@tangle-network/agent-profile-materialize 0.15.2 → 0.16.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/candidate-types.d.ts +32 -0
- package/dist/candidate-types.js +1 -0
- package/dist/candidate.d.ts +9 -0
- package/dist/candidate.js +279 -0
- package/dist/commands.d.ts +2 -0
- package/dist/commands.js +32 -0
- package/dist/control-maps.d.ts +34 -0
- package/dist/control-maps.js +185 -0
- package/dist/execution-controls.d.ts +5 -0
- package/dist/execution-controls.js +186 -0
- package/dist/harness.d.ts +81 -0
- package/dist/harness.js +167 -0
- package/dist/hooks.d.ts +6 -0
- package/dist/hooks.js +261 -0
- package/dist/index.d.ts +23 -623
- package/dist/index.js +14 -3733
- package/dist/mcp.d.ts +22 -0
- package/dist/mcp.js +244 -0
- package/dist/plan-application.d.ts +10 -0
- package/dist/plan-application.js +152 -0
- package/dist/plan-digest.d.ts +18 -0
- package/dist/plan-digest.js +90 -0
- package/dist/plan-json.d.ts +18 -0
- package/dist/plan-json.js +91 -0
- package/dist/plan-normalize.d.ts +13 -0
- package/dist/plan-normalize.js +156 -0
- package/dist/plan-prompt.d.ts +38 -0
- package/dist/plan-prompt.js +49 -0
- package/dist/plan-support.d.ts +9 -0
- package/dist/plan-support.js +22 -0
- package/dist/plan-types.d.ts +294 -0
- package/dist/plan-types.js +106 -0
- package/dist/prime-agent-dir.d.ts +47 -0
- package/dist/prime-agent-dir.js +164 -0
- package/dist/prime-profile.d.ts +88 -0
- package/dist/prime-profile.js +284 -0
- package/dist/profile-materializer.d.ts +12 -0
- package/dist/profile-materializer.js +59 -0
- package/dist/profile-plan.d.ts +14 -0
- package/dist/profile-plan.js +86 -0
- package/dist/profile-prompt.d.ts +4 -0
- package/dist/profile-prompt.js +105 -0
- package/dist/profile-support.d.ts +8 -0
- package/dist/profile-support.js +6 -0
- package/dist/resource-lowering.d.ts +7 -0
- package/dist/resource-lowering.js +103 -0
- package/dist/resource-resolution.d.ts +27 -0
- package/dist/resource-resolution.js +241 -0
- package/dist/subagents.d.ts +6 -0
- package/dist/subagents.js +133 -0
- package/dist/workspace-apply.d.ts +12 -0
- package/dist/workspace-apply.js +334 -0
- package/dist/workspace-files.d.ts +8 -0
- package/dist/workspace-files.js +46 -0
- package/package.json +3 -3
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
export function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDir, options, publicPlanDigest = hashWorkspacePlan(publicPlan)) {
|
|
2
|
+
const workspaceRoot = resolve(workspaceDir);
|
|
3
|
+
const runtimeOnlyFiles = new Set((options.runtimeOnlyFiles ?? []).map(normalizeWorkspaceRelativePath));
|
|
4
|
+
const existingFiles = options.existingFiles ?? "allow-identical";
|
|
5
|
+
if (existingFiles !== "allow-identical" && existingFiles !== "reject") {
|
|
6
|
+
throw new Error(`Unsupported existing workspace file policy: ${existingFiles}`);
|
|
7
|
+
}
|
|
8
|
+
const resolvedFiles = resolvedPlan.files;
|
|
9
|
+
const privateRuntimeFiles = Object.fromEntries(resolvedFiles
|
|
10
|
+
.filter((file) => runtimeOnlyFiles.has(file.relPath))
|
|
11
|
+
.map((file) => [file.relPath, file.content]));
|
|
12
|
+
const files = resolvedFiles
|
|
13
|
+
.filter((file) => !runtimeOnlyFiles.has(file.relPath))
|
|
14
|
+
.map((file) => ({
|
|
15
|
+
...file,
|
|
16
|
+
absPath: resolveWorkspaceFilePath(workspaceRoot, file.relPath),
|
|
17
|
+
}));
|
|
18
|
+
// A prior application of THIS EXACT plan makes the workspace the running
|
|
19
|
+
// agent's live state: a file the agent edited or removed since then is the
|
|
20
|
+
// session's own memory, not a conflict, so a byte comparison against the
|
|
21
|
+
// plan would refuse a resume the caller proved is unchanged. The digest the
|
|
22
|
+
// caller passes is its own record of what it already applied here; only the
|
|
23
|
+
// caller can know that, which is why this is an option and not an inference
|
|
24
|
+
// from disk. `reject` is not short-circuited: that policy asserts an empty
|
|
25
|
+
// target and must keep failing on any pre-existing path.
|
|
26
|
+
if (existingFiles === "allow-identical" &&
|
|
27
|
+
options.appliedPlanDigest !== undefined &&
|
|
28
|
+
options.appliedPlanDigest === publicPlanDigest) {
|
|
29
|
+
return {
|
|
30
|
+
receipt: safeWorkspacePlanReceipt(publicPlan, files.map((file) => file.relPath), publicPlanDigest),
|
|
31
|
+
spawn: {
|
|
32
|
+
env: { ...resolvedPlan.env },
|
|
33
|
+
flags: [...resolvedPlan.flags],
|
|
34
|
+
...promptIntentFields(resolvedPlan, samePromptIntentValue),
|
|
35
|
+
...systemPromptBindingField(resolvedPlan),
|
|
36
|
+
},
|
|
37
|
+
runtimeOnlyFiles: privateRuntimeFiles,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
// A path this package invented can only hold this package's own earlier
|
|
41
|
+
// output, so re-applying into the same workspace replaces it rather than
|
|
42
|
+
// failing the turn. `reject` still refuses: that policy is "nothing may
|
|
43
|
+
// pre-exist here", which is a statement about the workspace, not authorship.
|
|
44
|
+
const mayReplace = (file) => existingFiles === "allow-identical" && file.ownership === "materializer";
|
|
45
|
+
// Validate the complete plan before the first write so a late traversal or
|
|
46
|
+
// existing symlink cannot leave a partially materialized workspace.
|
|
47
|
+
for (const file of files) {
|
|
48
|
+
assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
|
|
49
|
+
if (workspaceFileExists(file.absPath) &&
|
|
50
|
+
!mayReplace(file) &&
|
|
51
|
+
(existingFiles === "reject" || !workspaceFileMatches(file))) {
|
|
52
|
+
throw new Error(workspaceFileConflictMessage(file, existingFiles, publicPlanDigest));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Parent directories remain on failure because they may contain concurrent
|
|
56
|
+
// workspace state that this call does not own.
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
mkdirSync(dirname(file.absPath), { recursive: true });
|
|
59
|
+
}
|
|
60
|
+
for (const file of files) {
|
|
61
|
+
assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
|
|
62
|
+
}
|
|
63
|
+
const prepared = [];
|
|
64
|
+
// Prepared temp links stay alive through publication and rollback. Their
|
|
65
|
+
// inodes distinguish files created here from concurrent replacements.
|
|
66
|
+
try {
|
|
67
|
+
for (const file of files) {
|
|
68
|
+
if (existingFiles === "allow-identical" &&
|
|
69
|
+
workspaceFileExists(file.absPath) &&
|
|
70
|
+
workspaceFileMatches(file)) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
prepared.push(prepareWorkspaceFile(workspaceRoot, file));
|
|
74
|
+
}
|
|
75
|
+
for (const entry of prepared) {
|
|
76
|
+
assertNoSymlinkPathComponents(workspaceRoot, entry.file.absPath);
|
|
77
|
+
try {
|
|
78
|
+
linkSync(entry.temporaryPath, entry.file.absPath);
|
|
79
|
+
entry.published = true;
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (isFsError(error, "EEXIST") &&
|
|
83
|
+
existingFiles === "allow-identical" &&
|
|
84
|
+
workspaceFileMatches(entry.file)) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (isFsError(error, "EEXIST") && mayReplace(entry.file)) {
|
|
88
|
+
// rename() replaces in one step, so no window exists in which the
|
|
89
|
+
// path is missing and a concurrent spawn reads nothing. The staged
|
|
90
|
+
// inode moves onto the path, which is what rollback identifies the
|
|
91
|
+
// file by, so an abort still removes exactly what this call
|
|
92
|
+
// published — leaving the path ABSENT rather than restored to the
|
|
93
|
+
// superseded bytes. That is the fail-closed direction: the harness
|
|
94
|
+
// whose flag names a missing file refuses to start, whereas silently
|
|
95
|
+
// restored previous-turn bytes would run the wrong prompt.
|
|
96
|
+
assertNoSymlinkPathComponents(workspaceRoot, entry.file.absPath);
|
|
97
|
+
renameSync(entry.temporaryPath, entry.file.absPath);
|
|
98
|
+
entry.published = true;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (isFsError(error, "EEXIST")) {
|
|
102
|
+
throw new Error(workspaceFileConflictMessage(entry.file, existingFiles, publicPlanDigest), { cause: error });
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
const cleanupErrors = [];
|
|
110
|
+
for (const entry of [...prepared].reverse()) {
|
|
111
|
+
try {
|
|
112
|
+
if (entry.published &&
|
|
113
|
+
workspacePathHasIdentity(entry.file.absPath, entry.identity)) {
|
|
114
|
+
unlinkSync(entry.file.absPath);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch (cleanupError) {
|
|
118
|
+
cleanupErrors.push(cleanupError);
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
if (workspacePathHasIdentity(entry.temporaryPath, entry.identity)) {
|
|
122
|
+
unlinkSync(entry.temporaryPath);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch (cleanupError) {
|
|
126
|
+
cleanupErrors.push(cleanupError);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (cleanupErrors.length > 0) {
|
|
130
|
+
throw new AggregateError([error, ...cleanupErrors], "Failed to apply workspace plan and roll back published files");
|
|
131
|
+
}
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
const receipt = safeWorkspacePlanReceipt(publicPlan, files.map((file) => file.relPath), publicPlanDigest);
|
|
135
|
+
const cleanupErrors = [];
|
|
136
|
+
const temporaryPaths = [];
|
|
137
|
+
for (const entry of prepared) {
|
|
138
|
+
try {
|
|
139
|
+
unlinkSync(entry.temporaryPath);
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (!isFsError(error, "ENOENT")) {
|
|
143
|
+
cleanupErrors.push(error);
|
|
144
|
+
temporaryPaths.push(entry.temporaryPath);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (cleanupErrors.length > 0) {
|
|
149
|
+
throw new WorkspacePlanCleanupError(cleanupErrors, receipt, temporaryPaths);
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
receipt,
|
|
153
|
+
spawn: {
|
|
154
|
+
env: { ...resolvedPlan.env },
|
|
155
|
+
flags: [...resolvedPlan.flags],
|
|
156
|
+
...promptIntentFields(resolvedPlan, samePromptIntentValue),
|
|
157
|
+
...systemPromptBindingField(resolvedPlan),
|
|
158
|
+
},
|
|
159
|
+
runtimeOnlyFiles: privateRuntimeFiles,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function workspaceFileExists(pathValue) {
|
|
163
|
+
try {
|
|
164
|
+
lstatSync(pathValue);
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
if (isFsError(error, "ENOENT"))
|
|
169
|
+
return false;
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function workspaceFileMatches(file) {
|
|
174
|
+
const stats = lstatSync(file.absPath);
|
|
175
|
+
return (stats.isFile() &&
|
|
176
|
+
(stats.mode & 0o777) === file.mode &&
|
|
177
|
+
readFileSync(file.absPath, "utf8") === file.content);
|
|
178
|
+
}
|
|
179
|
+
function sha256Hex(content) {
|
|
180
|
+
return createHash("sha256").update(content).digest("hex");
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* The refusal names what differs — planned versus existing content digest,
|
|
184
|
+
* mode, or file kind — plus the plan digest, so the operator can tell a
|
|
185
|
+
* mid-session profile swap from an agent-edited workspace file without
|
|
186
|
+
* reproducing the workspace. Reads here are best-effort diagnosis inside an
|
|
187
|
+
* already-failing path; an unreadable existing file is reported, not thrown
|
|
188
|
+
* over.
|
|
189
|
+
*/
|
|
190
|
+
function workspaceFileConflictMessage(file, existingFiles, planDigest) {
|
|
191
|
+
const prefix = `Refusing to replace existing workspace file: ${file.relPath}`;
|
|
192
|
+
if (existingFiles === "reject") {
|
|
193
|
+
return `${prefix} — the reject policy forbids any pre-existing plan path (plan ${planDigest})`;
|
|
194
|
+
}
|
|
195
|
+
const planned = `planned sha256:${sha256Hex(file.content)} mode ${file.mode.toString(8)}`;
|
|
196
|
+
let existing;
|
|
197
|
+
try {
|
|
198
|
+
const stats = lstatSync(file.absPath);
|
|
199
|
+
if (!stats.isFile()) {
|
|
200
|
+
existing = stats.isSymbolicLink()
|
|
201
|
+
? "existing path is a symlink, not a regular file"
|
|
202
|
+
: "existing path is not a regular file";
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
const mode = (stats.mode & 0o777).toString(8);
|
|
206
|
+
existing = `existing sha256:${sha256Hex(readFileSync(file.absPath))} mode ${mode}`;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
211
|
+
existing = `existing file unreadable: ${message}`;
|
|
212
|
+
}
|
|
213
|
+
return `${prefix} — ${planned} != ${existing} (plan ${planDigest})`;
|
|
214
|
+
}
|
|
215
|
+
function prepareWorkspaceFile(workspaceRoot, file) {
|
|
216
|
+
assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
|
|
217
|
+
const temporaryPath = join(dirname(file.absPath), `.agent-profile-${randomUUID()}.tmp`);
|
|
218
|
+
let descriptor;
|
|
219
|
+
try {
|
|
220
|
+
descriptor = openSync(temporaryPath, SAFE_WRITE_FLAGS, 0o600);
|
|
221
|
+
writeFileSync(descriptor, file.content, "utf8");
|
|
222
|
+
fchmodSync(descriptor, file.mode);
|
|
223
|
+
const stats = fstatSync(descriptor);
|
|
224
|
+
const descriptorToClose = descriptor;
|
|
225
|
+
descriptor = undefined;
|
|
226
|
+
closeSync(descriptorToClose);
|
|
227
|
+
return {
|
|
228
|
+
file,
|
|
229
|
+
temporaryPath,
|
|
230
|
+
identity: { device: stats.dev, inode: stats.ino },
|
|
231
|
+
published: false,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
const cleanupErrors = [];
|
|
236
|
+
if (descriptor !== undefined) {
|
|
237
|
+
try {
|
|
238
|
+
closeSync(descriptor);
|
|
239
|
+
}
|
|
240
|
+
catch (cleanupError) {
|
|
241
|
+
cleanupErrors.push(cleanupError);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
unlinkSync(temporaryPath);
|
|
246
|
+
}
|
|
247
|
+
catch (cleanupError) {
|
|
248
|
+
if (!isFsError(cleanupError, "ENOENT"))
|
|
249
|
+
cleanupErrors.push(cleanupError);
|
|
250
|
+
}
|
|
251
|
+
if (cleanupErrors.length > 0) {
|
|
252
|
+
throw new AggregateError([error, ...cleanupErrors], `Failed to prepare workspace file: ${file.relPath}`);
|
|
253
|
+
}
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function workspacePathHasIdentity(pathValue, identity) {
|
|
258
|
+
try {
|
|
259
|
+
const stats = lstatSync(pathValue);
|
|
260
|
+
return stats.dev === identity.device && stats.ino === identity.inode;
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
if (isFsError(error, "ENOENT"))
|
|
264
|
+
return false;
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function resolveWorkspaceFilePath(workspaceRoot, relativePath) {
|
|
269
|
+
const targetPath = resolve(workspaceRoot, ...relativePath.split("/"));
|
|
270
|
+
const targetRelativePath = relative(workspaceRoot, targetPath);
|
|
271
|
+
if (!targetRelativePath ||
|
|
272
|
+
targetRelativePath === ".." ||
|
|
273
|
+
targetRelativePath.startsWith(`..${sep}`) ||
|
|
274
|
+
isAbsolute(targetRelativePath)) {
|
|
275
|
+
throw new Error(`Refusing to materialize profile file outside workspace: ${relativePath}`);
|
|
276
|
+
}
|
|
277
|
+
return targetPath;
|
|
278
|
+
}
|
|
279
|
+
function assertNoSymlinkPathComponents(workspaceRoot, targetPath) {
|
|
280
|
+
const targetRelativePath = relative(workspaceRoot, targetPath);
|
|
281
|
+
let currentPath = workspaceRoot;
|
|
282
|
+
const components = ["", ...targetRelativePath.split(sep)];
|
|
283
|
+
for (const [index, component] of components.entries()) {
|
|
284
|
+
if (component)
|
|
285
|
+
currentPath = join(currentPath, component);
|
|
286
|
+
let stats;
|
|
287
|
+
try {
|
|
288
|
+
stats = lstatSync(currentPath);
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
if (isFsError(error, "ENOENT"))
|
|
292
|
+
return;
|
|
293
|
+
if (isFsError(error, "ELOOP")) {
|
|
294
|
+
throw new Error(`Refusing to materialize profile file through symlink path: ${targetRelativePath}`);
|
|
295
|
+
}
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
298
|
+
if (stats.isSymbolicLink()) {
|
|
299
|
+
throw new Error(`Refusing to materialize profile file through symlink path: ${targetRelativePath}`);
|
|
300
|
+
}
|
|
301
|
+
const isTarget = index === components.length - 1;
|
|
302
|
+
if ((!isTarget && !stats.isDirectory()) || (isTarget && !stats.isFile())) {
|
|
303
|
+
throw new Error(`Refusing to materialize profile file through non-file path: ${targetRelativePath}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
function isFsError(error, code) {
|
|
308
|
+
return (typeof error === "object" &&
|
|
309
|
+
error !== null &&
|
|
310
|
+
"code" in error &&
|
|
311
|
+
error.code === code);
|
|
312
|
+
}
|
|
313
|
+
export function safeWorkspacePlanReceipt(plan, written, workspacePlanDigest = hashWorkspacePlan(plan)) {
|
|
314
|
+
return {
|
|
315
|
+
workspacePlanDigest,
|
|
316
|
+
env: Object.fromEntries(Object.entries(plan.env).map(([name, value]) => [
|
|
317
|
+
name,
|
|
318
|
+
typeof value === "string" ? value : { ...value },
|
|
319
|
+
])),
|
|
320
|
+
flags: plan.flags.map(normalizeWorkspacePlanArgument),
|
|
321
|
+
...promptIntentFields(plan, samePromptIntentValue),
|
|
322
|
+
...systemPromptBindingField(plan),
|
|
323
|
+
unsupported: plan.unsupported.map((entry) => ({ ...entry })),
|
|
324
|
+
written: [...written],
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
328
|
+
import { closeSync, fchmodSync, fstatSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
329
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
330
|
+
import { hashWorkspacePlan } from "./plan-digest.js";
|
|
331
|
+
import { normalizeWorkspacePlanArgument, } from "./plan-normalize.js";
|
|
332
|
+
import { promptIntentFields, samePromptIntentValue, systemPromptBindingField, } from "./plan-prompt.js";
|
|
333
|
+
import { WorkspacePlanCleanupError } from "./plan-types.js";
|
|
334
|
+
import { normalizeWorkspaceRelativePath, SAFE_WRITE_FLAGS, } from "./workspace-files.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const compareStrings: (a: string, b: string) => 0 | 1 | -1;
|
|
2
|
+
export declare const DEFAULT_FILE_MODE = 420;
|
|
3
|
+
export declare const PRIVATE_FILE_MODE = 384;
|
|
4
|
+
export declare const EXECUTABLE_FILE_MODE = 493;
|
|
5
|
+
export declare const SAFE_WRITE_FLAGS: number;
|
|
6
|
+
export declare function normalizeWorkspaceRelativePath(pathValue: string): string;
|
|
7
|
+
export declare function hasControlCharacter(value: string): boolean;
|
|
8
|
+
export declare function validateFileMode(mode: number | undefined): number;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export const compareStrings = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
2
|
+
export const DEFAULT_FILE_MODE = 0o644;
|
|
3
|
+
export const PRIVATE_FILE_MODE = 0o600;
|
|
4
|
+
export const EXECUTABLE_FILE_MODE = 0o755;
|
|
5
|
+
export const SAFE_WRITE_FLAGS = fsConstants.O_WRONLY |
|
|
6
|
+
fsConstants.O_CREAT |
|
|
7
|
+
fsConstants.O_EXCL |
|
|
8
|
+
(typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0);
|
|
9
|
+
export function normalizeWorkspaceRelativePath(pathValue) {
|
|
10
|
+
if (typeof pathValue !== "string" ||
|
|
11
|
+
!pathValue ||
|
|
12
|
+
hasControlCharacter(pathValue)) {
|
|
13
|
+
throw new Error("Workspace file path must be non-empty and contain no control characters");
|
|
14
|
+
}
|
|
15
|
+
if (isAbsolute(pathValue) ||
|
|
16
|
+
win32.isAbsolute(pathValue) ||
|
|
17
|
+
/^[A-Za-z]:/.test(pathValue)) {
|
|
18
|
+
throw new Error(`Workspace file path must be relative: ${pathValue}`);
|
|
19
|
+
}
|
|
20
|
+
const segments = pathValue.replace(/\\/g, "/").split("/");
|
|
21
|
+
if (segments.some((segment) => !segment ||
|
|
22
|
+
segment === "." ||
|
|
23
|
+
segment === ".." ||
|
|
24
|
+
segment === ".git" ||
|
|
25
|
+
segment === ".sidecar")) {
|
|
26
|
+
throw new Error(`Workspace file path must be canonical: ${pathValue}`);
|
|
27
|
+
}
|
|
28
|
+
return segments.join("/");
|
|
29
|
+
}
|
|
30
|
+
export function hasControlCharacter(value) {
|
|
31
|
+
for (let index = 0; index < value.length; index++) {
|
|
32
|
+
const code = value.charCodeAt(index);
|
|
33
|
+
if (code < 0x20 || code === 0x7f)
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
export function validateFileMode(mode) {
|
|
39
|
+
const normalized = mode ?? DEFAULT_FILE_MODE;
|
|
40
|
+
if (!Number.isInteger(normalized) || normalized < 0 || normalized > 0o777) {
|
|
41
|
+
throw new Error(`Workspace file mode must be between 0o000 and 0o777: ${mode}`);
|
|
42
|
+
}
|
|
43
|
+
return normalized;
|
|
44
|
+
}
|
|
45
|
+
import { constants as fsConstants } from "node:fs";
|
|
46
|
+
import { isAbsolute, win32 } from "node:path";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tangle-network/agent-profile-materialize",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Materialize an AgentProfile into the native per-harness workspace files (the one shared materializer for cli-bridge, agent-dev-container, and VB).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -20,13 +20,13 @@
|
|
|
20
20
|
"dist"
|
|
21
21
|
],
|
|
22
22
|
"devDependencies": {
|
|
23
|
-
"@tangle-network/agent-interface": "0.
|
|
23
|
+
"@tangle-network/agent-interface": "1.0.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": "
|
|
29
|
+
"@tangle-network/agent-interface": "^1.0.0"
|
|
30
30
|
},
|
|
31
31
|
"scripts": {
|
|
32
32
|
"build": "tsc -p tsconfig.json",
|