From 572667d99b725a4925f69e1cfd715cdb6602dcc4 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:22:47 -0700 Subject: [PATCH 1/5] TASK-706: staging walk signs in to the shared account Staging signup needs a verified email the walk cannot receive, so the walk signs in to SRE's pre-verified staging walk account when COMMONLY_WALK_EMAIL is set. Credentials come from env only. - chooseWalkMode: signin only on a staging host; COMMONLY_WALK_EMAIL aimed at any other host is refused before the walk starts. - Signin runs skip the signup and signup-workspace steps, so no pod that the shared account already owns is recorded for cleanup. - cleanupRun deleteAccount:false removes only the recorded pods and never calls DELETE /api/users/me or the post-delete login check. - Tests: 22 pass (cleanup, including sign-in account kept and only recorded pods removed; mode selection, including refusal on prod). Written by Rivet, a Commonly agent. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: rivet Agent-Model: claude/claude-haiku-5-5/default --- docs/runbooks/first-user-walk.md | 18 +++++++++ scripts/first-user-walk-cleanup.mjs | 43 +++++++++++++-------- scripts/first-user-walk-cleanup.test.mjs | 24 ++++++++++++ scripts/first-user-walk-mode.mjs | 13 +++++++ scripts/first-user-walk-mode.test.mjs | 20 ++++++++++ scripts/first-user-walk.mjs | 49 ++++++++++++++++-------- 6 files changed, 136 insertions(+), 31 deletions(-) create mode 100644 scripts/first-user-walk-mode.mjs create mode 100644 scripts/first-user-walk-mode.test.mjs diff --git a/docs/runbooks/first-user-walk.md b/docs/runbooks/first-user-walk.md index d9511a0454..4d9e4b8e45 100644 --- a/docs/runbooks/first-user-walk.md +++ b/docs/runbooks/first-user-walk.md @@ -30,6 +30,24 @@ COMMONLY_WALK_PASSWORD="$(openssl rand -base64 24)" \ cleanup, `2` the walk passed but a step is not yet covered (today, the Bring-your-own connect step). +### Staging: the shared sign-in account + +Staging signup needs a verified email, which a walk address cannot receive, so +staging runs sign in to one shared walk account that SRE pre-verifies. Set +`COMMONLY_WALK_EMAIL` and `COMMONLY_WALK_PASSWORD` from the environment and pass +the staging API as the target: + +```bash +COMMONLY_WALK_EMAIL= COMMONLY_WALK_PASSWORD= \ + node scripts/first-user-walk.mjs --base https://api-staging.commonly.me +``` + +- The run signs in and never signs up. It removes only the pods, seats and + agents it created, and it never deletes the shared account. +- `COMMONLY_WALK_EMAIL` aimed at any host other than staging is refused before + the walk starts, so the shared account cannot be used on prod. +- The manifest records `"mode": "signin"` for these runs. + ## Cleanup is part of the run The walk deletes its own account in a `finally` block, sending the password and diff --git a/scripts/first-user-walk-cleanup.mjs b/scripts/first-user-walk-cleanup.mjs index 4637656212..5190698b3d 100644 --- a/scripts/first-user-walk-cleanup.mjs +++ b/scripts/first-user-walk-cleanup.mjs @@ -29,7 +29,10 @@ export const isRunBot = (member, agentName) => { // removed and verified even when the owner match misses them, so a filter // miss cannot turn into a silent survivor. `onBotIds(podId, botIds)` is called // before each pod is deleted, so a throw later in the loop cannot lose the ids. -export const cleanupRun = async ({ api, token, password, email, userId, agentName, podIds = [], onBotIds = () => {} }) => { +// `deleteAccount: false` is the shared staging sign-in account: the run removes +// only the pods it recorded, never pods that account owns from elsewhere, and +// never the account itself. +export const cleanupRun = async ({ api, token, password, email, userId, agentName, podIds = [], onBotIds = () => {}, deleteAccount = true }) => { const survivors = []; const botIds = new Set(); @@ -44,10 +47,16 @@ export const cleanupRun = async ({ api, token, password, email, userId, agentNam const stop = await api('POST', '/api/hosted/deprovision', { token, body: { agentName } }); if (stop.status >= 300 && stop.status !== 404) survivors.push(`hosted:${agentName} (deprovision ${stop.status})`); - const owned = await listOwnPods(); - if (owned === null) survivors.push('pods (list failed)'); + let owned = []; + if (deleteAccount) { + owned = await listOwnPods(); + if (owned === null) { + survivors.push('pods (list failed)'); + owned = []; + } + } const known = new Set(podIds.map(String)); - for (const pod of owned || []) known.add(String(pod._id)); + for (const pod of owned) known.add(String(pod._id)); // Pods only: the walk never joins a pod it did not create, so every pod // whose createdBy is this account is the run's own footprint. Bot members @@ -75,23 +84,27 @@ export const cleanupRun = async ({ api, token, password, email, userId, agentNam // listed under this account, or still readable by id, is a survivor // whatever the delete said. A list that fails here cannot prove the pods // are gone, so it is a survivor too. - const after = await listOwnPods(); - if (after === null) survivors.push('pods (list failed on verify)'); - for (const pod of after || []) survivors.push(`pod:${String(pod._id)} (still listed)`); + if (deleteAccount) { + const after = await listOwnPods(); + if (after === null) survivors.push('pods (list failed on verify)'); + for (const pod of after || []) survivors.push(`pod:${String(pod._id)} (still listed)`); + } for (const id of known) { const check = await api('GET', `/api/pods/${id}`, { token }); if (check.status === 200) survivors.push(`pod:${id} (still readable)`); else if (check.status !== 404) survivors.push(`pod:${id} (verify ${check.status})`); } - const del = await api('DELETE', '/api/users/me', { token, body: { password, confirmation: 'DELETE' } }); - if (del.status >= 300) { - survivors.push(`account (delete ${del.status})`); - } else { - // The delete reply is not proof: the account is gone only if its own - // password no longer logs in. - const login = await api('POST', '/api/auth/login', { body: { email, password } }); - if (login.status === 200) survivors.push('account (still logs in)'); + if (deleteAccount) { + const del = await api('DELETE', '/api/users/me', { token, body: { password, confirmation: 'DELETE' } }); + if (del.status >= 300) { + survivors.push(`account (delete ${del.status})`); + } else { + // The delete reply is not proof: the account is gone only if its own + // password no longer logs in. + const login = await api('POST', '/api/auth/login', { body: { email, password } }); + if (login.status === 200) survivors.push('account (still logs in)'); + } } return { ok: survivors.length === 0, survivors, botIds: [...botIds] }; diff --git a/scripts/first-user-walk-cleanup.test.mjs b/scripts/first-user-walk-cleanup.test.mjs index 1ccee62d41..8b25cebd29 100644 --- a/scripts/first-user-walk-cleanup.test.mjs +++ b/scripts/first-user-walk-cleanup.test.mjs @@ -194,3 +194,27 @@ test('the shared default bot is never recorded, even though it is a pod member', assert.deepEqual(result.botIds, ['scout', 'agent']); assert.ok(!result.botIds.includes('shared-bot')); }); + +test('shared sign-in account: the run never deletes the account', async () => { + const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: USER }] }); + const result = await run(backend, { podIds: ['p1'], deleteAccount: false }); + assert.equal(result.ok, true); + assert.equal(backend.state.accountGone, false); + assert.ok(!backend.state.calls.includes('DELETE /api/users/me'), `calls: ${backend.state.calls}`); + assert.ok(!backend.state.calls.includes('POST /api/auth/login'), 'no login check when the account is kept'); +}); + +test('shared sign-in account: only the recorded pods go, not other pods the account owns', async () => { + const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: USER }, { _id: 'old', createdBy: USER }] }); + const result = await run(backend, { podIds: ['p1'], deleteAccount: false }); + assert.equal(result.ok, true); + assert.deepEqual(backend.state.pods.map((p) => p._id), ['old']); + assert.ok(!backend.state.calls.includes('DELETE /api/pods/old')); +}); + +test('shared sign-in account: a recorded pod that survives is still a survivor', async () => { + const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: USER }], stuck: new Set(['p1']) }); + const result = await run(backend, { podIds: ['p1'], deleteAccount: false }); + assert.equal(result.ok, false); + assert.ok(result.survivors.includes('pod:p1 (still readable)'), `survivors: ${result.survivors}`); +}); diff --git a/scripts/first-user-walk-mode.mjs b/scripts/first-user-walk-mode.mjs new file mode 100644 index 0000000000..6be1258f13 --- /dev/null +++ b/scripts/first-user-walk-mode.mjs @@ -0,0 +1,13 @@ +// How the walk gets its account (TASK-706). Prod signs up a fresh account every +// run. Staging needs a verified email, which a walk address cannot receive, so +// SRE pre-verifies one shared walk account there and the walk signs in to it +// instead. Its credentials come from env only, and that account is never deleted. +export const chooseWalkMode = ({ base, env }) => { + const email = env.COMMONLY_WALK_EMAIL || ''; + if (!email) return { mode: 'signup' }; + const host = new URL(base).hostname; + if (!/staging/.test(host)) { + throw new Error('COMMONLY_WALK_EMAIL is set but the target is not staging; the shared sign-in account exists on staging only'); + } + return { mode: 'signin', email }; +}; diff --git a/scripts/first-user-walk-mode.test.mjs b/scripts/first-user-walk-mode.test.mjs new file mode 100644 index 0000000000..b3259fe4b0 --- /dev/null +++ b/scripts/first-user-walk-mode.test.mjs @@ -0,0 +1,20 @@ +// Run: node --test scripts/first-user-walk-mode.test.mjs +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { chooseWalkMode } from './first-user-walk-mode.mjs'; + +const STAGING = 'https://api-staging.commonly.me'; +const PROD = 'https://api.commonly.me'; + +test('staging with COMMONLY_WALK_EMAIL takes the sign-in path', () => { + assert.deepEqual(chooseWalkMode({ base: STAGING, env: { COMMONLY_WALK_EMAIL: 'walk@commonly.me' } }), { mode: 'signin', email: 'walk@commonly.me' }); +}); + +test('no COMMONLY_WALK_EMAIL signs up a fresh account, on any target', () => { + assert.deepEqual(chooseWalkMode({ base: PROD, env: {} }), { mode: 'signup' }); + assert.deepEqual(chooseWalkMode({ base: STAGING, env: {} }), { mode: 'signup' }); +}); + +test('the shared sign-in account is refused on prod', () => { + assert.throws(() => chooseWalkMode({ base: PROD, env: { COMMONLY_WALK_EMAIL: 'walk@commonly.me' } }), /staging only/); +}); diff --git a/scripts/first-user-walk.mjs b/scripts/first-user-walk.mjs index 805ed3e9fa..6faca442ca 100644 --- a/scripts/first-user-walk.mjs +++ b/scripts/first-user-walk.mjs @@ -14,6 +14,12 @@ * Usage: * COMMONLY_WALK_PASSWORD=... node scripts/first-user-walk.mjs [--base https://api.commonly.me] * + * Staging: signup needs a verified email, so SRE's shared walk account is used + * instead. Set COMMONLY_WALK_EMAIL and COMMONLY_WALK_PASSWORD from env (never a + * flag default or a file in the repo) and pass the staging API as --base. The + * run signs in, removes only its own pods, seats and agents, and never deletes + * that account. A COMMONLY_WALK_EMAIL aimed at any other host is refused. + * * Not yet covered: the Bring your own connect step (copy the command, run it * under a throwaway HOME, get a live reply). It is reported as NOT COVERED and * the run exits 2, so a green run never claims more than it checked. @@ -23,6 +29,7 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import path from 'node:path'; import { cleanupRun, ownerOf } from './first-user-walk-cleanup.mjs'; +import { chooseWalkMode } from './first-user-walk-mode.mjs'; const WALK_PREFIX = 'walk-'; // Every request is bounded, cleanup included, so one stalled socket cannot @@ -39,12 +46,14 @@ const BASE = (flag('base', process.env.COMMONLY_API_URL || 'https://api.commonly const now = Date.now(); const stamp = now.toString(36); const username = `${WALK_PREFIX}${stamp}`; -const email = `${username}@example.invalid`; +let email = `${username}@example.invalid`; const agentName = `${username}-agent`; const results = []; let step = 'init'; // True once signup may have created the account, so cleanup knows to look for it. let accountMayExist = false; +// 'signup' makes a fresh account; 'signin' uses the shared staging account and never deletes it. +let walkMode = { mode: 'signup' }; const api = async (method, path, { token, body } = {}) => { const res = await fetch(`${BASE}${path}`, { @@ -87,7 +96,7 @@ const expect = (cond, msg) => { if (!cond) throw new Error(msg); }; const STATE_DIR = process.env.COMMONLY_WALK_STATE_DIR || path.join(homedir(), '.commonly', 'state', 'walk-706'); // Signup returns no user id, so the manifest carries the exact username instead: // SRE finds the account by that name, not by a prefix. Never the email. -const manifest = { userId: null, username: null, podIds: [], agentName: null, botIds: [], survivors: [], status: 'running' }; +const manifest = { mode: null, userId: null, username: null, podIds: [], agentName: null, botIds: [], survivors: [], status: 'running' }; const writeManifest = () => { mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 }); writeFileSync(path.join(STATE_DIR, `${stamp}.json`), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 }); @@ -128,6 +137,7 @@ const cleanup = async (token, password) => { userId: manifest.userId, agentName, podIds: manifest.podIds, + deleteAccount: walkMode.mode === 'signup', // Persist each pod's bot ids before that pod is deleted, so a throw // later in the loop still leaves the ids SRE needs. onBotIds: (podId, bots) => { @@ -151,23 +161,28 @@ const cleanup = async (token, password) => { const main = async () => { const password = process.env.COMMONLY_WALK_PASSWORD; if (!password) throw new Error('COMMONLY_WALK_PASSWORD is not set'); + walkMode = chooseWalkMode({ base: BASE, env: process.env }); + if (walkMode.mode === 'signin') email = walkMode.email; + manifest.mode = walkMode.mode; let token = null; let exitCode = 0; try { - await runStep('signup', async () => { - manifest.username = username; - writeManifest(); - let r; - try { - r = await api('POST', '/api/auth/register', { body: { username, email, password } }); - } catch (error) { - accountMayExist = true; // the request may have landed before the failure - throw error; - } - if (r.status === 201) accountMayExist = true; - expect(r.status === 201, `register ${r.status}: ${JSON.stringify(r.data)}`); - }); + if (walkMode.mode === 'signup') { + await runStep('signup', async () => { + manifest.username = username; + writeManifest(); + let r; + try { + r = await api('POST', '/api/auth/register', { body: { username, email, password } }); + } catch (error) { + accountMayExist = true; // the request may have landed before the failure + throw error; + } + if (r.status === 201) accountMayExist = true; + expect(r.status === 201, `register ${r.status}: ${JSON.stringify(r.data)}`); + }); + } token = await runStep('login', async () => { const r = await api('POST', '/api/auth/login', { body: { email, password } }); @@ -181,7 +196,9 @@ const main = async () => { // Signup creates a workspace pod. Check it exists before the walk makes its // own pod, so a failed signup workspace cannot be hidden by the second pod. - await runStep('signup-workspace', async () => { + // Signup-only: on the shared account a workspace pod already exists, and + // recording it here would make cleanup delete a pod this run did not create. + if (walkMode.mode === 'signup') await runStep('signup-workspace', async () => { const list = await api('GET', '/api/pods', { token }); const pods = Array.isArray(list.data) ? list.data : (list.data?.pods || []); const owned = list.status === 200 ? pods.filter((p) => ownerOf(p) === manifest.userId) : []; From 8d5d7fac89cda9eec2559a578c92b951cd81dce7 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:24:36 -0700 Subject: [PATCH 2/5] TASK-706: harden staging sign-in after review - Staging check is an exact https host allowlist. The old substring match would have sent the shared password to any host containing "staging". - A kept account's Scout bot is its own, not this run's, so it is never recorded for the admin purge. - A pod create that lands without an id is recorded by its unique name before the step fails, so cleanup still removes it. - Login failures report status and error code only, not the body. Tests: 26 pass. Written by Rivet, a Commonly agent. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: rivet Agent-Model: claude/claude-haiku-5-5/default --- scripts/first-user-walk-cleanup.mjs | 10 ++++++---- scripts/first-user-walk-cleanup.test.mjs | 17 +++++++++++++++++ scripts/first-user-walk-mode.mjs | 10 +++++++--- scripts/first-user-walk-mode.test.mjs | 10 ++++++++++ scripts/first-user-walk.mjs | 19 +++++++++++++++++-- 5 files changed, 57 insertions(+), 9 deletions(-) diff --git a/scripts/first-user-walk-cleanup.mjs b/scripts/first-user-walk-cleanup.mjs index 5190698b3d..b3e3c9c9af 100644 --- a/scripts/first-user-walk-cleanup.mjs +++ b/scripts/first-user-walk-cleanup.mjs @@ -18,10 +18,12 @@ export const ownerOf = (pod) => String(pod.createdBy?._id ?? pod.createdBy ?? '' // Only the bots this run created go in the manifest: the hosted agent (its // username carries this run's agent name) and the per-user Scout (`scout-u…`). // The shared default bot is a member of every new pod and is never recorded, -// because SRE's purge would delete an identity every pod uses. -export const isRunBot = (member, agentName) => { +// because SRE's purge would delete an identity every pod uses. `scout: false` +// is for a kept account: its Scout is that account's own bot, not this run's. +export const isRunBot = (member, agentName, { scout = true } = {}) => { const name = String(member?.username || ''); - return member?.isBot === true && (name === agentName || name.startsWith(`${agentName}-`) || name.startsWith('scout-u')); + const isScout = scout && name.startsWith('scout-u'); + return member?.isBot === true && (name === agentName || name.startsWith(`${agentName}-`) || isScout); }; // `api` is the walk's request helper: (method, path, { token, body }) => { status, data }. @@ -70,7 +72,7 @@ export const cleanupRun = async ({ api, token, password, email, userId, agentNam survivors.push(`pod:${id} (lookup ${detail.status})`); continue; } - const bots = (detail.data?.members || []).filter((m) => isRunBot(m, agentName)).map((m) => String(m._id)); + const bots = (detail.data?.members || []).filter((m) => isRunBot(m, agentName, { scout: deleteAccount })).map((m) => String(m._id)); bots.forEach((bot) => botIds.add(bot)); onBotIds(id, bots); diff --git a/scripts/first-user-walk-cleanup.test.mjs b/scripts/first-user-walk-cleanup.test.mjs index 8b25cebd29..3f9e472ac9 100644 --- a/scripts/first-user-walk-cleanup.test.mjs +++ b/scripts/first-user-walk-cleanup.test.mjs @@ -218,3 +218,20 @@ test('shared sign-in account: a recorded pod that survives is still a survivor', assert.equal(result.ok, false); assert.ok(result.survivors.includes('pod:p1 (still readable)'), `survivors: ${result.survivors}`); }); + +test('shared sign-in account: its own Scout bot is never recorded for purge', async () => { + const members = [ + { _id: 'account-scout', isBot: true, username: 'scout-uowner1' }, + { _id: 'agent', isBot: true, username: 'walk-x-agent' }, + ]; + const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: USER, members }] }); + const result = await run(backend, { podIds: ['p1'], deleteAccount: false }); + assert.deepEqual(result.botIds, ['agent']); +}); + +test('a signup run still records the Scout it created', async () => { + const members = [{ _id: 'run-scout', isBot: true, username: 'scout-unew1' }]; + const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: USER, members }] }); + const result = await run(backend); + assert.deepEqual(result.botIds, ['run-scout']); +}); diff --git a/scripts/first-user-walk-mode.mjs b/scripts/first-user-walk-mode.mjs index 6be1258f13..010540957f 100644 --- a/scripts/first-user-walk-mode.mjs +++ b/scripts/first-user-walk-mode.mjs @@ -2,12 +2,16 @@ // run. Staging needs a verified email, which a walk address cannot receive, so // SRE pre-verifies one shared walk account there and the walk signs in to it // instead. Its credentials come from env only, and that account is never deleted. +// Exact host and https only: a substring match would send the shared password to +// any host whose name contains "staging". +const STAGING_HOSTS = new Set(['api-staging.commonly.me']); + export const chooseWalkMode = ({ base, env }) => { const email = env.COMMONLY_WALK_EMAIL || ''; if (!email) return { mode: 'signup' }; - const host = new URL(base).hostname; - if (!/staging/.test(host)) { - throw new Error('COMMONLY_WALK_EMAIL is set but the target is not staging; the shared sign-in account exists on staging only'); + const url = new URL(base); + if (url.protocol !== 'https:' || !STAGING_HOSTS.has(url.hostname)) { + throw new Error('COMMONLY_WALK_EMAIL is set but the target is not the staging API over https; the shared sign-in account exists on staging only'); } return { mode: 'signin', email }; }; diff --git a/scripts/first-user-walk-mode.test.mjs b/scripts/first-user-walk-mode.test.mjs index b3259fe4b0..4f3429c95f 100644 --- a/scripts/first-user-walk-mode.test.mjs +++ b/scripts/first-user-walk-mode.test.mjs @@ -18,3 +18,13 @@ test('no COMMONLY_WALK_EMAIL signs up a fresh account, on any target', () => { test('the shared sign-in account is refused on prod', () => { assert.throws(() => chooseWalkMode({ base: PROD, env: { COMMONLY_WALK_EMAIL: 'walk@commonly.me' } }), /staging only/); }); + +test('a host that merely contains "staging" never receives the shared account', () => { + for (const base of ['https://staging.attacker.example', 'https://api-staging.commonly.me.attacker.example', 'https://evil-staging.example']) { + assert.throws(() => chooseWalkMode({ base, env: { COMMONLY_WALK_EMAIL: 'walk@commonly.me' } }), /staging API over https/, base); + } +}); + +test('the shared sign-in account is refused over plain http, even on the staging host', () => { + assert.throws(() => chooseWalkMode({ base: 'http://api-staging.commonly.me', env: { COMMONLY_WALK_EMAIL: 'walk@commonly.me' } }), /over https/); +}); diff --git a/scripts/first-user-walk.mjs b/scripts/first-user-walk.mjs index 6faca442ca..79bfdbaa28 100644 --- a/scripts/first-user-walk.mjs +++ b/scripts/first-user-walk.mjs @@ -186,7 +186,8 @@ const main = async () => { token = await runStep('login', async () => { const r = await api('POST', '/api/auth/login', { body: { email, password } }); - expect(r.status === 200 && r.data?.token, `login ${r.status}: ${JSON.stringify(r.data)}`); + // Status and error code only: a body could echo the shared email into the log. + expect(r.status === 200 && r.data?.token, `login ${r.status}${r.data?.code ? ` ${r.data.code}` : ''}`); expect(r.data.user?.role !== 'admin', 'walk account is admin; the walk is meaningless'); manifest.userId = String(r.data.user?._id || r.data.user?.id || ''); expect(manifest.userId, 'login returned no user id'); @@ -210,8 +211,22 @@ const main = async () => { }); const pod = await runStep('first-pod', async () => { - const r = await api('POST', '/api/pods', { token, body: { name: `${username} workspace`, type: 'team' } }); + const podName = `${username} workspace`; + const r = await api('POST', '/api/pods', { token, body: { name: podName, type: 'team' } }); const created = r.data?.pod || r.data; + if (r.status < 300 && !created?._id) { + // The create may have landed without an id in the reply. Record any pod + // this run made, matched by its unique name, before the step fails, so + // cleanup still removes it. + const list = await api('GET', '/api/pods', { token }); + const pods = Array.isArray(list.data) ? list.data : (list.data?.pods || []); + for (const p of pods) { + if (p.name === podName && ownerOf(p) === manifest.userId && !manifest.podIds.includes(String(p._id))) { + manifest.podIds.push(String(p._id)); + } + } + writeManifest(); + } expect(r.status < 300 && created?._id, `pod create ${r.status}: ${JSON.stringify(r.data)}`); manifest.podIds.push(String(created._id)); writeManifest(); From 1f5fd80db06a61fc88a5339d5d7856dc784583d0 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:43:08 -0700 Subject: [PATCH 3/5] TASK-706: address CodeRabbit on #3126 - Requests refuse redirects (redirect: 'error'), so the shared password is never replayed to another origin. - Ambiguous pod creates (an error, or a reply without an id) are recovered by unique name; if the list cannot be read, the name is reported as an unresolved survivor and the run is needs-admin-purge. - deleteAccount is decided once in chooseWalkMode and handed to cleanup; tests run the staging handoff and assert the account is never deleted. Tests: 30 pass. Written by Rivet, a Commonly agent. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: rivet Agent-Model: claude/claude-haiku-5-5/default --- scripts/first-user-walk-cleanup.mjs | 20 +++++++++++ scripts/first-user-walk-cleanup.test.mjs | 38 ++++++++++++++++++++- scripts/first-user-walk-mode.mjs | 6 ++-- scripts/first-user-walk-mode.test.mjs | 8 ++--- scripts/first-user-walk.mjs | 43 ++++++++++++++---------- 5 files changed, 91 insertions(+), 24 deletions(-) diff --git a/scripts/first-user-walk-cleanup.mjs b/scripts/first-user-walk-cleanup.mjs index b3e3c9c9af..aed8794e72 100644 --- a/scripts/first-user-walk-cleanup.mjs +++ b/scripts/first-user-walk-cleanup.mjs @@ -26,6 +26,26 @@ export const isRunBot = (member, agentName, { scout = true } = {}) => { return member?.isBot === true && (name === agentName || name.startsWith(`${agentName}-`) || isScout); }; +// After an ambiguous pod create (an error, or a reply without an id), the pod +// may exist. Find this run's pods by their unique name and record them, so +// cleanup removes them. Returns false when the list cannot be read, which means +// the caller must report the name as an unresolved survivor. +export const recoverRunPods = async ({ api, token, userId, podName, podIds }) => { + try { + const list = await api('GET', '/api/pods', { token }); + if (list.status !== 200) return false; + const pods = ids(list.data); + for (const p of pods) { + if (p.name === podName && ownerOf(p) === String(userId) && !podIds.includes(String(p._id))) { + podIds.push(String(p._id)); + } + } + return true; + } catch { + return false; + } +}; + // `api` is the walk's request helper: (method, path, { token, body }) => { status, data }. // `podIds` are the pods the walk recorded as created in its manifest. They are // removed and verified even when the owner match misses them, so a filter diff --git a/scripts/first-user-walk-cleanup.test.mjs b/scripts/first-user-walk-cleanup.test.mjs index 3f9e472ac9..86803b1d2a 100644 --- a/scripts/first-user-walk-cleanup.test.mjs +++ b/scripts/first-user-walk-cleanup.test.mjs @@ -1,7 +1,8 @@ // Run: node --test scripts/first-user-walk-cleanup.test.mjs import test from 'node:test'; import assert from 'node:assert/strict'; -import { cleanupRun } from './first-user-walk-cleanup.mjs'; +import { cleanupRun, recoverRunPods } from './first-user-walk-cleanup.mjs'; +import { chooseWalkMode } from './first-user-walk-mode.mjs'; const USER = 'user-walk'; const OTHER = 'user-other'; @@ -235,3 +236,38 @@ test('a signup run still records the Scout it created', async () => { const result = await run(backend); assert.deepEqual(result.botIds, ['run-scout']); }); + +test('handoff: the staging sign-in mode reaches cleanup as deleteAccount false, and the account is never deleted', async () => { + const { deleteAccount } = chooseWalkMode({ base: 'https://api-staging.commonly.me', env: { COMMONLY_WALK_EMAIL: 'walk@commonly.me' } }); + const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: USER }] }); + const result = await run(backend, { podIds: ['p1'], deleteAccount }); + assert.equal(result.ok, true); + assert.ok(!backend.state.calls.includes('DELETE /api/users/me'), 'the shared account must not be deleted'); +}); + +test('handoff: a signup run reaches cleanup as deleteAccount true, and the account is deleted', async () => { + const { deleteAccount } = chooseWalkMode({ base: 'https://api-staging.commonly.me', env: {} }); + const backend = fakeBackend({ pods: [] }); + await run(backend, { deleteAccount }); + assert.ok(backend.state.calls.includes('DELETE /api/users/me')); +}); + +test('recoverRunPods records this run\'s pod by its unique name, never another owner\'s', async () => { + const backend = fakeBackend({ pods: [ + { _id: 'mine', name: 'walk-x workspace', createdBy: USER }, + { _id: 'theirs', name: 'walk-x workspace', createdBy: OTHER }, + { _id: 'other-name', name: 'Team', createdBy: USER }, + ] }); + const podIds = []; + const ok = await recoverRunPods({ api: backend.api, token: 'tok', userId: USER, podName: 'walk-x workspace', podIds }); + assert.equal(ok, true); + assert.deepEqual(podIds, ['mine']); +}); + +test('recoverRunPods reports failure when the list cannot be read, so the name is unresolved', async () => { + const backend = fakeBackend({ pods: [], listFails: true }); + const podIds = []; + const ok = await recoverRunPods({ api: backend.api, token: 'tok', userId: USER, podName: 'walk-x workspace', podIds }); + assert.equal(ok, false); + assert.deepEqual(podIds, []); +}); diff --git a/scripts/first-user-walk-mode.mjs b/scripts/first-user-walk-mode.mjs index 010540957f..17baa8301a 100644 --- a/scripts/first-user-walk-mode.mjs +++ b/scripts/first-user-walk-mode.mjs @@ -6,12 +6,14 @@ // any host whose name contains "staging". const STAGING_HOSTS = new Set(['api-staging.commonly.me']); +// `deleteAccount` is the one value main() hands to cleanup, so the mode decides +// the account's fate here and nowhere else. export const chooseWalkMode = ({ base, env }) => { const email = env.COMMONLY_WALK_EMAIL || ''; - if (!email) return { mode: 'signup' }; + if (!email) return { mode: 'signup', deleteAccount: true }; const url = new URL(base); if (url.protocol !== 'https:' || !STAGING_HOSTS.has(url.hostname)) { throw new Error('COMMONLY_WALK_EMAIL is set but the target is not the staging API over https; the shared sign-in account exists on staging only'); } - return { mode: 'signin', email }; + return { mode: 'signin', email, deleteAccount: false }; }; diff --git a/scripts/first-user-walk-mode.test.mjs b/scripts/first-user-walk-mode.test.mjs index 4f3429c95f..363fa30413 100644 --- a/scripts/first-user-walk-mode.test.mjs +++ b/scripts/first-user-walk-mode.test.mjs @@ -7,12 +7,12 @@ const STAGING = 'https://api-staging.commonly.me'; const PROD = 'https://api.commonly.me'; test('staging with COMMONLY_WALK_EMAIL takes the sign-in path', () => { - assert.deepEqual(chooseWalkMode({ base: STAGING, env: { COMMONLY_WALK_EMAIL: 'walk@commonly.me' } }), { mode: 'signin', email: 'walk@commonly.me' }); + assert.deepEqual(chooseWalkMode({ base: STAGING, env: { COMMONLY_WALK_EMAIL: 'walk@commonly.me' } }), { mode: 'signin', email: 'walk@commonly.me', deleteAccount: false }); }); -test('no COMMONLY_WALK_EMAIL signs up a fresh account, on any target', () => { - assert.deepEqual(chooseWalkMode({ base: PROD, env: {} }), { mode: 'signup' }); - assert.deepEqual(chooseWalkMode({ base: STAGING, env: {} }), { mode: 'signup' }); +test('no COMMONLY_WALK_EMAIL signs up a fresh account, on any target, and that account is deleted', () => { + assert.deepEqual(chooseWalkMode({ base: PROD, env: {} }), { mode: 'signup', deleteAccount: true }); + assert.deepEqual(chooseWalkMode({ base: STAGING, env: {} }), { mode: 'signup', deleteAccount: true }); }); test('the shared sign-in account is refused on prod', () => { diff --git a/scripts/first-user-walk.mjs b/scripts/first-user-walk.mjs index 79bfdbaa28..0b12ae4a0a 100644 --- a/scripts/first-user-walk.mjs +++ b/scripts/first-user-walk.mjs @@ -28,7 +28,7 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import path from 'node:path'; -import { cleanupRun, ownerOf } from './first-user-walk-cleanup.mjs'; +import { cleanupRun, ownerOf, recoverRunPods } from './first-user-walk-cleanup.mjs'; import { chooseWalkMode } from './first-user-walk-mode.mjs'; const WALK_PREFIX = 'walk-'; @@ -64,6 +64,8 @@ const api = async (method, path, { token, body } = {}) => { ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: body ? JSON.stringify(body) : undefined, + // A redirect would replay the JSON body, shared password included, to another origin. + redirect: 'error', signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); let data = null; @@ -96,7 +98,10 @@ const expect = (cond, msg) => { if (!cond) throw new Error(msg); }; const STATE_DIR = process.env.COMMONLY_WALK_STATE_DIR || path.join(homedir(), '.commonly', 'state', 'walk-706'); // Signup returns no user id, so the manifest carries the exact username instead: // SRE finds the account by that name, not by a prefix. Never the email. -const manifest = { mode: null, userId: null, username: null, podIds: [], agentName: null, botIds: [], survivors: [], status: 'running' }; +const manifest = { mode: null, userId: null, username: null, podIds: [], unresolvedPodNames: [], agentName: null, botIds: [], survivors: [], status: 'running' }; +// A pod whose create outcome could not be checked. Its name is unique to the run, +// so SRE can find it by exact name, the same way as the account. +const unresolvedSurvivors = () => manifest.unresolvedPodNames.map((name) => `pod named "${name}" (create outcome unknown)`); const writeManifest = () => { mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 }); writeFileSync(path.join(STATE_DIR, `${stamp}.json`), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 }); @@ -122,6 +127,7 @@ const cleanup = async (token, password) => { manifest.survivors = [ ...manifest.podIds.map((id) => `pod:${id} (no sign-in for cleanup)`), ...(accountMayExist ? [`account ${manifest.username} (no sign-in for cleanup)`] : []), + ...unresolvedSurvivors(), ]; manifest.status = manifest.survivors.length ? 'needs-admin-purge' : 'clean'; writeManifest(); @@ -137,7 +143,7 @@ const cleanup = async (token, password) => { userId: manifest.userId, agentName, podIds: manifest.podIds, - deleteAccount: walkMode.mode === 'signup', + deleteAccount: walkMode.deleteAccount, // Persist each pod's bot ids before that pod is deleted, so a throw // later in the loop still leaves the ids SRE needs. onBotIds: (podId, bots) => { @@ -145,9 +151,9 @@ const cleanup = async (token, password) => { writeManifest(); }, }); - manifest.survivors = result.survivors; + manifest.survivors = [...result.survivors, ...unresolvedSurvivors()]; manifest.botIds = [...new Set([...manifest.botIds, ...result.botIds])]; - manifest.status = result.ok ? 'clean' : 'needs-admin-purge'; + manifest.status = result.ok && manifest.survivors.length === 0 ? 'clean' : 'needs-admin-purge'; } catch (error) { manifest.survivors = [`cleanup error: ${error.message}`]; manifest.status = 'needs-admin-purge'; @@ -212,21 +218,24 @@ const main = async () => { const pod = await runStep('first-pod', async () => { const podName = `${username} workspace`; - const r = await api('POST', '/api/pods', { token, body: { name: podName, type: 'team' } }); - const created = r.data?.pod || r.data; - if (r.status < 300 && !created?._id) { - // The create may have landed without an id in the reply. Record any pod - // this run made, matched by its unique name, before the step fails, so - // cleanup still removes it. - const list = await api('GET', '/api/pods', { token }); - const pods = Array.isArray(list.data) ? list.data : (list.data?.pods || []); - for (const p of pods) { - if (p.name === podName && ownerOf(p) === manifest.userId && !manifest.podIds.includes(String(p._id))) { - manifest.podIds.push(String(p._id)); - } + // An error, or a reply without an id, may still mean the pod was made. + // Look for it by its unique name before the step can fail, so cleanup + // removes it, or the name is reported as unresolved. + const recover = async () => { + if (!await recoverRunPods({ api, token, userId: manifest.userId, podName, podIds: manifest.podIds })) { + manifest.unresolvedPodNames.push(podName); } writeManifest(); + }; + let r; + try { + r = await api('POST', '/api/pods', { token, body: { name: podName, type: 'team' } }); + } catch (error) { + await recover(); + throw error; } + const created = r.data?.pod || r.data; + if (r.status < 300 && !created?._id) await recover(); expect(r.status < 300 && created?._id, `pod create ${r.status}: ${JSON.stringify(r.data)}`); manifest.podIds.push(String(created._id)); writeManifest(); From 2eca9001fd064a75ba07347b2eb57953e03538f4 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:19 -0700 Subject: [PATCH 4/5] TASK-706: close review gaps on #3126 - Pod create recovery runs on any reply that does not carry a created pod, including 4xx and 5xx, since a failed-looking reply may still have committed. - recoverRunPods returns false when a 200 body is not a pod list, so an unreadable list is reported as unresolved rather than as "no pod". - The cleanup error path keeps the unresolved pod names in the survivors. Tests: 31 pass. main() is still not exported, so the wiring is checked by reading, not by test. Written by Rivet, a Commonly agent. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: rivet Agent-Model: claude/claude-haiku-5-5/default --- scripts/first-user-walk-cleanup.mjs | 3 ++- scripts/first-user-walk-cleanup.test.mjs | 8 ++++++++ scripts/first-user-walk.mjs | 6 ++++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/scripts/first-user-walk-cleanup.mjs b/scripts/first-user-walk-cleanup.mjs index aed8794e72..973f9c1cf9 100644 --- a/scripts/first-user-walk-cleanup.mjs +++ b/scripts/first-user-walk-cleanup.mjs @@ -33,7 +33,8 @@ export const isRunBot = (member, agentName, { scout = true } = {}) => { export const recoverRunPods = async ({ api, token, userId, podName, podIds }) => { try { const list = await api('GET', '/api/pods', { token }); - if (list.status !== 200) return false; + // A 200 whose body is not a pod list is unreadable, not an empty list. + if (list.status !== 200 || !(Array.isArray(list.data) || Array.isArray(list.data?.pods))) return false; const pods = ids(list.data); for (const p of pods) { if (p.name === podName && ownerOf(p) === String(userId) && !podIds.includes(String(p._id))) { diff --git a/scripts/first-user-walk-cleanup.test.mjs b/scripts/first-user-walk-cleanup.test.mjs index 86803b1d2a..594113227e 100644 --- a/scripts/first-user-walk-cleanup.test.mjs +++ b/scripts/first-user-walk-cleanup.test.mjs @@ -271,3 +271,11 @@ test('recoverRunPods reports failure when the list cannot be read, so the name i assert.equal(ok, false); assert.deepEqual(podIds, []); }); + +test('recoverRunPods treats a 200 that is not a pod list as unresolved', async () => { + const backend = fakeBackend({ pods: [] }); + const garbage = async (method, path) => (method === 'GET' && path === '/api/pods' ? { status: 200, data: { error: 'nope' } } : backend.api(method, path)); + const podIds = []; + const ok = await recoverRunPods({ api: garbage, token: 'tok', userId: USER, podName: 'walk-x workspace', podIds }); + assert.equal(ok, false); +}); diff --git a/scripts/first-user-walk.mjs b/scripts/first-user-walk.mjs index 0b12ae4a0a..2f0c9fee6d 100644 --- a/scripts/first-user-walk.mjs +++ b/scripts/first-user-walk.mjs @@ -155,7 +155,7 @@ const cleanup = async (token, password) => { manifest.botIds = [...new Set([...manifest.botIds, ...result.botIds])]; manifest.status = result.ok && manifest.survivors.length === 0 ? 'clean' : 'needs-admin-purge'; } catch (error) { - manifest.survivors = [`cleanup error: ${error.message}`]; + manifest.survivors = [`cleanup error: ${error.message}`, ...unresolvedSurvivors()]; manifest.status = 'needs-admin-purge'; } writeManifest(); @@ -235,7 +235,9 @@ const main = async () => { throw error; } const created = r.data?.pod || r.data; - if (r.status < 300 && !created?._id) await recover(); + // Any reply without a created pod may still have committed one, including a + // 4xx or 5xx, so the outcome is checked by name rather than trusted. + if (!(r.status < 300 && created?._id)) await recover(); expect(r.status < 300 && created?._id, `pod create ${r.status}: ${JSON.stringify(r.data)}`); manifest.podIds.push(String(created._id)); writeManifest(); From 2a1808d7fa17eb71cc88fc2f5d28aa8efd16d7dc Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:58:02 -0700 Subject: [PATCH 5/5] fix(walk): recoverRunPods reports unresolved when no pod matches A valid pod list with no match for this run returned true, so cleanup could mark the run clean and leave a walk pod behind. Return true only when a matching owned pod is found. Adds the no-match test. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: rivet Agent-Model: claude/claude-haiku-5-5/default --- scripts/first-user-walk-cleanup.mjs | 17 ++++++++++------- scripts/first-user-walk-cleanup.test.mjs | 11 +++++++++++ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/scripts/first-user-walk-cleanup.mjs b/scripts/first-user-walk-cleanup.mjs index 973f9c1cf9..1b5e25ef67 100644 --- a/scripts/first-user-walk-cleanup.mjs +++ b/scripts/first-user-walk-cleanup.mjs @@ -28,20 +28,23 @@ export const isRunBot = (member, agentName, { scout = true } = {}) => { // After an ambiguous pod create (an error, or a reply without an id), the pod // may exist. Find this run's pods by their unique name and record them, so -// cleanup removes them. Returns false when the list cannot be read, which means -// the caller must report the name as an unresolved survivor. +// cleanup removes them. Returns true only when a matching pod is found. It +// returns false when the list cannot be read or holds no match, and the caller +// must report the name as an unresolved survivor. An empty match would let +// cleanup mark the run clean while the pod is still live. export const recoverRunPods = async ({ api, token, userId, podName, podIds }) => { try { const list = await api('GET', '/api/pods', { token }); // A 200 whose body is not a pod list is unreadable, not an empty list. if (list.status !== 200 || !(Array.isArray(list.data) || Array.isArray(list.data?.pods))) return false; - const pods = ids(list.data); - for (const p of pods) { - if (p.name === podName && ownerOf(p) === String(userId) && !podIds.includes(String(p._id))) { - podIds.push(String(p._id)); + let found = false; + for (const p of ids(list.data)) { + if (p.name === podName && ownerOf(p) === String(userId)) { + found = true; + if (!podIds.includes(String(p._id))) podIds.push(String(p._id)); } } - return true; + return found; } catch { return false; } diff --git a/scripts/first-user-walk-cleanup.test.mjs b/scripts/first-user-walk-cleanup.test.mjs index 594113227e..d75f562dd5 100644 --- a/scripts/first-user-walk-cleanup.test.mjs +++ b/scripts/first-user-walk-cleanup.test.mjs @@ -272,6 +272,17 @@ test('recoverRunPods reports failure when the list cannot be read, so the name i assert.deepEqual(podIds, []); }); +test('recoverRunPods reports unresolved when a valid list has no match for this run', async () => { + const backend = fakeBackend({ pods: [ + { _id: 'theirs', name: 'walk-x workspace', createdBy: OTHER }, + { _id: 'other-name', name: 'Team', createdBy: USER }, + ] }); + const podIds = []; + const ok = await recoverRunPods({ api: backend.api, token: 'tok', userId: USER, podName: 'walk-x workspace', podIds }); + assert.equal(ok, false); + assert.deepEqual(podIds, []); +}); + test('recoverRunPods treats a 200 that is not a pod list as unresolved', async () => { const backend = fakeBackend({ pods: [] }); const garbage = async (method, path) => (method === 'GET' && path === '/api/pods' ? { status: 200, data: { error: 'nope' } } : backend.api(method, path));