@zgeoff/atc 2.19.0 → 2.22.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 +187 -113
- package/src/client/collect-agent-picks.ts +5 -2
- package/src/client/daemon-client.ts +29 -18
- 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/build-payload-hash.ts +4 -3
- package/src/daemon/daemon-connection.ts +277 -20
- package/src/daemon/daemon-context.ts +8 -0
- package/src/daemon/daemon.ts +186 -32
- package/src/daemon/find-token-fingerprint.ts +23 -0
- package/src/daemon/handshake-throttle.ts +47 -0
- package/src/daemon/idempotency-ledger.ts +20 -2
- package/src/daemon/imp-port.ts +4 -2
- package/src/daemon/imp-provider.ts +24 -19
- package/src/daemon/is-allowed-listen-host.ts +63 -0
- package/src/daemon/load-listener-tokens.ts +46 -0
- package/src/daemon/parse-listen-address.ts +33 -0
- package/src/daemon/restore-fleet.ts +2 -1
- package/src/daemon/sessions.ts +6 -0
- package/src/daemon/start-tcp-listener.ts +165 -0
- package/src/federation/build-binding-payload-hash.ts +34 -0
- package/src/federation/build-daemon-outdated-error.ts +14 -0
- package/src/federation/build-events-filter-hash.ts +16 -0
- package/src/federation/build-gateway-error.ts +45 -0
- package/src/federation/build-gateway-id.ts +14 -0
- package/src/federation/build-gateway-result.ts +31 -0
- package/src/federation/build-ruled-value.ts +53 -0
- package/src/federation/collect-unruled-id-paths.ts +46 -0
- package/src/federation/daemon-caller.ts +473 -0
- package/src/federation/daemon-pool.ts +56 -0
- package/src/federation/decode-gateway-cursor.ts +73 -0
- package/src/federation/encode-gateway-cursor.ts +15 -0
- package/src/federation/gateway-error.ts +25 -0
- package/src/federation/gateway-store.ts +253 -0
- package/src/federation/id-rules.ts +85 -0
- package/src/federation/load-gateway-registry.ts +29 -0
- package/src/federation/max-events-cursor-bytes.ts +4 -0
- package/src/federation/max-registry-daemons.ts +26 -0
- package/src/federation/merge-event-pages.ts +228 -0
- package/src/federation/open-gateway-caller.ts +55 -0
- package/src/federation/parse-gateway-id.ts +28 -0
- package/src/federation/parse-gateway-registry.ts +123 -0
- package/src/federation/pick-daemon-state.ts +46 -0
- package/src/federation/plan-event-reads.ts +54 -0
- package/src/federation/read-fleet-events.ts +279 -0
- package/src/federation/require-serving-daemon.ts +27 -0
- package/src/federation/resolve-daemon-request.ts +59 -0
- package/src/federation/routing-caller.ts +450 -0
- package/src/federation/types.ts +33 -0
- package/src/federation/wait-for-outcome.ts +38 -0
- package/src/mcp/answer-rpc-request.ts +14 -1
- package/src/mcp/build-tool-list.ts +6 -5
- package/src/mcp/mcp-tools.ts +41 -8
- package/src/mcp/require-daemon-features.ts +2 -0
- package/src/mcp/run-tool.ts +15 -1
- package/src/mcp/start-mcp-http-server.ts +53 -9
- package/src/mcp/types.ts +8 -1
- package/src/protocol/daemon-features.ts +9 -0
- package/src/protocol/protocol.ts +2 -0
- package/src/protocol/request-param-schemas.ts +6 -0
- package/src/run-daemon-id.ts +52 -0
- package/src/shared/collect-auth-profiles.ts +122 -0
- package/src/shared/collect-gateways.ts +223 -6
- package/src/shared/config.ts +32 -4
- package/src/shared/find-daemon-record.ts +8 -3
- 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 +202 -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
|
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
|
|
|
@@ -78,6 +88,9 @@ const DEFAULTS: Config = {
|
|
|
78
88
|
targetRoots: new Map(),
|
|
79
89
|
},
|
|
80
90
|
gateways: [],
|
|
91
|
+
gatewayErrors: [],
|
|
92
|
+
authProfiles: new Map(),
|
|
93
|
+
authProfileErrors: [],
|
|
81
94
|
hooks: {},
|
|
82
95
|
leader: { code: 0, label: '^Space' },
|
|
83
96
|
targets: [{ id: 'local', provider: 'local-pty', options: {} }],
|
|
@@ -118,6 +131,7 @@ const CONFIG_SCHEMA = z.object({
|
|
|
118
131
|
dirs: z.unknown().optional(),
|
|
119
132
|
workspaces: z.unknown().optional(),
|
|
120
133
|
gateways: z.unknown().optional(),
|
|
134
|
+
authProfiles: z.unknown().optional(),
|
|
121
135
|
hooks: z.unknown().optional(),
|
|
122
136
|
leader: buildOptionalString(),
|
|
123
137
|
targets: z.unknown().optional(),
|
|
@@ -206,11 +220,14 @@ function tryWriteDefaultConfig(file: string): void {
|
|
|
206
220
|
/**
|
|
207
221
|
* The config.json text a first run writes. It leaves out the targets and
|
|
208
222
|
* principals, so the file holds the one implicit `local` target and no
|
|
209
|
-
* principals until the user sets their own,
|
|
210
|
-
* 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.
|
|
211
225
|
*/
|
|
212
226
|
export function renderDefaultConfig(): string {
|
|
213
227
|
const {
|
|
228
|
+
gatewayErrors: _gatewayErrors,
|
|
229
|
+
authProfiles: _authProfiles,
|
|
230
|
+
authProfileErrors: _authProfileErrors,
|
|
214
231
|
targets: _targets,
|
|
215
232
|
defaultTarget: _default,
|
|
216
233
|
targetErrors: _errors,
|
|
@@ -266,7 +283,15 @@ export function parseConfig(raw: unknown, file: string = configFile): Config {
|
|
|
266
283
|
const codexArgs = parsed.data.codexArgs ?? DEFAULTS.codexArgs;
|
|
267
284
|
const dirs = { roots: collectDirRoots(parsed.data.dirs) };
|
|
268
285
|
const workspaces = collectWorkspacesConfig(parsed.data.workspaces);
|
|
269
|
-
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
|
+
|
|
270
295
|
const hooks = collectHooks(parsed.data.hooks);
|
|
271
296
|
const targets = collectTargets(parsed.data.targets, parsed.data.defaultTarget);
|
|
272
297
|
const principals = collectPrincipals(parsed.data.principals);
|
|
@@ -283,7 +308,10 @@ export function parseConfig(raw: unknown, file: string = configFile): Config {
|
|
|
283
308
|
codexArgs,
|
|
284
309
|
dirs,
|
|
285
310
|
workspaces: workspaces.workspaces,
|
|
286
|
-
gateways,
|
|
311
|
+
gateways: gateways.gateways,
|
|
312
|
+
gatewayErrors: gateways.errors,
|
|
313
|
+
authProfiles: authProfiles.profiles,
|
|
314
|
+
authProfileErrors: authProfiles.errors,
|
|
287
315
|
hooks,
|
|
288
316
|
leader,
|
|
289
317
|
targets: targets.targets,
|
|
@@ -6,6 +6,9 @@ export interface DaemonRecord {
|
|
|
6
6
|
readonly socketPath: string;
|
|
7
7
|
readonly reporterSocketPath: string;
|
|
8
8
|
readonly eventsSocketPath: string | null;
|
|
9
|
+
|
|
10
|
+
// The port the TCP listener bound, or null without one.
|
|
11
|
+
readonly listenPort: number | null;
|
|
9
12
|
}
|
|
10
13
|
|
|
11
14
|
const RECORD_SCHEMA = z.object({
|
|
@@ -13,12 +16,14 @@ const RECORD_SCHEMA = z.object({
|
|
|
13
16
|
socketPath: z.string().min(1),
|
|
14
17
|
reporterSocketPath: z.string().min(1),
|
|
15
18
|
eventsSocketPath: z.string().min(1).nullable(),
|
|
19
|
+
listenPort: z.number().int().min(1).max(65_535).nullable().default(null),
|
|
16
20
|
});
|
|
17
21
|
|
|
18
22
|
/**
|
|
19
|
-
* Reads the record a running daemon keeps in its state directory: its pid
|
|
20
|
-
*
|
|
21
|
-
* computes other socket paths uses to find it
|
|
23
|
+
* Reads the record a running daemon keeps in its state directory: its pid,
|
|
24
|
+
* the socket paths it listens on, which a client whose environment
|
|
25
|
+
* computes other socket paths uses to find it, and the port its TCP
|
|
26
|
+
* listener bound. A missing, torn, or
|
|
22
27
|
* malformed record reads as null; a record left by a crashed daemon still
|
|
23
28
|
* parses, so a caller proves liveness by connecting.
|
|
24
29
|
*/
|
|
@@ -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
|
+
}
|
|
@@ -5,6 +5,7 @@ import { DEFAULT_MIGRATION_TABLE, Migrator } from 'kysely/migration';
|
|
|
5
5
|
import type { Migration, MigrationProvider, MigrationResultSet } from 'kysely/migration';
|
|
6
6
|
import type { IdempotencyState } from './idempotency-record';
|
|
7
7
|
import type { MessageStatus } from './message-record';
|
|
8
|
+
import type { RuntimeAuthBindingState, RuntimeAuthGrantPhase } from './runtime-auth-binding';
|
|
8
9
|
import type { MaterializationPhase } from './workspace-materialization';
|
|
9
10
|
|
|
10
11
|
interface FleetTable {
|
|
@@ -118,6 +119,41 @@ interface WorkspaceMaterializationTable {
|
|
|
118
119
|
withheld_env: string | null;
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
// One host's runtime auth binding, keyed by the session that owns the imp.
|
|
123
|
+
// The rebind columns hold a rebind in flight or the one that failed, all
|
|
124
|
+
// null otherwise.
|
|
125
|
+
interface RuntimeAuthBindingTable {
|
|
126
|
+
host_key: string;
|
|
127
|
+
target: string;
|
|
128
|
+
target_identity: string;
|
|
129
|
+
imp_name: string;
|
|
130
|
+
imp_id: string | null;
|
|
131
|
+
revision: number;
|
|
132
|
+
binding_hash: string;
|
|
133
|
+
binding_json: string;
|
|
134
|
+
state: RuntimeAuthBindingState;
|
|
135
|
+
attempt_id: string;
|
|
136
|
+
imp_created_by_attempt: number;
|
|
137
|
+
rebind_revision: number | null;
|
|
138
|
+
rebind_binding_hash: string | null;
|
|
139
|
+
rebind_binding_json: string | null;
|
|
140
|
+
rebind_attempt_id: string | null;
|
|
141
|
+
created_at: number;
|
|
142
|
+
updated_at: number;
|
|
143
|
+
revoked_at: number | null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// One secret's grant to a host's imp, keyed by the host and the secret.
|
|
147
|
+
interface RuntimeAuthGrantTable {
|
|
148
|
+
host_key: string;
|
|
149
|
+
secret: string;
|
|
150
|
+
revision: number;
|
|
151
|
+
attempt_id: string;
|
|
152
|
+
preexisting: number;
|
|
153
|
+
phase: RuntimeAuthGrantPhase;
|
|
154
|
+
updated_at: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
121
157
|
interface MessagesTable {
|
|
122
158
|
id: string;
|
|
123
159
|
atc_id: string;
|
|
@@ -148,6 +184,8 @@ export interface StateStoreSchema {
|
|
|
148
184
|
session_owner: SessionOwnerTable;
|
|
149
185
|
idempotency: IdempotencyTable;
|
|
150
186
|
workspace_materialization: WorkspaceMaterializationTable;
|
|
187
|
+
runtime_auth_binding: RuntimeAuthBindingTable;
|
|
188
|
+
runtime_auth_grant: RuntimeAuthGrantTable;
|
|
151
189
|
}
|
|
152
190
|
|
|
153
191
|
// Every shape the fleet table has shipped with: the oldest carries only
|
|
@@ -472,6 +510,45 @@ const MIGRATIONS: Record<string, Migration> = {
|
|
|
472
510
|
}
|
|
473
511
|
},
|
|
474
512
|
},
|
|
513
|
+
'025_create_runtime_auth': {
|
|
514
|
+
async up(db: Kysely<StateStoreSchema>) {
|
|
515
|
+
await db.schema
|
|
516
|
+
.createTable('runtime_auth_binding')
|
|
517
|
+
.ifNotExists()
|
|
518
|
+
.addColumn('host_key', 'text', (c) => c.primaryKey())
|
|
519
|
+
.addColumn('target', 'text', (c) => c.notNull())
|
|
520
|
+
.addColumn('target_identity', 'text', (c) => c.notNull())
|
|
521
|
+
.addColumn('imp_name', 'text', (c) => c.notNull())
|
|
522
|
+
.addColumn('imp_id', 'text')
|
|
523
|
+
.addColumn('revision', 'integer', (c) => c.notNull())
|
|
524
|
+
.addColumn('binding_hash', 'text', (c) => c.notNull())
|
|
525
|
+
.addColumn('binding_json', 'text', (c) => c.notNull())
|
|
526
|
+
.addColumn('state', 'text', (c) => c.notNull())
|
|
527
|
+
.addColumn('attempt_id', 'text', (c) => c.notNull())
|
|
528
|
+
.addColumn('imp_created_by_attempt', 'integer', (c) => c.notNull().defaultTo(0))
|
|
529
|
+
.addColumn('rebind_revision', 'integer')
|
|
530
|
+
.addColumn('rebind_binding_hash', 'text')
|
|
531
|
+
.addColumn('rebind_binding_json', 'text')
|
|
532
|
+
.addColumn('rebind_attempt_id', 'text')
|
|
533
|
+
.addColumn('created_at', 'integer', (c) => c.notNull())
|
|
534
|
+
.addColumn('updated_at', 'integer', (c) => c.notNull())
|
|
535
|
+
.addColumn('revoked_at', 'integer')
|
|
536
|
+
.execute();
|
|
537
|
+
|
|
538
|
+
await db.schema
|
|
539
|
+
.createTable('runtime_auth_grant')
|
|
540
|
+
.ifNotExists()
|
|
541
|
+
.addColumn('host_key', 'text', (c) => c.notNull())
|
|
542
|
+
.addColumn('secret', 'text', (c) => c.notNull())
|
|
543
|
+
.addColumn('revision', 'integer', (c) => c.notNull())
|
|
544
|
+
.addColumn('attempt_id', 'text', (c) => c.notNull())
|
|
545
|
+
.addColumn('preexisting', 'integer', (c) => c.notNull())
|
|
546
|
+
.addColumn('phase', 'text', (c) => c.notNull())
|
|
547
|
+
.addColumn('updated_at', 'integer', (c) => c.notNull())
|
|
548
|
+
.addPrimaryKeyConstraint('runtime_auth_grant_pk', ['host_key', 'secret'])
|
|
549
|
+
.execute();
|
|
550
|
+
},
|
|
551
|
+
},
|
|
475
552
|
};
|
|
476
553
|
|
|
477
554
|
const PROVIDER: MigrationProvider = {
|