@aexhq/cli 0.13.6

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.
Files changed (92) hide show
  1. package/LICENSE +201 -0
  2. package/dist/cli.d.ts +1 -0
  3. package/dist/cli.js +51 -0
  4. package/dist/cli.js.map +1 -0
  5. package/dist/cli.mjs +4140 -0
  6. package/dist/cli.mjs.sha256 +1 -0
  7. package/dist/host/cancel.d.ts +3 -0
  8. package/dist/host/cancel.js +30 -0
  9. package/dist/host/cancel.js.map +1 -0
  10. package/dist/host/common.d.ts +135 -0
  11. package/dist/host/common.js +281 -0
  12. package/dist/host/common.js.map +1 -0
  13. package/dist/host/delete-asset.d.ts +3 -0
  14. package/dist/host/delete-asset.js +34 -0
  15. package/dist/host/delete-asset.js.map +1 -0
  16. package/dist/host/delete.d.ts +3 -0
  17. package/dist/host/delete.js +30 -0
  18. package/dist/host/delete.js.map +1 -0
  19. package/dist/host/download.d.ts +3 -0
  20. package/dist/host/download.js +84 -0
  21. package/dist/host/download.js.map +1 -0
  22. package/dist/host/events.d.ts +3 -0
  23. package/dist/host/events.js +97 -0
  24. package/dist/host/events.js.map +1 -0
  25. package/dist/host/index.d.ts +17 -0
  26. package/dist/host/index.js +18 -0
  27. package/dist/host/index.js.map +1 -0
  28. package/dist/host/outputs.d.ts +3 -0
  29. package/dist/host/outputs.js +36 -0
  30. package/dist/host/outputs.js.map +1 -0
  31. package/dist/host/run-cmd.d.ts +3 -0
  32. package/dist/host/run-cmd.js +546 -0
  33. package/dist/host/run-cmd.js.map +1 -0
  34. package/dist/host/skills-cmd.d.ts +3 -0
  35. package/dist/host/skills-cmd.js +304 -0
  36. package/dist/host/skills-cmd.js.map +1 -0
  37. package/dist/host/status.d.ts +3 -0
  38. package/dist/host/status.js +31 -0
  39. package/dist/host/status.js.map +1 -0
  40. package/dist/host/wait.d.ts +3 -0
  41. package/dist/host/wait.js +88 -0
  42. package/dist/host/wait.js.map +1 -0
  43. package/dist/host/whoami.d.ts +3 -0
  44. package/dist/host/whoami.js +33 -0
  45. package/dist/host/whoami.js.map +1 -0
  46. package/dist/index.d.ts +7 -0
  47. package/dist/index.js +7 -0
  48. package/dist/index.js.map +1 -0
  49. package/dist/internal.d.ts +53 -0
  50. package/dist/internal.js +10 -0
  51. package/dist/internal.js.map +1 -0
  52. package/dist/outputs-sync.d.ts +24 -0
  53. package/dist/outputs-sync.js +70 -0
  54. package/dist/outputs-sync.js.map +1 -0
  55. package/dist/proxy.d.ts +15 -0
  56. package/dist/proxy.js +256 -0
  57. package/dist/proxy.js.map +1 -0
  58. package/dist/run.d.ts +10 -0
  59. package/dist/run.js +166 -0
  60. package/dist/run.js.map +1 -0
  61. package/dist/runtime.d.ts +5 -0
  62. package/dist/runtime.js +4 -0
  63. package/dist/runtime.js.map +1 -0
  64. package/package.json +42 -0
  65. package/scripts/finalize-bundle.mjs +72 -0
  66. package/src/cli.ts +51 -0
  67. package/src/host/cancel.ts +39 -0
  68. package/src/host/common.ts +329 -0
  69. package/src/host/delete-asset.ts +43 -0
  70. package/src/host/delete.ts +39 -0
  71. package/src/host/download.ts +101 -0
  72. package/src/host/events.ts +113 -0
  73. package/src/host/index.ts +25 -0
  74. package/src/host/outputs.ts +45 -0
  75. package/src/host/run-cmd.ts +573 -0
  76. package/src/host/skills-cmd.ts +317 -0
  77. package/src/host/status.ts +40 -0
  78. package/src/host/wait.ts +110 -0
  79. package/src/host/whoami.ts +42 -0
  80. package/src/index.ts +7 -0
  81. package/src/internal.ts +63 -0
  82. package/src/outputs-sync.ts +76 -0
  83. package/src/proxy.ts +288 -0
  84. package/src/run.ts +189 -0
  85. package/src/runtime.ts +15 -0
  86. package/test/cli.test.ts +456 -0
  87. package/test/host.test.ts +1154 -0
  88. package/test/no-env-vars.test.ts +59 -0
  89. package/test/outputs-sync.test.ts +105 -0
  90. package/tsconfig.build.json +9 -0
  91. package/tsconfig.json +4 -0
  92. package/vitest.config.ts +14 -0
@@ -0,0 +1,456 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { PROXY_PROTOCOL_VERSION } from "@aexhq/contracts";
3
+ import { runCli } from "../src/run.js";
4
+ import { AEX_INDEX_PATH, AEX_RUN_TOKEN_PATH, type CliIO } from "../src/internal.js";
5
+
6
+ interface IoCapture {
7
+ io: CliIO;
8
+ stdout: string;
9
+ stderr: string;
10
+ exitCode: number | null;
11
+ fetchCalls: Array<{ url: string; init: RequestInit | undefined }>;
12
+ }
13
+
14
+ function makeIo(opts: {
15
+ argv: readonly string[];
16
+ files?: Record<string, string>;
17
+ fetchHandler?: (url: string, init?: RequestInit) => Promise<Response>;
18
+ } = { argv: [] }): IoCapture {
19
+ const files: Record<string, string> = opts.files ?? {};
20
+ const cap: IoCapture = {
21
+ stdout: "",
22
+ stderr: "",
23
+ exitCode: null,
24
+ fetchCalls: [],
25
+ io: undefined as unknown as CliIO
26
+ };
27
+ const io: CliIO = {
28
+ argv: ["node", "/aex/aex", ...opts.argv],
29
+ readFile: async (path) => {
30
+ if (!(path in files)) throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
31
+ return files[path]!;
32
+ },
33
+ writeFile: async () => {
34
+ throw new Error("writeFile not configured for this test");
35
+ },
36
+ cwd: () => "/tmp/test-cwd",
37
+ fetchImpl: async (url, init) => {
38
+ cap.fetchCalls.push({ url: String(url), init });
39
+ if (!opts.fetchHandler) {
40
+ return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
41
+ }
42
+ return opts.fetchHandler(String(url), init);
43
+ },
44
+ stdout: (chunk) => {
45
+ cap.stdout += chunk;
46
+ },
47
+ stderr: (chunk) => {
48
+ cap.stderr += chunk;
49
+ },
50
+ exit: (code) => {
51
+ cap.exitCode = code;
52
+ }
53
+ };
54
+ cap.io = io;
55
+ return cap;
56
+ }
57
+
58
+ function manifestJson(opts: {
59
+ endpoints?: Array<{ name: string; allowMethods?: string[]; allowPathPrefixes?: string[] }>;
60
+ proxyBaseUrl?: string | null;
61
+ }): string {
62
+ return JSON.stringify({
63
+ protocolVersion: PROXY_PROTOCOL_VERSION,
64
+ runId: "run-1",
65
+ proxyBaseUrl: opts.proxyBaseUrl === undefined ? "https://dash.example.com/api/runs/run-1/proxy" : opts.proxyBaseUrl,
66
+ endpoints: (opts.endpoints ?? []).map((e) => ({
67
+ name: e.name,
68
+ baseUrl: "https://upstream.example.com",
69
+ authShape: { type: "bearer" },
70
+ allowMethods: e.allowMethods ?? ["GET"],
71
+ allowPathPrefixes: e.allowPathPrefixes ?? ["/"],
72
+ allowHeaders: [],
73
+ responseMode: "headers_only",
74
+ maxRequestBytes: 65536,
75
+ maxResponseBytes: 65536,
76
+ timeoutMs: 10000,
77
+ perCallBudget: 60,
78
+ responseByteBudget: 1048576
79
+ }))
80
+ });
81
+ }
82
+
83
+ describe("aex --help", () => {
84
+ it("prints host-mode usage and exits 0 without a manifest", async () => {
85
+ const cap = makeIo({ argv: ["--help"] });
86
+ await runCli(cap.io);
87
+ expect(cap.exitCode).toBe(0);
88
+ expect(cap.stdout).toContain("aex run");
89
+ expect(cap.stdout).toContain("aex whoami");
90
+ expect(cap.stdout).toContain("Usage:");
91
+ });
92
+
93
+ it("does not advertise --workspace anywhere in host help", async () => {
94
+ // The workspace is derived 1:1 from the API token; --workspace is rejected
95
+ // at parse time. The help text must not contradict that contract.
96
+ const cap = makeIo({ argv: ["--help"] });
97
+ await runCli(cap.io);
98
+ expect(cap.exitCode).toBe(0);
99
+ expect(cap.stdout).not.toContain("--workspace");
100
+ });
101
+
102
+ it("advertises --aex-url as optional with the api.aex.dev default", async () => {
103
+ const cap = makeIo({ argv: ["--help"] });
104
+ await runCli(cap.io);
105
+ expect(cap.exitCode).toBe(0);
106
+ expect(cap.stdout).toContain("https://api.aex.dev");
107
+ });
108
+
109
+ it("lists declared endpoints when manifest is present", async () => {
110
+ const cap = makeIo({
111
+ argv: [],
112
+ files: {
113
+ [AEX_INDEX_PATH]: manifestJson({ endpoints: [{ name: "stripe" }, { name: "internal" }] })
114
+ }
115
+ });
116
+ await runCli(cap.io);
117
+ expect(cap.exitCode).toBe(0);
118
+ expect(cap.stdout).toContain("stripe");
119
+ expect(cap.stdout).toContain("internal");
120
+ });
121
+
122
+ it("notes when no proxy endpoints were declared", async () => {
123
+ const cap = makeIo({
124
+ argv: ["--help"],
125
+ files: { [AEX_INDEX_PATH]: manifestJson({ endpoints: [], proxyBaseUrl: null }) }
126
+ });
127
+ await runCli(cap.io);
128
+ expect(cap.exitCode).toBe(0);
129
+ expect(cap.stdout).toContain("no proxy endpoints");
130
+ });
131
+ });
132
+
133
+ describe("aex proxy — argument validation", () => {
134
+ it("exits 2 on unknown subcommand", async () => {
135
+ const cap = makeIo({ argv: ["snorlax"] });
136
+ await runCli(cap.io);
137
+ expect(cap.exitCode).toBe(2);
138
+ expect(cap.stderr).toContain("unknown subcommand");
139
+ });
140
+
141
+ it("exits 2 with missing endpoint-name", async () => {
142
+ const cap = makeIo({ argv: ["proxy"] });
143
+ await runCli(cap.io);
144
+ expect(cap.exitCode).toBe(2);
145
+ expect(cap.stderr).toContain("missing endpoint-name");
146
+ });
147
+
148
+ it("exits 2 on unknown flag", async () => {
149
+ const cap = makeIo({ argv: ["proxy", "--bogus", "x", "stripe"] });
150
+ await runCli(cap.io);
151
+ expect(cap.exitCode).toBe(2);
152
+ expect(cap.stderr).toMatch(/unknown flag/);
153
+ });
154
+
155
+ it("exits 2 on invalid --response-mode", async () => {
156
+ const cap = makeIo({ argv: ["proxy", "stripe", "--response-mode", "everything"] });
157
+ await runCli(cap.io);
158
+ expect(cap.exitCode).toBe(2);
159
+ expect(cap.stderr).toContain("--response-mode");
160
+ });
161
+
162
+ it("rejects --header without =", async () => {
163
+ const cap = makeIo({ argv: ["proxy", "stripe", "--header", "boom"] });
164
+ await runCli(cap.io);
165
+ expect(cap.exitCode).toBe(2);
166
+ expect(cap.stderr).toContain("KEY=VALUE");
167
+ });
168
+ });
169
+
170
+ describe("aex proxy — IO contract", () => {
171
+ it("fails when the manifest is missing", async () => {
172
+ const cap = makeIo({ argv: ["proxy", "stripe"] });
173
+ await runCli(cap.io);
174
+ expect(cap.exitCode).toBe(1);
175
+ expect(cap.stderr).toContain("manifest not mounted");
176
+ });
177
+
178
+ it("fails when the run has no proxyBaseUrl declared", async () => {
179
+ const cap = makeIo({
180
+ argv: ["proxy", "stripe"],
181
+ files: { [AEX_INDEX_PATH]: manifestJson({ endpoints: [], proxyBaseUrl: null }) }
182
+ });
183
+ await runCli(cap.io);
184
+ expect(cap.exitCode).toBe(1);
185
+ const body = JSON.parse(cap.stderr.trim());
186
+ expect(body.error).toBe("endpoint_not_found");
187
+ });
188
+
189
+ it("fails when the run-token file is missing", async () => {
190
+ const cap = makeIo({
191
+ argv: ["proxy", "stripe"],
192
+ files: { [AEX_INDEX_PATH]: manifestJson({ endpoints: [{ name: "stripe" }] }) }
193
+ });
194
+ await runCli(cap.io);
195
+ expect(cap.exitCode).toBe(1);
196
+ const body = JSON.parse(cap.stderr.trim());
197
+ expect(body.error).toBe("unauthorized");
198
+ });
199
+ });
200
+
201
+ describe("aex proxy — successful call", () => {
202
+ it("sends the protocol headers and writes the response envelope to stdout", async () => {
203
+ const upstreamBody = {
204
+ endpointName: "stripe",
205
+ upstreamStatus: 200,
206
+ upstreamHeaders: { "content-type": "application/json" },
207
+ effectiveResponseMode: "headers_only",
208
+ modeClamped: false,
209
+ remainingCalls: 59,
210
+ remainingResponseBytes: 1000000
211
+ };
212
+ const cap = makeIo({
213
+ argv: ["proxy", "stripe", "--method", "POST", "--path", "/v1/refunds"],
214
+ files: {
215
+ [AEX_INDEX_PATH]: manifestJson({ endpoints: [{ name: "stripe", allowMethods: ["POST"] }] }),
216
+ [AEX_RUN_TOKEN_PATH]: "bearer-xyz"
217
+ },
218
+ fetchHandler: async () =>
219
+ new Response(JSON.stringify(upstreamBody), {
220
+ status: 200,
221
+ headers: { "content-type": "application/json" }
222
+ })
223
+ });
224
+ await runCli(cap.io);
225
+ expect(cap.exitCode).toBe(0);
226
+ expect(cap.fetchCalls).toHaveLength(1);
227
+ const call = cap.fetchCalls[0]!;
228
+ expect(call.url).toBe("https://dash.example.com/api/runs/run-1/proxy/stripe");
229
+ const headers = new Headers((call.init as RequestInit).headers);
230
+ expect(headers.get("authorization")).toBe("Bearer bearer-xyz");
231
+ expect(headers.get("x-aex-proxy-protocol")).toBe(PROXY_PROTOCOL_VERSION);
232
+ expect(headers.get("x-aex-method")).toBe("POST");
233
+ expect(headers.get("x-aex-path")).toBe("/v1/refunds");
234
+ const body = JSON.parse(cap.stdout.trim());
235
+ expect(body.upstreamStatus).toBe(200);
236
+ });
237
+
238
+ it("forwards --header K=V via the X-Aex-Headers JSON record", async () => {
239
+ let captured: Headers | null = null;
240
+ const cap = makeIo({
241
+ argv: ["proxy", "stripe", "--header", "accept=application/json"],
242
+ files: {
243
+ [AEX_INDEX_PATH]: manifestJson({ endpoints: [{ name: "stripe" }] }),
244
+ [AEX_RUN_TOKEN_PATH]: "tok"
245
+ },
246
+ fetchHandler: async (_url, init) => {
247
+ captured = new Headers((init as RequestInit).headers);
248
+ return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
249
+ }
250
+ });
251
+ await runCli(cap.io);
252
+ expect(cap.exitCode).toBe(0);
253
+ const json = JSON.parse(captured!.get("x-aex-headers")!);
254
+ expect(json.accept).toBe("application/json");
255
+ });
256
+
257
+ it("propagates --query as the X-Aex-Query header", async () => {
258
+ let captured: Headers | null = null;
259
+ const cap = makeIo({
260
+ argv: ["proxy", "stripe", "--query", '{"limit":"10"}'],
261
+ files: {
262
+ [AEX_INDEX_PATH]: manifestJson({ endpoints: [{ name: "stripe" }] }),
263
+ [AEX_RUN_TOKEN_PATH]: "tok"
264
+ },
265
+ fetchHandler: async (_url, init) => {
266
+ captured = new Headers((init as RequestInit).headers);
267
+ return new Response("{}", { status: 200 });
268
+ }
269
+ });
270
+ await runCli(cap.io);
271
+ expect(captured!.get("x-aex-query")).toBe('{"limit":"10"}');
272
+ });
273
+
274
+ it("forwards --data inline as the request body", async () => {
275
+ let bodyCaptured: string | null = null;
276
+ const cap = makeIo({
277
+ argv: ["proxy", "stripe", "--method", "POST", "--data", "hello"],
278
+ files: {
279
+ [AEX_INDEX_PATH]: manifestJson({ endpoints: [{ name: "stripe", allowMethods: ["POST"] }] }),
280
+ [AEX_RUN_TOKEN_PATH]: "tok"
281
+ },
282
+ fetchHandler: async (_url, init) => {
283
+ const b = (init as RequestInit).body;
284
+ bodyCaptured = b ? Buffer.from(b as Uint8Array).toString("utf8") : null;
285
+ return new Response("{}", { status: 200 });
286
+ }
287
+ });
288
+ await runCli(cap.io);
289
+ expect(bodyCaptured).toBe("hello");
290
+ });
291
+ });
292
+
293
+ describe("aex proxy — error envelope", () => {
294
+ it("exits 1 with a stable error body on a 4xx from the BFF", async () => {
295
+ const cap = makeIo({
296
+ argv: ["proxy", "stripe", "--method", "GET", "--path", "/x"],
297
+ files: {
298
+ [AEX_INDEX_PATH]: manifestJson({ endpoints: [{ name: "stripe" }] }),
299
+ [AEX_RUN_TOKEN_PATH]: "tok"
300
+ },
301
+ fetchHandler: async () =>
302
+ new Response(JSON.stringify({ error: "policy_denied", message: "[redacted]" }), {
303
+ status: 403,
304
+ headers: { "content-type": "application/json" }
305
+ })
306
+ });
307
+ await runCli(cap.io);
308
+ expect(cap.exitCode).toBe(1);
309
+ const body = JSON.parse(cap.stderr.trim());
310
+ expect(body.error).toBe("policy_denied");
311
+ });
312
+
313
+ it("exits 1 with unauthorized on a 401 from the BFF", async () => {
314
+ const cap = makeIo({
315
+ argv: ["proxy", "stripe"],
316
+ files: {
317
+ [AEX_INDEX_PATH]: manifestJson({ endpoints: [{ name: "stripe" }] }),
318
+ [AEX_RUN_TOKEN_PATH]: "tok"
319
+ },
320
+ fetchHandler: async () =>
321
+ new Response(JSON.stringify({ error: "unauthorized", message: "[redacted]" }), { status: 401 })
322
+ });
323
+ await runCli(cap.io);
324
+ expect(cap.exitCode).toBe(1);
325
+ const body = JSON.parse(cap.stderr.trim());
326
+ expect(body.error).toBe("unauthorized");
327
+ });
328
+
329
+ // Locks the agent-visible CLI contract: every BFF-returned error code
330
+ // exits non-zero with a structured stderr envelope that matches the
331
+ // BFF's `error` field exactly. The agent reads stderr — drift here
332
+ // means the agent has to learn a second error-code vocabulary.
333
+ const errorTable: ReadonlyArray<{
334
+ name: string;
335
+ httpStatus: number;
336
+ errorCode: string;
337
+ }> = [
338
+ { name: "bad_request", httpStatus: 400, errorCode: "bad_request" },
339
+ { name: "endpoint_not_found", httpStatus: 404, errorCode: "endpoint_not_found" },
340
+ { name: "rate_limited", httpStatus: 429, errorCode: "rate_limited" },
341
+ { name: "budget_exceeded", httpStatus: 429, errorCode: "budget_exceeded" },
342
+ { name: "upstream_error", httpStatus: 502, errorCode: "upstream_error" },
343
+ { name: "upstream_timeout", httpStatus: 504, errorCode: "upstream_timeout" },
344
+ { name: "exceeded_cap", httpStatus: 502, errorCode: "exceeded_cap" },
345
+ { name: "internal_error", httpStatus: 500, errorCode: "internal_error" },
346
+ { name: "ssrf_denied", httpStatus: 403, errorCode: "ssrf_denied" },
347
+ { name: "unsupported_protocol", httpStatus: 426, errorCode: "unsupported_protocol" }
348
+ ];
349
+
350
+ for (const tc of errorTable) {
351
+ it(`exits 1 and surfaces ${tc.errorCode} on HTTP ${tc.httpStatus}`, async () => {
352
+ const cap = makeIo({
353
+ argv: ["proxy", "stripe"],
354
+ files: {
355
+ [AEX_INDEX_PATH]: manifestJson({ endpoints: [{ name: "stripe" }] }),
356
+ [AEX_RUN_TOKEN_PATH]: "tok"
357
+ },
358
+ fetchHandler: async () =>
359
+ new Response(JSON.stringify({ error: tc.errorCode, message: "[redacted]" }), {
360
+ status: tc.httpStatus,
361
+ headers: { "content-type": "application/json" }
362
+ })
363
+ });
364
+ await runCli(cap.io);
365
+ expect(cap.exitCode).toBe(1);
366
+ const body = JSON.parse(cap.stderr.trim());
367
+ expect(body.error).toBe(tc.errorCode);
368
+ // The agent must never see upstream-derived strings — the BFF's
369
+ // sanitization rule mandates `message: "[redacted]"`.
370
+ expect(body.message).toBe("[redacted]");
371
+ });
372
+ }
373
+
374
+ it("falls back to a stable error envelope when the BFF body is not JSON", async () => {
375
+ const cap = makeIo({
376
+ argv: ["proxy", "stripe"],
377
+ files: {
378
+ [AEX_INDEX_PATH]: manifestJson({ endpoints: [{ name: "stripe" }] }),
379
+ [AEX_RUN_TOKEN_PATH]: "tok"
380
+ },
381
+ fetchHandler: async () =>
382
+ new Response("not json at all", { status: 502, headers: { "content-type": "text/plain" } })
383
+ });
384
+ await runCli(cap.io);
385
+ expect(cap.exitCode).toBe(1);
386
+ // CLI must produce a structured stderr line even when the BFF
387
+ // misbehaves. The contract: the FIRST line of stderr is parseable
388
+ // JSON with an `error` field.
389
+ const envelopeLine = cap.stderr.split("\n").find((l) => l.startsWith("{"));
390
+ expect(envelopeLine, `expected a JSON envelope line in stderr; got:\n${cap.stderr}`).toBeDefined();
391
+ const body = JSON.parse(envelopeLine!);
392
+ expect(typeof body.error).toBe("string");
393
+ });
394
+
395
+ it(
396
+ "reproduces the customer's canonical-host redirect regression: HTTP 307 + 'Redirecting...' body, " +
397
+ "and the new diagnostic message exposes HTTP status + body snippet",
398
+ async () => {
399
+ // This mirrors the response shape captured when a proxy public base
400
+ // URL pointed at a host that canonicalized to another host:
401
+ //
402
+ // HTTP/2 307
403
+ // cache-control: public, max-age=0, must-revalidate
404
+ // content-type: text/plain
405
+ // location: https://aex.dev/api/runs/<runId>/proxy/<name>
406
+ // server: edge
407
+ //
408
+ // Redirecting...
409
+ //
410
+ // The bug: in-container CLI uses `redirect: "manual"` (SSRF
411
+ // guard) and parses "Redirecting...\n" as JSON, surfacing
412
+ // "proxy returned non-JSON response" with no actionable detail.
413
+ // Fix: the error message now embeds HTTP status + first 200
414
+ // chars of the body so triage doesn't require another live run.
415
+ const cap = makeIo({
416
+ argv: ["proxy", "stripe"],
417
+ files: {
418
+ [AEX_INDEX_PATH]: manifestJson({ endpoints: [{ name: "stripe" }] }),
419
+ [AEX_RUN_TOKEN_PATH]: "tok"
420
+ },
421
+ fetchHandler: async () =>
422
+ new Response("Redirecting...\n", {
423
+ status: 307,
424
+ headers: {
425
+ "content-type": "text/plain",
426
+ "cache-control": "public, max-age=0, must-revalidate",
427
+ location: "https://www.dashboard.example.com/api/runs/run-1/proxy/stripe",
428
+ server: "edge"
429
+ }
430
+ })
431
+ });
432
+ await runCli(cap.io);
433
+ expect(cap.exitCode).toBe(1);
434
+ const envelopeLine = cap.stderr.split("\n").find((l) => l.startsWith("{"));
435
+ expect(envelopeLine, `expected JSON envelope line in stderr; got:\n${cap.stderr}`).toBeDefined();
436
+ const body = JSON.parse(envelopeLine!) as { error: string; message: string };
437
+ expect(body.error).toBe("internal_error");
438
+ // Load-bearing assertions — these would have FAILED against the
439
+ // pre-fix CLI which only emitted a generic
440
+ // "proxy returned non-JSON response" with no detail. Triage
441
+ // signal is in the message itself.
442
+ expect(body.message).toMatch(/HTTP 307/);
443
+ expect(body.message).toMatch(/Redirecting/);
444
+ }
445
+ );
446
+ });
447
+
448
+ describe("aex proxy --help", () => {
449
+ it("renders proxy help and exits 0", async () => {
450
+ const cap = makeIo({ argv: ["proxy", "--help"] });
451
+ await runCli(cap.io);
452
+ expect(cap.exitCode).toBe(0);
453
+ expect(cap.stdout).toContain("--method");
454
+ expect(cap.stdout).toContain("--response-mode");
455
+ });
456
+ });