Files
dohooo__helmor/cloud/scripts/local-docker-team.ts
Caspian Zhao ecc41ef7c0 fix(team-cloud): container gets ONLY the forge creator's tokens (P1-2a)
Cold start previously minted EVERY member's ForgeIdentity DO and wrote
the whole team's gh/glab tokens into the shared container — one induced
'cat' away from a full-team credential leak. Per the user product
ruling the creator-identity model stays (everyone's pushes reuse the
creator's forge identity); only the storage narrows:

- teams.forge_identity_member_id: the forge 'creator', bound
  FIRST-authorizer-wins on PUT /team/forge-identity (mirrors
  cloud_identity_member_id's single-row upsert, but COALESCE keeps an
  existing binding; a non-creator racing to authorize first is the same
  accepted TOFU edge as cloud identity). Added via the P1-4c idempotent
  D1 ALTER runner (0002) + schema.sql for fresh DBs.
- collectMemberForgeCreds injects tokens ONLY for the creator entry
  (flagged creator:true); every other member is login-only (kept for
  per-member commit authorship). Local-docker launcher mirrors the same
  narrowing.
- member_creds.rs (CONTAINER RUST — needs the next team-image bump):
  token lookups fall back to the creator's tokens when the acting
  member has none, and when no acting member is bound at all (the
  detached auto-push thread). Old injected files without the creator
  flag still parse (no fallback). Authorship lookups (member_for) do
  NOT fall back.

NOTE: P1-8a (scrubbing the token out of .git/config) depends on this
creator fallback for push auth — rolling back this commit requires
rolling back the P1-8a commit with it.
2026-07-10 20:04:27 -07:00

646 lines
22 KiB
TypeScript

#!/usr/bin/env bun
import { spawnSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import http from "node:http";
import https from "node:https";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { Readable } from "node:stream";
import { createLocalTeamProxy } from "../src/local-dev/proxy";
import {
InMemoryLocalTeamRegistry,
type LocalTeamSnapshot,
} from "../src/local-dev/registry";
type LocalSecrets = {
adminToken: string;
companionToken: string;
};
const scriptDir = dirname(new URL(import.meta.url).pathname);
const repoRoot = resolve(scriptDir, "../..");
const cloudRoot = resolve(scriptDir, "..");
const stateDir = resolvePath(
process.env.HELMOR_LOCAL_TEAM_STATE_DIR ??
join(repoRoot, ".local-data", "team-docker"),
);
const proxyHost = process.env.HELMOR_LOCAL_TEAM_HOST ?? "127.0.0.1";
const proxyPort = Number(process.env.HELMOR_LOCAL_TEAM_PORT ?? "8787");
const companionPort = Number(
process.env.HELMOR_LOCAL_TEAM_COMPANION_PORT ?? "18080",
);
const image = process.env.HELMOR_LOCAL_TEAM_IMAGE ?? "helmor-team-local:dev";
const platform =
process.env.HELMOR_LOCAL_TEAM_PLATFORM ?? defaultDockerPlatform();
const containerName =
process.env.HELMOR_LOCAL_TEAM_CONTAINER ?? "helmor-team-local-dev";
const dataVolume =
process.env.HELMOR_LOCAL_TEAM_DATA_VOLUME ?? "helmor-team-local-data";
const workspaceVolume =
process.env.HELMOR_LOCAL_TEAM_WORKSPACE_VOLUME ??
"helmor-team-local-workspace";
const publicBaseUrl =
process.env.HELMOR_LOCAL_TEAM_PUBLIC_URL ??
`http://${proxyHost}:${proxyPort}`;
const companionBaseUrl =
process.env.HELMOR_LOCAL_TEAM_COMPANION_BASE ??
`http://127.0.0.1:${companionPort}`;
const debugProxy = process.env.HELMOR_LOCAL_TEAM_DEBUG === "1";
// Fixed local-dev member token (NOT random) so the desktop can default to a
// known token and connect with zero manual entry. Local dev only: it is only
// valid against this 127.0.0.1 dev proxy, and the desktop gates its matching
// default on `import.meta.env.DEV`. Keep in sync with `src/lib/team-mode.ts`.
const devMemberToken =
process.env.HELMOR_LOCAL_TEAM_MEMBER_TOKEN ?? "hlm_dev_team_local";
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
chmodSync(stateDir, 0o700);
const secrets = loadSecrets(join(stateDir, "secrets.json"));
const registryPath = join(stateDir, "registry.json");
// Per-member forge creds for the container's `member_creds` loader. A DIR mount
// (not a single file) so the launcher can rewrite members.json live on each
// `PUT /team/forge-identity` and the container hot-reloads it via mtime — no
// restart, so the desktop's auto-sync-on-connect never disrupts running agents.
const forgeMembersDir = join(stateDir, "forge-members");
const forgeMembersFile = join(forgeMembersDir, "members.json");
const registry = new InMemoryLocalTeamRegistry(
loadSnapshot(registryPath),
(s) => {
writeJsonFile(registryPath, s);
writeForgeMembersFile(s);
},
);
const memberToken = await ensureDefaultMemberToken();
// Ensure the file exists before the container mounts it (first run has no creds
// yet; the desktop auto-sync fills it in live afterwards).
writeForgeMembersFile(registry.snapshot());
if (!process.env.HELMOR_LOCAL_TEAM_COMPANION_BASE) {
// Build by DEFAULT so a backend (src-tauri / sidecar / Dockerfile.local)
// change is always picked up — buildx is content-hash aware, so an unchanged
// tree is a few-second cache no-op (the heavy trees are .dockerignore'd and
// cargo/bun use cache mounts). `HELMOR_LOCAL_TEAM_BUILD=0` skips the build for
// a fast restart when you KNOW nothing changed (still builds if missing).
if (process.env.HELMOR_LOCAL_TEAM_BUILD !== "0" || !localImageExists()) {
console.log(`Building local Team image ${image} (cache-aware)…`);
buildLocalImage();
}
prepareCodexHome();
prepareForgeAuth();
// ensureDockerContainer recreates only if the image actually changed, so a
// no-op build never pointlessly restarts a healthy container.
ensureDockerContainer();
// Sweep the ~1.7GB image the rebuild just superseded (now unreferenced since
// the container was recreated off the new one). No-op when nothing was built.
pruneSupersededImages();
}
// Setup-only mode: `dev:team` runs this FIRST (sequentially) to build the image
// + container with clean output, then starts the proxy server as a separate
// step. Exit here so the build phase doesn't also park on the long-lived proxy.
if (process.env.HELMOR_LOCAL_TEAM_SETUP_ONLY === "1") {
console.log("Local Team image + container ready.");
process.exit(0);
}
const proxy = createLocalTeamProxy({
registry,
companionBaseUrl,
companionToken: secrets.companionToken,
adminToken: secrets.adminToken,
publicBaseUrl,
sandboxId: "local-docker",
fetchCompanion: fetchCompanionWithNodeHttp,
restartCompanion: async () => restartDockerContainer(),
});
Bun.serve({
// Bind all interfaces (not just proxyHost) so the container can reach this
// proxy via host.docker.internal for the Stage B write-through (PUT /team/sync).
// The desktop still connects on 127.0.0.1 — publicBaseUrl is unchanged.
hostname: "0.0.0.0",
port: proxyPort,
// Long-lived subscription streams (e.g. /rpc-stream/subscribe_ui_mutations)
// sit idle between sparse events. Bun's default ~10s idleTimeout would close
// them, and the desktop's rpc-stream transport has no reconnect — so UiMutation
// events (workspaceChanged, etc.) would silently stop arriving. Disable it.
idleTimeout: 0,
fetch: proxy.fetch,
});
console.log(`Helmor local Team proxy: ${publicBaseUrl}`);
console.log(`Member token: ${memberToken}`);
console.log(`Admin token: ${secrets.adminToken}`);
console.log(`Companion: ${companionBaseUrl}`);
console.log(`State: ${stateDir}`);
console.log("Use Settings -> Team with the URL above and the member token.");
console.log("Use the admin token only for bootstrap/admin operations.");
function buildLocalImage() {
runDocker(
[
"buildx",
"build",
"--platform",
platform,
"-f",
join(cloudRoot, "Dockerfile.local"),
"--target",
"runtime",
// Label so we can prune ONLY our own superseded builds (the old `:dev`
// image goes dangling on every rebuild) without touching other projects'
// dangling images. See `pruneSupersededImages`.
"--label",
"helmor.local-team=dev",
"-t",
image,
"--load",
repoRoot,
// Stream buildx progress to the terminal — a backend rebuild can take
// minutes, so a silent (captured) build looks like a hang.
],
{ inherit: true },
);
}
/** Remove our own now-dangling images (previous `:dev` builds that lost their
* tag on the latest rebuild). Scoped by label so other projects' dangling
* images are never touched; the current tagged `:dev` is not dangling, so it's
* kept. Best-effort — a prune failure must never block the dev loop. Run only
* AFTER the container is (re)created off the new image, or the old image is
* still referenced and can't be removed. */
function pruneSupersededImages() {
docker(["image", "prune", "-f", "--filter", "label=helmor.local-team=dev"], {
allowFailure: true,
});
}
function localImageExists(): boolean {
return (
docker(["image", "inspect", image], { allowFailure: true }).status === 0
);
}
function ensureDockerContainer() {
const latestImageId = docker(["image", "inspect", "-f", "{{.Id}}", image], {
allowFailure: true,
}).stdout.trim();
const inspect = docker(
["inspect", "-f", "{{.State.Running}}|{{.Image}}", containerName],
{
allowFailure: true,
},
);
if (inspect.status === 0) {
const [running, containerImageId] = inspect.stdout.trim().split("|");
// Reuse only when the container was created from the CURRENT image. A
// rebuild yields a new image id, so recreate to run the new binary;
// an unchanged image keeps the same id, so a no-op build never restarts it.
if (!latestImageId || containerImageId === latestImageId) {
if (running !== "true") runDocker(["start", containerName]);
waitForHealth();
return;
}
console.log("Image changed — recreating the container with the new build");
runDocker(["rm", "-f", containerName]);
}
const args = [
"run",
"-d",
"--name",
containerName,
"--platform",
platform,
"-p",
`127.0.0.1:${companionPort}:8080`,
"-e",
`HELMOR_COMPANION_TOKEN=${secrets.companionToken}`,
// Stage B write-through target: the in-container TeamSync mirrors
// sessions/messages to this proxy's PUT /team/sync so team-mode history
// (GET /team/messages) populates even while the sandbox is "asleep".
// `host.docker.internal` reaches the host proxy from inside the container.
// Without this the mirror stays inert locally — matching the real Worker,
// which injects HELMOR_SYNC_URL via ensureServe when it starts the process.
"-e",
`HELMOR_SYNC_URL=http://host.docker.internal:${proxyPort}`,
"-e",
"HELMOR_CLOUD_AUTOPUSH=0",
"-e",
"HELMOR_LOG=debug",
"-e",
"HELMOR_DATA_DIR=/home/helmor",
"-e",
"HOME=/root",
"-e",
"IS_SANDBOX=1",
"-v",
`${dataVolume}:/home/helmor`,
"-v",
`${workspaceVolume}:/workspace`,
];
const codexHome = join(stateDir, "codex-home");
if (existsSync(codexHome)) {
args.push("-v", `${codexHome}:/root/.codex`);
}
// Forge auth (gh/glab) snapshotted from the host by prepareForgeAuth(). The
// dirs are disposable copies under stateDir, so a plain (rw) bind is safe —
// gh/glab never touch the real host config.
const ghAuthDir = join(stateDir, "forge-auth", "gh");
if (existsSync(join(ghAuthDir, "hosts.yml"))) {
args.push("-v", `${ghAuthDir}:/root/.config/gh`);
}
// Per-member forge creds for the in-container `member_creds` loader (true
// per-member: the forge layer selects by the acting member). DIR mount so live
// rewrites hot-reload without a restart.
if (existsSync(forgeMembersFile)) {
args.push(
"-v",
`${forgeMembersDir}:/run/helmor-forge:ro`,
"-e",
"HELMOR_FORGE_MEMBERS_PATH=/run/helmor-forge/members.json",
);
}
const glabAuthDir = join(stateDir, "forge-auth", "glab-cli");
const glabConfig = join(glabAuthDir, "config.yml");
if (existsSync(glabConfig)) {
args.push(
"-v",
`${glabAuthDir}:/root/.config/glab-cli`,
"-e",
"GLAB_CONFIG_DIR=/root/.config/glab-cli",
);
const glabHosts = readGlabHosts(glabConfig);
if (glabHosts.length) {
args.push("-e", `HELMOR_GLAB_HOSTS=${glabHosts.join(",")}`);
}
}
if (process.env.GITHUB_TOKEN) {
args.push("-e", `GITHUB_TOKEN=${process.env.GITHUB_TOKEN}`);
}
// Claude has no host-credential mount (unlike Codex): inject the token the
// user authorized IN-APP (stored in the local registry) — or an explicit env
// override — so cloud Claude turns actually authenticate in the container.
const claudeToken = resolveClaudeToken();
if (claudeToken) {
args.push("-e", `CLAUDE_CODE_OAUTH_TOKEN=${claudeToken}`);
}
args.push("--entrypoint", "/usr/local/bin/helmor-start-serve", image);
runDocker(args);
waitForHealth();
}
function resolveClaudeToken(): string | undefined {
return (
process.env.CLAUDE_CODE_OAUTH_TOKEN ??
registry.getClaudeToken() ??
undefined
);
}
function restartDockerContainer() {
if (process.env.HELMOR_LOCAL_TEAM_COMPANION_BASE) return;
// RECREATE (not `docker restart`) so a freshly-authorized Claude token is
// re-injected as env — `docker restart` keeps the create-time env, so a new
// token would never reach the container otherwise.
docker(["rm", "-f", containerName], { allowFailure: true });
ensureDockerContainer();
}
function waitForHealth() {
const deadline = Date.now() + 120_000;
let lastError = "";
while (Date.now() < deadline) {
const response = Bun.spawnSync({
cmd: [
"curl",
"-fsS",
`${companionBaseUrl.replace(/\/+$/, "")}/v1/health`,
],
stdout: "pipe",
stderr: "pipe",
});
if (response.exitCode === 0) return;
lastError = response.stderr.toString();
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500);
}
throw new Error(
`local Docker companion did not become healthy: ${lastError}`,
);
}
function prepareCodexHome() {
const source = resolvePath(
process.env.HELMOR_LOCAL_TEAM_CODEX_HOME ?? join(homedir(), ".codex"),
);
const auth = join(source, "auth.json");
const config = join(source, "config.toml");
if (!existsSync(auth) && !existsSync(config)) return;
const target = join(stateDir, "codex-home");
mkdirSync(target, { recursive: true, mode: 0o700 });
chmodSync(target, 0o700);
if (existsSync(auth)) copyFileSync(auth, join(target, "auth.json"));
if (existsSync(config)) copyFileSync(config, join(target, "config.toml"));
}
/** Snapshot the host's gh + glab auth into stateDir so the container can use
* the same forge credentials. gh's token lives in the macOS keychain (not in
* hosts.yml), so it's pulled via `gh auth token` and written into a synthetic
* hosts.yml the Linux gh reads directly; glab keeps its token in plaintext
* config.yml, so that's copied as-is. Best-effort — a missing CLI / logged-out
* host just skips that provider. */
/** Mirror the registry's forge creds to the file the container's
* `member_creds` loader reads. Round6 P1-2a: ONLY the team's forge creator
* (`forge_identity_member_id`, first-authorizer-wins) gets tokens — flagged
* `creator: true` — while every other member contributes just their login
* (commit authorship). Rewritten on every registry change so a live
* `PUT /team/forge-identity` is picked up by the container via mtime reload. */
function writeForgeMembersFile(snapshot: LocalTeamSnapshot) {
mkdirSync(forgeMembersDir, { recursive: true, mode: 0o700 });
chmodSync(forgeMembersDir, 0o700);
const creatorId = Object.values(snapshot.teams ?? {})[0]
?.forge_identity_member_id;
const members: Record<
string,
{
githubToken?: string;
glabConfigYml?: string;
login?: string;
creator?: boolean;
}
> = {};
for (const [memberId, member] of Object.entries(snapshot.members ?? {})) {
members[memberId] = { login: member.github_login };
}
if (creatorId && snapshot.forgeCredentials?.[creatorId]) {
members[creatorId] = {
...snapshot.forgeCredentials[creatorId],
login: snapshot.members[creatorId]?.github_login,
creator: true,
};
}
writeFileSync(forgeMembersFile, `${JSON.stringify(members, null, 2)}\n`, {
mode: 0o600,
});
}
function prepareForgeAuth() {
const synced: string[] = [];
if (prepareGhAuth(join(stateDir, "forge-auth", "gh"))) synced.push("gh");
if (prepareGlabAuth(join(stateDir, "forge-auth", "glab-cli")))
synced.push("glab");
if (synced.length) {
console.log(`Forge auth synced into the container: ${synced.join(" + ")}`);
}
}
function prepareGhAuth(targetDir: string): boolean {
const ghBin = process.env.HELMOR_LOCAL_TEAM_GH_BIN ?? "gh";
const status = captureCommand(ghBin, ["auth", "status"]);
if (status.status !== 0) return false;
let yaml = "";
for (const { host, login } of parseGhHosts(status.stdout)) {
const token = captureCommand(ghBin, [
"auth",
"token",
"--hostname",
host,
]).stdout.trim();
if (!token) continue;
// Write both the legacy top-level token and the `users` map so any gh
// version in the container reads it.
yaml += `${host}:\n git_protocol: https\n oauth_token: ${token}\n`;
if (login) {
yaml += ` user: ${login}\n users:\n ${login}:\n oauth_token: ${token}\n`;
}
}
if (!yaml) return false;
mkdirSync(targetDir, { recursive: true, mode: 0o700 });
chmodSync(targetDir, 0o700);
writeFileSync(join(targetDir, "hosts.yml"), yaml, { mode: 0o600 });
return true;
}
/** Hosts + active login from `gh auth status` (one block per host). */
function parseGhHosts(out: string): Array<{ host: string; login: string }> {
const hosts: Array<{ host: string; login: string }> = [];
let current: { host: string; login: string } | null = null;
for (const line of out.split("\n")) {
const host = line.match(/^([A-Za-z0-9][A-Za-z0-9.-]*\.[A-Za-z]{2,})\s*$/);
if (host) {
current = { host: host[1], login: "" };
hosts.push(current);
continue;
}
if (current && !current.login) {
const m = line.match(/(?:account|as)\s+([A-Za-z0-9-]+)/);
if (m) current.login = m[1];
}
}
return hosts;
}
function prepareGlabAuth(targetDir: string): boolean {
const source = [
process.env.HELMOR_LOCAL_TEAM_GLAB_CONFIG,
join(homedir(), ".config", "glab-cli", "config.yml"),
join(homedir(), "Library", "Application Support", "glab-cli", "config.yml"),
].find((p): p is string => Boolean(p) && existsSync(p as string));
if (!source) return false;
mkdirSync(targetDir, { recursive: true, mode: 0o700 });
chmodSync(targetDir, 0o700);
const dest = join(targetDir, "config.yml");
copyFileSync(source, dest);
chmodSync(dest, 0o600);
return true;
}
/** Host keys under glab's `hosts:` block — used to register git credential
* helpers per host in the container. */
function readGlabHosts(configPath: string): string[] {
const hosts: string[] = [];
let inHosts = false;
for (const line of readFileSync(configPath, "utf8").split("\n")) {
if (/^hosts:\s*$/.test(line)) {
inHosts = true;
continue;
}
if (!inHosts) continue;
if (/^\S/.test(line)) break;
const m = line.match(/^ {4}([A-Za-z0-9.-]+):\s*$/);
if (m) hosts.push(m[1]);
}
return hosts;
}
function captureCommand(
cmd: string,
args: string[],
): { status: number; stdout: string; stderr: string } {
const result = spawnSync(cmd, args, { encoding: "utf8", stdio: "pipe" });
return {
status: result.status ?? (result.error ? 1 : 0),
stdout: result.stdout ?? "",
stderr: result.stderr ?? "",
};
}
function loadSecrets(path: string): LocalSecrets {
if (existsSync(path)) {
return JSON.parse(readFileSync(path, "utf8")) as LocalSecrets;
}
const value = {
adminToken: `hlm_local_admin_${randomUUID()}`,
companionToken: `hlm_local_companion_${randomUUID()}`,
};
writeJsonFile(path, value);
return value;
}
function loadSnapshot(path: string): Partial<LocalTeamSnapshot> | undefined {
if (!existsSync(path)) return undefined;
return JSON.parse(readFileSync(path, "utf8")) as Partial<LocalTeamSnapshot>;
}
async function ensureDefaultMemberToken(): Promise<string> {
// Ensure the FIXED dev token specifically is a valid member (not just "some"
// member) so the desktop's known default always works, even if an older
// random-token member already exists in persisted state.
const existing = registry.snapshot().invites[devMemberToken];
if (existing?.member_id) return devMemberToken;
const memberId =
process.env.HELMOR_LOCAL_TEAM_MEMBER_ID ?? "local-docker-dev";
const login =
process.env.HELMOR_LOCAL_TEAM_MEMBER_LOGIN ?? "local-docker-dev";
await registry.bootstrap("local-docker");
const invite = await registry.createInvite({
baseUrl: publicBaseUrl,
token: devMemberToken,
});
const accepted = await registry.acceptInvite({
token: invite.token,
githubId: memberId,
login,
displayName:
process.env.HELMOR_LOCAL_TEAM_MEMBER_NAME ?? "Local Docker Dev",
});
if (!accepted.ok) {
throw new Error(
`failed to create default local member: ${accepted.message}`,
);
}
return invite.token;
}
function writeJsonFile(path: string, value: unknown) {
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, {
mode: 0o600,
});
}
function runDocker(args: string[], options?: { inherit?: boolean }) {
const result = docker(args, options);
if (result.status !== 0) {
throw new Error(
`docker ${args.join(" ")} failed\n${result.stderr || result.stdout}`,
);
}
return result;
}
function docker(
args: string[],
options?: { allowFailure?: boolean; inherit?: boolean },
): { status: number; stdout: string; stderr: string } {
const result = spawnSync("docker", args, {
cwd: repoRoot,
encoding: "utf8",
// `inherit` streams long/verbose commands (the image build) straight to
// the terminal so progress is visible live; otherwise pipe so callers can
// parse stdout (image ids, container state).
stdio: options?.inherit ? "inherit" : "pipe",
});
if (!options?.allowFailure && result.error) throw result.error;
return {
status: result.status ?? (result.error ? 1 : 0),
stdout: result.stdout ?? "",
stderr: result.stderr ?? "",
};
}
function resolvePath(path: string): string {
if (path === "~") return homedir();
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
return resolve(path);
}
function defaultDockerPlatform(): string {
if (process.arch === "arm64") return "linux/arm64";
return "linux/amd64";
}
async function fetchCompanionWithNodeHttp(request: Request): Promise<Response> {
const url = new URL(request.url);
const body =
request.method === "GET" || request.method === "HEAD"
? null
: Buffer.from(await request.arrayBuffer());
if (debugProxy) {
console.error(
`[local-team-proxy] -> ${request.method} ${url.pathname}${url.search} body=${body?.length ?? 0}`,
);
}
return new Promise((resolve, reject) => {
const client = url.protocol === "https:" ? https : http;
const headers = Object.fromEntries(request.headers);
if (body) headers["content-length"] = String(body.length);
const upstream = client.request(
{
protocol: url.protocol,
hostname: url.hostname,
port: url.port,
path: `${url.pathname}${url.search}`,
method: request.method,
headers,
},
(response) => {
if (debugProxy) {
console.error(
`[local-team-proxy] <- ${response.statusCode ?? 0} ${url.pathname}`,
);
}
const headers = new Headers();
for (const [key, value] of Object.entries(response.headers)) {
if (Array.isArray(value)) {
for (const item of value) headers.append(key, item);
} else if (value !== undefined) {
headers.set(key, String(value));
}
}
resolve(
new Response(Readable.toWeb(response) as ReadableStream, {
status: response.statusCode ?? 500,
statusText: response.statusMessage,
headers,
}),
);
},
);
upstream.on("error", reject);
if (body) upstream.write(body);
upstream.end();
});
}