@cursor/july 0.1.19 → 0.1.21
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/AGENTS.md +10 -0
- package/dist/bin/agent-serve.js +0 -0
- package/dist/channels/github/instrument.d.ts +20 -0
- package/dist/channels/github/instrument.d.ts.map +1 -0
- package/dist/channels/slack/api.d.ts +2 -0
- package/dist/channels/slack/api.d.ts.map +1 -1
- package/dist/channels/slack/api.js +3 -0
- package/dist/channels/slack/bot-mentions.d.ts +106 -0
- package/dist/channels/slack/bot-mentions.d.ts.map +1 -0
- package/dist/channels/slack/bot-mentions.js +243 -0
- package/dist/channels/slack/dispatch.d.ts +6 -0
- package/dist/channels/slack/dispatch.d.ts.map +1 -1
- package/dist/channels/slack/dispatch.js +24 -3
- package/dist/channels/slack/inbound.d.ts +10 -1
- package/dist/channels/slack/inbound.d.ts.map +1 -1
- package/dist/channels/slack/inbound.js +12 -4
- package/dist/channels/slack/index.d.ts +1 -0
- package/dist/channels/slack/index.d.ts.map +1 -1
- package/dist/channels/slack/index.js +1 -0
- package/dist/channels/slack/slack-channel.d.ts.map +1 -1
- package/dist/channels/slack/slack-channel.js +61 -22
- package/dist/channels/slack/types.d.ts +58 -0
- package/dist/channels/slack/types.d.ts.map +1 -1
- package/dist/docs/404.html +1 -1
- package/dist/docs/ab.html +2 -2
- package/dist/docs/assets/{app.CrsWMchO.js → app.jDxLzWv4.js} +1 -1
- package/dist/docs/assets/chunks/@localSearchIndexroot.DoJHJjqF.js +1 -0
- package/dist/docs/assets/chunks/{VPLocalSearchBox.D1JqzSh8.js → VPLocalSearchBox.Y6bDR1-a.js} +1 -1
- package/dist/docs/assets/chunks/{theme.DaBvZYwl.js → theme.CLazCWlJ.js} +2 -2
- package/dist/docs/building-with-agents.html +2 -2
- package/dist/docs/concepts.html +2 -2
- package/dist/docs/deployment.html +2 -2
- package/dist/docs/evals.html +2 -2
- package/dist/docs/example-agents/approval-buddy.html +2 -2
- package/dist/docs/example-agents/benny.html +2 -2
- package/dist/docs/example-agents/bugbot.html +2 -2
- package/dist/docs/example-agents/codebase-wiki.html +2 -2
- package/dist/docs/example-agents/codeowners-review.html +2 -2
- package/dist/docs/example-agents/concierge.html +2 -2
- package/dist/docs/example-agents/fsd.html +2 -2
- package/dist/docs/example-agents/index.html +2 -2
- package/dist/docs/example-agents/knowledge-base.html +2 -2
- package/dist/docs/example-agents/oncall.html +2 -2
- package/dist/docs/example-agents/security-reviewer.html +2 -2
- package/dist/docs/example-agents/slack-agent.html +2 -2
- package/dist/docs/example-agents/weather-agent.html +2 -2
- package/dist/docs/guides/agent-to-agent.html +2 -2
- package/dist/docs/guides/cloud-runtime.html +2 -2
- package/dist/docs/guides/github.html +2 -2
- package/dist/docs/guides/human-in-the-loop.html +2 -2
- package/dist/docs/guides/mcp-oauth.html +2 -2
- package/dist/docs/guides/slack.html +2 -2
- package/dist/docs/guides/webhooks.html +2 -2
- package/dist/docs/hillclimbing.html +2 -2
- package/dist/docs/index.html +2 -2
- package/dist/docs/quickstart.html +2 -2
- package/dist/docs/reference/agent-config.html +2 -2
- package/dist/docs/reference/channels.html +2 -2
- package/dist/docs/reference/cli.html +2 -2
- package/dist/docs/reference/connections.html +2 -2
- package/dist/docs/reference/hooks.html +2 -2
- package/dist/docs/reference/http-api.html +2 -2
- package/dist/docs/reference/instructions.html +2 -2
- package/dist/docs/reference/playground.html +2 -2
- package/dist/docs/reference/project-layout.html +2 -2
- package/dist/docs/reference/prompt.html +2 -2
- package/dist/docs/reference/schedules.html +2 -2
- package/dist/docs/reference/sessions.html +2 -2
- package/dist/docs/reference/skills.html +2 -2
- package/dist/docs/reference/subagents.html +2 -2
- package/dist/docs/reference/tools.html +2 -2
- package/dist/docs/scaffolding-agents.html +2 -2
- package/dist/docs/storage.html +2 -2
- package/dist/docs/troubleshooting.html +2 -2
- package/dist/internal/json-dir-store.d.ts +32 -0
- package/dist/internal/json-dir-store.d.ts.map +1 -0
- package/dist/internal/json-dir-store.js +100 -0
- package/dist/internal/resolved-connections.d.ts +1 -1
- package/dist/internal/resolved-connections.d.ts.map +1 -1
- package/dist/internal/resolved-connections.js +10 -0
- package/dist/internal/session-engine.d.ts +4 -1
- package/dist/internal/session-engine.d.ts.map +1 -1
- package/dist/internal/session-engine.js +13 -2
- package/dist/playground/assets/index-CjOQ4hN9.css +1 -0
- package/dist/playground/assets/{index-C2SU2xV5.js → index-dshZQJCp.js} +46 -46
- package/dist/playground/index.html +2 -2
- package/dist/types.d.ts +7 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +9 -0
- package/package.json +25 -25
- package/skills/create-agent/SKILL.md +36 -4
- package/skills/evals/SKILL.md +3 -0
- package/skills/framework-map/SKILL.md +14 -0
- package/skills/github/SKILL.md +6 -1
- package/skills/hillclimb/SKILL.md +28 -7
- package/src/bin/agent-serve.version.test.ts +62 -0
- package/src/channels/github/api.test.ts +64 -0
- package/src/channels/github/auth.test.ts +105 -0
- package/src/channels/github/cursor-account.test.ts +204 -0
- package/src/channels/github/forward.test.ts +457 -0
- package/src/channels/github/github.test.ts +937 -0
- package/src/channels/github/replay.test.ts +179 -0
- package/src/channels/slack/api.post-message.test.ts +148 -0
- package/src/channels/slack/api.ts +5 -0
- package/src/channels/slack/approvals.test.ts +328 -0
- package/src/channels/slack/block-actions.test.ts +452 -0
- package/src/channels/slack/bot-mentions.test.ts +217 -0
- package/src/channels/slack/bot-mentions.ts +315 -0
- package/src/channels/slack/channel-watch.test.ts +363 -0
- package/src/channels/slack/cursor-account.test.ts +253 -0
- package/src/channels/slack/defaults.final-post.test.ts +182 -0
- package/src/channels/slack/dispatch.test.ts +795 -0
- package/src/channels/slack/dispatch.ts +27 -1
- package/src/channels/slack/eval-directive.test.ts +273 -0
- package/src/channels/slack/inbound.ts +25 -4
- package/src/channels/slack/index.ts +1 -0
- package/src/channels/slack/message-body.test.ts +54 -0
- package/src/channels/slack/nudge-store.test.ts +143 -0
- package/src/channels/slack/slack-channel.ts +66 -8
- package/src/channels/slack/slack.test.ts +391 -0
- package/src/channels/slack/stop.test.ts +23 -0
- package/src/channels/slack/thread-context.test.ts +202 -0
- package/src/channels/slack/types.ts +60 -0
- package/src/evals/assertions.test.ts +580 -0
- package/src/evals/expect.test.ts +144 -0
- package/src/evals/judge.test.ts +181 -0
- package/src/evals/loaders.test.ts +132 -0
- package/src/evals/matchers.test.ts +95 -0
- package/src/evals/reporters.test.ts +303 -0
- package/src/evals/run-facts.test.ts +259 -0
- package/src/internal/ab-snapshot.test.ts +325 -0
- package/src/internal/approval-gate.test.ts +49 -0
- package/src/internal/approvals.integration.test.ts +383 -0
- package/src/internal/authored-loaders.test.ts +31 -0
- package/src/internal/builtin-tools/reminders.test.ts +201 -0
- package/src/internal/channel-route-schema.test.ts +294 -0
- package/src/internal/chat-attach.test.ts +262 -0
- package/src/internal/cli-deploy.test.ts +1991 -0
- package/src/internal/cli-mcp.test.ts +789 -0
- package/src/internal/cli-skills.test.ts +133 -0
- package/src/internal/cli-slack.test.ts +1647 -0
- package/src/internal/cloud-merge.test.ts +74 -0
- package/src/internal/cron.test.ts +22 -0
- package/src/internal/cursor/account-mcp.test.ts +807 -0
- package/src/internal/cursor/backend-client.test.ts +591 -0
- package/src/internal/cursor/credentials.test.ts +351 -0
- package/src/internal/cursor/github-credentials.test.ts +136 -0
- package/src/internal/cursor-account-mcp-auth.test.ts +310 -0
- package/src/internal/cursor-account.integration.test.ts +441 -0
- package/src/internal/cursor-event-relay.test.ts +746 -0
- package/src/internal/cursor-github-credentials.integration.test.ts +271 -0
- package/src/internal/cursor-slack-relay.test.ts +525 -0
- package/src/internal/deploy-source.test.ts +111 -0
- package/src/internal/discovery.builtin-tools.test.ts +94 -0
- package/src/internal/discovery.concurrency.test.ts +60 -0
- package/src/internal/discovery.cursor-account.test.ts +133 -0
- package/src/internal/discovery.cwd.test.ts +83 -0
- package/src/internal/discovery.hosting.test.ts +80 -0
- package/src/internal/discovery.identity.test.ts +44 -0
- package/src/internal/docs-site.test.ts +66 -0
- package/src/internal/duration.test.ts +29 -0
- package/src/internal/eval-judge-model.test.ts +187 -0
- package/src/internal/eval-run-store.cancel.test.ts +142 -0
- package/src/internal/eval-run-store.storage.test.ts +211 -0
- package/src/internal/eval-runner.http.test.ts +403 -0
- package/src/internal/eval-runner.run.test.ts +928 -0
- package/src/internal/evals-client.test.ts +307 -0
- package/src/internal/event-mapper.test.ts +243 -0
- package/src/internal/github-fanout.test.ts +213 -0
- package/src/internal/handleAgentServeTrigger.test.ts +179 -0
- package/src/internal/host-kv.test.ts +82 -0
- package/src/internal/host-platforms.test.ts +126 -0
- package/src/internal/http-channel.test.ts +402 -0
- package/src/internal/init-project.test.ts +269 -0
- package/src/internal/install-cursor-skills.test.ts +262 -0
- package/src/internal/local-env.test.ts +100 -0
- package/src/internal/log-ring.test.ts +31 -0
- package/src/internal/logs-client.test.ts +350 -0
- package/src/internal/mcp-endpoint.test.ts +436 -0
- package/src/internal/mcp-host.test.ts +298 -0
- package/src/internal/mcp-oauth.test.ts +148 -0
- package/src/internal/net.test.ts +17 -0
- package/src/internal/peer-connections.test.ts +128 -0
- package/src/internal/peer-mcp.integration.test.ts +289 -0
- package/src/internal/playground/toolchain.test.ts +53 -0
- package/src/internal/playground-cli.test.ts +187 -0
- package/src/internal/playground-proxy.test.ts +376 -0
- package/src/internal/prompt-context.integration.test.ts +232 -0
- package/src/internal/prompt-context.test.ts +127 -0
- package/src/internal/reminder-runner.test.ts +390 -0
- package/src/internal/reminder-store.test.ts +53 -0
- package/src/internal/request-headers.test.ts +27 -0
- package/src/internal/resolve-prod-target.test.ts +787 -0
- package/src/internal/resolved-connections.test.ts +295 -0
- package/src/internal/resolved-connections.ts +18 -1
- package/src/internal/router.test.ts +57 -0
- package/src/internal/sdk-runner.test.ts +290 -0
- package/src/internal/session-engine.coalesce.test.ts +169 -0
- package/src/internal/session-engine.concurrency.test.ts +250 -0
- package/src/internal/session-engine.host-oauth-mcp.test.ts +110 -0
- package/src/internal/session-engine.interrupt.test.ts +577 -0
- package/src/internal/session-engine.storage.test.ts +547 -0
- package/src/internal/session-engine.ts +15 -1
- package/src/internal/session-urls.test.ts +28 -0
- package/src/internal/sessions-client.test.ts +518 -0
- package/src/internal/storage-coordinator.test.ts +517 -0
- package/src/internal/tool-call.test.ts +458 -0
- package/src/internal/tool-result.test.ts +52 -0
- package/src/internal/trajectory.approvals.test.ts +83 -0
- package/src/internal/trajectory.subagents.test.ts +198 -0
- package/src/internal/turn-governor.test.ts +137 -0
- package/src/internal/update-check.test.ts +485 -0
- package/src/internal/workspace.test.ts +81 -0
- package/src/storage-backends/cursor-hosted.test.ts +121 -0
- package/src/types.ts +12 -0
- package/dist/docs/assets/chunks/@localSearchIndexroot.BnHRjfoe.js +0 -1
- package/dist/playground/assets/index-CidizGZv.css +0 -1
|
@@ -0,0 +1,1991 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deploy client + CLI handlers against an in-process mock of the Agent
|
|
3
|
+
* Serve management API (backend/server/src/agentServe/agentServeEndpoints).
|
|
4
|
+
* The mock is a real HTTP server so status codes, Retry-After headers, and
|
|
5
|
+
* query strings travel the actual wire; state is a plain object the tests
|
|
6
|
+
* script (status progressions, injected errors, alias-token-once).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
10
|
+
import { once } from "node:events";
|
|
11
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
12
|
+
import { createServer, type Server } from "node:http";
|
|
13
|
+
import type { AddressInfo } from "node:net";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { PassThrough } from "node:stream";
|
|
17
|
+
import { promisify } from "node:util";
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
19
|
+
import {
|
|
20
|
+
cmdDeploy,
|
|
21
|
+
cmdDeploymentStatus,
|
|
22
|
+
cmdDeployments,
|
|
23
|
+
cmdRotatePodCredential,
|
|
24
|
+
cmdRotateToken,
|
|
25
|
+
cmdSecretsList,
|
|
26
|
+
cmdSecretsSet,
|
|
27
|
+
cmdSecretsUnset,
|
|
28
|
+
cmdStop,
|
|
29
|
+
promptChooseDeployTargets,
|
|
30
|
+
type SecretInputStream,
|
|
31
|
+
} from "./cli-deploy.js";
|
|
32
|
+
import { cmdMcpOauth } from "./cli-mcp-oauth.js";
|
|
33
|
+
import {
|
|
34
|
+
AgentServeDeployClient,
|
|
35
|
+
DeployApiError,
|
|
36
|
+
slugFromDirectoryName,
|
|
37
|
+
validateDeploymentSlug,
|
|
38
|
+
} from "./deploy-client.js";
|
|
39
|
+
|
|
40
|
+
const execFileAsync = promisify(execFile);
|
|
41
|
+
|
|
42
|
+
const TEAM_ID = 7;
|
|
43
|
+
const API_KEY = "crsr_test_key";
|
|
44
|
+
|
|
45
|
+
interface MockDeployment {
|
|
46
|
+
id: number;
|
|
47
|
+
slug: string;
|
|
48
|
+
teamId: number;
|
|
49
|
+
publicId: string;
|
|
50
|
+
generation: number;
|
|
51
|
+
status: string;
|
|
52
|
+
deploymentKind: string;
|
|
53
|
+
lastError: string | null;
|
|
54
|
+
desiredUpdatedAt: string;
|
|
55
|
+
aliasToken: string;
|
|
56
|
+
gitRepoUrl: string | null;
|
|
57
|
+
gitRef: string;
|
|
58
|
+
agentPath: string;
|
|
59
|
+
cursorEventRepos: string[];
|
|
60
|
+
egressAllowedDomains: string[];
|
|
61
|
+
secrets: Map<string, { value: string; createdAt: string }>;
|
|
62
|
+
podCredentialMaskedKey: string;
|
|
63
|
+
/** Statuses returned by successive detail GETs; last one repeats. */
|
|
64
|
+
statusQueue: string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface MockControlPlane {
|
|
68
|
+
url: string;
|
|
69
|
+
close: () => Promise<void>;
|
|
70
|
+
deployments: Map<string, MockDeployment>;
|
|
71
|
+
/** When set, the next matching request answers with this and clears it. */
|
|
72
|
+
nextError?: { status: number; body: unknown; retryAfterSeconds?: number };
|
|
73
|
+
/** When set, the next accepted deploy converges through these statuses. */
|
|
74
|
+
nextDeployOutcome?: { statusQueue: string[]; lastError: string | null };
|
|
75
|
+
seenAuth: string[];
|
|
76
|
+
deployCount: number;
|
|
77
|
+
secretsPutCount: number;
|
|
78
|
+
/** Body of the most recent accepted deploy POST. */
|
|
79
|
+
lastDeployBody?: Record<string, unknown>;
|
|
80
|
+
/** CheckHttpMcpStatus calls seen (SA connector auth flow). */
|
|
81
|
+
mcpStatusCalls: number;
|
|
82
|
+
/** The connector reports authorized after this many status calls. */
|
|
83
|
+
mcpAuthorizeAfterCalls: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const SECRET_NAME_PATTERN = /^[A-Z][A-Z0-9_]*$/;
|
|
87
|
+
|
|
88
|
+
function progressForStatus(status: string): string | undefined {
|
|
89
|
+
switch (status) {
|
|
90
|
+
case "pending":
|
|
91
|
+
return "creating";
|
|
92
|
+
case "deploying":
|
|
93
|
+
return "building";
|
|
94
|
+
case "running":
|
|
95
|
+
return "running";
|
|
96
|
+
case "failed":
|
|
97
|
+
return "failed";
|
|
98
|
+
case "degraded":
|
|
99
|
+
return "degraded";
|
|
100
|
+
case "stopping":
|
|
101
|
+
return "stopping";
|
|
102
|
+
case "stopped":
|
|
103
|
+
return "stopped";
|
|
104
|
+
default:
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function statusMessageForStatus(status: string): string | null {
|
|
110
|
+
switch (status) {
|
|
111
|
+
case "pending":
|
|
112
|
+
return "Creating runtime…";
|
|
113
|
+
case "deploying":
|
|
114
|
+
return "Checking out source…";
|
|
115
|
+
case "stopping":
|
|
116
|
+
return "Stopping deployment…";
|
|
117
|
+
default:
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function serializeDeployment(d: MockDeployment): Record<string, unknown> {
|
|
123
|
+
return {
|
|
124
|
+
id: d.id,
|
|
125
|
+
slug: d.slug,
|
|
126
|
+
teamId: d.teamId,
|
|
127
|
+
deploymentKind: d.deploymentKind,
|
|
128
|
+
gitRepoUrl: d.gitRepoUrl,
|
|
129
|
+
gitRef: d.gitRef,
|
|
130
|
+
agentPath: d.agentPath,
|
|
131
|
+
// Newer serializeDeployment fields (identity decoupling); the client
|
|
132
|
+
// must tolerate and pass these through.
|
|
133
|
+
cursorEventRepos: d.cursorEventRepos,
|
|
134
|
+
egressAllowedDomains: d.egressAllowedDomains,
|
|
135
|
+
serviceAccountId: 4242,
|
|
136
|
+
podCredentialMaskedKey: d.podCredentialMaskedKey,
|
|
137
|
+
secretNames: [...d.secrets.keys()].sort(),
|
|
138
|
+
desiredState: d.status === "stopped" ? "stopped" : "running",
|
|
139
|
+
status: d.status,
|
|
140
|
+
progress: progressForStatus(d.status),
|
|
141
|
+
statusMessage: statusMessageForStatus(d.status),
|
|
142
|
+
generation: d.generation,
|
|
143
|
+
observedGeneration: d.generation,
|
|
144
|
+
engineGeneration: d.status === "running" ? d.generation : null,
|
|
145
|
+
publicId: d.publicId,
|
|
146
|
+
engineUrl: d.status === "running" ? "http://engine.internal:3000" : null,
|
|
147
|
+
lastError: d.lastError,
|
|
148
|
+
engineAlias: `/internal/agent-serve/agents/${d.publicId}/engine`,
|
|
149
|
+
desiredUpdatedAt: d.desiredUpdatedAt,
|
|
150
|
+
lastReconciledAt: d.desiredUpdatedAt,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function startMockControlPlane(): Promise<MockControlPlane> {
|
|
155
|
+
let nextId = 1;
|
|
156
|
+
const state: MockControlPlane = {
|
|
157
|
+
url: "",
|
|
158
|
+
close: async () => {},
|
|
159
|
+
deployments: new Map(),
|
|
160
|
+
seenAuth: [],
|
|
161
|
+
deployCount: 0,
|
|
162
|
+
secretsPutCount: 0,
|
|
163
|
+
mcpStatusCalls: 0,
|
|
164
|
+
mcpAuthorizeAfterCalls: 1,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const server: Server = createServer((req, res) => {
|
|
168
|
+
let raw = "";
|
|
169
|
+
req.on("data", (chunk: Buffer) => {
|
|
170
|
+
raw += chunk.toString("utf8");
|
|
171
|
+
});
|
|
172
|
+
req.on("end", () => {
|
|
173
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
174
|
+
const send = (status: number, body: unknown): void => {
|
|
175
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
176
|
+
res.end(JSON.stringify(body));
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
state.seenAuth.push(req.headers.authorization ?? "");
|
|
180
|
+
|
|
181
|
+
// Account RPCs used by default-team resolution.
|
|
182
|
+
if (url.pathname === "/auth/exchange_user_api_key") {
|
|
183
|
+
const claims = Buffer.from(
|
|
184
|
+
JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600 })
|
|
185
|
+
).toString("base64url");
|
|
186
|
+
send(200, { accessToken: `header.${claims}.signature` });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (url.pathname === "/aiserver.v1.DashboardService/GetMe") {
|
|
190
|
+
send(200, {
|
|
191
|
+
authId: "auth0|1",
|
|
192
|
+
email: "dev@example.com",
|
|
193
|
+
teamId: TEAM_ID,
|
|
194
|
+
teamName: "Acme",
|
|
195
|
+
});
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (
|
|
199
|
+
url.pathname === "/aiserver.v1.DashboardService/GetAvailableMcpServers"
|
|
200
|
+
) {
|
|
201
|
+
send(200, {
|
|
202
|
+
servers: [
|
|
203
|
+
{ id: 1, name: "Datadog", isTeamServer: true },
|
|
204
|
+
{ id: 2, name: "Personal" },
|
|
205
|
+
],
|
|
206
|
+
});
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (url.pathname === "/aiserver.v1.DashboardService/CheckHttpMcpStatus") {
|
|
210
|
+
state.mcpStatusCalls++;
|
|
211
|
+
const authorized = state.mcpStatusCalls > state.mcpAuthorizeAfterCalls;
|
|
212
|
+
send(200, {
|
|
213
|
+
statuses: [
|
|
214
|
+
authorized
|
|
215
|
+
? { id: 1, isAvailable: true, hasValidToken: true }
|
|
216
|
+
: {
|
|
217
|
+
id: 1,
|
|
218
|
+
requiresAuth: true,
|
|
219
|
+
authUrl: "https://cursor.com/oauth/datadog",
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
});
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!url.pathname.startsWith("/internal/agent-serve/deployments")) {
|
|
227
|
+
send(404, { error: "Not found" });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (req.headers.authorization !== `Bearer ${API_KEY}`) {
|
|
231
|
+
send(401, { error: "Unauthorized" });
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (state.nextError !== undefined) {
|
|
235
|
+
const { status, body, retryAfterSeconds } = state.nextError;
|
|
236
|
+
state.nextError = undefined;
|
|
237
|
+
if (retryAfterSeconds !== undefined) {
|
|
238
|
+
res.setHeader("Retry-After", String(retryAfterSeconds));
|
|
239
|
+
}
|
|
240
|
+
send(status, body);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const rest = url.pathname.slice(
|
|
245
|
+
"/internal/agent-serve/deployments".length
|
|
246
|
+
);
|
|
247
|
+
const teamId = Number(url.searchParams.get("teamId"));
|
|
248
|
+
|
|
249
|
+
// POST / (deploy)
|
|
250
|
+
if (req.method === "POST" && (rest === "" || rest === "/")) {
|
|
251
|
+
const body = JSON.parse(raw) as {
|
|
252
|
+
teamId: number;
|
|
253
|
+
slug: string;
|
|
254
|
+
gitRepoUrl?: string;
|
|
255
|
+
gitRef?: string;
|
|
256
|
+
agentPath?: string;
|
|
257
|
+
cursorEventRepos?: string[];
|
|
258
|
+
egressAllowedDomains?: string[];
|
|
259
|
+
};
|
|
260
|
+
const existing = state.deployments.get(body.slug);
|
|
261
|
+
const effectiveRepo = body.gitRepoUrl ?? existing?.gitRepoUrl ?? null;
|
|
262
|
+
if (
|
|
263
|
+
(body.egressAllowedDomains?.length ?? 0) > 0 &&
|
|
264
|
+
effectiveRepo === null
|
|
265
|
+
) {
|
|
266
|
+
send(400, {
|
|
267
|
+
error:
|
|
268
|
+
"egressAllowedDomains requires a repo-backed deployment; redeploy with a gitRepoUrl first",
|
|
269
|
+
});
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
state.deployCount++;
|
|
273
|
+
state.lastDeployBody = body;
|
|
274
|
+
const outcome = state.nextDeployOutcome ?? {
|
|
275
|
+
statusQueue: ["pending", "deploying", "running"],
|
|
276
|
+
lastError: null,
|
|
277
|
+
};
|
|
278
|
+
state.nextDeployOutcome = undefined;
|
|
279
|
+
if (existing !== undefined) {
|
|
280
|
+
existing.generation++;
|
|
281
|
+
existing.status = "pending";
|
|
282
|
+
existing.statusQueue = [...outcome.statusQueue];
|
|
283
|
+
existing.lastError = outcome.lastError;
|
|
284
|
+
existing.gitRepoUrl = effectiveRepo;
|
|
285
|
+
existing.gitRef = body.gitRef ?? existing.gitRef;
|
|
286
|
+
existing.agentPath = body.agentPath ?? existing.agentPath;
|
|
287
|
+
existing.cursorEventRepos =
|
|
288
|
+
body.cursorEventRepos ?? existing.cursorEventRepos;
|
|
289
|
+
existing.egressAllowedDomains =
|
|
290
|
+
body.egressAllowedDomains ?? existing.egressAllowedDomains;
|
|
291
|
+
existing.deploymentKind =
|
|
292
|
+
effectiveRepo === null ? "static-http-200" : "engine";
|
|
293
|
+
send(202, {
|
|
294
|
+
id: existing.id,
|
|
295
|
+
slug: existing.slug,
|
|
296
|
+
teamId: existing.teamId,
|
|
297
|
+
status: "pending",
|
|
298
|
+
generation: existing.generation,
|
|
299
|
+
deploymentKind: existing.deploymentKind,
|
|
300
|
+
engineAlias: `/internal/agent-serve/agents/${existing.publicId}/engine`,
|
|
301
|
+
});
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const created: MockDeployment = {
|
|
305
|
+
id: nextId++,
|
|
306
|
+
slug: body.slug,
|
|
307
|
+
teamId: body.teamId,
|
|
308
|
+
publicId: `asd_${"0".repeat(31)}${nextId}`,
|
|
309
|
+
generation: 1,
|
|
310
|
+
status: "pending",
|
|
311
|
+
deploymentKind: effectiveRepo === null ? "static-http-200" : "engine",
|
|
312
|
+
lastError: outcome.lastError,
|
|
313
|
+
desiredUpdatedAt: "2026-07-14T00:00:00.000Z",
|
|
314
|
+
aliasToken: `alias_secret_${nextId}`,
|
|
315
|
+
gitRepoUrl: effectiveRepo,
|
|
316
|
+
gitRef: body.gitRef ?? "",
|
|
317
|
+
agentPath: body.agentPath ?? "",
|
|
318
|
+
cursorEventRepos: body.cursorEventRepos ?? [],
|
|
319
|
+
egressAllowedDomains: body.egressAllowedDomains ?? [],
|
|
320
|
+
secrets: new Map(),
|
|
321
|
+
podCredentialMaskedKey: `sk_agent_****${nextId}00a`,
|
|
322
|
+
statusQueue: [...outcome.statusQueue],
|
|
323
|
+
};
|
|
324
|
+
state.deployments.set(body.slug, created);
|
|
325
|
+
send(202, {
|
|
326
|
+
id: created.id,
|
|
327
|
+
slug: created.slug,
|
|
328
|
+
teamId: created.teamId,
|
|
329
|
+
status: "pending",
|
|
330
|
+
generation: created.generation,
|
|
331
|
+
deploymentKind: created.deploymentKind,
|
|
332
|
+
engineAlias: `/internal/agent-serve/agents/${created.publicId}/engine`,
|
|
333
|
+
aliasToken: created.aliasToken,
|
|
334
|
+
});
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// GET / (list)
|
|
339
|
+
if (req.method === "GET" && (rest === "" || rest === "/")) {
|
|
340
|
+
const rows = [...state.deployments.values()].filter(
|
|
341
|
+
(d) => d.teamId === teamId
|
|
342
|
+
);
|
|
343
|
+
send(200, { deployments: rows.map(serializeDeployment) });
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const segments = rest.replace(/^\//, "").split("/");
|
|
348
|
+
const slug = decodeURIComponent(segments[0] ?? "");
|
|
349
|
+
const deployment = state.deployments.get(slug);
|
|
350
|
+
|
|
351
|
+
// POST /:slug/stop
|
|
352
|
+
if (req.method === "POST" && segments[1] === "stop") {
|
|
353
|
+
if (deployment === undefined) {
|
|
354
|
+
send(404, { error: "Deployment not found" });
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
deployment.statusQueue = ["stopping", "stopped"];
|
|
358
|
+
send(202, { id: deployment.id, status: "stopping" });
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// POST /:slug/rotate-alias-token
|
|
363
|
+
if (req.method === "POST" && segments[1] === "rotate-alias-token") {
|
|
364
|
+
if (deployment === undefined) {
|
|
365
|
+
send(404, { error: "Deployment not found" });
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
deployment.aliasToken = `alias_secret_rotated_${deployment.id}`;
|
|
369
|
+
send(200, { aliasToken: deployment.aliasToken });
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// POST /:slug/rotate-pod-credential
|
|
374
|
+
if (req.method === "POST" && segments[1] === "rotate-pod-credential") {
|
|
375
|
+
if (deployment === undefined) {
|
|
376
|
+
send(404, { error: "Deployment not found" });
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
deployment.podCredentialMaskedKey = `sk_agent_****rot${deployment.id}`;
|
|
380
|
+
send(200, {
|
|
381
|
+
podCredentialMaskedKey: deployment.podCredentialMaskedKey,
|
|
382
|
+
});
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// PUT /:slug/secrets (upsert by name; unlisted names untouched)
|
|
387
|
+
if (req.method === "PUT" && segments[1] === "secrets") {
|
|
388
|
+
state.secretsPutCount++;
|
|
389
|
+
if (deployment === undefined) {
|
|
390
|
+
send(404, { error: "Deployment not found" });
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (deployment.gitRepoUrl === null) {
|
|
394
|
+
send(400, {
|
|
395
|
+
error:
|
|
396
|
+
"Secrets require a repo-backed deployment; redeploy with a gitRepoUrl first",
|
|
397
|
+
});
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const body = JSON.parse(raw) as {
|
|
401
|
+
teamId: number;
|
|
402
|
+
secrets: Array<{ name: string; value: string }>;
|
|
403
|
+
};
|
|
404
|
+
for (const { name, value } of body.secrets) {
|
|
405
|
+
if (
|
|
406
|
+
!SECRET_NAME_PATTERN.test(name) ||
|
|
407
|
+
name.length > 64 ||
|
|
408
|
+
name.startsWith("CURSOR_")
|
|
409
|
+
) {
|
|
410
|
+
send(400, { error: `Invalid or reserved secret name: ${name}` });
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (Buffer.byteLength(value, "utf8") > 4096) {
|
|
414
|
+
send(400, { error: `Secret value too large for ${name}` });
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
const names = new Set([
|
|
419
|
+
...deployment.secrets.keys(),
|
|
420
|
+
...body.secrets.map((s) => s.name),
|
|
421
|
+
]);
|
|
422
|
+
if (names.size > 32) {
|
|
423
|
+
send(400, { error: "Too many secrets (max 32)" });
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
for (const { name, value } of body.secrets) {
|
|
427
|
+
deployment.secrets.set(name, {
|
|
428
|
+
value,
|
|
429
|
+
createdAt:
|
|
430
|
+
deployment.secrets.get(name)?.createdAt ??
|
|
431
|
+
"2026-07-14T00:00:00.000Z",
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
send(200, { secretNames: [...deployment.secrets.keys()].sort() });
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// GET /:slug/secrets (names only, never values)
|
|
439
|
+
if (
|
|
440
|
+
req.method === "GET" &&
|
|
441
|
+
segments[1] === "secrets" &&
|
|
442
|
+
segments.length === 2
|
|
443
|
+
) {
|
|
444
|
+
if (deployment === undefined) {
|
|
445
|
+
send(404, { error: "Deployment not found" });
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
send(200, {
|
|
449
|
+
secrets: [...deployment.secrets.entries()]
|
|
450
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
451
|
+
.map(([name, entry]) => ({ name, createdAt: entry.createdAt })),
|
|
452
|
+
});
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// DELETE /:slug/secrets/:name
|
|
457
|
+
if (
|
|
458
|
+
req.method === "DELETE" &&
|
|
459
|
+
segments[1] === "secrets" &&
|
|
460
|
+
segments.length === 3
|
|
461
|
+
) {
|
|
462
|
+
const name = decodeURIComponent(segments[2] ?? "");
|
|
463
|
+
if (name.startsWith("CURSOR_")) {
|
|
464
|
+
send(400, { error: `Reserved secret name: ${name}` });
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (deployment === undefined || !deployment.secrets.has(name)) {
|
|
468
|
+
send(404, { error: "Secret not found" });
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
deployment.secrets.delete(name);
|
|
472
|
+
send(200, { removed: name });
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// GET /:slug (detail; advances the scripted status)
|
|
477
|
+
if (req.method === "GET" && segments.length === 1) {
|
|
478
|
+
if (deployment === undefined || deployment.teamId !== teamId) {
|
|
479
|
+
send(404, { error: "Deployment not found" });
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const next = deployment.statusQueue.shift();
|
|
483
|
+
if (next !== undefined) {
|
|
484
|
+
deployment.status = next;
|
|
485
|
+
if (deployment.statusQueue.length === 0) {
|
|
486
|
+
deployment.statusQueue = [next];
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
send(200, {
|
|
490
|
+
...serializeDeployment(deployment),
|
|
491
|
+
engineAccess:
|
|
492
|
+
deployment.status === "running"
|
|
493
|
+
? {
|
|
494
|
+
url: "https://anyrun.example/proxy",
|
|
495
|
+
headers: { "x-anyrun-port-token": "pt" },
|
|
496
|
+
expiresAt: "2026-07-14T01:00:00.000Z",
|
|
497
|
+
}
|
|
498
|
+
: null,
|
|
499
|
+
});
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
send(404, { error: "Not found" });
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
server.listen(0, "127.0.0.1");
|
|
508
|
+
await once(server, "listening");
|
|
509
|
+
const { port } = server.address() as AddressInfo;
|
|
510
|
+
state.url = `http://127.0.0.1:${port}`;
|
|
511
|
+
state.close = () =>
|
|
512
|
+
new Promise<void>((resolvePromise, reject) =>
|
|
513
|
+
server.close((error) => (error ? reject(error) : resolvePromise()))
|
|
514
|
+
);
|
|
515
|
+
return state;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Capture out/err streams for a handler invocation. */
|
|
519
|
+
function capture(): {
|
|
520
|
+
out: (text: string) => void;
|
|
521
|
+
err: (text: string) => void;
|
|
522
|
+
stdout: () => string;
|
|
523
|
+
stderr: () => string;
|
|
524
|
+
} {
|
|
525
|
+
let stdout = "";
|
|
526
|
+
let stderr = "";
|
|
527
|
+
return {
|
|
528
|
+
out: (text) => {
|
|
529
|
+
stdout += text;
|
|
530
|
+
},
|
|
531
|
+
err: (text) => {
|
|
532
|
+
stderr += text;
|
|
533
|
+
},
|
|
534
|
+
stdout: () => stdout,
|
|
535
|
+
stderr: () => stderr,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
let backend: MockControlPlane;
|
|
540
|
+
const savedEnv: Record<string, string | undefined> = {};
|
|
541
|
+
|
|
542
|
+
beforeEach(async () => {
|
|
543
|
+
backend = await startMockControlPlane();
|
|
544
|
+
for (const key of [
|
|
545
|
+
"CURSOR_API_BASE_URL",
|
|
546
|
+
"CURSOR_API_KEY",
|
|
547
|
+
"AGENT_SERVE_CONFIG_DIR",
|
|
548
|
+
]) {
|
|
549
|
+
savedEnv[key] = process.env[key];
|
|
550
|
+
}
|
|
551
|
+
process.env.CURSOR_API_BASE_URL = backend.url;
|
|
552
|
+
delete process.env.CURSOR_API_KEY;
|
|
553
|
+
// Point stored-credential reads at an empty dir so a developer's real
|
|
554
|
+
// login can never leak into these tests.
|
|
555
|
+
process.env.AGENT_SERVE_CONFIG_DIR = join(
|
|
556
|
+
tmpdir(),
|
|
557
|
+
`agentkit-deploy-test-${process.pid}`
|
|
558
|
+
);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
afterEach(async () => {
|
|
562
|
+
await backend.close();
|
|
563
|
+
for (const [key, value] of Object.entries(savedEnv)) {
|
|
564
|
+
if (value === undefined) {
|
|
565
|
+
delete process.env[key];
|
|
566
|
+
} else {
|
|
567
|
+
process.env[key] = value;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
function makeClient(): AgentServeDeployClient {
|
|
573
|
+
return new AgentServeDeployClient({
|
|
574
|
+
backendUrl: backend.url,
|
|
575
|
+
apiKey: API_KEY,
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const fastPoll = { pollIntervalMs: 5, timeoutMs: 5_000 };
|
|
580
|
+
|
|
581
|
+
const tempDirs: string[] = [];
|
|
582
|
+
|
|
583
|
+
afterEach(async () => {
|
|
584
|
+
while (tempDirs.length > 0) {
|
|
585
|
+
const dir = tempDirs.pop();
|
|
586
|
+
if (dir !== undefined) {
|
|
587
|
+
await rm(dir, { recursive: true, force: true });
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Minimal agent project whose agent.mjs default-exports a pre-branded
|
|
594
|
+
* config (no framework import, so plain Node can load it under vitest).
|
|
595
|
+
*/
|
|
596
|
+
async function writeManifestProject(
|
|
597
|
+
hosting: Record<string, unknown>
|
|
598
|
+
): Promise<string> {
|
|
599
|
+
const dir = await mkdtemp(join(tmpdir(), "agentkit-manifest-"));
|
|
600
|
+
tempDirs.push(dir);
|
|
601
|
+
const agentDir = join(dir, "agent");
|
|
602
|
+
await mkdir(agentDir, { recursive: true });
|
|
603
|
+
await writeFile(
|
|
604
|
+
join(agentDir, "agent.mjs"),
|
|
605
|
+
`export default { __agentServe: "agent", model: "composer-2.5", hosting: ${JSON.stringify(hosting)} };\n`
|
|
606
|
+
);
|
|
607
|
+
await writeFile(join(agentDir, "instructions.md"), "manifest fixture\n");
|
|
608
|
+
return dir;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
describe("slug validation", () => {
|
|
612
|
+
it("accepts lowercase letters, digits, - and _ up to 64 chars", () => {
|
|
613
|
+
expect(validateDeploymentSlug("weather-agent_2")).toBeUndefined();
|
|
614
|
+
expect(validateDeploymentSlug("a".repeat(64))).toBeUndefined();
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it("rejects uppercase, spaces, dots, empty, and >64 chars", () => {
|
|
618
|
+
for (const bad of ["Weather", "has space", "dot.dot", "", "a".repeat(65)]) {
|
|
619
|
+
expect(validateDeploymentSlug(bad)).toMatch(/Invalid slug/);
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it("derives slugs from directory names", () => {
|
|
624
|
+
expect(slugFromDirectoryName("Weather Agent")).toBe("weather-agent");
|
|
625
|
+
expect(slugFromDirectoryName("my_agent-2")).toBe("my_agent-2");
|
|
626
|
+
expect(slugFromDirectoryName("---")).toBeUndefined();
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
describe("AgentServeDeployClient", () => {
|
|
631
|
+
it("deploys, returns the alias token exactly once, and bumps generation on redeploy", async () => {
|
|
632
|
+
const client = makeClient();
|
|
633
|
+
const first = await client.deploy({
|
|
634
|
+
teamId: TEAM_ID,
|
|
635
|
+
slug: "weather",
|
|
636
|
+
source: { kind: "git-ref" },
|
|
637
|
+
});
|
|
638
|
+
expect(first.generation).toBe(1);
|
|
639
|
+
expect(first.deploymentKind).toBe("static-http-200");
|
|
640
|
+
expect(first.aliasToken).toMatch(/^alias_secret_/);
|
|
641
|
+
expect(first.engineAlias).toMatch(/^\/internal\/agent-serve\/agents\/asd_/);
|
|
642
|
+
|
|
643
|
+
const second = await client.deploy({
|
|
644
|
+
teamId: TEAM_ID,
|
|
645
|
+
slug: "weather",
|
|
646
|
+
source: { kind: "git-ref" },
|
|
647
|
+
});
|
|
648
|
+
expect(second.generation).toBe(2);
|
|
649
|
+
expect(second.aliasToken).toBeUndefined();
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it("rejects an invalid slug client-side without calling the API", async () => {
|
|
653
|
+
const client = makeClient();
|
|
654
|
+
await expect(
|
|
655
|
+
client.deploy({
|
|
656
|
+
teamId: TEAM_ID,
|
|
657
|
+
slug: "Not A Slug",
|
|
658
|
+
source: { kind: "git-ref" },
|
|
659
|
+
})
|
|
660
|
+
).rejects.toThrow(/Invalid slug/);
|
|
661
|
+
expect(backend.deployCount).toBe(0);
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
it("sends the API key as the bearer", async () => {
|
|
665
|
+
const client = makeClient();
|
|
666
|
+
await client.listDeployments({ teamId: TEAM_ID });
|
|
667
|
+
expect(backend.seenAuth.at(-1)).toBe(`Bearer ${API_KEY}`);
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
it("lists and gets deployments", async () => {
|
|
671
|
+
const client = makeClient();
|
|
672
|
+
await client.deploy({
|
|
673
|
+
teamId: TEAM_ID,
|
|
674
|
+
slug: "weather",
|
|
675
|
+
source: { kind: "git-ref" },
|
|
676
|
+
});
|
|
677
|
+
const listed = await client.listDeployments({ teamId: TEAM_ID });
|
|
678
|
+
expect(listed).toHaveLength(1);
|
|
679
|
+
expect(listed[0]?.slug).toBe("weather");
|
|
680
|
+
|
|
681
|
+
const detail = await client.getDeployment({
|
|
682
|
+
teamId: TEAM_ID,
|
|
683
|
+
slug: "weather",
|
|
684
|
+
});
|
|
685
|
+
expect(detail.status).toBe("pending");
|
|
686
|
+
expect(detail.engineAlias).toContain("/engine");
|
|
687
|
+
// Fields the client does not model yet must pass through unchanged
|
|
688
|
+
// (schemas are passthrough), so --json output tracks the API.
|
|
689
|
+
expect(
|
|
690
|
+
(detail as unknown as Record<string, unknown>).serviceAccountId
|
|
691
|
+
).toBe(4242);
|
|
692
|
+
expect(
|
|
693
|
+
(listed[0] as unknown as Record<string, unknown>)?.egressAllowedDomains
|
|
694
|
+
).toEqual([]);
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it("accepts string serviceAccountId ids from the API", async () => {
|
|
698
|
+
const server = createServer((_req, res) => {
|
|
699
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
700
|
+
res.end(
|
|
701
|
+
JSON.stringify({
|
|
702
|
+
id: 1,
|
|
703
|
+
slug: "weather",
|
|
704
|
+
teamId: TEAM_ID,
|
|
705
|
+
deploymentKind: "engine",
|
|
706
|
+
status: "running",
|
|
707
|
+
generation: 1,
|
|
708
|
+
engineAlias: "/internal/agent-serve/agents/asd_x/engine",
|
|
709
|
+
serviceAccountId: "sa_f2178255-c11d-467e-bd30-f867d0127107",
|
|
710
|
+
})
|
|
711
|
+
);
|
|
712
|
+
});
|
|
713
|
+
await new Promise<void>((resolve) =>
|
|
714
|
+
server.listen(0, "127.0.0.1", resolve)
|
|
715
|
+
);
|
|
716
|
+
const { port } = server.address() as AddressInfo;
|
|
717
|
+
try {
|
|
718
|
+
const detail = await new AgentServeDeployClient({
|
|
719
|
+
backendUrl: `http://127.0.0.1:${port}`,
|
|
720
|
+
apiKey: API_KEY,
|
|
721
|
+
}).getDeployment({ teamId: TEAM_ID, slug: "weather" });
|
|
722
|
+
expect(detail.serviceAccountId).toBe(
|
|
723
|
+
"sa_f2178255-c11d-467e-bd30-f867d0127107"
|
|
724
|
+
);
|
|
725
|
+
} finally {
|
|
726
|
+
await new Promise<void>((resolve, reject) =>
|
|
727
|
+
server.close((error) => (error ? reject(error) : resolve()))
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
it("maps Fastify route-not-found 404s to a wrong-host hint", async () => {
|
|
733
|
+
backend.nextError = {
|
|
734
|
+
status: 404,
|
|
735
|
+
body: {
|
|
736
|
+
message: "Route POST:/internal/agent-serve/deployments not found",
|
|
737
|
+
error: "Not Found",
|
|
738
|
+
statusCode: 404,
|
|
739
|
+
},
|
|
740
|
+
};
|
|
741
|
+
await expect(
|
|
742
|
+
makeClient().deploy({
|
|
743
|
+
teamId: TEAM_ID,
|
|
744
|
+
slug: "weather",
|
|
745
|
+
source: { kind: "git-ref" },
|
|
746
|
+
})
|
|
747
|
+
).rejects.toThrow(/api\.cursor\.com|CURSOR_API_BASE_URL/);
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it("maps 403 to a team-permission message", async () => {
|
|
751
|
+
backend.nextError = {
|
|
752
|
+
status: 403,
|
|
753
|
+
body: { error: "Not authorized for this team" },
|
|
754
|
+
};
|
|
755
|
+
await expect(
|
|
756
|
+
makeClient().listDeployments({ teamId: TEAM_ID })
|
|
757
|
+
).rejects.toThrow(/team-admin permission.*cloud-agent entitlement/);
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
it("maps 404 to a feature-gate hint", async () => {
|
|
761
|
+
backend.nextError = { status: 404, body: { error: "Not found" } };
|
|
762
|
+
await expect(
|
|
763
|
+
makeClient().getDeployment({ teamId: TEAM_ID, slug: "ghost" })
|
|
764
|
+
).rejects.toThrow(/agent_serve_mvp feature gate/);
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
it("maps 409 to a retry suggestion", async () => {
|
|
768
|
+
backend.nextError = {
|
|
769
|
+
status: 409,
|
|
770
|
+
body: { error: "A concurrent deploy or stop changed this deployment" },
|
|
771
|
+
};
|
|
772
|
+
await expect(
|
|
773
|
+
makeClient().deploy({
|
|
774
|
+
teamId: TEAM_ID,
|
|
775
|
+
slug: "weather",
|
|
776
|
+
source: { kind: "git-ref" },
|
|
777
|
+
})
|
|
778
|
+
).rejects.toThrow(/concurrent deploy or stop.*retry/);
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
it("maps 429 and surfaces Retry-After", async () => {
|
|
782
|
+
backend.nextError = {
|
|
783
|
+
status: 429,
|
|
784
|
+
body: { error: "Agent Serve deploys are limited to one per minute" },
|
|
785
|
+
retryAfterSeconds: 42,
|
|
786
|
+
};
|
|
787
|
+
const error = await makeClient()
|
|
788
|
+
.deploy({ teamId: TEAM_ID, slug: "weather", source: { kind: "git-ref" } })
|
|
789
|
+
.then(
|
|
790
|
+
() => undefined,
|
|
791
|
+
(e: unknown) => e
|
|
792
|
+
);
|
|
793
|
+
expect(error).toBeInstanceOf(DeployApiError);
|
|
794
|
+
expect((error as DeployApiError).status).toBe(429);
|
|
795
|
+
expect((error as DeployApiError).retryAfterSeconds).toBe(42);
|
|
796
|
+
expect((error as DeployApiError).message).toMatch(/retry in 42s/);
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
it("stops and rotates alias tokens", async () => {
|
|
800
|
+
const client = makeClient();
|
|
801
|
+
await client.deploy({
|
|
802
|
+
teamId: TEAM_ID,
|
|
803
|
+
slug: "weather",
|
|
804
|
+
source: { kind: "git-ref" },
|
|
805
|
+
});
|
|
806
|
+
const stopped = await client.stopDeployment({
|
|
807
|
+
teamId: TEAM_ID,
|
|
808
|
+
slug: "weather",
|
|
809
|
+
});
|
|
810
|
+
expect(stopped.status).toBe("stopping");
|
|
811
|
+
|
|
812
|
+
const rotated = await client.rotateAliasToken({
|
|
813
|
+
teamId: TEAM_ID,
|
|
814
|
+
slug: "weather",
|
|
815
|
+
});
|
|
816
|
+
expect(rotated).toMatch(/^alias_secret_rotated_/);
|
|
817
|
+
});
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
describe("cmdDeploy", () => {
|
|
821
|
+
it("deploys, polls to running, and prints the alias token block once", async () => {
|
|
822
|
+
const io = capture();
|
|
823
|
+
const code = await cmdDeploy({
|
|
824
|
+
slug: "weather",
|
|
825
|
+
team: String(TEAM_ID),
|
|
826
|
+
apiKey: API_KEY,
|
|
827
|
+
out: io.out,
|
|
828
|
+
err: io.err,
|
|
829
|
+
...fastPoll,
|
|
830
|
+
});
|
|
831
|
+
expect(code).toBe(0);
|
|
832
|
+
expect(io.stdout()).toContain("shown ONCE");
|
|
833
|
+
expect(io.stdout()).toContain("alias_secret_");
|
|
834
|
+
expect(io.stdout()).toContain("X-Agent-Alias-Token");
|
|
835
|
+
expect(io.stdout()).toContain("status: running");
|
|
836
|
+
expect(io.stdout()).toContain("kind: static-http-200");
|
|
837
|
+
expect(io.stdout()).toMatch(
|
|
838
|
+
/alias:\s+http.*\/internal\/agent-serve\/agents\/asd_.*\/engine/
|
|
839
|
+
);
|
|
840
|
+
expect(io.stdout()).toMatch(
|
|
841
|
+
/playground:\s+http.*\/internal\/agent-serve\/agents\/asd_.*\/engine\/playground/
|
|
842
|
+
);
|
|
843
|
+
expect(io.stdout()).toMatch(
|
|
844
|
+
/open: .*playground --prod --slug weather --team /
|
|
845
|
+
);
|
|
846
|
+
// Progress transitions on the (non-TTY) stderr stream — customer step
|
|
847
|
+
// text, not raw API status.
|
|
848
|
+
expect(io.stderr()).toContain("weather: Creating runtime");
|
|
849
|
+
expect(io.stderr()).toContain("weather: Checking out source");
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
it("does not print an alias token block on redeploy", async () => {
|
|
853
|
+
const first = capture();
|
|
854
|
+
await cmdDeploy({
|
|
855
|
+
slug: "weather",
|
|
856
|
+
team: String(TEAM_ID),
|
|
857
|
+
apiKey: API_KEY,
|
|
858
|
+
out: first.out,
|
|
859
|
+
err: first.err,
|
|
860
|
+
...fastPoll,
|
|
861
|
+
});
|
|
862
|
+
const second = capture();
|
|
863
|
+
const code = await cmdDeploy({
|
|
864
|
+
slug: "weather",
|
|
865
|
+
team: String(TEAM_ID),
|
|
866
|
+
apiKey: API_KEY,
|
|
867
|
+
out: second.out,
|
|
868
|
+
err: second.err,
|
|
869
|
+
...fastPoll,
|
|
870
|
+
});
|
|
871
|
+
expect(code).toBe(0);
|
|
872
|
+
expect(second.stdout()).not.toContain("shown ONCE");
|
|
873
|
+
expect(second.stdout()).toContain("generation: 2");
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
it("returns right after the 202 with --no-wait", async () => {
|
|
877
|
+
const io = capture();
|
|
878
|
+
const code = await cmdDeploy({
|
|
879
|
+
slug: "weather",
|
|
880
|
+
team: String(TEAM_ID),
|
|
881
|
+
apiKey: API_KEY,
|
|
882
|
+
noWait: true,
|
|
883
|
+
out: io.out,
|
|
884
|
+
err: io.err,
|
|
885
|
+
...fastPoll,
|
|
886
|
+
});
|
|
887
|
+
expect(code).toBe(0);
|
|
888
|
+
expect(io.stdout()).toContain("Deploy accepted");
|
|
889
|
+
expect(io.stdout()).toContain("agentkit deployment weather");
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
it("prints lastError and exits non-zero when the deploy fails", async () => {
|
|
893
|
+
backend.nextDeployOutcome = {
|
|
894
|
+
statusQueue: ["pending", "deploying", "failed"],
|
|
895
|
+
lastError: "engine pod crashed: OOMKilled",
|
|
896
|
+
};
|
|
897
|
+
const failed = capture();
|
|
898
|
+
const code = await cmdDeploy({
|
|
899
|
+
slug: "weather",
|
|
900
|
+
team: String(TEAM_ID),
|
|
901
|
+
apiKey: API_KEY,
|
|
902
|
+
out: failed.out,
|
|
903
|
+
err: failed.err,
|
|
904
|
+
...fastPoll,
|
|
905
|
+
});
|
|
906
|
+
expect(code).toBe(1);
|
|
907
|
+
expect(failed.stderr()).toContain("engine pod crashed: OOMKilled");
|
|
908
|
+
expect(failed.stderr()).toContain("failed");
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
it("derives the slug from --dir and says so", async () => {
|
|
912
|
+
const io = capture();
|
|
913
|
+
const code = await cmdDeploy({
|
|
914
|
+
dir: "/tmp/My Weather Agent",
|
|
915
|
+
team: String(TEAM_ID),
|
|
916
|
+
apiKey: API_KEY,
|
|
917
|
+
noWait: true,
|
|
918
|
+
out: io.out,
|
|
919
|
+
err: io.err,
|
|
920
|
+
...fastPoll,
|
|
921
|
+
});
|
|
922
|
+
expect(code).toBe(0);
|
|
923
|
+
expect(io.stderr()).toContain('Deploying as "my-weather-agent"');
|
|
924
|
+
expect(backend.deployments.has("my-weather-agent")).toBe(true);
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
it("resolves the default team from the account and says so", async () => {
|
|
928
|
+
const io = capture();
|
|
929
|
+
const code = await cmdDeploy({
|
|
930
|
+
slug: "weather",
|
|
931
|
+
apiKey: API_KEY,
|
|
932
|
+
noWait: true,
|
|
933
|
+
out: io.out,
|
|
934
|
+
err: io.err,
|
|
935
|
+
...fastPoll,
|
|
936
|
+
});
|
|
937
|
+
expect(code).toBe(0);
|
|
938
|
+
expect(io.stderr()).toContain(`Using team ${TEAM_ID} (Acme)`);
|
|
939
|
+
expect(backend.deployments.get("weather")?.teamId).toBe(TEAM_ID);
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
it("runs service-account connector auth after deploying a cursorAccount agent", async () => {
|
|
943
|
+
const dir = await writeManifestProject({});
|
|
944
|
+
await mkdir(join(dir, "agent", "mcp-connections"), { recursive: true });
|
|
945
|
+
await writeFile(
|
|
946
|
+
join(dir, "agent", "mcp-connections", "cursor.mjs"),
|
|
947
|
+
`export default { __agentServe: "connection", cursorAccount: true, servers: ["Datadog"] };\n`
|
|
948
|
+
);
|
|
949
|
+
const io = capture();
|
|
950
|
+
const opened: string[] = [];
|
|
951
|
+
const code = await cmdDeploy({
|
|
952
|
+
dir,
|
|
953
|
+
slug: "weather",
|
|
954
|
+
team: String(TEAM_ID),
|
|
955
|
+
apiKey: API_KEY,
|
|
956
|
+
out: io.out,
|
|
957
|
+
err: io.err,
|
|
958
|
+
openBrowserImpl: (url) => void opened.push(url),
|
|
959
|
+
sleepImpl: async () => {},
|
|
960
|
+
connectorPollIntervalMs: 1,
|
|
961
|
+
connectorPollTimeoutMs: 1_000,
|
|
962
|
+
...fastPoll,
|
|
963
|
+
});
|
|
964
|
+
expect(code).toBe(0);
|
|
965
|
+
// The minted authUrl was opened once, then polling saw the stored token.
|
|
966
|
+
expect(opened).toEqual(["https://cursor.com/oauth/datadog"]);
|
|
967
|
+
expect(io.stdout()).toContain("Datadog: authorized ✓");
|
|
968
|
+
expect(io.stdout()).toContain("as yourself");
|
|
969
|
+
expect(backend.mcpStatusCalls).toBeGreaterThanOrEqual(2);
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
it("keeps the deploy green and points at the playground when connector auth is left pending", async () => {
|
|
973
|
+
backend.mcpAuthorizeAfterCalls = Number.MAX_SAFE_INTEGER;
|
|
974
|
+
const dir = await writeManifestProject({});
|
|
975
|
+
await mkdir(join(dir, "agent", "mcp-connections"), { recursive: true });
|
|
976
|
+
await writeFile(
|
|
977
|
+
join(dir, "agent", "mcp-connections", "cursor.mjs"),
|
|
978
|
+
`export default { __agentServe: "connection", cursorAccount: true, servers: ["Datadog"] };\n`
|
|
979
|
+
);
|
|
980
|
+
const io = capture();
|
|
981
|
+
const code = await cmdDeploy({
|
|
982
|
+
dir,
|
|
983
|
+
slug: "weather",
|
|
984
|
+
team: String(TEAM_ID),
|
|
985
|
+
apiKey: API_KEY,
|
|
986
|
+
out: io.out,
|
|
987
|
+
err: io.err,
|
|
988
|
+
openBrowserImpl: () => {},
|
|
989
|
+
sleepImpl: async () => {},
|
|
990
|
+
connectorPollIntervalMs: 1,
|
|
991
|
+
connectorPollTimeoutMs: 3,
|
|
992
|
+
...fastPoll,
|
|
993
|
+
});
|
|
994
|
+
expect(code).toBe(0);
|
|
995
|
+
expect(io.stdout()).toContain("status: running");
|
|
996
|
+
expect(io.stdout()).toContain(
|
|
997
|
+
"You can authorize connectors at the playground page"
|
|
998
|
+
);
|
|
999
|
+
expect(io.stdout()).toContain("Deployed Agents → weather");
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
it("emits JSON with --json (including the one-time alias token)", async () => {
|
|
1003
|
+
const io = capture();
|
|
1004
|
+
const code = await cmdDeploy({
|
|
1005
|
+
slug: "weather",
|
|
1006
|
+
team: String(TEAM_ID),
|
|
1007
|
+
apiKey: API_KEY,
|
|
1008
|
+
json: true,
|
|
1009
|
+
out: io.out,
|
|
1010
|
+
err: io.err,
|
|
1011
|
+
...fastPoll,
|
|
1012
|
+
});
|
|
1013
|
+
expect(code).toBe(0);
|
|
1014
|
+
const parsed = JSON.parse(io.stdout()) as {
|
|
1015
|
+
status: string;
|
|
1016
|
+
aliasToken: string;
|
|
1017
|
+
generation: number;
|
|
1018
|
+
};
|
|
1019
|
+
expect(parsed.status).toBe("running");
|
|
1020
|
+
expect(parsed.generation).toBe(1);
|
|
1021
|
+
expect(parsed.aliasToken).toMatch(/^alias_secret_/);
|
|
1022
|
+
});
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
describe("cmdMcpOauth cursorAccount connections", () => {
|
|
1026
|
+
async function writeCursorAccountProject(): Promise<string> {
|
|
1027
|
+
const dir = await writeManifestProject({});
|
|
1028
|
+
await mkdir(join(dir, "agent", "mcp-connections"), { recursive: true });
|
|
1029
|
+
await writeFile(
|
|
1030
|
+
join(dir, "agent", "mcp-connections", "cursor.mjs"),
|
|
1031
|
+
`export default { __agentServe: "connection", cursorAccount: true, servers: ["Datadog"] };\n`
|
|
1032
|
+
);
|
|
1033
|
+
return dir;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
it("authorizes the deployment service account and treats --store as a no-op", async () => {
|
|
1037
|
+
// Seed the deployment the SA flow resolves.
|
|
1038
|
+
const seed = capture();
|
|
1039
|
+
await cmdDeploy({
|
|
1040
|
+
slug: "weather",
|
|
1041
|
+
team: String(TEAM_ID),
|
|
1042
|
+
apiKey: API_KEY,
|
|
1043
|
+
noWait: true,
|
|
1044
|
+
out: seed.out,
|
|
1045
|
+
err: seed.err,
|
|
1046
|
+
...fastPoll,
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
const dir = await writeCursorAccountProject();
|
|
1050
|
+
const io = capture();
|
|
1051
|
+
const opened: string[] = [];
|
|
1052
|
+
const code = await cmdMcpOauth({
|
|
1053
|
+
dir,
|
|
1054
|
+
connection: "cursor",
|
|
1055
|
+
store: true,
|
|
1056
|
+
slug: "weather",
|
|
1057
|
+
team: String(TEAM_ID),
|
|
1058
|
+
apiKey: API_KEY,
|
|
1059
|
+
out: io.out,
|
|
1060
|
+
err: io.err,
|
|
1061
|
+
openBrowserImpl: (url) => void opened.push(url),
|
|
1062
|
+
sleep: async () => {},
|
|
1063
|
+
pollIntervalMs: 1,
|
|
1064
|
+
pollTimeoutMs: 1_000,
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
expect(code).toBe(0);
|
|
1068
|
+
expect(opened).toEqual(["https://cursor.com/oauth/datadog"]);
|
|
1069
|
+
expect(io.stdout()).toContain("Datadog: authorized ✓");
|
|
1070
|
+
expect(io.stdout()).toContain("--store is not needed");
|
|
1071
|
+
// Nothing was written to deployment secrets.
|
|
1072
|
+
expect(backend.secretsPutCount).toBe(0);
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
it("explains when the deployment does not exist yet", async () => {
|
|
1076
|
+
const dir = await writeCursorAccountProject();
|
|
1077
|
+
const io = capture();
|
|
1078
|
+
const code = await cmdMcpOauth({
|
|
1079
|
+
dir,
|
|
1080
|
+
connection: "cursor",
|
|
1081
|
+
slug: "ghost",
|
|
1082
|
+
team: String(TEAM_ID),
|
|
1083
|
+
apiKey: API_KEY,
|
|
1084
|
+
out: io.out,
|
|
1085
|
+
err: io.err,
|
|
1086
|
+
openBrowserImpl: () => {},
|
|
1087
|
+
sleep: async () => {},
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
expect(code).toBe(1);
|
|
1091
|
+
expect(io.stderr()).toContain('Could not load deployment "ghost"');
|
|
1092
|
+
expect(io.stderr()).toContain("Deploy first");
|
|
1093
|
+
});
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
describe("cmdDeployments / cmdDeploymentStatus", () => {
|
|
1097
|
+
it("renders the table and the detail view", async () => {
|
|
1098
|
+
const seed = capture();
|
|
1099
|
+
await cmdDeploy({
|
|
1100
|
+
slug: "weather",
|
|
1101
|
+
team: String(TEAM_ID),
|
|
1102
|
+
apiKey: API_KEY,
|
|
1103
|
+
out: seed.out,
|
|
1104
|
+
err: seed.err,
|
|
1105
|
+
...fastPoll,
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
const list = capture();
|
|
1109
|
+
expect(
|
|
1110
|
+
await cmdDeployments({
|
|
1111
|
+
team: String(TEAM_ID),
|
|
1112
|
+
apiKey: API_KEY,
|
|
1113
|
+
out: list.out,
|
|
1114
|
+
err: list.err,
|
|
1115
|
+
})
|
|
1116
|
+
).toBe(0);
|
|
1117
|
+
expect(list.stdout()).toMatch(/SLUG\s+STATUS\s+GEN\s+KIND\s+UPDATED/);
|
|
1118
|
+
expect(list.stdout()).toMatch(/weather\s+running\s+1\s+static-http-200/);
|
|
1119
|
+
|
|
1120
|
+
const detail = capture();
|
|
1121
|
+
expect(
|
|
1122
|
+
await cmdDeploymentStatus("weather", {
|
|
1123
|
+
team: String(TEAM_ID),
|
|
1124
|
+
apiKey: API_KEY,
|
|
1125
|
+
out: detail.out,
|
|
1126
|
+
err: detail.err,
|
|
1127
|
+
})
|
|
1128
|
+
).toBe(0);
|
|
1129
|
+
expect(detail.stdout()).toContain("status: running");
|
|
1130
|
+
expect(detail.stdout()).toContain(
|
|
1131
|
+
"engine: http://engine.internal:3000"
|
|
1132
|
+
);
|
|
1133
|
+
expect(detail.stdout()).toContain(
|
|
1134
|
+
"engine access: https://anyrun.example/proxy"
|
|
1135
|
+
);
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
it("reports an unknown slug with the gate hint", async () => {
|
|
1139
|
+
const io = capture();
|
|
1140
|
+
const code = await cmdDeploymentStatus("ghost", {
|
|
1141
|
+
team: String(TEAM_ID),
|
|
1142
|
+
apiKey: API_KEY,
|
|
1143
|
+
out: io.out,
|
|
1144
|
+
err: io.err,
|
|
1145
|
+
});
|
|
1146
|
+
expect(code).toBe(1);
|
|
1147
|
+
expect(io.stderr()).toMatch(/"ghost" was not found/);
|
|
1148
|
+
});
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
describe("cmdStop", () => {
|
|
1152
|
+
it("stops and polls to stopped", async () => {
|
|
1153
|
+
const seed = capture();
|
|
1154
|
+
await cmdDeploy({
|
|
1155
|
+
slug: "weather",
|
|
1156
|
+
team: String(TEAM_ID),
|
|
1157
|
+
apiKey: API_KEY,
|
|
1158
|
+
out: seed.out,
|
|
1159
|
+
err: seed.err,
|
|
1160
|
+
...fastPoll,
|
|
1161
|
+
});
|
|
1162
|
+
const io = capture();
|
|
1163
|
+
const code = await cmdStop("weather", {
|
|
1164
|
+
team: String(TEAM_ID),
|
|
1165
|
+
apiKey: API_KEY,
|
|
1166
|
+
out: io.out,
|
|
1167
|
+
err: io.err,
|
|
1168
|
+
...fastPoll,
|
|
1169
|
+
});
|
|
1170
|
+
expect(code).toBe(0);
|
|
1171
|
+
expect(io.stdout()).toContain("Stopped weather");
|
|
1172
|
+
expect(backend.deployments.get("weather")?.status).toBe("stopped");
|
|
1173
|
+
});
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
describe("cmdRotateToken", () => {
|
|
1177
|
+
it("prints the new token once with a warning", async () => {
|
|
1178
|
+
const seed = capture();
|
|
1179
|
+
await cmdDeploy({
|
|
1180
|
+
slug: "weather",
|
|
1181
|
+
team: String(TEAM_ID),
|
|
1182
|
+
apiKey: API_KEY,
|
|
1183
|
+
noWait: true,
|
|
1184
|
+
out: seed.out,
|
|
1185
|
+
err: seed.err,
|
|
1186
|
+
...fastPoll,
|
|
1187
|
+
});
|
|
1188
|
+
const io = capture();
|
|
1189
|
+
const code = await cmdRotateToken("weather", {
|
|
1190
|
+
team: String(TEAM_ID),
|
|
1191
|
+
apiKey: API_KEY,
|
|
1192
|
+
out: io.out,
|
|
1193
|
+
err: io.err,
|
|
1194
|
+
});
|
|
1195
|
+
expect(code).toBe(0);
|
|
1196
|
+
expect(io.stdout()).toContain("old token stopped working immediately");
|
|
1197
|
+
expect(io.stdout()).toContain("shown ONCE");
|
|
1198
|
+
expect(io.stdout()).toMatch(/alias_secret_rotated_\d+/);
|
|
1199
|
+
});
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
describe("cmdDeploy multi-agent parent", () => {
|
|
1203
|
+
async function writeMultiAgentParent(
|
|
1204
|
+
slugs: string[]
|
|
1205
|
+
): Promise<{ parent: string; children: Record<string, string> }> {
|
|
1206
|
+
const parent = await mkdtemp(join(tmpdir(), "agentkit-multi-"));
|
|
1207
|
+
tempDirs.push(parent);
|
|
1208
|
+
const children: Record<string, string> = {};
|
|
1209
|
+
for (const slug of slugs) {
|
|
1210
|
+
const dir = join(parent, slug);
|
|
1211
|
+
await mkdir(join(dir, "agent"), { recursive: true });
|
|
1212
|
+
await writeFile(
|
|
1213
|
+
join(dir, "agent", "agent.mjs"),
|
|
1214
|
+
`export default { __agentServe: "agent", model: "composer-2.5" };\n`
|
|
1215
|
+
);
|
|
1216
|
+
await writeFile(join(dir, "agent", "instructions.md"), `${slug}\n`);
|
|
1217
|
+
children[slug] = dir;
|
|
1218
|
+
}
|
|
1219
|
+
return { parent, children };
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
it("auto-selects the only child under a multi-agent parent", async () => {
|
|
1223
|
+
const { parent } = await writeMultiAgentParent(["weather-agent"]);
|
|
1224
|
+
const io = capture();
|
|
1225
|
+
const code = await cmdDeploy({
|
|
1226
|
+
dir: parent,
|
|
1227
|
+
team: String(TEAM_ID),
|
|
1228
|
+
apiKey: API_KEY,
|
|
1229
|
+
noWait: true,
|
|
1230
|
+
out: io.out,
|
|
1231
|
+
err: io.err,
|
|
1232
|
+
...fastPoll,
|
|
1233
|
+
});
|
|
1234
|
+
expect(code).toBe(0);
|
|
1235
|
+
expect(io.stderr()).toContain("only agent under");
|
|
1236
|
+
expect(backend.deployments.has("weather-agent")).toBe(true);
|
|
1237
|
+
});
|
|
1238
|
+
|
|
1239
|
+
it("deploys every child with --all", async () => {
|
|
1240
|
+
const { parent } = await writeMultiAgentParent([
|
|
1241
|
+
"approval-buddy",
|
|
1242
|
+
"weather-agent",
|
|
1243
|
+
]);
|
|
1244
|
+
const io = capture();
|
|
1245
|
+
const code = await cmdDeploy({
|
|
1246
|
+
dir: parent,
|
|
1247
|
+
all: true,
|
|
1248
|
+
team: String(TEAM_ID),
|
|
1249
|
+
apiKey: API_KEY,
|
|
1250
|
+
noWait: true,
|
|
1251
|
+
out: io.out,
|
|
1252
|
+
err: io.err,
|
|
1253
|
+
...fastPoll,
|
|
1254
|
+
});
|
|
1255
|
+
expect(code).toBe(0);
|
|
1256
|
+
expect(backend.deployments.has("approval-buddy")).toBe(true);
|
|
1257
|
+
expect(backend.deployments.has("weather-agent")).toBe(true);
|
|
1258
|
+
expect(io.stderr()).toContain("Deployed 2 agents");
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
it("rejects --all on a single agent project directory", async () => {
|
|
1262
|
+
const dir = await writeManifestProject({});
|
|
1263
|
+
const io = capture();
|
|
1264
|
+
const code = await cmdDeploy({
|
|
1265
|
+
dir,
|
|
1266
|
+
all: true,
|
|
1267
|
+
team: String(TEAM_ID),
|
|
1268
|
+
apiKey: API_KEY,
|
|
1269
|
+
noWait: true,
|
|
1270
|
+
out: io.out,
|
|
1271
|
+
err: io.err,
|
|
1272
|
+
...fastPoll,
|
|
1273
|
+
});
|
|
1274
|
+
expect(code).toBe(1);
|
|
1275
|
+
expect(io.stderr()).toContain("--all requires a multi-agent parent");
|
|
1276
|
+
expect(backend.deployCount).toBe(0);
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
it("ignores --path when deploying multiple agents", async () => {
|
|
1280
|
+
const { parent } = await writeMultiAgentParent([
|
|
1281
|
+
"approval-buddy",
|
|
1282
|
+
"weather-agent",
|
|
1283
|
+
]);
|
|
1284
|
+
const io = capture();
|
|
1285
|
+
const code = await cmdDeploy({
|
|
1286
|
+
dir: parent,
|
|
1287
|
+
all: true,
|
|
1288
|
+
path: "agents/wrong",
|
|
1289
|
+
team: String(TEAM_ID),
|
|
1290
|
+
apiKey: API_KEY,
|
|
1291
|
+
repo: "https://github.com/acme/agents",
|
|
1292
|
+
ref: "main",
|
|
1293
|
+
noWait: true,
|
|
1294
|
+
out: io.out,
|
|
1295
|
+
err: io.err,
|
|
1296
|
+
...fastPoll,
|
|
1297
|
+
});
|
|
1298
|
+
expect(code).toBe(0);
|
|
1299
|
+
expect(io.stderr()).toContain("ignoring --path");
|
|
1300
|
+
// Without per-agent git inference (tmpdir has no git), path stays unset
|
|
1301
|
+
// rather than the shared override.
|
|
1302
|
+
expect(backend.lastDeployBody?.agentPath).toBeUndefined();
|
|
1303
|
+
});
|
|
1304
|
+
|
|
1305
|
+
it("picks one child via --slug under a multi-agent parent", async () => {
|
|
1306
|
+
const { parent } = await writeMultiAgentParent([
|
|
1307
|
+
"approval-buddy",
|
|
1308
|
+
"weather-agent",
|
|
1309
|
+
]);
|
|
1310
|
+
const io = capture();
|
|
1311
|
+
const code = await cmdDeploy({
|
|
1312
|
+
dir: parent,
|
|
1313
|
+
slug: "weather-agent",
|
|
1314
|
+
team: String(TEAM_ID),
|
|
1315
|
+
apiKey: API_KEY,
|
|
1316
|
+
noWait: true,
|
|
1317
|
+
out: io.out,
|
|
1318
|
+
err: io.err,
|
|
1319
|
+
...fastPoll,
|
|
1320
|
+
});
|
|
1321
|
+
expect(code).toBe(0);
|
|
1322
|
+
expect(backend.deployments.has("weather-agent")).toBe(true);
|
|
1323
|
+
expect(backend.deployments.has("approval-buddy")).toBe(false);
|
|
1324
|
+
});
|
|
1325
|
+
|
|
1326
|
+
it("uses the interactive chooser when multiple agents and no --slug/--all", async () => {
|
|
1327
|
+
const { parent, children } = await writeMultiAgentParent([
|
|
1328
|
+
"approval-buddy",
|
|
1329
|
+
"weather-agent",
|
|
1330
|
+
]);
|
|
1331
|
+
const io = capture();
|
|
1332
|
+
const code = await cmdDeploy({
|
|
1333
|
+
dir: parent,
|
|
1334
|
+
team: String(TEAM_ID),
|
|
1335
|
+
apiKey: API_KEY,
|
|
1336
|
+
noWait: true,
|
|
1337
|
+
chooseTargets: async (agents) => {
|
|
1338
|
+
expect(agents.map((a) => a.slug).sort()).toEqual([
|
|
1339
|
+
"approval-buddy",
|
|
1340
|
+
"weather-agent",
|
|
1341
|
+
]);
|
|
1342
|
+
return [agents.find((a) => a.slug === "approval-buddy")!];
|
|
1343
|
+
},
|
|
1344
|
+
out: io.out,
|
|
1345
|
+
err: io.err,
|
|
1346
|
+
...fastPoll,
|
|
1347
|
+
});
|
|
1348
|
+
expect(code).toBe(0);
|
|
1349
|
+
expect(backend.deployments.has("approval-buddy")).toBe(true);
|
|
1350
|
+
expect(backend.deployments.has("weather-agent")).toBe(false);
|
|
1351
|
+
expect(children["approval-buddy"]).toBeDefined();
|
|
1352
|
+
});
|
|
1353
|
+
|
|
1354
|
+
it("requires --slug or --all when non-interactive and multiple agents", async () => {
|
|
1355
|
+
const { parent } = await writeMultiAgentParent([
|
|
1356
|
+
"approval-buddy",
|
|
1357
|
+
"weather-agent",
|
|
1358
|
+
]);
|
|
1359
|
+
const io = capture();
|
|
1360
|
+
const code = await cmdDeploy({
|
|
1361
|
+
dir: parent,
|
|
1362
|
+
team: String(TEAM_ID),
|
|
1363
|
+
apiKey: API_KEY,
|
|
1364
|
+
noWait: true,
|
|
1365
|
+
chooseTargets: async () => [],
|
|
1366
|
+
out: io.out,
|
|
1367
|
+
err: io.err,
|
|
1368
|
+
...fastPoll,
|
|
1369
|
+
});
|
|
1370
|
+
expect(code).toBe(1);
|
|
1371
|
+
expect(io.stderr()).toMatch(/Pass --slug|--all/);
|
|
1372
|
+
expect(backend.deployCount).toBe(0);
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
it("promptChooseDeployTargets accepts number, slug, all, and cancel", async () => {
|
|
1376
|
+
const agents = [
|
|
1377
|
+
{ slug: "approval-buddy", dir: "/a" },
|
|
1378
|
+
{ slug: "weather-agent", dir: "/w" },
|
|
1379
|
+
];
|
|
1380
|
+
const ask = async (line: string) => {
|
|
1381
|
+
const stdin = new PassThrough() as PassThrough & SecretInputStream;
|
|
1382
|
+
stdin.isTTY = true;
|
|
1383
|
+
queueMicrotask(() => {
|
|
1384
|
+
stdin.write(`${line}\n`);
|
|
1385
|
+
stdin.end();
|
|
1386
|
+
});
|
|
1387
|
+
const err = capture();
|
|
1388
|
+
return promptChooseDeployTargets(agents, {
|
|
1389
|
+
err: err.err,
|
|
1390
|
+
stdin,
|
|
1391
|
+
interactive: true,
|
|
1392
|
+
});
|
|
1393
|
+
};
|
|
1394
|
+
await expect(ask("2")).resolves.toEqual([agents[1]]);
|
|
1395
|
+
await expect(ask("approval-buddy")).resolves.toEqual([agents[0]]);
|
|
1396
|
+
await expect(ask("all")).resolves.toEqual(agents);
|
|
1397
|
+
await expect(ask("q")).resolves.toBeNull();
|
|
1398
|
+
});
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
describe("cmdDeploy git source inference", () => {
|
|
1402
|
+
it("fills repo/ref/path from git when deploying an agent project", async () => {
|
|
1403
|
+
const dir = await writeManifestProject({});
|
|
1404
|
+
const gitForDir = async (
|
|
1405
|
+
args: readonly string[]
|
|
1406
|
+
): Promise<string | undefined> => {
|
|
1407
|
+
const key = args.join(" ");
|
|
1408
|
+
if (key === "rev-parse --show-toplevel") {
|
|
1409
|
+
// Parent of the fixture dir → agentPath = basename(dir).
|
|
1410
|
+
return join(dir, "..");
|
|
1411
|
+
}
|
|
1412
|
+
if (key === "remote get-url origin") {
|
|
1413
|
+
return "git@github.com:acme/agents.git";
|
|
1414
|
+
}
|
|
1415
|
+
if (key === "rev-parse --abbrev-ref HEAD") {
|
|
1416
|
+
return "release";
|
|
1417
|
+
}
|
|
1418
|
+
return undefined;
|
|
1419
|
+
};
|
|
1420
|
+
const io = capture();
|
|
1421
|
+
const code = await cmdDeploy({
|
|
1422
|
+
dir,
|
|
1423
|
+
team: String(TEAM_ID),
|
|
1424
|
+
apiKey: API_KEY,
|
|
1425
|
+
git: gitForDir,
|
|
1426
|
+
noWait: true,
|
|
1427
|
+
out: io.out,
|
|
1428
|
+
err: io.err,
|
|
1429
|
+
...fastPoll,
|
|
1430
|
+
});
|
|
1431
|
+
expect(code).toBe(0);
|
|
1432
|
+
expect(backend.lastDeployBody).toMatchObject({
|
|
1433
|
+
gitRepoUrl: "https://github.com/acme/agents",
|
|
1434
|
+
gitRef: "release",
|
|
1435
|
+
agentPath: expect.stringMatching(/agentkit-manifest-/),
|
|
1436
|
+
});
|
|
1437
|
+
expect(io.stderr()).toContain("inferred repo, ref, path from git");
|
|
1438
|
+
expect(io.stderr()).toContain("https://github.com/acme/agents @ release");
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1441
|
+
it("lets explicit --repo/--ref/--path win over git inference", async () => {
|
|
1442
|
+
const dir = await writeManifestProject({});
|
|
1443
|
+
const io = capture();
|
|
1444
|
+
const code = await cmdDeploy({
|
|
1445
|
+
dir,
|
|
1446
|
+
slug: "weather",
|
|
1447
|
+
team: String(TEAM_ID),
|
|
1448
|
+
apiKey: API_KEY,
|
|
1449
|
+
repo: "https://github.com/acme/explicit",
|
|
1450
|
+
ref: "v1",
|
|
1451
|
+
path: "agents/weather",
|
|
1452
|
+
git: async () => {
|
|
1453
|
+
throw new Error("git should not be probed when all flags are set");
|
|
1454
|
+
},
|
|
1455
|
+
noWait: true,
|
|
1456
|
+
out: io.out,
|
|
1457
|
+
err: io.err,
|
|
1458
|
+
...fastPoll,
|
|
1459
|
+
});
|
|
1460
|
+
expect(code).toBe(0);
|
|
1461
|
+
expect(backend.lastDeployBody).toMatchObject({
|
|
1462
|
+
gitRepoUrl: "https://github.com/acme/explicit",
|
|
1463
|
+
gitRef: "v1",
|
|
1464
|
+
agentPath: "agents/weather",
|
|
1465
|
+
});
|
|
1466
|
+
expect(io.stderr()).not.toContain("inferred");
|
|
1467
|
+
});
|
|
1468
|
+
});
|
|
1469
|
+
|
|
1470
|
+
describe("cmdDeploy hosting manifest + repo flags", () => {
|
|
1471
|
+
it("sends manifest egressDomains unioned with --allow-domain", async () => {
|
|
1472
|
+
const dir = await writeManifestProject({
|
|
1473
|
+
egressDomains: ["api.example.com", "*.internal.example.com"],
|
|
1474
|
+
});
|
|
1475
|
+
const io = capture();
|
|
1476
|
+
const code = await cmdDeploy({
|
|
1477
|
+
dir,
|
|
1478
|
+
slug: "weather",
|
|
1479
|
+
team: String(TEAM_ID),
|
|
1480
|
+
apiKey: API_KEY,
|
|
1481
|
+
repo: "https://github.com/acme/agents",
|
|
1482
|
+
allowDomains: ["api.example.com", "extra.example.org"],
|
|
1483
|
+
noWait: true,
|
|
1484
|
+
out: io.out,
|
|
1485
|
+
err: io.err,
|
|
1486
|
+
...fastPoll,
|
|
1487
|
+
});
|
|
1488
|
+
expect(code).toBe(0);
|
|
1489
|
+
expect(backend.lastDeployBody?.egressAllowedDomains).toEqual([
|
|
1490
|
+
"api.example.com",
|
|
1491
|
+
"*.internal.example.com",
|
|
1492
|
+
"extra.example.org",
|
|
1493
|
+
]);
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
it("maps --repo/--ref/--path/--cursor-events-repo into the deploy body", async () => {
|
|
1497
|
+
const io = capture();
|
|
1498
|
+
const code = await cmdDeploy({
|
|
1499
|
+
slug: "weather",
|
|
1500
|
+
team: String(TEAM_ID),
|
|
1501
|
+
apiKey: API_KEY,
|
|
1502
|
+
repo: "https://github.com/acme/agents",
|
|
1503
|
+
ref: "release-1",
|
|
1504
|
+
path: "agents/weather",
|
|
1505
|
+
cursorEventsRepos: ["acme/agents", "acme/infra"],
|
|
1506
|
+
noWait: true,
|
|
1507
|
+
out: io.out,
|
|
1508
|
+
err: io.err,
|
|
1509
|
+
...fastPoll,
|
|
1510
|
+
});
|
|
1511
|
+
expect(code).toBe(0);
|
|
1512
|
+
expect(backend.lastDeployBody).toMatchObject({
|
|
1513
|
+
gitRepoUrl: "https://github.com/acme/agents",
|
|
1514
|
+
gitRef: "release-1",
|
|
1515
|
+
agentPath: "agents/weather",
|
|
1516
|
+
cursorEventRepos: ["acme/agents", "acme/infra"],
|
|
1517
|
+
});
|
|
1518
|
+
expect(backend.deployments.get("weather")?.deploymentKind).toBe("engine");
|
|
1519
|
+
});
|
|
1520
|
+
|
|
1521
|
+
it("rejects a non-https --repo and a malformed --cursor-events-repo before the API", async () => {
|
|
1522
|
+
const nonHttps = capture();
|
|
1523
|
+
const nonHttpsCode = await cmdDeploy({
|
|
1524
|
+
slug: "weather",
|
|
1525
|
+
team: String(TEAM_ID),
|
|
1526
|
+
apiKey: API_KEY,
|
|
1527
|
+
repo: "git@github.com:acme/agents.git",
|
|
1528
|
+
noWait: true,
|
|
1529
|
+
out: nonHttps.out,
|
|
1530
|
+
err: nonHttps.err,
|
|
1531
|
+
...fastPoll,
|
|
1532
|
+
});
|
|
1533
|
+
expect(nonHttpsCode).toBe(1);
|
|
1534
|
+
expect(nonHttps.stderr()).toMatch(/Invalid --repo/);
|
|
1535
|
+
|
|
1536
|
+
// Cursor-event repos without a resolvable https origin fail before the
|
|
1537
|
+
// owner/name grammar check (would otherwise become an invalid static deploy).
|
|
1538
|
+
const missingRepo = capture();
|
|
1539
|
+
const missingRepoCode = await cmdDeploy({
|
|
1540
|
+
slug: "weather",
|
|
1541
|
+
team: String(TEAM_ID),
|
|
1542
|
+
apiKey: API_KEY,
|
|
1543
|
+
cursorEventsRepos: ["not-a-repo"],
|
|
1544
|
+
noWait: true,
|
|
1545
|
+
out: missingRepo.out,
|
|
1546
|
+
err: missingRepo.err,
|
|
1547
|
+
...fastPoll,
|
|
1548
|
+
});
|
|
1549
|
+
expect(missingRepoCode).toBe(1);
|
|
1550
|
+
expect(missingRepo.stderr()).toContain(
|
|
1551
|
+
"Could not resolve an https:// git repository URL"
|
|
1552
|
+
);
|
|
1553
|
+
|
|
1554
|
+
const badEventsRepo = capture();
|
|
1555
|
+
const badEventsRepoCode = await cmdDeploy({
|
|
1556
|
+
slug: "weather",
|
|
1557
|
+
team: String(TEAM_ID),
|
|
1558
|
+
apiKey: API_KEY,
|
|
1559
|
+
repo: "https://github.com/acme/agents",
|
|
1560
|
+
cursorEventsRepos: ["not-a-repo"],
|
|
1561
|
+
noWait: true,
|
|
1562
|
+
out: badEventsRepo.out,
|
|
1563
|
+
err: badEventsRepo.err,
|
|
1564
|
+
...fastPoll,
|
|
1565
|
+
});
|
|
1566
|
+
expect(badEventsRepoCode).toBe(1);
|
|
1567
|
+
expect(badEventsRepo.stderr()).toMatch(/Invalid --cursor-events-repo/);
|
|
1568
|
+
|
|
1569
|
+
expect(backend.deployCount).toBe(0);
|
|
1570
|
+
});
|
|
1571
|
+
|
|
1572
|
+
it("rejects an invalid --allow-domain client-side without calling the API", async () => {
|
|
1573
|
+
const io = capture();
|
|
1574
|
+
const code = await cmdDeploy({
|
|
1575
|
+
slug: "weather",
|
|
1576
|
+
team: String(TEAM_ID),
|
|
1577
|
+
apiKey: API_KEY,
|
|
1578
|
+
repo: "https://github.com/acme/agents",
|
|
1579
|
+
allowDomains: ["Not_A_Domain"],
|
|
1580
|
+
noWait: true,
|
|
1581
|
+
out: io.out,
|
|
1582
|
+
err: io.err,
|
|
1583
|
+
...fastPoll,
|
|
1584
|
+
});
|
|
1585
|
+
expect(code).toBe(1);
|
|
1586
|
+
expect(io.stderr()).toMatch(/Invalid egress domain "Not_A_Domain"/);
|
|
1587
|
+
expect(backend.deployCount).toBe(0);
|
|
1588
|
+
});
|
|
1589
|
+
|
|
1590
|
+
it("rejects egress on a static (repo-less) deploy before the API", async () => {
|
|
1591
|
+
const io = capture();
|
|
1592
|
+
const code = await cmdDeploy({
|
|
1593
|
+
slug: "weather",
|
|
1594
|
+
team: String(TEAM_ID),
|
|
1595
|
+
apiKey: API_KEY,
|
|
1596
|
+
allowDomains: ["api.example.com"],
|
|
1597
|
+
noWait: true,
|
|
1598
|
+
out: io.out,
|
|
1599
|
+
err: io.err,
|
|
1600
|
+
...fastPoll,
|
|
1601
|
+
});
|
|
1602
|
+
expect(code).toBe(1);
|
|
1603
|
+
expect(io.stderr()).toContain(
|
|
1604
|
+
"Could not resolve an https:// git repository URL"
|
|
1605
|
+
);
|
|
1606
|
+
expect(backend.deployCount).toBe(0);
|
|
1607
|
+
});
|
|
1608
|
+
|
|
1609
|
+
it("warns about declared-but-unset secrets after deploy, and stops once they are set", async () => {
|
|
1610
|
+
const dir = await writeManifestProject({
|
|
1611
|
+
secretNames: ["OPENAI_API_KEY", "WEATHER_TOKEN"],
|
|
1612
|
+
});
|
|
1613
|
+
const first = capture();
|
|
1614
|
+
const code = await cmdDeploy({
|
|
1615
|
+
dir,
|
|
1616
|
+
slug: "weather",
|
|
1617
|
+
team: String(TEAM_ID),
|
|
1618
|
+
apiKey: API_KEY,
|
|
1619
|
+
repo: "https://github.com/acme/agents",
|
|
1620
|
+
out: first.out,
|
|
1621
|
+
err: first.err,
|
|
1622
|
+
...fastPoll,
|
|
1623
|
+
});
|
|
1624
|
+
expect(code).toBe(0);
|
|
1625
|
+
expect(first.stderr()).toContain(
|
|
1626
|
+
"not set on weather: OPENAI_API_KEY, WEATHER_TOKEN"
|
|
1627
|
+
);
|
|
1628
|
+
expect(first.stderr()).toContain(
|
|
1629
|
+
"agentkit secrets set weather OPENAI_API_KEY WEATHER_TOKEN"
|
|
1630
|
+
);
|
|
1631
|
+
|
|
1632
|
+
await makeClient().setSecrets({
|
|
1633
|
+
teamId: TEAM_ID,
|
|
1634
|
+
slug: "weather",
|
|
1635
|
+
secrets: [{ name: "OPENAI_API_KEY", value: "sk-1" }],
|
|
1636
|
+
});
|
|
1637
|
+
const second = capture();
|
|
1638
|
+
await cmdDeploy({
|
|
1639
|
+
dir,
|
|
1640
|
+
slug: "weather",
|
|
1641
|
+
team: String(TEAM_ID),
|
|
1642
|
+
apiKey: API_KEY,
|
|
1643
|
+
out: second.out,
|
|
1644
|
+
err: second.err,
|
|
1645
|
+
...fastPoll,
|
|
1646
|
+
});
|
|
1647
|
+
expect(second.stderr()).toContain("not set on weather: WEATHER_TOKEN");
|
|
1648
|
+
expect(second.stderr()).not.toContain("OPENAI_API_KEY");
|
|
1649
|
+
|
|
1650
|
+
await makeClient().setSecrets({
|
|
1651
|
+
teamId: TEAM_ID,
|
|
1652
|
+
slug: "weather",
|
|
1653
|
+
secrets: [{ name: "WEATHER_TOKEN", value: "t-1" }],
|
|
1654
|
+
});
|
|
1655
|
+
const third = capture();
|
|
1656
|
+
await cmdDeploy({
|
|
1657
|
+
dir,
|
|
1658
|
+
slug: "weather",
|
|
1659
|
+
team: String(TEAM_ID),
|
|
1660
|
+
apiKey: API_KEY,
|
|
1661
|
+
out: third.out,
|
|
1662
|
+
err: third.err,
|
|
1663
|
+
...fastPoll,
|
|
1664
|
+
});
|
|
1665
|
+
expect(third.stderr()).not.toContain("declared in the agent manifest");
|
|
1666
|
+
});
|
|
1667
|
+
});
|
|
1668
|
+
|
|
1669
|
+
describe("secrets", () => {
|
|
1670
|
+
async function seedRepoDeploy(slug = "weather"): Promise<void> {
|
|
1671
|
+
await makeClient().deploy({
|
|
1672
|
+
teamId: TEAM_ID,
|
|
1673
|
+
slug,
|
|
1674
|
+
source: {
|
|
1675
|
+
kind: "git-ref",
|
|
1676
|
+
gitRepoUrl: "https://github.com/acme/agents",
|
|
1677
|
+
},
|
|
1678
|
+
});
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
it("sets secrets from argv with a shell-history warning and upserts by name", async () => {
|
|
1682
|
+
await seedRepoDeploy();
|
|
1683
|
+
const first = capture();
|
|
1684
|
+
const code = await cmdSecretsSet(
|
|
1685
|
+
"weather",
|
|
1686
|
+
["OPENAI_API_KEY=sk-1", "WEATHER_TOKEN=t-1"],
|
|
1687
|
+
{
|
|
1688
|
+
team: String(TEAM_ID),
|
|
1689
|
+
apiKey: API_KEY,
|
|
1690
|
+
out: first.out,
|
|
1691
|
+
err: first.err,
|
|
1692
|
+
}
|
|
1693
|
+
);
|
|
1694
|
+
expect(code).toBe(0);
|
|
1695
|
+
expect(first.stderr()).toContain("shell history");
|
|
1696
|
+
expect(first.stdout()).toContain(
|
|
1697
|
+
"Secrets now set: OPENAI_API_KEY, WEATHER_TOKEN"
|
|
1698
|
+
);
|
|
1699
|
+
|
|
1700
|
+
// Upsert: overwrite one, add one; the untouched name stays.
|
|
1701
|
+
const second = capture();
|
|
1702
|
+
await cmdSecretsSet("weather", ["WEATHER_TOKEN=t-2", "EXTRA_TOKEN=e-1"], {
|
|
1703
|
+
team: String(TEAM_ID),
|
|
1704
|
+
apiKey: API_KEY,
|
|
1705
|
+
out: second.out,
|
|
1706
|
+
err: second.err,
|
|
1707
|
+
});
|
|
1708
|
+
expect(second.stdout()).toContain(
|
|
1709
|
+
"Secrets now set: EXTRA_TOKEN, OPENAI_API_KEY, WEATHER_TOKEN"
|
|
1710
|
+
);
|
|
1711
|
+
const stored = backend.deployments.get("weather")?.secrets;
|
|
1712
|
+
expect(stored?.get("WEATHER_TOKEN")?.value).toBe("t-2");
|
|
1713
|
+
expect(stored?.get("OPENAI_API_KEY")?.value).toBe("sk-1");
|
|
1714
|
+
});
|
|
1715
|
+
|
|
1716
|
+
it("reads values from piped stdin (one line per NAME) without echoing", async () => {
|
|
1717
|
+
await seedRepoDeploy();
|
|
1718
|
+
const stdin = new PassThrough();
|
|
1719
|
+
stdin.end("sk-piped\nt-piped\n");
|
|
1720
|
+
const io = capture();
|
|
1721
|
+
const code = await cmdSecretsSet(
|
|
1722
|
+
"weather",
|
|
1723
|
+
["OPENAI_API_KEY", "WEATHER_TOKEN"],
|
|
1724
|
+
{
|
|
1725
|
+
team: String(TEAM_ID),
|
|
1726
|
+
apiKey: API_KEY,
|
|
1727
|
+
stdin,
|
|
1728
|
+
json: true,
|
|
1729
|
+
out: io.out,
|
|
1730
|
+
err: io.err,
|
|
1731
|
+
}
|
|
1732
|
+
);
|
|
1733
|
+
expect(code).toBe(0);
|
|
1734
|
+
expect(io.stderr()).not.toContain("shell history");
|
|
1735
|
+
expect(io.stdout()).not.toContain("sk-piped");
|
|
1736
|
+
expect(JSON.parse(io.stdout())).toEqual({
|
|
1737
|
+
secretNames: ["OPENAI_API_KEY", "WEATHER_TOKEN"],
|
|
1738
|
+
});
|
|
1739
|
+
expect(
|
|
1740
|
+
backend.deployments.get("weather")?.secrets.get("OPENAI_API_KEY")?.value
|
|
1741
|
+
).toBe("sk-piped");
|
|
1742
|
+
});
|
|
1743
|
+
|
|
1744
|
+
it("prompts with hidden input on a TTY (backspace honored, no echo)", async () => {
|
|
1745
|
+
await seedRepoDeploy();
|
|
1746
|
+
const stdin = new PassThrough() as PassThrough & SecretInputStream;
|
|
1747
|
+
stdin.isTTY = true;
|
|
1748
|
+
const rawModes: boolean[] = [];
|
|
1749
|
+
stdin.setRawMode = (mode: boolean) => {
|
|
1750
|
+
rawModes.push(mode);
|
|
1751
|
+
};
|
|
1752
|
+
const io = capture();
|
|
1753
|
+
const pending = cmdSecretsSet("weather", ["OPENAI_API_KEY"], {
|
|
1754
|
+
team: String(TEAM_ID),
|
|
1755
|
+
apiKey: API_KEY,
|
|
1756
|
+
stdin,
|
|
1757
|
+
out: io.out,
|
|
1758
|
+
err: io.err,
|
|
1759
|
+
});
|
|
1760
|
+
// Wait for the prompt before "typing" (backspace fixes a typo).
|
|
1761
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, 50));
|
|
1762
|
+
stdin.write("sk-secreX\u007ft\r");
|
|
1763
|
+
const code = await pending;
|
|
1764
|
+
expect(code).toBe(0);
|
|
1765
|
+
expect(io.stderr()).toContain("Value for OPENAI_API_KEY:");
|
|
1766
|
+
expect(io.stderr()).not.toContain("sk-secret");
|
|
1767
|
+
expect(rawModes).toEqual([true, false]);
|
|
1768
|
+
expect(
|
|
1769
|
+
backend.deployments.get("weather")?.secrets.get("OPENAI_API_KEY")?.value
|
|
1770
|
+
).toBe("sk-secret");
|
|
1771
|
+
});
|
|
1772
|
+
|
|
1773
|
+
it("rejects reserved and malformed names client-side without calling the API", async () => {
|
|
1774
|
+
await seedRepoDeploy();
|
|
1775
|
+
const requestsBefore = backend.secretsPutCount;
|
|
1776
|
+
for (const [spec, message] of [
|
|
1777
|
+
["CURSOR_API_KEY=x", /platform-managed/],
|
|
1778
|
+
["lower_case=x", /Invalid secret name/],
|
|
1779
|
+
[`${"A".repeat(65)}=x`, /too long/],
|
|
1780
|
+
] as const) {
|
|
1781
|
+
const io = capture();
|
|
1782
|
+
const code = await cmdSecretsSet("weather", [spec], {
|
|
1783
|
+
team: String(TEAM_ID),
|
|
1784
|
+
apiKey: API_KEY,
|
|
1785
|
+
out: io.out,
|
|
1786
|
+
err: io.err,
|
|
1787
|
+
});
|
|
1788
|
+
expect(code).toBe(1);
|
|
1789
|
+
expect(io.stderr()).toMatch(message);
|
|
1790
|
+
}
|
|
1791
|
+
expect(backend.secretsPutCount).toBe(requestsBefore);
|
|
1792
|
+
});
|
|
1793
|
+
|
|
1794
|
+
it("surfaces the backend 400 for secrets on a static deployment", async () => {
|
|
1795
|
+
await makeClient().deploy({
|
|
1796
|
+
teamId: TEAM_ID,
|
|
1797
|
+
slug: "static",
|
|
1798
|
+
source: { kind: "git-ref" },
|
|
1799
|
+
});
|
|
1800
|
+
const io = capture();
|
|
1801
|
+
const code = await cmdSecretsSet("static", ["OPENAI_API_KEY=x"], {
|
|
1802
|
+
team: String(TEAM_ID),
|
|
1803
|
+
apiKey: API_KEY,
|
|
1804
|
+
out: io.out,
|
|
1805
|
+
err: io.err,
|
|
1806
|
+
});
|
|
1807
|
+
expect(code).toBe(1);
|
|
1808
|
+
expect(io.stderr()).toContain("redeploy with a gitRepoUrl first");
|
|
1809
|
+
});
|
|
1810
|
+
|
|
1811
|
+
it("lists names + createdAt only (never values)", async () => {
|
|
1812
|
+
await seedRepoDeploy();
|
|
1813
|
+
await makeClient().setSecrets({
|
|
1814
|
+
teamId: TEAM_ID,
|
|
1815
|
+
slug: "weather",
|
|
1816
|
+
secrets: [
|
|
1817
|
+
{ name: "OPENAI_API_KEY", value: "sk-1" },
|
|
1818
|
+
{ name: "WEATHER_TOKEN", value: "t-1" },
|
|
1819
|
+
],
|
|
1820
|
+
});
|
|
1821
|
+
const io = capture();
|
|
1822
|
+
const code = await cmdSecretsList("weather", {
|
|
1823
|
+
team: String(TEAM_ID),
|
|
1824
|
+
apiKey: API_KEY,
|
|
1825
|
+
out: io.out,
|
|
1826
|
+
err: io.err,
|
|
1827
|
+
});
|
|
1828
|
+
expect(code).toBe(0);
|
|
1829
|
+
expect(io.stdout()).toMatch(/NAME\s+CREATED/);
|
|
1830
|
+
expect(io.stdout()).toMatch(/OPENAI_API_KEY\s+2026-07-14T00:00:00.000Z/);
|
|
1831
|
+
expect(io.stdout()).not.toContain("sk-1");
|
|
1832
|
+
|
|
1833
|
+
const json = capture();
|
|
1834
|
+
await cmdSecretsList("weather", {
|
|
1835
|
+
team: String(TEAM_ID),
|
|
1836
|
+
apiKey: API_KEY,
|
|
1837
|
+
json: true,
|
|
1838
|
+
out: json.out,
|
|
1839
|
+
err: json.err,
|
|
1840
|
+
});
|
|
1841
|
+
expect(JSON.parse(json.stdout())).toEqual({
|
|
1842
|
+
secrets: [
|
|
1843
|
+
{ name: "OPENAI_API_KEY", createdAt: "2026-07-14T00:00:00.000Z" },
|
|
1844
|
+
{ name: "WEATHER_TOKEN", createdAt: "2026-07-14T00:00:00.000Z" },
|
|
1845
|
+
],
|
|
1846
|
+
});
|
|
1847
|
+
});
|
|
1848
|
+
|
|
1849
|
+
it("unsets one secret and reports missing names clearly", async () => {
|
|
1850
|
+
await seedRepoDeploy();
|
|
1851
|
+
await makeClient().setSecrets({
|
|
1852
|
+
teamId: TEAM_ID,
|
|
1853
|
+
slug: "weather",
|
|
1854
|
+
secrets: [{ name: "OPENAI_API_KEY", value: "sk-1" }],
|
|
1855
|
+
});
|
|
1856
|
+
const io = capture();
|
|
1857
|
+
const code = await cmdSecretsUnset("weather", "OPENAI_API_KEY", {
|
|
1858
|
+
team: String(TEAM_ID),
|
|
1859
|
+
apiKey: API_KEY,
|
|
1860
|
+
out: io.out,
|
|
1861
|
+
err: io.err,
|
|
1862
|
+
});
|
|
1863
|
+
expect(code).toBe(0);
|
|
1864
|
+
expect(io.stdout()).toContain("Removed secret OPENAI_API_KEY");
|
|
1865
|
+
expect(backend.deployments.get("weather")?.secrets.size).toBe(0);
|
|
1866
|
+
|
|
1867
|
+
const missing = capture();
|
|
1868
|
+
const missingCode = await cmdSecretsUnset("weather", "OPENAI_API_KEY", {
|
|
1869
|
+
team: String(TEAM_ID),
|
|
1870
|
+
apiKey: API_KEY,
|
|
1871
|
+
out: missing.out,
|
|
1872
|
+
err: missing.err,
|
|
1873
|
+
});
|
|
1874
|
+
expect(missingCode).toBe(1);
|
|
1875
|
+
expect(missing.stderr()).toContain(
|
|
1876
|
+
"No secret named OPENAI_API_KEY on deployment weather"
|
|
1877
|
+
);
|
|
1878
|
+
|
|
1879
|
+
const reserved = capture();
|
|
1880
|
+
const reservedCode = await cmdSecretsUnset("weather", "CURSOR_API_KEY", {
|
|
1881
|
+
team: String(TEAM_ID),
|
|
1882
|
+
apiKey: API_KEY,
|
|
1883
|
+
out: reserved.out,
|
|
1884
|
+
err: reserved.err,
|
|
1885
|
+
});
|
|
1886
|
+
expect(reservedCode).toBe(1);
|
|
1887
|
+
expect(reserved.stderr()).toMatch(/platform-managed/);
|
|
1888
|
+
});
|
|
1889
|
+
});
|
|
1890
|
+
|
|
1891
|
+
describe("cmdRotatePodCredential", () => {
|
|
1892
|
+
it("prints the masked key and the redeploy note", async () => {
|
|
1893
|
+
await makeClient().deploy({
|
|
1894
|
+
teamId: TEAM_ID,
|
|
1895
|
+
slug: "weather",
|
|
1896
|
+
source: { kind: "git-ref" },
|
|
1897
|
+
});
|
|
1898
|
+
const io = capture();
|
|
1899
|
+
const code = await cmdRotatePodCredential("weather", {
|
|
1900
|
+
team: String(TEAM_ID),
|
|
1901
|
+
apiKey: API_KEY,
|
|
1902
|
+
out: io.out,
|
|
1903
|
+
err: io.err,
|
|
1904
|
+
});
|
|
1905
|
+
expect(code).toBe(0);
|
|
1906
|
+
expect(io.stdout()).toMatch(/sk_agent_\*\*\*\*rot\d+/);
|
|
1907
|
+
expect(io.stdout()).toContain("old credential until the next deploy");
|
|
1908
|
+
});
|
|
1909
|
+
});
|
|
1910
|
+
|
|
1911
|
+
describe("new response fields end-to-end", () => {
|
|
1912
|
+
it("shows secretNames, egress, service account, and pod credential in the detail view", async () => {
|
|
1913
|
+
const client = makeClient();
|
|
1914
|
+
await client.deploy({
|
|
1915
|
+
teamId: TEAM_ID,
|
|
1916
|
+
slug: "weather",
|
|
1917
|
+
source: {
|
|
1918
|
+
kind: "git-ref",
|
|
1919
|
+
gitRepoUrl: "https://github.com/acme/agents",
|
|
1920
|
+
egressAllowedDomains: ["api.example.com"],
|
|
1921
|
+
},
|
|
1922
|
+
});
|
|
1923
|
+
await client.setSecrets({
|
|
1924
|
+
teamId: TEAM_ID,
|
|
1925
|
+
slug: "weather",
|
|
1926
|
+
secrets: [{ name: "OPENAI_API_KEY", value: "sk-1" }],
|
|
1927
|
+
});
|
|
1928
|
+
|
|
1929
|
+
const detail = capture();
|
|
1930
|
+
const code = await cmdDeploymentStatus("weather", {
|
|
1931
|
+
team: String(TEAM_ID),
|
|
1932
|
+
apiKey: API_KEY,
|
|
1933
|
+
out: detail.out,
|
|
1934
|
+
err: detail.err,
|
|
1935
|
+
});
|
|
1936
|
+
expect(code).toBe(0);
|
|
1937
|
+
expect(detail.stdout()).toContain("service account: 4242");
|
|
1938
|
+
expect(detail.stdout()).toContain("egress: api.example.com");
|
|
1939
|
+
expect(detail.stdout()).toContain("secrets: OPENAI_API_KEY");
|
|
1940
|
+
expect(detail.stdout()).toMatch(/pod credential: sk_agent_\*\*\*\*/);
|
|
1941
|
+
|
|
1942
|
+
const json = capture();
|
|
1943
|
+
await cmdDeploymentStatus("weather", {
|
|
1944
|
+
team: String(TEAM_ID),
|
|
1945
|
+
apiKey: API_KEY,
|
|
1946
|
+
json: true,
|
|
1947
|
+
out: json.out,
|
|
1948
|
+
err: json.err,
|
|
1949
|
+
});
|
|
1950
|
+
const parsed = JSON.parse(json.stdout()) as Record<string, unknown>;
|
|
1951
|
+
expect(parsed.serviceAccountId).toBe(4242);
|
|
1952
|
+
expect(parsed.secretNames).toEqual(["OPENAI_API_KEY"]);
|
|
1953
|
+
expect(parsed.egressAllowedDomains).toEqual(["api.example.com"]);
|
|
1954
|
+
});
|
|
1955
|
+
});
|
|
1956
|
+
|
|
1957
|
+
describe("CLI dispatch (subprocess)", () => {
|
|
1958
|
+
it("runs `agentkit deployments --json` end to end through the bin entry", async () => {
|
|
1959
|
+
const client = makeClient();
|
|
1960
|
+
await client.deploy({
|
|
1961
|
+
teamId: TEAM_ID,
|
|
1962
|
+
slug: "weather",
|
|
1963
|
+
source: { kind: "git-ref" },
|
|
1964
|
+
});
|
|
1965
|
+
|
|
1966
|
+
const packageRoot = join(import.meta.dirname, "..", "..");
|
|
1967
|
+
const { stdout } = await execFileAsync(
|
|
1968
|
+
join(packageRoot, "node_modules", ".bin", "tsx"),
|
|
1969
|
+
[
|
|
1970
|
+
join(packageRoot, "src", "bin", "agent-serve.ts"),
|
|
1971
|
+
"deployments",
|
|
1972
|
+
"--team",
|
|
1973
|
+
String(TEAM_ID),
|
|
1974
|
+
"--json",
|
|
1975
|
+
],
|
|
1976
|
+
{
|
|
1977
|
+
env: {
|
|
1978
|
+
...process.env,
|
|
1979
|
+
CURSOR_API_BASE_URL: backend.url,
|
|
1980
|
+
CURSOR_API_KEY: API_KEY,
|
|
1981
|
+
},
|
|
1982
|
+
timeout: 25_000,
|
|
1983
|
+
}
|
|
1984
|
+
);
|
|
1985
|
+
const parsed = JSON.parse(stdout) as {
|
|
1986
|
+
deployments: Array<{ slug: string; status: string }>;
|
|
1987
|
+
};
|
|
1988
|
+
expect(parsed.deployments).toHaveLength(1);
|
|
1989
|
+
expect(parsed.deployments[0]?.slug).toBe("weather");
|
|
1990
|
+
}, 30_000);
|
|
1991
|
+
});
|