@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.
- package/dist/execution-controls.js +119 -111
- package/dist/harness.d.ts +0 -25
- package/dist/harness.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/managed-workspace-bind.js +4 -5
- package/dist/managed-workspace-stage.d.ts +2 -1
- package/dist/mcp-config-ownership.d.ts +0 -18
- package/dist/mcp-config-ownership.js +4 -4
- package/dist/mcp-opencode.d.ts +25 -0
- package/dist/mcp-opencode.js +52 -0
- package/dist/mcp-timeouts.d.ts +7 -0
- package/dist/mcp-timeouts.js +48 -0
- package/dist/mcp.d.ts +1 -36
- package/dist/mcp.js +119 -201
- package/dist/plan-json.d.ts +2 -7
- package/dist/plan-json.js +3 -3
- package/dist/plan-types.d.ts +1 -1
- package/dist/prime-profile.js +124 -111
- package/dist/profile-bundle-lru.d.ts +2 -1
- package/dist/secret-concurrent-revision.js +45 -29
- package/dist/workspace-apply-publish.d.ts +26 -0
- package/dist/workspace-apply-publish.js +178 -0
- package/dist/workspace-apply.js +60 -196
- package/package.json +4 -4
package/dist/prime-profile.js
CHANGED
|
@@ -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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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 (
|
|
157
|
-
rejectPrimeControl(`
|
|
158
|
-
"
|
|
159
|
-
"
|
|
160
|
-
"
|
|
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 (
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
37
|
-
if (
|
|
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:
|
|
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
|
-
|
|
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";
|