diff --git a/README.md b/README.md index 4defd35..49d9060 100644 --- a/README.md +++ b/README.md @@ -326,6 +326,9 @@ Cloud viewer journey is still unproven, and the old draft evidence for current `main`. The source-candidate contract is: - Starting a session prints a link and a QR code. +- `aether --json rc start|link|status` gives local integrations one structured + session/device identity. Only `start` and `link` return the short-lived + observer link; `status` never replays it. Integrations must not log that link. - Your phone or browser **watches** the run. It never gets tool authority. - One command shows what is exposed; another revokes it. - Outbound TLS only — no inbound listener on your machine. diff --git a/src/commands/rc.ts b/src/commands/rc.ts index aba557f..ba87db0 100644 --- a/src/commands/rc.ts +++ b/src/commands/rc.ts @@ -34,6 +34,7 @@ import { detectBrowserRuntime } from "../core/browser_runtime.js"; import { McpClient } from "../core/mcp.js"; import { loadEnrollmentMetadata } from "../core/device_runtime/identity.js"; import { + RC_HOST_SCHEMA, RcError, attachHost, flushOutbox, @@ -252,25 +253,49 @@ export interface RcCommandDeps { err: (text: string) => void; isTTY: boolean; columns: number | undefined; + json: boolean; +} + +interface RcObserverInvitation { + url: string; + expires_at: string; +} + +/** Stable machine handoff for a caller that must bind its own browser session. */ +export function renderStatusJson(view: RcStatusView, invitation: RcObserverInvitation | null = null): string { + return JSON.stringify({ + schema: RC_HOST_SCHEMA, + host_state: view.running ? view.state : "off", + session_id: view.session_id, + device_id: view.device_id, + project_ref: view.project_ref, + revoke_pending: view.revoke_pending, + outbox_pending: view.pending, + acked_seq: view.acked, + viewer_capabilities: VIEWER_CAPABILITIES, + observer: invitation, + }) + "\n"; } async function printObserverLink( deps: RcCommandDeps, hostDeps: RcHostDeps, sessionId: string, -): Promise { +): Promise { try { const grant = await mintObserverGrant(hostDeps, sessionId, newObserverId()); const link = observerLink(grant); - deps.out(`\nObserver link (expires ${grant.expires_at}):\n${link}\n`); - const qr = deps.isTTY ? observerQr(link, deps.columns) : null; - if (qr) deps.out(`${qr}\n`); - else deps.out("Open the link directly; this terminal cannot fit a scannable QR.\n"); - return true; + if (!deps.json) { + deps.out(`\nObserver link (expires ${grant.expires_at}):\n${link}\n`); + const qr = deps.isTTY ? observerQr(link, deps.columns) : null; + if (qr) deps.out(`${qr}\n`); + else deps.out("Open the link directly; this terminal cannot fit a scannable QR.\n"); + } + return { url: link, expires_at: grant.expires_at }; } catch (error) { const code = error instanceof RcError ? error.code : "RC_BROKER_UNREACHABLE"; deps.err(`${code}: RC is running, but an observer link could not be minted. Retry with \`aether rc link\`.\n`); - return false; + return null; } } @@ -282,7 +307,7 @@ function viewOf(record: OutboxRecord, deps: RcCommandDeps, observers: number | n browser: browser?.code ?? null, connector: deps.connector(), last_receipt: null, - device_id: enrolled?.device_id ?? null, + device_id: record.session_id ? record.device_id : enrolled?.device_id ?? null, device_name: enrolled?.display_name ?? null, session_id: record.session_id || null, project_ref: record.project_ref || null, @@ -358,9 +383,10 @@ async function start( saveOutbox(hostDeps.outboxPath, record); const flushed = await flushOutbox(hostDeps, record); - deps.out(renderStatus(viewOf(record, deps, null))); - if (flushed.ok) await printObserverLink(deps, hostDeps, session.session_id); - else deps.err(`${flushed.code}: RC is running, but its opening events are pending. Retry with \`aether rc link\`.\n`); + if (!deps.json) deps.out(renderStatus(viewOf(record, deps, null))); + const invitation = flushed.ok ? await printObserverLink(deps, hostDeps, session.session_id) : null; + if (!flushed.ok) deps.err(`${flushed.code}: RC is running, but its opening events are pending. Retry with \`aether rc link\`.\n`); + if (deps.json) deps.out(renderStatusJson(viewOf(record, deps, null), invitation)); return EXIT_OK; } catch (error) { if (error instanceof RcError) { @@ -396,6 +422,7 @@ export async function cmdRc( err: overrides.err ?? ((text): void => void process.stderr.write(text)), isTTY: overrides.isTTY ?? Boolean(process.stdout.isTTY), columns: overrides.columns ?? process.stdout.columns, + json: overrides.json ?? ctx.flags.json, }; // Connector state is read once, best-effort, before anything renders. A @@ -421,7 +448,7 @@ export async function cmdRc( return start(deps, hostDeps, record, flags.str("name"), projectRef); case "status": - deps.out(renderStatus(viewOf(record, deps, null))); + deps.out(deps.json ? renderStatusJson(viewOf(record, deps, null)) : renderStatus(viewOf(record, deps, null))); return EXIT_OK; case "link": @@ -435,7 +462,9 @@ export async function cmdRc( deps.err(`${flushed.code}: RC opening events are still pending. Retry \`aether rc link\` after reconnecting.\n`); return EXIT_OPERATIONAL; } - return await printObserverLink(deps, hostDeps, record.session_id) ? EXIT_OK : EXIT_OPERATIONAL; + const invitation = await printObserverLink(deps, hostDeps, record.session_id); + if (deps.json && invitation) deps.out(renderStatusJson(viewOf(record, deps, null), invitation)); + return invitation ? EXIT_OK : EXIT_OPERATIONAL; } case "exposure": @@ -460,7 +489,8 @@ export async function cmdRc( deps.err(`${outcome.code}: ${outcome.detail}\n`); return EXIT_OPERATIONAL; } - deps.out("RC is off. The session, its grants and its streams are revoked.\n"); + deps.out(deps.json ? renderStatusJson(viewOf(record, deps, null)) : + "RC is off. The session, its grants and its streams are revoked.\n"); return EXIT_OK; } diff --git a/test/rc_command.test.ts b/test/rc_command.test.ts index 596b7f8..e9bb219 100644 --- a/test/rc_command.test.ts +++ b/test/rc_command.test.ts @@ -11,16 +11,24 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { RC_NO_CONTROL_LINE, + cmdRc, renderExposure, renderStatus, + renderStatusJson, type RcStatusView, } from "../src/commands/rc.js"; import { COMMAND_MANIFEST_SOURCE } from "../src/commands/command_manifest_data.js"; import { assertViewerManifest, tokenize } from "../src/core/rc/viewer_profile.js"; import { producerCoverage } from "../src/core/rc/producers.js"; +import { payloadDigest } from "../src/core/rc/receipts.js"; +import type { AppContext } from "../src/core/context.js"; +import type { CommandFlags } from "../src/core/command_dispatch.js"; const TOKEN_SHAPED = "aek_" + "Z".repeat(32); @@ -215,6 +223,100 @@ test("status renders with nothing running and invents no session", () => { assert.match(text, /session\s+—/); }); +test("machine status binds session and device without replaying an invitation", () => { + const data = JSON.parse(renderStatusJson(view())) as Record; + assert.equal(data["schema"], "aether.cli.rc/1"); + assert.equal(data["session_id"], "rs_" + "e".repeat(32)); + assert.equal(data["device_id"], "dev-1"); + assert.equal(data["host_state"], "active"); + assert.deepEqual(data["viewer_capabilities"], ["observe"]); + assert.equal(data["observer"], null); + assert.ok(!renderStatusJson(view()).includes("rsgt_")); +}); + +test("machine handoff carries a one-time link only in the requested start or link result", () => { + const url = "https://app.aethersystems.net/rc#grant=rsgt_canary"; + const data = JSON.parse(renderStatusJson(view(), { + url, + expires_at: "2026-09-07T00:05:00.000Z", + })) as { observer: { url: string; expires_at: string } }; + assert.equal(data.observer.url, url); + assert.equal(data.observer.expires_at, "2026-09-07T00:05:00.000Z"); +}); + +test("json start and status bind one RC host without replaying the one-time link", async () => { + const directory = mkdtempSync(join(tmpdir(), "aether-rc-json-")); + const priorConfig = process.env["AETHER_CONFIG_DIR"]; + process.env["AETHER_CONFIG_DIR"] = directory; + const output: string[] = []; + const sessionId = "rs_" + "1".repeat(32); + const grantToken = "rsgt_" + "a".repeat(48); + const api = { + async postJson(path: string, body: unknown): Promise { + if (path === "/remote/sessions") return { session_id: sessionId, state: "pending_host" }; + if (path.endsWith("/host/attach")) return { session_id: sessionId, state: "live" }; + if (path.endsWith("/host/events")) { + const events = (body as { events: Array<{ host_event_id: string; payload: Record }> }).events; + return { session_id: sessionId, receipts: events.map((event, index) => ({ + host_event_id: event.host_event_id, seq: index + 1, payload_digest: payloadDigest(event.payload), + })) }; + } + if (path.endsWith("/grants")) return { + session_id: sessionId, purpose: "observe", device_id: (body as { device_id: string }).device_id, + token: grantToken, expires_at: new Date(Date.now() + 300_000).toISOString(), + }; + if (path.endsWith("/revoke")) return {}; + throw new Error(`unexpected route ${path}`); + }, + }; + const ctx = { api, flags: { cwd: directory, json: true } } as unknown as AppContext; + const flags = { str: () => undefined } as unknown as CommandFlags; + const overrides = { + cwd: directory, + enrollment: () => ({ device_id: "dev-1", display_name: "test" }), + repo: () => ({ repo: "fixture", branch: "main", base_commit: "0".repeat(40), dirty_file_count: 0 }), + connector: () => null, + browser: () => null, + out: (value: string) => output.push(value), + err: (value: string) => { throw new Error(value); }, + isTTY: false, + columns: undefined, + }; + try { + assert.equal(await cmdRc(ctx, ["start"], flags, overrides), 0); + assert.equal(output.length, 1); + const started = JSON.parse(output.pop()!) as { session_id: string; device_id: string; observer: { url: string } }; + assert.equal(started.session_id, sessionId); + assert.equal(started.device_id, "dev-1"); + assert.equal(new URL(started.observer.url).search, ""); + assert.ok(started.observer.url.includes(`#grant=${grantToken}`)); + + assert.equal(await cmdRc(ctx, ["status"], flags, overrides), 0); + assert.equal(output.length, 1); + const status = JSON.parse(output[0]!) as { session_id: string; observer: unknown }; + assert.equal(status.session_id, sessionId); + assert.equal(status.observer, null); + assert.ok(!output[0]!.includes(grantToken)); + + output.length = 0; + assert.equal(await cmdRc(ctx, ["link"], flags, overrides), 0); + assert.equal(output.length, 1); + const linked = JSON.parse(output.pop()!) as { session_id: string; observer: { url: string } }; + assert.equal(linked.session_id, sessionId); + assert.ok(linked.observer.url.includes(grantToken)); + + assert.equal(await cmdRc(ctx, ["off"], flags, overrides), 0); + assert.equal(output.length, 1); + const closed = JSON.parse(output[0]!) as { session_id: string | null; host_state: string }; + assert.equal(closed.session_id, null); + assert.equal(closed.host_state, "off"); + } finally { + if (priorConfig === undefined) delete process.env["AETHER_CONFIG_DIR"]; + else process.env["AETHER_CONFIG_DIR"] = priorConfig; + rmSync(directory, { recursive: true, force: true }); + } +}); + // ── 3. Nothing rendered can carry a credential ────────────────────────────── test("no rendered surface exposes a secret-bearing field", () => {