From 9cd4ae80c191d98f4bace42526ab446adacad251 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 23:09:21 +0000 Subject: [PATCH 1/2] Prefer enrollment for first-run agents Co-authored-by: Gersham Meharg --- src/cli-commands/agent.ts | 5 +- src/cli-commands/guidance.ts | 5 +- src/cli.test.ts | 105 ++++++++++++++++++++++++++++++++++- src/commands.ts | 54 ++++++++++++++---- 4 files changed, 151 insertions(+), 18 deletions(-) diff --git a/src/cli-commands/agent.ts b/src/cli-commands/agent.ts index a5525cbf..55fdedae 100644 --- a/src/cli-commands/agent.ts +++ b/src/cli-commands/agent.ts @@ -6,13 +6,14 @@ import { addCommandNotes } from "./notes.js"; export function registerAgentCommands(root: Command, bind: CommandActionBinder): void { const agent = root.command("agent").description("Enroll, connect, and manage this agent"); - agent.command("enroll").description("Create the first agent with an email address") + agent.command("enroll").description("Create a new account agent (default first-run path)") .option("--email
", "Set the account contact email") .option("--name ", "Set this agent installation name") + .option("--force", "Discard a pending existing-account connection before enrolling") .option("--open-dashboard", "Open the dashboard after enrolling") .action(bind(handleAgentEnroll)); - addCommandNotes(agent.command("connect").description("Connect this installation with approval") + addCommandNotes(agent.command("connect").description("Reconnect this installation to an existing account") .option("--name ", "Set this agent installation name") .option("--print-url", "Return the browser handoff URL") .option("--wait", "Wait for dashboard approval (30000 ms default; bounded by --timeout)") diff --git a/src/cli-commands/guidance.ts b/src/cli-commands/guidance.ts index aa0e5175..24ec5862 100644 --- a/src/cli-commands/guidance.ts +++ b/src/cli-commands/guidance.ts @@ -6,7 +6,7 @@ import { addCommandExamples, addCommandNotes, commandExamples } from "./notes.js export function registerGuidance(root: Command): void { const examples: Record = { "account show": [""], - "agent enroll": ["--email operator@example.com --name Lobby-agent"], + "agent enroll": ["--email operator@example.com --name Lobby-agent", "--force --email operator@example.com"], "agent connect": ["--print-url", "--wait --timeout 10000"], "agent status": [""], "agent disconnect": ["--yes"], @@ -63,7 +63,8 @@ export function registerGuidance(root: Command): void { if (!commandExamples(command).length) addCommandExamples(command, ...suffixes.map(suffix => `screenrig ${path}${suffix ? ` ${suffix}` : ""}`)); } const notes: Record = { - "agent enroll": "For a new account, supply the user's contact email with --email. Existing accounts use agent connect. An already enrolled installation can reuse its saved enrollment.", + "agent enroll": "The default first-run path. For a new account, ask the user for their contact email and supply it with --email. If an unwanted existing-account connection is pending, --force discards it before enrollment. Intentional existing-account reconnects use agent connect. An already enrolled installation can reuse its saved enrollment.", + "agent connect": "Existing-account reconnect only. Requires dashboard approval and resumes a pending connection when present. New accounts and first-run installations use agent enroll.", "app pack": "Local and unauthenticated. Pack an already-built static directory with a root index.html. app upload packs the directory itself; packing first is optional.", "app upload": "Creates a new application and its first immutable release. Packs the built directory and waits for processing by default (120000 ms, polling every 1000 ms). With --no-wait, use operations wait on the returned operation_id before using the release.", "app update": "Creates a new immutable release for the same application and preserves its K/V. Existing playlists remain pinned to their previous release. Use playlist replace-release to preview and apply an explicit replacement. Wait defaults: 120000 ms, polling every 1000 ms.", diff --git a/src/cli.test.ts b/src/cli.test.ts index c78d19c1..bf1470ca 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -354,6 +354,48 @@ test("agent enroll rejects missing or malformed contact email before the network await rm(malformed.configDir, { recursive: true, force: true }); }); +test("agent enroll --force discards an unwanted pending connection before enrolling", async () => { + const configDir = await testTemp("enroll-force-pending-connect-"); + const fsLike = { mkdir, open, rename, rm, chmod, stat, homedir: () => configDir, env: { XDG_CONFIG_HOME: configDir } }; + const configPath = path.join(configDir, "screenrig", "config.json"); + await writeConfigAtomic(configPath, { + api_url: "https://api.screenrig.ai", + agent_connection: { + private_jwk: { kty: "OKP", crv: "X25519", x: "pending-public", d: "pending-private" }, + connection_id: "acn_UNWANTED", + connection_token: "private-connection-token", + approval_url: "https://app.screenrig.ai/agent-connections/acn_UNWANTED", + expires_at: "2099-01-01T00:00:00Z", + }, + }, fsLike); + + const blocked = await withRuntime( + ["--json", "agent", "enroll", "--email", "owner@example.com"], + new FakeTransport(), + { fs: fsLike }, + ); + assert.equal(blocked.code, ExitCode.Usage, blocked.stdout); + assert.equal( + JSON.parse(blocked.stdout).error.next.command, + "screenrig agent enroll --force --email ADDRESS", + ); + + const transport = memoryBackend(); + const enrolled = await withRuntime( + ["--json", "agent", "enroll", "--force", "--email", "owner@example.com"], + transport, + { fs: fsLike }, + ); + assert.equal(enrolled.code, ExitCode.Success, enrolled.stdout); + assert.equal(JSON.parse(enrolled.stdout).data.status, "active"); + const config = await readConfigFile(configPath, fsLike); + assert.equal(config?.agent_connection, undefined); + assert.ok(config?.token); + assert.doesNotMatch(JSON.stringify(config), /acn_UNWANTED|private-connection-token|pending-private/); + assert.equal(transport.calls.some((call) => call.path === "/api/v1/agent-connections"), false); + await rm(configDir, { recursive: true, force: true }); +}); + test("email_conflict is terminal and generic, clears only pending enrollment, and requires a new explicit enrollment", async () => { const configDir = await testTemp("email-conflict-"); const fsLike = { mkdir, open, rename, rm, chmod, stat, homedir: () => configDir, env: { XDG_CONFIG_HOME: configDir } }; @@ -416,11 +458,36 @@ test("every authenticated command reports not_enrolled instead of enrolling", as } }); +test("revoked agent history without a pending reconnect directs authenticated commands to enroll", async () => { + const configDir = await testTemp("revoked-prefers-enroll-"); + const fsLike = { mkdir, open, rename, rm, chmod, stat, homedir: () => configDir, env: { XDG_CONFIG_HOME: configDir } }; + await writeConfigAtomic(path.join(configDir, "screenrig", "config.json"), { + api_url: "https://api.screenrig.ai", + last_agent: { + id: TEST_AGENT.id, + name: TEST_AGENT.name, + agent_type: TEST_AGENT.agent_type, + state: "revoked", + revoked_at: "2026-09-21T00:00:00.000Z", + }, + }, fsLike); + const result = await withRuntime(["--json", "account", "show"], new FakeTransport(), { fs: fsLike }); + assert.equal(result.code, ExitCode.Auth, result.stdout); + const envelope = JSON.parse(result.stdout) as { + error: { code: string; next: { command: string; reason: string } }; + }; + assert.equal(envelope.error.code, "not_enrolled"); + assert.equal(envelope.error.next.command, "screenrig agent enroll --email ADDRESS"); + assert.match(envelope.error.next.reason, /contact email/i); + await rm(configDir, { recursive: true, force: true }); +}); + test("agent status never enrolls a missing installation", async () => { const transport = new FakeTransport(); const status = await withRuntime(["--json", "agent", "status"], transport); assert.equal(status.code, 0, status.stdout); assert.equal(JSON.parse(status.stdout).data.status, "not_enrolled"); + assert.equal(JSON.parse(status.stdout).data.path, "first_run_enroll"); assert.equal(transport.calls.length, 0); await rm(status.configDir, { recursive: true, force: true }); }); @@ -731,7 +798,10 @@ test("agent disconnect locally cleans an unauthorized credential without retryin assert.equal(local?.account_id, undefined); assert.equal(local?.agent_id, undefined); const status = await withRuntime(["--json", "agent", "status"], new FakeTransport(), { fs: fsLike }); - assert.equal(JSON.parse(status.stdout).data.status, "not_enrolled"); + assert.deepEqual(JSON.parse(status.stdout).data, { + status: "not_enrolled", + path: "first_run_enroll", + }); await rm(configDir, { recursive: true, force: true }); }); @@ -807,6 +877,7 @@ test("agent disconnect revokes only this installation and preserves safe disconn assert.equal(local?.last_agent?.id, active.id); const status = await withRuntime(["--json", "agent", "status"], new FakeTransport(), { fs: fsLike }); assert.equal(JSON.parse(status.stdout).data.status, "disconnected"); + assert.equal(JSON.parse(status.stdout).data.path, "first_run_enroll"); await rm(configDir, { recursive: true, force: true }); }); @@ -2850,13 +2921,14 @@ test("doctor warns, exits 0, and names agent enroll when a fresh install has no data: { status: string; next?: { command: string; reason: string }; - checks: Array<{ name: string; status: string; detail: string; next?: { command: string; reason: string } }>; + checks: Array<{ name: string; status: string; detail: string; path?: string; next?: { command: string; reason: string } }>; }; }; assert.equal(envelope.data.status, "warn"); const token = envelope.data.checks.find((check) => check.name === "token"); assert.ok(token, stdout); assert.equal(token.status, "warn"); + assert.equal(token.path, "first_run_enroll"); assert.match(token.detail, /^\(none\); this installation is not enrolled/); assert.equal(token.next?.command, "screenrig agent enroll --email ADDRESS"); assert.equal(envelope.data.next?.command, "screenrig agent enroll --email ADDRESS"); @@ -2885,16 +2957,43 @@ test("doctor points a pending agent connection at agent connect instead of enrol const { code, stdout } = await withRuntime(["--json", "doctor"], memoryBackend(), { fs: fsLike, runProcess: fullToolchainProbe() }); assert.equal(code, ExitCode.Success, stdout); const envelope = JSON.parse(stdout) as { - data: { next?: { command: string }; checks: Array<{ name: string; status: string; next?: { command: string } }> }; + data: { next?: { command: string }; checks: Array<{ name: string; status: string; path?: string; next?: { command: string } }> }; }; const token = envelope.data.checks.find((check) => check.name === "token"); assert.equal(token?.status, "warn"); + assert.equal(token?.path, "reconnect_existing"); assert.equal(token?.next?.command, "screenrig agent connect"); assert.equal(envelope.data.next?.command, "screenrig agent connect"); assert.doesNotMatch(stdout, /acn_PENDING/); await rm(configDir, { recursive: true, force: true }); }); +test("doctor treats revoked agent history without a pending reconnect as first-run enrollment", async () => { + resetFfmpegToolchainCache(); + const configDir = await testTemp("doctor-revoked-enroll-"); + const fsLike = { mkdir, open, rename, rm, chmod, stat, homedir: () => configDir, env: { XDG_CONFIG_HOME: configDir } }; + await writeConfigAtomic(path.join(configDir, "screenrig", "config.json"), { + api_url: "https://api.screenrig.ai", + last_agent: { + id: TEST_AGENT.id, + name: TEST_AGENT.name, + agent_type: TEST_AGENT.agent_type, + state: "revoked", + }, + }, fsLike); + const result = await withRuntime(["--json", "doctor"], memoryBackend(), { fs: fsLike, runProcess: fullToolchainProbe() }); + assert.equal(result.code, ExitCode.Success, result.stdout); + const envelope = JSON.parse(result.stdout) as { + data: { next?: { command: string }; checks: Array<{ name: string; path?: string; next?: { command: string; reason: string } }> }; + }; + const token = envelope.data.checks.find((check) => check.name === "token"); + assert.equal(token?.path, "first_run_enroll"); + assert.equal(token?.next?.command, "screenrig agent enroll --email ADDRESS"); + assert.match(token?.next?.reason ?? "", /contact email/i); + assert.equal(envelope.data.next?.command, "screenrig agent enroll --email ADDRESS"); + await rm(configDir, { recursive: true, force: true }); +}); + test("control-plane payloads and mutation idempotency match the v0.2 architecture", async () => { const transport = memoryBackend(); const configDir = await testTemp("contract-"); diff --git a/src/commands.ts b/src/commands.ts index 4c3c5f2f..d4dbfaab 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -383,13 +383,13 @@ function commandHandler( if (resolved.agentConnection) { throw notEnrolledError("This installation has a pending agent connection and no active credential.", { command: "screenrig agent connect", - reason: "Resume the passkey-approved connection before running account commands.", + reason: "Resume the intentional existing-account connection before running account commands.", }); } if (resolved.lastAgent) { throw notEnrolledError("This installation is disconnected and cannot run account commands.", { - command: "screenrig agent connect", - reason: "Connect a new independently revocable agent through dashboard passkey approval.", + command: "screenrig agent enroll --email ADDRESS", + reason: "Ask the user for their contact email, then create a new account agent. Use agent connect only for an intentional existing-account reconnect.", }); } throw notEnrolledError("This installation is not enrolled. Enrollment is an explicit step and is never a side effect of another command.", { @@ -611,6 +611,7 @@ async function agentStatus( const connection = resolved.agentConnection; const data = { status: "connecting", + path: "reconnect_existing", phase: resolved.token && connection.pending_agent_id ? "activating" : connection.connection_id ? "approval" : "starting", ...(connection.connection_id ? { connection_id: connection.connection_id } : {}), ...(connection.expires_at ? { expires_at: connection.expires_at } : {}), @@ -630,7 +631,7 @@ async function agentStatus( const local = resolved.lastAgent; const status = local ? "disconnected" : "not_enrolled"; return { - envelope: successEnvelope({ status, ...(local ? { agent: local } : {}) }), + envelope: successEnvelope({ status, path: "first_run_enroll", ...(local ? { agent: local } : {}) }), exitCode: ExitCode.Success, human: humanLines("Agent", [ ["status", status], @@ -706,13 +707,21 @@ async function agentEnroll( requireFlagValue(args, "name", "Office MacBook Codex"); const name = flagString(args.flags, "name"); if (name && name.length > 80) throw usageError("agent enroll --name is at most 80 characters."); + let enrollmentConfig = resolved; if (resolved.agentConnection) { - throw usageError("A different agent connection is already pending in this config.", { - command: "screenrig agent connect", - reason: "Resume the pending connection before creating a new account.", + if (!flagBool(args.flags, "force")) { + throw usageError("A different agent connection is already pending in this config.", { + command: "screenrig agent enroll --force --email ADDRESS", + reason: "Use --force only to discard the unwanted existing-account connection and enroll a new account.", + }); + } + await clearAgentConnectionForEnrollment(runtime, resolved); + enrollmentConfig = await resolveConfig({ + flags: args.flags, + fs: { ...runtime.fs, env: runtime.env, homedir: runtime.homedir }, }); } - const enrolled = await enrollForCommand(args, runtime, resolved, { + const enrolled = await enrollForCommand(args, runtime, enrollmentConfig, { ...(name ? { name } : {}), explicit: true, }); @@ -919,6 +928,25 @@ async function clearAgentConnection( }); } +async function clearAgentConnectionForEnrollment( + runtime: CliRuntime, + resolved: Awaited>, +): Promise { + const fsLike = { ...runtime.fs, env: runtime.env, homedir: runtime.homedir }; + await withConfigLock(resolved.configPath, fsLike, { sleep: runtime.sleep, now: () => runtime.now().getTime() }, async () => { + const current = await readConfigFile(resolved.configPath, fsLike); + if (!current?.agent_connection) return; + const { agent_connection: connection, ...rest } = current; + const cleaned = connection.pending_agent_id + ? (({ token: _token, account_id: _account, agent_id: _agent, ...safe }) => safe)(rest) + : rest; + await writeConfigAtomic(resolved.configPath, { + ...cleaned, + updated_at: runtime.now().toISOString(), + }, fsLike); + }); +} + async function clearDefinitivePendingAgentFailure( runtime: CliRuntime, resolved: Awaited>, @@ -4000,6 +4028,7 @@ interface DoctorCheck { name: string; status: DoctorStatus; detail: string; + path?: "first_run_enroll" | "reconnect_existing"; /** The command that clears a `warn` or `fail` row, when one exists. */ next?: ProblemNext; } @@ -4021,9 +4050,10 @@ function credentialCheck(resolved: Awaited>): D name: "token", status: "warn", detail: `${detail}; an agent connection is pending dashboard approval`, + path: "reconnect_existing", next: { command: "screenrig agent connect", - reason: "Resume the pending passkey-approved connection, then rerun doctor.", + reason: "Resume the intentional existing-account connection, then rerun doctor.", }, }; } @@ -4032,9 +4062,10 @@ function credentialCheck(resolved: Awaited>): D name: "token", status: "warn", detail: `${detail}; this installation was disconnected`, + path: "first_run_enroll", next: { - command: "screenrig agent connect", - reason: "Connect a new independently revocable agent through dashboard passkey approval, then rerun doctor.", + command: "screenrig agent enroll --email ADDRESS", + reason: "Ask the user for their contact email, enroll a new account agent, then rerun doctor. Use agent connect only for an intentional existing-account reconnect.", }, }; } @@ -4042,6 +4073,7 @@ function credentialCheck(resolved: Awaited>): D name: "token", status: "warn", detail: `${detail}; this installation is not enrolled`, + path: "first_run_enroll", next: { command: resolved.enrollment?.email ? "screenrig agent enroll" : "screenrig agent enroll --email ADDRESS", reason: resolved.enrollment?.email From ce63ed301d590ee489afd6524357013edb2612e0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 23:11:17 +0000 Subject: [PATCH 2/2] Cover forced pending credential cleanup Co-authored-by: Gersham Meharg --- src/cli.test.ts | 6 +++++- src/commands.ts | 8 +++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index bf1470ca..112e36d2 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -360,12 +360,16 @@ test("agent enroll --force discards an unwanted pending connection before enroll const configPath = path.join(configDir, "screenrig", "config.json"); await writeConfigAtomic(configPath, { api_url: "https://api.screenrig.ai", + token: `sr_live_pending_${"P".repeat(43)}`, + account_id: "acc_PENDINGAAAAAAAAAAAAAAAA", + agent_id: "agt_PENDINGAAAAAAAAAAAAAAAA", agent_connection: { private_jwk: { kty: "OKP", crv: "X25519", x: "pending-public", d: "pending-private" }, connection_id: "acn_UNWANTED", connection_token: "private-connection-token", approval_url: "https://app.screenrig.ai/agent-connections/acn_UNWANTED", expires_at: "2099-01-01T00:00:00Z", + pending_agent_id: "agt_PENDINGAAAAAAAAAAAAAAAA", }, }, fsLike); @@ -391,7 +395,7 @@ test("agent enroll --force discards an unwanted pending connection before enroll const config = await readConfigFile(configPath, fsLike); assert.equal(config?.agent_connection, undefined); assert.ok(config?.token); - assert.doesNotMatch(JSON.stringify(config), /acn_UNWANTED|private-connection-token|pending-private/); + assert.doesNotMatch(JSON.stringify(config), /acn_UNWANTED|private-connection-token|pending-private|sr_live_pending|acc_PENDING|agt_PENDING/); assert.equal(transport.calls.some((call) => call.path === "/api/v1/agent-connections"), false); await rm(configDir, { recursive: true, force: true }); }); diff --git a/src/commands.ts b/src/commands.ts index d4dbfaab..2370f945 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -937,9 +937,11 @@ async function clearAgentConnectionForEnrollment( const current = await readConfigFile(resolved.configPath, fsLike); if (!current?.agent_connection) return; const { agent_connection: connection, ...rest } = current; - const cleaned = connection.pending_agent_id - ? (({ token: _token, account_id: _account, agent_id: _agent, ...safe }) => safe)(rest) - : rest; + let cleaned = rest; + if (connection.pending_agent_id) { + const { token: _token, account_id: _account, agent_id: _agent, ...withoutPendingCredential } = rest; + cleaned = withoutPendingCredential; + } await writeConfigAtomic(resolved.configPath, { ...cleaned, updated_at: runtime.now().toISOString(),