@phnx-labs/agents-cli 1.22.72 → 1.22.73

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 (50) hide show
  1. package/CHANGELOG.md +41 -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.js +6 -5
  12. package/dist/lib/actor.d.ts +36 -4
  13. package/dist/lib/actor.js +73 -9
  14. package/dist/lib/agent-spec/index.d.ts +4 -0
  15. package/dist/lib/agent-spec/index.js +5 -0
  16. package/dist/lib/agent-spec/materialize.d.ts +13 -0
  17. package/dist/lib/agent-spec/materialize.js +414 -0
  18. package/dist/lib/agent-spec/package-resolve.d.ts +12 -0
  19. package/dist/lib/agent-spec/package-resolve.js +274 -0
  20. package/dist/lib/agent-spec/package-schema.d.ts +5 -0
  21. package/dist/lib/agent-spec/package-schema.js +147 -0
  22. package/dist/lib/agent-spec/package-types.d.ts +121 -0
  23. package/dist/lib/agent-spec/package-types.js +11 -0
  24. package/dist/lib/daemon/usage-sync-service.d.ts +12 -5
  25. package/dist/lib/daemon/usage-sync-service.js +27 -5
  26. package/dist/lib/exec.js +3 -0
  27. package/dist/lib/fleet-shared-state.d.ts +30 -0
  28. package/dist/lib/fleet-shared-state.js +5 -0
  29. package/dist/lib/hooks/install.d.ts +31 -1
  30. package/dist/lib/hooks/install.js +44 -2
  31. package/dist/lib/mcp.d.ts +14 -2
  32. package/dist/lib/mcp.js +12 -2
  33. package/dist/lib/packages/output-home.d.ts +39 -0
  34. package/dist/lib/packages/output-home.js +203 -0
  35. package/dist/lib/paths.d.ts +9 -0
  36. package/dist/lib/paths.js +26 -0
  37. package/dist/lib/session/active.d.ts +12 -0
  38. package/dist/lib/session/active.js +5 -1
  39. package/dist/lib/session/actor-sidecar.d.ts +7 -0
  40. package/dist/lib/session/actor-sidecar.js +2 -0
  41. package/dist/lib/session/db.d.ts +60 -1
  42. package/dist/lib/session/db.js +191 -6
  43. package/dist/lib/session/mirror.d.ts +67 -0
  44. package/dist/lib/session/mirror.js +158 -0
  45. package/dist/lib/session/types.d.ts +18 -0
  46. package/dist/lib/spinner.d.ts +39 -0
  47. package/dist/lib/spinner.js +41 -0
  48. package/dist/lib/startup/command-registry.js +1 -1
  49. package/dist/lib/types.d.ts +6 -0
  50. package/package.json +1 -1
package/dist/lib/actor.js CHANGED
@@ -7,8 +7,10 @@
7
7
  *
8
8
  * - Over SSH (the shared-fleet case): `tailscale whois` the SSH client IP to
9
9
  * the connecting tailnet identity -- a real name + login email.
10
- * - Locally (non-SSH): we can't honestly say who is at the box, so the id is
11
- * `UNRESOLVED@<host>` and no personal git identity is claimed.
10
+ * - Locally (non-SSH): the run belongs to whoever owns this device on the
11
+ * tailnet, so we read that owner from `tailscale status` (`.Self.UserID` ->
12
+ * `.User[]`). Only if tailscale can't name the device owner either do we fall
13
+ * back to the honest `UNRESOLVED@<host>` with no personal git identity claimed.
12
14
  * - Inherited: a child spawn trusts the `AGENTS_ACTOR*` env its parent
13
15
  * stamped rather than re-resolving, so the whole spawn tree shares one actor.
14
16
  *
@@ -54,6 +56,38 @@ function tailscaleWhois(ip) {
54
56
  return undefined;
55
57
  }
56
58
  }
59
+ /**
60
+ * Resolve this device's own tailnet owner via `tailscale status --json`:
61
+ * `.Self.UserID` indexes into the `.User` map for the owner's login + display
62
+ * name. Used for a LOCAL run, where there is no SSH client to whois — the run
63
+ * belongs to whoever owns the box on the tailnet. Same graceful-undefined +
64
+ * timeout discipline as `tailscaleWhois`: tailscale absent, a wedged daemon, a
65
+ * tagged (owner-less) device, or a parse failure all yield undefined, never an
66
+ * error and never a hang on the spawn hot path.
67
+ */
68
+ function tailscaleSelf() {
69
+ try {
70
+ const res = spawnSync('tailscale', ['status', '--json'], {
71
+ encoding: 'utf-8',
72
+ windowsHide: true,
73
+ timeout: WHOIS_TIMEOUT_MS,
74
+ });
75
+ if (res.status !== 0 || !res.stdout)
76
+ return undefined;
77
+ const data = JSON.parse(res.stdout);
78
+ const uid = data.Self?.UserID;
79
+ if (uid == null)
80
+ return undefined;
81
+ // The User map is keyed by the UserID rendered as a string.
82
+ const u = data.User?.[String(uid)];
83
+ if (!u?.LoginName)
84
+ return undefined;
85
+ return { login: u.LoginName, displayName: u.DisplayName };
86
+ }
87
+ catch {
88
+ return undefined;
89
+ }
90
+ }
57
91
  /** Read the actors map from config, tolerant of a missing/unreadable config. */
58
92
  function readActors() {
59
93
  try {
@@ -96,6 +130,7 @@ export function actorFromIdentity(who, host, actors) {
96
130
  name: cfg?.name ?? who?.displayName,
97
131
  email: cfg?.email ?? emailFromLogin,
98
132
  github: cfg?.github,
133
+ phoenixId: cfg?.phoenixId,
99
134
  };
100
135
  }
101
136
  /** Reconstruct an actor an ancestor process already resolved into the env. */
@@ -109,31 +144,58 @@ function inheritedActor(env) {
109
144
  name: env.AGENTS_ACTOR_NAME || undefined,
110
145
  email: env.AGENTS_ACTOR_EMAIL || undefined,
111
146
  github: env.AGENTS_ACTOR_GITHUB || undefined,
147
+ phoenixId: env.AGENTS_ACTOR_PHOENIX_ID || undefined,
112
148
  };
113
149
  }
150
+ const defaultResolvers = { whois: tailscaleWhois, self: tailscaleSelf };
114
151
  /**
115
- * Compute the actor for a given environment. Pure with respect to `env` (the
116
- * only impurity is the `tailscale whois` / config read on the fresh-SSH path),
117
- * so tests can drive every branch by passing an env explicitly.
152
+ * Compute the actor for a given environment. The only impurity is the tailscale
153
+ * shell-out (injectable via `resolvers`), so tests drive every branch explicitly.
154
+ *
155
+ * Resolution order: an inherited env actor wins; otherwise an SSH run whois-es
156
+ * its client IP; a local run (no SSH) credits the device's own tailnet owner;
157
+ * and anything unresolvable degrades to `UNRESOLVED@<host>`. Note the self
158
+ * fallback fires ONLY for a truly local run — an SSH run whose whois fails must
159
+ * NOT be credited to the box owner (that would misattribute a remote human to
160
+ * whoever owns the machine).
118
161
  */
119
- export function computeActor(env = process.env) {
162
+ export function computeActor(env = process.env, resolvers = defaultResolvers) {
120
163
  const inherited = inheritedActor(env);
121
164
  if (inherited)
122
165
  return inherited;
123
- const ssh = env.SSH_CONNECTION ? parseSshConnection(env.SSH_CONNECTION) : undefined;
124
- const who = ssh?.clientIp ? tailscaleWhois(ssh.clientIp) : undefined;
166
+ const sshRaw = env.SSH_CONNECTION;
167
+ const ssh = sshRaw ? parseSshConnection(sshRaw) : undefined;
168
+ let who = ssh?.clientIp ? resolvers.whois(ssh.clientIp) : undefined;
169
+ // Self-credit only for a genuinely LOCAL run (no SSH_CONNECTION at all). An
170
+ // SSH session whose connection is unparseable or unresolvable stays
171
+ // UNRESOLVED rather than being misattributed to the box's owner.
172
+ if (!who && !sshRaw)
173
+ who = resolvers.self();
125
174
  return actorFromIdentity(who, machineId(), readActors());
126
175
  }
127
176
  let cached;
177
+ let resolverOverride;
128
178
  /**
129
179
  * Resolve the actor for the current process, cached for the process lifetime
130
180
  * (the SSH `whois` shell-out runs at most once).
131
181
  */
132
182
  export function resolveActor() {
133
183
  if (!cached)
134
- cached = computeActor(process.env);
184
+ cached = computeActor(process.env, resolverOverride ?? defaultResolvers);
135
185
  return cached;
136
186
  }
187
+ /**
188
+ * Test-only: pin the tailscale resolvers `resolveActor()` uses, so a test that
189
+ * exercises the cached production entrypoint (e.g. `withActorEnv()`) is isolated
190
+ * from whether the box running it is on the tailnet. `computeActor` already takes
191
+ * injected resolvers for its unit tests; this extends the same seam to the cached
192
+ * path. Pass `undefined` to restore the real tailscale resolvers. Resets the
193
+ * cache so the next `resolveActor()` recomputes under the new resolvers.
194
+ */
195
+ export function setActorResolvers(resolvers) {
196
+ resolverOverride = resolvers;
197
+ cached = undefined;
198
+ }
137
199
  /** Clear the per-process cache. For tests, and for env changes within a run. */
138
200
  export function resetActorCache() {
139
201
  cached = undefined;
@@ -156,6 +218,8 @@ export function actorEnv(actor) {
156
218
  env.AGENTS_ACTOR_EMAIL = actor.email;
157
219
  if (actor.github)
158
220
  env.AGENTS_ACTOR_GITHUB = actor.github;
221
+ if (actor.phoenixId)
222
+ env.AGENTS_ACTOR_PHOENIX_ID = actor.phoenixId;
159
223
  if (actor.kind === 'human' && actor.name && actor.email) {
160
224
  env.GIT_AUTHOR_NAME = actor.name;
161
225
  env.GIT_AUTHOR_EMAIL = actor.email;
@@ -2,6 +2,10 @@ import type { AgentId } from '../types.js';
2
2
  import type { AgentTarget, ResolveOptions, VersionFilter } from './types.js';
3
3
  export * from './types.js';
4
4
  export * from './primitives.js';
5
+ export * from './package-types.js';
6
+ export { parseAgentPackageManifest, loadAgentPackageManifest } from './package-schema.js';
7
+ export { resolveAgentPackage, effectiveResources } from './package-resolve.js';
8
+ export { materializeAgentPackage, sha256OfReceiptFile } from './materialize.js';
5
9
  /** Shared `--help` epilog so every agent-spec command documents the same grammar. */
6
10
  export declare const AGENT_SPEC_HELP: string;
7
11
  /** Resolve a spec (single or comma-list) into concrete installed targets. */
@@ -10,6 +10,11 @@ import { defaultVersionProvider } from './provider.js';
10
10
  import * as core from './resolve.js';
11
11
  export * from './types.js';
12
12
  export * from './primitives.js';
13
+ // Portable agent-package resolver + native-home materializer (PHNX-3838).
14
+ export * from './package-types.js';
15
+ export { parseAgentPackageManifest, loadAgentPackageManifest } from './package-schema.js';
16
+ export { resolveAgentPackage, effectiveResources } from './package-resolve.js';
17
+ export { materializeAgentPackage, sha256OfReceiptFile } from './materialize.js';
13
18
  /** Shared `--help` epilog so every agent-spec command documents the same grammar. */
14
19
  export const AGENT_SPEC_HELP = 'Agent spec: <agent>[@<qualifier>]. Qualifiers: ' +
15
20
  '@latest (highest installed), @oldest (lowest installed), ' +
@@ -0,0 +1,13 @@
1
+ import type { MaterializationReceipt, MaterializeOptions, ResolvedAgentPackage } from './package-types.js';
2
+ /**
3
+ * Materialize `resolved` into a fresh native home for `options.harness`. Fails
4
+ * closed (throws `AgentPackageError`, writes nothing new) when the harness is
5
+ * not declared supported by the package, or when any effective resource needs
6
+ * a capability the harness+version does not have. Idempotent and
7
+ * deterministic: re-running against the same `outputHome` with the same
8
+ * inputs produces a byte-identical receipt and prunes any managed path this
9
+ * materializer previously wrote that the current resource set no longer needs.
10
+ */
11
+ export declare function materializeAgentPackage(resolved: ResolvedAgentPackage, options: MaterializeOptions): MaterializationReceipt;
12
+ /** Lowercase hex sha256 of arbitrary bytes — exposed for callers that want to verify the receipt file's own digest (Factory's observed-digest record). */
13
+ export declare function sha256OfReceiptFile(receiptPath: string): string;
@@ -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[];