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
25 changes: 18 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,16 @@ rules validate invocations before configuration or network access.

## Projects and enrollment

Before writing with an existing credential, run `screenrig project show` and
confirm the project name and ID. The contact email alone does not identify the
intended project: one person may belong to several projects.

On a fresh or disconnected installation, ask whether the user wants to connect
to an existing project or create a new one. `screenrig agent connect` requests
dashboard approval for an existing project; after approval, verify the selected
project with `project show`. Only use `agent enroll` after the user chooses a
new project. The CLI does not discover memberships from an email address.

An explicit enrollment creates a project, attaches this agent, and emails a
member invitation to the contact address. The same person can belong to more
than one project.
Expand All @@ -136,10 +146,12 @@ screenrig project rename NAME
`--project-name` names the project; `--name` names this agent. Enrollment reports
the project ID and name and confirms that a member invitation was requested,
without exposing a credential or invitation URL. The invitation is the human's
dashboard login; nothing waits on it, and the agent can pair screens and publish
right away. Other authenticated commands never enroll automatically. If the
server rejects the stored credential, `agent disconnect --yes` clears it locally
before a new enrollment.
project membership; a matching email or existing dashboard login does not accept
it. The person must accept the invitation before the new project appears in
their dashboard. Nothing waits on that step, and the agent can pair screens and
publish right away. Other authenticated commands never enroll automatically.
If the server rejects the stored credential, resolve the intended project
before disconnecting or enrolling again.

## Invitations

Expand Down Expand Up @@ -183,9 +195,8 @@ The response is neutral whether or not the address is known: if this address can
receive sign-in instructions, check its inbox. Delivery is not confirmed.
Rerunning an ambiguous request reuses its saved Idempotency-Key.

First setup is `agent enroll`. Only to attach a project that already exists to
this installation, run `screenrig agent connect` and approve the connection
request in that project's dashboard.
To attach a project that already exists to this installation, run `screenrig
agent connect` and approve the connection request in that project's dashboard.

## Screen host and recovery

Expand Down
6 changes: 3 additions & 3 deletions src/cli-commands/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@ import { usageError } from "../problems.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 a new project and its first agent")
agent.command("enroll").description("Create a new project and its first agent after choosing new-project setup")
.option("--email <ADDRESS>", "Set the project contact email")
.option("--project-name <NAME>", "Set the project display name")
.addOption(new Option("--intent <INTENT>", "Choose the project's purpose: signage (default) or advertising").choices(["signage", "advertising"]))
.option("--name <NAME>", "Set this agent installation name")
.option("--force", "Discard pending enrollment or connection state before enrolling")
.option("--force", "Discard pending enrollment or connection state after choosing a new project")
.action(bind(handleAgentEnroll));

addCommandNotes(agent.command("connect").description("Reconnect this installation to an existing project")
addCommandNotes(agent.command("connect").description("Connect this installation to an existing project")
.option("--name <NAME>", "Set this agent installation name")
.addOption(new Option("--capability <NAME>", "Request a capability (repeatable; default: all six)")
.choices([...AGENT_CAPABILITIES])
Expand Down
2 changes: 1 addition & 1 deletion src/cli-commands/guidance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ export function registerGuidance(root: Command): void {
"dashboard reset-sign-in": "Unauthenticated: never enrolls, sends a stored token, or changes stored credentials. The acknowledgment is identical for known and unknown addresses. Instructions expire after one hour. An unresolved request retains only its retry key and fingerprint in the local write ledger.",
"agent connect": "Repeat --capability to request only the named areas; omitted requests all six. Dashboard approval can grant a non-empty subset. Capabilities cannot change on an existing request or credential: resume without changing flags and inspect the grant with agent status. To change permissions, connect a new agent in a separate private --config, approve and verify it, then disconnect the old agent.",
"agent status": "Shows this credential's granted capabilities, distinct from project feature entitlements. A forbidden capability refusal requires a newly connected and approved agent with that capability; retrying the same credential cannot expand its permissions.",
"agent enroll": "Creates a new project and first agent, even when the contact email belongs to another project. Ask for the contact email and supply --email. --project-name sets its display name; --name names this installation. The server emails the contact address a member invitation for their dashboard login; nothing waits on it, so tell the user and go straight on to pairing and content without creating an invitation link. An already enrolled installation reuses its project. --force discards pending enrollment or connection state; intentional existing-project reconnects use agent connect. --intent advertising enables advertiser=true/screens=false without changing billing-plan assignment. A pending enrollment keeps its original email, name, and purpose.",
"agent enroll": "On an unconnected installation, ask whether the user wants an existing project or a new one. Use agent connect for an existing project. Only after the user chooses new, ask for the contact email and supply --email. Enrollment creates a separate project even when that email belongs to another project. --project-name sets its display name; --name names this installation. The server emails the contact address a member invitation for their dashboard login; nothing waits on it, so tell the user and go straight on to pairing and content without creating an invitation link. The person must accept the invitation before the project appears in their dashboard. An already enrolled installation reuses its project; verify it with project show before writes. --force discards pending enrollment or connection state and must not skip an intentional existing-project connection. --intent advertising enables advertiser=true/screens=false without changing billing-plan assignment. A pending enrollment keeps its original email, name, and purpose.",
"project capabilities": "Read this project's plan id, independent advertiser/screens feature flags, feature revision, and the server's effective capability set. Never infer permission from a quota, plan name, or command inventory. A route denial still wins over previously read capabilities.",
"dashboard": "Opens the dashboard origin without minting credentials. If the browser opener fails, the origin is printed. Sign in with a retained person credential.",
"dashboard open": "Opens the dashboard origin without minting credentials. The origin is printed only if the browser opener fails.",
Expand Down
65 changes: 45 additions & 20 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ test("pairing requires explicit enrollment and then preserves the original pairi
assert.equal(missing.code, ExitCode.Auth);
const blocked = JSON.parse(missing.stdout) as { error: { code: string; next: { command: string } } };
assert.equal(blocked.error.code, "not_enrolled");
assert.equal(blocked.error.next.command, "screenrig agent enroll --email ADDRESS");
assert.equal(blocked.error.next.command, "screenrig agent --help");
assert.equal(transport.calls.length, 0);

const enrolled = await withRuntime(
Expand Down Expand Up @@ -440,13 +440,13 @@ test("every authenticated command reports not_enrolled instead of enrolling", as
const envelope = JSON.parse(result.stdout) as { ok: boolean; error: { code: string; next: { command: string } } };
assert.equal(envelope.ok, false, argv.join(" "));
assert.equal(envelope.error.code, "not_enrolled", argv.join(" "));
assert.equal(envelope.error.next.command, "screenrig agent enroll --email ADDRESS", argv.join(" "));
assert.equal(envelope.error.next.command, "screenrig agent --help", argv.join(" "));
assert.equal(transport.calls.length, 0, argv.join(" "));
await rm(result.configDir, { recursive: true, force: true });
}
});

test("revoked agent history without a pending reconnect directs authenticated commands to enroll", async () => {
test("revoked agent history without a pending reconnect asks which project to use", 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"), {
Expand All @@ -466,8 +466,8 @@ test("revoked agent history without a pending reconnect directs authenticated co
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);
assert.equal(envelope.error.next.command, "screenrig agent --help");
assert.match(envelope.error.next.reason, /existing project or create a new one/i);
await rm(configDir, { recursive: true, force: true });
});

Expand All @@ -476,11 +476,36 @@ test("agent status never enrolls a missing installation", async () => {
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(JSON.parse(status.stdout).data.path, "choose_project");
assert.equal(transport.calls.length, 0);
await rm(status.configDir, { recursive: true, force: true });
});

test("a pending new-project enrollment still resumes instead of asking again", async () => {
const configDir = await testTemp("pending-enrollment-choice-");
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",
enrollment: { client_id: `cli_${"a".repeat(43)}`, idempotency_key: "pending-project-choice", email: "owner@example.com" },
}, fsLike);
const transport = new FakeTransport();
const status = await withRuntime(["--json", "agent", "status"], transport, { fs: fsLike });
assert.equal(JSON.parse(status.stdout).data.path, "first_run_enroll");
const blocked = await withRuntime(["--json", "project", "show"], transport, { fs: fsLike });
assert.equal(JSON.parse(blocked.stdout).error.next.command, "screenrig agent enroll");
await writeConfigAtomic(path.join(configDir, "screenrig", "config.json"), {
api_url: "https://api.screenrig.ai",
enrollment: { client_id: `cli_${"a".repeat(43)}`, idempotency_key: "pending-project-choice", email: "owner@example.com" },
last_agent: { id: TEST_AGENT.id, name: TEST_AGENT.name, agent_type: TEST_AGENT.agent_type, capabilities: [...AGENT_CAPABILITIES], state: "revoked" },
}, fsLike);
const disconnected = await withRuntime(["--json", "project", "show"], transport, { fs: fsLike });
assert.equal(JSON.parse(disconnected.stdout).error.next.command, "screenrig agent enroll");
assert.equal(transport.calls.length, 0);
const doctor = await withRuntime(["--json", "doctor"], transport, { fs: fsLike, runProcess: fullToolchainProbe() });
assert.equal(JSON.parse(doctor.stdout).data.checks.find((check: { name: string }) => check.name === "token")?.path, "first_run_enroll");
await rm(configDir, { recursive: true, force: true });
});

test("unsupported auth aliases do not make requests or enroll", async () => {
const transport = new FakeTransport();
for (const command of [[], ["status"], ["revoke", "--yes"]]) {
Expand Down Expand Up @@ -865,7 +890,7 @@ test("agent disconnect locally cleans an unauthorized credential without retryin
const status = await withRuntime(["--json", "agent", "status"], new FakeTransport(), { fs: fsLike });
assert.deepEqual(JSON.parse(status.stdout).data, {
status: "not_enrolled",
path: "first_run_enroll",
path: "choose_project",
});
await rm(configDir, { recursive: true, force: true });
});
Expand Down Expand Up @@ -942,7 +967,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");
assert.equal(JSON.parse(status.stdout).data.path, "choose_project");
await rm(configDir, { recursive: true, force: true });
});

Expand Down Expand Up @@ -2922,7 +2947,7 @@ test("doctor reports a configured credential as presence only", async () => {
}
});

test("doctor warns, exits 0, and names agent enroll when a fresh install has no credential", async () => {
test("doctor warns, exits 0, and asks which project to use when a fresh install has no credential", async () => {
resetFfmpegToolchainCache();
const { code, stdout, configDir } = await withRuntime(["--json", "doctor"], memoryBackend(), { runProcess: fullToolchainProbe() });
assert.equal(code, ExitCode.Success, stdout);
Expand All @@ -2937,11 +2962,11 @@ test("doctor warns, exits 0, and names agent enroll when a fresh install has no
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.equal(token.path, "choose_project");
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");
assert.match(envelope.data.next?.reason ?? "", /not_enrolled/);
assert.equal(token.next?.command, "screenrig agent --help");
assert.equal(envelope.data.next?.command, "screenrig agent --help");
assert.match(envelope.data.next?.reason ?? "", /existing project or create a new one/i);
assert.equal(envelope.data.checks.some((check) => check.status === "fail" && check.name === "token"), false);
assert.doesNotMatch(stdout, /sr_live_/);
await rm(configDir, { recursive: true, force: true });
Expand Down Expand Up @@ -2978,7 +3003,7 @@ test("doctor points a pending agent connection at agent connect instead of enrol
await rm(configDir, { recursive: true, force: true });
});

test("doctor treats revoked agent history without a pending reconnect as first-run enrollment", async () => {
test("doctor asks which project to use after revocation without a pending reconnect", async () => {
resetFfmpegToolchainCache();
const configDir = await testTemp("doctor-revoked-enroll-");
const fsLike = { mkdir, open, rename, rm, chmod, stat, homedir: () => configDir, env: { XDG_CONFIG_HOME: configDir } };
Expand All @@ -2998,10 +3023,10 @@ test("doctor treats revoked agent history without a pending reconnect as first-r
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");
assert.equal(token?.path, "choose_project");
assert.equal(token?.next?.command, "screenrig agent --help");
assert.match(token?.next?.reason ?? "", /existing project or create a new one/i);
assert.equal(envelope.data.next?.command, "screenrig agent --help");
await rm(configDir, { recursive: true, force: true });
});

Expand Down Expand Up @@ -7270,12 +7295,12 @@ test("every error envelope carries a hint; usage errors point at the invoked com
const unknown = await withRuntime(["--json", "screen", "no-such-command"], transport);
const unknownError = JSON.parse(unknown.stdout).error as { hint?: string; next?: { command: string } };
assert.ok(unknownError.hint);
// Not enrolled: a hint plus the enrollment next.
// Not enrolled: a hint plus the neutral project-choice next.
const notEnrolled = await withRuntime(["--json", "screen", "list"], transport);
const notEnrolledError = JSON.parse(notEnrolled.stdout).error as { code: string; hint?: string; next?: { command: string } };
assert.equal(notEnrolledError.code, "not_enrolled");
assert.match(String(notEnrolledError.hint), /Run the next command/);
assert.match(String(notEnrolledError.next?.command), /agent enroll/);
assert.equal(notEnrolledError.next?.command, "screenrig agent --help");
// Human output shows the same hint.
const human = await withRuntime(["--human", "screen", "list"], transport);
assert.match(human.stderr, /^hint: .*Run the next command/m);
Expand Down
4 changes: 2 additions & 2 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,8 +444,8 @@ export function requireToken(token: string | undefined): string {
throw new CliError(
makeProblem("unauthenticated", "Credential unavailable", 401, "This installation has no durable agent credential.", {
next: {
command: "screenrig agent enroll --email ADDRESS",
reason: "Enrollment is explicit. Create the first agent, then retry the original command.",
command: "screenrig agent --help",
reason: "Ask whether to connect to an existing project or create a new one before retrying the original command.",
},
}),
ExitCode.Auth,
Expand Down
Loading
Loading