@hellcoder/companion 0.96.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/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
- package/dist/assets/CronManager-EGwLJONv.js +1 -0
- package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
- package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
- package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
- package/dist/assets/Playground-BV3k0RbV.js +109 -0
- package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
- package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
- package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
- package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
- package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
- package/dist/assets/index-BhUa1e6X.css +1 -0
- package/dist/assets/index-DkqeP-R9.js +134 -0
- package/dist/assets/sw-register-BibwRdvC.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +20 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/sw.js +2 -0
- package/package.json +104 -0
- package/server/agent-cron-migrator.test.ts +610 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.test.ts +1108 -0
- package/server/agent-executor.ts +346 -0
- package/server/agent-store.test.ts +588 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-types.ts +138 -0
- package/server/ai-validation-settings.test.ts +128 -0
- package/server/ai-validation-settings.ts +35 -0
- package/server/ai-validator.test.ts +387 -0
- package/server/ai-validator.ts +271 -0
- package/server/auth-manager.test.ts +83 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-namer.test.ts +252 -0
- package/server/auto-namer.ts +78 -0
- package/server/backend-adapter.test.ts +38 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.test.ts +98 -0
- package/server/cache-headers.ts +61 -0
- package/server/claude-adapter.test.ts +1363 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.test.ts +44 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-protocol-contract.test.ts +71 -0
- package/server/claude-protocol-drift.test.ts +78 -0
- package/server/claude-session-discovery.test.ts +132 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.test.ts +158 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.test.ts +1343 -0
- package/server/cli-launcher.ts +1298 -0
- package/server/cli.test.ts +16 -0
- package/server/codex-adapter.test.ts +5545 -0
- package/server/codex-adapter.ts +3062 -0
- package/server/codex-container-auth.test.ts +50 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.test.ts +61 -0
- package/server/codex-home.ts +26 -0
- package/server/codex-protocol-contract.test.ts +96 -0
- package/server/codex-protocol-drift.test.ts +123 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.test.ts +179 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.test.ts +1211 -0
- package/server/container-manager.ts +1053 -0
- package/server/cron-scheduler.test.ts +957 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.test.ts +422 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/env-manager.test.ts +268 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +64 -0
- package/server/event-bus.test.ts +244 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.test.ts +307 -0
- package/server/execution-store.ts +170 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.test.ts +938 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.test.ts +498 -0
- package/server/github-pr.ts +379 -0
- package/server/image-pull-manager.test.ts +303 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +396 -0
- package/server/linear-agent-bridge.test.ts +1157 -0
- package/server/linear-agent-bridge.ts +629 -0
- package/server/linear-agent.test.ts +473 -0
- package/server/linear-agent.ts +479 -0
- package/server/linear-cache.test.ts +136 -0
- package/server/linear-cache.ts +113 -0
- package/server/linear-connections.test.ts +350 -0
- package/server/linear-connections.ts +231 -0
- package/server/linear-credential-migration.test.ts +337 -0
- package/server/linear-credential-migration.ts +63 -0
- package/server/linear-oauth-connections-migration.test.ts +268 -0
- package/server/linear-oauth-connections.test.ts +365 -0
- package/server/linear-oauth-connections.ts +294 -0
- package/server/linear-project-manager.test.ts +162 -0
- package/server/linear-project-manager.ts +111 -0
- package/server/linear-prompt-builder.test.ts +74 -0
- package/server/linear-prompt-builder.ts +61 -0
- package/server/linear-staging.test.ts +276 -0
- package/server/linear-staging.ts +142 -0
- package/server/logger.test.ts +393 -0
- package/server/logger.ts +259 -0
- package/server/metrics-collector.test.ts +413 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.test.ts +264 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.test.ts +333 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.test.ts +552 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.test.ts +31 -0
- package/server/paths.ts +11 -0
- package/server/pr-poller.test.ts +191 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.test.ts +211 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/recorder.test.ts +454 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.test.ts +150 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.test.ts +140 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.test.ts +44 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.test.ts +417 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.test.ts +262 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.test.ts +294 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.test.ts +337 -0
- package/server/relay-client.ts +320 -0
- package/server/replay.test.ts +200 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.test.ts +1400 -0
- package/server/routes/agent-routes.ts +409 -0
- package/server/routes/cron-routes.test.ts +881 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.test.ts +383 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/fs-routes.test.ts +1198 -0
- package/server/routes/fs-routes.ts +605 -0
- package/server/routes/git-routes.test.ts +813 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/linear-agent-routes.test.ts +721 -0
- package/server/routes/linear-agent-routes.ts +304 -0
- package/server/routes/linear-connection-routes.test.ts +927 -0
- package/server/routes/linear-connection-routes.ts +244 -0
- package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
- package/server/routes/linear-oauth-connection-routes.ts +129 -0
- package/server/routes/linear-routes.test.ts +1510 -0
- package/server/routes/linear-routes.ts +953 -0
- package/server/routes/metrics-routes.test.ts +103 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/sandbox-routes.test.ts +513 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +270 -0
- package/server/routes/skills-routes.test.ts +690 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/system-routes.test.ts +637 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.test.ts +176 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes.test.ts +4655 -0
- package/server/routes.ts +1277 -0
- package/server/sandbox-manager.test.ts +378 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.test.ts +1419 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.test.ts +661 -0
- package/server/session-creation-service.ts +473 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-linear-issues.test.ts +118 -0
- package/server/session-linear-issues.ts +88 -0
- package/server/session-names.test.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.test.ts +1784 -0
- package/server/session-orchestrator.ts +973 -0
- package/server/session-state-machine.test.ts +606 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.test.ts +290 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +509 -0
- package/server/settings-manager.test.ts +275 -0
- package/server/settings-manager.ts +173 -0
- package/server/tailscale-manager.test.ts +553 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.test.ts +306 -0
- package/server/update-checker.ts +197 -0
- package/server/usage-limits.test.ts +536 -0
- package/server/usage-limits.ts +225 -0
- package/server/worktree-tracker.test.ts +243 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.test.ts +59 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.test.ts +272 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.test.ts +302 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.test.ts +1837 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.test.ts +124 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.test.ts +296 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.test.ts +234 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.test.ts +44 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +106 -0
- package/server/ws-bridge.test.ts +4777 -0
- package/server/ws-bridge.ts +1279 -0
|
@@ -0,0 +1,1400 @@
|
|
|
1
|
+
import { vi, describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ─── Mock agent-store module ────────────────────────────────────────────────
|
|
4
|
+
// Mocked before imports so every `import` of agent-store gets the mock.
|
|
5
|
+
vi.mock("../agent-store.js", () => ({
|
|
6
|
+
listAgents: vi.fn(() => []),
|
|
7
|
+
getAgent: vi.fn(() => null),
|
|
8
|
+
createAgent: vi.fn(),
|
|
9
|
+
updateAgent: vi.fn(),
|
|
10
|
+
deleteAgent: vi.fn(() => false),
|
|
11
|
+
regenerateWebhookSecret: vi.fn(() => null),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
// ─── Mock settings-manager module ──────────────────────────────────────────
|
|
15
|
+
vi.mock("../settings-manager.js", () => ({
|
|
16
|
+
getSettings: vi.fn(() => ({
|
|
17
|
+
linearOAuthClientId: "",
|
|
18
|
+
linearOAuthClientSecret: "",
|
|
19
|
+
linearOAuthWebhookSecret: "",
|
|
20
|
+
linearOAuthAccessToken: "",
|
|
21
|
+
linearOAuthRefreshToken: "",
|
|
22
|
+
})),
|
|
23
|
+
updateSettings: vi.fn(),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
// ─── Mock linear-staging module ──────────────────────────────────────────────
|
|
27
|
+
// Mocked so agent creation tests can control staging slot resolution without
|
|
28
|
+
// touching the filesystem.
|
|
29
|
+
vi.mock("../linear-staging.js", () => ({
|
|
30
|
+
consumeSlot: vi.fn(() => null),
|
|
31
|
+
createSlot: vi.fn(),
|
|
32
|
+
getSlot: vi.fn(() => null),
|
|
33
|
+
updateSlotTokens: vi.fn(() => false),
|
|
34
|
+
deleteSlot: vi.fn(() => false),
|
|
35
|
+
pruneExpired: vi.fn(),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
// ─── Mock linear-oauth-connections module ────────────────────────────────────
|
|
39
|
+
// Mocked so agent creation tests involving staging slots can control OAuth
|
|
40
|
+
// connection creation without touching the filesystem.
|
|
41
|
+
vi.mock("../linear-oauth-connections.js", () => ({
|
|
42
|
+
getOAuthConnection: vi.fn(() => null),
|
|
43
|
+
createOAuthConnection: vi.fn((data: Record<string, unknown>) => ({
|
|
44
|
+
id: "mock-oauth-conn-id",
|
|
45
|
+
name: data.name || "Mock OAuth Connection",
|
|
46
|
+
oauthClientId: data.oauthClientId || "",
|
|
47
|
+
oauthClientSecret: data.oauthClientSecret || "",
|
|
48
|
+
webhookSecret: data.webhookSecret || "",
|
|
49
|
+
accessToken: data.accessToken || "",
|
|
50
|
+
refreshToken: data.refreshToken || "",
|
|
51
|
+
status: data.accessToken ? "connected" : "disconnected",
|
|
52
|
+
createdAt: Date.now(),
|
|
53
|
+
updatedAt: Date.now(),
|
|
54
|
+
})),
|
|
55
|
+
findOAuthConnectionByClientId: vi.fn(() => null),
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
import { Hono } from "hono";
|
|
59
|
+
import * as agentStore from "../agent-store.js";
|
|
60
|
+
import { getSettings, updateSettings } from "../settings-manager.js";
|
|
61
|
+
import * as staging from "../linear-staging.js";
|
|
62
|
+
import type { AgentConfig } from "../agent-types.js";
|
|
63
|
+
import { registerAgentRoutes } from "./agent-routes.js";
|
|
64
|
+
|
|
65
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/** Minimal agent fixture with sensible defaults. Override fields as needed. */
|
|
68
|
+
function makeAgent(overrides: Partial<AgentConfig> = {}): AgentConfig {
|
|
69
|
+
return {
|
|
70
|
+
id: "test-agent",
|
|
71
|
+
version: 1,
|
|
72
|
+
name: "Test Agent",
|
|
73
|
+
description: "A test agent",
|
|
74
|
+
backendType: "claude",
|
|
75
|
+
model: "claude-sonnet-4-6",
|
|
76
|
+
permissionMode: "bypassPermissions",
|
|
77
|
+
cwd: "/tmp/test",
|
|
78
|
+
prompt: "Do something useful",
|
|
79
|
+
enabled: true,
|
|
80
|
+
createdAt: 1000,
|
|
81
|
+
updatedAt: 2000,
|
|
82
|
+
totalRuns: 0,
|
|
83
|
+
consecutiveFailures: 0,
|
|
84
|
+
...overrides,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Build a mock AgentExecutor with vi.fn() stubs for every method the routes use. */
|
|
89
|
+
function createMockExecutor() {
|
|
90
|
+
return {
|
|
91
|
+
getNextRunTime: vi.fn(() => null as Date | null),
|
|
92
|
+
scheduleAgent: vi.fn(),
|
|
93
|
+
stopAgent: vi.fn(),
|
|
94
|
+
executeAgentManually: vi.fn(),
|
|
95
|
+
getExecutions: vi.fn(() => []),
|
|
96
|
+
listAllExecutions: vi.fn(() => ({ executions: [] as Record<string, unknown>[], total: 0 })),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── Test setup ─────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
let app: Hono;
|
|
103
|
+
let executor: ReturnType<typeof createMockExecutor>;
|
|
104
|
+
|
|
105
|
+
beforeEach(() => {
|
|
106
|
+
vi.clearAllMocks();
|
|
107
|
+
|
|
108
|
+
executor = createMockExecutor();
|
|
109
|
+
|
|
110
|
+
// Create a Hono app and mount agent routes under /api
|
|
111
|
+
app = new Hono();
|
|
112
|
+
const api = new Hono();
|
|
113
|
+
registerAgentRoutes(api, executor as any);
|
|
114
|
+
app.route("/api", api);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ─── GET /api/agents ────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
describe("GET /api/agents", () => {
|
|
120
|
+
it("returns an empty list when no agents exist", async () => {
|
|
121
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([]);
|
|
122
|
+
|
|
123
|
+
const res = await app.request("/api/agents");
|
|
124
|
+
|
|
125
|
+
expect(res.status).toBe(200);
|
|
126
|
+
const json = await res.json();
|
|
127
|
+
expect(json).toEqual([]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("returns the list of agents enriched with nextRunAt", async () => {
|
|
131
|
+
const agent = makeAgent();
|
|
132
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([agent]);
|
|
133
|
+
const nextRun = new Date("2026-03-01T00:00:00Z");
|
|
134
|
+
executor.getNextRunTime.mockReturnValue(nextRun);
|
|
135
|
+
|
|
136
|
+
const res = await app.request("/api/agents");
|
|
137
|
+
|
|
138
|
+
expect(res.status).toBe(200);
|
|
139
|
+
const json = await res.json();
|
|
140
|
+
expect(json).toHaveLength(1);
|
|
141
|
+
expect(json[0].id).toBe("test-agent");
|
|
142
|
+
// nextRunAt should be the epoch ms of the returned Date
|
|
143
|
+
expect(json[0].nextRunAt).toBe(nextRun.getTime());
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ─── POST /api/agents ───────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
describe("POST /api/agents", () => {
|
|
150
|
+
it("creates an agent and returns 201", async () => {
|
|
151
|
+
const created = makeAgent({ id: "my-agent", name: "My Agent" });
|
|
152
|
+
vi.mocked(agentStore.createAgent).mockReturnValue(created);
|
|
153
|
+
|
|
154
|
+
const res = await app.request("/api/agents", {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers: { "Content-Type": "application/json" },
|
|
157
|
+
body: JSON.stringify({ name: "My Agent", prompt: "Hello" }),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(res.status).toBe(201);
|
|
161
|
+
const json = await res.json();
|
|
162
|
+
expect(json.id).toBe("my-agent");
|
|
163
|
+
expect(agentStore.createAgent).toHaveBeenCalledTimes(1);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("returns 400 when the store throws a validation error", async () => {
|
|
167
|
+
// e.g. missing name
|
|
168
|
+
vi.mocked(agentStore.createAgent).mockImplementation(() => {
|
|
169
|
+
throw new Error("Agent name is required");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const res = await app.request("/api/agents", {
|
|
173
|
+
method: "POST",
|
|
174
|
+
headers: { "Content-Type": "application/json" },
|
|
175
|
+
body: JSON.stringify({}),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(res.status).toBe(400);
|
|
179
|
+
const json = await res.json();
|
|
180
|
+
expect(json.error).toBe("Agent name is required");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("schedules the agent when enabled with a schedule trigger", async () => {
|
|
184
|
+
const created = makeAgent({
|
|
185
|
+
enabled: true,
|
|
186
|
+
triggers: {
|
|
187
|
+
schedule: { enabled: true, expression: "*/5 * * * *", recurring: true },
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
vi.mocked(agentStore.createAgent).mockReturnValue(created);
|
|
191
|
+
|
|
192
|
+
await app.request("/api/agents", {
|
|
193
|
+
method: "POST",
|
|
194
|
+
headers: { "Content-Type": "application/json" },
|
|
195
|
+
body: JSON.stringify({
|
|
196
|
+
name: "Scheduled Agent",
|
|
197
|
+
prompt: "Run periodically",
|
|
198
|
+
triggers: { schedule: { enabled: true, expression: "*/5 * * * *", recurring: true } },
|
|
199
|
+
}),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(executor.scheduleAgent).toHaveBeenCalledWith(created);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// ─── GET /api/agents/:id ────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
describe("GET /api/agents/:id", () => {
|
|
209
|
+
it("returns the agent when it exists", async () => {
|
|
210
|
+
const agent = makeAgent({ id: "existing" });
|
|
211
|
+
vi.mocked(agentStore.getAgent).mockReturnValue(agent);
|
|
212
|
+
|
|
213
|
+
const res = await app.request("/api/agents/existing");
|
|
214
|
+
|
|
215
|
+
expect(res.status).toBe(200);
|
|
216
|
+
const json = await res.json();
|
|
217
|
+
expect(json.id).toBe("existing");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("returns 404 when the agent does not exist", async () => {
|
|
221
|
+
vi.mocked(agentStore.getAgent).mockReturnValue(null);
|
|
222
|
+
|
|
223
|
+
const res = await app.request("/api/agents/nonexistent");
|
|
224
|
+
|
|
225
|
+
expect(res.status).toBe(404);
|
|
226
|
+
const json = await res.json();
|
|
227
|
+
expect(json.error).toBe("Agent not found");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("enriches the agent with nextRunAt from the executor", async () => {
|
|
231
|
+
const agent = makeAgent({ id: "scheduled" });
|
|
232
|
+
vi.mocked(agentStore.getAgent).mockReturnValue(agent);
|
|
233
|
+
const nextRun = new Date("2026-06-15T12:00:00Z");
|
|
234
|
+
executor.getNextRunTime.mockReturnValue(nextRun);
|
|
235
|
+
|
|
236
|
+
const res = await app.request("/api/agents/scheduled");
|
|
237
|
+
|
|
238
|
+
const json = await res.json();
|
|
239
|
+
expect(json.nextRunAt).toBe(nextRun.getTime());
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// ─── PUT /api/agents/:id ────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
describe("PUT /api/agents/:id", () => {
|
|
246
|
+
it("updates the agent and returns the updated version", async () => {
|
|
247
|
+
const updated = makeAgent({ id: "test-agent", name: "Updated Name" });
|
|
248
|
+
vi.mocked(agentStore.updateAgent).mockReturnValue(updated);
|
|
249
|
+
|
|
250
|
+
const res = await app.request("/api/agents/test-agent", {
|
|
251
|
+
method: "PUT",
|
|
252
|
+
headers: { "Content-Type": "application/json" },
|
|
253
|
+
body: JSON.stringify({ name: "Updated Name" }),
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
expect(res.status).toBe(200);
|
|
257
|
+
const json = await res.json();
|
|
258
|
+
expect(json.name).toBe("Updated Name");
|
|
259
|
+
// Should only pass editable fields to updateAgent
|
|
260
|
+
expect(agentStore.updateAgent).toHaveBeenCalledWith(
|
|
261
|
+
"test-agent",
|
|
262
|
+
expect.objectContaining({ name: "Updated Name" }),
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("returns 404 when agent does not exist", async () => {
|
|
267
|
+
vi.mocked(agentStore.updateAgent).mockReturnValue(null);
|
|
268
|
+
|
|
269
|
+
const res = await app.request("/api/agents/nonexistent", {
|
|
270
|
+
method: "PUT",
|
|
271
|
+
headers: { "Content-Type": "application/json" },
|
|
272
|
+
body: JSON.stringify({ name: "Nope" }),
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
expect(res.status).toBe(404);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("strips non-editable fields from the update payload", async () => {
|
|
279
|
+
// Fields like 'id', 'createdAt', 'totalRuns' should NOT be passed through
|
|
280
|
+
const updated = makeAgent();
|
|
281
|
+
vi.mocked(agentStore.updateAgent).mockReturnValue(updated);
|
|
282
|
+
|
|
283
|
+
await app.request("/api/agents/test-agent", {
|
|
284
|
+
method: "PUT",
|
|
285
|
+
headers: { "Content-Type": "application/json" },
|
|
286
|
+
body: JSON.stringify({
|
|
287
|
+
name: "Good Field",
|
|
288
|
+
id: "hacked-id",
|
|
289
|
+
createdAt: 9999,
|
|
290
|
+
totalRuns: 999,
|
|
291
|
+
}),
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const passedUpdates = vi.mocked(agentStore.updateAgent).mock.calls[0][1];
|
|
295
|
+
expect(passedUpdates).toHaveProperty("name", "Good Field");
|
|
296
|
+
// Non-editable fields should be stripped by pickEditable
|
|
297
|
+
expect(passedUpdates).not.toHaveProperty("id");
|
|
298
|
+
expect(passedUpdates).not.toHaveProperty("createdAt");
|
|
299
|
+
expect(passedUpdates).not.toHaveProperty("totalRuns");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("reschedules the agent when schedule trigger is enabled", async () => {
|
|
303
|
+
const updated = makeAgent({
|
|
304
|
+
enabled: true,
|
|
305
|
+
triggers: {
|
|
306
|
+
schedule: { enabled: true, expression: "0 * * * *", recurring: true },
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
vi.mocked(agentStore.updateAgent).mockReturnValue(updated);
|
|
310
|
+
|
|
311
|
+
await app.request("/api/agents/test-agent", {
|
|
312
|
+
method: "PUT",
|
|
313
|
+
headers: { "Content-Type": "application/json" },
|
|
314
|
+
body: JSON.stringify({ name: "Test Agent" }),
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
expect(executor.scheduleAgent).toHaveBeenCalledWith(updated);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("stops the agent schedule when disabled", async () => {
|
|
321
|
+
const updated = makeAgent({ enabled: false });
|
|
322
|
+
vi.mocked(agentStore.updateAgent).mockReturnValue(updated);
|
|
323
|
+
|
|
324
|
+
await app.request("/api/agents/test-agent", {
|
|
325
|
+
method: "PUT",
|
|
326
|
+
headers: { "Content-Type": "application/json" },
|
|
327
|
+
body: JSON.stringify({ enabled: false }),
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
expect(executor.stopAgent).toHaveBeenCalledWith(updated.id);
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// ─── DELETE /api/agents/:id ─────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
describe("DELETE /api/agents/:id", () => {
|
|
337
|
+
it("deletes an existing agent and stops its executor", async () => {
|
|
338
|
+
vi.mocked(agentStore.deleteAgent).mockReturnValue(true);
|
|
339
|
+
|
|
340
|
+
const res = await app.request("/api/agents/test-agent", { method: "DELETE" });
|
|
341
|
+
|
|
342
|
+
expect(res.status).toBe(200);
|
|
343
|
+
const json = await res.json();
|
|
344
|
+
expect(json.ok).toBe(true);
|
|
345
|
+
expect(executor.stopAgent).toHaveBeenCalledWith("test-agent");
|
|
346
|
+
expect(agentStore.deleteAgent).toHaveBeenCalledWith("test-agent");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("returns 404 when agent does not exist", async () => {
|
|
350
|
+
vi.mocked(agentStore.deleteAgent).mockReturnValue(false);
|
|
351
|
+
|
|
352
|
+
const res = await app.request("/api/agents/nonexistent", { method: "DELETE" });
|
|
353
|
+
|
|
354
|
+
expect(res.status).toBe(404);
|
|
355
|
+
const json = await res.json();
|
|
356
|
+
expect(json.error).toBe("Agent not found");
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// ─── POST /api/agents/:id/toggle ───────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
describe("POST /api/agents/:id/toggle", () => {
|
|
363
|
+
it("toggles an enabled agent to disabled", async () => {
|
|
364
|
+
const agent = makeAgent({ id: "my-agent", enabled: true });
|
|
365
|
+
const toggled = makeAgent({ id: "my-agent", enabled: false });
|
|
366
|
+
vi.mocked(agentStore.getAgent).mockReturnValue(agent);
|
|
367
|
+
vi.mocked(agentStore.updateAgent).mockReturnValue(toggled);
|
|
368
|
+
|
|
369
|
+
const res = await app.request("/api/agents/my-agent/toggle", { method: "POST" });
|
|
370
|
+
|
|
371
|
+
expect(res.status).toBe(200);
|
|
372
|
+
const json = await res.json();
|
|
373
|
+
expect(json.enabled).toBe(false);
|
|
374
|
+
// Should have called updateAgent with enabled: false (opposite of current)
|
|
375
|
+
expect(agentStore.updateAgent).toHaveBeenCalledWith("my-agent", { enabled: false });
|
|
376
|
+
// When toggled off, should stop the agent
|
|
377
|
+
expect(executor.stopAgent).toHaveBeenCalledWith("my-agent");
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("toggles a disabled agent to enabled and reschedules if schedule trigger active", async () => {
|
|
381
|
+
const agent = makeAgent({ id: "my-agent", enabled: false });
|
|
382
|
+
const toggled = makeAgent({
|
|
383
|
+
id: "my-agent",
|
|
384
|
+
enabled: true,
|
|
385
|
+
triggers: {
|
|
386
|
+
schedule: { enabled: true, expression: "0 * * * *", recurring: true },
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
vi.mocked(agentStore.getAgent).mockReturnValue(agent);
|
|
390
|
+
vi.mocked(agentStore.updateAgent).mockReturnValue(toggled);
|
|
391
|
+
|
|
392
|
+
const res = await app.request("/api/agents/my-agent/toggle", { method: "POST" });
|
|
393
|
+
|
|
394
|
+
expect(res.status).toBe(200);
|
|
395
|
+
const json = await res.json();
|
|
396
|
+
expect(json.enabled).toBe(true);
|
|
397
|
+
expect(executor.scheduleAgent).toHaveBeenCalledWith(toggled);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("returns 404 when agent does not exist", async () => {
|
|
401
|
+
vi.mocked(agentStore.getAgent).mockReturnValue(null);
|
|
402
|
+
|
|
403
|
+
const res = await app.request("/api/agents/nonexistent/toggle", { method: "POST" });
|
|
404
|
+
|
|
405
|
+
expect(res.status).toBe(404);
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// ─── POST /api/agents/:id/run ───────────────────────────────────────────────
|
|
410
|
+
|
|
411
|
+
describe("POST /api/agents/:id/run", () => {
|
|
412
|
+
it("triggers a manual agent run", async () => {
|
|
413
|
+
const agent = makeAgent({ id: "runner" });
|
|
414
|
+
vi.mocked(agentStore.getAgent).mockReturnValue(agent);
|
|
415
|
+
|
|
416
|
+
const res = await app.request("/api/agents/runner/run", {
|
|
417
|
+
method: "POST",
|
|
418
|
+
headers: { "Content-Type": "application/json" },
|
|
419
|
+
body: JSON.stringify({}),
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
expect(res.status).toBe(200);
|
|
423
|
+
const json = await res.json();
|
|
424
|
+
expect(json.ok).toBe(true);
|
|
425
|
+
expect(json.message).toBe("Agent triggered");
|
|
426
|
+
expect(executor.executeAgentManually).toHaveBeenCalledWith("runner", undefined);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("passes an input string to the executor when provided", async () => {
|
|
430
|
+
const agent = makeAgent({ id: "runner" });
|
|
431
|
+
vi.mocked(agentStore.getAgent).mockReturnValue(agent);
|
|
432
|
+
|
|
433
|
+
await app.request("/api/agents/runner/run", {
|
|
434
|
+
method: "POST",
|
|
435
|
+
headers: { "Content-Type": "application/json" },
|
|
436
|
+
body: JSON.stringify({ input: "custom input" }),
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
expect(executor.executeAgentManually).toHaveBeenCalledWith("runner", "custom input");
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("returns 404 when agent does not exist", async () => {
|
|
443
|
+
vi.mocked(agentStore.getAgent).mockReturnValue(null);
|
|
444
|
+
|
|
445
|
+
const res = await app.request("/api/agents/nonexistent/run", {
|
|
446
|
+
method: "POST",
|
|
447
|
+
headers: { "Content-Type": "application/json" },
|
|
448
|
+
body: JSON.stringify({}),
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
expect(res.status).toBe(404);
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// ─── POST /api/agents/import ────────────────────────────────────────────────
|
|
456
|
+
|
|
457
|
+
describe("POST /api/agents/import", () => {
|
|
458
|
+
it("imports an agent from exported JSON and returns 201 with enabled=false", async () => {
|
|
459
|
+
// Import should always set enabled to false for safety
|
|
460
|
+
const importedAgent = makeAgent({ id: "imported", name: "Imported Agent", enabled: false });
|
|
461
|
+
vi.mocked(agentStore.createAgent).mockReturnValue(importedAgent);
|
|
462
|
+
|
|
463
|
+
const res = await app.request("/api/agents/import", {
|
|
464
|
+
method: "POST",
|
|
465
|
+
headers: { "Content-Type": "application/json" },
|
|
466
|
+
body: JSON.stringify({
|
|
467
|
+
name: "Imported Agent",
|
|
468
|
+
prompt: "Do stuff",
|
|
469
|
+
backendType: "claude",
|
|
470
|
+
model: "claude-sonnet-4-6",
|
|
471
|
+
permissionMode: "bypassPermissions",
|
|
472
|
+
cwd: "/tmp",
|
|
473
|
+
}),
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
expect(res.status).toBe(201);
|
|
477
|
+
const json = await res.json();
|
|
478
|
+
expect(json.enabled).toBe(false);
|
|
479
|
+
// createAgent should be called with enabled: false (safety)
|
|
480
|
+
expect(agentStore.createAgent).toHaveBeenCalledWith(
|
|
481
|
+
expect.objectContaining({ enabled: false }),
|
|
482
|
+
);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("returns 400 when store throws a validation error", async () => {
|
|
486
|
+
vi.mocked(agentStore.createAgent).mockImplementation(() => {
|
|
487
|
+
throw new Error("Agent name is required");
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
const res = await app.request("/api/agents/import", {
|
|
491
|
+
method: "POST",
|
|
492
|
+
headers: { "Content-Type": "application/json" },
|
|
493
|
+
body: JSON.stringify({}),
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
expect(res.status).toBe(400);
|
|
497
|
+
const json = await res.json();
|
|
498
|
+
expect(json.error).toBe("Agent name is required");
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("preserves provided import version metadata", async () => {
|
|
502
|
+
// Imported payload version should flow through to createAgent instead of being reset.
|
|
503
|
+
const importedAgent = makeAgent({ id: "imported-v2", name: "Imported V2", enabled: false });
|
|
504
|
+
vi.mocked(agentStore.createAgent).mockReturnValue(importedAgent);
|
|
505
|
+
|
|
506
|
+
const res = await app.request("/api/agents/import", {
|
|
507
|
+
method: "POST",
|
|
508
|
+
headers: { "Content-Type": "application/json" },
|
|
509
|
+
body: JSON.stringify({
|
|
510
|
+
version: 2,
|
|
511
|
+
name: "Imported V2",
|
|
512
|
+
prompt: "Do stuff",
|
|
513
|
+
backendType: "claude",
|
|
514
|
+
model: "claude-sonnet-4-6",
|
|
515
|
+
permissionMode: "bypassPermissions",
|
|
516
|
+
cwd: "/tmp",
|
|
517
|
+
}),
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
expect(res.status).toBe(201);
|
|
521
|
+
expect(agentStore.createAgent).toHaveBeenCalledWith(
|
|
522
|
+
expect.objectContaining({ version: 2, enabled: false }),
|
|
523
|
+
);
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// ─── GET /api/agents/:id/export ─────────────────────────────────────────────
|
|
528
|
+
|
|
529
|
+
describe("GET /api/agents/:id/export", () => {
|
|
530
|
+
it("exports an agent as JSON without internal tracking fields", async () => {
|
|
531
|
+
const agent = makeAgent({
|
|
532
|
+
id: "exportable",
|
|
533
|
+
name: "Exportable Agent",
|
|
534
|
+
totalRuns: 42,
|
|
535
|
+
consecutiveFailures: 2,
|
|
536
|
+
lastRunAt: 3000,
|
|
537
|
+
lastSessionId: "sess-xyz",
|
|
538
|
+
});
|
|
539
|
+
vi.mocked(agentStore.getAgent).mockReturnValue(agent);
|
|
540
|
+
|
|
541
|
+
const res = await app.request("/api/agents/exportable/export");
|
|
542
|
+
|
|
543
|
+
expect(res.status).toBe(200);
|
|
544
|
+
const json = await res.json();
|
|
545
|
+
// Should include portable config fields
|
|
546
|
+
expect(json.name).toBe("Exportable Agent");
|
|
547
|
+
expect(json.prompt).toBe("Do something useful");
|
|
548
|
+
// Should NOT include internal tracking fields
|
|
549
|
+
expect(json).not.toHaveProperty("id");
|
|
550
|
+
expect(json).not.toHaveProperty("createdAt");
|
|
551
|
+
expect(json).not.toHaveProperty("updatedAt");
|
|
552
|
+
expect(json).not.toHaveProperty("totalRuns");
|
|
553
|
+
expect(json).not.toHaveProperty("consecutiveFailures");
|
|
554
|
+
expect(json).not.toHaveProperty("lastRunAt");
|
|
555
|
+
expect(json).not.toHaveProperty("lastSessionId");
|
|
556
|
+
expect(json).not.toHaveProperty("enabled");
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("returns 404 when agent does not exist", async () => {
|
|
560
|
+
vi.mocked(agentStore.getAgent).mockReturnValue(null);
|
|
561
|
+
|
|
562
|
+
const res = await app.request("/api/agents/nonexistent/export");
|
|
563
|
+
|
|
564
|
+
expect(res.status).toBe(404);
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
// ─── POST /api/agents/:id/webhook/:secret ───────────────────────────────────
|
|
569
|
+
|
|
570
|
+
describe("POST /api/agents/:id/webhook/:secret", () => {
|
|
571
|
+
it("triggers the agent via webhook with a valid secret", async () => {
|
|
572
|
+
const agent = makeAgent({
|
|
573
|
+
id: "webhook-agent",
|
|
574
|
+
triggers: {
|
|
575
|
+
webhook: { enabled: true, secret: "valid-secret-123" },
|
|
576
|
+
},
|
|
577
|
+
});
|
|
578
|
+
vi.mocked(agentStore.getAgent).mockReturnValue(agent);
|
|
579
|
+
|
|
580
|
+
const res = await app.request("/api/agents/webhook-agent/webhook/valid-secret-123", {
|
|
581
|
+
method: "POST",
|
|
582
|
+
headers: { "Content-Type": "application/json" },
|
|
583
|
+
body: JSON.stringify({ input: "webhook payload" }),
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
expect(res.status).toBe(200);
|
|
587
|
+
const json = await res.json();
|
|
588
|
+
expect(json.ok).toBe(true);
|
|
589
|
+
expect(json.message).toBe("Agent triggered via webhook");
|
|
590
|
+
expect(executor.executeAgentManually).toHaveBeenCalledWith("webhook-agent", "webhook payload");
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it("returns 401 when the webhook secret is invalid", async () => {
|
|
594
|
+
const agent = makeAgent({
|
|
595
|
+
id: "webhook-agent",
|
|
596
|
+
triggers: {
|
|
597
|
+
webhook: { enabled: true, secret: "correct-secret" },
|
|
598
|
+
},
|
|
599
|
+
});
|
|
600
|
+
vi.mocked(agentStore.getAgent).mockReturnValue(agent);
|
|
601
|
+
|
|
602
|
+
const res = await app.request("/api/agents/webhook-agent/webhook/wrong-secret", {
|
|
603
|
+
method: "POST",
|
|
604
|
+
headers: { "Content-Type": "application/json" },
|
|
605
|
+
body: JSON.stringify({}),
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
expect(res.status).toBe(401);
|
|
609
|
+
const json = await res.json();
|
|
610
|
+
expect(json.error).toBe("Invalid webhook secret");
|
|
611
|
+
// Should NOT trigger the agent
|
|
612
|
+
expect(executor.executeAgentManually).not.toHaveBeenCalled();
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it("returns 403 when the webhook trigger is disabled", async () => {
|
|
616
|
+
const agent = makeAgent({
|
|
617
|
+
id: "webhook-agent",
|
|
618
|
+
triggers: {
|
|
619
|
+
webhook: { enabled: false, secret: "some-secret" },
|
|
620
|
+
},
|
|
621
|
+
});
|
|
622
|
+
vi.mocked(agentStore.getAgent).mockReturnValue(agent);
|
|
623
|
+
|
|
624
|
+
const res = await app.request("/api/agents/webhook-agent/webhook/some-secret", {
|
|
625
|
+
method: "POST",
|
|
626
|
+
headers: { "Content-Type": "application/json" },
|
|
627
|
+
body: JSON.stringify({}),
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
expect(res.status).toBe(403);
|
|
631
|
+
const json = await res.json();
|
|
632
|
+
expect(json.error).toBe("Webhook not enabled for this agent");
|
|
633
|
+
expect(executor.executeAgentManually).not.toHaveBeenCalled();
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
it("returns 404 when agent does not exist", async () => {
|
|
637
|
+
vi.mocked(agentStore.getAgent).mockReturnValue(null);
|
|
638
|
+
|
|
639
|
+
const res = await app.request("/api/agents/nonexistent/webhook/any-secret", {
|
|
640
|
+
method: "POST",
|
|
641
|
+
headers: { "Content-Type": "application/json" },
|
|
642
|
+
body: JSON.stringify({}),
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
expect(res.status).toBe(404);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it("accepts plain text body as webhook input", async () => {
|
|
649
|
+
// The webhook endpoint should also accept plain text (non-JSON) as input
|
|
650
|
+
const agent = makeAgent({
|
|
651
|
+
id: "webhook-agent",
|
|
652
|
+
triggers: {
|
|
653
|
+
webhook: { enabled: true, secret: "valid-secret" },
|
|
654
|
+
},
|
|
655
|
+
});
|
|
656
|
+
vi.mocked(agentStore.getAgent).mockReturnValue(agent);
|
|
657
|
+
|
|
658
|
+
const res = await app.request("/api/agents/webhook-agent/webhook/valid-secret", {
|
|
659
|
+
method: "POST",
|
|
660
|
+
headers: { "Content-Type": "text/plain" },
|
|
661
|
+
body: "plain text input",
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
expect(res.status).toBe(200);
|
|
665
|
+
expect(executor.executeAgentManually).toHaveBeenCalledWith("webhook-agent", "plain text input");
|
|
666
|
+
});
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
// ─── POST /api/agents — credential staging ─────────────────────────────────
|
|
670
|
+
|
|
671
|
+
describe("POST /api/agents — Linear credential staging", () => {
|
|
672
|
+
it("copies global OAuth credentials to the agent and clears them from settings when creating a Linear agent", async () => {
|
|
673
|
+
// When a Linear agent is created with no credentials on the agent itself,
|
|
674
|
+
// the route should copy staged credentials from global settings to the agent
|
|
675
|
+
// and then clear them from global settings (one-time staging flow).
|
|
676
|
+
const createdAgent = makeAgent({
|
|
677
|
+
id: "linear-agent",
|
|
678
|
+
name: "Linear Agent",
|
|
679
|
+
triggers: {
|
|
680
|
+
linear: { enabled: true },
|
|
681
|
+
},
|
|
682
|
+
});
|
|
683
|
+
vi.mocked(agentStore.createAgent).mockReturnValue(createdAgent);
|
|
684
|
+
|
|
685
|
+
// Simulate global settings with staged OAuth credentials
|
|
686
|
+
vi.mocked(getSettings).mockReturnValue({
|
|
687
|
+
linearOAuthClientId: "client-id-123",
|
|
688
|
+
linearOAuthClientSecret: "client-secret-456",
|
|
689
|
+
linearOAuthWebhookSecret: "webhook-secret-789",
|
|
690
|
+
linearOAuthAccessToken: "access-token-abc",
|
|
691
|
+
linearOAuthRefreshToken: "refresh-token-def",
|
|
692
|
+
} as any);
|
|
693
|
+
|
|
694
|
+
// updateAgent returns the agent with credentials merged
|
|
695
|
+
const updatedAgent = makeAgent({
|
|
696
|
+
id: "linear-agent",
|
|
697
|
+
name: "Linear Agent",
|
|
698
|
+
triggers: {
|
|
699
|
+
linear: {
|
|
700
|
+
enabled: true,
|
|
701
|
+
oauthClientId: "client-id-123",
|
|
702
|
+
oauthClientSecret: "client-secret-456",
|
|
703
|
+
webhookSecret: "webhook-secret-789",
|
|
704
|
+
accessToken: "access-token-abc",
|
|
705
|
+
refreshToken: "refresh-token-def",
|
|
706
|
+
},
|
|
707
|
+
},
|
|
708
|
+
});
|
|
709
|
+
vi.mocked(agentStore.updateAgent).mockReturnValue(updatedAgent);
|
|
710
|
+
|
|
711
|
+
const res = await app.request("/api/agents", {
|
|
712
|
+
method: "POST",
|
|
713
|
+
headers: { "Content-Type": "application/json" },
|
|
714
|
+
body: JSON.stringify({
|
|
715
|
+
name: "Linear Agent",
|
|
716
|
+
prompt: "Handle linear issues",
|
|
717
|
+
triggers: { linear: { enabled: true } },
|
|
718
|
+
}),
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
expect(res.status).toBe(201);
|
|
722
|
+
const json = await res.json();
|
|
723
|
+
|
|
724
|
+
// updateAgent should have been called to copy credentials to the agent
|
|
725
|
+
expect(agentStore.updateAgent).toHaveBeenCalledWith("linear-agent", {
|
|
726
|
+
triggers: {
|
|
727
|
+
linear: {
|
|
728
|
+
enabled: true,
|
|
729
|
+
oauthClientId: "client-id-123",
|
|
730
|
+
oauthClientSecret: "client-secret-456",
|
|
731
|
+
webhookSecret: "webhook-secret-789",
|
|
732
|
+
accessToken: "access-token-abc",
|
|
733
|
+
refreshToken: "refresh-token-def",
|
|
734
|
+
},
|
|
735
|
+
},
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
// Global settings should have been cleared after staging
|
|
739
|
+
expect(updateSettings).toHaveBeenCalledWith({
|
|
740
|
+
linearOAuthClientId: "",
|
|
741
|
+
linearOAuthClientSecret: "",
|
|
742
|
+
linearOAuthWebhookSecret: "",
|
|
743
|
+
linearOAuthAccessToken: "",
|
|
744
|
+
linearOAuthRefreshToken: "",
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
// The response should have the sanitized agent (secrets stripped, boolean flags present)
|
|
748
|
+
expect(json).not.toHaveProperty("triggers.linear.oauthClientSecret");
|
|
749
|
+
expect(json).not.toHaveProperty("triggers.linear.accessToken");
|
|
750
|
+
expect(json).not.toHaveProperty("triggers.linear.refreshToken");
|
|
751
|
+
expect(json).not.toHaveProperty("triggers.linear.webhookSecret");
|
|
752
|
+
expect(json.triggers.linear.hasAccessToken).toBe(true);
|
|
753
|
+
expect(json.triggers.linear.hasClientSecret).toBe(true);
|
|
754
|
+
expect(json.triggers.linear.hasWebhookSecret).toBe(true);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it("does not stage credentials when global settings have no linearOAuthClientId", async () => {
|
|
758
|
+
// When the global settings have no staged OAuth client ID, the normal creation
|
|
759
|
+
// flow should proceed without any credential copying or settings clearing.
|
|
760
|
+
const createdAgent = makeAgent({
|
|
761
|
+
id: "linear-agent-no-creds",
|
|
762
|
+
name: "Linear Agent No Creds",
|
|
763
|
+
triggers: {
|
|
764
|
+
linear: { enabled: true },
|
|
765
|
+
},
|
|
766
|
+
});
|
|
767
|
+
vi.mocked(agentStore.createAgent).mockReturnValue(createdAgent);
|
|
768
|
+
|
|
769
|
+
// Global settings have no staged credentials (empty strings)
|
|
770
|
+
vi.mocked(getSettings).mockReturnValue({
|
|
771
|
+
linearOAuthClientId: "",
|
|
772
|
+
linearOAuthClientSecret: "",
|
|
773
|
+
linearOAuthWebhookSecret: "",
|
|
774
|
+
linearOAuthAccessToken: "",
|
|
775
|
+
linearOAuthRefreshToken: "",
|
|
776
|
+
} as any);
|
|
777
|
+
|
|
778
|
+
const res = await app.request("/api/agents", {
|
|
779
|
+
method: "POST",
|
|
780
|
+
headers: { "Content-Type": "application/json" },
|
|
781
|
+
body: JSON.stringify({
|
|
782
|
+
name: "Linear Agent No Creds",
|
|
783
|
+
prompt: "Handle linear issues",
|
|
784
|
+
triggers: { linear: { enabled: true } },
|
|
785
|
+
}),
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
expect(res.status).toBe(201);
|
|
789
|
+
// updateAgent should NOT have been called for credential staging
|
|
790
|
+
expect(agentStore.updateAgent).not.toHaveBeenCalled();
|
|
791
|
+
// updateSettings should NOT have been called to clear staging creds
|
|
792
|
+
expect(updateSettings).not.toHaveBeenCalled();
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
it("does NOT clear global settings when updateAgent fails during credential staging", async () => {
|
|
796
|
+
// If updateAgent returns null (store failure), the global OAuth credentials
|
|
797
|
+
// must NOT be cleared — otherwise the user's credentials are silently lost.
|
|
798
|
+
const createdAgent = makeAgent({
|
|
799
|
+
id: "linear-fail",
|
|
800
|
+
name: "Linear Fail",
|
|
801
|
+
triggers: {
|
|
802
|
+
linear: { enabled: true },
|
|
803
|
+
},
|
|
804
|
+
});
|
|
805
|
+
vi.mocked(agentStore.createAgent).mockReturnValue(createdAgent);
|
|
806
|
+
|
|
807
|
+
vi.mocked(getSettings).mockReturnValue({
|
|
808
|
+
linearOAuthClientId: "client-id-staged",
|
|
809
|
+
linearOAuthClientSecret: "secret-staged",
|
|
810
|
+
linearOAuthWebhookSecret: "webhook-staged",
|
|
811
|
+
linearOAuthAccessToken: "access-staged",
|
|
812
|
+
linearOAuthRefreshToken: "refresh-staged",
|
|
813
|
+
} as any);
|
|
814
|
+
|
|
815
|
+
// Simulate updateAgent failure (returns null)
|
|
816
|
+
vi.mocked(agentStore.updateAgent).mockReturnValue(null);
|
|
817
|
+
|
|
818
|
+
const res = await app.request("/api/agents", {
|
|
819
|
+
method: "POST",
|
|
820
|
+
headers: { "Content-Type": "application/json" },
|
|
821
|
+
body: JSON.stringify({
|
|
822
|
+
name: "Linear Fail",
|
|
823
|
+
prompt: "Handle linear issues",
|
|
824
|
+
triggers: { linear: { enabled: true } },
|
|
825
|
+
}),
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
expect(res.status).toBe(201);
|
|
829
|
+
// updateAgent was called (to try to copy creds) but returned null
|
|
830
|
+
expect(agentStore.updateAgent).toHaveBeenCalled();
|
|
831
|
+
// updateSettings must NOT have been called — creds are preserved for retry
|
|
832
|
+
expect(updateSettings).not.toHaveBeenCalled();
|
|
833
|
+
});
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
// ─── POST /api/agents — Priority 1: staging slot ────────────────────────────
|
|
837
|
+
|
|
838
|
+
describe("POST /api/agents — Priority 1: staging slot (stagingId)", () => {
|
|
839
|
+
it("resolves Linear credentials from a staging slot when stagingId is provided", async () => {
|
|
840
|
+
// When the request body includes a stagingId, the route should call
|
|
841
|
+
// staging.consumeSlot() to retrieve and delete the one-time slot,
|
|
842
|
+
// then create an OAuth connection from the slot's credentials and
|
|
843
|
+
// store oauthConnectionId on the agent (new model).
|
|
844
|
+
const { createOAuthConnection } = await import("../linear-oauth-connections.js");
|
|
845
|
+
|
|
846
|
+
const createdAgent = makeAgent({
|
|
847
|
+
id: "staged-agent",
|
|
848
|
+
name: "Staged Agent",
|
|
849
|
+
triggers: { linear: { enabled: true } },
|
|
850
|
+
});
|
|
851
|
+
vi.mocked(agentStore.createAgent).mockReturnValue(createdAgent);
|
|
852
|
+
|
|
853
|
+
// Simulate a valid staging slot returned by consumeSlot
|
|
854
|
+
vi.mocked(staging.consumeSlot).mockReturnValue({
|
|
855
|
+
id: "abc123def456abc123def456abc123de",
|
|
856
|
+
clientId: "slot-client-id",
|
|
857
|
+
clientSecret: "slot-client-secret",
|
|
858
|
+
webhookSecret: "slot-webhook-secret",
|
|
859
|
+
accessToken: "slot-access-token",
|
|
860
|
+
refreshToken: "slot-refresh-token",
|
|
861
|
+
createdAt: Date.now(),
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
// updateAgent returns the agent with oauthConnectionId
|
|
865
|
+
const updatedAgent = makeAgent({
|
|
866
|
+
id: "staged-agent",
|
|
867
|
+
name: "Staged Agent",
|
|
868
|
+
triggers: {
|
|
869
|
+
linear: {
|
|
870
|
+
enabled: true,
|
|
871
|
+
oauthConnectionId: "mock-oauth-conn-id",
|
|
872
|
+
},
|
|
873
|
+
},
|
|
874
|
+
});
|
|
875
|
+
vi.mocked(agentStore.updateAgent).mockReturnValue(updatedAgent);
|
|
876
|
+
|
|
877
|
+
const res = await app.request("/api/agents", {
|
|
878
|
+
method: "POST",
|
|
879
|
+
headers: { "Content-Type": "application/json" },
|
|
880
|
+
body: JSON.stringify({
|
|
881
|
+
name: "Staged Agent",
|
|
882
|
+
prompt: "Handle linear issues",
|
|
883
|
+
triggers: { linear: { enabled: true } },
|
|
884
|
+
stagingId: "abc123def456abc123def456abc123de",
|
|
885
|
+
}),
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
expect(res.status).toBe(201);
|
|
889
|
+
|
|
890
|
+
// consumeSlot should have been called with the provided stagingId
|
|
891
|
+
expect(staging.consumeSlot).toHaveBeenCalledWith("abc123def456abc123def456abc123de");
|
|
892
|
+
|
|
893
|
+
// createOAuthConnection should have been called with the slot's credentials
|
|
894
|
+
expect(createOAuthConnection).toHaveBeenCalledWith(expect.objectContaining({
|
|
895
|
+
name: "Staged Agent OAuth App",
|
|
896
|
+
oauthClientId: "slot-client-id",
|
|
897
|
+
oauthClientSecret: "slot-client-secret",
|
|
898
|
+
webhookSecret: "slot-webhook-secret",
|
|
899
|
+
accessToken: "slot-access-token",
|
|
900
|
+
refreshToken: "slot-refresh-token",
|
|
901
|
+
}));
|
|
902
|
+
|
|
903
|
+
// updateAgent should have been called with oauthConnectionId reference
|
|
904
|
+
expect(agentStore.updateAgent).toHaveBeenCalledWith("staged-agent", {
|
|
905
|
+
triggers: {
|
|
906
|
+
linear: {
|
|
907
|
+
enabled: true,
|
|
908
|
+
oauthConnectionId: "mock-oauth-conn-id",
|
|
909
|
+
},
|
|
910
|
+
},
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
// Global settings should NOT be cleared when using a staging slot
|
|
914
|
+
expect(updateSettings).not.toHaveBeenCalled();
|
|
915
|
+
|
|
916
|
+
// Response should have oauthConnectionId and sanitized credentials
|
|
917
|
+
const json = await res.json();
|
|
918
|
+
expect(json.triggers.linear.oauthConnectionId).toBe("mock-oauth-conn-id");
|
|
919
|
+
expect(json.triggers.linear).not.toHaveProperty("accessToken");
|
|
920
|
+
expect(json.triggers.linear).not.toHaveProperty("refreshToken");
|
|
921
|
+
expect(json.triggers.linear).not.toHaveProperty("webhookSecret");
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
it("falls through to global staging when stagingId points to a missing/expired slot", async () => {
|
|
925
|
+
// When consumeSlot returns null (slot not found or expired), the route should
|
|
926
|
+
// skip Priority 1 and fall through. Since no cloneFromAgentId is provided either,
|
|
927
|
+
// it should reach Priority 3 (global staging) and use those credentials.
|
|
928
|
+
const createdAgent = makeAgent({
|
|
929
|
+
id: "fallthrough-agent",
|
|
930
|
+
name: "Fallthrough Agent",
|
|
931
|
+
triggers: { linear: { enabled: true } },
|
|
932
|
+
});
|
|
933
|
+
vi.mocked(agentStore.createAgent).mockReturnValue(createdAgent);
|
|
934
|
+
|
|
935
|
+
// consumeSlot returns null — slot is missing or expired
|
|
936
|
+
vi.mocked(staging.consumeSlot).mockReturnValue(null);
|
|
937
|
+
|
|
938
|
+
// Global settings have staged credentials (Priority 3 fallback)
|
|
939
|
+
vi.mocked(getSettings).mockReturnValue({
|
|
940
|
+
linearOAuthClientId: "global-client-id",
|
|
941
|
+
linearOAuthClientSecret: "global-client-secret",
|
|
942
|
+
linearOAuthWebhookSecret: "global-webhook-secret",
|
|
943
|
+
linearOAuthAccessToken: "global-access-token",
|
|
944
|
+
linearOAuthRefreshToken: "global-refresh-token",
|
|
945
|
+
} as any);
|
|
946
|
+
|
|
947
|
+
const updatedAgent = makeAgent({
|
|
948
|
+
id: "fallthrough-agent",
|
|
949
|
+
name: "Fallthrough Agent",
|
|
950
|
+
triggers: {
|
|
951
|
+
linear: {
|
|
952
|
+
enabled: true,
|
|
953
|
+
oauthClientId: "global-client-id",
|
|
954
|
+
oauthClientSecret: "global-client-secret",
|
|
955
|
+
webhookSecret: "global-webhook-secret",
|
|
956
|
+
accessToken: "global-access-token",
|
|
957
|
+
refreshToken: "global-refresh-token",
|
|
958
|
+
},
|
|
959
|
+
},
|
|
960
|
+
});
|
|
961
|
+
vi.mocked(agentStore.updateAgent).mockReturnValue(updatedAgent);
|
|
962
|
+
|
|
963
|
+
const res = await app.request("/api/agents", {
|
|
964
|
+
method: "POST",
|
|
965
|
+
headers: { "Content-Type": "application/json" },
|
|
966
|
+
body: JSON.stringify({
|
|
967
|
+
name: "Fallthrough Agent",
|
|
968
|
+
prompt: "Handle linear issues",
|
|
969
|
+
triggers: { linear: { enabled: true } },
|
|
970
|
+
stagingId: "expired00000000000000000000000000",
|
|
971
|
+
}),
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
expect(res.status).toBe(201);
|
|
975
|
+
|
|
976
|
+
// consumeSlot was called but returned null
|
|
977
|
+
expect(staging.consumeSlot).toHaveBeenCalledWith("expired00000000000000000000000000");
|
|
978
|
+
|
|
979
|
+
// Since stagingId was provided (even though it failed), global settings
|
|
980
|
+
// should NOT be cleared — the clearing logic only runs when neither
|
|
981
|
+
// stagingId nor cloneFromAgentId was in the request body.
|
|
982
|
+
// However, the global credentials should still be used for the agent.
|
|
983
|
+
expect(agentStore.updateAgent).toHaveBeenCalledWith("fallthrough-agent", {
|
|
984
|
+
triggers: {
|
|
985
|
+
linear: {
|
|
986
|
+
enabled: true,
|
|
987
|
+
oauthClientId: "global-client-id",
|
|
988
|
+
oauthClientSecret: "global-client-secret",
|
|
989
|
+
webhookSecret: "global-webhook-secret",
|
|
990
|
+
accessToken: "global-access-token",
|
|
991
|
+
refreshToken: "global-refresh-token",
|
|
992
|
+
},
|
|
993
|
+
},
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
// Global settings should NOT be cleared because body.stagingId was present
|
|
997
|
+
// (the route only clears when !body.stagingId && !body.cloneFromAgentId)
|
|
998
|
+
expect(updateSettings).not.toHaveBeenCalled();
|
|
999
|
+
});
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
// ─── POST /api/agents — Priority 2: clone from existing agent ───────────────
|
|
1003
|
+
|
|
1004
|
+
describe("POST /api/agents — Priority 2: clone from existing agent (cloneFromAgentId)", () => {
|
|
1005
|
+
it("clones Linear credentials from an existing agent when cloneFromAgentId is provided", async () => {
|
|
1006
|
+
// When the request body includes cloneFromAgentId (and no stagingId), the route
|
|
1007
|
+
// should look up the source agent and copy its Linear OAuth credentials.
|
|
1008
|
+
const createdAgent = makeAgent({
|
|
1009
|
+
id: "cloned-agent",
|
|
1010
|
+
name: "Cloned Agent",
|
|
1011
|
+
triggers: { linear: { enabled: true } },
|
|
1012
|
+
});
|
|
1013
|
+
vi.mocked(agentStore.createAgent).mockReturnValue(createdAgent);
|
|
1014
|
+
|
|
1015
|
+
// The source agent has Linear credentials to clone
|
|
1016
|
+
const sourceAgent = makeAgent({
|
|
1017
|
+
id: "source-agent",
|
|
1018
|
+
name: "Source Agent",
|
|
1019
|
+
triggers: {
|
|
1020
|
+
linear: {
|
|
1021
|
+
enabled: true,
|
|
1022
|
+
oauthClientId: "source-client-id",
|
|
1023
|
+
oauthClientSecret: "source-client-secret",
|
|
1024
|
+
webhookSecret: "source-webhook-secret",
|
|
1025
|
+
accessToken: "source-access-token",
|
|
1026
|
+
refreshToken: "source-refresh-token",
|
|
1027
|
+
},
|
|
1028
|
+
},
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
// getAgent is called twice: once internally by the route for the source lookup.
|
|
1032
|
+
// We need it to return the source agent when called with "source-agent".
|
|
1033
|
+
vi.mocked(agentStore.getAgent).mockImplementation((id: string) => {
|
|
1034
|
+
if (id === "source-agent") return sourceAgent;
|
|
1035
|
+
return null;
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
// updateAgent returns the agent with cloned credentials
|
|
1039
|
+
const updatedAgent = makeAgent({
|
|
1040
|
+
id: "cloned-agent",
|
|
1041
|
+
name: "Cloned Agent",
|
|
1042
|
+
triggers: {
|
|
1043
|
+
linear: {
|
|
1044
|
+
enabled: true,
|
|
1045
|
+
oauthClientId: "source-client-id",
|
|
1046
|
+
oauthClientSecret: "source-client-secret",
|
|
1047
|
+
webhookSecret: "source-webhook-secret",
|
|
1048
|
+
accessToken: "source-access-token",
|
|
1049
|
+
refreshToken: "source-refresh-token",
|
|
1050
|
+
},
|
|
1051
|
+
},
|
|
1052
|
+
});
|
|
1053
|
+
vi.mocked(agentStore.updateAgent).mockReturnValue(updatedAgent);
|
|
1054
|
+
|
|
1055
|
+
const res = await app.request("/api/agents", {
|
|
1056
|
+
method: "POST",
|
|
1057
|
+
headers: { "Content-Type": "application/json" },
|
|
1058
|
+
body: JSON.stringify({
|
|
1059
|
+
name: "Cloned Agent",
|
|
1060
|
+
prompt: "Handle linear issues",
|
|
1061
|
+
triggers: { linear: { enabled: true } },
|
|
1062
|
+
cloneFromAgentId: "source-agent",
|
|
1063
|
+
}),
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
expect(res.status).toBe(201);
|
|
1067
|
+
|
|
1068
|
+
// consumeSlot should NOT have been called (no stagingId in the request)
|
|
1069
|
+
expect(staging.consumeSlot).not.toHaveBeenCalled();
|
|
1070
|
+
|
|
1071
|
+
// getAgent should have been called with the source agent ID to look up credentials
|
|
1072
|
+
expect(agentStore.getAgent).toHaveBeenCalledWith("source-agent");
|
|
1073
|
+
|
|
1074
|
+
// updateAgent should have been called with the cloned credentials
|
|
1075
|
+
expect(agentStore.updateAgent).toHaveBeenCalledWith("cloned-agent", {
|
|
1076
|
+
triggers: {
|
|
1077
|
+
linear: {
|
|
1078
|
+
enabled: true,
|
|
1079
|
+
oauthClientId: "source-client-id",
|
|
1080
|
+
oauthClientSecret: "source-client-secret",
|
|
1081
|
+
webhookSecret: "source-webhook-secret",
|
|
1082
|
+
accessToken: "source-access-token",
|
|
1083
|
+
refreshToken: "source-refresh-token",
|
|
1084
|
+
},
|
|
1085
|
+
},
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
// Global settings should NOT be cleared when cloning from an agent
|
|
1089
|
+
expect(updateSettings).not.toHaveBeenCalled();
|
|
1090
|
+
|
|
1091
|
+
// Response should have sanitized credentials
|
|
1092
|
+
const json = await res.json();
|
|
1093
|
+
expect(json.triggers.linear.hasAccessToken).toBe(true);
|
|
1094
|
+
expect(json.triggers.linear.hasClientSecret).toBe(true);
|
|
1095
|
+
expect(json.triggers.linear.hasWebhookSecret).toBe(true);
|
|
1096
|
+
expect(json.triggers.linear).not.toHaveProperty("oauthClientSecret");
|
|
1097
|
+
expect(json.triggers.linear).not.toHaveProperty("accessToken");
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
it("falls through to global staging when cloneFromAgentId points to a non-existent agent", async () => {
|
|
1101
|
+
// When the source agent doesn't exist, getAgent returns null, so the clone
|
|
1102
|
+
// path is skipped and the route falls through to Priority 3 (global staging).
|
|
1103
|
+
const createdAgent = makeAgent({
|
|
1104
|
+
id: "clone-fallthrough-agent",
|
|
1105
|
+
name: "Clone Fallthrough Agent",
|
|
1106
|
+
triggers: { linear: { enabled: true } },
|
|
1107
|
+
});
|
|
1108
|
+
vi.mocked(agentStore.createAgent).mockReturnValue(createdAgent);
|
|
1109
|
+
|
|
1110
|
+
// getAgent returns null for the non-existent source agent
|
|
1111
|
+
vi.mocked(agentStore.getAgent).mockReturnValue(null);
|
|
1112
|
+
|
|
1113
|
+
// Global settings have staged credentials (Priority 3 fallback)
|
|
1114
|
+
vi.mocked(getSettings).mockReturnValue({
|
|
1115
|
+
linearOAuthClientId: "global-client-id",
|
|
1116
|
+
linearOAuthClientSecret: "global-client-secret",
|
|
1117
|
+
linearOAuthWebhookSecret: "global-webhook-secret",
|
|
1118
|
+
linearOAuthAccessToken: "global-access-token",
|
|
1119
|
+
linearOAuthRefreshToken: "global-refresh-token",
|
|
1120
|
+
} as any);
|
|
1121
|
+
|
|
1122
|
+
const updatedAgent = makeAgent({
|
|
1123
|
+
id: "clone-fallthrough-agent",
|
|
1124
|
+
name: "Clone Fallthrough Agent",
|
|
1125
|
+
triggers: {
|
|
1126
|
+
linear: {
|
|
1127
|
+
enabled: true,
|
|
1128
|
+
oauthClientId: "global-client-id",
|
|
1129
|
+
oauthClientSecret: "global-client-secret",
|
|
1130
|
+
webhookSecret: "global-webhook-secret",
|
|
1131
|
+
accessToken: "global-access-token",
|
|
1132
|
+
refreshToken: "global-refresh-token",
|
|
1133
|
+
},
|
|
1134
|
+
},
|
|
1135
|
+
});
|
|
1136
|
+
vi.mocked(agentStore.updateAgent).mockReturnValue(updatedAgent);
|
|
1137
|
+
|
|
1138
|
+
const res = await app.request("/api/agents", {
|
|
1139
|
+
method: "POST",
|
|
1140
|
+
headers: { "Content-Type": "application/json" },
|
|
1141
|
+
body: JSON.stringify({
|
|
1142
|
+
name: "Clone Fallthrough Agent",
|
|
1143
|
+
prompt: "Handle linear issues",
|
|
1144
|
+
triggers: { linear: { enabled: true } },
|
|
1145
|
+
cloneFromAgentId: "nonexistent-agent",
|
|
1146
|
+
}),
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
expect(res.status).toBe(201);
|
|
1150
|
+
|
|
1151
|
+
// getAgent should have been called to look up the (non-existent) source agent
|
|
1152
|
+
expect(agentStore.getAgent).toHaveBeenCalledWith("nonexistent-agent");
|
|
1153
|
+
|
|
1154
|
+
// updateAgent should have been called with global credentials (Priority 3 fallback)
|
|
1155
|
+
expect(agentStore.updateAgent).toHaveBeenCalledWith("clone-fallthrough-agent", {
|
|
1156
|
+
triggers: {
|
|
1157
|
+
linear: {
|
|
1158
|
+
enabled: true,
|
|
1159
|
+
oauthClientId: "global-client-id",
|
|
1160
|
+
oauthClientSecret: "global-client-secret",
|
|
1161
|
+
webhookSecret: "global-webhook-secret",
|
|
1162
|
+
accessToken: "global-access-token",
|
|
1163
|
+
refreshToken: "global-refresh-token",
|
|
1164
|
+
},
|
|
1165
|
+
},
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
// Global settings should NOT be cleared because body.cloneFromAgentId was present
|
|
1169
|
+
// (the route only clears when !body.stagingId && !body.cloneFromAgentId)
|
|
1170
|
+
expect(updateSettings).not.toHaveBeenCalled();
|
|
1171
|
+
|
|
1172
|
+
// Response should have sanitized credentials
|
|
1173
|
+
const json = await res.json();
|
|
1174
|
+
expect(json.triggers.linear.hasAccessToken).toBe(true);
|
|
1175
|
+
expect(json.triggers.linear.hasClientSecret).toBe(true);
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
it("falls through to global staging when source agent exists but has no Linear credentials", async () => {
|
|
1179
|
+
// Edge case: the source agent exists but has no oauthClientId on its Linear trigger,
|
|
1180
|
+
// so the clone condition (source?.triggers?.linear?.oauthClientId) is falsy.
|
|
1181
|
+
const createdAgent = makeAgent({
|
|
1182
|
+
id: "clone-no-creds-agent",
|
|
1183
|
+
name: "Clone No Creds Agent",
|
|
1184
|
+
triggers: { linear: { enabled: true } },
|
|
1185
|
+
});
|
|
1186
|
+
vi.mocked(agentStore.createAgent).mockReturnValue(createdAgent);
|
|
1187
|
+
|
|
1188
|
+
// Source agent exists but has no Linear credentials
|
|
1189
|
+
const sourceAgentNoCreds = makeAgent({
|
|
1190
|
+
id: "source-no-creds",
|
|
1191
|
+
name: "Source No Creds",
|
|
1192
|
+
triggers: { linear: { enabled: true } },
|
|
1193
|
+
});
|
|
1194
|
+
vi.mocked(agentStore.getAgent).mockImplementation((id: string) => {
|
|
1195
|
+
if (id === "source-no-creds") return sourceAgentNoCreds;
|
|
1196
|
+
return null;
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
// Global settings have staged credentials (Priority 3 fallback)
|
|
1200
|
+
vi.mocked(getSettings).mockReturnValue({
|
|
1201
|
+
linearOAuthClientId: "global-client-id",
|
|
1202
|
+
linearOAuthClientSecret: "global-client-secret",
|
|
1203
|
+
linearOAuthWebhookSecret: "global-webhook-secret",
|
|
1204
|
+
linearOAuthAccessToken: "global-access-token",
|
|
1205
|
+
linearOAuthRefreshToken: "global-refresh-token",
|
|
1206
|
+
} as any);
|
|
1207
|
+
|
|
1208
|
+
const updatedAgent = makeAgent({
|
|
1209
|
+
id: "clone-no-creds-agent",
|
|
1210
|
+
name: "Clone No Creds Agent",
|
|
1211
|
+
triggers: {
|
|
1212
|
+
linear: {
|
|
1213
|
+
enabled: true,
|
|
1214
|
+
oauthClientId: "global-client-id",
|
|
1215
|
+
oauthClientSecret: "global-client-secret",
|
|
1216
|
+
webhookSecret: "global-webhook-secret",
|
|
1217
|
+
accessToken: "global-access-token",
|
|
1218
|
+
refreshToken: "global-refresh-token",
|
|
1219
|
+
},
|
|
1220
|
+
},
|
|
1221
|
+
});
|
|
1222
|
+
vi.mocked(agentStore.updateAgent).mockReturnValue(updatedAgent);
|
|
1223
|
+
|
|
1224
|
+
const res = await app.request("/api/agents", {
|
|
1225
|
+
method: "POST",
|
|
1226
|
+
headers: { "Content-Type": "application/json" },
|
|
1227
|
+
body: JSON.stringify({
|
|
1228
|
+
name: "Clone No Creds Agent",
|
|
1229
|
+
prompt: "Handle linear issues",
|
|
1230
|
+
triggers: { linear: { enabled: true } },
|
|
1231
|
+
cloneFromAgentId: "source-no-creds",
|
|
1232
|
+
}),
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
expect(res.status).toBe(201);
|
|
1236
|
+
|
|
1237
|
+
// The route looked up the source agent
|
|
1238
|
+
expect(agentStore.getAgent).toHaveBeenCalledWith("source-no-creds");
|
|
1239
|
+
|
|
1240
|
+
// updateAgent should use global credentials since clone source had none
|
|
1241
|
+
expect(agentStore.updateAgent).toHaveBeenCalledWith("clone-no-creds-agent", {
|
|
1242
|
+
triggers: {
|
|
1243
|
+
linear: {
|
|
1244
|
+
enabled: true,
|
|
1245
|
+
oauthClientId: "global-client-id",
|
|
1246
|
+
oauthClientSecret: "global-client-secret",
|
|
1247
|
+
webhookSecret: "global-webhook-secret",
|
|
1248
|
+
accessToken: "global-access-token",
|
|
1249
|
+
refreshToken: "global-refresh-token",
|
|
1250
|
+
},
|
|
1251
|
+
},
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
// Global settings should NOT be cleared because body.cloneFromAgentId was present
|
|
1255
|
+
expect(updateSettings).not.toHaveBeenCalled();
|
|
1256
|
+
});
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
// ─── GET /api/executions ────────────────────────────────────────────────────
|
|
1260
|
+
|
|
1261
|
+
describe("GET /api/executions", () => {
|
|
1262
|
+
it("passes query parameters as filters to agentExecutor.listAllExecutions", async () => {
|
|
1263
|
+
// The /executions endpoint parses agentId, triggerType, status, limit, offset
|
|
1264
|
+
// from query params and passes them to the executor's listAllExecutions method.
|
|
1265
|
+
const mockResult = {
|
|
1266
|
+
executions: [{ sessionId: "s1", agentId: "a1", triggerType: "manual", startedAt: 100 }],
|
|
1267
|
+
total: 1,
|
|
1268
|
+
};
|
|
1269
|
+
executor.listAllExecutions.mockReturnValue(mockResult);
|
|
1270
|
+
|
|
1271
|
+
const res = await app.request(
|
|
1272
|
+
"/api/executions?agentId=a1&triggerType=manual&status=success&limit=10&offset=5",
|
|
1273
|
+
);
|
|
1274
|
+
|
|
1275
|
+
expect(res.status).toBe(200);
|
|
1276
|
+
const json = await res.json();
|
|
1277
|
+
expect(json).toEqual(mockResult);
|
|
1278
|
+
expect(executor.listAllExecutions).toHaveBeenCalledWith({
|
|
1279
|
+
agentId: "a1",
|
|
1280
|
+
triggerType: "manual",
|
|
1281
|
+
status: "success",
|
|
1282
|
+
limit: 10,
|
|
1283
|
+
offset: 5,
|
|
1284
|
+
});
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
it("uses default limit and offset when not provided, and ignores invalid status", async () => {
|
|
1288
|
+
// When no limit/offset are provided, defaults should be limit=50 and offset=0.
|
|
1289
|
+
// An invalid status value (not "running", "success", or "error") should be undefined.
|
|
1290
|
+
executor.listAllExecutions.mockReturnValue({ executions: [], total: 0 });
|
|
1291
|
+
|
|
1292
|
+
const res = await app.request("/api/executions?status=invalid");
|
|
1293
|
+
|
|
1294
|
+
expect(res.status).toBe(200);
|
|
1295
|
+
expect(executor.listAllExecutions).toHaveBeenCalledWith({
|
|
1296
|
+
agentId: undefined,
|
|
1297
|
+
triggerType: undefined,
|
|
1298
|
+
status: undefined,
|
|
1299
|
+
limit: 50,
|
|
1300
|
+
offset: 0,
|
|
1301
|
+
});
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
it("clamps limit to the range [1, 500]", async () => {
|
|
1305
|
+
// Limit is computed as Math.min(Math.max(Number(query) || 50, 1), 500).
|
|
1306
|
+
// Values above 500 are clamped down; 0 and NaN fall back to 50 via the || operator.
|
|
1307
|
+
executor.listAllExecutions.mockReturnValue({ executions: [], total: 0 });
|
|
1308
|
+
|
|
1309
|
+
// Test upper bound: 9999 should become 500
|
|
1310
|
+
await app.request("/api/executions?limit=9999");
|
|
1311
|
+
expect(executor.listAllExecutions).toHaveBeenCalledWith(
|
|
1312
|
+
expect.objectContaining({ limit: 500 }),
|
|
1313
|
+
);
|
|
1314
|
+
|
|
1315
|
+
executor.listAllExecutions.mockClear();
|
|
1316
|
+
|
|
1317
|
+
// Test that 0 is treated as falsy and defaults to 50 (via || 50)
|
|
1318
|
+
await app.request("/api/executions?limit=0");
|
|
1319
|
+
expect(executor.listAllExecutions).toHaveBeenCalledWith(
|
|
1320
|
+
expect.objectContaining({ limit: 50 }),
|
|
1321
|
+
);
|
|
1322
|
+
|
|
1323
|
+
executor.listAllExecutions.mockClear();
|
|
1324
|
+
|
|
1325
|
+
// Test that a non-numeric value defaults to 50
|
|
1326
|
+
await app.request("/api/executions?limit=abc");
|
|
1327
|
+
expect(executor.listAllExecutions).toHaveBeenCalledWith(
|
|
1328
|
+
expect.objectContaining({ limit: 50 }),
|
|
1329
|
+
);
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
it("returns empty result when no executor is available", async () => {
|
|
1333
|
+
// If agentExecutor is undefined, the route should return a fallback empty result.
|
|
1334
|
+
// We test this by creating a separate app with no executor.
|
|
1335
|
+
const appNoExecutor = new Hono();
|
|
1336
|
+
const apiNoExecutor = new Hono();
|
|
1337
|
+
registerAgentRoutes(apiNoExecutor, undefined);
|
|
1338
|
+
appNoExecutor.route("/api", apiNoExecutor);
|
|
1339
|
+
|
|
1340
|
+
const res = await appNoExecutor.request("/api/executions");
|
|
1341
|
+
|
|
1342
|
+
expect(res.status).toBe(200);
|
|
1343
|
+
const json = await res.json();
|
|
1344
|
+
expect(json).toEqual({ executions: [], total: 0 });
|
|
1345
|
+
});
|
|
1346
|
+
});
|
|
1347
|
+
|
|
1348
|
+
// ─── POST /api/agents/:id/regenerate-secret ─────────────────────────────────
|
|
1349
|
+
|
|
1350
|
+
describe("POST /api/agents/:id/regenerate-secret", () => {
|
|
1351
|
+
it("regenerates the webhook secret and returns the sanitized agent", async () => {
|
|
1352
|
+
// The regenerate-secret endpoint calls agentStore.regenerateWebhookSecret
|
|
1353
|
+
// and returns the agent with sensitive Linear fields stripped.
|
|
1354
|
+
const agentWithNewSecret = makeAgent({
|
|
1355
|
+
id: "regen-agent",
|
|
1356
|
+
triggers: {
|
|
1357
|
+
webhook: { enabled: true, secret: "new-secret-xyz" },
|
|
1358
|
+
linear: {
|
|
1359
|
+
enabled: true,
|
|
1360
|
+
oauthClientId: "cid",
|
|
1361
|
+
oauthClientSecret: "csecret",
|
|
1362
|
+
webhookSecret: "ws",
|
|
1363
|
+
accessToken: "at",
|
|
1364
|
+
refreshToken: "rt",
|
|
1365
|
+
},
|
|
1366
|
+
},
|
|
1367
|
+
});
|
|
1368
|
+
vi.mocked(agentStore.regenerateWebhookSecret).mockReturnValue(agentWithNewSecret);
|
|
1369
|
+
|
|
1370
|
+
const res = await app.request("/api/agents/regen-agent/regenerate-secret", {
|
|
1371
|
+
method: "POST",
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
expect(res.status).toBe(200);
|
|
1375
|
+
expect(agentStore.regenerateWebhookSecret).toHaveBeenCalledWith("regen-agent");
|
|
1376
|
+
const json = await res.json();
|
|
1377
|
+
expect(json.id).toBe("regen-agent");
|
|
1378
|
+
// Sanitize should strip Linear OAuth secrets and add boolean flags
|
|
1379
|
+
expect(json.triggers.linear.hasAccessToken).toBe(true);
|
|
1380
|
+
expect(json.triggers.linear.hasClientSecret).toBe(true);
|
|
1381
|
+
expect(json.triggers.linear.hasWebhookSecret).toBe(true);
|
|
1382
|
+
expect(json.triggers.linear).not.toHaveProperty("oauthClientSecret");
|
|
1383
|
+
expect(json.triggers.linear).not.toHaveProperty("accessToken");
|
|
1384
|
+
expect(json.triggers.linear).not.toHaveProperty("refreshToken");
|
|
1385
|
+
expect(json.triggers.linear).not.toHaveProperty("webhookSecret");
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
it("returns 404 when agent does not exist", async () => {
|
|
1389
|
+
vi.mocked(agentStore.regenerateWebhookSecret).mockReturnValue(null);
|
|
1390
|
+
|
|
1391
|
+
const res = await app.request("/api/agents/nonexistent/regenerate-secret", {
|
|
1392
|
+
method: "POST",
|
|
1393
|
+
});
|
|
1394
|
+
|
|
1395
|
+
expect(res.status).toBe(404);
|
|
1396
|
+
const json = await res.json();
|
|
1397
|
+
expect(json.error).toBe("Agent not found");
|
|
1398
|
+
});
|
|
1399
|
+
});
|
|
1400
|
+
|