@zgeoff/atc 2.17.0 → 2.20.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 +1 -1
- package/src/agents/agent-adapter.ts +5 -0
- package/src/agents/gateway-adapter.ts +25 -2
- package/src/cli.ts +6 -1
- package/src/client/build-workspace-destination.ts +38 -0
- package/src/client/collect-agent-picks.ts +5 -2
- package/src/client/pick-refusal-step.ts +29 -0
- package/src/client/resolve-workspace-root.ts +49 -0
- package/src/client/spawn-picker.ts +1655 -182
- package/src/daemon/build-agent-list.ts +8 -6
- package/src/daemon/build-auth-binding.ts +58 -0
- package/src/daemon/build-imp-name.ts +11 -0
- package/src/daemon/build-imp-provider.ts +25 -2
- package/src/daemon/daemon-connection.ts +7 -0
- package/src/daemon/imp-port.ts +4 -2
- package/src/daemon/imp-provider.ts +24 -19
- package/src/daemon/restore-fleet.ts +2 -1
- package/src/daemon/sessions.ts +6 -0
- package/src/protocol/protocol.ts +1 -0
- package/src/shared/collect-auth-profiles.ts +122 -0
- package/src/shared/collect-gateways.ts +223 -6
- package/src/shared/collect-workspaces-config.ts +25 -10
- package/src/shared/config.ts +49 -6
- package/src/shared/resolve-auth-profiles.ts +186 -0
- package/src/store/run-migrations.ts +77 -0
- package/src/store/runtime-auth-binding.ts +110 -0
- package/src/store/state-store.ts +186 -0
|
@@ -1,13 +1,26 @@
|
|
|
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 type { AuthProfile } from './collect-auth-profiles';
|
|
4
5
|
import { isRecord } from './report';
|
|
6
|
+
import { resolveAuthProfiles } from './resolve-auth-profiles';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The auth profiles a gateway's sessions are bound to through impd's
|
|
10
|
+
* broker, and the variables each session gets in place of a credential,
|
|
11
|
+
* every value the fixed placeholder.
|
|
12
|
+
*/
|
|
13
|
+
export interface GatewayAuth {
|
|
14
|
+
readonly profiles: readonly string[];
|
|
15
|
+
readonly placeholderEnv: Readonly<Record<string, string>>;
|
|
16
|
+
}
|
|
5
17
|
|
|
6
18
|
/**
|
|
7
19
|
* A Claude-compatible backend the Claude CLI is pointed at: its own agent id,
|
|
8
20
|
* its own row in the spawn menu, and its own generated settings file. The
|
|
9
|
-
* credential is never held here
|
|
10
|
-
*
|
|
21
|
+
* credential is never held here: a helper command supplies it at run time,
|
|
22
|
+
* or, with `auth`, impd's credential broker adds it to each request on the
|
|
23
|
+
* host's side, so it stays out of the file atc writes.
|
|
11
24
|
*/
|
|
12
25
|
export interface GatewayConfig {
|
|
13
26
|
readonly id: AgentID;
|
|
@@ -19,6 +32,14 @@ export interface GatewayConfig {
|
|
|
19
32
|
readonly apiKeyHelper?: string;
|
|
20
33
|
readonly env: Readonly<Record<string, string>>;
|
|
21
34
|
readonly settings?: Readonly<Record<string, unknown>>;
|
|
35
|
+
readonly auth?: GatewayAuth;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface Gateways {
|
|
39
|
+
readonly gateways: GatewayConfig[];
|
|
40
|
+
|
|
41
|
+
// The problems that kept a gateway with `auth` out, one per problem.
|
|
42
|
+
readonly errors: string[];
|
|
22
43
|
}
|
|
23
44
|
|
|
24
45
|
// Ids the built-in adapters answer to; a gateway may not take one.
|
|
@@ -41,18 +62,22 @@ const GATEWAY_ENTRY_SCHEMA = z.object({
|
|
|
41
62
|
/**
|
|
42
63
|
* Reads the gateway map into menu order. An entry without a base URL, or
|
|
43
64
|
* under an id another adapter already answers to, is left out rather than
|
|
44
|
-
* registered half-formed: every gateway in the result can be spawned.
|
|
65
|
+
* registered half-formed: every gateway in the result can be spawned. A
|
|
66
|
+
* gateway with `auth` is checked against the auth profiles, and one that
|
|
67
|
+
* fails a check is left out with an error for each problem.
|
|
45
68
|
*/
|
|
46
69
|
export function collectGateways(
|
|
47
70
|
raw: unknown,
|
|
48
71
|
claudeBin: string,
|
|
49
72
|
claudeArgs: readonly string[],
|
|
50
|
-
|
|
73
|
+
authProfiles: ReadonlyMap<string, AuthProfile> = new Map(),
|
|
74
|
+
): Gateways {
|
|
51
75
|
if (!isRecord(raw)) {
|
|
52
|
-
return [];
|
|
76
|
+
return { gateways: [], errors: [] };
|
|
53
77
|
}
|
|
54
78
|
|
|
55
79
|
const gateways: GatewayConfig[] = [];
|
|
80
|
+
const errors: string[] = [];
|
|
56
81
|
|
|
57
82
|
for (const [id, entry] of Object.entries(raw)) {
|
|
58
83
|
if (id === '' || BUILT_IN_IDS.has(id)) {
|
|
@@ -65,6 +90,19 @@ export function collectGateways(
|
|
|
65
90
|
continue;
|
|
66
91
|
}
|
|
67
92
|
|
|
93
|
+
let auth: GatewayAuth | undefined;
|
|
94
|
+
|
|
95
|
+
if (isRecord(entry) && entry['auth'] !== undefined) {
|
|
96
|
+
const checked = checkGatewayAuth(entry['auth'], parsed.data, authProfiles);
|
|
97
|
+
|
|
98
|
+
if (Array.isArray(checked)) {
|
|
99
|
+
errors.push(...checked.map((problem) => `gateways.${id}: ${problem}`));
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
auth = checked;
|
|
104
|
+
}
|
|
105
|
+
|
|
68
106
|
const firstOfMark = (parsed.data.mark ?? id).codePointAt(0);
|
|
69
107
|
|
|
70
108
|
gateways.push({
|
|
@@ -79,10 +117,189 @@ export function collectGateways(
|
|
|
79
117
|
...(parsed.data.apiKeyHelper === undefined ? {} : { apiKeyHelper: parsed.data.apiKeyHelper }),
|
|
80
118
|
env: parsed.data.env,
|
|
81
119
|
...(parsed.data.settings === undefined ? {} : { settings: parsed.data.settings }),
|
|
120
|
+
...(auth === undefined ? {} : { auth }),
|
|
82
121
|
});
|
|
83
122
|
}
|
|
84
123
|
|
|
85
|
-
return gateways;
|
|
124
|
+
return { gateways, errors };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface GatewayEntry {
|
|
128
|
+
readonly baseURL?: string | undefined;
|
|
129
|
+
readonly apiKeyHelper?: string | undefined;
|
|
130
|
+
readonly env: Readonly<Record<string, string>>;
|
|
131
|
+
readonly settings?: Readonly<Record<string, unknown>> | undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The gateway's auth, or every problem that refuses it. The checks cover
|
|
135
|
+
// each place a session's environment comes from: the gateway env, the
|
|
136
|
+
// settings env, and the placeholders, and which of them would win.
|
|
137
|
+
function checkGatewayAuth(
|
|
138
|
+
raw: unknown,
|
|
139
|
+
entry: GatewayEntry,
|
|
140
|
+
authProfiles: ReadonlyMap<string, AuthProfile>,
|
|
141
|
+
): GatewayAuth | string[] {
|
|
142
|
+
const auth = parseGatewayAuth(raw);
|
|
143
|
+
|
|
144
|
+
if (typeof auth === 'string') {
|
|
145
|
+
return [auth];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const problems: string[] = [];
|
|
149
|
+
|
|
150
|
+
if (entry.apiKeyHelper !== undefined) {
|
|
151
|
+
problems.push(
|
|
152
|
+
'apiKeyHelper cannot be set together with auth, which supplies the credential through the broker',
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (entry.settings?.['apiKeyHelper'] !== undefined) {
|
|
157
|
+
problems.push(
|
|
158
|
+
'settings.apiKeyHelper cannot be set together with auth, which supplies the credential through the broker',
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const settingsEnv = toEnvKeys(entry.settings?.['env']);
|
|
163
|
+
|
|
164
|
+
problems.push(
|
|
165
|
+
...collectEnvProblems('env', Object.keys(entry.env)),
|
|
166
|
+
...collectEnvProblems('settings.env', settingsEnv),
|
|
167
|
+
...collectPlaceholderProblems(auth.placeholderEnv),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
for (const key of Object.keys(auth.placeholderEnv)) {
|
|
171
|
+
for (const [source, keys] of [
|
|
172
|
+
['env', Object.keys(entry.env)],
|
|
173
|
+
['settings.env', settingsEnv],
|
|
174
|
+
] as const) {
|
|
175
|
+
if (keys.includes(key)) {
|
|
176
|
+
problems.push(`placeholderEnv.${key} is also set in ${source}, which would override it`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const resolution = resolveAuthProfiles(authProfiles, auth.profiles);
|
|
182
|
+
|
|
183
|
+
if ('problem' in resolution) {
|
|
184
|
+
problems.push(resolution.problem.message);
|
|
185
|
+
} else {
|
|
186
|
+
const hostProblem = findBaseURLProblem(entry.baseURL ?? '', resolution.resolved.hosts);
|
|
187
|
+
|
|
188
|
+
if (hostProblem !== null) {
|
|
189
|
+
problems.push(hostProblem);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return problems.length > 0 ? problems : auth;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// The value every placeholder variable holds; impd's broker swaps the real
|
|
197
|
+
// credential in on the host's side.
|
|
198
|
+
const PLACEHOLDER = 'imp-broker-placeholder';
|
|
199
|
+
|
|
200
|
+
function parseGatewayAuth(raw: unknown): GatewayAuth | string {
|
|
201
|
+
const profiles = isRecord(raw) ? raw['profiles'] : undefined;
|
|
202
|
+
|
|
203
|
+
if (
|
|
204
|
+
!isRecord(raw) ||
|
|
205
|
+
!Array.isArray(profiles) ||
|
|
206
|
+
profiles.length === 0 ||
|
|
207
|
+
!profiles.every((name) => typeof name === 'string')
|
|
208
|
+
) {
|
|
209
|
+
return 'auth must be an object with a non-empty profiles array';
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const placeholderEnv = raw['placeholderEnv'] ?? {};
|
|
213
|
+
|
|
214
|
+
if (!isRecord(placeholderEnv) || Array.isArray(placeholderEnv)) {
|
|
215
|
+
return 'placeholderEnv must be an object of variable names';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const env: Record<string, string> = {};
|
|
219
|
+
|
|
220
|
+
for (const [key, value] of Object.entries(placeholderEnv)) {
|
|
221
|
+
if (value !== PLACEHOLDER) {
|
|
222
|
+
return `placeholderEnv.${key} must be ${PLACEHOLDER}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
env[key] = value;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return { profiles: profiles.map(String), placeholderEnv: env };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// A settings env's variable names; anything but an object sets none.
|
|
232
|
+
function toEnvKeys(value: unknown): string[] {
|
|
233
|
+
return isRecord(value) && !Array.isArray(value) ? Object.keys(value) : [];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Variables Claude reads its endpoint and credential from. Only the
|
|
237
|
+
// placeholders may set a credential variable, and the base URL comes from
|
|
238
|
+
// `baseURL` alone, whose host is checked against the profiles.
|
|
239
|
+
const CREDENTIAL_VARIABLES: ReadonlySet<string> = new Set([
|
|
240
|
+
'ANTHROPIC_AUTH_TOKEN',
|
|
241
|
+
'ANTHROPIC_API_KEY',
|
|
242
|
+
]);
|
|
243
|
+
|
|
244
|
+
const BASE_URL_VARIABLE = 'ANTHROPIC_BASE_URL';
|
|
245
|
+
|
|
246
|
+
function collectEnvProblems(source: string, keys: readonly string[]): string[] {
|
|
247
|
+
return keys
|
|
248
|
+
.filter(
|
|
249
|
+
(key) => isBrokerVariable(key) || CREDENTIAL_VARIABLES.has(key) || key === BASE_URL_VARIABLE,
|
|
250
|
+
)
|
|
251
|
+
.map((key) => `${source} must not set ${key}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Variables impd sets in a brokered exec to route requests through the
|
|
255
|
+
// broker and trust its CA. impd lets a variable the caller sets win, so a
|
|
256
|
+
// configured one would route around the broker or mask a failed CA install.
|
|
257
|
+
const BROKER_VARIABLES: ReadonlySet<string> = new Set([
|
|
258
|
+
'HTTPS_PROXY',
|
|
259
|
+
'HTTP_PROXY',
|
|
260
|
+
'ALL_PROXY',
|
|
261
|
+
'NO_PROXY',
|
|
262
|
+
'NODE_USE_ENV_PROXY',
|
|
263
|
+
'SSL_CERT_FILE',
|
|
264
|
+
'SSL_CERT_DIR',
|
|
265
|
+
'NODE_EXTRA_CA_CERTS',
|
|
266
|
+
'GIT_SSL_CAINFO',
|
|
267
|
+
'REQUESTS_CA_BUNDLE',
|
|
268
|
+
'CURL_CA_BUNDLE',
|
|
269
|
+
]);
|
|
270
|
+
|
|
271
|
+
// Proxy and CA variables are read in either case by some clients.
|
|
272
|
+
function isBrokerVariable(key: string): boolean {
|
|
273
|
+
return BROKER_VARIABLES.has(key.toUpperCase());
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function collectPlaceholderProblems(env: Readonly<Record<string, string>>): string[] {
|
|
277
|
+
return Object.keys(env)
|
|
278
|
+
.filter((key) => isBrokerVariable(key) || key === BASE_URL_VARIABLE)
|
|
279
|
+
.map((key) => `placeholderEnv must not set ${key}`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// The broker matches a request by the exact host it connects to on the
|
|
283
|
+
// default https port, so the base URL must be https on that port, with a
|
|
284
|
+
// host one of the selected profiles covers.
|
|
285
|
+
function findBaseURLProblem(baseURL: string, hosts: readonly string[]): string | null {
|
|
286
|
+
let url: URL;
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
url = new URL(baseURL);
|
|
290
|
+
} catch {
|
|
291
|
+
return 'baseURL must be an https URL with no port or user info';
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (url.protocol !== 'https:' || url.port !== '' || url.username !== '' || url.password !== '') {
|
|
295
|
+
return 'baseURL must be an https URL with no port or user info';
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (!hosts.includes(url.hostname)) {
|
|
299
|
+
return `baseURL host ${url.hostname} is not a host of the selected profiles (${hosts.join(', ')})`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return null;
|
|
86
303
|
}
|
|
87
304
|
|
|
88
305
|
// Claude Code settings the sessions of this gateway are started with, on top of
|
|
@@ -2,15 +2,21 @@ import { DEFAULT_GIT_TRANSPORTS } from './default-git-transports';
|
|
|
2
2
|
import { isRecord } from './report';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Where workspace sources come from
|
|
6
|
-
* the spawn picker lists by default, or
|
|
7
|
-
*
|
|
8
|
-
* default order;
|
|
5
|
+
* Where workspace sources come from and where their checkouts land: the
|
|
6
|
+
* GitHub owner whose repositories the spawn picker lists by default, or
|
|
7
|
+
* null to list the gh account's own; the ids of the sources the picker
|
|
8
|
+
* offers, in order, or null for the default order; the git transports the
|
|
9
|
+
* daemon fetches over; the root a checkout lands under on any target
|
|
10
|
+
* without its own, or null for the default; and each target's own root, by
|
|
11
|
+
* target id. A root is kept as written: whether it fits its target, such
|
|
12
|
+
* as `~` on a remote target, is checked where the target is known.
|
|
9
13
|
*/
|
|
10
14
|
export interface WorkspacesConfig {
|
|
11
15
|
readonly githubOwner: string | null;
|
|
12
16
|
readonly sources: readonly string[] | null;
|
|
13
17
|
readonly gitTransports: readonly string[] | InvalidGitTransports;
|
|
18
|
+
readonly root: string | null;
|
|
19
|
+
readonly targetRoots: ReadonlyMap<string, string>;
|
|
14
20
|
}
|
|
15
21
|
|
|
16
22
|
/**
|
|
@@ -34,17 +40,20 @@ const GITHUB_OWNER_PATTERN = /^[A-Za-z\d][A-Za-z\d-]{0,38}$/u;
|
|
|
34
40
|
/**
|
|
35
41
|
* Reads the `workspaces` section of config.json. An owner that is not a
|
|
36
42
|
* GitHub login is dropped, so a typo lists the gh account's own
|
|
37
|
-
* repositories instead of failing,
|
|
38
|
-
*
|
|
43
|
+
* repositories instead of failing, a source order that is not a list of
|
|
44
|
+
* ids falls back to the default order, and a root that is not a non-empty
|
|
45
|
+
* string is dropped. A transport list that is not a
|
|
39
46
|
* list of transports atc allows is a config error, and the list is then
|
|
40
47
|
* invalid, never the default, so the daemon runs no git until it is fixed.
|
|
41
48
|
* An empty list is valid and allows no transport.
|
|
42
49
|
*/
|
|
43
50
|
export function collectWorkspacesConfig(raw: unknown): CollectedWorkspacesConfig {
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
const
|
|
51
|
+
const section = isRecord(raw) ? raw : {};
|
|
52
|
+
const owner = section['githubOwner'];
|
|
53
|
+
const sources = section['sources'];
|
|
54
|
+
const root = section['root'];
|
|
55
|
+
const targets = isRecord(section['targets']) ? section['targets'] : {};
|
|
56
|
+
const transports = collectGitTransports(section['gitTransports']);
|
|
48
57
|
|
|
49
58
|
return {
|
|
50
59
|
workspaces: {
|
|
@@ -55,6 +64,12 @@ export function collectWorkspacesConfig(raw: unknown): CollectedWorkspacesConfig
|
|
|
55
64
|
? sources
|
|
56
65
|
: null,
|
|
57
66
|
gitTransports: transports.transports,
|
|
67
|
+
root: typeof root === 'string' && root !== '' ? root : null,
|
|
68
|
+
targetRoots: new Map(
|
|
69
|
+
Object.entries(targets).flatMap(([id, dir]) =>
|
|
70
|
+
typeof dir === 'string' && dir !== '' ? [[id, dir] as const] : [],
|
|
71
|
+
),
|
|
72
|
+
),
|
|
58
73
|
},
|
|
59
74
|
errors: transports.errors,
|
|
60
75
|
};
|
package/src/shared/config.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { dirname, join } from 'node:path';
|
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { buildOptionalString } from './build-optional-string';
|
|
5
5
|
import { buildOptionalStringArray } from './build-optional-string-array';
|
|
6
|
+
import { collectAuthProfiles } from './collect-auth-profiles';
|
|
7
|
+
import type { AuthProfile } from './collect-auth-profiles';
|
|
6
8
|
import { collectDirRoots } from './collect-dir-roots';
|
|
7
9
|
import { collectGateways } from './collect-gateways';
|
|
8
10
|
import type { GatewayConfig } from './collect-gateways';
|
|
@@ -28,6 +30,14 @@ export interface Config {
|
|
|
28
30
|
dirs: DirsConfig;
|
|
29
31
|
workspaces: WorkspacesConfig;
|
|
30
32
|
gateways: GatewayConfig[];
|
|
33
|
+
|
|
34
|
+
// The problems that kept a gateway with auth out of the gateways.
|
|
35
|
+
gatewayErrors: readonly string[];
|
|
36
|
+
|
|
37
|
+
// The credential references a gateway's auth selects from, by profile
|
|
38
|
+
// name, and the problems that kept a profile out.
|
|
39
|
+
authProfiles: ReadonlyMap<string, AuthProfile>;
|
|
40
|
+
authProfileErrors: readonly string[];
|
|
31
41
|
hooks: HooksConfig;
|
|
32
42
|
leader: LeaderKey;
|
|
33
43
|
|
|
@@ -70,8 +80,17 @@ const DEFAULTS: Config = {
|
|
|
70
80
|
codexBin: 'codex',
|
|
71
81
|
codexArgs: [],
|
|
72
82
|
dirs: { roots: [] },
|
|
73
|
-
workspaces: {
|
|
83
|
+
workspaces: {
|
|
84
|
+
githubOwner: null,
|
|
85
|
+
sources: null,
|
|
86
|
+
gitTransports: DEFAULT_GIT_TRANSPORTS,
|
|
87
|
+
root: null,
|
|
88
|
+
targetRoots: new Map(),
|
|
89
|
+
},
|
|
74
90
|
gateways: [],
|
|
91
|
+
gatewayErrors: [],
|
|
92
|
+
authProfiles: new Map(),
|
|
93
|
+
authProfileErrors: [],
|
|
75
94
|
hooks: {},
|
|
76
95
|
leader: { code: 0, label: '^Space' },
|
|
77
96
|
targets: [{ id: 'local', provider: 'local-pty', options: {} }],
|
|
@@ -112,6 +131,7 @@ const CONFIG_SCHEMA = z.object({
|
|
|
112
131
|
dirs: z.unknown().optional(),
|
|
113
132
|
workspaces: z.unknown().optional(),
|
|
114
133
|
gateways: z.unknown().optional(),
|
|
134
|
+
authProfiles: z.unknown().optional(),
|
|
115
135
|
hooks: z.unknown().optional(),
|
|
116
136
|
leader: buildOptionalString(),
|
|
117
137
|
targets: z.unknown().optional(),
|
|
@@ -200,21 +220,33 @@ function tryWriteDefaultConfig(file: string): void {
|
|
|
200
220
|
/**
|
|
201
221
|
* The config.json text a first run writes. It leaves out the targets and
|
|
202
222
|
* principals, so the file holds the one implicit `local` target and no
|
|
203
|
-
* principals until the user sets their own,
|
|
204
|
-
* reports, which belong to no file.
|
|
223
|
+
* principals until the user sets their own, no auth profiles, and the
|
|
224
|
+
* errors a parse reports, which belong to no file.
|
|
205
225
|
*/
|
|
206
226
|
export function renderDefaultConfig(): string {
|
|
207
227
|
const {
|
|
228
|
+
gatewayErrors: _gatewayErrors,
|
|
229
|
+
authProfiles: _authProfiles,
|
|
230
|
+
authProfileErrors: _authProfileErrors,
|
|
208
231
|
targets: _targets,
|
|
209
232
|
defaultTarget: _default,
|
|
210
233
|
targetErrors: _errors,
|
|
211
234
|
principals: _principals,
|
|
212
235
|
principalErrors: _principalErrors,
|
|
213
236
|
workspaceErrors: _workspaceErrors,
|
|
237
|
+
workspaces,
|
|
214
238
|
...written
|
|
215
239
|
} = DEFAULTS;
|
|
216
240
|
|
|
217
|
-
|
|
241
|
+
// The target roots are a map in memory and an object of target ids in
|
|
242
|
+
// the file.
|
|
243
|
+
const { targetRoots, ...rest } = workspaces;
|
|
244
|
+
|
|
245
|
+
return `${JSON.stringify(
|
|
246
|
+
{ ...written, workspaces: { ...rest, targets: Object.fromEntries(targetRoots) } },
|
|
247
|
+
null,
|
|
248
|
+
2,
|
|
249
|
+
)}\n`;
|
|
218
250
|
}
|
|
219
251
|
|
|
220
252
|
/**
|
|
@@ -251,7 +283,15 @@ export function parseConfig(raw: unknown, file: string = configFile): Config {
|
|
|
251
283
|
const codexArgs = parsed.data.codexArgs ?? DEFAULTS.codexArgs;
|
|
252
284
|
const dirs = { roots: collectDirRoots(parsed.data.dirs) };
|
|
253
285
|
const workspaces = collectWorkspacesConfig(parsed.data.workspaces);
|
|
254
|
-
const
|
|
286
|
+
const authProfiles = collectAuthProfiles(parsed.data.authProfiles);
|
|
287
|
+
|
|
288
|
+
const gateways = collectGateways(
|
|
289
|
+
parsed.data.gateways,
|
|
290
|
+
claudeBin,
|
|
291
|
+
claudeArgs,
|
|
292
|
+
authProfiles.profiles,
|
|
293
|
+
);
|
|
294
|
+
|
|
255
295
|
const hooks = collectHooks(parsed.data.hooks);
|
|
256
296
|
const targets = collectTargets(parsed.data.targets, parsed.data.defaultTarget);
|
|
257
297
|
const principals = collectPrincipals(parsed.data.principals);
|
|
@@ -268,7 +308,10 @@ export function parseConfig(raw: unknown, file: string = configFile): Config {
|
|
|
268
308
|
codexArgs,
|
|
269
309
|
dirs,
|
|
270
310
|
workspaces: workspaces.workspaces,
|
|
271
|
-
gateways,
|
|
311
|
+
gateways: gateways.gateways,
|
|
312
|
+
gatewayErrors: gateways.errors,
|
|
313
|
+
authProfiles: authProfiles.profiles,
|
|
314
|
+
authProfileErrors: authProfiles.errors,
|
|
272
315
|
hooks,
|
|
273
316
|
leader,
|
|
274
317
|
targets: targets.targets,
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { AuthProfile } from './collect-auth-profiles';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How impd applies one secret to requests for one host.
|
|
5
|
+
*/
|
|
6
|
+
interface AuthRule {
|
|
7
|
+
readonly host: string;
|
|
8
|
+
readonly header: string;
|
|
9
|
+
readonly scheme: 'bearer';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One secret and the complete set of rules a selection needs impd to hold
|
|
14
|
+
* for it, one per host, sorted by host.
|
|
15
|
+
*/
|
|
16
|
+
export interface ResolvedAuthSecret {
|
|
17
|
+
readonly secret: string;
|
|
18
|
+
readonly kind: 'custom';
|
|
19
|
+
readonly rules: readonly AuthRule[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A selection with its dependencies expanded: every profile it reaches,
|
|
24
|
+
* every host those profiles send a credential to, and the rules grouped by
|
|
25
|
+
* secret, each list sorted so two selections of the same profiles resolve
|
|
26
|
+
* the same.
|
|
27
|
+
*/
|
|
28
|
+
interface ResolvedAuthProfiles {
|
|
29
|
+
readonly profiles: readonly string[];
|
|
30
|
+
readonly hosts: readonly string[];
|
|
31
|
+
readonly secrets: readonly ResolvedAuthSecret[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Why a selection cannot be bound:
|
|
36
|
+
*
|
|
37
|
+
* - `auth_profile_unknown`: it reaches a profile that is missing or that
|
|
38
|
+
* the config refused.
|
|
39
|
+
* - `auth_dependency_cycle`: the dependencies loop back on themselves.
|
|
40
|
+
* - `auth_collision`: two profiles send different credentials or rules to
|
|
41
|
+
* one host, which impd could not tell apart.
|
|
42
|
+
*/
|
|
43
|
+
export interface AuthProfileProblem {
|
|
44
|
+
readonly code: 'auth_profile_unknown' | 'auth_dependency_cycle' | 'auth_collision';
|
|
45
|
+
readonly message: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type AuthProfileResolution =
|
|
49
|
+
| { readonly resolved: ResolvedAuthProfiles }
|
|
50
|
+
| { readonly problem: AuthProfileProblem };
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Expands the selected profiles through their dependencies, then checks
|
|
54
|
+
* the whole expanded set: each host must get exactly one rule, so a
|
|
55
|
+
* credential is never picked by order. Two profiles holding the identical
|
|
56
|
+
* secret and rule for a host merge into one rule.
|
|
57
|
+
*/
|
|
58
|
+
export function resolveAuthProfiles(
|
|
59
|
+
profiles: ReadonlyMap<string, AuthProfile>,
|
|
60
|
+
selected: readonly string[],
|
|
61
|
+
): AuthProfileResolution {
|
|
62
|
+
let reached: ReadonlyMap<string, AuthProfile> = new Map();
|
|
63
|
+
|
|
64
|
+
for (const name of selected) {
|
|
65
|
+
const next = collectReached(profiles, name, null, [], reached);
|
|
66
|
+
|
|
67
|
+
if (isAuthProfileProblem(next)) {
|
|
68
|
+
return { problem: next };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
reached = next;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const ordered = [...reached.values()].toSorted((a, b) => (a.name < b.name ? -1 : 1));
|
|
75
|
+
|
|
76
|
+
const byHost = new Map<string, AuthProfile>();
|
|
77
|
+
|
|
78
|
+
for (const profile of ordered) {
|
|
79
|
+
const other = byHost.get(profile.host);
|
|
80
|
+
|
|
81
|
+
if (other === undefined) {
|
|
82
|
+
byHost.set(profile.host, profile);
|
|
83
|
+
} else if (!hasSameRule(other, profile)) {
|
|
84
|
+
return {
|
|
85
|
+
problem: {
|
|
86
|
+
code: 'auth_collision',
|
|
87
|
+
message: `profiles ${other.name} and ${profile.name} both send a credential to ${profile.host}`,
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
resolved: {
|
|
95
|
+
profiles: ordered.map((profile) => profile.name),
|
|
96
|
+
hosts: [...byHost.keys()].toSorted(),
|
|
97
|
+
secrets: buildSecrets([...byHost.values()]),
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Depth-first over the dependencies from one name: returns `reached` plus
|
|
103
|
+
// every profile this name reaches, each added once its own dependencies are
|
|
104
|
+
// done. A profile already reached is not walked again, so a shared
|
|
105
|
+
// dependency costs one visit.
|
|
106
|
+
// `path` holds the names on the way here, so a name already on it closes a
|
|
107
|
+
// cycle.
|
|
108
|
+
function collectReached(
|
|
109
|
+
profiles: ReadonlyMap<string, AuthProfile>,
|
|
110
|
+
name: string,
|
|
111
|
+
from: string | null,
|
|
112
|
+
path: readonly string[],
|
|
113
|
+
reached: ReadonlyMap<string, AuthProfile>,
|
|
114
|
+
): ReadonlyMap<string, AuthProfile> | AuthProfileProblem {
|
|
115
|
+
if (path.includes(name)) {
|
|
116
|
+
return {
|
|
117
|
+
code: 'auth_dependency_cycle',
|
|
118
|
+
message: `profile dependencies form a cycle: ${[...path.slice(path.indexOf(name)), name].join(' -> ')}`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (reached.has(name)) {
|
|
123
|
+
return reached;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const profile = profiles.get(name);
|
|
127
|
+
|
|
128
|
+
if (profile === undefined) {
|
|
129
|
+
return {
|
|
130
|
+
code: 'auth_profile_unknown',
|
|
131
|
+
message:
|
|
132
|
+
from === null
|
|
133
|
+
? `profile ${name} is selected, but authProfiles has no usable profile by that name`
|
|
134
|
+
: `profile ${from} depends on ${name}, but authProfiles has no usable profile by that name`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let next = reached;
|
|
139
|
+
|
|
140
|
+
for (const dependency of profile.dependencies) {
|
|
141
|
+
const after = collectReached(profiles, dependency, name, [...path, name], next);
|
|
142
|
+
|
|
143
|
+
if (isAuthProfileProblem(after)) {
|
|
144
|
+
return after;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
next = after;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return new Map([...next, [name, profile]]);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isAuthProfileProblem(
|
|
154
|
+
value: ReadonlyMap<string, AuthProfile> | AuthProfileProblem,
|
|
155
|
+
): value is AuthProfileProblem {
|
|
156
|
+
return 'code' in value;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function hasSameRule(left: AuthProfile, right: AuthProfile): boolean {
|
|
160
|
+
return (
|
|
161
|
+
left.secret === right.secret &&
|
|
162
|
+
left.header === right.header &&
|
|
163
|
+
left.scheme === right.scheme &&
|
|
164
|
+
left.kind === right.kind
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// One entry per secret, sorted by secret, each with its rules sorted by host.
|
|
169
|
+
function buildSecrets(profiles: readonly AuthProfile[]): ResolvedAuthSecret[] {
|
|
170
|
+
const rules = new Map<string, AuthRule[]>();
|
|
171
|
+
|
|
172
|
+
for (const profile of profiles) {
|
|
173
|
+
const list = rules.get(profile.secret) ?? [];
|
|
174
|
+
|
|
175
|
+
list.push({ host: profile.host, header: profile.header, scheme: profile.scheme });
|
|
176
|
+
rules.set(profile.secret, list);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return [...rules.entries()]
|
|
180
|
+
.toSorted(([a], [b]) => (a < b ? -1 : 1))
|
|
181
|
+
.map(([secret, list]) => ({
|
|
182
|
+
secret,
|
|
183
|
+
kind: 'custom',
|
|
184
|
+
rules: list.toSorted((a, b) => (a.host < b.host ? -1 : 1)),
|
|
185
|
+
}));
|
|
186
|
+
}
|