diff --git a/README.md b/README.md index 582492c7..81544962 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ ATS requires the separate Python engine and policy consent. This build does not [**Aether Code**](https://app.aethersystems.net/) is the browser coding app alongside Web Chat and Design Lab. It uses the same Aether account; the CLI also works independently with local Ollama. Coding sessions stay on their host, while managed agents share their Online conversations. -**Remote viewing (`aether rc`) is a source candidate.** The observer bridge is on `main`; a live Cloud viewer journey (Windows and Linux hosts, phone viewer) was recorded on 2026-10-06, and `aether rc start` still needs an operator-enrolled device ([#300](https://github.com/AetherAI3/aether-agent/issues/300)). It is designed to let a browser or phone watch a redacted terminal run through a link or QR code. The viewer has observation access only. [Remote viewing status and controls](docs/REMOTE_VIEWING.md). +**Remote viewing (`aether rc`) is a source candidate.** The observer bridge is on `main`; a live Cloud viewer journey (Windows and Linux hosts, phone viewer) was recorded on 2026-10-06. `aether rc start` now requests a separate owner-scoped RC identity and does not require operator device enrollment; an ordinary-account deployed journey still needs qualification. It is designed to let a browser or phone watch a redacted terminal run through a link or QR code. The viewer has observation access only. [Remote viewing status and controls](docs/REMOTE_VIEWING.md). ## Privacy and control diff --git a/docs/REMOTE_VIEWING.md b/docs/REMOTE_VIEWING.md index c5dd2bd0..36c616d2 100644 --- a/docs/REMOTE_VIEWING.md +++ b/docs/REMOTE_VIEWING.md @@ -6,11 +6,12 @@ terminal run and a browser. Its host foundation and local status/exposure controls are on `main`. A live Cloud viewer journey was recorded on 2026-10-06 on a Windows and a Linux host with a phone viewer — -[evidence](reviews/2026-10-06-rc-live-journey.md). Two limits remain: -`aether rc start` needs a device enrolled with `aether device enroll`, which -the Cloud currently allows only for operator accounts -([#300](https://github.com/AetherAI3/aether-agent/issues/300)), and a broker -outage during a run was exercised in tests only. The old draft +[evidence](reviews/2026-10-06-rc-live-journey.md). RC now obtains its own +owner-scoped observer device label from Cloud using an installation ID; it +does not use the operator-only `aether device enroll` path. The label is not +an authenticator and grants no control authority. The ordinary-account +deployed journey and a broker outage during a run still need live +qualification. The old draft [PR #108](https://github.com/AetherAI3/aether-agent/pull/108) is not release evidence for current `main`. diff --git a/src/commands/rc.ts b/src/commands/rc.ts index dfdcfa1e..c35e6f1e 100644 --- a/src/commands/rc.ts +++ b/src/commands/rc.ts @@ -33,7 +33,7 @@ import type { AppContext } from "../core/context.js"; import { digestOf } from "../core/device_runtime/canonical_json.js"; import { detectBrowserRuntime } from "../core/browser_runtime.js"; import { McpClient } from "../core/mcp.js"; -import { loadEnrollmentMetadata } from "../core/device_runtime/identity.js"; +import { resolveRcDeviceIdentity } from "../core/rc/device_identity.js"; import { RC_HOST_SCHEMA, RcError, @@ -383,6 +383,8 @@ export interface RcCommandDeps { /** Aether connector state, or null when it could not be determined. */ connector: () => string | null; enrollment: () => { device_id: string; display_name: string } | null; + /** RC-only Cloud identity; legacy injected enrollment is retained for fixtures. */ + rcIdentity?: () => Promise<{ device_id: string; display_name: string }>; repo: (cwd: string) => RepoSummary; out: (text: string) => void; err: (text: string) => void; @@ -553,15 +555,15 @@ function exposedNow(record: OutboxRecord, state: string): string[] { } function viewOf(record: OutboxRecord, deps: RcCommandDeps, cloud: RcCloudView = { kind: "unchecked" }): RcStatusView { - const enrolled = deps.enrollment(); + const localIdentity = deps.enrollment(); const browser = deps.browser(); const host = hostState(record, cloud); return { running: Boolean(record.session_id) || Boolean(record.recovery), browser: browser?.code ?? null, connector: deps.connector(), - device_id: record.session_id ? record.device_id : enrolled?.device_id ?? null, - device_name: enrolled?.display_name ?? null, + device_id: record.session_id ? record.device_id : localIdentity?.device_id ?? null, + device_name: localIdentity?.display_name ?? null, session_id: record.session_id || null, project_ref: record.project_ref || null, repo: record.session_id ? deps.repo(deps.cwd) : null, @@ -596,16 +598,6 @@ async function start( name: string | undefined, projectRef: string, ): Promise { - const enrolled = deps.enrollment(); - if (!enrolled) { - // Enrollment is identity, not permission: RC needs a canonical device id to - // name the machine an observer is watching. A self-minted one authenticates - // nothing, so there is deliberately no fallback here. - deps.err( - "RC_NOT_ENROLLED: run `aether device enroll` first — RC needs an enrolled device to name this machine\n", - ); - return EXIT_OPERATIONAL; - } if (record.recovery) { deps.err(`${recoveryMessage(record)}\n`); return EXIT_OPERATIONAL; @@ -628,6 +620,21 @@ async function start( return EXIT_OPERATIONAL; } + let identity: { device_id: string; display_name: string } | null; + try { + identity = deps.rcIdentity ? await deps.rcIdentity() : deps.enrollment(); + } catch (error) { + const status = (error as { status?: unknown } | null)?.status; + deps.err(status === 401 || status === 403 + ? "RC_NOT_AUTHORIZED: this account is not enabled for remote viewing\n" + : "RC_IDENTITY_UNAVAILABLE: Cloud could not confirm an RC device identity; check remote-viewing availability and retry\n"); + return EXIT_OPERATIONAL; + } + if (!identity) { + deps.err("RC_IDENTITY_UNAVAILABLE: Cloud did not confirm an RC device identity\n"); + return EXIT_OPERATIONAL; + } + const repo = deps.repo(deps.cwd); const sessionName = name?.trim() || `${repo.repo}@${repo.branch}`; @@ -636,7 +643,7 @@ async function start( try { sessionId = (await registerSession(hostDeps, { project_ref: projectRef, - device_id: enrolled.device_id, + device_id: identity.device_id, session_name: sessionName, repo, })).session_id; @@ -652,7 +659,7 @@ async function start( const session = createOutbox({ session_id: sessionId, project_ref: projectRef, - device_id: enrolled.device_id, + device_id: identity.device_id, epoch: 1, project_root: hostDeps.projectRoot, start_phase: "registered", @@ -661,7 +668,7 @@ async function start( // 3. attach. A refusal means this host will never own the session. try { - await attachHost(hostDeps, sessionId, enrolled.device_id); + await attachHost(hostDeps, sessionId, identity.device_id); } catch (error) { if (!(error instanceof RcError)) throw error; return rollbackStart(deps, hostDeps, session, error); @@ -676,7 +683,7 @@ async function start( dirty_file_count: repo.dirty_file_count, protocol_version: RC_OPENING_PROTOCOL_VERSION, }); - const presence = hostPresenceEvent(enrolled.device_id, "live"); + const presence = hostPresenceEvent(identity.device_id, "live"); session.start_phase = "attached"; if (!enqueueEvent(session, opened.event_type, opened.payload) || !enqueueEvent(session, presence.event_type, presence.payload)) { @@ -831,7 +838,9 @@ export async function cmdRc( // without side effects. browser: overrides.browser ?? (() => detectBrowserRuntime()), connector: overrides.connector ?? ((): string | null => connectorState), - enrollment: overrides.enrollment ?? loadEnrollmentMetadata, + enrollment: overrides.enrollment ?? (() => null), + ...(!overrides.enrollment ? { rcIdentity: overrides.rcIdentity ?? (() => resolveRcDeviceIdentity(ctx.api)) } + : overrides.rcIdentity ? { rcIdentity: overrides.rcIdentity } : {}), repo: overrides.repo ?? repoSummary, out: overrides.out ?? ((text): void => void process.stdout.write(text)), err: overrides.err ?? ((text): void => void process.stderr.write(text)), diff --git a/src/core/device_runtime/identity.ts b/src/core/device_runtime/identity.ts index 8abfcc7f..382064a6 100644 --- a/src/core/device_runtime/identity.ts +++ b/src/core/device_runtime/identity.ts @@ -157,19 +157,14 @@ export function loadEnrollment(): EnrollmentRecord | null { /** * Identity and display fields only — never the secrets. * - * Remote Control needs to say WHICH device is publishing: `device_id` for the - * canonical identity the broker checks, `display_name` for the operator, and - * `base_url` for the endpoint. It has no use for `device_token` or - * `device_command_key`, and it must not hold them: RC is the process that - * publishes observation events, so a credential within its reach is one - * redaction bug away from a viewer stream. + * SC-DEVICE status can show which enrolled device it is inspecting without + * receiving either secret. Observer-only RC now uses its separate Cloud + * owner-scoped device label and does not read this enrollment projection. * * This is a separate accessor rather than "call loadEnrollment and read only * three fields", because the latter is a convention and a convention is not - * enforceable. A projection is: test/rc_enrollment_metadata.test.ts asserts no - * RC module references `loadEnrollment`, `device_token`, or - * `device_command_key` at all — which is only fair to demand once a - * secret-free accessor exists for them to use instead. + * enforceable. Keeping this projection field-by-field also prevents a future + * device-runtime status caller from receiving a newly added secret by spread. */ export interface EnrollmentMetadata { device_id: string; @@ -183,7 +178,7 @@ export function loadEnrollmentMetadata(): EnrollmentMetadata | null { if (!record) return null; // Explicit field-by-field projection, deliberately not a destructuring rest. // A rest spread would silently carry any future secret added to - // EnrollmentRecord into RC's reach; this way a new field has to be allowed + // EnrollmentRecord into a status caller's reach; a new field must be allowed // here on purpose. return { device_id: record.device_id, diff --git a/src/core/rc/device_identity.ts b/src/core/rc/device_identity.ts new file mode 100644 index 00000000..8fe0ce3e --- /dev/null +++ b/src/core/rc/device_identity.ts @@ -0,0 +1,53 @@ +// Observer-only RC identity. The installation UUID is a label seed, not a +// bearer credential; Cloud combines it with the authenticated account owner. +// SC-DEVICE enrollment and its command authority remain separate. +import { randomUUID } from "node:crypto"; +import { existsSync, lstatSync } from "node:fs"; +import { hostname } from "node:os"; +import { join } from "node:path"; +import { configDir } from "../config.js"; +import { atomicWriteFile, readJsonFile, withFileLock } from "../durable_store.js"; +import type { ApiClient } from "../transport.js"; + +const SCHEMA = "aether.remote_device_identity.v1"; +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const RC_DEVICE_ID = /^rcd_[0-9a-f]{32}$/; + +export function rcInstallationPath(): string { + return join(configDir(), "rc", "device-identity.json"); +} + +/** Fail closed on an unreadable identity; never silently switch device IDs. */ +export function loadOrCreateRcInstallationId(path = rcInstallationPath()): string { + return withFileLock(`${path}.lock`, "rc-device-identity", () => { + if (existsSync(path) && lstatSync(path).isSymbolicLink()) { + throw new Error("RC device identity file is a link"); + } + const prior = readJsonFile(path); + if (prior.ok) { + const value = prior.value as { schema_version?: unknown; installation_id?: unknown }; + if (value?.schema_version !== SCHEMA || typeof value.installation_id !== "string" || !UUID_V4.test(value.installation_id)) { + throw new Error("RC device identity file is invalid"); + } + return value.installation_id; + } + if (prior.reason !== "missing") throw new Error("RC device identity file is unreadable or corrupt"); + const installationId = randomUUID(); + atomicWriteFile(path, JSON.stringify({ schema_version: SCHEMA, installation_id: installationId }) + "\n", { mode: 0o600 }); + return installationId; + }); +} + +export async function resolveRcDeviceIdentity( + api: ApiClient, + path = rcInstallationPath(), +): Promise<{ device_id: string; display_name: string }> { + const installation_id = loadOrCreateRcInstallationId(path); + const raw = await api.postJson("/remote/device-identity", { installation_id }, undefined, 10_000); + if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Cloud returned an invalid RC identity"); + const value = raw as Record; + if (value["schema_version"] !== SCHEMA || typeof value["device_id"] !== "string" || !RC_DEVICE_ID.test(value["device_id"])) { + throw new Error("Cloud returned an invalid RC identity"); + } + return { device_id: value["device_id"], display_name: hostname() }; +} diff --git a/src/core/rc/host.ts b/src/core/rc/host.ts index 5e2740a4..b41db5f3 100644 --- a/src/core/rc/host.ts +++ b/src/core/rc/host.ts @@ -16,15 +16,12 @@ // secret. The specification's §5.2 custody sequence describes a surface that // was never built Cloud-side, and minting a local credential to satisfy it // would CREATE the very thing that section exists to protect -- another secret -// at rest. So "no raw credential on disk" holds by construction here, and -// identity is read through loadEnrollmentMetadata(), the projection that -// cannot return either of the enrolment record's two secret fields at all. +// at rest. So "no raw credential on disk" holds by construction here. The +// observer-only device label comes from Cloud's authenticated, owner-scoped RC +// identity route; it is not an SC-DEVICE enrollment or command credential. // -// That last sentence is deliberately worded around those field names rather -// than quoting them: test/rc_viewer_host.test.ts greps this directory's raw -// source for them, and it is right to stay that strict -- a guard that has to -// reason about which mentions are "only a comment" is a guard with an -// exception, and exceptions are what get argued into existence later. +// test/rc_viewer_host.test.ts scans this directory's source to ensure no RC +// module reads the separate enrollment secrets. // // WHY EVERY FAILURE IS TYPED // @@ -48,8 +45,10 @@ export const RC_HOST_SCHEMA = "aether.cli.rc/1"; * these, so a value is added rather than renamed. */ export type RcCode = - /** No enrolled device, so RC cannot name the machine it publishes from. */ + /** Legacy code retained for older consumers; RC start no longer uses enrollment. */ | "RC_NOT_ENROLLED" + /** RC could not obtain the owner-scoped observer device label. */ + | "RC_IDENTITY_UNAVAILABLE" /** The session is gone, or this device is not its host. */ | "RC_SESSION_NOT_FOUND" /** Another host already owns this session. Never a takeover. */ diff --git a/test/rc_device_identity.test.ts b/test/rc_device_identity.test.ts new file mode 100644 index 00000000..98e98e88 --- /dev/null +++ b/test/rc_device_identity.test.ts @@ -0,0 +1,116 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ApiClient } from "../src/core/transport.js"; +import { StaticTokenStore } from "../src/core/auth.js"; +import { loadOrCreateRcInstallationId, resolveRcDeviceIdentity } from "../src/core/rc/device_identity.js"; +import { cmdRc } from "../src/commands/rc.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"; + +test("RC installation label seed is stable and corrupt state cannot silently change identity", () => { + const dir = mkdtempSync(join(tmpdir(), "aether-rc-identity-")); + const path = join(dir, "identity.json"); + try { + const first = loadOrCreateRcInstallationId(path); + assert.match(first, /^[0-9a-f]{8}-[0-9a-f]{4}-4/); + assert.equal(loadOrCreateRcInstallationId(path), first); + assert.equal(JSON.parse(readFileSync(path, "utf8")).installation_id, first); + writeFileSync(path, "broken", "utf8"); + assert.throws(() => loadOrCreateRcInstallationId(path), /corrupt/); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test("Cloud resolves the RC label from account auth without calling SC enrollment", async () => { + const dir = mkdtempSync(join(tmpdir(), "aether-rc-account-")); + const path = join(dir, "identity.json"); + const previous = globalThis.fetch; + const calls: string[] = []; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(String(input)); + calls.push(url.pathname); + assert.equal(url.pathname, "/remote/device-identity"); + assert.equal(init?.method, "POST"); + assert.match(JSON.parse(String(init?.body)).installation_id, /^[0-9a-f-]{36}$/); + return new Response(JSON.stringify({ schema_version: "aether.remote_device_identity.v1", device_id: "rcd_" + "a".repeat(32) }), { headers: { "Content-Type": "application/json" } }); + }) as typeof fetch; + try { + const api = new ApiClient("https://example.test", new StaticTokenStore("aek_normal_account")); + assert.equal((await resolveRcDeviceIdentity(api, path)).device_id, "rcd_" + "a".repeat(32)); + assert.deepEqual(calls, ["/remote/device-identity"]); + } finally { globalThis.fetch = previous; rmSync(dir, { recursive: true, force: true }); } +}); + +test("ordinary RC start uses its own identity and publishes with zero SC enrollment calls", async () => { + const dir = mkdtempSync(join(tmpdir(), "aether-rc-normal-")); + const priorConfig = process.env["AETHER_CONFIG_DIR"]; + process.env["AETHER_CONFIG_DIR"] = dir; + const calls: string[] = []; + const sessionId = "rs_" + "1".repeat(32); + const api = { + async postJson(path: string, body: unknown): Promise { + calls.push(path); + if (path === "/remote/device-identity") return { schema_version: "aether.remote_device_identity.v1", device_id: "rcd_" + "a".repeat(32) }; + if (path === "/remote/sessions") { + assert.equal((body as { device_id: string }).device_id, "rcd_" + "a".repeat(32)); + 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: "rsgt_" + "b".repeat(48), expires_at: new Date(Date.now() + 300_000).toISOString(), + }; + throw new Error(`unexpected route ${path}`); + }, + }; + const output: string[] = []; + const ctx = { api, flags: { cwd: dir, json: true } } as unknown as AppContext; + const flags = { str: () => undefined } as unknown as CommandFlags; + try { + assert.equal(await cmdRc(ctx, ["start"], flags, { + cwd: dir, connector: () => null, browser: () => null, + repo: () => ({ repo: "fixture", branch: "main", base_commit: "0".repeat(40), dirty_file_count: 0 }), + out: text => output.push(text), err: text => { throw new Error(text); }, isTTY: false, columns: 80, + }), 0); + assert.equal(JSON.parse(output[0]!).device_id, "rcd_" + "a".repeat(32)); + assert.equal(calls[0], "/remote/device-identity"); + assert.ok(calls.every(path => !path.includes("/device/v1/"))); + } finally { + if (priorConfig === undefined) delete process.env["AETHER_CONFIG_DIR"]; else process.env["AETHER_CONFIG_DIR"] = priorConfig; + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("RC identity refusal stops before session registration with an actionable code", async () => { + const dir = mkdtempSync(join(tmpdir(), "aether-rc-refused-")); + const priorConfig = process.env["AETHER_CONFIG_DIR"]; + process.env["AETHER_CONFIG_DIR"] = dir; + const calls: string[] = []; + const errors: string[] = []; + const ctx = { api: { async postJson(path: string) { + calls.push(path); + throw Object.assign(new Error("private Cloud detail"), { status: 403 }); + } }, flags: { cwd: dir, json: false } } as unknown as AppContext; + try { + assert.equal(await cmdRc(ctx, ["start"], { str: () => undefined } as unknown as CommandFlags, { + cwd: dir, connector: () => null, browser: () => null, + repo: () => ({ repo: "fixture", branch: "main", base_commit: "0".repeat(40), dirty_file_count: 0 }), + out: () => {}, err: text => errors.push(text), isTTY: false, columns: 80, + }), 1); + assert.deepEqual(calls, ["/remote/device-identity"]); + assert.match(errors.join(""), /RC_NOT_AUTHORIZED/); + assert.doesNotMatch(errors.join(""), /private Cloud detail/); + } finally { + if (priorConfig === undefined) delete process.env["AETHER_CONFIG_DIR"]; else process.env["AETHER_CONFIG_DIR"] = priorConfig; + rmSync(dir, { recursive: true, force: true }); + } +});