@desplega.ai/agent-swarm 1.92.2 → 1.93.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/openapi.json +63 -3
- package/package.json +5 -5
- package/src/be/db.ts +91 -6
- package/src/be/memory/boot-reembed.ts +0 -1
- package/src/be/memory/providers/sqlite-store.ts +42 -25
- package/src/be/memory/raters/llm-client.ts +12 -5
- package/src/be/memory/types.ts +3 -0
- package/src/be/migrations/088_script_runs_list_indexes.sql +10 -0
- package/src/be/migrations/089_harness_variant.sql +2 -0
- package/src/be/modelsdev-cache.json +1222 -986
- package/src/be/seed-pricing.ts +1 -0
- package/src/be/seed-scripts/catalog/boot-triage.inline.ts +221 -0
- package/src/be/seed-scripts/catalog/catalog-report.inline.ts +457 -0
- package/src/be/seed-scripts/catalog/compound-insights.inline.ts +863 -0
- package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +506 -0
- package/src/be/seed-scripts/index.ts +5 -5
- package/src/be/skill-sync.ts +28 -179
- package/src/commands/runner.ts +124 -7
- package/src/http/api-keys.ts +42 -0
- package/src/http/mcp-bridge.ts +1 -1
- package/src/http/memory.ts +23 -24
- package/src/http/tasks.ts +10 -6
- package/src/providers/claude-adapter.ts +33 -1
- package/src/providers/claude-managed-adapter.ts +3 -0
- package/src/providers/claude-managed-models.ts +7 -0
- package/src/providers/codex-adapter.ts +8 -1
- package/src/providers/codex-models.ts +1 -0
- package/src/providers/codex-oauth/auth-json.ts +1 -0
- package/src/providers/harness-version.ts +7 -0
- package/src/providers/opencode-adapter.ts +11 -4
- package/src/providers/pi-mono-adapter.ts +12 -2
- package/src/providers/types.ts +2 -0
- package/src/scripts-runtime/egress-secrets.ts +83 -0
- package/src/scripts-runtime/eval-harness.ts +4 -0
- package/src/scripts-runtime/executors/types.ts +7 -0
- package/src/scripts-runtime/loader.ts +2 -0
- package/src/server-user.ts +2 -2
- package/src/slack/channel-join.ts +41 -0
- package/src/tests/additive-buffer.test.ts +0 -1
- package/src/tests/api-key-tracking.test.ts +113 -0
- package/src/tests/approval-requests.test.ts +0 -6
- package/src/tests/claude-managed-setup.test.ts +0 -4
- package/src/tests/codex-pool.test.ts +2 -6
- package/src/tests/http-api-integration.test.ts +4 -6
- package/src/tests/memory-edges.test.ts +0 -2
- package/src/tests/memory-rate-endpoint.test.ts +0 -2
- package/src/tests/memory-rater-e2e.test.ts +0 -2
- package/src/tests/memory-store.test.ts +19 -1
- package/src/tests/memory.test.ts +51 -0
- package/src/tests/model-control.test.ts +1 -1
- package/src/tests/reload-config.test.ts +33 -17
- package/src/tests/runner-skills-refresh.test.ts +216 -46
- package/src/tests/script-runs-http.test.ts +7 -1
- package/src/tests/scripts-runtime-secret-egress.test.ts +129 -0
- package/src/tests/seed-scripts.test.ts +13 -1
- package/src/tests/session-attach.test.ts +6 -6
- package/src/tests/skill-fs-writer.test.ts +250 -0
- package/src/tests/slack-attachments-block.test.ts +0 -1
- package/src/tests/slack-blocks.test.ts +0 -1
- package/src/tests/slack-channel-join.test.ts +80 -0
- package/src/tests/slack-identity-resolution.test.ts +0 -1
- package/src/tests/structured-output.test.ts +0 -2
- package/src/tests/use-dismissible-card.test.ts +0 -4
- package/src/tools/schedules/create-schedule.ts +2 -2
- package/src/tools/schedules/update-schedule.ts +1 -1
- package/src/tools/send-task.ts +2 -2
- package/src/tools/slack-post.ts +18 -15
- package/src/tools/slack-read.ts +9 -11
- package/src/tools/slack-reply.ts +18 -15
- package/src/tools/slack-start-thread.ts +17 -14
- package/src/tools/task-action.ts +2 -2
- package/src/types.ts +11 -0
- package/src/utils/context-window.ts +3 -0
- package/src/utils/credentials.ts +22 -2
- package/src/utils/skill-fs-writer.ts +220 -0
- package/src/utils/skills-refresh.ts +123 -40
|
@@ -470,6 +470,8 @@ class ClaudeSession implements ProviderSession {
|
|
|
470
470
|
private sessionMcpConfig: string | null = null,
|
|
471
471
|
private claudeBinaryArgv: readonly string[] = ["claude"],
|
|
472
472
|
systemPromptFile: string | null = null,
|
|
473
|
+
private harnessVariant?: string,
|
|
474
|
+
private harnessVariantMeta?: Record<string, unknown>,
|
|
473
475
|
) {
|
|
474
476
|
this.taskFilePid = taskFilePid;
|
|
475
477
|
this.contextWindowSize = getContextWindowSize(model);
|
|
@@ -682,7 +684,13 @@ class ClaudeSession implements ProviderSession {
|
|
|
682
684
|
// Session ID from init message
|
|
683
685
|
if (json.type === "system" && json.subtype === "init" && json.session_id) {
|
|
684
686
|
this._sessionId = json.session_id;
|
|
685
|
-
this.emit({
|
|
687
|
+
this.emit({
|
|
688
|
+
type: "session_init",
|
|
689
|
+
sessionId: json.session_id,
|
|
690
|
+
provider: "claude",
|
|
691
|
+
...(this.harnessVariant ? { harnessVariant: this.harnessVariant } : {}),
|
|
692
|
+
...(this.harnessVariantMeta ? { harnessVariantMeta: this.harnessVariantMeta } : {}),
|
|
693
|
+
});
|
|
686
694
|
if (json.model) {
|
|
687
695
|
// Phase 4: the CLI's `init.model` reflects the actual model after any
|
|
688
696
|
// backoff/fallback. Update `this.model` so subsequent CostData rows
|
|
@@ -970,6 +978,28 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
970
978
|
}
|
|
971
979
|
}
|
|
972
980
|
|
|
981
|
+
const harnessVariant = useClaudeBridge ? "bridge" : "stock";
|
|
982
|
+
let harnessVariantMeta: Record<string, unknown> | undefined;
|
|
983
|
+
if (useClaudeBridge) {
|
|
984
|
+
try {
|
|
985
|
+
const bin = effectiveClaudeBinaryArgv[0] ?? "claude-bridge";
|
|
986
|
+
const result = await Bun.$`${bin} --version`.quiet();
|
|
987
|
+
const trimmed = result.text().trim();
|
|
988
|
+
if (trimmed) harnessVariantMeta = { version: trimmed };
|
|
989
|
+
} catch {
|
|
990
|
+
// bridge version is best-effort
|
|
991
|
+
}
|
|
992
|
+
} else {
|
|
993
|
+
try {
|
|
994
|
+
const bin = effectiveClaudeBinaryArgv[0] ?? "claude";
|
|
995
|
+
const result = await Bun.$`${bin} --version`.quiet();
|
|
996
|
+
const trimmed = result.text().trim();
|
|
997
|
+
if (trimmed) harnessVariantMeta = { version: trimmed };
|
|
998
|
+
} catch {
|
|
999
|
+
// stock version is best-effort
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
973
1003
|
return new ClaudeSession(
|
|
974
1004
|
config,
|
|
975
1005
|
model,
|
|
@@ -978,6 +1008,8 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
978
1008
|
sessionMcpConfig,
|
|
979
1009
|
effectiveClaudeBinaryArgv,
|
|
980
1010
|
systemPromptFile,
|
|
1011
|
+
harnessVariant,
|
|
1012
|
+
harnessVariantMeta,
|
|
981
1013
|
);
|
|
982
1014
|
}
|
|
983
1015
|
|
|
@@ -69,6 +69,7 @@ import { scrubSecrets } from "../utils/secret-scrubber";
|
|
|
69
69
|
import { computeClaudeManagedCostUsd } from "./claude-managed-models";
|
|
70
70
|
import { getRuntimeFeePerHour } from "./claude-managed-pricing";
|
|
71
71
|
import { createClaudeManagedSwarmEventHandler } from "./claude-managed-swarm-events";
|
|
72
|
+
import { readPkgVersion } from "./harness-version";
|
|
72
73
|
import type {
|
|
73
74
|
CostData,
|
|
74
75
|
CredStatus,
|
|
@@ -639,11 +640,13 @@ class ClaudeManagedSession implements ProviderSession {
|
|
|
639
640
|
// 3. Emit `session_init` once the session is wired up. Listeners
|
|
640
641
|
// attached via `onEvent` will see this either immediately (if they
|
|
641
642
|
// attached pre-emit) or via the queue flush.
|
|
643
|
+
const sdkVersion = readPkgVersion("@anthropic-ai/sdk");
|
|
642
644
|
this.emit({
|
|
643
645
|
type: "session_init",
|
|
644
646
|
sessionId: this._sessionId,
|
|
645
647
|
provider: "claude-managed",
|
|
646
648
|
providerMeta: { managed: true },
|
|
649
|
+
...(sdkVersion ? { harnessVariantMeta: { version: sdkVersion } } : {}),
|
|
647
650
|
});
|
|
648
651
|
|
|
649
652
|
// 4. Drain the SSE stream.
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
/** Models supported by the managed-agents surface for the swarm worker. */
|
|
27
27
|
export const CLAUDE_MANAGED_MODELS = [
|
|
28
|
+
"claude-fable-5",
|
|
28
29
|
"claude-sonnet-4-6",
|
|
29
30
|
"claude-opus-4-8",
|
|
30
31
|
"claude-opus-4-7",
|
|
@@ -57,6 +58,12 @@ export interface ClaudeManagedModelPricing {
|
|
|
57
58
|
* - claude-haiku-4-5: $1 / $5 / $0.10 / $1.25
|
|
58
59
|
*/
|
|
59
60
|
export const CLAUDE_MANAGED_MODEL_PRICING: Record<ClaudeManagedModel, ClaudeManagedModelPricing> = {
|
|
61
|
+
"claude-fable-5": {
|
|
62
|
+
inputPerMillion: 10.0,
|
|
63
|
+
outputPerMillion: 50.0,
|
|
64
|
+
cacheReadPerMillion: 1.0,
|
|
65
|
+
cacheWritePerMillion: 12.5,
|
|
66
|
+
},
|
|
60
67
|
"claude-sonnet-4-6": {
|
|
61
68
|
inputPerMillion: 3.0,
|
|
62
69
|
outputPerMillion: 15.0,
|
|
@@ -83,6 +83,7 @@ import { getValidCodexOAuth } from "./codex-oauth/storage.js";
|
|
|
83
83
|
import { resolveCodexPrompt } from "./codex-skill-resolver";
|
|
84
84
|
import { createCodexSwarmEventHandler } from "./codex-swarm-events";
|
|
85
85
|
import { CTX_MODE_NUDGE_EVERY } from "./ctx-mode-env";
|
|
86
|
+
import { readPkgVersion } from "./harness-version";
|
|
86
87
|
import { buildOtelTraceparentEnv } from "./otel-env";
|
|
87
88
|
import type {
|
|
88
89
|
CostData,
|
|
@@ -694,7 +695,13 @@ export class CodexSession implements ProviderSession {
|
|
|
694
695
|
switch (event.type) {
|
|
695
696
|
case "thread.started": {
|
|
696
697
|
this._sessionId = event.thread_id;
|
|
697
|
-
|
|
698
|
+
const codexVersion = readPkgVersion("@openai/codex-sdk");
|
|
699
|
+
this.emit({
|
|
700
|
+
type: "session_init",
|
|
701
|
+
sessionId: event.thread_id,
|
|
702
|
+
provider: "codex",
|
|
703
|
+
...(codexVersion ? { harnessVariantMeta: { version: codexVersion } } : {}),
|
|
704
|
+
});
|
|
698
705
|
break;
|
|
699
706
|
}
|
|
700
707
|
case "turn.started": {
|
|
@@ -36,6 +36,7 @@ export const CODEX_DEFAULT_MODEL: CodexModel = "gpt-5.4";
|
|
|
36
36
|
* a task authored for Claude works unchanged when pointed at a Codex worker.
|
|
37
37
|
*/
|
|
38
38
|
const CLAUDE_SHORTNAMES: Record<string, CodexModel> = {
|
|
39
|
+
fable: "gpt-5.5",
|
|
39
40
|
opus: "gpt-5.4",
|
|
40
41
|
sonnet: "gpt-5.4",
|
|
41
42
|
haiku: "gpt-5.4-mini",
|
|
@@ -21,6 +21,7 @@ import { validateOpencodeCredentials } from "../utils/credentials";
|
|
|
21
21
|
import { fetchInstalledMcpServers } from "../utils/mcp-server-fetcher";
|
|
22
22
|
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
23
23
|
import { CTX_MODE_NUDGE_EVERY } from "./ctx-mode-env";
|
|
24
|
+
import { readPkgVersion } from "./harness-version";
|
|
24
25
|
import type {
|
|
25
26
|
CostData,
|
|
26
27
|
CredCheckOptions,
|
|
@@ -210,7 +211,7 @@ export class OpencodeSession implements ProviderSession {
|
|
|
210
211
|
// The runner attaches its listener after `await adapter.createSession(...)`
|
|
211
212
|
// resolves, but events queued via Promise.resolve().then(...) inside
|
|
212
213
|
// createSession fire on the next microtask — *before* that listener call —
|
|
213
|
-
// so the runner would miss session_init and never PUT /
|
|
214
|
+
// so the runner would miss session_init and never PUT /session,
|
|
214
215
|
// leaving agent_tasks.provider/.model NULL. Buffer + flush on first attach.
|
|
215
216
|
private pendingEvents: ProviderEvent[] = [];
|
|
216
217
|
private completionResolve!: (result: ProviderResult) => void;
|
|
@@ -280,8 +281,13 @@ export class OpencodeSession implements ProviderSession {
|
|
|
280
281
|
|
|
281
282
|
/** Emit the synthetic session_init event. Called by the adapter immediately
|
|
282
283
|
* after construction; buffers if no listener is attached yet. */
|
|
283
|
-
emitSessionInit(provider: "opencode"): void {
|
|
284
|
-
this.emit({
|
|
284
|
+
emitSessionInit(provider: "opencode", harnessVariantMeta?: Record<string, unknown>): void {
|
|
285
|
+
this.emit({
|
|
286
|
+
type: "session_init",
|
|
287
|
+
sessionId: this._sessionId,
|
|
288
|
+
provider,
|
|
289
|
+
...(harnessVariantMeta ? { harnessVariantMeta } : {}),
|
|
290
|
+
});
|
|
285
291
|
}
|
|
286
292
|
|
|
287
293
|
onEvent(listener: (event: ProviderEvent) => void): void {
|
|
@@ -767,7 +773,8 @@ export class OpencodeAdapter implements ProviderAdapter {
|
|
|
767
773
|
|
|
768
774
|
// Emit session_init synchronously; the session buffers events until the
|
|
769
775
|
// runner's `onEvent(listener)` call attaches a listener.
|
|
770
|
-
|
|
776
|
+
const opcVersion = readPkgVersion("@opencode-ai/sdk");
|
|
777
|
+
session.emitSessionInit("opencode", opcVersion ? { version: opcVersion } : undefined);
|
|
771
778
|
|
|
772
779
|
// Subscribe to SSE events and drive the session
|
|
773
780
|
client.event
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
} from "@earendil-works/pi-coding-agent";
|
|
27
27
|
import { type TSchema, Type } from "typebox";
|
|
28
28
|
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
29
|
+
import { readPkgVersion } from "./harness-version";
|
|
29
30
|
import { createSwarmHooksExtension } from "./pi-mono-extension";
|
|
30
31
|
import { McpHttpClient } from "./pi-mono-mcp-client";
|
|
31
32
|
import type {
|
|
@@ -173,6 +174,7 @@ function mcpToolsToDefinitions(
|
|
|
173
174
|
* (`anthropic/claude-{opus,sonnet,haiku}-*`).
|
|
174
175
|
*/
|
|
175
176
|
const ANTHROPIC_SHORTNAME_OPENROUTER_MIRROR: Record<string, string> = {
|
|
177
|
+
fable: "anthropic/claude-fable-5",
|
|
176
178
|
opus: "anthropic/claude-opus-4",
|
|
177
179
|
sonnet: "anthropic/claude-sonnet-4",
|
|
178
180
|
haiku: "anthropic/claude-haiku-4.5",
|
|
@@ -233,7 +235,8 @@ export function resolveModel(
|
|
|
233
235
|
if (!modelStr) return undefined;
|
|
234
236
|
|
|
235
237
|
const lower = modelStr.toLowerCase();
|
|
236
|
-
const isAnthropicShortname =
|
|
238
|
+
const isAnthropicShortname =
|
|
239
|
+
lower === "opus" || lower === "sonnet" || lower === "haiku" || lower === "fable";
|
|
237
240
|
|
|
238
241
|
// Reroute anthropic shortnames through OpenRouter when no anthropic cred
|
|
239
242
|
// is available. The OpenRouter mirror IDs (`anthropic/claude-sonnet-4`,
|
|
@@ -251,6 +254,7 @@ export function resolveModel(
|
|
|
251
254
|
|
|
252
255
|
// Map common shortnames to provider/model pairs (native anthropic path).
|
|
253
256
|
const shortnames: Record<string, [string, string]> = {
|
|
257
|
+
fable: ["anthropic", "claude-fable-5"],
|
|
254
258
|
opus: ["anthropic", "claude-opus-4-20250514"],
|
|
255
259
|
sonnet: ["anthropic", "claude-sonnet-4-20250514"],
|
|
256
260
|
haiku: ["anthropic", "claude-haiku-4-5-20251001"],
|
|
@@ -367,7 +371,13 @@ export class PiMonoSession implements ProviderSession {
|
|
|
367
371
|
this.sessionStartedAt = Date.now();
|
|
368
372
|
|
|
369
373
|
// Emit session_init immediately
|
|
370
|
-
|
|
374
|
+
const piVersion = readPkgVersion("@earendil-works/pi-coding-agent");
|
|
375
|
+
this.emit({
|
|
376
|
+
type: "session_init",
|
|
377
|
+
sessionId: this._sessionId,
|
|
378
|
+
provider: "pi",
|
|
379
|
+
...(piVersion ? { harnessVariantMeta: { version: piVersion } } : {}),
|
|
380
|
+
});
|
|
371
381
|
|
|
372
382
|
// Subscribe to agent events and normalize
|
|
373
383
|
this.agentSession.subscribe((event) => this.handleAgentEvent(event));
|
package/src/providers/types.ts
CHANGED
|
@@ -42,6 +42,8 @@ export type ProviderEvent =
|
|
|
42
42
|
sessionId: string;
|
|
43
43
|
provider?: ProviderName;
|
|
44
44
|
providerMeta?: Record<string, unknown>;
|
|
45
|
+
harnessVariant?: string;
|
|
46
|
+
harnessVariantMeta?: Record<string, unknown>;
|
|
45
47
|
}
|
|
46
48
|
| { type: "message"; role: "assistant" | "user"; content: string }
|
|
47
49
|
| { type: "tool_start"; toolCallId: string; toolName: string; args: unknown }
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { EgressSecretEntry } from "./executors/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hardcoded allowlist mapping env-var names to the hosts where egress
|
|
5
|
+
* substitution is permitted. Adding a new entry here is a security-boundary
|
|
6
|
+
* decision — it lets scripts authenticate to that host without the caller
|
|
7
|
+
* passing the secret explicitly.
|
|
8
|
+
*/
|
|
9
|
+
const EGRESS_ALLOWLIST: Record<string, string[]> = {
|
|
10
|
+
GITHUB_TOKEN: ["api.github.com"],
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function buildEgressSecrets(): EgressSecretEntry[] {
|
|
14
|
+
const entries: EgressSecretEntry[] = [];
|
|
15
|
+
for (const [envKey, hosts] of Object.entries(EGRESS_ALLOWLIST)) {
|
|
16
|
+
const value = process.env[envKey];
|
|
17
|
+
if (!value) continue;
|
|
18
|
+
entries.push({
|
|
19
|
+
placeholder: `[REDACTED:${envKey}]`,
|
|
20
|
+
hosts,
|
|
21
|
+
value,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
return entries;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function patchFetchWithEgressSubstitution(secrets: EgressSecretEntry[]): void {
|
|
28
|
+
if (secrets.length === 0) return;
|
|
29
|
+
|
|
30
|
+
const byPlaceholder = new Map<string, EgressSecretEntry>();
|
|
31
|
+
for (const entry of secrets) {
|
|
32
|
+
byPlaceholder.set(entry.placeholder, entry);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const originalFetch = globalThis.fetch;
|
|
36
|
+
|
|
37
|
+
globalThis.fetch = function patchedFetch(
|
|
38
|
+
input: string | URL | Request,
|
|
39
|
+
init?: RequestInit,
|
|
40
|
+
): Promise<Response> {
|
|
41
|
+
let hostname: string;
|
|
42
|
+
try {
|
|
43
|
+
const url = input instanceof Request ? input.url : input instanceof URL ? input.href : input;
|
|
44
|
+
hostname = new URL(url).hostname;
|
|
45
|
+
} catch {
|
|
46
|
+
return originalFetch(input, init);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const headers = new Headers(input instanceof Request ? input.headers : init?.headers);
|
|
50
|
+
|
|
51
|
+
let modified = false;
|
|
52
|
+
const newHeaders = new Headers();
|
|
53
|
+
|
|
54
|
+
for (const [key, rawValue] of headers.entries()) {
|
|
55
|
+
let value = rawValue;
|
|
56
|
+
for (const [placeholder, entry] of byPlaceholder) {
|
|
57
|
+
if (value.includes(placeholder) && entry.hosts.includes(hostname)) {
|
|
58
|
+
value = value.split(placeholder).join(entry.value);
|
|
59
|
+
modified = true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
newHeaders.set(key, value);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!modified) return originalFetch(input, init);
|
|
66
|
+
|
|
67
|
+
const mergedInit: RequestInit = {
|
|
68
|
+
...(input instanceof Request
|
|
69
|
+
? {
|
|
70
|
+
method: input.method,
|
|
71
|
+
body: input.body,
|
|
72
|
+
redirect: input.redirect,
|
|
73
|
+
signal: input.signal,
|
|
74
|
+
}
|
|
75
|
+
: {}),
|
|
76
|
+
...init,
|
|
77
|
+
headers: newHeaders,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const url = input instanceof Request ? input.url : input;
|
|
81
|
+
return originalFetch(url, mergedInit);
|
|
82
|
+
} as typeof fetch;
|
|
83
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildCtx } from "./ctx";
|
|
2
|
+
import { patchFetchWithEgressSubstitution } from "./egress-secrets";
|
|
2
3
|
import type { SwarmConfigPayload } from "./executors/types";
|
|
3
4
|
import { SwarmConfig } from "./swarm-config";
|
|
4
5
|
|
|
@@ -84,6 +85,9 @@ try {
|
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
const payload = JSON.parse(stdin) as SwarmConfigPayload;
|
|
88
|
+
if (payload.egressSecrets?.length) {
|
|
89
|
+
patchFetchWithEgressSubstitution(payload.egressSecrets);
|
|
90
|
+
}
|
|
87
91
|
const swarmConfig = new SwarmConfig(payload);
|
|
88
92
|
const rawArgs = JSON.parse(await Bun.file(requiredEnv("SWARM_SCRIPT_ARGS_FILE")).text());
|
|
89
93
|
// Accept both shapes: callers may pass an already-serialized JSON string.
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
export type ScriptFsMode = "none" | "workspace-rw";
|
|
2
2
|
|
|
3
|
+
export type EgressSecretEntry = {
|
|
4
|
+
placeholder: string;
|
|
5
|
+
hosts: string[];
|
|
6
|
+
value: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
3
9
|
export type SwarmConfigPayload = {
|
|
4
10
|
system: {
|
|
5
11
|
apiKey: { value: string; isSecret: true };
|
|
@@ -7,6 +13,7 @@ export type SwarmConfigPayload = {
|
|
|
7
13
|
mcpBaseUrl: { value: string; isSecret: false };
|
|
8
14
|
};
|
|
9
15
|
user: Record<string, { value: string; isSecret: boolean }>;
|
|
16
|
+
egressSecrets?: EgressSecretEntry[];
|
|
10
17
|
};
|
|
11
18
|
|
|
12
19
|
export type ScriptResourcePolicy = {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getApiKey } from "../utils/api-key";
|
|
2
2
|
import { scrubObject, scrubSecrets } from "../utils/secret-scrubber";
|
|
3
|
+
import { buildEgressSecrets } from "./egress-secrets";
|
|
3
4
|
import { getScriptExecutor } from "./executors/registry";
|
|
4
5
|
import {
|
|
5
6
|
DEFAULT_SCRIPT_RESOURCES,
|
|
@@ -44,6 +45,7 @@ function buildConfigPayload(input: RunScriptInput): SwarmConfigPayload {
|
|
|
44
45
|
},
|
|
45
46
|
},
|
|
46
47
|
user: input.userConfig ?? {},
|
|
48
|
+
egressSecrets: buildEgressSecrets(),
|
|
47
49
|
};
|
|
48
50
|
}
|
|
49
51
|
|
package/src/server-user.ts
CHANGED
|
@@ -28,9 +28,9 @@ const userSendTaskInputSchema = z.object({
|
|
|
28
28
|
tags: z.array(z.string()).optional().describe("Tags for filtering (e.g., ['urgent'])."),
|
|
29
29
|
priority: z.number().int().min(0).max(100).optional().describe("Priority 0-100 (default: 50)."),
|
|
30
30
|
model: z
|
|
31
|
-
.enum(["haiku", "sonnet", "opus"])
|
|
31
|
+
.enum(["haiku", "sonnet", "opus", "fable"])
|
|
32
32
|
.optional()
|
|
33
|
-
.describe("Model to use for this task ('haiku', 'sonnet', or '
|
|
33
|
+
.describe("Model to use for this task ('haiku', 'sonnet', 'opus', or 'fable')."),
|
|
34
34
|
});
|
|
35
35
|
|
|
36
36
|
export function createUserServer(user: User): McpServer {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { WebClient } from "@slack/web-api";
|
|
2
|
+
|
|
3
|
+
// @slack/web-api platform errors set message to "An API error occurred: <code>"
|
|
4
|
+
// and store the raw Slack API code at error.data.error.
|
|
5
|
+
function slackCode(error: unknown): string | undefined {
|
|
6
|
+
if (!(error instanceof Error)) return undefined;
|
|
7
|
+
const d = (error as { data?: { error?: unknown } }).data;
|
|
8
|
+
return typeof d?.error === "string" ? d.error : undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Wraps a Slack API call with automatic channel join for public channels.
|
|
13
|
+
*
|
|
14
|
+
* On not_in_channel: calls conversations.join and retries the original call once.
|
|
15
|
+
* On private channel (method_not_supported_for_channel_type): throws a descriptive
|
|
16
|
+
* error telling the caller the bot must be /invite-d — it cannot self-join private channels.
|
|
17
|
+
*/
|
|
18
|
+
export async function withAutoJoin<T>(
|
|
19
|
+
client: WebClient,
|
|
20
|
+
channelId: string,
|
|
21
|
+
fn: () => Promise<T>,
|
|
22
|
+
): Promise<T> {
|
|
23
|
+
try {
|
|
24
|
+
return await fn();
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (slackCode(error) !== "not_in_channel") throw error;
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
await client.conversations.join({ channel: channelId });
|
|
30
|
+
} catch (joinError) {
|
|
31
|
+
if (slackCode(joinError) === "method_not_supported_for_channel_type") {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`Cannot access private channel ${channelId} — invite the bot with /invite @<bot-name> first.`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
throw joinError;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return await fn();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -15,7 +15,6 @@ describe("createAdditiveBuffer", () => {
|
|
|
15
15
|
/positive number/,
|
|
16
16
|
);
|
|
17
17
|
expect(() => createAdditiveBuffer({ timeoutMs: -1, onFlush: () => {} })).toThrow();
|
|
18
|
-
// biome-ignore lint/suspicious/noExplicitAny: type-guard test
|
|
19
18
|
expect(() => createAdditiveBuffer({ timeoutMs: NaN as any, onFlush: () => {} })).toThrow();
|
|
20
19
|
});
|
|
21
20
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
6
6
|
import { unlink } from "node:fs/promises";
|
|
7
7
|
import {
|
|
8
|
+
clearKeyRateLimit,
|
|
8
9
|
closeDb,
|
|
9
10
|
getAvailableKeyIndices,
|
|
10
11
|
getKeyStatuses,
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
markKeyRateLimited,
|
|
13
14
|
recordKeyUsage,
|
|
14
15
|
} from "../be/db";
|
|
16
|
+
import type { CredentialSelection } from "../utils/credentials";
|
|
15
17
|
import { resolveCredentialPools, selectCredential } from "../utils/credentials";
|
|
16
18
|
|
|
17
19
|
// ─── Credential Selection Unit Tests ────────────────────────────────────────
|
|
@@ -53,6 +55,7 @@ describe("selectCredential", () => {
|
|
|
53
55
|
const value = "key-aaa11,key-bbb22";
|
|
54
56
|
const result = selectCredential(value, []);
|
|
55
57
|
expect(["key-aaa11", "key-bbb22"]).toContain(result.selected);
|
|
58
|
+
expect(result.isRateLimitFallback).toBe(true);
|
|
56
59
|
});
|
|
57
60
|
|
|
58
61
|
test("filters out-of-range availableIndices", () => {
|
|
@@ -60,6 +63,23 @@ describe("selectCredential", () => {
|
|
|
60
63
|
const result = selectCredential(value, [99]); // Out of range
|
|
61
64
|
// Falls back to random
|
|
62
65
|
expect(["key-aaa11", "key-bbb22"]).toContain(result.selected);
|
|
66
|
+
expect(result.isRateLimitFallback).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("isRateLimitFallback is false when indices are available", () => {
|
|
70
|
+
const result = selectCredential("key-aaa11,key-bbb22", [0, 1]);
|
|
71
|
+
expect(result.isRateLimitFallback).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("isRateLimitFallback is false when no availability info", () => {
|
|
75
|
+
const result = selectCredential("key-aaa11,key-bbb22");
|
|
76
|
+
expect(result.isRateLimitFallback).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("single key with empty availableIndices sets isRateLimitFallback", () => {
|
|
80
|
+
const result = selectCredential("single-key", []);
|
|
81
|
+
expect(result.isRateLimitFallback).toBe(true);
|
|
82
|
+
expect(result.selected).toBe("single-key");
|
|
63
83
|
});
|
|
64
84
|
|
|
65
85
|
test("keySuffix is last 5 chars of selected key", () => {
|
|
@@ -198,4 +218,97 @@ describe("API key tracking DB queries", () => {
|
|
|
198
218
|
const key1b = statuses2.find((s) => s.keySuffix === "bbb22");
|
|
199
219
|
expect(key1b!.rateLimitCount).toBe(2);
|
|
200
220
|
});
|
|
221
|
+
|
|
222
|
+
test("clearKeyRateLimit clears a rate-limited key", () => {
|
|
223
|
+
const until = new Date(Date.now() + 300_000).toISOString();
|
|
224
|
+
recordKeyUsage("OPENAI_API_KEY", "oai01", 0, null);
|
|
225
|
+
markKeyRateLimited("OPENAI_API_KEY", "oai01", 0, until);
|
|
226
|
+
|
|
227
|
+
let statuses = getKeyStatuses("OPENAI_API_KEY");
|
|
228
|
+
expect(statuses.find((s) => s.keySuffix === "oai01")!.status).toBe("rate_limited");
|
|
229
|
+
|
|
230
|
+
const cleared = clearKeyRateLimit("OPENAI_API_KEY", "oai01");
|
|
231
|
+
expect(cleared).toBe(true);
|
|
232
|
+
|
|
233
|
+
statuses = getKeyStatuses("OPENAI_API_KEY");
|
|
234
|
+
expect(statuses.find((s) => s.keySuffix === "oai01")!.status).toBe("available");
|
|
235
|
+
expect(statuses.find((s) => s.keySuffix === "oai01")!.rateLimitedUntil).toBeNull();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("clearKeyRateLimit returns false for already-available key", () => {
|
|
239
|
+
recordKeyUsage("OPENAI_API_KEY", "oai02", 1, null);
|
|
240
|
+
const cleared = clearKeyRateLimit("OPENAI_API_KEY", "oai02");
|
|
241
|
+
expect(cleared).toBe(false);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// ─── Cross-keyType Failover Logic Tests ──────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
describe("cross-keyType failover", () => {
|
|
248
|
+
test("prefers non-rate-limited credential when both keyTypes available", () => {
|
|
249
|
+
const rateLimited: CredentialSelection = {
|
|
250
|
+
selected: "sk-xxx",
|
|
251
|
+
index: 0,
|
|
252
|
+
total: 1,
|
|
253
|
+
keySuffix: "k-xxx",
|
|
254
|
+
keyType: "OPENAI_API_KEY",
|
|
255
|
+
isRateLimitFallback: true,
|
|
256
|
+
};
|
|
257
|
+
const healthy: CredentialSelection = {
|
|
258
|
+
selected: "oauth-yyy",
|
|
259
|
+
index: 0,
|
|
260
|
+
total: 2,
|
|
261
|
+
keySuffix: "h-yyy",
|
|
262
|
+
keyType: "CODEX_OAUTH",
|
|
263
|
+
isRateLimitFallback: false,
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
// Simulate the runner's primary selection logic
|
|
267
|
+
let primarySelection: CredentialSelection | undefined;
|
|
268
|
+
if (rateLimited && healthy) {
|
|
269
|
+
if (rateLimited.isRateLimitFallback && !healthy.isRateLimitFallback) {
|
|
270
|
+
primarySelection = healthy;
|
|
271
|
+
} else {
|
|
272
|
+
primarySelection = rateLimited;
|
|
273
|
+
}
|
|
274
|
+
} else {
|
|
275
|
+
primarySelection = rateLimited ?? healthy;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
expect(primarySelection).toBe(healthy);
|
|
279
|
+
expect(primarySelection!.keyType).toBe("CODEX_OAUTH");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("uses first credential when neither is rate-limited", () => {
|
|
283
|
+
const first: CredentialSelection = {
|
|
284
|
+
selected: "sk-aaa",
|
|
285
|
+
index: 0,
|
|
286
|
+
total: 1,
|
|
287
|
+
keySuffix: "k-aaa",
|
|
288
|
+
keyType: "OPENAI_API_KEY",
|
|
289
|
+
isRateLimitFallback: false,
|
|
290
|
+
};
|
|
291
|
+
const second: CredentialSelection = {
|
|
292
|
+
selected: "oauth-bbb",
|
|
293
|
+
index: 0,
|
|
294
|
+
total: 1,
|
|
295
|
+
keySuffix: "h-bbb",
|
|
296
|
+
keyType: "CODEX_OAUTH",
|
|
297
|
+
isRateLimitFallback: false,
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
let primarySelection: CredentialSelection | undefined;
|
|
301
|
+
if (first && second) {
|
|
302
|
+
if (first.isRateLimitFallback && !second.isRateLimitFallback) {
|
|
303
|
+
primarySelection = second;
|
|
304
|
+
} else {
|
|
305
|
+
primarySelection = first;
|
|
306
|
+
}
|
|
307
|
+
} else {
|
|
308
|
+
primarySelection = first ?? second;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
expect(primarySelection).toBe(first);
|
|
312
|
+
expect(primarySelection!.keyType).toBe("OPENAI_API_KEY");
|
|
313
|
+
});
|
|
201
314
|
});
|
|
@@ -577,11 +577,8 @@ describe("Approval Requests", () => {
|
|
|
577
577
|
});
|
|
578
578
|
|
|
579
579
|
expect(result.status).toBe("success");
|
|
580
|
-
// biome-ignore lint/suspicious/noExplicitAny: test assertion on untyped executor result
|
|
581
580
|
expect((result as any).async).toBe(true);
|
|
582
|
-
// biome-ignore lint/suspicious/noExplicitAny: test assertion on untyped executor result
|
|
583
581
|
expect((result as any).waitFor).toBe("approval.resolved");
|
|
584
|
-
// biome-ignore lint/suspicious/noExplicitAny: test assertion on untyped executor result
|
|
585
582
|
expect((result as any).correlationId).toBeTruthy();
|
|
586
583
|
|
|
587
584
|
// Verify the request was created in DB
|
|
@@ -616,9 +613,7 @@ describe("Approval Requests", () => {
|
|
|
616
613
|
});
|
|
617
614
|
|
|
618
615
|
expect(result.status).toBe("success");
|
|
619
|
-
// biome-ignore lint/suspicious/noExplicitAny: test assertion on untyped executor result
|
|
620
616
|
expect((result as any).async).toBe(true);
|
|
621
|
-
// biome-ignore lint/suspicious/noExplicitAny: test assertion on untyped executor result
|
|
622
617
|
expect((result as any).correlationId).toBe(existingId);
|
|
623
618
|
});
|
|
624
619
|
|
|
@@ -650,7 +645,6 @@ describe("Approval Requests", () => {
|
|
|
650
645
|
});
|
|
651
646
|
|
|
652
647
|
expect(result.status).toBe("success");
|
|
653
|
-
// biome-ignore lint/suspicious/noExplicitAny: test assertion on untyped executor result
|
|
654
648
|
expect((result as any).async).toBeUndefined();
|
|
655
649
|
expect(result.output).toBeDefined();
|
|
656
650
|
expect(result.output!.requestId).toBe(existingId);
|
|
@@ -59,7 +59,6 @@ describe("runClaudeManagedSetupFlow — happy path", () => {
|
|
|
59
59
|
const log = mock((_msg: string) => undefined);
|
|
60
60
|
|
|
61
61
|
const result = await runClaudeManagedSetupFlow(baseConfig, {
|
|
62
|
-
// biome-ignore lint/suspicious/noExplicitAny: fake client subset for mock
|
|
63
62
|
client: client as any,
|
|
64
63
|
fetchConfig,
|
|
65
64
|
upsert,
|
|
@@ -132,7 +131,6 @@ describe("runClaudeManagedSetupFlow — happy path", () => {
|
|
|
132
131
|
await runClaudeManagedSetupFlow(
|
|
133
132
|
{ ...baseConfig, mcpBaseUrl: "https://swarm.example.com/" },
|
|
134
133
|
{
|
|
135
|
-
// biome-ignore lint/suspicious/noExplicitAny: fake client subset for mock
|
|
136
134
|
client: client as any,
|
|
137
135
|
fetchConfig: mock(async () => null),
|
|
138
136
|
upsert: mock(async () => undefined),
|
|
@@ -175,7 +173,6 @@ describe("runClaudeManagedSetupFlow — idempotent re-run", () => {
|
|
|
175
173
|
const uploadOne = mock(async () => null);
|
|
176
174
|
|
|
177
175
|
const result = await runClaudeManagedSetupFlow(baseConfig, {
|
|
178
|
-
// biome-ignore lint/suspicious/noExplicitAny: fake client subset for mock
|
|
179
176
|
client: client as any,
|
|
180
177
|
fetchConfig,
|
|
181
178
|
upsert,
|
|
@@ -208,7 +205,6 @@ describe("runClaudeManagedSetupFlow — idempotent re-run", () => {
|
|
|
208
205
|
await runClaudeManagedSetupFlow(
|
|
209
206
|
{ ...baseConfig, force: true },
|
|
210
207
|
{
|
|
211
|
-
// biome-ignore lint/suspicious/noExplicitAny: fake client subset for mock
|
|
212
208
|
client: client as any,
|
|
213
209
|
fetchConfig,
|
|
214
210
|
upsert,
|