agent-relay-runner 0.67.1 → 0.68.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
- "version": "0.67.1",
3
+ "version": "0.68.1",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,7 @@
20
20
  "directory": "runner"
21
21
  },
22
22
  "dependencies": {
23
- "agent-relay-sdk": "0.2.43"
23
+ "agent-relay-sdk": "0.2.44"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "latest",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.67.1",
4
+ "version": "0.68.1",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
package/src/adapter.ts CHANGED
@@ -191,6 +191,17 @@ export function profileAllowsRelayFeature(config: RunnerSpawnConfig, feature: ke
191
191
  return config.agentProfile?.relay?.[feature] !== false;
192
192
  }
193
193
 
194
+ // #552 — the strictest base. Beyond the isolated provider home that every
195
+ // non-host base already gets (fresh CLAUDE_CONFIG_DIR / CODEX_HOME, which drops
196
+ // the GLOBAL instruction + host MCP/hooks/plugins cascade), "vanilla" actively
197
+ // suppresses every remaining host customization channel at launch, including the
198
+ // REPO CLAUDE.md/AGENTS.md cascade that lives in the cwd and so survives home
199
+ // isolation. Claude: `--setting-sources "user"` (the --safe-mode pivot — safe-mode
200
+ // blocked even explicit provisioning). Codex: `project_doc_max_bytes=0`.
201
+ export function profileIsVanillaBase(config: Pick<RunnerSpawnConfig, "agentProfile">): boolean {
202
+ return config.agentProfile?.base === "vanilla";
203
+ }
204
+
194
205
  export const RELAY_CONTEXT = `[agent-relay] You are connected to Agent Relay, a real-time message bus between agents and users. When you receive a relay message: read it, do what it asks, and reply through the relay when a text response is needed. Use agent-relay /react <messageId> <emoji> for lightweight acknowledgement or approval. If Relay MCP tools are available, prefer relay_reply, relay_get_message, relay_get_thread, relay_send_message, relay_upload_artifact, relay_attach_artifact, relay_agent_status, relay_find_agents, relay_compact_and_resume, relay_recall, relay_spawn_agent, and relay_shutdown_agent. You never need to know or pass your own agent id — relay fills it from your token; use relay_whoami only if you need to reason about yourself. relay_compact_and_resume is for clean-seam self-resume after a context advisory: pass workingState and optional ruledOut; Relay owns the objective envelope. relay_recall searches your own archived pre-compaction segments by keyword when a discarded detail is needed. relay_spawn_targets / relay_spawn_agent / relay_shutdown_agent only appear if your profile grants spawning (a live-children quota); when present, call relay_spawn_targets FIRST for the live host/provider/model matrix + your quota, then stand up long-living child agents and shut down your own — find them later with relay_find_agents spawnedBy:me. CLI fallback: agent-relay /reply <messageId> --stdin < response.md; if a delivered message says it was truncated, fetch the full body with: agent-relay get-message <messageId>. For command details, run: agent-relay /guide`;
195
206
 
196
207
  // #306 — deliver the FULL message body by default. Only a pathological body beyond this
@@ -10,7 +10,7 @@ import { profileAllowsRelayFeature, type ManagedProcess, type ProviderAdapter, t
10
10
  import { collectClaudeSessionEvents } from "./claude-transcript";
11
11
  import type { SessionEvent } from "../session-insights";
12
12
  import { prepareClaudeProfileHome, profileUsesHostProviderGlobals } from "../profile-home";
13
- import { relayMcpClaudeConfigArg } from "../relay-mcp";
13
+ import { assembleLaunch, materializeLaunchAssembly, sessionStatusLineSettingsArgs } from "../launch-assembly";
14
14
  import { claudeProviderMessageText } from "./claude-delivery";
15
15
  import { buildRateLimitProviderState, parseClaudeRateLimitPane } from "../rate-limit";
16
16
 
@@ -219,10 +219,18 @@ export class ClaudeAdapter implements ProviderAdapter {
219
219
  }
220
220
 
221
221
  buildSpawnArgs(config: RunnerSpawnConfig, providerConfig: ProviderConfig): SpawnArgs {
222
- const profileHome = prepareClaudeProfileHome(config);
223
- const pluginRoot = resolve(import.meta.dir, "../../plugins/claude");
222
+ // Create + bootstrap + auth-link the isolated config home (no-op for host base) so the
223
+ // assembler's materialize step has somewhere to write the provisioned skills/plugins.
224
+ prepareClaudeProfileHome(config);
224
225
  const defaultArgs = profileUsesHostProviderGlobals(config) ? providerConfig.defaultArgs : [];
225
- const pluginDirs = profileAllowsRelayFeature(config, "plugins") ? [...new Set([pluginRoot, ...providerConfig.pluginDirs])] : [];
226
+ // #557 ONE assembler owns the provisioning/vanilla/instruction launch surface
227
+ // (--setting-sources, --plugin-dir, --mcp-config, status-line, --append-system-prompt);
228
+ // profile-projection describes the SAME assembled result, so report == launch. The
229
+ // adapter only adds the bits the assembler does not own: claude-rig command resolution,
230
+ // headless permission flags, --model, and the positional prompt. materializeLaunchAssembly
231
+ // writes the provisioned skills/plugins the assembled args point at.
232
+ const assembled = assembleLaunch("claude", config, providerConfig);
233
+ materializeLaunchAssembly("claude", config);
226
234
  const isClaudeRig = /claude-rig/.test(providerConfig.command);
227
235
  // Isolated profiles run their own CLAUDE_CONFIG_DIR and must never route through
228
236
  // claude-rig — claude-rig resolves host rig config (and a bare `claude-rig` with no
@@ -238,10 +246,7 @@ export class ClaudeAdapter implements ProviderAdapter {
238
246
  : [...defaultArgs, ...config.providerArgs];
239
247
  const args = [
240
248
  ...rigPrefix,
241
- ...pluginDirs.flatMap((dir) => ["--plugin-dir", dir]),
242
- ...(profileAllowsRelayFeature(config, "mcp") ? relayMcpClaudeConfigArg(config.relayUrl, config.relayMcpEndpoint) : []),
243
- ...(profileAllowsRelayFeature(config, "statusLine") ? sessionStatusLineSettingsArgs(defaultArgs, config.providerArgs) : []),
244
- ...(config.systemPromptAppend ? ["--append-system-prompt", config.systemPromptAppend] : []),
249
+ ...assembled.args,
245
250
  ...providerArgs,
246
251
  ];
247
252
  if (config.model) args.push("--model", config.model);
@@ -256,7 +261,8 @@ export class ClaudeAdapter implements ProviderAdapter {
256
261
  cwd: config.cwd,
257
262
  env: {
258
263
  ...config.env,
259
- ...(profileHome ? { CLAUDE_CONFIG_DIR: profileHome.path } : {}),
264
+ // #557 the assembler owns the isolated config-home env (CLAUDE_CONFIG_DIR).
265
+ ...assembled.env,
260
266
  // Plain `claude` (isolated profiles) runs its own CLAUDE_CONFIG_DIR and never
261
267
  // routes through claude-rig, so it never inherits the host's long-lived
262
268
  // CLAUDE_CODE_OAUTH_TOKEN. Without it, claude falls back to the (often stale)
@@ -431,23 +437,9 @@ export function claudeShutdownGraceMs(timeoutMs: number): number {
431
437
  return Math.min(timeoutMs, 10_000);
432
438
  }
433
439
 
434
- export function sessionStatusLineSettingsArgs(...argLists: string[][]): string[] {
435
- if (argLists.some(hasSettingsArg)) return [];
436
- return ["--settings", JSON.stringify({
437
- statusLine: {
438
- type: "command",
439
- command: "agent-relay context-probe --wrap",
440
- refreshInterval: 30,
441
- },
442
- // Force readable thinking text for managed sessions so the session-mirror can
443
- // surface reasoning in the dashboard. With showThinkingSummaries:false the API
444
- // redacts thinking to a signature-only stub (empty text), leaving the transcript
445
- // tail nothing to mirror. --settings merges per-key, so this overrides only this
446
- // key for managed sessions — a host rig default of false still governs the
447
- // operator's own interactive TUI sessions.
448
- showThinkingSummaries: true,
449
- })];
450
- }
440
+ // #557 sessionStatusLineSettingsArgs now lives in ../launch-assembly (the single
441
+ // launch-arg home); re-exported so existing ./claude consumers + tests keep their path.
442
+ export { sessionStatusLineSettingsArgs };
451
443
 
452
444
  export function claudeLaunchArgs(defaultArgs: string[], providerArgs: string[], approvalMode?: string): string[] {
453
445
  const stripToolArgs = approvalMode === "read-only";
@@ -489,9 +481,6 @@ function stripClaudePermissionArgs(args: string[], stripToolArgs = false): strin
489
481
  return result;
490
482
  }
491
483
 
492
- function hasSettingsArg(args: string[]): boolean {
493
- return args.some((arg) => arg === "--settings" || arg.startsWith("--settings="));
494
- }
495
484
 
496
485
  export function tmuxSessionName(prefix: string, instanceId: string, label?: string): string {
497
486
  if (label) return `${prefix}-${sanitizeFsName(label, { replacement: "-", collapse: false, lowercase: true })}`;
@@ -6,7 +6,8 @@ import { isRecord, stringValue } from "agent-relay-sdk";
6
6
  import { isPidAlive, killPid, processTreePids, processTreePidsFromTable, waitForPidsExit } from "agent-relay-sdk/process-utils";
7
7
  import { profileAllowsRelayFeature, providerMessageText, RELAY_CONTEXT, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type ProviderPermissionDecisionInput, type ProviderSessionEvent, type ProviderStatusUpdate, type RunnerSpawnConfig, type SpawnArgs, type TerminalAttachSpec } from "../adapter";
8
8
  import { workspaceDepsNoteFromEnv } from "../relay-instructions";
9
- import { relayMcpCodexConfigArgs, tomlString } from "../relay-mcp";
9
+ import { tomlString } from "../relay-mcp";
10
+ import { assembleLaunch, bundledCodexSkillDirs, bundledSkillConfigArgs, materializeLaunchAssembly } from "../launch-assembly";
10
11
  import { logger } from "../logger";
11
12
  import type { SessionEvent } from "../session-insights";
12
13
 
@@ -345,17 +346,24 @@ export class CodexAdapter implements ProviderAdapter {
345
346
 
346
347
  buildSpawnArgs(config: RunnerSpawnConfig, providerConfig: ProviderConfig): SpawnArgs {
347
348
  const appServerUrl = String(config.appServerUrl || process.env.CODEX_APP_SERVER_URL || `ws://127.0.0.1:${config.controlPort + 1000}`);
348
- const profileHome = prepareCodexProfileHome(config);
349
+ // Create + bootstrap the isolated CODEX_HOME (no-op for host base) so the assembler's
350
+ // materialize step has somewhere to write the provisioned skills.
351
+ prepareCodexProfileHome(config);
349
352
  const defaultArgs = profileUsesHostProviderGlobals(config) ? providerConfig.defaultArgs : [];
353
+ // #557 — ONE assembler owns the provisioning/vanilla surface (skills.config, relay +
354
+ // provisioned -c mcp_servers.*, project_doc_max_bytes); profile-projection describes the
355
+ // SAME assembled result, so report == launch. The adapter only adds core launch config the
356
+ // assembler does not own: app-server wiring, model/approval/tool-output, managed flags, --listen.
357
+ const assembled = assembleLaunch("codex", config, providerConfig);
358
+ materializeLaunchAssembly("codex", config);
350
359
  const args = isCodexCliCommand(providerConfig.command)
351
360
  ? [
352
361
  "app-server",
353
362
  ...codexAppServerConfigArgs(defaultArgs, config.providerArgs),
354
363
  ...codexModelConfigArgs(config.model, config.effort),
355
364
  ...codexApprovalConfigArgs(config.approvalMode),
356
- ...(profileAllowsRelayFeature(config, "skills") ? bundledSkillConfigArgs() : []),
357
- ...(profileAllowsRelayFeature(config, "mcp") ? relayMcpCodexConfigArgs(config.relayUrl, config.relayMcpEndpoint) : []),
358
365
  ...codexToolOutputTokenLimitConfigArgs(config),
366
+ ...assembled.args,
359
367
  ...codexManagedConfigArgs(),
360
368
  "--listen",
361
369
  appServerUrl,
@@ -367,7 +375,8 @@ export class CodexAdapter implements ProviderAdapter {
367
375
  cwd: config.cwd,
368
376
  env: {
369
377
  ...config.env,
370
- ...(profileHome ? { CODEX_HOME: profileHome.path } : {}),
378
+ // #557 the assembler owns the isolated config-home env (CODEX_HOME).
379
+ ...assembled.env,
371
380
  CODEX_APP_SERVER_URL: appServerUrl,
372
381
  AGENT_RELAY_PROVIDER: "codex",
373
382
  },
@@ -1166,24 +1175,10 @@ function isAgentRelayCodexShim(path: string): boolean {
1166
1175
  return path.includes("/.agent-relay/codex/bin/codex");
1167
1176
  }
1168
1177
 
1169
- export function bundledCodexSkillDirs(baseDir = resolve(import.meta.dir, "../../plugins/codex/skills")): string[] {
1170
- if (!existsSync(baseDir)) return [];
1171
- return readdirSync(baseDir, { withFileTypes: true })
1172
- .filter((entry) => entry.isDirectory() && existsSync(join(baseDir, entry.name, "SKILL.md")))
1173
- .map((entry) => join(baseDir, entry.name))
1174
- .sort();
1175
- }
1176
-
1177
- export function bundledSkillConfigArgs(skillDirs = bundledCodexSkillDirs()): string[] {
1178
- if (skillDirs.length === 0) return [];
1179
- const skillsConfig = skillDirs
1180
- .map((dir) => `{path=${tomlString(dir)},enabled=true}`)
1181
- .join(",");
1182
- return ["-c", `skills.config=[${skillsConfig}]`];
1183
- }
1184
-
1185
- // tomlString now lives in ../relay-mcp (shared with the relay MCP injection args);
1186
- // re-exported here so existing codex.ts consumers/tests keep their import path.
1178
+ // #557 bundledCodexSkillDirs + bundledSkillConfigArgs now live in ../launch-assembly
1179
+ // (the single launch-arg home, shared with the assembler); re-exported so existing
1180
+ // codex.ts consumers + tests keep their import path. tomlString likewise from ../relay-mcp.
1181
+ export { bundledCodexSkillDirs, bundledSkillConfigArgs };
1187
1182
  export { tomlString };
1188
1183
 
1189
1184
  export function codexAppServerConfigArgs(...argLists: string[][]): string[] {
@@ -0,0 +1,303 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { resolve, join } from "node:path";
3
+ import type { AgentProfile, AgentProfileBase, SpawnProvider } from "agent-relay-sdk";
4
+ import {
5
+ profileAllowsRelayFeature,
6
+ profileIsVanillaBase,
7
+ type ProviderConfig,
8
+ type RunnerSpawnConfig,
9
+ } from "./adapter";
10
+ import { profileUsesHostProviderGlobals, providerHomePathFor } from "./profile-home";
11
+ import {
12
+ materializeResolvedAssets,
13
+ resolveClaudeProvisionedPlugins,
14
+ resolveClaudeProvisionedSkills,
15
+ resolveCodexProvisionedSkills,
16
+ profileProvisioning,
17
+ } from "./provisioning";
18
+ import {
19
+ claudeMcpLaunchArgs,
20
+ codexProvisionedMcpArgs,
21
+ relayMcpCodexConfigArgs,
22
+ tomlString,
23
+ RELAY_MCP_SERVER_NAME,
24
+ } from "./relay-mcp";
25
+
26
+ // #557 — THE single launch-assembly contract. Given a resolved AgentProfile (+ its
27
+ // spawn-time ResolvedProvisioning), `assembleLaunch` produces ONE provider-neutral
28
+ // description of the launch: the relay-provisioned skills/plugins/MCP, the bundled relay
29
+ // surface, the instruction-source disposition, and `base:"vanilla"` as the zero-host
30
+ // floor. Both adapters' buildSpawnArgs splice `assembled.args`/`assembled.env`, and
31
+ // profile-projection describes `assembled` directly — so projection and reality come
32
+ // from one source and cannot drift.
33
+ //
34
+ // `assembleLaunch` is PURE (it reads the filesystem to decide which declared dirs
35
+ // resolve, but writes nothing). `materializeLaunchAssembly` performs the disk writes the
36
+ // description implies, driven by the SAME resolvers — so what's written equals what's
37
+ // reported equals what's launched.
38
+
39
+ export interface AssembledAsset {
40
+ name: string;
41
+ /** Materialized/in-place dir feeding the launch (e.g. a --plugin-dir or skills.config path). */
42
+ dir: string;
43
+ }
44
+
45
+ export type AssembledInstructionDisposition = "allow" | "suppressed" | "isolated" | "unsupported";
46
+
47
+ export interface AssembledInstructions {
48
+ /** Repo CLAUDE.md/AGENTS.md cascade: "allow" left available, "suppressed" by the
49
+ * vanilla launch mechanism, "unsupported" requested-but-not-enforced (non-vanilla isolated). */
50
+ repo: AssembledInstructionDisposition;
51
+ /** Host/global provider instructions: "allow", "isolated" (dropped by the isolated home), or "unsupported". */
52
+ global: AssembledInstructionDisposition;
53
+ /** A composed systemPromptAppend block (project preamble + profile system/append +
54
+ * caller context, composed relay-side by composeSpawnInstructions) flows into this launch. */
55
+ systemPromptAppend: boolean;
56
+ // ── #559 SEAM ──────────────────────────────────────────────────────────────────
57
+ // Scoped {profile|user} memory does not exist yet (#559 generalizes Projects' anchor
58
+ // to project|profile|user). When it lands, the assembled instruction world becomes
59
+ // "instructions + provisioned assets + scoped knowledge": the relay-side composer
60
+ // (composeSpawnInstructions) gains a profile/user-scoped preamble beside the existing
61
+ // project preamble, and THIS field reports which scopes contributed. Until then it is
62
+ // always "none" — a thin add for #559, not a refactor.
63
+ scopedKnowledge: "none";
64
+ }
65
+
66
+ export interface AssembledMcp {
67
+ /** The identity-bearing relay HTTP MCP endpoint is injected. */
68
+ relayEndpoint: boolean;
69
+ /** Only the assembled servers may load (Claude --strict-mcp-config) — the vanilla floor. */
70
+ strict: boolean;
71
+ /** Relay-provisioned server names (excludes the relay endpoint itself). */
72
+ servers: string[];
73
+ }
74
+
75
+ export interface AssembledLaunch {
76
+ provider: SpawnProvider;
77
+ /** The resolved profile this launch was assembled from; undefined for a profile-less
78
+ * (legacy default) spawn, which behaves as host base with relay features default-on. */
79
+ profile?: AgentProfile;
80
+ base: AgentProfileBase;
81
+ vanilla: boolean;
82
+ /** Isolated provider config home (CLAUDE_CONFIG_DIR / CODEX_HOME), or undefined for host base. */
83
+ configHome?: string;
84
+ relayContext: boolean;
85
+ /** Bundled relay skills are available (Codex skills.config / Claude via the relay plugin). */
86
+ relaySkills: boolean;
87
+ /** Bundled relay plugin dir is passed (Claude only). */
88
+ relayPlugin: boolean;
89
+ /** Relay status-line context probe is injected (Claude only). */
90
+ statusLine: boolean;
91
+ /** Relay-provisioned skills materialized for this launch. */
92
+ skills: AssembledAsset[];
93
+ /** Relay-provisioned plugins (Claude --plugin-dir). Empty for Codex (no plugin surface). */
94
+ plugins: AssembledAsset[];
95
+ mcp: AssembledMcp;
96
+ instructions: AssembledInstructions;
97
+ /** Provider provisioning/vanilla/instruction launch-arg block, in launch order. */
98
+ args: string[];
99
+ /** Provisioning-derived env additions (the isolated config-home var). */
100
+ env: Record<string, string>;
101
+ }
102
+
103
+ // ── Bundled relay assets (relocated from the adapters so the assembler — not each
104
+ // adapter — owns launch-arg construction; #557). Adapters re-export for test parity. ──
105
+
106
+ const CLAUDE_RELAY_PLUGIN_DIR = resolve(import.meta.dir, "../plugins/claude");
107
+ const CODEX_BUNDLED_SKILLS_DIR = resolve(import.meta.dir, "../plugins/codex/skills");
108
+
109
+ export function bundledCodexSkillDirs(baseDir = CODEX_BUNDLED_SKILLS_DIR): string[] {
110
+ if (!existsSync(baseDir)) return [];
111
+ return readdirSync(baseDir, { withFileTypes: true })
112
+ .filter((entry) => entry.isDirectory() && existsSync(join(baseDir, entry.name, "SKILL.md")))
113
+ .map((entry) => join(baseDir, entry.name))
114
+ .sort();
115
+ }
116
+
117
+ export function bundledSkillConfigArgs(skillDirs = bundledCodexSkillDirs()): string[] {
118
+ if (skillDirs.length === 0) return [];
119
+ const skillsConfig = skillDirs.map((dir) => `{path=${tomlString(dir)},enabled=true}`).join(",");
120
+ return ["-c", `skills.config=[${skillsConfig}]`];
121
+ }
122
+
123
+ // #552 vanilla base (Codex): suppress the AGENTS.md project-doc cascade. project_doc_max_bytes
124
+ // (default 32768) caps bytes Codex reads from AGENTS.md walking root→cwd; 0 stops before adding
125
+ // any, so no repo (or fresh-CODEX_HOME global) project doc loads. The isolated CODEX_HOME already
126
+ // drops host config.toml MCP/hooks; this closes the cwd-resident repo instruction channel.
127
+ export function codexProjectDocSuppressionArgs(config: Pick<RunnerSpawnConfig, "agentProfile">): string[] {
128
+ return profileIsVanillaBase(config) ? ["-c", "project_doc_max_bytes=0"] : [];
129
+ }
130
+
131
+ function hasSettingsArg(args: string[]): boolean {
132
+ return args.some((arg) => arg === "--settings" || arg.startsWith("--settings="));
133
+ }
134
+
135
+ // Claude relay status-line + readable-thinking settings, injected only when no caller
136
+ // --settings is already present (caller wins). Relocated from claude.ts (#557).
137
+ export function sessionStatusLineSettingsArgs(...argLists: string[][]): string[] {
138
+ if (argLists.some(hasSettingsArg)) return [];
139
+ return ["--settings", JSON.stringify({
140
+ statusLine: { type: "command", command: "agent-relay context-probe --wrap", refreshInterval: 30 },
141
+ showThinkingSummaries: true,
142
+ })];
143
+ }
144
+
145
+ // ── Instruction disposition (the vanilla floor's effect on the host instruction cascade) ──
146
+
147
+ function repoInstructionDisposition(profile: AgentProfile): AssembledInstructionDisposition {
148
+ if (profile.instructions.repoInstructions === "allow") return "allow";
149
+ if (profile.base === "vanilla") return "suppressed";
150
+ // Non-vanilla isolated bases can request ignore but have no launch-time enforcement yet.
151
+ return "unsupported";
152
+ }
153
+
154
+ function globalInstructionDisposition(profile: AgentProfile): AssembledInstructionDisposition {
155
+ if (profile.instructions.globalInstructions === "allow") return "allow";
156
+ if (profile.base !== "host") return "isolated";
157
+ return "unsupported";
158
+ }
159
+
160
+ function assembleInstructions(config: RunnerSpawnConfig): AssembledInstructions {
161
+ const profile = config.agentProfile;
162
+ return {
163
+ // A profile-less spawn is host-base: the full host instruction cascade is left available.
164
+ repo: profile ? repoInstructionDisposition(profile) : "allow",
165
+ global: profile ? globalInstructionDisposition(profile) : "allow",
166
+ systemPromptAppend: Boolean(config.systemPromptAppend?.trim()),
167
+ scopedKnowledge: "none",
168
+ };
169
+ }
170
+
171
+ // ── Per-provider arg assembly ──────────────────────────────────────────────────────
172
+
173
+ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfig, hostDefaultArgs: string[]): AssembledLaunch {
174
+ const configHome = providerHomePathFor("claude", config);
175
+ const vanilla = profileIsVanillaBase(config);
176
+ const relayPlugin = profileAllowsRelayFeature(config, "plugins");
177
+ const relaySkills = profileAllowsRelayFeature(config, "skills");
178
+ const statusLine = profileAllowsRelayFeature(config, "statusLine");
179
+
180
+ const skills = configHome ? resolveClaudeProvisionedSkills(configHome, config) : [];
181
+ const provisionedPlugins = configHome ? resolveClaudeProvisionedPlugins(configHome, config) : [];
182
+
183
+ // Plugin dirs (--plugin-dir loads explicitly, independent of --setting-sources):
184
+ // relay-bundled plugin → only when relay.plugins (carries the relay monitor +
185
+ // turn-lifecycle hooks #556, not reproducible via settings.json).
186
+ // relay-provisioned plugins (#556) → materialized into the isolated home.
187
+ // host plugin dirs → ONLY host-base profiles; isolated/vanilla must not pull them back in.
188
+ const relayPluginDirs = relayPlugin ? [CLAUDE_RELAY_PLUGIN_DIR] : [];
189
+ const provisionedPluginDirs = provisionedPlugins.map((p) => p.dir);
190
+ const hostPluginDirs = profileUsesHostProviderGlobals(config) ? providerConfig.pluginDirs : [];
191
+ const pluginDirs = [...new Set([...relayPluginDirs, ...provisionedPluginDirs, ...hostPluginDirs])];
192
+
193
+ const provisionedMcp = profileProvisioning(config).mcpServers;
194
+ const mcpServers = Object.keys(provisionedMcp).filter((name) => name !== RELAY_MCP_SERVER_NAME);
195
+ const relayEndpoint = profileAllowsRelayFeature(config, "mcp");
196
+
197
+ const instructions = assembleInstructions(config);
198
+
199
+ // Launch order matches the historical claude buildSpawnArgs block exactly so existing
200
+ // arg assertions hold: vanilla scope → plugin dirs → MCP → status-line → append.
201
+ const args = [
202
+ ...(vanilla ? ["--setting-sources", "user"] : []),
203
+ ...pluginDirs.flatMap((dir) => ["--plugin-dir", dir]),
204
+ ...claudeMcpLaunchArgs({
205
+ relayUrl: config.relayUrl,
206
+ ...(config.relayMcpEndpoint ? { endpoint: config.relayMcpEndpoint } : {}),
207
+ includeRelay: relayEndpoint,
208
+ servers: provisionedMcp,
209
+ strict: vanilla,
210
+ }),
211
+ ...(statusLine ? sessionStatusLineSettingsArgs(hostDefaultArgs, config.providerArgs) : []),
212
+ ...(config.systemPromptAppend ? ["--append-system-prompt", config.systemPromptAppend] : []),
213
+ ];
214
+
215
+ return {
216
+ provider: "claude",
217
+ ...(config.agentProfile ? { profile: config.agentProfile } : {}),
218
+ base: config.agentProfile?.base ?? "host",
219
+ vanilla,
220
+ ...(configHome ? { configHome } : {}),
221
+ relayContext: profileAllowsRelayFeature(config, "context"),
222
+ relaySkills,
223
+ relayPlugin,
224
+ statusLine,
225
+ skills: skills.map((s) => ({ name: s.name, dir: s.dir })),
226
+ plugins: provisionedPlugins.map((p) => ({ name: p.name, dir: p.dir })),
227
+ mcp: { relayEndpoint, strict: vanilla, servers: mcpServers },
228
+ instructions,
229
+ args,
230
+ env: configHome ? { CLAUDE_CONFIG_DIR: configHome } : {},
231
+ };
232
+ }
233
+
234
+ function assembleCodex(config: RunnerSpawnConfig, _providerConfig: ProviderConfig): AssembledLaunch {
235
+ const configHome = providerHomePathFor("codex", config);
236
+ const vanilla = profileIsVanillaBase(config);
237
+ const relaySkills = profileAllowsRelayFeature(config, "skills");
238
+ const relayEndpoint = profileAllowsRelayFeature(config, "mcp");
239
+
240
+ const provisionedSkills = configHome ? resolveCodexProvisionedSkills(configHome, config) : [];
241
+ const bundledSkillDirs = relaySkills ? bundledCodexSkillDirs() : [];
242
+ const skillDirs = [...bundledSkillDirs, ...provisionedSkills.map((s) => s.dir)];
243
+
244
+ const provisionedMcp = profileProvisioning(config).mcpServers;
245
+ const mcpServers = Object.keys(provisionedMcp).filter((name) => name !== RELAY_MCP_SERVER_NAME);
246
+
247
+ const instructions = assembleInstructions(config);
248
+
249
+ // The skills/MCP/project-doc block; core launch config (model/approval/listen) stays
250
+ // in the adapter. -c overrides are order-independent, so the assembled block is contiguous.
251
+ const args = [
252
+ ...bundledSkillConfigArgs(skillDirs),
253
+ ...(relayEndpoint ? relayMcpCodexConfigArgs(config.relayUrl, config.relayMcpEndpoint) : []),
254
+ ...codexProvisionedMcpArgs(provisionedMcp),
255
+ ...codexProjectDocSuppressionArgs(config),
256
+ ];
257
+
258
+ return {
259
+ provider: "codex",
260
+ ...(config.agentProfile ? { profile: config.agentProfile } : {}),
261
+ base: config.agentProfile?.base ?? "host",
262
+ vanilla,
263
+ ...(configHome ? { configHome } : {}),
264
+ relayContext: profileAllowsRelayFeature(config, "context"),
265
+ relaySkills,
266
+ relayPlugin: false,
267
+ statusLine: false,
268
+ skills: provisionedSkills.map((s) => ({ name: s.name, dir: s.dir })),
269
+ plugins: [],
270
+ mcp: { relayEndpoint, strict: false, servers: mcpServers },
271
+ instructions,
272
+ args,
273
+ env: configHome ? { CODEX_HOME: configHome } : {},
274
+ };
275
+ }
276
+
277
+ /**
278
+ * Assemble the launch for `provider` from the resolved profile + provisioning. PURE:
279
+ * resolves which declared assets exist (filesystem reads) but writes nothing. A missing
280
+ * `agentProfile` assembles the legacy default (host base, relay features on, no provisioning).
281
+ */
282
+ export function assembleLaunch(provider: SpawnProvider, config: RunnerSpawnConfig, providerConfig: ProviderConfig): AssembledLaunch {
283
+ if (provider === "codex") return assembleCodex(config, providerConfig);
284
+ const hostDefaultArgs = profileUsesHostProviderGlobals(config) ? providerConfig.defaultArgs : [];
285
+ return assembleClaude(config, providerConfig, hostDefaultArgs);
286
+ }
287
+
288
+ /**
289
+ * Write the provisioned assets the assembly describes (Claude skills + plugins, Codex
290
+ * skills). Driven by the SAME resolvers `assembleLaunch` uses, so disk == launch ==
291
+ * report. Idempotent; the isolated home must already exist (prepare*ProfileHome). No-op
292
+ * for a host-base profile.
293
+ */
294
+ export function materializeLaunchAssembly(provider: SpawnProvider, config: RunnerSpawnConfig): void {
295
+ const configHome = providerHomePathFor(provider, config);
296
+ if (!configHome) return;
297
+ if (provider === "claude") {
298
+ materializeResolvedAssets(resolveClaudeProvisionedSkills(configHome, config));
299
+ materializeResolvedAssets(resolveClaudeProvisionedPlugins(configHome, config));
300
+ return;
301
+ }
302
+ materializeResolvedAssets(resolveCodexProvisionedSkills(configHome, config));
303
+ }
@@ -19,6 +19,15 @@ function profileRequiresIsolatedHome(config: RunnerSpawnConfig): boolean {
19
19
  return Boolean(config.agentProfile && config.agentProfile.base !== "host");
20
20
  }
21
21
 
22
+ // #557 — PURE: the isolated provider config home path a launch WILL use, or undefined
23
+ // for a host-base profile (which uses the provider's normal home). The launch assembler
24
+ // computes the home this way to report it + build CLAUDE_CONFIG_DIR/skill/plugin paths
25
+ // without side effects; prepare*ProfileHome creates it at the same path at spawn time.
26
+ export function providerHomePathFor(provider: "claude" | "codex", config: RunnerSpawnConfig): string | undefined {
27
+ if (!profileRequiresIsolatedHome(config)) return undefined;
28
+ return providerHomePath(provider, config);
29
+ }
30
+
22
31
  // First-run bootstrap (provider layer)
23
32
  // -------------------------------------
24
33
  // Isolated profile homes are keyed by instanceId, so every spawn gets a brand
@@ -70,6 +79,9 @@ function bootstrapClaudeFirstRun(claudeHome: string, config: RunnerSpawnConfig):
70
79
  // agent-relay communication instructions written into its config home.
71
80
  if (profileAllowsRelayFeature(config, "context")) writeClaudeRelayManual(claudeHome);
72
81
  seedClaudeConfigIfMissing(claudeHome, config);
82
+ // #554/#557 — relay-provisioned skills are materialized into <home>/skills by the launch
83
+ // assembler (materializeLaunchAssembly), the single owner of all provisioned-asset writes;
84
+ // the bootstrap only creates/auths the home so the assembler has somewhere to write.
73
85
  const sourceHome = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
74
86
  return linkExistingAuthItems(sourceHome, claudeHome, CLAUDE_AUTH_ITEMS);
75
87
  }
@@ -1,28 +1,42 @@
1
1
  import type { AgentProfile, AgentProfileProjectionEntry, AgentProfileProjectionReport, SpawnProvider } from "agent-relay-sdk";
2
+ import type { AssembledLaunch } from "./launch-assembly";
3
+
4
+ // #557 — the projection report DESCRIBES an AssembledLaunch, the same object the adapters
5
+ // turn into launch args. It is no longer a separate re-derivation from the profile, so the
6
+ // report and the real launch share one source of truth and cannot drift: change the
7
+ // assembler and both the args AND this report change together. Every entry below reads a
8
+ // field the assembler actually computed (relay gates, provisioned sets, instruction
9
+ // disposition, vanilla mechanism, config home) — only the not-yet-enforced category modes
10
+ // (mcp/hooks) and the purely-declarative env/permissions read straight off the profile.
2
11
 
3
12
  interface AgentProfileProjectionInput {
4
- provider: SpawnProvider;
5
- profile: AgentProfile;
13
+ assembled: AssembledLaunch;
6
14
  generatedAt?: number;
7
15
  }
8
16
 
9
17
  export function agentProfileProjectionReport(input: AgentProfileProjectionInput): AgentProfileProjectionReport {
18
+ const { assembled } = input;
19
+ const { provider } = assembled;
20
+ const profile = assembled.profile;
21
+ const generatedAt = input.generatedAt ?? Date.now();
10
22
  const entries: AgentProfileProjectionEntry[] = [];
11
23
  const add = (entry: AgentProfileProjectionEntry) => entries.push(entry);
12
- const { provider, profile } = input;
13
- const generatedAt = input.generatedAt ?? Date.now();
14
24
 
15
- add(relayContextEntry(profile));
16
- add(relaySkillsEntry(provider, profile));
17
- add(relayPluginsEntry(provider, profile));
18
- add(relayStatusLineEntry(provider, profile));
19
- add(repoInstructionsEntry(profile));
20
- add(globalInstructionsEntry(provider, profile));
21
- add(mcpEntry(provider, profile));
22
- add(hooksEntry(provider, profile));
23
- add(filesystemEntry(profile));
24
- add(configHomeEntry(provider, profile));
25
- add(envEntry(profile));
25
+ add(relayContextEntry(assembled));
26
+ add(relaySkillsEntry(assembled));
27
+ add(relayPluginsEntry(assembled));
28
+ add(relayStatusLineEntry(assembled));
29
+ add(repoInstructionsEntry(assembled));
30
+ add(globalInstructionsEntry(assembled));
31
+ add(mcpEntry(assembled));
32
+ add(hooksEntry(assembled));
33
+ add(provisioningSkillsEntry(assembled));
34
+ add(provisioningPluginsEntry(assembled));
35
+ add(provisioningMcpEntry(assembled));
36
+ if (profile) add(filesystemEntry(profile));
37
+ add(configHomeEntry(assembled));
38
+ if (assembled.vanilla) add(vanillaLaunchEntry(provider));
39
+ if (profile) add(envEntry(profile));
26
40
 
27
41
  const warnings = entries
28
42
  .filter((entry) => entry.result === "partial" || entry.result === "unsupported")
@@ -32,9 +46,9 @@ export function agentProfileProjectionReport(input: AgentProfileProjectionInput)
32
46
  .map((entry) => entry.capability);
33
47
 
34
48
  return {
35
- profileName: profile.name,
49
+ profileName: profile?.name ?? "default",
36
50
  provider,
37
- base: profile.base,
51
+ base: assembled.base,
38
52
  generatedAt,
39
53
  entries,
40
54
  warnings,
@@ -42,74 +56,116 @@ export function agentProfileProjectionReport(input: AgentProfileProjectionInput)
42
56
  };
43
57
  }
44
58
 
45
- function relayContextEntry(profile: AgentProfile): AgentProfileProjectionEntry {
46
- return profile.relay.context
59
+ function relayContextEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
60
+ return a.relayContext
47
61
  ? applied("relay.context", "enabled", "Relay context is injected before Relay-delivered provider turns.")
48
62
  : applied("relay.context", "disabled", "Relay context injection is disabled by the profile.");
49
63
  }
50
64
 
51
- function relaySkillsEntry(provider: SpawnProvider, profile: AgentProfile): AgentProfileProjectionEntry {
52
- if (provider === "codex") {
53
- return profile.relay.skills
65
+ function relaySkillsEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
66
+ if (a.provider === "codex") {
67
+ return a.relaySkills
54
68
  ? applied("relay.skills", "enabled", "Bundled Agent Relay Codex skills are passed through Codex skills.config.")
55
69
  : applied("relay.skills", "disabled", "Bundled Agent Relay Codex skills are omitted.");
56
70
  }
57
- return profile.relay.skills
71
+ return a.relaySkills
58
72
  ? applied("relay.skills", "enabled", "Agent Relay Claude skills are available through the bundled Relay plugin.")
59
- : applied("relay.skills", "disabled", profile.relay.plugins
73
+ : applied("relay.skills", "disabled", a.relayPlugin
60
74
  ? "Claude does not expose an independent Relay skill switch; disabling skills requires disabling the Relay plugin."
61
75
  : "Bundled Agent Relay Claude plugin dirs are omitted, so Relay plugin-provided skills are unavailable.");
62
76
  }
63
77
 
64
- function relayPluginsEntry(provider: SpawnProvider, profile: AgentProfile): AgentProfileProjectionEntry {
65
- if (provider === "claude") {
66
- return profile.relay.plugins
78
+ function relayPluginsEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
79
+ if (a.provider === "claude") {
80
+ return a.relayPlugin
67
81
  ? applied("relay.plugins", "enabled", "Bundled Agent Relay Claude plugin dir is passed to Claude.")
68
82
  : applied("relay.plugins", "disabled", "Bundled Agent Relay Claude plugin dirs are omitted.");
69
83
  }
70
- return notApplicable("relay.plugins", profile.relay.plugins ? "enabled" : "disabled", "Codex Relay integration uses skills/app-server delivery, not a Relay plugin dir.");
84
+ return notApplicable("relay.plugins", a.relaySkills ? "enabled" : "disabled", "Codex Relay integration uses skills/app-server delivery, not a Relay plugin dir.");
71
85
  }
72
86
 
73
- function relayStatusLineEntry(provider: SpawnProvider, profile: AgentProfile): AgentProfileProjectionEntry {
74
- if (provider === "claude") {
75
- return profile.relay.statusLine
87
+ function relayStatusLineEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
88
+ if (a.provider === "claude") {
89
+ return a.statusLine
76
90
  ? applied("relay.statusLine", "enabled", "Agent Relay status-line context probe settings are injected when caller settings allow it.")
77
91
  : applied("relay.statusLine", "disabled", "Agent Relay status-line settings are omitted.");
78
92
  }
79
- return notApplicable("relay.statusLine", profile.relay.statusLine ? "enabled" : "disabled", "Codex reports context through App Server events instead of a Relay status-line setting.");
93
+ return notApplicable("relay.statusLine", "disabled", "Codex reports context through App Server events instead of a Relay status-line setting.");
80
94
  }
81
95
 
82
- function repoInstructionsEntry(profile: AgentProfile): AgentProfileProjectionEntry {
83
- if (profile.instructions.repoInstructions === "allow") {
84
- return applied("instructions.repo", "allow", "Repo-local provider instructions are left available.");
96
+ function repoInstructionsEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
97
+ const disposition = a.instructions.repo;
98
+ if (disposition === "allow") return applied("instructions.repo", "allow", "Repo-local provider instructions are left available.");
99
+ if (disposition === "suppressed") {
100
+ return applied("instructions.repo", "ignore", a.provider === "claude"
101
+ ? "Repo CLAUDE.md is suppressed: Claude launches with --setting-sources \"user\" (the project scope carrying repo CLAUDE.md is excluded; only the relay-built config home loads)."
102
+ : "Repo AGENTS.md is suppressed: Codex launches with project_doc_max_bytes=0 (no project doc loaded).");
85
103
  }
86
- return unsupported("instructions.repo", "ignore", "Provider-specific repo instruction suppression is not projected yet.");
104
+ return unsupported("instructions.repo", "ignore", "Repo instruction suppression requires the vanilla base; isolated/minimal do not enforce it at launch yet.");
87
105
  }
88
106
 
89
- function globalInstructionsEntry(provider: SpawnProvider, profile: AgentProfile): AgentProfileProjectionEntry {
90
- if (profile.instructions.globalInstructions === "allow") {
91
- return applied("instructions.global", "allow", "Host/global provider instructions are left available.");
92
- }
93
- if (profile.base !== "host") {
94
- return applied("instructions.global", "ignore", `${provider} uses an isolated provider config home, so host/global provider instructions are not loaded.`);
107
+ // #552/#554/#555/#556 — the vanilla base's launch-time host suppression, naming the exact
108
+ // provider mechanism the assembler emitted (Claude --setting-sources "user"; Codex
109
+ // project_doc_max_bytes=0). OS-level admin/managed policy is beyond any client flag and is
110
+ // called out so it isn't mistaken for a leak.
111
+ function vanillaLaunchEntry(provider: SpawnProvider): AgentProfileProjectionEntry {
112
+ return provider === "claude"
113
+ ? applied("provider.vanilla", "--setting-sources user", "Claude launches the isolated config home under --setting-sources \"user\": host CLAUDE.md (repo + global), host skills/plugins/hooks, and host MCP (.mcp.json / ~/.claude.json) are all excluded; relay-provisioned assets in the home load. Auth, model, and built-in tools work normally. OS-level admin-managed policy still applies.")
114
+ : applied("provider.vanilla", "project_doc_max_bytes=0", "Codex launches with an isolated CODEX_HOME (no host config.toml MCP/hooks) and project_doc_max_bytes=0 (no AGENTS.md project doc, repo or global).");
115
+ }
116
+
117
+ function provisioningSkillsEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
118
+ const count = a.skills.length;
119
+ if (!count) return notApplicable("provisioning.skills", "none", "No relay-provisioned skills materialized for this launch.");
120
+ const names = ` (${a.skills.map((s) => s.name).join(", ")})`;
121
+ return applied("provisioning.skills", `${count}`, a.provider === "claude"
122
+ ? `Relay-provisioned skills materialized into the isolated config home skills/ dir, auto-discovered under --setting-sources "user"${names}.`
123
+ : `Relay-provisioned skills added to Codex skills.config from the isolated CODEX_HOME${names}.`);
124
+ }
125
+
126
+ function provisioningPluginsEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
127
+ if (a.provider === "codex") {
128
+ const declared = (a.profile?.plugins ?? []).filter((p) => p.enabled).length;
129
+ return notApplicable("provisioning.plugins", `${declared}`, "Codex has no --plugin-dir surface; provisioned plugins apply to Claude only.");
95
130
  }
96
- return unsupported("instructions.global", "ignore", `${provider} global instruction isolation requires an isolated provider config home.`);
131
+ const count = a.plugins.length;
132
+ if (!count) return notApplicable("provisioning.plugins", "none", "No relay-provisioned plugins materialized for this launch.");
133
+ const names = ` (${a.plugins.map((p) => p.name).join(", ")})`;
134
+ return applied("provisioning.plugins", `${count}`, `Relay-provisioned plugins passed to Claude via --plugin-dir${names}.`);
135
+ }
136
+
137
+ function provisioningMcpEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
138
+ const names = a.mcp.servers;
139
+ if (!names.length) return notApplicable("provisioning.mcp", "none", "No relay-provisioned MCP servers materialized for this launch.");
140
+ const label = `${names.length} server${names.length === 1 ? "" : "s"}`;
141
+ return applied("provisioning.mcp", label, a.provider === "claude"
142
+ ? `Relay-provisioned MCP servers composed into --mcp-config (${names.join(", ")})${a.mcp.strict ? "; --strict-mcp-config guarantees only these load" : ""}.`
143
+ : `Relay-provisioned MCP servers emitted as -c mcp_servers.* overrides (${names.join(", ")}).`);
144
+ }
145
+
146
+ function globalInstructionsEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
147
+ const disposition = a.instructions.global;
148
+ if (disposition === "allow") return applied("instructions.global", "allow", "Host/global provider instructions are left available.");
149
+ if (disposition === "isolated") return applied("instructions.global", "ignore", `${a.provider} uses an isolated provider config home, so host/global provider instructions are not loaded.`);
150
+ return unsupported("instructions.global", "ignore", `${a.provider} global instruction isolation requires an isolated provider config home.`);
97
151
  }
98
152
 
99
- function mcpEntry(provider: SpawnProvider, profile: AgentProfile): AgentProfileProjectionEntry {
100
- if (profile.mcp.mode === "host") return applied("mcp", "host", "Host provider MCP configuration is left available.");
101
- if (profile.mcp.mode === "none" && profile.base !== "host") {
102
- return applied("mcp", "none", `${provider} uses an isolated provider config home without host MCP configuration.`);
153
+ function mcpEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
154
+ const mode = a.profile?.mcp.mode ?? "host";
155
+ if (mode === "host") return applied("mcp", "host", "Host provider MCP configuration is left available.");
156
+ if (mode === "none" && a.base !== "host") {
157
+ return applied("mcp", "none", `${a.provider} uses an isolated provider config home without host MCP configuration.`);
103
158
  }
104
- return unsupported("mcp", profile.mcp.mode, `${provider} MCP category projection is not enforced yet.`);
159
+ return unsupported("mcp", mode, `${a.provider} MCP category projection is not enforced yet.`);
105
160
  }
106
161
 
107
- function hooksEntry(provider: SpawnProvider, profile: AgentProfile): AgentProfileProjectionEntry {
108
- if (profile.hooks.mode === "host") return applied("hooks", "host", "Host provider hooks are left available.");
109
- if (profile.hooks.mode === "none" && profile.base !== "host") {
110
- return applied("hooks", "none", `${provider} uses an isolated provider config home without host hook configuration.`);
162
+ function hooksEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
163
+ const mode = a.profile?.hooks.mode ?? "host";
164
+ if (mode === "host") return applied("hooks", "host", "Host provider hooks are left available.");
165
+ if (mode === "none" && a.base !== "host") {
166
+ return applied("hooks", "none", `${a.provider} uses an isolated provider config home without host hook configuration.`);
111
167
  }
112
- return unsupported("hooks", profile.hooks.mode, `${provider} hook category projection is not enforced yet.`);
168
+ return unsupported("hooks", mode, `${a.provider} hook category projection is not enforced yet.`);
113
169
  }
114
170
 
115
171
  function filesystemEntry(profile: AgentProfile): AgentProfileProjectionEntry {
@@ -117,9 +173,9 @@ function filesystemEntry(profile: AgentProfile): AgentProfileProjectionEntry {
117
173
  return partial("permissions.filesystem", profile.permissions.filesystem, "Relay constrains spawn cwd through the orchestrator; provider-level filesystem sandbox projection is not complete yet.");
118
174
  }
119
175
 
120
- function configHomeEntry(provider: SpawnProvider, profile: AgentProfile): AgentProfileProjectionEntry {
121
- if (profile.base === "host") return applied("provider.configHome", "host", "Provider uses the host's normal configuration home.");
122
- return applied("provider.configHome", profile.base, `${provider} is launched with an Agent Relay managed provider config home and linked host auth where available.`);
176
+ function configHomeEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
177
+ if (!a.configHome) return applied("provider.configHome", "host", "Provider uses the host's normal configuration home.");
178
+ return applied("provider.configHome", a.base, `${a.provider} is launched with an Agent Relay managed provider config home and linked host auth where available.`);
123
179
  }
124
180
 
125
181
  function envEntry(profile: AgentProfile): AgentProfileProjectionEntry {
@@ -0,0 +1,157 @@
1
+ import { existsSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, resolve } from "node:path";
3
+ import { sanitizeFsName } from "agent-relay-sdk/fs-name";
4
+ import type { ProvisioningAssetFile, ResolvedProvisioning } from "agent-relay-sdk";
5
+ import type { RunnerSpawnConfig } from "./adapter";
6
+
7
+ // Runner-side materialization of the relay-resolved provisioning bundle
8
+ // (#554/#555/#556). The relay resolves a profile's refs against the DB registry and
9
+ // attaches the concrete definitions to profile.provisioning; here we turn those into
10
+ // real on-disk dirs (skills/plugins). MCP is handled in relay-mcp.ts.
11
+ //
12
+ // #557 — split into PURE RESOLUTION (resolve* → where each asset lands, no writes) and
13
+ // MATERIALIZATION (materialize* → the disk writes). The single launch assembler
14
+ // (launch-assembly.ts) calls the resolvers to build BOTH the launch args and the
15
+ // projection report, so what's reported can never drift from what's written; the
16
+ // materialize* writers consume the same resolved entries, so what's written can never
17
+ // drift from what's reported either. One resolution, three consumers.
18
+
19
+ const EMPTY: ResolvedProvisioning = { skills: [], plugins: [], mcpServers: {} };
20
+
21
+ export function profileProvisioning(config: Pick<RunnerSpawnConfig, "agentProfile">): ResolvedProvisioning {
22
+ const p = config.agentProfile?.provisioning;
23
+ if (!p) return EMPTY;
24
+ return { skills: p.skills ?? [], plugins: p.plugins ?? [], mcpServers: p.mcpServers ?? {} };
25
+ }
26
+
27
+ // A resolved provisioning asset: WHERE it lands (`dir`) and HOW it materializes
28
+ // (`source`). `files` writes the inline set into `dir`; `link` symlinks a pre-existing
29
+ // source dir at `dir`; `inPlace` uses the source dir as-is (no write, dir IS the source).
30
+ export type ResolvedAssetSource =
31
+ | { kind: "files"; files: ProvisioningAssetFile[] }
32
+ | { kind: "link"; source: string }
33
+ | { kind: "inPlace" };
34
+ export interface ResolvedAsset {
35
+ name: string;
36
+ dir: string;
37
+ source: ResolvedAssetSource;
38
+ }
39
+
40
+ function assetDirName(name: string): string {
41
+ return sanitizeFsName(name, { replacement: "-", collapse: false, maxLen: 120, fallback: "asset" });
42
+ }
43
+
44
+ function resolveExistingDir(dir: string, cwd: string): string | null {
45
+ const abs = isAbsolute(dir) ? dir : resolve(cwd, dir);
46
+ return existsSync(abs) ? abs : null;
47
+ }
48
+
49
+ function hasRootSkillManifest(files: ProvisioningAssetFile[]): boolean {
50
+ return files.some((f) => f.path === "SKILL.md");
51
+ }
52
+
53
+ // Write an inline file set into <dir>, guarding against path escapes from a malformed
54
+ // registry entry. Shared by every files-source materialization.
55
+ function writeInlineFiles(dir: string, files: ProvisioningAssetFile[]): void {
56
+ mkdirSync(dir, { recursive: true });
57
+ for (const file of files) {
58
+ const dest = resolve(dir, file.path);
59
+ if (!dest.startsWith(resolve(dir))) continue;
60
+ mkdirSync(dirname(dest), { recursive: true });
61
+ writeFileSync(dest, file.content, { mode: 0o600 });
62
+ }
63
+ }
64
+
65
+ // Perform the disk write a resolved asset describes. files → write inline; link →
66
+ // (re)create the symlink; inPlace → nothing (the dir already exists at the source).
67
+ // Best-effort for links: a failed link must not abort the launch.
68
+ function materializeAsset(asset: ResolvedAsset): boolean {
69
+ if (asset.source.kind === "files") {
70
+ writeInlineFiles(asset.dir, asset.source.files);
71
+ return true;
72
+ }
73
+ if (asset.source.kind === "link") {
74
+ try {
75
+ mkdirSync(dirname(asset.dir), { recursive: true });
76
+ if (existsSync(asset.dir)) rmSync(asset.dir, { recursive: true, force: true });
77
+ symlinkSync(asset.source.source, asset.dir);
78
+ return true;
79
+ } catch {
80
+ return false;
81
+ }
82
+ }
83
+ return existsSync(asset.dir);
84
+ }
85
+
86
+ // #554 (Claude) — each provisioned skill lands at <claudeHome>/skills/<name> so it's
87
+ // auto-discovered under the launch's `--setting-sources "user"` scope (host skills
88
+ // never bleed). Inline files are written there; a pre-existing dir is symlinked in.
89
+ export function resolveClaudeProvisionedSkills(claudeHome: string, config: RunnerSpawnConfig): ResolvedAsset[] {
90
+ const { skills } = profileProvisioning(config);
91
+ const skillsRoot = join(claudeHome, "skills");
92
+ const resolved: ResolvedAsset[] = [];
93
+ for (const skill of skills) {
94
+ const dir = join(skillsRoot, assetDirName(skill.name));
95
+ if (skill.files?.length) {
96
+ resolved.push({ name: skill.name, dir, source: { kind: "files", files: skill.files } });
97
+ continue;
98
+ }
99
+ if (skill.dir) {
100
+ const source = resolveExistingDir(skill.dir, config.cwd);
101
+ if (source) resolved.push({ name: skill.name, dir, source: { kind: "link", source } });
102
+ }
103
+ }
104
+ return resolved;
105
+ }
106
+
107
+ // #556 (Claude) — each provisioned plugin resolves to a --plugin-dir. Inline plugins
108
+ // materialize under <claudeHome>/relay-provisioned-plugins/<name>; declared dirs are
109
+ // used in place. The resolved `dir` is appended to the launch's --plugin-dir set.
110
+ export function resolveClaudeProvisionedPlugins(claudeHome: string, config: RunnerSpawnConfig): ResolvedAsset[] {
111
+ const { plugins } = profileProvisioning(config);
112
+ const parent = join(claudeHome, "relay-provisioned-plugins");
113
+ const resolved: ResolvedAsset[] = [];
114
+ for (const plugin of plugins) {
115
+ if (plugin.files?.length) {
116
+ resolved.push({ name: plugin.name, dir: join(parent, assetDirName(plugin.name)), source: { kind: "files", files: plugin.files } });
117
+ continue;
118
+ }
119
+ if (plugin.dir) {
120
+ const source = resolveExistingDir(plugin.dir, config.cwd);
121
+ if (source) resolved.push({ name: plugin.name, dir: source, source: { kind: "inPlace" } });
122
+ }
123
+ }
124
+ return resolved;
125
+ }
126
+
127
+ // #554 (Codex) — each provisioned skill resolves to a dir for `-c skills.config`.
128
+ // Inline skills materialize under <codexHome>/relay-provisioned-skills/<name>; declared
129
+ // dirs are used in place. Codex requires a SKILL.md at the dir root to register it, so
130
+ // inline sets without one and dirs missing it are dropped at resolution time.
131
+ export function resolveCodexProvisionedSkills(codexHome: string, config: RunnerSpawnConfig): ResolvedAsset[] {
132
+ const { skills } = profileProvisioning(config);
133
+ const parent = join(codexHome, "relay-provisioned-skills");
134
+ const resolved: ResolvedAsset[] = [];
135
+ for (const skill of skills) {
136
+ if (skill.files?.length) {
137
+ if (!hasRootSkillManifest(skill.files)) continue;
138
+ resolved.push({ name: skill.name, dir: join(parent, assetDirName(skill.name)), source: { kind: "files", files: skill.files } });
139
+ continue;
140
+ }
141
+ if (skill.dir) {
142
+ const source = resolveExistingDir(skill.dir, config.cwd);
143
+ if (source && existsSync(join(source, "SKILL.md"))) resolved.push({ name: skill.name, dir: source, source: { kind: "inPlace" } });
144
+ }
145
+ }
146
+ return resolved;
147
+ }
148
+
149
+ // Write the resolved assets to disk; returns the dirs that materialized successfully,
150
+ // in resolution order. Idempotent — safe to re-run against an existing home.
151
+ export function materializeResolvedAssets(assets: ResolvedAsset[]): string[] {
152
+ const dirs: string[] = [];
153
+ for (const asset of assets) {
154
+ if (materializeAsset(asset)) dirs.push(asset.dir);
155
+ }
156
+ return dirs;
157
+ }
package/src/relay-mcp.ts CHANGED
@@ -23,27 +23,6 @@ export function relayMcpEndpoint(relayUrl: string): string {
23
23
  return `${relayUrl.replace(/\/+$/, "")}${RELAY_MCP_PATH}`;
24
24
  }
25
25
 
26
- // Claude: additive `--mcp-config` JSON (NOT --strict-mcp-config, which would clobber
27
- // the user's own servers). HTTP transport, token via env-var expansion so it never
28
- // hits argv. Returns the full ["--mcp-config", "<json>"] arg pair.
29
- //
30
- // `endpoint` overrides the target URL: the runner passes its local MCP proxy URL (Stage 2,
31
- // #215) so the agent connects to the Runner, not the relay. Omitted → the direct relay endpoint.
32
- export function relayMcpClaudeConfigArg(relayUrl: string, endpoint?: string): string[] {
33
- return [
34
- "--mcp-config",
35
- JSON.stringify({
36
- mcpServers: {
37
- [RELAY_MCP_SERVER_NAME]: {
38
- type: "http",
39
- url: endpoint ?? relayMcpEndpoint(relayUrl),
40
- headers: { Authorization: `Bearer \${${RELAY_MCP_TOKEN_ENV}}` },
41
- },
42
- },
43
- }),
44
- ];
45
- }
46
-
47
26
  // Codex: `-c mcp_servers.<name>.*` overrides. `bearer_token_env_var` tells Codex to
48
27
  // read the token from the env var itself → transport resolves to streamable_http.
49
28
  // `endpoint` overrides the target URL (runner-local proxy, Stage 2 #215) — see above.
@@ -62,3 +41,64 @@ export function relayMcpCodexConfigArgs(relayUrl: string, endpoint?: string): st
62
41
  export function tomlString(value: string): string {
63
42
  return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
64
43
  }
44
+
45
+ // #555 — compose the launch MCP set from (the relay endpoint, if relay.mcp) PLUS the
46
+ // profile's relay-provisioned servers. One `--mcp-config` JSON carries them all.
47
+ // `strict` (vanilla) adds `--strict-mcp-config` so ONLY these load — host `.mcp.json`
48
+ // / `~/.claude.json` are ignored even if a stray config exists (belt-and-suspenders
49
+ // on top of the `--setting-sources "user"` scope that already excludes repo MCP).
50
+ import type { ProvisioningMcpServer } from "agent-relay-sdk";
51
+
52
+ function claudeMcpServerEntry(server: ProvisioningMcpServer): Record<string, unknown> {
53
+ const entry: Record<string, unknown> = {};
54
+ if (server.type) entry.type = server.type;
55
+ if (server.command) entry.command = server.command;
56
+ if (server.args?.length) entry.args = server.args;
57
+ if (server.env && Object.keys(server.env).length) entry.env = server.env;
58
+ if (server.url) entry.url = server.url;
59
+ if (server.headers && Object.keys(server.headers).length) entry.headers = server.headers;
60
+ return entry;
61
+ }
62
+
63
+ export function claudeMcpLaunchArgs(opts: {
64
+ relayUrl: string;
65
+ endpoint?: string;
66
+ includeRelay: boolean;
67
+ servers?: Record<string, ProvisioningMcpServer>;
68
+ strict?: boolean;
69
+ }): string[] {
70
+ const mcpServers: Record<string, unknown> = {};
71
+ if (opts.includeRelay) {
72
+ mcpServers[RELAY_MCP_SERVER_NAME] = {
73
+ type: "http",
74
+ url: opts.endpoint ?? relayMcpEndpoint(opts.relayUrl),
75
+ headers: { Authorization: `Bearer \${${RELAY_MCP_TOKEN_ENV}}` },
76
+ };
77
+ }
78
+ for (const [name, server] of Object.entries(opts.servers ?? {})) {
79
+ // Never let a declared server shadow the relay endpoint (identity-bearing).
80
+ if (name === RELAY_MCP_SERVER_NAME) continue;
81
+ const entry = claudeMcpServerEntry(server);
82
+ if (Object.keys(entry).length) mcpServers[name] = entry;
83
+ }
84
+ // Nothing to inject and not enforcing strict isolation → don't pass an empty config.
85
+ if (Object.keys(mcpServers).length === 0 && !opts.strict) return [];
86
+ return ["--mcp-config", JSON.stringify({ mcpServers }), ...(opts.strict ? ["--strict-mcp-config"] : [])];
87
+ }
88
+
89
+ // #555 (Codex) — `-c mcp_servers.<name>.*` overrides for each relay-provisioned
90
+ // server, the same shape relayMcpCodexConfigArgs uses for the relay endpoint.
91
+ export function codexProvisionedMcpArgs(servers?: Record<string, ProvisioningMcpServer>): string[] {
92
+ const args: string[] = [];
93
+ for (const [name, server] of Object.entries(servers ?? {})) {
94
+ if (name === RELAY_MCP_SERVER_NAME) continue;
95
+ const key = `mcp_servers.${name}`;
96
+ if (server.command) args.push("-c", `${key}.command=${tomlString(server.command)}`);
97
+ if (server.args?.length) args.push("-c", `${key}.args=[${server.args.map(tomlString).join(",")}]`);
98
+ if (server.url) args.push("-c", `${key}.url=${tomlString(server.url)}`);
99
+ for (const [envKey, envVal] of Object.entries(server.env ?? {})) {
100
+ args.push("-c", `${key}.env.${envKey}=${tomlString(envVal)}`);
101
+ }
102
+ }
103
+ return args;
104
+ }
@@ -378,7 +378,9 @@ export class AgentRunner {
378
378
  model: options.model ?? null,
379
379
  effort: options.effort ?? null,
380
380
  profile: options.profile ?? null,
381
- agentProfile: options.agentProfile ? appliedAgentProfileMetadata(options.provider, options.agentProfile) : null,
381
+ // #557 pass a spawn-shaped config so the projection describes the SAME assembled
382
+ // launch the adapter builds (env/controlPort are placeholders the pure assembler ignores).
383
+ agentProfile: options.agentProfile ? appliedAgentProfileMetadata(options.provider, options.agentProfile, { ...options, agentId: this.agentId, env: {}, controlPort: 0 } as RunnerSpawnConfig, options.providerConfig) : null,
382
384
  runnerId: options.runnerId,
383
385
  startedAt: options.startedAt,
384
386
  tmuxSession: this.providerTerminalSession() ?? options.tmuxSession ?? null,
@@ -2,8 +2,9 @@ import { closeSync, openSync, readSync, statSync } from "node:fs";
2
2
  import type { AgentProfile, ContextState, Message, ProviderCapabilities } from "agent-relay-sdk";
3
3
  import { errMessage } from "agent-relay-sdk";
4
4
  import { targetMatchesAgentIdentity } from "agent-relay-sdk/agent-target";
5
- import type { ProviderAdapter, SemanticStatus } from "./adapter";
5
+ import type { ProviderAdapter, ProviderConfig, RunnerSpawnConfig, SemanticStatus } from "./adapter";
6
6
  import { agentProfileProjectionReport } from "./profile-projection";
7
+ import { assembleLaunch } from "./launch-assembly";
7
8
 
8
9
  const MAX_TIMER_DELAY_MS = 2_147_483_647;
9
10
  const LOG_TAIL_BYTES = 128 * 1024;
@@ -272,9 +273,17 @@ function runtimeProviderTerminalCapabilities(options: RuntimeProviderOptions): P
272
273
  return {};
273
274
  }
274
275
 
275
- export function appliedAgentProfileMetadata(provider: string, profile: AgentProfile): Record<string, unknown> {
276
- const projection = provider === "claude" || provider === "codex"
277
- ? agentProfileProjectionReport({ provider, profile })
276
+ // #557 the agent-card projection now DESCRIBES the assembled launch (same source the
277
+ // adapter turns into args), so it can't drift from reality. `config`/`providerConfig` are
278
+ // the spawn inputs; when present we assemble (pure no disk writes here) and report that.
279
+ export function appliedAgentProfileMetadata(
280
+ provider: string,
281
+ profile: AgentProfile,
282
+ config?: RunnerSpawnConfig,
283
+ providerConfig?: ProviderConfig,
284
+ ): Record<string, unknown> {
285
+ const projection = (provider === "claude" || provider === "codex") && config && providerConfig
286
+ ? agentProfileProjectionReport({ assembled: assembleLaunch(provider, config, providerConfig) })
278
287
  : undefined;
279
288
  return {
280
289
  name: profile.name,