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..112e36d2 100644
--- a/src/cli.test.ts
+++ b/src/cli.test.ts
@@ -354,6 +354,52 @@ 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",
+ 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);
+
+ 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|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 });
+});
+
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 +462,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 +802,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 +881,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 +2925,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 +2961,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..2370f945 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,27 @@ 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;
+ 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(),
+ }, fsLike);
+ });
+}
+
async function clearDefinitivePendingAgentFailure(
runtime: CliRuntime,
resolved: Awaited>,
@@ -4000,6 +4030,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 +4052,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 +4064,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 +4075,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