@tangle-network/agent-profile-materialize 0.17.1 → 0.18.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 CHANGED
@@ -6,7 +6,7 @@ export type { HarnessId } from "./harness.js";
6
6
  export { HARNESS_SKILL_DIRS, harnessAutoLoadsContextFile, harnessContextFile, isMaterializerHarness, MATERIALIZER_HARNESSES, skillDirForHarness, } from "./harness.js";
7
7
  export type { OpenCodeMcpConfig } from "./mcp.js";
8
8
  export { openCodeMcpConfig } from "./mcp.js";
9
- export { modelIdsMatch, qualifyModelId } from "./model-id.js";
9
+ export { modelIdsMatch, qualifyModelId, unqualifyModelId, } from "./model-id.js";
10
10
  export { buildPiModelsConfig, buildPiModelsConfigFile, PI_MODELS_CONFIG_PATH, type PiModelsConfigFile, type PiModelsConfigInput, } from "./pi-models-config.js";
11
11
  export { applyWorkspacePlan, applyWorkspacePlanWithSecretProvider, } from "./plan-application.js";
12
12
  export { agentCandidateProfilePlanMaterial, canonicalizeAgentCandidateProfilePlan, captureAgentCandidateProfilePlan, hashWorkspacePlan, } from "./plan-digest.js";
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ export { applyAgentCandidateWorkspacePlan, materializeCandidateProfile, } from "
3
3
  export { OPENCODE_NATIVE_TOOL_IDS, openCodeNativeToolId, } from "./control-maps.js";
4
4
  export { HARNESS_SKILL_DIRS, harnessAutoLoadsContextFile, harnessContextFile, isMaterializerHarness, MATERIALIZER_HARNESSES, skillDirForHarness, } from "./harness.js";
5
5
  export { openCodeMcpConfig } from "./mcp.js";
6
- export { modelIdsMatch, qualifyModelId } from "./model-id.js";
6
+ export { modelIdsMatch, qualifyModelId, unqualifyModelId, } from "./model-id.js";
7
7
  export { buildPiModelsConfig, buildPiModelsConfigFile, PI_MODELS_CONFIG_PATH, } from "./pi-models-config.js";
8
8
  export { applyWorkspacePlan, applyWorkspacePlanWithSecretProvider, } from "./plan-application.js";
9
9
  export { agentCandidateProfilePlanMaterial, canonicalizeAgentCandidateProfilePlan, captureAgentCandidateProfilePlan, hashWorkspacePlan, } from "./plan-digest.js";
@@ -26,6 +26,19 @@
26
26
  * `qualifyModelId(p, qualifyModelId(p, m)) === qualifyModelId(p, m)`.
27
27
  */
28
28
  export declare function qualifyModelId<T extends string | undefined>(provider: string | undefined, modelId: T): T;
29
+ /**
30
+ * Remove one exact `provider/` qualification from `modelId`.
31
+ *
32
+ * This is the inverse operation for a model id that was qualified by this
33
+ * provider. It removes only the known outer prefix and preserves upstream
34
+ * segments such as `zai/glm-4.7`.
35
+ *
36
+ * The helper removes one outer qualification per call. It leaves missing
37
+ * providers and model ids unchanged.
38
+ */
39
+ export declare function unqualifyModelId(provider: string | undefined, modelId: string): string;
40
+ export declare function unqualifyModelId(provider: string | undefined, modelId: undefined): undefined;
41
+ export declare function unqualifyModelId(provider: string | undefined, modelId: string | undefined): string | undefined;
29
42
  /**
30
43
  * Do two model ids name the same model?
31
44
  *
package/dist/model-id.js CHANGED
@@ -32,6 +32,14 @@ export function qualifyModelId(provider, modelId) {
32
32
  return modelId;
33
33
  return `${provider}/${modelId}`;
34
34
  }
35
+ export function unqualifyModelId(provider, modelId) {
36
+ if (!provider || !modelId)
37
+ return modelId;
38
+ const providerPrefix = `${provider}/`;
39
+ return modelId.startsWith(providerPrefix)
40
+ ? modelId.slice(providerPrefix.length)
41
+ : modelId;
42
+ }
35
43
  /**
36
44
  * Do two model ids name the same model?
37
45
  *
@@ -281,16 +281,21 @@ export declare class WorkspacePlanCleanupError extends AggregateError {
281
281
  export interface ApplyWorkspacePlanOptions {
282
282
  /** Plan files consumed in memory by the runtime and never persisted to cwd. */
283
283
  runtimeOnlyFiles?: readonly string[];
284
+ /** Own newly written workspace files as the process that will read them. */
285
+ ownership?: {
286
+ uid: number;
287
+ gid: number;
288
+ };
284
289
  /** Existing workspace paths are either exact idempotent inputs or forbidden. */
285
290
  existingFiles?: "allow-identical" | "reject";
286
291
  /**
287
292
  * `workspacePlanDigest` of a plan a prior call already applied into this
288
- * workspace. When it equals this plan's digest, application writes no files:
289
- * the workspace holds the running agent's live state, and a file the agent
290
- * edited or removed since the prior application stays exactly as the agent
291
- * left it. Env, flags, prompt intents, and the receipt are still produced.
292
- * A different digest applies normally, so a plan that changed mid-session
293
- * still meets the existing-file refusal.
293
+ * workspace. When it equals this plan's digest, application changes no file
294
+ * content. It can repair ownership on unchanged materializer-owned files.
295
+ * A file the agent edited or removed stays exactly as the agent left it.
296
+ * Env, flags, prompt intents, and the receipt are still produced. A different
297
+ * digest applies normally, so a changed plan still meets the existing-file
298
+ * refusal.
294
299
  */
295
300
  appliedPlanDigest?: string;
296
301
  }
@@ -0,0 +1,50 @@
1
+ declare function workspaceFileExists(pathValue: string): boolean;
2
+ declare function regularFileContent(pathValue: string): string | undefined;
3
+ declare function workspaceFileMatches(file: NormalizedPlanFile & {
4
+ absPath: string;
5
+ }): boolean;
6
+ declare function workspaceFileHasOwnership(pathValue: string, ownership: {
7
+ uid: number;
8
+ gid: number;
9
+ }): boolean;
10
+ declare function validateWorkspaceOwnership(ownership: ApplyWorkspacePlanOptions["ownership"]): {
11
+ uid: number;
12
+ gid: number;
13
+ } | undefined;
14
+ /**
15
+ * The refusal names what differs — planned versus existing content digest,
16
+ * mode, or file kind — plus the plan digest, so the operator can tell a
17
+ * mid-session profile swap from an agent-edited workspace file without
18
+ * reproducing the workspace. Reads here are best-effort diagnosis inside an
19
+ * already-failing path; an unreadable existing file is reported, not thrown
20
+ * over.
21
+ */
22
+ declare function workspaceFileConflictMessage(file: NormalizedPlanFile & {
23
+ absPath: string;
24
+ }, existingFiles: "allow-identical" | "reject", planDigest: string): string;
25
+ interface WorkspaceFileIdentity {
26
+ device: number;
27
+ inode: number;
28
+ }
29
+ interface PreparedWorkspaceFile {
30
+ file: NormalizedPlanFile & {
31
+ absPath: string;
32
+ };
33
+ temporaryPath: string;
34
+ identity: WorkspaceFileIdentity;
35
+ published: boolean;
36
+ }
37
+ declare function prepareWorkspaceFile(workspaceRoot: string, file: NormalizedPlanFile & {
38
+ absPath: string;
39
+ }, ownership: {
40
+ uid: number;
41
+ gid: number;
42
+ } | undefined): PreparedWorkspaceFile;
43
+ declare function workspacePathHasIdentity(pathValue: string, identity: WorkspaceFileIdentity): boolean;
44
+ declare function resolveWorkspaceFilePath(workspaceRoot: string, relativePath: string): string;
45
+ declare function assertNoSymlinkPathComponents(workspaceRoot: string, targetPath: string): void;
46
+ declare function isFsError(error: unknown, code: string): error is NodeJS.ErrnoException;
47
+ export type { PreparedWorkspaceFile };
48
+ export { assertNoSymlinkPathComponents, isFsError, prepareWorkspaceFile, regularFileContent, resolveWorkspaceFilePath, validateWorkspaceOwnership, workspaceFileConflictMessage, workspaceFileExists, workspaceFileHasOwnership, workspaceFileMatches, workspacePathHasIdentity, };
49
+ import type { NormalizedPlanFile } from "./plan-normalize.js";
50
+ import type { ApplyWorkspacePlanOptions } from "./plan-types.js";
@@ -0,0 +1,178 @@
1
+ function workspaceFileExists(pathValue) {
2
+ try {
3
+ lstatSync(pathValue);
4
+ return true;
5
+ }
6
+ catch (error) {
7
+ if (isFsError(error, "ENOENT"))
8
+ return false;
9
+ throw error;
10
+ }
11
+ }
12
+ function regularFileContent(pathValue) {
13
+ return lstatSync(pathValue).isFile()
14
+ ? readFileSync(pathValue, "utf8")
15
+ : undefined;
16
+ }
17
+ function workspaceFileMatches(file) {
18
+ const stats = lstatSync(file.absPath);
19
+ return (stats.isFile() &&
20
+ (stats.mode & 0o777) === file.mode &&
21
+ readFileSync(file.absPath, "utf8") === file.content);
22
+ }
23
+ function workspaceFileHasOwnership(pathValue, ownership) {
24
+ const stats = lstatSync(pathValue);
25
+ return (stats.isFile() && stats.uid === ownership.uid && stats.gid === ownership.gid);
26
+ }
27
+ function validateWorkspaceOwnership(ownership) {
28
+ if (ownership === undefined)
29
+ return undefined;
30
+ if (!Number.isInteger(ownership.uid) ||
31
+ ownership.uid < 0 ||
32
+ !Number.isInteger(ownership.gid) ||
33
+ ownership.gid < 0) {
34
+ throw new Error("Workspace file ownership must use non-negative integer uid/gid values");
35
+ }
36
+ return ownership;
37
+ }
38
+ function sha256Hex(content) {
39
+ return createHash("sha256").update(content).digest("hex");
40
+ }
41
+ /**
42
+ * The refusal names what differs — planned versus existing content digest,
43
+ * mode, or file kind — plus the plan digest, so the operator can tell a
44
+ * mid-session profile swap from an agent-edited workspace file without
45
+ * reproducing the workspace. Reads here are best-effort diagnosis inside an
46
+ * already-failing path; an unreadable existing file is reported, not thrown
47
+ * over.
48
+ */
49
+ function workspaceFileConflictMessage(file, existingFiles, planDigest) {
50
+ const prefix = `Refusing to replace existing workspace file: ${file.relPath}`;
51
+ if (existingFiles === "reject") {
52
+ return `${prefix} — the reject policy forbids any pre-existing plan path (plan ${planDigest})`;
53
+ }
54
+ const planned = `planned sha256:${sha256Hex(file.content)} mode ${file.mode.toString(8)}`;
55
+ let existing;
56
+ try {
57
+ const stats = lstatSync(file.absPath);
58
+ if (!stats.isFile()) {
59
+ existing = stats.isSymbolicLink()
60
+ ? "existing path is a symlink, not a regular file"
61
+ : "existing path is not a regular file";
62
+ }
63
+ else {
64
+ const mode = (stats.mode & 0o777).toString(8);
65
+ existing = `existing sha256:${sha256Hex(readFileSync(file.absPath))} mode ${mode}`;
66
+ }
67
+ }
68
+ catch (error) {
69
+ const message = error instanceof Error ? error.message : String(error);
70
+ existing = `existing file unreadable: ${message}`;
71
+ }
72
+ return `${prefix} — ${planned} != ${existing} (plan ${planDigest})`;
73
+ }
74
+ function prepareWorkspaceFile(workspaceRoot, file, ownership) {
75
+ assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
76
+ const temporaryPath = join(dirname(file.absPath), `.agent-profile-${randomUUID()}.tmp`);
77
+ let descriptor;
78
+ try {
79
+ descriptor = openSync(temporaryPath, SAFE_WRITE_FLAGS, 0o600);
80
+ writeFileSync(descriptor, file.content, "utf8");
81
+ if (ownership)
82
+ fchownSync(descriptor, ownership.uid, ownership.gid);
83
+ fchmodSync(descriptor, file.mode);
84
+ const stats = fstatSync(descriptor);
85
+ const descriptorToClose = descriptor;
86
+ descriptor = undefined;
87
+ closeSync(descriptorToClose);
88
+ return {
89
+ file,
90
+ temporaryPath,
91
+ identity: { device: stats.dev, inode: stats.ino },
92
+ published: false,
93
+ };
94
+ }
95
+ catch (error) {
96
+ const cleanupErrors = [];
97
+ if (descriptor !== undefined) {
98
+ try {
99
+ closeSync(descriptor);
100
+ }
101
+ catch (cleanupError) {
102
+ cleanupErrors.push(cleanupError);
103
+ }
104
+ }
105
+ try {
106
+ unlinkSync(temporaryPath);
107
+ }
108
+ catch (cleanupError) {
109
+ if (!isFsError(cleanupError, "ENOENT"))
110
+ cleanupErrors.push(cleanupError);
111
+ }
112
+ if (cleanupErrors.length > 0) {
113
+ throw new AggregateError([error, ...cleanupErrors], `Failed to prepare workspace file: ${file.relPath}`);
114
+ }
115
+ throw error;
116
+ }
117
+ }
118
+ function workspacePathHasIdentity(pathValue, identity) {
119
+ try {
120
+ const stats = lstatSync(pathValue);
121
+ return stats.dev === identity.device && stats.ino === identity.inode;
122
+ }
123
+ catch (error) {
124
+ if (isFsError(error, "ENOENT"))
125
+ return false;
126
+ throw error;
127
+ }
128
+ }
129
+ function resolveWorkspaceFilePath(workspaceRoot, relativePath) {
130
+ const targetPath = resolve(workspaceRoot, ...relativePath.split("/"));
131
+ const targetRelativePath = relative(workspaceRoot, targetPath);
132
+ if (!targetRelativePath ||
133
+ targetRelativePath === ".." ||
134
+ targetRelativePath.startsWith(`..${sep}`) ||
135
+ isAbsolute(targetRelativePath)) {
136
+ throw new Error(`Refusing to materialize profile file outside workspace: ${relativePath}`);
137
+ }
138
+ return targetPath;
139
+ }
140
+ function assertNoSymlinkPathComponents(workspaceRoot, targetPath) {
141
+ const targetRelativePath = relative(workspaceRoot, targetPath);
142
+ let currentPath = workspaceRoot;
143
+ const components = ["", ...targetRelativePath.split(sep)];
144
+ for (const [index, component] of components.entries()) {
145
+ if (component)
146
+ currentPath = join(currentPath, component);
147
+ let stats;
148
+ try {
149
+ stats = lstatSync(currentPath);
150
+ }
151
+ catch (error) {
152
+ if (isFsError(error, "ENOENT"))
153
+ return;
154
+ if (isFsError(error, "ELOOP")) {
155
+ throw new Error(`Refusing to materialize profile file through symlink path: ${targetRelativePath}`);
156
+ }
157
+ throw error;
158
+ }
159
+ if (stats.isSymbolicLink()) {
160
+ throw new Error(`Refusing to materialize profile file through symlink path: ${targetRelativePath}`);
161
+ }
162
+ const isTarget = index === components.length - 1;
163
+ if ((!isTarget && !stats.isDirectory()) || (isTarget && !stats.isFile())) {
164
+ throw new Error(`Refusing to materialize profile file through non-file path: ${targetRelativePath}`);
165
+ }
166
+ }
167
+ }
168
+ function isFsError(error, code) {
169
+ return (typeof error === "object" &&
170
+ error !== null &&
171
+ "code" in error &&
172
+ error.code === code);
173
+ }
174
+ export { assertNoSymlinkPathComponents, isFsError, prepareWorkspaceFile, regularFileContent, resolveWorkspaceFilePath, validateWorkspaceOwnership, workspaceFileConflictMessage, workspaceFileExists, workspaceFileHasOwnership, workspaceFileMatches, workspacePathHasIdentity, };
175
+ import { createHash, randomUUID } from "node:crypto";
176
+ import { closeSync, fchmodSync, fchownSync, fstatSync, lstatSync, openSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
177
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
178
+ import { SAFE_WRITE_FLAGS } from "./workspace-files.js";
@@ -1,6 +1,10 @@
1
1
  export function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDir, options, publicPlanDigest = hashWorkspacePlan(publicPlan)) {
2
2
  const workspaceRoot = resolve(workspaceDir);
3
3
  const runtimeOnlyFiles = new Set((options.runtimeOnlyFiles ?? []).map(normalizeWorkspaceRelativePath));
4
+ const ownership = validateWorkspaceOwnership(options.ownership);
5
+ const privateFilePaths = new Set(normalizePlanFiles(publicPlan.files)
6
+ .filter((file) => file.secretSlots.length > 0)
7
+ .map((file) => file.relPath));
4
8
  const existingFiles = options.existingFiles ?? "allow-identical";
5
9
  if (existingFiles !== "allow-identical" && existingFiles !== "reject") {
6
10
  throw new Error(`Unsupported existing workspace file policy: ${existingFiles}`);
@@ -15,17 +19,31 @@ export function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDi
15
19
  ...file,
16
20
  absPath: resolveWorkspaceFilePath(workspaceRoot, file.relPath),
17
21
  }));
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" &&
22
+ // A prior application of this exact plan makes the workspace live agent
23
+ // state. Preserve changed and removed files. Refresh private generated files
24
+ // because the public digest excludes secret values, and repair unchanged
25
+ // materializer files that still have the writer's ownership. `reject` does
26
+ // not use this path because it requires an empty target.
27
+ const resumeSamePlan = existingFiles === "allow-identical" &&
27
28
  options.appliedPlanDigest !== undefined &&
28
- options.appliedPlanDigest === publicPlanDigest) {
29
+ options.appliedPlanDigest === publicPlanDigest;
30
+ const filesToApply = resumeSamePlan
31
+ ? files.filter((file) => {
32
+ if (file.ownership !== "materializer")
33
+ return false;
34
+ assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
35
+ if (!workspaceFileExists(file.absPath))
36
+ return false;
37
+ const matches = workspaceFileMatches(file);
38
+ const hasCurrentOwnership = ownership === undefined ||
39
+ workspaceFileHasOwnership(file.absPath, ownership);
40
+ if (privateFilePaths.has(file.relPath)) {
41
+ return !matches || !hasCurrentOwnership;
42
+ }
43
+ return matches && !hasCurrentOwnership;
44
+ })
45
+ : files;
46
+ if (resumeSamePlan && filesToApply.length === 0) {
29
47
  return {
30
48
  receipt: safeWorkspacePlanReceipt(publicPlan, files.map((file) => file.relPath), publicPlanDigest),
31
49
  spawn: {
@@ -47,7 +65,7 @@ export function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDi
47
65
  isMaterializerOutput(file.relPath, () => regularFileContent(file.absPath));
48
66
  // Validate the complete plan before the first write so a late traversal or
49
67
  // existing symlink cannot leave a partially materialized workspace.
50
- for (const file of files) {
68
+ for (const file of filesToApply) {
51
69
  assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
52
70
  if (workspaceFileExists(file.absPath) &&
53
71
  !mayReplace(file) &&
@@ -57,23 +75,26 @@ export function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDi
57
75
  }
58
76
  // Parent directories remain on failure because they may contain concurrent
59
77
  // workspace state that this call does not own.
60
- for (const file of files) {
78
+ for (const file of filesToApply) {
61
79
  mkdirSync(dirname(file.absPath), { recursive: true });
62
80
  }
63
- for (const file of files) {
81
+ for (const file of filesToApply) {
64
82
  assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
65
83
  }
66
84
  const prepared = [];
67
85
  // Prepared temp links stay alive through publication and rollback. Their
68
86
  // inodes distinguish files created here from concurrent replacements.
69
87
  try {
70
- for (const file of files) {
88
+ for (const file of filesToApply) {
71
89
  if (existingFiles === "allow-identical" &&
72
90
  workspaceFileExists(file.absPath) &&
73
- workspaceFileMatches(file)) {
91
+ workspaceFileMatches(file) &&
92
+ (file.ownership !== "materializer" ||
93
+ ownership === undefined ||
94
+ workspaceFileHasOwnership(file.absPath, ownership))) {
74
95
  continue;
75
96
  }
76
- prepared.push(prepareWorkspaceFile(workspaceRoot, file));
97
+ prepared.push(prepareWorkspaceFile(workspaceRoot, file, ownership));
77
98
  }
78
99
  for (const entry of prepared) {
79
100
  assertNoSymlinkPathComponents(workspaceRoot, entry.file.absPath);
@@ -84,7 +105,10 @@ export function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDi
84
105
  catch (error) {
85
106
  if (isFsError(error, "EEXIST") &&
86
107
  existingFiles === "allow-identical" &&
87
- workspaceFileMatches(entry.file)) {
108
+ workspaceFileMatches(entry.file) &&
109
+ (entry.file.ownership !== "materializer" ||
110
+ ownership === undefined ||
111
+ workspaceFileHasOwnership(entry.file.absPath, ownership))) {
88
112
  continue;
89
113
  }
90
114
  if (isFsError(error, "EEXIST") && mayReplace(entry.file)) {
@@ -162,162 +186,6 @@ export function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDi
162
186
  runtimeOnlyFiles: privateRuntimeFiles,
163
187
  };
164
188
  }
165
- function workspaceFileExists(pathValue) {
166
- try {
167
- lstatSync(pathValue);
168
- return true;
169
- }
170
- catch (error) {
171
- if (isFsError(error, "ENOENT"))
172
- return false;
173
- throw error;
174
- }
175
- }
176
- function regularFileContent(pathValue) {
177
- return lstatSync(pathValue).isFile()
178
- ? readFileSync(pathValue, "utf8")
179
- : undefined;
180
- }
181
- function workspaceFileMatches(file) {
182
- const stats = lstatSync(file.absPath);
183
- return (stats.isFile() &&
184
- (stats.mode & 0o777) === file.mode &&
185
- readFileSync(file.absPath, "utf8") === file.content);
186
- }
187
- function sha256Hex(content) {
188
- return createHash("sha256").update(content).digest("hex");
189
- }
190
- /**
191
- * The refusal names what differs — planned versus existing content digest,
192
- * mode, or file kind — plus the plan digest, so the operator can tell a
193
- * mid-session profile swap from an agent-edited workspace file without
194
- * reproducing the workspace. Reads here are best-effort diagnosis inside an
195
- * already-failing path; an unreadable existing file is reported, not thrown
196
- * over.
197
- */
198
- function workspaceFileConflictMessage(file, existingFiles, planDigest) {
199
- const prefix = `Refusing to replace existing workspace file: ${file.relPath}`;
200
- if (existingFiles === "reject") {
201
- return `${prefix} — the reject policy forbids any pre-existing plan path (plan ${planDigest})`;
202
- }
203
- const planned = `planned sha256:${sha256Hex(file.content)} mode ${file.mode.toString(8)}`;
204
- let existing;
205
- try {
206
- const stats = lstatSync(file.absPath);
207
- if (!stats.isFile()) {
208
- existing = stats.isSymbolicLink()
209
- ? "existing path is a symlink, not a regular file"
210
- : "existing path is not a regular file";
211
- }
212
- else {
213
- const mode = (stats.mode & 0o777).toString(8);
214
- existing = `existing sha256:${sha256Hex(readFileSync(file.absPath))} mode ${mode}`;
215
- }
216
- }
217
- catch (error) {
218
- const message = error instanceof Error ? error.message : String(error);
219
- existing = `existing file unreadable: ${message}`;
220
- }
221
- return `${prefix} — ${planned} != ${existing} (plan ${planDigest})`;
222
- }
223
- function prepareWorkspaceFile(workspaceRoot, file) {
224
- assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
225
- const temporaryPath = join(dirname(file.absPath), `.agent-profile-${randomUUID()}.tmp`);
226
- let descriptor;
227
- try {
228
- descriptor = openSync(temporaryPath, SAFE_WRITE_FLAGS, 0o600);
229
- writeFileSync(descriptor, file.content, "utf8");
230
- fchmodSync(descriptor, file.mode);
231
- const stats = fstatSync(descriptor);
232
- const descriptorToClose = descriptor;
233
- descriptor = undefined;
234
- closeSync(descriptorToClose);
235
- return {
236
- file,
237
- temporaryPath,
238
- identity: { device: stats.dev, inode: stats.ino },
239
- published: false,
240
- };
241
- }
242
- catch (error) {
243
- const cleanupErrors = [];
244
- if (descriptor !== undefined) {
245
- try {
246
- closeSync(descriptor);
247
- }
248
- catch (cleanupError) {
249
- cleanupErrors.push(cleanupError);
250
- }
251
- }
252
- try {
253
- unlinkSync(temporaryPath);
254
- }
255
- catch (cleanupError) {
256
- if (!isFsError(cleanupError, "ENOENT"))
257
- cleanupErrors.push(cleanupError);
258
- }
259
- if (cleanupErrors.length > 0) {
260
- throw new AggregateError([error, ...cleanupErrors], `Failed to prepare workspace file: ${file.relPath}`);
261
- }
262
- throw error;
263
- }
264
- }
265
- function workspacePathHasIdentity(pathValue, identity) {
266
- try {
267
- const stats = lstatSync(pathValue);
268
- return stats.dev === identity.device && stats.ino === identity.inode;
269
- }
270
- catch (error) {
271
- if (isFsError(error, "ENOENT"))
272
- return false;
273
- throw error;
274
- }
275
- }
276
- function resolveWorkspaceFilePath(workspaceRoot, relativePath) {
277
- const targetPath = resolve(workspaceRoot, ...relativePath.split("/"));
278
- const targetRelativePath = relative(workspaceRoot, targetPath);
279
- if (!targetRelativePath ||
280
- targetRelativePath === ".." ||
281
- targetRelativePath.startsWith(`..${sep}`) ||
282
- isAbsolute(targetRelativePath)) {
283
- throw new Error(`Refusing to materialize profile file outside workspace: ${relativePath}`);
284
- }
285
- return targetPath;
286
- }
287
- function assertNoSymlinkPathComponents(workspaceRoot, targetPath) {
288
- const targetRelativePath = relative(workspaceRoot, targetPath);
289
- let currentPath = workspaceRoot;
290
- const components = ["", ...targetRelativePath.split(sep)];
291
- for (const [index, component] of components.entries()) {
292
- if (component)
293
- currentPath = join(currentPath, component);
294
- let stats;
295
- try {
296
- stats = lstatSync(currentPath);
297
- }
298
- catch (error) {
299
- if (isFsError(error, "ENOENT"))
300
- return;
301
- if (isFsError(error, "ELOOP")) {
302
- throw new Error(`Refusing to materialize profile file through symlink path: ${targetRelativePath}`);
303
- }
304
- throw error;
305
- }
306
- if (stats.isSymbolicLink()) {
307
- throw new Error(`Refusing to materialize profile file through symlink path: ${targetRelativePath}`);
308
- }
309
- const isTarget = index === components.length - 1;
310
- if ((!isTarget && !stats.isDirectory()) || (isTarget && !stats.isFile())) {
311
- throw new Error(`Refusing to materialize profile file through non-file path: ${targetRelativePath}`);
312
- }
313
- }
314
- }
315
- function isFsError(error, code) {
316
- return (typeof error === "object" &&
317
- error !== null &&
318
- "code" in error &&
319
- error.code === code);
320
- }
321
189
  export function safeWorkspacePlanReceipt(plan, written, workspacePlanDigest = hashWorkspacePlan(plan)) {
322
190
  return {
323
191
  workspacePlanDigest,
@@ -332,12 +200,12 @@ export function safeWorkspacePlanReceipt(plan, written, workspacePlanDigest = ha
332
200
  written: [...written],
333
201
  };
334
202
  }
335
- import { createHash, randomUUID } from "node:crypto";
336
- import { closeSync, fchmodSync, fstatSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
337
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
203
+ import { linkSync, mkdirSync, renameSync, unlinkSync } from "node:fs";
204
+ import { dirname, resolve } from "node:path";
338
205
  import { isMaterializerOutput } from "./harness.js";
339
206
  import { hashWorkspacePlan } from "./plan-digest.js";
340
- import { normalizeWorkspacePlanArgument, } from "./plan-normalize.js";
207
+ import { normalizePlanFiles, normalizeWorkspacePlanArgument, } from "./plan-normalize.js";
341
208
  import { promptIntentFields, samePromptIntentValue, systemPromptBindingField, } from "./plan-prompt.js";
342
209
  import { WorkspacePlanCleanupError } from "./plan-types.js";
343
- import { normalizeWorkspaceRelativePath, SAFE_WRITE_FLAGS, } from "./workspace-files.js";
210
+ import { assertNoSymlinkPathComponents, isFsError, prepareWorkspaceFile, regularFileContent, resolveWorkspaceFilePath, validateWorkspaceOwnership, workspaceFileConflictMessage, workspaceFileExists, workspaceFileHasOwnership, workspaceFileMatches, workspacePathHasIdentity, } from "./workspace-apply-files.js";
211
+ import { normalizeWorkspaceRelativePath } from "./workspace-files.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/agent-profile-materialize",
3
- "version": "0.17.1",
3
+ "version": "0.18.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": "1.0.0",
23
+ "@tangle-network/agent-interface": "2.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": "^1.0.0"
29
+ "@tangle-network/agent-interface": "^1.0.0 || ^2.0.0"
30
30
  },
31
31
  "scripts": {
32
32
  "build": "tsc -p tsconfig.json",