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
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ contracts/managed-ats-tool-host/v1/*.json text eol=lf
test/fixtures/managed_tool_host_golden.json text eol=lf
test/fixtures/ats_*.json text eol=lf
test/fixtures/rc-display-v1.json text eol=lf
test/fixtures/rc-viewer-profile-v1.json text eol=lf

# Source custody hashes canonical ATS bytes on every checkout platform.
packages/ats-skills/** text eol=lf
Expand Down
15 changes: 15 additions & 0 deletions COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,21 @@ instead of trusting a PID file; an unverifiable stale PID is never signalled.
}
```

While an `aether rc` session is active for the project, `preview start`,
`status` and `stop` publish the phases they observe (`starting`, `ready`,
`failed`, `stopping`, `stopped`) to viewers under a session-scoped handle. They
never publish the loopback URL, port, PIDs, argv or child error text. A viewer
link appears only for the declared preview while it is `ready`, and only when
the declaration carries an optional `"publicUrl"`. That value must be a public
`https://` origin and path with no credentials, query, fragment, IP address,
private or loopback-resolving host name (`.local`, `.internal`, `.ts.net`,
`.test`, `.onion`, `localtest.me`, `*.nip.io`, a name spelling an IPv4 address,
…) or token-shaped segment (long random runs, UUIDs, hex digests). The check is
conservative: a name it cannot prove public is refused. While an RC session is
active, a refused `publicUrl` stops `preview start` before anything starts;
without one it is ignored with a warning. Publication is best-effort and never
changes a preview's result or exit code.

Use `--no-open` in automation. A headless machine always receives the URL and
an honest “not opened” result. `/preview start|open|logs|status|stop` uses the
same project declaration and lifecycle inside the REPL. This local opener is
Expand Down
46 changes: 41 additions & 5 deletions docs/REMOTE_VIEWING.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,39 @@ has no tool authority.
|---|---|
| `aether rc start` | Start the observer session and return its link. |
| `aether rc link` | Return the current short-lived observer link. |
| `aether rc status` | Inspect local session status without replaying the link. |
| `aether rc exposure` | Inspect what is exposed. |
| `aether rc status` | Ask the Cloud for session health (active, pending, reconnecting, offline, revoked, expired), expiry, last accepted receipt and queued/dropped/quarantined counts, without replaying the link. When the Cloud cannot be asked the state is `unknown`, never `active`. |
| `aether rc exposure` | Inspect what is exposed now, and what is declared but not yet sent. |
| `aether rc viewers` | Show how many observers the Cloud reports attached, against the broker's cap (a count, not identities). When the Cloud cannot answer it reads `unknown`, never zero, and exits 1. |
| `aether rc off` | Revoke observation access. |

Use `aether --json rc start`, `link`, or `status` for structured session/device
identity. Only `start` and `link` return the short-lived link. Integrations must
not log it.
Use `aether --json rc start`, `link`, `status`, `exposure` or `viewers` for
structured session/device identity. Only `start` and `link` return the
short-lived link. Integrations must not log it.

## The browser viewer is observer-only

The link opens the Aether Code viewer at `app.aethersystems.net/rc`, which
hands off to the session page at `/rc/<session>`. Both routes are
observer-only. They declare the `aether.rc_viewer_profile/1` profile:
capability `observe`, grant purpose `observe`, presence roles `host` and
`observer`, the structured event types in `src/core/rc/viewer_profile.ts`
with `transcript` excluded, and no control capability. The viewer has no text
entry, terminal input, or prompt submission.

- `test/rc_viewer_profile.test.ts` pins the Agent's profile against a
hash-pinned, byte-identical copy of the Cloud manifest
(`test/fixtures/rc-viewer-profile-v1.json`).
- Aether Code's build scan enforces the same manifest against the shipped
viewer bundles. Control endpoints, takeover labels, or text-entry elements
fail the build.

Predator browser takeover and process control are not part of `aether rc`.
They live on a separate operator route with its own profile entry and feature
gates, and `aether rc` never links to it: its links carry only an observe
grant and a viewer device id.

These checks bound what a viewer build can do. They do not qualify a
deployment; the live Cloud viewer journey still needs qualification.

## Connection and privacy

Expand All @@ -34,6 +60,16 @@ not log it.
credentials, cookies, private memory, raw file contents, absolute paths,
and unredacted shell history.
- Your local session continues if the broker disconnects.
- Test results come only from the host's own verification (`aether agent`'s
final gate, `aether review verify`, or a stored reading `aether review`
shows): the status (`verified`, `failed`, `stale`, `unknown`), a fixed
reason and, for a failure, the exit code. The test command, its output and
test counts stay local. A run that was interrupted, timed out or could not
start, or whose working tree changed or could not be identified, is
`unknown`, never a pass.
- While a coding run is active, the host heartbeats the session and delivers
queued events, backing off during outages. It never delays the run; events
still queued at the end are delivered by the next run.

These are release requirements. Local source tests do not establish a qualified
Cloud viewer or publication.
Expand Down
82 changes: 71 additions & 11 deletions src/commands/code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ import { sanitizeServerText } from "../core/transport.js";
import { redactInline } from "../core/redaction.js";
import { isPublicationToolCall } from "../core/goal_run.js";
import { turnOutcomeRecord } from "./chat.js";
import { openRcCodingObserver } from "./rc_observation.js";
import { openRcCodingObserver, type RcCodingObserver } from "./rc_observation.js";
import { publishCodingVerification } from "./rc_verification.js";
import {
TRANSIENT_READ_AUTO_RETRIES,
checkpointDoneEvent,
Expand Down Expand Up @@ -696,6 +697,53 @@ export async function verifyCodeTurn(
return turn.settledRun!;
}

/** Where the host's check runs: the run's workspace runner and directory. */
export interface CodeCheckout {
run: Runner;
cwd: string;
}

/**
* The post-loop path exactly as cmdCode runs it — verifyCodeTurn plus its two
* side channels, both fed by the one check reading the footer renders:
*
* - in a git checkout the check runs through the review rail's single writer
* (recordingRunner → verifyAndRecord), so `aether review` reads back the
* result this run reports, bound to this tree and stale after the next edit;
* - the settled reading is queued for an active RC session's Tests panel
* (#219): that recorded reading when the check completed in a checkout,
* otherwise what the CheckReading proves — never a pass it cannot attribute.
*
* The recorded reading and record are also returned as `recordedCheck`, the
* exact tree-bound receipt the session log and a saved-goal phase run keep
* (#299); null when no check was recorded.
*
* RC never decides, delays or changes the result: without an observer nothing
* extra happens, and every observer failure is contained.
*/
export async function verifyCodeTurnInCheckout(
turn: CodeTurnLifecycle,
exec: VerifyRunner,
checkout: CodeCheckout,
observer: RcCodingObserver | null,
opts: CodeVerificationOptions,
): Promise<{ report: CodeRunReport; verification: VerifyOutcome | null; recordedCheck: CodeRunFinished["recordedCheck"] }> {
const prior = turn.settledRun;
if (prior) return { ...prior, recordedCheck: null };
const checkRoot = opts.testCmd ? repoRoot(checkout.run, checkout.cwd) : null;
// A holder rather than a `let`: it is assigned inside the runner's callback,
// where control-flow narrowing cannot see it.
const recorded: { check: Pick<VerifyRunResult, "reading" | "written"> | null } = { check: null };
const checkRunner = checkRoot
? recordingRunner(exec, checkout.run, checkRoot, (result) => {
recorded.check = { reading: result.reading, written: result.written };
})
: exec;
const settled = await verifyCodeTurn(turn, checkRunner, opts);
publishCodingVerification(observer, settled.report.check, recorded.check?.reading ?? null);
return { ...settled, recordedCheck: recorded.check };
}

/**
* The session record of a settled run. A stopped run is filed as stopped —
* "cancelled" or "timed-out" — rather than as a red one, and `remaining` is
Expand Down Expand Up @@ -993,6 +1041,8 @@ export async function cmdCode(
process.on("SIGTERM", onSigterm);
if (opts.signal?.aborted) onExternalAbort();
else opts.signal?.addEventListener("abort", onExternalAbort, { once: true });
// Hoisted so the outer finally can end the RC host on every exit path.
let rcObserver: RcCodingObserver | null = null;

try {

Expand Down Expand Up @@ -1102,7 +1152,8 @@ export async function cmdCode(
// which terminal renderer is active. The observer owns its best-effort upload.
// RC was explicitly enabled for the launch project. An agent may execute in
// a generated worktree, but that does not change which project was shared.
const rcObserver = openRcCodingObserver(ctx.flags.cwd, ctx.api);
// Opening it binds one RC host lifetime (heartbeat + delivery) to this run.
rcObserver = openRcCodingObserver(ctx.flags.cwd, ctx.api);

let onEvent: (ev: BrainEvent) => void | Promise<void>;
let teardown = (): void => {};
Expand Down Expand Up @@ -1260,13 +1311,10 @@ export async function cmdCode(
// A cancelled, timed-out or capped turn never starts the check at all.
// In a git checkout the check is recorded through the review rail's single
// writer, so `aether review` reads back the same result this run reports —
// bound to this tree, and stale after the next edit.
const checkRoot = opts.testCmd ? repoRoot(workspaceRun, cwd) : null;
const checkCapture: { result: Pick<VerifyRunResult, "reading" | "written"> | null } = { result: null };
const checkRunner = checkRoot ? recordingRunner(exec, workspaceRun, checkRoot, (result) => {
checkCapture.result = { reading: result.reading, written: result.written };
}) : exec;
const { report, verification } = await verifyCodeTurn(turn, checkRunner, {
// bound to this tree, and stale after the next edit. The same reading is
// queued for an active RC session's viewer (#219); a no-op without one. The
// recorded receipt goes to the session log and the goal-phase observer (#299).
const { report, verification, recordedCheck } = await verifyCodeTurnInCheckout(turn, exec, { run: workspaceRun, cwd }, rcObserver, {
testCmd: opts.testCmd,
signal: commandAbort.signal,
timeoutMs: progressTimeoutMs,
Expand All @@ -1280,8 +1328,8 @@ export async function cmdCode(
emitCodeTurnOutcome(report, ctx.flags.json);
const record = codeRunRecord(report, verification);
const verifyExit = verification?.exitCode ?? 1;
log?.close(record.finalStatus, nowIso(), record.remaining, record.verification, checkCapture.result?.written ?? undefined, hostRefusals);
await opts.runObserver?.finished({ ...correlation, report, verification, recordedCheck: checkCapture.result, touchedFiles: [...touched], hostRefusals });
log?.close(record.finalStatus, nowIso(), record.remaining, record.verification, recordedCheck?.written ?? undefined, hostRefusals);
await opts.runObserver?.finished({ ...correlation, report, verification, recordedCheck, touchedFiles: [...touched], hostRefusals });
// The verdict line — printed even with --no-log (which used to end with
// NOTHING); suppressed under --json (the outcome record carries the same
// reading). Rendered from the turn outcome plus what the check actually did,
Expand All @@ -1294,6 +1342,15 @@ export async function cmdCode(
process.stderr.write("\n " + runSummary(report, touched.size, secs) + "\n");
}
if (log) process.stderr.write(` ⤷ log: ${log.dir}\n`);
// A viewer sees only Git's measured checkout snapshot after the run settles.
// RC is optional and never awaited here: the measurement runs alongside the
// rest of the run's end, and rcObserver.close() in `finally` waits for it
// only inside its one bounded deadline (#223), so the run never waits on it.
try {
if (!repoSpec) void rcObserver?.publishDiff(cwd);
} catch {
// The coding verdict remains authoritative if observation fails.
}
if (process.env["AETHER_PROJECT_MEMORY_RECEIPTS_ENABLED"] === "1") {
const memory = await completeMemory(memoryContext, pinnedMemory, outcome.state === "succeeded");
if (ctx.flags.json) process.stdout.write(JSON.stringify({ type: "project_memory_status", text: memory }) + "\n");
Expand Down Expand Up @@ -1322,6 +1379,9 @@ export async function cmdCode(
process.removeListener("SIGINT", onSigint);
process.removeListener("SIGTERM", onSigterm);
opts.signal?.removeEventListener("abort", onExternalAbort);
// RC is observation only: one final flush bounded by a short deadline,
// never a rejection and never a delay beyond it (#223).
await rcObserver?.close();
}
}

Expand Down
18 changes: 18 additions & 0 deletions src/commands/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
// approval phrase. `--yes` is not authority here, and deliberately still isn't.

import { readFileSync } from "node:fs";
import { resolve } from "node:path";

import type { AppContext } from "../core/context.js";
import { openBrowser } from "../core/browser.js";
Expand Down Expand Up @@ -50,6 +51,8 @@ import {
renderPlan,
renderReceipt,
} from "../core/action_rail.js";
import { publishActionReceipt } from "../core/rc/action_receipts.js";
import { projectRefFor, rcOutboxPath } from "./rc.js";

export interface GithubOpts {
noBrowser?: boolean;
Expand Down Expand Up @@ -538,5 +541,20 @@ async function doExecute(
receipt: result.receipt,
}),
);
publishReceiptToRc(ctx, plan, result.receipt);
return EXIT_OK;
}

/**
* The receipt hook for an active RC session (#220). Runs after the receipt is
* printed, queues the CI/PR projection durably and starts its upload without
* awaiting it: a viewer can never delay or change a Cloud action's result.
*/
function publishReceiptToRc(ctx: AppContext, plan: ActionPlan, receipt: unknown): void {
try {
const root = resolve(ctx.flags.cwd);
publishActionReceipt(ctx.api, root, rcOutboxPath(projectRefFor(root)), receipt, plan);
} catch {
// RC publication is best-effort by contract.
}
}
3 changes: 2 additions & 1 deletion src/commands/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from "../core/vision.js";
import { theme } from "../ui/theme.js";
import { sanitizeTerm } from "../ui/text.js";
import { rcArtifactObserver } from "./rc_artifacts.js";
import { basename } from "node:path";
import { createInterface, type Interface } from "node:readline";

Expand Down Expand Up @@ -197,7 +198,7 @@ async function mediaGenerate(ctx: AppContext, prompt: string, kind: MediaKind, f
model: modelKey, prompt: vp, kind, filepath, filename: basename(filepath),
url: resp.media_url, timestamp: new Date().toISOString(), flags,
};
const { entry, warning } = recordOutput(result);
const { entry, warning } = recordOutput(result, { onCommitted: rcArtifactObserver(ctx) });
if (warning) process.stderr.write(theme.dim(` ⚠ ${warning.message}\n`));
// resp.media_url and resp.text/response below are server-controlled
// (an LLM generation response) — sanitized before hitting the
Expand Down
Loading
Loading