@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.
- package/LICENSE +21 -0
- package/README.md +1 -1
- package/database/field.crypto.ts +38 -13
- package/database/mongodbadapter.ts +85 -25
- package/database/vars.ts +22 -2
- package/index.ts +22 -0
- package/lib/agent.ts +707 -79
- package/lib/agentMemory.ts +199 -0
- package/lib/agents.ts +133 -52
- package/lib/attachment.ts +24 -3
- package/lib/define.ts +5 -1
- package/lib/eval.ts +123 -0
- package/lib/files.ts +21 -15
- package/lib/jsonSchema.ts +8 -0
- package/lib/mcp.ts +52 -30
- package/lib/messages.ts +202 -19
- package/lib/partialJson.ts +79 -0
- package/lib/providers.ts +26 -2
- package/lib/redact.ts +11 -0
- package/lib/tracing.ts +20 -0
- package/lib/zip.ts +23 -3
- package/package.json +4 -5
- package/server/agents.ts +66 -1
- package/server/api.ts +302 -20
- package/server/boot.ts +13 -4
- package/server/hono.ts +146 -6
- package/tests/access-log-body.test.ts +253 -0
- package/tests/agent-checkpoint.test.ts +195 -0
- package/tests/agent-hardening.test.ts +238 -0
- package/tests/agent-memory.test.ts +175 -1
- package/tests/agent-stream-advanced.test.ts +246 -0
- package/tests/agent-tracing-eval.test.ts +219 -0
- package/tests/agent-working-memory.test.ts +7 -4
- package/tests/agent.test.ts +54 -3
- package/tests/agents-api.test.ts +21 -0
- package/tests/api-external-guards.test.ts +304 -0
- package/tests/attachment.test.ts +63 -0
- package/tests/audit-redact.test.ts +26 -1
- package/tests/field-encryption.test.ts +47 -3
- package/tests/filters-hardening.test.ts +81 -0
- package/tests/fixtures/ai-tenant/ai/agents/support/billing.agent.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/agents/support/billing.tool.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/agents/support.agent.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/agents/support.tool.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/mcp/resources/readme.resource.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/mcp/tools/echo.tool.ts +9 -0
- package/tests/fixtures/fake-provider.ts +33 -0
- package/tests/fixtures/tool-tenant/ai/agents/report.agent.ts +12 -0
- package/tests/fixtures/tool-tenant/ai/agents/report.tool.ts +12 -0
- package/tests/fixtures/vars-tenant/vars/open.var.ts +18 -0
- package/tests/hono.test.ts +81 -1
- package/tests/jwt-tenant.test.ts +165 -0
- package/tests/logger.test.ts +62 -10
- package/tests/messages.test.ts +88 -32
- package/tests/tool-runtime.test.ts +291 -0
- package/tests/tool-types.ts +33 -0
- package/tests/upload.test.ts +175 -3
- package/tests/vars-api.test.ts +73 -1
- package/tests/vars.test.ts +1 -1
- package/types/agent.d.ts +386 -4
- package/types/config.d.ts +56 -2
- package/types/file.d.ts +1 -1
- package/types/mcp.d.ts +2 -2
- package/utils/func.ts +20 -0
- 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
|
+
};
|
package/tests/hono.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/tests/logger.test.ts
CHANGED
|
@@ -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/
|
|
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/
|
|
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 !== "
|
|
72
|
+
const rotated = fs.readdirSync(dir).filter((f) => f !== "app.log");
|
|
73
73
|
// Dated naming: anteros-YYYY-MM-DD-N.log
|
|
74
|
-
const dated =
|
|
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, "
|
|
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/
|
|
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", "
|
|
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/
|
|
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/
|
|
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/
|
|
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
|
+
});
|
package/tests/messages.test.ts
CHANGED
|
@@ -1,23 +1,17 @@
|
|
|
1
|
-
import { describe, it, expect
|
|
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
|
-
/**
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|