@tangle-network/agent-profile-materialize 0.26.1 → 0.26.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
CHANGED
|
@@ -26,3 +26,4 @@ export * from "./profile-start-check.js";
|
|
|
26
26
|
export type { ResolveAgentProfileResourcesOptions } from "./resource-resolution.js";
|
|
27
27
|
export { normalizeSkillMd, resolveAgentProfileResources, } from "./resource-resolution.js";
|
|
28
28
|
export { prepareSecretConcurrentProfileRevision } from "./secret-concurrent-revision.js";
|
|
29
|
+
export * from "./secret-session-view.js";
|
package/dist/index.js
CHANGED
|
@@ -20,3 +20,4 @@ export { executionControlConflict, materializeProfile, UNAVAILABLE_AGENT_PROFILE
|
|
|
20
20
|
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
|
+
export * from "./secret-session-view.js";
|
|
@@ -19,3 +19,4 @@ export declare function prepareSecretConcurrentProfileRevision(input: {
|
|
|
19
19
|
secretProvider: AgentProfileSecretProvider;
|
|
20
20
|
options?: Pick<ApplyWorkspacePlanOptions, "runtimeOnlyFiles">;
|
|
21
21
|
}): Promise<ConcurrentProfileRevision>;
|
|
22
|
+
export declare function runSetfacl(args: string[], paths: readonly string[]): void;
|
|
@@ -154,7 +154,7 @@ function secretRevisionPaths(root) {
|
|
|
154
154
|
walk(root);
|
|
155
155
|
return { all, exposed };
|
|
156
156
|
}
|
|
157
|
-
function runSetfacl(args, paths) {
|
|
157
|
+
export function runSetfacl(args, paths) {
|
|
158
158
|
for (let start = 0; start < paths.length; start += 64) {
|
|
159
159
|
execFileSync("/usr/bin/setfacl", [...args, "--", ...paths.slice(start, start + 64)], {
|
|
160
160
|
env: { PATH: "/usr/bin:/bin", LANG: "C" },
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stable, sealed cwd of one secret OpenCode session. OpenCode reopens a
|
|
3
|
+
* native session at the directory it was created in, so a session resumed on
|
|
4
|
+
* a later turn keeps this path while each turn's revision changes. The name
|
|
5
|
+
* binds the shared workspace, the session and its subprocess UID, so two
|
|
6
|
+
* sessions never share a view.
|
|
7
|
+
*/
|
|
8
|
+
export declare function secretSessionViewWorkspace(input: {
|
|
9
|
+
launchRoot: string;
|
|
10
|
+
sharedWorkspaceDir: string;
|
|
11
|
+
sessionId: string;
|
|
12
|
+
subprocessUid: number;
|
|
13
|
+
}): string;
|
|
14
|
+
/**
|
|
15
|
+
* Point a session's view at one published secret revision before the
|
|
16
|
+
* session's turn runs. The view's `reference/` becomes a fresh tree of hard
|
|
17
|
+
* links to the revision's read-only files (the same inodes, owner, mode and
|
|
18
|
+
* ACL), so the session reads this turn's files and nothing the previous
|
|
19
|
+
* revision held. The view holds nothing else, so OpenCode's config search
|
|
20
|
+
* finds no config there; profile config stays bound by absolute path.
|
|
21
|
+
*
|
|
22
|
+
* The swap is not atomic for a reader that is already running: the caller
|
|
23
|
+
* runs one turn of a session at a time and binds before that turn spawns.
|
|
24
|
+
*/
|
|
25
|
+
export declare function bindSecretSessionView(input: {
|
|
26
|
+
launchRoot: string;
|
|
27
|
+
viewWorkspaceDir: string;
|
|
28
|
+
revisionWorkspaceDir: string;
|
|
29
|
+
subprocessUid: number;
|
|
30
|
+
}): void;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { chmodSync, linkSync, lstatSync, mkdirSync, readdirSync, renameSync, rmSync, } from "node:fs";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { assertLaunchRoot } from "./managed-workspace.js";
|
|
5
|
+
import { runSetfacl } from "./secret-concurrent-revision.js";
|
|
6
|
+
/** The only cwd-relative content a secret OpenCode revision serves. */
|
|
7
|
+
const VIEW_ENTRY = "reference";
|
|
8
|
+
/**
|
|
9
|
+
* The stable, sealed cwd of one secret OpenCode session. OpenCode reopens a
|
|
10
|
+
* native session at the directory it was created in, so a session resumed on
|
|
11
|
+
* a later turn keeps this path while each turn's revision changes. The name
|
|
12
|
+
* binds the shared workspace, the session and its subprocess UID, so two
|
|
13
|
+
* sessions never share a view.
|
|
14
|
+
*/
|
|
15
|
+
export function secretSessionViewWorkspace(input) {
|
|
16
|
+
if (!input.sessionId)
|
|
17
|
+
throw new Error("Secret session view needs a session");
|
|
18
|
+
const name = createHash("sha256")
|
|
19
|
+
.update("tangle-secret-session-view\0")
|
|
20
|
+
.update(input.sharedWorkspaceDir)
|
|
21
|
+
.update("\0")
|
|
22
|
+
.update(input.sessionId)
|
|
23
|
+
.update("\0")
|
|
24
|
+
.update(String(input.subprocessUid))
|
|
25
|
+
.digest("hex");
|
|
26
|
+
return join(input.launchRoot, `session-${name}`, "workspace");
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Point a session's view at one published secret revision before the
|
|
30
|
+
* session's turn runs. The view's `reference/` becomes a fresh tree of hard
|
|
31
|
+
* links to the revision's read-only files (the same inodes, owner, mode and
|
|
32
|
+
* ACL), so the session reads this turn's files and nothing the previous
|
|
33
|
+
* revision held. The view holds nothing else, so OpenCode's config search
|
|
34
|
+
* finds no config there; profile config stays bound by absolute path.
|
|
35
|
+
*
|
|
36
|
+
* The swap is not atomic for a reader that is already running: the caller
|
|
37
|
+
* runs one turn of a session at a time and binds before that turn spawns.
|
|
38
|
+
*/
|
|
39
|
+
export function bindSecretSessionView(input) {
|
|
40
|
+
const { launchRoot, viewWorkspaceDir, revisionWorkspaceDir } = input;
|
|
41
|
+
if (process.platform !== "linux" ||
|
|
42
|
+
process.getuid?.() !== 0 ||
|
|
43
|
+
!Number.isSafeInteger(input.subprocessUid) ||
|
|
44
|
+
input.subprocessUid < 200_000) {
|
|
45
|
+
throw new Error("Secret session view needs a root executor and reserved identity");
|
|
46
|
+
}
|
|
47
|
+
assertLaunchRoot(launchRoot);
|
|
48
|
+
const viewRoot = dirname(viewWorkspaceDir);
|
|
49
|
+
if (!isLaunchWorkspace(launchRoot, viewWorkspaceDir, /^session-[a-f0-9]{64}$/)) {
|
|
50
|
+
throw new Error("Secret session view is not a launch-root session path");
|
|
51
|
+
}
|
|
52
|
+
if (!isLaunchWorkspace(launchRoot, revisionWorkspaceDir, /^revision-[a-f0-9]{64}$/) ||
|
|
53
|
+
!isSealedNode(lstatSync(revisionWorkspaceDir), "directory")) {
|
|
54
|
+
throw new Error("Secret session view needs a published revision");
|
|
55
|
+
}
|
|
56
|
+
for (const directory of [viewRoot, viewWorkspaceDir]) {
|
|
57
|
+
const stat = lstatIfPresent(directory);
|
|
58
|
+
if (!stat)
|
|
59
|
+
mkdirSync(directory, { mode: 0o700 });
|
|
60
|
+
else if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== 0) {
|
|
61
|
+
throw new Error("Secret session view is not an executor-owned directory");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (readdirSync(viewWorkspaceDir).some((name) => name !== VIEW_ENTRY)) {
|
|
65
|
+
throw new Error("Secret session view holds an unexpected entry");
|
|
66
|
+
}
|
|
67
|
+
// Only this function writes a view. An interrupted bind leaves a staged or
|
|
68
|
+
// retired tree beside `workspace`, never inside it.
|
|
69
|
+
clearBesideWorkspace(viewRoot);
|
|
70
|
+
const source = join(revisionWorkspaceDir, VIEW_ENTRY);
|
|
71
|
+
const sourceStat = lstatIfPresent(source);
|
|
72
|
+
let staged;
|
|
73
|
+
if (sourceStat) {
|
|
74
|
+
if (!isSealedNode(sourceStat, "directory")) {
|
|
75
|
+
throw new Error("Secret revision reference/ is not sealed");
|
|
76
|
+
}
|
|
77
|
+
staged = join(viewRoot, `.stage-${randomBytes(8).toString("hex")}`);
|
|
78
|
+
const directories = linkSealedTree(source, staged);
|
|
79
|
+
sealDirectories(directories, input.subprocessUid);
|
|
80
|
+
}
|
|
81
|
+
const current = join(viewWorkspaceDir, VIEW_ENTRY);
|
|
82
|
+
if (lstatIfPresent(current)) {
|
|
83
|
+
renameSync(current, join(viewRoot, `.retired-${randomBytes(8).toString("hex")}`));
|
|
84
|
+
}
|
|
85
|
+
if (staged)
|
|
86
|
+
renameSync(staged, current);
|
|
87
|
+
clearBesideWorkspace(viewRoot);
|
|
88
|
+
sealDirectories([viewRoot, viewWorkspaceDir], input.subprocessUid);
|
|
89
|
+
}
|
|
90
|
+
function isLaunchWorkspace(launchRoot, workspace, name) {
|
|
91
|
+
return (basename(workspace) === "workspace" &&
|
|
92
|
+
dirname(dirname(workspace)) === launchRoot &&
|
|
93
|
+
name.test(basename(dirname(workspace))));
|
|
94
|
+
}
|
|
95
|
+
function lstatIfPresent(path) {
|
|
96
|
+
return lstatSync(path, { throwIfNoEntry: false });
|
|
97
|
+
}
|
|
98
|
+
function isSealedNode(stat, kind) {
|
|
99
|
+
return (!stat.isSymbolicLink() &&
|
|
100
|
+
(kind === "directory" ? stat.isDirectory() : stat.isFile()) &&
|
|
101
|
+
stat.uid === 0 &&
|
|
102
|
+
(stat.mode & 0o222) === 0);
|
|
103
|
+
}
|
|
104
|
+
/** Hard-link a sealed revision tree; returns every directory it created. */
|
|
105
|
+
function linkSealedTree(source, target) {
|
|
106
|
+
mkdirSync(target, { mode: 0o700 });
|
|
107
|
+
const directories = [target];
|
|
108
|
+
for (const name of readdirSync(source)) {
|
|
109
|
+
const from = join(source, name);
|
|
110
|
+
const stat = lstatSync(from);
|
|
111
|
+
if (isSealedNode(stat, "directory")) {
|
|
112
|
+
directories.push(...linkSealedTree(from, join(target, name)));
|
|
113
|
+
}
|
|
114
|
+
else if (isSealedNode(stat, "file")) {
|
|
115
|
+
linkSync(from, join(target, name));
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
throw new Error("Secret revision reference/ holds an unsealed inode");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return directories;
|
|
122
|
+
}
|
|
123
|
+
/** Root-owned, read-only, and traversable only by the session's UID. */
|
|
124
|
+
function sealDirectories(directories, uid) {
|
|
125
|
+
runSetfacl(["-b"], directories);
|
|
126
|
+
for (const directory of directories)
|
|
127
|
+
chmodSync(directory, 0o500);
|
|
128
|
+
runSetfacl(["-m", `u:${uid}:r-X`], directories);
|
|
129
|
+
}
|
|
130
|
+
function clearBesideWorkspace(viewRoot) {
|
|
131
|
+
for (const name of readdirSync(viewRoot)) {
|
|
132
|
+
if (name !== "workspace") {
|
|
133
|
+
rmSync(join(viewRoot, name), { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tangle-network/agent-profile-materialize",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.3",
|
|
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": "c7971c78f69c191b991da10bcb17ac1a30ab66b2",
|
|
39
|
+
"mergedAt": "2026-10-19T02:02:40-06:00"
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
42
42
|
"build": "tsc -p tsconfig.json",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"test": "pnpm run test:installed-peer && pnpm run test:current-peer",
|
|
46
46
|
"test:installed-peer": "vitest run",
|
|
47
47
|
"test:current-peer": "vitest run --config vitest.current-peer.config.ts",
|
|
48
|
-
"test:root": "node ../../scripts/ci/vitest-as-root.mjs tests/concurrent-subagents.test.ts tests/secret-provider-revision.test.ts tests/secret-reference-resources.test.ts",
|
|
48
|
+
"test:root": "node ../../scripts/ci/vitest-as-root.mjs tests/concurrent-subagents.test.ts tests/secret-provider-revision.test.ts tests/secret-reference-resources.test.ts tests/secret-session-view.test.ts",
|
|
49
49
|
"test:watch": "vitest"
|
|
50
50
|
}
|
|
51
51
|
}
|