@aexhq/cli 0.25.1 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/dist/index.d.ts +2 -7
- package/dist/index.js +233 -7
- package/package.json +26 -29
- package/LICENSE +0 -201
- package/dist/cli.d.ts +0 -1
- package/dist/cli.js +0 -51
- package/dist/cli.js.map +0 -1
- package/dist/cli.mjs +0 -4657
- package/dist/cli.mjs.sha256 +0 -1
- package/dist/host/cancel.d.ts +0 -3
- package/dist/host/cancel.js +0 -30
- package/dist/host/cancel.js.map +0 -1
- package/dist/host/common.d.ts +0 -135
- package/dist/host/common.js +0 -281
- package/dist/host/common.js.map +0 -1
- package/dist/host/delete-asset.d.ts +0 -3
- package/dist/host/delete-asset.js +0 -34
- package/dist/host/delete-asset.js.map +0 -1
- package/dist/host/delete.d.ts +0 -3
- package/dist/host/delete.js +0 -30
- package/dist/host/delete.js.map +0 -1
- package/dist/host/download.d.ts +0 -3
- package/dist/host/download.js +0 -80
- package/dist/host/download.js.map +0 -1
- package/dist/host/events.d.ts +0 -3
- package/dist/host/events.js +0 -97
- package/dist/host/events.js.map +0 -1
- package/dist/host/index.d.ts +0 -17
- package/dist/host/index.js +0 -18
- package/dist/host/index.js.map +0 -1
- package/dist/host/outputs.d.ts +0 -3
- package/dist/host/outputs.js +0 -36
- package/dist/host/outputs.js.map +0 -1
- package/dist/host/run-cmd.d.ts +0 -3
- package/dist/host/run-cmd.js +0 -549
- package/dist/host/run-cmd.js.map +0 -1
- package/dist/host/skills-cmd.d.ts +0 -3
- package/dist/host/skills-cmd.js +0 -304
- package/dist/host/skills-cmd.js.map +0 -1
- package/dist/host/status.d.ts +0 -3
- package/dist/host/status.js +0 -31
- package/dist/host/status.js.map +0 -1
- package/dist/host/wait.d.ts +0 -3
- package/dist/host/wait.js +0 -88
- package/dist/host/wait.js.map +0 -1
- package/dist/host/whoami.d.ts +0 -3
- package/dist/host/whoami.js +0 -33
- package/dist/host/whoami.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/internal.d.ts +0 -53
- package/dist/internal.js +0 -10
- package/dist/internal.js.map +0 -1
- package/dist/outputs-sync.d.ts +0 -24
- package/dist/outputs-sync.js +0 -70
- package/dist/outputs-sync.js.map +0 -1
- package/dist/proxy.d.ts +0 -16
- package/dist/proxy.js +0 -316
- package/dist/proxy.js.map +0 -1
- package/dist/run.d.ts +0 -10
- package/dist/run.js +0 -166
- package/dist/run.js.map +0 -1
- package/dist/runtime.d.ts +0 -5
- package/dist/runtime.js +0 -4
- package/dist/runtime.js.map +0 -1
- package/scripts/finalize-bundle.mjs +0 -72
- package/src/cli.ts +0 -51
- package/src/host/cancel.ts +0 -39
- package/src/host/common.ts +0 -329
- package/src/host/delete-asset.ts +0 -43
- package/src/host/delete.ts +0 -39
- package/src/host/download.ts +0 -97
- package/src/host/events.ts +0 -113
- package/src/host/index.ts +0 -25
- package/src/host/outputs.ts +0 -45
- package/src/host/run-cmd.ts +0 -578
- package/src/host/skills-cmd.ts +0 -317
- package/src/host/status.ts +0 -40
- package/src/host/wait.ts +0 -110
- package/src/host/whoami.ts +0 -42
- package/src/index.ts +0 -7
- package/src/internal.ts +0 -63
- package/src/outputs-sync.ts +0 -76
- package/src/proxy.ts +0 -359
- package/src/run.ts +0 -187
- package/src/runtime.ts +0 -15
- package/test/cli.test.ts +0 -519
- package/test/host.test.ts +0 -1154
- package/test/no-env-vars.test.ts +0 -59
- package/test/outputs-sync.test.ts +0 -107
- package/tsconfig.build.json +0 -9
- package/tsconfig.json +0 -4
- package/vitest.config.ts +0 -14
package/test/host.test.ts
DELETED
|
@@ -1,1154 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { strToU8, unzipSync } from "fflate";
|
|
3
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
5
|
-
import { join, resolve as resolvePath } from "node:path";
|
|
6
|
-
import { runCli } from "../src/run.js";
|
|
7
|
-
import { parseDuration } from "../src/host/common.js";
|
|
8
|
-
import type { CliIO } from "../src/internal.js";
|
|
9
|
-
|
|
10
|
-
const CWD = "/tmp/cli-test";
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Make a real temp directory containing a SKILL.md file so the
|
|
14
|
-
* `aex skills upload --file ...` path can read actual bytes.
|
|
15
|
-
* The skills-cmd module reads from `node:fs/promises` directly (not the
|
|
16
|
-
* CliIO abstraction), so virtualizing files via `makeHostIo({files})`
|
|
17
|
-
* does not work for this path. The test is responsible for cleanup.
|
|
18
|
-
*/
|
|
19
|
-
function makeSkillsTmpDir(skillBody: string): { dir: string; cleanup: () => void } {
|
|
20
|
-
const dir = mkdtempSync(join(tmpdir(), "aex-cli-skills-"));
|
|
21
|
-
writeFileSync(join(dir, "SKILL.md"), skillBody, "utf8");
|
|
22
|
-
return {
|
|
23
|
-
dir,
|
|
24
|
-
cleanup: () => {
|
|
25
|
-
try { rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
26
|
-
}
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** Compute the absolute path the run-config loader will produce for a
|
|
31
|
-
* given input — keeps tests cross-platform between Windows and POSIX. */
|
|
32
|
-
function resolvedFromCwd(p: string): string {
|
|
33
|
-
return resolvePath(CWD, p);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
interface FetchCall {
|
|
37
|
-
url: string;
|
|
38
|
-
init: RequestInit;
|
|
39
|
-
body: unknown;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function makeHostIo(opts: {
|
|
43
|
-
argv: readonly string[];
|
|
44
|
-
fetchHandler?: (call: FetchCall) => Response;
|
|
45
|
-
files?: Record<string, string>;
|
|
46
|
-
writes?: Map<string, Uint8Array>;
|
|
47
|
-
}): {
|
|
48
|
-
io: CliIO;
|
|
49
|
-
stdout: string;
|
|
50
|
-
stderr: string;
|
|
51
|
-
exitCode: number | null;
|
|
52
|
-
calls: FetchCall[];
|
|
53
|
-
} {
|
|
54
|
-
const state = {
|
|
55
|
-
stdout: "",
|
|
56
|
-
stderr: "",
|
|
57
|
-
exitCode: null as number | null,
|
|
58
|
-
calls: [] as FetchCall[]
|
|
59
|
-
};
|
|
60
|
-
const files = opts.files ?? {};
|
|
61
|
-
const writes = opts.writes ?? new Map<string, Uint8Array>();
|
|
62
|
-
|
|
63
|
-
const io: CliIO = {
|
|
64
|
-
argv: ["node", "/aex/aex", ...opts.argv],
|
|
65
|
-
readFile: async (path) => {
|
|
66
|
-
if (!(path in files)) {
|
|
67
|
-
throw Object.assign(new Error(`ENOENT: ${path}`), { code: "ENOENT" });
|
|
68
|
-
}
|
|
69
|
-
return files[path]!;
|
|
70
|
-
},
|
|
71
|
-
writeFile: async (path, data) => {
|
|
72
|
-
writes.set(path, data);
|
|
73
|
-
},
|
|
74
|
-
cwd: () => CWD,
|
|
75
|
-
fetchImpl: (async (input, init) => {
|
|
76
|
-
const reqInit: RequestInit = init ?? {};
|
|
77
|
-
let body: unknown;
|
|
78
|
-
if (typeof reqInit.body === "string") {
|
|
79
|
-
try {
|
|
80
|
-
body = JSON.parse(reqInit.body);
|
|
81
|
-
} catch {
|
|
82
|
-
body = reqInit.body;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
const url = String(input);
|
|
86
|
-
const call: FetchCall = { url, init: reqInit, body };
|
|
87
|
-
state.calls.push(call);
|
|
88
|
-
const handler = opts.fetchHandler ?? (() => new Response("{}", { status: 200, headers: { "content-type": "application/json" } }));
|
|
89
|
-
return handler(call);
|
|
90
|
-
}) as typeof fetch,
|
|
91
|
-
stdout: (chunk) => {
|
|
92
|
-
state.stdout += chunk;
|
|
93
|
-
},
|
|
94
|
-
stderr: (chunk) => {
|
|
95
|
-
state.stderr += chunk;
|
|
96
|
-
},
|
|
97
|
-
exit: (code) => {
|
|
98
|
-
state.exitCode = code;
|
|
99
|
-
}
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
return {
|
|
103
|
-
io,
|
|
104
|
-
get stdout() { return state.stdout; },
|
|
105
|
-
get stderr() { return state.stderr; },
|
|
106
|
-
get exitCode() { return state.exitCode; },
|
|
107
|
-
get calls() { return state.calls; }
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const COMMON = ["--api-token", "tok-1", "--aex-url", "https://dash.example/"];
|
|
112
|
-
|
|
113
|
-
describe("aex whoami", () => {
|
|
114
|
-
it("calls GET /api/whoami without a workspace query and prints the body", async () => {
|
|
115
|
-
const cap = makeHostIo({
|
|
116
|
-
argv: ["whoami", "--api-token", "tok-1", "--aex-url", "https://dash.example/"],
|
|
117
|
-
fetchHandler: () =>
|
|
118
|
-
new Response(JSON.stringify({ principalType: "api_token", workspaceId: "ws-7", scopes: ["runs.write"] }), {
|
|
119
|
-
status: 200,
|
|
120
|
-
headers: { "content-type": "application/json" }
|
|
121
|
-
})
|
|
122
|
-
});
|
|
123
|
-
await runCli(cap.io);
|
|
124
|
-
expect(cap.exitCode).toBe(0);
|
|
125
|
-
expect(cap.calls).toHaveLength(1);
|
|
126
|
-
expect(cap.calls[0]!.url).toBe("https://dash.example/api/whoami");
|
|
127
|
-
expect(cap.calls[0]!.init.method ?? "GET").toBe("GET");
|
|
128
|
-
const printed = JSON.parse(cap.stdout.trim()) as { principalType: string; workspaceId: string };
|
|
129
|
-
expect(printed.principalType).toBe("api_token");
|
|
130
|
-
expect(printed.workspaceId).toBe("ws-7");
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it("rejects when --api-token is missing", async () => {
|
|
134
|
-
const cap = makeHostIo({ argv: ["whoami", "--aex-url", "https://dash.example/"] });
|
|
135
|
-
await runCli(cap.io);
|
|
136
|
-
expect(cap.exitCode).toBe(2);
|
|
137
|
-
expect(cap.stderr).toContain("--api-token");
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it("defaults --aex-url to https://api.aex.dev when omitted", async () => {
|
|
141
|
-
const cap = makeHostIo({
|
|
142
|
-
argv: ["whoami", "--api-token", "tok-1"],
|
|
143
|
-
fetchHandler: () =>
|
|
144
|
-
new Response(JSON.stringify({ principalType: "api_token", workspaceId: "ws-9", scopes: [] }), {
|
|
145
|
-
status: 200,
|
|
146
|
-
headers: { "content-type": "application/json" }
|
|
147
|
-
})
|
|
148
|
-
});
|
|
149
|
-
await runCli(cap.io);
|
|
150
|
-
expect(cap.exitCode).toBe(0);
|
|
151
|
-
expect(cap.calls[0]!.url).toBe("https://api.aex.dev/api/whoami");
|
|
152
|
-
});
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
describe("aex status", () => {
|
|
156
|
-
it("does not send a workspaceId query parameter (derived from token server-side)", async () => {
|
|
157
|
-
const cap = makeHostIo({
|
|
158
|
-
argv: ["status", "run-42", ...COMMON],
|
|
159
|
-
fetchHandler: () =>
|
|
160
|
-
new Response(JSON.stringify({ id: "run-42", status: "succeeded" }), {
|
|
161
|
-
status: 200,
|
|
162
|
-
headers: { "content-type": "application/json" }
|
|
163
|
-
})
|
|
164
|
-
});
|
|
165
|
-
await runCli(cap.io);
|
|
166
|
-
expect(cap.exitCode).toBe(0);
|
|
167
|
-
expect(cap.calls[0]!.url).toContain("/api/runs/run-42");
|
|
168
|
-
expect(cap.calls[0]!.url).not.toContain("workspaceId=");
|
|
169
|
-
expect(JSON.parse(cap.stdout)).toEqual({ id: "run-42", status: "succeeded" });
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it("does not accept a --workspace flag (workspace is derived from the token)", async () => {
|
|
173
|
-
const cap = makeHostIo({
|
|
174
|
-
argv: ["status", "run-1", "--workspace", "ws-1", "--api-token", "tok", "--aex-url", "https://x"]
|
|
175
|
-
});
|
|
176
|
-
await runCli(cap.io);
|
|
177
|
-
expect(cap.exitCode).toBe(2);
|
|
178
|
-
expect(cap.stderr).toContain("unknown");
|
|
179
|
-
});
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
describe("aex events", () => {
|
|
183
|
-
it("lists events as NDJSON", async () => {
|
|
184
|
-
const cap = makeHostIo({
|
|
185
|
-
argv: ["events", "run-9", ...COMMON],
|
|
186
|
-
fetchHandler: () =>
|
|
187
|
-
new Response(
|
|
188
|
-
JSON.stringify({
|
|
189
|
-
events: [
|
|
190
|
-
{ id: "e1", type: "agent.message" },
|
|
191
|
-
{ id: "e2", type: "session.status_running" }
|
|
192
|
-
]
|
|
193
|
-
}),
|
|
194
|
-
{ status: 200, headers: { "content-type": "application/json" } }
|
|
195
|
-
)
|
|
196
|
-
});
|
|
197
|
-
await runCli(cap.io);
|
|
198
|
-
expect(cap.exitCode).toBe(0);
|
|
199
|
-
const lines = cap.stdout.trim().split("\n");
|
|
200
|
-
expect(lines).toHaveLength(2);
|
|
201
|
-
expect(JSON.parse(lines[0]!)).toEqual({ id: "e1", type: "agent.message" });
|
|
202
|
-
expect(JSON.parse(lines[1]!)).toEqual({ id: "e2", type: "session.status_running" });
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
it("--follow polls /events and emits NDJSON until terminal (never opens an SSE stream)", async () => {
|
|
206
|
-
const cap = makeHostIo({
|
|
207
|
-
argv: ["events", "run-poll", "--follow", ...COMMON],
|
|
208
|
-
fetchHandler: (call) => {
|
|
209
|
-
if (call.url.endsWith("/events/stream")) {
|
|
210
|
-
throw new Error("SSE endpoint must not be touched");
|
|
211
|
-
}
|
|
212
|
-
if (call.url.endsWith("/events")) {
|
|
213
|
-
return new Response(
|
|
214
|
-
JSON.stringify({ events: [{ id: "p1", type: "TEXT_MESSAGE_CONTENT" }] }),
|
|
215
|
-
{ status: 200, headers: { "content-type": "application/json" } }
|
|
216
|
-
);
|
|
217
|
-
}
|
|
218
|
-
// GET run record — return terminal so the loop exits.
|
|
219
|
-
return new Response(JSON.stringify({ id: "run-poll", status: "succeeded" }), {
|
|
220
|
-
status: 200,
|
|
221
|
-
headers: { "content-type": "application/json" }
|
|
222
|
-
});
|
|
223
|
-
}
|
|
224
|
-
});
|
|
225
|
-
await runCli(cap.io);
|
|
226
|
-
expect(cap.exitCode).toBe(0);
|
|
227
|
-
expect(cap.stdout.trim()).toContain("p1");
|
|
228
|
-
expect(cap.calls.some((c) => c.url.endsWith("/events/stream"))).toBe(false);
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
it("--follow stops on a timed_out run instead of hanging", async () => {
|
|
232
|
-
// Regression: `timed_out` is a terminal status. A prior hardcoded set
|
|
233
|
-
// omitted it, so the polling loop would never exit for a timed-out run.
|
|
234
|
-
const cap = makeHostIo({
|
|
235
|
-
argv: ["events", "run-timeout", "--follow", ...COMMON],
|
|
236
|
-
fetchHandler: (call) => {
|
|
237
|
-
if (call.url.endsWith("/events")) {
|
|
238
|
-
return new Response(
|
|
239
|
-
JSON.stringify({ events: [{ id: "t1", type: "TEXT_MESSAGE_CONTENT" }] }),
|
|
240
|
-
{ status: 200, headers: { "content-type": "application/json" } }
|
|
241
|
-
);
|
|
242
|
-
}
|
|
243
|
-
// GET run record — terminal `timed_out` must exit the loop.
|
|
244
|
-
return new Response(JSON.stringify({ id: "run-timeout", status: "timed_out" }), {
|
|
245
|
-
status: 200,
|
|
246
|
-
headers: { "content-type": "application/json" }
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
});
|
|
250
|
-
await runCli(cap.io);
|
|
251
|
-
expect(cap.exitCode).toBe(0);
|
|
252
|
-
expect(cap.stdout.trim()).toContain("t1");
|
|
253
|
-
});
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
describe("parseDuration", () => {
|
|
257
|
-
it("parses unit suffixes and bare ms integers", () => {
|
|
258
|
-
expect(parseDuration("500ms").ms).toBe(500);
|
|
259
|
-
expect(parseDuration("30s").ms).toBe(30_000);
|
|
260
|
-
expect(parseDuration("8m").ms).toBe(480_000);
|
|
261
|
-
expect(parseDuration("1h").ms).toBe(3_600_000);
|
|
262
|
-
expect(parseDuration("2000").ms).toBe(2000);
|
|
263
|
-
expect(parseDuration("1.5s").ms).toBe(1500);
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
it("rejects malformed and negative input without coercing to 0", () => {
|
|
267
|
-
expect(parseDuration("soon").error).toBeTruthy();
|
|
268
|
-
expect(parseDuration("5min").error).toBeTruthy();
|
|
269
|
-
expect(parseDuration("-5s").error).toBeTruthy();
|
|
270
|
-
expect(parseDuration("").error).toBeTruthy();
|
|
271
|
-
expect(parseDuration("soon").ms).toBeNull();
|
|
272
|
-
});
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
describe("aex wait", () => {
|
|
276
|
-
it("polls GET /runs/{id} until terminal, prints the final run, exits 0 on succeeded", async () => {
|
|
277
|
-
let polls = 0;
|
|
278
|
-
const cap = makeHostIo({
|
|
279
|
-
argv: ["wait", "run-w", "--interval", "1ms", ...COMMON],
|
|
280
|
-
fetchHandler: () => {
|
|
281
|
-
polls++;
|
|
282
|
-
const status = polls < 3 ? "running" : "succeeded";
|
|
283
|
-
return new Response(JSON.stringify({ id: "run-w", status }), {
|
|
284
|
-
status: 200,
|
|
285
|
-
headers: { "content-type": "application/json" }
|
|
286
|
-
});
|
|
287
|
-
}
|
|
288
|
-
});
|
|
289
|
-
await runCli(cap.io);
|
|
290
|
-
expect(cap.exitCode).toBe(0);
|
|
291
|
-
expect(polls).toBe(3);
|
|
292
|
-
const printed = JSON.parse(cap.stdout.trim()) as { id: string; status: string };
|
|
293
|
-
expect(printed).toMatchObject({ id: "run-w", status: "succeeded" });
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
it("exits 1 (RUNTIME_ERR) when the run reaches a non-succeeded terminal status", async () => {
|
|
297
|
-
const cap = makeHostIo({
|
|
298
|
-
argv: ["wait", "run-f", ...COMMON],
|
|
299
|
-
fetchHandler: () =>
|
|
300
|
-
new Response(JSON.stringify({ id: "run-f", status: "failed" }), {
|
|
301
|
-
status: 200,
|
|
302
|
-
headers: { "content-type": "application/json" }
|
|
303
|
-
})
|
|
304
|
-
});
|
|
305
|
-
await runCli(cap.io);
|
|
306
|
-
expect(cap.exitCode).toBe(1);
|
|
307
|
-
expect(JSON.parse(cap.stdout.trim())).toMatchObject({ status: "failed" });
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
it("exits 3 (TIMEOUT_ERR) with a JSON error when --timeout elapses before terminal", async () => {
|
|
311
|
-
const cap = makeHostIo({
|
|
312
|
-
argv: ["wait", "run-slow", "--timeout", "0ms", ...COMMON],
|
|
313
|
-
fetchHandler: () =>
|
|
314
|
-
new Response(JSON.stringify({ id: "run-slow", status: "queued" }), {
|
|
315
|
-
status: 200,
|
|
316
|
-
headers: { "content-type": "application/json" }
|
|
317
|
-
})
|
|
318
|
-
});
|
|
319
|
-
await runCli(cap.io);
|
|
320
|
-
expect(cap.exitCode).toBe(3);
|
|
321
|
-
const err = JSON.parse(cap.stderr.trim()) as { error: string; runId: string; lastStatus: string };
|
|
322
|
-
expect(err.error).toBe("wait_timeout");
|
|
323
|
-
expect(err.runId).toBe("run-slow");
|
|
324
|
-
expect(err.lastStatus).toBe("queued");
|
|
325
|
-
});
|
|
326
|
-
|
|
327
|
-
it("rejects a malformed --timeout with USAGE_ERR", async () => {
|
|
328
|
-
const cap = makeHostIo({ argv: ["wait", "run-x", "--timeout", "soon", ...COMMON] });
|
|
329
|
-
await runCli(cap.io);
|
|
330
|
-
expect(cap.exitCode).toBe(2);
|
|
331
|
-
expect(cap.stderr).toContain("--timeout");
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
it("requires exactly one run-id positional", async () => {
|
|
335
|
-
const cap = makeHostIo({ argv: ["wait", ...COMMON] });
|
|
336
|
-
await runCli(cap.io);
|
|
337
|
-
expect(cap.exitCode).toBe(2);
|
|
338
|
-
expect(cap.stderr).toContain("usage: aex wait");
|
|
339
|
-
});
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
describe("aex events --follow --timeout", () => {
|
|
343
|
-
it("exits 3 with a JSON error when the follow deadline elapses before terminal", async () => {
|
|
344
|
-
const cap = makeHostIo({
|
|
345
|
-
argv: ["events", "run-ev", "--follow", "--timeout", "0ms", ...COMMON],
|
|
346
|
-
fetchHandler: (call) => {
|
|
347
|
-
if (call.url.endsWith("/events")) {
|
|
348
|
-
return new Response(JSON.stringify({ events: [] }), {
|
|
349
|
-
status: 200,
|
|
350
|
-
headers: { "content-type": "application/json" }
|
|
351
|
-
});
|
|
352
|
-
}
|
|
353
|
-
return new Response(JSON.stringify({ id: "run-ev", status: "running" }), {
|
|
354
|
-
status: 200,
|
|
355
|
-
headers: { "content-type": "application/json" }
|
|
356
|
-
});
|
|
357
|
-
}
|
|
358
|
-
});
|
|
359
|
-
await runCli(cap.io);
|
|
360
|
-
expect(cap.exitCode).toBe(3);
|
|
361
|
-
expect(JSON.parse(cap.stderr.trim())).toMatchObject({ error: "events_follow_timeout", runId: "run-ev" });
|
|
362
|
-
});
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
describe("aex outputs", () => {
|
|
366
|
-
it("lists outputs as NDJSON", async () => {
|
|
367
|
-
const cap = makeHostIo({
|
|
368
|
-
argv: ["outputs", "run-9", ...COMMON],
|
|
369
|
-
fetchHandler: () =>
|
|
370
|
-
new Response(
|
|
371
|
-
JSON.stringify({ outputs: [{ id: "o1", filename: "report.md" }] }),
|
|
372
|
-
{ status: 200, headers: { "content-type": "application/json" } }
|
|
373
|
-
)
|
|
374
|
-
});
|
|
375
|
-
await runCli(cap.io);
|
|
376
|
-
expect(cap.exitCode).toBe(0);
|
|
377
|
-
expect(JSON.parse(cap.stdout.trim())).toMatchObject({ id: "o1", filename: "report.md" });
|
|
378
|
-
});
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
describe("aex cancel + delete", () => {
|
|
382
|
-
it("cancel POSTs and prints the result", async () => {
|
|
383
|
-
const cap = makeHostIo({
|
|
384
|
-
argv: ["cancel", "run-x", ...COMMON],
|
|
385
|
-
fetchHandler: () => new Response("{}", { status: 200, headers: { "content-type": "application/json" } })
|
|
386
|
-
});
|
|
387
|
-
await runCli(cap.io);
|
|
388
|
-
expect(cap.exitCode).toBe(0);
|
|
389
|
-
expect(cap.calls[0]!.init.method).toBe("POST");
|
|
390
|
-
expect(cap.calls[0]!.url).toContain("/api/runs/run-x/cancel");
|
|
391
|
-
expect(JSON.parse(cap.stdout)).toEqual({ runId: "run-x", status: "cancel_requested" });
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
it("delete DELETEs and prints the result", async () => {
|
|
395
|
-
const cap = makeHostIo({
|
|
396
|
-
argv: ["delete", "run-x", ...COMMON],
|
|
397
|
-
fetchHandler: () => new Response("{}", { status: 200, headers: { "content-type": "application/json" } })
|
|
398
|
-
});
|
|
399
|
-
await runCli(cap.io);
|
|
400
|
-
expect(cap.exitCode).toBe(0);
|
|
401
|
-
expect(cap.calls[0]!.init.method).toBe("DELETE");
|
|
402
|
-
expect(JSON.parse(cap.stdout)).toEqual({ runId: "run-x", deleted: true });
|
|
403
|
-
});
|
|
404
|
-
|
|
405
|
-
it("delete-asset DELETEs a normalized workspace asset id and prints the result", async () => {
|
|
406
|
-
const hex = "a".repeat(64);
|
|
407
|
-
const cap = makeHostIo({
|
|
408
|
-
argv: ["delete-asset", `sha256:${hex}`, ...COMMON],
|
|
409
|
-
fetchHandler: () => new Response(null, { status: 204 })
|
|
410
|
-
});
|
|
411
|
-
await runCli(cap.io);
|
|
412
|
-
expect(cap.exitCode).toBe(0);
|
|
413
|
-
expect(cap.calls).toHaveLength(1);
|
|
414
|
-
expect(cap.calls[0]!.url).toBe(`https://dash.example/assets/asset_${hex}`);
|
|
415
|
-
expect(cap.calls[0]!.init.method).toBe("DELETE");
|
|
416
|
-
expect(JSON.parse(cap.stdout.trim())).toEqual({ hash: `sha256:${hex}`, deleted: true });
|
|
417
|
-
});
|
|
418
|
-
|
|
419
|
-
it("delete-asset rejects missing hashes without calling the API", async () => {
|
|
420
|
-
const cap = makeHostIo({
|
|
421
|
-
argv: ["delete-asset", ...COMMON]
|
|
422
|
-
});
|
|
423
|
-
await runCli(cap.io);
|
|
424
|
-
expect(cap.exitCode).toBe(2);
|
|
425
|
-
expect(cap.stderr).toContain("usage: aex delete-asset");
|
|
426
|
-
expect(cap.calls).toHaveLength(0);
|
|
427
|
-
});
|
|
428
|
-
|
|
429
|
-
it("delete-asset emits a structured error with the requested hash on API failure", async () => {
|
|
430
|
-
const hex = "c".repeat(64);
|
|
431
|
-
const cap = makeHostIo({
|
|
432
|
-
argv: ["delete-asset", hex, ...COMMON],
|
|
433
|
-
fetchHandler: () =>
|
|
434
|
-
new Response(JSON.stringify({ error: "asset_not_found", message: "asset not found" }), {
|
|
435
|
-
status: 404,
|
|
436
|
-
headers: { "content-type": "application/json" }
|
|
437
|
-
})
|
|
438
|
-
});
|
|
439
|
-
await runCli(cap.io);
|
|
440
|
-
expect(cap.exitCode).toBe(1);
|
|
441
|
-
expect(cap.calls[0]!.url).toBe(`https://dash.example/assets/asset_${hex}`);
|
|
442
|
-
const err = JSON.parse(cap.stderr.trim()) as { error: string; message: string; hash: string };
|
|
443
|
-
expect(err).toEqual({ error: "delete_asset_failed", message: "asset_not_found", hash: hex });
|
|
444
|
-
});
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
describe("aex download", () => {
|
|
448
|
-
// Route the reads the download verbs fan out to: getRun + listEvents +
|
|
449
|
-
// listOutputs + per-output /download.
|
|
450
|
-
const json = (body: unknown) =>
|
|
451
|
-
new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } });
|
|
452
|
-
const wholeRunHandler =
|
|
453
|
-
(runId: string) =>
|
|
454
|
-
({ url }: { url: string }): Response => {
|
|
455
|
-
if (url.endsWith(`/api/runs/${runId}/events`)) return json({ events: [{ seq: 0, kind: "runtime_start" }] });
|
|
456
|
-
if (url.endsWith(`/api/runs/${runId}/outputs`)) {
|
|
457
|
-
return json({ outputs: [{ id: "o1", filename: "report.txt", sizeBytes: 5, contentType: "text/plain" }] });
|
|
458
|
-
}
|
|
459
|
-
if (url.endsWith(`/api/runs/${runId}/outputs/o1/download`)) {
|
|
460
|
-
return new Response(strToU8("hello").buffer, { status: 200, headers: { "content-type": "text/plain" } });
|
|
461
|
-
}
|
|
462
|
-
return json({ id: runId, status: "succeeded" });
|
|
463
|
-
};
|
|
464
|
-
|
|
465
|
-
it("assembles the public whole-run zip client-side and writes it to --out", async () => {
|
|
466
|
-
const writes = new Map<string, Uint8Array>();
|
|
467
|
-
const cap = makeHostIo({
|
|
468
|
-
argv: ["download", "run-1", "--out", "run-1.zip", ...COMMON],
|
|
469
|
-
writes,
|
|
470
|
-
fetchHandler: wholeRunHandler("run-1")
|
|
471
|
-
});
|
|
472
|
-
await runCli(cap.io);
|
|
473
|
-
expect(cap.exitCode).toBe(0);
|
|
474
|
-
// getRun + events + outputs + one per-output download.
|
|
475
|
-
expect(cap.calls).toHaveLength(4);
|
|
476
|
-
|
|
477
|
-
const writtenKey = [...writes.keys()][0]!;
|
|
478
|
-
expect(writtenKey).toMatch(/run-1\.zip$/);
|
|
479
|
-
const entries = unzipSync(writes.get(writtenKey)!);
|
|
480
|
-
expect(Object.keys(entries).sort()).toEqual([
|
|
481
|
-
"events/events.jsonl",
|
|
482
|
-
"manifest.json",
|
|
483
|
-
"metadata/run.json",
|
|
484
|
-
"outputs/report.txt"
|
|
485
|
-
]);
|
|
486
|
-
expect(new TextDecoder().decode(entries["outputs/report.txt"]!)).toBe("hello");
|
|
487
|
-
expect(JSON.parse(new TextDecoder().decode(entries["metadata/run.json"]!)).id).toBe("run-1");
|
|
488
|
-
|
|
489
|
-
const printed = JSON.parse(cap.stdout.trim()) as { runId: string; namespace: string; bytes: number };
|
|
490
|
-
expect(printed.runId).toBe("run-1");
|
|
491
|
-
expect(printed.namespace).toBe("all");
|
|
492
|
-
expect(printed.bytes).toBe(writes.get(writtenKey)!.byteLength);
|
|
493
|
-
});
|
|
494
|
-
|
|
495
|
-
it("defaults the output path to aex-run-<run-id>.zip when --out is omitted", async () => {
|
|
496
|
-
const writes = new Map<string, Uint8Array>();
|
|
497
|
-
const cap = makeHostIo({
|
|
498
|
-
argv: ["download", "run-2", ...COMMON],
|
|
499
|
-
writes,
|
|
500
|
-
fetchHandler: wholeRunHandler("run-2")
|
|
501
|
-
});
|
|
502
|
-
await runCli(cap.io);
|
|
503
|
-
expect(cap.exitCode).toBe(0);
|
|
504
|
-
const writtenKey = [...writes.keys()][0]!;
|
|
505
|
-
expect(writtenKey).toMatch(/aex-run-run-2\.zip$/);
|
|
506
|
-
});
|
|
507
|
-
|
|
508
|
-
it("--only outputs zips just the deliverables (no logs, no metadata/events)", async () => {
|
|
509
|
-
const writes = new Map<string, Uint8Array>();
|
|
510
|
-
const cap = makeHostIo({
|
|
511
|
-
argv: ["download", "run-1", "--only", "outputs", ...COMMON],
|
|
512
|
-
writes,
|
|
513
|
-
fetchHandler: wholeRunHandler("run-1")
|
|
514
|
-
});
|
|
515
|
-
await runCli(cap.io);
|
|
516
|
-
expect(cap.exitCode).toBe(0);
|
|
517
|
-
const writtenKey = [...writes.keys()][0]!;
|
|
518
|
-
expect(writtenKey).toMatch(/aex-run-run-1-outputs\.zip$/);
|
|
519
|
-
const entries = unzipSync(writes.get(writtenKey)!);
|
|
520
|
-
expect(Object.keys(entries).sort()).toEqual(["manifest.json", "report.txt"]);
|
|
521
|
-
expect(new TextDecoder().decode(entries["report.txt"]!)).toBe("hello");
|
|
522
|
-
expect((JSON.parse(cap.stdout.trim()) as { namespace: string }).namespace).toBe("outputs");
|
|
523
|
-
});
|
|
524
|
-
|
|
525
|
-
it("rejects --only logs with a usage error", async () => {
|
|
526
|
-
const cap = makeHostIo({
|
|
527
|
-
argv: ["download", "run-1", "--only", "logs", ...COMMON],
|
|
528
|
-
fetchHandler: wholeRunHandler("run-1")
|
|
529
|
-
});
|
|
530
|
-
await runCli(cap.io);
|
|
531
|
-
expect(cap.exitCode).toBe(2);
|
|
532
|
-
expect(cap.stderr).toContain("--only must be one of");
|
|
533
|
-
expect(cap.calls).toHaveLength(0);
|
|
534
|
-
});
|
|
535
|
-
|
|
536
|
-
it("--only metadata reads only the run record", async () => {
|
|
537
|
-
const writes = new Map<string, Uint8Array>();
|
|
538
|
-
const cap = makeHostIo({
|
|
539
|
-
argv: ["download", "run-1", "--only", "metadata", ...COMMON],
|
|
540
|
-
writes,
|
|
541
|
-
fetchHandler: wholeRunHandler("run-1")
|
|
542
|
-
});
|
|
543
|
-
await runCli(cap.io);
|
|
544
|
-
expect(cap.exitCode).toBe(0);
|
|
545
|
-
expect(cap.calls).toHaveLength(1);
|
|
546
|
-
expect(cap.calls[0]!.url).toBe("https://dash.example/api/runs/run-1");
|
|
547
|
-
const entries = unzipSync(writes.get([...writes.keys()][0]!)!);
|
|
548
|
-
expect(Object.keys(entries)).toEqual(["run.json"]);
|
|
549
|
-
});
|
|
550
|
-
|
|
551
|
-
it("rejects an unknown --only namespace with a usage error", async () => {
|
|
552
|
-
const cap = makeHostIo({
|
|
553
|
-
argv: ["download", "run-1", "--only", "bogus", ...COMMON],
|
|
554
|
-
fetchHandler: wholeRunHandler("run-1")
|
|
555
|
-
});
|
|
556
|
-
await runCli(cap.io);
|
|
557
|
-
expect(cap.exitCode).toBe(2);
|
|
558
|
-
expect(cap.stderr).toContain("--only must be one of");
|
|
559
|
-
expect(cap.calls).toHaveLength(0);
|
|
560
|
-
});
|
|
561
|
-
});
|
|
562
|
-
|
|
563
|
-
describe("aex run", () => {
|
|
564
|
-
it("submits a run config loaded from --config and prints the run record", async () => {
|
|
565
|
-
const runConfig = {
|
|
566
|
-
model: "claude-haiku-4-5",
|
|
567
|
-
system: "be helpful",
|
|
568
|
-
prompt: ["hi"],
|
|
569
|
-
skills: [{ kind: "provider", vendor: "anthropic", skillId: "pdf", version: "v1" }],
|
|
570
|
-
mcpServers: [
|
|
571
|
-
{
|
|
572
|
-
name: "github",
|
|
573
|
-
url: "https://example.com/mcp",
|
|
574
|
-
headers: { Authorization: "Bearer t-from-config" }
|
|
575
|
-
}
|
|
576
|
-
],
|
|
577
|
-
postHook: {
|
|
578
|
-
command: "pnpm test",
|
|
579
|
-
timeout: "2m",
|
|
580
|
-
maxTurns: 2,
|
|
581
|
-
maxChars: 2048
|
|
582
|
-
}
|
|
583
|
-
};
|
|
584
|
-
const cap = makeHostIo({
|
|
585
|
-
argv: [
|
|
586
|
-
"run",
|
|
587
|
-
"--config",
|
|
588
|
-
"/abs/run.json",
|
|
589
|
-
"--anthropic-api-key",
|
|
590
|
-
"sk-ant-1",
|
|
591
|
-
"--idempotency-key",
|
|
592
|
-
"idem-deterministic",
|
|
593
|
-
...COMMON
|
|
594
|
-
],
|
|
595
|
-
files: { [resolvedFromCwd("/abs/run.json")]: JSON.stringify(runConfig) },
|
|
596
|
-
fetchHandler: () =>
|
|
597
|
-
new Response(JSON.stringify({ id: "run-new", status: "queued" }), {
|
|
598
|
-
status: 200,
|
|
599
|
-
headers: { "content-type": "application/json" }
|
|
600
|
-
})
|
|
601
|
-
});
|
|
602
|
-
await runCli(cap.io);
|
|
603
|
-
expect(cap.exitCode).toBe(0);
|
|
604
|
-
expect(cap.calls).toHaveLength(1);
|
|
605
|
-
expect(cap.calls[0]!.url).toBe("https://dash.example/api/runs");
|
|
606
|
-
expect(cap.calls[0]!.init.method).toBe("POST");
|
|
607
|
-
const body = cap.calls[0]!.body as Record<string, unknown>;
|
|
608
|
-
expect(body.workspaceId).toBeUndefined();
|
|
609
|
-
expect(body.idempotencyKey).toBe("idem-deterministic");
|
|
610
|
-
expect(body.postHook).toEqual({
|
|
611
|
-
command: "pnpm test",
|
|
612
|
-
timeout: "2m",
|
|
613
|
-
maxTurns: 2,
|
|
614
|
-
maxChars: 2048
|
|
615
|
-
});
|
|
616
|
-
const submission = body.submission as Record<string, unknown>;
|
|
617
|
-
expect(submission.model).toBe("claude-haiku-4-5");
|
|
618
|
-
expect(submission.prompt).toEqual(["hi"]);
|
|
619
|
-
expect(submission.skills).toEqual([
|
|
620
|
-
{ kind: "provider", vendor: "anthropic", skillId: "pdf", version: "v1" }
|
|
621
|
-
]);
|
|
622
|
-
expect(submission.mcpServers).toEqual([
|
|
623
|
-
{ name: "github", url: "https://example.com/mcp" }
|
|
624
|
-
]);
|
|
625
|
-
const secrets = body.secrets as Record<string, unknown>;
|
|
626
|
-
expect(secrets.apiKey).toBe("sk-ant-1");
|
|
627
|
-
expect(secrets.mcpServers).toEqual([
|
|
628
|
-
{
|
|
629
|
-
name: "github",
|
|
630
|
-
url: "https://example.com/mcp",
|
|
631
|
-
headers: { Authorization: "Bearer t-from-config" }
|
|
632
|
-
}
|
|
633
|
-
]);
|
|
634
|
-
const printed = JSON.parse(cap.stdout.trim()) as { id: string; status: string };
|
|
635
|
-
expect(printed).toMatchObject({ id: "run-new", status: "queued" });
|
|
636
|
-
});
|
|
637
|
-
|
|
638
|
-
it("submits a run request built from --model/--prompt/--mcp/--mcp-auth flags", async () => {
|
|
639
|
-
const cap = makeHostIo({
|
|
640
|
-
argv: [
|
|
641
|
-
"run",
|
|
642
|
-
"--model",
|
|
643
|
-
"claude-haiku-4-5",
|
|
644
|
-
"--prompt",
|
|
645
|
-
"hello",
|
|
646
|
-
"--mcp",
|
|
647
|
-
"github=https://example.com/mcp",
|
|
648
|
-
"--mcp-auth",
|
|
649
|
-
"github=Authorization:Bearer t",
|
|
650
|
-
"--anthropic-api-key",
|
|
651
|
-
"sk-ant-2",
|
|
652
|
-
"--idempotency-key",
|
|
653
|
-
"idem-flat",
|
|
654
|
-
...COMMON
|
|
655
|
-
],
|
|
656
|
-
fetchHandler: () =>
|
|
657
|
-
new Response(JSON.stringify({ id: "r-flat", status: "queued" }), {
|
|
658
|
-
status: 200,
|
|
659
|
-
headers: { "content-type": "application/json" }
|
|
660
|
-
})
|
|
661
|
-
});
|
|
662
|
-
await runCli(cap.io);
|
|
663
|
-
expect(cap.exitCode).toBe(0);
|
|
664
|
-
const body = cap.calls[0]!.body as Record<string, unknown>;
|
|
665
|
-
const submission = body.submission as Record<string, unknown>;
|
|
666
|
-
expect(submission.skills).toEqual([]);
|
|
667
|
-
expect(submission.mcpServers).toEqual([
|
|
668
|
-
{ name: "github", url: "https://example.com/mcp" }
|
|
669
|
-
]);
|
|
670
|
-
const secrets = body.secrets as Record<string, unknown>;
|
|
671
|
-
expect(secrets.mcpServers).toEqual([
|
|
672
|
-
{
|
|
673
|
-
name: "github",
|
|
674
|
-
url: "https://example.com/mcp",
|
|
675
|
-
headers: { Authorization: "Bearer t" }
|
|
676
|
-
}
|
|
677
|
-
]);
|
|
678
|
-
});
|
|
679
|
-
|
|
680
|
-
it("submits DeepSeek runs with --provider deepseek and --deepseek-api-key", async () => {
|
|
681
|
-
const cap = makeHostIo({
|
|
682
|
-
argv: [
|
|
683
|
-
"run",
|
|
684
|
-
"--provider",
|
|
685
|
-
"deepseek",
|
|
686
|
-
"--model",
|
|
687
|
-
"deepseek-chat",
|
|
688
|
-
"--prompt",
|
|
689
|
-
"hello",
|
|
690
|
-
"--deepseek-api-key",
|
|
691
|
-
"sk-ds-1",
|
|
692
|
-
"--idempotency-key",
|
|
693
|
-
"idem-ds",
|
|
694
|
-
...COMMON
|
|
695
|
-
],
|
|
696
|
-
fetchHandler: () =>
|
|
697
|
-
new Response(JSON.stringify({ id: "r-deepseek", status: "queued" }), {
|
|
698
|
-
status: 200,
|
|
699
|
-
headers: { "content-type": "application/json" }
|
|
700
|
-
})
|
|
701
|
-
});
|
|
702
|
-
await runCli(cap.io);
|
|
703
|
-
expect(cap.exitCode).toBe(0);
|
|
704
|
-
const body = cap.calls[0]!.body as Record<string, unknown>;
|
|
705
|
-
expect(body.provider).toBe("deepseek");
|
|
706
|
-
expect(body.secrets).toEqual({ apiKey: "sk-ds-1" });
|
|
707
|
-
});
|
|
708
|
-
|
|
709
|
-
it("rejects when --anthropic-api-key is missing", async () => {
|
|
710
|
-
const cap = makeHostIo({
|
|
711
|
-
argv: ["run", "--model", "claude-haiku-4-5", "--prompt", "p", ...COMMON]
|
|
712
|
-
});
|
|
713
|
-
await runCli(cap.io);
|
|
714
|
-
expect(cap.exitCode).toBe(2);
|
|
715
|
-
expect(cap.stderr).toContain("--anthropic-api-key");
|
|
716
|
-
});
|
|
717
|
-
|
|
718
|
-
it("parses --proxy-auth and validates it against --proxy-endpoint", async () => {
|
|
719
|
-
const endpoint = {
|
|
720
|
-
name: "stripe",
|
|
721
|
-
baseUrl: "https://api.stripe.com",
|
|
722
|
-
authShape: { type: "bearer" },
|
|
723
|
-
allowMethods: ["GET"],
|
|
724
|
-
allowPathPrefixes: ["/v1"]
|
|
725
|
-
};
|
|
726
|
-
const cap = makeHostIo({
|
|
727
|
-
argv: [
|
|
728
|
-
"run",
|
|
729
|
-
"--model",
|
|
730
|
-
"claude-haiku-4-5",
|
|
731
|
-
"--prompt",
|
|
732
|
-
"x",
|
|
733
|
-
"--anthropic-api-key",
|
|
734
|
-
"sk-ant-1",
|
|
735
|
-
"--proxy-endpoint",
|
|
736
|
-
JSON.stringify(endpoint),
|
|
737
|
-
"--proxy-auth",
|
|
738
|
-
"stripe=bearer:sk_test",
|
|
739
|
-
...COMMON
|
|
740
|
-
],
|
|
741
|
-
fetchHandler: () =>
|
|
742
|
-
new Response(JSON.stringify({ id: "r1", status: "queued" }), {
|
|
743
|
-
status: 200,
|
|
744
|
-
headers: { "content-type": "application/json" }
|
|
745
|
-
})
|
|
746
|
-
});
|
|
747
|
-
await runCli(cap.io);
|
|
748
|
-
expect(cap.exitCode).toBe(0);
|
|
749
|
-
const body = cap.calls[0]!.body as Record<string, unknown>;
|
|
750
|
-
expect((body.proxyEndpoints as Array<{ name: string }>)[0]!.name).toBe("stripe");
|
|
751
|
-
const secrets = body.secrets as Record<string, unknown>;
|
|
752
|
-
const auth = secrets.proxyEndpointAuth as Array<{ name: string; value: { type: string; token: string } }>;
|
|
753
|
-
expect(auth[0]!.name).toBe("stripe");
|
|
754
|
-
expect(auth[0]!.value).toEqual({ type: "bearer", token: "sk_test" });
|
|
755
|
-
});
|
|
756
|
-
|
|
757
|
-
it("rejects when --proxy-endpoint shape disagrees with --proxy-auth shape", async () => {
|
|
758
|
-
const endpoint = {
|
|
759
|
-
name: "stripe",
|
|
760
|
-
baseUrl: "https://api.stripe.com",
|
|
761
|
-
authShape: { type: "bearer" },
|
|
762
|
-
allowMethods: ["GET"],
|
|
763
|
-
allowPathPrefixes: ["/v1"]
|
|
764
|
-
};
|
|
765
|
-
const cap = makeHostIo({
|
|
766
|
-
argv: [
|
|
767
|
-
"run",
|
|
768
|
-
"--model",
|
|
769
|
-
"claude-haiku-4-5",
|
|
770
|
-
"--prompt",
|
|
771
|
-
"x",
|
|
772
|
-
"--anthropic-api-key",
|
|
773
|
-
"sk-ant-1",
|
|
774
|
-
"--proxy-endpoint",
|
|
775
|
-
JSON.stringify(endpoint),
|
|
776
|
-
"--proxy-auth",
|
|
777
|
-
"stripe=basic:u:p",
|
|
778
|
-
...COMMON
|
|
779
|
-
]
|
|
780
|
-
});
|
|
781
|
-
await runCli(cap.io);
|
|
782
|
-
expect(cap.exitCode).toBe(2);
|
|
783
|
-
expect(cap.stderr).toContain("proxy auth validation failed");
|
|
784
|
-
});
|
|
785
|
-
|
|
786
|
-
it("rejects --mcp-auth that does not match a declared --mcp", async () => {
|
|
787
|
-
const cap = makeHostIo({
|
|
788
|
-
argv: [
|
|
789
|
-
"run",
|
|
790
|
-
"--model",
|
|
791
|
-
"claude-haiku-4-5",
|
|
792
|
-
"--prompt",
|
|
793
|
-
"x",
|
|
794
|
-
"--mcp",
|
|
795
|
-
"github=https://example.com/mcp",
|
|
796
|
-
"--mcp-auth",
|
|
797
|
-
"gitlab=Authorization:Bearer t",
|
|
798
|
-
"--anthropic-api-key",
|
|
799
|
-
"sk-ant-1",
|
|
800
|
-
...COMMON
|
|
801
|
-
]
|
|
802
|
-
});
|
|
803
|
-
await runCli(cap.io);
|
|
804
|
-
expect(cap.exitCode).toBe(2);
|
|
805
|
-
expect(cap.stderr).toContain("--mcp-auth gitlab");
|
|
806
|
-
});
|
|
807
|
-
|
|
808
|
-
it("merges multiple --mcp-auth flags for the same server (does not collapse)", async () => {
|
|
809
|
-
const cap = makeHostIo({
|
|
810
|
-
argv: [
|
|
811
|
-
"run",
|
|
812
|
-
"--model",
|
|
813
|
-
"claude-haiku-4-5",
|
|
814
|
-
"--prompt",
|
|
815
|
-
"x",
|
|
816
|
-
"--mcp",
|
|
817
|
-
"github=https://example.com/mcp",
|
|
818
|
-
"--mcp-auth",
|
|
819
|
-
"github=Authorization:Bearer t",
|
|
820
|
-
"--mcp-auth",
|
|
821
|
-
"github=X-Trace-Id:abc-123",
|
|
822
|
-
"--anthropic-api-key",
|
|
823
|
-
"sk-ant-1",
|
|
824
|
-
...COMMON
|
|
825
|
-
],
|
|
826
|
-
fetchHandler: () =>
|
|
827
|
-
new Response(JSON.stringify({ id: "r-merge", status: "queued" }), {
|
|
828
|
-
status: 200,
|
|
829
|
-
headers: { "content-type": "application/json" }
|
|
830
|
-
})
|
|
831
|
-
});
|
|
832
|
-
await runCli(cap.io);
|
|
833
|
-
expect(cap.exitCode).toBe(0);
|
|
834
|
-
const body = cap.calls[0]!.body as Record<string, unknown>;
|
|
835
|
-
const secrets = body.secrets as Record<string, unknown>;
|
|
836
|
-
const mcpSecrets = secrets.mcpServers as Array<{ name: string; headers: Record<string, string> }>;
|
|
837
|
-
expect(mcpSecrets).toHaveLength(1);
|
|
838
|
-
expect(mcpSecrets[0]!.name).toBe("github");
|
|
839
|
-
expect(mcpSecrets[0]!.headers).toEqual({
|
|
840
|
-
Authorization: "Bearer t",
|
|
841
|
-
"X-Trace-Id": "abc-123"
|
|
842
|
-
});
|
|
843
|
-
});
|
|
844
|
-
|
|
845
|
-
it("rejects duplicate --mcp-auth header names for the same server", async () => {
|
|
846
|
-
const cap = makeHostIo({
|
|
847
|
-
argv: [
|
|
848
|
-
"run",
|
|
849
|
-
"--model",
|
|
850
|
-
"claude-haiku-4-5",
|
|
851
|
-
"--prompt",
|
|
852
|
-
"x",
|
|
853
|
-
"--mcp",
|
|
854
|
-
"github=https://example.com/mcp",
|
|
855
|
-
"--mcp-auth",
|
|
856
|
-
"github=Authorization:Bearer t1",
|
|
857
|
-
"--mcp-auth",
|
|
858
|
-
"github=Authorization:Bearer t2",
|
|
859
|
-
"--anthropic-api-key",
|
|
860
|
-
"sk-ant-1",
|
|
861
|
-
...COMMON
|
|
862
|
-
]
|
|
863
|
-
});
|
|
864
|
-
await runCli(cap.io);
|
|
865
|
-
expect(cap.exitCode).toBe(2);
|
|
866
|
-
expect(cap.stderr).toContain("duplicate header");
|
|
867
|
-
});
|
|
868
|
-
|
|
869
|
-
it("rejects positional arguments (no run-config positional)", async () => {
|
|
870
|
-
const cap = makeHostIo({
|
|
871
|
-
argv: ["run", "/some/run.json", "--anthropic-api-key", "x", ...COMMON]
|
|
872
|
-
});
|
|
873
|
-
await runCli(cap.io);
|
|
874
|
-
expect(cap.exitCode).toBe(2);
|
|
875
|
-
expect(cap.stderr).toContain("no positional arguments");
|
|
876
|
-
});
|
|
877
|
-
|
|
878
|
-
it("treats @@literal as a literal '@literal' on --prompt", async () => {
|
|
879
|
-
const cap = makeHostIo({
|
|
880
|
-
argv: [
|
|
881
|
-
"run",
|
|
882
|
-
"--model",
|
|
883
|
-
"claude-haiku-4-5",
|
|
884
|
-
"--prompt",
|
|
885
|
-
"@@alice please look at this",
|
|
886
|
-
"--anthropic-api-key",
|
|
887
|
-
"sk-ant-1",
|
|
888
|
-
...COMMON
|
|
889
|
-
],
|
|
890
|
-
fetchHandler: () =>
|
|
891
|
-
new Response(JSON.stringify({ id: "r-esc", status: "queued" }), {
|
|
892
|
-
status: 200,
|
|
893
|
-
headers: { "content-type": "application/json" }
|
|
894
|
-
})
|
|
895
|
-
});
|
|
896
|
-
await runCli(cap.io);
|
|
897
|
-
expect(cap.exitCode).toBe(0);
|
|
898
|
-
const body = cap.calls[0]!.body as Record<string, unknown>;
|
|
899
|
-
const submission = body.submission as Record<string, unknown>;
|
|
900
|
-
expect(submission.prompt).toEqual(["@alice please look at this"]);
|
|
901
|
-
});
|
|
902
|
-
});
|
|
903
|
-
|
|
904
|
-
describe("aex skills", () => {
|
|
905
|
-
it("lists skills as NDJSON", async () => {
|
|
906
|
-
const cap = makeHostIo({
|
|
907
|
-
argv: ["skills", "list", ...COMMON],
|
|
908
|
-
fetchHandler: () =>
|
|
909
|
-
new Response(
|
|
910
|
-
JSON.stringify({
|
|
911
|
-
skills: [
|
|
912
|
-
{ id: "skl_a", name: "alpha", hash: "h1", state: "ready" },
|
|
913
|
-
{ id: "skl_b", name: "beta", hash: "h2", state: "ready" }
|
|
914
|
-
]
|
|
915
|
-
}),
|
|
916
|
-
{ status: 200, headers: { "content-type": "application/json" } }
|
|
917
|
-
)
|
|
918
|
-
});
|
|
919
|
-
await runCli(cap.io);
|
|
920
|
-
expect(cap.exitCode).toBe(0);
|
|
921
|
-
expect(cap.calls[0]!.url).toBe("https://dash.example/api/skills");
|
|
922
|
-
expect(cap.calls[0]!.init.method ?? "GET").toBe("GET");
|
|
923
|
-
const lines = cap.stdout.trim().split("\n");
|
|
924
|
-
expect(lines).toHaveLength(2);
|
|
925
|
-
expect(JSON.parse(lines[0]!)).toMatchObject({ id: "skl_a" });
|
|
926
|
-
expect(JSON.parse(lines[1]!)).toMatchObject({ id: "skl_b" });
|
|
927
|
-
});
|
|
928
|
-
|
|
929
|
-
it("fetches a single skill by id", async () => {
|
|
930
|
-
const cap = makeHostIo({
|
|
931
|
-
argv: ["skills", "get", "skl_x", ...COMMON],
|
|
932
|
-
fetchHandler: () =>
|
|
933
|
-
new Response(
|
|
934
|
-
JSON.stringify({ skill: { id: "skl_x", name: "x", hash: "h", state: "ready" } }),
|
|
935
|
-
{ status: 200, headers: { "content-type": "application/json" } }
|
|
936
|
-
)
|
|
937
|
-
});
|
|
938
|
-
await runCli(cap.io);
|
|
939
|
-
expect(cap.exitCode).toBe(0);
|
|
940
|
-
expect(cap.calls[0]!.url).toBe("https://dash.example/api/skills/skl_x");
|
|
941
|
-
expect(JSON.parse(cap.stdout.trim())).toMatchObject({ id: "skl_x" });
|
|
942
|
-
});
|
|
943
|
-
|
|
944
|
-
it("DELETEs a skill", async () => {
|
|
945
|
-
const cap = makeHostIo({
|
|
946
|
-
argv: ["skills", "delete", "skl_y_long_id", ...COMMON],
|
|
947
|
-
fetchHandler: () => new Response(null, { status: 204 })
|
|
948
|
-
});
|
|
949
|
-
await runCli(cap.io);
|
|
950
|
-
expect(cap.exitCode).toBe(0);
|
|
951
|
-
expect(cap.calls[0]!.url).toBe("https://dash.example/api/skills/skl_y_long_id");
|
|
952
|
-
expect(cap.calls[0]!.init.method).toBe("DELETE");
|
|
953
|
-
expect(JSON.parse(cap.stdout.trim())).toEqual({ skillId: "skl_y_long_id", deleted: true });
|
|
954
|
-
});
|
|
955
|
-
|
|
956
|
-
it("requires --name on upload", async () => {
|
|
957
|
-
const cap = makeHostIo({
|
|
958
|
-
argv: ["skills", "upload", "--file", "SKILL.md", ...COMMON]
|
|
959
|
-
});
|
|
960
|
-
await runCli(cap.io);
|
|
961
|
-
expect(cap.exitCode).toBe(2);
|
|
962
|
-
expect(cap.stderr).toContain("--name is required");
|
|
963
|
-
});
|
|
964
|
-
|
|
965
|
-
it("rejects upload without --from-path or --file", async () => {
|
|
966
|
-
const cap = makeHostIo({
|
|
967
|
-
argv: ["skills", "upload", "--name", "x", ...COMMON]
|
|
968
|
-
});
|
|
969
|
-
await runCli(cap.io);
|
|
970
|
-
expect(cap.exitCode).toBe(2);
|
|
971
|
-
expect(cap.stderr).toContain("--from-path");
|
|
972
|
-
});
|
|
973
|
-
|
|
974
|
-
it("rejects upload combining --from-path and --file", async () => {
|
|
975
|
-
const cap = makeHostIo({
|
|
976
|
-
argv: [
|
|
977
|
-
"skills",
|
|
978
|
-
"upload",
|
|
979
|
-
"--name",
|
|
980
|
-
"x",
|
|
981
|
-
"--from-path",
|
|
982
|
-
"./d",
|
|
983
|
-
"--file",
|
|
984
|
-
"SKILL.md",
|
|
985
|
-
...COMMON
|
|
986
|
-
]
|
|
987
|
-
});
|
|
988
|
-
await runCli(cap.io);
|
|
989
|
-
expect(cap.exitCode).toBe(2);
|
|
990
|
-
expect(cap.stderr).toContain("--from-path");
|
|
991
|
-
});
|
|
992
|
-
|
|
993
|
-
it("rejects unknown skills verb", async () => {
|
|
994
|
-
const cap = makeHostIo({ argv: ["skills", "frobnicate", ...COMMON] });
|
|
995
|
-
await runCli(cap.io);
|
|
996
|
-
expect(cap.exitCode).toBe(2);
|
|
997
|
-
expect(cap.stderr).toContain("unknown skills verb");
|
|
998
|
-
});
|
|
999
|
-
|
|
1000
|
-
it("rejects unknown flags on skills list", async () => {
|
|
1001
|
-
const cap = makeHostIo({ argv: ["skills", "list", "--typo", ...COMMON] });
|
|
1002
|
-
await runCli(cap.io);
|
|
1003
|
-
expect(cap.exitCode).toBe(2);
|
|
1004
|
-
expect(cap.stderr).toContain("unknown flag: --typo");
|
|
1005
|
-
});
|
|
1006
|
-
|
|
1007
|
-
it("upload --file: runs the direct-to-storage flow (presign → object storage PUT → finalize) and prints the skill record", async () => {
|
|
1008
|
-
// The CLI catalog upload now goes direct-to-storage: the bytes never transit
|
|
1009
|
-
// the worker. Assert the three-step wire shape (presign → PUT → finalize)
|
|
1010
|
-
// and that the signed checksum header rides the object storage PUT.
|
|
1011
|
-
const tmp = makeSkillsTmpDir(
|
|
1012
|
-
"---\nname: rules-cli\ndescription: cli upload skill\n---\n# rules-cli\n"
|
|
1013
|
-
);
|
|
1014
|
-
const cap = makeHostIo({
|
|
1015
|
-
argv: [
|
|
1016
|
-
"skills",
|
|
1017
|
-
"upload",
|
|
1018
|
-
"--name",
|
|
1019
|
-
"rules-cli",
|
|
1020
|
-
"--file",
|
|
1021
|
-
join(tmp.dir, "SKILL.md"),
|
|
1022
|
-
...COMMON
|
|
1023
|
-
],
|
|
1024
|
-
fetchHandler: (call) => {
|
|
1025
|
-
if (call.url.endsWith("/api/skills/presign")) {
|
|
1026
|
-
const reqBody = JSON.parse(call.init.body as string) as { name: string; hash: string; sizeBytes: number };
|
|
1027
|
-
if (reqBody.name !== "rules-cli" || !/^sha256:[0-9a-f]{64}$/.test(reqBody.hash)) {
|
|
1028
|
-
return new Response(JSON.stringify({ error: { message: "bad presign body" } }), { status: 400, headers: { "content-type": "application/json" } });
|
|
1029
|
-
}
|
|
1030
|
-
return new Response(
|
|
1031
|
-
JSON.stringify({
|
|
1032
|
-
ok: true,
|
|
1033
|
-
skillId: "skl_cli_42",
|
|
1034
|
-
uploadUrl: "https://object-storage.example.test/bucket/assets/ws/hash?X-Amz-Signature=sig",
|
|
1035
|
-
requiredHeaders: { "x-amz-checksum-sha256": "Y2hlY2tzdW0=" },
|
|
1036
|
-
expiresInSeconds: 300
|
|
1037
|
-
}),
|
|
1038
|
-
{ status: 201, headers: { "content-type": "application/json" } }
|
|
1039
|
-
);
|
|
1040
|
-
}
|
|
1041
|
-
if (call.url.includes("object-storage.example.test")) {
|
|
1042
|
-
return new Response("", { status: 200 }); // object storage accepts the direct PUT
|
|
1043
|
-
}
|
|
1044
|
-
if (call.url.endsWith("/api/skills/skl_cli_42/finalize")) {
|
|
1045
|
-
return new Response(
|
|
1046
|
-
JSON.stringify({
|
|
1047
|
-
skill: { id: "skl_cli_42", name: "rules-cli", state: "ready", hash: "sha256:" + "a".repeat(64), fileCount: 1 }
|
|
1048
|
-
}),
|
|
1049
|
-
{ status: 200, headers: { "content-type": "application/json" } }
|
|
1050
|
-
);
|
|
1051
|
-
}
|
|
1052
|
-
return new Response(JSON.stringify({ error: { message: `unexpected url ${call.url}` } }), { status: 400, headers: { "content-type": "application/json" } });
|
|
1053
|
-
}
|
|
1054
|
-
});
|
|
1055
|
-
try {
|
|
1056
|
-
await runCli(cap.io);
|
|
1057
|
-
expect(cap.stderr).toBe("");
|
|
1058
|
-
expect(cap.exitCode).toBe(0);
|
|
1059
|
-
const urls = cap.calls.map((c) => c.url);
|
|
1060
|
-
expect(urls).toContain("https://dash.example/api/skills/presign");
|
|
1061
|
-
expect(urls.some((u) => u.includes("object-storage.example.test"))).toBe(true);
|
|
1062
|
-
expect(urls).toContain("https://dash.example/api/skills/skl_cli_42/finalize");
|
|
1063
|
-
const storagePut = cap.calls.find((c) => c.url.includes("object-storage.example.test"))!;
|
|
1064
|
-
expect(storagePut.init.method).toBe("PUT");
|
|
1065
|
-
expect((storagePut.init.headers as Record<string, string>)["x-amz-checksum-sha256"]).toBe("Y2hlY2tzdW0=");
|
|
1066
|
-
const printed = JSON.parse(cap.stdout.trim()) as { id: string; name: string };
|
|
1067
|
-
expect(printed.id).toBe("skl_cli_42");
|
|
1068
|
-
expect(printed.name).toBe("rules-cli");
|
|
1069
|
-
} finally {
|
|
1070
|
-
tmp.cleanup();
|
|
1071
|
-
}
|
|
1072
|
-
});
|
|
1073
|
-
|
|
1074
|
-
it("upload: falls back to the buffered multipart POST /api/skills when presign is unconfigured (503)", async () => {
|
|
1075
|
-
const tmp = makeSkillsTmpDir(
|
|
1076
|
-
"---\nname: rules-fallback\ndescription: cli upload skill\n---\n# rules-fallback\n"
|
|
1077
|
-
);
|
|
1078
|
-
const cap = makeHostIo({
|
|
1079
|
-
argv: ["skills", "upload", "--name", "rules-fallback", "--file", join(tmp.dir, "SKILL.md"), ...COMMON],
|
|
1080
|
-
fetchHandler: (call) => {
|
|
1081
|
-
if (call.url.endsWith("/api/skills/presign")) {
|
|
1082
|
-
return new Response(JSON.stringify({ ok: false, code: "presign_unconfigured", message: "object storage S3 creds not configured" }), { status: 503, headers: { "content-type": "application/json" } });
|
|
1083
|
-
}
|
|
1084
|
-
// Fallback path: buffered multipart upload to /api/skills.
|
|
1085
|
-
const body = call.init.body;
|
|
1086
|
-
if (!(body instanceof FormData) || body.get("name") !== "rules-fallback" || !(body.get("bundle") instanceof Blob)) {
|
|
1087
|
-
return new Response(JSON.stringify({ error: { message: "expected multipart FormData" } }), { status: 400, headers: { "content-type": "application/json" } });
|
|
1088
|
-
}
|
|
1089
|
-
return new Response(
|
|
1090
|
-
JSON.stringify({ skill: { id: "skl_fb", name: "rules-fallback", state: "ready", hash: "sha256:" + "b".repeat(64), fileCount: 1 } }),
|
|
1091
|
-
{ status: 201, headers: { "content-type": "application/json" } }
|
|
1092
|
-
);
|
|
1093
|
-
}
|
|
1094
|
-
});
|
|
1095
|
-
try {
|
|
1096
|
-
await runCli(cap.io);
|
|
1097
|
-
expect(cap.stderr).toBe("");
|
|
1098
|
-
expect(cap.exitCode).toBe(0);
|
|
1099
|
-
const urls = cap.calls.map((c) => c.url);
|
|
1100
|
-
expect(urls).toContain("https://dash.example/api/skills/presign");
|
|
1101
|
-
expect(urls).toContain("https://dash.example/api/skills");
|
|
1102
|
-
expect(urls.some((u) => u.includes("object-storage.example.test"))).toBe(false);
|
|
1103
|
-
const printed = JSON.parse(cap.stdout.trim()) as { id: string };
|
|
1104
|
-
expect(printed.id).toBe("skl_fb");
|
|
1105
|
-
} finally {
|
|
1106
|
-
tmp.cleanup();
|
|
1107
|
-
}
|
|
1108
|
-
});
|
|
1109
|
-
|
|
1110
|
-
it("upload: propagates the server's verbose error message (so 'Bucket not found' reaches the user)", async () => {
|
|
1111
|
-
// Customer regression coverage (Bug 1, the user-visible half):
|
|
1112
|
-
// when object storage returns a 400 with a body like
|
|
1113
|
-
// `{"statusCode":"404","error":"Bucket not found"}`, the API
|
|
1114
|
-
// wraps it into a 500 carrying that body verbatim. The CLI must
|
|
1115
|
-
// forward the verbose detail to the user — otherwise the operator
|
|
1116
|
-
// sees only "skill_upload_failed: upload failed" and cannot
|
|
1117
|
-
// diagnose the missing-bucket cause.
|
|
1118
|
-
const tmp = makeSkillsTmpDir(
|
|
1119
|
-
"---\nname: broken\ndescription: broken upload\n---\n# broken\n"
|
|
1120
|
-
);
|
|
1121
|
-
const cap = makeHostIo({
|
|
1122
|
-
argv: [
|
|
1123
|
-
"skills",
|
|
1124
|
-
"upload",
|
|
1125
|
-
"--name",
|
|
1126
|
-
"broken",
|
|
1127
|
-
"--file",
|
|
1128
|
-
join(tmp.dir, "SKILL.md"),
|
|
1129
|
-
...COMMON
|
|
1130
|
-
],
|
|
1131
|
-
fetchHandler: () =>
|
|
1132
|
-
new Response(
|
|
1133
|
-
JSON.stringify({
|
|
1134
|
-
error:
|
|
1135
|
-
"Object storage upload failed with HTTP 400: " +
|
|
1136
|
-
'{"statusCode":"404","error":"Bucket not found","message":"Bucket not found"}'
|
|
1137
|
-
}),
|
|
1138
|
-
{ status: 500, headers: { "content-type": "application/json" } }
|
|
1139
|
-
)
|
|
1140
|
-
});
|
|
1141
|
-
try {
|
|
1142
|
-
await runCli(cap.io);
|
|
1143
|
-
expect(cap.exitCode).toBe(1);
|
|
1144
|
-
// The CLI emits JSON errors on stderr; the verbose message must
|
|
1145
|
-
// travel through unmodified.
|
|
1146
|
-
const errLine = cap.stderr.trim();
|
|
1147
|
-
const errJson = JSON.parse(errLine) as { error: string; message: string };
|
|
1148
|
-
expect(errJson.error).toBe("skill_upload_failed");
|
|
1149
|
-
expect(errJson.message).toMatch(/Bucket not found/i);
|
|
1150
|
-
} finally {
|
|
1151
|
-
tmp.cleanup();
|
|
1152
|
-
}
|
|
1153
|
-
});
|
|
1154
|
-
});
|