@assuredloop/orca-bot-kit 0.10.0 → 0.12.0

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.
@@ -12,9 +12,14 @@
12
12
  // editing the file by hand may get the format wrong; code does not.
13
13
  //
14
14
  // The kit owns only the entries `allow` holds. Any other entry in the file is
15
- // left where it is, and health names it.
15
+ // left where it is, and health names it. A rule the user says yes to taking
16
+ // back leaves `allow` and the files through `bot change --disallow` (#360).
17
+ //
18
+ // A bot on Codex gets the same yes in Codex's own form (#354, ADR 0028): each
19
+ // `Bash(<words>:*)` becomes a `prefix_rule` of those words in
20
+ // `.codex/rules/obk.rules`, a file the kit owns whole and rewrites from `allow`.
16
21
 
17
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
22
+ import { accessSync, constants, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
18
23
  import path from 'node:path';
19
24
 
20
25
  import { leadsOutside } from './bot.js';
@@ -24,6 +29,10 @@ import { harnessOf, ownCli, shellWord } from './launch.js';
24
29
  /** Where Claude Code reads a project's settings, inside the bot home. */
25
30
  const FILE = '.claude/settings.json';
26
31
 
32
+ /** Where Codex reads a trusted project's rules, and the one file of them the kit owns. */
33
+ const CODEX_RULES = '.codex/rules';
34
+ const CODEX_FILE = `${CODEX_RULES}/obk.rules`;
35
+
27
36
  /**
28
37
  * The rules every Claude bot is offered, in this order. The mail commands are
29
38
  * narrowed to this bots folder and spelled as the kit prints them in its
@@ -43,6 +52,134 @@ export function defaultRules(bots, cli = ownCli()) {
43
52
  ];
44
53
  }
45
54
 
55
+ /** Command wrappers: programs that run the command their arguments name. */
56
+ const WRAPPERS = new Set(['env', 'xargs', 'sudo', 'eval', 'exec', 'nohup', 'timeout', 'nice', 'command', 'time', 'watch']);
57
+
58
+ /**
59
+ * Programs that run whatever their arguments tell them to: shells, interpreters
60
+ * and the wrappers above. With a wildcard after one of them, a rule lets the bot
61
+ * run any command, unless the next word fixes what runs by its absolute path.
62
+ */
63
+ const RUNNERS = new Set([
64
+ 'sh', 'bash', 'zsh', 'fish', 'dash', 'ksh', 'csh', 'tcsh',
65
+ 'python', 'python2', 'python3', 'node', 'deno', 'bun', 'ruby', 'perl', 'php', 'osascript',
66
+ 'npx', 'pnpx', 'bunx', 'uvx',
67
+ ...WRAPPERS,
68
+ ]);
69
+
70
+ const ANY = 'it lets the bot run any command';
71
+
72
+ /**
73
+ * Why a rule is broad, as the end of a sentence, or undefined for a narrow one
74
+ * (#353). A charter's grants are written as narrow, exact rules only; one that
75
+ * lets the bot run any command, a program with any arguments, or any file on
76
+ * the disk or in the home is the user's to add by hand (ADR 0027).
77
+ */
78
+ export function broadness(rule) {
79
+ if (typeof rule !== 'string') return undefined;
80
+ const file = /^(Read|Edit|Write)(?:\((.*)\))?$/s.exec(rule);
81
+ if (file !== null) {
82
+ const spec = file[2] ?? '';
83
+ return spec === '' || /^(\/\/|~\/)[*/]*$/.test(spec) ? `it lets the bot ${file[1].toLowerCase()} any file` : undefined;
84
+ }
85
+ const bash = /^Bash(?:\((.*)\))?$/s.exec(rule);
86
+ if (bash === null) return undefined;
87
+ const { words, odd } = shellWords(bash[1] ?? '');
88
+ if (odd !== undefined) return `it holds ${odd}, which the shell reads specially, so the kit cannot tell what it runs`;
89
+ return commandBroadness(words);
90
+ }
91
+
92
+ /** What a word may hold outside quotes; anything else the shell reads specially. */
93
+ const PLAIN = /[A-Za-z0-9\-_./:=@%+,^*]/;
94
+
95
+ /**
96
+ * A Bash rule's command as the shell words it is, quotes taken off, or the
97
+ * first thing in it the shell reads specially: `{ words }` or `{ odd }`. Only
98
+ * plain words are judged, so an escape, an expansion or a second command can
99
+ * never hide which program runs.
100
+ */
101
+ function shellWords(text) {
102
+ const words = [];
103
+ let word;
104
+ for (let at = 0; at < text.length; at += 1) {
105
+ const char = text[at];
106
+ if (char === ' ' || char === '\t') {
107
+ if (word !== undefined) words.push(word);
108
+ word = undefined;
109
+ continue;
110
+ }
111
+ word ??= '';
112
+ if (char === "'" || char === '"') {
113
+ const end = text.indexOf(char, at + 1);
114
+ if (end === -1) return { odd: `a ${char} that does not close` };
115
+ const inside = text.slice(at + 1, end);
116
+ const special = char === '"' ? /[$`\\]/.exec(inside) : null;
117
+ if (special !== null) return { odd: `the character ${special[0]}` };
118
+ word += inside;
119
+ at = end;
120
+ } else if (char === '\\' && text[at + 1] === "'") {
121
+ // An apostrophe outside quotes, the form shellWord gives one in a path.
122
+ word += "'";
123
+ at += 1;
124
+ } else if (PLAIN.test(char) || (char === '~' && word === '')) {
125
+ word += char;
126
+ } else {
127
+ return { odd: char === '\n' ? 'a newline' : `the character ${char}` };
128
+ }
129
+ }
130
+ if (word !== undefined) words.push(word);
131
+ return { words };
132
+ }
133
+
134
+ /** The shell or interpreter a program name is, with a version on its name or not. */
135
+ const runnerOf = (name) => [name, name.replace(/[\d.]+$/, '')].find((one) => RUNNERS.has(one));
136
+
137
+ /** Why a Bash rule's command, as shell words, is broad, or undefined. */
138
+ function commandBroadness(all) {
139
+ // The program is the first word after any shell assignments in front of it.
140
+ let words = all;
141
+ while (words.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) words = words.slice(1);
142
+ if (words.length === 0) return ANY;
143
+ const star = words.findIndex((word) => word.includes('*'));
144
+ if (star === -1) return undefined;
145
+
146
+ const program = path.basename(words[0].replace(/:?\*.*$/, ''));
147
+ if (runnerOf(program) !== undefined) {
148
+ // Only what runs, fixed by its absolute path, narrows it. A wrapper's
149
+ // program is then judged by itself; an interpreter's script is the end.
150
+ const next = (words[1] ?? '').replace(/:\*$/, '');
151
+ const fixed = next.startsWith('/') && !next.includes('*');
152
+ if (fixed && WRAPPERS.has(program)) return commandBroadness(words.slice(1));
153
+ if (fixed && runnerOf(path.basename(next)) === undefined) return undefined;
154
+ return `${program} runs whatever its arguments say, so ${ANY}`;
155
+ }
156
+
157
+ // Two words at least before the first wildcard: a program and what it does.
158
+ const part = words[star].slice(0, words[star].indexOf('*')).replace(/:$/, '');
159
+ const before = star + (part === '' ? 0 : 1);
160
+ if (before === 0) return ANY;
161
+ if (before < 2) return `it lets the bot run ${words[0].replace(/:?\*.*$/, '')} with any arguments`;
162
+ return undefined;
163
+ }
164
+
165
+ /**
166
+ * Refuse the rules `--allow` was given when any is broad, before anything is
167
+ * written, naming the file the user can add it to themselves.
168
+ */
169
+ export function refuseBroad(home, rules) {
170
+ for (const rule of rules) {
171
+ const why = broadness(rule);
172
+ if (why === undefined) continue;
173
+ throw new Error(`--allow ${rule} is broad: ${why}. The kit writes only narrow, exact rules, such as Bash(gh pr merge:*), so nothing was written. If the user wants this rule for ${path.basename(home)}, they add it to ${path.join(home, FILE)} themselves.`);
174
+ }
175
+ }
176
+
177
+ /** The rules the bot is allowed beyond the kit's defaults, in `allow`'s order. */
178
+ export const beyondDefaults = (bots, home, bot) => {
179
+ const defaults = defaultRules(bots);
180
+ return allowOf(home, bot).filter((rule) => !defaults.includes(rule));
181
+ };
182
+
46
183
  /** Whether any of a bot's sessions, or the bot itself, runs on Claude Code. */
47
184
  export const runsOnClaude = (bot) => bot.harness === 'claude'
48
185
  || bot.sessions.some((session) => harnessOf(session, bot.harness) === 'claude');
@@ -58,11 +195,64 @@ export function allowOf(home, bot) {
58
195
  return value;
59
196
  }
60
197
 
61
- /** The default rules this bot has not been allowed yet, in the default order. */
62
- export function waitingFor(bots, home, bot) {
63
- if (!runsOnClaude(bot)) return [];
198
+ /** Whether any of a bot's sessions, or the bot itself, runs on Codex. */
199
+ export const runsOnCodex = (bot) => bot.harness === 'codex'
200
+ || bot.sessions.some((session) => harnessOf(session, bot.harness) === 'codex');
201
+
202
+ /**
203
+ * The default rules this bot has not been allowed yet for one harness's file,
204
+ * in the default order. Codex is not offered the Read rule: its sandbox reads
205
+ * every file already.
206
+ */
207
+ function waitingFor(bots, home, bot, harness) {
64
208
  const allowed = allowOf(home, bot);
65
- return defaultRules(bots).filter((rule) => !allowed.includes(rule));
209
+ return defaultRules(bots)
210
+ .filter((rule) => harness === 'claude' || codexForm(rule).line !== undefined)
211
+ .filter((rule) => !allowed.includes(rule));
212
+ }
213
+
214
+ /**
215
+ * A rule's Codex form: `{ line }`, `{ needless: true }` for a Read rule, which
216
+ * Codex's sandbox does not need, or `{ why }` for a rule Codex has none for.
217
+ * Only a prefix of plain words has one; a prefix rule for one exact command
218
+ * would let the bot add any arguments the user did not say yes to.
219
+ */
220
+ export function codexForm(rule) {
221
+ if (/^Read(\(.*\))?$/s.test(rule)) return { needless: true };
222
+ const bash = /^Bash\((.*)\)$/s.exec(rule);
223
+ if (bash === null) return { why: 'Codex has no rule for anything but a command, so its sandbox decides it' };
224
+ const { words, odd } = shellWords(bash[1]);
225
+ if (odd !== undefined) return { why: `Codex has no rule for it: it holds ${odd}` };
226
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0] ?? '')) {
227
+ return { why: 'Codex has no rule for a command with a variable set in front of it' };
228
+ }
229
+ if (!words.some((word) => word.includes('*'))) {
230
+ return { why: 'Codex has no rule for one exact command: its rules match a command\'s first words, whatever comes after them' };
231
+ }
232
+ // Only the wildcard comes off, ` *` or `:*` at the very end: every word
233
+ // before it stays, an empty quoted one included, or the rule would be looser
234
+ // than the one the user said yes to.
235
+ const text = bash[1].trimEnd();
236
+ const end = /(?: |:)\*$/.exec(text);
237
+ const prefix = end === null ? [] : shellWords(text.slice(0, end.index)).words;
238
+ if (prefix.length === 0 || prefix.some((word) => word.includes('*'))) {
239
+ return { why: 'Codex has no rule for a wildcard anywhere but at the end, as a word of its own or after a colon' };
240
+ }
241
+ return { line: `prefix_rule(pattern=[${prefix.map((word) => JSON.stringify(word)).join(', ')}], decision="allow")` };
242
+ }
243
+
244
+ /**
245
+ * Refuse the rules `--allow` was given for a bot that runs only on Codex when
246
+ * Codex has no form for one, before anything is written: recorded, it would
247
+ * let the bot do nothing.
248
+ */
249
+ export function refuseNoCodexForm(bot, rules) {
250
+ if (runsOnClaude(bot) || !runsOnCodex(bot)) return;
251
+ for (const rule of rules) {
252
+ const { why } = codexForm(rule);
253
+ if (why === undefined) continue;
254
+ throw new Error(`--allow ${rule} is not for ${bot.name}: ${why}, and ${bot.name} runs only on Codex, so nothing was written.`);
255
+ }
66
256
  }
67
257
 
68
258
  /** The one command that allows `rules` for `bot`, as the user can run it. */
@@ -72,19 +262,34 @@ export const allowCommand = (bots, bot, rules) => [
72
262
  ].join(' ');
73
263
 
74
264
  /**
75
- * Write what the bot is allowed into its Claude settings, and say what waits:
76
- * `{ bot, file, written, waiting }`, with `written` the rules this run added.
77
- * Nothing for a bot that does not run on Claude.
265
+ * Write what the bot is allowed into the files of the harnesses it runs on, and
266
+ * say what waits: one entry per file, Claude's first. With `keepGoing`, a file
267
+ * the kit may not write is an entry with its `trouble` rather than a throw.
268
+ */
269
+ export function writePermissions(bots, home, bot, { keepGoing = false } = {}) {
270
+ const writers = [[runsOnClaude, FILE, writeClaude], [runsOnCodex, CODEX_FILE, writeCodex]];
271
+ return writers.filter(([runs]) => runs(bot)).map(([, file, write]) => {
272
+ try {
273
+ return write(bots, home, bot);
274
+ } catch (error) {
275
+ if (!keepGoing) throw error;
276
+ return { bot: bot.name, file: path.join(home, file), written: [], waiting: [], trouble: error.message };
277
+ }
278
+ });
279
+ }
280
+
281
+ /**
282
+ * Write what the bot is allowed into its Claude settings: `{ bot, file,
283
+ * written, waiting }`, with `written` the rules this run added.
78
284
  *
79
285
  * Only rules `allow` holds are written. Everything else in the file stays as it
80
286
  * is, the user's own entries and their order included; a rule missing from the
81
287
  * file goes after them, and a run with nothing to add writes nothing.
82
288
  */
83
- export function writePermissions(bots, home, bot) {
84
- if (!runsOnClaude(bot)) return undefined;
289
+ function writeClaude(bots, home, bot) {
85
290
  const file = path.join(home, FILE);
86
291
  const allowed = allowOf(home, bot);
87
- const waiting = waitingFor(bots, home, bot);
292
+ const waiting = waitingFor(bots, home, bot, 'claude');
88
293
  const answer = { bot: bot.name, file, written: [], waiting };
89
294
  if (allowed.length === 0) return answer;
90
295
 
@@ -100,6 +305,141 @@ export function writePermissions(bots, home, bot) {
100
305
  return { ...answer, written: missing };
101
306
  }
102
307
 
308
+ /**
309
+ * Take the rules the user said yes to taking back out of the files of the
310
+ * harnesses the bot runs on (#360): from its Claude settings every entry of
311
+ * that exact text and nothing else, and its Codex rules rewritten from what
312
+ * `allow` still holds. Only rules `allow` holds are taken back; one it does not
313
+ * hold is refused, since the kit did not write it (ADR 0029).
314
+ *
315
+ * Everything is checked before anything is written, a file the kit could not
316
+ * write included, bot.yaml too. What comes back writes the files: one entry per file,
317
+ * Claude's first, with `removed` the rules taken out of it. The caller takes
318
+ * them out of `allow` after, so a write that fails leaves them there to take
319
+ * back again.
320
+ */
321
+ export function takeBack(bots, home, bot, rules) {
322
+ const allowed = allowOf(home, bot);
323
+ const foreign = rules.find((rule) => !allowed.includes(rule));
324
+ if (foreign !== undefined) throw new Error(notAllowed(home, bot, foreign));
325
+ const after = { ...bot, allow: allowed.filter((rule) => !rules.includes(rule)) };
326
+ const taken = [...new Set(rules)];
327
+ // bot.yaml is written last, and a harness file taken out of step with it
328
+ // would leave nothing to say a running Codex session still has the rule.
329
+ refuseUnwritable(path.join(home, 'bot.yaml'));
330
+
331
+ const file = path.join(home, FILE);
332
+ let claude;
333
+ if (runsOnClaude(bot)) {
334
+ refuseOutside(home, file);
335
+ const settings = readSettings(file, 'the permission rules the user allowed');
336
+ const present = presentIn(settings, file);
337
+ claude = { settings, present, removed: taken.filter((rule) => present.includes(rule)) };
338
+ if (claude.removed.length > 0) refuseUnwritable(file);
339
+ }
340
+ const codex = path.join(home, CODEX_FILE);
341
+ let before = [];
342
+ if (runsOnCodex(bot)) {
343
+ refuseOutside(home, codex);
344
+ if (existsSync(codex)) {
345
+ before = ruleLines(readFileSync(codex, 'utf8'));
346
+ refuseUnwritable(codex);
347
+ }
348
+ }
349
+
350
+ return () => {
351
+ const entries = [];
352
+ if (claude !== undefined) {
353
+ const { settings, present, removed } = claude;
354
+ if (removed.length > 0) {
355
+ const allow = present.filter((rule) => !removed.includes(rule));
356
+ writeFileSync(file, `${JSON.stringify({ ...settings, permissions: { ...settings.permissions, allow } }, null, 2)}\n`);
357
+ }
358
+ entries.push({ bot: bot.name, file, written: [], removed, waiting: waitingFor(bots, home, after, 'claude') });
359
+ }
360
+ if (runsOnCodex(after)) {
361
+ const entry = writeCodex(bots, home, after);
362
+ // A line goes only when no rule still allowed gives it.
363
+ const kept = [...codexOf(home, after).lines.keys()];
364
+ const removed = taken.filter((rule) => {
365
+ const { line } = codexForm(rule);
366
+ return line !== undefined && before.includes(line) && !kept.includes(line);
367
+ });
368
+ entries.push({ ...entry, written: [], removed });
369
+ }
370
+ return entries;
371
+ };
372
+ }
373
+
374
+ /** A refusal, before anything is written, of a file the kit could not write. */
375
+ function refuseUnwritable(file) {
376
+ try {
377
+ accessSync(file, constants.W_OK);
378
+ } catch (error) {
379
+ throw new Error(`${file} cannot be written (${error.code ?? error.message}), so nothing was changed. Let the kit write it, then run the command again.`);
380
+ }
381
+ }
382
+
383
+ /** Why `--disallow` refuses a rule `allow` does not hold, and where one of that text is. */
384
+ function notAllowed(home, bot, rule) {
385
+ const yaml = path.join(home, 'bot.yaml');
386
+ const file = path.join(home, FILE);
387
+ let byHand = false;
388
+ try {
389
+ byHand = runsOnClaude(bot) && leadsOutside(home, file) === undefined
390
+ && presentIn(readSettings(file), file).includes(rule);
391
+ } catch {
392
+ // A file the kit cannot read holds nothing it would take out.
393
+ }
394
+ const where = byHand
395
+ ? ` ${file} allows it, which the user added by hand, not the kit: it stays theirs, to take out themselves.`
396
+ : '';
397
+ return `--disallow ${rule} is not in the allow list in ${yaml}, so it is not the kit's to take back, and nothing was changed.${where} The kit takes back only rules that list holds, spelled exactly as they are there.`;
398
+ }
399
+
400
+ /**
401
+ * The Codex form of what the bot is allowed: the file's rule lines, each once,
402
+ * with the rules that gave them, and the rules Codex has no form for.
403
+ */
404
+ function codexOf(home, bot) {
405
+ const lines = new Map();
406
+ const unwritten = [];
407
+ for (const rule of allowOf(home, bot)) {
408
+ const form = codexForm(rule);
409
+ if (form.why !== undefined) unwritten.push({ rule, why: form.why });
410
+ if (form.line === undefined) continue;
411
+ lines.set(form.line, [...(lines.get(form.line) ?? []), rule]);
412
+ }
413
+ return { lines, unwritten };
414
+ }
415
+
416
+ /** The lines of a rules file that are rules: not blank, not a comment. */
417
+ const ruleLines = (text) => text.split('\n').map((line) => line.trim()).filter((line) => line !== '' && !line.startsWith('#'));
418
+
419
+ /**
420
+ * Write the Codex form of what the bot is allowed into `.codex/rules/obk.rules`:
421
+ * `{ bot, file, written, waiting, unwritten }`, with `written` every rule in
422
+ * the file when this run wrote it. The kit owns the file whole and writes it
423
+ * only when it would change; with nothing to write and no file, it makes none.
424
+ */
425
+ function writeCodex(bots, home, bot) {
426
+ const file = path.join(home, CODEX_FILE);
427
+ const { lines, unwritten } = codexOf(home, bot);
428
+ const answer = { bot: bot.name, file, written: [], waiting: waitingFor(bots, home, bot, 'codex'), unwritten };
429
+ if (lines.size === 0 && !existsSync(file)) return answer;
430
+
431
+ refuseOutside(home, file);
432
+ const text = [
433
+ `# Written by obk from ${bot.name}'s bot.yaml, where the user's yes to each rule is kept.`,
434
+ '# obk rewrites this whole file: put rules of your own in another file in this folder.',
435
+ ...lines.keys(),
436
+ ].join('\n');
437
+ if (existsSync(file) && readFileSync(file, 'utf8') === `${text}\n`) return answer;
438
+ mkdirSync(path.dirname(file), { recursive: true });
439
+ writeFileSync(file, `${text}\n`);
440
+ return { ...answer, written: [...lines.values()].flat() };
441
+ }
442
+
103
443
  /**
104
444
  * What health has to say about a bot's permission rules: `{ where, says }` for
105
445
  * each entry in the file that `allow` does not hold, and each rule `allow`
@@ -107,15 +447,20 @@ export function writePermissions(bots, home, bot) {
107
447
  * never taken out.
108
448
  */
109
449
  export function permissionsTrouble(home, bot) {
110
- if (!runsOnClaude(bot)) return [];
111
- const file = path.join(home, FILE);
112
-
113
450
  let allowed;
114
451
  try {
115
452
  allowed = allowOf(home, bot);
116
453
  } catch (error) {
454
+ if (!runsOnClaude(bot) && !runsOnCodex(bot)) return [];
117
455
  return [{ where: path.join(home, 'bot.yaml'), says: error.message }];
118
456
  }
457
+ return [...claudeTrouble(home, bot, allowed), ...codexTrouble(home, bot)];
458
+ }
459
+
460
+ /** Health's findings in the bot's Claude settings. */
461
+ function claudeTrouble(home, bot, allowed) {
462
+ if (!runsOnClaude(bot)) return [];
463
+ const file = path.join(home, FILE);
119
464
  if (!existsSync(file) && allowed.length === 0) return [];
120
465
 
121
466
  // A link out of the bot folder is named by the hook's check already; here it
@@ -133,7 +478,11 @@ export function permissionsTrouble(home, bot) {
133
478
  return [
134
479
  ...present.filter((rule) => !allowed.includes(rule)).map((rule) => ({
135
480
  where: file,
136
- says: `${file} allows ${rule}, and ${bot.name}'s bot.yaml does not: the kit did not write it. It stays where it is. If the user wants it, record their yes with obk bot change --allow; if not, take it out of the file.`,
481
+ // One `--allow` would refuse was added by the user by hand, which the
482
+ // boundary leaves to them: named, in neutral words, and left alone.
483
+ says: broadness(rule) === undefined
484
+ ? `${file} allows ${rule}, and ${bot.name}'s bot.yaml does not: the kit did not write it. It stays where it is. If the user wants it, record their yes with obk bot change --allow; if not, take it out of the file.`
485
+ : `${file} allows ${rule}, which the user added by hand, not the kit. It stays where it is.`,
137
486
  })),
138
487
  ...allowed.filter((rule) => !present.includes(rule)).map((rule) => ({
139
488
  where: file,
@@ -142,6 +491,71 @@ export function permissionsTrouble(home, bot) {
142
491
  ];
143
492
  }
144
493
 
494
+ /**
495
+ * Health's findings in the bot's Codex rules: each rule in a file of the
496
+ * user's beside obk.rules, named and left alone, and an obk.rules that is not
497
+ * what the kit writes from `allow`.
498
+ */
499
+ function codexTrouble(home, bot) {
500
+ if (!runsOnCodex(bot)) return [];
501
+ const folder = path.join(home, CODEX_RULES);
502
+ const file = path.join(home, CODEX_FILE);
503
+ const { lines } = codexOf(home, bot);
504
+ if (leadsOutside(home, file) !== undefined) {
505
+ if (lines.size === 0) return [];
506
+ try {
507
+ refuseOutside(home, file);
508
+ } catch (error) {
509
+ return [{ where: file, says: error.message }];
510
+ }
511
+ }
512
+
513
+ // A file health cannot read is one finding of its own, and the rest of the
514
+ // fleet's health still comes out.
515
+ const unread = (where, error) => [{ where, says: `${where} could not be read (${error.code ?? error.message}), so health cannot say what it holds. It stays where it is.` }];
516
+ let names = [];
517
+ try {
518
+ names = existsSync(folder) ? readdirSync(folder) : [];
519
+ } catch (error) {
520
+ return unread(folder, error);
521
+ }
522
+ const theirs = names
523
+ .filter((name) => name.endsWith('.rules') && name !== path.basename(file))
524
+ .sort()
525
+ .flatMap((name) => {
526
+ const where = path.join(folder, name);
527
+ let text;
528
+ try {
529
+ text = readFileSync(where, 'utf8');
530
+ } catch (error) {
531
+ return unread(where, error);
532
+ }
533
+ return ruleLines(text).map((line) => ({
534
+ where,
535
+ says: `${where} holds ${line}, which the user added, not the kit. It stays where it is.`,
536
+ }));
537
+ });
538
+
539
+ const wanted = [...lines.keys()];
540
+ let present = [];
541
+ try {
542
+ present = existsSync(file) ? ruleLines(readFileSync(file, 'utf8')) : [];
543
+ } catch (error) {
544
+ return [...theirs, ...unread(file, error)];
545
+ }
546
+ const missing = wanted.filter((line) => !present.includes(line));
547
+ const extra = present.filter((line) => !wanted.includes(line));
548
+ if (missing.length === 0 && extra.length === 0) return theirs;
549
+ const what = [
550
+ ...(missing.length === 0 ? [] : [`it lacks ${missing.join(' and ')}, which ${bot.name}'s bot.yaml allows`]),
551
+ ...(extra.length === 0 ? [] : [`it holds ${extra.join(' and ')}, which the kit did not write`]),
552
+ ].join(', and ');
553
+ return [...theirs, {
554
+ where: file,
555
+ says: `${file} is not what the kit writes from ${bot.name}'s bot.yaml: ${what}. The kit owns this file, and obk up rewrites it; a rule of the user's own goes in another file in ${folder}.`,
556
+ }];
557
+ }
558
+
145
559
  /** The file's `permissions.allow`, or a refusal when it is there and is not a list. */
146
560
  function presentIn(settings, file) {
147
561
  const permissions = settings.permissions;
package/src/up.js CHANGED
@@ -8,7 +8,7 @@ import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { setTimeout as pause } from 'node:timers/promises';
10
10
 
11
- import { forgetClaimed, forgetSession, readBook, sessionIdsIn, tabIdsIn, updateBook, withUnclaimed } from './book.js';
11
+ import { forgetClaimed, forgetSession, MAILBOX_WAIT_MS, readBook, sessionIdsIn, tabIdsIn, takeMailboxTurn, updateBook, withUnclaimed } from './book.js';
12
12
  import { botDir, botNames, displayName, readBot } from './bot.js';
13
13
  import { conversationsIn, hasConversation, heldAsUserTurn, transcriptsIn } from './conversations.js';
14
14
  import { installHook } from './hooks.js';
@@ -155,7 +155,7 @@ export function prepareBots(bots, names, onlySession) {
155
155
  // And the permission rules the user allowed, beside the hook and for the same
156
156
  // reason: a session that comes up before its rules are there is asked about
157
157
  // what the user already said yes to (#344).
158
- const permissions = running.map(({ bot, home }) => writePermissions(bots, home, bot)).filter((entry) => entry !== undefined);
158
+ const permissions = running.flatMap(({ bot, home }) => writePermissions(bots, home, bot));
159
159
 
160
160
  return { running, rules, skills, permissions };
161
161
  }
@@ -296,20 +296,31 @@ async function bringUpSession(bots, home, live, session, bot, title) {
296
296
  // does not (#272).
297
297
  const rules = rulesStamp(home);
298
298
  let held;
299
- await updateBook(home, (current) => {
300
- // What the harness has in this folder that nobody claims goes on the record,
301
- // for a person or Bot Father to settle — added to whatever was already noted,
302
- // because this run's scan cannot see what an earlier one found. The kit never
303
- // settles it itself.
304
- let entry = { ...current.sessions[session.name], tab: made.tabId, launched, rules };
305
- if (rules === undefined) delete entry.rules;
306
- held = entry.session;
307
- // Before the line is typed, so the hook finds no id here and takes the one
308
- // it reports for a start rather than a clear, which would tell the duty twice.
309
- if (which.noConversation !== undefined && held === which.noConversation) entry = forgetSession(entry, 'no conversation');
310
- current.sessions[session.name] = withUnclaimed(entry, which.unclaimed ?? []);
311
- forgetClaimed(current);
312
- });
299
+ // Not while the session's mailbox step or a check in its old tab is at work:
300
+ // the book moving under one leaves the mailbox bound to, or read in, a tab the
301
+ // book no longer names (#321). Waited for a bounded time and then written all
302
+ // the same, because this tab is open and its harness has to start; the step
303
+ // in it then waits its own turn and says what it left. Let go before the line
304
+ // is typed, since that step takes it.
305
+ const turn = takeMailboxTurn(home, session.name);
306
+ try {
307
+ await updateBook(home, (current) => {
308
+ // What the harness has in this folder that nobody claims goes on the record,
309
+ // for a person or Bot Father to settle — added to whatever was already noted,
310
+ // because this run's scan cannot see what an earlier one found. The kit never
311
+ // settles it itself.
312
+ let entry = { ...current.sessions[session.name], tab: made.tabId, launched, rules };
313
+ if (rules === undefined) delete entry.rules;
314
+ held = entry.session;
315
+ // Before the line is typed, so the hook finds no id here and takes the one
316
+ // it reports for a start rather than a clear, which would tell the duty twice.
317
+ if (which.noConversation !== undefined && held === which.noConversation) entry = forgetSession(entry, 'no conversation');
318
+ current.sessions[session.name] = withUnclaimed(entry, which.unclaimed ?? []);
319
+ forgetClaimed(current);
320
+ });
321
+ } finally {
322
+ turn?.release();
323
+ }
313
324
 
314
325
  // The old tab's hook can name another conversation while the tab is being
315
326
  // opened. A fresh start chosen before that is chosen again from the id the
@@ -524,6 +535,20 @@ export async function ownMailbox(bots, botName, sessionName) {
524
535
  throw new Error(`session mailbox binds ${who}'s mailbox to the Orca tab it runs in, and this is not one: ${TERMINAL_ENV} is not set. The kit runs it in the session's own tab when it starts the session.`);
525
536
  }
526
537
 
538
+ // One step, check or tab write for this session at a time, and the book read
539
+ // once the turn is had (#321). A step that does not get it leaves everything
540
+ // as it is and says so, and the launch line goes on to the harness.
541
+ const turn = takeMailboxTurn(home, sessionName);
542
+ if (turn === undefined) throw noTurn(home, who, sessionName);
543
+ try {
544
+ return await mailboxInTurn(home, botName, sessionName, who);
545
+ } finally {
546
+ turn.release();
547
+ }
548
+ }
549
+
550
+ /** What `ownMailbox` does once it has the session's turn. */
551
+ async function mailboxInTurn(home, botName, sessionName, who) {
527
552
  // Only in the tab the book names for the session. Two runs of `up` at once
528
553
  // can leave a session two tabs, and the book one of them: a mailbox made or
529
554
  // bound in the other would be bound to a tab no message is meant for.
@@ -600,6 +625,15 @@ export async function ownMailbox(bots, botName, sessionName) {
600
625
  */
601
626
  const STEP_WAIT = { timeoutMs: 20_000 };
602
627
 
628
+ /** A step that waited its turn out: nothing was made or bound, and where the mailbox was left. */
629
+ function noTurn(home, who, sessionName) {
630
+ const held = readBook(home).sessions[sessionName]?.mailbox;
631
+ const left = typeof held === 'string'
632
+ ? `${who}'s mailbox ${held} is left bound to ${boundTo(held)}; an \`obk message check\` in ${who}'s own tab binds it there.`
633
+ : `None is written down for ${who}; it gets one the next time the kit starts it.`;
634
+ return new Error(`waited ${MAILBOX_WAIT_MS / 1000} seconds for ${who}'s mailbox while another obk was making, binding or reading it, and it did not finish. Nothing was made or bound in this tab. ${left}`);
635
+ }
636
+
603
637
  /** Whether a session that can have a mailbox has none in its book. */
604
638
  const lacksMailbox = (home, session, harness) => reachesMail(session, harness)
605
639
  && typeof readBook(home).sessions[session.name]?.mailbox !== 'string';