@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.37.0",
3
+ "version": "2.39.0",
4
4
  "description": "Terminal control tower for coding-agent sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -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. It holds no account: the placeholder
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 on its first
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]: { hasTrustDialogAccepted: true } } };
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
- modeArgs: readonly string[],
387
+ leadArgs: readonly string[],
369
388
  settings: string,
370
389
  pluginDir: string,
371
390
  ): string[] {
372
391
  return [
373
392
  ...buildClaudeOverrideArgs(configured, opts),
374
- ...modeArgs,
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
- Object.keys(entry.auth.placeholderEnv).length > 0
118
- ? { profiles: entry.auth.profiles, placeholderEnv: entry.auth.placeholderEnv }
119
- : { profiles: entry.auth.profiles };
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;
@@ -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 failure once the host is ready takes it back.
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
- if (materialized === null || readied.root === null) {
1649
- throw new DaemonError(
1650
- 'bad_args',
1651
- 'trustClonedWorkspace requires a successfully cloned workspace',
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 trustWorkspace(readied.root);
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 trust setup failed and its clone could not be removed',
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. A value that is not an object leaves the
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 = { readonly entry: AgentEntry } | { readonly problems: string[] };
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
- return { entry: { ...entry, ...claude } };
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
- ): { readonly auth?: GatewayAuth } | { readonly problems: string[] } {
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(...collected.errors, ...collectSubscriptionProblems(entry));
306
+ problems.push(...collectSubscriptionProblems(entry));
292
307
 
293
- if (problems.length > 0 || collected.auth === null) {
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 { auth: { profiles: collected.auth.profiles, placeholderEnv: {} } };
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
- return { auth: { profiles: selected }, errors: [] };
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',