@zgeoff/atc 2.39.0 → 2.41.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 +6 -5
- package/package.json +1 -1
- package/src/agents/agent-adapter.ts +7 -3
- package/src/agents/claude-adapter.ts +6 -2
- package/src/agents/gateway-adapter.ts +29 -7
- package/src/build-migrated-config.ts +29 -5
- package/src/cli.ts +7 -1
- package/src/daemon/build-auth-binding.ts +9 -4
- package/src/daemon/daemon-connection.ts +0 -3
- package/src/daemon/daemon.ts +63 -51
- package/src/daemon/restore-fleet.ts +21 -68
- package/src/daemon/sessions.ts +7 -16
- package/src/mcp/mcp-tools.ts +0 -4
- package/src/mcp/require-daemon-features.ts +0 -1
- package/src/mcp/run-tool.ts +0 -7
- package/src/protocol/daemon-features.ts +0 -3
- package/src/protocol/request-param-schemas.ts +0 -6
- package/src/shared/check-gateway-auth.ts +11 -0
- package/src/shared/collect-agents.ts +32 -0
- package/src/shared/collect-auth-profiles.ts +64 -1
- package/src/shared/collect-profile-env-problems.ts +16 -0
- package/src/shared/config.ts +13 -7
- package/src/shared/removed-config-keys.ts +5 -0
- package/src/shared/resolve-auth-profiles.ts +27 -2
- package/src/store/fleet-entry.ts +0 -5
- package/src/store/run-migrations.ts +0 -4
- package/src/store/state-store.ts +0 -34
package/README.md
CHANGED
|
@@ -91,11 +91,12 @@ zoxide is installed. A typed path such as `~/pro` completes like a shell. Inside
|
|
|
91
91
|
your statusline gains a fleet segment, so `● 2 need you: auth-bug` is visible without opening the
|
|
92
92
|
list.
|
|
93
93
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
daemon,
|
|
94
|
+
When the daemon restarts, it restores the fleet by itself and every session respawns from its
|
|
95
|
+
transcript; set `restoreFleetOnRestart` to `false` to wait for `R` on the home screen instead. After
|
|
96
|
+
you upgrade atc, the status bar shows `⟳ update ready`, and `u` restarts the daemon and restores the
|
|
97
|
+
fleet when you are ready. The bar shows `⟳ restarting daemon` until the restart finishes. When the
|
|
98
|
+
running daemon speaks another protocol version, `atc` asks before it restarts the daemon, since the
|
|
99
|
+
restart ends every session the daemon hosts.
|
|
99
100
|
|
|
100
101
|
atc runs inside zellij or tmux. Give the pane locked mode so the leader key reaches atc.
|
|
101
102
|
|
package/package.json
CHANGED
|
@@ -31,7 +31,6 @@ export interface SpawnOverrides {
|
|
|
31
31
|
readonly trustClonedWorkspace?: boolean;
|
|
32
32
|
readonly model?: string;
|
|
33
33
|
readonly effort?: string;
|
|
34
|
-
readonly resumeInterruptedTurns?: boolean;
|
|
35
34
|
}
|
|
36
35
|
|
|
37
36
|
export interface SpawnPlan {
|
|
@@ -45,12 +44,17 @@ export interface SpawnPlan {
|
|
|
45
44
|
* files unpack into. `auth` is given when the harness takes its credential
|
|
46
45
|
* from impd's broker: the revision of the host's runtime auth binding it
|
|
47
46
|
* launches under, which keys any settings the agent writes for it, and the
|
|
48
|
-
* placeholder variables the harness holds in place of a credential
|
|
47
|
+
* placeholder variables the harness holds in place of a credential, and
|
|
48
|
+
* the variables its auth profiles set.
|
|
49
49
|
*/
|
|
50
50
|
export interface GuestPaths {
|
|
51
51
|
readonly atc: string | null;
|
|
52
52
|
readonly dir: string;
|
|
53
|
-
readonly auth?: {
|
|
53
|
+
readonly auth?: {
|
|
54
|
+
readonly revision: number;
|
|
55
|
+
readonly env: Readonly<Record<string, string>>;
|
|
56
|
+
readonly profileEnv: Readonly<Record<string, string>>;
|
|
57
|
+
};
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
/**
|
|
@@ -290,7 +290,11 @@ export class ClaudeAdapter implements AgentAdapter {
|
|
|
290
290
|
const padding = typeof userSettings === 'string' ? findStatuslinePadding(userSettings) : 0;
|
|
291
291
|
|
|
292
292
|
const settings = buildHookSettings(
|
|
293
|
-
this.buildSettingsProfile({
|
|
293
|
+
this.buildSettingsProfile({
|
|
294
|
+
...auth.profileEnv,
|
|
295
|
+
...auth.env,
|
|
296
|
+
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
|
297
|
+
}),
|
|
294
298
|
padding,
|
|
295
299
|
argv,
|
|
296
300
|
);
|
|
@@ -312,7 +316,7 @@ export class ClaudeAdapter implements AgentAdapter {
|
|
|
312
316
|
),
|
|
313
317
|
...Object.fromEntries(bridge),
|
|
314
318
|
},
|
|
315
|
-
env: { ...launch.env, ...auth.env },
|
|
319
|
+
env: { ...launch.env, ...auth.profileEnv, ...auth.env },
|
|
316
320
|
};
|
|
317
321
|
}
|
|
318
322
|
|
|
@@ -224,6 +224,7 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
224
224
|
env: {
|
|
225
225
|
...this.gateway.env,
|
|
226
226
|
ANTHROPIC_BASE_URL: this.gateway.baseURL,
|
|
227
|
+
...guest.auth.profileEnv,
|
|
227
228
|
...guest.auth.env,
|
|
228
229
|
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
|
229
230
|
},
|
|
@@ -253,7 +254,7 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
253
254
|
...buildClaudeConfigSeed(null),
|
|
254
255
|
...Object.fromEntries(bridge),
|
|
255
256
|
},
|
|
256
|
-
env: { ...launch.env, ...guest.auth.env },
|
|
257
|
+
env: { ...launch.env, ...guest.auth.profileEnv, ...guest.auth.env },
|
|
257
258
|
};
|
|
258
259
|
}
|
|
259
260
|
|
|
@@ -352,19 +353,31 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
352
353
|
}
|
|
353
354
|
|
|
354
355
|
// The Claude CLI sends ANTHROPIC_AUTH_TOKEN as a bearer authorization
|
|
355
|
-
// header, the one pairing atc binds, so the placeholders must
|
|
356
|
-
// variable
|
|
357
|
-
//
|
|
358
|
-
//
|
|
356
|
+
// header, the one pairing atc binds, so the placeholders must include that
|
|
357
|
+
// variable, holding the placeholder, for a profile whose rule on the base
|
|
358
|
+
// URL's host sets that header. Any other variable passes through to the
|
|
359
|
+
// session for a tool in it, whose own host a selected profile covers,
|
|
360
|
+
// except a variable the CLI reads as its own credential, which would
|
|
361
|
+
// compete with the bearer variable and put the placeholder in a header
|
|
362
|
+
// the broker never fills, and the variable that names the session's
|
|
363
|
+
// Claude config folder, which atc sets itself.
|
|
359
364
|
private findPlaceholderRefusal(
|
|
360
365
|
env: Readonly<Record<string, string>>,
|
|
361
366
|
profiles: readonly string[],
|
|
362
367
|
): DaemonError | null {
|
|
363
368
|
const keys = Object.keys(env);
|
|
364
369
|
|
|
365
|
-
if (keys.
|
|
370
|
+
if (!keys.includes(BEARER_VARIABLE)) {
|
|
366
371
|
return this.buildPlaceholderRefusal(
|
|
367
|
-
`needs ${BEARER_VARIABLE}
|
|
372
|
+
`needs ${BEARER_VARIABLE} among its placeholder variables, which the broker fills as a bearer authorization header; it has ${keys.length === 0 ? 'none' : keys.join(', ')}`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const competing = keys.find((key) => COMPETING_CREDENTIAL_VARIABLES.has(key));
|
|
377
|
+
|
|
378
|
+
if (competing !== undefined) {
|
|
379
|
+
return this.buildPlaceholderRefusal(
|
|
380
|
+
`cannot use ${competing} as a placeholder variable, since the Claude CLI reads it as its own credential or config folder beside ${BEARER_VARIABLE}`,
|
|
368
381
|
);
|
|
369
382
|
}
|
|
370
383
|
|
|
@@ -444,6 +457,15 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
444
457
|
const BEARER_VARIABLE = 'ANTHROPIC_AUTH_TOKEN';
|
|
445
458
|
const PLACEHOLDER = 'imp-broker-placeholder';
|
|
446
459
|
|
|
460
|
+
// Variables the Claude CLI reads as its own credential beside the bearer
|
|
461
|
+
// variable, plus the one that points it at the session's config folder,
|
|
462
|
+
// whose launch value a placeholder would overwrite.
|
|
463
|
+
const COMPETING_CREDENTIAL_VARIABLES: ReadonlySet<string> = new Set([
|
|
464
|
+
'ANTHROPIC_API_KEY',
|
|
465
|
+
'CLAUDE_CODE_OAUTH_TOKEN',
|
|
466
|
+
'CLAUDE_CONFIG_DIR',
|
|
467
|
+
]);
|
|
468
|
+
|
|
447
469
|
// The Claude CLI's mode that asks a person before each action.
|
|
448
470
|
const MANUAL_PERMISSION_MODE = 'default';
|
|
449
471
|
|
|
@@ -3,6 +3,7 @@ import { parseConfig } from './shared/config';
|
|
|
3
3
|
import { formatJSONKind } from './shared/format-json-kind';
|
|
4
4
|
import { formatMixedAgentKeys } from './shared/format-mixed-agent-keys';
|
|
5
5
|
import { LEGACY_AGENT_KEYS } from './shared/legacy-agent-keys';
|
|
6
|
+
import { REMOVED_CONFIG_KEYS } from './shared/removed-config-keys';
|
|
6
7
|
import { isRecord } from './shared/report';
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -17,7 +18,8 @@ type MigratedConfig =
|
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Rewrites a config.json that uses the old agent keys so it holds the same
|
|
20
|
-
* agents under `agents
|
|
21
|
+
* agents under `agents`, and drops every key atc no longer reads. A file
|
|
22
|
+
* that holds `agents` and a removed key is rewritten without the key. The old keys are removed and `agents` takes the
|
|
21
23
|
* place of the first of them, or the end of the file when it sets none. Each
|
|
22
24
|
* entry holds only the fields that differ from a default. Values are copied
|
|
23
25
|
* and never reported, so a note names a key and a reason only.
|
|
@@ -31,11 +33,26 @@ export function buildMigratedConfig(raw: unknown): MigratedConfig {
|
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
const present = Object.keys(raw).filter((key) => LEGACY_AGENT_KEYS.includes(key));
|
|
36
|
+
const removed = REMOVED_CONFIG_KEYS.filter((key) => Object.hasOwn(raw, key));
|
|
37
|
+
|
|
38
|
+
const removedNotes = removed.map(
|
|
39
|
+
(key) => `atc config migrate: ${key} is dropped: atc no longer reads it`,
|
|
40
|
+
);
|
|
34
41
|
|
|
35
42
|
if (Object.hasOwn(raw, 'agents')) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
43
|
+
if (present.length > 0) {
|
|
44
|
+
return { kind: 'unusable', detail: formatMixedAgentKeys(present) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (removed.length === 0) {
|
|
48
|
+
return { kind: 'current' };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
kind: 'migrated',
|
|
53
|
+
text: `${JSON.stringify(Object.fromEntries(Object.entries(raw).filter(([key]) => !removed.includes(key))), null, 2)}\n`,
|
|
54
|
+
notes: removedNotes,
|
|
55
|
+
};
|
|
39
56
|
}
|
|
40
57
|
|
|
41
58
|
const config = parseConfig(raw);
|
|
@@ -49,6 +66,10 @@ export function buildMigratedConfig(raw: unknown): MigratedConfig {
|
|
|
49
66
|
let placed = false;
|
|
50
67
|
|
|
51
68
|
for (const [key, value] of Object.entries(raw)) {
|
|
69
|
+
if (removed.includes(key)) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
52
73
|
if (!LEGACY_AGENT_KEYS.includes(key)) {
|
|
53
74
|
migrated[key] = value;
|
|
54
75
|
} else if (!placed) {
|
|
@@ -64,7 +85,10 @@ export function buildMigratedConfig(raw: unknown): MigratedConfig {
|
|
|
64
85
|
return {
|
|
65
86
|
kind: 'migrated',
|
|
66
87
|
text: `${JSON.stringify(migrated, null, 2)}\n`,
|
|
67
|
-
notes:
|
|
88
|
+
notes: [
|
|
89
|
+
...collectDroppedGateways(raw['gateways'], config.agents, config.agentErrors),
|
|
90
|
+
...removedNotes,
|
|
91
|
+
],
|
|
68
92
|
};
|
|
69
93
|
}
|
|
70
94
|
|
package/src/cli.ts
CHANGED
|
@@ -435,6 +435,12 @@ async function runDaemon(listenArg: string | null, tokenFile: string | null): Pr
|
|
|
435
435
|
);
|
|
436
436
|
}
|
|
437
437
|
|
|
438
|
+
if (cfg.removedKeys.length > 0) {
|
|
439
|
+
console.error(
|
|
440
|
+
`atc daemon: config: config.json sets ${cfg.removedKeys.join(', ')}, which atc no longer reads; run 'atc config migrate' to drop it`,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
438
444
|
const sources = sourceOrder.buildSources(
|
|
439
445
|
builtinSources.collectBuiltinSources({
|
|
440
446
|
roots: cfg.dirs.roots,
|
|
@@ -489,7 +495,7 @@ async function runDaemon(listenArg: string | null, tokenFile: string | null): Pr
|
|
|
489
495
|
? {}
|
|
490
496
|
: { listen: { host: listen.host, port: listen.port, tokenFile } }),
|
|
491
497
|
restoreBootTimeoutMs,
|
|
492
|
-
|
|
498
|
+
restoreFleetOnRestart: cfg.restoreFleetOnRestart,
|
|
493
499
|
...(Number.isFinite(graceOverride) && graceOverride >= 0
|
|
494
500
|
? { tapGraceMs: graceOverride }
|
|
495
501
|
: {}),
|
|
@@ -11,8 +11,9 @@ import { sortJSONKeys } from '../shared/sort-json-keys';
|
|
|
11
11
|
* What a session's runtime is bound to through impd's broker: every
|
|
12
12
|
* profile its gateway's selection reaches, the secrets they need impd to
|
|
13
13
|
* hold with exactly these rules, the placeholder variables in place of a
|
|
14
|
-
* credential, and the endpoint. `hash`
|
|
15
|
-
*
|
|
14
|
+
* credential, the variables the profiles set, and the endpoint. `hash`
|
|
15
|
+
* covers the secrets and their rules, and the profile variables when there
|
|
16
|
+
* are any, so a change to a profile the binding does not reach leaves it as
|
|
16
17
|
* it was, and a change to one it reaches gives another hash.
|
|
17
18
|
*/
|
|
18
19
|
export interface AuthBinding {
|
|
@@ -21,6 +22,7 @@ export interface AuthBinding {
|
|
|
21
22
|
readonly profiles: readonly string[];
|
|
22
23
|
readonly secrets: readonly ResolvedAuthSecret[];
|
|
23
24
|
readonly placeholderEnv: Readonly<Record<string, string>>;
|
|
25
|
+
readonly profileEnv: Readonly<Record<string, string>>;
|
|
24
26
|
readonly hash: string;
|
|
25
27
|
}
|
|
26
28
|
|
|
@@ -29,7 +31,7 @@ type AuthGateway = Pick<GatewayConfig, 'id' | 'baseURL'> & { readonly auth: Gate
|
|
|
29
31
|
/**
|
|
30
32
|
* Plans the binding a gateway's auth asks for against the current auth
|
|
31
33
|
* profiles, or the problem that refuses it. The hash is the SHA-256 of the
|
|
32
|
-
* resolved secrets as canonical JSON, with each list sorted, so the order
|
|
34
|
+
* resolved secrets (and profile variables, when set) as canonical JSON, with each list sorted, so the order
|
|
33
35
|
* of the selection or of the config never changes it.
|
|
34
36
|
*/
|
|
35
37
|
export function buildAuthBinding(
|
|
@@ -43,6 +45,8 @@ export function buildAuthBinding(
|
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
const secrets = resolution.resolved.secrets;
|
|
48
|
+
const profileEnv = resolution.resolved.env;
|
|
49
|
+
const hashed = Object.keys(profileEnv).length === 0 ? { secrets } : { secrets, profileEnv };
|
|
46
50
|
|
|
47
51
|
return {
|
|
48
52
|
binding: {
|
|
@@ -51,8 +55,9 @@ export function buildAuthBinding(
|
|
|
51
55
|
profiles: resolution.resolved.profiles,
|
|
52
56
|
secrets,
|
|
53
57
|
placeholderEnv: gateway.auth.placeholderEnv,
|
|
58
|
+
profileEnv,
|
|
54
59
|
hash: createHash('sha256')
|
|
55
|
-
.update(JSON.stringify(sortJSONKeys(
|
|
60
|
+
.update(JSON.stringify(sortJSONKeys(hashed)))
|
|
56
61
|
.digest('hex'),
|
|
57
62
|
},
|
|
58
63
|
};
|
|
@@ -1034,9 +1034,6 @@ export class DaemonConnection {
|
|
|
1034
1034
|
...(data.trustClonedWorkspace === undefined
|
|
1035
1035
|
? {}
|
|
1036
1036
|
: { trustClonedWorkspace: data.trustClonedWorkspace }),
|
|
1037
|
-
...(data.resumeInterruptedTurns === undefined
|
|
1038
|
-
? {}
|
|
1039
|
-
: { resumeInterruptedTurns: data.resumeInterruptedTurns }),
|
|
1040
1037
|
},
|
|
1041
1038
|
target,
|
|
1042
1039
|
workspace: data.workspace ?? null,
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -162,10 +162,9 @@ export interface DaemonOptions {
|
|
|
162
162
|
readonly restoreBootTimeoutMs?: number;
|
|
163
163
|
readonly tapGraceMs?: number;
|
|
164
164
|
|
|
165
|
-
// Whether
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
readonly resumeInterruptedTurns?: boolean;
|
|
165
|
+
// Whether the daemon restores the stored fleet by itself once it is
|
|
166
|
+
// listening, when that fleet holds sessions; off when unset.
|
|
167
|
+
readonly restoreFleetOnRestart?: boolean;
|
|
169
168
|
|
|
170
169
|
// How long a confirm token from `session.forget` stays usable.
|
|
171
170
|
readonly forgetConfirmMs?: number;
|
|
@@ -291,11 +290,6 @@ const LOCK_WAIT_MS = 2000;
|
|
|
291
290
|
* because transports guarantee byte integrity and such a line means a buggy
|
|
292
291
|
* or hostile peer.
|
|
293
292
|
*/
|
|
294
|
-
// The sender and opening of the message a restore sends a session whose
|
|
295
|
-
// turn the previous daemon's stop cut off.
|
|
296
|
-
const RESUME_SENDER = 'atc';
|
|
297
|
-
const RESUME_LEAD = 'atc restarted the daemon';
|
|
298
|
-
|
|
299
293
|
export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
300
294
|
let stopDaemon: (() => Promise<void>) | null = null;
|
|
301
295
|
|
|
@@ -1327,37 +1321,6 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1327
1321
|
return record;
|
|
1328
1322
|
};
|
|
1329
1323
|
|
|
1330
|
-
const startedAt = new Date().toISOString();
|
|
1331
|
-
|
|
1332
|
-
// Sends a restored session the message that carries on a turn the
|
|
1333
|
-
// previous daemon's stop cut off. A resume message still pending from an
|
|
1334
|
-
// earlier restart covers this one, so a session never holds two.
|
|
1335
|
-
const sendResumeMessage = async (s: Session): Promise<void> => {
|
|
1336
|
-
const pending = await store.collectPendingMessages(buildMessageOwner(s));
|
|
1337
|
-
|
|
1338
|
-
if (
|
|
1339
|
-
pending.some((record) => record.from === RESUME_SENDER && record.text.startsWith(RESUME_LEAD))
|
|
1340
|
-
) {
|
|
1341
|
-
return;
|
|
1342
|
-
}
|
|
1343
|
-
|
|
1344
|
-
const refusal = findMessageRefusal(s.id);
|
|
1345
|
-
|
|
1346
|
-
if (refusal !== null) {
|
|
1347
|
-
mgr.log(`atc sent session ${s.id} no resume message (${refusal})`);
|
|
1348
|
-
|
|
1349
|
-
return;
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
await writeAcceptedMessage(
|
|
1353
|
-
s.id,
|
|
1354
|
-
RESUME_SENDER,
|
|
1355
|
-
`${RESUME_LEAD} at ${startedAt}; your last turn was interrupted. Check the state of anything you had in flight, then continue.`,
|
|
1356
|
-
mintMessageID(),
|
|
1357
|
-
() => {},
|
|
1358
|
-
);
|
|
1359
|
-
};
|
|
1360
|
-
|
|
1361
1324
|
// A retried send answers with the message's current status, so a caller
|
|
1362
1325
|
// that lost the first answer learns where its message stands now.
|
|
1363
1326
|
const loadMessageReplay = async (
|
|
@@ -1396,6 +1359,44 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1396
1359
|
|
|
1397
1360
|
const sources = opts.sources ?? [];
|
|
1398
1361
|
|
|
1362
|
+
// The restore in flight, from its first call until its staggered terminal
|
|
1363
|
+
// adoption finishes. A restore requested meanwhile joins it, so the fleet
|
|
1364
|
+
// never registers twice at once.
|
|
1365
|
+
let fleetRestore: Promise<number> | null = null;
|
|
1366
|
+
|
|
1367
|
+
const runFleetRestore = (cols: number, rows: number): Promise<number> => {
|
|
1368
|
+
if (fleetRestore !== null) {
|
|
1369
|
+
return fleetRestore;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
const restored = Promise.withResolvers<number>();
|
|
1373
|
+
|
|
1374
|
+
fleetRestore = restored.promise;
|
|
1375
|
+
|
|
1376
|
+
void (async () => {
|
|
1377
|
+
try {
|
|
1378
|
+
const result = await restoreFleet({
|
|
1379
|
+
mgr,
|
|
1380
|
+
store,
|
|
1381
|
+
findRuntime,
|
|
1382
|
+
cols,
|
|
1383
|
+
rows,
|
|
1384
|
+
capMs: opts.restoreBootTimeoutMs ?? 0,
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
restored.resolve(result.restored);
|
|
1388
|
+
|
|
1389
|
+
await result.settled;
|
|
1390
|
+
} catch (error) {
|
|
1391
|
+
restored.reject(error);
|
|
1392
|
+
} finally {
|
|
1393
|
+
fleetRestore = null;
|
|
1394
|
+
}
|
|
1395
|
+
})();
|
|
1396
|
+
|
|
1397
|
+
return restored.promise;
|
|
1398
|
+
};
|
|
1399
|
+
|
|
1399
1400
|
const ctx: DaemonContext = {
|
|
1400
1401
|
build: opts.build,
|
|
1401
1402
|
daemonID: store.daemonID,
|
|
@@ -1825,17 +1826,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1825
1826
|
getEffectiveDims: (sessionID) =>
|
|
1826
1827
|
attachments.findEffectiveDims(sessionID) ??
|
|
1827
1828
|
runtimes.get(sessionID)?.dims ?? { cols: 80, rows: 24 },
|
|
1828
|
-
restoreFleet:
|
|
1829
|
-
restoreFleet({
|
|
1830
|
-
mgr,
|
|
1831
|
-
store,
|
|
1832
|
-
findRuntime,
|
|
1833
|
-
cols,
|
|
1834
|
-
rows,
|
|
1835
|
-
capMs: opts.restoreBootTimeoutMs ?? 0,
|
|
1836
|
-
resumeInterruptedTurns: opts.resumeInterruptedTurns ?? false,
|
|
1837
|
-
sendResumeMessage,
|
|
1838
|
-
}),
|
|
1829
|
+
restoreFleet: runFleetRestore,
|
|
1839
1830
|
readSessionRecord: async (id, access) => {
|
|
1840
1831
|
const s = mgr.sessions.find((x) => x.id === id);
|
|
1841
1832
|
|
|
@@ -2275,6 +2266,27 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
2275
2266
|
listenPort: tcpListener?.port ?? null,
|
|
2276
2267
|
});
|
|
2277
2268
|
|
|
2269
|
+
// The stored fleet restores once per start, at the size a restored
|
|
2270
|
+
// terminal takes until its first attach resizes it. A failure is logged
|
|
2271
|
+
// and never stops the daemon.
|
|
2272
|
+
const tryRunFleetRestore = async (): Promise<void> => {
|
|
2273
|
+
try {
|
|
2274
|
+
await runFleetRestore(80, 24);
|
|
2275
|
+
} catch (error) {
|
|
2276
|
+
mgr.log(
|
|
2277
|
+
`atc could not restore the fleet (${error instanceof Error ? error.message : String(error)})`,
|
|
2278
|
+
);
|
|
2279
|
+
}
|
|
2280
|
+
};
|
|
2281
|
+
|
|
2282
|
+
if (opts.restoreFleetOnRestart === true) {
|
|
2283
|
+
const stored = await store.loadFleet();
|
|
2284
|
+
|
|
2285
|
+
if (stored.length > 0) {
|
|
2286
|
+
void tryRunFleetRestore();
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2278
2290
|
return {
|
|
2279
2291
|
stop: stopDaemon,
|
|
2280
2292
|
countClients: () => clients.size,
|
|
@@ -16,12 +16,15 @@ export interface RestoreFleetParams {
|
|
|
16
16
|
// it has booted before moving on regardless; zero waits on the signal
|
|
17
17
|
// alone.
|
|
18
18
|
readonly capMs: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RestoreFleetResult {
|
|
22
|
+
// How many sessions the restore registered.
|
|
23
|
+
readonly restored: number;
|
|
19
24
|
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
readonly resumeInterruptedTurns: boolean;
|
|
24
|
-
readonly sendResumeMessage: (s: Session) => Promise<void>;
|
|
25
|
+
// Resolves once the staggered terminal adoption behind the restore has
|
|
26
|
+
// finished, also when it failed, and at once when nothing was queued.
|
|
27
|
+
readonly settled: Promise<void>;
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
/**
|
|
@@ -29,18 +32,15 @@ export interface RestoreFleetParams {
|
|
|
29
32
|
* the rest by recency, registers every one as a terminal-less session so
|
|
30
33
|
* the whole fleet lists at once, then adopts terminals one at a time,
|
|
31
34
|
* waiting for each session to report it has booted before starting the
|
|
32
|
-
* next.
|
|
33
|
-
* that resumes interrupted turns, gets one message to carry on once its
|
|
34
|
-
* terminal is adopted.
|
|
35
|
+
* next.
|
|
35
36
|
*/
|
|
36
|
-
export async function restoreFleet(params: RestoreFleetParams): Promise<
|
|
37
|
+
export async function restoreFleet(params: RestoreFleetParams): Promise<RestoreFleetResult> {
|
|
37
38
|
const mgr = params.mgr;
|
|
38
39
|
const store = params.store;
|
|
39
40
|
const findRuntime = params.findRuntime;
|
|
40
41
|
const cols = params.cols;
|
|
41
42
|
const rows = params.rows;
|
|
42
43
|
const capMs = params.capMs;
|
|
43
|
-
const sendResumeMessage = params.sendResumeMessage;
|
|
44
44
|
|
|
45
45
|
const hasLiveSession = (entry: FleetEntry) =>
|
|
46
46
|
mgr.sessions.some(
|
|
@@ -92,33 +92,11 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<number>
|
|
|
92
92
|
.filter((r) => r.revive || r.session.state !== 'exited')
|
|
93
93
|
.map((r) => r.session);
|
|
94
94
|
|
|
95
|
-
// Only a session this restore registers comes from the previous daemon's
|
|
96
|
-
// fleet; a listed one this daemon already ran is revived by hand. The
|
|
97
|
-
// trail is read before any terminal is adopted, since a revived agent's
|
|
98
|
-
// own events move it on.
|
|
99
|
-
const interrupted = await collectInterruptedTurns(
|
|
100
|
-
mgr,
|
|
101
|
-
store,
|
|
102
|
-
registered.filter((r) => !r.revive && r.session.state !== 'exited').map((r) => r.session),
|
|
103
|
-
params.resumeInterruptedTurns,
|
|
104
|
-
);
|
|
105
|
-
|
|
106
95
|
// A session whose target refuses to start a terminal stays listed without
|
|
107
|
-
// one, and the restore moves on to the next.
|
|
108
|
-
// to send is logged, and the session runs on without it.
|
|
96
|
+
// one, and the restore moves on to the next.
|
|
109
97
|
const adoptQueued = async (s: Session): Promise<boolean> => {
|
|
110
98
|
const adopted = await tryAdoptTerminal(mgr, s.id, cols, rows);
|
|
111
99
|
|
|
112
|
-
if (adopted !== null && interrupted.has(s.id)) {
|
|
113
|
-
try {
|
|
114
|
-
await sendResumeMessage(s);
|
|
115
|
-
} catch (error) {
|
|
116
|
-
mgr.log(
|
|
117
|
-
`atc could not send session ${s.id} its resume message (${error instanceof Error ? error.message : String(error)})`,
|
|
118
|
-
);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
100
|
return adopted !== null;
|
|
123
101
|
};
|
|
124
102
|
|
|
@@ -159,7 +137,7 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<number>
|
|
|
159
137
|
const [first, ...rest] = queued;
|
|
160
138
|
|
|
161
139
|
if (first === undefined) {
|
|
162
|
-
return registered.length;
|
|
140
|
+
return { restored: registered.length, settled: Promise.resolve() };
|
|
163
141
|
}
|
|
164
142
|
|
|
165
143
|
// The first terminal attaches before the restore answers so a caller can
|
|
@@ -200,42 +178,17 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<number>
|
|
|
200
178
|
|
|
201
179
|
const firstBooted = firstAdopted ? first : null;
|
|
202
180
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
// beats the config, and an agent that takes no atc messages has no path for
|
|
211
|
-
// the resume message. Only a harness on the daemon's own machine ends with
|
|
212
|
-
// the daemon; one on a host with a lifecycle of its own runs on, so its
|
|
213
|
-
// turn was never cut off and a revive attaches to it as it stands.
|
|
214
|
-
async function collectInterruptedTurns(
|
|
215
|
-
mgr: SessionManager,
|
|
216
|
-
store: StateStore,
|
|
217
|
-
sessions: readonly Session[],
|
|
218
|
-
configured: boolean,
|
|
219
|
-
): Promise<ReadonlySet<SessionID>> {
|
|
220
|
-
const interrupted = new Set<SessionID>();
|
|
221
|
-
|
|
222
|
-
for (const s of sessions) {
|
|
223
|
-
if (
|
|
224
|
-
!(s.resumeInterruptedTurns ?? configured) ||
|
|
225
|
-
s.attachment !== 'local' ||
|
|
226
|
-
mgr.findAdapter(s.agent)?.takesMessages !== true
|
|
227
|
-
) {
|
|
228
|
-
continue;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const kind = await store.findLatestTurnKind(s.id, s.agentSessionID);
|
|
232
|
-
|
|
233
|
-
if (kind === 'prompt-submitted') {
|
|
234
|
-
interrupted.add(s.id);
|
|
181
|
+
const adoptRestLogged = async (): Promise<void> => {
|
|
182
|
+
try {
|
|
183
|
+
await adoptRest(firstBooted);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
mgr.log(
|
|
186
|
+
`atc could not finish restoring the fleet (${error instanceof Error ? error.message : String(error)})`,
|
|
187
|
+
);
|
|
235
188
|
}
|
|
236
|
-
}
|
|
189
|
+
};
|
|
237
190
|
|
|
238
|
-
return
|
|
191
|
+
return { restored: registered.length, settled: adoptRestLogged() };
|
|
239
192
|
}
|
|
240
193
|
|
|
241
194
|
// A stored entry matches a listed session by its atc session id, or by its
|
package/src/daemon/sessions.ts
CHANGED
|
@@ -185,10 +185,6 @@ export interface Session {
|
|
|
185
185
|
// The epoch of the session's latest harness start or attach, which the
|
|
186
186
|
// daemon's bridge to that harness is bound to; 0 before the first.
|
|
187
187
|
bridgeEpoch: number;
|
|
188
|
-
|
|
189
|
-
// Whether a fleet restore sends the session one message to carry on a
|
|
190
|
-
// turn a daemon restart cut off; absent follows the daemon's config.
|
|
191
|
-
resumeInterruptedTurns?: boolean;
|
|
192
188
|
}
|
|
193
189
|
|
|
194
190
|
// A session's ready workspace and the variables its harnesses go without.
|
|
@@ -606,9 +602,6 @@ export class SessionManager {
|
|
|
606
602
|
attachment: this.hasHostLifecycle(target) ? 'detached' : 'local',
|
|
607
603
|
hostKey: entry.hostKey ?? entry.sessionID,
|
|
608
604
|
bridgeEpoch: 0,
|
|
609
|
-
...(entry.resumeInterruptedTurns === undefined
|
|
610
|
-
? {}
|
|
611
|
-
: { resumeInterruptedTurns: entry.resumeInterruptedTurns }),
|
|
612
605
|
};
|
|
613
606
|
|
|
614
607
|
this.sessions.push(session);
|
|
@@ -1179,9 +1172,6 @@ export class SessionManager {
|
|
|
1179
1172
|
suspended: false,
|
|
1180
1173
|
hostKey,
|
|
1181
1174
|
bridgeEpoch: binding.epoch,
|
|
1182
|
-
...(overrides.resumeInterruptedTurns === undefined
|
|
1183
|
-
? {}
|
|
1184
|
-
: { resumeInterruptedTurns: overrides.resumeInterruptedTurns }),
|
|
1185
1175
|
};
|
|
1186
1176
|
|
|
1187
1177
|
this.attachHarness(session, pty, this.hasHostLifecycle(target));
|
|
@@ -2024,7 +2014,7 @@ export class SessionManager {
|
|
|
2024
2014
|
: {
|
|
2025
2015
|
atc: guest.atc,
|
|
2026
2016
|
dir,
|
|
2027
|
-
auth: await this.planGuestAuth(hostKey, auth.mode, auth.binding
|
|
2017
|
+
auth: await this.planGuestAuth(hostKey, auth.mode, auth.binding),
|
|
2028
2018
|
};
|
|
2029
2019
|
|
|
2030
2020
|
const plan =
|
|
@@ -2110,11 +2100,15 @@ export class SessionManager {
|
|
|
2110
2100
|
private async planGuestAuth(
|
|
2111
2101
|
hostKey: SessionID,
|
|
2112
2102
|
mode: HarnessAuthSetup['mode'],
|
|
2113
|
-
|
|
2103
|
+
binding: Pick<AuthBinding, 'placeholderEnv' | 'profileEnv'>,
|
|
2114
2104
|
): Promise<NonNullable<GuestPaths['auth']>> {
|
|
2115
2105
|
const held = mode === 'create' ? null : await this.requireAuthBinder().findBinding(hostKey);
|
|
2116
2106
|
|
|
2117
|
-
return {
|
|
2107
|
+
return {
|
|
2108
|
+
revision: held?.revision ?? 1,
|
|
2109
|
+
env: binding.placeholderEnv,
|
|
2110
|
+
profileEnv: binding.profileEnv,
|
|
2111
|
+
};
|
|
2118
2112
|
}
|
|
2119
2113
|
|
|
2120
2114
|
// Creates the binding of a host a spawn provisions, or verifies the one
|
|
@@ -3050,9 +3044,6 @@ export class SessionManager {
|
|
|
3050
3044
|
targetIdentity: s.targetIdentity,
|
|
3051
3045
|
...(s.desired === 'run' ? {} : { desired: s.desired }),
|
|
3052
3046
|
...(s.hostKey === s.id ? {} : { hostKey: s.hostKey }),
|
|
3053
|
-
...(s.resumeInterruptedTurns === undefined
|
|
3054
|
-
? {}
|
|
3055
|
-
: { resumeInterruptedTurns: s.resumeInterruptedTurns }),
|
|
3056
3047
|
});
|
|
3057
3048
|
}
|
|
3058
3049
|
|
package/src/mcp/mcp-tools.ts
CHANGED
|
@@ -65,9 +65,6 @@ const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
|
65
65
|
trustClonedWorkspace: SPAWN_SCHEMA.shape.trustClonedWorkspace.describe(
|
|
66
66
|
"Trust the exact verified clone for this launch. An explicit true or false overrides the configured target trustClonedWorkspace default; omitting both keeps trust off. Requires a workspace source and either stock Claude on the local target, which adds trust for the clone root alone to the user's Claude config, or, on an imp target, a brokered Claude gateway or stock Claude signed in through the broker, each with isolated guest config; other launches are refused. Accepts repository configuration and helpers without changing tool permission mode. Existing guest config is preserved.",
|
|
67
67
|
),
|
|
68
|
-
resumeInterruptedTurns: SPAWN_SCHEMA.shape.resumeInterruptedTurns.describe(
|
|
69
|
-
"Whether atc sends the session one message to carry on when a daemon restart cuts off its turn. The message goes out when the fleet is restored, only to a session whose last recorded event was a submitted prompt, and only to an agent that takes atc messages. Omit it to follow the daemon's resumeInterruptedTurns config, which is off by default.",
|
|
70
|
-
),
|
|
71
68
|
detached: z
|
|
72
69
|
.boolean()
|
|
73
70
|
.optional()
|
|
@@ -423,7 +420,6 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
|
|
|
423
420
|
target: 'spawn.target',
|
|
424
421
|
workspace: 'spawn.workspace',
|
|
425
422
|
trustClonedWorkspace: 'spawn.workspace.trust',
|
|
426
|
-
resumeInterruptedTurns: 'spawn.resumeInterruptedTurns',
|
|
427
423
|
},
|
|
428
424
|
},
|
|
429
425
|
},
|
|
@@ -26,7 +26,6 @@ const FEATURE_USES: Readonly<Record<DaemonFeature, string>> = {
|
|
|
26
26
|
'transport.tcp': 'a TCP connection to the daemon',
|
|
27
27
|
'idempotency.replayOnly': 'a resend that only replays a held idempotency key',
|
|
28
28
|
'session.auth': 'session.auth.revoke and session.auth.rebind',
|
|
29
|
-
'spawn.resumeInterruptedTurns': "atc_session_spawn's resumeInterruptedTurns",
|
|
30
29
|
};
|
|
31
30
|
|
|
32
31
|
/**
|
package/src/mcp/run-tool.ts
CHANGED
|
@@ -58,9 +58,6 @@ export function runTool(
|
|
|
58
58
|
...(args['trustClonedWorkspace'] === undefined
|
|
59
59
|
? {}
|
|
60
60
|
: { trustClonedWorkspace: args['trustClonedWorkspace'] }),
|
|
61
|
-
...(args['resumeInterruptedTurns'] === undefined
|
|
62
|
-
? {}
|
|
63
|
-
: { resumeInterruptedTurns: args['resumeInterruptedTurns'] }),
|
|
64
61
|
cols: 100,
|
|
65
62
|
rows: 30,
|
|
66
63
|
};
|
|
@@ -81,9 +78,6 @@ export function runTool(
|
|
|
81
78
|
const trustFeatures: readonly DaemonFeature[] =
|
|
82
79
|
args['trustClonedWorkspace'] === undefined ? [] : ['spawn.workspace.trust'];
|
|
83
80
|
|
|
84
|
-
const resumeFeatures: readonly DaemonFeature[] =
|
|
85
|
-
args['resumeInterruptedTurns'] === undefined ? [] : ['spawn.resumeInterruptedTurns'];
|
|
86
|
-
|
|
87
81
|
// Only a daemon that picks a git workspace's directory takes one
|
|
88
82
|
// without a cwd.
|
|
89
83
|
const autoDirFeatures: readonly DaemonFeature[] =
|
|
@@ -97,7 +91,6 @@ export function runTool(
|
|
|
97
91
|
...targetFeatures,
|
|
98
92
|
...workspaceFeatures,
|
|
99
93
|
...trustFeatures,
|
|
100
|
-
...resumeFeatures,
|
|
101
94
|
...autoDirFeatures,
|
|
102
95
|
];
|
|
103
96
|
|
|
@@ -85,9 +85,6 @@ export const DAEMON_FEATURES = [
|
|
|
85
85
|
// `session.auth.revoke` and `session.auth.rebind` exist, open to the
|
|
86
86
|
// daemon's owner only.
|
|
87
87
|
'session.auth',
|
|
88
|
-
|
|
89
|
-
// `session.spawn` takes `resumeInterruptedTurns`.
|
|
90
|
-
'spawn.resumeInterruptedTurns',
|
|
91
88
|
] as const;
|
|
92
89
|
|
|
93
90
|
export type DaemonFeature = (typeof DAEMON_FEATURES)[number];
|
|
@@ -196,12 +196,6 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
196
196
|
.boolean({ error: 'session.spawn trustClonedWorkspace must be a boolean' })
|
|
197
197
|
.optional(),
|
|
198
198
|
|
|
199
|
-
// Whether a fleet restore after a daemon restart sends the session one
|
|
200
|
-
// message to carry on an interrupted turn; absent follows the config.
|
|
201
|
-
resumeInterruptedTurns: z
|
|
202
|
-
.boolean({ error: 'session.spawn resumeInterruptedTurns must be a boolean' })
|
|
203
|
-
.optional(),
|
|
204
|
-
|
|
205
199
|
// The session the new one is a sub-session of; absent or empty spawns a
|
|
206
200
|
// top-level session.
|
|
207
201
|
parent: z.preprocess(
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AuthProfile } from './collect-auth-profiles';
|
|
2
|
+
import { collectProfileEnvProblems } from './collect-profile-env-problems';
|
|
2
3
|
import { isBrokerVariable } from './is-broker-variable';
|
|
3
4
|
import { isRecord } from './report';
|
|
4
5
|
import { resolveAuthProfiles } from './resolve-auth-profiles';
|
|
@@ -75,6 +76,16 @@ export function checkGatewayAuth(
|
|
|
75
76
|
if ('problem' in resolution) {
|
|
76
77
|
problems.push(resolution.problem.message);
|
|
77
78
|
} else {
|
|
79
|
+
problems.push(
|
|
80
|
+
...collectProfileEnvProblems(
|
|
81
|
+
[
|
|
82
|
+
['env', Object.keys(entry.env)],
|
|
83
|
+
['settings.env', settingsEnv],
|
|
84
|
+
],
|
|
85
|
+
resolution.resolved.envOwners,
|
|
86
|
+
),
|
|
87
|
+
);
|
|
88
|
+
|
|
78
89
|
const hostProblem = findBaseURLProblem(entry.baseURL ?? '', resolution.resolved.hosts);
|
|
79
90
|
|
|
80
91
|
if (hostProblem !== null) {
|
|
@@ -4,8 +4,10 @@ import type { GatewayAuth } from './check-gateway-auth';
|
|
|
4
4
|
import type { AuthProfile } from './collect-auth-profiles';
|
|
5
5
|
import { collectClaudeAuth } from './collect-claude-auth';
|
|
6
6
|
import type { ClaudeMCPServer } from './collect-claude-auth';
|
|
7
|
+
import { collectProfileEnvProblems } from './collect-profile-env-problems';
|
|
7
8
|
import { isSubscriptionOverrideVariable } from './is-subscription-override-variable';
|
|
8
9
|
import { isRecord } from './report';
|
|
10
|
+
import { resolveAuthProfiles } from './resolve-auth-profiles';
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* The agent CLI an entry drives, which decides the adapter behind it.
|
|
@@ -305,6 +307,10 @@ function readClaudeAuth(
|
|
|
305
307
|
|
|
306
308
|
problems.push(...collectSubscriptionProblems(entry));
|
|
307
309
|
|
|
310
|
+
if (collected.auth !== null) {
|
|
311
|
+
problems.push(...collectEntryProfileEnvProblems(entry, collected.auth.profiles, authProfiles));
|
|
312
|
+
}
|
|
313
|
+
|
|
308
314
|
if (collected.auth === null) {
|
|
309
315
|
return { problems: [...collected.errors, ...problems] };
|
|
310
316
|
}
|
|
@@ -320,6 +326,32 @@ function readClaudeAuth(
|
|
|
320
326
|
};
|
|
321
327
|
}
|
|
322
328
|
|
|
329
|
+
// The variables a stock entry sets that its selected profiles also set.
|
|
330
|
+
function collectEntryProfileEnvProblems(
|
|
331
|
+
entry: AgentEntry,
|
|
332
|
+
selected: readonly string[],
|
|
333
|
+
authProfiles: ReadonlyMap<string, AuthProfile>,
|
|
334
|
+
): string[] {
|
|
335
|
+
const resolution = resolveAuthProfiles(authProfiles, selected);
|
|
336
|
+
|
|
337
|
+
if ('problem' in resolution) {
|
|
338
|
+
return [];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const settingsEnv = entry.settings?.['env'];
|
|
342
|
+
|
|
343
|
+
return collectProfileEnvProblems(
|
|
344
|
+
[
|
|
345
|
+
['env', Object.keys(entry.env)],
|
|
346
|
+
[
|
|
347
|
+
'settings.env',
|
|
348
|
+
isRecord(settingsEnv) && !Array.isArray(settingsEnv) ? Object.keys(settingsEnv) : [],
|
|
349
|
+
],
|
|
350
|
+
],
|
|
351
|
+
resolution.resolved.envOwners,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
323
355
|
// What a stock entry with `auth` sets that would override the subscription
|
|
324
356
|
// sign-in or route the CLI around impd's broker.
|
|
325
357
|
function collectSubscriptionProblems(entry: AgentEntry): string[] {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isBrokerVariable } from './is-broker-variable';
|
|
2
|
+
import { isSubscriptionOverrideVariable } from './is-subscription-override-variable';
|
|
1
3
|
import { isRecord } from './report';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -16,6 +18,7 @@ interface CustomAuthProfile {
|
|
|
16
18
|
readonly host: string;
|
|
17
19
|
readonly header: string;
|
|
18
20
|
readonly scheme: 'bearer';
|
|
21
|
+
readonly env: Readonly<Record<string, string>>;
|
|
19
22
|
readonly dependencies: readonly string[];
|
|
20
23
|
}
|
|
21
24
|
|
|
@@ -23,6 +26,7 @@ interface GitHubAuthProfile {
|
|
|
23
26
|
readonly name: string;
|
|
24
27
|
readonly secret: string;
|
|
25
28
|
readonly kind: 'github';
|
|
29
|
+
readonly env: Readonly<Record<string, string>>;
|
|
26
30
|
readonly dependencies: readonly string[];
|
|
27
31
|
}
|
|
28
32
|
|
|
@@ -112,7 +116,11 @@ function parseAuthProfile(name: string, entry: unknown): AuthProfile | string {
|
|
|
112
116
|
return `${extra} cannot be set on a github profile, whose hosts and headers impd's github kind fixes`;
|
|
113
117
|
}
|
|
114
118
|
|
|
115
|
-
|
|
119
|
+
if (entry['env'] !== undefined) {
|
|
120
|
+
return "env cannot be set on a github profile, whose placeholders impd's github kind sets";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { name, secret, kind, env: {}, dependencies: dependencies ?? [] };
|
|
116
124
|
}
|
|
117
125
|
|
|
118
126
|
if (typeof host !== 'string' || host.length > BROKER_HOST_MAX || !BROKER_HOST.test(host)) {
|
|
@@ -131,6 +139,12 @@ function parseAuthProfile(name: string, entry: unknown): AuthProfile | string {
|
|
|
131
139
|
return 'user pairs only with the basic scheme, which atc does not bind';
|
|
132
140
|
}
|
|
133
141
|
|
|
142
|
+
const env = parseProfileEnv(entry['env'], host);
|
|
143
|
+
|
|
144
|
+
if (typeof env === 'string') {
|
|
145
|
+
return env;
|
|
146
|
+
}
|
|
147
|
+
|
|
134
148
|
return {
|
|
135
149
|
name,
|
|
136
150
|
secret,
|
|
@@ -138,6 +152,55 @@ function parseAuthProfile(name: string, entry: unknown): AuthProfile | string {
|
|
|
138
152
|
host,
|
|
139
153
|
header,
|
|
140
154
|
scheme,
|
|
155
|
+
env,
|
|
141
156
|
dependencies: dependencies ?? [],
|
|
142
157
|
};
|
|
143
158
|
}
|
|
159
|
+
|
|
160
|
+
// The value a profile's variable may hold: the placeholder the broker swaps
|
|
161
|
+
// a credential in for, or the profile's own host as an https origin. No
|
|
162
|
+
// other value, so a credential is never written into a profile.
|
|
163
|
+
const ENV_PLACEHOLDER = 'imp-broker-placeholder';
|
|
164
|
+
const ENV_NAME = /^[A-Z_][A-Z0-9_]*$/;
|
|
165
|
+
const RESERVED_ENV_PREFIXES = ['ANTHROPIC_', 'CLAUDE_', 'ATC_'];
|
|
166
|
+
|
|
167
|
+
const RESERVED_ENV_NAMES: ReadonlySet<string> = new Set(['PATH', 'HOME']);
|
|
168
|
+
|
|
169
|
+
function parseProfileEnv(raw: unknown, host: string): Record<string, string> | string {
|
|
170
|
+
if (raw === undefined) {
|
|
171
|
+
return {};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!isRecord(raw) || Array.isArray(raw)) {
|
|
175
|
+
return 'env must be an object of variable names';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const env: Record<string, string> = {};
|
|
179
|
+
|
|
180
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
181
|
+
if (!ENV_NAME.test(key)) {
|
|
182
|
+
return `env.${key} is not a variable name: use capital letters, digits and underscores`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (isReservedEnvName(key)) {
|
|
186
|
+
return `env.${key} cannot be set: atc or impd sets or reserves it`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (value !== ENV_PLACEHOLDER && value !== `https://${host}`) {
|
|
190
|
+
return `env.${key} must be ${ENV_PLACEHOLDER} or https://${host}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
env[key] = value;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return env;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function isReservedEnvName(key: string): boolean {
|
|
200
|
+
return (
|
|
201
|
+
isBrokerVariable(key) ||
|
|
202
|
+
isSubscriptionOverrideVariable(key) ||
|
|
203
|
+
RESERVED_ENV_NAMES.has(key) ||
|
|
204
|
+
RESERVED_ENV_PREFIXES.some((prefix) => key.startsWith(prefix))
|
|
205
|
+
);
|
|
206
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every variable an entry's `env` or `settings.env` sets that one of its
|
|
3
|
+
* selected auth profiles also sets, which would win over or lose to the
|
|
4
|
+
* profile's value by order. `owners` maps each profile variable to the
|
|
5
|
+
* profile that sets it.
|
|
6
|
+
*/
|
|
7
|
+
export function collectProfileEnvProblems(
|
|
8
|
+
sources: readonly (readonly [string, readonly string[]])[],
|
|
9
|
+
owners: Readonly<Record<string, string>>,
|
|
10
|
+
): string[] {
|
|
11
|
+
return sources.flatMap(([source, keys]) =>
|
|
12
|
+
keys
|
|
13
|
+
.filter((key) => owners[key] !== undefined)
|
|
14
|
+
.map((key) => `${source} sets ${key}, which auth profile ${owners[key]} sets`),
|
|
15
|
+
);
|
|
16
|
+
}
|
package/src/shared/config.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { formatJSONKind } from './format-json-kind';
|
|
|
23
23
|
import { formatMixedAgentKeys } from './format-mixed-agent-keys';
|
|
24
24
|
import { LEGACY_AGENT_KEYS } from './legacy-agent-keys';
|
|
25
25
|
import { pickDefaultAgent } from './pick-default-agent';
|
|
26
|
+
import { REMOVED_CONFIG_KEYS } from './removed-config-keys';
|
|
26
27
|
import { isRecord } from './report';
|
|
27
28
|
import { resolveHomeDir } from './resolve-home-dir';
|
|
28
29
|
|
|
@@ -65,10 +66,12 @@ export interface Config {
|
|
|
65
66
|
// The workspace config problems, one line each.
|
|
66
67
|
workspaceErrors: readonly string[];
|
|
67
68
|
|
|
68
|
-
// Whether
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
// Whether the daemon restores the stored fleet by itself after a restart.
|
|
70
|
+
restoreFleetOnRestart: boolean;
|
|
71
|
+
|
|
72
|
+
// The keys the file sets that atc no longer reads, in the order the file
|
|
73
|
+
// holds them. Values are never recorded.
|
|
74
|
+
removedKeys: readonly string[];
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
/**
|
|
@@ -117,7 +120,8 @@ const DEFAULTS: Config = {
|
|
|
117
120
|
principals: null,
|
|
118
121
|
principalErrors: [],
|
|
119
122
|
workspaceErrors: [],
|
|
120
|
-
|
|
123
|
+
restoreFleetOnRestart: true,
|
|
124
|
+
removedKeys: [],
|
|
121
125
|
};
|
|
122
126
|
|
|
123
127
|
const configDir = join(resolveHomeDir(), '.config', 'atc');
|
|
@@ -158,7 +162,7 @@ const CONFIG_SCHEMA = z.object({
|
|
|
158
162
|
targets: z.unknown().optional(),
|
|
159
163
|
defaultTarget: z.unknown().optional(),
|
|
160
164
|
principals: z.unknown().optional(),
|
|
161
|
-
|
|
165
|
+
restoreFleetOnRestart: buildOptionalBoolean(),
|
|
162
166
|
});
|
|
163
167
|
|
|
164
168
|
/**
|
|
@@ -259,6 +263,7 @@ export function renderDefaultConfig(): string {
|
|
|
259
263
|
principals: _principals,
|
|
260
264
|
principalErrors: _principalErrors,
|
|
261
265
|
workspaceErrors: _workspaceErrors,
|
|
266
|
+
removedKeys: _removedKeys,
|
|
262
267
|
workspaces,
|
|
263
268
|
...written
|
|
264
269
|
} = DEFAULTS;
|
|
@@ -354,7 +359,8 @@ export function parseConfig(raw: unknown, file: string = configFile): Config {
|
|
|
354
359
|
principals: principals.principals,
|
|
355
360
|
principalErrors: principals.errors,
|
|
356
361
|
workspaceErrors: workspaces.errors,
|
|
357
|
-
|
|
362
|
+
restoreFleetOnRestart: parsed.data.restoreFleetOnRestart ?? DEFAULTS.restoreFleetOnRestart,
|
|
363
|
+
removedKeys: REMOVED_CONFIG_KEYS.filter((key) => Object.hasOwn(raw, key)),
|
|
358
364
|
};
|
|
359
365
|
}
|
|
360
366
|
|
|
@@ -23,7 +23,8 @@ export interface ResolvedAuthSecret {
|
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* A selection with its dependencies expanded: every profile it reaches,
|
|
26
|
-
* every host those profiles send a credential to,
|
|
26
|
+
* every host those profiles send a credential to, the variables they set
|
|
27
|
+
* in the guest with the profile each came from, and the rules grouped by
|
|
27
28
|
* secret, each list sorted so two selections of the same profiles resolve
|
|
28
29
|
* the same.
|
|
29
30
|
*/
|
|
@@ -31,6 +32,8 @@ interface ResolvedAuthProfiles {
|
|
|
31
32
|
readonly profiles: readonly string[];
|
|
32
33
|
readonly hosts: readonly string[];
|
|
33
34
|
readonly secrets: readonly ResolvedAuthSecret[];
|
|
35
|
+
readonly env: Readonly<Record<string, string>>;
|
|
36
|
+
readonly envOwners: Readonly<Record<string, string>>;
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
/**
|
|
@@ -40,7 +43,8 @@ interface ResolvedAuthProfiles {
|
|
|
40
43
|
* the config refused.
|
|
41
44
|
* - `auth_dependency_cycle`: the dependencies loop back on themselves.
|
|
42
45
|
* - `auth_collision`: two profiles send different credentials or rules to
|
|
43
|
-
* one host, which impd could not tell apart
|
|
46
|
+
* one host, which impd could not tell apart, or set one variable to
|
|
47
|
+
* different values.
|
|
44
48
|
*/
|
|
45
49
|
export interface AuthProfileProblem {
|
|
46
50
|
readonly code: 'auth_profile_unknown' | 'auth_dependency_cycle' | 'auth_collision';
|
|
@@ -85,6 +89,9 @@ export function resolveAuthProfiles(
|
|
|
85
89
|
const byHost = new Map<string, ProfileRule>();
|
|
86
90
|
const kinds = new Map<string, AuthProfile>();
|
|
87
91
|
|
|
92
|
+
const env: Record<string, string> = {};
|
|
93
|
+
const envOwners: Record<string, string> = {};
|
|
94
|
+
|
|
88
95
|
for (const profile of ordered) {
|
|
89
96
|
const kindOwner = kinds.get(profile.secret);
|
|
90
97
|
|
|
@@ -99,6 +106,22 @@ export function resolveAuthProfiles(
|
|
|
99
106
|
};
|
|
100
107
|
}
|
|
101
108
|
|
|
109
|
+
for (const [key, value] of Object.entries(profile.env)) {
|
|
110
|
+
const owner = envOwners[key];
|
|
111
|
+
|
|
112
|
+
if (owner === undefined) {
|
|
113
|
+
env[key] = value;
|
|
114
|
+
envOwners[key] = profile.name;
|
|
115
|
+
} else if (env[key] !== value) {
|
|
116
|
+
return {
|
|
117
|
+
problem: {
|
|
118
|
+
code: 'auth_collision',
|
|
119
|
+
message: `profiles ${owner} and ${profile.name} set ${key} to different values`,
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
102
125
|
for (const rule of getProfileRules(profile)) {
|
|
103
126
|
const other = byHost.get(rule.host);
|
|
104
127
|
|
|
@@ -120,6 +143,8 @@ export function resolveAuthProfiles(
|
|
|
120
143
|
profiles: ordered.map((profile) => profile.name),
|
|
121
144
|
hosts: [...byHost.keys()].toSorted(),
|
|
122
145
|
secrets: buildSecrets([...byHost.values()]),
|
|
146
|
+
env,
|
|
147
|
+
envOwners,
|
|
123
148
|
},
|
|
124
149
|
};
|
|
125
150
|
}
|
package/src/store/fleet-entry.ts
CHANGED
|
@@ -67,11 +67,6 @@ export interface FleetEntry {
|
|
|
67
67
|
// parent's when the two share one host. A row without one runs on a host
|
|
68
68
|
// of its own.
|
|
69
69
|
readonly hostKey?: SessionID;
|
|
70
|
-
|
|
71
|
-
// Whether a fleet restore after a daemon restart sends the session one
|
|
72
|
-
// message to carry on a turn the restart cut off; a row without one
|
|
73
|
-
// follows the daemon's config.
|
|
74
|
-
readonly resumeInterruptedTurns?: boolean;
|
|
75
70
|
}
|
|
76
71
|
|
|
77
72
|
export interface FleetStore {
|
|
@@ -34,10 +34,6 @@ interface FleetTable {
|
|
|
34
34
|
// its parent's when the two share one host. Null for a row from before
|
|
35
35
|
// hosts were recorded, which runs on its own.
|
|
36
36
|
host_key: string | null;
|
|
37
|
-
|
|
38
|
-
// Whether a restore sends the session one message to carry on an
|
|
39
|
-
// interrupted turn: 1 or 0 as the spawn chose, null to follow the config.
|
|
40
|
-
resume_interrupted_turns: number | null;
|
|
41
37
|
}
|
|
42
38
|
|
|
43
39
|
interface EventsTable {
|
package/src/store/state-store.ts
CHANGED
|
@@ -38,7 +38,6 @@ import type {
|
|
|
38
38
|
|
|
39
39
|
// Spelled as the partial index's predicate so SQLite can match them.
|
|
40
40
|
const TRAIL_FILTER = sql<boolean>`kind IS NOT NULL AND kind != 'heartbeat'`;
|
|
41
|
-
const TURN_KINDS = ['prompt-submitted', 'turn-done', 'needs-input', 'ended'];
|
|
42
41
|
|
|
43
42
|
/**
|
|
44
43
|
* Some sessions' slice of the event trail: rows under their atc ids, plus
|
|
@@ -211,7 +210,6 @@ export class StateStore {
|
|
|
211
210
|
'workspace.withheld_env',
|
|
212
211
|
'fleet.desired',
|
|
213
212
|
'fleet.host_key',
|
|
214
|
-
'fleet.resume_interrupted_turns',
|
|
215
213
|
])
|
|
216
214
|
.execute();
|
|
217
215
|
|
|
@@ -241,9 +239,6 @@ export class StateStore {
|
|
|
241
239
|
...buildWithheldEnvField(row.withheld_env),
|
|
242
240
|
...(row.desired === 'sleep' || row.desired === 'stop' ? { desired: row.desired } : {}),
|
|
243
241
|
...(row.host_key === null ? {} : { hostKey: toSessionID(row.host_key) }),
|
|
244
|
-
...(row.resume_interrupted_turns === null
|
|
245
|
-
? {}
|
|
246
|
-
: { resumeInterruptedTurns: row.resume_interrupted_turns !== 0 }),
|
|
247
242
|
});
|
|
248
243
|
}
|
|
249
244
|
|
|
@@ -351,10 +346,6 @@ export class StateStore {
|
|
|
351
346
|
target_identity: entry.targetIdentity ?? null,
|
|
352
347
|
desired: entry.desired ?? null,
|
|
353
348
|
host_key: entry.hostKey ?? null,
|
|
354
|
-
resume_interrupted_turns:
|
|
355
|
-
entry.resumeInterruptedTurns === undefined
|
|
356
|
-
? null
|
|
357
|
-
: Number(entry.resumeInterruptedTurns),
|
|
358
349
|
})
|
|
359
350
|
.execute();
|
|
360
351
|
|
|
@@ -548,31 +539,6 @@ export class StateStore {
|
|
|
548
539
|
return row?.ts === null || row?.ts === undefined ? null : Date.parse(row.ts);
|
|
549
540
|
}
|
|
550
541
|
|
|
551
|
-
// The kind of the session's latest event that moves a turn: a prompt
|
|
552
|
-
// submitted, a turn done, a request for input, or the session's end.
|
|
553
|
-
// Null when the trail holds none. Matches the session by either id, as
|
|
554
|
-
// the activity read does.
|
|
555
|
-
async findLatestTurnKind(
|
|
556
|
-
atcID: SessionID,
|
|
557
|
-
agentSessionID: AgentSessionID | undefined,
|
|
558
|
-
): Promise<string | null> {
|
|
559
|
-
const row = await this.db
|
|
560
|
-
.selectFrom('events')
|
|
561
|
-
.select('kind')
|
|
562
|
-
.where('kind', 'in', TURN_KINDS)
|
|
563
|
-
.where((eb) =>
|
|
564
|
-
eb.or([
|
|
565
|
-
eb('atc_id', '=', atcID),
|
|
566
|
-
...(agentSessionID === undefined ? [] : [eb('session_id', '=', agentSessionID)]),
|
|
567
|
-
]),
|
|
568
|
-
)
|
|
569
|
-
.orderBy('id', 'desc')
|
|
570
|
-
.limit(1)
|
|
571
|
-
.executeTakeFirst();
|
|
572
|
-
|
|
573
|
-
return row?.kind ?? null;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
542
|
async recordSpawnDir(cwd: string, grant: SpawnTarget): Promise<void> {
|
|
577
543
|
await this.db
|
|
578
544
|
.insertInto('spawn_history')
|