@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.
- package/dist/index.d.ts +1226 -29
- package/dist/index.js +4987 -23
- package/package.json +7 -5
- package/dist/candidate-types.d.ts +0 -36
- package/dist/candidate-types.js +0 -1
- package/dist/candidate.d.ts +0 -9
- package/dist/candidate.js +0 -283
- package/dist/commands.d.ts +0 -2
- package/dist/commands.js +0 -32
- package/dist/control-maps.d.ts +0 -58
- package/dist/control-maps.js +0 -277
- package/dist/execution-controls.d.ts +0 -5
- package/dist/execution-controls.js +0 -199
- package/dist/harness-prompt.d.ts +0 -22
- package/dist/harness-prompt.js +0 -64
- package/dist/harness.d.ts +0 -131
- package/dist/harness.js +0 -416
- package/dist/hooks.d.ts +0 -6
- package/dist/hooks.js +0 -250
- package/dist/managed-workspace-bind.d.ts +0 -4
- package/dist/managed-workspace-bind.js +0 -112
- package/dist/managed-workspace-stage.d.ts +0 -25
- package/dist/managed-workspace-stage.js +0 -199
- package/dist/managed-workspace-types.d.ts +0 -38
- package/dist/managed-workspace-types.js +0 -1
- package/dist/managed-workspace.d.ts +0 -37
- package/dist/managed-workspace.js +0 -290
- package/dist/mcp-config-ownership.d.ts +0 -13
- package/dist/mcp-config-ownership.js +0 -79
- package/dist/mcp-opencode.d.ts +0 -25
- package/dist/mcp-opencode.js +0 -52
- package/dist/mcp-timeouts.d.ts +0 -7
- package/dist/mcp-timeouts.js +0 -48
- package/dist/mcp.d.ts +0 -4
- package/dist/mcp.js +0 -246
- package/dist/model-id.d.ts +0 -55
- package/dist/model-id.js +0 -67
- package/dist/pi-models-config.d.ts +0 -32
- package/dist/pi-models-config.js +0 -65
- package/dist/plan-application.d.ts +0 -10
- package/dist/plan-application.js +0 -190
- package/dist/plan-digest.d.ts +0 -18
- package/dist/plan-digest.js +0 -116
- package/dist/plan-json.d.ts +0 -13
- package/dist/plan-json.js +0 -91
- package/dist/plan-normalize.d.ts +0 -12
- package/dist/plan-normalize.js +0 -168
- package/dist/plan-prompt.d.ts +0 -38
- package/dist/plan-prompt.js +0 -49
- package/dist/plan-support.d.ts +0 -9
- package/dist/plan-support.js +0 -22
- package/dist/plan-types.d.ts +0 -341
- package/dist/plan-types.js +0 -105
- package/dist/prime-agent-dir.d.ts +0 -64
- package/dist/prime-agent-dir.js +0 -173
- package/dist/prime-fork.d.ts +0 -155
- package/dist/prime-fork.js +0 -262
- package/dist/prime-profile.d.ts +0 -88
- package/dist/prime-profile.js +0 -297
- package/dist/profile-bundle-cache.d.ts +0 -37
- package/dist/profile-bundle-cache.js +0 -320
- package/dist/profile-bundle-key.d.ts +0 -69
- package/dist/profile-bundle-key.js +0 -62
- package/dist/profile-bundle-lru.d.ts +0 -56
- package/dist/profile-bundle-lru.js +0 -220
- package/dist/profile-materializer.d.ts +0 -24
- package/dist/profile-materializer.js +0 -114
- package/dist/profile-plan.d.ts +0 -16
- package/dist/profile-plan.js +0 -90
- package/dist/profile-prompt.d.ts +0 -4
- package/dist/profile-prompt.js +0 -107
- package/dist/profile-support.d.ts +0 -8
- package/dist/profile-support.js +0 -6
- package/dist/resource-lowering.d.ts +0 -6
- package/dist/resource-lowering.js +0 -103
- package/dist/resource-resolution.d.ts +0 -27
- package/dist/resource-resolution.js +0 -265
- package/dist/secret-concurrent-revision.d.ts +0 -30
- package/dist/secret-concurrent-revision.js +0 -291
- package/dist/secret-session-view.d.ts +0 -36
- package/dist/secret-session-view.js +0 -179
- package/dist/subagents.d.ts +0 -6
- package/dist/subagents.js +0 -137
- package/dist/tool-bin-path.d.ts +0 -23
- package/dist/tool-bin-path.js +0 -61
- package/dist/workspace-apply-files.d.ts +0 -57
- package/dist/workspace-apply-files.js +0 -193
- package/dist/workspace-apply-publish.d.ts +0 -22
- package/dist/workspace-apply-publish.js +0 -178
- package/dist/workspace-apply.d.ts +0 -11
- package/dist/workspace-apply.js +0 -124
- package/dist/workspace-files.d.ts +0 -8
- package/dist/workspace-files.js +0 -46
- package/dist/workspace-launch.d.ts +0 -3
- 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
|
-
}
|
package/dist/subagents.d.ts
DELETED
|
@@ -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";
|
package/dist/tool-bin-path.d.ts
DELETED
|
@@ -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>;
|