mirror of
https://github.com/dohooo/helmor.git
synced 2026-09-22 16:40:02 +08:00
6a80be25b1
The F-B lockout (live-verified in class1 run #2): provision rotated HELMOR_COMPANION_TOKEN via 'secret put' BEFORE deploy/verify, and the desktop only receives the token on the terminal 'deployed' line — so any mid-run failure stranded the Worker on a NEW token the desktop never saw (desktop/Worker/container three-way mismatch, existing teams only). Fix (architect-approved failure matrix in class4-design.md par.2): reuse-or- generate. On a RE-provision the frontend passes the desktop's stored admin token (getTeamAdminToken) -> deploy_team_cloud forwards it to the script as HELMOR_EXISTING_COMPANION_TOKEN (env only, never logged) -> provision reuses it, so 'secret put' writes the value the desktop ALREADY holds. After that point every failure row has desktop == Worker; a pre-existing F-B mismatch even self-heals (the put forces the Worker back to the desktop's value). The step ORDER is deliberately unchanged: rotate-then-put stays for FRESH teams (nothing held -> nothing to lock out; 'wrangler secret put' before the first deploy also auto-creates the draft Worker, a load-bearing behavior both rollouts exercised). F-C's 401 propagation grace and destroy-warm keep their semantics — on the reuse path the propagation window physically disappears (same value), on the fresh path they still do their verified job. Forced rotation remains reachable via Leave team -> Create team (clears the token). resolveAdminToken extracted pure (cloud/scripts/admin-token.ts) with unit tests; the frontend wiring is pinned by team-create-flow tests (red-verified against the unwired call).
608 lines
23 KiB
TypeScript
608 lines
23 KiB
TypeScript
#!/usr/bin/env bun
|
|
|
|
//
|
|
// Team-cloud auto-provisioner — the deploy ENGINE behind the in-app "Create a
|
|
// team" flow. Stands up a fresh team backend on the operator's OWN Cloudflare
|
|
// account by driving `wrangler`, then prints the Worker URL + admin token.
|
|
//
|
|
// WHY a standalone script (not Rust spawning wrangler step-by-step): the
|
|
// CF-interaction complexity lives here in readable TS you can run + verify +
|
|
// fix directly (`bun run cloud/scripts/provision-team.ts`), instead of as blind
|
|
// Rust. The Rust command (`deploy_team_cloud`) is a thin layer: it locates this
|
|
// script (repo `cloud/scripts/*.ts` in dev; the staged
|
|
// `vendor/team-cloud/scripts/provision-team.mjs` in a release bundle) plus a JS
|
|
// runtime (PATH `bun` in dev; the vendored Node in release), runs it, and
|
|
// forwards the progress JSON to the UI. `wrangler` is NOT expected on the
|
|
// operator's machine: it ships inside the same payload next to this script and
|
|
// is self-resolved below (see WRANGLER_JS).
|
|
//
|
|
// PROTOCOL (stdout = machine, stderr = human):
|
|
// each step emits one JSON line {"kind":"progress","step":...,"status":...}
|
|
// terminal line is exactly one of
|
|
// {"kind":"deployed","workerUrl":"...","adminToken":"..."}
|
|
// {"kind":"needs-upgrade","upgradeUrl":"..."}
|
|
// human-readable logs go to stderr and are ignored by the parser.
|
|
//
|
|
// REQUIRES (operator side): a logged-in or loginnable Cloudflare account on the
|
|
// Workers Paid plan (Containers need it) and a workers.dev subdomain. The
|
|
// container image is referenced from a PUBLIC registry (HELMOR_TEAM_IMAGE) —
|
|
// no per-account `docker build`.
|
|
//
|
|
// VERIFY-AGAINST-REAL-CF spots (flagged inline with `// TODO(verify)`): the
|
|
// exact `d1 create --update-config` behaviour, the `wrangler deploy` URL line
|
|
// format, and the free-plan / missing-subdomain error strings. These are the
|
|
// only places that can't be confirmed without a real paid CF account.
|
|
|
|
import { spawnSync } from "node:child_process";
|
|
import { randomBytes } from "node:crypto";
|
|
import {
|
|
appendFileSync,
|
|
existsSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { resolveAdminToken } from "./admin-token";
|
|
import { decideBrokerKeyAction, toSecretListOutcome } from "./broker-key";
|
|
import { classifyMigrationError, D1_MIGRATIONS } from "./d1-migrations";
|
|
import { pollHealth } from "./verify-live";
|
|
import { decideWranglerCommand, findNodeOnPath } from "./wrangler-runtime";
|
|
|
|
// fileURLToPath (not URL.pathname) so Windows paths don't keep the leading
|
|
// slash (`/C:/…`), which breaks resolve/join.
|
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
const cloudRoot = resolve(scriptDir, "..");
|
|
|
|
/** `wrangler` to invoke. HELMOR_WRANGLER_BIN (manual override / harness) is
|
|
* spawned directly; otherwise we run wrangler's JS entry from the
|
|
* node_modules NEXT TO this script (repo `cloud/node_modules` in dev, staged
|
|
* `vendor/team-cloud/node_modules` in a release bundle) under a NODE runtime
|
|
* — never bun, which swallows wrangler's output (round6 F-A; decision rules +
|
|
* rationale in wrangler-runtime.ts). Spawning the JS entry instead of the
|
|
* `.bin/wrangler` shim matters: the shim's `#!/usr/bin/env node` shebang
|
|
* needs a PATH `node`, and a Finder-launched app has no such PATH. */
|
|
const WRANGLER_JS = resolve(cloudRoot, "node_modules/wrangler/bin/wrangler.js");
|
|
const wranglerNodeHint = process.env.HELMOR_WRANGLER_NODE || "";
|
|
const wranglerCmd = decideWranglerCommand({
|
|
overrideBin: process.env.HELMOR_WRANGLER_BIN || "",
|
|
wranglerJs: WRANGLER_JS,
|
|
execPath: process.execPath,
|
|
isBun: Boolean(process.versions.bun),
|
|
nodeHint:
|
|
wranglerNodeHint && existsSync(wranglerNodeHint) ? wranglerNodeHint : "",
|
|
pathNode: findNodeOnPath(
|
|
process.env.PATH,
|
|
process.platform === "win32",
|
|
existsSync,
|
|
),
|
|
});
|
|
|
|
/** Writable working directory for every wrangler invocation. wrangler scratches
|
|
* under its project dir (`.wrangler/`), and in a release bundle `cloudRoot`
|
|
* lives in the app's READ-ONLY Resources — so wrangler must never run with its
|
|
* cwd there. */
|
|
const workDir = mkdtempSync(join(tmpdir(), "helmor-team-wrangler-"));
|
|
|
|
/** Optional image OVERRIDE. Empty ⇒ deploy the tag committed in `wrangler.toml`
|
|
* — the SINGLE source of truth, kept in lockstep with publish-team-image.yml.
|
|
* A hard-coded default here previously OVERWROTE wrangler.toml's tag, so every
|
|
* in-app provision shipped a STALE image (a `:0.1.0` frozen while the repo /
|
|
* Worker moved to `:0.1.1`), diverging the container from the Worker it runs
|
|
* under (team-cloud-stabilize WP6). CF Containers still reject `:latest`, so an
|
|
* override MUST be a pinned tag. */
|
|
const TEAM_IMAGE_OVERRIDE = process.env.HELMOR_TEAM_IMAGE || "";
|
|
|
|
/** The image tag actually deployed: the override, else wrangler.toml's committed
|
|
* `image = "…"`. Used for logging + kept identical to what `writeAccountConfig`
|
|
* writes so the two never drift. */
|
|
function resolveImage(): string {
|
|
if (TEAM_IMAGE_OVERRIDE) return TEAM_IMAGE_OVERRIDE;
|
|
const template = readFileSync(join(cloudRoot, "wrangler.toml"), "utf8");
|
|
return template.match(/image\s*=\s*"([^"]*)"/)?.[1] ?? "(from wrangler.toml)";
|
|
}
|
|
|
|
/** Optional gh token to inject as the Worker's GITHUB_TOKEN secret (clone/push
|
|
* for the cloud sandbox). Absent → skipped; basic chat doesn't need it. */
|
|
const GITHUB_TOKEN = process.env.HELMOR_TEAM_GITHUB_TOKEN || "";
|
|
|
|
type Step = "login" | "plan" | "provision" | "deploy" | "verify";
|
|
|
|
function progress(
|
|
step: Step,
|
|
status: "start" | "done" | "error",
|
|
message: string,
|
|
): void {
|
|
process.stdout.write(
|
|
`${JSON.stringify({ kind: "progress", step, status, message })}\n`,
|
|
);
|
|
}
|
|
function log(line: string): void {
|
|
process.stderr.write(`${line}\n`);
|
|
}
|
|
function emitDeployed(workerUrl: string, adminToken: string): never {
|
|
process.stdout.write(
|
|
`${JSON.stringify({ kind: "deployed", workerUrl, adminToken })}\n`,
|
|
);
|
|
process.exit(0);
|
|
}
|
|
function emitNeedsUpgrade(upgradeUrl: string): never {
|
|
process.stdout.write(
|
|
`${JSON.stringify({ kind: "needs-upgrade", upgradeUrl })}\n`,
|
|
);
|
|
process.exit(0);
|
|
}
|
|
|
|
const UPGRADE_URL = "https://dash.cloudflare.com/?to=/:account/workers/plans";
|
|
|
|
/** Run wrangler with inherited stdio for the OAuth/login TTY but captured
|
|
* output otherwise. Returns {code, stdout, stderr}. `stdinValue` feeds secrets
|
|
* via stdin (how `wrangler secret put` reads non-interactively). */
|
|
function wrangler(
|
|
args: string[],
|
|
opts: {
|
|
stdinValue?: string;
|
|
inheritStdio?: boolean;
|
|
configPath?: string;
|
|
} = {},
|
|
): { code: number; stdout: string; stderr: string } {
|
|
const fullArgs = opts.configPath
|
|
? [...args, "--config", opts.configPath]
|
|
: args;
|
|
const result = spawnSync(
|
|
wranglerCmd.argv0,
|
|
[...wranglerCmd.prefixArgs, ...fullArgs],
|
|
{
|
|
cwd: workDir,
|
|
input: opts.stdinValue,
|
|
stdio: opts.inheritStdio
|
|
? ["inherit", "inherit", "inherit"]
|
|
: ["pipe", "pipe", "pipe"],
|
|
encoding: "utf8",
|
|
env: { ...process.env, WRANGLER_SEND_METRICS: "false" },
|
|
},
|
|
);
|
|
return {
|
|
code: result.status ?? 1,
|
|
stdout: result.stdout ?? "",
|
|
stderr: result.stderr ?? "",
|
|
};
|
|
}
|
|
|
|
/** Free-plan / missing-Containers signal in a failed deploy's output. */
|
|
function looksLikeNeedsPaid(text: string): boolean {
|
|
const t = text.toLowerCase();
|
|
// TODO(verify): confirm the exact billing/plan error string on a real free
|
|
// account. Kept broad so we surface the upgrade gate rather than a raw error.
|
|
return (
|
|
t.includes("workers paid") ||
|
|
t.includes("requires a paid") ||
|
|
t.includes("not entitled") ||
|
|
(t.includes("container") && t.includes("plan"))
|
|
);
|
|
}
|
|
|
|
/** Generate the per-account wrangler config from the committed template:
|
|
* - drop the hard-coded D1 block (its id is our dev account's; the caller
|
|
* injects THIS account's id),
|
|
* - swap the local Dockerfile build for the PUBLIC image,
|
|
* - make `main` ABSOLUTE — wrangler resolves `main` relative to the config
|
|
* file, and ours lives in a temp dir, so a relative `src/index.ts` 404s
|
|
* ("entry-point file … not found"). Returns the temp config path. */
|
|
function writeAccountConfig(): string {
|
|
const template = readFileSync(join(cloudRoot, "wrangler.toml"), "utf8");
|
|
// TOML blocks are blank-line separated; drop the committed D1 block.
|
|
const blocks = template
|
|
.split(/\n\n+/)
|
|
.filter((b) => !b.includes("[[d1_databases]]"));
|
|
let rewritten = blocks
|
|
.join("\n\n")
|
|
.replace(
|
|
/^main\s*=\s*"[^"]*"/m,
|
|
`main = "${join(cloudRoot, "src/index.ts")}"`,
|
|
);
|
|
// Keep wrangler.toml's committed image tag UNLESS explicitly overridden — the
|
|
// tag is the single source of truth (WP6). Only rewrite for HELMOR_TEAM_IMAGE.
|
|
if (TEAM_IMAGE_OVERRIDE) {
|
|
rewritten = rewritten.replace(
|
|
/image\s*=\s*"[^"]*"/,
|
|
`image = "${TEAM_IMAGE_OVERRIDE}"`,
|
|
);
|
|
}
|
|
const dir = mkdtempSync(join(tmpdir(), "helmor-team-deploy-"));
|
|
const path = join(dir, "wrangler.toml");
|
|
writeFileSync(path, rewritten, "utf8");
|
|
return path;
|
|
}
|
|
|
|
/** Resolve a D1 database's id by name via `d1 list --json` (works whether it
|
|
* was just created or already existed). Empty string if not found. */
|
|
function findD1Id(name: string): string {
|
|
const res = wrangler(["d1", "list", "--json"]);
|
|
if (res.code !== 0) return "";
|
|
try {
|
|
const dbs = JSON.parse(res.stdout) as Array<{
|
|
uuid?: string;
|
|
name?: string;
|
|
}>;
|
|
return dbs.find((db) => db.name === name)?.uuid ?? "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
/** Cold-start ceiling for the REAL verify below (matches the Worker's own
|
|
* SERVE_READY_TIMEOUT_MS). A first boot of the GTK/WebKit stack is slow. */
|
|
const VERIFY_TIMEOUT_MS = 180_000;
|
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
/** One authenticated probe. Never throws — a network error is `{ ok:false }` so
|
|
* the caller keeps polling (cold start) or reports the failing stage. Reads the
|
|
* Worker's structured body so a `permanent` container error is surfaced. */
|
|
async function probeVerify(
|
|
url: string,
|
|
token: string,
|
|
method: "GET" | "POST" = "GET",
|
|
): Promise<{
|
|
ok: boolean;
|
|
status?: number;
|
|
permanent?: boolean;
|
|
message?: string;
|
|
}> {
|
|
try {
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
...(method === "POST" ? { "Content-Type": "application/json" } : {}),
|
|
},
|
|
...(method === "POST" ? { body: "{}" } : {}),
|
|
});
|
|
if (res.ok) return { ok: true, status: res.status };
|
|
const body = (await res.json().catch(() => ({}))) as {
|
|
permanent?: boolean;
|
|
message?: string;
|
|
};
|
|
return {
|
|
ok: false,
|
|
status: res.status,
|
|
permanent: body.permanent === true,
|
|
message: body.message,
|
|
};
|
|
} catch (error) {
|
|
return { ok: false, message: (error as Error).message };
|
|
}
|
|
}
|
|
|
|
/** REAL end-to-end verify (WP6, S3): Worker auth → the container actually STARTS
|
|
* (`/v1/health` goes through the Worker's `ensureServe`) → the model catalog
|
|
* returns. Returns a human error NAMING the failing stage, or null on success.
|
|
* A permanent container error or a cold start that never finishes is a HARD fail
|
|
* — setup must not go green on a backend that can't run a turn (the old VERIFY
|
|
* step was a no-op progress marker). A 401/403 is retried within a grace window
|
|
* (round6 F-C): provision rotates HELMOR_COMPANION_TOKEN right before this, and
|
|
* CF secret propagation lags a few seconds — see pollHealth / verify-live.ts.
|
|
* Agent-identity readiness isn't checked here (this runs pre-authorize on the
|
|
* admin token); the create-flow's Finish gate owns that. */
|
|
/** WP6.1: POST `/admin/destroy-sandbox` so any WARM container (holding the
|
|
* PREVIOUS companion token) is scaled to zero before verify — the next request
|
|
* cold-starts and injects the freshly-rotated token, so a Reset→re-provision no
|
|
* longer 401s and recovery needs no manual `wrangler`. Best-effort: a non-2xx or
|
|
* network error is logged and swallowed (verify still catches a token mismatch).
|
|
* A 401/403 gets a couple of bounded retries: the call authenticates with the
|
|
* token that was JUST rotated, and CF secret propagation lags a few seconds
|
|
* (round6 F-C, measured ~3-10s live) — still never fatal. */
|
|
async function destroyWarmContainer(
|
|
workerUrl: string,
|
|
token: string,
|
|
): Promise<void> {
|
|
const base = workerUrl.replace(/\/+$/, "");
|
|
for (let attempt = 1; ; attempt++) {
|
|
try {
|
|
const res = await fetch(`${base}/admin/destroy-sandbox`, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
if (
|
|
!res.ok &&
|
|
(res.status === 401 || res.status === 403) &&
|
|
attempt < 3
|
|
) {
|
|
log(
|
|
`[provision] destroy-sandbox HTTP ${res.status} — likely the rotated token still propagating; retrying (${attempt}/2)…`,
|
|
);
|
|
await sleep(5000);
|
|
continue;
|
|
}
|
|
log(
|
|
res.ok
|
|
? "[provision] destroyed any warm container (next request cold-starts fresh)"
|
|
: `[provision] destroy-sandbox HTTP ${res.status} (non-fatal; verify is the backstop)`,
|
|
);
|
|
} catch (error) {
|
|
log(
|
|
`[provision] destroy-sandbox failed (non-fatal): ${
|
|
error instanceof Error ? error.message : error
|
|
}`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
async function verifyLive(
|
|
workerUrl: string,
|
|
token: string,
|
|
): Promise<string | null> {
|
|
const base = workerUrl.replace(/\/+$/, "");
|
|
// (a) Container start — poll /v1/health (through ensureServe) up to the
|
|
// ceiling, with a 401/403 grace window for secret propagation. Loop
|
|
// semantics live in verify-live.ts (unit-tested).
|
|
const healthErr = await pollHealth({
|
|
probe: () => probeVerify(`${base}/v1/health`, token),
|
|
timeoutMs: VERIFY_TIMEOUT_MS,
|
|
});
|
|
if (healthErr) return healthErr;
|
|
// (b) Model catalog — the composer's blocking query; proves the serve host
|
|
// answers RPC, not just /v1/health.
|
|
const models = await probeVerify(
|
|
`${base}/rpc/list_agent_model_sections`,
|
|
token,
|
|
"POST",
|
|
);
|
|
if (!models.ok) {
|
|
return models.permanent
|
|
? `Model catalog: ${models.message ?? "permanent error"}`
|
|
: `Model catalog: the serve host didn't return models (HTTP ${
|
|
models.status ?? "network error"
|
|
}).`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
if (wranglerCmd.prefixArgs.length > 0 && !existsSync(WRANGLER_JS)) {
|
|
log(
|
|
`[provision] fatal: wrangler is missing from this build (expected ${WRANGLER_JS}). ` +
|
|
"In a release bundle that means vendor/team-cloud/node_modules didn't ship — reinstall Helmor. " +
|
|
"In a repo checkout, run `bun install` in cloud/ first.",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
if (wranglerCmd.warning) {
|
|
log(`[provision] ⚠️ ${wranglerCmd.warning}`);
|
|
}
|
|
log(
|
|
`[provision] wrangler: ${[wranglerCmd.argv0, ...wranglerCmd.prefixArgs].join(" ")}`,
|
|
);
|
|
log(`[provision] image: ${resolveImage()}`);
|
|
|
|
// 1) LOGIN — reuse `wrangler login` (OAuth loopback). whoami first so an
|
|
// already-authenticated operator skips the browser dance. Detected by
|
|
// OUTPUT, not exit code: wrangler 4.100 `whoami` exits 0 even when
|
|
// logged out (it prints "You are not authenticated…"), so exit-code-only
|
|
// detection silently skipped the browser login on every fresh machine
|
|
// and provisioning face-planted later at the first authed call
|
|
// (round6 P1-1a, caught by the dimension-1 clean-shell smoke).
|
|
progress("login", "start", "Checking Cloudflare sign-in…");
|
|
const who = wrangler(["whoami"]);
|
|
const needsLogin =
|
|
who.code !== 0 || /not authenticated/i.test(`${who.stdout}\n${who.stderr}`);
|
|
if (needsLogin) {
|
|
log("[provision] not logged in — launching `wrangler login` (browser)…");
|
|
const login = wrangler(["login"], { inheritStdio: true });
|
|
if (login.code !== 0) {
|
|
log("[provision] wrangler login failed/cancelled.");
|
|
process.exit(1);
|
|
}
|
|
}
|
|
progress("login", "done", "Signed in to Cloudflare.");
|
|
|
|
// 2) PLAN — no reliable pre-deploy plan probe via wrangler; we surface the
|
|
// upgrade gate from the deploy error below. Mark the step passed-through.
|
|
progress("plan", "start", "Checking account plan…");
|
|
progress("plan", "done", "Plan check deferred to deploy.");
|
|
|
|
// 3) PROVISION — config + D1 + R2 + schema + admin secret.
|
|
progress("provision", "start", "Provisioning D1 / R2 / secrets…");
|
|
const configPath = writeAccountConfig();
|
|
log(`[provision] account config: ${configPath}`);
|
|
|
|
// D1: ensure it exists, then write its REAL id into the config. (`d1 create
|
|
// --update-config` silently skips the binding when the DB already exists,
|
|
// leaving the deploy with no DB binding; resolving the id explicitly also
|
|
// handles re-running against an account that's already set up.)
|
|
wrangler(["d1", "create", "helmor-team"]); // best-effort; "already exists" ok
|
|
const dbId = findD1Id("helmor-team");
|
|
if (!dbId) {
|
|
log("[provision] couldn't resolve the helmor-team D1 id.");
|
|
process.exit(1);
|
|
}
|
|
appendFileSync(
|
|
configPath,
|
|
`\n[[d1_databases]]\nbinding = "DB"\ndatabase_name = "helmor-team"\ndatabase_id = "${dbId}"\n`,
|
|
);
|
|
|
|
// R2 bucket (name fixed in the template's binding).
|
|
const r2 = wrangler(["r2", "bucket", "create", "helmor-team-backups"]);
|
|
if (r2.code !== 0 && !r2.stderr.toLowerCase().includes("already")) {
|
|
log(`[provision] r2 create failed:\n${r2.stderr}`);
|
|
// Non-fatal: backups are an enhancement, the team still works without R2.
|
|
}
|
|
|
|
// Schema into the freshly-created D1. Absolute path — wrangler's cwd is a
|
|
// temp workDir (never cloudRoot, which is read-only in a release bundle).
|
|
const schema = wrangler(
|
|
[
|
|
"d1",
|
|
"execute",
|
|
"helmor-team",
|
|
"--remote",
|
|
"--file",
|
|
join(cloudRoot, "schema.sql"),
|
|
"--yes",
|
|
],
|
|
{ configPath },
|
|
);
|
|
if (schema.code !== 0) {
|
|
log(`[provision] schema apply failed:\n${schema.stderr}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// One-time migrations for PRE-EXISTING databases (P1-4c): schema.sql only
|
|
// shapes fresh DBs (CREATE TABLE IF NOT EXISTS never backfills a column),
|
|
// so additive columns are applied here on every provision, tolerating
|
|
// SQLite's "duplicate column name" — the expected outcome on a fresh DB.
|
|
// List + rationale + upgrade path live in d1-migrations.ts.
|
|
for (const migration of D1_MIGRATIONS) {
|
|
const applied = wrangler(
|
|
[
|
|
"d1",
|
|
"execute",
|
|
"helmor-team",
|
|
"--remote",
|
|
"--command",
|
|
migration.sql,
|
|
"--yes",
|
|
],
|
|
{ configPath },
|
|
);
|
|
if (applied.code === 0) {
|
|
log(`[provision] migration ${migration.id}: applied`);
|
|
continue;
|
|
}
|
|
const combined = `${applied.stdout}\n${applied.stderr}`;
|
|
if (classifyMigrationError(combined) === "already-applied") {
|
|
log(`[provision] migration ${migration.id}: already applied — skipped`);
|
|
continue;
|
|
}
|
|
log(`[provision] migration ${migration.id} failed:\n${combined}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Admin/companion token — the bearer the desktop saves + the Worker accepts.
|
|
// Round6 P1-4b: on a RE-provision the desktop passes its currently-held
|
|
// token (HELMOR_EXISTING_COMPANION_TOKEN) and provision REUSES it instead
|
|
// of rotating — `secret put` then writes the value the desktop already
|
|
// holds, so no mid-run failure can leave desktop and Worker on different
|
|
// tokens (F-B lockout), and a pre-existing mismatch self-heals. A fresh
|
|
// team (no token held) still rotates; the value itself is never logged.
|
|
const { token: adminToken, reused: reusedAdminToken } = resolveAdminToken(
|
|
process.env.HELMOR_EXISTING_COMPANION_TOKEN,
|
|
() => `hlm_${randomBytes(24).toString("hex")}`,
|
|
);
|
|
log(
|
|
reusedAdminToken
|
|
? "[provision] reusing the desktop's existing companion token (no rotation)"
|
|
: "[provision] generated a fresh companion token",
|
|
);
|
|
const putToken = wrangler(["secret", "put", "HELMOR_COMPANION_TOKEN"], {
|
|
stdinValue: `${adminToken}\n`,
|
|
configPath,
|
|
});
|
|
if (putToken.code !== 0) {
|
|
log(`[provision] secret put failed:\n${putToken.stderr}`);
|
|
process.exit(1);
|
|
}
|
|
if (GITHUB_TOKEN) {
|
|
wrangler(["secret", "put", "GITHUB_TOKEN"], {
|
|
stdinValue: `${GITHUB_TOKEN}\n`,
|
|
configPath,
|
|
});
|
|
}
|
|
|
|
// BROKER_ENC_KEY — the AES-256-GCM key the identity brokers (Codex /
|
|
// Claude / Forge DOs) encrypt member credentials with. A fresh account
|
|
// never had it (P1-1b: authorize threw on the undefined key → blind 500),
|
|
// so provision generates it — but ⚠️ ONLY when it is confidently absent:
|
|
// rotating an existing key bricks every stored identity. Decision logic +
|
|
// its four branches live in broker-key.ts (unit-tested); inconclusive
|
|
// checks FAIL the run rather than guess.
|
|
const secretList = wrangler(["secret", "list"], { configPath });
|
|
const brokerKeyAction = decideBrokerKeyAction(
|
|
toSecretListOutcome(secretList),
|
|
);
|
|
if (brokerKeyAction === "fail") {
|
|
log(
|
|
`[provision] couldn't determine whether BROKER_ENC_KEY already exists (secret list failed):\n${secretList.stderr || secretList.stdout}`,
|
|
);
|
|
log(
|
|
"[provision] refusing to write it blindly — overwriting an existing key would invalidate every stored agent identity. Check connectivity/login and retry.",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
if (brokerKeyAction === "put") {
|
|
// 32 random bytes, standard base64 — exactly what the DOs' atob-based
|
|
// base64ToBytes expects for AES-256. The value is never logged.
|
|
const brokerKey = randomBytes(32).toString("base64");
|
|
const putBrokerKey = wrangler(["secret", "put", "BROKER_ENC_KEY"], {
|
|
stdinValue: `${brokerKey}\n`,
|
|
configPath,
|
|
});
|
|
if (putBrokerKey.code !== 0) {
|
|
log(`[provision] BROKER_ENC_KEY put failed:\n${putBrokerKey.stderr}`);
|
|
process.exit(1);
|
|
}
|
|
log("[provision] generated BROKER_ENC_KEY (was not set).");
|
|
} else {
|
|
log(
|
|
"[provision] BROKER_ENC_KEY already set — left untouched (rotating would invalidate stored identities).",
|
|
);
|
|
}
|
|
progress("provision", "done", "Backend resources ready.");
|
|
|
|
// 4) DEPLOY — Worker + Container binding referencing the public image.
|
|
progress("deploy", "start", "Deploying Worker + sandbox…");
|
|
const deploy = wrangler(["deploy"], { configPath });
|
|
if (deploy.code !== 0) {
|
|
const combined = `${deploy.stdout}\n${deploy.stderr}`;
|
|
if (looksLikeNeedsPaid(combined)) {
|
|
log("[provision] account lacks Workers Paid — surfacing upgrade gate.");
|
|
emitNeedsUpgrade(UPGRADE_URL);
|
|
}
|
|
log(`[provision] deploy failed:\n${combined}`);
|
|
process.exit(1);
|
|
}
|
|
// TODO(verify): confirm the deployed URL line format on a real deploy.
|
|
const urlMatch = `${deploy.stdout}\n${deploy.stderr}`.match(
|
|
/https:\/\/[a-z0-9-]+\.[a-z0-9-]+\.workers\.dev/i,
|
|
);
|
|
if (!urlMatch) {
|
|
log(
|
|
`[provision] deployed but couldn't parse the Worker URL:\n${deploy.stdout}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
const workerUrl = urlMatch[0];
|
|
progress("deploy", "done", `Deployed at ${workerUrl}`);
|
|
|
|
// 4.5) RESET WARM CONTAINER (WP6.1): a re-provision just rotated
|
|
// HELMOR_COMPANION_TOKEN, but a container still WARM from a prior run
|
|
// caches the OLD token (injected at its startProcess) and 401s every
|
|
// proxied rpc. Force-destroy it so verify's cold-start injects the NEW
|
|
// token. Best-effort — never blocks provision; verify is the backstop.
|
|
await destroyWarmContainer(workerUrl, adminToken);
|
|
|
|
// 5) VERIFY — REAL end-to-end (WP6, fixes S3 "green but broken"): the Worker
|
|
// must auth, the container must actually START, and the model catalog must
|
|
// return. A green here PROVES the backend can run a turn — not just that the
|
|
// Worker deployed. A failing stage exits non-zero with a stage-named error.
|
|
progress("verify", "start", "Waking the sandbox to verify it starts…");
|
|
const verifyErr = await verifyLive(workerUrl, adminToken);
|
|
if (verifyErr) {
|
|
progress("verify", "error", verifyErr);
|
|
log(`[provision] verify failed: ${verifyErr}`);
|
|
process.exit(1);
|
|
}
|
|
progress("verify", "done", "Sandbox starts and the model catalog is live.");
|
|
|
|
emitDeployed(workerUrl, adminToken);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
log(`[provision] fatal: ${error instanceof Error ? error.message : error}`);
|
|
process.exit(1);
|
|
});
|