@phnx-labs/agents-cli 1.20.68 → 1.20.70
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 +66 -0
- package/README.md +30 -7
- package/dist/bin/agents +0 -0
- package/dist/commands/computer.d.ts +26 -0
- package/dist/commands/computer.js +173 -149
- package/dist/commands/doctor.js +5 -0
- package/dist/commands/exec.d.ts +18 -0
- package/dist/commands/exec.js +88 -6
- package/dist/commands/fork.d.ts +9 -0
- package/dist/commands/fork.js +67 -0
- package/dist/commands/run-account-picker.d.ts +14 -0
- package/dist/commands/run-account-picker.js +131 -0
- package/dist/commands/setup-browser.d.ts +18 -0
- package/dist/commands/setup-browser.js +142 -0
- package/dist/commands/setup-computer.d.ts +19 -0
- package/dist/commands/setup-computer.js +133 -0
- package/dist/commands/setup-share.d.ts +17 -0
- package/dist/commands/setup-share.js +85 -0
- package/dist/commands/setup.d.ts +1 -1
- package/dist/commands/setup.js +58 -1
- package/dist/commands/share.d.ts +15 -0
- package/dist/commands/share.js +10 -4
- package/dist/commands/ssh.js +202 -9
- package/dist/commands/view.d.ts +4 -14
- package/dist/commands/view.js +32 -30
- package/dist/index.js +2 -1
- package/dist/lib/agents.d.ts +10 -0
- package/dist/lib/agents.js +32 -0
- package/dist/lib/auth-health.d.ts +141 -0
- package/dist/lib/auth-health.js +277 -0
- package/dist/lib/browser/chrome.d.ts +9 -0
- package/dist/lib/browser/chrome.js +22 -0
- package/dist/lib/computer/download.d.ts +54 -0
- package/dist/lib/computer/download.js +146 -0
- package/dist/lib/computer-rpc.js +9 -3
- package/dist/lib/daemon.js +38 -0
- package/dist/lib/devices/connect.d.ts +15 -0
- package/dist/lib/devices/connect.js +17 -5
- package/dist/lib/devices/health-report.d.ts +11 -0
- package/dist/lib/devices/health-report.js +73 -4
- package/dist/lib/devices/stats-cache.d.ts +36 -0
- package/dist/lib/devices/stats-cache.js +115 -0
- package/dist/lib/devices/terminfo.d.ts +44 -0
- package/dist/lib/devices/terminfo.js +167 -0
- package/dist/lib/rotate.d.ts +12 -8
- package/dist/lib/rotate.js +22 -23
- package/dist/lib/session/fork.d.ts +32 -0
- package/dist/lib/session/fork.js +101 -0
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/usage.d.ts +23 -0
- package/dist/lib/usage.js +84 -0
- package/package.json +1 -1
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { type AccountInfo } from './agents.js';
|
|
2
|
+
import type { AgentId } from './types.js';
|
|
3
|
+
import { type ProviderProbe } from './usage.js';
|
|
4
|
+
/**
|
|
5
|
+
* - `live` — completed an authenticated request (200).
|
|
6
|
+
* - `revoked` — the server rejected the token (401/403).
|
|
7
|
+
* - `expired` — locally-detected expiry; not network-verified (no refresh on the read path).
|
|
8
|
+
* - `rate_limited`— token works but is throttled right now (429).
|
|
9
|
+
* - `unverified` — credential present, not locally expired, but this agent has no in-repo probe endpoint (codex/grok).
|
|
10
|
+
* - `unconfigured`— no usable credential on disk.
|
|
11
|
+
* - `error` — network/other failure; verdict indeterminate (keep the last known one).
|
|
12
|
+
*/
|
|
13
|
+
export type AuthVerdict = 'live' | 'revoked' | 'expired' | 'rate_limited' | 'unverified' | 'unconfigured' | 'error';
|
|
14
|
+
export interface AuthHealth {
|
|
15
|
+
verdict: AuthVerdict;
|
|
16
|
+
/** epoch ms of the probe. */
|
|
17
|
+
checkedAt: number;
|
|
18
|
+
/** optional short human detail (e.g. "HTTP 401", a network error). */
|
|
19
|
+
detail?: string;
|
|
20
|
+
/** account label for display (email / id), when known. Never part of the key. */
|
|
21
|
+
account?: string;
|
|
22
|
+
}
|
|
23
|
+
/** Agents with a live network probe wired up today. The rest are best-effort. */
|
|
24
|
+
export declare const LIVE_PROBE_AGENTS: ReadonlySet<AgentId>;
|
|
25
|
+
/** Map an HTTP status from a live probe to a verdict. */
|
|
26
|
+
export declare function classifyHttpStatus(status: number): AuthVerdict;
|
|
27
|
+
/** Turn a raw provider probe (from usage.ts) into a verdict. */
|
|
28
|
+
export declare function verdictFromProbe(probe: ProviderProbe): AuthVerdict;
|
|
29
|
+
/** A short human detail line for a probe result (rendered under --verbose). */
|
|
30
|
+
export declare function probeDetail(probe: ProviderProbe): string | undefined;
|
|
31
|
+
/** Uncolored glyph for a verdict (color is applied by the caller). */
|
|
32
|
+
export declare function verdictGlyph(verdict: AuthVerdict): string;
|
|
33
|
+
/** One-word label for matrices/verbose output. */
|
|
34
|
+
export declare function verdictLabel(verdict: AuthVerdict): string;
|
|
35
|
+
/** Roll a set of verdicts (one host×agent's installs) into counts for a matrix cell. */
|
|
36
|
+
export interface VerdictSummary {
|
|
37
|
+
live: number;
|
|
38
|
+
/** revoked — the server rejected the token (401/403). Genuinely needs re-login. */
|
|
39
|
+
bad: number;
|
|
40
|
+
/**
|
|
41
|
+
* expired / rate_limited / unverified / error — degraded or unknown, but NOT
|
|
42
|
+
* "re-login now". `expired` is soft for kimi/droid (their CLIs refresh the
|
|
43
|
+
* token on next launch; we don't refresh on the read path), so it must not be
|
|
44
|
+
* lumped with revoked or we'd cry wolf on a self-healing token.
|
|
45
|
+
*/
|
|
46
|
+
warn: number;
|
|
47
|
+
total: number;
|
|
48
|
+
}
|
|
49
|
+
export declare function summarizeVerdicts(verdicts: AuthVerdict[]): VerdictSummary;
|
|
50
|
+
/** Verdicts that mean "this token was rejected by the server — re-login required". */
|
|
51
|
+
export declare function isDeadVerdict(verdict: AuthVerdict): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* A host's rolled-up auth state for the `fleet status` Auth column.
|
|
54
|
+
*
|
|
55
|
+
* The four display buckets are deliberately finer-grained than
|
|
56
|
+
* {@link VerdictSummary}'s live/bad/warn: they separate "present but this agent
|
|
57
|
+
* has no live probe" (`unverified`) and "soft, self-healing expiry"
|
|
58
|
+
* (`expired`/`rate_limited`) from a genuine server rejection (`revoked`). The
|
|
59
|
+
* old three-bucket rollup lumped all of those into `warn` and the column painted
|
|
60
|
+
* them one alarming yellow — so a fleet of perfectly logged-in accounts on
|
|
61
|
+
* codex/grok/etc (which can NEVER be probed live) read as half-degraded. These
|
|
62
|
+
* buckets let the renderer show `unverified` as neutral and reserve red for the
|
|
63
|
+
* only verdict that actually means "re-login now" ({@link isDeadVerdict}).
|
|
64
|
+
*/
|
|
65
|
+
export interface HostAuthSummary {
|
|
66
|
+
/** Live-verified accounts (a real 2xx). */
|
|
67
|
+
live: number;
|
|
68
|
+
/** Signed in but this agent has no live-probe endpoint — benign, neutral. */
|
|
69
|
+
present: number;
|
|
70
|
+
/** Soft/degraded: expired (self-healing) / rate_limited / error. Mild warning. */
|
|
71
|
+
degraded: number;
|
|
72
|
+
/** Server rejected the token — genuinely needs re-login. */
|
|
73
|
+
revoked: number;
|
|
74
|
+
/** Total cached rows for this host (0 → the renderer shows "—"). */
|
|
75
|
+
total: number;
|
|
76
|
+
/** Oldest `checkedAt` (epoch ms) among this host's cached rows, or null when none. */
|
|
77
|
+
oldestCheckedAt: number | null;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Roll every cached (agent, version) row for one host into a {@link HostAuthSummary}
|
|
81
|
+
* plus the age of its stalest entry. Pure — reads the map the caller already
|
|
82
|
+
* loaded via {@link readAuthHealthCache}, so `fleet status` renders the Auth
|
|
83
|
+
* column without any network probe. A host with no cached rows yields an empty
|
|
84
|
+
* summary (total 0), which the renderer shows as "—".
|
|
85
|
+
*
|
|
86
|
+
* Keys are `host:agent:version` ({@link authCacheKey}); we match on the `host:`
|
|
87
|
+
* prefix so agent/version segments can never be mistaken for a host.
|
|
88
|
+
*/
|
|
89
|
+
export declare function summarizeHostAuth(cache: Record<string, AuthHealth>, host: string): HostAuthSummary;
|
|
90
|
+
/** Human "3m ago" style age for a checkedAt timestamp. */
|
|
91
|
+
export declare function formatCheckedAge(checkedAt: number, now?: number): string;
|
|
92
|
+
/**
|
|
93
|
+
* Human account label for display (email, else id). NOT used in the cache key —
|
|
94
|
+
* two installs on one host can hold the same account with independently valid
|
|
95
|
+
* tokens, so the key is keyed by version (below), not account.
|
|
96
|
+
*/
|
|
97
|
+
export declare function authAccountLabel(info: Pick<AccountInfo, 'email' | 'accountId' | 'userId'> | null | undefined): string | undefined;
|
|
98
|
+
/** Cache key: one entry per install — (host, agent, version). Unique per token. */
|
|
99
|
+
export declare function authCacheKey(host: string, agent: AgentId | string, version: string): string;
|
|
100
|
+
/** Read the whole cache (best-effort; a corrupt/missing file yields an empty map). */
|
|
101
|
+
export declare function readAuthHealthCache(): Record<string, AuthHealth>;
|
|
102
|
+
/** Read one entry, or null. */
|
|
103
|
+
export declare function readAuthHealth(host: string, agent: AgentId | string, version: string): AuthHealth | null;
|
|
104
|
+
/**
|
|
105
|
+
* Merge entries into the cache. An incoming `error` verdict (a network blip,
|
|
106
|
+
* not a server rejection) is indeterminate, so it must NOT clobber a prior
|
|
107
|
+
* known verdict — otherwise one 8s timeout flips a `live` chip to `error`,
|
|
108
|
+
* exactly the "cry wolf" the verdict model avoids for `expired`. This is the
|
|
109
|
+
* behaviour promised by the `error` doc on AuthVerdict ("keep the last known
|
|
110
|
+
* one"). Pure, so it's unit-tested directly. */
|
|
111
|
+
export declare function mergeAuthHealthEntries(current: Record<string, AuthHealth>, incoming: Record<string, AuthHealth>): Record<string, AuthHealth>;
|
|
112
|
+
/** Merge one or more entries into the cache (best-effort write). */
|
|
113
|
+
export declare function writeAuthHealthEntries(entries: Record<string, AuthHealth>): void;
|
|
114
|
+
/**
|
|
115
|
+
* Complete a live auth probe for one (agent, home). For claude/kimi/droid this
|
|
116
|
+
* hits the provider; for everyone else it reports a best-effort local verdict
|
|
117
|
+
* (`unverified` when a credential is present, `unconfigured` otherwise) — never
|
|
118
|
+
* masquerading as `live`.
|
|
119
|
+
*/
|
|
120
|
+
export declare function probeAuthHealth(agent: AgentId, home: string | undefined, opts?: {
|
|
121
|
+
cliVersion?: string | null;
|
|
122
|
+
info?: AccountInfo | null;
|
|
123
|
+
}): Promise<AuthHealth>;
|
|
124
|
+
/** One probed install on a host. */
|
|
125
|
+
export interface AuthProbeRow {
|
|
126
|
+
agent: AgentId;
|
|
127
|
+
version: string;
|
|
128
|
+
account?: string;
|
|
129
|
+
health: AuthHealth;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Enumerate every installed (agent, version) on THIS host, probe each in
|
|
133
|
+
* parallel, and return the rows (installs with no credential at all are
|
|
134
|
+
* dropped). Shared by `agents fleet ping --local` and the daemon refresh.
|
|
135
|
+
*/
|
|
136
|
+
export declare function probeLocalFleetAuth(opts?: {
|
|
137
|
+
cliVersion?: string | null;
|
|
138
|
+
agents?: readonly AgentId[];
|
|
139
|
+
}): Promise<AuthProbeRow[]>;
|
|
140
|
+
/** Persist a host's probed rows into the cache (keyed by host+agent+version). */
|
|
141
|
+
export declare function writeFleetAuthRows(host: string, rows: AuthProbeRow[]): void;
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live auth-health: does an agent account's stored credential actually complete
|
|
3
|
+
* an authenticated request right now?
|
|
4
|
+
*
|
|
5
|
+
* The rest of the CLI reports "signed in" from a local heuristic — a credential
|
|
6
|
+
* file is present and its email decodes — which cannot distinguish a good token
|
|
7
|
+
* from a revoked-but-unexpired one. This module completes a real request per
|
|
8
|
+
* (agent, account) and records the verdict in a small cache that `agents view`
|
|
9
|
+
* (per-version chip), `agents fleet status` (the per-host Auth column, via
|
|
10
|
+
* {@link summarizeHostAuth}), and the run rotation all read. The writers are
|
|
11
|
+
* the daemon (a periodic local refresh) and `agents fleet ping` (which also
|
|
12
|
+
* fans out to write remote hosts' rows into the local cache); everyone else
|
|
13
|
+
* reads.
|
|
14
|
+
*
|
|
15
|
+
* The network probes themselves live in lib/usage.ts (where the per-provider
|
|
16
|
+
* token loaders + endpoints already are); this module classifies their result,
|
|
17
|
+
* covers the best-effort (non-networked) providers, and owns the cache.
|
|
18
|
+
*/
|
|
19
|
+
import * as fs from 'fs';
|
|
20
|
+
import * as path from 'path';
|
|
21
|
+
import { ALL_AGENT_IDS, getAccountInfo } from './agents.js';
|
|
22
|
+
import { getCacheDir } from './state.js';
|
|
23
|
+
import { probeClaudeStatus, probeDroidStatus, probeKimiStatus, } from './usage.js';
|
|
24
|
+
import { getVersionHomePath, listInstalledVersions } from './versions.js';
|
|
25
|
+
/** Agents with a live network probe wired up today. The rest are best-effort. */
|
|
26
|
+
export const LIVE_PROBE_AGENTS = new Set(['claude', 'kimi', 'droid']);
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Pure classifiers / render (unit-tested; no network, no fs)
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
/** Map an HTTP status from a live probe to a verdict. */
|
|
31
|
+
export function classifyHttpStatus(status) {
|
|
32
|
+
if (status >= 200 && status < 300)
|
|
33
|
+
return 'live';
|
|
34
|
+
if (status === 401 || status === 403)
|
|
35
|
+
return 'revoked';
|
|
36
|
+
if (status === 429)
|
|
37
|
+
return 'rate_limited';
|
|
38
|
+
return 'error';
|
|
39
|
+
}
|
|
40
|
+
/** Turn a raw provider probe (from usage.ts) into a verdict. */
|
|
41
|
+
export function verdictFromProbe(probe) {
|
|
42
|
+
if (probe.token === 'missing')
|
|
43
|
+
return 'unconfigured';
|
|
44
|
+
if (probe.token === 'expired')
|
|
45
|
+
return 'expired';
|
|
46
|
+
if (probe.status == null)
|
|
47
|
+
return 'error';
|
|
48
|
+
return classifyHttpStatus(probe.status);
|
|
49
|
+
}
|
|
50
|
+
/** A short human detail line for a probe result (rendered under --verbose). */
|
|
51
|
+
export function probeDetail(probe) {
|
|
52
|
+
if (probe.status != null && (probe.status < 200 || probe.status >= 300))
|
|
53
|
+
return `HTTP ${probe.status}`;
|
|
54
|
+
if (probe.error)
|
|
55
|
+
return probe.error;
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
const VERDICT_GLYPHS = {
|
|
59
|
+
live: '●', // ●
|
|
60
|
+
revoked: '○', // ○
|
|
61
|
+
expired: '○', // ○
|
|
62
|
+
rate_limited: '◐', // ◐
|
|
63
|
+
unverified: '◐', // ◐
|
|
64
|
+
unconfigured: '·', // ·
|
|
65
|
+
error: '·', // ·
|
|
66
|
+
};
|
|
67
|
+
/** Uncolored glyph for a verdict (color is applied by the caller). */
|
|
68
|
+
export function verdictGlyph(verdict) {
|
|
69
|
+
return VERDICT_GLYPHS[verdict] ?? '·';
|
|
70
|
+
}
|
|
71
|
+
/** One-word label for matrices/verbose output. */
|
|
72
|
+
export function verdictLabel(verdict) {
|
|
73
|
+
switch (verdict) {
|
|
74
|
+
case 'live': return 'live';
|
|
75
|
+
case 'revoked': return 'revoked';
|
|
76
|
+
case 'expired': return 'expired';
|
|
77
|
+
case 'rate_limited': return 'limited';
|
|
78
|
+
case 'unverified': return 'unverified';
|
|
79
|
+
case 'unconfigured': return '—';
|
|
80
|
+
case 'error': return '?';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export function summarizeVerdicts(verdicts) {
|
|
84
|
+
let live = 0;
|
|
85
|
+
let bad = 0;
|
|
86
|
+
let warn = 0;
|
|
87
|
+
for (const v of verdicts) {
|
|
88
|
+
if (v === 'live')
|
|
89
|
+
live++;
|
|
90
|
+
else if (v === 'revoked')
|
|
91
|
+
bad++;
|
|
92
|
+
else
|
|
93
|
+
warn++;
|
|
94
|
+
}
|
|
95
|
+
return { live, bad, warn, total: verdicts.length };
|
|
96
|
+
}
|
|
97
|
+
/** Verdicts that mean "this token was rejected by the server — re-login required". */
|
|
98
|
+
export function isDeadVerdict(verdict) {
|
|
99
|
+
return verdict === 'revoked';
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Roll every cached (agent, version) row for one host into a {@link HostAuthSummary}
|
|
103
|
+
* plus the age of its stalest entry. Pure — reads the map the caller already
|
|
104
|
+
* loaded via {@link readAuthHealthCache}, so `fleet status` renders the Auth
|
|
105
|
+
* column without any network probe. A host with no cached rows yields an empty
|
|
106
|
+
* summary (total 0), which the renderer shows as "—".
|
|
107
|
+
*
|
|
108
|
+
* Keys are `host:agent:version` ({@link authCacheKey}); we match on the `host:`
|
|
109
|
+
* prefix so agent/version segments can never be mistaken for a host.
|
|
110
|
+
*/
|
|
111
|
+
export function summarizeHostAuth(cache, host) {
|
|
112
|
+
const prefix = `${host}:`;
|
|
113
|
+
let live = 0, present = 0, degraded = 0, revoked = 0, total = 0;
|
|
114
|
+
let oldest = null;
|
|
115
|
+
for (const [key, health] of Object.entries(cache)) {
|
|
116
|
+
if (!key.startsWith(prefix))
|
|
117
|
+
continue;
|
|
118
|
+
// `unconfigured` = no credential at all — not a probed account. Writers
|
|
119
|
+
// already drop these before they reach the cache; skip here too so a stray
|
|
120
|
+
// one never counts toward total or the freshness age (belt-and-suspenders).
|
|
121
|
+
if (health.verdict === 'unconfigured')
|
|
122
|
+
continue;
|
|
123
|
+
total++;
|
|
124
|
+
switch (health.verdict) {
|
|
125
|
+
case 'live':
|
|
126
|
+
live++;
|
|
127
|
+
break;
|
|
128
|
+
case 'unverified':
|
|
129
|
+
present++;
|
|
130
|
+
break; // signed in, no probe — benign
|
|
131
|
+
case 'revoked':
|
|
132
|
+
revoked++;
|
|
133
|
+
break; // server said no — re-login
|
|
134
|
+
default:
|
|
135
|
+
degraded++;
|
|
136
|
+
break; // expired / rate_limited / error — soft
|
|
137
|
+
}
|
|
138
|
+
if (oldest === null || health.checkedAt < oldest)
|
|
139
|
+
oldest = health.checkedAt;
|
|
140
|
+
}
|
|
141
|
+
return { live, present, degraded, revoked, total, oldestCheckedAt: oldest };
|
|
142
|
+
}
|
|
143
|
+
/** Human "3m ago" style age for a checkedAt timestamp. */
|
|
144
|
+
export function formatCheckedAge(checkedAt, now = Date.now()) {
|
|
145
|
+
const secs = Math.max(0, Math.round((now - checkedAt) / 1000));
|
|
146
|
+
if (secs < 60)
|
|
147
|
+
return `${secs}s ago`;
|
|
148
|
+
const mins = Math.round(secs / 60);
|
|
149
|
+
if (mins < 60)
|
|
150
|
+
return `${mins}m ago`;
|
|
151
|
+
const hours = Math.round(mins / 60);
|
|
152
|
+
if (hours < 24)
|
|
153
|
+
return `${hours}h ago`;
|
|
154
|
+
return `${Math.round(hours / 24)}d ago`;
|
|
155
|
+
}
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// Cache identity + IO (single source of truth read by view/fleet/rotation)
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
/**
|
|
160
|
+
* Human account label for display (email, else id). NOT used in the cache key —
|
|
161
|
+
* two installs on one host can hold the same account with independently valid
|
|
162
|
+
* tokens, so the key is keyed by version (below), not account.
|
|
163
|
+
*/
|
|
164
|
+
export function authAccountLabel(info) {
|
|
165
|
+
return info?.email || info?.accountId || info?.userId || undefined;
|
|
166
|
+
}
|
|
167
|
+
/** Cache key: one entry per install — (host, agent, version). Unique per token. */
|
|
168
|
+
export function authCacheKey(host, agent, version) {
|
|
169
|
+
return `${host}:${agent}:${version}`;
|
|
170
|
+
}
|
|
171
|
+
function cacheFilePath() {
|
|
172
|
+
return path.join(getCacheDir(), '.auth-health.json');
|
|
173
|
+
}
|
|
174
|
+
/** Read the whole cache (best-effort; a corrupt/missing file yields an empty map). */
|
|
175
|
+
export function readAuthHealthCache() {
|
|
176
|
+
try {
|
|
177
|
+
const parsed = JSON.parse(fs.readFileSync(cacheFilePath(), 'utf-8'));
|
|
178
|
+
if (parsed && parsed.entries && typeof parsed.entries === 'object')
|
|
179
|
+
return parsed.entries;
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// missing or corrupt — treat as empty
|
|
183
|
+
}
|
|
184
|
+
return {};
|
|
185
|
+
}
|
|
186
|
+
/** Read one entry, or null. */
|
|
187
|
+
export function readAuthHealth(host, agent, version) {
|
|
188
|
+
return readAuthHealthCache()[authCacheKey(host, agent, version)] ?? null;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Merge entries into the cache. An incoming `error` verdict (a network blip,
|
|
192
|
+
* not a server rejection) is indeterminate, so it must NOT clobber a prior
|
|
193
|
+
* known verdict — otherwise one 8s timeout flips a `live` chip to `error`,
|
|
194
|
+
* exactly the "cry wolf" the verdict model avoids for `expired`. This is the
|
|
195
|
+
* behaviour promised by the `error` doc on AuthVerdict ("keep the last known
|
|
196
|
+
* one"). Pure, so it's unit-tested directly. */
|
|
197
|
+
export function mergeAuthHealthEntries(current, incoming) {
|
|
198
|
+
const merged = { ...current };
|
|
199
|
+
for (const [key, health] of Object.entries(incoming)) {
|
|
200
|
+
if (health.verdict === 'error' && merged[key])
|
|
201
|
+
continue; // keep last known
|
|
202
|
+
merged[key] = health;
|
|
203
|
+
}
|
|
204
|
+
return merged;
|
|
205
|
+
}
|
|
206
|
+
/** Merge one or more entries into the cache (best-effort write). */
|
|
207
|
+
export function writeAuthHealthEntries(entries) {
|
|
208
|
+
try {
|
|
209
|
+
const dir = getCacheDir();
|
|
210
|
+
if (!fs.existsSync(dir))
|
|
211
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
212
|
+
const merged = {
|
|
213
|
+
version: 1,
|
|
214
|
+
entries: mergeAuthHealthEntries(readAuthHealthCache(), entries),
|
|
215
|
+
};
|
|
216
|
+
fs.writeFileSync(cacheFilePath(), JSON.stringify(merged, null, 2));
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
// best-effort; a failed write just means the next reader falls back to heuristics
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// The probe (writer side)
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
/**
|
|
226
|
+
* Complete a live auth probe for one (agent, home). For claude/kimi/droid this
|
|
227
|
+
* hits the provider; for everyone else it reports a best-effort local verdict
|
|
228
|
+
* (`unverified` when a credential is present, `unconfigured` otherwise) — never
|
|
229
|
+
* masquerading as `live`.
|
|
230
|
+
*/
|
|
231
|
+
export async function probeAuthHealth(agent, home, opts) {
|
|
232
|
+
const checkedAt = Date.now();
|
|
233
|
+
if (LIVE_PROBE_AGENTS.has(agent)) {
|
|
234
|
+
let probe;
|
|
235
|
+
if (agent === 'claude')
|
|
236
|
+
probe = await probeClaudeStatus(home, opts?.cliVersion);
|
|
237
|
+
else if (agent === 'kimi')
|
|
238
|
+
probe = await probeKimiStatus(home);
|
|
239
|
+
else
|
|
240
|
+
probe = await probeDroidStatus(home);
|
|
241
|
+
return { verdict: verdictFromProbe(probe), checkedAt, detail: probeDetail(probe) };
|
|
242
|
+
}
|
|
243
|
+
const info = opts?.info !== undefined ? opts.info : await getAccountInfo(agent, home).catch(() => null);
|
|
244
|
+
return { verdict: info?.signedIn ? 'unverified' : 'unconfigured', checkedAt };
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Enumerate every installed (agent, version) on THIS host, probe each in
|
|
248
|
+
* parallel, and return the rows (installs with no credential at all are
|
|
249
|
+
* dropped). Shared by `agents fleet ping --local` and the daemon refresh.
|
|
250
|
+
*/
|
|
251
|
+
export async function probeLocalFleetAuth(opts) {
|
|
252
|
+
const agentIds = opts?.agents ?? ALL_AGENT_IDS;
|
|
253
|
+
const tasks = [];
|
|
254
|
+
for (const agent of agentIds) {
|
|
255
|
+
for (const version of listInstalledVersions(agent)) {
|
|
256
|
+
const home = getVersionHomePath(agent, version);
|
|
257
|
+
tasks.push((async () => {
|
|
258
|
+
const info = await getAccountInfo(agent, home).catch(() => null);
|
|
259
|
+
const health = await probeAuthHealth(agent, home, { cliVersion: opts?.cliVersion, info });
|
|
260
|
+
health.account = authAccountLabel(info);
|
|
261
|
+
if (health.verdict === 'unconfigured')
|
|
262
|
+
return null;
|
|
263
|
+
return { agent, version, account: health.account, health };
|
|
264
|
+
})());
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const settled = await Promise.all(tasks);
|
|
268
|
+
return settled.filter((r) => r !== null);
|
|
269
|
+
}
|
|
270
|
+
/** Persist a host's probed rows into the cache (keyed by host+agent+version). */
|
|
271
|
+
export function writeFleetAuthRows(host, rows) {
|
|
272
|
+
const entries = {};
|
|
273
|
+
for (const row of rows) {
|
|
274
|
+
entries[authCacheKey(host, row.agent, row.version)] = row.health;
|
|
275
|
+
}
|
|
276
|
+
writeAuthHealthEntries(entries);
|
|
277
|
+
}
|
|
@@ -24,6 +24,15 @@ export declare function findFirstInstalledBrowser(platform?: string): {
|
|
|
24
24
|
browserType: BrowserType;
|
|
25
25
|
binary: string;
|
|
26
26
|
} | null;
|
|
27
|
+
/**
|
|
28
|
+
* List every installed Chromium-family browser on this machine, in the platform
|
|
29
|
+
* priority order. Used by `agents setup browser` to offer a pick when more than
|
|
30
|
+
* one is present. Returns [] if none are installed.
|
|
31
|
+
*/
|
|
32
|
+
export declare function listInstalledBrowsers(platform?: string): {
|
|
33
|
+
browserType: BrowserType;
|
|
34
|
+
binary: string;
|
|
35
|
+
}[];
|
|
27
36
|
export interface LaunchResult {
|
|
28
37
|
pid: number;
|
|
29
38
|
port: number;
|
|
@@ -203,6 +203,28 @@ export function findFirstInstalledBrowser(platform = os.platform()) {
|
|
|
203
203
|
}
|
|
204
204
|
return null;
|
|
205
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* List every installed Chromium-family browser on this machine, in the platform
|
|
208
|
+
* priority order. Used by `agents setup browser` to offer a pick when more than
|
|
209
|
+
* one is present. Returns [] if none are installed.
|
|
210
|
+
*/
|
|
211
|
+
export function listInstalledBrowsers(platform = os.platform()) {
|
|
212
|
+
const priority = DEFAULT_BROWSER_PRIORITY[platform];
|
|
213
|
+
const platformPaths = BROWSER_PATHS[platform];
|
|
214
|
+
if (!priority || !platformPaths)
|
|
215
|
+
return [];
|
|
216
|
+
const found = [];
|
|
217
|
+
for (const browserType of priority) {
|
|
218
|
+
const candidates = platformPaths[browserType] || [];
|
|
219
|
+
for (const p of candidates) {
|
|
220
|
+
if (fs.existsSync(p)) {
|
|
221
|
+
found.push({ browserType, binary: resolveBrowserBinary(p) });
|
|
222
|
+
break; // one install path per browser type is enough
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return found;
|
|
227
|
+
}
|
|
206
228
|
export async function launchBrowser(profileName, browserType, port, options = {}, secrets, customBinary,
|
|
207
229
|
// `electron: true` distinguishes Notion / VS Code-style apps from
|
|
208
230
|
// regular Chrome — purely informational, stored in meta.json so the
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-demand download + verification of the macOS `agents computer` helper
|
|
3
|
+
* ("ComputerHelper.app").
|
|
4
|
+
*
|
|
5
|
+
* The helper is a signed + notarized universal `.app` bundle published as a
|
|
6
|
+
* GitHub release asset per tagged CLI version — the same distribution model as
|
|
7
|
+
* the Windows helper (see `lib/ssh-tunnel.ts`). A fresh `npm i -g` machine has
|
|
8
|
+
* no local build, so `agents computer setup` / `agents setup computer` fetch the
|
|
9
|
+
* asset for the running CLI version, verify its sha256 against the published
|
|
10
|
+
* `.sha256`, then verify the code signature (Developer ID Team + notarization)
|
|
11
|
+
* before it is ever copied to /Applications.
|
|
12
|
+
*
|
|
13
|
+
* A `.app` is a directory, so the asset is a zip (`ditto -c -k --keepParent`);
|
|
14
|
+
* we extract it with `ditto -x -k` after the checksum passes.
|
|
15
|
+
*/
|
|
16
|
+
/** GitHub repo whose `v<version>` releases carry the helper asset. */
|
|
17
|
+
export declare const HELPER_RELEASE_REPO = "phnx-labs/agents-cli";
|
|
18
|
+
/** The zipped `.app` release asset name. */
|
|
19
|
+
export declare const MAC_HELPER_ASSET = "ComputerHelper.app.zip";
|
|
20
|
+
/** The bundle directory name once extracted. */
|
|
21
|
+
export declare const MAC_HELPER_APP_NAME = "ComputerHelper.app";
|
|
22
|
+
/** Apple Developer ID Team the helper must be signed by (defense in depth on top
|
|
23
|
+
* of `spctl` notarization assessment). "Developer ID Application: Muqit Nawaz". */
|
|
24
|
+
export declare const EXPECTED_TEAM_ID = "2HTP252L87";
|
|
25
|
+
/** Cache dir for the downloaded helper, one subdir per release tag. */
|
|
26
|
+
export declare function macHelperCacheDir(version: string): string;
|
|
27
|
+
/** Release-asset URLs for the helper zip + its checksum at one `v<version>` tag. */
|
|
28
|
+
export declare function macHelperAssetUrls(version: string): {
|
|
29
|
+
zip: string;
|
|
30
|
+
sha256: string;
|
|
31
|
+
};
|
|
32
|
+
/** Extract `TeamIdentifier=XXXX` from `codesign -dv --verbose=4` output (which is
|
|
33
|
+
* emitted on stderr). Returns null when absent (ad-hoc / unsigned). */
|
|
34
|
+
export declare function parseTeamId(codesignInfo: string): string | null;
|
|
35
|
+
/**
|
|
36
|
+
* Verify a helper `.app` bundle is intact, signed by the expected Developer ID
|
|
37
|
+
* Team, and notarized (Gatekeeper-accepted). Throws with an actionable message
|
|
38
|
+
* on any failure — a downloaded bundle is never trusted without this.
|
|
39
|
+
*/
|
|
40
|
+
export declare function verifyMacHelper(appPath: string): void;
|
|
41
|
+
/**
|
|
42
|
+
* Download the helper release asset for `version`, verify sha256, extract the
|
|
43
|
+
* `.app`, and verify its signature. Returns the path to the extracted
|
|
44
|
+
* `ComputerHelper.app`. A missing asset is a hard error naming the exact tag —
|
|
45
|
+
* never a silent fallback to another release.
|
|
46
|
+
*/
|
|
47
|
+
export declare function downloadMacHelperApp(version: string): Promise<string>;
|
|
48
|
+
/**
|
|
49
|
+
* Resolve the helper `.app` to install from: a local build / bundled copy first
|
|
50
|
+
* (repo checkout), else the checksum + signature-verified release-asset download
|
|
51
|
+
* for the running CLI version. Throws with the tag it checked when neither
|
|
52
|
+
* exists. macOS only.
|
|
53
|
+
*/
|
|
54
|
+
export declare function ensureMacHelperApp(version?: string): Promise<string>;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-demand download + verification of the macOS `agents computer` helper
|
|
3
|
+
* ("ComputerHelper.app").
|
|
4
|
+
*
|
|
5
|
+
* The helper is a signed + notarized universal `.app` bundle published as a
|
|
6
|
+
* GitHub release asset per tagged CLI version — the same distribution model as
|
|
7
|
+
* the Windows helper (see `lib/ssh-tunnel.ts`). A fresh `npm i -g` machine has
|
|
8
|
+
* no local build, so `agents computer setup` / `agents setup computer` fetch the
|
|
9
|
+
* asset for the running CLI version, verify its sha256 against the published
|
|
10
|
+
* `.sha256`, then verify the code signature (Developer ID Team + notarization)
|
|
11
|
+
* before it is ever copied to /Applications.
|
|
12
|
+
*
|
|
13
|
+
* A `.app` is a directory, so the asset is a zip (`ditto -c -k --keepParent`);
|
|
14
|
+
* we extract it with `ditto -x -k` after the checksum passes.
|
|
15
|
+
*/
|
|
16
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
17
|
+
import * as fs from 'node:fs';
|
|
18
|
+
import * as os from 'node:os';
|
|
19
|
+
import * as path from 'node:path';
|
|
20
|
+
import { Readable } from 'node:stream';
|
|
21
|
+
import { pipeline } from 'node:stream/promises';
|
|
22
|
+
import { getCacheDir } from '../state.js';
|
|
23
|
+
import { getCliVersion } from '../version.js';
|
|
24
|
+
import { parseSha256Asset, sha256File } from '../ssh-tunnel.js';
|
|
25
|
+
import { resolveHelperApp } from '../computer-rpc.js';
|
|
26
|
+
/** GitHub repo whose `v<version>` releases carry the helper asset. */
|
|
27
|
+
export const HELPER_RELEASE_REPO = 'phnx-labs/agents-cli';
|
|
28
|
+
/** The zipped `.app` release asset name. */
|
|
29
|
+
export const MAC_HELPER_ASSET = 'ComputerHelper.app.zip';
|
|
30
|
+
/** The bundle directory name once extracted. */
|
|
31
|
+
export const MAC_HELPER_APP_NAME = 'ComputerHelper.app';
|
|
32
|
+
/** Apple Developer ID Team the helper must be signed by (defense in depth on top
|
|
33
|
+
* of `spctl` notarization assessment). "Developer ID Application: Muqit Nawaz". */
|
|
34
|
+
export const EXPECTED_TEAM_ID = '2HTP252L87';
|
|
35
|
+
/** Cache dir for the downloaded helper, one subdir per release tag. */
|
|
36
|
+
export function macHelperCacheDir(version) {
|
|
37
|
+
return path.join(getCacheDir(), 'computer', 'mac-helper', `v${version}`);
|
|
38
|
+
}
|
|
39
|
+
/** Release-asset URLs for the helper zip + its checksum at one `v<version>` tag. */
|
|
40
|
+
export function macHelperAssetUrls(version) {
|
|
41
|
+
const base = `https://github.com/${HELPER_RELEASE_REPO}/releases/download/v${version}`;
|
|
42
|
+
return { zip: `${base}/${MAC_HELPER_ASSET}`, sha256: `${base}/${MAC_HELPER_ASSET}.sha256` };
|
|
43
|
+
}
|
|
44
|
+
/** Extract `TeamIdentifier=XXXX` from `codesign -dv --verbose=4` output (which is
|
|
45
|
+
* emitted on stderr). Returns null when absent (ad-hoc / unsigned). */
|
|
46
|
+
export function parseTeamId(codesignInfo) {
|
|
47
|
+
return codesignInfo.match(/TeamIdentifier=([A-Z0-9]+)/)?.[1] ?? null;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Verify a helper `.app` bundle is intact, signed by the expected Developer ID
|
|
51
|
+
* Team, and notarized (Gatekeeper-accepted). Throws with an actionable message
|
|
52
|
+
* on any failure — a downloaded bundle is never trusted without this.
|
|
53
|
+
*/
|
|
54
|
+
export function verifyMacHelper(appPath) {
|
|
55
|
+
// 1. Structural + signature integrity.
|
|
56
|
+
try {
|
|
57
|
+
execFileSync('/usr/bin/codesign', ['--verify', '--deep', '--strict', appPath], { stdio: 'pipe' });
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
throw new Error(`code signature invalid for ${appPath}: ${e.message}`);
|
|
61
|
+
}
|
|
62
|
+
// 2. Team identity — must be our Developer ID, not some other valid signer.
|
|
63
|
+
// `codesign -dv` writes its details to STDERR even on success (exit 0), so we
|
|
64
|
+
// must read stderr, not stdout. spawnSync captures both streams regardless of
|
|
65
|
+
// exit code; execFileSync would return only (empty) stdout and the Team check
|
|
66
|
+
// would falsely reject every validly-signed helper.
|
|
67
|
+
const dv = spawnSync('/usr/bin/codesign', ['-dv', '--verbose=4', appPath], { encoding: 'utf8' });
|
|
68
|
+
const info = `${dv.stdout ?? ''}${dv.stderr ?? ''}`;
|
|
69
|
+
const team = parseTeamId(info);
|
|
70
|
+
if (team !== EXPECTED_TEAM_ID) {
|
|
71
|
+
throw new Error(`helper signed by unexpected Team (${team ?? 'none'}), expected ${EXPECTED_TEAM_ID}. Refusing to install.`);
|
|
72
|
+
}
|
|
73
|
+
// 3. Notarization / Gatekeeper — confirms Apple stapled a notarization ticket.
|
|
74
|
+
try {
|
|
75
|
+
execFileSync('/usr/sbin/spctl', ['--assess', '--type', 'execute', '--verbose', appPath], { stdio: 'pipe' });
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
throw new Error(`helper is not notarized / rejected by Gatekeeper: ${e.message}. Refusing to install.`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Download the helper release asset for `version`, verify sha256, extract the
|
|
83
|
+
* `.app`, and verify its signature. Returns the path to the extracted
|
|
84
|
+
* `ComputerHelper.app`. A missing asset is a hard error naming the exact tag —
|
|
85
|
+
* never a silent fallback to another release.
|
|
86
|
+
*/
|
|
87
|
+
export async function downloadMacHelperApp(version) {
|
|
88
|
+
const dir = macHelperCacheDir(version);
|
|
89
|
+
const cachedApp = path.join(dir, MAC_HELPER_APP_NAME);
|
|
90
|
+
if (fs.existsSync(cachedApp)) {
|
|
91
|
+
// Re-verify a cached bundle cheaply; a tampered cache must not be trusted.
|
|
92
|
+
verifyMacHelper(cachedApp);
|
|
93
|
+
return cachedApp;
|
|
94
|
+
}
|
|
95
|
+
const tag = `v${version}`;
|
|
96
|
+
const { zip: zipUrl, sha256: shaUrl } = macHelperAssetUrls(version);
|
|
97
|
+
const missing = (status, url) => new Error(`no ${MAC_HELPER_ASSET} release asset for tag ${tag} (HTTP ${status} on ${url}). ` +
|
|
98
|
+
`The macOS helper ships as a GitHub release asset per tagged CLI version; ` +
|
|
99
|
+
`from a repo checkout you can build it locally instead: ` +
|
|
100
|
+
`bash native/computer-mac/scripts/build.sh release`);
|
|
101
|
+
// Checksum first: it is tiny and 404s fast when the tag has no assets.
|
|
102
|
+
const shaRes = await fetch(shaUrl, { signal: AbortSignal.timeout(30_000) });
|
|
103
|
+
if (!shaRes.ok)
|
|
104
|
+
throw missing(shaRes.status, shaUrl);
|
|
105
|
+
const expected = parseSha256Asset(await shaRes.text());
|
|
106
|
+
console.error(`Downloading ${MAC_HELPER_ASSET} ${tag} from GitHub releases...`);
|
|
107
|
+
const zipRes = await fetch(zipUrl, { signal: AbortSignal.timeout(15 * 60_000) });
|
|
108
|
+
if (!zipRes.ok || !zipRes.body)
|
|
109
|
+
throw missing(zipRes.status, zipUrl);
|
|
110
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
111
|
+
const partial = path.join(dir, `${MAC_HELPER_ASSET}.download`);
|
|
112
|
+
try {
|
|
113
|
+
await pipeline(Readable.fromWeb(zipRes.body), fs.createWriteStream(partial));
|
|
114
|
+
const actual = await sha256File(partial);
|
|
115
|
+
if (actual !== expected) {
|
|
116
|
+
throw new Error(`sha256 mismatch for ${zipUrl}: expected ${expected}, got ${actual}`);
|
|
117
|
+
}
|
|
118
|
+
// Extract the zip (created with `ditto -c -k --keepParent`, so it contains
|
|
119
|
+
// ComputerHelper.app/ at top level) into the version cache dir.
|
|
120
|
+
fs.rmSync(cachedApp, { recursive: true, force: true });
|
|
121
|
+
execFileSync('/usr/bin/ditto', ['-x', '-k', partial, dir], { stdio: 'pipe' });
|
|
122
|
+
if (!fs.existsSync(cachedApp)) {
|
|
123
|
+
throw new Error(`extracted asset did not contain ${MAC_HELPER_APP_NAME}`);
|
|
124
|
+
}
|
|
125
|
+
verifyMacHelper(cachedApp);
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
fs.rmSync(partial, { force: true });
|
|
129
|
+
}
|
|
130
|
+
return cachedApp;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Resolve the helper `.app` to install from: a local build / bundled copy first
|
|
134
|
+
* (repo checkout), else the checksum + signature-verified release-asset download
|
|
135
|
+
* for the running CLI version. Throws with the tag it checked when neither
|
|
136
|
+
* exists. macOS only.
|
|
137
|
+
*/
|
|
138
|
+
export async function ensureMacHelperApp(version = getCliVersion()) {
|
|
139
|
+
if (os.platform() !== 'darwin') {
|
|
140
|
+
throw new Error('The macOS computer helper is only available on macOS.');
|
|
141
|
+
}
|
|
142
|
+
const local = resolveHelperApp();
|
|
143
|
+
if (local)
|
|
144
|
+
return local;
|
|
145
|
+
return downloadMacHelperApp(version);
|
|
146
|
+
}
|