@desplega.ai/agent-swarm 1.62.0 → 1.63.1

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 (47) hide show
  1. package/README.md +1 -1
  2. package/openapi.json +66 -1
  3. package/package.json +2 -1
  4. package/src/be/db.ts +49 -7
  5. package/src/be/migrations/035_api_key_name_provider.sql +22 -0
  6. package/src/cli.tsx +20 -0
  7. package/src/commands/codex-login.ts +263 -0
  8. package/src/commands/runner.ts +95 -3
  9. package/src/http/api-keys.ts +46 -0
  10. package/src/http/index.ts +24 -1
  11. package/src/http/mcp-servers.ts +35 -12
  12. package/src/http/poll.ts +12 -0
  13. package/src/http/tasks.ts +27 -0
  14. package/src/oauth/ensure-token.ts +6 -2
  15. package/src/oauth/keepalive.ts +76 -0
  16. package/src/providers/codex-adapter.ts +905 -0
  17. package/src/providers/codex-agents-md.ts +119 -0
  18. package/src/providers/codex-models.ts +141 -0
  19. package/src/providers/codex-oauth/auth-json.ts +58 -0
  20. package/src/providers/codex-oauth/flow.ts +368 -0
  21. package/src/providers/codex-oauth/pkce.ts +26 -0
  22. package/src/providers/codex-oauth/storage.ts +121 -0
  23. package/src/providers/codex-oauth/types.ts +37 -0
  24. package/src/providers/codex-skill-resolver.ts +113 -0
  25. package/src/providers/codex-swarm-events.ts +186 -0
  26. package/src/providers/index.ts +4 -1
  27. package/src/slack/handlers.test.ts +50 -0
  28. package/src/slack/handlers.ts +20 -7
  29. package/src/telemetry.ts +109 -0
  30. package/src/tests/codex-adapter.test.ts +961 -0
  31. package/src/tests/codex-login.test.ts +155 -0
  32. package/src/tests/codex-oauth-storage.test.ts +306 -0
  33. package/src/tests/codex-oauth.test.ts +307 -0
  34. package/src/tests/codex-skill-resolver.test.ts +149 -0
  35. package/src/tests/codex-swarm-events.test.ts +234 -0
  36. package/src/tests/ensure-token.test.ts +34 -0
  37. package/src/tests/error-tracker.test.ts +49 -0
  38. package/src/tests/mcp-server-resolved-env.test.ts +221 -0
  39. package/src/tests/provider-adapter.test.ts +1 -1
  40. package/src/tests/provider-command-format.test.ts +16 -0
  41. package/src/tests/runner-fallback-output.test.ts +27 -2
  42. package/src/tests/slack-router-require-mention.test.ts +6 -0
  43. package/src/tests/workflow-engine-v2.test.ts +98 -2
  44. package/src/utils/credentials.ts +69 -5
  45. package/src/utils/error-tracker.ts +6 -1
  46. package/src/workflows/checkpoint.ts +10 -6
  47. package/src/workflows/engine.ts +43 -11
package/README.md CHANGED
@@ -49,7 +49,7 @@ Agent Swarm lets you run a team of AI coding agents that coordinate autonomously
49
49
  - **Templates registry** — Pre-built agent templates (9 official: lead, coder, researcher, reviewer, tester, FDE, content-writer, content-reviewer, content-strategist) with a gallery UI and docker-compose builder
50
50
  - **GitLab integration** — Full GitLab webhook support alongside GitHub via provider adapter pattern
51
51
  - **Working directory support** — Tasks can specify a custom starting directory for agents via the `dir` parameter
52
- - **Multi-provider** — Run agents with Claude Code or pi-mono (`HARNESS_PROVIDER=claude|pi`)
52
+ - **Multi-provider** — Run agents with Claude Code, pi-mono, or OpenAI Codex (`HARNESS_PROVIDER=claude|pi|codex`)
53
53
  - **Agent-fs integration** — Persistent, searchable filesystem shared across the swarm with auto-registration on first boot
54
54
  - **Debug dashboard** — SQL query interface with Monaco editor and AG Grid results for database inspection
55
55
  - **Workflow engine** — DAG-based workflow automation with executor registry, checkpoint durability, webhook/schedule/manual triggers, per-step retry, structured I/O schemas, fan-out/convergence, configurable failure handling, and version history
package/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Swarm API",
5
- "version": "1.62.0",
5
+ "version": "1.63.1",
6
6
  "description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
7
7
  },
8
8
  "servers": [
@@ -1677,6 +1677,71 @@
1677
1677
  }
1678
1678
  }
1679
1679
  },
1680
+ "/api/keys/name": {
1681
+ "patch": {
1682
+ "summary": "Set or clear the human-friendly label on a pooled credential",
1683
+ "tags": [
1684
+ "API Keys"
1685
+ ],
1686
+ "security": [
1687
+ {
1688
+ "bearerAuth": []
1689
+ }
1690
+ ],
1691
+ "requestBody": {
1692
+ "content": {
1693
+ "application/json": {
1694
+ "schema": {
1695
+ "type": "object",
1696
+ "properties": {
1697
+ "keyType": {
1698
+ "type": "string",
1699
+ "minLength": 1
1700
+ },
1701
+ "keySuffix": {
1702
+ "type": "string",
1703
+ "minLength": 1,
1704
+ "maxLength": 10
1705
+ },
1706
+ "name": {
1707
+ "type": [
1708
+ "string",
1709
+ "null"
1710
+ ],
1711
+ "maxLength": 60
1712
+ },
1713
+ "scope": {
1714
+ "type": "string"
1715
+ },
1716
+ "scopeId": {
1717
+ "type": "string"
1718
+ }
1719
+ },
1720
+ "required": [
1721
+ "keyType",
1722
+ "keySuffix",
1723
+ "name"
1724
+ ]
1725
+ }
1726
+ }
1727
+ }
1728
+ },
1729
+ "responses": {
1730
+ "200": {
1731
+ "description": "Name updated"
1732
+ },
1733
+ "400": {
1734
+ "description": "Validation error"
1735
+ },
1736
+ "401": {
1737
+ "description": "Unauthorized"
1738
+ },
1739
+ "404": {
1740
+ "description": "Key not found"
1741
+ }
1742
+ }
1743
+ }
1744
+ },
1680
1745
  "/api/events": {
1681
1746
  "post": {
1682
1747
  "summary": "Store a single event",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.62.0",
3
+ "version": "1.63.1",
4
4
  "description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
5
5
  "license": "MIT",
6
6
  "author": "desplega.sh <contact@desplega.sh>",
@@ -105,6 +105,7 @@
105
105
  "@mariozechner/pi-ai": "^0.57.1",
106
106
  "@mariozechner/pi-coding-agent": "^0.57.1",
107
107
  "@modelcontextprotocol/sdk": "^1.25.1",
108
+ "@openai/codex-sdk": "^0.118.0",
108
109
  "@openfort/openfort-node": "^0.9.1",
109
110
  "@slack/bolt": "^4.6.0",
110
111
  "@types/react": "^19.2.7",
package/src/be/db.ts CHANGED
@@ -58,6 +58,7 @@ import type {
58
58
  WorkflowSnapshot,
59
59
  WorkflowVersion,
60
60
  } from "../types";
61
+ import { deriveProviderFromKeyType } from "../utils/credentials";
61
62
  import { normalizeDate, normalizeDateRequired } from "./date-utils";
62
63
  import { runMigrations } from "./migrations/runner";
63
64
  import { seedDefaultTemplates } from "./seed";
@@ -7576,6 +7577,10 @@ export interface ApiKeyStatus {
7576
7577
  lastRateLimitAt: string | null;
7577
7578
  totalUsageCount: number;
7578
7579
  rateLimitCount: number;
7580
+ /** Optional human-friendly label set from the dashboard. */
7581
+ name: string | null;
7582
+ /** Auto-derived harness provider (claude/pi/codex) — see deriveProviderFromKeyType. */
7583
+ provider: string;
7579
7584
  createdAt: string;
7580
7585
  updatedAt: string;
7581
7586
  }
@@ -7634,17 +7639,21 @@ export function recordKeyUsage(
7634
7639
  const db = getDb();
7635
7640
  const effectiveScopeId = scopeId ?? "";
7636
7641
 
7637
- // Upsert key status record
7642
+ // Upsert key status record. Sets `provider` on insert (auto-derived from
7643
+ // keyType — see deriveProviderFromKeyType in src/utils/credentials.ts).
7644
+ // The `name` column is left null on insert and only set via the
7645
+ // setApiKeyName API endpoint when the user manually labels the key.
7646
+ const provider = deriveProviderFromKeyType(keyType);
7638
7647
  db.prepare(
7639
- `INSERT INTO api_key_status (keyType, keySuffix, keyIndex, scope, scopeId, lastUsedAt, totalUsageCount, updatedAt)
7640
- VALUES (?, ?, ?, ?, ?, ?, 1, ?)
7648
+ `INSERT INTO api_key_status (keyType, keySuffix, keyIndex, scope, scopeId, lastUsedAt, totalUsageCount, provider, updatedAt)
7649
+ VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)
7641
7650
  ON CONFLICT(keyType, keySuffix, scope, scopeId)
7642
7651
  DO UPDATE SET
7643
7652
  lastUsedAt = excluded.lastUsedAt,
7644
7653
  totalUsageCount = totalUsageCount + 1,
7645
7654
  keyIndex = excluded.keyIndex,
7646
7655
  updatedAt = excluded.updatedAt`,
7647
- ).run(keyType, keySuffix, keyIndex, scope, effectiveScopeId, now, now);
7656
+ ).run(keyType, keySuffix, keyIndex, scope, effectiveScopeId, now, provider, now);
7648
7657
 
7649
7658
  // Record which key was used on the task
7650
7659
  if (taskId) {
@@ -7667,10 +7676,11 @@ export function markKeyRateLimited(
7667
7676
  ): void {
7668
7677
  const now = new Date().toISOString();
7669
7678
  const effectiveScopeId = scopeId ?? "";
7679
+ const provider = deriveProviderFromKeyType(keyType);
7670
7680
  getDb()
7671
7681
  .prepare(
7672
- `INSERT INTO api_key_status (keyType, keySuffix, keyIndex, scope, scopeId, status, rateLimitedUntil, lastRateLimitAt, rateLimitCount, updatedAt)
7673
- VALUES (?, ?, ?, ?, ?, 'rate_limited', ?, ?, 1, ?)
7682
+ `INSERT INTO api_key_status (keyType, keySuffix, keyIndex, scope, scopeId, status, rateLimitedUntil, lastRateLimitAt, rateLimitCount, provider, updatedAt)
7683
+ VALUES (?, ?, ?, ?, ?, 'rate_limited', ?, ?, 1, ?, ?)
7674
7684
  ON CONFLICT(keyType, keySuffix, scope, scopeId)
7675
7685
  DO UPDATE SET
7676
7686
  status = 'rate_limited',
@@ -7680,7 +7690,39 @@ export function markKeyRateLimited(
7680
7690
  keyIndex = excluded.keyIndex,
7681
7691
  updatedAt = excluded.updatedAt`,
7682
7692
  )
7683
- .run(keyType, keySuffix, keyIndex, scope, effectiveScopeId, rateLimitedUntil, now, now);
7693
+ .run(
7694
+ keyType,
7695
+ keySuffix,
7696
+ keyIndex,
7697
+ scope,
7698
+ effectiveScopeId,
7699
+ rateLimitedUntil,
7700
+ now,
7701
+ provider,
7702
+ now,
7703
+ );
7704
+ }
7705
+
7706
+ /**
7707
+ * Set or clear the human-friendly `name` label on a pooled credential.
7708
+ * Identified by the natural key (keyType + keySuffix + scope + scopeId).
7709
+ * Returns true if a row was updated, false if no matching key exists.
7710
+ */
7711
+ export function setApiKeyName(
7712
+ keyType: string,
7713
+ keySuffix: string,
7714
+ name: string | null,
7715
+ scope = "global",
7716
+ scopeId: string | null = null,
7717
+ ): boolean {
7718
+ const result = getDb()
7719
+ .prepare(
7720
+ `UPDATE api_key_status
7721
+ SET name = ?, updatedAt = ?
7722
+ WHERE keyType = ? AND keySuffix = ? AND scope = ? AND scopeId = ?`,
7723
+ )
7724
+ .run(name, new Date().toISOString(), keyType, keySuffix, scope, scopeId ?? "");
7725
+ return result.changes > 0;
7684
7726
  }
7685
7727
 
7686
7728
  /**
@@ -0,0 +1,22 @@
1
+ -- Add user-facing `name` (manually settable from the dashboard) and
2
+ -- automatic `provider` (claude/pi/codex, derived from keyType) columns to
3
+ -- api_key_status. This lets users label their pooled credentials and lets
4
+ -- the dashboard / runner group keys by harness without re-deriving the
5
+ -- mapping at every read site.
6
+
7
+ ALTER TABLE api_key_status ADD COLUMN name TEXT;
8
+ ALTER TABLE api_key_status ADD COLUMN provider TEXT NOT NULL DEFAULT 'claude';
9
+
10
+ -- Backfill provider for existing rows based on the keyType, mirroring the
11
+ -- PROVIDER_CREDENTIAL_VARS mapping in src/utils/credentials.ts. ANTHROPIC_API_KEY
12
+ -- is shared between claude and pi-mono — we default it to claude (the primary
13
+ -- consumer) since the runner sets the provider on every subsequent usage.
14
+ UPDATE api_key_status SET provider = 'claude'
15
+ WHERE keyType IN ('CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY');
16
+ UPDATE api_key_status SET provider = 'pi'
17
+ WHERE keyType = 'OPENROUTER_API_KEY';
18
+ UPDATE api_key_status SET provider = 'codex'
19
+ WHERE keyType = 'OPENAI_API_KEY';
20
+
21
+ CREATE INDEX IF NOT EXISTS idx_api_key_status_provider
22
+ ON api_key_status(provider);
package/src/cli.tsx CHANGED
@@ -255,6 +255,21 @@ const COMMAND_HELP: Record<
255
255
  options: " -h, --help Show this help",
256
256
  examples: [` ${binName} artifact serve`, ` ${binName} artifact help`].join("\n"),
257
257
  },
258
+ "codex-login": {
259
+ usage: `${binName} codex-login [options]`,
260
+ description:
261
+ "Authenticate Codex via ChatGPT OAuth (browser or manual paste).\nPrompts interactively for the target API URL and a best-effort masked API key, then stores credentials in the swarm API config store for deployed workers.",
262
+ options: [
263
+ " --api-url <url> Swarm API URL (default: MCP_BASE_URL or http://localhost:3013)",
264
+ " --api-key <key> Swarm API key (default: API_KEY or 123123)",
265
+ " -h, --help Show this help",
266
+ ].join("\n"),
267
+ examples: [
268
+ ` ${binName} codex-login`,
269
+ ` ${binName} codex-login --api-url https://swarm.example.com`,
270
+ ` ${binName} codex-login --api-url https://swarm.example.com --api-key <api-key>`,
271
+ ].join("\n"),
272
+ },
258
273
  };
259
274
 
260
275
  function printHelp(command?: string) {
@@ -283,6 +298,7 @@ function printHelp(command?: string) {
283
298
  ["hook", "Handle Claude Code hook events (stdin)"],
284
299
  ["artifact", "Manage agent artifacts"],
285
300
  ["docs", "Open documentation (--open to launch in browser)"],
301
+ ["codex-login", "Authenticate Codex via ChatGPT OAuth"],
286
302
  ["version", "Show version number"],
287
303
  ["help", "Show this help message"],
288
304
  ];
@@ -535,6 +551,10 @@ if (args.showHelp || args.command === "help" || args.command === undefined) {
535
551
  port: args.port,
536
552
  key: args.key,
537
553
  });
554
+ } else if (args.command === "codex-login") {
555
+ const { runCodexLogin } = await import("./commands/codex-login");
556
+ const codexLoginArgs = process.argv.slice(process.argv.indexOf("codex-login") + 1);
557
+ await runCodexLogin(codexLoginArgs);
538
558
  } else {
539
559
  render(<App args={args} />);
540
560
  }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * `agent-swarm codex-login` — authenticate Codex via ChatGPT OAuth.
3
+ *
4
+ * Runs the OAuth PKCE flow (browser redirect to localhost:1455, manual paste
5
+ * fallback), extracts chatgpt_account_id from the JWT, and stores the
6
+ * credentials in the swarm API config store at global scope.
7
+ *
8
+ * This is a non-UI command (plain stdout, no Ink) — it exits immediately
9
+ * after completing or failing the OAuth flow.
10
+ */
11
+
12
+ import { exec } from "node:child_process";
13
+ import { emitKeypressEvents } from "node:readline";
14
+
15
+ import { loginCodexOAuth } from "../providers/codex-oauth/flow.js";
16
+ import { storeCodexOAuth } from "../providers/codex-oauth/storage.js";
17
+
18
+ type PromptTextFn = (label: string, defaultValue: string) => Promise<string>;
19
+ type PromptSecretFn = (label: string, defaultValue: string, helpText?: string) => Promise<string>;
20
+
21
+ type ResolveCodexLoginConfigDeps = {
22
+ env?: Record<string, string | undefined>;
23
+ isInteractive?: boolean;
24
+ promptText?: PromptTextFn;
25
+ promptSecret?: PromptSecretFn;
26
+ };
27
+
28
+ type RunCodexLoginDeps = {
29
+ resolveConfig?: typeof resolveCodexLoginConfig;
30
+ login?: typeof loginCodexOAuth;
31
+ store?: typeof storeCodexOAuth;
32
+ log?: (message: string) => void;
33
+ error?: (message: string) => void;
34
+ exit?: (code: number) => void;
35
+ };
36
+
37
+ type ParsedCodexLoginArgs = {
38
+ apiUrl?: string;
39
+ apiKey?: string;
40
+ showHelp: boolean;
41
+ };
42
+
43
+ function parseCodexLoginArgs(args: string[]): ParsedCodexLoginArgs {
44
+ const parsed: ParsedCodexLoginArgs = { showHelp: false };
45
+
46
+ for (let i = 0; i < args.length; i++) {
47
+ const arg = args[i];
48
+ if (arg === "--api-url" && args[i + 1]) {
49
+ parsed.apiUrl = args[++i]!;
50
+ } else if (arg === "--api-key" && args[i + 1]) {
51
+ parsed.apiKey = args[++i]!;
52
+ } else if (arg === "--help" || arg === "-h") {
53
+ parsed.showHelp = true;
54
+ }
55
+ }
56
+
57
+ return parsed;
58
+ }
59
+
60
+ async function promptTextInput(label: string, defaultValue: string): Promise<string> {
61
+ const { createInterface } = await import("node:readline");
62
+ const rl = createInterface({
63
+ input: process.stdin,
64
+ output: process.stdout,
65
+ });
66
+
67
+ return new Promise<string>((resolve) => {
68
+ const suffix = defaultValue ? ` [${defaultValue}]` : "";
69
+ rl.question(`${label}${suffix}: `, (answer) => {
70
+ rl.close();
71
+ resolve(answer.trim());
72
+ });
73
+ });
74
+ }
75
+
76
+ async function promptHiddenInput(
77
+ label: string,
78
+ _defaultValue: string,
79
+ helpText?: string,
80
+ ): Promise<string> {
81
+ const stdin = process.stdin;
82
+ const stdout = process.stdout;
83
+
84
+ if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function") {
85
+ return promptTextInput(label, "");
86
+ }
87
+
88
+ if (helpText) {
89
+ stdout.write(`${helpText}\n`);
90
+ }
91
+ stdout.write(`${label}: `);
92
+
93
+ emitKeypressEvents(stdin);
94
+ const wasRaw = stdin.isRaw;
95
+ stdin.setRawMode(true);
96
+ stdin.resume();
97
+
98
+ return new Promise<string>((resolve, reject) => {
99
+ let value = "";
100
+
101
+ const cleanup = () => {
102
+ stdin.setRawMode(Boolean(wasRaw));
103
+ stdin.pause();
104
+ stdin.removeListener("keypress", onKeypress);
105
+ stdout.write("\n");
106
+ };
107
+
108
+ const onKeypress = (str: string, key: { name?: string; ctrl?: boolean; meta?: boolean }) => {
109
+ if (key.ctrl && key.name === "c") {
110
+ cleanup();
111
+ reject(new Error("Aborted"));
112
+ return;
113
+ }
114
+
115
+ if (key.name === "return" || key.name === "enter") {
116
+ cleanup();
117
+ resolve(value.trim());
118
+ return;
119
+ }
120
+
121
+ if (key.name === "backspace") {
122
+ if (value.length > 0) {
123
+ value = value.slice(0, -1);
124
+ stdout.write("\b \b");
125
+ }
126
+ return;
127
+ }
128
+
129
+ if (!key.ctrl && !key.meta && str) {
130
+ value += str;
131
+ stdout.write("*");
132
+ }
133
+ };
134
+
135
+ stdin.on("keypress", onKeypress);
136
+ });
137
+ }
138
+
139
+ export async function resolveCodexLoginConfig(
140
+ args: string[],
141
+ deps: ResolveCodexLoginConfigDeps = {},
142
+ ): Promise<{ apiUrl: string; apiKey: string }> {
143
+ const env = deps.env ?? process.env;
144
+ const parsed = parseCodexLoginArgs(args);
145
+ const promptText = deps.promptText ?? promptTextInput;
146
+ const promptSecret = deps.promptSecret ?? promptHiddenInput;
147
+ const isInteractive = deps.isInteractive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
148
+ const defaultApiUrl = env.MCP_BASE_URL || "http://localhost:3013";
149
+ const defaultApiKey = env.API_KEY || "123123";
150
+
151
+ let apiUrl = parsed.apiUrl ?? defaultApiUrl;
152
+ let apiKey = parsed.apiKey ?? defaultApiKey;
153
+
154
+ if (!parsed.apiUrl && isInteractive) {
155
+ apiUrl = (await promptText("Swarm API URL", defaultApiUrl)).trim() || defaultApiUrl;
156
+ }
157
+
158
+ if (!parsed.apiKey && isInteractive) {
159
+ const apiKeyHelp = env.API_KEY
160
+ ? "Press Enter to use API_KEY from the environment"
161
+ : "Press Enter to use the default local API key";
162
+ apiKey =
163
+ (await promptSecret("Swarm API key", defaultApiKey, apiKeyHelp)).trim() || defaultApiKey;
164
+ }
165
+
166
+ return { apiUrl, apiKey };
167
+ }
168
+
169
+ function printHelp() {
170
+ console.log(`
171
+ agent-swarm codex-login — Authenticate Codex via ChatGPT OAuth
172
+
173
+ Usage:
174
+ agent-swarm codex-login [options]
175
+
176
+ Options:
177
+ --api-url <url> Swarm API URL (default: MCP_BASE_URL or http://localhost:3013)
178
+ --api-key <key> Swarm API key (default: API_KEY or 123123)
179
+ -h, --help Show this help
180
+
181
+ Without flags, the command prompts interactively for the target API URL and
182
+ for the swarm API key using masked input when the terminal supports it.
183
+
184
+ This command runs the OpenAI Codex OAuth PKCE flow:
185
+ 1. Opens a browser to ChatGPT login
186
+ 2. Receives the authorization code via localhost:1455 callback
187
+ 3. Exchanges the code for access/refresh tokens
188
+ 4. Stores credentials in the swarm API config store
189
+
190
+ Deployed Codex workers automatically restore these credentials at boot.
191
+ `);
192
+ }
193
+
194
+ export async function runCodexLogin(args: string[], deps: RunCodexLoginDeps = {}): Promise<void> {
195
+ const resolveConfig = deps.resolveConfig ?? resolveCodexLoginConfig;
196
+ const login = deps.login ?? loginCodexOAuth;
197
+ const store = deps.store ?? storeCodexOAuth;
198
+ const log = deps.log ?? console.log;
199
+ const error = deps.error ?? console.error;
200
+ const exit = deps.exit ?? ((code: number) => process.exit(code));
201
+
202
+ if (parseCodexLoginArgs(args).showHelp) {
203
+ printHelp();
204
+ return;
205
+ }
206
+
207
+ let browserOpened = false;
208
+
209
+ try {
210
+ const { apiUrl, apiKey } = await resolveConfig(args);
211
+
212
+ log("Starting Codex ChatGPT OAuth login...\n");
213
+ log(`Target swarm API: ${apiUrl}\n`);
214
+
215
+ const creds = await login({
216
+ onAuth: ({ url, instructions }) => {
217
+ log(`Open this URL in your browser:\n\n ${url}\n`);
218
+ if (instructions) {
219
+ log(instructions);
220
+ }
221
+ // Try to open the browser (fire-and-forget, non-fatal)
222
+ if (!browserOpened) {
223
+ browserOpened = true;
224
+ const cmd =
225
+ process.platform === "darwin"
226
+ ? "open"
227
+ : process.platform === "win32"
228
+ ? "start"
229
+ : "xdg-open";
230
+ exec(`${cmd} "${url}"`, (err) => {
231
+ if (err) {
232
+ log("(Could not open browser automatically)\n");
233
+ }
234
+ });
235
+ }
236
+ },
237
+ onPrompt: async ({ message }) => {
238
+ return promptTextInput(message, "");
239
+ },
240
+ onProgress: (message) => {
241
+ log(message);
242
+ },
243
+ onManualCodeInput: async () => {
244
+ return promptTextInput("Or paste the authorization code here", "");
245
+ },
246
+ });
247
+
248
+ log("\nOAuth flow completed successfully!");
249
+ log(` Account ID: ${creds.accountId}`);
250
+ log(` Expires: ${new Date(creds.expires).toISOString()}`);
251
+
252
+ // Store credentials in the swarm API config store
253
+ log("\nStoring credentials in swarm API config store...");
254
+ await store(apiUrl, apiKey, creds);
255
+ log("Credentials stored successfully!");
256
+
257
+ log("\nDeployed Codex workers will automatically restore these credentials at boot.");
258
+ } catch (err) {
259
+ const message = err instanceof Error ? err.message : String(err);
260
+ error(`\nError: ${message}`);
261
+ exit(1);
262
+ }
263
+ }
@@ -10,6 +10,7 @@ import {
10
10
  generateDefaultToolsMd,
11
11
  } from "../prompts/defaults.ts";
12
12
  import { configureHttpResolver, resolveTemplateAsync } from "../prompts/resolver.ts";
13
+ import { authJsonToCredentialSelection } from "../providers/codex-oauth/auth-json.js";
13
14
  import {
14
15
  type CostData,
15
16
  createProviderAdapter,
@@ -17,6 +18,7 @@ import {
17
18
  type ProviderSession,
18
19
  type ProviderSessionConfig,
19
20
  } from "../providers/index.ts";
21
+ import { initTelemetry, telemetry } from "../telemetry.ts";
20
22
  import type { RepoGuidelines } from "../types.ts";
21
23
  import { getContextWindowSize } from "../utils/context-window.ts";
22
24
  import { type CredentialSelection, resolveCredentialPools } from "../utils/credentials.ts";
@@ -232,7 +234,15 @@ async function fetchResolvedEnv(
232
234
  }
233
235
  }
234
236
 
235
- const credentialSelections = await resolveCredentialPools(env, { apiUrl, apiKey });
237
+ const credentialSelections = await resolveCredentialPools(env, {
238
+ apiUrl,
239
+ apiKey,
240
+ // Provider-aware selection: codex tasks should not get a
241
+ // CLAUDE_CODE_OAUTH_TOKEN stamped on their task record (and vice
242
+ // versa) just because both env vars happen to be set in the worker
243
+ // container. See `PROVIDER_CREDENTIAL_VARS` in src/utils/credentials.ts.
244
+ provider: process.env.HARNESS_PROVIDER,
245
+ });
236
246
 
237
247
  return { env, credentialSelections };
238
248
  }
@@ -626,6 +636,33 @@ async function reportKeyUsage(
626
636
  }
627
637
  }
628
638
 
639
+ async function resolveCodexOAuthCredentialInfo(): Promise<CredentialSelection | null> {
640
+ try {
641
+ const home = process.env.HOME;
642
+ if (!home) return null;
643
+
644
+ const authFile = Bun.file(`${home}/.codex/auth.json`);
645
+ if (!(await authFile.exists())) {
646
+ return null;
647
+ }
648
+
649
+ const auth = JSON.parse(await authFile.text()) as {
650
+ auth_mode?: string;
651
+ tokens?: { account_id?: string };
652
+ };
653
+
654
+ if (auth.auth_mode !== "chatgpt" || !auth.tokens?.account_id) {
655
+ return null;
656
+ }
657
+
658
+ return authJsonToCredentialSelection(
659
+ auth as Parameters<typeof authJsonToCredentialSelection>[0],
660
+ );
661
+ } catch {
662
+ return null;
663
+ }
664
+ }
665
+
629
666
  /** Report a rate-limited key to the API (fire-and-forget) */
630
667
  async function reportKeyRateLimit(
631
668
  apiUrl: string,
@@ -840,6 +877,7 @@ function setupShutdownHandlers(
840
877
  }
841
878
 
842
879
  if (apiConfig) {
880
+ telemetry.session("ended", { agentId: apiConfig.agentId });
843
881
  await closeAgent(apiConfig, role);
844
882
  }
845
883
  await savePm2State(role);
@@ -1559,6 +1597,20 @@ async function spawnProviderProcess(
1559
1597
 
1560
1598
  const session = await adapter.createSession(config);
1561
1599
 
1600
+ let oauthSelection: CredentialSelection | undefined;
1601
+ if (adapter.name === "codex" && credentialSelections.length === 0) {
1602
+ oauthSelection = (await resolveCodexOAuthCredentialInfo()) ?? undefined;
1603
+ if (oauthSelection && realTaskId) {
1604
+ reportKeyUsage(
1605
+ opts.apiUrl,
1606
+ opts.apiKey,
1607
+ oauthSelection.keyType,
1608
+ oauthSelection,
1609
+ realTaskId,
1610
+ ).catch(() => {});
1611
+ }
1612
+ }
1613
+
1562
1614
  // Set up log streaming
1563
1615
  const logBuffer: LogBuffer = { lines: [], lastFlush: Date.now(), partialLine: "" };
1564
1616
  const shouldStream = opts.apiUrl && opts.runnerSessionId && opts.iteration;
@@ -1866,7 +1918,7 @@ async function spawnProviderProcess(
1866
1918
  });
1867
1919
 
1868
1920
  // Build credential info for rate limit tracking
1869
- const primarySelection = credentialSelections[0];
1921
+ const primarySelection = credentialSelections[0] ?? oauthSelection;
1870
1922
  const credentialInfo = primarySelection
1871
1923
  ? {
1872
1924
  keyType: primarySelection.keyType,
@@ -2000,7 +2052,7 @@ async function checkCompletedProcesses(
2000
2052
  console.log(`[${role}] Detected error for task ${taskId.slice(0, 8)}: ${failureReason}`);
2001
2053
 
2002
2054
  // If rate-limited and we know which key was used, report it
2003
- if (credentialInfo && /rate.?limit/i.test(failureReason)) {
2055
+ if (credentialInfo && /rate.?limit|hit your limit/i.test(failureReason)) {
2004
2056
  // Try to extract reset time from the error message (e.g. "resets 3pm (UTC)")
2005
2057
  const parsedResetTime = parseRateLimitResetTime(failureReason);
2006
2058
  const defaultCooldownMs = 5 * 60 * 1000;
@@ -2154,6 +2206,46 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
2154
2206
  configureHttpResolver(apiUrl, process.env.API_KEY);
2155
2207
  }
2156
2208
 
2209
+ // Initialize anonymized telemetry (opt-out via ANONYMIZED_TELEMETRY=false)
2210
+ // Workers use HTTP-based config access (cannot import DB directly)
2211
+ {
2212
+ const telemetryApiKey = process.env.API_KEY;
2213
+ await initTelemetry(
2214
+ "worker",
2215
+ async (key) => {
2216
+ if (!telemetryApiKey) return undefined;
2217
+ try {
2218
+ const resp = await fetch(`${apiUrl}/api/config?scope=global&includeSecrets=true`, {
2219
+ headers: { Authorization: `Bearer ${telemetryApiKey}` },
2220
+ signal: AbortSignal.timeout(5_000),
2221
+ });
2222
+ if (!resp.ok) return undefined;
2223
+ const data = (await resp.json()) as { configs: { key: string; value: string }[] };
2224
+ return data.configs.find((c) => c.key === key)?.value;
2225
+ } catch {
2226
+ return undefined;
2227
+ }
2228
+ },
2229
+ async (key, value) => {
2230
+ if (!telemetryApiKey) return;
2231
+ try {
2232
+ await fetch(`${apiUrl}/api/config`, {
2233
+ method: "PUT",
2234
+ headers: {
2235
+ Authorization: `Bearer ${telemetryApiKey}`,
2236
+ "Content-Type": "application/json",
2237
+ },
2238
+ body: JSON.stringify({ scope: "global", key, value }),
2239
+ signal: AbortSignal.timeout(5_000),
2240
+ });
2241
+ } catch {
2242
+ // Silently ignore — telemetry is best-effort
2243
+ }
2244
+ },
2245
+ );
2246
+ }
2247
+ telemetry.session("started", { agentId });
2248
+
2157
2249
  let capabilities = config.capabilities;
2158
2250
 
2159
2251
  // Agent identity fields — populated after registration by fetching full profile