@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 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[], env: Record<string, string>): 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, hookWrapperSourceFromEnvironment("/bin/sh", ["-c", command.command], envSources));
198
+ add(path, hookWrapperSource("/bin/sh", ["-c", command.command], {}, envSources));
199
199
  return `node ${path}`;
200
200
  }
201
- function hookWrapperSourceFromEnvironment(executable, args, envSources) {
202
- return [
203
- 'import { spawnSync } from "node:child_process"',
204
- `const executable = ${JSON.stringify(executable)}`,
205
- `const args = ${JSON.stringify(args)}`,
206
- `const envSources = ${JSON.stringify(envSources)}`,
207
- "const extraEnv = {}",
208
- "for (const [name, source] of Object.entries(envSources)) {",
209
- " const value = process.env[source]",
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
- `const extraEnv = ${JSON.stringify(env)}`,
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 secretOpenCodeRevision?: boolean;
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
- // Secret OpenCode launches from its immutable revision workspace, with
79
- // that revision's .opencode as config directory. These relative native
80
- // resources therefore read the revision, never the shared workspace.
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
- SECRET_REVISION_NATIVE_PATH.test(file.relPath)) {
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
- /** Move OpenCode's cwd-discovered primary agent into its explicit JSON config. */
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 identity = createHash("sha256")
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
- const existing = lstatIfPresent(directory);
71
- if (existing) {
72
- assertPublishedRevision(directory, prepared.manifest);
63
+ if (lstatIfPresent(directory)) {
64
+ assertExisting();
73
65
  prepared.dispose();
74
66
  }
75
67
  else {
76
68
  try {
77
- // Keep the top directory private and writable for rename. The child
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
- // This is the publication point: agents cannot traverse the root
82
- // before its mode changes from 0700 to 0555.
83
- chmodSync(directory, 0o555);
71
+ if (!privateRevision)
72
+ chmodSync(directory, 0o555);
84
73
  }
85
74
  catch (error) {
86
- const raced = lstatIfPresent(directory);
87
- if (!raced?.isDirectory() || raced.isSymbolicLink())
75
+ if (!lstatIfPresent(directory))
88
76
  throw error;
89
- assertPublishedRevision(directory, prepared.manifest);
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
- root.isSymbolicLink() ||
192
- (root.mode & 0o777) !== 0o555) {
193
- throw new Error("Concurrent profile revision is not fully published");
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("Concurrent profile revision digest collision");
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.isSymbolicLink() ||
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(`Concurrent profile revision file changed: ${file.relPath}`);
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(`Concurrent profile revision has an unexpected file: ${rootName}/${relPath}`);
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("Concurrent profile revision is missing a manifest file");
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 { AgentProfileSecretProvider } from "@tangle-network/agent-interface";
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, readFileSync, renameSync, writeFileSync, } from "node:fs";
3
+ import { chmodSync, lstatSync, mkdirSync, readdirSync } from "node:fs";
4
4
  import { join } from "node:path";
5
- import { assertLaunchRoot, listRevisionFiles, makeStageWritable, prepareExplicitOpenCodePlan, } from "./managed-workspace.js";
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 { lstatIfPresent, prepareManagedWorkspacePlan, } from "./managed-workspace-stage.js";
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 OpenCode reserves ${name} for its launch binding`);
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
- /** 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();
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
- const plan = secretOpenCodePlan(input.plan, input.providerConfig, 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);
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
- 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 = {
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
- OPENCODE_DISABLE_PROJECT_CONFIG: "1",
133
- OPENCODE_CONFIG_DIR: join(directory, "workspace", ".opencode"),
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
- 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
- }
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 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.
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
- * 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.
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 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.
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 { HARNESS_SKILL_DIRS } from "./harness.js";
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
- /** The profile's read-only files, which products name cwd-relative. */
8
- const REFERENCE_ENTRY = "reference";
6
+ import { normalizeWorkspaceRelativePath } from "./workspace-files.js";
9
7
  /**
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.
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
- * 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.
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 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.
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
- !isSealedNode(lstatSync(revisionWorkspaceDir), "directory")) {
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
- 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");
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
- if (readdirSync(viewWorkspaceDir).some((name) => !VIEW_ENTRIES.has(name))) {
83
- throw new Error("Secret session view holds an unexpected entry");
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
- // 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"));
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
- const next = staged.get(entry);
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
- 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`);
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 `workspace`. */
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
- /** 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
- }
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.5",
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": "3adca738603b0b972c48f75100cb139a503b6652",
39
- "mergedAt": "2026-10-28T20:53:28-06:00"
38
+ "sourceSha": "9802462d5041a120e98d4fec5bb975765462494c",
39
+ "mergedAt": "2026-10-29T06:14:09-06:00"
40
40
  },
41
41
  "scripts": {
42
42
  "build": "tsc -p tsconfig.json",