@tangle-network/agent-profile-materialize 0.26.8 → 0.27.0
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 -0
- package/dist/index.js +1 -0
- package/dist/managed-workspace-bind.js +18 -3
- package/dist/plan-types.d.ts +7 -0
- package/dist/secret-session-view.d.ts +11 -5
- package/dist/secret-session-view.js +65 -22
- package/dist/tool-bin-path.d.ts +23 -0
- package/dist/tool-bin-path.js +61 -0
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -27,3 +27,4 @@ export type { ResolveAgentProfileResourcesOptions } from "./resource-resolution.
|
|
|
27
27
|
export { normalizeSkillMd, resolveAgentProfileResources, } from "./resource-resolution.js";
|
|
28
28
|
export { prepareSecretConcurrentProfileRevision } from "./secret-concurrent-revision.js";
|
|
29
29
|
export * from "./secret-session-view.js";
|
|
30
|
+
export * from "./tool-bin-path.js";
|
package/dist/index.js
CHANGED
|
@@ -21,3 +21,4 @@ export * from "./profile-start-check.js";
|
|
|
21
21
|
export { normalizeSkillMd, resolveAgentProfileResources, } from "./resource-resolution.js";
|
|
22
22
|
export { prepareSecretConcurrentProfileRevision } from "./secret-concurrent-revision.js";
|
|
23
23
|
export * from "./secret-session-view.js";
|
|
24
|
+
export * from "./tool-bin-path.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { join } from "node:path";
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
2
|
/**
|
|
3
3
|
* Paths a secret OpenCode revision serves from its own immutable tree:
|
|
4
4
|
* read-only profile content under `reference/`, skills, and OpenCode's
|
|
@@ -88,9 +88,19 @@ export function bindConcurrentLaunch(plan, application, revisionWorkspaceDir, op
|
|
|
88
88
|
break;
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
|
+
// A declared executable reference/ tool runs from the immutable revision:
|
|
92
|
+
// its directory, absolute in that revision, joins the launch PATH.
|
|
93
|
+
const toolBinDirs = [];
|
|
91
94
|
for (const file of plan.files) {
|
|
92
|
-
if (ridesSecretRevision(plan, file, options))
|
|
95
|
+
if (ridesSecretRevision(plan, file, options)) {
|
|
96
|
+
if (file.relPath.startsWith("reference/") &&
|
|
97
|
+
((file.mode ?? 0) & 0o111) !== 0) {
|
|
98
|
+
const dir = join(revisionWorkspaceDir, dirname(file.relPath));
|
|
99
|
+
if (!toolBinDirs.includes(dir))
|
|
100
|
+
toolBinDirs.push(dir);
|
|
101
|
+
}
|
|
93
102
|
continue;
|
|
103
|
+
}
|
|
94
104
|
if (file.source !== "generated") {
|
|
95
105
|
throw new Error(`Concurrent ${plan.harness} profile resource ${file.relPath} needs a shared-workspace collision-safe copy`);
|
|
96
106
|
}
|
|
@@ -104,5 +114,10 @@ export function bindConcurrentLaunch(plan, application, revisionWorkspaceDir, op
|
|
|
104
114
|
throw new Error(`Concurrent ${plan.harness} profile cannot bind runtime-only file ${name}`);
|
|
105
115
|
}
|
|
106
116
|
}
|
|
107
|
-
return {
|
|
117
|
+
return {
|
|
118
|
+
...application.spawn,
|
|
119
|
+
flags,
|
|
120
|
+
env,
|
|
121
|
+
...(toolBinDirs.length > 0 ? { toolBinDirs } : {}),
|
|
122
|
+
};
|
|
108
123
|
}
|
package/dist/plan-types.d.ts
CHANGED
|
@@ -284,6 +284,13 @@ export interface WorkspacePlanSpawnInputs {
|
|
|
284
284
|
appendSystemPrompt?: string;
|
|
285
285
|
/** Read it with {@link takeSystemPromptBinding}, which refuses a foreign control. */
|
|
286
286
|
systemPromptBinding?: WorkspacePlanSystemPromptBinding;
|
|
287
|
+
/**
|
|
288
|
+
* Absolute dirs of the profile's declared executable `reference/` tools in
|
|
289
|
+
* the immutable revision the launch runs from. The provider puts them on
|
|
290
|
+
* the launch PATH in place of the app's own tool dirs (bindLaunchToolBinDirs),
|
|
291
|
+
* so a profile names its CLI bare.
|
|
292
|
+
*/
|
|
293
|
+
toolBinDirs?: string[];
|
|
287
294
|
}
|
|
288
295
|
/** Result of applying a public plan at the private executor boundary. */
|
|
289
296
|
export interface WorkspacePlanApplication {
|
|
@@ -13,11 +13,17 @@ export declare function secretSessionViewWorkspace(input: {
|
|
|
13
13
|
}): string;
|
|
14
14
|
/**
|
|
15
15
|
* Point a session's view at one published secret revision before the
|
|
16
|
-
* session's turn runs. The view's `reference/`
|
|
17
|
-
* links to the revision's read-only files (the same
|
|
18
|
-
* ACL), so the session reads this turn's files and
|
|
19
|
-
*
|
|
20
|
-
*
|
|
16
|
+
* session's turn runs. The view's `reference/` and `.opencode/skills/` become
|
|
17
|
+
* fresh trees of hard links to the revision's read-only files (the same
|
|
18
|
+
* inodes, owner, mode and ACL), so the session reads this turn's files and
|
|
19
|
+
* skills and nothing the previous revision held. A product's skill index
|
|
20
|
+
* names `.opencode/skills/<id>/SKILL.md` cwd-relative, and OpenCode's skill
|
|
21
|
+
* tool lists the same files through the revision's config directory.
|
|
22
|
+
*
|
|
23
|
+
* The view holds nothing else: no `opencode.json`, and a `.opencode/` with
|
|
24
|
+
* only the revision's digest-attested skills, which the launch does not load
|
|
25
|
+
* as project config (it sets OPENCODE_DISABLE_PROJECT_CONFIG). Profile config
|
|
26
|
+
* stays bound by absolute path.
|
|
21
27
|
*
|
|
22
28
|
* The swap is not atomic for a reader that is already running: the caller
|
|
23
29
|
* runs one turn of a session at a time and binds before that turn spawns.
|
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { chmodSync, linkSync, lstatSync, mkdirSync, readdirSync, renameSync, rmSync, } from "node:fs";
|
|
3
3
|
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { HARNESS_SKILL_DIRS } from "./harness.js";
|
|
4
5
|
import { assertLaunchRoot } from "./managed-workspace.js";
|
|
5
6
|
import { runSetfacl } from "./secret-concurrent-revision.js";
|
|
6
|
-
/** The
|
|
7
|
-
const
|
|
7
|
+
/** The profile's read-only files, which products name cwd-relative. */
|
|
8
|
+
const REFERENCE_ENTRY = "reference";
|
|
9
|
+
/**
|
|
10
|
+
* The profile's mounted skills, at the cwd-relative directory the platform
|
|
11
|
+
* publishes for products to name in their prompts (`.opencode/skills`).
|
|
12
|
+
*/
|
|
13
|
+
const SKILLS_DIR = HARNESS_SKILL_DIRS.opencode;
|
|
14
|
+
const SKILLS_ENTRY = dirname(SKILLS_DIR);
|
|
15
|
+
/** The only cwd-relative content a secret OpenCode session view serves. */
|
|
16
|
+
const VIEW_ENTRIES = new Set([
|
|
17
|
+
REFERENCE_ENTRY,
|
|
18
|
+
SKILLS_ENTRY,
|
|
19
|
+
]);
|
|
8
20
|
/**
|
|
9
21
|
* The stable, sealed cwd of one secret OpenCode session. OpenCode reopens a
|
|
10
22
|
* native session at the directory it was created in, so a session resumed on
|
|
@@ -27,11 +39,17 @@ export function secretSessionViewWorkspace(input) {
|
|
|
27
39
|
}
|
|
28
40
|
/**
|
|
29
41
|
* Point a session's view at one published secret revision before the
|
|
30
|
-
* session's turn runs. The view's `reference/`
|
|
31
|
-
* links to the revision's read-only files (the same
|
|
32
|
-
* ACL), so the session reads this turn's files and
|
|
33
|
-
*
|
|
34
|
-
*
|
|
42
|
+
* session's turn runs. The view's `reference/` and `.opencode/skills/` become
|
|
43
|
+
* fresh trees of hard links to the revision's read-only files (the same
|
|
44
|
+
* inodes, owner, mode and ACL), so the session reads this turn's files and
|
|
45
|
+
* skills and nothing the previous revision held. A product's skill index
|
|
46
|
+
* names `.opencode/skills/<id>/SKILL.md` cwd-relative, and OpenCode's skill
|
|
47
|
+
* tool lists the same files through the revision's config directory.
|
|
48
|
+
*
|
|
49
|
+
* The view holds nothing else: no `opencode.json`, and a `.opencode/` with
|
|
50
|
+
* only the revision's digest-attested skills, which the launch does not load
|
|
51
|
+
* as project config (it sets OPENCODE_DISABLE_PROJECT_CONFIG). Profile config
|
|
52
|
+
* stays bound by absolute path.
|
|
35
53
|
*
|
|
36
54
|
* The swap is not atomic for a reader that is already running: the caller
|
|
37
55
|
* runs one turn of a session at a time and binds before that turn spawns.
|
|
@@ -61,32 +79,57 @@ export function bindSecretSessionView(input) {
|
|
|
61
79
|
throw new Error("Secret session view is not an executor-owned directory");
|
|
62
80
|
}
|
|
63
81
|
}
|
|
64
|
-
if (readdirSync(viewWorkspaceDir).some((name) => name
|
|
82
|
+
if (readdirSync(viewWorkspaceDir).some((name) => !VIEW_ENTRIES.has(name))) {
|
|
65
83
|
throw new Error("Secret session view holds an unexpected entry");
|
|
66
84
|
}
|
|
67
85
|
// Only this function writes a view. An interrupted bind leaves a staged or
|
|
68
86
|
// retired tree beside `workspace`, never inside it.
|
|
69
87
|
clearBesideWorkspace(viewRoot);
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
88
|
+
const staged = new Map();
|
|
89
|
+
const reference = sealedRevisionDirectory(revisionWorkspaceDir, REFERENCE_ENTRY);
|
|
90
|
+
if (reference) {
|
|
91
|
+
const target = besideWorkspace(viewRoot, "stage");
|
|
92
|
+
sealDirectories(linkSealedTree(reference, target), input.subprocessUid);
|
|
93
|
+
staged.set(REFERENCE_ENTRY, target);
|
|
94
|
+
}
|
|
95
|
+
const skills = sealedRevisionDirectory(revisionWorkspaceDir, SKILLS_DIR);
|
|
96
|
+
if (skills) {
|
|
97
|
+
const target = besideWorkspace(viewRoot, "stage");
|
|
98
|
+
mkdirSync(target, { mode: 0o700 });
|
|
99
|
+
const directories = [
|
|
100
|
+
target,
|
|
101
|
+
...linkSealedTree(skills, join(target, basename(SKILLS_DIR))),
|
|
102
|
+
];
|
|
79
103
|
sealDirectories(directories, input.subprocessUid);
|
|
104
|
+
staged.set(SKILLS_ENTRY, target);
|
|
80
105
|
}
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
106
|
+
for (const entry of VIEW_ENTRIES) {
|
|
107
|
+
const current = join(viewWorkspaceDir, entry);
|
|
108
|
+
if (lstatIfPresent(current)) {
|
|
109
|
+
renameSync(current, besideWorkspace(viewRoot, "retired"));
|
|
110
|
+
}
|
|
111
|
+
const next = staged.get(entry);
|
|
112
|
+
if (next)
|
|
113
|
+
renameSync(next, current);
|
|
84
114
|
}
|
|
85
|
-
if (staged)
|
|
86
|
-
renameSync(staged, current);
|
|
87
115
|
clearBesideWorkspace(viewRoot);
|
|
88
116
|
sealDirectories([viewRoot, viewWorkspaceDir], input.subprocessUid);
|
|
89
117
|
}
|
|
118
|
+
/** A sealed directory of the published revision, or undefined when absent. */
|
|
119
|
+
function sealedRevisionDirectory(revisionWorkspaceDir, relPath) {
|
|
120
|
+
const source = join(revisionWorkspaceDir, relPath);
|
|
121
|
+
const stat = lstatIfPresent(source);
|
|
122
|
+
if (!stat)
|
|
123
|
+
return undefined;
|
|
124
|
+
if (!isSealedNode(stat, "directory")) {
|
|
125
|
+
throw new Error(`Secret revision ${relPath}/ is not sealed`);
|
|
126
|
+
}
|
|
127
|
+
return source;
|
|
128
|
+
}
|
|
129
|
+
/** A fresh staged or retired path beside the view's `workspace`. */
|
|
130
|
+
function besideWorkspace(viewRoot, kind) {
|
|
131
|
+
return join(viewRoot, `.${kind}-${randomBytes(8).toString("hex")}`);
|
|
132
|
+
}
|
|
90
133
|
function isLaunchWorkspace(launchRoot, workspace, name) {
|
|
91
134
|
return (basename(workspace) === "workspace" &&
|
|
92
135
|
dirname(dirname(workspace)) === launchRoot &&
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** The colon-separated, absolute CLI bin dirs a sandbox app mounts for its tools. */
|
|
2
|
+
export declare const SANDBOX_TOOL_BIN_DIRS_ENV = "SANDBOX_TOOL_BIN_DIRS";
|
|
3
|
+
/**
|
|
4
|
+
* The tool bin dirs a `SANDBOX_TOOL_BIN_DIRS` value names, or `[]` when it is
|
|
5
|
+
* unset or blank.
|
|
6
|
+
*
|
|
7
|
+
* Fail loud, never silently degrade: a malformed value (a relative segment,
|
|
8
|
+
* or an empty segment from a stray/leading/trailing colon) throws rather than
|
|
9
|
+
* silently dropping a tool dir from PATH or admitting a relative path that
|
|
10
|
+
* resolves against whatever the process cwd happens to be.
|
|
11
|
+
*/
|
|
12
|
+
export declare function parseSandboxToolBinDirs(value: string | undefined): string[];
|
|
13
|
+
/**
|
|
14
|
+
* A launch environment whose tool bin dirs are `toolBinDirs`, at the TAIL of
|
|
15
|
+
* PATH after every Nix, npm, image and system entry, so a tool never shadows
|
|
16
|
+
* a platform-owned binary of the same name.
|
|
17
|
+
*
|
|
18
|
+
* They replace `appToolBinDirs` (by default the dirs the environment's own
|
|
19
|
+
* `SANDBOX_TOOL_BIN_DIRS` names) wherever those sit in PATH, so an app's older
|
|
20
|
+
* copy of the same tool cannot win by PATH order. Relative PATH entries are
|
|
21
|
+
* dropped too: they resolve against whatever cwd the turn moves to.
|
|
22
|
+
*/
|
|
23
|
+
export declare function bindLaunchToolBinDirs(env: Readonly<Record<string, string | undefined>>, toolBinDirs: readonly string[], appToolBinDirs?: readonly string[]): Record<string, string | undefined>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { isAbsolute } from "node:path";
|
|
2
|
+
/** The colon-separated, absolute CLI bin dirs a sandbox app mounts for its tools. */
|
|
3
|
+
export const SANDBOX_TOOL_BIN_DIRS_ENV = "SANDBOX_TOOL_BIN_DIRS";
|
|
4
|
+
/**
|
|
5
|
+
* The tool bin dirs a `SANDBOX_TOOL_BIN_DIRS` value names, or `[]` when it is
|
|
6
|
+
* unset or blank.
|
|
7
|
+
*
|
|
8
|
+
* Fail loud, never silently degrade: a malformed value (a relative segment,
|
|
9
|
+
* or an empty segment from a stray/leading/trailing colon) throws rather than
|
|
10
|
+
* silently dropping a tool dir from PATH or admitting a relative path that
|
|
11
|
+
* resolves against whatever the process cwd happens to be.
|
|
12
|
+
*/
|
|
13
|
+
export function parseSandboxToolBinDirs(value) {
|
|
14
|
+
const trimmed = value?.trim();
|
|
15
|
+
if (!trimmed)
|
|
16
|
+
return [];
|
|
17
|
+
const segments = trimmed.split(":");
|
|
18
|
+
for (const segment of segments) {
|
|
19
|
+
if (!segment || !isAbsolute(segment)) {
|
|
20
|
+
throw new Error(`SANDBOX_TOOL_BIN_DIRS must be a colon-separated list of absolute paths with no empty segments, got: ${trimmed}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return segments;
|
|
24
|
+
}
|
|
25
|
+
function assertToolBinDir(dir) {
|
|
26
|
+
// A ':' would split one declared dir into several PATH entries, admitting
|
|
27
|
+
// a dir no profile declared.
|
|
28
|
+
if (!dir || !isAbsolute(dir) || dir.includes(":")) {
|
|
29
|
+
throw new Error(`a tool bin dir must be an absolute path without ':', got: ${dir}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A launch environment whose tool bin dirs are `toolBinDirs`, at the TAIL of
|
|
34
|
+
* PATH after every Nix, npm, image and system entry, so a tool never shadows
|
|
35
|
+
* a platform-owned binary of the same name.
|
|
36
|
+
*
|
|
37
|
+
* They replace `appToolBinDirs` (by default the dirs the environment's own
|
|
38
|
+
* `SANDBOX_TOOL_BIN_DIRS` names) wherever those sit in PATH, so an app's older
|
|
39
|
+
* copy of the same tool cannot win by PATH order. Relative PATH entries are
|
|
40
|
+
* dropped too: they resolve against whatever cwd the turn moves to.
|
|
41
|
+
*/
|
|
42
|
+
export function bindLaunchToolBinDirs(env, toolBinDirs, appToolBinDirs = parseSandboxToolBinDirs(env[SANDBOX_TOOL_BIN_DIRS_ENV])) {
|
|
43
|
+
for (const dir of toolBinDirs)
|
|
44
|
+
assertToolBinDir(dir);
|
|
45
|
+
const dirs = [...new Set(toolBinDirs)];
|
|
46
|
+
// No PATH to bind: the spawner appends SANDBOX_TOOL_BIN_DIRS to its default.
|
|
47
|
+
if (env.PATH === undefined) {
|
|
48
|
+
return { ...env, [SANDBOX_TOOL_BIN_DIRS_ENV]: dirs.join(":") };
|
|
49
|
+
}
|
|
50
|
+
const replaced = new Set(appToolBinDirs);
|
|
51
|
+
const entries = env.PATH.split(":").filter((entry) => isAbsolute(entry) && !replaced.has(entry));
|
|
52
|
+
for (const dir of dirs) {
|
|
53
|
+
if (!entries.includes(dir))
|
|
54
|
+
entries.push(dir);
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
...env,
|
|
58
|
+
PATH: entries.join(":"),
|
|
59
|
+
[SANDBOX_TOOL_BIN_DIRS_ENV]: dirs.join(":"),
|
|
60
|
+
};
|
|
61
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tangle-network/agent-profile-materialize",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "Materialize an AgentProfile into the native per-harness workspace files (the one shared materializer for cli-bridge, agent-container, and VB).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
"@tangle-network/agent-interface": "^3.0.0 || ^4.0.0 || ^5.0.0"
|
|
36
36
|
},
|
|
37
37
|
"tangleRelease": {
|
|
38
|
-
"sourceSha": "
|
|
39
|
-
"mergedAt": "2026-10-
|
|
38
|
+
"sourceSha": "f4c8596359a591a5139a8180fba415239536202c",
|
|
39
|
+
"mergedAt": "2026-10-20T13:35:56-06:00"
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
42
42
|
"build": "tsc -p tsconfig.json",
|