@zgeoff/atc 2.19.0 → 2.22.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/package.json +1 -1
- package/src/agents/agent-adapter.ts +5 -0
- package/src/agents/gateway-adapter.ts +25 -2
- package/src/cli.ts +187 -113
- package/src/client/collect-agent-picks.ts +5 -2
- package/src/client/daemon-client.ts +29 -18
- package/src/daemon/build-agent-list.ts +8 -6
- package/src/daemon/build-auth-binding.ts +58 -0
- package/src/daemon/build-imp-name.ts +11 -0
- package/src/daemon/build-imp-provider.ts +25 -2
- package/src/daemon/build-payload-hash.ts +4 -3
- package/src/daemon/daemon-connection.ts +277 -20
- package/src/daemon/daemon-context.ts +8 -0
- package/src/daemon/daemon.ts +186 -32
- package/src/daemon/find-token-fingerprint.ts +23 -0
- package/src/daemon/handshake-throttle.ts +47 -0
- package/src/daemon/idempotency-ledger.ts +20 -2
- package/src/daemon/imp-port.ts +4 -2
- package/src/daemon/imp-provider.ts +24 -19
- package/src/daemon/is-allowed-listen-host.ts +63 -0
- package/src/daemon/load-listener-tokens.ts +46 -0
- package/src/daemon/parse-listen-address.ts +33 -0
- package/src/daemon/restore-fleet.ts +2 -1
- package/src/daemon/sessions.ts +6 -0
- package/src/daemon/start-tcp-listener.ts +165 -0
- package/src/federation/build-binding-payload-hash.ts +34 -0
- package/src/federation/build-daemon-outdated-error.ts +14 -0
- package/src/federation/build-events-filter-hash.ts +16 -0
- package/src/federation/build-gateway-error.ts +45 -0
- package/src/federation/build-gateway-id.ts +14 -0
- package/src/federation/build-gateway-result.ts +31 -0
- package/src/federation/build-ruled-value.ts +53 -0
- package/src/federation/collect-unruled-id-paths.ts +46 -0
- package/src/federation/daemon-caller.ts +473 -0
- package/src/federation/daemon-pool.ts +56 -0
- package/src/federation/decode-gateway-cursor.ts +73 -0
- package/src/federation/encode-gateway-cursor.ts +15 -0
- package/src/federation/gateway-error.ts +25 -0
- package/src/federation/gateway-store.ts +253 -0
- package/src/federation/id-rules.ts +85 -0
- package/src/federation/load-gateway-registry.ts +29 -0
- package/src/federation/max-events-cursor-bytes.ts +4 -0
- package/src/federation/max-registry-daemons.ts +26 -0
- package/src/federation/merge-event-pages.ts +228 -0
- package/src/federation/open-gateway-caller.ts +55 -0
- package/src/federation/parse-gateway-id.ts +28 -0
- package/src/federation/parse-gateway-registry.ts +123 -0
- package/src/federation/pick-daemon-state.ts +46 -0
- package/src/federation/plan-event-reads.ts +54 -0
- package/src/federation/read-fleet-events.ts +279 -0
- package/src/federation/require-serving-daemon.ts +27 -0
- package/src/federation/resolve-daemon-request.ts +59 -0
- package/src/federation/routing-caller.ts +450 -0
- package/src/federation/types.ts +33 -0
- package/src/federation/wait-for-outcome.ts +38 -0
- package/src/mcp/answer-rpc-request.ts +14 -1
- package/src/mcp/build-tool-list.ts +6 -5
- package/src/mcp/mcp-tools.ts +41 -8
- package/src/mcp/require-daemon-features.ts +2 -0
- package/src/mcp/run-tool.ts +15 -1
- package/src/mcp/start-mcp-http-server.ts +53 -9
- package/src/mcp/types.ts +8 -1
- package/src/protocol/daemon-features.ts +9 -0
- package/src/protocol/protocol.ts +2 -0
- package/src/protocol/request-param-schemas.ts +6 -0
- package/src/run-daemon-id.ts +52 -0
- package/src/shared/collect-auth-profiles.ts +122 -0
- package/src/shared/collect-gateways.ts +223 -6
- package/src/shared/config.ts +32 -4
- package/src/shared/find-daemon-record.ts +8 -3
- package/src/shared/resolve-auth-profiles.ts +186 -0
- package/src/store/run-migrations.ts +77 -0
- package/src/store/runtime-auth-binding.ts +110 -0
- package/src/store/state-store.ts +202 -0
package/package.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AdapterEvent } from '../protocol/adapter-event';
|
|
2
|
+
import type { DaemonError } from '../protocol/daemon-error';
|
|
2
3
|
import type { HookEvent } from '../protocol/hook-event';
|
|
3
4
|
import type { AgentID } from '../shared/agent-id';
|
|
4
5
|
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
@@ -205,6 +206,10 @@ export interface AgentAdapter {
|
|
|
205
206
|
// Absent: the agent runs there as a local spawn plans it, with no files.
|
|
206
207
|
readonly planGuestSpawn?: (opts: SpawnOptions, guest: GuestPaths) => GuestSpawnPlan | null;
|
|
207
208
|
|
|
209
|
+
// The refusal every start of this agent's harness gets, on any target,
|
|
210
|
+
// or null when it may start. Absent: no start is refused.
|
|
211
|
+
readonly findSpawnRefusal?: () => DaemonError | null;
|
|
212
|
+
|
|
208
213
|
// The command that exits 0 inside a remote host when the agent there can
|
|
209
214
|
// sign in without a person. Absent: atc runs no check.
|
|
210
215
|
readonly planAuthCheck?: () => readonly string[];
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AdapterEvent } from '../protocol/adapter-event';
|
|
2
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
2
3
|
import type { HookEvent } from '../protocol/hook-event';
|
|
3
4
|
import type { AgentID } from '../shared/agent-id';
|
|
4
5
|
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
@@ -39,7 +40,8 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
39
40
|
readonly id: AgentID;
|
|
40
41
|
|
|
41
42
|
// A headless turn carries the same settings file the terminal spawn does,
|
|
42
|
-
// so it reaches this backend rather than the default one.
|
|
43
|
+
// so it reaches this backend rather than the default one. A gateway whose
|
|
44
|
+
// credential comes through impd's broker runs no headless turn.
|
|
43
45
|
readonly headlessRunner: HeadlessRunner | null;
|
|
44
46
|
|
|
45
47
|
// The CLI's hooks are authoritative; no screen heuristics needed.
|
|
@@ -90,7 +92,7 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
90
92
|
this.claude = new ClaudeAdapter(config);
|
|
91
93
|
|
|
92
94
|
this.headlessRunner =
|
|
93
|
-
headlessRun === null
|
|
95
|
+
headlessRun === null || gateway.auth !== undefined
|
|
94
96
|
? null
|
|
95
97
|
: makeClaudeHeadlessRunner(headlessRun, {
|
|
96
98
|
claudeBin: gateway.bin,
|
|
@@ -100,6 +102,21 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
100
102
|
});
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
// A gateway whose credential comes through impd's broker never starts
|
|
106
|
+
// until the broker path exists: started without it, the CLI would send
|
|
107
|
+
// whatever credential it holds to the gateway's host.
|
|
108
|
+
findSpawnRefusal(): DaemonError | null {
|
|
109
|
+
if (this.gateway.auth === undefined) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return new DaemonError(
|
|
114
|
+
'auth_target_unsupported',
|
|
115
|
+
`gateway '${this.id}' takes its credential from impd's broker, and brokered credentials are not wired yet`,
|
|
116
|
+
{ agent: this.id },
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
103
120
|
planSpawn(opts: SpawnOptions): SpawnPlan {
|
|
104
121
|
return {
|
|
105
122
|
bin: this.gateway.bin,
|
|
@@ -142,7 +159,13 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
142
159
|
// explicit flag, so that mode overrides the one the CLI would restore, and
|
|
143
160
|
// the generated settings file, because
|
|
144
161
|
// without it the CLI would resume the session against the default backend.
|
|
162
|
+
// A gateway whose credential comes through impd's broker has none, since
|
|
163
|
+
// outside atc the broker never reaches it.
|
|
145
164
|
buildResumeCommand(cwd: string, agentSessionID: AgentSessionID | undefined): string | null {
|
|
165
|
+
if (this.gateway.auth !== undefined) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
146
169
|
const args = [
|
|
147
170
|
...this.gateway.args,
|
|
148
171
|
...buildRestoreModeArgs(this.gateway.args, this.gateway.settings),
|
package/src/cli.ts
CHANGED
|
@@ -3,10 +3,23 @@
|
|
|
3
3
|
import { defineCommand, runMain } from 'citty';
|
|
4
4
|
import pkg from '../package.json';
|
|
5
5
|
import { collectRedirectURIs } from './collect-redirect-uris';
|
|
6
|
+
import { parseListenAddress } from './daemon/parse-listen-address';
|
|
6
7
|
import { normalizeCLIArgs } from './normalize-cli-args';
|
|
7
8
|
import { parsePort } from './parse-port';
|
|
8
9
|
import { getBuild } from './shared/get-build';
|
|
9
10
|
|
|
11
|
+
// The flags `atc daemon` takes.
|
|
12
|
+
const DAEMON_ARGS = {
|
|
13
|
+
listen: {
|
|
14
|
+
type: 'string',
|
|
15
|
+
description: 'Serve the client protocol on <host>:<port> too, a loopback or tailnet address',
|
|
16
|
+
},
|
|
17
|
+
'token-file': {
|
|
18
|
+
type: 'string',
|
|
19
|
+
description: 'File holding the one or two bearer tokens a --listen handshake must present',
|
|
20
|
+
},
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
10
23
|
const main = defineCommand({
|
|
11
24
|
meta: {
|
|
12
25
|
name: 'atc',
|
|
@@ -152,122 +165,36 @@ const main = defineCommand({
|
|
|
152
165
|
name: 'daemon',
|
|
153
166
|
description: 'Run the atc daemon in the foreground',
|
|
154
167
|
},
|
|
155
|
-
async run() {
|
|
156
|
-
const daemon = await import('./daemon/daemon');
|
|
157
|
-
const config = await import('./shared/config');
|
|
158
|
-
const claude = await import('./agents/claude-adapter');
|
|
159
|
-
const grok = await import('./agents/grok-adapter');
|
|
160
|
-
const codex = await import('./agents/codex-adapter');
|
|
161
|
-
const gateway = await import('./agents/gateway-adapter');
|
|
162
|
-
const headless = await import('./agents/start-claude-headless-run');
|
|
163
|
-
const targets = await import('./daemon/build-execution-targets');
|
|
164
|
-
const sourceOrder = await import('./sources/build-sources');
|
|
165
|
-
const builtinSources = await import('./sources/collect-builtin-sources');
|
|
166
|
-
const zoxide = await import('./shared/collect-zoxide-dirs');
|
|
167
|
-
const home = await import('./shared/resolve-home-dir');
|
|
168
|
-
|
|
169
|
-
// Test harnesses shrink the outbound queue to force overflow
|
|
170
|
-
// deterministically; unset means the production default.
|
|
171
|
-
const queueBytes = Number(process.env['ATC_QUEUE_BYTES']);
|
|
172
|
-
const cfg = config.loadConfig();
|
|
173
|
-
const built = targets.buildExecutionTargets(cfg.targets);
|
|
174
|
-
const targetErrors = [...cfg.targetErrors, ...built.errors];
|
|
175
|
-
|
|
176
|
-
for (const error of targetErrors) {
|
|
177
|
-
const line =
|
|
178
|
-
error.scope === 'config'
|
|
179
|
-
? `${error.path} cannot be used (${error.problem}: ${error.detail}); every spawn is refused, local ones included, until it is fixed`
|
|
180
|
-
: error.problem;
|
|
181
|
-
|
|
182
|
-
console.error(`atc daemon: config: ${line}`);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
for (const problem of [...cfg.principalErrors, ...cfg.workspaceErrors]) {
|
|
186
|
-
console.error(`atc daemon: config: ${problem}`);
|
|
187
|
-
}
|
|
188
168
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
169
|
+
// Declared here as well as on `serve`, so the parser skips their
|
|
170
|
+
// values when it looks for a subcommand name.
|
|
171
|
+
args: DAEMON_ARGS,
|
|
172
|
+
default: 'serve',
|
|
173
|
+
subCommands: {
|
|
174
|
+
serve: () =>
|
|
175
|
+
defineCommand({
|
|
176
|
+
meta: {
|
|
177
|
+
name: 'serve',
|
|
178
|
+
description: 'Run the atc daemon in the foreground',
|
|
179
|
+
hidden: true,
|
|
180
|
+
},
|
|
181
|
+
args: DAEMON_ARGS,
|
|
182
|
+
async run(ctx) {
|
|
183
|
+
await runDaemon(ctx.args.listen ?? null, ctx.args['token-file'] ?? null);
|
|
184
|
+
},
|
|
196
185
|
}),
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
// Cap on how long a fleet restore waits for one revived session to
|
|
207
|
-
// report it has booted before moving to the next. Tests pin it to
|
|
208
|
-
// keep timing deterministic; unset means the production default.
|
|
209
|
-
const capOverride = Number(process.env['ATC_RESTORE_BOOT_TIMEOUT_MS']);
|
|
210
|
-
|
|
211
|
-
const restoreBootTimeoutMs =
|
|
212
|
-
Number.isFinite(capOverride) && capOverride >= 0 ? capOverride : 15_000;
|
|
213
|
-
|
|
214
|
-
// How long a started session may go without a tap before a message
|
|
215
|
-
// to it is refused. Tests pin it to 0 to reach the refusal at once.
|
|
216
|
-
const graceOverride = Number(process.env['ATC_TAP_GRACE_MS']);
|
|
217
|
-
|
|
218
|
-
const claudeAdapter = new claude.ClaudeAdapter(cfg, headless.startClaudeHeadlessRun);
|
|
219
|
-
const grokAdapter = new grok.GrokAdapter(cfg);
|
|
220
|
-
const codexAdapter = new codex.CodexAdapter(cfg);
|
|
221
|
-
|
|
222
|
-
const gatewayAdapters = cfg.gateways.map(
|
|
223
|
-
(entry) => new gateway.GatewayAdapter(entry, cfg, headless.startClaudeHeadlessRun),
|
|
224
|
-
);
|
|
225
|
-
|
|
226
|
-
let handle: Awaited<ReturnType<typeof daemon.startDaemon>>;
|
|
227
|
-
|
|
228
|
-
try {
|
|
229
|
-
handle = await daemon.startDaemon({
|
|
230
|
-
socketPath: config.daemonSocketPath,
|
|
231
|
-
reporterSocketPath: config.socketPath,
|
|
232
|
-
eventsSocketPath: config.eventsSocketPath,
|
|
233
|
-
build: getBuild(),
|
|
234
|
-
adapter: claudeAdapter,
|
|
235
|
-
adapters: [claudeAdapter, grokAdapter, codexAdapter, ...gatewayAdapters],
|
|
236
|
-
dbPath: config.dbFile,
|
|
237
|
-
legacyFleetPath: config.legacyFleetFile,
|
|
238
|
-
pidPath: config.daemonPidFile,
|
|
239
|
-
hooks: cfg.hooks,
|
|
240
|
-
targets: built.targets,
|
|
241
|
-
defaultTarget: cfg.defaultTarget,
|
|
242
|
-
targetErrors,
|
|
243
|
-
principals: cfg.principals,
|
|
244
|
-
sources: sources.sources,
|
|
245
|
-
gitTransports: cfg.workspaces.gitTransports,
|
|
246
|
-
restoreBootTimeoutMs,
|
|
247
|
-
...(Number.isFinite(graceOverride) && graceOverride >= 0
|
|
248
|
-
? { tapGraceMs: graceOverride }
|
|
249
|
-
: {}),
|
|
250
|
-
...(Number.isFinite(queueBytes) && queueBytes > 0 ? { queueBytes } : {}),
|
|
251
|
-
onQuit: () => process.exit(0),
|
|
252
|
-
});
|
|
253
|
-
} catch (error) {
|
|
254
|
-
// A second daemon on the same state directory refuses to start
|
|
255
|
-
// and touches nothing; any other startup failure stays a crash.
|
|
256
|
-
if (error instanceof Error && Reflect.get(error, 'code') === 'daemon_locked') {
|
|
257
|
-
console.error(error.message);
|
|
258
|
-
process.exit(1);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
throw error;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
process.on('SIGTERM', () => {
|
|
265
|
-
void (async () => {
|
|
266
|
-
await handle.stop();
|
|
186
|
+
id: () =>
|
|
187
|
+
defineCommand({
|
|
188
|
+
meta: {
|
|
189
|
+
name: 'id',
|
|
190
|
+
description: "Print the running daemon's daemonID",
|
|
191
|
+
},
|
|
192
|
+
async run() {
|
|
193
|
+
const id = await import('./run-daemon-id');
|
|
267
194
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
195
|
+
await id.runDaemonID(getBuild());
|
|
196
|
+
},
|
|
197
|
+
}),
|
|
271
198
|
},
|
|
272
199
|
}),
|
|
273
200
|
events: () =>
|
|
@@ -395,4 +322,151 @@ const main = defineCommand({
|
|
|
395
322
|
},
|
|
396
323
|
});
|
|
397
324
|
|
|
325
|
+
// Runs the daemon in the foreground until SIGTERM, with a TCP listener when
|
|
326
|
+
// --listen and --token-file are given.
|
|
327
|
+
async function runDaemon(listenArg: string | null, tokenFile: string | null): Promise<void> {
|
|
328
|
+
if ((listenArg === null) !== (tokenFile === null)) {
|
|
329
|
+
console.error('atc daemon: --listen and --token-file go together');
|
|
330
|
+
process.exit(1);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const listen = listenArg === null ? null : parseListenAddress(listenArg);
|
|
334
|
+
|
|
335
|
+
if (listen !== null && !listen.ok) {
|
|
336
|
+
console.error(`atc daemon: ${listen.message}`);
|
|
337
|
+
process.exit(1);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const daemon = await import('./daemon/daemon');
|
|
341
|
+
const config = await import('./shared/config');
|
|
342
|
+
const claude = await import('./agents/claude-adapter');
|
|
343
|
+
const grok = await import('./agents/grok-adapter');
|
|
344
|
+
const codex = await import('./agents/codex-adapter');
|
|
345
|
+
const gateway = await import('./agents/gateway-adapter');
|
|
346
|
+
const headless = await import('./agents/start-claude-headless-run');
|
|
347
|
+
const targets = await import('./daemon/build-execution-targets');
|
|
348
|
+
const sourceOrder = await import('./sources/build-sources');
|
|
349
|
+
const builtinSources = await import('./sources/collect-builtin-sources');
|
|
350
|
+
const zoxide = await import('./shared/collect-zoxide-dirs');
|
|
351
|
+
const home = await import('./shared/resolve-home-dir');
|
|
352
|
+
|
|
353
|
+
// Test harnesses shrink the outbound queue to force overflow
|
|
354
|
+
// deterministically; unset means the production default.
|
|
355
|
+
const queueBytes = Number(process.env['ATC_QUEUE_BYTES']);
|
|
356
|
+
const cfg = config.loadConfig();
|
|
357
|
+
const built = targets.buildExecutionTargets(cfg.targets);
|
|
358
|
+
const targetErrors = [...cfg.targetErrors, ...built.errors];
|
|
359
|
+
|
|
360
|
+
for (const error of targetErrors) {
|
|
361
|
+
const line =
|
|
362
|
+
error.scope === 'config'
|
|
363
|
+
? `${error.path} cannot be used (${error.problem}: ${error.detail}); every spawn is refused, local ones included, until it is fixed`
|
|
364
|
+
: error.problem;
|
|
365
|
+
|
|
366
|
+
console.error(`atc daemon: config: ${line}`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
for (const problem of [
|
|
370
|
+
...cfg.principalErrors,
|
|
371
|
+
...cfg.workspaceErrors,
|
|
372
|
+
...cfg.authProfileErrors,
|
|
373
|
+
...cfg.gatewayErrors,
|
|
374
|
+
]) {
|
|
375
|
+
console.error(`atc daemon: config: ${problem}`);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const sources = sourceOrder.buildSources(
|
|
379
|
+
builtinSources.collectBuiltinSources({
|
|
380
|
+
roots: cfg.dirs.roots,
|
|
381
|
+
githubOwner: cfg.workspaces.githubOwner,
|
|
382
|
+
ghBin: 'gh',
|
|
383
|
+
homeDir: home.resolveHomeDir(),
|
|
384
|
+
collectZoxideDirs: zoxide.collectZoxideDirs,
|
|
385
|
+
}),
|
|
386
|
+
cfg.workspaces.sources,
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
for (const id of sources.missing) {
|
|
390
|
+
console.error(
|
|
391
|
+
`atc daemon: config: workspaces.sources holds '${id}', which this daemon cannot offer; the picker leaves it out`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Cap on how long a fleet restore waits for one revived session to
|
|
396
|
+
// report it has booted before moving to the next. Tests pin it to
|
|
397
|
+
// keep timing deterministic; unset means the production default.
|
|
398
|
+
const capOverride = Number(process.env['ATC_RESTORE_BOOT_TIMEOUT_MS']);
|
|
399
|
+
|
|
400
|
+
const restoreBootTimeoutMs =
|
|
401
|
+
Number.isFinite(capOverride) && capOverride >= 0 ? capOverride : 15_000;
|
|
402
|
+
|
|
403
|
+
// How long a started session may go without a tap before a message
|
|
404
|
+
// to it is refused. Tests pin it to 0 to reach the refusal at once.
|
|
405
|
+
const graceOverride = Number(process.env['ATC_TAP_GRACE_MS']);
|
|
406
|
+
|
|
407
|
+
const claudeAdapter = new claude.ClaudeAdapter(cfg, headless.startClaudeHeadlessRun);
|
|
408
|
+
const grokAdapter = new grok.GrokAdapter(cfg);
|
|
409
|
+
const codexAdapter = new codex.CodexAdapter(cfg);
|
|
410
|
+
|
|
411
|
+
const gatewayAdapters = cfg.gateways.map(
|
|
412
|
+
(entry) => new gateway.GatewayAdapter(entry, cfg, headless.startClaudeHeadlessRun),
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
let handle: Awaited<ReturnType<typeof daemon.startDaemon>>;
|
|
416
|
+
|
|
417
|
+
try {
|
|
418
|
+
handle = await daemon.startDaemon({
|
|
419
|
+
socketPath: config.daemonSocketPath,
|
|
420
|
+
reporterSocketPath: config.socketPath,
|
|
421
|
+
eventsSocketPath: config.eventsSocketPath,
|
|
422
|
+
build: getBuild(),
|
|
423
|
+
adapter: claudeAdapter,
|
|
424
|
+
adapters: [claudeAdapter, grokAdapter, codexAdapter, ...gatewayAdapters],
|
|
425
|
+
dbPath: config.dbFile,
|
|
426
|
+
legacyFleetPath: config.legacyFleetFile,
|
|
427
|
+
pidPath: config.daemonPidFile,
|
|
428
|
+
hooks: cfg.hooks,
|
|
429
|
+
targets: built.targets,
|
|
430
|
+
defaultTarget: cfg.defaultTarget,
|
|
431
|
+
targetErrors,
|
|
432
|
+
principals: cfg.principals,
|
|
433
|
+
sources: sources.sources,
|
|
434
|
+
gitTransports: cfg.workspaces.gitTransports,
|
|
435
|
+
...(listen === null || tokenFile === null
|
|
436
|
+
? {}
|
|
437
|
+
: { listen: { host: listen.host, port: listen.port, tokenFile } }),
|
|
438
|
+
restoreBootTimeoutMs,
|
|
439
|
+
...(Number.isFinite(graceOverride) && graceOverride >= 0
|
|
440
|
+
? { tapGraceMs: graceOverride }
|
|
441
|
+
: {}),
|
|
442
|
+
...(Number.isFinite(queueBytes) && queueBytes > 0 ? { queueBytes } : {}),
|
|
443
|
+
onQuit: () => process.exit(0),
|
|
444
|
+
});
|
|
445
|
+
} catch (error) {
|
|
446
|
+
// A second daemon on the same state directory, or a listener whose
|
|
447
|
+
// address or token file is refused or whose bind fails, stops the start
|
|
448
|
+
// with nothing left held; any other startup failure stays a crash.
|
|
449
|
+
const code: unknown = error instanceof Error ? Reflect.get(error, 'code') : null;
|
|
450
|
+
|
|
451
|
+
if (error instanceof Error && (code === 'daemon_locked' || code === 'listen_refused')) {
|
|
452
|
+
console.error(error.message);
|
|
453
|
+
process.exit(1);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
throw error;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
process.on('SIGHUP', () => {
|
|
460
|
+
handle.refreshTokens();
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
process.on('SIGTERM', () => {
|
|
464
|
+
void (async () => {
|
|
465
|
+
await handle.stop();
|
|
466
|
+
|
|
467
|
+
process.exit(0);
|
|
468
|
+
})();
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
398
472
|
await runMain(main, { rawArgs: normalizeCLIArgs(process.argv.slice(2)) });
|
|
@@ -9,7 +9,8 @@ export interface AgentPick {
|
|
|
9
9
|
/**
|
|
10
10
|
* The agent choices the spawn and adopt flows offer, in menu order. An agent
|
|
11
11
|
* whose configured binary does not resolve is left out, so every row in the
|
|
12
|
-
* menu is a session that can start.
|
|
12
|
+
* menu is a session that can start. A gateway with auth is left out too, since
|
|
13
|
+
* every start of one is refused. Resolution follows the rule a spawn
|
|
13
14
|
* follows: a bare name comes off PATH, a name carrying a separator is taken
|
|
14
15
|
* as a path, and either way it has to be executable.
|
|
15
16
|
*/
|
|
@@ -22,7 +23,9 @@ export function collectAgentPicks(config: Config): AgentPick[] {
|
|
|
22
23
|
{ agent: 'claude', label: 'Claude', bin: config.claudeBin },
|
|
23
24
|
{ agent: 'grok', label: 'Grok', bin: config.grokBin },
|
|
24
25
|
{ agent: 'codex', label: 'Codex', bin: config.codexBin },
|
|
25
|
-
...config.gateways
|
|
26
|
+
...config.gateways
|
|
27
|
+
.filter((g) => g.auth === undefined)
|
|
28
|
+
.map((g) => ({ agent: g.id, label: g.label, bin: g.bin })),
|
|
26
29
|
];
|
|
27
30
|
|
|
28
31
|
return candidates
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SocketHandler } from 'bun';
|
|
1
2
|
import type { DaemonChannel } from '../protocol/daemon-channel';
|
|
2
3
|
import { DaemonError } from '../protocol/daemon-error';
|
|
3
4
|
import { LineDecoder } from '../protocol/line-decoder';
|
|
@@ -34,25 +35,30 @@ export class DaemonClient implements DaemonChannel {
|
|
|
34
35
|
// once instead of waiting on a response that can never arrive.
|
|
35
36
|
private closedReason: string | null = null;
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
// Connects to the daemon at a unix socket path, or at a TCP address.
|
|
39
|
+
static async open(
|
|
40
|
+
address: string | { readonly hostname: string; readonly port: number },
|
|
41
|
+
): Promise<DaemonClient> {
|
|
38
42
|
const client = new DaemonClient();
|
|
39
43
|
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
data(_s, buf) {
|
|
44
|
-
client.applyChunk(buf);
|
|
45
|
-
},
|
|
46
|
-
drain() {
|
|
47
|
-
client.queue?.drain();
|
|
48
|
-
},
|
|
49
|
-
close() {
|
|
50
|
-
client.drainPending('connection closed');
|
|
51
|
-
client.onClose();
|
|
52
|
-
},
|
|
53
|
-
error() {},
|
|
44
|
+
const handlers: SocketHandler = {
|
|
45
|
+
data(_s, buf) {
|
|
46
|
+
client.applyChunk(buf);
|
|
54
47
|
},
|
|
55
|
-
|
|
48
|
+
drain() {
|
|
49
|
+
client.queue?.drain();
|
|
50
|
+
},
|
|
51
|
+
close() {
|
|
52
|
+
client.drainPending('connection closed');
|
|
53
|
+
client.onClose();
|
|
54
|
+
},
|
|
55
|
+
error() {},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const socket =
|
|
59
|
+
typeof address === 'string'
|
|
60
|
+
? await Bun.connect({ unix: address, socket: handlers })
|
|
61
|
+
: await Bun.connect({ hostname: address.hostname, port: address.port, socket: handlers });
|
|
56
62
|
|
|
57
63
|
client.socket = socket;
|
|
58
64
|
|
|
@@ -61,8 +67,13 @@ export class DaemonClient implements DaemonChannel {
|
|
|
61
67
|
return client;
|
|
62
68
|
}
|
|
63
69
|
|
|
64
|
-
|
|
65
|
-
|
|
70
|
+
// A handshake with a token presents it as a bearer token, which a TCP
|
|
71
|
+
// listener requires.
|
|
72
|
+
sendHello(build: string, token?: string): Promise<Readonly<Record<string, unknown>>> {
|
|
73
|
+
return this.sendRequest('daemon.hello', {
|
|
74
|
+
client: build,
|
|
75
|
+
auth: token === undefined ? { scheme: 'none' } : { scheme: 'bearer', token },
|
|
76
|
+
});
|
|
66
77
|
}
|
|
67
78
|
|
|
68
79
|
// A request with a principal acts as that principal, within what the
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { AgentAdapter, SpawnOptionSpec } from '../agents/agent-adapter';
|
|
2
2
|
|
|
3
3
|
interface AgentCapabilities {
|
|
4
|
-
// Only an installed agent can start a
|
|
4
|
+
// Only an installed agent whose starts are not all refused can start a
|
|
5
|
+
// session.
|
|
5
6
|
readonly spawn: boolean;
|
|
6
7
|
readonly readTranscript: boolean;
|
|
7
8
|
|
|
@@ -51,6 +52,7 @@ export function buildAgentList(
|
|
|
51
52
|
return adapters.map((adapter) => {
|
|
52
53
|
const profile = adapter.profile;
|
|
53
54
|
const installed = profile === undefined ? false : isInstalled(profile.bin);
|
|
55
|
+
const spawnable = installed && (adapter.findSpawnRefusal?.() ?? null) === null;
|
|
54
56
|
|
|
55
57
|
return {
|
|
56
58
|
id: adapter.id,
|
|
@@ -58,7 +60,7 @@ export function buildAgentList(
|
|
|
58
60
|
kind: profile?.kind ?? adapter.id,
|
|
59
61
|
installed,
|
|
60
62
|
capabilities: {
|
|
61
|
-
spawn:
|
|
63
|
+
spawn: spawnable,
|
|
62
64
|
readTranscript: adapter.parseTranscriptLine !== undefined,
|
|
63
65
|
message: adapter.takesMessages,
|
|
64
66
|
attach: true,
|
|
@@ -67,8 +69,8 @@ export function buildAgentList(
|
|
|
67
69
|
},
|
|
68
70
|
models: profile?.models ?? null,
|
|
69
71
|
spawnOptions: {
|
|
70
|
-
model: buildSpawnOptionEntry(profile?.spawnOptions.model,
|
|
71
|
-
effort: buildSpawnOptionEntry(profile?.spawnOptions.effort,
|
|
72
|
+
model: buildSpawnOptionEntry(profile?.spawnOptions.model, spawnable),
|
|
73
|
+
effort: buildSpawnOptionEntry(profile?.spawnOptions.effort, spawnable),
|
|
72
74
|
},
|
|
73
75
|
};
|
|
74
76
|
});
|
|
@@ -86,9 +88,9 @@ const NO_SPAWN_OPTION: SpawnOptionSpec = {
|
|
|
86
88
|
|
|
87
89
|
function buildSpawnOptionEntry(
|
|
88
90
|
spec: SpawnOptionSpec | undefined,
|
|
89
|
-
|
|
91
|
+
spawnable: boolean,
|
|
90
92
|
): SpawnOptionEntry {
|
|
91
93
|
const resolved = spec ?? NO_SPAWN_OPTION;
|
|
92
94
|
|
|
93
|
-
return { ...resolved, available:
|
|
95
|
+
return { ...resolved, available: spawnable && resolved.supported };
|
|
94
96
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { AgentID } from '../shared/agent-id';
|
|
3
|
+
import type { AuthProfile } from '../shared/collect-auth-profiles';
|
|
4
|
+
import type { GatewayAuth, GatewayConfig } from '../shared/collect-gateways';
|
|
5
|
+
import { resolveAuthProfiles } from '../shared/resolve-auth-profiles';
|
|
6
|
+
import type { AuthProfileProblem, ResolvedAuthSecret } from '../shared/resolve-auth-profiles';
|
|
7
|
+
import { sortJSONKeys } from '../shared/sort-json-keys';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What a session's runtime is bound to through impd's broker: every
|
|
11
|
+
* profile its gateway's selection reaches, the secrets they need impd to
|
|
12
|
+
* hold with exactly these rules, the placeholder variables in place of a
|
|
13
|
+
* credential, and the endpoint. `hash` covers the secrets and their rules
|
|
14
|
+
* alone, so a change to a profile the binding does not reach leaves it as
|
|
15
|
+
* it was, and a change to one it reaches gives another hash.
|
|
16
|
+
*/
|
|
17
|
+
export interface AuthBinding {
|
|
18
|
+
readonly agent: AgentID;
|
|
19
|
+
readonly baseURL: string;
|
|
20
|
+
readonly profiles: readonly string[];
|
|
21
|
+
readonly secrets: readonly ResolvedAuthSecret[];
|
|
22
|
+
readonly placeholderEnv: Readonly<Record<string, string>>;
|
|
23
|
+
readonly hash: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type AuthGateway = Pick<GatewayConfig, 'id' | 'baseURL'> & { readonly auth: GatewayAuth };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Plans the binding a gateway's auth asks for against the current auth
|
|
30
|
+
* profiles, or the problem that refuses it. The hash is the SHA-256 of the
|
|
31
|
+
* resolved secrets as canonical JSON, with each list sorted, so the order
|
|
32
|
+
* of the selection or of the config never changes it.
|
|
33
|
+
*/
|
|
34
|
+
export function buildAuthBinding(
|
|
35
|
+
gateway: AuthGateway,
|
|
36
|
+
profiles: ReadonlyMap<string, AuthProfile>,
|
|
37
|
+
): { readonly binding: AuthBinding } | { readonly problem: AuthProfileProblem } {
|
|
38
|
+
const resolution = resolveAuthProfiles(profiles, gateway.auth.profiles);
|
|
39
|
+
|
|
40
|
+
if ('problem' in resolution) {
|
|
41
|
+
return resolution;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const secrets = resolution.resolved.secrets;
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
binding: {
|
|
48
|
+
agent: gateway.id,
|
|
49
|
+
baseURL: gateway.baseURL,
|
|
50
|
+
profiles: resolution.resolved.profiles,
|
|
51
|
+
secrets,
|
|
52
|
+
placeholderEnv: gateway.auth.placeholderEnv,
|
|
53
|
+
hash: createHash('sha256')
|
|
54
|
+
.update(JSON.stringify(sortJSONKeys({ secrets })))
|
|
55
|
+
.digest('hex'),
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The imp a host key runs on: the target's imp name prefix and the first
|
|
3
|
+
* 20 letters and digits of the key, which is the atc session id of the
|
|
4
|
+
* session that owns the host.
|
|
5
|
+
*/
|
|
6
|
+
export function buildImpName(impPrefix: string, hostKey: string): string {
|
|
7
|
+
return `${impPrefix}${hostKey
|
|
8
|
+
.toLowerCase()
|
|
9
|
+
.replaceAll(/[^a-z0-9]/g, '')
|
|
10
|
+
.slice(0, 20)}`;
|
|
11
|
+
}
|
|
@@ -17,8 +17,12 @@ export interface ImpProviderBuild {
|
|
|
17
17
|
* so no credential sits in the config. `tokenEnv` holds the name of the
|
|
18
18
|
* daemon's environment variable that carries the token, read once here.
|
|
19
19
|
* `tokenFile` holds the path of a file that carries it, read here and again
|
|
20
|
-
* before each impd call and connection. `
|
|
21
|
-
*
|
|
20
|
+
* before each impd call and connection. `impPrefix` starts every imp name
|
|
21
|
+
* the target builds, `atc-` when absent, and must be a lowercase letter
|
|
22
|
+
* followed by up to 10 lowercase letters, digits or hyphens, so the name
|
|
23
|
+
* it builds with a host key's 20 characters stays within impd's 31. `image`,
|
|
24
|
+
* `memoryMib`, `guestDir`, and `guestATC` pass through. No provider when
|
|
25
|
+
* `url` is missing, so the
|
|
22
26
|
* target lists and refuses every spawn. Both token options, either one not
|
|
23
27
|
* a non-empty string, an unset or empty variable, or an empty or unreadable
|
|
24
28
|
* file is a problem and leaves no provider; a target without either calls
|
|
@@ -41,7 +45,17 @@ export function buildImpProvider(
|
|
|
41
45
|
return { provider: null, problem: source };
|
|
42
46
|
}
|
|
43
47
|
|
|
48
|
+
const impPrefix = options['impPrefix'];
|
|
49
|
+
|
|
50
|
+
if (impPrefix !== undefined && !isImpPrefix(impPrefix)) {
|
|
51
|
+
return {
|
|
52
|
+
provider: null,
|
|
53
|
+
problem: `target ${JSON.stringify(id)} must give impPrefix as a lowercase letter followed by up to 10 lowercase letters, digits or hyphens, so every imp name it builds is one impd accepts`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
44
57
|
const provider = new ImpProvider(new ImpClientPort({ url, readToken: source.readToken }), {
|
|
58
|
+
...(impPrefix === undefined ? {} : { impPrefix }),
|
|
45
59
|
...pickString(options, 'image'),
|
|
46
60
|
...pickString(options, 'guestDir'),
|
|
47
61
|
...pickString(options, 'guestATC'),
|
|
@@ -105,6 +119,15 @@ function loadTokenSource(
|
|
|
105
119
|
return { readToken: () => token };
|
|
106
120
|
}
|
|
107
121
|
|
|
122
|
+
// impd's imp name rule, a lowercase letter then up to 30 lowercase letters,
|
|
123
|
+
// digits or hyphens, with room left for the 20 characters of a host key.
|
|
124
|
+
// It holds no `*`, so the prefix never reads as an imp pattern.
|
|
125
|
+
const IMP_PREFIX = /^[a-z][a-z0-9-]{0,10}$/;
|
|
126
|
+
|
|
127
|
+
function isImpPrefix(value: unknown): value is string {
|
|
128
|
+
return typeof value === 'string' && IMP_PREFIX.test(value);
|
|
129
|
+
}
|
|
130
|
+
|
|
108
131
|
function pickString(
|
|
109
132
|
options: Readonly<Record<string, unknown>>,
|
|
110
133
|
key: 'image' | 'guestDir' | 'guestATC',
|