@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.
@@ -0,0 +1,173 @@
1
+ import type { AuthProfile } from './collect-auth-profiles';
2
+ import { isBrokerVariable } from './is-broker-variable';
3
+ import { isRecord } from './report';
4
+ import { resolveAuthProfiles } from './resolve-auth-profiles';
5
+
6
+ /**
7
+ * The auth profiles a gateway's sessions are bound to through impd's
8
+ * broker, and the variables each session gets in place of a credential,
9
+ * every value the fixed placeholder.
10
+ */
11
+ export interface GatewayAuth {
12
+ readonly profiles: readonly string[];
13
+ readonly placeholderEnv: Readonly<Record<string, string>>;
14
+ }
15
+
16
+ /**
17
+ * The fields of a gateway entry that its auth is checked against.
18
+ */
19
+ interface GatewayAuthEntry {
20
+ readonly baseURL?: string | undefined;
21
+ readonly apiKeyHelper?: string | undefined;
22
+ readonly env: Readonly<Record<string, string>>;
23
+ readonly settings?: Readonly<Record<string, unknown>> | undefined;
24
+ }
25
+
26
+ // The gateway's auth, or every problem that refuses it. The checks cover
27
+ // each place a session's environment comes from: the gateway env, the
28
+ // settings env, and the placeholders, and which of them would win.
29
+ export function checkGatewayAuth(
30
+ raw: unknown,
31
+ entry: GatewayAuthEntry,
32
+ authProfiles: ReadonlyMap<string, AuthProfile>,
33
+ ): GatewayAuth | string[] {
34
+ const auth = parseGatewayAuth(raw);
35
+
36
+ if (typeof auth === 'string') {
37
+ return [auth];
38
+ }
39
+
40
+ const problems: string[] = [];
41
+
42
+ if (entry.apiKeyHelper !== undefined) {
43
+ problems.push(
44
+ 'apiKeyHelper cannot be set together with auth, which supplies the credential through the broker',
45
+ );
46
+ }
47
+
48
+ if (entry.settings?.['apiKeyHelper'] !== undefined) {
49
+ problems.push(
50
+ 'settings.apiKeyHelper cannot be set together with auth, which supplies the credential through the broker',
51
+ );
52
+ }
53
+
54
+ const settingsEnv = toEnvKeys(entry.settings?.['env']);
55
+
56
+ problems.push(
57
+ ...collectEnvProblems('env', Object.keys(entry.env)),
58
+ ...collectEnvProblems('settings.env', settingsEnv),
59
+ ...collectPlaceholderProblems(auth.placeholderEnv),
60
+ );
61
+
62
+ for (const key of Object.keys(auth.placeholderEnv)) {
63
+ for (const [source, keys] of [
64
+ ['env', Object.keys(entry.env)],
65
+ ['settings.env', settingsEnv],
66
+ ] as const) {
67
+ if (keys.includes(key)) {
68
+ problems.push(`placeholderEnv.${key} is also set in ${source}, which would override it`);
69
+ }
70
+ }
71
+ }
72
+
73
+ const resolution = resolveAuthProfiles(authProfiles, auth.profiles);
74
+
75
+ if ('problem' in resolution) {
76
+ problems.push(resolution.problem.message);
77
+ } else {
78
+ const hostProblem = findBaseURLProblem(entry.baseURL ?? '', resolution.resolved.hosts);
79
+
80
+ if (hostProblem !== null) {
81
+ problems.push(hostProblem);
82
+ }
83
+ }
84
+
85
+ return problems.length > 0 ? problems : auth;
86
+ }
87
+
88
+ // The value every placeholder variable holds; impd's broker swaps the real
89
+ // credential in on the host's side.
90
+ const PLACEHOLDER = 'imp-broker-placeholder';
91
+
92
+ function parseGatewayAuth(raw: unknown): GatewayAuth | string {
93
+ const profiles = isRecord(raw) ? raw['profiles'] : undefined;
94
+
95
+ if (
96
+ !isRecord(raw) ||
97
+ !Array.isArray(profiles) ||
98
+ profiles.length === 0 ||
99
+ !profiles.every((name) => typeof name === 'string')
100
+ ) {
101
+ return 'auth must be an object with a non-empty profiles array';
102
+ }
103
+
104
+ const placeholderEnv = raw['placeholderEnv'] ?? {};
105
+
106
+ if (!isRecord(placeholderEnv) || Array.isArray(placeholderEnv)) {
107
+ return 'placeholderEnv must be an object of variable names';
108
+ }
109
+
110
+ const env: Record<string, string> = {};
111
+
112
+ for (const [key, value] of Object.entries(placeholderEnv)) {
113
+ if (value !== PLACEHOLDER) {
114
+ return `placeholderEnv.${key} must be ${PLACEHOLDER}`;
115
+ }
116
+
117
+ env[key] = value;
118
+ }
119
+
120
+ return { profiles: profiles.map(String), placeholderEnv: env };
121
+ }
122
+
123
+ // A settings env's variable names; anything but an object sets none.
124
+ function toEnvKeys(value: unknown): string[] {
125
+ return isRecord(value) && !Array.isArray(value) ? Object.keys(value) : [];
126
+ }
127
+
128
+ // Variables Claude reads its endpoint and credential from. Only the
129
+ // placeholders may set a credential variable, and the base URL comes from
130
+ // `baseURL` alone, whose host is checked against the profiles.
131
+ const CREDENTIAL_VARIABLES: ReadonlySet<string> = new Set([
132
+ 'ANTHROPIC_AUTH_TOKEN',
133
+ 'ANTHROPIC_API_KEY',
134
+ ]);
135
+
136
+ const BASE_URL_VARIABLE = 'ANTHROPIC_BASE_URL';
137
+
138
+ function collectEnvProblems(source: string, keys: readonly string[]): string[] {
139
+ return keys
140
+ .filter(
141
+ (key) => isBrokerVariable(key) || CREDENTIAL_VARIABLES.has(key) || key === BASE_URL_VARIABLE,
142
+ )
143
+ .map((key) => `${source} must not set ${key}`);
144
+ }
145
+
146
+ function collectPlaceholderProblems(env: Readonly<Record<string, string>>): string[] {
147
+ return Object.keys(env)
148
+ .filter((key) => isBrokerVariable(key) || key === BASE_URL_VARIABLE)
149
+ .map((key) => `placeholderEnv must not set ${key}`);
150
+ }
151
+
152
+ // The broker matches a request by the exact host it connects to on the
153
+ // default https port, so the base URL must be https on that port, with a
154
+ // host one of the selected profiles covers.
155
+ function findBaseURLProblem(baseURL: string, hosts: readonly string[]): string | null {
156
+ let url: URL;
157
+
158
+ try {
159
+ url = new URL(baseURL);
160
+ } catch {
161
+ return 'baseURL must be an https URL with no port or user info';
162
+ }
163
+
164
+ if (url.protocol !== 'https:' || url.port !== '' || url.username !== '' || url.password !== '') {
165
+ return 'baseURL must be an https URL with no port or user info';
166
+ }
167
+
168
+ if (!hosts.includes(url.hostname)) {
169
+ return `baseURL host ${url.hostname} is not a host of the selected profiles (${hosts.join(', ')})`;
170
+ }
171
+
172
+ return null;
173
+ }
@@ -0,0 +1,326 @@
1
+ import type { AgentID } from './agent-id';
2
+ import { checkGatewayAuth } from './check-gateway-auth';
3
+ import type { GatewayAuth } from './check-gateway-auth';
4
+ import type { AuthProfile } from './collect-auth-profiles';
5
+ import { collectClaudeAuth } from './collect-claude-auth';
6
+ import { isSubscriptionOverrideVariable } from './is-subscription-override-variable';
7
+ import { isRecord } from './report';
8
+
9
+ /**
10
+ * The agent CLI an entry drives, which decides the adapter behind it.
11
+ */
12
+ type AgentKind = 'claude' | 'codex' | 'grok';
13
+
14
+ /**
15
+ * One agent atc offers: its id, how it appears in the spawn menu, the binary
16
+ * and arguments it starts with, and, for a Claude entry, the settings, the
17
+ * environment, and the backend and credential it runs against. A Claude entry
18
+ * with a `baseURL` is a gateway; without one it is stock Claude, whose
19
+ * `auth` holds profiles alone, for the subscription sign-in.
20
+ */
21
+ export interface AgentEntry {
22
+ readonly id: AgentID;
23
+ readonly kind: AgentKind;
24
+ readonly label: string;
25
+ readonly mark: string;
26
+ readonly bin: string;
27
+ readonly args: readonly string[];
28
+ readonly env: Readonly<Record<string, string>>;
29
+ readonly settings?: Readonly<Record<string, unknown>>;
30
+ readonly baseURL?: string;
31
+ readonly apiKeyHelper?: string;
32
+ readonly auth?: GatewayAuth;
33
+ }
34
+
35
+ interface CollectedAgents {
36
+ readonly agents: AgentEntry[];
37
+
38
+ // One line per problem, each starting with the entry it refused.
39
+ readonly errors: string[];
40
+ }
41
+
42
+ /**
43
+ * Reads the `agents` map into registry order. An entry that is not an object,
44
+ * sets an unknown field or a field outside its kind, holds a wrong-typed
45
+ * value, or fails an auth check is left out with every problem that refused
46
+ * it, and the other entries load. A value that is not an object leaves the
47
+ * registry empty.
48
+ */
49
+ export function collectAgents(
50
+ raw: unknown,
51
+ authProfiles: ReadonlyMap<string, AuthProfile> = new Map(),
52
+ ): CollectedAgents {
53
+ if (!isRecord(raw) || Array.isArray(raw)) {
54
+ return { agents: [], errors: ['agents must be an object of agent entries'] };
55
+ }
56
+
57
+ const agents: AgentEntry[] = [];
58
+ const errors: string[] = [];
59
+
60
+ for (const [id, entry] of Object.entries(raw)) {
61
+ const parsed = parseAgentEntry(id, entry, authProfiles);
62
+
63
+ if ('problems' in parsed) {
64
+ errors.push(...parsed.problems.map((problem) => `agents.${id}: ${problem}`));
65
+ } else {
66
+ agents.push(parsed.entry);
67
+ }
68
+ }
69
+
70
+ return { agents, errors };
71
+ }
72
+
73
+ const KINDS: readonly AgentKind[] = ['claude', 'codex', 'grok'];
74
+
75
+ function isAgentKind(value: unknown): value is AgentKind {
76
+ return KINDS.some((kind) => kind === value);
77
+ }
78
+
79
+ // The fields every kind takes, and the ones only a Claude entry takes.
80
+ const COMMON_FIELDS = new Set(['kind', 'label', 'mark', 'bin', 'args']);
81
+ const CLAUDE_FIELDS = new Set(['settings', 'env', 'baseURL', 'apiKeyHelper', 'auth']);
82
+
83
+ const DEFAULT_LABELS: Readonly<Record<AgentKind, string>> = {
84
+ claude: 'Claude',
85
+ codex: 'Codex',
86
+ grok: 'Grok',
87
+ };
88
+
89
+ type ParsedEntry = { readonly entry: AgentEntry } | { readonly problems: string[] };
90
+
91
+ function parseAgentEntry(
92
+ id: string,
93
+ raw: unknown,
94
+ authProfiles: ReadonlyMap<string, AuthProfile>,
95
+ ): ParsedEntry {
96
+ if (id === '' || id === '__proto__') {
97
+ return { problems: ['the id cannot be used'] };
98
+ }
99
+
100
+ if (!isRecord(raw) || Array.isArray(raw)) {
101
+ return { problems: ['the entry must be an object'] };
102
+ }
103
+
104
+ const found = findKind(id, raw['kind']);
105
+
106
+ if ('problem' in found) {
107
+ return { problems: [found.problem] };
108
+ }
109
+
110
+ const problems = collectFieldProblems(raw, found.kind);
111
+
112
+ if (problems.length > 0) {
113
+ return { problems };
114
+ }
115
+
116
+ const entry = buildEntry(id, found.kind, raw);
117
+
118
+ if (found.kind !== 'claude') {
119
+ return { entry };
120
+ }
121
+
122
+ const claude = readClaudeAuth(entry, raw['auth'], authProfiles);
123
+
124
+ if ('problems' in claude) {
125
+ return claude;
126
+ }
127
+
128
+ return { entry: { ...entry, ...claude } };
129
+ }
130
+
131
+ // The entry's kind, or the problem that refuses it. An absent kind is the id
132
+ // for one of the three ids that name a kind, and a problem for any other.
133
+ function findKind(
134
+ id: string,
135
+ raw: unknown,
136
+ ): { readonly kind: AgentKind } | { readonly problem: string } {
137
+ const named = KINDS.find((kind) => kind === id);
138
+
139
+ if (raw === undefined) {
140
+ return named === undefined
141
+ ? { problem: 'kind is required for an id other than claude, codex, or grok' }
142
+ : { kind: named };
143
+ }
144
+
145
+ if (!isAgentKind(raw)) {
146
+ return { problem: 'kind must be claude, codex, or grok' };
147
+ }
148
+
149
+ if (named !== undefined && named !== raw) {
150
+ return { problem: `kind ${raw} contradicts the id ${id}` };
151
+ }
152
+
153
+ return { kind: raw };
154
+ }
155
+
156
+ function collectFieldProblems(raw: Readonly<Record<string, unknown>>, kind: AgentKind): string[] {
157
+ const problems: string[] = [];
158
+
159
+ for (const [name, value] of Object.entries(raw)) {
160
+ if (CLAUDE_FIELDS.has(name) && kind !== 'claude') {
161
+ problems.push(`${name} is not valid for kind ${kind}`);
162
+ continue;
163
+ }
164
+
165
+ if (!COMMON_FIELDS.has(name) && !CLAUDE_FIELDS.has(name)) {
166
+ problems.push(`unknown field ${name}`);
167
+ continue;
168
+ }
169
+
170
+ const problem = findFieldProblem(name, value);
171
+
172
+ if (problem !== null) {
173
+ problems.push(problem);
174
+ }
175
+ }
176
+
177
+ return problems;
178
+ }
179
+
180
+ // The problem with one field's value, or null. `kind` and `auth` are checked
181
+ // elsewhere.
182
+ function findFieldProblem(name: string, value: unknown): string | null {
183
+ switch (name) {
184
+ case 'label':
185
+ case 'mark':
186
+ case 'bin':
187
+ case 'baseURL':
188
+ case 'apiKeyHelper': {
189
+ return typeof value === 'string' && value !== ''
190
+ ? null
191
+ : `${name} must be a non-empty string`;
192
+ }
193
+ case 'args': {
194
+ return Array.isArray(value) && value.every((arg) => typeof arg === 'string')
195
+ ? null
196
+ : 'args must be an array of strings';
197
+ }
198
+ case 'env': {
199
+ return isStringRecord(value) ? null : 'env must be an object of strings';
200
+ }
201
+ case 'settings': {
202
+ return isRecord(value) && !Array.isArray(value) ? null : 'settings must be an object';
203
+ }
204
+ default: {
205
+ return null;
206
+ }
207
+ }
208
+ }
209
+
210
+ function isStringRecord(value: unknown): value is Record<string, string> {
211
+ return (
212
+ isRecord(value) &&
213
+ !Array.isArray(value) &&
214
+ Object.values(value).every((v) => typeof v === 'string')
215
+ );
216
+ }
217
+
218
+ // An entry with every default applied, from fields already checked.
219
+ function buildEntry(
220
+ id: string,
221
+ kind: AgentKind,
222
+ raw: Readonly<Record<string, unknown>>,
223
+ ): AgentEntry {
224
+ const label = raw['label'];
225
+ const mark = raw['mark'];
226
+ const bin = raw['bin'];
227
+ const args = raw['args'];
228
+ const env = raw['env'];
229
+ const settings = raw['settings'];
230
+ const baseURL = raw['baseURL'];
231
+ const apiKeyHelper = raw['apiKeyHelper'];
232
+ const markSource = typeof mark === 'string' ? mark : id;
233
+
234
+ return {
235
+ id,
236
+ kind,
237
+ label: typeof label === 'string' ? label : buildDefaultLabel(id, kind),
238
+ mark: String.fromCodePoint(markSource.codePointAt(0) ?? 0),
239
+ bin: typeof bin === 'string' ? bin : kind,
240
+ args: Array.isArray(args) ? args.map(String) : [],
241
+ env: isStringRecord(env) ? env : {},
242
+ ...(isRecord(settings) ? { settings } : {}),
243
+ ...(typeof baseURL === 'string' ? { baseURL } : {}),
244
+ ...(typeof apiKeyHelper === 'string' ? { apiKeyHelper } : {}),
245
+ };
246
+ }
247
+
248
+ // The three ids that name a kind show that kind's name; any other id shows
249
+ // itself.
250
+ function buildDefaultLabel(id: string, kind: AgentKind): string {
251
+ return id === kind ? DEFAULT_LABELS[kind] : id;
252
+ }
253
+
254
+ // The `auth` a Claude entry holds, or every problem with it. With a base URL
255
+ // the entry is a gateway and its auth is the gateway's. Without one it is
256
+ // stock Claude, whose auth is profiles alone and whose environment may not
257
+ // override or route around the subscription sign-in.
258
+ function readClaudeAuth(
259
+ entry: AgentEntry,
260
+ raw: unknown,
261
+ authProfiles: ReadonlyMap<string, AuthProfile>,
262
+ ): { readonly auth?: GatewayAuth } | { readonly problems: string[] } {
263
+ if (entry.baseURL !== undefined) {
264
+ if (raw === undefined) {
265
+ return {};
266
+ }
267
+
268
+ const checked = checkGatewayAuth(
269
+ raw,
270
+ {
271
+ baseURL: entry.baseURL,
272
+ apiKeyHelper: entry.apiKeyHelper,
273
+ env: entry.env,
274
+ settings: entry.settings,
275
+ },
276
+ authProfiles,
277
+ );
278
+
279
+ return Array.isArray(checked) ? { problems: checked } : { auth: checked };
280
+ }
281
+
282
+ const problems =
283
+ entry.apiKeyHelper === undefined ? [] : ['apiKeyHelper is only valid together with baseURL'];
284
+
285
+ if (raw === undefined) {
286
+ return problems.length > 0 ? { problems } : {};
287
+ }
288
+
289
+ const collected = collectClaudeAuth(raw, authProfiles, 'auth');
290
+
291
+ problems.push(...collected.errors, ...collectSubscriptionProblems(entry));
292
+
293
+ if (problems.length > 0 || collected.auth === null) {
294
+ return { problems };
295
+ }
296
+
297
+ return { auth: { profiles: collected.auth.profiles, placeholderEnv: {} } };
298
+ }
299
+
300
+ // What a stock entry with `auth` sets that would override the subscription
301
+ // sign-in or route the CLI around impd's broker.
302
+ function collectSubscriptionProblems(entry: AgentEntry): string[] {
303
+ const settingsEnv = entry.settings?.['env'];
304
+
305
+ const settingsKeys =
306
+ isRecord(settingsEnv) && !Array.isArray(settingsEnv) ? Object.keys(settingsEnv) : [];
307
+
308
+ const problems = [
309
+ ...Object.keys(entry.env)
310
+ .filter((key) => isSubscriptionOverrideVariable(key))
311
+ .map((key) => `env must not set ${key}${OVERRIDE_REASON}`),
312
+ ...settingsKeys
313
+ .filter((key) => isSubscriptionOverrideVariable(key))
314
+ .map((key) => `settings.env must not set ${key}${OVERRIDE_REASON}`),
315
+ ];
316
+
317
+ if (entry.settings?.['apiKeyHelper'] !== undefined) {
318
+ problems.push(
319
+ 'settings.apiKeyHelper must not be set, which would override the subscription sign-in',
320
+ );
321
+ }
322
+
323
+ return problems;
324
+ }
325
+
326
+ const OVERRIDE_REASON = ', which would override or route around the subscription sign-in';
@@ -8,7 +8,7 @@ import { resolveAuthProfiles } from './resolve-auth-profiles';
8
8
  * token to the Anthropic API as a bearer authorization header, and the
9
9
  * rest bind beside it, such as a GitHub token.
10
10
  */
11
- export interface ClaudeAuth {
11
+ interface ClaudeAuth {
12
12
  readonly profiles: readonly string[];
13
13
  }
14
14
 
@@ -18,7 +18,8 @@ interface CollectedClaudeAuth {
18
18
  }
19
19
 
20
20
  /**
21
- * Reads the `claudeAuth` entry against the auth profiles. An entry that
21
+ * Reads a Claude subscription auth entry against the auth profiles, `field`
22
+ * being the key it was set under in error text. An entry that
22
23
  * does not resolve, or whose profiles send no bearer authorization header
23
24
  * to the Anthropic API, is left out with every problem that refused it, so
24
25
  * stock Claude keeps the sign-in of the host it runs on rather than binding
@@ -27,6 +28,7 @@ interface CollectedClaudeAuth {
27
28
  export function collectClaudeAuth(
28
29
  raw: unknown,
29
30
  authProfiles: ReadonlyMap<string, AuthProfile>,
31
+ field = 'claudeAuth',
30
32
  ): CollectedClaudeAuth {
31
33
  if (raw === undefined) {
32
34
  return { auth: null, errors: [] };
@@ -42,7 +44,7 @@ export function collectClaudeAuth(
42
44
  ) {
43
45
  return {
44
46
  auth: null,
45
- errors: ['claudeAuth must be an object with a non-empty profiles array'],
47
+ errors: [`${field} must be an object with a non-empty profiles array`],
46
48
  };
47
49
  }
48
50
 
@@ -52,7 +54,7 @@ export function collectClaudeAuth(
52
54
  return {
53
55
  auth: null,
54
56
  errors: [
55
- `claudeAuth.${extra} cannot be set: atc fixes the endpoint and the placeholder of a Claude subscription session`,
57
+ `${field}.${extra} cannot be set: atc fixes the endpoint and the placeholder of a Claude subscription session`,
56
58
  ],
57
59
  };
58
60
  }
@@ -61,7 +63,7 @@ export function collectClaudeAuth(
61
63
  const resolution = resolveAuthProfiles(authProfiles, selected);
62
64
 
63
65
  if ('problem' in resolution) {
64
- return { auth: null, errors: [`claudeAuth: ${resolution.problem.message}`] };
66
+ return { auth: null, errors: [`${field}: ${resolution.problem.message}`] };
65
67
  }
66
68
 
67
69
  const rule = resolution.resolved.secrets
@@ -72,7 +74,7 @@ export function collectClaudeAuth(
72
74
  return {
73
75
  auth: null,
74
76
  errors: [
75
- `claudeAuth needs a profile that sets a bearer authorization header for ${ANTHROPIC_API_HOST}, where Claude Code sends its subscription token`,
77
+ `${field} needs a profile that sets a bearer authorization header for ${ANTHROPIC_API_HOST}, where Claude Code sends its subscription token`,
76
78
  ],
77
79
  };
78
80
  }