@crvouga/dev-fleet 0.8.0 → 0.9.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.
Files changed (205) hide show
  1. package/README.md +65 -9
  2. package/agents/audit.md +18 -18
  3. package/ansible/agents/agents.yml +134 -0
  4. package/ansible/agents/templates/antigravity.settings.json.j2 +3 -0
  5. package/ansible/agents/templates/claude.settings.json.j2 +3 -0
  6. package/ansible/agents/templates/codex.config.toml.j2 +11 -0
  7. package/ansible/agents/templates/gemini.settings.json.j2 +14 -0
  8. package/ansible/agents/templates/grok.config.toml.j2 +19 -0
  9. package/ansible/agents/templates/opencode.json.j2 +24 -0
  10. package/ansible/agents/templates/receipt.json.j2 +10 -0
  11. package/ansible/ansible.cfg +14 -0
  12. package/ansible/github/git.yml +82 -0
  13. package/ansible/macos/manager-privilege.yml +55 -9
  14. package/ansible/macos/manager.yml +154 -7
  15. package/ansible/macos/policy.json +13 -0
  16. package/ansible/macos/t3-policy.yml +5 -3
  17. package/ansible/macos/vault-renew.yml +85 -0
  18. package/ansible/monitoring/agent-darwin.yml +3 -0
  19. package/ansible/monitoring/agent.yml +2 -0
  20. package/ansible/monitoring/hub-darwin.yml +3 -0
  21. package/ansible/monitoring/hub-route-darwin.yml +1 -0
  22. package/ansible/net/remote-probe.sh +4 -1
  23. package/ansible/platforms.json +2 -2
  24. package/ansible/pyproject.toml +5 -0
  25. package/ansible/ubuntu/baseline.yml +39 -13
  26. package/ansible/ubuntu/disk-gc.py +6 -2
  27. package/ansible/ubuntu/firewall.nft.j2 +50 -0
  28. package/ansible/ubuntu/policy.json +125 -6
  29. package/ansible/ubuntu/reboot.yml +89 -0
  30. package/ansible/ubuntu/recovery.yml +13 -3
  31. package/ansible/ubuntu/t3/policy.json +3 -0
  32. package/ansible/ubuntu/t3-desktop.yml +23 -2
  33. package/ansible/ubuntu/t3-headless.yml +128 -0
  34. package/ansible/ubuntu/t3-health.sh +24 -10
  35. package/ansible/ubuntu/t3-idle.py +37 -2
  36. package/ansible/ubuntu/t3-policy.yml +4 -1
  37. package/ansible/ubuntu/t3-route.yml +1 -0
  38. package/ansible/ubuntu/tailscale.yml +54 -8
  39. package/ansible/ubuntu/tasks/browser.yml +3 -0
  40. package/ansible/ubuntu/tasks/containers-off.yml +43 -0
  41. package/ansible/ubuntu/tasks/docker.yml +53 -6
  42. package/ansible/ubuntu/tasks/dpkg.yml +4 -0
  43. package/ansible/ubuntu/tasks/features.yml +22 -0
  44. package/ansible/ubuntu/tasks/firewall.yml +172 -0
  45. package/ansible/ubuntu/tasks/install.yml +2 -1
  46. package/ansible/ubuntu/tasks/packages.yml +4 -2
  47. package/ansible/ubuntu/tasks/release.yml +6 -0
  48. package/ansible/ubuntu/tasks/resources.yml +6 -4
  49. package/ansible/ubuntu/tasks/services.yml +2 -2
  50. package/ansible/ubuntu/tasks/sizing.yml +49 -0
  51. package/ansible/ubuntu/tasks/snapshot.yml +14 -3
  52. package/ansible/ubuntu/tasks/ssh.yml +61 -4
  53. package/ansible/ubuntu/tasks/t3-restart.yml +3 -1
  54. package/ansible/ubuntu/tasks/user.yml +1 -0
  55. package/ansible/ubuntu/worker-account.yml +117 -0
  56. package/ansible/uv.lock +265 -0
  57. package/bin/devfleet.mjs +42 -4
  58. package/bootstrap/fleet-remote-bootstrap.sh +91 -25
  59. package/bootstrap/global-config.ts +29 -3
  60. package/bootstrap/package-smoke.ts +127 -0
  61. package/bootstrap/pins.ts +189 -0
  62. package/bootstrap/release-notes.ts +88 -0
  63. package/bootstrap/release-version.ts +41 -7
  64. package/bootstrap/worker-features.ts +114 -0
  65. package/bootstrap/worker-seed.yaml +7 -3
  66. package/bootstrap/worker-tools.ts +39 -9
  67. package/cliproxy/README.md +1 -1
  68. package/cliproxy/docs/health-workflow.example.yml +20 -143
  69. package/docs/authentication.md +19 -11
  70. package/docs/consumer-setup.md +42 -27
  71. package/docs/convergence.md +67 -0
  72. package/docs/declared.md +61 -34
  73. package/docs/machine-setup.md +73 -7
  74. package/docs/manager-setup.md +10 -3
  75. package/docs/operations.md +64 -9
  76. package/docs/releasing.md +49 -1
  77. package/docs/runbooks.md +98 -7
  78. package/docs/state.md +23 -11
  79. package/docs/support-matrix.md +40 -4
  80. package/docs/topology.md +67 -6
  81. package/docs/ubuntu-sync.md +78 -28
  82. package/docs/worker-contract.md +122 -19
  83. package/docs/worker-requirements.md +107 -0
  84. package/examples/fleet.json +1 -0
  85. package/examples/workers-from-infrastructure/main.tf +132 -0
  86. package/examples/workers-from-infrastructure/worker-records.json +14 -0
  87. package/llms.txt +3 -1
  88. package/mise.lock +29 -0
  89. package/mise.toml +5 -3
  90. package/package.json +2 -2
  91. package/schema/check-ids.json +1967 -170
  92. package/schema/check-ids.schema.json +34 -0
  93. package/schema/fleet.schema.json +327 -8
  94. package/schema/output/check.schema.json +1 -1
  95. package/schema/output/fleet-state-definition.schema.json +14 -0
  96. package/schema/output/fleet-sync-schedule.schema.json +26 -0
  97. package/schema/output/fleet-sync.schema.json +18 -0
  98. package/schema/output/report.schema.json +3 -2
  99. package/schema/worker-record.schema.json +76 -0
  100. package/src/agents/converge.ts +140 -24
  101. package/src/agents/files.ts +2 -32
  102. package/src/agents/native-verify.ts +34 -3
  103. package/src/agents/secrets.ts +39 -4
  104. package/src/check-owners.ts +61 -2
  105. package/src/check-registry.ts +630 -0
  106. package/src/cli/agent-guide.ts +3 -3
  107. package/src/cli/cli.ts +4 -0
  108. package/src/cli/fleet-manifest.ts +9 -3
  109. package/src/cli/fleet-node-lifecycle.ts +313 -64
  110. package/src/cli/fleet-node-park.ts +238 -0
  111. package/src/cli/fleet-node-seed.ts +51 -35
  112. package/src/cli/fleet-state.ts +2 -1
  113. package/src/cli/fleet-sync-checkout.ts +7 -0
  114. package/src/cli/fleet-sync-enrollment.ts +193 -32
  115. package/src/cli/fleet-sync-forward.ts +4 -0
  116. package/src/cli/fleet-sync-manager.ts +103 -33
  117. package/src/cli/fleet-sync-output.ts +31 -11
  118. package/src/cli/fleet-sync-plan.ts +65 -9
  119. package/src/cli/fleet-sync-records.ts +113 -0
  120. package/src/cli/fleet-sync-remote.ts +151 -9
  121. package/src/cli/fleet-sync-schedule.ts +14 -4
  122. package/src/cli/fleet-sync-state.ts +28 -16
  123. package/src/cli/fleet-sync-vault.ts +77 -0
  124. package/src/cli/fleet-sync-worker.ts +214 -0
  125. package/src/cli/fleet-sync.ts +150 -19
  126. package/src/cli/fleet-validate.ts +59 -21
  127. package/src/cli/fleet-watch.ts +7 -1
  128. package/src/cli/fleet.ts +87 -5
  129. package/src/cli/managed-health.ts +25 -1
  130. package/src/cli/register-fleet.ts +115 -4
  131. package/src/cli/registry.ts +14 -0
  132. package/src/cli/tailnet.ts +43 -10
  133. package/src/cli/ui-data.ts +3 -1
  134. package/src/cli/worker-usage-report.ts +63 -0
  135. package/src/clock.ts +42 -0
  136. package/src/definition.ts +332 -9
  137. package/src/diagnostics.ts +5 -2
  138. package/src/exposure.ts +102 -0
  139. package/src/fleet-config.ts +3 -1
  140. package/src/git-identity.ts +76 -3
  141. package/src/ide/ide.ts +5 -0
  142. package/src/ide/t3.ts +12 -2
  143. package/src/input-hashes.ts +6 -0
  144. package/src/machine/machine-class.ts +59 -0
  145. package/src/machine/platform.ts +90 -11
  146. package/src/machine/state.ts +37 -15
  147. package/src/machine/support-matrix.ts +11 -9
  148. package/src/machine-health.ts +28 -5
  149. package/src/manager-health.ts +148 -2
  150. package/src/manager-sync.ts +65 -4
  151. package/src/monitoring/beszel.ts +8 -8
  152. package/src/monitoring/enroll.ts +8 -1
  153. package/src/monitoring/hub-access.ts +10 -4
  154. package/src/monitoring/systems.ts +138 -0
  155. package/src/network-definition.ts +13 -4
  156. package/src/node-converge-linux.ts +136 -27
  157. package/src/node-converge.ts +13 -3
  158. package/src/platform/ansible-receipt.ts +17 -2
  159. package/src/platform/ansible.ts +40 -4
  160. package/src/platform/manager-host.ts +25 -8
  161. package/src/platform/resource-verify.ts +18 -7
  162. package/src/platform/t3-policy.ts +24 -10
  163. package/src/platform/t3-service.ts +109 -34
  164. package/src/platform/ubuntu-release.ts +24 -0
  165. package/src/platform/ubuntu-verify.ts +31 -3
  166. package/src/platform/ubuntu-worker.ts +58 -0
  167. package/src/platform/worker-platform.ts +72 -0
  168. package/src/protocol.ts +1 -1
  169. package/src/public-reachability.ts +186 -0
  170. package/src/remote-bootstrap.ts +50 -12
  171. package/src/remote-operation.ts +18 -4
  172. package/src/repair-plan.ts +23 -12
  173. package/src/report.ts +2 -1
  174. package/src/schedule-check.ts +21 -2
  175. package/src/secrets.ts +4 -0
  176. package/src/service-auth.ts +55 -48
  177. package/src/ssh-connection.ts +141 -0
  178. package/src/ssh.ts +63 -24
  179. package/src/state/store.ts +4 -2
  180. package/src/state/sync.ts +35 -6
  181. package/src/state/workers.ts +140 -0
  182. package/src/t3/pairing.ts +59 -0
  183. package/src/t3/peer-link.ts +333 -0
  184. package/src/t3/version.ts +1 -1
  185. package/src/tailnet-cleanup.ts +185 -0
  186. package/src/tailnet-control.ts +346 -0
  187. package/src/tailscale.ts +1 -1
  188. package/src/toolchain-checks.ts +11 -1
  189. package/src/types.ts +4 -2
  190. package/src/worker-account.ts +63 -0
  191. package/src/worker-activity.ts +303 -0
  192. package/src/worker-capability-checks.ts +55 -16
  193. package/src/worker-capacity.ts +31 -0
  194. package/src/worker-exposure.ts +388 -0
  195. package/src/worker-features.ts +201 -0
  196. package/src/worker-host-checks.ts +71 -20
  197. package/src/worker-manifest.ts +27 -4
  198. package/src/worker-parking.ts +70 -0
  199. package/src/worker-policy.ts +17 -1
  200. package/src/worker-readiness.ts +15 -3
  201. package/src/worker-reboot.ts +129 -0
  202. package/src/worker-records.ts +305 -0
  203. package/src/worker-seed.ts +16 -11
  204. package/src/worker-sizing.ts +76 -0
  205. package/src/cli/schedule-logs.ts +0 -36
package/README.md CHANGED
@@ -41,8 +41,10 @@ dev-fleet is opinionated. These are part of it, not options:
41
41
 
42
42
  - The **manager** is a macOS machine. You run `fleet-sync` on it, and it hosts
43
43
  the shared services (the CLI proxy and the monitoring hub) in Docker Desktop.
44
- - **Workers** are Ubuntu 26.04 machines reached over SSH. They are stateless:
45
- everything on them is rebuilt from Git and your secrets. The
44
+ - **Workers** are Ubuntu 26.04 or 24.04 machines reached over SSH. They are stateless:
45
+ everything on them is rebuilt from Git and your secrets.
46
+ [What a worker must offer](docs/worker-requirements.md) is one page: image,
47
+ architecture, size, access, first-boot data and network. The
46
48
  [support matrix](docs/support-matrix.md) lists every platform and what
47
49
  happens to it.
48
50
  - The fleet state is kept in an **S3-compatible bucket** you own.
@@ -128,10 +130,26 @@ npm install --save-dev --save-exact @crvouga/dev-fleet@latest
128
130
  npx devfleet fleet-sync # the fleet moves to that release
129
131
  ```
130
132
 
131
- Install an older version and run `fleet-sync` to go back. One worker, the
132
- canary, proves a new revision before the others receive it: the first
133
- reachable worker, or the one `canary` in `fleet.json` (or `--canary`) names
134
- when it is reachable. An offline worker never holds the others back.
133
+ One worker, the canary, proves a new revision before the others receive it:
134
+ the first reachable worker, or the one `canary` in `fleet.json` (or
135
+ `--canary`) names when it is reachable. An offline worker never holds the
136
+ others back.
137
+
138
+ A sync never ends a running agent turn. A worker's T3 that must restart for
139
+ a new release or provider policy restarts only once it runs no agent turn;
140
+ until then the restart is pending and the worker's recovery timer applies it
141
+ at the first idle check. `fleet-sync --restart-busy` restarts it at once and
142
+ ends those turns, and `fleet-sync --dry-run` (with or without the flag) says
143
+ which of the two a sync would do.
144
+
145
+ To go back, run `npx devfleet fleet-sync --rollback` on the manager: it
146
+ converges every machine to the revision applied before the approved one, which
147
+ the fleet state records: after an upgrade whose canary failed, that is the
148
+ revision the fleet still runs. A rollback covers what a revision declares: the
149
+ checkout, the pinned tools and T3 Code. System packages roll forward only
150
+ (apt never downgrades them), so a release that moved one keeps it. Installing
151
+ an older version and running `fleet-sync` does the same for any earlier
152
+ release.
135
153
 
136
154
  ## Your project's own tools
137
155
 
@@ -139,8 +157,10 @@ On a fleet machine the global mise configuration (`~/.config/mise/config.toml`
139
157
  and its `mise.lock`) belongs to dev-fleet: `fleet-sync` publishes it with the
140
158
  fleet's pinned tools and the agent worktree roots mise must trust. A project
141
159
  pins its own tools in its own `mise.toml` and installs them with
142
- `mise install --locked` in its checkout. It never publishes a global
143
- configuration. If anything replaces dev-fleet's, `fleet-status` fails the check
160
+ `mise install --locked` in its checkout. On a fleet machine mise installs a
161
+ project's tools only from the project's `mise.lock` (run `mise lock` after
162
+ changing a pin); a tool the lock does not name is refused. A project never
163
+ publishes a global configuration. If anything replaces dev-fleet's, `fleet-status` fails the check
144
164
  `tool:global-mise-config` until the next `fleet-sync`.
145
165
 
146
166
  ## Using it from code
@@ -219,6 +239,12 @@ propagates them without a full sync.
219
239
  }
220
240
  ```
221
241
 
242
+ A worker may add `"features": { "preset": "editing" }` for a rented
243
+ machine that only edits code: no Docker, and T3 as a headless server with
244
+ no desktop. `containers` and `session` set each one; fleet-wide defaults go
245
+ under `worker.features`. The [worker contract](docs/worker-contract.md)
246
+ says what each feature installs and the minimum size of a worker.
247
+
222
248
  3. Run `fleet-sync`. A machine that has only an SSH server and an account
223
249
  with sudo is enough: every pull request proves that against a bare
224
250
  Ubuntu machine, over SSH
@@ -243,7 +269,10 @@ Remove a machine with `npx devfleet fleet-node-retire --id ID --apply --yes`
243
269
  and leaves a tombstone in the fleet state), then delete its entry and run
244
270
  `fleet-sync`. To put a fresh machine in place of a worker under the same id,
245
271
  run `npx devfleet fleet-node-replace --id ID --host-key "TYPE BASE64" --apply
246
- --yes` and then `fleet-sync --node ID`.
272
+ --yes` and then `fleet-sync --node ID`; the manager's T3 pairs with the new
273
+ machine's T3 by itself, with no code to copy. A run scoped with `--node` records only the
274
+ revision of the workers it covered; the fleet's applied revision moves with a
275
+ full `fleet-sync`.
247
276
 
248
277
  ## Configuration
249
278
 
@@ -399,6 +428,32 @@ Every pull request runs these checks ([`ci.yml`](.github/workflows/ci.yml)):
399
428
  | Fleet state | The pinned Litestream replicates the fleet state to a directory and to an S3 API, and a second manager restores it |
400
429
  | Fresh worker over SSH | A new Ubuntu machine with only an SSH server and sudo is bootstrapped by the production code over real SSH, on amd64 and on arm64 |
401
430
  | Workflows | The workflow files pass `actionlint` |
431
+ | Playbooks | Every playbook passes `ansible-lint` (production profile) and `--syntax-check` |
432
+ | Manager on macOS | Every feature on macOS, launchd jobs linted by macOS, and the manager playbook converged, then re-run and checked with no change |
433
+
434
+ The ruleset of `main` requires one check, **Required checks**, which passes
435
+ only when every job above passed: a job that is added, renamed or split is
436
+ required from the pull request that does it. The two fresh worker features
437
+ run side by side, each in a job of its own for every release and
438
+ architecture.
439
+
440
+ A pull request needs nobody attending
441
+ ([`pull-requests.feature`](features/rules/pull-requests.feature)):
442
+
443
+ - A pull request from a branch of this repository is squash-merged as soon as
444
+ **Required checks** passes ([`auto-merge.yml`](.github/workflows/auto-merge.yml)).
445
+ A draft is left alone until it is marked ready; a fork's is left to a person.
446
+ - The merge is checked again on `main` and then released. A merge made with
447
+ the workflow's own token starts no workflow, so `auto-merge.yml` starts CI
448
+ on `main` itself when the newest commit there has no run.
449
+ - A workflow that fails on `main` (CI, the release, the weekly VM and tailnet
450
+ tests, the pins) has one open issue labelled
451
+ [`ci-failure`](https://github.com/crvouga/dev-fleet/issues?q=is%3Aissue+is%3Aopen+label%3Aci-failure)
452
+ for as long as it fails ([`failures.yml`](.github/workflows/failures.yml)):
453
+ the run, the commit, the jobs and steps that failed. Each failing run
454
+ rewrites it and the first passing run closes it, so those issues are what is
455
+ broken on `main` now. Start there; fix with a pull request that says
456
+ `Fixes #<issue>`.
402
457
 
403
458
  Some scenarios need something a laptop may not have, and are skipped there:
404
459
 
@@ -406,6 +461,7 @@ Some scenarios need something a laptop may not have, and are skipped there:
406
461
  DEVFLEET_TEST_FRESH_BOX=1 bun test features/fresh-box # Docker; tests the HEAD commit
407
462
  mise install litestream
408
463
  DEVFLEET_TEST_S3=http://127.0.0.1:5000 bun test features/state
464
+ bun ansible/ubuntu/tests/run-vm.ts baseline # Linux amd64 with KVM; the Ubuntu VM workflow
409
465
  ```
410
466
 
411
467
  ## License
package/agents/audit.md CHANGED
@@ -7,24 +7,24 @@ nodes were inspected or authenticated. The execution host exposes Codex, Claude
7
7
  and T3 on PATH; the mise catalog additionally includes Gemini, Copilot and OpenCode.
8
8
  Cursor and Antigravity are desktop prerequisites, not mise-managed binaries.
9
9
 
10
- | Source/owner | Installed destination | Scope |
11
- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
12
- | `mise.toml`, `mise.lock`, `.mise/locks/`; bootstrap | Global mise config, shims and installed tools | Versions; no authentication |
13
- | `agents/registry.json`; dev-fleet | Dedicated profile outputs described below | Endpoint, models, secret references, node model overrides |
14
- | `cli/lib/agents/render.ts`; dev-fleet | `ROOT/NODE/codex/{direct,proxy}/config.toml` | Separate CODEX_HOME, Responses HTTP/SSE provider declaration |
15
- | same | `ROOT/NODE/claude/{direct,proxy}/settings.json` | Passed using native --settings; process environment routes proxy |
16
- | same | `ROOT/NODE/opencode/{direct,proxy}/opencode.json` | OPENCODE_CONFIG, environment key placeholder |
17
- | same | `ROOT/NODE/gemini/direct/.gemini/settings.json` | GEMINI_CLI_HOME is the profile root; independent native login |
18
- | `cli/lib/agents/converge.ts`; dev-fleet | Each profile's receipt.json; ROOT/backups | Nonsecret fingerprints/revision; private migration backups |
19
- | Existing `opencode/provider-secrets.ts`, OpenRouter package; dev-fleet | Platform OpenCode directory/opencode.json | Vault provider catalog, OpenRouter auto policy; existing sync embeds private keys with mode 0600 |
20
- | Existing OpenCode plugins/bin and cli/lib/links.ts; dev-fleet | Platform OpenCode plugins/bin, tui.json, notifier files | Notification plugins, sounds, platform-derived app paths |
21
- | `agents/codex-proxy.ts`; dev-fleet | T3 binaryPath points directly into reviewed Git checkout | Runtime Vault injection; WS_AGENT_NODE is a declared machine value |
22
- | `agents/t3.json`; personal T3 reconciler | T3CODE_HOME/userdata/settings.json | Owned provider instances only; plan/apply/import/cleanup and private ownership state |
23
- | `../fleet/definition.json`, cli/lib/net; dev-fleet | Managed SSH block and VS Code Remote-SSH settings | Existing machine aliases, OS/user/identity public paths and T3 ports; no private keys |
24
- | `.agents/commands/*.md`; agents:sync | Harness command symlinks | Canonical agent commands; do not edit copies |
25
- | Root AGENTS.md, CLAUDE.md, GEMINI.md, .github/copilot-instructions.md | Repository instruction discovery | Same canonical repository rules |
26
- | Existing cliproxy clients/bootstrap.py and launcher.py | ~/.config/cliproxy-fleet; ~/.local/bin/*-fleet | Legacy opt-in migration boundary; native settings preserved |
27
- | Fleet configure | checkout/node_modules/.fleet-revisions/SHA; .fleet-configuration/*.json | Pinned Git deployment worktrees and per-selection rollout receipts |
10
+ | Source/owner | Installed destination | Scope |
11
+ | ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
12
+ | `mise.toml`, `mise.lock`, `.mise/locks/`; bootstrap | Global mise config, shims and installed tools | Versions; no authentication |
13
+ | `agents/registry.json`; dev-fleet | Dedicated profile outputs described below | Endpoint, models, secret references, node model overrides |
14
+ | `ansible/agents/templates/` (specified by `src/agents/render.ts`); `ansible/agents/agents.yml` | `ROOT/NODE/codex/{direct,proxy}/config.toml` | Separate CODEX_HOME, Responses HTTP/SSE provider declaration |
15
+ | same | `ROOT/NODE/claude/{direct,proxy}/settings.json` | Passed using native --settings; process environment routes proxy |
16
+ | same | `ROOT/NODE/opencode/{direct,proxy}/opencode.json` | OPENCODE_CONFIG, environment key placeholder |
17
+ | same | `ROOT/NODE/gemini/direct/.gemini/settings.json` | GEMINI_CLI_HOME is the profile root; independent native login |
18
+ | `src/agents/converge.ts` decides, `ansible/agents/agents.yml` writes | Each profile's receipt.json; ROOT/backups | Nonsecret fingerprints/revision; private migration backups |
19
+ | Existing `opencode/provider-secrets.ts`, OpenRouter package; dev-fleet | Platform OpenCode directory/opencode.json | Vault provider catalog, OpenRouter auto policy; existing sync embeds private keys with mode 0600 |
20
+ | Existing OpenCode plugins/bin and cli/lib/links.ts; dev-fleet | Platform OpenCode plugins/bin, tui.json, notifier files | Notification plugins, sounds, platform-derived app paths |
21
+ | `agents/codex-proxy.ts`; dev-fleet | T3 binaryPath points directly into reviewed Git checkout | Runtime Vault injection; WS_AGENT_NODE is a declared machine value |
22
+ | `agents/t3.json`; personal T3 reconciler | T3CODE_HOME/userdata/settings.json | Owned provider instances only; plan/apply/import/cleanup and private ownership state |
23
+ | `../fleet/definition.json`, cli/lib/net; dev-fleet | Managed SSH block and VS Code Remote-SSH settings | Existing machine aliases, OS/user/identity public paths and T3 ports; no private keys |
24
+ | `.agents/commands/*.md`; agents:sync | Harness command symlinks | Canonical agent commands; do not edit copies |
25
+ | Root AGENTS.md, CLAUDE.md, GEMINI.md, .github/copilot-instructions.md | Repository instruction discovery | Same canonical repository rules |
26
+ | Existing cliproxy clients/bootstrap.py and launcher.py | ~/.config/cliproxy-fleet; ~/.local/bin/*-fleet | Legacy opt-in migration boundary; native settings preserved |
27
+ | Fleet configure | checkout/node_modules/.fleet-revisions/SHA; .fleet-configuration/*.json | Pinned Git deployment worktrees and per-selection rollout receipts |
28
28
 
29
29
  ROOT is `~/.config/personal/agents` on macOS/Linux and
30
30
  `%USERPROFILE%\AppData\Local\personal\agents` on native Windows. Home and platform
@@ -0,0 +1,134 @@
1
+ ---
2
+ # One agent CLI's dedicated profile (src/agents/converge.ts): its
3
+ # configuration files, rendered from templates/, and the receipt that records
4
+ # them. Written as the developer: no become. These files already exist on
5
+ # running machines and every byte of them is an interface; the templates
6
+ # render exactly what src/agents/render.ts specifies
7
+ # (features/agents/agent-configuration-templates.feature proves it).
8
+ #
9
+ # TypeScript decides beforehand: it refuses a file someone edited unless the
10
+ # person adopts it, names the files to back up first, and passes the SHA-256
11
+ # of each file as it must be written. A rendered file without that digest
12
+ # never replaces the one in place, the play measures every file again before
13
+ # it writes the receipt, so a receipt never vouches for a file it does not
14
+ # describe, and TypeScript measures the profile once more afterwards. A
15
+ # profile with nothing to change is not played at all. Variables arrive
16
+ # through a private 0600
17
+ # variables file, never argv; none of them is a secret (a profile names the
18
+ # variable that holds the proxy key, never the key).
19
+ - name: Render an agent CLI's dedicated profile
20
+ hosts: localhost
21
+ connection: local
22
+ become: false
23
+ gather_facts: false
24
+ vars:
25
+ # Where each CLI reads its configuration, relative to its profile.
26
+ agent_templates:
27
+ codex:
28
+ directories: []
29
+ files:
30
+ - { dest: config.toml, src: codex.config.toml.j2 }
31
+ claude:
32
+ directories: []
33
+ files:
34
+ - { dest: settings.json, src: claude.settings.json.j2 }
35
+ grok:
36
+ directories: []
37
+ files:
38
+ - { dest: config.toml, src: grok.config.toml.j2 }
39
+ antigravity:
40
+ directories: [.gemini, .gemini/antigravity-cli]
41
+ files:
42
+ - {
43
+ dest: .gemini/antigravity-cli/settings.json,
44
+ src: antigravity.settings.json.j2,
45
+ }
46
+ gemini:
47
+ directories: [.gemini]
48
+ files:
49
+ - { dest: .gemini/settings.json, src: gemini.settings.json.j2 }
50
+ opencode:
51
+ directories: []
52
+ files:
53
+ - { dest: opencode.json, src: opencode.json.j2 }
54
+ agent_profile_directory: '{{ agent_root }}/{{ agent_node }}/{{ agent_tool }}/{{ agent_profile }}'
55
+ agent_spec: '{{ agent_templates[agent_tool] }}'
56
+ tasks:
57
+ - name: Require one known profile of one plain machine name
58
+ ansible.builtin.assert:
59
+ that:
60
+ - agent_root is match('^/')
61
+ - agent_node is match('^[a-z0-9][a-z0-9-]{0,62}$')
62
+ - agent_tool in agent_templates
63
+ - agent_profile in ['direct', 'proxy']
64
+ - agent_model is match('^[a-zA-Z0-9_./:+-]+$')
65
+ - agent_revision is match('^[a-f0-9]{40}$')
66
+ - agent_fingerprint is match('^[a-f0-9]{64}$')
67
+ - agent_profile == 'direct' or agent_proxy_endpoint is match('^https?://')
68
+ - agent_files.keys() | sort == agent_spec.files | map(attribute='dest') | sort
69
+ fail_msg: AGENT_PROFILE_REQUEST_INVALID; dev-fleet passes a validated selection and the digest of every file it renders.
70
+ - name: Create the profile directories
71
+ ansible.builtin.file:
72
+ path: '{{ item }}'
73
+ state: directory
74
+ mode: '0700'
75
+ loop: "{{ [agent_root ~ '/' ~ agent_node, agent_root ~ '/' ~ agent_node ~ '/' ~ agent_tool, agent_profile_directory] + (agent_spec.directories | map('regex_replace', '^', agent_profile_directory ~ '/') | list) }}"
76
+ # Only what TypeScript names: a file someone edited and the person
77
+ # adopted, or a managed file that changes.
78
+ - name: Create the backup directory
79
+ ansible.builtin.file:
80
+ path: '{{ agent_root }}/backups'
81
+ state: directory
82
+ mode: '0700'
83
+ when: agent_backups | length > 0
84
+ - name: Back up a file before it is replaced
85
+ ansible.builtin.copy:
86
+ src: '{{ item.source }}'
87
+ dest: '{{ item.dest }}'
88
+ remote_src: true
89
+ force: false
90
+ mode: '0600'
91
+ loop: '{{ agent_backups }}'
92
+ loop_control:
93
+ label: '{{ item.dest | basename }}'
94
+ # A rendered file replaces the one in place only when it has the digest
95
+ # dev-fleet specified: a template that drifted never reaches a machine.
96
+ - name: Render the configuration files
97
+ ansible.builtin.template:
98
+ src: '{{ item.src }}'
99
+ dest: '{{ agent_profile_directory }}/{{ item.dest }}'
100
+ mode: '0600'
101
+ validate: >-
102
+ {{ ansible_playbook_python | quote }} -c 'import hashlib, sys;
103
+ sys.exit(None if hashlib.sha256(open(sys.argv[1], "rb").read()).hexdigest() == sys.argv[2]
104
+ else "AGENT_PROFILE_RENDER_MISMATCH; the template and src/agents/render.ts disagree.")'
105
+ %s {{ agent_files[item.dest] | quote }}
106
+ loop: '{{ agent_spec.files }}'
107
+ loop_control:
108
+ label: '{{ item.dest }}'
109
+ - name: Measure what was written
110
+ ansible.builtin.stat:
111
+ path: '{{ agent_profile_directory }}/{{ item.dest }}'
112
+ checksum_algorithm: sha256
113
+ loop: '{{ agent_spec.files }}'
114
+ loop_control:
115
+ label: '{{ item.dest }}'
116
+ register: agent_written
117
+ when: not ansible_check_mode
118
+ - name: Require every file to be byte for byte what dev-fleet specifies
119
+ ansible.builtin.assert:
120
+ that:
121
+ - item.stat.checksum == agent_files[item.item.dest]
122
+ fail_msg: AGENT_PROFILE_RENDER_MISMATCH; the template and src/agents/render.ts disagree. No receipt was written.
123
+ quiet: true
124
+ loop: '{{ agent_written.results }}'
125
+ loop_control:
126
+ label: '{{ item.item.dest }}'
127
+ when: not ansible_check_mode
128
+ # A template, not copy's content: before 2.19, Ansible turns a task
129
+ # argument that reads as JSON into data and writes it back reformatted.
130
+ - name: Record the receipt of the profile
131
+ ansible.builtin.template:
132
+ src: receipt.json.j2
133
+ dest: '{{ agent_profile_directory }}/receipt.json'
134
+ mode: '0600'
@@ -0,0 +1,3 @@
1
+ {
2
+ "modelProvider": "gemini"
3
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "model": {{ agent_model | to_json }}
3
+ }
@@ -0,0 +1,11 @@
1
+ model = {{ agent_model | to_json }}
2
+ {% if agent_profile == 'proxy' %}
3
+ model_provider = "personal_proxy"
4
+ [model_providers.personal_proxy]
5
+ name = "Personal CLI Proxy"
6
+ base_url = {{ (agent_proxy_endpoint ~ '/v1') | to_json }}
7
+ env_key = "CLIPROXY_API_KEY"
8
+ wire_api = "responses"
9
+ requires_openai_auth = false
10
+ supports_websockets = false
11
+ {% endif %}
@@ -0,0 +1,14 @@
1
+ {
2
+ "model": {
3
+ "name": {{ agent_model | to_json }}
4
+ {% if agent_profile == 'proxy' %}
5
+ },
6
+ "security": {
7
+ "auth": {
8
+ "selectedType": "gateway"
9
+ }
10
+ }
11
+ {% else %}
12
+ }
13
+ {% endif %}
14
+ }
@@ -0,0 +1,19 @@
1
+ [cli]
2
+ auto_update = false
3
+
4
+ [marketplace]
5
+ default_skills_installs_purged = true
6
+
7
+ [models]
8
+ {% if agent_profile == 'proxy' %}
9
+ default = "personal-proxy"
10
+
11
+ [model.personal-proxy]
12
+ model = {{ agent_model | to_json }}
13
+ name = {{ (agent_model ~ ' (Personal CLI Proxy)') | to_json }}
14
+ base_url = {{ (agent_proxy_endpoint ~ '/v1') | to_json }}
15
+ env_key = "CLIPROXY_API_KEY"
16
+ api_backend = "chat_completions"
17
+ {% else %}
18
+ default = {{ agent_model | to_json }}
19
+ {% endif %}
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://opencode.ai/config.json",
3
+ {% if agent_profile == 'proxy' %}
4
+ "model": {{ ('personal_proxy/' ~ agent_model) | to_json }},
5
+ "provider": {
6
+ "personal_proxy": {
7
+ "npm": "@ai-sdk/openai",
8
+ "options": {
9
+ "baseURL": {{ (agent_proxy_endpoint ~ '/v1') | to_json }},
10
+ "apiKey": "{env:CLIPROXY_API_KEY}"
11
+ },
12
+ "models": {
13
+ {% for id in (agent_proxy_models + [agent_model]) | unique %}
14
+ {{ id | to_json }}: {
15
+ "name": {{ id | to_json }}
16
+ }{{ '' if loop.last else ',' }}
17
+ {% endfor %}
18
+ }
19
+ }
20
+ }
21
+ {% else %}
22
+ "model": {{ agent_model | to_json }}
23
+ {% endif %}
24
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 1,
3
+ "revision": {{ agent_revision | to_json }},
4
+ "fingerprint": {{ agent_fingerprint | to_json }},
5
+ "files": {
6
+ {% for name, sha256 in agent_files.items() %}
7
+ {{ name | to_json }}: {{ sha256 | to_json }}{{ '' if loop.last else ',' }}
8
+ {% endfor %}
9
+ }
10
+ }
@@ -0,0 +1,14 @@
1
+ # How Ansible reaches a machine over SSH. dev-fleet points ANSIBLE_CONFIG at
2
+ # this file for every run (src/platform/ansible.ts), so no ansible.cfg of the
3
+ # operator's or of the working directory changes it. The SSH options
4
+ # themselves (strict host-key checking, keepalives, connection attempts, the
5
+ # fleet-owned known_hosts, user and port) come from src/ssh-connection.ts
6
+ # through the inventory, the same list every direct session takes, so the two
7
+ # cannot drift. Playbooks that run on the machine itself use no SSH at all.
8
+ [defaults]
9
+ timeout = 10
10
+
11
+ [ssh_connection]
12
+ retries = 3
13
+ pipelining = True
14
+ ssh_args = -o ControlMaster=auto -o ControlPersist=60s
@@ -14,6 +14,11 @@
14
14
  # gh answers for GitHub through the pinned toolchain's shim, so any
15
15
  # session's PATH works.
16
16
  git_credential_helper: "!'{{ git_home }}/.local/share/mise/shims/gh' auth git-credential"
17
+ # Optional commit signing (git.signing in fleet.json): each node signs
18
+ # with an SSH key of its own, made on the node. The private key never
19
+ # leaves it and is never in fleet.json, a variable or argv.
20
+ git_signs: "{{ git_signing | default('') == 'ssh' }}"
21
+ git_signing_key: '{{ git_home }}/.ssh/dev-fleet-git-signing'
17
22
  tasks:
18
23
  - name: Require a declared identity
19
24
  ansible.builtin.assert:
@@ -64,3 +69,80 @@
64
69
  - git_effective.results[2].stdout_lines | default([]) | last | default('') == git_credential_helper
65
70
  fail_msg: GIT_IDENTITY_OVERRIDDEN; another Git configuration (often ~/.gitconfig) sets user.name, user.email or the GitHub credential helper after dev-fleet's. Remove those lines.
66
71
  when: not ansible_check_mode
72
+ - name: Create the per-user SSH directory for the signing key
73
+ ansible.builtin.file:
74
+ path: '{{ git_home }}/.ssh'
75
+ state: directory
76
+ mode: '0700'
77
+ when: git_signs | bool
78
+ - name: Make this node's own commit signing key, once
79
+ ansible.builtin.command:
80
+ argv:
81
+ - ssh-keygen
82
+ - -q
83
+ - -t
84
+ - ed25519
85
+ - -N
86
+ - ''
87
+ - -C
88
+ - dev-fleet git signing
89
+ - -f
90
+ - '{{ git_signing_key }}'
91
+ creates: '{{ git_signing_key }}'
92
+ when: git_signs | bool
93
+ - name: Inspect the public half of the signing key
94
+ ansible.builtin.stat:
95
+ path: '{{ git_signing_key }}.pub'
96
+ register: git_signing_public_file
97
+ when: git_signs | bool
98
+ - name: Read the public half of the signing key
99
+ ansible.builtin.slurp:
100
+ src: '{{ git_signing_key }}.pub'
101
+ register: git_signing_public
102
+ when: git_signs | bool and git_signing_public_file.stat.exists
103
+ - name: Name the node's own key as the signer of the declared identity
104
+ ansible.builtin.copy:
105
+ dest: '{{ git_home }}/.config/git/allowed_signers'
106
+ content: "{{ git_email }} namespaces=\"git\" {{ git_signing_public.content | b64decode | trim }}\n"
107
+ mode: '0600'
108
+ when: git_signing_public is not skipped
109
+ - name: Sign every commit and tag with the node's own key, or stop signing
110
+ ansible.builtin.blockinfile:
111
+ path: '{{ git_config }}'
112
+ create: true
113
+ mode: '0600'
114
+ marker: '# {mark} dev-fleet: commit signing'
115
+ state: "{{ 'present' if git_signs | bool else 'absent' }}"
116
+ block: |
117
+ [gpg]
118
+ format = ssh
119
+ [gpg "ssh"]
120
+ allowedSignersFile = {{ git_home }}/.config/git/allowed_signers
121
+ [user]
122
+ signingkey = {{ git_signing_key }}.pub
123
+ [commit]
124
+ gpgsign = true
125
+ [tag]
126
+ gpgsign = true
127
+ - name: Read the effective signing configuration
128
+ ansible.builtin.command:
129
+ argv: [git, config, --global, --get-all, '{{ item }}']
130
+ environment:
131
+ HOME: '{{ git_home }}'
132
+ loop:
133
+ - gpg.format
134
+ - user.signingkey
135
+ - commit.gpgsign
136
+ register: git_signing_effective
137
+ check_mode: false
138
+ changed_when: false
139
+ failed_when: false
140
+ when: git_signs | bool
141
+ - name: Require the node's own key to sign every commit (worker:git-signing)
142
+ ansible.builtin.assert:
143
+ that:
144
+ - git_signing_effective.results[0].stdout_lines | default([]) | last | default('') == 'ssh'
145
+ - git_signing_effective.results[1].stdout_lines | default([]) | last | default('') == git_signing_key + '.pub'
146
+ - git_signing_effective.results[2].stdout_lines | default([]) | last | default('') == 'true'
147
+ fail_msg: GIT_SIGNING_OVERRIDDEN; another Git configuration (often ~/.gitconfig) sets gpg.format, user.signingkey or commit.gpgsign after dev-fleet's. Remove those lines.
148
+ when: git_signs | bool and not ansible_check_mode
@@ -1,17 +1,30 @@
1
1
  ---
2
- # The one privilege grant a manager needs, given once by its administrator:
2
+ # What a manager needs from root, given once by its administrator:
3
3
  # ansible-playbook -K manager-privilege.yml -e user=$(id -un)
4
- # It lets that user run exactly `pmset -c` (the AC power settings
5
- # manager.yml declares) without a password, so every fleet-sync holds the
6
- # manager's power policy with no prompt. Run again with -e grant=absent to
7
- # remove it; nothing else on the manager needs root.
8
- - name: Grant the fleet manager its power settings without a prompt
4
+ # - a sudoers rule that lets that user run exactly `pmset -c` (the AC power
5
+ # settings manager.yml declares) and read whether network time is on
6
+ # (`systemsetup -getusingnetworktime`, manager:clock), without a password,
7
+ # so every fleet-sync holds and measures them with no prompt;
8
+ # - a newsyslog drop-in that keeps the logs of the manager's scheduled jobs
9
+ # (fleet-sync --schedule and fleet-watch) under the size jobLogs in
10
+ # policy.json names. newsyslog runs as root, so it is installed here and
11
+ # measured by manager:job-logs;
12
+ # - the fleet's T3 administrator policy (t3-policy.yml), measured by
13
+ # t3:updates.
14
+ # Run it again after an upgrade when manager:clock or manager:job-logs says
15
+ # so, or with -e grant=absent to remove both; nothing else on the manager
16
+ # needs root.
17
+ - name: Grant the fleet manager its power settings and job log rotation
9
18
  hosts: localhost
10
19
  connection: local
11
20
  become: true
12
21
  gather_facts: false
22
+ vars_files:
23
+ - policy.json
13
24
  vars:
14
25
  grant: present
26
+ manager_home: "{{ ('~' ~ user) | expanduser }}"
27
+ job_log_directory: '{{ manager_home }}/{{ jobLogs.directory }}'
15
28
  tasks:
16
29
  - name: Check the request
17
30
  ansible.builtin.assert:
@@ -19,19 +32,52 @@
19
32
  - "user is match('^[a-z_][a-z0-9_.-]{0,31}$')"
20
33
  - "user != 'root'"
21
34
  - "grant in ['present', 'absent']"
22
- - name: Allow exactly the AC power settings
35
+ - "manager_home is match('^/')"
36
+ - name: Allow exactly the AC power settings and the network time reading
23
37
  ansible.builtin.copy:
24
38
  dest: '/etc/sudoers.d/dev-fleet-manager-{{ user }}'
25
39
  content: |
26
40
  # Managed by dev-fleet: manager power policy
27
41
  {{ user }} ALL=(root) NOPASSWD: /usr/bin/pmset -c *
42
+ {{ user }} ALL=(root) NOPASSWD: /usr/sbin/systemsetup -getusingnetworktime
28
43
  owner: root
29
44
  group: wheel
30
45
  mode: '0440'
31
46
  validate: /usr/sbin/visudo -cf %s
32
47
  when: grant == 'present'
33
- - name: Remove the grant
48
+ - name: Read the manager user's group
49
+ ansible.builtin.command:
50
+ argv: [/usr/bin/id, -gn, '{{ user }}']
51
+ changed_when: false
52
+ check_mode: false
53
+ register: manager_group
54
+ when: grant == 'present'
55
+ # newsyslog rotates a log once it is larger than the size given (in KiB),
56
+ # keeps that many compressed archives, and signals nothing (N): launchd
57
+ # opens the log again for every run of the job.
58
+ - name: Rotate the logs of the manager's scheduled jobs
59
+ ansible.builtin.copy:
60
+ dest: '/etc/newsyslog.d/dev-fleet-{{ user }}.conf'
61
+ content: |
62
+ # Managed by dev-fleet: manager job logs
63
+ # logfilename [owner:group] mode count size(KiB) when flags
64
+ {{ job_log_directory }}/{{ fleetSync.label }}.out.log {{ user }}:{{ manager_group.stdout }} 600 {{ jobLogs.keep }} {{ jobLogs.maxKiB }} * NZ
65
+ {{ job_log_directory }}/{{ fleetSync.label }}.err.log {{ user }}:{{ manager_group.stdout }} 600 {{ jobLogs.keep }} {{ jobLogs.maxKiB }} * NZ
66
+ {{ job_log_directory }}/{{ fleetWatch.label }}.out.log {{ user }}:{{ manager_group.stdout }} 600 {{ jobLogs.keep }} {{ jobLogs.maxKiB }} * NZ
67
+ {{ job_log_directory }}/{{ fleetWatch.label }}.err.log {{ user }}:{{ manager_group.stdout }} 600 {{ jobLogs.keep }} {{ jobLogs.maxKiB }} * NZ
68
+ owner: root
69
+ group: wheel
70
+ mode: '0644'
71
+ validate: /usr/sbin/newsyslog -n -f %s
72
+ when: grant == 'present'
73
+ - name: Remove the grant and the log rotation
34
74
  ansible.builtin.file:
35
- path: '/etc/sudoers.d/dev-fleet-manager-{{ user }}'
75
+ path: '{{ item }}'
36
76
  state: absent
77
+ loop:
78
+ - '/etc/sudoers.d/dev-fleet-manager-{{ user }}'
79
+ - '/etc/newsyslog.d/dev-fleet-{{ user }}.conf'
37
80
  when: grant == 'absent'
81
+
82
+ - name: Stage the fleet's T3 policy
83
+ ansible.builtin.import_playbook: t3-policy.yml