Skip to content

feat(studio-bridge): persistent sessions and Linux/Wine support#669

Open
Quenty wants to merge 20 commits intomainfrom
feat/studio-bridge
Open

feat(studio-bridge): persistent sessions and Linux/Wine support#669
Quenty wants to merge 20 commits intomainfrom
feat/studio-bridge

Conversation

@Quenty
Copy link
Copy Markdown
Owner

@Quenty Quenty commented Feb 24, 2026

Adds persistent sessions to studio-bridge: the plugin stays open in Studio with a live WebSocket to the bridge instead of the previous one-shot launch-execute-exit flow, enabling multiple concurrent Studio instances, host failover, and split-server mode for devcontainers. Ships new commands (sessions, exec, run, query, screenshot, logs, plugin install/uninstall) plus Linux/Wine and Docker support for headless E2E. Also consolidates auth into nevermore-cli-helpers, adds the cli-output-helpers package for structured output, and wires path-filtered TypeScript lint/test jobs into the linting and tests workflows.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Feb 24, 2026

Deploy Results

ℹ️ No changed packages with deploy targets were discovered for this PR. · View logs

Test Results

ℹ️ No changed packages with test targets were discovered for this PR. · View logs

@Quenty Quenty force-pushed the feat/studio-bridge branch from 74b3063 to 1a5b8da Compare February 24, 2026 00:45
@Quenty Quenty force-pushed the feat/studio-bridge branch from a45bded to cf9cc1a Compare February 25, 2026 01:43
@Quenty Quenty force-pushed the feat/studio-bridge branch from 8b17a6a to 57d5f71 Compare April 28, 2026 00:06
@Quenty Quenty force-pushed the feat/studio-bridge branch from 57d5f71 to 2643673 Compare April 28, 2026 00:12
@Quenty Quenty changed the title feat(studio-bridge): add persistent sessions with new commands and support feat(studio-bridge): persistent sessions and Linux/Wine support Apr 28, 2026
Quenty added 5 commits April 29, 2026 01:21
Security
- Use execFileSync with args arrays in FileResultReporter and
  linux-credential-writer instead of execSync + JSON.stringify quoting,
  which is not equivalent to shell quoting.
- Pass ROBLOSECURITY through the docker process env (via -e ROBLOSECURITY
  with no value) instead of baking it into argv, where it would be visible
  to ps on the host.
- Tighten ~/.nevermore/credentials.json to mode 0600 (and parent dir 0700).
- Use err.message instead of template-stringifying raw err in
  validateApiKeyAsync.
- Restore the terminal cursor in watch-renderer on render-callback errors
  and SIGINT/SIGTERM, instead of leaving the cursor hidden.

Bugs
- process run no longer drops the global --verbose flag; it reads the
  effective value via OutputHelper.isVerbose().
- exec/run resolve script file paths to absolute via path.resolve and
  switch from sync fs.readFileSync to async fs.readFile.
- explorer query honors --depth even when --descendants is not passed.
- process close (still a stub) returns success: false so the adapter
  exits non-zero.
- StudioBridgePlugin sends state "Edit" on register (was "ready", which
  is not a valid StudioState) and advertises the dynamically-dispatched
  capabilities (execute, queryState, captureScreenshot, queryDataModel,
  queryLogs).
- Persistent plugin install is now atomic: rojo builds into the temp
  build dir and the result is copyFile + rename'd into the plugins
  folder so Studio's polling watcher never observes a partial .rbxm.
- Plugin uninstall uses unlink-with-ENOENT-handling instead of a
  pre-check, removing a TOCTOU window.
- CompositeResultReporter teardown is failure-isolated via
  Promise.allSettled so a throwing reporter no longer skips siblings.
- DiscoveryStateMachine drops the dead _currentPort rotation logic
  (the field was set on construction but never advanced, so the
  rotation was a no-op anyway given parallel scanPortsAsync).
- png.luau asserts that dynamic-Huffman code 16 (repeat-previous) is
  not the first symbol; previously a malformed input would silently
  no-op into table.insert(nil) and spin the until-loop forever.

Cleanup
- Remove stale welcome / protocolVersion: 2 references left over from
  the v1 protocol drop in plugin-test templates and hand-off.test.ts;
  drop the matching "(v2)" suffix in describe and rename
  web-socket-protocol-v2.test.ts to web-socket-protocol.test.ts (the
  prior basic file becomes web-socket-protocol-basic.test.ts).
- Rename linux-display-manager sleep to sleepAsync per repo convention.
- Extract resolveScriptContentAsync (shared by exec and run) and
  colorizeState (shared by process list and process info).
- Combine duplicate getPersistentPluginPath imports in uninstall.ts.

CI
- Guard the new TS jobs' .npmrc _authToken= writes on $NPM_TOKEN /
  $GITHUB_TOKEN being non-empty, so fork PRs don't append empty
  _authToken= lines.
- Gate the studio-linux-ci "Diagnose Wine networking" step behind
  failure() instead of always() so it doesn't run on every successful PR.
sendToPluginAsync attached a fresh ws.on('message', ...) listener per
call and matched the first non-heartbeat reply (or any error) when no
requestId was supplied. With ≥2 requests in flight this could (a) cross
responses between callers, (b) let an unrelated error reply satisfy a
real request, and (c) leak listeners until the EventEmitter MaxListeners
warning fired.

Replace the per-call pattern with a per-connection PluginConnectionState
that owns a PendingRequestMap. A single dispatcher is installed in
_registerPlugin; it routes plugin responses by requestId. Every outgoing
request must carry a requestId — all real callers (BridgeSession,
BridgeClient) already do; ensureRequestId is a defensive fallback.
Pending requests are cancelled on plugin disconnect, replacement, host
stopAsync, and shutdownAsync (graceful + force-close paths).

New unit tests in bridge-host.test.ts cover:
- concurrent in-flight requests resolve to the correct caller even when
  the plugin replies in reverse order
- an error reply with an unrelated requestId no longer satisfies a
  pending request (it now times out, as it should)
- pending requests reject when the plugin disconnects
- the dispatcher is installed once and does not accumulate listeners
  across many requests

All 669 TS tests + 158 plugin Lune tests pass.
Replace the unchecked `obj.action as ServerMessage` cast in decodeHostMessage
with a zod discriminated union covering all nine ServerMessage variants. A
buggy or hostile /client connection can no longer forward malformed actions
to the plugin. Adds nine targeted tests for missing/unknown action types,
wrong field types, and unknown error codes.
The settle path that gates cold-start commands had no direct test coverage —
all bridge-connection tests used keepAlive: true, which short-circuits the
settle in the constructor. Adds five targeted tests using the existing
options parameter to inject small timeouts: no-plugin firstSessionTimeout
exit, single-plugin settleMs quiet wait, settle timer reset on second
plugin, maxMs cap on continuous streams, and immediate return for client
role. Establishes a baseline before any settle-constant tuning.
cli.ts has always registered commands explicitly, and discoverAsync
(plus DiscoverOptions and the _tryImportAsync helper) was never wired
into production. Removes the convention-based loader, its barrel
re-export, and its seven unit tests. Net −245 / +4 lines.
Comment thread tools/studio-bridge/src/commands/viewport/screenshot/capture-screenshot.luau Outdated
Quenty added 3 commits May 1, 2026 23:37
…I output

The single-result output path had grown a 3-mode dispatch system (text |
table | json) plus a studio-bridge-specific base64 extension, all routed
through a thin format-output.ts wrapper that re-exported upstream types.
On inspection: every command's text and table formatters were literally
identical fns — text/table was a phantom distinction nobody used; base64
was a stdout pipeline strictly inferior to --output file.png.

Collapses the per-command `formatResult: { text, table, json }` dict to
two optional callbacks:

  cli.format(result) — human terminal output (also for --format=text)
  cli.json(result)   — overrides default formatJson, e.g. drop binary

Hoists reporter selection (Stdout/File/Watch dispatch) into a new
`buildResultReporter` factory in cli-output-helpers — the package that
owns the reporter classes — and inlines its construction in the adapter
handler, which is now mode-blind. Drops --format=base64 (binary file
output via -o is unaffected; binaryField + extractBinaryBuffer remain),
deletes the format-output.ts wrapper and its tests, and removes the
unused upstream output-mode module (OutputMode/resolveOutputMode no
longer have any callers). Updates tools/CLAUDE.md to match.

Net: −240 lines across the four formerly-separate refactor steps.
Apt deps (winehq-stable, nodejs, gh, ...) in the studio-linux Docker
image aren't pinned to specific versions. That's a deliberate trade-off
versus the brittleness of "this exact apt version is no longer in the
repo," but it leaves drift across rebuilds invisible. Dumps a sorted TSV
of all installed packages and versions to /image-manifest-apt.tsv at
build time, then has the studio-linux-ci workflow extract and upload it
as a 90-day artifact. When a future rebuild mysteriously breaks Studio,
diffing the manifest against the last known-good build shows what
actually changed.
Replaces the hand-rolled buffer-based base64 encoder (~60 lines, including
a 64-entry ASCII LUT and a localized hot loop) with a call to Roblox's
built-in EncodingService:Base64Encode. The native implementation is
faster than any Luau loop and removes a chunk of vendored encoding logic
we'd otherwise have to maintain. Resolves a code review note from Quenty.
@Quenty Quenty force-pushed the feat/studio-bridge branch from f390f50 to c55e513 Compare May 1, 2026 23:38
@Quenty Quenty deployed to integration May 1, 2026 23:39 — with GitHub Actions Active
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant