@phnx-labs/agents-cli 1.22.72 → 1.22.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +2 -0
  3. package/dist/cli/command-registry.d.ts +1 -1
  4. package/dist/cli/command-registry.js +2 -1
  5. package/dist/commands/packages-materialize.d.ts +17 -0
  6. package/dist/commands/packages-materialize.js +93 -0
  7. package/dist/commands/packages.d.ts +6 -4
  8. package/dist/commands/packages.js +8 -4
  9. package/dist/commands/repo.js +8 -8
  10. package/dist/commands/sessions-picker.js +38 -7
  11. package/dist/commands/sessions.d.ts +50 -1
  12. package/dist/commands/sessions.js +85 -10
  13. package/dist/lib/actor.d.ts +36 -4
  14. package/dist/lib/actor.js +73 -9
  15. package/dist/lib/agent-spec/index.d.ts +4 -0
  16. package/dist/lib/agent-spec/index.js +5 -0
  17. package/dist/lib/agent-spec/materialize.d.ts +13 -0
  18. package/dist/lib/agent-spec/materialize.js +414 -0
  19. package/dist/lib/agent-spec/package-resolve.d.ts +12 -0
  20. package/dist/lib/agent-spec/package-resolve.js +274 -0
  21. package/dist/lib/agent-spec/package-schema.d.ts +5 -0
  22. package/dist/lib/agent-spec/package-schema.js +147 -0
  23. package/dist/lib/agent-spec/package-types.d.ts +121 -0
  24. package/dist/lib/agent-spec/package-types.js +11 -0
  25. package/dist/lib/cloud/rush.d.ts +1 -1
  26. package/dist/lib/cloud/rush.js +2 -2
  27. package/dist/lib/daemon/usage-sync-service.d.ts +12 -5
  28. package/dist/lib/daemon/usage-sync-service.js +27 -5
  29. package/dist/lib/exec.js +3 -0
  30. package/dist/lib/fleet-shared-state.d.ts +30 -0
  31. package/dist/lib/fleet-shared-state.js +5 -0
  32. package/dist/lib/hooks/install.d.ts +31 -1
  33. package/dist/lib/hooks/install.js +44 -2
  34. package/dist/lib/mcp.d.ts +14 -2
  35. package/dist/lib/mcp.js +12 -2
  36. package/dist/lib/packages/output-home.d.ts +39 -0
  37. package/dist/lib/packages/output-home.js +203 -0
  38. package/dist/lib/paths.d.ts +9 -0
  39. package/dist/lib/paths.js +26 -0
  40. package/dist/lib/project-resources.d.ts +6 -2
  41. package/dist/lib/project-resources.js +133 -44
  42. package/dist/lib/rush-session.d.ts +10 -2
  43. package/dist/lib/rush-session.js +12 -3
  44. package/dist/lib/secrets/drivers/rush.js +1 -1
  45. package/dist/lib/session/active.d.ts +12 -0
  46. package/dist/lib/session/active.js +5 -1
  47. package/dist/lib/session/actor-sidecar.d.ts +7 -0
  48. package/dist/lib/session/actor-sidecar.js +2 -0
  49. package/dist/lib/session/cloud.js +1 -1
  50. package/dist/lib/session/db.d.ts +60 -1
  51. package/dist/lib/session/db.js +191 -6
  52. package/dist/lib/session/live-metadata.d.ts +24 -0
  53. package/dist/lib/session/live-metadata.js +55 -0
  54. package/dist/lib/session/mirror.d.ts +67 -0
  55. package/dist/lib/session/mirror.js +158 -0
  56. package/dist/lib/session/types.d.ts +18 -0
  57. package/dist/lib/spinner.d.ts +39 -0
  58. package/dist/lib/spinner.js +41 -0
  59. package/dist/lib/startup/command-registry.js +1 -1
  60. package/dist/lib/types.d.ts +6 -0
  61. package/package.json +1 -1
@@ -0,0 +1,414 @@
1
+ /**
2
+ * Native-home materializer (PHNX-3838).
3
+ *
4
+ * `materializeAgentPackage` is the harness-adapter layer: it takes the ONE
5
+ * canonical result `resolveAgentPackage` already decided and projects it into
6
+ * a fresh, isolated native home for one harness (Claude Code, Codex, or
7
+ * OpenCode). It reuses the codebase's existing native-projection primitives
8
+ * instead of re-deriving per-harness format knowledge:
9
+ *
10
+ * - `agentConfigDirName` / `AGENTS[..].capabilities.rules.file` (agent-spec/agents.ts)
11
+ * for instructions placement
12
+ * - `subagentTarget(...)` + its registered transforms (subagents-registry.ts)
13
+ * for subagent projection
14
+ * - `writeMcpConfig` (lib/mcp.ts) for per-harness MCP config format
15
+ * - `registerHooksToSettings` (lib/hooks/install.ts) for per-harness hook
16
+ * registration, including its stale-registration GC
17
+ *
18
+ * It writes a deterministic, unsigned `materialization-receipt.json` — every
19
+ * `target` path it lists is a path THIS materializer owns, so a second run
20
+ * whose resource set shrank can prune exactly those paths without touching
21
+ * anything else in the output home.
22
+ */
23
+ import * as crypto from 'crypto';
24
+ import * as fs from 'fs';
25
+ import * as path from 'path';
26
+ import { supports } from '../capabilities.js';
27
+ import { AGENTS, agentConfigDirName, getMcpConfigPathForHome } from './agents.js';
28
+ import { getHooksDirInHome } from '../hooks/install.js';
29
+ import { registerHooksToSettings, hookRegistrationTargets } from '../hooks/install.js';
30
+ import { writeMcpConfig } from '../mcp.js';
31
+ import { subagentTarget } from '../subagents-registry.js';
32
+ import { isSafeSegmentName, realpathExistingPrefix } from '../paths.js';
33
+ import { AgentPackageError } from './package-types.js';
34
+ import { effectiveResources } from './package-resolve.js';
35
+ const RECEIPT_FILE = 'materialization-receipt.json';
36
+ const KIND_TO_CAPABILITY = {
37
+ instructions: 'rules',
38
+ skills: 'skills',
39
+ subagents: 'subagents',
40
+ mcp: 'mcp',
41
+ hooks: 'hooks',
42
+ };
43
+ const COPY_IGNORE = new Set(['.DS_Store', '.git', '.gitignore', 'node_modules']);
44
+ function copyDir(src, dest) {
45
+ fs.mkdirSync(dest, { recursive: true });
46
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
47
+ if (entry.isSymbolicLink() || COPY_IGNORE.has(entry.name))
48
+ continue;
49
+ const s = path.join(src, entry.name);
50
+ const d = path.join(dest, entry.name);
51
+ if (entry.isDirectory())
52
+ copyDir(s, d);
53
+ else if (entry.isFile())
54
+ fs.copyFileSync(s, d);
55
+ }
56
+ }
57
+ function removePath(p) {
58
+ try {
59
+ const stat = fs.lstatSync(p);
60
+ if (stat.isDirectory())
61
+ fs.rmSync(p, { recursive: true, force: true });
62
+ else
63
+ fs.unlinkSync(p);
64
+ }
65
+ catch {
66
+ /* already gone */
67
+ }
68
+ }
69
+ /**
70
+ * Fail closed: every effective resource's kind must be a supported capability
71
+ * on this harness+version. MCP carries two finer-grained sub-capabilities the
72
+ * coarse `mcp` flag does not cover — `mcpHttp` (remote transport at all) and
73
+ * `mcpHeaders` (custom headers on a remote server) — mirroring the check
74
+ * `registerMcp` already applies (agent-spec/agents.ts). Skipping these let a
75
+ * harness with `mcpHttp: false` (e.g. opencode) or `mcpHeaders: false` (e.g.
76
+ * codex) silently receive an http/sse server or a header it cannot express.
77
+ */
78
+ function assertCapabilitiesSupported(resources, harness, harnessVersion) {
79
+ const unsupported = [];
80
+ for (const r of resources) {
81
+ const cap = KIND_TO_CAPABILITY[r.kind];
82
+ const result = supports(harness, cap, harnessVersion);
83
+ if (!result.ok) {
84
+ const need = 'need' in result && result.need ? ` (need ${result.need})` : '';
85
+ unsupported.push(`${r.kind} '${r.name}' requires capability '${cap}' on ${harness}${need}`);
86
+ }
87
+ if (r.kind === 'mcp' && r.mcp) {
88
+ const isRemote = r.mcp.transport === 'http' || r.mcp.transport === 'sse';
89
+ if (isRemote && !supports(harness, 'mcpHttp', harnessVersion).ok) {
90
+ unsupported.push(`mcp '${r.name}' declares transport '${r.mcp.transport}' but ${harness} does not support capability 'mcpHttp'`);
91
+ }
92
+ const hasHeaders = r.mcp.headers && Object.keys(r.mcp.headers).length > 0;
93
+ if (isRemote && hasHeaders && !supports(harness, 'mcpHeaders', harnessVersion).ok) {
94
+ unsupported.push(`mcp '${r.name}' declares headers but ${harness} does not support capability 'mcpHeaders'`);
95
+ }
96
+ }
97
+ }
98
+ if (unsupported.length > 0) {
99
+ throw new AgentPackageError(`${harness}@${harnessVersion} cannot materialize this package: ${unsupported.length} unsupported capability request(s)`, 'unsupported-capability', unsupported);
100
+ }
101
+ }
102
+ /**
103
+ * The load-bearing containment invariant: every path this materializer is about
104
+ * to write to (or delete) must, once symlinks in its existing prefix are
105
+ * resolved, stay inside `realOutputHome` (the realpath of the output home). The
106
+ * writers form their targets by APPENDING the harness config dir + resource name
107
+ * to `outputHome` — so a symlink planted at that join point (e.g. an
108
+ * `outputHome/.claude` symlink → the live `~/.claude`) redirects the write
109
+ * outside the output home. `resolveOutputHome`'s front-door guard can't see that
110
+ * child; this check, in the materializer that every writer already funnels
111
+ * through, is what protects a direct (Factory / Prix Cloud) caller too. Called
112
+ * BEFORE any `mkdirSync`/`copyFileSync`/`rmSync`, since `mkdir -p` would happily
113
+ * traverse the symlink first.
114
+ */
115
+ function assertTargetContained(realOutputHome, target, label) {
116
+ const canonical = realpathExistingPrefix(target);
117
+ if (canonical !== realOutputHome && !canonical.startsWith(realOutputHome + path.sep)) {
118
+ throw new AgentPackageError(`${label}: refusing to write outside the output home — '${target}' resolves outside '${realOutputHome}'`, 'path-escape');
119
+ }
120
+ }
121
+ /**
122
+ * The final-leaf guard, stricter than {@link assertTargetContained}: it also
123
+ * refuses a symlink planted AT the leaf itself. `assertTargetContained` resolves
124
+ * an existing leaf symlink and catches it only when the target is outside — but a
125
+ * DANGLING leaf symlink (its target does not exist yet) resolves via the leaf's
126
+ * real parent, reads as contained, and then `copyFileSync`/`writeFileSync`/
127
+ * `chmodSync` FOLLOWS it and creates/overwrites the file at the link's
128
+ * destination. So `lstat` the leaf and reject any symlink outright. Called
129
+ * immediately before each write/chmod of a concrete destination file.
130
+ */
131
+ function assertLeafSafe(realOutputHome, leaf, label) {
132
+ assertTargetContained(realOutputHome, leaf, label);
133
+ let lst;
134
+ try {
135
+ lst = fs.lstatSync(leaf);
136
+ }
137
+ catch {
138
+ return; // leaf does not exist — nothing planted
139
+ }
140
+ if (lst.isSymbolicLink()) {
141
+ throw new AgentPackageError(`${label}: refusing to write through a symlink at '${leaf}'`, 'path-escape');
142
+ }
143
+ }
144
+ function materializeInstructions(resource, harness, outputHome, realOutputHome) {
145
+ const cap = AGENTS[harness].capabilities.rules;
146
+ if (cap === false) {
147
+ throw new AgentPackageError(`${harness} has no instructions target (rules capability is false)`, 'unsupported-capability');
148
+ }
149
+ const agentDir = path.join(outputHome, agentConfigDirName(harness));
150
+ const destFile = path.join(agentDir, cap.file);
151
+ // Ancestor guard BEFORE mkdir (else `mkdir -p` traverses a symlinked config
152
+ // dir into the live home); leaf guard AFTER mkdir (a symlink planted at the
153
+ // file itself, e.g. on a re-run into a reused home).
154
+ assertTargetContained(realOutputHome, destFile, `${harness} instructions`);
155
+ fs.mkdirSync(path.dirname(destFile), { recursive: true });
156
+ assertLeafSafe(realOutputHome, destFile, `${harness} instructions`);
157
+ fs.copyFileSync(resource.sourcePath, destFile);
158
+ return path.relative(outputHome, destFile);
159
+ }
160
+ function materializeSkill(resource, harness, outputHome, realOutputHome) {
161
+ const agentDir = path.join(outputHome, agentConfigDirName(harness));
162
+ const destDir = path.join(agentDir, 'skills', resource.name);
163
+ assertTargetContained(realOutputHome, destDir, `${harness} skill '${resource.name}'`);
164
+ removePath(destDir);
165
+ copyDir(resource.sourcePath, destDir);
166
+ return path.relative(outputHome, destDir);
167
+ }
168
+ function materializeSubagent(resource, harness, outputHome, realOutputHome) {
169
+ const target = subagentTarget(harness);
170
+ if (!target) {
171
+ throw new AgentPackageError(`${harness} has no subagent target registered`, 'unsupported-capability');
172
+ }
173
+ const dir = target.dir(outputHome);
174
+ // Ancestor guard BEFORE the write's `mkdir -p` (a symlinked `.claude/agents`
175
+ // would otherwise be traversed); leaf guard on the concrete subagent file the
176
+ // writer forms itself, so a preplanted symlink AT that leaf can't redirect it.
177
+ assertTargetContained(realOutputHome, dir, `${harness} subagent '${resource.name}'`);
178
+ fs.mkdirSync(dir, { recursive: true });
179
+ const occupied = target.occupied(dir, resource.name)[0];
180
+ assertLeafSafe(realOutputHome, occupied.path, `${harness} subagent '${resource.name}'`);
181
+ target.write(dir, { name: resource.name, path: resource.sourcePath });
182
+ return path.relative(outputHome, occupied.path);
183
+ }
184
+ /**
185
+ * MCP servers write into one shared per-harness config file that may also
186
+ * carry unrelated content the materializer does not own (an oauth account, a
187
+ * project list — anything the real harness binary writes into that same file
188
+ * once the pod actually runs it). `writeMcpConfig`'s `overwrite` mode already
189
+ * preserves every other top-level key; `allowEmpty: true` is what lets THIS
190
+ * call — which always knows the complete current mcp resource set, including
191
+ * zero — converge the mcp section to exactly that set, rather than the
192
+ * generic path-based pruner deleting the whole file when the package's last
193
+ * mcp resource is removed (that would take the unrelated content with it).
194
+ * Runs whenever the file already exists so a package with zero mcp resources
195
+ * that never wrote one doesn't spuriously create an empty config.
196
+ */
197
+ function materializeMcp(resources, harness, outputHome, realOutputHome) {
198
+ const configPath = getMcpConfigPathForHome(harness, outputHome);
199
+ if (resources.length === 0 && !fs.existsSync(configPath))
200
+ return [];
201
+ assertTargetContained(realOutputHome, configPath, `${harness} mcp config`);
202
+ const servers = resources.map((r) => ({
203
+ name: r.mcp.name,
204
+ transport: r.mcp.transport,
205
+ command: r.mcp.command,
206
+ args: r.mcp.args,
207
+ env: r.mcp.env,
208
+ url: r.mcp.url,
209
+ headers: r.mcp.headers,
210
+ }));
211
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
212
+ // The shared config is a regular file the materializer (and, later, the harness
213
+ // itself) rewrites in place; a SYMLINK at that leaf is never something we wrote,
214
+ // so refuse it before writeMcpConfig follows it out of the output home.
215
+ assertLeafSafe(realOutputHome, configPath, `${harness} mcp config`);
216
+ try {
217
+ writeMcpConfig(harness, configPath, servers, 'overwrite', { allowEmpty: true });
218
+ }
219
+ catch (err) {
220
+ throw new AgentPackageError(`${harness}: cannot write mcp config — ${err.message}`, 'unsupported-capability');
221
+ }
222
+ const rel = path.relative(outputHome, configPath);
223
+ return resources.map(() => rel);
224
+ }
225
+ function materializeHooks(resources, harness, outputHome, realOutputHome) {
226
+ const targets = new Map();
227
+ if (resources.length === 0)
228
+ return targets;
229
+ const hooksDir = getHooksDirInHome(harness, outputHome);
230
+ // Guarding the hooks dir also protects the harness settings.json that
231
+ // registerHooksToSettings writes below — both live under the same
232
+ // agent-config-dir ancestor, so a symlink there is caught before either write.
233
+ assertTargetContained(realOutputHome, hooksDir, `${harness} hooks dir`);
234
+ fs.mkdirSync(hooksDir, { recursive: true });
235
+ // The hooks-dir guard catches a symlinked ANCESTOR, but registerHooksToSettings
236
+ // (called below) writes a per-harness settings/registrar leaf under that dir's
237
+ // sibling config root — settings.json, codex hooks.json/config.toml, the
238
+ // opencode plugin. A symlink planted AT one of those leaves would redirect that
239
+ // write; fail loud here, before any hook script is copied.
240
+ for (const settingsLeaf of hookRegistrationTargets(harness, outputHome)) {
241
+ assertLeafSafe(realOutputHome, settingsLeaf, `${harness} hook settings`);
242
+ }
243
+ const manifest = {};
244
+ for (const r of resources) {
245
+ const { def, scriptPath } = r.hook;
246
+ // The hook name is attacker-controlled (it comes from the package's
247
+ // hooks/*.yaml `name:`) and is used as a filename here — a name like
248
+ // '../../../.config/foo' would copy + chmod +x OUTSIDE the output home.
249
+ // Require a single safe path segment AND re-assert containment before the
250
+ // write, failing loud rather than escaping.
251
+ if (!isSafeSegmentName(r.name)) {
252
+ throw new AgentPackageError(`${harness}: hook name '${r.name}' is not a safe single path segment`, 'invalid-resource');
253
+ }
254
+ const hooksDirResolved = path.resolve(hooksDir);
255
+ const destScript = path.join(hooksDirResolved, `${r.name}${path.extname(scriptPath)}`);
256
+ if (!destScript.startsWith(hooksDirResolved + path.sep)) {
257
+ throw new AgentPackageError(`${harness}: hook '${r.name}' resolves outside the hooks directory`, 'invalid-resource');
258
+ }
259
+ // The check above is textual (name-traversal); this one refuses a preplanted
260
+ // symlink AT the script leaf, which both copyFileSync AND chmodSync would
261
+ // otherwise follow — chmod +x on a file outside the output home.
262
+ assertLeafSafe(realOutputHome, destScript, `${harness} hook '${r.name}'`);
263
+ fs.copyFileSync(scriptPath, destScript);
264
+ fs.chmodSync(destScript, 0o755);
265
+ manifest[r.name] = { script: destScript, events: def.events, matcher: def.matcher, timeout: def.timeout };
266
+ targets.set(r.name, path.relative(outputHome, destScript));
267
+ }
268
+ // `skipGlobalShimSweep`: this manifest is the PACKAGE's hooks only, so the
269
+ // default orphan-shim sweep would delete every unrelated `.sh` from the ONE
270
+ // process-global shim dir (`~/.agents/.cache/shims/hooks/`) — the operator's
271
+ // real hooks. Materialization is isolated to `outputHome`, so it must never
272
+ // GC that global directory (PHNX-3838).
273
+ const result = registerHooksToSettings(harness, outputHome, manifest, undefined, { skipGlobalShimSweep: true });
274
+ if (result.errors.length > 0) {
275
+ throw new AgentPackageError(`${harness}: failed to register hook(s) — ${result.errors.join('; ')}`, 'invalid-resource');
276
+ }
277
+ return targets;
278
+ }
279
+ /** Deterministic package ref used in the receipt — `<slug>@<manifest-digest-prefix>`, since packages carry no separate semver here. */
280
+ function packageRef(resolved) {
281
+ return `${resolved.manifest.slug}@${resolved.digest.slice(0, 12)}`;
282
+ }
283
+ /**
284
+ * True when `rel` is a target this pruner may safely delete: a non-empty,
285
+ * `..`-free RELATIVE path whose CANONICAL form (symlinks in its existing prefix
286
+ * resolved) stays strictly inside `realOutputHome`. The receipt is unsigned and
287
+ * sits inside the output home, so a planted receipt could carry a `target` of
288
+ * `../../victim`, `/etc/passwd`, OR an innocuous-looking `skills/keep.txt` sitting
289
+ * one level under a symlinked ancestor (`outputHome/skills` → a victim dir) — a
290
+ * purely textual containment check misses the last shape because `removePath`'s
291
+ * `lstat` only refuses to follow the FINAL component, still traversing symlinked
292
+ * ancestors. Realpath-verify before deleting; skip (never delete) anything that
293
+ * escapes.
294
+ */
295
+ function isSafeContainedTarget(realOutputHome, outputHome, rel) {
296
+ if (typeof rel !== 'string' || rel.length === 0 || rel.includes('\0'))
297
+ return false;
298
+ if (path.isAbsolute(rel))
299
+ return false;
300
+ if (rel.split(/[\\/]/).includes('..'))
301
+ return false;
302
+ const canonical = realpathExistingPrefix(path.resolve(outputHome, rel));
303
+ return canonical.startsWith(realOutputHome + path.sep);
304
+ }
305
+ /** Delete any path this materializer owned in a PRIOR run of this exact output home that is no longer part of the current resource set. */
306
+ function pruneStaleManagedPaths(realOutputHome, outputHome, previousTargets, currentTargets) {
307
+ for (const rel of previousTargets) {
308
+ if (currentTargets.has(rel))
309
+ continue;
310
+ // The prior receipt is unsigned; never delete a target that escapes the
311
+ // output home — textually OR through a symlinked ancestor.
312
+ if (!isSafeContainedTarget(realOutputHome, outputHome, rel))
313
+ continue;
314
+ removePath(path.join(outputHome, rel));
315
+ }
316
+ }
317
+ /** Structurally validate an untrusted, unsigned prior receipt before its targets are ever trusted for deletion. */
318
+ function isValidReceipt(value) {
319
+ if (!value || typeof value !== 'object')
320
+ return false;
321
+ const v = value;
322
+ if (v.schemaVersion !== 1)
323
+ return false;
324
+ if (!Array.isArray(v.resources))
325
+ return false;
326
+ return v.resources.every((r) => r && typeof r === 'object' && typeof r.kind === 'string' && typeof r.target === 'string');
327
+ }
328
+ function readPriorReceipt(outputHome) {
329
+ const receiptPath = path.join(outputHome, RECEIPT_FILE);
330
+ // A preplanted SYMLINK at the receipt is untrusted: reading through it slurps
331
+ // an arbitrary outside file as the "prior receipt". Treat it as no prior
332
+ // receipt (the write path refuses to follow it too — see assertLeafSafe below).
333
+ try {
334
+ if (fs.lstatSync(receiptPath).isSymbolicLink())
335
+ return null;
336
+ }
337
+ catch {
338
+ return null; // no receipt yet
339
+ }
340
+ let parsed;
341
+ try {
342
+ parsed = JSON.parse(fs.readFileSync(receiptPath, 'utf-8'));
343
+ }
344
+ catch {
345
+ return null;
346
+ }
347
+ return isValidReceipt(parsed) ? parsed : null;
348
+ }
349
+ /**
350
+ * Materialize `resolved` into a fresh native home for `options.harness`. Fails
351
+ * closed (throws `AgentPackageError`, writes nothing new) when the harness is
352
+ * not declared supported by the package, or when any effective resource needs
353
+ * a capability the harness+version does not have. Idempotent and
354
+ * deterministic: re-running against the same `outputHome` with the same
355
+ * inputs produces a byte-identical receipt and prunes any managed path this
356
+ * materializer previously wrote that the current resource set no longer needs.
357
+ */
358
+ export function materializeAgentPackage(resolved, options) {
359
+ const { harness, harnessVersion, outputHome } = options;
360
+ if (!resolved.manifest.execution.harnesses.supported.includes(harness)) {
361
+ throw new AgentPackageError(`package '${resolved.manifest.slug}' does not declare '${harness}' as a supported harness`, 'unsupported-harness', resolved.manifest.execution.harnesses.supported);
362
+ }
363
+ const resources = effectiveResources(resolved, harness);
364
+ assertCapabilitiesSupported(resources, harness, harnessVersion);
365
+ fs.mkdirSync(outputHome, { recursive: true });
366
+ // The output home now exists, so realpath it once: every write/delete target
367
+ // is verified against THIS canonical root, so a symlink planted at the
368
+ // harness-config-dir join point (or a symlinked ancestor named by a stale
369
+ // receipt) can't redirect a write/delete outside the output home.
370
+ const realOutputHome = fs.realpathSync(outputHome);
371
+ const prior = readPriorReceipt(outputHome);
372
+ // 'mcp' is excluded: it's a shared config file `materializeMcp` converges
373
+ // (including to empty) by editing its own section, not a path this generic
374
+ // delete-by-path pruner may ever remove wholesale — see materializeMcp's doc.
375
+ const previousTargets = new Set((prior?.resources ?? []).filter((r) => r.kind !== 'mcp').map((r) => r.target));
376
+ const entries = [];
377
+ const mcpResources = resources.filter((r) => r.kind === 'mcp');
378
+ const hookResources = resources.filter((r) => r.kind === 'hooks');
379
+ const mcpTargets = materializeMcp(mcpResources, harness, outputHome, realOutputHome);
380
+ const hookTargets = materializeHooks(hookResources, harness, outputHome, realOutputHome);
381
+ for (const r of resources) {
382
+ let target;
383
+ if (r.kind === 'instructions')
384
+ target = materializeInstructions(r, harness, outputHome, realOutputHome);
385
+ else if (r.kind === 'skills')
386
+ target = materializeSkill(r, harness, outputHome, realOutputHome);
387
+ else if (r.kind === 'subagents')
388
+ target = materializeSubagent(r, harness, outputHome, realOutputHome);
389
+ else if (r.kind === 'mcp')
390
+ target = mcpTargets[mcpResources.indexOf(r)];
391
+ else
392
+ target = hookTargets.get(r.name);
393
+ entries.push({ kind: r.kind, name: r.name, target, sha256: r.sha256, provenance: r.provenance });
394
+ }
395
+ const currentTargets = new Set(entries.map((e) => e.target));
396
+ pruneStaleManagedPaths(realOutputHome, outputHome, previousTargets, currentTargets);
397
+ const receipt = {
398
+ schemaVersion: 1,
399
+ agent: { ref: packageRef(resolved), digest: `sha256:${resolved.digest}` },
400
+ harness: { id: harness, version: harnessVersion },
401
+ resources: entries,
402
+ warnings: [],
403
+ };
404
+ const receiptPath = path.join(outputHome, RECEIPT_FILE);
405
+ // Final leaf guard: a symlink planted at the receipt (dangling or live) would
406
+ // redirect this write out of the output home; refuse it.
407
+ assertLeafSafe(realOutputHome, receiptPath, 'materialization receipt');
408
+ fs.writeFileSync(receiptPath, JSON.stringify(receipt, null, 2) + '\n');
409
+ return receipt;
410
+ }
411
+ /** Lowercase hex sha256 of arbitrary bytes — exposed for callers that want to verify the receipt file's own digest (Factory's observed-digest record). */
412
+ export function sha256OfReceiptFile(receiptPath) {
413
+ return crypto.createHash('sha256').update(fs.readFileSync(receiptPath)).digest('hex');
414
+ }
@@ -0,0 +1,12 @@
1
+ import type { AgentId } from '../types.js';
2
+ import type { ResolvedAgentPackage, ResolvedResource } from './package-types.js';
3
+ /** Parse, validate, and resolve every declared resource of a package directory into one canonical result. */
4
+ export declare function resolveAgentPackage(packageDir: string): ResolvedAgentPackage;
5
+ /**
6
+ * The resource set a specific harness actually materializes: portable
7
+ * resources, with any harness-overlay resource of the same `(kind, name)`
8
+ * deterministically replacing its portable counterpart, plus overlay-only
9
+ * additions. This is the ONE merge rule every harness adapter shares —
10
+ * `materialize.ts` calls this instead of re-deriving precedence per harness.
11
+ */
12
+ export declare function effectiveResources(resolved: ResolvedAgentPackage, harness: AgentId): ResolvedResource[];