Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/cli-commands/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ADDRESS>", "Set the account contact email")
.option("--name <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 <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)")
Expand Down
5 changes: 3 additions & 2 deletions src/cli-commands/guidance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { addCommandExamples, addCommandNotes, commandExamples } from "./notes.js
export function registerGuidance(root: Command): void {
const examples: Record<string, string[]> = {
"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"],
Expand Down Expand Up @@ -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<string, string> = {
"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.",
Expand Down
109 changes: 106 additions & 3 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } };
Expand Down Expand Up @@ -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 });
});
Expand Down Expand Up @@ -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 });
});

Expand Down Expand Up @@ -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 });
});

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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-");
Expand Down
56 changes: 45 additions & 11 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.", {
Expand Down Expand Up @@ -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 } : {}),
Expand All @@ -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],
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -919,6 +928,27 @@ async function clearAgentConnection(
});
}

async function clearAgentConnectionForEnrollment(
runtime: CliRuntime,
resolved: Awaited<ReturnType<typeof resolveConfig>>,
): Promise<void> {
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<ReturnType<typeof resolveConfig>>,
Expand Down Expand Up @@ -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;
}
Expand All @@ -4021,9 +4052,10 @@ function credentialCheck(resolved: Awaited<ReturnType<typeof resolveConfig>>): 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.",
},
};
}
Expand All @@ -4032,16 +4064,18 @@ function credentialCheck(resolved: Awaited<ReturnType<typeof resolveConfig>>): 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.",
},
};
}
return {
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
Expand Down
Loading