clementine-agent 1.0.45 → 1.0.46
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/assistant.js +41 -11
- package/dist/config/integrations-registry.d.ts +80 -0
- package/dist/config/integrations-registry.js +268 -0
- package/dist/index.js +6 -0
- package/dist/secrets/auth-profiles.d.ts +57 -0
- package/dist/secrets/auth-profiles.js +111 -0
- package/dist/secrets/keychain.d.ts +35 -0
- package/dist/secrets/keychain.js +119 -0
- package/dist/secrets/resolver.d.ts +34 -0
- package/dist/secrets/resolver.js +70 -0
- package/dist/tools/admin-tools.js +131 -8
- package/package.json +1 -1
package/dist/agent/assistant.js
CHANGED
|
@@ -1052,26 +1052,39 @@ Obsidian vault with YAML frontmatter, [[wikilinks]], #tags.
|
|
|
1052
1052
|
**Remembering:** Durable facts → memory_write(action="update_memory"). Daily context → note_take / memory_write(action="append_daily"). New person → note_create. New task → task_add.
|
|
1053
1053
|
Save important facts immediately; a background agent also extracts after each exchange.
|
|
1054
1054
|
|
|
1055
|
-
## Self-Configuration (
|
|
1055
|
+
## Self-Configuration (never tell ${owner} to edit a config file)
|
|
1056
1056
|
|
|
1057
|
-
|
|
1057
|
+
Clementine is self-configuring. Every credential, every integration, every tool permission can be set by calling a tool — no hand-editing.
|
|
1058
1058
|
|
|
1059
|
-
|
|
1059
|
+
### Integrations (Slack, Notion, Stripe, Salesforce, etc.)
|
|
1060
1060
|
|
|
1061
|
-
|
|
1061
|
+
You have a declarative registry of every integration you can configure. Use it:
|
|
1062
1062
|
|
|
1063
|
-
|
|
1063
|
+
- \`list_integrations\` — shows every integration you know about
|
|
1064
|
+
- \`integration_status [slug]\` — reports which are configured, partial (some creds missing), or missing entirely
|
|
1065
|
+
- \`setup_integration <slug>\` — returns the required env vars, doc URLs, and current status — use this BEFORE asking ${owner} for any credential
|
|
1066
|
+
- \`auth_profile_status\` — shows stored OAuth profiles and token expiry
|
|
1064
1067
|
|
|
1065
|
-
|
|
1066
|
-
|
|
1068
|
+
**When ${owner} says "set up X":** always call \`setup_integration(x)\` first. It returns the exact env var names and where to get each one. Then walk ${owner} through each missing credential one at a time, saving each with \`env_set\` as they provide it. After the last one, call \`integration_status\` to confirm "configured".
|
|
1069
|
+
|
|
1070
|
+
**Never invent env var names.** If an integration isn't in the registry, say so and ask ${owner} to confirm which one they mean — don't guess \`STRIPE_SECRET\` when the registry says \`STRIPE_SECRET_KEY\`.
|
|
1071
|
+
|
|
1072
|
+
### Saving credentials
|
|
1073
|
+
|
|
1074
|
+
\`env_set(key, value)\` — the one tool for saving any API key, token, or config. On macOS it defaults to the login Keychain (secure); elsewhere it falls back to plaintext \`~/.clementine/.env\`. \`process.env\` is updated immediately — the next tool call can use the value. No restart needed unless a long-lived channel adapter needs re-auth.
|
|
1075
|
+
|
|
1076
|
+
Companion tools: \`env_list\` (masked values + backend), \`env_unset\` (removes + clears Keychain entry).
|
|
1077
|
+
|
|
1078
|
+
### When a tool call is refused
|
|
1067
1079
|
|
|
1068
|
-
|
|
1080
|
+
If any tool call fails with "not in my function schema" / "tool not allowed" / "unknown tool" while the tool appears in your SDK inventory:
|
|
1069
1081
|
|
|
1070
|
-
|
|
1082
|
+
1. Call \`allow_tool("<exact_tool_name>")\` — persists to your whitelist
|
|
1083
|
+
2. Retry the original call
|
|
1071
1084
|
|
|
1072
|
-
|
|
1085
|
+
Takes effect on your next query. **Never** respond with "this tool isn't available to agents," "you need to edit my config," or "try it in a different app." If the tool name exists, you can add it.
|
|
1073
1086
|
|
|
1074
|
-
|
|
1087
|
+
\`list_allowed_tools\` / \`disallow_tool\` manage the list.
|
|
1075
1088
|
|
|
1076
1089
|
## Context Window Management
|
|
1077
1090
|
|
|
@@ -1304,6 +1317,18 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1304
1317
|
}
|
|
1305
1318
|
// Security rules are now appended to systemPrompt in buildOptions()
|
|
1306
1319
|
// Volatile suffix — put last so the stable prefix above stays cache-friendly.
|
|
1320
|
+
// Integration status — injected here (not in the stable prefix) because
|
|
1321
|
+
// it changes as ${owner} configures new credentials, and we don't want
|
|
1322
|
+
// every env_set to invalidate the cache.
|
|
1323
|
+
if (!isAutonomous) {
|
|
1324
|
+
try {
|
|
1325
|
+
const { summarizeIntegrationStatus } = require('../config/integrations-registry.js');
|
|
1326
|
+
const summary = summarizeIntegrationStatus(process.env);
|
|
1327
|
+
if (summary)
|
|
1328
|
+
parts.push(`## Integration Status\n\n${summary}\n\nCall \`integration_status\`, \`list_integrations\`, or \`setup_integration\` for details.`);
|
|
1329
|
+
}
|
|
1330
|
+
catch { /* non-fatal */ }
|
|
1331
|
+
}
|
|
1307
1332
|
const channel = deriveChannel({ sessionKey, isAutonomous, cronTier });
|
|
1308
1333
|
const resolvedModel = resolveModel(model) ?? MODEL;
|
|
1309
1334
|
const modelLabel = Object.entries(MODELS).find(([, v]) => v === resolvedModel)?.[0] ?? resolvedModel;
|
|
@@ -1360,6 +1385,11 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1360
1385
|
mcpTool('env_set'),
|
|
1361
1386
|
mcpTool('env_list'),
|
|
1362
1387
|
mcpTool('env_unset'),
|
|
1388
|
+
// Integration registry — proactive configuration
|
|
1389
|
+
mcpTool('integration_status'),
|
|
1390
|
+
mcpTool('list_integrations'),
|
|
1391
|
+
mcpTool('setup_integration'),
|
|
1392
|
+
mcpTool('auth_profile_status'),
|
|
1363
1393
|
// Self-service tool whitelist — Clementine can add tools she discovers
|
|
1364
1394
|
// in the SDK init inventory but that aren't in her baseline allowedTools
|
|
1365
1395
|
mcpTool('allow_tool'),
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Integration registry.
|
|
3
|
+
*
|
|
4
|
+
* Declarative metadata for every integration Clementine knows how to set up.
|
|
5
|
+
* The registry is the single source of truth for:
|
|
6
|
+
* - What env vars an integration needs
|
|
7
|
+
* - Where to get each credential (doc URLs)
|
|
8
|
+
* - Whether the integration is configured right now
|
|
9
|
+
* - How to surface gaps to the owner proactively
|
|
10
|
+
*
|
|
11
|
+
* Used by:
|
|
12
|
+
* - integration_status() — reports configured/partial/missing per integration
|
|
13
|
+
* - setup_integration() — walks the owner through setup conversationally
|
|
14
|
+
* - list_integrations() — enumerates what's available
|
|
15
|
+
* - System prompt — "Notion is configured, Stripe needs STRIPE_API_KEY"
|
|
16
|
+
*
|
|
17
|
+
* Replaces the previous pattern where the agent had to recall from chat
|
|
18
|
+
* history or invent which env vars a provider needs — the registry tells her
|
|
19
|
+
* exactly, with docs links, so she never has to guess.
|
|
20
|
+
*/
|
|
21
|
+
export type IntegrationKind = 'api-key' | 'oauth' | 'hybrid' | 'channel';
|
|
22
|
+
export interface IntegrationRequirement {
|
|
23
|
+
/** Env var name (uppercase, underscores). */
|
|
24
|
+
envVar: string;
|
|
25
|
+
/** One-line description shown during setup. */
|
|
26
|
+
label: string;
|
|
27
|
+
/** Whether this credential is required (false = optional). */
|
|
28
|
+
required: boolean;
|
|
29
|
+
/** Doc link where the owner can find or generate this credential. */
|
|
30
|
+
docUrl?: string;
|
|
31
|
+
/** Optional regex the value must match (for early validation). */
|
|
32
|
+
pattern?: RegExp;
|
|
33
|
+
/** If true, value is long-lived (API key). If false, short-lived (OAuth token). */
|
|
34
|
+
persistent?: boolean;
|
|
35
|
+
}
|
|
36
|
+
export interface IntegrationDefinition {
|
|
37
|
+
/** Machine-readable slug (kebab-case). */
|
|
38
|
+
slug: string;
|
|
39
|
+
/** Human-friendly label. */
|
|
40
|
+
label: string;
|
|
41
|
+
/** One-line summary shown in status output. */
|
|
42
|
+
description: string;
|
|
43
|
+
/** Credential style. */
|
|
44
|
+
kind: IntegrationKind;
|
|
45
|
+
/** Required + optional env vars. */
|
|
46
|
+
requirements: IntegrationRequirement[];
|
|
47
|
+
/** Top-level docs URL. */
|
|
48
|
+
docUrl?: string;
|
|
49
|
+
/** Tools/capabilities this integration unlocks, for the status panel. */
|
|
50
|
+
capabilities?: string[];
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The curated registry. Keep entries alphabetical within each kind for
|
|
54
|
+
* easier scanning. Adding a new integration: declare it here, and both
|
|
55
|
+
* integration_status and setup_integration automatically pick it up.
|
|
56
|
+
*/
|
|
57
|
+
export declare const INTEGRATIONS: IntegrationDefinition[];
|
|
58
|
+
export type IntegrationStatus = 'configured' | 'partial' | 'missing';
|
|
59
|
+
export interface IntegrationStatusReport {
|
|
60
|
+
slug: string;
|
|
61
|
+
label: string;
|
|
62
|
+
status: IntegrationStatus;
|
|
63
|
+
/** All required env vars that are currently set. */
|
|
64
|
+
have: string[];
|
|
65
|
+
/** Required env vars that are missing. */
|
|
66
|
+
missing: string[];
|
|
67
|
+
/** Optional env vars that are missing (informational only). */
|
|
68
|
+
optionalMissing: string[];
|
|
69
|
+
/** Helpful setup link. */
|
|
70
|
+
docUrl?: string;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Classify each integration against the current environment.
|
|
74
|
+
* Pure function — caller passes in env so this is testable.
|
|
75
|
+
*/
|
|
76
|
+
export declare function classifyIntegrations(env: NodeJS.ProcessEnv, slugs?: string[]): IntegrationStatusReport[];
|
|
77
|
+
export declare function findIntegration(slug: string): IntegrationDefinition | undefined;
|
|
78
|
+
/** A short one-line summary for prompt injection. */
|
|
79
|
+
export declare function summarizeIntegrationStatus(env: NodeJS.ProcessEnv): string;
|
|
80
|
+
//# sourceMappingURL=integrations-registry.d.ts.map
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Integration registry.
|
|
3
|
+
*
|
|
4
|
+
* Declarative metadata for every integration Clementine knows how to set up.
|
|
5
|
+
* The registry is the single source of truth for:
|
|
6
|
+
* - What env vars an integration needs
|
|
7
|
+
* - Where to get each credential (doc URLs)
|
|
8
|
+
* - Whether the integration is configured right now
|
|
9
|
+
* - How to surface gaps to the owner proactively
|
|
10
|
+
*
|
|
11
|
+
* Used by:
|
|
12
|
+
* - integration_status() — reports configured/partial/missing per integration
|
|
13
|
+
* - setup_integration() — walks the owner through setup conversationally
|
|
14
|
+
* - list_integrations() — enumerates what's available
|
|
15
|
+
* - System prompt — "Notion is configured, Stripe needs STRIPE_API_KEY"
|
|
16
|
+
*
|
|
17
|
+
* Replaces the previous pattern where the agent had to recall from chat
|
|
18
|
+
* history or invent which env vars a provider needs — the registry tells her
|
|
19
|
+
* exactly, with docs links, so she never has to guess.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* The curated registry. Keep entries alphabetical within each kind for
|
|
23
|
+
* easier scanning. Adding a new integration: declare it here, and both
|
|
24
|
+
* integration_status and setup_integration automatically pick it up.
|
|
25
|
+
*/
|
|
26
|
+
export const INTEGRATIONS = [
|
|
27
|
+
// NOTE: Anthropic auth is handled through `clementine login` (OAuth) or
|
|
28
|
+
// the ANTHROPIC_API_KEY env var — it's foundational and managed outside
|
|
29
|
+
// this registry because the auth path isn't uniform.
|
|
30
|
+
// ── Channels ──────────────────────────────────────────────────────
|
|
31
|
+
{
|
|
32
|
+
slug: 'discord',
|
|
33
|
+
label: 'Discord',
|
|
34
|
+
description: 'Main chat channel. Bot token + owner ID required.',
|
|
35
|
+
kind: 'channel',
|
|
36
|
+
docUrl: 'https://discord.com/developers/applications',
|
|
37
|
+
capabilities: ['chat', 'DMs', 'agent bots', 'notifications'],
|
|
38
|
+
requirements: [
|
|
39
|
+
{ envVar: 'DISCORD_TOKEN', label: 'Bot token', required: true, docUrl: 'https://discord.com/developers/applications', persistent: true },
|
|
40
|
+
{ envVar: 'DISCORD_OWNER_ID', label: 'Your Discord user ID (right-click your name → Copy User ID)', required: true, pattern: /^\d{15,22}$/, persistent: true },
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
slug: 'slack',
|
|
45
|
+
label: 'Slack',
|
|
46
|
+
description: 'Alternate chat channel. Socket-mode app with bot + app tokens.',
|
|
47
|
+
kind: 'channel',
|
|
48
|
+
docUrl: 'https://api.slack.com/apps',
|
|
49
|
+
capabilities: ['chat', 'DMs', 'channel posts'],
|
|
50
|
+
requirements: [
|
|
51
|
+
{ envVar: 'SLACK_BOT_TOKEN', label: 'Bot token (xoxb-...)', required: true, docUrl: 'https://api.slack.com/apps', pattern: /^xoxb-/, persistent: true },
|
|
52
|
+
{ envVar: 'SLACK_APP_TOKEN', label: 'App-level token (xapp-...)', required: true, docUrl: 'https://api.slack.com/apps', pattern: /^xapp-/, persistent: true },
|
|
53
|
+
{ envVar: 'SLACK_OWNER_USER_ID', label: 'Your Slack user ID (starts with U)', required: false, pattern: /^U[A-Z0-9]{6,}$/, persistent: true },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
slug: 'telegram',
|
|
58
|
+
label: 'Telegram',
|
|
59
|
+
description: 'Chat channel via Bot API.',
|
|
60
|
+
kind: 'channel',
|
|
61
|
+
docUrl: 'https://core.telegram.org/bots',
|
|
62
|
+
capabilities: ['chat', 'DMs'],
|
|
63
|
+
requirements: [
|
|
64
|
+
{ envVar: 'TELEGRAM_BOT_TOKEN', label: 'Bot token from @BotFather', required: true, docUrl: 'https://t.me/BotFather', persistent: true },
|
|
65
|
+
{ envVar: 'TELEGRAM_OWNER_CHAT_ID', label: 'Your Telegram chat ID', required: false, pattern: /^-?\d+$/, persistent: true },
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
slug: 'whatsapp',
|
|
70
|
+
label: 'WhatsApp (via Twilio)',
|
|
71
|
+
description: 'Chat channel via Twilio WhatsApp API.',
|
|
72
|
+
kind: 'channel',
|
|
73
|
+
docUrl: 'https://www.twilio.com/docs/whatsapp',
|
|
74
|
+
capabilities: ['chat', 'DMs'],
|
|
75
|
+
requirements: [
|
|
76
|
+
{ envVar: 'TWILIO_ACCOUNT_SID', label: 'Twilio account SID (AC...)', required: true, pattern: /^AC[a-f0-9]{32}$/i, persistent: true },
|
|
77
|
+
{ envVar: 'TWILIO_AUTH_TOKEN', label: 'Twilio auth token', required: true, persistent: true },
|
|
78
|
+
{ envVar: 'TWILIO_WHATSAPP_FROM', label: 'Twilio WhatsApp sender (whatsapp:+14155238886)', required: true, persistent: true },
|
|
79
|
+
{ envVar: 'WHATSAPP_OWNER_NUMBER', label: 'Your WhatsApp number (E.164 format, e.g. +15551234567)', required: true, pattern: /^\+\d{8,15}$/, persistent: true },
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
// ── Productivity / knowledge ──────────────────────────────────────
|
|
83
|
+
{
|
|
84
|
+
slug: 'notion',
|
|
85
|
+
label: 'Notion',
|
|
86
|
+
description: 'Read/write Notion pages and databases via integration.',
|
|
87
|
+
kind: 'api-key',
|
|
88
|
+
docUrl: 'https://www.notion.so/my-integrations',
|
|
89
|
+
capabilities: ['docs', 'databases', 'task tracking'],
|
|
90
|
+
requirements: [
|
|
91
|
+
{ envVar: 'NOTION_API_KEY', label: 'Internal integration secret (secret_... or ntn_...)', required: true, docUrl: 'https://www.notion.so/my-integrations', persistent: true },
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
slug: 'linear',
|
|
96
|
+
label: 'Linear',
|
|
97
|
+
description: 'Issue tracking and project management.',
|
|
98
|
+
kind: 'api-key',
|
|
99
|
+
docUrl: 'https://linear.app/settings/api',
|
|
100
|
+
capabilities: ['issues', 'projects', 'teams'],
|
|
101
|
+
requirements: [
|
|
102
|
+
{ envVar: 'LINEAR_API_KEY', label: 'Personal API key (lin_api_...)', required: true, docUrl: 'https://linear.app/settings/api', pattern: /^lin_api_/, persistent: true },
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
slug: 'github',
|
|
107
|
+
label: 'GitHub',
|
|
108
|
+
description: 'Read PRs, issues, manage releases.',
|
|
109
|
+
kind: 'api-key',
|
|
110
|
+
docUrl: 'https://github.com/settings/tokens',
|
|
111
|
+
capabilities: ['pull requests', 'issues', 'releases'],
|
|
112
|
+
requirements: [
|
|
113
|
+
{ envVar: 'GITHUB_TOKEN', label: 'Personal access token (ghp_... or github_pat_...)', required: true, docUrl: 'https://github.com/settings/tokens?type=beta', pattern: /^(ghp_|github_pat_)/, persistent: true },
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
// ── Email / calendar (OAuth) ──────────────────────────────────────
|
|
117
|
+
{
|
|
118
|
+
slug: 'microsoft-365',
|
|
119
|
+
label: 'Microsoft 365 (Outlook/Graph)',
|
|
120
|
+
description: 'Email, calendar, Teams, SharePoint via Microsoft Graph. OAuth app-only or delegated.',
|
|
121
|
+
kind: 'oauth',
|
|
122
|
+
docUrl: 'https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade',
|
|
123
|
+
capabilities: ['outlook inbox', 'outlook send', 'calendar', 'teams chat'],
|
|
124
|
+
requirements: [
|
|
125
|
+
{ envVar: 'MS_TENANT_ID', label: 'Azure AD tenant ID', required: true, pattern: /^[a-f0-9-]{36}$/i, persistent: true },
|
|
126
|
+
{ envVar: 'MS_CLIENT_ID', label: 'App registration client ID', required: true, pattern: /^[a-f0-9-]{36}$/i, persistent: true },
|
|
127
|
+
{ envVar: 'MS_CLIENT_SECRET', label: 'App registration client secret value', required: true, persistent: true },
|
|
128
|
+
{ envVar: 'MS_USER_EMAIL', label: 'Your primary email (used for delegated calls)', required: false, pattern: /@/, persistent: true },
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
// ── CRM / sales ───────────────────────────────────────────────────
|
|
132
|
+
{
|
|
133
|
+
slug: 'salesforce',
|
|
134
|
+
label: 'Salesforce',
|
|
135
|
+
description: 'CRM access via SOAP/REST. Username + password + token flow.',
|
|
136
|
+
kind: 'api-key',
|
|
137
|
+
docUrl: 'https://help.salesforce.com/s/articleView?id=sf.code_sample_auth_api_oauth.htm',
|
|
138
|
+
capabilities: ['leads', 'contacts', 'opportunities', 'custom objects'],
|
|
139
|
+
requirements: [
|
|
140
|
+
{ envVar: 'SF_INSTANCE_URL', label: 'Salesforce instance URL (https://your-org.my.salesforce.com)', required: true, pattern: /^https?:\/\//, persistent: true },
|
|
141
|
+
{ envVar: 'SF_CLIENT_ID', label: 'Connected app consumer key', required: true, persistent: true },
|
|
142
|
+
{ envVar: 'SF_CLIENT_SECRET', label: 'Connected app consumer secret', required: true, persistent: true },
|
|
143
|
+
{ envVar: 'SF_USERNAME', label: 'Salesforce username (email)', required: true, pattern: /@/, persistent: true },
|
|
144
|
+
{ envVar: 'SF_PASSWORD', label: 'Salesforce password + security token concatenated', required: true, persistent: true },
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
// ── AI auxiliaries ────────────────────────────────────────────────
|
|
148
|
+
{
|
|
149
|
+
slug: 'openai',
|
|
150
|
+
label: 'OpenAI',
|
|
151
|
+
description: 'GPT + Whisper (used for auxiliary tasks and fallback).',
|
|
152
|
+
kind: 'api-key',
|
|
153
|
+
docUrl: 'https://platform.openai.com/api-keys',
|
|
154
|
+
capabilities: ['transcription', 'image generation', 'fallback model'],
|
|
155
|
+
requirements: [
|
|
156
|
+
{ envVar: 'OPENAI_API_KEY', label: 'API key (sk-...)', required: true, docUrl: 'https://platform.openai.com/api-keys', pattern: /^sk-/, persistent: true },
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
slug: 'elevenlabs',
|
|
161
|
+
label: 'ElevenLabs',
|
|
162
|
+
description: 'Text-to-speech for voice replies.',
|
|
163
|
+
kind: 'api-key',
|
|
164
|
+
docUrl: 'https://elevenlabs.io/app/settings/api-keys',
|
|
165
|
+
capabilities: ['voice synthesis'],
|
|
166
|
+
requirements: [
|
|
167
|
+
{ envVar: 'ELEVENLABS_API_KEY', label: 'API key', required: true, docUrl: 'https://elevenlabs.io/app/settings/api-keys', persistent: true },
|
|
168
|
+
],
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
slug: 'groq',
|
|
172
|
+
label: 'Groq',
|
|
173
|
+
description: 'Fast inference, used for real-time transcription.',
|
|
174
|
+
kind: 'api-key',
|
|
175
|
+
docUrl: 'https://console.groq.com/keys',
|
|
176
|
+
capabilities: ['voice transcription'],
|
|
177
|
+
requirements: [
|
|
178
|
+
{ envVar: 'GROQ_API_KEY', label: 'API key (gsk_...)', required: true, docUrl: 'https://console.groq.com/keys', pattern: /^gsk_/, persistent: true },
|
|
179
|
+
],
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
slug: 'google-ai',
|
|
183
|
+
label: 'Google AI (Gemini)',
|
|
184
|
+
description: 'Used for video analysis and multimodal tasks.',
|
|
185
|
+
kind: 'api-key',
|
|
186
|
+
docUrl: 'https://aistudio.google.com/apikey',
|
|
187
|
+
capabilities: ['video analysis', 'multimodal'],
|
|
188
|
+
requirements: [
|
|
189
|
+
{ envVar: 'GOOGLE_API_KEY', label: 'Gemini API key', required: true, docUrl: 'https://aistudio.google.com/apikey', persistent: true },
|
|
190
|
+
],
|
|
191
|
+
},
|
|
192
|
+
// ── Payments ──────────────────────────────────────────────────────
|
|
193
|
+
{
|
|
194
|
+
slug: 'stripe',
|
|
195
|
+
label: 'Stripe',
|
|
196
|
+
description: 'Payments read/write (one-off or recurring).',
|
|
197
|
+
kind: 'api-key',
|
|
198
|
+
docUrl: 'https://dashboard.stripe.com/apikeys',
|
|
199
|
+
capabilities: ['customers', 'payments', 'subscriptions'],
|
|
200
|
+
requirements: [
|
|
201
|
+
{ envVar: 'STRIPE_SECRET_KEY', label: 'Secret key (sk_live_... or sk_test_...)', required: true, docUrl: 'https://dashboard.stripe.com/apikeys', pattern: /^sk_(live|test)_/, persistent: true },
|
|
202
|
+
],
|
|
203
|
+
},
|
|
204
|
+
];
|
|
205
|
+
/**
|
|
206
|
+
* Classify each integration against the current environment.
|
|
207
|
+
* Pure function — caller passes in env so this is testable.
|
|
208
|
+
*/
|
|
209
|
+
export function classifyIntegrations(env, slugs) {
|
|
210
|
+
const targets = slugs?.length
|
|
211
|
+
? INTEGRATIONS.filter(i => slugs.includes(i.slug))
|
|
212
|
+
: INTEGRATIONS;
|
|
213
|
+
return targets.map(integration => {
|
|
214
|
+
const required = integration.requirements.filter(r => r.required);
|
|
215
|
+
const optional = integration.requirements.filter(r => !r.required);
|
|
216
|
+
const have = [];
|
|
217
|
+
const missing = [];
|
|
218
|
+
for (const req of required) {
|
|
219
|
+
if (env[req.envVar])
|
|
220
|
+
have.push(req.envVar);
|
|
221
|
+
else
|
|
222
|
+
missing.push(req.envVar);
|
|
223
|
+
}
|
|
224
|
+
const optionalMissing = optional
|
|
225
|
+
.filter(r => !env[r.envVar])
|
|
226
|
+
.map(r => r.envVar);
|
|
227
|
+
let status;
|
|
228
|
+
if (missing.length === 0 && have.length > 0)
|
|
229
|
+
status = 'configured';
|
|
230
|
+
else if (have.length > 0)
|
|
231
|
+
status = 'partial';
|
|
232
|
+
else
|
|
233
|
+
status = 'missing';
|
|
234
|
+
// Special case for hybrid: "have" counts if ANY required-or-alternative is set.
|
|
235
|
+
if (integration.kind === 'hybrid' && missing.length > 0 && integration.requirements.some(r => env[r.envVar])) {
|
|
236
|
+
status = 'configured';
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
slug: integration.slug,
|
|
240
|
+
label: integration.label,
|
|
241
|
+
status,
|
|
242
|
+
have,
|
|
243
|
+
missing,
|
|
244
|
+
optionalMissing,
|
|
245
|
+
docUrl: integration.docUrl,
|
|
246
|
+
};
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
export function findIntegration(slug) {
|
|
250
|
+
const normalized = slug.trim().toLowerCase();
|
|
251
|
+
return INTEGRATIONS.find(i => i.slug === normalized);
|
|
252
|
+
}
|
|
253
|
+
/** A short one-line summary for prompt injection. */
|
|
254
|
+
export function summarizeIntegrationStatus(env) {
|
|
255
|
+
const reports = classifyIntegrations(env);
|
|
256
|
+
const configured = reports.filter(r => r.status === 'configured').map(r => r.label);
|
|
257
|
+
const partial = reports.filter(r => r.status === 'partial').map(r => `${r.label} (missing ${r.missing.join(', ')})`);
|
|
258
|
+
const missing = reports.filter(r => r.status === 'missing').map(r => r.label);
|
|
259
|
+
const lines = [];
|
|
260
|
+
if (configured.length > 0)
|
|
261
|
+
lines.push(`**Configured:** ${configured.join(', ')}`);
|
|
262
|
+
if (partial.length > 0)
|
|
263
|
+
lines.push(`**Partial:** ${partial.join('; ')}`);
|
|
264
|
+
if (missing.length > 0)
|
|
265
|
+
lines.push(`**Available but not configured:** ${missing.join(', ')}`);
|
|
266
|
+
return lines.join('\n');
|
|
267
|
+
}
|
|
268
|
+
//# sourceMappingURL=integrations-registry.js.map
|
package/dist/index.js
CHANGED
|
@@ -534,6 +534,12 @@ async function asyncMain() {
|
|
|
534
534
|
for (const warning of secretWarnings) {
|
|
535
535
|
logger.warn(warning);
|
|
536
536
|
}
|
|
537
|
+
// ── Resolve keychain-backed secrets before anything reads process.env ──
|
|
538
|
+
try {
|
|
539
|
+
const { hydrateSecretsFromEnv } = await import('./secrets/resolver.js');
|
|
540
|
+
hydrateSecretsFromEnv();
|
|
541
|
+
}
|
|
542
|
+
catch { /* non-fatal — non-macOS systems, or keychain unavailable */ }
|
|
537
543
|
// ── Check MCP extension permissions ────────────────────────────
|
|
538
544
|
try {
|
|
539
545
|
const { checkPermissionsOnStartup, bootstrapClaudeIntegrationsFromAuditLog, probeAvailableTools } = await import('./agent/mcp-bridge.js');
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — OAuth auth profile store.
|
|
3
|
+
*
|
|
4
|
+
* OAuth tokens (access + refresh + expiry) have different semantics from
|
|
5
|
+
* API keys: they expire and need refresh. Keeping them in ~/.clementine/.env
|
|
6
|
+
* alongside long-lived API keys mixes two lifecycles and makes token refresh
|
|
7
|
+
* awkward. This store keeps them separate at ~/.clementine/auth-profiles/<provider>.json
|
|
8
|
+
* with mode 0o600, so the OAuth flow code can atomically update tokens
|
|
9
|
+
* without touching .env.
|
|
10
|
+
*
|
|
11
|
+
* Current scope: storage + basic read/write/refresh-check. Callers (outlook,
|
|
12
|
+
* Google OAuth flows) can adopt this incrementally; existing env-based OAuth
|
|
13
|
+
* keeps working until migrated.
|
|
14
|
+
*/
|
|
15
|
+
export interface AuthProfile {
|
|
16
|
+
/** Provider slug (matches integrations-registry, e.g. "microsoft-365"). */
|
|
17
|
+
provider: string;
|
|
18
|
+
/** Short-lived access token. */
|
|
19
|
+
accessToken: string;
|
|
20
|
+
/** Long-lived refresh token, optional. */
|
|
21
|
+
refreshToken?: string;
|
|
22
|
+
/** Unix epoch ms — when accessToken expires. */
|
|
23
|
+
expiresAt?: number;
|
|
24
|
+
/** Identifier (email, user id) for the authenticated account. */
|
|
25
|
+
accountId?: string;
|
|
26
|
+
/** Comma-delimited scopes granted. */
|
|
27
|
+
scopes?: string;
|
|
28
|
+
/** When this profile was first written. */
|
|
29
|
+
createdAt: string;
|
|
30
|
+
/** When this profile was last refreshed. */
|
|
31
|
+
updatedAt: string;
|
|
32
|
+
/** Provider-specific extras. */
|
|
33
|
+
extras?: Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
/** Atomic write — write tmp then rename. Mode 0o600. */
|
|
36
|
+
export declare function save(profile: AuthProfile): void;
|
|
37
|
+
export declare function load(provider: string): AuthProfile | null;
|
|
38
|
+
export declare function remove(provider: string): boolean;
|
|
39
|
+
/** List provider slugs with stored profiles. */
|
|
40
|
+
export declare function list(): string[];
|
|
41
|
+
/**
|
|
42
|
+
* True if accessToken is present and either has no expiry set or expires
|
|
43
|
+
* more than `bufferMs` from now. Callers should call their refresh flow if
|
|
44
|
+
* this returns false.
|
|
45
|
+
*/
|
|
46
|
+
export declare function isValid(profile: AuthProfile | null, bufferMs?: number): boolean;
|
|
47
|
+
/** Get access token if valid, else null. Does not refresh — caller refreshes. */
|
|
48
|
+
export declare function getValidAccessToken(provider: string): string | null;
|
|
49
|
+
/** Status summary for status reporting. */
|
|
50
|
+
export interface AuthProfileStatus {
|
|
51
|
+
provider: string;
|
|
52
|
+
accountId?: string;
|
|
53
|
+
valid: boolean;
|
|
54
|
+
expiresInMinutes: number | null;
|
|
55
|
+
}
|
|
56
|
+
export declare function statusAll(): AuthProfileStatus[];
|
|
57
|
+
//# sourceMappingURL=auth-profiles.d.ts.map
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — OAuth auth profile store.
|
|
3
|
+
*
|
|
4
|
+
* OAuth tokens (access + refresh + expiry) have different semantics from
|
|
5
|
+
* API keys: they expire and need refresh. Keeping them in ~/.clementine/.env
|
|
6
|
+
* alongside long-lived API keys mixes two lifecycles and makes token refresh
|
|
7
|
+
* awkward. This store keeps them separate at ~/.clementine/auth-profiles/<provider>.json
|
|
8
|
+
* with mode 0o600, so the OAuth flow code can atomically update tokens
|
|
9
|
+
* without touching .env.
|
|
10
|
+
*
|
|
11
|
+
* Current scope: storage + basic read/write/refresh-check. Callers (outlook,
|
|
12
|
+
* Google OAuth flows) can adopt this incrementally; existing env-based OAuth
|
|
13
|
+
* keeps working until migrated.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import pino from 'pino';
|
|
18
|
+
import { BASE_DIR } from '../config.js';
|
|
19
|
+
const logger = pino({ name: 'clementine.auth-profiles' });
|
|
20
|
+
const PROFILES_DIR = path.join(BASE_DIR, 'auth-profiles');
|
|
21
|
+
function ensureDir() {
|
|
22
|
+
if (!existsSync(PROFILES_DIR)) {
|
|
23
|
+
mkdirSync(PROFILES_DIR, { recursive: true, mode: 0o700 });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function profilePath(provider) {
|
|
27
|
+
const safe = provider.replace(/[^a-z0-9_-]/gi, '_');
|
|
28
|
+
return path.join(PROFILES_DIR, `${safe}.json`);
|
|
29
|
+
}
|
|
30
|
+
/** Atomic write — write tmp then rename. Mode 0o600. */
|
|
31
|
+
export function save(profile) {
|
|
32
|
+
ensureDir();
|
|
33
|
+
const p = profilePath(profile.provider);
|
|
34
|
+
const tmp = p + '.tmp';
|
|
35
|
+
const content = JSON.stringify(profile, null, 2);
|
|
36
|
+
writeFileSync(tmp, content, { mode: 0o600 });
|
|
37
|
+
renameSync(tmp, p);
|
|
38
|
+
logger.info({ provider: profile.provider, account: profile.accountId }, 'Auth profile saved');
|
|
39
|
+
}
|
|
40
|
+
export function load(provider) {
|
|
41
|
+
const p = profilePath(provider);
|
|
42
|
+
if (!existsSync(p))
|
|
43
|
+
return null;
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(readFileSync(p, 'utf-8'));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function remove(provider) {
|
|
52
|
+
const p = profilePath(provider);
|
|
53
|
+
if (!existsSync(p))
|
|
54
|
+
return false;
|
|
55
|
+
try {
|
|
56
|
+
// Overwrite with empty data before unlink so contents aren't trivially recoverable.
|
|
57
|
+
writeFileSync(p, '');
|
|
58
|
+
const { unlinkSync } = require('node:fs');
|
|
59
|
+
unlinkSync(p);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** List provider slugs with stored profiles. */
|
|
67
|
+
export function list() {
|
|
68
|
+
ensureDir();
|
|
69
|
+
try {
|
|
70
|
+
return readdirSync(PROFILES_DIR)
|
|
71
|
+
.filter(f => f.endsWith('.json'))
|
|
72
|
+
.map(f => f.replace(/\.json$/, ''))
|
|
73
|
+
.sort();
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* True if accessToken is present and either has no expiry set or expires
|
|
81
|
+
* more than `bufferMs` from now. Callers should call their refresh flow if
|
|
82
|
+
* this returns false.
|
|
83
|
+
*/
|
|
84
|
+
export function isValid(profile, bufferMs = 5 * 60 * 1000) {
|
|
85
|
+
if (!profile?.accessToken)
|
|
86
|
+
return false;
|
|
87
|
+
if (!profile.expiresAt)
|
|
88
|
+
return true;
|
|
89
|
+
return Date.now() + bufferMs < profile.expiresAt;
|
|
90
|
+
}
|
|
91
|
+
/** Get access token if valid, else null. Does not refresh — caller refreshes. */
|
|
92
|
+
export function getValidAccessToken(provider) {
|
|
93
|
+
const p = load(provider);
|
|
94
|
+
return isValid(p) ? p.accessToken : null;
|
|
95
|
+
}
|
|
96
|
+
export function statusAll() {
|
|
97
|
+
const slugs = list();
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
return slugs.map(slug => {
|
|
100
|
+
const p = load(slug);
|
|
101
|
+
const valid = isValid(p);
|
|
102
|
+
const expiresInMinutes = p?.expiresAt ? Math.round((p.expiresAt - now) / 60000) : null;
|
|
103
|
+
return {
|
|
104
|
+
provider: slug,
|
|
105
|
+
accountId: p?.accountId,
|
|
106
|
+
valid,
|
|
107
|
+
expiresInMinutes,
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
//# sourceMappingURL=auth-profiles.js.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — macOS Keychain backend for secret storage.
|
|
3
|
+
*
|
|
4
|
+
* When available, lets `env_set` store API keys in the user's login keychain
|
|
5
|
+
* instead of plaintext in ~/.clementine/.env. The .env file then holds only
|
|
6
|
+
* a reference stub: `STRIPE_API_KEY=keychain:clementine-STRIPE_API_KEY`.
|
|
7
|
+
*
|
|
8
|
+
* Graceful fallback: on non-macOS systems or when `security` is unavailable,
|
|
9
|
+
* isAvailable() returns false and callers fall back to raw .env storage.
|
|
10
|
+
*
|
|
11
|
+
* Secrets are stored under service "clementine-agent" with the env var
|
|
12
|
+
* name as the account label, so the user can inspect / revoke them via
|
|
13
|
+
* Keychain Access.app if needed.
|
|
14
|
+
*/
|
|
15
|
+
/** Is macOS keychain usable in this environment? */
|
|
16
|
+
export declare function isAvailable(): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Construct the stub value written to .env when a secret is keychain-backed.
|
|
19
|
+
* Reading this back through resolver.ts triggers a keychain lookup.
|
|
20
|
+
*/
|
|
21
|
+
export declare function makeRef(envVar: string): string;
|
|
22
|
+
export declare function isRef(value: string): boolean;
|
|
23
|
+
/** Parse a ref stub back into its account name, or null if not a ref. */
|
|
24
|
+
export declare function parseRef(value: string): {
|
|
25
|
+
account: string;
|
|
26
|
+
} | null;
|
|
27
|
+
/** Write a secret. Returns the ref stub suitable for .env. */
|
|
28
|
+
export declare function set(envVar: string, value: string): string;
|
|
29
|
+
/** Read a secret back. Returns undefined if missing or unreadable. */
|
|
30
|
+
export declare function get(envVar: string): string | undefined;
|
|
31
|
+
/** Delete a secret. No-op if it doesn't exist. */
|
|
32
|
+
export declare function remove(envVar: string): boolean;
|
|
33
|
+
/** List env var names that have keychain entries (best-effort). */
|
|
34
|
+
export declare function list(): string[];
|
|
35
|
+
//# sourceMappingURL=keychain.d.ts.map
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — macOS Keychain backend for secret storage.
|
|
3
|
+
*
|
|
4
|
+
* When available, lets `env_set` store API keys in the user's login keychain
|
|
5
|
+
* instead of plaintext in ~/.clementine/.env. The .env file then holds only
|
|
6
|
+
* a reference stub: `STRIPE_API_KEY=keychain:clementine-STRIPE_API_KEY`.
|
|
7
|
+
*
|
|
8
|
+
* Graceful fallback: on non-macOS systems or when `security` is unavailable,
|
|
9
|
+
* isAvailable() returns false and callers fall back to raw .env storage.
|
|
10
|
+
*
|
|
11
|
+
* Secrets are stored under service "clementine-agent" with the env var
|
|
12
|
+
* name as the account label, so the user can inspect / revoke them via
|
|
13
|
+
* Keychain Access.app if needed.
|
|
14
|
+
*/
|
|
15
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
16
|
+
import pino from 'pino';
|
|
17
|
+
const logger = pino({ name: 'clementine.keychain' });
|
|
18
|
+
const SERVICE_NAME = 'clementine-agent';
|
|
19
|
+
const REF_PREFIX = 'keychain:'; // pragma: allowlist secret
|
|
20
|
+
/** Is macOS keychain usable in this environment? */
|
|
21
|
+
export function isAvailable() {
|
|
22
|
+
if (process.platform !== 'darwin')
|
|
23
|
+
return false;
|
|
24
|
+
try {
|
|
25
|
+
execFileSync('/usr/bin/security', ['-h'], {
|
|
26
|
+
stdio: 'pipe',
|
|
27
|
+
timeout: 1000,
|
|
28
|
+
});
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Construct the stub value written to .env when a secret is keychain-backed.
|
|
37
|
+
* Reading this back through resolver.ts triggers a keychain lookup.
|
|
38
|
+
*/
|
|
39
|
+
export function makeRef(envVar) {
|
|
40
|
+
return `${REF_PREFIX}${SERVICE_NAME}-${envVar}`;
|
|
41
|
+
}
|
|
42
|
+
export function isRef(value) {
|
|
43
|
+
return value.startsWith(REF_PREFIX);
|
|
44
|
+
}
|
|
45
|
+
/** Parse a ref stub back into its account name, or null if not a ref. */
|
|
46
|
+
export function parseRef(value) {
|
|
47
|
+
if (!isRef(value))
|
|
48
|
+
return null;
|
|
49
|
+
return { account: value.slice(REF_PREFIX.length) };
|
|
50
|
+
}
|
|
51
|
+
/** Write a secret. Returns the ref stub suitable for .env. */
|
|
52
|
+
export function set(envVar, value) {
|
|
53
|
+
if (!isAvailable()) {
|
|
54
|
+
throw new Error('Keychain unavailable on this platform');
|
|
55
|
+
}
|
|
56
|
+
const account = `${SERVICE_NAME}-${envVar}`;
|
|
57
|
+
// -U updates existing entry in place; -s = service; -a = account; -w = password
|
|
58
|
+
const result = spawnSync('/usr/bin/security', [
|
|
59
|
+
'add-generic-password',
|
|
60
|
+
'-U',
|
|
61
|
+
'-s', SERVICE_NAME,
|
|
62
|
+
'-a', account,
|
|
63
|
+
'-w', value,
|
|
64
|
+
'-T', '', // no apps pre-approved — will prompt on first read; user can approve for login session
|
|
65
|
+
'-l', `Clementine: ${envVar}`,
|
|
66
|
+
], { stdio: 'pipe' });
|
|
67
|
+
if (result.status !== 0) {
|
|
68
|
+
throw new Error(`security add-generic-password failed (code ${result.status}): ${result.stderr.toString().slice(0, 200)}`);
|
|
69
|
+
}
|
|
70
|
+
return makeRef(envVar);
|
|
71
|
+
}
|
|
72
|
+
/** Read a secret back. Returns undefined if missing or unreadable. */
|
|
73
|
+
export function get(envVar) {
|
|
74
|
+
if (!isAvailable())
|
|
75
|
+
return undefined;
|
|
76
|
+
const account = `${SERVICE_NAME}-${envVar}`;
|
|
77
|
+
const result = spawnSync('/usr/bin/security', [
|
|
78
|
+
'find-generic-password',
|
|
79
|
+
'-s', SERVICE_NAME,
|
|
80
|
+
'-a', account,
|
|
81
|
+
'-w',
|
|
82
|
+
], { stdio: 'pipe' });
|
|
83
|
+
if (result.status !== 0) {
|
|
84
|
+
// Exit code 44 = item not found — expected, don't log
|
|
85
|
+
if (result.status !== 44) {
|
|
86
|
+
logger.debug({ code: result.status, envVar }, 'keychain read non-zero');
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
const value = result.stdout.toString();
|
|
91
|
+
// security prints a trailing newline we need to strip
|
|
92
|
+
return value.replace(/\r?\n$/, '');
|
|
93
|
+
}
|
|
94
|
+
/** Delete a secret. No-op if it doesn't exist. */
|
|
95
|
+
export function remove(envVar) {
|
|
96
|
+
if (!isAvailable())
|
|
97
|
+
return false;
|
|
98
|
+
const account = `${SERVICE_NAME}-${envVar}`;
|
|
99
|
+
const result = spawnSync('/usr/bin/security', [
|
|
100
|
+
'delete-generic-password',
|
|
101
|
+
'-s', SERVICE_NAME,
|
|
102
|
+
'-a', account,
|
|
103
|
+
], { stdio: 'pipe' });
|
|
104
|
+
return result.status === 0;
|
|
105
|
+
}
|
|
106
|
+
/** List env var names that have keychain entries (best-effort). */
|
|
107
|
+
export function list() {
|
|
108
|
+
if (!isAvailable())
|
|
109
|
+
return [];
|
|
110
|
+
const result = spawnSync('/usr/bin/security', [
|
|
111
|
+
'dump-keychain',
|
|
112
|
+
], { stdio: 'pipe', timeout: 5000 });
|
|
113
|
+
if (result.status !== 0)
|
|
114
|
+
return [];
|
|
115
|
+
const out = result.stdout.toString();
|
|
116
|
+
const matches = out.matchAll(new RegExp(`"acct"<blob>="${SERVICE_NAME}-([^"]+)"`, 'g'));
|
|
117
|
+
return [...new Set([...matches].map(m => m[1]))];
|
|
118
|
+
}
|
|
119
|
+
//# sourceMappingURL=keychain.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Secret reference resolver.
|
|
3
|
+
*
|
|
4
|
+
* When code reads process.env.FOO, it should get the resolved SECRET VALUE
|
|
5
|
+
* regardless of whether that secret is stored plaintext in .env or as a
|
|
6
|
+
* keychain ref stub (`keychain:clementine-agent-FOO`). This module bridges
|
|
7
|
+
* the gap: called once at daemon boot, it walks process.env, detects ref
|
|
8
|
+
* stubs, resolves them via the appropriate backend, and replaces the stub
|
|
9
|
+
* with the real value in-place.
|
|
10
|
+
*
|
|
11
|
+
* After hydrate(), downstream code can treat process.env as usual — it has
|
|
12
|
+
* no knowledge of keychain/1Password/etc. This mirrors OpenClaw's SecretRef
|
|
13
|
+
* pattern but narrower: one backend (macOS Keychain), one convention.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Check every env var for a ref stub and resolve in place. Returns a summary
|
|
17
|
+
* of what was resolved / failed so startup can log visibility.
|
|
18
|
+
*/
|
|
19
|
+
export declare function hydrateSecretsFromEnv(): {
|
|
20
|
+
resolved: string[];
|
|
21
|
+
failed: string[];
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Lazy single-key lookup — reads process.env[key], resolves the ref if
|
|
25
|
+
* needed, returns the resolved value. Useful when code calls us at runtime
|
|
26
|
+
* rather than relying on startup hydration.
|
|
27
|
+
*/
|
|
28
|
+
export declare function resolveEnvValue(key: string): string | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* Classify a given value by its storage backend (for status reporting).
|
|
31
|
+
* Returns e.g. "keychain" or "env" or undefined (not set).
|
|
32
|
+
*/
|
|
33
|
+
export declare function secretBackend(envVar: string): 'keychain' | 'env' | undefined;
|
|
34
|
+
//# sourceMappingURL=resolver.d.ts.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Secret reference resolver.
|
|
3
|
+
*
|
|
4
|
+
* When code reads process.env.FOO, it should get the resolved SECRET VALUE
|
|
5
|
+
* regardless of whether that secret is stored plaintext in .env or as a
|
|
6
|
+
* keychain ref stub (`keychain:clementine-agent-FOO`). This module bridges
|
|
7
|
+
* the gap: called once at daemon boot, it walks process.env, detects ref
|
|
8
|
+
* stubs, resolves them via the appropriate backend, and replaces the stub
|
|
9
|
+
* with the real value in-place.
|
|
10
|
+
*
|
|
11
|
+
* After hydrate(), downstream code can treat process.env as usual — it has
|
|
12
|
+
* no knowledge of keychain/1Password/etc. This mirrors OpenClaw's SecretRef
|
|
13
|
+
* pattern but narrower: one backend (macOS Keychain), one convention.
|
|
14
|
+
*/
|
|
15
|
+
import pino from 'pino';
|
|
16
|
+
import * as keychain from './keychain.js';
|
|
17
|
+
const logger = pino({ name: 'clementine.secrets' });
|
|
18
|
+
/**
|
|
19
|
+
* Check every env var for a ref stub and resolve in place. Returns a summary
|
|
20
|
+
* of what was resolved / failed so startup can log visibility.
|
|
21
|
+
*/
|
|
22
|
+
export function hydrateSecretsFromEnv() {
|
|
23
|
+
const resolved = [];
|
|
24
|
+
const failed = [];
|
|
25
|
+
for (const [key, rawValue] of Object.entries(process.env)) {
|
|
26
|
+
if (!rawValue)
|
|
27
|
+
continue;
|
|
28
|
+
if (!keychain.isRef(rawValue))
|
|
29
|
+
continue;
|
|
30
|
+
const resolvedValue = keychain.get(key);
|
|
31
|
+
if (resolvedValue !== undefined) {
|
|
32
|
+
process.env[key] = resolvedValue;
|
|
33
|
+
resolved.push(key);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
failed.push(key);
|
|
37
|
+
// Leave the ref stub in place so downstream errors are obvious rather
|
|
38
|
+
// than silently returning the literal "keychain:..." as if it were a
|
|
39
|
+
// real API key.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (resolved.length > 0 || failed.length > 0) {
|
|
43
|
+
logger.info({ resolved: resolved.length, failed }, 'Secrets hydrated');
|
|
44
|
+
}
|
|
45
|
+
return { resolved, failed };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Lazy single-key lookup — reads process.env[key], resolves the ref if
|
|
49
|
+
* needed, returns the resolved value. Useful when code calls us at runtime
|
|
50
|
+
* rather than relying on startup hydration.
|
|
51
|
+
*/
|
|
52
|
+
export function resolveEnvValue(key) {
|
|
53
|
+
const raw = process.env[key];
|
|
54
|
+
if (!raw)
|
|
55
|
+
return undefined;
|
|
56
|
+
if (!keychain.isRef(raw))
|
|
57
|
+
return raw;
|
|
58
|
+
return keychain.get(key);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Classify a given value by its storage backend (for status reporting).
|
|
62
|
+
* Returns e.g. "keychain" or "env" or undefined (not set).
|
|
63
|
+
*/
|
|
64
|
+
export function secretBackend(envVar) {
|
|
65
|
+
const raw = process.env[envVar];
|
|
66
|
+
if (!raw)
|
|
67
|
+
return undefined;
|
|
68
|
+
return keychain.isRef(raw) ? 'keychain' : 'env';
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=resolver.js.map
|
|
@@ -17,6 +17,9 @@ import { z } from 'zod';
|
|
|
17
17
|
import { BASE_DIR, CRON_FILE, SYSTEM_DIR, env, getStore, logger, textResult, } from './shared.js';
|
|
18
18
|
import { getInteractionSource } from '../agent/hooks.js';
|
|
19
19
|
import { renameSync } from 'node:fs';
|
|
20
|
+
import * as keychain from '../secrets/keychain.js';
|
|
21
|
+
import * as authProfiles from '../secrets/auth-profiles.js';
|
|
22
|
+
import { classifyIntegrations, findIntegration, INTEGRATIONS } from '../config/integrations-registry.js';
|
|
20
23
|
function readEnvFile() { return env; }
|
|
21
24
|
// ── Env file management helpers (tools registered inside registerAdminTools) ──
|
|
22
25
|
const ENV_PATH = path.join(BASE_DIR, '.env');
|
|
@@ -113,10 +116,11 @@ export function registerAdminTools(server) {
|
|
|
113
116
|
return textResult(`Timer set. Reminder in ${minutes} minute${minutes !== 1 ? 's' : ''} (~${fireTime}): "${message}"`);
|
|
114
117
|
});
|
|
115
118
|
// ── Env self-configuration (owner-DM only) ────────────────────────────
|
|
116
|
-
server.tool('env_set', 'Save or update
|
|
119
|
+
server.tool('env_set', 'Save or update a credential (API key, token, config). Owner-DM only. On macOS it defaults to the login Keychain for security — the .env file only gets a reference stub. Changes take effect immediately; process.env gets the real value and the next tool call can use it. Use this when the owner gives a credential in chat — never tell them to hand-edit files.', {
|
|
117
120
|
key: z.string().describe('Env var name (uppercase with underscores, e.g. STRIPE_API_KEY)'),
|
|
118
121
|
value: z.string().describe('The value to store. Never echo back to the user; it will be masked in logs.'),
|
|
119
|
-
|
|
122
|
+
storage: z.enum(['keychain', 'env', 'auto']).optional().describe('Where to store it. "auto" (default) uses macOS Keychain when available, falls back to plain .env. "keychain" forces Keychain. "env" forces plaintext .env.'),
|
|
123
|
+
}, async ({ key, value, storage }) => {
|
|
120
124
|
const gate = requireOwnerDm();
|
|
121
125
|
if (!gate.ok)
|
|
122
126
|
return textResult(gate.message);
|
|
@@ -126,18 +130,41 @@ export function registerAdminTools(server) {
|
|
|
126
130
|
}
|
|
127
131
|
if (!value)
|
|
128
132
|
return textResult('Refused: empty value. Use env_unset to remove a key.');
|
|
133
|
+
const mode = storage ?? 'auto';
|
|
134
|
+
const useKeychain = (mode === 'keychain') || (mode === 'auto' && keychain.isAvailable());
|
|
135
|
+
if (mode === 'keychain' && !keychain.isAvailable()) {
|
|
136
|
+
return textResult('Refused: Keychain storage requested but macOS Keychain is unavailable on this system.');
|
|
137
|
+
}
|
|
129
138
|
const map = parseEnvFile();
|
|
130
139
|
const existed = map.has(normalizedKey);
|
|
131
|
-
|
|
140
|
+
let envFileValue;
|
|
141
|
+
let backendNote;
|
|
142
|
+
if (useKeychain) {
|
|
143
|
+
try {
|
|
144
|
+
envFileValue = keychain.set(normalizedKey, value);
|
|
145
|
+
backendNote = 'stored in macOS Keychain (service: clementine-agent); .env holds only a reference';
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
logger.warn({ err, key: normalizedKey }, 'Keychain write failed — falling back to plain .env');
|
|
149
|
+
envFileValue = value;
|
|
150
|
+
backendNote = `Keychain unavailable (${String(err).slice(0, 100)}) — stored plaintext in .env`;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
envFileValue = value;
|
|
155
|
+
backendNote = 'stored plaintext in .env';
|
|
156
|
+
}
|
|
157
|
+
map.set(normalizedKey, envFileValue);
|
|
132
158
|
try {
|
|
133
159
|
writeEnvFile(map);
|
|
134
160
|
}
|
|
135
161
|
catch (err) {
|
|
136
162
|
return textResult(`Failed to write .env: ${String(err).slice(0, 200)}`);
|
|
137
163
|
}
|
|
164
|
+
// process.env gets the REAL value regardless of backend, so tools can use it now
|
|
138
165
|
process.env[normalizedKey] = value;
|
|
139
|
-
logger.info({ key: normalizedKey, existed, masked: maskSecret(value) }, 'env_set');
|
|
140
|
-
return textResult(`${existed ? 'Updated' : 'Added'} ${normalizedKey}
|
|
166
|
+
logger.info({ key: normalizedKey, existed, masked: maskSecret(value), storage: useKeychain ? 'keychain' : 'env' }, 'env_set');
|
|
167
|
+
return textResult(`${existed ? 'Updated' : 'Added'} ${normalizedKey} (value: ${maskSecret(value)}). ${backendNote}. Active immediately — no restart needed.`);
|
|
141
168
|
});
|
|
142
169
|
server.tool('env_list', 'List the names of environment variables configured in ~/.clementine/.env. Returns key names only — values are masked. Owner-DM only.', {}, async () => {
|
|
143
170
|
const gate = requireOwnerDm();
|
|
@@ -149,7 +176,7 @@ export function registerAdminTools(server) {
|
|
|
149
176
|
const lines = [...map.entries()].map(([k, v]) => `- ${k} = ${maskSecret(v)}`);
|
|
150
177
|
return textResult(`Configured env vars (${map.size}):\n${lines.join('\n')}`);
|
|
151
178
|
});
|
|
152
|
-
server.tool('env_unset', 'Remove an environment variable
|
|
179
|
+
server.tool('env_unset', 'Remove an environment variable. Owner-DM only. Also clears from Keychain (if backed by Keychain) and from the running process.', {
|
|
153
180
|
key: z.string().describe('Env var name to remove'),
|
|
154
181
|
}, async ({ key }) => {
|
|
155
182
|
const gate = requireOwnerDm();
|
|
@@ -157,6 +184,7 @@ export function registerAdminTools(server) {
|
|
|
157
184
|
return textResult(gate.message);
|
|
158
185
|
const normalizedKey = key.trim();
|
|
159
186
|
const map = parseEnvFile();
|
|
187
|
+
const hadKeychain = map.has(normalizedKey) && keychain.isRef(map.get(normalizedKey) ?? '');
|
|
160
188
|
if (!map.has(normalizedKey))
|
|
161
189
|
return textResult(`${normalizedKey} is not set in ~/.clementine/.env`);
|
|
162
190
|
map.delete(normalizedKey);
|
|
@@ -166,9 +194,104 @@ export function registerAdminTools(server) {
|
|
|
166
194
|
catch (err) {
|
|
167
195
|
return textResult(`Failed to write .env: ${String(err).slice(0, 200)}`);
|
|
168
196
|
}
|
|
197
|
+
if (hadKeychain) {
|
|
198
|
+
try {
|
|
199
|
+
keychain.remove(normalizedKey);
|
|
200
|
+
}
|
|
201
|
+
catch { /* best-effort */ }
|
|
202
|
+
}
|
|
169
203
|
delete process.env[normalizedKey];
|
|
170
|
-
logger.info({ key: normalizedKey }, 'env_unset');
|
|
171
|
-
return textResult(`Removed ${normalizedKey}
|
|
204
|
+
logger.info({ key: normalizedKey, keychainCleared: hadKeychain }, 'env_unset');
|
|
205
|
+
return textResult(`Removed ${normalizedKey}${hadKeychain ? ' (including Keychain entry)' : ''}.`);
|
|
206
|
+
});
|
|
207
|
+
server.tool('env_list', 'List configured env vars with their storage backend (Keychain or plaintext) and masked values. Owner-DM only.', {}, async () => {
|
|
208
|
+
const gate = requireOwnerDm();
|
|
209
|
+
if (!gate.ok)
|
|
210
|
+
return textResult(gate.message);
|
|
211
|
+
const map = parseEnvFile();
|
|
212
|
+
if (map.size === 0)
|
|
213
|
+
return textResult('No env vars configured.');
|
|
214
|
+
const lines = [...map.entries()].map(([k, v]) => {
|
|
215
|
+
const backend = keychain.isRef(v) ? '[keychain]' : '[env] ';
|
|
216
|
+
const resolved = keychain.isRef(v) ? (keychain.get(k) ?? '(keychain read failed)') : v;
|
|
217
|
+
return `${backend} ${k} = ${maskSecret(resolved)}`;
|
|
218
|
+
});
|
|
219
|
+
return textResult(`Configured env vars (${map.size}):\n${lines.join('\n')}`);
|
|
220
|
+
});
|
|
221
|
+
// ── Integration registry tools ──────────────────────────────────────
|
|
222
|
+
server.tool('integration_status', 'Show which third-party integrations (Slack, Notion, Stripe, etc.) are configured, partial (some credentials set, others missing), or missing entirely. Use this when the owner asks "what\'s set up?" or when you need to know whether to expect a credential before attempting a tool call. Reports the declarative registry — do not invent integrations not listed here.', {
|
|
223
|
+
slug: z.string().optional().describe('Optional: specific integration slug to check (e.g. "slack"). If omitted, returns all.'),
|
|
224
|
+
}, async ({ slug }) => {
|
|
225
|
+
const slugs = slug ? [slug] : undefined;
|
|
226
|
+
const reports = classifyIntegrations(process.env, slugs);
|
|
227
|
+
if (reports.length === 0)
|
|
228
|
+
return textResult(`Unknown integration slug: ${slug}. Use list_integrations to see available.`);
|
|
229
|
+
const icon = (s) => s === 'configured' ? '✓' : s === 'partial' ? '~' : '✗';
|
|
230
|
+
const lines = reports.map(r => {
|
|
231
|
+
const base = `${icon(r.status)} ${r.label} (${r.slug}) — ${r.status}`;
|
|
232
|
+
const detail = [];
|
|
233
|
+
if (r.have.length > 0)
|
|
234
|
+
detail.push(`have: ${r.have.join(', ')}`);
|
|
235
|
+
if (r.missing.length > 0)
|
|
236
|
+
detail.push(`missing: ${r.missing.join(', ')}`);
|
|
237
|
+
if (r.optionalMissing.length > 0)
|
|
238
|
+
detail.push(`optional not set: ${r.optionalMissing.join(', ')}`);
|
|
239
|
+
return detail.length > 0 ? `${base}\n ${detail.join(' | ')}` : base;
|
|
240
|
+
});
|
|
241
|
+
return textResult(lines.join('\n'));
|
|
242
|
+
});
|
|
243
|
+
server.tool('list_integrations', 'List every integration Clementine knows how to configure, with one-line descriptions. Use this to answer "what can I hook up?" or to find the slug for setup_integration.', {}, async () => {
|
|
244
|
+
const lines = INTEGRATIONS.map(i => {
|
|
245
|
+
const required = i.requirements.filter(r => r.required).map(r => r.envVar).join(', ');
|
|
246
|
+
return `- **${i.label}** (\`${i.slug}\`, ${i.kind}): ${i.description}${required ? ` — requires ${required}` : ''}`;
|
|
247
|
+
});
|
|
248
|
+
return textResult(`Available integrations (${INTEGRATIONS.length}):\n${lines.join('\n')}`);
|
|
249
|
+
});
|
|
250
|
+
server.tool('setup_integration', 'Get the step-by-step setup info for an integration: required env vars, doc URLs for where to find each credential, and current status. Use this when the owner says "set up Slack" or similar — call this first, then walk them through each missing credential conversationally and save each one with env_set. Never guess at required env vars; trust this registry.', {
|
|
251
|
+
slug: z.string().describe('Integration slug from list_integrations (e.g. "slack", "notion", "stripe")'),
|
|
252
|
+
}, async ({ slug }) => {
|
|
253
|
+
const integration = findIntegration(slug);
|
|
254
|
+
if (!integration) {
|
|
255
|
+
return textResult(`Unknown integration slug: ${slug}. Run list_integrations to see what's available.`);
|
|
256
|
+
}
|
|
257
|
+
const [status] = classifyIntegrations(process.env, [integration.slug]);
|
|
258
|
+
const lines = [];
|
|
259
|
+
lines.push(`## ${integration.label} (${integration.slug})`);
|
|
260
|
+
lines.push('');
|
|
261
|
+
lines.push(integration.description);
|
|
262
|
+
if (integration.docUrl)
|
|
263
|
+
lines.push(`Docs: ${integration.docUrl}`);
|
|
264
|
+
if (integration.capabilities?.length)
|
|
265
|
+
lines.push(`Unlocks: ${integration.capabilities.join(', ')}`);
|
|
266
|
+
lines.push('');
|
|
267
|
+
lines.push(`**Current status:** ${status.status}${status.have.length ? ` (have ${status.have.join(', ')})` : ''}`);
|
|
268
|
+
lines.push('');
|
|
269
|
+
lines.push('**Credentials:**');
|
|
270
|
+
for (const req of integration.requirements) {
|
|
271
|
+
const present = !!process.env[req.envVar];
|
|
272
|
+
const badge = present ? '✓ set' : (req.required ? '✗ REQUIRED' : '○ optional');
|
|
273
|
+
const line = `- \`${req.envVar}\` — ${req.label} [${badge}]`;
|
|
274
|
+
lines.push(line);
|
|
275
|
+
if (!present && req.docUrl)
|
|
276
|
+
lines.push(` → ${req.docUrl}`);
|
|
277
|
+
}
|
|
278
|
+
lines.push('');
|
|
279
|
+
lines.push('Next step: ask the owner for each unset REQUIRED credential, one at a time, and save each with env_set. Quote the doc URL when you ask so they know where to find it. After saving the last one, run integration_status to confirm "configured".');
|
|
280
|
+
return textResult(lines.join('\n'));
|
|
281
|
+
});
|
|
282
|
+
// ── OAuth profile tools ────────────────────────────────────────────
|
|
283
|
+
server.tool('auth_profile_status', 'Show stored OAuth profiles (for providers that use OAuth, not just API keys). Reports which accounts are authenticated and when tokens expire. Owner-DM only.', {}, async () => {
|
|
284
|
+
const gate = requireOwnerDm();
|
|
285
|
+
if (!gate.ok)
|
|
286
|
+
return textResult(gate.message);
|
|
287
|
+
const statuses = authProfiles.statusAll();
|
|
288
|
+
if (statuses.length === 0)
|
|
289
|
+
return textResult('No OAuth profiles stored. (API-key integrations are tracked via integration_status instead.)');
|
|
290
|
+
const lines = statuses.map(s => {
|
|
291
|
+
const exp = s.expiresInMinutes === null ? 'no expiry' : s.expiresInMinutes < 0 ? `expired ${Math.abs(s.expiresInMinutes)}m ago` : `expires in ${s.expiresInMinutes}m`;
|
|
292
|
+
return `- ${s.provider}${s.accountId ? ` (${s.accountId})` : ''}: ${s.valid ? '✓' : '✗'} ${exp}`;
|
|
293
|
+
});
|
|
294
|
+
return textResult(`OAuth profiles:\n${lines.join('\n')}`);
|
|
172
295
|
});
|
|
173
296
|
// ── Self-service tool whitelist ────────────────────────────────────────
|
|
174
297
|
const ALLOWED_TOOLS_EXTRA = path.join(BASE_DIR, 'allowed-tools-extra.json');
|