@tangle-network/agent-profile-materialize 1.1.5 → 1.1.7
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/hooks.d.ts +1 -1
- package/dist/hooks.js +24 -35
- package/dist/managed-workspace-bind.d.ts +1 -1
- package/dist/managed-workspace-bind.js +10 -16
- package/dist/managed-workspace.d.ts +5 -11
- package/dist/managed-workspace.js +29 -48
- package/dist/secret-concurrent-revision.d.ts +1 -1
- package/dist/secret-concurrent-revision.js +64 -106
- package/dist/secret-session-view.d.ts +8 -19
- package/dist/secret-session-view.js +88 -101
- package/package.json +3 -3
package/dist/hooks.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export declare function materializeHooks(h: HarnessId, hooks: Record<string, AgentProfileHookCommand[]>, add: AddPlanFile, unsupported: (d: Unsupported["dimension"], r: string) => void, plan: WorkspacePlan): void;
|
|
2
2
|
export declare function registerClaudeSettings(plan: WorkspacePlan): void;
|
|
3
|
-
export declare function hookWrapperSource(executable: string, args: string[],
|
|
3
|
+
export declare function hookWrapperSource(executable: string, args: string[], extraEnv: Record<string, string>, envSources?: Record<string, string>): string;
|
|
4
4
|
import type { AgentProfileHookCommand } from "@tangle-network/agent-interface";
|
|
5
5
|
import type { AddPlanFile } from "./plan-json.js";
|
|
6
6
|
import type { HarnessId, Unsupported, WorkspacePlan } from "./plan-types.js";
|
package/dist/hooks.js
CHANGED
|
@@ -195,47 +195,36 @@ function materializedHookCommand(event, command, add, plan) {
|
|
|
195
195
|
setPlanEnvironment(plan, sourceName, value);
|
|
196
196
|
return [name, sourceName];
|
|
197
197
|
}));
|
|
198
|
-
add(path,
|
|
198
|
+
add(path, hookWrapperSource("/bin/sh", ["-c", command.command], {}, envSources));
|
|
199
199
|
return `node ${path}`;
|
|
200
200
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
" if (value === undefined) {",
|
|
211
|
-
' console.error("missing materialized hook environment " + source)',
|
|
212
|
-
" process.exit(127)",
|
|
213
|
-
" }",
|
|
214
|
-
" extraEnv[name] = value",
|
|
215
|
-
"}",
|
|
216
|
-
"const result = spawnSync(executable, args, {",
|
|
217
|
-
' stdio: "inherit",',
|
|
218
|
-
" env: { ...process.env, ...extraEnv },",
|
|
219
|
-
" shell: false,",
|
|
220
|
-
"})",
|
|
221
|
-
"if (result.error) {",
|
|
222
|
-
" console.error(result.error.message)",
|
|
223
|
-
" process.exit(127)",
|
|
224
|
-
"}",
|
|
225
|
-
"if (result.signal) {",
|
|
226
|
-
' console.error("hook terminated by " + result.signal)',
|
|
227
|
-
" process.exit(1)",
|
|
228
|
-
"}",
|
|
229
|
-
"process.exit(result.status ?? 1)",
|
|
230
|
-
"",
|
|
231
|
-
].join("\n");
|
|
232
|
-
}
|
|
233
|
-
export function hookWrapperSource(executable, args, env) {
|
|
201
|
+
// Classic plan env accepts string | AgentProfileConfigValue (public | secretRef)
|
|
202
|
+
// in profile-plan.ts: keep resolved secrets out of workspace files by carrying
|
|
203
|
+
// source names and resolving at runtime, failing closed with 127 when missing.
|
|
204
|
+
// candidatePublicString rejects non-public values, so candidates inline them
|
|
205
|
+
// to keep sealed bundles self-contained without a runtime environment channel.
|
|
206
|
+
export function hookWrapperSource(executable, args, extraEnv, envSources) {
|
|
207
|
+
if (envSources !== undefined && Object.keys(extraEnv).length > 0) {
|
|
208
|
+
throw new Error("Hook wrapper cannot mix inline environment values and sources");
|
|
209
|
+
}
|
|
234
210
|
return [
|
|
235
211
|
'import { spawnSync } from "node:child_process"',
|
|
236
212
|
`const executable = ${JSON.stringify(executable)}`,
|
|
237
213
|
`const args = ${JSON.stringify(args)}`,
|
|
238
|
-
|
|
214
|
+
...(envSources === undefined
|
|
215
|
+
? [`const extraEnv = ${JSON.stringify(extraEnv)}`]
|
|
216
|
+
: [
|
|
217
|
+
`const envSources = ${JSON.stringify(envSources)}`,
|
|
218
|
+
"const extraEnv = {}",
|
|
219
|
+
"for (const [name, source] of Object.entries(envSources)) {",
|
|
220
|
+
" const value = process.env[source]",
|
|
221
|
+
" if (value === undefined) {",
|
|
222
|
+
' console.error("missing materialized hook environment " + source)',
|
|
223
|
+
" process.exit(127)",
|
|
224
|
+
" }",
|
|
225
|
+
" extraEnv[name] = value",
|
|
226
|
+
"}",
|
|
227
|
+
]),
|
|
239
228
|
"const result = spawnSync(executable, args, {",
|
|
240
229
|
' stdio: "inherit",',
|
|
241
230
|
" env: { ...process.env, ...extraEnv },",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { WorkspacePlan, WorkspacePlanApplication } from "./plan-types.js";
|
|
2
2
|
export declare function bindConcurrentLaunch(plan: WorkspacePlan, application: WorkspacePlanApplication, revisionWorkspaceDir: string, options?: {
|
|
3
|
-
readonly
|
|
3
|
+
readonly secretRevision?: boolean;
|
|
4
4
|
}): WorkspacePlanApplication["spawn"];
|
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
import { dirname, join } from "node:path";
|
|
2
2
|
import { CODEX_SYSTEM_PROMPT_FILE } from "./harness.js";
|
|
3
3
|
import { bindWorkspaceLaunchFlags } from "./workspace-launch.js";
|
|
4
|
-
/**
|
|
5
|
-
* Paths a secret OpenCode revision serves from its own immutable tree:
|
|
6
|
-
* read-only profile content under `reference/`, skills, and OpenCode's
|
|
7
|
-
* standard agent and command definitions (OpenCode reads
|
|
8
|
-
* `.opencode/{agent,agents}/**\/*.md` and `.opencode/{command,commands}/**\/*.md`).
|
|
9
|
-
*/
|
|
10
4
|
const SECRET_REVISION_NATIVE_PATH = /^(?:reference\/|\.opencode\/skills\/|\.opencode\/(?:agents?|commands?)\/(?:[^./][^/]*\/)*[^./][^/]*\.md$)/u;
|
|
11
5
|
export function bindConcurrentLaunch(plan, application, revisionWorkspaceDir, options = {}) {
|
|
12
6
|
if (plan.requires?.length) {
|
|
@@ -44,10 +38,6 @@ export function bindConcurrentLaunch(plan, application, revisionWorkspaceDir, op
|
|
|
44
38
|
supportedFiles.add(CODEX_SYSTEM_PROMPT_FILE);
|
|
45
39
|
}
|
|
46
40
|
break;
|
|
47
|
-
// kimi-code has no case: kimi-code 2.x discovers MCP only from
|
|
48
|
-
// <cwd>/.kimi-code/mcp.json and has no launch flag that rebinds it to the
|
|
49
|
-
// revision, so a concurrent kimi-code plan carrying one fails closed in
|
|
50
|
-
// the redirect check below.
|
|
51
41
|
case "opencode": {
|
|
52
42
|
const runtimeConfig = application.runtimeOnlyFiles["opencode.json"];
|
|
53
43
|
if (runtimeConfig !== undefined)
|
|
@@ -75,13 +65,17 @@ export function bindConcurrentLaunch(plan, application, revisionWorkspaceDir, op
|
|
|
75
65
|
// its directory, absolute in that revision, joins the launch PATH.
|
|
76
66
|
const toolBinDirs = [];
|
|
77
67
|
for (const file of plan.files) {
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
if (options.secretOpenCodeRevision === true &&
|
|
82
|
-
plan.harness === "opencode" &&
|
|
68
|
+
// Private launches read their exact user resources from a sealed session
|
|
69
|
+
// view. Generated native authority keeps its verified absolute binding.
|
|
70
|
+
if (options.secretRevision === true &&
|
|
83
71
|
(file.root ?? "workspace") === "workspace" &&
|
|
84
|
-
|
|
72
|
+
(file.source === "user" ||
|
|
73
|
+
(plan.harness === "opencode" &&
|
|
74
|
+
SECRET_REVISION_NATIVE_PATH.test(file.relPath)))) {
|
|
75
|
+
if (plan.harness === "codex" &&
|
|
76
|
+
/^\.codex\/(?:config\.toml|auth\.json)$/u.test(file.relPath)) {
|
|
77
|
+
throw new Error("Secret Codex profile resource claims native authority");
|
|
78
|
+
}
|
|
85
79
|
if (file.relPath.startsWith("reference/") &&
|
|
86
80
|
((file.mode ?? 0) & 0o111) !== 0) {
|
|
87
81
|
const dir = join(revisionWorkspaceDir, dirname(file.relPath));
|
|
@@ -2,36 +2,30 @@ import type { AgentProfileSecretProvider } from "@tangle-network/agent-interface
|
|
|
2
2
|
import type { ManagedWorkspaceManifest } from "./managed-workspace-types.js";
|
|
3
3
|
import type { ApplyWorkspacePlanOptions, WorkspacePlan, WorkspacePlanApplication } from "./plan-types.js";
|
|
4
4
|
export type { ManagedWorkspaceFileDigest, ManagedWorkspaceManifest, } from "./managed-workspace-types.js";
|
|
5
|
-
/** A private, write-once revision and the exact inputs for one native spawn. */
|
|
6
5
|
export interface ConcurrentProfileRevision {
|
|
7
6
|
directory: string;
|
|
8
7
|
manifest: ManagedWorkspaceManifest;
|
|
9
8
|
application: WorkspacePlanApplication;
|
|
10
9
|
}
|
|
11
|
-
/** Create the two executor-owned roots on one filesystem. */
|
|
12
10
|
export declare function ensurePublicConcurrentProfileRoots(base: string): {
|
|
13
11
|
managedRoot: string;
|
|
14
12
|
launchRoot: string;
|
|
15
13
|
};
|
|
16
|
-
/**
|
|
17
|
-
* Materialize PUBLIC profile material beside the shared workspace. Processes
|
|
18
|
-
* in one sandbox share a uid and can read each other's revision files and env.
|
|
19
|
-
* Callers must not use this path for confidential profile text or credentials.
|
|
20
|
-
* Only native controls with a verified explicit launch path are admitted.
|
|
21
|
-
* A separate reaper owns removal after no native process reads the revision.
|
|
22
|
-
*/
|
|
23
14
|
export declare function preparePublicConcurrentProfileRevision(input: {
|
|
24
15
|
plan: WorkspacePlan;
|
|
25
16
|
managedRoot: string;
|
|
26
|
-
/** Root-owned, agent-traversable publication root on the same filesystem. */
|
|
27
17
|
launchRoot: string;
|
|
28
18
|
sharedWorkspaceDir: string;
|
|
29
19
|
ownerId: string;
|
|
30
20
|
secretProvider: AgentProfileSecretProvider;
|
|
31
21
|
options?: Pick<ApplyWorkspacePlanOptions, "ownership" | "runtimeOnlyFiles">;
|
|
32
22
|
}): Promise<ConcurrentProfileRevision>;
|
|
33
|
-
|
|
23
|
+
export declare function publishConcurrentProfileRevision(prepared: Omit<ConcurrentProfileRevision, "directory"> & {
|
|
24
|
+
root: string;
|
|
25
|
+
dispose(): void;
|
|
26
|
+
}, directory: string, bind: (directory: string) => WorkspacePlanApplication["spawn"], seal: (root: string) => void, privateRevision?: boolean): ConcurrentProfileRevision;
|
|
34
27
|
export declare function prepareExplicitOpenCodePlan(plan: WorkspacePlan): WorkspacePlan;
|
|
28
|
+
export declare function assertPublishedRevision(directory: string, expected: ManagedWorkspaceManifest, inodeOwnerUid?: number): void;
|
|
35
29
|
export declare function listRevisionFiles(root: string, prefix?: string): string[];
|
|
36
30
|
export declare function assertLaunchRoot(root: string): void;
|
|
37
31
|
export declare function makeStageWritable(root: string): void;
|
|
@@ -4,7 +4,6 @@ import { dirname, join, resolve } from "node:path";
|
|
|
4
4
|
import { bindConcurrentLaunch } from "./managed-workspace-bind.js";
|
|
5
5
|
import { assertPrivateManagedRoot, lstatIfPresent, prepareManagedWorkspacePlan, } from "./managed-workspace-stage.js";
|
|
6
6
|
import { assertWorkspacePlanSupported } from "./plan-support.js";
|
|
7
|
-
/** Create the two executor-owned roots on one filesystem. */
|
|
8
7
|
export function ensurePublicConcurrentProfileRoots(base) {
|
|
9
8
|
if (process.getuid?.() !== 0 || !base || resolve(base) !== base) {
|
|
10
9
|
throw new Error("Concurrent profile roots require an absolute path and a root executor");
|
|
@@ -33,13 +32,6 @@ export function ensurePublicConcurrentProfileRoots(base) {
|
|
|
33
32
|
}
|
|
34
33
|
return { managedRoot, launchRoot };
|
|
35
34
|
}
|
|
36
|
-
/**
|
|
37
|
-
* Materialize PUBLIC profile material beside the shared workspace. Processes
|
|
38
|
-
* in one sandbox share a uid and can read each other's revision files and env.
|
|
39
|
-
* Callers must not use this path for confidential profile text or credentials.
|
|
40
|
-
* Only native controls with a verified explicit launch path are admitted.
|
|
41
|
-
* A separate reaper owns removal after no native process reads the revision.
|
|
42
|
-
*/
|
|
43
35
|
export async function preparePublicConcurrentProfileRevision(input) {
|
|
44
36
|
if (!input.ownerId)
|
|
45
37
|
throw new Error("Concurrent profile revision needs a binding owner");
|
|
@@ -55,38 +47,34 @@ export async function preparePublicConcurrentProfileRevision(input) {
|
|
|
55
47
|
assertWorkspacePlanSupported(plan);
|
|
56
48
|
assertLaunchRoot(input.launchRoot);
|
|
57
49
|
const prepared = await prepareManagedWorkspacePlan(plan, input.managedRoot, input.sharedWorkspaceDir, input.secretProvider, input.options);
|
|
50
|
+
const identity = createHash("sha256")
|
|
51
|
+
.update(`${input.ownerId}\0${prepared.manifest.materializedDigest}`)
|
|
52
|
+
.digest("hex");
|
|
53
|
+
return publishConcurrentProfileRevision(prepared, join(input.launchRoot, `revision-${identity}`), (directory) => bindConcurrentLaunch(plan, prepared.application, join(directory, "workspace")), (root) => makeRevisionReadOnly(root, false));
|
|
54
|
+
}
|
|
55
|
+
export function publishConcurrentProfileRevision(prepared, directory, bind, seal, privateRevision = false) {
|
|
56
|
+
const assertExisting = () => assertPublishedRevision(directory, prepared.manifest, privateRevision ? 0 : undefined);
|
|
58
57
|
try {
|
|
59
|
-
const
|
|
60
|
-
.update(`${input.ownerId}\0${prepared.manifest.materializedDigest}`)
|
|
61
|
-
.digest("hex");
|
|
62
|
-
const directory = join(input.launchRoot, `revision-${identity}`);
|
|
63
|
-
// Validate every native file before publishing a revision that a launcher
|
|
64
|
-
// could mistake for a complete profile.
|
|
65
|
-
const spawn = bindConcurrentLaunch(plan, prepared.application, join(directory, "workspace"));
|
|
58
|
+
const spawn = bind(directory);
|
|
66
59
|
prepared.manifest.launchDigest = createHash("sha256")
|
|
67
60
|
.update(JSON.stringify(spawn))
|
|
68
61
|
.digest("hex");
|
|
69
62
|
writeFileSync(join(prepared.root, "manifest.json"), JSON.stringify(prepared.manifest));
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
assertPublishedRevision(directory, prepared.manifest);
|
|
63
|
+
if (lstatIfPresent(directory)) {
|
|
64
|
+
assertExisting();
|
|
73
65
|
prepared.dispose();
|
|
74
66
|
}
|
|
75
67
|
else {
|
|
76
68
|
try {
|
|
77
|
-
|
|
78
|
-
// tree is complete and read-only before publication.
|
|
79
|
-
makeRevisionReadOnly(prepared.root, false);
|
|
69
|
+
seal(prepared.root);
|
|
80
70
|
renameSync(prepared.root, directory);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
chmodSync(directory, 0o555);
|
|
71
|
+
if (!privateRevision)
|
|
72
|
+
chmodSync(directory, 0o555);
|
|
84
73
|
}
|
|
85
74
|
catch (error) {
|
|
86
|
-
|
|
87
|
-
if (!raced?.isDirectory() || raced.isSymbolicLink())
|
|
75
|
+
if (!lstatIfPresent(directory))
|
|
88
76
|
throw error;
|
|
89
|
-
|
|
77
|
+
assertExisting();
|
|
90
78
|
makeStageWritable(prepared.root);
|
|
91
79
|
prepared.dispose();
|
|
92
80
|
}
|
|
@@ -96,16 +84,12 @@ export async function preparePublicConcurrentProfileRevision(input) {
|
|
|
96
84
|
manifest: prepared.manifest,
|
|
97
85
|
application: {
|
|
98
86
|
...prepared.application,
|
|
99
|
-
receipt: {
|
|
100
|
-
...prepared.application.receipt,
|
|
101
|
-
flags: [...spawn.flags],
|
|
102
|
-
},
|
|
87
|
+
receipt: { ...prepared.application.receipt, flags: [...spawn.flags] },
|
|
103
88
|
spawn,
|
|
104
89
|
},
|
|
105
90
|
};
|
|
106
91
|
}
|
|
107
92
|
catch (error) {
|
|
108
|
-
// A published revision is never removed here; only an unpublished stage is.
|
|
109
93
|
if (lstatIfPresent(prepared.root)) {
|
|
110
94
|
makeStageWritable(prepared.root);
|
|
111
95
|
prepared.dispose();
|
|
@@ -113,7 +97,6 @@ export async function preparePublicConcurrentProfileRevision(input) {
|
|
|
113
97
|
throw error;
|
|
114
98
|
}
|
|
115
99
|
}
|
|
116
|
-
/** Move OpenCode's cwd-discovered primary agent into its explicit JSON config. */
|
|
117
100
|
export function prepareExplicitOpenCodePlan(plan) {
|
|
118
101
|
if (plan.harness !== "opencode")
|
|
119
102
|
return plan;
|
|
@@ -185,40 +168,41 @@ export function prepareExplicitOpenCodePlan(plan) {
|
|
|
185
168
|
files: files.map((file) => file === configFile ? { ...file, content: JSON.stringify(body) } : file),
|
|
186
169
|
};
|
|
187
170
|
}
|
|
188
|
-
function assertPublishedRevision(directory, expected) {
|
|
171
|
+
export function assertPublishedRevision(directory, expected, inodeOwnerUid) {
|
|
172
|
+
const label = inodeOwnerUid === undefined ? "Concurrent" : "Secret concurrent";
|
|
189
173
|
const root = lstatSync(directory);
|
|
190
174
|
if (!root.isDirectory() ||
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
175
|
+
(inodeOwnerUid === undefined
|
|
176
|
+
? (root.mode & 0o777) !== 0o555
|
|
177
|
+
: root.uid !== inodeOwnerUid || (root.mode & 0o222) !== 0)) {
|
|
178
|
+
throw new Error(`${label} profile revision is not fully published or immutable`);
|
|
194
179
|
}
|
|
195
180
|
const persisted = JSON.parse(readFileSync(join(directory, "manifest.json"), "utf8"));
|
|
196
181
|
if (persisted.materializedDigest !== expected.materializedDigest ||
|
|
197
182
|
persisted.launchDigest !== expected.launchDigest) {
|
|
198
|
-
throw new Error(
|
|
183
|
+
throw new Error(`${label} profile revision digest collision`);
|
|
199
184
|
}
|
|
200
185
|
for (const file of expected.files) {
|
|
201
186
|
const path = join(directory, file.root, file.relPath);
|
|
202
187
|
const stat = lstatSync(path);
|
|
203
188
|
if (!stat.isFile() ||
|
|
204
|
-
stat.
|
|
189
|
+
(inodeOwnerUid !== undefined && stat.uid !== inodeOwnerUid) ||
|
|
205
190
|
(stat.mode & 0o222) !== 0 ||
|
|
206
191
|
createHash("sha256").update(readFileSync(path)).digest("hex") !==
|
|
207
192
|
file.sha256) {
|
|
208
|
-
throw new Error(
|
|
193
|
+
throw new Error(`${label} profile revision file changed`);
|
|
209
194
|
}
|
|
210
195
|
}
|
|
211
196
|
const expectedPaths = new Set(expected.files.map((file) => `${file.root}/${file.relPath}`));
|
|
212
197
|
for (const rootName of ["workspace", "agent"]) {
|
|
213
198
|
for (const relPath of listRevisionFiles(join(directory, rootName))) {
|
|
214
199
|
if (!expectedPaths.delete(`${rootName}/${relPath}`)) {
|
|
215
|
-
throw new Error(
|
|
200
|
+
throw new Error(`${label} profile revision has an unexpected file`);
|
|
216
201
|
}
|
|
217
202
|
}
|
|
218
203
|
}
|
|
219
|
-
if (expectedPaths.size)
|
|
220
|
-
throw new Error(
|
|
221
|
-
}
|
|
204
|
+
if (expectedPaths.size)
|
|
205
|
+
throw new Error(`${label} profile revision is missing a manifest file`);
|
|
222
206
|
}
|
|
223
207
|
export function listRevisionFiles(root, prefix = "") {
|
|
224
208
|
const found = [];
|
|
@@ -239,7 +223,6 @@ export function assertLaunchRoot(root) {
|
|
|
239
223
|
}
|
|
240
224
|
const stat = lstatSync(root);
|
|
241
225
|
if (!stat.isDirectory() ||
|
|
242
|
-
stat.isSymbolicLink() ||
|
|
243
226
|
realpathSync(root) !== root ||
|
|
244
227
|
(stat.mode & 0o022) !== 0 ||
|
|
245
228
|
(stat.mode & 0o111) !== 0o111 ||
|
|
@@ -249,9 +232,7 @@ export function assertLaunchRoot(root) {
|
|
|
249
232
|
let ancestor = dirname(root);
|
|
250
233
|
while (ancestor !== root) {
|
|
251
234
|
const ancestorStat = lstatSync(ancestor);
|
|
252
|
-
if (!ancestorStat.isDirectory() ||
|
|
253
|
-
ancestorStat.isSymbolicLink() ||
|
|
254
|
-
(ancestorStat.mode & 0o001) === 0) {
|
|
235
|
+
if (!ancestorStat.isDirectory() || (ancestorStat.mode & 0o001) === 0) {
|
|
255
236
|
throw new Error(`Concurrent profile launch root has a non-traversable ancestor: ${ancestor}`);
|
|
256
237
|
}
|
|
257
238
|
if (ancestor === dirname(ancestor))
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type AgentProfileSecretProvider } from "@tangle-network/agent-interface";
|
|
2
2
|
import { type ConcurrentProfileRevision } from "./managed-workspace.js";
|
|
3
3
|
import type { ApplyWorkspacePlanOptions, WorkspacePlan } from "./plan-types.js";
|
|
4
4
|
/**
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { createHash, createHmac } from "node:crypto";
|
|
3
|
-
import { chmodSync, lstatSync, mkdirSync, readdirSync
|
|
3
|
+
import { chmodSync, lstatSync, mkdirSync, readdirSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import { agentProfileSecretRefSchema, } from "@tangle-network/agent-interface";
|
|
6
|
+
import { assertLaunchRoot, prepareExplicitOpenCodePlan, publishConcurrentProfileRevision, } from "./managed-workspace.js";
|
|
6
7
|
import { bindConcurrentLaunch } from "./managed-workspace-bind.js";
|
|
7
|
-
import {
|
|
8
|
+
import { prepareManagedWorkspacePlan } from "./managed-workspace-stage.js";
|
|
8
9
|
import { normalizeWorkspacePlanArgument } from "./plan-normalize.js";
|
|
9
10
|
import { assertWorkspacePlanSupported } from "./plan-support.js";
|
|
10
11
|
/** Refuse an executor that cannot isolate the revision, or a plan that claims its launch names. */
|
|
@@ -21,9 +22,10 @@ function assertSecretRevisionLaunch(input) {
|
|
|
21
22
|
"OPENCODE_DISABLE_PROJECT_CONFIG",
|
|
22
23
|
"OPENCODE_CONFIG_DIR",
|
|
23
24
|
"TANGLE_SHARED_WORKSPACE",
|
|
25
|
+
"TANGLE_PROFILE_REVISION_WORKSPACE",
|
|
24
26
|
]) {
|
|
25
27
|
if (Object.hasOwn(input.plan.env, name)) {
|
|
26
|
-
throw new Error(`Secret
|
|
28
|
+
throw new Error(`Secret ${input.plan.harness} reserves ${name} for its launch binding`);
|
|
27
29
|
}
|
|
28
30
|
}
|
|
29
31
|
}
|
|
@@ -67,27 +69,29 @@ function secretOpenCodePlan(sourcePlan, providerConfig, secretReferences) {
|
|
|
67
69
|
}
|
|
68
70
|
return plan;
|
|
69
71
|
}
|
|
70
|
-
/**
|
|
71
|
-
function
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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();
|
|
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`);
|
|
89
78
|
}
|
|
90
|
-
|
|
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 };
|
|
91
95
|
}
|
|
92
96
|
/**
|
|
93
97
|
* Publish a resolved revision for exactly one native subprocess UID. The
|
|
@@ -97,62 +101,53 @@ function publishSecretRevision(prepared, directory, subprocessUid) {
|
|
|
97
101
|
export async function prepareSecretConcurrentProfileRevision(input) {
|
|
98
102
|
assertSecretRevisionLaunch(input);
|
|
99
103
|
const secretReferences = {};
|
|
100
|
-
|
|
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);
|
|
101
110
|
assertWorkspacePlanSupported(plan);
|
|
102
111
|
assertLaunchRoot(input.launchRoot);
|
|
103
112
|
// File slots reference their secrets, so an unchanged profile with a rotated
|
|
104
113
|
// per-turn secret reuses this revision.
|
|
105
114
|
const prepared = await prepareManagedWorkspacePlan(plan, input.managedRoot, input.sharedWorkspaceDir, input.secretProvider, { ...input.options, secretFileSlotReferences: secretReferences });
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const directory = join(input.launchRoot, `revision-${identity}`);
|
|
127
|
-
const bound = bindConcurrentLaunch(plan, prepared.application, join(directory, "workspace"), { secretOpenCodeRevision: true });
|
|
128
|
-
const spawn = {
|
|
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 {
|
|
129
135
|
...bound,
|
|
130
136
|
env: {
|
|
131
137
|
...bound.env,
|
|
132
|
-
|
|
133
|
-
|
|
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
|
+
}),
|
|
134
146
|
TANGLE_SHARED_WORKSPACE: input.sharedWorkspaceDir,
|
|
135
147
|
},
|
|
136
148
|
};
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
}
|
|
149
|
+
}, (root) => makeSecretRevisionReadOnly(root, input.subprocessUid), true);
|
|
150
|
+
return { ...revision, secretReferences };
|
|
156
151
|
}
|
|
157
152
|
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
158
153
|
/** How `servableMcpServer` recognizes a header value the executor holds. */
|
|
@@ -252,40 +247,3 @@ function makeSecretRevisionReadOnly(root, uid) {
|
|
|
252
247
|
// files, but never the executor inventory or another revision's files.
|
|
253
248
|
runSetfacl(["-m", `u:${uid}:r-X`], paths.exposed);
|
|
254
249
|
}
|
|
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,9 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The stable, sealed cwd of one secret
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* binds the shared workspace, the session and its subprocess UID, so two
|
|
6
|
-
* sessions never share a view.
|
|
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.
|
|
7
5
|
*/
|
|
8
6
|
export declare function secretSessionViewWorkspace(input: {
|
|
9
7
|
launchRoot: string;
|
|
@@ -12,21 +10,12 @@ export declare function secretSessionViewWorkspace(input: {
|
|
|
12
10
|
subprocessUid: number;
|
|
13
11
|
}): string;
|
|
14
12
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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.
|
|
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.
|
|
22
16
|
*
|
|
23
|
-
* The
|
|
24
|
-
*
|
|
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.
|
|
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.
|
|
30
19
|
*/
|
|
31
20
|
export declare function bindSecretSessionView(input: {
|
|
32
21
|
launchRoot: string;
|
|
@@ -1,28 +1,13 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
-
import { chmodSync, linkSync, lstatSync, mkdirSync, readdirSync, renameSync, rmSync, } from "node:fs";
|
|
2
|
+
import { chmodSync, linkSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, } from "node:fs";
|
|
3
3
|
import { basename, dirname, join } from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
import { assertLaunchRoot } from "./managed-workspace.js";
|
|
4
|
+
import { assertLaunchRoot, assertPublishedRevision, } from "./managed-workspace.js";
|
|
6
5
|
import { runSetfacl } from "./secret-concurrent-revision.js";
|
|
7
|
-
|
|
8
|
-
const REFERENCE_ENTRY = "reference";
|
|
6
|
+
import { normalizeWorkspaceRelativePath } from "./workspace-files.js";
|
|
9
7
|
/**
|
|
10
|
-
* The
|
|
11
|
-
*
|
|
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.
|
|
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.
|
|
26
11
|
*/
|
|
27
12
|
export function secretSessionViewWorkspace(input) {
|
|
28
13
|
if (!input.sessionId)
|
|
@@ -38,21 +23,12 @@ export function secretSessionViewWorkspace(input) {
|
|
|
38
23
|
return join(input.launchRoot, `session-${name}`, "workspace");
|
|
39
24
|
}
|
|
40
25
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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.
|
|
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.
|
|
53
29
|
*
|
|
54
|
-
* The
|
|
55
|
-
*
|
|
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.
|
|
56
32
|
*/
|
|
57
33
|
export function bindSecretSessionView(input) {
|
|
58
34
|
const { launchRoot, viewWorkspaceDir, revisionWorkspaceDir } = input;
|
|
@@ -67,66 +43,86 @@ export function bindSecretSessionView(input) {
|
|
|
67
43
|
if (!isLaunchWorkspace(launchRoot, viewWorkspaceDir, /^session-[a-f0-9]{64}$/)) {
|
|
68
44
|
throw new Error("Secret session view is not a launch-root session path");
|
|
69
45
|
}
|
|
46
|
+
const revisionRoot = dirname(revisionWorkspaceDir);
|
|
47
|
+
const manifestPath = join(revisionRoot, "manifest.json");
|
|
70
48
|
if (!isLaunchWorkspace(launchRoot, revisionWorkspaceDir, /^revision-[a-f0-9]{64}$/) ||
|
|
71
|
-
|
|
49
|
+
realpathSync(revisionWorkspaceDir) !== revisionWorkspaceDir ||
|
|
50
|
+
!isSealedNode(lstatSync(revisionRoot), "directory") ||
|
|
51
|
+
!isSealedNode(lstatSync(revisionWorkspaceDir), "directory") ||
|
|
52
|
+
!isSealedNode(lstatSync(manifestPath), "file")) {
|
|
72
53
|
throw new Error("Secret session view needs a published revision");
|
|
73
54
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (!
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
throw new Error("Secret session view is not an executor-owned directory");
|
|
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");
|
|
80
60
|
}
|
|
81
61
|
}
|
|
82
|
-
|
|
83
|
-
|
|
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");
|
|
84
71
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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"));
|
|
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");
|
|
110
76
|
}
|
|
111
|
-
|
|
112
|
-
if (next)
|
|
113
|
-
renameSync(next, current);
|
|
77
|
+
assertSealedTree(viewWorkspaceDir);
|
|
114
78
|
}
|
|
79
|
+
// An interrupted bind leaves its staging/retired tree beside workspace.
|
|
115
80
|
clearBesideWorkspace(viewRoot);
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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 });
|
|
126
123
|
}
|
|
127
|
-
return source;
|
|
128
124
|
}
|
|
129
|
-
/** A fresh staged or retired path beside the view's
|
|
125
|
+
/** A fresh staged or retired path beside the view's workspace. */
|
|
130
126
|
function besideWorkspace(viewRoot, kind) {
|
|
131
127
|
return join(viewRoot, `.${kind}-${randomBytes(8).toString("hex")}`);
|
|
132
128
|
}
|
|
@@ -144,24 +140,15 @@ function isSealedNode(stat, kind) {
|
|
|
144
140
|
stat.uid === 0 &&
|
|
145
141
|
(stat.mode & 0o222) === 0);
|
|
146
142
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
}
|
|
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");
|
|
163
151
|
}
|
|
164
|
-
return directories;
|
|
165
152
|
}
|
|
166
153
|
/** Root-owned, read-only, and traversable only by the session's UID. */
|
|
167
154
|
function sealDirectories(directories, uid) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tangle-network/agent-profile-materialize",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.7",
|
|
4
4
|
"description": "Materialize an AgentProfile into the native per-harness workspace files (the one shared materializer for cli-bridge, agent-container, and VB).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
"@tangle-network/agent-interface": "^5.10.2"
|
|
36
36
|
},
|
|
37
37
|
"tangleRelease": {
|
|
38
|
-
"sourceSha": "
|
|
39
|
-
"mergedAt": "2026-10-
|
|
38
|
+
"sourceSha": "9802462d5041a120e98d4fec5bb975765462494c",
|
|
39
|
+
"mergedAt": "2026-10-29T06:14:09-06:00"
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
42
42
|
"build": "tsc -p tsconfig.json",
|