@bli-cockpit/cli 0.2.290 → 0.2.292
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/raw-evidence-transcript-file.js +7 -0
- package/dist/agent-plugins-converge.js +24 -5
- package/dist/commands/books-usage.js +2 -1
- package/dist/commands/books.js +12 -4
- package/dist/commands/codex-hooks-trust-choice.js +51 -0
- package/dist/commands/codex-hooks-trust-prompt.js +172 -0
- package/dist/commands/codex-managed-hooks.js +226 -0
- package/dist/commands/doctor-report.js +2 -2
- package/dist/commands/doctor-update.js +2 -0
- package/dist/commands/doctor.js +2 -0
- package/dist/commands/local-args-collector-setup.js +2 -0
- package/dist/commands/local-args-collector-status.js +5 -3
- package/dist/commands/local-help-commands-tower.js +2 -1
- package/dist/commands/local-help-commands.js +3 -2
- package/dist/commands/local-help.js +1 -1
- package/dist/commands/memory-install-codex-hooks.js +95 -2
- package/dist/commands/memory-install-receipt.js +17 -1
- package/dist/commands/memory-install-report.js +5 -4
- package/dist/commands/memory-install.js +15 -6
- package/dist/commands/memory-log.js +13 -2
- package/dist/commands/memory-map.js +6 -2
- package/dist/commands/memory-verb-list.js +1 -1
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/setup-receipt-lines.js +2 -8
- package/dist/raw-evidence-staging.js +3 -1
- package/dist/raw-evidence-stuck-summary.js +25 -0
- package/package.json +4 -4
|
@@ -67,6 +67,13 @@ export async function collectOneEvidenceFile(collection, options) {
|
|
|
67
67
|
markCapApplied(collection, options.kind, "max_file_bytes");
|
|
68
68
|
return skip("file_too_large");
|
|
69
69
|
}
|
|
70
|
+
// A zero-byte transcript (a subagent sidecar opened and never written) has nothing to preserve. Staging it made a
|
|
71
|
+
// 0-byte object the uploader refuses with `empty_file` on every tick, forever, and the fleet board read that
|
|
72
|
+
// permanent refusal as a stuck object (RED). Like an empty git diff it is not a gap; the line says it was seen.
|
|
73
|
+
if (read.byteLength === 0) {
|
|
74
|
+
console.error("[raw-evidence] empty transcript not staged, there is nothing in it to preserve", JSON.stringify({ reason: "empty_file", kind: options.kind, byte_size: 0 }));
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
70
77
|
let prepared;
|
|
71
78
|
try {
|
|
72
79
|
prepared = await prepareTranscriptForUpload(collection, options, names, read.source);
|
|
@@ -113,7 +113,12 @@ async function convergeHost(host, reading, entry, options, now) {
|
|
|
113
113
|
if (reading.installed) {
|
|
114
114
|
// Installed by us or by hand: a failure remembered from before is spent.
|
|
115
115
|
delete entry.last_reason;
|
|
116
|
-
|
|
116
|
+
// A failed refresh waits out the six-hour retry window, not a whole day: it
|
|
117
|
+
// never counts as the day's refresh, so a machine (or a Boat template
|
|
118
|
+
// snapshot) that failed once is not left on an old version (BLI-24352).
|
|
119
|
+
const due = options.refresh
|
|
120
|
+
&& msSince(entry.last_refresh_at, now) >= PLUGIN_REFRESH_EVERY_MS
|
|
121
|
+
&& msSince(entry.last_refresh_failed_at, now) >= PLUGIN_RETRY_AFTER_FAILURE_MS;
|
|
117
122
|
if (!due)
|
|
118
123
|
return { host, status: "already_installed", reason: "installed", quiet: true };
|
|
119
124
|
return refreshHost(host, reading, entry, options, now);
|
|
@@ -166,17 +171,31 @@ async function installHost(host, reading, entry, options, now) {
|
|
|
166
171
|
console.error("[agent-plugins] plugin installed", JSON.stringify({ reason: "installed", host }));
|
|
167
172
|
return { host, status: "installed", reason: "installed", quiet: false };
|
|
168
173
|
}
|
|
169
|
-
/**
|
|
174
|
+
/**
|
|
175
|
+
* The daily refresh. A failure here never un-installs anything and never counts
|
|
176
|
+
* as the day's refresh: `last_refresh_at` stays on the last clean one and the
|
|
177
|
+
* next try is after the six-hour retry window (BLI-24352).
|
|
178
|
+
*/
|
|
170
179
|
async function refreshHost(host, reading, entry, options, now) {
|
|
171
|
-
|
|
180
|
+
const failed = (reason) => {
|
|
181
|
+
entry.last_refresh_failed_at = now.toISOString();
|
|
182
|
+
entry.last_refresh_reason = reason;
|
|
183
|
+
};
|
|
172
184
|
const binary = await locateHostBinary(host, options.homeDir, options);
|
|
173
185
|
if (!binary) {
|
|
174
|
-
|
|
186
|
+
failed("binary_missing");
|
|
187
|
+
console.error("[agent-plugins] host program not found for the daily refresh", JSON.stringify({ reason: "binary_missing", host, retry_after_hours: PLUGIN_RETRY_AFTER_FAILURE_MS / 3_600_000 }));
|
|
175
188
|
return { host, status: "already_installed", reason: "refresh_skipped_binary_missing", quiet: true };
|
|
176
189
|
}
|
|
177
190
|
const failure = await runSteps(host, binary, stepsFor(host, reading, true), options);
|
|
178
|
-
if (failure)
|
|
191
|
+
if (failure) {
|
|
192
|
+
failed(failure);
|
|
193
|
+
console.error("[agent-plugins] daily refresh failed; the installed plugin stays and the refresh is retried after the failure window", JSON.stringify({ reason: `refresh_failed_${failure}`, host, retry_after_hours: PLUGIN_RETRY_AFTER_FAILURE_MS / 3_600_000 }));
|
|
179
194
|
return { host, status: "already_installed", reason: `refresh_failed_${failure}`, quiet: false };
|
|
195
|
+
}
|
|
196
|
+
entry.last_refresh_at = now.toISOString();
|
|
197
|
+
delete entry.last_refresh_failed_at;
|
|
198
|
+
delete entry.last_refresh_reason;
|
|
180
199
|
console.error("[agent-plugins] plugin refreshed", JSON.stringify({ reason: "refreshed", host }));
|
|
181
200
|
return { host, status: "refreshed", reason: "refreshed", quiet: false };
|
|
182
201
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** `cockpit books` usage text (BLI-23711), in its own module so the help table can print it without loading the command. */
|
|
2
2
|
export const BOOKS_USAGE = [
|
|
3
|
-
"Usage: cockpit books list [--json]",
|
|
3
|
+
"Usage: cockpit books list [--json] (includes archived books, marked archived)",
|
|
4
|
+
" cockpit books archive <book> [--restore] [--json] (founders; nothing deleted)",
|
|
4
5
|
" cockpit books create <book> --title \"<title>\" [--json] (founders)",
|
|
5
6
|
" cockpit books members list|add|remove <book> [<email or user id>] [--edit] [--json] (founders)",
|
|
6
7
|
" cockpit books ls <book> <collection> [--after <doc_id>] [--limit <1-500>] [--deleted] [--json]",
|
package/dist/commands/books.js
CHANGED
|
@@ -4,7 +4,7 @@ import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
|
4
4
|
import { BOOKS_USAGE } from "./books-usage.js";
|
|
5
5
|
import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
|
|
6
6
|
export { BOOKS_USAGE };
|
|
7
|
-
const ACTIONS = ["list", "create", "members", "ls", "get", "put", "delete", "batch", "export", "import"];
|
|
7
|
+
const ACTIONS = ["list", "archive", "create", "members", "ls", "get", "put", "delete", "batch", "export", "import"];
|
|
8
8
|
const BODY_MAX_CHARS = 4_000_000;
|
|
9
9
|
function usage() {
|
|
10
10
|
throw new Error(BOOKS_USAGE);
|
|
@@ -21,7 +21,7 @@ function wholeNumber(raw, min, max) {
|
|
|
21
21
|
}
|
|
22
22
|
export function parseBooksArgs(args) {
|
|
23
23
|
const values = parseNamedArgs(args, {
|
|
24
|
-
allowedFlags: ["--title", "--edit", "--after", "--limit", "--deleted", "--merge", "--version", "--data", "--json", "--home", "--dashboard-url"],
|
|
24
|
+
allowedFlags: ["--restore", "--title", "--edit", "--after", "--limit", "--deleted", "--merge", "--version", "--data", "--json", "--home", "--dashboard-url"],
|
|
25
25
|
valueFlags: ["--title", "--after", "--limit", "--version", "--data", "--home", "--dashboard-url"],
|
|
26
26
|
});
|
|
27
27
|
const [action, ...rest] = values.positionals;
|
|
@@ -30,7 +30,7 @@ export function parseBooksArgs(args) {
|
|
|
30
30
|
const flags = values.flags;
|
|
31
31
|
const has = (flag) => flags.has(flag) || values.booleans.has(flag);
|
|
32
32
|
const allowOnly = (...allowed) => {
|
|
33
|
-
for (const flag of ["--title", "--edit", "--after", "--limit", "--deleted", "--merge", "--version", "--data"])
|
|
33
|
+
for (const flag of ["--restore", "--title", "--edit", "--after", "--limit", "--deleted", "--merge", "--version", "--data"])
|
|
34
34
|
if (has(flag) && !allowed.includes(flag))
|
|
35
35
|
usage();
|
|
36
36
|
};
|
|
@@ -47,6 +47,11 @@ export function parseBooksArgs(args) {
|
|
|
47
47
|
usage();
|
|
48
48
|
allowOnly();
|
|
49
49
|
return command;
|
|
50
|
+
case "archive":
|
|
51
|
+
if (rest.length !== 1)
|
|
52
|
+
usage();
|
|
53
|
+
allowOnly("--restore");
|
|
54
|
+
return { ...command, book: rest[0], restore: values.booleans.has("--restore") };
|
|
50
55
|
case "create": {
|
|
51
56
|
const title = optionalNonEmpty(flags.get("--title"));
|
|
52
57
|
if (rest.length !== 1 || !title)
|
|
@@ -137,10 +142,13 @@ export async function runBooks(command, io) {
|
|
|
137
142
|
if (books.length === 0)
|
|
138
143
|
writeLine(io.stdout, "No books you can open.");
|
|
139
144
|
for (const b of books)
|
|
140
|
-
writeLine(io.stdout, `${b.id}\t${b.access}${b.founder ? " (founder)" : ""}\t${b.title}`);
|
|
145
|
+
writeLine(io.stdout, `${b.id}\t${b.access}${b.founder ? " (founder)" : ""}\t${b.title}${b.archived_at ? " (archived)" : ""}`);
|
|
141
146
|
return 0;
|
|
142
147
|
}
|
|
143
148
|
break;
|
|
149
|
+
case "archive":
|
|
150
|
+
answer = await booksRequest(door, "POST", `/api/books/${book}/archive`, "archive", { archived: !command.restore });
|
|
151
|
+
break;
|
|
144
152
|
case "create":
|
|
145
153
|
answer = await booksRequest(door, "POST", "/api/books", "create", { id: command.book, title: command.title });
|
|
146
154
|
break;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The person's answer to "Auto-trust the BLI Memory hooks in Codex?"
|
|
3
|
+
* (BLI-24349), kept so a "no" is never asked again.
|
|
4
|
+
*
|
|
5
|
+
* `<state_dir>/codex-hooks-trust.json`, one small object. Only a "no" changes
|
|
6
|
+
* behaviour (the question is not asked again); a "yes" is recorded for the
|
|
7
|
+
* operator reading the machine, since the system hooks file itself is what
|
|
8
|
+
* proves the yes. `cockpit doctor --trust-codex-hooks` is how a person who
|
|
9
|
+
* said no changes their mind.
|
|
10
|
+
*
|
|
11
|
+
* A file that will not read or parse counts as no answer: asking again is the
|
|
12
|
+
* harmless direction for a question whose default is yes.
|
|
13
|
+
*/
|
|
14
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
export const CODEX_HOOKS_TRUST_CHOICE_FILE = "codex-hooks-trust.json";
|
|
17
|
+
const SCHEMA = "codex-hooks-trust.v1";
|
|
18
|
+
function choiceFile(paths) {
|
|
19
|
+
return path.join(paths.state_dir, CODEX_HOOKS_TRUST_CHOICE_FILE);
|
|
20
|
+
}
|
|
21
|
+
export async function readCodexHooksTrustChoice(paths) {
|
|
22
|
+
let raw;
|
|
23
|
+
try {
|
|
24
|
+
raw = await readFile(choiceFile(paths), "utf8");
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
const code = error?.code;
|
|
28
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
29
|
+
return { choice: null, reason: "choice_absent" };
|
|
30
|
+
console.error("[codex-hooks-trust] choice file unreadable; treating as no answer", JSON.stringify({ reason: "choice_unreadable", error_code: code ?? "unknown" }));
|
|
31
|
+
return { choice: null, reason: "choice_unreadable" };
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(raw);
|
|
35
|
+
if (parsed?.schema === SCHEMA && (parsed.choice === "declined" || parsed.choice === "trusted")) {
|
|
36
|
+
return { choice: parsed.choice, reason: "choice_read" };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// fall through to the named reason below
|
|
41
|
+
}
|
|
42
|
+
console.error("[codex-hooks-trust] choice file does not parse; treating as no answer", JSON.stringify({ reason: "choice_unreadable", byte_size: raw.length }));
|
|
43
|
+
return { choice: null, reason: "choice_unreadable" };
|
|
44
|
+
}
|
|
45
|
+
export async function writeCodexHooksTrustChoice(paths, choice, via, now = new Date()) {
|
|
46
|
+
const file = choiceFile(paths);
|
|
47
|
+
await mkdir(paths.state_dir, { recursive: true });
|
|
48
|
+
const staged = `${file}.${process.pid}.tmp`;
|
|
49
|
+
await writeFile(staged, `${JSON.stringify({ schema: SCHEMA, choice, via, at: now.toISOString() }, null, 2)}\n`, "utf8");
|
|
50
|
+
await rename(staged, file);
|
|
51
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Auto-trust the BLI Memory hooks in Codex? [Y/n]" (BLI-24349).
|
|
3
|
+
*
|
|
4
|
+
* Codex skips a hook from a person's own `~/.codex/hooks.json` until they
|
|
5
|
+
* trust it in `/hooks`, and asks again whenever the hook changes. On
|
|
6
|
+
* 2026-11-04 three of eight reporting machines were stuck there. This is the
|
|
7
|
+
* one question that ends it: a yes puts the four BLI Memory hooks in Codex's
|
|
8
|
+
* system folder, where Codex runs them as managed hooks with no trust step
|
|
9
|
+
* (`codex-managed-hooks.ts` has the proof), and takes the user-scope copy out
|
|
10
|
+
* so nothing runs twice.
|
|
11
|
+
*
|
|
12
|
+
* Rules (Edward, 2026-11-04):
|
|
13
|
+
* - Default yes: Enter or y trusts.
|
|
14
|
+
* - Asked only when a person is plainly there (same test as the BLI Memory
|
|
15
|
+
* question: stdin and stdout are terminals, no `--json`, not the scheduled
|
|
16
|
+
* tick, not `--check`/`--dry-run`, not CI). Every other run never prompts
|
|
17
|
+
* and never changes trust; the receipt keeps saying `needs_trust`.
|
|
18
|
+
* - A no is remembered and not asked again. `cockpit doctor
|
|
19
|
+
* --trust-codex-hooks` is the yes without the question, for a person who
|
|
20
|
+
* changes their mind.
|
|
21
|
+
* - The elevation (one sudo on macOS, one UAC prompt on Windows) happens only
|
|
22
|
+
* after a yes.
|
|
23
|
+
* - Every branch logs one reason label: `trusted_by_prompt`, `declined`,
|
|
24
|
+
* `non_interactive_skip`, `elevation_refused`, `write_failed:<reason>`,
|
|
25
|
+
* plus the no-question ones (`already_managed`, `already_trusted`,
|
|
26
|
+
* `declined_before`, `not_needed:<why>`, `unclear_answer`).
|
|
27
|
+
*/
|
|
28
|
+
import os from "node:os";
|
|
29
|
+
import { readLine, writeLine } from "./cli-io.js";
|
|
30
|
+
import { getCollectorRuntimePaths } from "../local-state.js";
|
|
31
|
+
import { isMemoryCaptureOff } from "../memory-capture-choice.js";
|
|
32
|
+
import { parseYesNo } from "../yes-no.js";
|
|
33
|
+
import { memoryChoicePromptBlocker } from "./memory-capture-prompt.js";
|
|
34
|
+
import { codexSystemHooksFile, defaultCodexElevationRunner, readCodexManagedHooks, writeCodexManagedHooks, } from "./codex-managed-hooks.js";
|
|
35
|
+
import { expectedCodexTrustRows, installCodexMemoryHooks, readCodexHookTrustFromDisk, } from "./memory-install-codex-hooks.js";
|
|
36
|
+
import { readCodexHooksTrustChoice, writeCodexHooksTrustChoice } from "./codex-hooks-trust-choice.js";
|
|
37
|
+
import { defaultMemoryFileIo } from "./memory-install-files.js";
|
|
38
|
+
import { resolveMemoryConfig } from "./memory-install-config.js";
|
|
39
|
+
export const CODEX_HOOKS_TRUST_QUESTION = "Auto-trust the BLI Memory hooks in Codex? [Y/n] ";
|
|
40
|
+
export const CODEX_HOOKS_TRUST_FLAG = "--trust-codex-hooks";
|
|
41
|
+
/**
|
|
42
|
+
* The whole question, start to finish. Never throws: a doctor run is never
|
|
43
|
+
* lost to it. Returns the reason label it logged.
|
|
44
|
+
*/
|
|
45
|
+
export async function settleCodexHooksTrust(io, options, deps = {}) {
|
|
46
|
+
try {
|
|
47
|
+
const outcome = await decide(io, options, deps);
|
|
48
|
+
console.error("[codex-hooks-trust] settled", JSON.stringify({ reason: outcome }));
|
|
49
|
+
return outcome;
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
const outcome = "write_failed:step_threw";
|
|
53
|
+
console.error("[codex-hooks-trust] settled", JSON.stringify({ reason: outcome, error_name: error instanceof Error ? error.name : typeof error }));
|
|
54
|
+
return outcome;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function decide(io, options, deps) {
|
|
58
|
+
// First, and before anything is read: a run with nobody at the keyboard
|
|
59
|
+
// never prompts and never changes trust. The receipt carries the state.
|
|
60
|
+
const blocker = memoryChoicePromptBlocker(io, options);
|
|
61
|
+
if (blocker) {
|
|
62
|
+
console.error("[codex-hooks-trust] not asked; nobody at a terminal", JSON.stringify({ reason: "non_interactive_skip", blocker }));
|
|
63
|
+
return "non_interactive_skip";
|
|
64
|
+
}
|
|
65
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
66
|
+
const paths = getCollectorRuntimePaths(homeDir);
|
|
67
|
+
const platform = deps.platform ?? process.platform;
|
|
68
|
+
const fileIo = deps.io ?? defaultMemoryFileIo();
|
|
69
|
+
const off = await (deps.memoryOff ?? (() => isMemoryCaptureOff(paths, { fetch: io.fetch })))();
|
|
70
|
+
if (off)
|
|
71
|
+
return "not_needed:memory_off";
|
|
72
|
+
const config = await (deps.resolveConfig ?? (() => defaultResolveConfig(io, options, platform)))();
|
|
73
|
+
if (!config)
|
|
74
|
+
return "not_needed:bin_missing";
|
|
75
|
+
const trust = await readCodexHookTrustFromDisk({ homeDir, io: fileIo });
|
|
76
|
+
if (trust.feature === "disabled")
|
|
77
|
+
return "not_needed:hooks_disabled_in_config";
|
|
78
|
+
const file = deps.systemHooksFile ?? codexSystemHooksFile(platform, io.env ?? process.env);
|
|
79
|
+
const managed = await readCodexManagedHooks({ config, io: fileIo, file });
|
|
80
|
+
if (managed.state === "current")
|
|
81
|
+
return "already_managed";
|
|
82
|
+
// A machine somebody already trusted in /hooks runs the hooks today. The
|
|
83
|
+
// question is for untrusted machines (and a stale managed copy).
|
|
84
|
+
if (managed.state !== "stale" && trust.trustedRows >= expectedCodexTrustRows(config))
|
|
85
|
+
return "already_trusted";
|
|
86
|
+
if (!options.trustCodexHooks) {
|
|
87
|
+
const choice = await readCodexHooksTrustChoice(paths);
|
|
88
|
+
if (choice.choice === "declined") {
|
|
89
|
+
writeLine(io.stdout, `Codex hooks: not auto-trusted (you said no earlier). \`cockpit doctor ${CODEX_HOOKS_TRUST_FLAG}\` trusts them.`);
|
|
90
|
+
return "declined_before";
|
|
91
|
+
}
|
|
92
|
+
const answer = await ask(io, managed.state === "stale");
|
|
93
|
+
if (answer === "unclear") {
|
|
94
|
+
writeLine(io.stdout, "No answer recorded; the next `cockpit doctor` asks again.");
|
|
95
|
+
return "unclear_answer";
|
|
96
|
+
}
|
|
97
|
+
if (answer === "no") {
|
|
98
|
+
await recordChoice(paths, "declined", "prompt");
|
|
99
|
+
writeLine(io.stdout, `OK, not trusted. Codex skips the BLI Memory hooks until they are trusted; this is not asked again. ` +
|
|
100
|
+
`Change your mind: cockpit doctor ${CODEX_HOOKS_TRUST_FLAG} (or trust them in Codex's /hooks).`);
|
|
101
|
+
return "declined";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
writeLine(io.stdout, platform === "win32"
|
|
105
|
+
? `Windows may ask once for administrator permission to write ${file}.`
|
|
106
|
+
: `This writes ${file}; your computer asks for your password once (sudo).`);
|
|
107
|
+
const written = await writeCodexManagedHooks({
|
|
108
|
+
config,
|
|
109
|
+
io: fileIo,
|
|
110
|
+
file,
|
|
111
|
+
platform,
|
|
112
|
+
elevate: deps.elevate ?? defaultCodexElevationRunner(),
|
|
113
|
+
});
|
|
114
|
+
if (written.outcome === "elevation_refused") {
|
|
115
|
+
writeLine(io.stdout, "Not trusted: the administrator step was cancelled. The next `cockpit doctor` asks again.");
|
|
116
|
+
return "elevation_refused";
|
|
117
|
+
}
|
|
118
|
+
if (written.outcome === "write_failed") {
|
|
119
|
+
writeLine(io.stdout, `Not trusted: the system hooks file could not be written (${written.reason}). Fallback: trust them in Codex's /hooks.`);
|
|
120
|
+
return written.reason;
|
|
121
|
+
}
|
|
122
|
+
// The system copy carries the hooks now; take the user copy out so nothing runs twice.
|
|
123
|
+
const userCopy = await installCodexMemoryHooks({ homeDir, config, dryRun: false, io: fileIo, systemHooksFile: file });
|
|
124
|
+
if (userCopy.status === "failed") {
|
|
125
|
+
console.error("[codex-hooks-trust] system copy written; user copy not removed", JSON.stringify({ reason: userCopy.reason }));
|
|
126
|
+
}
|
|
127
|
+
await recordChoice(paths, "trusted", options.trustCodexHooks ? "flag" : "prompt");
|
|
128
|
+
writeLine(io.stdout, "Codex hooks: trusted. Codex now runs the BLI Memory hooks as managed hooks, with no /hooks review.");
|
|
129
|
+
return options.trustCodexHooks ? "trusted_by_flag" : "trusted_by_prompt";
|
|
130
|
+
}
|
|
131
|
+
async function ask(io, stale) {
|
|
132
|
+
writeLine(io.stdout, stale
|
|
133
|
+
? "BLI Memory's Codex hooks changed since they were trusted on this machine. Yes updates the trusted copy."
|
|
134
|
+
: "Codex will not run BLI Memory's hooks (recall and save) until they are trusted. Yes trusts them for this machine, once.");
|
|
135
|
+
let verdict = parseYesNo(await readLine(io, CODEX_HOOKS_TRUST_QUESTION), { emptyIsYes: true });
|
|
136
|
+
if (verdict === "unclear") {
|
|
137
|
+
verdict = parseYesNo(await readLine(io, `Please answer y or n. ${CODEX_HOOKS_TRUST_QUESTION}`), { emptyIsYes: true });
|
|
138
|
+
}
|
|
139
|
+
return verdict;
|
|
140
|
+
}
|
|
141
|
+
async function recordChoice(paths, choice, via) {
|
|
142
|
+
try {
|
|
143
|
+
await writeCodexHooksTrustChoice(paths, choice, via);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
// The trust itself (or the "no") already happened; only the memory of it is lost.
|
|
147
|
+
console.error("[codex-hooks-trust] answer not saved", JSON.stringify({ reason: "choice_write_failed", choice, error_name: error instanceof Error ? error.name : typeof error }));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async function defaultResolveConfig(io, options, platform) {
|
|
151
|
+
const resolved = await resolveMemoryConfig({
|
|
152
|
+
kind: "memory",
|
|
153
|
+
action: "status",
|
|
154
|
+
...(options.homeDir ? { homeDir: options.homeDir } : {}),
|
|
155
|
+
...(options.dashboardUrl ? { dashboardUrl: options.dashboardUrl } : {}),
|
|
156
|
+
dryRun: false,
|
|
157
|
+
json: false,
|
|
158
|
+
}, io, platform, {});
|
|
159
|
+
return resolved.config;
|
|
160
|
+
}
|
|
161
|
+
/** What `cockpit doctor` calls: its own flags mapped onto the question's options. */
|
|
162
|
+
export function settleDoctorCodexHooksTrust(command, io) {
|
|
163
|
+
return settleCodexHooksTrust(io, {
|
|
164
|
+
json: command.json,
|
|
165
|
+
...(command.homeDir ? { homeDir: command.homeDir } : {}),
|
|
166
|
+
...(command.automaticTick ? { automaticTick: true } : {}),
|
|
167
|
+
dryRun: command.dryRun,
|
|
168
|
+
...(command.checkOnly ? { checkOnly: true } : {}),
|
|
169
|
+
dashboardUrl: command.dashboardUrl,
|
|
170
|
+
...(command.trustCodexHooks ? { trustCodexHooks: true } : {}),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BLI Memory's Codex hooks as MANAGED hooks (BLI-24349).
|
|
3
|
+
*
|
|
4
|
+
* Codex will not run a hook from a person's own `~/.codex/hooks.json` until
|
|
5
|
+
* they trust it in `/hooks`, and asks again whenever the hook text changes.
|
|
6
|
+
* Hooks in Codex's SYSTEM config folder are different: Codex marks them
|
|
7
|
+
* "managed, trusted by policy" and runs them with no review at all.
|
|
8
|
+
*
|
|
9
|
+
* macOS (and any Unix) /etc/codex/hooks.json
|
|
10
|
+
* Windows %ProgramData%\OpenAI\Codex\hooks.json
|
|
11
|
+
*
|
|
12
|
+
* ## Proven, not assumed (2026-11-04, Codex CLI 0.166.0)
|
|
13
|
+
*
|
|
14
|
+
* On a Linux box signed in with a personal ChatGPT account (plan `pro`, no
|
|
15
|
+
* Business workspace), with no `[hooks.state]` row anywhere, Codex's own
|
|
16
|
+
* `hooks/list` reported hooks in `/etc/codex/hooks.json` as `source: system,
|
|
17
|
+
* isManaged: true, trustStatus: managed`, and a real turn fired session-start,
|
|
18
|
+
* prompt and stop. The source agrees: `hook_metadata_for_config_layer_source`
|
|
19
|
+
* in openai/codex `codex-rs/hooks/src/engine/discovery.rs` maps the system
|
|
20
|
+
* config layer to `(HookSource::System, true)`, and nothing on that path reads
|
|
21
|
+
* the account. Windows was NOT run on a real machine (the test VM was out of
|
|
22
|
+
* reach); its folder comes from `windows_codex_system_dir` in
|
|
23
|
+
* `codex-rs/config/src/loader/mod.rs` (known folder ProgramData). Receipts:
|
|
24
|
+
* the BLI-24349 ticket comments and `docs/runbooks/codex-hooks-trust.md`.
|
|
25
|
+
*
|
|
26
|
+
* ## Why `hooks.json`, not `requirements.toml`
|
|
27
|
+
*
|
|
28
|
+
* Hooks in `requirements.toml` are managed too, but a broken write to that file
|
|
29
|
+
* stops Codex from starting at all ("failed to parse requirements layer",
|
|
30
|
+
* exit 1). A broken system `hooks.json` costs one warning line and the turn
|
|
31
|
+
* still runs. `requirements.toml` is also the admin policy file an
|
|
32
|
+
* organisation's device management owns; this module never touches it, and
|
|
33
|
+
* never sets `allow_managed_hooks_only` (that would switch off a person's own
|
|
34
|
+
* hooks).
|
|
35
|
+
*
|
|
36
|
+
* ## The write
|
|
37
|
+
*
|
|
38
|
+
* The system folder belongs to the administrator, so the write is tried as
|
|
39
|
+
* the person first (works on Windows when ProgramData lets users create
|
|
40
|
+
* folders, which is its default) and elevated only when that is refused:
|
|
41
|
+
* one `sudo` on macOS, one UAC prompt on Windows. Only our hook groups are
|
|
42
|
+
* replaced; anything else in the file survives, exactly like the user-scope
|
|
43
|
+
* writer (`applyHookGroups`). The result is read back off disk before it
|
|
44
|
+
* counts (BLI-2541).
|
|
45
|
+
*/
|
|
46
|
+
import { spawnSync } from "node:child_process";
|
|
47
|
+
import { randomBytes } from "node:crypto";
|
|
48
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
49
|
+
import os from "node:os";
|
|
50
|
+
import path from "node:path";
|
|
51
|
+
import { applyHookGroups, hookGroupsMatch, ourHookCount, parseJsonRecord, serializeJson, } from "./memory-install-claude.js";
|
|
52
|
+
/**
|
|
53
|
+
* Moves the system folder somewhere else. The test suite sets it
|
|
54
|
+
* (`vitest.setup.ts`) so no test ever reads or elevates into the real
|
|
55
|
+
* `/etc/codex` or ProgramData, the same guard it keeps on HOME. Read from the
|
|
56
|
+
* process too, because callers inject a trimmed `env` for other reasons.
|
|
57
|
+
*/
|
|
58
|
+
export const CODEX_SYSTEM_DIR_ENV = "COCKPIT_CODEX_SYSTEM_DIR";
|
|
59
|
+
/** Codex's system config folder on this platform. */
|
|
60
|
+
export function codexSystemDir(platform, env = process.env) {
|
|
61
|
+
const override = (env[CODEX_SYSTEM_DIR_ENV] ?? process.env[CODEX_SYSTEM_DIR_ENV] ?? "").trim();
|
|
62
|
+
if (override)
|
|
63
|
+
return override;
|
|
64
|
+
if (platform === "win32") {
|
|
65
|
+
const programData = (env["ProgramData"] ?? env["PROGRAMDATA"] ?? "").trim() || "C:\\ProgramData";
|
|
66
|
+
return path.win32.join(programData, "OpenAI", "Codex");
|
|
67
|
+
}
|
|
68
|
+
return "/etc/codex";
|
|
69
|
+
}
|
|
70
|
+
/** `/etc/codex/hooks.json`, or `%ProgramData%\OpenAI\Codex\hooks.json` on Windows. */
|
|
71
|
+
export function codexSystemHooksFile(platform, env = process.env) {
|
|
72
|
+
const dir = codexSystemDir(platform, env);
|
|
73
|
+
if ((env[CODEX_SYSTEM_DIR_ENV] ?? process.env[CODEX_SYSTEM_DIR_ENV] ?? "").trim())
|
|
74
|
+
return path.join(dir, "hooks.json");
|
|
75
|
+
return platform === "win32" ? path.win32.join(dir, "hooks.json") : path.posix.join(dir, "hooks.json");
|
|
76
|
+
}
|
|
77
|
+
/** An injected override (null = do not look) or this platform's system hooks file. */
|
|
78
|
+
export function codexSystemHooksFileFor(override, platform, env) {
|
|
79
|
+
return override !== undefined ? override : codexSystemHooksFile(platform, env ?? process.env);
|
|
80
|
+
}
|
|
81
|
+
export async function readCodexManagedHooks(options) {
|
|
82
|
+
let raw;
|
|
83
|
+
try {
|
|
84
|
+
raw = await options.io.readText(options.file);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return { state: "unreadable", file: options.file };
|
|
88
|
+
}
|
|
89
|
+
if (raw === null || !raw.trim())
|
|
90
|
+
return { state: "absent", file: options.file };
|
|
91
|
+
const root = parseJsonRecord(raw);
|
|
92
|
+
if (!root)
|
|
93
|
+
return { state: "unreadable", file: options.file };
|
|
94
|
+
if (hookGroupsMatch(root, options.config.hooks))
|
|
95
|
+
return { state: "current", file: options.file };
|
|
96
|
+
return { state: ourHookCount(root) > 0 ? "stale" : "absent", file: options.file };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Puts our four hooks into the system file. Never throws.
|
|
100
|
+
*
|
|
101
|
+
* Order: read what is there (refuse to replace a file that will not parse,
|
|
102
|
+
* same rule as every other config we touch), merge our groups in, try the
|
|
103
|
+
* write as the person, elevate only on a permission refusal, read back.
|
|
104
|
+
*/
|
|
105
|
+
export async function writeCodexManagedHooks(options) {
|
|
106
|
+
const { file } = options;
|
|
107
|
+
let raw;
|
|
108
|
+
try {
|
|
109
|
+
raw = await options.io.readText(file);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
// Reading a world-readable file failed for a reason other than "absent".
|
|
113
|
+
return { outcome: "write_failed", reason: `write_failed:${errorCode(error, "read_failed")}`, file };
|
|
114
|
+
}
|
|
115
|
+
const existing = raw === null || !raw.trim() ? {} : parseJsonRecord(raw);
|
|
116
|
+
if (!existing) {
|
|
117
|
+
// Somebody else's system hooks file that does not parse: not ours to replace.
|
|
118
|
+
return { outcome: "write_failed", reason: "write_failed:system_file_unparseable", file };
|
|
119
|
+
}
|
|
120
|
+
if (hookGroupsMatch(existing, options.config.hooks)) {
|
|
121
|
+
return { outcome: "written", reason: "already_current", file };
|
|
122
|
+
}
|
|
123
|
+
const contents = serializeJson(applyHookGroups(structuredClone(existing), options.config.hooks), raw ?? "");
|
|
124
|
+
let reason = "wrote_direct";
|
|
125
|
+
try {
|
|
126
|
+
await options.io.writeText(file, contents);
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
const code = errorCode(error, "write_error");
|
|
130
|
+
if (code !== "EACCES" && code !== "EPERM") {
|
|
131
|
+
return { outcome: "write_failed", reason: `write_failed:${code}`, file };
|
|
132
|
+
}
|
|
133
|
+
console.error("[codex-hooks-trust] the system hooks folder needs an administrator; asking the OS once", JSON.stringify({ reason: "elevation_needed", platform: options.platform, error_code: code }));
|
|
134
|
+
const elevated = await elevatedCopy({ ...options, contents });
|
|
135
|
+
if (elevated.status === "refused")
|
|
136
|
+
return { outcome: "elevation_refused", reason: "elevation_refused", file };
|
|
137
|
+
if (elevated.status === "failed")
|
|
138
|
+
return { outcome: "write_failed", reason: `write_failed:${elevated.reason}`, file };
|
|
139
|
+
reason = "wrote_elevated";
|
|
140
|
+
}
|
|
141
|
+
// BLI-2541: what the disk holds, not what we sent.
|
|
142
|
+
const stored = await readCodexManagedHooks({ config: options.config, io: options.io, file });
|
|
143
|
+
if (stored.state !== "current") {
|
|
144
|
+
return { outcome: "write_failed", reason: "write_failed:read_back_mismatch", file };
|
|
145
|
+
}
|
|
146
|
+
return { outcome: "written", reason, file };
|
|
147
|
+
}
|
|
148
|
+
async function elevatedCopy(options) {
|
|
149
|
+
let stagingDir = null;
|
|
150
|
+
try {
|
|
151
|
+
stagingDir = await mkdtemp(path.join(options.tempDir ?? os.tmpdir(), "cockpit-codex-hooks-"));
|
|
152
|
+
const stagedFile = path.join(stagingDir, `hooks.${randomBytes(4).toString("hex")}.json`);
|
|
153
|
+
await writeFile(stagedFile, options.contents, "utf8");
|
|
154
|
+
const pathApi = options.platform === "win32" ? path.win32 : path.posix;
|
|
155
|
+
return await options.elevate({
|
|
156
|
+
platform: options.platform,
|
|
157
|
+
stagedFile,
|
|
158
|
+
targetDir: pathApi.dirname(options.file),
|
|
159
|
+
targetFile: options.file,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
return { status: "failed", reason: errorCode(error, "staging_failed") };
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
if (stagingDir)
|
|
167
|
+
await rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The real elevation: `sudo` on macOS, UAC on Windows. Both talk to the person
|
|
172
|
+
* directly (sudo reads the password from the terminal, UAC is its own secure
|
|
173
|
+
* desktop), so nothing secret ever passes through this process.
|
|
174
|
+
*/
|
|
175
|
+
export function defaultCodexElevationRunner(spawn = spawnSync) {
|
|
176
|
+
return async (request) => (request.platform === "win32" ? elevateWindows(request, spawn) : elevateUnix(request, spawn));
|
|
177
|
+
}
|
|
178
|
+
function elevateUnix(request, spawn) {
|
|
179
|
+
// `sudo -v` is the one password prompt; a wrong password three times, Ctrl-C
|
|
180
|
+
// or a machine where this person is not an administrator all end here.
|
|
181
|
+
const validate = spawn("sudo", ["-v"], { stdio: ["inherit", "inherit", "inherit"], windowsHide: true });
|
|
182
|
+
if (validate.error)
|
|
183
|
+
return { status: "failed", reason: errorCode(validate.error, "sudo_unavailable") };
|
|
184
|
+
if (validate.status !== 0)
|
|
185
|
+
return { status: "refused" };
|
|
186
|
+
// `-n`: the credential is cached now, so this never prompts a second time.
|
|
187
|
+
// Paths travel as arguments, never spliced into the script.
|
|
188
|
+
const copy = spawn("sudo", ["-n", "/bin/sh", "-c", 'mkdir -p "$1" && cp "$2" "$3" && chmod 644 "$3"', "sh", request.targetDir, request.stagedFile, request.targetFile], { stdio: ["ignore", "inherit", "inherit"], windowsHide: true });
|
|
189
|
+
if (copy.error)
|
|
190
|
+
return { status: "failed", reason: errorCode(copy.error, "sudo_copy_failed") };
|
|
191
|
+
return copy.status === 0 ? { status: "ok" } : { status: "failed", reason: `elevated_exit_${copy.status ?? "signal"}` };
|
|
192
|
+
}
|
|
193
|
+
/** Windows exit code for "the operation was cancelled by the user" (UAC "No"). */
|
|
194
|
+
const ERROR_CANCELLED = 1223;
|
|
195
|
+
function elevateWindows(request, spawn) {
|
|
196
|
+
const inner = codexWindowsElevatedScript(request);
|
|
197
|
+
const encoded = Buffer.from(inner, "utf16le").toString("base64");
|
|
198
|
+
const outer = "try { $p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden " +
|
|
199
|
+
`-ArgumentList @('-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-EncodedCommand','${encoded}'); exit $p.ExitCode } ` +
|
|
200
|
+
`catch { exit ${ERROR_CANCELLED} }`;
|
|
201
|
+
const result = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", outer], {
|
|
202
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
203
|
+
windowsHide: true,
|
|
204
|
+
});
|
|
205
|
+
if (result.error)
|
|
206
|
+
return { status: "failed", reason: errorCode(result.error, "powershell_unavailable") };
|
|
207
|
+
if (result.status === ERROR_CANCELLED)
|
|
208
|
+
return { status: "refused" };
|
|
209
|
+
return result.status === 0 ? { status: "ok" } : { status: "failed", reason: `elevated_exit_${result.status ?? "signal"}` };
|
|
210
|
+
}
|
|
211
|
+
/** The script the elevated PowerShell runs. Exported for its test; paths are single-quoted literals. */
|
|
212
|
+
export function codexWindowsElevatedScript(request) {
|
|
213
|
+
const quote = (value) => `'${value.replace(/'/gu, "''")}'`;
|
|
214
|
+
return [
|
|
215
|
+
"$ErrorActionPreference = 'Stop'",
|
|
216
|
+
`New-Item -ItemType Directory -Force -Path ${quote(request.targetDir)} | Out-Null`,
|
|
217
|
+
`Copy-Item -LiteralPath ${quote(request.stagedFile)} -Destination ${quote(request.targetFile)} -Force`,
|
|
218
|
+
"exit 0",
|
|
219
|
+
].join("; ");
|
|
220
|
+
}
|
|
221
|
+
/** A short, charset-safe code for a failure: `EACCES`, `ENOENT`, or the fallback. */
|
|
222
|
+
function errorCode(error, fallback) {
|
|
223
|
+
const code = error?.code;
|
|
224
|
+
const raw = typeof code === "string" && code.trim() ? code : fallback;
|
|
225
|
+
return raw.replace(/[^A-Za-z0-9_.-]/gu, "_").slice(0, 40);
|
|
226
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { codexHooksTrustAction, setupReceiptPieces } from "@bli-cockpit/telemetry-core";
|
|
2
2
|
import { redactedHealthDetail } from "../health-detail.js";
|
|
3
3
|
import { setupReceiptBlock } from "./setup-receipt-lines.js";
|
|
4
4
|
export function ok(id, code, message) {
|
|
@@ -194,7 +194,7 @@ export function doctorNeedsPerson(rows, receipt) {
|
|
|
194
194
|
const step = key === "device" || key === "browser" ? "authed" : key === "collector.autostart" ? "autostart-alive" : "memory-registered";
|
|
195
195
|
if (piece.status !== "needs_trust" && items.some((item) => item.step === step))
|
|
196
196
|
continue;
|
|
197
|
-
items.push({ step: key, reason: piece.reason ?? piece.status, action: piece.status === "needs_trust" ?
|
|
197
|
+
items.push({ step: key, reason: piece.reason ?? piece.status, action: piece.status === "needs_trust" ? codexHooksTrustAction(piece.reason) : defaults[step] });
|
|
198
198
|
}
|
|
199
199
|
}
|
|
200
200
|
return items;
|
|
@@ -80,6 +80,8 @@ export function reexecDoctor(command, io) {
|
|
|
80
80
|
args.push("--no-usage-telemetry");
|
|
81
81
|
if (command.noMemory)
|
|
82
82
|
args.push("--no-memory");
|
|
83
|
+
if (command.trustCodexHooks)
|
|
84
|
+
args.push("--trust-codex-hooks");
|
|
83
85
|
if (command.homeDir)
|
|
84
86
|
args.push("--home", command.homeDir);
|
|
85
87
|
if (command.backfillBudgetSeconds)
|
package/dist/commands/doctor.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { checkSlackConnected } from "./doctor-slack.js";
|
|
2
2
|
import { checkUsageTelemetry, fixUsageTelemetry } from "./doctor-usage-telemetry.js";
|
|
3
3
|
import { settleDoctorMemoryChoice } from "./memory-capture-prompt.js";
|
|
4
|
+
import { settleDoctorCodexHooksTrust } from "./codex-hooks-trust-prompt.js";
|
|
4
5
|
import { reportIgnoredOptOuts } from "../capture-opt-outs.js";
|
|
5
6
|
import { checkShelfRouting, fixShelfRouting } from "./doctor-shelf.js";
|
|
6
7
|
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION } from "../local-state.js";
|
|
@@ -105,6 +106,7 @@ export async function runDoctorWithDeps(command, io, deps) {
|
|
|
105
106
|
console.error("[doctor] row done", JSON.stringify({ row: invariant.id, elapsed_ms: rowCheckMs + fixMs, check_ms: rowCheckMs, fix_ms: fixMs, verdict: row.status, reason: row.code }));
|
|
106
107
|
rows.push(row);
|
|
107
108
|
}
|
|
109
|
+
await (deps.settleCodexHooksTrust ?? ((c) => settleDoctorCodexHooksTrust(c.command, c.io)))(context); // BLI-24349: after memory-registered, before the re-read
|
|
108
110
|
// Re-read host state after repairs. Sync is proven by its upload receipts;
|
|
109
111
|
// re-running its diagnostic would always request a new upload by design.
|
|
110
112
|
if (!diagnoseOnly) {
|
|
@@ -96,6 +96,7 @@ const DOCTOR_ALLOWED_FLAGS = [
|
|
|
96
96
|
"--no-repair",
|
|
97
97
|
"--no-usage-telemetry",
|
|
98
98
|
"--no-memory",
|
|
99
|
+
"--trust-codex-hooks",
|
|
99
100
|
"--lock-wait",
|
|
100
101
|
"--backfill-budget",
|
|
101
102
|
"--json",
|
|
@@ -143,6 +144,7 @@ export function parseDoctorArgs(alias, args) {
|
|
|
143
144
|
kind: "doctor",
|
|
144
145
|
noUsageTelemetry: values.booleans.has("--no-usage-telemetry"),
|
|
145
146
|
noMemory: values.booleans.has("--no-memory"),
|
|
147
|
+
trustCodexHooks: values.booleans.has("--trust-codex-hooks"),
|
|
146
148
|
checkOnly: values.booleans.has("--check") || values.booleans.has("--no-repair"),
|
|
147
149
|
// Absent stays absent (BLI-11101): `doctor-budget.ts` owns the defaults,
|
|
148
150
|
// and `--json` without either flag runs under one bounded budget.
|
|
@@ -191,11 +191,13 @@ export function parseMemoryArgs(args) {
|
|
|
191
191
|
throw new Error("memory log takes one reason or --reason-stdin, never --dry-run");
|
|
192
192
|
if (verdict !== "win" && verdict !== "loss" && verdict !== "noise")
|
|
193
193
|
throw new Error("memory log needs win|loss|noise");
|
|
194
|
-
|
|
195
|
-
|
|
194
|
+
// BLI-24380: BLI Memory is the only store, so --store is optional (bli). The retired
|
|
195
|
+
// `supermemory` and `both` still parse; runMemoryLog logs them as retired, never an error.
|
|
196
|
+
if (store !== undefined && store !== "bli" && store !== "supermemory" && store !== "both")
|
|
197
|
+
throw new Error("memory log --store takes bli (the only store)");
|
|
196
198
|
if (!reasonStdin && !reason.trim())
|
|
197
199
|
throw new Error("memory log needs a reason");
|
|
198
|
-
return { kind: "memory-log", verdict, store, reason, reasonStdin, homeDir: optionalNonEmpty(values.flags.get("--home")), dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")), json: values.booleans.has("--json") };
|
|
200
|
+
return { kind: "memory-log", verdict, store: store ?? "bli", reason, reasonStdin, homeDir: optionalNonEmpty(values.flags.get("--home")), dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")), json: values.booleans.has("--json") };
|
|
199
201
|
}
|
|
200
202
|
if (values.flags.has("--store") || values.booleans.has("--reason-stdin"))
|
|
201
203
|
throw new Error("--store and --reason-stdin require memory log");
|
|
@@ -480,7 +480,8 @@ export const TOWER_COMMAND_HELP = [
|
|
|
480
480
|
"log appends an opinion to ~/.codex/AGENT-EXPERIENCE.md and posts it to Tower.",
|
|
481
481
|
"Use --reason-stdin instead of a quoted reason; --json reports shipped status.",
|
|
482
482
|
"Reasons: one line, max 500 characters; no prompts, memory bodies or secrets.",
|
|
483
|
-
"
|
|
483
|
+
"--store is optional: bli (BLI Memory) is the only store. The retired supermemory and both",
|
|
484
|
+
"values are accepted and logged as retired: both records as bli, supermemory records nothing.",
|
|
484
485
|
"Offline entries stay queued; the next collector sync retries with the same ID.",
|
|
485
486
|
],
|
|
486
487
|
],
|
|
@@ -23,7 +23,7 @@ import { TOWER_COMMAND_HELP } from "./local-help-commands-tower.js";
|
|
|
23
23
|
import { TOWER_OPS_COMMAND_HELP } from "./local-help-commands-tower-ops.js";
|
|
24
24
|
/** One manual per collector command; the Tower nouns are spread in from their own file. */
|
|
25
25
|
const COMMAND_HELP = new Map([
|
|
26
|
-
["books", [BOOKS_USAGE, "Client Books: each engagement's record (timeline, actions, documents, meetings, ...). A member of a book reads it; a member with edit access, or a founder, writes it; only founders create books and manage members. put without --merge replaces the record's data; --merge changes only the fields given. --version <n> makes a write conditional: refused as version_conflict when someone changed the record since you read version n (0 = must not exist yet). delete is soft. export writes <dir>/<collection>/<doc_id>.json; import reads that layout and writes 50 records per batch. MCP twins: books_list, books_create, books_members, books_read, books_write, books_batch. Docs: docs/architecture/client-books.md."]],
|
|
26
|
+
["books", [BOOKS_USAGE, "Client Books: each engagement's record (timeline, actions, documents, meetings, ...). A member of a book reads it; a member with edit access, or a founder, writes it; only founders create books and manage members. put without --merge replaces the record's data; --merge changes only the fields given. --version <n> makes a write conditional: refused as version_conflict when someone changed the record since you read version n (0 = must not exist yet). delete is soft. export writes <dir>/<collection>/<doc_id>.json; import reads that layout and writes 50 records per batch. archive hides a book from the Client Books page; archive --restore returns it; nothing is deleted. list marks archived books. MCP twins: books_list, books_archive, books_create, books_members, books_read, books_write, books_batch. Docs: docs/architecture/client-books.md."]],
|
|
27
27
|
["glossary", ["Usage: cockpit glossary list|add <wrong> --to <name>|remove <wrong> [--json]", "Quote phrases. Removing a form prevents future corrections of it."]],
|
|
28
28
|
["skills", ["Usage: cockpit skills library [--weeks <1-52>] [--json] | cockpit skills refresh [--weeks <1-104>] [--json]", "The Skill Library: every agent skill and command our GitHub repos define (name, description, repo, path, versions) with weekly use by person, from the sessions Tower already collects. Uses that match no scanned skill are listed as unmatched, never dropped. The first line is the coverage: repos scanned, skills, invocations matched of total. You see your own usage; Vee, Jeetesh and Ed see everyone's except a founder's. refresh scans the repos and recomputes the weekly totals now (super_admin only; a stale library also refreshes itself when read). MCP twins: skills_library, skills_refresh. Web: TEAM > Skills."]],
|
|
29
29
|
["push", ["Usage: cockpit push status | test [--dashboard-url <url>] [--json]", "Phone notifications. status lists your saved devices and whether the server has its push keys; test sends a test push to every saved device. Turn a phone on from Settings on the phone."]],
|
|
@@ -105,7 +105,7 @@ const COMMAND_HELP = new Map([
|
|
|
105
105
|
[
|
|
106
106
|
"doctor",
|
|
107
107
|
[
|
|
108
|
-
"Usage: cockpit doctor [--workspace <path>] [--check | --no-repair] [--no-usage-telemetry] [--no-memory] [--lock-wait <seconds>] [--backfill-budget <seconds>] [--json]",
|
|
108
|
+
"Usage: cockpit doctor [--workspace <path>] [--check | --no-repair] [--no-usage-telemetry] [--no-memory] [--trust-codex-hooks] [--lock-wait <seconds>] [--backfill-budget <seconds>] [--json]",
|
|
109
109
|
"",
|
|
110
110
|
"Diagnose, repair, and verify this machine.",
|
|
111
111
|
"Repairs CLI, background sync, sign-in, roots, memory and hooks, agent rules, catch-up, uploads, and cleanup.",
|
|
@@ -113,6 +113,7 @@ const COMMAND_HELP = new Map([
|
|
|
113
113
|
"--no-usage-telemetry switches off Claude Code and Codex usage export, including later doctor runs.",
|
|
114
114
|
"--no-memory turns BLI Memory off on this machine (same as `cockpit memory off`), including later runs and the background sync.",
|
|
115
115
|
"In a terminal, the first doctor or onboard asks once whether BLI Memory may save your sessions; `cockpit memory on|off` changes the answer.",
|
|
116
|
+
"In a terminal, doctor also asks once \"Auto-trust the BLI Memory hooks in Codex? [Y/n]\" while Codex has not trusted them; yes (the default) writes them to Codex's system hooks folder with one sudo or Windows administrator prompt, so Codex never asks again. A no is remembered; --trust-codex-hooks trusts them without the question. Background and --json runs never ask.",
|
|
116
117
|
"--check or --no-repair diagnoses without repairs. --dry-run previews repairs.",
|
|
117
118
|
"--backfill-budget defaults to 900 seconds; catch-up repeats chunks until complete, with progress every 30 seconds.",
|
|
118
119
|
"Doctor never waits for the background sync: a running sync is reported as running (with how long ago it started) and doctor finishes; a sync that stopped responding or made no progress for 15 minutes is ended and doctor carries on. --lock-wait is accepted and changes nothing. Progress prints at most every 30 seconds and says what changed.",
|
|
@@ -152,7 +152,7 @@ const COMMAND_SUMMARY_LINES = [
|
|
|
152
152
|
" cockpit serve [--port <port>] [--workspace <path>]",
|
|
153
153
|
" cockpit autostart [install|uninstall|status] [--workspace <path>] [--dashboard-url <url>] [--interval-seconds <n>] [--json]",
|
|
154
154
|
" cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--workspace <path>] [--json]",
|
|
155
|
-
" cockpit memory [search \"<question>\"|save|update <id>|forget <id>|profile|on|off|install|uninstall|status|log <win|loss|noise> --store
|
|
155
|
+
" cockpit memory [search \"<question>\"|save|update <id>|forget <id>|profile|on|off|install|uninstall|status|log <win|loss|noise> [--store bli] \"<reason>\"] [--container <tag>] [--limit <n>] [--no-recent] [--include-originals|--no-originals] [--include-source|--no-source] [--custom-id <id>] [--extract] [--content-stdin] [--reason <label>] [--file <path>] [--dashboard-url <url>] [--dry-run] [--json]",
|
|
156
156
|
" cockpit clean [--dry-run] [--all-committed] [--reconcile] [--dashboard-url <url>] [--json]",
|
|
157
157
|
" cockpit sources check [--reconcile] [--older-than <days>] [--dashboard-url <url>] [--json]",
|
|
158
158
|
" cockpit docs [list|tree|read <id|slug>|create --title <t>|update <id>|delete <id|slug>|trash|versions <id|slug>|restore <id>] [--parent <id>|root|--clear-parent] [--query <text> [--word]] [--limit <n>] [--visibility org|private] [--file <path>|--body-stdin] [--allow-empty] [--dashboard-url <url>] [--json]",
|
|
@@ -38,7 +38,16 @@
|
|
|
38
38
|
* `needs_trust` exists to name — and it is the one step an intern has to
|
|
39
39
|
* do by hand.
|
|
40
40
|
*
|
|
41
|
-
* ##
|
|
41
|
+
* ## Managed hooks (BLI-24349)
|
|
42
|
+
*
|
|
43
|
+
* Doctor's "Auto-trust the BLI Memory hooks in Codex?" puts the same hooks in
|
|
44
|
+
* Codex's SYSTEM folder (`codex-managed-hooks.ts`), where Codex runs them with
|
|
45
|
+
* no trust step. When that copy is there, the installer below takes the
|
|
46
|
+
* user-scope copy OUT instead of writing it (nothing runs twice), and the
|
|
47
|
+
* inspector reports the system file. Proven on a real Codex 0.166.0 run; see
|
|
48
|
+
* that module's header.
|
|
49
|
+
*
|
|
50
|
+
* ## What is NOT verified (user-scope route)
|
|
42
51
|
*
|
|
43
52
|
* Nobody has yet trusted these hooks on a real machine, so the wiring past the
|
|
44
53
|
* write is unproven end to end: the file shape, the event names and the trust
|
|
@@ -59,14 +68,36 @@
|
|
|
59
68
|
* A gauge that can only be too pessimistic is the right way round for this one.
|
|
60
69
|
*/
|
|
61
70
|
import path from "node:path";
|
|
62
|
-
import { applyHookGroups, applyJsonTarget, hookGroupsMatch, inspectJsonTarget, ourHookCount, } from "./memory-install-claude.js";
|
|
71
|
+
import { applyHookGroups, applyJsonTarget, hookGroupsMatch, inspectJsonTarget, ourHookCount, parseJsonRecord, serializeJson, stripOurHooks, } from "./memory-install-claude.js";
|
|
72
|
+
import { codexSystemHooksFile, readCodexManagedHooks, } from "./codex-managed-hooks.js";
|
|
63
73
|
export function codexHooksFile(homeDir) {
|
|
64
74
|
return path.join(homeDir, ".codex", "hooks.json");
|
|
65
75
|
}
|
|
66
76
|
export function codexConfigTomlFile(homeDir) {
|
|
67
77
|
return path.join(homeDir, ".codex", "config.toml");
|
|
68
78
|
}
|
|
79
|
+
function systemHooksFileFor(options) {
|
|
80
|
+
return options.systemHooksFile === undefined ? codexSystemHooksFile(process.platform) : options.systemHooksFile;
|
|
81
|
+
}
|
|
82
|
+
/** The managed reading, or null when nobody asked to look. Never throws. */
|
|
83
|
+
export async function readCodexManagedHooksFor(options) {
|
|
84
|
+
const file = systemHooksFileFor(options);
|
|
85
|
+
if (!file)
|
|
86
|
+
return null;
|
|
87
|
+
return readCodexManagedHooks({ config: options.config, io: options.io, file });
|
|
88
|
+
}
|
|
69
89
|
export async function installCodexMemoryHooks(options) {
|
|
90
|
+
// BLI-24349: once the hooks live in Codex's system folder (managed, run
|
|
91
|
+
// with no trust step), a user-scope copy would make every hook run twice
|
|
92
|
+
// the day somebody also trusts it in /hooks. So the user copy is taken out
|
|
93
|
+
// instead of written, and the target names the system file. A STALE
|
|
94
|
+
// system copy is treated the same way: the old managed hooks keep running,
|
|
95
|
+
// and doctor asks once to refresh them (the receipt says `needs_trust`,
|
|
96
|
+
// reason `managed_hooks_stale`).
|
|
97
|
+
const managed = await readCodexManagedHooksFor(options);
|
|
98
|
+
if (managed && (managed.state === "current" || managed.state === "stale")) {
|
|
99
|
+
return stripUserScopeCopy(options, managed);
|
|
100
|
+
}
|
|
70
101
|
return applyJsonTarget({
|
|
71
102
|
target: "codex_hooks",
|
|
72
103
|
file: codexHooksFile(options.homeDir),
|
|
@@ -76,6 +107,15 @@ export async function installCodexMemoryHooks(options) {
|
|
|
76
107
|
});
|
|
77
108
|
}
|
|
78
109
|
export async function inspectCodexMemoryHooks(options) {
|
|
110
|
+
const managed = await readCodexManagedHooksFor(options);
|
|
111
|
+
if (managed && (managed.state === "current" || managed.state === "stale")) {
|
|
112
|
+
return {
|
|
113
|
+
target: "codex_hooks",
|
|
114
|
+
status: "installed",
|
|
115
|
+
reason: managed.state === "current" ? "managed_by_system" : "managed_hooks_stale",
|
|
116
|
+
path: managed.file,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
79
119
|
return inspectJsonTarget({
|
|
80
120
|
target: "codex_hooks",
|
|
81
121
|
file: codexHooksFile(options.homeDir),
|
|
@@ -84,6 +124,59 @@ export async function inspectCodexMemoryHooks(options) {
|
|
|
84
124
|
matches: (root) => hookGroupsMatch(root, options.config.hooks),
|
|
85
125
|
});
|
|
86
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Takes OUR groups out of `~/.codex/hooks.json` (a person's own hooks stay),
|
|
129
|
+
* because the system copy now carries them. Same read-back rule as every
|
|
130
|
+
* other write here.
|
|
131
|
+
*/
|
|
132
|
+
async function stripUserScopeCopy(options, managed) {
|
|
133
|
+
const reason = managed.state === "current" ? "managed_by_system" : "managed_hooks_stale";
|
|
134
|
+
const file = codexHooksFile(options.homeDir);
|
|
135
|
+
let raw;
|
|
136
|
+
try {
|
|
137
|
+
raw = await options.io.readText(file);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
return { target: "codex_hooks", status: "failed", reason: "user_copy_unreadable", path: file, detail: error instanceof Error ? error.name : "unknown" };
|
|
141
|
+
}
|
|
142
|
+
const root = raw === null || !raw.trim() ? null : parseJsonRecord(raw);
|
|
143
|
+
if (!root || ourHookCount(root) === 0) {
|
|
144
|
+
return { target: "codex_hooks", status: "already", reason, path: managed.file };
|
|
145
|
+
}
|
|
146
|
+
if (options.dryRun) {
|
|
147
|
+
return { target: "codex_hooks", status: "would_install", reason: "dry_run", path: file, detail: "would remove the user-scope copy; the system copy carries the hooks" };
|
|
148
|
+
}
|
|
149
|
+
const next = removeOurGroups(structuredClone(root));
|
|
150
|
+
try {
|
|
151
|
+
await options.io.writeText(file, serializeJson(next, raw ?? ""));
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
return { target: "codex_hooks", status: "failed", reason: "user_copy_write_failed", path: file, detail: error instanceof Error ? error.name : "unknown" };
|
|
155
|
+
}
|
|
156
|
+
const storedRaw = await options.io.readText(file);
|
|
157
|
+
const stored = storedRaw === null ? null : parseJsonRecord(storedRaw);
|
|
158
|
+
if (!stored || ourHookCount(stored) > 0) {
|
|
159
|
+
return { target: "codex_hooks", status: "failed", reason: "read_back_mismatch", path: file, detail: "the user-scope copy is still there after the write" };
|
|
160
|
+
}
|
|
161
|
+
console.error("[codex-hooks-trust] user-scope copy removed; Codex runs the system copy", JSON.stringify({ reason: "user_copy_removed", managed_state: managed.state }));
|
|
162
|
+
return { target: "codex_hooks", status: "installed", reason, path: managed.file, detail: "user-scope copy removed" };
|
|
163
|
+
}
|
|
164
|
+
function removeOurGroups(root) {
|
|
165
|
+
const hooks = root["hooks"];
|
|
166
|
+
if (!hooks || typeof hooks !== "object" || Array.isArray(hooks))
|
|
167
|
+
return root;
|
|
168
|
+
const record = hooks;
|
|
169
|
+
for (const [event, groups] of Object.entries(record)) {
|
|
170
|
+
if (!Array.isArray(groups))
|
|
171
|
+
continue;
|
|
172
|
+
const kept = stripOurHooks(groups).groups;
|
|
173
|
+
if (kept.length === 0)
|
|
174
|
+
delete record[event];
|
|
175
|
+
else
|
|
176
|
+
record[event] = kept;
|
|
177
|
+
}
|
|
178
|
+
return root;
|
|
179
|
+
}
|
|
87
180
|
/**
|
|
88
181
|
* Reads the trust state out of `~/.codex/config.toml` WITHOUT parsing the
|
|
89
182
|
* whole file.
|
|
@@ -86,6 +86,18 @@ function judgeCodexHooks(input, reasons) {
|
|
|
86
86
|
reasons[key] = "hooks_disabled_in_config";
|
|
87
87
|
return "unsupported";
|
|
88
88
|
}
|
|
89
|
+
// BLI-24349: hooks in Codex's system folder are managed and run with no
|
|
90
|
+
// trust step. `ok` carries its reason so the board can say HOW it is ok.
|
|
91
|
+
if (input.codexManaged === "current") {
|
|
92
|
+
reasons[key] = "managed_by_system";
|
|
93
|
+
return "ok";
|
|
94
|
+
}
|
|
95
|
+
if (input.codexManaged === "stale") {
|
|
96
|
+
// The old managed copy may still run, but not today's hooks; doctor asks
|
|
97
|
+
// once to refresh it, so the action is the same as an untrusted machine's.
|
|
98
|
+
reasons[key] = "managed_hooks_stale";
|
|
99
|
+
return "needs_trust";
|
|
100
|
+
}
|
|
89
101
|
const target = input.targets.find((entry) => entry.target === "codex_hooks");
|
|
90
102
|
if (!target) {
|
|
91
103
|
reasons[key] = input.binFound ? "target_not_checked" : "bin_missing";
|
|
@@ -105,7 +117,11 @@ function judgeCodexHooks(input, reasons) {
|
|
|
105
117
|
if (input.codexTrust.trustedRows >= input.expectedTrustRows)
|
|
106
118
|
return "ok";
|
|
107
119
|
reasons[key] =
|
|
108
|
-
input.codexTrust.disabledRows > 0
|
|
120
|
+
input.codexTrust.disabledRows > 0
|
|
121
|
+
? "trust_rows_disabled"
|
|
122
|
+
: input.codexTrustDeclined
|
|
123
|
+
? "trust_declined"
|
|
124
|
+
: "no_trust_rows";
|
|
109
125
|
return "needs_trust";
|
|
110
126
|
}
|
|
111
127
|
/**
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* one outcome, and rendering that outcome for a log line or a terminal.
|
|
4
4
|
* Sibling of `memory-install.ts`, named in its header.
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import { codexHooksTrustAction, memoryInstallGaps } from "@bli-cockpit/telemetry-core";
|
|
7
7
|
import { memoryDaemonLine } from "./memory-daemon-probe.js";
|
|
8
8
|
import { memoryReceiptLine } from "./memory-install-receipt.js";
|
|
9
9
|
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
|
|
@@ -102,9 +102,10 @@ export function memoryOutcomeLines(outcome) {
|
|
|
102
102
|
// The trust step is the one thing a person has to do by hand, so it is said
|
|
103
103
|
// here in words rather than left as a status word they have to look up.
|
|
104
104
|
if (outcome.receipt.codex.hooks === "needs_trust") {
|
|
105
|
-
lines.push(
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
lines.push(` Codex will NOT run these hooks until they are trusted. ${codexHooksTrustAction(outcome.receipt.reasons?.["codex.hooks"])}`);
|
|
106
|
+
}
|
|
107
|
+
if (outcome.receipt.codex.hooks === "ok" && outcome.receipt.reasons?.["codex.hooks"] === "managed_by_system") {
|
|
108
|
+
lines.push(" Codex runs these hooks from its system hooks folder (managed), so no /hooks trust step is needed.");
|
|
108
109
|
}
|
|
109
110
|
if (outcome.receipt.codex.hooks === "unsupported") {
|
|
110
111
|
lines.push(" Codex hooks are turned off in your ~/.codex/config.toml ([features] hooks = false), " +
|
|
@@ -52,7 +52,9 @@ import { removeMemoryIntegration } from "./memory-uninstall.js";
|
|
|
52
52
|
import { readEffectiveMemoryCapture, readMemoryCaptureChoice, writeMemoryCaptureChoice } from "../memory-capture-choice.js";
|
|
53
53
|
import { envWithNodeRuntimeOnPath } from "../scheduled-self-update.js";
|
|
54
54
|
import { probeMemoryDaemon } from "./memory-daemon-probe.js";
|
|
55
|
-
import { expectedCodexTrustRows, readCodexHookTrustFromDisk, } from "./memory-install-codex-hooks.js";
|
|
55
|
+
import { expectedCodexTrustRows, readCodexHookTrustFromDisk, readCodexManagedHooksFor, } from "./memory-install-codex-hooks.js";
|
|
56
|
+
import { codexSystemHooksFileFor } from "./codex-managed-hooks.js";
|
|
57
|
+
import { readCodexHooksTrustChoice } from "./codex-hooks-trust-choice.js";
|
|
56
58
|
import { buildMemoryInstallReceipt, writeMemoryReceiptFile, } from "./memory-install-receipt.js";
|
|
57
59
|
import { getCollectorRuntimePaths } from "../local-state.js";
|
|
58
60
|
import { aggregate, logMemoryOutcome, memoryOutcomeLines, resolveDashboardUrl } from "./memory-install-report.js";
|
|
@@ -128,8 +130,9 @@ export async function installMemoryIntegration(command, io, deps = {}) {
|
|
|
128
130
|
const targets = [resolved.binTarget];
|
|
129
131
|
// `no_bin_no_write`: with no resolved server there is nothing safe to
|
|
130
132
|
// register, so not one file is opened. The bin target carries the reason.
|
|
133
|
+
const systemHooksFile = codexSystemHooksFileFor(deps.codexSystemHooksFile, platform, io.env);
|
|
131
134
|
if (resolved.config) {
|
|
132
|
-
const host = { homeDir, config: resolved.config, dryRun: command.dryRun, io: fileIo };
|
|
135
|
+
const host = { homeDir, config: resolved.config, dryRun: command.dryRun, io: fileIo, systemHooksFile };
|
|
133
136
|
targets.push(...(await installClaudeMemoryIntegration(host)));
|
|
134
137
|
targets.push(...(await installCodexMemoryIntegration(host)));
|
|
135
138
|
}
|
|
@@ -142,7 +145,7 @@ export async function installMemoryIntegration(command, io, deps = {}) {
|
|
|
142
145
|
const outcome = {
|
|
143
146
|
action: "install",
|
|
144
147
|
...(capture.ignored ? { opt_out_ignored: "opt_out_ignored_not_founder" } : {}),
|
|
145
|
-
...(await readBackOutcome(targets, resolved, homeDir, fileIo)),
|
|
148
|
+
...(await readBackOutcome(targets, resolved, homeDir, fileIo, systemHooksFile)),
|
|
146
149
|
};
|
|
147
150
|
logMemoryOutcome(outcome, platform);
|
|
148
151
|
// The heartbeat reads the cache, not the machine — see the receipt module's
|
|
@@ -223,7 +226,10 @@ async function installWhileOptedOut(command, io, deps, scope) {
|
|
|
223
226
|
return outcome;
|
|
224
227
|
}
|
|
225
228
|
/** What both `install` and `status` report once the targets are known: their verdict and the receipt. */
|
|
226
|
-
async function readBackOutcome(targets, resolved, homeDir, fileIo) {
|
|
229
|
+
async function readBackOutcome(targets, resolved, homeDir, fileIo, systemHooksFile) {
|
|
230
|
+
// BLI-24349: the managed copy and the person's "no" both shape `codex.hooks`.
|
|
231
|
+
const managed = resolved.config ? await readCodexManagedHooksFor({ config: resolved.config, io: fileIo, systemHooksFile }) : null;
|
|
232
|
+
const choice = await readCodexHooksTrustChoice(getCollectorRuntimePaths(homeDir));
|
|
227
233
|
return {
|
|
228
234
|
...aggregate(targets),
|
|
229
235
|
config_source: resolved.source,
|
|
@@ -235,6 +241,8 @@ async function readBackOutcome(targets, resolved, homeDir, fileIo) {
|
|
|
235
241
|
binFound: resolved.config !== null,
|
|
236
242
|
codexTrust: await codexTrustFor(homeDir, fileIo),
|
|
237
243
|
expectedTrustRows: resolved.config ? expectedCodexTrustRows(resolved.config) : 0,
|
|
244
|
+
codexManaged: managed?.state ?? null,
|
|
245
|
+
codexTrustDeclined: choice.choice === "declined",
|
|
238
246
|
}),
|
|
239
247
|
};
|
|
240
248
|
}
|
|
@@ -274,8 +282,9 @@ export async function inspectMemoryIntegration(command, io, deps = {}) {
|
|
|
274
282
|
// Without a resolved server there is no shape to compare the stored config
|
|
275
283
|
// against, so `status` says the one true thing — the server is not here —
|
|
276
284
|
// rather than reporting four targets against a shape we invented.
|
|
285
|
+
const systemHooksFile = codexSystemHooksFileFor(deps.codexSystemHooksFile, platform, io.env);
|
|
277
286
|
if (resolved.config) {
|
|
278
|
-
const host = { homeDir, config: resolved.config, io: fileIo };
|
|
287
|
+
const host = { homeDir, config: resolved.config, io: fileIo, systemHooksFile };
|
|
279
288
|
targets.push(...(await inspectClaudeMemoryIntegration(host)));
|
|
280
289
|
targets.push(...(await inspectCodexMemoryIntegration(host)));
|
|
281
290
|
}
|
|
@@ -302,7 +311,7 @@ export async function inspectMemoryIntegration(command, io, deps = {}) {
|
|
|
302
311
|
// bounded, it opens nothing but a local socket, and a machine without one
|
|
303
312
|
// is reported as exactly that rather than as a problem.
|
|
304
313
|
daemon: await probeMemoryDaemon({ ...(homeDir ? { homeDir } : {}) }),
|
|
305
|
-
...(await readBackOutcome(targets, resolved, homeDir, fileIo)),
|
|
314
|
+
...(await readBackOutcome(targets, resolved, homeDir, fileIo, systemHooksFile)),
|
|
306
315
|
};
|
|
307
316
|
// `status` is a pure read and its reading is exactly as good as an install's
|
|
308
317
|
// — both re-parse the stored files — so it refreshes the cache too. A person
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import { appendExperience, drainExperiences, shipExperience, validateExperience } from "@bli-cockpit/telemetry-core/memory-experience";
|
|
2
|
+
import { appendExperience, drainExperiences, normaliseExperienceStore, shipExperience, validateExperience } from "@bli-cockpit/telemetry-core/memory-experience";
|
|
3
3
|
import { readPipedText, writeLine } from "./cli-io.js";
|
|
4
4
|
import { callTower, openTower } from "./tower-command.js";
|
|
5
5
|
function sender(command, io) {
|
|
@@ -25,7 +25,18 @@ export async function runMemoryLog(command, io) {
|
|
|
25
25
|
? (await readPipedText(io.stdin, { maxChars: 1002 })).trim()
|
|
26
26
|
: command.reason;
|
|
27
27
|
validateExperience(command.store, command.verdict, reason);
|
|
28
|
-
|
|
28
|
+
// BLI-24380: BLI Memory is the only store. A retired store is accepted and
|
|
29
|
+
// logged (the normaliser logs the line); `supermemory` records nothing.
|
|
30
|
+
const store = normaliseExperienceStore(command.store);
|
|
31
|
+
if (store.store === null) {
|
|
32
|
+
const receipt = { ok: true, recorded: false, reason: store.reason, given: store.given };
|
|
33
|
+
writeLine(io.stderr, `[memory experience] not recorded ${JSON.stringify(receipt)}`);
|
|
34
|
+
writeLine(io.stdout, command.json
|
|
35
|
+
? JSON.stringify(receipt)
|
|
36
|
+
: "Not recorded: Supermemory is retired (store_retired). BLI Memory is the only store; use --store bli.");
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
const entry = await appendExperience({ store: store.store, verdict: command.verdict, reason }, {
|
|
29
40
|
homeDir: command.homeDir,
|
|
30
41
|
agent: "cockpit",
|
|
31
42
|
project: path.basename(process.cwd()),
|
|
@@ -14,7 +14,7 @@ export async function runMemoryMap(door) {
|
|
|
14
14
|
if (!answer.ok)
|
|
15
15
|
return failAgentDoor(door, "[memory map cli]", answer.reason, answer.detail);
|
|
16
16
|
const body = answer.body;
|
|
17
|
-
writeLine(door.io.stderr, `[memory map cli] answered ${JSON.stringify({ reason: body.capped ? "capped" : "exhausted", examined: body.examined, total: body.total })}`);
|
|
17
|
+
writeLine(door.io.stderr, `[memory map cli] answered ${JSON.stringify({ reason: body.capped ? "capped" : "exhausted", examined: body.examined, total: body.total, total_source: body.totalSource ?? null })}`);
|
|
18
18
|
if (door.json)
|
|
19
19
|
return emitAgentDoor(door, body);
|
|
20
20
|
for (const project of body.projects) {
|
|
@@ -22,7 +22,11 @@ export async function runMemoryMap(door) {
|
|
|
22
22
|
for (const topic of project.topics)
|
|
23
23
|
writeLine(door.io.stdout, ` ${topic.label}: ${topic.count}${topic.averageImportance === null ? " (unscored)" : ` (importance ${Math.round(topic.averageImportance)}/100)`}`);
|
|
24
24
|
}
|
|
25
|
-
|
|
25
|
+
// BLI-24124: the total is the stored count with its time, or the rows read when none is stored (an older server sends neither field).
|
|
26
|
+
// BLI-24122: a server that sends `words` is printed as-is, so the page, this command and MCP say one thing.
|
|
27
|
+
const totalAge = body.totalSource === "stored_count" && body.totalAsOf ? ` Total counted ${body.totalAsOf}.`
|
|
28
|
+
: body.totalSource === "rows_read" ? " No stored total yet, so the total is the memories read." : "";
|
|
29
|
+
writeLine(door.io.stdout, body.words ?? `${body.total.toLocaleString("en-US")} visible memories; the map draws ${body.examined.toLocaleString("en-US")} of them; as of ${body.computedAt}.${totalAge}`);
|
|
26
30
|
if (!body.projects.length)
|
|
27
31
|
writeLine(door.io.stdout, "No project knowledge yet.");
|
|
28
32
|
return 0;
|
|
@@ -55,7 +55,7 @@ const VERB_LIST_LINES = [
|
|
|
55
55
|
" cockpit memory status [--json] read the stored config back",
|
|
56
56
|
"",
|
|
57
57
|
"The experience log:",
|
|
58
|
-
" cockpit memory log <win|loss|noise> --store
|
|
58
|
+
" cockpit memory log <win|loss|noise> [--store bli] \"<reason>\"",
|
|
59
59
|
"",
|
|
60
60
|
"Search: --limit caps the MEMORIES (default 5, at most 20). Up to 3 Tower pages and",
|
|
61
61
|
"tickets ride beside them whatever --limit says; for more, use `cockpit search`.",
|
|
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
if (command === "--version" || command === "-V" || command === "version") {
|
|
24
|
-
writeLine(io?.stdout ?? process.stdout, "0.2.
|
|
24
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.292");
|
|
25
25
|
return 0;
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* refusal that does not say what to do next is half a message. Every gap here
|
|
10
10
|
* gets ONE command or ONE click, never a paragraph and never "see the docs".
|
|
11
11
|
*/
|
|
12
|
-
import {
|
|
12
|
+
import { codexHooksTrustAction, setupPieceMark, setupReceiptLine, setupReceiptPieces, } from "@bli-cockpit/telemetry-core";
|
|
13
13
|
/** Cached host facts are old after the same seven-day window as their readers. */
|
|
14
14
|
const OLD_READING_MS = 7 * 24 * 60 * 60 * 1000;
|
|
15
15
|
/** The one fix for each piece, by key. Present for every key the block prints. */
|
|
@@ -40,12 +40,6 @@ const REASON_FIXES = {
|
|
|
40
40
|
};
|
|
41
41
|
/** Codex hooks a person switched OFF is a decision, not a fault. */
|
|
42
42
|
const UNSUPPORTED_FIX = "Switched off in your own config; nothing to do.";
|
|
43
|
-
/**
|
|
44
|
-
* The one exception to "a gap gets a command": Codex hooks that are installed
|
|
45
|
-
* and waiting to be trusted are finished by a PERSON, in the Codex UI, and no
|
|
46
|
-
* command we could print would do it.
|
|
47
|
-
*/
|
|
48
|
-
const NEEDS_TRUST_FIX = `Open Codex and press \`/hooks\` to trust them (once, ~15s). Step by step: ${CODEX_HOOKS_TRUST_GUIDE_URL}`;
|
|
49
43
|
/**
|
|
50
44
|
* The block a terminal prints: the one line, then one fix per gap, then
|
|
51
45
|
* nothing. A fully connected machine gets one line and no advice — a receipt
|
|
@@ -81,7 +75,7 @@ export function setupReceiptText(reading, options = {}) {
|
|
|
81
75
|
}
|
|
82
76
|
function fixFor(key, piece) {
|
|
83
77
|
if (piece.status === "needs_trust")
|
|
84
|
-
return
|
|
78
|
+
return codexHooksTrustAction(piece.reason);
|
|
85
79
|
if (piece.status === "unsupported")
|
|
86
80
|
return UNSUPPORTED_FIX;
|
|
87
81
|
if (piece.status === "unknown") {
|
|
@@ -4,6 +4,7 @@ import fs from "node:fs/promises";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { bookkeepingRead, bookkeepingWrite } from "./bookkeeping-session.js";
|
|
6
6
|
import { describeError, isMissingFileFailure } from "./health-detail.js";
|
|
7
|
+
import { dropEmptyObjectRecords } from "./raw-evidence-stuck-summary.js";
|
|
7
8
|
/**
|
|
8
9
|
* Durable local state for raw-evidence STAGING and DELIVERY ATTEMPTS.
|
|
9
10
|
*
|
|
@@ -29,7 +30,7 @@ import { describeError, isMissingFileFailure } from "./health-detail.js";
|
|
|
29
30
|
// BLI-4022. The stuck/held summary moved to its own sibling so this file
|
|
30
31
|
// stays under the readability band; it is still importable from here,
|
|
31
32
|
// which is the address every caller already knows.
|
|
32
|
-
export { summarizeStuckEvidence
|
|
33
|
+
export { summarizeStuckEvidence } from "./raw-evidence-stuck-summary.js";
|
|
33
34
|
export const RAW_EVIDENCE_STAGING_FILENAME = "raw-evidence-staging.json";
|
|
34
35
|
/**
|
|
35
36
|
* Backoff schedule for an object whose delivery keeps failing.
|
|
@@ -75,6 +76,7 @@ async function readStagingFromDisk(stateDir) {
|
|
|
75
76
|
const raw = JSON.parse(await fs.readFile(rawEvidenceStagingStatePath(stateDir), "utf8"));
|
|
76
77
|
const state = parseStagingState(raw);
|
|
77
78
|
resetStorage403ForEncoding(state);
|
|
79
|
+
dropEmptyObjectRecords(state);
|
|
78
80
|
return state;
|
|
79
81
|
}
|
|
80
82
|
catch (error) {
|
|
@@ -72,4 +72,29 @@ function summarizeLiveDeliveryAttempts(entries, ghostCount, now) {
|
|
|
72
72
|
held_next_attempt_at: heldNextAttemptAt,
|
|
73
73
|
held_last_reason: heldOldest?.last_reason ?? null,
|
|
74
74
|
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Forget the zero-byte objects an older collector staged. It offered them on every tick and the uploader refused each
|
|
78
|
+
* one with `empty_file`, which no retry can fix, so the fleet board read one as a stuck object for days (RED). The
|
|
79
|
+
* collector no longer stages an empty transcript (`adapters/raw-evidence-transcript-file.ts`); this clears what already
|
|
80
|
+
* sits in the history so the machine stops reporting it. The empty file itself is left for the ordinary staging prune.
|
|
81
|
+
*/
|
|
82
|
+
export function dropEmptyObjectRecords(state) {
|
|
83
|
+
let dropped = 0;
|
|
84
|
+
for (const [hash, entry] of Object.entries(state.staged)) {
|
|
85
|
+
if (entry.byte_size !== 0)
|
|
86
|
+
continue;
|
|
87
|
+
delete state.staged[hash];
|
|
88
|
+
dropped += 1;
|
|
89
|
+
}
|
|
90
|
+
for (const [hash, entry] of Object.entries(state.delivery_attempts)) {
|
|
91
|
+
if (entry.last_reason !== "empty_file")
|
|
92
|
+
continue;
|
|
93
|
+
delete state.delivery_attempts[hash];
|
|
94
|
+
dropped += 1;
|
|
95
|
+
}
|
|
96
|
+
if (dropped > 0) {
|
|
97
|
+
console.error("[raw-evidence-staging] empty objects forgotten, nothing in them can be delivered", JSON.stringify({ reason: "empty_file_records_dropped", records_dropped: dropped }));
|
|
98
|
+
}
|
|
99
|
+
return dropped;
|
|
75
100
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.292",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
"test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@bli-cockpit/memory-mcp": "0.1.
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
32
|
-
"@bli-cockpit/telemetry-core": "0.1.
|
|
30
|
+
"@bli-cockpit/memory-mcp": "0.1.90",
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.177",
|
|
32
|
+
"@bli-cockpit/telemetry-core": "0.1.96",
|
|
33
33
|
"unpdf": "1.8.1"
|
|
34
34
|
},
|
|
35
35
|
"repository": {
|