@ours.network/fleet 1.2.0-nightly.5 → 1.2.0-nightly.51
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 +263 -53
- package/dist/account-origin.d.ts +2 -0
- package/dist/account-origin.js +6 -0
- package/dist/agent-ours/bridge.js +9 -0
- package/dist/agent-ours/correspondence.d.ts +10 -0
- package/dist/agent-ours/correspondence.js +1 -0
- package/dist/agent-ours/harness.js +8 -0
- package/dist/agent-ours/runtime.d.ts +6 -0
- package/dist/agent-ours/runtime.js +36 -0
- package/dist/agent-ours/service.js +33 -13
- package/dist/application/codex-models.d.ts +15 -0
- package/dist/application/codex-models.js +56 -0
- package/dist/application/conversation-history.d.ts +25 -0
- package/dist/application/conversation-history.js +189 -0
- package/dist/application/fleet-query-service.d.ts +1 -1
- package/dist/application/fleet-query-service.js +2 -2
- package/dist/application/legacy-supervisor-identity.d.ts +7 -0
- package/dist/application/legacy-supervisor-identity.js +43 -0
- package/dist/application/model-catalog.d.ts +1 -1
- package/dist/application/model-catalog.js +5 -2
- package/dist/application/preset-provenance.d.ts +19 -0
- package/dist/application/preset-provenance.js +59 -0
- package/dist/application/role-creation-service.js +1 -1
- package/dist/application/role-removal-service.js +2 -0
- package/dist/application/role-repository.js +2 -2
- package/dist/application/room-layout-definitions.d.ts +45 -0
- package/dist/application/room-layout-definitions.js +130 -0
- package/dist/application/session-control.d.ts +30 -0
- package/dist/application/session-control.js +72 -3
- package/dist/application/supervisor-ours-tools.d.ts +159 -0
- package/dist/application/supervisor-ours-tools.js +113 -0
- package/dist/application/task-layouts.d.ts +91 -0
- package/dist/application/task-layouts.js +249 -0
- package/dist/application/task-room-service.d.ts +99 -3
- package/dist/application/task-room-service.js +296 -31
- package/dist/atomic-file.d.ts +5 -0
- package/dist/atomic-file.js +62 -1
- package/dist/briefing.d.ts +2 -0
- package/dist/briefing.js +8 -1
- package/dist/build-info.json +7 -4
- package/dist/capabilities.d.ts +6 -1
- package/dist/capabilities.js +8 -0
- package/dist/cli.js +376 -34
- package/dist/client-profile.d.ts +5 -17
- package/dist/client-profile.js +4 -127
- package/dist/config.d.ts +28 -0
- package/dist/config.js +41 -6
- package/dist/creation.js +2 -2
- package/dist/daemon-recovery.d.ts +1 -8
- package/dist/daemon-recovery.js +8 -113
- package/dist/docs.d.ts +3 -3
- package/dist/docs.js +126 -22
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +55 -60
- package/dist/erased-resources.d.ts +9 -0
- package/dist/erased-resources.js +87 -0
- package/dist/fleet-command-audit.d.ts +7 -0
- package/dist/fleet-command-audit.js +26 -9
- package/dist/harness/claude-code.js +9 -1
- package/dist/harness/claude-runtime.d.ts +12 -0
- package/dist/harness/claude-runtime.js +43 -0
- package/dist/harness/codex-app-server-proxy.d.ts +4 -1
- package/dist/harness/codex-app-server-proxy.js +41 -5
- package/dist/harness/codex-reasoning.d.ts +5 -0
- package/dist/harness/codex-reasoning.js +12 -0
- package/dist/harness/codex-runtime.d.ts +11 -1
- package/dist/harness/codex-runtime.js +31 -4
- package/dist/harness/codex.d.ts +3 -2
- package/dist/harness/codex.js +20 -11
- package/dist/init-wizard.d.ts +2 -0
- package/dist/init-wizard.js +38 -19
- package/dist/isolation/policy.js +2 -0
- package/dist/isolation/types.d.ts +5 -0
- package/dist/managed-cli.d.ts +253 -0
- package/dist/managed-cli.js +923 -0
- package/dist/minimal-setup.d.ts +2 -0
- package/dist/minimal-setup.js +44 -0
- package/dist/monitor.d.ts +1 -34
- package/dist/monitor.js +24 -151
- package/dist/notifications/fleet-producer.d.ts +19 -0
- package/dist/notifications/fleet-producer.js +208 -0
- package/dist/notifications/outbox.d.ts +46 -0
- package/dist/notifications/outbox.js +158 -0
- package/dist/notifications/target-cleanup.d.ts +8 -0
- package/dist/notifications/target-cleanup.js +41 -0
- package/dist/ops.js +5 -1
- package/dist/owner-channel/channel.d.ts +3 -0
- package/dist/owner-channel/channel.js +34 -6
- package/dist/owner-channel/commands.d.ts +4 -0
- package/dist/owner-channel/commands.js +8 -0
- package/dist/owner-channel/lifecycle-outbox.js +4 -3
- package/dist/owner-channel/ours-client.js +2 -17
- package/dist/paths.d.ts +2 -0
- package/dist/paths.js +2 -0
- package/dist/preset-migration.js +25 -0
- package/dist/resolved-plan.js +1 -0
- package/dist/rooms-tasks/add-member.d.ts +48 -0
- package/dist/rooms-tasks/add-member.js +83 -0
- package/dist/rooms-tasks/archived-absence.d.ts +8 -0
- package/dist/rooms-tasks/archived-absence.js +48 -0
- package/dist/rooms-tasks/cli.js +34 -5
- package/dist/rooms-tasks/close.d.ts +5 -2
- package/dist/rooms-tasks/close.js +208 -28
- package/dist/rooms-tasks/config.d.ts +1 -1
- package/dist/rooms-tasks/config.js +11 -4
- package/dist/rooms-tasks/cowork-adapter.d.ts +6 -4
- package/dist/rooms-tasks/cowork-adapter.js +8 -121
- package/dist/rooms-tasks/deletion.d.ts +1 -1
- package/dist/rooms-tasks/deletion.js +74 -5
- package/dist/rooms-tasks/erasure.d.ts +6 -0
- package/dist/rooms-tasks/erasure.js +169 -0
- package/dist/rooms-tasks/external-worker.d.ts +1 -1
- package/dist/rooms-tasks/external-worker.js +3 -4
- package/dist/rooms-tasks/layout-binding-client.d.ts +11 -0
- package/dist/rooms-tasks/layout-binding-client.js +56 -0
- package/dist/rooms-tasks/layout-binding-grants.d.ts +21 -0
- package/dist/rooms-tasks/layout-binding-grants.js +128 -0
- package/dist/rooms-tasks/layout-cli.d.ts +4 -0
- package/dist/rooms-tasks/layout-cli.js +107 -0
- package/dist/rooms-tasks/layout-config.d.ts +11 -0
- package/dist/rooms-tasks/layout-config.js +89 -0
- package/dist/rooms-tasks/layout-control.d.ts +21 -0
- package/dist/rooms-tasks/layout-control.js +52 -0
- package/dist/rooms-tasks/layout-service.d.ts +42 -0
- package/dist/rooms-tasks/layout-service.js +185 -0
- package/dist/rooms-tasks/layout.d.ts +114 -0
- package/dist/rooms-tasks/layout.js +250 -0
- package/dist/rooms-tasks/live-readiness.d.ts +18 -0
- package/dist/rooms-tasks/live-readiness.js +85 -0
- package/dist/rooms-tasks/member-startup.d.ts +1 -0
- package/dist/rooms-tasks/member-startup.js +2 -0
- package/dist/rooms-tasks/provision.d.ts +14 -0
- package/dist/rooms-tasks/provision.js +173 -46
- package/dist/rooms-tasks/room-state.d.ts +4 -2
- package/dist/rooms-tasks/room-state.js +53 -8
- package/dist/rooms-tasks/task-state.d.ts +42 -7
- package/dist/rooms-tasks/task-state.js +153 -23
- package/dist/rooms-tasks/types.d.ts +28 -0
- package/dist/rooms-tasks/workspace-artifacts.d.ts +6 -0
- package/dist/rooms-tasks/workspace-artifacts.js +93 -0
- package/dist/rooms-tasks/workspace.d.ts +26 -0
- package/dist/rooms-tasks/workspace.js +195 -0
- package/dist/runner.d.ts +19 -3
- package/dist/runner.js +128 -45
- package/dist/session/acp.d.ts +9 -1
- package/dist/session/acp.js +31 -6
- package/dist/session/arbiter.d.ts +5 -0
- package/dist/session/arbiter.js +28 -2
- package/dist/session/codex-app-server.d.ts +5 -0
- package/dist/session/codex-app-server.js +36 -3
- package/dist/session/control.d.ts +12 -1
- package/dist/session/control.js +68 -3
- package/dist/session/conversation-normalizer.d.ts +2 -1
- package/dist/session/conversation-normalizer.js +1 -1
- package/dist/session/conversation-store.d.ts +1 -1
- package/dist/session/conversation-store.js +7 -11
- package/dist/session/conversation-types.d.ts +7 -3
- package/dist/session/task-notice.d.ts +14 -0
- package/dist/session/task-notice.js +13 -0
- package/dist/session/types.d.ts +6 -0
- package/dist/setup-tunnel-preflight.d.ts +34 -0
- package/dist/setup-tunnel-preflight.js +62 -0
- package/dist/socket-path.d.ts +2 -0
- package/dist/socket-path.js +22 -0
- package/dist/spawn.js +8 -1
- package/dist/startup-readiness.d.ts +13 -0
- package/dist/startup-readiness.js +38 -0
- package/dist/subscriptions/cli.d.ts +91 -0
- package/dist/subscriptions/cli.js +223 -0
- package/dist/subscriptions/launch.d.ts +37 -0
- package/dist/subscriptions/launch.js +93 -0
- package/dist/subscriptions/login.d.ts +75 -0
- package/dist/subscriptions/login.js +182 -0
- package/dist/subscriptions/observed.d.ts +7 -0
- package/dist/subscriptions/observed.js +44 -0
- package/dist/subscriptions/probe.d.ts +10 -0
- package/dist/subscriptions/probe.js +73 -0
- package/dist/subscriptions/service.d.ts +102 -0
- package/dist/subscriptions/service.js +364 -0
- package/dist/subscriptions/store.d.ts +71 -0
- package/dist/subscriptions/store.js +140 -0
- package/dist/supervisor/launchd.js +2 -1
- package/dist/supervisor/systemd.js +9 -23
- package/dist/temp-idle.d.ts +17 -0
- package/dist/temp-idle.js +42 -0
- package/dist/temp-lifecycle.d.ts +6 -1
- package/dist/temp-lifecycle.js +29 -13
- package/dist/tunnel-setup-completion.d.ts +7 -0
- package/dist/tunnel-setup-completion.js +13 -0
- package/dist/tunnel-setup-input.d.ts +19 -0
- package/dist/tunnel-setup-input.js +124 -0
- package/dist/web/agent-attachments.d.ts +29 -0
- package/dist/web/agent-attachments.js +162 -0
- package/dist/web/auth.d.ts +29 -1
- package/dist/web/auth.js +82 -7
- package/dist/web/available-port.d.ts +2 -0
- package/dist/web/available-port.js +23 -0
- package/dist/web/control.d.ts +3 -3
- package/dist/web/control.js +10 -8
- package/dist/web/fleet-config-service.d.ts +4 -1
- package/dist/web/fleet-config-service.js +46 -15
- package/dist/web/prefix-gateway.d.ts +32 -0
- package/dist/web/prefix-gateway.js +234 -0
- package/dist/web/runtime.d.ts +3 -1
- package/dist/web/runtime.js +116 -31
- package/dist/web/server.d.ts +16 -0
- package/dist/web/server.js +592 -53
- package/dist/web/service.d.ts +11 -2
- package/dist/web/service.js +30 -4
- package/dist/web/workspace-devices.d.ts +36 -0
- package/dist/web/workspace-devices.js +101 -0
- package/dist/web/workspace-tunnel.d.ts +1 -0
- package/dist/web/workspace-tunnel.js +34 -0
- package/dist/workspace-enrollment.d.ts +82 -0
- package/dist/workspace-enrollment.js +566 -0
- package/dist/workspace-replacement.d.ts +66 -0
- package/dist/workspace-replacement.js +220 -0
- package/docs/live-voice.md +13 -0
- package/docs/room-layouts.md +227 -0
- package/docs/task-workspaces.md +130 -0
- package/docs/validation/managed-cli-permissions.md +421 -0
- package/examples/room-layouts/fresh.yaml +27 -0
- package/examples/room-layouts/scoped.yaml +21 -0
- package/examples/room-layouts/shared.yaml +19 -0
- package/package.json +15 -18
- package/presets/README.md +78 -0
- package/presets/brain-catalog.json +8 -2
- package/presets/fleet/agent_templates/Critic.yaml +1 -1
- package/presets/fleet/agent_templates/Developer.yaml +1 -1
- package/presets/fleet/agent_templates/Engineer.yaml +5 -0
- package/presets/fleet/agent_templates/LocalCoordinator.yaml +1 -1
- package/presets/fleet/agents/FleetCoordinator.yaml +1 -1
- package/presets/fleet/brains/claude-fable-5-1-high.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-1-low.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-1-max.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-1-medium.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-1-xhigh.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-high.yaml +1 -1
- package/presets/fleet/brains/claude-fable-5-low.yaml +1 -1
- package/presets/fleet/brains/claude-fable-5-max.yaml +1 -1
- package/presets/fleet/brains/claude-fable-5-medium.yaml +1 -1
- package/presets/fleet/brains/claude-fable-5-xhigh.yaml +1 -1
- package/presets/fleet/brains/claude-haiku-4-5-20251001-default.yaml +4 -0
- package/presets/fleet/brains/claude-opus-5-5-high.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-5-low.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-5-max.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-5-medium.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-5-xhigh.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-high.yaml +1 -1
- package/presets/fleet/brains/claude-opus-5-low.yaml +1 -1
- package/presets/fleet/brains/claude-opus-5-max.yaml +1 -1
- package/presets/fleet/brains/claude-opus-5-medium.yaml +1 -1
- package/presets/fleet/brains/claude-opus-5-xhigh.yaml +1 -1
- package/presets/fleet/brains/claude-sonnet-5-high.yaml +1 -1
- package/presets/fleet/brains/claude-sonnet-5-low.yaml +1 -1
- package/presets/fleet/brains/claude-sonnet-5-max.yaml +1 -1
- package/presets/fleet/brains/claude-sonnet-5-medium.yaml +1 -1
- package/presets/fleet/brains/claude-sonnet-5-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-mini-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-mini-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-mini-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-mini-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-5-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-5-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-5-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-5-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-luna-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-luna-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-luna-max.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-luna-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-luna-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-sol-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-sol-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-sol-max.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-sol-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-sol-ultra.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-sol-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-terra-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-terra-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-terra-max.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-terra-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-terra-ultra.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-terra-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-1-sol-high.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-1-sol-low.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-1-sol-max.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-1-sol-medium.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-1-sol-ultra.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-1-sol-xhigh.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-astra-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-astra-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-astra-max.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-astra-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-astra-ultra.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-astra-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-luna-high.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-luna-low.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-luna-max.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-luna-medium.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-luna-xhigh.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-sol-high.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-sol-low.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-sol-max.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-sol-medium.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-sol-ultra.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-sol-xhigh.yaml +5 -0
- package/presets/fleet/roles/Coordinator.yaml +34 -11
- package/presets/fleet/roles/Engineer.yaml +40 -0
- package/presets/fleet/room_layouts/engineering.yaml +25 -0
- package/presets/fleet/room_layouts/pair.yaml +24 -0
- package/presets/fleet/room_layouts/single.yaml +17 -0
- package/presets/fleet/room_layouts/team.yaml +30 -0
- package/presets/fleet/room_templates/engineering.yaml +19 -0
- package/presets/manifest.json +2 -2
- package/dist/web-app/assets/index-59GF-gsZ.css +0 -1
- package/dist/web-app/assets/index-C0h3ALvs.js +0 -10
- package/dist/web-app/icons/ours-fleet-maskable.svg +0 -4
- package/dist/web-app/icons/ours-fleet.svg +0 -4
- package/dist/web-app/index.html +0 -17
- package/dist/web-app/manifest.webmanifest +0 -15
- package/dist/web-app/offline.html +0 -18
- package/dist/web-app/sw.js +0 -51
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fleet-owned setup of native harness execution policy for the managed CLI.
|
|
3
|
+
*
|
|
4
|
+
* A managed `ours-fleet` call audits through its supervisor's Unix socket before
|
|
5
|
+
* it parses anything, so a harness command sandbox can refuse even `--help`
|
|
6
|
+
* (#231). Fleet prescribes the Coordinator's task workflow and knows the node
|
|
7
|
+
* binary, CLI and configuration a launch uses, so it — not the operator, and not
|
|
8
|
+
* the installer — derives the native rules that let exactly those invocations
|
|
9
|
+
* run outside the command sandbox.
|
|
10
|
+
*
|
|
11
|
+
* What this is NOT: argument validation, target authorization or per-role
|
|
12
|
+
* isolation. A native rule matches an argv PREFIX and admits every trailing
|
|
13
|
+
* argument. The one thing Fleet adds on top is the pinned entry form below,
|
|
14
|
+
* which refuses a different configuration after the process has started.
|
|
15
|
+
*/
|
|
16
|
+
import { spawnSync } from 'node:child_process';
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, } from 'node:fs';
|
|
20
|
+
import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { parseDocument } from 'yaml';
|
|
23
|
+
import { replaceFileAtomically, withSynchronousFileLock } from './atomic-file.js';
|
|
24
|
+
import { erasedArg } from './erased-resources.js';
|
|
25
|
+
import { managedPinMarker } from './fleet-command-audit.js';
|
|
26
|
+
import { MANAGED_CLI_WORKFLOW_IDS, splitRootFor, } from './config.js';
|
|
27
|
+
import { resolveBundledAcpAgent } from './harness/acp-agent.js';
|
|
28
|
+
import { executableOnPath, hostCodex } from './harness/codex-runtime.js';
|
|
29
|
+
import { isManagedCliCodexConfig } from './harness/codex-reasoning.js';
|
|
30
|
+
import { getAdapter } from './harness/registry.js';
|
|
31
|
+
import { agentDir, defaultConfigPath, home, stateRoot } from './paths.js';
|
|
32
|
+
/** Capability-gated by the installer; see src/capabilities.ts. */
|
|
33
|
+
export const MANAGED_CONFIGURATION_FLAG = '--managed-configuration';
|
|
34
|
+
/** What the last launch of a role actually prepared. Lives in the role's state dir. */
|
|
35
|
+
export const MANAGED_CLI_RECORD = '.managed-cli.json';
|
|
36
|
+
const RULES_MARKER = '# ours-fleet managed-cli v1 ';
|
|
37
|
+
/** Quoted path tokens were refused by Codex 0.159.0 (#231) and granted by 0.160.0. */
|
|
38
|
+
export const CODEX_QUOTED_PATH_MINIMUM = '0.160.0';
|
|
39
|
+
const form = (tokens, effect, configuration) => ({ tokens: tokens.split(' '), effect, configuration });
|
|
40
|
+
export const MANAGED_CLI_WORKFLOWS = {
|
|
41
|
+
'task-workflow': {
|
|
42
|
+
description: "the packaged Coordinator's task workflow",
|
|
43
|
+
forms: [
|
|
44
|
+
form('--help', 'help', false),
|
|
45
|
+
form('task --help', 'help', false),
|
|
46
|
+
form('room --help', 'help', false),
|
|
47
|
+
form('template --help', 'help', false),
|
|
48
|
+
form('docs', 'help', false),
|
|
49
|
+
form('config', 'read', true),
|
|
50
|
+
form('template list', 'read', true),
|
|
51
|
+
form('template show', 'read', true),
|
|
52
|
+
form('template validate', 'read', true),
|
|
53
|
+
form('task list', 'read', true),
|
|
54
|
+
form('task show', 'read', true),
|
|
55
|
+
form('room show', 'read', true),
|
|
56
|
+
form('room members', 'read', true),
|
|
57
|
+
form('task create', 'lifecycle', true),
|
|
58
|
+
form('task start', 'lifecycle', true),
|
|
59
|
+
form('task finish', 'lifecycle', true),
|
|
60
|
+
// These three take no configuration option: they act on the task state of
|
|
61
|
+
// the Fleet home the supervisor's environment selects.
|
|
62
|
+
form('task block', 'lifecycle', false),
|
|
63
|
+
form('task unblock', 'lifecycle', false),
|
|
64
|
+
form('task review', 'lifecycle', false),
|
|
65
|
+
],
|
|
66
|
+
excluded: [
|
|
67
|
+
'spawn', 'ours tools/call', 'status/peek/send', 'room create/delete/close',
|
|
68
|
+
'task cancel/delete', 'service administration',
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
const allForms = (workflows) => {
|
|
73
|
+
const seen = new Map();
|
|
74
|
+
for (const id of workflows)
|
|
75
|
+
for (const entry of MANAGED_CLI_WORKFLOWS[id].forms)
|
|
76
|
+
seen.set(entry.tokens.join(' '), entry);
|
|
77
|
+
return [...seen.values()];
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Recognise the pinned entry form in `argv` (process.argv.slice(2)) and return
|
|
81
|
+
* the ordinary argv Commander should parse.
|
|
82
|
+
*
|
|
83
|
+
* The pin must be the first argument: that is the only position a native prefix
|
|
84
|
+
* rule can fix. For a command that takes `--configuration`, the pinned file is
|
|
85
|
+
* injected directly after the command tokens; a later, different value is
|
|
86
|
+
* refused by `assertPinnedConfiguration` on the PARSED option, so `-c`,
|
|
87
|
+
* `-cFILE`, `--configuration=FILE` and repeats need no token heuristics here.
|
|
88
|
+
*/
|
|
89
|
+
export function parseManagedEntry(argv) {
|
|
90
|
+
if (argv[0] !== MANAGED_CONFIGURATION_FLAG) {
|
|
91
|
+
const terminator = argv.indexOf('--');
|
|
92
|
+
const options = terminator < 0 ? argv : argv.slice(0, terminator);
|
|
93
|
+
if (options.some(arg => arg === MANAGED_CONFIGURATION_FLAG
|
|
94
|
+
|| arg.startsWith(`${MANAGED_CONFIGURATION_FLAG}=`)))
|
|
95
|
+
return { kind: 'error', message: `${MANAGED_CONFIGURATION_FLAG} must be the first argument, `
|
|
96
|
+
+ 'followed by the absolute configuration path as a separate argument' };
|
|
97
|
+
return { kind: 'none' };
|
|
98
|
+
}
|
|
99
|
+
const configuration = argv[1];
|
|
100
|
+
if (!configuration || !isAbsolute(configuration) || resolve(configuration) !== configuration)
|
|
101
|
+
return { kind: 'error', message: `${MANAGED_CONFIGURATION_FLAG} requires a normalized absolute configuration path` };
|
|
102
|
+
const rest = argv.slice(2);
|
|
103
|
+
const matched = allForms(MANAGED_CLI_WORKFLOW_IDS)
|
|
104
|
+
.filter(entry => entry.tokens.every((token, index) => rest[index] === token))
|
|
105
|
+
.sort((a, b) => b.tokens.length - a.tokens.length)[0];
|
|
106
|
+
if (!matched) {
|
|
107
|
+
const shown = rest.slice(0, 2).filter(arg => !arg.startsWith('-') || arg === '--help').join(' ') || '<none>';
|
|
108
|
+
return { kind: 'error', message: `'${shown}' is not part of a Fleet-prepared managed workflow; `
|
|
109
|
+
+ `prepared commands: ${allForms(MANAGED_CLI_WORKFLOW_IDS).map(entry => entry.tokens.join(' ')).join(', ')}. `
|
|
110
|
+
+ `Run other commands without ${MANAGED_CONFIGURATION_FLAG}; they stay subject to the command sandbox` };
|
|
111
|
+
}
|
|
112
|
+
const tail = rest.slice(matched.tokens.length);
|
|
113
|
+
return {
|
|
114
|
+
kind: 'pinned', configuration, form: matched,
|
|
115
|
+
argv: matched.configuration
|
|
116
|
+
? [...matched.tokens, '--configuration', configuration, ...tail] : [...rest],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** Refuse a parsed `--configuration` that is not the pinned file. Throws a plain Error. */
|
|
120
|
+
export function assertPinnedConfiguration(pinned, parsed) {
|
|
121
|
+
if (parsed === undefined)
|
|
122
|
+
return;
|
|
123
|
+
if (typeof parsed !== 'string' || resolve(parsed) !== pinned)
|
|
124
|
+
throw new Error(`this invocation is pinned to configuration ${pinned} by ${MANAGED_CONFIGURATION_FLAG}; `
|
|
125
|
+
+ 'a different -c/--configuration is refused. Run the command without the pin to use another '
|
|
126
|
+
+ 'configuration; it then stays subject to the command sandbox');
|
|
127
|
+
}
|
|
128
|
+
function installedCli() {
|
|
129
|
+
const adjacent = fileURLToPath(new URL('./cli.js', import.meta.url));
|
|
130
|
+
// Vitest imports src/ directly; the executable CLI is the built one.
|
|
131
|
+
const candidate = existsSync(adjacent) ? adjacent
|
|
132
|
+
: resolve(dirname(fileURLToPath(import.meta.url)), '../dist/cli.js');
|
|
133
|
+
try {
|
|
134
|
+
return realpathSync(candidate);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return candidate;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** The exact tokens a rule fixes: this process's node, this installation's CLI, this configuration. */
|
|
141
|
+
export function managedCliPaths(configPath, overrides = {}) {
|
|
142
|
+
return {
|
|
143
|
+
node: overrides.node ?? process.execPath,
|
|
144
|
+
cli: overrides.cli ?? installedCli(),
|
|
145
|
+
configuration: overrides.configuration ?? resolve(configPath ?? defaultConfigPath()),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const PLAIN_TOKEN = /^[A-Za-z0-9_@%+=:,./-]+$/u;
|
|
149
|
+
/**
|
|
150
|
+
* Single quotes are the one spelling both Codex (>=0.160.0) and Claude match
|
|
151
|
+
* against a rule — for words after the executable. Only a space is admitted
|
|
152
|
+
* beyond the plain characters: that is what was measured natively. `*`, `?`,
|
|
153
|
+
* brackets, parentheses and the like are pattern syntax in Claude's permission
|
|
154
|
+
* and exclusion entries, where a path is inserted verbatim, so a path carrying
|
|
155
|
+
* one would widen the entry beyond this installation.
|
|
156
|
+
*/
|
|
157
|
+
const QUOTABLE_TOKEN = /^[A-Za-z0-9_@%+=:,./ -]+$/u;
|
|
158
|
+
/** The canonical shell spelling of one path token, or undefined when none is qualified. */
|
|
159
|
+
export function shellSpelling(token) {
|
|
160
|
+
if (PLAIN_TOKEN.test(token))
|
|
161
|
+
return token;
|
|
162
|
+
return QUOTABLE_TOKEN.test(token) ? `'${token}'` : undefined;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* The executable token is the exception: neither Codex 0.160.0 nor Claude Code
|
|
166
|
+
* 2.1.289 matches a QUOTED first word against a rule, while both match quoted
|
|
167
|
+
* later words. Node must therefore be reachable at a path needing no quoting.
|
|
168
|
+
*/
|
|
169
|
+
const plainExecutable = (node) => PLAIN_TOKEN.test(node);
|
|
170
|
+
export const needsQuoting = (paths) => [paths.cli, paths.configuration].some(token => !PLAIN_TOKEN.test(token));
|
|
171
|
+
/** The argv prefix every prepared invocation starts with. */
|
|
172
|
+
export function managedCliPrefix(paths) {
|
|
173
|
+
return [paths.node, paths.cli, MANAGED_CONFIGURATION_FLAG, paths.configuration];
|
|
174
|
+
}
|
|
175
|
+
/** The same prefix as the agent must type it. Undefined when a path cannot be spelled. */
|
|
176
|
+
export function managedCliCommandPrefix(paths) {
|
|
177
|
+
const [cli, configuration] = [paths.cli, paths.configuration].map(shellSpelling);
|
|
178
|
+
if (!plainExecutable(paths.node) || cli === undefined || configuration === undefined)
|
|
179
|
+
return undefined;
|
|
180
|
+
return [paths.node, cli, MANAGED_CONFIGURATION_FLAG, configuration].join(' ');
|
|
181
|
+
}
|
|
182
|
+
/** Codex execution rules: one `prefix_rule` per form. JSON strings are valid Starlark strings. */
|
|
183
|
+
export function renderCodexRules(paths, workflows) {
|
|
184
|
+
const header = { ...paths, workflows: [...workflows].sort() };
|
|
185
|
+
const lines = [
|
|
186
|
+
`${RULES_MARKER}${JSON.stringify(header)}`,
|
|
187
|
+
'# Generated by ours-fleet. Do not edit: this file is replaced at every launch and setup.',
|
|
188
|
+
'# Put your own rules in another file in this directory; Fleet never touches those.',
|
|
189
|
+
'# Each rule lets the named Fleet CLI command run outside the Codex command sandbox for',
|
|
190
|
+
'# every trusted Codex session in this workspace. It matches an argv prefix and admits all',
|
|
191
|
+
'# trailing arguments; it is not argument validation or per-agent isolation.',
|
|
192
|
+
];
|
|
193
|
+
for (const entry of allForms(workflows))
|
|
194
|
+
lines.push(`prefix_rule(pattern=${JSON.stringify([...managedCliPrefix(paths), ...entry.tokens])}, decision="allow")`);
|
|
195
|
+
return `${lines.join('\n')}\n`;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Claude needs BOTH halves: `permissions.allow` authorizes the Bash call and
|
|
199
|
+
* `sandbox.excludedCommands` places it outside the OS sandbox when one is
|
|
200
|
+
* enabled. Each form is listed bare and with ` *` because the wildcard form
|
|
201
|
+
* alone is not relied on to match an invocation without arguments.
|
|
202
|
+
*/
|
|
203
|
+
export function claudeManagedCliSettings(paths, workflows) {
|
|
204
|
+
const prefix = managedCliCommandPrefix(paths);
|
|
205
|
+
if (!prefix)
|
|
206
|
+
return undefined;
|
|
207
|
+
const patterns = allForms(workflows).flatMap(entry => {
|
|
208
|
+
const command = `${prefix} ${entry.tokens.join(' ')}`;
|
|
209
|
+
return [command, `${command} *`];
|
|
210
|
+
});
|
|
211
|
+
return {
|
|
212
|
+
permissions: { allow: patterns.map(pattern => `Bash(${pattern})`) },
|
|
213
|
+
sandbox: { excludedCommands: patterns },
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
const isFleetPattern = (value) => typeof value === 'string' && value.includes(` ${MANAGED_CONFIGURATION_FLAG} `);
|
|
217
|
+
/**
|
|
218
|
+
* Merge the fragment into Fleet's own per-role settings overlay. Entries from a
|
|
219
|
+
* previous launch are dropped first, so a moved node, CLI or configuration
|
|
220
|
+
* leaves nothing stale behind. `sandbox.enabled` is never written: whether
|
|
221
|
+
* Claude's OS sandbox is on stays the operator's setting.
|
|
222
|
+
*/
|
|
223
|
+
export function mergeClaudeOverlay(base, fragment) {
|
|
224
|
+
const record = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
225
|
+
const list = (value) => Array.isArray(value) ? value : [];
|
|
226
|
+
const permissions = record(base.permissions);
|
|
227
|
+
const sandbox = record(base.sandbox);
|
|
228
|
+
const allow = [...list(permissions.allow).filter(entry => !isFleetPattern(entry)),
|
|
229
|
+
...(fragment?.permissions.allow ?? [])];
|
|
230
|
+
const excluded = [...list(sandbox.excludedCommands).filter(entry => !isFleetPattern(entry)),
|
|
231
|
+
...(fragment?.sandbox.excludedCommands ?? [])];
|
|
232
|
+
const result = { ...base };
|
|
233
|
+
const nextPermissions = { ...permissions, ...(allow.length ? { allow } : {}) };
|
|
234
|
+
if (!allow.length)
|
|
235
|
+
delete nextPermissions.allow;
|
|
236
|
+
const nextSandbox = { ...sandbox, ...(excluded.length ? { excludedCommands: excluded } : {}) };
|
|
237
|
+
if (!excluded.length)
|
|
238
|
+
delete nextSandbox.excludedCommands;
|
|
239
|
+
if (Object.keys(nextPermissions).length)
|
|
240
|
+
result.permissions = nextPermissions;
|
|
241
|
+
else
|
|
242
|
+
delete result.permissions;
|
|
243
|
+
if (Object.keys(nextSandbox).length)
|
|
244
|
+
result.sandbox = nextSandbox;
|
|
245
|
+
else
|
|
246
|
+
delete result.sandbox;
|
|
247
|
+
return result;
|
|
248
|
+
}
|
|
249
|
+
const inside = (child, parent) => child === parent || child.startsWith(parent.endsWith(sep) ? parent : `${parent}${sep}`);
|
|
250
|
+
/** Where `path` really is: symlinks resolved through the deepest ancestor that exists. */
|
|
251
|
+
function canonical(path) {
|
|
252
|
+
const missing = [];
|
|
253
|
+
for (let current = resolve(path);; current = dirname(current)) {
|
|
254
|
+
try {
|
|
255
|
+
return join(realpathSync(current), ...missing.reverse());
|
|
256
|
+
}
|
|
257
|
+
catch { /* not there yet */ }
|
|
258
|
+
if (dirname(current) === current)
|
|
259
|
+
return resolve(path);
|
|
260
|
+
missing.push(basename(current));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
export function roleWorkspace(role, temp = false) {
|
|
264
|
+
return role.cwd && existsSync(role.cwd) ? role.cwd : agentDir(role.name, temp);
|
|
265
|
+
}
|
|
266
|
+
const versionAtLeast = (version, minimum) => {
|
|
267
|
+
const parse = (value) => (/(\d+)\.(\d+)\.(\d+)/u.exec(value) ?? []).slice(1).map(Number);
|
|
268
|
+
const [a, b] = [parse(version), parse(minimum)];
|
|
269
|
+
if (a.length !== 3)
|
|
270
|
+
return false;
|
|
271
|
+
for (let index = 0; index < 3; index++)
|
|
272
|
+
if (a[index] !== b[index])
|
|
273
|
+
return a[index] > b[index];
|
|
274
|
+
return true;
|
|
275
|
+
};
|
|
276
|
+
/**
|
|
277
|
+
* The Codex that will run this role, by the same choice the launch makes: an
|
|
278
|
+
* explicit CODEX_PATH, else (for the bundled ACP adapter) a host Codex at least
|
|
279
|
+
* as new as the bundled one, else the bundled copy. `--version` only; no model.
|
|
280
|
+
*/
|
|
281
|
+
function codexRuntimeVersion(role, cwd) {
|
|
282
|
+
const env = { ...process.env, ...(role.env ?? {}) };
|
|
283
|
+
const reported = (path) => {
|
|
284
|
+
const result = spawnSync(path, ['--version'], { env, encoding: 'utf8', timeout: 5_000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
285
|
+
return result.status === 0 ? /\b(\d+\.\d+\.\d+)\b/u.exec(result.stdout ?? '')?.[1] : undefined;
|
|
286
|
+
};
|
|
287
|
+
try {
|
|
288
|
+
if (env.CODEX_PATH)
|
|
289
|
+
return reported(executableOnPath(env.CODEX_PATH, env, cwd));
|
|
290
|
+
if (role.session !== 'acp')
|
|
291
|
+
return reported(executableOnPath('codex', env, cwd));
|
|
292
|
+
const manifest = resolveBundledAcpAgent('@agentclientprotocol/codex-acp', 'codex-acp', 'codex-acp').manifestPath;
|
|
293
|
+
const host = hostCodex(manifest, env, cwd);
|
|
294
|
+
if (host || !manifest)
|
|
295
|
+
return host?.version;
|
|
296
|
+
return JSON.parse(readFileSync(createRequire(manifest).resolve('@openai/codex/package.json'), 'utf8')).version;
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/** Decide, without touching the filesystem, what Fleet prepares for one role. */
|
|
303
|
+
export function analyzeManagedCli(role, configPath, options = {}) {
|
|
304
|
+
const paths = managedCliPaths(configPath, options.paths);
|
|
305
|
+
const workspace = options.workspace ?? roleWorkspace(role, options.temp);
|
|
306
|
+
const workflows = [...(role.managed_cli ?? [])];
|
|
307
|
+
const base = {
|
|
308
|
+
role: role.name, harness: role.harness, session: role.session, workflows, workspace, paths,
|
|
309
|
+
reasons: [], scope: [],
|
|
310
|
+
};
|
|
311
|
+
if (!workflows.length)
|
|
312
|
+
return { ...base, state: 'not-declared' };
|
|
313
|
+
const reasons = [];
|
|
314
|
+
const platform = options.platform ?? process.platform;
|
|
315
|
+
if (platform !== 'linux' && platform !== 'darwin')
|
|
316
|
+
reasons.push(`platform '${platform}' is not qualified; only Linux and macOS are declared`);
|
|
317
|
+
let mechanism;
|
|
318
|
+
if (role.harness === 'codex') {
|
|
319
|
+
mechanism = 'codex-workspace-rules';
|
|
320
|
+
if (role.session === 'acp' && role.session_options?.acp?.command != null)
|
|
321
|
+
reasons.push('a custom session_options.acp.command is not the bundled Codex ACP adapter Fleet qualifies; drop it or keep this role sandboxed');
|
|
322
|
+
if (role.session === 'codex-app-server' && role.session_options?.codex_app_server?.command != null)
|
|
323
|
+
reasons.push('a custom session_options.codex_app_server.command is not the Codex launch Fleet qualifies; drop it or keep this role sandboxed');
|
|
324
|
+
}
|
|
325
|
+
else if (role.harness === 'claude-code') {
|
|
326
|
+
mechanism = 'claude-session-settings';
|
|
327
|
+
if (role.session_options?.acp?.command != null)
|
|
328
|
+
reasons.push('a custom session_options.acp.command cannot receive Fleet\'s settings overlay; drop it or keep this role sandboxed');
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
reasons.push(`harness '${role.harness}' has no qualified native execution policy; supported: codex (native app-server, bundled ACP), claude-code (bundled ACP)`);
|
|
332
|
+
}
|
|
333
|
+
// Every root this launch lets the agent write: its workspace and the extra
|
|
334
|
+
// directories the role adds. Native settings Fleet cannot read may add more.
|
|
335
|
+
const native = (role.harness_options ?? {});
|
|
336
|
+
const writable = [workspace, ...(Array.isArray(native.add_dirs) ? native.add_dirs.filter((dir) => typeof dir === 'string').map(dir => resolve(workspace, dir)) : [])];
|
|
337
|
+
if (role.harness === 'codex' && (native.profile != null || !isManagedCliCodexConfig(native.config)))
|
|
338
|
+
reasons.push('harness_native.config or harness_native.profile contains invalid or uninspected Codex settings (for example extra writable roots), so Fleet does not inspect enough to tell whether the agent could replace the pinned files; only validated model_reasoning_effort is supported; drop the other settings or keep this role sandboxed');
|
|
339
|
+
if (role.isolation)
|
|
340
|
+
reasons.push('the role declares isolation:, and Fleet\'s outer sandbox is kept as-is; the workflow is not qualified inside it');
|
|
341
|
+
if (!plainExecutable(paths.node))
|
|
342
|
+
reasons.push(`Node executable path ${JSON.stringify(paths.node)} would need shell quoting, and neither Codex nor Claude matches a quoted executable against a rule; install or link Node at a path without spaces or shell metacharacters and start Fleet with that Node`);
|
|
343
|
+
for (const [label, value] of [['Node executable', paths.node], ['Fleet CLI', paths.cli], ['configuration', paths.configuration]]) {
|
|
344
|
+
if (label !== 'Node executable' && shellSpelling(value) === undefined)
|
|
345
|
+
reasons.push(`${label} path ${JSON.stringify(value)} contains a character other than letters, digits, spaces and _@%+=:,./- and has no qualified literal spelling in a native rule (quotes, backslashes and pattern characters such as * ? [ ] ( ) are not matched literally); install it at a path without them`);
|
|
346
|
+
// Lexically and by where the path really leads: a link outside the workspace
|
|
347
|
+
// that points into it is just as replaceable by the agent.
|
|
348
|
+
const real = canonical(value);
|
|
349
|
+
const root = writable.find(dir => inside(value, dir) || inside(real, canonical(dir)));
|
|
350
|
+
if (root !== undefined)
|
|
351
|
+
reasons.push(`${label} ${value}${real === value ? '' : ` (which resolves to ${real})`} is inside the agent-writable ${root === workspace ? 'workspace' : 'directory (harness_options.add_dirs)'} ${root}; a rule on a file the agent can replace would let it run anything outside the sandbox`);
|
|
352
|
+
}
|
|
353
|
+
if (mechanism === 'codex-workspace-rules' && needsQuoting(paths) && !reasons.length) {
|
|
354
|
+
const version = options.codexVersion ?? codexRuntimeVersion(role, workspace);
|
|
355
|
+
if (version === undefined)
|
|
356
|
+
reasons.push(`the Fleet CLI or configuration path needs shell quoting, which only Codex >=${CODEX_QUOTED_PATH_MINIMUM} matches against rules, and this role's Codex version could not be read; install Codex or keep Fleet and its configuration at paths without spaces`);
|
|
357
|
+
else if (!versionAtLeast(version, CODEX_QUOTED_PATH_MINIMUM))
|
|
358
|
+
reasons.push(`the Fleet CLI or configuration path needs shell quoting, which Codex ${version} does not match against rules; upgrade Codex to >=${CODEX_QUOTED_PATH_MINIMUM} or keep Fleet and its configuration at paths without spaces`);
|
|
359
|
+
}
|
|
360
|
+
if (reasons.length)
|
|
361
|
+
return { ...base, state: 'unsupported', mechanism, reasons };
|
|
362
|
+
if (mechanism === 'codex-workspace-rules') {
|
|
363
|
+
let sandbox;
|
|
364
|
+
try {
|
|
365
|
+
const adapter = getAdapter(role.harness);
|
|
366
|
+
const effective = adapter.effectivePermissions?.(role) ?? adapter.translatePermissions(role.permissions);
|
|
367
|
+
sandbox = effective.supported ? effective.native.sandbox : undefined;
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
sandbox = undefined;
|
|
371
|
+
}
|
|
372
|
+
if (sandbox === 'danger-full-access')
|
|
373
|
+
return { ...base, state: 'not-required', mechanism,
|
|
374
|
+
reasons: ['the role runs Codex with sandbox=danger-full-access, so no command sandbox stands between it and the supervisor; nothing is generated'] };
|
|
375
|
+
}
|
|
376
|
+
const names = workflows.map(id => MANAGED_CLI_WORKFLOWS[id].description).join(', ');
|
|
377
|
+
const scope = [
|
|
378
|
+
`Runs outside the ${mechanism === 'codex-workspace-rules' ? 'Codex' : 'Claude'} command sandbox: ${allForms(workflows).map(entry => entry.tokens.join(' ')).join('; ')} (${names}).`,
|
|
379
|
+
'Each rule fixes the Node executable, this Fleet CLI, the configuration and the command; every trailing option and argument is admitted, including task/room content, member overrides and file paths. Ordinary CLI validation and the supervisor audit still apply.',
|
|
380
|
+
'task start and task finish provision and retire rooms and agents: this is real authority, not read access.',
|
|
381
|
+
mechanism === 'codex-workspace-rules'
|
|
382
|
+
? `Codex workspace rules apply to every trusted Codex session in ${workspace}, not to this agent alone.`
|
|
383
|
+
: 'The Claude entries live in this agent\'s own Fleet settings overlay; Claude\'s OS sandbox is left as your settings configure it.',
|
|
384
|
+
`Not prepared and still sandboxed: ${[...new Set(workflows.flatMap(id => MANAGED_CLI_WORKFLOWS[id].excluded))].join(', ')}, and compound, redirected or environment-prefixed command lines.`,
|
|
385
|
+
];
|
|
386
|
+
return { ...base, state: 'supported', mechanism, scope, commandPrefix: managedCliCommandPrefix(paths) };
|
|
387
|
+
}
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
// Codex: one Fleet-owned rules file per (workspace, configuration)
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
export function codexRulesPath(workspace, configuration) {
|
|
392
|
+
const id = createHash('sha256').update(configuration).digest('hex').slice(0, 12);
|
|
393
|
+
return join(workspace, '.codex', 'rules', `ours-fleet-${id}.rules`);
|
|
394
|
+
}
|
|
395
|
+
function readRulesHeader(path) {
|
|
396
|
+
if (!existsSync(path))
|
|
397
|
+
return undefined;
|
|
398
|
+
const first = readFileSync(path, 'utf8').split('\n', 1)[0] ?? '';
|
|
399
|
+
if (!first.startsWith(RULES_MARKER))
|
|
400
|
+
return 'foreign';
|
|
401
|
+
try {
|
|
402
|
+
const header = JSON.parse(first.slice(RULES_MARKER.length));
|
|
403
|
+
return typeof header.configuration === 'string' ? header : 'foreign';
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
return 'foreign';
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const registryPath = () => join(stateRoot(), 'managed-cli', 'registry.json');
|
|
410
|
+
function withRegistry(work) {
|
|
411
|
+
const path = registryPath();
|
|
412
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
413
|
+
return withSynchronousFileLock(`${path}.lock`, () => {
|
|
414
|
+
let registry = { version: 1, files: {} };
|
|
415
|
+
if (existsSync(path)) {
|
|
416
|
+
try {
|
|
417
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
418
|
+
if (parsed.version === 1 && parsed.files && typeof parsed.files === 'object')
|
|
419
|
+
registry = parsed;
|
|
420
|
+
}
|
|
421
|
+
catch { /* a damaged registry forgets old files; it never deletes on a guess */ }
|
|
422
|
+
}
|
|
423
|
+
const before = JSON.stringify(registry);
|
|
424
|
+
const result = work(registry);
|
|
425
|
+
if (JSON.stringify(registry) !== before)
|
|
426
|
+
replaceFileAtomically(path, `${JSON.stringify(registry, null, 2)}\n`);
|
|
427
|
+
return result;
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
const holderId = (role, temp) => temp ? `temp:${role}` : role;
|
|
431
|
+
/** Write this workspace's rules file and record `holder` as needing it. */
|
|
432
|
+
export function acquireCodexRules(workspace, paths, workflows, holder) {
|
|
433
|
+
const path = codexRulesPath(workspace, paths.configuration);
|
|
434
|
+
return withRegistry(registry => {
|
|
435
|
+
const entry = registry.files[path] ?? { configuration: paths.configuration, holders: [] };
|
|
436
|
+
const header = readRulesHeader(path);
|
|
437
|
+
if (header === 'foreign')
|
|
438
|
+
return { path, action: 'conflict',
|
|
439
|
+
detail: `${path} exists and was not generated by ours-fleet; it is left untouched. Move your rules to another file name in that directory` };
|
|
440
|
+
// Other holders of the same file may declare other workflows; never narrow theirs.
|
|
441
|
+
const wanted = [...new Set([...(header && entry.holders.some(name => name !== holder) ? header.workflows : []), ...workflows])]
|
|
442
|
+
.filter((id) => MANAGED_CLI_WORKFLOW_IDS.includes(id));
|
|
443
|
+
const content = renderCodexRules(paths, wanted);
|
|
444
|
+
const current = header ? readFileSync(path, 'utf8') : undefined;
|
|
445
|
+
if (current !== content)
|
|
446
|
+
replaceFileAtomically(path, content, 0o644);
|
|
447
|
+
if (!entry.holders.includes(holder))
|
|
448
|
+
entry.holders = [...entry.holders, holder].sort();
|
|
449
|
+
registry.files[path] = entry;
|
|
450
|
+
return { path, action: current === undefined ? 'created' : current === content ? 'unchanged' : 'updated' };
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Drop `holder` from every rules file except `keep`. A file left without a
|
|
455
|
+
* holder is removed when Fleet still owns it; a file other agents hold is left
|
|
456
|
+
* exactly as it is. This is what a launch does when the role stopped declaring
|
|
457
|
+
* the workflow, lost support, or moved to another workspace or configuration.
|
|
458
|
+
*/
|
|
459
|
+
export function releaseCodexRules(holder, keep) {
|
|
460
|
+
return withRegistry(registry => {
|
|
461
|
+
const results = [];
|
|
462
|
+
for (const [path, entry] of Object.entries(registry.files)) {
|
|
463
|
+
if (path === keep || !entry.holders.includes(holder))
|
|
464
|
+
continue;
|
|
465
|
+
entry.holders = entry.holders.filter(name => name !== holder);
|
|
466
|
+
if (entry.holders.length)
|
|
467
|
+
continue;
|
|
468
|
+
const header = readRulesHeader(path);
|
|
469
|
+
if (header && header !== 'foreign') {
|
|
470
|
+
rmSync(path, { force: true });
|
|
471
|
+
results.push({ path, action: 'removed', detail: `${holder} no longer uses it` });
|
|
472
|
+
}
|
|
473
|
+
else if (header === 'foreign')
|
|
474
|
+
results.push({ path, action: 'conflict', detail: `${path} is no longer Fleet-generated; left untouched` });
|
|
475
|
+
delete registry.files[path];
|
|
476
|
+
}
|
|
477
|
+
return results;
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Bring every registered rules file of `configuration` in line with `expected`
|
|
482
|
+
* (path → persistent holders). Temporary holders stay while their state dir
|
|
483
|
+
* exists. A file nobody holds is removed if — and only if — Fleet still owns
|
|
484
|
+
* it; a file whose configuration no longer exists is obsolete and goes too.
|
|
485
|
+
*/
|
|
486
|
+
export function reconcileCodexRules(configuration, expected, write) {
|
|
487
|
+
return withRegistry(registry => {
|
|
488
|
+
const results = [];
|
|
489
|
+
for (const [path, entry] of Object.entries(registry.files)) {
|
|
490
|
+
const obsolete = entry.configuration !== configuration && !existsSync(entry.configuration);
|
|
491
|
+
if (entry.configuration !== configuration && !obsolete)
|
|
492
|
+
continue;
|
|
493
|
+
const temporary = entry.holders.filter(name => name.startsWith('temp:')
|
|
494
|
+
&& existsSync(agentDir(name.slice('temp:'.length), true)));
|
|
495
|
+
const holders = obsolete ? [] : [...new Set([...(expected.get(path) ?? []), ...temporary])].sort();
|
|
496
|
+
if (holders.length) {
|
|
497
|
+
if (write)
|
|
498
|
+
entry.holders = holders;
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
const header = readRulesHeader(path);
|
|
502
|
+
const detail = obsolete ? `configuration ${entry.configuration} no longer exists` : 'no agent declares it for this workspace any more';
|
|
503
|
+
if (!write) {
|
|
504
|
+
if (header && header !== 'foreign')
|
|
505
|
+
results.push({ path, action: 'removed', detail: `${detail} (would be removed)` });
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
if (header && header !== 'foreign') {
|
|
509
|
+
rmSync(path, { force: true });
|
|
510
|
+
results.push({ path, action: 'removed', detail });
|
|
511
|
+
}
|
|
512
|
+
else if (header === 'foreign')
|
|
513
|
+
results.push({ path, action: 'conflict', detail: `${path} is no longer Fleet-generated; left untouched` });
|
|
514
|
+
delete registry.files[path];
|
|
515
|
+
}
|
|
516
|
+
return results;
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
/** Static comparison of the rules file with what this installation would generate. */
|
|
520
|
+
export function inspectCodexRules(workspace, paths, workflows) {
|
|
521
|
+
const path = codexRulesPath(workspace, paths.configuration);
|
|
522
|
+
const header = readRulesHeader(path);
|
|
523
|
+
if (header === undefined)
|
|
524
|
+
return { path, state: 'missing', detail: 'rules file not generated yet' };
|
|
525
|
+
if (header === 'foreign')
|
|
526
|
+
return { path, state: 'conflict', detail: 'a non-Fleet file occupies the Fleet rules file name' };
|
|
527
|
+
const missing = workflows.filter(id => !header.workflows.includes(id));
|
|
528
|
+
const expected = renderCodexRules(paths, [...new Set([...header.workflows, ...workflows])]
|
|
529
|
+
.filter((id) => MANAGED_CLI_WORKFLOW_IDS.includes(id)));
|
|
530
|
+
if (!missing.length && readFileSync(path, 'utf8') === expected)
|
|
531
|
+
return { path, state: 'prepared', detail: 'matches this installation' };
|
|
532
|
+
const moved = ['node', 'cli', 'configuration'].filter(key => header[key] !== paths[key]);
|
|
533
|
+
return { path, state: 'stale', detail: moved.length
|
|
534
|
+
? `generated for another ${moved.join('/')} path (${moved.map(key => header[key]).join(', ')})`
|
|
535
|
+
: missing.length ? `does not cover ${missing.join(', ')}` : 'content differs from what this Fleet generates' };
|
|
536
|
+
}
|
|
537
|
+
/** Whether Codex treats the workspace as trusted, which is what loads project rules. */
|
|
538
|
+
export function codexWorkspaceTrust(workspace, env = process.env) {
|
|
539
|
+
const source = join(env.CODEX_HOME ?? join(home(), '.codex'), 'config.toml');
|
|
540
|
+
if (!existsSync(source))
|
|
541
|
+
return { state: 'unknown', source };
|
|
542
|
+
let text;
|
|
543
|
+
try {
|
|
544
|
+
text = readFileSync(source, 'utf8');
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
return { state: 'unknown', source };
|
|
548
|
+
}
|
|
549
|
+
const headers = [`[projects.${JSON.stringify(workspace)}]`, `[projects.'${workspace}']`];
|
|
550
|
+
const lines = text.split('\n');
|
|
551
|
+
const start = lines.findIndex(line => headers.includes(line.trim()));
|
|
552
|
+
if (start < 0)
|
|
553
|
+
return { state: 'untrusted', source };
|
|
554
|
+
for (const line of lines.slice(start + 1)) {
|
|
555
|
+
if (/^\s*\[/u.test(line))
|
|
556
|
+
break;
|
|
557
|
+
const match = /^\s*trust_level\s*=\s*["']([^"']*)["']/u.exec(line);
|
|
558
|
+
if (match)
|
|
559
|
+
return { state: match[1] === 'trusted' ? 'trusted' : 'untrusted', source };
|
|
560
|
+
}
|
|
561
|
+
return { state: 'untrusted', source };
|
|
562
|
+
}
|
|
563
|
+
export function inspectClaudeSettings(workspace, env = process.env, platform = process.platform) {
|
|
564
|
+
const managed = platform === 'darwin'
|
|
565
|
+
? '/Library/Application Support/ClaudeCode/managed-settings.json'
|
|
566
|
+
: '/etc/claude-code/managed-settings.json';
|
|
567
|
+
// Lowest precedence first; a later source overrides `sandbox.enabled`.
|
|
568
|
+
const candidates = [
|
|
569
|
+
join(env.CLAUDE_CONFIG_DIR ?? join(home(), '.claude'), 'settings.json'),
|
|
570
|
+
join(workspace, '.claude', 'settings.json'),
|
|
571
|
+
join(workspace, '.claude', 'settings.local.json'),
|
|
572
|
+
managed,
|
|
573
|
+
];
|
|
574
|
+
const result = { sandbox: 'unknown', sources: [], bashDeny: [], unreadable: [] };
|
|
575
|
+
for (const source of candidates) {
|
|
576
|
+
if (!existsSync(source))
|
|
577
|
+
continue;
|
|
578
|
+
try {
|
|
579
|
+
const parsed = JSON.parse(readFileSync(source, 'utf8'));
|
|
580
|
+
result.sources.push(source);
|
|
581
|
+
if (typeof parsed.sandbox?.enabled === 'boolean')
|
|
582
|
+
result.sandbox = parsed.sandbox.enabled ? 'enabled' : 'disabled';
|
|
583
|
+
if (Array.isArray(parsed.permissions?.deny))
|
|
584
|
+
result.bashDeny.push(...parsed.permissions.deny.filter((rule) => typeof rule === 'string' && /^Bash(?:\(|$)/u.test(rule)));
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
result.unreadable.push(source);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return result;
|
|
591
|
+
}
|
|
592
|
+
export function managedCliDigest(analysis) {
|
|
593
|
+
return createHash('sha256').update(JSON.stringify([
|
|
594
|
+
analysis.state, analysis.mechanism ?? null, [...analysis.workflows].sort(), analysis.paths, analysis.workspace,
|
|
595
|
+
analysis.workflows.map(id => MANAGED_CLI_WORKFLOWS[id].forms.map(entry => entry.tokens.join(' '))),
|
|
596
|
+
])).digest('hex');
|
|
597
|
+
}
|
|
598
|
+
export function readManagedCliRecord(stateDir) {
|
|
599
|
+
try {
|
|
600
|
+
const parsed = JSON.parse(readFileSync(join(stateDir, MANAGED_CLI_RECORD), 'utf8'));
|
|
601
|
+
return parsed.version === 1 ? parsed : undefined;
|
|
602
|
+
}
|
|
603
|
+
catch {
|
|
604
|
+
return undefined;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Prepare the native policy for one launch and return the session prep to use.
|
|
609
|
+
* Never throws: a role whose policy cannot be prepared still launches, exactly
|
|
610
|
+
* as sandboxed as it was, and the reason is logged and recorded.
|
|
611
|
+
*/
|
|
612
|
+
export function prepareManagedCliLaunch(role, options) {
|
|
613
|
+
const recordPath = join(options.stateDir, MANAGED_CLI_RECORD);
|
|
614
|
+
try {
|
|
615
|
+
const analysis = analyzeManagedCli(role, options.configPath, { ...options.deps, temp: options.temp, workspace: options.runCwd });
|
|
616
|
+
let prep = options.prep;
|
|
617
|
+
// A previous launch may have left Fleet entries in the overlay; a role that
|
|
618
|
+
// no longer declares or supports the workflow must not keep them.
|
|
619
|
+
if (role.harness === 'claude-code' && role.session_options?.acp?.command == null) {
|
|
620
|
+
const fragment = analysis.state === 'supported'
|
|
621
|
+
? claudeManagedCliSettings(analysis.paths, analysis.workflows) : undefined;
|
|
622
|
+
const overlayPath = prep.settingsOverlay ?? join(options.stateDir, '.settings-overlay.json');
|
|
623
|
+
const base = prep.settingsOverlay && existsSync(prep.settingsOverlay)
|
|
624
|
+
? JSON.parse(readFileSync(prep.settingsOverlay, 'utf8')) : {};
|
|
625
|
+
const merged = mergeClaudeOverlay(base, fragment);
|
|
626
|
+
if (fragment || prep.settingsOverlay) {
|
|
627
|
+
replaceFileAtomically(overlayPath, `${JSON.stringify(merged, null, 2)}\n`);
|
|
628
|
+
prep = { ...prep, settingsOverlay: overlayPath };
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
const holder = holderId(role.name, options.temp === true);
|
|
632
|
+
const released = (keep) => {
|
|
633
|
+
for (const result of releaseCodexRules(holder, keep))
|
|
634
|
+
options.log(`[${role.name}] managed CLI: Codex rules ${result.action} at ${result.path} (${result.detail})`);
|
|
635
|
+
};
|
|
636
|
+
if (analysis.state === 'not-declared') {
|
|
637
|
+
released();
|
|
638
|
+
rmSync(recordPath, { force: true });
|
|
639
|
+
return prep;
|
|
640
|
+
}
|
|
641
|
+
let state = analysis.state;
|
|
642
|
+
let artifact;
|
|
643
|
+
const reasons = [...analysis.reasons];
|
|
644
|
+
if (analysis.state === 'supported' && analysis.mechanism === 'codex-workspace-rules') {
|
|
645
|
+
const result = acquireCodexRules(analysis.workspace, analysis.paths, analysis.workflows, holder);
|
|
646
|
+
artifact = result.path;
|
|
647
|
+
// A changed workspace or configuration: the rules this role held before go.
|
|
648
|
+
released(result.action === 'conflict' ? undefined : result.path);
|
|
649
|
+
if (result.action === 'conflict') {
|
|
650
|
+
state = 'conflict';
|
|
651
|
+
reasons.push(result.detail);
|
|
652
|
+
}
|
|
653
|
+
else
|
|
654
|
+
options.log(`[${role.name}] managed CLI: Codex rules ${result.action} at ${result.path}`);
|
|
655
|
+
}
|
|
656
|
+
else if (analysis.state === 'supported') {
|
|
657
|
+
released();
|
|
658
|
+
artifact = prep.settingsOverlay;
|
|
659
|
+
options.log(`[${role.name}] managed CLI: Claude permission and sandbox-exclusion entries written to ${artifact}`);
|
|
660
|
+
}
|
|
661
|
+
if (analysis.state !== 'supported')
|
|
662
|
+
released();
|
|
663
|
+
if (state !== 'supported')
|
|
664
|
+
options.log(`[${role.name}] managed CLI: ${state} — ${reasons.join('; ')}`);
|
|
665
|
+
const digest = managedCliDigest(analysis);
|
|
666
|
+
const previous = readManagedCliRecord(options.stateDir);
|
|
667
|
+
const record = {
|
|
668
|
+
version: 1, state, ...(analysis.mechanism ? { mechanism: analysis.mechanism } : {}),
|
|
669
|
+
workflows: analysis.workflows, paths: analysis.paths, workspace: analysis.workspace, digest,
|
|
670
|
+
preparedAt: previous?.digest === digest && previous.state === state
|
|
671
|
+
? previous.preparedAt : (options.now?.() ?? new Date()).toISOString(),
|
|
672
|
+
...(artifact ? { artifact } : {}), reasons,
|
|
673
|
+
};
|
|
674
|
+
replaceFileAtomically(recordPath, `${JSON.stringify(record, null, 2)}\n`);
|
|
675
|
+
return prep;
|
|
676
|
+
}
|
|
677
|
+
catch (error) {
|
|
678
|
+
options.log(`[${role.name}] managed CLI: preparation failed (${error.message}); the role launches without it`);
|
|
679
|
+
return options.prep;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
/** Whether Commander answers this argv with help text instead of running the command. */
|
|
683
|
+
const asksForHelp = (tail) => {
|
|
684
|
+
const terminator = tail.indexOf('--');
|
|
685
|
+
return (terminator < 0 ? tail : tail.slice(0, terminator)).some(arg => arg === '--help' || arg === '-h');
|
|
686
|
+
};
|
|
687
|
+
/** Read the role's audit ledger without mutating or recovering it. */
|
|
688
|
+
export function observeManagedCli(stateDir, record) {
|
|
689
|
+
const none = (summary) => ({ current: [], historical: 0, foreign: 0, summary });
|
|
690
|
+
let attempts;
|
|
691
|
+
try {
|
|
692
|
+
const parsed = JSON.parse(readFileSync(join(stateDir, '.fleet-command-audit.json'), 'utf8'));
|
|
693
|
+
attempts = Array.isArray(parsed.attempts) ? parsed.attempts : [];
|
|
694
|
+
}
|
|
695
|
+
catch {
|
|
696
|
+
return none('not observed: this role has no supervisor audit ledger yet');
|
|
697
|
+
}
|
|
698
|
+
const latest = new Map();
|
|
699
|
+
let historical = 0, foreign = 0;
|
|
700
|
+
// Erasing a task or room replaces every argv word of its rows by a hash. The
|
|
701
|
+
// words that decide what a row is evidence of are a small fixed vocabulary, so
|
|
702
|
+
// they stay recognisable without the ledger holding any erased content.
|
|
703
|
+
const vocabulary = [MANAGED_CONFIGURATION_FLAG, '--help', '-h', '--',
|
|
704
|
+
...allForms(MANAGED_CLI_WORKFLOW_IDS).flatMap(entry => entry.tokens),
|
|
705
|
+
...(record?.paths?.configuration ? [managedPinMarker(record.paths.configuration)] : [])];
|
|
706
|
+
const erased = new Map(vocabulary.map(word => [erasedArg(word), word]));
|
|
707
|
+
for (const attempt of attempts) {
|
|
708
|
+
if (!Array.isArray(attempt.argv))
|
|
709
|
+
continue;
|
|
710
|
+
const argv = attempt.argv.map(String).map(word => erased.get(word) ?? word);
|
|
711
|
+
if (argv[0] !== MANAGED_CONFIGURATION_FLAG)
|
|
712
|
+
continue;
|
|
713
|
+
const entry = parseManagedEntry(['--managed-configuration', '/', ...argv.slice(2)]);
|
|
714
|
+
if (entry.kind !== 'pinned' || !attempt.outcome?.completedAt)
|
|
715
|
+
continue;
|
|
716
|
+
if (!record || attempt.outcome.completedAt < record.preparedAt) {
|
|
717
|
+
historical++;
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
// Evidence for THIS policy only: a row pinned to another configuration ran under other
|
|
721
|
+
// rules. The ledger holds the pin as a fingerprint; a row without one is not attributable.
|
|
722
|
+
if (argv[1] !== managedPinMarker(record.paths.configuration)) {
|
|
723
|
+
foreign++;
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
// The row records what was typed, and `task create --help` only printed help:
|
|
727
|
+
// it is evidence for the help path of that command, never for the command.
|
|
728
|
+
const help = entry.form.effect !== 'help' && asksForHelp(argv.slice(2 + entry.form.tokens.length));
|
|
729
|
+
const name = `${entry.form.tokens.join(' ')}${help ? ' --help' : ''}`;
|
|
730
|
+
// One success is the evidence; a later refusal of the same form does not erase it.
|
|
731
|
+
if (latest.get(name)?.class === 'success' && attempt.outcome.class !== 'success')
|
|
732
|
+
continue;
|
|
733
|
+
latest.set(name, { form: name, effect: help ? 'help' : entry.form.effect, at: attempt.outcome.completedAt, class: attempt.outcome.class ?? 'unknown' });
|
|
734
|
+
}
|
|
735
|
+
const current = [...latest.values()].sort((a, b) => a.form.localeCompare(b.form));
|
|
736
|
+
const succeeded = current.filter(item => item.class === 'success');
|
|
737
|
+
const lifecycle = succeeded.filter(item => item.effect === 'lifecycle');
|
|
738
|
+
const failed = current.filter(item => item.class !== 'success');
|
|
739
|
+
const past = (failed.length ? ` Audited but not completed successfully: ${failed.map(item => `${item.form} (${item.class})`).join(', ')}.` : '')
|
|
740
|
+
+ (historical ? ` ${historical} earlier pinned invocation(s) predate the current policy and verify nothing about it.` : '')
|
|
741
|
+
+ (foreign ? ` ${foreign} invocation(s) pinned to another configuration, or not attributable to one, are not evidence for this policy.` : '');
|
|
742
|
+
if (!succeeded.length)
|
|
743
|
+
return { current, historical, foreign, summary: `not observed: no pinned invocation has completed through the supervisor since the current policy was loaded.${past}` };
|
|
744
|
+
if (!lifecycle.length)
|
|
745
|
+
return { current, historical, foreign, summary: `supervisor reached by ${succeeded.map(item => item.form).join(', ')} only; help and read commands prove their own audited path, not the task lifecycle.${past}` };
|
|
746
|
+
return { current, historical, foreign, summary: `supervisor reached by ${succeeded.map(item => item.form).join(', ')}; lifecycle commands not listed remain unobserved.${past}` };
|
|
747
|
+
}
|
|
748
|
+
export function managedCliReport(cfg, configPath, options) {
|
|
749
|
+
const paths = managedCliPaths(configPath, options.paths);
|
|
750
|
+
const platform = options.platform ?? process.platform;
|
|
751
|
+
const expected = new Map();
|
|
752
|
+
const roles = [];
|
|
753
|
+
for (const role of cfg.roles) {
|
|
754
|
+
const analysis = analyzeManagedCli(role, configPath, options);
|
|
755
|
+
const stateDir = agentDir(role.name);
|
|
756
|
+
const record = readManagedCliRecord(stateDir);
|
|
757
|
+
const warnings = [];
|
|
758
|
+
const env = { ...(options.env ?? process.env), ...(role.env ?? {}) };
|
|
759
|
+
let setup;
|
|
760
|
+
if (analysis.state !== 'supported') {
|
|
761
|
+
setup = { state: analysis.state, ...(analysis.mechanism ? { mechanism: analysis.mechanism } : {}),
|
|
762
|
+
detail: analysis.reasons.join('; ') || 'the agent does not declare managed_cli' };
|
|
763
|
+
}
|
|
764
|
+
else if (analysis.mechanism === 'codex-workspace-rules') {
|
|
765
|
+
const path = codexRulesPath(analysis.workspace, paths.configuration);
|
|
766
|
+
expected.set(path, [...(expected.get(path) ?? []), holderId(role.name, false)]);
|
|
767
|
+
let action;
|
|
768
|
+
if (options.write)
|
|
769
|
+
action = acquireCodexRules(analysis.workspace, paths, analysis.workflows, role.name).action;
|
|
770
|
+
const inspected = inspectCodexRules(analysis.workspace, paths, analysis.workflows);
|
|
771
|
+
setup = { state: inspected.state, mechanism: analysis.mechanism, artifact: inspected.path,
|
|
772
|
+
...(action ? { action } : {}), detail: inspected.detail };
|
|
773
|
+
const trust = codexWorkspaceTrust(analysis.workspace, env);
|
|
774
|
+
if (trust.state !== 'trusted')
|
|
775
|
+
warnings.push(`Codex does not record ${analysis.workspace} as a trusted project (${trust.source}: ${trust.state}); `
|
|
776
|
+
+ 'project rules load only in trusted workspaces. Trust it through Codex\'s own prompt or configuration — Fleet does not change trust');
|
|
777
|
+
}
|
|
778
|
+
else {
|
|
779
|
+
setup = { state: 'generated-at-launch', mechanism: analysis.mechanism,
|
|
780
|
+
artifact: join(stateDir, '.settings-overlay.json'),
|
|
781
|
+
detail: 'Claude entries are written into the agent\'s own settings overlay each time its session starts' };
|
|
782
|
+
const claude = inspectClaudeSettings(analysis.workspace, env, platform);
|
|
783
|
+
warnings.push(claude.sandbox === 'enabled'
|
|
784
|
+
? `Claude's OS sandbox is enabled in your settings (${claude.sources.join(', ')}); Fleet's exclusions place the prepared commands outside it`
|
|
785
|
+
: `Claude's OS sandbox is ${claude.sandbox === 'disabled' ? 'disabled' : 'not enabled by any readable settings file'}: Fleet's sandbox exclusions are inert and only the Bash permission entries take effect. Fleet does not enable the sandbox; no sandbox qualification is claimed for this agent`);
|
|
786
|
+
if (claude.bashDeny.length)
|
|
787
|
+
warnings.push(`your Claude settings deny ${claude.bashDeny.join(', ')}; deny rules win over Fleet's allow entries and are left in place`);
|
|
788
|
+
if (claude.unreadable.length)
|
|
789
|
+
warnings.push(`could not read ${claude.unreadable.join(', ')}; their effect on the sandbox and on Bash permissions is unknown`);
|
|
790
|
+
}
|
|
791
|
+
const supported = analysis.state === 'supported';
|
|
792
|
+
const session_policy = !supported && !record ? 'not-applicable'
|
|
793
|
+
: !record ? 'never-launched'
|
|
794
|
+
: record.digest === managedCliDigest(analysis) && (!supported || record.state === 'supported') ? 'current'
|
|
795
|
+
: 'restart-required';
|
|
796
|
+
roles.push({
|
|
797
|
+
role: role.name, harness: role.harness, session: role.session, workflows: analysis.workflows,
|
|
798
|
+
setup, session_policy,
|
|
799
|
+
observed: supported || record ? observeManagedCli(stateDir, session_policy === 'current' ? record : undefined)
|
|
800
|
+
: { current: [], historical: 0, foreign: 0, summary: 'not applicable' },
|
|
801
|
+
...(analysis.commandPrefix ? { commandPrefix: analysis.commandPrefix } : {}),
|
|
802
|
+
scope: analysis.scope, warnings,
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
const removed = reconcileCodexRules(paths.configuration, expected, options.write);
|
|
806
|
+
return {
|
|
807
|
+
version: 1, ...paths,
|
|
808
|
+
platform: { os: platform, declared: platform === 'linux' || platform === 'darwin' },
|
|
809
|
+
wrote: options.write, roles, removed,
|
|
810
|
+
disclosure: [
|
|
811
|
+
'setup is static: it generates and compares configuration and never starts an agent session or a model.',
|
|
812
|
+
'observed comes only from the supervisor audit ledger; a prepared setup is not evidence that a command reached the supervisor.',
|
|
813
|
+
'A running session keeps the policy it started with; restart agents listed as restart-required.',
|
|
814
|
+
...(platform === 'darwin' ? [
|
|
815
|
+
'macOS: the generated policy uses the same mechanisms qualified on Linux, but the generated-setup integration matrix has not been run on macOS for this release. Treat observed, not setup, as the evidence here.',
|
|
816
|
+
] : []),
|
|
817
|
+
],
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
/** Whether a regular file the caller owns sits at `path`. */
|
|
821
|
+
const regularFile = (path) => {
|
|
822
|
+
try {
|
|
823
|
+
return statSync(path).isFile();
|
|
824
|
+
}
|
|
825
|
+
catch {
|
|
826
|
+
return false;
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
/**
|
|
830
|
+
* Add a workflow to an existing Agent file, and nothing else. Comments, key
|
|
831
|
+
* order and every other setting — permissions included — are preserved.
|
|
832
|
+
*/
|
|
833
|
+
export function enableManagedCli(configPath, agent, workflow) {
|
|
834
|
+
const root = splitRootFor(resolve(configPath ?? defaultConfigPath()));
|
|
835
|
+
const file = ['.yaml', '.yml'].map(extension => join(root, 'agents', `${agent}${extension}`)).find(regularFile);
|
|
836
|
+
if (!file)
|
|
837
|
+
throw new Error(`agent '${agent}' has no definition file under ${join(root, 'agents')}; `
|
|
838
|
+
+ 'only configured persistent Agents can be enabled here. For a template, add `managed_cli: [task-workflow]` to its file');
|
|
839
|
+
const document = parseDocument(readFileSync(file, 'utf8'));
|
|
840
|
+
if (document.errors.length)
|
|
841
|
+
throw new Error(`${file}: ${document.errors[0].message}`);
|
|
842
|
+
const path = document.has('template') ? ['overrides', 'managed_cli'] : ['managed_cli'];
|
|
843
|
+
const current = document.getIn(path, true);
|
|
844
|
+
const existing = current?.toJSON ? current.toJSON() : current;
|
|
845
|
+
if (existing !== undefined && !Array.isArray(existing))
|
|
846
|
+
throw new Error(`${file}: managed_cli must be a list; fix it before enabling a workflow`);
|
|
847
|
+
if (existing?.includes(workflow))
|
|
848
|
+
return { file, changed: false };
|
|
849
|
+
// Edit the text, not the document: re-serializing YAML would re-fold long
|
|
850
|
+
// lines and re-space flow collections the operator wrote. The parsed document
|
|
851
|
+
// is used only to find where the one value goes.
|
|
852
|
+
const source = readFileSync(file, 'utf8');
|
|
853
|
+
const ends = source === '' || source.endsWith('\n') ? '' : '\n';
|
|
854
|
+
const value = `[${[...(existing ?? []), workflow].join(', ')}]`;
|
|
855
|
+
const range = (node) => {
|
|
856
|
+
const found = node?.range;
|
|
857
|
+
return found ? [found[0], found[1]] : undefined;
|
|
858
|
+
};
|
|
859
|
+
const overrides = path.length === 2 ? document.get('overrides', true) : undefined;
|
|
860
|
+
let next;
|
|
861
|
+
if (existing !== undefined) {
|
|
862
|
+
const at = range(current);
|
|
863
|
+
if (at)
|
|
864
|
+
next = `${source.slice(0, at[0])}${value}${source.slice(at[1])}`;
|
|
865
|
+
}
|
|
866
|
+
else if (path.length === 1) {
|
|
867
|
+
next = `${source}${ends}managed_cli: ${value}\n`;
|
|
868
|
+
}
|
|
869
|
+
else if (overrides === undefined) {
|
|
870
|
+
next = `${source}${ends}overrides:\n managed_cli: ${value}\n`;
|
|
871
|
+
}
|
|
872
|
+
else if (!overrides.flow && overrides.items?.length) {
|
|
873
|
+
const at = range(overrides.items[0].key);
|
|
874
|
+
const line = at ? source.lastIndexOf('\n', at[0] - 1) + 1 : -1;
|
|
875
|
+
if (at && /^[ ]*$/u.test(source.slice(line, at[0])))
|
|
876
|
+
next = `${source.slice(0, line)}${source.slice(line, at[0])}managed_cli: ${value}\n${source.slice(line)}`;
|
|
877
|
+
}
|
|
878
|
+
if (next === undefined) {
|
|
879
|
+
// A shape with no safe insertion point (for example a flow-style overrides
|
|
880
|
+
// map): fall back to the YAML writer, which keeps comments but may respace.
|
|
881
|
+
document.setIn(path, document.createNode([...(existing ?? []), workflow], { flow: true }));
|
|
882
|
+
next = document.toString({ lineWidth: 0 });
|
|
883
|
+
}
|
|
884
|
+
// Never write something that no longer says what was asked for.
|
|
885
|
+
const verify = parseDocument(next);
|
|
886
|
+
const written = verify.getIn(path);
|
|
887
|
+
if (verify.errors.length || !(written?.toJSON ? written.toJSON() : written)?.includes(workflow))
|
|
888
|
+
throw new Error(`${file}: could not add managed_cli safely; add \`managed_cli: [${workflow}]\` to the Agent by hand`);
|
|
889
|
+
replaceFileAtomically(file, next, statSync(file).mode & 0o777);
|
|
890
|
+
return { file, changed: true };
|
|
891
|
+
}
|
|
892
|
+
/** The briefing section for a role, or nothing when it declares no workflow. */
|
|
893
|
+
export function managedCliBriefing(role, configPath, temp = false) {
|
|
894
|
+
const analysis = analyzeManagedCli(role, configPath, { temp });
|
|
895
|
+
if (analysis.state === 'not-declared')
|
|
896
|
+
return [];
|
|
897
|
+
if (analysis.state === 'not-required')
|
|
898
|
+
return [];
|
|
899
|
+
if (analysis.state !== 'supported' || !analysis.commandPrefix)
|
|
900
|
+
return [
|
|
901
|
+
'', '### Fleet commands outside the command sandbox',
|
|
902
|
+
'This role declares a managed Fleet workflow, but Fleet could not prepare it for this launch:',
|
|
903
|
+
...analysis.reasons.map(reason => `- ${reason}`),
|
|
904
|
+
'Fleet commands therefore stay inside your command sandbox and may fail with `connect EPERM`.',
|
|
905
|
+
'Report that as a blocker; do not look for another way to run them.',
|
|
906
|
+
];
|
|
907
|
+
const forms = allForms(analysis.workflows);
|
|
908
|
+
const list = (effect) => forms.filter(entry => entry.effect === effect).map(entry => `\`${entry.tokens.join(' ')}\``).join(', ');
|
|
909
|
+
return [
|
|
910
|
+
'', '### Fleet commands outside the command sandbox',
|
|
911
|
+
'Your command sandbox blocks the supervisor socket every `ours-fleet` call needs. Fleet prepared',
|
|
912
|
+
'these commands to run outside it. Wherever your instructions say `ours-fleet <command>`, type',
|
|
913
|
+
'this exact prefix instead, as one simple command:',
|
|
914
|
+
'', '```sh', `${analysis.commandPrefix} <command> [options]`, '```', '',
|
|
915
|
+
`Prepared: ${list('lifecycle')}; ${list('read')}; ${list('help')}.`,
|
|
916
|
+
'Spell the prefix exactly as shown, quotes included. Quote option values normally',
|
|
917
|
+
'(`--title "Two words"`). Do not add `-c`/`--configuration`: the prefix pins it and another',
|
|
918
|
+
'value is refused. A line with `&&`, `;`, a pipe, a redirect, `$(...)` or a leading `VAR=value`',
|
|
919
|
+
'does not match and runs inside the sandbox.',
|
|
920
|
+
'Every other Fleet command — `spawn`, `ours call`, `status`, `peek`, `send`, room deletion —',
|
|
921
|
+
'is not prepared here and will be refused by the sandbox. Report that; do not work around it.',
|
|
922
|
+
];
|
|
923
|
+
}
|