@ours.network/fleet 0.14.0 → 0.14.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/README.md CHANGED
@@ -131,14 +131,18 @@ ours-fleet spawn Coder --harness codex --model gpt-5.4 \
131
131
 
132
132
  ## Local web console
133
133
 
134
- The interactive console is packaged with `@ours.network/fleet` and runs only on
135
- IPv4 loopback:
134
+ The interactive console is packaged with `@ours.network/fleet` and binds to
135
+ IPv4 loopback by default. Remote or proxy exposure is always explicit:
136
136
 
137
137
  ```sh
138
138
  npm run build
139
139
  ours-fleet web
140
140
  # choose a free port for an isolated test:
141
141
  ours-fleet web serve --port 0 --no-open
142
+ # nginx/TLS terminates at the declared browser origin; fleet remains loopback-bound
143
+ ours-fleet web install --public-origin https://fleet.example.com --password-file /secure/fleet-password
144
+ # Intentional no-password mode, for example when nginx already authenticates:
145
+ ours-fleet web install --public-origin https://fleet.example.com --no-password
142
146
  ```
143
147
 
144
148
  The normal `ours-fleet web` command installs or updates an owner-level native
@@ -163,8 +167,25 @@ Only a domain-separated SHA-256 device-secret hash and bounded timestamps are
163
167
  stored in the owner-private fleet state directory (`0700` directory, `0600`
164
168
  atomic file). The re-pair and revoke controls use an owner-private Unix socket;
165
169
  local processes running as the same OS user are therefore inside the trust
166
- boundary. Keep the console local: it has no `--host`, proxy, TLS, or
167
- remote-access mode.
170
+ boundary.
171
+
172
+ On first setup, the CLI requires an explicit access choice: `--password-file`
173
+ or `--pairing` for protected access, or `--no-password` for intentional
174
+ unprotected access. `--password-file` stores only a salted scrypt verifier in
175
+ the owner-private web state; the source file remains operator-managed. New
176
+ browsers sign in and then receive the same rotating HttpOnly trusted-device
177
+ credential. `--no-password` is deliberately named and prints a warning: anyone
178
+ who can reach that origin can control the fleet.
179
+
180
+ For nginx on a VPS, keep the default loopback bind and set the exact external
181
+ `--public-origin` (scheme, hostname, optional port). nginx should proxy HTTP and
182
+ WebSocket upgrades to `127.0.0.1:49271` and provide TLS; rewriting the upstream
183
+ Host is not required because the declared browser Origin remains authoritative. To listen beyond
184
+ loopback, add an explicit `--bind`; fleet refuses a non-loopback bind without a
185
+ public origin. Host and Origin validation use that declaration rather than
186
+ trusting forwarded headers. `localhost` and `127.0.0.1` both work in normal
187
+ local mode; an unconfigured hostname gets a self-describing HTML page instead
188
+ of raw internal Host-header JSON.
168
189
 
169
190
  The console is installable as a standalone PWA. Its service worker caches only
170
191
  the data-free offline page and successful content-hashed JavaScript/CSS assets.
@@ -187,8 +208,8 @@ diagnostic.
187
208
 
188
209
  Security boundaries:
189
210
 
190
- - exact runtime `Host` and `Origin`, CSRF, one-time WebSocket tickets, and
191
- loopback binding are enforced server-side;
211
+ - configured `Host` and `Origin`, CSRF, one-time WebSocket tickets, and explicit
212
+ bind/origin policy are enforced server-side;
192
213
  - cwd values must resolve beneath configured local roots;
193
214
  - terminal bytes are intentionally unredacted and are never copied into audit
194
215
  records; normal logs are bounded and redacted;
package/dist/cli.js CHANGED
@@ -32,6 +32,7 @@ import { readScheduledLoops } from './loops/state.js';
32
32
  import { startWebConsole } from './web/runtime.js';
33
33
  import { requestWebControl } from './web/control.js';
34
34
  import { WebServiceManager } from './web/service.js';
35
+ import { WebAccessStore, passwordAccess, validatePublicOrigin } from './web/access.js';
35
36
  import './harness/claude-code.js'; // registers the claude-code adapter
36
37
  import './harness/codex.js'; // registers the codex adapter
37
38
  // sudo/su shells lack XDG_RUNTIME_DIR, breaking every systemctl/journalctl
@@ -1102,15 +1103,25 @@ const webCommand = cOpt(program.command('web').description('start or open the se
1102
1103
  return port;
1103
1104
  })
1104
1105
  .option('--no-open', 'do not open a browser automatically')
1106
+ .option('--bind <address>', 'explicit listen address (default: 127.0.0.1)')
1107
+ .option('--public-origin <url>', 'browser origin served by an explicit reverse proxy')
1108
+ .option('--password-file <path>', 'configure password protection from an owner-readable file')
1109
+ .option('--no-password', 'intentionally configure an unprotected control panel')
1110
+ .option('--pairing', 'configure trusted-browser pairing mode')
1105
1111
  .action(async (opts) => {
1106
1112
  try {
1113
+ const accessNotice = configureWebAccess(opts);
1107
1114
  const manager = new WebServiceManager();
1108
- for (const line of await manager.install(binPath, opts.port ?? 49_271, opts.configuration))
1115
+ for (const line of await manager.install(binPath, opts.port ?? 49_271, opts.configuration, {
1116
+ bind: opts.bind, publicOrigin: opts.publicOrigin,
1117
+ }))
1109
1118
  process.stdout.write(line + '\n');
1119
+ if (accessNotice)
1120
+ process.stdout.write(accessNotice + '\n');
1110
1121
  await manager.start();
1111
1122
  if (opts.open !== false) {
1112
1123
  await requestWebControlWhenReady('open');
1113
- process.stdout.write('Trusted-browser pairing opened locally.\n');
1124
+ process.stdout.write('Control-panel authentication opened in the browser.\n');
1114
1125
  }
1115
1126
  else
1116
1127
  process.stdout.write('Web service started; run `ours-fleet web open` to pair a browser.\n');
@@ -1135,17 +1146,25 @@ const webServe = cOpt(webCommand.command('serve').description('run the web conso
1135
1146
  });
1136
1147
  webServe
1137
1148
  .option('--no-open', 'do not open a browser automatically')
1149
+ .option('--bind <address>', 'explicit listen address (default: 127.0.0.1)')
1150
+ .option('--public-origin <url>', 'browser origin served by an explicit reverse proxy')
1151
+ .option('--password-file <path>', 'configure password protection from an owner-readable file')
1152
+ .option('--no-password', 'intentionally configure an unprotected control panel')
1153
+ .option('--pairing', 'configure trusted-browser pairing mode')
1138
1154
  .action(async (opts) => {
1139
1155
  try {
1156
+ const accessNotice = configureWebAccess(opts);
1140
1157
  const consoleServer = await startWebConsole({
1141
1158
  configPath: opts.configuration, port: opts.port,
1142
- open: opts.open !== false, binPath,
1159
+ open: opts.open !== false, binPath, bind: opts.bind, publicOrigin: opts.publicOrigin,
1143
1160
  log: line => process.stderr.write(line + '\n'),
1144
1161
  });
1162
+ if (accessNotice)
1163
+ process.stdout.write(accessNotice + '\n');
1145
1164
  process.stdout.write(`ours-fleet web listening on ${consoleServer.address}\n`);
1146
1165
  process.stdout.write(opts.open !== false
1147
- ? 'Trusted-browser pairing opened locally.\n'
1148
- : 'Run `ours-fleet web open` on this computer to pair a browser.\n');
1166
+ ? 'Control-panel authentication opened in the browser.\n'
1167
+ : 'Run `ours-fleet web open` to authenticate a browser.\n');
1149
1168
  const shutdown = async () => { await consoleServer.close(); process.exit(0); };
1150
1169
  process.once('SIGINT', () => { void shutdown(); });
1151
1170
  process.once('SIGTERM', () => { void shutdown(); });
@@ -1155,10 +1174,18 @@ webServe
1155
1174
  }
1156
1175
  });
1157
1176
  webPort(webCommand.command('install').description('install or update the owner web service'))
1177
+ .option('--bind <address>', 'explicit listen address (default: 127.0.0.1)')
1178
+ .option('--public-origin <url>', 'browser origin served by an explicit reverse proxy')
1179
+ .option('--password-file <path>', 'configure password protection from an owner-readable file')
1180
+ .option('--no-password', 'intentionally configure an unprotected control panel')
1181
+ .option('--pairing', 'configure trusted-browser pairing mode')
1158
1182
  .action(async (opts) => {
1159
1183
  try {
1160
- for (const line of await new WebServiceManager().install(binPath, opts.port ?? 49_271, opts.configuration))
1184
+ const accessNotice = configureWebAccess(opts);
1185
+ for (const line of await new WebServiceManager().install(binPath, opts.port ?? 49_271, opts.configuration, { bind: opts.bind, publicOrigin: opts.publicOrigin }))
1161
1186
  process.stdout.write(line + '\n');
1187
+ if (accessNotice)
1188
+ process.stdout.write(accessNotice + '\n');
1162
1189
  }
1163
1190
  catch (e) {
1164
1191
  die(e);
@@ -1200,7 +1227,7 @@ webCommand.command('open').description('securely open or re-pair a browser with
1200
1227
  .action(async () => {
1201
1228
  try {
1202
1229
  await requestWebControl('open');
1203
- process.stdout.write('Trusted-browser pairing opened locally.\n');
1230
+ process.stdout.write('Control-panel authentication opened in the browser.\n');
1204
1231
  }
1205
1232
  catch (e) {
1206
1233
  die(e);
@@ -1230,6 +1257,32 @@ async function requestWebControlWhenReady(command) {
1230
1257
  }
1231
1258
  throw last;
1232
1259
  }
1260
+ function configureWebAccess(opts) {
1261
+ if (opts.publicOrigin)
1262
+ validatePublicOrigin(opts.publicOrigin);
1263
+ const noPassword = opts.password === false;
1264
+ const choices = [Boolean(opts.passwordFile), noPassword, Boolean(opts.pairing)].filter(Boolean).length;
1265
+ if (choices > 1)
1266
+ throw new Error('choose only one of --password-file, --no-password, or --pairing');
1267
+ const store = new WebAccessStore();
1268
+ if (opts.passwordFile) {
1269
+ const password = readFileSync(realpathSync(opts.passwordFile), 'utf8').replace(/[\r\n]+$/, '');
1270
+ store.write(passwordAccess(password));
1271
+ return 'Access mode: password protected (only a salted scrypt verifier is stored).';
1272
+ }
1273
+ if (noPassword) {
1274
+ store.write({ version: 1, mode: 'none' });
1275
+ return 'WARNING: unprotected mode enabled; anyone who can reach the configured origin can control the fleet.';
1276
+ }
1277
+ if (opts.pairing) {
1278
+ store.write({ version: 1, mode: 'pairing' });
1279
+ return 'Access mode: trusted-browser pairing.';
1280
+ }
1281
+ if (!existsSync(store.path))
1282
+ throw new Error('first web setup requires an explicit access choice: use --password-file <path> '
1283
+ + 'or --pairing for protection, or --no-password only for intentional unprotected access');
1284
+ return undefined;
1285
+ }
1233
1286
  program.command('_run <name>', { hidden: true }).description('internal: supervisor entrypoint')
1234
1287
  .option('-c, --configuration <file>')
1235
1288
  .action(async (name, opts) => {
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] # 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\nThe default is `false`. For a temporary role whose mission intentionally arrives\nafter its readiness announcement, set `mode: fleet` and `interrupt: true`\nexplicitly. The readiness announcement does not change the transport: the\nmission remains ordinary ours mail, fleet injects only the body-free wake, and\nthe role calls `get_messages` before acting. Every later configured wake uses\nthe same interruption policy.\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## Trusted owner channel\n\nAn ACP role may declare a separate, existing ours identity which fleet \u2014 never\nthe agent \u2014 binds:\n\n```yaml\nowner_channel:\n identity: Coordinator Owner Channel\n owners: [authenticated-owner-contact-cid]\n agent: authenticated-managed-agent-cid\n interrupt: false\n progress_interval_ms: 30000\n attachments:\n enabled: true\n max_files_per_request: 4\n max_file_bytes: 10485760\n max_request_bytes: 20971520\n retention_ms: 86400000\n allowed_mime: [application/pdf, text/plain, image/png, audio/ogg]\n```\n\nThis does not replace the role identity. Normal identity mail remains untrusted\npeer input: the agent reads it through `get_messages` and replies through\n`send_message`. Mail arriving on the dedicated channel from a CID in `owners`\nis injected as a direct `[fleet-owner]` prompt. Mail from the exact `agent`\nCID is forwarded as a new message to the latest authenticated owner conversation.\nEvery other CID is rejected and warned about without reflecting its body. Fleet sends\naccepted/queued/progress/interrupted/failure notices and routes the ACP turn's\nfinal assistant text back to the authenticated sender with its source wire ID.\nFor file replies, fleet injects a request-specific outbox path into the owner\nprompt. The agent copies completed artifacts there; fleet sends every regular\nfile from the channel identity with the same source wire ID and removes the\ntemporary outbox only after successful delivery. The agent never chooses an owner\nrecipient or calls ours `send_file` for an owner-channel response.\nOwner messages whose trimmed text starts with `/` are deterministic\nsupervisor commands and never enter the model: `/help` (alias `/commands`),\n`/status`, `/interrupt`, `/clear`, `/compact`, `/model <model-id>`,\n`/restart`, `/force-restart`, `/ls`, `/peek`, `/worklog`, and\n`/version`. Unknown or malformed commands answer with the help text instead of\nbeing forwarded; plain messages reach the agent unchanged. `/clear`,\n`/compact`, and `/model` are forwarded only when the role's bundled ACP\nadapter executes them locally (claude-code: all three; codex: `/compact`\nonly) and are otherwise refused with a notice, so slash text never reaches the\nmodel as a prompt.\n\nOwner documents, images, and voice messages use the same authenticated sender\nand source-wire boundary. Fleet inspects body-free metadata first and rejects\ndisabled, over-count, over-size, or disallowed-MIME requests before selective\nretrieval. Unauthorized CIDs are never retrieved or answered. Reply-linked text\nand files from the same sender become one ordered request; a file-only wake also\nstarts a turn. Retrieved bytes must match their structured size and SHA-256,\ntheir content signature must match the declared MIME, and symlinks or non-regular\npaths fail closed. Sanitized copies live only in a mode-0700 request directory as\nmode-0600 files and are removed after completion or bounded stale retention.\n\nVoice prompts include a bounded transcript only when ours-mcp reports success.\nFailure or unavailability is explicit and preserves the private audio path as the\nfallback. Run `ours-mcp voice-status --json` to inspect the host configuration.\nA mode-0600 crash journal contains only authenticated CID and wire routing data;\nit never stores captions, filenames, paths, transcript text, or bytes. Journaled\npost-retrieval files resume selectively through `save_file`; corrupt state\ndisables attachment admission rather than weakening provenance checks.\n\nThe channel identity must be unique and must not be a role identity. The bridge\npersists bounded wire IDs only, never message/reply plaintext, and requeues input\nbefore starting its turn for at-least-once crash recovery. It currently requires\n`session: acp`: tmux has no structured, turn-correlated final answer, and pane\nscraping cannot provide the same reliable reply guarantee.\n\n### Live contact and owner administration\n\nThe supervisor which is already running the ACP role remains the sole binder of\n`owner_channel.identity`. The CLI reaches that exact live `OwnerChannel`\nthrough the role's token-authenticated, mode-0600 Unix control socket for contact\ninspection and setup; it never starts another ours client and never force-binds:\n\n```sh\nours-fleet owner-channel contact list <Role>\nours-fleet owner-channel contact invite <Role> [--name <label>]\nours-fleet owner-channel contact add <Role> (--invite-file <path> | --invite-stdin) [--name <label>]\nours-fleet owner-channel owner list <Role>\nours-fleet owner-channel owner authorize <Role> <exact-64-hex-contact-cid>\nours-fleet owner-channel owner revoke <Role> <exact-64-hex-contact-cid>\n```\n\nContact establishment and owner authorization are separate security steps.\n`contact add` never authorizes: invite redemption is pending until the peer\nverifies it. Once `contact list` reports the established contact, authorize\nits exact immutable CID explicitly. Invite creation emits invite material only\non stdout; acceptance reads it from a file or stdin, not argv.\n\nConfigured `owners` remain the baseline. On legacy channels without `agent`,\nlive authorizations/revocations are an immediately effective, restart-persistent\noverlay. Managed-agent CID gating makes fleet configuration authoritative and\ndisables live owner mutation and direct control-socket sends. `owner list` labels\nbaseline versus dynamic entries and effective status. The atomic mode-0600 file\ncontains bounded CIDs and audit actions only. Corruption disables all effective\nowners and refuses mutation rather than resurrecting authority; revoking the\nlast effective owner is always refused.\n\nA missing/stopped role, tmux session, role without `owner_channel`, unavailable\nMCP client, or a role entering shutdown returns an actionable error with no\nside effects. Management uses no network listener and never logs or persists\ninvite material.\n\nFor any non-final message\u2014progress, blocker, suggestion, or later proactive note\u2014\nthe managed agent calls ordinary ours `send_message` to the channel identity.\nFleet checks only that the authenticated sender CID exactly equals `agent`, then\nforwards the text as a new message. There is no task/request/update type, phase,\nreply correlation, or owner recipient argument. A sole owner is the safe fallback;\nwith multiple owners and no inbound route history the relay fails closed. Devices\nsharing one identity share its CID; separate owner identities hand off the route\nwhen either sends channel mail. The ACP final is separate: fleet extracts it from\nthe completed turn and deterministically replies to the initiating owner wire.\n\nThe bounded mode-0600 route state stores CIDs, wire IDs, timestamps, delivery state,\nand hashes but never message plaintext. Unauthorized attempts produce a bounded\nCID-only owner warning; attempted bodies are neither reflected nor persisted.\n\nFor a mobile owner, establish the contact first, wait for peer verification,\nauthorize its exact CID, and revoke that same CID when access ends. The bounded\nmode-0600 CID overlay survives supervisor restart and remains fail-closed on\ncorruption. Update bodies remain memory-only. After a crash/restart, unfinished\ndeferred owner input follows the existing at-least-once replay path; the restarted\nsupervisor remains the sole binder.\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 IPv4-loopback-only by default. Both `localhost` and\n`127.0.0.1` are accepted locally. For an nginx/TLS reverse proxy, keep the\ndefault bind and declare the exact browser origin:\n\n`ours-fleet web install --public-origin https://fleet.example.com --password-file /secure/fleet-password`\n\nFleet reads the password file during setup and persists only a salted scrypt\nverifier. New browsers authenticate and retain rotating HttpOnly/SameSite\ntrusted-device credentials. If nginx already authenticates, the operator may\ndeliberately select `--no-password`; the CLI and browser warn that anyone\nreaching the origin can control the fleet. First setup requires an explicit\nchoice: `--password-file` or `--pairing` for protected access, or\n`--no-password` for intentional unprotected access.\n\nUse `--bind ADDRESS` only for an intentional direct listen. A non-loopback\nbind is rejected unless `--public-origin` is also present. Host/Origin checks\nuse the declaration and do not trust forwarded headers. Configure nginx to\nproxy HTTP and WebSocket upgrades to `127.0.0.1:49271` and terminate TLS;\nfleet accepts nginx's loopback upstream Host, so no Host rewrite is required.\nBrowser credentials add Secure for HTTPS, and `revoke-all` invalidates all\ntrusted 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\nThe default is `false`. For a temporary role whose mission intentionally arrives\nafter its readiness announcement, set `mode: fleet` and `interrupt: true`\nexplicitly. The readiness announcement does not change the transport: the\nmission remains ordinary ours mail, fleet injects only the body-free wake, and\nthe role calls `get_messages` before acting. Every later configured wake uses\nthe same interruption policy.\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## Trusted owner channel\n\nAn ACP role may declare a separate, existing ours identity which fleet \u2014 never\nthe agent \u2014 binds:\n\n```yaml\nowner_channel:\n identity: Coordinator Owner Channel\n owners: [authenticated-owner-contact-cid]\n agent: authenticated-managed-agent-cid\n interrupt: false\n progress_interval_ms: 30000\n attachments:\n enabled: true\n max_files_per_request: 4\n max_file_bytes: 10485760\n max_request_bytes: 20971520\n retention_ms: 86400000\n allowed_mime: [application/pdf, text/plain, image/png, audio/ogg]\n```\n\nThis does not replace the role identity. Normal identity mail remains untrusted\npeer input: the agent reads it through `get_messages` and replies through\n`send_message`. Mail arriving on the dedicated channel from a CID in `owners`\nis injected as a direct `[fleet-owner]` prompt. Mail from the exact `agent`\nCID is forwarded as a new message to the latest authenticated owner conversation.\nEvery other CID is rejected and warned about without reflecting its body. Fleet sends\naccepted/queued/progress/interrupted/failure notices and routes the ACP turn's\nfinal assistant text back to the authenticated sender with its source wire ID.\nFor file replies, fleet injects a request-specific outbox path into the owner\nprompt. The agent copies completed artifacts there; fleet sends every regular\nfile from the channel identity with the same source wire ID and removes the\ntemporary outbox only after successful delivery. The agent never chooses an owner\nrecipient or calls ours `send_file` for an owner-channel response.\nOwner messages whose trimmed text starts with `/` are deterministic\nsupervisor commands and never enter the model: `/help` (alias `/commands`),\n`/status`, `/interrupt`, `/clear`, `/compact`, `/model <model-id>`,\n`/restart`, `/force-restart`, `/ls`, `/peek`, `/worklog`, and\n`/version`. Unknown or malformed commands answer with the help text instead of\nbeing forwarded; plain messages reach the agent unchanged. `/clear`,\n`/compact`, and `/model` are forwarded only when the role's bundled ACP\nadapter executes them locally (claude-code: all three; codex: `/compact`\nonly) and are otherwise refused with a notice, so slash text never reaches the\nmodel as a prompt.\n\nOwner documents, images, and voice messages use the same authenticated sender\nand source-wire boundary. Fleet inspects body-free metadata first and rejects\ndisabled, over-count, over-size, or disallowed-MIME requests before selective\nretrieval. Unauthorized CIDs are never retrieved or answered. Reply-linked text\nand files from the same sender become one ordered request; a file-only wake also\nstarts a turn. Retrieved bytes must match their structured size and SHA-256,\ntheir content signature must match the declared MIME, and symlinks or non-regular\npaths fail closed. Sanitized copies live only in a mode-0700 request directory as\nmode-0600 files and are removed after completion or bounded stale retention.\n\nVoice prompts include a bounded transcript only when ours-mcp reports success.\nFailure or unavailability is explicit and preserves the private audio path as the\nfallback. Run `ours-mcp voice-status --json` to inspect the host configuration.\nA mode-0600 crash journal contains only authenticated CID and wire routing data;\nit never stores captions, filenames, paths, transcript text, or bytes. Journaled\npost-retrieval files resume selectively through `save_file`; corrupt state\ndisables attachment admission rather than weakening provenance checks.\n\nThe channel identity must be unique and must not be a role identity. The bridge\npersists bounded wire IDs only, never message/reply plaintext, and requeues input\nbefore starting its turn for at-least-once crash recovery. It currently requires\n`session: acp`: tmux has no structured, turn-correlated final answer, and pane\nscraping cannot provide the same reliable reply guarantee.\n\n### Live contact and owner administration\n\nThe supervisor which is already running the ACP role remains the sole binder of\n`owner_channel.identity`. The CLI reaches that exact live `OwnerChannel`\nthrough the role's token-authenticated, mode-0600 Unix control socket for contact\ninspection and setup; it never starts another ours client and never force-binds:\n\n```sh\nours-fleet owner-channel contact list <Role>\nours-fleet owner-channel contact invite <Role> [--name <label>]\nours-fleet owner-channel contact add <Role> (--invite-file <path> | --invite-stdin) [--name <label>]\nours-fleet owner-channel owner list <Role>\nours-fleet owner-channel owner authorize <Role> <exact-64-hex-contact-cid>\nours-fleet owner-channel owner revoke <Role> <exact-64-hex-contact-cid>\n```\n\nContact establishment and owner authorization are separate security steps.\n`contact add` never authorizes: invite redemption is pending until the peer\nverifies it. Once `contact list` reports the established contact, authorize\nits exact immutable CID explicitly. Invite creation emits invite material only\non stdout; acceptance reads it from a file or stdin, not argv.\n\nConfigured `owners` remain the baseline. On legacy channels without `agent`,\nlive authorizations/revocations are an immediately effective, restart-persistent\noverlay. Managed-agent CID gating makes fleet configuration authoritative and\ndisables live owner mutation and direct control-socket sends. `owner list` labels\nbaseline versus dynamic entries and effective status. The atomic mode-0600 file\ncontains bounded CIDs and audit actions only. Corruption disables all effective\nowners and refuses mutation rather than resurrecting authority; revoking the\nlast effective owner is always refused.\n\nA missing/stopped role, tmux session, role without `owner_channel`, unavailable\nMCP client, or a role entering shutdown returns an actionable error with no\nside effects. Management uses no network listener and never logs or persists\ninvite material.\n\nFor any non-final message\u2014progress, blocker, suggestion, or later proactive note\u2014\nthe managed agent calls ordinary ours `send_message` to the channel identity.\nFleet checks only that the authenticated sender CID exactly equals `agent`, then\nforwards the text as a new message. There is no task/request/update type, phase,\nreply correlation, or owner recipient argument. A sole owner is the safe fallback;\nwith multiple owners and no inbound route history the relay fails closed. Devices\nsharing one identity share its CID; separate owner identities hand off the route\nwhen either sends channel mail. The ACP final is separate: fleet extracts it from\nthe completed turn and deterministically replies to the initiating owner wire.\n\nThe bounded mode-0600 route state stores CIDs, wire IDs, timestamps, delivery state,\nand hashes but never message plaintext. Unauthorized attempts produce a bounded\nCID-only owner warning; attempted bodies are neither reflected nor persisted.\n\nFor a mobile owner, establish the contact first, wait for peer verification,\nauthorize its exact CID, and revoke that same CID when access ends. The bounded\nmode-0600 CID overlay survives supervisor restart and remains fail-closed on\ncorruption. Update bodies remain memory-only. After a crash/restart, unfinished\ndeferred owner input follows the existing at-least-once replay path; the restarted\nsupervisor remains the sole binder.\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
@@ -74,10 +74,27 @@ ours-fleet web uninstall
74
74
  ours-fleet web serve --port 0 --no-open # isolated foreground/testing mode
75
75
  \`\`\`
76
76
 
77
- The console is intentionally IPv4-loopback-only. It has no LAN/Internet host,
78
- proxy, TLS, or remote-access mode. Do not expose port 49271 through a reverse
79
- proxy. Browser credentials are HttpOnly/SameSite, and \`revoke-all\` invalidates
80
- all trusted devices. Role creation offers harness-scoped known-model choices
77
+ The console is IPv4-loopback-only by default. Both \`localhost\` and
78
+ \`127.0.0.1\` are accepted locally. For an nginx/TLS reverse proxy, keep the
79
+ default bind and declare the exact browser origin:
80
+
81
+ \`ours-fleet web install --public-origin https://fleet.example.com --password-file /secure/fleet-password\`
82
+
83
+ Fleet reads the password file during setup and persists only a salted scrypt
84
+ verifier. New browsers authenticate and retain rotating HttpOnly/SameSite
85
+ trusted-device credentials. If nginx already authenticates, the operator may
86
+ deliberately select \`--no-password\`; the CLI and browser warn that anyone
87
+ reaching the origin can control the fleet. First setup requires an explicit
88
+ choice: \`--password-file\` or \`--pairing\` for protected access, or
89
+ \`--no-password\` for intentional unprotected access.
90
+
91
+ Use \`--bind ADDRESS\` only for an intentional direct listen. A non-loopback
92
+ bind is rejected unless \`--public-origin\` is also present. Host/Origin checks
93
+ use the declaration and do not trust forwarded headers. Configure nginx to
94
+ proxy HTTP and WebSocket upgrades to \`127.0.0.1:49271\` and terminate TLS;
95
+ fleet accepts nginx's loopback upstream Host, so no Host rewrite is required.
96
+ Browser credentials add Secure for HTTPS, and \`revoke-all\` invalidates all
97
+ trusted devices. Role creation offers harness-scoped known-model choices
81
98
  while still accepting a typed model ID; blank explicitly uses the selected
82
99
  harness's own default.
83
100
 
package/dist/monitor.js CHANGED
@@ -4,7 +4,12 @@ import { join } from 'node:path';
4
4
  import { classifyFailureText } from './model-recovery.js';
5
5
  // Code constants (not config — YAGNI, design §2).
6
6
  const DEFAULT_PORT = 3050;
7
- const LONGPOLL_TIMEOUT_MS = 35_000; // > the daemon's 25s hold
7
+ // The daemon normally holds for 25s, but that value is operator-configurable
8
+ // and synchronous daemon work can delay the response. The former 35s timer
9
+ // repeatedly cancelled a healthy local stream and then reported its own abort
10
+ // as a connectivity failure. Keep a generous stall detector so a genuinely
11
+ // wedged connection is still visible, with an explicit diagnostic.
12
+ const LONGPOLL_STALL_MS = 120_000;
8
13
  const COALESCE_HOLD_MS = 500; // straggler poll must not block
9
14
  const BOOT_GRACE_MS = 15_000; // hold injection until the TUI is up
10
15
  const POST_VERIFY_MS = 1_000;
@@ -375,7 +380,7 @@ export class Monitor {
375
380
  return;
376
381
  }
377
382
  try {
378
- const body = await this.doFetch('tip', LONGPOLL_TIMEOUT_MS);
383
+ const body = await this.doFetch('tip', LONGPOLL_STALL_MS, 'stall');
379
384
  this.cursor = typeof body.cursor === 'number' ? body.cursor : 0;
380
385
  this.persistCursor();
381
386
  this.writeStatus();
@@ -405,7 +410,7 @@ export class Monitor {
405
410
  }
406
411
  let body;
407
412
  try {
408
- body = await this.doFetch(String(this.cursor ?? 0), LONGPOLL_TIMEOUT_MS);
413
+ body = await this.doFetch(String(this.cursor ?? 0), LONGPOLL_STALL_MS, 'stall');
409
414
  backoff = 0;
410
415
  // A poll that worked proves the stream is healthy — and only that.
411
416
  this.recover('connectivity');
@@ -467,7 +472,7 @@ export class Monitor {
467
472
  if (this.stopped)
468
473
  return;
469
474
  try {
470
- const more = await this.doFetch(String(this.cursor ?? 0), COALESCE_HOLD_MS);
475
+ const more = await this.doFetch(String(this.cursor ?? 0), COALESCE_HOLD_MS, 'coalesce');
471
476
  this.advance(more.cursor, false);
472
477
  appendUniqueEvents(batch, filterEvents(more.events ?? [], this.cfg.wake_sources));
473
478
  }
@@ -637,14 +642,20 @@ export class Monitor {
637
642
  await this.deps.sleep(MODAL_RETRY_MS);
638
643
  }
639
644
  }
640
- async doFetch(since, holdMs) {
645
+ async doFetch(since, timeoutMs, timeoutKind) {
641
646
  const ctrl = new AbortController();
642
647
  this.currentAbort = ctrl;
643
- const timer = this.deps.timers.set(() => ctrl.abort(), holdMs);
648
+ let timedOut = false;
649
+ const timer = this.deps.timers.set(() => { timedOut = true; ctrl.abort(); }, timeoutMs);
644
650
  let resp;
645
651
  try {
646
652
  resp = await this.deps.fetch(`${this.ep.url(this.identity)}?since=${since}`, { headers: this.ep.headers, signal: ctrl.signal });
647
653
  }
654
+ catch (error) {
655
+ if (timedOut && timeoutKind === 'stall')
656
+ throw new Error(`notification stream stalled for ${Math.round(timeoutMs / 1000)}s`);
657
+ throw error;
658
+ }
648
659
  finally {
649
660
  this.deps.timers.clear(timer);
650
661
  this.currentAbort = null;
@@ -0,0 +1,19 @@
1
+ export type WebAccessMode = 'pairing' | 'password' | 'none';
2
+ export interface WebAccessConfig {
3
+ version: 1;
4
+ mode: WebAccessMode;
5
+ password?: {
6
+ salt: string;
7
+ hash: string;
8
+ };
9
+ }
10
+ export declare function passwordAccess(password: string): WebAccessConfig;
11
+ export declare function verifyPassword(config: WebAccessConfig, supplied: string): boolean;
12
+ export declare class WebAccessStore {
13
+ private readonly dir;
14
+ readonly path: string;
15
+ constructor(dir?: string);
16
+ read(): WebAccessConfig;
17
+ write(config: WebAccessConfig): void;
18
+ }
19
+ export declare function validatePublicOrigin(value: string): URL;
@@ -0,0 +1,70 @@
1
+ import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
2
+ import { chmodSync, lstatSync, mkdirSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { replaceFileAtomically } from '../atomic-file.js';
5
+ import { FleetError } from '../application/errors.js';
6
+ import { stateRoot } from '../paths.js';
7
+ const DEFAULT = { version: 1, mode: 'pairing' };
8
+ const b64 = /^[A-Za-z0-9_-]{20,128}$/;
9
+ export function passwordAccess(password) {
10
+ if (password.length < 12 || Buffer.byteLength(password) > 1024)
11
+ throw new FleetError('invalid_request', 'control-panel password must be 12–1024 bytes');
12
+ const salt = randomBytes(16).toString('base64url');
13
+ return { version: 1, mode: 'password', password: {
14
+ salt, hash: scryptSync(password, salt, 32).toString('base64url'),
15
+ } };
16
+ }
17
+ export function verifyPassword(config, supplied) {
18
+ if (config.mode !== 'password' || !config.password || Buffer.byteLength(supplied) > 1024)
19
+ return false;
20
+ const actual = scryptSync(supplied, config.password.salt, 32);
21
+ const expected = Buffer.from(config.password.hash, 'base64url');
22
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
23
+ }
24
+ export class WebAccessStore {
25
+ dir;
26
+ path;
27
+ constructor(dir = join(stateRoot(), 'web')) {
28
+ this.dir = dir;
29
+ this.path = join(dir, 'access.json');
30
+ }
31
+ read() {
32
+ try {
33
+ const stat = lstatSync(this.path);
34
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 16 * 1024)
35
+ throw new FleetError('forbidden', 'web access configuration is not a safe regular file');
36
+ chmodSync(this.path, 0o600);
37
+ const value = JSON.parse(readFileSync(this.path, 'utf8'));
38
+ if (value.version !== 1 || !['pairing', 'password', 'none'].includes(value.mode ?? ''))
39
+ throw new FleetError('forbidden', 'web access configuration is invalid');
40
+ if (value.mode === 'password' && (!value.password || !b64.test(value.password.salt)
41
+ || !b64.test(value.password.hash)))
42
+ throw new FleetError('forbidden', 'web password configuration is invalid');
43
+ return value;
44
+ }
45
+ catch (error) {
46
+ if (error.code === 'ENOENT')
47
+ return DEFAULT;
48
+ throw error;
49
+ }
50
+ }
51
+ write(config) {
52
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
53
+ chmodSync(this.dir, 0o700);
54
+ replaceFileAtomically(this.path, JSON.stringify(config, null, 2) + '\n', 0o600);
55
+ chmodSync(this.path, 0o600);
56
+ }
57
+ }
58
+ export function validatePublicOrigin(value) {
59
+ let origin;
60
+ try {
61
+ origin = new URL(value);
62
+ }
63
+ catch {
64
+ throw new FleetError('invalid_request', 'public origin must be an absolute http(s) URL');
65
+ }
66
+ if (!['http:', 'https:'].includes(origin.protocol) || origin.username || origin.password
67
+ || origin.pathname !== '/' || origin.search || origin.hash)
68
+ throw new FleetError('invalid_request', 'public origin must contain only scheme, host, and optional port');
69
+ return origin;
70
+ }
@@ -1,6 +1,7 @@
1
1
  import type { FastifyRequest } from 'fastify';
2
2
  import type { WebSocket } from 'ws';
3
3
  import { TrustedDeviceStore, type TrustedDeviceIssue } from './device-store.js';
4
+ import { type WebAccessConfig } from './access.js';
4
5
  export interface BrowserSession {
5
6
  id: string;
6
7
  csrf: string;
@@ -24,6 +25,7 @@ export declare class WebAuth {
24
25
  private _host;
25
26
  private readonly now;
26
27
  private readonly devices;
28
+ private readonly access;
27
29
  private _bootstrapSecret;
28
30
  private bootstrapExpiresAt;
29
31
  private bootstrapUsed;
@@ -32,15 +34,24 @@ export declare class WebAuth {
32
34
  private readonly tickets;
33
35
  private readonly rates;
34
36
  private readonly sockets;
35
- constructor(_origin: string, _host: string, now?: () => number, devices?: TrustedDeviceStore);
37
+ constructor(_origin: string, _host: string, now?: () => number, devices?: TrustedDeviceStore, access?: WebAccessConfig);
36
38
  get bootstrapSecret(): string;
37
39
  get origin(): string;
38
40
  get host(): string;
39
- setBoundary(origin: string, host: string): void;
41
+ get mode(): WebAccessConfig['mode'];
42
+ get secureCookies(): boolean;
43
+ private allowedOrigins;
44
+ private allowedHosts;
45
+ setBoundary(origin: string, host: string, aliases?: {
46
+ origins?: string[];
47
+ hosts?: string[];
48
+ }): void;
40
49
  /** Mint a replacement for an operator-triggered reauthentication ceremony. */
41
50
  mintBootstrap(): string;
42
51
  validateBoundary(request: FastifyRequest, requireOrigin: boolean): void;
43
52
  exchange(request: FastifyRequest): AuthResult;
53
+ login(request: FastifyRequest, password: string): AuthResult;
54
+ anonymous(request: FastifyRequest): BrowserSession;
44
55
  resume(request: FastifyRequest): AuthResult;
45
56
  authenticate(request: FastifyRequest, mutation?: boolean): BrowserSession;
46
57
  logout(request: FastifyRequest): void;