@tangle-network/agent-profile-materialize 0.26.2 → 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.2",
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": "48d1261ddd76219c35be25a869689fbb120c5f31",
39
- "mergedAt": "2026-10-19T00:38:28-06:00"
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
  }