-
Notifications
You must be signed in to change notification settings - Fork 1
opencode: persist custody telemetry to a bounded file, and say so when serving #35
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
49cfe57
ea69323
1c4d842
032d252
7efb207
6386b0e
750452c
53d266c
a77e2f7
6781f8c
ef4f90c
da337ac
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,8 @@ | ||
| import { appendFileSync, chmodSync, mkdirSync, renameSync, statSync } from "node:fs"; | ||
| import { dirname, join } from "node:path"; | ||
|
|
||
| import { identifierIsValid } from "./handles"; | ||
|
|
||
| export type LogLevel = "debug" | "info" | "warn" | "error"; | ||
|
|
||
| export type CustodyLogEntry = { | ||
|
|
@@ -12,6 +17,8 @@ export type CustodyLogEntry = { | |
| errorClass?: string; | ||
| errorCode?: string; | ||
| errorMessage?: string; | ||
| ts?: string; | ||
| pid?: number; | ||
| }; | ||
|
|
||
| export type LogSink = (entry: CustodyLogEntry) => void; | ||
|
|
@@ -23,21 +30,145 @@ export type CustodyLogger = { | |
| error(entry: Omit<CustodyLogEntry, "level">): void; | ||
| }; | ||
|
|
||
| function defaultSink(entry: CustodyLogEntry): void { | ||
| const out = entry.level === "debug" | ||
| ? console.debug | ||
| : entry.level === "warn" || entry.level === "error" | ||
| ? console.error | ||
| : console.log; | ||
| out(JSON.stringify(entry)); | ||
| const FILE_LIMIT_BYTES = 5 * 1024 * 1024; | ||
| export const FILE_FIELDS: Array<keyof CustodyLogEntry> = [ | ||
| "level", "provider", "label", "credentialId", "recordVersion", "state", "httpStatus", | ||
| "cooldownUntil", "errorClass", "errorCode", "ts", "pid", | ||
| ]; | ||
| const CREDENTIAL_ID = /^[A-Za-z0-9._:-]{1,128}$/; | ||
| // A ≤24-character lowercase-snake residual such as sk_fake_secret shares the admitted code shape and is not all-hex. | ||
| export const ERROR_CLASS = /^(?:[A-Z][A-Za-z0-9]{0,47}|[a-z][a-z0-9_]{1,23})$/; | ||
| export const ERROR_CODE = /^(?:[A-Z][A-Z0-9_]{1,23}|[a-z][a-z0-9_]{1,23})$/; | ||
| // False positives to expect when diagnosing: an English word made only of hex letters (deadbeef, facade, | ||
| // decade) is rejected here and reaches the file as invalid_shape while looking ordinary at the call site. | ||
| // No current producer emits one — .name gives JS error names, .code gives errno strings — so a field that | ||
| // silently reads invalid_shape is the symptom to check first if a future producer starts emitting one. | ||
| export function isAllHexBody(value: string): boolean { | ||
| return /^[0-9a-f]+$/i.test(value); | ||
| } | ||
| const LEVELS = new Set(["debug", "info", "warn", "error"]); | ||
| const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T[\d:.]+Z$/; | ||
| export const STATES = new Set([ | ||
| "available", "transient", "cooldown", "reauth", "other_owner", "orphan", "split", "unmanaged", | ||
| "refusing", "serving", "served", "gone", | ||
| ]); | ||
|
|
||
| // NOTHING ROUTINE GOES TO THE CONSOLE. The console is the OpenCode TUI's screen, and a | ||
| // plugin writing there corrupts the operator's terminal -- including mid-render, which is | ||
| // how this surfaced twice: first as info-level "serving" lines (2026-09-05), then as | ||
| // warn/error JSON printed over the TUI during a transient vault timeout (2026-09-11). | ||
| // | ||
| // The second one is the instructive one. The first fix kept faults on the console on the | ||
| // reasoning that "only faults belong there" -- a judgement substituted for the instruction, | ||
| // which was that ALL plugin logs go to the file. A fault is exactly when the plugin is | ||
| // noisiest, so the carve-out preserved the defect for the case that produces the most output. | ||
| // | ||
| // The console sink is gone. Every level goes to the file. The ONE remaining console write in | ||
| // this module is the once-per-process notice below, emitted only when the log FILE itself is | ||
| // unwritable -- reporting that logging is broken is not logging, and there is nowhere else to | ||
| // put it. If that line is ever seen in a terminal, the file sink has failed, which is the only | ||
| // condition under which this module may speak. | ||
|
|
||
| export type FileLogSinkOptions = { | ||
| path?: string; | ||
| env?: NodeJS.ProcessEnv; | ||
| warn?: (message: string) => void; | ||
| }; | ||
|
|
||
| function defaultFilePath(env: NodeJS.ProcessEnv): string { | ||
| const stateHome = env.XDG_STATE_HOME || (env.HOME ? join(env.HOME, ".local", "state") : ".local/state"); | ||
| return join(stateHome, "cortexkit", "opencode-plugin", "custody.jsonl"); | ||
| } | ||
|
|
||
| function fileEntry(entry: CustodyLogEntry): Record<string, unknown> { | ||
| // These pre-filter additions are process-generated ts/pid only; caller-influenced values enter through entry and their rules. | ||
| const withMetadata = { ...entry, ts: new Date().toISOString(), pid: process.pid }; | ||
| const safe: Record<string, unknown> = {}; | ||
| for (const field of FILE_FIELDS) { | ||
| if (withMetadata[field] !== undefined) { | ||
| const value = withMetadata[field]; | ||
| if (typeof value !== "string") { | ||
| safe[field] = (typeof value === "number" && Number.isFinite(value)) || typeof value === "boolean" | ||
| ? value | ||
| : "invalid_shape"; | ||
| continue; | ||
| } | ||
| let valid: boolean; | ||
| switch (field) { | ||
| case "level": valid = LEVELS.has(value); break; | ||
| case "provider": | ||
| case "label": valid = identifierIsValid(value); break; | ||
| case "credentialId": valid = CREDENTIAL_ID.test(value); break; | ||
| case "state": valid = STATES.has(value); break; | ||
| case "errorClass": valid = ERROR_CLASS.test(value) && !isAllHexBody(value); break; | ||
| case "errorCode": valid = ERROR_CODE.test(value) && !isAllHexBody(value); break; | ||
| case "ts": valid = ISO_TIMESTAMP.test(value); break; | ||
| default: valid = false; | ||
| } | ||
| safe[field] = valid ? value : "invalid_shape"; | ||
| } | ||
| } | ||
| return safe; | ||
| } | ||
|
|
||
| export function createFileLogSink(options: FileLogSinkOptions = {}): LogSink { | ||
| const env = options.env ?? process.env; | ||
| if (options.path === undefined && ["off", "0", "false", "no"].includes(env.CLAUSTRUM_CUSTODY_LOG ?? "")) { | ||
| return () => {}; | ||
| } | ||
| const path = options.path ?? env.CLAUSTRUM_CUSTODY_LOG ?? defaultFilePath(env); | ||
| const warn = options.warn ?? ((message: string) => console.error(JSON.stringify({ | ||
| level: "warn", | ||
| errorCode: "custody_log_unavailable", | ||
| errorMessage: message, | ||
| }))); | ||
| let unavailable = false; | ||
| let initialized = false; | ||
| const fail = () => { | ||
| if (unavailable) return; | ||
| unavailable = true; | ||
| // This notice is the ONLY thing this module prints, so it must describe the state it | ||
| // actually leaves behind. It used to end "faults still reach the console", which was true | ||
| // while a console sink carried warn/error -- deleting that sink made the sentence a lie in | ||
| // the same commit, and an operator reading it would go looking for errors on a channel that | ||
| // no longer carries any. Every level is dropped once the file is gone. | ||
| warn("persistent custody log unavailable; ALL custody telemetry dropped, including warnings and errors"); | ||
| }; | ||
| const rotateIfNeeded = () => { | ||
| try { | ||
| if (statSync(path).size > FILE_LIMIT_BYTES) { | ||
| renameSync(path, `${path}.1`); | ||
| chmodSync(`${path}.1`, 0o600); | ||
| } | ||
| } catch (error) { | ||
| if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; | ||
| } | ||
| }; | ||
| return (entry) => { | ||
| if (unavailable) return; | ||
| try { | ||
| if (!initialized) { | ||
| mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
|
||
| chmodSync(dirname(path), 0o700); | ||
| rotateIfNeeded(); | ||
| initialized = true; | ||
| } | ||
| rotateIfNeeded(); | ||
| appendFileSync(path, `${JSON.stringify(fileEntry(entry))}\n`, { mode: 0o600 }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When the file is near 5 MiB, this append can push it over the limit and leave it oversized until another event occurs. Rotate based on existing size plus the next line's byte length before appending. Prompt for AI agents |
||
| chmodSync(path, 0o600); | ||
| } catch { | ||
| fail(); | ||
| } | ||
| }; | ||
| } | ||
|
|
||
| export function createLogger(sink: LogSink = defaultSink): CustodyLogger { | ||
| export function createLogger(sink?: LogSink): CustodyLogger { | ||
| const output = sink ?? createFileLogSink(); | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
|
||
| return { | ||
| debug: (entry) => sink({ level: "debug", ...entry }), | ||
| info: (entry) => sink({ level: "info", ...entry }), | ||
| warn: (entry) => sink({ level: "warn", ...entry }), | ||
| error: (entry) => sink({ level: "error", ...entry }), | ||
| debug: (entry) => output({ level: "debug", ...entry }), | ||
| info: (entry) => output({ level: "info", ...entry }), | ||
| warn: (entry) => output({ level: "warn", ...entry }), | ||
| error: (entry) => output({ level: "error", ...entry }), | ||
| }; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -204,6 +204,7 @@ export function createOpencodeClaustrumPlugin(dependencies: ConfigHookDependenci | |
| const handleReader = dependencies.handleReader ?? readHandleFile; | ||
| const authReader = dependencies.authReader ?? readAuthFile; | ||
| const log = createLogger(dependencies.logSink ?? (dependencies.log ? serializedLogSink(dependencies.log) : undefined)); | ||
| const announcedProviders = new Set<string>(); | ||
| if (process.env.CLAUSTRUM_CUSTODY_DISABLE === "1") { | ||
| return async () => ({ | ||
| config: async () => { | ||
|
|
@@ -244,7 +245,15 @@ export function createOpencodeClaustrumPlugin(dependencies: ConfigHookDependenci | |
| const pending = (async () => { | ||
| const result = await detection(); | ||
| if (result.status !== "available") throw new Error(`Claustrum connection ${result.status}`); | ||
| return clientFactory(); | ||
| // The client's default unknown-class logger is `console.warn`, which is the OpenCode | ||
| // TUI's screen. Routing it into our file sink keeps the whole plugin -- including the | ||
| // vendored client bundled with it -- off the operator's terminal. Without this the | ||
| // console ban in log.ts is only two thirds enforced: our own levels are silenced and | ||
| // a wire response carrying an unrecognised error class still prints. | ||
| return clientFactory({ | ||
| logger: (errorClass: string) => | ||
| log.warn({ errorClass: typeof errorClass === "string" ? errorClass : "invalid_shape" }), | ||
| }); | ||
| })(); | ||
| connected = pending; | ||
| try { | ||
|
|
@@ -359,6 +368,7 @@ export function createOpencodeClaustrumPlugin(dependencies: ConfigHookDependenci | |
| // breaks the contract documented in docs/opencode-custody-design.md. | ||
| if (owner !== undefined && owner !== OUR_PLUGIN_ID) { | ||
| log.debug({ provider, errorClass: "other_owner", errorCode: owner }); | ||
| log.info({ provider, state: "other_owner" }); | ||
| continue; | ||
| } | ||
|
|
||
|
|
@@ -369,36 +379,42 @@ export function createOpencodeClaustrumPlugin(dependencies: ConfigHookDependenci | |
| if (consumesTombstone) { | ||
| const refusal = new CustodyOrphanError(`${sentinelShapeDrift(entry, provider)}; refusing before OpenCode can load it`); | ||
| logError(log, refusal, provider); | ||
| log.info({ provider, state: "orphan" }); | ||
| configureRefusal(provider, refusal); | ||
| continue; | ||
| } | ||
| if (owner === OUR_PLUGIN_ID) { | ||
| if (entry === undefined) { | ||
| logError(log, new CustodyOrphanError("handle entry has no auth.json counterpart; run ck auth migrate-opencode"), provider); | ||
| log.info({ provider, state: "orphan" }); | ||
| continue; | ||
| } | ||
| const error = new CustodySplitError( | ||
| `local credential is real while custody handles remain; run ck auth migrate-opencode --provider ${provider} to re-tombstone, or ck auth migrate-opencode --restore ${provider} to use the local credential`, | ||
| ); | ||
| logError(log, error, provider); | ||
| log.info({ provider, state: "split" }); | ||
| const configured = materializeProvider(provider); | ||
| if (!configured) continue; | ||
| configured.options = { | ||
| ...(configured.options ?? {}), | ||
| fetch: async () => { throw error; }, | ||
| }; | ||
| } | ||
| log.info({ provider, state: "unmanaged" }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: A split provider (custody handle present with a real local credential) is logged with both Prompt for AI agents |
||
| continue; | ||
| } | ||
| if (owner === undefined) { | ||
| const refusal = new CustodyOrphanError("tombstone has no serving handle; run ck auth migrate-opencode"); | ||
| logError(log, refusal, provider); | ||
| log.info({ provider, state: "orphan" }); | ||
| configureRefusal(provider, refusal); | ||
| continue; | ||
| } | ||
| if (handle!.shape !== (entry as { type?: unknown }).type) { | ||
| const refusal = new CustodySplitError("custody handle shape disagrees with auth entry; run ck auth migrate-opencode"); | ||
| logError(log, refusal, provider); | ||
| log.info({ provider, state: "split" }); | ||
| configureRefusal(provider, refusal); | ||
| continue; | ||
| } | ||
|
|
@@ -411,12 +427,14 @@ export function createOpencodeClaustrumPlugin(dependencies: ConfigHookDependenci | |
| `OpenCode native LLM mode bypasses the custody fetch seam; OPENCODE_EXPERIMENTAL_NATIVE_LLM=${observed} must be unset or disabled`, | ||
| ); | ||
| logError(log, refusal, provider); | ||
| log.info({ provider, state: "refusing" }); | ||
| configureRefusal(provider, refusal); | ||
| continue; | ||
| } | ||
|
|
||
| const configured = materializeProvider(provider); | ||
| if (!configured) continue; | ||
| log.info({ provider, state: "serving" }); | ||
| const freshness = new FreshnessController({ | ||
| provider, | ||
| shape: handle!.shape, | ||
|
|
@@ -457,6 +475,11 @@ export function createOpencodeClaustrumPlugin(dependencies: ConfigHookDependenci | |
| }, | ||
| readAuthEntry: async () => (await readAuth(defaultAuthPath(), authReader))[provider], | ||
| upstreamFetch, | ||
| onServed: (account, recordVersion) => { | ||
| if (announcedProviders.has(provider)) return; | ||
| announcedProviders.add(provider); | ||
| log.info({ provider, label: account.label, credentialId: account.credential_id, recordVersion, state: "served" }); | ||
| }, | ||
| log, | ||
| }), | ||
| }; | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.