diff --git a/docs/runbooks/first-user-walk.md b/docs/runbooks/first-user-walk.md new file mode 100644 index 0000000000..d9511a0454 --- /dev/null +++ b/docs/runbooks/first-user-walk.md @@ -0,0 +1,65 @@ +# First-user walk (daily, prod) + +`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. + +## 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. + +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. + +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: +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. + +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 + +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-cleanup.mjs b/scripts/first-user-walk-cleanup.mjs new file mode 100644 index 0000000000..4637656212 --- /dev/null +++ b/scripts/first-user-walk-cleanup.mjs @@ -0,0 +1,98 @@ +// 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 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 +// run records their ids in the manifest and SRE purges them from there. + +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. +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 +// 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. 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 }); + if (detail.status === 404) continue; + if (detail.status !== 200) { + survivors.push(`pod:${id} (lookup ${detail.status})`); + continue; + } + const bots = (detail.data?.members || []).filter((m) => isRunBot(m, agentName)).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. 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 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)'); + } + + 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 new file mode 100644 index 0000000000..1ccee62d41 --- /dev/null +++ b/scripts/first-user-walk-cleanup.test.mjs @@ -0,0 +1,196 @@ +// 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. 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) => { + 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; + 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('reports the bot members of each pod before the pod goes', async () => { + 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); + 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); + 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); +}); + +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}`); +}); + +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, username: 'scout-uabc123' }]; + 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')); +}); + +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 new file mode 100644 index 0000000000..805ed3e9fa --- /dev/null +++ b/scripts/first-user-walk.mjs @@ -0,0 +1,258 @@ +#!/usr/bin/env node +/** + * 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. + * + * Walk accounts are recognisable by WALK_PREFIX (`walk-`), which Growth's + * 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] + * + * 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. + */ + +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'; + +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) => { + 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'; +// 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}`, { + method, + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'commonly-walk/first-user', + ...(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 */ } + 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); }; + +// 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'); +// 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 }); +}; + +// 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) => { + 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 ${manifest.username} (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; + } + 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 (manifest.status === 'clean') console.log(`cleanup: removed ${username}`); + else console.error(`cleanup: survivors: ${manifest.survivors.join('; ')}`); + return manifest.status === 'clean'; +}; + +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 () => { + 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 } }); + 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; + }); + + // 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; + 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: [] }, + }); + 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'); + }); + + // 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`); + 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 { + if (!await cleanup(token, password)) exitCode = 1; + } + + 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); +});