@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
@@ -0,0 +1,76 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "dev-fleet worker records, version 1",
4
+ "description": "Workers as the tool that created them describes them, without naming the tool or the provider. fleet.json names a file or a command that returns this document under workerRecords; dev-fleet only reads it, never creates a machine and never calls a provider. Each record is a declared worker reached from its data: its host key is pinned as given. A record needs a tailnetName or an address. A worker whose record disappears (a file that is gone holds no record) is retired by the next fleet-sync.",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["version", "workers"],
8
+ "properties": {
9
+ "$schema": { "type": "string" },
10
+ "version": { "const": 1 },
11
+ "workers": {
12
+ "type": "array",
13
+ "maxItems": 64,
14
+ "items": {
15
+ "type": "object",
16
+ "additionalProperties": false,
17
+ "required": ["id", "user"],
18
+ "properties": {
19
+ "id": {
20
+ "type": "string",
21
+ "pattern": "^[a-z][a-z0-9-]{0,62}$",
22
+ "description": "The worker's fleet ID. No other worker, inline or from a record, may have it."
23
+ },
24
+ "tailnetName": {
25
+ "type": "string",
26
+ "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}$",
27
+ "description": "The tailnet name the machine joins under, without the trailing dot."
28
+ },
29
+ "address": {
30
+ "type": "string",
31
+ "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}$",
32
+ "description": "A name or address SSH reaches the machine at, when it has no tailnet name."
33
+ },
34
+ "user": {
35
+ "type": "string",
36
+ "pattern": "^[a-z_][a-z0-9_-]{0,31}$",
37
+ "description": "The non-root account with passwordless sudo the manager signs in as."
38
+ },
39
+ "port": {
40
+ "type": "integer",
41
+ "minimum": 1,
42
+ "maximum": 65535,
43
+ "description": "The port sshd listens on. Default 22."
44
+ },
45
+ "hostKey": {
46
+ "type": "string",
47
+ "pattern": "^(ssh-ed25519|ecdsa-sha2-nistp256|ssh-rsa) [A-Za-z0-9+/=]{16,}$",
48
+ "description": "The machine's SSH host public key, \"TYPE BASE64\": generated before the machine exists (devfleet fleet-node-seed prints it) or read from the machine by the tool that created it. It is pinned, never learned on first use. Without it the key the fleet state pins is used (devfleet fleet-node-seed --apply --yes), and until there is one every session is refused."
49
+ },
50
+ "t3Port": {
51
+ "type": "integer",
52
+ "minimum": 1,
53
+ "maximum": 65535,
54
+ "description": "The port the worker's T3 server listens on. Default 3773."
55
+ },
56
+ "labels": {
57
+ "type": "object",
58
+ "additionalProperties": { "type": "string" },
59
+ "description": "Free-form labels; only displayed."
60
+ },
61
+ "billing": {
62
+ "type": "object",
63
+ "additionalProperties": false,
64
+ "required": ["hourlyRate"],
65
+ "properties": {
66
+ "hourlyRate": { "type": "number", "minimum": 0 },
67
+ "currency": { "type": "string", "pattern": "^[A-Z]{3}$" },
68
+ "note": { "type": "string" }
69
+ },
70
+ "description": "What the machine costs while it runs, as you declare it; fleet-status sums it for idle workers."
71
+ }
72
+ }
73
+ }
74
+ }
75
+ }
76
+ }
@@ -1,15 +1,14 @@
1
1
  import { AgentConfigurationError } from './errors';
2
2
  import { mkdir, rm } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
4
5
  import { digest, registry, type Selection } from './registry';
5
6
  import { render } from './render';
6
- import {
7
- atomicWrite,
8
- backup,
9
- fileState,
10
- readOwned,
11
- safeParents,
12
- } from './files';
7
+ import { readOwned, fileState, safeParents } from './files';
8
+ import { runAnsible } from '../platform/ansible';
9
+ import { PACKAGE_ROOT } from '../package-info';
10
+ import { runCommand } from '../runner';
11
+ import type { CommandResult, Runner } from '../types';
13
12
 
14
13
  export type Receipt = {
15
14
  version: 1;
@@ -19,6 +18,58 @@ export type Receipt = {
19
18
  };
20
19
  export type Context = { root: string; revision: string; selection: Selection };
21
20
 
21
+ /** What `ansible/agents/agents.yml` is given to write one profile. TypeScript
22
+ * decides here, beforehand, what is backed up and what each file must hash
23
+ * to; the play renders the files from its templates and asserts them.
24
+ */
25
+ export interface ProfileVariables {
26
+ agent_root: string;
27
+ agent_node: string;
28
+ agent_tool: string;
29
+ agent_profile: string;
30
+ agent_model: string;
31
+ /** Empty for a direct profile: it never names the proxy. */
32
+ agent_proxy_endpoint: string;
33
+ /** The model of every proxy route, in the registry's order. */
34
+ agent_proxy_models: string[];
35
+ /** The SHA-256 of each file as src/agents/render.ts specifies it. */
36
+ agent_files: Record<string, string>;
37
+ /** What the receipt records beside those digests. */
38
+ agent_revision: string;
39
+ agent_fingerprint: string;
40
+ /** Files to copy into ROOT/backups before they are replaced. */
41
+ agent_backups: { source: string; dest: string }[];
42
+ }
43
+
44
+ /** Runs the agents play with these variables. */
45
+ export type ProfilePlay = (
46
+ variables: ProfileVariables
47
+ ) => Promise<CommandResult>;
48
+
49
+ /** The package's agents play, with the Ansible this machine runs. `scratch`
50
+ * is where its variables file is kept while it runs (the fleet's private
51
+ * run directory by default).
52
+ */
53
+ export function agentsPlay(
54
+ run: Runner = runCommand,
55
+ options: { check?: boolean; scratch?: string } = {}
56
+ ): ProfilePlay {
57
+ const directory = join(PACKAGE_ROOT, 'ansible', 'agents');
58
+ return (variables) =>
59
+ runAnsible(
60
+ run,
61
+ join(directory, 'agents.yml'),
62
+ {
63
+ cwd: directory,
64
+ input: JSON.stringify(variables),
65
+ // Inside the two minutes the sync gives `agents apply`.
66
+ timeoutMs: 90_000,
67
+ ...(options.check ? { check: true } : {}),
68
+ },
69
+ options.scratch
70
+ );
71
+ }
72
+
22
73
  export async function plan(context: Context) {
23
74
  const { root, selection } = context;
24
75
  const directory = join(
@@ -46,7 +97,16 @@ export async function plan(context: Context) {
46
97
  return { directory, receiptPath, previous, files, fingerprint };
47
98
  }
48
99
 
49
- export async function convergeAgents(context: Context, adopt = false) {
100
+ /** Applies one profile. A file someone edited is refused unless `adopt`;
101
+ * then the agents play writes the profile, and what it wrote is measured
102
+ * against the specification before this reports success. With nothing to
103
+ * change, the play is not run.
104
+ */
105
+ export async function convergeAgents(
106
+ context: Context,
107
+ adopt = false,
108
+ play: ProfilePlay = agentsPlay()
109
+ ) {
50
110
  await safeParents(context.root);
51
111
  await mkdir(context.root, { recursive: true, mode: 0o700 });
52
112
  const lock = join(context.root, '.apply-lock');
@@ -63,23 +123,25 @@ export async function convergeAgents(context: Context, adopt = false) {
63
123
  throw new AgentConfigurationError(
64
124
  'Unmanaged or modified configuration. Review locally, then use --adopt to back up and replace only this profile.'
65
125
  );
66
- for (const file of result.files) {
67
- if (file.state === 'unchanged') continue;
68
- if ((await readOwned(file.path)) !== null)
69
- await backup(file.path, context.root);
70
- await atomicWrite(file.path, file.content);
126
+ const { receipt, encoded } = receiptFor(result, context.revision);
127
+ const changing = result.files.filter((file) => file.state !== 'unchanged');
128
+ if (changing.length || (await readOwned(result.receiptPath)) !== encoded) {
129
+ const applied = await play(
130
+ profileVariables(context, receipt, {
131
+ backups: changing
132
+ .filter((file) => file.state !== 'create')
133
+ .map((file) => ({
134
+ source: file.path,
135
+ dest: join(context.root, 'backups', randomUUID()),
136
+ })),
137
+ })
138
+ );
139
+ if (!applied.ok)
140
+ throw new AgentConfigurationError(
141
+ 'The agents play (ansible/agents/agents.yml) failed for this profile; no receipt was advanced and its output is not reported. Inspect the profile with agents preview, then apply again.'
142
+ );
143
+ await measure(context, encoded);
71
144
  }
72
- const receipt: Receipt = {
73
- version: 1,
74
- revision: context.revision,
75
- fingerprint: result.fingerprint,
76
- files: Object.fromEntries(
77
- result.files.map((file) => [file.name, digest(file.content)])
78
- ),
79
- };
80
- const encoded = JSON.stringify(receipt, null, 2) + '\n';
81
- if ((await readOwned(result.receiptPath)) !== encoded)
82
- await atomicWrite(result.receiptPath, encoded);
83
145
  return {
84
146
  ...publicPlan(result, context.revision),
85
147
  appliedRevision: context.revision,
@@ -90,6 +152,60 @@ export async function convergeAgents(context: Context, adopt = false) {
90
152
  }
91
153
  }
92
154
 
155
+ /** The receipt an apply of this plan records, and its exact text. */
156
+ export function receiptFor(
157
+ result: Awaited<ReturnType<typeof plan>>,
158
+ revision: string
159
+ ): { receipt: Receipt; encoded: string } {
160
+ const receipt: Receipt = {
161
+ version: 1,
162
+ revision,
163
+ fingerprint: result.fingerprint,
164
+ files: Object.fromEntries(
165
+ result.files.map((file) => [file.name, digest(file.content)])
166
+ ),
167
+ };
168
+ return { receipt, encoded: JSON.stringify(receipt, null, 2) + '\n' };
169
+ }
170
+
171
+ export function profileVariables(
172
+ context: Context,
173
+ receipt: Receipt,
174
+ options: { backups: { source: string; dest: string }[] } = { backups: [] }
175
+ ): ProfileVariables {
176
+ const { selection } = context;
177
+ return {
178
+ agent_root: context.root,
179
+ agent_node: selection.node,
180
+ agent_tool: selection.tool,
181
+ agent_profile: selection.profile,
182
+ agent_model: selection.model,
183
+ agent_proxy_endpoint:
184
+ selection.profile === 'proxy' ? registry.proxy.endpoint : '',
185
+ agent_proxy_models: Object.values(registry.proxy.routes).map(
186
+ (route) => route.model
187
+ ),
188
+ agent_files: receipt.files,
189
+ agent_revision: receipt.revision,
190
+ agent_fingerprint: receipt.fingerprint,
191
+ agent_backups: options.backups,
192
+ };
193
+ }
194
+
195
+ /** After the play: every file is what was specified, byte for byte, and the
196
+ * receipt is the one this apply decided on.
197
+ */
198
+ async function measure(context: Context, encoded: string): Promise<void> {
199
+ const after = await plan(context);
200
+ if (
201
+ after.files.some((file) => file.state !== 'unchanged') ||
202
+ (await readOwned(after.receiptPath)) !== encoded
203
+ )
204
+ throw new AgentConfigurationError(
205
+ 'After the agents play, the profile on disk is not what dev-fleet specifies. Inspect it with agents preview before applying again.'
206
+ );
207
+ }
208
+
93
209
  export function publicPlan(
94
210
  result: Awaited<ReturnType<typeof plan>>,
95
211
  revision: string
@@ -1,16 +1,6 @@
1
1
  import { AgentConfigurationError } from './errors';
2
- import {
3
- lstat,
4
- mkdir,
5
- readFile,
6
- rename,
7
- rm,
8
- writeFile,
9
- copyFile,
10
- chmod,
11
- } from 'node:fs/promises';
12
- import { dirname, join, parse } from 'node:path';
13
- import { randomUUID } from 'node:crypto';
2
+ import { lstat, readFile } from 'node:fs/promises';
3
+ import { dirname, parse } from 'node:path';
14
4
  import { digest } from './registry';
15
5
 
16
6
  export async function safeParents(path: string): Promise<void> {
@@ -42,26 +32,6 @@ export async function readOwned(path: string): Promise<string | null> {
42
32
  }
43
33
  }
44
34
 
45
- export async function atomicWrite(path: string, value: string): Promise<void> {
46
- await safeParents(path);
47
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
48
- const temp = `${path}.${randomUUID()}.tmp`;
49
- try {
50
- await writeFile(temp, value, { flag: 'wx', mode: 0o600 });
51
- await rename(temp, path);
52
- } finally {
53
- await rm(temp, { force: true });
54
- }
55
- }
56
-
57
- export async function backup(path: string, root: string): Promise<void> {
58
- const dir = join(root, 'backups');
59
- await mkdir(dir, { recursive: true, mode: 0o700 });
60
- const target = join(dir, randomUUID());
61
- await copyFile(path, target);
62
- await chmod(target, 0o600);
63
- }
64
-
65
35
  export function fileState(
66
36
  current: string | null,
67
37
  desired: string,
@@ -1,6 +1,9 @@
1
1
  import { mkdtemp, readFile, rm } from 'node:fs/promises';
2
- import { tmpdir } from 'node:os';
2
+ import { homedir, tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
+ import { currentPlatform } from '../platform';
5
+ import { agentRoot, render } from './render';
6
+ import { select } from './registry';
4
7
  import type { Check, Runner } from '../types';
5
8
  import { pinnedPath } from './toolchain';
6
9
  import { registry } from './registry';
@@ -110,9 +113,13 @@ export async function verifyNativeAgents(
110
113
  * account change or key rotation re-verifies at once.
111
114
  */
112
115
  accounts?: AccountContext;
116
+ /** Where the agent profiles are rendered; this user's by default. */
117
+ profiles?: string;
113
118
  } = {}
114
119
  ): Promise<Check[]> {
115
120
  const { outOfQuota = new Set<string>(), cache = {} } = options;
121
+ const profiles =
122
+ options.profiles ?? agentRoot(homedir(), currentPlatform().name);
116
123
  const pins = parsePins(await readFile(join(workspace, 'mise.toml'), 'utf8'));
117
124
  const environment = options.t3Service
118
125
  ? await serviceLaunch(run)
@@ -136,7 +143,8 @@ export async function verifyNativeAgents(
136
143
  registry,
137
144
  await sourceKey(launcherSources(workspace, agent)),
138
145
  options.accounts?.rosterVersion ?? '',
139
- options.accounts?.keyVersion ?? ''
146
+ options.accounts?.keyVersion ?? '',
147
+ await configurationKey(profiles, node, agent.binary)
140
148
  ),
141
149
  () =>
142
150
  sessionCheck(agent, { node, workspace, cwd, launch }, run),
@@ -157,6 +165,7 @@ export async function verifyNativeAgents(
157
165
  launch,
158
166
  cache,
159
167
  run,
168
+ profiles,
160
169
  })
161
170
  )
162
171
  : [];
@@ -207,6 +216,7 @@ async function accountSession(
207
216
  launch: Launch;
208
217
  cache: VerifyCache;
209
218
  run: Runner;
219
+ profiles: string;
210
220
  }
211
221
  ): Promise<'ok' | 'cooling-down' | 'failed'> {
212
222
  const spec = ACCOUNT_LAUNCH[instance.tool];
@@ -218,7 +228,8 @@ async function accountSession(
218
228
  instance,
219
229
  spec,
220
230
  accounts.rosterVersion,
221
- accounts.keyVersion
231
+ accounts.keyVersion,
232
+ await configurationKey(context.profiles, context.node, instance.tool)
222
233
  ),
223
234
  async () => {
224
235
  const agents = join(context.workspace, 'agents');
@@ -484,6 +495,26 @@ function sessionRemedy(agent: Agent): string {
484
495
  }
485
496
 
486
497
  /** The launcher and profile code a session depends on. */
498
+ /** Digest of the agent's proxy profile as it is on this machine: the files
499
+ * its session reads. A profile rendered again, edited or removed changes it,
500
+ * so the session is proven again at once. Empty for a tool with no profile.
501
+ */
502
+ export async function configurationKey(
503
+ profiles: string,
504
+ node: string,
505
+ tool: string
506
+ ): Promise<string> {
507
+ try {
508
+ const selection = select(node, tool, 'proxy');
509
+ const directory = join(profiles, node, tool, 'proxy');
510
+ return await sourceKey(
511
+ Object.keys(render(selection)).map((name) => join(directory, name))
512
+ );
513
+ } catch {
514
+ return '';
515
+ }
516
+ }
517
+
487
518
  function launcherSources(workspace: string, agent: Agent): string[] {
488
519
  const agents = join(workspace, 'agents');
489
520
  const lib = join(workspace, 'src/agents');
@@ -1,13 +1,48 @@
1
+ import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
1
4
  import { AgentConfigurationError } from './errors';
2
5
  import { secret } from '../fleet-config';
3
6
 
7
+ /** Where a node keeps the last proxy client key its secrets provider gave it,
8
+ * readable by its user alone. Disposable: the next successful read rewrites it.
9
+ */
10
+ export const lastProxyKeyPath = (home = homedir()): string =>
11
+ join(home, '.cache/dev-fleet/proxy-client-key');
12
+
4
13
  /** The key every node presents to the CLI proxy, from this fleet's secrets
5
- * provider with this node's own access.
14
+ * provider with this node's own access. The provider stays the source: a key
15
+ * rotated there is used at the next launch. When the provider cannot answer
16
+ * (Vault unreachable, or this node's session expired) the last key it gave is
17
+ * used instead, so an outage of the secrets provider never takes every agent
18
+ * down with it; the node's own Vault checks still fail until it is repaired.
6
19
  */
7
- export async function proxyKey(): Promise<string> {
8
- const value = await secret('proxy-client-key');
9
- if (value) return value;
20
+ export async function proxyKey(
21
+ options: {
22
+ read?: () => Promise<string | null>;
23
+ path?: string;
24
+ } = {}
25
+ ): Promise<string> {
26
+ const read = options.read ?? (() => secret('proxy-client-key'));
27
+ const path = options.path ?? lastProxyKeyPath();
28
+ const value = await read().catch(() => null);
29
+ if (value) {
30
+ await remember(path, value).catch(() => undefined);
31
+ return value;
32
+ }
33
+ const last = (await readFile(path, 'utf8').catch(() => '')).trim();
34
+ if (last) return last;
10
35
  throw new AgentConfigurationError(
11
36
  "Required secret unavailable: proxy-client-key. Restore this node's access to the fleet's secrets provider (see `secrets` in the fleet configuration)."
12
37
  );
13
38
  }
39
+
40
+ async function remember(path: string, value: string): Promise<void> {
41
+ if ((await readFile(path, 'utf8').catch(() => '')).trim() === value) return;
42
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
43
+ await chmod(dirname(path), 0o700);
44
+ // Written aside and renamed, so a launch never reads half a key.
45
+ const staged = `${path}.${process.pid}`;
46
+ await writeFile(staged, `${value}\n`, { mode: 0o600 });
47
+ await rename(staged, path);
48
+ }
@@ -19,6 +19,9 @@ const DECLARATION =
19
19
  const HARDWARE =
20
20
  'It is a property of the hardware, the firmware or the operating system release, which no command changes.';
21
21
 
22
+ const PARKING =
23
+ 'Whether a worker runs is the operator’s choice: park it with fleet-node-park or resume it with fleet-node-resume.';
24
+
22
25
  export const CHECK_OWNERS: CheckOwner[] = [
23
26
  // Toolchain and workstation: automatic.
24
27
  { pattern: 'tool:*', repair: 'workspace' },
@@ -68,12 +71,46 @@ export const CHECK_OWNERS: CheckOwner[] = [
68
71
  'Re-converge the worker with fleet-sync: it applies the whole worker policy at the approved revision.',
69
72
  },
70
73
  { pattern: 'worker:power-loss-recovery', manual: HARDWARE },
74
+ {
75
+ pattern: 'worker:account',
76
+ manual:
77
+ 'fleet-sync creates the declared account over a root login; when it cannot, the account is created by the operator on the machine, or declared in fleet.json.',
78
+ },
79
+ {
80
+ pattern: 'worker:reboot',
81
+ manual:
82
+ 'A reboot ends running agent turns, so it waits for them: rerun fleet-sync --maintenance-reboot once they end, or with --force.',
83
+ },
84
+ {
85
+ pattern: 'tailscale:enrolment-key',
86
+ manual:
87
+ 'The tailnet-admin credential and the tag it may use are the operator’s, kept in the secrets provider and the control plane.',
88
+ },
89
+ {
90
+ pattern: 'tailscale:key-expiry-policy',
91
+ manual:
92
+ 'The tailnet-admin credential and the tag it may use are the operator’s, kept in the secrets provider and the control plane.',
93
+ },
94
+ {
95
+ pattern: 'tailscale:device',
96
+ manual:
97
+ 'Removing a device needs the tailnet-admin credential and a declared control plane, both the operator’s; without them a person removes it in the admin console.',
98
+ },
99
+ {
100
+ pattern: 'tailscale:leftover',
101
+ manual:
102
+ 'Whether a device tagged as a worker belongs to the fleet is the operator’s choice: declare it, or remove it from the tailnet.',
103
+ },
104
+ // Whether a worker runs is the operator's choice (fleet-node-park).
105
+ { pattern: 'worker:idle', manual: PARKING },
106
+ { pattern: 'worker:park*', manual: PARKING },
107
+ { pattern: 'worker:auto-park', manual: PARKING },
71
108
  { pattern: 'worker:github-principal', manual: IDENTITY },
72
109
  { pattern: 'worker:vault-renewal', manual: IDENTITY },
73
110
  {
74
111
  pattern: 'manager:*',
75
112
  manual:
76
- 'The manager converges itself in fleet-sync; a setting that needs an administrator is applied with ansible-playbook -K ansible/macos/manager.yml.',
113
+ 'The manager converges itself in fleet-sync; what needs an administrator is granted once with ansible-playbook -e user=$(id -un) -K ansible/macos/manager-privilege.yml.',
77
114
  },
78
115
  { pattern: 'manager:identity', manual: DECLARATION },
79
116
  {
@@ -104,7 +141,7 @@ export const CHECK_OWNERS: CheckOwner[] = [
104
141
  {
105
142
  pattern: 't3:*',
106
143
  manual:
107
- 'T3 pairing and its private route are approved by a person; fleet-sync applies the rest.',
144
+ 'A worker’s first T3 pairing and its private route are approved by a person; a replaced worker is paired by fleet-node-replace or the next fleet-sync, and fleet-sync applies the rest.',
108
145
  },
109
146
  { pattern: 'vscode:*', manual: IDENTITY },
110
147
  {
@@ -130,6 +167,7 @@ export const CHECK_OWNERS: CheckOwner[] = [
130
167
  { pattern: 'github:rate-limit', manual: EVIDENCE },
131
168
  { pattern: 'github:workflow', manual: EVIDENCE },
132
169
  { pattern: 'fleet:*', manual: DECLARATION },
170
+ { pattern: 'definition:*', manual: DECLARATION },
133
171
  { pattern: 'enrollment:*', manual: DECLARATION },
134
172
  { pattern: 'notify:*', manual: DECLARATION },
135
173
  {
@@ -143,11 +181,33 @@ export const CHECK_OWNERS: CheckOwner[] = [
143
181
  'The roster reaches every node with devfleet fleet-sync --only accounts, run from the manager.',
144
182
  },
145
183
  { pattern: 'accounts:native-login', manual: IDENTITY },
184
+ { pattern: 'accounts:verification', manual: EVIDENCE },
146
185
  { pattern: 'configuration:*', manual: EVIDENCE },
147
186
  { pattern: 'source:*', manual: EVIDENCE },
148
187
  { pattern: 'bootstrap:*', manual: EVIDENCE },
149
188
  { pattern: 'rollout:*', manual: EVIDENCE },
150
189
  { pattern: 'repair:*', manual: EVIDENCE },
190
+ // A roster computer's checkout compared with the manager's.
191
+ {
192
+ pattern: 'revision',
193
+ manual:
194
+ 'The checkout of that computer differs from the manager’s: reconcile its branch, or converge it with fleet-sync; dev-fleet never resets a working tree.',
195
+ },
196
+ {
197
+ pattern: 'sync:*',
198
+ manual:
199
+ 'The checkout of that computer differs from the manager’s: reconcile its lockfiles and pins, or converge it with fleet-sync; dev-fleet never resets a working tree.',
200
+ },
201
+ {
202
+ pattern: 'restore:*',
203
+ manual:
204
+ 'fleet-restore rebuilds a replacement manager from the bucket; what it needs first (the project’s fleet.json, the bucket’s credentials, Git, Docker Desktop, a tailnet sign-in) is the operator’s: follow the remedy, then run fleet-restore again.',
205
+ },
206
+ {
207
+ pattern: 'watch:*',
208
+ manual:
209
+ 'The heartbeat is kept in the bucket fleet.json names: name it, or restore access to it, then run fleet-watch again.',
210
+ },
151
211
  ];
152
212
 
153
213
  const matches = (pattern: string, id: string): boolean =>