clementine-agent 1.1.2 → 1.1.3
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/dist/cli/index.js +137 -0
- package/dist/config/effective-config.js +3 -1
- package/dist/config/keychain-fix-acl.d.ts +56 -0
- package/dist/config/keychain-fix-acl.js +93 -0
- package/dist/config/migrate-from-keychain.d.ts +70 -0
- package/dist/config/migrate-from-keychain.js +173 -0
- package/dist/config.js +12 -2
- package/dist/gateway/cron-scheduler.d.ts +19 -0
- package/dist/gateway/cron-scheduler.js +51 -3
- package/dist/secrets/keychain.js +8 -2
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -1148,6 +1148,128 @@ async function cmdConfigMigrateToKeychain(opts) {
|
|
|
1148
1148
|
console.log(` ${DIM}choose Always Allow to make the prompt permanent.${RESET}`);
|
|
1149
1149
|
console.log();
|
|
1150
1150
|
}
|
|
1151
|
+
// ── Config migrate-from-keychain (inverse of migrate-to-keychain) ───
|
|
1152
|
+
async function cmdConfigMigrateFromKeychain(opts) {
|
|
1153
|
+
const { planReverseMigration, applyReverseMigration } = await import('../config/migrate-from-keychain.js');
|
|
1154
|
+
const DIM = '\x1b[0;90m';
|
|
1155
|
+
const BOLD = '\x1b[1m';
|
|
1156
|
+
const GREEN = '\x1b[0;32m';
|
|
1157
|
+
const YELLOW = '\x1b[0;33m';
|
|
1158
|
+
const RED = '\x1b[0;31m';
|
|
1159
|
+
const CYAN = '\x1b[0;36m';
|
|
1160
|
+
const RESET = '\x1b[0m';
|
|
1161
|
+
const only = opts.key
|
|
1162
|
+
? opts.key.flatMap(k => k.split(',').map(s => s.trim()).filter(Boolean))
|
|
1163
|
+
: undefined;
|
|
1164
|
+
const plan = planReverseMigration(BASE_DIR, only ? { only } : {});
|
|
1165
|
+
console.log();
|
|
1166
|
+
console.log(` ${BOLD}.env path:${RESET} ${plan.envPath}`);
|
|
1167
|
+
console.log();
|
|
1168
|
+
const groups = {};
|
|
1169
|
+
for (const c of plan.candidates)
|
|
1170
|
+
(groups[c.status] ??= []).push(c);
|
|
1171
|
+
const renderGroup = (label, color, items, showRef = false) => {
|
|
1172
|
+
if (!items || items.length === 0)
|
|
1173
|
+
return;
|
|
1174
|
+
console.log(` ${color}${label}${RESET} ${DIM}(${items.length})${RESET}`);
|
|
1175
|
+
for (const c of items) {
|
|
1176
|
+
const refTag = showRef && c.ref ? ` ${DIM}${c.ref}${RESET}` : '';
|
|
1177
|
+
console.log(` ${c.key}${refTag}`);
|
|
1178
|
+
}
|
|
1179
|
+
console.log();
|
|
1180
|
+
};
|
|
1181
|
+
renderGroup('Will migrate from keychain → .env', CYAN, groups.migrated);
|
|
1182
|
+
renderGroup('Sensitive — left in keychain (correct)', DIM, groups['sensitive-skipped']);
|
|
1183
|
+
renderGroup('Unresolvable refs (keychain entry missing)', RED, groups.unresolvable, true);
|
|
1184
|
+
if (plan.toMigrate.length === 0) {
|
|
1185
|
+
console.log(` ${GREEN}Nothing to migrate.${RESET}`);
|
|
1186
|
+
if (plan.unresolvable.length > 0) {
|
|
1187
|
+
console.log(` ${YELLOW}${plan.unresolvable.length} unresolvable ref(s) above — fix or delete by hand.${RESET}`);
|
|
1188
|
+
}
|
|
1189
|
+
console.log();
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
if (opts.dryRun) {
|
|
1193
|
+
console.log(` ${YELLOW}Dry run — no changes written.${RESET}`);
|
|
1194
|
+
console.log();
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
console.log(` ${BOLD}Applying...${RESET}`);
|
|
1198
|
+
let result;
|
|
1199
|
+
try {
|
|
1200
|
+
result = applyReverseMigration(BASE_DIR, only ? { only } : {});
|
|
1201
|
+
}
|
|
1202
|
+
catch (err) {
|
|
1203
|
+
console.error(` ${RED}Failed:${RESET} ${err.message}`);
|
|
1204
|
+
process.exit(1);
|
|
1205
|
+
}
|
|
1206
|
+
if (result.failed.length > 0) {
|
|
1207
|
+
console.log(` ${RED}Some keychain reads failed — .env was NOT modified:${RESET}`);
|
|
1208
|
+
for (const f of result.failed)
|
|
1209
|
+
console.log(` ${RED}✗${RESET} ${f.key}: ${f.error}`);
|
|
1210
|
+
console.log();
|
|
1211
|
+
process.exit(1);
|
|
1212
|
+
}
|
|
1213
|
+
for (const key of result.migrated) {
|
|
1214
|
+
console.log(` ${GREEN}✓${RESET} ${key} ${DIM}→ .env (keychain entry deleted)${RESET}`);
|
|
1215
|
+
}
|
|
1216
|
+
console.log();
|
|
1217
|
+
console.log(` ${GREEN}Migrated ${result.migrated.length} key${result.migrated.length === 1 ? '' : 's'} out of keychain.${RESET}`);
|
|
1218
|
+
console.log();
|
|
1219
|
+
}
|
|
1220
|
+
// ── Config keychain-fix-acl ─────────────────────────────────────────
|
|
1221
|
+
async function cmdConfigKeychainFixAcl(opts) {
|
|
1222
|
+
const { listClementineKeychainEntries, fixAllClementineEntries } = await import('../config/keychain-fix-acl.js');
|
|
1223
|
+
const DIM = '\x1b[0;90m';
|
|
1224
|
+
const BOLD = '\x1b[1m';
|
|
1225
|
+
const GREEN = '\x1b[0;32m';
|
|
1226
|
+
const YELLOW = '\x1b[0;33m';
|
|
1227
|
+
const RED = '\x1b[0;31m';
|
|
1228
|
+
const RESET = '\x1b[0m';
|
|
1229
|
+
const entries = listClementineKeychainEntries();
|
|
1230
|
+
console.log();
|
|
1231
|
+
console.log(` ${BOLD}Found ${entries.length} clementine-agent keychain entr${entries.length === 1 ? 'y' : 'ies'}.${RESET}`);
|
|
1232
|
+
for (const e of entries)
|
|
1233
|
+
console.log(` ${DIM}${e.account}${RESET}`);
|
|
1234
|
+
console.log();
|
|
1235
|
+
if (entries.length === 0) {
|
|
1236
|
+
console.log(` ${GREEN}Nothing to fix.${RESET}`);
|
|
1237
|
+
console.log();
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
if (opts.list) {
|
|
1241
|
+
console.log(` ${DIM}--list mode — no changes made. Drop the flag to apply.${RESET}`);
|
|
1242
|
+
console.log();
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
console.log(` ${BOLD}Fixing ACLs...${RESET}`);
|
|
1246
|
+
console.log(` ${DIM}macOS may ask for your login keychain password (the system prompt — it DOES appear).${RESET}`);
|
|
1247
|
+
console.log(` ${DIM}You may also be asked to "Always Allow" — pick that.${RESET}`);
|
|
1248
|
+
console.log();
|
|
1249
|
+
const results = fixAllClementineEntries();
|
|
1250
|
+
let okCount = 0;
|
|
1251
|
+
let failCount = 0;
|
|
1252
|
+
for (const r of results) {
|
|
1253
|
+
if (r.status === 'fixed') {
|
|
1254
|
+
console.log(` ${GREEN}✓${RESET} ${r.account}`);
|
|
1255
|
+
okCount++;
|
|
1256
|
+
}
|
|
1257
|
+
else {
|
|
1258
|
+
console.log(` ${RED}✗${RESET} ${r.account} ${DIM}— ${r.error}${RESET}`);
|
|
1259
|
+
failCount++;
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
console.log();
|
|
1263
|
+
if (failCount === 0) {
|
|
1264
|
+
console.log(` ${GREEN}All ${okCount} entries fixed.${RESET} ${DIM}Future reads via the security CLI succeed silently.${RESET}`);
|
|
1265
|
+
}
|
|
1266
|
+
else {
|
|
1267
|
+
console.log(` ${YELLOW}${okCount} fixed, ${failCount} failed.${RESET}`);
|
|
1268
|
+
console.log(` ${DIM}Failed entries can be fixed manually in Keychain Access.app:${RESET}`);
|
|
1269
|
+
console.log(` ${DIM} search "clementine-agent" → double-click → Access Control → Allow all applications.${RESET}`);
|
|
1270
|
+
}
|
|
1271
|
+
console.log();
|
|
1272
|
+
}
|
|
1151
1273
|
// ── Advisor commands ────────────────────────────────────────────────
|
|
1152
1274
|
const ADVISOR_MODES = ['off', 'shadow', 'primary'];
|
|
1153
1275
|
function readAdvisorMode() {
|
|
@@ -1802,6 +1924,21 @@ configCmd
|
|
|
1802
1924
|
.action(async (opts) => {
|
|
1803
1925
|
await cmdConfigMigrateToKeychain(opts);
|
|
1804
1926
|
});
|
|
1927
|
+
configCmd
|
|
1928
|
+
.command('migrate-from-keychain')
|
|
1929
|
+
.description('Pull non-credential values OUT of keychain back to plaintext .env (only API keys belong in keychain)')
|
|
1930
|
+
.option('--dry-run', 'Show what would migrate without writing anything')
|
|
1931
|
+
.option('-k, --key <name...>', 'Limit to specific key(s); repeat or comma-separate for multiple')
|
|
1932
|
+
.action(async (opts) => {
|
|
1933
|
+
await cmdConfigMigrateFromKeychain(opts);
|
|
1934
|
+
});
|
|
1935
|
+
configCmd
|
|
1936
|
+
.command('keychain-fix-acl')
|
|
1937
|
+
.description('One-shot fix for clementine-agent keychain entries that prompt on every read (one master prompt then no more)')
|
|
1938
|
+
.option('--list', 'List entries without changing anything')
|
|
1939
|
+
.action(async (opts) => {
|
|
1940
|
+
await cmdConfigKeychainFixAcl(opts);
|
|
1941
|
+
});
|
|
1805
1942
|
configCmd
|
|
1806
1943
|
.command('edit')
|
|
1807
1944
|
.description('Open .env in your editor')
|
|
@@ -64,6 +64,8 @@ const refCache = new Map();
|
|
|
64
64
|
function shellEscape(s) {
|
|
65
65
|
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
66
66
|
}
|
|
67
|
+
/** Hard cap per shell call — see config.ts for rationale. */
|
|
68
|
+
const KEYCHAIN_TIMEOUT_MS = Math.max(500, parseInt(process.env.CLEMENTINE_KEYCHAIN_TIMEOUT_MS ?? '3000', 10) || 3000);
|
|
67
69
|
function resolveRef(stub) {
|
|
68
70
|
if (refCache.has(stub)) {
|
|
69
71
|
const v = refCache.get(stub);
|
|
@@ -71,7 +73,7 @@ function resolveRef(stub) {
|
|
|
71
73
|
}
|
|
72
74
|
const account = stub.slice(KEYCHAIN_REF_PREFIX.length);
|
|
73
75
|
try {
|
|
74
|
-
const result = execSync(`security find-generic-password -s clementine-agent -a ${shellEscape(account)} -w`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
76
|
+
const result = execSync(`security find-generic-password -s clementine-agent -a ${shellEscape(account)} -w`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: KEYCHAIN_TIMEOUT_MS }).trim();
|
|
75
77
|
refCache.set(stub, result || null);
|
|
76
78
|
return result || undefined;
|
|
77
79
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bulk-fix the ACL on existing clementine-agent keychain entries.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: keychain entries created before commit 88cfd99 used
|
|
5
|
+
* `add-generic-password -T ''` which means NO apps are pre-approved to
|
|
6
|
+
* read. Every Clementine read triggered a per-app dialog that often
|
|
7
|
+
* didn't appear (hidden behind windows, dismissed silently, queued).
|
|
8
|
+
*
|
|
9
|
+
* The new keychain.set uses `-T /usr/bin/security` so future entries
|
|
10
|
+
* don't have this problem. For existing entries this module runs
|
|
11
|
+
*
|
|
12
|
+
* security set-generic-password-partition-list \
|
|
13
|
+
* -s clementine-agent -a <account> -S apple-tool:,apple:
|
|
14
|
+
*
|
|
15
|
+
* which adds Apple-signed tools (including /usr/bin/security itself)
|
|
16
|
+
* to the partition allowlist. Result: future reads via security
|
|
17
|
+
* succeed silently, no per-app dialog.
|
|
18
|
+
*
|
|
19
|
+
* The user is prompted for their LOGIN keychain password once per call
|
|
20
|
+
* (the macOS system prompt — the one that DOES reliably appear). After
|
|
21
|
+
* approving, all entries become readable without further prompts.
|
|
22
|
+
*/
|
|
23
|
+
export interface KeychainEntry {
|
|
24
|
+
account: string;
|
|
25
|
+
}
|
|
26
|
+
export interface AclFixResult {
|
|
27
|
+
account: string;
|
|
28
|
+
status: 'fixed' | 'failed';
|
|
29
|
+
error?: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Enumerate every clementine-agent keychain entry. Uses the dump-keychain
|
|
33
|
+
* grep approach since `security` doesn't expose a clean list-by-service.
|
|
34
|
+
* Read-only, no prompts.
|
|
35
|
+
*/
|
|
36
|
+
export declare function listClementineKeychainEntries(): KeychainEntry[];
|
|
37
|
+
/**
|
|
38
|
+
* Add `apple-tool:,apple:` to the partition list of a given account.
|
|
39
|
+
*
|
|
40
|
+
* `security set-generic-password-partition-list` prompts on the controlling
|
|
41
|
+
* terminal — `password to unlock default:` — for the user's login keychain
|
|
42
|
+
* password. We must inherit stdio so the child can read from the parent's
|
|
43
|
+
* TTY; piped stdio causes security to consume an empty line and fail with
|
|
44
|
+
* "exit code null" / "wrong password."
|
|
45
|
+
*
|
|
46
|
+
* That means this function only works when called from an interactive shell.
|
|
47
|
+
* Callers in non-TTY contexts should fall back to instructing the user to
|
|
48
|
+
* run `clementine config keychain-fix-acl` from their own terminal.
|
|
49
|
+
*/
|
|
50
|
+
export declare function fixAcl(account: string): AclFixResult;
|
|
51
|
+
/**
|
|
52
|
+
* Plan + apply: enumerate entries, fix each in turn. Returns per-entry
|
|
53
|
+
* results so the CLI can render a checklist.
|
|
54
|
+
*/
|
|
55
|
+
export declare function fixAllClementineEntries(): AclFixResult[];
|
|
56
|
+
//# sourceMappingURL=keychain-fix-acl.d.ts.map
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bulk-fix the ACL on existing clementine-agent keychain entries.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: keychain entries created before commit 88cfd99 used
|
|
5
|
+
* `add-generic-password -T ''` which means NO apps are pre-approved to
|
|
6
|
+
* read. Every Clementine read triggered a per-app dialog that often
|
|
7
|
+
* didn't appear (hidden behind windows, dismissed silently, queued).
|
|
8
|
+
*
|
|
9
|
+
* The new keychain.set uses `-T /usr/bin/security` so future entries
|
|
10
|
+
* don't have this problem. For existing entries this module runs
|
|
11
|
+
*
|
|
12
|
+
* security set-generic-password-partition-list \
|
|
13
|
+
* -s clementine-agent -a <account> -S apple-tool:,apple:
|
|
14
|
+
*
|
|
15
|
+
* which adds Apple-signed tools (including /usr/bin/security itself)
|
|
16
|
+
* to the partition allowlist. Result: future reads via security
|
|
17
|
+
* succeed silently, no per-app dialog.
|
|
18
|
+
*
|
|
19
|
+
* The user is prompted for their LOGIN keychain password once per call
|
|
20
|
+
* (the macOS system prompt — the one that DOES reliably appear). After
|
|
21
|
+
* approving, all entries become readable without further prompts.
|
|
22
|
+
*/
|
|
23
|
+
import { execSync, spawnSync } from 'node:child_process';
|
|
24
|
+
const SERVICE = 'clementine-agent';
|
|
25
|
+
/**
|
|
26
|
+
* Enumerate every clementine-agent keychain entry. Uses the dump-keychain
|
|
27
|
+
* grep approach since `security` doesn't expose a clean list-by-service.
|
|
28
|
+
* Read-only, no prompts.
|
|
29
|
+
*/
|
|
30
|
+
export function listClementineKeychainEntries() {
|
|
31
|
+
try {
|
|
32
|
+
const out = execSync('/usr/bin/security dump-keychain 2>/dev/null', {
|
|
33
|
+
encoding: 'utf-8',
|
|
34
|
+
timeout: 5000,
|
|
35
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
36
|
+
});
|
|
37
|
+
const accounts = new Set();
|
|
38
|
+
// Lines look like: "acct"<blob>="clementine-agent-DISCORD_TOKEN"
|
|
39
|
+
const re = /"acct"<blob>="(clementine-agent-[^"]+)"/g;
|
|
40
|
+
for (const m of out.matchAll(re)) {
|
|
41
|
+
accounts.add(m[1]);
|
|
42
|
+
}
|
|
43
|
+
return Array.from(accounts).sort().map(account => ({ account }));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Add `apple-tool:,apple:` to the partition list of a given account.
|
|
51
|
+
*
|
|
52
|
+
* `security set-generic-password-partition-list` prompts on the controlling
|
|
53
|
+
* terminal — `password to unlock default:` — for the user's login keychain
|
|
54
|
+
* password. We must inherit stdio so the child can read from the parent's
|
|
55
|
+
* TTY; piped stdio causes security to consume an empty line and fail with
|
|
56
|
+
* "exit code null" / "wrong password."
|
|
57
|
+
*
|
|
58
|
+
* That means this function only works when called from an interactive shell.
|
|
59
|
+
* Callers in non-TTY contexts should fall back to instructing the user to
|
|
60
|
+
* run `clementine config keychain-fix-acl` from their own terminal.
|
|
61
|
+
*/
|
|
62
|
+
export function fixAcl(account) {
|
|
63
|
+
const result = spawnSync('/usr/bin/security', [
|
|
64
|
+
'set-generic-password-partition-list',
|
|
65
|
+
'-s', SERVICE,
|
|
66
|
+
'-a', account,
|
|
67
|
+
'-S', 'apple-tool:,apple:',
|
|
68
|
+
], {
|
|
69
|
+
stdio: 'inherit',
|
|
70
|
+
timeout: 120_000, // 2min — generous since the user is typing per call
|
|
71
|
+
});
|
|
72
|
+
if (result.status === 0) {
|
|
73
|
+
return { account, status: 'fixed' };
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
account,
|
|
77
|
+
status: 'failed',
|
|
78
|
+
error: result.error?.message ?? `exit code ${result.status}`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Plan + apply: enumerate entries, fix each in turn. Returns per-entry
|
|
83
|
+
* results so the CLI can render a checklist.
|
|
84
|
+
*/
|
|
85
|
+
export function fixAllClementineEntries() {
|
|
86
|
+
const entries = listClementineKeychainEntries();
|
|
87
|
+
const results = [];
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
results.push(fixAcl(entry.account));
|
|
90
|
+
}
|
|
91
|
+
return results;
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=keychain-fix-acl.js.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inverse of migrate-keychain.ts: pulls non-credential values OUT of the
|
|
3
|
+
* macOS keychain and back into plaintext .env entries.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: an earlier env_set bug routed every value to keychain
|
|
6
|
+
* regardless of whether the key looked like a credential, producing stale
|
|
7
|
+
* keychain entries for things like TASK_BUDGET_HEARTBEAT (a token-count
|
|
8
|
+
* config knob, not a secret). Each one costs the user a keychain prompt
|
|
9
|
+
* for no benefit. This module reverses that mistake — and the user-rule
|
|
10
|
+
* "only actual API keys belong in keychain" stays enforced going forward
|
|
11
|
+
* by the env_set classifier fix in 897bb97.
|
|
12
|
+
*
|
|
13
|
+
* For each line in .env that holds a `keychain:` ref AND whose key does
|
|
14
|
+
* NOT match the sensitivity classifier:
|
|
15
|
+
* 1. Resolve via `security find-generic-password`
|
|
16
|
+
* 2. Replace the .env line with `KEY=<plaintext value>`
|
|
17
|
+
* 3. Delete the keychain entry
|
|
18
|
+
*
|
|
19
|
+
* Atomic: phase-1 reads + writes succeed in a temp file before the original
|
|
20
|
+
* .env is replaced via rename. Keychain deletes happen last, so a partial
|
|
21
|
+
* failure leaves the keychain entry intact (no data loss).
|
|
22
|
+
*
|
|
23
|
+
* Idempotent + opt-in: lines whose key IS credential-shaped pass through
|
|
24
|
+
* untouched even when stored as a keychain ref — we don't undo legitimate
|
|
25
|
+
* keychain storage. --key filter for surgical migrations.
|
|
26
|
+
*/
|
|
27
|
+
export type ReverseMigrationStatus = 'migrated' | 'sensitive-skipped' | 'not-keychain' | 'unresolvable' | 'skipped';
|
|
28
|
+
export interface ReverseMigrationCandidate {
|
|
29
|
+
key: string;
|
|
30
|
+
status: ReverseMigrationStatus;
|
|
31
|
+
/** The keychain stub itself (always safe to log — it's just an account name). */
|
|
32
|
+
ref?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface ReverseMigrationPlan {
|
|
35
|
+
envPath: string;
|
|
36
|
+
candidates: ReverseMigrationCandidate[];
|
|
37
|
+
/** Keys this run would migrate out of keychain. */
|
|
38
|
+
toMigrate: string[];
|
|
39
|
+
/** Refs that look bad — surfaced separately so doctor can flag them. */
|
|
40
|
+
unresolvable: string[];
|
|
41
|
+
}
|
|
42
|
+
export interface ReverseMigrationResult {
|
|
43
|
+
envPath: string;
|
|
44
|
+
migrated: string[];
|
|
45
|
+
failed: Array<{
|
|
46
|
+
key: string;
|
|
47
|
+
error: string;
|
|
48
|
+
}>;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Pure read + classify pass — no .env writes, no keychain deletes, but
|
|
52
|
+
* DOES make read-only `security find-generic-password` calls to detect
|
|
53
|
+
* unresolvable refs (and to verify resolvable ones won't fail later).
|
|
54
|
+
*/
|
|
55
|
+
export declare function planReverseMigration(baseDir: string, opts?: {
|
|
56
|
+
only?: string[];
|
|
57
|
+
}): ReverseMigrationPlan;
|
|
58
|
+
/**
|
|
59
|
+
* Apply the migration. Two phases:
|
|
60
|
+
* 1. Rewrite .env in a temp file, swapping each migrated ref for plaintext.
|
|
61
|
+
* If anything throws, the original .env is untouched.
|
|
62
|
+
* 2. Atomically rename the temp file over .env.
|
|
63
|
+
* 3. Delete each migrated key's keychain entry. Best-effort — failure to
|
|
64
|
+
* delete is logged but doesn't roll back the .env update (the value
|
|
65
|
+
* is now safely in .env regardless).
|
|
66
|
+
*/
|
|
67
|
+
export declare function applyReverseMigration(baseDir: string, opts?: {
|
|
68
|
+
only?: string[];
|
|
69
|
+
}): ReverseMigrationResult;
|
|
70
|
+
//# sourceMappingURL=migrate-from-keychain.d.ts.map
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inverse of migrate-keychain.ts: pulls non-credential values OUT of the
|
|
3
|
+
* macOS keychain and back into plaintext .env entries.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: an earlier env_set bug routed every value to keychain
|
|
6
|
+
* regardless of whether the key looked like a credential, producing stale
|
|
7
|
+
* keychain entries for things like TASK_BUDGET_HEARTBEAT (a token-count
|
|
8
|
+
* config knob, not a secret). Each one costs the user a keychain prompt
|
|
9
|
+
* for no benefit. This module reverses that mistake — and the user-rule
|
|
10
|
+
* "only actual API keys belong in keychain" stays enforced going forward
|
|
11
|
+
* by the env_set classifier fix in 897bb97.
|
|
12
|
+
*
|
|
13
|
+
* For each line in .env that holds a `keychain:` ref AND whose key does
|
|
14
|
+
* NOT match the sensitivity classifier:
|
|
15
|
+
* 1. Resolve via `security find-generic-password`
|
|
16
|
+
* 2. Replace the .env line with `KEY=<plaintext value>`
|
|
17
|
+
* 3. Delete the keychain entry
|
|
18
|
+
*
|
|
19
|
+
* Atomic: phase-1 reads + writes succeed in a temp file before the original
|
|
20
|
+
* .env is replaced via rename. Keychain deletes happen last, so a partial
|
|
21
|
+
* failure leaves the keychain entry intact (no data loss).
|
|
22
|
+
*
|
|
23
|
+
* Idempotent + opt-in: lines whose key IS credential-shaped pass through
|
|
24
|
+
* untouched even when stored as a keychain ref — we don't undo legitimate
|
|
25
|
+
* keychain storage. --key filter for surgical migrations.
|
|
26
|
+
*/
|
|
27
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import * as keychain from '../secrets/keychain.js';
|
|
30
|
+
import { isSensitiveEnvKey } from '../secrets/sensitivity.js';
|
|
31
|
+
const REF_PREFIX = 'keychain:'; // pragma: allowlist secret
|
|
32
|
+
function parseLine(line) {
|
|
33
|
+
const trimmed = line.trim();
|
|
34
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
35
|
+
return { raw: line, passthrough: true };
|
|
36
|
+
const eq = trimmed.indexOf('=');
|
|
37
|
+
if (eq === -1)
|
|
38
|
+
return { raw: line, passthrough: true };
|
|
39
|
+
const key = trimmed.slice(0, eq);
|
|
40
|
+
let value = trimmed.slice(eq + 1);
|
|
41
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
42
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
43
|
+
value = value.slice(1, -1);
|
|
44
|
+
}
|
|
45
|
+
return { raw: line, key, value, passthrough: false };
|
|
46
|
+
}
|
|
47
|
+
function refAccount(stub) {
|
|
48
|
+
return stub.slice(REF_PREFIX.length);
|
|
49
|
+
}
|
|
50
|
+
function tryResolveRef(stub) {
|
|
51
|
+
// Account names are stored under the well-known service "clementine-agent",
|
|
52
|
+
// and the stub is encoded as keychain:<service>-<envVar>. The actual
|
|
53
|
+
// keychain lookup uses the env-var name as the account label, which is
|
|
54
|
+
// also the suffix of the stub past the service prefix.
|
|
55
|
+
const account = refAccount(stub);
|
|
56
|
+
// The keychain `get(envVar)` helper expects just the env-var name; our
|
|
57
|
+
// stub format is `keychain:clementine-agent-<envVar>`, so strip the
|
|
58
|
+
// service prefix before delegating.
|
|
59
|
+
const SERVICE_PREFIX = 'clementine-agent-';
|
|
60
|
+
const envVar = account.startsWith(SERVICE_PREFIX) ? account.slice(SERVICE_PREFIX.length) : account;
|
|
61
|
+
return keychain.get(envVar);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Pure read + classify pass — no .env writes, no keychain deletes, but
|
|
65
|
+
* DOES make read-only `security find-generic-password` calls to detect
|
|
66
|
+
* unresolvable refs (and to verify resolvable ones won't fail later).
|
|
67
|
+
*/
|
|
68
|
+
export function planReverseMigration(baseDir, opts = {}) {
|
|
69
|
+
const envPath = path.join(baseDir, '.env');
|
|
70
|
+
if (!existsSync(envPath)) {
|
|
71
|
+
return { envPath, candidates: [], toMigrate: [], unresolvable: [] };
|
|
72
|
+
}
|
|
73
|
+
const raw = readFileSync(envPath, 'utf-8');
|
|
74
|
+
const onlySet = opts.only ? new Set(opts.only) : undefined;
|
|
75
|
+
const candidates = [];
|
|
76
|
+
const toMigrate = [];
|
|
77
|
+
const unresolvable = [];
|
|
78
|
+
for (const line of raw.split('\n')) {
|
|
79
|
+
const parsed = parseLine(line);
|
|
80
|
+
if (parsed.passthrough || !parsed.key || parsed.value === undefined)
|
|
81
|
+
continue;
|
|
82
|
+
if (onlySet && !onlySet.has(parsed.key)) {
|
|
83
|
+
candidates.push({ key: parsed.key, status: 'skipped' });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (!parsed.value.startsWith(REF_PREFIX)) {
|
|
87
|
+
candidates.push({ key: parsed.key, status: 'not-keychain' });
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (isSensitiveEnvKey(parsed.key)) {
|
|
91
|
+
candidates.push({ key: parsed.key, status: 'sensitive-skipped', ref: parsed.value });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
// Try to resolve. If unresolvable, surface separately — caller decides
|
|
95
|
+
// whether to delete the orphan stub.
|
|
96
|
+
const resolved = tryResolveRef(parsed.value);
|
|
97
|
+
if (resolved === undefined) {
|
|
98
|
+
candidates.push({ key: parsed.key, status: 'unresolvable', ref: parsed.value });
|
|
99
|
+
unresolvable.push(parsed.key);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
candidates.push({ key: parsed.key, status: 'migrated', ref: parsed.value });
|
|
103
|
+
toMigrate.push(parsed.key);
|
|
104
|
+
}
|
|
105
|
+
return { envPath, candidates, toMigrate, unresolvable };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Apply the migration. Two phases:
|
|
109
|
+
* 1. Rewrite .env in a temp file, swapping each migrated ref for plaintext.
|
|
110
|
+
* If anything throws, the original .env is untouched.
|
|
111
|
+
* 2. Atomically rename the temp file over .env.
|
|
112
|
+
* 3. Delete each migrated key's keychain entry. Best-effort — failure to
|
|
113
|
+
* delete is logged but doesn't roll back the .env update (the value
|
|
114
|
+
* is now safely in .env regardless).
|
|
115
|
+
*/
|
|
116
|
+
export function applyReverseMigration(baseDir, opts = {}) {
|
|
117
|
+
const envPath = path.join(baseDir, '.env');
|
|
118
|
+
const result = { envPath, migrated: [], failed: [] };
|
|
119
|
+
if (!existsSync(envPath))
|
|
120
|
+
return result;
|
|
121
|
+
const raw = readFileSync(envPath, 'utf-8');
|
|
122
|
+
const onlySet = opts.only ? new Set(opts.only) : undefined;
|
|
123
|
+
const lines = raw.split('\n');
|
|
124
|
+
const parsedLines = lines.map(parseLine);
|
|
125
|
+
// Phase 1: resolve every target via keychain (read-only). Bail before
|
|
126
|
+
// touching anything if any target is unresolvable — caller can rerun
|
|
127
|
+
// with --key to skip the bad ones.
|
|
128
|
+
const newValues = new Map();
|
|
129
|
+
for (const parsed of parsedLines) {
|
|
130
|
+
if (parsed.passthrough || !parsed.key || parsed.value === undefined)
|
|
131
|
+
continue;
|
|
132
|
+
if (onlySet && !onlySet.has(parsed.key))
|
|
133
|
+
continue;
|
|
134
|
+
if (!parsed.value.startsWith(REF_PREFIX))
|
|
135
|
+
continue;
|
|
136
|
+
if (isSensitiveEnvKey(parsed.key))
|
|
137
|
+
continue;
|
|
138
|
+
const resolved = tryResolveRef(parsed.value);
|
|
139
|
+
if (resolved === undefined) {
|
|
140
|
+
result.failed.push({ key: parsed.key, error: `keychain entry missing or unreadable for ${parsed.value}` });
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
newValues.set(parsed.key, resolved);
|
|
144
|
+
}
|
|
145
|
+
if (result.failed.length > 0)
|
|
146
|
+
return result;
|
|
147
|
+
if (newValues.size === 0)
|
|
148
|
+
return result;
|
|
149
|
+
// Phase 2: rewrite .env atomically.
|
|
150
|
+
const newLines = parsedLines.map((parsed) => {
|
|
151
|
+
if (parsed.passthrough || !parsed.key)
|
|
152
|
+
return parsed.raw;
|
|
153
|
+
const newValue = newValues.get(parsed.key);
|
|
154
|
+
if (newValue === undefined)
|
|
155
|
+
return parsed.raw;
|
|
156
|
+
return `${parsed.key}=${newValue}`;
|
|
157
|
+
});
|
|
158
|
+
const tmp = `${envPath}.${process.pid}.${Date.now()}.tmp`;
|
|
159
|
+
writeFileSync(tmp, newLines.join('\n'));
|
|
160
|
+
renameSync(tmp, envPath);
|
|
161
|
+
// Phase 3: best-effort keychain deletes.
|
|
162
|
+
for (const key of newValues.keys()) {
|
|
163
|
+
try {
|
|
164
|
+
keychain.remove(key);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
/* keychain delete failure is non-fatal — the value is in .env now */
|
|
168
|
+
}
|
|
169
|
+
result.migrated.push(key);
|
|
170
|
+
}
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
//# sourceMappingURL=migrate-from-keychain.js.map
|
package/dist/config.js
CHANGED
|
@@ -60,6 +60,16 @@ const resolvedKeychainRefs = new Map();
|
|
|
60
60
|
function isKeychainRef(value) {
|
|
61
61
|
return typeof value === 'string' && value.startsWith(KEYCHAIN_REF_PREFIX);
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Hard cap on each `security find-generic-password` shell call. The macOS
|
|
65
|
+
* keychain prompts the user for read access on first use of any entry created
|
|
66
|
+
* with restrictive ACL (`-T ''`). If the prompt doesn't appear (hidden behind
|
|
67
|
+
* a window, denied silently, or the user is afk), the call would otherwise
|
|
68
|
+
* block indefinitely — which means `clementine` and the daemon would hang at
|
|
69
|
+
* boot. Cap and fall through to the caller's default; the failure caches as
|
|
70
|
+
* null so we don't re-prompt this process. Override via env if needed.
|
|
71
|
+
*/
|
|
72
|
+
const KEYCHAIN_TIMEOUT_MS = Math.max(500, parseInt(env['CLEMENTINE_KEYCHAIN_TIMEOUT_MS'] ?? process.env.CLEMENTINE_KEYCHAIN_TIMEOUT_MS ?? '3000', 10) || 3000);
|
|
63
73
|
function resolveKeychainRef(stub) {
|
|
64
74
|
if (resolvedKeychainRefs.has(stub)) {
|
|
65
75
|
const cached = resolvedKeychainRefs.get(stub);
|
|
@@ -69,7 +79,7 @@ function resolveKeychainRef(stub) {
|
|
|
69
79
|
// is the account name under the well-known service "clementine-agent".
|
|
70
80
|
const account = stub.slice(KEYCHAIN_REF_PREFIX.length);
|
|
71
81
|
try {
|
|
72
|
-
const result = execSync(`security find-generic-password -s clementine-agent -a ${shellEscape(account)} -w`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
82
|
+
const result = execSync(`security find-generic-password -s clementine-agent -a ${shellEscape(account)} -w`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: KEYCHAIN_TIMEOUT_MS }).trim();
|
|
73
83
|
resolvedKeychainRefs.set(stub, result || null);
|
|
74
84
|
return result || undefined;
|
|
75
85
|
}
|
|
@@ -158,7 +168,7 @@ function getSecret(envKey, keychainService) {
|
|
|
158
168
|
return local;
|
|
159
169
|
const service = keychainService ?? ASSISTANT_NAME.toLowerCase();
|
|
160
170
|
try {
|
|
161
|
-
const result = execSync(`security find-generic-password -s ${shellEscape(service)} -a ${shellEscape(envKey)} -w`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
171
|
+
const result = execSync(`security find-generic-password -s ${shellEscape(service)} -a ${shellEscape(envKey)} -w`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: KEYCHAIN_TIMEOUT_MS });
|
|
162
172
|
return result.trim();
|
|
163
173
|
}
|
|
164
174
|
catch {
|
|
@@ -32,6 +32,25 @@ export declare function parseAgentCronJobs(agentsDir: string): CronJobDefinition
|
|
|
32
32
|
* Returns null on success, or the error message on failure.
|
|
33
33
|
*/
|
|
34
34
|
export declare function validateCronYaml(content: string): string | null;
|
|
35
|
+
/**
|
|
36
|
+
* Detect duplicate job definitions across the global CRON.md and agent-scoped
|
|
37
|
+
* CRON.md files. A "duplicate" is a global job tagged with `agentSlug: X`
|
|
38
|
+
* whose bare name matches a job defined in `agents/X/CRON.md` — i.e. the same
|
|
39
|
+
* conceptual job exists in two places, usually with diverged prompts.
|
|
40
|
+
*
|
|
41
|
+
* Returns a list of warnings; pure function, safe to call from any surface.
|
|
42
|
+
* The scheduler logs these on every reload so the user sees them on first
|
|
43
|
+
* boot AND on every CRON.md edit.
|
|
44
|
+
*/
|
|
45
|
+
export interface DuplicateJobWarning {
|
|
46
|
+
bareName: string;
|
|
47
|
+
agentSlug: string;
|
|
48
|
+
globalJobName: string;
|
|
49
|
+
agentJobName: string;
|
|
50
|
+
/** Diverged fields between the two definitions, for actionable reporting. */
|
|
51
|
+
divergedFields: string[];
|
|
52
|
+
}
|
|
53
|
+
export declare function findDuplicateJobDefinitions(globalJobs: CronJobDefinition[], agentJobs: CronJobDefinition[]): DuplicateJobWarning[];
|
|
35
54
|
export declare function classifyError(err: unknown): 'transient' | 'permanent';
|
|
36
55
|
/**
|
|
37
56
|
* Classify a TerminalReason from the SDK into a retry strategy.
|
|
@@ -193,6 +193,43 @@ export function validateCronYaml(content) {
|
|
|
193
193
|
return err instanceof Error ? err.message : String(err);
|
|
194
194
|
}
|
|
195
195
|
}
|
|
196
|
+
export function findDuplicateJobDefinitions(globalJobs, agentJobs) {
|
|
197
|
+
const warnings = [];
|
|
198
|
+
// Index agent jobs by `${slug}:${bareName}` (which is how parseAgentCronJobs
|
|
199
|
+
// names them). For each global job with an agentSlug, look up the
|
|
200
|
+
// corresponding agent-scoped name.
|
|
201
|
+
const agentByName = new Map();
|
|
202
|
+
for (const a of agentJobs)
|
|
203
|
+
agentByName.set(a.name, a);
|
|
204
|
+
for (const g of globalJobs) {
|
|
205
|
+
if (!g.agentSlug)
|
|
206
|
+
continue;
|
|
207
|
+
const expected = `${g.agentSlug}:${g.name}`;
|
|
208
|
+
const a = agentByName.get(expected);
|
|
209
|
+
if (!a)
|
|
210
|
+
continue;
|
|
211
|
+
// Found a duplicate. Compute diverged fields so the warning is actionable.
|
|
212
|
+
const diverged = [];
|
|
213
|
+
const compare = [
|
|
214
|
+
'schedule', 'enabled', 'tier', 'mode', 'maxHours', 'maxTurns',
|
|
215
|
+
'workDir', 'prompt', 'preCheck',
|
|
216
|
+
];
|
|
217
|
+
for (const f of compare) {
|
|
218
|
+
const gv = g[f];
|
|
219
|
+
const av = a[f];
|
|
220
|
+
if (JSON.stringify(gv) !== JSON.stringify(av))
|
|
221
|
+
diverged.push(f);
|
|
222
|
+
}
|
|
223
|
+
warnings.push({
|
|
224
|
+
bareName: g.name,
|
|
225
|
+
agentSlug: g.agentSlug,
|
|
226
|
+
globalJobName: g.name,
|
|
227
|
+
agentJobName: a.name,
|
|
228
|
+
divergedFields: diverged,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
return warnings;
|
|
232
|
+
}
|
|
196
233
|
// ── Retry / backoff ──────────────────────────────────────────────────
|
|
197
234
|
/** Exponential backoff schedule in ms: 30s, 1m, 5m, 15m, 60m */
|
|
198
235
|
const BACKOFF_MS = [30_000, 60_000, 300_000, 900_000, 3_600_000];
|
|
@@ -425,10 +462,21 @@ export class CronScheduler {
|
|
|
425
462
|
}
|
|
426
463
|
/** Load job definitions from CRON.md and agent dirs without scheduling tasks. */
|
|
427
464
|
loadJobDefinitions() {
|
|
428
|
-
|
|
465
|
+
const globalJobs = parseCronJobs();
|
|
429
466
|
const agentJobs = parseAgentCronJobs(AGENTS_DIR);
|
|
430
|
-
|
|
431
|
-
|
|
467
|
+
this.jobs = [...globalJobs, ...agentJobs];
|
|
468
|
+
// Surface duplicate definitions loud — these are footguns where the same
|
|
469
|
+
// conceptual job is defined in both global and agent-scoped CRON.md, often
|
|
470
|
+
// with diverged prompts. The user usually wants one of them deleted.
|
|
471
|
+
const dupes = findDuplicateJobDefinitions(globalJobs, agentJobs);
|
|
472
|
+
for (const d of dupes) {
|
|
473
|
+
logger.warn({
|
|
474
|
+
bareName: d.bareName,
|
|
475
|
+
agentSlug: d.agentSlug,
|
|
476
|
+
globalJob: d.globalJobName,
|
|
477
|
+
agentJob: d.agentJobName,
|
|
478
|
+
divergedFields: d.divergedFields,
|
|
479
|
+
}, `Duplicate cron definition: '${d.bareName}' is defined in both global CRON.md (with agentSlug=${d.agentSlug}) and agents/${d.agentSlug}/CRON.md. Consolidate to avoid confusion — pick one and delete the other.`);
|
|
432
480
|
}
|
|
433
481
|
}
|
|
434
482
|
/** Register a listener that fires when system state changes (job start/finish, self-improve, etc). */
|
package/dist/secrets/keychain.js
CHANGED
|
@@ -54,14 +54,20 @@ export function set(envVar, value) {
|
|
|
54
54
|
throw new Error('Keychain unavailable on this platform');
|
|
55
55
|
}
|
|
56
56
|
const account = `${SERVICE_NAME}-${envVar}`;
|
|
57
|
-
// -U updates existing entry in place; -s = service; -a = account; -w = password
|
|
57
|
+
// -U updates existing entry in place; -s = service; -a = account; -w = password.
|
|
58
|
+
// -T /usr/bin/security pre-approves the `security` CLI itself — that's what
|
|
59
|
+
// every Clementine read goes through, so reads via clementine/the daemon
|
|
60
|
+
// don't produce a per-process keychain dialog. Without this flag, every
|
|
61
|
+
// node process that reads the entry would block on a UI prompt that may
|
|
62
|
+
// never appear (hidden behind windows, dismissed silently, etc.) — which
|
|
63
|
+
// is the bug that motivated this change.
|
|
58
64
|
const result = spawnSync('/usr/bin/security', [
|
|
59
65
|
'add-generic-password',
|
|
60
66
|
'-U',
|
|
61
67
|
'-s', SERVICE_NAME,
|
|
62
68
|
'-a', account,
|
|
63
69
|
'-w', value,
|
|
64
|
-
'-T', '',
|
|
70
|
+
'-T', '/usr/bin/security',
|
|
65
71
|
'-l', `Clementine: ${envVar}`,
|
|
66
72
|
], { stdio: 'pipe' });
|
|
67
73
|
if (result.status !== 0) {
|