@desplega.ai/agent-swarm 1.71.2 → 1.72.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 +3 -2
- package/openapi.json +994 -62
- package/package.json +2 -1
- package/src/be/budget-admission.ts +121 -0
- package/src/be/budget-refusal-notify.ts +145 -0
- package/src/be/db.ts +488 -5
- package/src/be/migrations/044_provider_meta.sql +2 -0
- package/src/be/migrations/046_budgets_and_pricing.sql +87 -0
- package/src/be/migrations/047_session_costs_cost_source.sql +16 -0
- package/src/cli.tsx +22 -1
- package/src/commands/claude-managed-setup.ts +687 -0
- package/src/commands/codex-login.ts +1 -1
- package/src/commands/runner.ts +175 -28
- package/src/commands/templates.ts +10 -6
- package/src/http/budgets.ts +219 -0
- package/src/http/index.ts +6 -0
- package/src/http/integrations.ts +134 -0
- package/src/http/poll.ts +161 -3
- package/src/http/pricing.ts +245 -0
- package/src/http/session-data.ts +54 -6
- package/src/http/tasks.ts +23 -2
- package/src/prompts/base-prompt.ts +103 -73
- package/src/prompts/session-templates.ts +43 -0
- package/src/providers/claude-adapter.ts +3 -1
- package/src/providers/claude-managed-adapter.ts +871 -0
- package/src/providers/claude-managed-models.ts +117 -0
- package/src/providers/claude-managed-swarm-events.ts +77 -0
- package/src/providers/codex-adapter.ts +3 -1
- package/src/providers/codex-skill-resolver.ts +10 -0
- package/src/providers/codex-swarm-events.ts +20 -161
- package/src/providers/devin-adapter.ts +894 -0
- package/src/providers/devin-api.ts +207 -0
- package/src/providers/devin-playbooks.ts +91 -0
- package/src/providers/devin-skill-resolver.ts +113 -0
- package/src/providers/index.ts +10 -1
- package/src/providers/pi-mono-adapter.ts +3 -1
- package/src/providers/swarm-events-shared.ts +262 -0
- package/src/providers/types.ts +26 -1
- package/src/tests/base-prompt.test.ts +199 -0
- package/src/tests/budget-admission.test.ts +339 -0
- package/src/tests/budget-claim-gate.test.ts +288 -0
- package/src/tests/budget-refusal-notification.test.ts +324 -0
- package/src/tests/budgets-routes.test.ts +331 -0
- package/src/tests/claude-managed-adapter.test.ts +1301 -0
- package/src/tests/claude-managed-setup.test.ts +325 -0
- package/src/tests/devin-adapter.test.ts +677 -0
- package/src/tests/devin-api.test.ts +339 -0
- package/src/tests/integrations-http.test.ts +211 -0
- package/src/tests/migration-046-budgets.test.ts +327 -0
- package/src/tests/pricing-routes.test.ts +315 -0
- package/src/tests/prompt-template-remaining.test.ts +4 -0
- package/src/tests/prompt-template-session.test.ts +2 -2
- package/src/tests/provider-adapter.test.ts +1 -1
- package/src/tests/runner-budget-refused.test.ts +271 -0
- package/src/tests/session-costs-codex-recompute.test.ts +386 -0
- package/src/tools/poll-task.ts +13 -2
- package/src/tools/task-action.ts +92 -2
- package/src/tools/templates.ts +29 -0
- package/src/types.ts +116 -0
- package/src/utils/budget-backoff.ts +34 -0
- package/src/utils/credentials.ts +4 -0
- package/src/utils/provider-metadata.ts +9 -0
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agent-swarm claude-managed-setup` — bootstrap the Anthropic-side managed
|
|
3
|
+
* agent + environment + skills, then persist their IDs to `swarm_config` so
|
|
4
|
+
* deployed workers can fetch them at boot.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors the shape of `codex-login.ts`: non-UI command, plain stdout, exits
|
|
7
|
+
* via `process.exit`. No Ink. Talks to the swarm API exclusively over HTTP
|
|
8
|
+
* (NO direct DB access — the boundary check enforces this).
|
|
9
|
+
*
|
|
10
|
+
* Reference: thoughts/taras/plans/2026-04-28-claude-managed-agents-provider.md
|
|
11
|
+
* Phase 2 §1 — "Setup CLI command (NOT a standalone script)"
|
|
12
|
+
*
|
|
13
|
+
* SDK shape note: the plan's spec referred to `skill_id` / `content_md` field
|
|
14
|
+
* names, but the actual `@anthropic-ai/sdk` `client.beta.skills.create`
|
|
15
|
+
* accepts `{ display_title?, files: Array<Uploadable> }` and returns a
|
|
16
|
+
* response object with `id` (the field used as `skill_id` when later
|
|
17
|
+
* referencing the skill from an agent definition via
|
|
18
|
+
* `BetaManagedAgentsCustomSkillParams`). The MCP-server param shape is
|
|
19
|
+
* `{ name, type: "url", url }` — the SDK does NOT accept `http_headers`
|
|
20
|
+
* here, so MCP auth is configured Anthropic-side via the dashboard / vault.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import Anthropic, { BadRequestError, ConflictError } from "@anthropic-ai/sdk";
|
|
26
|
+
import type {
|
|
27
|
+
AgentCreateParams,
|
|
28
|
+
BetaManagedAgentsAgent,
|
|
29
|
+
BetaManagedAgentsCustomSkillParams,
|
|
30
|
+
BetaManagedAgentsURLMCPServerParams,
|
|
31
|
+
} from "@anthropic-ai/sdk/resources/beta/agents";
|
|
32
|
+
import type { BetaEnvironment } from "@anthropic-ai/sdk/resources/beta/environments";
|
|
33
|
+
import type { SkillCreateResponse } from "@anthropic-ai/sdk/resources/beta/skills";
|
|
34
|
+
import { toFile } from "@anthropic-ai/sdk/uploads";
|
|
35
|
+
|
|
36
|
+
import { promptHiddenInput } from "./codex-login.js";
|
|
37
|
+
|
|
38
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
type ParsedArgs = {
|
|
41
|
+
apiUrl?: string;
|
|
42
|
+
apiKey?: string;
|
|
43
|
+
force: boolean;
|
|
44
|
+
showHelp: boolean;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type SwarmConfigEntry = {
|
|
48
|
+
id?: string;
|
|
49
|
+
scope: string;
|
|
50
|
+
scopeId?: string | null;
|
|
51
|
+
key: string;
|
|
52
|
+
value: string;
|
|
53
|
+
isSecret?: boolean;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type ClaudeManagedSetupResult = {
|
|
57
|
+
agentId: string;
|
|
58
|
+
environmentId: string;
|
|
59
|
+
skillIds: string[];
|
|
60
|
+
alreadyConfigured: boolean;
|
|
61
|
+
mcpVaultId?: string | null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
const DEFAULT_AGENT_MODEL = "claude-sonnet-4-6";
|
|
67
|
+
const SKILLS_DIR_RELATIVE = "plugin/commands";
|
|
68
|
+
const SKILLS_BETA_HEADER = "skills-2025-10-02";
|
|
69
|
+
|
|
70
|
+
// Config keys persisted to `swarm_config`. The docker-entrypoint hydrates env
|
|
71
|
+
// vars from these on worker boot.
|
|
72
|
+
const CONFIG_KEY_AGENT_ID = "managed_agent_id";
|
|
73
|
+
const CONFIG_KEY_ENVIRONMENT_ID = "managed_environment_id";
|
|
74
|
+
const CONFIG_KEY_API_KEY = "anthropic_api_key";
|
|
75
|
+
const CONFIG_KEY_MCP_VAULT_ID = "managed_mcp_vault_id";
|
|
76
|
+
|
|
77
|
+
// Display name for the swarm-MCP vault. Used as the lookup key on rerun so the
|
|
78
|
+
// upsert is idempotent — there is intentionally only one MCP vault per
|
|
79
|
+
// installation, scoped to the configured `MCP_BASE_URL`.
|
|
80
|
+
const MCP_VAULT_DISPLAY_NAME = "swarm-mcp";
|
|
81
|
+
|
|
82
|
+
// ─── Arg parsing + help ──────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
function parseArgs(args: string[]): ParsedArgs {
|
|
85
|
+
const parsed: ParsedArgs = { force: false, showHelp: false };
|
|
86
|
+
|
|
87
|
+
for (let i = 0; i < args.length; i++) {
|
|
88
|
+
const arg = args[i];
|
|
89
|
+
if (arg === "--api-url" && args[i + 1]) {
|
|
90
|
+
parsed.apiUrl = args[++i]!;
|
|
91
|
+
} else if (arg === "--api-key" && args[i + 1]) {
|
|
92
|
+
parsed.apiKey = args[++i]!;
|
|
93
|
+
} else if (arg === "--force") {
|
|
94
|
+
parsed.force = true;
|
|
95
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
96
|
+
parsed.showHelp = true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return parsed;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function printHelp(): void {
|
|
104
|
+
console.log(`
|
|
105
|
+
agent-swarm claude-managed-setup — Bootstrap Anthropic Managed Agents for the swarm
|
|
106
|
+
|
|
107
|
+
Usage:
|
|
108
|
+
agent-swarm claude-managed-setup [options]
|
|
109
|
+
|
|
110
|
+
Options:
|
|
111
|
+
--api-url <url> Swarm API URL (default: MCP_BASE_URL or http://localhost:3013)
|
|
112
|
+
--api-key <key> Swarm API key (default: API_KEY or 123123)
|
|
113
|
+
--force Recreate agent + environment even if already configured
|
|
114
|
+
-h, --help Show this help
|
|
115
|
+
|
|
116
|
+
This command:
|
|
117
|
+
1. Creates an Anthropic-side environment (cloud, unrestricted networking).
|
|
118
|
+
2. Uploads each plugin/commands/*.md as a managed-agents skill (skips on 409).
|
|
119
|
+
3. Creates a managed-agents agent referencing the uploaded skills + the
|
|
120
|
+
swarm MCP server (MCP_BASE_URL/mcp).
|
|
121
|
+
4. Persists the resulting IDs to swarm_config (managed_agent_id,
|
|
122
|
+
managed_environment_id, anthropic_api_key) via PUT /api/config.
|
|
123
|
+
|
|
124
|
+
Required environment variables:
|
|
125
|
+
ANTHROPIC_API_KEY Anthropic API key (prompted with masked input if missing).
|
|
126
|
+
MCP_BASE_URL Public HTTPS URL where Anthropic can reach the swarm MCP.
|
|
127
|
+
MUST start with https:// — fail-fast otherwise.
|
|
128
|
+
|
|
129
|
+
Optional:
|
|
130
|
+
MANAGED_AGENT_MODEL Default model for the managed agent (default: ${DEFAULT_AGENT_MODEL}).
|
|
131
|
+
|
|
132
|
+
Re-running the command is idempotent: if managed_agent_id is already set in
|
|
133
|
+
swarm_config it exits with a "already configured" message. Pass --force to
|
|
134
|
+
recreate the Anthropic-side resources.
|
|
135
|
+
`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─── Swarm-API helpers (HTTP only — no DB imports) ───────────────────────────
|
|
139
|
+
|
|
140
|
+
function apiHeaders(apiKey: string): Record<string, string> {
|
|
141
|
+
return {
|
|
142
|
+
"Content-Type": "application/json",
|
|
143
|
+
Authorization: `Bearer ${apiKey}`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function fetchConfigByKey(
|
|
148
|
+
apiUrl: string,
|
|
149
|
+
apiKey: string,
|
|
150
|
+
key: string,
|
|
151
|
+
): Promise<SwarmConfigEntry | null> {
|
|
152
|
+
// The list endpoint doesn't filter by key server-side, but the resolved
|
|
153
|
+
// endpoint returns merged global+agent+repo entries, which is what the
|
|
154
|
+
// worker entrypoint also uses. We filter client-side.
|
|
155
|
+
const res = await fetch(
|
|
156
|
+
`${apiUrl}/api/config/resolved?includeSecrets=true&key=${encodeURIComponent(key)}`,
|
|
157
|
+
{ headers: { Authorization: `Bearer ${apiKey}` } },
|
|
158
|
+
);
|
|
159
|
+
if (!res.ok) return null;
|
|
160
|
+
|
|
161
|
+
const data = (await res.json()) as { configs?: SwarmConfigEntry[] };
|
|
162
|
+
const entry = data.configs?.find((c) => c.key === key);
|
|
163
|
+
return entry ?? null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function upsertConfig(
|
|
167
|
+
apiUrl: string,
|
|
168
|
+
apiKey: string,
|
|
169
|
+
entry: { key: string; value: string; isSecret?: boolean; description?: string },
|
|
170
|
+
): Promise<void> {
|
|
171
|
+
const res = await fetch(`${apiUrl}/api/config`, {
|
|
172
|
+
method: "PUT",
|
|
173
|
+
headers: apiHeaders(apiKey),
|
|
174
|
+
body: JSON.stringify({
|
|
175
|
+
scope: "global",
|
|
176
|
+
key: entry.key,
|
|
177
|
+
value: entry.value,
|
|
178
|
+
isSecret: entry.isSecret ?? false,
|
|
179
|
+
description: entry.description ?? null,
|
|
180
|
+
}),
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
if (!res.ok) {
|
|
184
|
+
const text = await res.text().catch(() => "");
|
|
185
|
+
throw new Error(`Failed to upsert ${entry.key}: HTTP ${res.status} ${text}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ─── Skills upload helpers ───────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Slug used as the skill's `display_title`. Mirrors the slugs that
|
|
193
|
+
* `bun run build:pi-skills` generates for the worker-side filesystem layout.
|
|
194
|
+
*/
|
|
195
|
+
function skillSlugFromFilename(filename: string): string {
|
|
196
|
+
return filename.replace(/\.md$/i, "");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function loadSkillFiles(
|
|
200
|
+
skillsDir: string,
|
|
201
|
+
): Promise<Array<{ slug: string; absPath: string; content: string }>> {
|
|
202
|
+
const entries = await readdir(skillsDir);
|
|
203
|
+
const mdEntries = entries.filter((f) => f.toLowerCase().endsWith(".md"));
|
|
204
|
+
const out: Array<{ slug: string; absPath: string; content: string }> = [];
|
|
205
|
+
for (const filename of mdEntries) {
|
|
206
|
+
const absPath = path.join(skillsDir, filename);
|
|
207
|
+
const content = await readFile(absPath, "utf8");
|
|
208
|
+
out.push({ slug: skillSlugFromFilename(filename), absPath, content });
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function uploadSkill(
|
|
214
|
+
client: Anthropic,
|
|
215
|
+
slug: string,
|
|
216
|
+
content: string,
|
|
217
|
+
log: (msg: string) => void,
|
|
218
|
+
existingByTitle?: Map<string, string>,
|
|
219
|
+
): Promise<string | null> {
|
|
220
|
+
// The SDK's beta.skills.create expects { display_title?, files: Uploadable[] }
|
|
221
|
+
// and the API requires a SKILL.md at the root of the upload's top-level
|
|
222
|
+
// folder. We name the single file "<slug>/SKILL.md" so each
|
|
223
|
+
// plugin/commands/*.md becomes one skill in its own top-level folder.
|
|
224
|
+
if (existingByTitle?.has(slug)) {
|
|
225
|
+
const id = existingByTitle.get(slug)!;
|
|
226
|
+
log(` · skill "${slug}" already exists — reusing id=${id}`);
|
|
227
|
+
return id;
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
const file = await toFile(Buffer.from(content, "utf8"), `${slug}/SKILL.md`, {
|
|
231
|
+
type: "text/markdown",
|
|
232
|
+
});
|
|
233
|
+
const res: SkillCreateResponse = await client.beta.skills.create({
|
|
234
|
+
display_title: slug,
|
|
235
|
+
files: [file],
|
|
236
|
+
betas: [SKILLS_BETA_HEADER],
|
|
237
|
+
});
|
|
238
|
+
log(` + uploaded skill "${slug}" (id=${res.id})`);
|
|
239
|
+
return res.id;
|
|
240
|
+
} catch (err) {
|
|
241
|
+
// The API returns 400 (not 409) when display_title is reused. Both shapes
|
|
242
|
+
// are treated as "already exists" — caller pre-fetched the skill list to
|
|
243
|
+
// recover the id, but we may still race a concurrent upload. Surface the
|
|
244
|
+
// raw error if we can't recover.
|
|
245
|
+
const isDisplayTitleConflict =
|
|
246
|
+
err instanceof BadRequestError &&
|
|
247
|
+
typeof err.message === "string" &&
|
|
248
|
+
err.message.includes("display_title");
|
|
249
|
+
if (err instanceof ConflictError || isDisplayTitleConflict) {
|
|
250
|
+
log(` · skill "${slug}" already exists (server-side) — skipping`);
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
throw err;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ─── MCP vault upsert ────────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Find or create the swarm-MCP vault, then ensure a static-bearer credential
|
|
261
|
+
* exists for `mcpServerUrl` with the given `bearerToken`. Returns the vault id.
|
|
262
|
+
*
|
|
263
|
+
* Idempotency rules:
|
|
264
|
+
* - Vault: matched by `display_name === MCP_VAULT_DISPLAY_NAME`. Only one
|
|
265
|
+
* should exist per Anthropic account; if you somehow have multiple, the
|
|
266
|
+
* most recently created one wins (sorted by `created_at`). On rerun, no
|
|
267
|
+
* new vault is created.
|
|
268
|
+
* - Credential: matched by `auth.type === "static_bearer"` AND
|
|
269
|
+
* `auth.mcp_server_url === mcpServerUrl` (the URL is immutable per the
|
|
270
|
+
* SDK type docstring, so a URL change requires a new credential).
|
|
271
|
+
* The token itself is never returned in API responses, so on rerun we
|
|
272
|
+
* update the existing credential's token only when `force=true` (matches
|
|
273
|
+
* the agent-recreate semantics of `--force`).
|
|
274
|
+
*
|
|
275
|
+
* Anthropic's managed sandbox refuses to call our `/mcp` endpoint until a
|
|
276
|
+
* matching credential exists in a vault that the session is bound to via
|
|
277
|
+
* `vault_ids`. Calling this function from the setup CLI is what closes the
|
|
278
|
+
* loop without an out-of-band dashboard step.
|
|
279
|
+
*/
|
|
280
|
+
export async function upsertMcpVault(
|
|
281
|
+
client: Anthropic,
|
|
282
|
+
mcpServerUrl: string,
|
|
283
|
+
bearerToken: string,
|
|
284
|
+
force: boolean,
|
|
285
|
+
log: (msg: string) => void,
|
|
286
|
+
): Promise<string> {
|
|
287
|
+
// 1. Find an existing swarm-MCP vault by display name.
|
|
288
|
+
let vaultId: string | null = null;
|
|
289
|
+
let vaultExisted = false;
|
|
290
|
+
try {
|
|
291
|
+
let mostRecent: { id: string; created_at: string } | null = null;
|
|
292
|
+
for await (const v of client.beta.vaults.list({})) {
|
|
293
|
+
if (v.archived_at) continue;
|
|
294
|
+
if (v.display_name !== MCP_VAULT_DISPLAY_NAME) continue;
|
|
295
|
+
if (!mostRecent || v.created_at > mostRecent.created_at) {
|
|
296
|
+
mostRecent = { id: v.id, created_at: v.created_at };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (mostRecent) {
|
|
300
|
+
vaultId = mostRecent.id;
|
|
301
|
+
vaultExisted = true;
|
|
302
|
+
log(` · vault "${MCP_VAULT_DISPLAY_NAME}" already exists — reusing id=${vaultId}`);
|
|
303
|
+
}
|
|
304
|
+
} catch (err) {
|
|
305
|
+
log(` · vault list failed (${(err as Error).message}); proceeding to create`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// 2. Create the vault if it didn't exist.
|
|
309
|
+
if (!vaultId) {
|
|
310
|
+
const created = await client.beta.vaults.create({ display_name: MCP_VAULT_DISPLAY_NAME });
|
|
311
|
+
vaultId = created.id;
|
|
312
|
+
log(` + created vault "${MCP_VAULT_DISPLAY_NAME}" id=${vaultId}`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// 3. Find or create-or-update the static-bearer credential matching mcpServerUrl.
|
|
316
|
+
let existingCredentialId: string | null = null;
|
|
317
|
+
if (vaultExisted) {
|
|
318
|
+
try {
|
|
319
|
+
for await (const c of client.beta.vaults.credentials.list(vaultId)) {
|
|
320
|
+
if (c.archived_at) continue;
|
|
321
|
+
if (
|
|
322
|
+
c.auth?.type === "static_bearer" &&
|
|
323
|
+
(c.auth as { mcp_server_url?: string }).mcp_server_url === mcpServerUrl
|
|
324
|
+
) {
|
|
325
|
+
existingCredentialId = c.id;
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
} catch (err) {
|
|
330
|
+
log(` · credential list failed (${(err as Error).message}); proceeding to create`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (existingCredentialId) {
|
|
335
|
+
if (force) {
|
|
336
|
+
await client.beta.vaults.credentials.update(existingCredentialId, {
|
|
337
|
+
vault_id: vaultId,
|
|
338
|
+
auth: { type: "static_bearer", token: bearerToken },
|
|
339
|
+
});
|
|
340
|
+
log(` + rotated credential token (--force) id=${existingCredentialId}`);
|
|
341
|
+
} else {
|
|
342
|
+
log(` · credential for ${mcpServerUrl} already exists — leaving token alone`);
|
|
343
|
+
}
|
|
344
|
+
} else {
|
|
345
|
+
const cred = await client.beta.vaults.credentials.create(vaultId, {
|
|
346
|
+
auth: {
|
|
347
|
+
type: "static_bearer",
|
|
348
|
+
token: bearerToken,
|
|
349
|
+
mcp_server_url: mcpServerUrl,
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
log(` + added static-bearer credential id=${cred.id} for ${mcpServerUrl}`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return vaultId;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ─── Validation helpers ──────────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
function validateMcpBaseUrl(mcpBaseUrl: string | undefined): string {
|
|
361
|
+
if (!mcpBaseUrl || mcpBaseUrl.length === 0) {
|
|
362
|
+
throw new Error(
|
|
363
|
+
"MCP_BASE_URL is not set. Anthropic's managed sandboxes need a public HTTPS URL " +
|
|
364
|
+
"to reach the swarm MCP server. Set MCP_BASE_URL=https://… in your .env or shell.",
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
if (!mcpBaseUrl.startsWith("https://")) {
|
|
368
|
+
throw new Error(
|
|
369
|
+
`MCP_BASE_URL must start with https:// (got: ${mcpBaseUrl}). ` +
|
|
370
|
+
"Anthropic's managed agents only connect to HTTPS MCP endpoints. ",
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
return mcpBaseUrl;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ─── Resolve config (defaults + prompts) ────────────────────────────────────
|
|
377
|
+
|
|
378
|
+
export async function resolveClaudeManagedSetupConfig(
|
|
379
|
+
args: string[],
|
|
380
|
+
deps: {
|
|
381
|
+
env?: Record<string, string | undefined>;
|
|
382
|
+
isInteractive?: boolean;
|
|
383
|
+
promptSecret?: typeof promptHiddenInput;
|
|
384
|
+
} = {},
|
|
385
|
+
): Promise<{
|
|
386
|
+
apiUrl: string;
|
|
387
|
+
apiKey: string;
|
|
388
|
+
anthropicApiKey: string;
|
|
389
|
+
mcpBaseUrl: string;
|
|
390
|
+
agentModel: string;
|
|
391
|
+
force: boolean;
|
|
392
|
+
disableMcp: boolean;
|
|
393
|
+
}> {
|
|
394
|
+
const env = deps.env ?? process.env;
|
|
395
|
+
const parsed = parseArgs(args);
|
|
396
|
+
const promptSecret = deps.promptSecret ?? promptHiddenInput;
|
|
397
|
+
const isInteractive = deps.isInteractive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
398
|
+
|
|
399
|
+
const apiUrl = parsed.apiUrl ?? env.MCP_BASE_URL ?? "http://localhost:3013";
|
|
400
|
+
const apiKey = parsed.apiKey ?? env.API_KEY ?? "123123";
|
|
401
|
+
|
|
402
|
+
let anthropicApiKey = env.ANTHROPIC_API_KEY ?? "";
|
|
403
|
+
if (!anthropicApiKey && isInteractive) {
|
|
404
|
+
anthropicApiKey = (
|
|
405
|
+
await promptSecret(
|
|
406
|
+
"Anthropic API key",
|
|
407
|
+
"",
|
|
408
|
+
"Paste your sk-ant-... key (input is hidden). Stored encrypted in swarm_config.",
|
|
409
|
+
)
|
|
410
|
+
).trim();
|
|
411
|
+
}
|
|
412
|
+
if (!anthropicApiKey) {
|
|
413
|
+
throw new Error(
|
|
414
|
+
"ANTHROPIC_API_KEY is required. Either export it before running, or run interactively.",
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const mcpBaseUrl = validateMcpBaseUrl(env.MCP_BASE_URL);
|
|
419
|
+
const agentModel = env.MANAGED_AGENT_MODEL ?? DEFAULT_AGENT_MODEL;
|
|
420
|
+
const disableMcp = env.MANAGED_DISABLE_MCP === "1" || env.MANAGED_DISABLE_MCP === "true";
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
apiUrl,
|
|
424
|
+
apiKey,
|
|
425
|
+
anthropicApiKey,
|
|
426
|
+
mcpBaseUrl,
|
|
427
|
+
agentModel,
|
|
428
|
+
force: parsed.force,
|
|
429
|
+
disableMcp,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ─── Core flow (testable) ────────────────────────────────────────────────────
|
|
434
|
+
|
|
435
|
+
export type RunClaudeManagedSetupDeps = {
|
|
436
|
+
client?: Anthropic;
|
|
437
|
+
fetchConfig?: typeof fetchConfigByKey;
|
|
438
|
+
upsert?: typeof upsertConfig;
|
|
439
|
+
loadSkills?: typeof loadSkillFiles;
|
|
440
|
+
uploadOne?: typeof uploadSkill;
|
|
441
|
+
skillsDir?: string;
|
|
442
|
+
log?: (msg: string) => void;
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
export async function runClaudeManagedSetupFlow(
|
|
446
|
+
config: {
|
|
447
|
+
apiUrl: string;
|
|
448
|
+
apiKey: string;
|
|
449
|
+
anthropicApiKey: string;
|
|
450
|
+
mcpBaseUrl: string;
|
|
451
|
+
agentModel: string;
|
|
452
|
+
force: boolean;
|
|
453
|
+
disableMcp?: boolean;
|
|
454
|
+
},
|
|
455
|
+
deps: RunClaudeManagedSetupDeps = {},
|
|
456
|
+
): Promise<ClaudeManagedSetupResult> {
|
|
457
|
+
const log = deps.log ?? ((msg: string) => console.log(msg));
|
|
458
|
+
const fetchCfg = deps.fetchConfig ?? fetchConfigByKey;
|
|
459
|
+
const upsert = deps.upsert ?? upsertConfig;
|
|
460
|
+
const loadSkills = deps.loadSkills ?? loadSkillFiles;
|
|
461
|
+
const uploadOne = deps.uploadOne ?? uploadSkill;
|
|
462
|
+
const skillsDir = deps.skillsDir ?? path.resolve(process.cwd(), SKILLS_DIR_RELATIVE);
|
|
463
|
+
|
|
464
|
+
// Idempotency check: if an agent ID is already persisted, short-circuit
|
|
465
|
+
// unless --force was passed.
|
|
466
|
+
if (!config.force) {
|
|
467
|
+
const existing = await fetchCfg(config.apiUrl, config.apiKey, CONFIG_KEY_AGENT_ID);
|
|
468
|
+
if (existing?.value) {
|
|
469
|
+
const existingEnv = await fetchCfg(config.apiUrl, config.apiKey, CONFIG_KEY_ENVIRONMENT_ID);
|
|
470
|
+
log(
|
|
471
|
+
`claude-managed already configured (managed_agent_id=${existing.value}). ` +
|
|
472
|
+
"Re-run with --force to recreate.",
|
|
473
|
+
);
|
|
474
|
+
return {
|
|
475
|
+
agentId: existing.value,
|
|
476
|
+
environmentId: existingEnv?.value ?? "",
|
|
477
|
+
skillIds: [],
|
|
478
|
+
alreadyConfigured: true,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const client = deps.client ?? new Anthropic({ apiKey: config.anthropicApiKey });
|
|
484
|
+
|
|
485
|
+
// 1. Create the environment.
|
|
486
|
+
log("Creating Anthropic-side environment (swarm-worker-env)...");
|
|
487
|
+
const env: BetaEnvironment = await client.beta.environments.create({
|
|
488
|
+
name: "swarm-worker-env",
|
|
489
|
+
config: {
|
|
490
|
+
type: "cloud",
|
|
491
|
+
networking: { type: "unrestricted" },
|
|
492
|
+
},
|
|
493
|
+
});
|
|
494
|
+
log(` + environment id=${env.id}`);
|
|
495
|
+
|
|
496
|
+
// 2. Upload skills.
|
|
497
|
+
log(`Uploading skills from ${skillsDir} ...`);
|
|
498
|
+
const skillFiles = await loadSkills(skillsDir);
|
|
499
|
+
log(` found ${skillFiles.length} skill markdown file(s)`);
|
|
500
|
+
// Pre-fetch existing custom skills so we can reuse their IDs on rerun. The
|
|
501
|
+
// API returns 400 (not 409) on display_title collision and skill IDs aren't
|
|
502
|
+
// surfaced from that error, so the only recovery is to look them up.
|
|
503
|
+
const existingByTitle = new Map<string, string>();
|
|
504
|
+
try {
|
|
505
|
+
for await (const sk of client.beta.skills.list({
|
|
506
|
+
source: "custom",
|
|
507
|
+
betas: [SKILLS_BETA_HEADER],
|
|
508
|
+
})) {
|
|
509
|
+
if (sk.display_title) existingByTitle.set(sk.display_title, sk.id);
|
|
510
|
+
}
|
|
511
|
+
} catch (err) {
|
|
512
|
+
log(` · could not list existing skills (${(err as Error).message}); proceeding anyway`);
|
|
513
|
+
}
|
|
514
|
+
const skillIds: string[] = [];
|
|
515
|
+
let reused = 0;
|
|
516
|
+
let created = 0;
|
|
517
|
+
for (const skill of skillFiles) {
|
|
518
|
+
const wasExisting = existingByTitle.has(skill.slug);
|
|
519
|
+
const id = await uploadOne(client, skill.slug, skill.content, log, existingByTitle);
|
|
520
|
+
if (id) {
|
|
521
|
+
skillIds.push(id);
|
|
522
|
+
if (wasExisting) reused++;
|
|
523
|
+
else created++;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
log(` reused ${reused} existing skill(s); uploaded ${created} new skill(s)`);
|
|
527
|
+
|
|
528
|
+
// 3. Create the agent.
|
|
529
|
+
//
|
|
530
|
+
// NOTE: Anthropic's managed-agents `mcp_servers` URL params don't accept
|
|
531
|
+
// inline auth — credentials must live in an Anthropic-side vault keyed by
|
|
532
|
+
// `mcp_server_url`. Until the setup CLI auto-provisions that vault (see
|
|
533
|
+
// follow-up ticket — `MANAGED_DISABLE_MCP=1` is the temporary bypass), the
|
|
534
|
+
// agent must be created WITHOUT mcp_servers / mcp_toolset, otherwise every
|
|
535
|
+
// session errors out with `mcp_authentication_failed_error`.
|
|
536
|
+
const mcpDisabled = config.disableMcp;
|
|
537
|
+
const mcpServer: BetaManagedAgentsURLMCPServerParams | null = mcpDisabled
|
|
538
|
+
? null
|
|
539
|
+
: {
|
|
540
|
+
name: "agent-swarm",
|
|
541
|
+
type: "url",
|
|
542
|
+
url: `${config.mcpBaseUrl.replace(/\/$/, "")}/mcp`,
|
|
543
|
+
};
|
|
544
|
+
const skillsParam: BetaManagedAgentsCustomSkillParams[] = skillIds.map((id) => ({
|
|
545
|
+
type: "custom",
|
|
546
|
+
skill_id: id,
|
|
547
|
+
}));
|
|
548
|
+
const agentParams: AgentCreateParams = {
|
|
549
|
+
name: "swarm-worker",
|
|
550
|
+
model: config.agentModel,
|
|
551
|
+
description:
|
|
552
|
+
"Agent Swarm worker. Per-task system prompt is delivered in the user.message; the static system field is intentionally minimal.",
|
|
553
|
+
system: mcpServer
|
|
554
|
+
? "You are an agent-swarm worker. Per-task instructions arrive in the next user message. Use the agent-swarm MCP server for swarm operations."
|
|
555
|
+
: "You are an agent-swarm worker. Per-task instructions arrive in the next user message. (No MCP tools available in this configuration.)",
|
|
556
|
+
tools: mcpServer
|
|
557
|
+
? [
|
|
558
|
+
{ type: "agent_toolset_20260401" },
|
|
559
|
+
{ type: "mcp_toolset", mcp_server_name: mcpServer.name },
|
|
560
|
+
]
|
|
561
|
+
: [{ type: "agent_toolset_20260401" }],
|
|
562
|
+
skills: skillsParam,
|
|
563
|
+
...(mcpServer ? { mcp_servers: [mcpServer] } : {}),
|
|
564
|
+
};
|
|
565
|
+
if (mcpDisabled) {
|
|
566
|
+
log("MCP disabled (MANAGED_DISABLE_MCP=1) — agent will run without swarm tools.");
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
log(`Creating agent (model=${config.agentModel}) ...`);
|
|
570
|
+
const agent: BetaManagedAgentsAgent = await client.beta.agents.create(agentParams);
|
|
571
|
+
log(` + agent id=${agent.id}`);
|
|
572
|
+
|
|
573
|
+
// 3b. Upsert MCP vault + static-bearer credential when MCP is enabled.
|
|
574
|
+
// Anthropic's managed sandbox refuses to call our `/mcp` endpoint until a
|
|
575
|
+
// matching vault credential exists. Without this step the operator would
|
|
576
|
+
// have to configure the vault manually via the Anthropic dashboard — see
|
|
577
|
+
// DES-279 in the QA report (`thoughts/taras/qa/2026-04-29-…`) for context.
|
|
578
|
+
let mcpVaultId: string | null = null;
|
|
579
|
+
if (mcpServer) {
|
|
580
|
+
log("Upserting Anthropic MCP vault + static-bearer credential ...");
|
|
581
|
+
try {
|
|
582
|
+
mcpVaultId = await upsertMcpVault(client, mcpServer.url, config.apiKey, config.force, log);
|
|
583
|
+
} catch (err) {
|
|
584
|
+
log(` · MCP vault upsert failed: ${(err as Error).message}`);
|
|
585
|
+
log(
|
|
586
|
+
" · The agent was created but MCP tool calls will fail with " +
|
|
587
|
+
"`mcp_authentication_failed_error` until a vault credential exists. " +
|
|
588
|
+
"Re-run with --force, or configure the vault manually via the Anthropic dashboard.",
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// 4. Persist IDs to swarm_config.
|
|
594
|
+
log("Persisting IDs to swarm_config via PUT /api/config ...");
|
|
595
|
+
await upsert(config.apiUrl, config.apiKey, {
|
|
596
|
+
key: CONFIG_KEY_AGENT_ID,
|
|
597
|
+
value: agent.id,
|
|
598
|
+
isSecret: false,
|
|
599
|
+
description: "Anthropic Managed Agents agent ID (claude-managed-setup)",
|
|
600
|
+
});
|
|
601
|
+
await upsert(config.apiUrl, config.apiKey, {
|
|
602
|
+
key: CONFIG_KEY_ENVIRONMENT_ID,
|
|
603
|
+
value: env.id,
|
|
604
|
+
isSecret: false,
|
|
605
|
+
description: "Anthropic Managed Agents environment ID (claude-managed-setup)",
|
|
606
|
+
});
|
|
607
|
+
await upsert(config.apiUrl, config.apiKey, {
|
|
608
|
+
key: CONFIG_KEY_API_KEY,
|
|
609
|
+
value: config.anthropicApiKey,
|
|
610
|
+
isSecret: true,
|
|
611
|
+
description: "Anthropic API key for claude-managed provider",
|
|
612
|
+
});
|
|
613
|
+
if (mcpVaultId) {
|
|
614
|
+
await upsert(config.apiUrl, config.apiKey, {
|
|
615
|
+
key: CONFIG_KEY_MCP_VAULT_ID,
|
|
616
|
+
value: mcpVaultId,
|
|
617
|
+
isSecret: false,
|
|
618
|
+
description:
|
|
619
|
+
"Anthropic vault id holding the static-bearer credential for the swarm MCP server",
|
|
620
|
+
});
|
|
621
|
+
log(
|
|
622
|
+
` + persisted managed_agent_id, managed_environment_id, anthropic_api_key, managed_mcp_vault_id`,
|
|
623
|
+
);
|
|
624
|
+
} else {
|
|
625
|
+
log(" + persisted managed_agent_id, managed_environment_id, anthropic_api_key");
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
log("");
|
|
629
|
+
log("Done. Add the following to your .env if you prefer env-based config:");
|
|
630
|
+
log(` HARNESS_PROVIDER=claude-managed`);
|
|
631
|
+
log(` ANTHROPIC_API_KEY=<the key you just provided>`);
|
|
632
|
+
log(` MANAGED_AGENT_ID=${agent.id}`);
|
|
633
|
+
log(` MANAGED_ENVIRONMENT_ID=${env.id}`);
|
|
634
|
+
if (mcpVaultId) log(` MANAGED_MCP_VAULT_ID=${mcpVaultId}`);
|
|
635
|
+
log("");
|
|
636
|
+
log(
|
|
637
|
+
"Or skip the .env entries — deployed workers automatically restore these from " +
|
|
638
|
+
"swarm_config at boot via docker-entrypoint.sh.",
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
return {
|
|
642
|
+
agentId: agent.id,
|
|
643
|
+
environmentId: env.id,
|
|
644
|
+
skillIds,
|
|
645
|
+
alreadyConfigured: false,
|
|
646
|
+
mcpVaultId,
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// ─── Entry point ─────────────────────────────────────────────────────────────
|
|
651
|
+
|
|
652
|
+
export type RunClaudeManagedSetupDepsRoot = {
|
|
653
|
+
resolveConfig?: typeof resolveClaudeManagedSetupConfig;
|
|
654
|
+
flow?: typeof runClaudeManagedSetupFlow;
|
|
655
|
+
log?: (msg: string) => void;
|
|
656
|
+
error?: (msg: string) => void;
|
|
657
|
+
exit?: (code: number) => void;
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
export async function runClaudeManagedSetup(
|
|
661
|
+
args: string[],
|
|
662
|
+
deps: RunClaudeManagedSetupDepsRoot = {},
|
|
663
|
+
): Promise<void> {
|
|
664
|
+
const log = deps.log ?? ((msg: string) => console.log(msg));
|
|
665
|
+
const error = deps.error ?? ((msg: string) => console.error(msg));
|
|
666
|
+
const exit = deps.exit ?? ((code: number) => process.exit(code));
|
|
667
|
+
const resolveConfig = deps.resolveConfig ?? resolveClaudeManagedSetupConfig;
|
|
668
|
+
const flow = deps.flow ?? runClaudeManagedSetupFlow;
|
|
669
|
+
|
|
670
|
+
if (parseArgs(args).showHelp) {
|
|
671
|
+
printHelp();
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
try {
|
|
676
|
+
const config = await resolveConfig(args);
|
|
677
|
+
log(`Target swarm API: ${config.apiUrl}`);
|
|
678
|
+
log(`MCP base URL : ${config.mcpBaseUrl}`);
|
|
679
|
+
log(`Agent model : ${config.agentModel}`);
|
|
680
|
+
log("");
|
|
681
|
+
await flow(config);
|
|
682
|
+
} catch (err) {
|
|
683
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
684
|
+
error(`\n[claude-managed-setup] ${message}`);
|
|
685
|
+
exit(1);
|
|
686
|
+
}
|
|
687
|
+
}
|