@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.
@@ -1,8 +1,11 @@
1
- import { execSync } from 'child_process';
2
- import { getSecret, listProfiles } from './vault';
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
- function listAgentContainers(): string[] {
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
- function containerExec(container: string, script: string, timeoutMs = 30000): void {
26
- execSync(`docker exec ${container} sh -c ${JSON.stringify(script)}`, {
27
- timeout: timeoutMs,
28
- stdio: 'pipe',
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
- function containerRead(container: string, path: string): string {
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
- return execSync(
35
- `docker exec ${container} sh -c ${JSON.stringify(`cat ${path} 2>/dev/null || true`)}`,
36
- { timeout: 5000, encoding: 'utf-8', maxBuffer: 512 * 1024 },
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
- const hasGh = execSync(
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
- const hasVercel = execSync(
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
- const hasSupabase = execSync(
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
- containerExec(
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
- `supabase login --token ${JSON.stringify(accessToken.trim())} --no-browser 2>&1`,
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
- function deliverTrello(container: string, secret: Record<string, string>): void {
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-cli/default && echo ${JSON.stringify(b64(config))} | base64 -d > /root/.trello-cli/default/config.json && chmod 600 /root/.trello-cli/default/config.json`,
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 CLI — `trello card:list`, `trello card:create`, `trello interactive`',
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 — read/write pages, databases, and blocks via REST 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-cli', 10000); } catch {}
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
- const selectedTargets = containers ?? listAgentContainers();
407
-
408
- // Clean the specific provider from selected targets (don't touch other providers)
409
- for (const container of selectedTargets) {
410
- cleanProvider(container, providerId);
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
- // Deliver credentials to selected targets
414
- const results: SyncResult[] = [];
415
- for (const container of selectedTargets) {
416
- const safe = container.replace(/[^a-zA-Z0-9_.-]/g, '');
417
- if (!safe) continue;
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 deliverToAgent(safe, providerId, secret);
421
- results.push({ container, status: 'ok' });
422
- } catch (e: unknown) {
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
  }