@phnx-labs/agents-cli 1.22.71 → 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 +45 -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/project-resources.js +14 -5
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/lib/paths.d.ts
CHANGED
|
@@ -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.
|
|
@@ -141,7 +141,9 @@ function applyManagedBlock(content, begin, end, entries) {
|
|
|
141
141
|
if (entries.length > 0) {
|
|
142
142
|
return [...lines.slice(0, bi), begin, ...entries, end, ...lines.slice(ei + 1)].join('\n');
|
|
143
143
|
}
|
|
144
|
-
// Prune the block, tidying the blank lines that hugged it.
|
|
144
|
+
// Prune the block, tidying the blank lines that hugged it. Vestigial on the
|
|
145
|
+
// reconcileProjectGitignore path (its entries always include the manifest,
|
|
146
|
+
// so entries.length is never 0 there) — kept for a direct caller/unit test.
|
|
145
147
|
const before = lines.slice(0, bi);
|
|
146
148
|
const after = lines.slice(ei + 1);
|
|
147
149
|
while (before.length && before[before.length - 1].trim() === '')
|
|
@@ -162,16 +164,23 @@ function applyManagedBlock(content, begin, end, entries) {
|
|
|
162
164
|
* generated per-harness resource dir never shows as untracked dirt. Idempotent
|
|
163
165
|
* and convergent: replaces the block in place and writes only when the content
|
|
164
166
|
* actually changes, so the launch hot path does not churn the file (or its
|
|
165
|
-
* watchers) every run — even in a project synced by several harnesses.
|
|
166
|
-
*
|
|
167
|
-
*
|
|
167
|
+
* watchers) every run — even in a project synced by several harnesses. When a
|
|
168
|
+
* sync clears a harness's resources the block does not vanish: it shrinks to the
|
|
169
|
+
* lone `.agents-managed.json` entry (that file still sits in the harness dir and
|
|
170
|
+
* must stay ignored), so the block is only ever fully pruned by hand, never via
|
|
171
|
+
* this call path. Never creates a `.gitignore` outside a git working tree.
|
|
168
172
|
*/
|
|
169
173
|
function reconcileProjectGitignore(projectRoot, agent, agentRoot, managed) {
|
|
170
174
|
const gitignorePath = path.join(projectRoot, '.gitignore');
|
|
171
175
|
if (!isInsideGitRepo(projectRoot) && !pathExists(gitignorePath))
|
|
172
176
|
return;
|
|
173
177
|
const { begin, end } = gitignoreMarkers(agent);
|
|
174
|
-
|
|
178
|
+
// Ignore the manifest marker file too, not just the synced resources: the
|
|
179
|
+
// sync always writes `<agentRoot>/.agents-managed.json`, so without this the
|
|
180
|
+
// harness dir still shows as untracked in `git status` on the strength of that
|
|
181
|
+
// one file (defeating the whole point). It lives at agentRoot, so it resolves
|
|
182
|
+
// through the same anchoring + escape guard as any managed path.
|
|
183
|
+
const entries = managedGitignoreEntries(agentRoot, projectRoot, [MANIFEST_FILE, ...managed]);
|
|
175
184
|
let original = '';
|
|
176
185
|
try {
|
|
177
186
|
original = fs.readFileSync(gitignorePath, 'utf-8');
|
|
@@ -402,6 +402,18 @@ export interface ActiveSession {
|
|
|
402
402
|
* did not inherit a terminal id.
|
|
403
403
|
*/
|
|
404
404
|
terminalId?: string;
|
|
405
|
+
/**
|
|
406
|
+
* The launch id (`AGENT_LAUNCH_ID`) the CLI stamps on every agent at spawn — a
|
|
407
|
+
* stable UUID that is identical locally and across an SSH hop and survives a
|
|
408
|
+
* session-id rotation (`/clear`, exit-and-rerun). Unlike `sessionId` (which the
|
|
409
|
+
* non-Claude harnesses only mint after boot) it exists from the first tick, so
|
|
410
|
+
* it is the join key a client uses to re-identify a session on the watch stream.
|
|
411
|
+
* Populated wherever the by-pid launch registry resolves — reliably for
|
|
412
|
+
* `agents run`-launched processes; still absent for editor-launched terminals
|
|
413
|
+
* whose shell pid does not line up with the agent-pid registry today (the same
|
|
414
|
+
* limitation the `readPidSessionEntry` call in `listTerminalsActive` documents).
|
|
415
|
+
*/
|
|
416
|
+
launchId?: string;
|
|
405
417
|
/**
|
|
406
418
|
* tmux pane id (`%N`) when this row was discovered via the tmux source AND its
|
|
407
419
|
* session id could not be resolved (a born-unidentifiable non-Claude pane). It
|
|
@@ -1059,6 +1059,8 @@ export async function listTerminalsActive() {
|
|
|
1059
1059
|
tty: procByPid.get(t.pid)?.tty,
|
|
1060
1060
|
pid: t.pid,
|
|
1061
1061
|
sessionId: t.sessionId ?? sessionIdFromFile(sessionFile),
|
|
1062
|
+
launchId: pidEntry?.launchId,
|
|
1063
|
+
terminalId: pidEntry?.terminalId,
|
|
1062
1064
|
cwd: t.cwd ?? undefined,
|
|
1063
1065
|
label,
|
|
1064
1066
|
name,
|
|
@@ -1558,7 +1560,8 @@ async function listUnattributedActiveLive(attributed) {
|
|
|
1558
1560
|
lastActivityMs: mtimeMs,
|
|
1559
1561
|
pidCount: 1 + (foldedByRoot.get(pid) ?? 0),
|
|
1560
1562
|
owner: resolveOwner(entry?.actor, resolvedId),
|
|
1561
|
-
|
|
1563
|
+
launchId: entry?.launchId ?? hookRec?.launch_id,
|
|
1564
|
+
terminalId: entry?.terminalId ?? hookRec?.terminal_id,
|
|
1562
1565
|
}, state, sessionFile, true));
|
|
1563
1566
|
}
|
|
1564
1567
|
// Housekeeping: drop registry files for pids that have since died.
|
|
@@ -1773,6 +1776,7 @@ export async function listTmuxAgentSessions() {
|
|
|
1773
1776
|
// Factory / --active join key: AGENT_TERMINAL_ID stamped on the launch
|
|
1774
1777
|
// registry and preserved by SessionStart. Without this, Grok/Codex tmux
|
|
1775
1778
|
// panes never surface terminalId even when by-pid has it (RUSH-2192).
|
|
1779
|
+
launchId: liveEntry?.launchId,
|
|
1776
1780
|
terminalId: liveEntry?.terminalId,
|
|
1777
1781
|
// An id-less pane keys its dedupe on the unique pane, so two anonymous
|
|
1778
1782
|
// co-located panes stay two rows instead of folding into one.
|
|
@@ -5,6 +5,13 @@ export interface SessionActorRecord {
|
|
|
5
5
|
actor?: string;
|
|
6
6
|
/** Actor kind (`resolveActor().kind`). */
|
|
7
7
|
initiatedBy?: 'human' | 'agent';
|
|
8
|
+
/**
|
|
9
|
+
* Phoenix id of the responsible actor (`resolveActor().phoenixId`) — the
|
|
10
|
+
* tailnet human's stable Phoenix identity, resolved from the `actors:` map at
|
|
11
|
+
* spawn (PHNX-3798). Pairs with {@link actor}: joined onto the session index at
|
|
12
|
+
* scan time so a durable listing can surface the Phoenix id, not just the email.
|
|
13
|
+
*/
|
|
14
|
+
phoenixId?: string;
|
|
8
15
|
/** Effective permissions mode used by the launcher. */
|
|
9
16
|
mode?: SessionRunMode;
|
|
10
17
|
/**
|
|
@@ -39,6 +39,7 @@ function isSafeAlias(alias) {
|
|
|
39
39
|
}
|
|
40
40
|
function hasRecordData(record) {
|
|
41
41
|
return typeof record.actor === 'string'
|
|
42
|
+
|| typeof record.phoenixId === 'string'
|
|
42
43
|
|| typeof record.mode === 'string'
|
|
43
44
|
|| typeof record.version === 'string'
|
|
44
45
|
|| typeof record.harness === 'string'
|
|
@@ -84,6 +85,7 @@ export function writeSessionAliasRecord(sessionId, alias) {
|
|
|
84
85
|
sessionId,
|
|
85
86
|
actor: previous?.actor,
|
|
86
87
|
initiatedBy: previous?.initiatedBy,
|
|
88
|
+
phoenixId: previous?.phoenixId,
|
|
87
89
|
mode: previous?.mode,
|
|
88
90
|
version: previous?.version,
|
|
89
91
|
harness: previous?.harness,
|