@crvouga/dev-fleet 0.2.0 → 0.4.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 (129) hide show
  1. package/README.md +50 -16
  2. package/ansible/github/git.yml +66 -0
  3. package/ansible/macos/manager.yml +3 -0
  4. package/ansible/macos/policy.json +11 -1
  5. package/ansible/macos/t3-policy.yml +49 -0
  6. package/ansible/monitoring/compose.yml +10 -0
  7. package/ansible/monitoring/container-watchdog.py +118 -0
  8. package/ansible/monitoring/hub-darwin.yml +146 -0
  9. package/ansible/net/remote-probe.sh +5 -0
  10. package/ansible/ubuntu/baseline.yml +2 -0
  11. package/ansible/ubuntu/disk-gc.py +25 -3
  12. package/ansible/ubuntu/disk-guard.py +24 -5
  13. package/ansible/ubuntu/policy.json +51 -1
  14. package/ansible/ubuntu/t3-desktop.yml +19 -0
  15. package/ansible/ubuntu/t3-health.sh +93 -22
  16. package/ansible/ubuntu/t3-idle.py +42 -0
  17. package/ansible/ubuntu/t3-policy.yml +4 -11
  18. package/ansible/ubuntu/tasks/browser.yml +56 -0
  19. package/ansible/ubuntu/tasks/docker.yml +52 -0
  20. package/ansible/ubuntu/tasks/recovery.yml +60 -1
  21. package/ansible/ubuntu/tasks/resources.yml +2 -2
  22. package/ansible/ubuntu/tasks/services.yml +13 -0
  23. package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
  24. package/ansible/ubuntu/wip-guard.py +121 -0
  25. package/bootstrap/doctor.ts +32 -6
  26. package/bootstrap/fleet-remote-bootstrap.sh +31 -4
  27. package/bootstrap/native-t3.ts +22 -11
  28. package/bootstrap/setup.ts +23 -8
  29. package/bootstrap/worker-tools.ts +118 -0
  30. package/cliproxy/README.md +4 -4
  31. package/cliproxy/compose.yaml +4 -0
  32. package/cliproxy/scripts/accounts.py +216 -0
  33. package/cliproxy/scripts/checkpoint.py +30 -2
  34. package/cliproxy/scripts/cli.py +1 -1
  35. package/cliproxy/scripts/clientkeys.py +99 -0
  36. package/cliproxy/scripts/lease.py +80 -0
  37. package/cliproxy/scripts/ops +137 -25
  38. package/cliproxy/scripts/restore-verify +15 -0
  39. package/docs/accounts.md +154 -0
  40. package/docs/authentication.md +90 -0
  41. package/docs/declared.md +5 -1
  42. package/docs/durability.md +26 -0
  43. package/docs/machine-setup.md +26 -15
  44. package/docs/operations.md +87 -31
  45. package/docs/runbooks.md +380 -0
  46. package/docs/state.md +13 -7
  47. package/docs/topology.md +2 -0
  48. package/docs/ubuntu-sync.md +35 -28
  49. package/examples/fleet.json +19 -1
  50. package/llms.txt +3 -0
  51. package/mise.toml +10 -1
  52. package/package.json +2 -2
  53. package/schema/check-ids.json +522 -0
  54. package/schema/fleet.schema.json +244 -1
  55. package/schema/output/check.schema.json +23 -0
  56. package/schema/output/error.schema.json +14 -0
  57. package/schema/output/fleet-report.schema.json +79 -0
  58. package/schema/output/fleet-sync.schema.json +112 -0
  59. package/schema/output/report.schema.json +38 -0
  60. package/src/accounts/checks.ts +220 -0
  61. package/src/accounts/context.ts +76 -0
  62. package/src/accounts/propagate.ts +62 -0
  63. package/src/accounts/roster.ts +85 -0
  64. package/src/accounts/selection.ts +67 -0
  65. package/src/accounts/sessions.ts +120 -0
  66. package/src/agents/account-relay.ts +176 -26
  67. package/src/agents/launch.ts +84 -5
  68. package/src/agents/native-verify.ts +128 -2
  69. package/src/auth-checks.ts +3 -0
  70. package/src/cli/agent-guide.ts +5 -3
  71. package/src/cli/fleet-accounts.ts +253 -0
  72. package/src/cli/fleet-report.ts +126 -0
  73. package/src/cli/fleet-sync-accounts.ts +58 -0
  74. package/src/cli/fleet-sync-manager.ts +31 -4
  75. package/src/cli/fleet-sync-output.ts +3 -2
  76. package/src/cli/fleet-sync-plan.ts +200 -0
  77. package/src/cli/fleet-sync-remote.ts +23 -14
  78. package/src/cli/fleet-sync-schedule.ts +142 -35
  79. package/src/cli/fleet-sync.ts +126 -8
  80. package/src/cli/fleet-watch.ts +198 -0
  81. package/src/cli/fleet.ts +4 -0
  82. package/src/cli/register-fleet.ts +86 -0
  83. package/src/cli/registry.ts +26 -0
  84. package/src/cli/schedule-logs.ts +36 -0
  85. package/src/clock.ts +63 -0
  86. package/src/definition.ts +226 -7
  87. package/src/git-identity.ts +160 -0
  88. package/src/ide/definition.ts +86 -0
  89. package/src/ide/ide.ts +43 -0
  90. package/src/ide/index.ts +5 -0
  91. package/src/ide/orchestrate.ts +50 -0
  92. package/src/ide/t3.ts +111 -0
  93. package/src/machine-health.ts +51 -4
  94. package/src/manager-health.ts +205 -0
  95. package/src/manager-sync.ts +13 -17
  96. package/src/monitoring/hub-backup.ts +63 -17
  97. package/src/monitoring/manager-hub.ts +44 -2
  98. package/src/node-converge-linux.ts +44 -12
  99. package/src/notify.ts +239 -0
  100. package/src/platform/darwin.ts +0 -13
  101. package/src/platform/fallback.ts +0 -8
  102. package/src/platform/linux.ts +0 -10
  103. package/src/platform/t3-policy.ts +33 -6
  104. package/src/platform/t3-service.ts +104 -0
  105. package/src/platform/types.ts +0 -4
  106. package/src/platform/windows.ts +0 -14
  107. package/src/remote-bootstrap.ts +87 -25
  108. package/src/remote-entry.ts +15 -1
  109. package/src/remote-operation.ts +213 -0
  110. package/src/schedule-check.ts +40 -0
  111. package/src/secrets.ts +26 -8
  112. package/src/service-auth.ts +15 -5
  113. package/src/ssh.ts +10 -2
  114. package/src/state/history.ts +172 -0
  115. package/src/state/store.ts +11 -4
  116. package/src/t3/accounts.ts +7 -1
  117. package/src/t3/models.ts +133 -0
  118. package/src/t3/pairing.ts +31 -0
  119. package/src/t3/profile.ts +203 -0
  120. package/src/t3/version.ts +119 -0
  121. package/src/t3-linked-check.ts +1 -1
  122. package/src/tailscale.ts +51 -0
  123. package/src/toolchain-checks.ts +8 -3
  124. package/src/transport.ts +3 -2
  125. package/src/worker-capability-checks.ts +167 -0
  126. package/src/worker-capacity.ts +19 -7
  127. package/src/worker-host-checks.ts +68 -13
  128. package/src/worker-policy.ts +33 -0
  129. package/src/worker-readiness.ts +10 -1
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/python3
2
+ """Push work in progress of every agent worktree to a recovery ref.
3
+
4
+ Managed by personal fleet-sync (tasks/recovery.yml) as
5
+ personal-worker-wip-guard.service, started by personal-worker-wip-guard.timer.
6
+
7
+ Workers are disposable: a worker lost mid-task must lose at most one interval
8
+ of uncommitted work. For each Git worktree under --root, the files as they are
9
+ now (tracked changes and untracked files that are not ignored) are written as
10
+ one commit on top of HEAD, built in a private index so the worktree, its index
11
+ and its branches are never touched, and pushed to
12
+
13
+ refs/recovery/<node>/<branch>
14
+
15
+ of the worktree's own remote. A clean worktree whose HEAD is already on a
16
+ remote branch has nothing to recover and pushes nothing. Recover with
17
+ `git fetch origin 'refs/recovery/*:refs/recovery/*'`.
18
+
19
+ Every decision is one JSON line on stdout.
20
+
21
+ Usage: wip-guard.py --root DIR [--root DIR ...] [--node ID] [--remote NAME]
22
+ [--max-depth N] [--dry-run]
23
+ """
24
+ import argparse
25
+ import json
26
+ import os
27
+ import re
28
+ import socket
29
+ import subprocess
30
+ import tempfile
31
+ from pathlib import Path
32
+
33
+ GIT = ['git', '-c', 'safe.directory=*']
34
+ AUTHOR = {
35
+ 'GIT_AUTHOR_NAME': 'dev-fleet work-in-progress guard',
36
+ 'GIT_AUTHOR_EMAIL': 'wip-guard@example.invalid',
37
+ 'GIT_COMMITTER_NAME': 'dev-fleet work-in-progress guard',
38
+ 'GIT_COMMITTER_EMAIL': 'wip-guard@example.invalid',
39
+ }
40
+
41
+
42
+ def log(**event):
43
+ print(json.dumps(event, sort_keys=True), flush=True)
44
+
45
+
46
+ def git(path, *args, env=None):
47
+ return subprocess.run(
48
+ [*GIT, '-C', str(path), *args], capture_output=True, text=True,
49
+ env={**os.environ, **(env or {})}, timeout=120)
50
+
51
+
52
+ def worktrees(root, depth):
53
+ if depth < 0 or not root.is_dir() or root.is_symlink():
54
+ return
55
+ if (root / '.git').exists():
56
+ yield root
57
+ return
58
+ for child in sorted(root.iterdir()):
59
+ if child.is_dir() and not child.is_symlink():
60
+ yield from worktrees(child, depth - 1)
61
+
62
+
63
+ def ref_part(value):
64
+ part = re.sub(r'[^A-Za-z0-9._/-]+', '-', value).strip('./-')
65
+ part = re.sub(r'/+', '/', part).replace('..', '.')
66
+ return part or 'unnamed'
67
+
68
+
69
+ def snapshot(path):
70
+ """The worktree's files as one commit on HEAD, or HEAD when clean."""
71
+ head = git(path, 'rev-parse', '--verify', 'HEAD').stdout.strip()
72
+ if not head:
73
+ return None, None
74
+ with tempfile.TemporaryDirectory() as private:
75
+ index = {'GIT_INDEX_FILE': os.path.join(private, 'index')}
76
+ if git(path, 'read-tree', 'HEAD', env=index).returncode != 0:
77
+ return head, None
78
+ if git(path, 'add', '--all', '--', '.', env=index).returncode != 0:
79
+ return head, None
80
+ tree = git(path, 'write-tree', env=index).stdout.strip()
81
+ if tree == git(path, 'rev-parse', 'HEAD^{tree}').stdout.strip():
82
+ return head, head
83
+ commit = git(path, 'commit-tree', tree, '-p', head, '-m',
84
+ 'Work in progress saved by the dev-fleet guard', env=AUTHOR)
85
+ return head, commit.stdout.strip() or None
86
+
87
+
88
+ def guard(path, node, remote, dry_run):
89
+ if git(path, 'remote', 'get-url', remote).returncode != 0:
90
+ return log(worktree=str(path), action='skip', reason='no-remote')
91
+ head, commit = snapshot(path)
92
+ if not commit:
93
+ return log(worktree=str(path), action='skip', reason='no-commit')
94
+ if commit == head and git(path, 'branch', '--remotes', '--contains', head).stdout.strip():
95
+ return log(worktree=str(path), action='skip', reason='clean')
96
+ branch = git(path, 'symbolic-ref', '--quiet', '--short', 'HEAD').stdout.strip()
97
+ ref = 'refs/recovery/%s/%s' % (ref_part(node), ref_part(branch or 'detached-' + head[:12]))
98
+ if dry_run:
99
+ return log(worktree=str(path), action='would-push', ref=ref)
100
+ pushed = git(path, 'push', '--force', '--no-verify', '--quiet', remote, '%s:%s' % (commit, ref))
101
+ log(worktree=str(path), action='pushed' if pushed.returncode == 0 else 'failed', ref=ref)
102
+
103
+
104
+ def main():
105
+ parser = argparse.ArgumentParser()
106
+ parser.add_argument('--root', action='append', required=True)
107
+ parser.add_argument('--node', default=socket.gethostname().split('.')[0])
108
+ parser.add_argument('--remote', default='origin')
109
+ parser.add_argument('--max-depth', type=int, default=3)
110
+ parser.add_argument('--dry-run', action='store_true')
111
+ options = parser.parse_args()
112
+ for root in options.root:
113
+ for path in worktrees(Path(root).expanduser(), options.max_depth):
114
+ try:
115
+ guard(path, options.node, options.remote, options.dry_run)
116
+ except (OSError, subprocess.SubprocessError):
117
+ log(worktree=str(path), action='failed', reason='git')
118
+
119
+
120
+ if __name__ == '__main__':
121
+ main()
@@ -1,4 +1,9 @@
1
- import { workerTools } from './worker-tools';
1
+ import {
2
+ NO_SELECTION,
3
+ parseSelectionArguments,
4
+ workerToolset,
5
+ type ToolSelection,
6
+ } from './worker-tools';
2
7
  /** Read-only toolchain checks; no logins, installations or changes to Docker. */
3
8
  import { spawnSync } from 'node:child_process';
4
9
  import { readFileSync } from 'node:fs';
@@ -15,7 +20,7 @@ import {
15
20
  type Tool = string | { version: string; lazy?: boolean; os?: string[] };
16
21
  type Config = {
17
22
  tools: Record<string, Tool>;
18
- vars?: { worker_tools?: unknown };
23
+ vars?: Record<string, unknown>;
19
24
  };
20
25
  export type Check = {
21
26
  id: string;
@@ -42,6 +47,11 @@ const VERSION_ARGS: Record<string, string[]> = {
42
47
  kubectl: ['version', '--client=true', '--output=json'],
43
48
  helm: ['version', '--short'],
44
49
  };
50
+ /** The version a tool prints, where it differs from its mise pin. */
51
+ const PRINTED: Record<string, (pin: string) => string> = {
52
+ // temurin-25.0.5+8.0.LTS prints as 25.0.5.
53
+ java: (pin) => pin.replace(/^[a-z]+-/, '').replace(/\+.*$/, ''),
54
+ };
45
55
  const run: Runner = (command) => {
46
56
  const result = spawnSync(command[0]!, command.slice(1), {
47
57
  encoding: 'utf8',
@@ -80,7 +90,8 @@ export function toolChecks(
80
90
  if (typeof tool !== 'string' && tool.lazy) continue;
81
91
  // A tool pinned only for other platforms has no artifact to verify here.
82
92
  if (unavailable(id, tool, platform)) continue;
83
- const version = typeof tool === 'string' ? tool : tool.version;
93
+ const pin = typeof tool === 'string' ? tool : tool.version;
94
+ const version = PRINTED[id]?.(pin) ?? pin;
84
95
  const command = COMMANDS[id] ?? id;
85
96
  const result = execute([command, ...(VERSION_ARGS[id] ?? ['--version'])]);
86
97
  const exact = new RegExp(
@@ -195,7 +206,21 @@ export function globalConfigCheck(
195
206
  };
196
207
  }
197
208
 
198
- export function checkMachine(requireDocker = false, worker = false): Check[] {
209
+ /** A worker installs every tool it selects, lazy or not, so each is checked. */
210
+ function installed(tools: Record<string, Tool>): Record<string, Tool> {
211
+ return Object.fromEntries(
212
+ Object.entries(tools).map(([id, tool]) => [
213
+ id,
214
+ typeof tool === 'string' ? tool : { ...tool, lazy: false },
215
+ ])
216
+ );
217
+ }
218
+
219
+ export function checkMachine(
220
+ requireDocker = false,
221
+ worker = false,
222
+ selection: ToolSelection = NO_SELECTION
223
+ ): Check[] {
199
224
  const config = Bun.TOML.parse(
200
225
  readFileSync(join(workspaceRoot, 'mise.toml'), 'utf8')
201
226
  ) as Config;
@@ -203,7 +228,7 @@ export function checkMachine(requireDocker = false, worker = false): Check[] {
203
228
  return [
204
229
  ...toolChecks(
205
230
  worker
206
- ? workerTools(config.tools, config.vars?.worker_tools)
231
+ ? installed(workerToolset(config.tools, config.vars ?? {}, selection))
207
232
  : config.tools
208
233
  ),
209
234
  ...dockerChecks(requireDocker),
@@ -222,7 +247,8 @@ export function checkMachine(requireDocker = false, worker = false): Check[] {
222
247
  if (import.meta.main) {
223
248
  const checks = checkMachine(
224
249
  process.argv.includes('--docker'),
225
- process.argv.includes('--worker')
250
+ process.argv.includes('--worker'),
251
+ parseSelectionArguments(process.argv)
226
252
  );
227
253
  const ok = checks.every((check) => check.status !== 'fail');
228
254
  if (process.argv.includes('--json'))
@@ -41,7 +41,7 @@ remedy() {
41
41
  BOOTSTRAP_LOCK_TOOL_REQUIRED) echo 'Install python3 so the operation lock can be taken, then rerun fleet-sync.';;
42
42
  SOURCE_CACHE_CHANGED|SOURCE_STATUS_FAILED|SOURCE_CHECKOUT_CONFLICT) echo 'The fleet-owned checkout of this revision holds changes fleet-sync did not make. Inspect and remove that revisions directory, then rerun fleet-sync.';;
43
43
  CONFIGURATION_INSTALL_FAILED) echo 'The fleet configuration could not be written under ~/.config/dev-fleet. Check that directory, then rerun fleet-sync.';;
44
- TOOLCHAIN_*) echo 'The pinned toolchain did not converge. Rerun fleet-sync; it resumes where it stopped. If it repeats, run devfleet doctor on the worker.';;
44
+ TOOLCHAIN_*) echo 'The pinned toolchain did not converge. Rerun fleet-sync; it resumes where it stopped. If it repeats, run devfleet fleet-status --node for this worker.';;
45
45
  *) echo 'Inspect the remote operation lock and rerun the approved revision. No success receipt advanced.';;
46
46
  esac
47
47
  }
@@ -97,7 +97,18 @@ else
97
97
  command -v python3 >/dev/null 2>&1 || die BOOTSTRAP_LOCK_TOOL_REQUIRED
98
98
  python3 -c 'import fcntl; fcntl.flock(9, fcntl.LOCK_EX | fcntl.LOCK_NB)' 9>&9 || die FLEET_REMOTE_LOCKED
99
99
  fi
100
- printf '{"version":1,"pid":%s,"revision":"%s","node":"%s"}\n' "$$" "$revision" "$node" >"$root/remote-operation.json"
100
+ # The operation record: a manager that lost its session reads it to follow
101
+ # this operation to its end instead of starting another (see
102
+ # src/remote-operation.ts). The result of the last operation is beside it.
103
+ started=$(date +%s)
104
+ result="$root/remote-operation-result.json"
105
+ [ ! -L "$root/remote-operation.json" ] && [ ! -L "$result" ] || die FLEET_STATE_PATH_UNSAFE
106
+ rm -f -- "$result"
107
+ operation() {
108
+ printf '{"version":1,"pid":%s,"revision":"%s","node":"%s","started":%s,"phase":"%s"%s}\n' "$$" "$revision" "$node" "$started" "$1" "${2:-}" >"$root/remote-operation.json.new" &&
109
+ mv -f -- "$root/remote-operation.json.new" "$root/remote-operation.json"
110
+ }
111
+ operation lock
101
112
  # The fleet configuration is installed under the lock, so two overlapping
102
113
  # runs never write it at once. It holds no secret.
103
114
  phase='configuration-install'
@@ -109,6 +120,7 @@ if [ -n "$configuration" ]; then
109
120
  mv "$config_dir/fleet.json.new" "$config_dir/fleet.json" || die CONFIGURATION_INSTALL_FAILED
110
121
  fi
111
122
  phase=resources
123
+ operation resources
112
124
  # A bootstrap floor protects source transfer and minimal prerequisites. The
113
125
  # Workstation baseline subsequently enforces its stricter declared capacity.
114
126
  free_kib=$(df -Pk "$root" | awk 'END {print $4}')
@@ -200,6 +212,7 @@ bundle=''
200
212
  clean=$(git -C "$deployment" status --porcelain) || die SOURCE_STATUS_FAILED
201
213
  [ -z "$clean" ] || die SOURCE_CHECKOUT_CONFLICT
202
214
  phase=toolchain
215
+ operation toolchain
203
216
  cd "$deployment"
204
217
  export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:/usr/local/bin:/usr/bin:/bin"
205
218
  status_file="$root/toolchain-status.json"
@@ -211,6 +224,11 @@ ready_file="$root/toolchain-ready"
211
224
  # are still checked (and reinstalled on drift) by node convergence.
212
225
  toolchain_key=$(git rev-parse HEAD:mise.toml HEAD:mise.lock HEAD:.mise HEAD:bun.lock HEAD:package.json HEAD:bootstrap 2>/dev/null | tr '\n' ' ')
213
226
  [ -n "$toolchain_key" ] || toolchain_key=$revision
227
+ # The tool profiles of the fleet configuration choose what is installed, so a
228
+ # changed configuration runs the setup again.
229
+ if [ -f "$HOME/.config/dev-fleet/fleet.json" ]; then
230
+ toolchain_key="$toolchain_key$(cksum <"$HOME/.config/dev-fleet/fleet.json" | cut -d' ' -f1)"
231
+ fi
214
232
  if [ "$(cat "$ready_file" 2>/dev/null)" = "$toolchain_key" ] && [ -n "$(find "$ready_file" -mmin -1440 2>/dev/null)" ]; then
215
233
  toolchain_warm=1
216
234
  else
@@ -219,7 +237,7 @@ else
219
237
  fi
220
238
  if command -v timeout >/dev/null 2>&1; then bounded='timeout 1800'; else bounded=''; fi
221
239
  # shellcheck disable=SC2086
222
- if [ -z "$toolchain_warm" ] && ! $bounded bash bootstrap/bootstrap.sh --worker --non-interactive --status-file "$status_file" >/dev/null 2>&1; then
240
+ if [ -z "$toolchain_warm" ] && ! $bounded bash bootstrap/bootstrap.sh --worker --node "$node" --non-interactive --status-file "$status_file" >/dev/null 2>&1; then
223
241
  tool_phase=$(sed -n 's/.*"phase":"\([a-z0-9-]*\)".*/\1/p' "$status_file" 2>/dev/null) || tool_phase=''
224
242
  case "$tool_phase" in
225
243
  managed-config) die TOOLCHAIN_CONFIG_CONFLICT;;
@@ -254,5 +272,14 @@ for old in $(ls -1t "$root/revisions"); do
254
272
  rm -rf -- "${root:?}/revisions/$old" || true
255
273
  done
256
274
  phase=configuration
275
+ operation configuration
257
276
  trap - EXIT INT TERM
258
- exec mise exec --locked bun -- bun src/node-converge.ts "$node" "$revision"
277
+ # Node convergence writes its evidence to the result file, not to the SSH
278
+ # session: the session may be gone by the time it ends. The evidence is then
279
+ # printed for the manager that is still connected, under the same lock.
280
+ code=0
281
+ mise exec --locked bun -- bun src/node-converge.ts "$node" "$revision" >"$result.partial" 2>"$root/remote-operation.log" || code=$?
282
+ mv -f -- "$result.partial" "$result"
283
+ operation finished ",\"finished\":$(date +%s),\"exit\":$code"
284
+ cat -- "$result" || true
285
+ exit "$code"
@@ -19,6 +19,7 @@ interface Configuration {
19
19
  t3_version: string;
20
20
  t3_linux_x64_sha256: string;
21
21
  t3_linux_arm64_sha256: string;
22
+ t3_darwin_arm64_sha256?: string;
22
23
  t3_desktop_amd64_sha256: string;
23
24
  t3_desktop_arm64_sha256: string;
24
25
  };
@@ -52,20 +53,27 @@ export function managedT3Desktop() {
52
53
  ) as Configuration;
53
54
  return desktopT3Artifact(config, process.arch);
54
55
  }
55
- export function nativeT3Artifact(config: Configuration, architecture: string) {
56
- if (!['x64', 'arm64'].includes(architecture))
57
- throw new Error('Unsupported T3 architecture.');
56
+ /** The pinned server archive: Ubuntu workers on either architecture, and
57
+ * the manager (macOS on Apple silicon, the only macOS build upstream ships).
58
+ */
59
+ export function nativeT3Artifact(
60
+ config: Configuration,
61
+ architecture: string,
62
+ platform: string = 'linux'
63
+ ) {
64
+ const sha256 = (<Record<string, string | undefined>>{
65
+ 'linux-x64': config.vars.t3_linux_x64_sha256,
66
+ 'linux-arm64': config.vars.t3_linux_arm64_sha256,
67
+ 'darwin-arm64': config.vars.t3_darwin_arm64_sha256,
68
+ })[`${platform}-${architecture}`];
69
+ if (sha256 === undefined) throw new Error('Unsupported T3 architecture.');
58
70
  const version = config.vars.t3_version;
59
- const sha256 =
60
- architecture === 'x64'
61
- ? config.vars.t3_linux_x64_sha256
62
- : config.vars.t3_linux_arm64_sha256;
63
71
  if (
64
72
  !/^\d+\.\d+\.\d+(?:-nightly\.\d{8}\.\d+)?$/.test(version) ||
65
73
  !/^[a-f0-9]{64}$/.test(sha256)
66
74
  )
67
75
  throw new Error('Invalid T3 pin.');
68
- const name = `t3-${version}-linux-${architecture}`;
76
+ const name = `t3-${version}-${platform}-${architecture}`;
69
77
  return {
70
78
  version,
71
79
  sha256,
@@ -77,18 +85,21 @@ export function managedT3(): { path: string; version: string } {
77
85
  const config = Bun.TOML.parse(
78
86
  readFileSync(join(workspaceRoot, 'mise.toml'), 'utf8')
79
87
  ) as Configuration;
80
- const artifact = nativeT3Artifact(config, process.arch);
88
+ const artifact = nativeT3Artifact(config, process.arch, process.platform);
81
89
  return {
82
90
  path: join(homedir(), '.local/share/personal/tools', artifact.name, 't3'),
83
91
  version: artifact.version,
84
92
  };
85
93
  }
94
+ /** Installs the pinned, checksum-verified server archive on a worker and on
95
+ * the manager, so both run the T3 version the fleet pins.
96
+ */
86
97
  export async function installNativeT3(): Promise<void> {
87
- if (process.platform !== 'linux') return;
98
+ if (!['linux', 'darwin'].includes(process.platform)) return;
88
99
  const config = Bun.TOML.parse(
89
100
  readFileSync(join(workspaceRoot, 'mise.toml'), 'utf8')
90
101
  ) as Configuration;
91
- const artifact = nativeT3Artifact(config, process.arch);
102
+ const artifact = nativeT3Artifact(config, process.arch, process.platform);
92
103
  const root = join(homedir(), '.local/share/personal/tools');
93
104
  const archive = join(root, `${artifact.name}.tar.gz`);
94
105
  mkdirSync(root, { recursive: true, mode: 0o700 });
@@ -1,4 +1,9 @@
1
- import { workerTools } from './worker-tools';
1
+ import {
2
+ installedSelection,
3
+ NO_SELECTION,
4
+ workerToolset,
5
+ type ToolSelection,
6
+ } from './worker-tools';
2
7
  import { readFileSync } from 'node:fs';
3
8
  import { join } from 'node:path';
4
9
  /** Used by both OS bootstraps, dev-fleet tools sync and dev-fleet update. No workspace dependencies. */
@@ -26,7 +31,8 @@ function run(args: string[]): void {
26
31
 
27
32
  export async function syncTools(
28
33
  observe: (phase: SetupPhase) => void = () => {},
29
- worker = false
34
+ worker = false,
35
+ selection: ToolSelection = NO_SELECTION
30
36
  ): Promise<void> {
31
37
  observe('managed-config');
32
38
  const dir = globalConfigDir();
@@ -34,9 +40,9 @@ export async function syncTools(
34
40
  observe('mise');
35
41
  const config = Bun.TOML.parse(
36
42
  readFileSync(join(workspaceRoot, 'mise.toml'), 'utf8')
37
- ) as { tools: Record<string, unknown>; vars: { worker_tools: unknown } };
43
+ ) as { tools: Record<string, unknown>; vars: Record<string, unknown> };
38
44
  const selected = worker
39
- ? Object.keys(workerTools(config.tools, config.vars.worker_tools))
45
+ ? Object.keys(workerToolset(config.tools, config.vars, selection))
40
46
  : [];
41
47
  run(['mise', 'install', '--locked', ...selected]);
42
48
  observe('aws');
@@ -54,11 +60,20 @@ if (import.meta.main) {
54
60
  const flag = process.argv.indexOf('--status-file');
55
61
  const report = setupStatus(flag < 0 ? undefined : process.argv[flag + 1]);
56
62
  let phase: SetupPhase = 'managed-config';
63
+ const worker = process.argv.includes('--worker');
64
+ const node = process.argv.indexOf('--node');
57
65
  try {
58
- await syncTools((current) => {
59
- phase = current;
60
- report(phase, 'running');
61
- }, process.argv.includes('--worker'));
66
+ await syncTools(
67
+ (current) => {
68
+ phase = current;
69
+ report(phase, 'running');
70
+ },
71
+ worker,
72
+ // The tool profiles of the fleet configuration fleet-sync installed.
73
+ worker && node >= 0
74
+ ? installedSelection(process.argv[node + 1])
75
+ : NO_SELECTION
76
+ );
62
77
  if (!process.argv.includes('--tools-only')) {
63
78
  phase = 'dependencies';
64
79
  report(phase, 'running');
@@ -1,3 +1,7 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
1
5
  /** A worker installs only this declared subset; every version still comes from mise.lock. */
2
6
  export function workerTools<T>(
3
7
  tools: Record<string, T>,
@@ -22,3 +26,117 @@ export function workerTools<T>(
22
26
  selected.map((key: string) => [key, tools[key]!])
23
27
  ) as Record<string, T>;
24
28
  }
29
+
30
+ /** Named tool sets a fleet adds to its workers on top of the base set
31
+ * (`worker_tools`). Each is `worker_profile_<name>` under [vars] in
32
+ * mise.toml; every version still comes from mise.lock.
33
+ */
34
+ export const TOOL_PROFILES = ['web', 'go', 'rust', 'jvm'] as const;
35
+ export type ToolProfile = (typeof TOOL_PROFILES)[number];
36
+
37
+ /** What a fleet configuration adds to one worker's base tools: profiles and
38
+ * single tools, each already pinned in mise.toml.
39
+ */
40
+ export interface ToolSelection {
41
+ profiles: string[];
42
+ tools: string[];
43
+ }
44
+ export const NO_SELECTION: ToolSelection = { profiles: [], tools: [] };
45
+
46
+ const list = (value: unknown): string[] =>
47
+ Array.isArray(value)
48
+ ? value.filter((item): item is string => typeof item === 'string')
49
+ : [];
50
+ const record = (value: unknown): value is Record<string, unknown> =>
51
+ typeof value === 'object' && value !== null && !Array.isArray(value);
52
+
53
+ /** The selection a fleet configuration makes for one worker: the fleet-wide
54
+ * `worker.toolProfiles` and `worker.tools`, plus that worker's own
55
+ * `toolProfiles`. Read from the plain document, so the bootstrap needs no
56
+ * fleet code.
57
+ */
58
+ export function workerSelection(
59
+ definition: unknown,
60
+ node?: string
61
+ ): ToolSelection {
62
+ if (!record(definition)) return NO_SELECTION;
63
+ const policy = record(definition['worker']) ? definition['worker'] : {};
64
+ const workers: unknown[] = Array.isArray(definition['workers'])
65
+ ? definition['workers']
66
+ : [];
67
+ const own = workers
68
+ .filter(record)
69
+ .filter((entry) => node !== undefined && entry['id'] === node)
70
+ .flatMap((entry) => list(entry['toolProfiles']));
71
+ return {
72
+ profiles: [...new Set([...list(policy['toolProfiles']), ...own])],
73
+ tools: [...new Set(list(policy['tools']))],
74
+ };
75
+ }
76
+
77
+ /** The selection of the fleet configuration fleet-sync installed on this
78
+ * machine; none when there is no configuration yet.
79
+ */
80
+ export function installedSelection(
81
+ node: string | undefined,
82
+ home: string = homedir()
83
+ ): ToolSelection {
84
+ const path = join(home, '.config/dev-fleet/fleet.json');
85
+ if (!existsSync(path)) return NO_SELECTION;
86
+ try {
87
+ return workerSelection(JSON.parse(readFileSync(path, 'utf8')), node);
88
+ } catch {
89
+ return NO_SELECTION;
90
+ }
91
+ }
92
+
93
+ /** The tools of one worker: the base set, then each selected profile's, then
94
+ * the single tools, in that order and each once. A profile mise.toml does not
95
+ * declare, or a tool it does not pin, is refused.
96
+ */
97
+ export function workerToolset<T>(
98
+ tools: Record<string, T>,
99
+ vars: Record<string, unknown>,
100
+ selection: ToolSelection = NO_SELECTION
101
+ ): Record<string, T> {
102
+ const names = (value: unknown) =>
103
+ typeof value === 'string' ? value.split(',').filter(Boolean) : [];
104
+ const base = names(vars['worker_tools']);
105
+ const added = selection.profiles.flatMap((profile) => {
106
+ const declared = vars[`worker_profile_${profile}`];
107
+ if (typeof declared !== 'string' || !declared)
108
+ throw new Error(
109
+ `Unknown worker tool profile "${profile}". Choose from ${TOOL_PROFILES.join(', ')}.`
110
+ );
111
+ return names(declared);
112
+ });
113
+ for (const tool of selection.tools)
114
+ if (!Object.hasOwn(tools, tool))
115
+ throw new Error(
116
+ `The worker tool "${tool}" has no pin in dev-fleet's mise.toml. Ask for it in an issue.`
117
+ );
118
+ return workerTools(tools, [
119
+ ...new Set([...base, ...added, ...selection.tools]),
120
+ ]);
121
+ }
122
+
123
+ /** `--profiles web,go --tools fd` for the doctor and the setup. */
124
+ export function selectionArguments(selection: ToolSelection): string[] {
125
+ return [
126
+ ...(selection.profiles.length
127
+ ? ['--profiles', selection.profiles.join(',')]
128
+ : []),
129
+ ...(selection.tools.length ? ['--tools', selection.tools.join(',')] : []),
130
+ ];
131
+ }
132
+
133
+ /** The selection named by `--profiles` and `--tools`. */
134
+ export function parseSelectionArguments(argv: string[]): ToolSelection {
135
+ const value = (flag: string) => {
136
+ const index = argv.indexOf(flag);
137
+ return index < 0
138
+ ? []
139
+ : (argv[index + 1] ?? '').split(',').filter((item) => item.length > 0);
140
+ };
141
+ return { profiles: value('--profiles'), tools: value('--tools') };
142
+ }
@@ -146,11 +146,11 @@ devfleet cliproxy inference responses EXACT_CODEX_MODEL_ID
146
146
  devfleet cliproxy inference messages EXACT_CLAUDE_MODEL_ID
147
147
  ```
148
148
 
149
- Codex uses the upstream device-code flow. Remote callback flows may require the documented dashboard callback flow or temporary SSH loopback forwarding; callback ports are not exposed by default. This pinned version has no generic Gemini CLI login flag; use its supported dashboard/API-key integration. Provider rules still govern permissible account use.
149
+ `devfleet fleet-accounts --add|--relogin|--remove` wraps these and propagates the result to every node; a login writes into a staging directory and only a complete credential is moved in. Codex uses the upstream device-code flow. Remote callback flows may require the documented dashboard callback flow or temporary SSH loopback forwarding; callback ports are not exposed by default. This pinned version has no generic Gemini CLI login flag; use its supported dashboard/API-key integration. Provider rules still govern permissible account use.
150
150
 
151
151
  **Account routing.** Credential files are the only list of accounts. `ops declared` (run by every fleet-sync) gives each Claude, Codex and Antigravity credential the model prefix `<type>-<email>` through the proxy's management API (`scripts/accounts.py`; a credential file is never edited directly). A request for `<type>-<email>/<model>` is served only by that account, and `/v1/models` lists those ids, which is how every fleet node discovers the accounts to offer in T3. Nothing in Git names an account.
152
152
 
153
- Unprefixed routing is **fill-first** (`routing.strategy: fill-first`): one account serves until its quota is exhausted and it cools down, then the next account, with session affinity keeping a session on its account while available and bounded retries across eligible accounts for the requested model. `cliproxy apply` requires this policy and the host monitor reports drift; see [Multi-account routing](docs/model-health.md#multi-account-routing-fill-first) for applying it to a running proxy. Automatic preview-model switching is disabled. No model aliases are configured: clients use the upstream model ids exactly as `/v1/models` lists them. [`models.json`](models.json) declares the model ids that are health-checked (`claude-opus-5-5`, `claude-sonnet-5-5`, `gpt-6-astra`, `gpt-6.1-sol`), and the checks fail loudly when one is not served. Usage statistics are enabled but are not a durable billing ledger. Available quota details depend on the provider.
153
+ Unprefixed routing is **fill-first** (`routing.strategy: fill-first`): one account serves until its quota is exhausted and it cools down, then the next account, with session affinity keeping a session on its account while available and bounded retries across eligible accounts for the requested model. `cliproxy apply` requires this policy and the host monitor reports drift; see [Multi-account routing](docs/model-health.md#multi-account-routing-fill-first) for applying it to a running proxy. Automatic preview-model switching is disabled. The only model aliases are the ones `config.template.yaml` declares under `oauth.model-alias` (`ops` applies them and waits until `/v1/models` serves them); every other model id is the upstream id exactly as `/v1/models` lists it. [`models.json`](models.json) declares the model ids that are health-checked (`claude-opus-5-5`, `claude-sonnet-5-5`, `gpt-6-astra`, `gpt-6.1-sol`), and the checks fail loudly when one is not served. Usage statistics are enabled but are not a durable billing ledger. Available quota details depend on the provider.
154
154
 
155
155
  ## Fleet clients: macOS, Ubuntu and Windows/WSL
156
156
 
@@ -167,7 +167,7 @@ python3 cliproxy/clients/bootstrap.py --validate
167
167
 
168
168
  Enter the inference key at the hidden prompt. It is available in Vault `secret/personal/prd:CLIPROXY_FLEET_CLIENT_API_KEY`; dashboard login uses `CLIPROXY_FLEET_MANAGEMENT_KEY`. Secrets never appear in command arguments. Keys are saved mode 0600 and loaded only into child environments. Give runners separate revocable inference keys where needed, never provider OAuth credentials or the admin key.
169
169
 
170
- Codex uses custom-provider CLI overrides and Responses HTTP/SSE. Claude uses its supported base URL/auth-token variables and an explicit Claude model. OpenCode uses its config-file override, with separate Responses and Anthropic SDK providers. Once Claude is authenticated, rerun with `--claude-model MODEL_ID`; otherwise the Claude launcher fails with an explicit setup requirement. Native client config files are preserved. Use plain native clients during a proxy outage.
170
+ Codex uses custom-provider CLI overrides and Responses HTTP/SSE. Claude uses its supported base URL/auth-token variables and an explicit Claude model. OpenCode uses its config-file override, with separate Responses and Anthropic SDK providers. Once Claude is authenticated, rerun with `--claude-model MODEL_ID`; otherwise the Claude launcher fails with an explicit setup requirement. Native client config files are preserved. Fleet nodes never fall back to native clients: provider accounts exist only on the proxy (see [docs/accounts.md](../docs/accounts.md)), and during a proxy outage every relayed launcher retries for about a minute, then reports "proxy unreachable".
171
171
 
172
172
  Run bootstrap from a repository checkout containing `clients/launcher.py`; detached downloaded bootstrap files are unsupported. Bootstrap is idempotent and refuses unrelated launcher collisions. `--uninstall` removes launcher links while retaining private settings for deliberate cleanup. On Windows run inside WSL; native PowerShell launchers are not provided. Validate from each actual machine and target repository because OpenCode merges project settings.
173
173
 
@@ -187,7 +187,7 @@ Vault login is interactive with hidden input; its token is restricted locally an
187
187
 
188
188
  Encrypted cloud checkpoints run every 15 minutes (legacy systemd adds up to 30 seconds jitter). A checkpoint never stops, pauses or restarts the stack: agents use the proxy around the clock, and a stopped connector fails every request. The capture copies the live state and keeps the copy only if no state file changed while it was taken (retrying a few times); a stack that never holds still fails that backup. A failed capture, restart, upload or retention operation records failure and never advances the success marker. Retention keeps the latest 96 snapshots plus 7 daily, 5 weekly and 12 monthly. Weekly integrity checks read 10% of stored data; `check-full` reads all data. Monitoring fails when the last successful checkpoint is older than 45 minutes. Failures are visible in `cliproxy status` (and `launchctl print gui/$UID/LABEL` or systemd); off-machine checks also run through the CLI proxy health workflow described below.
189
189
 
190
- R2 checkpoints are the durable recovery source for config, provider OAuth/account state and the dedicated Tailscale identity. Local state is a working cache with asynchronous durability: normally up to approximately 15 minutes plus upload duration can be lost, and an outage can extend that indefinitely. Run a successful manual checkpoint after important login/config changes or before moving machines. Provider refresh-token invalidation can still require a fresh provider login after recovering an older checkpoint. This is a single-writer deployment, with operator fencing of the previous node; there is no distributed lease or active-active support.
190
+ R2 checkpoints are the durable recovery source for config, provider OAuth/account state and the dedicated Tailscale identity. Local state is a working cache with asynchronous durability: normally up to approximately 15 minutes plus upload duration can be lost, and an outage can extend that indefinitely. Run a successful manual checkpoint after important login/config changes or before moving machines. Provider refresh-token invalidation can still require a fresh provider login after recovering an older checkpoint. Each checkpoint records a digest of every refresh token, so `restore-verify RESTORED --newest NEWEST` names exactly the accounts that need one. This is a single-writer deployment, with operator fencing of the previous node; there is no distributed lease or active-active support.
191
191
 
192
192
  Checkpoints include only allowlisted runtime state, endpoint intent, repository identity and source revision metadata. GitHub supplies code; Vault supplies client/admin keys, the encryption password and current cloud credentials. Vault/tunnel tokens, local secret files, logs, prior config copies, status files and executable source are excluded. Config and OAuth files themselves contain secrets and remain encrypted in R2. Local capture/restore directories are private plaintext caches, deleted after normal completion. Abandoned generated `checkpoint-*` and `restore-*` directories under `recovery/` expire after 24 hours. Cleanup runs under the operation lock before each backup operation, so active local captures/restores cannot be collected. Unknown/manual rollback directories are preserved. Restic runs without a persistent local metadata cache.
193
193
 
@@ -3,6 +3,10 @@ services:
3
3
  proxy:
4
4
  image: ${CLI_PROXY_IMAGE:?image must be pinned}
5
5
  restart: unless-stopped
6
+ # Docker restarts a proxy that exits; the container watchdog on the
7
+ # manager restarts one that stays unhealthy.
8
+ labels:
9
+ dev.devfleet.watchdog: restart
6
10
  user: '${CLIPROXY_UID:-1000}:${CLIPROXY_GID:-1000}'
7
11
  working_dir: /state
8
12
  command: ['/CLIProxyAPI/CLIProxyAPI', '-config', '/state/config.yaml']