@flame0510/project-aether 1.1.14 → 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/agents/route.ts +16 -12
- 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,8 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSecret
|
|
1
|
+
import { dockerExecShell, mapWithConcurrency } from '@/lib/docker-exec';
|
|
2
|
+
import { getSecret } from './vault';
|
|
3
3
|
import { getProvider } from './providers';
|
|
4
4
|
import { listAgentContainers as listAgentContainerInfo } from './detect';
|
|
5
5
|
|
|
6
|
+
/** Containers written to at once. Writes are heavier than reads, so lower. */
|
|
7
|
+
const AGENT_WRITE_CONCURRENCY = 4;
|
|
8
|
+
|
|
6
9
|
export interface SyncResult {
|
|
7
10
|
container: string;
|
|
8
11
|
status: 'ok' | 'error';
|
|
@@ -12,9 +15,10 @@ export interface SyncResult {
|
|
|
12
15
|
// ── Container discovery ────────────────────────────────────────────────────
|
|
13
16
|
|
|
14
17
|
// Container names only, tolerant of docker failures (shared discovery lives in detect.ts).
|
|
15
|
-
|
|
18
|
+
// Async because discovery moved off `execSync`; the only caller is already async.
|
|
19
|
+
async function listAgentContainers(): Promise<string[]> {
|
|
16
20
|
try {
|
|
17
|
-
return listAgentContainerInfo().map((c) => c.name).filter(Boolean);
|
|
21
|
+
return (await listAgentContainerInfo()).map((c) => c.name).filter(Boolean);
|
|
18
22
|
} catch {
|
|
19
23
|
return [];
|
|
20
24
|
}
|
|
@@ -22,21 +26,75 @@ function listAgentContainers(): string[] {
|
|
|
22
26
|
|
|
23
27
|
// ── Exec helpers ───────────────────────────────────────────────────────────
|
|
24
28
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Run a shell snippet inside a container.
|
|
31
|
+
*
|
|
32
|
+
* Goes through `dockerExecShell`, which spawns docker with an argv array and no
|
|
33
|
+
* host shell in between, so `script` reaches `sh -c` inside the container
|
|
34
|
+
* verbatim. That matters: were the command assembled as one string for a host
|
|
35
|
+
* shell, a `$(...)` or a backtick anywhere in it — including inside a secret
|
|
36
|
+
* interpolated into it — would execute on the host, as the user running the
|
|
37
|
+
* dashboard, before docker was ever invoked.
|
|
38
|
+
*
|
|
39
|
+
* Secrets must still never be interpolated into `script`. Pass them via `env` and
|
|
40
|
+
* reference them as "$KEY": parameter expansion does not re-parse the value for
|
|
41
|
+
* command substitution. Only the name reaches argv, as `docker exec -e KEY`;
|
|
42
|
+
* docker forwards the value from its own environment, so it appears neither in
|
|
43
|
+
* the host process table nor in any rejection built from that argv.
|
|
44
|
+
*/
|
|
45
|
+
async function containerExec(
|
|
46
|
+
container: string,
|
|
47
|
+
script: string,
|
|
48
|
+
timeoutMs = 30000,
|
|
49
|
+
env?: Record<string, string>,
|
|
50
|
+
): Promise<void> {
|
|
51
|
+
await dockerExecShell(container, script, { timeoutMs, env });
|
|
30
52
|
}
|
|
31
53
|
|
|
32
|
-
|
|
54
|
+
/** Whether a CLI is on PATH inside the container. Throws if the exec fails. */
|
|
55
|
+
async function cliPresent(container: string, name: string): Promise<boolean> {
|
|
56
|
+
const out = await dockerExecShell(
|
|
57
|
+
container,
|
|
58
|
+
`command -v ${name} >/dev/null 2>&1 && echo ok || echo missing`,
|
|
59
|
+
{ timeoutMs: 5000 },
|
|
60
|
+
);
|
|
61
|
+
return out.trim() === 'ok';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Printed after the file, so its absence proves the read was cut short. */
|
|
65
|
+
const READ_COMPLETE = '\x04';
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read a file from a container.
|
|
69
|
+
*
|
|
70
|
+
* Returns '' when the file is genuinely absent or empty, and null when the read
|
|
71
|
+
* did not complete. Callers that write the file back MUST treat null as "do not
|
|
72
|
+
* write": to the rewrite path an unfinished read is indistinguishable from an
|
|
73
|
+
* empty file, and TOOLS.md's "no block yet" branch replaces the whole file.
|
|
74
|
+
*
|
|
75
|
+
* A try/catch alone does not separate the two, which is the trap this function
|
|
76
|
+
* was already caught in. `docker exec` exits 0 on the SIGTERM a timeout sends,
|
|
77
|
+
* so a read killed halfway *resolves* with whatever it had printed — no
|
|
78
|
+
* rejection to catch. `detect.ts` relies on that same behaviour deliberately for
|
|
79
|
+
* its probe sentinel; here it has to be defended against, and by the same means:
|
|
80
|
+
* a terminator written last. Present, the content is whole; absent, the process
|
|
81
|
+
* died before the end and nothing may be concluded from what arrived.
|
|
82
|
+
*
|
|
83
|
+
* `;` rather than `|| true`: when the file really is missing `cat` fails, the
|
|
84
|
+
* terminator is still printed, and the caller correctly gets '' — the case that
|
|
85
|
+
* must still create TOOLS.md, since an agent from the `custom` template starts
|
|
86
|
+
* without one until OpenClaw writes it.
|
|
87
|
+
*/
|
|
88
|
+
async function containerRead(container: string, path: string): Promise<string | null> {
|
|
33
89
|
try {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
);
|
|
90
|
+
const raw = await dockerExecShell(container, `cat ${path} 2>/dev/null; printf '\\4'`, {
|
|
91
|
+
timeoutMs: 5000,
|
|
92
|
+
maxBuffer: 512 * 1024,
|
|
93
|
+
});
|
|
94
|
+
if (!raw.endsWith(READ_COMPLETE)) return null;
|
|
95
|
+
return raw.slice(0, -READ_COMPLETE.length);
|
|
38
96
|
} catch {
|
|
39
|
-
return
|
|
97
|
+
return null;
|
|
40
98
|
}
|
|
41
99
|
}
|
|
42
100
|
|
|
@@ -86,18 +144,14 @@ async function discoverVercelScope(token: string): Promise<{ token: string; team
|
|
|
86
144
|
return { token, teamId };
|
|
87
145
|
}
|
|
88
146
|
|
|
89
|
-
function deliverGithub(container: string, secret: Record<string, string>): void {
|
|
147
|
+
async function deliverGithub(container: string, secret: Record<string, string>): Promise<void> {
|
|
90
148
|
const { token } = secret;
|
|
91
149
|
if (!token) throw new Error('Missing token');
|
|
92
150
|
|
|
93
151
|
// Require gh CLI preinstalled in the agent image
|
|
94
|
-
|
|
95
|
-
`docker exec ${container} sh -c ${JSON.stringify('command -v gh >/dev/null 2>&1 && echo ok || echo missing')}`,
|
|
96
|
-
{ timeout: 5000, encoding: 'utf-8' },
|
|
97
|
-
).trim();
|
|
98
|
-
if (hasGh !== 'ok') throw new Error('gh CLI not installed in this container. Rebuild the base image: docker build -t openclaw-agent-base:latest agent-templates/base-image/');
|
|
152
|
+
if (!await cliPresent(container, 'gh')) throw new Error('gh CLI not installed in this container. Rebuild the base image: docker build -t openclaw-agent-base:latest agent-templates/base-image/');
|
|
99
153
|
|
|
100
|
-
containerExec(
|
|
154
|
+
await containerExec(
|
|
101
155
|
container,
|
|
102
156
|
`echo ${JSON.stringify(b64(token))} | base64 -d | gh auth login --with-token`,
|
|
103
157
|
);
|
|
@@ -108,18 +162,14 @@ async function deliverVercel(container: string, secret: Record<string, string>):
|
|
|
108
162
|
if (!token) throw new Error('Missing token');
|
|
109
163
|
|
|
110
164
|
// Require vercel CLI preinstalled in the agent image
|
|
111
|
-
|
|
112
|
-
`docker exec ${container} sh -c ${JSON.stringify('command -v vercel >/dev/null 2>&1 && echo ok || echo missing')}`,
|
|
113
|
-
{ timeout: 5000, encoding: 'utf-8' },
|
|
114
|
-
).trim();
|
|
115
|
-
if (hasVercel !== 'ok') throw new Error('vercel CLI not installed in this container. Rebuild the base image: docker build -t openclaw-agent-base:latest agent-templates/base-image/');
|
|
165
|
+
if (!await cliPresent(container, 'vercel')) throw new Error('vercel CLI not installed in this container. Rebuild the base image: docker build -t openclaw-agent-base:latest agent-templates/base-image/');
|
|
116
166
|
|
|
117
167
|
// Validate token and discover team scope via Node fetch (no curl/exec)
|
|
118
168
|
const scope = await discoverVercelScope(token);
|
|
119
169
|
|
|
120
170
|
// Write auth.json for Vercel CLI auth — same directory as config.json
|
|
121
171
|
const authJson = JSON.stringify({ token });
|
|
122
|
-
containerExec(
|
|
172
|
+
await containerExec(
|
|
123
173
|
container,
|
|
124
174
|
`mkdir -p /root/.local/share/com.vercel.cli && echo ${JSON.stringify(b64(authJson))} | base64 -d > /root/.local/share/com.vercel.cli/auth.json && chmod 600 /root/.local/share/com.vercel.cli/auth.json`,
|
|
125
175
|
);
|
|
@@ -128,7 +178,7 @@ async function deliverVercel(container: string, secret: Record<string, string>):
|
|
|
128
178
|
// Both auth.json and config.json MUST be in ~/.local/share/com.vercel.cli/
|
|
129
179
|
if (scope.teamId) {
|
|
130
180
|
const vercelConfig = JSON.stringify({ currentTeam: scope.teamId });
|
|
131
|
-
containerExec(
|
|
181
|
+
await containerExec(
|
|
132
182
|
container,
|
|
133
183
|
`echo ${JSON.stringify(b64(vercelConfig))} | base64 -d > /root/.local/share/com.vercel.cli/config.json && chmod 600 /root/.local/share/com.vercel.cli/config.json`,
|
|
134
184
|
);
|
|
@@ -136,52 +186,59 @@ async function deliverVercel(container: string, secret: Record<string, string>):
|
|
|
136
186
|
|
|
137
187
|
}
|
|
138
188
|
|
|
139
|
-
function deliverSupabase(container: string, secret: Record<string, string>): void {
|
|
189
|
+
async function deliverSupabase(container: string, secret: Record<string, string>): Promise<void> {
|
|
140
190
|
const { accessToken } = secret;
|
|
141
191
|
if (!accessToken || !accessToken.trim()) throw new Error('Missing Supabase access token');
|
|
142
192
|
|
|
143
193
|
// Check supabase CLI is available (installed globally in the image)
|
|
144
|
-
|
|
145
|
-
`docker exec ${container} sh -c ${JSON.stringify('command -v supabase >/dev/null 2>&1 && echo ok || echo missing')}`,
|
|
146
|
-
{ timeout: 5000, encoding: 'utf-8' },
|
|
147
|
-
).trim();
|
|
148
|
-
if (hasSupabase !== 'ok') throw new Error('supabase CLI not installed in this container. Rebuild the base image: docker build -t openclaw-agent-base:latest agent-templates/base-image/');
|
|
194
|
+
if (!await cliPresent(container, 'supabase')) throw new Error('supabase CLI not installed in this container. Rebuild the base image: docker build -t openclaw-agent-base:latest agent-templates/base-image/');
|
|
149
195
|
|
|
150
196
|
// Login with access token — grants access to all projects on the account
|
|
151
197
|
// supabase login --token supports non-interactive auth
|
|
152
|
-
|
|
198
|
+
// The token travels through `docker exec -e`, never inside the script text.
|
|
199
|
+
// Interpolating it here is what let a token containing `$(...)` execute on the
|
|
200
|
+
// host as root: JSON.stringify escapes quotes but not command substitution,
|
|
201
|
+
// and execSync handed the result to the host shell before docker saw it.
|
|
202
|
+
// "$SUPABASE_ACCESS_TOKEN" is expanded by the container's shell, which does
|
|
203
|
+
// not re-parse the expanded value.
|
|
204
|
+
await containerExec(
|
|
153
205
|
container,
|
|
154
|
-
|
|
206
|
+
'supabase login --token "$SUPABASE_ACCESS_TOKEN" --no-browser 2>&1',
|
|
155
207
|
30000,
|
|
208
|
+
{ SUPABASE_ACCESS_TOKEN: accessToken.trim() },
|
|
156
209
|
);
|
|
157
210
|
}
|
|
158
211
|
|
|
159
|
-
|
|
212
|
+
/**
|
|
213
|
+
* Trello is delivered for the REST API, not for the CLI.
|
|
214
|
+
*
|
|
215
|
+
* The `trello` CLI stays in the base image and can still read a config of this
|
|
216
|
+
* shape, but it is no longer required: the sync used to abort with "rebuild the
|
|
217
|
+
* base image" when it was missing, over a tool nothing depends on. The agent is
|
|
218
|
+
* told to call the API directly — see PROVIDER_ENTRIES and the Authentication
|
|
219
|
+
* section that TOOLS.md carries.
|
|
220
|
+
*
|
|
221
|
+
* The file therefore lives under `~/.config/`, alongside Notion, rather than in
|
|
222
|
+
* the CLI's own directory.
|
|
223
|
+
*/
|
|
224
|
+
async function deliverTrello(container: string, secret: Record<string, string>): Promise<void> {
|
|
160
225
|
const { apiKey, token } = secret;
|
|
161
226
|
if (!apiKey || !token) throw new Error('Missing Trello API key or token');
|
|
162
227
|
|
|
163
|
-
// Require trello CLI preinstalled in the agent image
|
|
164
|
-
const hasTrello = execSync(
|
|
165
|
-
`docker exec ${container} sh -c ${JSON.stringify('command -v trello >/dev/null 2>&1 && echo ok || echo missing')}`,
|
|
166
|
-
{ timeout: 5000, encoding: 'utf-8' },
|
|
167
|
-
).trim();
|
|
168
|
-
if (hasTrello !== 'ok') throw new Error('trello CLI not installed in this container. Rebuild the base image: docker build -t openclaw-agent-base:latest agent-templates/base-image/');
|
|
169
|
-
|
|
170
|
-
// Write trello-cli config file (same format trello auth would create)
|
|
171
228
|
const config = JSON.stringify({ apiKey, token });
|
|
172
|
-
containerExec(
|
|
229
|
+
await containerExec(
|
|
173
230
|
container,
|
|
174
|
-
`mkdir -p /root/.trello
|
|
231
|
+
`mkdir -p /root/.config/trello && echo ${JSON.stringify(b64(config))} | base64 -d > /root/.config/trello/config.json && chmod 600 /root/.config/trello/config.json`,
|
|
175
232
|
);
|
|
176
233
|
}
|
|
177
234
|
|
|
178
235
|
/** Notion: no CLI, just write a config file (same pattern as Trello). */
|
|
179
|
-
function deliverNotion(container: string, secret: Record<string, string>): void {
|
|
236
|
+
async function deliverNotion(container: string, secret: Record<string, string>): Promise<void> {
|
|
180
237
|
const { token } = secret;
|
|
181
238
|
if (!token) throw new Error('Missing Notion API token');
|
|
182
239
|
|
|
183
240
|
const config = JSON.stringify({ token });
|
|
184
|
-
containerExec(
|
|
241
|
+
await containerExec(
|
|
185
242
|
container,
|
|
186
243
|
`mkdir -p /root/.config/notion && echo ${JSON.stringify(b64(config))} | base64 -d > /root/.config/notion/config.json && chmod 600 /root/.config/notion/config.json`,
|
|
187
244
|
);
|
|
@@ -218,15 +275,38 @@ const PROVIDER_ENTRIES: Record<string, IntegrationEntry> = {
|
|
|
218
275
|
trello: {
|
|
219
276
|
tool: 'trello',
|
|
220
277
|
status: '✓ ready',
|
|
221
|
-
note: 'Trello
|
|
278
|
+
note: 'Trello REST API — boards, lists, cards. See Authentication below',
|
|
222
279
|
},
|
|
223
280
|
notion: {
|
|
224
281
|
tool: 'notion',
|
|
225
282
|
status: '✓ ready',
|
|
226
|
-
note: 'Notion API —
|
|
283
|
+
note: 'Notion REST API — pages, databases, blocks. See Authentication below',
|
|
227
284
|
},
|
|
228
285
|
};
|
|
229
286
|
|
|
287
|
+
/**
|
|
288
|
+
* How to authenticate, for the providers with no CLI to do it for them.
|
|
289
|
+
*
|
|
290
|
+
* These are kept out of the table on purpose. A table cell is one line, and
|
|
291
|
+
* these instructions have several steps — open a file, read named fields,
|
|
292
|
+
* assemble a request. The field-name mismatch is the reason they exist at all:
|
|
293
|
+
* the file Rev4a writes calls it `apiKey`, and Trello's API expects `key`.
|
|
294
|
+
*
|
|
295
|
+
* Only the entries for providers actually synced to a container are emitted, so
|
|
296
|
+
* an agent with only CLI-backed credentials carries none of this.
|
|
297
|
+
*/
|
|
298
|
+
const AUTH_NOTES: Record<string, string> = {
|
|
299
|
+
trello: [
|
|
300
|
+
'**Trello** — read `~/.config/trello/config.json`: it holds `apiKey` and `token`.',
|
|
301
|
+
'Pass them as query parameters, where `apiKey` becomes `key`:',
|
|
302
|
+
'`https://api.trello.com/1/members/me?key=<apiKey>&token=<token>`',
|
|
303
|
+
].join('\n'),
|
|
304
|
+
notion: [
|
|
305
|
+
'**Notion** — read `~/.config/notion/config.json`: it holds `token`.',
|
|
306
|
+
'Send `Authorization: Bearer <token>` and `Notion-Version: 2026-03-11` on every request.',
|
|
307
|
+
].join('\n'),
|
|
308
|
+
};
|
|
309
|
+
|
|
230
310
|
function entryRow(entry: IntegrationEntry): string {
|
|
231
311
|
return `| \`${entry.tool}\` | ${entry.status} | ${entry.note} |`;
|
|
232
312
|
}
|
|
@@ -239,6 +319,10 @@ function buildBlock(integrations: IntegrationEntry[]): string {
|
|
|
239
319
|
}
|
|
240
320
|
|
|
241
321
|
const rows = integrations.map((e) => entryRow(e)).join('\n');
|
|
322
|
+
// Emitted only for the providers present that need it; absent entirely when
|
|
323
|
+
// every synced credential is CLI-backed.
|
|
324
|
+
const notes = integrations.map((e) => AUTH_NOTES[e.tool]).filter(Boolean);
|
|
325
|
+
const auth = notes.length ? `\n\n### Authentication\n\n${notes.join('\n\n')}` : '';
|
|
242
326
|
return `${MARKER_START}
|
|
243
327
|
## Active Integrations
|
|
244
328
|
|
|
@@ -246,28 +330,11 @@ function buildBlock(integrations: IntegrationEntry[]): string {
|
|
|
246
330
|
|
|
247
331
|
| Tool | Status | Notes |
|
|
248
332
|
|---|---|---|
|
|
249
|
-
${rows}
|
|
333
|
+
${rows}${auth}
|
|
250
334
|
|
|
251
335
|
${MARKER_END}`;
|
|
252
336
|
}
|
|
253
337
|
|
|
254
|
-
function collectIntegrations(): IntegrationEntry[] {
|
|
255
|
-
const profiles = listProfiles();
|
|
256
|
-
const seen = new Set<string>();
|
|
257
|
-
const entries: IntegrationEntry[] = [];
|
|
258
|
-
|
|
259
|
-
for (const profile of profiles) {
|
|
260
|
-
if (profile.status !== 'active') continue;
|
|
261
|
-
if (seen.has(profile.providerId)) continue;
|
|
262
|
-
seen.add(profile.providerId);
|
|
263
|
-
|
|
264
|
-
const entry = PROVIDER_ENTRIES[profile.providerId];
|
|
265
|
-
if (entry) entries.push(entry);
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
return entries;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
338
|
/**
|
|
272
339
|
* Helper: extract rows as pure string lines from the block content (between markers).
|
|
273
340
|
*/
|
|
@@ -291,22 +358,44 @@ function rowsToEntries(rows: string[]): IntegrationEntry[] {
|
|
|
291
358
|
.filter(Boolean) as IntegrationEntry[];
|
|
292
359
|
}
|
|
293
360
|
|
|
361
|
+
/**
|
|
362
|
+
* Write through a temporary file, then rename over the target.
|
|
363
|
+
*
|
|
364
|
+
* `> file` truncates before the first byte arrives, so an exec killed mid-write
|
|
365
|
+
* leaves nothing behind — verified: a redirect interrupted after 1s left a 31
|
|
366
|
+
* byte file at 0, while this form left it untouched. TOOLS.md carries the
|
|
367
|
+
* agent's own instructions and is not regenerable, so the window matters here
|
|
368
|
+
* even though it is small. `mv` on the same filesystem is atomic: the target is
|
|
369
|
+
* either the old content or the new one, never a prefix of either.
|
|
370
|
+
*
|
|
371
|
+
* `lib/channelManager.ts` writes `openclaw.json` the same way.
|
|
372
|
+
*/
|
|
373
|
+
function writeAtomically(content: string, path: string): string {
|
|
374
|
+
const tmp = `${path}.rev4a.tmp`;
|
|
375
|
+
return `echo ${JSON.stringify(b64(content))} | base64 -d > ${tmp} && mv ${tmp} ${path}`;
|
|
376
|
+
}
|
|
377
|
+
|
|
294
378
|
/**
|
|
295
379
|
* Add or remove a single provider's row from the TOOLS.md integration block
|
|
296
380
|
* inside a single agent container (docker exec only, no host filesystem access).
|
|
297
381
|
*/
|
|
298
|
-
function toggleProviderRow(
|
|
382
|
+
async function toggleProviderRow(
|
|
299
383
|
container: string,
|
|
300
384
|
providerId: string,
|
|
301
385
|
action: 'add' | 'remove',
|
|
302
|
-
): void {
|
|
386
|
+
): Promise<void> {
|
|
303
387
|
const safe = container.replace(/[^a-zA-Z0-9_.-]/g, '');
|
|
304
388
|
if (!safe) return;
|
|
305
389
|
|
|
306
390
|
const entry = PROVIDER_ENTRIES[providerId];
|
|
307
391
|
if (!entry) return;
|
|
308
392
|
|
|
309
|
-
const existing = containerRead(safe, TOOLS_MD_PATH);
|
|
393
|
+
const existing = await containerRead(safe, TOOLS_MD_PATH);
|
|
394
|
+
// A failed read must not lead to a write. It used to: '' meant both "empty"
|
|
395
|
+
// and "unreadable", so a transient docker error sent the add path into its
|
|
396
|
+
// "no block yet" branch, which writes the block plus `existing` — replacing
|
|
397
|
+
// the whole of TOOLS.md with just the block.
|
|
398
|
+
if (existing === null) return;
|
|
310
399
|
const row = entryRow(entry);
|
|
311
400
|
const si = existing.indexOf(MARKER_START);
|
|
312
401
|
const ei = existing.indexOf(MARKER_END);
|
|
@@ -322,10 +411,7 @@ function toggleProviderRow(
|
|
|
322
411
|
} else {
|
|
323
412
|
// No block yet — create one with just this row
|
|
324
413
|
const newBlock = buildBlock([entry]);
|
|
325
|
-
containerExec(
|
|
326
|
-
safe,
|
|
327
|
-
`echo ${JSON.stringify(b64(newBlock + '\n' + existing))} | base64 -d > ${TOOLS_MD_PATH}`,
|
|
328
|
-
);
|
|
414
|
+
await containerExec(safe, writeAtomically(newBlock + '\n' + existing, TOOLS_MD_PATH));
|
|
329
415
|
return;
|
|
330
416
|
}
|
|
331
417
|
} else {
|
|
@@ -336,10 +422,7 @@ function toggleProviderRow(
|
|
|
336
422
|
|
|
337
423
|
const newBlock = buildBlock(rowsToEntries(rows));
|
|
338
424
|
const patched = existing.slice(0, si) + newBlock + existing.slice(ei + MARKER_END.length);
|
|
339
|
-
containerExec(
|
|
340
|
-
safe,
|
|
341
|
-
`echo ${JSON.stringify(b64(patched))} | base64 -d > ${TOOLS_MD_PATH}`,
|
|
342
|
-
);
|
|
425
|
+
await containerExec(safe, writeAtomically(patched, TOOLS_MD_PATH));
|
|
343
426
|
}
|
|
344
427
|
|
|
345
428
|
// ── Agent primitives — operate on ONE container ──────────────────────────
|
|
@@ -347,25 +430,25 @@ function toggleProviderRow(
|
|
|
347
430
|
/**
|
|
348
431
|
* Remove credentials for a specific provider from a single agent container.
|
|
349
432
|
*/
|
|
350
|
-
function cleanProvider(container: string, providerId: string): void {
|
|
433
|
+
async function cleanProvider(container: string, providerId: string): Promise<void> {
|
|
351
434
|
const safe = container.replace(/[^a-zA-Z0-9_.-]/g, '');
|
|
352
435
|
if (!safe) return;
|
|
353
436
|
|
|
354
437
|
switch (providerId) {
|
|
355
438
|
case 'github-pat':
|
|
356
|
-
try { containerExec(safe, 'rm -rf /root/.config/gh /root/.local/share/gh 2>/dev/null; exit 0', 10000); } catch {}
|
|
439
|
+
try { await containerExec(safe, 'rm -rf /root/.config/gh /root/.local/share/gh 2>/dev/null; exit 0', 10000); } catch {}
|
|
357
440
|
break;
|
|
358
441
|
case 'vercel':
|
|
359
|
-
try { containerExec(safe, 'rm -rf /root/.local/share/com.vercel.cli', 10000); } catch {}
|
|
442
|
+
try { await containerExec(safe, 'rm -rf /root/.local/share/com.vercel.cli', 10000); } catch {}
|
|
360
443
|
break;
|
|
361
444
|
case 'supabase':
|
|
362
|
-
try { containerExec(safe, 'rm -f /root/.supabase/access-token 2>/dev/null; exit 0', 10000); } catch {}
|
|
445
|
+
try { await containerExec(safe, 'rm -f /root/.supabase/access-token 2>/dev/null; exit 0', 10000); } catch {}
|
|
363
446
|
break;
|
|
364
447
|
case 'trello':
|
|
365
|
-
try { containerExec(safe, 'rm -rf /root/.trello
|
|
448
|
+
try { await containerExec(safe, 'rm -rf /root/.config/trello', 10000); } catch {}
|
|
366
449
|
break;
|
|
367
450
|
case 'notion':
|
|
368
|
-
try { containerExec(safe, 'rm -rf /root/.config/notion', 10000); } catch {}
|
|
451
|
+
try { await containerExec(safe, 'rm -rf /root/.config/notion', 10000); } catch {}
|
|
369
452
|
break;
|
|
370
453
|
}
|
|
371
454
|
}
|
|
@@ -379,11 +462,11 @@ async function deliverToAgent(
|
|
|
379
462
|
secret: Record<string, string>,
|
|
380
463
|
): Promise<void> {
|
|
381
464
|
switch (providerId) {
|
|
382
|
-
case 'github-pat': deliverGithub(container, secret); break;
|
|
465
|
+
case 'github-pat': await deliverGithub(container, secret); break;
|
|
383
466
|
case 'vercel': await deliverVercel(container, secret); break;
|
|
384
|
-
case 'supabase': deliverSupabase(container, secret); break;
|
|
385
|
-
case 'trello': deliverTrello(container, secret); break;
|
|
386
|
-
case 'notion': deliverNotion(container, secret); break;
|
|
467
|
+
case 'supabase': await deliverSupabase(container, secret); break;
|
|
468
|
+
case 'trello': await deliverTrello(container, secret); break;
|
|
469
|
+
case 'notion': await deliverNotion(container, secret); break;
|
|
387
470
|
default: throw new Error(`No delivery adapter for: ${providerId}`);
|
|
388
471
|
}
|
|
389
472
|
}
|
|
@@ -403,34 +486,44 @@ export async function syncProfileToAgents(
|
|
|
403
486
|
if (!getProvider(providerId)) throw new Error(`Unknown provider: ${providerId}`);
|
|
404
487
|
|
|
405
488
|
const secret = getSecret(profileId);
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
//
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
489
|
+
// Deduplicated: TOOLS.md is edited with a read-modify-write, so the same
|
|
490
|
+
// container appearing twice would have two passes racing on one file. No
|
|
491
|
+
// caller sends duplicates today — the UI always passes a single name and
|
|
492
|
+
// `docker ps` names are unique — but the route forwards `body.containers`
|
|
493
|
+
// unchecked, so nothing else stops it.
|
|
494
|
+
const selectedTargets = [...new Set(containers ?? await listAgentContainers())]
|
|
495
|
+
.map((c) => c.replace(/[^a-zA-Z0-9_.-]/g, ''))
|
|
496
|
+
.filter(Boolean);
|
|
497
|
+
|
|
498
|
+
// Three passes, kept sequential relative to each other: every target is
|
|
499
|
+
// cleaned before any is delivered to, and TOOLS.md is only touched once the
|
|
500
|
+
// outcome of every delivery is known. Within a pass the containers run
|
|
501
|
+
// concurrently — each callback handles its own failures, because
|
|
502
|
+
// mapWithConcurrency is fail-fast and one unreachable container would
|
|
503
|
+
// otherwise discard the results already computed for the others.
|
|
504
|
+
await mapWithConcurrency(selectedTargets, AGENT_WRITE_CONCURRENCY, async (container) => {
|
|
505
|
+
try { await cleanProvider(container, providerId); } catch { /* best-effort, as before */ }
|
|
506
|
+
});
|
|
412
507
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
508
|
+
const results: SyncResult[] = await mapWithConcurrency(
|
|
509
|
+
selectedTargets,
|
|
510
|
+
AGENT_WRITE_CONCURRENCY,
|
|
511
|
+
async (container): Promise<SyncResult> => {
|
|
512
|
+
try {
|
|
513
|
+
await deliverToAgent(container, providerId, secret);
|
|
514
|
+
return { container, status: 'ok' };
|
|
515
|
+
} catch (e: unknown) {
|
|
516
|
+
return { container, status: 'error', error: (e as Error).message };
|
|
517
|
+
}
|
|
518
|
+
},
|
|
519
|
+
);
|
|
418
520
|
|
|
521
|
+
await mapWithConcurrency(selectedTargets, AGENT_WRITE_CONCURRENCY, async (container, i) => {
|
|
522
|
+
const wasDelivered = results[i]?.status === 'ok';
|
|
419
523
|
try {
|
|
420
|
-
await
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
results.push({ container, status: 'error', error: (e as Error).message });
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
// Update TOOLS.md on selected targets only
|
|
428
|
-
for (const container of selectedTargets) {
|
|
429
|
-
const wasDelivered = results.some(
|
|
430
|
-
(r) => r.container === container && r.status === 'ok',
|
|
431
|
-
);
|
|
432
|
-
toggleProviderRow(container, providerId, wasDelivered ? 'add' : 'remove');
|
|
433
|
-
}
|
|
524
|
+
await toggleProviderRow(container, providerId, wasDelivered ? 'add' : 'remove');
|
|
525
|
+
} catch { /* TOOLS.md is documentation; a failure here must not fail the sync */ }
|
|
526
|
+
});
|
|
434
527
|
|
|
435
528
|
return results;
|
|
436
529
|
}
|
|
@@ -439,10 +532,10 @@ export async function syncProfileToAgents(
|
|
|
439
532
|
* Clean a single provider from a single agent container and update its TOOLS.md.
|
|
440
533
|
* Used by the De-sync button in the UI and by the sync endpoint for initial cleanup.
|
|
441
534
|
*/
|
|
442
|
-
export function cleanAgentProviders(container: string, providerId: string): void {
|
|
535
|
+
export async function cleanAgentProviders(container: string, providerId: string): Promise<void> {
|
|
443
536
|
const safe = container.replace(/[^a-zA-Z0-9_.-]/g, '');
|
|
444
537
|
if (!safe) return;
|
|
445
538
|
|
|
446
|
-
cleanProvider(safe, providerId);
|
|
447
|
-
toggleProviderRow(safe, providerId, 'remove');
|
|
539
|
+
await cleanProvider(safe, providerId);
|
|
540
|
+
await toggleProviderRow(safe, providerId, 'remove');
|
|
448
541
|
}
|