@desplega.ai/agent-swarm 1.87.0 → 1.88.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/README.md +2 -1
- package/openapi.json +13 -1
- package/package.json +5 -5
- package/src/be/db.ts +49 -7
- package/src/be/migrations/080_skill_system_defaults.sql +8 -0
- package/src/be/modelsdev-cache.json +1123 -1034
- package/src/be/seed/registry.ts +3 -2
- package/src/be/seed-skills/index.ts +172 -0
- package/src/cli.tsx +33 -4
- package/src/commands/e2b-stack-wizard.tsx +394 -0
- package/src/commands/e2b.ts +1352 -53
- package/src/commands/onboard/dashboard-url.ts +29 -0
- package/src/commands/onboard/steps/post-dashboard.tsx +3 -1
- package/src/commands/onboard.tsx +3 -1
- package/src/commands/runner.ts +1 -0
- package/src/e2b/dispatch.ts +234 -18
- package/src/http/memory.ts +13 -1
- package/src/http/skills.ts +53 -0
- package/src/http/webhooks.ts +75 -0
- package/src/integrations/kapso/client.ts +82 -0
- package/src/memory/automatic-task-gate.ts +47 -0
- package/src/prompts/base-prompt.ts +16 -1
- package/src/prompts/session-templates.ts +51 -0
- package/src/providers/claude-adapter.ts +19 -0
- package/src/providers/codex-adapter.ts +22 -0
- package/src/providers/ctx-mode-env.ts +10 -0
- package/src/providers/opencode-adapter.ts +50 -1
- package/src/slack/blocks.ts +12 -4
- package/src/slack/watcher.ts +3 -3
- package/src/telemetry.ts +14 -1
- package/src/templates.d.ts +4 -0
- package/src/tests/base-prompt.test.ts +41 -0
- package/src/tests/claude-adapter.test.ts +86 -1
- package/src/tests/codex-adapter.test.ts +89 -0
- package/src/tests/e2b-dispatch.test.ts +603 -11
- package/src/tests/http-api-integration.test.ts +113 -0
- package/src/tests/kapso-client.test.ts +74 -1
- package/src/tests/kapso-inbound.test.ts +60 -2
- package/src/tests/opencode-adapter.test.ts +95 -0
- package/src/tests/prompt-template-session.test.ts +4 -2
- package/src/tests/self-improvement.test.ts +89 -0
- package/src/tests/skill-update-scope.test.ts +88 -1
- package/src/tests/slack-blocks.test.ts +15 -0
- package/src/tests/system-default-skills.test.ts +119 -0
- package/src/tests/telemetry-init.test.ts +86 -0
- package/src/tools/skills/skill-delete.ts +14 -0
- package/src/tools/skills/skill-update.ts +14 -0
- package/src/tools/store-progress.ts +19 -5
- package/src/types.ts +1 -0
- package/templates/skills/artifacts/config.json +1 -0
- package/templates/skills/kv-storage/config.json +1 -0
- package/templates/skills/pages/config.json +1 -0
- package/templates/skills/scheduled-task-resilience/config.json +1 -0
- package/templates/skills/swarm-scripts/SKILL.md +91 -0
- package/templates/skills/swarm-scripts/config.json +14 -0
- package/templates/skills/swarm-scripts/content.md +86 -0
- package/templates/skills/workflow-iterate/config.json +1 -0
- package/templates/skills/workflow-structured-output/config.json +1 -0
- package/tsconfig.json +2 -1
package/src/commands/e2b.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { dirname, resolve } from "node:path";
|
|
2
|
+
import { render } from "ink";
|
|
3
|
+
import { createElement } from "react";
|
|
2
4
|
import {
|
|
3
5
|
buildImageTemplate,
|
|
4
6
|
buildTemplate,
|
|
@@ -8,8 +10,11 @@ import {
|
|
|
8
10
|
killSandbox,
|
|
9
11
|
listSandboxes,
|
|
10
12
|
sandboxPortUrl,
|
|
13
|
+
setSandboxTimeout,
|
|
11
14
|
setTemplateVisibility,
|
|
12
15
|
startDetachedProcess,
|
|
16
|
+
streamSandboxLog,
|
|
17
|
+
ttlRemaining,
|
|
13
18
|
waitForAgentRegistration,
|
|
14
19
|
waitForHttpOk,
|
|
15
20
|
} from "../e2b/dispatch";
|
|
@@ -29,8 +34,20 @@ import {
|
|
|
29
34
|
selectEnv,
|
|
30
35
|
splitKeys,
|
|
31
36
|
} from "../e2b/env";
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
import { getAppUrl } from "../utils/constants";
|
|
38
|
+
import {
|
|
39
|
+
DEFAULT_STACK_TIMEOUT_SEC,
|
|
40
|
+
DEFAULT_STACK_WORKERS,
|
|
41
|
+
STACK_INTEGRATIONS,
|
|
42
|
+
StackWizard,
|
|
43
|
+
type StackWizardDefaults,
|
|
44
|
+
type StackWizardResult,
|
|
45
|
+
type StackWizardSkips,
|
|
46
|
+
SwarmPicker,
|
|
47
|
+
slugify,
|
|
48
|
+
} from "./e2b-stack-wizard.tsx";
|
|
49
|
+
|
|
50
|
+
export type ParsedFlags = {
|
|
34
51
|
command?: string;
|
|
35
52
|
positionals: string[];
|
|
36
53
|
values: Map<string, string[]>;
|
|
@@ -43,10 +60,110 @@ type StartedRole = {
|
|
|
43
60
|
url?: string;
|
|
44
61
|
};
|
|
45
62
|
|
|
46
|
-
|
|
47
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Env scope for role-scoped secret/env-file layering. A lead is E2B
|
|
65
|
+
* `SwarmRole === "worker"` but gets its own `"lead"` env scope so lead and
|
|
66
|
+
* worker env never cross-contaminate.
|
|
67
|
+
*/
|
|
68
|
+
export type EnvScope = "api" | "lead" | "worker";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The swarm-grouping role stamped onto `metadata.swarmRole`. Distinct from the
|
|
72
|
+
* E2B `SwarmRole` ("api" | "worker") because a lead is E2B `SwarmRole:"worker"`
|
|
73
|
+
* but a separate grouping role for `e2b swarms` purposes. Used by `swarms info`
|
|
74
|
+
* to resolve which sandbox is the API vs lead vs workers.
|
|
75
|
+
*/
|
|
76
|
+
export type MetadataSwarmRole = "api" | "lead" | "worker";
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Per-instance launch spec threaded through {@link startRole}. `swarmRole` is
|
|
80
|
+
* the E2B template/entrypoint dimension (api vs worker). `agentRole` is the
|
|
81
|
+
* swarm-side role written to `AGENT_ROLE` (a lead is `swarmRole:"worker"` +
|
|
82
|
+
* `agentRole:"lead"`). `envScope` selects which scoped `--{scope}-env-file` /
|
|
83
|
+
* `--{scope}-secret` flags layer on top of the shared ones.
|
|
84
|
+
*/
|
|
85
|
+
export type LaunchSpec = {
|
|
86
|
+
swarmRole: SwarmRole;
|
|
87
|
+
agentRole?: "worker" | "lead";
|
|
88
|
+
envScope: EnvScope;
|
|
89
|
+
/**
|
|
90
|
+
* The grouping role stamped onto `metadata.swarmRole`. Defaults to a sensible
|
|
91
|
+
* value derived from `swarmRole`/`agentRole` (api → "api", lead → "lead",
|
|
92
|
+
* worker → "worker") when omitted. Kept explicit on the spec so the stack's
|
|
93
|
+
* lead and workers tag distinctly even though both are E2B `SwarmRole:"worker"`.
|
|
94
|
+
*/
|
|
95
|
+
metadataSwarmRole?: MetadataSwarmRole;
|
|
96
|
+
/**
|
|
97
|
+
* Flag the explicit AGENT_ID override is read from (default `"agent-id"`).
|
|
98
|
+
* The stack's lead reads `"lead-agent-id"` so a single `--agent-id` never
|
|
99
|
+
* collides the lead and a worker onto the same agent record.
|
|
100
|
+
*/
|
|
101
|
+
agentIdFlag?: string;
|
|
102
|
+
/**
|
|
103
|
+
* Prefix for the generated default AGENT_ID (`<prefix>-<sandboxID>`). Workers
|
|
104
|
+
* use `"e2b"` (legacy, unchanged); the stack's lead uses `"e2b-lead"`. The
|
|
105
|
+
* sandbox ID is unique per sandbox, so every instance still registers
|
|
106
|
+
* distinctly even without an explicit `--agent-id`.
|
|
107
|
+
*/
|
|
108
|
+
agentIdPrefix?: string;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** The byte-identical specs for the legacy `start-api` / `start-worker` paths. */
|
|
112
|
+
const API_SPEC: LaunchSpec = { swarmRole: "api", envScope: "api", metadataSwarmRole: "api" };
|
|
113
|
+
const WORKER_SPEC: LaunchSpec = {
|
|
114
|
+
swarmRole: "worker",
|
|
115
|
+
envScope: "worker",
|
|
116
|
+
metadataSwarmRole: "worker",
|
|
117
|
+
};
|
|
48
118
|
|
|
49
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Stack-specific specs. The lead is E2B `SwarmRole === "worker"` (same template
|
|
121
|
+
* + entrypoint) but pins `agentRole:"lead"`, its own `"lead"` env scope, a
|
|
122
|
+
* dedicated `--lead-agent-id` override + `e2b-lead-<sandboxID>` default, and a
|
|
123
|
+
* `"lead"` grouping role so `e2b swarms` can tell it apart from the workers.
|
|
124
|
+
*/
|
|
125
|
+
const STACK_LEAD_SPEC: LaunchSpec = {
|
|
126
|
+
swarmRole: "worker",
|
|
127
|
+
agentRole: "lead",
|
|
128
|
+
envScope: "lead",
|
|
129
|
+
agentIdFlag: "lead-agent-id",
|
|
130
|
+
agentIdPrefix: "e2b-lead",
|
|
131
|
+
metadataSwarmRole: "lead",
|
|
132
|
+
};
|
|
133
|
+
const STACK_WORKER_SPEC: LaunchSpec = {
|
|
134
|
+
swarmRole: "worker",
|
|
135
|
+
agentRole: "worker",
|
|
136
|
+
envScope: "worker",
|
|
137
|
+
metadataSwarmRole: "worker",
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const DEFAULT_API_PORT = 3013;
|
|
141
|
+
const BOOLEAN_FLAGS = new Set([
|
|
142
|
+
"dry-run",
|
|
143
|
+
"json",
|
|
144
|
+
"no-cache",
|
|
145
|
+
"no-wait",
|
|
146
|
+
"all",
|
|
147
|
+
"yes",
|
|
148
|
+
"non-interactive",
|
|
149
|
+
"no-lead",
|
|
150
|
+
"reveal-key",
|
|
151
|
+
// `swarms logs --follow` tails live output. Boolean so it never swallows the
|
|
152
|
+
// next positional/flag (e.g. the slug or `--role`).
|
|
153
|
+
"follow",
|
|
154
|
+
// `swarms add --add-lead` adds a lead to an existing swarm (in addition to or
|
|
155
|
+
// instead of workers). Boolean so it never swallows the next positional slug.
|
|
156
|
+
"add-lead",
|
|
157
|
+
// Integration disable shortcuts: `--no-<integration>` sets the matching
|
|
158
|
+
// API-side `*_DISABLE=true`. The `--integrations <csv>` allowlist is the
|
|
159
|
+
// value-bearing alternative (handled separately).
|
|
160
|
+
"no-slack",
|
|
161
|
+
"no-github",
|
|
162
|
+
"no-jira",
|
|
163
|
+
"no-linear",
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
export function parseFlags(argv: string[]): ParsedFlags {
|
|
50
167
|
const [command, ...rest] = argv;
|
|
51
168
|
const positionals: string[] = [];
|
|
52
169
|
const values = new Map<string, string[]>();
|
|
@@ -194,25 +311,80 @@ function e2bApiBase(flags: ParsedFlags, controllerEnv: EnvMap): string {
|
|
|
194
311
|
return value(flags, "e2b-api-base") || controllerEnv.E2B_API_URL || DEFAULT_E2B_API_BASE;
|
|
195
312
|
}
|
|
196
313
|
|
|
197
|
-
|
|
314
|
+
/** Read every `--{key}` env-file (repeatable) and merge them left-to-right. */
|
|
315
|
+
async function loadEnvFiles(flags: ParsedFlags, key: string): Promise<EnvMap> {
|
|
316
|
+
const paths = values(flags, key).map((path) => absolutePath(path));
|
|
317
|
+
const merged: EnvMap = {};
|
|
318
|
+
for (const env of await Promise.all(paths.map((path) => readDotenvFile(path)))) {
|
|
319
|
+
Object.assign(merged, env);
|
|
320
|
+
}
|
|
321
|
+
return merged;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Apply every `--{key} KEY=VALUE` secret (repeatable) onto `target`, in order. */
|
|
325
|
+
function applySecrets(flags: ParsedFlags, key: string, target: EnvMap): void {
|
|
326
|
+
for (const raw of values(flags, key)) {
|
|
327
|
+
const [secretKey, secretValue] = parseKeyValue(raw, `--${key}`);
|
|
328
|
+
target[secretKey] = secretValue;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Integrations toggleable via `--integrations <csv>` / `--no-<integration>`. */
|
|
333
|
+
const E2B_INTEGRATIONS = ["slack", "github", "jira", "linear"] as const;
|
|
334
|
+
type E2BIntegration = (typeof E2B_INTEGRATIONS)[number];
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Resolve which integrations are enabled. Default: all on. `--integrations
|
|
338
|
+
* <csv>` is an allowlist — anything not listed is disabled. `--no-<integration>`
|
|
339
|
+
* disables a single one (and stacks on top of the allowlist). Returns a map of
|
|
340
|
+
* integration → enabled.
|
|
341
|
+
*/
|
|
342
|
+
export function resolveIntegrationToggles(flags: ParsedFlags): Record<E2BIntegration, boolean> {
|
|
343
|
+
const allowlistRaw = splitKeys(values(flags, "integrations")).map((s) => s.toLowerCase());
|
|
344
|
+
const hasAllowlist = allowlistRaw.length > 0;
|
|
345
|
+
const toggles = {} as Record<E2BIntegration, boolean>;
|
|
346
|
+
for (const integration of E2B_INTEGRATIONS) {
|
|
347
|
+
// With an allowlist, only listed integrations stay on; without one, all on.
|
|
348
|
+
let enabled = hasAllowlist ? allowlistRaw.includes(integration) : true;
|
|
349
|
+
if (booleanFlag(flags, `no-${integration}`)) enabled = false;
|
|
350
|
+
toggles[integration] = enabled;
|
|
351
|
+
}
|
|
352
|
+
return toggles;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Stamp `*_DISABLE=true` for any integration the operator turned off. These envs
|
|
357
|
+
* are read API-side, so the caller only applies this to the API runtime scope.
|
|
358
|
+
*/
|
|
359
|
+
function applyIntegrationDisables(flags: ParsedFlags, target: EnvMap): void {
|
|
360
|
+
const toggles = resolveIntegrationToggles(flags);
|
|
361
|
+
for (const integration of E2B_INTEGRATIONS) {
|
|
362
|
+
if (!toggles[integration]) {
|
|
363
|
+
target[`${integration.toUpperCase()}_DISABLE`] = "true";
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export async function loadRuntimeEnv(
|
|
198
369
|
flags: ParsedFlags,
|
|
199
|
-
|
|
370
|
+
spec: LaunchSpec,
|
|
200
371
|
apiUrl?: string,
|
|
201
372
|
): Promise<EnvMap> {
|
|
202
|
-
const
|
|
203
|
-
const
|
|
204
|
-
for (const env of await Promise.all(envFiles.map((path) => readDotenvFile(path)))) {
|
|
205
|
-
Object.assign(fileEnv, env);
|
|
206
|
-
}
|
|
373
|
+
const role = spec.swarmRole;
|
|
374
|
+
const scope = spec.envScope;
|
|
207
375
|
|
|
376
|
+
// Precedence (lowest → highest, later overrides earlier):
|
|
377
|
+
// forward-keys (process.env) < shared --env-file < scoped --{scope}-env-file
|
|
378
|
+
// < shared --secret < scoped --{scope}-secret < forced API_KEY/AGENT_SWARM_API_KEY.
|
|
379
|
+
// Scoped flags LAYER ON TOP of the shared ones — they never replace them.
|
|
208
380
|
const inheritKeys = [...DEFAULT_E2B_FORWARD_KEYS, ...splitKeys(values(flags, "inherit-env"))];
|
|
209
|
-
const
|
|
210
|
-
const runtime: EnvMap = { ...inherited, ...fileEnv };
|
|
381
|
+
const runtime: EnvMap = selectEnv(process.env, inheritKeys);
|
|
211
382
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
383
|
+
Object.assign(runtime, await loadEnvFiles(flags, "env-file"));
|
|
384
|
+
Object.assign(runtime, await loadEnvFiles(flags, `${scope}-env-file`));
|
|
385
|
+
|
|
386
|
+
applySecrets(flags, "secret", runtime);
|
|
387
|
+
applySecrets(flags, `${scope}-secret`, runtime);
|
|
216
388
|
|
|
217
389
|
let swarmApiKey: string;
|
|
218
390
|
try {
|
|
@@ -242,12 +414,19 @@ async function loadRuntimeEnv(
|
|
|
242
414
|
runtime.SCRIPT_RUNTIME_DIR = value(flags, "script-runtime-dir", "/app/scripts-runtime");
|
|
243
415
|
runtime.TS_LIB_DIR = value(flags, "ts-lib-dir", "/app/typescript-lib");
|
|
244
416
|
runtime.SCRIPT_TYPES_DIR = value(flags, "script-types-dir", "/app/script-types");
|
|
417
|
+
// Integration toggles are read API-side, so they only ever apply to the API
|
|
418
|
+
// sandbox's runtime env. `--no-<integration>` / `--integrations <csv>`
|
|
419
|
+
// resolve to `*_DISABLE=true` here.
|
|
420
|
+
applyIntegrationDisables(flags, runtime);
|
|
245
421
|
} else {
|
|
246
422
|
if (!apiUrl) {
|
|
247
423
|
throw new Error("Worker startup requires --api-url, or use start-stack to create API first.");
|
|
248
424
|
}
|
|
249
425
|
runtime.MCP_BASE_URL = apiUrl;
|
|
250
|
-
|
|
426
|
+
// AGENT_ROLE comes from the spec (so start-stack can pin lead/worker per
|
|
427
|
+
// instance); when the spec leaves it unset we fall back to the global
|
|
428
|
+
// --agent-role flag, keeping start-worker byte-identical to before.
|
|
429
|
+
runtime.AGENT_ROLE = spec.agentRole ?? value(flags, "agent-role", "worker");
|
|
251
430
|
runtime.HARNESS_PROVIDER = value(flags, "provider", runtime.HARNESS_PROVIDER || "claude");
|
|
252
431
|
runtime.WORKER_YOLO = value(flags, "worker-yolo", "false");
|
|
253
432
|
runtime.WORKER_LOG_DIR = value(flags, "worker-log-dir", "/logs");
|
|
@@ -297,19 +476,89 @@ async function loadRuntimeEnv(
|
|
|
297
476
|
return runtime;
|
|
298
477
|
}
|
|
299
478
|
|
|
300
|
-
|
|
479
|
+
/**
|
|
480
|
+
* Reserved sandbox-metadata keys this dispatcher stamps on every launch. They
|
|
481
|
+
* are read back by `e2b list`, `e2b kill --all`, and the `e2b swarms` family to
|
|
482
|
+
* group/inspect sandboxes — operators should not override them via `--metadata`.
|
|
483
|
+
*
|
|
484
|
+
* app — "agent-swarm" (provenance; every sandbox we create)
|
|
485
|
+
* role — E2B SwarmRole ("api" | "worker"); template/entrypoint dimension
|
|
486
|
+
* launcher — "agent-swarm-e2b" (the dispatcher tag `kill --all` filters on)
|
|
487
|
+
* swarm — shared slug grouping every sandbox of one launch (Phase 4)
|
|
488
|
+
* swarmRole — grouping role ("api" | "lead" | "worker"); a lead is E2B
|
|
489
|
+
* role:"worker" but swarmRole:"lead", so `swarms info` can tell
|
|
490
|
+
* the lead apart from the workers
|
|
491
|
+
* apiPort — (API sandbox only) the port the swarm API listens on, so
|
|
492
|
+
* `swarms info` reconstructs the API URL without guessing
|
|
493
|
+
* agentId — (lead/worker only) the agent ID it registered under, when known
|
|
494
|
+
* pre-create (explicit --agent-id / --lead-agent-id / env). When
|
|
495
|
+
* absent (auto `<prefix>-<sandboxID>` default), `swarms info`
|
|
496
|
+
* reconstructs it from the sandbox ID + swarmRole.
|
|
497
|
+
*/
|
|
498
|
+
const RESERVED_METADATA_KEYS = [
|
|
499
|
+
"app",
|
|
500
|
+
"role",
|
|
501
|
+
"launcher",
|
|
502
|
+
"swarm",
|
|
503
|
+
"swarmRole",
|
|
504
|
+
"apiPort",
|
|
505
|
+
"agentId",
|
|
506
|
+
] as const;
|
|
507
|
+
|
|
508
|
+
type MetadataTagging = {
|
|
509
|
+
/** E2B SwarmRole — the template/entrypoint dimension. */
|
|
510
|
+
role: SwarmRole;
|
|
511
|
+
/** Shared swarm slug for grouping (Phase 4). Omitted on legacy single-role launches. */
|
|
512
|
+
swarm?: string;
|
|
513
|
+
/** Grouping role for `e2b swarms` (api | lead | worker). */
|
|
514
|
+
swarmRole?: MetadataSwarmRole;
|
|
515
|
+
/** API port (API sandbox only) so `swarms info` rebuilds the URL deterministically. */
|
|
516
|
+
apiPort?: number;
|
|
517
|
+
/** Resolved agent ID (lead/worker only) when known before sandbox creation. */
|
|
518
|
+
agentId?: string;
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
function parseMetadata(flags: ParsedFlags, tagging: MetadataTagging): Record<string, string> {
|
|
301
522
|
const metadata: Record<string, string> = {
|
|
302
523
|
app: "agent-swarm",
|
|
303
|
-
role,
|
|
524
|
+
role: tagging.role,
|
|
304
525
|
launcher: "agent-swarm-e2b",
|
|
305
526
|
};
|
|
527
|
+
if (tagging.swarm) metadata.swarm = tagging.swarm;
|
|
528
|
+
if (tagging.swarmRole) metadata.swarmRole = tagging.swarmRole;
|
|
529
|
+
if (tagging.apiPort !== undefined) metadata.apiPort = String(tagging.apiPort);
|
|
530
|
+
if (tagging.agentId) metadata.agentId = tagging.agentId;
|
|
531
|
+
const reserved = new Set<string>(RESERVED_METADATA_KEYS);
|
|
306
532
|
for (const raw of values(flags, "metadata")) {
|
|
307
533
|
const [key, metadataValue] = parseKeyValue(raw, "--metadata");
|
|
534
|
+
if (reserved.has(key)) {
|
|
535
|
+
// The dispatcher owns these keys (grouping/teardown depend on them); a
|
|
536
|
+
// user override would silently break `kill --all` / the `swarms` family.
|
|
537
|
+
console.warn(`e2b: ignoring --metadata ${key}=… (reserved by the dispatcher)`);
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
308
540
|
metadata[key] = metadataValue;
|
|
309
541
|
}
|
|
310
542
|
return metadata;
|
|
311
543
|
}
|
|
312
544
|
|
|
545
|
+
/** The grouping role to stamp for a spec (defaults from swarmRole/agentRole). */
|
|
546
|
+
function metadataSwarmRoleForSpec(spec: LaunchSpec): MetadataSwarmRole {
|
|
547
|
+
if (spec.metadataSwarmRole) return spec.metadataSwarmRole;
|
|
548
|
+
if (spec.swarmRole === "api") return "api";
|
|
549
|
+
return spec.agentRole === "lead" ? "lead" : "worker";
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* The auto-generated AGENT_ID prefix for a grouping role (mirrors the prefixes
|
|
554
|
+
* in {@link startRole}). Lets `swarms info` reconstruct an agent ID from a
|
|
555
|
+
* sandbox ID when `metadata.agentId` is absent (the auto `<prefix>-<sandboxID>`
|
|
556
|
+
* default was used).
|
|
557
|
+
*/
|
|
558
|
+
function agentIdPrefixForSwarmRole(swarmRole: MetadataSwarmRole): string {
|
|
559
|
+
return swarmRole === "lead" ? "e2b-lead" : "e2b";
|
|
560
|
+
}
|
|
561
|
+
|
|
313
562
|
function roleTemplate(flags: ParsedFlags, role: SwarmRole): string {
|
|
314
563
|
return value(
|
|
315
564
|
flags,
|
|
@@ -322,9 +571,24 @@ function localDockerfile(role: SwarmRole): string {
|
|
|
322
571
|
return role === "api" ? "Dockerfile" : "Dockerfile.worker";
|
|
323
572
|
}
|
|
324
573
|
|
|
574
|
+
function formatDuration(secondsLeft: number): string {
|
|
575
|
+
if (secondsLeft <= 0) return "expired";
|
|
576
|
+
const hours = Math.floor(secondsLeft / 3600);
|
|
577
|
+
const minutes = Math.floor((secondsLeft % 3600) / 60);
|
|
578
|
+
const parts: string[] = [];
|
|
579
|
+
if (hours > 0) parts.push(`${hours}h`);
|
|
580
|
+
// Always show minutes when under an hour, otherwise show them alongside hours.
|
|
581
|
+
if (minutes > 0 || hours === 0) parts.push(`${minutes}m`);
|
|
582
|
+
return parts.join(" ");
|
|
583
|
+
}
|
|
584
|
+
|
|
325
585
|
function printHumanStart(result: StartedRole, env: EnvMap): void {
|
|
326
586
|
console.log(`${result.role} sandbox: ${result.sandbox.sandboxID}`);
|
|
327
587
|
if (result.url) console.log(`${result.role} url: ${result.url}`);
|
|
588
|
+
const ttl = ttlRemaining(result.sandbox);
|
|
589
|
+
if (ttl.expiresAt && ttl.secondsLeft !== undefined) {
|
|
590
|
+
console.log(`${result.role} expires: ${ttl.expiresAt} (in ${formatDuration(ttl.secondsLeft)})`);
|
|
591
|
+
}
|
|
328
592
|
console.log(
|
|
329
593
|
redactWithEnv(`inspect: e2b sandbox info ${result.sandbox.sandboxID} --format json`, env),
|
|
330
594
|
);
|
|
@@ -340,18 +604,37 @@ function publicStartedRole(result: StartedRole, env: EnvMap): StartedRole {
|
|
|
340
604
|
async function startRole(
|
|
341
605
|
flags: ParsedFlags,
|
|
342
606
|
cwd: string,
|
|
343
|
-
|
|
607
|
+
spec: LaunchSpec,
|
|
344
608
|
apiUrl?: string,
|
|
345
609
|
): Promise<StartedRole> {
|
|
610
|
+
const role = spec.swarmRole;
|
|
346
611
|
const controllerEnv = await loadE2BControllerEnv(flags, cwd);
|
|
347
|
-
const runtimeEnv = await loadRuntimeEnv(flags,
|
|
612
|
+
const runtimeEnv = await loadRuntimeEnv(flags, spec, apiUrl);
|
|
348
613
|
const controllerApiKey = e2bControllerApiKey(controllerEnv);
|
|
349
614
|
const template = roleTemplate(flags, role);
|
|
350
615
|
const timeoutSec = integerFlag(flags, "timeout-sec", 3600);
|
|
351
616
|
const apiBase = e2bApiBase(flags, controllerEnv);
|
|
352
617
|
const dryRun = booleanFlag(flags, "dry-run");
|
|
353
618
|
const port = Number.parseInt(runtimeEnv.PORT || String(DEFAULT_API_PORT), 10);
|
|
354
|
-
|
|
619
|
+
|
|
620
|
+
const swarmSlug = value(flags, "swarm") || undefined;
|
|
621
|
+
const metadataSwarmRole = metadataSwarmRoleForSpec(spec);
|
|
622
|
+
// Resolve the agent ID we can know BEFORE the sandbox exists: an explicit
|
|
623
|
+
// --agent-id / --lead-agent-id flag, or AGENT_ID from the runtime env. The
|
|
624
|
+
// auto `<prefix>-<sandboxID>` default depends on the not-yet-created sandbox
|
|
625
|
+
// ID, so it is NOT stamped here — `swarms info` reconstructs it from the
|
|
626
|
+
// sandbox ID + swarmRole instead.
|
|
627
|
+
const preCreateAgentId =
|
|
628
|
+
role === "worker"
|
|
629
|
+
? value(flags, spec.agentIdFlag ?? "agent-id") || runtimeEnv.AGENT_ID || undefined
|
|
630
|
+
: undefined;
|
|
631
|
+
const metadata = parseMetadata(flags, {
|
|
632
|
+
role,
|
|
633
|
+
swarm: swarmSlug,
|
|
634
|
+
swarmRole: metadataSwarmRole,
|
|
635
|
+
apiPort: role === "api" ? port : undefined,
|
|
636
|
+
agentId: preCreateAgentId,
|
|
637
|
+
});
|
|
355
638
|
|
|
356
639
|
if (dryRun) {
|
|
357
640
|
const fakeSandbox = {
|
|
@@ -360,6 +643,7 @@ async function startRole(
|
|
|
360
643
|
envdAccessToken: "dry-run",
|
|
361
644
|
domain: "e2b.app",
|
|
362
645
|
metadata,
|
|
646
|
+
expiresAt: new Date(Date.now() + timeoutSec * 1000).toISOString(),
|
|
363
647
|
};
|
|
364
648
|
return {
|
|
365
649
|
role,
|
|
@@ -379,7 +663,14 @@ async function startRole(
|
|
|
379
663
|
|
|
380
664
|
try {
|
|
381
665
|
if (role === "worker" && !runtimeEnv.AGENT_ID) {
|
|
382
|
-
|
|
666
|
+
// Per-instance AGENT_ID. The explicit-override flag and the generated
|
|
667
|
+
// default prefix come from the spec so the stack's lead never collides
|
|
668
|
+
// with a worker (lead → --lead-agent-id / e2b-lead-<id>; worker →
|
|
669
|
+
// --agent-id / e2b-<id>). Sandbox IDs are unique, so each instance
|
|
670
|
+
// registers distinctly even without an explicit override.
|
|
671
|
+
const agentIdFlag = spec.agentIdFlag ?? "agent-id";
|
|
672
|
+
const agentIdPrefix = spec.agentIdPrefix ?? "e2b";
|
|
673
|
+
runtimeEnv.AGENT_ID = value(flags, agentIdFlag, `${agentIdPrefix}-${sandbox.sandboxID}`);
|
|
383
674
|
}
|
|
384
675
|
|
|
385
676
|
const entrypoint = role === "api" ? "/api-entrypoint.sh" : "/docker-entrypoint.sh";
|
|
@@ -541,8 +832,8 @@ async function templateVisibilityCommand(
|
|
|
541
832
|
}
|
|
542
833
|
|
|
543
834
|
async function startApiCommand(flags: ParsedFlags, cwd: string): Promise<void> {
|
|
544
|
-
const result = await startRole(flags, cwd,
|
|
545
|
-
const runtimeEnv = await loadRuntimeEnv(flags,
|
|
835
|
+
const result = await startRole(flags, cwd, API_SPEC);
|
|
836
|
+
const runtimeEnv = await loadRuntimeEnv(flags, API_SPEC);
|
|
546
837
|
if (booleanFlag(flags, "json")) {
|
|
547
838
|
console.log(JSON.stringify(publicStartedRole(result, runtimeEnv), null, 2));
|
|
548
839
|
} else {
|
|
@@ -552,8 +843,8 @@ async function startApiCommand(flags: ParsedFlags, cwd: string): Promise<void> {
|
|
|
552
843
|
|
|
553
844
|
async function startWorkerCommand(flags: ParsedFlags, cwd: string): Promise<void> {
|
|
554
845
|
const apiUrl = value(flags, "api-url");
|
|
555
|
-
const result = await startRole(flags, cwd,
|
|
556
|
-
const runtimeEnv = await loadRuntimeEnv(flags,
|
|
846
|
+
const result = await startRole(flags, cwd, WORKER_SPEC, apiUrl);
|
|
847
|
+
const runtimeEnv = await loadRuntimeEnv(flags, WORKER_SPEC, apiUrl);
|
|
557
848
|
if (booleanFlag(flags, "json")) {
|
|
558
849
|
console.log(JSON.stringify(publicStartedRole(result, runtimeEnv), null, 2));
|
|
559
850
|
} else {
|
|
@@ -587,39 +878,183 @@ async function cleanupStartedRoles(
|
|
|
587
878
|
}
|
|
588
879
|
}
|
|
589
880
|
|
|
881
|
+
async function resyncStackTimeout(
|
|
882
|
+
flags: ParsedFlags,
|
|
883
|
+
cwd: string,
|
|
884
|
+
started: StartedRole[],
|
|
885
|
+
): Promise<void> {
|
|
886
|
+
if (booleanFlag(flags, "dry-run") || started.length === 0) return;
|
|
887
|
+
|
|
888
|
+
const timeoutSec = integerFlag(flags, "timeout-sec", 3600);
|
|
889
|
+
const controllerEnv = await loadE2BControllerEnv(flags, cwd);
|
|
890
|
+
const controllerApiKey = e2bControllerApiKey(controllerEnv);
|
|
891
|
+
const apiBase = e2bApiBase(flags, controllerEnv);
|
|
892
|
+
|
|
893
|
+
for (const role of started) {
|
|
894
|
+
try {
|
|
895
|
+
await setSandboxTimeout({
|
|
896
|
+
sandboxId: role.sandbox.sandboxID,
|
|
897
|
+
apiKey: controllerApiKey,
|
|
898
|
+
apiBase,
|
|
899
|
+
e2bEnv: controllerEnv,
|
|
900
|
+
timeoutMs: timeoutSec * 1000,
|
|
901
|
+
});
|
|
902
|
+
} catch (err) {
|
|
903
|
+
// A re-sync failure is non-fatal — the sandbox is still up with its
|
|
904
|
+
// original (slightly shorter) TTL. setSandboxTimeout already redacts.
|
|
905
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
906
|
+
console.warn(
|
|
907
|
+
redactWithEnv(
|
|
908
|
+
`e2b: failed to re-sync TTL for ${role.role} sandbox ${role.sandbox.sandboxID}: ${message}`,
|
|
909
|
+
controllerEnv,
|
|
910
|
+
),
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* `start-stack` should run headless (no prompts, never read stdin) whenever:
|
|
918
|
+
* - `--yes` / `--non-interactive` is passed,
|
|
919
|
+
* - `--dry-run` is passed (CI/preview path), or
|
|
920
|
+
* - we're not on an interactive TTY (piped / redirected stdin or stdout).
|
|
921
|
+
* Critically, the piped case (`echo | … start-stack …`) MUST take this path so
|
|
922
|
+
* it exits without hanging on a prompt that no one can answer.
|
|
923
|
+
*/
|
|
924
|
+
function isStackHeadless(flags: ParsedFlags): boolean {
|
|
925
|
+
return (
|
|
926
|
+
booleanFlag(flags, "yes") ||
|
|
927
|
+
booleanFlag(flags, "non-interactive") ||
|
|
928
|
+
booleanFlag(flags, "dry-run") ||
|
|
929
|
+
!isInteractiveTty()
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
|
|
590
933
|
async function startStackCommand(flags: ParsedFlags, cwd: string): Promise<void> {
|
|
934
|
+
// `--agent-role` is meaningless for the split topology (API + lead + workers
|
|
935
|
+
// each get a fixed role). Warn and point the operator at the right tool
|
|
936
|
+
// rather than silently ignoring an intent to change roles.
|
|
937
|
+
if (value(flags, "agent-role")) {
|
|
938
|
+
console.warn(
|
|
939
|
+
"e2b start-stack: --agent-role is ignored (the stack pins API/lead/worker roles). " +
|
|
940
|
+
"Use --no-lead for an API + workers topology, or start-worker --agent-role for a single custom-role worker.",
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Normalize a user-supplied --swarm into a clean slug so the value is
|
|
945
|
+
// consistent whether it came from a flag or the wizard. We do NOT synthesize
|
|
946
|
+
// the random fallback here: doing so would mark `swarm` as "set" and make the
|
|
947
|
+
// wizard skip its Swarm-name step, so a TTY operator without --swarm could
|
|
948
|
+
// never name the swarm. The `swarm-<short-random>` default is applied AFTER
|
|
949
|
+
// the wizard (below) — by which point either the operator named it or we fill
|
|
950
|
+
// it in for the headless / unnamed path.
|
|
951
|
+
const swarmFlag = value(flags, "swarm");
|
|
952
|
+
if (swarmFlag) {
|
|
953
|
+
setFlagValue(flags, "swarm", slugify(swarmFlag));
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// Interactive wizard (TTY only). Headless runs (--yes / --non-interactive /
|
|
957
|
+
// --dry-run / non-TTY) skip it entirely and rely on flags + defaults. The
|
|
958
|
+
// wizard may set/overwrite the swarm slug if the operator names the swarm.
|
|
959
|
+
if (!isStackHeadless(flags)) {
|
|
960
|
+
await runStackWizard(flags);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Now that the wizard (if any) has run, GENERATE a shared slug when neither
|
|
964
|
+
// the flag nor the wizard produced one. Every sandbox of this launch then
|
|
965
|
+
// shares a single grouping slug stamped onto metadata.swarm (read by
|
|
966
|
+
// `e2b swarms`). This lands on `flags` BEFORE any startRole call so all roles
|
|
967
|
+
// inherit it.
|
|
968
|
+
if (!value(flags, "swarm")) {
|
|
969
|
+
setFlagValue(flags, "swarm", generateSwarmSlug());
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// A single explicit --agent-id is reused verbatim for every worker in the loop
|
|
973
|
+
// below, but the API registration path reuses the row for an existing
|
|
974
|
+
// X-Agent-ID — so N workers would collapse into one agent record and the wait
|
|
975
|
+
// loop would poll the same agent N times. Reject the shared explicit ID for
|
|
976
|
+
// multi-worker stacks; the per-sandbox `e2b-<sandboxID>` default (or a
|
|
977
|
+
// single-worker stack) stays unaffected. `--workers` is resolved here so a
|
|
978
|
+
// wizard-chosen count is also covered.
|
|
979
|
+
const explicitWorkerAgentId = value(flags, STACK_WORKER_SPEC.agentIdFlag ?? "agent-id");
|
|
980
|
+
if (explicitWorkerAgentId && integerFlag(flags, "workers", 1) > 1) {
|
|
981
|
+
throw new Error(
|
|
982
|
+
"e2b start-stack: --agent-id cannot be shared across multiple workers " +
|
|
983
|
+
"(it collapses them into a single agent record). Drop --agent-id to use " +
|
|
984
|
+
"the per-sandbox default, or run --workers 1.",
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// Echo the resolved slug up front so the operator can group/inspect/extend the
|
|
989
|
+
// launch via `e2b swarms <cmd> <slug>` even if a later role fails. Under --json
|
|
990
|
+
// STDOUT must carry ONLY the final JSON payload, so route this human echo to
|
|
991
|
+
// STDERR (still visible to the operator, never pollutes `... --json | jq`).
|
|
992
|
+
const swarmSlug = value(flags, "swarm");
|
|
993
|
+
if (booleanFlag(flags, "json")) {
|
|
994
|
+
console.error(`swarm: ${swarmSlug}`);
|
|
995
|
+
} else {
|
|
996
|
+
console.log(`swarm: ${swarmSlug}`);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const noLead = booleanFlag(flags, "no-lead");
|
|
591
1000
|
const started: StartedRole[] = [];
|
|
1001
|
+
let lead: StartedRole | undefined;
|
|
592
1002
|
const workers: StartedRole[] = [];
|
|
593
1003
|
|
|
594
1004
|
try {
|
|
595
|
-
const api = await startRole(flags, cwd,
|
|
1005
|
+
const api = await startRole(flags, cwd, API_SPEC);
|
|
596
1006
|
started.push(api);
|
|
597
1007
|
if (!api.url) throw new Error("API sandbox did not produce a public URL");
|
|
598
1008
|
|
|
1009
|
+
// (2) One lead, unless --no-lead retains the legacy homogeneous topology.
|
|
1010
|
+
if (!noLead) {
|
|
1011
|
+
lead = await startRole(flags, cwd, STACK_LEAD_SPEC, api.url);
|
|
1012
|
+
// The lead MUST be in `started[]` so a mid-launch failure tears it down,
|
|
1013
|
+
// and so the TTL re-sync pass below covers it.
|
|
1014
|
+
started.push(lead);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// (3) N workers.
|
|
599
1018
|
const workerCount = integerFlag(flags, "workers", 1);
|
|
600
1019
|
for (let i = 0; i < workerCount; i++) {
|
|
601
|
-
const worker = await startRole(flags, cwd,
|
|
1020
|
+
const worker = await startRole(flags, cwd, STACK_WORKER_SPEC, api.url);
|
|
602
1021
|
workers.push(worker);
|
|
603
1022
|
started.push(worker);
|
|
604
1023
|
}
|
|
605
|
-
|
|
1024
|
+
|
|
1025
|
+
// Re-sync the whole stack to a single wall-clock TTL. The API sandbox is
|
|
1026
|
+
// created first, so by the time the last worker is up its remaining TTL is
|
|
1027
|
+
// shorter than the API's. One setSandboxTimeout pass aligns every sandbox
|
|
1028
|
+
// to `timeoutSec` from now (E2B clamps to the tier max as usual). Dry-run
|
|
1029
|
+
// short-circuits — never touches E2B.
|
|
1030
|
+
await resyncStackTimeout(flags, cwd, started);
|
|
1031
|
+
|
|
1032
|
+
const runtimeEnv = await loadRuntimeEnv(flags, API_SPEC);
|
|
606
1033
|
|
|
607
1034
|
if (booleanFlag(flags, "json")) {
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
),
|
|
617
|
-
);
|
|
1035
|
+
// Legacy shape under --no-lead: {api, workers}. New shape with a lead:
|
|
1036
|
+
// {api, lead, workers}.
|
|
1037
|
+
const payload: Record<string, unknown> = {
|
|
1038
|
+
api: publicStartedRole(api, runtimeEnv),
|
|
1039
|
+
};
|
|
1040
|
+
if (lead) payload.lead = publicStartedRole(lead, runtimeEnv);
|
|
1041
|
+
payload.workers = workers.map((worker) => publicStartedRole(worker, runtimeEnv));
|
|
1042
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
618
1043
|
} else {
|
|
619
1044
|
printHumanStart(api, runtimeEnv);
|
|
1045
|
+
if (lead) printHumanStart(lead, runtimeEnv);
|
|
620
1046
|
for (const worker of workers) {
|
|
621
1047
|
printHumanStart(worker, runtimeEnv);
|
|
622
1048
|
}
|
|
1049
|
+
// Dashboard deep-link (key hidden unless --reveal-key). Only printed on the
|
|
1050
|
+
// human path — the --json payload is consumed programmatically and the URL
|
|
1051
|
+
// would otherwise embed the swarm key in machine output.
|
|
1052
|
+
printDashboardDeepLink(flags, {
|
|
1053
|
+
apiUrl: api.url,
|
|
1054
|
+
apiKey: runtimeEnv.AGENT_SWARM_API_KEY,
|
|
1055
|
+
name: swarmSlug,
|
|
1056
|
+
env: runtimeEnv,
|
|
1057
|
+
});
|
|
623
1058
|
}
|
|
624
1059
|
} catch (err) {
|
|
625
1060
|
await cleanupStartedRoles(flags, cwd, started);
|
|
@@ -627,13 +1062,315 @@ async function startStackCommand(flags: ParsedFlags, cwd: string): Promise<void>
|
|
|
627
1062
|
}
|
|
628
1063
|
}
|
|
629
1064
|
|
|
630
|
-
|
|
1065
|
+
/** Set/replace a single-value flag in place (mirrors `--key value`). */
|
|
1066
|
+
function setFlagValue(flags: ParsedFlags, key: string, value: string): void {
|
|
1067
|
+
flags.values.set(key, [value]);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Compute which wizard steps to skip because the operator already supplied the
|
|
1072
|
+
* value on the command line. A step is skipped when its driving flag is present.
|
|
1073
|
+
*/
|
|
1074
|
+
function stackWizardSkips(flags: ParsedFlags): StackWizardSkips {
|
|
1075
|
+
return {
|
|
1076
|
+
swarm: Boolean(value(flags, "swarm")),
|
|
1077
|
+
workers: flags.values.has("workers"),
|
|
1078
|
+
provider: flags.values.has("provider"),
|
|
1079
|
+
timeout: flags.values.has("timeout-sec"),
|
|
1080
|
+
envFiles: flags.values.has("env-file"),
|
|
1081
|
+
integrations:
|
|
1082
|
+
flags.values.has("integrations") ||
|
|
1083
|
+
STACK_INTEGRATIONS.some((i) => booleanFlag(flags, `no-${i}`)),
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/** Seed the wizard with whatever the flags already resolve to. */
|
|
1088
|
+
function stackWizardDefaults(flags: ParsedFlags): StackWizardDefaults {
|
|
1089
|
+
return {
|
|
1090
|
+
swarmSlug: value(flags, "swarm") || undefined,
|
|
1091
|
+
workers: integerFlag(flags, "workers", DEFAULT_STACK_WORKERS),
|
|
1092
|
+
provider: value(flags, "provider", "claude"),
|
|
1093
|
+
timeoutSec: integerFlag(flags, "timeout-sec", DEFAULT_STACK_TIMEOUT_SEC),
|
|
1094
|
+
envFiles: values(flags, "env-file"),
|
|
1095
|
+
integrations: resolveIntegrationToggles(flags),
|
|
1096
|
+
noLead: booleanFlag(flags, "no-lead"),
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* Fold the wizard's answers back onto `flags` so the single headless launch
|
|
1102
|
+
* path below picks them up. Only values the wizard actually collected are
|
|
1103
|
+
* written; flag-provided values were skipped in the wizard and remain as-is.
|
|
1104
|
+
*/
|
|
1105
|
+
function applyWizardResultToFlags(flags: ParsedFlags, result: StackWizardResult): void {
|
|
1106
|
+
setFlagValue(flags, "swarm", result.swarmSlug);
|
|
1107
|
+
setFlagValue(flags, "workers", String(result.workers));
|
|
1108
|
+
setFlagValue(flags, "provider", result.provider);
|
|
1109
|
+
setFlagValue(flags, "timeout-sec", String(result.timeoutSec));
|
|
1110
|
+
if (result.envFiles.length > 0) {
|
|
1111
|
+
flags.values.set("env-file", result.envFiles);
|
|
1112
|
+
}
|
|
1113
|
+
// A disabled integration becomes `--no-<integration>` (→ API `*_DISABLE`).
|
|
1114
|
+
for (const integration of STACK_INTEGRATIONS) {
|
|
1115
|
+
if (!result.integrations[integration]) {
|
|
1116
|
+
flags.booleans.add(`no-${integration}`);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
if (result.noLead) flags.booleans.add("no-lead");
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* Reconstruct the equivalent headless one-shot command from the resolved flags,
|
|
1124
|
+
* so an operator who ran the wizard can copy/paste it for a repeatable CI run.
|
|
1125
|
+
* Secrets are NOT included — only the topology-shaping flags the wizard sets.
|
|
1126
|
+
*/
|
|
1127
|
+
function buildOneShotCommand(flags: ParsedFlags): string {
|
|
1128
|
+
const parts = ["agent-swarm e2b start-stack --yes"];
|
|
1129
|
+
const slug = value(flags, "swarm");
|
|
1130
|
+
if (slug) parts.push(`--swarm ${slug}`);
|
|
1131
|
+
parts.push(`--workers ${integerFlag(flags, "workers", DEFAULT_STACK_WORKERS)}`);
|
|
1132
|
+
const provider = value(flags, "provider");
|
|
1133
|
+
if (provider) parts.push(`--provider ${provider}`);
|
|
1134
|
+
parts.push(`--timeout-sec ${integerFlag(flags, "timeout-sec", DEFAULT_STACK_TIMEOUT_SEC)}`);
|
|
1135
|
+
for (const file of values(flags, "env-file")) {
|
|
1136
|
+
parts.push(`--env-file ${file}`);
|
|
1137
|
+
}
|
|
1138
|
+
for (const integration of STACK_INTEGRATIONS) {
|
|
1139
|
+
if (booleanFlag(flags, `no-${integration}`)) parts.push(`--no-${integration}`);
|
|
1140
|
+
}
|
|
1141
|
+
if (booleanFlag(flags, "no-lead")) parts.push("--no-lead");
|
|
1142
|
+
return parts.join(" ");
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* Render the Ink wizard, await the operator's answers, fold them onto `flags`,
|
|
1147
|
+
* and echo the equivalent `--yes` command. Only called on an interactive TTY
|
|
1148
|
+
* (see {@link isStackHeadless}).
|
|
1149
|
+
*/
|
|
1150
|
+
async function runStackWizard(flags: ParsedFlags): Promise<void> {
|
|
1151
|
+
const skips = stackWizardSkips(flags);
|
|
1152
|
+
const defaults = stackWizardDefaults(flags);
|
|
1153
|
+
|
|
1154
|
+
let resolved: StackWizardResult | undefined;
|
|
1155
|
+
const instance = render(
|
|
1156
|
+
createElement(StackWizard, {
|
|
1157
|
+
defaults,
|
|
1158
|
+
skips,
|
|
1159
|
+
onComplete: (result: StackWizardResult) => {
|
|
1160
|
+
resolved = result;
|
|
1161
|
+
},
|
|
1162
|
+
}),
|
|
1163
|
+
);
|
|
1164
|
+
await instance.waitUntilExit();
|
|
1165
|
+
|
|
1166
|
+
if (!resolved) {
|
|
1167
|
+
throw new Error("stack wizard exited without producing a configuration");
|
|
1168
|
+
}
|
|
1169
|
+
applyWizardResultToFlags(flags, resolved);
|
|
1170
|
+
|
|
1171
|
+
console.log("\nEquivalent one-shot command:");
|
|
1172
|
+
console.log(` ${buildOneShotCommand(flags)}\n`);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* Generate a fresh swarm slug (`swarm-<short-random>`) when the operator did not
|
|
1177
|
+
* name the swarm. Shared across every sandbox of one launch via `metadata.swarm`.
|
|
1178
|
+
* `crypto.randomUUID()` is overkill; a short hex tail keeps the slug readable
|
|
1179
|
+
* while staying collision-free enough for a handful of concurrent launches.
|
|
1180
|
+
*/
|
|
1181
|
+
function generateSwarmSlug(): string {
|
|
1182
|
+
const tail = Math.random().toString(16).slice(2, 8);
|
|
1183
|
+
return `swarm-${tail}`;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* Mask a swarm API key for display: keep a short non-sensitive prefix/suffix and
|
|
1188
|
+
* elide the middle. Short keys are fully masked. Never prints the whole key.
|
|
1189
|
+
*/
|
|
1190
|
+
function maskKey(key: string): string {
|
|
1191
|
+
if (!key) return "(none)";
|
|
1192
|
+
if (key.length <= 8) return "****";
|
|
1193
|
+
return `${key.slice(0, 4)}…${key.slice(-4)}`;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/**
|
|
1197
|
+
* Report where `resolveSwarmApiKey` sourced the key from, for `swarms info`. The
|
|
1198
|
+
* precedence mirrors {@link resolveSwarmApiKey} (explicit > AGENT_SWARM_API_KEY >
|
|
1199
|
+
* API_KEY > getApiKey()/env default). Returns a human label, never the value.
|
|
1200
|
+
*
|
|
1201
|
+
* `runtime` is built from `selectEnv(process.env, FORWARD_KEYS)` by the caller,
|
|
1202
|
+
* so its AGENT_SWARM_API_KEY / API_KEY entries already reflect the process env —
|
|
1203
|
+
* no direct `process.env` reads here (that path is owned by getApiKey(), per the
|
|
1204
|
+
* api-key boundary). A resolved key with neither entry came from getApiKey().
|
|
1205
|
+
*/
|
|
1206
|
+
function swarmApiKeySource(flags: ParsedFlags, runtime: EnvMap): string {
|
|
1207
|
+
if (value(flags, "api-key")) return "from --api-key";
|
|
1208
|
+
if (runtime.AGENT_SWARM_API_KEY) return "from AGENT_SWARM_API_KEY";
|
|
1209
|
+
if (runtime.API_KEY) return "from API_KEY";
|
|
1210
|
+
return "from getApiKey() default";
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
export type DashboardDeepLinkParts = {
|
|
1214
|
+
apiUrl?: string;
|
|
1215
|
+
apiKey?: string;
|
|
1216
|
+
name?: string;
|
|
1217
|
+
};
|
|
1218
|
+
|
|
1219
|
+
/**
|
|
1220
|
+
* Build the dashboard deep-link the SPA reads. The SPA expects **camelCase**
|
|
1221
|
+
* `apiUrl` / `apiKey` / `name` query params (see ui/src/hooks/use-config.ts) and
|
|
1222
|
+
* silently ignores snake_case — so these MUST stay camelCase.
|
|
1223
|
+
*
|
|
1224
|
+
* When `reveal` is false the `apiKey` param is replaced with a placeholder so the
|
|
1225
|
+
* key never lands in logs/scrollback by default. When `reveal` is true the real
|
|
1226
|
+
* key is embedded — the caller is responsible for the secret warning and for NOT
|
|
1227
|
+
* routing the revealed URL through a redactor (the key would be scrubbed out).
|
|
1228
|
+
*/
|
|
1229
|
+
export function buildDashboardDeepLink(parts: DashboardDeepLinkParts, reveal: boolean): string {
|
|
1230
|
+
const params = new URLSearchParams();
|
|
1231
|
+
if (parts.apiUrl) params.set("apiUrl", parts.apiUrl);
|
|
1232
|
+
// URLSearchParams percent-encodes the placeholder's spaces/em-dash; build the
|
|
1233
|
+
// query manually so the hidden hint stays human-readable in the printed URL.
|
|
1234
|
+
const keyParam = reveal
|
|
1235
|
+
? parts.apiKey
|
|
1236
|
+
? `apiKey=${encodeURIComponent(parts.apiKey)}`
|
|
1237
|
+
: ""
|
|
1238
|
+
: "apiKey=<hidden — pass --reveal-key>";
|
|
1239
|
+
if (parts.name) params.set("name", parts.name);
|
|
1240
|
+
const encodedRest = params.toString();
|
|
1241
|
+
const query = [keyParam, encodedRest].filter(Boolean).join("&");
|
|
1242
|
+
return `${getAppUrl()}${query ? `?${query}` : ""}`;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* Print the dashboard deep-link. Default: key hidden. With `--reveal-key`: emit
|
|
1247
|
+
* the full key-bearing URL RAW (not via redactWithEnv — a redactor would mask
|
|
1248
|
+
* the very key the operator asked to reveal) under an explicit secret warning.
|
|
1249
|
+
*/
|
|
1250
|
+
function printDashboardDeepLink(
|
|
1251
|
+
flags: ParsedFlags,
|
|
1252
|
+
opts: { apiUrl?: string; apiKey?: string; name?: string; env: EnvMap },
|
|
1253
|
+
): void {
|
|
1254
|
+
if (!opts.apiUrl) return;
|
|
1255
|
+
const reveal = booleanFlag(flags, "reveal-key");
|
|
1256
|
+
const parts: DashboardDeepLinkParts = {
|
|
1257
|
+
apiUrl: opts.apiUrl,
|
|
1258
|
+
apiKey: opts.apiKey,
|
|
1259
|
+
name: opts.name,
|
|
1260
|
+
};
|
|
1261
|
+
if (reveal) {
|
|
1262
|
+
console.log("\n⚠ secret: the URL below embeds the swarm API key — do not share or paste it.");
|
|
1263
|
+
// Intentionally NOT redacted: the operator asked to reveal the key.
|
|
1264
|
+
console.log(`dashboard: ${buildDashboardDeepLink(parts, true)}`);
|
|
1265
|
+
} else {
|
|
1266
|
+
console.log(`dashboard: ${buildDashboardDeepLink(parts, false)}`);
|
|
1267
|
+
console.log(" (pass --reveal-key to embed the swarm API key for one-click connect)");
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
function isInteractiveTty(): boolean {
|
|
1272
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/**
|
|
1276
|
+
* Prompt for a yes/no confirmation on an interactive TTY. Returns true when the
|
|
1277
|
+
* operator answers "y"/"yes". In a non-TTY (CI, piped) context there is no one
|
|
1278
|
+
* to ask, so we require an explicit `--yes` to proceed and otherwise refuse.
|
|
1279
|
+
*/
|
|
1280
|
+
async function confirm(prompt: string, flags: ParsedFlags): Promise<boolean> {
|
|
1281
|
+
if (booleanFlag(flags, "yes")) return true;
|
|
1282
|
+
if (!isInteractiveTty()) return false;
|
|
1283
|
+
process.stdout.write(`${prompt} [y/N] `);
|
|
1284
|
+
for await (const line of console) {
|
|
1285
|
+
const answer = line.trim().toLowerCase();
|
|
1286
|
+
return answer === "y" || answer === "yes";
|
|
1287
|
+
}
|
|
1288
|
+
return false;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
async function extendCommand(flags: ParsedFlags, cwd: string): Promise<void> {
|
|
631
1292
|
const ids = flags.positionals;
|
|
632
|
-
if (ids.length === 0) throw new Error("
|
|
1293
|
+
if (ids.length === 0) throw new Error("extend requires at least one sandbox ID");
|
|
1294
|
+
const timeoutSec = integerFlag(flags, "timeout-sec", 3600);
|
|
1295
|
+
const dryRun = booleanFlag(flags, "dry-run");
|
|
1296
|
+
|
|
1297
|
+
if (dryRun) {
|
|
1298
|
+
// Short-circuit before any SDK/network work so --dry-run never touches E2B.
|
|
1299
|
+
for (const id of ids) {
|
|
1300
|
+
console.log(`would extend ${id} to ${timeoutSec}s TTL`);
|
|
1301
|
+
}
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
633
1305
|
const controllerEnv = await loadE2BControllerEnv(flags, cwd);
|
|
1306
|
+
const controllerApiKey = e2bControllerApiKey(controllerEnv);
|
|
634
1307
|
const apiBase = e2bApiBase(flags, controllerEnv);
|
|
1308
|
+
|
|
1309
|
+
let failures = 0;
|
|
1310
|
+
for (const id of ids) {
|
|
1311
|
+
try {
|
|
1312
|
+
const ttl = await setSandboxTimeout({
|
|
1313
|
+
sandboxId: id,
|
|
1314
|
+
apiKey: controllerApiKey,
|
|
1315
|
+
apiBase,
|
|
1316
|
+
e2bEnv: controllerEnv,
|
|
1317
|
+
timeoutMs: timeoutSec * 1000,
|
|
1318
|
+
});
|
|
1319
|
+
if (ttl.expiresAt && ttl.secondsLeft !== undefined) {
|
|
1320
|
+
console.log(
|
|
1321
|
+
`extended ${id} — expires ${ttl.expiresAt} (in ${formatDuration(ttl.secondsLeft)})`,
|
|
1322
|
+
);
|
|
1323
|
+
} else {
|
|
1324
|
+
console.log(`extended ${id}`);
|
|
1325
|
+
}
|
|
1326
|
+
} catch (err) {
|
|
1327
|
+
failures++;
|
|
1328
|
+
// setSandboxTimeout already produces a redacted message.
|
|
1329
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1330
|
+
console.error(redactWithEnv(`e2b: extend failed: ${message}`, controllerEnv));
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
if (failures > 0) {
|
|
1334
|
+
throw new Error(`extend failed for ${failures} of ${ids.length} sandbox(es)`);
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
async function killCommand(flags: ParsedFlags, cwd: string): Promise<void> {
|
|
1339
|
+
const controllerEnv = await loadE2BControllerEnv(flags, cwd);
|
|
1340
|
+
const apiBase = e2bApiBase(flags, controllerEnv);
|
|
1341
|
+
const controllerApiKey = e2bControllerApiKey(controllerEnv);
|
|
1342
|
+
|
|
1343
|
+
let ids = flags.positionals;
|
|
1344
|
+
|
|
1345
|
+
if (booleanFlag(flags, "all")) {
|
|
1346
|
+
// Sweep everything this dispatcher launched. The launcher tag is stamped on
|
|
1347
|
+
// every sandbox by parseMetadata, so this never touches unrelated sandboxes.
|
|
1348
|
+
const sandboxes = await listSandboxes(controllerApiKey, apiBase);
|
|
1349
|
+
ids = sandboxes
|
|
1350
|
+
.filter((sandbox) => sandbox.metadata?.launcher === "agent-swarm-e2b")
|
|
1351
|
+
.map((sandbox) => sandbox.sandboxID);
|
|
1352
|
+
if (ids.length === 0) {
|
|
1353
|
+
console.log("no agent-swarm sandboxes to kill");
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
// Guard against an accidental fleet-wide teardown. A single target is
|
|
1357
|
+
// unambiguous; multiple targets require confirmation (or --yes in CI).
|
|
1358
|
+
if (ids.length > 1) {
|
|
1359
|
+
const ok = await confirm(
|
|
1360
|
+
`Kill ${ids.length} agent-swarm sandboxes (${ids.join(", ")})?`,
|
|
1361
|
+
flags,
|
|
1362
|
+
);
|
|
1363
|
+
if (!ok) {
|
|
1364
|
+
console.log("aborted (pass --yes to skip this prompt)");
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
if (ids.length === 0) throw new Error("kill requires at least one sandbox ID (or --all)");
|
|
1371
|
+
|
|
635
1372
|
for (const id of ids) {
|
|
636
|
-
await killSandbox(id,
|
|
1373
|
+
await killSandbox(id, controllerApiKey, apiBase);
|
|
637
1374
|
console.log(`killed ${id}`);
|
|
638
1375
|
}
|
|
639
1376
|
}
|
|
@@ -653,6 +1390,504 @@ async function listCommand(flags: ParsedFlags, cwd: string): Promise<void> {
|
|
|
653
1390
|
}
|
|
654
1391
|
}
|
|
655
1392
|
|
|
1393
|
+
/** Bucket key for sandboxes carrying no `metadata.swarm` tag (legacy/standalone). */
|
|
1394
|
+
const UNGROUPED_BUCKET = "(ungrouped)";
|
|
1395
|
+
|
|
1396
|
+
/**
|
|
1397
|
+
* Group dispatcher sandboxes by `metadata.swarm`. Sandboxes with no swarm tag
|
|
1398
|
+
* (legacy `start-api`/`start-worker` launches, or anything created before Phase
|
|
1399
|
+
* 4) land in the `(ungrouped)` bucket. Returns an insertion-ordered map.
|
|
1400
|
+
*/
|
|
1401
|
+
function groupSandboxesBySwarm(sandboxes: E2BSandboxInfo[]): Map<string, E2BSandboxInfo[]> {
|
|
1402
|
+
const groups = new Map<string, E2BSandboxInfo[]>();
|
|
1403
|
+
for (const sandbox of sandboxes) {
|
|
1404
|
+
const slug = sandbox.metadata?.swarm || UNGROUPED_BUCKET;
|
|
1405
|
+
const bucket = groups.get(slug);
|
|
1406
|
+
if (bucket) bucket.push(sandbox);
|
|
1407
|
+
else groups.set(slug, [sandbox]);
|
|
1408
|
+
}
|
|
1409
|
+
return groups;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/** The grouping role for a sandbox, defaulting from the E2B `role` when absent. */
|
|
1413
|
+
function sandboxSwarmRole(sandbox: E2BSandboxInfo): MetadataSwarmRole {
|
|
1414
|
+
const swarmRole = sandbox.metadata?.swarmRole;
|
|
1415
|
+
if (swarmRole === "api" || swarmRole === "lead" || swarmRole === "worker") return swarmRole;
|
|
1416
|
+
// Pre-Phase-4 sandboxes only carry the E2B role (api|worker). Map worker → worker.
|
|
1417
|
+
return sandbox.metadata?.role === "api" ? "api" : "worker";
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
/** The agent ID for a lead/worker sandbox: metadata if present, else reconstructed. */
|
|
1421
|
+
function sandboxAgentId(sandbox: E2BSandboxInfo): string {
|
|
1422
|
+
const explicit = sandbox.metadata?.agentId;
|
|
1423
|
+
if (explicit) return explicit;
|
|
1424
|
+
// Auto-generated default was `<prefix>-<sandboxID>` (see startRole). Rebuild it
|
|
1425
|
+
// so `swarms info` can name + probe the agent even without a stamped agentId.
|
|
1426
|
+
return `${agentIdPrefixForSwarmRole(sandboxSwarmRole(sandbox))}-${sandbox.sandboxID}`;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
/** The API URL for a swarm's API sandbox, preferring its own custom `domain`. */
|
|
1430
|
+
function swarmApiUrl(apiSandbox: E2BSandboxInfo, controllerEnv: EnvMap): string {
|
|
1431
|
+
const port = Number.parseInt(apiSandbox.metadata?.apiPort || String(DEFAULT_API_PORT), 10);
|
|
1432
|
+
// sandboxPortUrl already prefers the sandbox's own `domain` field over the
|
|
1433
|
+
// configured controller domain (custom-domain correctness), falling back to
|
|
1434
|
+
// the controller env's E2B_DOMAIN/E2B_SANDBOX_URL only when domain is absent.
|
|
1435
|
+
return sandboxPortUrl(apiSandbox, port, controllerEnv);
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
/** A short role-count summary for a group, e.g. "1 api, 1 lead, 2 worker". */
|
|
1439
|
+
function roleCountSummary(sandboxes: E2BSandboxInfo[]): string {
|
|
1440
|
+
const counts: Record<MetadataSwarmRole, number> = { api: 0, lead: 0, worker: 0 };
|
|
1441
|
+
for (const sandbox of sandboxes) counts[sandboxSwarmRole(sandbox)]++;
|
|
1442
|
+
return (["api", "lead", "worker"] as const)
|
|
1443
|
+
.filter((role) => counts[role] > 0)
|
|
1444
|
+
.map((role) => `${counts[role]} ${role}`)
|
|
1445
|
+
.join(", ");
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/** The shortest remaining TTL across a group's sandboxes (the group's true expiry). */
|
|
1449
|
+
function groupTtlSummary(sandboxes: E2BSandboxInfo[]): string {
|
|
1450
|
+
let minSeconds: number | undefined;
|
|
1451
|
+
for (const sandbox of sandboxes) {
|
|
1452
|
+
const { secondsLeft } = ttlRemaining(sandbox);
|
|
1453
|
+
if (secondsLeft === undefined) continue;
|
|
1454
|
+
if (minSeconds === undefined || secondsLeft < minSeconds) minSeconds = secondsLeft;
|
|
1455
|
+
}
|
|
1456
|
+
return minSeconds === undefined ? "ttl unknown" : `expires in ${formatDuration(minSeconds)}`;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
/** Probe `GET <apiUrl>/health` once, unauthenticated. Returns up/down + detail. */
|
|
1460
|
+
async function probeHealth(apiUrl: string): Promise<{ up: boolean; detail: string }> {
|
|
1461
|
+
try {
|
|
1462
|
+
const response = await fetch(`${apiUrl.replace(/\/+$/, "")}/health`);
|
|
1463
|
+
return { up: response.ok, detail: `${response.status} ${response.statusText}`.trim() };
|
|
1464
|
+
} catch (err) {
|
|
1465
|
+
return { up: false, detail: err instanceof Error ? err.message : String(err) };
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
async function swarmsListCommand(flags: ParsedFlags, cwd: string): Promise<void> {
|
|
1470
|
+
const controllerEnv = await loadE2BControllerEnv(flags, cwd);
|
|
1471
|
+
const apiBase = e2bApiBase(flags, controllerEnv);
|
|
1472
|
+
const sandboxes = await listSandboxes(e2bControllerApiKey(controllerEnv), apiBase);
|
|
1473
|
+
const groups = groupSandboxesBySwarm(sandboxes);
|
|
1474
|
+
|
|
1475
|
+
if (booleanFlag(flags, "json")) {
|
|
1476
|
+
const payload = [...groups.entries()].map(([slug, members]) => ({
|
|
1477
|
+
swarm: slug,
|
|
1478
|
+
count: members.length,
|
|
1479
|
+
roles: roleCountSummary(members),
|
|
1480
|
+
sandboxIDs: members.map((m) => m.sandboxID),
|
|
1481
|
+
}));
|
|
1482
|
+
console.log(JSON.stringify(redactObjectWithEnv(payload, controllerEnv), null, 2));
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
if (groups.size === 0) {
|
|
1487
|
+
console.log("no swarms found");
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
for (const [slug, members] of groups) {
|
|
1491
|
+
console.log(
|
|
1492
|
+
`${slug}\t${members.length} sandbox(es)\t${roleCountSummary(members)}\t${groupTtlSummary(
|
|
1493
|
+
members,
|
|
1494
|
+
)}`,
|
|
1495
|
+
);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
/**
|
|
1500
|
+
* Select the sandboxes belonging to a swarm slug that WE launched. Restricting
|
|
1501
|
+
* to the `launcher === "agent-swarm-e2b"` tag (stamped by parseMetadata) matches
|
|
1502
|
+
* the `kill --all` ownership guard: without it, a foreign E2B sandbox using a
|
|
1503
|
+
* generic `metadata.swarm` key with a colliding slug would be pulled into the
|
|
1504
|
+
* group, so `swarms kill/info/logs/add <slug>` could operate on / delete
|
|
1505
|
+
* unrelated sandboxes. Pure (no I/O) so the ownership guarantee is unit-testable.
|
|
1506
|
+
*/
|
|
1507
|
+
export function swarmGroupMembers(sandboxes: E2BSandboxInfo[], slug: string): E2BSandboxInfo[] {
|
|
1508
|
+
return sandboxes.filter(
|
|
1509
|
+
(sandbox) =>
|
|
1510
|
+
sandbox.metadata?.swarm === slug && sandbox.metadata?.launcher === "agent-swarm-e2b",
|
|
1511
|
+
);
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
/** Find the sandboxes belonging to a swarm slug (throws if the group is empty). */
|
|
1515
|
+
async function resolveSwarmGroup(
|
|
1516
|
+
flags: ParsedFlags,
|
|
1517
|
+
cwd: string,
|
|
1518
|
+
slug: string,
|
|
1519
|
+
): Promise<{ members: E2BSandboxInfo[]; controllerEnv: EnvMap; apiBase: string }> {
|
|
1520
|
+
const controllerEnv = await loadE2BControllerEnv(flags, cwd);
|
|
1521
|
+
const apiBase = e2bApiBase(flags, controllerEnv);
|
|
1522
|
+
const sandboxes = await listSandboxes(e2bControllerApiKey(controllerEnv), apiBase);
|
|
1523
|
+
const members = swarmGroupMembers(sandboxes, slug);
|
|
1524
|
+
if (members.length === 0) {
|
|
1525
|
+
throw new Error(`no swarm found with slug "${slug}" (try: e2b swarms list)`);
|
|
1526
|
+
}
|
|
1527
|
+
return { members, controllerEnv, apiBase };
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
async function swarmsInfoCommand(flags: ParsedFlags, cwd: string): Promise<void> {
|
|
1531
|
+
const slug = flags.positionals[1];
|
|
1532
|
+
if (!slug) throw new Error("swarms info requires a slug: e2b swarms info <slug>");
|
|
1533
|
+
|
|
1534
|
+
const { members, controllerEnv } = await resolveSwarmGroup(flags, cwd, slug);
|
|
1535
|
+
const api = members.find((m) => sandboxSwarmRole(m) === "api");
|
|
1536
|
+
const lead = members.find((m) => sandboxSwarmRole(m) === "lead");
|
|
1537
|
+
const workers = members.filter((m) => sandboxSwarmRole(m) === "worker");
|
|
1538
|
+
|
|
1539
|
+
// Re-resolve the swarm API key LOCALLY (never from the sandbox) so we can build
|
|
1540
|
+
// the deep-link / authed probe. Source is reported; the value is masked.
|
|
1541
|
+
const runtime: EnvMap = selectEnv(process.env, [...DEFAULT_E2B_FORWARD_KEYS]);
|
|
1542
|
+
let resolvedKey = "";
|
|
1543
|
+
let keySource: string;
|
|
1544
|
+
try {
|
|
1545
|
+
resolvedKey = resolveSwarmApiKey(runtime, value(flags, "api-key"));
|
|
1546
|
+
keySource = swarmApiKeySource(flags, runtime);
|
|
1547
|
+
} catch {
|
|
1548
|
+
keySource = "unresolved (set AGENT_SWARM_API_KEY / API_KEY or pass --api-key)";
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
const apiUrl = api ? swarmApiUrl(api, controllerEnv) : undefined;
|
|
1552
|
+
|
|
1553
|
+
console.log(`swarm: ${slug}`);
|
|
1554
|
+
console.log(`sandboxes: ${members.length} (${roleCountSummary(members)})`);
|
|
1555
|
+
if (apiUrl) console.log(`api url: ${apiUrl}`);
|
|
1556
|
+
console.log(`api key: ${maskKey(resolvedKey)} (${keySource})`);
|
|
1557
|
+
|
|
1558
|
+
// Per-sandbox lines, grouped API → lead → workers (resolved by swarmRole), each
|
|
1559
|
+
// with its agent ID (lead/workers) and remaining TTL.
|
|
1560
|
+
const ttlText = (member: E2BSandboxInfo): string => {
|
|
1561
|
+
const { secondsLeft } = ttlRemaining(member);
|
|
1562
|
+
return secondsLeft !== undefined ? `expires in ${formatDuration(secondsLeft)}` : "ttl unknown";
|
|
1563
|
+
};
|
|
1564
|
+
if (api) console.log(` api ${api.sandboxID} ${ttlText(api)}`);
|
|
1565
|
+
if (lead) {
|
|
1566
|
+
console.log(` lead ${lead.sandboxID} ${sandboxAgentId(lead)} ${ttlText(lead)}`);
|
|
1567
|
+
}
|
|
1568
|
+
for (const worker of workers) {
|
|
1569
|
+
console.log(` worker ${worker.sandboxID} ${sandboxAgentId(worker)} ${ttlText(worker)}`);
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// Single-shot unauthenticated health probe.
|
|
1573
|
+
if (apiUrl) {
|
|
1574
|
+
const health = await probeHealth(apiUrl);
|
|
1575
|
+
console.log(`health: ${health.up ? "up" : "down"} (${health.detail})`);
|
|
1576
|
+
|
|
1577
|
+
// If the key resolved, do one authenticated probe to detect a key mismatch.
|
|
1578
|
+
if (resolvedKey) {
|
|
1579
|
+
try {
|
|
1580
|
+
const authed = await fetch(`${apiUrl.replace(/\/+$/, "")}/api/agents`, {
|
|
1581
|
+
headers: { Authorization: `Bearer ${resolvedKey}` },
|
|
1582
|
+
});
|
|
1583
|
+
if (authed.status === 401) {
|
|
1584
|
+
console.warn(
|
|
1585
|
+
"warning: authenticated probe returned 401 — the resolved key may not match the launch key.",
|
|
1586
|
+
);
|
|
1587
|
+
}
|
|
1588
|
+
} catch {
|
|
1589
|
+
// A network error on the authed probe is non-fatal; the unauth health
|
|
1590
|
+
// probe above already reported reachability.
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
// Dashboard deep-link (masked by default; --reveal-key embeds the key raw).
|
|
1596
|
+
printDashboardDeepLink(flags, {
|
|
1597
|
+
apiUrl,
|
|
1598
|
+
apiKey: resolvedKey || undefined,
|
|
1599
|
+
name: slug,
|
|
1600
|
+
env: controllerEnv,
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
async function swarmsKillCommand(flags: ParsedFlags, cwd: string): Promise<void> {
|
|
1605
|
+
const all = booleanFlag(flags, "all");
|
|
1606
|
+
const controllerEnv = await loadE2BControllerEnv(flags, cwd);
|
|
1607
|
+
const apiBase = e2bApiBase(flags, controllerEnv);
|
|
1608
|
+
const controllerApiKey = e2bControllerApiKey(controllerEnv);
|
|
1609
|
+
|
|
1610
|
+
// Build the ordered kill list. Within each group: every non-API sandbox first,
|
|
1611
|
+
// then the API LAST (so workers/lead never lose their API mid-teardown).
|
|
1612
|
+
function orderGroup(members: E2BSandboxInfo[]): E2BSandboxInfo[] {
|
|
1613
|
+
const apiLast = [...members].sort((a, b) => {
|
|
1614
|
+
const aApi = sandboxSwarmRole(a) === "api" ? 1 : 0;
|
|
1615
|
+
const bApi = sandboxSwarmRole(b) === "api" ? 1 : 0;
|
|
1616
|
+
return aApi - bApi;
|
|
1617
|
+
});
|
|
1618
|
+
return apiLast;
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
let targets: E2BSandboxInfo[];
|
|
1622
|
+
let label: string;
|
|
1623
|
+
if (all) {
|
|
1624
|
+
const sandboxes = await listSandboxes(controllerApiKey, apiBase);
|
|
1625
|
+
const swarmTagged = sandboxes.filter((s) => s.metadata?.launcher === "agent-swarm-e2b");
|
|
1626
|
+
if (swarmTagged.length === 0) {
|
|
1627
|
+
console.log("no agent-swarm swarms to kill");
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
// Order each group api-last, then concatenate.
|
|
1631
|
+
const grouped = groupSandboxesBySwarm(swarmTagged);
|
|
1632
|
+
targets = [...grouped.values()].flatMap(orderGroup);
|
|
1633
|
+
label = `all ${grouped.size} swarm(s) (${targets.length} sandboxes)`;
|
|
1634
|
+
} else {
|
|
1635
|
+
const slug = flags.positionals[1];
|
|
1636
|
+
if (!slug) throw new Error("swarms kill requires a slug (or --all): e2b swarms kill <slug>");
|
|
1637
|
+
const { members } = await resolveSwarmGroup(flags, cwd, slug);
|
|
1638
|
+
targets = orderGroup(members);
|
|
1639
|
+
label = `swarm "${slug}" (${targets.length} sandboxes)`;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
const ok = await confirm(`Kill ${label}?`, flags);
|
|
1643
|
+
if (!ok) {
|
|
1644
|
+
console.log("aborted (pass --yes to skip this prompt)");
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
for (const sandbox of targets) {
|
|
1649
|
+
await killSandbox(sandbox.sandboxID, controllerApiKey, apiBase);
|
|
1650
|
+
console.log(`killed ${sandbox.sandboxID} (${sandboxSwarmRole(sandbox)})`);
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
async function swarmsAddCommand(flags: ParsedFlags, cwd: string): Promise<void> {
|
|
1655
|
+
let slug = flags.positionals[1];
|
|
1656
|
+
|
|
1657
|
+
// No slug on a TTY → offer a picker of existing swarms.
|
|
1658
|
+
if (!slug) {
|
|
1659
|
+
if (!isInteractiveTty()) {
|
|
1660
|
+
throw new Error("swarms add requires a slug: e2b swarms add <slug>");
|
|
1661
|
+
}
|
|
1662
|
+
slug = await pickSwarmSlug(flags, cwd);
|
|
1663
|
+
if (!slug) {
|
|
1664
|
+
console.log("aborted (no swarm selected)");
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
const { members, controllerEnv, apiBase } = await resolveSwarmGroup(flags, cwd, slug);
|
|
1670
|
+
const api = members.find((m) => sandboxSwarmRole(m) === "api");
|
|
1671
|
+
if (!api) {
|
|
1672
|
+
throw new Error(`swarm "${slug}" has no API sandbox — cannot add members to it`);
|
|
1673
|
+
}
|
|
1674
|
+
const apiUrl = swarmApiUrl(api, controllerEnv);
|
|
1675
|
+
const controllerApiKey = e2bControllerApiKey(controllerEnv);
|
|
1676
|
+
|
|
1677
|
+
// Stamp the existing slug + point new members at the existing API. The slug
|
|
1678
|
+
// flows into metadata via parseMetadata; MCP_BASE_URL via the apiUrl arg below.
|
|
1679
|
+
setFlagValue(flags, "swarm", slug);
|
|
1680
|
+
|
|
1681
|
+
// Compute the group's current end so new members re-sync to the SAME wall-clock
|
|
1682
|
+
// expiry (reuse setSandboxTimeout). The shortest remaining TTL is the group's
|
|
1683
|
+
// true end; new members align to that rather than a fresh full TTL.
|
|
1684
|
+
const groupEndSeconds = members
|
|
1685
|
+
.map((m) => ttlRemaining(m).secondsLeft)
|
|
1686
|
+
.filter((s): s is number => s !== undefined);
|
|
1687
|
+
const resyncSeconds = groupEndSeconds.length > 0 ? Math.min(...groupEndSeconds) : undefined;
|
|
1688
|
+
|
|
1689
|
+
const addLead = booleanFlag(flags, "add-lead");
|
|
1690
|
+
const workerCount = integerFlag(flags, "workers", addLead ? 0 : 1);
|
|
1691
|
+
const added: StartedRole[] = [];
|
|
1692
|
+
|
|
1693
|
+
try {
|
|
1694
|
+
if (addLead) {
|
|
1695
|
+
const lead = await startRole(flags, cwd, STACK_LEAD_SPEC, apiUrl);
|
|
1696
|
+
added.push(lead);
|
|
1697
|
+
}
|
|
1698
|
+
for (let i = 0; i < workerCount; i++) {
|
|
1699
|
+
const worker = await startRole(flags, cwd, STACK_WORKER_SPEC, apiUrl);
|
|
1700
|
+
added.push(worker);
|
|
1701
|
+
}
|
|
1702
|
+
} catch (err) {
|
|
1703
|
+
await cleanupStartedRoles(flags, cwd, added);
|
|
1704
|
+
throw err;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
// Re-sync the freshly-added members to the group's current end (best-effort).
|
|
1708
|
+
if (resyncSeconds !== undefined && !booleanFlag(flags, "dry-run")) {
|
|
1709
|
+
for (const role of added) {
|
|
1710
|
+
try {
|
|
1711
|
+
await setSandboxTimeout({
|
|
1712
|
+
sandboxId: role.sandbox.sandboxID,
|
|
1713
|
+
apiKey: controllerApiKey,
|
|
1714
|
+
apiBase,
|
|
1715
|
+
e2bEnv: controllerEnv,
|
|
1716
|
+
timeoutMs: resyncSeconds * 1000,
|
|
1717
|
+
});
|
|
1718
|
+
} catch (err) {
|
|
1719
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1720
|
+
console.warn(
|
|
1721
|
+
redactWithEnv(
|
|
1722
|
+
`e2b: failed to re-sync TTL for added ${role.role} sandbox ${role.sandbox.sandboxID}: ${message}`,
|
|
1723
|
+
controllerEnv,
|
|
1724
|
+
),
|
|
1725
|
+
);
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
const runtimeEnv = await loadRuntimeEnv(flags, STACK_WORKER_SPEC, apiUrl);
|
|
1731
|
+
if (booleanFlag(flags, "json")) {
|
|
1732
|
+
console.log(
|
|
1733
|
+
JSON.stringify(
|
|
1734
|
+
{ swarm: slug, added: added.map((r) => publicStartedRole(r, runtimeEnv)) },
|
|
1735
|
+
null,
|
|
1736
|
+
2,
|
|
1737
|
+
),
|
|
1738
|
+
);
|
|
1739
|
+
} else {
|
|
1740
|
+
console.log(`added ${added.length} member(s) to swarm ${slug}:`);
|
|
1741
|
+
for (const role of added) {
|
|
1742
|
+
printHumanStart(role, runtimeEnv);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
/** Render a one-shot Ink picker over existing swarm slugs. Returns "" if cancelled. */
|
|
1748
|
+
async function pickSwarmSlug(flags: ParsedFlags, cwd: string): Promise<string> {
|
|
1749
|
+
const controllerEnv = await loadE2BControllerEnv(flags, cwd);
|
|
1750
|
+
const apiBase = e2bApiBase(flags, controllerEnv);
|
|
1751
|
+
const sandboxes = await listSandboxes(e2bControllerApiKey(controllerEnv), apiBase);
|
|
1752
|
+
const groups = groupSandboxesBySwarm(sandboxes);
|
|
1753
|
+
const slugs = [...groups.keys()].filter((slug) => slug !== UNGROUPED_BUCKET);
|
|
1754
|
+
if (slugs.length === 0) {
|
|
1755
|
+
throw new Error("no existing swarms to add to (create one with e2b start-stack)");
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
let chosen = "";
|
|
1759
|
+
const instance = render(
|
|
1760
|
+
createElement(SwarmPicker, {
|
|
1761
|
+
slugs: slugs.map((slug) => {
|
|
1762
|
+
const members = groups.get(slug) ?? [];
|
|
1763
|
+
return { slug, label: `${slug} (${roleCountSummary(members)})` };
|
|
1764
|
+
}),
|
|
1765
|
+
onSelect: (slug: string) => {
|
|
1766
|
+
chosen = slug;
|
|
1767
|
+
},
|
|
1768
|
+
}),
|
|
1769
|
+
);
|
|
1770
|
+
await instance.waitUntilExit();
|
|
1771
|
+
return chosen;
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
/** The E2B `role` (api|worker) a swarm sandbox launched its entrypoint under. */
|
|
1775
|
+
function sandboxE2BRole(sandbox: E2BSandboxInfo): "api" | "worker" {
|
|
1776
|
+
// The entrypoint's tee log path keys off the E2B role, not the grouping role:
|
|
1777
|
+
// a lead is grouping-role "lead" but E2B role "worker" (so its log lives at
|
|
1778
|
+
// /tmp/agent-swarm-e2b-worker.log). Map back via the grouping role.
|
|
1779
|
+
return sandboxSwarmRole(sandbox) === "api" ? "api" : "worker";
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
/**
|
|
1783
|
+
* `e2b swarms logs <slug> [--role api|lead|worker] [--follow]` — stream the
|
|
1784
|
+
* entrypoint log of a swarm's sandbox(es).
|
|
1785
|
+
*
|
|
1786
|
+
* Resolution: filter the swarm's members by `--role` (default: `api`, the most
|
|
1787
|
+
* useful single target — it carries the API boot lines + health). Reading the
|
|
1788
|
+
* deterministic per-role tee'd log path means NO PID bookkeeping is needed.
|
|
1789
|
+
*
|
|
1790
|
+
* Output is UNTRUSTED entrypoint stdout that can embed tokens, so every chunk is
|
|
1791
|
+
* routed through `redactWithEnv` (→ scrubSecrets) at this egress point before it
|
|
1792
|
+
* touches the terminal.
|
|
1793
|
+
*/
|
|
1794
|
+
async function swarmsLogsCommand(flags: ParsedFlags, cwd: string): Promise<void> {
|
|
1795
|
+
const slug = flags.positionals[1];
|
|
1796
|
+
if (!slug) throw new Error("swarms logs requires a slug: e2b swarms logs <slug>");
|
|
1797
|
+
|
|
1798
|
+
const roleFlag = value(flags, "role") || "api";
|
|
1799
|
+
if (roleFlag !== "api" && roleFlag !== "lead" && roleFlag !== "worker") {
|
|
1800
|
+
throw new Error("--role must be one of api|lead|worker");
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
const { members, controllerEnv, apiBase } = await resolveSwarmGroup(flags, cwd, slug);
|
|
1804
|
+
const targets = members.filter((m) => sandboxSwarmRole(m) === roleFlag);
|
|
1805
|
+
if (targets.length === 0) {
|
|
1806
|
+
throw new Error(`swarm "${slug}" has no ${roleFlag} sandbox`);
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
// Egress redaction set. `controllerEnv` only carries E2B-controller + locally
|
|
1810
|
+
// resolved values, so launch-time secrets supplied via --secret / --env-file /
|
|
1811
|
+
// --inherit-env / a launch-specific --api-key would NOT be redacted unless the
|
|
1812
|
+
// operator re-supplies them here. Resolve runtime env the SAME way the launch
|
|
1813
|
+
// path does (loadRuntimeEnv with API_SPEC handles --env-file/--secret/
|
|
1814
|
+
// --inherit-env/--api-key + scoped flags) so any re-supplied launch secret is
|
|
1815
|
+
// scrubbed. A missing swarm API key must NOT hard-fail `swarms logs` (the start
|
|
1816
|
+
// path tolerates this only under --dry-run), so degrade to controllerEnv-only.
|
|
1817
|
+
let redactionEnv: EnvMap = controllerEnv;
|
|
1818
|
+
try {
|
|
1819
|
+
redactionEnv = { ...controllerEnv, ...(await loadRuntimeEnv(flags, API_SPEC)) };
|
|
1820
|
+
} catch {
|
|
1821
|
+
redactionEnv = controllerEnv;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
const follow = booleanFlag(flags, "follow");
|
|
1825
|
+
const tailLines = integerFlag(flags, "tail", 200);
|
|
1826
|
+
const controllerApiKey = e2bControllerApiKey(controllerEnv);
|
|
1827
|
+
|
|
1828
|
+
// Multi-target follow would interleave two live streams ambiguously; restrict
|
|
1829
|
+
// --follow to a single sandbox and point multi-worker users at --role/history.
|
|
1830
|
+
if (follow && targets.length > 1) {
|
|
1831
|
+
throw new Error(
|
|
1832
|
+
`swarm "${slug}" has ${targets.length} ${roleFlag} sandboxes — --follow needs a single target (omit --follow for history, or there is no per-sandbox selector yet)`,
|
|
1833
|
+
);
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
// SIGINT (Ctrl-C) cleanly stops a --follow stream by aborting the tail.
|
|
1837
|
+
const controller = new AbortController();
|
|
1838
|
+
if (follow) {
|
|
1839
|
+
process.once("SIGINT", () => controller.abort());
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
for (const target of targets) {
|
|
1843
|
+
if (targets.length > 1) {
|
|
1844
|
+
console.log(`==> ${roleFlag} ${target.sandboxID} <==`);
|
|
1845
|
+
}
|
|
1846
|
+
await streamSandboxLog({
|
|
1847
|
+
sandboxId: target.sandboxID,
|
|
1848
|
+
role: sandboxE2BRole(target),
|
|
1849
|
+
apiKey: controllerApiKey,
|
|
1850
|
+
apiBase,
|
|
1851
|
+
e2bEnv: controllerEnv,
|
|
1852
|
+
tailLines,
|
|
1853
|
+
follow,
|
|
1854
|
+
signal: follow ? controller.signal : undefined,
|
|
1855
|
+
// Egress scrub: entrypoint output can embed secrets — redact every chunk.
|
|
1856
|
+
// Scrubbed = known token shapes (scrubSecrets) + the controller env + any
|
|
1857
|
+
// launch secrets re-supplied here via --secret/--env-file/--inherit-env/
|
|
1858
|
+
// --api-key (folded into redactionEnv). Residual limitation: an arbitrary
|
|
1859
|
+
// secret known ONLY to a prior launch (never re-supplied, no known shape)
|
|
1860
|
+
// is NOT recoverable here and can stream raw — re-pass it to `swarms logs`
|
|
1861
|
+
// to scrub it, or treat the logs as sensitive.
|
|
1862
|
+
onChunk: (chunk) => process.stdout.write(redactWithEnv(chunk, redactionEnv)),
|
|
1863
|
+
});
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
async function swarmsCommand(flags: ParsedFlags, cwd: string): Promise<void> {
|
|
1868
|
+
const sub = flags.positionals[0];
|
|
1869
|
+
switch (sub) {
|
|
1870
|
+
case undefined:
|
|
1871
|
+
case "list":
|
|
1872
|
+
await swarmsListCommand(flags, cwd);
|
|
1873
|
+
return;
|
|
1874
|
+
case "info":
|
|
1875
|
+
await swarmsInfoCommand(flags, cwd);
|
|
1876
|
+
return;
|
|
1877
|
+
case "kill":
|
|
1878
|
+
await swarmsKillCommand(flags, cwd);
|
|
1879
|
+
return;
|
|
1880
|
+
case "add":
|
|
1881
|
+
await swarmsAddCommand(flags, cwd);
|
|
1882
|
+
return;
|
|
1883
|
+
case "logs":
|
|
1884
|
+
await swarmsLogsCommand(flags, cwd);
|
|
1885
|
+
return;
|
|
1886
|
+
default:
|
|
1887
|
+
throw new Error(`Unknown e2b swarms subcommand: ${sub} (expected list|info|kill|add|logs)`);
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
|
|
656
1891
|
function printE2BHelp(): void {
|
|
657
1892
|
console.log(`
|
|
658
1893
|
agent-swarm e2b
|
|
@@ -662,20 +1897,78 @@ Usage:
|
|
|
662
1897
|
agent-swarm e2b delete-template <template-name...>
|
|
663
1898
|
agent-swarm e2b publish-template <template-name...>
|
|
664
1899
|
agent-swarm e2b unpublish-template <template-name...>
|
|
665
|
-
agent-swarm e2b start-api --template <
|
|
666
|
-
agent-swarm e2b start-worker --
|
|
667
|
-
agent-swarm e2b start-stack --
|
|
1900
|
+
agent-swarm e2b start-api [--template <name>] [--env-file .env]
|
|
1901
|
+
agent-swarm e2b start-worker --api-url <https-url> [--template <name>] [--env-file .env]
|
|
1902
|
+
agent-swarm e2b start-stack [--swarm <slug>] [--workers <n>] [--no-lead] [--yes]
|
|
668
1903
|
agent-swarm e2b list [--json]
|
|
669
|
-
agent-swarm e2b kill <
|
|
1904
|
+
agent-swarm e2b swarms list | info <slug> | kill <slug> | add <slug> | logs <slug>
|
|
1905
|
+
agent-swarm e2b extend <sandbox-id...> --timeout-sec <seconds>
|
|
1906
|
+
agent-swarm e2b kill <sandbox-id...> | --all
|
|
670
1907
|
|
|
671
1908
|
Common options:
|
|
672
|
-
--env-file <path> Load runtime env/secrets for
|
|
673
|
-
--secret KEY=VALUE Add/override one runtime secret (repeatable)
|
|
1909
|
+
--env-file <path> Load runtime env/secrets for all roles (repeatable)
|
|
1910
|
+
--secret KEY=VALUE Add/override one runtime secret for all roles (repeatable)
|
|
674
1911
|
--inherit-env KEY[,KEY] Forward extra local env vars into the sandbox
|
|
675
|
-
--api-key <key> Swarm API key
|
|
1912
|
+
--api-key <key> Swarm API key for API/worker (required unless env provides one)
|
|
1913
|
+
--api-url <https-url> Public API URL a worker connects to (start-worker)
|
|
676
1914
|
--agent-id <id> Worker agent ID (default: e2b-<sandbox-id>)
|
|
677
|
-
--
|
|
1915
|
+
--agent-role worker|lead Role for start-worker (ignored by start-stack)
|
|
1916
|
+
--provider <name> Harness provider for workers (default claude)
|
|
1917
|
+
--template <name> Override the E2B template for the role
|
|
1918
|
+
--api-template / --worker-template <name> Per-role E2B template overrides
|
|
1919
|
+
--timeout-sec <seconds> Sandbox TTL (default 3600); for extend, the new TTL from now
|
|
1920
|
+
--no-wait Skip waiting for API health / worker registration
|
|
678
1921
|
--e2b-api-key-file <path> Read the E2B controller API key from a file
|
|
1922
|
+
|
|
1923
|
+
start-stack (API + lead + N workers):
|
|
1924
|
+
Provisions an API, one lead, and N workers. Interactive wizard on a TTY;
|
|
1925
|
+
headless under --yes / --non-interactive / --dry-run / a non-TTY.
|
|
1926
|
+
--swarm <slug> Swarm name/slug (used for the wizard + echoed command)
|
|
1927
|
+
--workers <n> Worker count (default 1)
|
|
1928
|
+
--no-lead Legacy topology: API + N workers, no lead
|
|
1929
|
+
--lead-agent-id <id> Lead agent ID (default: e2b-lead-<sandbox-id>)
|
|
1930
|
+
--yes Skip the wizard; use flags + defaults (CI/headless)
|
|
1931
|
+
--non-interactive Same as --yes for prompting (never reads stdin)
|
|
1932
|
+
--integrations <csv> Allowlist of integrations to keep on (slack,github,jira,linear)
|
|
1933
|
+
--no-slack / --no-github / --no-jira / --no-linear
|
|
1934
|
+
Disable an integration (sets the API's <NAME>_DISABLE=true)
|
|
1935
|
+
JSON shape: {api, lead, workers:[...]} — or {api, workers:[...]} with --no-lead.
|
|
1936
|
+
|
|
1937
|
+
Role-scoped env (layer ON TOP of the shared --env-file/--secret, never replace):
|
|
1938
|
+
--api-env-file <path> Env file applied only to the API sandbox (repeatable)
|
|
1939
|
+
--lead-env-file <path> Env file applied only to the lead sandbox (repeatable)
|
|
1940
|
+
--worker-env-file <path> Env file applied only to worker sandboxes (repeatable)
|
|
1941
|
+
--api-secret KEY=VALUE Secret applied only to the API sandbox (repeatable)
|
|
1942
|
+
--lead-secret KEY=VALUE Secret applied only to the lead sandbox (repeatable)
|
|
1943
|
+
--worker-secret KEY=VALUE Secret applied only to worker sandboxes (repeatable)
|
|
1944
|
+
Precedence (highest wins): forward-keys < --env-file < --<scope>-env-file
|
|
1945
|
+
< --secret < --<scope>-secret < forced API_KEY/AGENT_SWARM_API_KEY.
|
|
1946
|
+
|
|
1947
|
+
swarms (group by metadata.swarm slug):
|
|
1948
|
+
list Group sandboxes by swarm (ungrouped → "(ungrouped)")
|
|
1949
|
+
info <slug> API URL, key source (masked), roles, per-sandbox TTL,
|
|
1950
|
+
a one-shot /health probe, and the dashboard deep-link
|
|
1951
|
+
kill <slug> | --all Tear down a swarm (API last) or every swarm (--all)
|
|
1952
|
+
add <slug> Add worker(s)/--add-lead to an existing swarm, TTL
|
|
1953
|
+
re-synced to the group's current end. No slug on a
|
|
1954
|
+
TTY → swarm picker. --workers <n> sets the count.
|
|
1955
|
+
logs <slug> Stream a sandbox's entrypoint log (envd-tracked +
|
|
1956
|
+
tee'd to file). --role api|lead|worker (default api),
|
|
1957
|
+
--follow to tail live, --tail <n> history lines
|
|
1958
|
+
(default 200). Output is scrubbed for secrets.
|
|
1959
|
+
--reveal-key Embed the swarm API key in the dashboard deep-link
|
|
1960
|
+
(printed RAW — the URL is a secret; hidden otherwise)
|
|
1961
|
+
|
|
1962
|
+
extend:
|
|
1963
|
+
Extend (or reduce) a live sandbox's TTL. E2B clamps to your tier max, so the
|
|
1964
|
+
printed expiry reflects what was actually applied. --dry-run never contacts E2B.
|
|
1965
|
+
|
|
1966
|
+
kill:
|
|
1967
|
+
--all Kill every sandbox launched by this dispatcher
|
|
1968
|
+
(metadata.launcher === agent-swarm-e2b)
|
|
1969
|
+
--yes Skip the multi-sandbox confirmation prompt (required in CI)
|
|
1970
|
+
|
|
1971
|
+
Global:
|
|
679
1972
|
--json Print machine-readable output
|
|
680
1973
|
--dry-run Print/derive planned work without touching E2B
|
|
681
1974
|
`);
|
|
@@ -714,6 +2007,12 @@ export async function runE2BCommand(argv: string[]): Promise<void> {
|
|
|
714
2007
|
case "list":
|
|
715
2008
|
await listCommand(flags, cwd);
|
|
716
2009
|
return;
|
|
2010
|
+
case "swarms":
|
|
2011
|
+
await swarmsCommand(flags, cwd);
|
|
2012
|
+
return;
|
|
2013
|
+
case "extend":
|
|
2014
|
+
await extendCommand(flags, cwd);
|
|
2015
|
+
return;
|
|
717
2016
|
case "kill":
|
|
718
2017
|
await killCommand(flags, cwd);
|
|
719
2018
|
return;
|