@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
@@ -1,10 +1,17 @@
1
1
  /**
2
- * Fleet usage-snapshot sync as a `PeriodicService` (PHNX-3392 usage-sync).
2
+ * Fleet shared-state sync as a `PeriodicService` (PHNX-3392 usage-sync,
3
+ * PHNX-3792 session mirror).
3
4
  *
4
- * A headed box publishes its local identity-keyed Claude usage rows into its
5
- * owned file in the fleet-synced user repo. The tick then runs one serialized,
6
- * timeout-bounded git exchange; a worker reads the delivered peer snapshots
7
- * and merges newest-wins. No tick opens a device-to-device SSH mesh.
5
+ * This is the one tick that owns the bounded Git exchange over the fleet-synced
6
+ * user repo, so every non-secret daemon-state field rides it rather than opening
7
+ * a second committer. Each tick: (1) publishes this box's own fields into its
8
+ * conflict-free `devices/<device>/daemon-state.json` — a headed box's Claude
9
+ * usage snapshot, and EVERY box's lightweight session digests (PHNX-3792);
10
+ * (2) runs one serialized, timeout-bounded commit/rebase/push; (3) consumes the
11
+ * peer fields the exchange delivered — a worker merges usage newest-wins, and
12
+ * every non-worker box folds peers' session digests into its local index so the
13
+ * picker renders remote-host previews inline. No tick opens a device-to-device
14
+ * SSH mesh.
8
15
  */
9
16
  import { BasePeriodicService } from './service.js';
10
17
  const USAGE_SYNC_TICK_MS = 15 * 60_000;
@@ -23,11 +30,18 @@ export class UsageSyncService extends BasePeriodicService {
23
30
  }
24
31
  async onTick(ctx) {
25
32
  const { consumeUsageSnapshotsFromSharedStore, publishUsageSnapshotToSharedStore } = await import('../accounting/usage-sync.js');
33
+ const { consumeSessionMirrorFromSharedStore, publishSessionMirrorToSharedStore } = await import('../session/mirror.js');
34
+ // Publish every owned field BEFORE the single git exchange so they ride one commit.
26
35
  const published = await publishUsageSnapshotToSharedStore();
27
36
  if (published.changed)
28
37
  ctx.log('INFO', `usage-sync: published usage snapshot to ${published.path}`);
29
38
  if (published.error)
30
39
  ctx.log('WARN', `usage-sync: publish: ${published.error}`);
40
+ const mirrored = await publishSessionMirrorToSharedStore();
41
+ if (mirrored.changed)
42
+ ctx.log('INFO', `session-mirror: published ${mirrored.count} session digest(s)`);
43
+ if (mirrored.error)
44
+ ctx.log('WARN', `session-mirror: publish: ${mirrored.error}`);
31
45
  const { syncFleetSharedStateRepo } = await import('../fleet-shared-repo-sync.js');
32
46
  const transport = await syncFleetSharedStateRepo();
33
47
  if (transport.skipped)
@@ -42,5 +56,13 @@ export class UsageSyncService extends BasePeriodicService {
42
56
  }
43
57
  for (const err of consumed.errors)
44
58
  ctx.log('WARN', `usage-sync: ${err.device}: ${err.message}`);
59
+ const foldedIn = consumeSessionMirrorFromSharedStore();
60
+ if (foldedIn.merged > 0) {
61
+ ctx.log('INFO', `session-mirror: folded ${foldedIn.merged} session(s) from ${foldedIn.sources.join(', ')}`);
62
+ }
63
+ if (foldedIn.pruned > 0)
64
+ ctx.log('INFO', `session-mirror: pruned ${foldedIn.pruned} stale mirror row(s)`);
65
+ for (const err of foldedIn.errors)
66
+ ctx.log('WARN', `session-mirror: ${err.device}: ${err.message}`);
45
67
  }
46
68
  }
package/dist/lib/exec.js CHANGED
@@ -1166,6 +1166,7 @@ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
1166
1166
  sessionId: passthroughSessionId,
1167
1167
  actor: resolveActor().id,
1168
1168
  initiatedBy: resolveActor().kind,
1169
+ phoenixId: resolveActor().phoenixId,
1169
1170
  startedAtMs: Date.now(),
1170
1171
  });
1171
1172
  }
@@ -1564,6 +1565,7 @@ async function runInTmux(options, executable, args) {
1564
1565
  sessionId: options.sessionId,
1565
1566
  actor: resolveActor().id,
1566
1567
  initiatedBy: resolveActor().kind,
1568
+ phoenixId: resolveActor().phoenixId,
1567
1569
  harness: customHarnessName(options),
1568
1570
  startedAtMs: Date.now(),
1569
1571
  });
@@ -1899,6 +1901,7 @@ async function spawnAgent(options) {
1899
1901
  sessionId: options.sessionId,
1900
1902
  actor: resolveActor().id,
1901
1903
  initiatedBy: resolveActor().kind,
1904
+ phoenixId: resolveActor().phoenixId,
1902
1905
  harness: customHarnessName(options),
1903
1906
  startedAtMs: Date.now(),
1904
1907
  });
@@ -2,6 +2,32 @@ import type { CachedUsageSnapshot } from './accounting/usage.js';
2
2
  export declare const FLEET_SHARED_STATE_VERSION = 1;
3
3
  export declare const FLEET_SHARED_STATE_FILE = "daemon-state.json";
4
4
  export type SharedAuthStatus = 'ready' | 'missing' | 'invalid';
5
+ /**
6
+ * One session's lightweight preview/metadata, mirrored to the fleet so the
7
+ * interactive device renders a remote-host row's topic/preview INLINE instead of
8
+ * fetching the peer's digest live over SSH per row (PHNX-3792). Deliberately
9
+ * NOT a full transcript: only the fields a list row and a compact preview card
10
+ * need. `machine` is the EXECUTION host the publisher recorded (so an offloaded
11
+ * session's mirror row matches the same `machine:id` key the live fan-out uses,
12
+ * never double-counting), `firstUser` is a bounded first-user-message snippet,
13
+ * and `capturedAt` stamps publish time for the staleness marker.
14
+ */
15
+ export interface SessionMirrorRow {
16
+ id: string;
17
+ shortId: string;
18
+ agent: string;
19
+ version?: string;
20
+ machine: string;
21
+ cwd?: string;
22
+ topic?: string;
23
+ label?: string;
24
+ firstUser?: string;
25
+ lastActivity?: string;
26
+ timestamp: string;
27
+ ticketId?: string;
28
+ prUrl?: string;
29
+ capturedAt: number;
30
+ }
5
31
  export interface FleetSharedDeviceState {
6
32
  version: typeof FLEET_SHARED_STATE_VERSION;
7
33
  device: string;
@@ -11,10 +37,14 @@ export interface FleetSharedDeviceState {
11
37
  auth?: {
12
38
  status: SharedAuthStatus;
13
39
  };
40
+ sessions?: {
41
+ rows: SessionMirrorRow[];
42
+ };
14
43
  }
15
44
  export interface FleetSharedStatePatch {
16
45
  usage?: FleetSharedDeviceState['usage'];
17
46
  auth?: FleetSharedDeviceState['auth'];
47
+ sessions?: FleetSharedDeviceState['sessions'];
18
48
  }
19
49
  export interface FleetSharedStateReadResult {
20
50
  states: FleetSharedDeviceState[];
@@ -37,6 +37,10 @@ function parseFleetSharedDeviceState(raw, owner) {
37
37
  (!isRecord(auth) || !['ready', 'missing', 'invalid'].includes(String(auth.status)))) {
38
38
  throw new Error('unrecognized auth verdict');
39
39
  }
40
+ const sessions = parsed.sessions;
41
+ if (sessions !== undefined && (!isRecord(sessions) || !Array.isArray(sessions.rows))) {
42
+ throw new Error('unrecognized session mirror');
43
+ }
40
44
  return parsed;
41
45
  }
42
46
  /**
@@ -58,6 +62,7 @@ function mergeFleetState(currentRaw, device, patch) {
58
62
  ...current,
59
63
  ...(patch.usage !== undefined ? { usage: patch.usage } : {}),
60
64
  ...(patch.auth !== undefined ? { auth: patch.auth } : {}),
65
+ ...(patch.sessions !== undefined ? { sessions: patch.sessions } : {}),
61
66
  version: FLEET_SHARED_STATE_VERSION,
62
67
  device,
63
68
  };
@@ -384,10 +384,40 @@ export declare function listUnmanagedHooksInVersionHome(agent: AgentId, version:
384
384
  * is normalized to >= 1 (Codex: unwrap_or(600).max(1)).
385
385
  */
386
386
  export declare function computeCodexHookTrustHash(eventKeyLabel: string, command: string, timeout: number, matcher: string | undefined): string;
387
- export declare function registerHooksToSettings(agentId: AgentId, versionHome: string, hookManifest?: Record<string, ManifestHook>, agentsDirOverride?: string): {
387
+ /**
388
+ * Options that narrow `registerHooksToSettings`'s process-global side effects.
389
+ *
390
+ * `skipGlobalShimSweep` — do NOT run {@link sweepOrphanShims}. The sweep deletes
391
+ * every `.sh` in the ONE process-global shim dir (`getHookShimsDir()`,
392
+ * `~/.agents/.cache/shims/hooks/`) whose name is absent from the manifest it is
393
+ * handed. That is correct for a normal install/sync (the manifest is the
394
+ * operator's complete hook set), but catastrophic when the materializer registers
395
+ * a portable PACKAGE's hooks into an isolated output home: that manifest carries
396
+ * only the package's hooks, so the sweep would wipe the operator's unrelated
397
+ * global shims. The materializer sets this so materialization stays isolated
398
+ * (PHNX-3838); every normal caller leaves it unset and keeps the sweep.
399
+ */
400
+ export interface RegisterHooksOptions {
401
+ skipGlobalShimSweep?: boolean;
402
+ }
403
+ export declare function registerHooksToSettings(agentId: AgentId, versionHome: string, hookManifest?: Record<string, ManifestHook>, agentsDirOverride?: string, options?: RegisterHooksOptions): {
388
404
  registered: string[];
389
405
  errors: string[];
390
406
  };
407
+ /**
408
+ * The concrete config file(s) `registerHooksToSettings` writes for `agentId` in
409
+ * `home` — the settings/registrar leaves, NOT the per-hook script copies. A
410
+ * caller materializing into an UNTRUSTED home (the portable-agent materializer)
411
+ * uses this to verify none of those leaves is a preplanted symlink before the
412
+ * registrar follows it and overwrites a file outside the home.
413
+ *
414
+ * This MUST mirror the `registerHooksToSettings` dispatch above — each arm's
415
+ * write target is reproduced here, so a new registrar branch adds its leaf here
416
+ * too. It intentionally returns only the primary settings/registrar file(s); the
417
+ * per-event/script files a registrar also writes live under the hooks dir the
418
+ * caller already guards. An agent with no hooks registrar returns `[]`.
419
+ */
420
+ export declare function hookRegistrationTargets(agentId: AgentId, home: string): string[];
391
421
  /**
392
422
  * Prune every Claude-family (`settings.json`) hook entry whose command lives
393
423
  * under a removed version's home
@@ -1748,7 +1748,7 @@ function sweepOrphanShims(manifest) {
1748
1748
  catch { /* best effort */ }
1749
1749
  }
1750
1750
  }
1751
- export function registerHooksToSettings(agentId, versionHome, hookManifest, agentsDirOverride) {
1751
+ export function registerHooksToSettings(agentId, versionHome, hookManifest, agentsDirOverride, options) {
1752
1752
  if (isAgentHardDeprecated(agentId)) {
1753
1753
  return { registered: [], errors: [] };
1754
1754
  }
@@ -1765,7 +1765,8 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
1765
1765
  }
1766
1766
  return { registered: [], errors: [] };
1767
1767
  }
1768
- sweepOrphanShims(manifest);
1768
+ if (!options?.skipGlobalShimSweep)
1769
+ sweepOrphanShims(manifest);
1769
1770
  const overrideRoots = agentsDirOverride ? [agentsDirOverride] : null;
1770
1771
  // Scripts are copied into the version home during sync — prefer that stable
1771
1772
  // local path so registered commands don't break when source dirs change.
@@ -1856,6 +1857,47 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
1856
1857
  }
1857
1858
  return { registered: [], errors: [] };
1858
1859
  }
1860
+ /**
1861
+ * The concrete config file(s) `registerHooksToSettings` writes for `agentId` in
1862
+ * `home` — the settings/registrar leaves, NOT the per-hook script copies. A
1863
+ * caller materializing into an UNTRUSTED home (the portable-agent materializer)
1864
+ * uses this to verify none of those leaves is a preplanted symlink before the
1865
+ * registrar follows it and overwrites a file outside the home.
1866
+ *
1867
+ * This MUST mirror the `registerHooksToSettings` dispatch above — each arm's
1868
+ * write target is reproduced here, so a new registrar branch adds its leaf here
1869
+ * too. It intentionally returns only the primary settings/registrar file(s); the
1870
+ * per-event/script files a registrar also writes live under the hooks dir the
1871
+ * caller already guards. An agent with no hooks registrar returns `[]`.
1872
+ */
1873
+ export function hookRegistrationTargets(agentId, home) {
1874
+ switch (agentId) {
1875
+ case 'claude':
1876
+ case 'droid':
1877
+ case 'muse':
1878
+ return [path.join(home, agentConfigDirName(agentId), 'settings.json')];
1879
+ case 'codex':
1880
+ return [path.join(home, '.codex', 'hooks.json'), path.join(home, '.codex', 'config.toml')];
1881
+ case 'antigravity':
1882
+ return [path.join(home, '.gemini', 'antigravity-cli', 'settings.json')];
1883
+ case 'grok':
1884
+ return [path.join(home, '.grok', 'hooks', 'hooks.json')];
1885
+ case 'kimi':
1886
+ return [path.join(home, '.kimi-code', 'config.toml')];
1887
+ case 'copilot':
1888
+ return [path.join(home, '.copilot', 'hooks', COPILOT_MANAGED_HOOKS_FILE)];
1889
+ case 'goose':
1890
+ return [path.join(home, '.agents', 'plugins', GOOSE_MANAGED_PLUGIN_NAME, 'hooks', 'hooks.json')];
1891
+ case 'cursor':
1892
+ return [path.join(home, '.cursor', 'hooks.json')];
1893
+ case 'hermes':
1894
+ return [path.join(home, '.hermes', 'config.yaml')];
1895
+ case 'opencode':
1896
+ return [path.join(home, '.config', 'opencode', 'plugins', 'agents-cli-hooks.ts')];
1897
+ default:
1898
+ return [];
1899
+ }
1900
+ }
1859
1901
  const OPENCODE_DIRECT_EVENT_MAP = {
1860
1902
  PreToolUse: 'tool.execute.before',
1861
1903
  PostToolUse: 'tool.execute.after',
package/dist/lib/mcp.d.ts CHANGED
@@ -142,8 +142,20 @@ export interface WritableMcpServer {
142
142
  * version-home configs). `mode: 'merge'` updates/adds the provided server
143
143
  * entries while preserving existing entries (used for project-level configs
144
144
  * that users may hand-edit or populate via agent CLI commands).
145
- */
146
- export declare function writeMcpConfig(agentId: AgentId, configPath: string, servers: WritableMcpServer[], mode?: 'overwrite' | 'merge'): void;
145
+ *
146
+ * An empty `servers` list is a no-op by default — most callers pass a
147
+ * resolved-but-possibly-empty selection and an empty one means "nothing to
148
+ * apply," not "clear whatever is there," so this must never wipe an existing
149
+ * config out from under an unrelated caller. `options.allowEmpty` is the
150
+ * explicit opt-in for a caller that owns the ENTIRE mcp section by
151
+ * construction (it always knows the complete current desired server set,
152
+ * including the empty set) and needs `overwrite` to actually clear it —
153
+ * still only the mcp section: `readExistingConfig` above preserves every
154
+ * other top-level key already, in both modes.
155
+ */
156
+ export declare function writeMcpConfig(agentId: AgentId, configPath: string, servers: WritableMcpServer[], mode?: 'overwrite' | 'merge', options?: {
157
+ allowEmpty?: boolean;
158
+ }): void;
147
159
  /**
148
160
  * Install MCP servers to an agent.
149
161
  * For Claude/Codex: uses CLI commands (claude mcp add, codex mcp add)
package/dist/lib/mcp.js CHANGED
@@ -516,9 +516,19 @@ function parseJsonc(raw) {
516
516
  * version-home configs). `mode: 'merge'` updates/adds the provided server
517
517
  * entries while preserving existing entries (used for project-level configs
518
518
  * that users may hand-edit or populate via agent CLI commands).
519
+ *
520
+ * An empty `servers` list is a no-op by default — most callers pass a
521
+ * resolved-but-possibly-empty selection and an empty one means "nothing to
522
+ * apply," not "clear whatever is there," so this must never wipe an existing
523
+ * config out from under an unrelated caller. `options.allowEmpty` is the
524
+ * explicit opt-in for a caller that owns the ENTIRE mcp section by
525
+ * construction (it always knows the complete current desired server set,
526
+ * including the empty set) and needs `overwrite` to actually clear it —
527
+ * still only the mcp section: `readExistingConfig` above preserves every
528
+ * other top-level key already, in both modes.
519
529
  */
520
- export function writeMcpConfig(agentId, configPath, servers, mode = 'overwrite') {
521
- if (servers.length === 0) {
530
+ export function writeMcpConfig(agentId, configPath, servers, mode = 'overwrite', options = {}) {
531
+ if (servers.length === 0 && !options.allowEmpty) {
522
532
  return;
523
533
  }
524
534
  // Narrowed, not asserted: this is what lets the `never` guard on the default
@@ -0,0 +1,39 @@
1
+ /** The three harness homes a portable schema-v3 package can be materialized into. */
2
+ export declare const PORTABLE_HARNESSES: readonly ["claude", "codex", "opencode"];
3
+ export type PortableHarness = (typeof PORTABLE_HARNESSES)[number];
4
+ export declare function isPortableHarness(value: string): value is PortableHarness;
5
+ /** Thrown for a bad front-door argument (harness / version / output home). Never `process.exit`. */
6
+ export declare class MaterializeGuardError extends Error {
7
+ constructor(message: string);
8
+ }
9
+ /** Reject a harness that is not one of the three portable homes, with a message naming it. */
10
+ export declare function assertPortableHarness(harness: string): PortableHarness;
11
+ /** Reject a non-exact harness version (empty, malformed, or `@latest`). */
12
+ export declare function assertExactHarnessVersion(version: string): string;
13
+ /** True when `raw` still contains a `..` segment after splitting on both separators. */
14
+ export declare function outputHomeHasDotDot(raw: string): boolean;
15
+ /**
16
+ * Resolve `--output-home` to an absolute path, refusing a target that climbs out
17
+ * of cwd (relative), uses a `..` segment, IS the live home root, or targets (or
18
+ * sits inside) an existing live Claude/Codex/OpenCode home — after canonicalizing
19
+ * symlinks in the existing ancestors so a symlinked target/ancestor can't alias
20
+ * `$HOME`. Every rejection carries a `Path escape:` prefix so callers surface one
21
+ * consistent reason.
22
+ *
23
+ * If any protected home is instead a DANGLING symlink or chain, the function
24
+ * fails closed and refuses EVERY output home (via {@link assertNoDanglingLiveHome}),
25
+ * because the absent target it points at has no canonical spelling to compare a
26
+ * candidate against and the materializer's first `mkdir -p` would follow the link
27
+ * and re-create the operator's live home there (PHNX-3838). The existing-home
28
+ * comparison uses a byte-exact `===` / `startsWith` on two `realpath`-canonical
29
+ * paths, which is exact filesystem identity — no approximation of the volume's
30
+ * case/Unicode collation, which an independent macOS review showed cannot be
31
+ * reproduced in JS from the path text.
32
+ *
33
+ * This is a front-door convenience guard. The load-bearing containment invariant
34
+ * (that no per-resource write/delete escapes the output home — including through
35
+ * a symlink planted at the harness-config-dir join point, which this function
36
+ * cannot see because the materializer forms that child itself) is enforced in
37
+ * `materializeAgentPackage`, so a direct (non-CLI) caller is protected too.
38
+ */
39
+ export declare function resolveOutputHome(raw: string, cwd?: string, home?: string): string;
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Front-door safety guard for `agents packages materialize` (PHNX-3838).
3
+ *
4
+ * The canonical materializer ({@link materializeAgentPackage} in
5
+ * `agent-spec/materialize.ts`) writes into whatever `outputHome` it is handed —
6
+ * that is its job, and it must stay destination-agnostic so Factory / Prix Cloud
7
+ * can point it at any ephemeral home. Refusing a *dangerous* destination is a
8
+ * CLI-front-door concern, not a materializer concern, so it lives here:
9
+ *
10
+ * - the target may not climb out of cwd (relative) or carry a `..` segment;
11
+ * - if any protected live harness home (`~/.claude`, `~/.codex`, `~/.opencode`)
12
+ * is a DANGLING symlink or dangling chain, EVERY output home is refused —
13
+ * fail closed (see {@link assertNoDanglingLiveHome});
14
+ * - the target may not be the operator's live home ROOT, nor — or sit inside —
15
+ * an EXISTING live `~/.claude`, `~/.codex`, or `~/.opencode` home;
16
+ * - the harness must be one of the three portable homes;
17
+ * - the harness version must be an exact version, never `@latest`.
18
+ *
19
+ * The live-home ROOT refusal is load-bearing: the materializer appends the
20
+ * harness config dir (`.claude`/`.codex`/`.opencode`) to `outputHome`, so an
21
+ * `outputHome` of `$HOME` itself would land straight in the operator's live
22
+ * `~/.claude`. And because a symlink (`outputHome` itself, or any ancestor)
23
+ * pointing at `$HOME` re-opens the same hole — `path.resolve` normalizes `..`
24
+ * but never follows links — the check canonicalizes the existing ancestors with
25
+ * `realpath` before comparing.
26
+ *
27
+ * Kept beside the command (not inside it) so the live-home refusal is
28
+ * unit-testable without spawning the whole CLI.
29
+ */
30
+ import * as fs from 'fs';
31
+ import * as os from 'os';
32
+ import * as path from 'path';
33
+ import { assertWithin, realpathExistingPrefix } from '../paths.js';
34
+ import { VERSION_RE } from '../agent-spec/primitives.js';
35
+ /** The three harness homes a portable schema-v3 package can be materialized into. */
36
+ export const PORTABLE_HARNESSES = ['claude', 'codex', 'opencode'];
37
+ export function isPortableHarness(value) {
38
+ return PORTABLE_HARNESSES.includes(value);
39
+ }
40
+ /** Thrown for a bad front-door argument (harness / version / output home). Never `process.exit`. */
41
+ export class MaterializeGuardError extends Error {
42
+ constructor(message) {
43
+ super(message);
44
+ this.name = 'MaterializeGuardError';
45
+ }
46
+ }
47
+ /** Reject a harness that is not one of the three portable homes, with a message naming it. */
48
+ export function assertPortableHarness(harness) {
49
+ if (!isPortableHarness(harness)) {
50
+ throw new MaterializeGuardError(`Unsupported capability: '${harness}' is not a portable-agent harness (${PORTABLE_HARNESSES.join(', ')}).`);
51
+ }
52
+ return harness;
53
+ }
54
+ /** Reject a non-exact harness version (empty, malformed, or `@latest`). */
55
+ export function assertExactHarnessVersion(version) {
56
+ if (!version || version === 'latest' || !VERSION_RE.test(version)) {
57
+ throw new MaterializeGuardError(`Invalid harness version '${version}'. Pass an exact harness version (not @latest).`);
58
+ }
59
+ return version;
60
+ }
61
+ /** True when `raw` still contains a `..` segment after splitting on both separators. */
62
+ export function outputHomeHasDotDot(raw) {
63
+ return raw.split(/[\\/]/).includes('..');
64
+ }
65
+ /**
66
+ * The absolute path a DANGLING symlink chain ultimately points at, or `null`
67
+ * when `p` is not a symlink or its chain resolves to an existing file.
68
+ *
69
+ * `realpathExistingPrefix` deliberately does NOT follow a dangling LEAF link — it
70
+ * resolves the link's parent and re-appends the basename — so `~/.claude` → an
71
+ * absent target canonicalizes to the literal `~/.claude`, and the target itself
72
+ * sails past every containment compare. Yet the materializer's very first act is
73
+ * `fs.mkdirSync(outputHome, { recursive: true })`, which FOLLOWS that link and
74
+ * creates the target, re-pointing the operator's live `~/.claude` at the
75
+ * materialized tree. So the guard treats a dangling protected home as a fail-
76
+ * closed condition (see {@link assertNoDanglingLiveHome}). A live symlink (its
77
+ * target exists) resolves cleanly through `realpathExistingPrefix`, hence the
78
+ * `!fs.existsSync` gate returns `null` for it. Relative links resolve against
79
+ * each hop's own directory; a bounded hop budget defuses a symlink cycle
80
+ * (returning the best-effort endpoint, which still reads as dangling → refused).
81
+ */
82
+ function danglingLinkChainTarget(p) {
83
+ let current = path.resolve(p);
84
+ let followed = false;
85
+ for (let hops = 0; hops < 40; hops++) {
86
+ let dest;
87
+ try {
88
+ dest = fs.readlinkSync(current);
89
+ }
90
+ catch {
91
+ return followed && !fs.existsSync(current) ? current : null;
92
+ }
93
+ followed = true;
94
+ current = path.resolve(path.dirname(current), dest);
95
+ }
96
+ return current;
97
+ }
98
+ /**
99
+ * Fail CLOSED when any protected live harness home (`~/.claude`, `~/.codex`,
100
+ * `~/.opencode`) is a DANGLING symlink or dangling symlink chain — refusing
101
+ * EVERY output home, not merely one that aliases the dangling target.
102
+ *
103
+ * Why refuse the whole operation instead of the aliasing path alone: the
104
+ * materializer's first act is `fs.mkdirSync(outputHome, { recursive: true })`,
105
+ * which FOLLOWS the link and creates its absent target, re-pointing the
106
+ * operator's live `~/.<harness>` at the materialized tree. To refuse only the
107
+ * aliasing output home we would have to decide whether the requested path and
108
+ * the dangling target name the SAME file — but the target does not exist, so it
109
+ * cannot be `realpath`'d, and its identity then depends on the destination
110
+ * volume's own case/Unicode collation, which is per-volume and NOT knowable from
111
+ * the path text. An independent macOS review confirmed this directly: NFC +
112
+ * `toLowerCase` is not APFS case folding (APFS treats e.g. U+017F ſ and ASCII `s`
113
+ * as identical, which `toLowerCase` does not), and an OS-derived case-sensitivity
114
+ * flag is wrong for per-volume semantics. Rather than approximate an unknowable
115
+ * collation, the guard refuses outright until the operator repairs the dangling
116
+ * home. A dangling protected home is an abnormal state, so this over-rejects
117
+ * nothing a healthy environment relies on; the EXISTING-home comparison stays
118
+ * exact because `realpath` gives it a trustworthy canonical spelling.
119
+ */
120
+ function assertNoDanglingLiveHome(realHome) {
121
+ for (const name of PORTABLE_HARNESSES) {
122
+ if (danglingLinkChainTarget(path.join(realHome, `.${name}`)) !== null) {
123
+ throw new MaterializeGuardError(`Path escape: the live .${name} home is a dangling symlink; refusing every output home until it is repaired ` +
124
+ `(the materializer's mkdir -p would follow the link and re-create your live .${name} at its absent target)`);
125
+ }
126
+ }
127
+ }
128
+ /**
129
+ * The canonical EXISTING live harness home to forbid, per harness: the
130
+ * `realpathExistingPrefix` of each `~/.<harness>`. For a live symlink this is the
131
+ * real on-disk target (so `--output-home ~/.claude` and its resolved dir both
132
+ * match); for a plain dir, or a home that does not exist at all, it is the
133
+ * literal `~/.<harness>` path. Because both this and the candidate output home
134
+ * are canonicalized with `realpath`, a byte-exact compare is exact filesystem
135
+ * identity — `realpath` returns the volume's own on-disk spelling, so two paths
136
+ * naming the same file collapse to the same string with no need to approximate
137
+ * the volume's case/Unicode collation. A DANGLING `~/.<harness>` never reaches
138
+ * here: {@link assertNoDanglingLiveHome} fails closed first, precisely because
139
+ * its absent target has no realpath-canonical spelling to compare against.
140
+ */
141
+ function liveHarnessHomes(realHome) {
142
+ return PORTABLE_HARNESSES.map((name) => ({
143
+ harness: name,
144
+ live: realpathExistingPrefix(path.join(realHome, `.${name}`)),
145
+ }));
146
+ }
147
+ /**
148
+ * Resolve `--output-home` to an absolute path, refusing a target that climbs out
149
+ * of cwd (relative), uses a `..` segment, IS the live home root, or targets (or
150
+ * sits inside) an existing live Claude/Codex/OpenCode home — after canonicalizing
151
+ * symlinks in the existing ancestors so a symlinked target/ancestor can't alias
152
+ * `$HOME`. Every rejection carries a `Path escape:` prefix so callers surface one
153
+ * consistent reason.
154
+ *
155
+ * If any protected home is instead a DANGLING symlink or chain, the function
156
+ * fails closed and refuses EVERY output home (via {@link assertNoDanglingLiveHome}),
157
+ * because the absent target it points at has no canonical spelling to compare a
158
+ * candidate against and the materializer's first `mkdir -p` would follow the link
159
+ * and re-create the operator's live home there (PHNX-3838). The existing-home
160
+ * comparison uses a byte-exact `===` / `startsWith` on two `realpath`-canonical
161
+ * paths, which is exact filesystem identity — no approximation of the volume's
162
+ * case/Unicode collation, which an independent macOS review showed cannot be
163
+ * reproduced in JS from the path text.
164
+ *
165
+ * This is a front-door convenience guard. The load-bearing containment invariant
166
+ * (that no per-resource write/delete escapes the output home — including through
167
+ * a symlink planted at the harness-config-dir join point, which this function
168
+ * cannot see because the materializer forms that child itself) is enforced in
169
+ * `materializeAgentPackage`, so a direct (non-CLI) caller is protected too.
170
+ */
171
+ export function resolveOutputHome(raw, cwd = process.cwd(), home = os.homedir()) {
172
+ if (!raw || raw.includes('\0')) {
173
+ throw new MaterializeGuardError('Path escape: output home is empty or contains a null byte');
174
+ }
175
+ if (outputHomeHasDotDot(raw)) {
176
+ throw new MaterializeGuardError(`Path escape: ${raw}`);
177
+ }
178
+ const resolved = path.resolve(cwd, raw);
179
+ if (!path.isAbsolute(raw)) {
180
+ try {
181
+ assertWithin(cwd, resolved);
182
+ }
183
+ catch {
184
+ throw new MaterializeGuardError(`Path escape: ${raw}`);
185
+ }
186
+ }
187
+ const canonical = realpathExistingPrefix(resolved);
188
+ const realHome = realpathExistingPrefix(home);
189
+ // Fail closed first: a dangling protected home makes EVERY output home unsafe,
190
+ // and its absent target has no realpath-canonical spelling to compare against.
191
+ assertNoDanglingLiveHome(realHome);
192
+ // The materializer appends the harness config dir to outputHome, so the live
193
+ // home ROOT would write straight into ~/.claude etc.
194
+ if (canonical === realHome) {
195
+ throw new MaterializeGuardError('Path escape: output home must not be the live home directory');
196
+ }
197
+ for (const { harness, live } of liveHarnessHomes(realHome)) {
198
+ if (canonical === live || canonical.startsWith(live + path.sep)) {
199
+ throw new MaterializeGuardError(`Path escape: output home must not target the live .${harness} directory`);
200
+ }
201
+ }
202
+ return resolved;
203
+ }
@@ -1,3 +1,12 @@
1
+ /**
2
+ * Canonicalize `target` by `realpath`-resolving its longest EXISTING ancestor and
3
+ * re-appending the not-yet-created tail. A plain `realpathSync(target)` throws when
4
+ * `target` (a fresh output dir, a not-yet-written file) does not exist — but any
5
+ * symlink in the part that DOES exist (the target itself, or an ancestor) is
6
+ * exactly the escape hatch a containment check must resolve before comparing.
7
+ * Unlike `path.resolve` (which only normalizes `..`), this follows links.
8
+ */
9
+ export declare function realpathExistingPrefix(target: string): string;
1
10
  /**
2
11
  * True when `name` is a safe single path segment: non-empty, not '.'/'..',
3
12
  * free of path separators and null bytes, and within the filename length limit.
package/dist/lib/paths.js CHANGED
@@ -1,4 +1,30 @@
1
+ import * as fs from 'fs';
1
2
  import * as path from 'path';
3
+ /**
4
+ * Canonicalize `target` by `realpath`-resolving its longest EXISTING ancestor and
5
+ * re-appending the not-yet-created tail. A plain `realpathSync(target)` throws when
6
+ * `target` (a fresh output dir, a not-yet-written file) does not exist — but any
7
+ * symlink in the part that DOES exist (the target itself, or an ancestor) is
8
+ * exactly the escape hatch a containment check must resolve before comparing.
9
+ * Unlike `path.resolve` (which only normalizes `..`), this follows links.
10
+ */
11
+ export function realpathExistingPrefix(target) {
12
+ let current = path.resolve(target);
13
+ const tail = [];
14
+ for (;;) {
15
+ try {
16
+ const real = fs.realpathSync(current);
17
+ return tail.length ? path.join(real, ...tail.reverse()) : real;
18
+ }
19
+ catch {
20
+ const parent = path.dirname(current);
21
+ if (parent === current)
22
+ return path.resolve(target); // nothing on this path exists
23
+ tail.push(path.basename(current));
24
+ current = parent;
25
+ }
26
+ }
27
+ }
2
28
  /**
3
29
  * True when `name` is a safe single path segment: non-empty, not '.'/'..',
4
30
  * free of path separators and null bytes, and within the filename length limit.
@@ -7,7 +7,11 @@ export declare function projectAgentRoot(projectRoot: string, agent: AgentId): s
7
7
  export declare function syncProjectResourcesToAgent(agent: AgentId, version: string, projectAgentsDir: string): ProjectResourceSyncResult;
8
8
  /**
9
9
  * Turn the manifest's managed paths (relative to agentRoot) into anchored,
10
- * POSIX, projectRoot-relative `.gitignore` entries. Two guards keep it honest:
10
+ * POSIX, `referenceRoot`-relative ignore entries. `referenceRoot` is the
11
+ * directory the anchored `/…` patterns resolve against — the git worktree root
12
+ * for a `.git/info/exclude` block, since git anchors info/exclude patterns at
13
+ * the top of the working tree (not at the harness dir). Two guards keep it
14
+ * honest:
11
15
  * - drop any path that escapes the harness config dir (e.g. grok writes
12
16
  * commands back into the tracked `.agents/` tree via a `../` subdir —
13
17
  * ignoring that would hide tracked source; separate bug, PHNX-3718);
@@ -16,7 +20,7 @@ export declare function syncProjectResourcesToAgent(agent: AgentId, version: str
16
20
  * these never masks a hand-authored or committed file (e.g. a repo that
17
21
  * commits its own `.claude/CLAUDE.md` keeps it — it is not in the manifest).
18
22
  */
19
- export declare function managedGitignoreEntries(agentRoot: string, projectRoot: string, managed: string[]): string[];
23
+ export declare function managedGitignoreEntries(agentRoot: string, referenceRoot: string, managed: string[]): string[];
20
24
  /**
21
25
  * One human line for the files a project sync left alone because you already
22
26
  * wrote them. This is the normal steady state — every sync of a project whose