Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/opencode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ grep -cE '^\s*(catch|} catch)|^\s*return[; ]|^\s*continue;' packages/opencode/sr
| closing-wave worktree | 27 | 18 | 13 | 9 |
| custody review triage | 27 | 18 | 13 | 9 |
| `a3b3a6c` | 27 | 21 | 13 | 10 |
| current (this commit) | 32 | 41 | 13 | 14 |
| current (this commit) | 33 | 41 | 13 | 15 |

A changed count without a matching sweep row is a review failure, not harmless churn.

Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/handles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ function handleIsValid(handle: unknown): handle is string {
return typeof handle === "string" && /^ckh_[A-Za-z0-9_-]{43}$/.test(handle);
}

function identifierIsValid(value: unknown): value is string {
export function identifierIsValid(value: unknown): value is string {
return typeof value === "string" && PROVIDER_ID.test(value) && !FORBIDDEN_IDENTIFIERS.has(value);
}

Expand Down
155 changes: 143 additions & 12 deletions packages/opencode/src/log.ts
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 = {
Expand All @@ -12,6 +17,8 @@ export type CustodyLogEntry = {
errorClass?: string;
errorCode?: string;
errorMessage?: string;
ts?: string;
pid?: number;
};

export type LogSink = (entry: CustodyLogEntry) => void;
Expand All @@ -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",
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
]);

// 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 });
Comment thread
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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/log.ts, line 97:

<comment>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.</comment>

<file context>
@@ -32,12 +41,78 @@ function defaultSink(entry: CustodyLogEntry): void {
+        initialized = true;
+      }
+      rotateIfNeeded();
+      appendFileSync(path, `${JSON.stringify(fileEntry(entry))}\n`, { mode: 0o600 });
+      chmodSync(path, 0o600);
+    } catch {
</file context>

chmodSync(path, 0o600);
} catch {
fail();
}
};
}

export function createLogger(sink: LogSink = defaultSink): CustodyLogger {
export function createLogger(sink?: LogSink): CustodyLogger {
const output = sink ?? createFileLogSink();
Comment thread
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 }),
};
}

Expand Down
25 changes: 24 additions & 1 deletion packages/opencode/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}

Expand All @@ -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" });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 state: "split" and state: "unmanaged". The split branch never continues, so control falls through to the unmanaged line after setting the throwing fetch. These are contradictory states and this also breaks the PR's stated "one info line per provider" contract. Only log unmanaged for providers that are not split, e.g. move log.info({ provider, state: "unmanaged" }) into an else of the owner === OUR_PLUGIN_ID branch (or continue at the end of that branch).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/plugin.ts, line 396:

<comment>A split provider (custody handle present with a real local credential) is logged with both `state: "split"` and `state: "unmanaged"`. The split branch never continues, so control falls through to the `unmanaged` line after setting the throwing fetch. These are contradictory states and this also breaks the PR's stated "one info line per provider" contract. Only log `unmanaged` for providers that are not split, e.g. move `log.info({ provider, state: "unmanaged" })` into an `else` of the `owner === OUR_PLUGIN_ID` branch (or `continue` at the end of that branch).</comment>

<file context>
@@ -369,36 +371,42 @@ export function createOpencodeClaustrumPlugin(dependencies: ConfigHookDependenci
                 fetch: async () => { throw error; },
               };
             }
+            log.info({ provider, state: "unmanaged" });
             continue;
           }
</file context>

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;
}
Expand All @@ -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,
Expand Down Expand Up @@ -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,
}),
};
Expand Down
7 changes: 6 additions & 1 deletion packages/opencode/src/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export type CreateServeFetchOptions = {
freshness?: FreshnessController;
verifyOwnership?: () => Promise<void>;
log?: CustodyLogger;
onServed?: (account: ServeAccount, recordVersion: number) => void;
// Test seam: replace the production snapshot when the test wants to drive
// the substitution-failure catch arm with a controlled error (e.g. a
// canary-message `withMaterial` throw) without a live daemon or a hand-
Expand Down Expand Up @@ -247,10 +248,14 @@ export function createServeFetch(options: CreateServeFetchOptions) {
await discard(response);
break;
}
options.onServed?.(account, attempt.recordVersion);
return response;
}
const location = response.headers.get("Location");
if (!location) return response;
if (!location) {
options.onServed?.(account, attempt.recordVersion);
return response;
}
let fromOrigin: string;
let next: URL;
try {
Expand Down
Loading