@desplega.ai/agent-swarm 1.74.4 → 1.76.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 +1264 -46
- package/package.json +2 -2
- package/src/be/db.ts +563 -9
- package/src/be/memory/edges-store.ts +69 -0
- package/src/be/memory/providers/sqlite-store.ts +4 -0
- package/src/be/memory/raters/explicit-self.ts +22 -0
- package/src/be/memory/raters/implicit-citation.ts +44 -0
- package/src/be/memory/raters/llm-client.ts +172 -0
- package/src/be/memory/raters/llm-summarizer.ts +218 -0
- package/src/be/memory/raters/llm.ts +375 -0
- package/src/be/memory/raters/noop.ts +14 -0
- package/src/be/memory/raters/registry.ts +86 -0
- package/src/be/memory/raters/retrieval.ts +88 -0
- package/src/be/memory/raters/run-server-raters.ts +97 -0
- package/src/be/memory/raters/store.ts +228 -0
- package/src/be/memory/raters/types.ts +101 -0
- package/src/be/memory/reranker.ts +32 -2
- package/src/be/memory/retrieval-store.ts +116 -0
- package/src/be/memory/types.ts +3 -0
- package/src/be/migrations/051_memory_posteriors_and_retrieval.sql +67 -0
- package/src/be/migrations/052_memory_edges.sql +36 -0
- package/src/be/migrations/053_agent_waiting_for_credentials_status.sql +61 -0
- package/src/be/migrations/054_agent_harness_provider.sql +21 -0
- package/src/be/migrations/055_agent_cred_status.sql +15 -0
- package/src/be/migrations/056_drop_agent_tasks_source_check.sql +139 -0
- package/src/be/migrations/057_inbox_item_state.sql +27 -0
- package/src/be/migrations/058_task_templates.sql +31 -0
- package/src/be/swarm-config-guard.ts +24 -0
- package/src/commands/credential-wait.ts +186 -0
- package/src/commands/provider-credentials.ts +434 -0
- package/src/commands/runner.ts +253 -21
- package/src/hooks/hook.ts +143 -66
- package/src/http/agents.ts +191 -1
- package/src/http/config.ts +11 -1
- package/src/http/core.ts +5 -0
- package/src/http/inbox-state.ts +89 -0
- package/src/http/index.ts +10 -0
- package/src/http/memory.ts +230 -1
- package/src/http/sessions.ts +86 -0
- package/src/http/status.ts +665 -0
- package/src/http/task-templates.ts +51 -0
- package/src/http/tasks.ts +85 -5
- package/src/http/users.ts +134 -0
- package/src/prompts/memories.ts +62 -0
- package/src/providers/claude-adapter.ts +22 -0
- package/src/providers/claude-managed-adapter.ts +24 -0
- package/src/providers/codex-adapter.ts +43 -1
- package/src/providers/devin-adapter.ts +18 -0
- package/src/providers/index.ts +7 -0
- package/src/providers/opencode-adapter.ts +60 -0
- package/src/providers/pi-mono-adapter.ts +71 -0
- package/src/providers/types.ts +34 -0
- package/src/server.ts +2 -0
- package/src/slack/handlers.ts +0 -1
- package/src/tests/agents-harness-provider.test.ts +333 -0
- package/src/tests/credential-check.test.ts +367 -0
- package/src/tests/credential-status-api.test.ts +223 -0
- package/src/tests/credential-status-routing.test.ts +150 -0
- package/src/tests/credential-wait.test.ts +282 -0
- package/src/tests/harness-provider-resolution.test.ts +242 -0
- package/src/tests/jira-sync.test.ts +1 -1
- package/src/tests/memory-edges.test.ts +722 -0
- package/src/tests/memory-rate-endpoint.test.ts +330 -0
- package/src/tests/memory-rate-tool.test.ts +252 -0
- package/src/tests/memory-rater-e2e.test.ts +578 -0
- package/src/tests/memory-rater-implicit-citation.test.ts +304 -0
- package/src/tests/memory-rater-llm-summarizer.test.ts +317 -0
- package/src/tests/memory-rater-llm.test.ts +964 -0
- package/src/tests/memory-rater-store.test.ts +249 -0
- package/src/tests/memory-reranker.test.ts +161 -2
- package/src/tests/migration-runner-regressions.test.ts +17 -2
- package/src/tests/mocks/mock-llm-rater-client.ts +35 -0
- package/src/tests/run-server-raters.test.ts +291 -0
- package/src/tests/sessions.test.ts +141 -0
- package/src/tests/status.test.ts +843 -0
- package/src/tests/stop-hook-task-resolution.test.ts +98 -0
- package/src/tests/template-recommendations.test.ts +148 -0
- package/src/tests/tool-annotations.test.ts +2 -2
- package/src/tests/use-dismissible-card.test.ts +140 -0
- package/src/tools/memory-rate.ts +166 -0
- package/src/tools/memory-search.ts +18 -0
- package/src/tools/store-progress.ts +37 -0
- package/src/tools/swarm-config/set-config.ts +17 -1
- package/src/tools/tool-config.ts +1 -0
- package/src/types.ts +122 -1
- package/src/utils/harness-provider.ts +32 -0
- package/tsconfig.json +0 -2
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildCredStatusReport,
|
|
4
|
+
checkProviderCredentials,
|
|
5
|
+
isCredCheckDisabled,
|
|
6
|
+
REQUIRED_CRED_VARS_BY_PROVIDER,
|
|
7
|
+
} from "../commands/provider-credentials";
|
|
8
|
+
import { checkClaudeCredentials } from "../providers/claude-adapter";
|
|
9
|
+
import { checkClaudeManagedCredentials } from "../providers/claude-managed-adapter";
|
|
10
|
+
import { checkCodexCredentials } from "../providers/codex-adapter";
|
|
11
|
+
import { checkDevinCredentials } from "../providers/devin-adapter";
|
|
12
|
+
import { checkOpencodeCredentials } from "../providers/opencode-adapter";
|
|
13
|
+
import { checkPiMonoCredentials } from "../providers/pi-mono-adapter";
|
|
14
|
+
|
|
15
|
+
/** Build a stub `fs` whose `existsSync` returns true only for paths in the set. */
|
|
16
|
+
function fsWith(present: Set<string>): { existsSync(p: string): boolean } {
|
|
17
|
+
return { existsSync: (p: string) => present.has(p) };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const noFiles = fsWith(new Set());
|
|
21
|
+
|
|
22
|
+
// ─── claude ──────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
describe("checkClaudeCredentials", () => {
|
|
25
|
+
test("ready when CLAUDE_CODE_OAUTH_TOKEN is set", () => {
|
|
26
|
+
const status = checkClaudeCredentials({ CLAUDE_CODE_OAUTH_TOKEN: "tok" });
|
|
27
|
+
expect(status.ready).toBe(true);
|
|
28
|
+
expect(status.missing).toEqual([]);
|
|
29
|
+
expect(status.satisfiedBy).toBe("env");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("ready when only ANTHROPIC_API_KEY is set", () => {
|
|
33
|
+
const status = checkClaudeCredentials({ ANTHROPIC_API_KEY: "sk-ant" });
|
|
34
|
+
expect(status.ready).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("not ready when both unset, lists both as missing with hint", () => {
|
|
38
|
+
const status = checkClaudeCredentials({});
|
|
39
|
+
expect(status.ready).toBe(false);
|
|
40
|
+
expect(status.missing).toEqual(["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]);
|
|
41
|
+
expect(status.hint).toBeTruthy();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// ─── claude-managed ──────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
describe("checkClaudeManagedCredentials", () => {
|
|
48
|
+
const full = {
|
|
49
|
+
ANTHROPIC_API_KEY: "sk-ant",
|
|
50
|
+
MANAGED_AGENT_ID: "ag_123",
|
|
51
|
+
MANAGED_ENVIRONMENT_ID: "env_123",
|
|
52
|
+
MCP_BASE_URL: "https://swarm.example",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
test("ready when all four are set", () => {
|
|
56
|
+
const status = checkClaudeManagedCredentials(full);
|
|
57
|
+
expect(status.ready).toBe(true);
|
|
58
|
+
expect(status.missing).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("not ready when any one is missing", () => {
|
|
62
|
+
for (const drop of Object.keys(full) as Array<keyof typeof full>) {
|
|
63
|
+
const env: Record<string, string | undefined> = { ...full };
|
|
64
|
+
delete env[drop];
|
|
65
|
+
const status = checkClaudeManagedCredentials(env);
|
|
66
|
+
expect(status.ready).toBe(false);
|
|
67
|
+
expect(status.missing).toContain(drop);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("not ready when env is empty, lists all four as missing", () => {
|
|
72
|
+
const status = checkClaudeManagedCredentials({});
|
|
73
|
+
expect(status.ready).toBe(false);
|
|
74
|
+
expect(status.missing.sort()).toEqual(
|
|
75
|
+
["ANTHROPIC_API_KEY", "MANAGED_AGENT_ID", "MANAGED_ENVIRONMENT_ID", "MCP_BASE_URL"].sort(),
|
|
76
|
+
);
|
|
77
|
+
expect(status.hint).toContain("claude-managed-setup");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ─── devin ───────────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
describe("checkDevinCredentials", () => {
|
|
84
|
+
test("ready when both keys are set", () => {
|
|
85
|
+
expect(checkDevinCredentials({ DEVIN_API_KEY: "k", DEVIN_ORG_ID: "o" }).ready).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("not ready when org id is missing", () => {
|
|
89
|
+
const status = checkDevinCredentials({ DEVIN_API_KEY: "k" });
|
|
90
|
+
expect(status.ready).toBe(false);
|
|
91
|
+
expect(status.missing).toEqual(["DEVIN_ORG_ID"]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("not ready when both are missing", () => {
|
|
95
|
+
const status = checkDevinCredentials({});
|
|
96
|
+
expect(status.ready).toBe(false);
|
|
97
|
+
expect(status.missing).toEqual(["DEVIN_API_KEY", "DEVIN_ORG_ID"]);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ─── codex ───────────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
describe("checkCodexCredentials", () => {
|
|
104
|
+
const HOME = "/home/worker";
|
|
105
|
+
const AUTH = `${HOME}/.codex/auth.json`;
|
|
106
|
+
|
|
107
|
+
test("ready (file) when ~/.codex/auth.json exists", () => {
|
|
108
|
+
const status = checkCodexCredentials({}, { homeDir: HOME, fs: fsWith(new Set([AUTH])) });
|
|
109
|
+
expect(status.ready).toBe(true);
|
|
110
|
+
expect(status.satisfiedBy).toBe("file");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("ready (side-effect-pending) when OPENAI_API_KEY is set but auth.json absent", () => {
|
|
114
|
+
const status = checkCodexCredentials(
|
|
115
|
+
{ OPENAI_API_KEY: "sk-proj" },
|
|
116
|
+
{ homeDir: HOME, fs: noFiles },
|
|
117
|
+
);
|
|
118
|
+
expect(status.ready).toBe(true);
|
|
119
|
+
expect(status.satisfiedBy).toBe("side-effect-pending");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("ready (side-effect-pending) when CODEX_OAUTH is set", () => {
|
|
123
|
+
const status = checkCodexCredentials({ CODEX_OAUTH: "{}" }, { homeDir: HOME, fs: noFiles });
|
|
124
|
+
expect(status.ready).toBe(true);
|
|
125
|
+
expect(status.satisfiedBy).toBe("side-effect-pending");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("not ready when nothing is present, missing list includes both env keys + the file", () => {
|
|
129
|
+
const status = checkCodexCredentials({}, { homeDir: HOME, fs: noFiles });
|
|
130
|
+
expect(status.ready).toBe(false);
|
|
131
|
+
expect(status.missing).toContain("OPENAI_API_KEY");
|
|
132
|
+
expect(status.missing).toContain("CODEX_OAUTH");
|
|
133
|
+
expect(status.missing).toContain(AUTH);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ─── pi-mono ─────────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
describe("checkPiMonoCredentials", () => {
|
|
140
|
+
const HOME = "/home/worker";
|
|
141
|
+
const AUTH = `${HOME}/.pi/agent/auth.json`;
|
|
142
|
+
|
|
143
|
+
test("ready (file) when ~/.pi/agent/auth.json exists", () => {
|
|
144
|
+
const status = checkPiMonoCredentials({}, { homeDir: HOME, fs: fsWith(new Set([AUTH])) });
|
|
145
|
+
expect(status.ready).toBe(true);
|
|
146
|
+
expect(status.satisfiedBy).toBe("file");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("permissive: ready when MODEL_OVERRIDE unset and any one supported key is present", () => {
|
|
150
|
+
expect(
|
|
151
|
+
checkPiMonoCredentials({ ANTHROPIC_API_KEY: "x" }, { homeDir: HOME, fs: noFiles }).ready,
|
|
152
|
+
).toBe(true);
|
|
153
|
+
expect(
|
|
154
|
+
checkPiMonoCredentials({ OPENROUTER_API_KEY: "x" }, { homeDir: HOME, fs: noFiles }).ready,
|
|
155
|
+
).toBe(true);
|
|
156
|
+
expect(
|
|
157
|
+
checkPiMonoCredentials({ OPENAI_API_KEY: "x" }, { homeDir: HOME, fs: noFiles }).ready,
|
|
158
|
+
).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("permissive: not ready when MODEL_OVERRIDE unset and no keys are set", () => {
|
|
162
|
+
const status = checkPiMonoCredentials({}, { homeDir: HOME, fs: noFiles });
|
|
163
|
+
expect(status.ready).toBe(false);
|
|
164
|
+
expect(status.missing).toContain("ANTHROPIC_API_KEY");
|
|
165
|
+
expect(status.missing).toContain("OPENROUTER_API_KEY");
|
|
166
|
+
expect(status.missing).toContain("OPENAI_API_KEY");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("strict: MODEL_OVERRIDE=anthropic/... requires ANTHROPIC_API_KEY", () => {
|
|
170
|
+
const env = { MODEL_OVERRIDE: "anthropic/claude-sonnet-4" };
|
|
171
|
+
expect(checkPiMonoCredentials(env, { homeDir: HOME, fs: noFiles }).ready).toBe(false);
|
|
172
|
+
expect(
|
|
173
|
+
checkPiMonoCredentials({ ...env, ANTHROPIC_API_KEY: "x" }, { homeDir: HOME, fs: noFiles })
|
|
174
|
+
.ready,
|
|
175
|
+
).toBe(true);
|
|
176
|
+
// OPENROUTER_API_KEY does NOT satisfy an anthropic-prefixed model
|
|
177
|
+
expect(
|
|
178
|
+
checkPiMonoCredentials({ ...env, OPENROUTER_API_KEY: "x" }, { homeDir: HOME, fs: noFiles })
|
|
179
|
+
.ready,
|
|
180
|
+
).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("strict: MODEL_OVERRIDE=openrouter/... requires OPENROUTER_API_KEY", () => {
|
|
184
|
+
const env = { MODEL_OVERRIDE: "openrouter/google/gemini-2.5-flash-lite" };
|
|
185
|
+
expect(
|
|
186
|
+
checkPiMonoCredentials({ ...env, OPENROUTER_API_KEY: "x" }, { homeDir: HOME, fs: noFiles })
|
|
187
|
+
.ready,
|
|
188
|
+
).toBe(true);
|
|
189
|
+
expect(
|
|
190
|
+
checkPiMonoCredentials({ ...env, ANTHROPIC_API_KEY: "x" }, { homeDir: HOME, fs: noFiles })
|
|
191
|
+
.ready,
|
|
192
|
+
).toBe(false);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("strict: shortname `sonnet` resolves to anthropic", () => {
|
|
196
|
+
const env = { MODEL_OVERRIDE: "sonnet" };
|
|
197
|
+
expect(
|
|
198
|
+
checkPiMonoCredentials({ ...env, ANTHROPIC_API_KEY: "x" }, { homeDir: HOME, fs: noFiles })
|
|
199
|
+
.ready,
|
|
200
|
+
).toBe(true);
|
|
201
|
+
expect(
|
|
202
|
+
checkPiMonoCredentials({ ...env, OPENROUTER_API_KEY: "x" }, { homeDir: HOME, fs: noFiles })
|
|
203
|
+
.ready,
|
|
204
|
+
).toBe(false);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ─── opencode ────────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
describe("checkOpencodeCredentials", () => {
|
|
211
|
+
const HOME = "/home/worker";
|
|
212
|
+
const AUTH = `${HOME}/.local/share/opencode/auth.json`;
|
|
213
|
+
|
|
214
|
+
test("ready (file) when ~/.local/share/opencode/auth.json exists", () => {
|
|
215
|
+
const status = checkOpencodeCredentials({}, { homeDir: HOME, fs: fsWith(new Set([AUTH])) });
|
|
216
|
+
expect(status.ready).toBe(true);
|
|
217
|
+
expect(status.satisfiedBy).toBe("file");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("permissive: ready with any one supported key", () => {
|
|
221
|
+
expect(
|
|
222
|
+
checkOpencodeCredentials({ OPENROUTER_API_KEY: "x" }, { homeDir: HOME, fs: noFiles }).ready,
|
|
223
|
+
).toBe(true);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("strict: MODEL_OVERRIDE=openai/... requires OPENAI_API_KEY", () => {
|
|
227
|
+
const env = { MODEL_OVERRIDE: "openai/gpt-4o" };
|
|
228
|
+
expect(checkOpencodeCredentials(env, { homeDir: HOME, fs: noFiles }).ready).toBe(false);
|
|
229
|
+
expect(
|
|
230
|
+
checkOpencodeCredentials({ ...env, OPENAI_API_KEY: "x" }, { homeDir: HOME, fs: noFiles })
|
|
231
|
+
.ready,
|
|
232
|
+
).toBe(true);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("not ready when nothing is set", () => {
|
|
236
|
+
const status = checkOpencodeCredentials({}, { homeDir: HOME, fs: noFiles });
|
|
237
|
+
expect(status.ready).toBe(false);
|
|
238
|
+
expect(status.missing).toContain("OPENROUTER_API_KEY");
|
|
239
|
+
expect(status.missing).toContain("ANTHROPIC_API_KEY");
|
|
240
|
+
expect(status.missing).toContain("OPENAI_API_KEY");
|
|
241
|
+
expect(status.missing).toContain(AUTH);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// ─── dispatcher ──────────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
describe("checkProviderCredentials dispatcher", () => {
|
|
248
|
+
const HOME = "/home/worker";
|
|
249
|
+
|
|
250
|
+
test("dispatches to the right adapter for every supported provider", () => {
|
|
251
|
+
expect(checkProviderCredentials("claude", { CLAUDE_CODE_OAUTH_TOKEN: "x" }).ready).toBe(true);
|
|
252
|
+
expect(checkProviderCredentials("claude", {}).ready).toBe(false);
|
|
253
|
+
|
|
254
|
+
expect(
|
|
255
|
+
checkProviderCredentials(
|
|
256
|
+
"claude-managed",
|
|
257
|
+
{
|
|
258
|
+
ANTHROPIC_API_KEY: "x",
|
|
259
|
+
MANAGED_AGENT_ID: "a",
|
|
260
|
+
MANAGED_ENVIRONMENT_ID: "e",
|
|
261
|
+
MCP_BASE_URL: "https://x",
|
|
262
|
+
},
|
|
263
|
+
{ homeDir: HOME, fs: noFiles },
|
|
264
|
+
).ready,
|
|
265
|
+
).toBe(true);
|
|
266
|
+
|
|
267
|
+
expect(checkProviderCredentials("devin", { DEVIN_API_KEY: "x", DEVIN_ORG_ID: "y" }).ready).toBe(
|
|
268
|
+
true,
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
expect(
|
|
272
|
+
checkProviderCredentials("codex", { OPENAI_API_KEY: "x" }, { homeDir: HOME, fs: noFiles })
|
|
273
|
+
.ready,
|
|
274
|
+
).toBe(true);
|
|
275
|
+
|
|
276
|
+
expect(
|
|
277
|
+
checkProviderCredentials("pi", { ANTHROPIC_API_KEY: "x" }, { homeDir: HOME, fs: noFiles })
|
|
278
|
+
.ready,
|
|
279
|
+
).toBe(true);
|
|
280
|
+
|
|
281
|
+
expect(
|
|
282
|
+
checkProviderCredentials(
|
|
283
|
+
"opencode",
|
|
284
|
+
{ OPENROUTER_API_KEY: "x" },
|
|
285
|
+
{ homeDir: HOME, fs: noFiles },
|
|
286
|
+
).ready,
|
|
287
|
+
).toBe(true);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("throws on unknown provider", () => {
|
|
291
|
+
expect(() => checkProviderCredentials("nope", {})).toThrow(/unknown provider/i);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// ─── snapshot tests required by the plan ────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
describe("snapshot: every provider", () => {
|
|
298
|
+
const HOME = "/home/worker";
|
|
299
|
+
const providers = ["claude", "claude-managed", "codex", "devin", "opencode", "pi"] as const;
|
|
300
|
+
|
|
301
|
+
test("fully unset env → ready=false with non-empty missing[] and hint", () => {
|
|
302
|
+
for (const p of providers) {
|
|
303
|
+
const status = checkProviderCredentials(p, {}, { homeDir: HOME, fs: noFiles });
|
|
304
|
+
expect(status.ready).toBe(false);
|
|
305
|
+
expect(status.missing.length).toBeGreaterThan(0);
|
|
306
|
+
expect(status.hint).toBeTruthy();
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("minimum sufficient env → ready=true", () => {
|
|
311
|
+
const minimums: Record<string, Record<string, string>> = {
|
|
312
|
+
claude: { CLAUDE_CODE_OAUTH_TOKEN: "x" },
|
|
313
|
+
"claude-managed": {
|
|
314
|
+
ANTHROPIC_API_KEY: "x",
|
|
315
|
+
MANAGED_AGENT_ID: "a",
|
|
316
|
+
MANAGED_ENVIRONMENT_ID: "e",
|
|
317
|
+
MCP_BASE_URL: "https://x",
|
|
318
|
+
},
|
|
319
|
+
codex: { OPENAI_API_KEY: "x" },
|
|
320
|
+
devin: { DEVIN_API_KEY: "x", DEVIN_ORG_ID: "y" },
|
|
321
|
+
opencode: { OPENROUTER_API_KEY: "x" },
|
|
322
|
+
pi: { ANTHROPIC_API_KEY: "x" },
|
|
323
|
+
};
|
|
324
|
+
for (const p of providers) {
|
|
325
|
+
const status = checkProviderCredentials(p, minimums[p]!, { homeDir: HOME, fs: noFiles });
|
|
326
|
+
expect(status.ready).toBe(true);
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// ─── REQUIRED_CRED_VARS_BY_PROVIDER documentation map ────────────────────────
|
|
332
|
+
|
|
333
|
+
describe("REQUIRED_CRED_VARS_BY_PROVIDER", () => {
|
|
334
|
+
test("covers every supported provider", () => {
|
|
335
|
+
const providers = ["claude", "claude-managed", "codex", "devin", "opencode", "pi"] as const;
|
|
336
|
+
for (const p of providers) {
|
|
337
|
+
expect(REQUIRED_CRED_VARS_BY_PROVIDER[p]).toBeDefined();
|
|
338
|
+
expect(REQUIRED_CRED_VARS_BY_PROVIDER[p].length).toBeGreaterThan(0);
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// ─── Migration 055: report composition + opt-out ────────────────────────────
|
|
344
|
+
|
|
345
|
+
describe("isCredCheckDisabled", () => {
|
|
346
|
+
test("true only when CRED_CHECK_DISABLE === '1'", () => {
|
|
347
|
+
expect(isCredCheckDisabled({})).toBe(false);
|
|
348
|
+
expect(isCredCheckDisabled({ CRED_CHECK_DISABLE: "0" })).toBe(false);
|
|
349
|
+
expect(isCredCheckDisabled({ CRED_CHECK_DISABLE: "true" })).toBe(false);
|
|
350
|
+
expect(isCredCheckDisabled({ CRED_CHECK_DISABLE: "1" })).toBe(true);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
describe("buildCredStatusReport", () => {
|
|
355
|
+
test("not ready → no live test, snapshot mirrors presence check", async () => {
|
|
356
|
+
const snap = await buildCredStatusReport("claude", {}, {}, "boot");
|
|
357
|
+
expect(snap.ready).toBe(false);
|
|
358
|
+
expect(snap.liveTest).toBeNull();
|
|
359
|
+
expect(snap.reportKind).toBe("boot");
|
|
360
|
+
expect(snap.missing.length).toBeGreaterThan(0);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("post_task kind is preserved on the snapshot", async () => {
|
|
364
|
+
const snap = await buildCredStatusReport("claude", {}, {}, "post_task");
|
|
365
|
+
expect(snap.reportKind).toBe("post_task");
|
|
366
|
+
});
|
|
367
|
+
});
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { createServer as createHttpServer, type Server } from "node:http";
|
|
4
|
+
import { closeDb, createAgent, initDb } from "../be/db";
|
|
5
|
+
import { handleAgentsRest } from "../http/agents";
|
|
6
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Phase 4 of the worker credential safe-loop plan
|
|
10
|
+
* (thoughts/taras/plans/2026-05-06-worker-credential-safe-loop.md).
|
|
11
|
+
*
|
|
12
|
+
* Verifies the three new endpoints from src/http/agents.ts:
|
|
13
|
+
*
|
|
14
|
+
* PUT /api/agents/:id/credential-status — worker self-report
|
|
15
|
+
* GET /api/agents/:id/credential-status — single-agent dashboard read
|
|
16
|
+
* GET /api/agents/credential-status — bulk dashboard read (with optional ?status= filter)
|
|
17
|
+
*
|
|
18
|
+
* The bulk route ordering matters — it MUST match before the single-agent
|
|
19
|
+
* route or the literal `credential-status` would be parsed as an agent id.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const TEST_DB_PATH = "./test-credential-status-api.sqlite";
|
|
23
|
+
const TEST_PORT = 13041;
|
|
24
|
+
|
|
25
|
+
function createTestServer(): Server {
|
|
26
|
+
return createHttpServer(async (req, res) => {
|
|
27
|
+
const url = req.url || "/";
|
|
28
|
+
const pathSegments = getPathSegments(url);
|
|
29
|
+
const queryParams = parseQueryParams(url);
|
|
30
|
+
|
|
31
|
+
const handled = await handleAgentsRest(req, res, pathSegments, queryParams, undefined);
|
|
32
|
+
if (!handled) {
|
|
33
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
34
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("Phase 4 — credential-status HTTP endpoints", () => {
|
|
40
|
+
let server: Server;
|
|
41
|
+
const baseUrl = `http://localhost:${TEST_PORT}`;
|
|
42
|
+
const readyAgentName = "api-ready";
|
|
43
|
+
const blockedAgentName = "api-blocked";
|
|
44
|
+
let readyAgentId = "";
|
|
45
|
+
let blockedAgentId = "";
|
|
46
|
+
|
|
47
|
+
beforeAll(async () => {
|
|
48
|
+
try {
|
|
49
|
+
await unlink(TEST_DB_PATH);
|
|
50
|
+
} catch {
|
|
51
|
+
// first run
|
|
52
|
+
}
|
|
53
|
+
initDb(TEST_DB_PATH);
|
|
54
|
+
|
|
55
|
+
// Seed two agents — one will stay idle, one will be flipped to
|
|
56
|
+
// waiting_for_credentials via the PUT endpoint below.
|
|
57
|
+
readyAgentId = createAgent({
|
|
58
|
+
name: readyAgentName,
|
|
59
|
+
isLead: false,
|
|
60
|
+
status: "idle",
|
|
61
|
+
capabilities: [],
|
|
62
|
+
maxTasks: 1,
|
|
63
|
+
}).id;
|
|
64
|
+
blockedAgentId = createAgent({
|
|
65
|
+
name: blockedAgentName,
|
|
66
|
+
isLead: false,
|
|
67
|
+
status: "idle",
|
|
68
|
+
capabilities: [],
|
|
69
|
+
maxTasks: 1,
|
|
70
|
+
}).id;
|
|
71
|
+
|
|
72
|
+
server = createTestServer();
|
|
73
|
+
await new Promise<void>((resolve) => server.listen(TEST_PORT, () => resolve()));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
afterAll(async () => {
|
|
77
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
78
|
+
closeDb();
|
|
79
|
+
try {
|
|
80
|
+
await unlink(TEST_DB_PATH);
|
|
81
|
+
await unlink(`${TEST_DB_PATH}-wal`);
|
|
82
|
+
await unlink(`${TEST_DB_PATH}-shm`);
|
|
83
|
+
} catch {
|
|
84
|
+
// best-effort
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("PUT /api/agents/:id/credential-status (ready=false) parks the agent", async () => {
|
|
89
|
+
const resp = await fetch(`${baseUrl}/api/agents/${blockedAgentId}/credential-status`, {
|
|
90
|
+
method: "PUT",
|
|
91
|
+
headers: { "Content-Type": "application/json" },
|
|
92
|
+
body: JSON.stringify({ ready: false, missing: ["CLAUDE_CODE_OAUTH_TOKEN"] }),
|
|
93
|
+
});
|
|
94
|
+
expect(resp.status).toBe(200);
|
|
95
|
+
const body = (await resp.json()) as { id: string; status: string; credentialMissing: string[] };
|
|
96
|
+
expect(body.status).toBe("waiting_for_credentials");
|
|
97
|
+
expect(body.credentialMissing).toEqual(["CLAUDE_CODE_OAUTH_TOKEN"]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("GET /api/agents/:id/credential-status returns the single-agent payload", async () => {
|
|
101
|
+
const resp = await fetch(`${baseUrl}/api/agents/${blockedAgentId}/credential-status`);
|
|
102
|
+
expect(resp.status).toBe(200);
|
|
103
|
+
const body = (await resp.json()) as {
|
|
104
|
+
agentId: string;
|
|
105
|
+
name: string;
|
|
106
|
+
status: string;
|
|
107
|
+
missing: string[];
|
|
108
|
+
lastCheckedAt: string;
|
|
109
|
+
};
|
|
110
|
+
expect(body.agentId).toBe(blockedAgentId);
|
|
111
|
+
expect(body.name).toBe(blockedAgentName);
|
|
112
|
+
expect(body.status).toBe("waiting_for_credentials");
|
|
113
|
+
expect(body.missing).toEqual(["CLAUDE_CODE_OAUTH_TOKEN"]);
|
|
114
|
+
expect(typeof body.lastCheckedAt).toBe("string");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("GET /api/agents/credential-status (bulk) returns every agent", async () => {
|
|
118
|
+
const resp = await fetch(`${baseUrl}/api/agents/credential-status`);
|
|
119
|
+
expect(resp.status).toBe(200);
|
|
120
|
+
const body = (await resp.json()) as {
|
|
121
|
+
agents: Array<{ agentId: string; status: string; missing: string[] }>;
|
|
122
|
+
};
|
|
123
|
+
expect(body.agents.length).toBeGreaterThanOrEqual(2);
|
|
124
|
+
const ids = body.agents.map((a) => a.agentId);
|
|
125
|
+
expect(ids).toContain(readyAgentId);
|
|
126
|
+
expect(ids).toContain(blockedAgentId);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("GET /api/agents/credential-status?status=waiting_for_credentials filters to blocked agents only", async () => {
|
|
130
|
+
const resp = await fetch(
|
|
131
|
+
`${baseUrl}/api/agents/credential-status?status=waiting_for_credentials`,
|
|
132
|
+
);
|
|
133
|
+
expect(resp.status).toBe(200);
|
|
134
|
+
const body = (await resp.json()) as {
|
|
135
|
+
agents: Array<{ agentId: string; status: string; missing: string[] }>;
|
|
136
|
+
};
|
|
137
|
+
const ids = body.agents.map((a) => a.agentId);
|
|
138
|
+
expect(ids).toContain(blockedAgentId);
|
|
139
|
+
expect(ids).not.toContain(readyAgentId);
|
|
140
|
+
for (const a of body.agents) {
|
|
141
|
+
expect(a.status).toBe("waiting_for_credentials");
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("PUT /api/agents/:id/credential-status (ready=true) clears the missing list", async () => {
|
|
146
|
+
const resp = await fetch(`${baseUrl}/api/agents/${blockedAgentId}/credential-status`, {
|
|
147
|
+
method: "PUT",
|
|
148
|
+
headers: { "Content-Type": "application/json" },
|
|
149
|
+
body: JSON.stringify({ ready: true }),
|
|
150
|
+
});
|
|
151
|
+
expect(resp.status).toBe(200);
|
|
152
|
+
const body = (await resp.json()) as {
|
|
153
|
+
status: string;
|
|
154
|
+
credentialMissing: string[] | null;
|
|
155
|
+
};
|
|
156
|
+
expect(body.status).toBe("idle");
|
|
157
|
+
expect(body.credentialMissing).toBeNull();
|
|
158
|
+
|
|
159
|
+
// Bulk endpoint should now report this agent as idle, not waiting.
|
|
160
|
+
const bulk = await fetch(`${baseUrl}/api/agents/credential-status`);
|
|
161
|
+
const bulkBody = (await bulk.json()) as {
|
|
162
|
+
agents: Array<{ agentId: string; status: string }>;
|
|
163
|
+
};
|
|
164
|
+
const blocked = bulkBody.agents.find((a) => a.agentId === blockedAgentId);
|
|
165
|
+
expect(blocked!.status).toBe("idle");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("GET single-agent endpoint returns 404 for unknown id", async () => {
|
|
169
|
+
const resp = await fetch(`${baseUrl}/api/agents/does-not-exist/credential-status`);
|
|
170
|
+
expect(resp.status).toBe(404);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("PUT 404 for unknown id", async () => {
|
|
174
|
+
const resp = await fetch(`${baseUrl}/api/agents/does-not-exist/credential-status`, {
|
|
175
|
+
method: "PUT",
|
|
176
|
+
headers: { "Content-Type": "application/json" },
|
|
177
|
+
body: JSON.stringify({ ready: false, missing: ["X"] }),
|
|
178
|
+
});
|
|
179
|
+
expect(resp.status).toBe(404);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Migration 055 — round-trip the new `cred_status` field.
|
|
183
|
+
test("PUT /credential-status round-trips a cred_status snapshot", async () => {
|
|
184
|
+
const snapshot = {
|
|
185
|
+
ready: true,
|
|
186
|
+
missing: [],
|
|
187
|
+
satisfiedBy: "env" as const,
|
|
188
|
+
hint: null,
|
|
189
|
+
liveTest: { ok: true, error: null, latency_ms: 91, testedAt: Date.now() },
|
|
190
|
+
reportedAt: Date.now(),
|
|
191
|
+
reportKind: "boot" as const,
|
|
192
|
+
};
|
|
193
|
+
const put = await fetch(`${baseUrl}/api/agents/${readyAgentId}/credential-status`, {
|
|
194
|
+
method: "PUT",
|
|
195
|
+
headers: { "Content-Type": "application/json" },
|
|
196
|
+
body: JSON.stringify({ ready: true, missing: [], cred_status: snapshot }),
|
|
197
|
+
});
|
|
198
|
+
expect(put.status).toBe(200);
|
|
199
|
+
|
|
200
|
+
const get = await fetch(`${baseUrl}/api/agents/${readyAgentId}/credential-status`);
|
|
201
|
+
const body = (await get.json()) as {
|
|
202
|
+
credStatus: typeof snapshot | null;
|
|
203
|
+
};
|
|
204
|
+
expect(body.credStatus).toMatchObject({
|
|
205
|
+
ready: true,
|
|
206
|
+
satisfiedBy: "env",
|
|
207
|
+
reportKind: "boot",
|
|
208
|
+
liveTest: { ok: true, latency_ms: 91 },
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("PUT rejects a malformed cred_status payload (Zod validation)", async () => {
|
|
213
|
+
const resp = await fetch(`${baseUrl}/api/agents/${readyAgentId}/credential-status`, {
|
|
214
|
+
method: "PUT",
|
|
215
|
+
headers: { "Content-Type": "application/json" },
|
|
216
|
+
body: JSON.stringify({
|
|
217
|
+
ready: true,
|
|
218
|
+
cred_status: { ready: "not-a-boolean", reportedAt: Date.now() }, // bad shape
|
|
219
|
+
}),
|
|
220
|
+
});
|
|
221
|
+
expect(resp.status).toBe(400);
|
|
222
|
+
});
|
|
223
|
+
});
|