@zgeoff/atc 2.37.0 → 2.39.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 +21 -0
- package/src/agents/build-claude-config-seed.ts +7 -4
- package/src/agents/build-claude-mcp-config.ts +27 -0
- package/src/agents/build-claude-project-settings-check.ts +74 -0
- package/src/agents/claude-adapter.ts +23 -4
- package/src/agents/gateway-adapter.ts +8 -0
- package/src/build-migrated-config.ts +16 -4
- package/src/daemon/sessions.ts +143 -11
- package/src/shared/collect-agents.ts +31 -8
- package/src/shared/collect-claude-auth.ts +117 -4
- package/src/shared/collect-legacy-agents.ts +3 -0
package/package.json
CHANGED
|
@@ -86,6 +86,22 @@ export interface GuestSpawnPlan extends SpawnPlan {
|
|
|
86
86
|
readonly env?: Readonly<Record<string, string>>;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* The project settings files the agent applies on top of what atc passes
|
|
91
|
+
* it, and the refusal for a file whose content would override or route
|
|
92
|
+
* around a sign-in through impd's broker, or null when it holds nothing
|
|
93
|
+
* that would. `files` are relative to the directory a harness starts in;
|
|
94
|
+
* `rootFiles` are relative to that directory, each directory above it, and
|
|
95
|
+
* the main checkout of the git worktree it lies in, since the agent reads
|
|
96
|
+
* them at a repository's root. `findRefusal` takes the file's absolute path
|
|
97
|
+
* on the host.
|
|
98
|
+
*/
|
|
99
|
+
export interface ProjectSettingsCheck {
|
|
100
|
+
readonly files: readonly string[];
|
|
101
|
+
readonly rootFiles: readonly string[];
|
|
102
|
+
readonly findRefusal: (path: string, content: string) => DaemonError | null;
|
|
103
|
+
}
|
|
104
|
+
|
|
89
105
|
export interface TranscriptToolUse {
|
|
90
106
|
readonly name: string;
|
|
91
107
|
readonly input: string;
|
|
@@ -269,6 +285,11 @@ export interface AgentAdapter {
|
|
|
269
285
|
// The command that exits 0 inside a remote host when the agent there can
|
|
270
286
|
// sign in without a person. Absent: atc runs no check.
|
|
271
287
|
readonly planAuthCheck?: () => readonly string[];
|
|
288
|
+
|
|
289
|
+
// The project settings a remote launch behind impd's broker reads in
|
|
290
|
+
// the directory the harness starts in, before it starts. Absent: atc
|
|
291
|
+
// reads none.
|
|
292
|
+
readonly planProjectSettingsCheck?: () => ProjectSettingsCheck;
|
|
272
293
|
readonly normalizeHook: (e: HookEvent) => AdapterEvent;
|
|
273
294
|
readonly loadName: (
|
|
274
295
|
source: string,
|
|
@@ -4,10 +4,12 @@ import { CLAUDE_CONFIG_SEED_FILE } from './claude-config-seed-file';
|
|
|
4
4
|
* The guest file that seeds a session's own Claude config folder with the
|
|
5
5
|
* state the CLI reads from `.claude.json` to start without its first-run
|
|
6
6
|
* onboarding, and with folder trust for `trustedRoot`, the exact root of a
|
|
7
|
-
* verified clone, when one is given.
|
|
7
|
+
* verified clone, when one is given. Trust also approves every MCP server
|
|
8
|
+
* the clone's own `.mcp.json` declares, so an unattended session never
|
|
9
|
+
* waits on the CLI's approval prompt. It holds no account: the placeholder
|
|
8
10
|
* 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
|
|
10
|
-
* start.
|
|
11
|
+
* trusted root the CLI asks a person to trust the workspace, and to approve
|
|
12
|
+
* the clone's MCP servers, on its first start.
|
|
11
13
|
*/
|
|
12
14
|
export function buildClaudeConfigSeed(
|
|
13
15
|
trustedRoot: string | null,
|
|
@@ -15,9 +17,10 @@ export function buildClaudeConfigSeed(
|
|
|
15
17
|
const seed =
|
|
16
18
|
trustedRoot === null
|
|
17
19
|
? ONBOARDED_CONFIG
|
|
18
|
-
: { ...ONBOARDED_CONFIG, projects: { [trustedRoot]:
|
|
20
|
+
: { ...ONBOARDED_CONFIG, projects: { [trustedRoot]: TRUSTED_PROJECT } };
|
|
19
21
|
|
|
20
22
|
return { [CLAUDE_CONFIG_SEED_FILE]: JSON.stringify(seed, null, 2) };
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
const ONBOARDED_CONFIG = { hasCompletedOnboarding: true };
|
|
26
|
+
const TRUSTED_PROJECT = { hasTrustDialogAccepted: true, enableAllProjectMcpServers: true };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ClaudeMCPServer } from '../shared/collect-claude-auth';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The MCP config file that gives a session each server over HTTP with the
|
|
5
|
+
* broker's placeholder as a bearer credential in the server's header, so
|
|
6
|
+
* impd's broker swaps in the credential it holds for the server's host and
|
|
7
|
+
* the session never holds it.
|
|
8
|
+
*/
|
|
9
|
+
export function buildClaudeMCPConfig(servers: readonly ClaudeMCPServer[]): {
|
|
10
|
+
readonly mcpServers: Readonly<Record<string, unknown>>;
|
|
11
|
+
} {
|
|
12
|
+
return {
|
|
13
|
+
mcpServers: Object.fromEntries(
|
|
14
|
+
servers.map((server) => [
|
|
15
|
+
server.name,
|
|
16
|
+
{
|
|
17
|
+
type: 'http',
|
|
18
|
+
url: server.url,
|
|
19
|
+
headers: { [server.header]: `Bearer ${BROKER_PLACEHOLDER}` },
|
|
20
|
+
},
|
|
21
|
+
]),
|
|
22
|
+
),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The value impd's broker replaces with the credential on the host's side.
|
|
27
|
+
const BROKER_PLACEHOLDER = 'imp-broker-placeholder';
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
2
|
+
import { isSubscriptionOverrideVariable } from '../shared/is-subscription-override-variable';
|
|
3
|
+
import { isRecord } from '../shared/report';
|
|
4
|
+
import type { ProjectSettingsCheck } from './agent-adapter';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The check of a Claude CLI session's project settings before a launch
|
|
8
|
+
* behind impd's broker. The CLI applies a repository's
|
|
9
|
+
* `.claude/settings.json` from the directory it starts in, and its
|
|
10
|
+
* `.claude/settings.local.json` from there and from the repository's root
|
|
11
|
+
* or a worktree's main checkout, once the folder is trusted; a person can
|
|
12
|
+
* accept that trust inside the session. Each file refuses the launch when
|
|
13
|
+
* its `env` block sets a variable that would override the sign-in or route
|
|
14
|
+
* around the broker, or when it sets `apiKeyHelper`. A file that is not a
|
|
15
|
+
* JSON object refuses it too, since what the CLI would take from it is
|
|
16
|
+
* unknown. A refusal holds the setting's name, never its value.
|
|
17
|
+
*/
|
|
18
|
+
export function buildClaudeProjectSettingsCheck(agent: string): ProjectSettingsCheck {
|
|
19
|
+
return {
|
|
20
|
+
files: ['.claude/settings.json', LOCAL_SETTINGS_FILE],
|
|
21
|
+
rootFiles: [LOCAL_SETTINGS_FILE],
|
|
22
|
+
findRefusal: (file, content) => {
|
|
23
|
+
const setting = findOverrideSetting(content);
|
|
24
|
+
|
|
25
|
+
if (setting === null) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const message =
|
|
30
|
+
setting === UNPARSEABLE
|
|
31
|
+
? `agent '${agent}' signs in through impd's broker, but ${file} is not a JSON object, so atc cannot check it for settings that would override that sign-in`
|
|
32
|
+
: `agent '${agent}' signs in through impd's broker, but ${file} sets ${setting}, which would override or route around that sign-in`;
|
|
33
|
+
|
|
34
|
+
return new DaemonError('auth_target_unsupported', message, {
|
|
35
|
+
agent,
|
|
36
|
+
problem: 'project_settings_conflict',
|
|
37
|
+
file,
|
|
38
|
+
setting,
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const LOCAL_SETTINGS_FILE = '.claude/settings.local.json';
|
|
45
|
+
const UNPARSEABLE = '(unparseable)';
|
|
46
|
+
|
|
47
|
+
// The first setting a project settings file holds that would override the
|
|
48
|
+
// sign-in, as `env.<variable>` or `apiKeyHelper`, the unparseable marker
|
|
49
|
+
// for content that is not a JSON object, or null when it holds none.
|
|
50
|
+
function findOverrideSetting(content: string): string | null {
|
|
51
|
+
let parsed: unknown;
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(content);
|
|
55
|
+
} catch {
|
|
56
|
+
return UNPARSEABLE;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!isRecord(parsed) || Array.isArray(parsed)) {
|
|
60
|
+
return UNPARSEABLE;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (parsed['apiKeyHelper'] !== undefined) {
|
|
64
|
+
return 'apiKeyHelper';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const env = parsed['env'];
|
|
68
|
+
|
|
69
|
+
const variable = isRecord(env)
|
|
70
|
+
? Object.keys(env).find((key) => isSubscriptionOverrideVariable(key))
|
|
71
|
+
: undefined;
|
|
72
|
+
|
|
73
|
+
return variable === undefined ? null : `env.${variable}`;
|
|
74
|
+
}
|
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
GuestSpawnPlan,
|
|
24
24
|
HeadlessRunner,
|
|
25
25
|
NameUpdate,
|
|
26
|
+
ProjectSettingsCheck,
|
|
26
27
|
ResumeCheck,
|
|
27
28
|
SpawnOptionSpecs,
|
|
28
29
|
SpawnOptions,
|
|
@@ -32,7 +33,9 @@ import { buildArgsWithoutFlags } from './build-args-without-flags';
|
|
|
32
33
|
import { buildATCBridgeFiles } from './build-atc-bridge-files';
|
|
33
34
|
import { buildClaudeConfigSeed } from './build-claude-config-seed';
|
|
34
35
|
import { buildClaudeGuestLaunch } from './build-claude-guest-launch';
|
|
36
|
+
import { buildClaudeMCPConfig } from './build-claude-mcp-config';
|
|
35
37
|
import { buildClaudeOverrideArgs } from './build-claude-override-args';
|
|
38
|
+
import { buildClaudeProjectSettingsCheck } from './build-claude-project-settings-check';
|
|
36
39
|
import { buildHookSettings } from './build-hook-settings';
|
|
37
40
|
import type { HookSettingsProfile } from './build-hook-settings';
|
|
38
41
|
import { buildRestoreModeArgs } from './build-restore-mode-args';
|
|
@@ -225,6 +228,12 @@ export class ClaudeAdapter implements AgentAdapter {
|
|
|
225
228
|
return buildClaudeConfigSeed(root);
|
|
226
229
|
}
|
|
227
230
|
|
|
231
|
+
// The repository's own settings files outrank what the session's user
|
|
232
|
+
// settings hold, so a launch behind impd's broker reads them first.
|
|
233
|
+
planProjectSettingsCheck(): ProjectSettingsCheck {
|
|
234
|
+
return buildClaudeProjectSettingsCheck(this.id);
|
|
235
|
+
}
|
|
236
|
+
|
|
228
237
|
// A settings file of the session's own per binding revision carries the
|
|
229
238
|
// placeholder, and so does the CLI's environment. The configured
|
|
230
239
|
// arguments go without a permission mode, so the mode the session's own
|
|
@@ -232,7 +241,11 @@ export class ClaudeAdapter implements AgentAdapter {
|
|
|
232
241
|
// config bundle, come from the host's own Claude config folder as it is
|
|
233
242
|
// at this launch, staged under a key of this launch's own. A credential, endpoint, or provider variable in the
|
|
234
243
|
// configured settings, or in the host's environment, would keep the CLI
|
|
235
|
-
// from sending the placeholder, so either refuses the start.
|
|
244
|
+
// from sending the placeholder, so either refuses the start. The entry's
|
|
245
|
+
// MCP servers reach the CLI through an MCP config file of the binding
|
|
246
|
+
// revision, each with the placeholder in its header. Their flag sits
|
|
247
|
+
// ahead of the settings flag, so the variadic flag never takes the
|
|
248
|
+
// prompt as one of its values.
|
|
236
249
|
private planSubscriptionGuestSpawn(
|
|
237
250
|
opts: SpawnOptions,
|
|
238
251
|
dir: string,
|
|
@@ -247,6 +260,9 @@ export class ClaudeAdapter implements AgentAdapter {
|
|
|
247
260
|
}
|
|
248
261
|
|
|
249
262
|
const settingsPath = `auth-r${auth.revision}/settings.json`;
|
|
263
|
+
const mcpServers = this.entry.mcpServers ?? [];
|
|
264
|
+
const mcpPath = `auth-r${auth.revision}/mcp.json`;
|
|
265
|
+
const mcpArgs = mcpServers.length === 0 ? [] : ['--mcp-config', `${dir}/${mcpPath}`];
|
|
250
266
|
const bundleKey = randomUUID();
|
|
251
267
|
|
|
252
268
|
const launch = buildClaudeGuestLaunch(
|
|
@@ -256,7 +272,7 @@ export class ClaudeAdapter implements AgentAdapter {
|
|
|
256
272
|
...this.buildArgs(
|
|
257
273
|
buildArgsWithoutFlags(this.entry.args, ['--permission-mode']),
|
|
258
274
|
opts,
|
|
259
|
-
|
|
275
|
+
mcpArgs,
|
|
260
276
|
`${dir}/${settingsPath}`,
|
|
261
277
|
`${dir}/atc-bridge`,
|
|
262
278
|
),
|
|
@@ -284,6 +300,9 @@ export class ClaudeAdapter implements AgentAdapter {
|
|
|
284
300
|
args: launch.args,
|
|
285
301
|
files: {
|
|
286
302
|
[settingsPath]: JSON.stringify(settings, null, 2),
|
|
303
|
+
...(mcpServers.length === 0
|
|
304
|
+
? {}
|
|
305
|
+
: { [mcpPath]: JSON.stringify(buildClaudeMCPConfig(mcpServers), null, 2) }),
|
|
287
306
|
...buildClaudeConfigSeed(null),
|
|
288
307
|
...Object.fromEntries(
|
|
289
308
|
Object.entries(bundle).map(([path, content]) => [
|
|
@@ -365,13 +384,13 @@ export class ClaudeAdapter implements AgentAdapter {
|
|
|
365
384
|
private buildArgs(
|
|
366
385
|
configured: readonly string[],
|
|
367
386
|
opts: SpawnOptions,
|
|
368
|
-
|
|
387
|
+
leadArgs: readonly string[],
|
|
369
388
|
settings: string,
|
|
370
389
|
pluginDir: string,
|
|
371
390
|
): string[] {
|
|
372
391
|
return [
|
|
373
392
|
...buildClaudeOverrideArgs(configured, opts),
|
|
374
|
-
...
|
|
393
|
+
...leadArgs,
|
|
375
394
|
'--settings',
|
|
376
395
|
settings,
|
|
377
396
|
'--plugin-dir',
|
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
GuestSpawnPlan,
|
|
18
18
|
HeadlessRunner,
|
|
19
19
|
NameUpdate,
|
|
20
|
+
ProjectSettingsCheck,
|
|
20
21
|
ResumeCheck,
|
|
21
22
|
SpawnOptionSpecs,
|
|
22
23
|
SpawnOptions,
|
|
@@ -26,6 +27,7 @@ import { buildATCBridgeFiles } from './build-atc-bridge-files';
|
|
|
26
27
|
import { buildClaudeConfigSeed } from './build-claude-config-seed';
|
|
27
28
|
import { buildClaudeGuestLaunch } from './build-claude-guest-launch';
|
|
28
29
|
import { buildClaudeOverrideArgs } from './build-claude-override-args';
|
|
30
|
+
import { buildClaudeProjectSettingsCheck } from './build-claude-project-settings-check';
|
|
29
31
|
import { buildHookSettings } from './build-hook-settings';
|
|
30
32
|
import { buildRestoreModeArgs } from './build-restore-mode-args';
|
|
31
33
|
import { ClaudeAdapter } from './claude-adapter';
|
|
@@ -335,6 +337,12 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
335
337
|
];
|
|
336
338
|
}
|
|
337
339
|
|
|
340
|
+
// The repository's own settings files reach the CLI as well, so a launch
|
|
341
|
+
// behind impd's broker reads them first.
|
|
342
|
+
planProjectSettingsCheck(): ProjectSettingsCheck {
|
|
343
|
+
return buildClaudeProjectSettingsCheck(this.id);
|
|
344
|
+
}
|
|
345
|
+
|
|
338
346
|
private buildBrokerRefusal(reason: string): DaemonError {
|
|
339
347
|
return new DaemonError(
|
|
340
348
|
'auth_target_unsupported',
|
|
@@ -113,10 +113,22 @@ function renderAgentEntry(entry: AgentEntry): Record<string, unknown> {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
if (entry.auth !== undefined) {
|
|
116
|
-
rendered['auth'] =
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
116
|
+
rendered['auth'] = {
|
|
117
|
+
profiles: entry.auth.profiles,
|
|
118
|
+
...(Object.keys(entry.auth.placeholderEnv).length > 0
|
|
119
|
+
? { placeholderEnv: entry.auth.placeholderEnv }
|
|
120
|
+
: {}),
|
|
121
|
+
...(entry.mcpServers === undefined
|
|
122
|
+
? {}
|
|
123
|
+
: {
|
|
124
|
+
mcpServers: Object.fromEntries(
|
|
125
|
+
entry.mcpServers.map((server) => [
|
|
126
|
+
server.name,
|
|
127
|
+
{ url: server.url, profile: server.profile },
|
|
128
|
+
]),
|
|
129
|
+
),
|
|
130
|
+
}),
|
|
131
|
+
};
|
|
120
132
|
}
|
|
121
133
|
|
|
122
134
|
return rendered;
|
package/src/daemon/sessions.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { truncateDetail } from '../shared/truncate-detail';
|
|
|
27
27
|
import { truncateToBytes } from '../shared/truncate-to-bytes';
|
|
28
28
|
import type { FleetEntry, FleetEntryUpdate, FleetStore } from '../store/fleet-entry';
|
|
29
29
|
import type { SessionWorkspace } from '../store/workspace-materialization';
|
|
30
|
+
import { REPOSITORY_ENV_VARS } from '../workspace/repository-env-vars';
|
|
30
31
|
import type { BrokerAuthHost } from './broker-auth-host';
|
|
31
32
|
import { buildAuthBinding } from './build-auth-binding';
|
|
32
33
|
import type { AuthBinding } from './build-auth-binding';
|
|
@@ -241,6 +242,31 @@ const RESOLVE_DIR_SCRIPT = `p=$1; s=; while [ ! -d "$p" ]; do s=/\${p##*/}$s; p=
|
|
|
241
242
|
const REMOVE_DIR_SCRIPT =
|
|
242
243
|
'cd -P -- "$1" || exit 3; [ "$(pwd -P; printf x)" = "$2" ] || exit 4; find . -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + || exit 5; cd / && rmdir -- "$1"';
|
|
243
244
|
|
|
245
|
+
// Enters a directory as the host resolves it and prints, each followed by
|
|
246
|
+
// a NUL, every path where something, even a dangling symlink, stands: each
|
|
247
|
+
// start file in that directory, then each root file in that directory,
|
|
248
|
+
// every directory above it, and the main checkout of the git worktree it
|
|
249
|
+
// lies in. A `--` argument ends the start files. A directory that does not
|
|
250
|
+
// exist exits with the absent status.
|
|
251
|
+
const FIND_PROJECT_SETTINGS_SCRIPT = `cd -P -- "$1" 2>/dev/null || exit 3; shift; d=$(pwd -P)
|
|
252
|
+
emit() { if [ -e "$1" ] || [ -L "$1" ]; then printf '%s\\0' "$1"; fi; }
|
|
253
|
+
while [ "$#" -gt 0 ] && [ "$1" != -- ]; do emit "\${d%/}/$1"; shift; done; [ "$#" -gt 0 ] && shift
|
|
254
|
+
m=$(git -c safe.directory='*' -c core.fsmonitor=false rev-parse --path-format=absolute --git-common-dir 2>/dev/null) && m=\${m%/.git} || m=
|
|
255
|
+
for n in "$@"; do p=$d; while :; do emit "\${p%/}/$n"; [ "$p" = / ] && break; p=\${p%/*}; [ -n "$p" ] || p=/; done; [ -z "$m" ] || emit "\${m%/}/$n"; done
|
|
256
|
+
exit 0`;
|
|
257
|
+
|
|
258
|
+
// Prints at most one byte more than the largest project settings file atc
|
|
259
|
+
// reads, following a symlink, and fails with the special-file status for
|
|
260
|
+
// anything but a regular file there, such as a FIFO or a device that would
|
|
261
|
+
// never end.
|
|
262
|
+
const READ_SETTINGS_FILE = '[ -f "$1" ] || exit 4; exec head -c 1048577 -- "$1"';
|
|
263
|
+
const ABSENT_DIR_EXIT = 3;
|
|
264
|
+
|
|
265
|
+
// Runs git on the directory it starts in, whatever repository the daemon's
|
|
266
|
+
// environment pins git to.
|
|
267
|
+
const GIT_ENV_ARGV = ['env', ...[...REPOSITORY_ENV_VARS].flatMap((name) => ['-u', name])];
|
|
268
|
+
const MAX_SETTINGS_BYTES = 1_048_576;
|
|
269
|
+
|
|
244
270
|
// A readied host's harness plan, and the auth attempt that provisioned the
|
|
245
271
|
// host, if one did.
|
|
246
272
|
interface HarnessSetup {
|
|
@@ -693,6 +719,7 @@ export class SessionManager {
|
|
|
693
719
|
...(s.effort === undefined ? {} : { effort: s.effort }),
|
|
694
720
|
},
|
|
695
721
|
authSetup,
|
|
722
|
+
s.cwd,
|
|
696
723
|
);
|
|
697
724
|
|
|
698
725
|
plan = setup.plan;
|
|
@@ -999,6 +1026,10 @@ export class SessionManager {
|
|
|
999
1026
|
}
|
|
1000
1027
|
}
|
|
1001
1028
|
|
|
1029
|
+
// A spawn that builds its workspace reads the workspace's project
|
|
1030
|
+
// settings once it is built, not when the host is readied before it.
|
|
1031
|
+
const startDir = materialize === null ? cwd : null;
|
|
1032
|
+
|
|
1002
1033
|
const setupHost = () =>
|
|
1003
1034
|
this.setupHarnessOnHost(
|
|
1004
1035
|
adapter,
|
|
@@ -1008,9 +1039,17 @@ export class SessionManager {
|
|
|
1008
1039
|
target,
|
|
1009
1040
|
{ prompt, resume, ...overrides },
|
|
1010
1041
|
authSetup,
|
|
1042
|
+
startDir,
|
|
1011
1043
|
hostKey === id,
|
|
1012
1044
|
);
|
|
1013
1045
|
|
|
1046
|
+
// A launch behind the broker reads the project settings of the clone it
|
|
1047
|
+
// starts in, once the clone is on the host and before it is trusted.
|
|
1048
|
+
const checkWorkspace =
|
|
1049
|
+
auth === null
|
|
1050
|
+
? null
|
|
1051
|
+
: (root: string) => this.requireProjectSettings(adapter, provider, hostKey, target, root);
|
|
1052
|
+
|
|
1014
1053
|
// Takes back trust a spawn accepted in the user's own agent config when
|
|
1015
1054
|
// the spawn fails before its harness starts.
|
|
1016
1055
|
const localTrust: { remove: (() => Promise<void>) | null } = { remove: null };
|
|
@@ -1061,6 +1100,7 @@ export class SessionManager {
|
|
|
1061
1100
|
execution.identity,
|
|
1062
1101
|
materialize,
|
|
1063
1102
|
setupHost,
|
|
1103
|
+
checkWorkspace,
|
|
1064
1104
|
trustWorkspace,
|
|
1065
1105
|
);
|
|
1066
1106
|
});
|
|
@@ -1591,7 +1631,9 @@ export class SessionManager {
|
|
|
1591
1631
|
// Materializes a spawn's workspace, readying its host once the source
|
|
1592
1632
|
// resolves, or after the workspace for a directory that runs as it
|
|
1593
1633
|
// stands, and returns the directory it created, null for one that runs
|
|
1594
|
-
// as it stands. A
|
|
1634
|
+
// as it stands. A clone is checked and then trusted, either as the caller
|
|
1635
|
+
// asks, and a failure there removes it. A failure once the host is ready
|
|
1636
|
+
// takes it back.
|
|
1595
1637
|
private async materializeOnSpawnHost(
|
|
1596
1638
|
provider: ExecutionProvider,
|
|
1597
1639
|
id: SessionID,
|
|
@@ -1601,6 +1643,7 @@ export class SessionManager {
|
|
|
1601
1643
|
targetIdentity: string,
|
|
1602
1644
|
materialize: SpawnMaterializer,
|
|
1603
1645
|
setupHost: () => Promise<HarnessSetup>,
|
|
1646
|
+
checkWorkspace: ((root: string) => Promise<void>) | null,
|
|
1604
1647
|
trustWorkspace: ((root: string) => Promise<void>) | null,
|
|
1605
1648
|
): Promise<{
|
|
1606
1649
|
readonly setup: HarnessSetup;
|
|
@@ -1644,16 +1687,17 @@ export class SessionManager {
|
|
|
1644
1687
|
|
|
1645
1688
|
readied.setup ??= await setupHost();
|
|
1646
1689
|
|
|
1647
|
-
if (trustWorkspace !== null) {
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
}
|
|
1690
|
+
if (trustWorkspace !== null && (materialized === null || readied.root === null)) {
|
|
1691
|
+
throw new DaemonError(
|
|
1692
|
+
'bad_args',
|
|
1693
|
+
'trustClonedWorkspace requires a successfully cloned workspace',
|
|
1694
|
+
);
|
|
1695
|
+
}
|
|
1654
1696
|
|
|
1697
|
+
if (materialized !== null && readied.root !== null) {
|
|
1655
1698
|
try {
|
|
1656
|
-
await
|
|
1699
|
+
await checkWorkspace?.(readied.root);
|
|
1700
|
+
await trustWorkspace?.(readied.root);
|
|
1657
1701
|
} catch (error) {
|
|
1658
1702
|
let removed = false;
|
|
1659
1703
|
|
|
@@ -1665,7 +1709,7 @@ export class SessionManager {
|
|
|
1665
1709
|
|
|
1666
1710
|
if (!removed) {
|
|
1667
1711
|
throw new EffectRemainsError(
|
|
1668
|
-
'workspace
|
|
1712
|
+
'workspace setup failed and its clone could not be removed',
|
|
1669
1713
|
{ cause: error },
|
|
1670
1714
|
);
|
|
1671
1715
|
}
|
|
@@ -1906,9 +1950,10 @@ export class SessionManager {
|
|
|
1906
1950
|
target: string,
|
|
1907
1951
|
options: SpawnOptions,
|
|
1908
1952
|
auth: HarnessAuthSetup | null,
|
|
1953
|
+
dir: string,
|
|
1909
1954
|
): Promise<{ readonly plan: HarnessPlan; readonly attemptID: string | null }> {
|
|
1910
1955
|
return this.withHostReadying(hostKey, () =>
|
|
1911
|
-
this.setupHarnessOnHost(adapter, provider, id, hostKey, target, options, auth, false),
|
|
1956
|
+
this.setupHarnessOnHost(adapter, provider, id, hostKey, target, options, auth, dir, false),
|
|
1912
1957
|
);
|
|
1913
1958
|
}
|
|
1914
1959
|
|
|
@@ -1938,6 +1983,11 @@ export class SessionManager {
|
|
|
1938
1983
|
options: SpawnOptions,
|
|
1939
1984
|
auth: HarnessAuthSetup | null,
|
|
1940
1985
|
|
|
1986
|
+
// The directory the harness starts in, whose project settings a launch
|
|
1987
|
+
// behind the broker reads once the host is ready; null for a spawn
|
|
1988
|
+
// whose workspace is built after this, which reads them once it is.
|
|
1989
|
+
cwd: string | null,
|
|
1990
|
+
|
|
1941
1991
|
// Whether the host is a spawn's new host of its own, which a failure
|
|
1942
1992
|
// once it is readied destroys.
|
|
1943
1993
|
isNewHost: boolean,
|
|
@@ -2000,6 +2050,10 @@ export class SessionManager {
|
|
|
2000
2050
|
|
|
2001
2051
|
try {
|
|
2002
2052
|
await this.setupGuest(adapter, provider, hostKey, target, dir, plan.files);
|
|
2053
|
+
|
|
2054
|
+
if (auth !== null && cwd !== null) {
|
|
2055
|
+
await this.requireProjectSettings(adapter, provider, hostKey, target, cwd);
|
|
2056
|
+
}
|
|
2003
2057
|
} catch (error) {
|
|
2004
2058
|
if (attemptID !== null || isNewHost) {
|
|
2005
2059
|
await this.destroyFailedSpawnHost(provider, id, hostKey, attemptID);
|
|
@@ -2135,6 +2189,84 @@ export class SessionManager {
|
|
|
2135
2189
|
}
|
|
2136
2190
|
}
|
|
2137
2191
|
|
|
2192
|
+
// Reads each project settings file the agent applies for a harness behind
|
|
2193
|
+
// the broker, from the directory it starts in as the host resolves it, and
|
|
2194
|
+
// throws the agent's refusal for one that would override its sign-in. An
|
|
2195
|
+
// absent file passes; one the host holds but cannot read, that is not a
|
|
2196
|
+
// regular file, or that is larger than any settings file refuses, since
|
|
2197
|
+
// what the agent would take from it is unknown. A relative directory is
|
|
2198
|
+
// under the host's home.
|
|
2199
|
+
private async requireProjectSettings(
|
|
2200
|
+
adapter: AgentAdapter,
|
|
2201
|
+
provider: ExecutionProvider,
|
|
2202
|
+
hostKey: SessionID,
|
|
2203
|
+
target: string,
|
|
2204
|
+
cwd: string,
|
|
2205
|
+
): Promise<void> {
|
|
2206
|
+
const check = adapter.planProjectSettingsCheck?.();
|
|
2207
|
+
|
|
2208
|
+
if (check === undefined) {
|
|
2209
|
+
return;
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
// The host resolves the directory as given, since a lexical join would
|
|
2213
|
+
// drop a parent step after a symlink that the host follows.
|
|
2214
|
+
const home = posix.isAbsolute(cwd) ? null : await this.resolveHostHome(provider, hostKey);
|
|
2215
|
+
const dir = home === null ? cwd : `${home}/${cwd}`;
|
|
2216
|
+
|
|
2217
|
+
const found = await provider.runCommand({
|
|
2218
|
+
argv: [
|
|
2219
|
+
...GIT_ENV_ARGV,
|
|
2220
|
+
'sh',
|
|
2221
|
+
'-c',
|
|
2222
|
+
FIND_PROJECT_SETTINGS_SCRIPT,
|
|
2223
|
+
'sh',
|
|
2224
|
+
dir,
|
|
2225
|
+
...check.files,
|
|
2226
|
+
'--',
|
|
2227
|
+
...check.rootFiles,
|
|
2228
|
+
],
|
|
2229
|
+
cwd: '/',
|
|
2230
|
+
host: hostKey,
|
|
2231
|
+
});
|
|
2232
|
+
|
|
2233
|
+
if (found.exitCode === ABSENT_DIR_EXIT) {
|
|
2234
|
+
return;
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
if (found.exitCode !== 0) {
|
|
2238
|
+
throw this.buildUnreadableSettingsRefusal(adapter.id, target, dir);
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
const paths = new Set(found.stdout.split('\0').filter((path) => path !== ''));
|
|
2242
|
+
|
|
2243
|
+
for (const path of paths) {
|
|
2244
|
+
const read = await provider.runCommand({
|
|
2245
|
+
argv: ['sh', '-c', READ_SETTINGS_FILE, 'sh', path],
|
|
2246
|
+
cwd: '/',
|
|
2247
|
+
host: hostKey,
|
|
2248
|
+
});
|
|
2249
|
+
|
|
2250
|
+
if (read.exitCode !== 0 || Buffer.byteLength(read.stdout) > MAX_SETTINGS_BYTES) {
|
|
2251
|
+
throw this.buildUnreadableSettingsRefusal(adapter.id, target, path);
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
const refusal = check.findRefusal(path, read.stdout);
|
|
2255
|
+
|
|
2256
|
+
if (refusal !== null) {
|
|
2257
|
+
throw new DaemonError(refusal.code, refusal.message, { ...refusal.data, target });
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
private buildUnreadableSettingsRefusal(agent: string, target: string, path: string): DaemonError {
|
|
2263
|
+
return new DaemonError(
|
|
2264
|
+
'auth_target_unsupported',
|
|
2265
|
+
`agent '${agent}' signs in through impd's broker on target '${target}', but atc cannot read ${path} there as a settings file to check it for settings that would override that sign-in`,
|
|
2266
|
+
{ agent, target, problem: 'project_settings_unreadable', file: path },
|
|
2267
|
+
);
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2138
2270
|
// A sub-session runs on its parent's host when its resolved target, name
|
|
2139
2271
|
// and identity both, is the one its parent is bound to and that target's
|
|
2140
2272
|
// hosts have a lifecycle, so one host serves a top-level session and
|
|
@@ -3,6 +3,7 @@ import { checkGatewayAuth } from './check-gateway-auth';
|
|
|
3
3
|
import type { GatewayAuth } from './check-gateway-auth';
|
|
4
4
|
import type { AuthProfile } from './collect-auth-profiles';
|
|
5
5
|
import { collectClaudeAuth } from './collect-claude-auth';
|
|
6
|
+
import type { ClaudeMCPServer } from './collect-claude-auth';
|
|
6
7
|
import { isSubscriptionOverrideVariable } from './is-subscription-override-variable';
|
|
7
8
|
import { isRecord } from './report';
|
|
8
9
|
|
|
@@ -16,7 +17,8 @@ type AgentKind = 'claude' | 'codex' | 'grok';
|
|
|
16
17
|
* and arguments it starts with, and, for a Claude entry, the settings, the
|
|
17
18
|
* environment, and the backend and credential it runs against. A Claude entry
|
|
18
19
|
* with a `baseURL` is a gateway; without one it is stock Claude, whose
|
|
19
|
-
* `auth` holds profiles alone, for the subscription sign-in
|
|
20
|
+
* `auth` holds profiles alone, for the subscription sign-in, and whose
|
|
21
|
+
* `mcpServers` reach their hosts through those profiles.
|
|
20
22
|
*/
|
|
21
23
|
export interface AgentEntry {
|
|
22
24
|
readonly id: AgentID;
|
|
@@ -30,6 +32,7 @@ export interface AgentEntry {
|
|
|
30
32
|
readonly baseURL?: string;
|
|
31
33
|
readonly apiKeyHelper?: string;
|
|
32
34
|
readonly auth?: GatewayAuth;
|
|
35
|
+
readonly mcpServers?: readonly ClaudeMCPServer[];
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
interface CollectedAgents {
|
|
@@ -43,7 +46,8 @@ interface CollectedAgents {
|
|
|
43
46
|
* Reads the `agents` map into registry order. An entry that is not an object,
|
|
44
47
|
* sets an unknown field or a field outside its kind, holds a wrong-typed
|
|
45
48
|
* value, or fails an auth check is left out with every problem that refused
|
|
46
|
-
* it, and the other entries load.
|
|
49
|
+
* it, and the other entries load. An MCP server that breaks a rule is left
|
|
50
|
+
* out of its entry with an error, and the entry loads. A value that is not an object leaves the
|
|
47
51
|
* registry empty.
|
|
48
52
|
*/
|
|
49
53
|
export function collectAgents(
|
|
@@ -64,6 +68,7 @@ export function collectAgents(
|
|
|
64
68
|
errors.push(...parsed.problems.map((problem) => `agents.${id}: ${problem}`));
|
|
65
69
|
} else {
|
|
66
70
|
agents.push(parsed.entry);
|
|
71
|
+
errors.push(...(parsed.warnings ?? []).map((warning) => `agents.${id}: ${warning}`));
|
|
67
72
|
}
|
|
68
73
|
}
|
|
69
74
|
|
|
@@ -86,7 +91,9 @@ const DEFAULT_LABELS: Readonly<Record<AgentKind, string>> = {
|
|
|
86
91
|
grok: 'Grok',
|
|
87
92
|
};
|
|
88
93
|
|
|
89
|
-
type ParsedEntry =
|
|
94
|
+
type ParsedEntry =
|
|
95
|
+
| { readonly entry: AgentEntry; readonly warnings?: readonly string[] }
|
|
96
|
+
| { readonly problems: string[] };
|
|
90
97
|
|
|
91
98
|
function parseAgentEntry(
|
|
92
99
|
id: string,
|
|
@@ -125,7 +132,9 @@ function parseAgentEntry(
|
|
|
125
132
|
return claude;
|
|
126
133
|
}
|
|
127
134
|
|
|
128
|
-
|
|
135
|
+
const { warnings, ...read } = claude;
|
|
136
|
+
|
|
137
|
+
return { entry: { ...entry, ...read }, ...(warnings === undefined ? {} : { warnings }) };
|
|
129
138
|
}
|
|
130
139
|
|
|
131
140
|
// The entry's kind, or the problem that refuses it. An absent kind is the id
|
|
@@ -251,6 +260,12 @@ function buildDefaultLabel(id: string, kind: AgentKind): string {
|
|
|
251
260
|
return id === kind ? DEFAULT_LABELS[kind] : id;
|
|
252
261
|
}
|
|
253
262
|
|
|
263
|
+
interface ReadClaudeAuth {
|
|
264
|
+
readonly auth?: GatewayAuth;
|
|
265
|
+
readonly mcpServers?: readonly ClaudeMCPServer[];
|
|
266
|
+
readonly warnings?: readonly string[];
|
|
267
|
+
}
|
|
268
|
+
|
|
254
269
|
// The `auth` a Claude entry holds, or every problem with it. With a base URL
|
|
255
270
|
// the entry is a gateway and its auth is the gateway's. Without one it is
|
|
256
271
|
// stock Claude, whose auth is profiles alone and whose environment may not
|
|
@@ -259,7 +274,7 @@ function readClaudeAuth(
|
|
|
259
274
|
entry: AgentEntry,
|
|
260
275
|
raw: unknown,
|
|
261
276
|
authProfiles: ReadonlyMap<string, AuthProfile>,
|
|
262
|
-
):
|
|
277
|
+
): ReadClaudeAuth | { readonly problems: string[] } {
|
|
263
278
|
if (entry.baseURL !== undefined) {
|
|
264
279
|
if (raw === undefined) {
|
|
265
280
|
return {};
|
|
@@ -288,13 +303,21 @@ function readClaudeAuth(
|
|
|
288
303
|
|
|
289
304
|
const collected = collectClaudeAuth(raw, authProfiles, 'auth');
|
|
290
305
|
|
|
291
|
-
problems.push(...
|
|
306
|
+
problems.push(...collectSubscriptionProblems(entry));
|
|
292
307
|
|
|
293
|
-
if (
|
|
308
|
+
if (collected.auth === null) {
|
|
309
|
+
return { problems: [...collected.errors, ...problems] };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (problems.length > 0) {
|
|
294
313
|
return { problems };
|
|
295
314
|
}
|
|
296
315
|
|
|
297
|
-
return {
|
|
316
|
+
return {
|
|
317
|
+
auth: { profiles: collected.auth.profiles, placeholderEnv: {} },
|
|
318
|
+
...(collected.auth.mcpServers.length === 0 ? {} : { mcpServers: collected.auth.mcpServers }),
|
|
319
|
+
warnings: collected.errors,
|
|
320
|
+
};
|
|
298
321
|
}
|
|
299
322
|
|
|
300
323
|
// What a stock entry with `auth` sets that would override the subscription
|
|
@@ -2,14 +2,28 @@ import type { AuthProfile } from './collect-auth-profiles';
|
|
|
2
2
|
import { isRecord } from './report';
|
|
3
3
|
import { resolveAuthProfiles } from './resolve-auth-profiles';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* An MCP server a stock Claude session on a target that reaches impd's
|
|
7
|
+
* broker reaches over HTTP, with the header that one of the session's
|
|
8
|
+
* profiles sets for the server's host. The session sends a placeholder in
|
|
9
|
+
* that header, and impd swaps the credential in on the host's side.
|
|
10
|
+
*/
|
|
11
|
+
export interface ClaudeMCPServer {
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly url: string;
|
|
14
|
+
readonly profile: string;
|
|
15
|
+
readonly header: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
5
18
|
/**
|
|
6
19
|
* The profiles a stock Claude session on a target that reaches impd's
|
|
7
20
|
* broker signs in through: one of them sends the subscription's setup
|
|
8
21
|
* token to the Anthropic API as a bearer authorization header, and the
|
|
9
|
-
* rest bind beside it, such as a GitHub token.
|
|
22
|
+
* rest bind beside it, such as a GitHub token or an MCP server's key.
|
|
10
23
|
*/
|
|
11
24
|
interface ClaudeAuth {
|
|
12
25
|
readonly profiles: readonly string[];
|
|
26
|
+
readonly mcpServers: readonly ClaudeMCPServer[];
|
|
13
27
|
}
|
|
14
28
|
|
|
15
29
|
interface CollectedClaudeAuth {
|
|
@@ -23,7 +37,8 @@ interface CollectedClaudeAuth {
|
|
|
23
37
|
* does not resolve, or whose profiles send no bearer authorization header
|
|
24
38
|
* to the Anthropic API, is left out with every problem that refused it, so
|
|
25
39
|
* stock Claude keeps the sign-in of the host it runs on rather than binding
|
|
26
|
-
* a credential the CLI would never send.
|
|
40
|
+
* a credential the CLI would never send. An MCP server that breaks a rule
|
|
41
|
+
* is left out alone, with an error, and the sign-in stays.
|
|
27
42
|
*/
|
|
28
43
|
export function collectClaudeAuth(
|
|
29
44
|
raw: unknown,
|
|
@@ -48,7 +63,7 @@ export function collectClaudeAuth(
|
|
|
48
63
|
};
|
|
49
64
|
}
|
|
50
65
|
|
|
51
|
-
const extra = Object.keys(raw).find((key) => key !== 'profiles');
|
|
66
|
+
const extra = Object.keys(raw).find((key) => key !== 'profiles' && key !== 'mcpServers');
|
|
52
67
|
|
|
53
68
|
if (extra !== undefined) {
|
|
54
69
|
return {
|
|
@@ -79,8 +94,106 @@ export function collectClaudeAuth(
|
|
|
79
94
|
};
|
|
80
95
|
}
|
|
81
96
|
|
|
82
|
-
|
|
97
|
+
const servers = collectMCPServers(raw['mcpServers'], field, selected, authProfiles);
|
|
98
|
+
|
|
99
|
+
return { auth: { profiles: selected, mcpServers: servers.servers }, errors: servers.errors };
|
|
83
100
|
}
|
|
84
101
|
|
|
85
102
|
// The one host Claude Code sends a subscription token to.
|
|
86
103
|
const ANTHROPIC_API_HOST = 'api.anthropic.com';
|
|
104
|
+
|
|
105
|
+
interface CollectedMCPServers {
|
|
106
|
+
readonly servers: readonly ClaudeMCPServer[];
|
|
107
|
+
readonly errors: readonly string[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Reads the `mcpServers` map, server names to the server's URL and the
|
|
111
|
+
// selected profile that carries its credential.
|
|
112
|
+
function collectMCPServers(
|
|
113
|
+
raw: unknown,
|
|
114
|
+
field: string,
|
|
115
|
+
selected: readonly string[],
|
|
116
|
+
authProfiles: ReadonlyMap<string, AuthProfile>,
|
|
117
|
+
): CollectedMCPServers {
|
|
118
|
+
if (raw === undefined) {
|
|
119
|
+
return { servers: [], errors: [] };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!isRecord(raw) || Array.isArray(raw)) {
|
|
123
|
+
return { servers: [], errors: [`${field}.mcpServers must be an object of named servers`] };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const servers: ClaudeMCPServer[] = [];
|
|
127
|
+
const errors: string[] = [];
|
|
128
|
+
|
|
129
|
+
for (const [name, entry] of Object.entries(raw)) {
|
|
130
|
+
const parsed = parseMCPServer(name, entry, field, selected, authProfiles);
|
|
131
|
+
|
|
132
|
+
if (typeof parsed === 'string') {
|
|
133
|
+
errors.push(`${field}.mcpServers.${name}: ${parsed}`);
|
|
134
|
+
} else {
|
|
135
|
+
servers.push(parsed);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { servers, errors };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Claude Code's rule for an MCP server's name.
|
|
143
|
+
const MCP_SERVER_NAME = /^[\w-]{1,64}$/u;
|
|
144
|
+
|
|
145
|
+
// The server an entry holds, or the first rule it breaks. The header comes
|
|
146
|
+
// from the profile, so the header the session sends and the one impd swaps
|
|
147
|
+
// the credential into never differ.
|
|
148
|
+
function parseMCPServer(
|
|
149
|
+
name: string,
|
|
150
|
+
entry: unknown,
|
|
151
|
+
field: string,
|
|
152
|
+
selected: readonly string[],
|
|
153
|
+
authProfiles: ReadonlyMap<string, AuthProfile>,
|
|
154
|
+
): ClaudeMCPServer | string {
|
|
155
|
+
if (!MCP_SERVER_NAME.test(name)) {
|
|
156
|
+
return 'a server name must be letters, digits, underscores or hyphens';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!isRecord(entry) || Array.isArray(entry)) {
|
|
160
|
+
return 'a server must be an object with url and profile';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const extra = Object.keys(entry).find((key) => key !== 'url' && key !== 'profile');
|
|
164
|
+
|
|
165
|
+
if (extra !== undefined) {
|
|
166
|
+
return `${extra} cannot be set: atc fixes the transport and the placeholder header`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const url = entry['url'];
|
|
170
|
+
const profileName = entry['profile'];
|
|
171
|
+
|
|
172
|
+
if (typeof profileName !== 'string' || !selected.includes(profileName)) {
|
|
173
|
+
return `profile must be one of ${field}.profiles`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const profile = authProfiles.get(profileName);
|
|
177
|
+
|
|
178
|
+
if (profile?.kind !== 'custom') {
|
|
179
|
+
return 'profile must be a custom profile, which sets one header for one host';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const parsedURL = typeof url === 'string' && URL.canParse(url) ? new URL(url) : null;
|
|
183
|
+
|
|
184
|
+
if (
|
|
185
|
+
parsedURL === null ||
|
|
186
|
+
parsedURL.protocol !== 'https:' ||
|
|
187
|
+
parsedURL.port !== '' ||
|
|
188
|
+
parsedURL.username !== '' ||
|
|
189
|
+
parsedURL.password !== ''
|
|
190
|
+
) {
|
|
191
|
+
return 'url must be an https URL with no port and no user info';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (parsedURL.hostname !== profile.host) {
|
|
195
|
+
return `url must be on ${profile.host}, the host profile ${profileName} sends its credential to`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { name, url: parsedURL.href, profile: profileName, header: profile.header };
|
|
199
|
+
}
|
|
@@ -51,6 +51,9 @@ export function collectLegacyAgents(
|
|
|
51
51
|
...(claudeAuth.auth === null
|
|
52
52
|
? {}
|
|
53
53
|
: { auth: { profiles: claudeAuth.auth.profiles, placeholderEnv: {} } }),
|
|
54
|
+
...(claudeAuth.auth === null || claudeAuth.auth.mcpServers.length === 0
|
|
55
|
+
? {}
|
|
56
|
+
: { mcpServers: claudeAuth.auth.mcpServers }),
|
|
54
57
|
},
|
|
55
58
|
{
|
|
56
59
|
id: 'grok',
|