Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
08d34a2
Add galaxyGetMostRecentHistory for resolving the current history
dannon Jun 18, 2026
fb3a578
Add pure helpers for env-gated Galaxy page sync
dannon Jun 18, 2026
1f5351f
Add the env-gated Galaxy page-sync engine (resume/debounce/flush)
dannon Jun 18, 2026
5929ffd
Wire Galaxy page sync into session start/shutdown
dannon Jun 18, 2026
97619f9
Enable page sync in remote mode and drain the brain on shutdown
dannon Jun 18, 2026
23f640f
Resume the per-history page by id, not slug
dannon Jun 18, 2026
7995a8e
Add the Galaxy interactive tool wrapper + deploy guide for Orbit
dannon Jun 18, 2026
71532ed
Ship web/auth.ts and web/rpc-guard.ts in the runner image
dannon Jun 18, 2026
22d0886
GxIT: force TMPDIR=/tmp and document the uvx requirement
dannon Jun 18, 2026
d05749d
Bundle and pre-warm galaxy-mcp (uvx) in the runtime image
dannon Jun 18, 2026
50e1872
Let the remote gate reach galaxy-mcp through the mcp proxy
dannon Jun 18, 2026
7048122
Add pure LLM-credential presence helpers for the web shell
dannon Jun 19, 2026
b920143
Hold a user-provided LLM key server-side and spawn the brain with it
dannon Jun 19, 2026
ad7a26e
Add provideLlmKey to the web shim and shared API type
dannon Jun 19, 2026
f7611b0
Prompt for a BYO LLM key in remote mode when none is injected
dannon Jun 19, 2026
404d236
Serialize Galaxy page-sync pushes so flush can't overlap one in flight
dannon Jun 19, 2026
715d8d7
Guard LOOM_SHUTDOWN_GRACE_MS against a non-numeric override
dannon Jun 19, 2026
bf1e3ad
make Docker container executable
bgruening Jun 21, 2026
8f829b7
Fix compiled-container runtime paths so the GxIT actually boots
dannon Jul 8, 2026
c3cd5da
Bundle the web server so the container image actually builds
dannon Jul 15, 2026
74dabb7
Pair the galaxy-mcp pre-warm with the spec the brain requests
dannon Jul 15, 2026
afd803c
Spawn the brain for custom OpenAI-compatible providers too
dannon Jul 15, 2026
2191e11
Drain the old brain before restart/reset spawns a new one
dannon Jul 15, 2026
619016c
Require an Origin on WS upgrades in insecure remote mode
dannon Jul 15, 2026
c466739
Stop a launch-time Galaxy blip from killing page sync for the session
dannon Jul 15, 2026
44803c5
Bring the GxIT deploy guide in line with what the PR ships
dannon Jul 15, 2026
e76ecad
Route provider keys by the canonical map instead of guessing
dannon Jul 15, 2026
1b2ee04
Serialize brain swaps and hold prompts while one is draining
dannon Jul 15, 2026
5612bd0
Harden page-sync recovery against Codex's counter-examples
dannon Jul 15, 2026
9e9e7e1
Make the galaxy-mcp lockstep unbypassable
dannon Jul 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
83 changes: 69 additions & 14 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,33 @@
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 ./
COPY app/package.json app/package-lock.json ./app/
COPY web/package.json web/package-lock.json ./web/
RUN npm ci
RUN cd app && npm ci
COPY web/package.json web/package-lock.json ./web/
RUN cd web && npm ci

COPY . .
RUN cd web && npm run build
COPY CHANGELOG.md README.md ./
COPY bin ./bin
COPY extensions ./extensions
COPY shared ./shared
COPY app ./app
COPY web ./web
# 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

Expand All @@ -23,24 +41,61 @@ 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/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

# 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
# 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

CMD ["node", "web/node_modules/.bin/tsx", "web/server.ts"]
# Pre-warm galaxy-mcp (and the managed Python it needs) into the node-owned uv
# 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 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"]
4 changes: 4 additions & 0 deletions app/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ export interface OrbitAPI {
saveConfig(config: Record<string, unknown>): Promise<{ success: boolean; error?: string }>;
refreshSkills: () => Promise<{ ok: boolean; error?: string }>;
getGalaxyUser(): Promise<import("../main/galaxy-user.js").GalaxyUserStatus>;
// 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<unknown>;
setBypassPermissions(
enabled: boolean,
): Promise<{ ok: boolean; enabled: boolean; cancelled?: boolean }>;
Expand Down
49 changes: 46 additions & 3 deletions app/src/renderer/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -917,8 +917,33 @@ 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;
}
// 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;
}
const oauth = isOAuthProvider(welcomeProvider.value);
const apiKey = welcomeApiKey.value.trim();
if (!oauth && !apiKey) {
Expand Down Expand Up @@ -1003,9 +1028,27 @@ async function checkFirstRun(): Promise<void> {
_mode?: string;
llm?: { active?: string; providers?: Record<string, { hasApiKey?: boolean }> };
};
// 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 <details> 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.
Expand Down
3 changes: 2 additions & 1 deletion bin/loom.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions extensions/loom/galaxy-api.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
16 changes: 16 additions & 0 deletions extensions/loom/galaxy-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,19 @@ export async function galaxyGetJobDetails(
): Promise<GalaxyJobDetailsResponse> {
return galaxyGet<GalaxyJobDetailsResponse>(`/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<GalaxyHistorySummary | null> {
const res = await galaxyGet<GalaxyHistorySummary | null>("/histories/most_recently_used", signal);
return res && typeof res.id === "string" && res.id.length > 0 ? res : null;
}
Loading
Loading