@tangle-network/agent-profile-materialize 1.1.6 → 1.1.8

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.
Files changed (95) hide show
  1. package/dist/index.d.ts +1226 -29
  2. package/dist/index.js +4987 -23
  3. package/package.json +7 -5
  4. package/dist/candidate-types.d.ts +0 -36
  5. package/dist/candidate-types.js +0 -1
  6. package/dist/candidate.d.ts +0 -9
  7. package/dist/candidate.js +0 -283
  8. package/dist/commands.d.ts +0 -2
  9. package/dist/commands.js +0 -32
  10. package/dist/control-maps.d.ts +0 -58
  11. package/dist/control-maps.js +0 -277
  12. package/dist/execution-controls.d.ts +0 -5
  13. package/dist/execution-controls.js +0 -199
  14. package/dist/harness-prompt.d.ts +0 -22
  15. package/dist/harness-prompt.js +0 -64
  16. package/dist/harness.d.ts +0 -131
  17. package/dist/harness.js +0 -416
  18. package/dist/hooks.d.ts +0 -6
  19. package/dist/hooks.js +0 -250
  20. package/dist/managed-workspace-bind.d.ts +0 -4
  21. package/dist/managed-workspace-bind.js +0 -112
  22. package/dist/managed-workspace-stage.d.ts +0 -25
  23. package/dist/managed-workspace-stage.js +0 -199
  24. package/dist/managed-workspace-types.d.ts +0 -38
  25. package/dist/managed-workspace-types.js +0 -1
  26. package/dist/managed-workspace.d.ts +0 -37
  27. package/dist/managed-workspace.js +0 -290
  28. package/dist/mcp-config-ownership.d.ts +0 -13
  29. package/dist/mcp-config-ownership.js +0 -79
  30. package/dist/mcp-opencode.d.ts +0 -25
  31. package/dist/mcp-opencode.js +0 -52
  32. package/dist/mcp-timeouts.d.ts +0 -7
  33. package/dist/mcp-timeouts.js +0 -48
  34. package/dist/mcp.d.ts +0 -4
  35. package/dist/mcp.js +0 -246
  36. package/dist/model-id.d.ts +0 -55
  37. package/dist/model-id.js +0 -67
  38. package/dist/pi-models-config.d.ts +0 -32
  39. package/dist/pi-models-config.js +0 -65
  40. package/dist/plan-application.d.ts +0 -10
  41. package/dist/plan-application.js +0 -190
  42. package/dist/plan-digest.d.ts +0 -18
  43. package/dist/plan-digest.js +0 -116
  44. package/dist/plan-json.d.ts +0 -13
  45. package/dist/plan-json.js +0 -91
  46. package/dist/plan-normalize.d.ts +0 -12
  47. package/dist/plan-normalize.js +0 -168
  48. package/dist/plan-prompt.d.ts +0 -38
  49. package/dist/plan-prompt.js +0 -49
  50. package/dist/plan-support.d.ts +0 -9
  51. package/dist/plan-support.js +0 -22
  52. package/dist/plan-types.d.ts +0 -341
  53. package/dist/plan-types.js +0 -105
  54. package/dist/prime-agent-dir.d.ts +0 -64
  55. package/dist/prime-agent-dir.js +0 -173
  56. package/dist/prime-fork.d.ts +0 -155
  57. package/dist/prime-fork.js +0 -262
  58. package/dist/prime-profile.d.ts +0 -88
  59. package/dist/prime-profile.js +0 -297
  60. package/dist/profile-bundle-cache.d.ts +0 -37
  61. package/dist/profile-bundle-cache.js +0 -320
  62. package/dist/profile-bundle-key.d.ts +0 -69
  63. package/dist/profile-bundle-key.js +0 -62
  64. package/dist/profile-bundle-lru.d.ts +0 -56
  65. package/dist/profile-bundle-lru.js +0 -220
  66. package/dist/profile-materializer.d.ts +0 -24
  67. package/dist/profile-materializer.js +0 -114
  68. package/dist/profile-plan.d.ts +0 -16
  69. package/dist/profile-plan.js +0 -90
  70. package/dist/profile-prompt.d.ts +0 -4
  71. package/dist/profile-prompt.js +0 -107
  72. package/dist/profile-support.d.ts +0 -8
  73. package/dist/profile-support.js +0 -6
  74. package/dist/resource-lowering.d.ts +0 -6
  75. package/dist/resource-lowering.js +0 -103
  76. package/dist/resource-resolution.d.ts +0 -27
  77. package/dist/resource-resolution.js +0 -265
  78. package/dist/secret-concurrent-revision.d.ts +0 -30
  79. package/dist/secret-concurrent-revision.js +0 -291
  80. package/dist/secret-session-view.d.ts +0 -36
  81. package/dist/secret-session-view.js +0 -179
  82. package/dist/subagents.d.ts +0 -6
  83. package/dist/subagents.js +0 -137
  84. package/dist/tool-bin-path.d.ts +0 -23
  85. package/dist/tool-bin-path.js +0 -61
  86. package/dist/workspace-apply-files.d.ts +0 -57
  87. package/dist/workspace-apply-files.js +0 -193
  88. package/dist/workspace-apply-publish.d.ts +0 -22
  89. package/dist/workspace-apply-publish.js +0 -178
  90. package/dist/workspace-apply.d.ts +0 -11
  91. package/dist/workspace-apply.js +0 -124
  92. package/dist/workspace-files.d.ts +0 -8
  93. package/dist/workspace-files.js +0 -46
  94. package/dist/workspace-launch.d.ts +0 -3
  95. package/dist/workspace-launch.js +0 -13
@@ -1,291 +0,0 @@
1
- import { execFileSync } from "node:child_process";
2
- import { createHash, createHmac } from "node:crypto";
3
- import { chmodSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
4
- import { join } from "node:path";
5
- import { assertLaunchRoot, listRevisionFiles, makeStageWritable, prepareExplicitOpenCodePlan, } from "./managed-workspace.js";
6
- import { bindConcurrentLaunch } from "./managed-workspace-bind.js";
7
- import { lstatIfPresent, prepareManagedWorkspacePlan, } from "./managed-workspace-stage.js";
8
- import { normalizeWorkspacePlanArgument } from "./plan-normalize.js";
9
- import { assertWorkspacePlanSupported } from "./plan-support.js";
10
- /** Refuse an executor that cannot isolate the revision, or a plan that claims its launch names. */
11
- function assertSecretRevisionLaunch(input) {
12
- if (process.platform !== "linux" ||
13
- process.getuid?.() !== 0 ||
14
- !Number.isSafeInteger(input.subprocessUid) ||
15
- input.subprocessUid < 200_000 ||
16
- input.namingKey.length !== 32 ||
17
- !input.ownerId) {
18
- throw new Error("Secret concurrent profile needs a root executor and reserved identity");
19
- }
20
- for (const name of [
21
- "OPENCODE_DISABLE_PROJECT_CONFIG",
22
- "OPENCODE_CONFIG_DIR",
23
- "TANGLE_SHARED_WORKSPACE",
24
- ]) {
25
- if (Object.hasOwn(input.plan.env, name)) {
26
- throw new Error(`Secret OpenCode reserves ${name} for its launch binding`);
27
- }
28
- }
29
- }
30
- /**
31
- * The OpenCode plan the revision materializes: the trusted provider config
32
- * merged in, and every public MCP header turned into a reference.
33
- */
34
- function secretOpenCodePlan(sourcePlan, providerConfig, secretReferences) {
35
- // Workspace `reference/` files stay in this revision's read-only tree. The
36
- // sealed launch runs from that tree, so a cwd-relative `reference/` path
37
- // reads this revision's copy and nothing is written to the shared workspace.
38
- let plan = prepareExplicitOpenCodePlan(sourcePlan);
39
- if (providerConfig) {
40
- plan = {
41
- ...plan,
42
- files: plan.files.map((file) => file.relPath === "opencode.json" &&
43
- file.source === "generated" &&
44
- (file.root ?? "workspace") === "workspace"
45
- ? {
46
- ...file,
47
- content: JSON.stringify({
48
- ...JSON.parse(file.content),
49
- provider: providerConfig,
50
- }),
51
- }
52
- : file),
53
- };
54
- }
55
- plan = referencePublicMcpHeaders(plan, secretReferences);
56
- if (plan.files.some((file) => file.relPath.startsWith(".config/opencode/"))) {
57
- throw new Error("Secret OpenCode reserves its native config directory");
58
- }
59
- // OpenCode and every process it spawns run as the session UID, so a value in
60
- // its environment, argv or config is readable by the agent's own shell
61
- // (`/proc/<pid>/environ`, `/proc/<pid>/cmdline`, `cat`). No profile secret
62
- // value may reach the launch: config slots stay references whose values the
63
- // launch never receives (#11875).
64
- if (Object.values(plan.env).some((value) => typeof value !== "string") ||
65
- plan.flags.some((argument) => typeof normalizeWorkspacePlanArgument(argument) !== "string")) {
66
- throw new Error("Secret OpenCode refuses a profile secret in its launch environment or arguments");
67
- }
68
- return plan;
69
- }
70
- /** Publish the staged revision read-only, or adopt an identical one already published. */
71
- function publishSecretRevision(prepared, directory, subprocessUid) {
72
- const existing = lstatIfPresent(directory);
73
- if (existing) {
74
- assertPublishedSecretRevision(directory, prepared.manifest);
75
- prepared.dispose();
76
- }
77
- else {
78
- try {
79
- makeSecretRevisionReadOnly(prepared.root, subprocessUid);
80
- renameSync(prepared.root, directory);
81
- }
82
- catch (error) {
83
- const raced = lstatIfPresent(directory);
84
- if (!raced?.isDirectory() || raced.isSymbolicLink())
85
- throw error;
86
- assertPublishedSecretRevision(directory, prepared.manifest);
87
- makeStageWritable(prepared.root);
88
- prepared.dispose();
89
- }
90
- }
91
- }
92
- /**
93
- * Publish a resolved revision for exactly one native subprocess UID. The
94
- * executor-owned naming key hides low-entropy credential guesses from paths;
95
- * the resolved digest and manifest never leave this server-side result.
96
- */
97
- export async function prepareSecretConcurrentProfileRevision(input) {
98
- assertSecretRevisionLaunch(input);
99
- const secretReferences = {};
100
- const plan = secretOpenCodePlan(input.plan, input.providerConfig, secretReferences);
101
- assertWorkspacePlanSupported(plan);
102
- assertLaunchRoot(input.launchRoot);
103
- // File slots reference their secrets, so an unchanged profile with a rotated
104
- // per-turn secret reuses this revision.
105
- const prepared = await prepareManagedWorkspacePlan(plan, input.managedRoot, input.sharedWorkspaceDir, input.secretProvider, { ...input.options, secretFileSlotReferences: secretReferences });
106
- try {
107
- // XDG_CONFIG_HOME is bound to this executor-owned tree at native launch.
108
- // An agent-owned HOME may hold SQLite state, never native authority.
109
- mkdirSync(join(prepared.root, "workspace", ".config", "opencode"), {
110
- recursive: true,
111
- mode: 0o700,
112
- });
113
- mkdirSync(join(prepared.root, "workspace", ".opencode"), {
114
- recursive: true,
115
- mode: 0o700,
116
- });
117
- const identity = createHmac("sha256", input.namingKey)
118
- .update(input.sharedWorkspaceDir)
119
- .update("\0")
120
- .update(input.ownerId)
121
- .update("\0")
122
- .update(String(input.subprocessUid))
123
- .update("\0")
124
- .update(prepared.manifest.materializedDigest)
125
- .digest("hex");
126
- const directory = join(input.launchRoot, `revision-${identity}`);
127
- const bound = bindConcurrentLaunch(plan, prepared.application, join(directory, "workspace"), { secretOpenCodeRevision: true });
128
- const spawn = {
129
- ...bound,
130
- env: {
131
- ...bound.env,
132
- OPENCODE_DISABLE_PROJECT_CONFIG: "1",
133
- OPENCODE_CONFIG_DIR: join(directory, "workspace", ".opencode"),
134
- TANGLE_SHARED_WORKSPACE: input.sharedWorkspaceDir,
135
- },
136
- };
137
- prepared.manifest.launchDigest = createHash("sha256")
138
- .update(JSON.stringify(spawn))
139
- .digest("hex");
140
- writeFileSync(join(prepared.root, "manifest.json"), JSON.stringify(prepared.manifest));
141
- publishSecretRevision(prepared, directory, input.subprocessUid);
142
- return {
143
- directory,
144
- manifest: prepared.manifest,
145
- application: { ...prepared.application, spawn },
146
- secretReferences,
147
- };
148
- }
149
- catch (error) {
150
- if (lstatIfPresent(prepared.root)) {
151
- makeStageWritable(prepared.root);
152
- prepared.dispose();
153
- }
154
- throw error;
155
- }
156
- }
157
- const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
158
- /** How `servableMcpServer` recognizes a header value the executor holds. */
159
- const HELD_VALUE_PREFIX = "TANGLE_PROFILE_SECRET_";
160
- /**
161
- * A remote MCP server's public header values never reach the sealed launch:
162
- * the executor's loopback proxy replaces the server's url and headers there.
163
- * They did reach the revision's bytes, so a value that changes every turn (a
164
- * run id, a message id) gave each turn its own digest, revision directory,
165
- * receipt and native health check. Each such value becomes a reference the
166
- * executor resolves, as a secret slot already does, and the revision names
167
- * only the header. The value stays as public as the profile declared it; it is
168
- * held beside the secret references and nothing the native process can read
169
- * carries it.
170
- *
171
- * A value that already looks like a reference, or is empty, stays in place.
172
- */
173
- function referencePublicMcpHeaders(plan, references) {
174
- const configFile = plan.files.find((file) => file.relPath === "opencode.json" &&
175
- file.source === "generated" &&
176
- (file.root ?? "workspace") === "workspace");
177
- if (!configFile)
178
- return plan;
179
- const config = JSON.parse(configFile.content);
180
- if (!isRecord(config) || !isRecord(config.mcp))
181
- return plan;
182
- let changed = false;
183
- for (const [server, entry] of Object.entries(config.mcp)) {
184
- if (!isRecord(entry) || entry.type !== "remote")
185
- continue;
186
- if (!isRecord(entry.headers))
187
- continue;
188
- for (const [header, value] of Object.entries(entry.headers)) {
189
- if (typeof value !== "string" || value === "")
190
- continue;
191
- if (/\{(?:env|file):/.test(value))
192
- continue;
193
- const name = `${HELD_VALUE_PREFIX}${createHash("sha256")
194
- .update(`public-mcp-header\0${server}\0${header}`, "utf8")
195
- .digest("hex")
196
- .slice(0, 24)
197
- .toUpperCase()}`;
198
- references[name] = value;
199
- entry.headers[header] = `{env:${name}}`;
200
- changed = true;
201
- }
202
- }
203
- if (!changed)
204
- return plan;
205
- return {
206
- ...plan,
207
- files: plan.files.map((file) => file === configFile ? { ...file, content: JSON.stringify(config) } : file),
208
- };
209
- }
210
- function secretRevisionPaths(root) {
211
- const all = [root];
212
- const exposed = [root];
213
- const walk = (directory) => {
214
- for (const entry of readdirSync(directory, { withFileTypes: true })) {
215
- const child = join(directory, entry.name);
216
- const stat = lstatSync(child);
217
- if (stat.uid !== 0 || stat.isSymbolicLink()) {
218
- throw new Error("Secret concurrent profile stage contains an untrusted inode");
219
- }
220
- if (!stat.isDirectory() && !stat.isFile()) {
221
- throw new Error("Secret concurrent profile stage contains a special file");
222
- }
223
- all.push(child);
224
- if (entry.name !== "manifest.json" || directory !== root) {
225
- exposed.push(child);
226
- }
227
- if (stat.isDirectory())
228
- walk(child);
229
- }
230
- };
231
- walk(root);
232
- return { all, exposed };
233
- }
234
- export function runSetfacl(args, paths) {
235
- for (let start = 0; start < paths.length; start += 64) {
236
- execFileSync("/usr/bin/setfacl", [...args, "--", ...paths.slice(start, start + 64)], {
237
- env: { PATH: "/usr/bin:/bin", LANG: "C" },
238
- timeout: 30_000,
239
- stdio: "pipe",
240
- });
241
- }
242
- }
243
- function makeSecretRevisionReadOnly(root, uid) {
244
- const paths = secretRevisionPaths(root);
245
- // Remove inherited named ACLs before widening the ACL mask for this UID.
246
- runSetfacl(["-b"], paths.all);
247
- for (const path of paths.all) {
248
- const stat = lstatSync(path);
249
- chmodSync(path, stat.isDirectory() ? 0o500 : stat.mode & 0o111 ? 0o500 : 0o400);
250
- }
251
- // The private manifest has resolved-file hashes. The CLI can read its native
252
- // files, but never the executor inventory or another revision's files.
253
- runSetfacl(["-m", `u:${uid}:r-X`], paths.exposed);
254
- }
255
- function assertPublishedSecretRevision(directory, expected) {
256
- const root = lstatSync(directory);
257
- if (!root.isDirectory() ||
258
- root.isSymbolicLink() ||
259
- root.uid !== 0 ||
260
- (root.mode & 0o222) !== 0) {
261
- throw new Error("Secret concurrent profile revision is not immutable");
262
- }
263
- const persisted = JSON.parse(readFileSync(join(directory, "manifest.json"), "utf8"));
264
- if (persisted.materializedDigest !== expected.materializedDigest ||
265
- persisted.launchDigest !== expected.launchDigest) {
266
- throw new Error("Secret concurrent profile revision digest collision");
267
- }
268
- for (const file of expected.files) {
269
- const filePath = join(directory, file.root, file.relPath);
270
- const stat = lstatSync(filePath);
271
- if (!stat.isFile() ||
272
- stat.isSymbolicLink() ||
273
- stat.uid !== 0 ||
274
- (stat.mode & 0o222) !== 0 ||
275
- createHash("sha256").update(readFileSync(filePath)).digest("hex") !==
276
- file.sha256) {
277
- throw new Error("Secret concurrent profile revision file changed");
278
- }
279
- }
280
- const expectedPaths = new Set(expected.files.map((file) => `${file.root}/${file.relPath}`));
281
- for (const rootName of ["workspace", "agent"]) {
282
- for (const relPath of listRevisionFiles(join(directory, rootName))) {
283
- if (!expectedPaths.delete(`${rootName}/${relPath}`)) {
284
- throw new Error("Secret concurrent profile revision has an unexpected file");
285
- }
286
- }
287
- }
288
- if (expectedPaths.size) {
289
- throw new Error("Secret concurrent profile revision is missing a manifest file");
290
- }
291
- }
@@ -1,36 +0,0 @@
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/` 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.
27
- *
28
- * The swap is not atomic for a reader that is already running: the caller
29
- * runs one turn of a session at a time and binds before that turn spawns.
30
- */
31
- export declare function bindSecretSessionView(input: {
32
- launchRoot: string;
33
- viewWorkspaceDir: string;
34
- revisionWorkspaceDir: string;
35
- subprocessUid: number;
36
- }): void;
@@ -1,179 +0,0 @@
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 { HARNESS_SKILL_DIRS } from "./harness.js";
5
- import { assertLaunchRoot } from "./managed-workspace.js";
6
- import { runSetfacl } from "./secret-concurrent-revision.js";
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
- ]);
20
- /**
21
- * The stable, sealed cwd of one secret OpenCode session. OpenCode reopens a
22
- * native session at the directory it was created in, so a session resumed on
23
- * a later turn keeps this path while each turn's revision changes. The name
24
- * binds the shared workspace, the session and its subprocess UID, so two
25
- * sessions never share a view.
26
- */
27
- export function secretSessionViewWorkspace(input) {
28
- if (!input.sessionId)
29
- throw new Error("Secret session view needs a session");
30
- const name = createHash("sha256")
31
- .update("tangle-secret-session-view\0")
32
- .update(input.sharedWorkspaceDir)
33
- .update("\0")
34
- .update(input.sessionId)
35
- .update("\0")
36
- .update(String(input.subprocessUid))
37
- .digest("hex");
38
- return join(input.launchRoot, `session-${name}`, "workspace");
39
- }
40
- /**
41
- * Point a session's view at one published secret revision before the
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.
53
- *
54
- * The swap is not atomic for a reader that is already running: the caller
55
- * runs one turn of a session at a time and binds before that turn spawns.
56
- */
57
- export function bindSecretSessionView(input) {
58
- const { launchRoot, viewWorkspaceDir, revisionWorkspaceDir } = input;
59
- if (process.platform !== "linux" ||
60
- process.getuid?.() !== 0 ||
61
- !Number.isSafeInteger(input.subprocessUid) ||
62
- input.subprocessUid < 200_000) {
63
- throw new Error("Secret session view needs a root executor and reserved identity");
64
- }
65
- assertLaunchRoot(launchRoot);
66
- const viewRoot = dirname(viewWorkspaceDir);
67
- if (!isLaunchWorkspace(launchRoot, viewWorkspaceDir, /^session-[a-f0-9]{64}$/)) {
68
- throw new Error("Secret session view is not a launch-root session path");
69
- }
70
- if (!isLaunchWorkspace(launchRoot, revisionWorkspaceDir, /^revision-[a-f0-9]{64}$/) ||
71
- !isSealedNode(lstatSync(revisionWorkspaceDir), "directory")) {
72
- throw new Error("Secret session view needs a published revision");
73
- }
74
- for (const directory of [viewRoot, viewWorkspaceDir]) {
75
- const stat = lstatIfPresent(directory);
76
- if (!stat)
77
- mkdirSync(directory, { mode: 0o700 });
78
- else if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== 0) {
79
- throw new Error("Secret session view is not an executor-owned directory");
80
- }
81
- }
82
- if (readdirSync(viewWorkspaceDir).some((name) => !VIEW_ENTRIES.has(name))) {
83
- throw new Error("Secret session view holds an unexpected entry");
84
- }
85
- // Only this function writes a view. An interrupted bind leaves a staged or
86
- // retired tree beside `workspace`, never inside it.
87
- clearBesideWorkspace(viewRoot);
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
- ];
103
- sealDirectories(directories, input.subprocessUid);
104
- staged.set(SKILLS_ENTRY, target);
105
- }
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);
114
- }
115
- clearBesideWorkspace(viewRoot);
116
- sealDirectories([viewRoot, viewWorkspaceDir], input.subprocessUid);
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
- }
133
- function isLaunchWorkspace(launchRoot, workspace, name) {
134
- return (basename(workspace) === "workspace" &&
135
- dirname(dirname(workspace)) === launchRoot &&
136
- name.test(basename(dirname(workspace))));
137
- }
138
- function lstatIfPresent(path) {
139
- return lstatSync(path, { throwIfNoEntry: false });
140
- }
141
- function isSealedNode(stat, kind) {
142
- return (!stat.isSymbolicLink() &&
143
- (kind === "directory" ? stat.isDirectory() : stat.isFile()) &&
144
- stat.uid === 0 &&
145
- (stat.mode & 0o222) === 0);
146
- }
147
- /** Hard-link a sealed revision tree; returns every directory it created. */
148
- function linkSealedTree(source, target) {
149
- mkdirSync(target, { mode: 0o700 });
150
- const directories = [target];
151
- for (const name of readdirSync(source)) {
152
- const from = join(source, name);
153
- const stat = lstatSync(from);
154
- if (isSealedNode(stat, "directory")) {
155
- directories.push(...linkSealedTree(from, join(target, name)));
156
- }
157
- else if (isSealedNode(stat, "file")) {
158
- linkSync(from, join(target, name));
159
- }
160
- else {
161
- throw new Error("Secret revision reference/ holds an unsealed inode");
162
- }
163
- }
164
- return directories;
165
- }
166
- /** Root-owned, read-only, and traversable only by the session's UID. */
167
- function sealDirectories(directories, uid) {
168
- runSetfacl(["-b"], directories);
169
- for (const directory of directories)
170
- chmodSync(directory, 0o500);
171
- runSetfacl(["-m", `u:${uid}:r-X`], directories);
172
- }
173
- function clearBesideWorkspace(viewRoot) {
174
- for (const name of readdirSync(viewRoot)) {
175
- if (name !== "workspace") {
176
- rmSync(join(viewRoot, name), { recursive: true, force: true });
177
- }
178
- }
179
- }
@@ -1,6 +0,0 @@
1
- export declare function materializeSubagent(h: HarnessId, name: string, sa: AgentSubagentProfile, plan: WorkspacePlan, add: AddPlanFile, unsupported: (d: Unsupported["dimension"], r: string) => void): void;
2
- export declare function codexAgentRoleName(resourceName: string): string | undefined;
3
- export declare function registerCodexAgent(plan: WorkspacePlan, name: string, configFile: string, description: string): void;
4
- import type { AgentSubagentProfile } from "@tangle-network/agent-interface";
5
- import type { AddPlanFile } from "./plan-json.js";
6
- import type { HarnessId, Unsupported, WorkspacePlan } from "./plan-types.js";
package/dist/subagents.js DELETED
@@ -1,137 +0,0 @@
1
- export function materializeSubagent(h, name, sa, plan, add, unsupported) {
2
- if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) {
3
- unsupported("subagents", `${h}: subagent name "${name}" must use lowercase letters, digits, underscores, or hyphens`);
4
- return;
5
- }
6
- switch (h) {
7
- case "claude-code":
8
- case "nanoclaw": {
9
- const fm = ["---", `name: ${name}`];
10
- fm.push(`description: ${JSON.stringify(sa.description ?? name)}`);
11
- if (sa.model)
12
- fm.push(`model: ${JSON.stringify(sa.model)}`);
13
- if (sa.tools) {
14
- const tools = Object.entries(sa.tools);
15
- const allowed = tools
16
- .filter(([, enabled]) => enabled)
17
- .map(([tool]) => tool);
18
- const denied = tools
19
- .filter(([, enabled]) => !enabled)
20
- .map(([tool]) => tool);
21
- if (allowed.length > 0)
22
- fm.push(`tools: ${JSON.stringify(allowed)}`);
23
- if (denied.length > 0)
24
- fm.push(`disallowedTools: ${JSON.stringify(denied)}`);
25
- }
26
- if (sa.maxSteps !== undefined)
27
- fm.push(`maxTurns: ${sa.maxSteps}`);
28
- reportUnsupportedSubagentFields(h, name, sa, new Set(["description", "prompt", "model", "tools", "maxSteps"]), unsupported);
29
- fm.push("---", "", sa.prompt ?? sa.description ?? name);
30
- const relPath = `.claude/agents/${name}.md`;
31
- add(relPath, markMaterializerSubagent(relPath, `${fm.join("\n")}\n`));
32
- break;
33
- }
34
- case "opencode": {
35
- const fm = [
36
- "---",
37
- `description: ${JSON.stringify(sa.description ?? name)}`,
38
- "mode: subagent",
39
- ];
40
- if (sa.model)
41
- fm.push(`model: ${JSON.stringify(sa.model)}`);
42
- if (sa.tools)
43
- fm.push(`tools: ${JSON.stringify(sa.tools)}`);
44
- if (sa.permissions)
45
- fm.push(`permission: ${JSON.stringify(sa.permissions)}`);
46
- if (sa.maxSteps !== undefined)
47
- fm.push(`steps: ${sa.maxSteps}`);
48
- reportUnsupportedSubagentFields(h, name, sa, new Set([
49
- "description",
50
- "prompt",
51
- "model",
52
- "tools",
53
- "permissions",
54
- "maxSteps",
55
- ]), unsupported);
56
- fm.push("---", "", sa.prompt ?? "");
57
- const relPath = `.opencode/agents/${name}.md`;
58
- add(relPath, markMaterializerSubagent(relPath, `${fm.join("\n")}\n`));
59
- break;
60
- }
61
- case "gemini": {
62
- const tools = sa.tools
63
- ? Object.entries(sa.tools)
64
- .filter(([, enabled]) => enabled)
65
- .map(([tool]) => tool)
66
- : undefined;
67
- reportUnsupportedSubagentFields(h, name, sa, new Set(["description", "prompt", "model", "tools", "maxSteps"]), unsupported);
68
- const relPath = `.gemini/agents/${name}.md`;
69
- add(relPath, markMaterializerSubagent(relPath, [
70
- "---",
71
- `name: ${name}`,
72
- `description: ${JSON.stringify(sa.description ?? name)}`,
73
- "kind: local",
74
- ...(sa.model ? [`model: ${JSON.stringify(sa.model)}`] : []),
75
- ...(tools ? [`tools: ${JSON.stringify(tools)}`] : []),
76
- ...(sa.maxSteps !== undefined ? [`max_turns: ${sa.maxSteps}`] : []),
77
- "---",
78
- "",
79
- sa.prompt ?? "",
80
- "",
81
- ].join("\n")));
82
- break;
83
- }
84
- case "codex": {
85
- const relPath = `.codex/agents/${name}.toml`;
86
- const lines = [
87
- `name = ${tomlStr(name)}`,
88
- `description = ${tomlStr(sa.description ?? name)}`,
89
- `developer_instructions = ${tomlStr(sa.prompt ?? sa.description ?? name)}`,
90
- ];
91
- if (sa.model)
92
- lines.push(`model = ${tomlStr(sa.model)}`);
93
- reportUnsupportedSubagentFields(h, name, sa, new Set(["description", "prompt", "model"]), unsupported);
94
- add(relPath, markMaterializerSubagent(relPath, `${lines.join("\n")}\n`));
95
- registerCodexAgent(plan, name, relPath, sa.description ?? name);
96
- break;
97
- }
98
- case "prime":
99
- // Prime subagents ARE expressible — as entries in the continual-harness
100
- // store at <agentDir>/harness/harness_state.json (fork refinement.ts:269-279,
101
- // re-read on every system-prompt build: agent-session.ts:7548-7554) — but
102
- // that store lives under the executor-owned agent dir, which no
103
- // workspace-relative plan file can reach. Executors that own the agent
104
- // dir seed it with seedPrimeProfileSubagents; the plan stays fail-closed
105
- // so a plan-forwarding caller can never believe the roster is in force.
106
- unsupported("subagents", `prime: subagents live in the agent-dir continual-harness store (<agentDir>/harness/harness_state.json), outside any workspace plan — seed "${name}" with seedPrimeProfileSubagents from the executor that owns the agent dir`);
107
- break;
108
- default:
109
- unsupported("subagents", `${h}: subagents have no cwd file (config/flag/global only); "${name}" not mounted`);
110
- }
111
- }
112
- export function codexAgentRoleName(resourceName) {
113
- const name = resourceName.endsWith(".toml")
114
- ? resourceName.slice(0, -".toml".length)
115
- : resourceName;
116
- return /^[a-z0-9][a-z0-9_-]*$/.test(name) ? name : undefined;
117
- }
118
- export function registerCodexAgent(plan, name, configFile, description) {
119
- plan.flags.push("-c", `agents.${name}.description=${tomlStr(description)}`, "-c", `agents.${name}.config_file=${tomlStr(configFile)}`);
120
- }
121
- function reportUnsupportedSubagentFields(harness, name, subagent, supported, unsupported) {
122
- for (const field of [
123
- "description",
124
- "prompt",
125
- "model",
126
- "tools",
127
- "permissions",
128
- "maxSteps",
129
- "metadata",
130
- ]) {
131
- if (subagent[field] !== undefined && !supported.has(field)) {
132
- unsupported("subagents", `${harness}: subagent "${name}" does not support ${field}`);
133
- }
134
- }
135
- }
136
- import { markMaterializerSubagent } from "./harness.js";
137
- import { tomlStr } from "./plan-json.js";
@@ -1,23 +0,0 @@
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>;