@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.
- package/CHANGELOG.md +41 -0
- package/README.md +2 -0
- package/dist/cli/command-registry.d.ts +1 -1
- package/dist/cli/command-registry.js +2 -1
- package/dist/commands/packages-materialize.d.ts +17 -0
- package/dist/commands/packages-materialize.js +93 -0
- package/dist/commands/packages.d.ts +6 -4
- package/dist/commands/packages.js +8 -4
- package/dist/commands/repo.js +8 -8
- package/dist/commands/sessions-picker.js +38 -7
- package/dist/commands/sessions.js +6 -5
- package/dist/lib/actor.d.ts +36 -4
- package/dist/lib/actor.js +73 -9
- package/dist/lib/agent-spec/index.d.ts +4 -0
- package/dist/lib/agent-spec/index.js +5 -0
- package/dist/lib/agent-spec/materialize.d.ts +13 -0
- package/dist/lib/agent-spec/materialize.js +414 -0
- package/dist/lib/agent-spec/package-resolve.d.ts +12 -0
- package/dist/lib/agent-spec/package-resolve.js +274 -0
- package/dist/lib/agent-spec/package-schema.d.ts +5 -0
- package/dist/lib/agent-spec/package-schema.js +147 -0
- package/dist/lib/agent-spec/package-types.d.ts +121 -0
- package/dist/lib/agent-spec/package-types.js +11 -0
- package/dist/lib/daemon/usage-sync-service.d.ts +12 -5
- package/dist/lib/daemon/usage-sync-service.js +27 -5
- package/dist/lib/exec.js +3 -0
- package/dist/lib/fleet-shared-state.d.ts +30 -0
- package/dist/lib/fleet-shared-state.js +5 -0
- package/dist/lib/hooks/install.d.ts +31 -1
- package/dist/lib/hooks/install.js +44 -2
- package/dist/lib/mcp.d.ts +14 -2
- package/dist/lib/mcp.js +12 -2
- package/dist/lib/packages/output-home.d.ts +39 -0
- package/dist/lib/packages/output-home.js +203 -0
- package/dist/lib/paths.d.ts +9 -0
- package/dist/lib/paths.js +26 -0
- package/dist/lib/session/active.d.ts +12 -0
- package/dist/lib/session/active.js +5 -1
- package/dist/lib/session/actor-sidecar.d.ts +7 -0
- package/dist/lib/session/actor-sidecar.js +2 -0
- package/dist/lib/session/db.d.ts +60 -1
- package/dist/lib/session/db.js +191 -6
- package/dist/lib/session/mirror.d.ts +67 -0
- package/dist/lib/session/mirror.js +158 -0
- package/dist/lib/session/types.d.ts +18 -0
- package/dist/lib/spinner.d.ts +39 -0
- package/dist/lib/spinner.js +41 -0
- package/dist/lib/startup/command-registry.js +1 -1
- package/dist/lib/types.d.ts +6 -0
- 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):
|
|
11
|
-
*
|
|
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.
|
|
116
|
-
*
|
|
117
|
-
*
|
|
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
|
|
124
|
-
const
|
|
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[];
|