@phnx-labs/agents-cli 1.22.3 → 1.22.5
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 +48 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/events.js +8 -0
- package/dist/commands/exec.js +7 -5
- package/dist/commands/inspect.js +18 -5
- package/dist/commands/models.d.ts +1 -1
- package/dist/commands/models.js +68 -9
- package/dist/commands/view.d.ts +12 -0
- package/dist/commands/view.js +2 -0
- package/dist/commands/webhook.js +8 -4
- package/dist/lib/browser/chrome.d.ts +12 -0
- package/dist/lib/browser/chrome.js +27 -11
- package/dist/lib/cloud/antigravity.js +8 -2
- package/dist/lib/crabbox/cli.d.ts +2 -0
- package/dist/lib/crabbox/cli.js +109 -39
- package/dist/lib/event-stream.d.ts +3 -0
- package/dist/lib/event-stream.js +5 -0
- package/dist/lib/events.d.ts +2 -0
- package/dist/lib/events.js +6 -1
- package/dist/lib/feed.d.ts +1 -1
- package/dist/lib/feed.js +19 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/model-tier-overrides.d.ts +43 -0
- package/dist/lib/model-tier-overrides.js +97 -0
- package/dist/lib/model-tiers.d.ts +13 -7
- package/dist/lib/model-tiers.js +104 -35
- package/dist/lib/models.d.ts +16 -0
- package/dist/lib/models.js +17 -3
- 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/bundles.d.ts +5 -30
- package/dist/lib/secrets/bundles.js +34 -60
- package/dist/lib/secrets/headless.d.ts +39 -0
- package/dist/lib/secrets/headless.js +63 -0
- package/dist/lib/secrets/index.d.ts +39 -0
- package/dist/lib/secrets/index.js +122 -6
- package/dist/lib/secrets/mcp.js +8 -4
- package/dist/lib/secrets/read-backoff.d.ts +27 -0
- package/dist/lib/secrets/read-backoff.js +64 -0
- package/dist/lib/secrets/session-store.js +6 -4
- package/dist/lib/secrets/vault.js +3 -1
- package/dist/lib/session/sync/config.d.ts +12 -11
- package/dist/lib/session/sync/config.js +40 -38
- package/dist/lib/share/config.js +4 -0
- package/dist/lib/types.d.ts +10 -0
- package/dist/lib/usage.js +3 -1
- package/dist/lib/versions.js +35 -0
- package/package.json +1 -1
|
@@ -29,6 +29,8 @@ import * as os from 'os';
|
|
|
29
29
|
import * as path from 'path';
|
|
30
30
|
import { linuxBackend, usesFileFallback as linuxUsesFileFallback, importNativeSecretToolItems } from './linux.js';
|
|
31
31
|
import { windowsBackend, usesFileFallback as windowsUsesFileFallback, importNativeCredManItems } from './windows.js';
|
|
32
|
+
import { isHeadlessSecretsContext } from './headless.js';
|
|
33
|
+
import { KEYCHAIN_READ_BACKOFF_TTL_MS, clearKeychainReadBackoff, isKeychainReadBackedOff, noteKeychainReadFailure, } from './read-backoff.js';
|
|
32
34
|
import { getKeychainHelperPath } from './install-helper.js';
|
|
33
35
|
import { deriveShortId } from '../session/short-id.js';
|
|
34
36
|
const SERVICE_PREFIX = 'agents-cli';
|
|
@@ -236,10 +238,11 @@ function parseHmacKeyRecord(raw) {
|
|
|
236
238
|
function readHmacKeyRecord() {
|
|
237
239
|
// HMAC_KEY_ITEM is exempt from the transform, so this routes to the helper
|
|
238
240
|
// (or the test backend) under its literal name. The item is no-ACL, so the
|
|
239
|
-
// read is silent
|
|
241
|
+
// read is silent — attest that to the storm guard so a headless hashed-name
|
|
242
|
+
// resolution never trips the fail-fast.
|
|
240
243
|
let raw;
|
|
241
244
|
try {
|
|
242
|
-
raw = getKeychainToken(HMAC_KEY_ITEM);
|
|
245
|
+
raw = getKeychainToken(HMAC_KEY_ITEM, { silentNoAcl: true });
|
|
243
246
|
}
|
|
244
247
|
catch {
|
|
245
248
|
return null;
|
|
@@ -254,6 +257,25 @@ function writeHmacKeyRecord(rec) {
|
|
|
254
257
|
setKeychainToken(HMAC_KEY_ITEM, JSON.stringify(rec), { noAcl: true });
|
|
255
258
|
hashStateCache = null;
|
|
256
259
|
}
|
|
260
|
+
/**
|
|
261
|
+
* Heal a `hmackey` item that an OLD helper (pre the metadata/hmackey no-ACL
|
|
262
|
+
* migration fix) re-stamped with a biometry ACL. Such an item makes EVERY hashed
|
|
263
|
+
* keychain lookup pop the generic "Agents CLI needs to authenticate" sheet,
|
|
264
|
+
* because the HMAC key is read before every hashed name resolves. The migration
|
|
265
|
+
* fix stopped the re-stamping but never un-stamped an already-damaged item, and
|
|
266
|
+
* nothing else re-stores it once hashing is already active — so it prompts forever.
|
|
267
|
+
*
|
|
268
|
+
* This re-stores the record no-ACL exactly once per machine (guarded by
|
|
269
|
+
* `healedNoAcl`), turning every future read silent. The read that produced `rec`
|
|
270
|
+
* has already happened (and already prompted if it was ACL'd); this only writes.
|
|
271
|
+
* Returns true if it healed. Exported for tests. No-op when already healed.
|
|
272
|
+
*/
|
|
273
|
+
export function healHmacKeyNoAclOnce(rec) {
|
|
274
|
+
if (rec.healedNoAcl)
|
|
275
|
+
return false;
|
|
276
|
+
writeHmacKeyRecord({ ...rec, healedNoAcl: true });
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
257
279
|
function resolveHashState() {
|
|
258
280
|
if (forcedTestKey)
|
|
259
281
|
return { active: true, key: forcedTestKey, record: null };
|
|
@@ -401,6 +423,19 @@ function maybeAutoRekey() {
|
|
|
401
423
|
return;
|
|
402
424
|
const st = resolveHashState();
|
|
403
425
|
if (st.active) {
|
|
426
|
+
// Heal an already-active machine whose hmackey was re-stamped ACL'd by an old
|
|
427
|
+
// helper (its read popped the generic Touch ID sheet on every hashed lookup).
|
|
428
|
+
// Runs once per machine; mutate the local so a later finishPendingDeletes write
|
|
429
|
+
// preserves the healed flag.
|
|
430
|
+
if (st.record && !st.record.healedNoAcl) {
|
|
431
|
+
try {
|
|
432
|
+
healHmacKeyNoAclOnce(st.record);
|
|
433
|
+
st.record.healedNoAcl = true;
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
/* next process retries */
|
|
437
|
+
}
|
|
438
|
+
}
|
|
404
439
|
if (st.record?.pendingDeletes?.length) {
|
|
405
440
|
try {
|
|
406
441
|
finishPendingDeletes(st.record);
|
|
@@ -707,6 +742,56 @@ export function hasKeychainToken(item) {
|
|
|
707
742
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
708
743
|
}).status === 0;
|
|
709
744
|
}
|
|
745
|
+
/**
|
|
746
|
+
* The detector the raw-read storm guard consults, overridable so tests on any
|
|
747
|
+
* platform exercise the fail-fast path — the real detector self-gates to
|
|
748
|
+
* darwin (the only platform with a Touch ID sheet to suppress), which would
|
|
749
|
+
* make the throw unreachable from a Linux CI run. Same parameterization
|
|
750
|
+
* argument as the injected env/platform/tty on isHeadlessSecretsContext.
|
|
751
|
+
*/
|
|
752
|
+
let rawReadHeadlessDetector = () => isHeadlessSecretsContext();
|
|
753
|
+
export function setKeychainHeadlessDetectorForTest(detector) {
|
|
754
|
+
rawReadHeadlessDetector = detector ?? (() => isHeadlessSecretsContext());
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* The raw-read storm guard, consulted by every getKeychainToken /
|
|
758
|
+
* getKeychainTokens read that could reach a prompting keychain query. Two
|
|
759
|
+
* fail-fast gates, both skipped for `silentNoAcl` (provably prompt-free)
|
|
760
|
+
* reads:
|
|
761
|
+
*
|
|
762
|
+
* 1. Headless fail-fast. A non-interactive process (AGENTS_RUNTIME set, or
|
|
763
|
+
* no TTY — see isHeadlessSecretsContext) must NEVER raise a Touch ID sheet
|
|
764
|
+
* on the interactive user's screen: the sheet has no one to answer it, a
|
|
765
|
+
* polling caller re-raises it every few seconds, and a cancel just feeds
|
|
766
|
+
* the next poll. Throw an actionable error naming the item instead.
|
|
767
|
+
* 2. Back-off. A read whose prompt recently failed or was cancelled is
|
|
768
|
+
* suppressed for KEYCHAIN_READ_BACKOFF_TTL_MS so an interactive-context
|
|
769
|
+
* poller (TTY but unwatched — a tmux pane, a VS Code task terminal) can't
|
|
770
|
+
* storm sheets either. A successful read or write clears the memo.
|
|
771
|
+
*
|
|
772
|
+
* Placement note: this runs BEFORE the platform branches so the back-off memo
|
|
773
|
+
* is honored identically everywhere, and the headless gate is a no-op off
|
|
774
|
+
* darwin (the detector returns false there) — Linux/Windows reads never
|
|
775
|
+
* prompt, so there is nothing to guard.
|
|
776
|
+
*/
|
|
777
|
+
function assertRawKeychainReadAllowed(key, context, label) {
|
|
778
|
+
if (context.silentNoAcl)
|
|
779
|
+
return;
|
|
780
|
+
const what = label ?? `Keychain item '${key}'`;
|
|
781
|
+
if (rawReadHeadlessDetector()) {
|
|
782
|
+
const hint = context.bundle
|
|
783
|
+
? `Run 'agents secrets unlock ${context.bundle}' in a terminal first`
|
|
784
|
+
: `Provision a prompt-free credential for headless use (a file-based setup token, or an item stored without the biometry ACL), ` +
|
|
785
|
+
`or read it once from an interactive terminal`;
|
|
786
|
+
throw new Error(`${what} requires Touch ID, but this process is non-interactive — ` +
|
|
787
|
+
`a prompt would appear on screen with no one to answer it. ${hint}.`);
|
|
788
|
+
}
|
|
789
|
+
if (isKeychainReadBackedOff(key)) {
|
|
790
|
+
throw new Error(`${what} is in read back-off: a Touch ID prompt for it failed or was cancelled within the last ` +
|
|
791
|
+
`${Math.round(KEYCHAIN_READ_BACKOFF_TTL_MS / 60000)} minutes, and retrying is suppressed so a polling caller can't storm prompts. ` +
|
|
792
|
+
`Read it once interactively or wait out the back-off.`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
710
795
|
export function keychainOperationPrompt(context = {}) {
|
|
711
796
|
const agent = context.agent || 'Agents CLI';
|
|
712
797
|
const bundle = context.bundle ? ` the '${context.bundle}' bundle` : ' secrets';
|
|
@@ -724,6 +809,7 @@ export function getKeychainToken(item, context = {}) {
|
|
|
724
809
|
item = prepareServiceName(item, { autoRekey: true });
|
|
725
810
|
if (backend)
|
|
726
811
|
return backend.get(item);
|
|
812
|
+
assertRawKeychainReadAllowed(requested, context);
|
|
727
813
|
assertSupportedPlatform();
|
|
728
814
|
if (isLinux())
|
|
729
815
|
return linuxBackend.get(item);
|
|
@@ -735,8 +821,10 @@ export function getKeychainToken(item, context = {}) {
|
|
|
735
821
|
});
|
|
736
822
|
if (sec.status === 0) {
|
|
737
823
|
const token = sec.stdout?.toString().trim();
|
|
738
|
-
if (token)
|
|
824
|
+
if (token) {
|
|
825
|
+
clearKeychainReadBackoff(requested);
|
|
739
826
|
return token;
|
|
827
|
+
}
|
|
740
828
|
}
|
|
741
829
|
throw new Error(`Keychain item '${requested}' not found.`);
|
|
742
830
|
}
|
|
@@ -749,17 +837,24 @@ export function getKeychainToken(item, context = {}) {
|
|
|
749
837
|
},
|
|
750
838
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
751
839
|
});
|
|
840
|
+
// Exit 1 is a plain miss — no prompt was raised, so it opens no back-off.
|
|
752
841
|
if (result.status === 1)
|
|
753
842
|
throw new Error(`Keychain item '${requested}' not found.`);
|
|
754
|
-
if (result.status === 4)
|
|
755
|
-
throw new Error(`Touch ID cancelled while reading '${requested}'.`);
|
|
756
843
|
if (result.status !== 0) {
|
|
844
|
+
// A cancel (4) or helper failure after a prompted read: open the back-off
|
|
845
|
+
// window BEFORE throwing, so the next poll of the same item fails fast
|
|
846
|
+
// instead of re-raising the sheet.
|
|
847
|
+
if (!context.silentNoAcl)
|
|
848
|
+
noteKeychainReadFailure(requested);
|
|
849
|
+
if (result.status === 4)
|
|
850
|
+
throw new Error(`Touch ID cancelled while reading '${requested}'.`);
|
|
757
851
|
const msg = result.stderr?.toString().trim();
|
|
758
852
|
throw new Error(msg || `Failed to read keychain item '${requested}'.`);
|
|
759
853
|
}
|
|
760
854
|
const token = result.stdout?.toString();
|
|
761
855
|
if (!token)
|
|
762
856
|
throw new Error(`Keychain item '${requested}' exists but is empty.`);
|
|
857
|
+
clearKeychainReadBackoff(requested);
|
|
763
858
|
return token;
|
|
764
859
|
}
|
|
765
860
|
/**
|
|
@@ -798,6 +893,13 @@ export function getKeychainTokens(items, context = {}) {
|
|
|
798
893
|
}
|
|
799
894
|
return result;
|
|
800
895
|
}
|
|
896
|
+
// One back-off memo covers the whole batch: the batch raises at most one
|
|
897
|
+
// prompt, so a cancel/failure suppresses retrying the same batch, keyed by
|
|
898
|
+
// the requested names (never the storage hashes) for a stable identity. The
|
|
899
|
+
// guard runs before the platform branches so the headless fail-fast is
|
|
900
|
+
// exercisable on any platform (the real detector self-gates to darwin).
|
|
901
|
+
const backoffKey = `batch:${items.join('\n')}`;
|
|
902
|
+
assertRawKeychainReadAllowed(backoffKey, context, `Batch read of ${items.length} keychain item(s)`);
|
|
801
903
|
assertSupportedPlatform();
|
|
802
904
|
if (isLinux()) {
|
|
803
905
|
for (const storage of storageItems) {
|
|
@@ -830,6 +932,8 @@ export function getKeychainTokens(items, context = {}) {
|
|
|
830
932
|
},
|
|
831
933
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
832
934
|
});
|
|
935
|
+
if (child.status !== 0 && !context.silentNoAcl)
|
|
936
|
+
noteKeychainReadFailure(backoffKey);
|
|
833
937
|
if (child.status === 4) {
|
|
834
938
|
throw new Error(`Touch ID cancelled while reading ${items.length} keychain item(s).`);
|
|
835
939
|
}
|
|
@@ -837,6 +941,7 @@ export function getKeychainTokens(items, context = {}) {
|
|
|
837
941
|
const msg = child.stderr?.toString().trim();
|
|
838
942
|
throw new Error(msg || `Failed to batch-read ${items.length} keychain items.`);
|
|
839
943
|
}
|
|
944
|
+
clearKeychainReadBackoff(backoffKey);
|
|
840
945
|
const out = child.stdout?.toString() ?? '';
|
|
841
946
|
parseBatchRecords(out, record);
|
|
842
947
|
return result;
|
|
@@ -917,6 +1022,7 @@ export function setKeychainToken(item, value, opts) {
|
|
|
917
1022
|
// resolve the storage name.
|
|
918
1023
|
if (/[\x00=\r\n]/.test(item))
|
|
919
1024
|
throw new Error('Secret item name contains invalid characters.');
|
|
1025
|
+
const requested = item;
|
|
920
1026
|
item = prepareServiceName(item, { autoRekey: true });
|
|
921
1027
|
if (backend) {
|
|
922
1028
|
backend.set(item, value, opts);
|
|
@@ -968,6 +1074,7 @@ export function setKeychainToken(item, value, opts) {
|
|
|
968
1074
|
const msg = sec.stderr?.toString().trim();
|
|
969
1075
|
throw new Error(msg || `Failed to write keychain item '${item}'.`);
|
|
970
1076
|
}
|
|
1077
|
+
clearKeychainReadBackoff(requested);
|
|
971
1078
|
return;
|
|
972
1079
|
}
|
|
973
1080
|
const bin = getKeychainHelperPath();
|
|
@@ -989,9 +1096,13 @@ export function setKeychainToken(item, value, opts) {
|
|
|
989
1096
|
}
|
|
990
1097
|
throw new Error(msg || `Failed to write keychain item '${item}'.`);
|
|
991
1098
|
}
|
|
1099
|
+
// A successful write supersedes any open back-off: the item is known-good
|
|
1100
|
+
// now, so the next read must not be suppressed by a stale failure memo.
|
|
1101
|
+
clearKeychainReadBackoff(requested);
|
|
992
1102
|
}
|
|
993
1103
|
/** Delete a keychain/keyring item. Returns true if it existed. Never prompts for biometry. */
|
|
994
1104
|
export function deleteKeychainToken(item) {
|
|
1105
|
+
const requested = item;
|
|
995
1106
|
item = prepareServiceName(item);
|
|
996
1107
|
if (backend)
|
|
997
1108
|
return backend.delete(item);
|
|
@@ -1001,9 +1112,14 @@ export function deleteKeychainToken(item) {
|
|
|
1001
1112
|
if (isWindows())
|
|
1002
1113
|
return windowsBackend.delete(item);
|
|
1003
1114
|
const bin = getKeychainHelperPath();
|
|
1004
|
-
|
|
1115
|
+
const deleted = spawnSync(bin, ['delete', item, os.userInfo().username], {
|
|
1005
1116
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1006
1117
|
}).status === 0;
|
|
1118
|
+
// A deleted item must fail its next read as plain "not found", not with a
|
|
1119
|
+
// stale back-off error left over from a pre-delete cancel.
|
|
1120
|
+
if (deleted)
|
|
1121
|
+
clearKeychainReadBackoff(requested);
|
|
1122
|
+
return deleted;
|
|
1007
1123
|
}
|
|
1008
1124
|
/**
|
|
1009
1125
|
* True when the active keychain backend transparently routes reads/writes to
|
package/dist/lib/secrets/mcp.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* unit-testable in-process.
|
|
25
25
|
*/
|
|
26
26
|
import * as readline from 'readline';
|
|
27
|
-
import { listBundles, readAndResolveBundleEnv,
|
|
27
|
+
import { listBundles, readAndResolveBundleEnv, readBundle, validateBundleName, } from './bundles.js';
|
|
28
28
|
/** MCP protocol revision this server negotiates. */
|
|
29
29
|
export const MCP_PROTOCOL_VERSION = '2024-11-05';
|
|
30
30
|
/** The single tool this server exposes. */
|
|
@@ -68,9 +68,13 @@ export function resolveSecret(bundle, key) {
|
|
|
68
68
|
throw new Error(`Key '${key}' not found in bundle '${bundle}'.` +
|
|
69
69
|
(available.length ? ` Available keys: ${available.join(', ')}.` : ' Bundle has no keys.'));
|
|
70
70
|
}
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
71
|
+
// An MCP `get_secret` tool call is a program asking for a value, never a human
|
|
72
|
+
// at a Touch ID sheet — so the read is always `agentOnly` (SEC-13: never pop
|
|
73
|
+
// biometry on its own). A `never`/no-ACL or broker-held bundle resolves
|
|
74
|
+
// silently; a locked bundle THROWS the actionable "unlock <name>" message,
|
|
75
|
+
// which propagates as the MCP tool error (the caller surfaces it) rather than
|
|
76
|
+
// popping an unanswerable prompt.
|
|
77
|
+
const { env } = readAndResolveBundleEnv(bundle, { caller: 'secrets-mcp', keys: [key], keyMode: 'storage', agentOnly: true });
|
|
74
78
|
const value = env[key];
|
|
75
79
|
if (value === undefined) {
|
|
76
80
|
throw new Error(`Key '${key}' in bundle '${bundle}' could not be resolved.`);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Negative memo for failed/cancelled macOS keychain reads (the "back-off").
|
|
3
|
+
*
|
|
4
|
+
* A cancelled Touch ID sheet is the user saying "not now" — but a polling
|
|
5
|
+
* caller (the Factory extension host's `agents view` loop, a watch script)
|
|
6
|
+
* retries the same read a few seconds later and pops the sheet again, forever.
|
|
7
|
+
* The headless guard (index.ts `assertRawKeychainReadAllowed`) covers the
|
|
8
|
+
* no-TTY case; this memo covers a context that CAN prompt but just had its
|
|
9
|
+
* prompt cancelled or fail: the next read of the same item within the TTL
|
|
10
|
+
* throws the back-off error instead of re-prompting. Any successful read (or
|
|
11
|
+
* write) of the item clears the memo.
|
|
12
|
+
*
|
|
13
|
+
* Stored as regenerable state under `~/.agents/.cache/keychain-read-backoff/`,
|
|
14
|
+
* one file per item (filename is a hash of the item name; the file carries no
|
|
15
|
+
* secret material — a name and a deadline only). All operations are
|
|
16
|
+
* best-effort: a lost memo costs at most one extra prompt, never a read.
|
|
17
|
+
*/
|
|
18
|
+
/** How long a failed/cancelled read suppresses retries of the same item. */
|
|
19
|
+
export declare const KEYCHAIN_READ_BACKOFF_TTL_MS: number;
|
|
20
|
+
/** Test seam: point the memo at a temp dir so tests never touch the real cache. */
|
|
21
|
+
export declare function setKeychainReadBackoffDirForTest(dir: string | null): void;
|
|
22
|
+
/** True while `key` is inside the back-off window opened by a failed/cancelled read. */
|
|
23
|
+
export declare function isKeychainReadBackedOff(key: string, now?: number): boolean;
|
|
24
|
+
/** Open (or refresh) the back-off window for `key` after a failed/cancelled read. */
|
|
25
|
+
export declare function noteKeychainReadFailure(key: string, now?: number): void;
|
|
26
|
+
/** Clear the memo: a successful read or write of the item resets the back-off. */
|
|
27
|
+
export declare function clearKeychainReadBackoff(key: string): void;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Negative memo for failed/cancelled macOS keychain reads (the "back-off").
|
|
3
|
+
*
|
|
4
|
+
* A cancelled Touch ID sheet is the user saying "not now" — but a polling
|
|
5
|
+
* caller (the Factory extension host's `agents view` loop, a watch script)
|
|
6
|
+
* retries the same read a few seconds later and pops the sheet again, forever.
|
|
7
|
+
* The headless guard (index.ts `assertRawKeychainReadAllowed`) covers the
|
|
8
|
+
* no-TTY case; this memo covers a context that CAN prompt but just had its
|
|
9
|
+
* prompt cancelled or fail: the next read of the same item within the TTL
|
|
10
|
+
* throws the back-off error instead of re-prompting. Any successful read (or
|
|
11
|
+
* write) of the item clears the memo.
|
|
12
|
+
*
|
|
13
|
+
* Stored as regenerable state under `~/.agents/.cache/keychain-read-backoff/`,
|
|
14
|
+
* one file per item (filename is a hash of the item name; the file carries no
|
|
15
|
+
* secret material — a name and a deadline only). All operations are
|
|
16
|
+
* best-effort: a lost memo costs at most one extra prompt, never a read.
|
|
17
|
+
*/
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import * as fs from 'fs';
|
|
20
|
+
import * as os from 'os';
|
|
21
|
+
import * as path from 'path';
|
|
22
|
+
/** How long a failed/cancelled read suppresses retries of the same item. */
|
|
23
|
+
export const KEYCHAIN_READ_BACKOFF_TTL_MS = 5 * 60 * 1000;
|
|
24
|
+
let dirOverride = null;
|
|
25
|
+
/** Test seam: point the memo at a temp dir so tests never touch the real cache. */
|
|
26
|
+
export function setKeychainReadBackoffDirForTest(dir) {
|
|
27
|
+
dirOverride = dir;
|
|
28
|
+
}
|
|
29
|
+
function backoffDir() {
|
|
30
|
+
return dirOverride ?? path.join(os.homedir(), '.agents', '.cache', 'keychain-read-backoff');
|
|
31
|
+
}
|
|
32
|
+
function backoffFile(key) {
|
|
33
|
+
const digest = createHash('sha256').update(key).digest('hex').slice(0, 24);
|
|
34
|
+
return path.join(backoffDir(), `${digest}.json`);
|
|
35
|
+
}
|
|
36
|
+
/** True while `key` is inside the back-off window opened by a failed/cancelled read. */
|
|
37
|
+
export function isKeychainReadBackedOff(key, now = Date.now()) {
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(fs.readFileSync(backoffFile(key), 'utf8'));
|
|
40
|
+
return typeof parsed.until === 'number' && parsed.until > now;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false; // absent or malformed memo → no back-off
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Open (or refresh) the back-off window for `key` after a failed/cancelled read. */
|
|
47
|
+
export function noteKeychainReadFailure(key, now = Date.now()) {
|
|
48
|
+
try {
|
|
49
|
+
fs.mkdirSync(backoffDir(), { recursive: true, mode: 0o700 });
|
|
50
|
+
fs.writeFileSync(backoffFile(key), JSON.stringify({ item: key, until: now + KEYCHAIN_READ_BACKOFF_TTL_MS }), { mode: 0o600 });
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
/* best-effort — the cache dir is regenerable; a lost memo costs one prompt */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Clear the memo: a successful read or write of the item resets the back-off. */
|
|
57
|
+
export function clearKeychainReadBackoff(key) {
|
|
58
|
+
try {
|
|
59
|
+
fs.rmSync(backoffFile(key), { force: true });
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
/* best-effort */
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -89,7 +89,8 @@ function sessionBlobItem(name, harness) {
|
|
|
89
89
|
/** Read the session index by its fixed name. `{bundles:{}}` when absent/unreadable. */
|
|
90
90
|
export function readIndex() {
|
|
91
91
|
try {
|
|
92
|
-
|
|
92
|
+
// Written no-ACL (writeIndex below) — attest so a headless resolve stays silent.
|
|
93
|
+
const raw = getKeychainToken(SESSION_INDEX_ITEM, { silentNoAcl: true });
|
|
93
94
|
const parsed = JSON.parse(raw);
|
|
94
95
|
if (parsed && typeof parsed === 'object' && parsed.bundles)
|
|
95
96
|
return parsed;
|
|
@@ -145,7 +146,8 @@ export function loadSession(name, now = Date.now(), harness = GLOBAL_HARNESS) {
|
|
|
145
146
|
if (!shouldPersist())
|
|
146
147
|
return null;
|
|
147
148
|
try {
|
|
148
|
-
|
|
149
|
+
// Written no-ACL (saveSession) — attest so a headless resolve stays silent.
|
|
150
|
+
const raw = getKeychainToken(sessionBlobItem(name, harness), { silentNoAcl: true });
|
|
149
151
|
const entry = JSON.parse(raw);
|
|
150
152
|
if (!entry || typeof entry !== 'object' || !entry.bundle || !entry.env)
|
|
151
153
|
return null;
|
|
@@ -227,7 +229,7 @@ export function rehydrateSessions(now = Date.now()) {
|
|
|
227
229
|
if (key.includes(':'))
|
|
228
230
|
continue;
|
|
229
231
|
try {
|
|
230
|
-
const raw = getKeychainToken(`${SESSION_ITEM_PREFIX}${key}
|
|
232
|
+
const raw = getKeychainToken(`${SESSION_ITEM_PREFIX}${key}`, { silentNoAcl: true });
|
|
231
233
|
const legacy = JSON.parse(raw);
|
|
232
234
|
setKeychainToken(sessionBlobItem(key, GLOBAL_HARNESS), JSON.stringify({ ...legacy, harness: GLOBAL_HARNESS }), { noAcl: true });
|
|
233
235
|
deleteKeychainToken(`${SESSION_ITEM_PREFIX}${key}`);
|
|
@@ -265,7 +267,7 @@ export function rehydrateSessions(now = Date.now()) {
|
|
|
265
267
|
continue;
|
|
266
268
|
}
|
|
267
269
|
try {
|
|
268
|
-
const raw = getKeychainToken(sessionBlobItem(bundleName, 'cli'));
|
|
270
|
+
const raw = getKeychainToken(sessionBlobItem(bundleName, 'cli'), { silentNoAcl: true });
|
|
269
271
|
const legacy = JSON.parse(raw);
|
|
270
272
|
setKeychainToken(sessionBlobItem(bundleName, GLOBAL_HARNESS), JSON.stringify({ ...legacy, harness: GLOBAL_HARNESS }), { noAcl: true });
|
|
271
273
|
deleteKeychainToken(sessionBlobItem(bundleName, 'cli'));
|
|
@@ -209,7 +209,9 @@ export function clearVaultKey() {
|
|
|
209
209
|
export function getVaultSession() {
|
|
210
210
|
let raw;
|
|
211
211
|
try {
|
|
212
|
-
|
|
212
|
+
// Written no-ACL (cacheVaultKey) — the vault session probe runs inside bundleBackend
|
|
213
|
+
// for EVERY bundle op, headless included, so it must stay silent.
|
|
214
|
+
raw = getKeychainToken(VAULT_SESSION_ITEM, { silentNoAcl: true });
|
|
213
215
|
}
|
|
214
216
|
catch {
|
|
215
217
|
return { loggedIn: false };
|
|
@@ -29,24 +29,25 @@ export interface R2Config {
|
|
|
29
29
|
*/
|
|
30
30
|
syncEncKey?: string;
|
|
31
31
|
}
|
|
32
|
-
/** Window after a prompt-bearing resolution failure during which we skip
|
|
33
|
-
* re-attempting (and thus re-prompting). SIGHUP / restart bypasses it. */
|
|
34
|
-
export declare const RESOLVE_RETRY_COOLDOWN_MS: number;
|
|
35
32
|
/** Drop the cached resolution so the next call reads the bundle fresh. Called on
|
|
36
33
|
* daemon SIGHUP (to pick up rotated credentials) and between tests. */
|
|
37
34
|
export declare function clearR2ConfigCache(): void;
|
|
38
35
|
/**
|
|
39
36
|
* Resolve R2 credentials, reading the keychain at most once per process. The
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
37
|
+
* read is `agentOnly` (resolveR2Config), so it never prompts: a `never`/no-ACL or
|
|
38
|
+
* broker-held bundle resolves silently and is memoized; a locked `hold`/`always`
|
|
39
|
+
* bundle throws the actionable "unlock r2.backups" error. Throws (not memoized)
|
|
40
|
+
* when the bundle/keys are missing or locked — isSyncConfigured catches the throw
|
|
41
|
+
* and degrades to no-transport.
|
|
43
42
|
*/
|
|
44
43
|
export declare function loadR2Config(): R2Config;
|
|
45
44
|
/**
|
|
46
|
-
* True when the sync bundle exists and resolves, without throwing.
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
45
|
+
* True when the sync bundle exists and resolves, without throwing. A missing OR
|
|
46
|
+
* locked bundle resolves to false (session-sync degrades to no-transport) and,
|
|
47
|
+
* because the `agentOnly` read never prompts, it is re-checked each cycle — so a
|
|
48
|
+
* later `agents secrets add` / `agents secrets unlock r2.backups` is picked up
|
|
49
|
+
* promptly with no daemon restart. `now` is accepted for a stable test signature
|
|
50
|
+
* but no longer gates a cooldown (there is no prompt-bearing failure to back off).
|
|
50
51
|
*/
|
|
51
|
-
export declare function isSyncConfigured(
|
|
52
|
+
export declare function isSyncConfigured(_now?: number): boolean;
|
|
52
53
|
export { machineId, normalizeHost } from '../../machine-id.js';
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* actually wired. Credentials come from the `r2.backups` secrets bundle (OS
|
|
12
12
|
* keychain on macOS, libsecret on Linux) — never from env or disk.
|
|
13
13
|
*/
|
|
14
|
-
import { readAndResolveBundleEnv
|
|
14
|
+
import { readAndResolveBundleEnv } from '../../secrets/bundles.js';
|
|
15
15
|
/** Secrets bundle holding the R2 credentials. */
|
|
16
16
|
export const SYNC_BUNDLE = 'r2.backups';
|
|
17
17
|
/**
|
|
@@ -20,13 +20,17 @@ export const SYNC_BUNDLE = 'r2.backups';
|
|
|
20
20
|
* without real credentials (no silent fallback).
|
|
21
21
|
*/
|
|
22
22
|
function resolveR2Config() {
|
|
23
|
-
//
|
|
24
|
-
// resolve
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
23
|
+
// Session-sync is a BACKGROUND read: the daemon's ~90s cycle (and the ~2-min
|
|
24
|
+
// watchdog) resolve this on their own, never at a human's request — so it must
|
|
25
|
+
// NEVER pop a Touch ID sheet, on the interactive launcher included (SEC-13: an
|
|
26
|
+
// agent launch never raises biometry on its own). The read is always
|
|
27
|
+
// `agentOnly`: a `never`/no-ACL or broker-held `r2.backups` bundle resolves
|
|
28
|
+
// silently; a locked `hold`/`always` bundle THROWS the actionable "unlock
|
|
29
|
+
// r2.backups" message instead of prompting. isSyncConfigured catches that throw
|
|
30
|
+
// and degrades to no-transport (sync disabled) with no prompt and no crash —
|
|
31
|
+
// unlock once (`agents secrets unlock r2.backups`) or set it no-ACL
|
|
32
|
+
// (`agents secrets policy r2.backups never`) for silent zero-friction sync.
|
|
33
|
+
const { env } = readAndResolveBundleEnv(SYNC_BUNDLE, { caller: 'session-transport', agentOnly: true });
|
|
30
34
|
const accountId = env.R2_ACCOUNT_ID?.trim();
|
|
31
35
|
const bucket = env.R2_BUCKET_NAME?.trim();
|
|
32
36
|
const accessKeyId = env.R2_ACCESS_KEY_ID?.trim();
|
|
@@ -55,31 +59,32 @@ function resolveR2Config() {
|
|
|
55
59
|
};
|
|
56
60
|
}
|
|
57
61
|
// ── Resolution cache ────────────────────────────────────────────────────────
|
|
58
|
-
// The daemon calls isSyncConfigured() + syncSessions() every ~90s
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
// at most once per process: a success is memoized for the process lifetime
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
62
|
+
// The daemon calls isSyncConfigured() + syncSessions() every ~90s. The read is
|
|
63
|
+
// now `agentOnly` (resolveR2Config) so it can NEVER pop Touch ID — a locked bundle
|
|
64
|
+
// throws a cheap, deterministic "unlock r2.backups" error instead. We still resolve
|
|
65
|
+
// at most once per process: a success is memoized for the process lifetime (cleared
|
|
66
|
+
// on daemon SIGHUP via clearR2ConfigCache), so subsequent cycles never touch the
|
|
67
|
+
// keychain again. A failure (absent bundle, or a LOCKED `hold`/`always` bundle) is
|
|
68
|
+
// NOT memoized and never prompts, so it is re-checked each cycle — session-sync
|
|
69
|
+
// degrades to no-transport until the bundle is added / unlocked, then picks it up
|
|
70
|
+
// promptly with no restart.
|
|
71
|
+
//
|
|
72
|
+
// The historical prompt-backoff cooldown (a cancelled Touch ID sheet) is gone: with
|
|
73
|
+
// agentOnly there is no sheet to cancel, so no failure is prompt-bearing and none
|
|
74
|
+
// needs a backoff.
|
|
67
75
|
let cachedConfig = null;
|
|
68
|
-
let lastPromptFailureAt = 0;
|
|
69
|
-
/** Window after a prompt-bearing resolution failure during which we skip
|
|
70
|
-
* re-attempting (and thus re-prompting). SIGHUP / restart bypasses it. */
|
|
71
|
-
export const RESOLVE_RETRY_COOLDOWN_MS = 30 * 60 * 1000; // 30 minutes
|
|
72
76
|
/** Drop the cached resolution so the next call reads the bundle fresh. Called on
|
|
73
77
|
* daemon SIGHUP (to pick up rotated credentials) and between tests. */
|
|
74
78
|
export function clearR2ConfigCache() {
|
|
75
79
|
cachedConfig = null;
|
|
76
|
-
lastPromptFailureAt = 0;
|
|
77
80
|
}
|
|
78
81
|
/**
|
|
79
82
|
* Resolve R2 credentials, reading the keychain at most once per process. The
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
+
* read is `agentOnly` (resolveR2Config), so it never prompts: a `never`/no-ACL or
|
|
84
|
+
* broker-held bundle resolves silently and is memoized; a locked `hold`/`always`
|
|
85
|
+
* bundle throws the actionable "unlock r2.backups" error. Throws (not memoized)
|
|
86
|
+
* when the bundle/keys are missing or locked — isSyncConfigured catches the throw
|
|
87
|
+
* and degrades to no-transport.
|
|
83
88
|
*/
|
|
84
89
|
export function loadR2Config() {
|
|
85
90
|
if (cachedConfig)
|
|
@@ -88,26 +93,23 @@ export function loadR2Config() {
|
|
|
88
93
|
return cachedConfig;
|
|
89
94
|
}
|
|
90
95
|
/**
|
|
91
|
-
* True when the sync bundle exists and resolves, without throwing.
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
96
|
+
* True when the sync bundle exists and resolves, without throwing. A missing OR
|
|
97
|
+
* locked bundle resolves to false (session-sync degrades to no-transport) and,
|
|
98
|
+
* because the `agentOnly` read never prompts, it is re-checked each cycle — so a
|
|
99
|
+
* later `agents secrets add` / `agents secrets unlock r2.backups` is picked up
|
|
100
|
+
* promptly with no daemon restart. `now` is accepted for a stable test signature
|
|
101
|
+
* but no longer gates a cooldown (there is no prompt-bearing failure to back off).
|
|
95
102
|
*/
|
|
96
|
-
export function isSyncConfigured(
|
|
103
|
+
export function isSyncConfigured(_now = Date.now()) {
|
|
97
104
|
if (cachedConfig)
|
|
98
105
|
return true;
|
|
99
|
-
if (lastPromptFailureAt && now - lastPromptFailureAt < RESOLVE_RETRY_COOLDOWN_MS)
|
|
100
|
-
return false;
|
|
101
106
|
try {
|
|
102
107
|
loadR2Config();
|
|
103
108
|
return true;
|
|
104
109
|
}
|
|
105
|
-
catch
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
// have cost a prompt (cancelled Touch ID, keychain error) — back off.
|
|
109
|
-
if (!/not found/i.test(err.message))
|
|
110
|
-
lastPromptFailureAt = now;
|
|
110
|
+
catch {
|
|
111
|
+
// Absent or locked bundle — never prompted (agentOnly), so no backoff: keep
|
|
112
|
+
// re-checking each cycle for fast pickup once the bundle is added / unlocked.
|
|
111
113
|
return false;
|
|
112
114
|
}
|
|
113
115
|
}
|
package/dist/lib/share/config.js
CHANGED
|
@@ -76,6 +76,9 @@ export function storeWriteToken(token) {
|
|
|
76
76
|
export function readWriteTokenFromBundle() {
|
|
77
77
|
const { env } = readAndResolveBundleEnv(SHARE_BUNDLE, {
|
|
78
78
|
caller: 'share',
|
|
79
|
+
// Explicit `agents share` command (a human published a file): a headless agent
|
|
80
|
+
// subprocess resolves broker-only, an interactive human may unlock. This is NOT
|
|
81
|
+
// an agent LAUNCH read (that is exec.ts's --secrets injection, always agentOnly).
|
|
79
82
|
agentOnly: isHeadlessSecretsContext(),
|
|
80
83
|
});
|
|
81
84
|
const token = env[SHARE_TOKEN_KEY];
|
|
@@ -133,6 +136,7 @@ export function readCloudflareCreds(bundle = DEFAULT_CF_BUNDLE, override) {
|
|
|
133
136
|
}
|
|
134
137
|
const { env } = readAndResolveBundleEnv(bundle, {
|
|
135
138
|
caller: 'share',
|
|
139
|
+
// Explicit `agents share setup` provisioning read — not an agent launch.
|
|
136
140
|
agentOnly: isHeadlessSecretsContext(),
|
|
137
141
|
});
|
|
138
142
|
const find = (re) => {
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -764,6 +764,16 @@ export interface Meta {
|
|
|
764
764
|
*/
|
|
765
765
|
isolatedAgents?: Partial<Record<AgentId, string>>;
|
|
766
766
|
run?: RunConfig;
|
|
767
|
+
/**
|
|
768
|
+
* Cost-tier overrides for `--model cheap|default|best|ultra`. Keyed by the same
|
|
769
|
+
* `<agent>:<version>` selector run.defaults uses (`kimi:*`, `kimi:0.19.2`); each
|
|
770
|
+
* value maps a tier to a concrete model id. Written by `agents models tier set`,
|
|
771
|
+
* never hand-edited. Resolution: exact version selector wins over `<agent>:*`,
|
|
772
|
+
* which wins over the auto-ranking. See lib/model-tier-overrides.ts.
|
|
773
|
+
*/
|
|
774
|
+
model?: {
|
|
775
|
+
tiers?: Record<string, Partial<Record<'cheap' | 'default' | 'best' | 'ultra', string>>>;
|
|
776
|
+
};
|
|
767
777
|
/**
|
|
768
778
|
* Daemon watchdog config. `rotate` (default `on`) lets the watchdog rotate a
|
|
769
779
|
* rate-limited session IN PLACE onto a healthy account/harness via
|
package/dist/lib/usage.js
CHANGED
|
@@ -1246,7 +1246,9 @@ function claudeOauthCacheActive() {
|
|
|
1246
1246
|
* or the token itself has expired — in which case the stale entry is dropped. */
|
|
1247
1247
|
function readCachedClaudeOauth(service) {
|
|
1248
1248
|
try {
|
|
1249
|
-
|
|
1249
|
+
// The cache item is written no-ACL (writeCachedClaudeOauth) — its whole purpose is
|
|
1250
|
+
// serving the token prompt-free, so attest that to the raw-read storm guard.
|
|
1251
|
+
const entry = JSON.parse(getKeychainToken(claudeOauthCacheItem(service), { silentNoAcl: true }));
|
|
1250
1252
|
if (!entry || typeof entry.accessToken !== 'string' || !entry.accessToken)
|
|
1251
1253
|
return null;
|
|
1252
1254
|
const now = Date.now();
|