From 37714805cd5e4beabc9a0a40cb00b111aae61785 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Fri, 6 Nov 2026 12:41:35 +0800 Subject: [PATCH 1/2] fix(telemetry): suppress temporary machine routes at the shared sender Signed-off-by: huangruiteng --- .../usage-statistics-settings.tsx | 5 +- .../connect-route-collision-guard-smoke.py | 1 + .../control_plane/runtime/usage_statistics.ts | 24 ++- .../runtime/usage_statistics_environment.ts | 49 ++++++ .../control_plane_ts/usage_statistics.test.ts | 4 +- .../usage_statistics_delivery.test.ts | 2 +- .../usage_statistics_diagnostics.test.ts | 4 +- .../usage_statistics_environment.test.ts | 105 ++++++++++++ .../usage_statistics_goals.test.ts | 2 +- .../usage_statistics_installation.test.ts | 6 +- .../usage_statistics_sources.test.ts | 10 +- tests/test_usage_goal.py | 1 + tests/test_usage_ping.py | 2 + tests/test_usage_temporary_environment.py | 156 ++++++++++++++++++ 14 files changed, 346 insertions(+), 25 deletions(-) create mode 100644 loopx/control_plane/runtime/usage_statistics_environment.ts create mode 100644 tests/control_plane_ts/usage_statistics_environment.test.ts create mode 100644 tests/test_usage_temporary_environment.py diff --git a/apps/presentation/dashboard/src/features/personal-workspace/usage-statistics-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/usage-statistics-settings.tsx index bfe25840d7..c331e3db47 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/usage-statistics-settings.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/usage-statistics-settings.tsx @@ -46,9 +46,10 @@ export function UsageStatisticsSettings() {

{state.sending ? (zh ? "已允许发送" : "Sending allowed") - : state.notice_required && state.consent !== "disabled" ? (zh ? "等待首次告知确认;尚未发送" : "Awaiting first-use acknowledgment; not sending") + : state.blocked_by === "temporary_path" ? (zh ? "临时环境不发送统计" : "Not sending in a temporary environment") + : state.blocked_by === "notice_required" ? (zh ? "等待首次告知确认;尚未发送" : "Awaiting first-use acknowledgment; not sending") : (zh ? `当前不发送:${({disabled:"已关闭",CI:"CI 环境",DO_NOT_TRACK:"请勿追踪开关",LOOPX_USAGE_PING:"环境变量已关闭",consent_required:"需要明确同意",invalid_policy:"策略配置无效",invalid_endpoint:"接收地址无效",notice_required:"需要重新告知"} as Record)[state.blocked_by ?? ""] ?? "请检查配置"}` : `Not sending: ${state.blocked_by}`)}

- {state.notice_required && state.consent !== "disabled" ? : null} + {["notice_required", "consent_required"].includes(state.blocked_by ?? "") ? : null}

{zh ? "接收地址:" : "Recipient: "}{state.endpoint ?? (zh ? "未配置" : "Not configured")}

{zh ? "查看待发送数据与本地发送摘要" : "Preview outgoing data and local delivery summaries"}
{JSON.stringify({ heartbeat: state.next_payload, aggregate: state.aggregate_preview, diagnostics: state.diagnostic_preview, goals: state.goal_preview, installation: state.installation_preview, identity_scope: state.identity_scope, dropped: state.diagnostic_dropped, delivery_history: state.delivery_history }, null, 2)}
: null} diff --git a/examples/project/connect-route-collision-guard-smoke.py b/examples/project/connect-route-collision-guard-smoke.py index 268600f945..026f613db4 100644 --- a/examples/project/connect-route-collision-guard-smoke.py +++ b/examples/project/connect-route-collision-guard-smoke.py @@ -88,6 +88,7 @@ def run_cli( process_env = os.environ.copy() if env: process_env.update(env) + process_env["LOOPX_USAGE_PING"] = "0" return subprocess.run( command, cwd=REPO_ROOT, diff --git a/loopx/control_plane/runtime/usage_statistics.ts b/loopx/control_plane/runtime/usage_statistics.ts index a376a7bc3c..8d5eaad103 100644 --- a/loopx/control_plane/runtime/usage_statistics.ts +++ b/loopx/control_plane/runtime/usage_statistics.ts @@ -18,6 +18,7 @@ import type { CycleObservation } from "./usage_statistics_cycles.ts"; import { recordInstallation, installationPreview } from "./usage_statistics_installation.ts"; import { validInstallationUsage } from "./usage_statistics_installation_contract.ts"; import type { InstallationUsage, ProfileFeature } from "./usage_statistics_installation_contract.ts"; +import { temporaryUsageEnvironment } from "./usage_statistics_environment.ts"; export const STATE_SCHEMA = "loopx_usage_ping_state_v1"; export const DEFAULT_ENDPOINT = "https://loopx-usage-collector.huangrt01.workers.dev/v1/ping"; @@ -50,14 +51,14 @@ function notice(ctx: Context): Notice { function sameNotice(state: State, ctx: Context): boolean { return JSON.stringify(state.notice) === JSON.stringify(notice(ctx)); } -function automaticNoticeRequired(state: State, ctx: Context): boolean { +function automaticNoticeRequired(state: State, ctx: Context, path: string): boolean { // New installations and expanded disclosures may use notice-before-default-on. // A changed recipient or policy still needs the owner's explicit choice. - return blockedBy(state, ctx) === "notice_required" + return blockedBy(state, ctx, path) === "notice_required" && (!state.notice || (state.notice.version !== NOTICE_VERSION && state.notice.endpoint === endpoint(ctx.env) && state.notice.policy === notice(ctx).policy)); } -export function blockedBy(state: State, ctx: Context): string | null { +export function blockedBy(state: State, ctx: Context, path: string): string | null { const env = ctx.env; if (["0", "false", "no", "off"].includes((env.LOOPX_USAGE_PING ?? "").trim().toLowerCase())) return "LOOPX_USAGE_PING"; if (!["", "0"].includes((env.DO_NOT_TRACK ?? "").trim())) return "DO_NOT_TRACK"; @@ -67,6 +68,11 @@ export function blockedBy(state: State, ctx: Context): string | null { if (!["opt_out", "consent_required"].includes(policy)) return "invalid_policy"; if (policy === "consent_required" && state.consent !== "enabled") return "consent_required"; if (!endpoint(env)) return "invalid_endpoint"; + // An explicit local transport qualification may use disposable state. This + // cannot authorize a remote recipient or override CI / opt-out / consent. + const localQualification = env.LOOPX_USAGE_PING === "1" + && ["127.0.0.1", "[::1]"].includes(new URL(endpoint(env)).hostname); + if (!localQualification && temporaryUsageEnvironment(path, env)) return "temporary_path"; if (!sameNotice(state, ctx)) return "notice_required"; return null; } @@ -116,11 +122,11 @@ function ping(state: State, ctx: Context): Ping | null { } export async function inspect(path: string, ctx: Context) { const state = await load(path); - const blocked = blockedBy(state, ctx); + const blocked = blockedBy(state, ctx, path); return { schema: "loopx_usage_ping_status_v1", consent: state.consent, sending: blocked === null, blocked_by: blocked, endpoint: endpoint(ctx.env) || null, policy: notice(ctx).policy, notice: notice(ctx), notice_required: !sameNotice(state, ctx), - automatic_notice_required: automaticNoticeRequired(state, ctx), last_sent_day: state.last_sent_day ?? null, + automatic_notice_required: automaticNoticeRequired(state, ctx, path), last_sent_day: state.last_sent_day ?? null, next_payload: state.consent === "disabled" ? null : ping(state, ctx), aggregate_preview: state.consent === "disabled" || !state.counters?.length ? null : { schema: AGGREGATE_SCHEMA, counters: state.counters }, diagnostic_preview: state.consent === "disabled" || !state.diagnostics?.length ? null : { schema: DIAGNOSTIC_SCHEMA, counters: state.diagnostics }, @@ -168,7 +174,7 @@ export async function configure(path: string, ctx: Context, action: "enable" | " return; } if (action === "acknowledge" && JSON.stringify(expectedNotice) !== JSON.stringify(notice(ctx))) throw new Error("usage_notice_changed"); - if (action === "acknowledge" && !automaticNoticeRequired(state, ctx)) return; + if (action === "acknowledge" && !automaticNoticeRequired(state, ctx, path)) return; if (action === "enable") state.consent = "enabled"; if (state.notice && !sameNotice(state, ctx)) { state.counters = []; @@ -208,7 +214,7 @@ export async function observe(path: string, ctx: Context, generation: string, co const now = (ctx.now ?? new Date()).getTime(); const allowed = await withFileMutationLock(path, async () => { const state = await load(path); - const blocked = blockedBy(state, ctx); + const blocked = blockedBy(state, ctx, path); if (blocked || !generation || generation !== state.generation) return false; if (state.day && state.day > today) return false; if (state.aggregate_last_attempt_ms !== undefined && now < state.aggregate_last_attempt_ms) return false; @@ -291,7 +297,7 @@ export async function observe(path: string, ctx: Context, generation: string, co if (url.endsWith("/goals") || url.endsWith("/installation")) { await withFileMutationLock(path, async () => { const current = await load(path); - if (!blockedBy(current, ctx) && current.generation === generation) request = send(url, payload).catch(() => 0); + if (!blockedBy(current, ctx, path) && current.generation === generation) request = send(url, payload).catch(() => 0); }, 250); } if (!request) break; @@ -300,7 +306,7 @@ export async function observe(path: string, ctx: Context, generation: string, co if (url.endsWith("/ping") && accepted) sent = true; await withFileMutationLock(path, async () => { const latest = await load(path); - if (latest.generation !== generation || blockedBy(latest, ctx)) return; + if (latest.generation !== generation || blockedBy(latest, ctx, path)) return; if (url.endsWith("/ping") && accepted) latest.last_sent_day = today; const delivery: Delivery = { day: today, channel: url.endsWith("/ping") ? "heartbeat" : url.endsWith("/aggregate") ? "cli" : url.endsWith("/installation") ? "installation" : "goal", diff --git a/loopx/control_plane/runtime/usage_statistics_environment.ts b/loopx/control_plane/runtime/usage_statistics_environment.ts new file mode 100644 index 0000000000..55b3084e49 --- /dev/null +++ b/loopx/control_plane/runtime/usage_statistics_environment.ts @@ -0,0 +1,49 @@ +/** Local-only path eligibility. Paths never enter an outgoing payload. */ +import { realpathSync } from "node:fs"; +import { homedir, platform, tmpdir } from "node:os"; +import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +type Env = Record; + +function physicalPath(value: string): string { + let parent = resolve(value); + const missing: string[] = []; + // A first-use state file need not exist yet. Resolve its nearest existing + // ancestor, including symlinked homes and macOS /var -> /private/var. + while (true) { + try { return join(realpathSync(parent), ...missing); } + catch (error) { + if (!["ENOENT", "ENOTDIR"].includes(String((error as NodeJS.ErrnoException).code))) throw error; + const next = dirname(parent); + if (next === parent) return resolve(value); + missing.unshift(parent.slice(next.length).replace(/^[/\\]+/, "")); + parent = next; + } + } +} + +export function temporaryUsageEnvironment(path: string, env: Env): boolean { + const windows = platform() === "win32"; + const home = env.HOME || env.USERPROFILE || homedir(); + const roots = [tmpdir(), env.TMPDIR, env.TEMP, env.TMP, + ...(windows ? [join(env.SystemRoot || env.WINDIR || "C:\\Windows", "Temp"), + join(env.LOCALAPPDATA || join(home, "AppData", "Local"), "Temp")] + : ["/tmp", "/var/tmp", "/private/tmp", "/private/var/tmp", + ...(platform() === "darwin" ? ["/var/folders", "/private/var/folders"] : [])])]; + const candidates = [path, home, env.USERPROFILE, env.CODEX_HOME, + env.XDG_STATE_HOME, env.XDG_DATA_HOME, env.XDG_CONFIG_HOME, + fileURLToPath(import.meta.url)]; + // Unreadable/looping routes throw into the existing unavailable-settings + // boundary; do not misclassify an unknown route as a temporary one. + const normalize = (value: string) => windows ? value.toLowerCase() : value; + const directories = [...new Set(roots.filter((root): root is string => !!root && isAbsolute(root)) + .map(physicalPath).filter(root => root !== parse(root).root).map(normalize))]; + return candidates.filter((value): value is string => !!value).some(value => { + const candidate = normalize(physicalPath(value)); + return directories.some(root => { + const child = relative(root, candidate); + return child === "" || (!isAbsolute(child) && child !== ".." && !child.startsWith(".." + sep)); + }); + }); +} diff --git a/tests/control_plane_ts/usage_statistics.test.ts b/tests/control_plane_ts/usage_statistics.test.ts index 3bb2ace3b9..4ff636a59d 100644 --- a/tests/control_plane_ts/usage_statistics.test.ts +++ b/tests/control_plane_ts/usage_statistics.test.ts @@ -16,7 +16,7 @@ import { acquireFileMutationLock, releaseFileMutationLock } from "../../loopx/co import type { FileMutationLock } from "../../loopx/control_plane/effect_runtime_io.ts"; const row: Counter = { feature: "todo", outcome: "ok", duration: "lt_1s", error: "none", count: 1 }; -const context = (day = "2026-09-26"): Context => ({ env: { LOOPX_USAGE_PING_ENDPOINT: "http://127.0.0.1:8787/v1/ping" }, version: "1.2.0", python: "3.13", channel: "source", now: new Date(day + "T12:00:00Z") }); +const context = (day = "2026-09-26"): Context => ({ env: { LOOPX_USAGE_PING: "1", LOOPX_USAGE_PING_ENDPOINT: "http://127.0.0.1:8787/v1/ping" }, version: "1.2.0", python: "3.13", channel: "source", now: new Date(day + "T12:00:00Z") }); async function fixture(t: test.TestContext) { const root = await mkdtemp(join(tmpdir(), "loopx-usage-")); t.after(() => rm(root, { recursive: true, force: true })); @@ -88,7 +88,7 @@ test("consent-required policy cannot be satisfied by acknowledgment; recipient c await configure(path, ctx, "acknowledge", (await inspect(path, ctx)).notice); assert.equal((await inspect(path, ctx)).blocked_by, "consent_required"); await configure(path, ctx, "enable"); assert.equal((await inspect(path, ctx)).sending, true); - ctx.env.LOOPX_USAGE_PING_ENDPOINT = "https://another.example/v1/ping"; + ctx.env.LOOPX_USAGE_PING_ENDPOINT = "http://127.0.0.1:8788/v1/ping"; assert.equal((await inspect(path, ctx)).blocked_by, "notice_required"); }); diff --git a/tests/control_plane_ts/usage_statistics_delivery.test.ts b/tests/control_plane_ts/usage_statistics_delivery.test.ts index 15d5ff7c0b..3bbe6cdea0 100644 --- a/tests/control_plane_ts/usage_statistics_delivery.test.ts +++ b/tests/control_plane_ts/usage_statistics_delivery.test.ts @@ -13,7 +13,7 @@ async function fixture(t: test.TestContext) { const root = await mkdtemp(join(tmpdir(), "loopx-usage-delivery-")); t.after(() => rm(root, { recursive: true, force: true })); const path = join(root, "usage-ping.json"); - const ctx: Context = { env: { LOOPX_USAGE_PING_ENDPOINT: "http://127.0.0.1:8787/v1/ping" }, + const ctx: Context = { env: { LOOPX_USAGE_PING: "1", LOOPX_USAGE_PING_ENDPOINT: "http://127.0.0.1:8787/v1/ping" }, version: "1.2.2", python: "3.13", channel: "source", now: new Date("2026-09-28T23:59:00Z") }; await configure(path, ctx, "enable"); const state = async () => JSON.parse(await readFile(path, "utf8")); diff --git a/tests/control_plane_ts/usage_statistics_diagnostics.test.ts b/tests/control_plane_ts/usage_statistics_diagnostics.test.ts index 1f65fbd4e8..7ef1347e6f 100644 --- a/tests/control_plane_ts/usage_statistics_diagnostics.test.ts +++ b/tests/control_plane_ts/usage_statistics_diagnostics.test.ts @@ -38,7 +38,7 @@ test("expanded contract rejects identity, raw errors, arbitrary fields and illeg test("new diagnostics flush once, preserve activity date, renew v4 notice and obey opt-out", async t => { const root = await mkdtemp(join(tmpdir(), "loopx-diagnostics-")); t.after(() => rm(root, { recursive: true, force: true })); const path = join(root, "state.json"); - const ctx = { env: { LOOPX_USAGE_PING_ENDPOINT: "http://127.0.0.1:1/v1/ping" }, version: "1.2.3", python: "3.13", channel: "source", now: new Date("2026-10-01T00:02:00Z") }; + const ctx = { env: { LOOPX_USAGE_PING: "1", LOOPX_USAGE_PING_ENDPOINT: "http://127.0.0.1:1/v1/ping" }, version: "1.2.3", python: "3.13", channel: "source", now: new Date("2026-10-01T00:02:00Z") }; await configure(path, ctx, "enable"); const old = JSON.parse(await readFile(path, "utf8")); old.notice.version = 4; old.diagnostics = [row]; await writeFile(path, JSON.stringify(old)); @@ -61,7 +61,7 @@ test("diagnostic diversity is bounded and stale activity cannot reach a later ba const root = await mkdtemp(join(tmpdir(), "loopx-diagnostic-capacity-")); t.after(() => rm(root, { recursive: true, force: true })); const path = join(root, "state.json"); - const ctx = { env: {}, version: "1.2.3", python: "3.13", channel: "source", now: new Date("2026-09-30T12:00:00Z") }; + const ctx = { env: { LOOPX_USAGE_PING: "1", LOOPX_USAGE_PING_ENDPOINT: "http://127.0.0.1:1/v1/ping" }, version: "1.2.3", python: "3.13", channel: "source", now: new Date("2026-09-30T12:00:00Z") }; await configure(path, ctx, "enable"); const state = JSON.parse(await readFile(path, "utf8")); const sent: unknown[] = []; diff --git a/tests/control_plane_ts/usage_statistics_environment.test.ts b/tests/control_plane_ts/usage_statistics_environment.test.ts new file mode 100644 index 0000000000..e6f4e961db --- /dev/null +++ b/tests/control_plane_ts/usage_statistics_environment.test.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { mkdir, mkdtemp, readFile, rm, symlink } from "node:fs/promises"; +import { platform, tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; +import { configure, inspect, observe } from "../../loopx/control_plane/runtime/usage_statistics.ts"; +import type { Context } from "../../loopx/control_plane/runtime/usage_statistics.ts"; +import { temporaryUsageEnvironment } from "../../loopx/control_plane/runtime/usage_statistics_environment.ts"; + +test("temporary machine routes suppress every HTTP channel even with enabled/copied state", async t => { + const requests: Array<{ path: string; schema: string }> = []; + const server = createServer(async (request, response) => { + let raw = ""; + for await (const chunk of request) raw += chunk; + requests.push({ path: request.url!, schema: JSON.parse(raw).schema }); + response.writeHead(204).end(); + }); + await new Promise(ready => server.listen(0, "127.0.0.1", ready)); + t.after(() => new Promise((done, reject) => server.close(error => error ? reject(error) : done()))); + const address = server.address(); assert.ok(address && typeof address !== "string"); + const root = await mkdtemp(join(tmpdir(), "loopx-usage-isolation-")); + t.after(() => rm(root, { recursive: true, force: true })); + const path = join(root, "home", ".loopx", "usage-ping.json"); + const ctx: Context = { env: { HOME: join(root, "home"), LOOPX_USAGE_PING: "1", + LOOPX_USAGE_PING_ENDPOINT: `http://127.0.0.1:${address.port}/v1/ping` }, + version: "1.2.3", python: "3.11", channel: "source", now: new Date("2026-11-06T12:00:00Z") }; + const automatic = { ...ctx, env: { ...ctx.env, LOOPX_USAGE_PING: undefined } }; + const initial = await inspect(path, automatic); + assert.equal(initial.blocked_by, "temporary_path"); + assert.equal(initial.automatic_notice_required, false); + await configure(path, automatic, "acknowledge", initial.notice); + await assert.rejects(readFile(path), { code: "ENOENT" }, "automatic disclosure must not create a throwaway ID"); + + await configure(path, ctx, "enable"); + const before = await readFile(path, "utf8"); + const { generation } = JSON.parse(before); + const row = { feature: "todo", outcome: "ok", error: "none", duration: "lt_1s", count: 1 } as const; + const end = ctx.now!.getTime() - 86400000; // Closed-day Goal summaries are eligible immediately. + const goal = { key: "a".repeat(64), start: end - 60000, end, measurement: "host_call", host: "unknown" } as const; + const diagnostic = { ...row, operation: "default", signal: "none", version: ctx.version, activity_day: "2026-11-06", context: "unknown" } as const; + await observe(path, automatic, generation, row, undefined, goal, undefined, diagnostic); + await observe(path, automatic, generation, null, undefined, undefined, + { key: "b".repeat(64), lane: "c".repeat(64), turn: null, phase: "start", at: ctx.now!.getTime(), host: "unknown" }); + assert.deepEqual(requests, [], "heartbeat, CLI, Goal and installation must all stay silent"); + assert.equal(await readFile(path, "utf8"), before, "suppression cannot consume claims or pending counts"); + for (const suffix of [".goals", ".cycles", ".installation"]) await assert.rejects(readFile(path + suffix), { code: "ENOENT" }); + const remote = { ...ctx, env: { ...ctx.env, LOOPX_USAGE_PING_ENDPOINT: "https://collector.invalid/v1/ping" } }; + assert.equal((await inspect(path, remote)).blocked_by, "temporary_path", "explicit enable is not a remote bypass"); + + await observe(path, ctx, generation, row, undefined, goal, undefined, diagnostic); + assert.deepEqual(new Set(requests.map(r => r.path)), new Set(["/v1/ping", "/v1/aggregate", "/v1/goals", "/v1/installation"])); + assert.equal(requests.length, 5, "positive control exercises both aggregate contracts as well as the other channels"); +}); + +test("persistent machine routes retain notice-before-default-on without an enable flag", async t => { + await mkdir(resolve(".local"), { recursive: true }); + const root = await mkdtemp(join(resolve(".local"), "usage-persistent-")); + t.after(() => rm(root, { recursive: true, force: true })); + const path = join(root, "usage-ping.json"); + const ctx: Context = { env: { HOME: root, LOOPX_USAGE_PING_ENDPOINT: "http://127.0.0.1:1/v1/ping" }, + version: "1.2.3", python: "3.11", channel: "source" }; + const initial = await inspect(path, ctx); + assert.equal(initial.blocked_by, "notice_required"); + assert.equal(initial.automatic_notice_required, true); + await configure(path, ctx, "acknowledge", initial.notice); + const state = JSON.parse(await readFile(path, "utf8")); + assert.equal(state.consent, "default"); + assert.equal((await inspect(path, ctx)).sending, true); + const sent: string[] = []; + await observe(path, ctx, state.generation, + { feature: "todo", outcome: "ok", error: "none", duration: "lt_1s", count: 1 }, + async (_url, payload) => { sent.push(payload.schema); return 204; }); + assert.ok(sent.includes("loopx_usage_ping_v1")); + assert.ok(sent.includes("loopx_installation_usage_v1")); +}); + +test("temporary roots, filesystem aliases and path-component boundaries are local eligibility facts", async t => { + const root = await mkdtemp(join(tmpdir(), "loopx-usage-paths-")); + t.after(() => rm(root, { recursive: true, force: true })); + const persistent = resolve(".local", "usage-policy-persistent"); + for (const key of ["HOME", "USERPROFILE", "CODEX_HOME", "XDG_STATE_HOME", "XDG_DATA_HOME", "XDG_CONFIG_HOME"]) { + assert.equal(temporaryUsageEnvironment(join(persistent, "usage-ping.json"), { HOME: persistent, [key]: join(root, "home") }), true, key); + } + const configured = join(persistent, "scratch"); + for (const key of ["TMPDIR", "TEMP", "TMP"]) { + assert.equal(temporaryUsageEnvironment(join(configured, "home", "usage-ping.json"), { HOME: persistent, [key]: configured }), true); + assert.equal(temporaryUsageEnvironment(join(configured + "-persistent", "usage-ping.json"), { HOME: persistent, [key]: configured }), false); + } + assert.equal(temporaryUsageEnvironment(join(persistent, "tests", "usage-ping.json"), { HOME: persistent }), false, + "a test-like name or a normal process temp directory must not suppress a persistent installation"); + await mkdir(resolve(".local"), { recursive: true }); + const links = await mkdtemp(join(resolve(".local"), "usage-alias-")); + t.after(() => rm(links, { recursive: true, force: true })); + await mkdir(join(root, "physical")); + const alias = join(links, "alias"); + await symlink(join(root, "physical"), alias, platform() === "win32" ? "junction" : "dir"); + assert.equal(temporaryUsageEnvironment(join(alias, "not-created", "usage-ping.json"), { HOME: persistent }), true); + if (platform() !== "win32") { + for (const prefix of ["/tmp", "/var/tmp", "/private/tmp", "/private/var/tmp", + ...(platform() === "darwin" ? ["/var/folders", "/private/var/folders"] : [])]) { + assert.equal(temporaryUsageEnvironment(join(prefix, "synthetic", "usage-ping.json"), { HOME: persistent }), true, prefix); + } + } +}); diff --git a/tests/control_plane_ts/usage_statistics_goals.test.ts b/tests/control_plane_ts/usage_statistics_goals.test.ts index e32bf32bca..5293a9d77e 100644 --- a/tests/control_plane_ts/usage_statistics_goals.test.ts +++ b/tests/control_plane_ts/usage_statistics_goals.test.ts @@ -15,7 +15,7 @@ async function fixture(t: test.TestContext) { t.after(() => rm(root, { recursive: true, force: true })); return join(root, "usage.json"); } -const ctx = (now: number): Context => ({ env: { LOOPX_USAGE_PING_ENDPOINT: "http://127.0.0.1:1/v1/ping" }, version: "1.0.0", python: "3.13", channel: "source", now: new Date(now) }); +const ctx = (now: number): Context => ({ env: { LOOPX_USAGE_PING: "1", LOOPX_USAGE_PING_ENDPOINT: "http://127.0.0.1:1/v1/ping" }, version: "1.0.0", python: "3.13", channel: "source", now: new Date(now) }); const checkpoint = (start: number, end: number) => ({ key, start, end, measurement: "host_call" as const, host: "unknown" as const }); test("union counts concurrent/nested intervals once and preserves idle gaps under replay and reordering", () => { diff --git a/tests/control_plane_ts/usage_statistics_installation.test.ts b/tests/control_plane_ts/usage_statistics_installation.test.ts index 0d451ed0cb..8e2868be54 100644 --- a/tests/control_plane_ts/usage_statistics_installation.test.ts +++ b/tests/control_plane_ts/usage_statistics_installation.test.ts @@ -20,7 +20,7 @@ async function fixture(t: test.TestContext) { } test("device context is shared, environment wins, and labeling never enables or rotates identity", async t => { const path = await fixture(t); - const ctx: Context = { env: {}, version: "1.2.4", python: "3.13", channel: "source" }; + const ctx: Context = { env: { LOOPX_USAGE_PING: "1", LOOPX_USAGE_PING_ENDPOINT: "http://127.0.0.1:1/v1/ping" }, version: "1.2.4", python: "3.13", channel: "source" }; await configureContext(path, ctx, "maintainer"); assert.equal((await inspect(path, ctx)).sending, false); assert.equal((await inspect(path, ctx)).effective_context, "maintainer"); @@ -119,7 +119,7 @@ test("expanded fixed families reject custom names and transport rejects content }); test("suppression and disable fence all new channels and erase local interval history", async t => { const path = await fixture(t); - const ctx: Context = { env: {}, version: "1.2.4", python: "3.13", channel: "source", now: new Date(at("01T12:00:00")) }; + const ctx: Context = { env: { LOOPX_USAGE_PING: "1", LOOPX_USAGE_PING_ENDPOINT: "http://127.0.0.1:1/v1/ping" }, version: "1.2.4", python: "3.13", channel: "source", now: new Date(at("01T12:00:00")) }; await configureContext(path, ctx, "maintainer"); await configure(path, ctx, "enable"); const generation = JSON.parse(await readFile(path, "utf8")).generation; const sent: unknown[] = [], post: Post = async (_url, payload) => { sent.push(payload); return 204; }; @@ -136,7 +136,7 @@ test("suppression and disable fence all new channels and erase local interval hi }); test("short-lived CLI completion survives a competing detached startup observation", async t => { const path = await fixture(t); - const ctx: Context = { env: {}, version: "1.2.4", python: "3.13", channel: "source" }; + const ctx: Context = { env: { LOOPX_USAGE_PING: "1", LOOPX_USAGE_PING_ENDPOINT: "http://127.0.0.1:1/v1/ping" }, version: "1.2.4", python: "3.13", channel: "source" }; await configure(path, ctx, "enable"); const generation = JSON.parse(await readFile(path, "utf8")).generation; const sent: Parameters[1][] = []; diff --git a/tests/control_plane_ts/usage_statistics_sources.test.ts b/tests/control_plane_ts/usage_statistics_sources.test.ts index 543ed0f0c5..709d51e98e 100644 --- a/tests/control_plane_ts/usage_statistics_sources.test.ts +++ b/tests/control_plane_ts/usage_statistics_sources.test.ts @@ -21,7 +21,7 @@ const event = (type: string, time: number, fields = {}) => JSON.stringify({ type test("cycle observations survive a short local statistics lock without inventing another cycle", async t => { const path = join(await fixture(t), "usage.json"); - const ctx = { env: { LOOPX_USAGE_PING_ENDPOINT: "http://127.0.0.1:1/v1/ping" }, version: "1.4.2", python: "3.11", channel: "source", now: new Date(at + 2000) }; + const ctx = { env: { LOOPX_USAGE_PING: "1", LOOPX_USAGE_PING_ENDPOINT: "http://127.0.0.1:1/v1/ping" }, version: "1.4.2", python: "3.11", channel: "source", now: new Date(at + 2000) }; await configure(path, ctx, "enable"); const generation = JSON.parse(await readFile(path, "utf8")).generation; for (const input of [cycle("start", at), cycle("spend", at + 1000)]) { @@ -44,7 +44,7 @@ test("cycle observations survive a short local statistics lock without inventing test("cycle observations retain a bounded contention failure and recover only after release", { timeout: 10000 }, async t => { const path = join(await fixture(t), "usage.json"); - const ctx = { env: { LOOPX_USAGE_PING_ENDPOINT: "http://127.0.0.1:1/v1/ping" }, version: "1.4.2", python: "3.11", channel: "source", now: new Date(at) }; + const ctx = { env: { LOOPX_USAGE_PING: "1", LOOPX_USAGE_PING_ENDPOINT: "http://127.0.0.1:1/v1/ping" }, version: "1.4.2", python: "3.11", channel: "source", now: new Date(at) }; await configure(path, ctx, "enable"); const generation = JSON.parse(await readFile(path, "utf8")).generation; const lock = await acquireFileMutationLock(path); @@ -64,7 +64,7 @@ test("cycle observations retain a bounded contention failure and recover only af test("queued cycle observations recheck withdrawal and consent generation under the lock", async t => { for (const revoked of [{ consent: "disabled" }, { generation: randomUUID() }]) { const path = join(await fixture(t), "usage.json"); - const ctx = { env: { LOOPX_USAGE_PING_ENDPOINT: "http://127.0.0.1:1/v1/ping" }, version: "1.4.2", python: "3.11", channel: "source", now: new Date(at) }; + const ctx = { env: { LOOPX_USAGE_PING: "1", LOOPX_USAGE_PING_ENDPOINT: "http://127.0.0.1:1/v1/ping" }, version: "1.4.2", python: "3.11", channel: "source", now: new Date(at) }; await configure(path, ctx, "enable"); const state = JSON.parse(await readFile(path, "utf8")); const lock = await acquireFileMutationLock(path); @@ -187,7 +187,7 @@ test("three clocks remain separate and same-source overlaps are deduplicated", a test("opt-out prevents opening bound transcript files; disable erases cycle cursors", async t => { const path=join(await fixture(t),"usage.json"); - const ctx={env:{LOOPX_USAGE_PING_ENDPOINT:"http://127.0.0.1:1/v1/ping"},version:"1.0.0",python:"3.13",channel:"source",now:new Date(at)}; + const ctx={env:{LOOPX_USAGE_PING:"1",LOOPX_USAGE_PING_ENDPOINT:"http://127.0.0.1:1/v1/ping"},version:"1.0.0",python:"3.13",channel:"source",now:new Date(at)}; await configure(path,ctx,"enable"); const generation=JSON.parse(await readFile(path,"utf8")).generation; const input=cycle("start",at,{codex:{path:"/nonexistent/private.jsonl",id:"thread"}}); await observe(path,{...ctx,env:{...ctx.env,DO_NOT_TRACK:"1"}},generation,null,async()=>{throw new Error("must not send");},undefined,input); @@ -269,7 +269,7 @@ test("unbound sequential cycles restart but exact replay keeps its original Host test("scope expansion renews disclosure and fences old observations without undoing disable", async t => { const path=join(await fixture(t),"usage.json"); - const ctx={env:{LOOPX_USAGE_PING_ENDPOINT:"http://127.0.0.1:1/v1/ping"},version:"1.0.0",python:"3.13",channel:"source",now:new Date(at)}; + const ctx={env:{LOOPX_USAGE_PING:"1",LOOPX_USAGE_PING_ENDPOINT:"http://127.0.0.1:1/v1/ping"},version:"1.0.0",python:"3.13",channel:"source",now:new Date(at)}; await configure(path,ctx,"enable"); const prior=JSON.parse(await readFile(path,"utf8")); prior.notice.version=2; await writeFile(path,JSON.stringify(prior)); diff --git a/tests/test_usage_goal.py b/tests/test_usage_goal.py index e37883d414..24685cc58a 100644 --- a/tests/test_usage_goal.py +++ b/tests/test_usage_goal.py @@ -19,6 +19,7 @@ def usage_transport(tmp_path, monkeypatch): monkeypatch.setattr(usage_ping, "select_default_runtime_root", lambda: tmp_path) for name in ("CI", "DO_NOT_TRACK", "LOOPX_USAGE_PING", "LOOPX_USAGE_POLICY"): monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("LOOPX_USAGE_PING", "1") monkeypatch.setenv("LOOPX_USAGE_PING_ENDPOINT", "http://127.0.0.1:1/v1/ping") popen = subprocess.Popen children = [] diff --git a/tests/test_usage_ping.py b/tests/test_usage_ping.py index 3eec7b54ba..960bb87a3d 100644 --- a/tests/test_usage_ping.py +++ b/tests/test_usage_ping.py @@ -69,6 +69,8 @@ def isolated(tmp_path, monkeypatch): monkeypatch.setattr(usage_ping, 'select_default_runtime_root', lambda: tmp_path) for key in ('CI', 'DO_NOT_TRACK', 'LOOPX_USAGE_PING', 'LOOPX_USAGE_POLICY'): monkeypatch.delenv(key, raising=False) + # Explicit transport qualifications may send only to a numeric loopback collector. + monkeypatch.setenv('LOOPX_USAGE_PING', '1') monkeypatch.setenv('LOOPX_USAGE_PING_ENDPOINT', 'http://127.0.0.1:1/v1/ping') return tmp_path diff --git a/tests/test_usage_temporary_environment.py b/tests/test_usage_temporary_environment.py new file mode 100644 index 0000000000..ca057f6388 --- /dev/null +++ b/tests/test_usage_temporary_environment.py @@ -0,0 +1,156 @@ +"""Direct smoke/CLI entrypoints stay silent after inherited opt-outs are lost.""" +from __future__ import annotations + +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import http.client +import json +import os +from pathlib import Path +import subprocess +import sys +import threading +import time + +import pytest + +from loopx import usage_ping + +ROOT = Path(__file__).resolve().parents[1] + + +@contextmanager +def local_collector(): + requests = [] + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + requests.append((self.path, json.loads(self.rfile.read(int(self.headers["Content-Length"]))))) + self.send_response(204) + self.end_headers() + + def log_message(self, *_args): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + worker = threading.Thread(target=server.serve_forever, daemon=True) + worker.start() + try: + yield f"http://127.0.0.1:{server.server_port}/v1/ping", requests + finally: + server.shutdown() + server.server_close() + worker.join() + + +def rebuilt_environment(home: Path, endpoint: str) -> dict[str, str]: + # Model an allowlisted subprocess environment: no pytest, canary, CI or + # inherited telemetry flag. Every recipient is strictly numeric loopback. + env = {key: os.environ[key] for key in ("PATH", "SystemRoot", "SYSTEMROOT", "WINDIR", "PATHEXT", + "LOCALAPPDATA", "TMPDIR", "TEMP", "TMP") if key in os.environ} + return {**env, "HOME": str(home), "USERPROFILE": str(home), + "LOOPX_USAGE_PING_ENDPOINT": endpoint} + + +def cli(env: dict[str, str], *args: str) -> dict: + result = subprocess.run([sys.executable, "-m", "loopx.cli", "--format", "json", *args], + cwd=ROOT, env=env, capture_output=True, text=True, timeout=20, check=True) + return json.loads(result.stdout) + + +@pytest.mark.parametrize("entrypoint", ["direct_cli", "standalone_smoke_helper"]) +def test_unmarked_temporary_home_does_not_create_an_id_or_contact_any_channel(tmp_path, entrypoint): + home = tmp_path / "home" + home.mkdir() + with local_collector() as (endpoint, requests): + env = rebuilt_environment(home, endpoint) + if entrypoint == "direct_cli": + for _ in range(4): + assert cli(env, "version")["ok"] + else: + # The same standalone helper that previously inherited collection + # must force it off, including a caller's explicit enable value. + script = """ +import json,runpy +helper = runpy.run_path('examples/project/connect-route-collision-guard-smoke.py')['run_cli'] +for _ in range(4): + result = helper(None, 'version', env={'LOOPX_USAGE_PING':'1'}) + assert json.loads(result.stdout)['ok'] +""" + subprocess.run([sys.executable, "-c", script], cwd=ROOT, env=env, + text=True, capture_output=True, timeout=60, check=True) + status = cli(env, "usage-ping", "status") + assert status["blocked_by"] == "temporary_path" + assert not status["automatic_notice_required"] + assert not (home / ".loopx" / "usage-ping.json").exists() + assert not (home / ".codex" / "loopx" / "usage-ping.json").exists() + assert requests == [] + + +def test_rebuilt_sender_environment_cannot_send_any_previously_enabled_channel(tmp_path): + home = tmp_path / "home" + home.mkdir() + with local_collector() as (endpoint, requests): + env = rebuilt_environment(home, endpoint) + status = cli(env, "usage-ping", "enable") + assert status["consent"] == "enabled" and status["blocked_by"] == "temporary_path" + path = next(home.rglob("usage-ping.json")) + before = path.read_bytes() + generation = json.loads(before)["generation"] + facts = {"version": "1.2.3", "python": "3.11", "channel": "source"} + now = time.time_ns() // 1_000_000 + observations = [ + {"action": "start"}, + {"action": "observe", "feature": "version", "elapsed_ms": 1, "outcome": "ok", "error": "none"}, + {"action": "observe", "feature": "version", "elapsed_ms": 1, "exit_code": 0}, + {"action": "goal", "observation": {"key": "a" * 64, "start": now - 60000, + "end": now, "measurement": "host_call", "host": "unknown"}}, + {"action": "cycle", "observation": {"key": "b" * 64, "lane": "c" * 64, + "turn": None, "phase": "start", "at": now, "host": "unknown"}}, + ] + for observation in observations: + result = subprocess.run(usage_ping._command(), input=json.dumps({"path": str(path), "facts": facts, + "generation": generation, **observation}), cwd=ROOT, env=env, + text=True, capture_output=True, timeout=15, check=True) + assert json.loads(result.stdout) == {"sent": False, "reason": "blocked"} + assert path.read_bytes() == before + assert not Path(str(path) + ".installation").exists() + assert requests == [] + + +def test_real_chat_settings_read_back_temporary_suppression_and_explicit_disable(tmp_path, monkeypatch): + from loopx.chat_server import ChatHTTPServer, ChatRequestHandler + monkeypatch.setattr(usage_ping, "select_default_runtime_root", lambda: tmp_path) + for key in ("CI", "DO_NOT_TRACK", "LOOPX_USAGE_PING"): + monkeypatch.delenv(key, raising=False) + with local_collector() as (endpoint, requests): + monkeypatch.setenv("LOOPX_USAGE_PING_ENDPOINT", endpoint) + server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + server.verbose = False + worker = threading.Thread(target=server.serve_forever, daemon=True) + worker.start() + connection = http.client.HTTPConnection(*server.server_address, timeout=8) + route = "/api/chat/usage-statistics" + def request(method, body=None): + connection.request(method, route, json.dumps(body) if body else None, + {"Content-Type": "application/json"}) + response = connection.getresponse() + assert response.status == 200 + return json.loads(response.read()) + try: + initial = request("GET") + assert initial["blocked_by"] == "temporary_path" and not initial["automatic_notice_required"] + request("POST", {"notice": initial["notice"]}) + assert not usage_ping.state_path().exists() + enabled = request("POST", {"enabled": True}) + assert enabled["consent"] == "enabled" and not enabled["sending"] + assert enabled["blocked_by"] == "temporary_path" + disabled = request("POST", {"enabled": False}) + assert disabled["blocked_by"] == "disabled" and disabled["next_payload"] is None + assert request("GET")["consent"] == "disabled" + assert requests == [] + finally: + connection.close() + server.shutdown() + server.server_close() + worker.join() From cddb47e88bb1319319a8290d36b28b55832fc29d Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Fri, 6 Nov 2026 12:41:35 +0800 Subject: [PATCH 2/2] docs(telemetry): define standalone smoke and temporary route isolation Signed-off-by: huangruiteng --- docs/development/good-smokes.md | 13 +++++++++ docs/reference/usage-ping.md | 27 +++++++++++++++++-- docs/reference/usage-ping.zh-CN.md | 20 +++++++++++++- .../references/repair-patterns.md | 2 +- 4 files changed, 58 insertions(+), 4 deletions(-) diff --git a/docs/development/good-smokes.md b/docs/development/good-smokes.md index 8614c97928..7c9676753a 100644 --- a/docs/development/good-smokes.md +++ b/docs/development/good-smokes.md @@ -161,6 +161,19 @@ provider behavior. 不要把 stdout/stderr tail 提交进 fixture。Fake provider 可以验证序列化、脱敏和 fail-closed,但不能证明真实 provider 行为合格。 +Standalone smoke subprocesses must force `LOOPX_USAGE_PING=0` after merging +caller overrides, including environments rebuilt from an allowlist. Temporary +HOME/state paths are also fenced by the [shared telemetry owner](../reference/usage-ping.md#disclosure-and-precedence). +For custom scratch roots, retain the declared temp root or persist an explicit +disabled setting before removing environment markers. Telemetry-specific tests +opt in only to numeric loopback collectors and qualify every channel with a +zero-request negative case and a sending positive control. + +独立 smoke 在合并调用方环境后必须强制关闭遥测,重建最小环境也不例外。 +共用 owner 额外防护临时 HOME/状态路径;自定义 scratch 应保留声明的临时根, +或先持久关闭再移除环境标记。遥测专项测试只向数字回环地址显式开启, +用全通道零请求反例和实际发送正例共同验收。 + Follow [Public/private boundaries](../public-private-boundary.md) whenever a smoke touches benchmark, host, agent, or external-system data. diff --git a/docs/reference/usage-ping.md b/docs/reference/usage-ping.md index 78d0866bda..21710793cb 100644 --- a/docs/reference/usage-ping.md +++ b/docs/reference/usage-ping.md @@ -236,8 +236,31 @@ Pytest and the canary smoke runner also disable collection for local validation. Native Codex benchmark profiles force the same opt-out during installation, runtime and Agent shell execution, even when a minimal environment removes `CI` or the parent requests collection. Release qualification uses the same boundary. -These synthetic runs must not count as reporting installations. Telemetry tests -may explicitly enable collection only against a disposable local collector. +These synthetic runs must not count as reporting installations. + +The shared sender also suppresses all channels when the machine home, telemetry +state, host home/configuration, or loaded runtime lives under a recognized +temporary root. This covers OS temporary directories (including macOS aliases +and Windows Temp), absolute `TMPDIR`/`TEMP`/`TMP` roots, and symlinks into them, +including a state file that does not exist yet. Status reports `temporary_path`; +automatic disclosure creates no installation ID. Copied/enabled state cannot +override this boundary or consume buffered counts. A temporary **working +directory alone** is not a temporary installation: detached senders themselves +work in the OS temp directory. Path checks remain local and upload no paths. + +For a custom scratch location outside those roots, declare its temporary root +when launching the process or persist `usage-ping disable` in that fixture's +machine state before rebuilding its environment. Directory names such as +`test` or `scratch` are not evidence of ephemerality. Standalone smoke helpers +must still force `LOOPX_USAGE_PING=0` after caller environment overrides; path +eligibility is defense in depth when an opt-out flag is lost, not a replacement +for test isolation. No historical installation IDs are relabeled or deleted. + +Telemetry transport qualifications use both `LOOPX_USAGE_PING=1` and an explicit +numeric loopback collector (`127.0.0.1` or `::1`) to exercise sends from disposable +state. This exception never permits remote collection and cannot override CI, +Do Not Track, stored disable or consent-required policy. Ordinary persistent +installations retain notice-before-default-on behavior. `LOOPX_USAGE_POLICY=consent_required` requires explicit enable; merely displaying the notice is insufficient. Default policy is `opt_out`; unknown policies fail diff --git a/docs/reference/usage-ping.zh-CN.md b/docs/reference/usage-ping.zh-CN.md index 76b55fd620..b470c43fab 100644 --- a/docs/reference/usage-ping.zh-CN.md +++ b/docs/reference/usage-ping.zh-CN.md @@ -180,7 +180,25 @@ App 不再要求首次点击启用。环境变量覆盖和 `consent_required` canary smoke 执行器也在本地验证时关闭采集。Native Codex 评测 profile 的安装、 运行和 Agent 工具 shell 都强制关闭,即使最小环境丢掉了 `CI` 或父进程要求开启, 也不会恢复采集。发布资格验证同样关闭。这些合成运行不能计作真实使用安装。 -遥测专项测试只能针对可丢弃的本地收集器显式开启。 + +共用发送 owner 还会检查机器 HOME、遥测状态、Host HOME/配置目录与加载的运行时 +是否位于已知临时根下,统一阻止所有通道。覆盖系统临时目录(含 macOS 别名、 +Windows Temp)、绝对路径的 `TMPDIR`/`TEMP`/`TMP`,以及指向这些目录的符号链接; +状态文件尚不存在也生效。状态返回 `temporary_path`;自动告知不创建安装 ID, +复制来的“已开启”状态也不能绕过,待发送计数不被消耗。仅工作目录位于临时区 +不算临时安装:后台发送进程本身就在系统临时目录工作。路径检查只在本机进行, +不上传路径。 + +自定义 scratch 若不在上述根下,启动时应声明临时根,或先在该 fixture 的机器 +状态中执行 `usage-ping disable`,再重建环境。不能凭 `test`、`scratch` 等目录名 +推断临时性。独立 smoke helper 仍须在合并调用方环境后强制设置 +`LOOPX_USAGE_PING=0`;路径防护用于兜住关闭标记丢失,不替代测试隔离。 +本次不重新标记或删除历史安装 ID。 + +遥测传输专项测试必须同时设置 `LOOPX_USAGE_PING=1` 和明确的数字回环收集地址 +(`127.0.0.1` 或 `::1`),才能从可丢弃状态发送。这一例外不能向远端发送, +也不能绕过 CI、请勿追踪、持久关闭或需要明确同意的策略。普通持久安装仍沿用 +“先告知、后默认开启”的规则。 `LOOPX_USAGE_POLICY=consent_required` 要求明确开启,单纯显示告知不够。 默认策略为 `opt_out`,未知值拒绝发送。发行方必须按实际适用要求选择策略; diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index bd46fbdec4..7a06e9580b 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -47,7 +47,7 @@ teaches a reusable control-plane lesson. | `notification_receipt_scope_and_frontier_gap` | A recovered blocker never notifies again, later notices starve, or a new channel claims delivery from an old receipt. | Canonical blocked/open/blocked transitions, full candidate frontier, destination-bound private receipts, and public readback. | Receipt lookup omitted destination/generation, recovery used an incomplete status list, or the batch limit was applied before delivered effects were removed. | Reuse canonical status and notice builders; retire only explicit recovery or supersession facts, preserve missing-row uncertainty, bind receipts to destination and durable generation, and budget pending effects with fair retries. Validate repeated same-clock recovery, more than one batch, persistent failures, target switches and default-off parity; keep historical receipts private. | | `local_state_physical_route_guard_fragmentation` | Migration reviews repeatedly find a new symlink or Windows junction route after earlier target/backup fixes passed. | Every source, destination, backup, receipt, and rollback read/copy/rename path; the shared redirect predicate; preview and near-effect negative cases on the native OS. | Individual phases used `is_symlink()` or existence checks instead of one physical-route rule, so green happy-path receipts hid reads or moves outside the declared tree. | Reuse one symlink/junction/reparse classifier at each I/O boundary, recheck immediately before effects, and fail closed with the backup retained. Cover source, target, nested runtime content, backup snapshots and rollback using synthetic substitution plus native platform negatives; keep active user state untouched. | | `directory_move_nested_destination` | A directory archive or cleanup preview reports success, but execution copies the source recursively into itself, fails with a path-too-long error, returns an archive path that disappears with the source, or leaves nested partial state. | Normalized lexical and resolved source and destination paths, source aliases, preview output, directory contents after failure, and the final move call. | A caller-controlled archive root placed the lexical or resolved destination inside the directory being moved, and preview did not share an effect-time containment check. | Reject a destination whose resolved path is inside the resolved source or whose lexical ancestor resolves to the source, and recheck immediately before the move. Cover source-as-root, direct-child, deeper-child, source-child symlink, and aliased-source destinations in preview and execute modes; prove rejection creates no directories and preserves source bytes. | -| `synthetic_profile_telemetry_leak` | Reporting installation IDs rise during tests, isolated installs or benchmark preparation. | Synthetic state with send receipts, collector matches when authorized, child environment builders and Agent shell policy. | Environment allowlists drop CI and telemetry opt-outs while fresh homes generate new IDs. | Force collection off at synthetic install/runtime/tool-shell boundaries and test/smoke entrypoints; exercise real child CLI and typed sender against a local collector with zero-request assertions. Keep normal user collection unchanged; do not infer or delete historical test samples from platform/channel heuristics. | +| `synthetic_profile_telemetry_leak` | Reporting installation IDs rise during tests, isolated installs or benchmark preparation. | Synthetic state with send receipts, collector matches when authorized, child environment builders and Agent shell policy. | Environment allowlists drop CI and telemetry opt-outs while fresh homes generate new IDs. | Force collection off after synthetic environment overrides. The shared TS sender also rejects home/state/runtime paths under OS or declared temp roots, resolving aliases before first ID creation and every channel send. For unrecognized scratch roots, retain the declared root or persist disable before rebuilding the environment. Qualify real child CLI and every typed channel against numeric loopback with zero-request negatives and explicit positive controls. Preserve persistent-user defaults; do not infer or delete historical test samples. | | `authority_cold_read_starves_runtime` | Unrelated pure Todo rules and ping time out while warm reads are fast. | Same-byte isolated cold/warm/alternating-Goal probes, original response budget, CPU profile and exact provider revision. | Synchronous retained-history verification monopolizes the shared event loop; allocation-heavy canonical key sorting amplifies it. | Preserve byte-level proof semantics, reduce codec allocations, yield between complete transaction proofs and share only identical in-flight proofs. Test real socket concurrency and late-history corruption; do not raise timeouts, weaken integrity or replay ambiguous writes. | | `native_todo_status_index_schema_gap` | A Goal shows “status load failed / invalid response” after native Todos appear, while the scoped status endpoint returns valid JSON. | Exact scoped status response, Zod issue paths, Todo `todo_id` and `index` fields, native presentation contract, packaged Goal load. | The dashboard still requires every Todo to have a numeric source index, but native Todos deliberately use stable `todo_id` with absent or null index. One row rejects the entire Goal snapshot. | Accept nullable/absent index only when a nonempty stable Todo ID exists; keep numeric legacy indexes and reject anonymous or malformed rows. Render and act by Todo ID, then prove a mixed native/legacy scoped snapshot loads in the packaged UI. | | `capability_catalog_editor_kind_drift` | Machine or Goal settings report an empty capability list even though the configuration API returns registered capabilities. | Live API catalog IDs and editor kinds, the dashboard's accepted field-kind schema, and the page's load-error state. | One new descriptor emits an unsupported field kind; strict validation rejects the shared catalog and the machine page presents the failed load as an empty registry. | Keep the published editor vocabulary aligned with the browser contract, check every built-in descriptor together, and show a retryable error when catalog loading or validation fails. Only a successfully loaded empty catalog may show the empty state. |