@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.
- package/CHANGELOG.md +73 -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.d.ts +50 -1
- package/dist/commands/sessions.js +85 -10
- 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/cloud/rush.d.ts +1 -1
- package/dist/lib/cloud/rush.js +2 -2
- 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.d.ts +6 -2
- package/dist/lib/project-resources.js +133 -44
- package/dist/lib/rush-session.d.ts +10 -2
- package/dist/lib/rush-session.js +12 -3
- package/dist/lib/secrets/drivers/rush.js +1 -1
- 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/cloud.js +1 -1
- package/dist/lib/session/db.d.ts +60 -1
- package/dist/lib/session/db.js +191 -6
- package/dist/lib/session/live-metadata.d.ts +24 -0
- package/dist/lib/session/live-metadata.js +55 -0
- 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
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Fleet
|
|
2
|
+
* Fleet shared-state sync as a `PeriodicService` (PHNX-3392 usage-sync,
|
|
3
|
+
* PHNX-3792 session mirror).
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
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.
|
|
@@ -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,
|
|
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,
|
|
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
|