@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
|
@@ -18,6 +18,7 @@ import { sanitizeForTerminal, redactSecrets } from '../lib/redact.js';
|
|
|
18
18
|
import { resolveProjectKey } from '../lib/project-key.js';
|
|
19
19
|
import { listProjectDefs, resolveProjectNameForCwd } from '../lib/projects.js';
|
|
20
20
|
import ora from 'ora';
|
|
21
|
+
import { interruptibleSpinner } from '../lib/spinner.js';
|
|
21
22
|
import { SESSION_AGENTS, isAgentTmuxAlias, sessionDisplayAgent } from '../lib/session/types.js';
|
|
22
23
|
import { discoverArtifacts, readArtifact, resolveArtifact } from '../lib/session/artifacts.js';
|
|
23
24
|
import { looksLikePath, toComparablePath, homeDir, needsWindowsShell, composeWin32CommandLine } from '../lib/platform/index.js';
|
|
@@ -28,14 +29,14 @@ import { mapPanesToTargets, listClients } from '../lib/tmux/session.js';
|
|
|
28
29
|
import { resolveViewingIn, viewingInLabel } from '../lib/session/viewing-in.js';
|
|
29
30
|
import { machineId, normalizeHost } from '../lib/session/sync/config.js';
|
|
30
31
|
import { gatherRemoteActive, NO_FANOUT_ENV } from '../lib/session/remote-active.js';
|
|
31
|
-
import { loadFleetActiveSessions, loadLocalActiveSessions, } from '../lib/session/session-cache.js';
|
|
32
|
+
import { loadFleetActiveSessions, loadLocalActiveSessions, readActiveSessionsCache, } from '../lib/session/session-cache.js';
|
|
32
33
|
import { gatherRemoteList, gatherRemoteToolProgramCounts, gatherRemoteToolSearch, runOnPeer } from '../lib/session/remote-list.js';
|
|
33
34
|
import { gatherRemoteAgentsJson } from '../lib/remote-agents-json.js';
|
|
34
35
|
import { stringWidth, truncateToWidth, padToWidth, terminalWidth } from '../lib/session/width.js';
|
|
35
36
|
import { inferSessionState } from '../lib/session/state.js';
|
|
36
37
|
import { discoverSessions, queryIndexedSessions, countSessionsInScope, resolveSessionById, isCompleteSessionId, looksLikeSessionId, searchContentIndex, getSessionRoots, scopeToManaged } from '../lib/session/discover.js';
|
|
37
38
|
import { findSessionsById, querySessions, readSessionContent, readArchivedSessionPreview } from '../lib/session/db.js';
|
|
38
|
-
import { liveSessionMetas } from '../lib/session/live-metadata.js';
|
|
39
|
+
import { liveSessionMetas, fleetExecutionMachineById, reconcileLiveMetaMachine } from '../lib/session/live-metadata.js';
|
|
39
40
|
import { filterTeamSessions, shouldShowTeamSessions, safeTeamText, groupSessionsByTeam, NO_TEAM_GROUP_KEY, } from '../lib/session/team-filter.js';
|
|
40
41
|
import { parseSession } from '../lib/session/parse.js';
|
|
41
42
|
import { runRemoteSessions, buildForwardedArgs, ensureWholeIndex } from '../lib/session/remote.js';
|
|
@@ -2801,7 +2802,7 @@ limitSource) {
|
|
|
2801
2802
|
const forwarded = ensureWholeIndex(buildForwardedArgs(process.argv, new Set(options.host ?? [])));
|
|
2802
2803
|
if (!forwarded.includes('--json'))
|
|
2803
2804
|
forwarded.push('--json');
|
|
2804
|
-
const fanSpinner = isInteractiveTerminal() ?
|
|
2805
|
+
const fanSpinner = isInteractiveTerminal() ? interruptibleSpinner('Reaching other machines...').start() : null;
|
|
2805
2806
|
try {
|
|
2806
2807
|
const { sessions: remoteSessions } = await gatherRemoteList(forwarded, undefined);
|
|
2807
2808
|
if (remoteSessions.length > 0) {
|
|
@@ -2841,7 +2842,7 @@ limitSource) {
|
|
|
2841
2842
|
const forwarded = buildForwardedArgs(process.argv, new Set(options.host ?? []));
|
|
2842
2843
|
if (!forwarded.includes('--json'))
|
|
2843
2844
|
forwarded.push('--json');
|
|
2844
|
-
const fanSpinner = isInteractiveTerminal() ?
|
|
2845
|
+
const fanSpinner = isInteractiveTerminal() ? interruptibleSpinner('Reaching other machines...').start() : null;
|
|
2845
2846
|
try {
|
|
2846
2847
|
const { sessions: remoteSessions } = await gatherRemoteList(forwarded, options.host);
|
|
2847
2848
|
if (remoteSessions.length > 0) {
|
|
@@ -4105,7 +4106,7 @@ async function runCloudSessions(query, options) {
|
|
|
4105
4106
|
process.exit(1);
|
|
4106
4107
|
}
|
|
4107
4108
|
const mode = resolveViewMode(options, filterOpts);
|
|
4108
|
-
const spinner = options.json ? null :
|
|
4109
|
+
const spinner = options.json ? null : interruptibleSpinner('Loading cloud sessions...').start();
|
|
4109
4110
|
let sessions;
|
|
4110
4111
|
try {
|
|
4111
4112
|
sessions = await discoverCloudSessions({ limit: parseInt(options.limit || '50', 10) });
|
|
@@ -4141,7 +4142,7 @@ async function runCloudSessions(query, options) {
|
|
|
4141
4142
|
process.exit(1);
|
|
4142
4143
|
}
|
|
4143
4144
|
const meta = matches[0];
|
|
4144
|
-
const cachedSpinner = options.json ? null :
|
|
4145
|
+
const cachedSpinner = options.json ? null : interruptibleSpinner('Fetching session...').start();
|
|
4145
4146
|
let cachedPath;
|
|
4146
4147
|
try {
|
|
4147
4148
|
cachedPath = await ensureCloudSessionCached(meta.id);
|
|
@@ -4843,6 +4844,59 @@ export function metadataResolveOutcome(localMatches, remote, selector) {
|
|
|
4843
4844
|
return { kind: 'ambiguous', candidates };
|
|
4844
4845
|
return { kind: 'resolved', session: candidates[0].hits[0].session };
|
|
4845
4846
|
}
|
|
4847
|
+
/**
|
|
4848
|
+
* Whether a local candidate answers a full-UUID selector **definitively** — i.e.
|
|
4849
|
+
* whether finding it on this box is the whole answer, so the fleet fan-out can be
|
|
4850
|
+
* skipped (PHNX-3890).
|
|
4851
|
+
*
|
|
4852
|
+
* Two shapes qualify, and both are claims this box can actually back:
|
|
4853
|
+
*
|
|
4854
|
+
* - **A real transcript on this disk** (`filePath`). It renders locally, whoever
|
|
4855
|
+
* owns it — a local session or a fleet-synced mirror — so no SSH hop is needed.
|
|
4856
|
+
* - **A genuine peer attribution** (`machine` names another box). The row already
|
|
4857
|
+
* routes the read to its owner through `transcriptOnPeerOf`, so the peer that
|
|
4858
|
+
* would answer the fan-out is the peer the render already dials.
|
|
4859
|
+
*
|
|
4860
|
+
* What does NOT qualify is the launcher-shim shape: a transcript-less live row
|
|
4861
|
+
* whose `machine` is this box only because it DEFAULTED there
|
|
4862
|
+
* (`activeSessionToSessionMeta`'s `active.machine ?? self`,
|
|
4863
|
+
* `computeLocalMetadataMatches`'s `machine || localMachine`). A dispatcher holds
|
|
4864
|
+
* exactly that row for a session whose agent and transcript live on a peer: the
|
|
4865
|
+
* launch process is here, the conversation is not. Treating it as definitive is
|
|
4866
|
+
* what dead-ended `agents sessions preview <full-uuid>` on the local
|
|
4867
|
+
* "Live session — full transcript not indexed here." stub while a passive peer —
|
|
4868
|
+
* which has no local row at all, so it fans out — rendered the real digest.
|
|
4869
|
+
* Process locality is not transcript locality.
|
|
4870
|
+
*/
|
|
4871
|
+
export function isLocallyDefinitiveMatch(session, self) {
|
|
4872
|
+
if (session.filePath)
|
|
4873
|
+
return true;
|
|
4874
|
+
return !!session.machine && session.machine !== self;
|
|
4875
|
+
}
|
|
4876
|
+
/**
|
|
4877
|
+
* Drop the launcher-shim local rows that a peer has since answered for
|
|
4878
|
+
* (PHNX-3890). `fleetCandidatesByQuery` groups a logical session's copies per
|
|
4879
|
+
* machine and every consumer reads `hits[0]`, with local rows passed first — so
|
|
4880
|
+
* simply fanning out is not enough: the self-defaulted shim would still win the
|
|
4881
|
+
* attribution and route the read back to a box with no transcript. When the peer
|
|
4882
|
+
* that actually owns the session has answered the sweep, its row is the strictly
|
|
4883
|
+
* better one, and the shim carries no information the candidate loses (same
|
|
4884
|
+
* logical id, so the candidate — and any ambiguity it is part of — survives
|
|
4885
|
+
* through the remote hit).
|
|
4886
|
+
*
|
|
4887
|
+
* A row that is {@link isLocallyDefinitiveMatch} is never dropped, so a locally
|
|
4888
|
+
* readable transcript or a synced mirror still renders here with no SSH hop. When
|
|
4889
|
+
* no peer answered for that id, the shim is kept: with no fleet evidence, a
|
|
4890
|
+
* transcript-less self-attributed row is indistinguishable from a session THIS box
|
|
4891
|
+
* just started, and dropping it would re-break the cold-index lookup RUSH-2682
|
|
4892
|
+
* fixed.
|
|
4893
|
+
*/
|
|
4894
|
+
export function preferOwnerAttribution(localMatches, remoteSessions, self) {
|
|
4895
|
+
if (remoteSessions.length === 0)
|
|
4896
|
+
return localMatches;
|
|
4897
|
+
const answeredByPeer = new Set(remoteSessions.map(session => session.id.toLowerCase()));
|
|
4898
|
+
return localMatches.filter(session => isLocallyDefinitiveMatch(session, self) || !answeredByPeer.has(session.id.toLowerCase()));
|
|
4899
|
+
}
|
|
4846
4900
|
/**
|
|
4847
4901
|
* Local metadata candidates for a selector: the indexed SQLite rows, plus — when
|
|
4848
4902
|
* an id-shaped selector misses the index entirely — the live-session registry
|
|
@@ -4880,7 +4934,20 @@ export async function computeLocalMetadataMatches(selector, scope, deps = {}) {
|
|
|
4880
4934
|
*/
|
|
4881
4935
|
export async function liveMetadataMatches(selector, scope, self, deps = {}) {
|
|
4882
4936
|
const load = deps.loadActive ?? loadLocalActiveSessions;
|
|
4883
|
-
const
|
|
4937
|
+
const loadFleet = deps.loadFleetActive ?? (() => readActiveSessionsCache('fleet')?.sessions ?? []);
|
|
4938
|
+
// A launcher-shim row whose `machine` self-defaulted to this box gets its true
|
|
4939
|
+
// EXECUTION host from the fleet snapshot, so a session dispatched to a peer is
|
|
4940
|
+
// read on that peer instead of dead-ending on the local stub (PHNX-3890). The
|
|
4941
|
+
// read is best-effort: an absent/cold snapshot leaves the row untouched and the
|
|
4942
|
+
// fleet fan-out in resolveSessionMetadataValue supplies the owner instead.
|
|
4943
|
+
let fleetExecMachine;
|
|
4944
|
+
try {
|
|
4945
|
+
fleetExecMachine = fleetExecutionMachineById(loadFleet());
|
|
4946
|
+
}
|
|
4947
|
+
catch {
|
|
4948
|
+
fleetExecMachine = new Map();
|
|
4949
|
+
}
|
|
4950
|
+
const match = (metas) => resolveIndexedMetadataRows(applyScopeFilters(reconcileLiveMetaMachine(metas, fleetExecMachine, self), scope), selector, scope);
|
|
4884
4951
|
try {
|
|
4885
4952
|
const cached = liveSessionMetas((await load()).sessions, self, Date.now());
|
|
4886
4953
|
const hit = match(cached);
|
|
@@ -4897,16 +4964,24 @@ export async function liveMetadataMatches(selector, scope, self, deps = {}) {
|
|
|
4897
4964
|
* Full UUIDs hit the local SQLite index without any SSH fan-out. */
|
|
4898
4965
|
export async function resolveSessionMetadataValue(selector, scope = {}, deps = { gatherRemoteList }) {
|
|
4899
4966
|
const localMatches = await computeLocalMetadataMatches(selector, scope, deps);
|
|
4967
|
+
const localMachine = machineId();
|
|
4900
4968
|
// A full-UUID local hit resolves with ZERO SSH: a UUID is globally unique, so
|
|
4901
4969
|
// finding it locally is the whole answer and no peer is dialed. (A label is
|
|
4902
4970
|
// NOT resolved local-only here — it can collide with a same-label session on a
|
|
4903
4971
|
// peer, so it must consult the fleet; see isDefinitiveMatch, RUSH-2203.) The
|
|
4904
4972
|
// local index lookup is synchronous and completes before any fan-out spawns,
|
|
4905
4973
|
// so this local hit never waits on a peer.
|
|
4974
|
+
//
|
|
4975
|
+
// "Found it locally" must mean this box can actually ANSWER for it, though —
|
|
4976
|
+
// see isLocallyDefinitiveMatch. A transcript-less launcher shim for a session
|
|
4977
|
+
// executing on a peer is a local row that is not a local answer, and
|
|
4978
|
+
// short-circuiting on it skipped the one fan-out that could have named the
|
|
4979
|
+
// owner (PHNX-3890).
|
|
4906
4980
|
if (FULL_SESSION_ID_RE.test(selector)) {
|
|
4907
4981
|
const localOutcome = metadataResolveOutcome(localMatches, { sessions: [], unreachable: [] }, selector);
|
|
4908
|
-
if (localOutcome.kind === 'resolved')
|
|
4982
|
+
if (localOutcome.kind === 'resolved' && isLocallyDefinitiveMatch(localOutcome.session, localMachine)) {
|
|
4909
4983
|
return localOutcome;
|
|
4984
|
+
}
|
|
4910
4985
|
}
|
|
4911
4986
|
if (scope.local === true)
|
|
4912
4987
|
return metadataResolveOutcome(localMatches, { sessions: [], unreachable: [] }, selector);
|
|
@@ -4919,7 +4994,7 @@ export async function resolveSessionMetadataValue(selector, scope = {}, deps = {
|
|
|
4919
4994
|
const remote = await deps.gatherRemoteList(forwarded, scope.hosts, selectorAllowsEarlyExit(selector)
|
|
4920
4995
|
? { isDefinitive: (session) => isDefinitiveMatch(session, selector) }
|
|
4921
4996
|
: undefined);
|
|
4922
|
-
return metadataResolveOutcome(localMatches, remote, selector);
|
|
4997
|
+
return metadataResolveOutcome(preferOwnerAttribution(localMatches, remote.sessions, localMachine), remote, selector);
|
|
4923
4998
|
}
|
|
4924
4999
|
catch (error) {
|
|
4925
5000
|
return metadataResolveOutcome(localMatches, { sessions: [], unreachable: [error?.message ?? 'fleet fan-out'] }, selector);
|
|
@@ -4972,7 +5047,7 @@ export async function resolveSessionMetadata(selector, scope, deps = { gatherRem
|
|
|
4972
5047
|
process.stdout.write(serializeResolvedSessionsJson([outcome.session]));
|
|
4973
5048
|
}
|
|
4974
5049
|
export async function resolveSessionAcrossFleet(query, mode, hosts, deps = { gatherRemoteList, runOnPeer }) {
|
|
4975
|
-
const spinner = isInteractiveTerminal() ?
|
|
5050
|
+
const spinner = isInteractiveTerminal() ? interruptibleSpinner('Searching the fleet...').start() : null;
|
|
4976
5051
|
let candidates = [];
|
|
4977
5052
|
let deviceCount = 0;
|
|
4978
5053
|
let unreachable = [];
|
package/dist/lib/actor.d.ts
CHANGED
|
@@ -13,6 +13,13 @@ export interface ResolvedActor {
|
|
|
13
13
|
email?: string;
|
|
14
14
|
/** GitHub handle, when the actors map records one. */
|
|
15
15
|
github?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Phoenix (work) identity id for this human, when the actors map records one.
|
|
18
|
+
* Bridges a personal tailnet login (e.g. a personal gmail) to the stable
|
|
19
|
+
* internal work identity, so attribution survives whichever email a person
|
|
20
|
+
* happens to be signed into tailscale with.
|
|
21
|
+
*/
|
|
22
|
+
phoenixId?: string;
|
|
16
23
|
}
|
|
17
24
|
/** Result of `tailscale whois --json <ip>` we care about. */
|
|
18
25
|
export interface WhoisIdentity {
|
|
@@ -28,16 +35,41 @@ export interface WhoisIdentity {
|
|
|
28
35
|
*/
|
|
29
36
|
export declare function actorFromIdentity(who: WhoisIdentity | undefined, host: string, actors: Record<string, ActorConfig>): ResolvedActor;
|
|
30
37
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
38
|
+
* Injectable tailscale resolvers, so tests can drive the SSH-whois and
|
|
39
|
+
* local-self branches deterministically without a real tailscale on the box
|
|
40
|
+
* (a dev machine that *is* on the tailnet would otherwise make the local path
|
|
41
|
+
* non-deterministic). Production callers use the defaults.
|
|
34
42
|
*/
|
|
35
|
-
export
|
|
43
|
+
export interface ActorResolvers {
|
|
44
|
+
whois: (ip: string) => WhoisIdentity | undefined;
|
|
45
|
+
self: () => WhoisIdentity | undefined;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Compute the actor for a given environment. The only impurity is the tailscale
|
|
49
|
+
* shell-out (injectable via `resolvers`), so tests drive every branch explicitly.
|
|
50
|
+
*
|
|
51
|
+
* Resolution order: an inherited env actor wins; otherwise an SSH run whois-es
|
|
52
|
+
* its client IP; a local run (no SSH) credits the device's own tailnet owner;
|
|
53
|
+
* and anything unresolvable degrades to `UNRESOLVED@<host>`. Note the self
|
|
54
|
+
* fallback fires ONLY for a truly local run — an SSH run whose whois fails must
|
|
55
|
+
* NOT be credited to the box owner (that would misattribute a remote human to
|
|
56
|
+
* whoever owns the machine).
|
|
57
|
+
*/
|
|
58
|
+
export declare function computeActor(env?: NodeJS.ProcessEnv, resolvers?: ActorResolvers): ResolvedActor;
|
|
36
59
|
/**
|
|
37
60
|
* Resolve the actor for the current process, cached for the process lifetime
|
|
38
61
|
* (the SSH `whois` shell-out runs at most once).
|
|
39
62
|
*/
|
|
40
63
|
export declare function resolveActor(): ResolvedActor;
|
|
64
|
+
/**
|
|
65
|
+
* Test-only: pin the tailscale resolvers `resolveActor()` uses, so a test that
|
|
66
|
+
* exercises the cached production entrypoint (e.g. `withActorEnv()`) is isolated
|
|
67
|
+
* from whether the box running it is on the tailnet. `computeActor` already takes
|
|
68
|
+
* injected resolvers for its unit tests; this extends the same seam to the cached
|
|
69
|
+
* path. Pass `undefined` to restore the real tailscale resolvers. Resets the
|
|
70
|
+
* cache so the next `resolveActor()` recomputes under the new resolvers.
|
|
71
|
+
*/
|
|
72
|
+
export declare function setActorResolvers(resolvers: ActorResolvers | undefined): void;
|
|
41
73
|
/** Clear the per-process cache. For tests, and for env changes within a run. */
|
|
42
74
|
export declare function resetActorCache(): void;
|
|
43
75
|
/**
|
package/dist/lib/actor.js
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
*
|
|
8
8
|
* - Over SSH (the shared-fleet case): `tailscale whois` the SSH client IP to
|
|
9
9
|
* the connecting tailnet identity -- a real name + login email.
|
|
10
|
-
* - Locally (non-SSH):
|
|
11
|
-
*
|
|
10
|
+
* - Locally (non-SSH): the run belongs to whoever owns this device on the
|
|
11
|
+
* tailnet, so we read that owner from `tailscale status` (`.Self.UserID` ->
|
|
12
|
+
* `.User[]`). Only if tailscale can't name the device owner either do we fall
|
|
13
|
+
* back to the honest `UNRESOLVED@<host>` with no personal git identity claimed.
|
|
12
14
|
* - Inherited: a child spawn trusts the `AGENTS_ACTOR*` env its parent
|
|
13
15
|
* stamped rather than re-resolving, so the whole spawn tree shares one actor.
|
|
14
16
|
*
|
|
@@ -54,6 +56,38 @@ function tailscaleWhois(ip) {
|
|
|
54
56
|
return undefined;
|
|
55
57
|
}
|
|
56
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Resolve this device's own tailnet owner via `tailscale status --json`:
|
|
61
|
+
* `.Self.UserID` indexes into the `.User` map for the owner's login + display
|
|
62
|
+
* name. Used for a LOCAL run, where there is no SSH client to whois — the run
|
|
63
|
+
* belongs to whoever owns the box on the tailnet. Same graceful-undefined +
|
|
64
|
+
* timeout discipline as `tailscaleWhois`: tailscale absent, a wedged daemon, a
|
|
65
|
+
* tagged (owner-less) device, or a parse failure all yield undefined, never an
|
|
66
|
+
* error and never a hang on the spawn hot path.
|
|
67
|
+
*/
|
|
68
|
+
function tailscaleSelf() {
|
|
69
|
+
try {
|
|
70
|
+
const res = spawnSync('tailscale', ['status', '--json'], {
|
|
71
|
+
encoding: 'utf-8',
|
|
72
|
+
windowsHide: true,
|
|
73
|
+
timeout: WHOIS_TIMEOUT_MS,
|
|
74
|
+
});
|
|
75
|
+
if (res.status !== 0 || !res.stdout)
|
|
76
|
+
return undefined;
|
|
77
|
+
const data = JSON.parse(res.stdout);
|
|
78
|
+
const uid = data.Self?.UserID;
|
|
79
|
+
if (uid == null)
|
|
80
|
+
return undefined;
|
|
81
|
+
// The User map is keyed by the UserID rendered as a string.
|
|
82
|
+
const u = data.User?.[String(uid)];
|
|
83
|
+
if (!u?.LoginName)
|
|
84
|
+
return undefined;
|
|
85
|
+
return { login: u.LoginName, displayName: u.DisplayName };
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
57
91
|
/** Read the actors map from config, tolerant of a missing/unreadable config. */
|
|
58
92
|
function readActors() {
|
|
59
93
|
try {
|
|
@@ -96,6 +130,7 @@ export function actorFromIdentity(who, host, actors) {
|
|
|
96
130
|
name: cfg?.name ?? who?.displayName,
|
|
97
131
|
email: cfg?.email ?? emailFromLogin,
|
|
98
132
|
github: cfg?.github,
|
|
133
|
+
phoenixId: cfg?.phoenixId,
|
|
99
134
|
};
|
|
100
135
|
}
|
|
101
136
|
/** Reconstruct an actor an ancestor process already resolved into the env. */
|
|
@@ -109,31 +144,58 @@ function inheritedActor(env) {
|
|
|
109
144
|
name: env.AGENTS_ACTOR_NAME || undefined,
|
|
110
145
|
email: env.AGENTS_ACTOR_EMAIL || undefined,
|
|
111
146
|
github: env.AGENTS_ACTOR_GITHUB || undefined,
|
|
147
|
+
phoenixId: env.AGENTS_ACTOR_PHOENIX_ID || undefined,
|
|
112
148
|
};
|
|
113
149
|
}
|
|
150
|
+
const defaultResolvers = { whois: tailscaleWhois, self: tailscaleSelf };
|
|
114
151
|
/**
|
|
115
|
-
* Compute the actor for a given environment.
|
|
116
|
-
*
|
|
117
|
-
*
|
|
152
|
+
* Compute the actor for a given environment. The only impurity is the tailscale
|
|
153
|
+
* shell-out (injectable via `resolvers`), so tests drive every branch explicitly.
|
|
154
|
+
*
|
|
155
|
+
* Resolution order: an inherited env actor wins; otherwise an SSH run whois-es
|
|
156
|
+
* its client IP; a local run (no SSH) credits the device's own tailnet owner;
|
|
157
|
+
* and anything unresolvable degrades to `UNRESOLVED@<host>`. Note the self
|
|
158
|
+
* fallback fires ONLY for a truly local run — an SSH run whose whois fails must
|
|
159
|
+
* NOT be credited to the box owner (that would misattribute a remote human to
|
|
160
|
+
* whoever owns the machine).
|
|
118
161
|
*/
|
|
119
|
-
export function computeActor(env = process.env) {
|
|
162
|
+
export function computeActor(env = process.env, resolvers = defaultResolvers) {
|
|
120
163
|
const inherited = inheritedActor(env);
|
|
121
164
|
if (inherited)
|
|
122
165
|
return inherited;
|
|
123
|
-
const
|
|
124
|
-
const
|
|
166
|
+
const sshRaw = env.SSH_CONNECTION;
|
|
167
|
+
const ssh = sshRaw ? parseSshConnection(sshRaw) : undefined;
|
|
168
|
+
let who = ssh?.clientIp ? resolvers.whois(ssh.clientIp) : undefined;
|
|
169
|
+
// Self-credit only for a genuinely LOCAL run (no SSH_CONNECTION at all). An
|
|
170
|
+
// SSH session whose connection is unparseable or unresolvable stays
|
|
171
|
+
// UNRESOLVED rather than being misattributed to the box's owner.
|
|
172
|
+
if (!who && !sshRaw)
|
|
173
|
+
who = resolvers.self();
|
|
125
174
|
return actorFromIdentity(who, machineId(), readActors());
|
|
126
175
|
}
|
|
127
176
|
let cached;
|
|
177
|
+
let resolverOverride;
|
|
128
178
|
/**
|
|
129
179
|
* Resolve the actor for the current process, cached for the process lifetime
|
|
130
180
|
* (the SSH `whois` shell-out runs at most once).
|
|
131
181
|
*/
|
|
132
182
|
export function resolveActor() {
|
|
133
183
|
if (!cached)
|
|
134
|
-
cached = computeActor(process.env);
|
|
184
|
+
cached = computeActor(process.env, resolverOverride ?? defaultResolvers);
|
|
135
185
|
return cached;
|
|
136
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* Test-only: pin the tailscale resolvers `resolveActor()` uses, so a test that
|
|
189
|
+
* exercises the cached production entrypoint (e.g. `withActorEnv()`) is isolated
|
|
190
|
+
* from whether the box running it is on the tailnet. `computeActor` already takes
|
|
191
|
+
* injected resolvers for its unit tests; this extends the same seam to the cached
|
|
192
|
+
* path. Pass `undefined` to restore the real tailscale resolvers. Resets the
|
|
193
|
+
* cache so the next `resolveActor()` recomputes under the new resolvers.
|
|
194
|
+
*/
|
|
195
|
+
export function setActorResolvers(resolvers) {
|
|
196
|
+
resolverOverride = resolvers;
|
|
197
|
+
cached = undefined;
|
|
198
|
+
}
|
|
137
199
|
/** Clear the per-process cache. For tests, and for env changes within a run. */
|
|
138
200
|
export function resetActorCache() {
|
|
139
201
|
cached = undefined;
|
|
@@ -156,6 +218,8 @@ export function actorEnv(actor) {
|
|
|
156
218
|
env.AGENTS_ACTOR_EMAIL = actor.email;
|
|
157
219
|
if (actor.github)
|
|
158
220
|
env.AGENTS_ACTOR_GITHUB = actor.github;
|
|
221
|
+
if (actor.phoenixId)
|
|
222
|
+
env.AGENTS_ACTOR_PHOENIX_ID = actor.phoenixId;
|
|
159
223
|
if (actor.kind === 'human' && actor.name && actor.email) {
|
|
160
224
|
env.GIT_AUTHOR_NAME = actor.name;
|
|
161
225
|
env.GIT_AUTHOR_EMAIL = actor.email;
|
|
@@ -2,6 +2,10 @@ import type { AgentId } from '../types.js';
|
|
|
2
2
|
import type { AgentTarget, ResolveOptions, VersionFilter } from './types.js';
|
|
3
3
|
export * from './types.js';
|
|
4
4
|
export * from './primitives.js';
|
|
5
|
+
export * from './package-types.js';
|
|
6
|
+
export { parseAgentPackageManifest, loadAgentPackageManifest } from './package-schema.js';
|
|
7
|
+
export { resolveAgentPackage, effectiveResources } from './package-resolve.js';
|
|
8
|
+
export { materializeAgentPackage, sha256OfReceiptFile } from './materialize.js';
|
|
5
9
|
/** Shared `--help` epilog so every agent-spec command documents the same grammar. */
|
|
6
10
|
export declare const AGENT_SPEC_HELP: string;
|
|
7
11
|
/** Resolve a spec (single or comma-list) into concrete installed targets. */
|
|
@@ -10,6 +10,11 @@ import { defaultVersionProvider } from './provider.js';
|
|
|
10
10
|
import * as core from './resolve.js';
|
|
11
11
|
export * from './types.js';
|
|
12
12
|
export * from './primitives.js';
|
|
13
|
+
// Portable agent-package resolver + native-home materializer (PHNX-3838).
|
|
14
|
+
export * from './package-types.js';
|
|
15
|
+
export { parseAgentPackageManifest, loadAgentPackageManifest } from './package-schema.js';
|
|
16
|
+
export { resolveAgentPackage, effectiveResources } from './package-resolve.js';
|
|
17
|
+
export { materializeAgentPackage, sha256OfReceiptFile } from './materialize.js';
|
|
13
18
|
/** Shared `--help` epilog so every agent-spec command documents the same grammar. */
|
|
14
19
|
export const AGENT_SPEC_HELP = 'Agent spec: <agent>[@<qualifier>]. Qualifiers: ' +
|
|
15
20
|
'@latest (highest installed), @oldest (lowest installed), ' +
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { MaterializationReceipt, MaterializeOptions, ResolvedAgentPackage } from './package-types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Materialize `resolved` into a fresh native home for `options.harness`. Fails
|
|
4
|
+
* closed (throws `AgentPackageError`, writes nothing new) when the harness is
|
|
5
|
+
* not declared supported by the package, or when any effective resource needs
|
|
6
|
+
* a capability the harness+version does not have. Idempotent and
|
|
7
|
+
* deterministic: re-running against the same `outputHome` with the same
|
|
8
|
+
* inputs produces a byte-identical receipt and prunes any managed path this
|
|
9
|
+
* materializer previously wrote that the current resource set no longer needs.
|
|
10
|
+
*/
|
|
11
|
+
export declare function materializeAgentPackage(resolved: ResolvedAgentPackage, options: MaterializeOptions): MaterializationReceipt;
|
|
12
|
+
/** Lowercase hex sha256 of arbitrary bytes — exposed for callers that want to verify the receipt file's own digest (Factory's observed-digest record). */
|
|
13
|
+
export declare function sha256OfReceiptFile(receiptPath: string): string;
|