@tangle-network/agent-profile-materialize 0.23.0 → 0.25.2
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/candidate.js +1 -0
- package/dist/managed-workspace-stage.d.ts +1 -1
- package/dist/plan-application.js +38 -6
- package/dist/plan-types.d.ts +9 -0
- package/dist/profile-bundle-cache.d.ts +2 -2
- package/dist/profile-bundle-cache.js +8 -6
- package/dist/profile-bundle-lru.d.ts +5 -6
- package/dist/profile-bundle-lru.js +19 -10
- package/dist/profile-materializer.js +4 -0
- package/dist/secret-concurrent-revision.js +9 -2
- package/package.json +4 -4
package/dist/candidate.js
CHANGED
|
@@ -20,5 +20,5 @@ export declare function assertPrivateManagedRoot(root: string): void;
|
|
|
20
20
|
* The caller must keep the returned tree while an admitted turn uses it and
|
|
21
21
|
* dispose it after that turn, so a later binding cannot expose old secrets.
|
|
22
22
|
*/
|
|
23
|
-
export declare function prepareManagedWorkspacePlan(plan: WorkspacePlan, managedRoot: string, sharedWorkspaceDir: string, secretProvider: AgentProfileSecretProvider, options?: Pick<ApplyWorkspacePlanOptions, "ownership" | "runtimeOnlyFiles">): Promise<PreparedManagedWorkspace>;
|
|
23
|
+
export declare function prepareManagedWorkspacePlan(plan: WorkspacePlan, managedRoot: string, sharedWorkspaceDir: string, secretProvider: AgentProfileSecretProvider, options?: Pick<ApplyWorkspacePlanOptions, "ownership" | "runtimeOnlyFiles" | "secretFileSlotEnvironment">): Promise<PreparedManagedWorkspace>;
|
|
24
24
|
export declare function lstatIfPresent(path: string): ReturnType<typeof lstatSync> | null;
|
package/dist/plan-application.js
CHANGED
|
@@ -9,7 +9,7 @@ export function applyWorkspacePlan(plan, workspaceDir, options = {}) {
|
|
|
9
9
|
*/
|
|
10
10
|
export async function applyWorkspacePlanWithSecretProvider(plan, workspaceDir, secretProvider, options = {}) {
|
|
11
11
|
const planDigest = hashWorkspacePlan(plan);
|
|
12
|
-
const resolved = await resolvePrivateWorkspacePlan(plan, secretProvider);
|
|
12
|
+
const resolved = await resolvePrivateWorkspacePlan(plan, secretProvider, options.secretFileSlotEnvironment);
|
|
13
13
|
return applyResolvedWorkspacePlan(plan, resolved, workspaceDir, options, planDigest);
|
|
14
14
|
}
|
|
15
15
|
function secretRefLabel(ref) {
|
|
@@ -78,13 +78,27 @@ function resolvePublicWorkspacePlan(plan) {
|
|
|
78
78
|
...systemPromptBindingField(plan),
|
|
79
79
|
};
|
|
80
80
|
}
|
|
81
|
-
|
|
81
|
+
/**
|
|
82
|
+
* OpenCode substitutes `{env:NAME}` textually into its config before parsing
|
|
83
|
+
* it, so only a value that cannot break out of a JSON string or start another
|
|
84
|
+
* substitution may be referenced; any other value is written in place.
|
|
85
|
+
*/
|
|
86
|
+
const SUBSTITUTABLE_SECRET_VALUE = /^[\x20-\x7e]+$/;
|
|
87
|
+
const UNSUBSTITUTABLE_SECRET_CHARACTERS = /["\\{}]/;
|
|
88
|
+
function secretSlotEnvironmentName(ref) {
|
|
89
|
+
const digest = createHash("sha256")
|
|
90
|
+
.update(ref.key, "utf8")
|
|
91
|
+
.digest("hex")
|
|
92
|
+
.slice(0, 24)
|
|
93
|
+
.toUpperCase();
|
|
94
|
+
return `TANGLE_PROFILE_SECRET_${digest}`;
|
|
95
|
+
}
|
|
96
|
+
async function resolvePrivateWorkspacePlan(plan, secretProvider, fileSlotEnvironment) {
|
|
82
97
|
if (!secretProvider || typeof secretProvider.get !== "function") {
|
|
83
98
|
throw new Error("AgentProfileSecretProvider.get is required");
|
|
84
99
|
}
|
|
85
100
|
const pending = new Map();
|
|
86
|
-
const
|
|
87
|
-
const ref = agentProfileSecretRefSchema.parse(input);
|
|
101
|
+
const resolveRawRef = (ref) => {
|
|
88
102
|
let raw = pending.get(ref.key);
|
|
89
103
|
if (!raw) {
|
|
90
104
|
raw = (async () => {
|
|
@@ -99,16 +113,33 @@ async function resolvePrivateWorkspacePlan(plan, secretProvider) {
|
|
|
99
113
|
})();
|
|
100
114
|
pending.set(ref.key, raw);
|
|
101
115
|
}
|
|
102
|
-
|
|
116
|
+
return raw;
|
|
117
|
+
};
|
|
118
|
+
const resolveRef = async (input) => {
|
|
119
|
+
const ref = agentProfileSecretRefSchema.parse(input);
|
|
120
|
+
const value = await resolveRawRef(ref);
|
|
103
121
|
return ref.format === "bearer" ? `Bearer ${value}` : value;
|
|
104
122
|
};
|
|
123
|
+
const resolveFileSlot = async (input) => {
|
|
124
|
+
const ref = agentProfileSecretRefSchema.parse(input);
|
|
125
|
+
const value = await resolveRawRef(ref);
|
|
126
|
+
if (!fileSlotEnvironment ||
|
|
127
|
+
!SUBSTITUTABLE_SECRET_VALUE.test(value) ||
|
|
128
|
+
UNSUBSTITUTABLE_SECRET_CHARACTERS.test(value)) {
|
|
129
|
+
return ref.format === "bearer" ? `Bearer ${value}` : value;
|
|
130
|
+
}
|
|
131
|
+
const name = secretSlotEnvironmentName(ref);
|
|
132
|
+
fileSlotEnvironment[name] = value;
|
|
133
|
+
return ref.format === "bearer" ? `Bearer {env:${name}}` : `{env:${name}}`;
|
|
134
|
+
};
|
|
105
135
|
const files = await Promise.all(normalizePlanFiles(plan.files).map(async (file) => {
|
|
106
136
|
if (file.secretSlots.length === 0)
|
|
107
137
|
return file;
|
|
138
|
+
// Generated native config files with secret slots are JSON objects.
|
|
108
139
|
const parsed = JSON.parse(file.content);
|
|
109
140
|
const values = await Promise.all(file.secretSlots.map(async (slot) => ({
|
|
110
141
|
path: slot.path,
|
|
111
|
-
value: await
|
|
142
|
+
value: await resolveFileSlot(slot.ref),
|
|
112
143
|
})));
|
|
113
144
|
for (const resolved of values) {
|
|
114
145
|
setValueAtJsonPath(parsed, resolved.path, resolved.value);
|
|
@@ -145,6 +176,7 @@ async function resolvePrivateWorkspacePlan(plan, secretProvider) {
|
|
|
145
176
|
...systemPromptBindingField(plan),
|
|
146
177
|
};
|
|
147
178
|
}
|
|
179
|
+
import { createHash } from "node:crypto";
|
|
148
180
|
import { agentProfileSecretRefSchema } from "@tangle-network/agent-interface";
|
|
149
181
|
import { hashWorkspacePlan } from "./plan-digest.js";
|
|
150
182
|
import { normalizePlanFiles, normalizeWorkspacePlanArgument, } from "./plan-normalize.js";
|
package/dist/plan-types.d.ts
CHANGED
|
@@ -320,6 +320,15 @@ export interface ApplyWorkspacePlanOptions {
|
|
|
320
320
|
* refusal.
|
|
321
321
|
*/
|
|
322
322
|
appliedPlanDigest?: string;
|
|
323
|
+
/**
|
|
324
|
+
* Receives the values of secrets that JSON file slots reference instead of
|
|
325
|
+
* containing. When set, a slot whose resolved value is safe to substitute
|
|
326
|
+
* is written as `{env:NAME}` (OpenCode config substitution) and the value
|
|
327
|
+
* lands here under NAME, so file bytes and every digest over them stay
|
|
328
|
+
* identical when only a secret's value changes. The caller must add these
|
|
329
|
+
* entries to the launch environment after digesting the launch.
|
|
330
|
+
*/
|
|
331
|
+
secretFileSlotEnvironment?: Record<string, string>;
|
|
323
332
|
}
|
|
324
333
|
import type { AgentProfileSecretRef } from "@tangle-network/agent-interface";
|
|
325
334
|
import type { HarnessId } from "./harness.js";
|
|
@@ -29,8 +29,8 @@ export declare function claimProfileBundle(root: string, claim: ProfileBundleCla
|
|
|
29
29
|
}) => Promise<boolean>): Promise<ProfileBundleClaimResult>;
|
|
30
30
|
/**
|
|
31
31
|
* Open a host-side cache handle over `root`, creating the layout and sweeping
|
|
32
|
-
* stale `incoming/` residue synchronously
|
|
33
|
-
*
|
|
32
|
+
* stale `incoming/` residue synchronously when the root is idle. A busy root
|
|
33
|
+
* leaves cleanup to its serialized admissions and sweeps.
|
|
34
34
|
*/
|
|
35
35
|
export declare function openProfileBundleCache(root: string, options?: {
|
|
36
36
|
maxBytes?: number;
|
|
@@ -3,7 +3,7 @@ import { mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
|
3
3
|
import { mkdir, open, readFile, rename, rm, stat, writeFile, } from "node:fs/promises";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { PROFILE_BUNDLE_DEFAULT_MAX_BYTES, PROFILE_BUNDLE_MAX_BYTES, PROFILE_BUNDLE_MAX_FILE_BYTES, PROFILE_BUNDLE_MAX_FILES, PROFILE_BUNDLE_SCHEMA, } from "./profile-bundle-key.js";
|
|
6
|
-
import { evictProfileBundles, evictUnlocked, fsyncDir, loadLru, pathExists, profileBundleManifestPath, readManifestFile, saveLru, sweepProfileBundleCache, withRootLock, } from "./profile-bundle-lru.js";
|
|
6
|
+
import { evictProfileBundles, evictUnlocked, fsyncDir, hasPendingRootWork, loadLru, pathExists, profileBundleManifestPath, readManifestFile, saveLru, sweepProfileBundleCache, sweepUnlocked, withRootLock, } from "./profile-bundle-lru.js";
|
|
7
7
|
import { hasControlCharacter, normalizeWorkspaceRelativePath, validateFileMode, } from "./workspace-files.js";
|
|
8
8
|
export * from "./profile-bundle-key.js";
|
|
9
9
|
export { evictProfileBundles, profileBundleManifestPath, sweepProfileBundleCache, } from "./profile-bundle-lru.js";
|
|
@@ -116,11 +116,11 @@ function validateManifestFiles(manifest) {
|
|
|
116
116
|
export async function admitProfileBundle(root, manifest, readObject) {
|
|
117
117
|
validateManifestShape(manifest);
|
|
118
118
|
const entries = validateManifestFiles(manifest);
|
|
119
|
-
await sweepProfileBundleCache(root);
|
|
120
119
|
await mkdir(join(root, "objects"), { recursive: true });
|
|
121
120
|
await mkdir(join(root, "bundles"), { recursive: true });
|
|
122
121
|
await mkdir(join(root, "incoming"), { recursive: true });
|
|
123
122
|
await withRootLock(root, async () => {
|
|
123
|
+
await sweepUnlocked(root);
|
|
124
124
|
const manifestPath = profileBundleManifestPath(root, manifest.bundle);
|
|
125
125
|
const existing = await readManifestFile(manifestPath);
|
|
126
126
|
if (existing && existing.bundle !== manifest.bundle) {
|
|
@@ -293,8 +293,8 @@ export async function claimProfileBundle(root, claim, linkFile) {
|
|
|
293
293
|
}
|
|
294
294
|
/**
|
|
295
295
|
* Open a host-side cache handle over `root`, creating the layout and sweeping
|
|
296
|
-
* stale `incoming/` residue synchronously
|
|
297
|
-
*
|
|
296
|
+
* stale `incoming/` residue synchronously when the root is idle. A busy root
|
|
297
|
+
* leaves cleanup to its serialized admissions and sweeps.
|
|
298
298
|
*/
|
|
299
299
|
export function openProfileBundleCache(root, options) {
|
|
300
300
|
const maxBytes = options?.maxBytes ?? PROFILE_BUNDLE_DEFAULT_MAX_BYTES;
|
|
@@ -305,8 +305,10 @@ export function openProfileBundleCache(root, options) {
|
|
|
305
305
|
mkdirSync(join(root, "bundles"), { recursive: true });
|
|
306
306
|
const incoming = join(root, "incoming");
|
|
307
307
|
mkdirSync(incoming, { recursive: true });
|
|
308
|
-
|
|
309
|
-
|
|
308
|
+
if (!hasPendingRootWork(root)) {
|
|
309
|
+
for (const entry of readdirSync(incoming)) {
|
|
310
|
+
rmSync(join(incoming, entry), { recursive: true, force: true });
|
|
311
|
+
}
|
|
310
312
|
}
|
|
311
313
|
return {
|
|
312
314
|
root,
|
|
@@ -20,6 +20,8 @@ export interface ProfileBundleLru {
|
|
|
20
20
|
export declare function pathExists(path: string): Promise<boolean>;
|
|
21
21
|
export declare function fsyncDir(dir: string): Promise<void>;
|
|
22
22
|
export declare function withRootLock<T>(root: string, run: () => Promise<T>): Promise<T>;
|
|
23
|
+
/** Synchronous open must not sweep a root with pending asynchronous work. */
|
|
24
|
+
export declare function hasPendingRootWork(root: string): boolean;
|
|
23
25
|
export declare function readManifestFile(path: string): Promise<{
|
|
24
26
|
schema?: unknown;
|
|
25
27
|
bundle?: unknown;
|
|
@@ -32,13 +34,10 @@ export declare function readManifestFile(path: string): Promise<{
|
|
|
32
34
|
* The raw key is always recoverable from the manifest's `bundle` field.
|
|
33
35
|
*/
|
|
34
36
|
export declare function profileBundleManifestPath(root: string, bundle: string): string;
|
|
35
|
-
/**
|
|
36
|
-
/**
|
|
37
|
-
* Remove stale `incoming/` admission directories. An admit only ever renames
|
|
38
|
-
* OUT of `incoming/`, so anything found here is residue from an interrupted
|
|
39
|
-
* admission and is safe to delete; objects and manifests are untouched.
|
|
40
|
-
*/
|
|
37
|
+
/** Sweep only while holding the per-root lock; active admissions own incoming/. */
|
|
41
38
|
export declare function sweepProfileBundleCache(root: string): Promise<void>;
|
|
39
|
+
/** Remove interrupted admission residue; the caller must hold the per-root lock. */
|
|
40
|
+
export declare function sweepUnlocked(root: string): Promise<void>;
|
|
42
41
|
export declare function loadLru(root: string): Promise<ProfileBundleLru>;
|
|
43
42
|
/** Best-effort by contract: lru corruption or a full cache must never break admit/claim. */
|
|
44
43
|
export declare function saveLru(root: string, lru: ProfileBundleLru): Promise<void>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { open, readdir, readFile, rename, rm, stat, writeFile, } from "node:fs/promises";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
4
|
import { compareStrings } from "./workspace-files.js";
|
|
5
5
|
export async function pathExists(path) {
|
|
6
6
|
try {
|
|
@@ -25,14 +25,24 @@ export async function fsyncDir(dir) {
|
|
|
25
25
|
// Best-effort durability: a cache without directory fsync still verifies.
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
-
/** Serialize
|
|
28
|
+
/** Serialize admissions, sweeps, and evictions per cache root. */
|
|
29
29
|
const rootQueues = new Map();
|
|
30
30
|
export function withRootLock(root, run) {
|
|
31
|
-
const
|
|
31
|
+
const key = resolve(root);
|
|
32
|
+
const previous = rootQueues.get(key) ?? Promise.resolve();
|
|
32
33
|
const next = previous.then(run, run);
|
|
33
|
-
|
|
34
|
+
const settled = next.then(() => undefined, () => undefined);
|
|
35
|
+
rootQueues.set(key, settled);
|
|
36
|
+
void settled.then(() => {
|
|
37
|
+
if (rootQueues.get(key) === settled)
|
|
38
|
+
rootQueues.delete(key);
|
|
39
|
+
});
|
|
34
40
|
return next;
|
|
35
41
|
}
|
|
42
|
+
/** Synchronous open must not sweep a root with pending asynchronous work. */
|
|
43
|
+
export function hasPendingRootWork(root) {
|
|
44
|
+
return rootQueues.has(resolve(root));
|
|
45
|
+
}
|
|
36
46
|
export async function readManifestFile(path) {
|
|
37
47
|
try {
|
|
38
48
|
return JSON.parse(await readFile(path, "utf8"));
|
|
@@ -50,13 +60,12 @@ export function profileBundleManifestPath(root, bundle) {
|
|
|
50
60
|
const filename = bundle.replace(/[^A-Za-z0-9._-]/g, (char) => `_${char.codePointAt(0)?.toString(16) ?? "3f"}_`);
|
|
51
61
|
return join(root, "bundles", `${filename}.json`);
|
|
52
62
|
}
|
|
53
|
-
/**
|
|
54
|
-
/**
|
|
55
|
-
* Remove stale `incoming/` admission directories. An admit only ever renames
|
|
56
|
-
* OUT of `incoming/`, so anything found here is residue from an interrupted
|
|
57
|
-
* admission and is safe to delete; objects and manifests are untouched.
|
|
58
|
-
*/
|
|
63
|
+
/** Sweep only while holding the per-root lock; active admissions own incoming/. */
|
|
59
64
|
export async function sweepProfileBundleCache(root) {
|
|
65
|
+
await withRootLock(root, () => sweepUnlocked(root));
|
|
66
|
+
}
|
|
67
|
+
/** Remove interrupted admission residue; the caller must hold the per-root lock. */
|
|
68
|
+
export async function sweepUnlocked(root) {
|
|
60
69
|
const incoming = join(root, "incoming");
|
|
61
70
|
let entries;
|
|
62
71
|
try {
|
|
@@ -40,6 +40,10 @@ export function executionControlConflict(profile, harness) {
|
|
|
40
40
|
export function materializeProfile(inputProfile, harness, opts = {}) {
|
|
41
41
|
assertMaterializerHarness(harness);
|
|
42
42
|
const profile = agentProfileSchema.parse(inputProfile);
|
|
43
|
+
const knowledge = profile.resources && Reflect.get(profile.resources, "knowledge");
|
|
44
|
+
if (Array.isArray(knowledge) && knowledge.length > 0) {
|
|
45
|
+
throw new Error("Knowledge source generations must be owner-authorized and resolved by the execution runtime before materialization");
|
|
46
|
+
}
|
|
43
47
|
const skip = new Set(assertSkippableDimensions(opts.skip));
|
|
44
48
|
const binds = assertBindableLaunchControls(opts.binds);
|
|
45
49
|
const instructionsRoot = opts.instructionsRoot ?? "workspace";
|
|
@@ -42,7 +42,10 @@ export async function prepareSecretConcurrentProfileRevision(input) {
|
|
|
42
42
|
}
|
|
43
43
|
assertWorkspacePlanSupported(plan);
|
|
44
44
|
assertLaunchRoot(input.launchRoot);
|
|
45
|
-
|
|
45
|
+
// Secret values stay out of the revision: file slots reference them, so an
|
|
46
|
+
// unchanged profile with a rotated per-turn secret reuses this revision.
|
|
47
|
+
const secretEnvironment = {};
|
|
48
|
+
const prepared = await prepareManagedWorkspacePlan(plan, input.managedRoot, input.sharedWorkspaceDir, input.secretProvider, { ...input.options, secretFileSlotEnvironment: secretEnvironment });
|
|
46
49
|
try {
|
|
47
50
|
// XDG_CONFIG_HOME is bound to this executor-owned tree at native launch.
|
|
48
51
|
// An agent-owned HOME may hold SQLite state, never native authority.
|
|
@@ -82,10 +85,14 @@ export async function prepareSecretConcurrentProfileRevision(input) {
|
|
|
82
85
|
prepared.dispose();
|
|
83
86
|
}
|
|
84
87
|
}
|
|
88
|
+
// Added after launchDigest so the digest and receipt never cover a value.
|
|
85
89
|
return {
|
|
86
90
|
directory,
|
|
87
91
|
manifest: prepared.manifest,
|
|
88
|
-
application: {
|
|
92
|
+
application: {
|
|
93
|
+
...prepared.application,
|
|
94
|
+
spawn: { ...spawn, env: { ...spawn.env, ...secretEnvironment } },
|
|
95
|
+
},
|
|
89
96
|
};
|
|
90
97
|
}
|
|
91
98
|
catch (error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tangle-network/agent-profile-materialize",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.2",
|
|
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
|
"repository": {
|
|
@@ -25,14 +25,14 @@
|
|
|
25
25
|
"dist"
|
|
26
26
|
],
|
|
27
27
|
"devDependencies": {
|
|
28
|
-
"@tangle-network/agent-interface": "
|
|
29
|
-
"@tangle-network/agent-interface-current": "npm:@tangle-network/agent-interface@
|
|
28
|
+
"@tangle-network/agent-interface": "5.0.0",
|
|
29
|
+
"@tangle-network/agent-interface-current": "npm:@tangle-network/agent-interface@5.0.0",
|
|
30
30
|
"@types/node": "25.6.0",
|
|
31
31
|
"typescript": "^6.0.3",
|
|
32
32
|
"vitest": "^4.1.11"
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
|
35
|
-
"@tangle-network/agent-interface": "^3.0.0"
|
|
35
|
+
"@tangle-network/agent-interface": "^3.0.0 || ^4.0.0 || ^5.0.0"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
38
|
"build": "tsc -p tsconfig.json",
|