From 90e9162014aa9f635619392443a04c784bf16452 Mon Sep 17 00:00:00 2001 From: oratis Date: Fri, 21 Aug 2026 12:11:01 +0800 Subject: [PATCH 01/15] fix(cloud): deny /api/consent routes at the hosted boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit consent state is stored per-machine, not per-tenant: src/consent/store.ts resolves ~/.lisa/consent.json with its own lisaHome() instead of going through the per-user home scope in src/paths.ts. the cloud deny-list in src/web/capabilities.ts covered every other host-control route but missed /api/consent, so in the hosted edition any signed-in tenant could: - GET /api/consent read every tenant's grant list + timestamps - POST /api/consent/grant turn on a signal (incl. "mail") for everyone - POST /api/consent/revoke-all switch consent off for the whole deployment, which stops the mail digest sweep for all tenants (server.ts gates it on isGranted) add the prefix to CLOUD_DENIED_ROUTE_PREFIXES. the existing matcher already handles both the bare root and sub-paths, and does not over-match sibling routes like /api/plans-public. local edition is unaffected — the deny gate only runs when edition is cloud. does not fix the underlying scoping bug in src/consent/store.ts; that needs a decision about per-tenant consent and is filed separately. --- src/web/capabilities.test.ts | 16 ++++++++++++++++ src/web/capabilities.ts | 7 +++++++ 2 files changed, 23 insertions(+) diff --git a/src/web/capabilities.test.ts b/src/web/capabilities.test.ts index 41e586d..a128ecb 100644 --- a/src/web/capabilities.test.ts +++ b/src/web/capabilities.test.ts @@ -48,6 +48,22 @@ describe("cloud route capability boundary", () => { } }); + test("denies consent routes — consent state is per-machine, not per-tenant", () => { + // src/consent/store.ts writes a single ~/.lisa/consent.json outside the + // per-user home scope, so in the hosted edition these routes would be + // cross-tenant: read another tenant's grants, grant "mail" deployment-wide, + // or revoke-all and kill the mail digest for everyone. + for (const route of [ + "/api/consent", + "/api/consent?x=1", + "/api/consent/grant", + "/api/consent/revoke", + "/api/consent/revoke-all", + ]) { + assert.equal(isCloudDeniedRoute(route), true, `${route} must be denied`); + } + }); + test("keeps tenant data, auth, billing, chat, and bounded KB routes available", () => { for (const route of [ "/api/auth/me", diff --git a/src/web/capabilities.ts b/src/web/capabilities.ts index 633bd08..5e14074 100644 --- a/src/web/capabilities.ts +++ b/src/web/capabilities.ts @@ -26,6 +26,13 @@ const CLOUD_DENIED_ROUTE_PREFIXES = [ "/api/advisor/", "/api/claude/", "/api/config/", + // Consent state is stored per-machine, not per-tenant (src/consent/store.ts + // resolves ~/.lisa/consent.json directly instead of going through the + // per-user home scope in src/paths.ts). Leaving these routes open in the + // hosted edition let any signed-in tenant read every tenant's grant list and + // overwrite it — including a one-request /api/consent/revoke-all that + // switches the mail digest off for the whole deployment. + "/api/consent/", "/api/control/", "/api/devices/", "/api/dispatch/", From eb92134477c6aba1fac2a5ce88e11ca7a1c3dc98 Mon Sep 17 00:00:00 2001 From: oratis Date: Fri, 21 Aug 2026 12:12:26 +0800 Subject: [PATCH 02/15] fix(soul): keep the device fingerprint out of the birth prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BEHAVIOR CHANGE — see CONTRIBUTING: touching src/soul/* wants a discussion issue first. this needs a maintainer decision before merge. seed.bornOn is sha256(hostname + username): unsalted, over two low-entropy and often-guessable strings, and stable across rebirths on the same machine. that makes it a device fingerprint an adversary can confirm offline from a candidate (hostname, username) pair. dreamSoul() stringified the WHOLE seed into the birth prompt, so the fingerprint was sent to whichever model provider is configured (Anthropic / OpenAI / Gemini / a local endpoint) on every birth. it contributes nothing to the dream — the personality comes from randomness + bigFive — so this was pure unnecessary egress. add seedForPrompt(), which drops bornOn and nothing else, and use it in the one place the seed is serialized for the provider. writeSeed() still records the full seed on disk, so nothing about the stored soul changes. the behavior change: the birth prompt text is now shorter by one field, so a NEW birth will dream a slightly different soul than it would have before. already-born souls are untouched (birth refuses to re-run). what this does NOT fix: GET /api/soul still serves the full seed, bornOn included, to authenticated clients (the iOS companion reads it). narrowing or removing that is a separate decision — noted in the type comment. document the invariant on SoulSeed.bornOn so the next person does not re-add it, and pin the redaction with a regression test. --- src/soul/birth.test.ts | 39 ++++++++++++++++++++++++++++++++++++++- src/soul/birth.ts | 20 +++++++++++++++++++- src/soul/types.ts | 17 ++++++++++++++++- 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/soul/birth.test.ts b/src/soul/birth.test.ts index 8fb46b4..e5330d2 100644 --- a/src/soul/birth.test.ts +++ b/src/soul/birth.test.ts @@ -8,7 +8,7 @@ const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "lisa-birth-")); process.env.LISA_HOME = TMP; process.env.LISA_SOUL_GIT = "0"; // keep tests fast; git no-op path is itself S3 behavior -const { birth, BirthInferenceError } = await import("./birth.js"); +const { birth, BirthInferenceError, seedForPrompt } = await import("./birth.js"); const { isBorn } = await import("./store.js"); const { soulSeedFile, soulNameFile } = await import("./paths.js"); import type { BirthOutput } from "./birth.js"; @@ -142,3 +142,40 @@ describe("birth transactionality (S3)", () => { ); }); }); + +describe("birth prompt does not carry the device fingerprint", () => { + const seed = { + bornAt: "2026-08-21T00:00:00.000Z", + bornOn: "b0b0b0b0deadbeefcafef00d1234567890abcdef1234567890abcdef12345678", + randomness: "a".repeat(64), + bigFive: { + openness: 0.5, + conscientiousness: 0.5, + extraversion: 0.5, + agreeableness: 0.5, + neuroticism: 0.5, + }, + }; + + test("seedForPrompt strips bornOn and keeps what the dream actually needs", () => { + const forPrompt = seedForPrompt(seed); + assert.equal("bornOn" in forPrompt, false, "bornOn must not reach the provider"); + assert.equal(forPrompt.bornAt, seed.bornAt); + assert.equal(forPrompt.randomness, seed.randomness); + assert.deepEqual(forPrompt.bigFive, seed.bigFive); + }); + + test("the serialized prompt payload contains no trace of the hash", () => { + // This is the actual wire shape: dreamSoul() JSON-stringifies the result of + // seedForPrompt() into the user message sent to the model provider. + const payload = JSON.stringify(seedForPrompt(seed), null, 2); + assert.equal(payload.includes(seed.bornOn), false); + assert.equal(payload.includes("bornOn"), false); + }); + + test("seedForPrompt does not mutate the seed that gets written to disk", () => { + const copy = { ...seed }; + seedForPrompt(copy); + assert.equal(copy.bornOn, seed.bornOn); + }); +}); diff --git a/src/soul/birth.ts b/src/soul/birth.ts index 7135536..f221d42 100644 --- a/src/soul/birth.ts +++ b/src/soul/birth.ts @@ -227,6 +227,24 @@ async function birthInner(opts: BirthOptions): Promise { } } +/** + * The seed as the LLM sees it — `bornOn` deliberately removed. + * + * `bornOn` is sha256(hostname + username): a stable, low-entropy device + * fingerprint that an adversary holding a candidate (hostname, username) pair + * can confirm offline with a single hash. It contributes nothing to the dream — + * the personality is derived from `randomness` and `bigFive` — so there is no + * reason to hand a machine identifier to a third-party model provider on every + * birth. The full seed (bornOn included) is still written to disk by + * writeSeed(); this only narrows what crosses the wire. + * + * Exported for the regression test in birth.test.ts. + */ +export function seedForPrompt(seed: SoulSeed): Omit { + const { bornOn: _bornOn, ...rest } = seed; + return rest; +} + /** One LLM turn → parsed birth output. Separated so the caller can retry. */ async function dreamSoul( provider: ReturnType, @@ -244,7 +262,7 @@ async function dreamSoul( { type: "text", text: - `Seed:\n${JSON.stringify(seed, null, 2)}\n\nBirth yourself. Output JSON only.`, + `Seed:\n${JSON.stringify(seedForPrompt(seed), null, 2)}\n\nBirth yourself. Output JSON only.`, }, ], }, diff --git a/src/soul/types.ts b/src/soul/types.ts index 1e9f660..ab465cb 100644 --- a/src/soul/types.ts +++ b/src/soul/types.ts @@ -20,7 +20,22 @@ export interface SoulSeed { bornAt: string; // ISO 8601 - bornOn: string; // hashed hostname + /** + * sha256(hostname + username) — see generateSeed() in birth.ts. + * + * NOT an anonymous identifier. It is an unsalted hash over two low-entropy, + * often-guessable strings ("'s MacBook Pro" + a first name), so + * anyone holding a candidate pair confirms a match with a single hash. It is + * stable across rebirths on the same machine, which makes it a device + * fingerprint and a linkable identifier. + * + * Keep it local. It is deliberately stripped from the birth prompt + * (seedForPrompt() in birth.ts) so it is never handed to a model provider; + * do not add it to prompts, telemetry, or any LISA-operated endpoint. Note + * it IS still part of the seed served by GET /api/soul to authenticated + * clients (the iOS companion reads that endpoint). + */ + bornOn: string; randomness: string; // hex bigFive: BigFiveSeed; // initial personality leanings } From bcf6ab366ec9214d8f403bd63ca5eea9fc0edf04 Mon Sep 17 00:00:00 2001 From: oratis Date: Fri, 21 Aug 2026 12:20:41 +0800 Subject: [PATCH 03/15] fix(dispatch): capture exit status, and stop trusting a bare pid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit two bugs in the dispatch ledger. they share DispatchEntry and the same two files, so they land together. 1. no exit code was ever captured. launchAgent() spawned detached and only ever attached an "error" listener for the first 150ms, which catches ENOENT-class launch failures and nothing about the run. dispatch_status then rendered any dead pid as "✓ finished", so an agent that exited 1, died on a missing API key, or was OOM-killed looked exactly like a clean run. attach a "close" listener and record (exitCode, exitSignal, exitedAt). the listener has to go on BEFORE the 150ms launch race — an agent that exits inside that window emits "close" first, and a listener attached afterwards never fires — so the result is stashed and written once the ledger row exists. both orderings are covered by tests. BEHAVIOR CHANGE in dispatch_status output. the label is now derived from what was actually observed: ▶ running / ✓ exit 0 / ✗ exit N / ✗ killed by SIG… / • exited (status not captured) the last one is the honest case for a dispatch that outlived LISA's own process: because the child is detached + unref'd, the listener only fires while LISA is alive (true for `lisa serve` and a live REPL, false for a one-shot CLI invocation). we say so instead of showing a checkmark. "✓ finished" is gone. 2. pid reuse could make signal_agent kill an unrelated process group. entries are retained 24h and matched by bare pid; isAlive() was just kill(pid, 0), so a recycled pid answers "alive". signal_agent then sends SIGTERM/SIGKILL to -pid — a whole process group the user owns. that directly contradicts the invariant in its own header ("LISA cannot kill an arbitrary process"). record a start-time fingerprint at dispatch (/proc//stat field 22 on linux, `ps -o lstart=` elsewhere) and require it to match before reporting alive or delivering a signal. pid + start time cannot be reused, since a recycled pid necessarily started later. a null probe means "cannot tell" and is treated as a match, and entries without a token (older ledger files) keep the old pid-only behavior, so nothing silently disappears. adds entryIsAlive(e) and moves every ledger call site onto it, including the two in web/server.ts, so the HTTP view is guarded too. DispatchView is deliberately unchanged — exposing exit status over /api/dispatch would change the generated API contract and is a separate call. --- src/integrations/dispatch-ledger.test.ts | 91 +++++++++++++++++++ src/integrations/dispatch-ledger.ts | 110 +++++++++++++++++++++-- src/tools/dispatch_agent.test.ts | 68 ++++++++++++++ src/tools/dispatch_agent.ts | 37 +++++++- src/tools/dispatch_status.test.ts | 54 +++++++++-- src/tools/dispatch_status.ts | 25 +++++- src/tools/signal_agent.ts | 6 +- src/web/server.ts | 6 +- 8 files changed, 374 insertions(+), 23 deletions(-) diff --git a/src/integrations/dispatch-ledger.test.ts b/src/integrations/dispatch-ledger.test.ts index 3a7ea29..529d0d5 100644 --- a/src/integrations/dispatch-ledger.test.ts +++ b/src/integrations/dispatch-ledger.test.ts @@ -18,6 +18,9 @@ const { findDispatch, removeDispatch, isAlive, + entryIsAlive, + processStartToken, + recordExit, toDispatchView, } = await import("./dispatch-ledger.js"); @@ -147,3 +150,91 @@ describe("toDispatchView", () => { assert.equal("logPath" in view, false); }); }); + +describe("pid reuse guard (start-time fingerprint)", () => { + test("a live pid has a readable start token", () => { + const tok = processStartToken(process.pid); + assert.equal(typeof tok, "string"); + assert.ok((tok as string).length > 0); + }); + + test("the token is stable across reads for the same process", () => { + assert.equal(processStartToken(process.pid), processStartToken(process.pid)); + }); + + test("no token for a dead pid or pid <= 1", () => { + assert.equal(processStartToken(DEAD_PID), null); + assert.equal(processStartToken(1), null); + assert.equal(processStartToken(0), null); + }); + + test("a live pid whose start token does NOT match is reported dead", () => { + // This is the pid-reuse case: the pid exists, but it is a different + // process than the one we dispatched. Without this, signal_agent would + // SIGTERM/SIGKILL the whole process group of an unrelated process. + assert.equal(isAlive(process.pid), true); + assert.equal(isAlive(process.pid, "ps:not-the-process-we-launched"), false); + }); + + test("a matching token still reports alive", () => { + const tok = processStartToken(process.pid) as string; + assert.equal(isAlive(process.pid, tok), true); + }); + + test("recordDispatch captures the token, and entryIsAlive honours it", () => { + const e = recordDispatch({ agent: "claude", pid: process.pid, cwd: "/a", task: "t" }); + assert.equal(typeof e.startToken, "string"); + assert.equal(entryIsAlive(e), true); + assert.equal(entryIsAlive({ ...e, startToken: "ps:someone-else" }), false); + }); + + test("entries without a token keep the old pid-only behavior", () => { + // Ledger files written before this field existed must not vanish. + const e = recordDispatch({ + agent: "codex", + pid: process.pid, + cwd: "/a", + task: "t", + startToken: null, + }); + assert.equal("startToken" in e, false); + assert.equal(entryIsAlive(e), true); + }); +}); + +describe("recordExit", () => { + test("records a clean exit", () => { + const e = recordDispatch({ agent: "claude", pid: DEAD_PID, cwd: "/a", task: "t", now: 5 }); + recordExit(e.id, 0, null, 99); + const stored = loadLedger().find((x) => x.id === e.id); + assert.equal(stored?.exitCode, 0); + assert.equal(stored?.exitSignal, null); + assert.equal(stored?.exitedAt, 99); + }); + + test("records a nonzero exit — the crash case F4 was about", () => { + const e = recordDispatch({ agent: "codex", pid: DEAD_PID, cwd: "/a", task: "t", now: 5 }); + recordExit(e.id, 1, null); + assert.equal(loadLedger().find((x) => x.id === e.id)?.exitCode, 1); + }); + + test("records death by signal", () => { + const e = recordDispatch({ agent: "aider", pid: DEAD_PID, cwd: "/a", task: "t", now: 5 }); + recordExit(e.id, null, "SIGKILL"); + const stored = loadLedger().find((x) => x.id === e.id); + assert.equal(stored?.exitCode, null); + assert.equal(stored?.exitSignal, "SIGKILL"); + }); + + test("a fresh entry has no exit status — undefined, not 0", () => { + const e = recordDispatch({ agent: "claude", pid: DEAD_PID, cwd: "/a", task: "t", now: 5 }); + assert.equal(e.exitCode, undefined); + assert.equal(loadLedger().find((x) => x.id === e.id)?.exitCode, undefined); + }); + + test("an unknown id is a silent no-op (entry already aged out)", () => { + recordDispatch({ agent: "claude", pid: DEAD_PID, cwd: "/a", task: "t", now: 5 }); + assert.doesNotThrow(() => recordExit("no-such-id", 0, null)); + assert.equal(loadLedger().length, 1); + }); +}); diff --git a/src/integrations/dispatch-ledger.ts b/src/integrations/dispatch-ledger.ts index a1534d9..594f9bb 100644 --- a/src/integrations/dispatch-ledger.ts +++ b/src/integrations/dispatch-ledger.ts @@ -15,6 +15,7 @@ * LISA started, never an arbitrary user process. */ +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -31,6 +32,24 @@ export interface DispatchEntry { startedAt: number; /** Captured stdout+stderr file for this agent (D1 feedback), if any. */ logPath?: string; + /** + * Kernel start-time fingerprint of the process, captured at dispatch. + * Guards against pid reuse: a recycled pid answers `kill(pid, 0)` exactly + * like the original, so pid alone is not an identity. Absent when the + * platform probe failed, and on entries written before this field existed — + * those fall back to the old pid-only behavior. + */ + startToken?: string; + /** + * Exit status, once observed. `undefined` means we never saw the process + * exit (LISA was not running when it finished) — which is NOT the same as + * "finished successfully", and must not be rendered as success. + */ + exitCode?: number | null; + /** Signal that killed it, when it died by signal (exitCode is null then). */ + exitSignal?: string | null; + /** Epoch ms when the exit was observed. */ + exitedAt?: number; } /** How long a finished dispatch (and its output log) is retained for readback. */ @@ -78,19 +97,73 @@ function saveLedger(entries: DispatchEntry[]): void { fs.writeFileSync(file, JSON.stringify(entries, null, 2)); } +/** + * Kernel start time of a running process, as an opaque comparable string. + * Together with the pid this is a stable process identity: the pair cannot be + * reused, because a recycled pid necessarily started later. + * + * Linux reads field 22 of /proc//stat (starttime, in clock ticks since + * boot). Everything else shells out to `ps -o lstart=`, which POSIX gives us on + * macOS and the BSDs. Returns null if the process is gone or the probe fails — + * callers must treat null as "cannot tell", never as a mismatch. + */ +export function processStartToken(pid: number): string | null { + if (!Number.isInteger(pid) || pid <= 1) return null; + if (process.platform === "linux") { + try { + const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8"); + // comm (field 2) is parenthesized and may contain spaces or ')', so + // split after the LAST ')' — fields 3.. are then whitespace-separated. + const rest = stat.slice(stat.lastIndexOf(")") + 1).trim().split(/\s+/); + const starttime = rest[19]; // field 22 == index 19 of fields 3.. + return starttime ? `lt:${starttime}` : null; + } catch { + return null; + } + } + try { + const res = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { + encoding: "utf8", + timeout: 2000, + }); + if (res.status !== 0 || !res.stdout) return null; + const line = res.stdout.trim(); + return line ? `ps:${line}` : null; + } catch { + return null; + } +} + /** * Is a process still alive? Signal 0 probes for existence without delivering a * signal. EPERM means the process exists but is owned by another user (still * "alive"); ESRCH means it's gone. + * + * `startToken` (when we recorded one at dispatch) additionally guards against + * pid reuse. Without it, a pid recycled by the OS inside the 24h retention + * window reports "alive" and — worse — makes signal_agent deliver SIGTERM / + * SIGKILL to whatever unrelated process group now owns that pid. If the token + * is present and the live process's token differs, this is a different process + * and we report dead. A null probe result means "cannot tell" and is treated + * as a match, preserving the old behavior rather than silently hiding agents. */ -export function isAlive(pid: number): boolean { +export function isAlive(pid: number, startToken?: string): boolean { if (!Number.isInteger(pid) || pid <= 1) return false; try { process.kill(pid, 0); - return true; } catch (err) { - return (err as NodeJS.ErrnoException).code === "EPERM"; + if ((err as NodeJS.ErrnoException).code !== "EPERM") return false; + } + if (startToken) { + const current = processStartToken(pid); + if (current && current !== startToken) return false; // pid was recycled } + return true; +} + +/** isAlive for a ledger entry — always consults the recorded start token. */ +export function entryIsAlive(e: DispatchEntry): boolean { + return isAlive(e.pid, e.startToken); } /** Record a freshly dispatched agent. Returns the stored entry. */ @@ -101,10 +174,13 @@ export function recordDispatch(d: { task: string; /** Captured-output log file for this agent (D1 feedback). */ logPath?: string; + /** Process start-time fingerprint; defaults to probing the live pid. */ + startToken?: string | null; /** Override the clock (tests). */ now?: number; }): DispatchEntry { const startedAt = d.now ?? Date.now(); + const startToken = d.startToken === undefined ? processStartToken(d.pid) : d.startToken; const entry: DispatchEntry = { id: `${d.pid}-${startedAt.toString(36)}`, agent: d.agent, @@ -113,18 +189,40 @@ export function recordDispatch(d: { task: d.task.slice(0, 200), startedAt, ...(d.logPath ? { logPath: d.logPath } : {}), + ...(startToken ? { startToken } : {}), }; // Drop any stale same-pid entry, and age out finished dispatches older than // the retention window so the file (and its logs) don't grow unbounded. const cutoff = startedAt - RETAIN_MS; const entries = loadLedger().filter( - (e) => e.pid !== d.pid && (isAlive(e.pid) || e.startedAt >= cutoff), + (e) => e.pid !== d.pid && (entryIsAlive(e) || e.startedAt >= cutoff), ); entries.push(entry); saveLedger(entries); return entry; } +/** + * Record the observed exit of a dispatched agent. Called from the "close" + * listener in launchAgent while LISA's own process is still alive; a dispatch + * that outlives LISA simply never gets one, and stays exitCode: undefined. + * No-op if the entry is already gone from the ledger. + */ +export function recordExit( + id: string, + code: number | null, + signal: NodeJS.Signals | string | null, + now = Date.now(), +): void { + const entries = loadLedger(); + const entry = entries.find((e) => e.id === id); + if (!entry) return; + entry.exitCode = code; + entry.exitSignal = signal ?? null; + entry.exitedAt = now; + saveLedger(entries); +} + /** * Live dispatched agents. Rewrites the ledger to retain live agents AND * recently-finished ones (so their captured output stays readable via @@ -133,7 +231,7 @@ export function recordDispatch(d: { export function listLiveDispatches(): DispatchEntry[] { const all = loadLedger(); const now = Date.now(); - const keep = all.filter((e) => isAlive(e.pid) || now - e.startedAt < RETAIN_MS); + const keep = all.filter((e) => entryIsAlive(e) || now - e.startedAt < RETAIN_MS); if (keep.length !== all.length) { for (const e of all) { if (!keep.includes(e) && e.logPath) { @@ -146,7 +244,7 @@ export function listLiveDispatches(): DispatchEntry[] { } saveLedger(keep); } - return all.filter((e) => isAlive(e.pid)); + return all.filter((e) => entryIsAlive(e)); } /** All retained dispatches (live + recently-finished). For status / result readback. */ diff --git a/src/tools/dispatch_agent.test.ts b/src/tools/dispatch_agent.test.ts index c3d152d..c87b991 100644 --- a/src/tools/dispatch_agent.test.ts +++ b/src/tools/dispatch_agent.test.ts @@ -1,5 +1,8 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { buildDispatchArgv } from "./dispatch_agent.js"; describe("buildDispatchArgv — headless invocations", () => { @@ -38,3 +41,68 @@ describe("buildDispatchArgv — headless invocations", () => { assert.equal(buildDispatchArgv("codex", multi).args[1], multi); }); }); + +describe("launchAgent captures the exit status (F4)", () => { + // A dispatched agent is spawned detached, so nothing used to observe how it + // ended: a crash and a clean run both rendered as "✓ finished". Here we put + // a fake `claude` on PATH that exits with a known code and assert the ledger + // actually records it. + // + // Both timing branches matter. launchAgent waits ~150ms for a spawn error; + // an agent that exits INSIDE that window emits "close" before the wait + // resolves, so the listener has to be attached before it and the result + // stashed until the ledger row exists. + const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "lisa-launch-")); + const BIN = path.join(TMP, "bin"); + process.env.LISA_HOME = path.join(TMP, "home"); + process.env.PATH = `${BIN}${path.delimiter}${process.env.PATH ?? ""}`; + + function fakeAgent(script: string): void { + fs.mkdirSync(BIN, { recursive: true }); + const p = path.join(BIN, "claude"); + fs.writeFileSync(p, `#!/bin/sh\n${script}\n`); + fs.chmodSync(p, 0o755); + } + + async function waitForExit(id: string, timeoutMs = 8000) { + const { loadLedger } = await import("../integrations/dispatch-ledger.js"); + const deadline = Date.now() + timeoutMs; + for (;;) { + const e = loadLedger().find((x) => x.id === id); + if (e && (typeof e.exitCode === "number" || e.exitSignal)) return e; + if (Date.now() > deadline) return null; + await new Promise((r) => setTimeout(r, 20)); + } + } + + test("an agent that crashes immediately still records its exit code", async () => { + // Exits well inside the 150ms launch race — the regression this guards. + fakeAgent('echo "boom"\nexit 3'); + const { launchAgent } = await import("./dispatch_agent.js"); + const res = await launchAgent("claude", "do a thing", TMP); + assert.equal(res.error, undefined, `launch failed: ${res.error ?? ""}`); + assert.ok(res.id, "expected a ledger id"); + const entry = await waitForExit(res.id as string); + assert.ok(entry, "exit status was never recorded"); + assert.equal(entry?.exitCode, 3); + assert.equal(entry?.exitSignal, null); + assert.equal(typeof entry?.exitedAt, "number"); + }); + + test("an agent that outlives the launch race records its exit code too", async () => { + fakeAgent('sleep 0.4\necho "done"\nexit 0'); + const { launchAgent } = await import("./dispatch_agent.js"); + const res = await launchAgent("claude", "slower thing", TMP); + assert.equal(res.error, undefined, `launch failed: ${res.error ?? ""}`); + const entry = await waitForExit(res.id as string); + assert.ok(entry, "exit status was never recorded"); + assert.equal(entry?.exitCode, 0); + }); + + test("a missing binary is still reported as a launch error, not an exit", async () => { + const { launchAgent } = await import("./dispatch_agent.js"); + const res = await launchAgent("codex", "x", TMP); // no fake `codex` on PATH + assert.match(res.error ?? "", /not found on PATH|Failed to launch/); + assert.equal(res.id, undefined); + }); +}); diff --git a/src/tools/dispatch_agent.ts b/src/tools/dispatch_agent.ts index cfa167c..6ffae16 100644 --- a/src/tools/dispatch_agent.ts +++ b/src/tools/dispatch_agent.ts @@ -28,7 +28,7 @@ import path from "node:path"; import crypto from "node:crypto"; import type { ToolDefinition } from "../types.js"; import { getCurrentHub } from "../integrations/current-hub.js"; -import { recordDispatch, dispatchLogDir } from "../integrations/dispatch-ledger.js"; +import { recordDispatch, recordExit, dispatchLogDir } from "../integrations/dispatch-ledger.js"; interface DispatchInput { agent: "claude" | "codex" | "opencode" | "aider" | "copilot"; @@ -140,6 +140,34 @@ export async function launchAgent( // The child dup'd the fd for its stdio; close our copy. if (outFd !== undefined) try { fs.closeSync(outFd); } catch { /* ignore */ } + // Capture the exit status so dispatch_status can stop claiming a success it + // never observed. This listener MUST be attached now, before the 150 ms + // launch race below — an agent that exits inside that window emits "close" + // before the race resolves, and a listener attached afterwards would never + // fire. The ledger id doesn't exist yet, so stash the result and let + // whichever of the two finishes last do the write. + // + // The child is detached + unref'd, so this only works while LISA's own + // process outlives the agent: true for `lisa serve` and a live REPL, false + // for a one-shot CLI invocation that exits first. In that case the entry + // keeps exitCode: undefined and renders as "status not captured" — never as + // a success. + interface ExitStatus { code: number | null; signal: NodeJS.Signals | null } + const pending: { exit: ExitStatus | null } = { exit: null }; + let ledgerId: string | undefined; + const saveExit = (code: number | null, signal: NodeJS.Signals | null): void => { + if (ledgerId === undefined) return; + try { + recordExit(ledgerId, code, signal); + } catch { + // ledger unwritable — the status just stays unknown + } + }; + child.once("close", (code, signal) => { + pending.exit = { code, signal }; + saveExit(code, signal); + }); + const launchError = await new Promise((resolve) => { let settled = false; child.once("error", (e: NodeJS.ErrnoException) => { @@ -159,7 +187,6 @@ export async function launchAgent( } const pid = child.pid; - child.unref(); let id: string | undefined; if (typeof pid === "number") { try { @@ -168,6 +195,12 @@ export async function launchAgent( log?.(`[dispatch] ledger write failed (non-fatal): ${(err as Error).message}`); } } + + // The ledger row now exists. If the agent already exited during the launch + // race, its status is waiting in `exited` — write it through. + ledgerId = id; + if (pending.exit) saveExit(pending.exit.code, pending.exit.signal); + child.unref(); return { pid, cmd, id, logPath }; } diff --git a/src/tools/dispatch_status.test.ts b/src/tools/dispatch_status.test.ts index 5cc7edd..80f15ba 100644 --- a/src/tools/dispatch_status.test.ts +++ b/src/tools/dispatch_status.test.ts @@ -9,8 +9,10 @@ const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "lisa-dstatus-")); process.env.LISA_HOME = TMP; const LEDGER = path.join(TMP, "dispatches.json"); -const { recordDispatch, dispatchLogDir } = await import("../integrations/dispatch-ledger.js"); -const { dispatchStatusTool } = await import("./dispatch_status.js"); +const { recordDispatch, recordExit, dispatchLogDir } = await import( + "../integrations/dispatch-ledger.js" +); +const { dispatchStatusTool, statusLabel } = await import("./dispatch_status.js"); const CTX = {} as ToolContext; // execute doesn't use ctx const DEAD_PID = 2_000_000_000; @@ -32,20 +34,22 @@ describe("dispatch_status", () => { assert.match(await dispatchStatusTool.execute({}, CTX), /No dispatched agents/); }); - test("distinguishes running vs finished and shows the output tail", async () => { + test("distinguishes running vs exited and shows the output tail", async () => { const now = Date.now(); withLog(process.pid, "still going...", now); // our own pid → alive → running - withLog(DEAD_PID, "FINAL RESULT: done", now); // dead → finished + withLog(DEAD_PID, "FINAL RESULT: done", now); // dead, no exit code on record const out = await dispatchStatusTool.execute({}, CTX); assert.match(out, /▶ running/); - assert.match(out, /✓ finished/); + // A dead pid with no observed exit status must NOT be reported as success. + assert.match(out, /• exited \(status not captured\)/); + assert.doesNotMatch(out, /✓ finished/); assert.match(out, /FINAL RESULT: done/); }); test("by id returns that one with its output", async () => { const e = withLog(DEAD_PID, "the answer is 42", Date.now()); const out = await dispatchStatusTool.execute({ id: e.id }, CTX); - assert.match(out, /✓ finished/); + assert.match(out, /• exited \(status not captured\)/); assert.match(out, /the answer is 42/); }); @@ -53,3 +57,41 @@ describe("dispatch_status", () => { assert.match(await dispatchStatusTool.execute({ id: "nope" }, CTX), /No dispatch found/); }); }); + +describe("dispatch_status labels — success is only claimed when observed", () => { + const base = { id: "1-a", agent: "claude", pid: DEAD_PID, cwd: "/r", task: "t", startedAt: 0 }; + + test("a running agent", () => { + assert.equal(statusLabel({ ...base }, true), "▶ running"); + }); + + test("exit 0 is the only success", () => { + assert.equal(statusLabel({ ...base, exitCode: 0 }, false), "✓ exit 0"); + }); + + test("a nonzero exit is shown as a failure, not a checkmark", () => { + assert.equal(statusLabel({ ...base, exitCode: 1 }, false), "✗ exit 1"); + assert.equal(statusLabel({ ...base, exitCode: 127 }, false), "✗ exit 127"); + }); + + test("death by signal names the signal (OOM-kill, cancel, …)", () => { + assert.equal( + statusLabel({ ...base, exitCode: null, exitSignal: "SIGKILL" }, false), + "✗ killed by SIGKILL", + ); + }); + + test("no observed status is reported as unknown — the F4 regression", () => { + // The agent is spawned detached: if LISA exited first, nothing ever saw the + // exit. Previously this rendered identically to a clean run. + assert.equal(statusLabel({ ...base }, false), "• exited (status not captured)"); + }); + + test("an exited entry that ran long is still judged by its recorded code", async () => { + const e = withLog(DEAD_PID, "boom", Date.now()); + recordExit(e.id, 2, null); + const out = await dispatchStatusTool.execute({ id: e.id }, CTX); + assert.match(out, /✗ exit 2/); + assert.doesNotMatch(out, /status not captured/); + }); +}); diff --git a/src/tools/dispatch_status.ts b/src/tools/dispatch_status.ts index 1c75dab..375705c 100644 --- a/src/tools/dispatch_status.ts +++ b/src/tools/dispatch_status.ts @@ -14,7 +14,7 @@ */ import type { ToolDefinition } from "../types.js"; import { - isAlive, + entryIsAlive, listRecentDispatches, readDispatchOutput, type DispatchEntry, @@ -38,11 +38,30 @@ function indent(text: string): string { .join("\n"); } +/** + * Status label for a dispatch. Only claims success when an exit code was + * actually observed: the process is spawned detached, so a dead pid on its own + * says nothing about whether the agent succeeded, crashed, or was OOM-killed. + * Rendering all three as "✓ finished" is what this replaces. + */ +export function statusLabel(e: DispatchEntry, live: boolean): string { + if (live) return "▶ running"; + if (e.exitSignal) return `✗ killed by ${e.exitSignal}`; + if (typeof e.exitCode === "number") { + return e.exitCode === 0 ? "✓ exit 0" : `✗ exit ${e.exitCode}`; + } + return "• exited (status not captured)"; +} + function render(e: DispatchEntry, now: number, maxBytes: number): string { - const live = isAlive(e.pid); + const live = entryIsAlive(e); const age = Math.max(0, Math.round((now - e.startedAt) / 1000)); + const unknown = !live && e.exitSignal == null && typeof e.exitCode !== "number"; const head = - `${live ? "▶ running" : "✓ finished"} · ${e.agent} (pid ${e.pid}, ${fmtAge(age)} ago) · id ${e.id}\n` + + `${statusLabel(e, live)} · ${e.agent} (pid ${e.pid}, ${fmtAge(age)} ago) · id ${e.id}\n` + + (unknown + ? " note: the agent outlived this LISA process, so its exit status was never seen — judge it from the output below\n" + : "") + ` task: ${e.task.slice(0, 100)}`; const out = readDispatchOutput(e, maxBytes).trim(); if (!out) return `${head}\n (no output captured${e.logPath ? " yet" : ""})`; diff --git a/src/tools/signal_agent.ts b/src/tools/signal_agent.ts index 12ae260..b14488a 100644 --- a/src/tools/signal_agent.ts +++ b/src/tools/signal_agent.ts @@ -24,7 +24,7 @@ import { findDispatch, listLiveDispatches, removeDispatch, - isAlive, + entryIsAlive, type DispatchEntry, } from "../integrations/dispatch-ledger.js"; @@ -138,7 +138,7 @@ export const signalAgentTool: ToolDefinition = { const sig: NodeJS.Signals = input.force ? "SIGKILL" : "SIGTERM"; const delivered = signalGroup(entry.pid, sig); - if (!delivered && !isAlive(entry.pid)) { + if (!delivered && !entryIsAlive(entry)) { // It exited on its own between the lookup and the signal — clean up. removeDispatch(entry.id); return `${entry.agent} (pid ${entry.pid}) had already exited; removed it from the ledger.`; @@ -148,7 +148,7 @@ export const signalAgentTool: ToolDefinition = { if (!input.force) { // Give it a moment to shut down gracefully, then SIGKILL if it clings on. await sleep(1500); - if (isAlive(entry.pid)) { + if (entryIsAlive(entry)) { escalated = signalGroup(entry.pid, "SIGKILL"); } } diff --git a/src/web/server.ts b/src/web/server.ts index 67fe62b..15832a3 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -105,7 +105,7 @@ import { isDigestDue, digestHour } from "../mail/scheduler.js"; import { loadAccounts, addAccount, removeAccount, setAccountEnabled } from "../mail/accounts.js"; import { inferHost } from "../mail/hosts.js"; import type { DailyDigest } from "../mail/types.js"; -import { listRecentDispatches, isAlive, toDispatchView, readDispatchOutput } from "../integrations/dispatch-ledger.js"; +import { listRecentDispatches, entryIsAlive, toDispatchView, readDispatchOutput } from "../integrations/dispatch-ledger.js"; import { loadControlPolicy, saveControlPolicy, type ControlPolicy } from "../control/policy.js"; import { loadAutonomyState, saveAutonomyState, type AutonomyState } from "../autonomy/state.js"; import { mintDevice, verifyDeviceToken, touchDevice, listDevices, revokeDevice } from "./devices.js"; @@ -2737,7 +2737,7 @@ export async function startWebServer(opts: WebServerOptions): Promise toDispatchView(e, isAlive(e.pid))); + const dispatches = listRecentDispatches().map((e) => toDispatchView(e, entryIsAlive(e))); res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ dispatches })); return; @@ -2758,7 +2758,7 @@ export async function startWebServer(opts: WebServerOptions): Promise Date: Fri, 21 Aug 2026 12:23:35 +0800 Subject: [PATCH 04/15] refactor(advisor): drop the repeated_failure category, which nothing emits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `repeated_failure` was declared in SuggestionCategory but no detector ever produced it: the five literals in detectors.ts are stuck, conflict, cost_spike, ready, idle. its supporting state was equally inert — AdvisorState.errorCommandCounts was written once as {} in emptyAdvisorState() and never read or incremented, under a comment promising a "rolling memory of (command → error count)" that never rolled. docs/PRODUCT_REVIEW_v0.9.md already called both out as dead code. delete both. this matters beyond tidiness: it is what makes the README fix in the next commit correct. a maintainer told "the README lists 4 categories and the type has 6" would naturally document all six and thereby promise a card the product cannot produce. the honest number is five. to stop it drifting back, declare the categories as a runtime tuple and derive the type from it, then assert in advisor.test.ts that the declared set and the set the detectors actually emit are equal — in both directions. the reverse direction is the one that catches this bug class. this had to be a runtime check: tsconfig.json excludes src/**/*.test.ts and tsx strips types without checking them, so a type-level pin inside a test file is never evaluated by `npm run typecheck` or `npm test`. verified by re-adding "repeated_failure" and watching the test fail. removing errorCommandCounts is safe for existing ~/.lisa/advisor-state.json files: loadAdvisorState() spreads parsed JSON over emptyAdvisorState(), so a leftover key is simply ignored. --- src/advisor/advisor.test.ts | 67 ++++++++++++++++++++++++++++++++++++- src/advisor/types.ts | 29 ++++++++++------ 2 files changed, 85 insertions(+), 11 deletions(-) diff --git a/src/advisor/advisor.test.ts b/src/advisor/advisor.test.ts index 2d38a05..821d0b5 100644 --- a/src/advisor/advisor.test.ts +++ b/src/advisor/advisor.test.ts @@ -11,7 +11,12 @@ import { COST_SPIKE_TOKENS, } from "./detectors.js"; import { decide, scoreSuggestion, applyDismissal } from "./engine.js"; -import { emptyAdvisorState, type AdvisorInput, type Suggestion } from "./types.js"; +import { + emptyAdvisorState, + SUGGESTION_CATEGORIES, + type AdvisorInput, + type Suggestion, +} from "./types.js"; import type { AgentSession } from "../integrations/types.js"; const NOW = 1_700_000_000_000; @@ -229,3 +234,63 @@ describe("engine — relevance bar + throttle + dedup", () => { assert.ok(after < before, "score drops after dismissals"); }); }); + +describe("suggestion categories match what detectors actually emit", () => { + // tsconfig excludes *.test.ts and tsx does not typecheck, so this has to be + // a runtime check to be worth anything. SUGGESTION_CATEGORIES is the single + // source of truth the type is derived from; these two tests tie it to the + // detectors and to the number the READMEs document. + test("there are exactly five, and they are the five the READMEs list", () => { + assert.deepEqual([...SUGGESTION_CATEGORIES].sort(), [ + "conflict", + "cost_spike", + "idle", + "ready", + "stuck", + ]); + assert.equal(SUGGESTION_CATEGORIES.length, 5); + }); + + test("every category a detector produces is declared, and every declared one is produced", () => { + const produced = new Set(); + const push = (list: Suggestion[]) => list.forEach((s) => produced.add(s.category)); + + // Reuse the exact fixtures the per-detector tests above already prove fire. + push(detectStuck(input([sess({ state: "waiting", stateReason: "idle", lastMtime: NOW - STUCK_MS - 1000 })]))); + push( + detectConflict( + input([ + sess({ agent: "claude-code", sessionId: "a", cwd: "/repo", state: "working" }), + sess({ agent: "codex", sessionId: "b", cwd: "/repo", state: "working" }), + ]), + ), + ); + push( + detectCostSpike( + input([ + sess({ + sessionId: "a", + activity: { + turnCount: 1, + lastTools: [], + filesTouched: [], + tokens: { input: COST_SPIKE_TOKENS, output: 100 }, + }, + }), + ]), + ), + ); + push(detectReady(input([sess({ state: "waiting", stateReason: "end_turn" })]))); + push(detectIdleCapacity(input([], { pendingDesireCount: 2 }))); + + const declared = new Set(SUGGESTION_CATEGORIES); + for (const c of produced) { + assert.ok(declared.has(c), `detector emitted an undeclared category: ${c}`); + } + // The other direction is the one that caught repeated_failure: a declared + // category no detector can ever emit is dead code that misleads the docs. + for (const c of declared) { + assert.ok(produced.has(c), `no detector emits "${c}" — dead category, delete it or implement it`); + } + }); +}); diff --git a/src/advisor/types.ts b/src/advisor/types.ts index e37939d..26e0501 100644 --- a/src/advisor/types.ts +++ b/src/advisor/types.ts @@ -10,13 +10,25 @@ import type { AgentSession } from "../integrations/types.js"; -export type SuggestionCategory = - | "stuck" - | "conflict" - | "repeated_failure" - | "cost_spike" - | "ready" - | "idle"; +/** + * Every category a detector can actually emit. + * + * Declared as a runtime tuple (not a bare type union) so a test can check it + * against what detectors.ts really produces. A union member with no detector + * is a promise the product never keeps, and it leaks into the docs as a + * feature that does not exist — `repeated_failure` sat here unimplemented and + * did exactly that. Note tsconfig excludes *.test.ts, so a type-level pin in a + * test would be inert; this has to be checkable at runtime. + */ +export const SUGGESTION_CATEGORIES = [ + "stuck", + "conflict", + "cost_spike", + "ready", + "idle", +] as const; + +export type SuggestionCategory = (typeof SUGGESTION_CATEGORIES)[number]; /** How loudly a suggestion wants to be surfaced. */ export type Urgency = "info" | "notice" | "urgent"; @@ -56,8 +68,6 @@ export interface AdvisorState { categoryDismissals: Partial>; /** last time ANY non-urgent digest was surfaced (throttle). */ lastDigestAt: number; - /** rolling memory of (command → error count) for repeated-failure detection. */ - errorCommandCounts: Record; } export function emptyAdvisorState(): AdvisorState { @@ -66,7 +76,6 @@ export function emptyAdvisorState(): AdvisorState { dismissals: {}, categoryDismissals: {}, lastDigestAt: 0, - errorCommandCounts: {}, }; } From 0aadd580efd7a40690292bf5e076e381634a365e Mon Sep 17 00:00:00 2001 From: oratis Date: Fri, 21 Aug 2026 12:24:59 +0800 Subject: [PATCH 05/15] docs: correct the observer count (ten, not five) and the advisor categories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit two counts in both READMEs did not match the code. observers. the prose said "all five observers (Claude Code, Codex, OpenCode, Aider, GitHub PRs)" but ten ship: registry.ts imports ten observer modules, hub.ts configures ten keys, and src/integrations/ has ten adapter dirs. the README also contradicted itself — the directory tree 490 lines below already listed seven of them plus an ellipsis, so this was a copy-editing miss, not staleness. say ten, split honestly into the five coding-agent adapters and the five others (git, shell, takoapi, managed, pty), and add the fact the old sentence was probably reaching for: only three are on by default (claude-code, managed, pty) — the other seven are opt-in per integration. the tree line now lists all ten and drops the ellipsis, so the two places agree. advisor categories. the tree said "(stuck / conflict / ready / idle)" — 4 of the 5 the code emits. add cost_spike, which detectors.ts has emitted since COST_SPIKE_TOKENS landed. repeated_failure is deliberately NOT listed: it was dead and is deleted in the previous commit, and documenting a card the product never produces would make the docs more wrong, not less. prose can't be unit-tested, but the facts behind it can: hub.test.ts now pins the roster to exactly those ten names, checks that registerBuiltinIntegrations() really registers one observer per configured key, and pins the three-enabled-by-default split. --- README.md | 6 +++--- README.zh-CN.md | 6 +++--- src/integrations/hub.test.ts | 39 ++++++++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6135d1a..128e38e 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ which owns that auth. See [Coding plans](#coding-plans--use-a-subscription-inste LISA is also a control plane for the *other* coding agents on your machine. Three layers, increasing in how much she touches them: -**1. Observe.** She watches the agents already running and tells you what you'd otherwise miss — a session stuck on the same error, two agents about to collide in one repo, a finished run sitting idle. Honest scope note: **all five observers (Claude Code, Codex, OpenCode, Aider, GitHub PRs) emit structural activity — tools, files touched, last command, errors — gated behind a per-integration `visibility` tier; fidelity varies by what each agent records on disk** (Claude Code is richest; Aider's markdown logs give files + turn counts but no tool stream; every adapter has a privacy test asserting prompts/replies/file-contents never leak). `lisa agents` prints a one-shot snapshot; the island shows it live. She can `dispatch_agent` headlessly (refusing directories another agent owns), `compare_agents` on the same task in parallel worktrees, and surface **advisor cards** — each with a one-click action that prefills the chat (nothing auto-runs) and a ✕ that teaches her to stop nagging about that category. +**1. Observe.** She watches the agents already running and tells you what you'd otherwise miss — a session stuck on the same error, two agents about to collide in one repo, a finished run sitting idle. Honest scope note: **all ten observers — five coding-agent adapters (Claude Code, Codex, OpenCode, Aider, GitHub PRs) plus git, shell, takoapi, managed and pty — emit structural activity — tools, files touched, last command, errors — gated behind a per-integration `visibility` tier; fidelity varies by what each agent records on disk** (Claude Code is richest; Aider's markdown logs give files + turn counts but no tool stream; every adapter has a privacy test asserting prompts/replies/file-contents never leak). Three are enabled by default — Claude Code, managed and pty — and the other seven are opt-in per integration in `~/.lisa/agents.json`. `lisa agents` prints a one-shot snapshot; the island shows it live. She can `dispatch_agent` headlessly (refusing directories another agent owns), `compare_agents` on the same task in parallel worktrees, and surface **advisor cards** — each with a one-click action that prefills the chat (nothing auto-runs) and a ✕ that teaches her to stop nagging about that category. **2. Control her own agents.** A **managed agent** runs LISA's *own* agent loop in a child context she fully drives: delegate a task, approve/deny each mutating tool, send follow-ups, cancel — from the GUI agents card or `POST /api/agents/managed//{send,approve,cancel}`. These are hers, so the model and provider are hers too. @@ -716,9 +716,9 @@ src/ ├── idle/ idle-time autonomous reflection (Reve) ├── autonomy/ run ledger — observable journal of self-driven runs (`lisa autonomy`) ├── agents/ managed agents (LISA's own loop) + PTY agents (drive real claude/codex) -├── integrations/ observers: claude-code · codex · opencode · aider · github-pr · pty · managed · … +├── integrations/ observers (10): claude-code · codex · opencode · aider · github-pr · git · shell · takoapi · managed · pty ├── orchestrator/ cross-agent journal + "while you were away" recap synthesis -├── advisor/ proactive advisor cards (stuck / conflict / ready / idle) + dismissal learning +├── advisor/ proactive advisor cards (stuck / conflict / cost_spike / ready / idle) + dismissal learning ├── consent/ unified consent gate for ambient signals + mail (default all off) ├── control/ remote-control policy — gates high-risk actions from remote callers ├── sense/ ambient signal sources (foreground app / window title), consent-gated diff --git a/README.zh-CN.md b/README.zh-CN.md index c68bda5..6f9b012 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -203,7 +203,7 @@ OpenAI 模型 (`gpt-*`) 还需要 `OPENAI_API_KEY`。 LISA 也是你机器上*其它* coding agent 的一个控制面。三层,对它们的介入逐层加深: -**1. 观察。** 她看着已经在跑的 agent,把你会错过的事告诉你——哪个会话卡在同一个报错上、哪两个要在同一个仓库里打架、哪个早就跑完在干等。诚实地说明范围:**五个 observer(Claude Code、Codex、OpenCode、Aider、GitHub PR)都能产出结构化活动——工具、改动的文件、最近命令、错误——由每个集成的 `visibility` 档位门控;精细度取决于各 agent 在磁盘上记录了什么**(Claude Code 最丰富;Aider 的 markdown 日志只给文件 + 轮次、没有工具流;每个 adapter 都有隐私测试断言提示词/回复/文件内容绝不泄漏)。`lisa agents` 打印一次性快照,灵动岛实时显示。她可以 `dispatch_agent` 无头派发(拒绝把新 agent 丢进已被占用的目录)、`compare_agents` 在并行 worktree 里对比多个 agent 做同一任务,并给出**顾问卡片**——每条带一个一键动作(预填到聊天框,**绝不自动执行**)和一个 ✕(教她少唠叨这一类)。 +**1. 观察。** 她看着已经在跑的 agent,把你会错过的事告诉你——哪个会话卡在同一个报错上、哪两个要在同一个仓库里打架、哪个早就跑完在干等。诚实地说明范围:**十个 observer——五个 coding agent 适配器(Claude Code、Codex、OpenCode、Aider、GitHub PR),外加 git、shell、takoapi、managed、pty——都能产出结构化活动——工具、改动的文件、最近命令、错误——由每个集成的 `visibility` 档位门控;精细度取决于各 agent 在磁盘上记录了什么**(Claude Code 最丰富;Aider 的 markdown 日志只给文件 + 轮次、没有工具流;每个 adapter 都有隐私测试断言提示词/回复/文件内容绝不泄漏)。默认开启的是三个——Claude Code、managed、pty——其余七个需要在 `~/.lisa/agents.json` 里逐个开启。`lisa agents` 打印一次性快照,灵动岛实时显示。她可以 `dispatch_agent` 无头派发(拒绝把新 agent 丢进已被占用的目录)、`compare_agents` 在并行 worktree 里对比多个 agent 做同一任务,并给出**顾问卡片**——每条带一个一键动作(预填到聊天框,**绝不自动执行**)和一个 ✕(教她少唠叨这一类)。 **2. 控制她自己的 agent。** **managed agent** 跑的是 LISA *自己*的 agent loop,在一个她完全驱动的子上下文里:派发任务、逐个审批/拒绝改写类工具、追加追问、取消——从 GUI 的 agents 卡片或 `POST /api/agents/managed//{send,approve,cancel}`。它们是她的,所以用的模型和 provider 也是她的。 @@ -664,9 +664,9 @@ src/ ├── idle/ 空闲自主反思(梦境 Reve) ├── autonomy/ run ledger —— 自驱运行的可观察日志(`lisa autonomy`) ├── agents/ managed agent(LISA 自己的 loop)+ PTY agent(操纵真实 claude/codex) -├── integrations/ observer:claude-code · codex · opencode · aider · github-pr · pty · managed · … +├── integrations/ observer(10 个):claude-code · codex · opencode · aider · github-pr · git · shell · takoapi · managed · pty ├── orchestrator/ 跨 agent 日志 + "你不在的时候"回顾合成 -├── advisor/ 主动顾问卡片(卡住 / 冲突 / 就绪 / 空闲)+ 关闭学习 +├── advisor/ 主动顾问卡片(卡住 / 冲突 / 成本飙升 / 就绪 / 空闲)+ 关闭学习 ├── consent/ 环境信号 + 邮箱的统一授权门控(默认全关) ├── control/ 远程控制策略 —— 对远程调用者门控高危动作 ├── sense/ 环境信号源(前台 app / 窗口标题),授权门控 diff --git a/src/integrations/hub.test.ts b/src/integrations/hub.test.ts index 1fad974..755b8a2 100644 --- a/src/integrations/hub.test.ts +++ b/src/integrations/hub.test.ts @@ -126,3 +126,42 @@ describe("loadOrchestratorConfig", () => { assert.deepEqual(cfg, DEFAULT_ORCHESTRATOR_CONFIG); }); }); + +describe("built-in observer roster (the number the READMEs claim)", () => { + // The READMEs said "all five observers" while ten shipped, and the directory + // tree 490 lines below listed seven plus an ellipsis — so the README + // contradicted itself as well as the code. Pin the count here: prose can't + // be tested, but the fact it describes can be. + const EXPECTED = [ + "aider", + "claude-code", + "codex", + "git", + "github-pr", + "managed", + "opencode", + "pty", + "shell", + "takoapi", + ]; + + test("ten integrations ship, and they are exactly these", () => { + const keys = Object.keys(DEFAULT_ORCHESTRATOR_CONFIG.integrations).sort(); + assert.equal(keys.length, 10, "README says ten observers — update both if this changes"); + assert.deepEqual(keys, EXPECTED); + }); + + test("registerBuiltinIntegrations registers one observer per configured key", async () => { + const { registerBuiltinIntegrations, listAvailableIntegrations } = await import("./registry.js"); + await registerBuiltinIntegrations(); + assert.deepEqual(listAvailableIntegrations().sort(), EXPECTED); + }); + + test("exactly three are enabled by default — the rest are opt-in", () => { + const on = Object.entries(DEFAULT_ORCHESTRATOR_CONFIG.integrations) + .filter(([, cfg]) => cfg.enabled) + .map(([name]) => name) + .sort(); + assert.deepEqual(on, ["claude-code", "managed", "pty"]); + }); +}); From 03e1a81839770d1cc9c838f31ee3c26ff4f3ac77 Mon Sep 17 00:00:00 2001 From: oratis Date: Fri, 21 Aug 2026 12:25:15 +0800 Subject: [PATCH 06/15] docs(pr-template): point regression tests at src/, not a test/ dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the checklist told contributors to add regression tests "under `test/`". no such directory exists, and package.json's test script is `node --import tsx --test "src/**/*.test.ts"` — so a test written where the template said would never be collected, and CI's `npm test` step would go green having never run it. the worst kind of stale doc: it silently produces a test that does not test. CONTRIBUTING.md already says it correctly ("co-located with the source (`src/**/*.test.ts`, run by `npm test`)"). match it, and name the failure mode so the next person does not re-introduce it. --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index f26e2a1..7b5fe38 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -20,7 +20,7 @@ - [ ] `npm run typecheck` passes - [ ] `npm run build` passes -- [ ] If touching agent loop / tool / sandbox: added or updated a regression test under `test/` +- [ ] If touching agent loop / tool / sandbox: added or updated a regression test co-located with the source (`src/**/*.test.ts` — this is what `npm test` runs; a `test/` dir would not be picked up) - [ ] If touching `src/soul/*` or `src/prompt.ts`: linked to a discussion issue (these change who every Lisa becomes) - [ ] Commit message is lowercase, imperative, no emoji - [ ] No `any` introduced (or comment explaining why) From 7df0048a5abdbe79fc1c471cf170d535d5ccce9f Mon Sep 17 00:00:00 2001 From: oratis Date: Fri, 21 Aug 2026 12:27:12 +0800 Subject: [PATCH 07/15] test: guard the published "no telemetry" promise against drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the promise is real and currently true — there is no analytics SDK, no tracking pixel, no third-party script and no phone-home anywhere in the tree. nothing enforced it. one `npm install` of a convenience wrapper, or one snippet pasted into a layout, would turn a published privacy claim into a false statement with nobody noticing. add a deny-list check over src/, website/src/ and the iOS companion sources, plus package.json's dependency names. it lives in the normal suite, so it gates every PR through ci.yml and blocks prepublishOnly too — no CI edit needed, and it runs locally with `npm test`. two deliberate choices: - the promise pages are listed BY PATH, not found by grepping for English phrases. the Chinese pages say "无云同步、无遥测、无任何账号" and "没有分析 SDK", which no English keyword search would ever match, so a keyword-driven guard would have silently covered only half the site. listing paths also means renaming a promise page fails loudly instead of shrinking coverage. - the tokens are SDK-shaped, not bare words. "segment", "heap" and "plausible" occur in ordinary prose and identifiers here (13, 35 and 2 files), and "amplitude" is an audio term — a guard that cries wolf gets deleted. two tests pin this from both sides: one asserts the list really matches posthog-js / googletagmanager / @sentry, so it can never go vacuous, and one asserts it stays quiet on the English words. verified by injecting a tracker script into the Chinese homepage and confirming three of these tests fail, then reverting. --- src/no-telemetry.test.ts | 220 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 src/no-telemetry.test.ts diff --git a/src/no-telemetry.test.ts b/src/no-telemetry.test.ts new file mode 100644 index 0000000..49b8e38 --- /dev/null +++ b/src/no-telemetry.test.ts @@ -0,0 +1,220 @@ +/** + * "No telemetry" is a published promise, not just a design preference: + * + * website/src/pages/index.astro "No cloud sync, no telemetry, no account of any kind." + * website/src/pages/privacy.astro "…collect no analytics, no advertising identifiers, + * and contain no third-party tracking SDKs." + * website/src/pages/cloud.astro "…no training on your data, no analytics SDKs." + * + * plus the Chinese mirrors of all three. Today those statements are true — there + * is no analytics SDK, no tracking pixel, and no phone-home anywhere in the + * tree. Nothing enforced it, so one `npm install` of a convenience wrapper, or + * one copy-pasted snippet in a layout, would quietly turn a published privacy + * claim into a false statement. + * + * This is that enforcement. It runs in the normal suite, so it gates every PR + * through .github/workflows/ci.yml and blocks `prepublishOnly` too. + * + * If this test fails, the choice is: remove the tracker, or change the promise + * on the website. Do not add an exemption without doing one of those. + */ + +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +/** + * Trackers, written in their SDK-shaped forms. + * + * Deliberately NOT bare English words: "segment", "heap" and "plausible" all + * occur in ordinary prose and identifiers in this repo (13, 35 and 2 files + * respectively at the time of writing), and "amplitude" is an audio term. A + * deny-list that cries wolf gets deleted, so each entry here is a token that + * only appears when an actual SDK or endpoint is present. + */ +const TRACKER_TOKENS = [ + "google-analytics.com", + "googletagmanager", + "gtag(", + "ga('create'", + "cdn.segment.com", + "segment.io/v1", + "analytics.load(", + "mixpanel", + "@amplitude", + "amplitude-js", + "posthog", + "@sentry/", + "sentry-cli", + "plausible.io", + "umami.js", + "usefathom.com", + "matomo", + "hotjar", + "statsig", + "bugsnag", + "firebase/analytics", + "FirebaseAnalytics", + "Crashlytics", + "appsflyer", + "onesignal", + "logrocket", + "heap.io", + "clarity.ms", + "braze.com", + "datadoghq", + "newrelic", +]; + +/** Directories whose contents ship to a user, in one form or another. */ +const SCAN_ROOTS = ["src", "website/src", "packaging/ios-companion/Sources"]; + +/** + * The pages that carry the promises, listed by path on purpose. + * + * Hard-coding them (rather than grepping for English phrases like "no + * telemetry") is what keeps the Chinese pages covered: zh-CN/index.astro says + * "无云同步、无遥测、无任何账号" and zh-CN/cloud.astro says "没有分析 SDK", which + * no English keyword search would ever find. It also means renaming or deleting + * a promise page breaks this test loudly instead of silently shrinking what is + * being guarded. + */ +const PROMISE_PAGES = [ + "website/src/pages/index.astro", + "website/src/pages/privacy.astro", + "website/src/pages/cloud.astro", + "website/src/pages/zh-CN/index.astro", + "website/src/pages/zh-CN/privacy.astro", + "website/src/pages/zh-CN/cloud.astro", +]; + +const SKIP_DIRS = new Set(["node_modules", "dist", ".git", "assets"]); + +function walk(dir: string, out: string[] = []): string[] { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return out; + } + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) { + if (SKIP_DIRS.has(e.name)) continue; + walk(full, out); + } else if (e.isFile()) { + // This file names every tracker it bans, and the other test files are + // not shipped behaviour. + if (e.name.endsWith(".test.ts")) continue; + out.push(full); + } + } + return out; +} + +/** Every tracker token present in `text`, matched case-insensitively. */ +function trackersIn(text: string): string[] { + const hay = text.toLowerCase(); + return TRACKER_TOKENS.filter((t) => hay.includes(t.toLowerCase())); +} + +describe("no telemetry — the promise on the website stays true", () => { + test("the deny-list actually matches a real SDK (guard is not vacuous)", () => { + // If this ever passes with an empty result, every other test here is + // meaningless. + assert.deepEqual(trackersIn('import posthog from "posthog-js";'), ["posthog"]); + assert.deepEqual(trackersIn('`),**从 `EVENT_POLICY` 派生,不手写**。 + +### 4.3 `POST /api/insight/event` 的协议 + +**唯一的 HTTP 入口**,注册在 `src/web/server.ts` 的路由分发里,走 `isRequestAuthorized` +(`server.ts:256`)——即:默认只有回环,非回环必须带 `LISA_WEB_TOKEN` 或设备 token。 + +```jsonc +// 请求 +POST /api/insight/event +Content-Type: application/json +{ + "events": [ + { "name": "surface_opened", "properties": { "surface": "web", "view": "room", "first": true }, + "occurredAt": "2026-08-21T09:14:02.117Z" } + ] +} +// 响应:204 No Content(永远,除非整批被拒 → 400) +``` + +六条闸,全部照蓝图 §3 但参数按 LISA 调过: + +| 闸 | LISA 的值 | 与 Luddi 的差异与理由 | +|---|---|---| +| batch 上限 | **20** | Luddi 是 50(= 客户端阈值 20 的 2.5 倍)。GUI 侧不批量(用户交互本来就稀疏),20 足够 | +| legacy 单事件体 sniff | **要** | mac app / iOS 会被 TestFlight 卡住好几个版本;**入口协议只能加不能破** | +| allowlist | `INGRESS_EVENTS`(**从声明表推导,无手工名单**) | 机制与蓝图完全一致。混合批里非法名字**静默丢弃**、合法的照收并 204 | +| 时钟窗口 | **`[now − 35d, now + 60s]`** | **Luddi 是 24h,这里必须放大**——见 §4.4 | +| 限流 | **60 次/分/连接** | 防的不是攻击者(入口只绑回环),是**我们自己写出来的 setInterval bug**。`lisa-client.ts` 是 4002 行手写 JS(实测 `wc -l`),一个 render loop 里的 track 能在一分钟里打几千条 | +| properties 大小 | **4 KB 序列化后,超限拒收这一条,且真的实现 + 写测试** | 蓝图 §3.5 记录了 Luddi 的注释声称 32 KB 但**代码里从未实现**。4 KB 而不是 32 KB:§3.3 已禁自由文本,正常事件 < 300 B,4 KB 已经是 10 倍余量 | + +**身份字段一律服务端覆写**:请求体里的任何 `installId` / `uid` / `deviceId` **直接丢弃**, +由服务端从 `scopedUid()` 和本地 seed 填。客户端自报身份在云版是伪造口子,在本地版是噪音源。 + +### 4.4 时钟窗口为什么是 35 天而不是 24 小时(对蓝图 §3.3 的实质偏离) + +蓝图的规则:客户端自报 `ts` 只在 `[now − 24h, now + 60s]` 内被采信,窗口外**丢弃 ts、用服务端 +到达时间**(不是 clamp 到边界)。理由是 >24h 的"迟到"更可能是设备时钟坏了。 + +**在 LISA 这条会毁掉留存口径。** 差异来自形态: + +| | Luddi 移动端 | LISA | +|---|---|---| +| 最长离线 | 几天(手机总会连上) | **数周**——笔记本合盖、出差、断网开发、强代理环境(整个 `undici-proxy-env` 包就是为此存在) | +| 本地缓冲 | 磁盘 outbox 200 条 | ledger **3000 行 / 30 天** | +| upload 节拍 | 20 条 / 10s | **6 小时一批** | + +一台断网两周的机器重新联网时,会一次性交出两周的事件。用 24h 窗口 = 这些行全部被重打成 +"送达日",D7 留存、cohort 归属、`sinceBirth` 分桶**全部作废**——而这恰好是 Q1 的核心口径。 + +**替代规则(三条,必须一起用)**: + +1. **`occurredAt` 与 `receivedAt` 双字段并存**,两个都落库,永不互相覆盖。 +2. **所有留存 / cohort / DAI / 漏斗用 `occurredAt`;所有管线健康检查(§8)用 `receivedAt`。** + 这条要写进口径视图(§7.5),不能只写在 prose 里。 +3. `occurredAt` 落在 `[receivedAt − 35d, receivedAt + 60s]` 之外 → **丢弃 `occurredAt`, + 并打一条 `telemetry_clock_skew` 到本地 ledger**(不是丢弃整条事件)。35d = ledger 保留期 + 30d + 5 天余量:ledger 里存在的行,其 `occurredAt` 不可能比 35 天更老,更老的必然是坏时钟。 + +**代价写进口径文档**:坏时钟的机器上,那些行的 `occurredAt` 变成送达日,日桶会有一个尖峰。 +这是"坏时钟 vs 真延迟"的取舍,我们选了偏向真延迟——因为在这个产品里真延迟远比坏时钟常见。 + +--- + +## §5 身份模型 + +### 5.1 本地版没有"用户"——锚点是 install,且必须从 `randomness` 派生 + +调研的关键结论原样成立:**本地版根本没有用户这个概念**。所以: + +```ts +// src/telemetry/identity.ts(新建) +// installId = 从 birth seed 的 32 字节真随机派生,截断到 32 hex。 +export function installId(seed: SoulSeed): string { + return crypto.createHash("sha256") + .update("lisa-install-v1:" + seed.randomness) + .digest("hex").slice(0, 32); +} +``` + +**红线:绝对不能用 `bornOn`。** + +`src/soul/birth.ts` 的 `generateSeed()`: + +```ts +const hostHash = crypto.createHash("sha256") + .update(hostname + os.userInfo().username).digest("hex"); +``` + +`bornOn` 看起来像一个完美的匿名安装锚点(稳定、已存在、是个哈希)。**它不是。** +`hostname + username` 的取值空间小到可以离线穷举:`Marks-MacBook-Pro.local` + `mark`、 +`MacBook-Air.lan` + `alice`……几百万个候选就能覆盖绝大多数真人。上报 `bornOn` 等于 +**上报用户名和机器名**,只是加了一层不起作用的糖衣。 + +`seed.randomness` 是 `crypto.randomBytes(32).toString("hex")` —— 真随机、不可反推。 +**再加一层带域分隔符的哈希**(`"lisa-install-v1:"`)而不是直接用 randomness, +是为了让 `installId` 与 soul seed 单向解耦:拿到 installId 推不回 seed, +所以 installId 泄漏不会威胁到 soul 的完整性校验(`soul.lock.json`)。 + +**为什么不新铸一个 UUID**(蓝图 §4.1 的做法):产品对外承诺 "no account of any kind" +(`index.astro:103`)。在 `~/.lisa/` 里新增一个持久化的 `anon-id` 文件,本身就是一个 +**可被 grep 出来、可被截图、可被写成推文**的标识符。从既有的 birth 产物派生, +不新增任何磁盘上的标识符——这是同样的功能、少一个攻击面。 + +### 5.2 `installId` 只在 upload sink 出现,本地 ledger 里没有 + +本地 ledger 不写 `installId`。理由很简单:**本地台账不需要认自己是谁**, +一台机器上的一个文件本来就只属于一台机器。写进去只是给"如果这个文件被别人拿到" +增加一点信息量。 + +### 5.3 云版:uid 权威,`installId` 一律不带 + +```ts +// upload 时的身份解析 +const uid = scopedUid(); // src/paths.ts +if (uid) return { uid }; // 云版:uid 权威,不带 installId +return { installId: installId(seed) }; // 本地版:installId +``` + +**两者互斥,永不同时出现。** 而且—— + +### 5.4 **刻意不建身份桥**(对蓝图 §4.3 的整节否决) + +蓝图要求注册时写一张 `(anonId, userId)` 桥表,用途是"历史匿名事件回溯归属 / 注册来源分析 / +跨端缝合"。 + +**LISA 不建这张桥。** 建桥 = 把"这台机器"和"这个邮箱"关联起来。 +`privacy.astro` 写着 "no account of any kind" 是针对本地版的;一旦有一张表把 +本地 installId 和云版 uid 连起来,那句话就不再是真的了——**而且是以最难辩解的方式不真**。 + +**代价必须记为永久盲区**: +- 本地版用户何时/是否变成云版用户,**永久不可观测**。 +- 云版转化漏斗只有从 `account_created` 往后的那一半。 +- 上游只能靠定性方法(问卷、GitHub issue、Discord 里问)。 + +**这条必须写进口径文档并标"不要事后顺手补上"** —— 因为这是一个"加两行代码就能大幅提升 +数据能力"的诱惑,而它的代价是产品定位。半年后的某个人不会记得这是刻意的。 + +### 5.5 多租户:复用 `homeScope`,不另起一套 + +调研约束原文:"`/events` 曾经因为进程级广播把一个账号的 idle-message 文本泄漏给所有登录用户, +修法是 `event-bus.ts` 的 `sameTenant`。埋点写入必须在正确的 home scope 内发生。" + +三条硬规则: + +1. **`src/telemetry/ledger.ts` 必须 `import { lisaHome } from "../paths.js"`**, + 不得像 `consent/store.ts` / `sense/log.ts` / `dispatch-ledger.ts` 那样自己定义一个 + (§0.2 发现 B)。写路径是 `path.join(lisaHome(), "telemetry", "events.jsonl")`, + 在云版自动落到 `~/.lisa/users//telemetry/`。 +2. **`track()` 必须在请求的 home scope 内被调用**。`homeScope` 是 AsyncLocalStorage, + 跨 `await` 是自动传播的;但 `setTimeout`/`setInterval`/`EventEmitter` 回调里 + **可能已经出了 scope**。所以:`track()` 在**推入 buffer 的那一刻**就调用 `scopedUid()` + 把 uid 钉在事件上,落盘 timer 只是搬运,不再解析身份。 + **这是最容易写错的一行**——落盘时才解析 scope,等于把所有租户的事件都写进 timer + 碰巧所在的那个 home。这就是 `event-bus` 泄漏的同一个形状。 +3. **buffer 按 home 分桶**:内存 buffer 的 key 是 `lisaHome()` 的路径,不是一个全局数组。 + 否则一次 flush 会把 A 的事件写进 B 的文件。 + +单测必须覆盖:"在两个不同的 `homeScope.run()` 里各 track 一条,flush 后两个文件各一行, +且互不含对方的行。" —— 这是 `sameTenant` 那条规则在打点管线里的对应测试。 + +--- + +## §6 存储与 sink 选型 + +### 6.1 Sink A:本地 ledger(照抄 `meter.ts` 的纪律,一个字不改) + +``` +~/.lisa/telemetry/events.jsonl ← 事件(append-only) +~/.lisa/telemetry/events.lock ← 跨进程 trim 锁(src/soul/lock.ts) +~/.lisa/telemetry/upload-state.json ← 上次上报的行号 + batchSeq(仅 opt-in 时存在) +``` + +| 属性 | 值 | 对齐哪个既有台账 | +|---|---|---| +| 写入原语 | `appendLine` / `atomicWrite`(`src/fs-utils.ts`)+ `withFileLock`(`src/soul/lock.ts`) | 三份台账全都用这套 | +| 行数上限 | **3000** | runs.jsonl 2000 / usage.jsonl 5000 之间 | +| 保留期 | **30 天** | sense/events.jsonl 是 7 天 + 1000 条的**双重界**,这里同构 | +| trim | 机会式,跨进程锁下做,失败静默 | `runs.ts` 的 `recordAutonomyRun` | +| 写失败 | `console.error` 一行 + 打 `telemetry_sink_failed`,**绝不 throw** | `meter.ts` 的 ENOSPC 处理 | +| 体积上限 | ~600 KB | 3000 × ~200 B | + +**双重界必须都有**:只有行数上限 → 一台闲置机器上 30 天前的事件永远不过期; +只有保留期 → 一台狂跑 heartbeat 的机器一天就能写几万行。sense/log.ts 已经踩明白了这一点。 + +### 6.2 Sink C(先做):L2 代理指标——零代码、零隐私成本、Phase 0 就能有 + +**这一层蓝图里完全没有**,因为 Luddi 的服务端本来就有全量数据。对 LISA 它是 +**Phase 0 唯一能拿到全体安装信号的东西**: + +| 源 | 拿得到 | 怎么拿 | 局限 | +|---|---|---|---| +| npm registry | `@oratis/lisa` 日/周下载、**按版本**下载 | `api.npmjs.org/downloads/point/{period}/@oratis/lisa` + `/versions` | 下载 ≠ 安装 ≠ birth;CI 镜像会灌水 | +| GitHub Releases | DMG / 各资产的 `download_count` | `api.github.com/repos/oratis/LISA/releases`(Updater.swift 已经在打这个域) | 累计值,不是时序——**必须自己每天快照存差分**,GitHub 不给历史 | +| GitHub stars / forks / traffic | star 时序、clone 数、referrer | **`scripts/star-history.sh` 已经写好了**(`REPO`/`OUT` 可覆盖,无需 auth,append 到 `docs/star-history.csv`:`timestamp,stars,forks,watchers,open_issues,last_push`)。**要做的是给它加调度 + 补 `/traffic/*` 两列**(traffic 只保留 14 天,**必须每天抓**,且 traffic API 需要 repo push 权限的 token,与其余几项不同) | GROWTH.md 的 10k star 目标的唯一真实计量 | +| Cloudflare Pages(meetlisa.ai) | 请求量、路径分布、地区、referrer | CF 控制台自带日志,**无需在页面加任何脚本** | 不是 SDK、不加 cookie——完全不违反 `WEBSITE_OPS.md` 的自托管字体那条纪律 | +| App Store Connect | TestFlight 安装、崩溃、留存 | ASC 自带 | 只覆盖 iOS | +| Homebrew tap | 待确认(§0.3 #5) | — | 第三方 tap 的可见性受限 | + +**这一层的落地方式**:一个每天跑的 GitHub Actions(`.github/workflows/` 里已有 6 个先例), +把快照 append 到仓内 CSV。**成本零、隐私成本零、今天就能开工**—— +而且它是 §8.1 "ingestion floor" 在没有服务端时的**唯一**替代物。 + +> **别把这条写成"新建一个脚本"。** 实测:`scripts/star-history.sh` 已经存在且完整, +> `docs/star-history.csv` 也已存在——**但里面只有 2026-05-09 的一行**。 +> 这个脚本从写好那天起就没有被调度过,所以 GROWTH.md 的 star 目标至今没有任何时间序列。 +> Phase 0 在这一项上要交付的**不是代码,是一个 `schedule: cron` 块**—— +> 这正是蓝图 §2.4 那条"脚本存在了几个月但从没接进 CI"在本仓的现成标本, +> 而且它已经**发生**了,不是一个假设的风险。 +> **验收:连续三天后 `docs/star-history.csv` 有三行新增,且行数与日期一一对应。** + +**必须提前承认的不对称**(调研约束原文):这层拿不到 web 那种全量漏斗。 +`npm download → 实际安装 → 完成 birth → 第二次会话` 这四个数**每一级差一个数量级**, +而我们只能直接观测第一级和(opt-in 之后的)后两级。中间那一级永远是估算。 + +### 6.3 Sink B:opt-in upload(Phase 2),选型与成本 + +**形态锁死**:手写的、批量的、可选的 HTTPS POST。**不引入任何 SDK。** +`posthog-node` / `@segment/analytics-node` / OTel SDK 与"8 个生产依赖 / 裸 REST 打 Firestore / +一个 form POST 打 Turnstile"的代码风格根本不兼容,也会显著抬高 Cloud Run 冷启动。 + +三个候选: + +| 候选 | 优点 | 缺点 | 判断 | +|---|---|---|---| +| **Cloudflare Worker + Analytics Engine** | 官网已在 CF Pages,账号已有;AE 免费额度大;与 Cloud Run 完全解耦(云版挂了不影响本地版上报,反之亦然);天然不落 prompt;边缘延迟低 | 新增一个部署单元;AE 的查询是 SQL API 不是仓 | **倾向这个** | +| 现有 Cloud Run 加 `/api/insight/ingest` | 零新增基础设施;复用现有鉴权与 Firestore | **把本地版的匿名上报和云版的登录态服务放进同一个进程**——一次配置错误就可能让两者互相污染;且云版实例的冷启动会拖慢上报(虽然上报不阻塞,但会拉长 6h 批次的尾部) | 备选 | +| 第三方托管 | 最省事 | **直接违反 privacy.astro 的 "no third-party tracking SDKs"** | **排除** | + +**成本估算**(按 §0.4 的假设:200 个 opt-in 安装 × 30 可上报事件/天): + +| 项 | 量 | CF 免费额度 | 结论 | +|---|---|---|---| +| Analytics Engine data points | 6k/天 ≈ **18 万/月** | 千万级/月 | 远在额度内 | +| Worker 请求 | 200 安装 × 4 批/天 = **800 req/天** | 10 万 req/天 | 远在额度内 | +| 出网带宽 | 每批 ~30 KB × 800 = **24 MB/天** | — | 可忽略 | + +**即使 opt-in 安装涨到 10,000(50 倍),仍在免费额度内。** +所以护栏(§8.6)不是为正常量设的,是为**失控循环**设的——和蓝图 §7.6 一模一样的结论, +只是量级小两个数量级。具体护栏:Worker 侧按 installId 每天 1000 事件硬上限, +超出直接 204 丢弃并记一条服务端日志。 + +**保留期:180 天**(不是蓝图的 730 天)。理由不是技术的,是政治的: +在一个"零遥测"定位的产品的隐私政策里,"我们保留两年"这句话写不出来。 +180 天够做半年趋势和一次版本间对比,这就够回答 §1 的九个问题了。 + +--- + +## §7 口径规则:LISA 最容易数错的七件事 + +蓝图 §6 的核心思想("让最省事的查询恰好就是正确的查询")完全成立,但**具体的坑全换了**。 + +### 7.1 身份单位对照表(本地版没有 userId) + +| 报表类型 | 本地版单位 | 云版单位 | 禁止 | +|---|---|---|---| +| 留存 / cohort / DAI / MAI | `COUNT(DISTINCT installId)` | `COUNT(DISTINCT uid)` | `COUNT(*)`、`COUNT(DISTINCT sessionId)` | +| 漏斗 | 同上 | 同上 | 同上 | +| 动作总量 | `COUNT(*)`,且**报表上必须显式标注"这是事件行数,不是安装数"** | 同 | 把它叫"活跃度" | + +**红线:`installId` 计数与 `uid` 计数永不相加。** 一个人可能本地一台机器 + 云版一个账号, +相加就是把一个人数成两个。且因为 §5.4 刻意不建桥,我们**无法**去重——所以规则只能是 +"两个数字并列展示,永不求和"。 + +### 7.2 `LISA_HOME` 会把一个人切成多个 install + +`~/.lisa` 的位置由 `LISA_HOME` 决定(`src/paths.ts`)。一个开发者跑 `LISA_HOME=/tmp/t1 lisa` +测试就会 birth 出一个新 seed = 一个新 `installId`。**LISA 的目标用户恰好是最会这么干的那批人** +(HN / r/ClaudeAI)。 + +后果:install 数虚高、留存虚低(测试用的 home 永远不会有第二次会话)。 + +**缓解(不是解决)**:`install_daily_ping` 带一个 `homeIsDefault: boolean` +(`lisaGlobalHome() === path.join(os.homedir(), ".lisa")`)。所有留存口径**默认加 +`WHERE homeIsDefault = true`**,并把这条编进视图(§7.5)。这不能覆盖"改了 HOME 但真在用" +的情况,但能砍掉绝大部分测试噪音。 + +### 7.3 heartbeat / idle 产生的 turn **不是**"用户活跃" + +这是 LISA 独有的、也是最危险的口径陷阱:**这个产品会在没人的时候自己动**。 +`launchd` 每小时触发一次 heartbeat,每次都会产生 LLM 调用、会写 `runs.jsonl`、 +(在 web 驱动的 autonomy-sweep 路径下)会写 `usage.jsonl`。 + +如果把"有 LLM 调用的天"算作活跃天,**每一台装了 heartbeat 的机器都是 100% 日活**, +包括那些主人三个月没碰过的。这会让 Q1 的留存指标变成一条完美的直线, +而那条直线**完全是假的**。 + +**规则**: +- 活跃 = **`session_started` 且 `surface != autonomous`**。`autonomy_run_recorded` 永不计入活跃。 +- 需要"她自己在动"的口径时,明确叫 **autonomous activity**,与 human activity 并列展示,永不合并。 +- `install_daily_ping` 本身也**不是**活跃证据(它是进程存活证据)。 + +### 7.4 三个 token 台账的覆盖面各不相同,**永远不许相加**(§0.2 发现 A 的口径后果) + +| 台账 | 覆盖 | 不覆盖 | 语义 | +|---|---|---|---| +| `usage.jsonl` | Web GUI `/chat`(`server.ts:4079`)、voice(`:2265`)、`autonomy-sweep`(×2)、云版 admission/gateway | **CLI 终端的全部 turn**、`heartbeat/runner.ts`、`idle/runner.ts` | **计费审计**——"谁花了我们的钱" | +| `runs.jsonl` | `idle` ×2、`heartbeat` ×4、`reflect` ×2 的 token | Web `/chat`、CLI chat、`autonomy-sweep` | **自主运行台账**——"她自己动了多少" | +| `turn_completed`(新增,§3.4 Q1) | **全部四条路径**(挂在 `agent.ts` 的 turn 出口) | — | **使用度量**——"总共跑了多少 turn" | + +**这不是三个台账在同一个量上互相校验,是三个不同的量。** +`usage.jsonl` 的覆盖面窄**是设计**(`PLAN_ACCOUNTS_BILLING_v1.0.md` §6.3),不是 bug; +`runs.jsonl` 的 token 是 autonomy 的内部会计,不是账单。**三者两两相加都是错的**: +- `usage + runs`:在 `autonomy-sweep` 路径上可能重叠(见 §0.3 待确认 #3),且单位语义不同。 +- `usage + turn_completed`:**必然双计**——同一个 Web turn 在两边各落一行。 +- 只用 `usage.jsonl` 回答"活跃度/成本":**系统性漏掉旗舰形态(终端 REPL)的全部消耗**。 + +**规则(写进 `docs/TELEMETRY.md`)**: +- "**花了多少钱**" → 只读 `usage.jsonl`,且必须标注"仅计费路径(Web + 云版)"。 +- "**跑了多少 turn / 烧了多少 token**" → 只读 `turn_completed`,且标注 opt-in 偏差。 +- "**她自主动了多少**" → 只读 `runs.jsonl`(或其派生的 `autonomy_run_recorded`)。 +- 任何把三者之一叫做"总量"的报表,**在评审时直接打回**。 + +这是一个"看起来能加、实际不能加"的陷阱,而且比蓝图 §5.3 的双端 `source` 消歧更隐蔽—— +Luddi 至少有一个 `source` 列可以 `GROUP BY`,这里三个文件连字段名都不一样, +**没有任何东西会在你加错的时候报错。** + +### 7.5 `no-update` 不是失败(Q2 的核心口径) + +`AutonomyOutcome` 的四个值里,`"no-update"` 的注释原文是 *ran fine, nothing worth surfacing*。 +把它算进错误率会得到一个 80% 的**假故障率**。 + +**规则**: +- **健康率** = `done / (done + no-update)` —— 这才是 Q2 要的"在产出还是在空转"。 +- **故障率** = `(blocked + error) / all` —— 这是工程指标,与 Q2 无关。 +- 两个比率**分开报,永不合并**。 + +### 7.6 到达滞后:T-7,不是 T-2;且比率的分母有 opt-in 偏差 + +蓝图 §6.4 的全局规则是"比率类查询一律切到 T-2",配一个 `v_ratio_safe` 视图。 +**LISA 的滞后要长得多**:upload 是 6 小时一批 + 机器可能离线数周(§4.4)。 + +**规则**: +- **比率类查询一律切到 T-7**(按 `occurredAt`)。这个数字要在上线一个月后**用真实到达曲线回测重调** + ——先量 P95 到达延迟,再定阈值,不要拍脑袋守着 7。 +- **管线健康检查用 `receivedAt`,不切**(它要的就是"今天到了多少")。 +- **所有 L3 比率必须标注 opt-in 偏差**:分子分母都只来自 opt-in 人群。 + "advisor 采纳率 62%" 的完整表述是 "在开启了遥测的安装中,advisor 采纳率 62%"。 + **这不是啰嗦,是防止半年后有人拿这个数去做产品决策时忘了它的来源。** + +### 7.7 口径固化的载体:Phase 2 之前没有数据库,所以固化进代码 + +蓝图 §6.3 的做法是建 SQL 视图。Phase 0/1 的 LISA **没有数据库**,无处建视图。 + +**替代:把口径固化进 `lisa telemetry report` 的实现**(`src/telemetry/report.ts`)。 +同一个思想——**让最省事的查询恰好就是正确的查询**:用户/我们想知道"她的健康率"时, +唯一顺手的做法是跑这个命令,而这个命令里已经编好了 §7.3(排除 autonomous)、 +§7.5(no-update 不算失败)、§7.2(homeIsDefault)三条规则。 + +Phase 2 有了 upload sink 之后,同样三条规则**再在 SQL 侧编一遍**, +并且在两处的注释里**互相指向对方**,注明"改一处必须改另一处"。这是重复, +但比"prose 里写了规则、两边各自实现、悄悄漂移"好。 + +--- + +## §8 监控:四件套 → 五件套 + +蓝图的四件套各覆盖一个盲区、不可合并。LISA 的形态全变了(因为**本地版没有服务端、 +没有告警通道、没有 on-call**),而且要**加第五件**。 + +### 8.1 蓝图 §7.1 Ingestion floor → 本地版不适用,两个替代物 + +**为什么不适用**:入口地板监控的前提是"有一个我们能观测请求量的入口"。 +LISA 本地版的入口是一个函数调用,发生在用户的机器上,我们看不见。 + +| 替代物 | 查什么 | 何时可用 | +|---|---|---| +| **`lisa doctor` 自检**(本地,给用户和给我们自己) | ledger 最近 24h 有没有行;有没有 `telemetry_sink_failed`;upload 上次成功时间 | Phase 0 | +| **L2 代理指标地板**(§6.2) | npm 周下载环比跌 > 50%、GitHub release 下载数连续 3 天零增长 | Phase 0 | +| 真正的 ingestion floor | upload endpoint 的 204 量跌破地板 | Phase 2 | + +**代理指标地板是 Phase 0 唯一的"东西坏了会有人知道"的机制。** 它不精确 +(npm 下载受镜像/CI 影响很大),但蓝图案例 A 的教训是"当时没有任何东西盯着事件量, +断供六天无人发现"——**一个粗糙的、真的在跑的地板,胜过一个精确的、还没建的**。 + +### 8.2 蓝图 §7.2 Sink 失败告警 → 本地版无告警通道,改成"用户可见 + 自记" + +fire-and-forget 的代价是失败静默;蓝图说这条告警是那个设计决定的**对价**,必须一起上线。 +LISA 没法给用户的机器发告警,所以对价换一种付法: + +1. **自记**:`telemetry_sink_failed`(`upload: "never"`,§3.4)落本地 ledger。 +2. **用户可见**:`lisa doctor` 和 `lisa telemetry status` 打印最近的失败计数与 code。 +3. **云版**:`[C]` 事件的 sink 失败走 Cloud Run 结构化日志 → logs-based metric → 告警策略。 + **日志字符串一旦被 metric filter 匹配,改它之前必须先搜 monitoring 配置**(蓝图长期纪律)。 +4. **Phase 2 的 upload sink**:服务端侧统计 4xx/5xx 比例,这是我们唯一能主动看到的失败面。 + +### 8.3 蓝图 §7.3 双 sink 日对账 → Phase 0/1 不适用;Phase 2 变成"送达率" + +Phase 0/1 只有一个 sink,无处对账。 + +Phase 2 的对应形态:串联架构(§2.2)让对账天然可做——**upload 是 ledger 的严格子集**。 +`telemetry_batch_sent` 带 `{ count, ledgerLines, seq }`: + +- 服务端按 `installId` 检查 `seq` 是否连续 → **缺口 = 丢批**(网络失败或进程被杀)。 +- `sum(count)` vs 服务端实收行数 → **不等 = 传输层丢行**。 +- `ledgerLines` 的增长速度 vs `count` 的增长速度 → **偏离 = upload 跟不上产生速度** + (比如一台机器每天产生 200 行但每批只发 50 行,说明批次上限设小了)。 + +蓝图的三个细节照抄:① mismatch 时**返回 200**,`ok:false` 在 body 里(重试一个"正确地 +发现了不一致"的对账只是烧钱重放同一发现);② "sink 不可达" ≠ mismatch,单独计数; +③ 对账范围限定 `UPLOADABLE_EVENTS`——`upload:"never"` 的事件本来就只在本地, +比出来的"缺口"全是假的。 + +### 8.4 蓝图 §7.4 Per-event 量级回归 → 有最低样本量门槛,不到不要建 + +蓝图的做法:逐事件名对比近窗与 14 天基线,量或去重人数任一跌破 10% 就红。 + +**在 LISA 这条有一个前提:样本量。** 200 个 opt-in 安装、一个低频事件(比如 +`plan_run_finished`)一天可能只有 5 行。**5 → 2 是噪声,不是回归。** 建一个天天误报的 +检测器,结果是所有人学会忽略它——防线名存实亡(蓝图 §2.5 关于 substring 误报的同一个道理)。 + +**规则**: +- 逐事件名,**日均 < 50 行的事件不参与**这个检测(只在看板上标"样本不足")。 +- 参与的事件用蓝图的双指标:**事件量**与**去重 installId 数**,任一跌破基线 10% 就红。 + 两个都要——蓝图的 banner 事故里人数 63→4 崩了而事件量还有基线的 48%(一个重度用户撑着), + 在 LISA 这个形态下更容易发生(一台狂跑 heartbeat 的机器能撑起整个事件量)。 +- 基线只取**健康日**的中值(否则一次管线事故会把中值拖到 ~0,检测器在事故后最需要它的 + 一周里失明——蓝图原话)。 +- 超过 50% 的事件同时报警 → 收敛成**一条**管线级 finding,不刷一墙名字。 + +**Phase 2 才建,且建之前先量一个月的实际日量**,用真实数据定那个 50 的门槛。 + +### 8.5 **第五件(LISA 专属):承诺一致性检查** + +**这是蓝图里没有的,也是这个项目最该有的一条。** + +**查什么**:以下声明的"会出网的事件清单"必须**逐字一致**,任一漂移 → CI 红: + +| # | 位置 | 内容 | +|---|---|---| +| 1 | `src/telemetry/schema.ts` 的 `UPLOADABLE_EVENTS`(派生,权威) | 代码事实 | +| 2 | `docs/TELEMETRY.md` 的事件表 | 开发者文档 | +| 3 | `README.md` 的遥测小节 | 对外主承诺 | +| 4 | **六个** astro 页面:`privacy` / `cloud` / `index` × `{en, zh-CN}` | 已发布的法律文本 | +| 5 | `packaging/ios-companion/Sources/PrivacyInfo.xcprivacy` 的 `NSPrivacyCollectedDataTypes` | Apple 侧的机器可读声明 | +| 6 | `lisa telemetry events` 的输出 | 用户自验证面(**这个不需要 CI 检查——它就是从 #1 打印的**) | + +**#4 是六个文件不是两个**(实测):三份英文页与三份中文页**各自独立**地做了同一个承诺—— +`website/src/pages/privacy.astro:20`(*"collect no analytics, no advertising identifiers…"*)、 +`cloud.astro:50`(*"no analytics SDKs"*)、`index.astro:103`(*"no telemetry, no account of any kind"*), +以及 `zh-CN/privacy.astro:19`("不含任何分析统计、广告标识或第三方追踪 SDK")、 +`zh-CN/cloud.astro:49`("没有分析 SDK")、`zh-CN/index.astro:102`("无遥测、无任何账号")。 +**中文页最容易被漏掉**——它不在任何人的 grep 习惯里(搜 "analytics" 搜不到"分析统计"), +而它对中文用户是同等效力的公开承诺。**所以检查脚本必须按文件路径清单硬编码这六个文件, +而不是 grep 一个英文关键词**。 + +由 `scripts/check-telemetry-events.mjs --check` 执行,挂在 `prepublishOnly` 与 +`.github/workflows/ci.yml`。 + +**为什么这条比蓝图的任何一条都重要**:这个产品的最大事故形态不是"数据丢了六天" +(Luddi 案例 A),是**"有人 grep 出一个没写在隐私政策里的上报事件,发到 HN"**。 +第一种事故的代价是几周的数据;第二种的代价是产品定位——而定位是这个项目 +(MIT、免费、靠 star 增长)**唯一的资产**。 + +对应的"如果当初有":没有这条,一个"顺手多带一个字段"的 PR 就能让 privacy.astro 变成假话, +而且**没有任何自动化会发现**,因为代码是对的、测试是绿的、文档只是旧了。 + +### 8.6 成本护栏(三层,量级小两个数量级但形状一样) + +| 层 | LISA 的值 | +|---|---| +| ① 客户端硬上限 | 单个安装每天最多 upload 1000 事件;超出本地丢弃并记 `telemetry_sink_failed{code:"quota"}` | +| ② 服务端硬上限 | Worker 按 installId 每天 1000 事件,超出直接 204 丢弃 + 服务端日志 | +| ③ 账单预算 | CF/GCP 预算 **20 USD**,20/50/100% 三档。**必须显式 `EXCLUDE_ALL_CREDITS`**——GCP 预算默认 `INCLUDE` credits,账户有赠金时永远不会触发(蓝图 §7.6 真实修过的坑) | + +**采样:不做。** 蓝图的结论是"先测集中度再谈采样,多数体量下答案是不采"。 +LISA 的日量比 Luddi 小两个数量级,采样的复杂度收益比是负的。 +(唯一可能例外:`autonomy_run_recorded` 在一台开了全部 desire 的机器上可能高频—— +Phase 2 上线一个月后**实测集中度再说**,不要预先优化。) + +--- + +## §9 合规与隐私:这个项目的特殊性 + +### 9.1 **第一个交付物是改政策,不是写代码** + +调研约束原文:*任何上报都必须先改这三页 + iOS 隐私标签 + App Store 提审, +否则就是对已生效隐私政策的直接违反。埋点方案必须把「改政策」当成第一个交付物,而不是脚注。* + +**这条完全成立,且顺序不可颠倒。** 但要精确区分**哪些改动需要改政策**: + +| 动作 | 需要改政策吗 | 理由 | +|---|---|---| +| 建本地 ledger(Sink A,永不出网) | **不需要**改隐私政策,**需要**在 README / `docs/TELEMETRY.md` 里说明 | 它和 `runs.jsonl` / `usage.jsonl` / `sense/events.jsonl` 是同一类东西——用户自己磁盘上的、用户自己能读的、有界会过期的本地文件。privacy.astro 承诺的是"不向我们的服务器收集",不是"不在你的机器上写文件" | +| 加 `POST /api/insight/event`(只绑回环) | **不需要** | 数据没有离开用户的机器 | +| 云版 `[C]` 事件(Q7 那五个) | **需要改 `cloud.astro` 与 `zh-CN/cloud.astro` 两页** | 两页现在分别写 "no analytics SDKs" / "没有分析 SDK"——字面上仍是真的(我们不用 SDK),但"我们记录你的配额消耗事件"必须明说,不能靠字面技巧。`privacy.astro` 的 LISA Cloud 一节也要同步 | +| **opt-in upload(Sink B)** | **需要改七处**:`{privacy, cloud, index}.astro` × `{en, zh-CN}` 六个页面 + `README.md`;**以及** `PrivacyInfo.xcprivacy` + ASC App Privacy 答案 | 这是真正的"数据离开用户的机器" | + +**改政策的措辞纪律**:不要写"我们可能收集使用数据"这种留后门的模糊句。 +写**具体的、可核对的**句子,并附上"完整清单见 `lisa telemetry events`"。 +在这个用户群面前,模糊 = 可疑;具体 + 可自验证 = 可信。 + +### 9.2 遥测作为一个 `ConsentSignal`,不新开开关 + +调研约束原文:*必须接入已有的 consent 框架,而不是新开一个开关……否则用户会有两套互相矛盾的 +隐私开关。* 完全成立: + +```ts +// src/consent/store.ts 的最小改动 +export const SENSE_SIGNALS: ConsentSignal[] = + ["screen", "voice", "clipboard", "selection", "mail", "telemetry"]; + +SIGNAL_DESCRIPTIONS.telemetry = + "anonymous usage counters (no text, no file paths, no soul content) " + + "sent to meetlisa.ai so we know what to build next — see `lisa telemetry events`"; +``` + +**"免费得到"这句话已实测确认,不是想当然**——`SENSE_SIGNALS` 的全部消费者只有三处 +(`grep -rn "SENSE_SIGNALS" src/`),加一个成员就同时得到: +- `lisa consent grant/revoke` 的合法值与错误提示(`src/cli/consent.ts:38`); +- `POST /api/consent/grant` 的入参校验(`src/web/server.ts:2969-2971`,非法 signal → 400); +- `src/consent/store.test.ts:32,59,71` 的三条既有断言——它们遍历 `SENSE_SIGNALS` + 断言"默认全 false""revoke-all 后全 false""listGrants 覆盖全部", + **新 signal 自动被这三条 fail-closed 回归测试保护**,一行测试都不用写。 + +`revoke-all` 连遥测一起停,这正是我们想要的语义。 + +> **一个命名债要留痕**:加进去之后 `SENSE_SIGNALS` 就名不副实了(遥测不是一个 ambient +> sense signal)。**不要为此改名**——改名会同时动 CLI 帮助文本、`/api/consent` 的错误串、 +> 和三个既有测试,风险远大于收益。正确做法是在 `consent/store.ts` 的头注释里补一句 +> "该常量现在也包含非 sense 的 consent 门(telemetry)",把债记在它所在的地方。 + +**三条硬约束**: + +1. **必须先修 §0.2 发现 B**(`consent/store.ts` 改用 `paths.ts` 的 `lisaHome()`), + 否则云版的第一个 opt-in 用户就替全体云版用户开了上报。**这是硬前置,不能并行。** +2. **`telemetry` 只 gate upload,不 gate 本地 ledger。** `isGranted("telemetry") === false` + 时 `track()` 照常写本地文件——因为本地文件对用户是有用的(`lisa telemetry report` + 回答的是"她这周在干什么",这是产品功能)。想连本地也停 → `LISA_TELEMETRY_LOCAL=0` + 或 `lisa telemetry off`。**这个区分必须在 consent card 的文案里说清楚**, + 否则用户会以为 revoke 了就什么都不记了。 +3. **revoke 时必须丢弃已攒未发的批次**(蓝图 §8.1:*撤回时连队列里攒的一起丢弃*)。 + 具体实现:revoke 时把 `upload-state.json` 的游标推到 ledger 末尾—— + 已产生但未上报的行永远不会被发出去。 + +### 9.3 **不做区域化 consent**(整节推翻蓝图 §8.1) + +蓝图:按地区决定是否需要事先同意,未知地区默认 opt_in(fail-closed), +显式 allowlist(如 US)跳过弹窗,尊重 `Sec-GPC`。 + +**LISA:全球一律 opt-in,不做任何区域判别。** 三条理由: + +1. **技术上办不到**。区域判别需要 IP geo。本地版**根本不出网**,没有 IP 可判; + 等到出网的那一刻做判别,已经晚了(第一次上报本身就是收集)。 +2. **政治上是自杀**。"美国用户默认被收集,欧洲用户需要同意"这句话, + 在一个卖 sovereign / local-first 的产品的 HN 讨论串里就是死刑。 +3. **工程上更简单**。少一套区域表 = 少一处漂移、少一个"未知地区"的边界情况、 + 少一次法务咨询。 + +**代价**:opt-in 率会远低于"US 免弹窗"的方案。**这是刻意付的价**, +并且它把 §7.6 的 opt-in 偏差变成了一个永久的口径约束。 + +### 9.4 soul 只出结构性计数,一个字都不出 + +调研约束原文:*可上报的上限是结构性计数(desire 条数、outcome 分布、value 数量、 +是否有 tampered 标记),任何文本、slug 名、标题都不行。* + +本方案的执行机制**不是纪律,是类型**: +- §3.3 禁 `string`(除一条书面豁免的 semver)。 +- §3.5 的 `SOUL_TOKENS` 让任何名字里带 `desire`/`journal`/`opinion`/`relationship`/`identity`/ + `purpose`/`constitution`/`value`/`emotion`/`memory`/`reflection` 的字段**必须** `upload:"never"`。 +- 唯一的 soul 相关上报事件 `desire_inventory_snapshot` 只有计数与一个 `tampered: boolean` + (来自 `soul.lock.json` 的 SHA256 校验)。 + +**`~/.lisa/soul/` 的读取只能通过一个专用的、只返回数字的函数** +(`src/telemetry/soul-stats.ts`),它不导出任何返回字符串的东西。 +Code review 时只需要看这一个文件,不需要审查每个调用点。 + +### 9.5 App Store 与删除权 + +- **删号真删**:ASC 5.1.1(v)。云版删号 = `sessionVersion` bump + wipe 整个 + `~/.lisa/users//`,**其中包含 `telemetry/`**——因为 §5.5 要求 ledger 写在 + scoped home 里,这一条**自动成立**,不需要额外代码。这是复用 `homeScope` 的直接红利。 +- **upload 侧的删除**:`installId` 是假名化标识符,在 GDPR 意义上**仍是个人数据**。 + 所以必须有 `lisa telemetry forget`:本地删 ledger + 向 upload endpoint 发一条 + `DELETE /insight/{installId}`。**这是 LISA 专属的新增**,蓝图没有对应物 + (Luddi 的删除是从 userId 走的,而这里没有 userId)。 +- **蓝图 §8.3 的 streaming-buffer 日扫**:CF Analytics Engine 的删除语义待确认。 + 如果它不支持按 key 删除,**那就不能用它存 `installId`** ——只能存已聚合的、 + 不含 installId 的日计数。**这条会实质性改变 §6.3 的选型,Phase 2 前必须先确认。** +- **iOS 隐私标签**:见 §0.3 待确认 #6。当前 `PrivacyInfo.xcprivacy` 只声明 + Email + UserID(App Functionality / linked / no tracking)。**不要假设不用改。** + +### 9.6 MIT 开源意味着一切都是公开可读的——把它变成优势 + +调研约束原文:*埋点代码、事件名、endpoint、密钥全部公开可读。用户群正是最会 grep 的那批人。* + +这不能规避,只能**主动利用**: + +- `lisa telemetry events` 直接从 `EVENT_POLICY` 打印全部事件、它们的 upload 策略、 + 和每个的 properties 形状。**用户不需要相信我们,他跑一条命令就能看到。** +- `lisa telemetry preview` 打印**下一批将要发出去的确切 JSON**,一字不差。 +- `LISA_TELEMETRY_DEBUG=1` 把每条 `track()` 打到 stderr。 +- upload endpoint 的地址写在文档里,由用户的 `lisa telemetry on` 写进他自己的 + `config.env`——**不是编译进二进制里**(§2.3)。 + +**这四条加起来是这个方案最强的信任论据**,也是它相对任何 SDK 方案的根本优势: +**可验证性代替可信性**。 + +--- + +## §10 分阶段落地 checklist + +分阶段的核心逻辑(蓝图两条 + LISA 一条): +**告警先于看板**(没人看的数据断了也没人知道); +**类型闸先于事件膨胀**(事件铺开之后再回头补声明表,成本是 10 倍); +**本地台账先于任何出网**(先证明数据有用,再谈上报——如果 Phase 0/1 的本地数据回答不了 +任何问题,那 Phase 2 只是把无用数据搬到了云上,同时赔掉了产品定位)。 + +### Phase 0 — 修前置 + 本地台账 + 代理指标(不出网,不需要改隐私政策) + +**修前置(只有一条,是遥测 consent 的硬地基)** +- [ ] 修 §0.2 发现 B **中的 `src/consent/store.ts:54` 一处**:改成 + `import { lisaHome } from "../paths.js"`。 + **验收**:`LISA_EDITION=cloud` 下两个 uid 各 grant 一个 signal, + `GET /api/consent` 互不影响(新增单测:两个 `homeScope.run()` 里各 `grant("screen")` / + 断言另一个 `isGranted("screen") === false`) +- [ ] 其余七个文件的私有 `lisaHome()`(`sense/log.ts`、`dispatch-ledger.ts`、`control/policy.ts`、 + `web/push.ts`、`mail/{store,accounts}.ts`、`takoapi/ledger.ts`)**只记录,不修**—— + 写进 `docs/TELEMETRY.md` 的"已知问题",附上"不要顺手把 `web/{accounts,devices,otp,sessions-auth}.ts` + 也改了,那四个是进程级的、现状正确"的警告。**验收**:`docs/TELEMETRY.md` 里有这一节 +- [ ] ~~修 §0.2 发现 A~~ —— **撤销。** 已核实 `usage.jsonl` 的窄覆盖是 + `PLAN_ACCOUNTS_BILLING_v1.0.md` §6.3 的设计意图;改它会在云版双计。 + 替代交付物是 `turn_completed` 事件(见下面"首批调用点接入") + +**schema 与治理** +- [ ] `src/telemetry/schema.ts`:§3.4 全部事件的 discriminated union + `EVENT_POLICY` 声明表(§3.2)。 + **验收**:往 union 加一个事件而不声明策略 → `npm run typecheck` 红 +- [ ] `src/telemetry/buckets.ts` 五个 bucket 函数 + 单测(§3.3)。 + **验收**:每个 bucket 函数的单测覆盖**所有边界值两侧**(如 `msBucket(1000)==="1-5s"`、 + `msBucket(999)==="<1s"`)+ 负数/`NaN`/`Infinity` 各返回一个确定值而不是 `undefined` + ——一个返回 `undefined` 的 bucket 会让整条事件的 properties 变成非法形状 +- [ ] `src/telemetry/plan-outcome.ts`:`PlanRunOutcome` 的六值映射纯函数(§3.4 Q6)。 + **验收**:六个分支各一个单测;且断言 `PlanId` 是从 `src/model/plans.ts` import 的 + (改那边加一个 plan 时,这边的 `Record` 应该编译失败) +- [ ] `src/telemetry/schema.test.ts`:禁 `string`、`SOUL_TOKENS`/`CONTENT_TOKENS`/`MONEY_TOKENS` + 分词匹配、声明表自钉死(§3.5)。 + **验收**:把 `plan_run_finished` 的 upload 改成 `event` 且给它加一个 `cwd: string` → 测试红 +- [ ] `src/telemetry/guard.test.ts` 源码扫描三条(§3.7)。 + **验收**:故意写一个 `import posthog from "posthog-node"` → 测试红,然后删掉 +- [ ] `scripts/check-telemetry-events.mjs`(missing / unused / **文档漂移**), + **同一个 PR 里**挂进 `package.json` 的 `check:telemetry` + `prepublishOnly` + CI workflow(§3.6)。 + **验收**:故意加一个 `track("nope", {})` → CI 红 + +**入口与 sink A** +- [ ] `src/telemetry/track.ts`:同步 void 签名、有界 buffer(500)、250ms **`.unref()`** timer、 + **timer 回调整体 try/catch**、`beforeExit`/`SIGINT`/`SIGTERM` drain、 + **入队即钉 `scopedUid()`**(§2.1、§5.5)。 + **验收三条**:① 让 ledger 写入 stub 抛异常 → 进程不退出、下一次 flush 照常 + (§2.1 规则 4);② `node -e 'import("./dist/telemetry/track.js").then(m=>m.track("x",{}))'` + 在 250ms 内自然退出(`.unref()` 生效);③ `lisa "hi"` 后 Ctrl-C, + ledger 里有 `session_ended`(drain 生效) +- [ ] `src/telemetry/ledger.ts`:`appendLine`+`withFileLock`、3000 行 + 30 天双重界、 + **`import { lisaHome } from "../paths.js"`**(§6.1、§5.5)。 + **验收**:两个 `homeScope.run()` 各 track 一条 → 两个文件各一行且互不含对方 +- [ ] `POST /api/insight/event`:六条闸(batch 20 / legacy sniff / `INGRESS_EVENTS` allowlist / + **35 天**时钟窗口 / 60 rpm / **4 KB 真的实现并写测试**),身份字段服务端覆写(§4.3)。 + **验收**:POST 一个 `internal` 事件名 → 400;POST 一个 6 KB properties → 那条被拒、同批其余照收 204 +- [ ] 首批调用点接入:`session_started`/`session_ended`/`cli_command_invoked`/`repl_slash_command`/ + `birth_*`(挂 `soul/birth.ts` 的 `birth()`)/ **`turn_completed`(挂 `agent.ts:307-311`)**/ + `autonomy_run_recorded`(**从 `runs.jsonl` 派生,不新增调用点**,§3.4 Q2 注)。 + **验收**:`lisa "hi"` 一次 → ledger 里恰好有 `session_started` ×1 + `turn_completed` ×1 + + `session_ended` ×1;`lisa heartbeat run` 一次 → `turn_completed` 的 `surface` 是 + `"autonomous"`(§7.3 的活跃口径依赖这个值是对的) +- [ ] `repl_slash_command` 的插件名折叠:喂一个名为 `deploy-acme-prod` 的假插件命令, + **验收**:上报的是 `"plugin"` 而不是那个名字(§3.4 Q8 注) + +**用户面与自检(这是本地版的"告警")** +- [ ] `src/cli/telemetry.ts` + `lisa telemetry` 子命令: + `status` / `events` / `preview` / `report` / `on` / `off` / `forget`(§9.6)。 + **验收**:`lisa telemetry events` 的输出行数 == `Object.keys(EVENT_POLICY).length` + (一个断言这件事的单测);`lisa telemetry preview` 在 upload 未开启时打印 + "(upload disabled — nothing would be sent)" 而不是空 +- [ ] `lisa doctor` 增加遥测自检:ledger 最近 24h 行数、`telemetry_sink_failed` 计数、 + upload 上次成功时间(§8.1、§8.2)。 + **验收**:`chmod 000 ~/.lisa/telemetry` 后跑 `lisa "hi"` → 聊天正常完成, + 随后 `lisa doctor` 报出一条 `telemetry_sink_failed{code:"eacces"}` +- [ ] `docs/TELEMETRY.md`:全部事件逐条列出(**从 `EVENT_POLICY` 生成,不手写**)+ 口径规则 + (§7.1 两种身份单位不相加 / §7.2 `homeIsDefault` / §7.3 autonomous 不算活跃 / + §7.4 三个 token 台账不相加 / §7.5 `no-update` 不是失败 / §3.4 Q7 的 email≡google 同 uid / + §3.4 Q8 的 Mail 不在 `surface_opened` 里)+ §5.4 的"刻意不建桥"盲区声明 + + §0.2 发现 B 的六处未修记录。 + **验收**:`scripts/check-telemetry-events.mjs --check` 在这个文件与 `EVENT_POLICY` + 不一致时红(故意删掉一行事件表确认) +- [ ] `README.md` 加遥测小节(**Phase 0 阶段的措辞是"全部留在你的机器上,没有上报"**)。 + **验收**:措辞里出现 `~/.lisa/telemetry/events.jsonl` 这个具体路径和 + `lisa telemetry events` 这条具体命令——**"我们只收集匿名使用数据"这种句子在这个用户群里 + 等于没写** + +**L2 代理指标(与上面并行,零依赖,今天就能开工)** +- [ ] **给已有的 `scripts/star-history.sh` 加 `schedule: cron` 的 workflow**(不是新写脚本—— + 它已经存在,只是从没被调度过,`docs/star-history.csv` 至今只有 2026-05-09 一行,§6.2)。 + **验收**:连续三天后 CSV 有三行新增,日期一一对应 +- [ ] 扩这个 workflow:npm downloads(总量 + `/versions` 按版本)、 + GitHub release 各资产 `download_count`、`/traffic/{views,clones,popular/referrers}` + (**14 天窗口,必须每天抓**;traffic API 需要带 repo 权限的 token,与其余几项不同)。 + **验收**:CSV/JSON 里出现这四组列,且缺 token 时 workflow **明确失败**而不是静默跳过 + ——静默跳过就是又一个"存在但没在跑"的防线 +- [ ] Cloudflare Pages 分析开启并记录基线(**不加任何页面脚本**)。 + **验收**:截图/记下开启当天的日请求量作为基线,写进 `docs/TELEMETRY.md` +- [ ] **代理指标地板**:npm 周下载环比 −50% 或 release 下载数连续 3 天零增长 → 一条 Actions 告警(§8.1)。 + **验收**:把阈值临时调到必然触发的值(如 −0.1%),确认告警真的响,然后调回来 + ——蓝图 §2.3/§2.4 两次强调的"故意违规一次"在这里同样适用 + +### Phase 1 — 回答 Q7(云版,服务端权威,天然全量) + +- [ ] `[C]` 五个事件接入,注入点已定位到分支级(§3.4 Q7): + `quota.ts` 的 `liveWindow()` 开窗分支(`:230-236`)、`precheckTurn()` 的 + `quota_exhausted`(`:277-279`)与 `premium_requires_balance`(`:271-275`)两个分支、 + `stripe.ts` webhook 与 `iap.ts` 的入账处、`web/accounts.ts` 的 uid 生成处。 + **验收**:枚举全部 `import type` 自源码(`QuotaTier` / `AccountKind` / + `PrecheckResult["error"]` / `STRIPE_PACKS` 的键),**schema.ts 里一个字面量都不手抄** +- [ ] 云版 sink 失败 → Cloud Run 结构化日志 → logs-based metric → 告警策略; + **日志字符串写进注释"改这个字符串必须同步改 metric filter"**(§8.2)。 + **验收**:故意让一次写入失败,确认告警在 5 分钟内响 +- [ ] 改 `cloud.astro` **与 `zh-CN/cloud.astro` 两页**:明说云版记录配额消耗事件(§9.1)。 + **验收**:两页都改并部署(中文页最容易漏,见 §8.5) +- [ ] `lisa billing` / 内部看板读出 Q7 的三个数:耗尽率 / 24h 转化率 / premium 被挡次数。 + **验收**:报表上显式标注"kind 分布是**首次创建 uid** 的 kind,email 与 google 共用 uid" + (§3.4 Q7 的口径陷阱) +- [ ] **用这三个数回测 `FREE_WINDOW_FULL`(=5_000_000 微美元)、`FREE_WINDOW_UNVERIFIED` + (=1_000_000)与 1.4× margin**——这是 Phase 1 的唯一验收标准。 + **验收**:给出"改 / 不改"的书面结论 + 依据的三个数,而不是"数据看起来还行" + +### Phase 2 — opt-in upload(必须先改政策) + +**改政策(第一交付物,代码之前)** +- [ ] `{privacy, cloud, index}.astro` × `{en, zh-CN}` 六页 + `README.md` **七处**改完并**已部署**。 + **验收**:`scripts/check-telemetry-events.mjs --check` 在七处都同步之前必须是红的 + (先改代码后改文档时它就该拦住你);部署后人工访问 meetlisa.ai 的中英两版隐私页各一次 +- [ ] `PrivacyInfo.xcprivacy` 更新 + ASC App Privacy 答案更新 + **提审通过**(§0.3 #6) +- [ ] 确认 sink 的删除语义(§9.5)——**如果不支持按 installId 删除,改选型,不要将就** + +**代码** +- [ ] `consent/store.ts` 加 `telemetry` signal + 描述文案(§9.2); + **验收**:`lisa consent revoke-all` 之后 upload 停且未发批次被丢弃 +- [ ] `src/telemetry/identity.ts`:`installId` 从 `seed.randomness` 派生。 + **验收**:单测断言 `installId !== seed.bornOn` 且不含 hostname/username 的任何子串(§5.1) +- [ ] `src/telemetry/upload.ts`:6h 批次、`occurredAt`+`receivedAt` 双字段、 + `telemetry_batch_sent{seq}`、失败**不重试不回队**、`upload-state.json` 游标 +- [ ] upload endpoint(CF Worker 倾向,§6.3)+ 两层硬上限 + **账单预算含 `EXCLUDE_ALL_CREDITS`**(§8.6) +- [ ] 送达率对账(§8.3) +- [ ] `lisa telemetry forget` + 服务端 `DELETE /insight/{installId}`(§9.5) + +**上线后一个月内(不要跳过,蓝图两次强调"上线后实测")** +- [ ] 实测 opt-in 率 —— 如果 < 5%,L3 的一切结论都要打上"极强样本偏差"的标签, + 并重新评估 Phase 2 是否值得维护 +- [ ] 实测到达延迟 P95 → **回测并重调 T-7 阈值**(§7.6) +- [ ] 实测逐事件日量 → **回测并重调 §8.4 的 50 行门槛**,然后才建 per-event 回归检测 +- [ ] 实测事件集中度 → 决定要不要采样(**预期答案是"不采"**,§8.6) + +### 长期纪律(没有完成态) + +- 新事件三件套:union arm + `EVENT_POLICY` 声明 + (若命中敏感 token)书面豁免理由。 +- **改任何 `upload !== "never"` 的事件之前,先看 §8.5 的六处一致性(含三份中文页)**—— + 漏改一处 = privacy.astro 变成假话。 +- 能从既有台账(`runs.jsonl` / `usage.jsonl` / `sense/events.jsonl` / `advisor-state.json`) + 派生的指标,**永远不要新增调用点**——调用点会漂移(§0.2 发现 B 的八处私有 `lisaHome` 就是活标本),派生器不会。 +- 任何"顺手把 installId 和 uid 关联一下"的 PR **直接拒**,并指向 §5.4。 +- 每次 `Surface` / `View` / `CliCommand` 枚举变更 = 一次全仓下游过滤搜索 + (蓝图案例 B 的三周静默漏计)。 + +--- + +## 附录 A:蓝图中不适用于本项目的条目及理由 + +按蓝图节号排列。**这一节比照抄适用的部分更有价值**——它记录了"为什么不照做", +免得半年后有人拿着蓝图来问"你们怎么少了这几节"。 + +| 蓝图节 | 结论 | 理由 | LISA 的替代做法 | +|---|---|---|---| +| **§1.2 双 sink 彼此解耦** | **形态反转**:LISA 的两个 sink 是**串联**不是并列 | 本地优先产品必须保证离线用户的可观测性不比在线用户差;且串联让 opt-in / opt-out 用户跑同一条代码路径,少一处"区别对待"的指控面 | 本地 ledger 是唯一真相源,upload 从它读。**代价**:ledger 写失败 = 两个 sink 一起哑(接受,见 §2.2) | +| **§2.3 ESLint 禁裸调** | **完全不适用** | 仓里**根本没有 ESLint**(实测:无 `eslint.config.*`、无 `.eslintrc*`、devDeps 无 eslint)。为一条规则引入 ESLint + `@typescript-eslint/parser` 违反"8 个生产依赖 / 手写一切"的技术偏好 | `src/telemetry/guard.test.ts` 用 `node:fs` + 正则扫源码,走已有的 `npm test`(§3.7)。踩坑等价物是"正则从没匹配到任何东西也是绿的",验收方法照抄:故意违规一次确认变红 | +| **§2.1 四值 emitter(防伪造)** | **维度整体替换** | 本地版没有伪造威胁模型——客户端就是用户自己的机器,伪造自己磁盘上的 JSONL 无收益无受害者 | 换成 `ingress`(能不能从 HTTP 进)+ `upload`(能不能出网、什么粒度)+ `editions`。fail-closed 的**机制**(Record over 字面量 union)原样保留,**语义**全换(§3.2) | +| **§2.5 敏感 token 表** | **token 表整个换掉** | Luddi 防"金钱事件被伪造";LISA 的第一威胁是 **soul 内容外泄**,第二是用户环境泄漏,钱只排第三且只在云版成立 | `SOUL_TOKENS` + `CONTENT_TOKENS` + `MONEY_TOKENS` 三套,前两套 gate `upload`,第三套 gate `ingress`(§3.5)。分词匹配的纪律照抄 | +| **§2.2 三端 chokepoint** | **裁到两个** | Swift 两端(mac / iOS)已经通过 HTTP 跟本地 Node 服务讲话,让它们 POST `/api/insight/event` 即可 | Node 一个 + 浏览器一个。Swift 侧零埋点代码、iOS 隐私标签影响最小化。**代价**:backend 未启动时 mac app 的事件丢失(接受,§4.2) | +| **§3.2 防伪造 allowlist** | **本地版不适用,云版适用且更严** | 同上 | 本地:allowlist 只用来挡 `internal` 事件的误发(一个 400 而已)。云版:`[C]` 的五个钱/配额事件强制 `internal`,由测试钉死(§3.5 规则 3) | +| **§3.3 时钟 clamp 24h** | **窗口必须放大到 35 天** | Luddi 移动端最多离线几天;LISA 是可能断网数周的笔记本 + 6h 上报节拍 + 30 天 ledger。24h 窗口会把离线批次整批重打成"送达日",**直接毁掉 Q1 的留存口径** | `occurredAt` + `receivedAt` 双字段并存;留存/cohort 用前者,管线健康用后者;窗口 = ledger 保留期 + 5 天余量(§4.4) | +| **§4.1 自持 anon ID(新铸 UUID)** | **规则成立,锚点换掉** | 产品承诺 "no account of any kind",在 `~/.lisa/` 里新增一个持久化标识符文件本身就是可被截图的把柄 | 从既有 birth 产物 `seed.randomness` 派生(§5.1)。**并新增一条蓝图没有的红线:`bornOn` 绝对不可外发**——`sha256(hostname+username)` 的取值空间小到可离线穷举 | +| **§4.2 (userId\|anonId) DB CHECK 约束** | **无数据库,无从约束** | Phase 0/1 是 JSONL 文件 | 用类型钉死:upload envelope 的身份字段是 `{uid: string} \| {installId: string}` 的 union,两者互斥(§5.3) | +| **§4.3 AnonIdentityLink 桥** | **整节否决** | 建桥 = 把"这台机器"和"这个邮箱"关联,直接让 `index.astro` 的 "no account of any kind" 变成假话 | **不建。** 代价(本地→云版转化漏斗永久不可观测)记为已知盲区并写进 `docs/TELEMETRY.md`,标注"不要事后顺手补上"(§5.4) | +| **§4.4 platform 归类优先级** | 大幅简化 | 没有 UA 需要解析——`os.platform()` 就是权威,`Surface` 由调用点显式给 | `install_daily_ping.os` 直接取 `process.platform`;`Surface` 是枚举参数 | +| **§4.5 identity_grade 三级** | **不适用** | 没有 JWT / 没有 `X-Distinct-Id` 断言身份这一形态。云版是 HMAC bearer(verified),本地是无身份 | 不建。若将来 `/api/insight/event` 开放给非回环调用,再重新评估 | +| **§5.1 双 sink 保留期 730/400 天** | **保留期整个缩短** | 730 天在一个"零遥测"定位的产品的隐私政策里写不出来;且本地 ledger 跑在**用户自己的磁盘**上,不是数据仓 | 本地 30 天,upload 180 天(§6.1、§6.3) | +| **§5.2 热表白名单 + 双写门控** | **不适用(Phase 0/1 无第二个 sink)** | — | Phase 2 的 `UPLOADABLE_EVENTS` 承担类似角色,但机制是"upload 是 ledger 的子集"而非两张表 | +| **§5.3 `source` 列消歧双端事件** | **不适用** | LISA 没有双端发射同名事件的形态(§4.2 已把 Swift 端的事件收敛到同一个 Node 入口,只有一个副本) | 无需 dedup 视图。**但如果将来 Swift 端建了自己的 sink,这条立刻恢复适用** | +| **§5.4 `insertId` 幂等** | Phase 2 才需要 | JSONL append 天然无重复 | upload envelope 带 `(installId, seq, lineOffset)` 三元组做服务端去重 | +| **§6.1 `COUNT(DISTINCT userId)`** | **本地版没有 userId** | 调研的关键结论 | 本地 `installId`、云版 `uid`,**两者永不相加**(因为 §5.4 不建桥所以也无法去重)(§7.1) | +| **§6.2 分端曝光/游玩口径** | **不适用** | 没有信息流、没有曝光、没有 billing_unit | 换成 LISA 自己的三个易错口径:autonomous vs human 活跃(§7.3)、两个台账覆盖面不同(§7.4)、`no-update` 不是失败(§7.5) | +| **§6.3 口径固化为 SQL 视图** | **Phase 2 之前无数据库,无处建视图** | — | 固化进 `lisa telemetry report` 的代码(§7.7)。Phase 2 后 SQL 侧再编一遍,两处注释互相指向 | +| **§6.4 比率切 T-2** | **规则成立,数值换成 T-7** | 上报滞后长一个量级(6h 批次 + 数周离线) | T-7,且**上线一个月后用实测到达曲线回测重调**(§7.6) | +| **§7.1 Ingestion floor** | **本地版不适用** | 入口是函数调用,发生在用户机器上,我们看不见 | 两个替代:`lisa doctor` 自检(本地)+ L2 代理指标地板(npm/GitHub,Phase 0 就有)(§8.1) | +| **§7.2 Sink 失败告警** | **本地版无告警通道** | 没法给用户的机器发告警 | 自记 `telemetry_sink_failed` + `lisa doctor` 呈现 + 云版走 logs-based metric(§8.2) | +| **§7.3 双 sink 日对账** | **Phase 0/1 不适用** | 只有一个 sink | Phase 2 变成"送达率对账"(`seq` 连续性 + 行数比),串联架构让它天然可做(§8.3) | +| **§7.4 Per-event 量级回归** | **有最低样本量前置** | 200 opt-in 安装下,低频事件一天 5 行,5→2 是噪声不是回归;天天误报的检测器等于没有 | 日均 < 50 行的事件不参与;**Phase 2 上线一个月后用实测日量定门槛再建**(§8.4) | +| **§7.5 告警 YAML drift check** | 只在云版有意义 | 本地版没有云端告警策略 | 云版沿用;**本地版的等价物是新增的第五件套**(§8.5) | +| **§7.6 采样** | 结论相同(不采),量级差两个数量级 | — | 同蓝图:先测集中度再谈采样 | +| **§8.1 区域化 consent** | **整节推翻** | ①本地版不出网,没有 IP 可判区域;②"US 用户默认被收集"在这个产品的 HN 讨论串里是死刑;③少一套区域表少一处漂移 | **全球一律 opt-in。** 代价(opt-in 率低、样本偏差大)刻意付(§9.3) | +| **§8.2 平台 consent 差异成文** | **规则成立,差异内容不同** | Luddi 的差异是 web 有 gate / mobile 没有 | LISA 的差异是:**本地 ledger 无 gate(不出网)/ upload 有 gate / 云版 `[C]` 事件无 gate(服务端自观测)**。三者的裁决与重估触发条件写进 `docs/TELEMETRY.md`(§9.1、§9.2) | +| **§8.3 GDPR streaming-buffer 日扫** | **本地版不适用;upload 侧要新增一条蓝图没有的** | 本地删除 = `rm -rf ~/.lisa`,用户自己就能做 | 云版删号自动带上 `telemetry/`(复用 `homeScope` 的红利);upload 侧新增 `lisa telemetry forget` + `DELETE /insight/{installId}`(§9.5) | +| **§8.4 身份特征不入仓** | **规则成立但更严** | Luddi 禁的是 email/生日/精确位置;LISA 要禁的是**几乎所有自由文本** | 用类型禁掉整个 `string` 类型,而不是逐字段判断敏感性(§3.3)——逐字段判断在这个产品里是一场必输的战争 | +| **§9 三个事故案例** | 案例 A/B/C 的**形态**在 LISA 不会重演 | 无 PostHog、无信息流曝光、无双管线 CTR | 但它们的**教训**全部保留:A→§8.1 地板;B→§3.3 枚举收敛与改名的下游搜索;C→§7.6 到达滞后。**LISA 的一号事故形态是蓝图里没有的第四种:承诺与代码漂移被公开发现**,对应新增的 §8.5 | + +--- + +## 附录 B:新增文件清单(便于 review 时按图索骥) + +| 路径 | 作用 | 阶段 | +|---|---|---| +| `src/telemetry/schema.ts` | 事件 union + `EVENT_POLICY` 声明表 + 派生集合 | P0 | +| `src/telemetry/buckets.ts` | 五个分桶纯函数 | P0 | +| `src/telemetry/track.ts` | **唯一入口**,同步 void,有界 buffer | P0 | +| `src/telemetry/ledger.ts` | Sink A,本地 JSONL(**必须 import `paths.js` 的 `lisaHome`**) | P0 | +| `src/telemetry/soul-stats.ts` | soul 的**只返回数字**的读取面 | P0 | +| `src/telemetry/report.ts` | `lisa telemetry report` 的口径实现(§7.7) | P0 | +| `src/telemetry/schema.test.ts` | 敏感命名 + 禁 string + 声明表自钉死 | P0 | +| `src/telemetry/guard.test.ts` | 源码扫描(ESLint 的替代物) | P0 | +| `src/cli/telemetry.ts` | `lisa telemetry` 子命令 | P0 | +| `scripts/check-telemetry-events.mjs` | CI 审计 + **六处文档一致性**(含 zh-CN 三页,§8.5) | P0 | +| `docs/TELEMETRY.md` | 对外事件清单 + 口径规则 + 盲区声明 | P0 | +| `.github/workflows/proxy-metrics.yml` | L2 代理指标每日快照 | P0 | +| `src/telemetry/identity.ts` | `installId` 派生(**不用 `bornOn`**) | P2 | +| `src/telemetry/upload.ts` | Sink B,批量 HTTPS POST | P2 | + +**改动的既有文件**(Phase 0): + +| 文件 | 改什么 | +|---|---| +| `src/consent/store.ts` | 改 `lisaHome` 来源(§0.2 发现 B)+ 加 `telemetry` signal(§9.2) | +| `src/agent.ts` | turn 出口发 `turn_completed`(`:307-311`)。**不动 `recordUsage`**(§0.2 已更正) | +| `src/cli.ts` | `cli_command_invoked`(子命令分发处)、`repl_slash_command`(`onSlash` @ `:855`)、`session_started`/`session_ended` | +| `src/cli-args.ts` | `cli_flag_used`;`CliCommand` 从 `ParsedArgs["subcommand"]` 派生 | +| `src/soul/birth.ts` | `birth_started`/`birth_completed`(`birth()` @ `:110`,不是 `cli.ts` 的两个 ceremony 调用点) | +| `src/web/server.ts` | 新路由 `POST /api/insight/event`;`advisor_card_dismissed`(`:2366`);`idle_message_engaged{dismissed}`(`:2299`) | +| `src/web/lisa-client.ts` | GUI chokepoint `lisaTrack()`;`surface_opened` 挂 `showView` @ `:3224`;`idle_message_engaged` @ `:494` | +| `src/web/room.ts` / `src/web/island.ts` | 复用 `lisaTrack`,各挂一处 `idle_message_engaged`(`:1064` / `:1237`) | +| `src/web/lisa-html.ts` | 注入 `window.__LISA_INGRESS`(客户端 allowlist,§4.2) | +| `src/advisor/engine.ts` | `advisor_card_surfaced`(`AdvisorDecision.surface` 出口) | +| `src/integrations/hub.ts` | `observer_scan_completed` / `observer_enabled_changed` | +| `src/cli/doctor.ts` | 遥测自检(§8.1、§8.2) | +| `.github/workflows/ci.yml` | 加第四步 `npm run check:telemetry`(前三步已在) | +| `README.md` | 遥测小节 | +| `package.json` | `check:telemetry` script + 挂进 `prepublishOnly` | + +**关于 `contracts/lisa-api-v1.openapi.json`——前一稿的"`npm run check:api-contract` 会强制" +是假的,特此更正。** 实测:该契约只覆盖 8 条路径(`/chat`、`/events`、`/api/sessions`、 +`/api/sessions/{id}/activate`、`/api/agents/sessions`、`/api/dispatch/{list,status}`、 +`/api/island/ping`),而 `scripts/generate-api-contract.mjs --check` **只比对两个生成文件** +(`src/web/api-contract.generated.ts` 与 `packaging/…/APIContract.generated.swift`) +是否与契约的 `x-lisa-api-major` / 版本头一致,**stale 才 `exitCode = 1`**。 +它**不会**、也从未打算审计"server.ts 里有没有路由没进契约"——server.ts 里有近百条 +`/api/*` 路由,进契约的只有 8 条。 + +**所以 `/api/insight/event` 进不进契约是一个要自己做的决定,不是 CI 会替你做的。** +本方案的建议是**进**,因为 mac app 与 iOS Pocket 都会 POST 它(§4.2), +而 TestFlight 的发版节奏意味着**旧客户端会用旧形状发很久**——这正是契约存在的理由。 +但要明白它换来的是什么:契约给的是版本号协商, +真正保护旧客户端的是 §4.3 那条"legacy 单事件体 sniff + 入口协议只能加不能破"。 +**契约是文档,sniff 才是防线。** 两者都要,别把前者当成后者。 From 23b046d8e232caab8600d327481a9a43be971e17 Mon Sep 17 00:00:00 2001 From: oratis Date: Fri, 21 Aug 2026 12:29:57 +0800 Subject: [PATCH 09/15] =?UTF-8?q?docs(analytics-plan):=20reconcile=20?= =?UTF-8?q?=C2=A70.1=20facts=20with=20the=20fixes=20in=20this=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the plan doc was written against the tree before these fixes, and three of its stated facts are no longer (or never were) true. left alone, the PR would ship a design doc contradicting the code in the same PR. - §0.1 said bornOn is "私有、从不外发" (private, never leaves the machine). it did leave: the whole seed was serialized into the birth prompt. now corrected to record what was actually true, what this PR fixed, and what is still open (GET /api/soul still serves the full seed). this makes §5.1's "never use bornOn as an identifier" red line better founded, not weaker. - §0.1 and §3.4-Q4 said advisor has 6 categories. it has 5 — repeated_failure was dead and is deleted here. - Phase 0's consent item now notes that this PR added only the route-level mitigation, so nobody reads the checkbox as already done: the underlying cross-tenant consent.json is untouched and is still a hard prerequisite for hanging telemetry consent off it. --- docs/analytics-plan-2026-08-21.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/analytics-plan-2026-08-21.md b/docs/analytics-plan-2026-08-21.md index 8084027..b0f98dd 100644 --- a/docs/analytics-plan-2026-08-21.md +++ b/docs/analytics-plan-2026-08-21.md @@ -49,9 +49,9 @@ no third-party tracking SDKs"。在这个产品里,**打点体系的第一个 | 统一 consent gate 已存在,默认全关、缺省即拒绝、corrupt 即拒绝 | `src/consent/store.ts` 的 `isGranted()` | | 多租户隔离靠 `homeScope` AsyncLocalStorage,不是 `WHERE uid=` | `src/paths.ts` | | SSE 扇出的租户规则是 `sameTenant(subscriberUid, originUid)`,是一次真实跨租户泄漏的修复 | `src/web/event-bus.ts:39-44` | -| `bornOn = sha256(hostname + username)`,私有、从不外发 | `src/soul/birth.ts` 的 `generateSeed()` | +| `bornOn = sha256(hostname + username)`。**「从不外发」已被证伪并部分修复**:`dreamSoul()` 原先把整个 seed(含 `bornOn`)序列化进出生提示词发给模型提供商,本 PR 已加 `seedForPrompt()` 剥掉它;但 `GET /api/soul` 至今仍把完整 seed 发给已认证客户端(iOS 伴侣 app 就在读它)。所以 §5.1 的红线依然成立且更有必要 | `src/soul/birth.ts` 的 `generateSeed()`、`seedForPrompt()`;`src/web/server.ts` 的 `/api/soul` | | 观测者不是 5 个而是 **10 个**目录:claude-code / codex / opencode / aider / github-pr / git / shell / takoapi / managed / pty。默认开三个:`claude-code`、`managed`、`pty`——后两个"在静止时不增加任何东西"(`managed` 只反射进程内 registry;`pty` 需要 `LISA_PTY_AGENTS=1` 才非空),**所以 Q5 的分母天然只有一个真观测者** | `src/integrations/`、`hub.ts:28-65` 的 `DEFAULT_ORCHESTRATOR_CONFIG` | -| advisor 有 **6 个** category(`stuck`/`conflict`/`repeated_failure`/`cost_spike`/`ready`/`idle`)与已存在的 `categoryDismissals` 计数 | `src/advisor/types.ts` | +| advisor 有 **5 个** category(`stuck`/`conflict`/`cost_spike`/`ready`/`idle`)与已存在的 `categoryDismissals` 计数。**原为 6 个**:`repeated_failure` 从来没有任何 detector 发出过,连同同样从未被读写的 `errorCommandCounts` 一起已在本 PR 删除,并加了双向断言防止再漂回来 | `src/advisor/types.ts` 的 `SUGGESTION_CATEGORIES`、`src/advisor/advisor.test.ts` | ### 0.2 本次调研新发现的两条事实(会直接改变取数方案,必须先解决) @@ -460,7 +460,7 @@ desire slug 会泄漏"这个人在学 Rust / 在处理离婚"(调研约束原 | `advisor_card_acted` | web-ui,native / ingress / event | `{ category, actionKind }` | **Q4 分子(采纳)** | | `advisor_card_dismissed` | core / **internal** / event | `{ category, categoryDismissals: number(≤100) }` | **Q4 分子(屏蔽)**;`categoryDismissals` 直接取 `AdvisorState.categoryDismissals[cat]` | -> 三个枚举全部复用 `src/advisor/types.ts` 的既有类型(`SuggestionCategory` 6 值 / `Urgency` 3 值 / +> 三个枚举全部复用 `src/advisor/types.ts` 的既有类型(`SuggestionCategory` **5** 值 / `Urgency` 3 值 / > `SuggestedAction["kind"]` 6 值),一个字都不新造。**`Suggestion.text`、`Suggestion.id`、 > `SuggestedAction.label` 和 `SuggestedAction.arg` 绝不上报**——`id` 是"稳定 dedup key"、 > `arg` 的注释原文就是 *(sessionId, cwd, …)*,两者都会直接携带路径。 @@ -1438,6 +1438,9 @@ Code review 时只需要看这一个文件,不需要审查每个调用点。 **修前置(只有一条,是遥测 consent 的硬地基)** - [ ] 修 §0.2 发现 B **中的 `src/consent/store.ts:54` 一处**:改成 `import { lisaHome } from "../paths.js"`。 + **注意本 PR 只加了路由层缓解**(`/api/consent/` 进了 `CLOUD_DENIED_ROUTE_PREFIXES`, + 云版这几个路由现在一律 403),**底层的跨租户共享文件没有修**——遥测 consent 要挂上去, + 这一条仍然是硬前置。 **验收**:`LISA_EDITION=cloud` 下两个 uid 各 grant 一个 signal, `GET /api/consent` 互不影响(新增单测:两个 `homeScope.run()` 里各 `grant("screen")` / 断言另一个 `isGranted("screen") === false`) From de3d76f62262ec0a86ff58ae939082392cea2450 Mon Sep 17 00:00:00 2001 From: oratis Date: Fri, 21 Aug 2026 12:47:53 +0800 Subject: [PATCH 10/15] test: widen the no-telemetry guard to every shipped directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the guard added in 7df0048 scanned src/, website/src/ and the iOS companion Sources only. website/public/ was not among them, and that is the one gap that matters most: astro copies public/ to the site root verbatim, so a tracker dropped there ships to the very site carrying the "no telemetry" promise without ever passing through a .astro file. verified before this commit — a googletagmanager snippet written to website/public/analytics.js left all 7 tests green. scan packaging/ whole rather than just ios-companion/Sources (the mac client, launcher and gcp-relay ship too), and add scripts/, deploy/, contracts/ and completions/ so the guard covers the same surface the promise was originally verified against. all six added roots are currently clean, so this is coverage, not a fix — 111 extra files, no measurable runtime cost. also skip build/, .build/, .swiftpm/ and DerivedData/, which appear under packaging/ once anyone builds the native clients locally, and pin the reach of the guard with a test asserting every SCAN_ROOTS entry still exists — a renamed directory now fails loudly instead of silently shrinking coverage. Co-Authored-By: Claude Fable 5 --- src/no-telemetry.test.ts | 62 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/src/no-telemetry.test.ts b/src/no-telemetry.test.ts index 49b8e38..a5953e1 100644 --- a/src/no-telemetry.test.ts +++ b/src/no-telemetry.test.ts @@ -70,8 +70,31 @@ const TRACKER_TOKENS = [ "newrelic", ]; -/** Directories whose contents ship to a user, in one form or another. */ -const SCAN_ROOTS = ["src", "website/src", "packaging/ios-companion/Sources"]; +/** + * Directories whose contents ship to a user, in one form or another. + * + * `website/public` matters as much as `website/src`: Astro copies it to the + * site root verbatim, so a tracker dropped there reaches production without + * ever passing through a `.astro` file. Guarding only `website/src` would let + * the single most convenient hiding place for a tracking snippet stay green — + * on the very site that carries the promise. + * + * `packaging` is scanned whole rather than just the iOS companion's Sources: + * the mac client, launcher and gcp-relay ship to users too. `scripts`, + * `deploy`, `contracts` and `completions` round this out to the same surface + * the promise was originally verified against; all are small and currently + * clean, so the cost is a few dozen extra file reads. + */ +const SCAN_ROOTS = [ + "src", + "website/src", + "website/public", + "packaging", + "scripts", + "deploy", + "contracts", + "completions", +]; /** * The pages that carry the promises, listed by path on purpose. @@ -92,7 +115,20 @@ const PROMISE_PAGES = [ "website/src/pages/zh-CN/cloud.astro", ]; -const SKIP_DIRS = new Set(["node_modules", "dist", ".git", "assets"]); +// Build output is generated from sources we already scan, so reading it adds +// no coverage — only noise and time. `.build`/`.swiftpm`/`DerivedData` are +// Swift/Xcode artifacts that appear under packaging/ once anyone builds the +// native clients locally. +const SKIP_DIRS = new Set([ + "node_modules", + "dist", + ".git", + "assets", + "build", + ".build", + ".swiftpm", + "DerivedData", +]); function walk(dir: string, out: string[] = []): string[] { let entries: fs.Dirent[]; @@ -141,6 +177,26 @@ describe("no telemetry — the promise on the website stays true", () => { assert.deepEqual(trackersIn("normalize the amplitude of the waveform"), []); }); + test("every scanned root still exists (coverage cannot silently shrink)", () => { + // SCAN_ROOTS is the whole reach of this guard. If a directory is renamed or + // moved and this list is not updated, the walk would skip it in silence and + // the suite would stay green over unscanned shipping code. Fail loudly + // instead. `website/public` is called out because it is the gap this list + // was widened to close: Astro copies it to the site root verbatim. + for (const root of SCAN_ROOTS) { + assert.ok( + fs.existsSync(path.join(REPO_ROOT, root)), + `SCAN_ROOTS lists "${root}" but it does not exist — update the list, ` + + "and make sure the code that moved is still covered", + ); + } + assert.ok( + SCAN_ROOTS.includes("website/public"), + "website/public ships verbatim to the site that carries the promise; " + + "it must stay in SCAN_ROOTS", + ); + }); + test("every promise page still exists", () => { // A rename must break this test, not quietly reduce coverage. for (const rel of PROMISE_PAGES) { From d5194da7f4be3fddb14da39c65370d950b64f4be Mon Sep 17 00:00:00 2001 From: oratis Date: Fri, 21 Aug 2026 12:48:04 +0800 Subject: [PATCH 11/15] docs(analytics-plan): drop absolute local paths from the plan header the doc's header cited a source document by absolute path (/Users/.../analytics-blueprint.md) and described its own path convention in terms of an absolute checkout location. this repo is public, so both are local-machine detail that means nothing to a reader and needlessly names a directory layout. the PR body flagged the first one as a follow-up; the second was missed. neither reference is load-bearing: the source is not published with this repo, and the path convention is simply "relative to the repo root". Co-Authored-By: Claude Fable 5 --- docs/analytics-plan-2026-08-21.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/analytics-plan-2026-08-21.md b/docs/analytics-plan-2026-08-21.md index b0f98dd..a8d83a0 100644 --- a/docs/analytics-plan-2026-08-21.md +++ b/docs/analytics-plan-2026-08-21.md @@ -1,10 +1,10 @@ # LISA 打点建设方案(2026-08-21) -> **方法论来源**:`/Users/oratis/Documents/Claude/analytics-blueprint.md`(从 Luddi 生产事故提炼的可移植手册)。 +> **方法论来源**:一份内部的可移植打点手册(未随本仓库发布)。 > 本文不是那本手册的改写版——Luddi 是 web+mobile 的 C 端 SaaS,LISA 是一个**公开承诺零遥测的 > 本地优先 CLI/常驻进程**。手册里有整整数节在这里是**反向**成立的,见文末《附录 A:蓝图不适用条目》。 > -> 所有路径均为 `/Users/oratis/Documents/LISA` 仓库相对路径,符号名可搜索,行号会漂移。 +> 所有路径均为本仓库根目录的相对路径,符号名可搜索,行号会漂移。 > 本文中所有"新建"文件都还不存在——这是设计,不是现状描述。 --- From 0bdd4f30631a1a0751117a8d2225017d9148d568 Mon Sep 17 00:00:00 2001 From: oratis Date: Thu, 27 Aug 2026 15:51:28 +0800 Subject: [PATCH 12/15] fix(cloud): deny /api/push at the hosted boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same defect class as the /api/consent entry directly above it, found by sweeping the rest of the route table for the same shape. src/web/push.ts resolved ~/.lisa with its own private lisaHome(), outside the per-uid homeScope in src/paths.ts, and a PushSubscription carries no owner field. The hosted edition runs one container with one LISA_HOME (deploy/Dockerfile) and isolates tenants purely by entering homeScope.enterWith(homeForUid(uid)) per request — so push.json was one shared file, and none of the five /api/push routes sat behind denyRemote, a loopback check, or an owner check. Any signed-in tenant could GET /api/push/list and read every other tenant's ntfy topic (which IS the send/read secret for that channel) and APNs device token, unregister another tenant's device, or rewrite their prefs. Verified by running the real modules under two uid scopes: tenant B saw tenant A's subscription and could unregister it. The store stays machine-wide on purpose — every producer wired to PushBridge (agent activity, idle notes, mail digest, KB brief, billing anomalies) is a host-level concern, not a per-tenant one, so this is an operator channel and there is no per-tenant push to preserve. What was wrong is that tenants could reach it. So: deny the routes in cloud, and say lisaGlobalHome() out loud in push.ts instead of re-deriving ~/.lisa, which made a deliberate choice look like an accidental scope bypass. Co-Authored-By: Claude Opus 5 --- src/web/capabilities.test.ts | 18 ++++++++++++++++++ src/web/capabilities.ts | 8 ++++++++ src/web/push.ts | 22 ++++++++++++++++------ 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/web/capabilities.test.ts b/src/web/capabilities.test.ts index a128ecb..40a3751 100644 --- a/src/web/capabilities.test.ts +++ b/src/web/capabilities.test.ts @@ -80,7 +80,25 @@ describe("cloud route capability boundary", () => { } }); + test("denies push routes — one machine-wide channel, subscriptions carry no owner", () => { + // src/web/push.ts keeps push.json in the operator home on purpose (every + // PushBridge producer is host-level), so in the hosted edition these routes + // would hand any signed-in tenant every other tenant's ntfy topic — which is + // itself the send/read secret — and APNs device token. + for (const route of [ + "/api/push", + "/api/push/list", + "/api/push/register", + "/api/push/unregister", + "/api/push/prefs", + "/api/push/live-activity", + ]) { + assert.equal(isCloudDeniedRoute(route), true, `${route} must be denied`); + } + }); + test("fails closed for malformed URLs", () => { assert.equal(isCloudDeniedRoute("http://["), true); }); }); + diff --git a/src/web/capabilities.ts b/src/web/capabilities.ts index 5e14074..77550aa 100644 --- a/src/web/capabilities.ts +++ b/src/web/capabilities.ts @@ -39,6 +39,13 @@ const CLOUD_DENIED_ROUTE_PREFIXES = [ "/api/mail/", "/api/pair/", "/api/plans/", + // Push subscriptions are one machine-wide channel (src/web/push.ts resolves + // push.json in the operator home — every producer wired to PushBridge is a + // host-level concern), and a PushSubscription carries no owner. Left open in + // the hosted edition, any signed-in tenant could GET /api/push/list and read + // every tenant's ntfy topic — which IS the send/read secret — and APNs device + // token, unregister another tenant's device, or rewrite their prefs. + "/api/push/", "/api/screen-advisor/", "/api/sense/", "/api/vision/", @@ -62,3 +69,4 @@ export function isCloudDeniedRoute(rawUrl: string): boolean { return pathname === root || pathname.startsWith(`${root}/`); }); } + diff --git a/src/web/push.ts b/src/web/push.ts index a63e2bf..0da92cc 100644 --- a/src/web/push.ts +++ b/src/web/push.ts @@ -16,10 +16,10 @@ * — never prompts, replies, full commands, or terminal output. */ import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import crypto from "node:crypto"; import http2 from "node:http2"; +import { lisaGlobalHome } from "../paths.js"; import type { AgentSession } from "../integrations/types.js"; export interface PushPrefs { @@ -62,11 +62,21 @@ export interface PushSubscription { createdAt: number; } -function lisaHome(): string { - return process.env.LISA_HOME ?? path.join(os.homedir(), ".lisa"); -} +/** + * Push state lives in the OPERATOR home, never a per-user subtree. Every + * producer wired to PushBridge — agent activity, idle notes, the mail digest, + * the KB brief, billing anomalies — is a host-level concern, so this is one + * machine-wide channel by design. + * + * It used to resolve ~/.lisa with a private copy of lisaHome(), which read as + * an accidental homeScope bypass (the same shape as the consent-store defect + * this batch fixes) and left /api/push/* serving every tenant's device tokens + * in the hosted edition. Those routes are now denied in cloud (see + * CLOUD_DENIED_ROUTE_PREFIXES in ./capabilities.ts); naming lisaGlobalHome() + * here makes "deliberately not per-tenant" explicit rather than incidental. + */ function pushPath(): string { - return path.join(lisaHome(), "push.json"); + return path.join(lisaGlobalHome(), "push.json"); } export function loadPush(): PushSubscription[] { @@ -135,7 +145,7 @@ export interface LiveActivityReg { createdAt: number; } function liveActivitiesPath(): string { - return path.join(lisaHome(), "live-activities.json"); + return path.join(lisaGlobalHome(), "live-activities.json"); } export function listLiveActivities(): LiveActivityReg[] { try { From 4288f6bc39ce0e40cd9e3909f8276a027513fe62 Mon Sep 17 00:00:00 2001 From: oratis Date: Thu, 27 Aug 2026 15:51:42 +0800 Subject: [PATCH 13/15] fix(cloud): reject non-canonical request paths before routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isCloudDeniedRoute matches the NORMALIZED pathname, but every route in server.ts matches the RAW req.url with startsWith/===. A dot-segment path exploits that disagreement in one request: GET /api/agents/recap/%2e%2e/%2e%2e/%2e%2e?sinceMinutes=1440 normalizes to "/" — so the deny gate answers "not denied" — while still satisfying url.startsWith("/api/agents/recap"), so the handler runs. The same shape reaches /api/agents/steps, /api/agents/transcript, /api/agents/pty/*/output, /api/dispatch/status (which returns raw captured agent stdout) and /api/mail/accounts, and "//api/consent/grant" reparses its authority so even the entry added earlier in this batch is bypassable. %2e survives upstream proxies, so Cloud Run's frontend does not neutralize it. Verified against the real exported function. Teaching ~80 route checks to normalize would leave the next route to remember, so reject non-canonical paths outright, before anything routes. That fails closed for routes added later and costs legitimate callers nothing: clients percent-encode, and an encoded separator that survives normalization leaves the pathname — and therefore the deny-list decision — unchanged, so it is left alone and the prefix still matches. Co-Authored-By: Claude Opus 5 --- src/web/capabilities.test.ts | 71 ++++++++++++++++++++++++++++++++++++ src/web/capabilities.ts | 28 ++++++++++++++ src/web/server.ts | 12 ++++++ 3 files changed, 111 insertions(+) diff --git a/src/web/capabilities.test.ts b/src/web/capabilities.test.ts index 40a3751..fd3b1fc 100644 --- a/src/web/capabilities.test.ts +++ b/src/web/capabilities.test.ts @@ -4,6 +4,7 @@ import type { ToolDefinition } from "../types.js"; import { capabilityProfileForEdition, isCloudDeniedRoute, + isNonCanonicalPath, toolsForCapabilityProfile, } from "./capabilities.js"; @@ -102,3 +103,73 @@ describe("cloud route capability boundary", () => { }); }); +describe("non-canonical request paths", () => { + // isCloudDeniedRoute matches the normalized pathname; server.ts routes match + // the raw req.url. Each of these normalizes to something the deny-list waves + // through while still matching its handler's raw prefix, so without the guard + // the route runs in the hosted edition. + test("rejects the dot-segment paths that slip past the deny-list", () => { + for (const route of [ + "/api/agents/recap/%2e%2e/%2e%2e/%2e%2e?sinceMinutes=1440", + "/api/agents/steps/../../../x?agent=claude-code", + "/api/agents/transcript/../../../x", + "/api/dispatch/status/../../../x?id=1", + "/api/agents/pty/%2e%2e/%2e%2e/%2e%2e/z/output", + "/api/mail/accounts/../../../q", + ]) { + assert.equal(isNonCanonicalPath(route), true, `${route} must be rejected`); + // The bypass is real: the deny-list alone does not stop these. + assert.equal( + isCloudDeniedRoute(route), + false, + `${route} is exactly the case the deny-list misses`, + ); + } + }); + + test("also rejects dot segments that would still have been denied", () => { + // "/api/consent/./grant" normalizes back onto a denied prefix, so it is not + // a bypass — but canonical form is the invariant, not "did it happen to be + // caught": the next route added under a non-denied prefix would be. + assert.equal(isNonCanonicalPath("/api/consent/./grant"), true); + assert.equal(isCloudDeniedRoute("/api/consent/./grant"), true); + }); + + test("rejects a leading // — it reparses as an authority, dropping the prefix", () => { + assert.equal(isNonCanonicalPath("//api/consent/grant"), true); + assert.equal(isCloudDeniedRoute("//api/consent/grant"), false); + }); + + test("fails closed for malformed URLs", () => { + assert.equal(isNonCanonicalPath("http://["), true); + }); + + test("leaves ordinary paths alone, including percent-encoded ones", () => { + for (const route of [ + "/", + "/health", + "/chat", + "/api/soul", + "/api/consent/grant", + "/api/push/list", + "/api/dispatch/status?id=99-ab", + "/api/kb/search?q=hello%20world", + "/assets/%E5%9B%BE%E7%89%87.png", + "/api/room/music/file/u_YWJjLm1wMw", + "/api/agents/pty/abc-123/output", + // Dot segments in the QUERY are not path traversal. + "/api/kb/search?q=a/../b", + ]) { + assert.equal(isNonCanonicalPath(route), false, `${route} must be allowed`); + } + }); + + test("an encoded separator that survives normalization is left to the deny-list", () => { + // %2f is not decoded into a path separator, so the pathname — and therefore + // the deny-list decision — is unchanged. Nothing is bypassed, so nothing to + // reject; the prefix still matches. + const route = "/api/agents/transcript/..%2f..%2f.."; + assert.equal(isNonCanonicalPath(route), false); + assert.equal(isCloudDeniedRoute(route), true); + }); +}); diff --git a/src/web/capabilities.ts b/src/web/capabilities.ts index 77550aa..dbf9c26 100644 --- a/src/web/capabilities.ts +++ b/src/web/capabilities.ts @@ -70,3 +70,31 @@ export function isCloudDeniedRoute(rawUrl: string): boolean { }); } +/** + * True when the request path is NOT already in canonical form — a dot segment + * ("." / ".."), a percent-encoded dot that decodes into one, or a leading "//" + * that reparses as an authority. + * + * This exists because the two layers disagree about what "the path" is: + * isCloudDeniedRoute() above matches the NORMALIZED pathname, while every route + * in server.ts matches the RAW req.url with startsWith/===. The gap is + * exploitable — "/api/agents/recap/%2e%2e/%2e%2e/%2e%2e" normalizes to "/" (so + * the deny-list says "not denied") yet still satisfies + * url.startsWith("/api/agents/recap"), so the handler runs in the hosted + * edition. Teaching ~80 route checks to normalize would leave the next one to + * remember; rejecting non-canonical paths outright fails closed for routes + * added later, and no legitimate client emits one (clients percent-encode, and + * an encoded separator that survives normalization leaves the pathname — and + * therefore the deny-list decision — unchanged). + */ +export function isNonCanonicalPath(rawUrl: string): boolean { + const cut = rawUrl.search(/[?#]/); + const rawPath = cut === -1 ? rawUrl : rawUrl.slice(0, cut); + let pathname: string; + try { + pathname = new URL(rawUrl, "http://localhost").pathname; + } catch { + return true; + } + return rawPath !== pathname; +} diff --git a/src/web/server.ts b/src/web/server.ts index 15832a3..a5ee7a7 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -192,6 +192,7 @@ import { capabilityProfileForEdition, isCloudDeniedRoute, toolsForCapabilityProfile, + isNonCanonicalPath, } from "./capabilities.js"; import type { ToolDefinition, StoredMessage } from "../types.js"; @@ -1108,6 +1109,17 @@ export async function startWebServer(opts: WebServerOptions): Promise Date: Thu, 27 Aug 2026 15:51:59 +0800 Subject: [PATCH 14/15] fix(dispatch): an observed exit outranks the pid probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This batch taught the ledger to record exitCode/exitSignal/exitedAt, then never consulted them in the one predicate that decides the headline label. entryIsAlive still asked the OS about a pid we had already watched die. Reproduced against the real module: after recordDispatch() then recordExit(id, 3, null), entryIsAlive() is true, statusLabel() returns "▶ running", listLiveDispatches() still contains the entry and findDispatch() still returns it — while the row on disk carries exitCode: 3. GET /api/dispatch/list reported alive: true for the same row. Second consequence, and the reason this is more than cosmetic: an entry with no startToken falls back to bare pid identity, and that is not a rare case — recordDispatch runs after launchAgent's 150 ms launch race, so a fast-crashing agent is already reaped when processStartToken shells out and the token is dropped. Those entries are retained 24 h, during which the freed pid is available for reuse; signal_agent cancel would then deliver SIGTERM and SIGKILL to whatever process group now owns it. The recorded exit already proved the entry was dead. Gate on exitedAt rather than exitCode — a signal death legitimately stores exitCode: null. Co-Authored-By: Claude Opus 5 --- src/integrations/dispatch-ledger.test.ts | 35 ++++++++++++++++++++++++ src/integrations/dispatch-ledger.ts | 17 +++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/integrations/dispatch-ledger.test.ts b/src/integrations/dispatch-ledger.test.ts index 529d0d5..ba734a6 100644 --- a/src/integrations/dispatch-ledger.test.ts +++ b/src/integrations/dispatch-ledger.test.ts @@ -237,4 +237,39 @@ describe("recordExit", () => { assert.doesNotThrow(() => recordExit("no-such-id", 0, null)); assert.equal(loadLedger().length, 1); }); + + // A recorded exit is definitive. process.pid is genuinely alive and its + // startToken genuinely matches, so every one of these assertions inverts if + // entryIsAlive goes back to asking the OS about a pid we already watched die + // — which is what made a finished dispatch render "▶ running", and what let + // signal_agent target a pid the OS had since handed to someone else. + test("an observed exit wins over the pid probe, even for a live pid", () => { + const e = recordDispatch({ agent: "claude", pid: process.pid, cwd: "/a", task: "t", now: Date.now() }); + assert.equal(entryIsAlive(e), true, "alive before the exit is recorded"); + + recordExit(e.id, 3, null); + const stored = loadLedger().find((x) => x.id === e.id); + assert.ok(stored); + assert.equal(stored.exitCode, 3); + assert.equal(entryIsAlive(stored), false, "a recorded exit means dead, whatever the pid says"); + assert.equal(findDispatch(e.id), null); + assert.equal(listLiveDispatches().some((x) => x.id === e.id), false); + assert.equal(toDispatchView(stored, entryIsAlive(stored)).alive, false); + }); + + test("death by signal counts as an exit too (exitCode is null there)", () => { + const e = recordDispatch({ agent: "codex", pid: process.pid, cwd: "/a", task: "t", now: Date.now() }); + recordExit(e.id, null, "SIGKILL"); + const stored = loadLedger().find((x) => x.id === e.id); + assert.ok(stored); + assert.equal(stored.exitCode, null); + assert.equal(entryIsAlive(stored), false, "gate on exitedAt, not on a truthy exitCode"); + }); + + test("an entry with no recorded exit still falls through to the pid probe", () => { + const live = recordDispatch({ agent: "claude", pid: process.pid, cwd: "/a", task: "t", now: Date.now() }); + const dead = recordDispatch({ agent: "claude", pid: DEAD_PID, cwd: "/b", task: "t", now: Date.now() }); + assert.equal(entryIsAlive(live), true); + assert.equal(entryIsAlive(dead), false); + }); }); diff --git a/src/integrations/dispatch-ledger.ts b/src/integrations/dispatch-ledger.ts index 594f9bb..2f3d650 100644 --- a/src/integrations/dispatch-ledger.ts +++ b/src/integrations/dispatch-ledger.ts @@ -161,8 +161,23 @@ export function isAlive(pid: number, startToken?: string): boolean { return true; } -/** isAlive for a ledger entry — always consults the recorded start token. */ +/** + * isAlive for a ledger entry — an observed exit is definitive, then the start + * token. + * + * recordExit() sets `exitedAt` only for a child we actually watched terminate, + * so once it is set the pid is stale by definition and must never be probed + * again. Skipping that check let a recycled pid resurrect a finished dispatch: + * dispatch_status printed "▶ running" for an entry whose exit code sat in the + * same JSON object, and — for an entry with no startToken, which is every + * agent that died inside launchAgent's 150 ms race — signal_agent would deliver + * SIGTERM / SIGKILL to the unrelated process group that now owns the pid. + * + * Gate on `exitedAt`, not `exitCode`: a signal death legitimately stores + * exitCode: null. + */ export function entryIsAlive(e: DispatchEntry): boolean { + if (e.exitedAt !== undefined) return false; return isAlive(e.pid, e.startToken); } From 2e46cdfcd34905b11982ce18137e4ea903baab7b Mon Sep 17 00:00:00 2001 From: oratis Date: Thu, 27 Aug 2026 15:51:59 +0800 Subject: [PATCH 15/15] test(soul): drive the real birth prompt path, not seedForPrompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three privacy tests added in this batch all assert on seedForPrompt's own output — one of them re-derives the payload with JSON.stringify(seedForPrompt(seed), null, 2) under a comment calling it "the actual wire shape". Nothing enforced that coupling: reverting birth.ts back to JSON.stringify(seed, null, 2) left the suite at 9 pass / 0 fail. The headline privacy fix of this PR could be silently undone. dreamSoul is where the prompt is actually assembled and it already takes its provider as a parameter, so export it and hand it a fake that captures what gets sent, then assert the fingerprint is absent and that the fields the dream does need still travel. Reverting the fix now fails this test. Co-Authored-By: Claude Opus 5 --- src/soul/birth.test.ts | 39 ++++++++++++++++++++++++++++++++++++++- src/soul/birth.ts | 11 +++++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/soul/birth.test.ts b/src/soul/birth.test.ts index e5330d2..0f3e050 100644 --- a/src/soul/birth.test.ts +++ b/src/soul/birth.test.ts @@ -8,7 +8,7 @@ const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "lisa-birth-")); process.env.LISA_HOME = TMP; process.env.LISA_SOUL_GIT = "0"; // keep tests fast; git no-op path is itself S3 behavior -const { birth, BirthInferenceError, seedForPrompt } = await import("./birth.js"); +const { birth, BirthInferenceError, seedForPrompt, dreamSoul } = await import("./birth.js"); const { isBorn } = await import("./store.js"); const { soulSeedFile, soulNameFile } = await import("./paths.js"); import type { BirthOutput } from "./birth.js"; @@ -178,4 +178,41 @@ describe("birth prompt does not carry the device fingerprint", () => { seedForPrompt(copy); assert.equal(copy.bornOn, seed.bornOn); }); + + // The three tests above all assert on seedForPrompt's own output, so they + // stay green even if dreamSoul stops calling it — the fix would be revertible + // with a clean suite. This one drives the real assembly path and captures + // what the provider is actually handed. + test("dreamSoul hands the provider a prompt with no trace of the fingerprint", async () => { + const sent: string[] = []; + const provider = { + runTurn: async (opts: { + systemPrompt: string; + messages: { content: { type: string; text?: string }[] }[]; + }) => { + for (const m of opts.messages) { + for (const b of m.content) if (b.type === "text" && b.text) sent.push(b.text); + } + return { + content: [{ type: "text", text: JSON.stringify(GOOD) }], + usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }, + }; + }, + }; + + const result = await dreamSoul( + provider as unknown as Parameters[0], + "test-model", + seed, + ); + assert.equal(result.output.name, GOOD.name); + + assert.equal(sent.length, 1, "one user message carries the seed"); + const wire = sent[0]; + assert.equal(wire.includes(seed.bornOn), false, "the device fingerprint must not reach the provider"); + assert.equal(wire.includes("bornOn"), false, "not even the field name"); + // …while everything the dream actually needs did travel. + assert.equal(wire.includes(seed.randomness), true); + assert.equal(wire.includes(seed.bornAt), true); + }); }); diff --git a/src/soul/birth.ts b/src/soul/birth.ts index f221d42..6dd955f 100644 --- a/src/soul/birth.ts +++ b/src/soul/birth.ts @@ -245,8 +245,15 @@ export function seedForPrompt(seed: SoulSeed): Omit { return rest; } -/** One LLM turn → parsed birth output. Separated so the caller can retry. */ -async function dreamSoul( +/** + * One LLM turn → parsed birth output. Separated so the caller can retry. + * + * Exported for the regression test in birth.test.ts: this is the only place the + * birth prompt is actually assembled, so a test that asserts on anything else + * (seedForPrompt's return value, a re-derived payload) cannot fail when this + * line stops calling it. + */ +export async function dreamSoul( provider: ReturnType, model: string, seed: SoulSeed,