@zgeoff/atc 2.36.1 → 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.
- package/README.md +2 -1
- package/package.json +1 -1
- package/src/agents/agent-adapter.ts +6 -1
- package/src/agents/build-agent-adapters.ts +32 -0
- package/src/agents/claude-adapter.ts +145 -65
- package/src/agents/codex-adapter.ts +17 -11
- package/src/agents/gateway-adapter.ts +17 -1
- package/src/agents/grok-adapter.ts +15 -9
- package/src/build-migrated-config.ts +184 -0
- package/src/cli.ts +49 -17
- package/src/client/collect-agent-picks.ts +3 -10
- package/src/client/index.ts +1 -1
- package/src/client/resolve-agent-metadata.ts +4 -10
- package/src/client/spawn-picker.ts +1 -2
- package/src/daemon/build-auth-binding.ts +2 -1
- package/src/daemon/daemon-connection.ts +1 -1
- package/src/daemon/daemon.ts +12 -3
- package/src/daemon/sessions.ts +8 -2
- package/src/mcp/build-spawn-descriptions.ts +2 -2
- package/src/run-config-migrate.ts +85 -0
- package/src/shared/check-gateway-auth.ts +173 -0
- package/src/shared/collect-agents.ts +326 -0
- package/src/shared/collect-claude-auth.ts +8 -6
- package/src/shared/collect-gateways.ts +2 -168
- package/src/shared/collect-legacy-agents.ts +77 -0
- package/src/shared/config.ts +70 -60
- package/src/shared/format-mixed-agent-keys.ts +10 -0
- package/src/shared/is-subscription-override-variable.ts +14 -0
- package/src/shared/legacy-agent-keys.ts +15 -0
- package/src/shared/outranking-variables.ts +17 -0
- package/src/shared/pick-default-agent.ts +12 -0
|
@@ -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
|
-
|
|
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
|
|
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: [
|
|
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
|
-
|
|
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: [
|
|
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
|
-
|
|
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
|
}
|