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