From 28584ce7075e0d86e0190efa9faa839e8491ef1d Mon Sep 17 00:00:00 2001 From: DBarr3 <143002219+DBarr3@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:03:22 -0400 Subject: [PATCH 1/2] feat(rc): publish measured checkout diff summaries --- src/commands/code.ts | 8 +++++ src/commands/rc.ts | 18 ++++++++++ src/core/diff_counts.ts | 8 +++-- src/core/rc/diff_summary.ts | 37 +++++++++++++++++++ src/core/rc/redaction.ts | 16 +++++++++ test/chat.test.ts | 8 +++-- test/rc_diff_summary.test.ts | 69 ++++++++++++++++++++++++++++++++++++ 7 files changed, 159 insertions(+), 5 deletions(-) create mode 100644 src/core/rc/diff_summary.ts create mode 100644 test/rc_diff_summary.test.ts diff --git a/src/commands/code.ts b/src/commands/code.ts index 0f371602..cfca90f1 100644 --- a/src/commands/code.ts +++ b/src/commands/code.ts @@ -1011,6 +1011,14 @@ export async function cmdCode( process.stderr.write("\n " + runSummary(summaryStatus, remaining, 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; an unavailable broker must not change the code result. + try { + const { publishRcCheckoutDiff } = await import("./rc.js"); + await publishRcCheckoutDiff(ctx.api, 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"); diff --git a/src/commands/rc.ts b/src/commands/rc.ts index 1c5469e5..e164fd14 100644 --- a/src/commands/rc.ts +++ b/src/commands/rc.ts @@ -26,6 +26,7 @@ import { spawnSync } from "node:child_process"; import { join } from "node:path"; import { configDir } from "../core/config.js"; +import { checkoutDiffSummary } from "../core/rc/diff_summary.js"; import type { CommandFlags } from "../core/command_dispatch.js"; import type { AppContext } from "../core/context.js"; import { digestOf } from "../core/device_runtime/canonical_json.js"; @@ -74,6 +75,17 @@ export function rcOutboxPath(projectRef: string): string { return join(configDir(), "device-runtime", "rc", `${projectRef}.json`); } +/** Best-effort refresh after a code run has established its final checkout. */ +export async function publishRcCheckoutDiff(api: AppContext["api"], projectRoot: string): Promise { + const outboxPath = rcOutboxPath(projectRefFor(projectRoot)); + const record = loadOutbox(outboxPath, projectRoot); + if (!record.session_id || record.revoke_pending) return; + const event = await checkoutDiffSummary(projectRoot); + if (!event || !enqueueEvent(record, event.event_type, event.payload)) return; + saveOutbox(outboxPath, record); + await flushOutbox({ api, outboxPath, projectRoot }, record); +} + // ── repo summary (identifiers only) ───────────────────────────────────────── function git(cwd: string, args: readonly string[]): string | null { @@ -329,6 +341,12 @@ async function start( enqueueEvent(record, opened.event_type, opened.payload); const presence = hostPresenceEvent(enrolled.device_id, "live"); enqueueEvent(record, presence.event_type, presence.payload); + try { + const diff = await checkoutDiffSummary(deps.cwd); + if (diff) enqueueEvent(record, diff.event_type, diff.payload); + } catch { + // Diff observation is optional; session opening still succeeds. + } saveOutbox(hostDeps.outboxPath, record); await flushOutbox(hostDeps, record); diff --git a/src/core/diff_counts.ts b/src/core/diff_counts.ts index e6ab9a19..1936c764 100644 --- a/src/core/diff_counts.ts +++ b/src/core/diff_counts.ts @@ -114,7 +114,7 @@ export function numstatArgs(staged: boolean): string[] { * repository, and running them in series doubles the latency of the headline * number for no benefit. Neither writes anything. */ -export async function readDiffCounts(run: AsyncRunner, root: string): Promise> { +export async function readDiffCountSnapshot(run: AsyncRunner, root: string): Promise<{ counts: Map; complete: boolean }> { const [stagedRun, unstagedRun] = await Promise.all([ run("git", ["-C", root, ...numstatArgs(true)], root), run("git", ["-C", root, ...numstatArgs(false)], root), @@ -133,7 +133,11 @@ export async function readDiffCounts(run: AsyncRunner, root: string): Promise> { + return (await readDiffCountSnapshot(run, root)).counts; } export interface CountTotal { diff --git a/src/core/rc/diff_summary.ts b/src/core/rc/diff_summary.ts new file mode 100644 index 00000000..3ce70d65 --- /dev/null +++ b/src/core/rc/diff_summary.ts @@ -0,0 +1,37 @@ +// A checkout snapshot for RC. Paths come from Git status; line counts come +// only from Git numstat. Neither model output nor a human-readable diff is read. +import { resolve } from "node:path"; +import { defaultAsyncRunner, readDiffCountSnapshot, totalCounts, type AsyncRunner } from "../diff_counts.js"; +import { parseStatusV2, STATUS_V2_ARGS } from "../review_state.js"; +import { defaultRunner, type Runner } from "../worktree.js"; +import { diffSummaryEvent, type RcProducedEvent } from "./producers.js"; +import { isSafeRelativePath } from "./redaction.js"; + +export async function checkoutDiffSummary( + projectRoot: string, + run: Runner = defaultRunner(), + runAsync: AsyncRunner = defaultAsyncRunner(), +): Promise { + const root = run("git", ["--no-optional-locks", "-C", projectRoot, "rev-parse", "--show-toplevel"], projectRoot); + if (root.status !== 0 || resolve(root.stdout.trim()) !== resolve(projectRoot)) return null; + + const status = run("git", ["--no-optional-locks", "-C", projectRoot, ...STATUS_V2_ARGS], projectRoot); + if (status.status !== 0) return null; + const paths = [...new Set(parseStatusV2(status.stdout).files.map((file) => file.path))].sort(); + // Refuse the whole snapshot: publishing counts for one set of paths and a + // filtered list for another would give the viewer a misleading summary. + if (paths.some((path) => !isSafeRelativePath(path)) || paths.length > 100_000) return null; + + const snapshot = await readDiffCountSnapshot(runAsync, projectRoot); + const total = totalCounts(snapshot.counts, paths); + const event = diffSummaryEvent(total, paths.slice(0, 64)); + event.payload["files_changed"] = paths.length; + // A failed side, binary or untracked path has no complete line count. The + // schema has optional counts, so omission is the honest unknown state. + if (!snapshot.complete || total.uncounted.length || + !Number.isSafeInteger(total.additions) || !Number.isSafeInteger(total.deletions)) { + delete event.payload["insertions"]; + delete event.payload["deletions"]; + } + return event; +} diff --git a/src/core/rc/redaction.ts b/src/core/rc/redaction.ts index ed9db882..2f65f5ad 100644 --- a/src/core/rc/redaction.ts +++ b/src/core/rc/redaction.ts @@ -76,6 +76,13 @@ const MAX_STRING_LENGTH = 1024; const MAX_LIST_ITEMS = 64; const ABSOLUTE_PATH = /^(?:[A-Za-z]:[\\/]|\\\\|\/|~[\\/])/; +/** Git's project-relative path form, with traversal and machine paths refused. */ +export function isSafeRelativePath(value: string): boolean { + return value.length > 0 && value.length <= 512 && + !ABSOLUTE_PATH.test(value) && !value.includes(":") && + !/[\\\u0000-\u001f\u007f]/.test(value) && + value.split("/").every((part) => part !== "" && part !== "." && part !== ".."); +} // C0 controls and DEL, built without literal control characters in the source. const CONTROL_CHARS = new RegExp( `[${String.fromCharCode(0)}-${String.fromCharCode(31)}${String.fromCharCode(127)}]`, @@ -139,6 +146,15 @@ export function sanitizeRemotePayload( options: SanitizeOptions, ): Record | null { if (!isViewerEventType(eventType)) return null; + if (eventType === "diff_summary") { + const files = payload["files"]; + if (files !== undefined && (!Array.isArray(files) || files.some((path: unknown) => + typeof path !== "string" || !isSafeRelativePath(path)))) return null; + for (const key of ["files_changed", "insertions", "deletions"]) { + const count = payload[key]; + if (count !== undefined && (typeof count !== "number" || !Number.isSafeInteger(count) || count < 0)) return null; + } + } const allowed = RC_ALLOWED_KEYS[eventType]; const env = options.env ?? process.env; const out: Record = {}; diff --git a/test/chat.test.ts b/test/chat.test.ts index 92eb787e..a7678439 100644 --- a/test/chat.test.ts +++ b/test/chat.test.ts @@ -381,15 +381,17 @@ test("cloud output cap counts task progress and duplicate acknowledgments cannot test("long task progress frames sharing a display prefix still advance on novel suffixes", async () => { const real = globalThis.fetch; const previous = process.env["AETHER_STREAM_TIMEOUT_MS"]; - process.env["AETHER_STREAM_TIMEOUT_MS"] = "18"; + // Keep the per-frame gap well below the timeout while the full stream runs + // longer than it. Windows CI can pause the event loop beyond 18 ms. + process.env["AETHER_STREAM_TIMEOUT_MS"] = "200"; globalThis.fetch = (async () => ({ ok: true, status: 200, headers: new Headers({ "content-type": "text/event-stream" }), body: (async function* (): AsyncIterable { - for (let i = 0; i < 12; i += 1) { + for (let i = 0; i < 60; i += 1) { yield new TextEncoder().encode(`data: ${JSON.stringify({ type: "task_progress", task_id: "t", delta: "shared ".repeat(110) + ` novel ${i}` })}\n\n`); - await new Promise((resolve) => setTimeout(resolve, 3)); + await new Promise((resolve) => setTimeout(resolve, 5)); } yield new TextEncoder().encode('data: {"type":"done","uvt":1,"cents":0}\n\n'); })(), diff --git a/test/rc_diff_summary.test.ts b/test/rc_diff_summary.test.ts new file mode 100644 index 00000000..2e6d55d2 --- /dev/null +++ b/test/rc_diff_summary.test.ts @@ -0,0 +1,69 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { checkoutDiffSummary } from "../src/core/rc/diff_summary.js"; +import { createOutbox, enqueueEvent } from "../src/core/rc/outbox.js"; +import { diffSummaryEvent } from "../src/core/rc/producers.js"; +import type { Runner } from "../src/core/worktree.js"; +import { tmpWorkspace } from "./tmp_workspace.js"; + +const haveGit = !spawnSync("git", ["--version"], { encoding: "utf8" }).error; + +test("RC publishes measured changed, clean, and binary checkout snapshots", async (t) => { + if (!haveGit) return t.skip("git not available"); + const dir = tmpWorkspace("aether-rc-diff-"); + const git = (...args: string[]): void => { + const result = spawnSync("git", ["-C", dir, ...args], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + }; + git("init", "-q", "-b", "main"); + git("config", "user.email", "t@t.t"); + git("config", "user.name", "t"); + git("config", "commit.gpgsign", "false"); + git("config", "core.autocrlf", "false"); + writeFileSync(join(dir, "a.txt"), "one\n"); + writeFileSync(join(dir, "image.bin"), Buffer.from([0, 1, 2])); + git("add", "-A"); + git("commit", "-q", "-m", "first"); + + const clean = await checkoutDiffSummary(dir); + assert.deepEqual(clean?.payload, { files_changed: 0, insertions: 0, deletions: 0, files: [] }); + + writeFileSync(join(dir, "a.txt"), "one\ntwo\n"); + const changed = await checkoutDiffSummary(dir); + assert.deepEqual(changed?.payload, { files_changed: 1, insertions: 1, deletions: 0, files: ["a.txt"] }); + + writeFileSync(join(dir, "image.bin"), Buffer.from([0, 1, 9])); + const binary = await checkoutDiffSummary(dir); + assert.equal(binary?.payload["files_changed"], 2); + assert.deepEqual(binary?.payload["files"], ["a.txt", "image.bin"]); + assert.equal(binary?.payload["insertions"], undefined, "binary line counts are unknown"); + assert.equal(binary?.payload["deletions"], undefined); + + const record = createOutbox({ session_id: "s", project_ref: "p", device_id: "d", epoch: 1, project_root: dir }); + assert.equal(enqueueEvent(record, binary!.event_type, binary!.payload), true); + assert.deepEqual(record.events[0]?.payload["files"], ["a.txt", "image.bin"]); +}); + +test("external paths are refused before durable enqueue", async () => { + const root = "C:/checkout"; + const run: Runner = (_cmd, args) => args.includes("rev-parse") + ? { status: 0, stdout: `${root}\n`, stderr: "" } + : { status: 0, stdout: "? /outside/secret.txt\0", stderr: "" }; + assert.equal(await checkoutDiffSummary(root, run), null); + const record = createOutbox({ session_id: "s", project_ref: "p", device_id: "d", epoch: 1, project_root: root }); + const unsafe = diffSummaryEvent({ additions: 1, deletions: 0, uncounted: [] }, ["/outside/secret.txt"]); + assert.equal(enqueueEvent(record, unsafe.event_type, unsafe.payload), false); + assert.equal(record.events.length, 0); +}); + +test("a failed numstat read never becomes a measured zero", async () => { + const root = "C:/checkout"; + const run: Runner = (_cmd, args) => args.includes("rev-parse") + ? { status: 0, stdout: `${root}\n`, stderr: "" } + : { status: 0, stdout: "? new.txt\0", stderr: "" }; + const event = await checkoutDiffSummary(root, run, async () => ({ status: 1, stdout: "", stderr: "unavailable" })); + assert.deepEqual(event?.payload, { files_changed: 1, files: ["new.txt"] }); +}); From 41fe152bfff3b58550be94191fa6ebf4f63f79a1 Mon Sep 17 00:00:00 2001 From: DBarr3 <143002219+DBarr3@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:38:55 -0400 Subject: [PATCH 2/2] Treat mixed binary diff counts as unknown --- src/core/rc/diff_summary.ts | 2 +- test/rc_diff_summary.test.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/core/rc/diff_summary.ts b/src/core/rc/diff_summary.ts index 3ce70d65..147f6329 100644 --- a/src/core/rc/diff_summary.ts +++ b/src/core/rc/diff_summary.ts @@ -28,7 +28,7 @@ export async function checkoutDiffSummary( event.payload["files_changed"] = paths.length; // A failed side, binary or untracked path has no complete line count. The // schema has optional counts, so omission is the honest unknown state. - if (!snapshot.complete || total.uncounted.length || + if (!snapshot.complete || total.uncounted.length || paths.some((path) => snapshot.counts.get(path)?.binary) || !Number.isSafeInteger(total.additions) || !Number.isSafeInteger(total.deletions)) { delete event.payload["insertions"]; delete event.payload["deletions"]; diff --git a/test/rc_diff_summary.test.ts b/test/rc_diff_summary.test.ts index 799960a2..7cf6053a 100644 --- a/test/rc_diff_summary.test.ts +++ b/test/rc_diff_summary.test.ts @@ -45,6 +45,14 @@ test("RC publishes measured changed, clean, and binary checkout snapshots", asyn const record = createOutbox({ session_id: "s", project_ref: "p", device_id: "d", epoch: 1, project_root: dir }); assert.equal(enqueueEvent(record, binary!.event_type, binary!.payload), true); assert.deepEqual(record.events[0]?.payload["files"], ["a.txt", "image.bin"]); + + git("restore", "image.bin"); + git("add", "a.txt"); + writeFileSync(join(dir, "a.txt"), Buffer.from([0, 1, 9])); + const mixed = await checkoutDiffSummary(dir); + assert.deepEqual(mixed?.payload["files"], ["a.txt"]); + assert.equal(mixed?.payload["insertions"], undefined, "a binary side makes the aggregate unknown"); + assert.equal(mixed?.payload["deletions"], undefined); }); test("external paths are refused before durable enqueue", async () => {