@phnx-labs/agents-cli 1.20.4 → 1.20.6
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 +19 -0
- package/README.md +49 -18
- package/dist/commands/browser.js +31 -4
- package/dist/commands/cli.js +1 -1
- package/dist/commands/cloud.js +1 -1
- package/dist/commands/commands.js +2 -0
- package/dist/commands/computer.js +10 -2
- package/dist/commands/defaults.d.ts +7 -0
- package/dist/commands/defaults.js +89 -0
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/exec.js +73 -19
- package/dist/commands/hooks.js +6 -6
- package/dist/commands/inspect.d.ts +26 -0
- package/dist/commands/inspect.js +590 -0
- package/dist/commands/mcp.js +17 -16
- package/dist/commands/models.js +1 -1
- package/dist/commands/packages.js +6 -4
- package/dist/commands/permissions.js +13 -12
- package/dist/commands/plugins.d.ts +13 -0
- package/dist/commands/plugins.js +100 -11
- package/dist/commands/prune.js +3 -2
- package/dist/commands/pull.d.ts +12 -5
- package/dist/commands/pull.js +26 -422
- package/dist/commands/push.d.ts +14 -0
- package/dist/commands/push.js +30 -0
- package/dist/commands/repo.d.ts +1 -1
- package/dist/commands/repo.js +155 -112
- package/dist/commands/resource-view.d.ts +2 -0
- package/dist/commands/resource-view.js +12 -3
- package/dist/commands/routines.js +32 -7
- package/dist/commands/rules.js +4 -4
- package/dist/commands/secrets.js +46 -9
- package/dist/commands/sessions.js +1 -0
- package/dist/commands/setup.d.ts +3 -3
- package/dist/commands/setup.js +17 -17
- package/dist/commands/skills.js +6 -5
- package/dist/commands/subagents.js +5 -4
- package/dist/commands/sync.d.ts +18 -5
- package/dist/commands/sync.js +251 -65
- package/dist/commands/teams.js +109 -11
- package/dist/commands/tmux.d.ts +25 -0
- package/dist/commands/tmux.js +415 -0
- package/dist/commands/trash.d.ts +2 -2
- package/dist/commands/trash.js +1 -1
- package/dist/commands/versions.js +2 -2
- package/dist/commands/view.d.ts +12 -1
- package/dist/commands/view.js +128 -40
- package/dist/commands/workflows.js +4 -3
- package/dist/commands/worktree.d.ts +4 -5
- package/dist/commands/worktree.js +4 -4
- package/dist/index.js +106 -41
- package/dist/lib/agents.d.ts +23 -10
- package/dist/lib/agents.js +88 -25
- package/dist/lib/auto-pull-worker.d.ts +1 -1
- package/dist/lib/auto-pull-worker.js +2 -2
- package/dist/lib/auto-pull.d.ts +1 -1
- package/dist/lib/auto-pull.js +1 -1
- package/dist/lib/beta.d.ts +1 -1
- package/dist/lib/beta.js +1 -1
- package/dist/lib/browser/chrome.d.ts +10 -0
- package/dist/lib/browser/chrome.js +84 -3
- package/dist/lib/capabilities.js +2 -0
- package/dist/lib/commands.d.ts +28 -1
- package/dist/lib/commands.js +125 -20
- package/dist/lib/doctor-diff.js +2 -2
- package/dist/lib/exec.d.ts +14 -0
- package/dist/lib/exec.js +59 -5
- package/dist/lib/fuzzy.d.ts +12 -2
- package/dist/lib/fuzzy.js +29 -4
- package/dist/lib/git.js +8 -1
- package/dist/lib/hooks.d.ts +2 -2
- package/dist/lib/hooks.js +97 -10
- package/dist/lib/mcp.js +32 -2
- package/dist/lib/migrate.d.ts +51 -0
- package/dist/lib/migrate.js +233 -5
- package/dist/lib/models.js +62 -15
- package/dist/lib/permissions.d.ts +59 -2
- package/dist/lib/permissions.js +299 -7
- package/dist/lib/plugin-marketplace.d.ts +98 -40
- package/dist/lib/plugin-marketplace.js +196 -93
- package/dist/lib/plugins.d.ts +21 -4
- package/dist/lib/plugins.js +130 -49
- package/dist/lib/profiles-presets.js +12 -12
- package/dist/lib/project-launch.d.ts +70 -0
- package/dist/lib/project-launch.js +404 -0
- package/dist/lib/pty-client.js +1 -1
- package/dist/lib/pty-server.d.ts +1 -1
- package/dist/lib/pty-server.js +8 -5
- package/dist/lib/refresh.d.ts +26 -0
- package/dist/lib/refresh.js +315 -0
- package/dist/lib/resource-patterns.d.ts +1 -1
- package/dist/lib/resource-patterns.js +1 -1
- package/dist/lib/resources/commands.js +2 -2
- package/dist/lib/resources/hooks.d.ts +1 -1
- package/dist/lib/resources/hooks.js +1 -1
- package/dist/lib/resources/mcp.d.ts +1 -1
- package/dist/lib/resources/mcp.js +5 -6
- package/dist/lib/resources/permissions.js +5 -2
- package/dist/lib/resources/rules.js +3 -2
- package/dist/lib/resources/skills.js +3 -2
- package/dist/lib/resources/types.d.ts +1 -1
- package/dist/lib/resources.d.ts +2 -0
- package/dist/lib/resources.js +4 -3
- package/dist/lib/rotate.d.ts +1 -1
- package/dist/lib/rotate.js +7 -19
- package/dist/lib/routines.d.ts +16 -4
- package/dist/lib/routines.js +67 -17
- package/dist/lib/rules/compile.js +22 -10
- package/dist/lib/rules/rules.js +3 -3
- package/dist/lib/run-config.d.ts +9 -0
- package/dist/lib/run-config.js +35 -0
- package/dist/lib/run-defaults.d.ts +42 -0
- package/dist/lib/run-defaults.js +180 -0
- package/dist/lib/runner.js +16 -3
- package/dist/lib/scheduler.js +15 -1
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +9 -1
- package/dist/lib/secrets/Agents CLI.app/Contents/embedded.provisionprofile +0 -0
- package/dist/lib/secrets/install-helper.d.ts +11 -3
- package/dist/lib/secrets/install-helper.js +48 -6
- package/dist/lib/secrets/linux.d.ts +56 -9
- package/dist/lib/secrets/linux.js +327 -59
- package/dist/lib/session/db.js +15 -2
- package/dist/lib/session/discover.js +118 -3
- package/dist/lib/session/parse.js +3 -0
- package/dist/lib/session/types.d.ts +1 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/shims.d.ts +18 -9
- package/dist/lib/shims.js +133 -50
- package/dist/lib/skills.d.ts +1 -1
- package/dist/lib/skills.js +10 -9
- package/dist/lib/staleness/detectors/commands.d.ts +3 -0
- package/dist/lib/staleness/detectors/commands.js +46 -0
- package/dist/lib/staleness/detectors/hooks.d.ts +3 -0
- package/dist/lib/staleness/detectors/hooks.js +44 -0
- package/dist/lib/staleness/detectors/mcp.d.ts +3 -0
- package/dist/lib/staleness/detectors/mcp.js +31 -0
- package/dist/lib/staleness/detectors/permissions.d.ts +3 -0
- package/dist/lib/staleness/detectors/permissions.js +201 -0
- package/dist/lib/staleness/detectors/plugins.d.ts +8 -0
- package/dist/lib/staleness/detectors/plugins.js +23 -0
- package/dist/lib/staleness/detectors/rules.d.ts +3 -0
- package/dist/lib/staleness/detectors/rules.js +34 -0
- package/dist/lib/staleness/detectors/skills.d.ts +3 -0
- package/dist/lib/staleness/detectors/skills.js +71 -0
- package/dist/lib/staleness/detectors/subagents.d.ts +3 -0
- package/dist/lib/staleness/detectors/subagents.js +50 -0
- package/dist/lib/staleness/detectors/types.d.ts +22 -0
- package/dist/lib/staleness/detectors/types.js +1 -0
- package/dist/lib/staleness/detectors/workflows.d.ts +3 -0
- package/dist/lib/staleness/detectors/workflows.js +28 -0
- package/dist/lib/staleness/registry.d.ts +26 -0
- package/dist/lib/staleness/registry.js +123 -0
- package/dist/lib/staleness/writers/commands.d.ts +3 -0
- package/dist/lib/staleness/writers/commands.js +111 -0
- package/dist/lib/staleness/writers/hooks.d.ts +3 -0
- package/dist/lib/staleness/writers/hooks.js +47 -0
- package/dist/lib/staleness/writers/kinds.d.ts +10 -0
- package/dist/lib/staleness/writers/kinds.js +15 -0
- package/dist/lib/staleness/writers/lazy-map.d.ts +13 -0
- package/dist/lib/staleness/writers/lazy-map.js +19 -0
- package/dist/lib/staleness/writers/mcp.d.ts +10 -0
- package/dist/lib/staleness/writers/mcp.js +19 -0
- package/dist/lib/staleness/writers/permissions.d.ts +13 -0
- package/dist/lib/staleness/writers/permissions.js +26 -0
- package/dist/lib/staleness/writers/plugins.d.ts +7 -0
- package/dist/lib/staleness/writers/plugins.js +31 -0
- package/dist/lib/staleness/writers/rules.d.ts +7 -0
- package/dist/lib/staleness/writers/rules.js +55 -0
- package/dist/lib/staleness/writers/skills.d.ts +3 -0
- package/dist/lib/staleness/writers/skills.js +81 -0
- package/dist/lib/staleness/writers/sources.d.ts +16 -0
- package/dist/lib/staleness/writers/sources.js +72 -0
- package/dist/lib/staleness/writers/subagents.d.ts +3 -0
- package/dist/lib/staleness/writers/subagents.js +53 -0
- package/dist/lib/staleness/writers/types.d.ts +36 -0
- package/dist/lib/staleness/writers/types.js +1 -0
- package/dist/lib/staleness/writers/workflows.d.ts +7 -0
- package/dist/lib/staleness/writers/workflows.js +31 -0
- package/dist/lib/state.d.ts +34 -11
- package/dist/lib/state.js +58 -13
- package/dist/lib/subagents.d.ts +0 -2
- package/dist/lib/subagents.js +6 -6
- package/dist/lib/teams/agents.js +1 -1
- package/dist/lib/teams/api.d.ts +67 -0
- package/dist/lib/teams/api.js +78 -0
- package/dist/lib/teams/parsers.d.ts +1 -1
- package/dist/lib/tmux/binary.d.ts +67 -0
- package/dist/lib/tmux/binary.js +141 -0
- package/dist/lib/tmux/index.d.ts +8 -0
- package/dist/lib/tmux/index.js +8 -0
- package/dist/lib/tmux/paths.d.ts +17 -0
- package/dist/lib/tmux/paths.js +30 -0
- package/dist/lib/tmux/session.d.ts +122 -0
- package/dist/lib/tmux/session.js +305 -0
- package/dist/lib/types.d.ts +73 -13
- package/dist/lib/types.js +1 -1
- package/dist/lib/usage.js +1 -1
- package/dist/lib/versions.d.ts +4 -4
- package/dist/lib/versions.js +138 -496
- package/dist/lib/workflows.d.ts +2 -4
- package/dist/lib/workflows.js +3 -4
- package/package.json +6 -3
- package/scripts/postinstall.js +16 -63
- package/dist/commands/status.d.ts +0 -9
- package/dist/commands/status.js +0 -25
|
@@ -14,14 +14,23 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { fileURLToPath } from 'url';
|
|
16
16
|
import { spawnSync } from 'child_process';
|
|
17
|
+
import { createHash } from 'crypto';
|
|
17
18
|
import * as fs from 'fs';
|
|
18
19
|
import * as os from 'os';
|
|
19
20
|
import * as path from 'path';
|
|
20
21
|
const APP_BUNDLE_NAME = 'Agents CLI.app';
|
|
21
22
|
const INSTALL_DIR_NAME = 'agents-cli';
|
|
23
|
+
let installRootOverride = null;
|
|
24
|
+
/** Redirect the install root (test only). Returns the previous override so callers can restore. */
|
|
25
|
+
export function setInstallRootForTest(dir) {
|
|
26
|
+
const prev = installRootOverride;
|
|
27
|
+
installRootOverride = dir;
|
|
28
|
+
return prev;
|
|
29
|
+
}
|
|
22
30
|
/** Absolute path to the installed `.app` bundle directory (not the executable). */
|
|
23
31
|
function installedAppPath() {
|
|
24
|
-
|
|
32
|
+
const root = installRootOverride ?? os.homedir();
|
|
33
|
+
return path.join(root, 'Library', 'Application Support', INSTALL_DIR_NAME, APP_BUNDLE_NAME);
|
|
25
34
|
}
|
|
26
35
|
/** Absolute path to the executable inside the installed `.app` bundle. */
|
|
27
36
|
function installedExecutablePath() {
|
|
@@ -57,6 +66,33 @@ function assertDarwin() {
|
|
|
57
66
|
throw new Error('Keychain helper is macOS only.');
|
|
58
67
|
}
|
|
59
68
|
}
|
|
69
|
+
function sha256File(filePath) {
|
|
70
|
+
return createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* True when the installed helper executable differs byte-for-byte from the
|
|
74
|
+
* bundled source helper. A stale-but-validly-signed helper is exactly how the
|
|
75
|
+
* broken 1.20.4 build (signed, but missing the keychain-access-groups
|
|
76
|
+
* entitlement) survived upgrades: codesign --verify passes on it, so the
|
|
77
|
+
* "exists and verifies" early-return kept it installed forever. Returns false
|
|
78
|
+
* when there is no bundled source to compare against (dev installs) or no
|
|
79
|
+
* installed copy yet — both cases are decided by the existence checks in the
|
|
80
|
+
* callers, not by staleness.
|
|
81
|
+
*/
|
|
82
|
+
function installedHelperIsStale() {
|
|
83
|
+
let srcApp;
|
|
84
|
+
try {
|
|
85
|
+
srcApp = sourceAppPath();
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
const srcExec = path.join(srcApp, 'Contents', 'MacOS', 'Agents CLI');
|
|
91
|
+
const destExec = installedExecutablePath();
|
|
92
|
+
if (!fs.existsSync(srcExec) || !fs.existsSync(destExec))
|
|
93
|
+
return false;
|
|
94
|
+
return sha256File(srcExec) !== sha256File(destExec);
|
|
95
|
+
}
|
|
60
96
|
function codesignVerify(appPath) {
|
|
61
97
|
const r = spawnSync('codesign', ['--verify', '--deep', '--strict', appPath], {
|
|
62
98
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -86,8 +122,10 @@ function copyAppBundle(src, dest) {
|
|
|
86
122
|
}
|
|
87
123
|
/**
|
|
88
124
|
* Idempotent install. Copies the bundled `.app` to the stable user path. Skips
|
|
89
|
-
* if the destination already exists
|
|
90
|
-
*
|
|
125
|
+
* if the destination already exists, `codesign --verify` passes, AND the
|
|
126
|
+
* installed executable matches the bundled one byte-for-byte — a valid
|
|
127
|
+
* signature alone is not enough, because an outdated helper signs clean too.
|
|
128
|
+
* `forceReinstall=true` skips all checks and always copies.
|
|
91
129
|
*
|
|
92
130
|
* Notarization is checked via `spctl --assess` after install — a failure is
|
|
93
131
|
* logged as a warning but does NOT throw. Notarization checks require network
|
|
@@ -99,7 +137,7 @@ export function ensureKeychainHelperInstalled(opts = {}) {
|
|
|
99
137
|
const dest = installedAppPath();
|
|
100
138
|
if (!opts.forceReinstall && fs.existsSync(dest)) {
|
|
101
139
|
const { ok } = codesignVerify(dest);
|
|
102
|
-
if (ok)
|
|
140
|
+
if (ok && !installedHelperIsStale())
|
|
103
141
|
return;
|
|
104
142
|
}
|
|
105
143
|
const src = sourceAppPath();
|
|
@@ -119,14 +157,18 @@ export function ensureKeychainHelperInstalled(opts = {}) {
|
|
|
119
157
|
}
|
|
120
158
|
/**
|
|
121
159
|
* Return the absolute path to the helper executable. If the installed bundle
|
|
122
|
-
* is missing,
|
|
160
|
+
* is missing, or is stale relative to the bundled source helper, performs a
|
|
161
|
+
* lazy (re)install first. The staleness check is what lets an upgraded CLI
|
|
162
|
+
* replace a helper a previous version installed — `agents helper install`
|
|
163
|
+
* never runs on `npm i -g`, so this call site is the only one every machine
|
|
164
|
+
* is guaranteed to pass through.
|
|
123
165
|
*
|
|
124
166
|
* Throws on non-darwin.
|
|
125
167
|
*/
|
|
126
168
|
export function getKeychainHelperPath() {
|
|
127
169
|
assertDarwin();
|
|
128
170
|
const exec = installedExecutablePath();
|
|
129
|
-
if (!fs.existsSync(exec)) {
|
|
171
|
+
if (!fs.existsSync(exec) || installedHelperIsStale()) {
|
|
130
172
|
ensureKeychainHelperInstalled();
|
|
131
173
|
}
|
|
132
174
|
return exec;
|
|
@@ -1,27 +1,74 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Linux secret storage via libsecret (GNOME Keyring / Secret Service API).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* On Ubuntu: apt install libsecret-tools
|
|
4
|
+
* Primary backend: `secret-tool` CLI (libsecret-tools package).
|
|
6
5
|
*
|
|
7
|
-
*
|
|
6
|
+
* Headless fallback: when the default Secret Service collection is locked
|
|
7
|
+
* (common on server-class Linux — no graphical login means the keyring
|
|
8
|
+
* passphrase never enters the daemon, so `secret-tool store` fails with
|
|
9
|
+
* "Cannot create an item in a locked collection"), we transparently switch
|
|
10
|
+
* to a file-based AES-256-GCM encrypted store under
|
|
11
|
+
* `~/.agents/.cache/secrets/`. The encryption key is scrypt-derived from a
|
|
12
|
+
* passphrase read from `AGENTS_SECRETS_PASSPHRASE` (preferred) or a TTY
|
|
13
|
+
* prompt. The decision is cached per process; one stderr line is emitted
|
|
14
|
+
* the first time the fallback activates.
|
|
15
|
+
*
|
|
16
|
+
* Secrets stored via secret-tool use:
|
|
8
17
|
* service = "agents-cli"
|
|
9
18
|
* account = username
|
|
10
|
-
* item
|
|
19
|
+
* item = the secret identifier
|
|
20
|
+
*
|
|
21
|
+
* File-fallback layout: one `<item>.enc` JSON file per item, mode 0600.
|
|
11
22
|
*/
|
|
12
23
|
import type { KeychainBackend } from './index.js';
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
24
|
+
/** Encrypted-file on-disk shape. Exported for tests. */
|
|
25
|
+
export interface EncFile {
|
|
26
|
+
salt: string;
|
|
27
|
+
iv: string;
|
|
28
|
+
authTag: string;
|
|
29
|
+
ciphertext: string;
|
|
30
|
+
}
|
|
31
|
+
/** Encrypt plaintext under a passphrase using AES-256-GCM with a random
|
|
32
|
+
* scrypt salt and a random 96-bit IV. Exported for tests. */
|
|
33
|
+
export declare function encryptForFallback(plaintext: string, passphrase: string): EncFile;
|
|
34
|
+
/** Decrypt an EncFile under a passphrase. Throws on wrong key or tampered
|
|
35
|
+
* ciphertext (auth-tag mismatch). Exported for tests. */
|
|
36
|
+
export declare function decryptForFallback(enc: EncFile, passphrase: string): string;
|
|
37
|
+
/** File-only KeychainBackend (exported for tests; the public surface uses
|
|
38
|
+
* the secret-tool-with-fallback `linuxBackend` below). */
|
|
39
|
+
export declare const fileBackend: KeychainBackend;
|
|
40
|
+
/** secret-tool lookup attributes:
|
|
41
|
+
* service=agents-cli account=<user> item=<itemName> */
|
|
17
42
|
export declare function hasSecretToolToken(item: string): boolean;
|
|
18
43
|
export declare function getSecretToolToken(item: string): string;
|
|
19
44
|
export declare function setSecretToolToken(item: string, value: string): void;
|
|
20
45
|
export declare function deleteSecretToolToken(item: string): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Parse the item names out of `secret-tool search --all` output, keeping only
|
|
48
|
+
* those starting with `prefix`. Exported for tests.
|
|
49
|
+
*
|
|
50
|
+
* `output` must be the combined stdout+stderr of the search: libsecret splits
|
|
51
|
+
* the dump across both streams — the value/label/schema lines go to stdout
|
|
52
|
+
* while the `attribute.*` lines (which carry `attribute.item`, the only place
|
|
53
|
+
* the item name is reliably machine-readable) go to stderr (observed on
|
|
54
|
+
* libsecret 0.21.4). Which stream each line lands on has varied across
|
|
55
|
+
* libsecret versions, so callers concatenate both rather than bet on one.
|
|
56
|
+
*/
|
|
57
|
+
export declare function parseSecretToolItems(output: string, prefix: string): string[];
|
|
21
58
|
/**
|
|
22
59
|
* List secrets by prefix. secret-tool doesn't have a list command,
|
|
23
60
|
* so we use secret-tool search which outputs in a specific format.
|
|
24
61
|
*/
|
|
25
62
|
export declare function listSecretToolItems(prefix: string): string[];
|
|
26
|
-
/** KeychainBackend implementation for Linux
|
|
63
|
+
/** KeychainBackend implementation for Linux. Routes through secret-tool
|
|
64
|
+
* with a transparent encrypted-file fallback when the default Secret
|
|
65
|
+
* Service collection is locked (or libsecret-tools is not installed but
|
|
66
|
+
* AGENTS_SECRETS_PASSPHRASE is set). */
|
|
27
67
|
export declare const linuxBackend: KeychainBackend;
|
|
68
|
+
/** Test-only: reset module state so independent test cases don't bleed
|
|
69
|
+
* passphrase / fallback decisions across each other. */
|
|
70
|
+
export declare function _resetForTest(opts?: {
|
|
71
|
+
fileDir?: string | null;
|
|
72
|
+
forceFileFallback?: boolean;
|
|
73
|
+
passphrase?: string | null;
|
|
74
|
+
}): void;
|
|
@@ -1,17 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Linux secret storage via libsecret (GNOME Keyring / Secret Service API).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* On Ubuntu: apt install libsecret-tools
|
|
4
|
+
* Primary backend: `secret-tool` CLI (libsecret-tools package).
|
|
6
5
|
*
|
|
7
|
-
*
|
|
6
|
+
* Headless fallback: when the default Secret Service collection is locked
|
|
7
|
+
* (common on server-class Linux — no graphical login means the keyring
|
|
8
|
+
* passphrase never enters the daemon, so `secret-tool store` fails with
|
|
9
|
+
* "Cannot create an item in a locked collection"), we transparently switch
|
|
10
|
+
* to a file-based AES-256-GCM encrypted store under
|
|
11
|
+
* `~/.agents/.cache/secrets/`. The encryption key is scrypt-derived from a
|
|
12
|
+
* passphrase read from `AGENTS_SECRETS_PASSPHRASE` (preferred) or a TTY
|
|
13
|
+
* prompt. The decision is cached per process; one stderr line is emitted
|
|
14
|
+
* the first time the fallback activates.
|
|
15
|
+
*
|
|
16
|
+
* Secrets stored via secret-tool use:
|
|
8
17
|
* service = "agents-cli"
|
|
9
18
|
* account = username
|
|
10
|
-
* item
|
|
19
|
+
* item = the secret identifier
|
|
20
|
+
*
|
|
21
|
+
* File-fallback layout: one `<item>.enc` JSON file per item, mode 0600.
|
|
11
22
|
*/
|
|
12
|
-
import { spawnSync } from 'child_process';
|
|
23
|
+
import { spawnSync, execSync } from 'child_process';
|
|
24
|
+
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';
|
|
25
|
+
import * as fs from 'fs';
|
|
13
26
|
import * as os from 'os';
|
|
27
|
+
import * as path from 'path';
|
|
14
28
|
const SERVICE = 'agents-cli';
|
|
29
|
+
// ---------- secret-tool availability ----------
|
|
15
30
|
function secretToolAvailable() {
|
|
16
31
|
const result = spawnSync('which', ['secret-tool'], {
|
|
17
32
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -20,111 +35,328 @@ function secretToolAvailable() {
|
|
|
20
35
|
}
|
|
21
36
|
let checkedAvailability = false;
|
|
22
37
|
let isAvailable = false;
|
|
23
|
-
|
|
38
|
+
// ---------- file fallback state ----------
|
|
39
|
+
let useFileFallback = false;
|
|
40
|
+
let warnedFallback = false;
|
|
41
|
+
let fileDirOverride = null;
|
|
42
|
+
let cachedPassphrase = null;
|
|
43
|
+
function fileDir() {
|
|
44
|
+
return fileDirOverride ?? path.join(os.homedir(), '.agents', '.cache', 'secrets');
|
|
45
|
+
}
|
|
46
|
+
function activateFileFallback() {
|
|
47
|
+
if (useFileFallback)
|
|
48
|
+
return;
|
|
49
|
+
useFileFallback = true;
|
|
50
|
+
if (!warnedFallback) {
|
|
51
|
+
warnedFallback = true;
|
|
52
|
+
process.stderr.write(`[agents] secret-service collection locked, using file-based store at ${fileDir()}\n`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function isLockedCollectionError(stderr) {
|
|
56
|
+
return /locked collection/i.test(stderr) ||
|
|
57
|
+
/Prompt was dismissed/i.test(stderr);
|
|
58
|
+
}
|
|
59
|
+
/** True if the fallback dir has any committed encrypted items. Means an
|
|
60
|
+
* earlier process (this one or another) already routed writes to the file
|
|
61
|
+
* store, so this process must keep reading/writing from the same store —
|
|
62
|
+
* otherwise `list` / `get` / `has` would silently miss them. */
|
|
63
|
+
function fileFallbackPreviouslyActivated() {
|
|
64
|
+
try {
|
|
65
|
+
return fs.readdirSync(fileDir()).some((e) => e.endsWith('.enc'));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Decide which backend a given op should use. Activates file fallback if
|
|
73
|
+
* `secret-tool` is missing and `AGENTS_SECRETS_PASSPHRASE` is set, OR if a
|
|
74
|
+
* previous run already committed to the file fallback (encrypted items on
|
|
75
|
+
* disk). The latter check is what makes the fallback persistent across the
|
|
76
|
+
* many short-lived `agents secrets ...` Node processes a user invokes.
|
|
77
|
+
*/
|
|
78
|
+
function preflight() {
|
|
79
|
+
if (useFileFallback)
|
|
80
|
+
return 'file';
|
|
81
|
+
if (fileFallbackPreviouslyActivated()) {
|
|
82
|
+
activateFileFallback();
|
|
83
|
+
return 'file';
|
|
84
|
+
}
|
|
24
85
|
if (!checkedAvailability) {
|
|
25
86
|
isAvailable = secretToolAvailable();
|
|
26
87
|
checkedAvailability = true;
|
|
27
88
|
}
|
|
28
89
|
if (!isAvailable) {
|
|
90
|
+
if (process.env.AGENTS_SECRETS_PASSPHRASE) {
|
|
91
|
+
activateFileFallback();
|
|
92
|
+
return 'file';
|
|
93
|
+
}
|
|
29
94
|
throw new Error('secret-tool not found. Install libsecret-tools:\n' +
|
|
30
95
|
' Ubuntu/Debian: sudo apt install libsecret-tools\n' +
|
|
31
96
|
' Fedora: sudo dnf install libsecret\n' +
|
|
32
|
-
' Arch: sudo pacman -S libsecret'
|
|
97
|
+
' Arch: sudo pacman -S libsecret\n' +
|
|
98
|
+
'\n' +
|
|
99
|
+
'Alternative: set AGENTS_SECRETS_PASSPHRASE to use the encrypted-file fallback.');
|
|
33
100
|
}
|
|
101
|
+
return 'secret-tool';
|
|
34
102
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
103
|
+
// ---------- passphrase ----------
|
|
104
|
+
function readPassphraseFromTty() {
|
|
105
|
+
const fd = fs.openSync('/dev/tty', 'r+');
|
|
106
|
+
let echoDisabled = false;
|
|
107
|
+
try {
|
|
108
|
+
fs.writeSync(fd, 'Enter AGENTS_SECRETS_PASSPHRASE: ');
|
|
109
|
+
try {
|
|
110
|
+
execSync('stty -echo < /dev/tty', { stdio: 'ignore' });
|
|
111
|
+
echoDisabled = true;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// stty not available — fall through; passphrase will echo. Better
|
|
115
|
+
// than refusing to function.
|
|
116
|
+
}
|
|
117
|
+
let pass = '';
|
|
118
|
+
const buf = Buffer.alloc(1);
|
|
119
|
+
while (true) {
|
|
120
|
+
const n = fs.readSync(fd, buf, 0, 1, null);
|
|
121
|
+
if (n === 0)
|
|
122
|
+
break;
|
|
123
|
+
const ch = buf.toString('utf8', 0, n);
|
|
124
|
+
if (ch === '\n' || ch === '\r')
|
|
125
|
+
break;
|
|
126
|
+
pass += ch;
|
|
127
|
+
}
|
|
128
|
+
return pass;
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
if (echoDisabled) {
|
|
132
|
+
try {
|
|
133
|
+
execSync('stty echo < /dev/tty', { stdio: 'ignore' });
|
|
134
|
+
}
|
|
135
|
+
catch { /* best effort */ }
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
fs.writeSync(fd, '\n');
|
|
139
|
+
}
|
|
140
|
+
catch { /* best effort */ }
|
|
141
|
+
fs.closeSync(fd);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function getPassphrase() {
|
|
145
|
+
if (cachedPassphrase !== null)
|
|
146
|
+
return cachedPassphrase;
|
|
147
|
+
const env = process.env.AGENTS_SECRETS_PASSPHRASE;
|
|
148
|
+
if (env && env.length > 0) {
|
|
149
|
+
cachedPassphrase = env;
|
|
150
|
+
return env;
|
|
151
|
+
}
|
|
152
|
+
if (!process.stdin.isTTY) {
|
|
153
|
+
throw new Error('Secret-service collection is locked and no AGENTS_SECRETS_PASSPHRASE is set.\n' +
|
|
154
|
+
'Set AGENTS_SECRETS_PASSPHRASE in your environment to use the encrypted-file fallback,\n' +
|
|
155
|
+
'or unlock the keyring (e.g. configure pam_gnome_keyring for SSH login).');
|
|
156
|
+
}
|
|
157
|
+
const p = readPassphraseFromTty();
|
|
158
|
+
if (!p)
|
|
159
|
+
throw new Error('No passphrase entered.');
|
|
160
|
+
cachedPassphrase = p;
|
|
161
|
+
return p;
|
|
162
|
+
}
|
|
163
|
+
function deriveKey(passphrase, salt) {
|
|
164
|
+
return scryptSync(passphrase, salt, 32);
|
|
165
|
+
}
|
|
166
|
+
/** Encrypt plaintext under a passphrase using AES-256-GCM with a random
|
|
167
|
+
* scrypt salt and a random 96-bit IV. Exported for tests. */
|
|
168
|
+
export function encryptForFallback(plaintext, passphrase) {
|
|
169
|
+
const salt = randomBytes(16);
|
|
170
|
+
const iv = randomBytes(12);
|
|
171
|
+
const key = deriveKey(passphrase, salt);
|
|
172
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
173
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
174
|
+
return {
|
|
175
|
+
salt: salt.toString('hex'),
|
|
176
|
+
iv: iv.toString('hex'),
|
|
177
|
+
authTag: cipher.getAuthTag().toString('hex'),
|
|
178
|
+
ciphertext: ciphertext.toString('hex'),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/** Decrypt an EncFile under a passphrase. Throws on wrong key or tampered
|
|
182
|
+
* ciphertext (auth-tag mismatch). Exported for tests. */
|
|
183
|
+
export function decryptForFallback(enc, passphrase) {
|
|
184
|
+
const salt = Buffer.from(enc.salt, 'hex');
|
|
185
|
+
const iv = Buffer.from(enc.iv, 'hex');
|
|
186
|
+
const authTag = Buffer.from(enc.authTag, 'hex');
|
|
187
|
+
const ciphertext = Buffer.from(enc.ciphertext, 'hex');
|
|
188
|
+
const key = deriveKey(passphrase, salt);
|
|
189
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
190
|
+
decipher.setAuthTag(authTag);
|
|
191
|
+
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
192
|
+
return plaintext.toString('utf8');
|
|
193
|
+
}
|
|
194
|
+
// ---------- file backend ----------
|
|
195
|
+
function fileFor(item) {
|
|
196
|
+
return path.join(fileDir(), `${item}.enc`);
|
|
197
|
+
}
|
|
198
|
+
function ensureFileDir() {
|
|
199
|
+
fs.mkdirSync(fileDir(), { recursive: true, mode: 0o700 });
|
|
200
|
+
}
|
|
201
|
+
function fileHas(item) {
|
|
202
|
+
return fs.existsSync(fileFor(item));
|
|
203
|
+
}
|
|
204
|
+
function fileGet(item) {
|
|
205
|
+
const fp = fileFor(item);
|
|
206
|
+
if (!fs.existsSync(fp)) {
|
|
207
|
+
throw new Error(`Secret '${item}' not found in encrypted store.`);
|
|
208
|
+
}
|
|
209
|
+
const raw = fs.readFileSync(fp, 'utf8');
|
|
210
|
+
let parsed;
|
|
211
|
+
try {
|
|
212
|
+
parsed = JSON.parse(raw);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
throw new Error(`Encrypted secret file ${fp} is corrupt (not valid JSON).`);
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
return decryptForFallback(parsed, getPassphrase());
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
throw new Error(`Failed to decrypt '${item}'. Wrong AGENTS_SECRETS_PASSPHRASE or tampered file.`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
function fileSet(item, value) {
|
|
225
|
+
ensureFileDir();
|
|
226
|
+
const enc = encryptForFallback(value, getPassphrase());
|
|
227
|
+
fs.writeFileSync(fileFor(item), JSON.stringify(enc), { mode: 0o600 });
|
|
228
|
+
}
|
|
229
|
+
function fileDelete(item) {
|
|
230
|
+
const fp = fileFor(item);
|
|
231
|
+
if (!fs.existsSync(fp))
|
|
232
|
+
return true; // idempotent, matches secret-tool clear
|
|
233
|
+
fs.unlinkSync(fp);
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
function fileList(prefix) {
|
|
237
|
+
const dir = fileDir();
|
|
238
|
+
if (!fs.existsSync(dir))
|
|
239
|
+
return [];
|
|
240
|
+
return fs.readdirSync(dir)
|
|
241
|
+
.filter((f) => f.endsWith('.enc'))
|
|
242
|
+
.map((f) => f.slice(0, -'.enc'.length))
|
|
243
|
+
.filter((name) => name.startsWith(prefix));
|
|
244
|
+
}
|
|
245
|
+
/** File-only KeychainBackend (exported for tests; the public surface uses
|
|
246
|
+
* the secret-tool-with-fallback `linuxBackend` below). */
|
|
247
|
+
export const fileBackend = {
|
|
248
|
+
has: fileHas,
|
|
249
|
+
get: fileGet,
|
|
250
|
+
set: fileSet,
|
|
251
|
+
delete: fileDelete,
|
|
252
|
+
list: fileList,
|
|
253
|
+
};
|
|
254
|
+
// ---------- secret-tool ops with fallback ----------
|
|
255
|
+
/** secret-tool lookup attributes:
|
|
256
|
+
* service=agents-cli account=<user> item=<itemName> */
|
|
39
257
|
export function hasSecretToolToken(item) {
|
|
40
|
-
|
|
258
|
+
if (preflight() === 'file')
|
|
259
|
+
return fileHas(item);
|
|
41
260
|
const user = os.userInfo().username;
|
|
42
261
|
const result = spawnSync('secret-tool', [
|
|
43
262
|
'lookup',
|
|
44
263
|
'service', SERVICE,
|
|
45
264
|
'account', user,
|
|
46
265
|
'item', item,
|
|
47
|
-
], {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
266
|
+
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
267
|
+
if (result.status === 0) {
|
|
268
|
+
return result.stdout?.toString().trim().length > 0;
|
|
269
|
+
}
|
|
270
|
+
const stderr = result.stderr?.toString() ?? '';
|
|
271
|
+
if (isLockedCollectionError(stderr)) {
|
|
272
|
+
activateFileFallback();
|
|
273
|
+
return fileHas(item);
|
|
274
|
+
}
|
|
275
|
+
return false;
|
|
51
276
|
}
|
|
52
277
|
export function getSecretToolToken(item) {
|
|
53
|
-
|
|
278
|
+
if (preflight() === 'file')
|
|
279
|
+
return fileGet(item);
|
|
54
280
|
const user = os.userInfo().username;
|
|
55
281
|
const result = spawnSync('secret-tool', [
|
|
56
282
|
'lookup',
|
|
57
283
|
'service', SERVICE,
|
|
58
284
|
'account', user,
|
|
59
285
|
'item', item,
|
|
60
|
-
], {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
286
|
+
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
287
|
+
if (result.status === 0) {
|
|
288
|
+
const token = result.stdout?.toString().trim();
|
|
289
|
+
if (!token)
|
|
290
|
+
throw new Error(`Secret '${item}' exists but is empty.`);
|
|
291
|
+
return token;
|
|
65
292
|
}
|
|
66
|
-
const
|
|
67
|
-
if (
|
|
68
|
-
|
|
293
|
+
const stderr = result.stderr?.toString() ?? '';
|
|
294
|
+
if (isLockedCollectionError(stderr)) {
|
|
295
|
+
activateFileFallback();
|
|
296
|
+
return fileGet(item);
|
|
69
297
|
}
|
|
70
|
-
|
|
298
|
+
throw new Error(`Secret '${item}' not found in keyring.`);
|
|
71
299
|
}
|
|
72
300
|
export function setSecretToolToken(item, value) {
|
|
73
|
-
ensureSecretTool();
|
|
74
301
|
if (!value || !value.trim())
|
|
75
302
|
throw new Error('Secret value is empty.');
|
|
303
|
+
if (preflight() === 'file')
|
|
304
|
+
return fileSet(item, value);
|
|
76
305
|
const user = os.userInfo().username;
|
|
77
306
|
const label = `agents-cli: ${item}`;
|
|
78
|
-
// secret-tool store reads value from stdin
|
|
79
307
|
const result = spawnSync('secret-tool', [
|
|
80
308
|
'store',
|
|
81
309
|
'--label', label,
|
|
82
310
|
'service', SERVICE,
|
|
83
311
|
'account', user,
|
|
84
312
|
'item', item,
|
|
85
|
-
], {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
313
|
+
], { input: value, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
314
|
+
if (result.status === 0)
|
|
315
|
+
return;
|
|
316
|
+
const stderr = result.stderr?.toString().trim() ?? '';
|
|
317
|
+
if (isLockedCollectionError(stderr)) {
|
|
318
|
+
activateFileFallback();
|
|
319
|
+
fileSet(item, value);
|
|
320
|
+
return;
|
|
93
321
|
}
|
|
322
|
+
throw new Error(`Failed to store secret '${item}': ${stderr || 'unknown error'}\n` +
|
|
323
|
+
'Make sure GNOME Keyring or another Secret Service provider is running,\n' +
|
|
324
|
+
'or set AGENTS_SECRETS_PASSPHRASE to use the encrypted-file fallback.');
|
|
94
325
|
}
|
|
95
326
|
export function deleteSecretToolToken(item) {
|
|
96
|
-
|
|
327
|
+
if (preflight() === 'file')
|
|
328
|
+
return fileDelete(item);
|
|
97
329
|
const user = os.userInfo().username;
|
|
98
330
|
const result = spawnSync('secret-tool', [
|
|
99
331
|
'clear',
|
|
100
332
|
'service', SERVICE,
|
|
101
333
|
'account', user,
|
|
102
334
|
'item', item,
|
|
103
|
-
], {
|
|
104
|
-
|
|
105
|
-
|
|
335
|
+
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
336
|
+
if (result.status === 0)
|
|
337
|
+
return true;
|
|
338
|
+
const stderr = result.stderr?.toString() ?? '';
|
|
339
|
+
if (isLockedCollectionError(stderr)) {
|
|
340
|
+
activateFileFallback();
|
|
341
|
+
return fileDelete(item);
|
|
342
|
+
}
|
|
106
343
|
// secret-tool clear returns 0 whether the item existed or not.
|
|
107
|
-
//
|
|
108
|
-
|
|
344
|
+
// A non-zero exit that isn't a locked-collection error is a real failure;
|
|
345
|
+
// surface that rather than silently swallowing.
|
|
346
|
+
return false;
|
|
109
347
|
}
|
|
110
348
|
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
349
|
+
* Parse the item names out of `secret-tool search --all` output, keeping only
|
|
350
|
+
* those starting with `prefix`. Exported for tests.
|
|
351
|
+
*
|
|
352
|
+
* `output` must be the combined stdout+stderr of the search: libsecret splits
|
|
353
|
+
* the dump across both streams — the value/label/schema lines go to stdout
|
|
354
|
+
* while the `attribute.*` lines (which carry `attribute.item`, the only place
|
|
355
|
+
* the item name is reliably machine-readable) go to stderr (observed on
|
|
356
|
+
* libsecret 0.21.4). Which stream each line lands on has varied across
|
|
357
|
+
* libsecret versions, so callers concatenate both rather than bet on one.
|
|
113
358
|
*/
|
|
114
|
-
export function
|
|
115
|
-
ensureSecretTool();
|
|
116
|
-
// secret-tool search outputs attributes, one item per block
|
|
117
|
-
const result = spawnSync('secret-tool', [
|
|
118
|
-
'search',
|
|
119
|
-
'--all',
|
|
120
|
-
'service', SERVICE,
|
|
121
|
-
], {
|
|
122
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
123
|
-
});
|
|
124
|
-
if (result.status !== 0) {
|
|
125
|
-
return [];
|
|
126
|
-
}
|
|
127
|
-
const output = result.stdout?.toString() || '';
|
|
359
|
+
export function parseSecretToolItems(output, prefix) {
|
|
128
360
|
const items = [];
|
|
129
361
|
// Parse output format:
|
|
130
362
|
// [/org/freedesktop/secrets/collection/login/1]
|
|
@@ -141,7 +373,33 @@ export function listSecretToolItems(prefix) {
|
|
|
141
373
|
}
|
|
142
374
|
return [...new Set(items)]; // dedupe
|
|
143
375
|
}
|
|
144
|
-
/**
|
|
376
|
+
/**
|
|
377
|
+
* List secrets by prefix. secret-tool doesn't have a list command,
|
|
378
|
+
* so we use secret-tool search which outputs in a specific format.
|
|
379
|
+
*/
|
|
380
|
+
export function listSecretToolItems(prefix) {
|
|
381
|
+
if (preflight() === 'file')
|
|
382
|
+
return fileList(prefix);
|
|
383
|
+
const result = spawnSync('secret-tool', [
|
|
384
|
+
'search',
|
|
385
|
+
'--all',
|
|
386
|
+
'service', SERVICE,
|
|
387
|
+
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
388
|
+
if (result.status !== 0) {
|
|
389
|
+
const stderr = result.stderr?.toString() ?? '';
|
|
390
|
+
if (isLockedCollectionError(stderr)) {
|
|
391
|
+
activateFileFallback();
|
|
392
|
+
return fileList(prefix);
|
|
393
|
+
}
|
|
394
|
+
return [];
|
|
395
|
+
}
|
|
396
|
+
const output = `${result.stdout?.toString() || ''}\n${result.stderr?.toString() || ''}`;
|
|
397
|
+
return parseSecretToolItems(output, prefix);
|
|
398
|
+
}
|
|
399
|
+
/** KeychainBackend implementation for Linux. Routes through secret-tool
|
|
400
|
+
* with a transparent encrypted-file fallback when the default Secret
|
|
401
|
+
* Service collection is locked (or libsecret-tools is not installed but
|
|
402
|
+
* AGENTS_SECRETS_PASSPHRASE is set). */
|
|
145
403
|
export const linuxBackend = {
|
|
146
404
|
has(item) {
|
|
147
405
|
return hasSecretToolToken(item);
|
|
@@ -159,3 +417,13 @@ export const linuxBackend = {
|
|
|
159
417
|
return listSecretToolItems(prefix);
|
|
160
418
|
},
|
|
161
419
|
};
|
|
420
|
+
/** Test-only: reset module state so independent test cases don't bleed
|
|
421
|
+
* passphrase / fallback decisions across each other. */
|
|
422
|
+
export function _resetForTest(opts = {}) {
|
|
423
|
+
fileDirOverride = opts.fileDir ?? null;
|
|
424
|
+
useFileFallback = opts.forceFileFallback ?? false;
|
|
425
|
+
warnedFallback = false;
|
|
426
|
+
cachedPassphrase = opts.passphrase ?? null;
|
|
427
|
+
checkedAvailability = false;
|
|
428
|
+
isAvailable = false;
|
|
429
|
+
}
|
package/dist/lib/session/db.js
CHANGED
|
@@ -605,10 +605,23 @@ function buildSessionWhere(options) {
|
|
|
605
605
|
export function querySessions(options = {}) {
|
|
606
606
|
const db = getDB();
|
|
607
607
|
const { clause, params } = buildSessionWhere(options);
|
|
608
|
-
|
|
608
|
+
// When a LIMIT is in play, we still need to filter stale rows AFTER the query,
|
|
609
|
+
// so over-fetch a small buffer. Without this, a page of 50 rows where the first
|
|
610
|
+
// 5 are stale would return only 45 to the caller even when there are more.
|
|
611
|
+
const limitClause = options.limit
|
|
612
|
+
? `LIMIT ${Math.max(1, Math.floor(options.limit)) + 16}`
|
|
613
|
+
: '';
|
|
609
614
|
const sql = `SELECT * FROM sessions ${clause} ORDER BY timestamp DESC ${limitClause}`;
|
|
610
615
|
const rows = db.prepare(sql).all(...params);
|
|
611
|
-
|
|
616
|
+
// Belt-and-suspenders: drop rows whose JSONL no longer exists on disk. The
|
|
617
|
+
// authoritative fix is to keep file_path in sync (see updateSessionFilePaths
|
|
618
|
+
// callers), but skipping vanished rows here prevents phantom sessions from
|
|
619
|
+
// surfacing in the Factory UI if any code path forgets to rewrite (#136).
|
|
620
|
+
// Synthetic rows (OpenClaw channels/cron — see scanOpenClawIncremental) carry
|
|
621
|
+
// an empty file_path and are exempt; they're keyed by CLI output, not files.
|
|
622
|
+
const live = rows.filter(r => !r.file_path || fs.existsSync(r.file_path));
|
|
623
|
+
const trimmed = options.limit ? live.slice(0, options.limit) : live;
|
|
624
|
+
return trimmed.map(rowToMeta);
|
|
612
625
|
}
|
|
613
626
|
/** Count sessions matching the given filter options. */
|
|
614
627
|
export function countSessions(options = {}) {
|