@crvouga/dev-fleet 0.7.0 → 0.9.0

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 (206) hide show
  1. package/README.md +88 -7
  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 +83 -0
  14. package/ansible/macos/manager.yml +164 -9
  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/macos/worker-host-key.yml +33 -0
  19. package/ansible/monitoring/agent-darwin.yml +3 -0
  20. package/ansible/monitoring/agent.yml +2 -0
  21. package/ansible/monitoring/hub-darwin.yml +3 -0
  22. package/ansible/monitoring/hub-route-darwin.yml +1 -0
  23. package/ansible/net/remote-probe.sh +4 -1
  24. package/ansible/platforms.json +2 -2
  25. package/ansible/pyproject.toml +5 -0
  26. package/ansible/ubuntu/baseline.yml +39 -13
  27. package/ansible/ubuntu/disk-gc.py +6 -2
  28. package/ansible/ubuntu/firewall.nft.j2 +50 -0
  29. package/ansible/ubuntu/policy.json +125 -6
  30. package/ansible/ubuntu/reboot.yml +89 -0
  31. package/ansible/ubuntu/recovery.yml +13 -3
  32. package/ansible/ubuntu/t3/policy.json +3 -0
  33. package/ansible/ubuntu/t3-desktop.yml +23 -2
  34. package/ansible/ubuntu/t3-headless.yml +128 -0
  35. package/ansible/ubuntu/t3-health.sh +24 -10
  36. package/ansible/ubuntu/t3-idle.py +37 -2
  37. package/ansible/ubuntu/t3-policy.yml +4 -1
  38. package/ansible/ubuntu/t3-route.yml +1 -0
  39. package/ansible/ubuntu/tailscale.yml +54 -8
  40. package/ansible/ubuntu/tasks/browser.yml +3 -0
  41. package/ansible/ubuntu/tasks/containers-off.yml +43 -0
  42. package/ansible/ubuntu/tasks/docker.yml +53 -6
  43. package/ansible/ubuntu/tasks/dpkg.yml +4 -0
  44. package/ansible/ubuntu/tasks/features.yml +22 -0
  45. package/ansible/ubuntu/tasks/firewall.yml +172 -0
  46. package/ansible/ubuntu/tasks/install.yml +2 -1
  47. package/ansible/ubuntu/tasks/packages.yml +4 -2
  48. package/ansible/ubuntu/tasks/release.yml +6 -0
  49. package/ansible/ubuntu/tasks/resources.yml +6 -4
  50. package/ansible/ubuntu/tasks/services.yml +2 -2
  51. package/ansible/ubuntu/tasks/sizing.yml +49 -0
  52. package/ansible/ubuntu/tasks/snapshot.yml +14 -3
  53. package/ansible/ubuntu/tasks/ssh.yml +61 -4
  54. package/ansible/ubuntu/tasks/t3-restart.yml +3 -1
  55. package/ansible/ubuntu/tasks/user.yml +1 -0
  56. package/ansible/ubuntu/worker-account.yml +117 -0
  57. package/ansible/uv.lock +265 -0
  58. package/bin/devfleet.mjs +42 -4
  59. package/bootstrap/fleet-remote-bootstrap.sh +91 -25
  60. package/bootstrap/global-config.ts +29 -3
  61. package/bootstrap/package-smoke.ts +127 -0
  62. package/bootstrap/pins.ts +189 -0
  63. package/bootstrap/release-notes.ts +88 -0
  64. package/bootstrap/release-version.ts +41 -7
  65. package/bootstrap/worker-features.ts +114 -0
  66. package/bootstrap/worker-seed.yaml +65 -0
  67. package/bootstrap/worker-tools.ts +39 -9
  68. package/cliproxy/README.md +1 -1
  69. package/cliproxy/docs/health-workflow.example.yml +20 -143
  70. package/docs/authentication.md +21 -11
  71. package/docs/consumer-setup.md +74 -58
  72. package/docs/convergence.md +67 -0
  73. package/docs/declared.md +61 -34
  74. package/docs/machine-setup.md +73 -7
  75. package/docs/manager-setup.md +34 -0
  76. package/docs/operations.md +64 -9
  77. package/docs/releasing.md +49 -1
  78. package/docs/runbooks.md +112 -7
  79. package/docs/state.md +23 -11
  80. package/docs/support-matrix.md +40 -4
  81. package/docs/topology.md +82 -12
  82. package/docs/ubuntu-sync.md +98 -42
  83. package/docs/worker-contract.md +122 -19
  84. package/docs/worker-requirements.md +107 -0
  85. package/examples/fleet.json +1 -0
  86. package/examples/workers-from-infrastructure/main.tf +132 -0
  87. package/examples/workers-from-infrastructure/worker-records.json +14 -0
  88. package/llms.txt +3 -1
  89. package/mise.lock +29 -0
  90. package/mise.toml +5 -3
  91. package/package.json +2 -2
  92. package/schema/check-ids.json +1991 -162
  93. package/schema/check-ids.schema.json +34 -0
  94. package/schema/fleet.schema.json +349 -10
  95. package/schema/output/check.schema.json +1 -1
  96. package/schema/output/fleet-state-definition.schema.json +14 -0
  97. package/schema/output/fleet-sync-schedule.schema.json +26 -0
  98. package/schema/output/fleet-sync.schema.json +18 -0
  99. package/schema/output/report.schema.json +3 -2
  100. package/schema/worker-record.schema.json +76 -0
  101. package/src/agents/converge.ts +140 -24
  102. package/src/agents/files.ts +2 -32
  103. package/src/agents/native-verify.ts +34 -3
  104. package/src/agents/secrets.ts +39 -4
  105. package/src/check-owners.ts +62 -2
  106. package/src/check-registry.ts +630 -0
  107. package/src/cli/agent-guide.ts +3 -3
  108. package/src/cli/cli.ts +4 -0
  109. package/src/cli/fleet-manifest.ts +108 -0
  110. package/src/cli/fleet-node-lifecycle.ts +704 -0
  111. package/src/cli/fleet-node-park.ts +238 -0
  112. package/src/cli/fleet-node-seed.ts +167 -0
  113. package/src/cli/fleet-state.ts +2 -1
  114. package/src/cli/fleet-sync-checkout.ts +7 -0
  115. package/src/cli/fleet-sync-enrollment.ts +193 -32
  116. package/src/cli/fleet-sync-forward.ts +6 -0
  117. package/src/cli/fleet-sync-manager.ts +123 -8
  118. package/src/cli/fleet-sync-output.ts +31 -11
  119. package/src/cli/fleet-sync-plan.ts +65 -9
  120. package/src/cli/fleet-sync-records.ts +113 -0
  121. package/src/cli/fleet-sync-remote.ts +172 -15
  122. package/src/cli/fleet-sync-schedule.ts +14 -4
  123. package/src/cli/fleet-sync-state.ts +31 -10
  124. package/src/cli/fleet-sync-vault.ts +152 -26
  125. package/src/cli/fleet-sync-worker.ts +214 -0
  126. package/src/cli/fleet-sync.ts +172 -21
  127. package/src/cli/fleet-validate.ts +277 -0
  128. package/src/cli/fleet-watch.ts +7 -1
  129. package/src/cli/fleet.ts +87 -5
  130. package/src/cli/managed-health.ts +25 -1
  131. package/src/cli/register-fleet.ts +251 -4
  132. package/src/cli/registry.ts +47 -0
  133. package/src/cli/tailnet.ts +43 -10
  134. package/src/cli/ui-data.ts +3 -1
  135. package/src/cli/worker-usage-report.ts +63 -0
  136. package/src/clock.ts +42 -0
  137. package/src/definition.ts +762 -232
  138. package/src/diagnostics.ts +5 -2
  139. package/src/exposure.ts +102 -0
  140. package/src/fleet-config.ts +3 -1
  141. package/src/git-identity.ts +76 -3
  142. package/src/ide/ide.ts +5 -0
  143. package/src/ide/t3.ts +12 -2
  144. package/src/input-hashes.ts +6 -0
  145. package/src/machine/machine-class.ts +59 -0
  146. package/src/machine/platform.ts +90 -11
  147. package/src/machine/state.ts +70 -16
  148. package/src/machine/support-matrix.ts +11 -9
  149. package/src/machine-health.ts +28 -5
  150. package/src/manager-health.ts +148 -2
  151. package/src/manager-sync.ts +89 -8
  152. package/src/monitoring/beszel.ts +8 -8
  153. package/src/monitoring/enroll.ts +8 -1
  154. package/src/monitoring/hub-access.ts +10 -4
  155. package/src/monitoring/systems.ts +138 -0
  156. package/src/network-definition.ts +13 -4
  157. package/src/node-converge-linux.ts +136 -27
  158. package/src/node-converge.ts +13 -3
  159. package/src/platform/ansible-receipt.ts +17 -2
  160. package/src/platform/ansible.ts +40 -4
  161. package/src/platform/manager-host.ts +25 -8
  162. package/src/platform/resource-verify.ts +18 -7
  163. package/src/platform/t3-policy.ts +24 -10
  164. package/src/platform/t3-service.ts +109 -34
  165. package/src/platform/ubuntu-release.ts +24 -0
  166. package/src/platform/ubuntu-verify.ts +31 -3
  167. package/src/platform/ubuntu-worker.ts +58 -0
  168. package/src/platform/worker-platform.ts +72 -0
  169. package/src/protocol.ts +1 -1
  170. package/src/public-reachability.ts +186 -0
  171. package/src/remote-bootstrap.ts +50 -12
  172. package/src/remote-operation.ts +18 -4
  173. package/src/repair-plan.ts +23 -12
  174. package/src/report.ts +2 -1
  175. package/src/schedule-check.ts +21 -2
  176. package/src/secrets.ts +7 -0
  177. package/src/service-auth.ts +55 -48
  178. package/src/ssh-connection.ts +141 -0
  179. package/src/ssh.ts +63 -24
  180. package/src/state/store.ts +4 -2
  181. package/src/state/sync.ts +35 -6
  182. package/src/state/workers.ts +140 -0
  183. package/src/t3/pairing.ts +59 -0
  184. package/src/t3/peer-link.ts +333 -0
  185. package/src/t3/version.ts +1 -1
  186. package/src/tailnet-cleanup.ts +185 -0
  187. package/src/tailnet-control.ts +346 -0
  188. package/src/tailscale.ts +1 -1
  189. package/src/toolchain-checks.ts +11 -1
  190. package/src/types.ts +4 -2
  191. package/src/worker-account.ts +63 -0
  192. package/src/worker-activity.ts +303 -0
  193. package/src/worker-capacity.ts +31 -0
  194. package/src/worker-credentials.ts +201 -0
  195. package/src/worker-exposure.ts +388 -0
  196. package/src/worker-features.ts +201 -0
  197. package/src/worker-host-checks.ts +71 -20
  198. package/src/worker-manifest.ts +224 -0
  199. package/src/worker-parking.ts +70 -0
  200. package/src/worker-policy.ts +38 -36
  201. package/src/worker-readiness.ts +15 -3
  202. package/src/worker-reboot.ts +129 -0
  203. package/src/worker-records.ts +305 -0
  204. package/src/worker-seed.ts +143 -0
  205. package/src/worker-sizing.ts +76 -0
  206. 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,8 +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. The first worker
132
- proves a new revision before the others receive it.
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.
133
153
 
134
154
  ## Your project's own tools
135
155
 
@@ -137,8 +157,10 @@ On a fleet machine the global mise configuration (`~/.config/mise/config.toml`
137
157
  and its `mise.lock`) belongs to dev-fleet: `fleet-sync` publishes it with the
138
158
  fleet's pinned tools and the agent worktree roots mise must trust. A project
139
159
  pins its own tools in its own `mise.toml` and installs them with
140
- `mise install --locked` in its checkout. It never publishes a global
141
- 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
142
164
  `tool:global-mise-config` until the next `fleet-sync`.
143
165
 
144
166
  ## Using it from code
@@ -217,12 +239,40 @@ propagates them without a full sync.
217
239
  }
218
240
  ```
219
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
+
220
248
  3. Run `fleet-sync`. A machine that has only an SSH server and an account
221
249
  with sudo is enough: every pull request proves that against a bare
222
250
  Ubuntu machine, over SSH
223
251
  ([`fresh-box.feature`](features/fresh-box/fresh-box.feature)).
224
252
 
225
- Remove a machine by deleting its entry and running `fleet-sync`.
253
+ A new machine can also join by itself: `npx devfleet fleet-node-seed --id ID
254
+ --output seed.yaml --apply --yes` writes cloud-init user data (or an Ubuntu
255
+ autoinstall file with `--autoinstall`) holding the worker account with your
256
+ manager's key, the privilege rule, a host key the manager pins at once and the
257
+ pinned Tailscale with a short-lived single-use auth key from your secrets
258
+ provider (`tailnet-auth-key`). Boot a stock Ubuntu image with it, then add the
259
+ worker with `fleet-node-add` and run `fleet-sync`.
260
+
261
+ `npx devfleet fleet-manifest --id ID` measures what a worker is made of
262
+ (packages, active tools, always-on services) with a digest that is the same
263
+ for two workers built from the same revision; its check `worker:manifest`
264
+ names every difference from the declared pins and, with `--reference` (the
265
+ `--json` manifest of a fresh build), from that build.
266
+
267
+ Remove a machine with `npx devfleet fleet-node-retire --id ID --apply --yes`
268
+ (it revokes the worker's Vault token, forgets its host key and roster entry
269
+ and leaves a tombstone in the fleet state), then delete its entry and run
270
+ `fleet-sync`. To put a fresh machine in place of a worker under the same id,
271
+ run `npx devfleet fleet-node-replace --id ID --host-key "TYPE BASE64" --apply
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`.
226
276
 
227
277
  ## Configuration
228
278
 
@@ -231,6 +281,10 @@ Your `fleet.json` is validated by
231
281
  package carries: `fleet-state --example` prints a complete example that points
232
282
  `$schema` at it, so an editor checks the file as you type.
233
283
  [`examples/fleet.json`](examples/fleet.json) is the same example.
284
+ `devfleet fleet-validate` checks the file without contacting anything and
285
+ names the first field that breaks a rule (`{code, path, expected, actual}`);
286
+ `--ansible` also renders the Ansible inventory and syntax-checks every
287
+ playbook with the pinned Ansible.
234
288
 
235
289
  | Block | What it declares |
236
290
  | ---------- | ------------------------------------------------------------------- |
@@ -374,6 +428,32 @@ Every pull request runs these checks ([`ci.yml`](.github/workflows/ci.yml)):
374
428
  | Fleet state | The pinned Litestream replicates the fleet state to a directory and to an S3 API, and a second manager restores it |
375
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 |
376
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>`.
377
457
 
378
458
  Some scenarios need something a laptop may not have, and are skipped there:
379
459
 
@@ -381,6 +461,7 @@ Some scenarios need something a laptop may not have, and are skipped there:
381
461
  DEVFLEET_TEST_FRESH_BOX=1 bun test features/fresh-box # Docker; tests the HEAD commit
382
462
  mise install litestream
383
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
384
465
  ```
385
466
 
386
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
@@ -0,0 +1,83 @@
1
+ ---
2
+ # What a manager needs from root, given once by its administrator:
3
+ # ansible-playbook -K manager-privilege.yml -e user=$(id -un)
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
18
+ hosts: localhost
19
+ connection: local
20
+ become: true
21
+ gather_facts: false
22
+ vars_files:
23
+ - policy.json
24
+ vars:
25
+ grant: present
26
+ manager_home: "{{ ('~' ~ user) | expanduser }}"
27
+ job_log_directory: '{{ manager_home }}/{{ jobLogs.directory }}'
28
+ tasks:
29
+ - name: Check the request
30
+ ansible.builtin.assert:
31
+ that:
32
+ - "user is match('^[a-z_][a-z0-9_.-]{0,31}$')"
33
+ - "user != 'root'"
34
+ - "grant in ['present', 'absent']"
35
+ - "manager_home is match('^/')"
36
+ - name: Allow exactly the AC power settings and the network time reading
37
+ ansible.builtin.copy:
38
+ dest: '/etc/sudoers.d/dev-fleet-manager-{{ user }}'
39
+ content: |
40
+ # Managed by dev-fleet: manager power policy
41
+ {{ user }} ALL=(root) NOPASSWD: /usr/bin/pmset -c *
42
+ {{ user }} ALL=(root) NOPASSWD: /usr/sbin/systemsetup -getusingnetworktime
43
+ owner: root
44
+ group: wheel
45
+ mode: '0440'
46
+ validate: /usr/sbin/visudo -cf %s
47
+ when: grant == 'present'
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
74
+ ansible.builtin.file:
75
+ path: '{{ item }}'
76
+ state: absent
77
+ loop:
78
+ - '/etc/sudoers.d/dev-fleet-manager-{{ user }}'
79
+ - '/etc/newsyslog.d/dev-fleet-{{ user }}.conf'
80
+ when: grant == 'absent'
81
+
82
+ - name: Stage the fleet's T3 policy
83
+ ansible.builtin.import_playbook: t3-policy.yml