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