@tangle-network/agent-profile-materialize 0.27.1 → 0.27.3

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.
@@ -80,6 +80,24 @@ export function readPrimeProfileControls(profile) {
80
80
  const controls = { skills: [], subagents: [] };
81
81
  if (!profile)
82
82
  return controls;
83
+ rejectUnboundPrimeControls(profile);
84
+ const instructionBlocks = [];
85
+ if (profile.prompt) {
86
+ readPrimePromptControls(profile.prompt, controls, instructionBlocks);
87
+ }
88
+ if (profile.resources) {
89
+ readPrimeResourceControls(profile.resources, controls, instructionBlocks);
90
+ }
91
+ if (instructionBlocks.length > 0) {
92
+ controls.instructions = instructionBlocks.join("\n\n");
93
+ }
94
+ if (profile.subagents) {
95
+ readPrimeSubagents(profile.subagents, controls);
96
+ }
97
+ return controls;
98
+ }
99
+ /** Refuse every profile control prime-agent has no verified read path for. */
100
+ function rejectUnboundPrimeControls(profile) {
83
101
  if (profile.harness !== undefined && profile.harness !== "prime") {
84
102
  rejectPrimeDeclaration("harness", `prime cannot run a profile pinned to harness "${profile.harness}" — ` +
85
103
  'only "prime" (or an unset harness preference) is accepted.');
@@ -115,128 +133,123 @@ export function readPrimeProfileControls(profile) {
115
133
  rejectPrimeControl("confidential", "confidential execution is a property of the executor's isolation boundary, not " +
116
134
  "of the agent dir; this lowering cannot establish it.");
117
135
  }
118
- const instructionBlocks = [];
119
- if (profile.prompt) {
120
- const { systemPrompt, appendSystemPrompt, instructions } = profile.prompt;
121
- const knownPromptFields = new Set([
122
- "systemPrompt",
123
- "appendSystemPrompt",
124
- "instructions",
125
- ]);
126
- const unknown = Object.entries(profile.prompt)
127
- .filter(([key, value]) => !knownPromptFields.has(key) && value !== undefined)
128
- .map(([key]) => key);
129
- if (unknown.length > 0) {
130
- rejectPrimeControl(`prompt fields: ${unknown.join(", ")}`, "no verified prime-agent read path exists for them.");
136
+ }
137
+ function readPrimePromptControls(prompt, controls, instructionBlocks) {
138
+ const { systemPrompt, appendSystemPrompt, instructions } = prompt;
139
+ const knownPromptFields = new Set([
140
+ "systemPrompt",
141
+ "appendSystemPrompt",
142
+ "instructions",
143
+ ]);
144
+ const unknown = Object.entries(prompt)
145
+ .filter(([key, value]) => !knownPromptFields.has(key) && value !== undefined)
146
+ .map(([key]) => key);
147
+ if (unknown.length > 0) {
148
+ rejectPrimeControl(`prompt fields: ${unknown.join(", ")}`, "no verified prime-agent read path exists for them.");
149
+ }
150
+ if (systemPrompt !== undefined) {
151
+ if (typeof systemPrompt !== "string" || systemPrompt.trim() === "") {
152
+ rejectPrimeDeclaration("prompt.systemPrompt", "an empty replacement would delete the built-in system prompt and " +
153
+ "install nothing.");
131
154
  }
132
- if (systemPrompt !== undefined) {
133
- if (typeof systemPrompt !== "string" || systemPrompt.trim() === "") {
134
- rejectPrimeDeclaration("prompt.systemPrompt", "an empty replacement would delete the built-in system prompt and " +
135
- "install nothing.");
136
- }
137
- controls.systemPrompt = systemPrompt;
155
+ controls.systemPrompt = systemPrompt;
156
+ }
157
+ if (appendSystemPrompt !== undefined) {
158
+ if (typeof appendSystemPrompt !== "string" ||
159
+ appendSystemPrompt.trim() === "") {
160
+ rejectPrimeDeclaration("prompt.appendSystemPrompt", "must be non-empty text.");
138
161
  }
139
- if (appendSystemPrompt !== undefined) {
140
- if (typeof appendSystemPrompt !== "string" ||
141
- appendSystemPrompt.trim() === "") {
142
- rejectPrimeDeclaration("prompt.appendSystemPrompt", "must be non-empty text.");
143
- }
144
- controls.appendSystemPrompt = appendSystemPrompt;
162
+ controls.appendSystemPrompt = appendSystemPrompt;
163
+ }
164
+ for (const block of instructions ?? []) {
165
+ if (typeof block === "string" && block.trim() !== "")
166
+ instructionBlocks.push(block);
167
+ }
168
+ }
169
+ function readPrimeResourceControls(resources, controls, instructionBlocks) {
170
+ const { skills, instructions, failOnError, ...unsupported } = resources;
171
+ const rejectedKeys = Object.entries(unsupported)
172
+ .filter(([, value]) => value !== undefined)
173
+ .map(([key]) => key);
174
+ if (rejectedKeys.length > 0) {
175
+ rejectPrimeControl(`resources: ${rejectedKeys.join(", ")}`, "only resources.skills (--skill dirs, fork resource-loader.ts:437-442) and " +
176
+ "resources.instructions (agent-dir AGENTS.md, resource-loader.ts:86) have a " +
177
+ "verified prime-agent read path. Generic workspace files, tool files, " +
178
+ "subagent-definition files and prompt templates either have no headless read " +
179
+ "path or would run caller-authored code inside the agent process.");
180
+ }
181
+ if (failOnError === false) {
182
+ rejectPrimeControl("resources.failOnError: false", "this lowering always fails loud on a resource it cannot " +
183
+ "materialize; lenient materialization is not implemented.");
184
+ }
185
+ if (instructions !== undefined) {
186
+ const block = primeInstructionBlock(instructions, "resources.instructions");
187
+ if (block.trim() !== "")
188
+ instructionBlocks.push(block);
189
+ }
190
+ const seen = new Set();
191
+ for (const [index, ref] of (skills ?? []).entries()) {
192
+ if (ref.kind !== "inline") {
193
+ rejectPrimeControl(`resources.skills[${index}]`, "only inline skill refs are accepted — resolve GitHub-backed refs " +
194
+ "(resolveAgentProfileResources) before handing the profile to " +
195
+ "this lowering.");
145
196
  }
146
- for (const block of instructions ?? []) {
147
- if (typeof block === "string" && block.trim() !== "")
148
- instructionBlocks.push(block);
197
+ const name = ref.name;
198
+ if (!PRIME_SKILL_NAME_RE.test(name) ||
199
+ name.length > PRIME_MAX_SKILL_NAME_LENGTH) {
200
+ rejectPrimeDeclaration(`resources.skills[${index}]`, `name ${JSON.stringify(name)} is not a valid prime skill name — it becomes the ` +
201
+ "skill directory name, so it must be lowercase a-z0-9 with single hyphens, at " +
202
+ `most ${PRIME_MAX_SKILL_NAME_LENGTH} chars (fork skills.ts:122-147).`);
149
203
  }
204
+ if (seen.has(name)) {
205
+ rejectPrimeDeclaration("resources.skills", `declares ${JSON.stringify(name)} twice; duplicate skill names collide in the ` +
206
+ "fork's skill registry, which keeps only the first and warns.");
207
+ }
208
+ seen.add(name);
209
+ if (ref.content.trim() === "") {
210
+ rejectPrimeDeclaration(`resources.skills[${index}]`, `skill ${JSON.stringify(name)} declares no content.`);
211
+ }
212
+ controls.skills.push({ name, content: ref.content });
150
213
  }
151
- if (profile.resources) {
152
- const { skills, instructions, failOnError, ...unsupported } = profile.resources;
153
- const rejectedKeys = Object.entries(unsupported)
214
+ }
215
+ function readPrimeSubagents(subagents, controls) {
216
+ const usedIds = new Map();
217
+ for (const [name, spec] of Object.entries(subagents)) {
218
+ if (name.trim() === "") {
219
+ rejectPrimeDeclaration("subagents", "a subagent name must be non-empty.");
220
+ }
221
+ const inexpressible = [
222
+ ["model", spec.model],
223
+ ["tools", spec.tools],
224
+ ["permissions", spec.permissions],
225
+ ["maxSteps", spec.maxSteps],
226
+ ]
154
227
  .filter(([, value]) => value !== undefined)
155
228
  .map(([key]) => key);
156
- if (rejectedKeys.length > 0) {
157
- rejectPrimeControl(`resources: ${rejectedKeys.join(", ")}`, "only resources.skills (--skill dirs, fork resource-loader.ts:437-442) and " +
158
- "resources.instructions (agent-dir AGENTS.md, resource-loader.ts:86) have a " +
159
- "verified prime-agent read path. Generic workspace files, tool files, " +
160
- "subagent-definition files and prompt templates either have no headless read " +
161
- "path or would run caller-authored code inside the agent process.");
162
- }
163
- if (failOnError === false) {
164
- rejectPrimeControl("resources.failOnError: false", "this lowering always fails loud on a resource it cannot " +
165
- "materialize; lenient materialization is not implemented.");
229
+ if (inexpressible.length > 0) {
230
+ rejectPrimeControl(`subagents[${JSON.stringify(name)}] fields: ${inexpressible.join(", ")}`, "a prime continual-harness subagent spec is prompt-roster " +
231
+ "guidance for `await rlm('<task>')` (fork refinement.ts:483-496) " +
232
+ "and pins no model, tool set, permission, or step budget on the " +
233
+ "spawned child.");
166
234
  }
167
- if (instructions !== undefined) {
168
- const block = primeInstructionBlock(instructions, "resources.instructions");
169
- if (block.trim() !== "")
170
- instructionBlocks.push(block);
235
+ if (!spec.description?.trim() && !spec.prompt?.trim()) {
236
+ rejectPrimeDeclaration(`subagents[${JSON.stringify(name)}]`, "declares no behavior — a spec needs a description and/or prompt, since an " +
237
+ "empty entry gives the model an empty roster line.");
171
238
  }
172
- const seen = new Set();
173
- for (const [index, ref] of (skills ?? []).entries()) {
174
- if (ref.kind !== "inline") {
175
- rejectPrimeControl(`resources.skills[${index}]`, "only inline skill refs are accepted — resolve GitHub-backed refs " +
176
- "(resolveAgentProfileResources) before handing the profile to " +
177
- "this lowering.");
178
- }
179
- const name = ref.name;
180
- if (!PRIME_SKILL_NAME_RE.test(name) ||
181
- name.length > PRIME_MAX_SKILL_NAME_LENGTH) {
182
- rejectPrimeDeclaration(`resources.skills[${index}]`, `name ${JSON.stringify(name)} is not a valid prime skill name — it becomes the ` +
183
- "skill directory name, so it must be lowercase a-z0-9 with single hyphens, at " +
184
- `most ${PRIME_MAX_SKILL_NAME_LENGTH} chars (fork skills.ts:122-147).`);
185
- }
186
- if (seen.has(name)) {
187
- rejectPrimeDeclaration("resources.skills", `declares ${JSON.stringify(name)} twice; duplicate skill names collide in the ` +
188
- "fork's skill registry, which keeps only the first and warns.");
189
- }
190
- seen.add(name);
191
- if (ref.content.trim() === "") {
192
- rejectPrimeDeclaration(`resources.skills[${index}]`, `skill ${JSON.stringify(name)} declares no content.`);
193
- }
194
- controls.skills.push({ name, content: ref.content });
239
+ const id = primeSubagentEntryId(name);
240
+ const clash = usedIds.get(id);
241
+ if (clash !== undefined) {
242
+ rejectPrimeDeclaration(`subagents[${JSON.stringify(name)}]`, `collides with subagent ${JSON.stringify(clash)} on harness-state id ` +
243
+ `${JSON.stringify(id)}.`);
195
244
  }
245
+ usedIds.set(id, name);
246
+ controls.subagents.push({
247
+ name,
248
+ description: spec.description,
249
+ prompt: spec.prompt,
250
+ metadata: spec.metadata,
251
+ });
196
252
  }
197
- if (instructionBlocks.length > 0) {
198
- controls.instructions = instructionBlocks.join("\n\n");
199
- }
200
- if (profile.subagents) {
201
- const usedIds = new Map();
202
- for (const [name, spec] of Object.entries(profile.subagents)) {
203
- if (name.trim() === "") {
204
- rejectPrimeDeclaration("subagents", "a subagent name must be non-empty.");
205
- }
206
- const inexpressible = [
207
- ["model", spec.model],
208
- ["tools", spec.tools],
209
- ["permissions", spec.permissions],
210
- ["maxSteps", spec.maxSteps],
211
- ]
212
- .filter(([, value]) => value !== undefined)
213
- .map(([key]) => key);
214
- if (inexpressible.length > 0) {
215
- rejectPrimeControl(`subagents[${JSON.stringify(name)}] fields: ${inexpressible.join(", ")}`, "a prime continual-harness subagent spec is prompt-roster " +
216
- "guidance for `await rlm('<task>')` (fork refinement.ts:483-496) " +
217
- "and pins no model, tool set, permission, or step budget on the " +
218
- "spawned child.");
219
- }
220
- if (!spec.description?.trim() && !spec.prompt?.trim()) {
221
- rejectPrimeDeclaration(`subagents[${JSON.stringify(name)}]`, "declares no behavior — a spec needs a description and/or prompt, since an " +
222
- "empty entry gives the model an empty roster line.");
223
- }
224
- const id = primeSubagentEntryId(name);
225
- const clash = usedIds.get(id);
226
- if (clash !== undefined) {
227
- rejectPrimeDeclaration(`subagents[${JSON.stringify(name)}]`, `collides with subagent ${JSON.stringify(clash)} on harness-state id ` +
228
- `${JSON.stringify(id)}.`);
229
- }
230
- usedIds.set(id, name);
231
- controls.subagents.push({
232
- name,
233
- description: spec.description,
234
- prompt: spec.prompt,
235
- metadata: spec.metadata,
236
- });
237
- }
238
- }
239
- return controls;
240
253
  }
241
254
  /**
242
255
  * Refuse a run whose selected model disagrees with the profile's own.
@@ -5,7 +5,7 @@
5
5
  * is absent or corrupt, and every write to it is best-effort so a full or
6
6
  * read-only cache can never break admit, claim, or eviction.
7
7
  */
8
- export interface ProfileBundleLru {
8
+ interface ProfileBundleLru {
9
9
  v: 1;
10
10
  objects: Record<string, {
11
11
  bytes: number;
@@ -53,3 +53,4 @@ export declare function evictUnlocked(root: string, maxBytes: number): Promise<v
53
53
  * never touched.
54
54
  */
55
55
  export declare function evictProfileBundles(root: string, maxBytes: number): Promise<void>;
56
+ export {};
@@ -7,12 +7,8 @@ import { bindConcurrentLaunch } from "./managed-workspace-bind.js";
7
7
  import { lstatIfPresent, prepareManagedWorkspacePlan, } from "./managed-workspace-stage.js";
8
8
  import { normalizeWorkspacePlanArgument } from "./plan-normalize.js";
9
9
  import { assertWorkspacePlanSupported } from "./plan-support.js";
10
- /**
11
- * Publish a resolved revision for exactly one native subprocess UID. The
12
- * executor-owned naming key hides low-entropy credential guesses from paths;
13
- * the resolved digest and manifest never leave this server-side result.
14
- */
15
- export async function prepareSecretConcurrentProfileRevision(input) {
10
+ /** Refuse an executor that cannot isolate the revision, or a plan that claims its launch names. */
11
+ function assertSecretRevisionLaunch(input) {
16
12
  if (process.platform !== "linux" ||
17
13
  process.getuid?.() !== 0 ||
18
14
  !Number.isSafeInteger(input.subprocessUid) ||
@@ -30,11 +26,17 @@ export async function prepareSecretConcurrentProfileRevision(input) {
30
26
  throw new Error(`Secret OpenCode reserves ${name} for its launch binding`);
31
27
  }
32
28
  }
29
+ }
30
+ /**
31
+ * The OpenCode plan the revision materializes: the trusted provider config
32
+ * merged in, and every public MCP header turned into a reference.
33
+ */
34
+ function secretOpenCodePlan(sourcePlan, providerConfig, secretReferences) {
33
35
  // Workspace `reference/` files stay in this revision's read-only tree. The
34
36
  // sealed launch runs from that tree, so a cwd-relative `reference/` path
35
37
  // reads this revision's copy and nothing is written to the shared workspace.
36
- let plan = prepareExplicitOpenCodePlan(input.plan);
37
- if (input.providerConfig) {
38
+ let plan = prepareExplicitOpenCodePlan(sourcePlan);
39
+ if (providerConfig) {
38
40
  plan = {
39
41
  ...plan,
40
42
  files: plan.files.map((file) => file.relPath === "opencode.json" &&
@@ -44,13 +46,12 @@ export async function prepareSecretConcurrentProfileRevision(input) {
44
46
  ...file,
45
47
  content: JSON.stringify({
46
48
  ...JSON.parse(file.content),
47
- provider: input.providerConfig,
49
+ provider: providerConfig,
48
50
  }),
49
51
  }
50
52
  : file),
51
53
  };
52
54
  }
53
- const secretReferences = {};
54
55
  plan = referencePublicMcpHeaders(plan, secretReferences);
55
56
  if (plan.files.some((file) => file.relPath.startsWith(".config/opencode/"))) {
56
57
  throw new Error("Secret OpenCode reserves its native config directory");
@@ -64,6 +65,39 @@ export async function prepareSecretConcurrentProfileRevision(input) {
64
65
  plan.flags.some((argument) => typeof normalizeWorkspacePlanArgument(argument) !== "string")) {
65
66
  throw new Error("Secret OpenCode refuses a profile secret in its launch environment or arguments");
66
67
  }
68
+ return plan;
69
+ }
70
+ /** Publish the staged revision read-only, or adopt an identical one already published. */
71
+ function publishSecretRevision(prepared, directory, subprocessUid) {
72
+ const existing = lstatIfPresent(directory);
73
+ if (existing) {
74
+ assertPublishedSecretRevision(directory, prepared.manifest);
75
+ prepared.dispose();
76
+ }
77
+ else {
78
+ try {
79
+ makeSecretRevisionReadOnly(prepared.root, subprocessUid);
80
+ renameSync(prepared.root, directory);
81
+ }
82
+ catch (error) {
83
+ const raced = lstatIfPresent(directory);
84
+ if (!raced?.isDirectory() || raced.isSymbolicLink())
85
+ throw error;
86
+ assertPublishedSecretRevision(directory, prepared.manifest);
87
+ makeStageWritable(prepared.root);
88
+ prepared.dispose();
89
+ }
90
+ }
91
+ }
92
+ /**
93
+ * Publish a resolved revision for exactly one native subprocess UID. The
94
+ * executor-owned naming key hides low-entropy credential guesses from paths;
95
+ * the resolved digest and manifest never leave this server-side result.
96
+ */
97
+ export async function prepareSecretConcurrentProfileRevision(input) {
98
+ assertSecretRevisionLaunch(input);
99
+ const secretReferences = {};
100
+ const plan = secretOpenCodePlan(input.plan, input.providerConfig, secretReferences);
67
101
  assertWorkspacePlanSupported(plan);
68
102
  assertLaunchRoot(input.launchRoot);
69
103
  // File slots reference their secrets, so an unchanged profile with a rotated
@@ -104,25 +138,7 @@ export async function prepareSecretConcurrentProfileRevision(input) {
104
138
  .update(JSON.stringify(spawn))
105
139
  .digest("hex");
106
140
  writeFileSync(join(prepared.root, "manifest.json"), JSON.stringify(prepared.manifest));
107
- const existing = lstatIfPresent(directory);
108
- if (existing) {
109
- assertPublishedSecretRevision(directory, prepared.manifest);
110
- prepared.dispose();
111
- }
112
- else {
113
- try {
114
- makeSecretRevisionReadOnly(prepared.root, input.subprocessUid);
115
- renameSync(prepared.root, directory);
116
- }
117
- catch (error) {
118
- const raced = lstatIfPresent(directory);
119
- if (!raced?.isDirectory() || raced.isSymbolicLink())
120
- throw error;
121
- assertPublishedSecretRevision(directory, prepared.manifest);
122
- makeStageWritable(prepared.root);
123
- prepared.dispose();
124
- }
125
- }
141
+ publishSecretRevision(prepared, directory, input.subprocessUid);
126
142
  return {
127
143
  directory,
128
144
  manifest: prepared.manifest,
@@ -0,0 +1,26 @@
1
+ export type ExistingFilePolicy = "allow-identical" | "reject";
2
+ export type WorkspaceOwnership = {
3
+ uid: number;
4
+ gid: number;
5
+ } | undefined;
6
+ export type ApplyFile = NormalizedPlanFile & {
7
+ rootDir: string;
8
+ absPath: string;
9
+ };
10
+ /** What one publication needs once the files to write are chosen. */
11
+ export interface PublishContext {
12
+ rootFor: (root: NormalizedPlanFile["root"]) => string;
13
+ existingFiles: ExistingFilePolicy;
14
+ ownership: WorkspaceOwnership;
15
+ publicPlanDigest: string;
16
+ plannedContent: Map<string, string>;
17
+ }
18
+ export declare const fileIdentity: (file: NormalizedPlanFile) => string;
19
+ export declare function holdsCurrentOwnership(absPath: string, ownership: WorkspaceOwnership): boolean;
20
+ export declare function planWrites(filesToApply: ApplyFile[], context: PublishContext): ApplyFile[];
21
+ export declare function publishWrites(writes: ApplyFile[], prepared: PreparedWorkspaceFile[], context: PublishContext): void;
22
+ export declare function rollbackPublished(prepared: PreparedWorkspaceFile[], error: unknown): never;
23
+ export declare function removeTemporaries(prepared: PreparedWorkspaceFile[], receipt: WorkspacePlanReceipt): void;
24
+ import type { NormalizedPlanFile } from "./plan-normalize.js";
25
+ import type { WorkspacePlanReceipt } from "./plan-types.js";
26
+ import { type PreparedWorkspaceFile } from "./workspace-apply-files.js";
@@ -0,0 +1,178 @@
1
+ export const fileIdentity = (file) => `${file.root}\0${file.relPath}`;
2
+ export function holdsCurrentOwnership(absPath, ownership) {
3
+ return (ownership === undefined || workspaceFileHasOwnership(absPath, ownership));
4
+ }
5
+ /** Only materializer-owned files are held to the writer's ownership. */
6
+ function ownershipCurrent(file, ownership) {
7
+ return (file.ownership !== "materializer" ||
8
+ holdsCurrentOwnership(file.absPath, ownership));
9
+ }
10
+ // An owned path holds this package's own earlier output, so re-applying
11
+ // into the same workspace replaces it rather than failing the turn — once
12
+ // the bytes already there prove that provenance. The replacement keeps what
13
+ // an agent appended below a generated context file (its memory), so it can
14
+ // differ from the planned bytes. `reject` still refuses: that policy is
15
+ // "nothing may pre-exist here", which is a statement about the workspace,
16
+ // not authorship.
17
+ function replacementContent(file, context) {
18
+ const planned = context.plannedContent.get(fileIdentity(file));
19
+ if (context.existingFiles !== "allow-identical" ||
20
+ file.ownership !== "materializer" ||
21
+ planned === undefined) {
22
+ return undefined;
23
+ }
24
+ return materializerReplacement(file.relPath, () => regularFileContent(file.absPath), planned);
25
+ }
26
+ // Validate the complete plan before the first write so a late traversal or
27
+ // existing symlink cannot leave a partially materialized workspace.
28
+ export function planWrites(filesToApply, context) {
29
+ const writes = [];
30
+ for (const file of filesToApply) {
31
+ assertNoSymlinkPathComponents(file.rootDir, file.absPath);
32
+ if (!workspaceFileExists(file.absPath)) {
33
+ writes.push(file);
34
+ continue;
35
+ }
36
+ const content = replacementContent(file, context);
37
+ if (content !== undefined) {
38
+ writes.push({ ...file, content });
39
+ continue;
40
+ }
41
+ if (context.existingFiles === "reject" || !workspaceFileMatches(file)) {
42
+ throw new Error(workspaceFileConflictMessage(file, context.existingFiles, context.publicPlanDigest));
43
+ }
44
+ writes.push(file);
45
+ }
46
+ return writes;
47
+ }
48
+ // rename() replaces in one step, so no window exists in which the path is
49
+ // missing and a concurrent spawn reads nothing. The staged inode moves onto the
50
+ // path, which is what rollback identifies the file by, so an abort still
51
+ // removes exactly what this call published — leaving the path ABSENT rather
52
+ // than restored to the superseded bytes. That is the fail-closed direction: the
53
+ // harness whose flag names a missing file refuses to start, whereas silently
54
+ // restored previous-turn bytes would run the wrong prompt. A replaced file that
55
+ // holds an agent's appended text is the exception: it is linked aside first and
56
+ // restored, because removing it would delete the agent's own text.
57
+ function replacePublishedFile(entry, context) {
58
+ assertNoSymlinkPathComponents(context.rootFor(entry.file.root), entry.file.absPath);
59
+ if (entry.file.content !== context.plannedContent.get(fileIdentity(entry.file))) {
60
+ const superseded = join(dirname(entry.file.absPath), `.agent-profile-${randomUUID()}.superseded`);
61
+ linkSync(entry.file.absPath, superseded);
62
+ entry.superseded = superseded;
63
+ }
64
+ renameSync(entry.temporaryPath, entry.file.absPath);
65
+ entry.published = true;
66
+ }
67
+ function publishPreparedFile(entry, context) {
68
+ assertNoSymlinkPathComponents(context.rootFor(entry.file.root), entry.file.absPath);
69
+ try {
70
+ linkSync(entry.temporaryPath, entry.file.absPath);
71
+ entry.published = true;
72
+ }
73
+ catch (error) {
74
+ if (isFsError(error, "EEXIST") &&
75
+ context.existingFiles === "allow-identical" &&
76
+ workspaceFileMatches(entry.file) &&
77
+ ownershipCurrent(entry.file, context.ownership)) {
78
+ return;
79
+ }
80
+ if (isFsError(error, "EEXIST") &&
81
+ replacementContent(entry.file, context) === entry.file.content) {
82
+ // The replacement is recomposed from the bytes there now, so an
83
+ // agent append that landed since validation refuses below instead
84
+ // of being overwritten; one landing between that read and the
85
+ // rename is not detected.
86
+ replacePublishedFile(entry, context);
87
+ return;
88
+ }
89
+ if (isFsError(error, "EEXIST")) {
90
+ throw new Error(workspaceFileConflictMessage(entry.file, context.existingFiles, context.publicPlanDigest), { cause: error });
91
+ }
92
+ throw error;
93
+ }
94
+ }
95
+ // Prepared temp links stay alive through publication and rollback. Their
96
+ // inodes distinguish files created here from concurrent replacements.
97
+ export function publishWrites(writes, prepared, context) {
98
+ for (const file of writes) {
99
+ if (context.existingFiles === "allow-identical" &&
100
+ workspaceFileExists(file.absPath) &&
101
+ workspaceFileMatches(file) &&
102
+ ownershipCurrent(file, context.ownership)) {
103
+ continue;
104
+ }
105
+ prepared.push(prepareWorkspaceFile(file.rootDir, file, context.ownership));
106
+ }
107
+ for (const entry of prepared)
108
+ publishPreparedFile(entry, context);
109
+ }
110
+ export function rollbackPublished(prepared,
111
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- rollback rethrows the caught failure unchanged; an exception has no schema to parse at a boundary.
112
+ error) {
113
+ const cleanupErrors = [];
114
+ for (const entry of [...prepared].reverse()) {
115
+ try {
116
+ if (entry.published &&
117
+ workspacePathHasIdentity(entry.file.absPath, entry.identity)) {
118
+ if (entry.superseded) {
119
+ renameSync(entry.superseded, entry.file.absPath);
120
+ }
121
+ else {
122
+ unlinkSync(entry.file.absPath);
123
+ }
124
+ }
125
+ }
126
+ catch (cleanupError) {
127
+ cleanupErrors.push(cleanupError);
128
+ }
129
+ try {
130
+ if (entry.superseded)
131
+ unlinkSync(entry.superseded);
132
+ }
133
+ catch (cleanupError) {
134
+ if (!isFsError(cleanupError, "ENOENT"))
135
+ cleanupErrors.push(cleanupError);
136
+ }
137
+ try {
138
+ if (workspacePathHasIdentity(entry.temporaryPath, entry.identity)) {
139
+ unlinkSync(entry.temporaryPath);
140
+ }
141
+ }
142
+ catch (cleanupError) {
143
+ cleanupErrors.push(cleanupError);
144
+ }
145
+ }
146
+ if (cleanupErrors.length > 0) {
147
+ throw new AggregateError([error, ...cleanupErrors], "Failed to apply workspace plan and roll back published files");
148
+ }
149
+ throw error;
150
+ }
151
+ export function removeTemporaries(prepared, receipt) {
152
+ const cleanupErrors = [];
153
+ const temporaryPaths = [];
154
+ for (const entry of prepared) {
155
+ for (const path of [entry.temporaryPath, entry.superseded]) {
156
+ if (path === undefined)
157
+ continue;
158
+ try {
159
+ unlinkSync(path);
160
+ }
161
+ catch (error) {
162
+ if (!isFsError(error, "ENOENT")) {
163
+ cleanupErrors.push(error);
164
+ temporaryPaths.push(path);
165
+ }
166
+ }
167
+ }
168
+ }
169
+ if (cleanupErrors.length > 0) {
170
+ throw new WorkspacePlanCleanupError(cleanupErrors, receipt, temporaryPaths);
171
+ }
172
+ }
173
+ import { randomUUID } from "node:crypto";
174
+ import { linkSync, renameSync, unlinkSync } from "node:fs";
175
+ import { dirname, join } from "node:path";
176
+ import { materializerReplacement } from "./harness.js";
177
+ import { WorkspacePlanCleanupError } from "./plan-types.js";
178
+ import { assertNoSymlinkPathComponents, isFsError, prepareWorkspaceFile, regularFileContent, workspaceFileConflictMessage, workspaceFileExists, workspaceFileHasOwnership, workspaceFileMatches, workspacePathHasIdentity, } from "./workspace-apply-files.js";