@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.
- package/README.md +50 -16
- package/ansible/github/git.yml +66 -0
- package/ansible/macos/manager.yml +3 -0
- package/ansible/macos/policy.json +11 -1
- package/ansible/macos/t3-policy.yml +49 -0
- package/ansible/monitoring/compose.yml +10 -0
- package/ansible/monitoring/container-watchdog.py +118 -0
- package/ansible/monitoring/hub-darwin.yml +146 -0
- package/ansible/net/remote-probe.sh +5 -0
- package/ansible/ubuntu/baseline.yml +2 -0
- package/ansible/ubuntu/disk-gc.py +25 -3
- package/ansible/ubuntu/disk-guard.py +24 -5
- package/ansible/ubuntu/policy.json +51 -1
- 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/tasks/browser.yml +56 -0
- package/ansible/ubuntu/tasks/docker.yml +52 -0
- package/ansible/ubuntu/tasks/recovery.yml +60 -1
- package/ansible/ubuntu/tasks/resources.yml +2 -2
- package/ansible/ubuntu/tasks/services.yml +13 -0
- package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
- package/ansible/ubuntu/wip-guard.py +121 -0
- package/bootstrap/doctor.ts +32 -6
- package/bootstrap/fleet-remote-bootstrap.sh +31 -4
- package/bootstrap/native-t3.ts +22 -11
- package/bootstrap/setup.ts +23 -8
- package/bootstrap/worker-tools.ts +118 -0
- package/cliproxy/README.md +4 -4
- package/cliproxy/compose.yaml +4 -0
- package/cliproxy/scripts/accounts.py +216 -0
- package/cliproxy/scripts/checkpoint.py +30 -2
- 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/authentication.md +90 -0
- package/docs/declared.md +5 -1
- package/docs/durability.md +26 -0
- package/docs/machine-setup.md +26 -15
- package/docs/operations.md +87 -31
- package/docs/runbooks.md +380 -0
- package/docs/state.md +13 -7
- package/docs/topology.md +2 -0
- package/docs/ubuntu-sync.md +35 -28
- package/examples/fleet.json +19 -1
- package/llms.txt +3 -0
- package/mise.toml +10 -1
- package/package.json +2 -2
- package/schema/check-ids.json +522 -0
- package/schema/fleet.schema.json +244 -1
- package/schema/output/check.schema.json +23 -0
- package/schema/output/error.schema.json +14 -0
- package/schema/output/fleet-report.schema.json +79 -0
- package/schema/output/fleet-sync.schema.json +112 -0
- package/schema/output/report.schema.json +38 -0
- 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/launch.ts +84 -5
- package/src/agents/native-verify.ts +128 -2
- package/src/auth-checks.ts +3 -0
- package/src/cli/agent-guide.ts +5 -3
- package/src/cli/fleet-accounts.ts +253 -0
- package/src/cli/fleet-report.ts +126 -0
- package/src/cli/fleet-sync-accounts.ts +58 -0
- package/src/cli/fleet-sync-manager.ts +31 -4
- package/src/cli/fleet-sync-output.ts +3 -2
- package/src/cli/fleet-sync-plan.ts +200 -0
- package/src/cli/fleet-sync-remote.ts +23 -14
- package/src/cli/fleet-sync-schedule.ts +142 -35
- package/src/cli/fleet-sync.ts +126 -8
- package/src/cli/fleet-watch.ts +198 -0
- package/src/cli/fleet.ts +4 -0
- package/src/cli/register-fleet.ts +86 -0
- package/src/cli/registry.ts +26 -0
- package/src/cli/schedule-logs.ts +36 -0
- package/src/clock.ts +63 -0
- package/src/definition.ts +226 -7
- package/src/git-identity.ts +160 -0
- 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-health.ts +51 -4
- package/src/manager-health.ts +205 -0
- package/src/manager-sync.ts +13 -17
- package/src/monitoring/hub-backup.ts +63 -17
- package/src/monitoring/manager-hub.ts +44 -2
- package/src/node-converge-linux.ts +44 -12
- package/src/notify.ts +239 -0
- package/src/platform/darwin.ts +0 -13
- package/src/platform/fallback.ts +0 -8
- package/src/platform/linux.ts +0 -10
- 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/windows.ts +0 -14
- package/src/remote-bootstrap.ts +87 -25
- package/src/remote-entry.ts +15 -1
- package/src/remote-operation.ts +213 -0
- package/src/schedule-check.ts +40 -0
- package/src/secrets.ts +26 -8
- package/src/service-auth.ts +15 -5
- package/src/ssh.ts +10 -2
- package/src/state/history.ts +172 -0
- package/src/state/store.ts +11 -4
- 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-linked-check.ts +1 -1
- package/src/tailscale.ts +51 -0
- package/src/toolchain-checks.ts +8 -3
- package/src/transport.ts +3 -2
- package/src/worker-capability-checks.ts +167 -0
- package/src/worker-capacity.ts +19 -7
- package/src/worker-host-checks.ts +68 -13
- package/src/worker-policy.ts +33 -0
- package/src/worker-readiness.ts +10 -1
|
@@ -1,19 +1,38 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AccountSelector,
|
|
3
|
+
accountRefused,
|
|
4
|
+
retryAfter,
|
|
5
|
+
} from '../accounts/selection';
|
|
6
|
+
|
|
1
7
|
/** Pins one agent process to one provider account. The proxy routes a model
|
|
2
8
|
* named `<prefix>/<model>` only to the credential that owns the prefix, but
|
|
3
9
|
* agents send whichever model the user picked. This loopback relay lives only
|
|
4
10
|
* as long as the agent it was started for: it adds the prefix to each request
|
|
5
11
|
* and forwards everything else (credentials, streaming) unchanged.
|
|
12
|
+
*
|
|
13
|
+
* With an empty prefix it pins nothing: every launch goes through a relay, so
|
|
14
|
+
* every launch rides out a proxy restart the same way. `selector` then picks
|
|
15
|
+
* the account of each request by the fleet's selection policy.
|
|
6
16
|
*/
|
|
7
17
|
export function startAccountRelay(
|
|
8
18
|
endpoint: string,
|
|
9
|
-
prefix: string
|
|
19
|
+
prefix: string,
|
|
20
|
+
options: Pick<RelayOptions, 'selector' | 'alternatives'> = {}
|
|
10
21
|
): { url: string; stop: () => void } {
|
|
22
|
+
const pinned =
|
|
23
|
+
prefix && !options.selector
|
|
24
|
+
? new AccountSelector([prefix], { strategy: 'fill-first' })
|
|
25
|
+
: options.selector;
|
|
11
26
|
const server = Bun.serve({
|
|
12
27
|
hostname: '127.0.0.1',
|
|
13
28
|
port: 0,
|
|
14
29
|
// Model streams are idle for as long as a model thinks.
|
|
15
30
|
idleTimeout: 0,
|
|
16
|
-
fetch: (request) =>
|
|
31
|
+
fetch: (request) =>
|
|
32
|
+
forward(request, endpoint, prefix, {
|
|
33
|
+
...options,
|
|
34
|
+
...(pinned ? { selector: pinned } : {}),
|
|
35
|
+
}),
|
|
17
36
|
});
|
|
18
37
|
return {
|
|
19
38
|
url: `http://127.0.0.1:${server.port}`,
|
|
@@ -24,6 +43,11 @@ export function startAccountRelay(
|
|
|
24
43
|
type RelayOptions = {
|
|
25
44
|
send?: typeof fetch;
|
|
26
45
|
pause?: (seconds: number) => Promise<void>;
|
|
46
|
+
/** Unpinned: the accounts to try in turn. Pinned: remembers cooldowns. */
|
|
47
|
+
selector?: AccountSelector;
|
|
48
|
+
/** Pinned: the other instances of the provider, named in its error. */
|
|
49
|
+
alternatives?: string[];
|
|
50
|
+
now?: () => number;
|
|
27
51
|
};
|
|
28
52
|
const sleep = (seconds: number) =>
|
|
29
53
|
new Promise<void>((done) => setTimeout(done, seconds * 1000));
|
|
@@ -32,33 +56,54 @@ export async function forward(
|
|
|
32
56
|
request: Request,
|
|
33
57
|
endpoint: string,
|
|
34
58
|
prefix: string,
|
|
35
|
-
|
|
59
|
+
options: RelayOptions = {}
|
|
36
60
|
): Promise<Response> {
|
|
37
61
|
const url = new URL(request.url);
|
|
38
62
|
const headers = new Headers(request.headers);
|
|
39
63
|
// fetch sets these for the upstream request and decodes its response.
|
|
40
64
|
for (const name of ['host', 'content-length', 'accept-encoding'])
|
|
41
65
|
headers.delete(name);
|
|
42
|
-
|
|
43
|
-
if (request.body)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
return new Response('The account relay needs an uncompressed request.', {
|
|
47
|
-
status: 415,
|
|
48
|
-
});
|
|
49
|
-
body = pinModel(await request.text(), prefix);
|
|
50
|
-
}
|
|
51
|
-
const attempt = () =>
|
|
52
|
-
send(endpoint + url.pathname + url.search, {
|
|
53
|
-
method: request.method,
|
|
54
|
-
headers,
|
|
55
|
-
...(body === undefined ? {} : { body }),
|
|
56
|
-
redirect: 'error',
|
|
57
|
-
signal: request.signal,
|
|
66
|
+
// An unreadable body could not be pinned; never forward it to the pool.
|
|
67
|
+
if (request.body && headers.has('content-encoding'))
|
|
68
|
+
return new Response('The account relay needs an uncompressed request.', {
|
|
69
|
+
status: 415,
|
|
58
70
|
});
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
71
|
+
const raw = request.body ? await request.text() : undefined;
|
|
72
|
+
const reach = reacher(
|
|
73
|
+
request,
|
|
74
|
+
headers,
|
|
75
|
+
endpoint + url.pathname + url.search,
|
|
76
|
+
options
|
|
77
|
+
);
|
|
78
|
+
const { selector } = options;
|
|
79
|
+
const reached =
|
|
80
|
+
!prefix && selector && raw !== undefined
|
|
81
|
+
? await failOver(selector, raw, reach)
|
|
82
|
+
: await pinnedReach(prefix, raw, reach, options);
|
|
83
|
+
if (reached instanceof Response) return reached;
|
|
84
|
+
return answer(reached);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Sends one body to the proxy, retrying while the route is down. */
|
|
88
|
+
function reacher(
|
|
89
|
+
request: Request,
|
|
90
|
+
headers: Headers,
|
|
91
|
+
target: string,
|
|
92
|
+
{ send = fetch, pause = sleep, now = Date.now }: RelayOptions
|
|
93
|
+
): (body: string | undefined) => Promise<Reached> {
|
|
94
|
+
return (body) =>
|
|
95
|
+
reachProxy(
|
|
96
|
+
() => send(target, upstreamInit(request, headers, body)),
|
|
97
|
+
request.signal,
|
|
98
|
+
pause,
|
|
99
|
+
now
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The proxy's answer, decoded, or the distinct "unreachable" error. */
|
|
104
|
+
function answer(reached: Reached): Response {
|
|
105
|
+
const upstream = reached.response;
|
|
106
|
+
if (!upstream) return unreachable(reached.since);
|
|
62
107
|
const answered = new Headers(upstream.headers);
|
|
63
108
|
answered.delete('content-encoding');
|
|
64
109
|
answered.delete('content-length');
|
|
@@ -68,6 +113,109 @@ export async function forward(
|
|
|
68
113
|
});
|
|
69
114
|
}
|
|
70
115
|
|
|
116
|
+
/** A pinned account (or no pinning at all). A pinned account the provider
|
|
117
|
+
* limited answers at once, until its cooldown clears, with the time and the
|
|
118
|
+
* other instances of its provider.
|
|
119
|
+
*/
|
|
120
|
+
async function pinnedReach(
|
|
121
|
+
prefix: string,
|
|
122
|
+
raw: string | undefined,
|
|
123
|
+
reach: (body: string | undefined) => Promise<Reached>,
|
|
124
|
+
{ selector, alternatives = [] }: RelayOptions
|
|
125
|
+
): Promise<Reached | Response> {
|
|
126
|
+
const cooling = prefix && selector ? selector.coolingUntil(prefix) : null;
|
|
127
|
+
if (cooling !== null) return coolingDown(cooling, alternatives);
|
|
128
|
+
const reached = await reach(
|
|
129
|
+
raw !== undefined && prefix ? pinModel(raw, prefix) : raw
|
|
130
|
+
);
|
|
131
|
+
if (!prefix || !selector || reached.response?.status !== 429) return reached;
|
|
132
|
+
selector.limited(prefix, retryAfter(reached.response.headers));
|
|
133
|
+
await reached.response.body?.cancel().catch(() => undefined);
|
|
134
|
+
return coolingDown(selector.coolingUntil(prefix)!, alternatives);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
type Reached = { response: Response | null; since: number };
|
|
138
|
+
|
|
139
|
+
function upstreamInit(
|
|
140
|
+
request: Request,
|
|
141
|
+
headers: Headers,
|
|
142
|
+
body: string | undefined
|
|
143
|
+
): RequestInit {
|
|
144
|
+
return {
|
|
145
|
+
method: request.method,
|
|
146
|
+
headers,
|
|
147
|
+
...(body === undefined ? {} : { body }),
|
|
148
|
+
redirect: 'error',
|
|
149
|
+
signal: request.signal,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** An unpinned request goes to the first usable account by the fleet's
|
|
154
|
+
* policy; a quota or authentication refusal sends it on to the next. With
|
|
155
|
+
* every account cooling down, the proxy's own pool serves it.
|
|
156
|
+
*/
|
|
157
|
+
async function failOver(
|
|
158
|
+
selector: AccountSelector,
|
|
159
|
+
raw: string,
|
|
160
|
+
reach: (body: string | undefined) => Promise<Reached>
|
|
161
|
+
): Promise<Reached> {
|
|
162
|
+
const candidates = selector.candidates();
|
|
163
|
+
if (candidates.length === 0) return reach(raw);
|
|
164
|
+
let reached!: Reached;
|
|
165
|
+
for (const [position, candidate] of candidates.entries()) {
|
|
166
|
+
reached = await reach(pinModel(raw, candidate));
|
|
167
|
+
const status = reached.response?.status;
|
|
168
|
+
if (status === undefined || !accountRefused(status)) return reached;
|
|
169
|
+
selector.limited(candidate, retryAfter(reached.response!.headers));
|
|
170
|
+
if (position < candidates.length - 1)
|
|
171
|
+
await reached.response!.body?.cancel().catch(() => undefined);
|
|
172
|
+
}
|
|
173
|
+
return reached;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** One error both Anthropic and OpenAI clients show as a message. */
|
|
177
|
+
function agentError(
|
|
178
|
+
status: number,
|
|
179
|
+
type: string,
|
|
180
|
+
message: string,
|
|
181
|
+
retry: number
|
|
182
|
+
) {
|
|
183
|
+
return new Response(
|
|
184
|
+
JSON.stringify({ type: 'error', error: { type, message } }),
|
|
185
|
+
{
|
|
186
|
+
status,
|
|
187
|
+
headers: {
|
|
188
|
+
'content-type': 'application/json',
|
|
189
|
+
'retry-after': String(Math.max(1, Math.ceil(retry))),
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function coolingDown(until: number, alternatives: string[]): Response {
|
|
196
|
+
const others = alternatives.length
|
|
197
|
+
? ` Pick another instance of this provider in T3: ${alternatives.join(', ')}.`
|
|
198
|
+
: '';
|
|
199
|
+
return agentError(
|
|
200
|
+
429,
|
|
201
|
+
'rate_limit_error',
|
|
202
|
+
`This account is cooling down until ${new Date(until).toISOString()}.${others}`,
|
|
203
|
+
(until - Date.now()) / 1000
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The proxy did not answer within the retry window: a distinct, retryable
|
|
208
|
+
* error that says since when, never a provider's error.
|
|
209
|
+
*/
|
|
210
|
+
function unreachable(since: number): Response {
|
|
211
|
+
return agentError(
|
|
212
|
+
503,
|
|
213
|
+
'proxy_unreachable',
|
|
214
|
+
`The CLI proxy has been unreachable since ${new Date(since).toISOString()}. This is retryable; nothing is wrong with the account.`,
|
|
215
|
+
30
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
71
219
|
/** Seconds to wait before each further attempt (about a minute in total). */
|
|
72
220
|
export const RETRY_DELAYS = [1, 2, 4, 8, 15, 30];
|
|
73
221
|
/** The route to the proxy answered instead of the proxy: its connector or the
|
|
@@ -83,13 +231,15 @@ const routeDown = (status: number) =>
|
|
|
83
231
|
async function reachProxy(
|
|
84
232
|
attempt: () => Promise<Response>,
|
|
85
233
|
signal: AbortSignal,
|
|
86
|
-
pause: (seconds: number) => Promise<void
|
|
87
|
-
|
|
234
|
+
pause: (seconds: number) => Promise<void>,
|
|
235
|
+
now: () => number
|
|
236
|
+
): Promise<Reached> {
|
|
237
|
+
const since = now();
|
|
88
238
|
for (let tries = 0; ; tries++) {
|
|
89
239
|
const delay = RETRY_DELAYS[tries];
|
|
90
240
|
const response = await attempt().catch(() => null);
|
|
91
|
-
if (response && !routeDown(response.status)) return response;
|
|
92
|
-
if (delay === undefined || signal.aborted) return response;
|
|
241
|
+
if (response && !routeDown(response.status)) return { response, since };
|
|
242
|
+
if (delay === undefined || signal.aborted) return { response, since };
|
|
93
243
|
await response?.body?.cancel().catch(() => undefined);
|
|
94
244
|
await pause(delay);
|
|
95
245
|
}
|
package/src/agents/launch.ts
CHANGED
|
@@ -6,6 +6,9 @@ import { accountPrefix, accountSlug, discoverAccounts } from './accounts';
|
|
|
6
6
|
import { startAccountRelay } from './account-relay';
|
|
7
7
|
import { proxyKey } from './secrets';
|
|
8
8
|
import { pinnedBinary, pinnedPath } from './toolchain';
|
|
9
|
+
import { AccountSelector } from '../accounts/selection';
|
|
10
|
+
import { fleet } from '../fleet-config';
|
|
11
|
+
import type { AccountSelection } from '../definition';
|
|
9
12
|
|
|
10
13
|
/** `account` (its email) pins a proxy launch to one signed-in proxy account. */
|
|
11
14
|
export async function launchProfile(
|
|
@@ -43,9 +46,11 @@ export async function launchProfile(
|
|
|
43
46
|
}
|
|
44
47
|
toolEnvironment(env, selection, directory);
|
|
45
48
|
const pinned =
|
|
46
|
-
|
|
47
|
-
?
|
|
48
|
-
:
|
|
49
|
+
selection.profile !== 'proxy'
|
|
50
|
+
? null
|
|
51
|
+
: account
|
|
52
|
+
? await pinAccount(env, selection, { account, directory })
|
|
53
|
+
: await routeThroughRelay(env, selection);
|
|
49
54
|
try {
|
|
50
55
|
return await spawnAgent(selection, directory, env, [
|
|
51
56
|
pinned?.args ?? [],
|
|
@@ -96,9 +101,14 @@ export async function pinAccount(
|
|
|
96
101
|
): Promise<{ args: string[]; stop: () => void }> {
|
|
97
102
|
const prefix = accountPrefix(selection.tool, account);
|
|
98
103
|
// What the account serves right now, preferred default first.
|
|
99
|
-
const
|
|
104
|
+
const roster = await discover(env['CLIPROXY_API_KEY'] ?? '');
|
|
105
|
+
const served = roster.find(
|
|
100
106
|
(entry) => entry.tool === selection.tool && entry.email === account
|
|
101
107
|
);
|
|
108
|
+
// Named in this account's "cooling down" error, so the person can switch.
|
|
109
|
+
const alternatives = roster
|
|
110
|
+
.filter((entry) => entry.tool === selection.tool && entry.email !== account)
|
|
111
|
+
.map((entry) => entry.email);
|
|
102
112
|
if (!served)
|
|
103
113
|
throw new AgentConfigurationError(
|
|
104
114
|
'This account is not signed in on the CLI proxy.'
|
|
@@ -108,7 +118,9 @@ export async function pinAccount(
|
|
|
108
118
|
env['OPENCODE_CONFIG_CONTENT'] = JSON.stringify(accountOpenCode(served));
|
|
109
119
|
return { args: [], stop: () => {} };
|
|
110
120
|
}
|
|
111
|
-
const { url, stop } = relay(registry.proxy.endpoint, prefix
|
|
121
|
+
const { url, stop } = relay(registry.proxy.endpoint, prefix, {
|
|
122
|
+
alternatives,
|
|
123
|
+
});
|
|
112
124
|
if (selection.tool === 'claude') {
|
|
113
125
|
env['ANTHROPIC_BASE_URL'] = url;
|
|
114
126
|
// Aliases and the default must name models a Claude account serves.
|
|
@@ -146,6 +158,73 @@ export async function pinAccount(
|
|
|
146
158
|
};
|
|
147
159
|
}
|
|
148
160
|
|
|
161
|
+
/** Which tools reach the proxy through an address this wrapper sets, and so
|
|
162
|
+
* can be sent through a relay. OpenCode and Grok read theirs from rendered
|
|
163
|
+
* configuration and still go to the proxy directly.
|
|
164
|
+
*/
|
|
165
|
+
const RELAYED = new Set(['claude', 'codex', 'gemini', 'antigravity']);
|
|
166
|
+
|
|
167
|
+
/** An unpinned launch goes through a relay as well: it retries a proxy that
|
|
168
|
+
* is restarting exactly as a pinned one does, reports "proxy unreachable"
|
|
169
|
+
* when it stays down, and picks each request's account by the fleet's
|
|
170
|
+
* selection policy when it declares one for the provider.
|
|
171
|
+
*/
|
|
172
|
+
export async function routeThroughRelay(
|
|
173
|
+
env: NodeJS.ProcessEnv,
|
|
174
|
+
selection: Selection,
|
|
175
|
+
{
|
|
176
|
+
relay = startAccountRelay,
|
|
177
|
+
discover = discoverAccounts,
|
|
178
|
+
policy = selectionPolicy,
|
|
179
|
+
} = {}
|
|
180
|
+
): Promise<{ args: string[]; stop: () => void } | null> {
|
|
181
|
+
if (!RELAYED.has(selection.tool)) return null;
|
|
182
|
+
const selector = await policySelector(env, selection, discover, policy);
|
|
183
|
+
const { url, stop } = relay(
|
|
184
|
+
registry.proxy.endpoint,
|
|
185
|
+
'',
|
|
186
|
+
selector ? { selector } : {}
|
|
187
|
+
);
|
|
188
|
+
if (selection.tool === 'claude') env['ANTHROPIC_BASE_URL'] = url;
|
|
189
|
+
if (selection.tool === 'gemini' || selection.tool === 'antigravity')
|
|
190
|
+
env['GOOGLE_GEMINI_BASE_URL'] = url;
|
|
191
|
+
const args =
|
|
192
|
+
selection.tool === 'codex'
|
|
193
|
+
? [
|
|
194
|
+
'-c',
|
|
195
|
+
`model_providers.personal_proxy.base_url=${JSON.stringify(`${url}/v1`)}`,
|
|
196
|
+
]
|
|
197
|
+
: [];
|
|
198
|
+
return { args, stop };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The fleet's selection policy for the tool's provider, over the accounts
|
|
202
|
+
* the proxy lists. The list is read only when a policy needs it; an
|
|
203
|
+
* unreadable list leaves the choice to the proxy's own pool.
|
|
204
|
+
*/
|
|
205
|
+
async function policySelector(
|
|
206
|
+
env: NodeJS.ProcessEnv,
|
|
207
|
+
selection: Selection,
|
|
208
|
+
discover: typeof discoverAccounts,
|
|
209
|
+
policy: (provider: string) => AccountSelection | undefined
|
|
210
|
+
): Promise<AccountSelector | undefined> {
|
|
211
|
+
const route = (
|
|
212
|
+
registry.proxy.accounts as Record<string, { provider: string }>
|
|
213
|
+
)[selection.tool];
|
|
214
|
+
const declared = route ? policy(route.provider) : undefined;
|
|
215
|
+
if (!declared) return undefined;
|
|
216
|
+
const accounts = await discover(env['CLIPROXY_API_KEY'] ?? '').catch(
|
|
217
|
+
() => []
|
|
218
|
+
);
|
|
219
|
+
const prefixes = accounts
|
|
220
|
+
.filter((account) => account.tool === selection.tool)
|
|
221
|
+
.map((account) => account.prefix);
|
|
222
|
+
return prefixes.length ? new AccountSelector(prefixes, declared) : undefined;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const selectionPolicy = (provider: string): AccountSelection | undefined =>
|
|
226
|
+
fleet.services.proxy.accounts?.selection?.[provider];
|
|
227
|
+
|
|
149
228
|
/** OpenCode configuration that offers only one account's models. */
|
|
150
229
|
function accountOpenCode(route: { prefix: string; models: string[] }) {
|
|
151
230
|
const models = route.models.map((model) => `${route.prefix}/${model}`);
|
|
@@ -4,6 +4,13 @@ import { join } from 'node:path';
|
|
|
4
4
|
import type { Check, Runner } from '../types';
|
|
5
5
|
import { pinnedPath } from './toolchain';
|
|
6
6
|
import { registry } from './registry';
|
|
7
|
+
import type { Account } from './accounts';
|
|
8
|
+
import { accountSlug } from './accounts';
|
|
9
|
+
import {
|
|
10
|
+
accountSessionChecks,
|
|
11
|
+
sessionResult,
|
|
12
|
+
type Instance,
|
|
13
|
+
} from '../accounts/sessions';
|
|
7
14
|
import {
|
|
8
15
|
cacheKey,
|
|
9
16
|
cachedCheck,
|
|
@@ -98,6 +105,11 @@ export async function verifyNativeAgents(
|
|
|
98
105
|
outOfQuota?: Set<string>;
|
|
99
106
|
cache?: VerifyCache;
|
|
100
107
|
t3Service?: boolean;
|
|
108
|
+
/** The proxy's accounts and what this node offers. Every cached result
|
|
109
|
+
* is keyed on the roster version and the client key's version, so any
|
|
110
|
+
* account change or key rotation re-verifies at once.
|
|
111
|
+
*/
|
|
112
|
+
accounts?: AccountContext;
|
|
101
113
|
} = {}
|
|
102
114
|
): Promise<Check[]> {
|
|
103
115
|
const { outOfQuota = new Set<string>(), cache = {} } = options;
|
|
@@ -122,7 +134,9 @@ export async function verifyNativeAgents(
|
|
|
122
134
|
agent,
|
|
123
135
|
pins[agent.pin] ?? '',
|
|
124
136
|
registry,
|
|
125
|
-
await sourceKey(launcherSources(workspace, agent))
|
|
137
|
+
await sourceKey(launcherSources(workspace, agent)),
|
|
138
|
+
options.accounts?.rosterVersion ?? '',
|
|
139
|
+
options.accounts?.keyVersion ?? ''
|
|
126
140
|
),
|
|
127
141
|
() =>
|
|
128
142
|
sessionCheck(agent, { node, workspace, cwd, launch }, run),
|
|
@@ -130,12 +144,124 @@ export async function verifyNativeAgents(
|
|
|
130
144
|
),
|
|
131
145
|
])
|
|
132
146
|
);
|
|
133
|
-
|
|
147
|
+
const accounts =
|
|
148
|
+
options.accounts && launch.kind !== 'incomplete'
|
|
149
|
+
? await accountSessionChecks(
|
|
150
|
+
options.accounts.live,
|
|
151
|
+
options.accounts.exposed,
|
|
152
|
+
(instance) =>
|
|
153
|
+
accountSession(instance, options.accounts!, {
|
|
154
|
+
node,
|
|
155
|
+
workspace,
|
|
156
|
+
cwd,
|
|
157
|
+
launch,
|
|
158
|
+
cache,
|
|
159
|
+
run,
|
|
160
|
+
})
|
|
161
|
+
)
|
|
162
|
+
: [];
|
|
163
|
+
return [...environment.checks, ...results.flat(), ...accounts];
|
|
134
164
|
} finally {
|
|
135
165
|
await rm(cwd, { recursive: true, force: true });
|
|
136
166
|
}
|
|
137
167
|
}
|
|
138
168
|
|
|
169
|
+
export interface AccountContext {
|
|
170
|
+
live: Account[];
|
|
171
|
+
exposed: Instance[];
|
|
172
|
+
rosterVersion: string;
|
|
173
|
+
keyVersion: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** How a per-account instance is launched: its tool's launcher, pinned to
|
|
177
|
+
* the account as T3 pins it.
|
|
178
|
+
*/
|
|
179
|
+
const ACCOUNT_LAUNCH: Record<
|
|
180
|
+
string,
|
|
181
|
+
{ wrapper: string; launcher: string; args: string[] }
|
|
182
|
+
> = {
|
|
183
|
+
claude: {
|
|
184
|
+
wrapper: 'claude-proxy.ts',
|
|
185
|
+
launcher: 'claude-proxy',
|
|
186
|
+
args: ['-p', PROMPT],
|
|
187
|
+
},
|
|
188
|
+
codex: {
|
|
189
|
+
wrapper: 'codex-proxy.ts',
|
|
190
|
+
launcher: 'codex-proxy.ts',
|
|
191
|
+
args: ['exec', '--skip-git-repo-check', PROMPT],
|
|
192
|
+
},
|
|
193
|
+
opencode: {
|
|
194
|
+
wrapper: 'opencode-proxy.ts',
|
|
195
|
+
launcher: 'opencode-proxy.ts',
|
|
196
|
+
args: ['run', PROMPT],
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
async function accountSession(
|
|
201
|
+
instance: Instance,
|
|
202
|
+
accounts: AccountContext,
|
|
203
|
+
context: {
|
|
204
|
+
node: string;
|
|
205
|
+
workspace: string;
|
|
206
|
+
cwd: string;
|
|
207
|
+
launch: Launch;
|
|
208
|
+
cache: VerifyCache;
|
|
209
|
+
run: Runner;
|
|
210
|
+
}
|
|
211
|
+
): Promise<'ok' | 'cooling-down' | 'failed'> {
|
|
212
|
+
const spec = ACCOUNT_LAUNCH[instance.tool];
|
|
213
|
+
if (!spec) return 'failed';
|
|
214
|
+
const check = await cachedCheck(
|
|
215
|
+
`account-${context.node}-${instance.tool}-${accountSlug(instance.account)}`,
|
|
216
|
+
cacheKey(
|
|
217
|
+
context.node,
|
|
218
|
+
instance,
|
|
219
|
+
spec,
|
|
220
|
+
accounts.rosterVersion,
|
|
221
|
+
accounts.keyVersion
|
|
222
|
+
),
|
|
223
|
+
async () => {
|
|
224
|
+
const agents = join(context.workspace, 'agents');
|
|
225
|
+
const service = context.launch.kind === 'service';
|
|
226
|
+
const result = await context.run(
|
|
227
|
+
service
|
|
228
|
+
? [join(agents, spec.launcher), ...spec.args]
|
|
229
|
+
: ['bun', join(agents, spec.wrapper), ...spec.args],
|
|
230
|
+
{
|
|
231
|
+
cwd: context.cwd,
|
|
232
|
+
env: {
|
|
233
|
+
...(context.launch.kind === 'service'
|
|
234
|
+
? context.launch.env
|
|
235
|
+
: { PATH: pinnedPath() }),
|
|
236
|
+
WS_AGENT_NODE: context.node,
|
|
237
|
+
WS_AGENT_ACCOUNT: instance.account,
|
|
238
|
+
},
|
|
239
|
+
...(service ? { isolatedEnv: true } : {}),
|
|
240
|
+
input: '',
|
|
241
|
+
timeoutMs: 150_000,
|
|
242
|
+
}
|
|
243
|
+
);
|
|
244
|
+
const outcome = sessionResult(result.ok, result.stdout + result.stderr);
|
|
245
|
+
return {
|
|
246
|
+
id: 'account-session',
|
|
247
|
+
status:
|
|
248
|
+
outcome === 'ok'
|
|
249
|
+
? 'pass'
|
|
250
|
+
: outcome === 'cooling-down'
|
|
251
|
+
? 'warn'
|
|
252
|
+
: 'fail',
|
|
253
|
+
summary: outcome,
|
|
254
|
+
};
|
|
255
|
+
},
|
|
256
|
+
context.cache
|
|
257
|
+
);
|
|
258
|
+
return check.status === 'pass'
|
|
259
|
+
? 'ok'
|
|
260
|
+
: check.status === 'warn'
|
|
261
|
+
? 'cooling-down'
|
|
262
|
+
: 'failed';
|
|
263
|
+
}
|
|
264
|
+
|
|
139
265
|
/** Reads `tool = "x"` and `"tool" = { version = "x", ... }` pins. */
|
|
140
266
|
export function parsePins(source: string): Record<string, string> {
|
|
141
267
|
const pins: Record<string, string> = {};
|
package/src/auth-checks.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { record } from './config';
|
|
|
5
5
|
import { codingAuth, serviceAuth } from './service-auth';
|
|
6
6
|
import { workstationChecks } from './workstation-checks';
|
|
7
7
|
import { toolchainChecks } from './toolchain-checks';
|
|
8
|
+
import { workerSelection } from '../bootstrap/worker-tools';
|
|
8
9
|
import type { Capability, Check, Runner, CommandResult } from './types';
|
|
9
10
|
|
|
10
11
|
export async function authCheck(
|
|
@@ -24,6 +25,8 @@ export async function authCheck(
|
|
|
24
25
|
root,
|
|
25
26
|
run,
|
|
26
27
|
definition.workers.some((node) => node.id === nodeId)
|
|
28
|
+
? workerSelection(definition, nodeId)
|
|
29
|
+
: undefined
|
|
27
30
|
);
|
|
28
31
|
return basicAuth(capability, run, root);
|
|
29
32
|
}
|
package/src/cli/agent-guide.ts
CHANGED
|
@@ -63,7 +63,7 @@ export function agentGuide(release: Release) {
|
|
|
63
63
|
'devfleet --interactive (explicit opt-in; TTY required, disabled in CI)',
|
|
64
64
|
groups: {
|
|
65
65
|
'fleet-*':
|
|
66
|
-
'Developer computers: fleet-status, fleet-discover, fleet-sync, fleet-state, fleet-node-sync, fleet-repair, fleet-nodes, fleet-node-add',
|
|
66
|
+
'Developer computers: fleet-status, fleet-discover, fleet-sync, fleet-state, fleet-report, fleet-node-sync, fleet-repair, fleet-nodes, fleet-node-add',
|
|
67
67
|
't3-*': 'Declared T3 provider instances: t3-status, t3-apply, t3-cleanup',
|
|
68
68
|
cliproxy: 'The CLI proxy service on the manager; arguments are forwarded',
|
|
69
69
|
},
|
|
@@ -89,9 +89,11 @@ export function agentGuide(release: Release) {
|
|
|
89
89
|
'130': 'Cancelled',
|
|
90
90
|
},
|
|
91
91
|
errors:
|
|
92
|
-
'{version:1,ok:false,code,error}; raw subprocess output and exception details are withheld',
|
|
92
|
+
'schema/output/error.schema.json: {version:1,ok:false,code,error}; raw subprocess output and exception details are withheld',
|
|
93
93
|
reports:
|
|
94
|
-
'
|
|
94
|
+
'Each output has a JSON schema in this package; parse by it and by its version. Diagnostic reports (fleet-status, fleet-state, fleet-repair): schema/output/report.schema.json, version 1. fleet-sync: schema/output/fleet-sync.schema.json, version 3 (a --dry-run adds plan). fleet-report: schema/output/fleet-report.schema.json, version 1.',
|
|
95
|
+
checks:
|
|
96
|
+
'schema/check-ids.json lists every check ID with what a passing check means; * stands for a name. Each failing check carries its own remedy.',
|
|
95
97
|
statuses: ['pass', 'warn', 'fail', 'skip'],
|
|
96
98
|
coverage:
|
|
97
99
|
'Default fleet-status tests the manager and all configured nodes. --local tests only this manager; --tailnet and --workers cover Tailscale computers. A missing roster fails explicitly. T3/VS Code end-to-end checks remain manual.',
|