@tangle-network/agent-profile-materialize 0.17.0 → 0.17.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 +3 -3
- package/dist/harness.d.ts +33 -9
- package/dist/harness.js +75 -12
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/mcp.js +3 -2
- package/dist/model-id.d.ts +13 -0
- package/dist/model-id.js +8 -0
- package/dist/plan-types.d.ts +23 -14
- package/dist/profile-plan.js +2 -2
- package/dist/profile-prompt.js +3 -3
- package/dist/workspace-apply-files.d.ts +50 -0
- package/dist/workspace-apply-files.js +178 -0
- package/dist/workspace-apply.js +55 -178
- package/package.json +1 -1
|
@@ -35,8 +35,8 @@ export function materializeExecutionControls(h, tools, permissions, modes, plan,
|
|
|
35
35
|
const nativeTools = mapSupportedBooleanEntries(enforceableTools, openCodeToolName, (name) => unsupported("tools", `opencode: no verified native tool mapping for "${name}"`), "opencode tool");
|
|
36
36
|
const nativePermissions = mapSupportedPermissionEntries(enforceablePermissions, openCodePermissionName, (name) => unsupported("permissions", `opencode: no verified native permission mapping for "${name}"`), "opencode permission");
|
|
37
37
|
if (enforceableTools.length > 0 || permissionEntries.length > 0) {
|
|
38
|
-
add(
|
|
39
|
-
$schema:
|
|
38
|
+
add(OPENCODE_CONFIG_FILE, serializeWorkspacePlanJson({
|
|
39
|
+
$schema: OPENCODE_CONFIG_SCHEMA,
|
|
40
40
|
...(nativeTools.length > 0
|
|
41
41
|
? { tools: Object.fromEntries(nativeTools) }
|
|
42
42
|
: {}),
|
|
@@ -180,7 +180,7 @@ function piExecutionControlExtensionSource(enabledTools, excludedTools) {
|
|
|
180
180
|
}
|
|
181
181
|
import { resolveNativeWebTools } from "@tangle-network/agent-interface";
|
|
182
182
|
import { claudePermissionRules, claudeToolName, mapSupportedBooleanEntries, mapSupportedPermissionEntries, normalizedControlName, openCodePermissionName, openCodeToolName, PI_EXECUTION_CONTROL_EXTENSION_PATH, permissionAllowsEverything, piToolId, sealClaudePermissionRules, } from "./control-maps.js";
|
|
183
|
-
import { CLAUDE_SETTINGS_PATH } from "./harness.js";
|
|
183
|
+
import { CLAUDE_SETTINGS_PATH, OPENCODE_CONFIG_FILE, OPENCODE_CONFIG_SCHEMA, } from "./harness.js";
|
|
184
184
|
import { registerClaudeSettings } from "./hooks.js";
|
|
185
185
|
import { serializeWorkspacePlanJson } from "./plan-json.js";
|
|
186
186
|
import { compareStrings } from "./workspace-files.js";
|
package/dist/harness.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ export declare function isMaterializerHarness(harness: string): harness is Harne
|
|
|
4
4
|
export declare function assertMaterializerHarness(harness: string): asserts harness is HarnessId;
|
|
5
5
|
export declare const CODEX_SYSTEM_PROMPT_FILE = ".codex/system-prompt.md";
|
|
6
6
|
export declare const OPENCODE_APPEND_SYSTEM_PROMPT_FILE = ".opencode/agent-system-prompt.md";
|
|
7
|
+
export declare const OPENCODE_CONFIG_FILE = "opencode.json";
|
|
8
|
+
export declare const OPENCODE_CONFIG_SCHEMA = "https://opencode.ai/config.json";
|
|
7
9
|
export declare const OPENCODE_PRIMARY_AGENT_PREFIX = "tangle-profile-";
|
|
8
10
|
export declare const CLAUDE_SETTINGS_PATH = ".tangle/claude-settings.json";
|
|
9
11
|
export declare function harnessAutoLoadsContextFile(harness: HarnessId): boolean;
|
|
@@ -38,30 +40,52 @@ export declare function appendSystemPromptRefusal(harness: HarnessId): string;
|
|
|
38
40
|
/** Project context file each harness actually reads from cwd. NEVER a constant. */
|
|
39
41
|
export declare const CONTEXT_FILE: Record<HarnessId, string>;
|
|
40
42
|
/**
|
|
41
|
-
* The paths
|
|
42
|
-
* {@link PlanFile.ownership}.
|
|
43
|
+
* The paths a profile resource may never mount.
|
|
43
44
|
*
|
|
44
45
|
* Codex names no system-prompt file of its own — `model_instructions_file`
|
|
45
46
|
* takes whatever path it is given — and opencode names none either, since it
|
|
46
47
|
* reads exactly the files its config lists. All three names below were chosen
|
|
47
48
|
* here, so a file already at one of them is this materializer's own output from
|
|
48
|
-
* an earlier application into the same workspace
|
|
49
|
+
* an earlier application into the same workspace, and a mount there could not
|
|
50
|
+
* survive the next application.
|
|
49
51
|
*
|
|
50
52
|
* That is an inference from the path name, not verified provenance: nothing
|
|
51
53
|
* checks the existing bytes are prior materializer output, so a workspace that
|
|
52
54
|
* carried a foreign file at one of these invented names is overwritten on the
|
|
53
55
|
* first application. Accepted cost of stateless multi-turn re-application,
|
|
54
56
|
* bounded by the names being ones no harness or user convention uses.
|
|
57
|
+
*/
|
|
58
|
+
export declare const MATERIALIZER_RESERVED_PATHS: ReadonlySet<string>;
|
|
59
|
+
/**
|
|
60
|
+
* The paths whose generated content is wholly this package's output, per
|
|
61
|
+
* {@link PlanFile.ownership}: the reserved names plus `opencode.json`, which
|
|
62
|
+
* carries only the registrations this package generates from the profile.
|
|
63
|
+
*
|
|
64
|
+
* `opencode.json` is a documented name a user repository can carry, so the
|
|
65
|
+
* name alone proves nothing and a profile resource may still mount it. An
|
|
66
|
+
* existing file there is replaced only when {@link isMaterializerOutput}
|
|
67
|
+
* recognizes its bytes as this package's own earlier output.
|
|
55
68
|
*
|
|
56
69
|
* Deliberately absent: `.gemini/system.md` (gemini's own documented name),
|
|
57
|
-
* `GEMINI.md`, `CLAUDE.md`, `AGENTS.md
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* `instructions[]` membership between turns of one workspace refuses loudly
|
|
61
|
-
* (the consolidated config no longer matches byte-for-byte) rather than
|
|
62
|
-
* replacing a possibly user-authored config.
|
|
70
|
+
* `GEMINI.md`, `CLAUDE.md`, `AGENTS.md`. A user's repository can carry any of
|
|
71
|
+
* those with content this package cannot tell from its own, and clobbering
|
|
72
|
+
* one is worse than refusing the turn.
|
|
63
73
|
*/
|
|
64
74
|
export declare const MATERIALIZER_OWNED_PATHS: ReadonlySet<string>;
|
|
75
|
+
/** The top-level keys this package writes into `opencode.json`. */
|
|
76
|
+
export declare const OPENCODE_CONFIG_KEYS: ReadonlySet<string>;
|
|
77
|
+
/**
|
|
78
|
+
* Whether the file already at owned path `relPath` is this package's own
|
|
79
|
+
* earlier output. A reserved name proves that by itself, so `content` is read
|
|
80
|
+
* only for `opencode.json`; `undefined` content means no regular file is there.
|
|
81
|
+
*
|
|
82
|
+
* An `opencode.json` this package wrote holds the opencode schema id, only the
|
|
83
|
+
* keys this package writes, and only instruction paths this package invented.
|
|
84
|
+
* A user config that holds nothing else is indistinguishable from that output
|
|
85
|
+
* and is replaced too; a config with any other key, or with an instruction path
|
|
86
|
+
* of the user's own, is refused.
|
|
87
|
+
*/
|
|
88
|
+
export declare function isMaterializerOutput(relPath: string, content: () => string | undefined): boolean;
|
|
65
89
|
/**
|
|
66
90
|
* cwd-relative native skill dir per harness, or null if the harness has no
|
|
67
91
|
* cwd skill primitive (skills live in a user/global dir the workspace plan
|
package/dist/harness.js
CHANGED
|
@@ -21,6 +21,8 @@ export function assertMaterializerHarness(harness) {
|
|
|
21
21
|
}
|
|
22
22
|
export const CODEX_SYSTEM_PROMPT_FILE = ".codex/system-prompt.md";
|
|
23
23
|
export const OPENCODE_APPEND_SYSTEM_PROMPT_FILE = ".opencode/agent-system-prompt.md";
|
|
24
|
+
export const OPENCODE_CONFIG_FILE = "opencode.json";
|
|
25
|
+
export const OPENCODE_CONFIG_SCHEMA = "https://opencode.ai/config.json";
|
|
24
26
|
export const OPENCODE_PRIMARY_AGENT_PREFIX = "tangle-profile-";
|
|
25
27
|
export const CLAUDE_SETTINGS_PATH = ".tangle/claude-settings.json";
|
|
26
28
|
export function harnessAutoLoadsContextFile(harness) {
|
|
@@ -101,34 +103,95 @@ export const CONTEXT_FILE = {
|
|
|
101
103
|
openclaw: "AGENTS.md",
|
|
102
104
|
};
|
|
103
105
|
/**
|
|
104
|
-
* The paths
|
|
105
|
-
* {@link PlanFile.ownership}.
|
|
106
|
+
* The paths a profile resource may never mount.
|
|
106
107
|
*
|
|
107
108
|
* Codex names no system-prompt file of its own — `model_instructions_file`
|
|
108
109
|
* takes whatever path it is given — and opencode names none either, since it
|
|
109
110
|
* reads exactly the files its config lists. All three names below were chosen
|
|
110
111
|
* here, so a file already at one of them is this materializer's own output from
|
|
111
|
-
* an earlier application into the same workspace
|
|
112
|
+
* an earlier application into the same workspace, and a mount there could not
|
|
113
|
+
* survive the next application.
|
|
112
114
|
*
|
|
113
115
|
* That is an inference from the path name, not verified provenance: nothing
|
|
114
116
|
* checks the existing bytes are prior materializer output, so a workspace that
|
|
115
117
|
* carried a foreign file at one of these invented names is overwritten on the
|
|
116
118
|
* first application. Accepted cost of stateless multi-turn re-application,
|
|
117
119
|
* bounded by the names being ones no harness or user convention uses.
|
|
118
|
-
*
|
|
119
|
-
* Deliberately absent: `.gemini/system.md` (gemini's own documented name),
|
|
120
|
-
* `GEMINI.md`, `CLAUDE.md`, `AGENTS.md`, `opencode.json`. A user's repository
|
|
121
|
-
* can carry any of those, and clobbering one is worse than refusing the turn.
|
|
122
|
-
* For `opencode.json` this means a profile change that alters the
|
|
123
|
-
* `instructions[]` membership between turns of one workspace refuses loudly
|
|
124
|
-
* (the consolidated config no longer matches byte-for-byte) rather than
|
|
125
|
-
* replacing a possibly user-authored config.
|
|
126
120
|
*/
|
|
127
|
-
export const
|
|
121
|
+
export const MATERIALIZER_RESERVED_PATHS = new Set([
|
|
128
122
|
CODEX_SYSTEM_PROMPT_FILE,
|
|
129
123
|
OPENCODE_APPEND_SYSTEM_PROMPT_FILE,
|
|
130
124
|
CONTEXT_FILE.opencode,
|
|
131
125
|
]);
|
|
126
|
+
/**
|
|
127
|
+
* The paths whose generated content is wholly this package's output, per
|
|
128
|
+
* {@link PlanFile.ownership}: the reserved names plus `opencode.json`, which
|
|
129
|
+
* carries only the registrations this package generates from the profile.
|
|
130
|
+
*
|
|
131
|
+
* `opencode.json` is a documented name a user repository can carry, so the
|
|
132
|
+
* name alone proves nothing and a profile resource may still mount it. An
|
|
133
|
+
* existing file there is replaced only when {@link isMaterializerOutput}
|
|
134
|
+
* recognizes its bytes as this package's own earlier output.
|
|
135
|
+
*
|
|
136
|
+
* Deliberately absent: `.gemini/system.md` (gemini's own documented name),
|
|
137
|
+
* `GEMINI.md`, `CLAUDE.md`, `AGENTS.md`. A user's repository can carry any of
|
|
138
|
+
* those with content this package cannot tell from its own, and clobbering
|
|
139
|
+
* one is worse than refusing the turn.
|
|
140
|
+
*/
|
|
141
|
+
export const MATERIALIZER_OWNED_PATHS = new Set([
|
|
142
|
+
...MATERIALIZER_RESERVED_PATHS,
|
|
143
|
+
OPENCODE_CONFIG_FILE,
|
|
144
|
+
]);
|
|
145
|
+
/** The top-level keys this package writes into `opencode.json`. */
|
|
146
|
+
export const OPENCODE_CONFIG_KEYS = new Set([
|
|
147
|
+
"$schema",
|
|
148
|
+
"instructions",
|
|
149
|
+
"mcp",
|
|
150
|
+
"tools",
|
|
151
|
+
"permission",
|
|
152
|
+
]);
|
|
153
|
+
/**
|
|
154
|
+
* Whether the file already at owned path `relPath` is this package's own
|
|
155
|
+
* earlier output. A reserved name proves that by itself, so `content` is read
|
|
156
|
+
* only for `opencode.json`; `undefined` content means no regular file is there.
|
|
157
|
+
*
|
|
158
|
+
* An `opencode.json` this package wrote holds the opencode schema id, only the
|
|
159
|
+
* keys this package writes, and only instruction paths this package invented.
|
|
160
|
+
* A user config that holds nothing else is indistinguishable from that output
|
|
161
|
+
* and is replaced too; a config with any other key, or with an instruction path
|
|
162
|
+
* of the user's own, is refused.
|
|
163
|
+
*/
|
|
164
|
+
export function isMaterializerOutput(relPath, content) {
|
|
165
|
+
if (MATERIALIZER_RESERVED_PATHS.has(relPath))
|
|
166
|
+
return true;
|
|
167
|
+
if (relPath !== OPENCODE_CONFIG_FILE)
|
|
168
|
+
return false;
|
|
169
|
+
const bytes = content();
|
|
170
|
+
if (bytes === undefined)
|
|
171
|
+
return false;
|
|
172
|
+
let parsed;
|
|
173
|
+
try {
|
|
174
|
+
parsed = JSON.parse(bytes);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
const config = parsed;
|
|
183
|
+
if (config.$schema !== OPENCODE_CONFIG_SCHEMA)
|
|
184
|
+
return false;
|
|
185
|
+
if (!Object.keys(config).every((key) => OPENCODE_CONFIG_KEYS.has(key))) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
const instructions = config.instructions;
|
|
189
|
+
if (instructions === undefined)
|
|
190
|
+
return true;
|
|
191
|
+
return (Array.isArray(instructions) &&
|
|
192
|
+
instructions.every((entry) => entry === OPENCODE_APPEND_SYSTEM_PROMPT_FILE ||
|
|
193
|
+
entry === CONTEXT_FILE.opencode));
|
|
194
|
+
}
|
|
132
195
|
/**
|
|
133
196
|
* cwd-relative native skill dir per harness, or null if the harness has no
|
|
134
197
|
* cwd skill primitive (skills live in a user/global dir the workspace plan
|
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";
|
package/dist/mcp.js
CHANGED
|
@@ -47,8 +47,8 @@ export function materializeMcp(h, mcp, plan, add, unsupported) {
|
|
|
47
47
|
}
|
|
48
48
|
case "opencode":
|
|
49
49
|
// opencode.json "mcp" block (local|remote).
|
|
50
|
-
add(
|
|
51
|
-
$schema:
|
|
50
|
+
add(OPENCODE_CONFIG_FILE, serializeWorkspacePlanJson({
|
|
51
|
+
$schema: OPENCODE_CONFIG_SCHEMA,
|
|
52
52
|
mcp: openCodeMcpConfig(mcp),
|
|
53
53
|
}));
|
|
54
54
|
break;
|
|
@@ -238,6 +238,7 @@ export function openCodeMcpConfig(mcp) {
|
|
|
238
238
|
}
|
|
239
239
|
return out;
|
|
240
240
|
}
|
|
241
|
+
import { OPENCODE_CONFIG_FILE, OPENCODE_CONFIG_SCHEMA } from "./harness.js";
|
|
241
242
|
import { sha256 } from "./plan-digest.js";
|
|
242
243
|
import { concatWorkspacePlanArguments, joinWorkspacePlanArguments, serializeWorkspacePlanJson, tomlProfileConfigValue, tomlStr, } from "./plan-json.js";
|
|
243
244
|
import { setPlanEnvironment } from "./profile-plan.js";
|
package/dist/model-id.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/plan-types.d.ts
CHANGED
|
@@ -28,16 +28,20 @@ export interface PlanFile {
|
|
|
28
28
|
* Who owns the PATH — which decides whether an existing file there may be
|
|
29
29
|
* replaced.
|
|
30
30
|
*
|
|
31
|
-
* `"materializer"` = a path
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
31
|
+
* `"materializer"` = a path whose generated content is wholly this
|
|
32
|
+
* package's output: a name this package invented, or `opencode.json`, which
|
|
33
|
+
* carries only the registrations generated from the profile. A file already
|
|
34
|
+
* there is this materializer's own output from an earlier application into
|
|
35
|
+
* the same workspace, and replacing it is how turn two of a resumed session
|
|
36
|
+
* gets the current profile instead of the previous one. Application replaces
|
|
37
|
+
* it only when the existing bytes prove that provenance — by the name for an
|
|
38
|
+
* invented path, by the content for `opencode.json`.
|
|
36
39
|
*
|
|
37
40
|
* `"shared"` (the default, and every other file) = a path the user or the
|
|
38
|
-
* harness also owns — `CLAUDE.md`, `AGENTS.md`, `
|
|
39
|
-
*
|
|
40
|
-
* file there may be the caller's own work, so it is
|
|
41
|
+
* harness also owns — `CLAUDE.md`, `AGENTS.md`, `.gemini/system.md` are all
|
|
42
|
+
* documented, user-authorable names whose content this package cannot tell
|
|
43
|
+
* from its own. An existing file there may be the caller's own work, so it is
|
|
44
|
+
* never replaced.
|
|
41
45
|
*
|
|
42
46
|
* Optional because hand-constructed plans have no path provenance to report;
|
|
43
47
|
* absent reads as `"shared"`, the safe answer.
|
|
@@ -277,16 +281,21 @@ export declare class WorkspacePlanCleanupError extends AggregateError {
|
|
|
277
281
|
export interface ApplyWorkspacePlanOptions {
|
|
278
282
|
/** Plan files consumed in memory by the runtime and never persisted to cwd. */
|
|
279
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
|
+
};
|
|
280
289
|
/** Existing workspace paths are either exact idempotent inputs or forbidden. */
|
|
281
290
|
existingFiles?: "allow-identical" | "reject";
|
|
282
291
|
/**
|
|
283
292
|
* `workspacePlanDigest` of a plan a prior call already applied into this
|
|
284
|
-
* workspace. When it equals this plan's digest, application
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
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.
|
|
290
299
|
*/
|
|
291
300
|
appliedPlanDigest?: string;
|
|
292
301
|
}
|
package/dist/profile-plan.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { MATERIALIZER_OWNED_PATHS } from "./harness.js";
|
|
1
|
+
import { MATERIALIZER_OWNED_PATHS, MATERIALIZER_RESERVED_PATHS, } from "./harness.js";
|
|
2
2
|
import { workspacePlanConfigValue } from "./plan-json.js";
|
|
3
3
|
import { mergeJsonFiles } from "./plan-normalize.js";
|
|
4
4
|
import { formatUnsupported } from "./plan-support.js";
|
|
@@ -47,7 +47,7 @@ export function createProfilePlanBuilder(harness) {
|
|
|
47
47
|
if (previousDimension) {
|
|
48
48
|
throw new Error(`Duplicate profile resource path: ${normalizedPath} (${previousDimension}, ${dimension})`);
|
|
49
49
|
}
|
|
50
|
-
if (
|
|
50
|
+
if (MATERIALIZER_RESERVED_PATHS.has(normalizedPath)) {
|
|
51
51
|
throw new Error(`Profile resource path is reserved by the materializer: ${normalizedPath} (${dimension}). ` +
|
|
52
52
|
"This package writes that path itself and replaces it on re-application, so a mount " +
|
|
53
53
|
"there cannot survive; choose another path.");
|
package/dist/profile-prompt.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { appendSystemPromptRefusal, CODEX_SYSTEM_PROMPT_FILE, CONTEXT_FILE, OPENCODE_APPEND_SYSTEM_PROMPT_FILE, OPENCODE_PRIMARY_AGENT_PREFIX, systemPromptRefusal, } from "./harness.js";
|
|
1
|
+
import { appendSystemPromptRefusal, CODEX_SYSTEM_PROMPT_FILE, CONTEXT_FILE, OPENCODE_APPEND_SYSTEM_PROMPT_FILE, OPENCODE_CONFIG_FILE, OPENCODE_CONFIG_SCHEMA, OPENCODE_PRIMARY_AGENT_PREFIX, systemPromptRefusal, } from "./harness.js";
|
|
2
2
|
import { sha256 } from "./plan-digest.js";
|
|
3
3
|
import { serializeWorkspacePlanJson } from "./plan-json.js";
|
|
4
4
|
export function lowerProfilePrompt(profile, harness, binds, builder) {
|
|
@@ -97,8 +97,8 @@ export function lowerProfilePrompt(profile, harness, binds, builder) {
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
if (openCodeInstructionFiles.length > 0) {
|
|
100
|
-
add(
|
|
101
|
-
$schema:
|
|
100
|
+
add(OPENCODE_CONFIG_FILE, serializeWorkspacePlanJson({
|
|
101
|
+
$schema: OPENCODE_CONFIG_SCHEMA,
|
|
102
102
|
instructions: openCodeInstructionFiles,
|
|
103
103
|
}));
|
|
104
104
|
}
|
|
@@ -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";
|
package/dist/workspace-apply.js
CHANGED
|
@@ -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
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
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: {
|
|
@@ -37,14 +55,17 @@ export function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDi
|
|
|
37
55
|
runtimeOnlyFiles: privateRuntimeFiles,
|
|
38
56
|
};
|
|
39
57
|
}
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
// pre-exist here", which is a statement about
|
|
44
|
-
|
|
58
|
+
// An owned path holds this package's own earlier output, so re-applying
|
|
59
|
+
// into the same workspace replaces it rather than failing the turn — once
|
|
60
|
+
// the bytes already there prove that provenance. `reject` still refuses:
|
|
61
|
+
// that policy is "nothing may pre-exist here", which is a statement about
|
|
62
|
+
// the workspace, not authorship.
|
|
63
|
+
const mayReplace = (file) => existingFiles === "allow-identical" &&
|
|
64
|
+
file.ownership === "materializer" &&
|
|
65
|
+
isMaterializerOutput(file.relPath, () => regularFileContent(file.absPath));
|
|
45
66
|
// Validate the complete plan before the first write so a late traversal or
|
|
46
67
|
// existing symlink cannot leave a partially materialized workspace.
|
|
47
|
-
for (const file of
|
|
68
|
+
for (const file of filesToApply) {
|
|
48
69
|
assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
|
|
49
70
|
if (workspaceFileExists(file.absPath) &&
|
|
50
71
|
!mayReplace(file) &&
|
|
@@ -54,23 +75,26 @@ export function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDi
|
|
|
54
75
|
}
|
|
55
76
|
// Parent directories remain on failure because they may contain concurrent
|
|
56
77
|
// workspace state that this call does not own.
|
|
57
|
-
for (const file of
|
|
78
|
+
for (const file of filesToApply) {
|
|
58
79
|
mkdirSync(dirname(file.absPath), { recursive: true });
|
|
59
80
|
}
|
|
60
|
-
for (const file of
|
|
81
|
+
for (const file of filesToApply) {
|
|
61
82
|
assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
|
|
62
83
|
}
|
|
63
84
|
const prepared = [];
|
|
64
85
|
// Prepared temp links stay alive through publication and rollback. Their
|
|
65
86
|
// inodes distinguish files created here from concurrent replacements.
|
|
66
87
|
try {
|
|
67
|
-
for (const file of
|
|
88
|
+
for (const file of filesToApply) {
|
|
68
89
|
if (existingFiles === "allow-identical" &&
|
|
69
90
|
workspaceFileExists(file.absPath) &&
|
|
70
|
-
workspaceFileMatches(file)
|
|
91
|
+
workspaceFileMatches(file) &&
|
|
92
|
+
(file.ownership !== "materializer" ||
|
|
93
|
+
ownership === undefined ||
|
|
94
|
+
workspaceFileHasOwnership(file.absPath, ownership))) {
|
|
71
95
|
continue;
|
|
72
96
|
}
|
|
73
|
-
prepared.push(prepareWorkspaceFile(workspaceRoot, file));
|
|
97
|
+
prepared.push(prepareWorkspaceFile(workspaceRoot, file, ownership));
|
|
74
98
|
}
|
|
75
99
|
for (const entry of prepared) {
|
|
76
100
|
assertNoSymlinkPathComponents(workspaceRoot, entry.file.absPath);
|
|
@@ -81,7 +105,10 @@ export function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDi
|
|
|
81
105
|
catch (error) {
|
|
82
106
|
if (isFsError(error, "EEXIST") &&
|
|
83
107
|
existingFiles === "allow-identical" &&
|
|
84
|
-
workspaceFileMatches(entry.file)
|
|
108
|
+
workspaceFileMatches(entry.file) &&
|
|
109
|
+
(entry.file.ownership !== "materializer" ||
|
|
110
|
+
ownership === undefined ||
|
|
111
|
+
workspaceFileHasOwnership(entry.file.absPath, ownership))) {
|
|
85
112
|
continue;
|
|
86
113
|
}
|
|
87
114
|
if (isFsError(error, "EEXIST") && mayReplace(entry.file)) {
|
|
@@ -159,157 +186,6 @@ export function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDi
|
|
|
159
186
|
runtimeOnlyFiles: privateRuntimeFiles,
|
|
160
187
|
};
|
|
161
188
|
}
|
|
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
189
|
export function safeWorkspacePlanReceipt(plan, written, workspacePlanDigest = hashWorkspacePlan(plan)) {
|
|
314
190
|
return {
|
|
315
191
|
workspacePlanDigest,
|
|
@@ -324,11 +200,12 @@ export function safeWorkspacePlanReceipt(plan, written, workspacePlanDigest = ha
|
|
|
324
200
|
written: [...written],
|
|
325
201
|
};
|
|
326
202
|
}
|
|
327
|
-
import {
|
|
328
|
-
import {
|
|
329
|
-
import {
|
|
203
|
+
import { linkSync, mkdirSync, renameSync, unlinkSync } from "node:fs";
|
|
204
|
+
import { dirname, resolve } from "node:path";
|
|
205
|
+
import { isMaterializerOutput } from "./harness.js";
|
|
330
206
|
import { hashWorkspacePlan } from "./plan-digest.js";
|
|
331
|
-
import { normalizeWorkspacePlanArgument, } from "./plan-normalize.js";
|
|
207
|
+
import { normalizePlanFiles, normalizeWorkspacePlanArgument, } from "./plan-normalize.js";
|
|
332
208
|
import { promptIntentFields, samePromptIntentValue, systemPromptBindingField, } from "./plan-prompt.js";
|
|
333
209
|
import { WorkspacePlanCleanupError } from "./plan-types.js";
|
|
334
|
-
import {
|
|
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.
|
|
3
|
+
"version": "0.17.3",
|
|
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",
|