@tangle-network/agent-profile-materialize 0.9.3 → 0.9.4

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/dist/index.d.ts CHANGED
@@ -21,7 +21,8 @@
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;
25
26
  export declare const MATERIALIZER_HARNESSES: readonly ["claude-code", "nanoclaw", "codex", "opencode", "kimi-code", "pi", "gemini", "hermes", "openclaw"];
26
27
  export type HarnessId = (typeof MATERIALIZER_HARNESSES)[number];
27
28
  export declare function isMaterializerHarness(harness: string): harness is HarnessId;
@@ -50,7 +51,31 @@ export interface PlanFile {
50
51
  * this on every file it emits.
51
52
  */
52
53
  source?: "user" | "generated";
54
+ /**
55
+ * Secret leaves in a generated JSON document. `content` contains `null` at
56
+ * each path until the private executor resolves the named reference. The
57
+ * public plan, its digest, and its receipt retain only these references.
58
+ */
59
+ secretSlots?: PlanFileSecretSlot[];
60
+ }
61
+ export interface PlanFileSecretSlot {
62
+ path: Array<string | number>;
63
+ ref: AgentProfileSecretRef;
64
+ }
65
+ export interface WorkspacePlanSecretSegment {
66
+ kind: "secret";
67
+ ref: AgentProfileSecretRef;
68
+ /** Encoding applied after private resolution. */
69
+ encoding: "raw" | "json-string";
53
70
  }
71
+ /** A launch argument whose private leaves are resolved immediately before spawn. */
72
+ export interface WorkspacePlanArgumentTemplate {
73
+ kind: "workspace-plan-argument-template";
74
+ segments: Array<string | WorkspacePlanSecretSegment>;
75
+ }
76
+ export type WorkspacePlanArgument = string | WorkspacePlanArgumentTemplate;
77
+ /** Public bytes stay strings; secret references stay opaque until application. */
78
+ export type WorkspacePlanConfigValue = string | AgentProfileSecretRef;
54
79
  export interface Unsupported {
55
80
  dimension: "files" | "skills" | "mcp" | "hooks" | "subagents" | "commands" | "instructions" | "systemPrompt" | "connections" | "confidential" | "permissions" | "tools" | "modes" | "extensions";
56
81
  reason: string;
@@ -61,9 +86,9 @@ export interface WorkspacePlan {
61
86
  /** Files to write relative to the run workspace (cwd). */
62
87
  files: PlanFile[];
63
88
  /** Env vars to set on the spawned process (for harnesses needing them). */
64
- env: Record<string, string>;
89
+ env: Record<string, WorkspacePlanConfigValue>;
65
90
  /** Extra CLI flags to pass (e.g. kimi --mcp-config-file <path>). */
66
- flags: string[];
91
+ flags: WorkspacePlanArgument[];
67
92
  /** Exact system-prompt replacement supplied to the harness. */
68
93
  systemPrompt?: string;
69
94
  /** Dimensions requested but with NO cwd path on this harness — fail-closed record. */
@@ -76,12 +101,27 @@ export interface WorkspacePlan {
76
101
  */
77
102
  export interface WorkspacePlanReceipt {
78
103
  workspacePlanDigest: string;
79
- env: Record<string, string>;
80
- flags: string[];
104
+ /** Public launch identity only; secret values never enter this receipt. */
105
+ env: Record<string, WorkspacePlanConfigValue>;
106
+ /** Public launch identity only; templates retain opaque secret references. */
107
+ flags: WorkspacePlanArgument[];
81
108
  systemPrompt?: string;
82
109
  unsupported: Unsupported[];
83
110
  written: string[];
84
111
  }
112
+ /** Private values handed directly to the provider spawn, never to receipts. */
113
+ export interface WorkspacePlanSpawnInputs {
114
+ env: Record<string, string>;
115
+ flags: string[];
116
+ systemPrompt?: string;
117
+ }
118
+ /** Result of applying a public plan at the private executor boundary. */
119
+ export interface WorkspacePlanApplication {
120
+ receipt: WorkspacePlanReceipt;
121
+ spawn: WorkspacePlanSpawnInputs;
122
+ /** Resolved generated files intentionally consumed in memory by the runtime. */
123
+ runtimeOnlyFiles: Record<string, string>;
124
+ }
85
125
  /** The plan was applied, but one or more private staging files remain. */
86
126
  export declare class WorkspacePlanCleanupError extends AggregateError {
87
127
  readonly receipt: WorkspacePlanReceipt;
@@ -218,12 +258,18 @@ export declare function captureAgentCandidateProfilePlan(plan: AgentCandidateWor
218
258
  */
219
259
  export declare function applyAgentCandidateWorkspacePlan(plan: AgentCandidateWorkspacePlan, workspaceDir: string, targetWorkspace: AgentCandidateProfileApplication["targetWorkspace"]): AgentCandidateWorkspacePlanReceipt;
220
260
  /**
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.
261
+ * Apply a public-only WorkspacePlan. A plan with any secret reference fails
262
+ * before the first write; private executors use
263
+ * {@link applyWorkspacePlanWithSecretProvider} instead.
225
264
  */
226
265
  export declare function applyWorkspacePlan(plan: WorkspacePlan, workspaceDir: string, options?: ApplyWorkspacePlanOptions): WorkspacePlanReceipt;
266
+ /**
267
+ * Resolve opaque profile references through an executor-owned provider, then
268
+ * apply the resulting private native files and launch inputs. Resolution
269
+ * completes before any workspace write, and resolved values never enter the
270
+ * public plan, digest, receipt, logs, or error messages.
271
+ */
272
+ export declare function applyWorkspacePlanWithSecretProvider(plan: WorkspacePlan, workspaceDir: string, secretProvider: AgentProfileSecretProvider, options?: ApplyWorkspacePlanOptions): Promise<WorkspacePlanApplication>;
227
273
  /** Tool IDs reported by the OpenCode binary shipped in the Sandbox image. */
228
274
  export declare const OPENCODE_NATIVE_TOOL_IDS: readonly ["apply_patch", "bash", "codesearch", "edit", "glob", "grep", "question", "read", "skill", "task", "todowrite", "webfetch", "websearch", "write"];
229
275
  /** Resolve portable and legacy profile names to current OpenCode tool IDs. */
@@ -233,12 +279,12 @@ export type OpenCodeMcpConfig = Record<string, {
233
279
  type: "remote";
234
280
  url: string;
235
281
  enabled: boolean;
236
- headers?: Record<string, string>;
282
+ headers?: Record<string, AgentProfileConfigValue>;
237
283
  } | {
238
284
  type: "local";
239
- command: string[];
285
+ command: Array<string | AgentProfileConfigValue>;
240
286
  enabled: boolean;
241
- environment?: Record<string, string>;
287
+ environment?: Record<string, AgentProfileConfigValue>;
242
288
  }>;
243
289
  /** Lower canonical MCP definitions to OpenCode's native startup configuration. */
244
290
  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;
@@ -187,6 +192,7 @@ const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/;
187
192
  const collapse = (s) => s.replace(/\s+/g, " ").trim();
188
193
  const compareStrings = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
189
194
  const DEFAULT_FILE_MODE = 0o644;
195
+ const PRIVATE_FILE_MODE = 0o600;
190
196
  const EXECUTABLE_FILE_MODE = 0o755;
191
197
  const DEFAULT_RESOURCE_FETCH_TIMEOUT_MS = 30_000;
192
198
  const MAX_RESOURCE_BYTES = 10 * 1024 * 1024;
@@ -437,6 +443,96 @@ export function normalizeSkillMd(name, raw) {
437
443
  return `---\nname: ${name}\ndescription: ${JSON.stringify(desc)}\n---\n\n${body}\n`;
438
444
  }
439
445
  const tomlStr = (s) => JSON.stringify(s); // valid TOML basic string
446
+ function isProfilePublicConfigValue(value) {
447
+ return (typeof value === "object" &&
448
+ value !== null &&
449
+ !Array.isArray(value) &&
450
+ value.kind === "public" &&
451
+ typeof value.value === "string");
452
+ }
453
+ function isProfileSecretRef(value) {
454
+ return (typeof value === "object" &&
455
+ value !== null &&
456
+ !Array.isArray(value) &&
457
+ value.kind === "secret-ref" &&
458
+ typeof value.key === "string");
459
+ }
460
+ function workspacePlanConfigValue(value) {
461
+ return isProfilePublicConfigValue(value) ? value.value : { ...value };
462
+ }
463
+ function serializeWorkspacePlanJson(value) {
464
+ const secretSlots = [];
465
+ const lower = (current, path, arrayEntry = false) => {
466
+ if (isProfilePublicConfigValue(current))
467
+ return current.value;
468
+ if (isProfileSecretRef(current)) {
469
+ secretSlots.push({ path: [...path], ref: { ...current } });
470
+ return null;
471
+ }
472
+ if (Array.isArray(current)) {
473
+ return current.map((entry, index) => lower(entry, [...path, index], true));
474
+ }
475
+ if (current && typeof current === "object") {
476
+ const output = Object.create(null);
477
+ for (const [key, entry] of Object.entries(current)) {
478
+ const lowered = lower(entry, [...path, key]);
479
+ if (lowered !== undefined) {
480
+ Object.defineProperty(output, key, {
481
+ value: lowered,
482
+ enumerable: true,
483
+ configurable: true,
484
+ writable: true,
485
+ });
486
+ }
487
+ }
488
+ return output;
489
+ }
490
+ return current === undefined && arrayEntry ? null : current;
491
+ };
492
+ const content = JSON.stringify(lower(value, []), null, 2);
493
+ if (content === undefined) {
494
+ throw new Error("Generated profile JSON must have a serializable root");
495
+ }
496
+ return {
497
+ content,
498
+ ...(secretSlots.length === 0 ? {} : { secretSlots }),
499
+ };
500
+ }
501
+ function workspacePlanArgumentSegments(value) {
502
+ return typeof value === "string" ? [value] : [...value.segments];
503
+ }
504
+ function concatWorkspacePlanArguments(...values) {
505
+ const segments = values.flatMap(workspacePlanArgumentSegments);
506
+ if (segments.every((segment) => typeof segment === "string")) {
507
+ return segments.join("");
508
+ }
509
+ return { kind: "workspace-plan-argument-template", segments };
510
+ }
511
+ function joinWorkspacePlanArguments(values, separator) {
512
+ if (values.length === 0)
513
+ return "";
514
+ const joined = [];
515
+ for (const [index, value] of values.entries()) {
516
+ if (index > 0)
517
+ joined.push(separator);
518
+ joined.push(value);
519
+ }
520
+ return concatWorkspacePlanArguments(...joined);
521
+ }
522
+ function tomlProfileConfigValue(value) {
523
+ if (isProfilePublicConfigValue(value))
524
+ return tomlStr(value.value);
525
+ return {
526
+ kind: "workspace-plan-argument-template",
527
+ segments: [
528
+ {
529
+ kind: "secret",
530
+ ref: { ...value },
531
+ encoding: "json-string",
532
+ },
533
+ ],
534
+ };
535
+ }
440
536
  // ─── The materializer ─────────────────────────────────────────────────────────
441
537
  /**
442
538
  * Build the WorkspacePlan for `profile` on `harness`. Pure — no IO. The caller
@@ -462,13 +558,27 @@ export function materializeProfile(inputProfile, harness, opts = {}) {
462
558
  // the synthetic native config) must gate on `source === "generated"` —
463
559
  // otherwise a user resource mounted at the same conventional path is
464
560
  // silently misread as trusted harness config.
465
- const pushPlanFile = (relPath, content, source, mode) => {
561
+ const pushPlanFile = (relPath, serialized, source, mode) => {
466
562
  const normalizedPath = normalizeWorkspaceRelativePath(relPath);
563
+ const content = typeof serialized === "string" ? serialized : serialized.content;
564
+ const secretSlots = typeof serialized === "string" ? undefined : serialized.secretSlots;
565
+ const fileMode = mode ??
566
+ (secretSlots !== undefined && secretSlots.length > 0
567
+ ? PRIVATE_FILE_MODE
568
+ : undefined);
467
569
  plan.files.push({
468
570
  relPath: normalizedPath,
469
571
  content,
470
572
  source,
471
- ...(mode === undefined ? {} : { mode: validateFileMode(mode) }),
573
+ ...(fileMode === undefined ? {} : { mode: validateFileMode(fileMode) }),
574
+ ...(secretSlots === undefined
575
+ ? {}
576
+ : {
577
+ secretSlots: secretSlots.map((slot) => ({
578
+ path: [...slot.path],
579
+ ref: { ...slot.ref },
580
+ })),
581
+ }),
472
582
  });
473
583
  };
474
584
  const add = (relPath, content, mode) => pushPlanFile(relPath, content, "generated", mode);
@@ -572,10 +682,10 @@ export function materializeProfile(inputProfile, harness, opts = {}) {
572
682
  const instructionPath = CONTEXT_FILE[h];
573
683
  add(instructionPath, `${instr.join("\n\n")}\n`);
574
684
  if (h === "opencode") {
575
- add("opencode.json", JSON.stringify({
685
+ add("opencode.json", serializeWorkspacePlanJson({
576
686
  $schema: "https://opencode.ai/config.json",
577
687
  instructions: [instructionPath],
578
- }, null, 2));
688
+ }));
579
689
  }
580
690
  // gemini reads AGENTS.md only via settings; we write GEMINI.md so no extra step.
581
691
  }
@@ -690,7 +800,7 @@ export function materializeCandidateProfile(profile, harness, opts = {}) {
690
800
  harness: classicPlan.harness,
691
801
  files: classicPlan.files,
692
802
  env: candidatePublicRecord(classicPlan.env),
693
- flags: classicPlan.flags.map(candidatePublicValue),
803
+ flags: classicPlan.flags.map(candidatePublicArgument),
694
804
  ...(classicPlan.systemPrompt === undefined
695
805
  ? {}
696
806
  : { systemPrompt: candidatePublicValue(classicPlan.systemPrompt) }),
@@ -781,13 +891,15 @@ function candidateMcpForClassicMaterializer(server) {
781
891
  ...(server.transport === undefined ? {} : { transport: server.transport }),
782
892
  ...(server.args === undefined
783
893
  ? {}
784
- : { args: server.args.map(candidatePublicString) }),
894
+ : {
895
+ args: server.args.map((value) => profilePublicValue(candidatePublicString(value))),
896
+ }),
785
897
  ...(server.env === undefined
786
898
  ? {}
787
899
  : {
788
900
  env: Object.fromEntries(Object.entries(server.env).map(([name, value]) => [
789
901
  name,
790
- candidatePublicString(value),
902
+ profilePublicValue(candidatePublicString(value)),
791
903
  ])),
792
904
  }),
793
905
  ...(server.cwd === undefined ? {} : { cwd: server.cwd }),
@@ -844,11 +956,22 @@ function candidatePublicString(value) {
844
956
  function candidatePublicValue(value) {
845
957
  return { kind: "public", value };
846
958
  }
959
+ function profilePublicValue(value) {
960
+ return { kind: "public", value };
961
+ }
962
+ function candidatePublicArgument(value) {
963
+ if (typeof value !== "string") {
964
+ throw new Error("Candidate materialization cannot emit private launch arguments");
965
+ }
966
+ return candidatePublicValue(value);
967
+ }
847
968
  function candidatePublicRecord(values) {
848
- return Object.fromEntries(Object.entries(values).map(([name, value]) => [
849
- name,
850
- candidatePublicValue(value),
851
- ]));
969
+ return Object.fromEntries(Object.entries(values).map(([name, value]) => {
970
+ if (typeof value !== "string") {
971
+ throw new Error("Candidate materialization cannot emit private environment values");
972
+ }
973
+ return [name, candidatePublicValue(value)];
974
+ }));
852
975
  }
853
976
  function verifyCandidateInlineResources(profile) {
854
977
  const resources = profile.resources;
@@ -894,13 +1017,23 @@ function mergeJsonFiles(files) {
894
1017
  }
895
1018
  if (f.relPath.endsWith(".json")) {
896
1019
  try {
1020
+ const next = JSON.parse(f.content);
897
1021
  const merged = {
898
1022
  ...JSON.parse(prev.content),
899
- ...JSON.parse(f.content),
1023
+ ...next,
900
1024
  };
1025
+ const replacedRoots = new Set(Object.keys(next));
1026
+ const secretSlots = [
1027
+ ...(prev.secretSlots ?? []).filter((slot) => typeof slot.path[0] === "string" &&
1028
+ !replacedRoots.has(slot.path[0])),
1029
+ ...(f.secretSlots ?? []),
1030
+ ];
1031
+ const { secretSlots: _previousSecretSlots, ...previousFile } = prev;
901
1032
  byPath.set(f.relPath, {
902
- ...prev,
1033
+ ...previousFile,
903
1034
  content: JSON.stringify(merged, null, 2),
1035
+ ...(secretSlots.length > 0 ? { mode: PRIVATE_FILE_MODE } : {}),
1036
+ ...(secretSlots.length === 0 ? {} : { secretSlots }),
904
1037
  });
905
1038
  continue;
906
1039
  }
@@ -912,6 +1045,60 @@ function mergeJsonFiles(files) {
912
1045
  }
913
1046
  return [...byPath.values()];
914
1047
  }
1048
+ function valueAtJsonPath(root, path) {
1049
+ let current = root;
1050
+ for (const segment of path) {
1051
+ if (current === null ||
1052
+ typeof current !== "object" ||
1053
+ !Object.hasOwn(current, segment)) {
1054
+ return undefined;
1055
+ }
1056
+ current = current[segment];
1057
+ }
1058
+ return current;
1059
+ }
1060
+ function normalizePlanFileSecretSlots(file) {
1061
+ if (file.secretSlots === undefined)
1062
+ return [];
1063
+ if (file.source !== "generated" ||
1064
+ !file.relPath.endsWith(".json") ||
1065
+ file.secretSlots.length === 0) {
1066
+ throw new Error(`Workspace secret slots require a generated JSON file: ${file.relPath}`);
1067
+ }
1068
+ let parsed;
1069
+ try {
1070
+ parsed = JSON.parse(file.content);
1071
+ }
1072
+ catch {
1073
+ throw new Error(`Workspace secret slots require valid JSON content: ${file.relPath}`);
1074
+ }
1075
+ const seen = new Set();
1076
+ return file.secretSlots.map((slot) => {
1077
+ if (!Array.isArray(slot.path) || slot.path.length === 0) {
1078
+ throw new Error(`Workspace secret slot path must be non-empty: ${file.relPath}`);
1079
+ }
1080
+ const path = slot.path.map((segment) => {
1081
+ if ((typeof segment !== "string" && typeof segment !== "number") ||
1082
+ (typeof segment === "number" &&
1083
+ (!Number.isSafeInteger(segment) || segment < 0))) {
1084
+ throw new Error(`Workspace secret slot path is invalid: ${file.relPath}`);
1085
+ }
1086
+ return segment;
1087
+ });
1088
+ const identity = JSON.stringify(path);
1089
+ if (seen.has(identity)) {
1090
+ throw new Error(`Duplicate workspace secret slot path: ${file.relPath} ${identity}`);
1091
+ }
1092
+ seen.add(identity);
1093
+ if (valueAtJsonPath(parsed, path) !== null) {
1094
+ throw new Error(`Workspace secret slot must point at a null placeholder: ${file.relPath} ${identity}`);
1095
+ }
1096
+ return {
1097
+ path,
1098
+ ref: agentProfileSecretRefSchema.parse(slot.ref),
1099
+ };
1100
+ });
1101
+ }
915
1102
  function normalizePlanFiles(files) {
916
1103
  const seen = new Set();
917
1104
  return files
@@ -924,15 +1111,56 @@ function normalizePlanFiles(files) {
924
1111
  if (typeof file.content !== "string") {
925
1112
  throw new Error(`Workspace file content must be a string: ${relPath}`);
926
1113
  }
1114
+ const mode = validateFileMode(file.mode);
1115
+ const secretSlots = normalizePlanFileSecretSlots(file);
1116
+ if (secretSlots.length > 0 && (mode & 0o077) !== 0) {
1117
+ throw new Error(`Workspace secret-bearing file must not grant group or other access: ${relPath}`);
1118
+ }
927
1119
  return {
928
1120
  relPath,
929
1121
  content: file.content,
930
- mode: validateFileMode(file.mode),
1122
+ mode,
1123
+ secretSlots,
931
1124
  };
932
1125
  })
933
1126
  .sort((a, b) => compareStrings(a.relPath, b.relPath));
934
1127
  }
935
1128
  const sha256 = (value) => createHash("sha256").update(value, "utf8").digest("hex");
1129
+ function normalizeWorkspacePlanArgument(value) {
1130
+ if (typeof value === "string")
1131
+ return value;
1132
+ if (value.kind !== "workspace-plan-argument-template" ||
1133
+ !Array.isArray(value.segments) ||
1134
+ value.segments.length === 0) {
1135
+ throw new Error("Workspace plan argument template is malformed");
1136
+ }
1137
+ const segments = [];
1138
+ for (const segment of value.segments) {
1139
+ if (typeof segment === "string") {
1140
+ const previous = segments.at(-1);
1141
+ if (typeof previous === "string") {
1142
+ segments[segments.length - 1] = previous + segment;
1143
+ }
1144
+ else if (segment.length > 0) {
1145
+ segments.push(segment);
1146
+ }
1147
+ continue;
1148
+ }
1149
+ if (segment.kind !== "secret" ||
1150
+ (segment.encoding !== "raw" && segment.encoding !== "json-string")) {
1151
+ throw new Error("Workspace plan secret argument segment is malformed");
1152
+ }
1153
+ segments.push({
1154
+ kind: "secret",
1155
+ ref: agentProfileSecretRefSchema.parse(segment.ref),
1156
+ encoding: segment.encoding,
1157
+ });
1158
+ }
1159
+ if (segments.every((segment) => typeof segment === "string")) {
1160
+ return segments.join("");
1161
+ }
1162
+ return { kind: "workspace-plan-argument-template", segments };
1163
+ }
936
1164
  /**
937
1165
  * Hash the exact files, modes, flags, environment, and unsupported dimensions
938
1166
  * represented by a plan. Array order is preserved only where it changes spawn
@@ -943,14 +1171,26 @@ export function hashWorkspacePlan(plan) {
943
1171
  relPath: file.relPath,
944
1172
  mode: file.mode,
945
1173
  contentSha256: sha256(file.content),
1174
+ ...(file.secretSlots.length === 0
1175
+ ? {}
1176
+ : {
1177
+ secretSlots: [...file.secretSlots].sort((left, right) => compareStrings(JSON.stringify(left.path), JSON.stringify(right.path))),
1178
+ }),
946
1179
  }));
947
- const env = Object.fromEntries(Object.entries(plan.env).sort(([a], [b]) => compareStrings(a, b)));
1180
+ const env = Object.fromEntries(Object.entries(plan.env)
1181
+ .sort(([a], [b]) => compareStrings(a, b))
1182
+ .map(([name, value]) => [
1183
+ name,
1184
+ typeof value === "string"
1185
+ ? value
1186
+ : agentProfileSecretRefSchema.parse(value),
1187
+ ]));
948
1188
  const unsupported = [...plan.unsupported].sort((a, b) => compareStrings(`${a.dimension}\0${a.reason}`, `${b.dimension}\0${b.reason}`));
949
1189
  const canonical = JSON.stringify({
950
1190
  harness: plan.harness,
951
1191
  files,
952
1192
  env,
953
- flags: [...plan.flags],
1193
+ flags: plan.flags.map(normalizeWorkspacePlanArgument),
954
1194
  ...(plan.systemPrompt === undefined
955
1195
  ? {}
956
1196
  : { systemPrompt: plan.systemPrompt }),
@@ -1053,19 +1293,186 @@ export function applyAgentCandidateWorkspacePlan(plan, workspaceDir, targetWorks
1053
1293
  };
1054
1294
  }
1055
1295
  /**
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.
1296
+ * Apply a public-only WorkspacePlan. A plan with any secret reference fails
1297
+ * before the first write; private executors use
1298
+ * {@link applyWorkspacePlanWithSecretProvider} instead.
1060
1299
  */
1061
1300
  export function applyWorkspacePlan(plan, workspaceDir, options = {}) {
1301
+ return applyResolvedWorkspacePlan(plan, resolvePublicWorkspacePlan(plan), workspaceDir, options).receipt;
1302
+ }
1303
+ /**
1304
+ * Resolve opaque profile references through an executor-owned provider, then
1305
+ * apply the resulting private native files and launch inputs. Resolution
1306
+ * completes before any workspace write, and resolved values never enter the
1307
+ * public plan, digest, receipt, logs, or error messages.
1308
+ */
1309
+ export async function applyWorkspacePlanWithSecretProvider(plan, workspaceDir, secretProvider, options = {}) {
1310
+ const planDigest = hashWorkspacePlan(plan);
1311
+ const resolved = await resolvePrivateWorkspacePlan(plan, secretProvider);
1312
+ return applyResolvedWorkspacePlan(plan, resolved, workspaceDir, options, planDigest);
1313
+ }
1314
+ function secretRefLabel(ref) {
1315
+ return JSON.stringify(ref.key);
1316
+ }
1317
+ function requireResolvedSecretValue(ref, value) {
1318
+ if (typeof value !== "string" || value.trim().length === 0) {
1319
+ throw new Error(`AgentProfile secret provider returned no value for ${secretRefLabel(ref)}`);
1320
+ }
1321
+ return value;
1322
+ }
1323
+ function setValueAtJsonPath(root, path, value) {
1324
+ let current = root;
1325
+ for (const segment of path.slice(0, -1)) {
1326
+ if (current === null ||
1327
+ typeof current !== "object" ||
1328
+ !Object.hasOwn(current, segment)) {
1329
+ throw new Error("Generated profile JSON secret path no longer exists");
1330
+ }
1331
+ current = current[segment];
1332
+ }
1333
+ const finalSegment = path.at(-1);
1334
+ if (finalSegment === undefined ||
1335
+ current === null ||
1336
+ typeof current !== "object" ||
1337
+ !Object.hasOwn(current, finalSegment)) {
1338
+ throw new Error("Generated profile JSON secret path no longer exists");
1339
+ }
1340
+ Object.defineProperty(current, finalSegment, {
1341
+ value,
1342
+ enumerable: true,
1343
+ configurable: true,
1344
+ writable: true,
1345
+ });
1346
+ }
1347
+ function resolvePublicWorkspacePlan(plan) {
1348
+ const files = normalizePlanFiles(plan.files);
1349
+ for (const file of files) {
1350
+ const ref = file.secretSlots[0]?.ref;
1351
+ if (ref) {
1352
+ throw new Error(`Workspace plan requires an AgentProfileSecretProvider for ${secretRefLabel(ref)}`);
1353
+ }
1354
+ }
1355
+ const env = Object.fromEntries(Object.entries(plan.env).map(([name, value]) => {
1356
+ if (typeof value !== "string") {
1357
+ throw new Error(`Workspace plan requires an AgentProfileSecretProvider for ${secretRefLabel(value)}`);
1358
+ }
1359
+ return [name, value];
1360
+ }));
1361
+ const flags = plan.flags.map((argument) => {
1362
+ const normalized = normalizeWorkspacePlanArgument(argument);
1363
+ if (typeof normalized !== "string") {
1364
+ const segment = normalized.segments.find((value) => typeof value !== "string");
1365
+ if (!segment) {
1366
+ throw new Error("Workspace plan argument template is malformed");
1367
+ }
1368
+ throw new Error(`Workspace plan requires an AgentProfileSecretProvider for ${secretRefLabel(segment.ref)}`);
1369
+ }
1370
+ return normalized;
1371
+ });
1372
+ return {
1373
+ files,
1374
+ env,
1375
+ flags,
1376
+ ...(plan.systemPrompt === undefined
1377
+ ? {}
1378
+ : { systemPrompt: plan.systemPrompt }),
1379
+ };
1380
+ }
1381
+ async function resolvePrivateWorkspacePlan(plan, secretProvider) {
1382
+ if (!secretProvider || typeof secretProvider.get !== "function") {
1383
+ throw new Error("AgentProfileSecretProvider.get is required");
1384
+ }
1385
+ const pending = new Map();
1386
+ const resolveRef = async (input) => {
1387
+ const ref = agentProfileSecretRefSchema.parse(input);
1388
+ let raw = pending.get(ref.key);
1389
+ if (!raw) {
1390
+ raw = (async () => {
1391
+ let value;
1392
+ try {
1393
+ value = await secretProvider.get(ref.key);
1394
+ }
1395
+ catch {
1396
+ throw new Error(`AgentProfile secret provider failed for ${secretRefLabel(ref)}`);
1397
+ }
1398
+ return requireResolvedSecretValue(ref, value);
1399
+ })();
1400
+ pending.set(ref.key, raw);
1401
+ }
1402
+ const value = await raw;
1403
+ return ref.format === "bearer" ? `Bearer ${value}` : value;
1404
+ };
1405
+ const files = await Promise.all(normalizePlanFiles(plan.files).map(async (file) => {
1406
+ if (file.secretSlots.length === 0)
1407
+ return file;
1408
+ const parsed = JSON.parse(file.content);
1409
+ const values = await Promise.all(file.secretSlots.map(async (slot) => ({
1410
+ path: slot.path,
1411
+ value: await resolveRef(slot.ref),
1412
+ })));
1413
+ for (const resolved of values) {
1414
+ setValueAtJsonPath(parsed, resolved.path, resolved.value);
1415
+ }
1416
+ return {
1417
+ ...file,
1418
+ content: JSON.stringify(parsed, null, 2),
1419
+ secretSlots: [],
1420
+ };
1421
+ }));
1422
+ const env = Object.fromEntries(await Promise.all(Object.entries(plan.env).map(async ([name, value]) => [
1423
+ name,
1424
+ typeof value === "string" ? value : await resolveRef(value),
1425
+ ])));
1426
+ const flags = await Promise.all(plan.flags.map(async (argument) => {
1427
+ const normalized = normalizeWorkspacePlanArgument(argument);
1428
+ if (typeof normalized === "string")
1429
+ return normalized;
1430
+ const segments = await Promise.all(normalized.segments.map(async (segment) => {
1431
+ if (typeof segment === "string")
1432
+ return segment;
1433
+ const value = await resolveRef(segment.ref);
1434
+ return segment.encoding === "json-string"
1435
+ ? JSON.stringify(value)
1436
+ : value;
1437
+ }));
1438
+ return segments.join("");
1439
+ }));
1440
+ return {
1441
+ files,
1442
+ env,
1443
+ flags,
1444
+ ...(plan.systemPrompt === undefined
1445
+ ? {}
1446
+ : { systemPrompt: plan.systemPrompt }),
1447
+ };
1448
+ }
1449
+ function safeWorkspacePlanReceipt(plan, written, workspacePlanDigest = hashWorkspacePlan(plan)) {
1450
+ return {
1451
+ workspacePlanDigest,
1452
+ env: Object.fromEntries(Object.entries(plan.env).map(([name, value]) => [
1453
+ name,
1454
+ typeof value === "string" ? value : { ...value },
1455
+ ])),
1456
+ flags: plan.flags.map(normalizeWorkspacePlanArgument),
1457
+ ...(plan.systemPrompt === undefined
1458
+ ? {}
1459
+ : { systemPrompt: plan.systemPrompt }),
1460
+ unsupported: plan.unsupported.map((entry) => ({ ...entry })),
1461
+ written: [...written],
1462
+ };
1463
+ }
1464
+ function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDir, options, publicPlanDigest = hashWorkspacePlan(publicPlan)) {
1062
1465
  const workspaceRoot = resolve(workspaceDir);
1063
1466
  const runtimeOnlyFiles = new Set((options.runtimeOnlyFiles ?? []).map(normalizeWorkspaceRelativePath));
1064
1467
  const existingFiles = options.existingFiles ?? "allow-identical";
1065
1468
  if (existingFiles !== "allow-identical" && existingFiles !== "reject") {
1066
1469
  throw new Error(`Unsupported existing workspace file policy: ${existingFiles}`);
1067
1470
  }
1068
- const files = normalizePlanFiles(plan.files)
1471
+ const resolvedFiles = resolvedPlan.files;
1472
+ const privateRuntimeFiles = Object.fromEntries(resolvedFiles
1473
+ .filter((file) => runtimeOnlyFiles.has(file.relPath))
1474
+ .map((file) => [file.relPath, file.content]));
1475
+ const files = resolvedFiles
1069
1476
  .filter((file) => !runtimeOnlyFiles.has(file.relPath))
1070
1477
  .map((file) => ({
1071
1478
  ...file,
@@ -1145,16 +1552,7 @@ export function applyWorkspacePlan(plan, workspaceDir, options = {}) {
1145
1552
  }
1146
1553
  throw error;
1147
1554
  }
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
- };
1555
+ const receipt = safeWorkspacePlanReceipt(publicPlan, files.map((file) => file.relPath), publicPlanDigest);
1158
1556
  const cleanupErrors = [];
1159
1557
  const temporaryPaths = [];
1160
1558
  for (const entry of prepared) {
@@ -1171,7 +1569,17 @@ export function applyWorkspacePlan(plan, workspaceDir, options = {}) {
1171
1569
  if (cleanupErrors.length > 0) {
1172
1570
  throw new WorkspacePlanCleanupError(cleanupErrors, receipt, temporaryPaths);
1173
1571
  }
1174
- return receipt;
1572
+ return {
1573
+ receipt,
1574
+ spawn: {
1575
+ env: { ...resolvedPlan.env },
1576
+ flags: [...resolvedPlan.flags],
1577
+ ...(resolvedPlan.systemPrompt === undefined
1578
+ ? {}
1579
+ : { systemPrompt: resolvedPlan.systemPrompt }),
1580
+ },
1581
+ runtimeOnlyFiles: privateRuntimeFiles,
1582
+ };
1175
1583
  }
1176
1584
  function workspaceFileExists(pathValue) {
1177
1585
  try {
@@ -1301,7 +1709,9 @@ function materializeExecutionControls(h, tools, permissions, modes, plan, add, u
1301
1709
  for (const [tool, enabled] of nativeTools) {
1302
1710
  rules[enabled ? "allow" : "deny"].push(tool);
1303
1711
  }
1304
- add(CLAUDE_SETTINGS_PATH, JSON.stringify({ permissions: sealClaudePermissionRules(rules) }, null, 2));
1712
+ add(CLAUDE_SETTINGS_PATH, serializeWorkspacePlanJson({
1713
+ permissions: sealClaudePermissionRules(rules),
1714
+ }));
1305
1715
  registerClaudeSettings(plan);
1306
1716
  }
1307
1717
  break;
@@ -1324,7 +1734,7 @@ function materializeExecutionControls(h, tools, permissions, modes, plan, add, u
1324
1734
  const nativeTools = mapSupportedBooleanEntries(enforceableTools, openCodeToolName, (name) => unsupported("tools", `opencode: no verified native tool mapping for "${name}"`), "opencode tool");
1325
1735
  const nativePermissions = mapSupportedPermissionEntries(enforceablePermissions, openCodePermissionName, (name) => unsupported("permissions", `opencode: no verified native permission mapping for "${name}"`), "opencode permission");
1326
1736
  if (enforceableTools.length > 0 || permissionEntries.length > 0) {
1327
- add("opencode.json", JSON.stringify({
1737
+ add("opencode.json", serializeWorkspacePlanJson({
1328
1738
  $schema: "https://opencode.ai/config.json",
1329
1739
  ...(nativeTools.length > 0
1330
1740
  ? { tools: Object.fromEntries(nativeTools) }
@@ -1332,7 +1742,7 @@ function materializeExecutionControls(h, tools, permissions, modes, plan, add, u
1332
1742
  ...(nativePermissions.length > 0
1333
1743
  ? { permission: Object.fromEntries(nativePermissions) }
1334
1744
  : {}),
1335
- }, null, 2));
1745
+ }));
1336
1746
  }
1337
1747
  break;
1338
1748
  }
@@ -1361,6 +1771,46 @@ function materializeExecutionControls(h, tools, permissions, modes, plan, add, u
1361
1771
  }
1362
1772
  break;
1363
1773
  }
1774
+ case "pi": {
1775
+ const nativeTools = mapSupportedBooleanEntries(toolEntries, piToolId, (name) => unsupported("tools", `pi: tool name "${name}" is empty after trimming`), "pi tool");
1776
+ const nativePermissions = mapSupportedPermissionEntries(permissionEntries, piToolId, (name) => unsupported("permissions", `pi: permission name "${name}" is empty after trimming`), "pi permission");
1777
+ const enabled = new Set(nativeTools
1778
+ .filter(([, value]) => value)
1779
+ .map(([name]) => name)
1780
+ .sort(compareStrings));
1781
+ const excluded = new Set(nativeTools
1782
+ .filter(([, value]) => !value)
1783
+ .map(([name]) => name)
1784
+ .sort(compareStrings));
1785
+ for (const [name, policy] of nativePermissions) {
1786
+ if (typeof policy !== "string") {
1787
+ unsupported("permissions", `pi: conditional permission policy for "${name}" has no verified headless mapping`);
1788
+ continue;
1789
+ }
1790
+ if (policy === "ask") {
1791
+ unsupported("permissions", `pi: permission policy "ask" for "${name}" requires an interactive decision surface`);
1792
+ continue;
1793
+ }
1794
+ if (policy === "allow") {
1795
+ if (excluded.has(name)) {
1796
+ throw new Error(`pi execution control "${name}" conflicts between disabled and allow`);
1797
+ }
1798
+ continue;
1799
+ }
1800
+ if (enabled.has(name)) {
1801
+ throw new Error(`pi execution control "${name}" conflicts between enabled and deny`);
1802
+ }
1803
+ excluded.add(name);
1804
+ }
1805
+ if (enabled.size > 0 || excluded.size > 0) {
1806
+ add(PI_EXECUTION_CONTROL_EXTENSION_PATH, piExecutionControlExtensionSource([...enabled], [...excluded]));
1807
+ plan.flags.push("--extension", PI_EXECUTION_CONTROL_EXTENSION_PATH);
1808
+ }
1809
+ if (excluded.size > 0) {
1810
+ plan.flags.push("--exclude-tools", [...excluded].sort(compareStrings).join(","));
1811
+ }
1812
+ break;
1813
+ }
1364
1814
  default:
1365
1815
  if (toolEntries.length > 0) {
1366
1816
  unsupported("tools", `${h}: no verified primary-process tool availability mapping`);
@@ -1373,6 +1823,56 @@ function materializeExecutionControls(h, tools, permissions, modes, plan, add, u
1373
1823
  unsupported("modes", `${h}: resolve one named AgentProfile mode before materialization`);
1374
1824
  }
1375
1825
  }
1826
+ function piExecutionControlExtensionSource(enabledTools, excludedTools) {
1827
+ return [
1828
+ `const enabledTools = Object.freeze(${JSON.stringify(enabledTools)});`,
1829
+ `const excludedTools = Object.freeze(${JSON.stringify(excludedTools)});`,
1830
+ "",
1831
+ "function failProfile(message) {",
1832
+ ' process.stderr.write("Pi profile execution controls failed: " + message + "\\n");',
1833
+ " process.exit(78);",
1834
+ "}",
1835
+ "",
1836
+ "function resolveTool(reference, available, required) {",
1837
+ " if (available.includes(reference)) return reference;",
1838
+ " const folded = reference.toLowerCase();",
1839
+ " const matches = available.filter((name) => name.toLowerCase() === folded);",
1840
+ " if (matches.length === 1) return matches[0];",
1841
+ " if (matches.length > 1) {",
1842
+ " failProfile('tool \"' + reference + '\" is ambiguous: ' + matches.join(\", \"));",
1843
+ " }",
1844
+ " if (required) {",
1845
+ " failProfile('requested tool \"' + reference + '\" is unavailable');",
1846
+ " }",
1847
+ " return undefined;",
1848
+ "}",
1849
+ "",
1850
+ "function applyControls(pi) {",
1851
+ " const available = pi.getAllTools().map((tool) => tool.name);",
1852
+ " for (const reference of excludedTools) {",
1853
+ " const remaining = resolveTool(reference, available, false);",
1854
+ " if (remaining !== undefined) {",
1855
+ " failProfile(",
1856
+ " 'excluded tool \"' + reference + '\" remains available as \"' + remaining +",
1857
+ " '\"; pass its exact registered Pi name',",
1858
+ " );",
1859
+ " }",
1860
+ " }",
1861
+ " const enabled = enabledTools.map((name) =>",
1862
+ " resolveTool(name, available, true),",
1863
+ " );",
1864
+ " const active = new Set(pi.getActiveTools());",
1865
+ " for (const name of enabled) active.add(name);",
1866
+ " pi.setActiveTools([...active]);",
1867
+ "}",
1868
+ "",
1869
+ "export default function applyProfileExecutionControls(pi) {",
1870
+ ' pi.on("before_agent_start", () => applyControls(pi));',
1871
+ ' pi.on("turn_start", () => applyControls(pi));',
1872
+ "}",
1873
+ "",
1874
+ ].join("\n");
1875
+ }
1376
1876
  function normalizedControlName(name) {
1377
1877
  return name.replace(/[^a-zA-Z0-9_]/g, "").toLowerCase();
1378
1878
  }
@@ -1457,6 +1957,24 @@ function openCodePermissionName(name) {
1457
1957
  ? normalized
1458
1958
  : undefined;
1459
1959
  }
1960
+ const PI_EXECUTION_CONTROL_EXTENSION_PATH = ".tangle/pi-execution-controls.ts";
1961
+ /**
1962
+ * Translate only portable spellings whose Pi equivalent differs.
1963
+ *
1964
+ * Every other exact name is resolved against Pi's live registry, keeping
1965
+ * extension and MCP tools profile-configurable without a static catalog.
1966
+ */
1967
+ function piToolId(name) {
1968
+ const trimmed = name.trim();
1969
+ if (trimmed.length === 0)
1970
+ return undefined;
1971
+ const portableName = trimmed.toLowerCase();
1972
+ if (portableName === "shell")
1973
+ return "bash";
1974
+ if (portableName === "glob")
1975
+ return "find";
1976
+ return trimmed;
1977
+ }
1460
1978
  function mapSupportedBooleanEntries(entries, mapName, onUnsupported, label) {
1461
1979
  return mapControlEntries(entries, mapName, onUnsupported, (left, right, nativeName) => {
1462
1980
  if (left !== right)
@@ -1562,7 +2080,7 @@ function materializeMcp(h, mcp, plan, add, unsupported) {
1562
2080
  };
1563
2081
  }
1564
2082
  const relPath = ".tangle/claude-mcp.json";
1565
- add(relPath, JSON.stringify({ mcpServers: servers }, null, 2));
2083
+ add(relPath, serializeWorkspacePlanJson({ mcpServers: servers }));
1566
2084
  plan.flags.push("--mcp-config", relPath, "--strict-mcp-config");
1567
2085
  break;
1568
2086
  }
@@ -1576,21 +2094,21 @@ function materializeMcp(h, mcp, plan, add, unsupported) {
1576
2094
  unsupported("mcp", `codex: remote MCP server "${name}" uses unsupported SSE transport`);
1577
2095
  continue;
1578
2096
  }
1579
- servers.push(`${tomlStr(name)} = ${codexMcpServerToml(name, server, plan)}`);
2097
+ servers.push(concatWorkspacePlanArguments(`${tomlStr(name)} = `, codexMcpServerToml(name, server, plan)));
1580
2098
  }
1581
2099
  if (servers.length > 0) {
1582
2100
  // Runtime overrides do not require persisted project trust. Header values
1583
2101
  // ride environment variables so credentials never appear in argv.
1584
- plan.flags.push("-c", `mcp_servers={ ${servers.join(", ")} }`);
2102
+ plan.flags.push("-c", concatWorkspacePlanArguments("mcp_servers={ ", joinWorkspacePlanArguments(servers, ", "), " }"));
1585
2103
  }
1586
2104
  break;
1587
2105
  }
1588
2106
  case "opencode":
1589
2107
  // opencode.json "mcp" block (local|remote).
1590
- add("opencode.json", JSON.stringify({
2108
+ add("opencode.json", serializeWorkspacePlanJson({
1591
2109
  $schema: "https://opencode.ai/config.json",
1592
2110
  mcp: openCodeMcpConfig(mcp),
1593
- }, null, 2));
2111
+ }));
1594
2112
  break;
1595
2113
  case "gemini": {
1596
2114
  const servers = {};
@@ -1609,7 +2127,7 @@ function materializeMcp(h, mcp, plan, add, unsupported) {
1609
2127
  ...(server.cwd ? { cwd: server.cwd } : {}),
1610
2128
  };
1611
2129
  }
1612
- add(".gemini/settings.json", JSON.stringify({ mcpServers: servers }, null, 2));
2130
+ add(".gemini/settings.json", serializeWorkspacePlanJson({ mcpServers: servers }));
1613
2131
  break;
1614
2132
  }
1615
2133
  case "kimi-code": {
@@ -1619,7 +2137,7 @@ function materializeMcp(h, mcp, plan, add, unsupported) {
1619
2137
  name,
1620
2138
  kimiMcpServer(server),
1621
2139
  ]));
1622
- add(rel, JSON.stringify({ mcpServers: servers }, null, 2));
2140
+ add(rel, serializeWorkspacePlanJson({ mcpServers: servers }));
1623
2141
  plan.flags.push("--mcp-config-file", rel);
1624
2142
  break;
1625
2143
  }
@@ -1662,7 +2180,7 @@ function codexMcpServerToml(name, server, plan) {
1662
2180
  }
1663
2181
  fields.push(`command = ${tomlStr(server.command)}`);
1664
2182
  if (server.args?.length) {
1665
- fields.push(`args = [${server.args.map(tomlStr).join(", ")}]`);
2183
+ fields.push(concatWorkspacePlanArguments("args = [", joinWorkspacePlanArguments(server.args.map(tomlProfileConfigValue), ", "), "]"));
1666
2184
  }
1667
2185
  if (server.cwd)
1668
2186
  fields.push(`cwd = ${tomlStr(server.cwd)}`);
@@ -1674,14 +2192,16 @@ function codexMcpServerToml(name, server, plan) {
1674
2192
  fields.push(`env_vars = [${envNames.map(tomlStr).join(", ")}]`);
1675
2193
  }
1676
2194
  }
1677
- return `{ ${fields.join(", ")} }`;
2195
+ return concatWorkspacePlanArguments("{ ", joinWorkspacePlanArguments(fields, ", "), " }");
1678
2196
  }
1679
2197
  function setPlanEnvironment(plan, name, value) {
2198
+ const normalized = typeof value === "string" ? value : workspacePlanConfigValue(value);
1680
2199
  const previous = plan.env[name];
1681
- if (previous !== undefined && previous !== value) {
2200
+ if (previous !== undefined &&
2201
+ JSON.stringify(previous) !== JSON.stringify(normalized)) {
1682
2202
  throw new Error(`profile environment values disagree for ${name}`);
1683
2203
  }
1684
- plan.env[name] = value;
2204
+ plan.env[name] = normalized;
1685
2205
  }
1686
2206
  function kimiMcpServer(server) {
1687
2207
  if (server.url) {
@@ -1746,14 +2266,14 @@ function materializeHooks(h, hooks, add, unsupported, plan) {
1746
2266
  hooks: [
1747
2267
  {
1748
2268
  type: "command",
1749
- command: materializedHookCommand(event, c, add),
2269
+ command: materializedHookCommand(event, c, add, plan),
1750
2270
  ...(c.timeoutMs ? { timeout: timeoutSeconds(c.timeoutMs) } : {}),
1751
2271
  ...(c.blocking === false ? { async: true } : {}),
1752
2272
  },
1753
2273
  ],
1754
2274
  }));
1755
2275
  }
1756
- add(CLAUDE_SETTINGS_PATH, JSON.stringify({ hooks: settingsHooks }, null, 2));
2276
+ add(CLAUDE_SETTINGS_PATH, serializeWorkspacePlanJson({ hooks: settingsHooks }));
1757
2277
  registerClaudeSettings(plan);
1758
2278
  break;
1759
2279
  }
@@ -1775,13 +2295,13 @@ function materializeHooks(h, hooks, add, unsupported, plan) {
1775
2295
  hooks: [
1776
2296
  {
1777
2297
  type: "command",
1778
- command: materializedHookCommand(event, c, add),
2298
+ command: materializedHookCommand(event, c, add, plan),
1779
2299
  ...(c.timeoutMs ? { timeout: c.timeoutMs } : {}),
1780
2300
  },
1781
2301
  ],
1782
2302
  }));
1783
2303
  }
1784
- add(".gemini/settings.json", JSON.stringify({ hooks: settingsHooks }, null, 2));
2304
+ add(".gemini/settings.json", serializeWorkspacePlanJson({ hooks: settingsHooks }));
1785
2305
  break;
1786
2306
  }
1787
2307
  case "codex": {
@@ -1797,7 +2317,7 @@ function materializeHooks(h, hooks, add, unsupported, plan) {
1797
2317
  const entries = cmds.map((command) => {
1798
2318
  const handler = [
1799
2319
  `type = ${tomlStr("command")}`,
1800
- `command = ${tomlStr(materializedHookCommand(event, command, add))}`,
2320
+ `command = ${tomlStr(materializedHookCommand(event, command, add, plan))}`,
1801
2321
  ...(command.timeoutMs
1802
2322
  ? [`timeout = ${timeoutSeconds(command.timeoutMs)}`]
1803
2323
  : []),
@@ -1906,7 +2426,7 @@ function hookEventSupported(harness, event) {
1906
2426
  function timeoutSeconds(timeoutMs) {
1907
2427
  return Math.max(1, Math.ceil(timeoutMs / 1000));
1908
2428
  }
1909
- function materializedHookCommand(event, command, add) {
2429
+ function materializedHookCommand(event, command, add, plan) {
1910
2430
  if (!command.env || Object.keys(command.env).length === 0) {
1911
2431
  return command.command;
1912
2432
  }
@@ -1916,9 +2436,50 @@ function materializedHookCommand(event, command, add) {
1916
2436
  env: Object.fromEntries(Object.entries(command.env).sort(([left], [right]) => compareStrings(left, right))),
1917
2437
  }).slice("sha256:".length);
1918
2438
  const path = `.tangle/profile-hooks/${wrapperId}.mjs`;
1919
- add(path, hookWrapperSource("/bin/sh", ["-c", command.command], command.env));
2439
+ const envSources = Object.fromEntries(Object.entries(command.env)
2440
+ .sort(([left], [right]) => compareStrings(left, right))
2441
+ .map(([name, value]) => {
2442
+ const sourceName = `TANGLE_PROFILE_HOOK_${sha256(`${wrapperId}\0${name}`)
2443
+ .slice(0, 24)
2444
+ .toUpperCase()}`;
2445
+ setPlanEnvironment(plan, sourceName, value);
2446
+ return [name, sourceName];
2447
+ }));
2448
+ add(path, hookWrapperSourceFromEnvironment("/bin/sh", ["-c", command.command], envSources));
1920
2449
  return `node ${path}`;
1921
2450
  }
2451
+ function hookWrapperSourceFromEnvironment(executable, args, envSources) {
2452
+ return [
2453
+ 'import { spawnSync } from "node:child_process"',
2454
+ `const executable = ${JSON.stringify(executable)}`,
2455
+ `const args = ${JSON.stringify(args)}`,
2456
+ `const envSources = ${JSON.stringify(envSources)}`,
2457
+ "const extraEnv = {}",
2458
+ "for (const [name, source] of Object.entries(envSources)) {",
2459
+ " const value = process.env[source]",
2460
+ " if (value === undefined) {",
2461
+ ' console.error("missing materialized hook environment " + source)',
2462
+ " process.exit(127)",
2463
+ " }",
2464
+ " extraEnv[name] = value",
2465
+ "}",
2466
+ "const result = spawnSync(executable, args, {",
2467
+ ' stdio: "inherit",',
2468
+ " env: { ...process.env, ...extraEnv },",
2469
+ " shell: false,",
2470
+ "})",
2471
+ "if (result.error) {",
2472
+ " console.error(result.error.message)",
2473
+ " process.exit(127)",
2474
+ "}",
2475
+ "if (result.signal) {",
2476
+ ' console.error("hook terminated by " + result.signal)',
2477
+ " process.exit(1)",
2478
+ "}",
2479
+ "process.exit(result.status ?? 1)",
2480
+ "",
2481
+ ].join("\n");
2482
+ }
1922
2483
  function hookWrapperSource(executable, args, env) {
1923
2484
  return [
1924
2485
  '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.9.4",
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",