@tangle-network/agent-profile-materialize 0.9.3 → 0.10.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.
Files changed (3) hide show
  1. package/dist/index.d.ts +110 -13
  2. package/dist/index.js +695 -57
  3. package/package.json +3 -3
package/dist/index.d.ts CHANGED
@@ -21,7 +21,13 @@
21
21
  * `node:fs` IO consumer. It was previously copied byte-for-byte into both
22
22
  * cli-bridge and agent-dev-container — this is the single source those re-export.
23
23
  */
24
- import type { AgentCandidateConfigValue, AgentCandidateProfile, AgentCandidateProfileActivation, AgentCandidateProfileApplication, AgentCandidateProfilePlanEvidence, AgentCandidateProfilePlanMaterial, AgentProfile, AgentProfileMcpServer, Sha256Digest } from "@tangle-network/agent-interface";
24
+ import type { AgentCandidateConfigValue, AgentCandidateProfile, AgentCandidateProfileActivation, AgentCandidateProfileApplication, AgentCandidateProfilePlanEvidence, AgentCandidateProfilePlanMaterial, AgentProfile, AgentProfileConfigValue, AgentProfileMcpServer, AgentProfileSecretProvider, AgentProfileSecretRef, Sha256Digest } from "@tangle-network/agent-interface";
25
+ export declare const UNAVAILABLE_AGENT_PROFILE_SECRET_PROVIDER: AgentProfileSecretProvider;
26
+ /**
27
+ * The harnesses this materializer can lower an AgentProfile into. `satisfies` binds every id to
28
+ * the canonical harness enum, so a rename upstream is a compile error here rather than an
29
+ * `isMaterializerHarness` that silently returns false for a harness that still exists.
30
+ */
25
31
  export declare const MATERIALIZER_HARNESSES: readonly ["claude-code", "nanoclaw", "codex", "opencode", "kimi-code", "pi", "gemini", "hermes", "openclaw"];
26
32
  export type HarnessId = (typeof MATERIALIZER_HARNESSES)[number];
27
33
  export declare function isMaterializerHarness(harness: string): harness is HarnessId;
@@ -50,24 +56,94 @@ export interface PlanFile {
50
56
  * this on every file it emits.
51
57
  */
52
58
  source?: "user" | "generated";
59
+ /**
60
+ * Secret leaves in a generated JSON document. `content` contains `null` at
61
+ * each path until the private executor resolves the named reference. The
62
+ * public plan, its digest, and its receipt retain only these references.
63
+ */
64
+ secretSlots?: PlanFileSecretSlot[];
53
65
  }
66
+ export interface PlanFileSecretSlot {
67
+ path: Array<string | number>;
68
+ ref: AgentProfileSecretRef;
69
+ }
70
+ export interface WorkspacePlanSecretSegment {
71
+ kind: "secret";
72
+ ref: AgentProfileSecretRef;
73
+ /** Encoding applied after private resolution. */
74
+ encoding: "raw" | "json-string";
75
+ }
76
+ /** A launch argument whose private leaves are resolved immediately before spawn. */
77
+ export interface WorkspacePlanArgumentTemplate {
78
+ kind: "workspace-plan-argument-template";
79
+ segments: Array<string | WorkspacePlanSecretSegment>;
80
+ }
81
+ export type WorkspacePlanArgument = string | WorkspacePlanArgumentTemplate;
82
+ /** Public bytes stay strings; secret references stay opaque until application. */
83
+ export type WorkspacePlanConfigValue = string | AgentProfileSecretRef;
54
84
  export interface Unsupported {
55
85
  dimension: "files" | "skills" | "mcp" | "hooks" | "subagents" | "commands" | "instructions" | "systemPrompt" | "connections" | "confidential" | "permissions" | "tools" | "modes" | "extensions";
56
86
  reason: string;
57
87
  }
88
+ /**
89
+ * The dimensions `materializeProfile`'s `skip` option honors.
90
+ *
91
+ * Every other dimension is materialized unconditionally: generated plan files are pushed through
92
+ * a single site that carries no dimension tag, so there is nothing to gate them on. Naming the
93
+ * honored set keeps `skip: ["hooks"]` a loud rejection instead of a silent no-op.
94
+ */
95
+ export declare const SKIPPABLE_DIMENSIONS: readonly ["mcp", "extensions"];
96
+ export type SkippableDimension = (typeof SKIPPABLE_DIMENSIONS)[number];
97
+ export declare function isSkippableDimension(value: string): value is SkippableDimension;
58
98
  /** The plan a caller writes into the run cwd, then applies to the spawn. */
59
99
  export interface WorkspacePlan {
60
100
  harness: HarnessId;
61
101
  /** Files to write relative to the run workspace (cwd). */
62
102
  files: PlanFile[];
63
103
  /** Env vars to set on the spawned process (for harnesses needing them). */
64
- env: Record<string, string>;
104
+ env: Record<string, WorkspacePlanConfigValue>;
65
105
  /** Extra CLI flags to pass (e.g. kimi --mcp-config-file <path>). */
66
- flags: string[];
106
+ flags: WorkspacePlanArgument[];
67
107
  /** Exact system-prompt replacement supplied to the harness. */
68
108
  systemPrompt?: string;
69
109
  /** Dimensions requested but with NO cwd path on this harness — fail-closed record. */
70
110
  unsupported: Unsupported[];
111
+ /**
112
+ * Prerequisites the workspace files CANNOT establish on their own.
113
+ *
114
+ * A plan is a set of files, an env map, and flags. Some harness capabilities need more than
115
+ * that to actually function: pi's MCP support is not built in, it arrives from an extension
116
+ * that must be installed into the harness's own package directory and permitted to load. No
117
+ * file written into a run workspace can make either true.
118
+ *
119
+ * Writing the config anyway and hoping is the failure this field exists to prevent: the
120
+ * harness starts, silently has no MCP, and the agent runs without the tools it was configured
121
+ * with while nothing reports a problem. So the plan states the requirement and the executor —
122
+ * which knows where its harness is installed and how it spawns — verifies or refuses it.
123
+ *
124
+ * Optional because this is an additive field on a published type: a plan built before it
125
+ * existed, or by a caller that does not construct one, is still a valid plan with no
126
+ * prerequisites. Every plan this package builds sets it.
127
+ */
128
+ requires?: PlanRequirement[];
129
+ }
130
+ /**
131
+ * One prerequisite a {@link WorkspacePlan} depends on but cannot create.
132
+ *
133
+ * Declarative on purpose: this package builds plans and never probes a filesystem, spawns a
134
+ * process, or reaches the network. The executor holds that context and does the checking.
135
+ */
136
+ export interface PlanRequirement {
137
+ /** The only kind so far: a harness extension/plugin that must be loaded for a dimension to work. */
138
+ kind: "harness-extension";
139
+ /** Dimension that produced the requirement, so a refusal can name what will not work. */
140
+ dimension: Unsupported["dimension"];
141
+ /** Package spec in the harness's own vocabulary, e.g. `npm:pi-mcp-adapter`. */
142
+ id: string;
143
+ /** Why the plan needs it — goes verbatim into the executor's refusal message. */
144
+ reason: string;
145
+ /** The exact command that satisfies it, so a failure is actionable without a doc lookup. */
146
+ remedy: string;
71
147
  }
72
148
  /**
73
149
  * Proof of the profile workspace inputs applied before a harness is spawned.
@@ -76,12 +152,27 @@ export interface WorkspacePlan {
76
152
  */
77
153
  export interface WorkspacePlanReceipt {
78
154
  workspacePlanDigest: string;
79
- env: Record<string, string>;
80
- flags: string[];
155
+ /** Public launch identity only; secret values never enter this receipt. */
156
+ env: Record<string, WorkspacePlanConfigValue>;
157
+ /** Public launch identity only; templates retain opaque secret references. */
158
+ flags: WorkspacePlanArgument[];
81
159
  systemPrompt?: string;
82
160
  unsupported: Unsupported[];
83
161
  written: string[];
84
162
  }
163
+ /** Private values handed directly to the provider spawn, never to receipts. */
164
+ export interface WorkspacePlanSpawnInputs {
165
+ env: Record<string, string>;
166
+ flags: string[];
167
+ systemPrompt?: string;
168
+ }
169
+ /** Result of applying a public plan at the private executor boundary. */
170
+ export interface WorkspacePlanApplication {
171
+ receipt: WorkspacePlanReceipt;
172
+ spawn: WorkspacePlanSpawnInputs;
173
+ /** Resolved generated files intentionally consumed in memory by the runtime. */
174
+ runtimeOnlyFiles: Record<string, string>;
175
+ }
85
176
  /** The plan was applied, but one or more private staging files remain. */
86
177
  export declare class WorkspacePlanCleanupError extends AggregateError {
87
178
  readonly receipt: WorkspacePlanReceipt;
@@ -185,7 +276,7 @@ export declare function normalizeSkillMd(name: string, raw: string): string;
185
276
  * The explicit target harness takes precedence over the profile's default.
186
277
  */
187
278
  export declare function materializeProfile(inputProfile: AgentProfile, harness: HarnessId, opts?: {
188
- skip?: Unsupported["dimension"][];
279
+ skip?: SkippableDimension[];
189
280
  }): WorkspacePlan;
190
281
  /**
191
282
  * Materialize a sealed candidate profile without weakening its process values.
@@ -218,12 +309,18 @@ export declare function captureAgentCandidateProfilePlan(plan: AgentCandidateWor
218
309
  */
219
310
  export declare function applyAgentCandidateWorkspacePlan(plan: AgentCandidateWorkspacePlan, workspaceDir: string, targetWorkspace: AgentCandidateProfileApplication["targetWorkspace"]): AgentCandidateWorkspacePlanReceipt;
220
311
  /**
221
- * Apply a WorkspacePlan to a real workspace dir: write every file (creating parent
222
- * dirs), and return the env + flags the caller must hand the spawn. This is the IO
223
- * consumer of the pure `materializeProfile` — used by the cli-bridge host spawn, the
224
- * agent-dev-container box, and VB. Returns `unsupported` so the caller can log/fail.
312
+ * Apply a public-only WorkspacePlan. A plan with any secret reference fails
313
+ * before the first write; private executors use
314
+ * {@link applyWorkspacePlanWithSecretProvider} instead.
225
315
  */
226
316
  export declare function applyWorkspacePlan(plan: WorkspacePlan, workspaceDir: string, options?: ApplyWorkspacePlanOptions): WorkspacePlanReceipt;
317
+ /**
318
+ * Resolve opaque profile references through an executor-owned provider, then
319
+ * apply the resulting private native files and launch inputs. Resolution
320
+ * completes before any workspace write, and resolved values never enter the
321
+ * public plan, digest, receipt, logs, or error messages.
322
+ */
323
+ export declare function applyWorkspacePlanWithSecretProvider(plan: WorkspacePlan, workspaceDir: string, secretProvider: AgentProfileSecretProvider, options?: ApplyWorkspacePlanOptions): Promise<WorkspacePlanApplication>;
227
324
  /** Tool IDs reported by the OpenCode binary shipped in the Sandbox image. */
228
325
  export declare const OPENCODE_NATIVE_TOOL_IDS: readonly ["apply_patch", "bash", "codesearch", "edit", "glob", "grep", "question", "read", "skill", "task", "todowrite", "webfetch", "websearch", "write"];
229
326
  /** Resolve portable and legacy profile names to current OpenCode tool IDs. */
@@ -233,12 +330,12 @@ export type OpenCodeMcpConfig = Record<string, {
233
330
  type: "remote";
234
331
  url: string;
235
332
  enabled: boolean;
236
- headers?: Record<string, string>;
333
+ headers?: Record<string, AgentProfileConfigValue>;
237
334
  } | {
238
335
  type: "local";
239
- command: string[];
336
+ command: Array<string | AgentProfileConfigValue>;
240
337
  enabled: boolean;
241
- environment?: Record<string, string>;
338
+ environment?: Record<string, AgentProfileConfigValue>;
242
339
  }>;
243
340
  /** Lower canonical MCP definitions to OpenCode's native startup configuration. */
244
341
  export declare function openCodeMcpConfig(mcp: Record<string, AgentProfileMcpServer>): OpenCodeMcpConfig;
package/dist/index.js CHANGED
@@ -24,7 +24,12 @@
24
24
  import { createHash, randomUUID } from "node:crypto";
25
25
  import { closeSync, fchmodSync, constants as fsConstants, fstatSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
26
26
  import { basename, dirname, isAbsolute, join, relative, resolve, sep, win32, } from "node:path";
27
- import { agentCandidateProfileActivationSchema, agentCandidateProfilePlanEvidenceSchema, agentCandidateProfilePlanMaterialSchema, agentCandidateProfileSchema, agentProfileSchema, canonicalCandidateBytes, canonicalCandidateDigest, resolveNativeWebTools, } from "@tangle-network/agent-interface";
27
+ import { agentCandidateProfileActivationSchema, agentCandidateProfilePlanEvidenceSchema, agentCandidateProfilePlanMaterialSchema, agentCandidateProfileSchema, agentProfileSchema, agentProfileSecretRefSchema, canonicalCandidateBytes, canonicalCandidateDigest, resolveNativeWebTools, } from "@tangle-network/agent-interface";
28
+ export const UNAVAILABLE_AGENT_PROFILE_SECRET_PROVIDER = Object.freeze({
29
+ async get() {
30
+ return undefined;
31
+ },
32
+ });
28
33
  function describeUnresolvedRef(r) {
29
34
  const name = typeof r.name === "string" ? r.name : undefined;
30
35
  const path = typeof r.path === "string" ? r.path : undefined;
@@ -54,6 +59,11 @@ function resolveInlineRef(ref) {
54
59
  }
55
60
  return null; // github / unresolved
56
61
  }
62
+ /**
63
+ * The harnesses this materializer can lower an AgentProfile into. `satisfies` binds every id to
64
+ * the canonical harness enum, so a rename upstream is a compile error here rather than an
65
+ * `isMaterializerHarness` that silently returns false for a harness that still exists.
66
+ */
57
67
  export const MATERIALIZER_HARNESSES = [
58
68
  "claude-code",
59
69
  "nanoclaw",
@@ -74,6 +84,32 @@ function assertMaterializerHarness(harness) {
74
84
  throw new Error(`Unsupported profile materialization harness "${harness}"`);
75
85
  }
76
86
  }
87
+ /**
88
+ * The dimensions `materializeProfile`'s `skip` option honors.
89
+ *
90
+ * Every other dimension is materialized unconditionally: generated plan files are pushed through
91
+ * a single site that carries no dimension tag, so there is nothing to gate them on. Naming the
92
+ * honored set keeps `skip: ["hooks"]` a loud rejection instead of a silent no-op.
93
+ */
94
+ export const SKIPPABLE_DIMENSIONS = [
95
+ "mcp",
96
+ "extensions",
97
+ ];
98
+ const SKIPPABLE_DIMENSION_SET = new Set(SKIPPABLE_DIMENSIONS);
99
+ export function isSkippableDimension(value) {
100
+ return SKIPPABLE_DIMENSION_SET.has(value);
101
+ }
102
+ /** Reject a dimension `materializeProfile` would not actually skip, rather than ignoring it. */
103
+ function assertSkippableDimensions(skip) {
104
+ if (!skip)
105
+ return [];
106
+ const unsupportedSkips = skip.filter((dimension) => !isSkippableDimension(dimension));
107
+ if (unsupportedSkips.length > 0) {
108
+ throw new Error(`materializeProfile: cannot skip ${unsupportedSkips.join(", ")}. ` +
109
+ `Skippable dimensions are: ${SKIPPABLE_DIMENSIONS.join(", ")}.`);
110
+ }
111
+ return skip;
112
+ }
77
113
  /** The plan was applied, but one or more private staging files remain. */
78
114
  export class WorkspacePlanCleanupError extends AggregateError {
79
115
  receipt;
@@ -187,6 +223,7 @@ const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/;
187
223
  const collapse = (s) => s.replace(/\s+/g, " ").trim();
188
224
  const compareStrings = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
189
225
  const DEFAULT_FILE_MODE = 0o644;
226
+ const PRIVATE_FILE_MODE = 0o600;
190
227
  const EXECUTABLE_FILE_MODE = 0o755;
191
228
  const DEFAULT_RESOURCE_FETCH_TIMEOUT_MS = 30_000;
192
229
  const MAX_RESOURCE_BYTES = 10 * 1024 * 1024;
@@ -437,6 +474,96 @@ export function normalizeSkillMd(name, raw) {
437
474
  return `---\nname: ${name}\ndescription: ${JSON.stringify(desc)}\n---\n\n${body}\n`;
438
475
  }
439
476
  const tomlStr = (s) => JSON.stringify(s); // valid TOML basic string
477
+ function isProfilePublicConfigValue(value) {
478
+ return (typeof value === "object" &&
479
+ value !== null &&
480
+ !Array.isArray(value) &&
481
+ value.kind === "public" &&
482
+ typeof value.value === "string");
483
+ }
484
+ function isProfileSecretRef(value) {
485
+ return (typeof value === "object" &&
486
+ value !== null &&
487
+ !Array.isArray(value) &&
488
+ value.kind === "secret-ref" &&
489
+ typeof value.key === "string");
490
+ }
491
+ function workspacePlanConfigValue(value) {
492
+ return isProfilePublicConfigValue(value) ? value.value : { ...value };
493
+ }
494
+ function serializeWorkspacePlanJson(value) {
495
+ const secretSlots = [];
496
+ const lower = (current, path, arrayEntry = false) => {
497
+ if (isProfilePublicConfigValue(current))
498
+ return current.value;
499
+ if (isProfileSecretRef(current)) {
500
+ secretSlots.push({ path: [...path], ref: { ...current } });
501
+ return null;
502
+ }
503
+ if (Array.isArray(current)) {
504
+ return current.map((entry, index) => lower(entry, [...path, index], true));
505
+ }
506
+ if (current && typeof current === "object") {
507
+ const output = Object.create(null);
508
+ for (const [key, entry] of Object.entries(current)) {
509
+ const lowered = lower(entry, [...path, key]);
510
+ if (lowered !== undefined) {
511
+ Object.defineProperty(output, key, {
512
+ value: lowered,
513
+ enumerable: true,
514
+ configurable: true,
515
+ writable: true,
516
+ });
517
+ }
518
+ }
519
+ return output;
520
+ }
521
+ return current === undefined && arrayEntry ? null : current;
522
+ };
523
+ const content = JSON.stringify(lower(value, []), null, 2);
524
+ if (content === undefined) {
525
+ throw new Error("Generated profile JSON must have a serializable root");
526
+ }
527
+ return {
528
+ content,
529
+ ...(secretSlots.length === 0 ? {} : { secretSlots }),
530
+ };
531
+ }
532
+ function workspacePlanArgumentSegments(value) {
533
+ return typeof value === "string" ? [value] : [...value.segments];
534
+ }
535
+ function concatWorkspacePlanArguments(...values) {
536
+ const segments = values.flatMap(workspacePlanArgumentSegments);
537
+ if (segments.every((segment) => typeof segment === "string")) {
538
+ return segments.join("");
539
+ }
540
+ return { kind: "workspace-plan-argument-template", segments };
541
+ }
542
+ function joinWorkspacePlanArguments(values, separator) {
543
+ if (values.length === 0)
544
+ return "";
545
+ const joined = [];
546
+ for (const [index, value] of values.entries()) {
547
+ if (index > 0)
548
+ joined.push(separator);
549
+ joined.push(value);
550
+ }
551
+ return concatWorkspacePlanArguments(...joined);
552
+ }
553
+ function tomlProfileConfigValue(value) {
554
+ if (isProfilePublicConfigValue(value))
555
+ return tomlStr(value.value);
556
+ return {
557
+ kind: "workspace-plan-argument-template",
558
+ segments: [
559
+ {
560
+ kind: "secret",
561
+ ref: { ...value },
562
+ encoding: "json-string",
563
+ },
564
+ ],
565
+ };
566
+ }
440
567
  // ─── The materializer ─────────────────────────────────────────────────────────
441
568
  /**
442
569
  * Build the WorkspacePlan for `profile` on `harness`. Pure — no IO. The caller
@@ -447,13 +574,14 @@ export function materializeProfile(inputProfile, harness, opts = {}) {
447
574
  assertMaterializerHarness(harness);
448
575
  const profile = agentProfileSchema.parse(inputProfile);
449
576
  const h = harness;
450
- const skip = new Set(opts.skip ?? []);
577
+ const skip = new Set(assertSkippableDimensions(opts.skip));
451
578
  const plan = {
452
579
  harness: h,
453
580
  files: [],
454
581
  env: {},
455
582
  flags: [],
456
583
  unsupported: [],
584
+ requires: [],
457
585
  };
458
586
  // Every plan file is tagged by provenance at the single push site: `add`
459
587
  // (harness-generated native config) vs `addExplicitResource` (a profile
@@ -462,13 +590,27 @@ export function materializeProfile(inputProfile, harness, opts = {}) {
462
590
  // the synthetic native config) must gate on `source === "generated"` —
463
591
  // otherwise a user resource mounted at the same conventional path is
464
592
  // silently misread as trusted harness config.
465
- const pushPlanFile = (relPath, content, source, mode) => {
593
+ const pushPlanFile = (relPath, serialized, source, mode) => {
466
594
  const normalizedPath = normalizeWorkspaceRelativePath(relPath);
595
+ const content = typeof serialized === "string" ? serialized : serialized.content;
596
+ const secretSlots = typeof serialized === "string" ? undefined : serialized.secretSlots;
597
+ const fileMode = mode ??
598
+ (secretSlots !== undefined && secretSlots.length > 0
599
+ ? PRIVATE_FILE_MODE
600
+ : undefined);
467
601
  plan.files.push({
468
602
  relPath: normalizedPath,
469
603
  content,
470
604
  source,
471
- ...(mode === undefined ? {} : { mode: validateFileMode(mode) }),
605
+ ...(fileMode === undefined ? {} : { mode: validateFileMode(fileMode) }),
606
+ ...(secretSlots === undefined
607
+ ? {}
608
+ : {
609
+ secretSlots: secretSlots.map((slot) => ({
610
+ path: [...slot.path],
611
+ ref: { ...slot.ref },
612
+ })),
613
+ }),
472
614
  });
473
615
  };
474
616
  const add = (relPath, content, mode) => pushPlanFile(relPath, content, "generated", mode);
@@ -572,10 +714,10 @@ export function materializeProfile(inputProfile, harness, opts = {}) {
572
714
  const instructionPath = CONTEXT_FILE[h];
573
715
  add(instructionPath, `${instr.join("\n\n")}\n`);
574
716
  if (h === "opencode") {
575
- add("opencode.json", JSON.stringify({
717
+ add("opencode.json", serializeWorkspacePlanJson({
576
718
  $schema: "https://opencode.ai/config.json",
577
719
  instructions: [instructionPath],
578
- }, null, 2));
720
+ }));
579
721
  }
580
722
  // gemini reads AGENTS.md only via settings; we write GEMINI.md so no extra step.
581
723
  }
@@ -690,7 +832,7 @@ export function materializeCandidateProfile(profile, harness, opts = {}) {
690
832
  harness: classicPlan.harness,
691
833
  files: classicPlan.files,
692
834
  env: candidatePublicRecord(classicPlan.env),
693
- flags: classicPlan.flags.map(candidatePublicValue),
835
+ flags: classicPlan.flags.map(candidatePublicArgument),
694
836
  ...(classicPlan.systemPrompt === undefined
695
837
  ? {}
696
838
  : { systemPrompt: candidatePublicValue(classicPlan.systemPrompt) }),
@@ -781,13 +923,15 @@ function candidateMcpForClassicMaterializer(server) {
781
923
  ...(server.transport === undefined ? {} : { transport: server.transport }),
782
924
  ...(server.args === undefined
783
925
  ? {}
784
- : { args: server.args.map(candidatePublicString) }),
926
+ : {
927
+ args: server.args.map((value) => profilePublicValue(candidatePublicString(value))),
928
+ }),
785
929
  ...(server.env === undefined
786
930
  ? {}
787
931
  : {
788
932
  env: Object.fromEntries(Object.entries(server.env).map(([name, value]) => [
789
933
  name,
790
- candidatePublicString(value),
934
+ profilePublicValue(candidatePublicString(value)),
791
935
  ])),
792
936
  }),
793
937
  ...(server.cwd === undefined ? {} : { cwd: server.cwd }),
@@ -844,11 +988,22 @@ function candidatePublicString(value) {
844
988
  function candidatePublicValue(value) {
845
989
  return { kind: "public", value };
846
990
  }
991
+ function profilePublicValue(value) {
992
+ return { kind: "public", value };
993
+ }
994
+ function candidatePublicArgument(value) {
995
+ if (typeof value !== "string") {
996
+ throw new Error("Candidate materialization cannot emit private launch arguments");
997
+ }
998
+ return candidatePublicValue(value);
999
+ }
847
1000
  function candidatePublicRecord(values) {
848
- return Object.fromEntries(Object.entries(values).map(([name, value]) => [
849
- name,
850
- candidatePublicValue(value),
851
- ]));
1001
+ return Object.fromEntries(Object.entries(values).map(([name, value]) => {
1002
+ if (typeof value !== "string") {
1003
+ throw new Error("Candidate materialization cannot emit private environment values");
1004
+ }
1005
+ return [name, candidatePublicValue(value)];
1006
+ }));
852
1007
  }
853
1008
  function verifyCandidateInlineResources(profile) {
854
1009
  const resources = profile.resources;
@@ -894,13 +1049,23 @@ function mergeJsonFiles(files) {
894
1049
  }
895
1050
  if (f.relPath.endsWith(".json")) {
896
1051
  try {
1052
+ const next = JSON.parse(f.content);
897
1053
  const merged = {
898
1054
  ...JSON.parse(prev.content),
899
- ...JSON.parse(f.content),
1055
+ ...next,
900
1056
  };
1057
+ const replacedRoots = new Set(Object.keys(next));
1058
+ const secretSlots = [
1059
+ ...(prev.secretSlots ?? []).filter((slot) => typeof slot.path[0] === "string" &&
1060
+ !replacedRoots.has(slot.path[0])),
1061
+ ...(f.secretSlots ?? []),
1062
+ ];
1063
+ const { secretSlots: _previousSecretSlots, ...previousFile } = prev;
901
1064
  byPath.set(f.relPath, {
902
- ...prev,
1065
+ ...previousFile,
903
1066
  content: JSON.stringify(merged, null, 2),
1067
+ ...(secretSlots.length > 0 ? { mode: PRIVATE_FILE_MODE } : {}),
1068
+ ...(secretSlots.length === 0 ? {} : { secretSlots }),
904
1069
  });
905
1070
  continue;
906
1071
  }
@@ -912,6 +1077,60 @@ function mergeJsonFiles(files) {
912
1077
  }
913
1078
  return [...byPath.values()];
914
1079
  }
1080
+ function valueAtJsonPath(root, path) {
1081
+ let current = root;
1082
+ for (const segment of path) {
1083
+ if (current === null ||
1084
+ typeof current !== "object" ||
1085
+ !Object.hasOwn(current, segment)) {
1086
+ return undefined;
1087
+ }
1088
+ current = current[segment];
1089
+ }
1090
+ return current;
1091
+ }
1092
+ function normalizePlanFileSecretSlots(file) {
1093
+ if (file.secretSlots === undefined)
1094
+ return [];
1095
+ if (file.source !== "generated" ||
1096
+ !file.relPath.endsWith(".json") ||
1097
+ file.secretSlots.length === 0) {
1098
+ throw new Error(`Workspace secret slots require a generated JSON file: ${file.relPath}`);
1099
+ }
1100
+ let parsed;
1101
+ try {
1102
+ parsed = JSON.parse(file.content);
1103
+ }
1104
+ catch {
1105
+ throw new Error(`Workspace secret slots require valid JSON content: ${file.relPath}`);
1106
+ }
1107
+ const seen = new Set();
1108
+ return file.secretSlots.map((slot) => {
1109
+ if (!Array.isArray(slot.path) || slot.path.length === 0) {
1110
+ throw new Error(`Workspace secret slot path must be non-empty: ${file.relPath}`);
1111
+ }
1112
+ const path = slot.path.map((segment) => {
1113
+ if ((typeof segment !== "string" && typeof segment !== "number") ||
1114
+ (typeof segment === "number" &&
1115
+ (!Number.isSafeInteger(segment) || segment < 0))) {
1116
+ throw new Error(`Workspace secret slot path is invalid: ${file.relPath}`);
1117
+ }
1118
+ return segment;
1119
+ });
1120
+ const identity = JSON.stringify(path);
1121
+ if (seen.has(identity)) {
1122
+ throw new Error(`Duplicate workspace secret slot path: ${file.relPath} ${identity}`);
1123
+ }
1124
+ seen.add(identity);
1125
+ if (valueAtJsonPath(parsed, path) !== null) {
1126
+ throw new Error(`Workspace secret slot must point at a null placeholder: ${file.relPath} ${identity}`);
1127
+ }
1128
+ return {
1129
+ path,
1130
+ ref: agentProfileSecretRefSchema.parse(slot.ref),
1131
+ };
1132
+ });
1133
+ }
915
1134
  function normalizePlanFiles(files) {
916
1135
  const seen = new Set();
917
1136
  return files
@@ -924,15 +1143,56 @@ function normalizePlanFiles(files) {
924
1143
  if (typeof file.content !== "string") {
925
1144
  throw new Error(`Workspace file content must be a string: ${relPath}`);
926
1145
  }
1146
+ const mode = validateFileMode(file.mode);
1147
+ const secretSlots = normalizePlanFileSecretSlots(file);
1148
+ if (secretSlots.length > 0 && (mode & 0o077) !== 0) {
1149
+ throw new Error(`Workspace secret-bearing file must not grant group or other access: ${relPath}`);
1150
+ }
927
1151
  return {
928
1152
  relPath,
929
1153
  content: file.content,
930
- mode: validateFileMode(file.mode),
1154
+ mode,
1155
+ secretSlots,
931
1156
  };
932
1157
  })
933
1158
  .sort((a, b) => compareStrings(a.relPath, b.relPath));
934
1159
  }
935
1160
  const sha256 = (value) => createHash("sha256").update(value, "utf8").digest("hex");
1161
+ function normalizeWorkspacePlanArgument(value) {
1162
+ if (typeof value === "string")
1163
+ return value;
1164
+ if (value.kind !== "workspace-plan-argument-template" ||
1165
+ !Array.isArray(value.segments) ||
1166
+ value.segments.length === 0) {
1167
+ throw new Error("Workspace plan argument template is malformed");
1168
+ }
1169
+ const segments = [];
1170
+ for (const segment of value.segments) {
1171
+ if (typeof segment === "string") {
1172
+ const previous = segments.at(-1);
1173
+ if (typeof previous === "string") {
1174
+ segments[segments.length - 1] = previous + segment;
1175
+ }
1176
+ else if (segment.length > 0) {
1177
+ segments.push(segment);
1178
+ }
1179
+ continue;
1180
+ }
1181
+ if (segment.kind !== "secret" ||
1182
+ (segment.encoding !== "raw" && segment.encoding !== "json-string")) {
1183
+ throw new Error("Workspace plan secret argument segment is malformed");
1184
+ }
1185
+ segments.push({
1186
+ kind: "secret",
1187
+ ref: agentProfileSecretRefSchema.parse(segment.ref),
1188
+ encoding: segment.encoding,
1189
+ });
1190
+ }
1191
+ if (segments.every((segment) => typeof segment === "string")) {
1192
+ return segments.join("");
1193
+ }
1194
+ return { kind: "workspace-plan-argument-template", segments };
1195
+ }
936
1196
  /**
937
1197
  * Hash the exact files, modes, flags, environment, and unsupported dimensions
938
1198
  * represented by a plan. Array order is preserved only where it changes spawn
@@ -943,18 +1203,34 @@ export function hashWorkspacePlan(plan) {
943
1203
  relPath: file.relPath,
944
1204
  mode: file.mode,
945
1205
  contentSha256: sha256(file.content),
1206
+ ...(file.secretSlots.length === 0
1207
+ ? {}
1208
+ : {
1209
+ secretSlots: [...file.secretSlots].sort((left, right) => compareStrings(JSON.stringify(left.path), JSON.stringify(right.path))),
1210
+ }),
946
1211
  }));
947
- const env = Object.fromEntries(Object.entries(plan.env).sort(([a], [b]) => compareStrings(a, b)));
1212
+ const env = Object.fromEntries(Object.entries(plan.env)
1213
+ .sort(([a], [b]) => compareStrings(a, b))
1214
+ .map(([name, value]) => [
1215
+ name,
1216
+ typeof value === "string"
1217
+ ? value
1218
+ : agentProfileSecretRefSchema.parse(value),
1219
+ ]));
948
1220
  const unsupported = [...plan.unsupported].sort((a, b) => compareStrings(`${a.dimension}\0${a.reason}`, `${b.dimension}\0${b.reason}`));
1221
+ // A plan that needs an extension is not the same plan as one that does not, even when every
1222
+ // byte it writes is identical — the digest is what a receipt proves, so it must say so.
1223
+ const requires = [...(plan.requires ?? [])].sort((a, b) => compareStrings(`${a.kind}\0${a.id}`, `${b.kind}\0${b.id}`));
949
1224
  const canonical = JSON.stringify({
950
1225
  harness: plan.harness,
951
1226
  files,
952
1227
  env,
953
- flags: [...plan.flags],
1228
+ flags: plan.flags.map(normalizeWorkspacePlanArgument),
954
1229
  ...(plan.systemPrompt === undefined
955
1230
  ? {}
956
1231
  : { systemPrompt: plan.systemPrompt }),
957
1232
  unsupported,
1233
+ ...(requires.length === 0 ? {} : { requires }),
958
1234
  });
959
1235
  return `sha256:${sha256(canonical)}`;
960
1236
  }
@@ -1053,19 +1329,186 @@ export function applyAgentCandidateWorkspacePlan(plan, workspaceDir, targetWorks
1053
1329
  };
1054
1330
  }
1055
1331
  /**
1056
- * Apply a WorkspacePlan to a real workspace dir: write every file (creating parent
1057
- * dirs), and return the env + flags the caller must hand the spawn. This is the IO
1058
- * consumer of the pure `materializeProfile` — used by the cli-bridge host spawn, the
1059
- * agent-dev-container box, and VB. Returns `unsupported` so the caller can log/fail.
1332
+ * Apply a public-only WorkspacePlan. A plan with any secret reference fails
1333
+ * before the first write; private executors use
1334
+ * {@link applyWorkspacePlanWithSecretProvider} instead.
1060
1335
  */
1061
1336
  export function applyWorkspacePlan(plan, workspaceDir, options = {}) {
1337
+ return applyResolvedWorkspacePlan(plan, resolvePublicWorkspacePlan(plan), workspaceDir, options).receipt;
1338
+ }
1339
+ /**
1340
+ * Resolve opaque profile references through an executor-owned provider, then
1341
+ * apply the resulting private native files and launch inputs. Resolution
1342
+ * completes before any workspace write, and resolved values never enter the
1343
+ * public plan, digest, receipt, logs, or error messages.
1344
+ */
1345
+ export async function applyWorkspacePlanWithSecretProvider(plan, workspaceDir, secretProvider, options = {}) {
1346
+ const planDigest = hashWorkspacePlan(plan);
1347
+ const resolved = await resolvePrivateWorkspacePlan(plan, secretProvider);
1348
+ return applyResolvedWorkspacePlan(plan, resolved, workspaceDir, options, planDigest);
1349
+ }
1350
+ function secretRefLabel(ref) {
1351
+ return JSON.stringify(ref.key);
1352
+ }
1353
+ function requireResolvedSecretValue(ref, value) {
1354
+ if (typeof value !== "string" || value.trim().length === 0) {
1355
+ throw new Error(`AgentProfile secret provider returned no value for ${secretRefLabel(ref)}`);
1356
+ }
1357
+ return value;
1358
+ }
1359
+ function setValueAtJsonPath(root, path, value) {
1360
+ let current = root;
1361
+ for (const segment of path.slice(0, -1)) {
1362
+ if (current === null ||
1363
+ typeof current !== "object" ||
1364
+ !Object.hasOwn(current, segment)) {
1365
+ throw new Error("Generated profile JSON secret path no longer exists");
1366
+ }
1367
+ current = current[segment];
1368
+ }
1369
+ const finalSegment = path.at(-1);
1370
+ if (finalSegment === undefined ||
1371
+ current === null ||
1372
+ typeof current !== "object" ||
1373
+ !Object.hasOwn(current, finalSegment)) {
1374
+ throw new Error("Generated profile JSON secret path no longer exists");
1375
+ }
1376
+ Object.defineProperty(current, finalSegment, {
1377
+ value,
1378
+ enumerable: true,
1379
+ configurable: true,
1380
+ writable: true,
1381
+ });
1382
+ }
1383
+ function resolvePublicWorkspacePlan(plan) {
1384
+ const files = normalizePlanFiles(plan.files);
1385
+ for (const file of files) {
1386
+ const ref = file.secretSlots[0]?.ref;
1387
+ if (ref) {
1388
+ throw new Error(`Workspace plan requires an AgentProfileSecretProvider for ${secretRefLabel(ref)}`);
1389
+ }
1390
+ }
1391
+ const env = Object.fromEntries(Object.entries(plan.env).map(([name, value]) => {
1392
+ if (typeof value !== "string") {
1393
+ throw new Error(`Workspace plan requires an AgentProfileSecretProvider for ${secretRefLabel(value)}`);
1394
+ }
1395
+ return [name, value];
1396
+ }));
1397
+ const flags = plan.flags.map((argument) => {
1398
+ const normalized = normalizeWorkspacePlanArgument(argument);
1399
+ if (typeof normalized !== "string") {
1400
+ const segment = normalized.segments.find((value) => typeof value !== "string");
1401
+ if (!segment) {
1402
+ throw new Error("Workspace plan argument template is malformed");
1403
+ }
1404
+ throw new Error(`Workspace plan requires an AgentProfileSecretProvider for ${secretRefLabel(segment.ref)}`);
1405
+ }
1406
+ return normalized;
1407
+ });
1408
+ return {
1409
+ files,
1410
+ env,
1411
+ flags,
1412
+ ...(plan.systemPrompt === undefined
1413
+ ? {}
1414
+ : { systemPrompt: plan.systemPrompt }),
1415
+ };
1416
+ }
1417
+ async function resolvePrivateWorkspacePlan(plan, secretProvider) {
1418
+ if (!secretProvider || typeof secretProvider.get !== "function") {
1419
+ throw new Error("AgentProfileSecretProvider.get is required");
1420
+ }
1421
+ const pending = new Map();
1422
+ const resolveRef = async (input) => {
1423
+ const ref = agentProfileSecretRefSchema.parse(input);
1424
+ let raw = pending.get(ref.key);
1425
+ if (!raw) {
1426
+ raw = (async () => {
1427
+ let value;
1428
+ try {
1429
+ value = await secretProvider.get(ref.key);
1430
+ }
1431
+ catch {
1432
+ throw new Error(`AgentProfile secret provider failed for ${secretRefLabel(ref)}`);
1433
+ }
1434
+ return requireResolvedSecretValue(ref, value);
1435
+ })();
1436
+ pending.set(ref.key, raw);
1437
+ }
1438
+ const value = await raw;
1439
+ return ref.format === "bearer" ? `Bearer ${value}` : value;
1440
+ };
1441
+ const files = await Promise.all(normalizePlanFiles(plan.files).map(async (file) => {
1442
+ if (file.secretSlots.length === 0)
1443
+ return file;
1444
+ const parsed = JSON.parse(file.content);
1445
+ const values = await Promise.all(file.secretSlots.map(async (slot) => ({
1446
+ path: slot.path,
1447
+ value: await resolveRef(slot.ref),
1448
+ })));
1449
+ for (const resolved of values) {
1450
+ setValueAtJsonPath(parsed, resolved.path, resolved.value);
1451
+ }
1452
+ return {
1453
+ ...file,
1454
+ content: JSON.stringify(parsed, null, 2),
1455
+ secretSlots: [],
1456
+ };
1457
+ }));
1458
+ const env = Object.fromEntries(await Promise.all(Object.entries(plan.env).map(async ([name, value]) => [
1459
+ name,
1460
+ typeof value === "string" ? value : await resolveRef(value),
1461
+ ])));
1462
+ const flags = await Promise.all(plan.flags.map(async (argument) => {
1463
+ const normalized = normalizeWorkspacePlanArgument(argument);
1464
+ if (typeof normalized === "string")
1465
+ return normalized;
1466
+ const segments = await Promise.all(normalized.segments.map(async (segment) => {
1467
+ if (typeof segment === "string")
1468
+ return segment;
1469
+ const value = await resolveRef(segment.ref);
1470
+ return segment.encoding === "json-string"
1471
+ ? JSON.stringify(value)
1472
+ : value;
1473
+ }));
1474
+ return segments.join("");
1475
+ }));
1476
+ return {
1477
+ files,
1478
+ env,
1479
+ flags,
1480
+ ...(plan.systemPrompt === undefined
1481
+ ? {}
1482
+ : { systemPrompt: plan.systemPrompt }),
1483
+ };
1484
+ }
1485
+ function safeWorkspacePlanReceipt(plan, written, workspacePlanDigest = hashWorkspacePlan(plan)) {
1486
+ return {
1487
+ workspacePlanDigest,
1488
+ env: Object.fromEntries(Object.entries(plan.env).map(([name, value]) => [
1489
+ name,
1490
+ typeof value === "string" ? value : { ...value },
1491
+ ])),
1492
+ flags: plan.flags.map(normalizeWorkspacePlanArgument),
1493
+ ...(plan.systemPrompt === undefined
1494
+ ? {}
1495
+ : { systemPrompt: plan.systemPrompt }),
1496
+ unsupported: plan.unsupported.map((entry) => ({ ...entry })),
1497
+ written: [...written],
1498
+ };
1499
+ }
1500
+ function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDir, options, publicPlanDigest = hashWorkspacePlan(publicPlan)) {
1062
1501
  const workspaceRoot = resolve(workspaceDir);
1063
1502
  const runtimeOnlyFiles = new Set((options.runtimeOnlyFiles ?? []).map(normalizeWorkspaceRelativePath));
1064
1503
  const existingFiles = options.existingFiles ?? "allow-identical";
1065
1504
  if (existingFiles !== "allow-identical" && existingFiles !== "reject") {
1066
1505
  throw new Error(`Unsupported existing workspace file policy: ${existingFiles}`);
1067
1506
  }
1068
- const files = normalizePlanFiles(plan.files)
1507
+ const resolvedFiles = resolvedPlan.files;
1508
+ const privateRuntimeFiles = Object.fromEntries(resolvedFiles
1509
+ .filter((file) => runtimeOnlyFiles.has(file.relPath))
1510
+ .map((file) => [file.relPath, file.content]));
1511
+ const files = resolvedFiles
1069
1512
  .filter((file) => !runtimeOnlyFiles.has(file.relPath))
1070
1513
  .map((file) => ({
1071
1514
  ...file,
@@ -1145,16 +1588,7 @@ export function applyWorkspacePlan(plan, workspaceDir, options = {}) {
1145
1588
  }
1146
1589
  throw error;
1147
1590
  }
1148
- const receipt = {
1149
- workspacePlanDigest: hashWorkspacePlan(plan),
1150
- env: plan.env,
1151
- flags: plan.flags,
1152
- ...(plan.systemPrompt === undefined
1153
- ? {}
1154
- : { systemPrompt: plan.systemPrompt }),
1155
- unsupported: plan.unsupported,
1156
- written: files.map((file) => file.relPath),
1157
- };
1591
+ const receipt = safeWorkspacePlanReceipt(publicPlan, files.map((file) => file.relPath), publicPlanDigest);
1158
1592
  const cleanupErrors = [];
1159
1593
  const temporaryPaths = [];
1160
1594
  for (const entry of prepared) {
@@ -1171,7 +1605,17 @@ export function applyWorkspacePlan(plan, workspaceDir, options = {}) {
1171
1605
  if (cleanupErrors.length > 0) {
1172
1606
  throw new WorkspacePlanCleanupError(cleanupErrors, receipt, temporaryPaths);
1173
1607
  }
1174
- return receipt;
1608
+ return {
1609
+ receipt,
1610
+ spawn: {
1611
+ env: { ...resolvedPlan.env },
1612
+ flags: [...resolvedPlan.flags],
1613
+ ...(resolvedPlan.systemPrompt === undefined
1614
+ ? {}
1615
+ : { systemPrompt: resolvedPlan.systemPrompt }),
1616
+ },
1617
+ runtimeOnlyFiles: privateRuntimeFiles,
1618
+ };
1175
1619
  }
1176
1620
  function workspaceFileExists(pathValue) {
1177
1621
  try {
@@ -1301,7 +1745,9 @@ function materializeExecutionControls(h, tools, permissions, modes, plan, add, u
1301
1745
  for (const [tool, enabled] of nativeTools) {
1302
1746
  rules[enabled ? "allow" : "deny"].push(tool);
1303
1747
  }
1304
- add(CLAUDE_SETTINGS_PATH, JSON.stringify({ permissions: sealClaudePermissionRules(rules) }, null, 2));
1748
+ add(CLAUDE_SETTINGS_PATH, serializeWorkspacePlanJson({
1749
+ permissions: sealClaudePermissionRules(rules),
1750
+ }));
1305
1751
  registerClaudeSettings(plan);
1306
1752
  }
1307
1753
  break;
@@ -1324,7 +1770,7 @@ function materializeExecutionControls(h, tools, permissions, modes, plan, add, u
1324
1770
  const nativeTools = mapSupportedBooleanEntries(enforceableTools, openCodeToolName, (name) => unsupported("tools", `opencode: no verified native tool mapping for "${name}"`), "opencode tool");
1325
1771
  const nativePermissions = mapSupportedPermissionEntries(enforceablePermissions, openCodePermissionName, (name) => unsupported("permissions", `opencode: no verified native permission mapping for "${name}"`), "opencode permission");
1326
1772
  if (enforceableTools.length > 0 || permissionEntries.length > 0) {
1327
- add("opencode.json", JSON.stringify({
1773
+ add("opencode.json", serializeWorkspacePlanJson({
1328
1774
  $schema: "https://opencode.ai/config.json",
1329
1775
  ...(nativeTools.length > 0
1330
1776
  ? { tools: Object.fromEntries(nativeTools) }
@@ -1332,7 +1778,7 @@ function materializeExecutionControls(h, tools, permissions, modes, plan, add, u
1332
1778
  ...(nativePermissions.length > 0
1333
1779
  ? { permission: Object.fromEntries(nativePermissions) }
1334
1780
  : {}),
1335
- }, null, 2));
1781
+ }));
1336
1782
  }
1337
1783
  break;
1338
1784
  }
@@ -1361,6 +1807,46 @@ function materializeExecutionControls(h, tools, permissions, modes, plan, add, u
1361
1807
  }
1362
1808
  break;
1363
1809
  }
1810
+ case "pi": {
1811
+ const nativeTools = mapSupportedBooleanEntries(toolEntries, piToolId, (name) => unsupported("tools", `pi: tool name "${name}" is empty after trimming`), "pi tool");
1812
+ const nativePermissions = mapSupportedPermissionEntries(permissionEntries, piToolId, (name) => unsupported("permissions", `pi: permission name "${name}" is empty after trimming`), "pi permission");
1813
+ const enabled = new Set(nativeTools
1814
+ .filter(([, value]) => value)
1815
+ .map(([name]) => name)
1816
+ .sort(compareStrings));
1817
+ const excluded = new Set(nativeTools
1818
+ .filter(([, value]) => !value)
1819
+ .map(([name]) => name)
1820
+ .sort(compareStrings));
1821
+ for (const [name, policy] of nativePermissions) {
1822
+ if (typeof policy !== "string") {
1823
+ unsupported("permissions", `pi: conditional permission policy for "${name}" has no verified headless mapping`);
1824
+ continue;
1825
+ }
1826
+ if (policy === "ask") {
1827
+ unsupported("permissions", `pi: permission policy "ask" for "${name}" requires an interactive decision surface`);
1828
+ continue;
1829
+ }
1830
+ if (policy === "allow") {
1831
+ if (excluded.has(name)) {
1832
+ throw new Error(`pi execution control "${name}" conflicts between disabled and allow`);
1833
+ }
1834
+ continue;
1835
+ }
1836
+ if (enabled.has(name)) {
1837
+ throw new Error(`pi execution control "${name}" conflicts between enabled and deny`);
1838
+ }
1839
+ excluded.add(name);
1840
+ }
1841
+ if (enabled.size > 0 || excluded.size > 0) {
1842
+ add(PI_EXECUTION_CONTROL_EXTENSION_PATH, piExecutionControlExtensionSource([...enabled], [...excluded]));
1843
+ plan.flags.push("--extension", PI_EXECUTION_CONTROL_EXTENSION_PATH);
1844
+ }
1845
+ if (excluded.size > 0) {
1846
+ plan.flags.push("--exclude-tools", [...excluded].sort(compareStrings).join(","));
1847
+ }
1848
+ break;
1849
+ }
1364
1850
  default:
1365
1851
  if (toolEntries.length > 0) {
1366
1852
  unsupported("tools", `${h}: no verified primary-process tool availability mapping`);
@@ -1373,6 +1859,56 @@ function materializeExecutionControls(h, tools, permissions, modes, plan, add, u
1373
1859
  unsupported("modes", `${h}: resolve one named AgentProfile mode before materialization`);
1374
1860
  }
1375
1861
  }
1862
+ function piExecutionControlExtensionSource(enabledTools, excludedTools) {
1863
+ return [
1864
+ `const enabledTools = Object.freeze(${JSON.stringify(enabledTools)});`,
1865
+ `const excludedTools = Object.freeze(${JSON.stringify(excludedTools)});`,
1866
+ "",
1867
+ "function failProfile(message) {",
1868
+ ' process.stderr.write("Pi profile execution controls failed: " + message + "\\n");',
1869
+ " process.exit(78);",
1870
+ "}",
1871
+ "",
1872
+ "function resolveTool(reference, available, required) {",
1873
+ " if (available.includes(reference)) return reference;",
1874
+ " const folded = reference.toLowerCase();",
1875
+ " const matches = available.filter((name) => name.toLowerCase() === folded);",
1876
+ " if (matches.length === 1) return matches[0];",
1877
+ " if (matches.length > 1) {",
1878
+ " failProfile('tool \"' + reference + '\" is ambiguous: ' + matches.join(\", \"));",
1879
+ " }",
1880
+ " if (required) {",
1881
+ " failProfile('requested tool \"' + reference + '\" is unavailable');",
1882
+ " }",
1883
+ " return undefined;",
1884
+ "}",
1885
+ "",
1886
+ "function applyControls(pi) {",
1887
+ " const available = pi.getAllTools().map((tool) => tool.name);",
1888
+ " for (const reference of excludedTools) {",
1889
+ " const remaining = resolveTool(reference, available, false);",
1890
+ " if (remaining !== undefined) {",
1891
+ " failProfile(",
1892
+ " 'excluded tool \"' + reference + '\" remains available as \"' + remaining +",
1893
+ " '\"; pass its exact registered Pi name',",
1894
+ " );",
1895
+ " }",
1896
+ " }",
1897
+ " const enabled = enabledTools.map((name) =>",
1898
+ " resolveTool(name, available, true),",
1899
+ " );",
1900
+ " const active = new Set(pi.getActiveTools());",
1901
+ " for (const name of enabled) active.add(name);",
1902
+ " pi.setActiveTools([...active]);",
1903
+ "}",
1904
+ "",
1905
+ "export default function applyProfileExecutionControls(pi) {",
1906
+ ' pi.on("before_agent_start", () => applyControls(pi));',
1907
+ ' pi.on("turn_start", () => applyControls(pi));',
1908
+ "}",
1909
+ "",
1910
+ ].join("\n");
1911
+ }
1376
1912
  function normalizedControlName(name) {
1377
1913
  return name.replace(/[^a-zA-Z0-9_]/g, "").toLowerCase();
1378
1914
  }
@@ -1457,6 +1993,24 @@ function openCodePermissionName(name) {
1457
1993
  ? normalized
1458
1994
  : undefined;
1459
1995
  }
1996
+ const PI_EXECUTION_CONTROL_EXTENSION_PATH = ".tangle/pi-execution-controls.ts";
1997
+ /**
1998
+ * Translate only portable spellings whose Pi equivalent differs.
1999
+ *
2000
+ * Every other exact name is resolved against Pi's live registry, keeping
2001
+ * extension and MCP tools profile-configurable without a static catalog.
2002
+ */
2003
+ function piToolId(name) {
2004
+ const trimmed = name.trim();
2005
+ if (trimmed.length === 0)
2006
+ return undefined;
2007
+ const portableName = trimmed.toLowerCase();
2008
+ if (portableName === "shell")
2009
+ return "bash";
2010
+ if (portableName === "glob")
2011
+ return "find";
2012
+ return trimmed;
2013
+ }
1460
2014
  function mapSupportedBooleanEntries(entries, mapName, onUnsupported, label) {
1461
2015
  return mapControlEntries(entries, mapName, onUnsupported, (left, right, nativeName) => {
1462
2016
  if (left !== right)
@@ -1562,7 +2116,7 @@ function materializeMcp(h, mcp, plan, add, unsupported) {
1562
2116
  };
1563
2117
  }
1564
2118
  const relPath = ".tangle/claude-mcp.json";
1565
- add(relPath, JSON.stringify({ mcpServers: servers }, null, 2));
2119
+ add(relPath, serializeWorkspacePlanJson({ mcpServers: servers }));
1566
2120
  plan.flags.push("--mcp-config", relPath, "--strict-mcp-config");
1567
2121
  break;
1568
2122
  }
@@ -1576,21 +2130,21 @@ function materializeMcp(h, mcp, plan, add, unsupported) {
1576
2130
  unsupported("mcp", `codex: remote MCP server "${name}" uses unsupported SSE transport`);
1577
2131
  continue;
1578
2132
  }
1579
- servers.push(`${tomlStr(name)} = ${codexMcpServerToml(name, server, plan)}`);
2133
+ servers.push(concatWorkspacePlanArguments(`${tomlStr(name)} = `, codexMcpServerToml(name, server, plan)));
1580
2134
  }
1581
2135
  if (servers.length > 0) {
1582
2136
  // Runtime overrides do not require persisted project trust. Header values
1583
2137
  // ride environment variables so credentials never appear in argv.
1584
- plan.flags.push("-c", `mcp_servers={ ${servers.join(", ")} }`);
2138
+ plan.flags.push("-c", concatWorkspacePlanArguments("mcp_servers={ ", joinWorkspacePlanArguments(servers, ", "), " }"));
1585
2139
  }
1586
2140
  break;
1587
2141
  }
1588
2142
  case "opencode":
1589
2143
  // opencode.json "mcp" block (local|remote).
1590
- add("opencode.json", JSON.stringify({
2144
+ add("opencode.json", serializeWorkspacePlanJson({
1591
2145
  $schema: "https://opencode.ai/config.json",
1592
2146
  mcp: openCodeMcpConfig(mcp),
1593
- }, null, 2));
2147
+ }));
1594
2148
  break;
1595
2149
  case "gemini": {
1596
2150
  const servers = {};
@@ -1609,7 +2163,7 @@ function materializeMcp(h, mcp, plan, add, unsupported) {
1609
2163
  ...(server.cwd ? { cwd: server.cwd } : {}),
1610
2164
  };
1611
2165
  }
1612
- add(".gemini/settings.json", JSON.stringify({ mcpServers: servers }, null, 2));
2166
+ add(".gemini/settings.json", serializeWorkspacePlanJson({ mcpServers: servers }));
1613
2167
  break;
1614
2168
  }
1615
2169
  case "kimi-code": {
@@ -1619,13 +2173,43 @@ function materializeMcp(h, mcp, plan, add, unsupported) {
1619
2173
  name,
1620
2174
  kimiMcpServer(server),
1621
2175
  ]));
1622
- add(rel, JSON.stringify({ mcpServers: servers }, null, 2));
2176
+ add(rel, serializeWorkspacePlanJson({ mcpServers: servers }));
1623
2177
  plan.flags.push("--mcp-config-file", rel);
1624
2178
  break;
1625
2179
  }
1626
- case "pi":
1627
- unsupported("mcp", "pi MCP requires a loaded MCP extension; the base Pi runtime has no MCP control");
2180
+ case "pi": {
2181
+ // pi ships NO MCP. It arrives from the `pi-mcp-adapter` extension, which discovers
2182
+ // `<cwd>/.pi/mcp.json` (adapter 2.17.0 config.ts:17) and also registers a `--mcp-config`
2183
+ // flag (index.ts:252). The flag is registered BY the extension, so passing it when the
2184
+ // extension is absent is not a fallback — it is an unknown flag.
2185
+ //
2186
+ // This was previously marked unsupported because a workspace file cannot install an
2187
+ // extension. That reasoning was right about the constraint and wrong about the conclusion:
2188
+ // the file half is perfectly materializable, and the half that is not becomes a declared
2189
+ // requirement the executor enforces. Marking the whole dimension unsupported pushed both
2190
+ // halves onto every consumer, and two of them independently rebuilt it.
2191
+ const rel = ".pi/mcp.json";
2192
+ const servers = Object.fromEntries(Object.entries(mcp).map(([name, server]) => [
2193
+ name,
2194
+ // `directTools` promotes this server's verbs to first-class pi tools. Without it the
2195
+ // agent gets one generic `mcp` tool and must connect, then describe, then call — three
2196
+ // round trips of discovery before it can do anything. Per-server, because the adapter
2197
+ // lets a per-server value override the global `settings.directTools` (direct-tools.ts:135).
2198
+ { ...piMcpServer(server), directTools: true },
2199
+ ]));
2200
+ // serializeWorkspacePlanJson, NOT JSON.stringify: it unwraps public config values to their
2201
+ // bytes and lifts any secret-ref into a tracked slot the applier resolves. Stringifying
2202
+ // straight would write `{"kind":"public","value":"..."}` into the file as an argv entry.
2203
+ add(rel, serializeWorkspacePlanJson({ mcpServers: servers }));
2204
+ plan.requires?.push({
2205
+ kind: "harness-extension",
2206
+ dimension: "mcp",
2207
+ id: "npm:pi-mcp-adapter",
2208
+ reason: "pi has no built-in MCP; without this extension the servers in .pi/mcp.json are never read and the agent starts with none of their tools",
2209
+ remedy: "pi install npm:pi-mcp-adapter",
2210
+ });
1628
2211
  break;
2212
+ }
1629
2213
  case "hermes":
1630
2214
  unsupported("mcp", "hermes MCP is global config.yaml (mcp_servers), no cwd discovery — write to the hermes global config or pass a config flag");
1631
2215
  break;
@@ -1662,7 +2246,7 @@ function codexMcpServerToml(name, server, plan) {
1662
2246
  }
1663
2247
  fields.push(`command = ${tomlStr(server.command)}`);
1664
2248
  if (server.args?.length) {
1665
- fields.push(`args = [${server.args.map(tomlStr).join(", ")}]`);
2249
+ fields.push(concatWorkspacePlanArguments("args = [", joinWorkspacePlanArguments(server.args.map(tomlProfileConfigValue), ", "), "]"));
1666
2250
  }
1667
2251
  if (server.cwd)
1668
2252
  fields.push(`cwd = ${tomlStr(server.cwd)}`);
@@ -1674,14 +2258,27 @@ function codexMcpServerToml(name, server, plan) {
1674
2258
  fields.push(`env_vars = [${envNames.map(tomlStr).join(", ")}]`);
1675
2259
  }
1676
2260
  }
1677
- return `{ ${fields.join(", ")} }`;
2261
+ return concatWorkspacePlanArguments("{ ", joinWorkspacePlanArguments(fields, ", "), " }");
1678
2262
  }
1679
2263
  function setPlanEnvironment(plan, name, value) {
2264
+ const normalized = typeof value === "string" ? value : workspacePlanConfigValue(value);
1680
2265
  const previous = plan.env[name];
1681
- if (previous !== undefined && previous !== value) {
2266
+ if (previous !== undefined &&
2267
+ JSON.stringify(previous) !== JSON.stringify(normalized)) {
1682
2268
  throw new Error(`profile environment values disagree for ${name}`);
1683
2269
  }
1684
- plan.env[name] = value;
2270
+ plan.env[name] = normalized;
2271
+ }
2272
+ /**
2273
+ * Lower one canonical MCP server to the shape `pi-mcp-adapter` reads from `.pi/mcp.json`.
2274
+ *
2275
+ * The adapter consumes the same `{mcpServers: {...}}` document Claude and Kimi use, so this
2276
+ * mirrors {@link kimiMcpServer} rather than inventing a pi dialect. `directTools` is added by the
2277
+ * caller, not here, because it is a policy about tool surfacing rather than a property of the
2278
+ * server definition.
2279
+ */
2280
+ function piMcpServer(server) {
2281
+ return kimiMcpServer(server);
1685
2282
  }
1686
2283
  function kimiMcpServer(server) {
1687
2284
  if (server.url) {
@@ -1746,14 +2343,14 @@ function materializeHooks(h, hooks, add, unsupported, plan) {
1746
2343
  hooks: [
1747
2344
  {
1748
2345
  type: "command",
1749
- command: materializedHookCommand(event, c, add),
2346
+ command: materializedHookCommand(event, c, add, plan),
1750
2347
  ...(c.timeoutMs ? { timeout: timeoutSeconds(c.timeoutMs) } : {}),
1751
2348
  ...(c.blocking === false ? { async: true } : {}),
1752
2349
  },
1753
2350
  ],
1754
2351
  }));
1755
2352
  }
1756
- add(CLAUDE_SETTINGS_PATH, JSON.stringify({ hooks: settingsHooks }, null, 2));
2353
+ add(CLAUDE_SETTINGS_PATH, serializeWorkspacePlanJson({ hooks: settingsHooks }));
1757
2354
  registerClaudeSettings(plan);
1758
2355
  break;
1759
2356
  }
@@ -1775,13 +2372,13 @@ function materializeHooks(h, hooks, add, unsupported, plan) {
1775
2372
  hooks: [
1776
2373
  {
1777
2374
  type: "command",
1778
- command: materializedHookCommand(event, c, add),
2375
+ command: materializedHookCommand(event, c, add, plan),
1779
2376
  ...(c.timeoutMs ? { timeout: c.timeoutMs } : {}),
1780
2377
  },
1781
2378
  ],
1782
2379
  }));
1783
2380
  }
1784
- add(".gemini/settings.json", JSON.stringify({ hooks: settingsHooks }, null, 2));
2381
+ add(".gemini/settings.json", serializeWorkspacePlanJson({ hooks: settingsHooks }));
1785
2382
  break;
1786
2383
  }
1787
2384
  case "codex": {
@@ -1797,7 +2394,7 @@ function materializeHooks(h, hooks, add, unsupported, plan) {
1797
2394
  const entries = cmds.map((command) => {
1798
2395
  const handler = [
1799
2396
  `type = ${tomlStr("command")}`,
1800
- `command = ${tomlStr(materializedHookCommand(event, command, add))}`,
2397
+ `command = ${tomlStr(materializedHookCommand(event, command, add, plan))}`,
1801
2398
  ...(command.timeoutMs
1802
2399
  ? [`timeout = ${timeoutSeconds(command.timeoutMs)}`]
1803
2400
  : []),
@@ -1906,7 +2503,7 @@ function hookEventSupported(harness, event) {
1906
2503
  function timeoutSeconds(timeoutMs) {
1907
2504
  return Math.max(1, Math.ceil(timeoutMs / 1000));
1908
2505
  }
1909
- function materializedHookCommand(event, command, add) {
2506
+ function materializedHookCommand(event, command, add, plan) {
1910
2507
  if (!command.env || Object.keys(command.env).length === 0) {
1911
2508
  return command.command;
1912
2509
  }
@@ -1916,9 +2513,50 @@ function materializedHookCommand(event, command, add) {
1916
2513
  env: Object.fromEntries(Object.entries(command.env).sort(([left], [right]) => compareStrings(left, right))),
1917
2514
  }).slice("sha256:".length);
1918
2515
  const path = `.tangle/profile-hooks/${wrapperId}.mjs`;
1919
- add(path, hookWrapperSource("/bin/sh", ["-c", command.command], command.env));
2516
+ const envSources = Object.fromEntries(Object.entries(command.env)
2517
+ .sort(([left], [right]) => compareStrings(left, right))
2518
+ .map(([name, value]) => {
2519
+ const sourceName = `TANGLE_PROFILE_HOOK_${sha256(`${wrapperId}\0${name}`)
2520
+ .slice(0, 24)
2521
+ .toUpperCase()}`;
2522
+ setPlanEnvironment(plan, sourceName, value);
2523
+ return [name, sourceName];
2524
+ }));
2525
+ add(path, hookWrapperSourceFromEnvironment("/bin/sh", ["-c", command.command], envSources));
1920
2526
  return `node ${path}`;
1921
2527
  }
2528
+ function hookWrapperSourceFromEnvironment(executable, args, envSources) {
2529
+ return [
2530
+ 'import { spawnSync } from "node:child_process"',
2531
+ `const executable = ${JSON.stringify(executable)}`,
2532
+ `const args = ${JSON.stringify(args)}`,
2533
+ `const envSources = ${JSON.stringify(envSources)}`,
2534
+ "const extraEnv = {}",
2535
+ "for (const [name, source] of Object.entries(envSources)) {",
2536
+ " const value = process.env[source]",
2537
+ " if (value === undefined) {",
2538
+ ' console.error("missing materialized hook environment " + source)',
2539
+ " process.exit(127)",
2540
+ " }",
2541
+ " extraEnv[name] = value",
2542
+ "}",
2543
+ "const result = spawnSync(executable, args, {",
2544
+ ' stdio: "inherit",',
2545
+ " env: { ...process.env, ...extraEnv },",
2546
+ " shell: false,",
2547
+ "})",
2548
+ "if (result.error) {",
2549
+ " console.error(result.error.message)",
2550
+ " process.exit(127)",
2551
+ "}",
2552
+ "if (result.signal) {",
2553
+ ' console.error("hook terminated by " + result.signal)',
2554
+ " process.exit(1)",
2555
+ "}",
2556
+ "process.exit(result.status ?? 1)",
2557
+ "",
2558
+ ].join("\n");
2559
+ }
1922
2560
  function hookWrapperSource(executable, args, env) {
1923
2561
  return [
1924
2562
  'import { spawnSync } from "node:child_process"',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/agent-profile-materialize",
3
- "version": "0.9.3",
3
+ "version": "0.10.0",
4
4
  "description": "Materialize an AgentProfile into the native per-harness workspace files (the one shared materializer for cli-bridge, agent-dev-container, and VB).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -20,13 +20,13 @@
20
20
  "dist"
21
21
  ],
22
22
  "devDependencies": {
23
- "@tangle-network/agent-interface": "0.36.0",
23
+ "@tangle-network/agent-interface": "0.38.0",
24
24
  "@types/node": "25.6.0",
25
25
  "typescript": "^6.0.3",
26
26
  "vitest": "^4.1.10"
27
27
  },
28
28
  "peerDependencies": {
29
- "@tangle-network/agent-interface": ">=0.36.0 <0.38.0"
29
+ "@tangle-network/agent-interface": ">=0.38.0 <0.41.0"
30
30
  },
31
31
  "scripts": {
32
32
  "build": "tsc -p tsconfig.json",