clementine-agent 1.1.3 → 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 +23 -12
- package/dist/config/keychain-fix-acl.d.ts +26 -20
- package/dist/config/keychain-fix-acl.js +130 -22
- package/dist/config.d.ts +7 -0
- package/dist/config.js +14 -0
- package/dist/index.js +11 -0
- package/dist/tools/admin-tools.js +12 -7
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -1227,13 +1227,19 @@ async function cmdConfigKeychainFixAcl(opts) {
|
|
|
1227
1227
|
const RED = '\x1b[0;31m';
|
|
1228
1228
|
const RESET = '\x1b[0m';
|
|
1229
1229
|
const entries = listClementineKeychainEntries();
|
|
1230
|
+
const ours = entries.filter(e => e.isClementine);
|
|
1231
|
+
const foreign = entries.filter(e => !e.isClementine);
|
|
1230
1232
|
console.log();
|
|
1231
|
-
console.log(` ${BOLD}Found ${entries.length}
|
|
1232
|
-
|
|
1233
|
-
console.log(` ${DIM}${e.account}${RESET}`);
|
|
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}`);
|
|
1234
1235
|
console.log();
|
|
1235
|
-
|
|
1236
|
-
|
|
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}`);
|
|
1237
1243
|
console.log();
|
|
1238
1244
|
return;
|
|
1239
1245
|
}
|
|
@@ -1244,29 +1250,34 @@ async function cmdConfigKeychainFixAcl(opts) {
|
|
|
1244
1250
|
}
|
|
1245
1251
|
console.log(` ${BOLD}Fixing ACLs...${RESET}`);
|
|
1246
1252
|
console.log(` ${DIM}macOS may ask for your login keychain password (the system prompt — it DOES appear).${RESET}`);
|
|
1247
|
-
console.log(` ${DIM}
|
|
1253
|
+
console.log(` ${DIM}One prompt per entry; type Mac password + Enter for each.${RESET}`);
|
|
1248
1254
|
console.log();
|
|
1249
1255
|
const results = fixAllClementineEntries();
|
|
1250
1256
|
let okCount = 0;
|
|
1251
1257
|
let failCount = 0;
|
|
1258
|
+
let skipCount = 0;
|
|
1252
1259
|
for (const r of results) {
|
|
1253
1260
|
if (r.status === 'fixed') {
|
|
1254
|
-
console.log(` ${GREEN}✓${RESET} ${r.account}`);
|
|
1261
|
+
console.log(` ${GREEN}✓${RESET} ${r.service}/${r.account}`);
|
|
1255
1262
|
okCount++;
|
|
1256
1263
|
}
|
|
1264
|
+
else if (r.status === 'skipped-foreign') {
|
|
1265
|
+
skipCount++;
|
|
1266
|
+
}
|
|
1257
1267
|
else {
|
|
1258
|
-
console.log(` ${RED}✗${RESET} ${r.account} ${DIM}— ${r.error}${RESET}`);
|
|
1268
|
+
console.log(` ${RED}✗${RESET} ${r.service}/${r.account} ${DIM}— ${r.error}${RESET}`);
|
|
1259
1269
|
failCount++;
|
|
1260
1270
|
}
|
|
1261
1271
|
}
|
|
1262
1272
|
console.log();
|
|
1263
1273
|
if (failCount === 0) {
|
|
1264
|
-
console.log(` ${GREEN}All ${okCount} entries fixed.${RESET} ${DIM}Future reads via the security CLI succeed silently.${RESET}`);
|
|
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}`);
|
|
1265
1277
|
}
|
|
1266
1278
|
else {
|
|
1267
|
-
console.log(` ${YELLOW}${okCount} fixed, ${failCount} failed.${RESET}`);
|
|
1268
|
-
console.log(` ${DIM}Failed entries can be fixed manually in Keychain Access.app
|
|
1269
|
-
console.log(` ${DIM} search "clementine-agent" → double-click → Access Control → Allow all applications.${RESET}`);
|
|
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}`);
|
|
1270
1281
|
}
|
|
1271
1282
|
console.log();
|
|
1272
1283
|
}
|
|
@@ -20,37 +20,43 @@
|
|
|
20
20
|
* (the macOS system prompt — the one that DOES reliably appear). After
|
|
21
21
|
* approving, all entries become readable without further prompts.
|
|
22
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];
|
|
23
32
|
export interface KeychainEntry {
|
|
33
|
+
service: Service;
|
|
24
34
|
account: string;
|
|
35
|
+
/** True when isClementineAccount returned true; only these get fixed. */
|
|
36
|
+
isClementine: boolean;
|
|
25
37
|
}
|
|
26
38
|
export interface AclFixResult {
|
|
39
|
+
service: Service;
|
|
27
40
|
account: string;
|
|
28
|
-
status: 'fixed' | 'failed';
|
|
41
|
+
status: 'fixed' | 'failed' | 'skipped-foreign';
|
|
29
42
|
error?: string;
|
|
30
43
|
}
|
|
31
44
|
/**
|
|
32
|
-
* Enumerate every
|
|
33
|
-
* grep approach since `security` doesn't expose a clean
|
|
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
|
+
* 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.
|
|
45
48
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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).
|
|
49
52
|
*/
|
|
50
|
-
export declare function
|
|
53
|
+
export declare function listClementineKeychainEntries(): KeychainEntry[];
|
|
54
|
+
export declare function fixAcl(service: Service, account: string): AclFixResult;
|
|
51
55
|
/**
|
|
52
|
-
* Plan + apply: enumerate entries, fix each in turn.
|
|
53
|
-
*
|
|
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.
|
|
54
59
|
*/
|
|
55
60
|
export declare function fixAllClementineEntries(): AclFixResult[];
|
|
61
|
+
export {};
|
|
56
62
|
//# sourceMappingURL=keychain-fix-acl.d.ts.map
|
|
@@ -21,30 +21,97 @@
|
|
|
21
21
|
* approving, all entries become readable without further prompts.
|
|
22
22
|
*/
|
|
23
23
|
import { execSync, spawnSync } from 'node:child_process';
|
|
24
|
-
const SERVICE = 'clementine-agent';
|
|
25
24
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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).
|
|
29
65
|
*/
|
|
30
66
|
export function listClementineKeychainEntries() {
|
|
67
|
+
let raw;
|
|
31
68
|
try {
|
|
32
|
-
|
|
69
|
+
raw = execSync('/usr/bin/security dump-keychain 2>/dev/null', {
|
|
33
70
|
encoding: 'utf-8',
|
|
34
|
-
timeout:
|
|
71
|
+
timeout: 10_000,
|
|
35
72
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
73
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
36
74
|
});
|
|
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
75
|
}
|
|
45
76
|
catch {
|
|
46
77
|
return [];
|
|
47
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;
|
|
48
115
|
}
|
|
49
116
|
/**
|
|
50
117
|
* Add `apple-tool:,apple:` to the partition list of a given account.
|
|
@@ -59,34 +126,75 @@ export function listClementineKeychainEntries() {
|
|
|
59
126
|
* Callers in non-TTY contexts should fall back to instructing the user to
|
|
60
127
|
* run `clementine config keychain-fix-acl` from their own terminal.
|
|
61
128
|
*/
|
|
62
|
-
|
|
63
|
-
|
|
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 = [
|
|
64
164
|
'set-generic-password-partition-list',
|
|
65
|
-
'-s',
|
|
165
|
+
'-s', service,
|
|
66
166
|
'-a', account,
|
|
67
167
|
'-S', 'apple-tool:,apple:',
|
|
68
|
-
|
|
168
|
+
keychainPath,
|
|
169
|
+
];
|
|
170
|
+
const result = spawnSync('/usr/bin/security', args, {
|
|
69
171
|
stdio: 'inherit',
|
|
70
|
-
timeout: 120_000,
|
|
172
|
+
timeout: 120_000,
|
|
71
173
|
});
|
|
72
174
|
if (result.status === 0) {
|
|
73
|
-
return { account, status: 'fixed' };
|
|
175
|
+
return { service, account, status: 'fixed' };
|
|
74
176
|
}
|
|
75
177
|
return {
|
|
178
|
+
service,
|
|
76
179
|
account,
|
|
77
180
|
status: 'failed',
|
|
78
181
|
error: result.error?.message ?? `exit code ${result.status}`,
|
|
79
182
|
};
|
|
80
183
|
}
|
|
81
184
|
/**
|
|
82
|
-
* Plan + apply: enumerate entries, fix each in turn.
|
|
83
|
-
*
|
|
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.
|
|
84
188
|
*/
|
|
85
189
|
export function fixAllClementineEntries() {
|
|
86
190
|
const entries = listClementineKeychainEntries();
|
|
87
191
|
const results = [];
|
|
88
192
|
for (const entry of entries) {
|
|
89
|
-
|
|
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));
|
|
90
198
|
}
|
|
91
199
|
return results;
|
|
92
200
|
}
|
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
|
@@ -117,6 +117,20 @@ export function envSnapshot() {
|
|
|
117
117
|
export function _resetKeychainRefCache() {
|
|
118
118
|
resolvedKeychainRefs.clear();
|
|
119
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
|
+
}
|
|
120
134
|
// ── Paths ────────────────────────────────────────────────────────────
|
|
121
135
|
export const VAULT_DIR = path.join(BASE_DIR, 'vault');
|
|
122
136
|
export const SYSTEM_DIR = path.join(VAULT_DIR, '00-System');
|
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');
|
|
@@ -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;
|