@nledford/omp-coord 0.4.0 → 0.4.2
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/.omp-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +17 -1
- package/docs/README.md +1 -0
- package/docs/commands.md +1 -1
- package/docs/configuration.md +2 -1
- package/docs/event-log.md +2 -2
- package/docs/protect.md +43 -0
- package/docs/queues.md +2 -1
- package/examples/omp-coord.json +19 -0
- package/package.json +1 -1
- package/src/adapters/cli/cli.ts +11 -3
- package/src/adapters/omp/extension.ts +105 -12
- package/src/adapters/sqlite/store.ts +6 -1
- package/src/adapters/text/report.ts +1 -1
- package/src/app/coordinator.ts +7 -1
- package/src/app/job-runner.ts +2 -0
- package/src/config.ts +41 -3
- package/src/domain/commands.ts +15 -6
- package/src/domain/history.ts +39 -1
- package/src/domain/model.ts +3 -0
- package/src/domain/protect.ts +537 -0
- package/src/domain/queues.ts +30 -0
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"name": "omp-coord",
|
|
8
8
|
"source": "./",
|
|
9
9
|
"description": "Shared subagent caps, build and test queues, claims, pause, a decision log and messages across omp sessions working in one project on one machine.",
|
|
10
|
-
"version": "0.4.
|
|
10
|
+
"version": "0.4.2",
|
|
11
11
|
"author": { "name": "Nathaniel Ledford" },
|
|
12
12
|
"homepage": "https://github.com/nledford/omp-coord",
|
|
13
13
|
"repository": "https://github.com/nledford/omp-coord",
|
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,20 @@ Add each user-visible change under Unreleased as it lands, in one of: Added, Cha
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.2] - 2026-11-02
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `omp-coord run --meta k=v` (repeatable, at most 8; the key matches `[a-z][a-z0-9_-]{0,31}`, the value is at most 200 characters on one line) tags a job. The tags go into the `job_queued` and `job_ended` events as `meta`, and `job_ended` now also carries the job's `key`. A script can detect support with `omp-coord --help 2>&1 | grep -q -- '--meta'`. The jobs table gains a nullable `meta` column (no schema version bump).
|
|
14
|
+
- `omp-coord stats` and `/coord stats` add `; of the failures: N repeat an unchanged failure, M intended` to a queue's line when a failed job of that day had tags: a repeat is a failed job whose `fp` tag equals that of an earlier failed job in the same queue, with no successful job of that `fp` in between; intended is `intent=failfirst`.
|
|
15
|
+
|
|
16
|
+
## [0.4.1] - 2026-10-30
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- `protect` in `.omp-coord.json`: the extension refuses `edit`, `write`, `ast_edit` and `apply_patch` calls (also through `xd://`) whose target lies in a protected checkout and not in a linked worktree below it, and a bash command whose state-changing `git` (`checkout`, `reset`, `commit`, `clean`, `rebase` and the like) runs there, naming the resolved path or folder. A relative path in the file tools resolves against omp's own folder, not the `cwd` of an agent's bash calls, and a bash call without a `cwd` runs there too, so an agent told to work in a worktree edited or reset the main checkout. Known gaps, in [protect](docs/protect.md#what-it-cannot-see): a shell command's file writes (`cat >`, `cp`, `mv`) and `eval`, the `eval` tool and `lsp` write without a file-tool call to judge; a git folder the shell computes (`cd "$W"`, `git -C $(pwd)`) is not judged; the guard fails open when its own check throws. Subagents only unless `protect.agents` is `"all"`; each refusal is a `write_refused` event naming the agent. Without `protect` nothing changes. The guard reads paths and command text, so it stops the usual forms, not a determined dodge; what it cannot see is listed in [protect](docs/protect.md).
|
|
21
|
+
- A `commands` rule may set `"agents": "sub"` to apply to subagents' commands only (default `"all"`). `examples/omp-coord.json` has three such `refuse` rules for in-place rewrites (`sed -i`, `perl -i`, an inline Python script that writes a file), which agents used instead of the edit tool; copy them into your file to use them.
|
|
22
|
+
|
|
9
23
|
## [0.4.0] - 2026-10-28
|
|
10
24
|
|
|
11
25
|
### Added
|
|
@@ -106,7 +120,9 @@ Add each user-visible change under Unreleased as it lands, in one of: Added, Cha
|
|
|
106
120
|
- An SQLite event log per scope, with retention.
|
|
107
121
|
- Packaging as an omp plugin, with a marketplace catalog; queued commands run on omp's own Bun.
|
|
108
122
|
|
|
109
|
-
[Unreleased]: https://github.com/nledford/omp-coord/compare/v0.4.
|
|
123
|
+
[Unreleased]: https://github.com/nledford/omp-coord/compare/v0.4.2...HEAD
|
|
124
|
+
[0.4.2]: https://github.com/nledford/omp-coord/compare/v0.4.1...v0.4.2
|
|
125
|
+
[0.4.1]: https://github.com/nledford/omp-coord/compare/v0.4.0...v0.4.1
|
|
110
126
|
[0.4.0]: https://github.com/nledford/omp-coord/compare/v0.3.2...v0.4.0
|
|
111
127
|
[0.3.2]: https://github.com/nledford/omp-coord/compare/v0.3.1...v0.3.2
|
|
112
128
|
[0.3.1]: https://github.com/nledford/omp-coord/compare/v0.3.0...v0.3.1
|
package/docs/README.md
CHANGED
|
@@ -10,6 +10,7 @@ How it works:
|
|
|
10
10
|
- [Agent slots and the line](agent-slots.md): caps, holds, revivals, agents started by subagents.
|
|
11
11
|
- [Build and test queues](queues.md): `omp-coord run`, order, lanes, time limits, agents' commands.
|
|
12
12
|
- [Working across sessions](coordination.md): messages, claims, pause, the decision log, ports, crash recovery.
|
|
13
|
+
- [Protected checkouts](protect.md): refusing file-tool writes into the main checkout, and in-place rewrites by shell commands.
|
|
13
14
|
|
|
14
15
|
Reference:
|
|
15
16
|
|
package/docs/commands.md
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
## `omp-coord`
|
|
27
27
|
|
|
28
28
|
```sh
|
|
29
|
-
omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] -- <command> [args…]
|
|
29
|
+
omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] [--meta k=v]... -- <command> [args…]
|
|
30
30
|
omp-coord status [--agents | --json] | stats [days] | interrupted [hours] | notes [count] | note <text…>
|
|
31
31
|
omp-coord pause [reason…] | resume | doctor [--fix]
|
|
32
32
|
```
|
package/docs/configuration.md
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
| `agents.startTimeoutSeconds` | `120` | How long an approved spawn may take to start before its slot frees. |
|
|
15
15
|
| `agents.queueSpawns` | `true` | While the session is the only one in the scope, keep omp's per-session spawn queue (`task.maxConcurrency`) at what the caps allow it, so spawns past the cap wait and start by themselves instead of being refused ([agent slots](agent-slots.md#spawns-wait-their-turn)); with other sessions, or paused, spawns over the cap are refused as before. A `task.maxConcurrency` you set yourself stays the ceiling. `false` keeps the refusals always. |
|
|
16
16
|
| `messages.perMinute` | `10` | Messages one session may send per minute. It slows two sessions that keep answering each other; it does not stop them. Each delivered message starts a turn in an idle session. |
|
|
17
|
-
| `commands` | `[]` | Rules for agents' bash commands, first match wins: `{ "pattern": "<regex>", "action": "refuse", "reason": "…" }`, or `{ "pattern", "action": "queue", "queue": "<name>", "lane"? }
|
|
17
|
+
| `commands` | `[]` | Rules for agents' bash commands, first match wins: `{ "pattern": "<regex>", "action": "refuse", "reason": "…" }`, or `{ "pattern", "action": "queue", "queue": "<name>", "lane"? }`; `"agents": "sub"` on a rule applies it to subagents' commands only (default `"all"`). The user's own `!` commands are not affected. Nothing is refused unless a rule is listed; `examples/omp-coord.json` has ready-made `refuse` rules, including two that protect git's rerere cache ([queues](queues.md#example-protecting-gits-rerere-cache)) and three that refuse in-place rewrites by `sed -i`, `perl -i` and inline Python ([protect](protect.md#in-place-rewrites-by-shell)). |
|
|
18
|
+
| `protect` | none | `{ "roots": ["."], "allow": [], "agents": "sub" }`: checkouts the file tools (`edit`, `write`, `ast_edit`, `apply_patch`) may not write to and where a bash command's state-changing `git` (`checkout`, `reset`, `commit`, `clean`, `rebase` and the like) may not run, so an agent that types a relative path does not change the main checkout instead of its worktree ([protect](protect.md)). `roots` are relative to the config file's folder; `allow` entries are files or folders below a root, relative to it (no leading `/`, no `..`); `agents` is `"sub"` (default) or `"all"`. |
|
|
18
19
|
| `queues.<name>.slots` | required | How many jobs of this queue run at once, across all processes in the scope. Queue names use letters, digits, `.`, `_`, `-`. |
|
|
19
20
|
| `queues.<name>.order` | `"fifo"` | `"fifo"`, `"fair-share"` or `"priority"` (see [queues](queues.md#order)). |
|
|
20
21
|
| `queues.<name>.lanes` | `["normal"]` | Lane names, first served first; the last is the default. |
|
package/docs/event-log.md
CHANGED
|
@@ -25,8 +25,8 @@ These tables are rebuilt as sessions come and go; their layout is not part of th
|
|
|
25
25
|
`events` is an append-only log: `at` (ms since epoch), `type`, `session_id` (null for jobs from outside omp), `label`, and `data` (JSON). Types:
|
|
26
26
|
|
|
27
27
|
- Sessions and agents: `session_joined`, `session_left`, `spawn_approved`, `spawn_refused` (with `position`, `used`, `cap`, or `nested` and `reason`), `run_started` (with `revived`), `run_ended` (with `ranMs`), `start_expired`, `hold_granted`, `hold_used` and `hold_expired` (with `waitedMs` since the first refusal), `message_sent` (recipients and length, never the text).
|
|
28
|
-
- Jobs: `job_queued` (with `queue`, `lane`, `priority`, `key`, and `agent` when recorded), `job_refused` (with `reason`), `job_started` (with `waitedMs`), `job_ended` (with `exitCode`, `ranMs`, and `timedOut` when the time limit stopped it), `job_withdrawn` (left the line before starting), `job_superseded`, `job_lost` (its runner died), `job_reprioritized`.
|
|
29
|
-
- Control: `paused` (with `reason`), `resumed` (with `pausedMs`), `claimed`, `released`, `claim_refused` (with `holder`), `note_added` (note number and length).
|
|
28
|
+
- Jobs: `job_queued` (with `queue`, `lane`, `priority`, `key`, and `agent` and `meta` when recorded), `job_refused` (with `reason`), `job_started` (with `waitedMs`), `job_ended` (with `exitCode`, `ranMs`, `key` and `meta` when the job had them, and `timedOut` when the time limit stopped it), `job_withdrawn` (left the line before starting), `job_superseded`, `job_lost` (its runner died), `job_reprioritized`.
|
|
29
|
+
- Control: `paused` (with `reason`), `resumed` (with `pausedMs`), `claimed`, `released`, `claim_refused` (with `holder`), `write_refused` (a file-tool write or a state-changing `git` command refused in a protected checkout: `agentId`, `tool` (`bash` for git), `paths` as the agent wrote them, empty for git), `note_added` (note number and length).
|
|
30
30
|
|
|
31
31
|
`notes` is the decision log: `id`, `at`, `label`, `author`, `text`.
|
|
32
32
|
|
package/docs/protect.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Protected checkouts
|
|
2
|
+
|
|
3
|
+
Agents are told to work in a worktree of their own and to leave the project's main checkout alone, but nothing stops them: **a relative path in `edit`, `write`, `ast_edit` and `read` resolves against omp's own folder, not against the `cwd` an agent gives its bash calls, and a bash call without a `cwd` runs in that same folder.** An agent whose brief says "work in `/repo/.claude/worktrees/fix`" and who types `crates/a.rs` into `edit` changes `/repo/crates/a.rs`, the main checkout, while its `git status` in the worktree shows nothing; one that runs `git checkout -- .` without a `cwd` resets it. A `protect` section makes omp-coord refuse both before they happen.
|
|
4
|
+
|
|
5
|
+
```json
|
|
6
|
+
"protect": { "roots": ["."], "allow": ["notes"], "agents": "sub" }
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
| Field | Default | Meaning |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `protect.roots` | required | Folders of the checkouts to protect, relative to the config file's folder (`"."` is the folder holding `.omp-coord.json`) or absolute. |
|
|
12
|
+
| `protect.allow` | `[]` | Files and folders below a root, relative to it, that stay writable. |
|
|
13
|
+
| `protect.agents` | `"sub"` | Whose writes are judged: `"sub"` for subagents only (the main agent edits the checkout as before), or `"all"`. |
|
|
14
|
+
|
|
15
|
+
## What is refused
|
|
16
|
+
|
|
17
|
+
A call of `edit`, `write`, `ast_edit` or `apply_patch` whose target, after `~` and `..` are resolved against omp's folder and symbolic links are followed, lies inside a root and is not inside a linked worktree below it (a folder with a `.git` of its own, such as `<root>/.claude/worktrees/<name>`, including the worktree's top folder), nor under an `allow` entry. A target that holds a root (`ast_edit` on `..` or `/`) is refused too. One protected target refuses the whole call. The refusal names the resolved path, says that a relative path resolves against omp's folder, and gives the path relative to the root to put after the worktree's own path, so the agent corrects the call instead of finding the change later. Failing to write the event below never lets a refused call through.
|
|
18
|
+
|
|
19
|
+
- A symbolic link is judged by where it points: `AGENTS.local.md` in the checkout, a link to a file of another repository, is writable; a link from elsewhere into the checkout is not.
|
|
20
|
+
- `write` to `xd://edit`, `xd://write`, `xd://ast_edit` and `xd://apply_patch` (any case) is judged as the tool whose arguments it carries.
|
|
21
|
+
- Every way `edit` names a file counts: a `[path#TAG]` header (the path runs to the last `]`, so `[slug]` folders work), an `MV` destination (resolved like a header), `path`, `rename` and `move_to` (also in `edits`), and the `*** Update File:`, `*** Add File:`, `*** Delete File:`, `*** Move to:` and `*** Edit File:` lines of the patch modes.
|
|
22
|
+
- omp reads a path differently per tool (it unquotes a header but not a `write` path, drops a `[…#TAG]` wrapper or a copied `Update File:` and a leading `@`), so each path is judged as written and with those decorations removed, and the call is refused when either lies in the checkout. A decorated path to a worktree can therefore be refused (`write` with `"/repo/.claude/worktrees/x/a.rs"` in quotes): write it plainly.
|
|
23
|
+
- For `ast_edit`, a glob is judged by what comes before its first wildcard.
|
|
24
|
+
- Each refusal is a `write_refused` event (`agentId`, `tool`, `paths`), so the log names the agent that tried ([event log](event-log.md)).
|
|
25
|
+
|
|
26
|
+
## State-changing git
|
|
27
|
+
|
|
28
|
+
A bash command whose `git` runs in a protected checkout is refused when its subcommand changes files, the index, HEAD or a branch: `checkout`, `switch`, `reset`, `restore`, `clean`, `rebase`, `merge`, `commit`, `pull`, `cherry-pick`, `revert`, `am`, `add`, `rm`, `mv` and `apply`. Reading (`status`, `diff`, `log`, `show`, `branch`), `fetch`, `push` and `worktree` stay allowed. Where a command runs follows the call's `cwd` (omp's folder when none is given), then each `cd` and `pushd`, `git -C <dir>` and `--work-tree`, through `&&`, `||`, `;`, `|` and subshells; a folder it cannot work out (`cd "$W"`, `cd -`) is not judged. Heredoc bodies are skipped. So `cd /repo && git checkout -q -- .` is refused from any folder, and `cd /repo/.claude/worktrees/x && git checkout -- a.rs` or `git -C /repo/.claude/worktrees/x reset` is not. An agent that really must run git there passes a worktree as `cwd`.
|
|
29
|
+
|
|
30
|
+
`read` is not refused: reading the main checkout is harmless, but it is the first step of the mistake, because the agent sees a file and edits what it just read. The refusal of the edit is what corrects it.
|
|
31
|
+
|
|
32
|
+
## What it cannot see
|
|
33
|
+
|
|
34
|
+
- **Bash writes.** A shell command's file writes are not parsed: `cat > crates/a.rs`, `cp`, `mv`, `awk` with redirects and a script all get through (the `commands` rules below refuse the in-place rewriters); the rest is for [claims](coordination.md) and the agent's own `git status`. Git commands whose folder the shell computes (`cd "$W"`, `git -C $(pwd)`, a `cd` to a substitution's output, an `xargs -I{}` placeholder) and `eval` are not judged; a substitution's own commands are, quotes inside a double-quoted `$(…)` included. `xargs git …`, `sh -c '…'` (`-c`, `-lc`, `-ec`…), `sudo`/`env`/`nice`/`timeout` wrappers (also by path), `env -C`/`--chdir` and `sudo -D` are followed. Not handled: `$'…'` strings with escaped quotes, a stray `)` in a `case` pattern, and the cost of resolving folders on a real disk is not cached.
|
|
35
|
+
- **Eval and `lsp`.** Python and JavaScript run by the `eval` tool, and the `lsp` tool's renames and code actions, write files without a file-tool call to judge.
|
|
36
|
+
- A worktree that does not exist yet is the checkout's own folder to this guard (a folder below the root without a `.git` of its own), and a worktree outside every root is never judged.
|
|
37
|
+
- The guard fails open: a check that throws lets the call run, with a notice.
|
|
38
|
+
|
|
39
|
+
## In-place rewrites by shell
|
|
40
|
+
|
|
41
|
+
A related slip: an agent that holds the text to change makes the edit with `sed -i`, `perl -pi` or an inline Python script (`python3 - <<'E' … open(p,'w') …`) instead of the edit tool. Nothing shows what the command matched, whether the file is the one meant, or that it changed at all. `examples/omp-coord.json` has three `refuse` rules for them (`sed -i` and `gsed -i`, `perl -i`, and Python run inline with `-c`, `-` or a heredoc whose code opens a file for writing, appending or creating, or calls `write_text` or `write_bytes`), each with `"agents": "sub"` so the main agent is not judged. A `commands` rule with `agents: "sub"` applies to subagents' commands only; the default is `"all"`.
|
|
42
|
+
|
|
43
|
+
The way out is deliberate and visible: a script saved to a file with the write tool and run from a short command (`python3 /path/script.py`, `bash script.sh`) is not judged, and neither is anything a `just` recipe or other program writes. The rules read command text like every rule here ([agents' commands](queues.md#agents-commands)), so `echo "sed -i is refused"` and a commit message that mentions it are refused too, and `awk` with redirects, `tee`, `ed`, `cat <<'PY' | python3` and `perl -pe '…' -i file` get through.
|
package/docs/queues.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
`omp-coord run <queue> -- <command>` waits for one of the queue's slots, shared by everything on this machine in the same scope, then runs the command. `just` recipes, scripts and terminals call it; agents' matching bash commands are rewritten to go through it. Queues are set under `queues` in the [configuration](configuration.md).
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
|
-
omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] -- <command> [args…]
|
|
6
|
+
omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] [--meta k=v]... -- <command> [args…]
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
## Running a job
|
|
@@ -14,6 +14,7 @@ The command runs in its own process group and session, so a time limit stops eve
|
|
|
14
14
|
|
|
15
15
|
- **Exit codes**: the command's own, `75` when the job was not run (a refused or superseded duplicate), `124` when its time limit stopped it, `2` for a usage or configuration error.
|
|
16
16
|
- **Owner**: jobs belong to the omp session in `OMP_COORD_SESSION` (set by the extension, so commands an agent runs inherit it) or `--owner`; otherwise to no session ("outside omp").
|
|
17
|
+
- **Tags**: `--meta k=v` (repeatable, at most 8; a repeated key: the last wins) records tags on the job's `job_queued` and `job_ended` events. A key matches `[a-z][a-z0-9_-]{0,31}`; a value is at most 200 characters on one line. Scripts can tag a job with a fingerprint of the code state (`fp`) and mark a deliberate failure (`intent=failfirst`); `omp-coord stats` then adds `; of the failures: N repeat an unchanged failure, M intended` to the queue's line. A repeat is a failed job whose `fp` equals that of an earlier failed job in the same queue inside the stats window, with no successful job of that `fp` in between. Detect support with `omp-coord --help 2>&1 | grep -q -- '--meta'`.
|
|
17
18
|
- **Duplicates**: jobs with the same `--key` (for example `"<checkout> ci"`) are duplicates. One that duplicates a running job is refused; one that duplicates a waiting job is refused or, with `onDuplicate: "supersede"`, replaces it in its place in line.
|
|
18
19
|
|
|
19
20
|
## Order
|
package/examples/omp-coord.json
CHANGED
|
@@ -32,6 +32,24 @@
|
|
|
32
32
|
"action": "refuse",
|
|
33
33
|
"reason": "git's rerere cache is shared by every worktree and session, and deleting it loses other sessions' recorded resolutions: to keep a throwaway build's resolutions out, run that command with `git -c rerere.enabled=false …`"
|
|
34
34
|
},
|
|
35
|
+
{
|
|
36
|
+
"pattern": "(?<![\\w.-])g?sed(?:\\s+-\\S+){0,8}?\\s+(?:-[A-Za-z]*i\\S*|--in-place\\S*)(?=\\s|$)",
|
|
37
|
+
"action": "refuse",
|
|
38
|
+
"agents": "sub",
|
|
39
|
+
"reason": "sed -i rewrites files in place without showing what changed: use the edit tool (read the lines first). For one rewrite across many files, save a script with the write tool and run it from a short command. If the text only mentions it (a commit message, a PR body), put it in a file with the write tool and pass the file"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"pattern": "(?<![\\w.-])perl(?:\\s+-\\S+){0,8}?\\s+-(?![IMmdDwWxX])\\w*i\\S*(?=\\s|$)",
|
|
43
|
+
"action": "refuse",
|
|
44
|
+
"agents": "sub",
|
|
45
|
+
"reason": "perl -i rewrites files in place without showing what changed: use the edit tool (read the lines first). For one rewrite across many files, save a script with the write tool and run it from a short command. If the text only mentions it (a commit message, a PR body), put it in a file with the write tool and pass the file"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"pattern": "(?<![\\w.-])python[0-9.]*(?=((?:\\s+-(?![c-]\\b)(?![\\s<])\\S*(?:\\s+(?![-<])[A-Za-z][\\w.]*)?){0,8}))\\1\\s+(?:-c\\b|-(?=\\s*<<)|<<|-\\s)(?=[\\s\\S]*?(?:\\.write_(?:text|bytes)\\(|\\bopen\\((?:[^,()\\n]|\\([^()\\n]*\\))*,\\s*(?:mode\\s*=\\s*)?['\"][wax]))",
|
|
49
|
+
"action": "refuse",
|
|
50
|
+
"agents": "sub",
|
|
51
|
+
"reason": "an inline Python script that writes a file edits it without showing what changed: use the edit tool (read the lines first). For one rewrite across many files, save the script with the write tool and run it from a short command (`python3 /path/script.py`). If the text only mentions it (a commit message, a PR body), put it in a file with the write tool and pass the file"
|
|
52
|
+
},
|
|
35
53
|
{ "pattern": "(^|[;&|(\\s])(npm|pnpm|yarn|bun)\\s+(run\\s+)?test\\b", "action": "queue", "queue": "test" },
|
|
36
54
|
{ "pattern": "(^|[;&|(\\s])(npm|pnpm|yarn|bun)\\s+run\\s+build\\b", "action": "queue", "queue": "build" },
|
|
37
55
|
{
|
|
@@ -49,6 +67,7 @@
|
|
|
49
67
|
"ports": { "from": 20000, "to": 20999, "perSession": 200, "perAgent": 10 },
|
|
50
68
|
"scratch": ".omp-coord-scratch"
|
|
51
69
|
},
|
|
70
|
+
"protect": { "roots": ["."], "allow": [], "agents": "sub" },
|
|
52
71
|
"messages": { "perMinute": 10 },
|
|
53
72
|
"history": { "retentionDays": 30, "maxEvents": 100000 }
|
|
54
73
|
}
|
package/package.json
CHANGED
package/src/adapters/cli/cli.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* terminals, or agents' bash calls (which the extension rewrites to go through here when a rule queues them), and
|
|
5
5
|
* reports on the scope.
|
|
6
6
|
*
|
|
7
|
-
* omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] -- <command> [args…]
|
|
7
|
+
* omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] [--meta k=v]... -- <command> [args…]
|
|
8
8
|
* omp-coord status [--agents | --json] | stats [days] | interrupted [hours] | notes [count] | note <text…>
|
|
9
9
|
* omp-coord pause [reason…] | resume | doctor [--fix]
|
|
10
10
|
*
|
|
@@ -24,7 +24,7 @@ import { doctor } from "../../app/doctor.ts";
|
|
|
24
24
|
import { EXIT_TIMED_OUT, runJob } from "../../app/job-runner.ts";
|
|
25
25
|
import { type Config, ConfigError, findConfigFile, loadConfig } from "../../config.ts";
|
|
26
26
|
import { statusView } from "../../domain/overview.ts";
|
|
27
|
-
import { MAX_PRIORITY } from "../../domain/queues.ts";
|
|
27
|
+
import { MAX_PRIORITY, parseMeta } from "../../domain/queues.ts";
|
|
28
28
|
import { agentOfOmpSession } from "../../domain/sessions.ts";
|
|
29
29
|
import { osLoad } from "../os/load.ts";
|
|
30
30
|
import { groupRunning } from "../os/process-group.ts";
|
|
@@ -45,7 +45,7 @@ const KILL_GRACE_MS = 10_000;
|
|
|
45
45
|
const DAY = 86_400_000;
|
|
46
46
|
|
|
47
47
|
const USAGE = `usage:
|
|
48
|
-
omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] -- <command…>
|
|
48
|
+
omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] [--meta k=v]... -- <command…>
|
|
49
49
|
omp-coord status [--agents | --json] | stats [days] | interrupted [hours] | notes [count] | note <text…>
|
|
50
50
|
omp-coord pause [reason…] | resume | doctor [--fix]`;
|
|
51
51
|
|
|
@@ -206,6 +206,12 @@ async function run(args: string[]): Promise<number> {
|
|
|
206
206
|
if (!Number.isSafeInteger(priority) || Math.abs(priority) > MAX_PRIORITY) {
|
|
207
207
|
throw new UsageError(`omp-coord run: --priority must be a whole number from -${MAX_PRIORITY} to ${MAX_PRIORITY}`);
|
|
208
208
|
}
|
|
209
|
+
let meta: ReturnType<typeof parseMeta>;
|
|
210
|
+
try {
|
|
211
|
+
meta = parseMeta(values.meta ?? []);
|
|
212
|
+
} catch (error) {
|
|
213
|
+
throw new UsageError(`omp-coord run: ${error instanceof Error ? error.message : String(error)}`);
|
|
214
|
+
}
|
|
209
215
|
const timeoutMs = values.timeout === undefined ? undefined : parseDuration(values.timeout);
|
|
210
216
|
const command = args.slice(split + 1);
|
|
211
217
|
|
|
@@ -258,6 +264,7 @@ async function run(args: string[]): Promise<number> {
|
|
|
258
264
|
...(values.lane === undefined ? {} : { lane: values.lane }),
|
|
259
265
|
priority,
|
|
260
266
|
...(values.key === undefined ? {} : { key: values.key }),
|
|
267
|
+
...(meta === undefined ? {} : { meta }),
|
|
261
268
|
command: command.join(" "),
|
|
262
269
|
pid: process.pid,
|
|
263
270
|
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
@@ -293,6 +300,7 @@ function parseRunFlags(args: string[]) {
|
|
|
293
300
|
key: { type: "string" },
|
|
294
301
|
owner: { type: "string" },
|
|
295
302
|
timeout: { type: "string" },
|
|
303
|
+
meta: { type: "string", multiple: true },
|
|
296
304
|
},
|
|
297
305
|
});
|
|
298
306
|
}
|
|
@@ -10,15 +10,25 @@
|
|
|
10
10
|
* one registration, and every binding is kept so a message can reach the subagent it is for.
|
|
11
11
|
*/
|
|
12
12
|
import { randomBytes } from "node:crypto";
|
|
13
|
-
import { existsSync, mkdirSync } from "node:fs";
|
|
13
|
+
import { existsSync, mkdirSync, realpathSync } from "node:fs";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { join, resolve } from "node:path";
|
|
16
16
|
import { Coordinator } from "../../app/coordinator.ts";
|
|
17
17
|
import type { Listener } from "../../app/ports.ts";
|
|
18
18
|
import { type Config, ConfigError, findConfigFile, loadConfig } from "../../config.ts";
|
|
19
19
|
import { spawnAllowance } from "../../domain/agents.ts";
|
|
20
|
-
import { decideCommand } from "../../domain/commands.ts";
|
|
20
|
+
import { decideCommand, MAX_CHECKED_COMMAND } from "../../domain/commands.ts";
|
|
21
21
|
import type { Claim } from "../../domain/model.ts";
|
|
22
|
+
import {
|
|
23
|
+
canonicalWith,
|
|
24
|
+
decideWrite,
|
|
25
|
+
type GuardFacts,
|
|
26
|
+
gitChange,
|
|
27
|
+
resolveCwd,
|
|
28
|
+
type WriteDecision,
|
|
29
|
+
type WriteGuard,
|
|
30
|
+
writeTargets,
|
|
31
|
+
} from "../../domain/protect.ts";
|
|
22
32
|
import { osLoad } from "../os/load.ts";
|
|
23
33
|
import { BoardStore, stateDir } from "../sqlite/store.ts";
|
|
24
34
|
import {
|
|
@@ -31,7 +41,7 @@ import {
|
|
|
31
41
|
formatStatus,
|
|
32
42
|
} from "../text/report.ts";
|
|
33
43
|
import { startBeacon, unixLiveness } from "../unix/beacon.ts";
|
|
34
|
-
import type { ExtensionAPI, ExtensionContext, Timer, ToolResult } from "./api.ts";
|
|
44
|
+
import type { ExtensionAPI, ExtensionContext, Timer, ToolCallEvent, ToolResult } from "./api.ts";
|
|
35
45
|
import { loadSettingHandle, type SpawnQueue, spawnQueue } from "./spawn-queue.ts";
|
|
36
46
|
|
|
37
47
|
/**
|
|
@@ -194,14 +204,15 @@ export default function ompCoord(pi: ExtensionAPI): void {
|
|
|
194
204
|
|
|
195
205
|
pi.on("tool_call", (event, ctx) => {
|
|
196
206
|
const rt = shared.runtime;
|
|
197
|
-
if (!rt
|
|
198
|
-
|
|
207
|
+
if (!rt) return undefined;
|
|
208
|
+
if (event.toolName !== "bash") return guardWrite(rt, event, ctx);
|
|
209
|
+
const command = event.input?.command;
|
|
199
210
|
if (typeof command !== "string") return undefined;
|
|
200
211
|
// the id must be on the board before the command can reach `omp-coord run`, whatever event announced a switch
|
|
201
212
|
if (ctx.agent.kind === "main") syncMainSession(rt, ctx);
|
|
202
213
|
|
|
203
214
|
try {
|
|
204
|
-
const cwd = typeof event.input
|
|
215
|
+
const cwd = typeof event.input?.cwd === "string" ? resolve(ctx.cwd, event.input.cwd) : ctx.cwd;
|
|
205
216
|
const held = rt.coordinator.checkCommand({
|
|
206
217
|
sessionId: rt.sessionId,
|
|
207
218
|
command,
|
|
@@ -220,12 +231,15 @@ export default function ompCoord(pi: ExtensionAPI): void {
|
|
|
220
231
|
} catch (error) {
|
|
221
232
|
notify(rt, `omp-coord could not check claims: ${describe(error)}`, "error");
|
|
222
233
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
234
|
+
const gitHeld = guardGit(rt, ctx, command, event.input?.cwd);
|
|
235
|
+
if (gitHeld) return gitHeld;
|
|
236
|
+
|
|
237
|
+
const decision = decideCommand(
|
|
238
|
+
rt.config.commands,
|
|
239
|
+
command,
|
|
240
|
+
{ ...RUNNER, owner: rt.sessionId, config: rt.config.source },
|
|
241
|
+
ctx.agent.kind,
|
|
242
|
+
);
|
|
229
243
|
if (decision.kind === "refuse") return { block: true, reason: `omp-coord: ${decision.reason}` };
|
|
230
244
|
// Only a queued command is rewritten. Which agent runs it needs no rewrite: omp gives every bash call its agent's
|
|
231
245
|
// session id (`PI_SESSION_ID`), and the board maps that id to the agent (`session_start`, `agent_start`).
|
|
@@ -709,6 +723,85 @@ function notify(rt: Runtime, message: string, type: "info" | "warning" | "error"
|
|
|
709
723
|
if (rt.ctx.hasUI) rt.ctx.ui.notify(message, type);
|
|
710
724
|
}
|
|
711
725
|
|
|
726
|
+
/**
|
|
727
|
+
* The `protect` guard for the file tools (`edit`, `write`, `ast_edit`, `apply_patch`): a write into a protected
|
|
728
|
+
* checkout is blocked with a reason naming the resolved path.
|
|
729
|
+
*/
|
|
730
|
+
function guardWrite(
|
|
731
|
+
rt: Runtime,
|
|
732
|
+
event: ToolCallEvent,
|
|
733
|
+
ctx: ExtensionContext,
|
|
734
|
+
): { block: true; reason: string } | undefined {
|
|
735
|
+
if (!rt.config.protect) return undefined;
|
|
736
|
+
let targets: string[] | undefined;
|
|
737
|
+
try {
|
|
738
|
+
targets = writeTargets(event.toolName, event.input ?? {});
|
|
739
|
+
} catch (error) {
|
|
740
|
+
notify(rt, `omp-coord could not read a ${event.toolName} call: ${describe(error)}`, "error");
|
|
741
|
+
return undefined;
|
|
742
|
+
}
|
|
743
|
+
if (!targets || targets.length === 0) return undefined;
|
|
744
|
+
return refuseProtected(rt, ctx, event.toolName, targets, ctx.cwd, (guard, facts) =>
|
|
745
|
+
decideWrite(guard, ctx.agent.kind, event.toolName, targets, facts),
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* The `protect` guard for a bash command's state-changing `git`: it must not run in a protected checkout. A command
|
|
751
|
+
* over the length cap is left to `decideCommand`, which refuses it while a refuse rule exists.
|
|
752
|
+
*/
|
|
753
|
+
function guardGit(
|
|
754
|
+
rt: Runtime,
|
|
755
|
+
ctx: ExtensionContext,
|
|
756
|
+
command: string,
|
|
757
|
+
inputCwd: unknown,
|
|
758
|
+
): { block: true; reason: string } | undefined {
|
|
759
|
+
if (!rt.config.protect || command.length > MAX_CHECKED_COMMAND || !/\bgit\b/.test(command)) return undefined;
|
|
760
|
+
const cwd = typeof inputCwd === "string" ? resolveCwd(ctx.cwd, inputCwd, homedir()) : ctx.cwd;
|
|
761
|
+
return refuseProtected(rt, ctx, "bash", [], cwd, (guard, facts) =>
|
|
762
|
+
gitChange(guard, ctx.agent.kind, command, cwd, facts),
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Judges a call with the disk's facts and blocks it on a refusal. Like every guard here it fails open: a check that
|
|
768
|
+
* throws lets the call run; but once the decision is "refuse", failing to log it never lets the call through.
|
|
769
|
+
*/
|
|
770
|
+
function refuseProtected(
|
|
771
|
+
rt: Runtime,
|
|
772
|
+
ctx: ExtensionContext,
|
|
773
|
+
tool: string,
|
|
774
|
+
shown: string[],
|
|
775
|
+
cwd: string,
|
|
776
|
+
judge: (guard: WriteGuard, facts: GuardFacts) => WriteDecision,
|
|
777
|
+
): { block: true; reason: string } | undefined {
|
|
778
|
+
const guard = rt.config.protect;
|
|
779
|
+
if (!guard) return undefined;
|
|
780
|
+
let decision: WriteDecision;
|
|
781
|
+
try {
|
|
782
|
+
decision = judge(guard, {
|
|
783
|
+
cwd,
|
|
784
|
+
home: homedir(),
|
|
785
|
+
canonical: canonicalWith(p => {
|
|
786
|
+
try {
|
|
787
|
+
return realpathSync(p);
|
|
788
|
+
} catch {
|
|
789
|
+
return undefined;
|
|
790
|
+
}
|
|
791
|
+
}),
|
|
792
|
+
hasGitEntry: dir => existsSync(join(dir, ".git")),
|
|
793
|
+
});
|
|
794
|
+
} catch (error) {
|
|
795
|
+
notify(rt, `omp-coord could not check a ${tool} call: ${describe(error)}`, "error");
|
|
796
|
+
return undefined;
|
|
797
|
+
}
|
|
798
|
+
if (decision.kind === "allow") return undefined;
|
|
799
|
+
guarded(rt, "record a refused call", () =>
|
|
800
|
+
rt.coordinator.recordWriteRefused(rt.sessionId, ctx.agent.id, tool, decision.shown ?? shown),
|
|
801
|
+
);
|
|
802
|
+
return { block: true, reason: `omp-coord: ${decision.reason}` };
|
|
803
|
+
}
|
|
804
|
+
|
|
712
805
|
function claimKind(word: string): Claim["kind"] | undefined {
|
|
713
806
|
const k = word.trim().toLowerCase();
|
|
714
807
|
return CLAIM_KINDS.find(x => x === k);
|
|
@@ -81,6 +81,7 @@ CREATE TABLE jobs (
|
|
|
81
81
|
key TEXT,
|
|
82
82
|
command TEXT NOT NULL,
|
|
83
83
|
agent TEXT,
|
|
84
|
+
meta TEXT,
|
|
84
85
|
socket TEXT NOT NULL,
|
|
85
86
|
pid INTEGER NOT NULL,
|
|
86
87
|
state TEXT NOT NULL CHECK (state IN ('waiting', 'running', 'superseded')),
|
|
@@ -258,6 +259,7 @@ export class BoardStore implements BoardRepository {
|
|
|
258
259
|
.all()
|
|
259
260
|
.some(c => c.name === column);
|
|
260
261
|
if (!has("jobs", "agent")) this.#db.exec("ALTER TABLE jobs ADD COLUMN agent TEXT");
|
|
262
|
+
if (!has("jobs", "meta")) this.#db.exec("ALTER TABLE jobs ADD COLUMN meta TEXT");
|
|
261
263
|
if (!has("slots", "unqueued"))
|
|
262
264
|
this.#db.exec("ALTER TABLE slots ADD COLUMN unqueued TEXT CHECK (unqueued IN ('revived', 'eval'))");
|
|
263
265
|
if (!has("sessions", "omp_session")) this.#db.exec("ALTER TABLE sessions ADD COLUMN omp_session TEXT");
|
|
@@ -369,6 +371,7 @@ export class BoardStore implements BoardRepository {
|
|
|
369
371
|
key: string | null;
|
|
370
372
|
command: string;
|
|
371
373
|
agent: string | null;
|
|
374
|
+
meta: string | null;
|
|
372
375
|
socket: string;
|
|
373
376
|
pid: number;
|
|
374
377
|
state: "waiting" | "running" | "superseded";
|
|
@@ -387,6 +390,7 @@ export class BoardStore implements BoardRepository {
|
|
|
387
390
|
key: r.key ?? undefined,
|
|
388
391
|
command: r.command,
|
|
389
392
|
agent: r.agent ?? undefined,
|
|
393
|
+
meta: r.meta === null ? undefined : (JSON.parse(r.meta) as Record<string, string>),
|
|
390
394
|
socket: r.socket,
|
|
391
395
|
pid: r.pid,
|
|
392
396
|
state: r.state,
|
|
@@ -499,7 +503,7 @@ export class BoardStore implements BoardRepository {
|
|
|
499
503
|
const sent = db.query("INSERT INTO sent (session_id, at) VALUES (?, ?)");
|
|
500
504
|
for (const x of board.sent) sent.run(x.sessionId, x.at);
|
|
501
505
|
const job = db.query(
|
|
502
|
-
"INSERT INTO jobs (id, queue, owner, lane, priority, key, command, agent, socket, pid, state, since, started_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
506
|
+
"INSERT INTO jobs (id, queue, owner, lane, priority, key, command, agent, meta, socket, pid, state, since, started_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
503
507
|
);
|
|
504
508
|
for (const j of board.jobs) {
|
|
505
509
|
job.run(
|
|
@@ -511,6 +515,7 @@ export class BoardStore implements BoardRepository {
|
|
|
511
515
|
j.key ?? null,
|
|
512
516
|
j.command,
|
|
513
517
|
j.agent ?? null,
|
|
518
|
+
j.meta === undefined ? null : JSON.stringify(j.meta),
|
|
514
519
|
j.socket,
|
|
515
520
|
j.pid,
|
|
516
521
|
j.state,
|
|
@@ -212,7 +212,7 @@ export function formatStats(days: DayStats[]): string {
|
|
|
212
212
|
);
|
|
213
213
|
for (const [q, s] of Object.entries(d.queues)) {
|
|
214
214
|
lines.push(
|
|
215
|
-
` ${q}: ${s.started} started, wait avg ${minutes(s.avgWaitMs)} / max ${minutes(s.maxWaitMs)}; ${s.ended} ended, ${s.failed} failed${s.timedOut ? ` (${s.timedOut} timed out)` : ""}`,
|
|
215
|
+
` ${q}: ${s.started} started, wait avg ${minutes(s.avgWaitMs)} / max ${minutes(s.maxWaitMs)}; ${s.ended} ended, ${s.failed} failed${s.timedOut ? ` (${s.timedOut} timed out)` : ""}${s.ofFailures ? `; of the failures: ${s.ofFailures.repeats} repeat an unchanged failure, ${s.ofFailures.intended} intended` : ""}`,
|
|
216
216
|
);
|
|
217
217
|
}
|
|
218
218
|
}
|
package/src/app/coordinator.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { type SendResult, sendMessage, takeInbox } from "../domain/messages.ts";
|
|
|
30
30
|
import type { Board, Claim, Note } from "../domain/model.ts";
|
|
31
31
|
import { addNote, type NoteResult } from "../domain/notes.ts";
|
|
32
32
|
import { type AgentPortsResult, agentPorts } from "../domain/ports.ts";
|
|
33
|
+
import { writeRefused } from "../domain/protect.ts";
|
|
33
34
|
import { dropJobs, type EnqueueResult, schedule, setJobPriority } from "../domain/queues.ts";
|
|
34
35
|
import { bindOmpSession, registerSession, removeSession } from "../domain/sessions.ts";
|
|
35
36
|
import type { BoardRepository, Clock, Liveness, LoadProbe } from "./ports.ts";
|
|
@@ -137,6 +138,11 @@ export class Coordinator {
|
|
|
137
138
|
return conflict;
|
|
138
139
|
}
|
|
139
140
|
|
|
141
|
+
/** Records that a file tool's write was refused by the `protect` guard (for the event log). */
|
|
142
|
+
recordWriteRefused(sessionId: string, agentId: string, tool: string, paths: string[]): void {
|
|
143
|
+
this.repo.transact(b => writeRefused(b, sessionId, { agentId, tool, paths, now: this.clock.now() }));
|
|
144
|
+
}
|
|
145
|
+
|
|
140
146
|
note(sessionId: string | null, author: string, text: string): NoteResult {
|
|
141
147
|
return this.repo.transact(b => addNote(b, { sessionId, author, text, now: this.clock.now() }));
|
|
142
148
|
}
|
|
@@ -155,7 +161,7 @@ export class Coordinator {
|
|
|
155
161
|
}
|
|
156
162
|
|
|
157
163
|
stats(sinceMs: number): DayStats[] {
|
|
158
|
-
return stats(this.repo.events(this.clock.now() - sinceMs)
|
|
164
|
+
return stats(this.repo.events(), this.clock.now() - sinceMs).days;
|
|
159
165
|
}
|
|
160
166
|
|
|
161
167
|
requestSpawn(req: Omit<SpawnRequest, "now">): SpawnDecision {
|
package/src/app/job-runner.ts
CHANGED
package/src/config.ts
CHANGED
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { createHash } from "node:crypto";
|
|
7
7
|
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
-
import { dirname, join, resolve } from "node:path";
|
|
8
|
+
import { dirname, join, posix, resolve } from "node:path";
|
|
9
9
|
import type { CommandRule } from "./domain/commands.ts";
|
|
10
10
|
import type { LoadLimit } from "./domain/limits.ts";
|
|
11
11
|
import type { AgentPolicy } from "./domain/model.ts";
|
|
12
12
|
import type { PortPolicy } from "./domain/ports.ts";
|
|
13
|
+
import type { WriteGuard } from "./domain/protect.ts";
|
|
13
14
|
import type { QueuePolicy, Queues } from "./domain/queues.ts";
|
|
14
15
|
|
|
15
16
|
export const CONFIG_FILE = ".omp-coord.json";
|
|
@@ -24,6 +25,8 @@ export interface Config {
|
|
|
24
25
|
agents: AgentPolicy;
|
|
25
26
|
/** Rules for agents' bash commands, first match wins. */
|
|
26
27
|
commands: CommandRule[];
|
|
28
|
+
/** Checkouts the agents' file tools may not write to; undefined when no `protect` is configured. */
|
|
29
|
+
protect: WriteGuard | undefined;
|
|
27
30
|
/** Build and test queues, machine-wide within the scope. */
|
|
28
31
|
queues: Queues;
|
|
29
32
|
/** Port ranges and scratch folders handed to sessions and their agents. */
|
|
@@ -135,8 +138,11 @@ export function parseConfig(raw: unknown, source: string, hash = "none"): Config
|
|
|
135
138
|
} catch (e) {
|
|
136
139
|
return fail(`${field}.pattern`, `is not a valid regular expression (${e instanceof Error ? e.message : e})`);
|
|
137
140
|
}
|
|
141
|
+
const agentsText = rule.agents ?? "all";
|
|
142
|
+
if (agentsText !== "all" && agentsText !== "sub") fail(`${field}.agents`, `must be "all" or "sub"`);
|
|
143
|
+
const agents = agentsText as "all" | "sub";
|
|
138
144
|
if (rule.action === "refuse")
|
|
139
|
-
return { pattern, action: "refuse", reason: str(rule.reason, `${field}.reason`, fail) };
|
|
145
|
+
return { pattern, action: "refuse", reason: str(rule.reason, `${field}.reason`, fail), agents };
|
|
140
146
|
if (rule.action !== "queue") return fail(`${field}.action`, `must be "refuse" or "queue"`);
|
|
141
147
|
const queue = str(rule.queue, `${field}.queue`, fail);
|
|
142
148
|
const policy = queues[queue];
|
|
@@ -144,9 +150,40 @@ export function parseConfig(raw: unknown, source: string, hash = "none"): Config
|
|
|
144
150
|
const lane = rule.lane === undefined ? undefined : str(rule.lane, `${field}.lane`, fail);
|
|
145
151
|
if (lane !== undefined && !policy.lanes.includes(lane))
|
|
146
152
|
fail(`${field}.lane`, `names no lane of queue ${queue} (${policy.lanes.join(", ")})`);
|
|
147
|
-
return { pattern, action: "queue", queue, lane };
|
|
153
|
+
return { pattern, action: "queue", queue, lane, agents };
|
|
148
154
|
});
|
|
149
155
|
|
|
156
|
+
let protect: WriteGuard | undefined;
|
|
157
|
+
if (root.protect !== undefined) {
|
|
158
|
+
const p = obj(root.protect, "protect", fail);
|
|
159
|
+
const roots = strList(p.roots, "protect.roots", fail);
|
|
160
|
+
if (roots.length === 0) fail("protect.roots", "must list at least one folder");
|
|
161
|
+
const agentsText = p.agents ?? "sub";
|
|
162
|
+
if (agentsText !== "sub" && agentsText !== "all") fail("protect.agents", `must be "sub" or "all"`);
|
|
163
|
+
protect = {
|
|
164
|
+
roots: roots.map(r => resolve(dirname(source), r)),
|
|
165
|
+
allow: strList(p.allow ?? [], "protect.allow", fail).map((a, i) => {
|
|
166
|
+
const entry = a.replace(/^(?:\.\/)+/, "");
|
|
167
|
+
const bare = entry.replace(/\/+$/, "");
|
|
168
|
+
if (
|
|
169
|
+
bare === "" ||
|
|
170
|
+
bare.startsWith("/") ||
|
|
171
|
+
bare.includes("\\") ||
|
|
172
|
+
posix.normalize(bare) !== bare ||
|
|
173
|
+
bare === "." ||
|
|
174
|
+
bare.startsWith("../") ||
|
|
175
|
+
bare === ".."
|
|
176
|
+
)
|
|
177
|
+
fail(
|
|
178
|
+
`protect.allow[${i}]`,
|
|
179
|
+
"must be a file or folder below a root, written relative to it with / separators (no leading /, no .. or . parts, no doubled /)",
|
|
180
|
+
);
|
|
181
|
+
return entry;
|
|
182
|
+
}),
|
|
183
|
+
agents: agentsText as WriteGuard["agents"],
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
150
187
|
const resources = obj(root.resources ?? {}, "resources", fail);
|
|
151
188
|
let ports: PortPolicy | undefined;
|
|
152
189
|
if (resources.ports !== undefined) {
|
|
@@ -185,6 +222,7 @@ export function parseConfig(raw: unknown, source: string, hash = "none"): Config
|
|
|
185
222
|
messagesPerMinute: count(messages.perMinute ?? 10, "messages.perMinute", fail),
|
|
186
223
|
},
|
|
187
224
|
commands,
|
|
225
|
+
protect,
|
|
188
226
|
queues,
|
|
189
227
|
resources: { ports, scratch },
|
|
190
228
|
history: {
|