@ours.network/fleet 0.11.0 → 0.11.1
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/dist/cli.js +2 -0
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +11 -1
- package/dist/harness/codex.js +16 -1
- package/dist/isolation/policy.js +5 -0
- package/dist/isolation/runtime.d.ts +16 -0
- package/dist/isolation/runtime.js +136 -0
- package/dist/isolation/types.d.ts +2 -0
- package/dist/resolved-plan.js +1 -0
- package/dist/runner.js +11 -5
- package/dist/watchdog/config.d.ts +4 -0
- package/dist/watchdog/config.js +9 -3
- package/dist/watchdog/run.d.ts +2 -0
- package/dist/watchdog/run.js +50 -23
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -197,6 +197,8 @@ cOpt(program.command('config').description('validate + print the merged plan (no
|
|
|
197
197
|
console.log(` watch: ${w.watch.join(', ')}`);
|
|
198
198
|
if (w.promptFile)
|
|
199
199
|
console.log(` focus: ${w.promptFile}`);
|
|
200
|
+
if (w.isolation)
|
|
201
|
+
console.log(` isolation: ${JSON.stringify(w.isolation)}`);
|
|
200
202
|
}
|
|
201
203
|
}
|
|
202
204
|
}
|
package/dist/docs.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Keep this concise enough to place directly in an agent context. Unlike
|
|
5
5
|
* Commander's per-command help, this describes how the pieces compose.
|
|
6
6
|
*/
|
|
7
|
-
export declare const AI_DOCS = "# ours-fleet reference\n\nours-fleet runs persistent or temporary, identity-bound AI roles. A role selects\na harness independently from its session backend:\n\n- harness: `claude-code` or `codex`\n- session: `tmux` (default) or `acp`\n- lifetime: permanent (supervised, restartable) or `spawn --temp`\n\n## Discover and validate\n\n```sh\nours-fleet docs # this complete reference (`man` is an alias)\nours-fleet help <command> # exact flags for one command\nours-fleet config [-c FILE] # validate and print the merged plan; no changes\nours-fleet doctor [-c FILE] [--harness codex|claude-code]\n```\n\nDefault configuration is `~/fleet.yaml` plus sorted `~/fleet.d/*.yaml` role\ndrop-ins. An explicit `-c FILE` replaces `~/fleet.yaml`; fleet.d still adds\nroles. Validate with `config` and `doctor` before starting or restarting.\n\n## Lifecycle and console commands\n\n```sh\nours-fleet init\nours-fleet up|down [Name...]\nours-fleet restart [Name...] # preserve/resume harness context\nours-fleet force-restart [Name...] # fresh context; briefing is reloaded\nours-fleet ls\nours-fleet status|peek|attach|logs Name\nours-fleet logs -f Name\nours-fleet send Name \"prompt\"\nours-fleet send Name --key Enter # tmux only\nours-fleet rm Name\nours-fleet watchdog-report <name> [run-id] [--list] [--json]\nours-fleet watchdog-run <name>\n```\n\n`peek`, `attach`, and text `send` work with tmux and ACP. ACP attachment\nalso accepts `/permit <permission-id> <option-id>`, `/interrupt`, and\n`/detach`. Raw `--key` input is tmux-only.\n\n## Local web console\n\nThe npm package includes the web console; installed users do not clone the repo\nor run `npm run build`:\n\n```sh\nnpm i -g @ours.network/fleet\nours-fleet init\nours-fleet doctor\nours-fleet web # install/update service, start, pair browser\n```\n\nThe normal command uses stable `http://127.0.0.1:49271/`, installs an\nowner-level systemd user service (Linux) or LaunchAgent (macOS), and opens a\nfive-minute one-use pairing link in the local browser. After pairing, bookmark\nthe plain URL or install the PWA. To pair a new, signed-out, or revoked browser,\nrun `ours-fleet web open`.\n\n```sh\nours-fleet web status\nours-fleet web start|stop|restart\nours-fleet web open\nours-fleet web revoke-all # revoke every browser and active session\nours-fleet web uninstall\nours-fleet web serve --port 0 --no-open # isolated foreground/testing mode\n```\n\nThe console is intentionally IPv4-loopback-only. It has no LAN/Internet host,\nproxy, TLS, or remote-access mode. Do not expose port 49271 through a reverse\nproxy. Browser credentials are HttpOnly/SameSite, and `revoke-all` invalidates\nall trusted devices. Role creation offers harness-scoped known-model choices\nwhile still accepting a typed model ID; blank explicitly uses the selected\nharness's own default.\n\n## Spawn\n\n```sh\nours-fleet spawn [--temp] Name \\\n --harness codex|claude-code --session tmux|acp \\\n --mission \"one line\" --cwd /absolute/path --identity Identity \\\n --coordinator Coordinator --model MODEL \\\n --approval ask|allow|deny \\\n --filesystem read-only|workspace|unrestricted \\\n --unattended deny|wait \\\n --bio-file /path/bio.md --persona-file /path/persona.md\n```\n\nPermanent spawn writes `~/fleet.d/Name.yaml` and starts a supervised role.\n`--temp` writes ephemeral state, starts a detached supervisor, and removes the\nrole after exit/reboot. Both lifetimes support `--session acp`.\n\nCodex-specific spawn flags: `--sandbox`, `--permission-mode`, `--launcher`,\n`--profile`, `--search`, repeatable `--codex-config key=value`, repeatable\n`--add-dir`, and legacy `--monitor` (consent for the native Codex monitor,\nnot the `monitor.mode` wake-owner selector). Run `ours-fleet help spawn` for\nexact values.\n\n## fleet.yaml\n\n```yaml\nvars:\n work_root: /home/me/work\nstart_stagger_ms: 0\ndefaults:\n harness: codex\n session: acp\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n monitor:\n mode: fleet # fleet (default) | native\nroles:\n Coordinator:\n harness: codex\n session: acp\n identity: Coordinator\n cwd: ${work_root}/project\n mission: Coordinate work and delegate implementation.\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n session_options: # advanced overrides; normally omit\n # acp:\n # command: [/custom/codex-acp, --flag]\n tmux:\n boot_grace_ms: 10000\n monitor:\n mode: fleet # fleet supervisor | native harness monitor\n interrupt: false # true cancels active work before every configured wake\n wake_sources: [message_received, file_received, local_contact_request, pending_message]\n batch_ms: 2000\n inject: notification\n turn_fail_threshold: 3\n harness_options:\n launcher: auto\n sandbox: workspace-write\n approval: on-request\n search: false\n profile: fleet\n add_dirs: [/data/shared]\n config:\n model_reasoning_effort: high\n bio: Public role card and when peers should engage it.\n persona: Local operating contract, boundaries, and escalation policy.\n briefing_file: /absolute/custom-briefing.md\n coordinator: AnotherCoordinator\n env:\n KEY: value\n oversee:\n - { role: Worker, interval: 5m }\nwatchdogs:\n nightwatch: # [A-Za-z0-9_-], must not collide with a role name\n coordinator: FleetCoordinator # required \u2014 where alerts go\n # everything below is optional\n enabled: true # default true; false = configured but never scheduled\n interval: 10m # default 10m; 30s | 10m | 2h, minimum 1m\n watch: [Alice, CodexReviewer] # default: every role in the merged config\n harness: claude-code # default: defaults.harness\n model: claude-fable-5 # default: same resolution rule roles use (resolveRoleModel)\n session: acp # default: defaults.session\n identity: Watchdog-nightwatch # default: Watchdog-<name>\n timeout: 5m # default 5m; a run past this is killed and recorded as error\n keep_reports: 50 # default 50 reports retained per watchdog\n alert_cooldown: 60m # default 60m before the same finding alerts again\n prompt_file: /abs/extra.md # optional extra focus, APPENDED to the fixed contract\n```\n\nA watchdog observes and reports; it never restarts, stops, spawns, or removes a\nrole, answers a pending permission, edits a workspace, or approves anything on\nthe owner's behalf. `watchdogs:` may appear only in the base config\n(`~/fleet.yaml` or `-c FILE`), not in `~/fleet.d/*.yaml` drop-ins.\n\nRole values override defaults. `${name}` substitutes entries from `vars`.\nOther role fields include `max_tokens`, `autocompact_pct`, and `isolation`.\nUse README.md for the complete isolation policy and resource-cap schema.\n\n## Permissions\n\nPrefer the harness-neutral `permissions` block:\n\n- `approval: ask|allow|deny`: whether actions may request or receive approval\n- `filesystem: read-only|workspace|unrestricted`: filesystem intent\n- `unattended: deny|wait`: what ACP does when no console can answer a request\n\nThe backend translates this common intent. Harness-native settings in\n`harness_options` take precedence where supplied. Do not choose\n`allow`/`unrestricted`, Codex `never`/`danger-full-access`, or Claude\n`bypassPermissions` without explicit authorization.\n\n### Creation-time isolation\n\n`ours-fleet spawn --isolation-file <path>` supplies a role's sandbox policy at\ncreation, so the FIRST launch is already confined \u2014 a role that only gains\n`isolation:` on a later `up` ran unsandboxed until then.\n\nThe file holds exactly the `isolation:` mapping documented above and nothing\nelse \u2014 the same schema, validated by the same code, so a policy written here\ncannot mean something different from the identical block in fleet.yaml:\n\n```yaml\nnetwork: deny\nfs:\n read: [/opt/reference]\nresources:\n mem: 2G\n```\n\nInvalid files are rejected before anything is created: no config, no state\ndirectory, no identity reservation. Works for both permanent and `--temp` roles.\n\n### Never-prompt failure\n\nThe failure this section exists to prevent leaves no error message anywhere.\n\nAn unattended role has no console. When the harness needs a permission decision\nthere is nobody to ask, so the request is refused INSIDE the harness \u2014 no\nprompt, no error, no log line. The agent simply does less than its briefing told\nit to, reports success, and nothing distinguishes that from having done the\nwork. Two settings produce it:\n\n1. a permission mode that suppresses the prompt without granting the action\n (Claude `dontAsk`, which is why neutral `allow` maps to\n `bypassPermissions` instead); and\n2. `unattended: deny`, which refuses every request that reaches it.\n\n**Automatic decisions are now recorded.** Every permission request decided\nwithout a human emits a completed event into\n`~/.ours-fleet/agents/<Name>/.session-events.jsonl` carrying the decision,\nwhether policy or a person made it, the policy that produced it\n(`permissions.unattended=deny` vs `permissions.approval=deny`/`=allow`),\nthe reason, and the option selected. `ours-fleet peek` and `attach` render\nthem. Automatic denial asks for a one-shot rejection, never a standing one, so a\nsingle unattended refusal cannot disable a tool for the rest of the session.\n\nA role that can auto-deny logs one line at startup saying so.\n\nTo detect an under-permissioned role BEFORE it runs, use the capability floor\nbelow: `ours-fleet doctor` fails such a role rather than letting it discover\nthe problem silently at work.\n\n### The unattended capability floor\n\nAn unattended role has no console, so a permission request cannot be answered \u2014\nit is refused, silently, inside the harness. The agent then does less than it\nwas told to and reports no error. To make that visible before launch,\n`ours-fleet config` and `ours-fleet doctor` resolve each role's neutral\npermissions through its harness and check the result against a fixed floor:\n\n- `read-state` \u2014 read its briefing, ROUTINES.md, and WORKLOG.md\n- `write-state` \u2014 append its WORKLOG and its own state files\n- `messaging` \u2014 bind its identity, send and receive ours mail\n- `monitor` \u2014 arm and observe its mail monitor\n- `workspace-edit` \u2014 edit and test files in its working directory\n- `status-commands` \u2014 run the inspection commands its briefing prescribes\n\n`doctor` reports this per role as `unattended floor: <Role>`. A role with\n`unattended: deny` that cannot meet the floor FAILS doctor, because it will\ndeny those requests with nobody to see it; with `unattended: wait` it warns,\nbecause a human can still attach and answer.\n\nSecurity meaning: `approval: allow` maps to Claude's `bypassPermissions`,\nwhich genuinely permits the actions the role was authorized to take \u2014\n`dontAsk` only suppresses the prompt while still refusing the action. Nothing\nother than an explicit `allow` is elevated: `ask` stays on Claude's default\nmode and `deny` maps to `plan`. `allow` is therefore a real grant and\nrequires explicit authorization; per-role `isolation:` remains the outer\nboundary that a permission mode cannot cross.\n\nSee also: `spawn --approval/--filesystem/--unattended` set this intent at\ncreation, and `ours-fleet config` prints each role's neutral settings, their\nnative translation, and any warning \u2014 the same text `doctor` reports.\n\nClaude `harness_options`: `permission_mode` (default, acceptEdits, plan,\ndontAsk, bypassPermissions), `plugins`, `mem_palace`, and\n`mem_palace_midsession_autosave`.\n\nCodex `harness_options`: `launcher` (auto, ours-codex, codex), `sandbox`\n(read-only, workspace-write, danger-full-access), `approval` or\n`permission_mode` (untrusted, on-request, never), `profile`, `search`,\n`config`, `add_dirs`, and `monitor`.\n\n## ACP adapters\n\nThe maintained `@agentclientprotocol/codex-acp` and\n`@agentclientprotocol/claude-agent-acp` runtimes are bundled automatically as\noptional ours-fleet dependencies. The supervisor resolves their executable\nentrypoints internally, so default ACP roles do not depend on global PATH.\nThe maintained Claude adapter requires Node 22; tmux and Codex ACP continue to\nwork on the ours-fleet core minimum of Node 20.\n\nOverride an adapter only when necessary with `session_options.acp.command`\n(string or argv list). If optional dependencies were deliberately omitted,\nours-fleet falls back to a compatible globally installed `codex-acp` or\n`claude-agent-acp`. `ours-fleet doctor -c FILE` verifies the resolved adapter.\n\n## Reliable mail wake\n\n`monitor.mode` selects exactly one wake owner:\n\n- `fleet` (default): the ours-fleet supervisor consumes body-free daemon\n events and advances its durable cursor only after delivery is accepted. ACP\n uses live steering when supported and falls back to structured\n `session/prompt`; tmux uses verified console injection.\n- `native`: ours-fleet starts no supervisor monitor; the generated briefing\n instructs Claude Code or Codex to arm its harness-native wake mechanism.\n\nSet `monitor.interrupt: true` in fleet mode to cancel active work before every\nconfigured wake. The policy is content-blind because the supervisor cannot\ninspect encrypted message bodies. Message bodies are released only when the\nrole calls the ours `get_messages` tool.\n\nLegacy `monitor.enabled: true|false` remains accepted as an alias for\n`mode: fleet|native`; use `mode` in new configuration. Codex's separate\n`harness_options.monitor: true` is native-monitor consent, not monitor-owner\nselection.\nInspect `ours-fleet status Name`, `peek Name`, role logs, and\n`~/.ours-fleet/agents/Name/.monitor-status` when diagnosing delivery.\n\n## Stable config and YAML migration\n\n`ours-fleet config --json` emits schemaVersion 1 resolved plans. Environment\nvalues and mission/persona/bio bodies are withheld; environment keys are sorted\nand values are marked redacted. Additive fields may appear in schema 1, while a\nremoval or semantic reuse requires a new schema version.\n\nYAML parsing always rejects duplicate keys. The current default\n`--yaml-mode compat` warns with file/line/column for anchors, aliases, explicit\ntags, non-scalar keys, and multiple documents. Use `--yaml-mode strict` in CI\nnow; strict becomes the next-major default and compat is the temporary migration\nescape hatch.\n\n## Bounded worklogs, auth proxy, and model recovery\n\nAn optional `worklog: { max_kb, keep_tail_kb, max_archives }` policy rotates a\nstable snapshot at fleet-owned lifecycle points. Concurrent changes defer\nrotation. Archives remain beside WORKLOG.md with the same sensitive-state\nboundary; retention deletes only recognized fleet archive names.\n\n`auth_proxy: { kind: anthropic, base_url, required, health_url }` is Claude-only\nand loopback-only. Fleet injects only ANTHROPIC_BASE_URL and doctor rejects\ncredential env keys. The privileged reference companion is\n`contrib/anthropic-auth-proxy.mjs`; deploy it separately as a dedicated account\nwith a 0600 token file and per-role listener access. Fleet never installs it or\nreads its credential.\n\n`model_chain` is an ordered authorization list and its first entry must equal\n`model`. Only sustained high-confidence entitlement/quota 429 evidence advances\none entry. Transient 429, overload, auth, policy, and unknown errors never\ndown-shift. Runtime state is atomic in .model-recovery.json; exhaustion is\nfail-closed and held down. Change the declared chain/model and restart to\nreconcile explicitly; no chain preserves detection-only behavior.\n";
|
|
7
|
+
export declare const AI_DOCS = "# ours-fleet reference\n\nours-fleet runs persistent or temporary, identity-bound AI roles. A role selects\na harness independently from its session backend:\n\n- harness: `claude-code` or `codex`\n- session: `tmux` (default) or `acp`\n- lifetime: permanent (supervised, restartable) or `spawn --temp`\n\n## Discover and validate\n\n```sh\nours-fleet docs # this complete reference (`man` is an alias)\nours-fleet help <command> # exact flags for one command\nours-fleet config [-c FILE] # validate and print the merged plan; no changes\nours-fleet doctor [-c FILE] [--harness codex|claude-code]\n```\n\nDefault configuration is `~/fleet.yaml` plus sorted `~/fleet.d/*.yaml` role\ndrop-ins. An explicit `-c FILE` replaces `~/fleet.yaml`; fleet.d still adds\nroles. Validate with `config` and `doctor` before starting or restarting.\n\n## Lifecycle and console commands\n\n```sh\nours-fleet init\nours-fleet up|down [Name...]\nours-fleet restart [Name...] # preserve/resume harness context\nours-fleet force-restart [Name...] # fresh context; briefing is reloaded\nours-fleet ls\nours-fleet status|peek|attach|logs Name\nours-fleet logs -f Name\nours-fleet send Name \"prompt\"\nours-fleet send Name --key Enter # tmux only\nours-fleet rm Name\nours-fleet watchdog-report <name> [run-id] [--list] [--json]\nours-fleet watchdog-run <name>\n```\n\n`peek`, `attach`, and text `send` work with tmux and ACP. ACP attachment\nalso accepts `/permit <permission-id> <option-id>`, `/interrupt`, and\n`/detach`. Raw `--key` input is tmux-only.\n\n## Local web console\n\nThe npm package includes the web console; installed users do not clone the repo\nor run `npm run build`:\n\n```sh\nnpm i -g @ours.network/fleet\nours-fleet init\nours-fleet doctor\nours-fleet web # install/update service, start, pair browser\n```\n\nThe normal command uses stable `http://127.0.0.1:49271/`, installs an\nowner-level systemd user service (Linux) or LaunchAgent (macOS), and opens a\nfive-minute one-use pairing link in the local browser. After pairing, bookmark\nthe plain URL or install the PWA. To pair a new, signed-out, or revoked browser,\nrun `ours-fleet web open`.\n\n```sh\nours-fleet web status\nours-fleet web start|stop|restart\nours-fleet web open\nours-fleet web revoke-all # revoke every browser and active session\nours-fleet web uninstall\nours-fleet web serve --port 0 --no-open # isolated foreground/testing mode\n```\n\nThe console is intentionally IPv4-loopback-only. It has no LAN/Internet host,\nproxy, TLS, or remote-access mode. Do not expose port 49271 through a reverse\nproxy. Browser credentials are HttpOnly/SameSite, and `revoke-all` invalidates\nall trusted devices. Role creation offers harness-scoped known-model choices\nwhile still accepting a typed model ID; blank explicitly uses the selected\nharness's own default.\n\n## Spawn\n\n```sh\nours-fleet spawn [--temp] Name \\\n --harness codex|claude-code --session tmux|acp \\\n --mission \"one line\" --cwd /absolute/path --identity Identity \\\n --coordinator Coordinator --model MODEL \\\n --approval ask|allow|deny \\\n --filesystem read-only|workspace|unrestricted \\\n --unattended deny|wait \\\n --bio-file /path/bio.md --persona-file /path/persona.md\n```\n\nPermanent spawn writes `~/fleet.d/Name.yaml` and starts a supervised role.\n`--temp` writes ephemeral state, starts a detached supervisor, and removes the\nrole after exit/reboot. Both lifetimes support `--session acp`.\n\nCodex-specific spawn flags: `--sandbox`, `--permission-mode`, `--launcher`,\n`--profile`, `--search`, repeatable `--codex-config key=value`, repeatable\n`--add-dir`, and legacy `--monitor` (consent for the native Codex monitor,\nnot the `monitor.mode` wake-owner selector). Run `ours-fleet help spawn` for\nexact values.\n\n## fleet.yaml\n\n```yaml\nvars:\n work_root: /home/me/work\nstart_stagger_ms: 0\ndefaults:\n harness: codex\n session: acp\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n monitor:\n mode: fleet # fleet (default) | native\nroles:\n Coordinator:\n harness: codex\n session: acp\n identity: Coordinator\n cwd: ${work_root}/project\n mission: Coordinate work and delegate implementation.\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n session_options: # advanced overrides; normally omit\n # acp:\n # command: [/custom/codex-acp, --flag]\n tmux:\n boot_grace_ms: 10000\n monitor:\n mode: fleet # fleet supervisor | native harness monitor\n interrupt: false # true cancels active work before every configured wake\n wake_sources: [message_received, file_received, local_contact_request, pending_message]\n batch_ms: 2000\n inject: notification\n turn_fail_threshold: 3\n harness_options:\n launcher: auto\n sandbox: workspace-write\n approval: on-request\n search: false\n profile: fleet\n add_dirs: [/data/shared]\n config:\n model_reasoning_effort: high\n bio: Public role card and when peers should engage it.\n persona: Local operating contract, boundaries, and escalation policy.\n briefing_file: /absolute/custom-briefing.md\n coordinator: AnotherCoordinator\n env:\n KEY: value\n oversee:\n - { role: Worker, interval: 5m }\nwatchdogs:\n nightwatch: # [A-Za-z0-9_-], must not collide with a role name\n coordinator: FleetCoordinator # required \u2014 where alerts go\n # everything below is optional\n enabled: true # default true; false = configured but never scheduled\n interval: 10m # default 10m; 30s | 10m | 2h, minimum 1m\n watch: [Alice, CodexReviewer] # explicit lists are exact; omit for configured + live temp roles\n harness: claude-code # default: defaults.harness\n model: claude-fable-5 # default: same resolution rule roles use (resolveRoleModel)\n session: acp # default: defaults.session\n identity: Watchdog-nightwatch # default: Watchdog-<name>\n timeout: 5m # default 5m; a run past this is killed and recorded as error\n keep_reports: 50 # default 50 reports retained per watchdog\n alert_cooldown: 60m # default 60m before the same finding alerts again\n prompt_file: /abs/extra.md # optional extra focus, APPENDED to the fixed contract\n isolation: # optional; omitted means no OS sandbox, like an ordinary role\n backend: bubblewrap # when present, the ordinary role isolation schema applies\n network: broker\n fs: { read: [/opt/watch-data] }\n```\n\nA watchdog observes and reports; it never restarts, stops, spawns, or removes a\nrole, answers a pending permission, edits a workspace, or approves anything on\nthe owner's behalf. `watchdogs:` may appear only in the base config\n(`~/fleet.yaml` or `-c FILE`), not in `~/fleet.d/*.yaml` drop-ins.\nWatchdogs are not isolated by default. An explicit watchdog `isolation:` block\nuses the same policy schema as a role and is applied unchanged; declare every\nextra filesystem access required by a custom prompt there.\nWhen `watch:` is omitted, each run watches the configured roles plus temporary\nfleet roles that are live when the run starts. An explicit `watch:` list is\nnever augmented.\n\nRole values override defaults. `${name}` substitutes entries from `vars`.\nOther role fields include `max_tokens`, `autocompact_pct`, and `isolation`.\nUse README.md for the complete isolation policy and resource-cap schema.\n\n## Permissions\n\nPrefer the harness-neutral `permissions` block:\n\n- `approval: ask|allow|deny`: whether actions may request or receive approval\n- `filesystem: read-only|workspace|unrestricted`: filesystem intent\n- `unattended: deny|wait`: what ACP does when no console can answer a request\n\nThe backend translates this common intent. Harness-native settings in\n`harness_options` take precedence where supplied. Do not choose\n`allow`/`unrestricted`, Codex `never`/`danger-full-access`, or Claude\n`bypassPermissions` without explicit authorization.\n\n### Creation-time isolation\n\n`ours-fleet spawn --isolation-file <path>` supplies a role's sandbox policy at\ncreation, so the FIRST launch is already confined \u2014 a role that only gains\n`isolation:` on a later `up` ran unsandboxed until then.\n\nThe file holds exactly the `isolation:` mapping documented above and nothing\nelse \u2014 the same schema, validated by the same code, so a policy written here\ncannot mean something different from the identical block in fleet.yaml:\n\n```yaml\nnetwork: deny\nfs:\n read: [/opt/reference]\nresources:\n mem: 2G\n```\n\nInvalid files are rejected before anything is created: no config, no state\ndirectory, no identity reservation. Works for both permanent and `--temp` roles.\n\n### Never-prompt failure\n\nThe failure this section exists to prevent leaves no error message anywhere.\n\nAn unattended role has no console. When the harness needs a permission decision\nthere is nobody to ask, so the request is refused INSIDE the harness \u2014 no\nprompt, no error, no log line. The agent simply does less than its briefing told\nit to, reports success, and nothing distinguishes that from having done the\nwork. Two settings produce it:\n\n1. a permission mode that suppresses the prompt without granting the action\n (Claude `dontAsk`, which is why neutral `allow` maps to\n `bypassPermissions` instead); and\n2. `unattended: deny`, which refuses every request that reaches it.\n\n**Automatic decisions are now recorded.** Every permission request decided\nwithout a human emits a completed event into\n`~/.ours-fleet/agents/<Name>/.session-events.jsonl` carrying the decision,\nwhether policy or a person made it, the policy that produced it\n(`permissions.unattended=deny` vs `permissions.approval=deny`/`=allow`),\nthe reason, and the option selected. `ours-fleet peek` and `attach` render\nthem. Automatic denial asks for a one-shot rejection, never a standing one, so a\nsingle unattended refusal cannot disable a tool for the rest of the session.\n\nA role that can auto-deny logs one line at startup saying so.\n\nTo detect an under-permissioned role BEFORE it runs, use the capability floor\nbelow: `ours-fleet doctor` fails such a role rather than letting it discover\nthe problem silently at work.\n\n### The unattended capability floor\n\nAn unattended role has no console, so a permission request cannot be answered \u2014\nit is refused, silently, inside the harness. The agent then does less than it\nwas told to and reports no error. To make that visible before launch,\n`ours-fleet config` and `ours-fleet doctor` resolve each role's neutral\npermissions through its harness and check the result against a fixed floor:\n\n- `read-state` \u2014 read its briefing, ROUTINES.md, and WORKLOG.md\n- `write-state` \u2014 append its WORKLOG and its own state files\n- `messaging` \u2014 bind its identity, send and receive ours mail\n- `monitor` \u2014 arm and observe its mail monitor\n- `workspace-edit` \u2014 edit and test files in its working directory\n- `status-commands` \u2014 run the inspection commands its briefing prescribes\n\n`doctor` reports this per role as `unattended floor: <Role>`. A role with\n`unattended: deny` that cannot meet the floor FAILS doctor, because it will\ndeny those requests with nobody to see it; with `unattended: wait` it warns,\nbecause a human can still attach and answer.\n\nSecurity meaning: `approval: allow` maps to Claude's `bypassPermissions`,\nwhich genuinely permits the actions the role was authorized to take \u2014\n`dontAsk` only suppresses the prompt while still refusing the action. Nothing\nother than an explicit `allow` is elevated: `ask` stays on Claude's default\nmode and `deny` maps to `plan`. `allow` is therefore a real grant and\nrequires explicit authorization; per-role `isolation:` remains the outer\nboundary that a permission mode cannot cross.\n\nSee also: `spawn --approval/--filesystem/--unattended` set this intent at\ncreation, and `ours-fleet config` prints each role's neutral settings, their\nnative translation, and any warning \u2014 the same text `doctor` reports.\n\nClaude `harness_options`: `permission_mode` (default, acceptEdits, plan,\ndontAsk, bypassPermissions), `plugins`, `mem_palace`, and\n`mem_palace_midsession_autosave`.\n\nCodex `harness_options`: `launcher` (auto, ours-codex, codex), `sandbox`\n(read-only, workspace-write, danger-full-access), `approval` or\n`permission_mode` (untrusted, on-request, never), `profile`, `search`,\n`config`, `add_dirs`, and `monitor`.\n\n## ACP adapters\n\nThe maintained `@agentclientprotocol/codex-acp` and\n`@agentclientprotocol/claude-agent-acp` runtimes are bundled automatically as\noptional ours-fleet dependencies. The supervisor resolves their executable\nentrypoints internally, so default ACP roles do not depend on global PATH.\nThe maintained Claude adapter requires Node 22; tmux and Codex ACP continue to\nwork on the ours-fleet core minimum of Node 20.\n\nOverride an adapter only when necessary with `session_options.acp.command`\n(string or argv list). If optional dependencies were deliberately omitted,\nours-fleet falls back to a compatible globally installed `codex-acp` or\n`claude-agent-acp`. `ours-fleet doctor -c FILE` verifies the resolved adapter.\n\n## Reliable mail wake\n\n`monitor.mode` selects exactly one wake owner:\n\n- `fleet` (default): the ours-fleet supervisor consumes body-free daemon\n events and advances its durable cursor only after delivery is accepted. ACP\n uses live steering when supported and falls back to structured\n `session/prompt`; tmux uses verified console injection.\n- `native`: ours-fleet starts no supervisor monitor; the generated briefing\n instructs Claude Code or Codex to arm its harness-native wake mechanism.\n\nSet `monitor.interrupt: true` in fleet mode to cancel active work before every\nconfigured wake. The policy is content-blind because the supervisor cannot\ninspect encrypted message bodies. Message bodies are released only when the\nrole calls the ours `get_messages` tool.\n\nLegacy `monitor.enabled: true|false` remains accepted as an alias for\n`mode: fleet|native`; use `mode` in new configuration. Codex's separate\n`harness_options.monitor: true` is native-monitor consent, not monitor-owner\nselection.\nInspect `ours-fleet status Name`, `peek Name`, role logs, and\n`~/.ours-fleet/agents/Name/.monitor-status` when diagnosing delivery.\n\n## Stable config and YAML migration\n\n`ours-fleet config --json` emits schemaVersion 1 resolved plans. Environment\nvalues and mission/persona/bio bodies are withheld; environment keys are sorted\nand values are marked redacted. Additive fields may appear in schema 1, while a\nremoval or semantic reuse requires a new schema version.\n\nYAML parsing always rejects duplicate keys. The current default\n`--yaml-mode compat` warns with file/line/column for anchors, aliases, explicit\ntags, non-scalar keys, and multiple documents. Use `--yaml-mode strict` in CI\nnow; strict becomes the next-major default and compat is the temporary migration\nescape hatch.\n\n## Bounded worklogs, auth proxy, and model recovery\n\nAn optional `worklog: { max_kb, keep_tail_kb, max_archives }` policy rotates a\nstable snapshot at fleet-owned lifecycle points. Concurrent changes defer\nrotation. Archives remain beside WORKLOG.md with the same sensitive-state\nboundary; retention deletes only recognized fleet archive names.\n\n`auth_proxy: { kind: anthropic, base_url, required, health_url }` is Claude-only\nand loopback-only. Fleet injects only ANTHROPIC_BASE_URL and doctor rejects\ncredential env keys. The privileged reference companion is\n`contrib/anthropic-auth-proxy.mjs`; deploy it separately as a dedicated account\nwith a 0600 token file and per-role listener access. Fleet never installs it or\nreads its credential.\n\n`model_chain` is an ordered authorization list and its first entry must equal\n`model`. Only sustained high-confidence entitlement/quota 429 evidence advances\none entry. Transient 429, overload, auth, policy, and unknown errors never\ndown-shift. Runtime state is atomic in .model-recovery.json; exhaustion is\nfail-closed and held down. Change the declared chain/model and restart to\nreconcile explicitly; no chain preserves detection-only behavior.\n";
|
|
8
8
|
/**
|
|
9
9
|
* What every shipped spawn-skill variant must say, and must not say (7.1).
|
|
10
10
|
*
|
package/dist/docs.js
CHANGED
|
@@ -167,7 +167,7 @@ watchdogs:
|
|
|
167
167
|
# everything below is optional
|
|
168
168
|
enabled: true # default true; false = configured but never scheduled
|
|
169
169
|
interval: 10m # default 10m; 30s | 10m | 2h, minimum 1m
|
|
170
|
-
watch: [Alice, CodexReviewer] #
|
|
170
|
+
watch: [Alice, CodexReviewer] # explicit lists are exact; omit for configured + live temp roles
|
|
171
171
|
harness: claude-code # default: defaults.harness
|
|
172
172
|
model: claude-fable-5 # default: same resolution rule roles use (resolveRoleModel)
|
|
173
173
|
session: acp # default: defaults.session
|
|
@@ -176,12 +176,22 @@ watchdogs:
|
|
|
176
176
|
keep_reports: 50 # default 50 reports retained per watchdog
|
|
177
177
|
alert_cooldown: 60m # default 60m before the same finding alerts again
|
|
178
178
|
prompt_file: /abs/extra.md # optional extra focus, APPENDED to the fixed contract
|
|
179
|
+
isolation: # optional; omitted means no OS sandbox, like an ordinary role
|
|
180
|
+
backend: bubblewrap # when present, the ordinary role isolation schema applies
|
|
181
|
+
network: broker
|
|
182
|
+
fs: { read: [/opt/watch-data] }
|
|
179
183
|
\`\`\`
|
|
180
184
|
|
|
181
185
|
A watchdog observes and reports; it never restarts, stops, spawns, or removes a
|
|
182
186
|
role, answers a pending permission, edits a workspace, or approves anything on
|
|
183
187
|
the owner's behalf. \`watchdogs:\` may appear only in the base config
|
|
184
188
|
(\`~/fleet.yaml\` or \`-c FILE\`), not in \`~/fleet.d/*.yaml\` drop-ins.
|
|
189
|
+
Watchdogs are not isolated by default. An explicit watchdog \`isolation:\` block
|
|
190
|
+
uses the same policy schema as a role and is applied unchanged; declare every
|
|
191
|
+
extra filesystem access required by a custom prompt there.
|
|
192
|
+
When \`watch:\` is omitted, each run watches the configured roles plus temporary
|
|
193
|
+
fleet roles that are live when the run starts. An explicit \`watch:\` list is
|
|
194
|
+
never augmented.
|
|
185
195
|
|
|
186
196
|
Role values override defaults. \`\${name}\` substitutes entries from \`vars\`.
|
|
187
197
|
Other role fields include \`max_tokens\`, \`autocompact_pct\`, and \`isolation\`.
|
package/dist/harness/codex.js
CHANGED
|
@@ -44,6 +44,17 @@ function sandboxMode(role) {
|
|
|
44
44
|
throw new Error(`invalid harness_options.sandbox "${s}"; allowed: ${SANDBOX_MODES.join(', ')}`);
|
|
45
45
|
return s;
|
|
46
46
|
}
|
|
47
|
+
/** codex-acp exposes the same sandbox postures as named ACP agent modes. */
|
|
48
|
+
function acpAgentMode(role) {
|
|
49
|
+
const sandbox = sandboxMode(role);
|
|
50
|
+
if (sandbox === 'read-only')
|
|
51
|
+
return 'read-only';
|
|
52
|
+
if (sandbox === 'workspace-write')
|
|
53
|
+
return 'agent';
|
|
54
|
+
if (sandbox === 'danger-full-access')
|
|
55
|
+
return 'agent-full-access';
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
47
58
|
/** Resolve & validate the per-role approval policy, throwing on an unknown value. */
|
|
48
59
|
function approvalPolicy(role) {
|
|
49
60
|
const o = role.harness_options;
|
|
@@ -215,7 +226,11 @@ export function makeCodexAdapter(exec = realExec) {
|
|
|
215
226
|
: typeof configured === 'string'
|
|
216
227
|
? ['sh', '-c', configured]
|
|
217
228
|
: bundledAcpAgent('@agentclientprotocol/codex-acp', 'codex-acp', 'codex-acp');
|
|
218
|
-
|
|
229
|
+
const initialMode = acpAgentMode(role);
|
|
230
|
+
return {
|
|
231
|
+
argv,
|
|
232
|
+
env: initialMode ? { ...prep.env, INITIAL_AGENT_MODE: initialMode } : prep.env,
|
|
233
|
+
};
|
|
219
234
|
},
|
|
220
235
|
isolationPaths(role, _dirs) {
|
|
221
236
|
const codexHome = join(home(), '.codex');
|
package/dist/isolation/policy.js
CHANGED
|
@@ -168,6 +168,11 @@ export function resolveIsolation(cfg, ctx) {
|
|
|
168
168
|
}
|
|
169
169
|
for (const dir of ctx.additionalWriteDirs ?? [])
|
|
170
170
|
addRw(dir);
|
|
171
|
+
// The selected harness/session command may live outside the system allowlist
|
|
172
|
+
// (for example Node and a bundled ACP adapter under ~/.local). The runner
|
|
173
|
+
// resolves its exact executable/package closure; keep every such bind RO.
|
|
174
|
+
for (const path of ctx.runtimeReadPaths ?? [])
|
|
175
|
+
addRo(path);
|
|
171
176
|
// Declared fs extras.
|
|
172
177
|
for (const p of cfg.fs?.write ?? [])
|
|
173
178
|
addRw(p);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface LaunchRuntime {
|
|
2
|
+
/** Launch argv with a PATH-resolved command, so the mounted executable is the one invoked. */
|
|
3
|
+
argv: string[];
|
|
4
|
+
/** Exact executable and package roots required by the launch, mounted read-only. */
|
|
5
|
+
readPaths: string[];
|
|
6
|
+
}
|
|
7
|
+
export interface LaunchRuntimeOptions {
|
|
8
|
+
path?: string;
|
|
9
|
+
nodeExecutable?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Resolve the concrete runtime closure for an already-selected harness launch.
|
|
13
|
+
* System roots are already present in every isolation policy; everything else
|
|
14
|
+
* is returned as an exact read-only executable or npm package-root bind.
|
|
15
|
+
*/
|
|
16
|
+
export declare function resolveLaunchRuntime(argv: string[], options?: LaunchRuntimeOptions): LaunchRuntime;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { accessSync, closeSync, constants, existsSync, openSync, readFileSync, readSync, realpathSync, } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { delimiter, dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
4
|
+
const SYSTEM_ROOTS = ['/usr', '/bin', '/sbin', '/lib', '/lib64', '/etc'];
|
|
5
|
+
const inside = (path, root) => path === root || path.startsWith(root + sep);
|
|
6
|
+
function canonical(path) {
|
|
7
|
+
try {
|
|
8
|
+
return realpathSync.native(path);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return resolve(path);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function commandPath(command, pathValue) {
|
|
15
|
+
const candidates = isAbsolute(command) || command.includes(sep)
|
|
16
|
+
? [resolve(command)]
|
|
17
|
+
: pathValue.split(delimiter).filter(Boolean).map(dir => resolve(dir, command));
|
|
18
|
+
for (const candidate of candidates) {
|
|
19
|
+
try {
|
|
20
|
+
accessSync(candidate, constants.X_OK);
|
|
21
|
+
return canonical(candidate);
|
|
22
|
+
}
|
|
23
|
+
catch { /* keep searching PATH */ }
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
function packageRoot(path) {
|
|
28
|
+
let dir = existsSync(path) ? dirname(path) : path;
|
|
29
|
+
for (;;) {
|
|
30
|
+
const manifest = join(dir, 'package.json');
|
|
31
|
+
if (existsSync(manifest))
|
|
32
|
+
return dir;
|
|
33
|
+
const parent = dirname(dir);
|
|
34
|
+
if (parent === dir)
|
|
35
|
+
return undefined;
|
|
36
|
+
dir = parent;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function dependencyManifest(manifestPath, name) {
|
|
40
|
+
const localRequire = createRequire(manifestPath);
|
|
41
|
+
try {
|
|
42
|
+
return localRequire.resolve(`${name}/package.json`);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Some packages do not export package.json. Their main entry still gives us
|
|
46
|
+
// a point from which to find the owning package root.
|
|
47
|
+
try {
|
|
48
|
+
const entry = localRequire.resolve(name);
|
|
49
|
+
const root = packageRoot(entry);
|
|
50
|
+
return root ? join(root, 'package.json') : undefined;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function addPackageClosure(root, paths, seen) {
|
|
58
|
+
const canonicalRoot = canonical(root);
|
|
59
|
+
if (seen.has(canonicalRoot))
|
|
60
|
+
return;
|
|
61
|
+
seen.add(canonicalRoot);
|
|
62
|
+
paths.add(canonicalRoot);
|
|
63
|
+
const manifestPath = join(canonicalRoot, 'package.json');
|
|
64
|
+
try {
|
|
65
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
66
|
+
const names = new Set([
|
|
67
|
+
...Object.keys(manifest.dependencies ?? {}),
|
|
68
|
+
...Object.keys(manifest.optionalDependencies ?? {}),
|
|
69
|
+
...Object.keys(manifest.peerDependencies ?? {}),
|
|
70
|
+
]);
|
|
71
|
+
for (const name of names) {
|
|
72
|
+
const dependency = dependencyManifest(manifestPath, name);
|
|
73
|
+
if (dependency)
|
|
74
|
+
addPackageClosure(dirname(dependency), paths, seen);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch { /* a malformed/unreadable manifest cannot contribute a closure */ }
|
|
78
|
+
}
|
|
79
|
+
function isNodeScript(path) {
|
|
80
|
+
let fd;
|
|
81
|
+
try {
|
|
82
|
+
fd = openSync(path, 'r');
|
|
83
|
+
const bytes = Buffer.alloc(128);
|
|
84
|
+
const count = readSync(fd, bytes, 0, bytes.length, 0);
|
|
85
|
+
return /^#!.*\bnode\b/.test(bytes.subarray(0, count).toString('utf8'));
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
if (fd !== undefined)
|
|
92
|
+
closeSync(fd);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Resolve the concrete runtime closure for an already-selected harness launch.
|
|
97
|
+
* System roots are already present in every isolation policy; everything else
|
|
98
|
+
* is returned as an exact read-only executable or npm package-root bind.
|
|
99
|
+
*/
|
|
100
|
+
export function resolveLaunchRuntime(argv, options = {}) {
|
|
101
|
+
if (!argv.length)
|
|
102
|
+
return { argv: [], readPaths: [] };
|
|
103
|
+
const nodeExecutable = canonical(options.nodeExecutable ?? process.execPath);
|
|
104
|
+
const executable = commandPath(argv[0], options.path ?? process.env.PATH ?? '');
|
|
105
|
+
const resolvedArgv = executable ? [executable, ...argv.slice(1)] : [...argv];
|
|
106
|
+
const paths = new Set();
|
|
107
|
+
const packages = new Set();
|
|
108
|
+
const consider = (path) => {
|
|
109
|
+
if (!isAbsolute(path) || !existsSync(path))
|
|
110
|
+
return;
|
|
111
|
+
const real = canonical(path);
|
|
112
|
+
const root = packageRoot(real);
|
|
113
|
+
if (root)
|
|
114
|
+
addPackageClosure(root, paths, packages);
|
|
115
|
+
else
|
|
116
|
+
paths.add(real);
|
|
117
|
+
if (real === nodeExecutable || isNodeScript(real))
|
|
118
|
+
paths.add(nodeExecutable);
|
|
119
|
+
};
|
|
120
|
+
if (executable)
|
|
121
|
+
consider(executable);
|
|
122
|
+
// A direct Node launch's first existing absolute non-option argument is its
|
|
123
|
+
// module entrypoint. Other absolute arguments are harness inputs/settings,
|
|
124
|
+
// not runtime code, and already follow the ordinary filesystem policy.
|
|
125
|
+
if (executable === nodeExecutable) {
|
|
126
|
+
const entrypointIndex = argv.findIndex((arg, index) => index > 0 && !arg.startsWith('-') && isAbsolute(arg) && existsSync(arg));
|
|
127
|
+
if (entrypointIndex !== -1) {
|
|
128
|
+
resolvedArgv[entrypointIndex] = canonical(argv[entrypointIndex]);
|
|
129
|
+
consider(resolvedArgv[entrypointIndex]);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
argv: resolvedArgv,
|
|
134
|
+
readPaths: [...paths].filter(path => !SYSTEM_ROOTS.some(root => inside(path, root))),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -63,6 +63,8 @@ export interface WrapContext {
|
|
|
63
63
|
* them for itself or for its peers.
|
|
64
64
|
*/
|
|
65
65
|
harnessSharedPaths?: string[];
|
|
66
|
+
/** Exact launcher/interpreter/module closure required by the selected command. */
|
|
67
|
+
runtimeReadPaths?: string[];
|
|
66
68
|
brokerEndpoint?: string;
|
|
67
69
|
}
|
|
68
70
|
/**
|
package/dist/resolved-plan.js
CHANGED
|
@@ -26,6 +26,7 @@ export function resolvedPlan(cfg) {
|
|
|
26
26
|
harness: w.harness, session: w.session, model: w.model ?? null,
|
|
27
27
|
identity: w.identity, timeoutMs: w.timeoutMs, keepReports: w.keepReports,
|
|
28
28
|
alertCooldownMs: w.alertCooldownMs, promptFile: w.promptFile ?? null,
|
|
29
|
+
isolation: w.isolation ?? null,
|
|
29
30
|
})),
|
|
30
31
|
};
|
|
31
32
|
}
|
package/dist/runner.js
CHANGED
|
@@ -11,6 +11,7 @@ import { realExec, shq } from './exec.js';
|
|
|
11
11
|
import { resolveIsolation } from './isolation/policy.js';
|
|
12
12
|
import { selectIsolationBackend } from './isolation/registry.js';
|
|
13
13
|
import { resourceArgs, cpuControllerDelegated } from './isolation/resources.js';
|
|
14
|
+
import { resolveLaunchRuntime } from './isolation/runtime.js';
|
|
14
15
|
import { AcpSession } from './session/acp.js';
|
|
15
16
|
import { RoleControlServer } from './session/control.js';
|
|
16
17
|
import { TmuxSession } from './session/tmux.js';
|
|
@@ -319,7 +320,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
319
320
|
const runCwd = role.cwd && existsSync(role.cwd) ? role.cwd : dir;
|
|
320
321
|
const prep = await adapter.prepareSession(role, { stateDir: dir, runCwd });
|
|
321
322
|
const sessionBackend = role.session ?? 'tmux';
|
|
322
|
-
|
|
323
|
+
let launch = sessionBackend === 'acp'
|
|
323
324
|
? (() => {
|
|
324
325
|
if (!adapter.buildAcpLaunch)
|
|
325
326
|
throw new Error(`harness '${role.harness}' does not support the ACP session backend`);
|
|
@@ -330,10 +331,15 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
330
331
|
// env prefix + exit capture in buildPaneCommand stay host-side (see §5.3).
|
|
331
332
|
let wrappedArgv = launch.argv;
|
|
332
333
|
if (role.isolation) {
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
const
|
|
334
|
+
// Start with the SAME durable context config validation and doctor judged
|
|
335
|
+
// (5.2), then add the selected launch's exact runtime closure. Those paths
|
|
336
|
+
// still pass through resolveIsolation's canonical blocklist enforcement.
|
|
337
|
+
const runtime = resolveLaunchRuntime(launch.argv);
|
|
338
|
+
launch = { ...launch, argv: runtime.argv };
|
|
339
|
+
const ctx = {
|
|
340
|
+
...isolationContextFor(role), stateDir: dir, runCwd,
|
|
341
|
+
runtimeReadPaths: runtime.readPaths,
|
|
342
|
+
};
|
|
337
343
|
const policy = resolveIsolation(role.isolation, ctx);
|
|
338
344
|
const sel = await selectIsolationBackend(policy, deps.exec); // throws on strict + unavailable
|
|
339
345
|
const degradedMarker = join(dir, '.isolation-degraded');
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { FleetConfig, ResolvedRole, SessionBackendId } from '../config.js';
|
|
2
|
+
import type { IsolationConfig } from '../isolation/types.js';
|
|
2
3
|
export interface WatchdogConfig {
|
|
3
4
|
coordinator?: string;
|
|
4
5
|
enabled?: boolean;
|
|
@@ -12,6 +13,7 @@ export interface WatchdogConfig {
|
|
|
12
13
|
keep_reports?: number;
|
|
13
14
|
alert_cooldown?: string;
|
|
14
15
|
prompt_file?: string;
|
|
16
|
+
isolation?: IsolationConfig;
|
|
15
17
|
}
|
|
16
18
|
export interface ResolvedWatchdog {
|
|
17
19
|
name: string;
|
|
@@ -19,6 +21,7 @@ export interface ResolvedWatchdog {
|
|
|
19
21
|
enabled: boolean;
|
|
20
22
|
intervalMs: number;
|
|
21
23
|
watch: string[];
|
|
24
|
+
watchExplicit: boolean;
|
|
22
25
|
harness: string;
|
|
23
26
|
session: SessionBackendId;
|
|
24
27
|
model?: string;
|
|
@@ -27,6 +30,7 @@ export interface ResolvedWatchdog {
|
|
|
27
30
|
keepReports: number;
|
|
28
31
|
alertCooldownMs: number;
|
|
29
32
|
promptFile?: string;
|
|
33
|
+
isolation?: IsolationConfig;
|
|
30
34
|
sourceFile: string;
|
|
31
35
|
}
|
|
32
36
|
export declare const WATCHDOG_DEFAULT_INTERVAL_MS = 600000;
|
package/dist/watchdog/config.js
CHANGED
|
@@ -2,9 +2,10 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { isAbsolute } from 'node:path';
|
|
3
3
|
import { parseDuration } from '../duration.js';
|
|
4
4
|
import { ConfigError, ROLE_NAME_RE, resolveRoleModel } from '../config.js';
|
|
5
|
+
import { validateIsolationConfig } from '../isolation/policy.js';
|
|
5
6
|
const WATCHDOG_KEYS = [
|
|
6
7
|
'coordinator', 'enabled', 'interval', 'watch', 'harness', 'model', 'session',
|
|
7
|
-
'identity', 'timeout', 'keep_reports', 'alert_cooldown', 'prompt_file',
|
|
8
|
+
'identity', 'timeout', 'keep_reports', 'alert_cooldown', 'prompt_file', 'isolation',
|
|
8
9
|
];
|
|
9
10
|
export const WATCHDOG_DEFAULT_INTERVAL_MS = 600_000;
|
|
10
11
|
export const WATCHDOG_MIN_INTERVAL_MS = 60_000;
|
|
@@ -69,6 +70,11 @@ export function resolveWatchdogs(baseDoc, baseFile, roles, vars, defaults) {
|
|
|
69
70
|
if (!existsSync(w.prompt_file))
|
|
70
71
|
throw new ConfigError(`${where}: prompt_file not found: ${w.prompt_file}`);
|
|
71
72
|
}
|
|
73
|
+
if (w.isolation !== undefined) {
|
|
74
|
+
const problems = validateIsolationConfig(w.isolation);
|
|
75
|
+
if (problems.length)
|
|
76
|
+
throw new ConfigError(`${where} ${problems.join('; ')}`);
|
|
77
|
+
}
|
|
72
78
|
const dur = (v, key, fallback, minMs) => {
|
|
73
79
|
if (v === undefined)
|
|
74
80
|
return fallback;
|
|
@@ -88,13 +94,13 @@ export function resolveWatchdogs(baseDoc, baseFile, roles, vars, defaults) {
|
|
|
88
94
|
name, coordinator: w.coordinator.trim(),
|
|
89
95
|
enabled: w.enabled ?? true,
|
|
90
96
|
intervalMs: dur(w.interval, 'interval', WATCHDOG_DEFAULT_INTERVAL_MS, WATCHDOG_MIN_INTERVAL_MS),
|
|
91
|
-
watch, harness, session: sessionRaw,
|
|
97
|
+
watch, watchExplicit: w.watch !== undefined, harness, session: sessionRaw,
|
|
92
98
|
model: resolveRoleModel(w.model, w.harness, defaults),
|
|
93
99
|
identity,
|
|
94
100
|
timeoutMs: dur(w.timeout, 'timeout', WATCHDOG_DEFAULT_TIMEOUT_MS),
|
|
95
101
|
keepReports,
|
|
96
102
|
alertCooldownMs: dur(w.alert_cooldown, 'alert_cooldown', WATCHDOG_DEFAULT_COOLDOWN_MS),
|
|
97
|
-
promptFile: w.prompt_file, sourceFile: baseFile,
|
|
103
|
+
promptFile: w.prompt_file, isolation: w.isolation, sourceFile: baseFile,
|
|
98
104
|
});
|
|
99
105
|
}
|
|
100
106
|
return out;
|
package/dist/watchdog/run.d.ts
CHANGED
|
@@ -21,6 +21,8 @@ export interface WatchdogRunDeps {
|
|
|
21
21
|
launchChild?(binPath: string, roleName: string, runDir: string): WatchdogChildHandle;
|
|
22
22
|
/** Pre-loaded config (defaults inheritance). Falls back to `loadConfig()`. */
|
|
23
23
|
cfg?: FleetConfig;
|
|
24
|
+
/** Live temporary-role discovery override for focused tests. */
|
|
25
|
+
discoverLiveTemporaryRoles?(): Promise<string[]>;
|
|
24
26
|
}
|
|
25
27
|
export interface WatchdogRunOutcome {
|
|
26
28
|
report: WatchdogReport;
|
package/dist/watchdog/run.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { closeSync, existsSync, openSync, readFileSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
1
|
+
import { closeSync, existsSync, openSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
2
2
|
import { spawn as spawnChild, execFile } from 'node:child_process';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
@@ -10,10 +10,12 @@ import { computeDigest, reconcileLedger, readLedger, writeLedger } from './alert
|
|
|
10
10
|
import { applyRole } from '../ops.js';
|
|
11
11
|
import { daemonIdentityProvisioner, ensureIdentity, } from '../creation.js';
|
|
12
12
|
import { runOnce, START_STAGGER_FILE } from '../runner.js';
|
|
13
|
-
import { agentDir } from '../paths.js';
|
|
14
|
-
import { loadConfig, resolveMonitorConfig, resolveWorklogPolicy, } from '../config.js';
|
|
13
|
+
import { agentDir, tmpRoot } from '../paths.js';
|
|
14
|
+
import { loadConfig, resolveMonitorConfig, resolveWorklogPolicy, ROLE_NAME_RE, } from '../config.js';
|
|
15
15
|
import { getAdapter } from '../harness/registry.js';
|
|
16
16
|
import { redactLogLine } from '../application/log-service.js';
|
|
17
|
+
import { controlRequest, controlSocketPath } from '../session/control.js';
|
|
18
|
+
import { Tmux } from '../tmux.js';
|
|
17
19
|
const execFileAsync = promisify(execFile);
|
|
18
20
|
/** How often the deadline loop polls for a completed report or a dead child. */
|
|
19
21
|
const POLL_MS = 1000;
|
|
@@ -92,33 +94,49 @@ function readTail(runDir) {
|
|
|
92
94
|
return undefined;
|
|
93
95
|
}
|
|
94
96
|
}
|
|
95
|
-
/**
|
|
96
|
-
* The temp role every watchdog-family run (inspection and notifier alike) launches under.
|
|
97
|
-
* `network: 'broker'` keeps ours messaging available; no write binds beyond stateDir/cwd, which
|
|
98
|
-
* resolveIsolation adds itself. ~/fleet.yaml and fleet.d are deliberately NOT bound — they're on
|
|
99
|
-
* the isolation blocklist, and everything either run flavor needs is written into its own dir.
|
|
100
|
-
*
|
|
101
|
-
* Read access is scoped to exactly `wd.watch` (finding #3): a watchdog configured to watch one
|
|
102
|
-
* role must not be able to read every other role's state dir just because they all live under
|
|
103
|
-
* the same agents root. `wd.watch` defaults to every role (watchdog/config.ts's
|
|
104
|
-
* resolveWatchdogs), so a watchdog that watches everything still sees everything — this only
|
|
105
|
-
* narrows visibility for a watchdog scoped to fewer roles. A watched role whose state dir doesn't
|
|
106
|
-
* exist (e.g. a role removed after the watchdog was configured) is simply absent from the
|
|
107
|
-
* bwrap ro-bind-try set — the agent reports it unreachable from status evidence, same as any
|
|
108
|
-
* other missing state dir.
|
|
109
|
-
*/
|
|
97
|
+
/** The temp role every watchdog-family run launches under. Isolation is opt-in. */
|
|
110
98
|
function buildWatchdogRole(wd, cfg) {
|
|
111
99
|
return {
|
|
112
100
|
name: wd.identity, sourceFile: '(watchdog)',
|
|
113
101
|
harness: wd.harness, session: wd.session,
|
|
114
102
|
identity: wd.identity, model: wd.model,
|
|
115
|
-
|
|
103
|
+
// Watchdogs are observe-only by contract, but their sanctioned status commands
|
|
104
|
+
// must reach host control sockets. Keep approvals/unattended escalation denied
|
|
105
|
+
// while disabling the harness's native filesystem/network sandbox.
|
|
106
|
+
permissions: { approval: 'deny', filesystem: 'unrestricted', unattended: 'deny' },
|
|
116
107
|
permissionsDeclared: true,
|
|
117
108
|
monitor: resolveMonitorConfig(cfg.defaults.monitor, undefined),
|
|
118
109
|
worklog: resolveWorklogPolicy(cfg.defaults.worklog, undefined),
|
|
119
|
-
isolation:
|
|
110
|
+
isolation: wd.isolation,
|
|
120
111
|
};
|
|
121
112
|
}
|
|
113
|
+
/** Discover temporary sessions that are live now, not merely stale dirs on disk. */
|
|
114
|
+
async function discoverLiveTemporaryRoles() {
|
|
115
|
+
let names;
|
|
116
|
+
try {
|
|
117
|
+
names = readdirSync(tmpRoot(), { withFileTypes: true })
|
|
118
|
+
.filter(entry => entry.isDirectory() && !entry.isSymbolicLink() && ROLE_NAME_RE.test(entry.name))
|
|
119
|
+
.map(entry => entry.name);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
const tmux = new Tmux();
|
|
125
|
+
const live = await Promise.all(names.map(async (name) => {
|
|
126
|
+
const dir = agentDir(name, true);
|
|
127
|
+
const [acpAlive, tmuxAlive] = await Promise.all([
|
|
128
|
+
existsSync(controlSocketPath(dir))
|
|
129
|
+
? controlRequest(dir, { command: 'status' }, 2_000)
|
|
130
|
+
.then(response => response.ok
|
|
131
|
+
&& response.result?.alive === true)
|
|
132
|
+
.catch(() => false)
|
|
133
|
+
: Promise.resolve(false),
|
|
134
|
+
tmux.has(name).catch(() => false),
|
|
135
|
+
]);
|
|
136
|
+
return acpAlive || tmuxAlive ? name : undefined;
|
|
137
|
+
}));
|
|
138
|
+
return live.filter((name) => name !== undefined);
|
|
139
|
+
}
|
|
122
140
|
/**
|
|
123
141
|
* Run one watchdog agent end-to-end in a clean-context temp state dir: provision
|
|
124
142
|
* identity, materialize the run's contract (briefing/manifest/role snapshot),
|
|
@@ -147,14 +165,20 @@ export async function executeWatchdogRun(wd, deps) {
|
|
|
147
165
|
try {
|
|
148
166
|
const guarantee = await ensureIdentity(wd.identity, {}, deps.identityProvisioner ?? daemonIdentityProvisioner(), deps.log);
|
|
149
167
|
const cfg = deps.cfg ?? loadConfig();
|
|
150
|
-
const
|
|
168
|
+
const discovered = wd.watchExplicit
|
|
169
|
+
? []
|
|
170
|
+
: await (deps.discoverLiveTemporaryRoles ?? discoverLiveTemporaryRoles)();
|
|
171
|
+
const watch = [...new Set([...wd.watch, ...discovered])]
|
|
172
|
+
.filter(name => name !== wd.identity);
|
|
173
|
+
const runWd = { ...wd, watch };
|
|
174
|
+
const role = buildWatchdogRole(runWd, cfg);
|
|
151
175
|
const dir = applyRole(role, { temp: true, identityGuarantee: guarantee.state });
|
|
152
176
|
const reportPath = join(dir, 'report.json');
|
|
153
177
|
const manifestPath = join(dir, 'watch.json');
|
|
154
178
|
const promptFocus = wd.promptFile ? readFileSync(wd.promptFile, 'utf8') : undefined;
|
|
155
179
|
// applyRole wrote the generic role briefing; the watchdog contract replaces it.
|
|
156
180
|
writeFileSync(join(dir, 'briefing.md'), generateWatchdogBriefing({
|
|
157
|
-
wd, manifestPath, reportPath,
|
|
181
|
+
wd: runWd, manifestPath, reportPath,
|
|
158
182
|
vocabulary: getAdapter(wd.harness).vocabulary,
|
|
159
183
|
identityGuarantee: guarantee.state,
|
|
160
184
|
promptFocus,
|
|
@@ -163,7 +187,10 @@ export async function executeWatchdogRun(wd, deps) {
|
|
|
163
187
|
writeFileSync(join(dir, 'role.yaml'), stringify(role));
|
|
164
188
|
const manifest = {
|
|
165
189
|
watchdog: wd.name, run_id: runId, coordinator: wd.coordinator, started_at: startedAt,
|
|
166
|
-
roles:
|
|
190
|
+
roles: runWd.watch.map(r => ({
|
|
191
|
+
name: r,
|
|
192
|
+
stateDir: wd.watch.includes(r) ? agentDir(r) : agentDir(r, true),
|
|
193
|
+
})),
|
|
167
194
|
digest: computeDigest(ledger, wd.alertCooldownMs, now()),
|
|
168
195
|
};
|
|
169
196
|
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux or ACP sessions, supervision, and ours.network messaging.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|