@flame0510/project-aether 1.1.15 → 1.3.0

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.
Files changed (60) hide show
  1. package/README.md +2 -1
  2. package/app/agents/ModelSection.tsx +313 -0
  3. package/app/agents/PageClient.tsx +83 -4
  4. package/app/agents/create/page.tsx +8 -21
  5. package/app/api/agents/[id]/model/route.ts +113 -0
  6. package/app/api/agents/[id]/recreate/route.ts +10 -34
  7. package/app/api/agents/[id]/route.ts +10 -29
  8. package/app/api/agents/create/route.ts +59 -57
  9. package/app/api/agents/models-summary/route.ts +163 -0
  10. package/app/api/assistant/route.ts +36 -15
  11. package/app/api/credentials/[id]/sync/route.ts +3 -3
  12. package/app/api/credentials/detect/route.ts +126 -176
  13. package/app/api/credentials/route.ts +3 -0
  14. package/app/api/gateway/agent/route.ts +23 -6
  15. package/app/api/gateway/provider/keys.ts +13 -1
  16. package/app/api/gateway/provider/route.ts +43 -12
  17. package/app/api/gateway/sync.ts +248 -72
  18. package/app/api/models/route.ts +28 -34
  19. package/app/api/provider/auth.ts +65 -0
  20. package/app/api/provider/upstream.ts +9 -2
  21. package/app/api/provider/v1/chat/completions/route.ts +22 -16
  22. package/app/api/provider/v1/models/route.ts +26 -133
  23. package/app/components/PulseChat.tsx +25 -39
  24. package/app/components/Skeleton.tsx +132 -0
  25. package/app/components/ui/RemoveButton.tsx +46 -0
  26. package/app/components/ui/Select.tsx +3 -2
  27. package/app/components/ui/index.ts +1 -0
  28. package/app/credentials/PageClient.tsx +461 -140
  29. package/app/credentials/loading.tsx +19 -5
  30. package/app/gateway/PageClient.tsx +257 -673
  31. package/app/globals.css +8 -0
  32. package/app/lib/models-context.tsx +43 -7
  33. package/app/wizard/useWizard.ts +6 -1
  34. package/bin/rev4a.js +73 -9
  35. package/docs/ARCHITECTURE.md +92 -33
  36. package/docs/FRONTEND-ARCHITECTURE.md +36 -6
  37. package/docs/REV4A.md +62 -30
  38. package/docs/dev/API-REFERENCE.md +490 -227
  39. package/docs/dev/DATABASE.md +8 -3
  40. package/docs/dev/GATEWAY.md +236 -92
  41. package/docs/dev/PROVIDERS.md +44 -44
  42. package/docs/rag/DATA-FRESHNESS.md +57 -28
  43. package/docs/rag/GLOSSARY.md +20 -18
  44. package/docs/rag/REV4A-OVERVIEW.md +28 -32
  45. package/docs/rag/WHAT-I-CAN-ANSWER.md +10 -12
  46. package/instrumentation.ts +9 -1
  47. package/lib/agent-readiness.ts +110 -0
  48. package/lib/channelManager.ts +64 -22
  49. package/lib/container-file.ts +27 -0
  50. package/lib/credentials/delivery.ts +212 -119
  51. package/lib/credentials/detect.ts +229 -97
  52. package/lib/credentials/providers.ts +38 -7
  53. package/lib/credentials/vault.ts +78 -13
  54. package/lib/docker-exec.ts +50 -14
  55. package/lib/model-catalogue.ts +140 -27
  56. package/lib/rev4a-paths.ts +0 -21
  57. package/model-pricing.json +118 -110
  58. package/models.config.json +27 -12
  59. package/package.json +1 -1
  60. package/app/api/gateway/route.ts +0 -191
@@ -1,6 +1,10 @@
1
- import { execSync } from 'child_process';
1
+ import { execFile } from 'child_process';
2
+ import { promisify } from 'util';
3
+ import { dockerExecShellNoFail } from '@/lib/docker-exec';
2
4
  import { hashToken } from './hash';
3
5
 
6
+ const execFileAsync = promisify(execFile);
7
+
4
8
  export interface AgentContainer {
5
9
  name: string;
6
10
  agentId: string;
@@ -11,14 +15,14 @@ export interface AgentContainer {
11
15
  * Shared by the detect API route and the credential delivery layer so both
12
16
  * reason over the exact same set of containers.
13
17
  */
14
- export function listAgentContainers(): AgentContainer[] {
18
+ export async function listAgentContainers(): Promise<AgentContainer[]> {
15
19
  try {
16
- const out = execSync(
17
- "docker ps --filter label=AGENT_ID --format '{{.Names}}\t{{.Label \"AGENT_ID\"}}'",
18
- { timeout: 5000, encoding: 'utf-8', maxBuffer: 64 * 1024 },
19
- ).trim();
20
- if (!out) return [];
21
- return out.split('\n').filter(Boolean).map((line) => {
20
+ const { stdout } = await execFileAsync(
21
+ 'docker',
22
+ ['ps', '--filter', 'label=AGENT_ID', '--format', '{{.Names}}\t{{.Label "AGENT_ID"}}'],
23
+ { encoding: 'utf-8', timeout: 5000, maxBuffer: 64 * 1024 },
24
+ );
25
+ return stdout.trim().split('\n').filter(Boolean).map((line) => {
22
26
  const [name, agentId] = line.split('\t');
23
27
  return { name: name ?? '', agentId: agentId ?? name ?? '' };
24
28
  });
@@ -27,55 +31,164 @@ export function listAgentContainers(): AgentContainer[] {
27
31
  }
28
32
  }
29
33
 
30
- /** Run a shell snippet inside a container, parsing stdout with a fallback on error. */
31
- export function execInContainer<T>(
32
- container: string,
33
- script: string,
34
- parse: (raw: string) => T,
35
- fallback: T,
36
- ): T {
37
- try {
38
- const raw = execSync(
39
- `docker exec ${container.replace(/[^a-zA-Z0-9_.-]/g, '')} sh -c ${JSON.stringify(script)}`,
40
- { timeout: 10000, encoding: 'utf-8', shell: '/bin/bash' },
41
- ).trim();
42
- return parse(raw);
43
- } catch {
44
- return fallback;
45
- }
46
- }
34
+ // ── Probing ─────────────────────────────────────────────────────────────────
47
35
 
48
- /** Trello: presence of the config file marks the CLI as authenticated. */
49
- export function detectTrello(container: string): { loggedIn: boolean } {
50
- const hasConfig = execInContainer(
51
- container,
52
- 'test -f /root/.trello-cli/default/config.json && echo yes || echo no',
53
- (r) => r.trim(),
54
- 'no',
55
- );
56
- return { loggedIn: hasConfig === 'yes' };
36
+ /** Fields the probe script emits, in order. */
37
+ export interface ContainerProbe {
38
+ cli: Record<'gh' | 'vercel' | 'supabase' | 'trello', boolean>;
39
+ ghStatus: string;
40
+ vercelWhoami: string;
41
+ supabaseProjects: string;
42
+ /** Exit status of `supabase projects list`. -1 when the probe never got there. */
43
+ supabaseRc: number;
44
+ agentName: string;
45
+ toolsMdMarkers: number;
46
+ /** Raw file contents, parsed by the caller. '' when absent or unreadable. */
47
+ files: Record<
48
+ 'ghHosts' | 'vercelAuth' | 'vercelConfig' | 'supabaseToken' | 'supabaseConfig' | 'trelloConfig' | 'notionConfig',
49
+ string
50
+ >;
51
+ /** False when the probe produced nothing at all — container gone, or it hung. */
52
+ ok: boolean;
57
53
  }
58
54
 
59
- /** Notion: no CLI — just check if the config file exists (same pattern as Trello). */
60
- export function detectNotion(container: string): { loggedIn: boolean } {
61
- const hasConfig = execInContainer(
62
- container,
63
- 'test -f /root/.config/notion/config.json && echo yes || echo no',
64
- (r) => r.trim(),
65
- 'no',
66
- );
67
- return { loggedIn: hasConfig === 'yes' };
68
- }
69
- export function cliInstalled(container: string, name: string): boolean {
70
- const raw = execInContainer(
71
- container,
72
- `command -v ${name} >/dev/null 2>&1 && echo yes || echo no`,
73
- (r) => r.trim(),
74
- 'no',
75
- );
76
- return raw === 'yes';
55
+ /**
56
+ * One `docker exec` per container instead of twenty-one.
57
+ *
58
+ * Every probe used to be its own process, ~21 per container, each paying the
59
+ * cost of starting `docker exec` — measured here at ~90ms, so roughly 1.9s of
60
+ * pure process startup before any probe did work.
61
+ *
62
+ * The point of collapsing them is not the probe's own wall time. Measured on
63
+ * Docker Desktop, this script costs ~6.5s per container, and ~6.4s of that is
64
+ * the three CLI calls at the end reaching the network from inside the container
65
+ * — which no amount of restructuring here makes faster. The gain is that the
66
+ * twenty-one were `execSync`: each one blocked Node's single thread, so every
67
+ * other request on the dashboard stopped for the duration. That is what this
68
+ * removed. Compare figures only within one host; the network share dominates
69
+ * and moves with the machine.
70
+ *
71
+ * Two properties of the script are load-bearing, and breaking either one loses
72
+ * far more than it saves:
73
+ *
74
+ * - **No command may leave a non-zero status standing, and the script ends in
75
+ * `exit 0`.** `dockerExecShellNoFail` returns '' for the *entire* script on
76
+ * any non-zero exit, so a single command failing — and `gh auth status` exits
77
+ * non-zero simply when logged out — would discard all the other fields with
78
+ * it. Most commands satisfy this with a trailing `|| true`; `supabase
79
+ * projects list` instead assigns into a variable, which both neutralises the
80
+ * status and preserves it in `$?` for the caller to read.
81
+ * - **NUL delimits the records.** A shell variable cannot hold a NUL byte, and
82
+ * none of the sources here (YAML, JSON, TOML, CLI stdout, ANSI escapes) emit
83
+ * one, so no value can forge a record boundary. This mirrors `readSkillsBulk`
84
+ * in `app/api/skills/route.js`, which has carried the same guarantee in
85
+ * production.
86
+ *
87
+ * What this does *not* preserve is per-probe timeout isolation: previously each
88
+ * command had its own 10s budget, and now one hung CLI costs the whole
89
+ * container's read. That is the deliberate trade — with `…NoFail` the damage
90
+ * stops at that container instead of blanking the fleet.
91
+ */
92
+ const PROBE_SCRIPT = [
93
+ 'k(){ printf "%s\\0" "$1"; }',
94
+ 'e(){ printf "\\0"; }',
95
+ 'for t in gh vercel supabase trello; do',
96
+ ' k "cli:$t"; if command -v "$t" >/dev/null 2>&1; then printf yes; else printf no; fi; e',
97
+ 'done',
98
+ // Local reads first. They are what decides whether a stored credential is
99
+ // installed, and they cost nothing. The three CLI calls below reach the
100
+ // network from inside the container and are the only plausible cause of a
101
+ // timeout — putting them last means a truncated run loses only the "logged
102
+ // in" niceties and keeps the token matching, instead of the other way round.
103
+ 'k file:ghHosts; cat /root/.config/gh/hosts.yml 2>/dev/null || true; e',
104
+ 'k file:vercelAuth; cat /root/.local/share/com.vercel.cli/auth.json 2>/dev/null || true; e',
105
+ 'k file:vercelConfig; cat /root/.local/share/com.vercel.cli/config.json 2>/dev/null || true; e',
106
+ 'k file:supabaseToken; cat /root/.supabase/access-token 2>/dev/null || true; e',
107
+ 'k file:supabaseConfig; cat /data/supabase/config.toml 2>/dev/null || true; e',
108
+ 'k file:trelloConfig; cat /root/.config/trello/config.json 2>/dev/null || true; e',
109
+ 'k file:notionConfig; cat /root/.config/notion/config.json 2>/dev/null || true; e',
110
+ 'k agent:name; printenv AGENT_NAME || true; e',
111
+ 'k tools:markers; grep -c "REV4A:INTEGRATIONS:START" /root/.openclaw/workspace/TOOLS.md 2>/dev/null || true; e',
112
+ // Sentinel, placed here rather than at the very end. A killed `docker exec`
113
+ // does not necessarily reject: verified directly against a real container,
114
+ // `dockerExecShellNoFail` resolves with whatever the script had already
115
+ // printed when the process is killed for running past its timeout — so a
116
+ // sentinel after the network calls discarded genuinely-captured local data
117
+ // whenever only those calls hung. Putting it here means the token-matching
118
+ // data survives a hang in any of the three CLI calls below; only their
119
+ // "logged in as" niceties are lost, and every consumer of `ghStatus` /
120
+ // `vercelWhoami` / `supabaseProjects` already treats an empty string as
121
+ // "not logged in", not as a crash or a false positive.
122
+ 'k probe:complete; printf ok; e',
123
+ 'k gh:status; gh auth status 2>&1 || true; e',
124
+ 'k vercel:whoami; vercel whoami 2>&1 || true; e',
125
+ // Captured into a variable so the exit status survives: `|| true` discarded it,
126
+ // and the only thing left to judge by was the text on stdout. Every failure
127
+ // whose wording did not happen to contain "Unauthorized" — no token at all, a
128
+ // container with no route to the network — then read as a successful listing,
129
+ // and the agent was reported as logged in to Supabase when it held nothing.
130
+ 'sbout=$(supabase projects list --output json 2>&1); sbrc=$?',
131
+ 'k supabase:projects; printf %s "$sbout"; e',
132
+ 'k supabase:rc; printf %s "$sbrc"; e',
133
+ 'exit 0',
134
+ ].join('\n');
135
+
136
+ const EMPTY_PROBE: ContainerProbe = {
137
+ cli: { gh: false, vercel: false, supabase: false, trello: false },
138
+ ghStatus: '', vercelWhoami: '', supabaseProjects: '', supabaseRc: -1, agentName: '',
139
+ toolsMdMarkers: 0,
140
+ files: {
141
+ ghHosts: '', vercelAuth: '', vercelConfig: '',
142
+ supabaseToken: '', supabaseConfig: '', trelloConfig: '', notionConfig: '',
143
+ },
144
+ ok: false,
145
+ };
146
+
147
+ export async function probeContainer(container: string): Promise<ContainerProbe> {
148
+ const raw = await dockerExecShellNoFail(container, PROBE_SCRIPT, {
149
+ timeoutMs: 25_000,
150
+ maxBuffer: 4 * 1024 * 1024,
151
+ });
152
+ if (!raw) return { ...EMPTY_PROBE, files: { ...EMPTY_PROBE.files }, cli: { ...EMPTY_PROBE.cli } };
153
+
154
+ const parts = raw.split('\0');
155
+ const map = new Map<string, string>();
156
+ for (let i = 0; i + 1 < parts.length; i += 2) map.set(parts[i], parts[i + 1]);
157
+
158
+ const g = (k: string) => map.get(k) ?? '';
159
+ if (g('probe:complete').trim() !== 'ok') {
160
+ return { ...EMPTY_PROBE, files: { ...EMPTY_PROBE.files }, cli: { ...EMPTY_PROBE.cli } };
161
+ }
162
+ return {
163
+ cli: {
164
+ gh: g('cli:gh').trim() === 'yes',
165
+ vercel: g('cli:vercel').trim() === 'yes',
166
+ supabase: g('cli:supabase').trim() === 'yes',
167
+ trello: g('cli:trello').trim() === 'yes',
168
+ },
169
+ ghStatus: g('gh:status'),
170
+ vercelWhoami: g('vercel:whoami'),
171
+ supabaseProjects: g('supabase:projects'),
172
+ // A hang in the call kills the script before either key is emitted, so an
173
+ // unparseable status has to mean failure — never success by default.
174
+ supabaseRc: /^\d+$/.test(g('supabase:rc').trim()) ? parseInt(g('supabase:rc').trim(), 10) : -1,
175
+ agentName: g('agent:name').trim(),
176
+ toolsMdMarkers: parseInt(g('tools:markers').trim(), 10) || 0,
177
+ files: {
178
+ ghHosts: g('file:ghHosts'),
179
+ vercelAuth: g('file:vercelAuth'),
180
+ vercelConfig: g('file:vercelConfig'),
181
+ supabaseToken: g('file:supabaseToken'),
182
+ supabaseConfig: g('file:supabaseConfig'),
183
+ trelloConfig: g('file:trelloConfig'),
184
+ notionConfig: g('file:notionConfig'),
185
+ },
186
+ ok: true,
187
+ };
77
188
  }
78
189
 
190
+ // ── Token extraction, in JavaScript ─────────────────────────────────────────
191
+
79
192
  /**
80
193
  * Secret field that holds the primary token used to match a stored profile
81
194
  * against what is installed in a container, per provider.
@@ -95,55 +208,74 @@ export function profileTokenHash(secret: Record<string, string>, providerId: str
95
208
  return val ? hashToken(val) : '';
96
209
  }
97
210
 
211
+ function jsonField(raw: string, key: string): string {
212
+ if (!raw.trim()) return '';
213
+ try {
214
+ const v = (JSON.parse(raw) as Record<string, unknown>)[key];
215
+ return typeof v === 'string' ? v : '';
216
+ } catch {
217
+ return '';
218
+ }
219
+ }
220
+
221
+ /** `oauth_token:` out of gh's hosts.yml. Two levels deep, so no YAML parser. */
222
+ function ghToken(raw: string): string {
223
+ for (const line of raw.split('\n')) {
224
+ const m = /^\s*oauth_token:\s*(.+?)\s*$/.exec(line);
225
+ if (m) return m[1];
226
+ }
227
+ return '';
228
+ }
229
+
98
230
  /**
99
- * Read the token currently installed for a provider inside a container and hash it.
100
- * Returns '' when nothing is installed or the token cannot be read — callers must
101
- * treat '' as "unknown / no match" and never as a wildcard.
231
+ * `project_id` out of supabase's config.toml.
232
+ *
233
+ * This replaces a `sed` expression that never worked. It was written inside a
234
+ * single-quoted JS string using backslash-escaped double quotes, which in
235
+ * JavaScript collapse to bare quotes; those closed the shell's quoting early, so
236
+ * the semicolon separating the two substitutions was read as a command
237
+ * separator and the shell tried to execute the second one as a program. Every
238
+ * detection logged `sh: 1: not found` for it, and the field came back empty.
102
239
  */
103
- export function providerTokenHash(container: string, providerId: string): string {
104
- let raw = '';
240
+ function tomlProjectId(raw: string): string {
241
+ for (const line of raw.split('\n')) {
242
+ const m = /^\s*project_id\s*=\s*"([^"]*)"/.exec(line);
243
+ if (m) return m[1];
244
+ }
245
+ return '';
246
+ }
247
+
248
+ /**
249
+ * Vercel's `currentTeam`.
250
+ *
251
+ * The previous `sed 's/.*: *"//'` was greedy: on a single line holding several
252
+ * key/value pairs it matched up to the *last* `": "` and returned whichever
253
+ * value came after, not currentTeam's. It went unnoticed only because the real
254
+ * config.json happens to be pretty-printed — correctness resting on a third
255
+ * party's formatting choice.
256
+ */
257
+ export function extractInstalledToken(probe: ContainerProbe, providerId: string): string {
105
258
  switch (providerId) {
106
- case 'github-pat':
107
- raw = execInContainer(
108
- container,
109
- "cat /root/.config/gh/hosts.yml 2>/dev/null | grep 'oauth_token:' | head -1 | sed 's/.*: *//'",
110
- (r) => r.trim(),
111
- '',
112
- );
113
- break;
114
- case 'vercel':
115
- raw = execInContainer(
116
- container,
117
- "cat /root/.local/share/com.vercel.cli/auth.json 2>/dev/null | grep -oE '\"token\"[^,}]*' | head -1 | grep -oE '\"[^\"]*\"$' | tr -d '\"'",
118
- (r) => r.trim(),
119
- '',
120
- );
121
- break;
122
- case 'supabase':
123
- raw = execInContainer(
124
- container,
125
- 'cat /root/.supabase/access-token 2>/dev/null | head -1',
126
- (r) => r.trim(),
127
- '',
128
- );
129
- break;
130
- case 'trello':
131
- raw = execInContainer(
132
- container,
133
- "cat /root/.trello-cli/default/config.json 2>/dev/null | grep -oE '\"token\"[^,}]*' | head -1 | grep -oE '\"[^\"]*\"$' | tr -d '\"'",
134
- (r) => r.trim(),
135
- '',
136
- );
137
- break;
138
- case 'notion':
139
- raw = execInContainer(
140
- container,
141
- "cat /root/.config/notion/config.json 2>/dev/null | grep -oE '\"token\"[^,}]*' | head -1 | grep -oE '\"[^\"]*\"$' | tr -d '\"'",
142
- (r) => r.trim(),
143
- '',
144
- );
145
- break;
259
+ case 'github-pat': return ghToken(probe.files.ghHosts);
260
+ case 'vercel': return jsonField(probe.files.vercelAuth, 'token');
261
+ case 'supabase': return probe.files.supabaseToken.split('\n')[0]?.trim() ?? '';
262
+ case 'trello': return jsonField(probe.files.trelloConfig, 'token');
263
+ case 'notion': return jsonField(probe.files.notionConfig, 'token');
264
+ default: return '';
146
265
  }
266
+ }
267
+
268
+ /** Hash of the token currently installed for a provider ('' when unreadable). */
269
+ export function providerTokenHash(probe: ContainerProbe, providerId: string): string {
270
+ const raw = extractInstalledToken(probe, providerId);
147
271
  if (!raw || raw.length < 10) return '';
148
272
  return hashToken(raw);
149
273
  }
274
+
275
+ export function vercelCurrentTeam(probe: ContainerProbe): string {
276
+ return jsonField(probe.files.vercelConfig, 'currentTeam');
277
+ }
278
+
279
+ export function supabaseProjectRef(probe: ContainerProbe): string {
280
+ return tomlProjectId(probe.files.supabaseConfig);
281
+ }
@@ -4,6 +4,15 @@ export interface CredentialField {
4
4
  placeholder: string;
5
5
  type: 'text' | 'password';
6
6
  hint?: string;
7
+ /**
8
+ * Where this specific value is created. Rendered as a link next to the field
9
+ * in the add/edit modal. The provider-level `docsUrl` sits on the card
10
+ * outside the modal, so without this the form itself offers no way out to go
11
+ * and fetch the value it is asking for.
12
+ */
13
+ docsUrl?: string;
14
+ /** Link text, minus the trailing arrow the UI adds. Defaults to `docs`. */
15
+ docsLabel?: string;
7
16
  }
8
17
 
9
18
  export interface CredentialProviderDef {
@@ -30,7 +39,9 @@ export const CREDENTIAL_PROVIDERS: CredentialProviderDef[] = [
30
39
  label: 'Personal Access Token',
31
40
  placeholder: 'ghp_...',
32
41
  type: 'password',
33
- hint: 'Requires repo, read:org scopes at minimum',
42
+ hint: 'Requires repo, read:org scopes at minimum.',
43
+ docsUrl: 'https://github.com/settings/tokens',
44
+ docsLabel: 'Create token',
34
45
  },
35
46
  ],
36
47
  deliveryMethod: 'gh auth login --with-token',
@@ -43,10 +54,24 @@ export const CREDENTIAL_PROVIDERS: CredentialProviderDef[] = [
43
54
  description: 'API Key and Token for Trello board access',
44
55
  authType: 'api-key-pair',
45
56
  fields: [
46
- { key: 'apiKey', label: 'API Key', placeholder: 'd3c4a0fee...', type: 'password' },
47
- { key: 'token', label: 'Token', placeholder: 'ATTA...', type: 'password', hint: 'Click "Generate Trello Token" below to authorize' },
57
+ {
58
+ key: 'apiKey',
59
+ label: 'API Key',
60
+ placeholder: 'd3c4a0fee...',
61
+ type: 'password',
62
+ hint: 'Create a Power-Up, then copy its API key.',
63
+ docsUrl: 'https://trello.com/power-ups/admin',
64
+ docsLabel: 'Create API key',
65
+ },
66
+ {
67
+ key: 'token',
68
+ label: 'Token',
69
+ placeholder: 'ATTA...',
70
+ type: 'password',
71
+ hint: 'Authorize Rev4a with the button below, then paste the token Trello returns.',
72
+ },
48
73
  ],
49
- deliveryMethod: 'writes ~/.trello-cli/default/config.json',
74
+ deliveryMethod: 'writes ~/.config/trello/config.json',
50
75
  docsUrl: 'https://trello.com/power-ups/admin',
51
76
  },
52
77
  {
@@ -61,7 +86,9 @@ export const CREDENTIAL_PROVIDERS: CredentialProviderDef[] = [
61
86
  label: 'Access Token',
62
87
  placeholder: 'vcp_...',
63
88
  type: 'password',
64
- hint: 'Create in Vercel Settings → Tokens',
89
+ hint: 'Create in Vercel Settings → Tokens.',
90
+ docsUrl: 'https://vercel.com/account/tokens',
91
+ docsLabel: 'Create token',
65
92
  },
66
93
  ],
67
94
  deliveryMethod: 'writes ~/.local/share/com.vercel.cli/auth.json',
@@ -79,7 +106,9 @@ export const CREDENTIAL_PROVIDERS: CredentialProviderDef[] = [
79
106
  label: 'Access Token',
80
107
  placeholder: 'sbp_...',
81
108
  type: 'password',
82
- hint: 'Supabase CLI personal access token. Create at supabase.com/dashboard/account/tokens. Grants access to all your projects for migrations, edge functions, and database management.',
109
+ hint: 'Grants access to every project on the account — migrations, edge functions, database management.',
110
+ docsUrl: 'https://supabase.com/dashboard/account/tokens',
111
+ docsLabel: 'Create token',
83
112
  },
84
113
  ],
85
114
  deliveryMethod: 'supabase login --token *** --no-browser',
@@ -97,7 +126,9 @@ export const CREDENTIAL_PROVIDERS: CredentialProviderDef[] = [
97
126
  label: 'API Token (Internal Integration)',
98
127
  placeholder: 'ntn_... or secret_...',
99
128
  type: 'password',
100
- hint: 'Create at notion.so/my-integrations. Grant Read + Insert + Update capabilities.',
129
+ hint: 'Grant Read + Insert + Update capabilities.',
130
+ docsUrl: 'https://www.notion.so/my-integrations',
131
+ docsLabel: 'Create integration',
101
132
  },
102
133
  ],
103
134
  deliveryMethod: 'writes ~/.config/notion/config.json',
@@ -125,19 +125,47 @@ export function updateProfile(
125
125
  const now = Date.now();
126
126
  const db = openDb(false);
127
127
  try {
128
- // Only update secret fields if at least one field has a non-empty value
129
- const hasValues = Object.values(secret).some((v) => v && v.trim().length > 0);
128
+ // Only the fields the form actually filled in. The edit modal opens with
129
+ // every secret input blank — it never prefills a stored secret — so a blank
130
+ // field means "leave this one alone", not "clear it".
131
+ const provided = Object.entries(secret)
132
+ .filter(([, v]) => v && v.trim().length > 0)
133
+ .reduce<CredentialSecret>((acc, [k, v]) => { acc[k] = v; return acc; }, {});
130
134
 
131
- if (hasValues) {
135
+ if (Object.keys(provided).length > 0) {
132
136
  const provider = getProvider(profile.providerId);
133
137
  if (!provider) throw new Error(`Unknown provider: ${profile.providerId}`);
134
138
 
139
+ const row = db.prepare(
140
+ 'SELECT payload FROM credential_secrets WHERE profile_id = ?',
141
+ ).get(id) as { payload: string } | undefined;
142
+
143
+ let existing: CredentialSecret = {};
144
+ try {
145
+ existing = row ? (JSON.parse(row.payload) as CredentialSecret) : {};
146
+ } catch {
147
+ existing = {};
148
+ }
149
+
150
+ // Merged onto what is stored, not substituted for it. Rotating only the
151
+ // Trello `token` used to write a payload holding that one field, dropping
152
+ // `apiKey` — and every later sync then failed with "Missing Trello API key
153
+ // or token". Trello is the only two-field provider, and the move to the
154
+ // REST endpoint made both fields load-bearing.
155
+ const merged: CredentialSecret = { ...existing, ...provided };
156
+ const payload = JSON.stringify(merged);
157
+ const secretChanged = payload !== (row?.payload ?? '');
158
+
135
159
  const maskKey = firstPasswordField(provider);
136
- const secretMasked = mask(maskKey ? (secret[maskKey] ?? '') : '');
137
- const payload = JSON.stringify(secret);
160
+ const secretMasked = mask(maskKey ? (merged[maskKey] ?? '') : '');
138
161
 
139
162
  db.prepare(
140
- 'UPDATE credential_profiles SET label = ?, secret_masked = ?, updated_at = ? WHERE id = ?',
163
+ secretChanged
164
+ // A stored secret the agents no longer hold has not been delivered, so
165
+ // the row must stop reading "synced 2d ago" the moment it changes.
166
+ // detect flags those same agents ◆ on its next pass.
167
+ ? 'UPDATE credential_profiles SET label = ?, secret_masked = ?, updated_at = ?, last_synced_at = NULL WHERE id = ?'
168
+ : 'UPDATE credential_profiles SET label = ?, secret_masked = ?, updated_at = ? WHERE id = ?',
141
169
  ).run(label, secretMasked, now, id);
142
170
 
143
171
  db.prepare(
@@ -167,6 +195,30 @@ export function deleteProfile(id: string): void {
167
195
  }
168
196
  }
169
197
 
198
+ /**
199
+ * Record that a credential was removed from one container.
200
+ *
201
+ * `last_synced_at` is deliberately left alone: it means "when this credential
202
+ * was last delivered", and a removal is not a delivery. The de-sync path used
203
+ * to call markSynced(), which stamped the removal as a fresh sync — so the row
204
+ * went on showing a green dot and "synced just now" at the exact moment the
205
+ * credential stopped being there.
206
+ *
207
+ * `status` stays 'active' because de-sync targets a single container: with
208
+ * several agents the credential is still delivered to the others.
209
+ */
210
+ export function markDesynced(id: string): void {
211
+ const db = openDb(false);
212
+ try {
213
+ db.prepare(
214
+ "UPDATE credential_profiles SET status = 'active', updated_at = ? WHERE id = ?",
215
+ ).run(Date.now(), id);
216
+ appendAudit(db, 'desync', id, 'ok');
217
+ } finally {
218
+ db.close();
219
+ }
220
+ }
221
+
170
222
  export function markSynced(id: string, status: 'active' | 'error'): void {
171
223
  const db = openDb(false);
172
224
  try {
@@ -184,12 +236,25 @@ export function markSynced(id: string, status: 'active' | 'error'): void {
184
236
  * Use sparingly — every call is logged.
185
237
  */
186
238
  export function revealSecret(profileId: string): CredentialSecret {
187
- const db = openDb(true);
239
+ const secret = getSecret(profileId);
240
+
241
+ // The audit handle must be writable: this used to open the database readonly
242
+ // and then INSERT through it, so SQLite rejected every write, appendAudit
243
+ // swallowed the error by design, and not one reveal was ever recorded.
244
+ //
245
+ // Wrapped because a writable open does more than the readonly one it replaced
246
+ // — it also runs migrateV1(), whose DDL has a `finally` but no `catch`, so a
247
+ // failure there propagates. An audit trail must never be able to break the
248
+ // operation it records: losing one log line beats refusing to return a secret
249
+ // the caller is entitled to.
188
250
  try {
189
- const secret = getSecret(profileId);
190
- appendAudit(db, 'reveal', profileId, 'ok');
191
- return secret;
192
- } finally {
193
- db.close();
194
- }
251
+ const db = openDb(false);
252
+ try {
253
+ appendAudit(db, 'reveal', profileId, 'ok');
254
+ } finally {
255
+ db.close();
256
+ }
257
+ } catch { /* audit unavailable; the reveal itself still stands */ }
258
+
259
+ return secret;
195
260
  }