@zgeoff/atc 2.24.0 → 2.26.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 +27 -2
- package/src/agents/gateway-adapter.ts +272 -25
- package/src/client/collect-agent-picks.ts +2 -5
- package/src/client/collect-target-picks.ts +5 -0
- package/src/client/spawn-picker.ts +15 -1
- package/src/daemon/broker-auth-host.ts +25 -0
- package/src/daemon/broker-authority-error.ts +2 -1
- package/src/daemon/build-agent-list.ts +16 -3
- package/src/daemon/build-scoped-context.ts +2 -0
- package/src/daemon/build-target-list.ts +5 -0
- package/src/daemon/daemon-connection.ts +66 -7
- package/src/daemon/daemon-context.ts +12 -0
- package/src/daemon/daemon.ts +84 -1
- package/src/daemon/execution-provider.ts +50 -2
- package/src/daemon/format-log-field.ts +38 -0
- package/src/daemon/imp-client-port.ts +71 -23
- package/src/daemon/imp-harness.ts +299 -18
- package/src/daemon/imp-port.ts +23 -1
- package/src/daemon/imp-provider.ts +161 -40
- package/src/daemon/local-pty-provider.ts +11 -0
- package/src/daemon/make-non-blocking-log.ts +91 -0
- package/src/daemon/refusal-log.ts +163 -0
- package/src/daemon/restore-fleet.ts +16 -1
- package/src/daemon/runtime-auth-binder.ts +1086 -0
- package/src/daemon/sessions.ts +565 -25
- package/src/daemon/start-tcp-listener.ts +47 -1
- package/src/daemon/verify-broker-authority.ts +10 -5
- package/src/mcp/mcp-tools.ts +2 -0
- package/src/mcp/require-daemon-features.ts +1 -0
- package/src/protocol/daemon-features.ts +4 -0
- package/src/protocol/protocol.ts +17 -0
- package/src/protocol/request-param-schemas.ts +5 -0
- package/src/shared/collect-gateways.ts +1 -22
- package/src/shared/is-broker-variable.ts +24 -0
- package/src/store/state-store.ts +11 -0
package/package.json
CHANGED
|
@@ -3,6 +3,8 @@ import type { DaemonError } from '../protocol/daemon-error';
|
|
|
3
3
|
import type { HookEvent } from '../protocol/hook-event';
|
|
4
4
|
import type { AgentID } from '../shared/agent-id';
|
|
5
5
|
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
6
|
+
import type { AuthProfile } from '../shared/collect-auth-profiles';
|
|
7
|
+
import type { GatewayAuth, GatewayConfig } from '../shared/collect-gateways';
|
|
6
8
|
import type { SessionID } from '../shared/session-id';
|
|
7
9
|
|
|
8
10
|
export interface SpawnOptions {
|
|
@@ -35,19 +37,36 @@ export interface SpawnPlan {
|
|
|
35
37
|
/**
|
|
36
38
|
* Where a session on a remote host finds atc: the atc binary inside the
|
|
37
39
|
* host, null when the host has none, and the folder the session's own
|
|
38
|
-
* files unpack into.
|
|
40
|
+
* files unpack into. `auth` is given when the harness takes its credential
|
|
41
|
+
* from impd's broker: the revision of the host's runtime auth binding it
|
|
42
|
+
* launches under, which keys any settings the agent writes for it, and the
|
|
43
|
+
* placeholder variables the harness holds in place of a credential.
|
|
39
44
|
*/
|
|
40
45
|
export interface GuestPaths {
|
|
41
46
|
readonly atc: string | null;
|
|
42
47
|
readonly dir: string;
|
|
48
|
+
readonly auth?: { readonly revision: number; readonly env: Readonly<Record<string, string>> };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The credential an agent takes from impd's broker instead of holding it:
|
|
53
|
+
* its gateway's endpoint and auth selection, and the auth profiles that
|
|
54
|
+
* selection resolves against.
|
|
55
|
+
*/
|
|
56
|
+
export interface AuthSelection {
|
|
57
|
+
readonly gateway: Pick<GatewayConfig, 'id' | 'baseURL'> & { readonly auth: GatewayAuth };
|
|
58
|
+
readonly profiles: ReadonlyMap<string, AuthProfile>;
|
|
43
59
|
}
|
|
44
60
|
|
|
45
61
|
/**
|
|
46
62
|
* A spawn on a remote host, with the files the harness reads there, keyed
|
|
47
|
-
* by their path inside the session's guest folder
|
|
63
|
+
* by their path inside the session's guest folder, and the variables the
|
|
64
|
+
* harness process starts with, which no variable the harness inherits
|
|
65
|
+
* overrides.
|
|
48
66
|
*/
|
|
49
67
|
export interface GuestSpawnPlan extends SpawnPlan {
|
|
50
68
|
readonly files: Readonly<Record<string, string>>;
|
|
69
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
51
70
|
}
|
|
52
71
|
|
|
53
72
|
export interface TranscriptToolUse {
|
|
@@ -204,8 +223,14 @@ export interface AgentAdapter {
|
|
|
204
223
|
// Plans a spawn on a remote host; null when this agent cannot run there,
|
|
205
224
|
// such as one whose instrumentation needs atc on a host without it.
|
|
206
225
|
// Absent: the agent runs there as a local spawn plans it, with no files.
|
|
226
|
+
// A plan for a guest whose `auth` is given that cannot launch behind the
|
|
227
|
+
// broker is null.
|
|
207
228
|
readonly planGuestSpawn?: (opts: SpawnOptions, guest: GuestPaths) => GuestSpawnPlan | null;
|
|
208
229
|
|
|
230
|
+
// The credential this agent takes from impd's broker, or null when it
|
|
231
|
+
// takes none. Absent: it takes none.
|
|
232
|
+
readonly findAuthSelection?: () => AuthSelection | null;
|
|
233
|
+
|
|
209
234
|
// The refusal every start of this agent's harness gets, on any target,
|
|
210
235
|
// or null when it may start. Absent: no start is refused.
|
|
211
236
|
readonly findSpawnRefusal?: () => DaemonError | null;
|
|
@@ -5,10 +5,16 @@ import type { AgentID } from '../shared/agent-id';
|
|
|
5
5
|
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
6
6
|
import type { GatewayConfig } from '../shared/collect-gateways';
|
|
7
7
|
import type { Config } from '../shared/config';
|
|
8
|
+
import { isBrokerVariable } from '../shared/is-broker-variable';
|
|
9
|
+
import { isRecord } from '../shared/report';
|
|
10
|
+
import { resolveAuthProfiles } from '../shared/resolve-auth-profiles';
|
|
8
11
|
import { toShellArg } from '../shared/to-shell-arg';
|
|
9
12
|
import type {
|
|
10
13
|
AgentAdapter,
|
|
11
14
|
AgentProfile,
|
|
15
|
+
AuthSelection,
|
|
16
|
+
GuestPaths,
|
|
17
|
+
GuestSpawnPlan,
|
|
12
18
|
HeadlessRunner,
|
|
13
19
|
NameUpdate,
|
|
14
20
|
ResumeCheck,
|
|
@@ -16,10 +22,13 @@ import type {
|
|
|
16
22
|
SpawnOptions,
|
|
17
23
|
SpawnPlan,
|
|
18
24
|
} from './agent-adapter';
|
|
25
|
+
import { buildATCBridgeFiles } from './build-atc-bridge-files';
|
|
19
26
|
import { buildClaudeOverrideArgs } from './build-claude-override-args';
|
|
27
|
+
import { buildHookSettings } from './build-hook-settings';
|
|
20
28
|
import { buildRestoreModeArgs } from './build-restore-mode-args';
|
|
21
29
|
import { ClaudeAdapter } from './claude-adapter';
|
|
22
30
|
import { CLAUDE_EFFORT_LEVELS } from './claude-effort-levels';
|
|
31
|
+
import { findClaudePermissionMode } from './find-claude-permission-mode';
|
|
23
32
|
import { findFlagValue } from './find-flag-value';
|
|
24
33
|
import { makeClaudeHeadlessRunner } from './make-claude-headless-runner';
|
|
25
34
|
import type { ClaudeHeadlessRun } from './make-claude-headless-runner';
|
|
@@ -59,6 +68,8 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
59
68
|
|
|
60
69
|
private readonly gateway: GatewayConfig;
|
|
61
70
|
|
|
71
|
+
private readonly config: Config;
|
|
72
|
+
|
|
62
73
|
private readonly claude: ClaudeAdapter;
|
|
63
74
|
|
|
64
75
|
// Written on first spawn so constructing the adapter touches no state.
|
|
@@ -77,6 +88,7 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
77
88
|
) {
|
|
78
89
|
this.bridgeTarget = bridgeTarget;
|
|
79
90
|
this.gateway = gateway;
|
|
91
|
+
this.config = config;
|
|
80
92
|
this.id = gateway.id;
|
|
81
93
|
|
|
82
94
|
const models = pickModels(gateway.env);
|
|
@@ -102,44 +114,131 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
102
114
|
});
|
|
103
115
|
}
|
|
104
116
|
|
|
105
|
-
// A gateway whose credential comes through impd's broker
|
|
106
|
-
//
|
|
107
|
-
//
|
|
117
|
+
// A gateway whose credential comes through impd's broker is refused
|
|
118
|
+
// before anything is prepared for it when its placeholders cannot pair
|
|
119
|
+
// with the broker's header, or when its settings env would route the CLI
|
|
120
|
+
// around the broker.
|
|
108
121
|
findSpawnRefusal(): DaemonError | null {
|
|
109
|
-
|
|
122
|
+
const auth = this.gateway.auth;
|
|
123
|
+
|
|
124
|
+
if (auth === undefined) {
|
|
110
125
|
return null;
|
|
111
126
|
}
|
|
112
127
|
|
|
113
|
-
return
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
{ agent: this.id },
|
|
128
|
+
return (
|
|
129
|
+
this.findPlaceholderRefusal(auth.placeholderEnv, auth.profiles) ??
|
|
130
|
+
this.findSettingsEnvConflict()
|
|
117
131
|
);
|
|
118
132
|
}
|
|
119
133
|
|
|
134
|
+
findAuthSelection(): AuthSelection | null {
|
|
135
|
+
const auth = this.gateway.auth;
|
|
136
|
+
|
|
137
|
+
if (auth === undefined) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
gateway: { id: this.gateway.id, baseURL: this.gateway.baseURL, auth },
|
|
143
|
+
profiles: this.config.authProfiles,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// A gateway whose credential comes through impd's broker never starts on
|
|
148
|
+
// the daemon's machine: started without the broker, the CLI would send
|
|
149
|
+
// whatever credential it holds to the gateway's host.
|
|
120
150
|
planSpawn(opts: SpawnOptions): SpawnPlan {
|
|
151
|
+
if (this.gateway.auth !== undefined) {
|
|
152
|
+
throw this.buildBrokerRefusal('which only an imp target can give it');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const modeArgs =
|
|
156
|
+
opts.resume === false ? [] : buildRestoreModeArgs(this.gateway.args, this.gateway.settings);
|
|
157
|
+
|
|
121
158
|
return {
|
|
122
159
|
bin: this.gateway.bin,
|
|
123
|
-
args:
|
|
124
|
-
...buildClaudeOverrideArgs(this.gateway.args, opts),
|
|
125
|
-
...(opts.resume === false
|
|
126
|
-
? []
|
|
127
|
-
: buildRestoreModeArgs(this.gateway.args, this.gateway.settings)),
|
|
128
|
-
'--settings',
|
|
129
|
-
this.writeSettings(),
|
|
130
|
-
'--plugin-dir',
|
|
131
|
-
this.writeBridge(),
|
|
132
|
-
...(opts.resume === true ? ['--resume'] : []),
|
|
133
|
-
...(typeof opts.resume === 'string' ? ['--resume', opts.resume] : []),
|
|
134
|
-
...(opts.prompt === '' ? [] : [opts.prompt]),
|
|
135
|
-
],
|
|
160
|
+
args: this.buildArgs(opts, modeArgs, this.writeSettings(), this.writeBridge()),
|
|
136
161
|
};
|
|
137
162
|
}
|
|
138
163
|
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
164
|
+
// A gateway whose credential comes through impd's broker runs on a
|
|
165
|
+
// remote host with a settings file of the session's own per binding
|
|
166
|
+
// revision, a Claude config folder of its own that holds no account,
|
|
167
|
+
// and placeholders in place of the credential, which the broker swaps
|
|
168
|
+
// for the real one on the host's side. A shell seeds the config folder
|
|
169
|
+
// before it runs the CLI, since a transferred file would replace the
|
|
170
|
+
// state an earlier run left. Any other gateway's credential
|
|
171
|
+
// helper runs on the daemon's machine, so it never runs remotely.
|
|
172
|
+
planGuestSpawn(opts: SpawnOptions, guest: GuestPaths): GuestSpawnPlan | null {
|
|
173
|
+
if (this.gateway.auth === undefined) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (guest.auth === undefined) {
|
|
178
|
+
throw this.buildBrokerRefusal('and this session has no broker binding');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (guest.atc === null) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const refusal =
|
|
186
|
+
this.findPlaceholderRefusal(guest.auth.env, this.gateway.auth.profiles) ??
|
|
187
|
+
this.findSettingsEnvConflict();
|
|
188
|
+
|
|
189
|
+
if (refusal !== null) {
|
|
190
|
+
throw refusal;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const argv = [guest.atc];
|
|
194
|
+
const settingsPath = `auth-r${guest.auth.revision}/settings.json`;
|
|
195
|
+
|
|
196
|
+
const bridge = Object.entries(buildATCBridgeFiles(argv)).map(
|
|
197
|
+
([path, content]): [string, string] => [`atc-bridge/${path}`, content],
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const settings = buildHookSettings(
|
|
201
|
+
{
|
|
202
|
+
id: this.id,
|
|
203
|
+
env: {
|
|
204
|
+
...this.gateway.env,
|
|
205
|
+
ANTHROPIC_BASE_URL: this.gateway.baseURL,
|
|
206
|
+
...guest.auth.env,
|
|
207
|
+
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
|
208
|
+
},
|
|
209
|
+
...(this.gateway.settings === undefined
|
|
210
|
+
? {}
|
|
211
|
+
: { settings: buildSettingsWithoutHelper(this.gateway.settings) }),
|
|
212
|
+
},
|
|
213
|
+
0,
|
|
214
|
+
argv,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const configDir = `${guest.dir}/${CLAUDE_CONFIG_FOLDER}`;
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
bin: 'sh',
|
|
221
|
+
args: [
|
|
222
|
+
'-c',
|
|
223
|
+
SEED_CONFIG_SCRIPT,
|
|
224
|
+
'sh',
|
|
225
|
+
configDir,
|
|
226
|
+
`${guest.dir}/${CONFIG_SEED_FILE}`,
|
|
227
|
+
this.gateway.bin,
|
|
228
|
+
...this.buildArgs(
|
|
229
|
+
opts,
|
|
230
|
+
this.buildGuestModeArgs(),
|
|
231
|
+
`${guest.dir}/${settingsPath}`,
|
|
232
|
+
`${guest.dir}/atc-bridge`,
|
|
233
|
+
),
|
|
234
|
+
],
|
|
235
|
+
files: {
|
|
236
|
+
[settingsPath]: JSON.stringify(settings, null, 2),
|
|
237
|
+
[CONFIG_SEED_FILE]: JSON.stringify(ONBOARDED_CONFIG, null, 2),
|
|
238
|
+
...Object.fromEntries(bridge),
|
|
239
|
+
},
|
|
240
|
+
env: { CLAUDE_CONFIG_DIR: configDir, ...guest.auth.env },
|
|
241
|
+
};
|
|
143
242
|
}
|
|
144
243
|
|
|
145
244
|
normalizeHook(e: HookEvent): AdapterEvent {
|
|
@@ -179,6 +278,117 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
179
278
|
return `cd ${toShellArg(cwd)} && ${this.gateway.bin}${args} --settings ${settings} --resume${resume}`;
|
|
180
279
|
}
|
|
181
280
|
|
|
281
|
+
private buildArgs(
|
|
282
|
+
opts: SpawnOptions,
|
|
283
|
+
modeArgs: readonly string[],
|
|
284
|
+
settings: string,
|
|
285
|
+
pluginDir: string,
|
|
286
|
+
): string[] {
|
|
287
|
+
return [
|
|
288
|
+
...buildClaudeOverrideArgs(this.gateway.args, opts),
|
|
289
|
+
...modeArgs,
|
|
290
|
+
'--settings',
|
|
291
|
+
settings,
|
|
292
|
+
'--plugin-dir',
|
|
293
|
+
pluginDir,
|
|
294
|
+
...(opts.resume === true ? ['--resume'] : []),
|
|
295
|
+
...(typeof opts.resume === 'string' ? ['--resume', opts.resume] : []),
|
|
296
|
+
...(opts.prompt === '' ? [] : [opts.prompt]),
|
|
297
|
+
];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// A brokered session's Claude config is fresh, so the CLI's own default
|
|
301
|
+
// mode would apply rather than the one the owner's settings set. Every
|
|
302
|
+
// start therefore names its mode: the one the gateway's arguments or
|
|
303
|
+
// settings set, else the CLI's manual mode, which asks before each
|
|
304
|
+
// action.
|
|
305
|
+
private buildGuestModeArgs(): string[] {
|
|
306
|
+
if (findFlagValue(this.gateway.args, ['--permission-mode']) !== null) {
|
|
307
|
+
return [];
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return [
|
|
311
|
+
'--permission-mode',
|
|
312
|
+
findClaudePermissionMode([], this.gateway.settings) ?? MANUAL_PERMISSION_MODE,
|
|
313
|
+
];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private buildBrokerRefusal(reason: string): DaemonError {
|
|
317
|
+
return new DaemonError(
|
|
318
|
+
'auth_target_unsupported',
|
|
319
|
+
`gateway '${this.id}' takes its credential from impd's broker, ${reason}`,
|
|
320
|
+
{ agent: this.id },
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// The Claude CLI sends ANTHROPIC_AUTH_TOKEN as a bearer authorization
|
|
325
|
+
// header, the one pairing atc binds, so the placeholders must be that
|
|
326
|
+
// variable alone, holding the placeholder, for a profile whose rule on
|
|
327
|
+
// the base URL's host sets that header. Any other variable would put the
|
|
328
|
+
// placeholder in a header the broker never fills.
|
|
329
|
+
private findPlaceholderRefusal(
|
|
330
|
+
env: Readonly<Record<string, string>>,
|
|
331
|
+
profiles: readonly string[],
|
|
332
|
+
): DaemonError | null {
|
|
333
|
+
const keys = Object.keys(env);
|
|
334
|
+
|
|
335
|
+
if (keys.length !== 1 || keys[0] !== BEARER_VARIABLE) {
|
|
336
|
+
return this.buildPlaceholderRefusal(
|
|
337
|
+
`needs ${BEARER_VARIABLE} as its only placeholder variable, which the broker fills as a bearer authorization header; it has ${keys.length === 0 ? 'none' : keys.join(', ')}`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (env[BEARER_VARIABLE] !== PLACEHOLDER) {
|
|
342
|
+
return this.buildPlaceholderRefusal(`needs ${BEARER_VARIABLE} to hold ${PLACEHOLDER}`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const resolution = resolveAuthProfiles(this.config.authProfiles, profiles);
|
|
346
|
+
|
|
347
|
+
const host = new URL(this.gateway.baseURL).hostname;
|
|
348
|
+
|
|
349
|
+
const rule =
|
|
350
|
+
'resolved' in resolution
|
|
351
|
+
? resolution.resolved.secrets.flatMap((s) => s.rules).find((r) => r.host === host)
|
|
352
|
+
: undefined;
|
|
353
|
+
|
|
354
|
+
if (rule?.header !== 'authorization' || rule.scheme !== 'bearer') {
|
|
355
|
+
return this.buildPlaceholderRefusal(
|
|
356
|
+
`needs a profile that sets a bearer authorization header for ${host}, the header ${BEARER_VARIABLE} fills`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// The settings file's env block reaches the CLI's own process, so a proxy
|
|
364
|
+
// or CA variable there would route the CLI around the broker.
|
|
365
|
+
private findSettingsEnvConflict(): DaemonError | null {
|
|
366
|
+
const settingsEnv = this.gateway.settings?.['env'];
|
|
367
|
+
|
|
368
|
+
const keys = [
|
|
369
|
+
...Object.keys(this.gateway.env),
|
|
370
|
+
...(isRecord(settingsEnv) ? Object.keys(settingsEnv) : []),
|
|
371
|
+
];
|
|
372
|
+
|
|
373
|
+
const variable = keys.find((key) => isBrokerVariable(key));
|
|
374
|
+
|
|
375
|
+
if (variable === undefined) {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return new DaemonError(
|
|
380
|
+
'auth_target_unsupported',
|
|
381
|
+
`gateway '${this.id}' sets ${variable} in its settings env, which would route around impd's broker`,
|
|
382
|
+
{ agent: this.id, problem: 'guest_env_conflict', variable },
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private buildPlaceholderRefusal(problem: string): DaemonError {
|
|
387
|
+
return new DaemonError('auth_placeholder_unsupported', `gateway '${this.id}' ${problem}`, {
|
|
388
|
+
agent: this.id,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
182
392
|
private writeSettings(): string {
|
|
183
393
|
this.settingsFile ??= writeHookSettings({
|
|
184
394
|
id: this.id,
|
|
@@ -199,6 +409,43 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
199
409
|
}
|
|
200
410
|
}
|
|
201
411
|
|
|
412
|
+
// The variable the Claude CLI sends as a bearer authorization header, and
|
|
413
|
+
// the value impd's broker replaces with the credential on the host's side.
|
|
414
|
+
const BEARER_VARIABLE = 'ANTHROPIC_AUTH_TOKEN';
|
|
415
|
+
const PLACEHOLDER = 'imp-broker-placeholder';
|
|
416
|
+
|
|
417
|
+
// The Claude CLI's mode that asks a person before each action.
|
|
418
|
+
const MANUAL_PERMISSION_MODE = 'default';
|
|
419
|
+
|
|
420
|
+
// The Claude config folder a brokered session gets inside its guest
|
|
421
|
+
// folder, so no account or setting of the host's image reaches it.
|
|
422
|
+
const CLAUDE_CONFIG_FOLDER = 'claude-config';
|
|
423
|
+
|
|
424
|
+
// The state the Claude CLI reads from its config folder's `.claude.json`
|
|
425
|
+
// to start without its first-run onboarding. It holds no account: the
|
|
426
|
+
// placeholder credential is what keeps the CLI from asking for a login.
|
|
427
|
+
// It holds no folder trust either, so the CLI asks a person to trust the
|
|
428
|
+
// workspace on its first start.
|
|
429
|
+
const ONBOARDED_CONFIG = { hasCompletedOnboarding: true };
|
|
430
|
+
|
|
431
|
+
// Where that state travels, beside the config folder rather than in it.
|
|
432
|
+
const CONFIG_SEED_FILE = 'claude-config-seed.json';
|
|
433
|
+
|
|
434
|
+
// Copies the seed into the config folder only when the folder holds no
|
|
435
|
+
// `.claude.json` yet, then runs the CLI. A resumed session keeps the state
|
|
436
|
+
// the CLI wrote, a folder trust a person accepted included. The arguments
|
|
437
|
+
// are the config folder, the seed, and the CLI's own command line.
|
|
438
|
+
const SEED_CONFIG_SCRIPT =
|
|
439
|
+
'mkdir -p "$1" && { [ -e "$1/.claude.json" ] || cp "$2" "$1/.claude.json"; } && shift 2 && exec "$@"';
|
|
440
|
+
|
|
441
|
+
// The gateway's settings without a credential helper, which a brokered
|
|
442
|
+
// session never runs.
|
|
443
|
+
function buildSettingsWithoutHelper(
|
|
444
|
+
settings: Readonly<Record<string, unknown>>,
|
|
445
|
+
): Readonly<Record<string, unknown>> {
|
|
446
|
+
return Object.fromEntries(Object.entries(settings).filter(([key]) => key !== 'apiKeyHelper'));
|
|
447
|
+
}
|
|
448
|
+
|
|
202
449
|
// The model names a gateway's env sets explicitly, keyed by role:
|
|
203
450
|
// `ANTHROPIC_MODEL` is `default`, and `ANTHROPIC_DEFAULT_<TIER>_MODEL` is the
|
|
204
451
|
// tier in lower case. No other env value leaves the adapter.
|
|
@@ -9,8 +9,7 @@ export interface AgentPick {
|
|
|
9
9
|
/**
|
|
10
10
|
* The agent choices the spawn and adopt flows offer, in menu order. An agent
|
|
11
11
|
* whose configured binary does not resolve is left out, so every row in the
|
|
12
|
-
* menu is a session that can start.
|
|
13
|
-
* every start of one is refused. Resolution follows the rule a spawn
|
|
12
|
+
* menu is a session that can start. Resolution follows the rule a spawn
|
|
14
13
|
* follows: a bare name comes off PATH, a name carrying a separator is taken
|
|
15
14
|
* as a path, and either way it has to be executable.
|
|
16
15
|
*/
|
|
@@ -23,9 +22,7 @@ export function collectAgentPicks(config: Config): AgentPick[] {
|
|
|
23
22
|
{ agent: 'claude', label: 'Claude', bin: config.claudeBin },
|
|
24
23
|
{ agent: 'grok', label: 'Grok', bin: config.grokBin },
|
|
25
24
|
{ agent: 'codex', label: 'Codex', bin: config.codexBin },
|
|
26
|
-
...config.gateways
|
|
27
|
-
.filter((g) => g.auth === undefined)
|
|
28
|
-
.map((g) => ({ agent: g.id, label: g.label, bin: g.bin })),
|
|
25
|
+
...config.gateways.map((g) => ({ agent: g.id, label: g.label, bin: g.bin })),
|
|
29
26
|
];
|
|
30
27
|
|
|
31
28
|
return candidates
|
|
@@ -13,6 +13,10 @@ export interface TargetPick {
|
|
|
13
13
|
// Whether a session there runs on the daemon's own machine, where a
|
|
14
14
|
// local directory runs in place.
|
|
15
15
|
readonly inPlace: boolean;
|
|
16
|
+
|
|
17
|
+
// Whether the target reaches impd's credential broker; false from a
|
|
18
|
+
// daemon that does not say.
|
|
19
|
+
readonly brokerAuth: boolean;
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
/**
|
|
@@ -47,6 +51,7 @@ export function collectTargetPicks(answer: Readonly<Record<string, unknown>>): T
|
|
|
47
51
|
takesWorkspace:
|
|
48
52
|
available && capabilities['transfer'] === true && capabilities['run'] === true,
|
|
49
53
|
inPlace: entry['provider'] === 'local-pty',
|
|
54
|
+
brokerAuth: entry['brokerAuth'] === true,
|
|
50
55
|
},
|
|
51
56
|
];
|
|
52
57
|
});
|
|
@@ -1616,7 +1616,10 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
1616
1616
|
return;
|
|
1617
1617
|
}
|
|
1618
1618
|
|
|
1619
|
-
|
|
1619
|
+
// An agent that takes its credential from impd's broker runs only on a
|
|
1620
|
+
// target that reaches the broker.
|
|
1621
|
+
const brokered = isBrokerAgent(listed['agents'], this.agent);
|
|
1622
|
+
const picks = collectTargetPicks(listed).filter((t) => !brokered || t.brokerAuth);
|
|
1620
1623
|
|
|
1621
1624
|
// An adopt resumes a session from its history on this host, which a
|
|
1622
1625
|
// fresh checkout elsewhere does not hold, so it is offered only the
|
|
@@ -1870,6 +1873,17 @@ function buildProbedRepo(
|
|
|
1870
1873
|
};
|
|
1871
1874
|
}
|
|
1872
1875
|
|
|
1876
|
+
// Whether the daemon lists an agent as taking its credential from impd's
|
|
1877
|
+
// broker; a daemon that does not say lists none.
|
|
1878
|
+
function isBrokerAgent(raw: unknown, agent: AgentID): boolean {
|
|
1879
|
+
return (
|
|
1880
|
+
Array.isArray(raw) &&
|
|
1881
|
+
raw.some(
|
|
1882
|
+
(entry: unknown) => isRecord(entry) && entry['id'] === agent && entry['brokerAuth'] === true,
|
|
1883
|
+
)
|
|
1884
|
+
);
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1873
1887
|
function parseAnnouncedSources(raw: unknown): AnnouncedSource[] {
|
|
1874
1888
|
if (!Array.isArray(raw)) {
|
|
1875
1889
|
return [];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ImpPort, ImpView } from './imp-port';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a provider whose hosts can reach impd's credential broker offers
|
|
5
|
+
* runtime auth: the literal start of every imp name it builds, the imp a
|
|
6
|
+
* host key runs on, impd's identity, secret and grant calls, and creating
|
|
7
|
+
* and destroying a host's imp the way the provider itself does. A
|
|
8
|
+
* destroy that finds no imp counts as done.
|
|
9
|
+
*/
|
|
10
|
+
export interface BrokerAuthHost {
|
|
11
|
+
readonly impPrefix: string;
|
|
12
|
+
readonly port: Pick<
|
|
13
|
+
ImpPort,
|
|
14
|
+
| 'readFeatures'
|
|
15
|
+
| 'readIdentity'
|
|
16
|
+
| 'readSecrets'
|
|
17
|
+
| 'readGrants'
|
|
18
|
+
| 'createGrant'
|
|
19
|
+
| 'removeGrant'
|
|
20
|
+
| 'readImp'
|
|
21
|
+
>;
|
|
22
|
+
readonly getImpName: (hostKey: string) => string;
|
|
23
|
+
readonly createImp: (hostKey: string) => Promise<ImpView>;
|
|
24
|
+
readonly destroyImp: (hostKey: string) => Promise<void>;
|
|
25
|
+
}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Why impd's credential broker may not be used for a session, or why atc
|
|
3
3
|
* may not clean up after one:
|
|
4
4
|
*
|
|
5
|
-
* - `auth_impd_too_old`: impd lacks grantable tokens
|
|
5
|
+
* - `auth_impd_too_old`: impd lacks grantable tokens, secret rebinds or
|
|
6
|
+
* exec requirements.
|
|
6
7
|
* - `auth_token_scope`: the token's scope is below `manage`.
|
|
7
8
|
* - `auth_token_too_broad`: the token can reach imps outside atc's
|
|
8
9
|
* namespace, through no imp patterns or a pattern whose literal text
|
|
@@ -2,7 +2,8 @@ import type { AgentAdapter, SpawnOptionSpec } from '../agents/agent-adapter';
|
|
|
2
2
|
|
|
3
3
|
interface AgentCapabilities {
|
|
4
4
|
// Only an installed agent whose starts are not all refused can start a
|
|
5
|
-
// session
|
|
5
|
+
// session, and one that takes its credential from impd's broker only
|
|
6
|
+
// where some target reaches the broker.
|
|
6
7
|
readonly spawn: boolean;
|
|
7
8
|
readonly readTranscript: boolean;
|
|
8
9
|
|
|
@@ -20,6 +21,10 @@ export interface AgentEntry {
|
|
|
20
21
|
|
|
21
22
|
// Whether the agent's binary resolves, on PATH or at its configured path.
|
|
22
23
|
readonly installed: boolean;
|
|
24
|
+
|
|
25
|
+
// Whether the agent takes its credential from impd's broker, so it runs
|
|
26
|
+
// only on a target whose entry has `brokerAuth`.
|
|
27
|
+
readonly brokerAuth: boolean;
|
|
23
28
|
readonly capabilities: AgentCapabilities;
|
|
24
29
|
readonly models: Readonly<Record<string, string>> | null;
|
|
25
30
|
readonly spawnOptions: SpawnOptionEntries;
|
|
@@ -43,22 +48,30 @@ interface SpawnOptionEntries {
|
|
|
43
48
|
* so no environment value, credential, helper command, or base URL reaches
|
|
44
49
|
* it. Every session runs in a PTY, so each agent can be attached, read as a
|
|
45
50
|
* screen, and typed into. A stand-in adapter without a profile is listed
|
|
46
|
-
* under its id as both label and kind.
|
|
51
|
+
* under its id as both label and kind. hasBrokerTarget holds whether any
|
|
52
|
+
* target reaches impd's credential broker.
|
|
47
53
|
*/
|
|
48
54
|
export function buildAgentList(
|
|
49
55
|
adapters: readonly AgentAdapter[],
|
|
50
56
|
isInstalled: (bin: string) => boolean,
|
|
57
|
+
hasBrokerTarget: boolean,
|
|
51
58
|
): AgentEntry[] {
|
|
52
59
|
return adapters.map((adapter) => {
|
|
53
60
|
const profile = adapter.profile;
|
|
54
61
|
const installed = profile === undefined ? false : isInstalled(profile.bin);
|
|
55
|
-
const
|
|
62
|
+
const brokerAuth = (adapter.findAuthSelection?.() ?? null) !== null;
|
|
63
|
+
|
|
64
|
+
const spawnable =
|
|
65
|
+
installed &&
|
|
66
|
+
(adapter.findSpawnRefusal?.() ?? null) === null &&
|
|
67
|
+
(!brokerAuth || hasBrokerTarget);
|
|
56
68
|
|
|
57
69
|
return {
|
|
58
70
|
id: adapter.id,
|
|
59
71
|
label: profile?.label ?? adapter.id,
|
|
60
72
|
kind: profile?.kind ?? adapter.id,
|
|
61
73
|
installed,
|
|
74
|
+
brokerAuth,
|
|
62
75
|
capabilities: {
|
|
63
76
|
spawn: spawnable,
|
|
64
77
|
readTranscript: adapter.parseTranscriptLine !== undefined,
|
|
@@ -139,6 +139,8 @@ export function buildScopedContext(
|
|
|
139
139
|
// out or taken, so its host is never touched.
|
|
140
140
|
forgetSession: (id, confirmToken) =>
|
|
141
141
|
canSee(id) ? ctx.forgetSession(id, confirmToken) : Promise.resolve('missing' as const),
|
|
142
|
+
revokeSessionAuth: (id) => (canSee(id) ? ctx.revokeSessionAuth(id) : Promise.resolve(false)),
|
|
143
|
+
updateSessionAuth: (id) => (canSee(id) ? ctx.updateSessionAuth(id) : Promise.resolve(null)),
|
|
142
144
|
updateSession: (id, name, pinned) => canSee(id) && ctx.updateSession(id, name, pinned),
|
|
143
145
|
ackSession: (id) => canSee(id) && ctx.ackSession(id),
|
|
144
146
|
buildResumeCommand: (id) => (canSee(id) ? ctx.buildResumeCommand(id) : null),
|
|
@@ -14,6 +14,10 @@ export interface TargetEntry {
|
|
|
14
14
|
readonly available: boolean;
|
|
15
15
|
readonly default: boolean;
|
|
16
16
|
readonly capabilities: ExecutionCapabilities;
|
|
17
|
+
|
|
18
|
+
// Whether the target's provider reaches impd's credential broker, so an
|
|
19
|
+
// agent that takes its credential from the broker can run there.
|
|
20
|
+
readonly brokerAuth: boolean;
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
// What a target without a provider can do: nothing.
|
|
@@ -46,5 +50,6 @@ export function buildTargetList(
|
|
|
46
50
|
available: target.provider !== null,
|
|
47
51
|
default: target.id === defaultTarget,
|
|
48
52
|
capabilities: target.provider?.capabilities ?? NO_CAPABILITIES,
|
|
53
|
+
brokerAuth: target.provider?.brokerAuth !== undefined,
|
|
49
54
|
}));
|
|
50
55
|
}
|