@tangle-network/agent-profile-materialize 1.1.7 → 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 -106
- 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 -31
- package/dist/managed-workspace.js +0 -271
- 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 -249
- package/dist/secret-session-view.d.ts +0 -25
- package/dist/secret-session-view.js +0 -166
- 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,249 +0,0 @@
|
|
|
1
|
-
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { createHash, createHmac } from "node:crypto";
|
|
3
|
-
import { chmodSync, lstatSync, mkdirSync, readdirSync } from "node:fs";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import { agentProfileSecretRefSchema, } from "@tangle-network/agent-interface";
|
|
6
|
-
import { assertLaunchRoot, prepareExplicitOpenCodePlan, publishConcurrentProfileRevision, } from "./managed-workspace.js";
|
|
7
|
-
import { bindConcurrentLaunch } from "./managed-workspace-bind.js";
|
|
8
|
-
import { prepareManagedWorkspacePlan } from "./managed-workspace-stage.js";
|
|
9
|
-
import { normalizeWorkspacePlanArgument } from "./plan-normalize.js";
|
|
10
|
-
import { assertWorkspacePlanSupported } from "./plan-support.js";
|
|
11
|
-
/** Refuse an executor that cannot isolate the revision, or a plan that claims its launch names. */
|
|
12
|
-
function assertSecretRevisionLaunch(input) {
|
|
13
|
-
if (process.platform !== "linux" ||
|
|
14
|
-
process.getuid?.() !== 0 ||
|
|
15
|
-
!Number.isSafeInteger(input.subprocessUid) ||
|
|
16
|
-
input.subprocessUid < 200_000 ||
|
|
17
|
-
input.namingKey.length !== 32 ||
|
|
18
|
-
!input.ownerId) {
|
|
19
|
-
throw new Error("Secret concurrent profile needs a root executor and reserved identity");
|
|
20
|
-
}
|
|
21
|
-
for (const name of [
|
|
22
|
-
"OPENCODE_DISABLE_PROJECT_CONFIG",
|
|
23
|
-
"OPENCODE_CONFIG_DIR",
|
|
24
|
-
"TANGLE_SHARED_WORKSPACE",
|
|
25
|
-
"TANGLE_PROFILE_REVISION_WORKSPACE",
|
|
26
|
-
]) {
|
|
27
|
-
if (Object.hasOwn(input.plan.env, name)) {
|
|
28
|
-
throw new Error(`Secret ${input.plan.harness} reserves ${name} for its launch binding`);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
/**
|
|
33
|
-
* The OpenCode plan the revision materializes: the trusted provider config
|
|
34
|
-
* merged in, and every public MCP header turned into a reference.
|
|
35
|
-
*/
|
|
36
|
-
function secretOpenCodePlan(sourcePlan, providerConfig, secretReferences) {
|
|
37
|
-
// Workspace `reference/` files stay in this revision's read-only tree. The
|
|
38
|
-
// sealed launch runs from that tree, so a cwd-relative `reference/` path
|
|
39
|
-
// reads this revision's copy and nothing is written to the shared workspace.
|
|
40
|
-
let plan = prepareExplicitOpenCodePlan(sourcePlan);
|
|
41
|
-
if (providerConfig) {
|
|
42
|
-
plan = {
|
|
43
|
-
...plan,
|
|
44
|
-
files: plan.files.map((file) => file.relPath === "opencode.json" &&
|
|
45
|
-
file.source === "generated" &&
|
|
46
|
-
(file.root ?? "workspace") === "workspace"
|
|
47
|
-
? {
|
|
48
|
-
...file,
|
|
49
|
-
content: JSON.stringify({
|
|
50
|
-
...JSON.parse(file.content),
|
|
51
|
-
provider: providerConfig,
|
|
52
|
-
}),
|
|
53
|
-
}
|
|
54
|
-
: file),
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
plan = referencePublicMcpHeaders(plan, secretReferences);
|
|
58
|
-
if (plan.files.some((file) => file.relPath.startsWith(".config/opencode/"))) {
|
|
59
|
-
throw new Error("Secret OpenCode reserves its native config directory");
|
|
60
|
-
}
|
|
61
|
-
// OpenCode and every process it spawns run as the session UID, so a value in
|
|
62
|
-
// its environment, argv or config is readable by the agent's own shell
|
|
63
|
-
// (`/proc/<pid>/environ`, `/proc/<pid>/cmdline`, `cat`). No profile secret
|
|
64
|
-
// value may reach the launch: config slots stay references whose values the
|
|
65
|
-
// launch never receives (#11875).
|
|
66
|
-
if (Object.values(plan.env).some((value) => typeof value !== "string") ||
|
|
67
|
-
plan.flags.some((argument) => typeof normalizeWorkspacePlanArgument(argument) !== "string")) {
|
|
68
|
-
throw new Error("Secret OpenCode refuses a profile secret in its launch environment or arguments");
|
|
69
|
-
}
|
|
70
|
-
return plan;
|
|
71
|
-
}
|
|
72
|
-
/** Codex's native readback identifies which held values belong to MCP servers. */
|
|
73
|
-
async function secretCodexPlan(plan, references, provider) {
|
|
74
|
-
const env = { ...plan.env };
|
|
75
|
-
for (const [variable, value] of Object.entries(env)) {
|
|
76
|
-
if (/^(?:CODEX_|XDG_|HOME$)/u.test(variable)) {
|
|
77
|
-
throw new Error(`Secret Codex reserves ${variable} for its launch binding`);
|
|
78
|
-
}
|
|
79
|
-
if (typeof value === "string" && !variable.startsWith("TANGLE_MCP_HEADER_"))
|
|
80
|
-
continue;
|
|
81
|
-
const ref = typeof value === "string"
|
|
82
|
-
? undefined
|
|
83
|
-
: agentProfileSecretRefSchema.parse(value);
|
|
84
|
-
const raw = ref ? await provider.get(ref.key) : value;
|
|
85
|
-
if (typeof raw !== "string" || !raw.trim())
|
|
86
|
-
throw new Error("Secret Codex MCP value is unavailable");
|
|
87
|
-
const name = `${HELD_VALUE_PREFIX}${createHash("sha256").update(`codex-mcp-env\0${variable}`).digest("hex").slice(0, 24).toUpperCase()}`;
|
|
88
|
-
references[name] = ref?.format === "bearer" ? `Bearer ${raw}` : raw;
|
|
89
|
-
env[variable] = `{env:${name}}`;
|
|
90
|
-
}
|
|
91
|
-
if (plan.flags.some((argument) => typeof normalizeWorkspacePlanArgument(argument) !== "string")) {
|
|
92
|
-
throw new Error("Secret Codex refuses a profile secret in native arguments");
|
|
93
|
-
}
|
|
94
|
-
return { ...plan, env };
|
|
95
|
-
}
|
|
96
|
-
/**
|
|
97
|
-
* Publish a resolved revision for exactly one native subprocess UID. The
|
|
98
|
-
* executor-owned naming key hides low-entropy credential guesses from paths;
|
|
99
|
-
* the resolved digest and manifest never leave this server-side result.
|
|
100
|
-
*/
|
|
101
|
-
export async function prepareSecretConcurrentProfileRevision(input) {
|
|
102
|
-
assertSecretRevisionLaunch(input);
|
|
103
|
-
const secretReferences = {};
|
|
104
|
-
if (input.plan.harness !== "codex" && input.plan.harness !== "opencode") {
|
|
105
|
-
throw new Error("Secret profile has no verified native launch binding");
|
|
106
|
-
}
|
|
107
|
-
const plan = input.plan.harness === "codex"
|
|
108
|
-
? await secretCodexPlan(input.plan, secretReferences, input.secretProvider)
|
|
109
|
-
: secretOpenCodePlan(input.plan, input.providerConfig, secretReferences);
|
|
110
|
-
assertWorkspacePlanSupported(plan);
|
|
111
|
-
assertLaunchRoot(input.launchRoot);
|
|
112
|
-
// File slots reference their secrets, so an unchanged profile with a rotated
|
|
113
|
-
// per-turn secret reuses this revision.
|
|
114
|
-
const prepared = await prepareManagedWorkspacePlan(plan, input.managedRoot, input.sharedWorkspaceDir, input.secretProvider, { ...input.options, secretFileSlotReferences: secretReferences });
|
|
115
|
-
const identity = createHmac("sha256", input.namingKey)
|
|
116
|
-
.update(input.sharedWorkspaceDir)
|
|
117
|
-
.update("\0")
|
|
118
|
-
.update(input.ownerId)
|
|
119
|
-
.update("\0")
|
|
120
|
-
.update(String(input.subprocessUid))
|
|
121
|
-
.update("\0")
|
|
122
|
-
.update(prepared.manifest.materializedDigest)
|
|
123
|
-
.digest("hex");
|
|
124
|
-
const revision = publishConcurrentProfileRevision(prepared, join(input.launchRoot, `revision-${identity}`), (directory) => {
|
|
125
|
-
// OpenCode's sealed XDG tree never reads authority from native HOME.
|
|
126
|
-
if (plan.harness === "opencode")
|
|
127
|
-
for (const relPath of [".config/opencode", ".opencode"]) {
|
|
128
|
-
mkdirSync(join(prepared.root, "workspace", relPath), {
|
|
129
|
-
recursive: true,
|
|
130
|
-
mode: 0o700,
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
const bound = bindConcurrentLaunch(plan, prepared.application, join(directory, "workspace"), { secretRevision: true });
|
|
134
|
-
return {
|
|
135
|
-
...bound,
|
|
136
|
-
env: {
|
|
137
|
-
...bound.env,
|
|
138
|
-
...(plan.harness === "opencode"
|
|
139
|
-
? {
|
|
140
|
-
OPENCODE_DISABLE_PROJECT_CONFIG: "1",
|
|
141
|
-
OPENCODE_CONFIG_DIR: join(directory, "workspace", ".opencode"),
|
|
142
|
-
}
|
|
143
|
-
: {
|
|
144
|
-
TANGLE_PROFILE_REVISION_WORKSPACE: join(directory, "workspace"),
|
|
145
|
-
}),
|
|
146
|
-
TANGLE_SHARED_WORKSPACE: input.sharedWorkspaceDir,
|
|
147
|
-
},
|
|
148
|
-
};
|
|
149
|
-
}, (root) => makeSecretRevisionReadOnly(root, input.subprocessUid), true);
|
|
150
|
-
return { ...revision, secretReferences };
|
|
151
|
-
}
|
|
152
|
-
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
153
|
-
/** How `servableMcpServer` recognizes a header value the executor holds. */
|
|
154
|
-
const HELD_VALUE_PREFIX = "TANGLE_PROFILE_SECRET_";
|
|
155
|
-
/**
|
|
156
|
-
* A remote MCP server's public header values never reach the sealed launch:
|
|
157
|
-
* the executor's loopback proxy replaces the server's url and headers there.
|
|
158
|
-
* They did reach the revision's bytes, so a value that changes every turn (a
|
|
159
|
-
* run id, a message id) gave each turn its own digest, revision directory,
|
|
160
|
-
* receipt and native health check. Each such value becomes a reference the
|
|
161
|
-
* executor resolves, as a secret slot already does, and the revision names
|
|
162
|
-
* only the header. The value stays as public as the profile declared it; it is
|
|
163
|
-
* held beside the secret references and nothing the native process can read
|
|
164
|
-
* carries it.
|
|
165
|
-
*
|
|
166
|
-
* A value that already looks like a reference, or is empty, stays in place.
|
|
167
|
-
*/
|
|
168
|
-
function referencePublicMcpHeaders(plan, references) {
|
|
169
|
-
const configFile = plan.files.find((file) => file.relPath === "opencode.json" &&
|
|
170
|
-
file.source === "generated" &&
|
|
171
|
-
(file.root ?? "workspace") === "workspace");
|
|
172
|
-
if (!configFile)
|
|
173
|
-
return plan;
|
|
174
|
-
const config = JSON.parse(configFile.content);
|
|
175
|
-
if (!isRecord(config) || !isRecord(config.mcp))
|
|
176
|
-
return plan;
|
|
177
|
-
let changed = false;
|
|
178
|
-
for (const [server, entry] of Object.entries(config.mcp)) {
|
|
179
|
-
if (!isRecord(entry) || entry.type !== "remote")
|
|
180
|
-
continue;
|
|
181
|
-
if (!isRecord(entry.headers))
|
|
182
|
-
continue;
|
|
183
|
-
for (const [header, value] of Object.entries(entry.headers)) {
|
|
184
|
-
if (typeof value !== "string" || value === "")
|
|
185
|
-
continue;
|
|
186
|
-
if (/\{(?:env|file):/.test(value))
|
|
187
|
-
continue;
|
|
188
|
-
const name = `${HELD_VALUE_PREFIX}${createHash("sha256")
|
|
189
|
-
.update(`public-mcp-header\0${server}\0${header}`, "utf8")
|
|
190
|
-
.digest("hex")
|
|
191
|
-
.slice(0, 24)
|
|
192
|
-
.toUpperCase()}`;
|
|
193
|
-
references[name] = value;
|
|
194
|
-
entry.headers[header] = `{env:${name}}`;
|
|
195
|
-
changed = true;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
if (!changed)
|
|
199
|
-
return plan;
|
|
200
|
-
return {
|
|
201
|
-
...plan,
|
|
202
|
-
files: plan.files.map((file) => file === configFile ? { ...file, content: JSON.stringify(config) } : file),
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
function secretRevisionPaths(root) {
|
|
206
|
-
const all = [root];
|
|
207
|
-
const exposed = [root];
|
|
208
|
-
const walk = (directory) => {
|
|
209
|
-
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
210
|
-
const child = join(directory, entry.name);
|
|
211
|
-
const stat = lstatSync(child);
|
|
212
|
-
if (stat.uid !== 0 || stat.isSymbolicLink()) {
|
|
213
|
-
throw new Error("Secret concurrent profile stage contains an untrusted inode");
|
|
214
|
-
}
|
|
215
|
-
if (!stat.isDirectory() && !stat.isFile()) {
|
|
216
|
-
throw new Error("Secret concurrent profile stage contains a special file");
|
|
217
|
-
}
|
|
218
|
-
all.push(child);
|
|
219
|
-
if (entry.name !== "manifest.json" || directory !== root) {
|
|
220
|
-
exposed.push(child);
|
|
221
|
-
}
|
|
222
|
-
if (stat.isDirectory())
|
|
223
|
-
walk(child);
|
|
224
|
-
}
|
|
225
|
-
};
|
|
226
|
-
walk(root);
|
|
227
|
-
return { all, exposed };
|
|
228
|
-
}
|
|
229
|
-
export function runSetfacl(args, paths) {
|
|
230
|
-
for (let start = 0; start < paths.length; start += 64) {
|
|
231
|
-
execFileSync("/usr/bin/setfacl", [...args, "--", ...paths.slice(start, start + 64)], {
|
|
232
|
-
env: { PATH: "/usr/bin:/bin", LANG: "C" },
|
|
233
|
-
timeout: 30_000,
|
|
234
|
-
stdio: "pipe",
|
|
235
|
-
});
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
function makeSecretRevisionReadOnly(root, uid) {
|
|
239
|
-
const paths = secretRevisionPaths(root);
|
|
240
|
-
// Remove inherited named ACLs before widening the ACL mask for this UID.
|
|
241
|
-
runSetfacl(["-b"], paths.all);
|
|
242
|
-
for (const path of paths.all) {
|
|
243
|
-
const stat = lstatSync(path);
|
|
244
|
-
chmodSync(path, stat.isDirectory() ? 0o500 : stat.mode & 0o111 ? 0o500 : 0o400);
|
|
245
|
-
}
|
|
246
|
-
// The private manifest has resolved-file hashes. The CLI can read its native
|
|
247
|
-
// files, but never the executor inventory or another revision's files.
|
|
248
|
-
runSetfacl(["-m", `u:${uid}:r-X`], paths.exposed);
|
|
249
|
-
}
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The stable, sealed cwd of one secret session. Native resume keeps this path
|
|
3
|
-
* while each turn's revision changes. Its name binds the workspace, session
|
|
4
|
-
* and subprocess UID, so two sessions never share a view.
|
|
5
|
-
*/
|
|
6
|
-
export declare function secretSessionViewWorkspace(input: {
|
|
7
|
-
launchRoot: string;
|
|
8
|
-
sharedWorkspaceDir: string;
|
|
9
|
-
sessionId: string;
|
|
10
|
-
subprocessUid: number;
|
|
11
|
-
}): string;
|
|
12
|
-
/**
|
|
13
|
-
* Bind every supplied workspace resource at its literal path, using the same
|
|
14
|
-
* root-owned, read-only inode and ACL as the published revision. Generated
|
|
15
|
-
* native configuration stays bound by absolute path, outside this cwd.
|
|
16
|
-
*
|
|
17
|
-
* The caller serializes a session's turns and binds before spawning: replacing
|
|
18
|
-
* a cwd while a previous native process still reads it is not supported.
|
|
19
|
-
*/
|
|
20
|
-
export declare function bindSecretSessionView(input: {
|
|
21
|
-
launchRoot: string;
|
|
22
|
-
viewWorkspaceDir: string;
|
|
23
|
-
revisionWorkspaceDir: string;
|
|
24
|
-
subprocessUid: number;
|
|
25
|
-
}): void;
|
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
-
import { chmodSync, linkSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, } from "node:fs";
|
|
3
|
-
import { basename, dirname, join } from "node:path";
|
|
4
|
-
import { assertLaunchRoot, assertPublishedRevision, } from "./managed-workspace.js";
|
|
5
|
-
import { runSetfacl } from "./secret-concurrent-revision.js";
|
|
6
|
-
import { normalizeWorkspaceRelativePath } from "./workspace-files.js";
|
|
7
|
-
/**
|
|
8
|
-
* The stable, sealed cwd of one secret session. Native resume keeps this path
|
|
9
|
-
* while each turn's revision changes. Its name binds the workspace, session
|
|
10
|
-
* and subprocess UID, so two sessions never share a view.
|
|
11
|
-
*/
|
|
12
|
-
export function secretSessionViewWorkspace(input) {
|
|
13
|
-
if (!input.sessionId)
|
|
14
|
-
throw new Error("Secret session view needs a session");
|
|
15
|
-
const name = createHash("sha256")
|
|
16
|
-
.update("tangle-secret-session-view\0")
|
|
17
|
-
.update(input.sharedWorkspaceDir)
|
|
18
|
-
.update("\0")
|
|
19
|
-
.update(input.sessionId)
|
|
20
|
-
.update("\0")
|
|
21
|
-
.update(String(input.subprocessUid))
|
|
22
|
-
.digest("hex");
|
|
23
|
-
return join(input.launchRoot, `session-${name}`, "workspace");
|
|
24
|
-
}
|
|
25
|
-
/**
|
|
26
|
-
* Bind every supplied workspace resource at its literal path, using the same
|
|
27
|
-
* root-owned, read-only inode and ACL as the published revision. Generated
|
|
28
|
-
* native configuration stays bound by absolute path, outside this cwd.
|
|
29
|
-
*
|
|
30
|
-
* The caller serializes a session's turns and binds before spawning: replacing
|
|
31
|
-
* a cwd while a previous native process still reads it is not supported.
|
|
32
|
-
*/
|
|
33
|
-
export function bindSecretSessionView(input) {
|
|
34
|
-
const { launchRoot, viewWorkspaceDir, revisionWorkspaceDir } = input;
|
|
35
|
-
if (process.platform !== "linux" ||
|
|
36
|
-
process.getuid?.() !== 0 ||
|
|
37
|
-
!Number.isSafeInteger(input.subprocessUid) ||
|
|
38
|
-
input.subprocessUid < 200_000) {
|
|
39
|
-
throw new Error("Secret session view needs a root executor and reserved identity");
|
|
40
|
-
}
|
|
41
|
-
assertLaunchRoot(launchRoot);
|
|
42
|
-
const viewRoot = dirname(viewWorkspaceDir);
|
|
43
|
-
if (!isLaunchWorkspace(launchRoot, viewWorkspaceDir, /^session-[a-f0-9]{64}$/)) {
|
|
44
|
-
throw new Error("Secret session view is not a launch-root session path");
|
|
45
|
-
}
|
|
46
|
-
const revisionRoot = dirname(revisionWorkspaceDir);
|
|
47
|
-
const manifestPath = join(revisionRoot, "manifest.json");
|
|
48
|
-
if (!isLaunchWorkspace(launchRoot, revisionWorkspaceDir, /^revision-[a-f0-9]{64}$/) ||
|
|
49
|
-
realpathSync(revisionWorkspaceDir) !== revisionWorkspaceDir ||
|
|
50
|
-
!isSealedNode(lstatSync(revisionRoot), "directory") ||
|
|
51
|
-
!isSealedNode(lstatSync(revisionWorkspaceDir), "directory") ||
|
|
52
|
-
!isSealedNode(lstatSync(manifestPath), "file")) {
|
|
53
|
-
throw new Error("Secret session view needs a published revision");
|
|
54
|
-
}
|
|
55
|
-
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
56
|
-
for (const file of manifest.files) {
|
|
57
|
-
if (!["workspace", "agent"].includes(file.root) ||
|
|
58
|
-
normalizeWorkspaceRelativePath(file.relPath) !== file.relPath) {
|
|
59
|
-
throw new Error("Secret revision resource path is not canonical");
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
assertPublishedRevision(revisionRoot, manifest, 0);
|
|
63
|
-
const current = lstatIfPresent(viewRoot);
|
|
64
|
-
if (!current)
|
|
65
|
-
mkdirSync(viewRoot, { mode: 0o700 });
|
|
66
|
-
else if (!current.isDirectory() ||
|
|
67
|
-
current.isSymbolicLink() ||
|
|
68
|
-
current.uid !== 0 ||
|
|
69
|
-
(current.mode & 0o022) !== 0) {
|
|
70
|
-
throw new Error("Secret session view is not an executor-owned directory");
|
|
71
|
-
}
|
|
72
|
-
const workspace = lstatIfPresent(viewWorkspaceDir);
|
|
73
|
-
if (workspace) {
|
|
74
|
-
if (!isSealedNode(workspace, "directory")) {
|
|
75
|
-
throw new Error("Secret session view is not an executor-owned directory");
|
|
76
|
-
}
|
|
77
|
-
assertSealedTree(viewWorkspaceDir);
|
|
78
|
-
}
|
|
79
|
-
// An interrupted bind leaves its staging/retired tree beside workspace.
|
|
80
|
-
clearBesideWorkspace(viewRoot);
|
|
81
|
-
const staged = besideWorkspace(viewRoot, "stage");
|
|
82
|
-
const retired = besideWorkspace(viewRoot, "retired");
|
|
83
|
-
mkdirSync(staged, { mode: 0o700 });
|
|
84
|
-
const directories = new Set([staged]);
|
|
85
|
-
try {
|
|
86
|
-
for (const file of manifest.files) {
|
|
87
|
-
if (file.root !== "workspace" || file.source !== "user")
|
|
88
|
-
continue;
|
|
89
|
-
const relative = file.relPath;
|
|
90
|
-
const source = join(revisionWorkspaceDir, relative);
|
|
91
|
-
let ancestor = dirname(source);
|
|
92
|
-
while (ancestor !== revisionRoot) {
|
|
93
|
-
if (!isSealedNode(lstatSync(ancestor), "directory")) {
|
|
94
|
-
throw new Error("Secret revision resource has an unsealed ancestor");
|
|
95
|
-
}
|
|
96
|
-
ancestor = dirname(ancestor);
|
|
97
|
-
}
|
|
98
|
-
const target = join(staged, relative);
|
|
99
|
-
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
100
|
-
linkSync(source, target);
|
|
101
|
-
ancestor = dirname(target);
|
|
102
|
-
while (ancestor !== staged) {
|
|
103
|
-
directories.add(ancestor);
|
|
104
|
-
ancestor = dirname(ancestor);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
sealDirectories([...directories], input.subprocessUid);
|
|
108
|
-
if (lstatIfPresent(viewWorkspaceDir))
|
|
109
|
-
renameSync(viewWorkspaceDir, retired);
|
|
110
|
-
try {
|
|
111
|
-
renameSync(staged, viewWorkspaceDir);
|
|
112
|
-
}
|
|
113
|
-
catch (error) {
|
|
114
|
-
if (lstatIfPresent(retired))
|
|
115
|
-
renameSync(retired, viewWorkspaceDir);
|
|
116
|
-
throw error;
|
|
117
|
-
}
|
|
118
|
-
sealDirectories([viewRoot], input.subprocessUid);
|
|
119
|
-
clearBesideWorkspace(viewRoot);
|
|
120
|
-
}
|
|
121
|
-
finally {
|
|
122
|
-
rmSync(staged, { recursive: true, force: true });
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
/** A fresh staged or retired path beside the view's workspace. */
|
|
126
|
-
function besideWorkspace(viewRoot, kind) {
|
|
127
|
-
return join(viewRoot, `.${kind}-${randomBytes(8).toString("hex")}`);
|
|
128
|
-
}
|
|
129
|
-
function isLaunchWorkspace(launchRoot, workspace, name) {
|
|
130
|
-
return (basename(workspace) === "workspace" &&
|
|
131
|
-
dirname(dirname(workspace)) === launchRoot &&
|
|
132
|
-
name.test(basename(dirname(workspace))));
|
|
133
|
-
}
|
|
134
|
-
function lstatIfPresent(path) {
|
|
135
|
-
return lstatSync(path, { throwIfNoEntry: false });
|
|
136
|
-
}
|
|
137
|
-
function isSealedNode(stat, kind) {
|
|
138
|
-
return (!stat.isSymbolicLink() &&
|
|
139
|
-
(kind === "directory" ? stat.isDirectory() : stat.isFile()) &&
|
|
140
|
-
stat.uid === 0 &&
|
|
141
|
-
(stat.mode & 0o222) === 0);
|
|
142
|
-
}
|
|
143
|
-
function assertSealedTree(root) {
|
|
144
|
-
const stat = lstatSync(root);
|
|
145
|
-
if (isSealedNode(stat, "directory")) {
|
|
146
|
-
for (const name of readdirSync(root))
|
|
147
|
-
assertSealedTree(join(root, name));
|
|
148
|
-
}
|
|
149
|
-
else if (!isSealedNode(stat, "file")) {
|
|
150
|
-
throw new Error("Secret session view holds an unexpected or unsealed entry");
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
/** Root-owned, read-only, and traversable only by the session's UID. */
|
|
154
|
-
function sealDirectories(directories, uid) {
|
|
155
|
-
runSetfacl(["-b"], directories);
|
|
156
|
-
for (const directory of directories)
|
|
157
|
-
chmodSync(directory, 0o500);
|
|
158
|
-
runSetfacl(["-m", `u:${uid}:r-X`], directories);
|
|
159
|
-
}
|
|
160
|
-
function clearBesideWorkspace(viewRoot) {
|
|
161
|
-
for (const name of readdirSync(viewRoot)) {
|
|
162
|
-
if (name !== "workspace") {
|
|
163
|
-
rmSync(join(viewRoot, name), { recursive: true, force: true });
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
}
|
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>;
|
package/dist/tool-bin-path.js
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import { isAbsolute } from "node:path";
|
|
2
|
-
/** The colon-separated, absolute CLI bin dirs a sandbox app mounts for its tools. */
|
|
3
|
-
export const SANDBOX_TOOL_BIN_DIRS_ENV = "SANDBOX_TOOL_BIN_DIRS";
|
|
4
|
-
/**
|
|
5
|
-
* The tool bin dirs a `SANDBOX_TOOL_BIN_DIRS` value names, or `[]` when it is
|
|
6
|
-
* unset or blank.
|
|
7
|
-
*
|
|
8
|
-
* Fail loud, never silently degrade: a malformed value (a relative segment,
|
|
9
|
-
* or an empty segment from a stray/leading/trailing colon) throws rather than
|
|
10
|
-
* silently dropping a tool dir from PATH or admitting a relative path that
|
|
11
|
-
* resolves against whatever the process cwd happens to be.
|
|
12
|
-
*/
|
|
13
|
-
export function parseSandboxToolBinDirs(value) {
|
|
14
|
-
const trimmed = value?.trim();
|
|
15
|
-
if (!trimmed)
|
|
16
|
-
return [];
|
|
17
|
-
const segments = trimmed.split(":");
|
|
18
|
-
for (const segment of segments) {
|
|
19
|
-
if (!segment || !isAbsolute(segment)) {
|
|
20
|
-
throw new Error(`SANDBOX_TOOL_BIN_DIRS must be a colon-separated list of absolute paths with no empty segments, got: ${trimmed}`);
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
return segments;
|
|
24
|
-
}
|
|
25
|
-
function assertToolBinDir(dir) {
|
|
26
|
-
// A ':' would split one declared dir into several PATH entries, admitting
|
|
27
|
-
// a dir no profile declared.
|
|
28
|
-
if (!dir || !isAbsolute(dir) || dir.includes(":")) {
|
|
29
|
-
throw new Error(`a tool bin dir must be an absolute path without ':', got: ${dir}`);
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
/**
|
|
33
|
-
* A launch environment whose tool bin dirs are `toolBinDirs`, at the TAIL of
|
|
34
|
-
* PATH after every Nix, npm, image and system entry, so a tool never shadows
|
|
35
|
-
* a platform-owned binary of the same name.
|
|
36
|
-
*
|
|
37
|
-
* They replace `appToolBinDirs` (by default the dirs the environment's own
|
|
38
|
-
* `SANDBOX_TOOL_BIN_DIRS` names) wherever those sit in PATH, so an app's older
|
|
39
|
-
* copy of the same tool cannot win by PATH order. Relative PATH entries are
|
|
40
|
-
* dropped too: they resolve against whatever cwd the turn moves to.
|
|
41
|
-
*/
|
|
42
|
-
export function bindLaunchToolBinDirs(env, toolBinDirs, appToolBinDirs = parseSandboxToolBinDirs(env[SANDBOX_TOOL_BIN_DIRS_ENV])) {
|
|
43
|
-
for (const dir of toolBinDirs)
|
|
44
|
-
assertToolBinDir(dir);
|
|
45
|
-
const dirs = [...new Set(toolBinDirs)];
|
|
46
|
-
// No PATH to bind: the spawner appends SANDBOX_TOOL_BIN_DIRS to its default.
|
|
47
|
-
if (env.PATH === undefined) {
|
|
48
|
-
return { ...env, [SANDBOX_TOOL_BIN_DIRS_ENV]: dirs.join(":") };
|
|
49
|
-
}
|
|
50
|
-
const replaced = new Set(appToolBinDirs);
|
|
51
|
-
const entries = env.PATH.split(":").filter((entry) => isAbsolute(entry) && !replaced.has(entry));
|
|
52
|
-
for (const dir of dirs) {
|
|
53
|
-
if (!entries.includes(dir))
|
|
54
|
-
entries.push(dir);
|
|
55
|
-
}
|
|
56
|
-
return {
|
|
57
|
-
...env,
|
|
58
|
-
PATH: entries.join(":"),
|
|
59
|
-
[SANDBOX_TOOL_BIN_DIRS_ENV]: dirs.join(":"),
|
|
60
|
-
};
|
|
61
|
-
}
|