@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 +9 -82
- package/SETUP.md +2 -2
- package/package.json +1 -1
- package/skills/obk-bot-building/SKILL.md +5 -8
- package/src/bot.js +0 -20
- package/src/cli.js +4 -18
- package/src/commands.js +5 -8
- package/src/health.js +3 -20
- package/src/orca.js +12 -12
- package/src/permissions.js +35 -518
- package/src/processes.js +8 -9
- package/src/up.js +2 -2
- package/src/run.js +0 -155
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
|
|
170
|
-
|
|
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`.
|
|
178
|
-
the
|
|
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
|
-
##
|
|
685
|
+
## Contributing
|
|
684
686
|
|
|
685
|
-
|
|
686
|
-
|
|
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
|
|
148
|
-
|
|
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
|
@@ -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
|
|
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
|
|
222
|
-
|
|
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.
|
|
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
|
|
345
|
-
|
|
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)
|
|
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
|
|
91
|
-
*
|
|
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 {
|
|
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)
|
|
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(
|
|
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`
|
|
385
|
-
const
|
|
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(
|
|
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`
|
|
745
|
-
export const
|
|
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`
|
|
842
|
-
export const
|
|
843
|
-
const
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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);
|
package/src/permissions.js
CHANGED
|
@@ -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,
|
|
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)
|
|
67
|
-
*
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
884
|
-
*
|
|
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(
|
|
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:
|
|
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
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
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.
|
|
23
|
-
//
|
|
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 {
|
|
30
|
+
import { LSOF, PS } from './orca.js';
|
|
32
31
|
|
|
33
|
-
/** The `kill`
|
|
34
|
-
const
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
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,
|
|
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
|
-
}
|