@phnx-labs/agents-cli 1.22.106 → 1.22.109
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 +78 -0
- package/README.md +9 -5
- package/dist/commands/daemon.js +45 -40
- package/dist/commands/doctor.js +7 -0
- package/dist/commands/exec.d.ts +78 -8
- package/dist/commands/exec.js +276 -53
- package/dist/commands/monitors.js +15 -2
- package/dist/commands/routines.js +46 -6
- package/dist/commands/run-account-picker.d.ts +11 -0
- package/dist/commands/run-account-picker.js +11 -1
- package/dist/commands/sessions-backup-setup.d.ts +12 -0
- package/dist/commands/sessions-backup-setup.js +65 -0
- package/dist/commands/sessions-resume.d.ts +14 -1
- package/dist/commands/sessions-resume.js +56 -9
- package/dist/commands/sessions.js +2 -0
- package/dist/commands/share.js +15 -2
- package/dist/commands/sync.js +30 -1
- package/dist/lib/accounts/slots.js +7 -0
- package/dist/lib/agent-spec/agents.d.ts +60 -8
- package/dist/lib/agent-spec/agents.js +118 -45
- package/dist/lib/daemon/daemon.d.ts +34 -1
- package/dist/lib/daemon/daemon.js +63 -9
- package/dist/lib/daemon/leaked-daemons.d.ts +60 -0
- package/dist/lib/daemon/leaked-daemons.js +180 -0
- package/dist/lib/devices/doctor-findings.d.ts +7 -1
- package/dist/lib/devices/doctor-findings.js +32 -1
- package/dist/lib/hooks/install.js +70 -63
- package/dist/lib/hosts/dispatch.d.ts +1 -1
- package/dist/lib/hosts/dispatch.js +1 -1
- package/dist/lib/models.js +76 -5
- package/dist/lib/session/cloud.js +3 -1
- package/dist/lib/session/parse.d.ts +1 -0
- package/dist/lib/session/parse.js +136 -2
- package/dist/lib/session/recovery.d.ts +9 -1
- package/dist/lib/session/recovery.js +14 -4
- package/dist/lib/session/tool-calls.d.ts +1 -1
- package/dist/lib/session/tool-calls.js +40 -5
- package/dist/lib/share/backend.d.ts +23 -0
- package/dist/lib/share/backend.js +24 -0
- package/dist/lib/share/provision.d.ts +12 -0
- package/dist/lib/share/provision.js +30 -0
- package/dist/lib/share/worker-template.js +273 -0
- package/dist/lib/terminal/engine.js +13 -1
- package/package.json +1 -1
|
@@ -1692,34 +1692,41 @@ async function antigravityKeychainSignedIn() {
|
|
|
1692
1692
|
}
|
|
1693
1693
|
return cachedAgyKeychainSignedIn;
|
|
1694
1694
|
}
|
|
1695
|
+
/** The XDG base dirs OpenCode reads, and the env var that overrides each. */
|
|
1696
|
+
const OPENCODE_XDG_DIRS = {
|
|
1697
|
+
data: { env: 'XDG_DATA_HOME', fallback: ['.local', 'share'] },
|
|
1698
|
+
state: { env: 'XDG_STATE_HOME', fallback: ['.local', 'state'] },
|
|
1699
|
+
};
|
|
1695
1700
|
/**
|
|
1696
|
-
* OpenCode (sst/opencode)
|
|
1697
|
-
*
|
|
1698
|
-
*
|
|
1699
|
-
*
|
|
1700
|
-
*
|
|
1701
|
-
*
|
|
1702
|
-
*
|
|
1701
|
+
* Resolve one of OpenCode's (sst/opencode) XDG-rooted files.
|
|
1702
|
+
*
|
|
1703
|
+
* OpenCode keeps provider credentials under `$XDG_DATA_HOME/opencode/` and TUI
|
|
1704
|
+
* state under `$XDG_STATE_HOME/opencode/`, defaulting to `~/.local/share` and
|
|
1705
|
+
* `~/.local/state` on EVERY platform — its `xdg-basedir` dependency does not
|
|
1706
|
+
* special-case macOS, so there is no `~/Library/Application Support` variant.
|
|
1707
|
+
* Both roots are account-global (not per-version), matching how
|
|
1708
|
+
* `session/discover.ts` already resolves `~/.local/share/opencode/opencode.db`.
|
|
1703
1709
|
*
|
|
1704
1710
|
* Resolution order, first existing wins:
|
|
1705
|
-
* 1. `<base
|
|
1706
|
-
*
|
|
1707
|
-
*
|
|
1708
|
-
* 2. `$
|
|
1711
|
+
* 1. `<base>/<fallback>/opencode/<file>` — the passed per-version home. This is
|
|
1712
|
+
* primarily a test hook (suites write a hermetic file under a temp home)
|
|
1713
|
+
* but also covers any relocated install.
|
|
1714
|
+
* 2. `$XDG_<KIND>_HOME/opencode/<file>` — an explicit XDG override, exactly
|
|
1709
1715
|
* what OpenCode itself honours.
|
|
1710
|
-
* 3. `<realHome
|
|
1716
|
+
* 3. `<realHome>/<fallback>/opencode/<file>` — the active default, under
|
|
1711
1717
|
* `AGENTS_REAL_HOME` or `os.homedir()`, so every installed version reflects
|
|
1712
|
-
* the one account-global
|
|
1718
|
+
* the one account-global state (same fallback shape as
|
|
1713
1719
|
* resolveAccountCredentialPath).
|
|
1714
1720
|
* Returns the first existing path, or null. Never throws.
|
|
1715
1721
|
*/
|
|
1716
|
-
function
|
|
1717
|
-
const
|
|
1718
|
-
const
|
|
1719
|
-
|
|
1720
|
-
|
|
1722
|
+
export function resolveOpenCodeXdgPath(base, kind, file) {
|
|
1723
|
+
const { env, fallback } = OPENCODE_XDG_DIRS[kind];
|
|
1724
|
+
const candidates = [path.join(base, ...fallback, 'opencode', file)];
|
|
1725
|
+
const override = process.env[env];
|
|
1726
|
+
if (override)
|
|
1727
|
+
candidates.push(path.join(override, 'opencode', file));
|
|
1721
1728
|
const realHome = process.env.AGENTS_REAL_HOME || os.homedir();
|
|
1722
|
-
candidates.push(path.join(realHome,
|
|
1729
|
+
candidates.push(path.join(realHome, ...fallback, 'opencode', file));
|
|
1723
1730
|
for (const candidate of candidates) {
|
|
1724
1731
|
try {
|
|
1725
1732
|
if (fs.existsSync(candidate))
|
|
@@ -1729,6 +1736,9 @@ function resolveOpenCodeAuthPath(base) {
|
|
|
1729
1736
|
}
|
|
1730
1737
|
return null;
|
|
1731
1738
|
}
|
|
1739
|
+
function resolveOpenCodeAuthPath(base) {
|
|
1740
|
+
return resolveOpenCodeXdgPath(base, 'data', 'auth.json');
|
|
1741
|
+
}
|
|
1732
1742
|
/**
|
|
1733
1743
|
* Validate one OpenCode auth.json entry against its discriminated union
|
|
1734
1744
|
* (`type: 'oauth' | 'api' | 'wellknown'`) and confirm the credential actually
|
|
@@ -1751,25 +1761,58 @@ function isValidOpenCodeCredential(value) {
|
|
|
1751
1761
|
}
|
|
1752
1762
|
}
|
|
1753
1763
|
/**
|
|
1754
|
-
*
|
|
1755
|
-
*
|
|
1756
|
-
*
|
|
1757
|
-
*
|
|
1758
|
-
*
|
|
1764
|
+
* The human identity an OpenCode `type: 'oauth'` credential carries in its
|
|
1765
|
+
* access token, when that token is a JWT. Provider-agnostic by shape: the
|
|
1766
|
+
* OpenAI-issued token OpenCode stores under its `openai` provider carries the
|
|
1767
|
+
* same namespaced claims Codex's own `auth.json` does (`…/profile.email`,
|
|
1768
|
+
* `…/auth.chatgpt_plan_type`), so the extraction is shared with `case 'codex'`
|
|
1769
|
+
* rather than reinvented. A plain `email` claim is accepted too, for providers
|
|
1770
|
+
* that issue an ordinary OIDC token.
|
|
1771
|
+
*
|
|
1772
|
+
* Only claims are read — the token itself is never returned or logged. A
|
|
1773
|
+
* non-JWT credential (Anthropic's `sk-ant-oat…` opaque token, any `type: 'api'`
|
|
1774
|
+
* key) simply yields nothing.
|
|
1775
|
+
*/
|
|
1776
|
+
function openCodeOauthIdentity(cred) {
|
|
1777
|
+
const access = cred?.access;
|
|
1778
|
+
if (typeof access !== 'string' || access.length === 0)
|
|
1779
|
+
return { email: null, plan: null };
|
|
1780
|
+
const claims = decodeJwtPayload(access);
|
|
1781
|
+
if (!claims)
|
|
1782
|
+
return { email: null, plan: null };
|
|
1783
|
+
const profile = claims['https://api.openai.com/profile'] || {};
|
|
1784
|
+
const auth = claims['https://api.openai.com/auth'] || {};
|
|
1785
|
+
const rawEmail = profile.email ?? claims.email;
|
|
1786
|
+
const email = typeof rawEmail === 'string' && rawEmail.includes('@') ? rawEmail : null;
|
|
1787
|
+
const rawPlan = auth.chatgpt_plan_type;
|
|
1788
|
+
const plan = typeof rawPlan === 'string' && rawPlan
|
|
1789
|
+
? rawPlan.charAt(0).toUpperCase() + rawPlan.slice(1)
|
|
1790
|
+
: null;
|
|
1791
|
+
return { email, plan };
|
|
1792
|
+
}
|
|
1793
|
+
/**
|
|
1794
|
+
* OpenCode's account identity, read from `auth.json`.
|
|
1795
|
+
*
|
|
1796
|
+
* The provider join (`"meta+openai+opencode-go"`) is the stable key: OpenCode is
|
|
1797
|
+
* a multi-provider harness, so "which providers are configured" is what actually
|
|
1798
|
+
* identifies an install, and `session/discover.ts` indexes sessions by it.
|
|
1799
|
+
* Providers that sign in over OAuth additionally hand OpenCode a token with real
|
|
1800
|
+
* identity claims, so the email and plan are surfaced alongside it instead of
|
|
1801
|
+
* leaving `agents view` showing a bare `id:` key for a login that knows exactly
|
|
1802
|
+
* whose it is. Providers are walked in sorted order so a multi-OAuth install
|
|
1803
|
+
* resolves to the same email on every machine.
|
|
1759
1804
|
*
|
|
1760
1805
|
* This is the ONLY correct source for an OpenCode "account". OpenCode's SQLite
|
|
1761
1806
|
* `opencode.db` also carries `account`/`account_state`/`control_account` tables,
|
|
1762
1807
|
* but on a real, actively-used install (yosemite-s1, 1.16.0, 35 applied
|
|
1763
1808
|
* migrations) all three are permanently empty — no migration ever populates
|
|
1764
1809
|
* them, and no session has ever written a row. Reading from them instead of
|
|
1765
|
-
* `auth.json` always yields `undefined`, credential or not
|
|
1766
|
-
* uses this function rather than duplicating a sqlite lookup against those
|
|
1767
|
-
* dead tables.
|
|
1810
|
+
* `auth.json` always yields `undefined`, credential or not.
|
|
1768
1811
|
*
|
|
1769
1812
|
* Sync (`fs.readFileSync`), no network. Returns undefined when `auth.json` is
|
|
1770
1813
|
* missing, unreadable, or carries no valid credential.
|
|
1771
1814
|
*/
|
|
1772
|
-
export function
|
|
1815
|
+
export function resolveOpenCodeIdentity(base) {
|
|
1773
1816
|
const authPath = resolveOpenCodeAuthPath(base);
|
|
1774
1817
|
if (!authPath)
|
|
1775
1818
|
return undefined;
|
|
@@ -1777,16 +1820,34 @@ export function resolveOpenCodeAccountId(base) {
|
|
|
1777
1820
|
const data = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
|
|
1778
1821
|
if (!data || typeof data !== 'object')
|
|
1779
1822
|
return undefined;
|
|
1780
|
-
const
|
|
1823
|
+
const valid = Object.entries(data)
|
|
1781
1824
|
.filter(([, cred]) => isValidOpenCodeCredential(cred))
|
|
1782
|
-
.
|
|
1783
|
-
|
|
1784
|
-
|
|
1825
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
1826
|
+
if (valid.length === 0)
|
|
1827
|
+
return undefined;
|
|
1828
|
+
let email = null;
|
|
1829
|
+
let plan = null;
|
|
1830
|
+
for (const [, cred] of valid) {
|
|
1831
|
+
const claimed = openCodeOauthIdentity(cred);
|
|
1832
|
+
email ??= claimed.email;
|
|
1833
|
+
plan ??= claimed.plan;
|
|
1834
|
+
if (email && plan)
|
|
1835
|
+
break;
|
|
1836
|
+
}
|
|
1837
|
+
return { providers: valid.map(([id]) => id).join('+'), email, plan };
|
|
1785
1838
|
}
|
|
1786
1839
|
catch {
|
|
1787
1840
|
return undefined;
|
|
1788
1841
|
}
|
|
1789
1842
|
}
|
|
1843
|
+
/**
|
|
1844
|
+
* The provider join alone — the value `session/discover.ts` indexes sessions by,
|
|
1845
|
+
* kept as its own entry point so callers that only need the key do not have to
|
|
1846
|
+
* know about the OAuth claim walk.
|
|
1847
|
+
*/
|
|
1848
|
+
export function resolveOpenCodeAccountId(base) {
|
|
1849
|
+
return resolveOpenCodeIdentity(base)?.providers;
|
|
1850
|
+
}
|
|
1790
1851
|
/**
|
|
1791
1852
|
* Whether a Muse Code `~/.config/muse/auth.json` document holds any usable
|
|
1792
1853
|
* access token. Live shape from `muse login` (device OAuth, Muse Code 0.1.0):
|
|
@@ -1972,6 +2033,11 @@ export async function getAccountInfo(agentId, home) {
|
|
|
1972
2033
|
claude: path.join(base, '.claude.json'),
|
|
1973
2034
|
codex: path.join(base, '.codex', 'auth.json'),
|
|
1974
2035
|
gemini: path.join(base, '.gemini', 'google_accounts.json'),
|
|
2036
|
+
// OpenCode keeps every session in ONE sqlite file rather than a directory of
|
|
2037
|
+
// per-session transcripts, so the session-file walk resolveLastActive runs
|
|
2038
|
+
// for other agents finds nothing. Its mtime fallback is the right read here:
|
|
2039
|
+
// opencode.db is written on every turn, so it tracks real activity.
|
|
2040
|
+
opencode: resolveOpenCodeXdgPath(base, 'data', 'opencode.db') ?? undefined,
|
|
1975
2041
|
};
|
|
1976
2042
|
const lastActive = resolveLastActive(agentId, base, configFiles[agentId]);
|
|
1977
2043
|
try {
|
|
@@ -2238,20 +2304,27 @@ export async function getAccountInfo(agentId, home) {
|
|
|
2238
2304
|
}
|
|
2239
2305
|
case 'opencode': {
|
|
2240
2306
|
// OpenCode's auth.json is a record keyed by provider id ->
|
|
2241
|
-
// { type: 'oauth'|'api'|'wellknown', ...secret fields }.
|
|
2242
|
-
//
|
|
2243
|
-
//
|
|
2244
|
-
//
|
|
2245
|
-
//
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
// resolveOpenCodeAccountId is the single source of truth for this join —
|
|
2249
|
-
// session/discover.ts reuses it for the indexed `account` field.
|
|
2250
|
-
const accountId = resolveOpenCodeAccountId(base);
|
|
2251
|
-
if (!accountId)
|
|
2307
|
+
// { type: 'oauth'|'api'|'wellknown', ...secret fields }. The provider
|
|
2308
|
+
// join is the identity key (see resolveOpenCodeIdentity), and an OAuth
|
|
2309
|
+
// provider's token additionally carries the real account email and plan
|
|
2310
|
+
// — so the row shows who is signed in rather than only a bare `id:` key.
|
|
2311
|
+
// Secrets are never read out; only JWT claims are.
|
|
2312
|
+
const identity = resolveOpenCodeIdentity(base);
|
|
2313
|
+
if (!identity)
|
|
2252
2314
|
return { ...empty, lastActive };
|
|
2253
|
-
|
|
2254
|
-
|
|
2315
|
+
// Keyed on providers, not email: OpenCode bills through whichever
|
|
2316
|
+
// provider credentials are configured, so two installs sharing one
|
|
2317
|
+
// email but different provider sets are genuinely different accounts.
|
|
2318
|
+
const accountKey = buildIdentityKey(agentId, [['providers', identity.providers]]);
|
|
2319
|
+
return {
|
|
2320
|
+
...empty,
|
|
2321
|
+
signedIn: true,
|
|
2322
|
+
accountId: identity.providers,
|
|
2323
|
+
accountKey,
|
|
2324
|
+
email: identity.email,
|
|
2325
|
+
plan: identity.plan,
|
|
2326
|
+
lastActive,
|
|
2327
|
+
};
|
|
2255
2328
|
}
|
|
2256
2329
|
case 'muse': {
|
|
2257
2330
|
// Muse Code authenticates with META_API_KEY (env, highest priority) or
|
|
@@ -11,6 +11,17 @@ import { getAgentsBinPath } from '../cli-entry.js';
|
|
|
11
11
|
import type { ServiceHealth } from './service.js';
|
|
12
12
|
/** Health for every service currently registered on the live supervisor, or `null` if the daemon isn't running in this process. */
|
|
13
13
|
export declare function getServiceSupervisorHealth(): Record<string, ServiceHealth> | null;
|
|
14
|
+
/**
|
|
15
|
+
* The service-manager identifiers of the REAL install's daemon — never
|
|
16
|
+
* namespaced. A caller under a redirected HOME (a test/e2e harness) uses these
|
|
17
|
+
* to recognize the box's production daemon as owned, where
|
|
18
|
+
* `daemonSystemdUnitName()`/`daemonServiceLabel()` would name only its own
|
|
19
|
+
* sandbox job (W4, PHNX-3736).
|
|
20
|
+
*/
|
|
21
|
+
export declare function productionDaemonServiceNames(): {
|
|
22
|
+
systemdUnit: string;
|
|
23
|
+
launchdLabel: string;
|
|
24
|
+
};
|
|
14
25
|
/**
|
|
15
26
|
* RUSH-2639 (residual): launchd/systemd route `unload`/`load`/`list` by the
|
|
16
27
|
* service identifier ALONE, never by the plist/unit file's path. Baking the
|
|
@@ -96,7 +107,7 @@ export declare function singleShot(fn: () => Promise<void>): () => Promise<void>
|
|
|
96
107
|
*/
|
|
97
108
|
export declare function getDaemonLogPath(): string;
|
|
98
109
|
/** Read the stored daemon PID from disk. Returns null if not present or invalid. */
|
|
99
|
-
export declare function readDaemonPid(): number | null;
|
|
110
|
+
export declare function readDaemonPid(daemonDir?: string): number | null;
|
|
100
111
|
/** Write the daemon PID to the pid file. */
|
|
101
112
|
export declare function writeDaemonPid(pid: number): void;
|
|
102
113
|
/** Remove the daemon PID file. */
|
|
@@ -286,6 +297,28 @@ export declare function generateLaunchdPlist(agentsBin?: string): string;
|
|
|
286
297
|
*/
|
|
287
298
|
export declare function generateSystemdUnit(agentsBin?: string): string;
|
|
288
299
|
export { getAgentsBinPath };
|
|
300
|
+
/**
|
|
301
|
+
* Ask the service manager for the daemon's live PID. Used as a fallback when
|
|
302
|
+
* the daemon hasn't yet written its pid file but launchd/systemd already report
|
|
303
|
+
* it running — so a start never has to surface a null PID for a daemon that is
|
|
304
|
+
* in fact up. Returns null when the service isn't running or the query fails.
|
|
305
|
+
*
|
|
306
|
+
* `names` defaults to THIS process's (possibly sandbox-namespaced) job; a
|
|
307
|
+
* caller under a redirected HOME passes `productionDaemonServiceNames()` to
|
|
308
|
+
* ask after the real install's unit instead. Read-only either way.
|
|
309
|
+
*/
|
|
310
|
+
export declare function readServiceManagerPid(platform?: NodeJS.Platform, names?: {
|
|
311
|
+
systemdUnit: string;
|
|
312
|
+
launchdLabel: string;
|
|
313
|
+
}): number | null;
|
|
314
|
+
/**
|
|
315
|
+
* Thrown when a daemon LAUNCH is attempted under a redirected HOME without the
|
|
316
|
+
* explicit test opt-in (W4, PHNX-3736). `bootstrap.ts` prints the message
|
|
317
|
+
* without a stack — this is user-actionable, not an engineering bug.
|
|
318
|
+
*/
|
|
319
|
+
export declare class RedirectedHomeDaemonError extends Error {
|
|
320
|
+
name: string;
|
|
321
|
+
}
|
|
289
322
|
/** Start the daemon via launchd, systemd, or as a detached process. */
|
|
290
323
|
export declare function startDaemon(agentsBin?: string): {
|
|
291
324
|
pid: number | null;
|
|
@@ -77,6 +77,16 @@ const LOG_MAX_SIZE = 5 * 1024 * 1024; // 5 MB
|
|
|
77
77
|
const LOG_ROTATE_COUNT = 3;
|
|
78
78
|
const PLIST_NAME = 'com.phnx-labs.agents-daemon';
|
|
79
79
|
const SYSTEMD_UNIT = 'agents-daemon.service';
|
|
80
|
+
/**
|
|
81
|
+
* The service-manager identifiers of the REAL install's daemon — never
|
|
82
|
+
* namespaced. A caller under a redirected HOME (a test/e2e harness) uses these
|
|
83
|
+
* to recognize the box's production daemon as owned, where
|
|
84
|
+
* `daemonSystemdUnitName()`/`daemonServiceLabel()` would name only its own
|
|
85
|
+
* sandbox job (W4, PHNX-3736).
|
|
86
|
+
*/
|
|
87
|
+
export function productionDaemonServiceNames() {
|
|
88
|
+
return { systemdUnit: SYSTEMD_UNIT, launchdLabel: PLIST_NAME };
|
|
89
|
+
}
|
|
80
90
|
/**
|
|
81
91
|
* RUSH-2639 (residual): launchd/systemd route `unload`/`load`/`list` by the
|
|
82
92
|
* service identifier ALONE, never by the plist/unit file's path. Baking the
|
|
@@ -286,8 +296,8 @@ function getSystemdUnitPath() {
|
|
|
286
296
|
return path.join(os.homedir(), '.config', 'systemd', 'user', daemonSystemdUnitName());
|
|
287
297
|
}
|
|
288
298
|
/** Read the stored daemon PID from disk. Returns null if not present or invalid. */
|
|
289
|
-
export function readDaemonPid() {
|
|
290
|
-
const pidPath = getPidPath();
|
|
299
|
+
export function readDaemonPid(daemonDir) {
|
|
300
|
+
const pidPath = daemonDir ? path.join(daemonDir, PID_FILE) : getPidPath();
|
|
291
301
|
if (!fs.existsSync(pidPath))
|
|
292
302
|
return null;
|
|
293
303
|
try {
|
|
@@ -1655,19 +1665,27 @@ export { getAgentsBinPath };
|
|
|
1655
1665
|
* the daemon hasn't yet written its pid file but launchd/systemd already report
|
|
1656
1666
|
* it running — so a start never has to surface a null PID for a daemon that is
|
|
1657
1667
|
* in fact up. Returns null when the service isn't running or the query fails.
|
|
1668
|
+
*
|
|
1669
|
+
* `names` defaults to THIS process's (possibly sandbox-namespaced) job; a
|
|
1670
|
+
* caller under a redirected HOME passes `productionDaemonServiceNames()` to
|
|
1671
|
+
* ask after the real install's unit instead. Read-only either way.
|
|
1658
1672
|
*/
|
|
1659
|
-
function readServiceManagerPid(platform = os.platform()) {
|
|
1660
|
-
const
|
|
1661
|
-
|
|
1673
|
+
export function readServiceManagerPid(platform = os.platform(), names) {
|
|
1674
|
+
const resolved = names ?? { systemdUnit: daemonSystemdUnitName(), launchdLabel: daemonServiceLabel() };
|
|
1675
|
+
// The registration gate exists because a sandboxed process must not REGISTER
|
|
1676
|
+
// or tear down jobs in the real per-user service manager. Asking after an
|
|
1677
|
+
// explicitly named job is read-only — in particular the production unit from
|
|
1678
|
+
// a redirected-HOME caller (W4) — and mutates nothing, so it is let through.
|
|
1679
|
+
if (names === undefined && !serviceManagerRegistrationAllowed().allowed)
|
|
1662
1680
|
return null;
|
|
1663
1681
|
try {
|
|
1664
1682
|
if (platform === 'linux') {
|
|
1665
|
-
const out = execFileSync('systemctl', ['--user', 'show', '-p', 'MainPID', '--value',
|
|
1683
|
+
const out = execFileSync('systemctl', ['--user', 'show', '-p', 'MainPID', '--value', resolved.systemdUnit], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
1666
1684
|
const pid = parseInt(out, 10);
|
|
1667
1685
|
return !isNaN(pid) && pid > 0 ? pid : null;
|
|
1668
1686
|
}
|
|
1669
1687
|
if (platform === 'darwin') {
|
|
1670
|
-
const out = execFileSync('launchctl', ['list',
|
|
1688
|
+
const out = execFileSync('launchctl', ['list', resolved.launchdLabel], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
1671
1689
|
const m = out.match(/"PID"\s*=\s*(\d+)/);
|
|
1672
1690
|
if (m) {
|
|
1673
1691
|
const pid = parseInt(m[1], 10);
|
|
@@ -1678,12 +1696,47 @@ function readServiceManagerPid(platform = os.platform()) {
|
|
|
1678
1696
|
catch { /* not running / manager unavailable */ }
|
|
1679
1697
|
return null;
|
|
1680
1698
|
}
|
|
1699
|
+
/**
|
|
1700
|
+
* Thrown when a daemon LAUNCH is attempted under a redirected HOME without the
|
|
1701
|
+
* explicit test opt-in (W4, PHNX-3736). `bootstrap.ts` prints the message
|
|
1702
|
+
* without a stack — this is user-actionable, not an engineering bug.
|
|
1703
|
+
*/
|
|
1704
|
+
export class RedirectedHomeDaemonError extends Error {
|
|
1705
|
+
name = 'RedirectedHomeDaemonError';
|
|
1706
|
+
}
|
|
1707
|
+
/**
|
|
1708
|
+
* W4 (PHNX-3736): never LAUNCH a daemon under a redirected (sandbox/test) HOME
|
|
1709
|
+
* without an explicit opt-in. A daemon started there keeps its own pid file
|
|
1710
|
+
* under the temp home, so the real install's pid-file takeover can never see
|
|
1711
|
+
* it — the leaked `HOME=/tmp/pin-e2e-<pid>` daemon that ran 4+ days on
|
|
1712
|
+
* yosemite-s1 was launched exactly this way by a headless e2e session that
|
|
1713
|
+
* never cleaned up. RUSH-3021 closed this for `ensureDaemonStarted`'s
|
|
1714
|
+
* AUTO-start path but left the explicit `startDaemon()` open, which is the
|
|
1715
|
+
* path the e2e harness took.
|
|
1716
|
+
*
|
|
1717
|
+
* Placed after the `already-running` early-return: reporting a live daemon (so
|
|
1718
|
+
* `daemon stop` can still kill a leaked one) must stay possible under any
|
|
1719
|
+
* HOME. `AGENTS_ALLOW_TEST_DAEMON=1` is the deliberate test/e2e seam — a
|
|
1720
|
+
* harness that sets it owns stopping what it starts.
|
|
1721
|
+
*/
|
|
1722
|
+
function assertDaemonLaunchHomeAllowed() {
|
|
1723
|
+
const suffix = isolatedHomeSuffix();
|
|
1724
|
+
if (!suffix)
|
|
1725
|
+
return;
|
|
1726
|
+
if (process.env.AGENTS_ALLOW_TEST_DAEMON === '1')
|
|
1727
|
+
return;
|
|
1728
|
+
throw new RedirectedHomeDaemonError(`refusing to start the daemon under a redirected HOME (sandbox-${suffix}): ` +
|
|
1729
|
+
`a daemon launched here keeps its own pid file under ${process.env.HOME}, invisible to the real ` +
|
|
1730
|
+
`install's pid-file takeover, and outlives whatever launched it (PHNX-3736). ` +
|
|
1731
|
+
`For a deliberate test/e2e launch set AGENTS_ALLOW_TEST_DAEMON=1 — and stop the daemon when done.`);
|
|
1732
|
+
}
|
|
1681
1733
|
/** Start the daemon via launchd, systemd, or as a detached process. */
|
|
1682
1734
|
export function startDaemon(agentsBin) {
|
|
1683
1735
|
if (isDaemonRunning()) {
|
|
1684
1736
|
const pid = readDaemonPid();
|
|
1685
1737
|
return { pid, method: 'already-running' };
|
|
1686
1738
|
}
|
|
1739
|
+
assertDaemonLaunchHomeAllowed();
|
|
1687
1740
|
const releaseLock = acquireStartLock();
|
|
1688
1741
|
if (!releaseLock) {
|
|
1689
1742
|
// Another process is already starting the daemon
|
|
@@ -1769,8 +1822,9 @@ export function ensureDaemonStarted() {
|
|
|
1769
1822
|
// recursive teardown rm (ENOTEMPTY). Placed after the already-running branch
|
|
1770
1823
|
// — reporting a live daemon stays allowed, same as the circuit breaker.
|
|
1771
1824
|
// AGENTS_SERVICE_MANAGER_ALLOW_REDIRECTED_HOME=1 is the test seam for suites
|
|
1772
|
-
// that exercise daemon startup deliberately
|
|
1773
|
-
//
|
|
1825
|
+
// that exercise daemon startup deliberately. The explicit `agents daemon
|
|
1826
|
+
// start` path is gated separately by startDaemon's own redirected-HOME
|
|
1827
|
+
// refusal (AGENTS_ALLOW_TEST_DAEMON=1, W4/PHNX-3736).
|
|
1774
1828
|
if (!serviceManagerRegistrationAllowed().allowed)
|
|
1775
1829
|
return null;
|
|
1776
1830
|
// RUSH-2418: the auto-start circuit breaker. A daemon that dies during
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daemon state dir for an ARBITRARY home — the same layout state.ts's
|
|
3
|
+
* DAEMON_DIR chain produces (`<home>/.agents/.cache/helpers/daemon`). A caller
|
|
4
|
+
* under a redirected HOME (a test/e2e harness) uses this to find the REAL
|
|
5
|
+
* install's daemon records. Kept here rather than exported from state.ts: any
|
|
6
|
+
* edit to state.ts selects its entire static-import closure into the required
|
|
7
|
+
* impact gate (186 files, 295s against the 240s budget, measured on this
|
|
8
|
+
* change's first CI run), so the helper cannot live there without a
|
|
9
|
+
* budget-policy change. If state.ts's layout ever moves, this must move with
|
|
10
|
+
* it — the two are the same address.
|
|
11
|
+
*/
|
|
12
|
+
export declare function getDaemonDirForHome(home: string): string;
|
|
13
|
+
/** One live `__daemon-run` process from the box-wide `ps` scan. */
|
|
14
|
+
export interface DaemonRunProcess {
|
|
15
|
+
pid: number;
|
|
16
|
+
/** Owning uid from `ps`, or null when unavailable. */
|
|
17
|
+
uid: number | null;
|
|
18
|
+
/** Whitespace-tokenized argv (`ps` renders it unquoted). */
|
|
19
|
+
tokens: string[];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Every live `__daemon-run` process on this box, regardless of which install
|
|
23
|
+
* launched it or which state dir it serves. POSIX-only (uses `ps`); a no-op on
|
|
24
|
+
* Windows.
|
|
25
|
+
*
|
|
26
|
+
* `getDaemonLaunch` always spawns `<node> <entry> __daemon-run` with nothing
|
|
27
|
+
* after it — the ONLY argv `__daemon-run` ever appears in for a real daemon.
|
|
28
|
+
* A substring/regex test anywhere in the full command line is not enough: an
|
|
29
|
+
* `agents run claude "<prompt>"` invocation whose prompt happens to quote the
|
|
30
|
+
* literal text `__daemon-run` (this ticket's own brief does) matches that test
|
|
31
|
+
* too, and was observed producing false "duplicate daemon" rows. Requiring it
|
|
32
|
+
* to be the LAST whitespace-delimited token is the actual invariant.
|
|
33
|
+
*
|
|
34
|
+
* This raw box-wide scan is deliberately NOT the duplicate-detection scope
|
|
35
|
+
* (RUSH-2368): a `__daemon-run` under a different HOME serves a different
|
|
36
|
+
* `getDaemonDir()` and is not a duplicate of THIS device's daemon, however
|
|
37
|
+
* `ps` sees it — a leaked vitest fixture under its own `/tmp` HOME matched
|
|
38
|
+
* this scan and was reported as a stray to `kill`. Callers attach their own
|
|
39
|
+
* scope: `agents daemon status` gates on the instance registry,
|
|
40
|
+
* {@link findLeakedDaemons} on the owner records above.
|
|
41
|
+
*/
|
|
42
|
+
export declare function listDaemonRunProcesses(): DaemonRunProcess[];
|
|
43
|
+
/** A `__daemon-run` process no owner record names. */
|
|
44
|
+
export interface LeakedDaemon {
|
|
45
|
+
pid: number;
|
|
46
|
+
/** The HOME the process runs under, or null when it cannot be read. */
|
|
47
|
+
home: string | null;
|
|
48
|
+
/** The process's start time as `ps lstart` renders it, or null when unavailable. */
|
|
49
|
+
startedAt: string | null;
|
|
50
|
+
/** The launch entry (the argv token before `__daemon-run`), best-effort. */
|
|
51
|
+
entry: string | null;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Every `__daemon-run` process running as THIS uid that no owner record names:
|
|
55
|
+
* neither the service manager's unit main PID nor the recorded
|
|
56
|
+
* `<daemonDir>/daemon.pid`. Each result carries the process's HOME and start
|
|
57
|
+
* time so the report can show them. An empty list means no leak — including
|
|
58
|
+
* the common stopped-daemon case, where there is simply nothing running.
|
|
59
|
+
*/
|
|
60
|
+
export declare function findLeakedDaemons(): LeakedDaemon[];
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Leaked-daemon detection (W4, PHNX-3736).
|
|
3
|
+
*
|
|
4
|
+
* One daemon per device is the contract: the always-on process behind
|
|
5
|
+
* `agents __daemon-run` is either the service manager's unit main PID or the
|
|
6
|
+
* pid recorded in `<daemonDir>/daemon.pid`. Anything else running
|
|
7
|
+
* `__daemon-run` as this uid is a LEAK — a daemon nothing owns.
|
|
8
|
+
*
|
|
9
|
+
* The motivating incident ran 4+ days on yosemite-s1: a headless e2e session
|
|
10
|
+
* launched a daemon under `HOME=/tmp/pin-e2e-<pid>` and never stopped it. The
|
|
11
|
+
* pid-file takeover in `daemon.ts` could not see it because it keeps its own
|
|
12
|
+
* pid file under the temp home, and `agents daemon status`'s duplicate scope
|
|
13
|
+
* (`findSurvivingStateDirDaemons`, RUSH-2368) deliberately covers only this
|
|
14
|
+
* install's state dir — so the leak was invisible to every existing surface.
|
|
15
|
+
*
|
|
16
|
+
* Why flagging a different-HOME daemon here is not the RUSH-2368 harm: that
|
|
17
|
+
* incident taught that a `__daemon-run` under another HOME is not a DUPLICATE
|
|
18
|
+
* of this device's daemon and must never be told to `kill` from the duplicate
|
|
19
|
+
* path. This module does not accuse it of being a duplicate — it reports that
|
|
20
|
+
* NO owner record (unit main PID, recorded daemon.pid) names the pid at all,
|
|
21
|
+
* and shows the process's own HOME and start time so the operator can judge
|
|
22
|
+
* before acting. A different uid is never named: we cannot inspect its
|
|
23
|
+
* environment or signal it.
|
|
24
|
+
*/
|
|
25
|
+
import { execFileSync } from 'child_process';
|
|
26
|
+
import * as fs from 'fs';
|
|
27
|
+
import * as os from 'os';
|
|
28
|
+
import * as path from 'path';
|
|
29
|
+
import { productionDaemonServiceNames, readDaemonPid, readServiceManagerPid } from './daemon.js';
|
|
30
|
+
import { getDaemonDir } from '../state.js';
|
|
31
|
+
/**
|
|
32
|
+
* The daemon state dir for an ARBITRARY home — the same layout state.ts's
|
|
33
|
+
* DAEMON_DIR chain produces (`<home>/.agents/.cache/helpers/daemon`). A caller
|
|
34
|
+
* under a redirected HOME (a test/e2e harness) uses this to find the REAL
|
|
35
|
+
* install's daemon records. Kept here rather than exported from state.ts: any
|
|
36
|
+
* edit to state.ts selects its entire static-import closure into the required
|
|
37
|
+
* impact gate (186 files, 295s against the 240s budget, measured on this
|
|
38
|
+
* change's first CI run), so the helper cannot live there without a
|
|
39
|
+
* budget-policy change. If state.ts's layout ever moves, this must move with
|
|
40
|
+
* it — the two are the same address.
|
|
41
|
+
*/
|
|
42
|
+
export function getDaemonDirForHome(home) {
|
|
43
|
+
return path.join(home, '.agents', '.cache', 'helpers', 'daemon');
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Every live `__daemon-run` process on this box, regardless of which install
|
|
47
|
+
* launched it or which state dir it serves. POSIX-only (uses `ps`); a no-op on
|
|
48
|
+
* Windows.
|
|
49
|
+
*
|
|
50
|
+
* `getDaemonLaunch` always spawns `<node> <entry> __daemon-run` with nothing
|
|
51
|
+
* after it — the ONLY argv `__daemon-run` ever appears in for a real daemon.
|
|
52
|
+
* A substring/regex test anywhere in the full command line is not enough: an
|
|
53
|
+
* `agents run claude "<prompt>"` invocation whose prompt happens to quote the
|
|
54
|
+
* literal text `__daemon-run` (this ticket's own brief does) matches that test
|
|
55
|
+
* too, and was observed producing false "duplicate daemon" rows. Requiring it
|
|
56
|
+
* to be the LAST whitespace-delimited token is the actual invariant.
|
|
57
|
+
*
|
|
58
|
+
* This raw box-wide scan is deliberately NOT the duplicate-detection scope
|
|
59
|
+
* (RUSH-2368): a `__daemon-run` under a different HOME serves a different
|
|
60
|
+
* `getDaemonDir()` and is not a duplicate of THIS device's daemon, however
|
|
61
|
+
* `ps` sees it — a leaked vitest fixture under its own `/tmp` HOME matched
|
|
62
|
+
* this scan and was reported as a stray to `kill`. Callers attach their own
|
|
63
|
+
* scope: `agents daemon status` gates on the instance registry,
|
|
64
|
+
* {@link findLeakedDaemons} on the owner records above.
|
|
65
|
+
*/
|
|
66
|
+
export function listDaemonRunProcesses() {
|
|
67
|
+
if (process.platform === 'win32')
|
|
68
|
+
return [];
|
|
69
|
+
let out;
|
|
70
|
+
try {
|
|
71
|
+
out = execFileSync('ps', ['-eo', 'pid=,uid=,args='], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
const found = [];
|
|
77
|
+
for (const line of out.split('\n')) {
|
|
78
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
|
|
79
|
+
if (!m)
|
|
80
|
+
continue;
|
|
81
|
+
const tokens = m[3].trim().split(/\s+/);
|
|
82
|
+
if (tokens.length === 0 || tokens[tokens.length - 1] !== '__daemon-run')
|
|
83
|
+
continue;
|
|
84
|
+
const pid = parseInt(m[1], 10);
|
|
85
|
+
if (isNaN(pid))
|
|
86
|
+
continue;
|
|
87
|
+
const uidParsed = parseInt(m[2], 10);
|
|
88
|
+
found.push({ pid, uid: isNaN(uidParsed) ? null : uidParsed, tokens });
|
|
89
|
+
}
|
|
90
|
+
return found;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The HOME of a live process, or null when unreadable. Linux reads
|
|
94
|
+
* `/proc/<pid>/environ`; macOS has no equivalent zero-dependency primitive, so
|
|
95
|
+
* it falls back to `ps -E`, which appends the environment to the command
|
|
96
|
+
* column. Both are best-effort: a null HOME must never turn into an accusation
|
|
97
|
+
* beyond "unknown".
|
|
98
|
+
*/
|
|
99
|
+
function processHome(pid) {
|
|
100
|
+
if (process.platform === 'linux') {
|
|
101
|
+
try {
|
|
102
|
+
const env = fs.readFileSync(`/proc/${pid}/environ`, 'utf-8');
|
|
103
|
+
for (const entry of env.split('\0')) {
|
|
104
|
+
if (entry.startsWith('HOME='))
|
|
105
|
+
return entry.slice('HOME='.length) || null;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const out = execFileSync('ps', ['-E', '-o', 'command=', '-p', String(pid)], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
115
|
+
const m = out.match(/(?:^|\s)HOME=(\S+)/);
|
|
116
|
+
return m ? m[1] : null;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** The start time of a live process as `ps lstart` renders it, or null (best-effort, POSIX only). */
|
|
123
|
+
function processStartTime(pid) {
|
|
124
|
+
if (process.platform === 'win32')
|
|
125
|
+
return null;
|
|
126
|
+
try {
|
|
127
|
+
const out = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
128
|
+
return out || null;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Every `__daemon-run` process running as THIS uid that no owner record names:
|
|
136
|
+
* neither the service manager's unit main PID nor the recorded
|
|
137
|
+
* `<daemonDir>/daemon.pid`. Each result carries the process's HOME and start
|
|
138
|
+
* time so the report can show them. An empty list means no leak — including
|
|
139
|
+
* the common stopped-daemon case, where there is simply nothing running.
|
|
140
|
+
*/
|
|
141
|
+
export function findLeakedDaemons() {
|
|
142
|
+
const owned = new Set();
|
|
143
|
+
const recorded = readDaemonPid();
|
|
144
|
+
if (recorded)
|
|
145
|
+
owned.add(recorded);
|
|
146
|
+
const unitPid = readServiceManagerPid();
|
|
147
|
+
if (unitPid)
|
|
148
|
+
owned.add(unitPid);
|
|
149
|
+
// A caller under a redirected HOME (a test/e2e harness) must still recognize
|
|
150
|
+
// the REAL install's daemon as owned: its records live under the account
|
|
151
|
+
// home (`os.userInfo().homedir` reads the passwd record, ignoring $HOME),
|
|
152
|
+
// not under this process's HOME. Flagging the box's healthy production
|
|
153
|
+
// daemon as a leak would be the RUSH-2368 harm through a new door.
|
|
154
|
+
const realDaemonDir = getDaemonDirForHome(os.userInfo().homedir);
|
|
155
|
+
if (realDaemonDir !== getDaemonDir()) {
|
|
156
|
+
const realRecorded = readDaemonPid(realDaemonDir);
|
|
157
|
+
if (realRecorded)
|
|
158
|
+
owned.add(realRecorded);
|
|
159
|
+
const realUnitPid = readServiceManagerPid(os.platform(), productionDaemonServiceNames());
|
|
160
|
+
if (realUnitPid)
|
|
161
|
+
owned.add(realUnitPid);
|
|
162
|
+
}
|
|
163
|
+
const myUid = typeof process.getuid === 'function' ? process.getuid() : null;
|
|
164
|
+
const leaked = [];
|
|
165
|
+
for (const p of listDaemonRunProcesses()) {
|
|
166
|
+
if (owned.has(p.pid))
|
|
167
|
+
continue;
|
|
168
|
+
// Another uid's daemon is never named: we cannot read its environment
|
|
169
|
+
// reliably and could not signal it if we tried.
|
|
170
|
+
if (myUid !== null && p.uid !== null && p.uid !== myUid)
|
|
171
|
+
continue;
|
|
172
|
+
leaked.push({
|
|
173
|
+
pid: p.pid,
|
|
174
|
+
home: processHome(p.pid),
|
|
175
|
+
startedAt: processStartTime(p.pid),
|
|
176
|
+
entry: p.tokens.length >= 2 ? p.tokens[p.tokens.length - 2] : null,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return leaked;
|
|
180
|
+
}
|