@assuredloop/orca-bot-kit 0.25.4 → 0.25.6

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/README.md CHANGED
@@ -643,13 +643,14 @@ npm run mutate # the mutation audit, on what your branch changed
643
643
 
644
644
  The tests come in two layers, and every test file belongs to one of them.
645
645
 
646
- `npm test` is `test/*.test.js`: unit tests and end-to-end runs of the CLI in a
647
- throwaway folder, with a fake `orca` on PATH. It needs nothing but Node, so
648
- [GitHub Actions](.github/workflows/ci.yml) runs it on every pull request, split
649
- into shards that run side by side, on Node 25.8.0, the current line. A release
650
- runs every shard on 25.8.0 and again on 24.21.0, the floor `engines.node`
651
- promises users, with only the kit's own dependencies installed, before anything
652
- is published.
646
+ `npm test` is `test/*.test.js` and `test/dev/*.test.js`: unit tests and
647
+ end-to-end runs of the CLI in a throwaway folder, with a fake `orca` on PATH. It
648
+ needs nothing but Node, so [GitHub Actions](.github/workflows/ci.yml) runs it on
649
+ every pull request, split into shards that run side by side, on Node 25.8.0, the
650
+ current line. A release runs every shard on 25.8.0 and again on 24.21.0, the
651
+ floor `engines.node` promises users, with only the kit's own dependencies
652
+ installed, before anything is published. The floor leaves out `test/dev/`, the
653
+ tests that need a development tool.
653
654
 
654
655
  `npm run test:system` is `test/system/*.test.js`: the real `obk` against the
655
656
  real Orca and the real harnesses on your own machine. No CI runner can do that,
package/SETUP.md CHANGED
@@ -248,6 +248,26 @@ Bash(<kit> temp answer:*)
248
248
  Ask the user, and only after their yes run
249
249
  `obk bot change --bots <their path> --bot <bot> --allow 'Bash(<kit> temp answer:*)'`.
250
250
 
251
+ A long-lived session's two screens go through the kit as well, not through the
252
+ keys. On Codex's hooks review, run
253
+ `obk session trust-hooks --bots <their path> --bot <bot> --session <session>`.
254
+ On Claude Code's `Teach auto mode` screen, run
255
+ `obk session answer --bots <their path> --bot <bot> --session <session>`.
256
+ A long-lived session is Bot Father's, so run them from outside the fleet's tabs,
257
+ as you do here, or from a Bot Father session. A session of any other bot is
258
+ refused. They make the same checks as the temp commands. Before either
259
+ trust-hooks command trusts hooks, the kit checks that the hooks on the review
260
+ are its own: the bot's `.codex/hooks.json` holds only the kit's hooks, and the
261
+ count on the screen is the number of the kit's hooks that Codex does not trust
262
+ yet. When it refuses, it says what it saw: type nothing, and tell them what it
263
+ said. Use the keys in the table for the screens the kit does not answer.
264
+ The kit reads Codex's trust from the caller's `CODEX_HOME`, else `~/.codex`;
265
+ a bot run under another `CODEX_HOME` is outside what the kit can see.
266
+ The kit also records the extra arguments it launched each session with, and
267
+ refuses when they mention hooks, or when there is no record. A Codex that
268
+ someone started again by hand in that tab, with other flags, is outside what
269
+ the kit records.
270
+
251
271
  **Anything you do not recognise: type nothing.** Tell them what is on the
252
272
  screen and which tab it is in, and wait. A keypress into a menu you have not read
253
273
  is how a harness quits back to the shell.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.25.4",
3
+ "version": "0.25.6",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -23,8 +23,9 @@
23
23
  "SETUP.md"
24
24
  ],
25
25
  "scripts": {
26
- "test": "node --test test/*.test.js",
27
- "test:shard": "node --test --test-shard=$SHARD test/*.test.js",
26
+ "test": "node --test test/*.test.js test/dev/*.test.js",
27
+ "test:shard": "node --test --test-shard=$SHARD test/*.test.js test/dev/*.test.js",
28
+ "test:floor": "node --test --test-shard=$SHARD test/*.test.js",
28
29
  "test:system": "node scripts/test-system.js",
29
30
  "mutate": "node scripts/mutate.js"
30
31
  },
package/rules/mail.md CHANGED
@@ -8,6 +8,10 @@ To reach another session, ask the kit for the road rather than guessing an
8
8
  address: `"${OBK_CLI:-obk}" message to --bots <bots> --to <bot>/<session>`. It
9
9
  answers with the address and the command that carries it.
10
10
 
11
+ If your harness refuses a message, do not send it again by another road. Tell
12
+ your maker or your user, with the harness's reason. A refusal can mean a missing
13
+ permission rule, which they can add through the kit after the user's yes.
14
+
11
15
  Mail is queued, not an interruption. Read it when you finish what you are on.
12
16
 
13
17
  Ask for a reply when you need one, and send one when you were asked for one.
@@ -335,6 +335,19 @@ only after a second look), refuses any other screen, and checks that it went. It
335
335
  rule is `Bash(<kit> temp answer:*)`, `<kit>` the kit's CLI path as the
336
336
  command prints it; ask the user before `obk bot change --allow` adds it.
337
337
 
338
+ A long-lived session's two screens go through the kit too: Codex's hooks
339
+ review with `obk session trust-hooks --bots <bots> --bot <bot> --session
340
+ <session>`, and Claude Code's `Teach auto mode` screen with `obk session
341
+ answer --bots <bots> --bot <bot> --session <session>`. A long-lived session is
342
+ Bot Father's: the caller is a Bot Father session or the user, from outside the
343
+ fleet's tabs, and a session of any other bot is refused. They make the same
344
+ checks as the temp commands. Before either trust-hooks command trusts hooks,
345
+ it checks that the hooks on the review are the kit's own: the bot's
346
+ `.codex/hooks.json` holds only the kit's, and the count on the screen is the
347
+ number of the kit's hooks Codex does not trust yet. A refusal says what it
348
+ saw; then type nothing and take it to the user. The keys in the table are for
349
+ the screens the kit does not answer.
350
+
338
351
  Where the kit says no session came up, the shell swallowed the launch line,
339
352
  usually while it was asking its own question. Answer the shell, then close
340
353
  that one tab (`<orca> terminal close --terminal <handle> --tab`) and run
package/src/book.js CHANGED
@@ -28,6 +28,9 @@ const HEADER = `# What Orca calls this bot on this machine, and where each of it
28
28
  # \`rules\` is a stamp of the bot's AGENTS.md as the session last read it, so a
29
29
  # change to it after the session started can be told apart.
30
30
  #
31
+ # \`launched_with\` is the extra arguments the kit typed on the session's last
32
+ # launch line, as bot.yaml's extra_args gave them then.
33
+ #
31
34
  # \`retired\` holds what the book knew about each session \`obk retire\` took off
32
35
  # the bot, with when, so the conversations it had are still accounted for.
33
36
  #
package/src/cli.js CHANGED
@@ -31,7 +31,7 @@ import { readRoster } from './roster.js';
31
31
  import { buildAgents, buildRules, CODEX_CAP } from './rules.js';
32
32
  import { addSkill, buildSkills, linkSkills, removeSkill } from './skills.js';
33
33
  import { addSource, fetchSources } from './sources.js';
34
- import { answerTemp, listRoles, makeTemp, optionsLine, retireTemp, trustHooks } from './temp.js';
34
+ import { answerSession, answerTemp, listRoles, makeTemp, optionsLine, retireTemp, trustHooks, trustSessionHooks } from './temp.js';
35
35
  import { readUsage } from './usage.js';
36
36
  import { BOT_FATHER, bringUp, ownMailbox } from './up.js';
37
37
 
@@ -139,7 +139,8 @@ Usage:
139
139
  of a Codex temporary session it made with "Trust
140
140
  all and continue", and check the review went. It
141
141
  refuses anything else on that screen, and types
142
- nothing then.
142
+ nothing then. It refuses, as obk session
143
+ trust-hooks does, hooks that are not the kit's.
143
144
  obk temp answer --bots <path> --name <session>
144
145
  Run in the maker's own tab: answer Claude Code's
145
146
  "Teach auto mode" screen on a temporary session it
@@ -266,6 +267,19 @@ Usage:
266
267
  next starts where it stopped, so no call is counted
267
268
  twice. It counts tokens and never money: what a
268
269
  token costs is looked up live by whoever is asking.
270
+ obk session trust-hooks --bots <path> --bot <bot> --session <name>
271
+ Run by Bot Father or the user: answer the hooks
272
+ review of a long-lived Codex session with "Trust
273
+ all and continue", as obk temp trust-hooks does,
274
+ and check the review went. It refuses when the
275
+ bot's .codex/hooks.json holds a hook that is not
276
+ the kit's, or when the review counts other hooks
277
+ than the kit's own that Codex does not trust yet.
278
+ obk session answer --bots <path> --bot <bot> --session <name>
279
+ Run by Bot Father or the user: answer Claude Code's
280
+ "Teach auto mode" screen on a long-lived session
281
+ with Not now, as obk temp answer does, and check
282
+ the screen went.
269
283
  obk session record --bots <path> --bot <bot>
270
284
  For the kit's own hook, not for typing: it reads
271
285
  what the harness says about a session starting on
@@ -337,6 +351,8 @@ const COMMANDS = {
337
351
  'session mail': ['bots', 'bot'],
338
352
  'session name': ['bots', 'bot'],
339
353
  'session mailbox': ['bots', 'bot', 'session'],
354
+ 'session trust-hooks': ['bots', 'bot', 'session'],
355
+ 'session answer': ['bots', 'bot', 'session'],
340
356
  'temp make': ['bots', 'name'],
341
357
  'temp roles': ['bots'],
342
358
  'temp retire': ['bots', 'name'],
@@ -845,6 +861,26 @@ const commands = {
845
861
  };
846
862
  },
847
863
 
864
+ async 'session trust-hooks'(bots, values) {
865
+ const tab = callerTab(bots, true);
866
+ refuseWhenOrcaIsDown();
867
+ const trusted = await trustSessionHooks(bots, { tab, bot: values.bot, session: values.session });
868
+ return {
869
+ answer: { bots, ...trusted },
870
+ lines: [`trusted ${trusted.bot} ${trusted.session}'s hooks: chose "Trust all and continue" on its hooks review, and the review has gone`],
871
+ };
872
+ },
873
+
874
+ async 'session answer'(bots, values) {
875
+ const tab = callerTab(bots, true);
876
+ refuseWhenOrcaIsDown();
877
+ const answered = await answerSession(bots, { tab, bot: values.bot, session: values.session });
878
+ return {
879
+ answer: { bots, ...answered },
880
+ lines: [`answered ${answered.bot} ${answered.session}: sent ${answered.sent} to its "Teach auto mode about your environment?" screen, and the screen has gone`],
881
+ };
882
+ },
883
+
848
884
  'bot create'(bots, values) {
849
885
  const made = createBot(bots, { name: values.name, harness: values.harness, charter: values.charter });
850
886
  // The bot's AGENTS.md is the rules build's, here as everywhere else, so
@@ -1262,7 +1298,12 @@ const commands = {
1262
1298
  function toLines(answer, bots, where) {
1263
1299
  if (answer.trouble !== undefined) return [` ${answer.trouble}`];
1264
1300
  if (answer.transport === 'native') {
1265
- return [` Write to ${answer.address} with your own harness's messaging. The kit does not carry that road.`];
1301
+ // A refused call is not re-sent by another road (PRD 6.9), so the answer
1302
+ // says what to do instead, before the bot needs it (#521).
1303
+ return [
1304
+ ` Write to ${answer.address} with your own harness's messaging. The kit does not carry that road.`,
1305
+ " If your harness refuses that call, do not send the message again by another road. Tell your maker or your user that it was refused, with the harness's reason. A refusal can mean a missing permission rule, which they can add through the kit after the user's yes.",
1306
+ ];
1266
1307
  }
1267
1308
  return [
1268
1309
  ...(answer.unnamed === true
@@ -0,0 +1,179 @@
1
+ // Which of a Codex bot's hooks Codex does not trust yet, so that the kit
2
+ // trusts only its own (#506).
3
+ //
4
+ // "Trust all and continue" on Codex's hooks review trusts every hook the
5
+ // review counts, from every source Codex reads: the user's own files, Orca's
6
+ // hooks among them, `-c` flags, plugins, and each `.codex/` folder from the git
7
+ // root down (tech notes, section 3). So the kit answers a review only when the
8
+ // bot's `.codex/hooks.json` holds the kit's hooks and nothing else, and the
9
+ // count on the screen is the number of those that Codex does not trust yet.
10
+ //
11
+ // Codex keeps its trust in the user's config.toml, one table a hook, keyed by
12
+ // the file, the event and the hook's place in it, with a hash of the hook. The
13
+ // kit reads only those tables' headers and their `trusted_hash` lines: the
14
+ // rest of the file can hold the user's secrets, and nothing of it is kept or
15
+ // said. A file it cannot read that way for one of its hooks is one where it
16
+ // cannot tell, and it refuses (the architect's ruling on #506).
17
+
18
+ import { createHash } from 'node:crypto';
19
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
20
+ import os from 'node:os';
21
+ import path from 'node:path';
22
+
23
+ import { hooksFileOf, readSettings, runsThisKit } from './hooks.js';
24
+
25
+ /** The events whose hooks Codex hashes with no matcher, whatever the group says. */
26
+ const NO_MATCHER = new Set(['user_prompt_submit', 'stop', 'interrupt']);
27
+
28
+ /** A hook's timeout when its entry gives none, in seconds, as Codex takes it. */
29
+ const DEFAULT_TIMEOUT = 600;
30
+
31
+ /**
32
+ * How many of the hooks in the bot at `home`'s `.codex/hooks.json` Codex does
33
+ * not trust yet: `{ untrusted, file }`. It throws, with `nothing` at the end,
34
+ * when the file holds a hook that is not the kit's own as this kit writes it,
35
+ * or when the kit cannot tell whether Codex trusts one.
36
+ */
37
+ export function untrustedKitHooks(home, run, nothing) {
38
+ const file = hooksFileOf(home, 'codex');
39
+ const settings = readSettings(file, "the kit's hooks");
40
+ const hooks = settings.hooks ?? {};
41
+ if (hooks === null || typeof hooks !== 'object' || Array.isArray(hooks)) {
42
+ throw new Error(`${run}: ${file} has a hooks entry that is not a mapping of events, so the kit cannot tell which hooks its review covers. ${nothing}`);
43
+ }
44
+ const ours = [];
45
+ const foreign = [];
46
+ for (const [event, groups] of Object.entries(hooks)) {
47
+ if (!Array.isArray(groups)) {
48
+ foreign.push(`${event}: ${JSON.stringify(groups)}`);
49
+ continue;
50
+ }
51
+ groups.forEach((group, at) => {
52
+ if (!Array.isArray(group?.hooks)) {
53
+ foreign.push(`${event}: ${JSON.stringify(group)}`);
54
+ return;
55
+ }
56
+ group.hooks.forEach((hook, handler) => {
57
+ if (runsThisKit(hook)) ours.push({ event: snake(event), at, handler, hash: hashOf(snake(event), group.matcher, hook) });
58
+ else foreign.push(`${event}: ${typeof hook?.command === 'string' ? hook.command : JSON.stringify(hook)}`);
59
+ });
60
+ });
61
+ }
62
+ if (foreign.length > 0) {
63
+ throw new Error(`${run}: ${file} holds hooks that are not the kit's own as this kit writes them, and "Trust all and continue" would let them run outside the sandbox: ${foreign.join('; ')}. ${nothing}`);
64
+ }
65
+
66
+ // Codex names the file by the path it found it at; the kit knows the bot
67
+ // home by its real path, and looks under both.
68
+ const files = [...new Set([file, realOr(file)])];
69
+ const keysOf = (hook) => files.map((one) => `${one}:${hook.event}:${hook.at}:${hook.handler}`);
70
+ const trusted = trustedHashes(ours.flatMap(keysOf), run, nothing);
71
+ const untrusted = ours.filter((hook) => !keysOf(hook).some((key) => trusted.get(key) === hook.hash)).length;
72
+ return { untrusted, file };
73
+ }
74
+
75
+ /** An event's name as Codex writes it in a key and a hash: `SessionStart` is `session_start`. */
76
+ const snake = (event) => event.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
77
+
78
+ /**
79
+ * The hash Codex trusts a command hook at: SHA-256, in hex, of its event, its
80
+ * group's matcher and the hook as Codex reads it, as JSON with every key
81
+ * sorted (Codex 0.162.0, tech notes, section 3).
82
+ */
83
+ function hashOf(event, matcher, hook) {
84
+ const handler = {
85
+ type: 'command',
86
+ command: hook.command,
87
+ timeout: hook.timeout ?? DEFAULT_TIMEOUT,
88
+ async: hook.async === true,
89
+ ...(hook.statusMessage === undefined ? {} : { statusMessage: hook.statusMessage }),
90
+ };
91
+ const identity = {
92
+ event_name: event,
93
+ ...(matcher === undefined || matcher === null || NO_MATCHER.has(event) ? {} : { matcher }),
94
+ hooks: [handler],
95
+ };
96
+ return `sha256:${createHash('sha256').update(sorted(identity)).digest('hex')}`;
97
+ }
98
+
99
+ /** A value as JSON with every object's keys sorted, and no spaces. */
100
+ const sorted = (value) => {
101
+ if (Array.isArray(value)) return `[${value.map(sorted).join(',')}]`;
102
+ if (value !== null && typeof value === 'object') {
103
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${sorted(value[key])}`).join(',')}}`;
104
+ }
105
+ return JSON.stringify(value);
106
+ };
107
+
108
+ const realOr = (file) => {
109
+ try {
110
+ return realpathSync(file);
111
+ } catch {
112
+ return file;
113
+ }
114
+ };
115
+
116
+ const HEADER = /^\s*\[\s*hooks\.state\."((?:[^"\\]|\\.)*)"\s*\]\s*(?:#.*)?$/;
117
+ const TRUSTED = /^\s*trusted_hash\s*=\s*"(sha256:[0-9a-f]{64})"\s*(?:#.*)?$/;
118
+ const ANY_TABLE = /^\s*\[/;
119
+
120
+ /** The short escapes of a TOML basic string. */
121
+ const SHORT = { b: '\b', t: '\t', n: '\n', f: '\f', r: '\r', e: '\x1b', '"': '"', '\\': '\\' };
122
+
123
+ /**
124
+ * Text with a TOML basic string's escapes decoded, as Codex reads them: a key
125
+ * written `\u002f` is the key with a `/` in it (the review of PR #517). An
126
+ * escape TOML does not have is left as it is.
127
+ */
128
+ const unescaped = (text) => text.replace(/\\(?:u([0-9A-Fa-f]{4})|U([0-9A-Fa-f]{8})|x([0-9A-Fa-f]{2})|(.))/g, (all, u4, u8, x2, one) => {
129
+ const code = u4 ?? u8 ?? x2;
130
+ if (code === undefined) return SHORT[one] ?? all;
131
+ const point = parseInt(code, 16);
132
+ return point <= 0x10ffff ? String.fromCodePoint(point) : all;
133
+ });
134
+
135
+ /**
136
+ * The `trusted_hash` for each of `keys` that has one, by key, from the
137
+ * config.toml the session's Codex uses: `$CODEX_HOME`'s, or else
138
+ * `~/.codex`'s. No file is no trust. A key the file holds in any other form
139
+ * than a `[hooks.state."<key>"]` table with a `trusted_hash` line, or holds
140
+ * twice, is one the kit cannot tell, and it throws.
141
+ */
142
+ function trustedHashes(keys, run, nothing) {
143
+ const file = path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'config.toml');
144
+ if (!existsSync(file)) return new Map();
145
+ const wanted = new Set(keys);
146
+ const text = readFileSync(file, 'utf8');
147
+ const cannotTell = (key, how) =>
148
+ new Error(`${run}: ${file} holds Codex's trust for the kit's hook ${key} ${how}, so the kit cannot tell whether Codex trusts it. ${nothing}`);
149
+
150
+ const found = new Map();
151
+ const headers = new Set();
152
+ let table;
153
+ for (const line of text.split(/\r?\n/)) {
154
+ const header = HEADER.exec(line);
155
+ if (header !== null) {
156
+ table = unescaped(header[1]);
157
+ if (wanted.has(table)) {
158
+ if (headers.has(table)) throw cannotTell(table, 'twice');
159
+ headers.add(table);
160
+ }
161
+ continue;
162
+ }
163
+ if (ANY_TABLE.test(line)) {
164
+ table = undefined;
165
+ continue;
166
+ }
167
+ const hash = TRUSTED.exec(line);
168
+ if (hash !== null && wanted.has(table)) found.set(table, hash[1]);
169
+ }
170
+ // A key in any other form is one the kit cannot read: as it stands, which
171
+ // a literal string keeps, backslashes and all, or with a basic string's
172
+ // escapes decoded (the review of PR #517).
173
+ const decoded = unescaped(text);
174
+ for (const key of wanted) {
175
+ if (headers.has(key) && !found.has(key)) throw cannotTell(key, 'in a table with no trusted_hash line the kit reads');
176
+ if (!headers.has(key) && (text.includes(key) || decoded.includes(key))) throw cannotTell(key, 'in a form the kit does not read');
177
+ }
178
+ return found;
179
+ }
package/src/hooks.js CHANGED
@@ -20,6 +20,9 @@ import { ownCli, shellWord } from './launch.js';
20
20
  /** Where each harness reads a project's hooks, inside the bot home. */
21
21
  const HOOK_FILE = { claude: '.claude/settings.json', codex: '.codex/hooks.json' };
22
22
 
23
+ /** The hooks file `harness` reads in the bot at `home`. */
24
+ export const hooksFileOf = (home, harness) => path.join(home, HOOK_FILE[harness]);
25
+
23
26
  /**
24
27
  * The one thing the kit asks to be told: a session beginning. Both harnesses
25
28
  * report it whenever a session starts, resumes or is begun again by the user —
@@ -381,6 +384,14 @@ const KIT_NUDGE = new RegExp(String.raw`^(${WORD}) session nudge --bots ${WORD}
381
384
  const KIT_MAIL = new RegExp(String.raw`^(${WORD}) session mail --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
382
385
  const KIT_NAME = new RegExp(String.raw`^(${WORD}) session name --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
383
386
 
387
+ /**
388
+ * Whether a hook entry is one of the kit's own as this kit writes it: a
389
+ * command of exactly a shape the kit writes, run by `cli`, the kit running now
390
+ * (#506). One that runs some other program, even another `obk`, is not.
391
+ */
392
+ export const runsThisKit = (hook, cli = ownCli()) =>
393
+ hook?.type === 'command' && [KIT_HOOK, KIT_SENT, KIT_NUDGE, KIT_NAME].some((pattern) => programOf(hook.command, pattern) === cli);
394
+
384
395
  /** The program a bot made before the kit named itself by path runs. */
385
396
  const BARE = 'obk';
386
397
 
package/src/orca.js CHANGED
@@ -284,8 +284,22 @@ export function asFolderProject(setupId, title) {
284
284
  * Take a bot's Orca project away: the setup, the project and the repo record,
285
285
  * in one call (tech notes, section 1). Only after its tabs are closed: a
286
286
  * project taken away first leaves tabs no command line can reach.
287
+ *
288
+ * With `--force`, because the caller has made those checks itself: Orca's next
289
+ * release refuses a plain delete of a project with saved workspace details,
290
+ * which a bot's project keeps after its tabs close (#528). Orca 1.4.223 does
291
+ * not know the flag and refuses it before it changes anything, so then the
292
+ * plain delete is sent, which it takes with no such guard.
287
293
  */
288
- export const deleteProject = (setupId) => orca(['project', 'setup-delete', '--setup', setupId]);
294
+ export function deleteProject(setupId) {
295
+ const plain = ['project', 'setup-delete', '--setup', setupId];
296
+ try {
297
+ return orca([...plain, '--force']);
298
+ } catch (error) {
299
+ if (error.code !== 'invalid_argument' || !error.reason?.includes('Unknown flag --force')) throw error;
300
+ return orca(plain);
301
+ }
302
+ }
289
303
 
290
304
  /** Said after a run that made or renamed a project, whatever `tellWindow` answered, and after a removal `reloadWindow` could not follow. */
291
305
  export const RELOAD_LINE = "If Orca's sidebar does not show it, reload the window with Cmd+Shift+R.";
package/src/temp.js CHANGED
@@ -1,7 +1,9 @@
1
1
  // `obk temp make` and `obk temp retire`: a session's own temporary sessions
2
2
  // (PRD 6.4, #227). And `obk temp trust-hooks` and `obk temp answer`, the
3
3
  // answers a maker gives its Codex run's hooks review (#238) and its Claude
4
- // session's Teach auto mode form (#489).
4
+ // session's Teach auto mode form (#489). And `obk session trust-hooks` and
5
+ // `obk session answer`, the same answers to a long-lived session's screens,
6
+ // given by Bot Father or the user (#506), on the same code.
5
7
  //
6
8
  // Any long-lived session can make a temporary session of its own bot for a
7
9
  // piece of work, and retire it when the work is done, without Bot Father, who
@@ -31,11 +33,12 @@ import { setTimeout as pause } from 'node:timers/promises';
31
33
 
32
34
  import { readBook, takeLineTurn, TYPING_HELD, TYPING_WAIT_MS, updateBook } from './book.js';
33
35
  import { addSession, dropSession, NAME, readBot, tempRoles } from './bot.js';
36
+ import { untrustedKitHooks } from './hook-trust.js';
34
37
  import { DEFAULT_APPROVAL, harnessOf, isShortPrompt, ownCli, shellWord, startPrompt, workDirOf } from './launch.js';
35
- import { sessionInTab } from './message.js';
38
+ import { findSession, sessionInTab } from './message.js';
36
39
  import { orca, screenRows, tabs } from './orca.js';
37
40
  import { retireSession } from './retire.js';
38
- import { bringUp, promptPath } from './up.js';
41
+ import { BOT_FATHER, bringUp, promptPath } from './up.js';
39
42
 
40
43
  /** The settings a temporary session takes from its maker unless told otherwise. */
41
44
  const INHERITED = ['model', 'effort', 'context', 'approval'];
@@ -342,26 +345,72 @@ const REVIEW_GONE_MS = 5000;
342
345
  */
343
346
  export async function trustHooks(bots, { tab, name }) {
344
347
  const caller = callerIn(bots, tab, 'trust-hooks', "answer a run's hooks review");
348
+ const { bot, session } = ownTemp(caller, name, 'answer for', 'Nothing was typed.');
349
+ await trustAll({ bots, run: `${caller.bot}/${name}`, bot: caller.bot, home: caller.home, session: name, harness: harnessOf(session, bot.harness) }, 'temp');
350
+ return { bot: caller.bot, session: name, maker: caller.session };
351
+ }
352
+
353
+ /** Shell text the shell passes on as it is, split at its spaces: what the kit's own launch line leaves unquoted, and spaces. */
354
+ const SHELL_PLAIN = /^[A-Za-z0-9,._+:@%/= -]*$/;
355
+
356
+ /** A count row of Codex's hooks review: `1 hook is new or changed.`, `3 hooks are new or changed.` */
357
+ const HOOKS_COUNT = /^ *(\d+) hooks? (?:is|are) new or changed\. *$/;
358
+
359
+ /**
360
+ * Answer `target`'s hooks review with "Trust all and continue", for `temp
361
+ * trust-hooks` or `session trust-hooks` (`kind`). "Trust all" lets every hook
362
+ * the review counts run outside the sandbox, so first the bot's
363
+ * `.codex/hooks.json` must hold the kit's own hooks alone, and the review's
364
+ * count row must be the number of them Codex does not trust yet (#506, R4).
365
+ * Codex also takes hook trust, and hooks, from the session's own `-c` flags,
366
+ * which its extra_args can carry. What it was started with is the book's
367
+ * `launched_with`, which the kit writes at each launch: when that names hooks
368
+ * at all, or there is none, the kit cannot tell the count, and refuses (the
369
+ * review of PR #517). The arguments are not quoted: they can hold a secret.
370
+ */
371
+ async function trustAll(target, kind) {
345
372
  const nothing = 'Nothing was typed.';
346
- const { bot, session } = ownTemp(caller, name, 'answer for', nothing);
347
- const run = `${caller.bot}/${name}`;
348
- const harness = harnessOf(session, bot.harness);
349
- if (harness !== 'codex') {
350
- throw new Error(`${run} runs on ${harness}, and temp trust-hooks answers a Codex run's "${HOOKS_REVIEW}" alone. ${nothing}`);
373
+ if (target.harness !== 'codex') {
374
+ throw new Error(`${target.run} runs on ${target.harness}, and ${kind} trust-hooks answers a Codex session's "${HOOKS_REVIEW}" alone. ${nothing}`);
375
+ }
376
+ const launchedWith = readBook(target.home).sessions[target.session]?.launched_with;
377
+ if (launchedWith === undefined) {
378
+ throw new Error(`${target.run}'s book entry does not say what extra arguments it was launched with: it was started before the kit kept that record, or not by the kit. Codex takes hooks and their trust from a session's own -c flags as well, so the kit cannot tell which hooks its review covers. Start it again with the kit, then run this again: ${shellWord(ownCli())} restart --bots ${shellWord(target.bots)} --bot ${target.bot} --session ${target.session}. ${nothing}`);
379
+ }
380
+ // A list is the arguments themselves. A string is shell text, which the
381
+ // shell reads before Codex does (`'h''ooks'` is `hooks`), so it is read only
382
+ // when it holds nothing the shell would change (the review of PR #517).
383
+ if (typeof launchedWith === 'string' && !SHELL_PLAIN.test(launchedWith)) {
384
+ throw new Error(`${target.run} was launched with extra arguments written as shell text the kit does not read, and Codex takes hooks and their trust from a session's own -c flags as well, so the kit cannot tell which hooks its review covers. ${nothing}`);
385
+ }
386
+ const words = typeof launchedWith === 'string' ? launchedWith.split(' ') : [launchedWith].flat().map(String);
387
+ if (words.some((word) => /hooks/i.test(word))) {
388
+ throw new Error(`${target.run} was launched with extra arguments that mention hooks, and Codex takes hooks and their trust from a session's own -c flags as well, so the kit cannot tell which hooks its review covers. ${nothing}`);
351
389
  }
352
- await answerScreen(caller, name, {
390
+ // `--profile <name>` (`-p`) adds `<name>.config.toml` from Codex's home as a
391
+ // second user layer, and Codex reads hook trust from it too (codex-rs
392
+ // 0.162.0, cli/src/main.rs:1954-1962, config/src/loader/mod.rs:290-332).
393
+ if (words.some((word) => /profile/i.test(word) || /^-[^-]*p/.test(word))) {
394
+ throw new Error(`${target.run} was launched with a Codex profile, whose own config file can hold hook trust the kit does not read, so the kit cannot tell which hooks its review covers. ${nothing}`);
395
+ }
396
+ const { untrusted, file } = untrustedKitHooks(target.home, target.run, nothing);
397
+ await answerScreen(target, {
353
398
  what: 'hooks review',
354
399
  mark: HOOKS_REVIEW,
355
400
  done: `"${TRUST_ALL}" was chosen on its hooks review`,
356
401
  answer(rows, { send }) {
357
402
  const keys = keysToTrustAll(rows);
358
403
  if (keys === undefined) {
359
- throw new Error(`${run}'s screen shows no "${HOOKS_REVIEW}" with its choices, so there is nothing for this to answer. It shows: ${shown(rows)}. ${nothing}`);
404
+ throw new Error(`${target.run}'s screen shows no "${HOOKS_REVIEW}" with its choices, so there is nothing for this to answer. It shows: ${shown(rows)}. ${nothing}`);
405
+ }
406
+ const counts = rows.filter((row) => HOOKS_COUNT.test(row));
407
+ if (counts.length !== 1 || Number(HOOKS_COUNT.exec(counts[0])[1]) !== untrusted) {
408
+ const saw = counts.length === 0 ? 'shows no row that says how many hooks it covers' : `says "${counts.map((row) => row.trim()).join('" and "')}"`;
409
+ throw new Error(`${target.run}'s hooks review ${saw}, but ${untrusted} of the kit's own hooks in ${file} are not trusted by Codex yet, so the review may cover hooks that are not the kit's, and "${TRUST_ALL}" would let them run outside the sandbox. ${nothing}`);
360
410
  }
361
411
  send(keys);
362
412
  },
363
413
  });
364
- return { bot: caller.bot, session: name, maker: caller.session };
365
414
  }
366
415
 
367
416
  /**
@@ -386,23 +435,22 @@ function shown(rows) {
386
435
  }
387
436
 
388
437
  /**
389
- * Answer a screen of `name`, a temporary session the caller made: under the
390
- * session's turn for a line (#482), read its screen and hand it to
391
- * `answer(rows, { send, look })`, which sends keys with `send`, may read the
392
- * screen again with `look`, and throws when the screen is not its to answer.
393
- * Then wait for `mark` to leave the screen. `what` names the screen and `done`
394
- * says what was sent, for the sentences that refuse or fail; `done` may be a
395
- * function of what `answer` returned.
438
+ * Answer a screen of the session `target`, `{ run, home, session }`, `run`
439
+ * being its `<bot>/<session>`: under the session's turn for a line (#482),
440
+ * read its screen and hand it to `answer(rows, { send, look })`, which sends
441
+ * keys with `send`, may read the screen again with `look`, and throws when the
442
+ * screen is not its to answer. Then wait for `mark` to leave the screen.
443
+ * `what` names the screen and `done` says what was sent, for the sentences
444
+ * that refuse or fail; `done` may be a function of what `answer` returned.
396
445
  */
397
- async function answerScreen(caller, name, { what, mark, done, answer }) {
398
- const run = `${caller.bot}/${name}`;
446
+ async function answerScreen({ run, home, session }, { what, mark, done, answer }) {
399
447
  const nothing = 'Nothing was typed.';
400
- const tabId = readBook(caller.home).sessions[name]?.tab;
401
- const handle = tabId === undefined ? undefined : tabs(caller.home).find((one) => one.tabId === tabId)?.handle;
448
+ const tabId = readBook(home).sessions[session]?.tab;
449
+ const handle = tabId === undefined ? undefined : tabs(home).find((one) => one.tabId === tabId)?.handle;
402
450
  if (handle === undefined) {
403
451
  throw new Error(`${run} has no tab open in Orca, so there is no ${what} of its to answer. ${nothing}`);
404
452
  }
405
- const turn = takeLineTurn(caller.home, name, TYPING_WAIT_MS);
453
+ const turn = takeLineTurn(home, session, TYPING_WAIT_MS);
406
454
  if (turn === undefined) throw new Error(`${run}: ${TYPING_HELD}.`);
407
455
  let answered;
408
456
  try {
@@ -540,18 +588,27 @@ const wholeScreen = (rows) => rows.filter((row) => row.trim() !== '').map((row)
540
588
  */
541
589
  export async function answerTemp(bots, { tab, name }) {
542
590
  const caller = callerIn(bots, tab, 'answer', "answer a session's first-run screen");
591
+ ownTemp(caller, name, 'answer for', 'Nothing was typed.');
592
+ const sent = await answerTeach({ run: `${caller.bot}/${name}`, home: caller.home, session: name }, 'temp');
593
+ return { bot: caller.bot, session: name, maker: caller.session, sent };
594
+ }
595
+
596
+ /**
597
+ * Answer `target`'s Teach auto mode screen with Not now, for `temp answer` or
598
+ * `session answer` (`kind`). Returns what answered it.
599
+ */
600
+ function answerTeach(target, kind) {
601
+ const { run } = target;
543
602
  const nothing = 'Nothing was typed.';
544
- ownTemp(caller, name, 'answer for', nothing);
545
- const run = `${caller.bot}/${name}`;
546
- const sent = await answerScreen(caller, name, {
603
+ return answerScreen(target, {
547
604
  what: `"${TEACH_TITLE}"`,
548
605
  mark: TEACH_TITLE,
549
606
  done: (said) => `${said} was sent to its Teach auto mode screen`,
550
607
  answer(rows, { send, look }) {
551
608
  const seen = teachScreen(rows);
552
609
  if (seen.misfit !== undefined) {
553
- const hooks = rows.some((row) => row.includes(HOOKS_REVIEW)) ? ` A Codex run's "${HOOKS_REVIEW}" is answered with temp trust-hooks.` : '';
554
- throw new Error(`${run}'s screen is not one temp answer answers: ${seen.misfit}.${hooks} ${nothing} It shows:\n${wholeScreen(rows)}`);
610
+ const hooks = rows.some((row) => row.includes(HOOKS_REVIEW)) ? ` A Codex session's "${HOOKS_REVIEW}" is answered with ${kind} trust-hooks.` : '';
611
+ throw new Error(`${run}'s screen is not one ${kind} answer answers: ${seen.misfit}.${hooks} ${nothing} It shows:\n${wholeScreen(rows)}`);
555
612
  }
556
613
  if (seen.shape === TEACH_SHAPES[0]) {
557
614
  send('\x1b');
@@ -574,7 +631,55 @@ export async function answerTemp(bots, { tab, name }) {
574
631
  return `"${NOT_NOW_CHOICE}"`;
575
632
  },
576
633
  });
577
- return { bot: caller.bot, session: name, maker: caller.session, sent };
634
+ }
635
+
636
+ /**
637
+ * `obk session trust-hooks`: answer the hooks review of `bot`/`session`, a
638
+ * long-lived Codex session, as `temp trust-hooks` answers a temporary one's
639
+ * (#506). Returns `{ bot, session }`.
640
+ */
641
+ export async function trustSessionHooks(bots, { tab, bot, session }) {
642
+ const target = longLivedTarget(bots, { tab, bot, session }, 'trust-hooks');
643
+ await trustAll(target, 'session');
644
+ return { bot: target.bot, session: target.session };
645
+ }
646
+
647
+ /**
648
+ * `obk session answer`: answer the Teach auto mode screen of `bot`/`session`,
649
+ * a long-lived Claude Code session, as `temp answer` answers a temporary
650
+ * one's (#506). Returns `{ bot, session, sent }`.
651
+ */
652
+ export async function answerSession(bots, { tab, bot, session }) {
653
+ const target = longLivedTarget(bots, { tab, bot, session }, 'answer');
654
+ const sent = await answerTeach(target, 'session');
655
+ return { bot: target.bot, session: target.session, sent };
656
+ }
657
+
658
+ /**
659
+ * The long-lived session `bot`/`session`, whose first-run screen `session
660
+ * <verb>` answers, or a refusal. A long-lived session is Bot Father's, so the
661
+ * caller is a session of Bot Father's, or no session of this fleet: the user,
662
+ * or an assistant of theirs (#506, R2). A temporary session's screen is its
663
+ * maker's to answer, with the temp command.
664
+ */
665
+ function longLivedTarget(bots, { tab, bot, session }, verb) {
666
+ const nothing = 'Nothing was typed.';
667
+ const caller = tab === undefined ? undefined : sessionInTab(bots, tab);
668
+ if (caller !== undefined && caller.bot !== BOT_FATHER) {
669
+ throw new Error(`session ${verb} runs in ${caller.bot}/${caller.session}'s tab, but a long-lived session is Bot Father's: Bot Father (${BOT_FATHER}) or the user answers its first-run screen. ${nothing}`);
670
+ }
671
+ let found;
672
+ try {
673
+ found = findSession(bots, `${bot}/${session}`);
674
+ } catch (error) {
675
+ throw new Error(`${error.message} ${nothing}`);
676
+ }
677
+ const run = `${found.bot}/${found.session}`;
678
+ const temporary = readBook(found.home).sessions[found.session]?.temporary;
679
+ if (temporary !== undefined) {
680
+ throw new Error(`${run} is a temporary session ${temporary.maker} made, so its first-run screen is ${temporary.maker}'s to answer, with temp ${verb} run in ${temporary.maker}'s tab. ${nothing}`);
681
+ }
682
+ return { bots, run, bot: found.bot, session: found.session, home: found.home, harness: found.harness };
578
683
  }
579
684
 
580
685
  /**
package/src/up.js CHANGED
@@ -309,7 +309,10 @@ async function bringUpSession(bots, home, live, session, bot, title) {
309
309
  // for a person or Bot Father to settle — added to whatever was already noted,
310
310
  // because this run's scan cannot see what an earlier one found. The kit never
311
311
  // settles it itself.
312
- let entry = { ...current.sessions[session.name], tab: made.tabId, launched, rules };
312
+ // The extra arguments typed on this line, as the kit's own record of what
313
+ // this harness was started with: bot.yaml says only what the next launch
314
+ // gets, and trust-hooks asks what this one got (#506).
315
+ let entry = { ...current.sessions[session.name], tab: made.tabId, launched, launched_with: session.extra_args ?? [], rules };
313
316
  if (rules === undefined) delete entry.rules;
314
317
  held = entry.session;
315
318
  // Before the line is typed, so the hook finds no id here and takes the one