@crvouga/dev-fleet 0.1.0 → 0.3.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.
- package/README.md +265 -37
- package/agents/setup.md +42 -12
- package/ansible/macos/manager.yml +10 -7
- package/ansible/macos/policy.json +2 -10
- package/ansible/macos/t3-policy.yml +49 -0
- package/ansible/monitoring/README.md +86 -25
- package/ansible/monitoring/agent.yml +40 -2
- package/ansible/monitoring/hub-route-darwin.yml +51 -0
- package/ansible/monitoring/lock.json +5 -0
- package/ansible/pyproject.toml +12 -0
- package/ansible/ubuntu/baseline.yml +1 -1
- package/ansible/ubuntu/disk-gc.py +336 -0
- package/ansible/ubuntu/policy.json +13 -3
- package/ansible/ubuntu/t3-desktop.yml +19 -0
- package/ansible/ubuntu/t3-health.sh +93 -22
- package/ansible/ubuntu/t3-idle.py +42 -0
- package/ansible/ubuntu/t3-policy.yml +4 -11
- package/ansible/ubuntu/tailscale.yml +8 -5
- package/ansible/ubuntu/tasks/recovery.yml +60 -1
- package/ansible/ubuntu/tasks/resources.yml +61 -1
- package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
- package/ansible/ubuntu/wip-guard.py +121 -0
- package/ansible/uv.lock +190 -0
- package/bootstrap/bootstrap.sh +25 -18
- package/bootstrap/doctor.ts +46 -1
- package/bootstrap/fleet-manager-run.sh +42 -15
- package/bootstrap/fleet-remote-bootstrap.sh +85 -12
- package/bootstrap/global-config.ts +64 -6
- package/bootstrap/install-launcher.sh +3 -0
- package/bootstrap/native-t3.ts +22 -11
- package/bootstrap/npm.ts +10 -2
- package/bootstrap/release-version.ts +157 -0
- package/cliproxy/README.md +3 -3
- package/cliproxy/scripts/accounts.py +216 -0
- package/cliproxy/scripts/checkpoint.py +29 -1
- package/cliproxy/scripts/cli.py +1 -1
- package/cliproxy/scripts/clientkeys.py +99 -0
- package/cliproxy/scripts/lease.py +80 -0
- package/cliproxy/scripts/ops +137 -25
- package/cliproxy/scripts/restore-verify +15 -0
- package/docs/accounts.md +154 -0
- package/docs/consumer-setup.md +408 -0
- package/docs/declared.md +91 -0
- package/docs/durability.md +26 -0
- package/docs/machine-setup.md +400 -0
- package/docs/operations.md +276 -0
- package/docs/releasing.md +103 -0
- package/docs/state.md +254 -0
- package/docs/topology.md +69 -0
- package/docs/ubuntu-sync.md +357 -0
- package/docs/worker-contract.md +67 -0
- package/examples/fleet.json +5 -0
- package/llms.txt +36 -0
- package/mise.lock +29 -0
- package/mise.toml +15 -2
- package/package.json +11 -2
- package/schema/fleet.schema.json +137 -15
- package/src/accounts/checks.ts +220 -0
- package/src/accounts/context.ts +76 -0
- package/src/accounts/propagate.ts +62 -0
- package/src/accounts/roster.ts +85 -0
- package/src/accounts/selection.ts +67 -0
- package/src/accounts/sessions.ts +120 -0
- package/src/agents/account-relay.ts +176 -26
- package/src/agents/fleet-verify.ts +9 -0
- package/src/agents/launch.ts +84 -5
- package/src/agents/native-verify.ts +293 -22
- package/src/agents/provider-errors.ts +9 -2
- package/src/agents/registry.ts +7 -1
- package/src/cli/agent-guide.ts +39 -5
- package/src/cli/app.ts +11 -4
- package/src/cli/cli.ts +7 -0
- package/src/cli/command-index.ts +7 -0
- package/src/cli/fleet-accounts.ts +253 -0
- package/src/cli/fleet-state.ts +499 -0
- package/src/cli/fleet-sync-accounts.ts +58 -0
- package/src/cli/fleet-sync-checkout.ts +56 -17
- package/src/cli/fleet-sync-enrollment.ts +19 -23
- package/src/cli/fleet-sync-forward.ts +1 -1
- package/src/cli/fleet-sync-manager.ts +84 -12
- package/src/cli/fleet-sync-monitoring.ts +5 -5
- package/src/cli/fleet-sync-output.ts +7 -3
- package/src/cli/fleet-sync-remote.ts +31 -18
- package/src/cli/fleet-sync-state.ts +86 -0
- package/src/cli/fleet-sync.ts +159 -57
- package/src/cli/fleet.ts +4 -2
- package/src/cli/managed-health.ts +4 -4
- package/src/cli/node-sync.ts +7 -0
- package/src/cli/program.ts +20 -4
- package/src/cli/project.ts +51 -0
- package/src/cli/register-fleet.ts +86 -1
- package/src/cli/registry.ts +41 -1
- package/src/cli/roster.ts +46 -30
- package/src/cli/settings.ts +15 -12
- package/src/cli/source-operations.ts +27 -3
- package/src/cli/tailnet.ts +2 -2
- package/src/definition.ts +116 -4
- package/src/diagnostics.ts +1 -1
- package/src/fleet-config.ts +105 -19
- package/src/ide/definition.ts +86 -0
- package/src/ide/ide.ts +43 -0
- package/src/ide/index.ts +5 -0
- package/src/ide/orchestrate.ts +50 -0
- package/src/ide/t3.ts +111 -0
- package/src/machine/source.ts +3 -3
- package/src/machine/state.ts +15 -27
- package/src/manager-sync.ts +9 -18
- package/src/monitoring/agent-evidence.ts +146 -0
- package/src/monitoring/apply.ts +15 -3
- package/src/monitoring/beszel.ts +11 -4
- package/src/monitoring/enroll.ts +2 -1
- package/src/monitoring/hub-access.ts +4 -3
- package/src/monitoring/hub-backup.ts +121 -0
- package/src/monitoring/hub-viewer.ts +8 -8
- package/src/monitoring/hub.ts +3 -3
- package/src/monitoring/manager-hub.ts +30 -14
- package/src/node-converge-linux.ts +30 -7
- package/src/package-info.ts +45 -0
- package/src/platform/ansible.ts +19 -15
- package/src/platform/darwin.ts +0 -13
- package/src/platform/fallback.ts +0 -8
- package/src/platform/linux.ts +0 -10
- package/src/platform/resource-verify.ts +8 -4
- package/src/platform/t3-policy.ts +33 -6
- package/src/platform/t3-service.ts +104 -0
- package/src/platform/types.ts +0 -4
- package/src/platform/ubuntu-audit.ts +5 -2
- package/src/platform/windows.ts +0 -14
- package/src/remote-bootstrap.ts +17 -19
- package/src/remote-entry.ts +15 -1
- package/src/repair-catalog.ts +1 -1
- package/src/runner.ts +1 -1
- package/src/service-auth.ts +15 -5
- package/src/state/bucket.ts +208 -0
- package/src/state/check.ts +74 -0
- package/src/state/connection.ts +92 -0
- package/src/state/enrollment.ts +45 -0
- package/src/state/legacy.ts +89 -0
- package/src/state/litestream.ts +143 -0
- package/src/state/operator.ts +20 -0
- package/src/state/paths.ts +40 -0
- package/src/state/roster.ts +62 -0
- package/src/state/store.ts +169 -0
- package/src/state/sync.ts +352 -0
- package/src/t3/accounts.ts +7 -1
- package/src/t3/models.ts +133 -0
- package/src/t3/pairing.ts +31 -0
- package/src/t3/profile.ts +203 -0
- package/src/t3/version.ts +119 -0
- package/src/t3-endpoint.ts +64 -8
- package/src/t3-linked-check.ts +24 -2
- package/src/toolchain-checks.ts +25 -0
- package/src/transport.ts +3 -2
- package/src/types.ts +4 -0
- package/src/worker-access-checks.ts +56 -3
- package/src/worker-host-checks.ts +41 -13
- package/src/workstation-checks.ts +1 -1
- package/src/index.ts +0 -6
- package/src/test-fixtures.ts +0 -36
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""One proxy client key per node, rotated with overlap and revoked with the node.
|
|
2
|
+
|
|
3
|
+
The key ring is a private JSON document in the proxy's secrets
|
|
4
|
+
(`state/secrets/client-keys.json`): per node, the current key and, during a
|
|
5
|
+
rotation, the previous one. The proxy accepts every key in the ring
|
|
6
|
+
(`api_keys`), so a rotation adds the new key, every node moves to it, and
|
|
7
|
+
only then is the old key retired: no request fails in between. Revoking a
|
|
8
|
+
node removes its keys and leaves every other node working. Reports name keys
|
|
9
|
+
only by a short fingerprint, never by value.
|
|
10
|
+
"""
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import pathlib
|
|
15
|
+
import re
|
|
16
|
+
import secrets
|
|
17
|
+
|
|
18
|
+
NODE = re.compile(r'^[a-z][a-z0-9-]{0,62}$')
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def fingerprint(key):
|
|
22
|
+
return hashlib.sha256(key.encode()).hexdigest()[:12]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load(path):
|
|
26
|
+
try:
|
|
27
|
+
ring = json.loads(pathlib.Path(path).read_text())
|
|
28
|
+
except (OSError, ValueError):
|
|
29
|
+
return {'version': 1, 'nodes': {}}
|
|
30
|
+
if not isinstance(ring, dict) or ring.get('version') != 1:
|
|
31
|
+
raise ValueError('Unsupported client key ring')
|
|
32
|
+
return ring
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def save(path, ring):
|
|
36
|
+
path = pathlib.Path(path)
|
|
37
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
staged = path.with_suffix('.new')
|
|
39
|
+
staged.write_text(json.dumps(ring, indent=2, sort_keys=True) + '\n')
|
|
40
|
+
os.chmod(staged, 0o600)
|
|
41
|
+
os.replace(staged, path)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _node(ring, node):
|
|
45
|
+
if not NODE.match(node):
|
|
46
|
+
raise ValueError('Invalid node id')
|
|
47
|
+
return ring['nodes'].setdefault(node, {'current': None, 'previous': None})
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def issue(ring, node, generate=lambda: secrets.token_urlsafe(32)):
|
|
51
|
+
"""Gives `node` a key when it has none; returns its current key."""
|
|
52
|
+
entry = _node(ring, node)
|
|
53
|
+
if not entry['current']:
|
|
54
|
+
entry['current'] = generate()
|
|
55
|
+
return entry['current']
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def rotate(ring, node, generate=lambda: secrets.token_urlsafe(32)):
|
|
59
|
+
"""Starts a rotation: a new current key, the old one kept as previous
|
|
60
|
+
until `retire`. Both are accepted meanwhile."""
|
|
61
|
+
entry = _node(ring, node)
|
|
62
|
+
if entry['previous']:
|
|
63
|
+
raise ValueError('A rotation of this node is already in progress; retire it first')
|
|
64
|
+
entry['previous'], entry['current'] = entry['current'], generate()
|
|
65
|
+
return entry['current']
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def retire(ring, node):
|
|
69
|
+
"""Ends a rotation once the node uses its new key."""
|
|
70
|
+
_node(ring, node)['previous'] = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def revoke(ring, node):
|
|
74
|
+
"""Removes every key of `node`."""
|
|
75
|
+
ring['nodes'].pop(node, None)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def api_keys(ring, shared=()):
|
|
79
|
+
"""Every key the proxy accepts: the ring's, plus `shared` keys still in
|
|
80
|
+
use (the fleet-wide key, until each node has its own)."""
|
|
81
|
+
keys = list(shared)
|
|
82
|
+
for node in sorted(ring['nodes']):
|
|
83
|
+
entry = ring['nodes'][node]
|
|
84
|
+
for key in (entry['current'], entry['previous']):
|
|
85
|
+
if key and key not in keys:
|
|
86
|
+
keys.append(key)
|
|
87
|
+
return keys
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def report(ring):
|
|
91
|
+
"""Per node: the current key's fingerprint and whether a rotation is
|
|
92
|
+
still waiting to be retired. No key value."""
|
|
93
|
+
return {
|
|
94
|
+
node: {
|
|
95
|
+
'current': fingerprint(entry['current']) if entry['current'] else None,
|
|
96
|
+
'rotating': bool(entry['previous']),
|
|
97
|
+
}
|
|
98
|
+
for node, entry in sorted(ring['nodes'].items())
|
|
99
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""A fencing lease on the proxy state: only its holder is the writer.
|
|
2
|
+
|
|
3
|
+
Providers rotate refresh tokens, so two proxies started from the same
|
|
4
|
+
checkpoint spend each other's tokens. The lease is one small JSON document in
|
|
5
|
+
a store both would see (the checkpoint bucket in production, a directory in
|
|
6
|
+
tests): the holder, a fencing epoch that only ever grows, and when it
|
|
7
|
+
expires. A proxy that cannot take the lease refuses to start as writer.
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import pathlib
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
TTL = 120
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Held(Exception):
|
|
18
|
+
"""Another proxy holds the lease."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, holder, expires):
|
|
21
|
+
super().__init__('The proxy state lease is held by another proxy')
|
|
22
|
+
self.holder = holder
|
|
23
|
+
self.expires = expires
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DirectoryStore:
|
|
27
|
+
"""A lease document in a directory, replaced with one rename. `put`
|
|
28
|
+
writes only when the document is still the one `get` returned."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, directory):
|
|
31
|
+
self.path = pathlib.Path(directory) / 'proxy-lease.json'
|
|
32
|
+
|
|
33
|
+
def get(self):
|
|
34
|
+
try:
|
|
35
|
+
return json.loads(self.path.read_text())
|
|
36
|
+
except (OSError, ValueError):
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
def put(self, document, expected):
|
|
40
|
+
import fcntl
|
|
41
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
with open(self.path.with_suffix('.lock'), 'w') as lock:
|
|
43
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
44
|
+
if self.get() != expected:
|
|
45
|
+
return False
|
|
46
|
+
staged = self.path.with_suffix('.new')
|
|
47
|
+
staged.write_text(json.dumps(document) + '\n')
|
|
48
|
+
os.replace(staged, self.path)
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def acquire(store, holder, now=None, ttl=TTL):
|
|
53
|
+
"""Takes or renews the lease for `holder`; returns its fencing epoch.
|
|
54
|
+
Raises Held while another holder's lease has not expired. Taking over an
|
|
55
|
+
expired lease increases the epoch, so the old holder's writes are fenced."""
|
|
56
|
+
now = time.time() if now is None else now
|
|
57
|
+
current = store.get()
|
|
58
|
+
if current and current.get('holder') != holder and current.get('expires', 0) > now:
|
|
59
|
+
raise Held(current.get('holder'), current.get('expires'))
|
|
60
|
+
epoch = (current or {}).get('epoch', 0)
|
|
61
|
+
if not current or current.get('holder') != holder:
|
|
62
|
+
epoch += 1
|
|
63
|
+
document = {'holder': holder, 'epoch': epoch, 'expires': now + ttl}
|
|
64
|
+
if not store.put(document, current):
|
|
65
|
+
raise Held(None, None)
|
|
66
|
+
return epoch
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def valid(store, holder, epoch, now=None):
|
|
70
|
+
"""Whether `holder` still holds the lease at `epoch`: checked before a
|
|
71
|
+
token refresh is written."""
|
|
72
|
+
now = time.time() if now is None else now
|
|
73
|
+
current = store.get() or {}
|
|
74
|
+
return current.get('holder') == holder and current.get('epoch') == epoch and current.get('expires', 0) > now
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def release(store, holder):
|
|
78
|
+
current = store.get()
|
|
79
|
+
if current and current.get('holder') == holder:
|
|
80
|
+
store.put({**current, 'expires': 0}, current)
|
package/cliproxy/scripts/ops
CHANGED
|
@@ -8,6 +8,7 @@ import pathlib as _pathlib
|
|
|
8
8
|
sys.path.insert(0, str(_pathlib.Path(__file__).resolve().parent))
|
|
9
9
|
import accounts
|
|
10
10
|
import cliproxy_host
|
|
11
|
+
import clientkeys
|
|
11
12
|
|
|
12
13
|
import fcntl, json, os, pathlib, shutil, subprocess, sys, tempfile, time, urllib.request, urllib.error
|
|
13
14
|
|
|
@@ -205,6 +206,92 @@ def apply_prefixes(prefixes):
|
|
|
205
206
|
sys.exit("The proxy did not record the declared account prefixes.")
|
|
206
207
|
|
|
207
208
|
|
|
209
|
+
LOGIN_FLAGS = {
|
|
210
|
+
"codex": "-codex-device-login",
|
|
211
|
+
"claude": "-claude-login",
|
|
212
|
+
"antigravity": "-antigravity-login",
|
|
213
|
+
"kimi": "-kimi-login",
|
|
214
|
+
"xai": "-xai-login",
|
|
215
|
+
"meta": "-meta-login",
|
|
216
|
+
"devin": "-devin-login",
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def login(provider, replace=None):
|
|
221
|
+
"""Runs the proxy's own login into a staging directory, then moves the
|
|
222
|
+
complete credential in with one rename: an interrupted login leaves no
|
|
223
|
+
half-written auth file. `replace` is the credential a re-login renews."""
|
|
224
|
+
import yaml
|
|
225
|
+
|
|
226
|
+
if provider not in LOGIN_FLAGS:
|
|
227
|
+
sys.exit(
|
|
228
|
+
"This release has no login flag for that provider. Use the official dashboard OAuth/API key configuration."
|
|
229
|
+
)
|
|
230
|
+
auths = root / "state/runtime/auths"
|
|
231
|
+
stage = accounts.begin(auths)
|
|
232
|
+
config = yaml.safe_load((root / "state/runtime/config.yaml").read_text())
|
|
233
|
+
config.setdefault("oauth", {})["auth-dir"] = "/state/" + stage.name
|
|
234
|
+
login_config = root / "state/runtime/login-config.yaml"
|
|
235
|
+
login_config.write_text(yaml.safe_dump(config))
|
|
236
|
+
try:
|
|
237
|
+
compose(
|
|
238
|
+
"exec",
|
|
239
|
+
"proxy",
|
|
240
|
+
"/CLIProxyAPI/CLIProxyAPI",
|
|
241
|
+
"-config",
|
|
242
|
+
"/state/" + login_config.name,
|
|
243
|
+
LOGIN_FLAGS[provider],
|
|
244
|
+
"-no-browser",
|
|
245
|
+
)
|
|
246
|
+
if not accounts.adopt(stage, auths, replace=replace):
|
|
247
|
+
sys.exit("The login wrote no credential; nothing was changed.")
|
|
248
|
+
finally:
|
|
249
|
+
login_config.unlink(missing_ok=True)
|
|
250
|
+
shutil.rmtree(stage, ignore_errors=True)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def account_roster():
|
|
254
|
+
"""The roster with each account's state, from the credential files and
|
|
255
|
+
the management API (which knows cooldowns and refresh failures)."""
|
|
256
|
+
entries = []
|
|
257
|
+
try:
|
|
258
|
+
admin = (root / "state/secrets/management-key").read_text().strip()
|
|
259
|
+
code, body = request("/v0/management/auth-files", admin)
|
|
260
|
+
if code == 200:
|
|
261
|
+
entries = json.loads(body).get("files") or []
|
|
262
|
+
except Exception:
|
|
263
|
+
entries = []
|
|
264
|
+
roster = accounts.roster(root / "state/runtime/auths", entries)
|
|
265
|
+
return {
|
|
266
|
+
"version": 1,
|
|
267
|
+
"rosterVersion": accounts.version(roster),
|
|
268
|
+
"managementApi": bool(entries),
|
|
269
|
+
"accounts": accounts.public(roster),
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def key_ring():
|
|
274
|
+
return clientkeys.load(root / "state/secrets/client-keys.json")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def apply_keys(ring):
|
|
278
|
+
"""The proxy accepts exactly the shared key and the ring's keys."""
|
|
279
|
+
import yaml
|
|
280
|
+
|
|
281
|
+
live = yaml.safe_load((root / "state/runtime/config.yaml").read_text())
|
|
282
|
+
shared = (root / "state/secrets/client-key").read_text().strip()
|
|
283
|
+
live.setdefault("access", {})["api-keys"] = clientkeys.api_keys(ring, [shared])
|
|
284
|
+
with tempfile.NamedTemporaryFile(
|
|
285
|
+
"w", dir=root / "state/runtime", suffix=".yaml", delete=False
|
|
286
|
+
) as file:
|
|
287
|
+
file.write(yaml.safe_dump(live, sort_keys=False))
|
|
288
|
+
try:
|
|
289
|
+
os.chmod(file.name, 0o600)
|
|
290
|
+
apply_config(pathlib.Path(file.name), restart=False)
|
|
291
|
+
finally:
|
|
292
|
+
pathlib.Path(file.name).unlink(missing_ok=True)
|
|
293
|
+
|
|
294
|
+
|
|
208
295
|
def lock():
|
|
209
296
|
f = open(root / "state/status/operation.lock", "w")
|
|
210
297
|
if os.geteuid() == 0:
|
|
@@ -414,33 +501,58 @@ try:
|
|
|
414
501
|
sys.exit(
|
|
415
502
|
"Install a reviewed Git revision with bun run cliproxy install --revision REV; use --rollback for the recorded previous revision."
|
|
416
503
|
)
|
|
417
|
-
elif command
|
|
504
|
+
elif command in ["add-account", "relogin"]:
|
|
505
|
+
# `add-account PROVIDER` signs in a new account; `relogin PROVIDER
|
|
506
|
+
# INDEX` signs that one account in again. Prefixes follow at once.
|
|
418
507
|
operation_lock = lock()
|
|
419
508
|
provider = sys.argv[2]
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
"antigravity": "-antigravity-login",
|
|
425
|
-
"kimi": "-kimi-login",
|
|
426
|
-
"xai": "-xai-login",
|
|
427
|
-
"meta": "-meta-login",
|
|
428
|
-
"devin": "-devin-login",
|
|
429
|
-
}
|
|
430
|
-
if provider == "gemini":
|
|
431
|
-
sys.exit(
|
|
432
|
-
"This release has no Gemini CLI login flag. Use official dashboard OAuth/API key configuration."
|
|
509
|
+
replace = None
|
|
510
|
+
if command == "relogin":
|
|
511
|
+
account = accounts.find(
|
|
512
|
+
accounts.roster(root / "state/runtime/auths"), provider, int(sys.argv[3])
|
|
433
513
|
)
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
"
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
514
|
+
if account is None:
|
|
515
|
+
sys.exit("No such account. List them with: devfleet fleet-accounts")
|
|
516
|
+
replace = account["file"]
|
|
517
|
+
login(provider, replace)
|
|
518
|
+
prefixes = pending_prefixes()
|
|
519
|
+
if prefixes:
|
|
520
|
+
apply_prefixes(prefixes)
|
|
521
|
+
elif command == "remove-account":
|
|
522
|
+
# Deletes the credential; the proxy stops serving it, and the next
|
|
523
|
+
# account propagation removes its instances from every node.
|
|
524
|
+
operation_lock = lock()
|
|
525
|
+
accounts.remove(root / "state/runtime/auths", sys.argv[2], int(sys.argv[3]))
|
|
526
|
+
elif command == "accounts":
|
|
527
|
+
report = account_roster()
|
|
528
|
+
if "--json" in sys.argv[2:]:
|
|
529
|
+
print(json.dumps(report))
|
|
530
|
+
else:
|
|
531
|
+
print("Roster version:", report["version"])
|
|
532
|
+
for account in report["accounts"]:
|
|
533
|
+
print(
|
|
534
|
+
account["provider"],
|
|
535
|
+
account["index"],
|
|
536
|
+
account["state"],
|
|
537
|
+
account["until"] or "",
|
|
538
|
+
)
|
|
539
|
+
elif command == "client-keys":
|
|
540
|
+
print(json.dumps({"version": 1, "nodes": clientkeys.report(key_ring())}))
|
|
541
|
+
elif command in ["issue-key", "rotate-key", "retire-key", "revoke-key"]:
|
|
542
|
+
# Per-node client keys. A rotation keeps the old key accepted until
|
|
543
|
+
# retire-key, so no running agent fails while nodes move over.
|
|
544
|
+
operation_lock = lock()
|
|
545
|
+
ring = key_ring()
|
|
546
|
+
node = sys.argv[2]
|
|
547
|
+
{
|
|
548
|
+
"issue-key": clientkeys.issue,
|
|
549
|
+
"rotate-key": clientkeys.rotate,
|
|
550
|
+
"retire-key": clientkeys.retire,
|
|
551
|
+
"revoke-key": clientkeys.revoke,
|
|
552
|
+
}[command](ring, node)
|
|
553
|
+
clientkeys.save(root / "state/secrets/client-keys.json", ring)
|
|
554
|
+
apply_keys(ring)
|
|
555
|
+
print(json.dumps({"version": 1, "nodes": clientkeys.report(ring)}))
|
|
444
556
|
elif command == "inference":
|
|
445
557
|
protocol, model = sys.argv[2:4]
|
|
446
558
|
key = (root / "state/secrets/client-key").read_text().strip()
|
|
@@ -519,7 +631,7 @@ try:
|
|
|
519
631
|
)
|
|
520
632
|
else:
|
|
521
633
|
sys.exit(
|
|
522
|
-
"Commands: start stop restart status health models logs validate apply FILE declared [--apply] add-account PROVIDER inference PROTOCOL MODEL portable-login activate drain"
|
|
634
|
+
"Commands: start stop restart status health models logs validate apply FILE declared [--apply] add-account PROVIDER relogin PROVIDER INDEX remove-account PROVIDER INDEX accounts [--json] client-keys issue-key|rotate-key|retire-key|revoke-key NODE inference PROTOCOL MODEL portable-login activate drain"
|
|
523
635
|
)
|
|
524
636
|
except Exception:
|
|
525
637
|
sys.exit(
|
|
@@ -58,3 +58,18 @@ print(
|
|
|
58
58
|
"credential files validated offline.",
|
|
59
59
|
)
|
|
60
60
|
print("No restored credentials or code were activated.")
|
|
61
|
+
# `--newest DIR`: the newest checkpoint. Refresh tokens rotate, so an older
|
|
62
|
+
# checkpoint can hold tokens the provider has already replaced; name exactly
|
|
63
|
+
# the accounts that need a new login (provider and index, never an account).
|
|
64
|
+
if "--newest" in sys.argv[2:]:
|
|
65
|
+
import checkpoint
|
|
66
|
+
|
|
67
|
+
newest = pathlib.Path(sys.argv[sys.argv.index("--newest") + 1]).resolve()
|
|
68
|
+
if not (newest / "CHECKPOINT.json").is_file():
|
|
69
|
+
newest = checkpoint.locate(newest)
|
|
70
|
+
stale = checkpoint.stale_accounts(root, newest)
|
|
71
|
+
for provider, index in stale:
|
|
72
|
+
print("needs-login:", provider, index)
|
|
73
|
+
print("Accounts restored with a current refresh token:", "all" if not stale else "all others")
|
|
74
|
+
if stale:
|
|
75
|
+
sys.exit(4)
|
package/docs/accounts.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Provider accounts
|
|
2
|
+
|
|
3
|
+
**The rule.** Provider accounts exist only as the CLI proxy's credential files
|
|
4
|
+
on the manager. Every node and IDE gets a view derived from them, and nothing
|
|
5
|
+
on a node holds a provider login of its own. T3 offers one instance per
|
|
6
|
+
account, named `<Provider> <account email>`, and every launcher reaches the
|
|
7
|
+
provider through the proxy.
|
|
8
|
+
|
|
9
|
+
## The roster
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npx devfleet fleet-accounts # provider, index, label, state, when a cooldown clears
|
|
13
|
+
npx devfleet fleet-accounts --diff # and what this machine offers against it
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Each account has one state, derived from its credential file and the proxy's
|
|
17
|
+
management API:
|
|
18
|
+
|
|
19
|
+
| State | Meaning |
|
|
20
|
+
| ----------------- | -------------------------------------------------------------------------- |
|
|
21
|
+
| `usable` | Serves requests |
|
|
22
|
+
| `cooling-down` | The provider limited it; `until` says when it clears, when the proxy knows |
|
|
23
|
+
| `refresh-failing` | Its token refresh fails for now, and its access token is still valid |
|
|
24
|
+
| `needs-login` | The provider refused its refresh token: only signing in again helps |
|
|
25
|
+
| `removed` | Disabled on the proxy |
|
|
26
|
+
|
|
27
|
+
The index of an account is its position among its provider's accounts, sorted
|
|
28
|
+
by sign-in email. Checks name accounts by provider and index only.
|
|
29
|
+
|
|
30
|
+
The **roster version** is a digest of the routing prefixes of the accounts.
|
|
31
|
+
The manager computes it from the credential files and every node from the
|
|
32
|
+
proxy's model list, so the two agree exactly when a node offers what the
|
|
33
|
+
proxy holds.
|
|
34
|
+
|
|
35
|
+
## Checks
|
|
36
|
+
|
|
37
|
+
| Check | Fails when |
|
|
38
|
+
| ---------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
|
39
|
+
| `proxy:account:<provider>` | The provider has no usable account; it warns below `services.proxy.accounts.minimumUsable` (default 2) |
|
|
40
|
+
| `accounts:roster-current` | The node offers another roster version than the proxy's |
|
|
41
|
+
| `accounts:native-login` | (warns) An agent CLI on the node keeps a provider login of its own |
|
|
42
|
+
| `proxy:reachable-from` | The node gets no answer from the proxy; it warns above two seconds |
|
|
43
|
+
| `agent:<tool>:account-<n>:session` | That account's instance does not run a session; it warns while the account is cooling down |
|
|
44
|
+
| `agent:<tool>:removed-<n>:session` | The node still offers an instance for an account the proxy no longer holds |
|
|
45
|
+
|
|
46
|
+
Session results are cached for six hours under a key that holds the roster
|
|
47
|
+
version and the version of the client key, so any account change or key
|
|
48
|
+
rotation re-verifies at once.
|
|
49
|
+
|
|
50
|
+
## Adding, signing in again, removing
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
npx devfleet fleet-accounts --add claude
|
|
54
|
+
npx devfleet fleet-accounts --relogin codex 2
|
|
55
|
+
npx devfleet fleet-accounts --remove codex 2
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
A sign-in writes into a staging directory beside the credentials; only a
|
|
59
|
+
complete credential is moved in, with one rename, so an interrupted sign-in
|
|
60
|
+
leaves nothing behind. Routing prefixes are applied in the same command. Every
|
|
61
|
+
change is then propagated to every node.
|
|
62
|
+
|
|
63
|
+
## Propagation without a full sync
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
npx devfleet fleet-sync --only accounts
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
On the manager and every worker at once, each node re-reads the proxy's
|
|
70
|
+
roster and rewrites only its per-account T3 instances. T3 watches its
|
|
71
|
+
settings, so nothing restarts and no running turn ends. Instance ids derive
|
|
72
|
+
from the account's routing prefix and stay the same. A node that cannot be
|
|
73
|
+
reached catches up on the next propagation or sync; until then
|
|
74
|
+
`accounts:roster-current` reports the version it has.
|
|
75
|
+
|
|
76
|
+
## Choosing an account
|
|
77
|
+
|
|
78
|
+
Unpinned requests go through a loopback relay on every node. When the fleet
|
|
79
|
+
declares a policy for a provider, the relay picks the account of each request:
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
"services": {
|
|
83
|
+
"proxy": {
|
|
84
|
+
"endpoint": "https://cli-proxy.example.com",
|
|
85
|
+
"accounts": {
|
|
86
|
+
"minimumUsable": 2,
|
|
87
|
+
"selection": {
|
|
88
|
+
"codex": { "strategy": "fill-first", "order": ["a@example.com"] }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
| Strategy | Order |
|
|
96
|
+
| ------------------------ | ------------------------------------------------ |
|
|
97
|
+
| `fill-first` | The same order for every request |
|
|
98
|
+
| `round-robin` | Each request starts one account further along |
|
|
99
|
+
| `least-recently-limited` | The account limited longest ago (or never) first |
|
|
100
|
+
|
|
101
|
+
The order is the accounts `order` names, in that order, then the others in
|
|
102
|
+
the order of their routing prefix. A quota or authentication refusal sends
|
|
103
|
+
the request on to the next account, and the refused one cools down for its
|
|
104
|
+
`Retry-After` (a minute without one). Without a policy the proxy's own
|
|
105
|
+
fill-first pool chooses. An instance pinned to one account answers "This
|
|
106
|
+
account is cooling down until …" and names the other instances of its
|
|
107
|
+
provider.
|
|
108
|
+
|
|
109
|
+
## Proxy outages
|
|
110
|
+
|
|
111
|
+
Every relayed launch (Claude, Codex, Gemini and Antigravity, pinned or not)
|
|
112
|
+
retries a proxy that does not answer for about a minute. If it stays down,
|
|
113
|
+
the agent gets one distinct, retryable error: "The CLI proxy has been
|
|
114
|
+
unreachable since …".
|
|
115
|
+
|
|
116
|
+
## Checkpoints and the proxy state
|
|
117
|
+
|
|
118
|
+
Each checkpoint records a short digest of every credential's refresh token,
|
|
119
|
+
never the token. After restoring an older checkpoint, `restore-verify
|
|
120
|
+
RESTORED --newest NEWEST` names exactly the accounts whose refresh token the
|
|
121
|
+
provider has since replaced (`needs-login: <provider> <index>`).
|
|
122
|
+
|
|
123
|
+
`cliproxy/scripts/lease.py` is a fencing lease on the proxy state: a second
|
|
124
|
+
proxy cannot take it while the first holds it, and taking over an expired
|
|
125
|
+
lease fences the old holder.
|
|
126
|
+
|
|
127
|
+
## Client keys
|
|
128
|
+
|
|
129
|
+
`devfleet cliproxy issue-key|rotate-key|retire-key|revoke-key NODE` keep one
|
|
130
|
+
proxy client key per node in the manager's key ring. A rotation keeps the old
|
|
131
|
+
key accepted until `retire-key`, so no running agent fails while nodes move
|
|
132
|
+
over. Revoking one node's key leaves the others working.
|
|
133
|
+
`devfleet cliproxy client-keys` reports each node's key by fingerprint only.
|
|
134
|
+
|
|
135
|
+
## Not yet done
|
|
136
|
+
|
|
137
|
+
- The proxy does not notify anyone when an account turns `needs-login`, and
|
|
138
|
+
no watcher on the manager triggers propagation or a checkpoint when a
|
|
139
|
+
credential file changes. Run `fleet-sync --only accounts` or
|
|
140
|
+
`fleet-accounts` instead.
|
|
141
|
+
- Nothing forces a refresh of every account after a restore. The digest
|
|
142
|
+
comparison above is offline.
|
|
143
|
+
- The lease is not yet taken by the proxy service at start, and its
|
|
144
|
+
production store (the checkpoint bucket) is not wired.
|
|
145
|
+
- Per-node client keys are not yet distributed to nodes through the secrets
|
|
146
|
+
provider, so nodes still present the shared key. No
|
|
147
|
+
`proxy:client-key-current` check exists yet.
|
|
148
|
+
- OpenCode and Grok read the proxy's address from rendered configuration and
|
|
149
|
+
are not relayed yet. There is no warm standby proxy.
|
|
150
|
+
- The selection policy is applied by the client relay; the proxy itself stays
|
|
151
|
+
fill-first.
|
|
152
|
+
- A sign-in uses the provider's device code where the proxy offers one.
|
|
153
|
+
Other providers print a callback URL, and no loopback forward is managed
|
|
154
|
+
for them.
|