@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.
Files changed (76) hide show
  1. package/openapi.json +63 -3
  2. package/package.json +5 -5
  3. package/src/be/db.ts +91 -6
  4. package/src/be/memory/boot-reembed.ts +0 -1
  5. package/src/be/memory/providers/sqlite-store.ts +42 -25
  6. package/src/be/memory/raters/llm-client.ts +12 -5
  7. package/src/be/memory/types.ts +3 -0
  8. package/src/be/migrations/088_script_runs_list_indexes.sql +10 -0
  9. package/src/be/migrations/089_harness_variant.sql +2 -0
  10. package/src/be/modelsdev-cache.json +1222 -986
  11. package/src/be/seed-pricing.ts +1 -0
  12. package/src/be/seed-scripts/catalog/boot-triage.inline.ts +221 -0
  13. package/src/be/seed-scripts/catalog/catalog-report.inline.ts +457 -0
  14. package/src/be/seed-scripts/catalog/compound-insights.inline.ts +863 -0
  15. package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +506 -0
  16. package/src/be/seed-scripts/index.ts +5 -5
  17. package/src/be/skill-sync.ts +28 -179
  18. package/src/commands/runner.ts +124 -7
  19. package/src/http/api-keys.ts +42 -0
  20. package/src/http/mcp-bridge.ts +1 -1
  21. package/src/http/memory.ts +23 -24
  22. package/src/http/tasks.ts +10 -6
  23. package/src/providers/claude-adapter.ts +33 -1
  24. package/src/providers/claude-managed-adapter.ts +3 -0
  25. package/src/providers/claude-managed-models.ts +7 -0
  26. package/src/providers/codex-adapter.ts +8 -1
  27. package/src/providers/codex-models.ts +1 -0
  28. package/src/providers/codex-oauth/auth-json.ts +1 -0
  29. package/src/providers/harness-version.ts +7 -0
  30. package/src/providers/opencode-adapter.ts +11 -4
  31. package/src/providers/pi-mono-adapter.ts +12 -2
  32. package/src/providers/types.ts +2 -0
  33. package/src/scripts-runtime/egress-secrets.ts +83 -0
  34. package/src/scripts-runtime/eval-harness.ts +4 -0
  35. package/src/scripts-runtime/executors/types.ts +7 -0
  36. package/src/scripts-runtime/loader.ts +2 -0
  37. package/src/server-user.ts +2 -2
  38. package/src/slack/channel-join.ts +41 -0
  39. package/src/tests/additive-buffer.test.ts +0 -1
  40. package/src/tests/api-key-tracking.test.ts +113 -0
  41. package/src/tests/approval-requests.test.ts +0 -6
  42. package/src/tests/claude-managed-setup.test.ts +0 -4
  43. package/src/tests/codex-pool.test.ts +2 -6
  44. package/src/tests/http-api-integration.test.ts +4 -6
  45. package/src/tests/memory-edges.test.ts +0 -2
  46. package/src/tests/memory-rate-endpoint.test.ts +0 -2
  47. package/src/tests/memory-rater-e2e.test.ts +0 -2
  48. package/src/tests/memory-store.test.ts +19 -1
  49. package/src/tests/memory.test.ts +51 -0
  50. package/src/tests/model-control.test.ts +1 -1
  51. package/src/tests/reload-config.test.ts +33 -17
  52. package/src/tests/runner-skills-refresh.test.ts +216 -46
  53. package/src/tests/script-runs-http.test.ts +7 -1
  54. package/src/tests/scripts-runtime-secret-egress.test.ts +129 -0
  55. package/src/tests/seed-scripts.test.ts +13 -1
  56. package/src/tests/session-attach.test.ts +6 -6
  57. package/src/tests/skill-fs-writer.test.ts +250 -0
  58. package/src/tests/slack-attachments-block.test.ts +0 -1
  59. package/src/tests/slack-blocks.test.ts +0 -1
  60. package/src/tests/slack-channel-join.test.ts +80 -0
  61. package/src/tests/slack-identity-resolution.test.ts +0 -1
  62. package/src/tests/structured-output.test.ts +0 -2
  63. package/src/tests/use-dismissible-card.test.ts +0 -4
  64. package/src/tools/schedules/create-schedule.ts +2 -2
  65. package/src/tools/schedules/update-schedule.ts +1 -1
  66. package/src/tools/send-task.ts +2 -2
  67. package/src/tools/slack-post.ts +18 -15
  68. package/src/tools/slack-read.ts +9 -11
  69. package/src/tools/slack-reply.ts +18 -15
  70. package/src/tools/slack-start-thread.ts +17 -14
  71. package/src/tools/task-action.ts +2 -2
  72. package/src/types.ts +11 -0
  73. package/src/utils/context-window.ts +3 -0
  74. package/src/utils/credentials.ts +22 -2
  75. package/src/utils/skill-fs-writer.ts +220 -0
  76. 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({ type: "session_init", sessionId: json.session_id, provider: "claude" });
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
- this.emit({ type: "session_init", sessionId: event.thread_id, provider: "codex" });
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",
@@ -48,6 +48,7 @@ export function authJsonToCredentialSelection(auth: CodexAuthJson, slot = 0, tot
48
48
  total,
49
49
  keySuffix: suffixSource.slice(-5),
50
50
  keyType: "CODEX_OAUTH",
51
+ isRateLimitFallback: false,
51
52
  };
52
53
  }
53
54
 
@@ -0,0 +1,7 @@
1
+ export function readPkgVersion(packageName: string): string | undefined {
2
+ try {
3
+ return require(`${packageName}/package.json`).version;
4
+ } catch {
5
+ return undefined;
6
+ }
7
+ }
@@ -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 /claude-session,
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({ type: "session_init", sessionId: this._sessionId, provider });
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
- session.emitSessionInit("opencode");
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 = lower === "opus" || lower === "sonnet" || lower === "haiku";
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
- this.emit({ type: "session_init", sessionId: this._sessionId, provider: "pi" });
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));
@@ -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
 
@@ -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 'opus')."),
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,