From 2ef56d5a8eb4465768e5c67c818578021eb70170 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:03:45 -0700 Subject: [PATCH 1/9] feat(scripts): first-user walk, sign-up to hosted reply and invite (TASK-706) Adds scripts/first-user-walk.mjs: a fresh non-admin account per run, with a walk- username prefix Growth can exclude. Steps: signup, login, first pod, hosted install and provision, hosted reply to a mention, invite link preview. Each step prints pass/fail, its duration and the first error, and the account is deleted at the end. The Bring your own connect step is not implemented yet and is reported NOT COVERED with exit 2, so a green run never claims it. Password comes from COMMONLY_WALK_PASSWORD. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: kai Agent-Model: claude/claude-haiku-5-5/default --- scripts/first-user-walk.mjs | 154 ++++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 scripts/first-user-walk.mjs diff --git a/scripts/first-user-walk.mjs b/scripts/first-user-walk.mjs new file mode 100644 index 0000000000..1f587d4e5d --- /dev/null +++ b/scripts/first-user-walk.mjs @@ -0,0 +1,154 @@ +#!/usr/bin/env node +/** + * Daily first-user walk (TASK-706, D141). A brand-new non-admin account, as a + * stranger would meet Commonly: sign up, get a hosted agent reply in the first + * pod, and invite a person whose link opens. Every step prints pass/fail, its + * duration, and the first error text. A failure exits 1 and names the step. + * + * Walk accounts are recognisable by WALK_PREFIX (`walk-`), which Growth's + * numbers exclude. Each run deletes its own account at the end, so nothing + * accumulates. Password comes from COMMONLY_WALK_PASSWORD, never from this file. + * + * Usage: + * COMMONLY_WALK_PASSWORD=... node scripts/first-user-walk.mjs [--base https://api.commonly.me] + * + * 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. + */ + +const WALK_PREFIX = 'walk-'; + +const args = process.argv.slice(2); +const flag = (name, dflt) => { + const i = args.indexOf(`--${name}`); + return i >= 0 ? args[i + 1] : dflt; +}; +const BASE = (flag('base', process.env.COMMONLY_API_URL || 'https://api.commonly.me')).replace(/\/+$/, ''); + +const now = Date.now(); +const stamp = now.toString(36); +const username = `${WALK_PREFIX}${stamp}`; +const email = `${username}@example.invalid`; +const agentName = `${username}-agent`; +const results = []; +let step = 'init'; + +const api = async (method, path, { token, body } = {}) => { + const res = await fetch(`${BASE}${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'commonly-walk/first-user', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + let data = null; + try { data = await res.json(); } catch { /* non-json */ } + return { status: res.status, data }; +}; + +// Runs one named step. A thrown error or a falsy check is a failure; the +// first failure stops the walk, because later steps depend on earlier ones. +const runStep = async (name, fn) => { + step = name; + const started = Date.now(); + try { + const value = await fn(); + results.push({ name, ok: true, ms: Date.now() - started }); + console.log(`PASS ${name} (${Date.now() - started} ms)`); + return value; + } catch (error) { + results.push({ name, ok: false, ms: Date.now() - started, error: error.message }); + console.error(`FAIL ${name} (${Date.now() - started} ms): ${error.message}`); + throw error; + } +}; + +const expect = (cond, msg) => { if (!cond) throw new Error(msg); }; + +const cleanup = async (token) => { + if (!token) return; + const del = await api('DELETE', '/api/users/me', { token, body: { confirmation: 'DELETE' } }); + if (del.status >= 300) console.error(`cleanup: account delete ${del.status}: ${JSON.stringify(del.data)}`); + else console.log(`cleanup: deleted ${username}`); +}; + +const main = async () => { + const password = process.env.COMMONLY_WALK_PASSWORD; + if (!password) throw new Error('COMMONLY_WALK_PASSWORD is not set'); + + let token = null; + let exitCode = 0; + try { + await runStep('signup', async () => { + const r = await api('POST', '/api/auth/register', { body: { username, email, password } }); + 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 } }); + expect(r.status === 200 && r.data?.token, `login ${r.status}: ${JSON.stringify(r.data)}`); + expect(r.data.user?.role !== 'admin', 'walk account is admin; the walk is meaningless'); + return r.data.token; + }); + + const pod = await runStep('first-pod', async () => { + const r = await api('POST', '/api/pods', { token, body: { name: `${username} workspace`, type: 'team' } }); + const created = r.data?.pod || r.data; + expect(r.status < 300 && created?._id, `pod create ${r.status}: ${JSON.stringify(r.data)}`); + return created; + }); + + await runStep('hosted-install', async () => { + const r = await api('POST', '/api/registry/install', { + token, + body: { agentName, podId: pod._id, displayName: agentName, config: { runtime: { runtimeType: 'hosted' } }, scopes: [] }, + }); + expect(r.status < 400, `install ${r.status}: ${JSON.stringify(r.data)}`); + const p = await api('POST', '/api/hosted/provision', { token, body: { agentName } }); + expect(p.status === 200 && p.data?.provisioned, `provision ${p.status}: ${JSON.stringify(p.data)}`); + }); + + await runStep('hosted-reply', async () => { + const m = await api('POST', `/api/messages/${pod._id}`, { + token, + body: { content: `@${agentName} walk check: reply with one short sentence containing KINGFISHER.` }, + }); + expect(m.status === 200, `mention ${m.status}: ${JSON.stringify(m.data)}`); + expect((m.data?.agentDelivery?.enqueued ?? 0) >= 1, 'mention was not enqueued to the agent'); + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + const msgs = await api('GET', `/api/messages/${pod._id}`, { token }); + const list = Array.isArray(msgs.data) ? msgs.data : (msgs.data?.messages || []); + if (list.some((x) => x.username === agentName && /KINGFISHER/i.test(x.content || ''))) return; + await new Promise((resolve) => { setTimeout(resolve, 3000); }); + } + throw new Error('no agent reply containing KINGFISHER within 120s'); + }); + + await runStep('invite-link', async () => { + const r = await api('POST', `/api/pods/${pod._id}/invites`, { token, body: {} }); + expect(r.status < 300 && r.data?.token, `invite create ${r.status}: ${JSON.stringify(r.data)}`); + const preview = await api('GET', `/api/invites/${r.data.token}/preview`); + expect(preview.status === 200, `invite preview ${preview.status}: ${JSON.stringify(preview.data)}`); + }); + + await runStep('byo-connect', async () => { + throw new Error('NOT COVERED: Bring your own connect step is not implemented yet'); + }).catch(() => { exitCode = 2; }); + } catch { + exitCode = 1; + } finally { + await cleanup(token); + } + + console.log(JSON.stringify({ walk: username, steps: results }, null, 2)); + process.exit(exitCode); +}; + +main().catch((error) => { + console.error(`FAIL ${step}: ${error.message}`); + process.exit(1); +}); From d8714d9735fa2645d031cbbcd88ed37adf46130b Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:40:14 -0700 Subject: [PATCH 2/9] fix(scripts): walk cleanup sends password and confirmation (TASK-706) DELETE /api/users/me needs the account password and confirmation: "DELETE". The walk sent only the confirmation, so every run left its account behind. A failed cleanup now exits 1. Adds the one-page runbook. 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 | 47 ++++++++++++++++++++++++++++++++ scripts/first-user-walk.mjs | 17 ++++++++---- 2 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 docs/runbooks/first-user-walk.md diff --git a/docs/runbooks/first-user-walk.md b/docs/runbooks/first-user-walk.md new file mode 100644 index 0000000000..5b3e1c45d7 --- /dev/null +++ b/docs/runbooks/first-user-walk.md @@ -0,0 +1,47 @@ +# First-user walk (daily, prod) + +`scripts/first-user-walk.mjs` (TASK-706, D141) runs the path a stranger takes on +commonly.me: sign up, get a hosted agent reply in the first pod, and invite a +person whose link opens. Each step prints PASS or FAIL, its duration, and the +first error text. + +## What a run creates + +- One non-admin account named `walk-`, with the email + `walk-<…>@example.invalid`. +- One team pod named `walk-<…> workspace`. +- One hosted agent named `walk-<…>-agent`, with a message in that pod. +- One invite link for that pod. + +The walk prefix `walk-` is how Growth's numbers exclude these accounts. Do not +rename it without changing the exclusion list too. + +## Run it + +```bash +COMMONLY_WALK_PASSWORD="$(openssl rand -base64 24)" \ + node scripts/first-user-walk.mjs --base https://api.commonly.me +``` + +- Generate a fresh password per run. The script reads it from the environment + and never writes it anywhere. Do not put a credential in the command you + paste into a pod, a PR or a task row. +- Exit codes: `0` every step passed, `1` a step failed or the account survived + cleanup, `2` the walk passed but a step is not yet covered (today, the + Bring-your-own connect step). + +## Cleanup is part of the run + +The walk deletes its own account in a `finally` block, sending the password and +`confirmation: "DELETE"` to `DELETE /api/users/me`. If that delete fails, the +run exits `1` and prints the error. A surviving account is a failed run, not a +warning. + +If a run fails before cleanup can run, the account is left behind. Find it by +the `walk-` prefix and delete it through the admin path. The script cannot +recover it, because the password is not stored. + +## Daily schedule + +The CTO schedules the run on the host (launchd) and posts the result to the +pod. Run it twice in a row before it counts as passing. diff --git a/scripts/first-user-walk.mjs b/scripts/first-user-walk.mjs index 1f587d4e5d..776fbcd2b3 100644 --- a/scripts/first-user-walk.mjs +++ b/scripts/first-user-walk.mjs @@ -68,11 +68,16 @@ const runStep = async (name, fn) => { const expect = (cond, msg) => { if (!cond) throw new Error(msg); }; -const cleanup = async (token) => { - if (!token) return; - const del = await api('DELETE', '/api/users/me', { token, body: { confirmation: 'DELETE' } }); - if (del.status >= 300) console.error(`cleanup: account delete ${del.status}: ${JSON.stringify(del.data)}`); - else console.log(`cleanup: deleted ${username}`); +// Returns false when the account survives, so the run can fail loudly. +const cleanup = async (token, password) => { + if (!token) return true; + const del = await api('DELETE', '/api/users/me', { token, body: { password, confirmation: 'DELETE' } }); + if (del.status >= 300) { + console.error(`cleanup: account delete ${del.status}: ${JSON.stringify(del.data)}`); + return false; + } + console.log(`cleanup: deleted ${username}`); + return true; }; const main = async () => { @@ -141,7 +146,7 @@ const main = async () => { } catch { exitCode = 1; } finally { - await cleanup(token); + if (!await cleanup(token, password)) exitCode = 1; } console.log(JSON.stringify({ walk: username, steps: results }, null, 2)); From 48add0d949b96d5e865a25b962857eb9823b8af8 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:44:42 -0700 Subject: [PATCH 3/9] docs(scripts): drop ledger ID from the walk script and runbook (TASK-706) Ledger IDs stay out of the public repo. The task ID is enough. 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 | 2 +- scripts/first-user-walk.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/runbooks/first-user-walk.md b/docs/runbooks/first-user-walk.md index 5b3e1c45d7..04982073f1 100644 --- a/docs/runbooks/first-user-walk.md +++ b/docs/runbooks/first-user-walk.md @@ -1,6 +1,6 @@ # First-user walk (daily, prod) -`scripts/first-user-walk.mjs` (TASK-706, D141) runs the path a stranger takes on +`scripts/first-user-walk.mjs` (TASK-706) runs the path a stranger takes on commonly.me: sign up, get a hosted agent reply in the first pod, and invite a person whose link opens. Each step prints PASS or FAIL, its duration, and the first error text. diff --git a/scripts/first-user-walk.mjs b/scripts/first-user-walk.mjs index 776fbcd2b3..1a0bdf4547 100644 --- a/scripts/first-user-walk.mjs +++ b/scripts/first-user-walk.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node /** - * Daily first-user walk (TASK-706, D141). A brand-new non-admin account, as a + * Daily first-user walk (TASK-706). A brand-new non-admin account, as a * stranger would meet Commonly: sign up, get a hosted agent reply in the first * pod, and invite a person whose link opens. Every step prints pass/fail, its * duration, and the first error text. A failure exits 1 and names the step. From 35a64dfa875bda5d0ba28a6db1c653036048044e Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:01:05 -0700 Subject: [PATCH 4/9] fix(scripts): walk cleanup removes its pods and checks each removal (TASK-706) The cleanup now removes the hosted seat, each pod the account created, and the account, then verifies them: a pod still listed or an account whose password still logs in is reported as a survivor and fails the run. A per-run manifest of ids (outside the repo) records what SRE purges by hand. 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 | 17 ++++- scripts/first-user-walk-cleanup.mjs | 47 ++++++++++++++ scripts/first-user-walk-cleanup.test.mjs | 82 ++++++++++++++++++++++++ scripts/first-user-walk.mjs | 51 ++++++++++++--- 4 files changed, 184 insertions(+), 13 deletions(-) create mode 100644 scripts/first-user-walk-cleanup.mjs create mode 100644 scripts/first-user-walk-cleanup.test.mjs diff --git a/docs/runbooks/first-user-walk.md b/docs/runbooks/first-user-walk.md index 04982073f1..99b2bc8dbc 100644 --- a/docs/runbooks/first-user-walk.md +++ b/docs/runbooks/first-user-walk.md @@ -37,9 +37,20 @@ The walk deletes its own account in a `finally` block, sending the password and run exits `1` and prints the error. A surviving account is a failed run, not a warning. -If a run fails before cleanup can run, the account is left behind. Find it by -the `walk-` prefix and delete it through the admin path. The script cannot -recover it, because the password is not stored. +The walk's token removes the hosted seat, every pod the account created (matched +by `createdBy`, never a pod it did not make), and the account. The cleanup then +checks each one is gone: a pod still listed, or an account whose password still +logs in, is a survivor. A run with survivors exits 1 and names them. + +Each run writes a manifest, ids only, to `~/.commonly/state/walk-706/.json` +(override with `COMMONLY_WALK_STATE_DIR`). It holds no password, token or email. +SRE purges what the walk's token cannot reach from that manifest: the hosted +agent's User and the per-run Scout bot. The walk does not record those two ids +yet, so the purge can only use the agent name and the pod ids. + +If a run dies before login, the account has no id and no manifest, and it is left +behind. Find it by the `walk-` prefix and delete it through the admin path. The +password is random and not stored, so the script cannot recover it. ## Daily schedule diff --git a/scripts/first-user-walk-cleanup.mjs b/scripts/first-user-walk-cleanup.mjs new file mode 100644 index 0000000000..9fc8cef906 --- /dev/null +++ b/scripts/first-user-walk-cleanup.mjs @@ -0,0 +1,47 @@ +// Cleanup for one first-user walk run (TASK-706). It runs with the walk +// account's own token, because that is the only authority a run has, and it +// removes what that token can reach: the hosted seat, each pod the account +// created, then the account. Anything it cannot remove is reported by class +// in `survivors`, so the caller fails loudly instead of leaving it silent. +// +// Left for the admin purge, by design: Scout's per-user bot and the hosted +// agent's own User. Account deletion keeps independent agent users, so the +// run records their ids in the manifest and SRE purges them from there. + +const ids = (value) => (Array.isArray(value) ? value : value?.pods || []); + +// `api` is the walk's request helper: (method, path, { token, body }) => { status, data }. +export const cleanupRun = async ({ api, token, password, email, userId, agentName }) => { + const survivors = []; + + const listOwnPods = async () => { + const list = await api('GET', '/api/pods', { token }); + return ids(list.data).filter((pod) => String(pod.createdBy) === String(userId)); + }; + + // 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. + const pods = await listOwnPods(); + for (const pod of pods) { + const id = String(pod._id); + await api('DELETE', `/api/registry/agents/${encodeURIComponent(agentName)}/pods/${id}?instanceId=default`, { token }); + const removed = await api('DELETE', `/api/pods/${id}`, { token }); + if (removed.status >= 300 && removed.status !== 404) survivors.push(`pod:${id} (delete ${removed.status})`); + } + + // Verify rather than trust the delete responses: a pod that is still + // listed under this account is a survivor whatever the delete said. + for (const pod of await listOwnPods()) survivors.push(`pod:${String(pod._id)} (still listed)`); + + 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 }; +}; diff --git a/scripts/first-user-walk-cleanup.test.mjs b/scripts/first-user-walk-cleanup.test.mjs new file mode 100644 index 0000000000..47f73b15b4 --- /dev/null +++ b/scripts/first-user-walk-cleanup.test.mjs @@ -0,0 +1,82 @@ +// 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'; + +const USER = 'user-walk'; +const OTHER = 'user-other'; + +// A fake backend holding pods and one account. `stuck` names pods whose +// delete returns 200 but leaves them listed; `accountFails` makes the account +// delete return 500. Calls are recorded so the test can check the order. +const fakeBackend = ({ pods, stuck = new Set(), accountFails = false, deleteIgnored = false }) => { + const state = { pods: [...pods], accountGone: false, calls: [] }; + const api = async (method, path) => { + state.calls.push(`${method} ${path.split('?')[0]}`); + if (method === 'GET' && path === '/api/pods') { + return { status: 200, data: state.pods.map((p) => ({ ...p })) }; + } + const podMatch = path.match(/^\/api\/pods\/([^/?]+)$/); + if (method === 'DELETE' && podMatch) { + const id = podMatch[1]; + if (!stuck.has(id)) state.pods = state.pods.filter((p) => String(p._id) !== id); + return { status: 200, data: {} }; + } + if (method === 'DELETE' && path.startsWith('/api/registry/agents/')) { + return { status: 200, data: {} }; + } + if (method === 'DELETE' && path === '/api/users/me') { + if (accountFails) return { status: 500, data: { error: 'boom' } }; + if (!deleteIgnored) state.accountGone = true; + return { status: 200, data: {} }; + } + if (method === 'POST' && path === '/api/auth/login') { + return state.accountGone ? { status: 401, data: {} } : { status: 200, data: { token: 't' } }; + } + return { status: 404, data: {} }; + }; + return { api, state }; +}; + +const run = (backend, opts = {}) => cleanupRun({ + api: backend.api, token: 'tok', password: 'pw', email: 'walk@example.invalid', userId: USER, agentName: 'walk-x-agent', ...opts, +}); + +test('fails when the account still logs in after a 200 delete', async () => { + const backend = fakeBackend({ pods: [], deleteIgnored: true }); + const result = await run(backend); + assert.equal(result.ok, false); + assert.ok(result.survivors.includes('account (still logs in)'), `survivors: ${result.survivors}`); +}); + +test('a clean run removes every pod the account created, then the account', async () => { + const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: USER }, { _id: 'p2', createdBy: USER }] }); + const result = await run(backend); + assert.equal(result.ok, true); + assert.deepEqual(result.survivors, []); + assert.equal(backend.state.pods.length, 0); + assert.equal(backend.state.accountGone, true); +}); + +test('a pod the account did not create is never touched', async () => { + const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: USER }, { _id: 'pub', createdBy: OTHER }] }); + const result = await run(backend); + assert.equal(result.ok, true); + assert.deepEqual(backend.state.pods.map((p) => p._id), ['pub']); + assert.ok(!backend.state.calls.includes('DELETE /api/pods/pub')); +}); + +test('fails and names the pod when a delete leaves it listed', async () => { + const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: USER }], stuck: new Set(['p1']) }); + const result = await run(backend); + assert.equal(result.ok, false); + assert.ok(result.survivors.some((s) => s.startsWith('pod:p1')), `survivors: ${result.survivors}`); +}); + +test('fails and names the account when its delete is refused', async () => { + const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: USER }], accountFails: true }); + const result = await run(backend); + assert.equal(result.ok, false); + assert.ok(result.survivors.includes('account (delete 500)'), `survivors: ${result.survivors}`); + assert.equal(backend.state.accountGone, false); +}); diff --git a/scripts/first-user-walk.mjs b/scripts/first-user-walk.mjs index 1a0bdf4547..d2f51f16a8 100644 --- a/scripts/first-user-walk.mjs +++ b/scripts/first-user-walk.mjs @@ -6,8 +6,10 @@ * duration, and the first error text. A failure exits 1 and names the step. * * Walk accounts are recognisable by WALK_PREFIX (`walk-`), which Growth's - * numbers exclude. Each run deletes its own account at the end, so nothing - * accumulates. Password comes from COMMONLY_WALK_PASSWORD, never from this file. + * numbers exclude. Each run removes the seat, the pods it created and its own + * account, then checks each of them is gone. What the walk's token cannot + * reach (Scout, the hosted agent's User) is recorded by id in a per-run + * manifest under the state dir, for SRE's admin purge. Password comes from COMMONLY_WALK_PASSWORD, never from this file. * * Usage: * COMMONLY_WALK_PASSWORD=... node scripts/first-user-walk.mjs [--base https://api.commonly.me] @@ -17,6 +19,11 @@ * the run exits 2, so a green run never claims more than it checked. */ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import path from 'node:path'; +import { cleanupRun } from './first-user-walk-cleanup.mjs'; + const WALK_PREFIX = 'walk-'; const args = process.argv.slice(2); @@ -68,16 +75,33 @@ const runStep = async (name, fn) => { const expect = (cond, msg) => { if (!cond) throw new Error(msg); }; -// Returns false when the account survives, so the run can fail loudly. +// Per-run manifest: ids only, outside the repo, for SRE's admin purge of what +// the walk's own token cannot remove (Scout, the hosted agent's User). It holds +// no password, token or email. It is named by the run stamp, not the username. +const STATE_DIR = process.env.COMMONLY_WALK_STATE_DIR || path.join(homedir(), '.commonly', 'state', 'walk-706'); +const manifest = { userId: null, podIds: [], agentName: null, 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 }); +}; + +// Returns false when anything survives, so the run fails loudly. With no token +// (the human is already gone) the walk cannot remove pods or the seat, so it +// reports the manifest's pods as survivors rather than claiming a clean run. const cleanup = async (token, password) => { - if (!token) return true; - const del = await api('DELETE', '/api/users/me', { token, body: { password, confirmation: 'DELETE' } }); - if (del.status >= 300) { - console.error(`cleanup: account delete ${del.status}: ${JSON.stringify(del.data)}`); - return false; + if (!token) { + manifest.survivors = manifest.podIds.map((id) => `pod:${id} (no token; human already gone)`); + manifest.status = manifest.survivors.length ? 'needs-admin-purge' : 'clean'; + writeManifest(); + return manifest.survivors.length === 0; } - console.log(`cleanup: deleted ${username}`); - return true; + const result = await cleanupRun({ api, token, password, email, userId: manifest.userId, agentName }); + manifest.survivors = result.survivors; + manifest.status = result.ok ? 'clean' : 'needs-admin-purge'; + writeManifest(); + if (result.ok) console.log(`cleanup: removed ${username}`); + else console.error(`cleanup: survivors: ${result.survivors.join('; ')}`); + return result.ok; }; const main = async () => { @@ -96,6 +120,9 @@ const main = 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)}`); 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'); + writeManifest(); return r.data.token; }); @@ -103,10 +130,14 @@ const main = async () => { const r = await api('POST', '/api/pods', { token, body: { name: `${username} workspace`, type: 'team' } }); const created = r.data?.pod || r.data; expect(r.status < 300 && created?._id, `pod create ${r.status}: ${JSON.stringify(r.data)}`); + manifest.podIds.push(String(created._id)); + writeManifest(); return created; }); await runStep('hosted-install', async () => { + manifest.agentName = agentName; + writeManifest(); const r = await api('POST', '/api/registry/install', { token, body: { agentName, podId: pod._id, displayName: agentName, config: { runtime: { runtimeType: 'hosted' } }, scopes: [] }, From a3732c36db49361df9e91fef3a51fda29c66ece7 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:05:26 -0700 Subject: [PATCH 5/9] fix(scripts): walk cleanup matches populated createdBy and verifies recorded pods (TASK-706) GET /api/pods can return createdBy as a populated object, so String(pod.createdBy) matched nothing and the cleanup silently skipped every pod. Accept either shape, and also remove and re-check the pod ids the walk recorded in its manifest. Bot member ids are collected before each pod goes, for the admin purge. 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 | 35 ++++++++++++++++++------ scripts/first-user-walk-cleanup.test.mjs | 33 ++++++++++++++++++++++ scripts/first-user-walk.mjs | 5 ++-- 3 files changed, 63 insertions(+), 10 deletions(-) diff --git a/scripts/first-user-walk-cleanup.mjs b/scripts/first-user-walk-cleanup.mjs index 9fc8cef906..2143e3c170 100644 --- a/scripts/first-user-walk-cleanup.mjs +++ b/scripts/first-user-walk-cleanup.mjs @@ -10,28 +10,47 @@ const ids = (value) => (Array.isArray(value) ? value : value?.pods || []); +// GET /api/pods returns createdBy either as an id or as a populated +// { _id, ... } object, depending on the route. Accept both. +const ownerOf = (pod) => String(pod.createdBy?._id ?? pod.createdBy ?? ''); + // `api` is the walk's request helper: (method, path, { token, body }) => { status, data }. -export const cleanupRun = async ({ api, token, password, email, userId, agentName }) => { +// `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 +// miss cannot turn into a silent survivor. +export const cleanupRun = async ({ api, token, password, email, userId, agentName, podIds = [] }) => { const survivors = []; const listOwnPods = async () => { const list = await api('GET', '/api/pods', { token }); - return ids(list.data).filter((pod) => String(pod.createdBy) === String(userId)); + return ids(list.data).filter((pod) => ownerOf(pod) === String(userId)); }; // 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. - const pods = await listOwnPods(); - for (const pod of pods) { - const id = String(pod._id); + // whose createdBy is this account is the run's own footprint. Bot members + // are read before the delete, because they outlive the account and the + // admin purge needs their ids. + const botIds = new Set(); + const known = new Set(podIds.map(String)); + for (const pod of await listOwnPods()) known.add(String(pod._id)); + for (const id of known) { + const detail = await api('GET', `/api/pods/${id}`, { token }); + for (const member of detail.data?.members || []) { + if (member?.isBot) botIds.add(String(member._id)); + } await api('DELETE', `/api/registry/agents/${encodeURIComponent(agentName)}/pods/${id}?instanceId=default`, { token }); const removed = await api('DELETE', `/api/pods/${id}`, { token }); if (removed.status >= 300 && removed.status !== 404) survivors.push(`pod:${id} (delete ${removed.status})`); } // Verify rather than trust the delete responses: a pod that is still - // listed under this account is a survivor whatever the delete said. + // listed under this account, or still readable by id, is a survivor + // whatever the delete said. for (const pod of await listOwnPods()) survivors.push(`pod:${String(pod._id)} (still listed)`); + for (const id of known) { + const after = await api('GET', `/api/pods/${id}`, { token }); + if (after.status === 200) survivors.push(`pod:${id} (still readable)`); + } const del = await api('DELETE', '/api/users/me', { token, body: { password, confirmation: 'DELETE' } }); if (del.status >= 300) { @@ -43,5 +62,5 @@ export const cleanupRun = async ({ api, token, password, email, userId, agentNam if (login.status === 200) survivors.push('account (still logs in)'); } - return { ok: survivors.length === 0, survivors }; + 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 47f73b15b4..17031c7c8c 100644 --- a/scripts/first-user-walk-cleanup.test.mjs +++ b/scripts/first-user-walk-cleanup.test.mjs @@ -17,6 +17,10 @@ const fakeBackend = ({ pods, stuck = new Set(), accountFails = false, deleteIgno return { status: 200, data: state.pods.map((p) => ({ ...p })) }; } const podMatch = path.match(/^\/api\/pods\/([^/?]+)$/); + if (method === 'GET' && podMatch) { + const pod = state.pods.find((p) => String(p._id) === podMatch[1]); + return pod ? { status: 200, data: pod } : { status: 404, data: {} }; + } if (method === 'DELETE' && podMatch) { const id = podMatch[1]; if (!stuck.has(id)) state.pods = state.pods.filter((p) => String(p._id) !== id); @@ -42,6 +46,14 @@ const run = (backend, opts = {}) => cleanupRun({ api: backend.api, token: 'tok', password: 'pw', email: 'walk@example.invalid', userId: USER, agentName: 'walk-x-agent', ...opts, }); +test('reports the bot members of each pod before the pod goes', async () => { + const members = [{ _id: 'bot-agent', isBot: true }, { _id: USER, isBot: false }]; + const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: USER, members }] }); + const result = await run(backend); + assert.equal(result.ok, true); + assert.deepEqual(result.botIds, ['bot-agent']); +}); + test('fails when the account still logs in after a 200 delete', async () => { const backend = fakeBackend({ pods: [], deleteIgnored: true }); const result = await run(backend); @@ -80,3 +92,24 @@ test('fails and names the account when its delete is refused', async () => { assert.ok(result.survivors.includes('account (delete 500)'), `survivors: ${result.survivors}`); assert.equal(backend.state.accountGone, false); }); + +test('matches a populated createdBy object, not only a bare id', async () => { + const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: { _id: USER, username: 'walk' } }] }); + const result = await run(backend); + assert.equal(result.ok, true); + assert.equal(backend.state.pods.length, 0); +}); + +test('removes a recorded pod the owner match misses, and checks it is gone', async () => { + const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: { _id: OTHER } }] }); + const result = await run(backend, { podIds: ['p1'] }); + assert.equal(result.ok, true); + assert.equal(backend.state.pods.length, 0); +}); + +test('fails and names a recorded pod that is still readable after delete', async () => { + const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: { _id: OTHER } }], stuck: new Set(['p1']) }); + const result = await run(backend, { podIds: ['p1'] }); + assert.equal(result.ok, false); + assert.ok(result.survivors.includes('pod:p1 (still readable)'), `survivors: ${result.survivors}`); +}); diff --git a/scripts/first-user-walk.mjs b/scripts/first-user-walk.mjs index d2f51f16a8..b962357e7b 100644 --- a/scripts/first-user-walk.mjs +++ b/scripts/first-user-walk.mjs @@ -79,7 +79,7 @@ const expect = (cond, msg) => { if (!cond) throw new Error(msg); }; // the walk's own token cannot remove (Scout, the hosted agent's User). It holds // no password, token or email. It is named by the run stamp, not the username. const STATE_DIR = process.env.COMMONLY_WALK_STATE_DIR || path.join(homedir(), '.commonly', 'state', 'walk-706'); -const manifest = { userId: null, podIds: [], agentName: null, survivors: [], status: 'running' }; +const manifest = { userId: 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 }); @@ -95,8 +95,9 @@ const cleanup = async (token, password) => { writeManifest(); return manifest.survivors.length === 0; } - const result = await cleanupRun({ api, token, password, email, userId: manifest.userId, agentName }); + const result = await cleanupRun({ api, token, password, email, userId: manifest.userId, agentName, podIds: manifest.podIds }); manifest.survivors = result.survivors; + manifest.botIds = result.botIds; manifest.status = result.ok ? 'clean' : 'needs-admin-purge'; writeManifest(); if (result.ok) console.log(`cleanup: removed ${username}`); From 87c7f8813fac4bbba2087d7d1c3f76cb8b69e5d9 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:36:35 -0700 Subject: [PATCH 6/9] docs(runbooks): walk manifest records bot member ids (TASK-706) 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 | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/runbooks/first-user-walk.md b/docs/runbooks/first-user-walk.md index 99b2bc8dbc..2aa39a02c9 100644 --- a/docs/runbooks/first-user-walk.md +++ b/docs/runbooks/first-user-walk.md @@ -43,10 +43,11 @@ checks each one is gone: a pod still listed, or an account whose password still logs in, is a survivor. A run with survivors exits 1 and names them. Each run writes a manifest, ids only, to `~/.commonly/state/walk-706/.json` -(override with `COMMONLY_WALK_STATE_DIR`). It holds no password, token or email. -SRE purges what the walk's token cannot reach from that manifest: the hosted -agent's User and the per-run Scout bot. The walk does not record those two ids -yet, so the purge can only use the agent name and the pod ids. +(override with `COMMONLY_WALK_STATE_DIR`). It holds no password, token or email: +the walk's user id, its agent name, its pod ids, and the `botIds` of every bot +member of those pods, read before each pod is deleted (the hosted agent's User +and the per-run Scout bot). SRE purges what the walk's token cannot reach from +that manifest, by id, never by name prefix. If a run dies before login, the account has no id and no manifest, and it is left behind. Find it by the `walk-` prefix and delete it through the admin path. The From 6a73cf67e9572fc1a90ee0d82f005bd51cf788c0 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:44:07 -0700 Subject: [PATCH 7/9] fix(scripts): walk cleanup stops the runtime, fails closed on lookups, bounds requests (TASK-706) Addresses CodeRabbit review on #3107: - stop the hosted runtime before the seat goes (POST /api/hosted/deprovision) - a failed pod lookup, pod list, seat uninstall or verify is a survivor; a pod whose members are unknown is not deleted - bot ids are reported before each pod is deleted, so a later throw keeps them - signup that created the account but failed login: cleanup signs in once more, and reports the account if that fails - signup-workspace step checks the default pod before the walk makes its own - every request has a 20-second timeout, cleanup included Tests: 15/15 in first-user-walk-cleanup.test.mjs. 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 | 7 +- scripts/first-user-walk-cleanup.mjs | 57 ++++++++++----- scripts/first-user-walk-cleanup.test.mjs | 77 ++++++++++++++++++-- scripts/first-user-walk.mjs | 89 ++++++++++++++++++++---- 4 files changed, 192 insertions(+), 38 deletions(-) diff --git a/docs/runbooks/first-user-walk.md b/docs/runbooks/first-user-walk.md index 2aa39a02c9..c579e2af0d 100644 --- a/docs/runbooks/first-user-walk.md +++ b/docs/runbooks/first-user-walk.md @@ -37,8 +37,11 @@ The walk deletes its own account in a `finally` block, sending the password and run exits `1` and prints the error. A surviving account is a failed run, not a warning. -The walk's token removes the hosted seat, every pod the account created (matched -by `createdBy`, never a pod it did not make), and the account. The cleanup then +The walk's token stops the hosted runtime, removes the hosted seat, every pod the +account created (matched by `createdBy`, never a pod it did not make), and the +account. If signup created the account but login failed, cleanup signs in once +more; if that also fails, the account is reported as a survivor. Every request +has a 20-second timeout, cleanup included. The cleanup then checks each one is gone: a pod still listed, or an account whose password still logs in, is a survivor. A run with survivors exits 1 and names them. diff --git a/scripts/first-user-walk-cleanup.mjs b/scripts/first-user-walk-cleanup.mjs index 2143e3c170..d942c755e5 100644 --- a/scripts/first-user-walk-cleanup.mjs +++ b/scripts/first-user-walk-cleanup.mjs @@ -1,8 +1,9 @@ // Cleanup for one first-user walk run (TASK-706). It runs with the walk // account's own token, because that is the only authority a run has, and it -// removes what that token can reach: the hosted seat, each pod the account -// created, then the account. Anything it cannot remove is reported by class -// in `survivors`, so the caller fails loudly instead of leaving it silent. +// removes what that token can reach: the hosted runtime, the seat, each pod the +// account created, then the account. Anything it cannot remove or cannot +// confirm is reported by class in `survivors`, so the caller fails loudly +// instead of leaving it silent. // // Left for the admin purge, by design: Scout's per-user bot and the hosted // agent's own User. Account deletion keeps independent agent users, so the @@ -12,44 +13,66 @@ const ids = (value) => (Array.isArray(value) ? value : value?.pods || []); // GET /api/pods returns createdBy either as an id or as a populated // { _id, ... } object, depending on the route. Accept both. -const ownerOf = (pod) => String(pod.createdBy?._id ?? pod.createdBy ?? ''); +export const ownerOf = (pod) => String(pod.createdBy?._id ?? pod.createdBy ?? ''); // `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 -// miss cannot turn into a silent survivor. -export const cleanupRun = async ({ api, token, password, email, userId, agentName, podIds = [] }) => { +// 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 = () => {} }) => { const survivors = []; + const botIds = new Set(); const listOwnPods = async () => { const list = await api('GET', '/api/pods', { token }); + if (list.status !== 200) return null; return ids(list.data).filter((pod) => ownerOf(pod) === String(userId)); }; + // Stop the runtime before the seat goes: uninstalling the seat does not stop + // the hosted worker. 404 means there was no hosted install to stop. + 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)'); + const known = new Set(podIds.map(String)); + 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 // are read before the delete, because they outlive the account and the - // admin purge needs their ids. - const botIds = new Set(); - const known = new Set(podIds.map(String)); - for (const pod of await listOwnPods()) known.add(String(pod._id)); + // admin purge needs their ids. A lookup that fails is a survivor, and the + // pod is left alone: an empty member list must never read as "no bots". for (const id of known) { const detail = await api('GET', `/api/pods/${id}`, { token }); - for (const member of detail.data?.members || []) { - if (member?.isBot) botIds.add(String(member._id)); + if (detail.status === 404) continue; + if (detail.status !== 200) { + survivors.push(`pod:${id} (lookup ${detail.status})`); + continue; } - await api('DELETE', `/api/registry/agents/${encodeURIComponent(agentName)}/pods/${id}?instanceId=default`, { token }); + const bots = (detail.data?.members || []).filter((m) => m?.isBot).map((m) => String(m._id)); + bots.forEach((bot) => botIds.add(bot)); + onBotIds(id, bots); + + const seat = await api('DELETE', `/api/registry/agents/${encodeURIComponent(agentName)}/pods/${id}?instanceId=default`, { token }); + if (seat.status >= 300 && seat.status !== 404) survivors.push(`seat:${id} (uninstall ${seat.status})`); const removed = await api('DELETE', `/api/pods/${id}`, { token }); if (removed.status >= 300 && removed.status !== 404) survivors.push(`pod:${id} (delete ${removed.status})`); } // Verify rather than trust the delete responses: a pod that is still // listed under this account, or still readable by id, is a survivor - // whatever the delete said. - for (const pod of await listOwnPods()) survivors.push(`pod:${String(pod._id)} (still listed)`); + // 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)`); for (const id of known) { - const after = await api('GET', `/api/pods/${id}`, { token }); - if (after.status === 200) survivors.push(`pod:${id} (still readable)`); + 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' } }); diff --git a/scripts/first-user-walk-cleanup.test.mjs b/scripts/first-user-walk-cleanup.test.mjs index 17031c7c8c..3aa144e121 100644 --- a/scripts/first-user-walk-cleanup.test.mjs +++ b/scripts/first-user-walk-cleanup.test.mjs @@ -6,29 +6,48 @@ import { cleanupRun } from './first-user-walk-cleanup.mjs'; const USER = 'user-walk'; const OTHER = 'user-other'; -// A fake backend holding pods and one account. `stuck` names pods whose -// delete returns 200 but leaves them listed; `accountFails` makes the account -// delete return 500. Calls are recorded so the test can check the order. -const fakeBackend = ({ pods, stuck = new Set(), accountFails = false, deleteIgnored = false }) => { +// A fake backend holding pods and one account. Options: +// stuck pods whose delete returns 200 but leaves them listed +// accountFails the account delete returns 500 +// deleteIgnored the account delete returns 200 but the account still logs in +// lookupFails pods whose GET /api/pods/:id returns 500 +// listFails GET /api/pods returns 500 +// seatFails the registry seat uninstall returns 500 +// deprovisionFails POST /api/hosted/deprovision returns 500 +// throwOn a DELETE to this pod path throws (network error) +// Calls are recorded so the test can check the order. +const fakeBackend = ({ + pods, stuck = new Set(), accountFails = false, deleteIgnored = false, + lookupFails = new Set(), listFails = false, seatFails = false, deprovisionFails = false, throwOn = null, +}) => { const state = { pods: [...pods], accountGone: false, calls: [] }; const api = async (method, path) => { - state.calls.push(`${method} ${path.split('?')[0]}`); + const bare = path.split('?')[0]; + state.calls.push(`${method} ${bare}`); if (method === 'GET' && path === '/api/pods') { + if (listFails) return { status: 500, data: {} }; return { status: 200, data: state.pods.map((p) => ({ ...p })) }; } const podMatch = path.match(/^\/api\/pods\/([^/?]+)$/); if (method === 'GET' && podMatch) { + if (lookupFails.has(podMatch[1])) return { status: 500, data: { error: 'boom' } }; const pod = state.pods.find((p) => String(p._id) === podMatch[1]); return pod ? { status: 200, data: pod } : { status: 404, data: {} }; } if (method === 'DELETE' && podMatch) { const id = podMatch[1]; + if (throwOn === `/api/pods/${id}`) throw new Error('socket hang up'); if (!stuck.has(id)) state.pods = state.pods.filter((p) => String(p._id) !== id); return { status: 200, data: {} }; } if (method === 'DELETE' && path.startsWith('/api/registry/agents/')) { + if (seatFails) return { status: 500, data: {} }; return { status: 200, data: {} }; } + if (method === 'POST' && bare === '/api/hosted/deprovision') { + if (deprovisionFails) return { status: 500, data: {} }; + return { status: 200, data: { deprovisioned: true } }; + } if (method === 'DELETE' && path === '/api/users/me') { if (accountFails) return { status: 500, data: { error: 'boom' } }; if (!deleteIgnored) state.accountGone = true; @@ -113,3 +132,51 @@ test('fails and names a recorded pod that is still readable after delete', async assert.equal(result.ok, false); assert.ok(result.survivors.includes('pod:p1 (still readable)'), `survivors: ${result.survivors}`); }); + +test('a failed pod lookup is a survivor, and the pod is not deleted', async () => { + const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: USER }], lookupFails: new Set(['p1']) }); + const result = await run(backend); + assert.equal(result.ok, false); + assert.ok(result.survivors.includes('pod:p1 (lookup 500)'), `survivors: ${result.survivors}`); + assert.ok(!backend.state.calls.includes('DELETE /api/pods/p1'), 'a pod whose members are unknown must not be deleted'); +}); + +test('a failed pod list is a survivor, not an empty list of pods', async () => { + const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: USER }], listFails: true }); + const result = await run(backend); + assert.equal(result.ok, false); + assert.ok(result.survivors.includes('pods (list failed)'), `survivors: ${result.survivors}`); +}); + +test('a refused seat uninstall is a survivor', async () => { + const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: USER }], seatFails: true }); + const result = await run(backend); + assert.equal(result.ok, false); + assert.ok(result.survivors.some((s) => s.startsWith('seat:p1 (uninstall 500)')), `survivors: ${result.survivors}`); +}); + +test('the hosted runtime is stopped before the seat and pods go', async () => { + const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: USER }] }); + await run(backend); + const calls = backend.state.calls; + assert.ok(calls.indexOf('POST /api/hosted/deprovision') < calls.indexOf('DELETE /api/pods/p1')); +}); + +test('a refused deprovision is a survivor', async () => { + const backend = fakeBackend({ pods: [], deprovisionFails: true }); + const result = await run(backend); + assert.equal(result.ok, false); + assert.ok(result.survivors.includes('hosted:walk-x-agent (deprovision 500)'), `survivors: ${result.survivors}`); +}); + +test('bot ids are reported before each pod is deleted, so a later throw keeps them', async () => { + const members = [{ _id: 'bot-scout', isBot: true }]; + const backend = fakeBackend({ + pods: [{ _id: 'p1', createdBy: USER, members }, { _id: 'p2', createdBy: USER, members }], + throwOn: '/api/pods/p2', + }); + const reported = []; + await assert.rejects(run(backend, { onBotIds: (podId, bots) => reported.push([podId, bots]) }), /socket hang up/); + assert.deepEqual(reported[0], ['p1', ['bot-scout']]); + assert.ok(backend.state.calls.indexOf('DELETE /api/pods/p1') > backend.state.calls.indexOf('GET /api/pods/p1')); +}); diff --git a/scripts/first-user-walk.mjs b/scripts/first-user-walk.mjs index b962357e7b..d8a5bb6482 100644 --- a/scripts/first-user-walk.mjs +++ b/scripts/first-user-walk.mjs @@ -22,9 +22,12 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import path from 'node:path'; -import { cleanupRun } from './first-user-walk-cleanup.mjs'; +import { cleanupRun, ownerOf } from './first-user-walk-cleanup.mjs'; const WALK_PREFIX = 'walk-'; +// Every request is bounded, cleanup included, so one stalled socket cannot +// hold the run or the report open. +const REQUEST_TIMEOUT_MS = 20_000; const args = process.argv.slice(2); const flag = (name, dflt) => { @@ -40,6 +43,8 @@ const 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; const api = async (method, path, { token, body } = {}) => { const res = await fetch(`${BASE}${path}`, { @@ -50,6 +55,7 @@ const api = async (method, path, { token, body } = {}) => { ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); let data = null; try { data = await res.json(); } catch { /* non-json */ } @@ -85,24 +91,59 @@ const writeManifest = () => { writeFileSync(path.join(STATE_DIR, `${stamp}.json`), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 }); }; -// Returns false when anything survives, so the run fails loudly. With no token -// (the human is already gone) the walk cannot remove pods or the seat, so it -// reports the manifest's pods as survivors rather than claiming a clean run. +// Returns false when anything survives, so the run fails loudly. Signup can +// create the account before the walk has a token, so cleanup signs in once +// more; if that fails the account is a survivor, not a silent gap. const cleanup = async (token, password) => { - if (!token) { - manifest.survivors = manifest.podIds.map((id) => `pod:${id} (no token; human already gone)`); + let session = token; + if (!session && accountMayExist) { + try { + const r = await api('POST', '/api/auth/login', { body: { email, password } }); + if (r.status === 200 && r.data?.token) { + session = r.data.token; + manifest.userId = manifest.userId || String(r.data.user?._id || r.data.user?.id || ''); + } + } catch { + // A network failure here leaves no session: it is reported below as a survivor. + } + } + if (!session) { + manifest.survivors = [ + ...manifest.podIds.map((id) => `pod:${id} (no sign-in for cleanup)`), + ...(accountMayExist ? ['account (no sign-in for cleanup)'] : []), + ]; manifest.status = manifest.survivors.length ? 'needs-admin-purge' : 'clean'; writeManifest(); + if (manifest.survivors.length) console.error(`cleanup: ${username} survives; sign-in failed`); return manifest.survivors.length === 0; } - const result = await cleanupRun({ api, token, password, email, userId: manifest.userId, agentName, podIds: manifest.podIds }); - manifest.survivors = result.survivors; - manifest.botIds = result.botIds; - manifest.status = result.ok ? 'clean' : 'needs-admin-purge'; + try { + const result = await cleanupRun({ + api, + token: session, + password, + email, + userId: manifest.userId, + agentName, + podIds: manifest.podIds, + // 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) => { + manifest.botIds = [...new Set([...manifest.botIds, ...bots])]; + writeManifest(); + }, + }); + manifest.survivors = result.survivors; + manifest.botIds = [...new Set([...manifest.botIds, ...result.botIds])]; + manifest.status = result.ok ? 'clean' : 'needs-admin-purge'; + } catch (error) { + manifest.survivors = [`cleanup error: ${error.message}`]; + manifest.status = 'needs-admin-purge'; + } writeManifest(); - if (result.ok) console.log(`cleanup: removed ${username}`); - else console.error(`cleanup: survivors: ${result.survivors.join('; ')}`); - return result.ok; + if (manifest.status === 'clean') console.log(`cleanup: removed ${username}`); + else console.error(`cleanup: survivors: ${manifest.survivors.join('; ')}`); + return manifest.status === 'clean'; }; const main = async () => { @@ -113,7 +154,14 @@ const main = async () => { let exitCode = 0; try { await runStep('signup', async () => { - const r = await api('POST', '/api/auth/register', { body: { username, email, password } }); + 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)}`); }); @@ -127,6 +175,19 @@ const main = async () => { return r.data.token; }); + // 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 () => { + 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) : []; + expect(owned.length > 0, `no signup workspace pod (list ${list.status}, owned ${owned.length})`); + for (const p of owned) { + if (!manifest.podIds.includes(String(p._id))) manifest.podIds.push(String(p._id)); + } + writeManifest(); + }); + const pod = await runStep('first-pod', async () => { const r = await api('POST', '/api/pods', { token, body: { name: `${username} workspace`, type: 'team' } }); const created = r.data?.pod || r.data; From d8c1ef1066f20194373b6892d6451b777eeb86ef Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:46:51 -0700 Subject: [PATCH 8/9] fix(scripts): invite step is named invite-preview-api (TASK-706) The step checks the preview endpoint only. The name now says so, so a PASS cannot be read as the invite link opening. The rendered check moves to the post-deploy walk. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: rivet Agent-Model: claude/claude-haiku-5-5/default --- scripts/first-user-walk.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/first-user-walk.mjs b/scripts/first-user-walk.mjs index d8a5bb6482..ceb4a6211d 100644 --- a/scripts/first-user-walk.mjs +++ b/scripts/first-user-walk.mjs @@ -226,7 +226,9 @@ const main = async () => { throw new Error('no agent reply containing KINGFISHER within 120s'); }); - await runStep('invite-link', async () => { + // API only: this checks the invite preview endpoint. The link is not opened + // in a browser, so a PASS here does not show that the rendered page loads. + await runStep('invite-preview-api', async () => { const r = await api('POST', `/api/pods/${pod._id}/invites`, { token, body: {} }); expect(r.status < 300 && r.data?.token, `invite create ${r.status}: ${JSON.stringify(r.data)}`); const preview = await api('GET', `/api/invites/${r.data.token}/preview`); From 41490c79105b1be46d6cdc4d8904f733b60adeb1 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:51:05 -0700 Subject: [PATCH 9/9] fix(scripts): walk records only its own bots and names the account it could not sign into (TASK-706) - cleanup records only bots this run created: the hosted agent (username carries this run's agent name) and the per-user Scout (scout-u...). The shared commonly-bot is a member of every new pod and is never recorded. Test added. - signup returns no user id, so the manifest records the exact username when signup starts (never the email). If login fails after signup, the survivor names that account and the run is needs-admin-purge. - runbook updated to match. 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 | 9 ++++++--- scripts/first-user-walk-cleanup.mjs | 11 ++++++++++- scripts/first-user-walk-cleanup.test.mjs | 18 ++++++++++++++++-- scripts/first-user-walk.mjs | 8 ++++++-- 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/docs/runbooks/first-user-walk.md b/docs/runbooks/first-user-walk.md index c579e2af0d..d9511a0454 100644 --- a/docs/runbooks/first-user-walk.md +++ b/docs/runbooks/first-user-walk.md @@ -52,9 +52,12 @@ member of those pods, read before each pod is deleted (the hosted agent's User and the per-run Scout bot). SRE purges what the walk's token cannot reach from that manifest, by id, never by name prefix. -If a run dies before login, the account has no id and no manifest, and it is left -behind. Find it by the `walk-` prefix and delete it through the admin path. The -password is random and not stored, so the script cannot recover it. +Signup returns no user id, so the manifest records the run's exact username as +soon as signup starts. If signup created the account and login then failed, +cleanup reports `account ` as a survivor and the manifest is +`needs-admin-purge`: SRE looks the account up by that exact name. The manifest +never holds the email. Only a run that dies before its manifest write (before +signup starts) leaves nothing to find, and that case needs no purge. ## Daily schedule diff --git a/scripts/first-user-walk-cleanup.mjs b/scripts/first-user-walk-cleanup.mjs index d942c755e5..4637656212 100644 --- a/scripts/first-user-walk-cleanup.mjs +++ b/scripts/first-user-walk-cleanup.mjs @@ -15,6 +15,15 @@ const ids = (value) => (Array.isArray(value) ? value : value?.pods || []); // { _id, ... } object, depending on the route. Accept both. 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) => { + const name = String(member?.username || ''); + return member?.isBot === true && (name === agentName || name.startsWith(`${agentName}-`) || name.startsWith('scout-u')); +}; + // `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 @@ -52,7 +61,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) => m?.isBot).map((m) => String(m._id)); + const bots = (detail.data?.members || []).filter((m) => isRunBot(m, agentName)).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 3aa144e121..1ccee62d41 100644 --- a/scripts/first-user-walk-cleanup.test.mjs +++ b/scripts/first-user-walk-cleanup.test.mjs @@ -66,7 +66,7 @@ const run = (backend, opts = {}) => cleanupRun({ }); test('reports the bot members of each pod before the pod goes', async () => { - const members = [{ _id: 'bot-agent', isBot: true }, { _id: USER, isBot: false }]; + const members = [{ _id: 'bot-agent', isBot: true, username: 'walk-x-agent' }, { _id: USER, isBot: false, username: 'walk' }]; const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: USER, members }] }); const result = await run(backend); assert.equal(result.ok, true); @@ -170,7 +170,7 @@ test('a refused deprovision is a survivor', async () => { }); test('bot ids are reported before each pod is deleted, so a later throw keeps them', async () => { - const members = [{ _id: 'bot-scout', isBot: true }]; + const members = [{ _id: 'bot-scout', isBot: true, username: 'scout-uabc123' }]; const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: USER, members }, { _id: 'p2', createdBy: USER, members }], throwOn: '/api/pods/p2', @@ -180,3 +180,17 @@ test('bot ids are reported before each pod is deleted, so a later throw keeps th assert.deepEqual(reported[0], ['p1', ['bot-scout']]); assert.ok(backend.state.calls.indexOf('DELETE /api/pods/p1') > backend.state.calls.indexOf('GET /api/pods/p1')); }); + +test('the shared default bot is never recorded, even though it is a pod member', async () => { + const members = [ + { _id: 'shared-bot', isBot: true, username: 'commonly-bot' }, + { _id: 'scout', isBot: true, username: 'scout-uabc123' }, + { _id: 'agent', isBot: true, username: 'walk-x-agent' }, + { _id: USER, isBot: false, username: 'walk' }, + ]; + const backend = fakeBackend({ pods: [{ _id: 'p1', createdBy: USER, members }] }); + const result = await run(backend); + assert.equal(result.ok, true); + assert.deepEqual(result.botIds, ['scout', 'agent']); + assert.ok(!result.botIds.includes('shared-bot')); +}); diff --git a/scripts/first-user-walk.mjs b/scripts/first-user-walk.mjs index ceb4a6211d..805ed3e9fa 100644 --- a/scripts/first-user-walk.mjs +++ b/scripts/first-user-walk.mjs @@ -85,7 +85,9 @@ const expect = (cond, msg) => { if (!cond) throw new Error(msg); }; // the walk's own token cannot remove (Scout, the hosted agent's User). It holds // no password, token or email. It is named by the run stamp, not the username. const STATE_DIR = process.env.COMMONLY_WALK_STATE_DIR || path.join(homedir(), '.commonly', 'state', 'walk-706'); -const manifest = { userId: null, podIds: [], agentName: null, botIds: [], survivors: [], status: 'running' }; +// 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 writeManifest = () => { mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 }); writeFileSync(path.join(STATE_DIR, `${stamp}.json`), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 }); @@ -110,7 +112,7 @@ const cleanup = async (token, password) => { if (!session) { manifest.survivors = [ ...manifest.podIds.map((id) => `pod:${id} (no sign-in for cleanup)`), - ...(accountMayExist ? ['account (no sign-in for cleanup)'] : []), + ...(accountMayExist ? [`account ${manifest.username} (no sign-in for cleanup)`] : []), ]; manifest.status = manifest.survivors.length ? 'needs-admin-purge' : 'clean'; writeManifest(); @@ -154,6 +156,8 @@ const main = async () => { 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 } });