clementine-agent 1.1.2 → 1.1.4
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 +148 -0
- package/dist/config/effective-config.js +3 -1
- package/dist/config/keychain-fix-acl.d.ts +62 -0
- package/dist/config/keychain-fix-acl.js +201 -0
- package/dist/config/migrate-from-keychain.d.ts +70 -0
- package/dist/config/migrate-from-keychain.js +173 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.js +26 -2
- package/dist/gateway/cron-scheduler.d.ts +19 -0
- package/dist/gateway/cron-scheduler.js +51 -3
- package/dist/index.js +11 -0
- package/dist/secrets/keychain.js +8 -2
- package/dist/tools/admin-tools.js +12 -7
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -1148,6 +1148,139 @@ 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
|
+
const ours = entries.filter(e => e.isClementine);
|
|
1231
|
+
const foreign = entries.filter(e => !e.isClementine);
|
|
1232
|
+
console.log();
|
|
1233
|
+
console.log(` ${BOLD}Found ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'} under clementine* services.${RESET}`);
|
|
1234
|
+
console.log(` ${DIM}Will fix ${ours.length}; will skip ${foreign.length} (look like other apps).${RESET}`);
|
|
1235
|
+
console.log();
|
|
1236
|
+
for (const e of entries) {
|
|
1237
|
+
const tag = e.isClementine ? `${GREEN}fix${RESET}` : `${DIM}skip${RESET}`;
|
|
1238
|
+
console.log(` [${tag}] ${e.service}/${e.account}`);
|
|
1239
|
+
}
|
|
1240
|
+
console.log();
|
|
1241
|
+
if (ours.length === 0) {
|
|
1242
|
+
console.log(` ${GREEN}Nothing Clementine-shaped to fix.${RESET}`);
|
|
1243
|
+
console.log();
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
if (opts.list) {
|
|
1247
|
+
console.log(` ${DIM}--list mode — no changes made. Drop the flag to apply.${RESET}`);
|
|
1248
|
+
console.log();
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
console.log(` ${BOLD}Fixing ACLs...${RESET}`);
|
|
1252
|
+
console.log(` ${DIM}macOS may ask for your login keychain password (the system prompt — it DOES appear).${RESET}`);
|
|
1253
|
+
console.log(` ${DIM}One prompt per entry; type Mac password + Enter for each.${RESET}`);
|
|
1254
|
+
console.log();
|
|
1255
|
+
const results = fixAllClementineEntries();
|
|
1256
|
+
let okCount = 0;
|
|
1257
|
+
let failCount = 0;
|
|
1258
|
+
let skipCount = 0;
|
|
1259
|
+
for (const r of results) {
|
|
1260
|
+
if (r.status === 'fixed') {
|
|
1261
|
+
console.log(` ${GREEN}✓${RESET} ${r.service}/${r.account}`);
|
|
1262
|
+
okCount++;
|
|
1263
|
+
}
|
|
1264
|
+
else if (r.status === 'skipped-foreign') {
|
|
1265
|
+
skipCount++;
|
|
1266
|
+
}
|
|
1267
|
+
else {
|
|
1268
|
+
console.log(` ${RED}✗${RESET} ${r.service}/${r.account} ${DIM}— ${r.error}${RESET}`);
|
|
1269
|
+
failCount++;
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
console.log();
|
|
1273
|
+
if (failCount === 0) {
|
|
1274
|
+
console.log(` ${GREEN}All ${okCount} Clementine entries fixed.${RESET} ${DIM}Future reads via the security CLI succeed silently.${RESET}`);
|
|
1275
|
+
if (skipCount > 0)
|
|
1276
|
+
console.log(` ${DIM}(${skipCount} foreign entr${skipCount === 1 ? 'y' : 'ies'} left untouched.)${RESET}`);
|
|
1277
|
+
}
|
|
1278
|
+
else {
|
|
1279
|
+
console.log(` ${YELLOW}${okCount} fixed, ${failCount} failed${skipCount > 0 ? `, ${skipCount} foreign-skipped` : ''}.${RESET}`);
|
|
1280
|
+
console.log(` ${DIM}Failed entries can be fixed manually in Keychain Access.app.${RESET}`);
|
|
1281
|
+
}
|
|
1282
|
+
console.log();
|
|
1283
|
+
}
|
|
1151
1284
|
// ── Advisor commands ────────────────────────────────────────────────
|
|
1152
1285
|
const ADVISOR_MODES = ['off', 'shadow', 'primary'];
|
|
1153
1286
|
function readAdvisorMode() {
|
|
@@ -1802,6 +1935,21 @@ configCmd
|
|
|
1802
1935
|
.action(async (opts) => {
|
|
1803
1936
|
await cmdConfigMigrateToKeychain(opts);
|
|
1804
1937
|
});
|
|
1938
|
+
configCmd
|
|
1939
|
+
.command('migrate-from-keychain')
|
|
1940
|
+
.description('Pull non-credential values OUT of keychain back to plaintext .env (only API keys belong in keychain)')
|
|
1941
|
+
.option('--dry-run', 'Show what would migrate without writing anything')
|
|
1942
|
+
.option('-k, --key <name...>', 'Limit to specific key(s); repeat or comma-separate for multiple')
|
|
1943
|
+
.action(async (opts) => {
|
|
1944
|
+
await cmdConfigMigrateFromKeychain(opts);
|
|
1945
|
+
});
|
|
1946
|
+
configCmd
|
|
1947
|
+
.command('keychain-fix-acl')
|
|
1948
|
+
.description('One-shot fix for clementine-agent keychain entries that prompt on every read (one master prompt then no more)')
|
|
1949
|
+
.option('--list', 'List entries without changing anything')
|
|
1950
|
+
.action(async (opts) => {
|
|
1951
|
+
await cmdConfigKeychainFixAcl(opts);
|
|
1952
|
+
});
|
|
1805
1953
|
configCmd
|
|
1806
1954
|
.command('edit')
|
|
1807
1955
|
.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,62 @@
|
|
|
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
|
+
/**
|
|
24
|
+
* Both keychain service names the codebase has used over time:
|
|
25
|
+
* - "clementine-agent" — used by src/secrets/keychain.ts (env_set / migrate-to-keychain)
|
|
26
|
+
* - "clementine" — getSecret's default fallback when no explicit service
|
|
27
|
+
* passed (src/config.ts: ASSISTANT_NAME.toLowerCase()).
|
|
28
|
+
* Holds older per-agent and handoff entries.
|
|
29
|
+
*/
|
|
30
|
+
declare const SERVICES: readonly ["clementine-agent", "clementine"];
|
|
31
|
+
type Service = typeof SERVICES[number];
|
|
32
|
+
export interface KeychainEntry {
|
|
33
|
+
service: Service;
|
|
34
|
+
account: string;
|
|
35
|
+
/** True when isClementineAccount returned true; only these get fixed. */
|
|
36
|
+
isClementine: boolean;
|
|
37
|
+
}
|
|
38
|
+
export interface AclFixResult {
|
|
39
|
+
service: Service;
|
|
40
|
+
account: string;
|
|
41
|
+
status: 'fixed' | 'failed' | 'skipped-foreign';
|
|
42
|
+
error?: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Enumerate every keychain entry under any service in SERVICES. Uses the
|
|
46
|
+
* dump-keychain grep approach since `security` doesn't expose a clean
|
|
47
|
+
* list-by-service. Read-only, no prompts.
|
|
48
|
+
*
|
|
49
|
+
* For the legacy "clementine" service we set `isClementine: false` on any
|
|
50
|
+
* entry that doesn't match our naming patterns — those get reported but
|
|
51
|
+
* never touched (could be other apps that coincidentally chose that name).
|
|
52
|
+
*/
|
|
53
|
+
export declare function listClementineKeychainEntries(): KeychainEntry[];
|
|
54
|
+
export declare function fixAcl(service: Service, account: string): AclFixResult;
|
|
55
|
+
/**
|
|
56
|
+
* Plan + apply: enumerate entries, fix each Clementine-shaped one in turn.
|
|
57
|
+
* Foreign entries (other apps under the legacy "clementine" service) get
|
|
58
|
+
* reported with status='skipped-foreign' and never touched.
|
|
59
|
+
*/
|
|
60
|
+
export declare function fixAllClementineEntries(): AclFixResult[];
|
|
61
|
+
export {};
|
|
62
|
+
//# sourceMappingURL=keychain-fix-acl.d.ts.map
|
|
@@ -0,0 +1,201 @@
|
|
|
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
|
+
/**
|
|
25
|
+
* Both keychain service names the codebase has used over time:
|
|
26
|
+
* - "clementine-agent" — used by src/secrets/keychain.ts (env_set / migrate-to-keychain)
|
|
27
|
+
* - "clementine" — getSecret's default fallback when no explicit service
|
|
28
|
+
* passed (src/config.ts: ASSISTANT_NAME.toLowerCase()).
|
|
29
|
+
* Holds older per-agent and handoff entries.
|
|
30
|
+
*/
|
|
31
|
+
const SERVICES = ['clementine-agent', 'clementine'];
|
|
32
|
+
/**
|
|
33
|
+
* Under the legacy "clementine" service, some non-Clementine apps
|
|
34
|
+
* coincidentally store entries (e.g., macOS "Local Crypto Key Data"
|
|
35
|
+
* with a UUID prefix). We refuse to touch those — only entries that
|
|
36
|
+
* match our naming conventions get the ACL update.
|
|
37
|
+
*/
|
|
38
|
+
function isClementineAccount(service, account) {
|
|
39
|
+
if (service === 'clementine-agent')
|
|
40
|
+
return true; // we own this whole service
|
|
41
|
+
// For the legacy "clementine" service, conservatively only touch entries
|
|
42
|
+
// that look like things we set: per-agent secrets (AGENT_*),
|
|
43
|
+
// handoff-decryption-key-*, oauth-tokens, env-var names (UPPER_SNAKE),
|
|
44
|
+
// anything starting with "clementine-".
|
|
45
|
+
if (account.startsWith('AGENT_'))
|
|
46
|
+
return true;
|
|
47
|
+
if (account.startsWith('handoff-'))
|
|
48
|
+
return true;
|
|
49
|
+
if (account === 'oauth-tokens')
|
|
50
|
+
return true;
|
|
51
|
+
if (account.startsWith('clementine-'))
|
|
52
|
+
return true;
|
|
53
|
+
if (/^[A-Z][A-Z0-9_]*$/.test(account))
|
|
54
|
+
return true;
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Enumerate every keychain entry under any service in SERVICES. Uses the
|
|
59
|
+
* dump-keychain grep approach since `security` doesn't expose a clean
|
|
60
|
+
* list-by-service. Read-only, no prompts.
|
|
61
|
+
*
|
|
62
|
+
* For the legacy "clementine" service we set `isClementine: false` on any
|
|
63
|
+
* entry that doesn't match our naming patterns — those get reported but
|
|
64
|
+
* never touched (could be other apps that coincidentally chose that name).
|
|
65
|
+
*/
|
|
66
|
+
export function listClementineKeychainEntries() {
|
|
67
|
+
let raw;
|
|
68
|
+
try {
|
|
69
|
+
raw = execSync('/usr/bin/security dump-keychain 2>/dev/null', {
|
|
70
|
+
encoding: 'utf-8',
|
|
71
|
+
timeout: 10_000,
|
|
72
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
73
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
// dump-keychain emits one record per item. Within a record, fields appear
|
|
80
|
+
// in arbitrary order — `acct` often comes BEFORE `svce`. So we can't track
|
|
81
|
+
// "last-seen svce" line-by-line; we have to split into per-record blocks
|
|
82
|
+
// and extract both fields from each block.
|
|
83
|
+
//
|
|
84
|
+
// Each record starts with `keychain: "/path/to/keychain"` followed by the
|
|
85
|
+
// `version`, `class`, `attributes:` lines and the field blobs. The next
|
|
86
|
+
// record begins at the next `^keychain: ` line.
|
|
87
|
+
const entries = [];
|
|
88
|
+
const seen = new Set();
|
|
89
|
+
// Split by record boundary. Use a positive lookahead so the delimiter stays
|
|
90
|
+
// at the start of each chunk.
|
|
91
|
+
const blocks = raw.split(/\n(?=keychain: ")/);
|
|
92
|
+
for (const block of blocks) {
|
|
93
|
+
const svceMatch = block.match(/"svce"<blob>="([^"]+)"/);
|
|
94
|
+
const acctMatch = block.match(/"acct"<blob>="([^"]+)"/);
|
|
95
|
+
if (!svceMatch || !acctMatch)
|
|
96
|
+
continue;
|
|
97
|
+
const svc = svceMatch[1];
|
|
98
|
+
const account = acctMatch[1];
|
|
99
|
+
if (!SERVICES.includes(svc))
|
|
100
|
+
continue;
|
|
101
|
+
const service = svc;
|
|
102
|
+
const dedupeKey = `${service}\x00${account}`;
|
|
103
|
+
if (seen.has(dedupeKey))
|
|
104
|
+
continue;
|
|
105
|
+
seen.add(dedupeKey);
|
|
106
|
+
entries.push({
|
|
107
|
+
service,
|
|
108
|
+
account,
|
|
109
|
+
isClementine: isClementineAccount(service, account),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
// Stable sort: service first, then account
|
|
113
|
+
entries.sort((a, b) => a.service === b.service ? a.account.localeCompare(b.account) : a.service.localeCompare(b.service));
|
|
114
|
+
return entries;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Add `apple-tool:,apple:` to the partition list of a given account.
|
|
118
|
+
*
|
|
119
|
+
* `security set-generic-password-partition-list` prompts on the controlling
|
|
120
|
+
* terminal — `password to unlock default:` — for the user's login keychain
|
|
121
|
+
* password. We must inherit stdio so the child can read from the parent's
|
|
122
|
+
* TTY; piped stdio causes security to consume an empty line and fail with
|
|
123
|
+
* "exit code null" / "wrong password."
|
|
124
|
+
*
|
|
125
|
+
* That means this function only works when called from an interactive shell.
|
|
126
|
+
* Callers in non-TTY contexts should fall back to instructing the user to
|
|
127
|
+
* run `clementine config keychain-fix-acl` from their own terminal.
|
|
128
|
+
*/
|
|
129
|
+
/**
|
|
130
|
+
* Discover which keychain a (service, account) pair lives in. Returns the
|
|
131
|
+
* path or null if find-generic-password can't locate it (in which case we
|
|
132
|
+
* skip — the entry isn't reachable via standard search anyway).
|
|
133
|
+
*/
|
|
134
|
+
function locateKeychain(service, account) {
|
|
135
|
+
const probe = spawnSync('/usr/bin/security', [
|
|
136
|
+
'find-generic-password',
|
|
137
|
+
'-s', service,
|
|
138
|
+
'-a', account,
|
|
139
|
+
], {
|
|
140
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
141
|
+
timeout: 5000,
|
|
142
|
+
encoding: 'utf-8',
|
|
143
|
+
});
|
|
144
|
+
if (probe.status !== 0)
|
|
145
|
+
return null;
|
|
146
|
+
// First line is `keychain: "/path/to/keychain"` — extract.
|
|
147
|
+
const first = (probe.stdout || '').split('\n')[0] ?? '';
|
|
148
|
+
const m = first.match(/^keychain:\s+"([^"]+)"/);
|
|
149
|
+
return m ? m[1] : null;
|
|
150
|
+
}
|
|
151
|
+
export function fixAcl(service, account) {
|
|
152
|
+
const keychainPath = locateKeychain(service, account);
|
|
153
|
+
if (!keychainPath) {
|
|
154
|
+
return {
|
|
155
|
+
service,
|
|
156
|
+
account,
|
|
157
|
+
status: 'failed',
|
|
158
|
+
error: 'item not findable via standard search (may be in iCloud or a non-default keychain) — leaving alone',
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
// Pass the keychain path as the trailing positional arg so partition-list
|
|
162
|
+
// doesn't search the wrong store.
|
|
163
|
+
const args = [
|
|
164
|
+
'set-generic-password-partition-list',
|
|
165
|
+
'-s', service,
|
|
166
|
+
'-a', account,
|
|
167
|
+
'-S', 'apple-tool:,apple:',
|
|
168
|
+
keychainPath,
|
|
169
|
+
];
|
|
170
|
+
const result = spawnSync('/usr/bin/security', args, {
|
|
171
|
+
stdio: 'inherit',
|
|
172
|
+
timeout: 120_000,
|
|
173
|
+
});
|
|
174
|
+
if (result.status === 0) {
|
|
175
|
+
return { service, account, status: 'fixed' };
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
service,
|
|
179
|
+
account,
|
|
180
|
+
status: 'failed',
|
|
181
|
+
error: result.error?.message ?? `exit code ${result.status}`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Plan + apply: enumerate entries, fix each Clementine-shaped one in turn.
|
|
186
|
+
* Foreign entries (other apps under the legacy "clementine" service) get
|
|
187
|
+
* reported with status='skipped-foreign' and never touched.
|
|
188
|
+
*/
|
|
189
|
+
export function fixAllClementineEntries() {
|
|
190
|
+
const entries = listClementineKeychainEntries();
|
|
191
|
+
const results = [];
|
|
192
|
+
for (const entry of entries) {
|
|
193
|
+
if (!entry.isClementine) {
|
|
194
|
+
results.push({ service: entry.service, account: entry.account, status: 'skipped-foreign' });
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
results.push(fixAcl(entry.service, entry.account));
|
|
198
|
+
}
|
|
199
|
+
return results;
|
|
200
|
+
}
|
|
201
|
+
//# 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.d.ts
CHANGED
|
@@ -14,6 +14,13 @@ export declare const BASE_DIR: string;
|
|
|
14
14
|
export declare function envSnapshot(): Record<string, string | undefined>;
|
|
15
15
|
/** Test-only: clear the keychain ref cache so re-resolution can be tested. */
|
|
16
16
|
export declare function _resetKeychainRefCache(): void;
|
|
17
|
+
/**
|
|
18
|
+
* Return the keychain stubs that couldn't be resolved this process. Used by
|
|
19
|
+
* the daemon entrypoint to log a clear remediation hint at boot if any
|
|
20
|
+
* keychain reads are failing (typically: ACL not yet partition-listed →
|
|
21
|
+
* `clementine config keychain-fix-acl` fixes it).
|
|
22
|
+
*/
|
|
23
|
+
export declare function getFailedKeychainResolutions(): string[];
|
|
17
24
|
export declare const VAULT_DIR: string;
|
|
18
25
|
export declare const SYSTEM_DIR: string;
|
|
19
26
|
export declare const DAILY_NOTES_DIR: string;
|
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
|
}
|
|
@@ -107,6 +117,20 @@ export function envSnapshot() {
|
|
|
107
117
|
export function _resetKeychainRefCache() {
|
|
108
118
|
resolvedKeychainRefs.clear();
|
|
109
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Return the keychain stubs that couldn't be resolved this process. Used by
|
|
122
|
+
* the daemon entrypoint to log a clear remediation hint at boot if any
|
|
123
|
+
* keychain reads are failing (typically: ACL not yet partition-listed →
|
|
124
|
+
* `clementine config keychain-fix-acl` fixes it).
|
|
125
|
+
*/
|
|
126
|
+
export function getFailedKeychainResolutions() {
|
|
127
|
+
const out = [];
|
|
128
|
+
for (const [stub, value] of resolvedKeychainRefs) {
|
|
129
|
+
if (value === null)
|
|
130
|
+
out.push(stub);
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
110
134
|
// ── Paths ────────────────────────────────────────────────────────────
|
|
111
135
|
export const VAULT_DIR = path.join(BASE_DIR, 'vault');
|
|
112
136
|
export const SYSTEM_DIR = path.join(VAULT_DIR, '00-System');
|
|
@@ -158,7 +182,7 @@ function getSecret(envKey, keychainService) {
|
|
|
158
182
|
return local;
|
|
159
183
|
const service = keychainService ?? ASSISTANT_NAME.toLowerCase();
|
|
160
184
|
try {
|
|
161
|
-
const result = execSync(`security find-generic-password -s ${shellEscape(service)} -a ${shellEscape(envKey)} -w`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
185
|
+
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
186
|
return result.trim();
|
|
163
187
|
}
|
|
164
188
|
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/index.js
CHANGED
|
@@ -548,6 +548,17 @@ async function asyncMain() {
|
|
|
548
548
|
hydrateSecretsFromEnv();
|
|
549
549
|
}
|
|
550
550
|
catch { /* non-fatal — non-macOS systems, or keychain unavailable */ }
|
|
551
|
+
// ── Surface keychain resolution failures with a clear remediation hint ──
|
|
552
|
+
// If any keychain ref couldn't be read at module-init time, the user is
|
|
553
|
+
// probably hitting the per-process approval-dialog issue (entry written
|
|
554
|
+
// with the wrong ACL). The fix is one command — print it loud so they
|
|
555
|
+
// don't have to grep for the answer.
|
|
556
|
+
const failedKcRefs = config.getFailedKeychainResolutions();
|
|
557
|
+
if (failedKcRefs.length > 0) {
|
|
558
|
+
logger.warn({ count: failedKcRefs.length, refs: failedKcRefs }, `${failedKcRefs.length} keychain reference(s) could not be resolved at startup.`);
|
|
559
|
+
logger.warn('Affected channels/integrations may be degraded. Fix in one command: clementine config keychain-fix-acl');
|
|
560
|
+
logger.warn('See: https://github.com/Natebreynolds/Clementine-AI-Assistant#keychain-prompts');
|
|
561
|
+
}
|
|
551
562
|
// ── Check MCP extension permissions ────────────────────────────
|
|
552
563
|
try {
|
|
553
564
|
const { checkPermissionsOnStartup, bootstrapClaudeIntegrationsFromAuditLog, probeAvailableTools } = await import('./agent/mcp-bridge.js');
|
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) {
|
|
@@ -122,10 +122,10 @@ export function registerAdminTools(server) {
|
|
|
122
122
|
return textResult(`Timer set. Reminder in ${minutes} minute${minutes !== 1 ? 's' : ''} (~${fireTime}): "${message}"`);
|
|
123
123
|
});
|
|
124
124
|
// ── Env self-configuration (owner-DM only) ────────────────────────────
|
|
125
|
-
server.tool('env_set', 'Save or update an env var. Owner-DM only.
|
|
125
|
+
server.tool('env_set', 'Save or update an env var. Owner-DM only. Default behavior writes to plain ~/.clementine/.env (mode 0600). Pass storage="keychain" to opt into macOS Keychain storage — but be aware keychain entries can require per-app approval prompts on first read which create UX friction (see commit history for the rabbit hole). Use plain .env unless you specifically need at-rest encryption beyond filesystem permissions.', {
|
|
126
126
|
key: z.string().describe('Env var name (uppercase with underscores, e.g. STRIPE_API_KEY)'),
|
|
127
127
|
value: z.string().describe('The value to store. Never echo back to the user; it will be masked in logs.'),
|
|
128
|
-
storage: z.enum(['keychain', 'env', 'auto']).optional().describe('Where to store it. "auto"
|
|
128
|
+
storage: z.enum(['keychain', 'env', 'auto']).optional().describe('Where to store it. Default (and "auto"/"env") writes plaintext to ~/.clementine/.env. "keychain" opts into macOS Keychain — only use when at-rest encryption matters more than read ergonomics.'),
|
|
129
129
|
}, async ({ key, value, storage }) => {
|
|
130
130
|
const gate = requireOwnerDm();
|
|
131
131
|
if (!gate.ok)
|
|
@@ -137,14 +137,19 @@ export function registerAdminTools(server) {
|
|
|
137
137
|
if (!value)
|
|
138
138
|
return textResult('Refused: empty value. Use env_unset to remove a key.');
|
|
139
139
|
const mode = storage ?? 'auto';
|
|
140
|
-
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
140
|
+
// Keychain is now strictly opt-in. The legacy 'auto' mode used to route
|
|
141
|
+
// credential-shaped keys to keychain, but that produced a class of read-
|
|
142
|
+
// approval dialog UX bugs (see commits 88cfd99 .. c34da0b). Plaintext .env
|
|
143
|
+
// with mode 0600 is the safer default — credentials still encrypted at
|
|
144
|
+
// rest if FileVault is on, and no per-process keychain prompts.
|
|
145
|
+
const useKeychain = mode === 'keychain';
|
|
145
146
|
if (mode === 'keychain' && !keychain.isAvailable()) {
|
|
146
147
|
return textResult('Refused: Keychain storage requested but macOS Keychain is unavailable on this system.');
|
|
147
148
|
}
|
|
149
|
+
// Reference unused-but-imported helper so the import line stays meaningful
|
|
150
|
+
// for grep — it's used by other modules and we may re-enable smart routing
|
|
151
|
+
// later behind a feature flag.
|
|
152
|
+
void isSensitiveEnvKey;
|
|
148
153
|
const map = parseEnvFile();
|
|
149
154
|
const existed = map.has(normalizedKey);
|
|
150
155
|
let envFileValue;
|