@desplega.ai/agent-swarm 1.61.0 → 1.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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/commands/runner.ts +9 -1
- package/src/http/api-keys.ts +46 -0
- package/src/http/index.ts +12 -0
- package/src/http/mcp-servers.ts +35 -12
- package/src/oauth/ensure-token.ts +6 -2
- package/src/oauth/keepalive.ts +76 -0
- package/src/providers/codex-adapter.ts +863 -0
- package/src/providers/codex-agents-md.ts +119 -0
- package/src/providers/codex-models.ts +141 -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/tests/codex-adapter.test.ts +961 -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/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/utils/credentials.ts +67 -5
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.0",
|
|
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.0",
|
|
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/commands/runner.ts
CHANGED
|
@@ -232,7 +232,15 @@ async function fetchResolvedEnv(
|
|
|
232
232
|
}
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
-
const credentialSelections = await resolveCredentialPools(env, {
|
|
235
|
+
const credentialSelections = await resolveCredentialPools(env, {
|
|
236
|
+
apiUrl,
|
|
237
|
+
apiKey,
|
|
238
|
+
// Provider-aware selection: codex tasks should not get a
|
|
239
|
+
// CLAUDE_CODE_OAUTH_TOKEN stamped on their task record (and vice
|
|
240
|
+
// versa) just because both env vars happen to be set in the worker
|
|
241
|
+
// container. See `PROVIDER_CREDENTIAL_VARS` in src/utils/credentials.ts.
|
|
242
|
+
provider: process.env.HARNESS_PROVIDER,
|
|
243
|
+
});
|
|
236
244
|
|
|
237
245
|
return { env, credentialSelections };
|
|
238
246
|
}
|
package/src/http/api-keys.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
getKeyStatuses,
|
|
7
7
|
markKeyRateLimited,
|
|
8
8
|
recordKeyUsage,
|
|
9
|
+
setApiKeyName,
|
|
9
10
|
} from "../be/db";
|
|
10
11
|
import { route } from "./route-def";
|
|
11
12
|
import { json, jsonError } from "./utils";
|
|
@@ -110,6 +111,29 @@ const getCosts = route({
|
|
|
110
111
|
auth: { apiKey: true },
|
|
111
112
|
});
|
|
112
113
|
|
|
114
|
+
const setKeyName = route({
|
|
115
|
+
method: "patch",
|
|
116
|
+
path: "/api/keys/name",
|
|
117
|
+
pattern: ["api", "keys", "name"],
|
|
118
|
+
summary: "Set or clear the human-friendly label on a pooled credential",
|
|
119
|
+
tags: ["API Keys"],
|
|
120
|
+
body: z.object({
|
|
121
|
+
keyType: z.string().min(1),
|
|
122
|
+
keySuffix: z.string().min(1).max(10),
|
|
123
|
+
/** Pass null or empty string to clear the existing label. */
|
|
124
|
+
name: z.string().max(60).nullable(),
|
|
125
|
+
scope: z.string().optional(),
|
|
126
|
+
scopeId: z.string().optional(),
|
|
127
|
+
}),
|
|
128
|
+
responses: {
|
|
129
|
+
200: { description: "Name updated" },
|
|
130
|
+
400: { description: "Validation error" },
|
|
131
|
+
401: { description: "Unauthorized" },
|
|
132
|
+
404: { description: "Key not found" },
|
|
133
|
+
},
|
|
134
|
+
auth: { apiKey: true },
|
|
135
|
+
});
|
|
136
|
+
|
|
113
137
|
// ─── Handler ─────────────────────────────────────────────────────────────────
|
|
114
138
|
|
|
115
139
|
export async function handleApiKeys(
|
|
@@ -196,5 +220,27 @@ export async function handleApiKeys(
|
|
|
196
220
|
return true;
|
|
197
221
|
}
|
|
198
222
|
|
|
223
|
+
// PATCH /api/keys/name
|
|
224
|
+
if (setKeyName.match(req.method, pathSegments)) {
|
|
225
|
+
const parsed = await setKeyName.parse(req, res, pathSegments, queryParams);
|
|
226
|
+
if (!parsed) return true;
|
|
227
|
+
|
|
228
|
+
const { keyType, keySuffix, name, scope, scopeId } = parsed.body;
|
|
229
|
+
try {
|
|
230
|
+
// Empty string is treated as "clear the label" so the dashboard's
|
|
231
|
+
// contenteditable can submit "" without sending an explicit null.
|
|
232
|
+
const value = name === "" ? null : name;
|
|
233
|
+
const updated = setApiKeyName(keyType, keySuffix, value, scope, scopeId ?? null);
|
|
234
|
+
if (!updated) {
|
|
235
|
+
jsonError(res, `No key matching ${keyType} ...${keySuffix}`, 404);
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
json(res, { success: true, keyType, keySuffix, name: value });
|
|
239
|
+
} catch (err) {
|
|
240
|
+
jsonError(res, err instanceof Error ? err.message : "Failed to set key name", 500);
|
|
241
|
+
}
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
|
|
199
245
|
return false;
|
|
200
246
|
}
|
package/src/http/index.ts
CHANGED
|
@@ -154,6 +154,12 @@ async function shutdown() {
|
|
|
154
154
|
// Stop Slack bot
|
|
155
155
|
await stopSlackApp();
|
|
156
156
|
|
|
157
|
+
// Stop OAuth keepalive
|
|
158
|
+
if (process.env.OAUTH_KEEPALIVE_DISABLE !== "true") {
|
|
159
|
+
const { stopOAuthKeepalive } = await import("../oauth/keepalive");
|
|
160
|
+
stopOAuthKeepalive();
|
|
161
|
+
}
|
|
162
|
+
|
|
157
163
|
// Close all active transports (SSE connections, etc.)
|
|
158
164
|
for (const [id, transport] of Object.entries(transports)) {
|
|
159
165
|
console.log(`[HTTP] Closing transport ${id}`);
|
|
@@ -242,6 +248,12 @@ httpServer
|
|
|
242
248
|
const heartbeatMs = Number(process.env.HEARTBEAT_INTERVAL_MS) || 90000;
|
|
243
249
|
startHeartbeat(heartbeatMs);
|
|
244
250
|
}
|
|
251
|
+
|
|
252
|
+
// Start OAuth token keepalive (proactive refresh to prevent expiry)
|
|
253
|
+
if (process.env.OAUTH_KEEPALIVE_DISABLE !== "true") {
|
|
254
|
+
const { startOAuthKeepalive } = await import("../oauth/keepalive");
|
|
255
|
+
startOAuthKeepalive();
|
|
256
|
+
}
|
|
245
257
|
})
|
|
246
258
|
.on("error", (err) => {
|
|
247
259
|
console.error("HTTP Server Error:", err);
|
package/src/http/mcp-servers.ts
CHANGED
|
@@ -174,14 +174,25 @@ export async function handleMcpServers(
|
|
|
174
174
|
const resolvedEnv: Record<string, string> = {};
|
|
175
175
|
const resolvedHeaders: Record<string, string> = {};
|
|
176
176
|
|
|
177
|
-
// Resolve env config keys
|
|
177
|
+
// Resolve env config keys
|
|
178
|
+
// Supports both array format ["KEY_A", "KEY_B"] (key = config key = element)
|
|
179
|
+
// and object format {"ENV_VAR": "config-key-name"}
|
|
178
180
|
if (server.envConfigKeys) {
|
|
179
181
|
try {
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
182
|
+
const parsed = JSON.parse(server.envConfigKeys);
|
|
183
|
+
if (Array.isArray(parsed)) {
|
|
184
|
+
for (const key of parsed) {
|
|
185
|
+
const value = configMap.get(key);
|
|
186
|
+
if (value !== undefined) {
|
|
187
|
+
resolvedEnv[key] = value;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
for (const [envVar, configKey] of Object.entries(parsed as Record<string, string>)) {
|
|
192
|
+
const value = configMap.get(configKey);
|
|
193
|
+
if (value !== undefined) {
|
|
194
|
+
resolvedEnv[envVar] = value;
|
|
195
|
+
}
|
|
185
196
|
}
|
|
186
197
|
}
|
|
187
198
|
} catch {
|
|
@@ -189,14 +200,26 @@ export async function handleMcpServers(
|
|
|
189
200
|
}
|
|
190
201
|
}
|
|
191
202
|
|
|
192
|
-
// Resolve header config keys
|
|
203
|
+
// Resolve header config keys
|
|
204
|
+
// Supports both array format ["Header-A", "Header-B"] and object format {"Header-Name": "config-key-name"}
|
|
193
205
|
if (server.headerConfigKeys) {
|
|
194
206
|
try {
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
207
|
+
const parsed = JSON.parse(server.headerConfigKeys);
|
|
208
|
+
if (Array.isArray(parsed)) {
|
|
209
|
+
for (const key of parsed) {
|
|
210
|
+
const value = configMap.get(key);
|
|
211
|
+
if (value !== undefined) {
|
|
212
|
+
resolvedHeaders[key] = value;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
for (const [headerName, configKey] of Object.entries(
|
|
217
|
+
parsed as Record<string, string>,
|
|
218
|
+
)) {
|
|
219
|
+
const value = configMap.get(configKey);
|
|
220
|
+
if (value !== undefined) {
|
|
221
|
+
resolvedHeaders[headerName] = value;
|
|
222
|
+
}
|
|
200
223
|
}
|
|
201
224
|
}
|
|
202
225
|
} catch {
|
|
@@ -25,9 +25,13 @@ function getOAuthConfig(provider: string): OAuthProviderConfig | null {
|
|
|
25
25
|
* Ensure a valid OAuth token exists for the given provider.
|
|
26
26
|
* If the token is expiring soon, attempt to refresh it.
|
|
27
27
|
* Call this before any API interaction with an OAuth-protected service.
|
|
28
|
+
*
|
|
29
|
+
* @param bufferMs - How far ahead to check for expiry. Default 5 min (reactive use).
|
|
30
|
+
* Keepalive callers should pass a larger value (e.g. 13h) to force
|
|
31
|
+
* a proactive refresh well before the token actually expires.
|
|
28
32
|
*/
|
|
29
|
-
export async function ensureToken(provider: string): Promise<void> {
|
|
30
|
-
if (!isTokenExpiringSoon(provider)) return;
|
|
33
|
+
export async function ensureToken(provider: string, bufferMs?: number): Promise<void> {
|
|
34
|
+
if (!isTokenExpiringSoon(provider, bufferMs)) return;
|
|
31
35
|
|
|
32
36
|
const config = getOAuthConfig(provider);
|
|
33
37
|
const tokens = getOAuthTokens(provider);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { ensureToken } from "./ensure-token";
|
|
2
|
+
|
|
3
|
+
const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000;
|
|
4
|
+
const THIRTEEN_HOURS_MS = 13 * 60 * 60 * 1000;
|
|
5
|
+
const SLACK_ALERTS_CHANNEL = process.env.SLACK_ALERTS_CHANNEL || "C08JCRURPBV";
|
|
6
|
+
|
|
7
|
+
let keepaliveInterval: ReturnType<typeof setInterval> | null = null;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Proactively refresh OAuth tokens on a schedule to prevent expiry.
|
|
11
|
+
* If refresh fails, posts a Slack notification so someone can re-auth manually.
|
|
12
|
+
*/
|
|
13
|
+
async function runKeepalive(): Promise<void> {
|
|
14
|
+
console.log("[OAuth Keepalive] Running scheduled token refresh for linear...");
|
|
15
|
+
try {
|
|
16
|
+
await ensureToken("linear", THIRTEEN_HOURS_MS);
|
|
17
|
+
console.log("[OAuth Keepalive] linear token check completed successfully");
|
|
18
|
+
} catch (err) {
|
|
19
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
20
|
+
console.error(`[OAuth Keepalive] Failed to refresh linear token: ${message}`);
|
|
21
|
+
await notifySlack(
|
|
22
|
+
`⚠️ *OAuth Keepalive Failed*\nProvider: \`linear\`\nError: ${message}\n\nManual re-authorization may be required.`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function notifySlack(text: string): Promise<void> {
|
|
28
|
+
try {
|
|
29
|
+
const { getSlackApp } = await import("../slack/app");
|
|
30
|
+
const app = getSlackApp();
|
|
31
|
+
if (!app) {
|
|
32
|
+
console.warn("[OAuth Keepalive] Slack not available, cannot send notification");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
await app.client.chat.postMessage({
|
|
36
|
+
channel: SLACK_ALERTS_CHANNEL,
|
|
37
|
+
text,
|
|
38
|
+
});
|
|
39
|
+
console.log("[OAuth Keepalive] Slack notification sent");
|
|
40
|
+
} catch (slackErr) {
|
|
41
|
+
console.error(
|
|
42
|
+
"[OAuth Keepalive] Failed to send Slack notification:",
|
|
43
|
+
slackErr instanceof Error ? slackErr.message : slackErr,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Start the OAuth keepalive timer. Runs immediately then every 12 hours.
|
|
50
|
+
*/
|
|
51
|
+
export function startOAuthKeepalive(): void {
|
|
52
|
+
if (keepaliveInterval) {
|
|
53
|
+
console.log("[OAuth Keepalive] Already running, skipping");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
console.log("[OAuth Keepalive] Starting (12h interval)");
|
|
58
|
+
|
|
59
|
+
// Run once after a short delay (let server finish startup)
|
|
60
|
+
setTimeout(() => runKeepalive(), 10_000);
|
|
61
|
+
|
|
62
|
+
keepaliveInterval = setInterval(() => {
|
|
63
|
+
runKeepalive();
|
|
64
|
+
}, TWELVE_HOURS_MS);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Stop the OAuth keepalive timer.
|
|
69
|
+
*/
|
|
70
|
+
export function stopOAuthKeepalive(): void {
|
|
71
|
+
if (keepaliveInterval) {
|
|
72
|
+
clearInterval(keepaliveInterval);
|
|
73
|
+
keepaliveInterval = null;
|
|
74
|
+
console.log("[OAuth Keepalive] Stopped");
|
|
75
|
+
}
|
|
76
|
+
}
|