@assuredloop/orca-bot-kit 0.30.0 → 0.31.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.
package/README.md CHANGED
@@ -166,16 +166,18 @@ nobody asked: its own commands for this bots folder, reading a long message,
166
166
  `git add` and `git commit`
167
167
  ([ADR 0050](docs/adr/0050-the-kit-writes-every-bots-default-permission-rules.md)).
168
168
  `obk permission`, `obk retire`, `obk pause`, `obk restart`, `obk bot create`,
169
- `obk bot change`, `obk session add`, `obk session change`, `obk init` and
170
- `obk run` are not in the set, and keep your yes
169
+ `obk bot change`, `obk session add`, `obk session change` and `obk init` are
170
+ not in the set, and keep your yes
171
171
  ([ADR 0051](docs/adr/0051-permission-changes-and-risky-operations-have-commands-of-their-own.md)).
172
172
  A rule beyond the set is written by `obk permission allow`, after your yes to
173
173
  the exact rule. A bot that still holds a rule that the kit gave every bot
174
174
  before and that is no longer in the set keeps it: the rule of `obk restart`,
175
175
  `obk bot create`, `obk bot change`, `obk session add`, `obk session change`,
176
176
  `obk session mail` or `obk session nudge`, or of Orca's
177
- `orca orchestration check --run`. `obk rules build` and `obk up` name it, with
178
- the `obk permission disallow` command that takes it back.
177
+ `orca orchestration check --run`. So does a bot that holds a rule you allowed
178
+ for the kit's `run` command, which the kit no longer has. `obk rules build` and
179
+ `obk up` name each such rule, with the `obk permission disallow` command that
180
+ takes it back.
179
181
 
180
182
  Neither command touches Orca. `obk up` is what makes it all real there: an Orca
181
183
  project per bot, a tab per session, each started at the bot home with its own
@@ -680,82 +682,7 @@ fleet at all. Turn it off for a session with your own
680
682
  the kit will report that session as out of reach of fleet mail rather than
681
683
  failing quietly.
682
684
 
683
- ## Working on the kit
685
+ ## Contributing
684
686
 
685
- ```sh
686
- npm test # the whole suite, in a few minutes
687
- npm run test:system # what the system tests would drive, and nothing else
688
- npm run test:system -- --yes # drive them, on this machine, for real
689
- npm run test:system -- --yes test/system/groom.test.js # only the ones you name
690
- npm run mutate # the mutation audit, on what your branch changed
691
- ```
692
-
693
- The tests come in two layers, and every test file belongs to one of them.
694
-
695
- `npm test` is `test/*.test.js` and `test/dev/*.test.js`: unit tests and
696
- end-to-end runs of the CLI in a throwaway folder, with a fake `orca` on PATH. It
697
- needs nothing but Node, so [GitHub Actions](.github/workflows/ci.yml) runs it on
698
- every pull request, split into shards that run side by side, on Node 25.8.0, the
699
- current line. A release runs every shard on 25.8.0 and again on 24.21.0, the
700
- floor `engines.node` promises users, with only the kit's own dependencies
701
- installed, before anything is published. The floor leaves out `test/dev/`, the
702
- tests that need a development tool.
703
-
704
- `npm run test:system` is `test/system/*.test.js`: the real `obk` against the
705
- real Orca and the real harnesses on your own machine. No CI runner can do that,
706
- so these are run by hand before a change that touches Orca or a harness is
707
- merged. A system test touches only what it creates and removes it afterwards,
708
- except the mailbox Runs Orca gives no way to delete, which the run names; it
709
- never closes a tab it did not open. Before any test loads, the command copies
710
- your `~/.claude.json` and `~/.codex/config.toml` into a new
711
- `obk-config-backup-*` folder in your temp folder, readable by you alone, and
712
- says where; if a copy fails, no test runs. After the run it says whether each
713
- file still matches its copy, and never copies one back: that is yours to
714
- decide. The trust its harnesses write into your `~/.codex/config.toml` and
715
- `~/.claude.json` for the run's own throwaway folders is taken out again after
716
- the run, and named; nothing else in them is changed.
717
- An Orca project the run leaves in one of its own throwaway folders is named,
718
- with its setup id, and fails the run; the command removes none itself.
719
-
720
- They run on the machine the command is typed on, so the command will not start
721
- them by itself. On its own it says what it is about to drive — whose machine,
722
- which Orca, which files, and what they will do to it — and then stops without
723
- driving any of it, answering non-zero so that a run which did not run them
724
- cannot be read as one that passed. `-- --yes` is how you say you meant it. When
725
- Orca is not answering, the command says which Orca it asked and skips, rather
726
- than report a kit that is not broken. Name files after it to run only those;
727
- each must be one of the `*.test.js` files under `test/system/`, links followed,
728
- and a name that is not is refused before anything runs. This command is the
729
- only way in: a system test loaded any other way, with `node --test` or an
730
- editor's runner, skips and says how to run it.
731
-
732
- `npm run mutate` runs [StrykerJS](https://stryker-mutator.io) over the
733
- JavaScript this branch changed against `main` — committed, still in the working
734
- tree, or not tracked yet — and reports the mutants the tests do not kill. It is
735
- an audit, not a step of every pull request
736
- ([PRD 7.3](docs/prd/prd.md#prd-21-73-decided-rules-inside-the-skills)): the everyday
737
- check is the implementer's own hand check, and the tool runs over the whole
738
- suite at a milestone or when the owner asks, or once, narrowed to the changed
739
- logic, for a change at the core of the kit. A surviving mutant is a change to
740
- the code that no test objects to. It is worth a test only where it shows a gap
741
- in behaviour the requirement cares about, and that test is written by the
742
- separate test author. Name targets yourself to check a whole area instead:
743
-
744
- ```sh
745
- npm run mutate -- 'src/**/*.js'
746
- ```
747
-
748
- Every mutant means running the tests again, so the check does not run them the
749
- way `npm test` does. It runs [`scripts/mutation-suite.js`](scripts/mutation-suite.js)
750
- instead: the same test files, one at a time, stopping at the first file that
751
- fails, trying the file that killed the last mutant first and then the quickest.
752
- On a ten-core machine, `src/up.js` as it was when this was measured (128 lines,
753
- 76 mutants) took about twelve minutes that way. Running the whole suite for every mutant, as it used to, took
754
- forty, and counted a mutant as killed whenever a run of it ran out of time. A
755
- change across several files takes proportionally longer, so check one or two at
756
- a time.
757
-
758
- The check still takes minutes where the suite takes seconds, which is why it is
759
- a command of its own. StrykerJS is a development dependency of this repo, pinned
760
- to an exact version and needing Node 22 or newer; the kit itself neither ships
761
- it nor needs it.
687
+ How to run the kit's own tests, and how they are laid out, is in
688
+ [CONTRIBUTING.md](CONTRIBUTING.md). The package does not ship it.
package/SETUP.md CHANGED
@@ -144,8 +144,8 @@ Read everything it prints. Three parts of it matter to them:
144
144
  beside the bots folder, and its commits. The kit writes this set for every
145
145
  bot, with nobody asked. Say in a sentence what it allows, and that
146
146
  `obk permission`, `obk retire`, `obk pause`, `obk restart`, `obk bot create`,
147
- `obk bot change`, `obk session add`, `obk session change`, `obk init` and
148
- `obk run` are not in it and keep their yes;
147
+ `obk bot change`, `obk session add`, `obk session change` and `obk init` are
148
+ not in it and keep their yes;
149
149
  - **anything it says about Orca's own default launch arguments.** Orca adds
150
150
  these to every agent it launches, relaunches and resumes. When they carry a
151
151
  permission bypass, every session runs in that mode whatever approval level the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -214,12 +214,13 @@ does not have yet to its `bot.yaml` and its settings, or for Codex its
214
214
  `.codex/rules/obk.rules`, and say which rules they wrote. The set leaves out
215
215
  the commands that keep the user's yes: `obk permission`, `obk retire`, `obk
216
216
  pause`, `obk restart`, `obk bot create`, `obk bot change`, `obk session add`,
217
- `obk session change`, `obk init` and `obk run`. A bot that still holds a rule that the
217
+ `obk session change` and `obk init`. A bot that still holds a rule that the
218
218
  kit gave every bot before and that is no longer in the set (`obk restart`,
219
219
  `obk bot create`, `obk bot change`, `obk session add`, `obk session change`,
220
220
  `obk session mail`, `obk session nudge`, or Orca's `orca orchestration check
221
- --run`) keeps it, and `obk rules build` and `obk up` name
222
- it with the `obk permission disallow` command that takes it back; run that
221
+ --run`) keeps it, and so does a bot that holds a rule the user allowed for the
222
+ kit's `run` command, which the kit no longer has. `obk rules build` and `obk up`
223
+ name each such rule with the `obk permission disallow` command that takes it back; run that
223
224
  command only after the user's yes. Never write a
224
225
  permission rule into a settings or rules file by hand.
225
226
 
@@ -245,11 +246,7 @@ out; the kit refuses it, and it refuses a rule of its own default set too. Keep
245
246
  each rule narrow: a program and what it does. The kit refuses a rule that lets
246
247
  the bot run any command, a program with any arguments, or a shell or
247
248
  interpreter with any arguments; a user who wants one for a bot adds it to that
248
- bot's settings themselves. For a test run with a time limit, propose `obk run`
249
- with the real command after `--`, such as
250
- `Bash(<kit> run --timeout 300 -- go test:*)`: the kit refuses a rule for
251
- `perl -e 'alarm …'`, since perl runs whatever its arguments say. Propose no
252
- rule for what the charter does not grant.
249
+ bot's settings themselves. Propose no rule for what the charter does not grant.
253
250
 
254
251
  A bot that should learn across its sessions can use its harness's own memory,
255
252
  turned on for that bot alone, whatever the user's own setting is for everything
package/src/bot.js CHANGED
@@ -8,7 +8,6 @@
8
8
  // writes nothing, rather than leave a bot half made or a bot.yaml half edited.
9
9
 
10
10
  import { accessSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
11
- import os from 'node:os';
12
11
  import path from 'node:path';
13
12
  import { isDeepStrictEqual } from 'node:util';
14
13
  import { isSeq, parse, parseDocument, stringify } from 'yaml';
@@ -133,25 +132,6 @@ function whereItLeads(target) {
133
132
  export const displayName = (name) =>
134
133
  name.split('-').map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
135
134
 
136
- /**
137
- * What Orca is asked to call a bot's project. A fleet whose bots folder is under
138
- * the system's temp folder, as a system test's or a live check's is, gets a mark
139
- * and the name of the first folder under the temp folder, so its Bot Father is
140
- * never taken for the owner's in Orca's sidebar (#401). Every other fleet's
141
- * project is called what it always was. Tab titles keep `displayName` either way.
142
- */
143
- export function projectName(bots, name) {
144
- let under;
145
- try {
146
- under = path.relative(realpathSync(os.tmpdir()), realpathSync(bots));
147
- } catch {
148
- return displayName(name);
149
- }
150
- // The temp folder itself, or anywhere outside it, is not a throwaway fleet's.
151
- if (under === '' || under === '..' || under.startsWith(`..${path.sep}`) || path.isAbsolute(under)) return displayName(name);
152
- return `${displayName(name)} · temp fleet ${under.split(path.sep)[0]}`;
153
- }
154
-
155
135
  /**
156
136
  * A bot's own file: which harness its sessions run on unless they say
157
137
  * otherwise, and the sessions it keeps.
package/src/cli.js CHANGED
@@ -21,7 +21,7 @@ import { nameSession } from './name.js';
21
21
  import { APPROVALS, harnessOf, HARNESSES, ownCli, refuseApprovalArgs, shellWord, workDirOf } from './launch.js';
22
22
  import { checkMail, lookUp, noMailboxYet, sendMessage, sessionInTab } from './message.js';
23
23
  import { orcaCli, orcaTrouble, RELOAD_LINE, TERMINAL_ENV } from './orca.js';
24
- import { allowIn, beyondDefaults, botDefaults, refuseBroad, refuseNoCodexForm, runsOnClaude, runsOnCodex, takeBack, writePermissions } from './permissions.js';
24
+ import { allowIn, beyondDefaults, botDefaults, isRunRule, refuseBroad, refuseNoCodexForm, runsOnClaude, runsOnCodex, takeBack, writePermissions } from './permissions.js';
25
25
  import { pauseSessions, unpauseSessions } from './pause.js';
26
26
  import { recordSession, SHELL_ENV, TAB_ENV } from './record.js';
27
27
  import { restartSessions } from './restart.js';
@@ -34,7 +34,6 @@ import { addSkill, buildSkills, linkSkills, removeSkill } from './skills.js';
34
34
  import { addSource, fetchSources } from './sources.js';
35
35
  import { answerSession, answerTemp, listRoles, makeTemp, optionsLine, retireTemp, trustHooks, trustSessionHooks } from './temp.js';
36
36
  import { readUsage } from './usage.js';
37
- import { parseRun, runBounded } from './run.js';
38
37
  import { BOT_FATHER, bringUp, ownMailbox } from './up.js';
39
38
 
40
39
  const USAGE = `obk — Orca Bot Kit.
@@ -327,22 +326,13 @@ Usage:
327
326
  For the kit's own launch line, not for typing: run
328
327
  in the session's own tab, it gives the session its
329
328
  mailbox, or binds the one it has, to that tab.
330
- obk run --timeout <seconds> -- <command> [<arg> ...]
331
- Run one command with a hard time limit, with no
332
- shell, and exit with its exit code. When the time
333
- runs out, it stops the command and everything it
334
- started (SIGTERM, then SIGKILL after 3 s), says so
335
- on stderr, and exits 124. A command it cannot find
336
- exits 127. A rule can allow it for one command,
337
- such as Bash(obk run --timeout 300 -- go test:*).
338
- It needs no --bots, and takes no --json.
339
329
  obk --version Print the kit's version.
340
330
  obk --help Print this text.
341
331
 
342
332
  Every command is safe to run again. Most only add what is missing; the ones
343
333
  that close a tab or take something away say so above. Every bot is allowed
344
- the kit's commands by default, except obk permission, retire, pause, init and
345
- run, which keep the user's yes.
334
+ the kit's commands by default, except obk permission, retire, pause and init,
335
+ which keep the user's yes.
346
336
  Add --json to any of them for the same answer as JSON.
347
337
 
348
338
  For another build of Orca, OBK_ORCA names its CLI, and OBK_ORCA_DATA the full
@@ -396,10 +386,6 @@ function version() {
396
386
  }
397
387
 
398
388
  async function run(argv) {
399
- // What follows `obk run --timeout <n> --` is the command's own, flags and
400
- // all, so it is read by `run` alone, before the kit reads its flags (#532).
401
- if (argv[0] === 'run') return runBounded(parseRun(argv.slice(1)));
402
-
403
389
  const { values, positionals, tokens } = parseArgs({
404
390
  args: argv,
405
391
  allowPositionals: true,
@@ -1819,7 +1805,7 @@ function permissionsLines(permissions, bots) {
1819
1805
  ...(entry.trouble === undefined ? [] : [`${'refused'.padEnd(9)} ${entry.trouble}`]),
1820
1806
  ...(listed.has(entry.bot) || !listed.add(entry.bot) ? [] : [
1821
1807
  ...(entry.defaults ?? []).map((rule) => `${'allowed'.padEnd(9)} ${entry.bot} ${rule}`),
1822
- ...(entry.held ?? []).map((rule) => `${'holds'.padEnd(9)} ${entry.bot} ${rule}, which is no longer one of the kit's defaults. To take it back: ${disallow(entry.bot, rule)}`),
1808
+ ...(entry.held ?? []).map((rule) => `${'holds'.padEnd(9)} ${entry.bot} ${rule}, ${isRunRule(rule) ? 'which is for obk run, a command the kit no longer has' : "which is no longer one of the kit's defaults"}. To take it back: ${disallow(entry.bot, rule)}`),
1823
1809
  ]),
1824
1810
  ...(entry.written.length === 0
1825
1811
  ? []
package/src/commands.js CHANGED
@@ -46,7 +46,6 @@ export const COMMANDS = {
46
46
  'temp retire': ['bots', 'name'],
47
47
  'temp trust-hooks': ['bots', 'name'],
48
48
  'temp answer': ['bots', 'name'],
49
- run: ['timeout'],
50
49
  };
51
50
 
52
51
  /**
@@ -69,14 +68,12 @@ export const DEFAULT_COMMANDS = [
69
68
  * The commands kept out of the default permission set, which keep the user's
70
69
  * yes (ADR 0051): the ones that change permissions, close a long-lived
71
70
  * session, make the bots folder, or make or change a bot or its sessions,
72
- * which the owner leaves to Bot Father (#548, #615); and `run`, which runs
73
- * whatever its arguments say, so a rule for it names its command (#532).
71
+ * which the owner leaves to Bot Father (#548, #615).
74
72
  */
75
73
  export const KEPT_BACK = [
76
74
  'init', 'retire', 'pause', 'restart', 'bot create',
77
75
  'bot change', 'session add', 'session change',
78
76
  'permission allow', 'permission disallow', 'permission approval',
79
- 'run',
80
77
  ];
81
78
 
82
79
  /**
@@ -87,8 +84,8 @@ export const KEPT_BACK = [
87
84
  export const NO_LONGER_DEFAULT = ['restart', 'bot create', 'bot change', 'session add', 'session change'];
88
85
 
89
86
  /**
90
- * The commands kit 0.26.0 gave every bot by default and that the kit no longer
91
- * has (#555). A bot may still hold their rules, which the kit names and leaves
92
- * (#564).
87
+ * The commands the kit no longer has: the two kit 0.26.0 gave every bot by
88
+ * default (#555), and `run` (#532), which #652 took out of the kit. A bot may
89
+ * still hold their rules, which the kit names and leaves (#564).
93
90
  */
94
- export const REMOVED = ['session mail', 'session nudge'];
91
+ export const REMOVED = ['session mail', 'session nudge', 'run'];
package/src/health.js CHANGED
@@ -27,7 +27,7 @@ import { transcriptsIn } from './conversations.js';
27
27
  import { hookTrouble } from './hooks.js';
28
28
  import { APPROVALS, bypassFlags, harnessOf, HARNESSES, isAddressOf, ownCli, promptFile, sessionTrouble, set, SHELL_ENV, shellWord, workDirOf } from './launch.js';
29
29
  import { frontOfTab, mailIn, orcaDefaultArgs, projects, runMissing, tabs, tabToTypeInto, wordsOfProcess } from './orca.js';
30
- import { kitRunTrouble, permissionsTrouble, runsOnClaude, runsOnCodex } from './permissions.js';
30
+ import { permissionsTrouble } from './permissions.js';
31
31
  import { processesIn } from './processes.js';
32
32
  import { TAB_ENV } from './record.js';
33
33
  import { agentsTrouble, rulesStamp } from './rules.js';
@@ -49,29 +49,12 @@ export function checkHealth(bots, { bot: onlyBot } = {}) {
49
49
  const names = botsNamed(bots, onlyBot);
50
50
  const setups = projects();
51
51
 
52
- const found = [...orcaSettingFindings(), ...leftInOrca(bots, setups), ...leftBeside(bots), ...kitRunElsewhere(bots)];
52
+ const found = [...orcaSettingFindings(), ...leftInOrca(bots, setups), ...leftBeside(bots)];
53
53
  const sessions = [];
54
54
  for (const name of names) found.push(...aboutBot(bots, name, setups, sessions));
55
55
  return { found, sessions };
56
56
  }
57
57
 
58
- /**
59
- * The rules beside every bot's folder that let the kit's run run any command
60
- * (#635), for the harnesses the fleet's bots run on. A bot the kit cannot
61
- * read is named by its own check.
62
- */
63
- function kitRunElsewhere(bots) {
64
- const read = botNames(bots).flatMap((name) => {
65
- try {
66
- return [readBot(botDir(bots, name), name)];
67
- } catch {
68
- return [];
69
- }
70
- });
71
- return kitRunTrouble(bots, { claude: read.some(runsOnClaude), codex: read.some(runsOnCodex) })
72
- .map(({ where, says }) => finding('config', where, says));
73
- }
74
-
75
58
  /** One finding, as the report and the `--json` answer carry it. */
76
59
  const finding = (kind, where, says, bot) => ({ kind, ...(bot === undefined ? {} : { bot }), where, says });
77
60
 
@@ -222,7 +205,7 @@ function aboutBot(bots, name, setups, sessions) {
222
205
  ...rolesTrouble(home, bot).map(said('config')),
223
206
  ...agentsTrouble(bots, home, bot).map(said('config')),
224
207
  ...hooksOf(bots, home, bot).map(said('config')),
225
- ...permissionsTrouble(bots, home, bot).map(said('config')),
208
+ ...permissionsTrouble(home, bot).map(said('config')),
226
209
  ...skillsTrouble(bots, home, bot).map(said('skill')),
227
210
  ...inOrca(bots, home, bot, setups, sessions),
228
211
  ...leftRunning(home, bot),
package/src/orca.js CHANGED
@@ -381,8 +381,8 @@ function askRuntime(method, params, killMs = CLIENT_KILL_MS) {
381
381
 
382
382
  const RUNTIME_SCRIPT = fileURLToPath(new URL('./orca-runtime.cjs', import.meta.url));
383
383
 
384
- /** The `osascript` this run asks. OBK_OSASCRIPT overrides it, as OBK_PS does `ps`. */
385
- const osascriptCli = () => process.env.OBK_OSASCRIPT || '/usr/bin/osascript';
384
+ /** The `osascript` the kit asks, by its fixed path: no setting changes it (#652). */
385
+ const OSASCRIPT = '/usr/bin/osascript';
386
386
 
387
387
  /** How long macOS is given to click the menu item. */
388
388
  const RELOAD_KILL_MS = 5000;
@@ -406,7 +406,7 @@ export function reloadWindow() {
406
406
  try {
407
407
  // <Orca.app>/Contents/Resources/bin/orca, often reached through a link.
408
408
  const app = path.resolve(realpathSync(orcaCli()), '..', '..', '..', '..');
409
- const asked = spawnSync(osascriptCli(), [RELOAD_SCRIPT, app], {
409
+ const asked = spawnSync(OSASCRIPT, [RELOAD_SCRIPT, app], {
410
410
  encoding: 'utf8',
411
411
  stdio: ['ignore', 'pipe', 'pipe'],
412
412
  timeout: RELOAD_KILL_MS,
@@ -741,8 +741,8 @@ export function frontOfTab(handle) {
741
741
  return frontOf(handle, shown?.ptyId);
742
742
  }
743
743
 
744
- /** The `ps` this run reads. OBK_PS overrides it, as OBK_ORCA does Orca. */
745
- export const psCli = () => process.env.OBK_PS || '/bin/ps';
744
+ /** The `ps` the kit reads, by its fixed path: no setting changes it (#652). */
745
+ export const PS = '/bin/ps';
746
746
 
747
747
  /**
748
748
  * Who holds the terminal of the tab `handle`, whose pane is `ptyId`:
@@ -838,9 +838,9 @@ function panePid(ptyId, readMs) {
838
838
  return { pane };
839
839
  }
840
840
 
841
- /** The `lsof` and `stty` this run asks. OBK_LSOF and OBK_STTY override them, as OBK_PS does `ps`. */
842
- export const lsofCli = () => process.env.OBK_LSOF || '/usr/sbin/lsof';
843
- const sttyCli = () => process.env.OBK_STTY || '/bin/stty';
841
+ /** The `lsof` and `stty` the kit asks, by their fixed paths, as `ps`. */
842
+ export const LSOF = '/usr/sbin/lsof';
843
+ const STTY = '/bin/stty';
844
844
 
845
845
  /**
846
846
  * One look at the shell of a tab the kit has just opened, before anything is
@@ -893,7 +893,7 @@ export function shellInTab(handle, until) {
893
893
  * or as the pane itself where the pane is the shell.
894
894
  */
895
895
  function ttyMode(pid, left) {
896
- const listed = spawnSync(lsofCli(), ['-a', '-R', '-d', '0', '-u', String(process.getuid()), '-FpRn'], { encoding: 'utf8', timeout: left() });
896
+ const listed = spawnSync(LSOF, ['-a', '-R', '-d', '0', '-u', String(process.getuid()), '-FpRn'], { encoding: 'utf8', timeout: left() });
897
897
  if (listed.error?.code === 'ETIMEDOUT') return { unreadable: 'lsof did not answer in time', late: true };
898
898
  if (listed.error || listed.status !== 0) return { unreadable: `lsof could not list the processes' ttys` };
899
899
  // One record per process: `p<pid>`, `R<parent pid>`, `f0`, `n<name>`.
@@ -906,7 +906,7 @@ function ttyMode(pid, left) {
906
906
  }
907
907
  if (tty === undefined) return { unreadable: `lsof named no tty for the shell of its pane, pid ${pid}` };
908
908
 
909
- const read = spawnSync(sttyCli(), ['-a', '-f', tty], { encoding: 'utf8', timeout: left() });
909
+ const read = spawnSync(STTY, ['-a', '-f', tty], { encoding: 'utf8', timeout: left() });
910
910
  if (read.error?.code === 'ETIMEDOUT') return { unreadable: `stty did not answer in time for ${tty}`, late: true };
911
911
  if (read.error || read.status !== 0) return { unreadable: `stty could not read ${tty}` };
912
912
  const words = read.stdout.split(/\s+/);
@@ -926,14 +926,14 @@ function ttyMode(pid, left) {
926
926
  * looks for one whole word it knows.
927
927
  */
928
928
  export function wordsOfProcess(pid) {
929
- const asked = spawnSync(psCli(), ['-E', '-ww', '-o', 'command=', '-p', String(pid)], { encoding: 'utf8' });
929
+ const asked = spawnSync(PS, ['-E', '-ww', '-o', 'command=', '-p', String(pid)], { encoding: 'utf8' });
930
930
  if (asked.error || asked.status !== 0) return undefined;
931
931
  return asked.stdout.trim().split(/\s+/);
932
932
  }
933
933
 
934
934
  /** One process as `ps` gives it, read only: `{ ppid, tpgid, comm }`, `{ late: true }` past `readMs`, or undefined. */
935
935
  function psLine(pid, readMs) {
936
- const asked = spawnSync(psCli(), ['-o', 'pid=,ppid=,tpgid=,comm=', '-p', String(pid)], { encoding: 'utf8', ...(readMs === undefined ? {} : { timeout: readMs }) });
936
+ const asked = spawnSync(PS, ['-o', 'pid=,ppid=,tpgid=,comm=', '-p', String(pid)], { encoding: 'utf8', ...(readMs === undefined ? {} : { timeout: readMs }) });
937
937
  if (asked.error?.code === 'ETIMEDOUT') return { late: true };
938
938
  if (asked.error || asked.status !== 0) return undefined;
939
939
  const line = /^\s*(\d+)\s+(\d+)\s+(-?\d+)\s+(.+?)\s*$/.exec(asked.stdout);
@@ -21,10 +21,9 @@
21
21
  // `.codex/rules/obk.rules`, a file the kit owns whole and rewrites from `allow`.
22
22
 
23
23
  import { accessSync, constants, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
24
- import os from 'node:os';
25
24
  import path from 'node:path';
26
25
 
27
- import { allowRules, botsDir, leadsOutside } from './bot.js';
26
+ import { allowRules, leadsOutside } from './bot.js';
28
27
  import { DEFAULT_COMMANDS, NO_LONGER_DEFAULT, REMOVED } from './commands.js';
29
28
  import { readSettings } from './hooks.js';
30
29
  import { harnessOf, ownCli, shellWord } from './launch.js';
@@ -60,18 +59,34 @@ export function defaultRules(bots, cli = ownCli()) {
60
59
  */
61
60
  const ORCA_CHECK = 'Bash(orca orchestration check --run:*)';
62
61
 
62
+ /**
63
+ * Whether a rule is one for `obk run` (#532), which #652 took out of the kit:
64
+ * the kit, then `run`. The kit is its CLI as `permission allow` recorded it,
65
+ * the bare `obk` that `obk run`'s own help spelled, or any path to an `obk`,
66
+ * quoted or not. A rule for it named the command after `--`, so it is known by
67
+ * its first two shell words.
68
+ */
69
+ export function isRunRule(rule, cli = ownCli()) {
70
+ const text = /^Bash\((.*)\)$/s.exec(rule)?.[1];
71
+ const [kit, word] = (text === undefined ? undefined : shellWords(text).words) ?? [];
72
+ if (kit === undefined || word === undefined) return false;
73
+ return (kit === cli || path.basename(kit) === 'obk') && (word === 'run' || word.startsWith('run:'));
74
+ }
75
+
63
76
  /**
64
77
  * The rules that were defaults once and are not now, that the bot's `allow`
65
78
  * holds: those of the commands kept back (#548, #615) or gone from the kit (#555),
66
- * spelled as `defaultRules` spelled them, and Orca's check (#564). The kit
67
- * cannot tell its own old entry from a user's yes to the same rule, so it
79
+ * spelled as `defaultRules` spelled them, and Orca's check (#564); and any rule
80
+ * for `obk run`, which the user allowed and the kit no longer has (#652). The
81
+ * kit cannot tell its own old entry from a user's yes to the same rule, so it
68
82
  * names them and leaves them (ADR 0051, ADR 0050).
69
83
  */
70
84
  export function heldRules(bots, home, bot, cli = ownCli()) {
71
85
  const allowed = allowOf(home, bot);
72
- return [...NO_LONGER_DEFAULT, ...REMOVED].map((command) => `Bash(${shellWord(cli)} ${command} --bots ${shellWord(bots)}:*)`)
86
+ const spelled = [...NO_LONGER_DEFAULT, ...REMOVED].map((command) => `Bash(${shellWord(cli)} ${command} --bots ${shellWord(bots)}:*)`)
73
87
  .concat(ORCA_CHECK)
74
88
  .filter((rule) => allowed.includes(rule));
89
+ return [...new Set([...spelled, ...allowed.filter((rule) => isRunRule(rule, cli))])];
75
90
  }
76
91
 
77
92
  /** Command wrappers: programs that run the command their arguments name. */
@@ -166,14 +181,12 @@ function commandBroadness(all) {
166
181
  if (star === -1) return undefined;
167
182
 
168
183
  const program = path.basename(words[0].replace(/:?\*.*$/, ''));
169
- if (isKit(words[0]) && mayRun(words[1] ?? '')) return runBroadness(words);
170
184
  if (runnerOf(program) !== undefined) {
171
185
  // Only what runs, fixed by its absolute path, narrows it. A wrapper's
172
- // program is then judged by itself; an interpreter's script is the end,
173
- // unless it is the kit, which is judged as the kit.
186
+ // program is then judged by itself; an interpreter's script is the end.
174
187
  const next = (words[1] ?? '').replace(/:\*$/, '');
175
188
  const fixed = next.startsWith('/') && !next.includes('*');
176
- if (fixed && (WRAPPERS.has(program) || isKit(next))) return commandBroadness(words.slice(1));
189
+ if (fixed && WRAPPERS.has(program)) return commandBroadness(words.slice(1));
177
190
  if (fixed && runnerOf(path.basename(next)) === undefined) return undefined;
178
191
  return `${program} runs whatever its arguments say, so ${ANY}`;
179
192
  }
@@ -186,349 +199,6 @@ function commandBroadness(all) {
186
199
  return undefined;
187
200
  }
188
201
 
189
- /** Whether a word is the kit's CLI: `obk` by its name, or the path the kit runs as. */
190
- const isKit = (word) => path.basename(word) === 'obk' || word === ownCli();
191
-
192
- /**
193
- * Whether a rule's word can match `run`: Claude's `*` matches any characters,
194
- * spaces too, so `ru*` matches `run --timeout 1 -- sh`, while `:*` matches
195
- * only at the end of a word.
196
- */
197
- function mayRun(word) {
198
- const star = word.indexOf('*');
199
- if (star === -1) return word === 'run';
200
- const before = word.slice(0, star);
201
- return before.endsWith(':') ? before === 'run:' : 'run'.startsWith(before);
202
- }
203
-
204
- /**
205
- * Why a rule for `obk run`, as shell words, is broad, or undefined (#532). It
206
- * runs whatever its arguments say, so only a fixed time limit, `--`, and a
207
- * command after it that is narrow as a rule of its own narrow it.
208
- */
209
- function runBroadness(words) {
210
- const [, , ...rest] = words;
211
- let command;
212
- if (rest[0] === '--timeout' && /^\d+$/.test(rest[1] ?? '') && rest[2] === '--') command = rest.slice(3);
213
- if (/^--timeout=\d+$/.test(rest[0] ?? '') && rest[1] === '--') command = rest.slice(2);
214
- if (words[1] !== 'run' || command === undefined || command.length === 0) {
215
- return `obk run runs whatever its arguments say, so ${ANY}. A rule for it names the command after --, such as Bash(obk run --timeout 300 -- go test:*)`;
216
- }
217
- return commandBroadness(command);
218
- }
219
-
220
- /**
221
- * A program no rule can name, standing for any command: a rule that matches
222
- * the kit's run of it matches the run of any command (#635). Shell words
223
- * cannot hold it.
224
- */
225
- const ANY_COMMAND = '\u0007';
226
-
227
- /** A wildcard in a rule, as `globOf` gives it: any text, spaces included. */
228
- const STAR = Symbol('*');
229
-
230
- /** A rule's text as a glob: each character itself, and each `*` a wildcard. */
231
- const globOf = (text) => [...text].map((char) => (char === '*' ? STAR : char));
232
-
233
- /*
234
- * The commands a rule is held against, as a small automaton (#635): each state
235
- * a list of edges, each a test of one character, or null for an edge that
236
- * takes none. A part is `(machine, from) => to`.
237
- */
238
- const text = (word) => (m, from) => [...word].reduce((at, char) => one((c) => c === char)(m, at), from);
239
- const one = (test) => (m, from) => {
240
- const to = m.state();
241
- m.edge(from, test, to);
242
- return to;
243
- };
244
- const many = (test) => (m, from) => {
245
- const to = m.state();
246
- m.edge(from, null, to);
247
- m.edge(to, test, to);
248
- return to;
249
- };
250
- const seq = (...parts) => (m, from) => parts.reduce((at, part) => part(m, at), from);
251
- const alt = (...parts) => (m, from) => {
252
- const to = m.state();
253
- for (const part of parts) m.edge(part(m, from), null, to);
254
- return to;
255
- };
256
- const maybe = (part) => alt(part, seq());
257
-
258
- function automaton(part) {
259
- const states = [];
260
- const m = { state: () => states.push([]) - 1, edge: (from, test, to) => states[from].push([test, to]) };
261
- const start = m.state();
262
- return { states, start, end: part(m, start) };
263
- }
264
-
265
- const notSpace = (c) => c !== ' ' && c !== '\t';
266
- const digit = (c) => c >= '0' && c <= '9';
267
- const anything = () => true;
268
-
269
- /** A program that runs whatever its arguments say, by its name or its absolute path, with a version on its name or not. */
270
- const runner = seq(maybe(seq(text('/'), maybe(seq(many(notSpace), text('/'))))), alt(...[...RUNNERS].map(text)), many((c) => digit(c) || c === '.'));
271
-
272
- /** A word that is a runner, and nothing more. */
273
- let runnerWord;
274
-
275
- /** The kit as a word: `obk`, any path to an `obk`, or the CLI the kit runs as. */
276
- const kitAs = (kit) => alt(text('obk'), seq(many(notSpace), text('/obk')), text(kit));
277
-
278
- /**
279
- * The kit's run of any command, for the kit as it is spelled now, alone or
280
- * behind a runner; then the run with a time limit; then after `--` any
281
- * command, a runner with whatever it is told to run (a first argument that is
282
- * not a fixed path, or the fixed path of another runner, as `commandBroadness`
283
- * judges it), or another such run of the kit.
284
- */
285
- const runsOfAny = new Map();
286
- function runOfAny() {
287
- const kit = shellWord(ownCli());
288
- if (!runsOfAny.has(kit)) {
289
- runsOfAny.set(kit, automaton((m, from) => {
290
- const after = seq(
291
- maybe(seq(runner, text(' '))), kitAs(kit),
292
- text(' run --timeout'), alt(text(' '), text('=')), one(digit), many(digit), text(' -- '),
293
- )(m, from);
294
- m.edge(after, null, from);
295
- m.edge(seq(runner, text(' '))(m, after), null, after);
296
- return alt(seq(text(ANY_COMMAND), many(anything)), seq(runner, text(' '), one((c) => c !== '/'), many(anything)))(m, after);
297
- }));
298
- }
299
- return runsOfAny.get(kit);
300
- }
301
-
302
- /** The kit as a word, and nothing more, for the kit as it is spelled now. */
303
- const kitWords = new Map();
304
- function kitWord() {
305
- const kit = shellWord(ownCli());
306
- if (!kitWords.has(kit)) kitWords.set(kit, automaton(kitAs(kit)));
307
- return kitWords.get(kit);
308
- }
309
-
310
- /**
311
- * Whether a glob matches any command the automaton takes: a walk of the two
312
- * together, where a wildcard takes any character, and every test takes one.
313
- */
314
- function meets(glob, { states, start, end }) {
315
- const seen = new Set();
316
- const todo = [[0, start]];
317
- while (todo.length > 0) {
318
- const [at, state] = todo.pop();
319
- const key = at * states.length + state;
320
- if (seen.has(key)) continue;
321
- seen.add(key);
322
- if (at === glob.length && state === end) return true;
323
- const here = glob[at];
324
- if (here === STAR) todo.push([at + 1, state]);
325
- for (const [test, to] of states[state]) {
326
- if (test === null || here === STAR) todo.push([at, to]);
327
- else if (here !== undefined && test(here)) todo.push([at + 1, to]);
328
- }
329
- }
330
- return false;
331
- }
332
-
333
- /**
334
- * Whether a Claude rule lets the kit's run run any command (#635), matched as
335
- * Claude Code matches it: `x:*` at the end is `x *`, and any `*` is any text,
336
- * spaces included. A rule with no wildcard is one exact command. One whose
337
- * first word can be a runner lets the bot run any command without the kit,
338
- * unless its next word can be only the kit, and is named as any other rule:
339
- * `Bash`, `Bash(*)`, `Bash(env *)`.
340
- */
341
- export function letsRunAnything(rule) {
342
- const bash = /^Bash(?:\((.*)\))?$/s.exec(rule);
343
- if (bash === null) return false;
344
- const spec = bash[1] ?? '*';
345
- if (!spec.includes('*')) return false;
346
- const { words, odd } = shellWords(spec);
347
- const [first = '', second = ''] = odd === undefined ? words : spec.split(' ');
348
- runnerWord ??= automaton(runner);
349
- const word = (one) => globOf(one.replace(/:\*$/, ''));
350
- const kitNext = second !== '' && meets(word(second), kitWord()) && !meets(word(second), runnerWord);
351
- if (meets(word(first), runnerWord) && !kitNext) return false;
352
- return meets(globOf(spec.endsWith(':*') ? `${spec.slice(0, -2)} *` : spec), runOfAny());
353
- }
354
-
355
- /*
356
- * A Codex rules file, read as far as health reads one (#635): its comments,
357
- * and calls of the three builtins whose arguments are literals. Anything else
358
- * is Starlark the kit does not evaluate, and health says it cannot tell.
359
- */
360
- const BUILTINS = new Set(['prefix_rule', 'host_executable', 'network_rule']);
361
-
362
- /** The text with each comment blanked out, so a place in it is the same place in the file. */
363
- function uncommented(source) {
364
- let out = '';
365
- let quote;
366
- for (let at = 0; at < source.length; at += 1) {
367
- const char = source[at];
368
- if (quote !== undefined) {
369
- out += char;
370
- if (char === '\\' && at + 1 < source.length) out += source[++at];
371
- else if (char === quote || char === '\n') quote = undefined;
372
- } else if (char === '#') {
373
- while (at + 1 < source.length && source[at + 1] !== '\n') {
374
- out += ' ';
375
- at += 1;
376
- }
377
- out += ' ';
378
- } else {
379
- if (char === '"' || char === "'") quote = char;
380
- out += char;
381
- }
382
- }
383
- return out;
384
- }
385
-
386
- const skipSpace = (source, at) => {
387
- let i = at;
388
- while (/\s/.test(source[i] ?? '')) i += 1;
389
- return i;
390
- };
391
-
392
- /** A literal at `at`: `{ value, end }`, or undefined for anything the kit does not read. */
393
- function literal(source, from) {
394
- const at = skipSpace(source, from);
395
- const quote = source[at];
396
- if (quote === '"' || quote === "'") {
397
- let value = '';
398
- // An escape is not read: a string with one is something health cannot tell.
399
- for (let i = at + 1; i < source.length && source[i] !== '\n' && source[i] !== '\\'; i += 1) {
400
- if (source[i] === quote) return { value, end: i + 1 };
401
- value += source[i];
402
- }
403
- return undefined;
404
- }
405
- if (quote === '[' || quote === '(') {
406
- const close = quote === '[' ? ']' : ')';
407
- const value = [];
408
- let i = skipSpace(source, at + 1);
409
- while (source[i] !== close) {
410
- const item = literal(source, i);
411
- if (item === undefined) return undefined;
412
- value.push(item.value);
413
- i = skipSpace(source, item.end);
414
- if (source[i] === ',') i = skipSpace(source, i + 1);
415
- else if (source[i] !== close) return undefined;
416
- }
417
- return { value, end: i + 1 };
418
- }
419
- const word = /^(-?\d+(?:\.\d+)?|True|False|None)\b/.exec(source.slice(at));
420
- return word === null ? undefined : { value: word[1], end: at + word[0].length };
421
- }
422
-
423
- /** A builtin's arguments from its `(`: `{ args, end }`, each `{ name, value }`, or undefined. */
424
- function argumentsOf(source, open) {
425
- const args = [];
426
- let at = skipSpace(source, open + 1);
427
- while (source[at] !== ')') {
428
- const named = /^([A-Za-z_]\w*)\s*=(?!=)/.exec(source.slice(at));
429
- if (named !== null) at += named[0].length;
430
- const item = literal(source, at);
431
- if (item === undefined) return undefined;
432
- args.push({ name: named?.[1], value: item.value });
433
- at = skipSpace(source, item.end);
434
- if (source[at] === ',') at = skipSpace(source, at + 1);
435
- else if (source[at] !== ')') return undefined;
436
- }
437
- return { args, end: at + 1 };
438
- }
439
-
440
- /**
441
- * A rules file's `prefix_rule` calls and what health cannot read in it:
442
- * `{ rules, unread }`, each rule `{ text, pattern, decision }` with its text as
443
- * the file has it, and `unread` the first statement it cannot read, or
444
- * undefined.
445
- */
446
- function readRules(source) {
447
- const bare = uncommented(source);
448
- const rules = [];
449
- let unread;
450
- let at = skipSpace(bare, 0);
451
- while (at < bare.length) {
452
- const call = /^([A-Za-z_]\w*)\s*\(/.exec(bare.slice(at));
453
- const read = call !== null && BUILTINS.has(call[1]) ? argumentsOf(bare, at + call[0].length - 1) : undefined;
454
- if (read === undefined) {
455
- // One line on: what health cannot read is said once, and what it can still comes out.
456
- const line = bare.indexOf('\n', at);
457
- const end = line === -1 ? bare.length : line;
458
- unread ??= source.slice(at, end).trim();
459
- at = skipSpace(bare, end);
460
- continue;
461
- }
462
- if (call[1] === 'prefix_rule') {
463
- // The pattern, then the decision, may come by place; anything more is not read.
464
- const placed = read.args.filter((arg) => arg.name === undefined);
465
- const named = (name) => read.args.find((arg) => arg.name === name);
466
- if (placed.length > 2 || (placed.length > 0 && named('pattern')) || (placed.length > 1 && named('decision'))) {
467
- unread ??= source.slice(at, read.end).trim();
468
- } else {
469
- const decision = (named('decision') ?? placed[1])?.value ?? 'allow';
470
- rules.push({ text: source.slice(at, read.end), pattern: (named('pattern') ?? placed[0])?.value, decision });
471
- }
472
- }
473
- at = skipSpace(bare, read.end);
474
- while (bare[at] === ';') at = skipSpace(bare, at + 1);
475
- }
476
- return { rules, unread };
477
- }
478
-
479
- /** Whether a word is a program that runs whatever its arguments say, by its name or its absolute path. */
480
- const isRunner = (word) => (!word.includes('/') || word.startsWith('/')) && runnerOf(path.basename(word)) !== undefined;
481
-
482
- /**
483
- * Where the kit's run of any command can be after one more word of a
484
- * pattern, from where it was, as `codexRunsAny` reads one; none when the word
485
- * cannot be there.
486
- */
487
- function afterWord(state, word) {
488
- const kitOrRunner = (runner) => [...(isKit(word) ? ['kit'] : []), ...(isRunner(word) ? [runner] : [])];
489
- switch (state) {
490
- case 'start': return kitOrRunner('wrapper');
491
- case 'wrapper': return isKit(word) ? ['kit'] : [];
492
- case 'kit': return word === 'run' ? ['run'] : [];
493
- case 'run': return word === '--timeout' ? ['timeout'] : (/^--timeout=\d+$/.test(word) ? ['limit'] : []);
494
- case 'timeout': return /^\d+$/.test(word) ? ['limit'] : [];
495
- case 'limit': return word === '--' ? ['command'] : [];
496
- case 'command': return kitOrRunner('runner');
497
- case 'runner': return [...(isKit(word) ? ['kit'] : []), ...(!word.startsWith('/') ? ['any'] : (isRunner(word) ? ['runner'] : []))];
498
- default: return ['any'];
499
- }
500
- }
501
-
502
- /**
503
- * Whether a `prefix_rule` pattern lets the kit's run run any command (#635),
504
- * matched as Codex matches it: each element one word of the command, exactly,
505
- * or any word of a list. It does when the pattern can be the start of the
506
- * kit's run of any command, as `runOfAny` has it for Claude. A runner alone
507
- * lets the bot run any command without the kit, and is not one.
508
- */
509
- function codexRunsAny(pattern) {
510
- if (!Array.isArray(pattern) || pattern.length === 0) return false;
511
- let states = new Set(['start']);
512
- for (const element of pattern) {
513
- const words = Array.isArray(element) ? element : [element];
514
- if (words.some((word) => typeof word !== 'string')) return false;
515
- states = new Set([...states].flatMap((state) => words.flatMap((word) => afterWord(state, word))));
516
- if (states.size === 0) return false;
517
- }
518
- return [...states].some((state) => state !== 'wrapper');
519
- }
520
-
521
- /**
522
- * A rules file's `prefix_rule` calls that let the kit's run run any command,
523
- * as their text, and the first statement health cannot read: `{ runs, unread }`.
524
- * Codex matches a rule's words as the command's first words, each exactly.
525
- */
526
- function codexRunRules(source) {
527
- const { rules, unread } = readRules(source);
528
- const runs = rules.filter(({ pattern, decision }) => decision === 'allow' && codexRunsAny(pattern));
529
- return { runs: runs.map((rule) => rule.text), unread };
530
- }
531
-
532
202
  /**
533
203
  * Refuse the rules `permission allow` was given when any is broad, before anything is
534
204
  * written, naming the file the user can add it to themselves.
@@ -879,12 +549,11 @@ function planCodex(bots, home, bot) {
879
549
 
880
550
  /**
881
551
  * What health has to say about a bot's permission rules: `{ where, says }` for
882
- * each entry in the file that `allow` does not hold, each rule `allow`
883
- * holds that the file lacks, and each rule in `allow` or in the bot's local
884
- * Claude settings that lets the kit's run run any command (#635). Read only:
885
- * an entry the kit did not write is named, never taken out.
552
+ * each entry in the file that `allow` does not hold, and each rule `allow`
553
+ * holds that the file lacks. Read only: an entry the kit did not write is named,
554
+ * never taken out.
886
555
  */
887
- export function permissionsTrouble(bots, home, bot) {
556
+ export function permissionsTrouble(home, bot) {
888
557
  let allowed;
889
558
  try {
890
559
  allowed = allowOf(home, bot);
@@ -892,27 +561,7 @@ export function permissionsTrouble(bots, home, bot) {
892
561
  if (!runsOnClaude(bot) && !runsOnCodex(bot)) return [];
893
562
  return [{ where: path.join(home, 'bot.yaml'), says: error.message }];
894
563
  }
895
- return [
896
- ...allowRunTrouble(bots, home, bot, allowed),
897
- ...claudeTrouble(home, bot, allowed),
898
- ...(runsOnClaude(bot) ? claudeRunTrouble(path.join(home, '.claude', 'settings.local.json'), bot.name) : []),
899
- ...codexTrouble(home, bot),
900
- ];
901
- }
902
-
903
- /**
904
- * Each rule in `allow` that lets the kit's run run any command. `permission
905
- * allow` refuses one, so it came into bot.yaml by hand, and the kit writes
906
- * what `allow` holds into the bot's harness files; the kit takes it back
907
- * through `permission disallow`, after the user's yes.
908
- */
909
- function allowRunTrouble(bots, home, bot, allowed) {
910
- if (!runsOnClaude(bot) && !runsOnCodex(bot)) return [];
911
- const yaml = path.join(home, 'bot.yaml');
912
- return [...new Set(allowed)].filter(letsRunAnything).map((rule) => ({
913
- where: yaml,
914
- says: `${yaml} allows ${rule}, and with obk run it lets ${bot.name} run any command as ${RUN_FORM}, with no judgment by auto mode or Codex's review: the kit writes what this allow list holds into ${bot.name}'s harness files. obk permission allow does not record such a rule, so it came into the file by hand. To close the gap, after the user's yes, run ${shellWord(ownCli())} permission disallow --bots ${shellWord(bots)} --bot ${shellWord(bot.name)} --rule ${shellWord(rule)}.`,
915
- }));
564
+ return [...claudeTrouble(home, bot, allowed), ...codexTrouble(home, bot)];
916
565
  }
917
566
 
918
567
  /** Health's findings in the bot's Claude settings. */
@@ -940,7 +589,7 @@ function claudeTrouble(home, bot, allowed) {
940
589
  where: file,
941
590
  // One `permission allow` would refuse was added by the user by hand, which the
942
591
  // boundary leaves to them: named, in neutral words, and left alone.
943
- says: letsRunAnything(rule) ? claudeRunSays(file, rule, bot.name) : broadness(rule) === undefined
592
+ says: broadness(rule) === undefined
944
593
  ? `${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 permission allow; if not, take it out of the file.`
945
594
  : `${file} allows ${rule}, which the user added by hand, not the kit. It stays where it is.`,
946
595
  })),
@@ -972,6 +621,9 @@ function codexTrouble(home, bot) {
972
621
  }
973
622
  }
974
623
 
624
+ // A file health cannot read is one finding of its own, and the rest of the
625
+ // fleet's health still comes out.
626
+ 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.` }];
975
627
  let names = [];
976
628
  try {
977
629
  names = existsSync(folder) ? readdirSync(folder) : [];
@@ -989,18 +641,10 @@ function codexTrouble(home, bot) {
989
641
  } catch (error) {
990
642
  return unread(where, error);
991
643
  }
992
- // A rule that lets the kit's run run any command and is not one whole
993
- // line, over several lines or with a comment after it, is named whole.
994
- const { runs, unread: hidden } = codexRunRules(text);
995
- const lines = ruleLines(text);
996
- return [
997
- ...lines.map((line) => ({
998
- where,
999
- says: runs.includes(line) ? codexRunSays(where, line, bot.name) : `${where} holds ${line}, which the user added, not the kit. It stays where it is.`,
1000
- })),
1001
- ...runs.filter((rule) => !lines.includes(rule)).map((rule) => ({ where, says: codexRunSays(where, rule, bot.name) })),
1002
- ...unclear(where, hidden, bot.name),
1003
- ];
644
+ return ruleLines(text).map((line) => ({
645
+ where,
646
+ says: `${where} holds ${line}, which the user added, not the kit. It stays where it is.`,
647
+ }));
1004
648
  });
1005
649
 
1006
650
  const wanted = [...lines.keys()];
@@ -1023,133 +667,6 @@ function codexTrouble(home, bot) {
1023
667
  }];
1024
668
  }
1025
669
 
1026
- /** How the kit's run is called, as a finding names it. */
1027
- const RUN_FORM = 'obk run --timeout <seconds> -- <command>';
1028
-
1029
- /**
1030
- * What the user does about a rule that lets the kit's run run any command, in
1031
- * a file the kit did not write: the user's own, or the machine's policy.
1032
- */
1033
- const takeOut = (file, policy = false) => `It stays where it is: ${file} is ${policy ? "this machine's policy" : "the user's"}, not the kit's. To close the gap, ${policy ? 'whoever keeps that policy takes' : 'the user takes'} the rule out of the file. The kit's default rules already allow the kit's own commands, narrowed to this bots folder, and a bounded run gets a narrow rule of its own, such as Bash(${shellWord(ownCli())} run --timeout 300 -- go test:*), through obk permission allow after the user's yes.`;
1034
-
1035
- /** Health's words on a Claude rule the kit did not write that lets the kit's run run any command (#635). */
1036
- const claudeRunSays = (file, rule, who, policy) => `${file} allows ${rule}, which the kit did not write. With obk run, it also lets ${who} run any command as ${RUN_FORM}, and auto mode does not judge that command. ${takeOut(file, policy)}`;
1037
-
1038
- /** Health's words on such a Codex rule. An allowed command runs outside Codex's sandbox, with no review. */
1039
- const codexRunSays = (file, rule, who, policy) => `${file} holds ${rule}, which the kit did not write. With obk run, it also lets ${who} run any command as ${RUN_FORM}, outside Codex's sandbox and with no review. ${takeOut(file, policy)}`;
1040
-
1041
- /**
1042
- * Health's words on a Codex rules file that holds what health does not read,
1043
- * such as a pattern kept in a variable: it cannot tell what the file allows.
1044
- */
1045
- const unclear = (where, part, who) => (part === undefined ? [] : [{
1046
- where,
1047
- says: `${where} holds ${part}, which health does not read: it reads only prefix_rule, host_executable and network_rule calls written out in full. So it cannot tell whether a rule there lets ${who} run whatever command obk run is given. It stays where it is. To check the file, run codex execpolicy check --rules ${shellWord(where)} with the kit's run, such as obk run --timeout 300 -- sh -c true, and read its decision.`,
1048
- }]);
1049
-
1050
- /** A file health cannot read is one finding of its own, and the rest of the fleet's health still comes out. */
1051
- 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.` }];
1052
-
1053
- /**
1054
- * Health's findings on each rule in a Claude settings file the kit does not
1055
- * write that lets the kit's run run any command.
1056
- */
1057
- function claudeRunTrouble(file, who, policy) {
1058
- if (!existsSync(file)) return [];
1059
- let present;
1060
- try {
1061
- present = presentIn(readSettings(file, 'permission rules'), file);
1062
- } catch (error) {
1063
- return [{ where: file, says: error.message }];
1064
- }
1065
- return [...new Set(present)].filter((rule) => typeof rule === 'string' && letsRunAnything(rule))
1066
- .map((rule) => ({ where: file, says: claudeRunSays(file, rule, who, policy) }));
1067
- }
1068
-
1069
- /**
1070
- * Health's findings on each rule in the `.rules` files of a Codex rules
1071
- * folder the kit does not write that lets the kit's run run any command.
1072
- */
1073
- function codexRunTrouble(folder, who, policy) {
1074
- let names;
1075
- try {
1076
- names = existsSync(folder) ? readdirSync(folder) : [];
1077
- } catch (error) {
1078
- return unread(folder, error);
1079
- }
1080
- return names.filter((name) => name.endsWith('.rules')).sort().flatMap((name) => {
1081
- const where = path.join(folder, name);
1082
- try {
1083
- if (!statSync(where).isFile()) return [];
1084
- const { runs, unread: hidden } = codexRunRules(readFileSync(where, 'utf8'));
1085
- return [...runs.map((rule) => ({ where, says: codexRunSays(where, rule, who, policy) })), ...unclear(where, hidden, who)];
1086
- } catch (error) {
1087
- return unread(where, error);
1088
- }
1089
- });
1090
- }
1091
-
1092
- /** The nearest folder at or above `from` that holds `.git`: the repository both harnesses read project files up to. */
1093
- function repoRoot(from) {
1094
- for (let at = path.resolve(from); ; at = path.dirname(at)) {
1095
- if (existsSync(path.join(at, '.git'))) return at;
1096
- if (path.dirname(at) === at) return undefined;
1097
- }
1098
- }
1099
-
1100
- /**
1101
- * What health has to say, for the whole fleet, about the allow lists every
1102
- * bot's harness reads beside its own folder (#635): each rule there that lets
1103
- * the kit's run run any command. Claude Code reads the user's settings, and,
1104
- * for a session started below a git repository's root, the local settings at
1105
- * that root; Codex reads the user's rules, and the rules of each folder from
1106
- * the root down to the session's, once the project is trusted. Both read the
1107
- * machine's policy too; Claude's can also come from MDM or the claude.ai
1108
- * console, which no file shows. Read only: the kit names a rule there, never
1109
- * takes it out.
1110
- */
1111
- export function kitRunTrouble(bots, { claude, codex }) {
1112
- const root = repoRoot(botsDir(bots));
1113
- // OBK_POLICY_ROOT stands for `/` where the policy folders are, as in a test.
1114
- const system = process.env.OBK_POLICY_ROOT || path.parse(process.cwd()).root;
1115
- const found = [];
1116
- if (claude) {
1117
- const managed = path.join(system, process.platform === 'darwin' ? 'Library/Application Support/ClaudeCode' : 'etc/claude-code');
1118
- const policy = 'every bot\'s Claude sessions';
1119
- found.push(...claudeRunTrouble(path.join(managed, 'managed-settings.json'), policy, true));
1120
- const parts = path.join(managed, 'managed-settings.d');
1121
- let names = [];
1122
- try {
1123
- names = existsSync(parts) ? readdirSync(parts) : [];
1124
- } catch (error) {
1125
- found.push(...unread(parts, error));
1126
- }
1127
- for (const name of names.filter((one) => one.endsWith('.json') && !one.startsWith('.')).sort()) {
1128
- found.push(...claudeRunTrouble(path.join(parts, name), policy, true));
1129
- }
1130
- const user = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
1131
- found.push(...claudeRunTrouble(path.join(user, 'settings.json'), 'every bot\'s Claude sessions'));
1132
- if (root !== undefined && root !== os.homedir()) {
1133
- found.push(...claudeRunTrouble(path.join(root, '.claude', 'settings.local.json'), `the Claude sessions of every bot in ${root}`));
1134
- }
1135
- }
1136
- if (codex) {
1137
- const user = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
1138
- found.push(...codexRunTrouble(path.join(system, 'etc/codex/rules'), 'every bot\'s Codex sessions', true));
1139
- found.push(...codexRunTrouble(path.join(user, 'rules'), 'every bot\'s Codex sessions'));
1140
- const folders = [];
1141
- for (let at = botsDir(bots); root !== undefined; at = path.dirname(at)) {
1142
- folders.unshift(at);
1143
- if (at === root || path.dirname(at) === at) break;
1144
- }
1145
- for (const folder of folders) {
1146
- if (path.resolve(folder, '.codex') === path.resolve(user)) continue;
1147
- found.push(...codexRunTrouble(path.join(folder, '.codex', 'rules'), `the Codex sessions of every bot in ${folder}`));
1148
- }
1149
- }
1150
- return found;
1151
- }
1152
-
1153
670
  /** One finding about `missing`, made by `finding`, or none when nothing is missing. */
1154
671
  const missingIn = (missing, finding) => (missing.length === 0 ? [] : [finding([...new Set(missing)])]);
1155
672
 
package/src/processes.js CHANGED
@@ -19,19 +19,18 @@
19
19
  // signals: SIGTERM first, to the whole group where every process in it is the
20
20
  // session's own, then up to 3 seconds for them to end, then SIGKILL to each
21
21
  // that still runs. Inside Codex's sandbox `ps` does not start (#298), and then
22
- // nothing is stopped and the answer says it cannot tell. OBK_PS, OBK_LSOF and
23
- // OBK_KILL override the three, as OBK_ORCA does Orca, so the suite runs on
24
- // fakes and sends no real signal.
22
+ // nothing is stopped and the answer says it cannot tell. The kit runs the three
23
+ // by their fixed paths, and no setting changes them (#652).
25
24
 
26
25
  import { spawnSync } from 'node:child_process';
27
26
  import { realpathSync } from 'node:fs';
28
27
  import path from 'node:path';
29
28
  import { setTimeout as delay } from 'node:timers/promises';
30
29
 
31
- import { lsofCli, psCli } from './orca.js';
30
+ import { LSOF, PS } from './orca.js';
32
31
 
33
- /** The `kill` this run sends signals with. OBK_KILL overrides it. */
34
- const killCli = () => process.env.OBK_KILL || '/bin/kill';
32
+ /** The `kill` the kit sends signals with, by its fixed path. */
33
+ const KILL = '/bin/kill';
35
34
 
36
35
  /** How long one `ps`, `lsof` or `kill` may take. */
37
36
  const READ_MS = 10_000;
@@ -273,7 +272,7 @@ function readProcesses() {
273
272
  * READ_MS.
274
273
  */
275
274
  function workingFolders(ms = READ_MS) {
276
- const asked = spawnSync(lsofCli(), ['-a', '-d', 'cwd', '-u', String(process.getuid()), '-Fpn'], { encoding: 'utf8', timeout: bound(ms), maxBuffer: 64 * 1024 * 1024 });
275
+ const asked = spawnSync(LSOF, ['-a', '-d', 'cwd', '-u', String(process.getuid()), '-Fpn'], { encoding: 'utf8', timeout: bound(ms), maxBuffer: 64 * 1024 * 1024 });
277
276
  if (asked.error?.code === 'ETIMEDOUT') return { unreadable: 'lsof did not answer in time' };
278
277
  if (asked.error || asked.status !== 0) return { unreadable: `lsof could not list the processes' working folders${because(asked)}` };
279
278
  const cwds = new Map();
@@ -292,7 +291,7 @@ function workingFolders(ms = READ_MS) {
292
291
  * own command line (review of PR #543). Or `{ unreadable: <why> }`.
293
292
  */
294
293
  function processTable(ms = READ_MS) {
295
- const asked = spawnSync(psCli(), ['-A', '-ww', '-o', 'pid=,ppid=,pgid=,uid=,stat=,lstart=,command='], { encoding: 'utf8', timeout: bound(ms), maxBuffer: 64 * 1024 * 1024 });
294
+ const asked = spawnSync(PS, ['-A', '-ww', '-o', 'pid=,ppid=,pgid=,uid=,stat=,lstart=,command='], { encoding: 'utf8', timeout: bound(ms), maxBuffer: 64 * 1024 * 1024 });
296
295
  if (asked.error?.code === 'ETIMEDOUT') return { unreadable: 'ps did not answer in time' };
297
296
  if (asked.error || asked.status !== 0) return { unreadable: `ps could not read the process table${because(asked)}` };
298
297
  const table = new Map();
@@ -383,7 +382,7 @@ const inside = (cwd, dir) => cwd === dir || cwd.startsWith(`${dir}${path.sep}`);
383
382
  */
384
383
  function signal(name, target, ms = READ_MS) {
385
384
  if (!Number.isInteger(target) || Math.abs(target) <= 1) return { refused: `the kit does not signal ${target}` };
386
- const sent = spawnSync(killCli(), ['-s', name, '--', String(target)], { encoding: 'utf8', timeout: bound(ms) });
385
+ const sent = spawnSync(KILL, ['-s', name, '--', String(target)], { encoding: 'utf8', timeout: bound(ms) });
387
386
  // Cut off at its time: whether the signal went is not known.
388
387
  if (sent.error?.code === 'ETIMEDOUT') return { late: true };
389
388
  if (!sent.error && sent.status === 0) return {};
package/src/up.js CHANGED
@@ -10,7 +10,7 @@ import path from 'node:path';
10
10
  import { setTimeout as pause } from 'node:timers/promises';
11
11
 
12
12
  import { forgetClaimed, forgetSession, MAILBOX_WAIT_MS, readBook, rememberSession, sessionIdsIn, tabIdsIn, takeLineTurn, takeMailboxTurn, TYPING_HELD, TYPING_WAIT_MS, updateBook, withUnclaimed } from './book.js';
13
- import { botDir, botNames, displayName, projectName, readBot } from './bot.js';
13
+ import { botDir, botNames, displayName, readBot } from './bot.js';
14
14
  import { conversationsIn, hasConversation, heldAsUserTurn, transcriptsIn } from './conversations.js';
15
15
  import { dropRemovedHooks, installHook } from './hooks.js';
16
16
  import { writePermissions } from './permissions.js';
@@ -233,7 +233,7 @@ async function bringUpBot(bots, home, bot, onlySession) {
233
233
  // Orca is asked first and the book is written after: nothing that takes time
234
234
  // happens while the book is held, because a session's own hook may be writing
235
235
  // its id into that same file at any moment.
236
- const { change, ...orca } = orcaProject(home, projectName(bots, name));
236
+ const { change, ...orca } = orcaProject(home, title);
237
237
  await updateBook(home, (book) => { book.orca = orca; });
238
238
  // Orca's window does not see a project made or renamed on the command line
239
239
  // until it is told (#224).
package/src/run.js DELETED
@@ -1,155 +0,0 @@
1
- // `obk run --timeout <seconds> -- <command…>`: one command run with a hard time
2
- // limit (#532). macOS has no `timeout`, and the fleet's form for a bounded run,
3
- // `perl -e 'alarm …; exec @ARGV' -- <command>`, cannot be allowed by a narrow
4
- // rule, since perl runs whatever its arguments say. A rule for this command
5
- // names the real command after `--`, and the kit judges that command as a rule
6
- // of its own (permissions.js). It runs whatever its arguments say too, so it is
7
- // never in the default set (ADR 0050).
8
- //
9
- // The command runs with no shell, in a process group of its own, so the time
10
- // limit stops what it started as well. Its exit code comes back as it is; one
11
- // ended by a signal the kit did not send comes back as 128 and the signal's
12
- // number, as a shell gives it.
13
-
14
- import { spawn } from 'node:child_process';
15
- import { constants } from 'node:os';
16
-
17
- /** The exit code of a run the time limit stopped, as GNU timeout gives it. */
18
- const TIMED_OUT = 124;
19
-
20
- /** The exit codes a shell gives a command it cannot find, and one it cannot run. */
21
- const NOT_FOUND = 127;
22
- const CANNOT_RUN = 126;
23
-
24
- /** How long the group has after SIGTERM before SIGKILL, as `retire` gives a session's processes (#537). */
25
- const GRACE_MS = 3000;
26
-
27
- /** How often the kit looks whether anything of the group still runs. */
28
- const LOOK_MS = 100;
29
-
30
- /** The longest time limit a timer holds, in seconds: about 24 days. */
31
- const LONGEST = Math.floor((2 ** 31 - 1) / 1000);
32
-
33
- /** The signals that end the kit and that it passes on to the command's group, so it is not left running. */
34
- const PASSED_ON = ['SIGINT', 'SIGTERM', 'SIGHUP'];
35
-
36
- const USE = 'obk run takes --timeout <seconds>, then --, then the command: obk run --timeout 300 -- go test ./...';
37
-
38
- /**
39
- * The time limit and the command from `obk run`'s arguments, or a refusal.
40
- * Only `--timeout` goes before `--`; everything after it is the command's.
41
- */
42
- export function parseRun(args) {
43
- const end = args.indexOf('--');
44
- const options = end === -1 ? args : args.slice(0, end);
45
- let value;
46
- for (let at = 0; at < options.length; at += 1) {
47
- const option = options[at];
48
- if (option === '--timeout' && at + 1 < options.length && value === undefined) {
49
- value = options[at + 1];
50
- at += 1;
51
- } else if (option.startsWith('--timeout=') && value === undefined) {
52
- value = option.slice('--timeout='.length);
53
- } else {
54
- throw new Error(`run does not take ${option}. ${USE}. Nothing was run.`);
55
- }
56
- }
57
- if (value === undefined) throw new Error(`run needs --timeout <seconds>: how long the command may run. ${USE}. Nothing was run.`);
58
- if (!/^\d+$/.test(value) || Number(value) === 0 || Number(value) > LONGEST) {
59
- throw new Error(`run's --timeout is a whole number of seconds from 1 to ${LONGEST}, and got: ${value === '' ? 'nothing' : value}. Nothing was run.`);
60
- }
61
- const command = end === -1 ? [] : args.slice(end + 1);
62
- if (command.length === 0) throw new Error(`run needs the command after --. ${USE}. Nothing was run.`);
63
- return { seconds: Number(value), command };
64
- }
65
-
66
- /**
67
- * Run the command with its time limit, its input and output the caller's, and
68
- * give back the exit code `obk run` exits with. The run ends when nothing of
69
- * the command's group runs any more: a process the command started and left
70
- * running is held to the same limit, and to a signal the kit passes on.
71
- */
72
- export function runBounded({ seconds, command }) {
73
- return new Promise((resolve) => {
74
- // `detached` makes the command the leader of a group of its own, so its
75
- // pid, taken here at spawn, is the group's id.
76
- const child = spawn(command[0], command.slice(1), { stdio: 'inherit', detached: true });
77
- // The leader's exit code, once it has exited.
78
- let exitCode;
79
- let stopping = false;
80
- let result;
81
- let killAt;
82
- let deadline;
83
- let look;
84
- const signal = (pid, name) => {
85
- try {
86
- process.kill(pid, name);
87
- return true;
88
- } catch {
89
- return false;
90
- }
91
- };
92
- // Each process of the group gets a signal once. The leader gets it by its
93
- // own pid as well only when it may have left the group: when nothing is
94
- // left in the group, or for SIGKILL, which no handler sees. And only
95
- // while it has not exited, since its pid is free again after that.
96
- const group = (name) => {
97
- const sent = signal(-child.pid, name);
98
- if (exitCode === undefined && name !== 0 && (!sent || name === 'SIGKILL')) signal(child.pid, name);
99
- return sent;
100
- };
101
-
102
- const finish = (code) => {
103
- clearTimeout(deadline);
104
- clearTimeout(look);
105
- for (const name of PASSED_ON) process.off(name, passOn);
106
- resolve(code);
107
- };
108
-
109
- // Once the leader has exited or the group is being stopped: look until
110
- // nothing of the group runs, and send SIGKILL when the grace is over.
111
- const watch = () => {
112
- if (exitCode !== undefined && !group(0)) return finish(result ?? exitCode);
113
- if (killAt !== undefined && Date.now() >= killAt) {
114
- group('SIGKILL');
115
- killAt = undefined;
116
- }
117
- look = setTimeout(watch, LOOK_MS);
118
- return undefined;
119
- };
120
-
121
- // Stop the group: `name` first, then SIGKILL to what still runs after the
122
- // grace. obk run exits with `code`, or with the leader's own when it is
123
- // undefined.
124
- const stop = (name, code) => {
125
- if (stopping) return;
126
- stopping = true;
127
- result = code;
128
- clearTimeout(deadline);
129
- group(name);
130
- killAt = Date.now() + GRACE_MS;
131
- clearTimeout(look);
132
- watch();
133
- };
134
- const passOn = (name) => stop(name, undefined);
135
-
136
- child.on('error', (error) => {
137
- const missing = error.code === 'ENOENT';
138
- process.stderr.write(`obk: run could not start ${command[0]}: ${missing ? 'there is no such command' : error.code ?? error.message}.\n`);
139
- finish(missing ? NOT_FOUND : CANNOT_RUN);
140
- });
141
- if (child.pid === undefined) return;
142
- for (const name of PASSED_ON) process.on(name, passOn);
143
-
144
- child.on('exit', (code, name) => {
145
- exitCode = code ?? 128 + constants.signals[name];
146
- clearTimeout(look);
147
- watch();
148
- });
149
-
150
- deadline = setTimeout(() => {
151
- process.stderr.write(`obk: run stopped ${command.join(' ')}: it ran past its time limit of ${seconds} seconds, and its whole process group was stopped.\n`);
152
- stop('SIGTERM', TIMED_OUT);
153
- }, seconds * 1000);
154
- });
155
- }