@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.
- package/README.md +1 -1
- package/openapi.json +66 -1
- package/package.json +2 -1
- package/src/be/db.ts +49 -7
- package/src/be/migrations/035_api_key_name_provider.sql +22 -0
- package/src/cli.tsx +20 -0
- package/src/commands/codex-login.ts +263 -0
- package/src/commands/runner.ts +95 -3
- package/src/http/api-keys.ts +46 -0
- package/src/http/index.ts +24 -1
- package/src/http/mcp-servers.ts +35 -12
- package/src/http/poll.ts +12 -0
- package/src/http/tasks.ts +27 -0
- package/src/oauth/ensure-token.ts +6 -2
- package/src/oauth/keepalive.ts +76 -0
- package/src/providers/codex-adapter.ts +905 -0
- package/src/providers/codex-agents-md.ts +119 -0
- package/src/providers/codex-models.ts +141 -0
- package/src/providers/codex-oauth/auth-json.ts +58 -0
- package/src/providers/codex-oauth/flow.ts +368 -0
- package/src/providers/codex-oauth/pkce.ts +26 -0
- package/src/providers/codex-oauth/storage.ts +121 -0
- package/src/providers/codex-oauth/types.ts +37 -0
- package/src/providers/codex-skill-resolver.ts +113 -0
- package/src/providers/codex-swarm-events.ts +186 -0
- package/src/providers/index.ts +4 -1
- package/src/slack/handlers.test.ts +50 -0
- package/src/slack/handlers.ts +20 -7
- package/src/telemetry.ts +109 -0
- package/src/tests/codex-adapter.test.ts +961 -0
- package/src/tests/codex-login.test.ts +155 -0
- package/src/tests/codex-oauth-storage.test.ts +306 -0
- package/src/tests/codex-oauth.test.ts +307 -0
- package/src/tests/codex-skill-resolver.test.ts +149 -0
- package/src/tests/codex-swarm-events.test.ts +234 -0
- package/src/tests/ensure-token.test.ts +34 -0
- package/src/tests/error-tracker.test.ts +49 -0
- package/src/tests/mcp-server-resolved-env.test.ts +221 -0
- package/src/tests/provider-adapter.test.ts +1 -1
- package/src/tests/provider-command-format.test.ts +16 -0
- package/src/tests/runner-fallback-output.test.ts +27 -2
- package/src/tests/slack-router-require-mention.test.ts +6 -0
- package/src/tests/workflow-engine-v2.test.ts +98 -2
- package/src/utils/credentials.ts +69 -5
- package/src/utils/error-tracker.ts +6 -1
- package/src/workflows/checkpoint.ts +10 -6
- 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
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
+
}
|
package/src/commands/runner.ts
CHANGED
|
@@ -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, {
|
|
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
|