From 08d34a21238436c70bc0c2dd30b61871c95e1e74 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Thu, 18 Jun 2026 08:49:26 -0400 Subject: [PATCH 01/30] Add galaxyGetMostRecentHistory for resolving the current history --- extensions/loom/galaxy-api.test.ts | 36 ++++++++++++++++++++++++++++++ extensions/loom/galaxy-api.ts | 16 +++++++++++++ vitest.config.ts | 2 +- 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 extensions/loom/galaxy-api.test.ts diff --git a/extensions/loom/galaxy-api.test.ts b/extensions/loom/galaxy-api.test.ts new file mode 100644 index 00000000..6de5617a --- /dev/null +++ b/extensions/loom/galaxy-api.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { galaxyGetMostRecentHistory } from "./galaxy-api.js"; + +describe("galaxyGetMostRecentHistory", () => { + const origFetch = global.fetch; + beforeEach(() => { + process.env.GALAXY_URL = "https://g.example"; + process.env.GALAXY_API_KEY = "k"; + }); + afterEach(() => { + global.fetch = origFetch; + delete process.env.GALAXY_URL; + delete process.env.GALAXY_API_KEY; + vi.restoreAllMocks(); + }); + + it("calls most_recently_used with the api key and returns the history", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: true, json: async () => ({ id: "h1", name: "My History" }) }); + global.fetch = fetchMock as unknown as typeof fetch; + const h = await galaxyGetMostRecentHistory(); + expect(h?.id).toBe("h1"); + expect(fetchMock).toHaveBeenCalledWith( + "https://g.example/api/histories/most_recently_used", + expect.objectContaining({ headers: { "x-api-key": "k" } }), + ); + }); + + it("returns null when Galaxy returns an empty body", async () => { + global.fetch = vi + .fn() + .mockResolvedValue({ ok: true, json: async () => null }) as unknown as typeof fetch; + expect(await galaxyGetMostRecentHistory()).toBeNull(); + }); +}); diff --git a/extensions/loom/galaxy-api.ts b/extensions/loom/galaxy-api.ts index 6ed637cb..0efbb2a1 100644 --- a/extensions/loom/galaxy-api.ts +++ b/extensions/loom/galaxy-api.ts @@ -139,3 +139,19 @@ export async function galaxyGetJobDetails( ): Promise { return galaxyGet(`/jobs/${encodeURIComponent(jobId)}`, signal); } + +export interface GalaxyHistorySummary { + id: string; + name?: string; +} + +/** + * The user's current (most-recently-used) history, resolved from just + * GALAXY_URL + GALAXY_API_KEY. Returns null when Galaxy reports none. + */ +export async function galaxyGetMostRecentHistory( + signal?: AbortSignal, +): Promise { + const res = await galaxyGet("/histories/most_recently_used", signal); + return res && typeof res.id === "string" && res.id.length > 0 ? res : null; +} diff --git a/vitest.config.ts b/vitest.config.ts index e51d40ca..cd826878 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,6 @@ export default defineConfig({ test: { globals: true, environment: "node", - include: ["tests/**/*.test.ts", "web/**/*.test.ts"], + include: ["tests/**/*.test.ts", "web/**/*.test.ts", "extensions/**/*.test.ts"], }, }); From fb3a57801eb45ae33ba6e8b7351fdef5877e2e41 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Thu, 18 Jun 2026 08:53:43 -0400 Subject: [PATCH 02/30] Add pure helpers for env-gated Galaxy page sync --- extensions/loom/galaxy-page-sync.test.ts | 43 ++++++++++++++++++++++++ extensions/loom/galaxy-page-sync.ts | 28 +++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 extensions/loom/galaxy-page-sync.test.ts create mode 100644 extensions/loom/galaxy-page-sync.ts diff --git a/extensions/loom/galaxy-page-sync.test.ts b/extensions/loom/galaxy-page-sync.test.ts new file mode 100644 index 00000000..b069955c --- /dev/null +++ b/extensions/loom/galaxy-page-sync.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from "vitest"; +import { + parsePageSyncMode, + pageSlugForHistory, + pageTitleForHistory, + strippedNotebookBody, + hasBodyChanged, +} from "./galaxy-page-sync.js"; + +describe("parsePageSyncMode", () => { + it("is auto only for the exact 'auto' value", () => { + expect(parsePageSyncMode({ LOOM_GALAXY_PAGE_SYNC: "auto" })).toBe("auto"); + expect(parsePageSyncMode({ LOOM_GALAXY_PAGE_SYNC: "1" })).toBe("off"); + expect(parsePageSyncMode({})).toBe("off"); + }); +}); + +describe("pageSlugForHistory / pageTitleForHistory", () => { + it("derives a stable per-history slug", () => { + expect(pageSlugForHistory("abc123")).toBe("orbit-abc123"); + }); + it("derives a readable title", () => { + expect(pageTitleForHistory("abc12345xyz")).toContain("abc12345"); + }); +}); + +describe("strippedNotebookBody / hasBodyChanged", () => { + it("removes the binding block and untrusted markers", () => { + const content = ["# Notebook", "body line", "```loom-galaxy-page", "page_id: p1", "```"].join( + "\n", + ); + const stripped = strippedNotebookBody(content); + expect(stripped).toContain("body line"); + expect(stripped).not.toContain("loom-galaxy-page"); + expect(stripped).not.toContain("page_id"); + }); + + it("treats identical stripped bodies as unchanged (breaks self-trigger loop)", () => { + expect(hasBodyChanged("same", "same")).toBe(false); + expect(hasBodyChanged("old", "new")).toBe(true); + expect(hasBodyChanged(null, "first")).toBe(true); + }); +}); diff --git a/extensions/loom/galaxy-page-sync.ts b/extensions/loom/galaxy-page-sync.ts new file mode 100644 index 00000000..7e621108 --- /dev/null +++ b/extensions/loom/galaxy-page-sync.ts @@ -0,0 +1,28 @@ +import { stripUntrustedMarkers } from "./galaxy-pages-sync.js"; +import { stripGalaxyPageBlocks } from "./galaxy-page-binding.js"; + +export function parsePageSyncMode(env: NodeJS.ProcessEnv): "auto" | "off" { + return env.LOOM_GALAXY_PAGE_SYNC === "auto" ? "auto" : "off"; +} + +/** Deterministic per-history page identity so a fresh container finds the prior page. */ +export function pageSlugForHistory(historyId: string): string { + return `orbit-${historyId}`; +} + +export function pageTitleForHistory(historyId: string): string { + return `Orbit notebook (${historyId.slice(0, 8)})`; +} + +/** + * The canonical comparison body: notebook content with the binding block and + * untrusted markers removed, matching exactly what pushNotebookToGalaxy persists + * locally. Used to dedupe pushes and break the watcher self-trigger loop. + */ +export function strippedNotebookBody(content: string): string { + return stripUntrustedMarkers(stripGalaxyPageBlocks(content)).trim(); +} + +export function hasBodyChanged(prev: string | null, next: string): boolean { + return prev !== next; +} From 1f5351f78ad49d3043498c437cc0b6724824cee9 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Thu, 18 Jun 2026 08:58:28 -0400 Subject: [PATCH 03/30] Add the env-gated Galaxy page-sync engine (resume/debounce/flush) --- extensions/loom/galaxy-page-sync.test.ts | 103 +++++++++++++++++- extensions/loom/galaxy-page-sync.ts | 131 +++++++++++++++++++++++ 2 files changed, 233 insertions(+), 1 deletion(-) diff --git a/extensions/loom/galaxy-page-sync.test.ts b/extensions/loom/galaxy-page-sync.test.ts index b069955c..fbc267cc 100644 --- a/extensions/loom/galaxy-page-sync.test.ts +++ b/extensions/loom/galaxy-page-sync.test.ts @@ -1,10 +1,12 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { parsePageSyncMode, pageSlugForHistory, pageTitleForHistory, strippedNotebookBody, hasBodyChanged, + createPageSyncEngine, + type PageSyncDeps, } from "./galaxy-page-sync.js"; describe("parsePageSyncMode", () => { @@ -41,3 +43,102 @@ describe("strippedNotebookBody / hasBodyChanged", () => { expect(hasBodyChanged(null, "first")).toBe(true); }); }); + +function makeDeps(over: Partial = {}): PageSyncDeps & { + pushes: Array<{ historyId: string; slug: string; title: string }>; + fire: () => void; +} { + let cb: (() => void) | null = null; + const pushes: Array<{ historyId: string; slug: string; title: string }> = []; + let body = "first"; + const deps: PageSyncDeps & { pushes: typeof pushes; fire: () => void } = { + mode: "auto", + hasGalaxy: () => true, + getHistoryId: async () => "h1", + readBody: async () => body, + resume: vi.fn(async () => {}), + push: vi.fn(async (o) => { + pushes.push(o); + }), + subscribe: (fn) => { + cb = fn; + return () => { + cb = null; + }; + }, + debounceMs: 100, + pushes, + fire: () => cb?.(), + ...over, + }; + // let tests mutate the body the watcher will read + (deps as unknown as { setBody: (b: string) => void }).setBody = (b: string) => { + body = b; + }; + return deps; +} + +describe("createPageSyncEngine", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("resumes on init using the per-history slug", async () => { + const deps = makeDeps(); + const engine = createPageSyncEngine(deps); + await engine.init(); + expect(deps.resume).toHaveBeenCalledWith("orbit-h1"); + }); + + it("is a no-op when mode is off", async () => { + const deps = makeDeps({ mode: "off" }); + const engine = createPageSyncEngine(deps); + await engine.init(); + expect(deps.resume).not.toHaveBeenCalled(); + }); + + it("debounce-pushes a changed body once", async () => { + const deps = makeDeps(); + const engine = createPageSyncEngine(deps); + await engine.init(); + (deps as unknown as { setBody: (b: string) => void }).setBody("changed"); + deps.fire(); + deps.fire(); + await vi.advanceTimersByTimeAsync(150); + expect(deps.pushes).toHaveLength(1); + expect(deps.pushes[0]).toEqual({ + historyId: "h1", + slug: "orbit-h1", + title: expect.any(String), + }); + }); + + it("skips a push when the stripped body is unchanged (self-trigger guard)", async () => { + const deps = makeDeps(); + const engine = createPageSyncEngine(deps); + await engine.init(); // body == "first" recorded as last-pushed baseline + deps.fire(); // body still "first" + await vi.advanceTimersByTimeAsync(150); + expect(deps.pushes).toHaveLength(0); + }); + + it("flush() pushes the latest body immediately", async () => { + const deps = makeDeps(); + const engine = createPageSyncEngine(deps); + await engine.init(); + (deps as unknown as { setBody: (b: string) => void }).setBody("final"); + await engine.flush(); + expect(deps.pushes).toHaveLength(1); + }); + + it("does not throw when getHistoryId rejects (fail-open hardening)", async () => { + const deps = makeDeps({ + getHistoryId: async () => { + throw new Error("404"); + }, + }); + const engine = createPageSyncEngine(deps); + await expect(engine.init()).resolves.toBeUndefined(); + expect(deps.resume).not.toHaveBeenCalled(); + expect(deps.pushes).toHaveLength(0); + }); +}); diff --git a/extensions/loom/galaxy-page-sync.ts b/extensions/loom/galaxy-page-sync.ts index 7e621108..80e12939 100644 --- a/extensions/loom/galaxy-page-sync.ts +++ b/extensions/loom/galaxy-page-sync.ts @@ -26,3 +26,134 @@ export function strippedNotebookBody(content: string): string { export function hasBodyChanged(prev: string | null, next: string): boolean { return prev !== next; } + +import { getNotebookPath, onNotebookChange } from "./state.js"; +import { readNotebook } from "./notebook-writer.js"; +import { getGalaxyConfig, galaxyGetMostRecentHistory } from "./galaxy-api.js"; +import { pushNotebookToGalaxy, resumeGalaxyPage } from "./galaxy-pages-sync.js"; + +export interface PageSyncDeps { + mode: "auto" | "off"; + hasGalaxy: () => boolean; + getHistoryId: () => Promise; + readBody: () => Promise; + resume: (slug: string) => Promise; + push: (o: { historyId: string; slug: string; title: string }) => Promise; + subscribe: (cb: () => void) => () => void; + debounceMs: number; +} + +export function createPageSyncEngine(deps: PageSyncDeps) { + let historyId: string | null = null; + let slug = ""; + let lastBody: string | null = null; + let timer: ReturnType | null = null; + let unsubscribe: (() => void) | null = null; + let active = false; + + async function doPush(): Promise { + if (!historyId) return; + const body = await deps.readBody(); + if (!hasBodyChanged(lastBody, body)) return; // dedupe + self-trigger guard + await deps.push({ historyId, slug, title: pageTitleForHistory(historyId) }); + lastBody = body; + } + + async function init(): Promise { + if (deps.mode !== "auto" || !deps.hasGalaxy()) return; + let resolved: string | null = null; + try { + resolved = await deps.getHistoryId(); + } catch { + /* treat a rejected getHistoryId as "no history" → skip sync */ + } + historyId = resolved; + if (!historyId) return; + slug = pageSlugForHistory(historyId); + try { + await deps.resume(slug); // refreshes notebook from the prior page if it exists + } catch { + /* no prior page (404) — fresh notebook; created on first push */ + } + lastBody = await deps.readBody(); + active = true; + unsubscribe = deps.subscribe(() => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + void doPush().catch((err) => console.error("[page-sync] push failed:", err)); + }, deps.debounceMs); + }); + } + + async function flush(): Promise { + if (!active) return; + if (timer) { + clearTimeout(timer); + timer = null; + } + try { + await doPush(); + } catch (err) { + console.error("[page-sync] flush push failed:", err); + } + } + + function dispose(): void { + if (timer) clearTimeout(timer); + timer = null; + if (unsubscribe) unsubscribe(); + unsubscribe = null; + active = false; + historyId = null; + lastBody = null; + } + + return { init, flush, dispose }; +} + +// ── Module-level wiring with the real dependencies ──────────────────────────── + +let engine: ReturnType | null = null; + +const DEBOUNCE_MS = parseInt(process.env.LOOM_GALAXY_PAGE_SYNC_DEBOUNCE_MS ?? "1500", 10); + +function realDeps(): PageSyncDeps { + return { + mode: parsePageSyncMode(process.env), + hasGalaxy: () => getGalaxyConfig() != null, + getHistoryId: async () => (await galaxyGetMostRecentHistory())?.id ?? null, + readBody: async () => { + const p = getNotebookPath(); + if (!p) return ""; + return strippedNotebookBody(await readNotebook(p)); + }, + resume: async (slug) => { + await resumeGalaxyPage(slug); + }, + push: async ({ historyId, slug, title }) => { + await pushNotebookToGalaxy({ historyId, slug, title }); + }, + subscribe: (cb) => onNotebookChange(() => cb()), + debounceMs: Number.isFinite(DEBOUNCE_MS) ? DEBOUNCE_MS : 1500, + }; +} + +export async function initGalaxyPageSync(): Promise { + resetGalaxyPageSync(); + engine = createPageSyncEngine(realDeps()); + try { + await engine.init(); + } catch (err) { + console.error("[page-sync] init failed:", err); + } +} + +export async function flushNotebookToGalaxy(): Promise { + if (engine) await engine.flush(); +} + +export function resetGalaxyPageSync(): void { + if (engine) engine.dispose(); + engine = null; +} From 5929ffdf9f33613af5d2f729b0949c7fcfcee0fd Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Thu, 18 Jun 2026 09:01:46 -0400 Subject: [PATCH 04/30] Wire Galaxy page sync into session start/shutdown 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. --- extensions/loom/session-lifecycle.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/extensions/loom/session-lifecycle.ts b/extensions/loom/session-lifecycle.ts index fe09ddf9..9a353f15 100644 --- a/extensions/loom/session-lifecycle.ts +++ b/extensions/loom/session-lifecycle.ts @@ -6,6 +6,7 @@ import { stopWatchingNotebook, } from "./state.js"; import { startGalaxyPoller, stopGalaxyPoller } from "./galaxy-poller.js"; +import { initGalaxyPageSync, flushNotebookToGalaxy } from "./galaxy-page-sync.js"; import { upsertSessionSummaryBlock, readNotebook, @@ -33,6 +34,10 @@ export function registerSessionLifecycle(pi: ExtensionAPI): void { initSessionArtifacts(process.cwd()); + // Resume the per-history Galaxy page and arm debounce auto-push (no-op + // unless LOOM_GALAXY_PAGE_SYNC=auto and Galaxy creds are present). + await initGalaxyPageSync(); + // Background poller for in-flight Galaxy invocations (#67 part 2). // Idempotent — start() stops any prior timer first. Pass the shell // notifier so a backgrounded invocation toasts the user on completion @@ -89,6 +94,8 @@ export function registerSessionLifecycle(pi: ExtensionAPI): void { stopWatchingNotebook(); await writeSessionSummary(); snapshotNotebook(pi); + // Best-effort final push (debounce already pushed recent changes). + await flushNotebookToGalaxy(); }); } From 97619f918fc3eaa95353fb9cc5a5fabbd9c3b9a8 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Thu, 18 Jun 2026 09:04:31 -0400 Subject: [PATCH 05/30] Enable page sync in remote mode and drain the brain on shutdown --- web/server.ts | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/web/server.ts b/web/server.ts index bf9ea8f9..cdac596c 100644 --- a/web/server.ts +++ b/web/server.ts @@ -142,6 +142,9 @@ function startLoom(): void { // (whose headless approval prompts would otherwise hang). See // extensions/loom/index.ts. env.LOOM_LOCAL_EXEC = "off"; + // Deterministic notebook -> Galaxy Page persistence: resume on launch, + // debounce-push on change, flush on shutdown. Brain-side, env-gated. + env.LOOM_GALAXY_PAGE_SYNC = "auto"; } else { // The local dev server DOES have a local execution surface, so pin the // guard on authoritatively (same as agent.ts and bin/loom.js) -- the @@ -210,6 +213,36 @@ function stopLoom(): void { } } +/** + * SIGTERM the brain and wait for it to exit so its session_shutdown hook can + * flush the notebook to Galaxy. SIGKILL backstop after timeoutMs (kept under + * the container orchestrator's grace window). + */ +function stopLoomGracefully(timeoutMs: number): Promise { + return new Promise((resolve) => { + const proc = loomProcess; + if (!proc) return resolve(); + const timer = setTimeout(() => { + try { + proc.kill("SIGKILL"); + } catch { + /* already gone */ + } + resolve(); + }, timeoutMs); + proc.once("exit", () => { + clearTimeout(timer); + resolve(); + }); + try { + proc.kill("SIGTERM"); + } catch { + clearTimeout(timer); + resolve(); + } + }); +} + function sendToLoom(obj: Record): void { if (!loomProcess?.stdin?.writable) return; loomProcess.stdin.write(JSON.stringify(obj) + "\n"); @@ -434,3 +467,20 @@ httpServer.listen(PORT, HOST, () => { function isLoopbackBind(): boolean { return HOST === "127.0.0.1" || HOST === "::1" || HOST === "localhost"; } + +let shuttingDown = false; +async function gracefulShutdown(signal: string): Promise { + if (shuttingDown) return; + shuttingDown = true; + log("received", signal, "-- draining"); + try { + wss.close(); + httpServer.close(); + } catch { + /* */ + } + await stopLoomGracefully(parseInt(process.env.LOOM_SHUTDOWN_GRACE_MS ?? "8000", 10)); + process.exit(0); +} +process.on("SIGTERM", () => void gracefulShutdown("SIGTERM")); +process.on("SIGINT", () => void gracefulShutdown("SIGINT")); From 23f640f60a0bf8a5320d64efa35a8bbc438e3e02 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Thu, 18 Jun 2026 10:06:21 -0400 Subject: [PATCH 06/30] Resume the per-history page by id, not slug Live testing against test.galaxyproject.org showed resume-on-launch silently failed: a fresh container only knows the derived slug (orbit-), 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. --- extensions/loom/galaxy-page-sync.test.ts | 13 +++++++++-- extensions/loom/galaxy-page-sync.ts | 29 ++++++++++++++++++++---- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/extensions/loom/galaxy-page-sync.test.ts b/extensions/loom/galaxy-page-sync.test.ts index fbc267cc..2176de8a 100644 --- a/extensions/loom/galaxy-page-sync.test.ts +++ b/extensions/loom/galaxy-page-sync.test.ts @@ -56,6 +56,7 @@ function makeDeps(over: Partial = {}): PageSyncDeps & { hasGalaxy: () => true, getHistoryId: async () => "h1", readBody: async () => body, + findPageId: vi.fn(async () => "page-h1"), resume: vi.fn(async () => {}), push: vi.fn(async (o) => { pushes.push(o); @@ -82,11 +83,19 @@ describe("createPageSyncEngine", () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); - it("resumes on init using the per-history slug", async () => { + it("resolves the page id by slug and resumes by id", async () => { const deps = makeDeps(); const engine = createPageSyncEngine(deps); await engine.init(); - expect(deps.resume).toHaveBeenCalledWith("orbit-h1"); + expect(deps.findPageId).toHaveBeenCalledWith("h1", "orbit-h1"); + expect(deps.resume).toHaveBeenCalledWith("page-h1"); + }); + + it("does not resume when no page exists for the history (fresh)", async () => { + const deps = makeDeps({ findPageId: vi.fn(async () => null) }); + const engine = createPageSyncEngine(deps); + await engine.init(); + expect(deps.resume).not.toHaveBeenCalled(); }); it("is a no-op when mode is off", async () => { diff --git a/extensions/loom/galaxy-page-sync.ts b/extensions/loom/galaxy-page-sync.ts index 80e12939..99da2758 100644 --- a/extensions/loom/galaxy-page-sync.ts +++ b/extensions/loom/galaxy-page-sync.ts @@ -31,13 +31,15 @@ import { getNotebookPath, onNotebookChange } from "./state.js"; import { readNotebook } from "./notebook-writer.js"; import { getGalaxyConfig, galaxyGetMostRecentHistory } from "./galaxy-api.js"; import { pushNotebookToGalaxy, resumeGalaxyPage } from "./galaxy-pages-sync.js"; +import { listHistoryPages } from "./galaxy-pages-api.js"; export interface PageSyncDeps { mode: "auto" | "off"; hasGalaxy: () => boolean; getHistoryId: () => Promise; readBody: () => Promise; - resume: (slug: string) => Promise; + findPageId: (historyId: string, slug: string) => Promise; + resume: (pageId: string) => Promise; push: (o: { historyId: string; slug: string; title: string }) => Promise; subscribe: (cb: () => void) => () => void; debounceMs: number; @@ -70,10 +72,22 @@ export function createPageSyncEngine(deps: PageSyncDeps) { historyId = resolved; if (!historyId) return; slug = pageSlugForHistory(historyId); + // Galaxy's GET /pages/{id} takes a page id, not a slug, so resolve the + // per-history page id by listing the history's pages and matching the + // derived slug, then resume by id. A fresh container has no local binding, + // so listing is the only way to rediscover the page. + let existingPageId: string | null = null; try { - await deps.resume(slug); // refreshes notebook from the prior page if it exists + existingPageId = await deps.findPageId(historyId, slug); } catch { - /* no prior page (404) — fresh notebook; created on first push */ + /* listing failed — treat as no prior page; created on first push */ + } + if (existingPageId) { + try { + await deps.resume(existingPageId); // refresh notebook from the prior page + } catch (err) { + console.error("[page-sync] resume failed:", err); + } } lastBody = await deps.readBody(); active = true; @@ -128,8 +142,13 @@ function realDeps(): PageSyncDeps { if (!p) return ""; return strippedNotebookBody(await readNotebook(p)); }, - resume: async (slug) => { - await resumeGalaxyPage(slug); + findPageId: async (historyId, slug) => { + const pages = await listHistoryPages(historyId); + const match = pages.find((p) => p.slug === slug); + return match ? match.id : null; + }, + resume: async (pageId) => { + await resumeGalaxyPage(pageId); }, push: async ({ historyId, slug, title }) => { await pushNotebookToGalaxy({ historyId, slug, title }); From 7995a8efbeec9dbcccac0f3c00fca4f7a5204483 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Thu, 18 Jun 2026 13:21:16 -0400 Subject: [PATCH 07/30] Add the Galaxy interactive tool wrapper + deploy guide for Orbit 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 --- gxit/README.md | 86 ++++++++++++++++++++++++++++++++++ gxit/interactivetool_orbit.xml | 43 +++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 gxit/README.md create mode 100644 gxit/interactivetool_orbit.xml diff --git a/gxit/README.md b/gxit/README.md new file mode 100644 index 00000000..88c9fb0d --- /dev/null +++ b/gxit/README.md @@ -0,0 +1,86 @@ +# Orbit as a Galaxy Interactive Tool + +Runs the remote Orbit web shell (`LOOM_MODE=remote`) as a per-user Galaxy +Interactive Tool. The same container image runs standalone (`docker run`) or as a +GxIT; this directory is the Galaxy-side wrapper that launches it per user, injects +a scoped API key, and proxies the user in. + +Requires the instance to have interactive tools enabled (gx-it-proxy + a +Docker/Podman-capable job destination). + +## 1. Build the image + +From the repo root: + +```bash +docker build -t quay.io/galaxyproject/orbit:0.1.0 . # or any tag +# docker push quay.io/galaxyproject/orbit:0.1.0 # for a shared instance +``` + +Set that exact tag in `interactivetool_orbit.xml`'s `` (replace `TAG`). +For a **local dev Galaxy**, you can skip the registry: build a local tag (e.g. +`docker build -t orbit:dev .`) and point `` at `orbit:dev` — the local +Docker daemon already has it. + +## 2. Register the tool + +- Add `interactivetool_orbit.xml` to the instance's tool config (a + `` entry in + `tool_conf.xml`, or via `config/tool_conf.xml.sample` → `tool_conf.xml`). +- Enable interactive tools in `config/galaxy.yml` (`galaxy:` section): + `interactivetools_enable: true` and an `interactivetools_map`. Configure and run + `gx-it-proxy` (`gravity:` section has a `gx_it_proxy:` block; `galaxy.yml.interactivetools` + and `job_conf.yml.interactivetools` in `config/` are ready-made templates). +- Route the tool to a Docker-enabled destination in `job_conf` (interactive tools + must run with `docker_enabled` / `podman` — see `job_conf.yml.interactivetools[.podman]`). + +## 3. Supply the admin LLM key (secret) + +Set the provider key on the IT's **job destination env** in `job_conf` — NOT in the +tool XML — so it never lands in a committed/shared file. For a YAML `job_conf`: + +```yaml +orbit_destination: + runner: local_docker # or your docker/podman runner + docker_enabled: true + env: + - name: ANTHROPIC_API_KEY + value: "sk-ant-..." # or another provider's key + # - name: LOOM_LLM_PROVIDER + # value: anthropic +``` + +If no admin key is provided, the user is prompted for their own once BYO-key +(Plan 2) lands. (For a free/local model, see "Custom provider" below.) + +### Custom OpenAI-compatible provider (e.g. a local/free endpoint) + +The brain reads a custom provider's `baseUrl` from `~/.loom/config.json` inside the +container and resolves the key from `LOOM_ACTIVE_LLM_API_KEY`. To use one, bake a +`~/.loom/config.json` into a derived image (or mount one) with: + +```json +{ + "llm": { + "active": "myprov", + "providers": { "myprov": { "baseUrl": "https://host/v1", "model": "model-id" } } + } +} +``` + +and inject `LOOM_ACTIVE_LLM_API_KEY` via the destination env. + +## 4. Trust model + +`LOOM_WEB_ALLOW_INSECURE=1` (set in the tool XML) is safe here: gx-it-proxy +authenticates the Galaxy user and routes only them to their container; the +container is not otherwise reachable, and a Galaxy-controlled entry-point URL +can't carry `LOOM_WEB_TOKEN`. The agent receives a **scoped** per-user key +(`inject="api_key"`), never the user's personal key. + +## 5. What the user gets + +Tools/workflows run in their Galaxy (outputs are history datasets); the analysis +notebook persists as a per-history Galaxy **Page** (slug `orbit-`) and +resumes on relaunch. Served at `/`, WebSocket at `/ws`, entry-point port 3000, +subdomain routing via gx-it-proxy. diff --git a/gxit/interactivetool_orbit.xml b/gxit/interactivetool_orbit.xml new file mode 100644 index 00000000..2ee21209 --- /dev/null +++ b/gxit/interactivetool_orbit.xml @@ -0,0 +1,43 @@ + + conversational AI analysis workbench (Loom) + + + quay.io/galaxyproject/orbit:TAG + + + + 3000 + + + + + $__galaxy_url__ + + + 1 + + + + + + + + From 71532edbcde97ebd942876c620538b620430161b Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Thu, 18 Jun 2026 13:39:41 -0400 Subject: [PATCH 08/30] Ship web/auth.ts and web/rpc-guard.ts in the runner image 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. --- Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile b/Dockerfile index d34fb199..bb33c513 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,6 +31,8 @@ COPY --from=builder /app/bin ./bin COPY --from=builder /app/extensions ./extensions COPY --from=builder /app/shared ./shared COPY --from=builder /app/web/server.ts ./web/server.ts +COPY --from=builder /app/web/auth.ts ./web/auth.ts +COPY --from=builder /app/web/rpc-guard.ts ./web/rpc-guard.ts COPY --from=builder /app/web/orbit-shim.ts ./web/orbit-shim.ts COPY --from=builder /app/web/extensions ./web/extensions COPY --from=builder /app/web/dist ./web/dist From 22d0886c59fced94e0ee0e37ce078db774115277 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Thu, 18 Jun 2026 14:14:47 -0400 Subject: [PATCH 09/30] GxIT: force TMPDIR=/tmp and document the uvx requirement 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. --- gxit/README.md | 13 +++++++++++++ gxit/interactivetool_orbit.xml | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/gxit/README.md b/gxit/README.md index 88c9fb0d..e16e99a1 100644 --- a/gxit/README.md +++ b/gxit/README.md @@ -78,6 +78,19 @@ container is not otherwise reachable, and a Galaxy-controlled entry-point URL can't carry `LOOM_WEB_TOKEN`. The agent receives a **scoped** per-user key (`inject="api_key"`), never the user's personal key. +## Known requirements / gotchas (found in live testing) + +- **`TMPDIR=/tmp` in the command.** Galaxy injects the _host's_ `TMPDIR` into the + container; if it points somewhere not writable in the container (e.g. a macOS + `/var/folders/...` path), `tsx`'s IPC setup fails with `EACCES`. The tool + command forces `TMPDIR=/tmp`. +- **The image needs `uvx` (Python) for galaxy-mcp.** `node:22-slim` has no + `uvx`, so the agent's Galaxy _tool/workflow execution_ surface (galaxy-mcp) + fails to start in the container (`spawn uvx ENOENT`). The notebook -> Galaxy + Page persistence still works (it uses the brain's direct Galaxy API, not MCP). + To get full in-Galaxy tool/workflow execution, add `uv`/`uvx` (and a Python) + to the runtime image. Tracked as a follow-up. + ## 5. What the user gets Tools/workflows run in their Galaxy (outputs are history datasets); the analysis diff --git a/gxit/interactivetool_orbit.xml b/gxit/interactivetool_orbit.xml index 2ee21209..96a7450b 100644 --- a/gxit/interactivetool_orbit.xml +++ b/gxit/interactivetool_orbit.xml @@ -30,7 +30,7 @@ explicitly (mirrors the image CMD) rather than depending on the job runner honoring the container's default CMD. --> From d05749d6422a3f0a6157d63f5cf7701a2a8a826f Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Thu, 18 Jun 2026 15:19:35 -0400 Subject: [PATCH 10/30] Bundle and pre-warm galaxy-mcp (uvx) in the runtime image 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. --- Dockerfile | 25 +++++++++++++++++++++++++ gxit/README.md | 15 +++++++++------ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index bb33c513..c95a970f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,10 +39,35 @@ COPY --from=builder /app/web/dist ./web/dist COPY --from=builder /app/web/node_modules ./web/node_modules COPY --from=builder /app/web/package.json ./web/package.json +# Galaxy's tool/workflow execution surface runs through `uvx galaxy-mcp` (a +# Python MCP server). node:slim ships no Python or uv, so bring uv -- which +# manages its own CPython -- from Astral's published image and pre-warm the +# galaxy-mcp tool plus a compatible interpreter into a shared, node-owned cache. +# Baking them in means the GxIT's Galaxy tools work even when PyPI is slow or +# unreachable at job-launch, and turns the build itself into a check that the +# MCP server resolves in this image. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=ghcr.io/astral-sh/uv:0.11.21 /uv /uvx /usr/local/bin/ + +ENV UV_CACHE_DIR=/opt/uv/cache \ + UV_PYTHON_INSTALL_DIR=/opt/uv/python \ + UV_TOOL_DIR=/opt/uv/tools \ + UV_TOOL_BIN_DIR=/opt/uv/bin +ENV PATH="/opt/uv/bin:${PATH}" + +RUN mkdir -p /opt/uv && chown -R node:node /opt/uv + EXPOSE 3000 # Drop root: the agent process holds live Galaxy/LLM credentials, so don't run # it as uid 0. The node:slim image ships an unprivileged `node` user. USER node +# Pre-warm galaxy-mcp (and the managed Python it needs) into the node-owned uv +# cache so the runtime `uvx galaxy-mcp>=1.8.0` launch resolves from cache. +RUN uv tool install "galaxy-mcp>=1.8.0" + CMD ["node", "web/node_modules/.bin/tsx", "web/server.ts"] diff --git a/gxit/README.md b/gxit/README.md index e16e99a1..6ef6a615 100644 --- a/gxit/README.md +++ b/gxit/README.md @@ -84,12 +84,15 @@ can't carry `LOOM_WEB_TOKEN`. The agent receives a **scoped** per-user key container; if it points somewhere not writable in the container (e.g. a macOS `/var/folders/...` path), `tsx`'s IPC setup fails with `EACCES`. The tool command forces `TMPDIR=/tmp`. -- **The image needs `uvx` (Python) for galaxy-mcp.** `node:22-slim` has no - `uvx`, so the agent's Galaxy _tool/workflow execution_ surface (galaxy-mcp) - fails to start in the container (`spawn uvx ENOENT`). The notebook -> Galaxy - Page persistence still works (it uses the brain's direct Galaxy API, not MCP). - To get full in-Galaxy tool/workflow execution, add `uv`/`uvx` (and a Python) - to the runtime image. Tracked as a follow-up. +- **galaxy-mcp (`uvx`) is bundled and pre-warmed.** The agent's Galaxy + _tool/workflow execution_ surface runs through `uvx galaxy-mcp` (a Python MCP + server), and `node:22-slim` ships no Python or `uvx`. The runtime image now + copies `uv` from Astral's published image and pre-warms `galaxy-mcp` plus a + managed Python into a node-owned uv cache at build time, so the server + resolves from that baked cache at launch -- verified to start fully offline + (`--network none`). Earlier images failed here with `spawn uvx ENOENT`, and + Galaxy tools silently vanished (notebook -> Page persistence kept working, + since it uses the brain's direct Galaxy API rather than MCP). ## 5. What the user gets From 50e18722574da6837ad39f650d96f88013e445c2 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Thu, 18 Jun 2026 19:59:45 -0400 Subject: [PATCH 11/30] Let the remote gate reach galaxy-mcp through the mcp proxy 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. --- web/extensions/web-mode-gate.test.ts | 95 +++++++++++++++++++++++++++- web/extensions/web-mode-gate.ts | 57 ++++++++++++++++- 2 files changed, 148 insertions(+), 4 deletions(-) diff --git a/web/extensions/web-mode-gate.test.ts b/web/extensions/web-mode-gate.test.ts index 931815f0..c000efcc 100644 --- a/web/extensions/web-mode-gate.test.ts +++ b/web/extensions/web-mode-gate.test.ts @@ -2,7 +2,12 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdtempSync, writeFileSync, symlinkSync, rmSync, realpathSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import gate, { isPathAllowed, shouldBlockTool, dropSymlinkedEntries } from "./web-mode-gate.js"; +import gate, { + isPathAllowed, + shouldBlockTool, + dropSymlinkedEntries, + gateMcpProxy, +} from "./web-mode-gate.js"; describe("isPathAllowed", () => { const allowlist = ["/tmp/loom-session/notebook.md"]; @@ -210,6 +215,94 @@ describe("shouldBlockTool", () => { const result = shouldBlockTool("write", { content: "x" }, allowlist, cwd); expect(result?.block).toBe(true); }); + + // #338 regression: a destructive Galaxy op wrapped in the mcp() proxy must be + // blocked even though its server is curated -- the destructive check runs + // BEFORE the mcp-proxy allow, so a purge/delete can't be smuggled through the + // gateway that reaches galaxy. Guards the gate ordering after the main merge. + it("blocks a history purge smuggled through the mcp proxy to a curated server", () => { + const result = shouldBlockTool( + "mcp", + { + tool: "galaxy_update_history", + args: '{"purged":true,"history_id":"abc"}', + server: "galaxy", + }, + allowlist, + cwd, + ); + expect(result).toMatchObject({ block: true, reason: expect.stringContaining("destructive") }); + }); + + it("blocks a whole-history delete smuggled through the mcp proxy", () => { + const result = shouldBlockTool( + "mcp", + { + tool: "galaxy_update_history", + args: '{"deleted":true,"history_id":"abc"}', + server: "galaxy", + }, + allowlist, + cwd, + ); + expect(result).toMatchObject({ block: true, reason: expect.stringContaining("destructive") }); + }); + + it("still allows a non-destructive mcp proxy call to a curated server", () => { + expect( + shouldBlockTool("mcp", { tool: "run_tool", args: "{}", server: "galaxy" }, allowlist, cwd), + ).toBeUndefined(); + }); +}); + +// The `mcp` proxy gateway is the only path to the curated MCP servers on a +// cold-cache container (every fresh remote launch). It must reach galaxy / +// brc-analytics but stay default-deny for any other server. +describe("gateMcpProxy / mcp proxy gating", () => { + it("allows a tool call scoped to a curated server", () => { + expect(gateMcpProxy({ tool: "run_tool", args: "{}", server: "galaxy" })).toBeUndefined(); + expect(gateMcpProxy({ tool: "get_assemblies", server: "brc-analytics" })).toBeUndefined(); + }); + + it("blocks a tool call to a non-curated server", () => { + const r = gateMcpProxy({ tool: "exfiltrate", server: "evil" }); + expect(r).toMatchObject({ block: true, reason: expect.stringContaining("evil") }); + }); + + it("blocks a tool call with no server (target unverifiable -> default deny)", () => { + const r = gateMcpProxy({ tool: "run_tool", args: "{}" }); + expect(r).toMatchObject({ block: true, reason: expect.stringContaining("server") }); + }); + + it("allows read-only discovery ops (no tool call)", () => { + expect(gateMcpProxy({ search: "fastqc" })).toBeUndefined(); + expect(gateMcpProxy({ describe: "run_tool" })).toBeUndefined(); + expect(gateMcpProxy({ server: "galaxy" })).toBeUndefined(); // list a server's tools + expect(gateMcpProxy({ action: "ui-messages" })).toBeUndefined(); + expect(gateMcpProxy({})).toBeUndefined(); // status + }); + + it("gates connect to curated servers only", () => { + expect(gateMcpProxy({ connect: "galaxy" })).toBeUndefined(); + expect(gateMcpProxy({ connect: "evil" })).toMatchObject({ block: true }); + }); + + it("a tool call wins over connect (server gating applies, not connect)", () => { + // proxy dispatch checks `tool` before `connect`; a curated connect must + // not smuggle a call to an unverified server. + expect(gateMcpProxy({ tool: "run_tool", connect: "galaxy" })).toMatchObject({ block: true }); + }); + + it("is wired through shouldBlockTool", () => { + expect( + shouldBlockTool("mcp", { tool: "run_tool", server: "galaxy" }, [], "/tmp/loom-session"), + ).toBeUndefined(); + expect( + shouldBlockTool("mcp", { tool: "run_tool", server: "evil" }, [], "/tmp/loom-session"), + ).toMatchObject({ block: true }); + // bare proxy (status) is allowed; default-deny no longer swallows `mcp` + expect(shouldBlockTool("mcp", {}, [], "/tmp/loom-session")).toBeUndefined(); + }); }); // Exercise the actual registered extension, not just the pure helpers: proves diff --git a/web/extensions/web-mode-gate.ts b/web/extensions/web-mode-gate.ts index 785d3074..476abbbc 100644 --- a/web/extensions/web-mode-gate.ts +++ b/web/extensions/web-mode-gate.ts @@ -4,6 +4,8 @@ * Default-DENY allowlist for the remote brain's tool surface. The agent may * only reach the curated remote surface: * - Galaxy / BRC-Analytics MCP tools (galaxy_*, brc_analytics_*) + * - the `mcp` proxy gateway, scoped to those same curated servers -- the only + * path to them on a cold-cache container, where direct tools don't register * - the brain's HTTP helper tools (gtn_*, notebook_*, skills_fetch) * - path-gated edit/write/read, confined to the session notebook.md * Everything else -- bash/grep/find/ls, the pi-web-access egress tools @@ -41,6 +43,49 @@ const ALLOWED_PREFIXES = ["galaxy_", "brc_analytics_", "gtn_", "notebook_"]; // Allowed tool names that don't share one of the prefixes above. const ALLOWED_EXACT = new Set(["skills_fetch"]); +// The curated MCP servers reachable through the `mcp` proxy gateway. On a +// cold-cache container -- every fresh remote launch -- pi-mcp-adapter never +// registers the galaxy_*/brc_analytics_* DIRECT tools (it needs a warm +// per-server metadata cache, and the per-user scoped GALAXY_API_KEY changes +// the cache's config hash every launch), so the single `mcp` proxy tool is the +// only path to those servers. We allow the proxy but scope it to these servers, +// matching the direct-tool surface ALLOWED_PREFIXES already permits. +const CURATED_MCP_SERVERS = new Set(["galaxy", "brc-analytics"]); + +function asTrimmedString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +/** + * Gate the pi-mcp-adapter `mcp` proxy gateway. Mirrors the proxy's own dispatch + * precedence: a `tool` field is a server-side tool CALL and wins over + * everything; `connect` opens a server connection. Both must target a curated + * server -- a call with no server is unverifiable, so it's denied. The + * remaining ops (search/describe/list/status/ui-messages) are read-only + * discovery against already-configured servers and pass through. + */ +export function gateMcpProxy(input: Record): BlockDecision | undefined { + const tool = asTrimmedString(input.tool); + if (tool) { + const server = asTrimmedString(input.server); + if (server && CURATED_MCP_SERVERS.has(server)) return undefined; + return { + block: true, + reason: server + ? `mcp proxy call to server "${server}" is blocked in remote mode (allowed: galaxy, brc-analytics)` + : `mcp proxy tool calls must set "server" to one of: galaxy, brc-analytics`, + }; + } + const connect = asTrimmedString(input.connect); + if (connect && !CURATED_MCP_SERVERS.has(connect)) { + return { + block: true, + reason: `mcp proxy connect to "${connect}" is blocked in remote mode (allowed: galaxy, brc-analytics)`, + }; + } + return undefined; +} + /** * Resolve an absolute path with symlink collapsing. Walks up until it finds * a component that exists, realpaths it, then rejoins the non-existent @@ -93,15 +138,21 @@ export function shouldBlockTool( if (isPathAllowed(raw, allowlist, cwd)) return undefined; return { block: true, reason: `path "${raw}" is not in the remote-mode allowlist` }; } - // Destructive Galaxy ops (whole-history delete/purge) -- called directly or via the - // code-mode run_galaxy_tool envelope -- are blocked in remote mode: this gate has no - // confirmation UI to require an are-you-sure (#338). A remote-confirm UX is a follow-up. + // Destructive Galaxy ops (whole-history delete/purge) -- called directly, via the + // code-mode run_galaxy_tool envelope, or wrapped in the mcp() proxy -- are blocked in + // remote mode: this gate has no confirmation UI to require an are-you-sure (#338). + // This MUST run before the mcp-proxy allow below: classifyGalaxyDestructive unwraps the + // mcp envelope, so ordering it first stops a delete/purge smuggled through mcp() from + // being waved through as a "curated server" call. A remote-confirm UX is a follow-up. if (classifyGalaxyDestructive(toolName, input)) { return { block: true, reason: `${toolName} is a destructive Galaxy operation; blocked in remote mode (no confirmation UI available)`, }; } + // The MCP proxy gateway reaches the curated servers when their direct tools + // aren't registered (cold cache). Allow it, scoped to those servers. + if (toolName === "mcp") return gateMcpProxy(input); // Curated remote surface -> allowed. if (ALLOWED_EXACT.has(toolName)) return undefined; if (ALLOWED_PREFIXES.some((p) => toolName.startsWith(p))) return undefined; From 7048122b0c0b918249bb22f166e7c99275471602 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Thu, 18 Jun 2026 20:15:41 -0400 Subject: [PATCH 12/30] Add pure LLM-credential presence helpers for the web shell --- web/llm-credentials.test.ts | 32 ++++++++++++++++++++++++++++++++ web/llm-credentials.ts | 20 ++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 web/llm-credentials.test.ts create mode 100644 web/llm-credentials.ts diff --git a/web/llm-credentials.test.ts b/web/llm-credentials.test.ts new file mode 100644 index 00000000..e280c594 --- /dev/null +++ b/web/llm-credentials.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { providerKeyVar, hasProviderKey } from "./llm-credentials.js"; + +describe("providerKeyVar", () => { + it("maps a known provider to its API-key env var", () => { + expect(providerKeyVar("anthropic")).toBe("ANTHROPIC_API_KEY"); + expect(providerKeyVar("xai")).toBe("XAI_API_KEY"); + expect(providerKeyVar("openai")).toBe("OPENAI_API_KEY"); + }); + it("returns null for an unknown provider (not in the canonical set)", () => { + expect(providerKeyVar("bogus")).toBeNull(); + }); +}); + +describe("hasProviderKey", () => { + it("is true when a provided key is non-empty", () => { + expect(hasProviderKey({ env: {}, provider: "anthropic", providedKey: "sk-x" })).toBe(true); + }); + it("is true when the env carries the provider's key var", () => { + expect(hasProviderKey({ env: { ANTHROPIC_API_KEY: "sk-y" }, provider: "anthropic" })).toBe( + true, + ); + }); + it("is false when neither env nor provided key is present", () => { + expect(hasProviderKey({ env: {}, provider: "anthropic" })).toBe(false); + }); + it("is false for an empty provided key and empty env value", () => { + expect( + hasProviderKey({ env: { ANTHROPIC_API_KEY: "" }, provider: "anthropic", providedKey: "" }), + ).toBe(false); + }); +}); diff --git a/web/llm-credentials.ts b/web/llm-credentials.ts new file mode 100644 index 00000000..e3e155be --- /dev/null +++ b/web/llm-credentials.ts @@ -0,0 +1,20 @@ +import { PROVIDER_API_KEY_NAMES } from "../shared/brain-env.js"; + +/** The env-var name a provider's API key lives in, or null if unknown. */ +export function providerKeyVar(provider: string): string | null { + const v = `${provider.toUpperCase()}_API_KEY`; + return (PROVIDER_API_KEY_NAMES as Set).has(v) ? v : null; +} + +export interface KeyPresenceInput { + env: NodeJS.ProcessEnv; + provider: string; + providedKey?: string | null; +} + +/** Is a usable provider key available -- either supplied at runtime or in env? */ +export function hasProviderKey({ env, provider, providedKey }: KeyPresenceInput): boolean { + if (typeof providedKey === "string" && providedKey.length > 0) return true; + const v = providerKeyVar(provider); + return v != null && typeof env[v] === "string" && (env[v] as string).length > 0; +} From b920143c57d940b1796c95df6830bb4d82d8d5b4 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Thu, 18 Jun 2026 20:17:38 -0400 Subject: [PATCH 13/30] Hold a user-provided LLM key server-side and spawn the brain with it --- web/server.ts | 53 ++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/web/server.ts b/web/server.ts index cdac596c..4f5bfeea 100644 --- a/web/server.ts +++ b/web/server.ts @@ -20,6 +20,7 @@ import { WebSocketServer, WebSocket } from "ws"; import { buildBrainEnv } from "../shared/brain-env.js"; import { evaluateBind, authorizeWsUpgrade } from "./auth.js"; import { isForwardableUiResponse } from "./rpc-guard.js"; +import { providerKeyVar, hasProviderKey } from "./llm-credentials.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const LOOM_BIN = resolve(__dirname, "../bin/loom.js"); @@ -61,7 +62,7 @@ function saveConfig(config: Record): void { } function synthesizedRemoteConfig(): Record { - const provider = process.env.LOOM_LLM_PROVIDER ?? "anthropic"; + const provider = activeProvider(); // Mirror the nested masked shape the desktop renderer expects (active + // providers / active + profiles) so the first-run welcome overlay stays // suppressed and the Galaxy status dot reads "connected" from the env-injected @@ -84,7 +85,7 @@ function synthesizedRemoteConfig(): Record { providers: { [provider]: { model: process.env.LOOM_LLM_MODEL ?? null, - hasApiKey: true, + hasApiKey: llmKeyPresent(), }, }, }, @@ -109,6 +110,23 @@ let loomProcess: ChildProcess | null = null; let activeSocket: WebSocket | null = null; let cwd = getCwd(); +// BYO-key: a provider key supplied by the user at runtime (held in memory for +// the process lifetime only -- never persisted, logged, or sent to the renderer). +let providedLlmKey: string | null = null; +let providedProvider: string | null = null; + +function activeProvider(): string { + return providedProvider ?? process.env.LOOM_LLM_PROVIDER ?? "anthropic"; +} + +function llmKeyPresent(): boolean { + return hasProviderKey({ + env: process.env, + provider: activeProvider(), + providedKey: providedLlmKey, + }); +} + function startLoom(): void { if (loomProcess) stopLoom(); @@ -130,12 +148,19 @@ function startLoom(): void { if (IS_REMOTE_MODE) { const gatePath = resolve(__dirname, "extensions/web-mode-gate.ts"); args.push("--extension", gatePath); - if (process.env.LOOM_LLM_PROVIDER) { - args.push("--provider", process.env.LOOM_LLM_PROVIDER); + const prov = activeProvider(); + if (providedProvider || process.env.LOOM_LLM_PROVIDER) { + args.push("--provider", prov); } if (process.env.LOOM_LLM_MODEL) { args.push("--model", process.env.LOOM_LLM_MODEL); } + // BYO-key: inject the user-supplied key into the brain's env (env var name + // per the active provider). Never logged. + const keyVar = providerKeyVar(prov); + if (providedLlmKey && keyVar) { + env[keyVar] = providedLlmKey; + } env.LOOM_NOTEBOOK_ALLOWLIST = join(cwd, "notebook.md"); // No local execution surface in the container: the web-mode-gate is the // sole tool_call authority, so tell the brain to skip its local-exec guard @@ -325,7 +350,11 @@ wss.on("connection", (socket) => { log("browser connected"); activeSocket = socket; - if (!loomProcess) startLoom(); + // In remote mode with no resolvable provider key, hold off spawning: the + // renderer's key-entry screen drives agent:provide-llm-key, which spawns. + // Non-remote (desktop/dev) is unchanged -- the brain reads its own + // ~/.loom/config.json key, which never reaches this env-only check. + if (!loomProcess && (!IS_REMOTE_MODE || llmKeyPresent())) startLoom(); socket.on("message", (raw) => { let msg: Record; @@ -355,6 +384,20 @@ wss.on("connection", (socket) => { respond(id, { success: true }); return; } + if (channel === "agent:provide-llm-key") { + const payload = (args[0] ?? {}) as { provider?: unknown; key?: unknown }; + if (typeof payload.key === "string" && payload.key.length > 0) { + providedLlmKey = payload.key; // never logged + if (typeof payload.provider === "string" && payload.provider.length > 0) { + providedProvider = payload.provider; + } + if (!loomProcess) startLoom(); + respond(id, { ok: true }); + } else { + respond(id, { ok: false, error: "missing provider key" }); + } + return; + } if (channel === "agent:get-cwd") { respond(id, cwd); return; From ad7a26ed8ad0ef230c8050712353dee2bd221b54 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Thu, 18 Jun 2026 20:18:51 -0400 Subject: [PATCH 14/30] Add provideLlmKey to the web shim and shared API type --- app/src/preload/preload.ts | 4 ++++ web/orbit-shim.ts | 2 ++ 2 files changed, 6 insertions(+) diff --git a/app/src/preload/preload.ts b/app/src/preload/preload.ts index 69fdbe9f..3539e935 100644 --- a/app/src/preload/preload.ts +++ b/app/src/preload/preload.ts @@ -78,6 +78,10 @@ export interface OrbitAPI { saveConfig(config: Record): Promise<{ success: boolean; error?: string }>; refreshSkills: () => Promise<{ ok: boolean; error?: string }>; getGalaxyUser(): Promise; + // Remote (web/GxIT) only: hand a user-supplied LLM key to the server, which + // holds it in memory and spawns the brain. The Electron shell uses saveConfig + // instead, so this is optional on the shared API. + provideLlmKey?(provider: string, key: string): Promise; setBypassPermissions( enabled: boolean, ): Promise<{ ok: boolean; enabled: boolean; cancelled?: boolean }>; diff --git a/web/orbit-shim.ts b/web/orbit-shim.ts index 0406f11d..5b1a8542 100644 --- a/web/orbit-shim.ts +++ b/web/orbit-shim.ts @@ -143,6 +143,8 @@ async function fetchMode(): Promise<"remote" | "desktop"> { }, getConfig: () => invoke("config:get"), saveConfig: (config: unknown) => invoke("config:save", config), + provideLlmKey: (provider: string, key: string) => + invoke("agent:provide-llm-key", { provider, key }), respondToUiRequest: (id: string, response: Record) => { send({ channel: "agent:ui-response", From f7611b023d0f4e59736b03ab2d7c72adc4727df1 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Thu, 18 Jun 2026 20:21:19 -0400 Subject: [PATCH 15/30] Prompt for a BYO LLM key in remote mode when none is injected --- app/src/renderer/app.ts | 40 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/app/src/renderer/app.ts b/app/src/renderer/app.ts index 02829eba..c6f37510 100644 --- a/app/src/renderer/app.ts +++ b/app/src/renderer/app.ts @@ -917,8 +917,24 @@ welcomeBrowseCwd.addEventListener("click", async () => { if (dir) welcomeCwd.value = dir; }); +// Remote (web/GxIT) BYO-key entry: when the server reports no LLM key, reuse +// this overlay to collect a provider key and hand it to the server +// (provideLlmKey) instead of writing config.json, which remote mode rejects. +let remoteKeyEntry = false; + welcomeSave.addEventListener("click", async () => { welcomeError.textContent = ""; + if (remoteKeyEntry) { + const key = welcomeApiKey.value.trim(); + if (!key) { + welcomeError.textContent = "API key is required"; + return; + } + await window.orbit.provideLlmKey?.(welcomeProvider.value, key); + welcomeOverlay.classList.add("hidden"); + await refreshGalaxyStatus(); + return; + } const oauth = isOAuthProvider(welcomeProvider.value); const apiKey = welcomeApiKey.value.trim(); if (!oauth && !apiKey) { @@ -1003,9 +1019,27 @@ async function checkFirstRun(): Promise { _mode?: string; llm?: { active?: string; providers?: Record }; }; - // Remote shells inject creds server-side; the first-run credential flow is N/A - // there and its OAuth/key controls aren't wired in the web shim. - if (cfg._mode === "remote") return; + // Remote shells inject Galaxy creds server-side, but the LLM key may be + // missing (no admin-baked key). When it is, reuse the welcome overlay to + // collect a provider key and hand it to the server (BYO-key); otherwise the + // first-run flow is N/A. + if (cfg._mode === "remote") { + const active = cfg.llm?.active; + const hasKey = active ? Boolean(cfg.llm?.providers?.[active]?.hasApiKey) : false; + if (!hasKey) { + remoteKeyEntry = true; + if (active) welcomeProvider.value = active; + populateWelcomeModels(welcomeProvider.value); + void updateWelcomeAuthUi(); + // Remote: cwd is fixed (/tmp/loom-session) and Galaxy creds are injected, + // so hide both optional
sections; "Skip" would leave no agent. + welcomeCwd.closest("details")?.classList.add("hidden"); + welcomeGalaxyUrl.closest("details")?.classList.add("hidden"); + welcomeSkip.classList.add("hidden"); + welcomeOverlay.classList.remove("hidden"); + } + return; + } const active = cfg.llm?.active; // Treat an OAuth-only setup (no API key, but provider has a stored token) // as fully configured -- skip the welcome screen. From 404d236c58b596e82c8353a2ea7bf6da0c3e086d Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Thu, 18 Jun 2026 23:02:55 -0400 Subject: [PATCH 16/30] Serialize Galaxy page-sync pushes so flush can't overlap one in flight --- extensions/loom/galaxy-page-sync.test.ts | 26 +++++++++++++++++++ extensions/loom/galaxy-page-sync.ts | 32 ++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/extensions/loom/galaxy-page-sync.test.ts b/extensions/loom/galaxy-page-sync.test.ts index 2176de8a..fd496ec2 100644 --- a/extensions/loom/galaxy-page-sync.test.ts +++ b/extensions/loom/galaxy-page-sync.test.ts @@ -139,6 +139,32 @@ describe("createPageSyncEngine", () => { expect(deps.pushes).toHaveLength(1); }); + it("serializes pushes: flush coalesces with an in-flight push (no concurrent overlap)", async () => { + let releasePush: () => void = () => {}; + let calls = 0; + const deps = makeDeps({ + push: vi.fn(async () => { + calls += 1; + await new Promise((resolve) => { + releasePush = () => resolve(); + }); + }), + }); + const engine = createPageSyncEngine(deps); + await engine.init(); // lastBody baseline = "first" + (deps as unknown as { setBody: (b: string) => void }).setBody("changed"); + deps.fire(); + await vi.advanceTimersByTimeAsync(150); // debounce fires -> push #1 starts and hangs + expect(calls).toBe(1); + // The body is unchanged since push #1 started; flush must await the in-flight + // push, not launch a second concurrent one (the pre-fix code did, because + // lastBody is only set after the await). + const flushP = engine.flush(); + releasePush(); // let push #1 complete + await flushP; + expect(calls).toBe(1); + }); + it("does not throw when getHistoryId rejects (fail-open hardening)", async () => { const deps = makeDeps({ getHistoryId: async () => { diff --git a/extensions/loom/galaxy-page-sync.ts b/extensions/loom/galaxy-page-sync.ts index 99da2758..d53fc4db 100644 --- a/extensions/loom/galaxy-page-sync.ts +++ b/extensions/loom/galaxy-page-sync.ts @@ -52,6 +52,10 @@ export function createPageSyncEngine(deps: PageSyncDeps) { let timer: ReturnType | null = null; let unsubscribe: (() => void) | null = null; let active = false; + // Serialize pushes: at most one in flight; a change arriving mid-push queues + // exactly one follow-up so the latest body still lands. flush() awaits this. + let inFlight: Promise | null = null; + let pendingAgain = false; async function doPush(): Promise { if (!historyId) return; @@ -61,6 +65,29 @@ export function createPageSyncEngine(deps: PageSyncDeps) { lastBody = body; } + // Run pushes one at a time. A change that lands while a push is in flight sets + // pendingAgain, so the loop runs doPush once more afterward -- never two + // concurrent pushes (which raced on lastBody and could double-create the + // page), and the latest body always wins. Returns the in-flight promise so + // flush() can await whatever is already running plus the queued follow-up. + function requestPush(): Promise { + if (inFlight) { + pendingAgain = true; + return inFlight; + } + inFlight = (async () => { + try { + do { + pendingAgain = false; + await doPush(); + } while (pendingAgain); + } finally { + inFlight = null; + } + })(); + return inFlight; + } + async function init(): Promise { if (deps.mode !== "auto" || !deps.hasGalaxy()) return; let resolved: string | null = null; @@ -95,7 +122,7 @@ export function createPageSyncEngine(deps: PageSyncDeps) { if (timer) clearTimeout(timer); timer = setTimeout(() => { timer = null; - void doPush().catch((err) => console.error("[page-sync] push failed:", err)); + void requestPush().catch((err) => console.error("[page-sync] push failed:", err)); }, deps.debounceMs); }); } @@ -107,7 +134,7 @@ export function createPageSyncEngine(deps: PageSyncDeps) { timer = null; } try { - await doPush(); + await requestPush(); } catch (err) { console.error("[page-sync] flush push failed:", err); } @@ -121,6 +148,7 @@ export function createPageSyncEngine(deps: PageSyncDeps) { active = false; historyId = null; lastBody = null; + pendingAgain = false; } return { init, flush, dispose }; From 715d8d75ef87efcabf2f4732e9017808dcd3440d Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Thu, 18 Jun 2026 23:05:03 -0400 Subject: [PATCH 17/30] Guard LOOM_SHUTDOWN_GRACE_MS against a non-numeric override --- web/server.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/server.ts b/web/server.ts index 4f5bfeea..99277345 100644 --- a/web/server.ts +++ b/web/server.ts @@ -522,7 +522,11 @@ async function gracefulShutdown(signal: string): Promise { } catch { /* */ } - await stopLoomGracefully(parseInt(process.env.LOOM_SHUTDOWN_GRACE_MS ?? "8000", 10)); + // Guard the env override: a non-numeric LOOM_SHUTDOWN_GRACE_MS would parse to + // NaN, and setTimeout(NaN) fires immediately -> the SIGKILL backstop would + // race the notebook flush. Fall back to 8s on any non-finite/negative value. + const graceMs = parseInt(process.env.LOOM_SHUTDOWN_GRACE_MS ?? "8000", 10); + await stopLoomGracefully(Number.isFinite(graceMs) && graceMs >= 0 ? graceMs : 8000); process.exit(0); } process.on("SIGTERM", () => void gracefulShutdown("SIGTERM")); From bf1e3ad1337f901b141b3f0cba3f278886094cf3 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sun, 21 Jun 2026 11:13:21 +0200 Subject: [PATCH 18/30] make Docker container executable --- Dockerfile | 33 +++++++++++++++------------------ 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/Dockerfile b/Dockerfile index c95a970f..b9856937 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,15 +2,16 @@ FROM node:22-slim AS builder WORKDIR /app -COPY package.json package-lock.json ./ -COPY app/package.json app/package-lock.json ./app/ +COPY CHANGELOG.md README.md ./ +COPY bin ./bin +COPY extensions ./extensions +COPY shared ./shared +COPY app ./app COPY web/package.json web/package-lock.json ./web/ -RUN npm ci -RUN cd app && npm ci RUN cd web && npm ci - -COPY . . -RUN cd web && npm run build +COPY web ./web +RUN cd web && npm run build && npx tsc server.ts --outDir build --module ESNext --moduleResolution bundler --target ES2022 --skipLibCheck --resolveJsonModule --esModuleInterop +RUN cd web && npm prune --production --no-optional FROM node:22-slim AS runner @@ -23,21 +24,17 @@ ENV PORT=3000 ENV LOOM_WEB_HOST=0.0.0.0 WORKDIR /app - -# Copy runtime artifacts only -COPY --from=builder /app/package.json ./package.json -COPY --from=builder /app/node_modules ./node_modules +COPY package.json package-lock.json ./ COPY --from=builder /app/bin ./bin COPY --from=builder /app/extensions ./extensions COPY --from=builder /app/shared ./shared -COPY --from=builder /app/web/server.ts ./web/server.ts -COPY --from=builder /app/web/auth.ts ./web/auth.ts -COPY --from=builder /app/web/rpc-guard.ts ./web/rpc-guard.ts -COPY --from=builder /app/web/orbit-shim.ts ./web/orbit-shim.ts -COPY --from=builder /app/web/extensions ./web/extensions +COPY --from=builder /app/web/build ./web/build COPY --from=builder /app/web/dist ./web/dist -COPY --from=builder /app/web/node_modules ./web/node_modules COPY --from=builder /app/web/package.json ./web/package.json +COPY --from=builder /app/web/package-lock.json ./web/package-lock.json +COPY --from=builder /app/web/extensions ./web/extensions + +RUN npm ci --production --omit=dev --no-optional && cd web && npm ci --production --omit=dev --no-optional # Galaxy's tool/workflow execution surface runs through `uvx galaxy-mcp` (a # Python MCP server). node:slim ships no Python or uv, so bring uv -- which @@ -70,4 +67,4 @@ USER node # cache so the runtime `uvx galaxy-mcp>=1.8.0` launch resolves from cache. RUN uv tool install "galaxy-mcp>=1.8.0" -CMD ["node", "web/node_modules/.bin/tsx", "web/server.ts"] +CMD ["node", "web/build/server.js"] From 8f829b7ef62cbaf72d82e3a202139816cd4ffabb Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Wed, 8 Jul 2026 06:44:06 -0400 Subject: [PATCH 19/30] Fix compiled-container runtime paths so the GxIT actually boots 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. --- gxit/interactivetool_orbit.xml | 2 +- web/server.ts | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/gxit/interactivetool_orbit.xml b/gxit/interactivetool_orbit.xml index 96a7450b..01bbec6e 100644 --- a/gxit/interactivetool_orbit.xml +++ b/gxit/interactivetool_orbit.xml @@ -30,7 +30,7 @@ explicitly (mirrors the image CMD) rather than depending on the job runner honoring the container's default CMD. --> diff --git a/web/server.ts b/web/server.ts index 99277345..ff2578a3 100644 --- a/web/server.ts +++ b/web/server.ts @@ -10,7 +10,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import { createInterface } from "node:readline"; import { createServer } from "node:http"; import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; -import { join, resolve, dirname } from "node:path"; +import { join, resolve, dirname, basename } from "node:path"; import { homedir } from "node:os"; import { fileURLToPath } from "node:url"; @@ -23,7 +23,13 @@ import { isForwardableUiResponse } from "./rpc-guard.js"; import { providerKeyVar, hasProviderKey } from "./llm-credentials.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const LOOM_BIN = resolve(__dirname, "../bin/loom.js"); +// In dev this file runs from web/; the container compiles it to web/build/ and +// runs that. Anchor all repo-relative asset paths (the brain binary, the gate +// extension, the built renderer) at the web dir so they resolve either way -- +// otherwise the compiled server looks one level too deep (web/build/...) and +// the brain, the lockdown gate, and the static bundle all silently go missing. +const WEB_ROOT = basename(__dirname) === "build" ? resolve(__dirname, "..") : __dirname; +const LOOM_BIN = resolve(WEB_ROOT, "../bin/loom.js"); const LOOM_CONFIG_DIR = join(homedir(), ".loom"); const LOOM_CONFIG_PATH = join(LOOM_CONFIG_DIR, "config.json"); const DEFAULT_CWD = join(LOOM_CONFIG_DIR, "analyses"); @@ -146,7 +152,7 @@ function startLoom(): void { env.LOOM_SHELL_KIND = "orbit"; if (IS_REMOTE_MODE) { - const gatePath = resolve(__dirname, "extensions/web-mode-gate.ts"); + const gatePath = resolve(WEB_ROOT, "extensions/web-mode-gate.ts"); args.push("--extension", gatePath); const prov = activeProvider(); if (providedProvider || process.env.LOOM_LLM_PROVIDER) { @@ -481,7 +487,7 @@ wss.on("connection", (socket) => { async function setupRenderer(): Promise { if (process.env.NODE_ENV === "production") { - const distDir = resolve(__dirname, "dist"); + const distDir = resolve(WEB_ROOT, "dist"); log("serving static renderer from", distDir); app.use(express.static(distDir)); app.get("/", (_req, res) => res.sendFile(resolve(distDir, "index.html"))); From c3cd5da86deade27937aa31c9fbb5bc7d72954ba Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Wed, 15 Jul 2026 14:12:23 -0400 Subject: [PATCH 20/30] Bundle the web server so the container image actually builds 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. --- .gitignore | 3 + Dockerfile | 42 +++++++-- web/package-lock.json | 215 +++++++++++++++++++++--------------------- web/package.json | 4 +- web/server.ts | 58 +++++++----- 5 files changed, 185 insertions(+), 137 deletions(-) diff --git a/.gitignore b/.gitignore index 5db8710a..f96aa27e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ node_modules/ # Production Vite build output for the web shell web/dist/ +# Bundled web server (esbuild output, built in the container image) +web/build/ + # macOS Finder metadata .DS_Store diff --git a/Dockerfile b/Dockerfile index b9856937..c3212284 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,15 +2,32 @@ FROM node:22-slim AS builder WORKDIR /app + +# Root deps before any source, so an edit doesn't bust the install layer. These +# are needed at BUILD time, not just runtime: the web bundle is built from the +# Orbit renderer (vite.config.ts roots at app/src/renderer), whose bare imports +# -- marked, dompurify -- are declared in the ROOT package.json. Node resolves +# them by walking up from app/src/renderer/ to /app/node_modules, which never +# reaches web/node_modules, so without this the vite build can't resolve them. +COPY package.json package-lock.json ./ +RUN npm ci +COPY web/package.json web/package-lock.json ./web/ +RUN cd web && npm ci + COPY CHANGELOG.md README.md ./ COPY bin ./bin COPY extensions ./extensions COPY shared ./shared COPY app ./app -COPY web/package.json web/package-lock.json ./web/ -RUN cd web && npm ci COPY web ./web -RUN cd web && npm run build && npx tsc server.ts --outDir build --module ESNext --moduleResolution bundler --target ES2022 --skipLibCheck --resolveJsonModule --esModuleInterop +# Bundle the server rather than compiling file-by-file. tsc would emit flat into +# web/build/, keeping `../shared/brain-env.js` in the output -- which resolves to +# web/shared/ at runtime, not the /app/shared this image ships, so the server +# died on module load. Bundling inlines every relative import (shared/, auth, +# rpc-guard, llm-credentials) and leaves bare package imports external for the +# runner's npm ci to supply. Typechecking stays in CI, which has the app deps +# this stage doesn't install. +RUN cd web && npm run build && npm run build:server RUN cd web && npm prune --production --no-optional FROM node:22-slim AS runner @@ -34,7 +51,13 @@ COPY --from=builder /app/web/package.json ./web/package.json COPY --from=builder /app/web/package-lock.json ./web/package-lock.json COPY --from=builder /app/web/extensions ./web/extensions -RUN npm ci --production --omit=dev --no-optional && cd web && npm ci --production --omit=dev --no-optional +# Drop the root `prepare` script before installing: it runs husky (a devDep) on +# every npm ci, including this --omit=dev one, so npm would exec a binary it was +# just told not to install and die with "husky: not found". The git hooks it +# sets up are meaningless in an image with no .git anyway. +RUN npm pkg delete scripts.prepare \ + && npm ci --production --omit=dev --no-optional \ + && cd web && npm ci --production --omit=dev --no-optional # Galaxy's tool/workflow execution surface runs through `uvx galaxy-mcp` (a # Python MCP server). node:slim ships no Python or uv, so bring uv -- which @@ -64,7 +87,14 @@ EXPOSE 3000 USER node # Pre-warm galaxy-mcp (and the managed Python it needs) into the node-owned uv -# cache so the runtime `uvx galaxy-mcp>=1.8.0` launch resolves from cache. -RUN uv tool install "galaxy-mcp>=1.8.0" +# cache so the runtime `uvx` launch resolves from cache instead of PyPI. +# +# This MUST stay the spec bin/loom.js writes into mcp.json (GALAXY_MCP_SPEC in +# shared/galaxy-mcp-spec.js) -- pre-warming a version the runtime spec doesn't +# accept sends uv back to the network at job-launch, which is exactly what +# baking the cache is meant to avoid. tests/galaxy-mcp-spec.test.ts fails the +# build if these two drift apart. +ARG GALAXY_MCP_SPEC="galaxy-mcp>=1.9.0" +RUN uv tool install "${GALAXY_MCP_SPEC}" CMD ["node", "web/build/server.js"] diff --git a/web/package-lock.json b/web/package-lock.json index c0403c95..96944e95 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -17,6 +17,7 @@ "@types/ws": "^8.18.0", "@vitejs/plugin-react": "^6.0.1", "@xyflow/react": "^12.8.0", + "esbuild": "^0.28.1", "marked": "^18.0.0", "react": "^19.2.5", "react-dom": "^19.2.5", @@ -77,9 +78,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -94,9 +95,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -111,9 +112,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -128,9 +129,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -145,9 +146,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -162,9 +163,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -179,9 +180,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -196,9 +197,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -213,9 +214,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -230,9 +231,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -247,9 +248,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -264,9 +265,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -281,9 +282,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -298,9 +299,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -315,9 +316,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -332,9 +333,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -349,9 +350,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -366,9 +367,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -383,9 +384,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -400,9 +401,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -417,9 +418,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -434,9 +435,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -451,9 +452,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -468,9 +469,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -485,9 +486,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -502,9 +503,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -1387,9 +1388,9 @@ } }, "node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1400,32 +1401,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escape-html": { diff --git a/web/package.json b/web/package.json index 05b9db9d..3bad1ef0 100644 --- a/web/package.json +++ b/web/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "tsx server.ts", - "build": "vite build" + "build": "vite build", + "build:server": "esbuild server.ts --bundle --platform=node --format=esm --target=node22 --packages=external --outfile=build/server.js" }, "dependencies": { "express": "^5.1.0", @@ -17,6 +18,7 @@ "@types/ws": "^8.18.0", "@vitejs/plugin-react": "^6.0.1", "@xyflow/react": "^12.8.0", + "esbuild": "^0.28.1", "marked": "^18.0.0", "react": "^19.2.5", "react-dom": "^19.2.5", diff --git a/web/server.ts b/web/server.ts index ff2578a3..7dfdbafc 100644 --- a/web/server.ts +++ b/web/server.ts @@ -20,13 +20,14 @@ import { WebSocketServer, WebSocket } from "ws"; import { buildBrainEnv } from "../shared/brain-env.js"; import { evaluateBind, authorizeWsUpgrade } from "./auth.js"; import { isForwardableUiResponse } from "./rpc-guard.js"; -import { providerKeyVar, hasProviderKey } from "./llm-credentials.js"; +import { hasProviderKey, llmKeyEnvVar } from "./llm-credentials.js"; +import { resolveShutdownGraceMs } from "./shutdown-grace.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); -// In dev this file runs from web/; the container compiles it to web/build/ and +// In dev this file runs from web/; the container bundles it to web/build/ and // runs that. Anchor all repo-relative asset paths (the brain binary, the gate // extension, the built renderer) at the web dir so they resolve either way -- -// otherwise the compiled server looks one level too deep (web/build/...) and +// otherwise the bundled server looks one level too deep (web/build/...) and // the brain, the lockdown gate, and the static bundle all silently go missing. const WEB_ROOT = basename(__dirname) === "build" ? resolve(__dirname, "..") : __dirname; const LOOM_BIN = resolve(WEB_ROOT, "../bin/loom.js"); @@ -162,10 +163,10 @@ function startLoom(): void { args.push("--model", process.env.LOOM_LLM_MODEL); } // BYO-key: inject the user-supplied key into the brain's env (env var name - // per the active provider). Never logged. - const keyVar = providerKeyVar(prov); - if (providedLlmKey && keyVar) { - env[keyVar] = providedLlmKey; + // per the active provider; custom endpoints go to LOOM_ACTIVE_LLM_API_KEY). + // Never logged. + if (providedLlmKey) { + env[llmKeyEnvVar(prov)] = providedLlmKey; } env.LOOM_NOTEBOOK_ALLOWLIST = join(cwd, "notebook.md"); // No local execution surface in the container: the web-mode-gate is the @@ -300,6 +301,11 @@ const wss = new WebSocketServer({ const auth = authorizeWsUpgrade( { origin: info.origin, host: info.req.headers.host, url: info.req.url }, WEB_TOKEN, + // Remote mode with no token (the GxIT shape: gx-it-proxy is the trust + // boundary, and a Galaxy-issued entry-point URL can't carry a token) has + // no other check left, so demand the Origin browsers always send. Local + // dev and token deployments keep accepting non-browser clients. + { requireOrigin: IS_REMOTE_MODE && !WEB_TOKEN }, ); if (auth.ok) { done(true); @@ -421,21 +427,31 @@ wss.on("connection", (socket) => { respond(id, cwd); return; } + // Restart/reset drain the old brain before spawning the new one. A bare + // SIGTERM-and-respawn let the outgoing brain's shutdown-hook page push + // overlap the incoming brain's resume/push: the new brain could resume a + // page the old one hadn't finished writing, or the two pushes could land + // last-writer-wins. Draining first makes the handoff ordered. if (channel === "agent:restart") { - stopLoom(); - startLoom(); - respond(id, null); + void (async () => { + await stopLoomGracefully(resolveShutdownGraceMs(process.env)); + startLoom(); + respond(id, null); + })(); return; } if (channel === "agent:reset-session") { - stopLoom(); - // Fresh start — tell loom not to auto-load notebook - const origEnv = process.env.LOOM_FRESH_SESSION; - process.env.LOOM_FRESH_SESSION = "1"; - startLoom(); - if (origEnv === undefined) delete process.env.LOOM_FRESH_SESSION; - else process.env.LOOM_FRESH_SESSION = origEnv; - respond(id, null); + void (async () => { + await stopLoomGracefully(resolveShutdownGraceMs(process.env)); + // Fresh start — tell loom not to auto-load notebook. Set around the + // synchronous spawn only: startLoom reads process.env at spawn time. + const origEnv = process.env.LOOM_FRESH_SESSION; + process.env.LOOM_FRESH_SESSION = "1"; + startLoom(); + if (origEnv === undefined) delete process.env.LOOM_FRESH_SESSION; + else process.env.LOOM_FRESH_SESSION = origEnv; + respond(id, null); + })(); return; } if (channel === "agent:new-session") { @@ -528,11 +544,7 @@ async function gracefulShutdown(signal: string): Promise { } catch { /* */ } - // Guard the env override: a non-numeric LOOM_SHUTDOWN_GRACE_MS would parse to - // NaN, and setTimeout(NaN) fires immediately -> the SIGKILL backstop would - // race the notebook flush. Fall back to 8s on any non-finite/negative value. - const graceMs = parseInt(process.env.LOOM_SHUTDOWN_GRACE_MS ?? "8000", 10); - await stopLoomGracefully(Number.isFinite(graceMs) && graceMs >= 0 ? graceMs : 8000); + await stopLoomGracefully(resolveShutdownGraceMs(process.env)); process.exit(0); } process.on("SIGTERM", () => void gracefulShutdown("SIGTERM")); From 74dabb725b0deded74c71a286381ef62371fe8a5 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Wed, 15 Jul 2026 14:12:32 -0400 Subject: [PATCH 21/30] Pair the galaxy-mcp pre-warm with the spec the brain requests 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. --- bin/loom.js | 3 ++- shared/galaxy-mcp-spec.d.ts | 1 + shared/galaxy-mcp-spec.js | 11 ++++++++++ tests/galaxy-mcp-spec.test.ts | 38 +++++++++++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 shared/galaxy-mcp-spec.d.ts create mode 100644 shared/galaxy-mcp-spec.js create mode 100644 tests/galaxy-mcp-spec.test.ts diff --git a/bin/loom.js b/bin/loom.js index 9c6347fa..b2f97e6f 100755 --- a/bin/loom.js +++ b/bin/loom.js @@ -19,6 +19,7 @@ import { syncCustomProviderModelsFile, resolveActiveLlmApiKey, } from "../shared/custom-provider.js"; +import { GALAXY_MCP_SPEC } from "../shared/galaxy-mcp-spec.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -318,7 +319,7 @@ if (!isInformationalCommand) { if (hasGalaxyCredentials) { mcpConfig.mcpServers.galaxy = { command: "uvx", - args: ["galaxy-mcp>=1.9.0"], + args: [GALAXY_MCP_SPEC], directTools: true, // Local-path upload over MCP times out on large files (-32001); the // loom-native galaxy_upload_local_file tool handles those instead. URL diff --git a/shared/galaxy-mcp-spec.d.ts b/shared/galaxy-mcp-spec.d.ts new file mode 100644 index 00000000..0818aedd --- /dev/null +++ b/shared/galaxy-mcp-spec.d.ts @@ -0,0 +1 @@ +export const GALAXY_MCP_SPEC: string; diff --git a/shared/galaxy-mcp-spec.js b/shared/galaxy-mcp-spec.js new file mode 100644 index 00000000..d89c5fcd --- /dev/null +++ b/shared/galaxy-mcp-spec.js @@ -0,0 +1,11 @@ +/** + * The single source of truth for which galaxy-mcp the brain launches. + * + * bin/loom.js writes this into ~/.loom/agent/mcp.json as the `uvx` argument, so + * it is what actually gets resolved at runtime. The container image pre-warms + * the SAME spec into its uv cache (see the Dockerfile) so a GxIT job can start + * without reaching PyPI -- a pre-warm of anything the runtime spec doesn't + * accept silently reintroduces a network dependency at launch. + * tests/galaxy-mcp-spec.test.ts guards that pairing. + */ +export const GALAXY_MCP_SPEC = "galaxy-mcp>=1.9.0"; diff --git a/tests/galaxy-mcp-spec.test.ts b/tests/galaxy-mcp-spec.test.ts new file mode 100644 index 00000000..fe2d2ccc --- /dev/null +++ b/tests/galaxy-mcp-spec.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +import { GALAXY_MCP_SPEC } from "../shared/galaxy-mcp-spec.js"; + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); + +/** + * The container bakes galaxy-mcp into its uv cache so a GxIT job starts without + * PyPI. That only holds while the pre-warmed spec is the one the brain actually + * asks for at runtime: the image once pre-warmed >=1.8.0 while mcp.json required + * >=1.9.0, so the cached copy didn't satisfy the request and uv silently went + * back to the network. Nothing in CI builds the image, so guard the pairing here. + */ +describe("galaxy-mcp spec lockstep", () => { + it("Dockerfile pre-warms exactly the spec the brain requests", () => { + const dockerfile = readFileSync(join(repoRoot, "Dockerfile"), "utf-8"); + const arg = dockerfile.match(/^ARG GALAXY_MCP_SPEC="([^"]+)"$/m); + expect(arg, 'Dockerfile should declare ARG GALAXY_MCP_SPEC="..."').not.toBeNull(); + expect(arg![1]).toBe(GALAXY_MCP_SPEC); + }); + + it("installs from the ARG rather than a second hardcoded spec", () => { + const dockerfile = readFileSync(join(repoRoot, "Dockerfile"), "utf-8"); + expect(dockerfile).toContain('RUN uv tool install "${GALAXY_MCP_SPEC}"'); + // A literal `uv tool install "galaxy-mcp..."` would dodge the ARG (and this + // test's pairing) even while the ARG stays correct. + expect(dockerfile).not.toMatch(/uv tool install "galaxy-mcp/); + }); + + it("bin/loom.js writes the shared constant into mcp.json, not a literal", () => { + const loomBin = readFileSync(join(repoRoot, "bin", "loom.js"), "utf-8"); + expect(loomBin).toContain("args: [GALAXY_MCP_SPEC]"); + expect(loomBin).not.toMatch(/args: \["galaxy-mcp/); + }); +}); From afd803cf2f38dcbcbd8e6a45c4b70674cd677526 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Wed, 15 Jul 2026 14:12:46 -0400 Subject: [PATCH 22/30] Spawn the brain for custom OpenAI-compatible providers too 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. --- app/src/renderer/app.ts | 11 ++++++++- web/llm-credentials.test.ts | 46 ++++++++++++++++++++++++++++++++++++- web/llm-credentials.ts | 29 +++++++++++++++++++++-- 3 files changed, 82 insertions(+), 4 deletions(-) diff --git a/app/src/renderer/app.ts b/app/src/renderer/app.ts index c6f37510..b91a4b84 100644 --- a/app/src/renderer/app.ts +++ b/app/src/renderer/app.ts @@ -930,7 +930,16 @@ welcomeSave.addEventListener("click", async () => { welcomeError.textContent = "API key is required"; return; } - await window.orbit.provideLlmKey?.(welcomeProvider.value, key); + // Keep the overlay up if the server couldn't route the key anywhere -- + // hiding it on a rejection stranded the user in front of a brain that never + // started, with no way back to the key prompt. + const res = (await window.orbit.provideLlmKey?.(welcomeProvider.value, key)) as + | { ok?: boolean; error?: string } + | undefined; + if (res && res.ok === false) { + welcomeError.textContent = res.error || "Could not start the agent with that key"; + return; + } welcomeOverlay.classList.add("hidden"); await refreshGalaxyStatus(); return; diff --git a/web/llm-credentials.test.ts b/web/llm-credentials.test.ts index e280c594..0eeedeb5 100644 --- a/web/llm-credentials.test.ts +++ b/web/llm-credentials.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { providerKeyVar, hasProviderKey } from "./llm-credentials.js"; +import { providerKeyVar, hasProviderKey, llmKeyEnvVar } from "./llm-credentials.js"; describe("providerKeyVar", () => { it("maps a known provider to its API-key env var", () => { @@ -30,3 +30,47 @@ describe("hasProviderKey", () => { ).toBe(false); }); }); + +// #330 regression: a custom OpenAI-compatible provider (gxit/README.md) carries +// its key in LOOM_ACTIVE_LLM_API_KEY, not ${PROVIDER}_API_KEY -- the brain +// resolves it from there (bin/loom.js resolveActiveLlmApiKey). Gating on the +// named var alone reported "no key" for a correctly-configured container, so the +// server never spawned the brain and the user got the BYO overlay instead. +describe("hasProviderKey with a custom provider", () => { + it("is true when LOOM_ACTIVE_LLM_API_KEY carries the key", () => { + expect( + hasProviderKey({ env: { LOOM_ACTIVE_LLM_API_KEY: "sk-custom" }, provider: "myprov" }), + ).toBe(true); + }); + it("is true even when the active provider name looks built-in", () => { + // The server defaults activeProvider() to "anthropic" when LOOM_LLM_PROVIDER + // is unset, while config.json names the real custom provider -- so the key + // must count regardless of the provider label the server happens to hold. + expect( + hasProviderKey({ env: { LOOM_ACTIVE_LLM_API_KEY: "sk-custom" }, provider: "anthropic" }), + ).toBe(true); + }); + it("is false when LOOM_ACTIVE_LLM_API_KEY is empty", () => { + expect(hasProviderKey({ env: { LOOM_ACTIVE_LLM_API_KEY: "" }, provider: "myprov" })).toBe( + false, + ); + }); +}); + +describe("llmKeyEnvVar", () => { + it("routes a built-in provider to its own key var", () => { + expect(llmKeyEnvVar("anthropic")).toBe("ANTHROPIC_API_KEY"); + }); + it("routes an unknown/custom provider to LOOM_ACTIVE_LLM_API_KEY", () => { + // Mirrors Orbit desktop (app/src/main/agent.ts): custom endpoints route + // through LOOM_ACTIVE_LLM_API_KEY. Returning null here meant startLoom + // injected nothing and spawned an unauthenticated brain. + expect(llmKeyEnvVar("myprov")).toBe("LOOM_ACTIVE_LLM_API_KEY"); + expect(llmKeyEnvVar("openai-compatible")).toBe("LOOM_ACTIVE_LLM_API_KEY"); + }); + it("never returns null, so a supplied key always lands somewhere", () => { + for (const p of ["anthropic", "myprov", "", "openai"]) { + expect(typeof llmKeyEnvVar(p)).toBe("string"); + } + }); +}); diff --git a/web/llm-credentials.ts b/web/llm-credentials.ts index e3e155be..e50f4c04 100644 --- a/web/llm-credentials.ts +++ b/web/llm-credentials.ts @@ -1,4 +1,5 @@ import { PROVIDER_API_KEY_NAMES } from "../shared/brain-env.js"; +import { ACTIVE_LLM_API_KEY_ENV } from "../shared/custom-provider.js"; /** The env-var name a provider's API key lives in, or null if unknown. */ export function providerKeyVar(provider: string): string | null { @@ -6,15 +7,39 @@ export function providerKeyVar(provider: string): string | null { return (PROVIDER_API_KEY_NAMES as Set).has(v) ? v : null; } +/** + * The env var to hand a key to the brain under. Built-in providers use their own + * ${PROVIDER}_API_KEY; anything else is an OpenAI-compatible custom endpoint, + * which the brain authenticates from LOOM_ACTIVE_LLM_API_KEY (see + * resolveActiveLlmApiKey in shared/custom-provider.js). Mirrors how Orbit + * desktop picks the target var in app/src/main/agent.ts. + * + * Unlike providerKeyVar this never returns null: a key with nowhere to go meant + * the server silently spawned an unauthenticated brain. + */ +export function llmKeyEnvVar(provider: string): string { + return providerKeyVar(provider) ?? ACTIVE_LLM_API_KEY_ENV; +} + export interface KeyPresenceInput { env: NodeJS.ProcessEnv; provider: string; providedKey?: string | null; } +function nonEmpty(v: string | undefined): boolean { + return typeof v === "string" && v.length > 0; +} + /** Is a usable provider key available -- either supplied at runtime or in env? */ export function hasProviderKey({ env, provider, providedKey }: KeyPresenceInput): boolean { - if (typeof providedKey === "string" && providedKey.length > 0) return true; + if (nonEmpty(providedKey ?? undefined)) return true; const v = providerKeyVar(provider); - return v != null && typeof env[v] === "string" && (env[v] as string).length > 0; + if (v != null && nonEmpty(env[v])) return true; + // A custom endpoint's key. Checked regardless of the provider label: the + // server defaults activeProvider() to "anthropic" when LOOM_LLM_PROVIDER is + // unset, while the custom provider is named only in the container's + // config.json -- which the server never reads in remote mode. Gating this on + // the label would put a correctly-configured GxIT back behind the BYO overlay. + return nonEmpty(env[ACTIVE_LLM_API_KEY_ENV]); } From 2191e11216f9b7268029bac0f7187ee3c4bc12ca Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Wed, 15 Jul 2026 14:13:02 -0400 Subject: [PATCH 23/30] Drain the old brain before restart/reset spawns a new one 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. --- web/shutdown-grace.test.ts | 29 +++++++++++++++++++++++++++++ web/shutdown-grace.ts | 23 +++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 web/shutdown-grace.test.ts create mode 100644 web/shutdown-grace.ts diff --git a/web/shutdown-grace.test.ts b/web/shutdown-grace.test.ts new file mode 100644 index 00000000..075a309d --- /dev/null +++ b/web/shutdown-grace.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { resolveShutdownGraceMs, DEFAULT_SHUTDOWN_GRACE_MS } from "./shutdown-grace.js"; + +describe("resolveShutdownGraceMs", () => { + it("defaults when unset", () => { + expect(resolveShutdownGraceMs({})).toBe(DEFAULT_SHUTDOWN_GRACE_MS); + }); + it("honors a numeric override", () => { + expect(resolveShutdownGraceMs({ LOOM_SHUTDOWN_GRACE_MS: "2500" })).toBe(2500); + }); + it("allows 0 (kill immediately) as an explicit choice", () => { + expect(resolveShutdownGraceMs({ LOOM_SHUTDOWN_GRACE_MS: "0" })).toBe(0); + }); + // setTimeout(NaN) fires immediately, so a garbage override would turn the + // SIGKILL backstop into an instant kill and drop the notebook flush. + it("falls back on a non-numeric override", () => { + expect(resolveShutdownGraceMs({ LOOM_SHUTDOWN_GRACE_MS: "soon" })).toBe( + DEFAULT_SHUTDOWN_GRACE_MS, + ); + }); + it("falls back on an empty override", () => { + expect(resolveShutdownGraceMs({ LOOM_SHUTDOWN_GRACE_MS: "" })).toBe(DEFAULT_SHUTDOWN_GRACE_MS); + }); + it("falls back on a negative override", () => { + expect(resolveShutdownGraceMs({ LOOM_SHUTDOWN_GRACE_MS: "-1" })).toBe( + DEFAULT_SHUTDOWN_GRACE_MS, + ); + }); +}); diff --git a/web/shutdown-grace.ts b/web/shutdown-grace.ts new file mode 100644 index 00000000..f1434de9 --- /dev/null +++ b/web/shutdown-grace.ts @@ -0,0 +1,23 @@ +/** + * How long to let the brain drain before the SIGKILL backstop. + * + * The brain's session_shutdown hook is what flushes the notebook to its Galaxy + * Page, so this window is the difference between a durable notebook and one that + * only ever existed under /tmp. Shared by the signal path and the + * restart/reset-session handlers, which respawn a brain and would otherwise race + * the outgoing one's final push. + */ +export const DEFAULT_SHUTDOWN_GRACE_MS = 8000; + +/** + * Resolve the drain window from the env. A non-numeric or negative override + * would parse to NaN and setTimeout(NaN) fires immediately -- the SIGKILL + * backstop would then race the flush it exists to protect -- so anything + * non-finite or negative falls back to the default. + */ +export function resolveShutdownGraceMs(env: NodeJS.ProcessEnv): number { + const raw = env.LOOM_SHUTDOWN_GRACE_MS; + if (raw === undefined) return DEFAULT_SHUTDOWN_GRACE_MS; + const parsed = parseInt(raw, 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_SHUTDOWN_GRACE_MS; +} From 619016c09fb7d4a390c5d91e512b644a9733c127 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Wed, 15 Jul 2026 14:13:02 -0400 Subject: [PATCH 24/30] Require an Origin on WS upgrades in insecure remote mode 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. --- web/auth.test.ts | 64 ++++++++++++++++++++++++++++++++++++++++++++++++ web/auth.ts | 28 ++++++++++++++++++++- 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/web/auth.test.ts b/web/auth.test.ts index a977d8b7..302b6088 100644 --- a/web/auth.test.ts +++ b/web/auth.test.ts @@ -54,3 +54,67 @@ describe("authorizeWsUpgrade", () => { expect(authorizeWsUpgrade({ host: "h", url: "/ws?token=sek" }, "sek").ok).toBe(true); }); }); + +// #330 (Codex, HIGH): in GxIT mode the tool XML sets LOOM_WEB_ALLOW_INSECURE=1 +// and no token, because a Galaxy-controlled entry-point URL can't carry one. +// The origin check was skipped entirely when Origin was absent, so any process +// that could reach port 3000 -- e.g. another container on the default Docker +// bridge, where inter-container traffic is allowed by default -- could open the +// control socket and drive the victim's credentialed agent. +// +// Browsers always send Origin on a WebSocket upgrade, so requiring it costs the +// real client nothing: a browser upgrade that passes today (Origin present and +// matching Host) still passes. Only origin-less non-browser clients are newly +// rejected -- which is exactly the threat. +describe("authorizeWsUpgrade with requireOrigin (insecure remote mode)", () => { + const policy = { requireOrigin: true }; + + it("rejects an origin-less upgrade", () => { + const r = authorizeWsUpgrade({ host: "orbit.example.org", url: "/ws" }, undefined, policy); + expect(r.ok).toBe(false); + expect(r.reason).toMatch(/origin/i); + }); + + it("still allows a same-origin browser upgrade", () => { + expect( + authorizeWsUpgrade( + { origin: "https://orbit.example.org", host: "orbit.example.org", url: "/ws" }, + undefined, + policy, + ).ok, + ).toBe(true); + }); + + it("still rejects a cross-origin upgrade", () => { + expect( + authorizeWsUpgrade( + { origin: "https://evil.example.org", host: "orbit.example.org", url: "/ws" }, + undefined, + policy, + ).ok, + ).toBe(false); + }); + + it("rejects when Host is missing, since the Origin can't be verified", () => { + expect( + authorizeWsUpgrade({ origin: "https://orbit.example.org", url: "/ws" }, undefined, policy).ok, + ).toBe(false); + }); + + it("rejects a malformed Origin", () => { + expect( + authorizeWsUpgrade({ origin: "not-a-url", host: "orbit.example.org" }, undefined, policy).ok, + ).toBe(false); + }); +}); + +// The policy is opt-in: local dev (loopback) and token-authenticated deployments +// keep accepting non-browser clients, so wscat/curl workflows still work. +describe("authorizeWsUpgrade without requireOrigin (default)", () => { + it("still allows an origin-less client when no policy is passed", () => { + expect(authorizeWsUpgrade({ host: "127.0.0.1:3000", url: "/ws" }, undefined).ok).toBe(true); + }); + it("still allows an origin-less client that presents the token", () => { + expect(authorizeWsUpgrade({ host: "h", url: "/ws?token=s3cret" }, "s3cret").ok).toBe(true); + }); +}); diff --git a/web/auth.ts b/web/auth.ts index df2b682c..e873ea5e 100644 --- a/web/auth.ts +++ b/web/auth.ts @@ -60,16 +60,38 @@ function tokenFromUrl(url: string | undefined): string | undefined { return new URLSearchParams(url.slice(q + 1)).get("token") ?? undefined; } +export interface WsAuthPolicy { + /** + * Reject upgrades that carry no Origin header at all. + * + * Set when the server has no token to check -- remote/GxIT mode, where the + * entry-point URL can't carry one. Without it, "no Origin" skipped the only + * check standing between the control socket and any process that can reach + * the port (a neighbouring container on the Docker bridge, say), which is a + * live credentialed agent for the taking. + * + * Browsers always send Origin on a WebSocket upgrade, so this only narrows + * the accepted set by the non-browser clients that have no business here: any + * upgrade that passes the origin match today still passes. + */ + requireOrigin?: boolean; +} + /** * Authorize a WebSocket upgrade: reject cross-origin sockets (blocks a drive-by * page in the user's browser from opening one), then require the shared token * when one is configured. A request with no Origin (non-browser client) skips - * the origin check but still needs the token. + * the origin check but still needs the token -- unless policy.requireOrigin is + * set, which rejects it outright. */ export function authorizeWsUpgrade( info: WsUpgradeInfo, expectedToken: string | undefined, + policy: WsAuthPolicy = {}, ): WsAuthResult { + if (policy.requireOrigin && !info.origin) { + return { ok: false, reason: "missing Origin header" }; + } if (info.origin && info.host) { let originHost: string; try { @@ -78,6 +100,10 @@ export function authorizeWsUpgrade( return { ok: false, reason: "malformed Origin header" }; } if (originHost !== info.host) return { ok: false, reason: "cross-origin WebSocket" }; + } else if (policy.requireOrigin) { + // Origin present but no Host to compare it against: nothing to verify, and + // this policy exists precisely because there's no token to fall back on. + return { ok: false, reason: "missing Host header" }; } if (expectedToken) { if (tokenFromUrl(info.url) !== expectedToken) { From c4667399f6f202b9425ca3bdeaca4734b9aeb759 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Wed, 15 Jul 2026 14:13:11 -0400 Subject: [PATCH 25/30] Stop a launch-time Galaxy blip from killing page sync for the session 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. --- extensions/loom/galaxy-page-sync.test.ts | 108 +++++++++++++++++++++++ extensions/loom/galaxy-page-sync.ts | 97 ++++++++++++++++++-- 2 files changed, 196 insertions(+), 9 deletions(-) diff --git a/extensions/loom/galaxy-page-sync.test.ts b/extensions/loom/galaxy-page-sync.test.ts index fd496ec2..8c70fbaa 100644 --- a/extensions/loom/galaxy-page-sync.test.ts +++ b/extensions/loom/galaxy-page-sync.test.ts @@ -177,3 +177,111 @@ describe("createPageSyncEngine", () => { expect(deps.pushes).toHaveLength(0); }); }); + +// #330 (Codex, HIGH): page-sync failures were silent AND permanent. A Galaxy +// that was briefly down at launch left init() with no history, which disabled +// sync for the entire session while the UI still showed Galaxy as connected -- +// the notebook then only ever existed under /tmp, and the container's /tmp dies +// with the job. +describe("createPageSyncEngine recovery", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("re-arms on a later change when the history lookup failed at launch", async () => { + let attempts = 0; + const deps = makeDeps({ + getHistoryId: async () => { + attempts++; + if (attempts === 1) throw new Error("galaxy down"); + return "h1"; + }, + findPageId: vi.fn(async () => null), // no prior page: local notebook is authoritative + }); + const engine = createPageSyncEngine(deps); + await engine.init(); + expect(deps.pushes).toHaveLength(0); + + (deps as unknown as { setBody: (b: string) => void }).setBody("written while galaxy was down"); + deps.fire(); + await vi.advanceTimersByTimeAsync(150); + + expect(attempts).toBe(2); + expect(deps.pushes).toHaveLength(1); + expect(deps.pushes[0].historyId).toBe("h1"); + }); + + it("recovers content written during the outage on flush, not just on change", async () => { + let attempts = 0; + const deps = makeDeps({ + getHistoryId: async () => { + attempts++; + if (attempts === 1) return null; // no history yet + return "h1"; + }, + findPageId: vi.fn(async () => null), + }); + const engine = createPageSyncEngine(deps); + await engine.init(); + (deps as unknown as { setBody: (b: string) => void }).setBody("work worth keeping"); + + await engine.flush(); + + // Without arming inside the push path this flushed nothing and the work was + // lost with the container. + expect(deps.pushes).toHaveLength(1); + expect(deps.pushes[0].historyId).toBe("h1"); + }); + + it("gives up after maxInitAttempts so a dead Galaxy isn't retried forever", async () => { + let attempts = 0; + const deps = makeDeps({ + getHistoryId: async () => { + attempts++; + throw new Error("galaxy down"); + }, + maxInitAttempts: 3, + }); + const engine = createPageSyncEngine(deps); + await engine.init(); // attempt 1 + for (let i = 0; i < 6; i++) { + (deps as unknown as { setBody: (b: string) => void }).setBody(`body-${i}`); + deps.fire(); + await vi.advanceTimersByTimeAsync(150); + } + expect(attempts).toBe(3); + expect(deps.pushes).toHaveLength(0); + }); + + it("times out a hanging history request instead of stalling session_start", async () => { + const deps = makeDeps({ + getHistoryId: () => new Promise(() => {}), // never settles + initTimeoutMs: 5000, + }); + const engine = createPageSyncEngine(deps); + const done = engine.init(); + await vi.advanceTimersByTimeAsync(5001); + await expect(done).resolves.toBeUndefined(); + }); + + it("does not publish a page for an untouched notebook", async () => { + const deps = makeDeps({ findPageId: vi.fn(async () => null) }); + (deps as unknown as { setBody: (b: string) => void }).setBody(""); + const engine = createPageSyncEngine(deps); + await engine.init(); + deps.fire(); + await vi.advanceTimersByTimeAsync(150); + await engine.flush(); + expect(deps.pushes).toHaveLength(0); + }); + + it("still skips a redundant push right after resuming an existing page", async () => { + // resume() rewrites the notebook, which fires the watcher; that self-trigger + // must not bounce straight back to Galaxy as a no-op push. + const deps = makeDeps(); // findPageId -> "page-h1", body stays "first" + const engine = createPageSyncEngine(deps); + await engine.init(); + deps.fire(); + await vi.advanceTimersByTimeAsync(150); + expect(deps.pushes).toHaveLength(0); + }); +}); diff --git a/extensions/loom/galaxy-page-sync.ts b/extensions/loom/galaxy-page-sync.ts index d53fc4db..33c7174f 100644 --- a/extensions/loom/galaxy-page-sync.ts +++ b/extensions/loom/galaxy-page-sync.ts @@ -43,6 +43,42 @@ export interface PageSyncDeps { push: (o: { historyId: string; slug: string; title: string }) => Promise; subscribe: (cb: () => void) => () => void; debounceMs: number; + /** + * Cap on each init-time Galaxy call. init() runs inside session_start, so an + * unbounded history request against a wedged Galaxy hangs the session's whole + * startup rather than just costing us page sync. + */ + initTimeoutMs?: number; + /** + * How many times to try resolving the history before giving up for good. + * Attempts are lazy -- one per change event -- so a Galaxy that's briefly down + * at launch costs a retry, not the session's durability. + */ + maxInitAttempts?: number; +} + +const DEFAULT_INIT_TIMEOUT_MS = 15000; +const DEFAULT_MAX_INIT_ATTEMPTS = 5; + +class TimeoutError extends Error {} + +function withTimeout(p: Promise, ms: number, label: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new TimeoutError(`${label} timed out after ${ms}ms`)), + ms, + ); + p.then( + (v) => { + clearTimeout(timer); + resolve(v); + }, + (err) => { + clearTimeout(timer); + reject(err); + }, + ); + }); } export function createPageSyncEngine(deps: PageSyncDeps) { @@ -52,15 +88,26 @@ export function createPageSyncEngine(deps: PageSyncDeps) { let timer: ReturnType | null = null; let unsubscribe: (() => void) | null = null; let active = false; + let initAttempts = 0; + const initTimeoutMs = deps.initTimeoutMs ?? DEFAULT_INIT_TIMEOUT_MS; + const maxInitAttempts = deps.maxInitAttempts ?? DEFAULT_MAX_INIT_ATTEMPTS; // Serialize pushes: at most one in flight; a change arriving mid-push queues // exactly one follow-up so the latest body still lands. flush() awaits this. let inFlight: Promise | null = null; let pendingAgain = false; async function doPush(): Promise { + // A launch-time blip leaves us unarmed; retry here so the first change after + // Galaxy comes back recovers sync instead of the session running to its end + // with a notebook that only exists under /tmp. + if (!historyId && !(await arm())) return; if (!historyId) return; const body = await deps.readBody(); if (!hasBodyChanged(lastBody, body)) return; // dedupe + self-trigger guard + // Never create/overwrite a page with an empty body: an untouched notebook + // has nothing worth persisting, and lastBody is null until a resume gives us + // a real baseline, so without this a no-op session would publish a blank page. + if (body.length === 0) return; await deps.push({ historyId, slug, title: pageTitleForHistory(historyId) }); lastBody = body; } @@ -88,16 +135,27 @@ export function createPageSyncEngine(deps: PageSyncDeps) { return inFlight; } - async function init(): Promise { - if (deps.mode !== "auto" || !deps.hasGalaxy()) return; - let resolved: string | null = null; + /** + * Resolve the history and adopt any prior page for it. Returns true once the + * engine can push. Safe to call repeatedly: it no-ops once armed and stops + * trying after maxInitAttempts so a permanently unreachable Galaxy doesn't + * mean a Galaxy call on every change event for the rest of the session. + */ + async function arm(): Promise { + if (historyId) return true; + if (initAttempts >= maxInitAttempts) return false; + initAttempts++; + + let resolved: string | null; try { - resolved = await deps.getHistoryId(); - } catch { - /* treat a rejected getHistoryId as "no history" → skip sync */ + resolved = await withTimeout(deps.getHistoryId(), initTimeoutMs, "history lookup"); + } catch (err) { + console.error("[page-sync] history lookup failed:", err); + return false; } + if (!resolved) return false; + historyId = resolved; - if (!historyId) return; slug = pageSlugForHistory(historyId); // Galaxy's GET /pages/{id} takes a page id, not a slug, so resolve the // per-history page id by listing the history's pages and matching the @@ -105,18 +163,38 @@ export function createPageSyncEngine(deps: PageSyncDeps) { // so listing is the only way to rediscover the page. let existingPageId: string | null = null; try { - existingPageId = await deps.findPageId(historyId, slug); + existingPageId = await withTimeout( + deps.findPageId(historyId, slug), + initTimeoutMs, + "page listing", + ); } catch { /* listing failed — treat as no prior page; created on first push */ } + let resumed = false; if (existingPageId) { try { await deps.resume(existingPageId); // refresh notebook from the prior page + resumed = true; } catch (err) { console.error("[page-sync] resume failed:", err); } } - lastBody = await deps.readBody(); + // Baseline only from a resume, where the notebook now mirrors the page and + // the write we just made would otherwise self-trigger a pointless push. + // Without a resume the local notebook is the authoritative copy, so leave + // the baseline empty and let the next push carry it to Galaxy -- seeding it + // from disk here is what made a recovered session flush nothing at all. + lastBody = resumed ? await deps.readBody() : null; + return true; + } + + async function init(): Promise { + if (deps.mode !== "auto" || !deps.hasGalaxy()) return; + await arm(); + // Subscribe regardless: an unarmed engine retries on the next change (see + // doPush), so a Galaxy that's down at launch no longer disables sync for the + // whole session. active = true; unsubscribe = deps.subscribe(() => { if (timer) clearTimeout(timer); @@ -149,6 +227,7 @@ export function createPageSyncEngine(deps: PageSyncDeps) { historyId = null; lastBody = null; pendingAgain = false; + initAttempts = 0; } return { init, flush, dispose }; From 44803c5a7a5db656d260a10e755908acbb24dd98 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Wed, 15 Jul 2026 15:19:42 -0400 Subject: [PATCH 26/30] Bring the GxIT deploy guide in line with what the PR ships 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. --- gxit/README.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/gxit/README.md b/gxit/README.md index 6ef6a615..c56cd064 100644 --- a/gxit/README.md +++ b/gxit/README.md @@ -50,8 +50,10 @@ orbit_destination: # value: anthropic ``` -If no admin key is provided, the user is prompted for their own once BYO-key -(Plan 2) lands. (For a free/local model, see "Custom provider" below.) +If no admin key is provided, the user is prompted for their own on first +connect; the key is held in server memory for the container's lifetime and is +never persisted or sent back to the browser. (For a free/local model, see +"Custom provider" below.) ### Custom OpenAI-compatible provider (e.g. a local/free endpoint) @@ -78,6 +80,17 @@ container is not otherwise reachable, and a Galaxy-controlled entry-point URL can't carry `LOOM_WEB_TOKEN`. The agent receives a **scoped** per-user key (`inject="api_key"`), never the user's personal key. +"Not otherwise reachable" is doing real work in that paragraph, and it's a +property of the deployment rather than of this container -- on a default Docker +bridge, every other container on the host can reach port 3000 directly, and the +WebSocket drives a live agent holding that scoped key. So in remote mode without +a token the server also requires the `Origin` header that browsers always send on +a WebSocket upgrade, and requires it to match `Host`. Browser clients through +gx-it-proxy are unaffected; an origin-less client connecting straight to the port +is refused. If you deploy this somewhere the container's port is genuinely shared, +that check is the only thing standing between a neighbour and the agent -- prefer +`LOOM_WEB_TOKEN` where the entry point can carry one. + ## Known requirements / gotchas (found in live testing) - **`TMPDIR=/tmp` in the command.** Galaxy injects the _host's_ `TMPDIR` into the From e76ecad04b84d20b22311e9a36fa531740816712 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Wed, 15 Jul 2026 15:34:11 -0400 Subject: [PATCH 27/30] Route provider keys by the canonical map instead of guessing 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. --- shared/brain-env.d.ts | 1 + shared/brain-env.js | 29 +++++++++---- web/llm-credentials.test.ts | 86 +++++++++++++++++++++++-------------- web/llm-credentials.ts | 58 +++++++++++++++---------- web/server.ts | 68 +++++++++++++++++++++++------ 5 files changed, 166 insertions(+), 76 deletions(-) diff --git a/shared/brain-env.d.ts b/shared/brain-env.d.ts index 5cd983f7..0c482f2b 100644 --- a/shared/brain-env.d.ts +++ b/shared/brain-env.d.ts @@ -1,5 +1,6 @@ export const BRAIN_ENV_PASSTHROUGH: ReadonlySet; export const BRAIN_ENV_PREFIXES: readonly string[]; +export const PROVIDER_KEY_VARS: Readonly>; export const PROVIDER_API_KEY_NAMES: ReadonlySet; export interface BuildBrainEnvOptions { diff --git a/shared/brain-env.js b/shared/brain-env.js index 5e9acdc4..2ece6ad6 100644 --- a/shared/brain-env.js +++ b/shared/brain-env.js @@ -46,19 +46,32 @@ export const BRAIN_ENV_PASSTHROUGH = new Set([ export const BRAIN_ENV_PREFIXES = ["LOOM_", "GALAXY_", "PI_"]; +// Built-in provider -> the env var its API key lives in. Mirrors the brain's +// PROVIDER_ENV_MAP (bin/loom.js / app/src/main/agent.ts). +// +// Don't try to derive these by uppercasing the provider name: google's key is +// GEMINI_API_KEY, not GOOGLE_API_KEY, so a name-derived guess silently fails to +// find a perfectly good key (and, worse, routes a supplied one somewhere the +// brain never reads). +export const PROVIDER_KEY_VARS = { + anthropic: "ANTHROPIC_API_KEY", + openai: "OPENAI_API_KEY", + google: "GEMINI_API_KEY", + mistral: "MISTRAL_API_KEY", + groq: "GROQ_API_KEY", + xai: "XAI_API_KEY", + deepseek: "DEEPSEEK_API_KEY", +}; + // Must stay a superset of the brain's built-in provider->env-key map // (PROVIDER_ENV_MAP in bin/loom.js / app/src/main/agent.ts): a provider key // that isn't listed here is dropped at this boundary, so in remote mode -- where // creds are env-only -- the brain would fail its credential check and refuse to -// launch. brain-env.test.ts guards the superset relationship. +// launch. brain-env.test.ts guards the superset relationship. Derived from the +// map above so the two can't drift; AI_GATEWAY_API_KEY has no provider name of +// its own (it's the brain's fallback var) so it's listed separately. export const PROVIDER_API_KEY_NAMES = new Set([ - "ANTHROPIC_API_KEY", - "OPENAI_API_KEY", - "GEMINI_API_KEY", - "MISTRAL_API_KEY", - "GROQ_API_KEY", - "XAI_API_KEY", - "DEEPSEEK_API_KEY", + ...Object.values(PROVIDER_KEY_VARS), "AI_GATEWAY_API_KEY", ]); diff --git a/web/llm-credentials.test.ts b/web/llm-credentials.test.ts index 0eeedeb5..7f28fde2 100644 --- a/web/llm-credentials.test.ts +++ b/web/llm-credentials.test.ts @@ -31,46 +31,66 @@ describe("hasProviderKey", () => { }); }); -// #330 regression: a custom OpenAI-compatible provider (gxit/README.md) carries -// its key in LOOM_ACTIVE_LLM_API_KEY, not ${PROVIDER}_API_KEY -- the brain -// resolves it from there (bin/loom.js resolveActiveLlmApiKey). Gating on the -// named var alone reported "no key" for a correctly-configured container, so the -// server never spawned the brain and the user got the BYO overlay instead. -describe("hasProviderKey with a custom provider", () => { - it("is true when LOOM_ACTIVE_LLM_API_KEY carries the key", () => { +// #330: providerKeyVar used to derive the var by uppercasing the provider name, +// which is wrong for google -- its key is GEMINI_API_KEY, not GOOGLE_API_KEY. +// The naive guess reported a perfectly good admin-injected GEMINI_API_KEY as +// absent, and sent a BYO Gemini key somewhere the brain never reads. +describe("providerKeyVar uses the canonical map, not the provider name", () => { + it("maps google to GEMINI_API_KEY", () => { + expect(providerKeyVar("google")).toBe("GEMINI_API_KEY"); + expect(providerKeyVar("google")).not.toBe("GOOGLE_API_KEY"); + }); + it("maps the rest of the built-ins", () => { + expect(providerKeyVar("deepseek")).toBe("DEEPSEEK_API_KEY"); + expect(providerKeyVar("mistral")).toBe("MISTRAL_API_KEY"); + expect(providerKeyVar("groq")).toBe("GROQ_API_KEY"); + }); + it("finds an admin-injected Gemini key for provider google", () => { + expect(hasProviderKey({ env: { GEMINI_API_KEY: "sk-g" }, provider: "google" })).toBe(true); + }); + it("routes a BYO google key to GEMINI_API_KEY", () => { + expect(llmKeyEnvVar("google")).toBe("GEMINI_API_KEY"); + }); +}); + +// A custom OpenAI-compatible provider (gxit/README.md) carries its key in +// LOOM_ACTIVE_LLM_API_KEY, not ${PROVIDER}_API_KEY -- the brain resolves it from +// there (resolveActiveLlmApiKey). Gating on the named var alone reported "no key" +// for a correctly-configured container, so the server never spawned the brain and +// showed the BYO overlay instead. +describe("custom providers", () => { + it("counts LOOM_ACTIVE_LLM_API_KEY when the provider is custom", () => { expect( - hasProviderKey({ env: { LOOM_ACTIVE_LLM_API_KEY: "sk-custom" }, provider: "myprov" }), + hasProviderKey({ + env: { LOOM_ACTIVE_LLM_API_KEY: "sk-custom" }, + provider: "myprov", + isCustom: true, + }), ).toBe(true); }); - it("is true even when the active provider name looks built-in", () => { - // The server defaults activeProvider() to "anthropic" when LOOM_LLM_PROVIDER - // is unset, while config.json names the real custom provider -- so the key - // must count regardless of the provider label the server happens to hold. + it("routes a custom provider's key to LOOM_ACTIVE_LLM_API_KEY", () => { + expect(llmKeyEnvVar("myprov", { isCustom: true })).toBe("LOOM_ACTIVE_LLM_API_KEY"); + }); + // Codex #10: a stale LOOM_ACTIVE_LLM_API_KEY must NOT vouch for a built-in + // provider. bin/loom.js only reads that var for baseUrl-carrying providers, so + // treating it as a key here suppressed the BYO prompt and spawned a brain that + // couldn't authenticate, with no way back to the prompt. + it("ignores LOOM_ACTIVE_LLM_API_KEY for a built-in provider", () => { expect( - hasProviderKey({ env: { LOOM_ACTIVE_LLM_API_KEY: "sk-custom" }, provider: "anthropic" }), - ).toBe(true); + hasProviderKey({ env: { LOOM_ACTIVE_LLM_API_KEY: "stale" }, provider: "anthropic" }), + ).toBe(false); }); - it("is false when LOOM_ACTIVE_LLM_API_KEY is empty", () => { - expect(hasProviderKey({ env: { LOOM_ACTIVE_LLM_API_KEY: "" }, provider: "myprov" })).toBe( - false, - ); + it("is false when a custom provider has no key", () => { + expect(hasProviderKey({ env: {}, provider: "myprov", isCustom: true })).toBe(false); }); }); -describe("llmKeyEnvVar", () => { - it("routes a built-in provider to its own key var", () => { - expect(llmKeyEnvVar("anthropic")).toBe("ANTHROPIC_API_KEY"); - }); - it("routes an unknown/custom provider to LOOM_ACTIVE_LLM_API_KEY", () => { - // Mirrors Orbit desktop (app/src/main/agent.ts): custom endpoints route - // through LOOM_ACTIVE_LLM_API_KEY. Returning null here meant startLoom - // injected nothing and spawned an unauthenticated brain. - expect(llmKeyEnvVar("myprov")).toBe("LOOM_ACTIVE_LLM_API_KEY"); - expect(llmKeyEnvVar("openai-compatible")).toBe("LOOM_ACTIVE_LLM_API_KEY"); - }); - it("never returns null, so a supplied key always lands somewhere", () => { - for (const p of ["anthropic", "myprov", "", "openai"]) { - expect(typeof llmKeyEnvVar(p)).toBe("string"); - } +describe("llmKeyEnvVar refuses to guess", () => { + // Codex #2: the overlay can send "openai-compatible", which is not built in and + // has no config entry in a stock container. There is nowhere to put the key, so + // say so rather than inject it somewhere inert and report success. + it("returns null for an unknown, non-custom provider", () => { + expect(llmKeyEnvVar("openai-compatible")).toBeNull(); + expect(llmKeyEnvVar("myprov")).toBeNull(); }); }); diff --git a/web/llm-credentials.ts b/web/llm-credentials.ts index e50f4c04..565e26fe 100644 --- a/web/llm-credentials.ts +++ b/web/llm-credentials.ts @@ -1,27 +1,36 @@ -import { PROVIDER_API_KEY_NAMES } from "../shared/brain-env.js"; +import { PROVIDER_KEY_VARS } from "../shared/brain-env.js"; import { ACTIVE_LLM_API_KEY_ENV } from "../shared/custom-provider.js"; -/** The env-var name a provider's API key lives in, or null if unknown. */ +/** The env-var name a built-in provider's API key lives in, or null if unknown. */ export function providerKeyVar(provider: string): string | null { - const v = `${provider.toUpperCase()}_API_KEY`; - return (PROVIDER_API_KEY_NAMES as Set).has(v) ? v : null; + return PROVIDER_KEY_VARS[provider.toLowerCase()] ?? null; +} + +export interface ProviderShape { + /** + * The provider is an OpenAI-compatible custom endpoint -- i.e. the container's + * config.json gives it a baseUrl. That, not the provider's name, is the + * discriminator everywhere (isCustomProvider in shared/custom-provider.js). + */ + isCustom?: boolean; } /** - * The env var to hand a key to the brain under. Built-in providers use their own - * ${PROVIDER}_API_KEY; anything else is an OpenAI-compatible custom endpoint, - * which the brain authenticates from LOOM_ACTIVE_LLM_API_KEY (see - * resolveActiveLlmApiKey in shared/custom-provider.js). Mirrors how Orbit - * desktop picks the target var in app/src/main/agent.ts. + * The env var to hand this provider's key to the brain under, or null when we + * can't route it anywhere the brain will read. * - * Unlike providerKeyVar this never returns null: a key with nowhere to go meant - * the server silently spawned an unauthenticated brain. + * Mirrors how Orbit desktop picks the target var (app/src/main/agent.ts): custom + * endpoints authenticate from LOOM_ACTIVE_LLM_API_KEY, built-in providers from + * their own var. Null is a real answer callers must handle -- injecting a key + * under a name nothing reads spawns an unauthenticated brain that dies at its + * first request, which is worse than refusing the key up front. */ -export function llmKeyEnvVar(provider: string): string { - return providerKeyVar(provider) ?? ACTIVE_LLM_API_KEY_ENV; +export function llmKeyEnvVar(provider: string, { isCustom }: ProviderShape = {}): string | null { + if (isCustom) return ACTIVE_LLM_API_KEY_ENV; + return providerKeyVar(provider); } -export interface KeyPresenceInput { +export interface KeyPresenceInput extends ProviderShape { env: NodeJS.ProcessEnv; provider: string; providedKey?: string | null; @@ -32,14 +41,17 @@ function nonEmpty(v: string | undefined): boolean { } /** Is a usable provider key available -- either supplied at runtime or in env? */ -export function hasProviderKey({ env, provider, providedKey }: KeyPresenceInput): boolean { +export function hasProviderKey({ + env, + provider, + providedKey, + isCustom, +}: KeyPresenceInput): boolean { if (nonEmpty(providedKey ?? undefined)) return true; - const v = providerKeyVar(provider); - if (v != null && nonEmpty(env[v])) return true; - // A custom endpoint's key. Checked regardless of the provider label: the - // server defaults activeProvider() to "anthropic" when LOOM_LLM_PROVIDER is - // unset, while the custom provider is named only in the container's - // config.json -- which the server never reads in remote mode. Gating this on - // the label would put a correctly-configured GxIT back behind the BYO overlay. - return nonEmpty(env[ACTIVE_LLM_API_KEY_ENV]); + // Only a custom provider reads LOOM_ACTIVE_LLM_API_KEY. Counting it for a + // built-in provider would suppress the BYO prompt on the strength of a key the + // brain never looks at -- a stale one left in the job env, say -- and strand the + // user in front of an agent that can't authenticate. + const v = llmKeyEnvVar(provider, { isCustom }); + return v != null && nonEmpty(env[v]); } diff --git a/web/server.ts b/web/server.ts index 7dfdbafc..3df79e8b 100644 --- a/web/server.ts +++ b/web/server.ts @@ -20,6 +20,7 @@ import { WebSocketServer, WebSocket } from "ws"; import { buildBrainEnv } from "../shared/brain-env.js"; import { evaluateBind, authorizeWsUpgrade } from "./auth.js"; import { isForwardableUiResponse } from "./rpc-guard.js"; +import { isCustomProvider } from "../shared/custom-provider.js"; import { hasProviderKey, llmKeyEnvVar } from "./llm-credentials.js"; import { resolveShutdownGraceMs } from "./shutdown-grace.js"; @@ -123,14 +124,38 @@ let providedLlmKey: string | null = null; let providedProvider: string | null = null; function activeProvider(): string { - return providedProvider ?? process.env.LOOM_LLM_PROVIDER ?? "anthropic"; + if (providedProvider) return providedProvider; + if (process.env.LOOM_LLM_PROVIDER) return process.env.LOOM_LLM_PROVIDER; + // Fall back to the container's own config.json, which is what the brain reads. + // A custom-endpoint image (gxit/README.md) names its provider only there, so + // defaulting straight to "anthropic" made the server disagree with the brain + // about which provider is even active. + const active = (loadConfig().llm as { active?: unknown } | undefined)?.active; + if (typeof active === "string" && active.length > 0) return active; + return "anthropic"; +} + +/** + * Does the container's config.json describe this provider as an OpenAI-compatible + * custom endpoint? That baseUrl is what makes the brain authenticate the provider + * from LOOM_ACTIVE_LLM_API_KEY instead of a named var, so it's also what decides + * where a key may go -- guessing from the provider's name instead would either + * strand a configured container behind the BYO overlay or let a stale key vouch + * for a provider that can't use it. + */ +function isCustomProviderName(provider: string): boolean { + const providers = (loadConfig().llm as { providers?: Record } | undefined) + ?.providers; + return isCustomProvider(providers?.[provider] as never); } function llmKeyPresent(): boolean { + const provider = activeProvider(); return hasProviderKey({ env: process.env, - provider: activeProvider(), + provider, providedKey: providedLlmKey, + isCustom: isCustomProviderName(provider), }); } @@ -164,9 +189,13 @@ function startLoom(): void { } // BYO-key: inject the user-supplied key into the brain's env (env var name // per the active provider; custom endpoints go to LOOM_ACTIVE_LLM_API_KEY). - // Never logged. + // Never logged. The provide-llm-key handler rejects keys it can't route, so + // by here a non-null var is expected -- but don't spawn a brain that silently + // drops the credential if that ever stops being true. if (providedLlmKey) { - env[llmKeyEnvVar(prov)] = providedLlmKey; + const keyVar = llmKeyEnvVar(prov, { isCustom: isCustomProviderName(prov) }); + if (keyVar) env[keyVar] = providedLlmKey; + else log("refusing to inject a key for unroutable provider:", prov); } env.LOOM_NOTEBOOK_ALLOWLIST = join(cwd, "notebook.md"); // No local execution surface in the container: the web-mode-gate is the @@ -398,16 +427,31 @@ wss.on("connection", (socket) => { } if (channel === "agent:provide-llm-key") { const payload = (args[0] ?? {}) as { provider?: unknown; key?: unknown }; - if (typeof payload.key === "string" && payload.key.length > 0) { - providedLlmKey = payload.key; // never logged - if (typeof payload.provider === "string" && payload.provider.length > 0) { - providedProvider = payload.provider; - } - if (!loomProcess) startLoom(); - respond(id, { ok: true }); - } else { + if (typeof payload.key !== "string" || payload.key.length === 0) { respond(id, { ok: false, error: "missing provider key" }); + return; + } + const provider = + typeof payload.provider === "string" && payload.provider.length > 0 + ? payload.provider + : activeProvider(); + // Refuse a key we have nowhere to put. The overlay offers + // "openai-compatible", but a custom endpoint also needs a baseUrl, and + // remote mode's config is read-only -- so unless this image was built with + // that provider in its config.json, there's no endpoint to talk to. Saying + // so beats accepting the key, reporting success, and leaving the user with + // an agent that can't authenticate and no way back to this prompt. + if (!llmKeyEnvVar(provider, { isCustom: isCustomProviderName(provider) })) { + respond(id, { + ok: false, + error: `Can't use a key for "${provider}" here: this deployment has no endpoint configured for it. Pick a built-in provider, or ask your admin to bake the endpoint into the image.`, + }); + return; } + providedLlmKey = payload.key; // never logged + providedProvider = provider; + if (!loomProcess) startLoom(); + respond(id, { ok: true }); return; } if (channel === "agent:get-cwd") { From 1b2ee043f826836aeabf20b000c490b51e61c5ab Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Wed, 15 Jul 2026 15:34:13 -0400 Subject: [PATCH 28/30] Serialize brain swaps and hold prompts while one is draining 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. --- web/server.ts | 72 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 58 insertions(+), 14 deletions(-) diff --git a/web/server.ts b/web/server.ts index 3df79e8b..c420b3d3 100644 --- a/web/server.ts +++ b/web/server.ts @@ -304,11 +304,55 @@ function stopLoomGracefully(timeoutMs: number): Promise { }); } +// A restart/reset now drains the outgoing brain before spawning its replacement, +// which opens a window where loomProcess is a brain under SIGTERM. Writing to it +// would look like it worked -- the pipe is still writable -- and the message would +// die with the process, leaving the UI waiting on a turn nobody is running. Hold +// messages instead and hand them to the replacement once it's up. +let draining = false; +const drainQueue: Record[] = []; + function sendToLoom(obj: Record): void { + if (draining) { + drainQueue.push(obj); + return; + } if (!loomProcess?.stdin?.writable) return; loomProcess.stdin.write(JSON.stringify(obj) + "\n"); } +function flushDrainQueue(): void { + const queued = drainQueue.splice(0, drainQueue.length); + for (const obj of queued) sendToLoom(obj); +} + +/** + * Run a brain swap with nothing else swapping underneath it. Two restarts landing + * together used to await the same dying process and then both spawn, so the + * second startLoom would SIGKILL the first replacement through the non-graceful + * path -- and a reset racing a restart could lose its fresh-session semantics + * entirely. + */ +let swapChain: Promise = Promise.resolve(); +function serializeSwap(fn: () => Promise): Promise { + const next = swapChain.then(fn, fn); + // Keep the chain alive regardless of outcome; errors surface via the handler. + swapChain = next.catch(() => {}); + return next; +} + +/** Drain the current brain, then spawn its replacement (startLoom by default). */ +async function respawnBrain(spawnReplacement: () => void = startLoom): Promise { + draining = true; + try { + await stopLoomGracefully(resolveShutdownGraceMs(process.env)); + spawnReplacement(); + } finally { + draining = false; + } + flushDrainQueue(); +} + function sendEvent(event: string, ...payload: unknown[]): void { if (!activeSocket || activeSocket.readyState !== WebSocket.OPEN) return; activeSocket.send( @@ -477,25 +521,25 @@ wss.on("connection", (socket) => { // page the old one hadn't finished writing, or the two pushes could land // last-writer-wins. Draining first makes the handoff ordered. if (channel === "agent:restart") { - void (async () => { - await stopLoomGracefully(resolveShutdownGraceMs(process.env)); - startLoom(); + void serializeSwap(async () => { + await respawnBrain(); respond(id, null); - })(); + }); return; } if (channel === "agent:reset-session") { - void (async () => { - await stopLoomGracefully(resolveShutdownGraceMs(process.env)); - // Fresh start — tell loom not to auto-load notebook. Set around the - // synchronous spawn only: startLoom reads process.env at spawn time. - const origEnv = process.env.LOOM_FRESH_SESSION; - process.env.LOOM_FRESH_SESSION = "1"; - startLoom(); - if (origEnv === undefined) delete process.env.LOOM_FRESH_SESSION; - else process.env.LOOM_FRESH_SESSION = origEnv; + void serializeSwap(async () => { + await respawnBrain(() => { + // Fresh start — tell loom not to auto-load notebook. Set around the + // synchronous spawn only: startLoom reads process.env at spawn time. + const origEnv = process.env.LOOM_FRESH_SESSION; + process.env.LOOM_FRESH_SESSION = "1"; + startLoom(); + if (origEnv === undefined) delete process.env.LOOM_FRESH_SESSION; + else process.env.LOOM_FRESH_SESSION = origEnv; + }); respond(id, null); - })(); + }); return; } if (channel === "agent:new-session") { From 5612bd09610d5152f40317f04b7a3fcafed94af3 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Wed, 15 Jul 2026 15:34:33 -0400 Subject: [PATCH 29/30] Harden page-sync recovery against Codex's counter-examples 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. --- extensions/loom/galaxy-page-sync.test.ts | 138 ++++++++++++++++++++++- extensions/loom/galaxy-page-sync.ts | 102 +++++++++++------ 2 files changed, 201 insertions(+), 39 deletions(-) diff --git a/extensions/loom/galaxy-page-sync.test.ts b/extensions/loom/galaxy-page-sync.test.ts index 8c70fbaa..42098b0a 100644 --- a/extensions/loom/galaxy-page-sync.test.ts +++ b/extensions/loom/galaxy-page-sync.test.ts @@ -187,7 +187,14 @@ describe("createPageSyncEngine recovery", () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); + // Cooldown is rate-limiting, not a budget: pass now() so tests control it. + function makeClock() { + let t = 0; + return { now: () => t, advance: (ms: number) => (t += ms) }; + } + it("re-arms on a later change when the history lookup failed at launch", async () => { + const clock = makeClock(); let attempts = 0; const deps = makeDeps({ getHistoryId: async () => { @@ -196,11 +203,14 @@ describe("createPageSyncEngine recovery", () => { return "h1"; }, findPageId: vi.fn(async () => null), // no prior page: local notebook is authoritative + now: clock.now, + retryCooldownMs: 1000, }); const engine = createPageSyncEngine(deps); await engine.init(); expect(deps.pushes).toHaveLength(0); + clock.advance(2000); // past the cooldown (deps as unknown as { setBody: (b: string) => void }).setBody("written while galaxy was down"); deps.fire(); await vi.advanceTimersByTimeAsync(150); @@ -219,6 +229,7 @@ describe("createPageSyncEngine recovery", () => { return "h1"; }, findPageId: vi.fn(async () => null), + now: makeClock().now, // clock never advances: flush must force past the cooldown }); const engine = createPageSyncEngine(deps); await engine.init(); @@ -232,24 +243,53 @@ describe("createPageSyncEngine recovery", () => { expect(deps.pushes[0].historyId).toBe("h1"); }); - it("gives up after maxInitAttempts so a dead Galaxy isn't retried forever", async () => { + // Codex #9: a hard attempt cap burned its whole budget on a few debounced edits + // during one short outage, then never synced again -- the exact silent data loss + // the retry was added to prevent. Rate-limit instead of counting. + it("keeps retrying after many failures, once the cooldown passes", async () => { + const clock = makeClock(); + let attempts = 0; + const deps = makeDeps({ + getHistoryId: async () => { + attempts++; + if (attempts <= 8) throw new Error("galaxy down"); + return "h1"; + }, + findPageId: vi.fn(async () => null), + now: clock.now, + retryCooldownMs: 1000, + }); + const engine = createPageSyncEngine(deps); + await engine.init(); + for (let i = 0; i < 8; i++) { + clock.advance(1500); + (deps as unknown as { setBody: (b: string) => void }).setBody(`body-${i}`); + deps.fire(); + await vi.advanceTimersByTimeAsync(150); + } + expect(deps.pushes).toHaveLength(1); // recovered on the 9th attempt + }); + + it("rate-limits retries so a dead Galaxy isn't hit on every keystroke", async () => { + const clock = makeClock(); let attempts = 0; const deps = makeDeps({ getHistoryId: async () => { attempts++; throw new Error("galaxy down"); }, - maxInitAttempts: 3, + now: clock.now, + retryCooldownMs: 30000, }); const engine = createPageSyncEngine(deps); await engine.init(); // attempt 1 for (let i = 0; i < 6; i++) { + clock.advance(100); // well inside the cooldown (deps as unknown as { setBody: (b: string) => void }).setBody(`body-${i}`); deps.fire(); await vi.advanceTimersByTimeAsync(150); } - expect(attempts).toBe(3); - expect(deps.pushes).toHaveLength(0); + expect(attempts).toBe(1); }); it("times out a hanging history request instead of stalling session_start", async () => { @@ -263,6 +303,19 @@ describe("createPageSyncEngine recovery", () => { await expect(done).resolves.toBeUndefined(); }); + // Codex #4: history and listing were timed out but resume wasn't, so a page GET + // that accepted the connection and never answered still hung session_start. + it("times out a hanging page resume", async () => { + const deps = makeDeps({ + resume: vi.fn(() => new Promise(() => {})), // never settles + initTimeoutMs: 5000, + }); + const engine = createPageSyncEngine(deps); + const done = engine.init(); + await vi.advanceTimersByTimeAsync(5001); + await expect(done).resolves.toBeUndefined(); + }); + it("does not publish a page for an untouched notebook", async () => { const deps = makeDeps({ findPageId: vi.fn(async () => null) }); (deps as unknown as { setBody: (b: string) => void }).setBody(""); @@ -274,6 +327,19 @@ describe("createPageSyncEngine recovery", () => { expect(deps.pushes).toHaveLength(0); }); + // Codex #5: skipping every empty body meant a user who deliberately cleared the + // notebook could never sync that -- Galaxy kept the stale content and restored + // it on the next launch. + it("pushes a deliberate clear of a resumed page", async () => { + const deps = makeDeps(); // resumes page-h1, baseline "first" + const engine = createPageSyncEngine(deps); + await engine.init(); + (deps as unknown as { setBody: (b: string) => void }).setBody(""); + deps.fire(); + await vi.advanceTimersByTimeAsync(150); + expect(deps.pushes).toHaveLength(1); + }); + it("still skips a redundant push right after resuming an existing page", async () => { // resume() rewrites the notebook, which fires the watcher; that self-trigger // must not bounce straight back to Galaxy as a no-op push. @@ -284,4 +350,68 @@ describe("createPageSyncEngine recovery", () => { await vi.advanceTimersByTimeAsync(150); expect(deps.pushes).toHaveLength(0); }); + + // Codex #6: historyId was committed before page discovery, so one failed + // listing left the engine armed but blind to an existing page -- the next push + // took the create path and duplicated it, and discovery never re-ran. + it("stays unarmed when page discovery fails, then adopts the page on retry", async () => { + const clock = makeClock(); + let lists = 0; + const deps = makeDeps({ + findPageId: vi.fn(async () => { + lists++; + if (lists === 1) throw new Error("503"); + return "page-h1"; + }), + now: clock.now, + retryCooldownMs: 1000, + }); + const engine = createPageSyncEngine(deps); + await engine.init(); + expect(deps.resume).not.toHaveBeenCalled(); + + (deps as unknown as { setBody: (b: string) => void }).setBody("edited"); + deps.fire(); + await vi.advanceTimersByTimeAsync(150); + // Cooldown still running: must not create a rival page for the same history. + expect(deps.pushes).toHaveLength(0); + + clock.advance(2000); + deps.fire(); + await vi.advanceTimersByTimeAsync(150); + expect(deps.resume).toHaveBeenCalledWith("page-h1"); // adopted, not duplicated + }); + + it("stays unarmed when resume of an existing page fails", async () => { + const deps = makeDeps({ + resume: vi.fn(async () => { + throw new Error("boom"); + }), + }); + const engine = createPageSyncEngine(deps); + await engine.init(); + (deps as unknown as { setBody: (b: string) => void }).setBody("edited"); + deps.fire(); + await vi.advanceTimersByTimeAsync(150); + // Pushing here would overwrite a page we failed to read. + expect(deps.pushes).toHaveLength(0); + }); + + // Codex #3: an arm() in flight when the engine is disposed used to land anyway, + // and resume() resolves the notebook path globally -- so a stale engine could + // overwrite the NEXT session's notebook with its own page. + it("does not resume after dispose", async () => { + let release: (v: string) => void = () => {}; + const deps = makeDeps({ + getHistoryId: () => new Promise((r) => (release = r)), + }); + const engine = createPageSyncEngine(deps); + const done = engine.init(); + engine.dispose(); + release("h1"); + await done; + await vi.advanceTimersByTimeAsync(50); + expect(deps.resume).not.toHaveBeenCalled(); + expect(deps.pushes).toHaveLength(0); + }); }); diff --git a/extensions/loom/galaxy-page-sync.ts b/extensions/loom/galaxy-page-sync.ts index 33c7174f..5faea364 100644 --- a/extensions/loom/galaxy-page-sync.ts +++ b/extensions/loom/galaxy-page-sync.ts @@ -50,15 +50,18 @@ export interface PageSyncDeps { */ initTimeoutMs?: number; /** - * How many times to try resolving the history before giving up for good. - * Attempts are lazy -- one per change event -- so a Galaxy that's briefly down - * at launch costs a retry, not the session's durability. + * Don't re-attempt arming more often than this. Rate, not a total: a count cap + * would burn its whole budget on a handful of debounced edits during one short + * outage and then never sync again, which is the failure this is here to + * prevent. flush() ignores the cooldown -- shutdown is the last chance to save. */ - maxInitAttempts?: number; + retryCooldownMs?: number; + /** Injectable clock so the cooldown is testable without wall time. */ + now?: () => number; } const DEFAULT_INIT_TIMEOUT_MS = 15000; -const DEFAULT_MAX_INIT_ATTEMPTS = 5; +const DEFAULT_RETRY_COOLDOWN_MS = 30000; class TimeoutError extends Error {} @@ -88,26 +91,29 @@ export function createPageSyncEngine(deps: PageSyncDeps) { let timer: ReturnType | null = null; let unsubscribe: (() => void) | null = null; let active = false; - let initAttempts = 0; + let disposed = false; + let lastAttemptAt: number | null = null; const initTimeoutMs = deps.initTimeoutMs ?? DEFAULT_INIT_TIMEOUT_MS; - const maxInitAttempts = deps.maxInitAttempts ?? DEFAULT_MAX_INIT_ATTEMPTS; + const retryCooldownMs = deps.retryCooldownMs ?? DEFAULT_RETRY_COOLDOWN_MS; + const now = deps.now ?? (() => Date.now()); // Serialize pushes: at most one in flight; a change arriving mid-push queues // exactly one follow-up so the latest body still lands. flush() awaits this. let inFlight: Promise | null = null; let pendingAgain = false; - async function doPush(): Promise { + async function doPush(force = false): Promise { // A launch-time blip leaves us unarmed; retry here so the first change after // Galaxy comes back recovers sync instead of the session running to its end // with a notebook that only exists under /tmp. - if (!historyId && !(await arm())) return; - if (!historyId) return; + if (!historyId && !(await arm(force))) return; + if (disposed || !historyId) return; const body = await deps.readBody(); if (!hasBodyChanged(lastBody, body)) return; // dedupe + self-trigger guard - // Never create/overwrite a page with an empty body: an untouched notebook - // has nothing worth persisting, and lastBody is null until a resume gives us - // a real baseline, so without this a no-op session would publish a blank page. - if (body.length === 0) return; + // Don't CREATE a page out of an empty notebook -- an untouched session has + // nothing worth publishing, and the baseline is null until a resume gives us + // a real one. Clearing a notebook we do have a baseline for is a real edit, + // though, so let that through rather than stranding deleted content in Galaxy. + if (body.length === 0 && lastBody === null) return; await deps.push({ historyId, slug, title: pageTitleForHistory(historyId) }); lastBody = body; } @@ -117,7 +123,7 @@ export function createPageSyncEngine(deps: PageSyncDeps) { // concurrent pushes (which raced on lastBody and could double-create the // page), and the latest body always wins. Returns the in-flight promise so // flush() can await whatever is already running plus the queued follow-up. - function requestPush(): Promise { + function requestPush(force = false): Promise { if (inFlight) { pendingAgain = true; return inFlight; @@ -126,7 +132,7 @@ export function createPageSyncEngine(deps: PageSyncDeps) { try { do { pendingAgain = false; - await doPush(); + await doPush(force); } while (pendingAgain); } finally { inFlight = null; @@ -137,49 +143,68 @@ export function createPageSyncEngine(deps: PageSyncDeps) { /** * Resolve the history and adopt any prior page for it. Returns true once the - * engine can push. Safe to call repeatedly: it no-ops once armed and stops - * trying after maxInitAttempts so a permanently unreachable Galaxy doesn't - * mean a Galaxy call on every change event for the rest of the session. + * engine can push. Safe to call repeatedly: it no-ops once armed, and a failed + * attempt won't retry until the cooldown passes (force skips the wait). + * + * Nothing is committed until discovery has actually succeeded. Committing + * historyId first, as this used to, meant a single failed page listing left the + * engine armed but unaware of an existing page -- the next push would then take + * the create path and either duplicate the page or fight its slug forever, with + * no way to re-run discovery. */ - async function arm(): Promise { + async function arm(force = false): Promise { if (historyId) return true; - if (initAttempts >= maxInitAttempts) return false; - initAttempts++; + if (!force && lastAttemptAt !== null && now() - lastAttemptAt < retryCooldownMs) return false; + lastAttemptAt = now(); - let resolved: string | null; + let resolvedHistory: string | null; try { - resolved = await withTimeout(deps.getHistoryId(), initTimeoutMs, "history lookup"); + resolvedHistory = await withTimeout(deps.getHistoryId(), initTimeoutMs, "history lookup"); } catch (err) { console.error("[page-sync] history lookup failed:", err); return false; } - if (!resolved) return false; + if (disposed || !resolvedHistory) return false; - historyId = resolved; - slug = pageSlugForHistory(historyId); + const resolvedSlug = pageSlugForHistory(resolvedHistory); // Galaxy's GET /pages/{id} takes a page id, not a slug, so resolve the // per-history page id by listing the history's pages and matching the // derived slug, then resume by id. A fresh container has no local binding, // so listing is the only way to rediscover the page. - let existingPageId: string | null = null; + let existingPageId: string | null; try { existingPageId = await withTimeout( - deps.findPageId(historyId, slug), + deps.findPageId(resolvedHistory, resolvedSlug), initTimeoutMs, "page listing", ); - } catch { - /* listing failed — treat as no prior page; created on first push */ + } catch (err) { + // Can't tell "no prior page" from "couldn't ask". Assuming the former is + // how you end up with two pages for one history, so stay unarmed and retry. + console.error("[page-sync] page listing failed:", err); + return false; } + if (disposed) return false; + let resumed = false; if (existingPageId) { try { - await deps.resume(existingPageId); // refresh notebook from the prior page + // Timed out like the others: init() runs inside session_start, and a page + // GET that accepts the connection then never answers would hang startup. + await withTimeout(deps.resume(existingPageId), initTimeoutMs, "page resume"); resumed = true; } catch (err) { + // The page exists but we couldn't read it. Pushing now would overwrite it + // with a notebook that never saw its content, so leave the engine unarmed + // and try the whole adoption again later. console.error("[page-sync] resume failed:", err); + return false; } } + if (disposed) return false; + + historyId = resolvedHistory; + slug = resolvedSlug; // Baseline only from a resume, where the notebook now mirrors the page and // the write we just made would otherwise self-trigger a pointless push. // Without a resume the local notebook is the authoritative copy, so leave @@ -206,13 +231,15 @@ export function createPageSyncEngine(deps: PageSyncDeps) { } async function flush(): Promise { - if (!active) return; + if (!active || disposed) return; if (timer) { clearTimeout(timer); timer = null; } try { - await requestPush(); + // force: shutdown is the last chance to get this into Galaxy, so don't sit + // out a retry cooldown that happens to still be running. + await requestPush(true); } catch (err) { console.error("[page-sync] flush push failed:", err); } @@ -224,10 +251,15 @@ export function createPageSyncEngine(deps: PageSyncDeps) { if (unsubscribe) unsubscribe(); unsubscribe = null; active = false; + // An arm() started before dispose can still be waiting on Galaxy, and it + // resolves against whatever notebook is current by then -- a later session's. + // Its resume would overwrite that session's notebook with this one's page, so + // the checks after each await bail on a disposed engine. + disposed = true; historyId = null; lastBody = null; pendingAgain = false; - initAttempts = 0; + lastAttemptAt = null; } return { init, flush, dispose }; From 9e9e7e1621bc14f8dfb61f640f5eddf279b842b7 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Wed, 15 Jul 2026 15:34:35 -0400 Subject: [PATCH 30/30] Make the galaxy-mcp lockstep unbypassable 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. --- Dockerfile | 9 +++++---- tests/galaxy-mcp-spec.test.ts | 15 +++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Dockerfile b/Dockerfile index c3212284..f6ad79b4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -92,9 +92,10 @@ USER node # This MUST stay the spec bin/loom.js writes into mcp.json (GALAXY_MCP_SPEC in # shared/galaxy-mcp-spec.js) -- pre-warming a version the runtime spec doesn't # accept sends uv back to the network at job-launch, which is exactly what -# baking the cache is meant to avoid. tests/galaxy-mcp-spec.test.ts fails the -# build if these two drift apart. -ARG GALAXY_MCP_SPEC="galaxy-mcp>=1.9.0" -RUN uv tool install "${GALAXY_MCP_SPEC}" +# baking the cache is meant to avoid. tests/galaxy-mcp-spec.test.ts fails if +# these two drift apart, so it's a literal rather than a build arg: an ARG would +# let --build-arg put a non-matching spec in the cache while the test still +# passed against its default, which is the bypass the test exists to prevent. +RUN uv tool install "galaxy-mcp>=1.9.0" CMD ["node", "web/build/server.js"] diff --git a/tests/galaxy-mcp-spec.test.ts b/tests/galaxy-mcp-spec.test.ts index fe2d2ccc..d91e14db 100644 --- a/tests/galaxy-mcp-spec.test.ts +++ b/tests/galaxy-mcp-spec.test.ts @@ -17,17 +17,16 @@ const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); describe("galaxy-mcp spec lockstep", () => { it("Dockerfile pre-warms exactly the spec the brain requests", () => { const dockerfile = readFileSync(join(repoRoot, "Dockerfile"), "utf-8"); - const arg = dockerfile.match(/^ARG GALAXY_MCP_SPEC="([^"]+)"$/m); - expect(arg, 'Dockerfile should declare ARG GALAXY_MCP_SPEC="..."').not.toBeNull(); - expect(arg![1]).toBe(GALAXY_MCP_SPEC); + // Deliberately a literal, not an ARG: a build arg could be overridden at + // build time to bake a spec the runtime won't accept while this test still + // passed against the default -- the exact drift it's here to catch. + expect(dockerfile).toContain(`RUN uv tool install "${GALAXY_MCP_SPEC}"`); }); - it("installs from the ARG rather than a second hardcoded spec", () => { + it("has no second, unpaired galaxy-mcp install in the image", () => { const dockerfile = readFileSync(join(repoRoot, "Dockerfile"), "utf-8"); - expect(dockerfile).toContain('RUN uv tool install "${GALAXY_MCP_SPEC}"'); - // A literal `uv tool install "galaxy-mcp..."` would dodge the ARG (and this - // test's pairing) even while the ARG stays correct. - expect(dockerfile).not.toMatch(/uv tool install "galaxy-mcp/); + const installs = dockerfile.match(/uv tool install "[^"]*"/g) ?? []; + expect(installs).toEqual([`uv tool install "${GALAXY_MCP_SPEC}"`]); }); it("bin/loom.js writes the shared constant into mcp.json, not a literal", () => {