@desplega.ai/agent-swarm 1.63.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/openapi.json +1 -1
- package/package.json +1 -1
- package/src/cli.tsx +20 -0
- package/src/commands/codex-login.ts +263 -0
- package/src/commands/runner.ts +86 -2
- package/src/http/index.ts +12 -1
- package/src/http/poll.ts +12 -0
- package/src/http/tasks.ts +27 -0
- package/src/providers/codex-adapter.ts +42 -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/telemetry.ts +109 -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/error-tracker.test.ts +49 -0
- package/src/tests/workflow-engine-v2.test.ts +98 -2
- package/src/utils/credentials.ts +3 -1
- package/src/utils/error-tracker.ts +6 -1
- package/src/workflows/checkpoint.ts +10 -6
- package/src/workflows/engine.ts +43 -11
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.63.
|
|
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": [
|
package/package.json
CHANGED
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";
|
|
@@ -634,6 +636,33 @@ async function reportKeyUsage(
|
|
|
634
636
|
}
|
|
635
637
|
}
|
|
636
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
|
+
|
|
637
666
|
/** Report a rate-limited key to the API (fire-and-forget) */
|
|
638
667
|
async function reportKeyRateLimit(
|
|
639
668
|
apiUrl: string,
|
|
@@ -848,6 +877,7 @@ function setupShutdownHandlers(
|
|
|
848
877
|
}
|
|
849
878
|
|
|
850
879
|
if (apiConfig) {
|
|
880
|
+
telemetry.session("ended", { agentId: apiConfig.agentId });
|
|
851
881
|
await closeAgent(apiConfig, role);
|
|
852
882
|
}
|
|
853
883
|
await savePm2State(role);
|
|
@@ -1567,6 +1597,20 @@ async function spawnProviderProcess(
|
|
|
1567
1597
|
|
|
1568
1598
|
const session = await adapter.createSession(config);
|
|
1569
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
|
+
|
|
1570
1614
|
// Set up log streaming
|
|
1571
1615
|
const logBuffer: LogBuffer = { lines: [], lastFlush: Date.now(), partialLine: "" };
|
|
1572
1616
|
const shouldStream = opts.apiUrl && opts.runnerSessionId && opts.iteration;
|
|
@@ -1874,7 +1918,7 @@ async function spawnProviderProcess(
|
|
|
1874
1918
|
});
|
|
1875
1919
|
|
|
1876
1920
|
// Build credential info for rate limit tracking
|
|
1877
|
-
const primarySelection = credentialSelections[0];
|
|
1921
|
+
const primarySelection = credentialSelections[0] ?? oauthSelection;
|
|
1878
1922
|
const credentialInfo = primarySelection
|
|
1879
1923
|
? {
|
|
1880
1924
|
keyType: primarySelection.keyType,
|
|
@@ -2008,7 +2052,7 @@ async function checkCompletedProcesses(
|
|
|
2008
2052
|
console.log(`[${role}] Detected error for task ${taskId.slice(0, 8)}: ${failureReason}`);
|
|
2009
2053
|
|
|
2010
2054
|
// If rate-limited and we know which key was used, report it
|
|
2011
|
-
if (credentialInfo && /rate.?limit/i.test(failureReason)) {
|
|
2055
|
+
if (credentialInfo && /rate.?limit|hit your limit/i.test(failureReason)) {
|
|
2012
2056
|
// Try to extract reset time from the error message (e.g. "resets 3pm (UTC)")
|
|
2013
2057
|
const parsedResetTime = parseRateLimitResetTime(failureReason);
|
|
2014
2058
|
const defaultCooldownMs = 5 * 60 * 1000;
|
|
@@ -2162,6 +2206,46 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
|
|
|
2162
2206
|
configureHttpResolver(apiUrl, process.env.API_KEY);
|
|
2163
2207
|
}
|
|
2164
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
|
+
|
|
2165
2249
|
let capabilities = config.capabilities;
|
|
2166
2250
|
|
|
2167
2251
|
// Agent identity fields — populated after registration by fetching full profile
|
package/src/http/index.ts
CHANGED
|
@@ -8,12 +8,13 @@ import { ensure, initialize } from "@desplega.ai/business-use";
|
|
|
8
8
|
import type { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
9
9
|
import { getEnabledCapabilities, hasCapability } from "@/server";
|
|
10
10
|
import { initAgentMail } from "../agentmail";
|
|
11
|
-
import { closeDb } from "../be/db";
|
|
11
|
+
import { closeDb, getSwarmConfigs, upsertSwarmConfig } from "../be/db";
|
|
12
12
|
import { initGitHub } from "../github";
|
|
13
13
|
import { initGitLab } from "../gitlab";
|
|
14
14
|
import { stopHeartbeat } from "../heartbeat";
|
|
15
15
|
import { initLinear } from "../linear";
|
|
16
16
|
import { startSlackApp, stopSlackApp } from "../slack";
|
|
17
|
+
import { initTelemetry, telemetry } from "../telemetry";
|
|
17
18
|
import { initWorkflows } from "../workflows";
|
|
18
19
|
import { handleActiveSessions } from "./active-sessions";
|
|
19
20
|
import { handleAgentRegister, handleAgentsRest } from "./agents";
|
|
@@ -214,6 +215,16 @@ httpServer
|
|
|
214
215
|
console.error("Failed to load global swarm configs:", e);
|
|
215
216
|
}
|
|
216
217
|
|
|
218
|
+
// Initialize anonymized telemetry (opt-out via ANONYMIZED_TELEMETRY=false)
|
|
219
|
+
await initTelemetry(
|
|
220
|
+
"api-server",
|
|
221
|
+
(key) => getSwarmConfigs({ scope: "global", key })?.[0]?.value,
|
|
222
|
+
(key, value) => {
|
|
223
|
+
upsertSwarmConfig({ scope: "global", key, value });
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
telemetry.server("started", { port });
|
|
227
|
+
|
|
217
228
|
// Start Slack bot (if configured)
|
|
218
229
|
await startSlackApp();
|
|
219
230
|
|
package/src/http/poll.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
upsertChannelActivityCursor,
|
|
19
19
|
} from "../be/db";
|
|
20
20
|
import { fetchChannelActivity } from "../slack/channel-activity";
|
|
21
|
+
import { telemetry } from "../telemetry";
|
|
21
22
|
import { route } from "./route-def";
|
|
22
23
|
import { json, jsonError } from "./utils";
|
|
23
24
|
|
|
@@ -145,6 +146,12 @@ export async function handlePoll(
|
|
|
145
146
|
conditions: [{ timeout_ms: 300_000 }], // 5 min: polling interval + queue wait
|
|
146
147
|
});
|
|
147
148
|
|
|
149
|
+
telemetry.taskEvent("started", {
|
|
150
|
+
taskId: pendingTask.id,
|
|
151
|
+
source: pendingTask.source,
|
|
152
|
+
agentId: myAgentId,
|
|
153
|
+
});
|
|
154
|
+
|
|
148
155
|
// Resolve requesting user if available
|
|
149
156
|
const requestedByUser = pendingTask.requestedByUserId
|
|
150
157
|
? getUserById(pendingTask.requestedByUserId)
|
|
@@ -199,6 +206,11 @@ export async function handlePoll(
|
|
|
199
206
|
for (const candidateId of unassignedIds) {
|
|
200
207
|
const claimed = claimTask(candidateId, myAgentId);
|
|
201
208
|
if (claimed) {
|
|
209
|
+
telemetry.taskEvent("claimed", {
|
|
210
|
+
taskId: claimed.id,
|
|
211
|
+
source: claimed.source,
|
|
212
|
+
agentId: myAgentId,
|
|
213
|
+
});
|
|
202
214
|
return {
|
|
203
215
|
trigger: {
|
|
204
216
|
type: "task_assigned",
|
package/src/http/tasks.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
updateTaskProgress,
|
|
20
20
|
updateTaskVcs,
|
|
21
21
|
} from "../be/db";
|
|
22
|
+
import { telemetry } from "../telemetry";
|
|
22
23
|
import { route } from "./route-def";
|
|
23
24
|
import { json, jsonError } from "./utils";
|
|
24
25
|
|
|
@@ -269,6 +270,14 @@ export async function handleTasks(
|
|
|
269
270
|
},
|
|
270
271
|
});
|
|
271
272
|
|
|
273
|
+
telemetry.taskEvent("created", {
|
|
274
|
+
taskId: task.id,
|
|
275
|
+
source: task.source,
|
|
276
|
+
tags: parsed.body.tags ?? [],
|
|
277
|
+
hasParent: !!task.parentTaskId,
|
|
278
|
+
priority: task.priority,
|
|
279
|
+
});
|
|
280
|
+
|
|
272
281
|
json(res, task, 201);
|
|
273
282
|
} catch (error) {
|
|
274
283
|
console.error("[HTTP] Failed to create task:", error);
|
|
@@ -370,6 +379,14 @@ export async function handleTasks(
|
|
|
370
379
|
});
|
|
371
380
|
}
|
|
372
381
|
|
|
382
|
+
telemetry.taskEvent("cancelled", {
|
|
383
|
+
taskId: parsed.params.id,
|
|
384
|
+
source: task.source,
|
|
385
|
+
agentId: task.agentId ?? undefined,
|
|
386
|
+
previousStatus: task.status,
|
|
387
|
+
durationMs: task.createdAt ? Date.now() - new Date(task.createdAt).getTime() : undefined,
|
|
388
|
+
});
|
|
389
|
+
|
|
373
390
|
if (task.agentId) {
|
|
374
391
|
updateAgentStatusFromCapacity(task.agentId);
|
|
375
392
|
}
|
|
@@ -469,6 +486,16 @@ export async function handleTasks(
|
|
|
469
486
|
|
|
470
487
|
if (result.task && !("alreadyFinished" in result && result.alreadyFinished)) {
|
|
471
488
|
const finishEventId = parsed.body.status === "completed" ? "completed" : "failed";
|
|
489
|
+
|
|
490
|
+
const durationMs = result.task.createdAt
|
|
491
|
+
? Date.now() - new Date(result.task.createdAt).getTime()
|
|
492
|
+
: undefined;
|
|
493
|
+
|
|
494
|
+
telemetry.taskEvent(finishEventId, {
|
|
495
|
+
taskId: parsed.params.id,
|
|
496
|
+
agentId: myAgentId,
|
|
497
|
+
durationMs,
|
|
498
|
+
});
|
|
472
499
|
ensure({
|
|
473
500
|
id: finishEventId,
|
|
474
501
|
flow: "task",
|
|
@@ -72,6 +72,8 @@ import {
|
|
|
72
72
|
getCodexContextWindow,
|
|
73
73
|
resolveCodexModel,
|
|
74
74
|
} from "./codex-models";
|
|
75
|
+
import { credentialsToAuthJson } from "./codex-oauth/auth-json.js";
|
|
76
|
+
import { getValidCodexOAuth } from "./codex-oauth/storage.js";
|
|
75
77
|
import { resolveCodexPrompt } from "./codex-skill-resolver";
|
|
76
78
|
import { createCodexSwarmEventHandler } from "./codex-swarm-events";
|
|
77
79
|
import type {
|
|
@@ -792,6 +794,46 @@ export class CodexAdapter implements ProviderAdapter {
|
|
|
792
794
|
...(config.env ?? {}),
|
|
793
795
|
};
|
|
794
796
|
|
|
797
|
+
// OAuth credential resolution: if no OPENAI_API_KEY is set, try to
|
|
798
|
+
// restore or refresh ChatGPT OAuth credentials from the config store.
|
|
799
|
+
// The entrypoint also restores at boot, but this handles cases where
|
|
800
|
+
// the entrypoint didn't run (local dev) or tokens expired mid-session.
|
|
801
|
+
if (!process.env.OPENAI_API_KEY && config.apiUrl && config.apiKey) {
|
|
802
|
+
const authJsonPath = join(os.homedir(), ".codex", "auth.json");
|
|
803
|
+
let hasAuth = false;
|
|
804
|
+
try {
|
|
805
|
+
const fs = await import("node:fs/promises");
|
|
806
|
+
await fs.access(authJsonPath);
|
|
807
|
+
hasAuth = true;
|
|
808
|
+
} catch {
|
|
809
|
+
// auth.json doesn't exist
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
if (!hasAuth) {
|
|
813
|
+
const oauthCreds = await getValidCodexOAuth(config.apiUrl, config.apiKey);
|
|
814
|
+
if (oauthCreds) {
|
|
815
|
+
try {
|
|
816
|
+
const fs = await import("node:fs/promises");
|
|
817
|
+
const authJson = credentialsToAuthJson(oauthCreds);
|
|
818
|
+
await fs.mkdir(join(os.homedir(), ".codex"), { recursive: true, mode: 0o700 });
|
|
819
|
+
await fs.writeFile(authJsonPath, JSON.stringify(authJson, null, 2), {
|
|
820
|
+
mode: 0o600,
|
|
821
|
+
});
|
|
822
|
+
bufferedEmit({
|
|
823
|
+
type: "raw_stderr",
|
|
824
|
+
content: "[codex] Restored OAuth credentials from config store\n",
|
|
825
|
+
});
|
|
826
|
+
} catch (err) {
|
|
827
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
828
|
+
bufferedEmit({
|
|
829
|
+
type: "raw_stderr",
|
|
830
|
+
content: `[codex] Failed to write auth.json: ${message}\n`,
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
795
837
|
// The SDK's default `findCodexPath()` does `require.resolve("@openai/codex")`
|
|
796
838
|
// from the SDK's own module. When agent-swarm runs as a Bun single-file
|
|
797
839
|
// compiled executable, the bundled SDK can't resolve `@openai/codex` at
|