@zgeoff/atc 2.36.0 → 2.37.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.
@@ -1,20 +1,10 @@
1
1
  import { z } from 'zod';
2
2
  import type { AgentID } from './agent-id';
3
3
  import { buildOptionalStringArray } from './build-optional-string-array';
4
+ import { checkGatewayAuth } from './check-gateway-auth';
5
+ import type { GatewayAuth } from './check-gateway-auth';
4
6
  import type { AuthProfile } from './collect-auth-profiles';
5
- import { isBrokerVariable } from './is-broker-variable';
6
7
  import { isRecord } from './report';
7
- import { resolveAuthProfiles } from './resolve-auth-profiles';
8
-
9
- /**
10
- * The auth profiles a gateway's sessions are bound to through impd's
11
- * broker, and the variables each session gets in place of a credential,
12
- * every value the fixed placeholder.
13
- */
14
- export interface GatewayAuth {
15
- readonly profiles: readonly string[];
16
- readonly placeholderEnv: Readonly<Record<string, string>>;
17
- }
18
8
 
19
9
  /**
20
10
  * A Claude-compatible backend the Claude CLI is pointed at: its own agent id,
@@ -125,162 +115,6 @@ export function collectGateways(
125
115
  return { gateways, errors };
126
116
  }
127
117
 
128
- interface GatewayEntry {
129
- readonly baseURL?: string | undefined;
130
- readonly apiKeyHelper?: string | undefined;
131
- readonly env: Readonly<Record<string, string>>;
132
- readonly settings?: Readonly<Record<string, unknown>> | undefined;
133
- }
134
-
135
- // The gateway's auth, or every problem that refuses it. The checks cover
136
- // each place a session's environment comes from: the gateway env, the
137
- // settings env, and the placeholders, and which of them would win.
138
- function checkGatewayAuth(
139
- raw: unknown,
140
- entry: GatewayEntry,
141
- authProfiles: ReadonlyMap<string, AuthProfile>,
142
- ): GatewayAuth | string[] {
143
- const auth = parseGatewayAuth(raw);
144
-
145
- if (typeof auth === 'string') {
146
- return [auth];
147
- }
148
-
149
- const problems: string[] = [];
150
-
151
- if (entry.apiKeyHelper !== undefined) {
152
- problems.push(
153
- 'apiKeyHelper cannot be set together with auth, which supplies the credential through the broker',
154
- );
155
- }
156
-
157
- if (entry.settings?.['apiKeyHelper'] !== undefined) {
158
- problems.push(
159
- 'settings.apiKeyHelper cannot be set together with auth, which supplies the credential through the broker',
160
- );
161
- }
162
-
163
- const settingsEnv = toEnvKeys(entry.settings?.['env']);
164
-
165
- problems.push(
166
- ...collectEnvProblems('env', Object.keys(entry.env)),
167
- ...collectEnvProblems('settings.env', settingsEnv),
168
- ...collectPlaceholderProblems(auth.placeholderEnv),
169
- );
170
-
171
- for (const key of Object.keys(auth.placeholderEnv)) {
172
- for (const [source, keys] of [
173
- ['env', Object.keys(entry.env)],
174
- ['settings.env', settingsEnv],
175
- ] as const) {
176
- if (keys.includes(key)) {
177
- problems.push(`placeholderEnv.${key} is also set in ${source}, which would override it`);
178
- }
179
- }
180
- }
181
-
182
- const resolution = resolveAuthProfiles(authProfiles, auth.profiles);
183
-
184
- if ('problem' in resolution) {
185
- problems.push(resolution.problem.message);
186
- } else {
187
- const hostProblem = findBaseURLProblem(entry.baseURL ?? '', resolution.resolved.hosts);
188
-
189
- if (hostProblem !== null) {
190
- problems.push(hostProblem);
191
- }
192
- }
193
-
194
- return problems.length > 0 ? problems : auth;
195
- }
196
-
197
- // The value every placeholder variable holds; impd's broker swaps the real
198
- // credential in on the host's side.
199
- const PLACEHOLDER = 'imp-broker-placeholder';
200
-
201
- function parseGatewayAuth(raw: unknown): GatewayAuth | string {
202
- const profiles = isRecord(raw) ? raw['profiles'] : undefined;
203
-
204
- if (
205
- !isRecord(raw) ||
206
- !Array.isArray(profiles) ||
207
- profiles.length === 0 ||
208
- !profiles.every((name) => typeof name === 'string')
209
- ) {
210
- return 'auth must be an object with a non-empty profiles array';
211
- }
212
-
213
- const placeholderEnv = raw['placeholderEnv'] ?? {};
214
-
215
- if (!isRecord(placeholderEnv) || Array.isArray(placeholderEnv)) {
216
- return 'placeholderEnv must be an object of variable names';
217
- }
218
-
219
- const env: Record<string, string> = {};
220
-
221
- for (const [key, value] of Object.entries(placeholderEnv)) {
222
- if (value !== PLACEHOLDER) {
223
- return `placeholderEnv.${key} must be ${PLACEHOLDER}`;
224
- }
225
-
226
- env[key] = value;
227
- }
228
-
229
- return { profiles: profiles.map(String), placeholderEnv: env };
230
- }
231
-
232
- // A settings env's variable names; anything but an object sets none.
233
- function toEnvKeys(value: unknown): string[] {
234
- return isRecord(value) && !Array.isArray(value) ? Object.keys(value) : [];
235
- }
236
-
237
- // Variables Claude reads its endpoint and credential from. Only the
238
- // placeholders may set a credential variable, and the base URL comes from
239
- // `baseURL` alone, whose host is checked against the profiles.
240
- const CREDENTIAL_VARIABLES: ReadonlySet<string> = new Set([
241
- 'ANTHROPIC_AUTH_TOKEN',
242
- 'ANTHROPIC_API_KEY',
243
- ]);
244
-
245
- const BASE_URL_VARIABLE = 'ANTHROPIC_BASE_URL';
246
-
247
- function collectEnvProblems(source: string, keys: readonly string[]): string[] {
248
- return keys
249
- .filter(
250
- (key) => isBrokerVariable(key) || CREDENTIAL_VARIABLES.has(key) || key === BASE_URL_VARIABLE,
251
- )
252
- .map((key) => `${source} must not set ${key}`);
253
- }
254
-
255
- function collectPlaceholderProblems(env: Readonly<Record<string, string>>): string[] {
256
- return Object.keys(env)
257
- .filter((key) => isBrokerVariable(key) || key === BASE_URL_VARIABLE)
258
- .map((key) => `placeholderEnv must not set ${key}`);
259
- }
260
-
261
- // The broker matches a request by the exact host it connects to on the
262
- // default https port, so the base URL must be https on that port, with a
263
- // host one of the selected profiles covers.
264
- function findBaseURLProblem(baseURL: string, hosts: readonly string[]): string | null {
265
- let url: URL;
266
-
267
- try {
268
- url = new URL(baseURL);
269
- } catch {
270
- return 'baseURL must be an https URL with no port or user info';
271
- }
272
-
273
- if (url.protocol !== 'https:' || url.port !== '' || url.username !== '' || url.password !== '') {
274
- return 'baseURL must be an https URL with no port or user info';
275
- }
276
-
277
- if (!hosts.includes(url.hostname)) {
278
- return `baseURL host ${url.hostname} is not a host of the selected profiles (${hosts.join(', ')})`;
279
- }
280
-
281
- return null;
282
- }
283
-
284
118
  // Claude Code settings the sessions of this gateway are started with, on top of
285
119
  // the ones atc writes itself. Anything but an object is no extra settings.
286
120
  function buildOptionalSettings() {
@@ -0,0 +1,77 @@
1
+ import type { AgentEntry } from './collect-agents';
2
+ import type { AuthProfile } from './collect-auth-profiles';
3
+ import { collectClaudeAuth } from './collect-claude-auth';
4
+ import { collectGateways } from './collect-gateways';
5
+
6
+ /**
7
+ * The agent keys of the old config.json shape, already defaulted: a binary and arguments per harness, and the raw `claudeAuth`
8
+ * and `gateways` values.
9
+ */
10
+ interface LegacyAgentKeys {
11
+ readonly claudeBin: string;
12
+ readonly claudeArgs: readonly string[];
13
+ readonly claudeAuth: unknown;
14
+ readonly grokBin: string;
15
+ readonly grokArgs: readonly string[];
16
+ readonly codexBin: string;
17
+ readonly codexArgs: readonly string[];
18
+ readonly gateways: unknown;
19
+ }
20
+
21
+ interface LegacyAgents {
22
+ readonly agents: AgentEntry[];
23
+
24
+ // The problems that left a Claude auth or a gateway out, in the text the
25
+ // old keys always reported.
26
+ readonly errors: string[];
27
+ }
28
+
29
+ /**
30
+ * Translates the old agent keys to registry entries: `claude`, `grok`, and
31
+ * `codex` in that order, then each gateway the old parse accepts, in file
32
+ * order. The parse is the old lenient one, so an absent or wrong-typed field
33
+ * takes its default.
34
+ */
35
+ export function collectLegacyAgents(
36
+ keys: LegacyAgentKeys,
37
+ authProfiles: ReadonlyMap<string, AuthProfile>,
38
+ ): LegacyAgents {
39
+ const claudeAuth = collectClaudeAuth(keys.claudeAuth, authProfiles);
40
+ const gateways = collectGateways(keys.gateways, keys.claudeBin, keys.claudeArgs, authProfiles);
41
+
42
+ const agents: AgentEntry[] = [
43
+ {
44
+ id: 'claude',
45
+ kind: 'claude',
46
+ label: 'Claude',
47
+ mark: 'c',
48
+ bin: keys.claudeBin,
49
+ args: keys.claudeArgs,
50
+ env: {},
51
+ ...(claudeAuth.auth === null
52
+ ? {}
53
+ : { auth: { profiles: claudeAuth.auth.profiles, placeholderEnv: {} } }),
54
+ },
55
+ {
56
+ id: 'grok',
57
+ kind: 'grok',
58
+ label: 'Grok',
59
+ mark: 'g',
60
+ bin: keys.grokBin,
61
+ args: keys.grokArgs,
62
+ env: {},
63
+ },
64
+ {
65
+ id: 'codex',
66
+ kind: 'codex',
67
+ label: 'Codex',
68
+ mark: 'c',
69
+ bin: keys.codexBin,
70
+ args: keys.codexArgs,
71
+ env: {},
72
+ },
73
+ ...gateways.gateways.map((gateway): AgentEntry => ({ ...gateway, kind: 'claude' })),
74
+ ];
75
+
76
+ return { agents, errors: [...claudeAuth.errors, ...gateways.errors] };
77
+ }
@@ -1,18 +1,18 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { z } from 'zod';
4
+ import type { AgentID } from './agent-id';
4
5
  import { buildOptionalBoolean } from './build-optional-boolean';
5
6
  import { buildOptionalString } from './build-optional-string';
6
7
  import { buildOptionalStringArray } from './build-optional-string-array';
8
+ import { collectAgents } from './collect-agents';
9
+ import type { AgentEntry } from './collect-agents';
7
10
  import { collectAuthProfiles } from './collect-auth-profiles';
8
11
  import type { AuthProfile } from './collect-auth-profiles';
9
- import { collectClaudeAuth } from './collect-claude-auth';
10
- import type { ClaudeAuth } from './collect-claude-auth';
11
12
  import { collectDirRoots } from './collect-dir-roots';
12
- import { collectGateways } from './collect-gateways';
13
- import type { GatewayConfig } from './collect-gateways';
14
13
  import { collectHooks } from './collect-hooks';
15
14
  import type { HooksConfig } from './collect-hooks';
15
+ import { collectLegacyAgents } from './collect-legacy-agents';
16
16
  import { collectPrincipals } from './collect-principals';
17
17
  import { collectTargets } from './collect-targets';
18
18
  import type { TargetConfig, TargetConfigError } from './collect-targets';
@@ -20,30 +20,28 @@ import { collectWorkspacesConfig } from './collect-workspaces-config';
20
20
  import type { WorkspacesConfig } from './collect-workspaces-config';
21
21
  import { DEFAULT_GIT_TRANSPORTS } from './default-git-transports';
22
22
  import { formatJSONKind } from './format-json-kind';
23
+ import { formatMixedAgentKeys } from './format-mixed-agent-keys';
24
+ import { LEGACY_AGENT_KEYS } from './legacy-agent-keys';
25
+ import { pickDefaultAgent } from './pick-default-agent';
23
26
  import { isRecord } from './report';
24
27
  import { resolveHomeDir } from './resolve-home-dir';
25
28
 
26
29
  export interface Config {
27
- claudeBin: string;
28
- claudeArgs: string[];
29
-
30
- // The auth profiles stock Claude signs in through on a target that
31
- // reaches impd's broker, null when it keeps the host's own sign-in there,
32
- // and the problems that left an entry out.
33
- claudeAuth: ClaudeAuth | null;
34
- claudeAuthErrors: readonly string[];
35
- grokBin: string;
36
- grokArgs: string[];
37
- codexBin: string;
38
- codexArgs: string[];
30
+ // The agents atc offers, in menu order, and the problems that left an
31
+ // entry out.
32
+ agents: readonly AgentEntry[];
33
+ agentErrors: readonly string[];
34
+
35
+ // The old agent keys a file without `agents` sets, in file order. Empty
36
+ // for a file that uses `agents` or sets none of them.
37
+ legacyAgentKeys: readonly string[];
38
+
39
+ // The agent a spawn without one runs.
40
+ defaultAgent: AgentID;
39
41
  dirs: DirsConfig;
40
42
  workspaces: WorkspacesConfig;
41
- gateways: GatewayConfig[];
42
-
43
- // The problems that kept a gateway with auth out of the gateways.
44
- gatewayErrors: readonly string[];
45
43
 
46
- // The credential references a gateway's auth selects from, by profile
44
+ // The credential references an agent's auth selects from, by profile
47
45
  // name, and the problems that kept a profile out.
48
46
  authProfiles: ReadonlyMap<string, AuthProfile>;
49
47
  authProfileErrors: readonly string[];
@@ -87,14 +85,20 @@ interface LeaderKey {
87
85
  }
88
86
 
89
87
  const DEFAULTS: Config = {
90
- claudeBin: 'claude',
91
- claudeArgs: [],
92
- claudeAuth: null,
93
- claudeAuthErrors: [],
94
- grokBin: 'grok',
95
- grokArgs: [],
96
- codexBin: 'codex',
97
- codexArgs: [],
88
+ agents: [
89
+ {
90
+ id: 'claude',
91
+ kind: 'claude',
92
+ label: 'Claude',
93
+ mark: 'c',
94
+ bin: 'claude',
95
+ args: [],
96
+ env: {},
97
+ },
98
+ ],
99
+ agentErrors: [],
100
+ legacyAgentKeys: [],
101
+ defaultAgent: 'claude',
98
102
  dirs: { roots: [] },
99
103
  workspaces: {
100
104
  githubOwner: null,
@@ -103,8 +107,6 @@ const DEFAULTS: Config = {
103
107
  root: null,
104
108
  targetRoots: new Map(),
105
109
  },
106
- gateways: [],
107
- gatewayErrors: [],
108
110
  authProfiles: new Map(),
109
111
  authProfileErrors: [],
110
112
  hooks: {},
@@ -139,6 +141,7 @@ export const daemonRecordFile = join(stateDir, 'daemon.json');
139
141
  // field parses to undefined rather than failing the file, so a bad config
140
142
  // falls back to a default instead of refusing to start atc.
141
143
  const CONFIG_SCHEMA = z.object({
144
+ agents: z.unknown().optional(),
142
145
  claudeBin: buildOptionalString(),
143
146
  claudeArgs: buildOptionalStringArray(),
144
147
  claudeAuth: z.unknown().optional(),
@@ -237,16 +240,17 @@ function tryWriteDefaultConfig(file: string): void {
237
240
  }
238
241
 
239
242
  /**
240
- * The config.json text a first run writes. It leaves out the targets and
241
- * principals, so the file holds the one implicit `local` target and no
243
+ * The config.json text a first run writes: the `claude` agent alone. It
244
+ * leaves out the targets and principals, so the file holds the one implicit `local` target and no
242
245
  * principals until the user sets their own, no auth profiles, and the
243
246
  * errors a parse reports, which belong to no file.
244
247
  */
245
248
  export function renderDefaultConfig(): string {
246
249
  const {
247
- claudeAuth: _claudeAuth,
248
- claudeAuthErrors: _claudeAuthErrors,
249
- gatewayErrors: _gatewayErrors,
250
+ agents: _agents,
251
+ agentErrors: _agentErrors,
252
+ legacyAgentKeys: _legacyAgentKeys,
253
+ defaultAgent: _defaultAgent,
250
254
  authProfiles: _authProfiles,
251
255
  authProfileErrors: _authProfileErrors,
252
256
  targets: _targets,
@@ -264,7 +268,11 @@ export function renderDefaultConfig(): string {
264
268
  const { targetRoots, ...rest } = workspaces;
265
269
 
266
270
  return `${JSON.stringify(
267
- { ...written, workspaces: { ...rest, targets: Object.fromEntries(targetRoots) } },
271
+ {
272
+ agents: { claude: {} },
273
+ ...written,
274
+ workspaces: { ...rest, targets: Object.fromEntries(targetRoots) },
275
+ },
268
276
  null,
269
277
  2,
270
278
  )}\n`;
@@ -296,23 +304,31 @@ export function parseConfig(raw: unknown, file: string = configFile): Config {
296
304
  );
297
305
  }
298
306
 
299
- const claudeBin = parsed.data.claudeBin ?? DEFAULTS.claudeBin;
300
- const claudeArgs = parsed.data.claudeArgs ?? DEFAULTS.claudeArgs;
301
- const grokBin = parsed.data.grokBin ?? DEFAULTS.grokBin;
302
- const grokArgs = parsed.data.grokArgs ?? DEFAULTS.grokArgs;
303
- const codexBin = parsed.data.codexBin ?? DEFAULTS.codexBin;
304
- const codexArgs = parsed.data.codexArgs ?? DEFAULTS.codexArgs;
307
+ const present = Object.keys(raw).filter((key) => LEGACY_AGENT_KEYS.includes(key));
308
+
309
+ if (Object.hasOwn(raw, 'agents') && present.length > 0) {
310
+ return buildUnusableConfig('config_malformed', file, formatMixedAgentKeys(present));
311
+ }
312
+
305
313
  const dirs = { roots: collectDirRoots(parsed.data.dirs) };
306
314
  const workspaces = collectWorkspacesConfig(parsed.data.workspaces);
307
315
  const authProfiles = collectAuthProfiles(parsed.data.authProfiles);
308
- const claudeAuth = collectClaudeAuth(parsed.data.claudeAuth, authProfiles.profiles);
309
316
 
310
- const gateways = collectGateways(
311
- parsed.data.gateways,
312
- claudeBin,
313
- claudeArgs,
314
- authProfiles.profiles,
315
- );
317
+ const registry = Object.hasOwn(raw, 'agents')
318
+ ? collectAgents(parsed.data.agents, authProfiles.profiles)
319
+ : collectLegacyAgents(
320
+ {
321
+ claudeBin: parsed.data.claudeBin ?? 'claude',
322
+ claudeArgs: parsed.data.claudeArgs ?? [],
323
+ claudeAuth: parsed.data.claudeAuth,
324
+ grokBin: parsed.data.grokBin ?? 'grok',
325
+ grokArgs: parsed.data.grokArgs ?? [],
326
+ codexBin: parsed.data.codexBin ?? 'codex',
327
+ codexArgs: parsed.data.codexArgs ?? [],
328
+ gateways: parsed.data.gateways,
329
+ },
330
+ authProfiles.profiles,
331
+ );
316
332
 
317
333
  const hooks = collectHooks(parsed.data.hooks);
318
334
  const targets = collectTargets(parsed.data.targets, parsed.data.defaultTarget);
@@ -322,18 +338,12 @@ export function parseConfig(raw: unknown, file: string = configFile): Config {
322
338
  (parsed.data.leader === undefined ? null : decodeLeader(parsed.data.leader)) ?? DEFAULTS.leader;
323
339
 
324
340
  return {
325
- claudeBin,
326
- claudeArgs,
327
- claudeAuth: claudeAuth.auth,
328
- claudeAuthErrors: claudeAuth.errors,
329
- grokBin,
330
- grokArgs,
331
- codexBin,
332
- codexArgs,
341
+ agents: registry.agents,
342
+ agentErrors: registry.errors,
343
+ legacyAgentKeys: present,
344
+ defaultAgent: pickDefaultAgent(registry.agents),
333
345
  dirs,
334
346
  workspaces: workspaces.workspaces,
335
- gateways: gateways.gateways,
336
- gatewayErrors: gateways.errors,
337
347
  authProfiles: authProfiles.profiles,
338
348
  authProfileErrors: authProfiles.errors,
339
349
  hooks,
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The problem a config.json that sets both `agents` and old agent keys
3
+ * reports: the keys, as "a", "a and b", or "a, b and c", and where they go.
4
+ */
5
+ export function formatMixedAgentKeys(keys: readonly string[]): string {
6
+ const last = keys.at(-1);
7
+ const list = keys.length < 2 ? keys.join('') : `${keys.slice(0, -1).join(', ')} and ${last}`;
8
+
9
+ return `${list} cannot be set together with agents; move them into agents or run 'atc config migrate'`;
10
+ }
@@ -0,0 +1,14 @@
1
+ import { isBrokerVariable } from './is-broker-variable';
2
+ import { OUTRANKING_VARIABLES } from './outranking-variables';
3
+
4
+ /**
5
+ * Whether a variable set in a Claude session's environment would override
6
+ * the subscription sign-in or route the CLI around impd's broker: an
7
+ * outranking credential, endpoint, or provider selector, the subscription
8
+ * token variable itself, or a proxy or CA variable.
9
+ */
10
+ export function isSubscriptionOverrideVariable(key: string): boolean {
11
+ return OUTRANKING_VARIABLES.has(key) || key === OAUTH_VARIABLE || isBrokerVariable(key);
12
+ }
13
+
14
+ const OAUTH_VARIABLE = 'CLAUDE_CODE_OAUTH_TOKEN';
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The config.json keys of the old agent shape, which `agents` replaces: the
3
+ * harness binaries and arguments, the Claude subscription auth, and the
4
+ * gateway map.
5
+ */
6
+ export const LEGACY_AGENT_KEYS: readonly string[] = [
7
+ 'claudeBin',
8
+ 'claudeArgs',
9
+ 'claudeAuth',
10
+ 'grokBin',
11
+ 'grokArgs',
12
+ 'codexBin',
13
+ 'codexArgs',
14
+ 'gateways',
15
+ ];
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The variables that keep Claude Code from sending the subscription token
3
+ * to the Anthropic API: a credential it takes ahead of that token, another
4
+ * endpoint, or a cloud provider it signs in to instead.
5
+ */
6
+ export const OUTRANKING_VARIABLES: ReadonlySet<string> = new Set([
7
+ 'ANTHROPIC_AUTH_TOKEN',
8
+ 'ANTHROPIC_API_KEY',
9
+ 'ANTHROPIC_BASE_URL',
10
+ 'CLAUDE_CODE_USE_BEDROCK',
11
+ 'CLAUDE_CODE_USE_VERTEX',
12
+ 'CLAUDE_CODE_USE_FOUNDRY',
13
+ 'CLAUDE_CODE_USE_MANTLE',
14
+ 'CLAUDE_CODE_USE_ANTHROPIC_AWS',
15
+ 'CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD',
16
+ 'CLAUDE_CODE_USE_GATEWAY',
17
+ ]);
@@ -0,0 +1,12 @@
1
+ import type { AgentID } from './agent-id';
2
+ import type { AgentEntry } from './collect-agents';
3
+
4
+ /**
5
+ * The agent a spawn without one runs: `claude` when the registry holds an
6
+ * entry with that id, else the first entry. An empty registry still reports
7
+ * `claude`, so a spawn is refused for want of an adapter rather than for an
8
+ * unknown default.
9
+ */
10
+ export function pickDefaultAgent(agents: readonly AgentEntry[]): AgentID {
11
+ return agents.find((entry) => entry.id === 'claude')?.id ?? agents[0]?.id ?? 'claude';
12
+ }