@flame0510/project-aether 1.1.15 → 1.2.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.
- package/app/api/credentials/[id]/sync/route.ts +3 -3
- package/app/api/credentials/detect/route.ts +126 -176
- package/app/api/credentials/route.ts +3 -0
- package/app/components/Skeleton.tsx +132 -0
- package/app/credentials/PageClient.tsx +460 -139
- package/app/credentials/loading.tsx +19 -5
- package/docs/ARCHITECTURE.md +78 -31
- package/docs/FRONTEND-ARCHITECTURE.md +13 -5
- package/docs/REV4A.md +23 -14
- package/docs/dev/API-REFERENCE.md +356 -184
- package/docs/dev/DATABASE.md +8 -3
- package/docs/dev/GATEWAY.md +9 -7
- package/docs/dev/PROVIDERS.md +22 -35
- package/docs/rag/GLOSSARY.md +8 -8
- package/docs/rag/REV4A-OVERVIEW.md +7 -10
- package/docs/rag/WHAT-I-CAN-ANSWER.md +6 -6
- package/lib/credentials/delivery.ts +212 -119
- package/lib/credentials/detect.ts +229 -97
- package/lib/credentials/providers.ts +38 -7
- package/lib/credentials/vault.ts +78 -13
- package/lib/docker-exec.ts +50 -14
- package/package.json +1 -1
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import {
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return
|
|
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
|
-
|
|
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
|
-
/**
|
|
49
|
-
export
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
/**
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
{
|
|
47
|
-
|
|
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
|
|
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: '
|
|
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: '
|
|
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',
|
package/lib/credentials/vault.ts
CHANGED
|
@@ -125,19 +125,47 @@ export function updateProfile(
|
|
|
125
125
|
const now = Date.now();
|
|
126
126
|
const db = openDb(false);
|
|
127
127
|
try {
|
|
128
|
-
// Only
|
|
129
|
-
|
|
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 (
|
|
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 ? (
|
|
137
|
-
const payload = JSON.stringify(secret);
|
|
160
|
+
const secretMasked = mask(maskKey ? (merged[maskKey] ?? '') : '');
|
|
138
161
|
|
|
139
162
|
db.prepare(
|
|
140
|
-
|
|
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
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
}
|