@phnx-labs/agents-cli 1.20.46 → 1.20.47
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 +9 -0
- package/dist/commands/secrets-import.d.ts +18 -0
- package/dist/commands/secrets-import.js +74 -0
- package/dist/commands/secrets.js +2 -0
- package/dist/lib/daemon.js +31 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/secrets/fallback.d.ts +48 -0
- package/dist/lib/secrets/fallback.js +48 -0
- package/dist/lib/secrets/index.d.ts +11 -0
- package/dist/lib/secrets/index.js +20 -2
- package/dist/lib/secrets/linux.d.ts +7 -0
- package/dist/lib/secrets/linux.js +113 -5
- package/dist/lib/secrets/windows.d.ts +7 -0
- package/dist/lib/secrets/windows.js +110 -5
- package/dist/lib/session/discover.js +41 -0
- package/dist/lib/versions.d.ts +16 -0
- package/dist/lib/versions.js +83 -12
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 1.20.47
|
|
6
|
+
|
|
7
|
+
- **Quick-issue bar (`Cmd-Shift-O`): `Cmd-V` now pastes into the note field, and double-clicking a screenshot thumbnail opens it in Preview.** Two fixes from dogfooding the new bar. (1) The panel is a borderless `.accessory` window with **no main menu**, so the standard clipboard key-equivalents (`Cmd-V`/`C`/`X`/`A`) were never dispatched to the field editor — paste silently did nothing. `PromptPanel.performKeyEquivalent` now routes them through the responder chain so the text field handles them. (2) Thumbnails are small, so there was no way to confirm which screenshot you were attaching: **single click still toggles selection, double click opens the full image in the default viewer (Preview)**. The single-click toggle is deferred by the double-click interval so a double-click previews without also flipping the selection, and the bar suppresses its own click-outside dismissal while Preview takes focus (so summoning Preview never closes the bar or drops your typed note; it re-arms when the bar regains focus). Source: `apps/cli/menubar/Sources/MenubarHelper/PromptPanel.swift`.
|
|
8
|
+
- **Fix: the headless file-store fallback no longer silently shadows the OS keyring; NEW `agents secrets import-keyring` migrates stranded secrets into it.** On headless Linux/Windows the encrypted-file store is *sticky* — once any item is on disk, `preflight()` routed **every** op to the file store and never consulted GNOME Keyring / Windows Credential Manager again, so a secret written earlier into the native store (e.g. while a desktop keyring was unlocked) read back **empty** with no hint. This stranded real Linear CLI credentials in a locked keyring while other bundles lived in the file store, silently breaking the SessionStart hook. Two fixes: (1) `get`/`has` now **read through** to the native store on a file-store *miss* (the fast path and the non-fallback keychain-first path are untouched — the file store is still checked first), emitting a one-time stderr notice pointing at `import-keyring`; once a locked/`1312` error is seen the store is marked unreachable so it stops re-probing a known-dead store. (2) NEW **`agents secrets import-keyring`** — the Linux/Windows analogue of the macOS `migrate-acl`/orphan sweep — enumerates `agents-cli` items in the native store and copies them into the encrypted file store (the durable, passwordless headless backend). Dry-run by default; `--commit` writes; existing file-store items are never overwritten; Windows enumeration is floored to the `agents-cli.` namespace since Credential Manager targets have no service scoping. macOS is unaffected (it has no file fallback and keeps `migrate-acl`). Source: `apps/cli/src/lib/secrets/{fallback,linux,windows,index}.ts`, `apps/cli/src/commands/{secrets-import,secrets}.ts`, `apps/cli/docs/secrets.md`.
|
|
9
|
+
- **Launch-health self-heal now covers Windows, and the daemon repairs a gutted install proactively — before your next `agents run`.** #764 gave `agents run` an install/run-time self-heal (probe `<binary> --version`; clean-reinstall in place, else fall back to another installed version that launches), but it **skipped the probe on Windows** — `verifyInstalledBinaryLaunches` returned healthy on `win32` unconditionally, because probing the extensionless `.bin/<cli>` wrapper would ENOENT even on a *healthy* install. So the exact Windows failure the self-heal was built for went unhealed: a vendor auto-update renames the native `claude.exe` to `claude.exe.old.<epochMs>` and never lands the replacement, leaving the shim chain intact but pointing at a missing file, and every launch dies with `'…claude.exe' is not recognized`. The probe now runs on Windows against the **real launch target** — the npm `.cmd` wrapper `agents run` actually execs (`getBinaryPath + '.cmd'`, resolved via `cmd.exe`), which chains to the native `.exe` — so a gutted install trips the existing missing-binary signature (`is not recognized`) and is repaired by the same `ensureAgentRunnable` machinery; a missing `.cmd` (a non-npm/global agent like `droid.exe`) is still treated as healthy so a good install is never destroyed. Separately, the **daemon** now runs a proactive launch-health pass (`healBrokenDefaultLaunches`) ~90s after startup and every ~6h: it probes each agent's default version and, if it won't launch, repairs it in the background — so a gutted install is fixed *before* the next `agents run` hits the ENOENT, not at spawn time (the run-time `ensureAgentRunnable` only fires once a run is already starting). Verified end-to-end on a real Windows host: renaming `claude.exe` to `.old` makes the `.cmd` probe emit `is not recognized`; restoring it returns `2.1.191 (Claude Code)`. Source: `apps/cli/src/lib/versions.ts` (`verifyInstalledBinaryLaunches`, `healBrokenDefaultLaunches`), `apps/cli/src/lib/daemon.ts`.
|
|
10
|
+
|
|
11
|
+
## 1.20.45
|
|
12
|
+
|
|
5
13
|
## 1.20.46
|
|
6
14
|
|
|
7
15
|
- **NEW: `Cmd-Shift-O` opens a Spotlight-style quick-issue bar in the menu-bar helper — type a sentence, attach recent screenshots, and an agent files the Linear ticket for you.** The menu-bar helper already turned a screenshot into a `<host>:<path>` token with `Cmd-Shift-V` (clip capture), but there was no path from "I see a bug" to "a triaged ticket exists." The new chord summons a borderless panel (a thin capture surface, not another form): you type a one-line note, optionally toggle one or more recent screenshots (from the system screencapture folder, CleanShot's export path, or the clip history) as a thumbnail strip (the newest is pre-selected when it's fresh), and hit Return. It then **dispatches a headless agent** (`agents run claude --mode auto`, isolated behind one `AgentsCLI.dispatchTicketAgent` call so a cloud pod is a later swap) that reads the screenshots, runs `agents sessions` to identify which repo/project this concerns, does a brief investigation for real context, and files the ticket via `~/.agents/skills/linear/scripts/linear create` with an honest priority + a `repo:<name>` label — no preview step, the panel closes immediately and a notification reports the created `RUSH-####`. Focus is handled for a no-Dock `.accessory` app (`NSApp.activate` → `makeKeyAndOrderFront` → `makeFirstResponder`, with a borderless `NSPanel` overriding `canBecomeKey`; click-outside dismissal is armed only after the summon settles so the activation race can't self-dismiss the panel). The `Cmd-Shift-V` clip hotkey is unchanged — the Carbon hotkey manager now demultiplexes both chords by `EventHotKeyID.id` through one installed handler. Self-test: `MENUBAR_ISSUE_TEST=1 MenubarHelper` exercises screenshot selection, ticket-id parsing, and the meta-prompt contract; `MENUBAR_PROMPT_PREVIEW=1` renders the panel without the global hotkey for QA. Source: `apps/cli/menubar/Sources/MenubarHelper/{PromptPanel,Hotkey,AgentsCLI,main,IssueSelfTest,Clip}.swift`.
|
|
@@ -9,6 +17,7 @@
|
|
|
9
17
|
- **NEW: a unified self-heal subsystem — the shim/PATH "repair" notice no longer nags on every terminal, and the daemon now heals shim drift in the background.** agents-cli had accumulated ~37 separate repair routines scattered across the daemon, every CLI startup, and a handful of commands, each hand-rolling its own detect+fix on its own trigger. The most visible symptom: the interactive shim bootstrap (`maybeBootstrapShimIntegration`) regenerated shims, adopted shadowing launchers, and offered to add the shims dir to PATH **in the foreground on every invocation**, suppressed only by a `process.ppid`-keyed temp sentinel — so a new terminal re-ran the whole detect-and-nag, and the underlying condition was never permanently fixed. This lands a single `HealCheck` registry (`lib/self-heal/`) with one runner (`runSelfHeal`) driven by two front doors — the daemon (on its existing ~30s-after-start + ~6h `safe`-mode cycle) and the interactive startup — sharing the same checks: `shims` (regenerate stale shims/aliases), `shadowing` (adopt symlink launchers; report real-binary shadows), `path` (add the shims dir to PATH once), and `resources` (the existing `heal()` engine, wrapped unchanged). The daemon's heal cycle now runs all four in `safe` mode (low-risk fixes silently; risky ones reported), replacing the resource-only `heal()` call — and drops the desktop toast for background heals (the log is the record). The interactive startup now heals **silently** and prints at most a **persistent, once-per-condition** notice (`lib/shim-heal.ts`, keyed to a signature of the actionable state under `~/.agents/.cache/state/shim-notice.json`) for what a machine genuinely can't fix for you — a real native binary shadowing the shim — instead of re-nagging every shell. What changes is *where* the repairs run (background/silent) and *how often* you hear about them (once, not every terminal). Source: `apps/cli/src/lib/self-heal/` (new), `apps/cli/src/lib/shim-heal.ts` (new), `apps/cli/src/lib/daemon.ts`, `apps/cli/src/index.ts`, `apps/cli/src/lib/shims.ts` (`isShimCurrent` exported).
|
|
10
18
|
## 1.20.45
|
|
11
19
|
- **NEW: `agents run <agent> --host <name>` without a prompt forwards your TTY over SSH and runs the agent interactively on the remote host.** Previously `--host` runs required a prompt and were always headless (`agents run <agent> "<task>" --host <name>`). Now, omitting the prompt takes the interactive path: when local stdin is a TTY, the local CLI SSHes with `-tt`, runs `agents run <agent>` on the host, and lets the remote machine's `agents` start its normal tmux wrapper. The tmux session lives on the remote box, so detaching (`Ctrl-b d`) ends the SSH connection but keeps the agent running; you can reattach from the host or resume by session id. Session ids for Claude are still minted up front so `agents sessions` can surface and resolve the remote run. `--no-follow` is rejected for interactive host runs (it is meaningless for an attached TTY), and `--mode`, `--model`, `--name`, passthrough args after `--`, and `--raw`/`--no-tmux` are forwarded to the remote invocation. Source: `apps/cli/src/commands/exec.ts`, `apps/cli/src/lib/hosts/dispatch.ts`, `apps/cli/src/lib/hosts/session-index.ts`, `apps/cli/docs/hosts.md`.
|
|
20
|
+
|
|
12
21
|
- **`agents secrets export --host` now works against Windows targets, and a new `agents secrets unlock --host` unlocks a bundle on a remote machine.** The export push was POSIX-only (`bash -lc`, `--from /dev/stdin`, `create … || true`, `IFS= read`), so a Windows remote died with `'true' is not recognized … cannot find the path specified`. Two changes fix it: `agents secrets import` now accepts **`--from -`** (read the `.env` from stdin, replacing the POSIX-only `/dev/stdin`), and the push is **platform-aware** — `bash -lc` on POSIX, `powershell -EncodedCommand` on Windows, with the target's OS taken from the device registry. Because the npm `agents.ps1` shim does **not** forward ssh-piped stdin to the underlying node process (a raw `--from -` read hangs), the Windows keychain push bridges the piped `.env` through PowerShell into a temp file and imports `--from <file>` (deleted afterwards). File-backend export to a Windows target is refused cleanly rather than emitting broken PowerShell. Verified end-to-end: `agents secrets export linear.app --host win-mini` imported all 13 keys. Separately, **`agents secrets unlock --host <machine> <bundle>`** runs the unlock ON the remote over `ssh -tt`, so a **file-backed** bundle's passphrase prompt surfaces on your terminal — the "unlock the Mac from the road with its password" path; keychain/biometry bundles are GUI-only (a local Touch-ID/passcode sheet can't cross SSH) and can't be remote-unlocked. `unlock`'s `--host` is single-valued so it never swallows the positional bundle name. Source: `apps/cli/src/commands/secrets.ts`, `apps/cli/src/lib/hosts/remote-cmd.ts`.
|
|
13
22
|
- **A session now has ONE name, not two. `--name` seeds the session label instead of a parallel column.** Shipping `agents run --name` (1.20.43) as a separate immutable `name` column created two look-alike fields — an unshown, frozen `name` and the shown, searchable `label` — that both resolved `agents sessions <ref>` and forced tie-break bookkeeping nobody could keep straight. They unify into one field. `--name` is now the universal way to *seed* the `label` at launch — the same field an agent-generated title (Claude's `/rename`) later refines and `agents sessions` displays and searches — and it works consistently across interactive, headless, `--host`, and teams teammate runs (a teammate's friendly name now seeds its session label; before, teammate sessions had no name at all). Priority is a plain fallback chain resolved at scan time, no stored winner: an agent-generated title wins, else the `--name` seed, else the listing falls back to `topic`. So a Claude run's `--name` shows until Claude titles it (your seed, then refined); a non-Claude run keeps its `--name` as the label (it has no auto-title). The seeded name is now fuzzy-searchable in FTS (the old `name` column was not). `agents hosts logs <name>` is unchanged — it resolves against the host-task sidecar, not the session column. Schema v10 folds any existing `name` into `label` (where the label was empty), mirrors it into the FTS row, then drops the `name` column; the run-name sidecars re-seed every scan (`seedLabelsFromNames`), so no rescan is needed. Reworks the 1.20.43 `--name` design (partly reverts its separate-column approach). Source: `apps/cli/src/lib/session/{db,discover,run-names,types}.ts`, `apps/cli/src/lib/hosts/session-index.ts`, `apps/cli/src/lib/teams/agents.ts`, `apps/cli/src/commands/exec.ts`, `apps/cli/docs/{05-sessions,hosts}.md`.
|
|
14
23
|
- **NEW: `agents teams add`/`start` warns when a *version-pinned* teammate is on a throttled or signed-out account.** The 1.20.43 `balanced`-default fix keeps *bare* teammates off rate-limited accounts (they route through bare `agents run`, which rotates), but a **version-pinned** (`claude@2.1.112`) or **profile** teammate spawns `agents run <agent>@<version>` / `agents run <profile>`, and a pin/profile deliberately *bypasses* rotation — so it would launch straight onto a maxed account and 429 on the first request, with no mid-run failover either (that only arms when a non-pinned strategy actually rotated). `agents teams add` (at add time) and `agents teams start` (per staged teammate, deduped by `agent@version`) now pre-check a **version-pinned** teammate's account and print an advisory when it's rate-limited, out of credits, or not signed in — reusing the router's *exact* eligibility gate (`checkRunAccountReadiness` → `hasUsageAvailable`, the same session-inclusive signal the `agents view` badge uses), so the warning can never disagree with what the spawn would actually do. It **warns, never blocks** (mirroring the existing "may not be signed in" advisory); `--force` silences it. Scoped to version-pinned teammates on purpose: bare teammates are already handled by rotation, and a profile injects its own auth (a different account than the version home carries) that isn't locally checkable — so no unreliable profile warning is emitted. Source: `apps/cli/src/lib/rotate.ts` (`readinessFromCandidate`, `checkRunAccountReadiness`, `rotate.test.ts`), `apps/cli/src/commands/teams.ts`.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents secrets import-keyring` — migrate agents-cli secrets out of the native
|
|
3
|
+
* credential store (GNOME Keyring / Windows Credential Manager) and into the
|
|
4
|
+
* encrypted file store.
|
|
5
|
+
*
|
|
6
|
+
* Why: on headless Linux/Windows the file store is the durable, passwordless
|
|
7
|
+
* backend, but secrets written earlier (e.g. while a desktop keyring was
|
|
8
|
+
* unlocked) can linger in the native store where a headless session can't reach
|
|
9
|
+
* them. This is the Linux/Windows analogue of the macOS `migrate-acl` /
|
|
10
|
+
* orphan sweep. Dry-run by default; `--commit` performs the copy.
|
|
11
|
+
*
|
|
12
|
+
* Requires the native store to be reachable/unlocked — a locked keyring can't be
|
|
13
|
+
* read, so unlock it first (or the values are already only in the file store and
|
|
14
|
+
* there is nothing to do).
|
|
15
|
+
*/
|
|
16
|
+
import type { Command } from 'commander';
|
|
17
|
+
/** Register `agents secrets import-keyring` on the parent secrets Command. */
|
|
18
|
+
export declare function registerSecretsImportKeyringCommand(secrets: Command): void;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents secrets import-keyring` — migrate agents-cli secrets out of the native
|
|
3
|
+
* credential store (GNOME Keyring / Windows Credential Manager) and into the
|
|
4
|
+
* encrypted file store.
|
|
5
|
+
*
|
|
6
|
+
* Why: on headless Linux/Windows the file store is the durable, passwordless
|
|
7
|
+
* backend, but secrets written earlier (e.g. while a desktop keyring was
|
|
8
|
+
* unlocked) can linger in the native store where a headless session can't reach
|
|
9
|
+
* them. This is the Linux/Windows analogue of the macOS `migrate-acl` /
|
|
10
|
+
* orphan sweep. Dry-run by default; `--commit` performs the copy.
|
|
11
|
+
*
|
|
12
|
+
* Requires the native store to be reachable/unlocked — a locked keyring can't be
|
|
13
|
+
* read, so unlock it first (or the values are already only in the file store and
|
|
14
|
+
* there is nothing to do).
|
|
15
|
+
*/
|
|
16
|
+
import chalk from 'chalk';
|
|
17
|
+
import { importNativeItems } from '../lib/secrets/index.js';
|
|
18
|
+
/** Register `agents secrets import-keyring` on the parent secrets Command. */
|
|
19
|
+
export function registerSecretsImportKeyringCommand(secrets) {
|
|
20
|
+
secrets
|
|
21
|
+
.command('import-keyring')
|
|
22
|
+
.description('Migrate agents-cli secrets from the OS keyring / Credential Manager into the encrypted file store (headless-safe). Dry-run by default.')
|
|
23
|
+
.option('--commit', 'Perform the import (default is dry-run reporting only)')
|
|
24
|
+
.option('--prefix <p>', 'Only import items beginning with PREFIX (default: all agents-cli items)')
|
|
25
|
+
.action((opts) => {
|
|
26
|
+
try {
|
|
27
|
+
if (process.platform === 'darwin') {
|
|
28
|
+
throw new Error('import-keyring is for the Linux/Windows file-store fallback. On macOS use `agents secrets migrate-acl`.');
|
|
29
|
+
}
|
|
30
|
+
const commit = !!opts.commit;
|
|
31
|
+
const report = importNativeItems(opts.prefix ?? '', commit);
|
|
32
|
+
if (!report.available) {
|
|
33
|
+
console.log(chalk.gray('No native credential tooling found (secret-tool / PowerShell) — nothing to import.'));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (report.locked) {
|
|
37
|
+
console.error(chalk.yellow('The native credential store is locked/unreachable, so its secrets can\'t be read. ' +
|
|
38
|
+
'Unlock it and retry (a locked store can\'t be migrated).'));
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
if (report.results.length === 0) {
|
|
42
|
+
console.log(chalk.green('Nothing to import — no native secrets outside the file store.'));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const imported = report.results.filter((r) => r.status === 'imported' || r.status === 'would-import');
|
|
46
|
+
const existing = report.results.filter((r) => r.status === 'exists');
|
|
47
|
+
const failed = report.results.filter((r) => r.status === 'failed');
|
|
48
|
+
for (const r of report.results) {
|
|
49
|
+
if (r.status === 'imported')
|
|
50
|
+
console.log(` ${chalk.green('imported')} ${r.item}`);
|
|
51
|
+
else if (r.status === 'would-import')
|
|
52
|
+
console.log(` ${chalk.cyan('would import')} ${r.item}`);
|
|
53
|
+
else if (r.status === 'exists')
|
|
54
|
+
console.log(` ${chalk.gray('exists')} ${r.item} ${chalk.gray('(already in file store)')}`);
|
|
55
|
+
else
|
|
56
|
+
console.log(` ${chalk.red('failed')} ${r.item} ${chalk.gray(r.detail ?? '')}`);
|
|
57
|
+
}
|
|
58
|
+
console.log();
|
|
59
|
+
if (!commit) {
|
|
60
|
+
console.log(chalk.gray(`Dry-run: ${imported.length} would be imported, ${existing.length} already present, ${failed.length} unreadable. Pass --commit to write.`));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (failed.length > 0) {
|
|
64
|
+
console.error(chalk.yellow(`Imported ${imported.length}; ${existing.length} already present; ${failed.length} failed.`));
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
console.log(chalk.green(`Imported ${imported.length} secret(s) into the file store (${existing.length} already present).`));
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
console.error(chalk.red(err.message));
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
package/dist/commands/secrets.js
CHANGED
|
@@ -25,6 +25,7 @@ import { registerCommandGroups, setHelpSections } from '../lib/help.js';
|
|
|
25
25
|
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
26
26
|
import { registerSecretsSyncCommands } from './secrets-sync.js';
|
|
27
27
|
import { registerSecretsMigrateAclCommand } from './secrets-migrate.js';
|
|
28
|
+
import { registerSecretsImportKeyringCommand } from './secrets-import.js';
|
|
28
29
|
/** Prompt the user for a secret value with masked input. Requires an interactive TTY. */
|
|
29
30
|
async function promptForSecret(message) {
|
|
30
31
|
if (!isInteractiveTerminal()) {
|
|
@@ -1817,6 +1818,7 @@ Examples:
|
|
|
1817
1818
|
});
|
|
1818
1819
|
registerSecretsSyncCommands(cmd);
|
|
1819
1820
|
registerSecretsMigrateAclCommand(cmd);
|
|
1821
|
+
registerSecretsImportKeyringCommand(cmd);
|
|
1820
1822
|
}
|
|
1821
1823
|
/** Validate a prompt-policy value, throwing a clear message on a bad one (the
|
|
1822
1824
|
* caller's try/catch renders it and exits). Accepts the legacy `biometry` /
|
package/dist/lib/daemon.js
CHANGED
|
@@ -454,6 +454,35 @@ export async function runDaemon() {
|
|
|
454
454
|
};
|
|
455
455
|
const tmuxReconcileInterval = setInterval(() => { void runTmuxReconcile(); }, 5 * 60_000);
|
|
456
456
|
const tmuxReconcileKickoff = setTimeout(() => { void runTmuxReconcile(); }, 20_000);
|
|
457
|
+
// Launch-health self-heal: probe that each agent's DEFAULT version actually
|
|
458
|
+
// LAUNCHES (not just that its files exist), and repair a gutted install — the
|
|
459
|
+
// JS wrapper present but its native binary renamed/missing (a vendor
|
|
460
|
+
// auto-update that never landed its replacement, or a partially-extracted
|
|
461
|
+
// tarball) — BEFORE the user's next `agents run` dies with a raw ENOENT. This
|
|
462
|
+
// is the proactive companion to the run-time heal (ensureAgentRunnable), which
|
|
463
|
+
// only fires once a run is already starting. Cheap steady-state: one
|
|
464
|
+
// `--version` probe per default version; a clean reinstall runs only on a real
|
|
465
|
+
// launch failure. ~every 6h, plus once ~90s after startup (staggered off launch).
|
|
466
|
+
let checkingLaunchHealth = false;
|
|
467
|
+
const runLaunchHealthCheck = async () => {
|
|
468
|
+
if (checkingLaunchHealth)
|
|
469
|
+
return;
|
|
470
|
+
checkingLaunchHealth = true;
|
|
471
|
+
try {
|
|
472
|
+
const { healBrokenDefaultLaunches } = await import('./versions.js');
|
|
473
|
+
const repaired = await healBrokenDefaultLaunches((m) => log('INFO', `launch-health: ${m}`));
|
|
474
|
+
if (repaired.length)
|
|
475
|
+
log('INFO', `launch-health: repaired ${repaired.join(', ')}`);
|
|
476
|
+
}
|
|
477
|
+
catch (err) {
|
|
478
|
+
log('ERROR', `launch-health check failed: ${err.message}`);
|
|
479
|
+
}
|
|
480
|
+
finally {
|
|
481
|
+
checkingLaunchHealth = false;
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
const launchHealthInterval = setInterval(() => { void runLaunchHealthCheck(); }, 6 * 60 * 60_000);
|
|
485
|
+
const launchHealthKickoff = setTimeout(() => { void runLaunchHealthCheck(); }, 90_000);
|
|
457
486
|
const handleReload = () => {
|
|
458
487
|
log('INFO', 'Reloading jobs (SIGHUP)');
|
|
459
488
|
scheduler.reloadAll();
|
|
@@ -475,6 +504,8 @@ export async function runDaemon() {
|
|
|
475
504
|
clearTimeout(deviceProbeKickoff);
|
|
476
505
|
clearInterval(tmuxReconcileInterval);
|
|
477
506
|
clearTimeout(tmuxReconcileKickoff);
|
|
507
|
+
clearInterval(launchHealthInterval);
|
|
508
|
+
clearTimeout(launchHealthKickoff);
|
|
478
509
|
removeDaemonPid();
|
|
479
510
|
process.exit(0);
|
|
480
511
|
};
|
|
Binary file
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the Linux/Windows encrypted-file fallback.
|
|
3
|
+
*
|
|
4
|
+
* When the native credential store (GNOME Keyring / Windows Credential Manager)
|
|
5
|
+
* is unreachable, both backends route to the AES-256-GCM file store. The routing
|
|
6
|
+
* is "sticky": once any item is on disk, every op stays on the file store. That
|
|
7
|
+
* is correct for perf, but on its own it would silently *shadow* secrets that
|
|
8
|
+
* still live in the native store — reads for them would return empty with no
|
|
9
|
+
* hint. This module centralizes the two pieces that keep that from being silent:
|
|
10
|
+
*
|
|
11
|
+
* 1. `noteNativeShadow()` — a one-time stderr notice, emitted when a read
|
|
12
|
+
* falls through to the native store (found something shadowed, or hit a
|
|
13
|
+
* locked/unreachable store), pointing at `agents secrets import-keyring`.
|
|
14
|
+
* 2. The result types for that import command.
|
|
15
|
+
*
|
|
16
|
+
* macOS has no file fallback (see ./index.ts) and uses `migrate-acl` /
|
|
17
|
+
* `migrate-orphans` for its own invisible-item classes, so none of this runs
|
|
18
|
+
* there.
|
|
19
|
+
*/
|
|
20
|
+
export type NativeImportStatus = 'imported' | 'would-import' | 'exists' | 'failed';
|
|
21
|
+
export interface NativeImportResult {
|
|
22
|
+
item: string;
|
|
23
|
+
status: NativeImportStatus;
|
|
24
|
+
detail?: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Outcome of an `import-keyring` run. `available` is false when no native
|
|
28
|
+
* tooling exists (no `secret-tool` / no `powershell.exe`); `locked` is true when
|
|
29
|
+
* the native store exists but is locked/unreachable, so nothing could be read.
|
|
30
|
+
*/
|
|
31
|
+
export interface NativeImportReport {
|
|
32
|
+
available: boolean;
|
|
33
|
+
locked: boolean;
|
|
34
|
+
results: NativeImportResult[];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Emit a one-time stderr notice that the file fallback is masking the native
|
|
38
|
+
* credential store. Both backends share the copy so the guidance is identical.
|
|
39
|
+
*
|
|
40
|
+
* 'shadowed' — a secret was just read from the native store that isn't in the
|
|
41
|
+
* file store. It works, but each read pays a native lookup and it
|
|
42
|
+
* won't survive the store locking; suggest migrating it.
|
|
43
|
+
* 'locked' — the native store is locked/unreachable, so its secrets can't be
|
|
44
|
+
* read in this session at all; the user must unlock, then migrate.
|
|
45
|
+
*/
|
|
46
|
+
export declare function noteNativeShadow(kind: 'shadowed' | 'locked', fileDir: string): void;
|
|
47
|
+
/** Test-only: clear the one-time notice guard between cases. */
|
|
48
|
+
export declare function _resetFallbackNoticeForTest(): void;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the Linux/Windows encrypted-file fallback.
|
|
3
|
+
*
|
|
4
|
+
* When the native credential store (GNOME Keyring / Windows Credential Manager)
|
|
5
|
+
* is unreachable, both backends route to the AES-256-GCM file store. The routing
|
|
6
|
+
* is "sticky": once any item is on disk, every op stays on the file store. That
|
|
7
|
+
* is correct for perf, but on its own it would silently *shadow* secrets that
|
|
8
|
+
* still live in the native store — reads for them would return empty with no
|
|
9
|
+
* hint. This module centralizes the two pieces that keep that from being silent:
|
|
10
|
+
*
|
|
11
|
+
* 1. `noteNativeShadow()` — a one-time stderr notice, emitted when a read
|
|
12
|
+
* falls through to the native store (found something shadowed, or hit a
|
|
13
|
+
* locked/unreachable store), pointing at `agents secrets import-keyring`.
|
|
14
|
+
* 2. The result types for that import command.
|
|
15
|
+
*
|
|
16
|
+
* macOS has no file fallback (see ./index.ts) and uses `migrate-acl` /
|
|
17
|
+
* `migrate-orphans` for its own invisible-item classes, so none of this runs
|
|
18
|
+
* there.
|
|
19
|
+
*/
|
|
20
|
+
let noticeEmitted = false;
|
|
21
|
+
/**
|
|
22
|
+
* Emit a one-time stderr notice that the file fallback is masking the native
|
|
23
|
+
* credential store. Both backends share the copy so the guidance is identical.
|
|
24
|
+
*
|
|
25
|
+
* 'shadowed' — a secret was just read from the native store that isn't in the
|
|
26
|
+
* file store. It works, but each read pays a native lookup and it
|
|
27
|
+
* won't survive the store locking; suggest migrating it.
|
|
28
|
+
* 'locked' — the native store is locked/unreachable, so its secrets can't be
|
|
29
|
+
* read in this session at all; the user must unlock, then migrate.
|
|
30
|
+
*/
|
|
31
|
+
export function noteNativeShadow(kind, fileDir) {
|
|
32
|
+
if (noticeEmitted)
|
|
33
|
+
return;
|
|
34
|
+
noticeEmitted = true;
|
|
35
|
+
if (kind === 'locked') {
|
|
36
|
+
process.stderr.write(`[agents] the native credential store is locked/unreachable — secrets stored there are not ` +
|
|
37
|
+
`readable in this session. Unlock it, then run \`agents secrets import-keyring\` to migrate ` +
|
|
38
|
+
`them into the encrypted file store at ${fileDir}.\n`);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
process.stderr.write(`[agents] read a secret from the native credential store that is not in the file store at ` +
|
|
42
|
+
`${fileDir}. Run \`agents secrets import-keyring\` to migrate it so it stays readable headless.\n`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Test-only: clear the one-time notice guard between cases. */
|
|
46
|
+
export function _resetFallbackNoticeForTest() {
|
|
47
|
+
noticeEmitted = false;
|
|
48
|
+
}
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
* through the explicit export/import flow in src/lib/secrets/sync.ts
|
|
23
23
|
* rather than the system's cloud-keychain path.
|
|
24
24
|
*/
|
|
25
|
+
import type { NativeImportReport } from './fallback.js';
|
|
26
|
+
export type { NativeImportReport, NativeImportResult, NativeImportStatus } from './fallback.js';
|
|
25
27
|
/** Supported secret resolution backends. */
|
|
26
28
|
export type SecretProvider = 'keychain' | 'env' | 'file' | 'exec';
|
|
27
29
|
/** A typed reference to a secret, consisting of a provider and a provider-specific value. */
|
|
@@ -181,6 +183,15 @@ export declare function parseOrphanMigrationOutput(stdout: string): OrphanMigrat
|
|
|
181
183
|
* aborted" from "nothing to do" (empty array).
|
|
182
184
|
*/
|
|
183
185
|
export declare function migrateOrphanedKeychainItems(prefix: string): OrphanMigrationResult[];
|
|
186
|
+
/**
|
|
187
|
+
* Import agents-cli secrets from the native store (GNOME Keyring / Windows
|
|
188
|
+
* Credential Manager) into the encrypted file store — the Linux/Windows
|
|
189
|
+
* analogue of the macOS orphan/legacy migration, exposed as
|
|
190
|
+
* `agents secrets import-keyring`. Requires the native store to be
|
|
191
|
+
* reachable/unlocked; `commit=false` is a dry-run. macOS returns an empty
|
|
192
|
+
* report (it has no file fallback and uses `migrate-acl` instead).
|
|
193
|
+
*/
|
|
194
|
+
export declare function importNativeItems(prefix: string, commit: boolean): NativeImportReport;
|
|
184
195
|
/** Options controlling how secret refs are resolved. */
|
|
185
196
|
export interface ResolveOptions {
|
|
186
197
|
/** Translate a short keychain ID to a fully namespaced item name. */
|
|
@@ -26,8 +26,8 @@ import { execFileSync, spawnSync } from 'child_process';
|
|
|
26
26
|
import * as fs from 'fs';
|
|
27
27
|
import * as os from 'os';
|
|
28
28
|
import * as path from 'path';
|
|
29
|
-
import { linuxBackend, usesFileFallback as linuxUsesFileFallback } from './linux.js';
|
|
30
|
-
import { windowsBackend, usesFileFallback as windowsUsesFileFallback } from './windows.js';
|
|
29
|
+
import { linuxBackend, usesFileFallback as linuxUsesFileFallback, importNativeSecretToolItems } from './linux.js';
|
|
30
|
+
import { windowsBackend, usesFileFallback as windowsUsesFileFallback, importNativeCredManItems } from './windows.js';
|
|
31
31
|
import { getKeychainHelperPath } from './install-helper.js';
|
|
32
32
|
const SERVICE_PREFIX = 'agents-cli';
|
|
33
33
|
const SECRETS_ITEM_PREFIX = `${SERVICE_PREFIX}.secrets.`;
|
|
@@ -518,6 +518,24 @@ export function migrateOrphanedKeychainItems(prefix) {
|
|
|
518
518
|
}
|
|
519
519
|
return parseOrphanMigrationOutput(result.stdout?.toString() || '');
|
|
520
520
|
}
|
|
521
|
+
/**
|
|
522
|
+
* Import agents-cli secrets from the native store (GNOME Keyring / Windows
|
|
523
|
+
* Credential Manager) into the encrypted file store — the Linux/Windows
|
|
524
|
+
* analogue of the macOS orphan/legacy migration, exposed as
|
|
525
|
+
* `agents secrets import-keyring`. Requires the native store to be
|
|
526
|
+
* reachable/unlocked; `commit=false` is a dry-run. macOS returns an empty
|
|
527
|
+
* report (it has no file fallback and uses `migrate-acl` instead).
|
|
528
|
+
*/
|
|
529
|
+
export function importNativeItems(prefix, commit) {
|
|
530
|
+
if (backend)
|
|
531
|
+
return { available: false, locked: false, results: [] };
|
|
532
|
+
assertSupportedPlatform();
|
|
533
|
+
if (isLinux())
|
|
534
|
+
return importNativeSecretToolItems(prefix, commit);
|
|
535
|
+
if (isWindows())
|
|
536
|
+
return importNativeCredManItems(prefix, commit);
|
|
537
|
+
return { available: false, locked: false, results: [] };
|
|
538
|
+
}
|
|
521
539
|
function expandHome(p) {
|
|
522
540
|
if (p.startsWith('~/') || p === '~') {
|
|
523
541
|
return path.join(os.homedir(), p.slice(1));
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* item = the secret identifier
|
|
18
18
|
*/
|
|
19
19
|
import type { KeychainBackend } from './index.js';
|
|
20
|
+
import { type NativeImportReport } from './fallback.js';
|
|
20
21
|
export { encryptForFallback, decryptForFallback, fileBackend, type EncFile, } from './filestore.js';
|
|
21
22
|
/**
|
|
22
23
|
* True when secret operations currently route to the encrypted-file store
|
|
@@ -55,6 +56,12 @@ export declare function parseSecretToolItems(output: string, prefix: string): st
|
|
|
55
56
|
* so we use secret-tool search which outputs in a specific format.
|
|
56
57
|
*/
|
|
57
58
|
export declare function listSecretToolItems(prefix: string): string[];
|
|
59
|
+
/**
|
|
60
|
+
* Copy agents-cli items from the keyring into the file store (the `import-keyring`
|
|
61
|
+
* backend for Linux). Requires an unlocked keyring; items already in the file
|
|
62
|
+
* store are left untouched. With `commit=false` it reports what it *would* do.
|
|
63
|
+
*/
|
|
64
|
+
export declare function importNativeSecretToolItems(prefix: string, commit: boolean): NativeImportReport;
|
|
58
65
|
/** KeychainBackend implementation for Linux. Routes through secret-tool
|
|
59
66
|
* with a transparent encrypted-file fallback when the default Secret
|
|
60
67
|
* Service collection is locked (or libsecret-tools is not installed but
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import { spawnSync } from 'child_process';
|
|
20
20
|
import * as os from 'os';
|
|
21
21
|
import { fileStore, fileDir, fileStoreHasItems, machinePassphraseExists, _resetFileStoreForTest, } from './filestore.js';
|
|
22
|
+
import { noteNativeShadow, _resetFallbackNoticeForTest, } from './fallback.js';
|
|
22
23
|
// Re-exported so existing importers (and tests) can keep reaching these via
|
|
23
24
|
// './linux.js'. The implementations live in ./filestore.ts.
|
|
24
25
|
export { encryptForFallback, decryptForFallback, fileBackend, } from './filestore.js';
|
|
@@ -35,13 +36,16 @@ let isAvailable = false;
|
|
|
35
36
|
// ---------- file fallback state ----------
|
|
36
37
|
let useFileFallback = false;
|
|
37
38
|
let warnedFallback = false;
|
|
39
|
+
// Set once the keyring is observed locked/unreachable in this process, so the
|
|
40
|
+
// read-through in get/has stops re-probing it (and stops re-emitting notices).
|
|
41
|
+
let nativeUnreachable = false;
|
|
38
42
|
function activateFileFallback() {
|
|
39
43
|
if (useFileFallback)
|
|
40
44
|
return;
|
|
41
45
|
useFileFallback = true;
|
|
42
46
|
if (!warnedFallback) {
|
|
43
47
|
warnedFallback = true;
|
|
44
|
-
process.stderr.write(`[agents]
|
|
48
|
+
process.stderr.write(`[agents] using the encrypted file store at ${fileDir()}\n`);
|
|
45
49
|
}
|
|
46
50
|
}
|
|
47
51
|
function isLockedCollectionError(stderr) {
|
|
@@ -111,8 +115,21 @@ export function usesFileFallback() {
|
|
|
111
115
|
/** secret-tool lookup attributes:
|
|
112
116
|
* service=agents-cli account=<user> item=<itemName> */
|
|
113
117
|
export function hasSecretToolToken(item) {
|
|
114
|
-
if (preflight() === 'file')
|
|
115
|
-
|
|
118
|
+
if (preflight() === 'file') {
|
|
119
|
+
if (fileStore.has(item))
|
|
120
|
+
return true;
|
|
121
|
+
// The file store is primary under the fallback, but an item can still live
|
|
122
|
+
// only in an (unlocked) keyring that predates it — read through so it isn't
|
|
123
|
+
// silently shadowed.
|
|
124
|
+
const probe = readNativeItemRaw(item);
|
|
125
|
+
if (probe.value !== undefined) {
|
|
126
|
+
noteNativeShadow('shadowed', fileDir());
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
if (probe.locked)
|
|
130
|
+
noteNativeShadow('locked', fileDir());
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
116
133
|
const user = os.userInfo().username;
|
|
117
134
|
const result = spawnSync('secret-tool', [
|
|
118
135
|
'lookup',
|
|
@@ -125,14 +142,25 @@ export function hasSecretToolToken(item) {
|
|
|
125
142
|
}
|
|
126
143
|
const stderr = result.stderr?.toString() ?? '';
|
|
127
144
|
if (isLockedCollectionError(stderr)) {
|
|
145
|
+
nativeUnreachable = true;
|
|
128
146
|
activateFileFallback();
|
|
129
147
|
return fileStore.has(item);
|
|
130
148
|
}
|
|
131
149
|
return false;
|
|
132
150
|
}
|
|
133
151
|
export function getSecretToolToken(item) {
|
|
134
|
-
if (preflight() === 'file')
|
|
135
|
-
|
|
152
|
+
if (preflight() === 'file') {
|
|
153
|
+
if (fileStore.has(item))
|
|
154
|
+
return fileStore.get(item);
|
|
155
|
+
const probe = readNativeItemRaw(item);
|
|
156
|
+
if (probe.value !== undefined) {
|
|
157
|
+
noteNativeShadow('shadowed', fileDir());
|
|
158
|
+
return probe.value;
|
|
159
|
+
}
|
|
160
|
+
if (probe.locked)
|
|
161
|
+
noteNativeShadow('locked', fileDir());
|
|
162
|
+
throw new Error(`Secret '${item}' not found in the file store or keyring.`);
|
|
163
|
+
}
|
|
136
164
|
const user = os.userInfo().username;
|
|
137
165
|
const result = spawnSync('secret-tool', [
|
|
138
166
|
'lookup',
|
|
@@ -148,6 +176,7 @@ export function getSecretToolToken(item) {
|
|
|
148
176
|
}
|
|
149
177
|
const stderr = result.stderr?.toString() ?? '';
|
|
150
178
|
if (isLockedCollectionError(stderr)) {
|
|
179
|
+
nativeUnreachable = true;
|
|
151
180
|
activateFileFallback();
|
|
152
181
|
return fileStore.get(item);
|
|
153
182
|
}
|
|
@@ -171,6 +200,7 @@ export function setSecretToolToken(item, value) {
|
|
|
171
200
|
return;
|
|
172
201
|
const stderr = result.stderr?.toString().trim() ?? '';
|
|
173
202
|
if (isLockedCollectionError(stderr)) {
|
|
203
|
+
nativeUnreachable = true;
|
|
174
204
|
activateFileFallback();
|
|
175
205
|
fileStore.set(item, value);
|
|
176
206
|
return;
|
|
@@ -193,6 +223,7 @@ export function deleteSecretToolToken(item) {
|
|
|
193
223
|
return true;
|
|
194
224
|
const stderr = result.stderr?.toString() ?? '';
|
|
195
225
|
if (isLockedCollectionError(stderr)) {
|
|
226
|
+
nativeUnreachable = true;
|
|
196
227
|
activateFileFallback();
|
|
197
228
|
return fileStore.delete(item);
|
|
198
229
|
}
|
|
@@ -252,6 +283,81 @@ export function listSecretToolItems(prefix) {
|
|
|
252
283
|
const output = `${result.stdout?.toString() || ''}\n${result.stderr?.toString() || ''}`;
|
|
253
284
|
return parseSecretToolItems(output, prefix);
|
|
254
285
|
}
|
|
286
|
+
// ---------- native-direct helpers (bypass preflight routing) ----------
|
|
287
|
+
//
|
|
288
|
+
// These always talk to secret-tool regardless of whether the process has fallen
|
|
289
|
+
// back to the file store. They power (a) the read-through that keeps the file
|
|
290
|
+
// store from silently shadowing keyring items, and (b) `import-keyring`.
|
|
291
|
+
/**
|
|
292
|
+
* Read one item straight from the keyring. Returns `{value}` on a hit,
|
|
293
|
+
* `{locked:true}` when the collection is locked/unreachable, and `{}` on a plain
|
|
294
|
+
* miss. Never throws and never emits — the caller decides whether to notice.
|
|
295
|
+
*/
|
|
296
|
+
function readNativeItemRaw(item) {
|
|
297
|
+
if (nativeUnreachable)
|
|
298
|
+
return { locked: true };
|
|
299
|
+
if (!secretToolAvailable())
|
|
300
|
+
return {};
|
|
301
|
+
const user = os.userInfo().username;
|
|
302
|
+
const r = spawnSync('secret-tool', [
|
|
303
|
+
'lookup', 'service', SERVICE, 'account', user, 'item', item,
|
|
304
|
+
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
305
|
+
if (r.status === 0) {
|
|
306
|
+
const v = r.stdout?.toString().trim();
|
|
307
|
+
return v && v.length ? { value: v } : {};
|
|
308
|
+
}
|
|
309
|
+
if (isLockedCollectionError(r.stderr?.toString() ?? '')) {
|
|
310
|
+
nativeUnreachable = true;
|
|
311
|
+
return { locked: true };
|
|
312
|
+
}
|
|
313
|
+
return {};
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Enumerate agents-cli items in the keyring whose name starts with `prefix`.
|
|
317
|
+
* `available` is false when secret-tool isn't installed; `locked` is true when
|
|
318
|
+
* the collection is locked.
|
|
319
|
+
*/
|
|
320
|
+
function listNativeItemsRaw(prefix) {
|
|
321
|
+
if (!secretToolAvailable())
|
|
322
|
+
return { items: [], locked: false, available: false };
|
|
323
|
+
const r = spawnSync('secret-tool', [
|
|
324
|
+
'search', '--all', 'service', SERVICE,
|
|
325
|
+
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
326
|
+
if (r.status !== 0) {
|
|
327
|
+
const locked = isLockedCollectionError(r.stderr?.toString() ?? '');
|
|
328
|
+
if (locked)
|
|
329
|
+
nativeUnreachable = true;
|
|
330
|
+
return { items: [], locked, available: true };
|
|
331
|
+
}
|
|
332
|
+
const output = `${r.stdout?.toString() || ''}\n${r.stderr?.toString() || ''}`;
|
|
333
|
+
return { items: parseSecretToolItems(output, prefix), locked: false, available: true };
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Copy agents-cli items from the keyring into the file store (the `import-keyring`
|
|
337
|
+
* backend for Linux). Requires an unlocked keyring; items already in the file
|
|
338
|
+
* store are left untouched. With `commit=false` it reports what it *would* do.
|
|
339
|
+
*/
|
|
340
|
+
export function importNativeSecretToolItems(prefix, commit) {
|
|
341
|
+
const { items, locked, available } = listNativeItemsRaw(prefix);
|
|
342
|
+
if (!available || locked)
|
|
343
|
+
return { available, locked, results: [] };
|
|
344
|
+
const results = [];
|
|
345
|
+
for (const item of items) {
|
|
346
|
+
if (fileStore.has(item)) {
|
|
347
|
+
results.push({ item, status: 'exists' });
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
const probe = readNativeItemRaw(item);
|
|
351
|
+
if (probe.value === undefined) {
|
|
352
|
+
results.push({ item, status: 'failed', detail: probe.locked ? 'keyring locked' : 'unreadable' });
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (commit)
|
|
356
|
+
fileStore.set(item, probe.value);
|
|
357
|
+
results.push({ item, status: commit ? 'imported' : 'would-import' });
|
|
358
|
+
}
|
|
359
|
+
return { available, locked, results };
|
|
360
|
+
}
|
|
255
361
|
/** KeychainBackend implementation for Linux. Routes through secret-tool
|
|
256
362
|
* with a transparent encrypted-file fallback when the default Secret
|
|
257
363
|
* Service collection is locked (or libsecret-tools is not installed but
|
|
@@ -280,6 +386,8 @@ export function _resetForTest(opts = {}) {
|
|
|
280
386
|
_resetFileStoreForTest({ fileDir: opts.fileDir ?? null, passphrase: opts.passphrase ?? null });
|
|
281
387
|
useFileFallback = opts.forceFileFallback ?? false;
|
|
282
388
|
warnedFallback = false;
|
|
389
|
+
nativeUnreachable = false;
|
|
283
390
|
checkedAvailability = false;
|
|
284
391
|
isAvailable = false;
|
|
392
|
+
_resetFallbackNoticeForTest();
|
|
285
393
|
}
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* names directly.
|
|
28
28
|
*/
|
|
29
29
|
import type { KeychainBackend } from './index.js';
|
|
30
|
+
import { type NativeImportReport } from './fallback.js';
|
|
30
31
|
export { encryptForFallback, decryptForFallback, fileBackend, type EncFile, } from './filestore.js';
|
|
31
32
|
/**
|
|
32
33
|
* CRED_MAX_CREDENTIAL_BLOB_SIZE — Credential Manager rejects a generic
|
|
@@ -53,6 +54,12 @@ export declare function listCredManItems(prefix: string): string[];
|
|
|
53
54
|
* parseSecretToolItems (linux.ts). Exported for tests.
|
|
54
55
|
*/
|
|
55
56
|
export declare function parseWindowsCredList(output: string, prefix: string): string[];
|
|
57
|
+
/**
|
|
58
|
+
* Copy agents-cli credentials from Credential Manager into the file store (the
|
|
59
|
+
* `import-keyring` backend for Windows). Requires a reachable store; items
|
|
60
|
+
* already in the file store are left untouched.
|
|
61
|
+
*/
|
|
62
|
+
export declare function importNativeCredManItems(prefix: string, commit: boolean): NativeImportReport;
|
|
56
63
|
/**
|
|
57
64
|
* KeychainBackend implementation for Windows. Routes through Windows Credential
|
|
58
65
|
* Manager (via PowerShell P/Invoke) with a transparent encrypted-file fallback
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import { spawnSync } from 'child_process';
|
|
30
30
|
import { encodePwshBase64 } from '../pwsh.js';
|
|
31
31
|
import { fileStore, fileDir, fileStoreHasItems, machinePassphraseExists, _resetFileStoreForTest, } from './filestore.js';
|
|
32
|
+
import { noteNativeShadow, _resetFallbackNoticeForTest, } from './fallback.js';
|
|
32
33
|
// Re-exported so importers (and tests) can keep reaching these via './windows.js'.
|
|
33
34
|
export { encryptForFallback, decryptForFallback, fileBackend, } from './filestore.js';
|
|
34
35
|
const POWERSHELL = 'powershell.exe';
|
|
@@ -250,13 +251,16 @@ let isAvailable = false;
|
|
|
250
251
|
// ---------- file fallback state ----------
|
|
251
252
|
let useFileFallback = false;
|
|
252
253
|
let warnedFallback = false;
|
|
254
|
+
// Set once Credential Manager is observed unreachable in this process, so the
|
|
255
|
+
// read-through in get/has stops re-probing it (and re-emitting notices).
|
|
256
|
+
let nativeUnreachable = false;
|
|
253
257
|
function activateFileFallback() {
|
|
254
258
|
if (useFileFallback)
|
|
255
259
|
return;
|
|
256
260
|
useFileFallback = true;
|
|
257
261
|
if (!warnedFallback) {
|
|
258
262
|
warnedFallback = true;
|
|
259
|
-
process.stderr.write(`[agents]
|
|
263
|
+
process.stderr.write(`[agents] using the encrypted file store at ${fileDir()}\n`);
|
|
260
264
|
}
|
|
261
265
|
}
|
|
262
266
|
/**
|
|
@@ -316,22 +320,45 @@ export function usesFileFallback() {
|
|
|
316
320
|
}
|
|
317
321
|
// ---------- Credential Manager ops with fallback ----------
|
|
318
322
|
export function hasCredManToken(item) {
|
|
319
|
-
if (preflight() === 'file')
|
|
320
|
-
|
|
323
|
+
if (preflight() === 'file') {
|
|
324
|
+
if (fileStore.has(item))
|
|
325
|
+
return true;
|
|
326
|
+
// Read through to Credential Manager so an item that predates the fallback
|
|
327
|
+
// isn't silently shadowed by the file store.
|
|
328
|
+
const probe = readNativeCredItemRaw(item);
|
|
329
|
+
if (probe.value !== undefined) {
|
|
330
|
+
noteNativeShadow('shadowed', fileDir());
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
if (probe.unavailable)
|
|
334
|
+
noteNativeShadow('locked', fileDir());
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
321
337
|
const r = runCred('has', { target: item });
|
|
322
338
|
if (r.status === 0)
|
|
323
339
|
return true;
|
|
324
340
|
if (r.status === 3)
|
|
325
341
|
return false;
|
|
326
342
|
if (isCredManUnavailableError(r)) {
|
|
343
|
+
nativeUnreachable = true;
|
|
327
344
|
activateFileFallback();
|
|
328
345
|
return fileStore.has(item);
|
|
329
346
|
}
|
|
330
347
|
return false;
|
|
331
348
|
}
|
|
332
349
|
export function getCredManToken(item) {
|
|
333
|
-
if (preflight() === 'file')
|
|
334
|
-
|
|
350
|
+
if (preflight() === 'file') {
|
|
351
|
+
if (fileStore.has(item))
|
|
352
|
+
return fileStore.get(item);
|
|
353
|
+
const probe = readNativeCredItemRaw(item);
|
|
354
|
+
if (probe.value !== undefined) {
|
|
355
|
+
noteNativeShadow('shadowed', fileDir());
|
|
356
|
+
return probe.value;
|
|
357
|
+
}
|
|
358
|
+
if (probe.unavailable)
|
|
359
|
+
noteNativeShadow('locked', fileDir());
|
|
360
|
+
throw new Error(`Secret '${item}' not found in the file store or Credential Manager.`);
|
|
361
|
+
}
|
|
335
362
|
const r = runCred('get', { target: item });
|
|
336
363
|
if (r.status === 0) {
|
|
337
364
|
// stdout is base64 of the raw UTF-8 blob (dodges PowerShell encoding corruption).
|
|
@@ -340,6 +367,7 @@ export function getCredManToken(item) {
|
|
|
340
367
|
if (r.status === 3)
|
|
341
368
|
throw new Error(`Secret '${item}' not found in Credential Manager.`);
|
|
342
369
|
if (isCredManUnavailableError(r)) {
|
|
370
|
+
nativeUnreachable = true;
|
|
343
371
|
activateFileFallback();
|
|
344
372
|
return fileStore.get(item);
|
|
345
373
|
}
|
|
@@ -362,6 +390,7 @@ export function setCredManToken(item, value) {
|
|
|
362
390
|
if (r.status === 0)
|
|
363
391
|
return;
|
|
364
392
|
if (isCredManUnavailableError(r)) {
|
|
393
|
+
nativeUnreachable = true;
|
|
365
394
|
activateFileFallback();
|
|
366
395
|
fileStore.set(item, value);
|
|
367
396
|
return;
|
|
@@ -377,6 +406,7 @@ export function deleteCredManToken(item) {
|
|
|
377
406
|
if (r.status === 3)
|
|
378
407
|
return false;
|
|
379
408
|
if (isCredManUnavailableError(r)) {
|
|
409
|
+
nativeUnreachable = true;
|
|
380
410
|
activateFileFallback();
|
|
381
411
|
return fileStore.delete(item);
|
|
382
412
|
}
|
|
@@ -389,6 +419,7 @@ export function listCredManItems(prefix) {
|
|
|
389
419
|
if (r.status === 0)
|
|
390
420
|
return parseWindowsCredList(r.stdout, prefix);
|
|
391
421
|
if (isCredManUnavailableError(r)) {
|
|
422
|
+
nativeUnreachable = true;
|
|
392
423
|
activateFileFallback();
|
|
393
424
|
return fileStore.list(prefix);
|
|
394
425
|
}
|
|
@@ -407,6 +438,78 @@ export function parseWindowsCredList(output, prefix) {
|
|
|
407
438
|
.filter((s) => s.startsWith(prefix));
|
|
408
439
|
return [...new Set(items)]; // dedupe
|
|
409
440
|
}
|
|
441
|
+
// ---------- native-direct helpers (bypass preflight routing) ----------
|
|
442
|
+
//
|
|
443
|
+
// Always talk to Credential Manager regardless of the file fallback. They power
|
|
444
|
+
// (a) the read-through that keeps the file store from shadowing credman items,
|
|
445
|
+
// and (b) `import-keyring`.
|
|
446
|
+
/**
|
|
447
|
+
* Read one item straight from Credential Manager. `{value}` on hit,
|
|
448
|
+
* `{unavailable:true}` when the store is unreachable, `{}` on a plain miss.
|
|
449
|
+
* Never throws, never emits.
|
|
450
|
+
*/
|
|
451
|
+
function readNativeCredItemRaw(item) {
|
|
452
|
+
if (nativeUnreachable)
|
|
453
|
+
return { unavailable: true };
|
|
454
|
+
if (!powershellAvailable())
|
|
455
|
+
return {};
|
|
456
|
+
const r = runCred('get', { target: item });
|
|
457
|
+
if (r.status === 0)
|
|
458
|
+
return { value: Buffer.from(r.stdout.trim(), 'base64').toString('utf8') };
|
|
459
|
+
if (r.status === 3)
|
|
460
|
+
return {};
|
|
461
|
+
if (isCredManUnavailableError(r)) {
|
|
462
|
+
nativeUnreachable = true;
|
|
463
|
+
return { unavailable: true };
|
|
464
|
+
}
|
|
465
|
+
return {};
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Enumerate agents-cli credentials under `prefix`. Windows credentials have no
|
|
469
|
+
* service scoping — the target IS the identifier — so we NEVER enumerate with an
|
|
470
|
+
* empty filter (that returns unrelated machine credentials). The filter is
|
|
471
|
+
* floored to the `agents-cli.` namespace; bare items (unprefixed targets) are
|
|
472
|
+
* therefore out of scope for auto-discovery on Windows.
|
|
473
|
+
*/
|
|
474
|
+
function listNativeCredItemsRaw(prefix) {
|
|
475
|
+
if (!powershellAvailable())
|
|
476
|
+
return { items: [], locked: false, available: false };
|
|
477
|
+
const floor = prefix && prefix.startsWith('agents-cli.') ? prefix : 'agents-cli.';
|
|
478
|
+
const r = runCred('list', { prefix: floor });
|
|
479
|
+
if (r.status === 0)
|
|
480
|
+
return { items: parseWindowsCredList(r.stdout, floor), locked: false, available: true };
|
|
481
|
+
if (isCredManUnavailableError(r)) {
|
|
482
|
+
nativeUnreachable = true;
|
|
483
|
+
return { items: [], locked: true, available: true };
|
|
484
|
+
}
|
|
485
|
+
return { items: [], locked: false, available: true };
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Copy agents-cli credentials from Credential Manager into the file store (the
|
|
489
|
+
* `import-keyring` backend for Windows). Requires a reachable store; items
|
|
490
|
+
* already in the file store are left untouched.
|
|
491
|
+
*/
|
|
492
|
+
export function importNativeCredManItems(prefix, commit) {
|
|
493
|
+
const { items, locked, available } = listNativeCredItemsRaw(prefix);
|
|
494
|
+
if (!available || locked)
|
|
495
|
+
return { available, locked, results: [] };
|
|
496
|
+
const results = [];
|
|
497
|
+
for (const item of items) {
|
|
498
|
+
if (fileStore.has(item)) {
|
|
499
|
+
results.push({ item, status: 'exists' });
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
const probe = readNativeCredItemRaw(item);
|
|
503
|
+
if (probe.value === undefined) {
|
|
504
|
+
results.push({ item, status: 'failed', detail: probe.unavailable ? 'credential manager unavailable' : 'unreadable' });
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
if (commit)
|
|
508
|
+
fileStore.set(item, probe.value);
|
|
509
|
+
results.push({ item, status: commit ? 'imported' : 'would-import' });
|
|
510
|
+
}
|
|
511
|
+
return { available, locked, results };
|
|
512
|
+
}
|
|
410
513
|
/**
|
|
411
514
|
* KeychainBackend implementation for Windows. Routes through Windows Credential
|
|
412
515
|
* Manager (via PowerShell P/Invoke) with a transparent encrypted-file fallback
|
|
@@ -440,6 +543,8 @@ export function _resetForTest(opts = {}) {
|
|
|
440
543
|
_resetFileStoreForTest({ fileDir: opts.fileDir ?? null, passphrase: opts.passphrase ?? null });
|
|
441
544
|
useFileFallback = opts.forceFileFallback ?? false;
|
|
442
545
|
warnedFallback = false;
|
|
546
|
+
nativeUnreachable = false;
|
|
547
|
+
_resetFallbackNoticeForTest();
|
|
443
548
|
if (opts.forceAvailable === undefined || opts.forceAvailable === null) {
|
|
444
549
|
checkedAvailability = false;
|
|
445
550
|
isAvailable = false;
|
|
@@ -2456,6 +2456,8 @@ export function readKimiMeta(filePath) {
|
|
|
2456
2456
|
project = parts.slice(0, -1).join('/');
|
|
2457
2457
|
}
|
|
2458
2458
|
}
|
|
2459
|
+
// Parse wire.jsonl to extract message count and token usage
|
|
2460
|
+
const { messageCount, tokenCount } = parseKimiWireMetrics(sessionDir);
|
|
2459
2461
|
const meta = {
|
|
2460
2462
|
id: sessionId,
|
|
2461
2463
|
shortId,
|
|
@@ -2464,9 +2466,48 @@ export function readKimiMeta(filePath) {
|
|
|
2464
2466
|
project,
|
|
2465
2467
|
filePath,
|
|
2466
2468
|
topic,
|
|
2469
|
+
messageCount,
|
|
2470
|
+
tokenCount: tokenCount > 0 ? tokenCount : undefined,
|
|
2467
2471
|
};
|
|
2468
2472
|
return { meta, content: lastPrompt || '' };
|
|
2469
2473
|
}
|
|
2474
|
+
/** Parse Kimi's wire.jsonl to extract message count and token usage.
|
|
2475
|
+
* TODO: optimize to stream (like scanClaudeSession) to avoid loading large files into memory.
|
|
2476
|
+
* For now, synchronous readFileSync matches the pattern of reading state.json and is acceptable
|
|
2477
|
+
* since session dirs are usually fresh in FS cache during incremental scans. */
|
|
2478
|
+
function parseKimiWireMetrics(sessionDir) {
|
|
2479
|
+
const wirePath = path.join(sessionDir, 'agents', 'main', 'wire.jsonl');
|
|
2480
|
+
let messageCount = 0;
|
|
2481
|
+
let tokenCount = 0;
|
|
2482
|
+
if (!fs.existsSync(wirePath)) {
|
|
2483
|
+
return { messageCount: 0, tokenCount: 0 };
|
|
2484
|
+
}
|
|
2485
|
+
try {
|
|
2486
|
+
const lines = fs.readFileSync(wirePath, 'utf-8').split('\n');
|
|
2487
|
+
for (const line of lines) {
|
|
2488
|
+
if (!line.trim())
|
|
2489
|
+
continue;
|
|
2490
|
+
try {
|
|
2491
|
+
const event = JSON.parse(line);
|
|
2492
|
+
if (event.type === 'context.append_message') {
|
|
2493
|
+
messageCount++;
|
|
2494
|
+
}
|
|
2495
|
+
else if (event.type === 'usage.record' && event.usage) {
|
|
2496
|
+
// Kimi usage structure: inputOther + output + inputCacheRead + inputCacheCreation
|
|
2497
|
+
const u = event.usage;
|
|
2498
|
+
tokenCount += (u.inputOther || 0) + (u.output || 0) + (u.inputCacheRead || 0) + (u.inputCacheCreation || 0);
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
catch {
|
|
2502
|
+
// Malformed line, skip
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
catch {
|
|
2507
|
+
// If wire.jsonl can't be read, return 0s (graceful degradation)
|
|
2508
|
+
}
|
|
2509
|
+
return { messageCount, tokenCount };
|
|
2510
|
+
}
|
|
2470
2511
|
/** Parse a time filter string (relative like '7d' or ISO timestamp) into epoch milliseconds. */
|
|
2471
2512
|
export function parseTimeFilter(input) {
|
|
2472
2513
|
const relativeMatch = input.match(/^(\d+)([mhdw])$/i);
|
package/dist/lib/versions.d.ts
CHANGED
|
@@ -320,6 +320,21 @@ export declare function isMissingBinarySignature(output: string): boolean;
|
|
|
320
320
|
* missing-binary signature (see isMissingBinarySignature) fails the check; a
|
|
321
321
|
* plain nonzero exit or a timeout is treated as healthy so we never false-fail.
|
|
322
322
|
*/
|
|
323
|
+
/**
|
|
324
|
+
* Compose the spawn spec for a `<binary> --version` launch probe. On Windows the
|
|
325
|
+
* `.cmd` wrapper runs through cmd.exe, so the path is fully quoted into ONE
|
|
326
|
+
* command line and the args array is emptied (composeWin32CommandLine) — the
|
|
327
|
+
* DEP0190-safe pattern the real launch uses. Critically this keeps a spaced
|
|
328
|
+
* Windows profile path (`C:\Users\John Doe\…\claude.cmd`) intact; passing the raw
|
|
329
|
+
* path to a shell would split it at the space and false-fail a HEALTHY install.
|
|
330
|
+
* On POSIX no shell is involved and the binary is exec'd directly. Pure/exported
|
|
331
|
+
* so the quoting is unit-testable without spawning.
|
|
332
|
+
*/
|
|
333
|
+
export declare function probeSpawnSpec(binary: string, isWin: boolean): {
|
|
334
|
+
command: string;
|
|
335
|
+
args: string[];
|
|
336
|
+
shell: boolean;
|
|
337
|
+
};
|
|
323
338
|
export declare function verifyInstalledBinaryLaunches(agent: AgentId, version: string): Promise<{
|
|
324
339
|
ok: boolean;
|
|
325
340
|
detail?: string;
|
|
@@ -344,6 +359,7 @@ export declare function verifyInstalledBinaryLaunches(agent: AgentId, version: s
|
|
|
344
359
|
* droid) have no such tarball and are returned unchanged.
|
|
345
360
|
*/
|
|
346
361
|
export declare function ensureAgentRunnable(agent: AgentId, version: string, log?: (message: string) => void): Promise<string | null>;
|
|
362
|
+
export declare function healBrokenDefaultLaunches(log?: (m: string) => void): Promise<string[]>;
|
|
347
363
|
/** Outcome of syncing resources to a version home, keyed by resource type. */
|
|
348
364
|
export interface SyncResult {
|
|
349
365
|
commands: boolean;
|
package/dist/lib/versions.js
CHANGED
|
@@ -33,7 +33,7 @@ import { discoverPermissionGroups, getActivePermissionPresetName, readPermission
|
|
|
33
33
|
import { parseMcpServerConfig } from './mcp.js';
|
|
34
34
|
import { createVersionedAlias, removeVersionedAlias, getConfigSymlinkVersion, ensureClaudeInsideSymlink } from './shims.js';
|
|
35
35
|
import { importInstallScriptBinary } from './import.js';
|
|
36
|
-
import { IS_WINDOWS } from './platform/index.js';
|
|
36
|
+
import { IS_WINDOWS, composeWin32CommandLine } from './platform/index.js';
|
|
37
37
|
import { pruneVersionHomeHookEntriesFromSettings } from './hooks.js';
|
|
38
38
|
import { supports, explainSkip } from './capabilities.js';
|
|
39
39
|
import { discoverPlugins } from './plugins.js';
|
|
@@ -1621,22 +1621,48 @@ export function isMissingBinarySignature(output) {
|
|
|
1621
1621
|
* missing-binary signature (see isMissingBinarySignature) fails the check; a
|
|
1622
1622
|
* plain nonzero exit or a timeout is treated as healthy so we never false-fail.
|
|
1623
1623
|
*/
|
|
1624
|
+
/**
|
|
1625
|
+
* Compose the spawn spec for a `<binary> --version` launch probe. On Windows the
|
|
1626
|
+
* `.cmd` wrapper runs through cmd.exe, so the path is fully quoted into ONE
|
|
1627
|
+
* command line and the args array is emptied (composeWin32CommandLine) — the
|
|
1628
|
+
* DEP0190-safe pattern the real launch uses. Critically this keeps a spaced
|
|
1629
|
+
* Windows profile path (`C:\Users\John Doe\…\claude.cmd`) intact; passing the raw
|
|
1630
|
+
* path to a shell would split it at the space and false-fail a HEALTHY install.
|
|
1631
|
+
* On POSIX no shell is involved and the binary is exec'd directly. Pure/exported
|
|
1632
|
+
* so the quoting is unit-testable without spawning.
|
|
1633
|
+
*/
|
|
1634
|
+
export function probeSpawnSpec(binary, isWin) {
|
|
1635
|
+
if (isWin)
|
|
1636
|
+
return { command: composeWin32CommandLine(binary, ['--version']), args: [], shell: true };
|
|
1637
|
+
return { command: binary, args: ['--version'], shell: false };
|
|
1638
|
+
}
|
|
1624
1639
|
export async function verifyInstalledBinaryLaunches(agent, version) {
|
|
1625
|
-
//
|
|
1626
|
-
//
|
|
1627
|
-
//
|
|
1628
|
-
//
|
|
1629
|
-
//
|
|
1630
|
-
//
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
const
|
|
1640
|
+
// The real launch target differs by platform, so probe whatever `agents run`
|
|
1641
|
+
// actually execs. On Windows that's the npm `.cmd` wrapper (exec.ts uses
|
|
1642
|
+
// `absPath + '.cmd'`), which chains to the native `.exe`; a gutted install
|
|
1643
|
+
// (renamed/missing `.exe`) makes that wrapper emit "is not recognized" — the
|
|
1644
|
+
// exact win-mini failure a vendor auto-update leaves behind. Probing the
|
|
1645
|
+
// extensionless `.bin/<cli>` instead would ENOENT even on a HEALTHY Windows
|
|
1646
|
+
// install, so we DON'T. On POSIX the `.bin/<cli>` binary is the launch target
|
|
1647
|
+
// and is probed directly.
|
|
1648
|
+
const isWin = process.platform === 'win32';
|
|
1649
|
+
const binary = isWin ? getBinaryPath(agent, version) + '.cmd' : getBinaryPath(agent, version);
|
|
1634
1650
|
if (!fs.existsSync(binary)) {
|
|
1635
|
-
|
|
1651
|
+
// Windows: a missing `.cmd` means a non-npm/global agent (droid.exe) we can't
|
|
1652
|
+
// safely probe — treat as healthy (isVersionInstalled validates presence).
|
|
1653
|
+
// POSIX: a missing launch binary is a genuine gutted install.
|
|
1654
|
+
return isWin ? { ok: true } : { ok: false, detail: `binary not found at ${binary}` };
|
|
1636
1655
|
}
|
|
1637
1656
|
try {
|
|
1638
|
-
|
|
1657
|
+
// On Windows the `.cmd` runs via cmd.exe (shell). Pass a single FULLY-QUOTED
|
|
1658
|
+
// command line + EMPTY args (composeWin32CommandLine) — the same DEP0190-safe
|
|
1659
|
+
// pattern the real launch uses (exec.ts) — so a space in the Windows profile
|
|
1660
|
+
// path (`C:\Users\John Doe\…`) can't split the path and false-fail a healthy
|
|
1661
|
+
// install into a destructive reinstall.
|
|
1662
|
+
const spec = probeSpawnSpec(binary, isWin);
|
|
1663
|
+
await execFileAsync(spec.command, spec.args, {
|
|
1639
1664
|
timeout: 15000,
|
|
1665
|
+
shell: spec.shell,
|
|
1640
1666
|
env: { ...process.env, HOME: getVersionHomePath(agent, version) },
|
|
1641
1667
|
});
|
|
1642
1668
|
return { ok: true };
|
|
@@ -1703,6 +1729,51 @@ export async function ensureAgentRunnable(agent, version, log) {
|
|
|
1703
1729
|
}
|
|
1704
1730
|
return null;
|
|
1705
1731
|
}
|
|
1732
|
+
/**
|
|
1733
|
+
* Proactive launch-health pass for the daemon. Probe the DEFAULT version of
|
|
1734
|
+
* every npm-package agent and repair any that won't launch (via
|
|
1735
|
+
* ensureAgentRunnable), so a gutted install is healed BEFORE the user's next
|
|
1736
|
+
* `agents run` hits a raw ENOENT — the run-time heal (ensureAgentRunnable) only
|
|
1737
|
+
* fires once a run is already starting; this catches it in the background.
|
|
1738
|
+
*
|
|
1739
|
+
* Returns a label (`agent@broken→healed`) for each version actually repaired, so
|
|
1740
|
+
* the daemon can log/notify. A version that already launches costs one cheap
|
|
1741
|
+
* `--version` probe and is left untouched.
|
|
1742
|
+
*/
|
|
1743
|
+
const failedRepairAt = new Map();
|
|
1744
|
+
const REPAIR_COOLDOWN_MS = 24 * 60 * 60_000;
|
|
1745
|
+
export async function healBrokenDefaultLaunches(log) {
|
|
1746
|
+
const repaired = [];
|
|
1747
|
+
for (const agent of Object.keys(AGENTS)) {
|
|
1748
|
+
if (!AGENTS[agent].npmPackage)
|
|
1749
|
+
continue; // native/global agents have no gutted-tarball failure mode
|
|
1750
|
+
const version = getGlobalDefault(agent);
|
|
1751
|
+
if (!version)
|
|
1752
|
+
continue;
|
|
1753
|
+
if ((await verifyInstalledBinaryLaunches(agent, version)).ok)
|
|
1754
|
+
continue;
|
|
1755
|
+
// Backoff: a version whose repair just failed (offline, npm 404, an arch the
|
|
1756
|
+
// registry can't serve) must NOT re-trigger a full clean-reinstall +
|
|
1757
|
+
// install-latest on every 6h pass. Skip it for a day; a daemon restart clears
|
|
1758
|
+
// the memo, giving a fresh attempt.
|
|
1759
|
+
const key = `${agent}@${version}`;
|
|
1760
|
+
const last = failedRepairAt.get(key);
|
|
1761
|
+
if (last !== undefined && Date.now() - last < REPAIR_COOLDOWN_MS) {
|
|
1762
|
+
log?.(`${AGENTS[agent].name}@${version} still won't launch — repair attempted recently, skipping until cooldown elapses.`);
|
|
1763
|
+
continue;
|
|
1764
|
+
}
|
|
1765
|
+
log?.(`${AGENTS[agent].name}@${version} won't launch — repairing…`);
|
|
1766
|
+
const healed = await ensureAgentRunnable(agent, version, log);
|
|
1767
|
+
if (healed) {
|
|
1768
|
+
failedRepairAt.delete(key);
|
|
1769
|
+
repaired.push(`${agent}@${version}${healed === version ? '' : `→${healed}`}`);
|
|
1770
|
+
}
|
|
1771
|
+
else {
|
|
1772
|
+
failedRepairAt.set(key, Date.now());
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
return repaired;
|
|
1776
|
+
}
|
|
1706
1777
|
async function getCliVersionFromPath(agent) {
|
|
1707
1778
|
const agentConfig = AGENTS[agent];
|
|
1708
1779
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.47",
|
|
4
4
|
"description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|