@anteros/core 0.0.44 → 0.0.47

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 (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -1
  3. package/database/field.crypto.ts +38 -13
  4. package/database/mongodbadapter.ts +85 -25
  5. package/database/vars.ts +22 -2
  6. package/index.ts +22 -0
  7. package/lib/agent.ts +707 -79
  8. package/lib/agentMemory.ts +199 -0
  9. package/lib/agents.ts +133 -52
  10. package/lib/attachment.ts +24 -3
  11. package/lib/define.ts +5 -1
  12. package/lib/eval.ts +123 -0
  13. package/lib/files.ts +21 -15
  14. package/lib/jsonSchema.ts +8 -0
  15. package/lib/mcp.ts +52 -30
  16. package/lib/messages.ts +202 -19
  17. package/lib/partialJson.ts +79 -0
  18. package/lib/providers.ts +26 -2
  19. package/lib/redact.ts +11 -0
  20. package/lib/tracing.ts +20 -0
  21. package/lib/zip.ts +23 -3
  22. package/package.json +4 -5
  23. package/server/agents.ts +66 -1
  24. package/server/api.ts +302 -20
  25. package/server/boot.ts +13 -4
  26. package/server/hono.ts +146 -6
  27. package/tests/access-log-body.test.ts +253 -0
  28. package/tests/agent-checkpoint.test.ts +195 -0
  29. package/tests/agent-hardening.test.ts +238 -0
  30. package/tests/agent-memory.test.ts +175 -1
  31. package/tests/agent-stream-advanced.test.ts +246 -0
  32. package/tests/agent-tracing-eval.test.ts +219 -0
  33. package/tests/agent-working-memory.test.ts +7 -4
  34. package/tests/agent.test.ts +54 -3
  35. package/tests/agents-api.test.ts +21 -0
  36. package/tests/api-external-guards.test.ts +304 -0
  37. package/tests/attachment.test.ts +63 -0
  38. package/tests/audit-redact.test.ts +26 -1
  39. package/tests/field-encryption.test.ts +47 -3
  40. package/tests/filters-hardening.test.ts +81 -0
  41. package/tests/fixtures/ai-tenant/ai/agents/support/billing.agent.ts +9 -0
  42. package/tests/fixtures/ai-tenant/ai/agents/support/billing.tool.ts +9 -0
  43. package/tests/fixtures/ai-tenant/ai/agents/support.agent.ts +9 -0
  44. package/tests/fixtures/ai-tenant/ai/agents/support.tool.ts +9 -0
  45. package/tests/fixtures/ai-tenant/ai/mcp/resources/readme.resource.ts +9 -0
  46. package/tests/fixtures/ai-tenant/ai/mcp/tools/echo.tool.ts +9 -0
  47. package/tests/fixtures/fake-provider.ts +33 -0
  48. package/tests/fixtures/tool-tenant/ai/agents/report.agent.ts +12 -0
  49. package/tests/fixtures/tool-tenant/ai/agents/report.tool.ts +12 -0
  50. package/tests/fixtures/vars-tenant/vars/open.var.ts +18 -0
  51. package/tests/hono.test.ts +81 -1
  52. package/tests/jwt-tenant.test.ts +165 -0
  53. package/tests/logger.test.ts +62 -10
  54. package/tests/messages.test.ts +88 -32
  55. package/tests/tool-runtime.test.ts +291 -0
  56. package/tests/tool-types.ts +33 -0
  57. package/tests/upload.test.ts +175 -3
  58. package/tests/vars-api.test.ts +73 -1
  59. package/tests/vars.test.ts +1 -1
  60. package/types/agent.d.ts +386 -4
  61. package/types/config.d.ts +56 -2
  62. package/types/file.d.ts +1 -1
  63. package/types/mcp.d.ts +2 -2
  64. package/utils/func.ts +20 -0
  65. package/utils/logger.ts +125 -33
@@ -87,6 +87,25 @@ export function openaiTool(name: string, args: any, id = "call_1"): Response {
87
87
  });
88
88
  }
89
89
 
90
+ /** Several tool calls in one assistant turn — what a model does in parallel. */
91
+ export function openaiToolCalls(calls: Array<{ name: string; args: any; id?: string }>): Response {
92
+ return Response.json({
93
+ choices: [{
94
+ message: {
95
+ role: "assistant",
96
+ content: null,
97
+ tool_calls: calls.map((call, index) => ({
98
+ id: call.id ?? `call_${index + 1}`,
99
+ type: "function",
100
+ function: { name: call.name, arguments: JSON.stringify(call.args) },
101
+ })),
102
+ },
103
+ finish_reason: "tool_calls",
104
+ }],
105
+ usage: USAGE,
106
+ });
107
+ }
108
+
90
109
  export function openaiStream(pieces: string[]): Response {
91
110
  const events = pieces.map((text) => JSON.stringify({ choices: [{ delta: { content: text } }] }));
92
111
  events.push(JSON.stringify({ choices: [], usage: USAGE }));
@@ -113,6 +132,20 @@ export function anthropicTool(name: string, input: any, id = "toolu_1"): Respons
113
132
  });
114
133
  }
115
134
 
135
+ /** Several `tool_use` blocks in one turn (Anthropic's parallel calls). */
136
+ export function anthropicToolCalls(calls: Array<{ name: string; input: any; id?: string }>): Response {
137
+ return Response.json({
138
+ content: calls.map((call, index) => ({
139
+ type: "tool_use",
140
+ id: call.id ?? `toolu_${index + 1}`,
141
+ name: call.name,
142
+ input: call.input,
143
+ })),
144
+ stop_reason: "tool_use",
145
+ usage: A_USAGE,
146
+ });
147
+ }
148
+
116
149
  export function anthropicStream(pieces: string[]): Response {
117
150
  const events: string[] = [
118
151
  JSON.stringify({ type: "message_start", message: { usage: { input_tokens: 9, output_tokens: 0 } } }),
@@ -0,0 +1,12 @@
1
+ import { define } from "../../../../../index";
2
+
3
+ /**
4
+ * Loader fixture for the description rule — `report.tool.ts` beside it exports one
5
+ * usable tool and one without a description, which the loader must skip.
6
+ */
7
+ export default define.Agent({
8
+ id: "report",
9
+ description: "Reports on charts",
10
+ instructions: "You build charts.",
11
+ provider: { model: "test-model", protocol: "openai", apiKey: "fixture-key" },
12
+ });
@@ -0,0 +1,12 @@
1
+ import { define, v } from "../../../../../index";
2
+
3
+ export default [
4
+ define.Tool({
5
+ id: "chart",
6
+ description: "Builds a chart for a metric",
7
+ inputSchema: v.object({ metric: v.string().required() }),
8
+ execute: async ({ metric }: any) => ({ metric, points: 3 }),
9
+ }),
10
+ // Deliberately not a define.Tool with a description: the loader must skip it.
11
+ { id: "hidden", _isTool_: true, inputSchema: v.object({}), execute: () => null } as any,
12
+ ];
@@ -0,0 +1,18 @@
1
+ // Test fixture — a namespace WITHOUT a declared `scope` (the #34 case), whose
2
+ // `all` rule also proves the access rule receives `{ namespace, body }`.
3
+ export default {
4
+ _isVars_: true,
5
+ namespace: "open",
6
+ vars: { token: "s3cret" },
7
+ api: {
8
+ access: {
9
+ all: (ctx: any) => ctx.namespace === "open" && !!ctx.body,
10
+ set: true,
11
+ get: true,
12
+ entries: true,
13
+ clear: true,
14
+ del: true,
15
+ },
16
+ },
17
+ replication: { enabled: false },
18
+ };
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from "bun:test";
2
- import { createApp } from "../server/hono";
2
+ import { createApp, collectHeaders, redactUri } from "../server/hono";
3
3
  import { cfg } from "../server/config";
4
4
  import { AppError } from "../lib/error";
5
5
  import { HTTPException } from "hono/http-exception";
@@ -13,6 +13,8 @@ function resetServerConfig() {
13
13
  ipRestriction: undefined,
14
14
  trustProxy: undefined,
15
15
  rateLimit: undefined,
16
+ security: undefined,
17
+ files: undefined,
16
18
  };
17
19
  }
18
20
 
@@ -138,6 +140,45 @@ describe("client IP resolution", () => {
138
140
 
139
141
  // ─── Health ──────────────────────────────────────────────────────────────
140
142
 
143
+ describe("Cross-Origin-Resource-Policy", () => {
144
+ it("keeps API responses same-origin but lets served files be embedded", async () => {
145
+ const { url, server } = await bootAppWith((app) => {
146
+ app.get("/api/v1/files/photos/size/pic.png", () => new Response("image"));
147
+ });
148
+
149
+ const api = await fetch(`${url}/health`);
150
+ expect(api.headers.get("Cross-Origin-Resource-Policy")).toBe("same-origin");
151
+
152
+ // Files are meant to be embedded by another origin (an `<img>` in the front).
153
+ const file = await fetch(`${url}/api/v1/files/photos/size/pic.png`);
154
+ expect(file.headers.get("Cross-Origin-Resource-Policy")).toBe("cross-origin");
155
+
156
+ server.stop(true);
157
+ });
158
+
159
+ it("honours server.security.crossOriginResourcePolicy", async () => {
160
+ cfg.server = { ...cfg.server, security: { crossOriginResourcePolicy: "cross-origin" } };
161
+ const { url, server } = await bootAppWith(() => {});
162
+
163
+ const res = await fetch(`${url}/health`);
164
+ expect(res.headers.get("Cross-Origin-Resource-Policy")).toBe("cross-origin");
165
+
166
+ server.stop(true);
167
+ });
168
+
169
+ it("honours server.files.crossOriginResourcePolicy", async () => {
170
+ cfg.server = { ...cfg.server, files: { crossOriginResourcePolicy: "same-origin" } };
171
+ const { url, server } = await bootAppWith((app) => {
172
+ app.get("/api/v1/files/photos/size/pic.png", () => new Response("image"));
173
+ });
174
+
175
+ const res = await fetch(`${url}/api/v1/files/photos/size/pic.png`);
176
+ expect(res.headers.get("Cross-Origin-Resource-Policy")).toBe("same-origin");
177
+
178
+ server.stop(true);
179
+ });
180
+ });
181
+
141
182
  describe("GET /health", () => {
142
183
  it("returns metrics + client ip", async () => {
143
184
  const { url, server } = await bootAppWith(() => {});
@@ -212,3 +253,42 @@ describe("rate limiting (in-memory)", () => {
212
253
  server.stop(true);
213
254
  });
214
255
  });
256
+
257
+ describe("access log sanitisation (#39)", () => {
258
+ it("removes sensitive headers", () => {
259
+ const headers = collectHeaders(new Headers({
260
+ authorization: "Bearer secret",
261
+ cookie: "sid=secret",
262
+ "x-api-key": "key-secret",
263
+ "x-team": "core",
264
+ }));
265
+ expect(headers["authorization"]).toBeUndefined();
266
+ expect(headers["cookie"]).toBeUndefined();
267
+ expect(headers["x-api-key"]).toBeUndefined();
268
+ expect(headers["x-team"]).toEqual(["core"]);
269
+ });
270
+
271
+ it("redacts sensitive query parameters", () => {
272
+ expect(redactUri("/api/v1/collections/users/find?token=secret&page=2"))
273
+ .toBe("/api/v1/collections/users/find?token=%5Bredacted%5D&page=2");
274
+ expect(redactUri("/health")).toBe("/health");
275
+ expect(redactUri("/x?query=hello")).toBe("/x?query=hello");
276
+ });
277
+ });
278
+
279
+ describe("body limit (#51)", () => {
280
+ it("rejects a request body over the 16MB default", async () => {
281
+ const { url, server } = await bootAppWith((app) => {
282
+ app.post("/echo", async (c) => c.json({ ok: true }));
283
+ });
284
+
285
+ const res = await fetch(`${url}/echo`, {
286
+ method: "POST",
287
+ headers: { "Content-Type": "application/json" },
288
+ body: "x".repeat(17 * 1024 * 1024),
289
+ });
290
+ expect(res.status).toBe(413);
291
+
292
+ server.stop(true);
293
+ });
294
+ });
@@ -0,0 +1,165 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import { createApp } from "../server/hono";
3
+ import { formatConfig, cfg } from "../server/config";
4
+ import { syncTenants } from "../database/tenant";
5
+ import { useRest } from "../database/rest";
6
+ import { jwt } from "../utils/func";
7
+
8
+ /**
9
+ * #35 — a JWT is bound to its tenant: the login action signs a `tenant` claim,
10
+ * and a token only opens the routes of that tenant.
11
+ */
12
+
13
+ const TA = "jwt-tenant-a";
14
+ const TB = "jwt-tenant-b";
15
+ const DB_A = "mongodb://localhost:27017/_JWT_TENANT_A";
16
+ const DB_B = "mongodb://localhost:27017/_JWT_TENANT_B";
17
+ const SECRET = "jwt-tenant-test-secret";
18
+
19
+ let server: any;
20
+ let url = "";
21
+ let restA: InstanceType<typeof useRest>;
22
+ let restB: InstanceType<typeof useRest>;
23
+
24
+ function collectionFor(tenant: string) {
25
+ return {
26
+ _tenant_: tenant,
27
+ slug: "users",
28
+ fields: [{ name: "email", type: "string" }],
29
+ api: {
30
+ access: { "*": true },
31
+ auth: {
32
+ enabled: true,
33
+ onLogin: async ({ payload, jwt: signer, cookies }: any) => {
34
+ const token = await signer.sign({ sub: payload.email });
35
+ cookies.set("session", token);
36
+ return { token, data: { email: payload.email } };
37
+ },
38
+ onLogout: async ({ cookies }: any) => {
39
+ cookies.delete("session", { domain: ".example.com" });
40
+ },
41
+ },
42
+ },
43
+ };
44
+ }
45
+
46
+ async function login(tenant: string, email = "u@test"): Promise<{ status: number; token?: string }> {
47
+ const res = await fetch(`${url}/api/${tenant}/collections/users/login`, {
48
+ method: "POST",
49
+ headers: { "Content-Type": "application/json" },
50
+ body: JSON.stringify({ payload: { email } }),
51
+ });
52
+ const body: any = await res.json();
53
+ return { status: res.status, token: body.token };
54
+ }
55
+
56
+ const withToken = (path: string, token?: string, method = "GET") =>
57
+ fetch(`${url}${path}`, { method, headers: token ? { Authorization: `Bearer ${token}` } : {} });
58
+
59
+ beforeAll(async () => {
60
+ formatConfig({
61
+ server: { port: 4000, jwt: { secret: SECRET } },
62
+ tenants: [
63
+ { id: TA, dir: "src", database: { uri: DB_A } },
64
+ { id: TB, dir: "src", database: { uri: DB_B } },
65
+ ],
66
+ });
67
+ await syncTenants();
68
+ (cfg as any).collections = [collectionFor(TA), collectionFor(TB)];
69
+ restA = new useRest({ internal: false, tenant_id: TA });
70
+ restB = new useRest({ internal: false, tenant_id: TB });
71
+ const app = createApp();
72
+ server = Bun.serve({ port: 0, fetch: app.fetch });
73
+ url = server.url.href.replace(/\/$/, "");
74
+ });
75
+
76
+ afterAll(async () => {
77
+ try { await restA.db.dropDatabase(); } catch {}
78
+ try { await restB.db.dropDatabase(); } catch {}
79
+ try { server.stop(true); } catch {}
80
+ delete (cfg.server.jwt as any)?.requireTenantClaim;
81
+ });
82
+
83
+ describe("JWT — tenant claim (#35)", () => {
84
+ it("signs the tenant into the token", async () => {
85
+ const { status, token } = await login(TA);
86
+ expect(status).toBe(200);
87
+ expect(token).toBeString();
88
+
89
+ const verified = await jwt.verify(token!);
90
+ expect(verified.error).toBeNull();
91
+ expect(verified.value?.tenant).toBe(TA);
92
+ });
93
+
94
+ it("refuses a token on another tenant's collection API", async () => {
95
+ const token = (await login(TA)).token!;
96
+ const same = await withToken(`/api/${TA}/collections/users/find`, token, "POST");
97
+ expect(same.status).toBe(200);
98
+
99
+ const other = await withToken(`/api/${TB}/collections/users/find`, token, "POST");
100
+ expect(other.status).toBe(401);
101
+ expect(((await other.json()) as any).code).toBe("TENANT_MISMATCH");
102
+ });
103
+
104
+ it("refuses a token on another tenant's public config", async () => {
105
+ const token = (await login(TA)).token!;
106
+ expect((await withToken(`/_dnax/config/${TA}`, token)).status).toBe(200);
107
+
108
+ const other = await withToken(`/_dnax/config/${TB}`, token);
109
+ expect(other.status).toBe(401);
110
+ expect(((await other.json()) as any).code).toBe("TENANT_MISMATCH");
111
+ });
112
+
113
+ it("can require a tenant claim (legacy tokens rejected)", async () => {
114
+ cfg.server = { ...cfg.server, jwt: { ...cfg.server.jwt, requireTenantClaim: true } };
115
+
116
+ const legacy = await jwt.sign({ sub: "legacy" });
117
+ const rejected = await withToken(`/_dnax/config/${TA}`, legacy);
118
+ expect(rejected.status).toBe(401);
119
+ expect(((await rejected.json()) as any).code).toBe("TENANT_CLAIM_REQUIRED");
120
+
121
+ const withClaim = await jwt.sign({ sub: "new", tenant: TA });
122
+ expect((await withToken(`/_dnax/config/${TA}`, withClaim)).status).toBe(200);
123
+ });
124
+ });
125
+
126
+ describe("cookies — safe defaults and matching deletion (#48)", () => {
127
+ const cookiesOf = (res: Response): string => {
128
+ const list = typeof (res.headers as any).getSetCookie === "function"
129
+ ? (res.headers as any).getSetCookie()
130
+ : [res.headers.get("set-cookie")];
131
+ return (list as (string | null)[]).filter(Boolean).join("; ");
132
+ };
133
+
134
+ it("sets HttpOnly / SameSite=Lax / Path=/ by default", async () => {
135
+ const res = await fetch(`${url}/api/${TA}/collections/users/login`, {
136
+ method: "POST",
137
+ headers: { "Content-Type": "application/json" },
138
+ body: JSON.stringify({ payload: { email: "cookie@test" } }),
139
+ });
140
+ expect(res.status).toBe(200);
141
+ const cookie = cookiesOf(res);
142
+ expect(cookie).toContain("HttpOnly");
143
+ expect(cookie).toContain("SameSite=Lax");
144
+ expect(cookie).toContain("Path=/");
145
+ });
146
+
147
+ it("deletes the cookie with the same domain", async () => {
148
+ const login = await fetch(`${url}/api/${TA}/collections/users/login`, {
149
+ method: "POST",
150
+ headers: { "Content-Type": "application/json" },
151
+ body: JSON.stringify({ payload: { email: "cookie@test" } }),
152
+ });
153
+ const token = ((await login.json()) as any).token;
154
+
155
+ const res = await fetch(`${url}/api/${TA}/collections/users/logout`, {
156
+ method: "POST",
157
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
158
+ body: JSON.stringify({ payload: {} }),
159
+ });
160
+ expect(res.status).toBe(200);
161
+ const cookie = cookiesOf(res);
162
+ expect(cookie).toContain("Domain=.example.com");
163
+ expect(cookie.toLowerCase()).toContain("max-age=0");
164
+ });
165
+ });
@@ -29,7 +29,7 @@ describe("logger", () => {
29
29
  logger.warn("careful", { code: "X" });
30
30
  logger.error("boom");
31
31
  await flush();
32
- const lines = readLogLines("basic/anteros.log");
32
+ const lines = readLogLines("basic/app.log");
33
33
  expect(lines.length).toBe(4);
34
34
  const hello = lines.find((l) => l.msg === "hello")!;
35
35
  expect(hello.level).toBe("info");
@@ -47,7 +47,7 @@ describe("logger", () => {
47
47
  logger.warn("shown-warn");
48
48
  logger.error("shown-error");
49
49
  await flush();
50
- const lines = readLogLines("filter/anteros.log");
50
+ const lines = readLogLines("filter/app.log");
51
51
  expect(lines.some((l) => l.msg === "hidden-info")).toBe(false);
52
52
  expect(lines.some((l) => l.msg === "hidden-debug")).toBe(false);
53
53
  expect(lines.some((l) => l.msg === "shown-warn")).toBe(true);
@@ -69,15 +69,15 @@ describe("logger", () => {
69
69
  logger.configure({ dir, file: true, console: false, maxSize: 200, maxFiles: 3 });
70
70
  for (let i = 0; i < 20; i++) logger.info(`line ${i} with some padding ${'x'.repeat(30)}`);
71
71
  await flush();
72
- const rotated = fs.readdirSync(dir).filter((f) => f !== "anteros.log");
72
+ const rotated = fs.readdirSync(dir).filter((f) => f !== "app.log");
73
73
  // Dated naming: anteros-YYYY-MM-DD-N.log
74
- const dated = /^anteros-\d{4}-\d{2}-\d{2}-\d+\.log$/;
74
+ const dated = /^\d{4}-\d{2}-\d{2}-\d+\.log$/;
75
75
  expect(rotated.length).toBeGreaterThan(0);
76
76
  expect(rotated.every((f) => dated.test(f))).toBe(true);
77
77
  // maxFiles: 3 → never more than 3 rotated files kept
78
78
  expect(rotated.length).toBeLessThanOrEqual(3);
79
79
  // the live file is never removed
80
- expect(fs.existsSync(path.join(dir, "anteros.log"))).toBe(true);
80
+ expect(fs.existsSync(path.join(dir, "app.log"))).toBe(true);
81
81
  });
82
82
 
83
83
  it("handles circular / non-JSON meta safely", async () => {
@@ -87,9 +87,9 @@ describe("logger", () => {
87
87
  logger.info("circular meta", circular);
88
88
  logger.info("bigint meta", { n: 123n });
89
89
  await flush();
90
- const lines = readLogLines("safe/anteros.log");
90
+ const lines = readLogLines("safe/app.log");
91
91
  expect(lines.some((l) => l.msg === "circular meta")).toBe(true);
92
- const raw = fs.readFileSync(path.join(DIR, "safe", "anteros.log"), "utf-8");
92
+ const raw = fs.readFileSync(path.join(DIR, "safe", "app.log"), "utf-8");
93
93
  expect(raw).toContain("[Circular]");
94
94
  expect(lines.find((l) => l.msg === "bigint meta")!.n).toBe("123");
95
95
  });
@@ -98,7 +98,7 @@ describe("logger", () => {
98
98
  logger.configure({ dir: path.join(DIR, "ts"), file: true, console: false });
99
99
  logger.info("custom ts", { ts: 123.5, customField: 1 });
100
100
  await flush();
101
- const lines = readLogLines("ts/anteros.log");
101
+ const lines = readLogLines("ts/app.log");
102
102
  expect(lines[0]!.ts).toBe(123.5);
103
103
  expect(lines[0]!.customField).toBe(1);
104
104
  expect(lines[0]!.msg).toBe("custom ts");
@@ -115,7 +115,7 @@ describe("logger", () => {
115
115
  await flush();
116
116
  console.log = orig;
117
117
 
118
- const lines = readLogLines("fileonly/anteros.log");
118
+ const lines = readLogLines("fileonly/app.log");
119
119
  expect(lines.some((l) => l.msg === "Server started")).toBe(true);
120
120
  expect(lines.some((l) => l.msg === "visible info")).toBe(true);
121
121
  expect(out.some((l) => l.includes("visible info"))).toBe(true);
@@ -134,7 +134,7 @@ describe("logger", () => {
134
134
  await flush();
135
135
  console.log = orig;
136
136
 
137
- const lines = readLogLines("filelevel/anteros.log");
137
+ const lines = readLogLines("filelevel/app.log");
138
138
  expect(lines.find((l) => l.msg === "default level")!.level).toBe("info");
139
139
  expect(lines.find((l) => l.msg === "explicit warn")!.level).toBe("warn");
140
140
  expect(lines.find((l) => l.msg === "explicit warn")!.b).toBe(2);
@@ -145,3 +145,55 @@ describe("logger", () => {
145
145
  expect(out.some((l) => l.includes("explicit error"))).toBe(false);
146
146
  });
147
147
  });
148
+
149
+ describe("calendar rotation", () => {
150
+ it("archives the previous period and keeps only maxFiles archives (monthly)", async () => {
151
+ const dir = path.join(DIR, "monthly");
152
+ fs.mkdirSync(dir, { recursive: true });
153
+ const live = path.join(dir, "app.log");
154
+ fs.writeFileSync(live, '{"msg":"old month"}\n');
155
+ const old = new Date("2026-01-15T12:00:00Z");
156
+ fs.utimesSync(live, old, old);
157
+
158
+ // Older archives that must be pruned
159
+ for (let m = 1; m <= 12; m++) {
160
+ fs.writeFileSync(path.join(dir, `2025-${String(m).padStart(2, "0")}.log`), "x");
161
+ }
162
+
163
+ logger.configure({ dir, file: true, console: false, rotate: "monthly", maxFiles: 10 });
164
+ logger.info("new month");
165
+ await flush();
166
+
167
+ const files = fs.readdirSync(dir);
168
+ expect(files).toContain("app.log");
169
+ expect(files).toContain("2026-01.log"); // archived under the file's own period
170
+ expect(files.some((file) => file.includes("anteros"))).toBe(false);
171
+
172
+ const archives = files.filter((file) => /^\d{4}-\d{2}\.log$/.test(file));
173
+ expect(archives.length).toBeLessThanOrEqual(10);
174
+
175
+ // The live file only contains the new period
176
+ const liveLines = fs.readFileSync(live, "utf-8").trim().split("\n").filter(Boolean);
177
+ expect(liveLines.length).toBe(1);
178
+ expect(JSON.parse(liveLines[0]!).msg).toBe("new month");
179
+ });
180
+
181
+ it("uses the daily period for the archive name", async () => {
182
+ const dir = path.join(DIR, "daily");
183
+ fs.mkdirSync(dir, { recursive: true });
184
+ const live = path.join(dir, "app.log");
185
+ fs.writeFileSync(live, '{"msg":"old day"}\n');
186
+ const yesterday = new Date(Date.now() - 86_400_000);
187
+ fs.utimesSync(live, yesterday, yesterday);
188
+
189
+ logger.configure({ dir, file: true, console: false, rotate: "daily", maxFiles: 3 });
190
+ logger.info("new day");
191
+ await flush();
192
+
193
+ const expected = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, "0")}-${String(yesterday.getDate()).padStart(2, "0")}.log`;
194
+ const files = fs.readdirSync(dir);
195
+ expect(files).toContain(expected);
196
+ expect(files).toContain("app.log");
197
+ expect(files.every((file) => file.endsWith(".log"))).toBe(true);
198
+ });
199
+ });
@@ -1,23 +1,17 @@
1
- import { describe, it, expect, afterAll } from "bun:test";
1
+ import { describe, it, expect } from "bun:test";
2
2
  import { normalizeMessages } from "../lib/messages";
3
3
  import { MAX_FILE_BYTES } from "../lib/attachment";
4
4
 
5
- /** A tiny local file server — the `file: { url }` path downloads through `fetch`. */
6
- const servers: Array<ReturnType<typeof Bun.serve>> = [];
7
- function serve(body: Uint8Array | string, contentType = "application/pdf") {
8
- const server = Bun.serve({
9
- port: 0,
10
- hostname: "127.0.0.1",
11
- fetch: () => new Response(body as any, { headers: { "Content-Type": contentType } }),
12
- });
13
- servers.push(server);
14
- return `http://127.0.0.1:${server.port}/report.pdf`;
5
+ /**
6
+ * Remote files (`file: { url }`) are fetched server-side: only **public HTTPS**
7
+ * targets are accepted (issue #30). `fetch` is stubbed — no network in the suite.
8
+ */
9
+ function stubFetch(handler: (url: string) => Response | Promise<Response>): () => void {
10
+ const original = globalThis.fetch;
11
+ globalThis.fetch = (async (input: any) => handler(String(input))) as unknown as typeof fetch;
12
+ return () => { globalThis.fetch = original; };
15
13
  }
16
14
 
17
- afterAll(() => {
18
- for (const server of servers) server.stop(true);
19
- });
20
-
21
15
  const first = async (input: any) => (await normalizeMessages(input))[0] as any;
22
16
 
23
17
  describe("normalizeMessages — the shapes it accepts", () => {
@@ -74,18 +68,73 @@ describe("normalizeMessages — content parts", () => {
74
68
  expect(message).toEqual({ role: "system", content: "a\nb" });
75
69
  });
76
70
 
77
- it("downloads a remote file and inlines it as base64", async () => {
78
- const url = serve("%PDF-1.7 payload");
79
- const [message] = await normalizeMessages([
80
- { role: "user", content: [{ type: "file", url, name: "invoice.pdf" }] },
81
- ] as any);
71
+ it("downloads a public HTTPS file and inlines it as base64", async () => {
72
+ const restore = stubFetch(() => new Response("%PDF-1.7 payload", {
73
+ headers: { "Content-Type": "application/pdf" },
74
+ }));
75
+ try {
76
+ const [message] = await normalizeMessages([
77
+ { role: "user", content: [{ type: "file", url: "https://8.8.8.8/report.pdf", name: "invoice.pdf" }] },
78
+ ] as any);
79
+
80
+ const part: any = (message as any).content[0];
81
+ expect(part.type).toBe("file");
82
+ expect(part.name).toBe("invoice.pdf");
83
+ // The URL is gone — the payload travels as base64, which every provider takes
84
+ expect(part.url).toBeUndefined();
85
+ expect(Buffer.from(part.data, "base64").toString()).toBe("%PDF-1.7 payload");
86
+ } finally {
87
+ restore();
88
+ }
89
+ });
82
90
 
83
- const part: any = (message as any).content[0];
84
- expect(part.type).toBe("file");
85
- expect(part.name).toBe("invoice.pdf");
86
- // The URL is gone — the payload travels as base64, which every provider takes
87
- expect(part.url).toBeUndefined();
88
- expect(Buffer.from(part.data, "base64").toString()).toBe("%PDF-1.7 payload");
91
+ it("refuses remote files that are not public HTTPS (SSRF)", async () => {
92
+ const cases = [
93
+ "http://8.8.8.8/report.pdf", // plain HTTP
94
+ "https://127.0.0.1/report.pdf", // loopback
95
+ "https://10.0.0.1/report.pdf", // RFC1918
96
+ "https://172.16.0.1/report.pdf", // RFC1918
97
+ "https://192.168.1.1/report.pdf", // RFC1918
98
+ "https://169.254.169.254/latest", // cloud metadata
99
+ "https://[::1]/report.pdf", // IPv6 loopback
100
+ "https://[fe80::1]/report.pdf", // IPv6 link-local
101
+ "https://localhost/report.pdf", // resolves to loopback
102
+ "https://user:pass@8.8.8.8/report.pdf", // credentials in the URL
103
+ ];
104
+ for (const url of cases) {
105
+ const error: any = await normalizeMessages([
106
+ { role: "user", content: [{ type: "file", url, name: "x.pdf" }] },
107
+ ] as any).catch((err) => err);
108
+ expect({ url, code: error.code }).toEqual({ url, code: "AGENT_FILE_INVALID" });
109
+ }
110
+ });
111
+
112
+ it("refuses a redirect that lands on a private address", async () => {
113
+ const restore = stubFetch(() => new Response(null, {
114
+ status: 302,
115
+ headers: { location: "https://127.0.0.1/secret.pdf" },
116
+ }));
117
+ try {
118
+ const error: any = await normalizeMessages([
119
+ { role: "user", content: [{ type: "file", url: "https://8.8.8.8/report.pdf", name: "x.pdf" }] },
120
+ ] as any).catch((err) => err);
121
+ expect(error.code).toBe("AGENT_FILE_INVALID");
122
+ } finally {
123
+ restore();
124
+ }
125
+ });
126
+
127
+ it("reports an unreachable remote file without echoing the network error", async () => {
128
+ const restore = stubFetch(() => new Response("nope", { status: 404 }));
129
+ try {
130
+ const error: any = await normalizeMessages([
131
+ { role: "user", content: [{ type: "file", url: "https://8.8.8.8/missing.pdf", name: "x.pdf" }] },
132
+ ] as any).catch((err) => err);
133
+ expect(error.code).toBe("AGENT_FILE_NOT_FOUND");
134
+ expect(error.message).not.toContain("404");
135
+ } finally {
136
+ restore();
137
+ }
89
138
  });
90
139
 
91
140
  it("leaves a remote image as a URL — the provider fetches it", async () => {
@@ -107,12 +156,19 @@ describe("normalizeMessages — content parts", () => {
107
156
  }
108
157
  });
109
158
 
110
- it("refuses a file that is too large", async () => {
111
- const url = serve(new Uint8Array(MAX_FILE_BYTES + 1));
112
- const error: any = await normalizeMessages([
113
- { role: "user", content: [{ type: "file", url, name: "huge.pdf" }] },
114
- ] as any).catch((err) => err);
115
- expect(error.code).toBe("AGENT_FILE_TOO_LARGE");
159
+ it("refuses a streamed remote file that is too large", async () => {
160
+ const body = new Uint8Array(MAX_FILE_BYTES + 1);
161
+ const restore = stubFetch(() => new Response(new ReadableStream({
162
+ start(controller) { controller.enqueue(body); controller.close(); },
163
+ })));
164
+ try {
165
+ const error: any = await normalizeMessages([
166
+ { role: "user", content: [{ type: "file", url: "https://8.8.8.8/huge.pdf", name: "huge.pdf" }] },
167
+ ] as any).catch((err) => err);
168
+ expect(error.code).toBe("AGENT_FILE_TOO_LARGE");
169
+ } finally {
170
+ restore();
171
+ }
116
172
  });
117
173
  });
118
174