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 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
- this.jobs = parseCronJobs();
465
+ const globalJobs = parseCronJobs();
429
466
  const agentJobs = parseAgentCronJobs(AGENTS_DIR);
430
- if (agentJobs.length > 0) {
431
- this.jobs.push(...agentJobs);
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). */
@@ -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', '', // no apps pre-approved — will prompt on first read; user can approve for login session
70
+ '-T', '/usr/bin/security',
65
71
  '-l', `Clementine: ${envVar}`,
66
72
  ], { stdio: 'pipe' });
67
73
  if (result.status !== 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.1.2",
3
+ "version": "1.1.3",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",