@tangle-network/agent-profile-materialize 0.2.3 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -21,7 +21,7 @@
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 { AgentProfile } from "@tangle-network/agent-interface";
24
+ import type { AgentCandidateConfigValue, AgentCandidateProfile, AgentCandidateProfileApplication, AgentCandidateProfilePlanEvidence, AgentCandidateProfilePlanMaterialV1, AgentProfile, Sha256Digest } from "@tangle-network/agent-interface";
25
25
  export type HarnessId = "claude-code" | "claude" | "claudish" | "nanoclaw" | "codex" | "opencode" | "kimi-code" | "kimi" | "pi" | "gemini" | "hermes" | "openclaw";
26
26
  export interface PlanFile {
27
27
  relPath: string;
@@ -30,7 +30,7 @@ export interface PlanFile {
30
30
  mode?: number;
31
31
  }
32
32
  export interface Unsupported {
33
- dimension: "skills" | "mcp" | "hooks" | "subagents" | "commands" | "instructions" | "systemPrompt" | "permissions" | "tools";
33
+ dimension: "files" | "skills" | "mcp" | "hooks" | "subagents" | "commands" | "instructions" | "systemPrompt" | "permissions" | "tools";
34
34
  reason: string;
35
35
  }
36
36
  /** The plan a caller writes into the run cwd, then applies to the spawn. */
@@ -45,6 +45,40 @@ export interface WorkspacePlan {
45
45
  /** Dimensions requested but with NO cwd path on this harness — fail-closed record. */
46
46
  unsupported: Unsupported[];
47
47
  }
48
+ /**
49
+ * Proof of the profile workspace inputs applied before a harness is spawned.
50
+ * `workspacePlanDigest` covers only the {@link WorkspacePlan}; it is not an
51
+ * execution-plan digest and does not attest unrelated files in `workspaceDir`.
52
+ */
53
+ export interface WorkspacePlanReceipt {
54
+ workspacePlanDigest: string;
55
+ env: Record<string, string>;
56
+ flags: string[];
57
+ unsupported: Unsupported[];
58
+ written: string[];
59
+ }
60
+ /**
61
+ * Candidate-safe workspace plan. Spawn values remain typed until the runtime
62
+ * resolves them; the materializer never joins argv into a shell command.
63
+ */
64
+ export interface AgentCandidateWorkspacePlan {
65
+ harness: HarnessId;
66
+ files: PlanFile[];
67
+ env: Record<string, AgentCandidateConfigValue>;
68
+ flags: AgentCandidateConfigValue[];
69
+ unsupported: Unsupported[];
70
+ }
71
+ export interface AgentCandidateWorkspacePlanReceipt {
72
+ profilePlan: AgentCandidateProfilePlanEvidence;
73
+ application: AgentCandidateProfileApplication;
74
+ env: Record<string, AgentCandidateConfigValue>;
75
+ flags: AgentCandidateConfigValue[];
76
+ unsupported: Unsupported[];
77
+ }
78
+ export interface MaterializeCandidateProfileOptions {
79
+ /** Exact text fetched from pinned resource refs, keyed by declared SHA-256. */
80
+ resolvedResources?: ReadonlyMap<Sha256Digest, string>;
81
+ }
48
82
  /** Normalize any skill markdown (VB frontmatter `skill:`/`description:`, or raw) into a
49
83
  * harness SKILL.md with `name:`/`description:` frontmatter the harnesses require. */
50
84
  export declare function normalizeSkillMd(name: string, raw: string): string;
@@ -55,15 +89,41 @@ export declare function normalizeSkillMd(name: string, raw: string): string;
55
89
  export declare function materializeProfile(profile: AgentProfile, harness: HarnessId, opts?: {
56
90
  skip?: Unsupported["dimension"][];
57
91
  }): WorkspacePlan;
92
+ /**
93
+ * Materialize a sealed candidate profile without weakening its process values.
94
+ * Candidate hooks deliberately fail closed because the currently supported
95
+ * native hook formats accept a shell command string rather than executable +
96
+ * argv + env as separate fields.
97
+ */
98
+ export declare function materializeCandidateProfile(profile: AgentCandidateProfile, harness: HarnessId, opts?: MaterializeCandidateProfileOptions): AgentCandidateWorkspacePlan;
99
+ /**
100
+ * Hash the exact files, modes, flags, environment, and unsupported dimensions
101
+ * represented by a plan. Array order is preserved only where it changes spawn
102
+ * behavior; unordered maps and file records are canonicalized first.
103
+ */
104
+ export declare function hashWorkspacePlan(plan: WorkspacePlan): string;
105
+ /** Build the exact digest-free identity document required by agent-interface. */
106
+ export declare function agentCandidateProfilePlanMaterial(plan: AgentCandidateWorkspacePlan): AgentCandidateProfilePlanMaterialV1;
107
+ /**
108
+ * Return the RFC 8785 UTF-8 bytes and SHA-256 identity of a candidate profile
109
+ * plan. The returned bytes are the exact bytes embedded in receipt evidence.
110
+ */
111
+ export declare function canonicalizeAgentCandidateProfilePlan(plan: AgentCandidateWorkspacePlan): {
112
+ material: AgentCandidateProfilePlanMaterialV1;
113
+ bytes: Uint8Array;
114
+ digest: Sha256Digest;
115
+ };
116
+ /** Capture portable evidence of the exact canonical profile-plan bytes. */
117
+ export declare function captureAgentCandidateProfilePlan(plan: AgentCandidateWorkspacePlan): AgentCandidateProfilePlanEvidence;
118
+ /**
119
+ * Apply a candidate plan and return its spawn values together with captured
120
+ * canonical evidence. No caller needs to re-serialize or reinterpret the plan.
121
+ */
122
+ export declare function applyAgentCandidateWorkspacePlan(plan: AgentCandidateWorkspacePlan, workspaceDir: string, targetWorkspace: AgentCandidateProfileApplication["targetWorkspace"]): AgentCandidateWorkspacePlanReceipt;
58
123
  /**
59
124
  * Apply a WorkspacePlan to a real workspace dir: write every file (creating parent
60
125
  * dirs), and return the env + flags the caller must hand the spawn. This is the IO
61
126
  * consumer of the pure `materializeProfile` — used by the cli-bridge host spawn, the
62
127
  * agent-dev-container box, and VB. Returns `unsupported` so the caller can log/fail.
63
128
  */
64
- export declare function applyWorkspacePlan(plan: WorkspacePlan, workspaceDir: string): {
65
- env: Record<string, string>;
66
- flags: string[];
67
- unsupported: Unsupported[];
68
- written: string[];
69
- };
129
+ export declare function applyWorkspacePlan(plan: WorkspacePlan, workspaceDir: string): WorkspacePlanReceipt;
package/dist/index.js CHANGED
@@ -21,8 +21,10 @@
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 { mkdirSync, writeFileSync } from "node:fs";
25
- import { dirname, join } from "node:path";
24
+ import { createHash, randomUUID } from "node:crypto";
25
+ import { closeSync, fchmodSync, constants as fsConstants, lstatSync, mkdirSync, openSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
26
+ import { dirname, isAbsolute, join, relative, resolve, sep, win32, } from "node:path";
27
+ import { agentCandidateProfilePlanEvidenceSchema, agentCandidateProfilePlanMaterialSchema, agentCandidateProfileSchema, } from "@tangle-network/agent-interface";
26
28
  /** Resolve an inline resource ref to {name, content}. github refs need an async fetch
27
29
  * (the providers' resolveProfileResource does this); the sync materializer can't, so a
28
30
  * github ref is reported unsupported and the caller pre-resolves it. A ref with
@@ -78,6 +80,48 @@ function canonicalHarness(h) {
78
80
  // ─── Helpers ────────────────────────────────────────────────────────────────
79
81
  const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/;
80
82
  const collapse = (s) => s.replace(/\s+/g, " ").trim();
83
+ const compareStrings = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
84
+ const DEFAULT_FILE_MODE = 0o644;
85
+ const EXECUTABLE_FILE_MODE = 0o755;
86
+ const SAFE_WRITE_FLAGS = fsConstants.O_WRONLY |
87
+ fsConstants.O_CREAT |
88
+ fsConstants.O_EXCL |
89
+ (typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0);
90
+ function normalizeWorkspaceRelativePath(pathValue) {
91
+ if (typeof pathValue !== "string" ||
92
+ !pathValue ||
93
+ hasControlCharacter(pathValue)) {
94
+ throw new Error("Workspace file path must be non-empty and contain no control characters");
95
+ }
96
+ if (isAbsolute(pathValue) ||
97
+ win32.isAbsolute(pathValue) ||
98
+ /^[A-Za-z]:/.test(pathValue)) {
99
+ throw new Error(`Workspace file path must be relative: ${pathValue}`);
100
+ }
101
+ const segments = pathValue.replace(/\\/g, "/").split("/");
102
+ if (segments.some((segment) => !segment ||
103
+ segment === "." ||
104
+ segment === ".." ||
105
+ segment === ".sidecar")) {
106
+ throw new Error(`Workspace file path must be canonical: ${pathValue}`);
107
+ }
108
+ return segments.join("/");
109
+ }
110
+ function hasControlCharacter(value) {
111
+ for (let index = 0; index < value.length; index++) {
112
+ const code = value.charCodeAt(index);
113
+ if (code < 0x20 || code === 0x7f)
114
+ return true;
115
+ }
116
+ return false;
117
+ }
118
+ function validateFileMode(mode) {
119
+ const normalized = mode ?? DEFAULT_FILE_MODE;
120
+ if (!Number.isInteger(normalized) || normalized < 0 || normalized > 0o777) {
121
+ throw new Error(`Workspace file mode must be between 0o000 and 0o777: ${mode}`);
122
+ }
123
+ return normalized;
124
+ }
81
125
  /** Normalize any skill markdown (VB frontmatter `skill:`/`description:`, or raw) into a
82
126
  * harness SKILL.md with `name:`/`description:` frontmatter the harnesses require. */
83
127
  export function normalizeSkillMd(name, raw) {
@@ -126,9 +170,73 @@ export function materializeProfile(profile, harness, opts = {}) {
126
170
  flags: [],
127
171
  unsupported: [],
128
172
  };
129
- const add = (relPath, content, mode) => plan.files.push({ relPath, content, ...(mode ? { mode } : {}) });
173
+ const add = (relPath, content, mode) => {
174
+ const normalizedPath = normalizeWorkspaceRelativePath(relPath);
175
+ plan.files.push({
176
+ relPath: normalizedPath,
177
+ content,
178
+ ...(mode === undefined ? {} : { mode: validateFileMode(mode) }),
179
+ });
180
+ };
130
181
  const unsupported = (dimension, reason) => plan.unsupported.push({ dimension, reason });
131
- // 1) CONTEXT (instructions + systemPrompt → the harness's native context file).
182
+ const explicitResourcePaths = new Map();
183
+ const addExplicitResource = (dimension, relPath, content, mode) => {
184
+ const normalizedPath = normalizeWorkspaceRelativePath(relPath);
185
+ const previousDimension = explicitResourcePaths.get(normalizedPath);
186
+ if (previousDimension) {
187
+ throw new Error(`Duplicate profile resource path: ${normalizedPath} (${previousDimension}, ${dimension})`);
188
+ }
189
+ explicitResourcePaths.set(normalizedPath, dimension);
190
+ add(normalizedPath, content, mode);
191
+ };
192
+ // 1) GENERIC FILES → exact workspace-relative paths. Inline refs are
193
+ // deterministic; remote refs must be resolved by an async caller first.
194
+ const fileMountPaths = new Set();
195
+ for (const mount of profile.resources?.files ?? []) {
196
+ const relPath = normalizeWorkspaceRelativePath(mount.path);
197
+ if (fileMountPaths.has(relPath)) {
198
+ throw new Error(`Duplicate workspace file mount: ${relPath}`);
199
+ }
200
+ fileMountPaths.add(relPath);
201
+ if (mount.executable !== undefined &&
202
+ typeof mount.executable !== "boolean") {
203
+ throw new Error(`Workspace file executable must be a boolean: ${relPath}`);
204
+ }
205
+ const resource = resolveInlineRef(mount.resource);
206
+ if (!resource) {
207
+ unsupported("files", `file mount "${relPath}" is not inline (github refs need async pre-resolution)`);
208
+ continue;
209
+ }
210
+ addExplicitResource("files", relPath, resource.content, mount.executable ? EXECUTABLE_FILE_MODE : DEFAULT_FILE_MODE);
211
+ }
212
+ // 2) PROVIDER-NATIVE TOOLS → only OpenCode exposes a verified cwd path.
213
+ for (const ref of profile.resources?.tools ?? []) {
214
+ const resource = resolveInlineRef(ref);
215
+ if (!resource) {
216
+ unsupported("tools", "tool ref is not inline (github refs need async pre-resolution)");
217
+ continue;
218
+ }
219
+ if (h !== "opencode") {
220
+ unsupported("tools", `${h}: no verified cwd-native file tool path; use MCP or a native extension`);
221
+ continue;
222
+ }
223
+ addExplicitResource("tools", `.opencode/tools/${resource.name}`, resource.content);
224
+ }
225
+ // 3) PROVIDER-NATIVE AGENTS → raw native definitions at verified cwd paths.
226
+ for (const ref of profile.resources?.agents ?? []) {
227
+ const resource = resolveInlineRef(ref);
228
+ if (!resource) {
229
+ unsupported("subagents", "agent ref is not inline (github refs need async pre-resolution)");
230
+ continue;
231
+ }
232
+ const directory = agentResourceDirectory(h);
233
+ if (!directory) {
234
+ unsupported("subagents", `${h}: agent definitions have no verified cwd-native resource path`);
235
+ continue;
236
+ }
237
+ addExplicitResource("subagents", `${directory}/${resource.name}`, resource.content);
238
+ }
239
+ // 4) CONTEXT (instructions + systemPrompt → the harness's native context file).
132
240
  const instr = [];
133
241
  if (profile.prompt?.systemPrompt)
134
242
  instr.push(profile.prompt.systemPrompt);
@@ -143,7 +251,7 @@ export function materializeProfile(profile, harness, opts = {}) {
143
251
  add(CONTEXT_FILE[h], `${instr.join("\n\n")}\n`);
144
252
  // gemini reads AGENTS.md only via settings; we write GEMINI.md so no extra step.
145
253
  }
146
- // 2) SKILLS → native skill dir, from resources.skills refs (the box's shape), or
254
+ // 5) SKILLS → native skill dir, from resources.skills refs (the box's shape), or
147
255
  // fail-closed (hermes has no cwd skill dir; github refs need async pre-resolution).
148
256
  for (const ref of profile.resources?.skills ?? []) {
149
257
  const r = resolveInlineRef(ref);
@@ -156,35 +264,295 @@ export function materializeProfile(profile, harness, opts = {}) {
156
264
  unsupported("skills", `${h}: no cwd skill dir (skills live in a user/global dir); skill "${r.name}" not mounted`);
157
265
  continue;
158
266
  }
159
- add(`${dir}/${r.name}/SKILL.md`, normalizeSkillMd(r.name, r.content));
267
+ addExplicitResource("skills", `${dir}/${r.name}/SKILL.md`, normalizeSkillMd(r.name, r.content));
160
268
  }
161
- // 3) MCP → per-harness format (the divergence the matrix names). Skippable so the
269
+ // 6) MCP → per-harness format (the divergence the matrix names). Skippable so the
162
270
  // additive host-wiring can leave MCP on cli-bridge's existing per-harness path.
163
271
  const mcp = profile.mcp ?? {};
164
272
  if (!skip.has("mcp") && Object.keys(mcp).length)
165
273
  materializeMcp(h, mcp, plan, add, unsupported);
166
- // 4) HOOKS → per-harness format; cwd-native only for claude/codex/gemini/opencode(plugin).
274
+ // 7) HOOKS → per-harness format; cwd-native only for claude/codex/gemini/opencode(plugin).
167
275
  const hooks = profile.hooks ?? {};
168
276
  if (Object.keys(hooks).length)
169
277
  materializeHooks(h, hooks, add, unsupported, plan);
170
- // 5) SUBAGENTS → cwd-native for claude/codex/opencode/gemini; flag/global elsewhere.
278
+ // 8) SUBAGENTS → cwd-native for claude/codex/opencode/gemini; flag/global elsewhere.
171
279
  const subagents = profile.subagents ?? {};
172
280
  for (const [name, sa] of Object.entries(subagents))
173
281
  materializeSubagent(h, name, sa, add, unsupported);
174
- // 6) COMMANDS → cwd-native (resources.commands refs); "commands are skills" on kimi.
282
+ // 9) COMMANDS → cwd-native (resources.commands refs); "commands are skills" on kimi.
175
283
  for (const ref of profile.resources?.commands ?? []) {
176
284
  const r = resolveInlineRef(ref);
177
285
  if (!r) {
178
286
  unsupported("commands", "command ref is not inline (github needs pre-resolution)");
179
287
  continue;
180
288
  }
181
- materializeCommand(h, r.name, r.content, add, unsupported);
289
+ materializeCommand(h, r.name, r.content, (path, content, mode) => addExplicitResource("commands", path, content, mode), unsupported);
290
+ }
291
+ for (const [relPath, dimension] of explicitResourcePaths) {
292
+ const writers = plan.files.filter((file) => file.relPath === relPath).length;
293
+ if (writers > 1) {
294
+ throw new Error(`Profile ${dimension} resource conflicts with generated harness file: ${relPath}`);
295
+ }
296
+ }
297
+ // Merge native multi-writer JSON files (e.g. .claude/settings.json from both
298
+ // mcp and hooks) so the second writer cannot clobber the first.
299
+ plan.files = mergeJsonFiles(plan.files).sort((a, b) => compareStrings(a.relPath, b.relPath));
300
+ if (profile.resources?.failOnError && plan.unsupported.length) {
301
+ throw new Error(formatUnsupported(plan.unsupported));
182
302
  }
183
- // Merge multi-writer JSON files (e.g. .claude/settings.json from both mcp and hooks)
184
- // so the second write can't clobber the first.
185
- plan.files = mergeJsonFiles(plan.files);
186
303
  return plan;
187
304
  }
305
+ /**
306
+ * Materialize a sealed candidate profile without weakening its process values.
307
+ * Candidate hooks deliberately fail closed because the currently supported
308
+ * native hook formats accept a shell command string rather than executable +
309
+ * argv + env as separate fields.
310
+ */
311
+ export function materializeCandidateProfile(profile, harness, opts = {}) {
312
+ const candidate = agentCandidateProfileSchema.parse(profile);
313
+ verifyCandidateInlineResources(candidate);
314
+ const hookLowering = lowerCandidateHooks(candidate.hooks, harness);
315
+ const candidateResources = candidate.resources
316
+ ? candidateResourcesForClassicMaterializer(candidate.resources, opts.resolvedResources)
317
+ : {};
318
+ candidateResources.files = [
319
+ ...(candidateResources.files ?? []),
320
+ ...hookLowering.wrapperFiles,
321
+ ];
322
+ const classicProfile = {
323
+ ...candidate,
324
+ resources: candidateResources,
325
+ mcp: candidateMcpRecordForClassicMaterializer(candidate.mcp),
326
+ hooks: hookLowering.hooks,
327
+ };
328
+ const classicPlan = materializeProfile(classicProfile, harness);
329
+ if (canonicalHarness(harness) === "opencode" &&
330
+ Object.values(candidate.mcp ?? {}).some((server) => server.enabled !== false && server.cwd !== undefined)) {
331
+ classicPlan.unsupported.push({
332
+ dimension: "mcp",
333
+ reason: "opencode local MCP configuration does not support a cwd field",
334
+ });
335
+ }
336
+ if (hookLowering.unsupportedReason) {
337
+ classicPlan.unsupported.push({
338
+ dimension: "hooks",
339
+ reason: hookLowering.unsupportedReason,
340
+ });
341
+ }
342
+ classicPlan.unsupported.sort((left, right) => compareStrings(`${left.dimension}\0${left.reason}`, `${right.dimension}\0${right.reason}`));
343
+ if (classicPlan.unsupported.length > 0) {
344
+ throw new Error(formatUnsupported(classicPlan.unsupported));
345
+ }
346
+ return {
347
+ harness: classicPlan.harness,
348
+ files: classicPlan.files,
349
+ env: candidatePublicRecord(classicPlan.env),
350
+ flags: classicPlan.flags.map(candidatePublicValue),
351
+ unsupported: classicPlan.unsupported,
352
+ };
353
+ }
354
+ function lowerCandidateHooks(hooks, harness) {
355
+ if (!hooks || Object.keys(hooks).length === 0)
356
+ return { wrapperFiles: [] };
357
+ if (canonicalHarness(harness) !== "codex") {
358
+ return {
359
+ wrapperFiles: [],
360
+ unsupportedReason: `${canonicalHarness(harness)} hook configuration requires an unverified command-string mapping; ` +
361
+ "sealed candidate hooks preserve executable, argv, and env separately",
362
+ };
363
+ }
364
+ const wrapperFiles = [];
365
+ const lowered = {};
366
+ for (const [event, commands] of Object.entries(hooks)) {
367
+ lowered[event] = commands.map((command, index) => {
368
+ const wrapper = candidateHookWrapper(event, index, command);
369
+ wrapperFiles.push({
370
+ path: wrapper.path,
371
+ resource: {
372
+ kind: "inline",
373
+ name: wrapper.path,
374
+ content: wrapper.content,
375
+ },
376
+ });
377
+ return {
378
+ command: `node ${wrapper.path}`,
379
+ timeoutMs: command.timeoutMs,
380
+ blocking: command.blocking,
381
+ matcher: command.matcher,
382
+ };
383
+ });
384
+ }
385
+ return { hooks: lowered, wrapperFiles };
386
+ }
387
+ function candidateHookWrapper(event, index, command) {
388
+ const args = (command.args ?? []).map(candidatePublicString);
389
+ const env = Object.fromEntries(Object.entries(command.env ?? {})
390
+ .sort(([left], [right]) => compareStrings(left, right))
391
+ .map(([name, value]) => [name, candidatePublicString(value)]));
392
+ const identity = rfc8785Serialize({
393
+ event,
394
+ index,
395
+ executable: command.executable,
396
+ args,
397
+ env,
398
+ timeoutMs: command.timeoutMs ?? null,
399
+ blocking: command.blocking ?? null,
400
+ matcher: command.matcher ?? null,
401
+ });
402
+ const wrapperId = sha256(identity);
403
+ const path = `.tangle/profile-hooks/${wrapperId}.mjs`;
404
+ const content = [
405
+ 'import { spawnSync } from "node:child_process"',
406
+ `const executable = ${JSON.stringify(command.executable)}`,
407
+ `const args = ${JSON.stringify(args)}`,
408
+ `const extraEnv = ${JSON.stringify(env)}`,
409
+ "const result = spawnSync(executable, args, {",
410
+ ' stdio: "inherit",',
411
+ " env: { ...process.env, ...extraEnv },",
412
+ " shell: false,",
413
+ "})",
414
+ "if (result.error) {",
415
+ " console.error(result.error.message)",
416
+ " process.exit(127)",
417
+ "}",
418
+ "if (result.signal) {",
419
+ ' console.error("hook terminated by " + result.signal)',
420
+ " process.exit(1)",
421
+ "}",
422
+ "process.exit(result.status ?? 1)",
423
+ "",
424
+ ].join("\n");
425
+ return { path, content };
426
+ }
427
+ function agentResourceDirectory(harness) {
428
+ switch (harness) {
429
+ case "claude-code":
430
+ case "nanoclaw":
431
+ return ".claude/agents";
432
+ case "codex":
433
+ return ".codex/agents";
434
+ case "opencode":
435
+ return ".opencode/agents";
436
+ case "gemini":
437
+ return ".gemini/agents";
438
+ default:
439
+ return null;
440
+ }
441
+ }
442
+ function candidateMcpRecordForClassicMaterializer(servers) {
443
+ if (!servers)
444
+ return undefined;
445
+ return Object.fromEntries(Object.entries(servers)
446
+ .filter(([, server]) => server.enabled !== false)
447
+ .map(([name, server]) => [
448
+ name,
449
+ candidateMcpForClassicMaterializer(server),
450
+ ]));
451
+ }
452
+ function candidateMcpForClassicMaterializer(server) {
453
+ return {
454
+ transport: server.transport,
455
+ command: server.command,
456
+ args: server.args?.map(candidatePublicString),
457
+ env: server.env
458
+ ? Object.fromEntries(Object.entries(server.env).map(([name, value]) => [
459
+ name,
460
+ candidatePublicString(value),
461
+ ]))
462
+ : undefined,
463
+ cwd: server.cwd,
464
+ enabled: server.enabled,
465
+ };
466
+ }
467
+ function candidateResourcesForClassicMaterializer(resources, resolvedResources) {
468
+ const convert = (ref) => {
469
+ if (ref.kind === "inline") {
470
+ return { kind: "inline", name: ref.name, content: ref.content };
471
+ }
472
+ const content = resolvedResources?.get(ref.sha256);
473
+ if (content !== undefined) {
474
+ verifyCandidateResourceBytes(ref, content, ref.name ?? ref.path);
475
+ return {
476
+ kind: "inline",
477
+ name: ref.name ?? ref.path.split("/").at(-1) ?? "resource",
478
+ content,
479
+ };
480
+ }
481
+ return {
482
+ kind: "github",
483
+ repository: `${ref.repository.owner}/${ref.repository.repo}`,
484
+ path: ref.path,
485
+ ref: ref.commit,
486
+ name: ref.name,
487
+ };
488
+ };
489
+ return {
490
+ files: resources.files?.map((mount) => ({
491
+ path: mount.path,
492
+ resource: convert(mount.resource),
493
+ executable: mount.executable,
494
+ })),
495
+ tools: resources.tools?.map(convert),
496
+ skills: resources.skills?.map(convert),
497
+ agents: resources.agents?.map(convert),
498
+ commands: resources.commands?.map(convert),
499
+ instructions: typeof resources.instructions === "string"
500
+ ? resources.instructions
501
+ : resources.instructions
502
+ ? convert(resources.instructions)
503
+ : undefined,
504
+ // The candidate wrapper owns the complete unsupported check after it adds
505
+ // dimensions that the classic materializer cannot represent.
506
+ failOnError: false,
507
+ };
508
+ }
509
+ function candidatePublicString(value) {
510
+ if (value.kind !== "public") {
511
+ throw new Error("Candidate materialization accepts public config values only");
512
+ }
513
+ return value.value;
514
+ }
515
+ function candidatePublicValue(value) {
516
+ return { kind: "public", value };
517
+ }
518
+ function candidatePublicRecord(values) {
519
+ return Object.fromEntries(Object.entries(values).map(([name, value]) => [
520
+ name,
521
+ candidatePublicValue(value),
522
+ ]));
523
+ }
524
+ function verifyCandidateInlineResources(profile) {
525
+ const resources = profile.resources;
526
+ if (!resources)
527
+ return;
528
+ const refs = [
529
+ ...(resources.files?.map((mount) => mount.resource) ?? []),
530
+ ...(resources.tools ?? []),
531
+ ...(resources.skills ?? []),
532
+ ...(resources.agents ?? []),
533
+ ...(resources.commands ?? []),
534
+ ...(typeof resources.instructions === "object"
535
+ ? [resources.instructions]
536
+ : []),
537
+ ];
538
+ for (const ref of refs) {
539
+ if (ref.kind !== "inline")
540
+ continue;
541
+ verifyCandidateResourceBytes(ref, ref.content, ref.name);
542
+ }
543
+ }
544
+ function verifyCandidateResourceBytes(ref, content, label) {
545
+ const bytes = Buffer.from(content, "utf8");
546
+ const digest = `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
547
+ if (bytes.byteLength !== ref.byteLength || digest !== ref.sha256) {
548
+ throw new Error(`Candidate resource integrity mismatch: ${label}`);
549
+ }
550
+ }
551
+ function formatUnsupported(unsupported) {
552
+ return `Unable to materialize ${unsupported.length} profile resource(s):\n${unsupported
553
+ .map(({ dimension, reason }) => `- ${dimension}: ${reason}`)
554
+ .join("\n")}`;
555
+ }
188
556
  /** Collapse multiple PlanFiles with the same relPath: top-level-merge JSON, last-wins
189
557
  * for non-JSON. settings.json contributors use disjoint top-level keys, so this unions them. */
190
558
  function mergeJsonFiles(files) {
@@ -215,6 +583,149 @@ function mergeJsonFiles(files) {
215
583
  }
216
584
  return [...byPath.values()];
217
585
  }
586
+ function normalizePlanFiles(files) {
587
+ const seen = new Set();
588
+ return files
589
+ .map((file) => {
590
+ const relPath = normalizeWorkspaceRelativePath(file.relPath);
591
+ if (seen.has(relPath)) {
592
+ throw new Error(`Duplicate workspace plan file: ${relPath}`);
593
+ }
594
+ seen.add(relPath);
595
+ if (typeof file.content !== "string") {
596
+ throw new Error(`Workspace file content must be a string: ${relPath}`);
597
+ }
598
+ return {
599
+ relPath,
600
+ content: file.content,
601
+ mode: validateFileMode(file.mode),
602
+ };
603
+ })
604
+ .sort((a, b) => compareStrings(a.relPath, b.relPath));
605
+ }
606
+ const sha256 = (value) => createHash("sha256").update(value, "utf8").digest("hex");
607
+ /**
608
+ * Hash the exact files, modes, flags, environment, and unsupported dimensions
609
+ * represented by a plan. Array order is preserved only where it changes spawn
610
+ * behavior; unordered maps and file records are canonicalized first.
611
+ */
612
+ export function hashWorkspacePlan(plan) {
613
+ const files = normalizePlanFiles(plan.files).map((file) => ({
614
+ relPath: file.relPath,
615
+ mode: file.mode,
616
+ contentSha256: sha256(file.content),
617
+ }));
618
+ const env = Object.fromEntries(Object.entries(plan.env).sort(([a], [b]) => compareStrings(a, b)));
619
+ const unsupported = [...plan.unsupported].sort((a, b) => compareStrings(`${a.dimension}\0${a.reason}`, `${b.dimension}\0${b.reason}`));
620
+ const canonical = JSON.stringify({
621
+ version: 1,
622
+ harness: canonicalHarness(plan.harness),
623
+ files,
624
+ env,
625
+ flags: [...plan.flags],
626
+ unsupported,
627
+ });
628
+ return `sha256:${sha256(canonical)}`;
629
+ }
630
+ /** Build the exact digest-free identity document required by agent-interface. */
631
+ export function agentCandidateProfilePlanMaterial(plan) {
632
+ const material = {
633
+ version: 1,
634
+ harness: canonicalHarness(plan.harness),
635
+ files: normalizePlanFiles(plan.files).map((file) => ({
636
+ relPath: file.relPath,
637
+ mode: file.mode,
638
+ contentSha256: `sha256:${sha256(file.content)}`,
639
+ })),
640
+ env: Object.fromEntries(Object.entries(plan.env).sort(([left], [right]) => compareStrings(left, right))),
641
+ flags: [...plan.flags],
642
+ unsupported: [...plan.unsupported].sort((left, right) => compareStrings(`${left.dimension}\0${left.reason}`, `${right.dimension}\0${right.reason}`)),
643
+ };
644
+ return agentCandidateProfilePlanMaterialSchema.parse(material);
645
+ }
646
+ /**
647
+ * Return the RFC 8785 UTF-8 bytes and SHA-256 identity of a candidate profile
648
+ * plan. The returned bytes are the exact bytes embedded in receipt evidence.
649
+ */
650
+ export function canonicalizeAgentCandidateProfilePlan(plan) {
651
+ const material = agentCandidateProfilePlanMaterial(plan);
652
+ const bytes = Uint8Array.from(Buffer.from(rfc8785Serialize(material), "utf8"));
653
+ const digest = `sha256:${createHash("sha256")
654
+ .update(bytes)
655
+ .digest("hex")}`;
656
+ return { material, bytes, digest };
657
+ }
658
+ /** Capture portable evidence of the exact canonical profile-plan bytes. */
659
+ export function captureAgentCandidateProfilePlan(plan) {
660
+ const canonical = canonicalizeAgentCandidateProfilePlan(plan);
661
+ const artifact = {
662
+ encoding: "base64",
663
+ content: Buffer.from(canonical.bytes).toString("base64"),
664
+ sha256: canonical.digest,
665
+ byteLength: canonical.bytes.byteLength,
666
+ };
667
+ return agentCandidateProfilePlanEvidenceSchema.parse({
668
+ schemaVersion: 1,
669
+ kind: "agent-profile-workspace-plan",
670
+ digest: canonical.digest,
671
+ material: canonical.material,
672
+ artifact,
673
+ });
674
+ }
675
+ /**
676
+ * Apply a candidate plan and return its spawn values together with captured
677
+ * canonical evidence. No caller needs to re-serialize or reinterpret the plan.
678
+ */
679
+ export function applyAgentCandidateWorkspacePlan(plan, workspaceDir, targetWorkspace) {
680
+ if (plan.unsupported.length > 0) {
681
+ throw new Error(formatUnsupported(plan.unsupported));
682
+ }
683
+ const profilePlan = captureAgentCandidateProfilePlan(plan);
684
+ const applied = applyWorkspacePlan({
685
+ harness: plan.harness,
686
+ files: plan.files,
687
+ env: Object.fromEntries(Object.entries(plan.env).map(([name, value]) => [
688
+ name,
689
+ candidatePublicString(value),
690
+ ])),
691
+ flags: plan.flags.map(candidatePublicString),
692
+ unsupported: plan.unsupported,
693
+ }, workspaceDir);
694
+ return {
695
+ profilePlan,
696
+ application: {
697
+ planDigest: profilePlan.digest,
698
+ targetWorkspace,
699
+ mountPaths: applied.written,
700
+ },
701
+ env: { ...plan.env },
702
+ flags: [...plan.flags],
703
+ unsupported: [...plan.unsupported],
704
+ };
705
+ }
706
+ function rfc8785Serialize(value) {
707
+ if (value === null || typeof value === "boolean")
708
+ return JSON.stringify(value);
709
+ if (typeof value === "string")
710
+ return JSON.stringify(value);
711
+ if (typeof value === "number") {
712
+ if (!Number.isFinite(value)) {
713
+ throw new Error("RFC 8785 cannot serialize a non-finite number");
714
+ }
715
+ return JSON.stringify(value);
716
+ }
717
+ if (Array.isArray(value)) {
718
+ return `[${value.map(rfc8785Serialize).join(",")}]`;
719
+ }
720
+ if (typeof value === "object") {
721
+ const record = value;
722
+ return `{${Object.keys(record)
723
+ .sort(compareStrings)
724
+ .map((key) => `${JSON.stringify(key)}:${rfc8785Serialize(record[key])}`)
725
+ .join(",")}}`;
726
+ }
727
+ throw new Error(`RFC 8785 cannot serialize ${typeof value}`);
728
+ }
218
729
  /**
219
730
  * Apply a WorkspacePlan to a real workspace dir: write every file (creating parent
220
731
  * dirs), and return the env + flags the caller must hand the spawn. This is the IO
@@ -222,20 +733,106 @@ function mergeJsonFiles(files) {
222
733
  * agent-dev-container box, and VB. Returns `unsupported` so the caller can log/fail.
223
734
  */
224
735
  export function applyWorkspacePlan(plan, workspaceDir) {
736
+ const workspaceRoot = resolve(workspaceDir);
737
+ const files = normalizePlanFiles(plan.files).map((file) => ({
738
+ ...file,
739
+ absPath: resolveWorkspaceFilePath(workspaceRoot, file.relPath),
740
+ }));
741
+ // Validate the complete plan before the first write so a late traversal or
742
+ // existing symlink cannot leave a partially materialized workspace.
743
+ for (const file of files) {
744
+ assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
745
+ }
746
+ for (const file of files) {
747
+ mkdirSync(dirname(file.absPath), { recursive: true });
748
+ }
749
+ for (const file of files) {
750
+ assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
751
+ }
225
752
  const written = [];
226
- for (const f of plan.files) {
227
- const abs = join(workspaceDir, f.relPath);
228
- mkdirSync(dirname(abs), { recursive: true });
229
- writeFileSync(abs, f.content, { mode: f.mode ?? 0o644 });
230
- written.push(f.relPath);
753
+ for (const file of files) {
754
+ writeWorkspaceFile(workspaceRoot, file);
755
+ written.push(file.relPath);
231
756
  }
232
757
  return {
758
+ workspacePlanDigest: hashWorkspacePlan(plan),
233
759
  env: plan.env,
234
760
  flags: plan.flags,
235
761
  unsupported: plan.unsupported,
236
762
  written,
237
763
  };
238
764
  }
765
+ function resolveWorkspaceFilePath(workspaceRoot, relativePath) {
766
+ const targetPath = resolve(workspaceRoot, ...relativePath.split("/"));
767
+ const targetRelativePath = relative(workspaceRoot, targetPath);
768
+ if (!targetRelativePath ||
769
+ targetRelativePath === ".." ||
770
+ targetRelativePath.startsWith(`..${sep}`) ||
771
+ isAbsolute(targetRelativePath)) {
772
+ throw new Error(`Refusing to materialize profile file outside workspace: ${relativePath}`);
773
+ }
774
+ return targetPath;
775
+ }
776
+ function assertNoSymlinkPathComponents(workspaceRoot, targetPath) {
777
+ const targetRelativePath = relative(workspaceRoot, targetPath);
778
+ let currentPath = workspaceRoot;
779
+ const components = ["", ...targetRelativePath.split(sep)];
780
+ for (const [index, component] of components.entries()) {
781
+ if (component)
782
+ currentPath = join(currentPath, component);
783
+ let stats;
784
+ try {
785
+ stats = lstatSync(currentPath);
786
+ }
787
+ catch (error) {
788
+ if (isFsError(error, "ENOENT"))
789
+ return;
790
+ if (isFsError(error, "ELOOP")) {
791
+ throw new Error(`Refusing to materialize profile file through symlink path: ${targetRelativePath}`);
792
+ }
793
+ throw error;
794
+ }
795
+ if (stats.isSymbolicLink()) {
796
+ throw new Error(`Refusing to materialize profile file through symlink path: ${targetRelativePath}`);
797
+ }
798
+ const isTarget = index === components.length - 1;
799
+ if ((!isTarget && !stats.isDirectory()) || (isTarget && !stats.isFile())) {
800
+ throw new Error(`Refusing to materialize profile file through non-file path: ${targetRelativePath}`);
801
+ }
802
+ }
803
+ }
804
+ function writeWorkspaceFile(workspaceRoot, file) {
805
+ assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
806
+ const temporaryPath = join(dirname(file.absPath), `.agent-profile-${randomUUID()}.tmp`);
807
+ let descriptor;
808
+ try {
809
+ descriptor = openSync(temporaryPath, SAFE_WRITE_FLAGS, 0o600);
810
+ writeFileSync(descriptor, file.content, "utf8");
811
+ fchmodSync(descriptor, file.mode);
812
+ closeSync(descriptor);
813
+ descriptor = undefined;
814
+ assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
815
+ renameSync(temporaryPath, file.absPath);
816
+ }
817
+ catch (error) {
818
+ if (descriptor !== undefined)
819
+ closeSync(descriptor);
820
+ try {
821
+ unlinkSync(temporaryPath);
822
+ }
823
+ catch (cleanupError) {
824
+ if (!isFsError(cleanupError, "ENOENT"))
825
+ throw cleanupError;
826
+ }
827
+ throw error;
828
+ }
829
+ }
830
+ function isFsError(error, code) {
831
+ return (typeof error === "object" &&
832
+ error !== null &&
833
+ "code" in error &&
834
+ error.code === code);
835
+ }
239
836
  // ─── Per-dimension routing (verified matrix) ────────────────────────────────
240
837
  function materializeMcp(h, mcp, plan, add, unsupported) {
241
838
  switch (h) {
@@ -257,6 +854,8 @@ function materializeMcp(h, mcp, plan, add, unsupported) {
257
854
  lines.push(`command = ${tomlStr(s.command)}`);
258
855
  if (s.args?.length)
259
856
  lines.push(`args = [${s.args.map(tomlStr).join(", ")}]`);
857
+ if (s.cwd)
858
+ lines.push(`cwd = ${tomlStr(s.cwd)}`);
260
859
  if (s.url)
261
860
  lines.push(`url = ${tomlStr(s.url)}`);
262
861
  if (s.env)
@@ -354,7 +953,7 @@ function materializeHooks(h, hooks, add, unsupported, plan) {
354
953
  // opencode hooks are PLUGINS (JS/TS), not a settings block — emit a plugin stub.
355
954
  for (const [event, cmds] of Object.entries(hooks)) {
356
955
  const body = cmds.map((c) => ` // ${event}: ${c.command}`).join("\n");
357
- add(`.opencode/plugin/vb-hooks-${event}.js`, `// auto-generated hook plugin for ${event}\nexport default async function () {\n${body}\n}\n`);
956
+ add(`.opencode/plugins/vb-hooks-${event}.js`, `// auto-generated hook plugin for ${event}\nexport default async function () {\n${body}\n}\n`);
358
957
  }
359
958
  unsupported("hooks", "opencode hooks are JS/TS plugins — generated a stub; a real handler must run the command (format differs from settings.json)");
360
959
  break;
@@ -401,7 +1000,7 @@ function materializeSubagent(h, name, sa, add, unsupported) {
401
1000
  if (sa.model)
402
1001
  fm.push(`model: ${sa.model}`);
403
1002
  fm.push("---", "", sa.prompt ?? "");
404
- add(`.opencode/agent/${name}.md`, `${fm.join("\n")}\n`);
1003
+ add(`.opencode/agents/${name}.md`, `${fm.join("\n")}\n`);
405
1004
  break;
406
1005
  }
407
1006
  case "gemini":
@@ -429,7 +1028,7 @@ function materializeCommand(h, name, body, add, unsupported) {
429
1028
  add(`.claude/commands/${name}.md`, body.endsWith("\n") ? body : `${body}\n`);
430
1029
  break;
431
1030
  case "opencode":
432
- add(`.opencode/command/${name}.md`, body.endsWith("\n") ? body : `${body}\n`);
1031
+ add(`.opencode/commands/${name}.md`, body.endsWith("\n") ? body : `${body}\n`);
433
1032
  break;
434
1033
  case "gemini":
435
1034
  add(`.gemini/commands/${name}.toml`, `prompt = ${tomlStr(body)}\n`);
package/package.json CHANGED
@@ -1,29 +1,43 @@
1
1
  {
2
2
  "name": "@tangle-network/agent-profile-materialize",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
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
- "main": "./dist/index.js",
7
- "types": "./dist/index.d.ts",
6
+ "main": "dist/index.js",
7
+ "types": "src/index.ts",
8
8
  "exports": {
9
9
  ".": {
10
10
  "import": "./dist/index.js",
11
- "types": "./dist/index.d.ts",
12
- "default": "./dist/index.js"
11
+ "types": "./src/index.ts"
12
+ }
13
+ },
14
+ "publishConfig": {
15
+ "main": "./dist/index.js",
16
+ "types": "./dist/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "import": "./dist/index.js",
20
+ "types": "./dist/index.d.ts",
21
+ "default": "./dist/index.js"
22
+ }
13
23
  }
14
24
  },
15
25
  "files": [
16
26
  "dist"
17
27
  ],
28
+ "scripts": {
29
+ "build": "tsc -p tsconfig.json",
30
+ "clean": "rm -rf dist",
31
+ "prepare": "pnpm run build",
32
+ "test": "vitest run",
33
+ "test:watch": "vitest"
34
+ },
18
35
  "dependencies": {
19
- "@tangle-network/agent-interface": "^0.18.0"
36
+ "@tangle-network/agent-interface": "^0.22.0"
20
37
  },
21
38
  "devDependencies": {
22
- "@types/node": "25.6.0",
23
- "typescript": "^6.0.3"
24
- },
25
- "scripts": {
26
- "build": "tsc -p tsconfig.json",
27
- "clean": "rm -rf dist"
39
+ "@types/node": "catalog:",
40
+ "typescript": "^6.0.3",
41
+ "vitest": "^4.1.5"
28
42
  }
29
- }
43
+ }
package/LICENSE DELETED
@@ -1,11 +0,0 @@
1
- Copyright (c) 2025 Tangle Network
2
-
3
- All rights reserved.
4
-
5
- This software and associated documentation files (the "Software") are proprietary
6
- and confidential. No part of this Software may be reproduced, distributed, or
7
- transmitted in any form or by any means, including photocopying, recording, or
8
- other electronic or mechanical methods, without the prior written permission of
9
- Tangle Network.
10
-
11
- For licensing inquiries, contact: hello@tangle.tools