@zgeoff/atc 2.32.0 → 2.32.1
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 +4 -1
- package/src/agents/build-claude-config-seed.ts +23 -0
- package/src/agents/build-claude-guest-launch.ts +55 -0
- package/src/agents/claude-adapter.ts +171 -5
- package/src/agents/claude-config-seed-file.ts +6 -0
- package/src/agents/gateway-adapter.ts +17 -49
- package/src/cli.ts +8 -0
- package/src/client/boot-daemon.ts +87 -6
- package/src/client/spawn-picker.ts +10 -5
- package/src/daemon/build-agent-list.ts +13 -7
- package/src/daemon/sessions.ts +22 -11
- package/src/mcp/mcp-tools.ts +2 -1
- package/src/mcp-http-server.ts +14 -3
- package/src/shared/collect-claude-auth.ts +84 -0
- package/src/shared/config.ts +16 -0
package/package.json
CHANGED
|
@@ -52,11 +52,14 @@ export interface GuestPaths {
|
|
|
52
52
|
/**
|
|
53
53
|
* The credential an agent takes from impd's broker instead of holding it:
|
|
54
54
|
* its gateway's endpoint and auth selection, and the auth profiles that
|
|
55
|
-
* selection resolves against.
|
|
55
|
+
* selection resolves against. `brokerRequired` is true for an agent that
|
|
56
|
+
* starts only behind the broker; false lets it start on a target that
|
|
57
|
+
* reaches no broker under the sign-in that target's host holds.
|
|
56
58
|
*/
|
|
57
59
|
export interface AuthSelection {
|
|
58
60
|
readonly gateway: Pick<GatewayConfig, 'id' | 'baseURL'> & { readonly auth: GatewayAuth };
|
|
59
61
|
readonly profiles: ReadonlyMap<string, AuthProfile>;
|
|
62
|
+
readonly brokerRequired: boolean;
|
|
60
63
|
}
|
|
61
64
|
|
|
62
65
|
/**
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { CLAUDE_CONFIG_SEED_FILE } from './claude-config-seed-file';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The guest file that seeds a session's own Claude config folder with the
|
|
5
|
+
* state the CLI reads from `.claude.json` to start without its first-run
|
|
6
|
+
* onboarding, and with folder trust for `trustedRoot`, the exact root of a
|
|
7
|
+
* verified clone, when one is given. It holds no account: the placeholder
|
|
8
|
+
* credential is what keeps the CLI from asking for a login. Without a
|
|
9
|
+
* trusted root the CLI asks a person to trust the workspace on its first
|
|
10
|
+
* start.
|
|
11
|
+
*/
|
|
12
|
+
export function buildClaudeConfigSeed(
|
|
13
|
+
trustedRoot: string | null,
|
|
14
|
+
): Readonly<Record<string, string>> {
|
|
15
|
+
const seed =
|
|
16
|
+
trustedRoot === null
|
|
17
|
+
? ONBOARDED_CONFIG
|
|
18
|
+
: { ...ONBOARDED_CONFIG, projects: { [trustedRoot]: { hasTrustDialogAccepted: true } } };
|
|
19
|
+
|
|
20
|
+
return { [CLAUDE_CONFIG_SEED_FILE]: JSON.stringify(seed, null, 2) };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ONBOARDED_CONFIG = { hasCompletedOnboarding: true };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { SpawnPlan } from './agent-adapter';
|
|
2
|
+
import { CLAUDE_CONFIG_SEED_FILE } from './claude-config-seed-file';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The launch of a Claude CLI that runs in a remote host with a config
|
|
6
|
+
* folder of the session's own inside its guest folder, so no account or
|
|
7
|
+
* setting of the host's image reaches it. A shell copies the seed into the
|
|
8
|
+
* folder only when the folder holds no `.claude.json` yet, then runs the
|
|
9
|
+
* CLI, so a resumed session keeps the state the CLI wrote, a folder trust
|
|
10
|
+
* a person accepted included. `argv` is the CLI's own command line. The
|
|
11
|
+
* shell exits before it runs the CLI when the host's environment sets any
|
|
12
|
+
* of `refusedEnv`, which must be variable names, and says which one.
|
|
13
|
+
*/
|
|
14
|
+
export function buildClaudeGuestLaunch(
|
|
15
|
+
guestDir: string,
|
|
16
|
+
argv: readonly string[],
|
|
17
|
+
refusedEnv: readonly string[] = [],
|
|
18
|
+
): SpawnPlan & { readonly env: Readonly<Record<string, string>> } {
|
|
19
|
+
const configDir = `${guestDir}/${CLAUDE_CONFIG_FOLDER}`;
|
|
20
|
+
const guards = refusedEnv.map((name) => buildEnvGuard(name)).join('');
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
bin: 'sh',
|
|
24
|
+
args: [
|
|
25
|
+
'-c',
|
|
26
|
+
`${guards}${SEED_CONFIG_SCRIPT}`,
|
|
27
|
+
'sh',
|
|
28
|
+
configDir,
|
|
29
|
+
`${guestDir}/${CLAUDE_CONFIG_SEED_FILE}`,
|
|
30
|
+
...argv,
|
|
31
|
+
],
|
|
32
|
+
env: { CLAUDE_CONFIG_DIR: configDir },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const ENV_NAME = /^[A-Z_][A-Z0-9_]*$/u;
|
|
37
|
+
|
|
38
|
+
// A shell step that stops the launch when the variable is set, empty or
|
|
39
|
+
// not, with a line that says which variable and why.
|
|
40
|
+
function buildEnvGuard(name: string): string {
|
|
41
|
+
if (!ENV_NAME.test(name)) {
|
|
42
|
+
throw new Error(`refused variable ${name} is not an environment variable name`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return `[ -z "\${${name}+x}" ] || { echo "atc: ${name} is set in this host's environment and overrides the sign-in atc gives this session, so Claude does not start" >&2; exit ${REFUSED_EXIT}; }; `;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// sysexits' EX_CONFIG: a configuration error.
|
|
49
|
+
const REFUSED_EXIT = 78;
|
|
50
|
+
const CLAUDE_CONFIG_FOLDER = 'claude-config';
|
|
51
|
+
|
|
52
|
+
// The arguments are the config folder, the seed, and the CLI's own command
|
|
53
|
+
// line.
|
|
54
|
+
const SEED_CONFIG_SCRIPT =
|
|
55
|
+
'mkdir -p "$1" && { [ -e "$1/.claude.json" ] || cp "$2" "$1/.claude.json"; } && shift 2 && exec "$@"';
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import type { AdapterEvent } from '../protocol/adapter-event';
|
|
4
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
4
5
|
import type { HookEvent } from '../protocol/hook-event';
|
|
5
6
|
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
6
7
|
import { buildOptionalString } from '../shared/build-optional-string';
|
|
7
8
|
import type { Config } from '../shared/config';
|
|
9
|
+
import { isBrokerVariable } from '../shared/is-broker-variable';
|
|
8
10
|
import { isRecord } from '../shared/report';
|
|
9
11
|
import { toAgentSessionID } from '../shared/to-agent-session-id';
|
|
10
12
|
import { toShellArg } from '../shared/to-shell-arg';
|
|
@@ -12,6 +14,7 @@ import { truncateDetail } from '../shared/truncate-detail';
|
|
|
12
14
|
import type {
|
|
13
15
|
AgentAdapter,
|
|
14
16
|
AgentProfile,
|
|
17
|
+
AuthSelection,
|
|
15
18
|
GuestPaths,
|
|
16
19
|
GuestSpawnPlan,
|
|
17
20
|
HeadlessRunner,
|
|
@@ -21,7 +24,10 @@ import type {
|
|
|
21
24
|
SpawnOptions,
|
|
22
25
|
SpawnPlan,
|
|
23
26
|
} from './agent-adapter';
|
|
27
|
+
import { buildArgsWithoutFlags } from './build-args-without-flags';
|
|
24
28
|
import { buildATCBridgeFiles } from './build-atc-bridge-files';
|
|
29
|
+
import { buildClaudeConfigSeed } from './build-claude-config-seed';
|
|
30
|
+
import { buildClaudeGuestLaunch } from './build-claude-guest-launch';
|
|
25
31
|
import { buildClaudeOverrideArgs } from './build-claude-override-args';
|
|
26
32
|
import { buildHookSettings } from './build-hook-settings';
|
|
27
33
|
import { CLAUDE_EFFORT_LEVELS } from './claude-effort-levels';
|
|
@@ -109,13 +115,36 @@ export class ClaudeAdapter implements AgentAdapter {
|
|
|
109
115
|
|
|
110
116
|
return {
|
|
111
117
|
bin: this.config.claudeBin,
|
|
112
|
-
args: this.buildArgs(opts, this.settingsFile, this.writeBridge()),
|
|
118
|
+
args: this.buildArgs(this.config.claudeArgs, opts, this.settingsFile, this.writeBridge()),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// With `claudeAuth` configured, Claude signs in with the subscription
|
|
123
|
+
// token impd's broker holds on a target that reaches the broker, and
|
|
124
|
+
// with the sign-in of the host it runs on anywhere else.
|
|
125
|
+
findAuthSelection(): AuthSelection | null {
|
|
126
|
+
const auth = this.config.claudeAuth;
|
|
127
|
+
|
|
128
|
+
if (auth === null) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
gateway: {
|
|
134
|
+
id: this.id,
|
|
135
|
+
baseURL: ANTHROPIC_API_URL,
|
|
136
|
+
auth: { profiles: auth.profiles, placeholderEnv: { [OAUTH_VARIABLE]: PLACEHOLDER } },
|
|
137
|
+
},
|
|
138
|
+
profiles: this.config.authProfiles,
|
|
139
|
+
brokerRequired: false,
|
|
113
140
|
};
|
|
114
141
|
}
|
|
115
142
|
|
|
116
143
|
// A remote session reports through the atc inside its host, so without
|
|
117
144
|
// one it has no instrumentation and cannot run there. Its settings and
|
|
118
|
-
// its copy of the mod travel with it, and it has no statusline.
|
|
145
|
+
// its copy of the mod travel with it, and it has no statusline. A
|
|
146
|
+
// session that signs in through impd's broker runs with a config folder
|
|
147
|
+
// of its own and the placeholder in place of the token.
|
|
119
148
|
planGuestSpawn(opts: SpawnOptions, guest: GuestPaths): GuestSpawnPlan | null {
|
|
120
149
|
if (guest.atc === null) {
|
|
121
150
|
return null;
|
|
@@ -127,9 +156,18 @@ export class ClaudeAdapter implements AgentAdapter {
|
|
|
127
156
|
([path, content]): [string, string] => [`atc-bridge/${path}`, content],
|
|
128
157
|
);
|
|
129
158
|
|
|
159
|
+
if (guest.auth !== undefined) {
|
|
160
|
+
return this.planSubscriptionGuestSpawn(opts, guest.dir, guest.auth, argv, bridge);
|
|
161
|
+
}
|
|
162
|
+
|
|
130
163
|
return {
|
|
131
164
|
bin: this.config.claudeBin,
|
|
132
|
-
args: this.buildArgs(
|
|
165
|
+
args: this.buildArgs(
|
|
166
|
+
this.config.claudeArgs,
|
|
167
|
+
opts,
|
|
168
|
+
`${guest.dir}/settings.json`,
|
|
169
|
+
`${guest.dir}/atc-bridge`,
|
|
170
|
+
),
|
|
133
171
|
files: {
|
|
134
172
|
'settings.json': JSON.stringify(buildHookSettings({ id: this.id }, 0, argv), null, 2),
|
|
135
173
|
...Object.fromEntries(bridge),
|
|
@@ -137,9 +175,114 @@ export class ClaudeAdapter implements AgentAdapter {
|
|
|
137
175
|
};
|
|
138
176
|
}
|
|
139
177
|
|
|
140
|
-
|
|
178
|
+
// Seeds the config folder of a session that signs in through impd's
|
|
179
|
+
// broker with trust for the clone; any other remote session reads the
|
|
180
|
+
// config of the host's image, which atc never writes.
|
|
181
|
+
planGuestWorkspaceTrust(root: string): Readonly<Record<string, string>> | null {
|
|
182
|
+
if (this.config.claudeAuth === null) {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return buildClaudeConfigSeed(root);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// A settings file of the session's own per binding revision carries the
|
|
190
|
+
// placeholder, and so does the CLI's environment. The configured
|
|
191
|
+
// arguments go without a permission mode, so the mode the session's own
|
|
192
|
+
// user settings set applies. A credential, endpoint, or provider variable
|
|
193
|
+
// in the configured settings, or in the host's environment, would keep
|
|
194
|
+
// the CLI from sending the placeholder, so either refuses the start.
|
|
195
|
+
private planSubscriptionGuestSpawn(
|
|
196
|
+
opts: SpawnOptions,
|
|
197
|
+
dir: string,
|
|
198
|
+
auth: NonNullable<GuestPaths['auth']>,
|
|
199
|
+
argv: readonly string[],
|
|
200
|
+
bridge: readonly (readonly [string, string])[],
|
|
201
|
+
): GuestSpawnPlan {
|
|
202
|
+
const refusal = this.findCredentialOverride();
|
|
203
|
+
|
|
204
|
+
if (refusal !== null) {
|
|
205
|
+
throw refusal;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const settingsPath = `auth-r${auth.revision}/settings.json`;
|
|
209
|
+
|
|
210
|
+
const settings = buildHookSettings(
|
|
211
|
+
{ id: this.id, env: { ...auth.env, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' } },
|
|
212
|
+
0,
|
|
213
|
+
argv,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const launch = buildClaudeGuestLaunch(
|
|
217
|
+
dir,
|
|
218
|
+
[
|
|
219
|
+
this.config.claudeBin,
|
|
220
|
+
...this.buildArgs(
|
|
221
|
+
buildArgsWithoutFlags(this.config.claudeArgs, ['--permission-mode']),
|
|
222
|
+
opts,
|
|
223
|
+
`${dir}/${settingsPath}`,
|
|
224
|
+
`${dir}/atc-bridge`,
|
|
225
|
+
),
|
|
226
|
+
],
|
|
227
|
+
[...OUTRANKING_VARIABLES],
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
bin: launch.bin,
|
|
232
|
+
args: launch.args,
|
|
233
|
+
files: {
|
|
234
|
+
[settingsPath]: JSON.stringify(settings, null, 2),
|
|
235
|
+
...buildClaudeConfigSeed(null),
|
|
236
|
+
...Object.fromEntries(bridge),
|
|
237
|
+
},
|
|
238
|
+
env: { ...launch.env, ...auth.env },
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// The refusal for configured arguments whose inline `--settings` sets a
|
|
243
|
+
// variable that overrides the subscription sign-in or routes the CLI
|
|
244
|
+
// around impd's broker, or null when they set none.
|
|
245
|
+
private findCredentialOverride(): DaemonError | null {
|
|
246
|
+
const inline = findFlagValue(this.config.claudeArgs, ['--settings']);
|
|
247
|
+
|
|
248
|
+
if (inline === null) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let parsed: unknown;
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
parsed = JSON.parse(inline);
|
|
256
|
+
} catch {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const env = isRecord(parsed) ? parsed['env'] : undefined;
|
|
261
|
+
const keys = isRecord(env) ? Object.keys(env) : [];
|
|
262
|
+
|
|
263
|
+
const variable = keys.find(
|
|
264
|
+
(key) => OUTRANKING_VARIABLES.has(key) || key === OAUTH_VARIABLE || isBrokerVariable(key),
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
if (variable === undefined) {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return new DaemonError(
|
|
272
|
+
'auth_target_unsupported',
|
|
273
|
+
`claude signs in through impd's broker on this target, but claudeArgs set ${variable} in --settings, which would override or route around that sign-in`,
|
|
274
|
+
{ agent: this.id, problem: 'guest_env_conflict', variable },
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private buildArgs(
|
|
279
|
+
configured: readonly string[],
|
|
280
|
+
opts: SpawnOptions,
|
|
281
|
+
settings: string,
|
|
282
|
+
pluginDir: string,
|
|
283
|
+
): string[] {
|
|
141
284
|
return [
|
|
142
|
-
...buildClaudeOverrideArgs(
|
|
285
|
+
...buildClaudeOverrideArgs(configured, opts),
|
|
143
286
|
'--settings',
|
|
144
287
|
settings,
|
|
145
288
|
'--plugin-dir',
|
|
@@ -298,6 +441,29 @@ export class ClaudeAdapter implements AgentAdapter {
|
|
|
298
441
|
}
|
|
299
442
|
}
|
|
300
443
|
|
|
444
|
+
// The endpoint Claude Code sends a subscription token to, the variable it
|
|
445
|
+
// reads that token from, and the value impd's broker replaces with the
|
|
446
|
+
// token on the host's side.
|
|
447
|
+
const ANTHROPIC_API_URL = 'https://api.anthropic.com';
|
|
448
|
+
const OAUTH_VARIABLE = 'CLAUDE_CODE_OAUTH_TOKEN';
|
|
449
|
+
const PLACEHOLDER = 'imp-broker-placeholder';
|
|
450
|
+
|
|
451
|
+
// The variables that keep Claude Code from sending the subscription token
|
|
452
|
+
// to the Anthropic API: a credential it takes ahead of that token, another
|
|
453
|
+
// endpoint, or a cloud provider it signs in to instead.
|
|
454
|
+
const OUTRANKING_VARIABLES: ReadonlySet<string> = new Set([
|
|
455
|
+
'ANTHROPIC_AUTH_TOKEN',
|
|
456
|
+
'ANTHROPIC_API_KEY',
|
|
457
|
+
'ANTHROPIC_BASE_URL',
|
|
458
|
+
'CLAUDE_CODE_USE_BEDROCK',
|
|
459
|
+
'CLAUDE_CODE_USE_VERTEX',
|
|
460
|
+
'CLAUDE_CODE_USE_FOUNDRY',
|
|
461
|
+
'CLAUDE_CODE_USE_MANTLE',
|
|
462
|
+
'CLAUDE_CODE_USE_ANTHROPIC_AWS',
|
|
463
|
+
'CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD',
|
|
464
|
+
'CLAUDE_CODE_USE_GATEWAY',
|
|
465
|
+
]);
|
|
466
|
+
|
|
301
467
|
// The aliases Claude Code documents for `--model`, each resolving to a model
|
|
302
468
|
// the account picks. A full model name is accepted as well.
|
|
303
469
|
const CLAUDE_MODEL_ALIASES = [
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the seed of a session's own Claude config folder travels inside its
|
|
3
|
+
* guest folder: beside the config folder rather than in it, so a transfer
|
|
4
|
+
* never replaces the state an earlier run of the CLI left there.
|
|
5
|
+
*/
|
|
6
|
+
export const CLAUDE_CONFIG_SEED_FILE = 'claude-config-seed.json';
|
|
@@ -23,6 +23,8 @@ import type {
|
|
|
23
23
|
SpawnPlan,
|
|
24
24
|
} from './agent-adapter';
|
|
25
25
|
import { buildATCBridgeFiles } from './build-atc-bridge-files';
|
|
26
|
+
import { buildClaudeConfigSeed } from './build-claude-config-seed';
|
|
27
|
+
import { buildClaudeGuestLaunch } from './build-claude-guest-launch';
|
|
26
28
|
import { buildClaudeOverrideArgs } from './build-claude-override-args';
|
|
27
29
|
import { buildHookSettings } from './build-hook-settings';
|
|
28
30
|
import { buildRestoreModeArgs } from './build-restore-mode-args';
|
|
@@ -141,6 +143,7 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
141
143
|
return {
|
|
142
144
|
gateway: { id: this.gateway.id, baseURL: this.gateway.baseURL, auth },
|
|
143
145
|
profiles: this.config.authProfiles,
|
|
146
|
+
brokerRequired: true,
|
|
144
147
|
};
|
|
145
148
|
}
|
|
146
149
|
|
|
@@ -214,30 +217,25 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
214
217
|
argv,
|
|
215
218
|
);
|
|
216
219
|
|
|
217
|
-
const
|
|
220
|
+
const launch = buildClaudeGuestLaunch(guest.dir, [
|
|
221
|
+
this.gateway.bin,
|
|
222
|
+
...this.buildArgs(
|
|
223
|
+
opts,
|
|
224
|
+
this.buildGuestModeArgs(),
|
|
225
|
+
`${guest.dir}/${settingsPath}`,
|
|
226
|
+
`${guest.dir}/atc-bridge`,
|
|
227
|
+
),
|
|
228
|
+
]);
|
|
218
229
|
|
|
219
230
|
return {
|
|
220
|
-
bin:
|
|
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
|
-
],
|
|
231
|
+
bin: launch.bin,
|
|
232
|
+
args: launch.args,
|
|
235
233
|
files: {
|
|
236
234
|
[settingsPath]: JSON.stringify(settings, null, 2),
|
|
237
|
-
|
|
235
|
+
...buildClaudeConfigSeed(null),
|
|
238
236
|
...Object.fromEntries(bridge),
|
|
239
237
|
},
|
|
240
|
-
env: {
|
|
238
|
+
env: { ...launch.env, ...guest.auth.env },
|
|
241
239
|
};
|
|
242
240
|
}
|
|
243
241
|
|
|
@@ -246,16 +244,7 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
246
244
|
return null;
|
|
247
245
|
}
|
|
248
246
|
|
|
249
|
-
return
|
|
250
|
-
[CONFIG_SEED_FILE]: JSON.stringify(
|
|
251
|
-
{
|
|
252
|
-
...ONBOARDED_CONFIG,
|
|
253
|
-
projects: { [root]: { hasTrustDialogAccepted: true } },
|
|
254
|
-
},
|
|
255
|
-
null,
|
|
256
|
-
2,
|
|
257
|
-
),
|
|
258
|
-
};
|
|
247
|
+
return buildClaudeConfigSeed(root);
|
|
259
248
|
}
|
|
260
249
|
|
|
261
250
|
normalizeHook(e: HookEvent): AdapterEvent {
|
|
@@ -434,27 +423,6 @@ const PLACEHOLDER = 'imp-broker-placeholder';
|
|
|
434
423
|
// The Claude CLI's mode that asks a person before each action.
|
|
435
424
|
const MANUAL_PERMISSION_MODE = 'default';
|
|
436
425
|
|
|
437
|
-
// The Claude config folder a brokered session gets inside its guest
|
|
438
|
-
// folder, so no account or setting of the host's image reaches it.
|
|
439
|
-
const CLAUDE_CONFIG_FOLDER = 'claude-config';
|
|
440
|
-
|
|
441
|
-
// The state the Claude CLI reads from its config folder's `.claude.json`
|
|
442
|
-
// to start without its first-run onboarding. It holds no account: the
|
|
443
|
-
// placeholder credential is what keeps the CLI from asking for a login.
|
|
444
|
-
// It holds no folder trust either, so the CLI asks a person to trust the
|
|
445
|
-
// workspace on its first start.
|
|
446
|
-
const ONBOARDED_CONFIG = { hasCompletedOnboarding: true };
|
|
447
|
-
|
|
448
|
-
// Where that state travels, beside the config folder rather than in it.
|
|
449
|
-
const CONFIG_SEED_FILE = 'claude-config-seed.json';
|
|
450
|
-
|
|
451
|
-
// Copies the seed into the config folder only when the folder holds no
|
|
452
|
-
// `.claude.json` yet, then runs the CLI. A resumed session keeps the state
|
|
453
|
-
// the CLI wrote, a folder trust a person accepted included. The arguments
|
|
454
|
-
// are the config folder, the seed, and the CLI's own command line.
|
|
455
|
-
const SEED_CONFIG_SCRIPT =
|
|
456
|
-
'mkdir -p "$1" && { [ -e "$1/.claude.json" ] || cp "$2" "$1/.claude.json"; } && shift 2 && exec "$@"';
|
|
457
|
-
|
|
458
426
|
// The gateway's settings without a credential helper, which a brokered
|
|
459
427
|
// session never runs.
|
|
460
428
|
function buildSettingsWithoutHelper(
|
package/src/cli.ts
CHANGED
|
@@ -62,6 +62,12 @@ const main = defineCommand({
|
|
|
62
62
|
description:
|
|
63
63
|
'Origin clients reach the --http server at, such as https://mcp.example.com',
|
|
64
64
|
},
|
|
65
|
+
'wait-for-daemon': {
|
|
66
|
+
type: 'boolean',
|
|
67
|
+
default: false,
|
|
68
|
+
description:
|
|
69
|
+
'With --http, wait for a running daemon instead of starting one, for a managed daemon',
|
|
70
|
+
},
|
|
65
71
|
},
|
|
66
72
|
async run(ctx) {
|
|
67
73
|
if (!ctx.args.http) {
|
|
@@ -85,6 +91,7 @@ const main = defineCommand({
|
|
|
85
91
|
host: ctx.args.host ?? null,
|
|
86
92
|
port: port === null ? null : port.port,
|
|
87
93
|
publicURL: ctx.args['public-url'] ?? null,
|
|
94
|
+
waitForDaemon: ctx.args['wait-for-daemon'],
|
|
88
95
|
});
|
|
89
96
|
},
|
|
90
97
|
}),
|
|
@@ -382,6 +389,7 @@ async function runDaemon(listenArg: string | null, tokenFile: string | null): Pr
|
|
|
382
389
|
...cfg.principalErrors,
|
|
383
390
|
...cfg.workspaceErrors,
|
|
384
391
|
...cfg.authProfileErrors,
|
|
392
|
+
...cfg.claudeAuthErrors,
|
|
385
393
|
...cfg.gatewayErrors,
|
|
386
394
|
]) {
|
|
387
395
|
console.error(`atc daemon: config: ${problem}`);
|
|
@@ -37,11 +37,18 @@ export interface DaemonBootOptions {
|
|
|
37
37
|
// session it hosts; without the callback, or resolving false, the boot
|
|
38
38
|
// rejects and the daemon keeps running.
|
|
39
39
|
readonly onProtocolMismatch?: (mismatch: ProtocolMismatch) => Promise<boolean>;
|
|
40
|
+
|
|
41
|
+
// When set, the boot never starts a daemon: it waits this many
|
|
42
|
+
// milliseconds for one to answer, then rejects. A caller that a service
|
|
43
|
+
// manager starts beside a managed daemon sets it, so the two never race
|
|
44
|
+
// for the state directory.
|
|
45
|
+
readonly waitForDaemonMs?: number;
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
/**
|
|
43
49
|
* Opens a handshaken client to the daemon, booting the daemon first when
|
|
44
|
-
* neither the computed socket nor the one in the daemon's record answers
|
|
50
|
+
* neither the computed socket nor the one in the daemon's record answers,
|
|
51
|
+
* or waiting for one to answer when the caller set `waitForDaemonMs`.
|
|
45
52
|
* Overlapping calls in one process share a single boot. A daemon from an
|
|
46
53
|
* older build stays in service, since stopping it would end every hosted
|
|
47
54
|
* session, and is reported as stale so the caller can offer a deliberate
|
|
@@ -53,15 +60,21 @@ export interface DaemonBootOptions {
|
|
|
53
60
|
* its own boot would otherwise flag daemons that are already current.
|
|
54
61
|
*/
|
|
55
62
|
export async function bootDaemonClient(options: DaemonBootOptions = {}): Promise<DaemonBoot> {
|
|
63
|
+
const wait =
|
|
64
|
+
options.waitForDaemonMs === undefined
|
|
65
|
+
? null
|
|
66
|
+
: { deadline: Date.now() + options.waitForDaemonMs, timeoutMs: options.waitForDaemonMs };
|
|
67
|
+
|
|
56
68
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
57
69
|
const build = getBuild();
|
|
58
|
-
|
|
59
|
-
const opened = await openOrBootDaemon();
|
|
60
|
-
|
|
70
|
+
const opened = wait === null ? await openOrBootDaemon() : await waitForDaemon(wait);
|
|
61
71
|
const client = opened.client;
|
|
62
72
|
|
|
63
73
|
try {
|
|
64
|
-
const hello =
|
|
74
|
+
const hello =
|
|
75
|
+
wait === null
|
|
76
|
+
? await client.sendHello(build)
|
|
77
|
+
: await waitForHello(() => client.sendHello(build), wait);
|
|
65
78
|
|
|
66
79
|
return {
|
|
67
80
|
client,
|
|
@@ -125,6 +138,59 @@ async function openOrBootDaemon(): Promise<OpenedDaemon> {
|
|
|
125
138
|
return booted;
|
|
126
139
|
}
|
|
127
140
|
|
|
141
|
+
// The time a waiting boot gives up, and the length of its wait for the
|
|
142
|
+
// error text.
|
|
143
|
+
interface DaemonWait {
|
|
144
|
+
readonly deadline: number;
|
|
145
|
+
readonly timeoutMs: number;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Polls the known sockets until a daemon answers, and never starts one.
|
|
150
|
+
*/
|
|
151
|
+
async function waitForDaemon(wait: DaemonWait): Promise<OpenedDaemon> {
|
|
152
|
+
for (;;) {
|
|
153
|
+
const opened = await tryOpenKnownDaemon();
|
|
154
|
+
|
|
155
|
+
if (opened !== null) {
|
|
156
|
+
return opened;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (Date.now() >= wait.deadline) {
|
|
160
|
+
throw new Error(formatWaitFailure(wait.timeoutMs));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
await Bun.sleep(100);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Sends the handshake and rejects once the wait's deadline passes, so a
|
|
169
|
+
* daemon that takes the connection but never answers cannot hold the wait
|
|
170
|
+
* open.
|
|
171
|
+
*/
|
|
172
|
+
async function waitForHello(
|
|
173
|
+
sendHello: () => Promise<Readonly<Record<string, unknown>>>,
|
|
174
|
+
wait: DaemonWait,
|
|
175
|
+
): Promise<Readonly<Record<string, unknown>>> {
|
|
176
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
177
|
+
|
|
178
|
+
const expired = new Promise<never>((_resolve, reject) => {
|
|
179
|
+
timer = setTimeout(
|
|
180
|
+
() => {
|
|
181
|
+
reject(new Error(formatWaitFailure(wait.timeoutMs)));
|
|
182
|
+
},
|
|
183
|
+
Math.max(0, wait.deadline - Date.now()),
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
return await Promise.race([sendHello(), expired]);
|
|
189
|
+
} finally {
|
|
190
|
+
clearTimeout(timer);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
128
194
|
/**
|
|
129
195
|
* Tries the socket this environment computes, then the one the running
|
|
130
196
|
* daemon recorded in the state directory: a client whose environment lacks
|
|
@@ -185,13 +251,28 @@ const bootDaemonOnce = makeSingleFlight(async () => {
|
|
|
185
251
|
// directory a sandbox hides, needs a different fix than one that never
|
|
186
252
|
// started, so the message tells them apart.
|
|
187
253
|
function formatBootFailure(): string {
|
|
254
|
+
return (
|
|
255
|
+
formatUnreachableDaemon() ?? 'the atc daemon did not come up; try `atc daemon` for its output'
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function formatUnreachableDaemon(): string | null {
|
|
188
260
|
const record = findDaemonRecord(daemonRecordFile);
|
|
189
261
|
|
|
190
262
|
if (record !== null && isProcessAlive(record.pid)) {
|
|
191
263
|
return `the atc daemon (pid ${record.pid}) is running, but its socket ${record.socketPath} is unreachable from here`;
|
|
192
264
|
}
|
|
193
265
|
|
|
194
|
-
return
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// A waiting caller was told not to start a daemon, so the message says so
|
|
270
|
+
// and points at starting the managed one.
|
|
271
|
+
function formatWaitFailure(timeoutMs: number): string {
|
|
272
|
+
return (
|
|
273
|
+
formatUnreachableDaemon() ??
|
|
274
|
+
`no atc daemon answered at ${daemonSocketPath} within ${timeoutMs / 1000}s, and this process does not start one; start \`atc daemon\` (or the service that runs it) first`
|
|
275
|
+
);
|
|
195
276
|
}
|
|
196
277
|
|
|
197
278
|
function isProcessAlive(pid: number): boolean {
|
|
@@ -1721,8 +1721,8 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
1721
1721
|
return;
|
|
1722
1722
|
}
|
|
1723
1723
|
|
|
1724
|
-
// An agent that
|
|
1725
|
-
//
|
|
1724
|
+
// An agent that starts only behind impd's broker runs only on a target
|
|
1725
|
+
// that reaches the broker.
|
|
1726
1726
|
const brokered = isBrokerAgent(listed['agents'], this.agent);
|
|
1727
1727
|
const listedTargets = collectTargetPicks(listed);
|
|
1728
1728
|
const picks = listedTargets.filter((t) => !brokered || t.brokerAuth);
|
|
@@ -2026,13 +2026,18 @@ function buildProbedRepo(
|
|
|
2026
2026
|
};
|
|
2027
2027
|
}
|
|
2028
2028
|
|
|
2029
|
-
// Whether the daemon lists an agent as
|
|
2030
|
-
//
|
|
2029
|
+
// Whether the daemon lists an agent as starting only behind impd's broker.
|
|
2030
|
+
// A daemon that sends no `brokerRequired` holds every agent with
|
|
2031
|
+
// `brokerAuth` to the broker, and one that sends neither lists none.
|
|
2031
2032
|
function isBrokerAgent(raw: unknown, agent: AgentID): boolean {
|
|
2032
2033
|
return (
|
|
2033
2034
|
Array.isArray(raw) &&
|
|
2034
2035
|
raw.some(
|
|
2035
|
-
(entry: unknown) =>
|
|
2036
|
+
(entry: unknown) =>
|
|
2037
|
+
isRecord(entry) &&
|
|
2038
|
+
entry['id'] === agent &&
|
|
2039
|
+
entry['brokerAuth'] === true &&
|
|
2040
|
+
entry['brokerRequired'] !== false,
|
|
2036
2041
|
)
|
|
2037
2042
|
);
|
|
2038
2043
|
}
|
|
@@ -2,8 +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, and one that
|
|
6
|
-
//
|
|
5
|
+
// session, and one that starts only behind impd's broker only where some
|
|
6
|
+
// target reaches the broker.
|
|
7
7
|
readonly spawn: boolean;
|
|
8
8
|
readonly readTranscript: boolean;
|
|
9
9
|
|
|
@@ -22,9 +22,13 @@ export interface AgentEntry {
|
|
|
22
22
|
// Whether the agent's binary resolves, on PATH or at its configured path.
|
|
23
23
|
readonly installed: boolean;
|
|
24
24
|
|
|
25
|
-
// Whether the agent takes its credential from impd's broker
|
|
26
|
-
//
|
|
25
|
+
// Whether the agent takes its credential from impd's broker on a target
|
|
26
|
+
// whose entry has `brokerAuth`.
|
|
27
27
|
readonly brokerAuth: boolean;
|
|
28
|
+
|
|
29
|
+
// Whether it starts only there. False for an agent that starts on any
|
|
30
|
+
// other target under the sign-in that target's host holds.
|
|
31
|
+
readonly brokerRequired: boolean;
|
|
28
32
|
readonly capabilities: AgentCapabilities;
|
|
29
33
|
readonly models: Readonly<Record<string, string>> | null;
|
|
30
34
|
readonly spawnOptions: SpawnOptionEntries;
|
|
@@ -59,19 +63,21 @@ export function buildAgentList(
|
|
|
59
63
|
return adapters.map((adapter) => {
|
|
60
64
|
const profile = adapter.profile;
|
|
61
65
|
const installed = profile === undefined ? false : isInstalled(profile.bin);
|
|
62
|
-
const
|
|
66
|
+
const selection = adapter.findAuthSelection?.() ?? null;
|
|
67
|
+
const brokerRequired = selection?.brokerRequired ?? false;
|
|
63
68
|
|
|
64
69
|
const spawnable =
|
|
65
70
|
installed &&
|
|
66
71
|
(adapter.findSpawnRefusal?.() ?? null) === null &&
|
|
67
|
-
(!
|
|
72
|
+
(!brokerRequired || hasBrokerTarget);
|
|
68
73
|
|
|
69
74
|
return {
|
|
70
75
|
id: adapter.id,
|
|
71
76
|
label: profile?.label ?? adapter.id,
|
|
72
77
|
kind: profile?.kind ?? adapter.id,
|
|
73
78
|
installed,
|
|
74
|
-
brokerAuth,
|
|
79
|
+
brokerAuth: selection !== null,
|
|
80
|
+
brokerRequired,
|
|
75
81
|
capabilities: {
|
|
76
82
|
spawn: spawnable,
|
|
77
83
|
readTranscript: adapter.parseTranscriptLine !== undefined,
|
package/src/daemon/sessions.ts
CHANGED
|
@@ -599,19 +599,24 @@ export class SessionManager {
|
|
|
599
599
|
|
|
600
600
|
/**
|
|
601
601
|
* Throws the refusal for an agent that takes its credential from impd's
|
|
602
|
-
* broker on a target
|
|
603
|
-
*
|
|
602
|
+
* broker on a target where it cannot, before a spawn or an adopt does
|
|
603
|
+
* any work: a target whose provider reaches no broker, for an agent that
|
|
604
|
+
* starts only behind one, or a broker the daemon cannot bind.
|
|
604
605
|
*/
|
|
605
606
|
requireAgentTarget(agent: AgentID, target: string): void {
|
|
606
|
-
const
|
|
607
|
+
const selection = this.findAdapter(agent)?.findAuthSelection?.() ?? null;
|
|
607
608
|
|
|
608
|
-
if (
|
|
609
|
+
if (selection === null) {
|
|
609
610
|
return;
|
|
610
611
|
}
|
|
611
612
|
|
|
612
|
-
const
|
|
613
|
+
const host = this.targets.get(target)?.provider?.brokerAuth;
|
|
614
|
+
|
|
615
|
+
if (host === undefined && !selection.brokerRequired) {
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
613
618
|
|
|
614
|
-
if (
|
|
619
|
+
if (host === undefined || this.authBinder === null) {
|
|
615
620
|
throw buildBrokerTargetRefusal(agent, target);
|
|
616
621
|
}
|
|
617
622
|
}
|
|
@@ -938,7 +943,7 @@ export class SessionManager {
|
|
|
938
943
|
if (!isLocalTrust && !isGuestTrust) {
|
|
939
944
|
throw new DaemonError(
|
|
940
945
|
'unsupported',
|
|
941
|
-
'trustClonedWorkspace requires stock Claude on the local target or a
|
|
946
|
+
'trustClonedWorkspace requires stock Claude on the local target, or stock Claude or a Claude gateway signed in through the broker on an imp target',
|
|
942
947
|
);
|
|
943
948
|
}
|
|
944
949
|
}
|
|
@@ -1138,10 +1143,12 @@ export class SessionManager {
|
|
|
1138
1143
|
}
|
|
1139
1144
|
|
|
1140
1145
|
// What a harness of this agent needs to take its credential from impd's
|
|
1141
|
-
// broker on a provider, or null when
|
|
1142
|
-
//
|
|
1143
|
-
//
|
|
1144
|
-
//
|
|
1146
|
+
// broker on a provider, or null when it takes none there: the agent
|
|
1147
|
+
// takes none at all, or its selection lets it start on a target that
|
|
1148
|
+
// reaches no broker under that host's own sign-in. Throws the refusal
|
|
1149
|
+
// for an agent that starts only behind the broker on a target whose
|
|
1150
|
+
// provider has none, so its credential never falls back to a local
|
|
1151
|
+
// start, and for a selection the current auth profiles cannot bind.
|
|
1145
1152
|
private resolveHarnessAuth(
|
|
1146
1153
|
adapter: AgentAdapter,
|
|
1147
1154
|
provider: ExecutionProvider,
|
|
@@ -1155,6 +1162,10 @@ export class SessionManager {
|
|
|
1155
1162
|
|
|
1156
1163
|
const host = provider.brokerAuth;
|
|
1157
1164
|
|
|
1165
|
+
if (host === undefined && !selection.brokerRequired) {
|
|
1166
|
+
return null;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1158
1169
|
if (host === undefined || this.authBinder === null) {
|
|
1159
1170
|
throw buildBrokerTargetRefusal(adapter.id, target);
|
|
1160
1171
|
}
|
package/src/mcp/mcp-tools.ts
CHANGED
|
@@ -61,7 +61,7 @@ const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
|
61
61
|
"Where the session's working directory comes from. Omit it to run the session in cwd as it stands. With it, atc materializes a clean checkout into cwd on the target, which must not exist yet: {kind:'path', path, allowDirty?} checks out the pushed HEAD of a git checkout on the atc host, leaving its uncommitted and untracked changes behind with a warning, or refusing them when allowDirty is 'refuse'; {kind:'git', url, ref or sha, credentialRef?} checks out a branch, tag, or full commit of a repository, with credentialRef {kind:'env', name} naming the atc daemon's environment variable that holds its token. A directory outside git runs in place only on a target on the atc host itself (provider local-pty), with cwd equal to its path. Submodules and Git LFS are refused, and so is a URL that carries a credential.",
|
|
62
62
|
),
|
|
63
63
|
trustClonedWorkspace: SPAWN_SCHEMA.shape.trustClonedWorkspace.describe(
|
|
64
|
-
"Trust the exact verified clone for this launch. An explicit true or false overrides the configured target trustClonedWorkspace default; omitting both keeps trust off. Requires a workspace source and either stock Claude on the local target, which adds trust for the clone root alone to the user's Claude config, or a brokered Claude gateway with isolated guest config
|
|
64
|
+
"Trust the exact verified clone for this launch. An explicit true or false overrides the configured target trustClonedWorkspace default; omitting both keeps trust off. Requires a workspace source and either stock Claude on the local target, which adds trust for the clone root alone to the user's Claude config, or, on an imp target, a brokered Claude gateway or stock Claude signed in through the broker, each with isolated guest config; other launches are refused. Accepts repository configuration and helpers without changing tool permission mode. Existing guest config is preserved.",
|
|
65
65
|
),
|
|
66
66
|
detached: z
|
|
67
67
|
.boolean()
|
|
@@ -223,6 +223,7 @@ const AGENTS_OUTPUT: Readonly<Record<string, unknown>> = {
|
|
|
223
223
|
kind: { type: 'string' },
|
|
224
224
|
installed: { type: 'boolean' },
|
|
225
225
|
brokerAuth: { type: 'boolean' },
|
|
226
|
+
brokerRequired: { type: 'boolean' },
|
|
226
227
|
capabilities: {
|
|
227
228
|
type: 'object',
|
|
228
229
|
properties: {
|
package/src/mcp-http-server.ts
CHANGED
|
@@ -12,17 +12,28 @@ interface MCPHTTPFlags {
|
|
|
12
12
|
readonly host: string | null;
|
|
13
13
|
readonly port: number | null;
|
|
14
14
|
readonly publicURL: string | null;
|
|
15
|
+
readonly waitForDaemon: boolean;
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
// How long `--wait-for-daemon` waits before it exits. A service manager's
|
|
19
|
+
// restart covers a daemon that takes longer.
|
|
20
|
+
const DAEMON_WAIT_MS = 30_000;
|
|
21
|
+
|
|
17
22
|
/**
|
|
18
|
-
* Runs `atc mcp --http` in the foreground: boots the daemon when it is down
|
|
19
|
-
*
|
|
23
|
+
* Runs `atc mcp --http` in the foreground: boots the daemon when it is down,
|
|
24
|
+
* or with `--wait-for-daemon` waits for one and exits when none answers, and
|
|
25
|
+
* serves MCP over HTTP until Ctrl-C. Approval codes print here, so the
|
|
20
26
|
* terminal running it is where the operator approves a client.
|
|
21
27
|
*/
|
|
22
28
|
export async function runMCPHTTPServer(build: string, flags: MCPHTTPFlags): Promise<void> {
|
|
23
29
|
const config = loadMCPHTTPConfig();
|
|
30
|
+
const bootOptions = flags.waitForDaemon ? { waitForDaemonMs: DAEMON_WAIT_MS } : {};
|
|
31
|
+
|
|
32
|
+
const boot = await bootDaemonClient(bootOptions).catch((error: unknown) => {
|
|
33
|
+
console.error(`atc mcp --http: ${error instanceof Error ? error.message : String(error)}`);
|
|
24
34
|
|
|
25
|
-
|
|
35
|
+
return process.exit(1);
|
|
36
|
+
});
|
|
26
37
|
|
|
27
38
|
boot.client.stop();
|
|
28
39
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { AuthProfile } from './collect-auth-profiles';
|
|
2
|
+
import { isRecord } from './report';
|
|
3
|
+
import { resolveAuthProfiles } from './resolve-auth-profiles';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The profiles a stock Claude session on a target that reaches impd's
|
|
7
|
+
* broker signs in through: one of them sends the subscription's setup
|
|
8
|
+
* token to the Anthropic API as a bearer authorization header, and the
|
|
9
|
+
* rest bind beside it, such as a GitHub token.
|
|
10
|
+
*/
|
|
11
|
+
export interface ClaudeAuth {
|
|
12
|
+
readonly profiles: readonly string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface CollectedClaudeAuth {
|
|
16
|
+
readonly auth: ClaudeAuth | null;
|
|
17
|
+
readonly errors: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Reads the `claudeAuth` entry against the auth profiles. An entry that
|
|
22
|
+
* does not resolve, or whose profiles send no bearer authorization header
|
|
23
|
+
* to the Anthropic API, is left out with every problem that refused it, so
|
|
24
|
+
* stock Claude keeps the sign-in of the host it runs on rather than binding
|
|
25
|
+
* a credential the CLI would never send.
|
|
26
|
+
*/
|
|
27
|
+
export function collectClaudeAuth(
|
|
28
|
+
raw: unknown,
|
|
29
|
+
authProfiles: ReadonlyMap<string, AuthProfile>,
|
|
30
|
+
): CollectedClaudeAuth {
|
|
31
|
+
if (raw === undefined) {
|
|
32
|
+
return { auth: null, errors: [] };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const profiles = isRecord(raw) ? raw['profiles'] : undefined;
|
|
36
|
+
|
|
37
|
+
if (
|
|
38
|
+
!isRecord(raw) ||
|
|
39
|
+
!Array.isArray(profiles) ||
|
|
40
|
+
profiles.length === 0 ||
|
|
41
|
+
!profiles.every((name) => typeof name === 'string')
|
|
42
|
+
) {
|
|
43
|
+
return {
|
|
44
|
+
auth: null,
|
|
45
|
+
errors: ['claudeAuth must be an object with a non-empty profiles array'],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const extra = Object.keys(raw).find((key) => key !== 'profiles');
|
|
50
|
+
|
|
51
|
+
if (extra !== undefined) {
|
|
52
|
+
return {
|
|
53
|
+
auth: null,
|
|
54
|
+
errors: [
|
|
55
|
+
`claudeAuth.${extra} cannot be set: atc fixes the endpoint and the placeholder of a Claude subscription session`,
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const selected = profiles.map(String);
|
|
61
|
+
const resolution = resolveAuthProfiles(authProfiles, selected);
|
|
62
|
+
|
|
63
|
+
if ('problem' in resolution) {
|
|
64
|
+
return { auth: null, errors: [`claudeAuth: ${resolution.problem.message}`] };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const rule = resolution.resolved.secrets
|
|
68
|
+
.flatMap((secret) => secret.rules)
|
|
69
|
+
.find((r) => r.host === ANTHROPIC_API_HOST);
|
|
70
|
+
|
|
71
|
+
if (rule?.header !== 'authorization' || rule.scheme !== 'bearer') {
|
|
72
|
+
return {
|
|
73
|
+
auth: null,
|
|
74
|
+
errors: [
|
|
75
|
+
`claudeAuth needs a profile that sets a bearer authorization header for ${ANTHROPIC_API_HOST}, where Claude Code sends its subscription token`,
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { auth: { profiles: selected }, errors: [] };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The one host Claude Code sends a subscription token to.
|
|
84
|
+
const ANTHROPIC_API_HOST = 'api.anthropic.com';
|
package/src/shared/config.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { buildOptionalString } from './build-optional-string';
|
|
|
5
5
|
import { buildOptionalStringArray } from './build-optional-string-array';
|
|
6
6
|
import { collectAuthProfiles } from './collect-auth-profiles';
|
|
7
7
|
import type { AuthProfile } from './collect-auth-profiles';
|
|
8
|
+
import { collectClaudeAuth } from './collect-claude-auth';
|
|
9
|
+
import type { ClaudeAuth } from './collect-claude-auth';
|
|
8
10
|
import { collectDirRoots } from './collect-dir-roots';
|
|
9
11
|
import { collectGateways } from './collect-gateways';
|
|
10
12
|
import type { GatewayConfig } from './collect-gateways';
|
|
@@ -23,6 +25,12 @@ import { resolveHomeDir } from './resolve-home-dir';
|
|
|
23
25
|
export interface Config {
|
|
24
26
|
claudeBin: string;
|
|
25
27
|
claudeArgs: string[];
|
|
28
|
+
|
|
29
|
+
// The auth profiles stock Claude signs in through on a target that
|
|
30
|
+
// reaches impd's broker, null when it keeps the host's own sign-in there,
|
|
31
|
+
// and the problems that left an entry out.
|
|
32
|
+
claudeAuth: ClaudeAuth | null;
|
|
33
|
+
claudeAuthErrors: readonly string[];
|
|
26
34
|
grokBin: string;
|
|
27
35
|
grokArgs: string[];
|
|
28
36
|
codexBin: string;
|
|
@@ -75,6 +83,8 @@ interface LeaderKey {
|
|
|
75
83
|
const DEFAULTS: Config = {
|
|
76
84
|
claudeBin: 'claude',
|
|
77
85
|
claudeArgs: [],
|
|
86
|
+
claudeAuth: null,
|
|
87
|
+
claudeAuthErrors: [],
|
|
78
88
|
grokBin: 'grok',
|
|
79
89
|
grokArgs: [],
|
|
80
90
|
codexBin: 'codex',
|
|
@@ -124,6 +134,7 @@ export const daemonRecordFile = join(stateDir, 'daemon.json');
|
|
|
124
134
|
const CONFIG_SCHEMA = z.object({
|
|
125
135
|
claudeBin: buildOptionalString(),
|
|
126
136
|
claudeArgs: buildOptionalStringArray(),
|
|
137
|
+
claudeAuth: z.unknown().optional(),
|
|
127
138
|
grokBin: buildOptionalString(),
|
|
128
139
|
grokArgs: buildOptionalStringArray(),
|
|
129
140
|
codexBin: buildOptionalString(),
|
|
@@ -225,6 +236,8 @@ function tryWriteDefaultConfig(file: string): void {
|
|
|
225
236
|
*/
|
|
226
237
|
export function renderDefaultConfig(): string {
|
|
227
238
|
const {
|
|
239
|
+
claudeAuth: _claudeAuth,
|
|
240
|
+
claudeAuthErrors: _claudeAuthErrors,
|
|
228
241
|
gatewayErrors: _gatewayErrors,
|
|
229
242
|
authProfiles: _authProfiles,
|
|
230
243
|
authProfileErrors: _authProfileErrors,
|
|
@@ -284,6 +297,7 @@ export function parseConfig(raw: unknown, file: string = configFile): Config {
|
|
|
284
297
|
const dirs = { roots: collectDirRoots(parsed.data.dirs) };
|
|
285
298
|
const workspaces = collectWorkspacesConfig(parsed.data.workspaces);
|
|
286
299
|
const authProfiles = collectAuthProfiles(parsed.data.authProfiles);
|
|
300
|
+
const claudeAuth = collectClaudeAuth(parsed.data.claudeAuth, authProfiles.profiles);
|
|
287
301
|
|
|
288
302
|
const gateways = collectGateways(
|
|
289
303
|
parsed.data.gateways,
|
|
@@ -302,6 +316,8 @@ export function parseConfig(raw: unknown, file: string = configFile): Config {
|
|
|
302
316
|
return {
|
|
303
317
|
claudeBin,
|
|
304
318
|
claudeArgs,
|
|
319
|
+
claudeAuth: claudeAuth.auth,
|
|
320
|
+
claudeAuthErrors: claudeAuth.errors,
|
|
305
321
|
grokBin,
|
|
306
322
|
grokArgs,
|
|
307
323
|
codexBin,
|