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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 6 additions & 5 deletions docs/REMOTE_VIEWING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
47 changes: 28 additions & 19 deletions src/commands/rc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -596,16 +598,6 @@ async function start(
name: string | undefined,
projectRef: string,
): Promise<number> {
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;
Expand All @@ -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}`;

Expand All @@ -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;
Expand All @@ -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",
Expand All @@ -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);
Expand All @@ -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)) {
Expand Down Expand Up @@ -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)),
Expand Down
17 changes: 6 additions & 11 deletions src/core/device_runtime/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
53 changes: 53 additions & 0 deletions src/core/rc/device_identity.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>(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<unknown>("/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<string, unknown>;
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() };
}
17 changes: 8 additions & 9 deletions src/core/rc/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
//
Expand All @@ -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. */
Expand Down
Loading
Loading