Skip to content

Orbit as a Galaxy analysis workbench (standalone + Galaxy Interactive Tool)#330

Open
dannon wants to merge 30 commits into
galaxyproject:mainfrom
dannon:feat/orbit-gxit-page-sync
Open

Orbit as a Galaxy analysis workbench (standalone + Galaxy Interactive Tool)#330
dannon wants to merge 30 commits into
galaxyproject:mainfrom
dannon:feat/orbit-gxit-page-sync

Conversation

@dannon

@dannon dannon commented Jun 19, 2026

Copy link
Copy Markdown
Member

What this does

Runs the existing LOOM_MODE=remote Orbit web shell as a per-user agentic analysis workbench, from one image, two ways: standalone (docker run) and as a Galaxy Interactive Tool -- Galaxy launches a per-user container, injects the server URL + a scoped API key, and proxies the user in.

The point is that durable artifacts live in Galaxy: tools/workflows run as Galaxy jobs (outputs are history datasets), and the analysis notebook persists as a per-history Galaxy Page that resumes on relaunch. Meant as a proving ground for ideas that can fold back into Galaxy's own assistant, not a replacement.

What's in here

Notebook -> Galaxy Page persistence (extensions/loom/galaxy-page-sync.ts)

  • env-gated (LOOM_GALAXY_PAGE_SYNC=auto), brain-side (pi's rpc dispatch is closed, so the shell just sets the env at spawn and drains the brain on shutdown)
  • per-history page identity (orbit-<historyId>), current history via most_recently_used
  • debounced push, self-trigger dedupe, fail-open; resume looks the page up by id (a slug 400s on GET /pages/{id})
  • wired into session-lifecycle.ts; enabled + SIGTERM-drained in web/server.ts

Galaxy Interactive Tool wrapper (gxit/) -- tool XML (scoped inject="api_key" + $__galaxy_url__, requires_domain entry point) + a deploy guide.

Container fixes (Dockerfile)

  • ship web/auth.ts + web/rpc-guard.ts in the runner -- the LOOM_MODE=remote image didn't actually boot without them (ERR_MODULE_NOT_FOUND)
  • force TMPDIR=/tmp (Galaxy injects the host's TMPDIR, which tsx can't use)
  • bundle uv + pre-warm galaxy-mcp so the Galaxy tool/workflow execution surface loads in-container (node:slim has no Python/uvx)

Remote-mode tool gate (web/extensions/web-mode-gate.ts) -- on a fresh container the galaxy_* MCP tools don't register as direct tools (needs a warm per-server cache, which the per-user key invalidates each launch), so the only path is the mcp proxy. Allow it, scoped to the curated servers (galaxy / brc-analytics); discovery passes, an unscoped call is denied with a message the agent self-corrects on.

Bring-your-own LLM key (web/llm-credentials.ts, web/server.ts, web shim + renderer) -- when the remote shell starts with no provider key in its env, the renderer prompts for a provider + key, the server holds it in memory for the session (never persisted, logged, or echoed -- only a hasApiKey boolean crosses to the renderer) and spawns the brain with it. An admin-injected env key still auto-spawns exactly as before. So the workbench can spread without an admin-baked key. (Standard providers; custom/openai-compatible BYO is a follow-up.)

Testing

New unit tests for the page-sync engine, the gate's proxy scoping, the history helper, and the LLM-credential helpers; full suite green, typechecks clean. Verified live as a real Galaxy Interactive Tool (Galaxy 26.1, interactive tools + gx-it-proxy + Docker): the notebook persisted to a per-history Page, and the agent -- running entirely in the container -- invoked Galaxy MCP through the scoped proxy and ran an upload_file_from_url job that landed a dataset. The BYO-key flow was verified at the WebSocket-protocol level: no env key -> the server reports hasApiKey:false and holds off spawning until the key is provided; an admin env key -> hasApiKey:true + auto-spawn; the key never reaches the logs or config.json.

Notes

  • Draft: builds on the shipped LOOM_MODE=remote shell; branch is based on an older main and will want a rebase. Follow-ups: wire BYO for custom/openai-compatible providers (baseUrl + key), and a browser-level eyeball of the BYO overlay.
  • Sizable but cohesive. The Dockerfile boot-fix is independently valuable and could be split out as a fast standalone fix if you'd prefer.

@dannon
dannon force-pushed the feat/orbit-gxit-page-sync branch from 8cb8a86 to cae0ebe Compare June 19, 2026 09:33
@dannon
dannon marked this pull request as ready for review June 19, 2026 09:59
dannon and others added 28 commits July 15, 2026 13:36
Import initGalaxyPageSync and flushNotebookToGalaxy into the lifecycle, and
call them at the appropriate hooks: init resumes the per-history page on
launch, and flush guarantees one final push (including any session-summary
block) on shutdown.
Live testing against test.galaxyproject.org showed resume-on-launch silently
failed: a fresh container only knows the derived slug (orbit-<historyId>), but
Galaxy's GET /pages/{id} 400s on a slug. List the history's pages, match the
slug, and resume by the real page id. Without this a fresh container also can't
update the existing page -- the next push would try to create a duplicate slug.
Tool XML (scoped api_key + $__galaxy_url__ injection, port 3000, ALLOW_INSECURE
trust model, explicit server start command) and a deploy README covering image
build, registration, the admin-key-via-job_conf path, a custom-provider option,
and the trust model.

Claude-Session: https://claude.ai/code/session_015LfwTStrqyxDTCv19PbTkT
The container never booted: web/server.ts imports ./auth.js and ./rpc-guard.js
(added by the auth-hardening commit), but the runner stage's COPY list was never
updated to include them -> ERR_MODULE_NOT_FOUND /app/web/auth.js at startup.
Caught by the Plan 3 container smoke test; verified the image now boots, serves
the prebuilt renderer, and the agent responds.
Live GxIT testing on a local Galaxy surfaced two things: Galaxy injects the
host TMPDIR into the container (a macOS /var/folders path that tsx can't use ->
EACCES), so the command forces TMPDIR=/tmp; and node:22-slim has no uvx, so
galaxy-mcp (tool/workflow execution) fails in-container while notebook->Page
persistence (direct API) still works. Documented both in the deploy guide.
The prior commit documented that node:22-slim has no Python or uvx, so the
agent's Galaxy tool/workflow surface (galaxy-mcp, launched via `uvx
galaxy-mcp>=1.8.0`) never started in the container and Galaxy tools silently
vanished. Fix it: copy uv from Astral's published image, point its cache,
python-install, and tool dirs at a node-owned /opt/uv, and pre-warm
`uv tool install galaxy-mcp>=1.8.0` at build so the package and a managed
Python are baked in. Verified the server now launches and answers an MCP
initialize fully offline (--network none), resolving entirely from the baked
cache -- so it works even in a network-locked job container. Notebook->Page
persistence was already fine (direct API); this restores the execution half.
Live GxIT testing surfaced a second blocker after uvx: the agent could
connect to galaxy-mcp but couldn't invoke any of its tools. On a fresh
container pi-mcp-adapter never registers the galaxy_*/brc_analytics_* direct
tools -- that needs a warm per-server metadata cache, and the per-user scoped
GALAXY_API_KEY changes the cache's config hash every launch, so it's always
cold. The only path to those servers is then the single `mcp` proxy tool,
which the gate denied by default ("mcp is not available in remote mode").

Allow the `mcp` proxy, but scope it: a tool call (or connect) must target one
of the curated servers (galaxy, brc-analytics), mirroring what ALLOWED_PREFIXES
already permits for direct tools; read-only discovery (search/describe/list/
status/ui-messages) passes through. A call with no server is unverifiable, so
it's denied with a message telling the agent to set it -- which the model then
does on its own. Verified live in the GxIT: the agent retrieved 42 histories
and ran an upload_file_from_url job that landed a dataset, both through the
scoped proxy.
bgruening's Docker refactor compiles web/server.ts to web/build/server.js and runs
that (CMD node web/build/server.js), which shifts __dirname one level deeper than the
dev layout. Every __dirname-relative path in server.ts was silently thrown off: the
brain binary (../bin/loom.js), the remote lockdown gate (extensions/web-mode-gate.ts),
and the built renderer (dist) all resolved into web/build/ instead of web/. The gate
one is security-relevant -- a missing --extension means the remote lockdown may not load.

Anchor those paths at a WEB_ROOT that points at the web dir whether we're running from
web/ (dev) or web/build/ (container). Also point the GxIT tool command at the compiled
node web/build/server.js instead of tsx web/server.ts -- tsx is a devDependency the image
prunes and server.ts isn't copied to the runner, so the old command couldn't launch.
The image never built end to end, and it turns out there were four separate
reasons rather than the one we thought. Working outward: the vite build couldn't
resolve marked from the renderer, because vite roots at app/src/renderer and
marked is declared in the root package.json -- node walks up to /app/node_modules
looking for it, which the builder stage never installed. That one hid everything
behind it, so the builder now installs root deps before building.

Next up was the tsc step, which is what we'd originally pinned this on: web pins
TypeScript 6, and TS6 refuses to compile named files while a tsconfig.json is
sitting there (TS5112) unless you pass --ignoreConfig. And even with that added
it only got us to the next problem -- tsc emits flat into web/build/, so the
emitted server.js kept its `../shared/brain-env.js` import, which resolves to
web/shared/ at runtime rather than the /app/shared the runner ships. Module load
would have died at boot for both the CMD and the GxIT command.

So rather than paper over that with another copy of shared/, esbuild now bundles
server.ts: every relative import gets inlined and bare packages stay external for
the runner's npm ci to supply. That kills the whole class of path fragility, and
WEB_ROOT still lands on /app/web since the output is still web/build/server.js.

Last one, in the runner: npm ci --omit=dev still runs the root prepare script,
which shells out to husky -- a devDep it was just told not to install -- and dies
with "husky: not found". Dropped the script before installing; git hooks don't
mean much in an image with no .git.

Verified by hand: docker build succeeds, and the image boots offline
(--network none) with no module-resolution errors.
The image pre-warmed galaxy-mcp>=1.8.0 into its uv cache while bin/loom.js wrote
>=1.9.0 into mcp.json. That's less broken than it looks -- uv resolves >=1.8.0 to
the newest release, which is 1.9.0 today, so the cache happened to satisfy the
runtime spec and offline launch worked. But it works by luck: the two specs are
independent, and any bump on either side (or a day when the newest release
predates the runtime floor) quietly sends uv back to PyPI at job-launch, which is
the one thing baking the cache is supposed to prevent. That failure would show up
as a slow or dead GxIT on a restricted-network deploy, nowhere near this line.

So there's now one constant, GALAXY_MCP_SPEC in shared/galaxy-mcp-spec.js, that
bin/loom.js writes into mcp.json, and the Dockerfile takes the same string as a
build arg. Nothing in CI builds the image, so a test asserts the two sites agree
and that neither has drifted back to a hardcoded literal.

Verified in the built image: uvx "galaxy-mcp>=1.9.0" --help resolves from the
baked cache with --network none.
The remote key gate only recognized ${PROVIDER}_API_KEY names, so a container set
up the way gxit/README.md describes -- baseUrl in config.json, key injected as
LOOM_ACTIVE_LLM_API_KEY -- looked keyless. The connection handler then declined
to spawn the brain and the user got the BYO-key overlay despite the operator
having configured everything correctly. The brain resolves custom keys from
LOOM_ACTIVE_LLM_API_KEY (resolveActiveLlmApiKey), so that var now counts as a key
being present, regardless of the provider label: the server defaults
activeProvider() to "anthropic" when LOOM_LLM_PROVIDER is unset, while the real
provider name only exists in a config.json remote mode never reads.

The BYO path had the mirror-image problem. providerKeyVar() returns null for
anything not built in -- including "openai-compatible", which the overlay itself
can send -- and the handler injected nothing, reported ok:true and hid the
overlay anyway, leaving an unauthenticated brain and no way back to the prompt.
Keys now route the way Orbit desktop routes them (agent.ts): built-in providers
to their own var, everything else to LOOM_ACTIVE_LLM_API_KEY, so a supplied key
always lands somewhere. The renderer also stops ignoring the response and keeps
the overlay up on a rejection.
Restart and reset-session fired SIGTERM, nulled the process and immediately
spawned a replacement. The brain's shutdown hook is what flushes the notebook to
its Galaxy Page, so the outgoing brain's final push could still be in flight when
the incoming one resumed and pushed: either the new brain resumes a page the old
one hasn't finished writing, or the two pushes land last-writer-wins. Both
handlers now await stopLoomGracefully first, the same drain the signal path
already used.

Pulled the grace-window resolution out into shutdown-grace.ts while I was in
there, since three call sites want it now. It keeps the NaN guard honest with
tests -- setTimeout(NaN) fires immediately, which would turn the SIGKILL backstop
into an instant kill and drop the very flush the window exists to protect.
In GxIT mode the tool XML sets LOOM_WEB_ALLOW_INSECURE=1 and no token, since a
Galaxy-issued entry-point URL can't carry one -- gx-it-proxy is the trust
boundary. But authorizeWsUpgrade skipped the origin check entirely when the
Origin header was absent, and with no token to check either, that left nothing at
all between the control socket and any process that can reach port 3000. On the
default Docker bridge that's every other container on the host, and the socket
drives a live agent holding the user's scoped Galaxy key.

Browsers always send Origin on a WebSocket upgrade, so requiring one costs the
real client nothing: this only narrows the accepted set, and every upgrade that
passes the existing origin match still passes. The bit that was doing no work was
the origin-less path, which is exactly the attacker. Left it off for loopback dev
and token deployments so wscat/curl workflows keep working.

Worth noting for review: this can't regress the gx-it-proxy path relative to
today. If the proxy rewrote Host such that Origin no longer matched, the existing
same-origin check would already be rejecting browsers and the GxIT would be
broken now -- so any browser connection that works today is unaffected. I haven't
been able to put this in front of a real Galaxy instance, so that reasoning is
the argument, not a live test.
If Galaxy was briefly unreachable when init() ran, the history lookup came back
empty, init returned early, and sync was off for the rest of the session -- no
retry, no reinit, while the UI happily kept showing Galaxy as connected. The
notebook then only ever existed under the container's /tmp, which dies with the
job. Silent and permanent, from a blip.

Arming (resolving the history, adopting any prior page) is now separate from
init, and the push path retries it, so the first change after Galaxy comes back
recovers sync. Attempts are capped so a genuinely dead Galaxy doesn't mean a
round trip on every change event forever. The init-time calls also get a timeout
-- init runs inside session_start, so a wedged Galaxy was hanging the session's
whole startup rather than just costing us sync.

Two things that had to move with it. The baseline body is now only seeded from
disk after an actual resume: seeding it unconditionally meant a recovered session
compared the notebook against itself and flushed nothing, losing exactly the work
the recovery was for. And since the baseline starts empty, pushes of an empty
body are skipped, so an untouched notebook doesn't publish a blank page.

Push failures are still stderr-only, and the shell has no way to show sync
status -- that one needs a UI surface and is worth its own issue rather than
smuggling it in here.
Two bits of the guide were describing a different branch than the one it's on.
It told operators the user gets prompted for their own key "once BYO-key (Plan 2)
lands" -- BYO-key lands in this same PR, so that's stale on arrival; said what
actually happens instead, including that the key stays in server memory.

The trust model also leaned on "the container is not otherwise reachable", which
is a property of the deployment and not of this container: on a default Docker
bridge every neighbour can hit port 3000, and that socket drives an agent holding
the user's scoped key. Wrote down the Origin requirement that now backstops it,
and pointed operators at LOOM_WEB_TOKEN for deployments where the port is really
shared.
Codex caught a real one in the previous key-gate commit. providerKeyVar built the
var name by uppercasing the provider, so google resolved to GOOGLE_API_KEY -- but
Loom's var is GEMINI_API_KEY. A GxIT launched with LOOM_LLM_PROVIDER=google and a
perfectly good admin-injected GEMINI_API_KEY was told it had no key and shown the
BYO overlay; hand it that same key through the overlay and it went into
LOOM_ACTIVE_LLM_API_KEY, which built-in google never reads, so the brain came up
unauthenticated. The map now lives next to PROVIDER_API_KEY_NAMES in
shared/brain-env.js, and that set is derived from it so the two can't drift.

The second one is mine from the same commit: I let a non-empty
LOOM_ACTIVE_LLM_API_KEY vouch for any provider, on the theory that the server
couldn't know the container's real provider. It can -- it just wasn't looking.
activeProvider() now falls back to config.json's llm.active (what the brain
actually reads) instead of defaulting to "anthropic" behind its back, and "is this
custom" comes from that provider's baseUrl, the same discriminator everything else
uses. So a stale LOOM_ACTIVE_LLM_API_KEY no longer suppresses the BYO prompt for a
built-in provider that can't use it, and a configured custom container still
starts without one.

That also gives the BYO handler a real answer to a question it was guessing at: if
a key has nowhere to go -- "openai-compatible" with no endpoint baked into the
image, say, which the overlay will happily offer -- it says so and the prompt stays
up, rather than reporting success and handing back an agent that can't
authenticate.
Fallout from the drain I added a few commits back, both found by Codex. Waiting
for the old brain to exit opened a window that didn't exist when restart was
synchronous, and two things walked into it.

Prompts kept going to the dying brain. loomProcess still points at it during the
drain and its stdin is still writable, so a prompt sent right after hitting
restart -- or after a /resume, which the renderer fires and forgets -- was
accepted, acknowledged, and then died with the process, leaving the UI waiting on
a turn nobody was running. Messages that arrive mid-drain now queue and go to the
replacement once it's up.

And nothing serialized the swaps themselves. Two of them could await the same
dying process and both spawn: reset's continuation would bring up a fresh-session
brain, then restart's would SIGKILL it through the non-graceful path and spawn a
normal one -- the reset the user asked for, silently undone. Swaps now run one at
a time on a chain.
dannon added 2 commits July 15, 2026 15:34
Five findings against my page-sync commit, all fair, all in the same code path.

The retry budget was the worst of them: a hard cap of five attempts sounds
conservative until you notice attempts are driven by change events, so five
debounced edits during one short outage exhaust it and sync is off for good --
the exact silent data loss the retry existed to prevent. It's a cooldown now, not
a budget: retry no more than once per interval, forever, and flush() ignores the
cooldown because shutdown is the last chance to save anything.

Committing historyId before page discovery meant one failed listing left the
engine armed but blind to a page that already existed -- the next push would take
the create path and duplicate it, and discovery never re-ran. Nothing commits now
until discovery actually succeeds, and a failed listing or resume leaves the
engine unarmed to try the whole adoption again. Refusing to push beats adopting a
page we couldn't read or growing a second one for the same history.

The resume call escaped the timeout I added to its neighbours, so a page GET that
accepts the connection and never answers could still hang session_start -- the
symptom the timeout was for. Wrapped.

Skipping every empty body was too blunt: a user who deliberately clears the
notebook could never sync that, and Galaxy would restore the stale content on the
next launch. Only refuse to CREATE a page from an empty body; clearing one we have
a baseline for is a real edit.

And an arm() already in flight when the engine is disposed used to land anyway.
resume() resolves the notebook path globally, so a stale engine could overwrite
the *next* session's notebook with its own page. It bails after each await now.
Codex pointed out the ARG I added undercut the test that was supposed to backstop
it: --build-arg GALAXY_MCP_SPEC=galaxy-mcp==1.8.0 bakes a non-matching spec into
the cache while the test happily passes against the ARG's default. A guarantee
with an override flag isn't one. It's a literal now, and the test asserts the
install line matches the shared constant and that it's the only galaxy-mcp install
in the image.
@dannon
dannon force-pushed the feat/orbit-gxit-page-sync branch from 70d7350 to 9e9e7e1 Compare July 15, 2026 20:29
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.

2 participants