@tangle-network/agent-profile-materialize 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +68 -8
- package/dist/index.js +621 -22
- package/package.json +7 -4
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 {
|
|
25
|
-
import {
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
267
|
+
addExplicitResource("skills", `${dir}/${r.name}/SKILL.md`, normalizeSkillMd(r.name, r.content));
|
|
160
268
|
}
|
|
161
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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,
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
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/
|
|
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/
|
|
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/
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tangle-network/agent-profile-materialize",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -16,14 +16,17 @@
|
|
|
16
16
|
"dist"
|
|
17
17
|
],
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@tangle-network/agent-interface": "^0.
|
|
19
|
+
"@tangle-network/agent-interface": "^0.21.0"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/node": "25.6.0",
|
|
23
|
-
"typescript": "^6.0.3"
|
|
23
|
+
"typescript": "^6.0.3",
|
|
24
|
+
"vitest": "^4.1.5"
|
|
24
25
|
},
|
|
25
26
|
"scripts": {
|
|
26
27
|
"build": "tsc -p tsconfig.json",
|
|
27
|
-
"clean": "rm -rf dist"
|
|
28
|
+
"clean": "rm -rf dist",
|
|
29
|
+
"test": "vitest run",
|
|
30
|
+
"test:watch": "vitest"
|
|
28
31
|
}
|
|
29
32
|
}
|