@zgeoff/atc 2.40.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.40.0",
3
+ "version": "2.41.0",
4
4
  "description": "Terminal control tower for coding-agent sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -44,12 +44,17 @@ export interface SpawnPlan {
44
44
  * files unpack into. `auth` is given when the harness takes its credential
45
45
  * from impd's broker: the revision of the host's runtime auth binding it
46
46
  * launches under, which keys any settings the agent writes for it, and the
47
- * 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.
48
49
  */
49
50
  export interface GuestPaths {
50
51
  readonly atc: string | null;
51
52
  readonly dir: string;
52
- readonly auth?: { readonly revision: number; readonly env: Readonly<Record<string, string>> };
53
+ readonly auth?: {
54
+ readonly revision: number;
55
+ readonly env: Readonly<Record<string, string>>;
56
+ readonly profileEnv: Readonly<Record<string, string>>;
57
+ };
53
58
  }
54
59
 
55
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({ ...auth.env, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' }),
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 be that
356
- // variable alone, holding the placeholder, for a profile whose rule on
357
- // the base URL's host sets that header. Any other variable would put the
358
- // placeholder in a header the broker never fills.
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.length !== 1 || keys[0] !== BEARER_VARIABLE) {
370
+ if (!keys.includes(BEARER_VARIABLE)) {
366
371
  return this.buildPlaceholderRefusal(
367
- `needs ${BEARER_VARIABLE} as its only placeholder variable, which the broker fills as a bearer authorization header; it has ${keys.length === 0 ? 'none' : keys.join(', ')}`,
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
 
@@ -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` covers the secrets and their rules
15
- * alone, so a change to a profile the binding does not reach leaves it as
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({ secrets })))
60
+ .update(JSON.stringify(sortJSONKeys(hashed)))
56
61
  .digest('hex'),
57
62
  },
58
63
  };
@@ -2014,7 +2014,7 @@ export class SessionManager {
2014
2014
  : {
2015
2015
  atc: guest.atc,
2016
2016
  dir,
2017
- auth: await this.planGuestAuth(hostKey, auth.mode, auth.binding.placeholderEnv),
2017
+ auth: await this.planGuestAuth(hostKey, auth.mode, auth.binding),
2018
2018
  };
2019
2019
 
2020
2020
  const plan =
@@ -2100,11 +2100,15 @@ export class SessionManager {
2100
2100
  private async planGuestAuth(
2101
2101
  hostKey: SessionID,
2102
2102
  mode: HarnessAuthSetup['mode'],
2103
- placeholderEnv: Readonly<Record<string, string>>,
2103
+ binding: Pick<AuthBinding, 'placeholderEnv' | 'profileEnv'>,
2104
2104
  ): Promise<NonNullable<GuestPaths['auth']>> {
2105
2105
  const held = mode === 'create' ? null : await this.requireAuthBinder().findBinding(hostKey);
2106
2106
 
2107
- return { revision: held?.revision ?? 1, env: placeholderEnv };
2107
+ return {
2108
+ revision: held?.revision ?? 1,
2109
+ env: binding.placeholderEnv,
2110
+ profileEnv: binding.profileEnv,
2111
+ };
2108
2112
  }
2109
2113
 
2110
2114
  // Creates the binding of a host a spawn provisions, or verifies the one
@@ -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
- return { name, secret, kind, dependencies: dependencies ?? [] };
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
+ }
@@ -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, and the rules grouped by
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
  }