clementine-agent 1.18.138 → 1.18.140
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/agent/action-enforcer.d.ts +0 -6
- package/dist/agent/action-enforcer.js +0 -25
- package/dist/agent/execution-advisor.d.ts +1 -1
- package/dist/agent/execution-advisor.js +1 -1
- package/dist/agent/mcp-bridge.js +13 -7
- package/dist/agent/self-improve.js +1 -1
- package/dist/channels/discord-agent-bot.d.ts +1 -1
- package/dist/channels/discord-bot-manager.d.ts +1 -1
- package/dist/channels/discord.d.ts +2 -1
- package/dist/cli/cron.js +2 -1
- package/dist/cli/dashboard.js +0 -75
- package/dist/cli/index.js +1 -250
- package/dist/index.js +2 -1
- package/dist/tools/admin-tools.js +2 -10
- package/package.json +1 -1
- package/dist/agent/contradiction-validator.d.ts +0 -70
- package/dist/agent/contradiction-validator.js +0 -143
- package/dist/config/migrate-from-keychain.d.ts +0 -70
- package/dist/config/migrate-from-keychain.js +0 -173
- package/dist/config/migrate-keychain.d.ts +0 -55
- package/dist/config/migrate-keychain.js +0 -144
- package/dist/gateway/heartbeat.d.ts +0 -3
- package/dist/gateway/heartbeat.js +0 -3
|
@@ -20,10 +20,4 @@ export declare function assessActionResponse(input: {
|
|
|
20
20
|
backgroundStarted?: boolean;
|
|
21
21
|
delegated?: boolean;
|
|
22
22
|
}): ActionResponseAssessment;
|
|
23
|
-
export declare function buildActionEnforcementPrompt(input: {
|
|
24
|
-
userText: string;
|
|
25
|
-
previousResponse: string;
|
|
26
|
-
reason: string;
|
|
27
|
-
}): string;
|
|
28
|
-
export declare function fallbackUnverifiedActionResponse(reason: string): string;
|
|
29
23
|
//# sourceMappingURL=action-enforcer.d.ts.map
|
|
@@ -92,29 +92,4 @@ export function assessActionResponse(input) {
|
|
|
92
92
|
}
|
|
93
93
|
return { violation: false, reason: 'no unsupported action claim detected' };
|
|
94
94
|
}
|
|
95
|
-
export function buildActionEnforcementPrompt(input) {
|
|
96
|
-
return [
|
|
97
|
-
'[SYSTEM ACTION ENFORCEMENT]',
|
|
98
|
-
'Your previous response was not allowed because it implied action without verified tool activity.',
|
|
99
|
-
`Reason: ${input.reason}`,
|
|
100
|
-
'',
|
|
101
|
-
'Original user request:',
|
|
102
|
-
input.userText.slice(0, 1200),
|
|
103
|
-
'',
|
|
104
|
-
'Previous response:',
|
|
105
|
-
input.previousResponse.slice(0, 1200),
|
|
106
|
-
'',
|
|
107
|
-
'Now correct this in the same turn:',
|
|
108
|
-
'- If the action is possible, use the appropriate tool now.',
|
|
109
|
-
'- If the action is blocked, say exactly what is blocking it.',
|
|
110
|
-
'- Do not say "done", "sent", "queued", "checked", or similar unless a tool call actually verifies it.',
|
|
111
|
-
].join('\n');
|
|
112
|
-
}
|
|
113
|
-
export function fallbackUnverifiedActionResponse(reason) {
|
|
114
|
-
return [
|
|
115
|
-
"I didn't complete that yet.",
|
|
116
|
-
`I caught an action-verification issue: ${reason}.`,
|
|
117
|
-
"I won't call it done without a tool confirmation. Please resend the request and I'll retry from a clean turn.",
|
|
118
|
-
].join(' ');
|
|
119
|
-
}
|
|
120
95
|
//# sourceMappingURL=action-enforcer.js.map
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* cron job execution parameters: turn limits, models, timeouts, prompt
|
|
6
6
|
* enrichment, escalation, and circuit-breaking.
|
|
7
7
|
*/
|
|
8
|
-
import { CronRunLog } from '../gateway/
|
|
8
|
+
import { CronRunLog } from '../gateway/cron-scheduler.js';
|
|
9
9
|
import type { CronJobDefinition, ExecutionAdvice } from '../types.js';
|
|
10
10
|
export declare const TIER_MAX_TURNS: Record<number, number>;
|
|
11
11
|
export declare const DEFAULT_TIMEOUT_MS = 600000;
|
|
@@ -9,7 +9,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import pino from 'pino';
|
|
11
11
|
import { ADVISOR_RULES_LOADER, CRON_REFLECTIONS_DIR, ADVISOR_LOG_PATH } from '../config.js';
|
|
12
|
-
import { CronRunLog } from '../gateway/
|
|
12
|
+
import { CronRunLog } from '../gateway/cron-scheduler.js';
|
|
13
13
|
import { evolvePrompt } from './prompt-evolver.js';
|
|
14
14
|
import { loadAdvisorRules, getLoadedRules, watchUserRulesDir, } from './advisor-rules/loader.js';
|
|
15
15
|
import { buildRuleContext } from './advisor-rules/context.js';
|
package/dist/agent/mcp-bridge.js
CHANGED
|
@@ -86,12 +86,18 @@ export function discoverMcpServers() {
|
|
|
86
86
|
}
|
|
87
87
|
catch { /* ignore */ }
|
|
88
88
|
// 2. Claude Code settings — project-level MCP configs
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
89
|
+
// Two files to check: ~/.claude/settings.json (newer; user-edited)
|
|
90
|
+
// and ~/.claude.json (where `claude mcp add` writes by default).
|
|
91
|
+
// Both surface mcpServers; we read both so users get the same set
|
|
92
|
+
// that Claude Code sees.
|
|
93
|
+
for (const claudeFile of [
|
|
94
|
+
path.join(os.homedir(), '.claude', 'settings.json'),
|
|
95
|
+
path.join(os.homedir(), '.claude.json'),
|
|
96
|
+
]) {
|
|
97
|
+
try {
|
|
98
|
+
if (!existsSync(claudeFile))
|
|
99
|
+
continue;
|
|
100
|
+
const settings = JSON.parse(readFileSync(claudeFile, 'utf-8'));
|
|
95
101
|
for (const [name, config] of Object.entries(settings.mcpServers ?? {})) {
|
|
96
102
|
if (servers.has(name))
|
|
97
103
|
continue;
|
|
@@ -110,8 +116,8 @@ export function discoverMcpServers() {
|
|
|
110
116
|
});
|
|
111
117
|
}
|
|
112
118
|
}
|
|
119
|
+
catch { /* skip malformed file */ }
|
|
113
120
|
}
|
|
114
|
-
catch { /* ignore */ }
|
|
115
121
|
// 3. Claude Desktop Extensions (newer format — ant.dir.* directories)
|
|
116
122
|
try {
|
|
117
123
|
const extensionsDir = path.join(os.homedir(), 'Library', 'Application Support', 'Claude', 'Claude Extensions');
|
|
@@ -688,7 +688,7 @@ export class SelfImproveLoop {
|
|
|
688
688
|
.filter(f => f.rating === 'negative');
|
|
689
689
|
store.close();
|
|
690
690
|
// Gather cron errors from run logs
|
|
691
|
-
const { CronRunLog } = await import('../gateway/
|
|
691
|
+
const { CronRunLog } = await import('../gateway/cron-scheduler.js');
|
|
692
692
|
const runLog = new CronRunLog();
|
|
693
693
|
const cronErrors = [];
|
|
694
694
|
let cronTotal = 0;
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { EmbedBuilder } from 'discord.js';
|
|
16
16
|
import type { AgentProfile } from '../types.js';
|
|
17
17
|
import type { Gateway } from '../gateway/router.js';
|
|
18
|
-
import type { CronScheduler } from '../gateway/
|
|
18
|
+
import type { CronScheduler } from '../gateway/cron-scheduler.js';
|
|
19
19
|
export interface AgentBotConfig {
|
|
20
20
|
slug: string;
|
|
21
21
|
token: string;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* to all visible).
|
|
8
8
|
*/
|
|
9
9
|
import type { Gateway } from '../gateway/router.js';
|
|
10
|
-
import type { CronScheduler } from '../gateway/
|
|
10
|
+
import type { CronScheduler } from '../gateway/cron-scheduler.js';
|
|
11
11
|
import { type AgentBotStatus } from './discord-agent-bot.js';
|
|
12
12
|
export interface BotManagerConfig {
|
|
13
13
|
gateway: Gateway;
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* Features: streaming responses, message chunking, model switching,
|
|
6
6
|
* heartbeat/cron commands, slash commands, and autonomous notifications.
|
|
7
7
|
*/
|
|
8
|
-
import type { HeartbeatScheduler
|
|
8
|
+
import type { HeartbeatScheduler } from '../gateway/heartbeat-scheduler.js';
|
|
9
|
+
import type { CronScheduler } from '../gateway/cron-scheduler.js';
|
|
9
10
|
import type { NotificationDispatcher } from '../gateway/notifications.js';
|
|
10
11
|
import type { Gateway } from '../gateway/router.js';
|
|
11
12
|
export declare function startDiscord(gateway: Gateway, heartbeat: HeartbeatScheduler, cronScheduler: CronScheduler, dispatcher: NotificationDispatcher, botManager?: import('./discord-bot-manager.js').BotManager): Promise<void>;
|
package/dist/cli/cron.js
CHANGED
|
@@ -11,7 +11,8 @@ import os from 'node:os';
|
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import cron from 'node-cron';
|
|
13
13
|
import matter from 'gray-matter';
|
|
14
|
-
import {
|
|
14
|
+
import { HeartbeatScheduler } from '../gateway/heartbeat-scheduler.js';
|
|
15
|
+
import { parseCronJobs, CronRunLog, classifyError } from '../gateway/cron-scheduler.js';
|
|
15
16
|
const BASE_DIR = process.env.CLEMENTINE_HOME || path.join(os.homedir(), '.clementine');
|
|
16
17
|
const LAST_RUN_FILE = path.join(BASE_DIR, '.cron_last_run.json');
|
|
17
18
|
/** Exponential backoff schedule in ms: 30s, 1m, 5m, 15m, 60m */
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -1485,17 +1485,6 @@ async function getBuildUsageForOperations(hoursInput = 168, limitInput = 50) {
|
|
|
1485
1485
|
db.close();
|
|
1486
1486
|
}
|
|
1487
1487
|
}
|
|
1488
|
-
function getTimers() {
|
|
1489
|
-
const timersFile = path.join(BASE_DIR, '.timers.json');
|
|
1490
|
-
if (!existsSync(timersFile))
|
|
1491
|
-
return [];
|
|
1492
|
-
try {
|
|
1493
|
-
return JSON.parse(readFileSync(timersFile, 'utf-8'));
|
|
1494
|
-
}
|
|
1495
|
-
catch {
|
|
1496
|
-
return [];
|
|
1497
|
-
}
|
|
1498
|
-
}
|
|
1499
1488
|
function getHeartbeat() {
|
|
1500
1489
|
const hbFile = path.join(BASE_DIR, '.heartbeat_state.json');
|
|
1501
1490
|
if (!existsSync(hbFile))
|
|
@@ -2128,8 +2117,6 @@ export async function cmdDashboard(opts) {
|
|
|
2128
2117
|
}
|
|
2129
2118
|
persistSessions();
|
|
2130
2119
|
}, 10 * 60 * 1000);
|
|
2131
|
-
// Quick ping — bypasses all middleware, tests /api path routing
|
|
2132
|
-
app.get('/api/ping', (_req, res) => { res.json({ pong: true }); });
|
|
2133
2120
|
// ── Background project scanner ───────────────────────────────────────
|
|
2134
2121
|
// scanProjects() does heavy synchronous filesystem I/O (statSync across
|
|
2135
2122
|
// hundreds of Desktop/Documents entries) which permanently blocks the
|
|
@@ -2661,9 +2648,6 @@ export async function cmdDashboard(opts) {
|
|
|
2661
2648
|
app.get('/api/cron', (_req, res) => {
|
|
2662
2649
|
res.json(getCronJobs());
|
|
2663
2650
|
});
|
|
2664
|
-
app.get('/api/timers', (_req, res) => {
|
|
2665
|
-
res.json(getTimers());
|
|
2666
|
-
});
|
|
2667
2651
|
app.get('/api/heartbeat', (_req, res) => {
|
|
2668
2652
|
res.json(getHeartbeat());
|
|
2669
2653
|
});
|
|
@@ -2846,23 +2830,6 @@ export async function cmdDashboard(opts) {
|
|
|
2846
2830
|
res.status(500).json({ error: String(err).slice(0, 200) });
|
|
2847
2831
|
}
|
|
2848
2832
|
});
|
|
2849
|
-
app.get('/api/autonomy', async (req, res) => {
|
|
2850
|
-
try {
|
|
2851
|
-
const { getStore } = await import('../tools/shared.js');
|
|
2852
|
-
const store = await getStore();
|
|
2853
|
-
const logs = store.queryAutonomyLog({
|
|
2854
|
-
component: typeof req.query.component === 'string' ? req.query.component : undefined,
|
|
2855
|
-
event: typeof req.query.event === 'string' ? req.query.event : undefined,
|
|
2856
|
-
agentSlug: typeof req.query.agentSlug === 'string' ? req.query.agentSlug : undefined,
|
|
2857
|
-
limit: typeof req.query.limit === 'string' ? parseInt(req.query.limit, 10) : 100,
|
|
2858
|
-
since: typeof req.query.since === 'string' ? req.query.since : undefined,
|
|
2859
|
-
});
|
|
2860
|
-
res.json({ logs });
|
|
2861
|
-
}
|
|
2862
|
-
catch (err) {
|
|
2863
|
-
res.status(500).json({ error: String(err).slice(0, 200) });
|
|
2864
|
-
}
|
|
2865
|
-
});
|
|
2866
2833
|
app.get('/api/heartbeat/agent/:slug', (req, res) => {
|
|
2867
2834
|
const slug = req.params.slug;
|
|
2868
2835
|
const state = getHeartbeat();
|
|
@@ -6964,28 +6931,6 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
|
|
|
6964
6931
|
res.status(500).json({ error: String(err) });
|
|
6965
6932
|
}
|
|
6966
6933
|
});
|
|
6967
|
-
app.post('/api/timers/:id/cancel', (req, res) => {
|
|
6968
|
-
const timerId = req.params.id;
|
|
6969
|
-
const timersFile = path.join(BASE_DIR, '.timers.json');
|
|
6970
|
-
try {
|
|
6971
|
-
if (!existsSync(timersFile)) {
|
|
6972
|
-
res.status(404).json({ error: 'No timers file' });
|
|
6973
|
-
return;
|
|
6974
|
-
}
|
|
6975
|
-
const timers = JSON.parse(readFileSync(timersFile, 'utf-8'));
|
|
6976
|
-
const idx = timers.findIndex((t) => String(t.id) === timerId);
|
|
6977
|
-
if (idx === -1) {
|
|
6978
|
-
res.status(404).json({ error: `Timer "${timerId}" not found` });
|
|
6979
|
-
return;
|
|
6980
|
-
}
|
|
6981
|
-
timers.splice(idx, 1);
|
|
6982
|
-
writeFileSync(timersFile, JSON.stringify(timers, null, 2));
|
|
6983
|
-
res.json({ ok: true, message: `Cancelled timer: ${timerId}` });
|
|
6984
|
-
}
|
|
6985
|
-
catch (err) {
|
|
6986
|
-
res.status(500).json({ error: String(err) });
|
|
6987
|
-
}
|
|
6988
|
-
});
|
|
6989
6934
|
// ── Projects ─────────────────────────────────────────────────
|
|
6990
6935
|
// Returns the project list from $CLEMENTINE_HOME/projects.json.
|
|
6991
6936
|
// Used by the trick builder's Quick Add Step picker so users can
|
|
@@ -12854,26 +12799,6 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
|
|
|
12854
12799
|
});
|
|
12855
12800
|
// ── Advisor Decision Analytics API ─────────────────────────────
|
|
12856
12801
|
const ADVISOR_LOG = path.join(BASE_DIR, 'cron', 'advisor-decisions.jsonl');
|
|
12857
|
-
app.get('/api/advisor/decisions', (req, res) => {
|
|
12858
|
-
const limit = parseInt(String(req.query.limit ?? '100'), 10);
|
|
12859
|
-
if (!existsSync(ADVISOR_LOG)) {
|
|
12860
|
-
res.json({ decisions: [] });
|
|
12861
|
-
return;
|
|
12862
|
-
}
|
|
12863
|
-
try {
|
|
12864
|
-
const lines = readFileSync(ADVISOR_LOG, 'utf-8').trim().split('\n').filter(Boolean);
|
|
12865
|
-
const decisions = lines.slice(-limit).map(l => { try {
|
|
12866
|
-
return JSON.parse(l);
|
|
12867
|
-
}
|
|
12868
|
-
catch {
|
|
12869
|
-
return null;
|
|
12870
|
-
} }).filter(Boolean).reverse();
|
|
12871
|
-
res.json({ decisions });
|
|
12872
|
-
}
|
|
12873
|
-
catch {
|
|
12874
|
-
res.json({ decisions: [] });
|
|
12875
|
-
}
|
|
12876
|
-
});
|
|
12877
12802
|
app.get('/api/advisor/analytics', (_req, res) => {
|
|
12878
12803
|
// Build analytics from run logs + advisor decisions
|
|
12879
12804
|
const runsDir = path.join(BASE_DIR, 'cron', 'runs');
|
package/dist/cli/index.js
CHANGED
|
@@ -1417,155 +1417,7 @@ async function cmdConfigDoctor(opts) {
|
|
|
1417
1417
|
console.log();
|
|
1418
1418
|
process.exit(report.exitCode);
|
|
1419
1419
|
}
|
|
1420
|
-
// ── Config
|
|
1421
|
-
async function cmdConfigMigrateToKeychain(opts) {
|
|
1422
|
-
const { planMigration, applyMigration } = await import('../config/migrate-keychain.js');
|
|
1423
|
-
const DIM = '\x1b[0;90m';
|
|
1424
|
-
const BOLD = '\x1b[1m';
|
|
1425
|
-
const GREEN = '\x1b[0;32m';
|
|
1426
|
-
const YELLOW = '\x1b[0;33m';
|
|
1427
|
-
const RED = '\x1b[0;31m';
|
|
1428
|
-
const CYAN = '\x1b[0;36m';
|
|
1429
|
-
const RESET = '\x1b[0m';
|
|
1430
|
-
// Commander gives us either ['a', 'b'] or ['a,b'] depending on how the
|
|
1431
|
-
// user passed the flag — normalize.
|
|
1432
|
-
const only = opts.key
|
|
1433
|
-
? opts.key.flatMap(k => k.split(',').map(s => s.trim()).filter(Boolean))
|
|
1434
|
-
: undefined;
|
|
1435
|
-
const plan = planMigration(BASE_DIR, only ? { only } : {});
|
|
1436
|
-
console.log();
|
|
1437
|
-
console.log(` ${BOLD}.env path:${RESET} ${plan.envPath}`);
|
|
1438
|
-
console.log();
|
|
1439
|
-
if (plan.candidates.length === 0) {
|
|
1440
|
-
console.log(` ${DIM}No env entries found (.env may be empty or missing).${RESET}`);
|
|
1441
|
-
console.log();
|
|
1442
|
-
return;
|
|
1443
|
-
}
|
|
1444
|
-
// Group by status for readable output
|
|
1445
|
-
const groups = {};
|
|
1446
|
-
for (const c of plan.candidates) {
|
|
1447
|
-
(groups[c.status] ??= []).push(c);
|
|
1448
|
-
}
|
|
1449
|
-
const renderGroup = (label, color, items) => {
|
|
1450
|
-
if (!items || items.length === 0)
|
|
1451
|
-
return;
|
|
1452
|
-
console.log(` ${color}${label}${RESET} ${DIM}(${items.length})${RESET}`);
|
|
1453
|
-
for (const c of items) {
|
|
1454
|
-
console.log(` ${c.key} ${DIM}(${c.valueLength} chars)${RESET}`);
|
|
1455
|
-
}
|
|
1456
|
-
console.log();
|
|
1457
|
-
};
|
|
1458
|
-
renderGroup('Will migrate to keychain', CYAN, groups.migrated);
|
|
1459
|
-
renderGroup('Already in keychain (skipped)', DIM, groups['already-keychain']);
|
|
1460
|
-
renderGroup('Not credential-shaped (skipped)', DIM, groups['not-sensitive']);
|
|
1461
|
-
renderGroup('Too short to be a credential (skipped)', DIM, groups['too-short']);
|
|
1462
|
-
if (plan.toMigrate.length === 0) {
|
|
1463
|
-
console.log(` ${GREEN}Nothing to migrate.${RESET}`);
|
|
1464
|
-
console.log();
|
|
1465
|
-
return;
|
|
1466
|
-
}
|
|
1467
|
-
if (opts.dryRun) {
|
|
1468
|
-
console.log(` ${YELLOW}Dry run — no changes written.${RESET}`);
|
|
1469
|
-
console.log(` ${DIM}Re-run without --dry-run to apply.${RESET}`);
|
|
1470
|
-
console.log();
|
|
1471
|
-
return;
|
|
1472
|
-
}
|
|
1473
|
-
console.log(` ${BOLD}Applying...${RESET}`);
|
|
1474
|
-
let result;
|
|
1475
|
-
try {
|
|
1476
|
-
result = applyMigration(BASE_DIR, only ? { only } : {});
|
|
1477
|
-
}
|
|
1478
|
-
catch (err) {
|
|
1479
|
-
console.error(` ${RED}Failed:${RESET} ${err.message}`);
|
|
1480
|
-
process.exit(1);
|
|
1481
|
-
}
|
|
1482
|
-
if (result.failed.length > 0) {
|
|
1483
|
-
console.log(` ${RED}Some keychain writes failed — .env was NOT modified:${RESET}`);
|
|
1484
|
-
for (const f of result.failed) {
|
|
1485
|
-
console.log(` ${RED}✗${RESET} ${f.key}: ${f.error}`);
|
|
1486
|
-
}
|
|
1487
|
-
console.log();
|
|
1488
|
-
process.exit(1);
|
|
1489
|
-
}
|
|
1490
|
-
for (const key of result.migrated) {
|
|
1491
|
-
console.log(` ${GREEN}✓${RESET} ${key} ${DIM}→ keychain${RESET}`);
|
|
1492
|
-
}
|
|
1493
|
-
console.log();
|
|
1494
|
-
console.log(` ${GREEN}Migrated ${result.migrated.length} key${result.migrated.length === 1 ? '' : 's'}.${RESET}`);
|
|
1495
|
-
console.log(` ${DIM}First daemon read of each ref will trigger a one-time keychain prompt;${RESET}`);
|
|
1496
|
-
console.log(` ${DIM}choose Always Allow to make the prompt permanent.${RESET}`);
|
|
1497
|
-
console.log();
|
|
1498
|
-
}
|
|
1499
|
-
// ── Config migrate-from-keychain (inverse of migrate-to-keychain) ───
|
|
1500
|
-
async function cmdConfigMigrateFromKeychain(opts) {
|
|
1501
|
-
const { planReverseMigration, applyReverseMigration } = await import('../config/migrate-from-keychain.js');
|
|
1502
|
-
const DIM = '\x1b[0;90m';
|
|
1503
|
-
const BOLD = '\x1b[1m';
|
|
1504
|
-
const GREEN = '\x1b[0;32m';
|
|
1505
|
-
const YELLOW = '\x1b[0;33m';
|
|
1506
|
-
const RED = '\x1b[0;31m';
|
|
1507
|
-
const CYAN = '\x1b[0;36m';
|
|
1508
|
-
const RESET = '\x1b[0m';
|
|
1509
|
-
const only = opts.key
|
|
1510
|
-
? opts.key.flatMap(k => k.split(',').map(s => s.trim()).filter(Boolean))
|
|
1511
|
-
: undefined;
|
|
1512
|
-
const plan = planReverseMigration(BASE_DIR, only ? { only } : {});
|
|
1513
|
-
console.log();
|
|
1514
|
-
console.log(` ${BOLD}.env path:${RESET} ${plan.envPath}`);
|
|
1515
|
-
console.log();
|
|
1516
|
-
const groups = {};
|
|
1517
|
-
for (const c of plan.candidates)
|
|
1518
|
-
(groups[c.status] ??= []).push(c);
|
|
1519
|
-
const renderGroup = (label, color, items, showRef = false) => {
|
|
1520
|
-
if (!items || items.length === 0)
|
|
1521
|
-
return;
|
|
1522
|
-
console.log(` ${color}${label}${RESET} ${DIM}(${items.length})${RESET}`);
|
|
1523
|
-
for (const c of items) {
|
|
1524
|
-
const refTag = showRef && c.ref ? ` ${DIM}${c.ref}${RESET}` : '';
|
|
1525
|
-
console.log(` ${c.key}${refTag}`);
|
|
1526
|
-
}
|
|
1527
|
-
console.log();
|
|
1528
|
-
};
|
|
1529
|
-
renderGroup('Will migrate from keychain → .env', CYAN, groups.migrated);
|
|
1530
|
-
renderGroup('Sensitive — left in keychain (correct)', DIM, groups['sensitive-skipped']);
|
|
1531
|
-
renderGroup('Unresolvable refs (keychain entry missing)', RED, groups.unresolvable, true);
|
|
1532
|
-
if (plan.toMigrate.length === 0) {
|
|
1533
|
-
console.log(` ${GREEN}Nothing to migrate.${RESET}`);
|
|
1534
|
-
if (plan.unresolvable.length > 0) {
|
|
1535
|
-
console.log(` ${YELLOW}${plan.unresolvable.length} unresolvable ref(s) above — fix or delete by hand.${RESET}`);
|
|
1536
|
-
}
|
|
1537
|
-
console.log();
|
|
1538
|
-
return;
|
|
1539
|
-
}
|
|
1540
|
-
if (opts.dryRun) {
|
|
1541
|
-
console.log(` ${YELLOW}Dry run — no changes written.${RESET}`);
|
|
1542
|
-
console.log();
|
|
1543
|
-
return;
|
|
1544
|
-
}
|
|
1545
|
-
console.log(` ${BOLD}Applying...${RESET}`);
|
|
1546
|
-
let result;
|
|
1547
|
-
try {
|
|
1548
|
-
result = applyReverseMigration(BASE_DIR, only ? { only } : {});
|
|
1549
|
-
}
|
|
1550
|
-
catch (err) {
|
|
1551
|
-
console.error(` ${RED}Failed:${RESET} ${err.message}`);
|
|
1552
|
-
process.exit(1);
|
|
1553
|
-
}
|
|
1554
|
-
if (result.failed.length > 0) {
|
|
1555
|
-
console.log(` ${RED}Some keychain reads failed — .env was NOT modified:${RESET}`);
|
|
1556
|
-
for (const f of result.failed)
|
|
1557
|
-
console.log(` ${RED}✗${RESET} ${f.key}: ${f.error}`);
|
|
1558
|
-
console.log();
|
|
1559
|
-
process.exit(1);
|
|
1560
|
-
}
|
|
1561
|
-
for (const key of result.migrated) {
|
|
1562
|
-
console.log(` ${GREEN}✓${RESET} ${key} ${DIM}→ .env (keychain entry deleted)${RESET}`);
|
|
1563
|
-
}
|
|
1564
|
-
console.log();
|
|
1565
|
-
console.log(` ${GREEN}Migrated ${result.migrated.length} key${result.migrated.length === 1 ? '' : 's'} out of keychain.${RESET}`);
|
|
1566
|
-
console.log();
|
|
1567
|
-
}
|
|
1568
|
-
// ── Config harden-permissions ────────────────────────────────────────
|
|
1420
|
+
// ── Config harden-permissions ───────────────────────────────────────
|
|
1569
1421
|
async function cmdConfigHardenPermissions(opts) {
|
|
1570
1422
|
const { hardenPermissions } = await import('../config/harden-permissions.js');
|
|
1571
1423
|
const report = hardenPermissions(BASE_DIR, { dryRun: opts.dryRun });
|
|
@@ -1624,84 +1476,6 @@ async function cmdConfigHardenPermissions(opts) {
|
|
|
1624
1476
|
}
|
|
1625
1477
|
process.exit(report.failed > 0 ? 1 : 0);
|
|
1626
1478
|
}
|
|
1627
|
-
// ── Config keychain-fix-acl ─────────────────────────────────────────
|
|
1628
|
-
async function cmdConfigKeychainFixAcl(opts) {
|
|
1629
|
-
const { listClementineKeychainEntries, fixAllClementineEntries } = await import('../config/keychain-fix-acl.js');
|
|
1630
|
-
const { markKeychainWizardDone, readPasswordFromTty } = await import('../config/keychain-first-run-wizard.js');
|
|
1631
|
-
const DIM = '\x1b[0;90m';
|
|
1632
|
-
const BOLD = '\x1b[1m';
|
|
1633
|
-
const GREEN = '\x1b[0;32m';
|
|
1634
|
-
const YELLOW = '\x1b[0;33m';
|
|
1635
|
-
const RED = '\x1b[0;31m';
|
|
1636
|
-
const RESET = '\x1b[0m';
|
|
1637
|
-
const entries = listClementineKeychainEntries();
|
|
1638
|
-
console.log();
|
|
1639
|
-
console.log(` ${BOLD}Found ${entries.length} clementine-agent keychain entr${entries.length === 1 ? 'y' : 'ies'}.${RESET}`);
|
|
1640
|
-
for (const e of entries)
|
|
1641
|
-
console.log(` ${DIM}${e.account}${RESET}`);
|
|
1642
|
-
console.log();
|
|
1643
|
-
if (entries.length === 0) {
|
|
1644
|
-
console.log(` ${GREEN}Nothing to fix.${RESET}`);
|
|
1645
|
-
console.log();
|
|
1646
|
-
// No entries means the launch wizard has nothing to do either.
|
|
1647
|
-
markKeychainWizardDone(BASE_DIR);
|
|
1648
|
-
return;
|
|
1649
|
-
}
|
|
1650
|
-
if (opts.list) {
|
|
1651
|
-
console.log(` ${DIM}--list mode — no changes made. Drop the flag to apply.${RESET}`);
|
|
1652
|
-
console.log();
|
|
1653
|
-
return;
|
|
1654
|
-
}
|
|
1655
|
-
console.log(` ${DIM}Enter your macOS login password ONCE — it authorizes${RESET}`);
|
|
1656
|
-
console.log(` ${DIM}all ${entries.length} ACL update${entries.length === 1 ? '' : 's'} in a single pass. Not stored.${RESET}`);
|
|
1657
|
-
console.log();
|
|
1658
|
-
const password = await readPasswordFromTty(` ${BOLD}macOS login password:${RESET} `);
|
|
1659
|
-
if (!password) {
|
|
1660
|
-
console.log();
|
|
1661
|
-
console.log(` ${YELLOW}Empty password — aborted.${RESET}`);
|
|
1662
|
-
console.log();
|
|
1663
|
-
return;
|
|
1664
|
-
}
|
|
1665
|
-
console.log();
|
|
1666
|
-
console.log(` ${BOLD}Fixing ACLs...${RESET}`);
|
|
1667
|
-
console.log();
|
|
1668
|
-
const results = fixAllClementineEntries({ keychainPassword: password });
|
|
1669
|
-
let okCount = 0;
|
|
1670
|
-
let failCount = 0;
|
|
1671
|
-
let wrongPasswordHit = false;
|
|
1672
|
-
for (const r of results) {
|
|
1673
|
-
if (r.status === 'fixed') {
|
|
1674
|
-
console.log(` ${GREEN}✓${RESET} ${r.account}`);
|
|
1675
|
-
okCount++;
|
|
1676
|
-
}
|
|
1677
|
-
else if (r.status === 'failed') {
|
|
1678
|
-
console.log(` ${RED}✗${RESET} ${r.account} ${DIM}— ${r.error}${RESET}`);
|
|
1679
|
-
failCount++;
|
|
1680
|
-
if (r.error && /MAC verification|AuthFailure|UserCanceled|-25293/i.test(r.error)) {
|
|
1681
|
-
wrongPasswordHit = true;
|
|
1682
|
-
}
|
|
1683
|
-
}
|
|
1684
|
-
}
|
|
1685
|
-
console.log();
|
|
1686
|
-
if (failCount === 0) {
|
|
1687
|
-
console.log(` ${GREEN}All ${okCount} entries fixed.${RESET} ${DIM}Future reads via the security CLI succeed silently.${RESET}`);
|
|
1688
|
-
}
|
|
1689
|
-
else if (wrongPasswordHit && okCount === 0) {
|
|
1690
|
-
console.log(` ${RED}Wrong password — no entries repaired.${RESET}`);
|
|
1691
|
-
console.log(` ${DIM}Re-run: clementine config keychain-fix-acl${RESET}`);
|
|
1692
|
-
}
|
|
1693
|
-
else {
|
|
1694
|
-
console.log(` ${YELLOW}${okCount} fixed, ${failCount} failed.${RESET}`);
|
|
1695
|
-
console.log(` ${DIM}Failed entries can be fixed manually in Keychain Access.app:${RESET}`);
|
|
1696
|
-
console.log(` ${DIM} search "clementine-agent" → double-click → Access Control → Allow all applications.${RESET}`);
|
|
1697
|
-
}
|
|
1698
|
-
// Mark the launch wizard satisfied unless the password was clearly wrong —
|
|
1699
|
-
// user has explicitly decided to deal with this via the manual command.
|
|
1700
|
-
if (!(wrongPasswordHit && okCount === 0)) {
|
|
1701
|
-
markKeychainWizardDone(BASE_DIR);
|
|
1702
|
-
}
|
|
1703
|
-
console.log();
|
|
1704
|
-
}
|
|
1705
1479
|
// ── Analytics ────────────────────────────────────────────────────────
|
|
1706
1480
|
async function cmdAnalyticsToolUsage(opts) {
|
|
1707
1481
|
const { buildToolUsageReport, defaultAuditLogPath } = await import('../analytics/tool-usage.js');
|
|
@@ -2634,29 +2408,6 @@ configCmd
|
|
|
2634
2408
|
.action(async (opts) => {
|
|
2635
2409
|
await cmdConfigDoctor(opts);
|
|
2636
2410
|
});
|
|
2637
|
-
configCmd
|
|
2638
|
-
.command('migrate-to-keychain')
|
|
2639
|
-
.description('Move plaintext credentials in .env into the macOS keychain (NOT recommended in v1.1.4+ — keychain entries can produce per-process approval prompts; plain .env at mode 0600 is the supported default)')
|
|
2640
|
-
.option('--dry-run', 'Show what would migrate without writing anything')
|
|
2641
|
-
.option('-k, --key <name...>', 'Limit to specific key(s); repeat or comma-separate for multiple')
|
|
2642
|
-
.action(async (opts) => {
|
|
2643
|
-
await cmdConfigMigrateToKeychain(opts);
|
|
2644
|
-
});
|
|
2645
|
-
configCmd
|
|
2646
|
-
.command('migrate-from-keychain')
|
|
2647
|
-
.description('Pull non-credential values OUT of keychain back to plaintext .env (only API keys belong in keychain)')
|
|
2648
|
-
.option('--dry-run', 'Show what would migrate without writing anything')
|
|
2649
|
-
.option('-k, --key <name...>', 'Limit to specific key(s); repeat or comma-separate for multiple')
|
|
2650
|
-
.action(async (opts) => {
|
|
2651
|
-
await cmdConfigMigrateFromKeychain(opts);
|
|
2652
|
-
});
|
|
2653
|
-
configCmd
|
|
2654
|
-
.command('keychain-fix-acl')
|
|
2655
|
-
.description('One-shot fix for clementine-agent keychain entries that prompt on every read (one master prompt then no more)')
|
|
2656
|
-
.option('--list', 'List entries without changing anything')
|
|
2657
|
-
.action(async (opts) => {
|
|
2658
|
-
await cmdConfigKeychainFixAcl(opts);
|
|
2659
|
-
});
|
|
2660
2411
|
configCmd
|
|
2661
2412
|
.command('harden-permissions')
|
|
2662
2413
|
.description('Tighten file modes in ~/.clementine/ — files to 0600, directories to 0700')
|
package/dist/index.js
CHANGED
|
@@ -717,7 +717,8 @@ async function asyncMain() {
|
|
|
717
717
|
}
|
|
718
718
|
})();
|
|
719
719
|
// Heartbeat + Cron schedulers
|
|
720
|
-
const { HeartbeatScheduler
|
|
720
|
+
const { HeartbeatScheduler } = await import('./gateway/heartbeat-scheduler.js');
|
|
721
|
+
const { CronScheduler } = await import('./gateway/cron-scheduler.js');
|
|
721
722
|
const heartbeat = new HeartbeatScheduler(gateway, dispatcher);
|
|
722
723
|
const cronScheduler = new CronScheduler(gateway, dispatcher);
|
|
723
724
|
heartbeat.setCronScheduler(cronScheduler);
|
|
@@ -1095,7 +1095,7 @@ export function registerAdminTools(server) {
|
|
|
1095
1095
|
parsed.data.jobs = jobs;
|
|
1096
1096
|
// Write back preserving body content — validate first to prevent daemon crash
|
|
1097
1097
|
const output = matterMod.default.stringify(parsed.content, parsed.data);
|
|
1098
|
-
const { validateCronYaml } = await import('../gateway/
|
|
1098
|
+
const { validateCronYaml } = await import('../gateway/cron-scheduler.js');
|
|
1099
1099
|
const yamlErr = validateCronYaml(output);
|
|
1100
1100
|
if (yamlErr) {
|
|
1101
1101
|
logger.error({ yamlErr, jobName }, 'Generated CRON.md has invalid YAML — aborting write');
|
|
@@ -1268,7 +1268,7 @@ export function registerAdminTools(server) {
|
|
|
1268
1268
|
}
|
|
1269
1269
|
parsed.data.jobs = jobs;
|
|
1270
1270
|
const output = matterMod.default.stringify(parsed.content, parsed.data);
|
|
1271
|
-
const { validateCronYaml } = await import('../gateway/
|
|
1271
|
+
const { validateCronYaml } = await import('../gateway/cron-scheduler.js');
|
|
1272
1272
|
const yamlErr = validateCronYaml(output);
|
|
1273
1273
|
if (yamlErr) {
|
|
1274
1274
|
logger.error({ yamlErr, jobName }, 'Generated CRON.md has invalid YAML — aborting write');
|
|
@@ -1976,14 +1976,6 @@ export function registerAdminTools(server) {
|
|
|
1976
1976
|
logger.info({ jobName: job_name, runCount: updated.runCount }, 'Cron progress saved');
|
|
1977
1977
|
return textResult(`Progress saved for "${job_name}" (run #${updated.runCount}). ${(completedItems?.length ?? 0)} items completed, ${(updated.pendingItems?.length ?? 0)} pending.`);
|
|
1978
1978
|
});
|
|
1979
|
-
// ── Browser harness — chat-driven Chrome connect ────────────────────
|
|
1980
|
-
server.tool('browser_connect', 'Connect Chrome to the browser harness via CDP. Idempotent — if Chrome is already running with remote debugging on :9222 this is a no-op. If no Chrome is running, launches Chrome with --remote-debugging-port=9222. If Chrome is running normally without the flag, refuses unless force_quit=true (which closes the user\'s open tabs). Use this so the user can connect from any chat channel without dropping to the terminal.', {
|
|
1981
|
-
force_quit: z.boolean().optional().describe('If true, quit any running Chrome before relaunching with the debug flag. DESTRUCTIVE — closes the user\'s open tabs. Only set after the user has explicitly confirmed they want this. Defaults to false.'),
|
|
1982
|
-
}, async ({ force_quit }) => {
|
|
1983
|
-
const { runConnectNonInteractive } = await import('../cli/browser.js');
|
|
1984
|
-
const result = await runConnectNonInteractive({ allowQuitChrome: !!force_quit });
|
|
1985
|
-
return textResult(result.message);
|
|
1986
|
-
});
|
|
1987
1979
|
// ── Broken-job diagnosis + fix-application (chat-equivalent of dashboard buttons) ──
|
|
1988
1980
|
//
|
|
1989
1981
|
// Before this, when the user asked "fix audit-inbox-check" in chat,
|
package/package.json
CHANGED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Post-turn contradiction validator.
|
|
3
|
-
*
|
|
4
|
-
* After a chat turn's SDK stream completes, compares the assistant's outgoing
|
|
5
|
-
* reply against the actual tool_use/tool_result pairs from that turn. If a
|
|
6
|
-
* claude_ai_* connector succeeded (or returned an argument error — a fixable
|
|
7
|
-
* per-call failure) but the reply claims the connector is broken, missing from
|
|
8
|
-
* the schema, or otherwise generalizes a single failure into connector-level
|
|
9
|
-
* "deadness," we flag it.
|
|
10
|
-
*
|
|
11
|
-
* This is deterministic: it does NOT rely on the model obeying prompt rules.
|
|
12
|
-
* It's the load-bearing guardrail that replaces the forbidden-phrase list we
|
|
13
|
-
* used to patch into the system prompt.
|
|
14
|
-
*/
|
|
15
|
-
export type ToolResultClass = 'success' | 'arg_error' | 'auth_error' | 'other_error';
|
|
16
|
-
export interface ToolCallRecord {
|
|
17
|
-
/** Tool name, e.g. mcp__claude_ai_Google_Drive__search_files */
|
|
18
|
-
name: string;
|
|
19
|
-
/** tool_use_id from the assistant's request */
|
|
20
|
-
id: string;
|
|
21
|
-
/** Classification of the paired tool_result */
|
|
22
|
-
resultClass: ToolResultClass;
|
|
23
|
-
/** First ~200 chars of the literal result content (or error text) */
|
|
24
|
-
resultPreview: string;
|
|
25
|
-
}
|
|
26
|
-
/**
|
|
27
|
-
* Regex matching reply phrasings that claim a connector-wide failure.
|
|
28
|
-
*
|
|
29
|
-
* Shrunk in 1.0.66 after the root-cause fix (env: SAFE_ENV was stripping
|
|
30
|
-
* claude.ai connector bootstrap in the daemon, landed in 1.0.65). That
|
|
31
|
-
* removed the upstream need for ~15 defensive phrasings. We keep three
|
|
32
|
-
* core patterns as a cheap safety net — anything else means the model
|
|
33
|
-
* invented a new way to confabulate, which we'd rather see raw in the
|
|
34
|
-
* audit log than silently paper over.
|
|
35
|
-
*/
|
|
36
|
-
export declare const CONTRADICTION_RE: RegExp;
|
|
37
|
-
export declare function classifyResult(content: string, isError: boolean): ToolResultClass;
|
|
38
|
-
/**
|
|
39
|
-
* Walk collected SDK messages (assistant + user) and pair every tool_use with
|
|
40
|
-
* its tool_result. Returns one record per tool_use; unpaired ones (still
|
|
41
|
-
* running at end of stream) are skipped.
|
|
42
|
-
*/
|
|
43
|
-
export declare function collectToolCalls(messages: Array<{
|
|
44
|
-
type: string;
|
|
45
|
-
message?: any;
|
|
46
|
-
}>): ToolCallRecord[];
|
|
47
|
-
export interface ContradictionFinding {
|
|
48
|
-
/** The tool call whose result contradicts the reply */
|
|
49
|
-
tool: ToolCallRecord;
|
|
50
|
-
/** The exact phrase from the reply that triggered detection */
|
|
51
|
-
matchedPhrase: string;
|
|
52
|
-
}
|
|
53
|
-
/**
|
|
54
|
-
* Check a reply against a set of tool-call records. Returns the first
|
|
55
|
-
* contradiction found, or null if the reply is consistent with tool results.
|
|
56
|
-
*
|
|
57
|
-
* Contradiction = reply contains a CONTRADICTION_RE phrase AND at least one
|
|
58
|
-
* mcp__claude_ai_* tool in this turn classified `success` or `arg_error`.
|
|
59
|
-
* `auth_error` and `other_error` are legitimate failures that can support
|
|
60
|
-
* those reply phrasings.
|
|
61
|
-
*/
|
|
62
|
-
export declare function detectContradiction(reply: string, calls: ToolCallRecord[]): ContradictionFinding | null;
|
|
63
|
-
/**
|
|
64
|
-
* Build the system-follow-up message we inject when a contradiction fires.
|
|
65
|
-
* The SDK will run one more turn with this as a user-role message (using
|
|
66
|
-
* `canUseTool` or similar hook), and the model's next reply replaces the
|
|
67
|
-
* bad one.
|
|
68
|
-
*/
|
|
69
|
-
export declare function buildCorrectionPrompt(finding: ContradictionFinding): string;
|
|
70
|
-
//# sourceMappingURL=contradiction-validator.d.ts.map
|
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Post-turn contradiction validator.
|
|
3
|
-
*
|
|
4
|
-
* After a chat turn's SDK stream completes, compares the assistant's outgoing
|
|
5
|
-
* reply against the actual tool_use/tool_result pairs from that turn. If a
|
|
6
|
-
* claude_ai_* connector succeeded (or returned an argument error — a fixable
|
|
7
|
-
* per-call failure) but the reply claims the connector is broken, missing from
|
|
8
|
-
* the schema, or otherwise generalizes a single failure into connector-level
|
|
9
|
-
* "deadness," we flag it.
|
|
10
|
-
*
|
|
11
|
-
* This is deterministic: it does NOT rely on the model obeying prompt rules.
|
|
12
|
-
* It's the load-bearing guardrail that replaces the forbidden-phrase list we
|
|
13
|
-
* used to patch into the system prompt.
|
|
14
|
-
*/
|
|
15
|
-
const ARG_ERROR_RE = /\b(invalid|unknown field|required|missing parameter|schema|unrecognized|unexpected property)\b/i;
|
|
16
|
-
const AUTH_ERROR_RE = /\b(unauthori[sz]ed|401|not authenticated|token expired|token has expired|invalid[_ ]?token|access denied)\b/i;
|
|
17
|
-
/**
|
|
18
|
-
* Regex matching reply phrasings that claim a connector-wide failure.
|
|
19
|
-
*
|
|
20
|
-
* Shrunk in 1.0.66 after the root-cause fix (env: SAFE_ENV was stripping
|
|
21
|
-
* claude.ai connector bootstrap in the daemon, landed in 1.0.65). That
|
|
22
|
-
* removed the upstream need for ~15 defensive phrasings. We keep three
|
|
23
|
-
* core patterns as a cheap safety net — anything else means the model
|
|
24
|
-
* invented a new way to confabulate, which we'd rather see raw in the
|
|
25
|
-
* audit log than silently paper over.
|
|
26
|
-
*/
|
|
27
|
-
export const CONTRADICTION_RE = /(dead\s*end|not in (the |my )?schema|no such tool available)/i;
|
|
28
|
-
export function classifyResult(content, isError) {
|
|
29
|
-
if (!isError)
|
|
30
|
-
return 'success';
|
|
31
|
-
if (ARG_ERROR_RE.test(content))
|
|
32
|
-
return 'arg_error';
|
|
33
|
-
if (AUTH_ERROR_RE.test(content))
|
|
34
|
-
return 'auth_error';
|
|
35
|
-
return 'other_error';
|
|
36
|
-
}
|
|
37
|
-
/** Extract string content from a tool_result block (which can be string or array of content blocks). */
|
|
38
|
-
function stringifyResultContent(content) {
|
|
39
|
-
if (typeof content === 'string')
|
|
40
|
-
return content;
|
|
41
|
-
if (Array.isArray(content)) {
|
|
42
|
-
return content
|
|
43
|
-
.map((b) => (typeof b === 'string' ? b : (b?.text ?? b?.content ?? JSON.stringify(b))))
|
|
44
|
-
.join('\n');
|
|
45
|
-
}
|
|
46
|
-
if (content == null)
|
|
47
|
-
return '';
|
|
48
|
-
try {
|
|
49
|
-
return JSON.stringify(content);
|
|
50
|
-
}
|
|
51
|
-
catch {
|
|
52
|
-
return String(content);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
/**
|
|
56
|
-
* Walk collected SDK messages (assistant + user) and pair every tool_use with
|
|
57
|
-
* its tool_result. Returns one record per tool_use; unpaired ones (still
|
|
58
|
-
* running at end of stream) are skipped.
|
|
59
|
-
*/
|
|
60
|
-
export function collectToolCalls(messages) {
|
|
61
|
-
const toolUses = new Map();
|
|
62
|
-
const results = new Map();
|
|
63
|
-
for (const msg of messages) {
|
|
64
|
-
if (msg.type === 'assistant' && msg.message?.content) {
|
|
65
|
-
const blocks = Array.isArray(msg.message.content) ? msg.message.content : [];
|
|
66
|
-
for (const b of blocks) {
|
|
67
|
-
if (b?.type === 'tool_use' && b.id && b.name) {
|
|
68
|
-
toolUses.set(b.id, { name: b.name, id: b.id });
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
else if (msg.type === 'user' && msg.message?.content) {
|
|
73
|
-
const blocks = Array.isArray(msg.message.content) ? msg.message.content : [];
|
|
74
|
-
for (const b of blocks) {
|
|
75
|
-
if (b?.type === 'tool_result' && b.tool_use_id) {
|
|
76
|
-
results.set(b.tool_use_id, {
|
|
77
|
-
content: stringifyResultContent(b.content),
|
|
78
|
-
isError: !!b.is_error,
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
const records = [];
|
|
85
|
-
for (const [id, tu] of toolUses) {
|
|
86
|
-
const r = results.get(id);
|
|
87
|
-
if (!r)
|
|
88
|
-
continue;
|
|
89
|
-
records.push({
|
|
90
|
-
name: tu.name,
|
|
91
|
-
id,
|
|
92
|
-
resultClass: classifyResult(r.content, r.isError),
|
|
93
|
-
resultPreview: r.content.slice(0, 200),
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
return records;
|
|
97
|
-
}
|
|
98
|
-
/**
|
|
99
|
-
* Check a reply against a set of tool-call records. Returns the first
|
|
100
|
-
* contradiction found, or null if the reply is consistent with tool results.
|
|
101
|
-
*
|
|
102
|
-
* Contradiction = reply contains a CONTRADICTION_RE phrase AND at least one
|
|
103
|
-
* mcp__claude_ai_* tool in this turn classified `success` or `arg_error`.
|
|
104
|
-
* `auth_error` and `other_error` are legitimate failures that can support
|
|
105
|
-
* those reply phrasings.
|
|
106
|
-
*/
|
|
107
|
-
export function detectContradiction(reply, calls) {
|
|
108
|
-
if (!reply)
|
|
109
|
-
return null;
|
|
110
|
-
const match = reply.match(CONTRADICTION_RE);
|
|
111
|
-
if (!match)
|
|
112
|
-
return null;
|
|
113
|
-
// Cover every connector — claude_ai_* (remote), imessage/figma/hostinger/etc.
|
|
114
|
-
// (Desktop Extensions + stdio servers), everything except Clementine's own
|
|
115
|
-
// tools server and plugins. Earlier versions only filtered claude_ai_*,
|
|
116
|
-
// which let "isn't loaded" replies slip through for iMessage etc.
|
|
117
|
-
const connectorCalls = calls.filter(c => c.name.startsWith('mcp__') &&
|
|
118
|
-
!c.name.startsWith('mcp__clementine-tools__') &&
|
|
119
|
-
!c.name.startsWith('mcp__plugin_'));
|
|
120
|
-
const recoverable = connectorCalls.find(c => c.resultClass === 'success' || c.resultClass === 'arg_error');
|
|
121
|
-
if (!recoverable)
|
|
122
|
-
return null;
|
|
123
|
-
return { tool: recoverable, matchedPhrase: match[0] };
|
|
124
|
-
}
|
|
125
|
-
/**
|
|
126
|
-
* Build the system-follow-up message we inject when a contradiction fires.
|
|
127
|
-
* The SDK will run one more turn with this as a user-role message (using
|
|
128
|
-
* `canUseTool` or similar hook), and the model's next reply replaces the
|
|
129
|
-
* bad one.
|
|
130
|
-
*/
|
|
131
|
-
export function buildCorrectionPrompt(finding) {
|
|
132
|
-
const { tool, matchedPhrase } = finding;
|
|
133
|
-
const classLabel = tool.resultClass === 'success' ? 'returned successful content' :
|
|
134
|
-
tool.resultClass === 'arg_error' ? 'returned an argument error (fixable by correcting the args — the connector itself works)' :
|
|
135
|
-
tool.resultClass;
|
|
136
|
-
return (`Your previous reply contained "${matchedPhrase}" but ${tool.name} ${classLabel}.\n\n` +
|
|
137
|
-
`Literal tool result (first 200 chars):\n${tool.resultPreview}\n\n` +
|
|
138
|
-
`Rewrite your reply using the actual tool result. ` +
|
|
139
|
-
(tool.resultClass === 'arg_error'
|
|
140
|
-
? `This was an argument error for one call — the connector is NOT broken. Re-read the tool's schema (the rejected argument names are in the error above), retry the call with correct args, and report what comes back.`
|
|
141
|
-
: `Do not generalize this to "the connector is broken" or "the tool doesn't exist" — those claims contradict the tool's actual return value.`));
|
|
142
|
-
}
|
|
143
|
-
//# sourceMappingURL=contradiction-validator.js.map
|
|
@@ -1,70 +0,0 @@
|
|
|
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
|
|
@@ -1,173 +0,0 @@
|
|
|
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
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Migrate plaintext credential values from .env into the macOS keychain.
|
|
3
|
-
*
|
|
4
|
-
* For each line in .env whose key looks like a credential (per the
|
|
5
|
-
* sensitivity classifier) and whose value is plaintext (not a `keychain:`
|
|
6
|
-
* stub), writes the value into the keychain under the well-known
|
|
7
|
-
* `clementine-agent` service and replaces the .env line with a stub ref.
|
|
8
|
-
*
|
|
9
|
-
* Atomic: all keychain writes complete first, then a single temp-file +
|
|
10
|
-
* rename rewrites .env. If any keychain write fails, the .env is untouched.
|
|
11
|
-
*
|
|
12
|
-
* Idempotent: lines already holding a keychain ref are skipped. Lines
|
|
13
|
-
* that don't match the sensitivity classifier are passed through verbatim.
|
|
14
|
-
*
|
|
15
|
-
* Pure: never reads .env from process.env, never mutates ambient state.
|
|
16
|
-
*/
|
|
17
|
-
export type MigrationStatus = 'migrated' | 'already-keychain' | 'not-sensitive' | 'too-short' | 'skipped';
|
|
18
|
-
export interface MigrationCandidate {
|
|
19
|
-
key: string;
|
|
20
|
-
status: MigrationStatus;
|
|
21
|
-
/** Length of the original value (never the value itself — avoid leaking via logs). */
|
|
22
|
-
valueLength: number;
|
|
23
|
-
}
|
|
24
|
-
export interface MigrationPlan {
|
|
25
|
-
envPath: string;
|
|
26
|
-
candidates: MigrationCandidate[];
|
|
27
|
-
/** Keys this run would actually migrate (status === 'migrated' after apply). */
|
|
28
|
-
toMigrate: string[];
|
|
29
|
-
}
|
|
30
|
-
export interface MigrationResult {
|
|
31
|
-
envPath: string;
|
|
32
|
-
migrated: string[];
|
|
33
|
-
failed: Array<{
|
|
34
|
-
key: string;
|
|
35
|
-
error: string;
|
|
36
|
-
}>;
|
|
37
|
-
unchanged: number;
|
|
38
|
-
}
|
|
39
|
-
/**
|
|
40
|
-
* Compute what would happen — pure read, no .env write, no keychain write.
|
|
41
|
-
* Use --dry-run paths or pre-flight UX in front of apply().
|
|
42
|
-
*/
|
|
43
|
-
export declare function planMigration(baseDir: string, opts?: {
|
|
44
|
-
only?: string[];
|
|
45
|
-
}): MigrationPlan;
|
|
46
|
-
/**
|
|
47
|
-
* Execute the migration. Two-phase to avoid leaving .env in an inconsistent
|
|
48
|
-
* state: (1) write every value to keychain, (2) atomically rewrite .env
|
|
49
|
-
* replacing each migrated value with its stub ref. Any keychain write
|
|
50
|
-
* failure aborts before the .env rewrite.
|
|
51
|
-
*/
|
|
52
|
-
export declare function applyMigration(baseDir: string, opts?: {
|
|
53
|
-
only?: string[];
|
|
54
|
-
}): MigrationResult;
|
|
55
|
-
//# sourceMappingURL=migrate-keychain.d.ts.map
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Migrate plaintext credential values from .env into the macOS keychain.
|
|
3
|
-
*
|
|
4
|
-
* For each line in .env whose key looks like a credential (per the
|
|
5
|
-
* sensitivity classifier) and whose value is plaintext (not a `keychain:`
|
|
6
|
-
* stub), writes the value into the keychain under the well-known
|
|
7
|
-
* `clementine-agent` service and replaces the .env line with a stub ref.
|
|
8
|
-
*
|
|
9
|
-
* Atomic: all keychain writes complete first, then a single temp-file +
|
|
10
|
-
* rename rewrites .env. If any keychain write fails, the .env is untouched.
|
|
11
|
-
*
|
|
12
|
-
* Idempotent: lines already holding a keychain ref are skipped. Lines
|
|
13
|
-
* that don't match the sensitivity classifier are passed through verbatim.
|
|
14
|
-
*
|
|
15
|
-
* Pure: never reads .env from process.env, never mutates ambient state.
|
|
16
|
-
*/
|
|
17
|
-
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
18
|
-
import path from 'node:path';
|
|
19
|
-
import * as keychain from '../secrets/keychain.js';
|
|
20
|
-
import { isSensitiveEnvKey } from '../secrets/sensitivity.js';
|
|
21
|
-
const REF_PREFIX = 'keychain:'; // pragma: allowlist secret
|
|
22
|
-
const MIN_VALUE_LENGTH = 16;
|
|
23
|
-
function parseLine(line) {
|
|
24
|
-
const trimmed = line.trim();
|
|
25
|
-
if (!trimmed || trimmed.startsWith('#')) {
|
|
26
|
-
return { raw: line, passthrough: true };
|
|
27
|
-
}
|
|
28
|
-
const eq = trimmed.indexOf('=');
|
|
29
|
-
if (eq === -1)
|
|
30
|
-
return { raw: line, passthrough: true };
|
|
31
|
-
const key = trimmed.slice(0, eq);
|
|
32
|
-
let value = trimmed.slice(eq + 1);
|
|
33
|
-
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
34
|
-
(value.startsWith("'") && value.endsWith("'"))) {
|
|
35
|
-
value = value.slice(1, -1);
|
|
36
|
-
}
|
|
37
|
-
return { raw: line, key, value, passthrough: false };
|
|
38
|
-
}
|
|
39
|
-
function classify(parsed, opts) {
|
|
40
|
-
if (parsed.passthrough || !parsed.key || parsed.value === undefined)
|
|
41
|
-
return 'skipped';
|
|
42
|
-
if (opts.only && !opts.only.has(parsed.key))
|
|
43
|
-
return 'skipped';
|
|
44
|
-
if (parsed.value.startsWith(REF_PREFIX))
|
|
45
|
-
return 'already-keychain';
|
|
46
|
-
if (!isSensitiveEnvKey(parsed.key))
|
|
47
|
-
return 'not-sensitive';
|
|
48
|
-
if (parsed.value.length < MIN_VALUE_LENGTH)
|
|
49
|
-
return 'too-short';
|
|
50
|
-
return 'migrated';
|
|
51
|
-
}
|
|
52
|
-
/**
|
|
53
|
-
* Compute what would happen — pure read, no .env write, no keychain write.
|
|
54
|
-
* Use --dry-run paths or pre-flight UX in front of apply().
|
|
55
|
-
*/
|
|
56
|
-
export function planMigration(baseDir, opts = {}) {
|
|
57
|
-
const envPath = path.join(baseDir, '.env');
|
|
58
|
-
if (!existsSync(envPath)) {
|
|
59
|
-
return { envPath, candidates: [], toMigrate: [] };
|
|
60
|
-
}
|
|
61
|
-
const raw = readFileSync(envPath, 'utf-8');
|
|
62
|
-
const onlySet = opts.only ? new Set(opts.only) : undefined;
|
|
63
|
-
const candidates = [];
|
|
64
|
-
const toMigrate = [];
|
|
65
|
-
for (const line of raw.split('\n')) {
|
|
66
|
-
const parsed = parseLine(line);
|
|
67
|
-
if (parsed.passthrough || !parsed.key)
|
|
68
|
-
continue;
|
|
69
|
-
const status = classify(parsed, { only: onlySet });
|
|
70
|
-
candidates.push({
|
|
71
|
-
key: parsed.key,
|
|
72
|
-
status,
|
|
73
|
-
valueLength: parsed.value?.length ?? 0,
|
|
74
|
-
});
|
|
75
|
-
if (status === 'migrated')
|
|
76
|
-
toMigrate.push(parsed.key);
|
|
77
|
-
}
|
|
78
|
-
return { envPath, candidates, toMigrate };
|
|
79
|
-
}
|
|
80
|
-
/**
|
|
81
|
-
* Execute the migration. Two-phase to avoid leaving .env in an inconsistent
|
|
82
|
-
* state: (1) write every value to keychain, (2) atomically rewrite .env
|
|
83
|
-
* replacing each migrated value with its stub ref. Any keychain write
|
|
84
|
-
* failure aborts before the .env rewrite.
|
|
85
|
-
*/
|
|
86
|
-
export function applyMigration(baseDir, opts = {}) {
|
|
87
|
-
const envPath = path.join(baseDir, '.env');
|
|
88
|
-
const result = { envPath, migrated: [], failed: [], unchanged: 0 };
|
|
89
|
-
if (!existsSync(envPath))
|
|
90
|
-
return result;
|
|
91
|
-
if (!keychain.isAvailable()) {
|
|
92
|
-
throw new Error('macOS keychain is not available on this system');
|
|
93
|
-
}
|
|
94
|
-
const raw = readFileSync(envPath, 'utf-8');
|
|
95
|
-
const onlySet = opts.only ? new Set(opts.only) : undefined;
|
|
96
|
-
const lines = raw.split('\n');
|
|
97
|
-
const parsedLines = lines.map(parseLine);
|
|
98
|
-
// Phase 1: write each migration target into the keychain. Build a map
|
|
99
|
-
// key → newStubValue. Bail on first keychain write failure (no .env touch).
|
|
100
|
-
const newValues = new Map();
|
|
101
|
-
for (const parsed of parsedLines) {
|
|
102
|
-
if (parsed.passthrough || !parsed.key)
|
|
103
|
-
continue;
|
|
104
|
-
const status = classify(parsed, { only: onlySet });
|
|
105
|
-
if (status !== 'migrated') {
|
|
106
|
-
if (status !== 'skipped' && status !== 'already-keychain')
|
|
107
|
-
result.unchanged++;
|
|
108
|
-
continue;
|
|
109
|
-
}
|
|
110
|
-
try {
|
|
111
|
-
const stub = keychain.set(parsed.key, parsed.value);
|
|
112
|
-
newValues.set(parsed.key, stub);
|
|
113
|
-
}
|
|
114
|
-
catch (err) {
|
|
115
|
-
result.failed.push({ key: parsed.key, error: String(err).slice(0, 200) });
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
if (result.failed.length > 0) {
|
|
119
|
-
// Don't touch .env if any keychain write failed — keeps the original
|
|
120
|
-
// plaintext intact rather than half-migrating.
|
|
121
|
-
return result;
|
|
122
|
-
}
|
|
123
|
-
if (newValues.size === 0)
|
|
124
|
-
return result;
|
|
125
|
-
// Phase 2: rewrite .env in place, line-by-line, swapping values for stubs.
|
|
126
|
-
const newLines = parsedLines.map((parsed) => {
|
|
127
|
-
if (parsed.passthrough || !parsed.key)
|
|
128
|
-
return parsed.raw;
|
|
129
|
-
const newStub = newValues.get(parsed.key);
|
|
130
|
-
if (!newStub)
|
|
131
|
-
return parsed.raw;
|
|
132
|
-
// Match the original line's leading whitespace and trailing comment-noise
|
|
133
|
-
// by reconstructing key=stub from scratch — keys are uppercase identifiers,
|
|
134
|
-
// so no whitespace ambiguity.
|
|
135
|
-
return `${parsed.key}=${newStub}`;
|
|
136
|
-
});
|
|
137
|
-
const tmp = `${envPath}.${process.pid}.${Date.now()}.tmp`;
|
|
138
|
-
writeFileSync(tmp, newLines.join('\n'));
|
|
139
|
-
renameSync(tmp, envPath);
|
|
140
|
-
for (const key of newValues.keys())
|
|
141
|
-
result.migrated.push(key);
|
|
142
|
-
return result;
|
|
143
|
-
}
|
|
144
|
-
//# sourceMappingURL=migrate-keychain.js.map
|