@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/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
|
-
});
|