@chloejs/core 0.2.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 (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +221 -0
  3. package/channels/api.ts +41 -0
  4. package/channels/shared.ts +250 -0
  5. package/channels/slack.ts +390 -0
  6. package/channels/telegram.ts +396 -0
  7. package/core/clock.ts +126 -0
  8. package/core/confine.ts +45 -0
  9. package/core/db.ts +117 -0
  10. package/core/markdown.ts +95 -0
  11. package/core/notes.ts +44 -0
  12. package/core/paths.ts +29 -0
  13. package/core/root.ts +26 -0
  14. package/core/settings.ts +124 -0
  15. package/core/steps.ts +896 -0
  16. package/core/turn.ts +314 -0
  17. package/do/email.ts +45 -0
  18. package/do/files.ts +96 -0
  19. package/do/mail.ts +155 -0
  20. package/do/run.ts +56 -0
  21. package/do/scripts.ts +49 -0
  22. package/do/web.ts +192 -0
  23. package/index.ts +52 -0
  24. package/load/job.ts +84 -0
  25. package/load/load.ts +478 -0
  26. package/model/ask.ts +84 -0
  27. package/model/claude.ts +261 -0
  28. package/model/memory.ts +68 -0
  29. package/model/model.ts +185 -0
  30. package/model/tool.ts +53 -0
  31. package/model/tools/files.ts +71 -0
  32. package/model/tools/gmail.ts +43 -0
  33. package/model/tools/index.ts +28 -0
  34. package/model/tools/memory.ts +23 -0
  35. package/model/tools/run_script.ts +44 -0
  36. package/model/tools/send_email.ts +29 -0
  37. package/model/tools/web.ts +23 -0
  38. package/model/tools/write_skill.ts +31 -0
  39. package/ops/account.ts +109 -0
  40. package/ops/agent.ts +290 -0
  41. package/ops/check.ts +37 -0
  42. package/ops/evals.ts +206 -0
  43. package/ops/install.sh +101 -0
  44. package/ops/test.ts +1976 -0
  45. package/package.json +65 -0
  46. package/scorers/calls.ts +50 -0
  47. package/scorers/expectations.ts +118 -0
  48. package/scorers/index.ts +5 -0
  49. package/serve/alerts.ts +79 -0
  50. package/serve/errors.ts +10 -0
  51. package/serve/files.ts +70 -0
  52. package/serve/http.ts +767 -0
  53. package/serve/login.ts +299 -0
  54. package/serve/memory.ts +372 -0
  55. package/serve/page.ts +142 -0
  56. package/serve/pass.ts +45 -0
  57. package/serve/recentWork.ts +69 -0
  58. package/serve/site.ts +409 -0
  59. package/serve/tokens.ts +132 -0
  60. package/server.ts +170 -0
  61. package/timer/cron.ts +92 -0
  62. package/timer/every.ts +153 -0
  63. package/timer/index.ts +4 -0
package/ops/test.ts ADDED
@@ -0,0 +1,1976 @@
1
+ // What a job does, checked by running it.
2
+ //
3
+ // A job is code, so it is tested rather than scored: `npm run evals` is for
4
+ // the prompts, and this is for the machinery underneath them. Nothing here
5
+ // touches the real database or the real gateway. The database is in memory and
6
+ // the gateway is a server on a loopback port that answers whatever the case
7
+ // says, so a model step is exercised without spending anything.
8
+ //
9
+ // These are set rather than left to settings.json, because a setting in a file
10
+ // applies here too: a box with model.via "claude" would otherwise run every
11
+ // case against a real subscription, slowly, and score differently from the
12
+ // next box.
13
+ process.env.AGENTS_DB = ":memory:";
14
+ // A folder of its own, so a case that writes state (an account, a note) cannot
15
+ // land in the real one. Set before any import, like the database above.
16
+ process.env.AGENTS_STATE = (await import("node:fs")).mkdtempSync(`${(await import("node:os")).tmpdir()}/chloe-test-`);
17
+ process.env.OWNER = "test:somebody";
18
+ process.env.AI_GATEWAY_API_KEY = "test";
19
+ process.env.MODEL_VIA = "gateway";
20
+
21
+ import { existsSync } from "node:fs";
22
+ import { createServer } from "node:http";
23
+ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
24
+ import { tmpdir } from "node:os";
25
+ import { pathToFileURL } from "node:url";
26
+ import { join } from "node:path";
27
+
28
+ import { z } from "zod";
29
+
30
+ import { about, failed, is } from "#chloe/ops/check.ts";
31
+
32
+ // A stand-in gateway, up before anything reads AI_GATEWAY_URL. An answer is
33
+ // either what the model said, or a whole message when a case needs it to ask
34
+ // for a tool.
35
+ type Said = string | { content?: string; tool_calls?: unknown[] };
36
+ const answers: Said[] = [];
37
+ let asked = 0;
38
+ /** The messages the last call was sent, for a case that checks what a model was shown. */
39
+ let lastAsked: { role: string; content: string }[] = [];
40
+ const gateway = createServer((request, response) => {
41
+ let raw = "";
42
+ request.on("data", (chunk) => (raw += chunk));
43
+ request.on("end", () => {
44
+ asked++;
45
+ lastAsked = (JSON.parse(raw || "{}") as { messages?: typeof lastAsked }).messages ?? [];
46
+ const next = answers.shift() ?? "{}";
47
+ response.writeHead(200, { "content-type": "application/json" });
48
+ response.end(
49
+ JSON.stringify({
50
+ choices: [{ message: typeof next === "string" ? { content: next } : next }],
51
+ usage: { cost: 0.0002, prompt_tokens: 10, completion_tokens: 10 },
52
+ }),
53
+ );
54
+ });
55
+ });
56
+ await new Promise<void>((done) => gateway.listen(0, "127.0.0.1", done));
57
+ process.env.AI_GATEWAY_URL = `http://127.0.0.1:${(gateway.address() as { port: number }).port}/v1/chat/completions`;
58
+
59
+ // Imported after the environment is set, and by hand rather than with a plain
60
+ // import, because those are hoisted above the lines above: core/db.ts would
61
+ // read AGENTS_DB before it was set and every case would write into the real
62
+ // run history. That is not hypothetical, it happened while this was written.
63
+ const { reachBy } = await import("@chloejs/core");
64
+ const { answer, db, sweep, waitingFor, waitingOn, work } = await import("@chloejs/core");
65
+ type Agent = import("@chloejs/core").Agent;
66
+ type Job = import("@chloejs/core").Job;
67
+
68
+ const sent: string[] = [];
69
+ reachBy("test", async (to, text) => void sent.push(`${to}: ${text}`));
70
+
71
+ function codeJob(id: string, run: Job["run"], state?: z.ZodType, summary?: Job["summary"]): Job {
72
+ return { agent: "test", id, cron: "* * * * *", timezone: "UTC", prompt: "", run, state, summary, files: [] };
73
+ }
74
+
75
+ function agentFor(job: Job): Agent {
76
+ return {
77
+ name: "test",
78
+ folder: tmpdir(),
79
+ memory: { folder: `${tmpdir()}/memory-of-test` },
80
+ description: "",
81
+ model: "anthropic/claude-haiku-4.5",
82
+ instructions: "",
83
+ skills: [],
84
+ jobs: [job],
85
+ channels: [],
86
+ };
87
+ }
88
+
89
+ const row = (id: string): any => db.prepare("select * from runs where id = ?").get(id);
90
+
91
+ /** Push the deadline into the past, which is what waiting does. */
92
+ function timePasses(runId: string): void {
93
+ const parked = { ...JSON.parse(row(runId).parked), expires: new Date(Date.now() - 1000).toISOString() };
94
+ db.prepare("update runs set parked = ? where id = ?").run(JSON.stringify(parked), runId);
95
+ }
96
+
97
+ about("a job with no model in it");
98
+ {
99
+ let checks = 0;
100
+ const job = codeJob("plain", async ({ step }) => {
101
+ const sites = await step("list", () => ["one", "two"]);
102
+ const down: string[] = [];
103
+ for (const site of sites) {
104
+ const ok = await step(`check ${site}`, () => {
105
+ checks++;
106
+ return site !== "two";
107
+ });
108
+ if (!ok) down.push(site);
109
+ }
110
+ return { down };
111
+ });
112
+ const result = await work({ agent: agentFor(job), job });
113
+ is("it finished", result.parked, false);
114
+ is("it spent nothing", result.cost, 0);
115
+ is("every step is a line", result.steps, 3);
116
+ is("the record says code rather than a model", row(result.runId).model, "code");
117
+ is("it did the work", JSON.parse(result.text), { down: ["two"] });
118
+ is("each check ran once", checks, 2);
119
+ is("the run says who it was for", row(result.runId).owner, "test:somebody");
120
+ }
121
+
122
+ about("what a run did, in one line");
123
+ {
124
+ const counted = codeJob("counted", async () => ({ checked: 15, down: [] }), undefined, (r) => `${(r as { checked: number }).checked} sites, all up`);
125
+ const said = await work({ agent: agentFor(counted), job: counted });
126
+ is("a job says it in its own words", row(said.runId).summary, "15 sites, all up");
127
+
128
+ const quiet = codeJob("quiet", async () => ({ checked: 15 }));
129
+ const unsaid = await work({ agent: agentFor(quiet), job: quiet });
130
+ is("a job with no summary says nothing, rather than a guess", row(unsaid.runId).summary, null);
131
+
132
+ const words = codeJob("words", async () => "**Done.**\n\n- three things\n- all fine");
133
+ const worded = await work({ agent: agentFor(words), job: words });
134
+ is("a string is read as one plain line", row(worded.runId).summary, "Done. three things all fine");
135
+
136
+ const broken = codeJob("broken", async () => ({}), undefined, () => {
137
+ throw new Error("no such field");
138
+ });
139
+ const done = await work({ agent: agentFor(broken), job: broken });
140
+ is("a summary that throws does not fail the run", row(done.runId).error, null);
141
+ is("and it says so", row(done.runId).summary, "(its summary failed: no such field)");
142
+
143
+ const { recentWork } = await import("#chloe/serve/recentWork.ts");
144
+ const agent = { ...agentFor(counted), name: "recent" };
145
+ const at = (minutes: number) => new Date(Date.UTC(2026, 0, 1, 0, minutes)).toISOString();
146
+ const insert = db.prepare(
147
+ "insert into runs (id, agent, started, finished, source, job, model, prompt, summary, error, cost) values (?, 'recent', ?, ?, ?, ?, 'code', '', ?, ?, ?)",
148
+ );
149
+ insert.run("l1", at(0), at(0), "schedule", "backup", "copied", null, 0);
150
+ insert.run("l2", at(1), at(1), "schedule", "counted", "15 sites", null, 0);
151
+ insert.run("l3", at(2), at(2), "schedule", "counted", null, "no answer", 0.5);
152
+ insert.run("l4", at(3), at(3), "schedule", "counted", "15 sites, all up", null, 0.25);
153
+ insert.run("l5", at(4), at(4), "telegram", null, "Hello.", null, 0.1);
154
+ const recent = recentWork(agent);
155
+ is("the newest first, a job in a row folded into one line", recent.map((one) => [one.id, one.times]), [
156
+ ["l5", 1],
157
+ ["l4", 3],
158
+ ["l1", 1],
159
+ ]);
160
+ is("a line says the channel and the job", recent.map((one) => [one.source, one.job]), [
161
+ ["telegram", null],
162
+ ["schedule", "counted"],
163
+ ["schedule", "backup"],
164
+ ]);
165
+ is("a folded line keeps count of what failed and what it cost", [recent[1].failed, recent[1].cost], [1, 0.75]);
166
+ is("and stops at the count it is given", recentWork(agent, 2).length, 2);
167
+ }
168
+
169
+ about("an agent step: the goal is yours, the order is the model's");
170
+ {
171
+ asked = 0;
172
+ answers.length = 0;
173
+ const { tool } = await import("@chloejs/core");
174
+ const looked: string[] = [];
175
+ const look = tool({
176
+ id: "look",
177
+ description: "Look in one place.",
178
+ inputSchema: z.object({ where: z.string() }),
179
+ execute: ({ where }) => {
180
+ looked.push(where);
181
+ return where === "logs" ? "the deploy failed at 03:00" : "nothing here";
182
+ },
183
+ });
184
+
185
+ // Two turns of the loop: one that asks for a tool, one that answers.
186
+ answers.push(
187
+ { content: "", tool_calls: [{ id: "1", type: "function", function: { name: "look", arguments: '{"where":"logs"}' } }] },
188
+ '{"why":"the deploy failed at 03:00"}',
189
+ );
190
+ const job = codeJob("investigate", async (work) =>
191
+ work.agent("work out what happened", {
192
+ goal: "Say why the site went down.",
193
+ tools: [look],
194
+ output: z.object({ why: z.string() }),
195
+ maxSteps: 4,
196
+ }),
197
+ );
198
+ const result = await work({ agent: agentFor(job), job });
199
+ is("it ran the tool it was given", looked, ["logs"]);
200
+ is("and answered in the shape", JSON.parse(result.text), { why: "the deploy failed at 03:00" });
201
+ const line = (JSON.parse(row(result.runId).trace) as { kind: string; cost: number; calls?: { tool: string }[] }[])[0];
202
+ is("the run calls it an agent step", line.kind, "agent");
203
+ is("what it ran is written down", line.calls?.map((one) => one.tool), ["look"]);
204
+ is("and both turns are priced", line.cost, 0.0004);
205
+ }
206
+
207
+ about("an agent step that runs out of steps, and one with nothing to call");
208
+ {
209
+ asked = 0;
210
+ answers.length = 0;
211
+ const { tool } = await import("@chloejs/core");
212
+ const wander = tool({
213
+ id: "wander",
214
+ description: "Go round again.",
215
+ inputSchema: z.object({}),
216
+ execute: () => "still nothing",
217
+ });
218
+ const asking = { id: "1", type: "function", function: { name: "wander", arguments: "{}" } };
219
+ answers.push({ content: "", tool_calls: [asking] }, { content: "", tool_calls: [asking] }, { content: "", tool_calls: [asking] });
220
+
221
+ const capped = codeJob("capped", async (work) =>
222
+ work.agent("go round", { goal: "Find something that is not there.", tools: [wander], maxSteps: 2 }),
223
+ );
224
+ const out = await work({ agent: agentFor(capped), job: capped }).then(() => "finished", (error: Error) => error.message);
225
+ is("it stops and says so rather than looping forever", out.includes("ran out of steps after 2"), true);
226
+
227
+ const empty = codeJob("empty", async (work) =>
228
+ work.agent("with nothing", { goal: "Do something.", tools: [] }),
229
+ );
230
+ const refused = await work({ agent: agentFor(empty), job: empty }).then(() => "", (error: Error) => error.message);
231
+ is("an agent step with no tools is a model step, and says so", refused.includes("use model(...)"), true);
232
+ // A capped run leaves whatever it did not use behind it.
233
+ answers.length = 0;
234
+ }
235
+
236
+ about("an agent step kept inside its budget");
237
+ {
238
+ asked = 0;
239
+ answers.length = 0;
240
+ const { tool } = await import("@chloejs/core");
241
+ const wander = tool({
242
+ id: "wander",
243
+ description: "Go round again.",
244
+ inputSchema: z.object({}),
245
+ execute: () => "still nothing",
246
+ });
247
+ const asking = { id: "1", type: "function", function: { name: "wander", arguments: "{}" } };
248
+ answers.push({ content: "", tool_calls: [asking] }, { content: "", tool_calls: [asking] });
249
+
250
+ // Two turns at $0.0002 each, against a budget that only covers one.
251
+ const job = codeJob("dear", async (work) =>
252
+ work.agent("go round", { goal: "Find something expensive.", tools: [wander], budget: 0.0003, maxSteps: 9 }),
253
+ );
254
+ const result = await work({ agent: agentFor(job), job }).then(() => "finished", (error: Error) => error.message);
255
+ is("it stops on the money, not only on the steps", result.includes("spent $0.0004 of its $0.0003 budget"), true);
256
+ const dear = db.prepare("select cost, trace from runs where job = 'dear'").get() as { cost: number; trace: string };
257
+ is("and the run is charged for what it did spend", dear.cost, 0.0004);
258
+ // A step that failed is still a step that happened, or a budget blowout
259
+ // would say what it cost and not what it spent the money on.
260
+ const line = (JSON.parse(dear.trace) as { kind: string; cost: number; failed?: string; calls?: { tool: string }[] }[])[0];
261
+ is("the step it failed on is still a line", [line.kind, line.cost], ["agent", 0.0004]);
262
+ is("with the calls that spent the money", line.calls?.map((one) => one.tool), ["wander"]);
263
+ is("and why it stopped", line.failed?.includes("budget"), true);
264
+
265
+ // The second go at the shape is another turn, so it is the budget's business
266
+ // too: a step with nothing left does not get one.
267
+ answers.length = 0;
268
+ answers.push("that is not the shape");
269
+ const once = codeJob("once", async (work) =>
270
+ work.agent("answer properly", {
271
+ goal: "Say how many.",
272
+ tools: [wander],
273
+ output: z.object({ n: z.number() }),
274
+ budget: 0.0002,
275
+ }),
276
+ );
277
+ const noRetry = await work({ agent: agentFor(once), job: once }).then(() => "", (error: Error) => error.message);
278
+ is("with nothing left, it does not pay for another go at the shape", noRetry.includes("spent $0.0002 of its $0.0002 budget"), true);
279
+ is("and it stopped after the one turn it could afford", asked, 3);
280
+ answers.length = 0;
281
+ }
282
+
283
+ about("an agent step whose calls the job has to allow");
284
+ {
285
+ asked = 0;
286
+ answers.length = 0;
287
+ const { tool } = await import("@chloejs/core");
288
+ const looked: string[] = [];
289
+ const look = tool({
290
+ id: "look",
291
+ description: "Look in one place.",
292
+ inputSchema: z.object({ where: z.string() }),
293
+ execute: ({ where }) => {
294
+ looked.push(where);
295
+ return "the deploy failed at 03:00";
296
+ },
297
+ });
298
+ const wanting = (where: string) => ({
299
+ id: "1",
300
+ type: "function",
301
+ function: { name: "look", arguments: JSON.stringify({ where }) },
302
+ });
303
+ answers.push(
304
+ { content: "", tool_calls: [wanting("the password file")] },
305
+ { content: "", tool_calls: [wanting("logs")] },
306
+ '{"why":"the deploy failed at 03:00"}',
307
+ );
308
+
309
+ const job = codeJob("allowed", async (work) =>
310
+ work.agent("work out what happened", {
311
+ goal: "Say why the site went down.",
312
+ tools: [look],
313
+ output: z.object({ why: z.string() }),
314
+ // The tool says it may look. This says where.
315
+ approve: ({ args }) => (args as { where: string }).where === "logs" || "only the logs are yours to read",
316
+ maxSteps: 4,
317
+ }),
318
+ );
319
+ const result = await work({ agent: agentFor(job), job });
320
+ is("the call it was not allowed never ran", looked, ["logs"]);
321
+ const line = (JSON.parse(row(result.runId).trace) as { calls?: { tool: string; result: unknown; refused?: boolean }[] }[])[0];
322
+ is("the refusal is written down beside the call", line.calls?.map((one) => one.refused === true), [true, false]);
323
+ is("and the model was told why", String(line.calls?.[0].result).includes("only the logs are yours to read"), true);
324
+ is("so it tried another way and finished", JSON.parse(result.text), { why: "the deploy failed at 03:00" });
325
+ answers.length = 0;
326
+ }
327
+
328
+ about("an approve that cannot answer, and a question from inside a step");
329
+ {
330
+ asked = 0;
331
+ answers.length = 0;
332
+ const { tool } = await import("@chloejs/core");
333
+ const look = tool({
334
+ id: "look",
335
+ description: "Look in one place.",
336
+ inputSchema: z.object({ where: z.string() }),
337
+ execute: () => "nothing here",
338
+ });
339
+ answers.push({
340
+ content: "",
341
+ tool_calls: [{ id: "1", type: "function", function: { name: "look", arguments: '{"where":"logs"}' } }],
342
+ });
343
+
344
+ // A gate that cannot answer is not a refusal: the step stops rather than
345
+ // guessing which way the job meant it.
346
+ const gate = codeJob("gate", async (work) =>
347
+ work.agent("work out what happened", {
348
+ goal: "Say why the site went down.",
349
+ tools: [look],
350
+ approve: () => {
351
+ throw new Error("the rule itself is broken");
352
+ },
353
+ }),
354
+ );
355
+ const why = await work({ agent: agentFor(gate), job: gate }).then(() => "", (error: Error) => error.message);
356
+ is("a broken gate stops the step and names the call", why, "Deciding whether look could run failed: the rule itself is broken");
357
+ is("and the turn it had already paid for is on the run", db.prepare("select cost from runs where job = 'gate'").get(), { cost: 0.0002 });
358
+
359
+ // Refusing without a reason still stops the call, and the model is told
360
+ // something it can act on rather than nothing.
361
+ answers.length = 0;
362
+ answers.push(
363
+ { content: "", tool_calls: [{ id: "1", type: "function", function: { name: "look", arguments: '{"where":"logs"}' } }] },
364
+ "nothing to report",
365
+ );
366
+ const flat = codeJob("flat", async (work) =>
367
+ work.agent("work out what happened", { goal: "Say why the site went down.", tools: [look], approve: () => false }),
368
+ );
369
+ const said = await work({ agent: agentFor(flat), job: flat });
370
+ const told = (JSON.parse(row(said.runId).trace) as { calls?: { result: unknown; refused?: boolean }[] }[])[0];
371
+ is("a refusal with no reason given still stops the call", told.calls?.[0].refused, true);
372
+ is("and says so in words the model can use", String(told.calls?.[0].result).includes("the job did not allow it"), true);
373
+
374
+ const priced = codeJob("priced", async (work) =>
375
+ work.agent("go round", { goal: "Spend nothing.", tools: [look], budget: 0 }),
376
+ );
377
+ const notANumber = await work({ agent: agentFor(priced), job: priced }).then(() => "", (error: Error) => error.message);
378
+ is("a budget that is not an amount is refused before anything runs", notANumber.includes("dollars above zero"), true);
379
+
380
+ const nested = codeJob("nested", async (work) =>
381
+ work.step("ask while working", () => work.ask("now?", { question: "Now?", answer: z.boolean() })),
382
+ );
383
+ const refused = await work({ agent: agentFor(nested), job: nested }).then(() => "", (error: Error) => error.message);
384
+ is("a job pauses between steps, not inside one", refused.includes('was called inside the step "ask while working"'), true);
385
+ answers.length = 0;
386
+ }
387
+
388
+ about("a job that waits for a person");
389
+ {
390
+ let gathered = 0;
391
+ let restarted = 0;
392
+ const job = codeJob(
393
+ "asking",
394
+ async ({ step, ask, setState, state }) => {
395
+ const down = await step("gather", () => {
396
+ gathered++;
397
+ return ["site"];
398
+ });
399
+ await setState({ down });
400
+ const go = await ask("restart?", { question: `Restart ${down.join(", ")}?`, answer: z.boolean() });
401
+ if (!go) return { skipped: true };
402
+ await step("restart", () => {
403
+ restarted++;
404
+ return "done";
405
+ });
406
+ return { restarted: down, state: state.down };
407
+ },
408
+ z.object({ down: z.array(z.string()).default([]) }),
409
+ );
410
+ const agent = agentFor(job);
411
+ const agents = new Map([[agent.name, agent]]);
412
+
413
+ const first = await work({ agent, job });
414
+ is("it parked", first.parked, true);
415
+ is("the question went to the owner, with what fits", sent[0], "somebody: Restart site?\n(yes or no)");
416
+ is("the job is held while it waits", waitingFor("test", "asking"), true);
417
+ is("the person has a question outstanding", waitingOn("test:somebody")?.id, first.runId);
418
+ is("state survived the pause", row(first.runId).state, JSON.stringify({ down: ["site"] }));
419
+
420
+ const confused = await answer(first.runId, "maybe later", agents);
421
+ is("an answer that does not fit parks again", confused.parked, true);
422
+ is("and it says so rather than guessing", sent[1].startsWith("somebody: I did not understand that."), true);
423
+
424
+ const done = await answer(first.runId, "yes", agents);
425
+ is("it carried on", JSON.parse(done.text).restarted, ["site"]);
426
+ is("the step before the question did not run twice", gathered, 1);
427
+ is("the step after it ran once", restarted, 1);
428
+ is("nothing is waiting now", waitingFor("test", "asking"), false);
429
+ is("the ask is a line in the record", JSON.parse(row(done.runId).trace)[1].kind, "ask");
430
+ }
431
+
432
+ about("a step that failed, on a run that carried on past it");
433
+ {
434
+ let tried = 0;
435
+ const job = codeJob("stumble", async ({ step, ask }) => {
436
+ let why = "";
437
+ try {
438
+ await step("the thing that fails", () => {
439
+ tried++;
440
+ throw new Error("it did not work");
441
+ });
442
+ } catch (error) {
443
+ why = (error as Error).message;
444
+ }
445
+ return { why, go: await ask("carry on?", { question: "Carry on?", answer: z.boolean() }) };
446
+ });
447
+ const agent = agentFor(job);
448
+ const first = await work({ agent, job });
449
+ is("the step that failed is a line in the record", JSON.parse(row(first.runId).trace)[0].failed, "it did not work");
450
+
451
+ const done = await answer(first.runId, "yes", new Map([[agent.name, agent]]));
452
+ is("it did not run again on the way back", tried, 1);
453
+ is("and it failed the same way it failed the first time", JSON.parse(done.text).why, "it did not work");
454
+ }
455
+
456
+ about("nobody answers");
457
+ {
458
+ const job = codeJob("lapsing", async ({ ask }) => ({
459
+ deployed: await ask("deploy?", { question: "Deploy?", answer: z.boolean(), within: "30m", otherwise: false }),
460
+ }));
461
+ const agent = agentFor(job);
462
+ const first = await work({ agent, job });
463
+ timePasses(first.runId);
464
+ await sweep(new Map([[agent.name, agent]]));
465
+ is("it carried on with what the ask said to", JSON.parse(row(first.runId).reply), { deployed: false });
466
+ is("and stopped holding its job", waitingFor("test", "lapsing"), false);
467
+ }
468
+
469
+ about("nobody answers, and the ask had nothing to carry on with");
470
+ {
471
+ const job = codeJob("stuck", ({ ask }) => ask("ok?", { question: "Ok?", answer: z.boolean(), within: "10m" }));
472
+ const agent = agentFor(job);
473
+ const first = await work({ agent, job });
474
+ timePasses(first.runId);
475
+ await sweep(new Map([[agent.name, agent]]));
476
+ is("the run stopped and said why", String(row(first.runId).error).startsWith("Nobody answered"), true);
477
+ is("and stopped holding its job", waitingFor("test", "stuck"), false);
478
+ }
479
+
480
+ about("the job was edited while a run was waiting");
481
+ {
482
+ const before = codeJob("edited", async ({ step, ask }) => {
483
+ await step("one", () => 1);
484
+ return ask("go?", { question: "Go?", answer: z.boolean() });
485
+ });
486
+ const first = await work({ agent: agentFor(before), job: before });
487
+ const after = { ...before, run: async ({ step, ask }: any) => {
488
+ await step("something else", () => 2);
489
+ return ask("go?", { question: "Go?", answer: z.boolean() });
490
+ } } as Job;
491
+ const edited = agentFor(after);
492
+ const result = await answer(first.runId, "yes", new Map([[edited.name, edited]]));
493
+ is("it refused to hand the wrong answer to the wrong step", String(row(result.runId).error).startsWith("This job changed"), true);
494
+ }
495
+
496
+ const shape = z.object({ unhealthy: z.array(z.string()), safe: z.boolean() });
497
+ const asking = (id: string) =>
498
+ codeJob(id, async ({ step, model }) => {
499
+ const services = await step("gather", () => [{ name: "one", state: "failed" }]);
500
+ return model("what is wrong", { prompt: JSON.stringify(services), output: shape });
501
+ });
502
+
503
+ about("a model step that answers in the shape");
504
+ {
505
+ asked = 0;
506
+ answers.push('```json\n{"unhealthy":["one"],"safe":true}\n```');
507
+ const job = asking("clean");
508
+ const result = await work({ agent: agentFor(job), job });
509
+ const saved = row(result.runId);
510
+ is("a fence around the JSON is not a failure", JSON.parse(result.text), { unhealthy: ["one"], safe: true });
511
+ is("it asked once", asked, 1);
512
+ is("the run now names the model it used", saved.model, "anthropic/claude-haiku-4.5");
513
+ const lines = JSON.parse(saved.trace) as { kind: string; cost: number }[];
514
+ is("the step that did not ask cost nothing", lines[0].cost, 0);
515
+ is("the step that asked carries the cost", lines[1].cost, 0.0002);
516
+ is("and is marked as the model step", lines[1].kind, "model");
517
+ }
518
+
519
+ about("a model step that has to be told again");
520
+ {
521
+ asked = 0;
522
+ answers.push('{"unhealthy":"one","safe":"maybe"}', '{"unhealthy":["one"],"safe":false}');
523
+ const job = asking("retried");
524
+ const result = await work({ agent: agentFor(job), job });
525
+ is("it came back in the shape the second time", JSON.parse(result.text), { unhealthy: ["one"], safe: false });
526
+ is("it asked twice", asked, 2);
527
+ is("both calls are charged to the run", row(result.runId).cost, 0.0004);
528
+ }
529
+
530
+ about("a model step that never fits");
531
+ {
532
+ asked = 0;
533
+ answers.push("sorry, I cannot help with that", "still not JSON");
534
+ const job = asking("hopeless");
535
+ let threw = "";
536
+ await work({ agent: agentFor(job), job }).catch((error: Error) => {
537
+ threw = error.message;
538
+ });
539
+ is("it gave up rather than passing the text on", threw.startsWith('The model step "what is wrong" did not answer'), true);
540
+ is("after two goes", asked, 2);
541
+ // The money left whether or not the answer was usable, so the run says so
542
+ // rather than reading as free.
543
+ const run = db.prepare("select cost, trace from runs where job = 'hopeless'").get() as { cost: number; trace: string };
544
+ const line = (JSON.parse(run.trace) as { kind: string; cost: number; failed?: string }[])[1];
545
+ is("and the run was charged for both", [run.cost, line.cost], [0.0004, 0.0004]);
546
+ is("with the step it stopped on named", [line.kind, line.failed?.slice(0, 14)], ["model", "The model step"]);
547
+ }
548
+
549
+ {
550
+ about("settings, and what wins");
551
+ const { readSettings, setting } = await import("@chloejs/core");
552
+
553
+ const base = { model: { via: "gateway", judge: "a" } };
554
+ is("a default fills in what no file mentions", readSettings(base, {}).model.gateway, "https://ai-gateway.vercel.sh/v1/chat/completions");
555
+ is("a local file wins over the tracked one", readSettings(base, { model: { via: "claude" } }).model.via, "claude");
556
+ is(
557
+ "and wins one key without clearing its neighbours",
558
+ readSettings(base, { model: { via: "claude" } }).model.judge,
559
+ "a",
560
+ );
561
+ is("a setting nobody set is empty rather than missing", readSettings({}, {}).node, "");
562
+ is("an environment variable beats the files", setting("fromfile", "TEST_SETTING_WINS"), "fromfile");
563
+ process.env.TEST_SETTING_WINS = "fromenv";
564
+ is("once there is one", setting("fromfile", "TEST_SETTING_WINS"), "fromenv");
565
+
566
+ let refused = "";
567
+ try {
568
+ readSettings({ model: { via: "telepathy" } }, {});
569
+ } catch (error) {
570
+ refused = error instanceof Error ? error.message.split("\n")[0] : "";
571
+ }
572
+ is("a setting that is not a choice is refused, not ignored", refused, "settings are not valid:");
573
+ }
574
+
575
+ {
576
+ about("what mail says when a person has to sign in");
577
+ const { explain } = await import("#chloe/do/mail.ts");
578
+
579
+ // The account is read from settings, which on a real box has a real one in it.
580
+ const { settings } = await import("@chloejs/core");
581
+ const was = settings.google.account;
582
+ delete process.env.GOG_ACCOUNT;
583
+ settings.google.account = "somebody@example.com";
584
+
585
+ const keyring = explain("read token: aes.KeyUnwrap(): integrity check failed");
586
+ is(
587
+ "a keyring that will not open hands over the command to paste",
588
+ keyring.includes("gog auth login --account somebody@example.com"),
589
+ true,
590
+ );
591
+ is("and says not to retry", keyring.includes("Do not retry"), true);
592
+
593
+ const expired = explain("oauth2: invalid_grant");
594
+ is(
595
+ "so does a sign-in Google has revoked",
596
+ expired.includes("gog auth login --account somebody@example.com"),
597
+ true,
598
+ );
599
+
600
+ settings.google.account = "";
601
+ is(
602
+ "with no account set there is nothing to put after --account",
603
+ explain("KeyUnwrap(): integrity check failed").includes("--account"),
604
+ false,
605
+ );
606
+
607
+ settings.google.account = was;
608
+ is(
609
+ "a missing keyring password is not a sign-in, so it does not say to sign in",
610
+ explain("GOG_KEYRING_PASSWORD is not set").includes("auth login"),
611
+ false,
612
+ );
613
+ }
614
+
615
+ {
616
+ about("reading a reply from the claude cli");
617
+ // Reaching into the package by path rather than through "@chloejs/core": reading the
618
+ // CLI's replies is the runtime's own business, and this case should move in
619
+ // with it the day chloe becomes its own repo.
620
+ const { readReply } = await import("#chloe/model/claude.ts");
621
+
622
+ is("plain words are an answer", readReply("The site is up.").call, undefined);
623
+ const tagged = readReply(
624
+ 'Let me look.\n<invoke name="read_notes">\n<parameter name="path">2026</parameter>\n<parameter name="limit">5</parameter>\n</invoke>\n</invoke>\n<invoke name="read_notes">',
625
+ [{ name: "read_notes", description: "", parameters: { type: "object", properties: { path: { type: "string" }, limit: { type: "number" } } } }],
626
+ );
627
+ is("the tag form Claude is trained on is a request too", tagged.call?.function.name, "read_notes");
628
+ is("its values follow the tool's schema", tagged.call?.function.arguments, '{"path":"2026","limit":5}');
629
+ is("and what came before it is what it said", tagged.said, "Let me look.");
630
+ is(
631
+ "an object on its own is a request",
632
+ readReply('{"tool": "check_site", "arguments": {"url": "x"}}').call?.function.name,
633
+ "check_site",
634
+ );
635
+ is(
636
+ "narration before it is kept, not thrown away",
637
+ readReply('Let me look first.\n\n{"tool": "check_site", "arguments": {}}').said,
638
+ "Let me look first.",
639
+ );
640
+ is(
641
+ "and the request still comes through",
642
+ readReply('Let me look first.\n\n{"tool": "check_site", "arguments": {}}').call?.function.arguments,
643
+ "{}",
644
+ );
645
+ is(
646
+ "a fence around it is not a failure",
647
+ readReply('Checking.\n\n```json\n{"tool": "check_site", "arguments": {}}\n```').call?.function.name,
648
+ "check_site",
649
+ );
650
+ is(
651
+ "writing about a tool is not asking for one",
652
+ readReply('You would send {"tool": "check_site"} to ask for it, but I cannot.').call,
653
+ undefined,
654
+ );
655
+ is("an object that is not a request is left as words", readReply('{"note": "not a tool"}').call, undefined);
656
+ is(
657
+ "missing arguments become none rather than nothing",
658
+ readReply('{"tool": "disk_report"}').call?.function.arguments,
659
+ "{}",
660
+ );
661
+ }
662
+
663
+ {
664
+ about("every agent in this repo still loads");
665
+ const { loadAll } = await import("@chloejs/core");
666
+
667
+ // One bad file in a jobs folder takes down every job that agent
668
+ // has, silently: the cron lines simply stop existing. That is how a
669
+ // nightly-backup.test.ts sitting beside the job it tests stopped the backup
670
+ // for as long as nobody looked. Loading them all is the cheapest way to
671
+ // notice.
672
+ const { defineAgent } = await import("@chloejs/core");
673
+ const here = defineAgent({ name: "here", model: "m", description: "", instructions: "Hello." });
674
+ is("an agent's folder is the one it is written in, unless it says", here.folder, import.meta.dirname);
675
+ is("and it can say", defineAgent({ ...here, folder: "/elsewhere" }).folder, "/elsewhere");
676
+
677
+ const all = await loadAll().then((found) => found, (error: Error) => error);
678
+ is("every agent loads", all instanceof Error ? all.message : null, null);
679
+ for (const agent of all instanceof Error ? [] : all.values()) {
680
+ // A job is only on the clock if the agent imports it, so one written and
681
+ // never named would sit there looking like a job and never run.
682
+ const named = new Set(agent.jobs.flatMap((one) => one.files));
683
+ const inJobs = await readdir(join(agent.folder, "jobs")).catch(() => [] as string[]);
684
+ const unnamed = inJobs
685
+ .filter((file) => /\.(ts|md)$/.test(file) && !file.endsWith(".test.ts"))
686
+ .map((file) => `jobs/${file}`)
687
+ .filter((file) => !named.has(file));
688
+ is(`every job in ${agent.name}'s jobs folder is named in its agent.ts`, unnamed, []);
689
+ }
690
+ }
691
+
692
+ {
693
+ about("when a job runs, written in words");
694
+
695
+ const { every, describe, parse } = await import("@chloejs/core/timer");
696
+
697
+ // It is a library of its own, so nothing in it may reach into the rest. Found
698
+ // from this file rather than from the repo root, because the runtime is a
699
+ // package and the repo that installed it is somewhere else.
700
+ const timer = join(import.meta.dirname, "../timer");
701
+ const reaching: string[] = [];
702
+ for (const file of await readdir(timer)) {
703
+ const source = await readFile(join(timer, file), "utf8");
704
+ for (const [, from] of source.matchAll(/^(?:import|export)\b[^;]*?\sfrom\s+"([^"]+)"/gm)) {
705
+ if (!from.startsWith("./") && !from.startsWith("node:")) reaching.push(`${file}: ${from}`);
706
+ }
707
+ }
708
+ is("@chloejs/core/timer imports nothing outside itself", reaching, []);
709
+ const said: [string, string, string][] = [
710
+ [every(15).minutes, "*/15 * * * *", "every 15 minutes"],
711
+ [every(4).hours, "0 */4 * * *", "every 4 hours"],
712
+ [every.minute, "* * * * *", "every minute"],
713
+ [every.hour.at(0), "0 * * * *", "every hour"],
714
+ [every.hour.at(30), "30 * * * *", "every hour at :30"],
715
+ [every.day.at("07:00"), "0 7 * * *", "every day at 07:00 UTC"],
716
+ [every.day.at("22:45", "10:45"), "45 10,22 * * *", "every day at 10:45 and 22:45 UTC"],
717
+ [every.weekday.at("9:30"), "30 9 * * 1-5", "weekdays at 09:30 UTC"],
718
+ [every.weekend.at("10:00"), "0 10 * * 0,6", "weekends at 10:00 UTC"],
719
+ [every.monday.at("9:00"), "0 9 * * 1", "mondays at 09:00 UTC"],
720
+ [every.month.on(1).at("09:00"), "0 9 1 * *", "on the 1st of every month at 09:00 UTC"],
721
+ ];
722
+ for (const [written, line, words] of said) {
723
+ is(`${words} is ${line}`, written, line);
724
+ is(`and the clock reads it`, typeof parse(written), "object");
725
+ is(`and it reads back as "${words}"`, describe(written), words);
726
+ }
727
+ // New York moves its clocks and the line does not: 07:00 there is 11:00 UTC
728
+ // in summer and 12:00 UTC in winter, including on the days it changes.
729
+ const { due } = await import("@chloejs/core/timer");
730
+ const seven = parse(every.day.at("07:00"));
731
+ const at = (utc: string) => due(seven, new Date(utc), "America/New_York");
732
+ is("07:00 New York in summer is 11:00 UTC", [at("2026-07-01T11:00:00Z"), at("2026-07-01T12:00:00Z")], [true, false]);
733
+ is("and in winter is 12:00 UTC", [at("2026-01-15T12:00:00Z"), at("2026-01-15T11:00:00Z")], [true, false]);
734
+ is("the morning the clocks go forward", at("2026-03-08T11:00:00Z"), true);
735
+ is("the morning they go back", at("2026-11-01T12:00:00Z"), true);
736
+ is("a zone other than UTC is said by its city", describe("20 23 * * *", "America/New_York"), "every day at 23:20 New York");
737
+ is("a line every() could not have written stays a cron line", describe("0 9 * 1 *"), undefined);
738
+
739
+ const refused = (write: () => string) => {
740
+ try {
741
+ return `wrote ${write()}`;
742
+ } catch (error) {
743
+ return (error as Error).message;
744
+ }
745
+ };
746
+ is("a count that does not divide the hour is refused, with what does",
747
+ refused(() => every(7).minutes),
748
+ "every(7).minutes does not divide an hour evenly, so the gaps would not all be the same. It can be 2, 3, 4, 5, 6, 10, 12, 15, 20, 30.");
749
+ is("every(1) points at the plain way to say it", refused(() => every(1).hours), "every(1).hours is every.hour.at(0).");
750
+ is("a time is on a 24 hour clock", refused(() => every.day.at("7am")),
751
+ '"7am" is not a time. Write it on a 24 hour clock, like "07:00" or "22:45".');
752
+ is("two times one cron line cannot hold are refused",
753
+ refused(() => every.day.at("07:00", "19:30")),
754
+ "07:00, 19:30 do not share a minute, and one cron line has only one. Make them two jobs.");
755
+ is("a day of the month some months do not have is refused",
756
+ refused(() => every.month.on(31).at("09:00")),
757
+ "every.month.on(31): the day is 1 to 28, so it happens in every month.");
758
+ }
759
+
760
+ {
761
+ about("a job with no cron line, and one with a bad cron line");
762
+
763
+ const { jobsOf, markdownJob } = await import("#chloe/load/load.ts");
764
+ const { startClock } = await import("#chloe/core/clock.ts");
765
+ const folder = await mkdtemp(join(tmpdir(), "chloe-jobs-"));
766
+ await mkdir(join(folder, "jobs"));
767
+ await writeFile(join(folder, "jobs/by-hand.md"), "---\ndescription: Says hello.\n---\n\nSay hello.\n");
768
+ const [byHand] = await jobsOf("test", folder, [markdownJob("jobs/by-hand.md")]);
769
+ is("it loads, with its description", byHand.description, "Says hello.");
770
+ is("its id is the file's name", byHand.id, "by-hand");
771
+ is("and has no cron line", byHand.cron, undefined);
772
+
773
+ // The clock ticks once as it starts. A job with a cron line of every minute
774
+ // is due, and the one with none is never due.
775
+ const onTheClock = { ...codeJob("every-minute", async () => "ran"), cron: "* * * * *" };
776
+ const offTheClock = codeJob("when-started", async () => "ran");
777
+ delete offTheClock.cron;
778
+ const clock = startClock(() => new Map([["test", { ...agentFor(onTheClock), jobs: [onTheClock, offTheClock] }]]));
779
+ await new Promise((done) => setTimeout(done, 200));
780
+ clock.stop();
781
+ const ran = (job: string) => db.prepare("select count(*) as n from runs where job = ?").get(job) as { n: number };
782
+ is("the clock runs the one with a cron line", ran("every-minute").n, 1);
783
+ is("and says so in the log", db.prepare("select source from runs where job = 'every-minute'").get(), { source: "schedule" });
784
+ is("and leaves the one without", ran("when-started").n, 0);
785
+
786
+ await writeFile(join(folder, "jobs/bad.md"), "---\ncron: 61 * * * *\n---\n\nSay hello.\n");
787
+ const refused = await jobsOf("test", folder, [markdownJob("jobs/bad.md")]).then(() => "", (error: Error) => error.message);
788
+ is("a bad cron line is refused as the agent loads, naming the file", refused.includes("jobs/bad.md has a cron line that does not read"), true);
789
+
790
+ const twice = await jobsOf("test", folder, [markdownJob("jobs/by-hand.md"), markdownJob("jobs/by-hand.md")])
791
+ .then(() => "", (error: Error) => error.message);
792
+ is("two jobs with one id are refused", twice, "test: two jobs are called by-hand.");
793
+
794
+ const both = await jobsOf("test", folder, [{ id: "both", run: async () => "", markdown: "Say hello." }])
795
+ .then(() => "", (error: Error) => error.message);
796
+ is("a job that is code and a prompt is refused", both, "test job both has both run and markdown. A job is code or a prompt, never both.");
797
+ await rm(folder, { recursive: true, force: true });
798
+ }
799
+
800
+ {
801
+ about("a note two jobs want at the same moment");
802
+
803
+ const { STATE, note } = await import("@chloejs/core");
804
+ const shape = z.object({ sites: z.record(z.string(), z.string()) }).catch({ sites: {} });
805
+ const kept = note("test-note", "sites", shape);
806
+
807
+ // One site to begin with, so that an empty answer later can only mean a
808
+ // reader caught the file mid-write. On a note that has never been written,
809
+ // empty is the honest answer and proves nothing.
810
+ await kept.write({ sites: { "one.example.com": "200" } });
811
+
812
+ const many: Record<string, string> = {};
813
+ for (let i = 0; i < 20_000; i++) many[`host-${i}.example.com`] = "200";
814
+
815
+ // A reader gets the whole of the old file or the whole of the new one. Before
816
+ // the write was a rename it could catch the file truncated, and a half file
817
+ // reads as the schema's default: no sites at all, which is a wrong answer
818
+ // that looks like a right one.
819
+ const reads: Array<Promise<{ sites: Record<string, string> }>> = [];
820
+ const writing = kept.write({ sites: many });
821
+ for (let i = 0; i < 200; i++) reads.push(kept.read());
822
+ await writing;
823
+ const counts = (await Promise.all(reads)).map((one) => Object.keys(one.sites).length);
824
+ is("nobody reads a half written note", counts.filter((n) => n !== 1 && n !== 20_000), []);
825
+ is("and the note itself is whole afterwards", Object.keys((await kept.read()).sites).length, 20_000);
826
+ const left = (await readdir(join(STATE, "test-note"))).filter((f) => f.endsWith(".part"));
827
+ is("the temporary file is renamed, not left behind", left, []);
828
+ await rm(join(STATE, "test-note"), { recursive: true, force: true });
829
+ }
830
+
831
+ {
832
+ about("no job reaches for a tool");
833
+
834
+ // A tool is for a model only, whether it is one of chloe's or the agent's
835
+ // own. A job that imports one is either doing work through a wrapper built
836
+ // for a model, or it wanted a `do/` folder and took the first import that
837
+ // compiled. The other direction is fine: a tool may call a job's function.
838
+ const { loadAll } = await import("@chloejs/core");
839
+ const found = [];
840
+ for (const agent of (await loadAll()).values()) {
841
+ found.push(...(await readdir(agent.folder, { recursive: true, withFileTypes: true })));
842
+ }
843
+ const reaching: string[] = [];
844
+ for (const file of found) {
845
+ if (!file.isFile() || !file.name.endsWith(".ts")) continue;
846
+ if (!file.parentPath.endsWith("/jobs")) continue;
847
+ const source = await readFile(join(file.parentPath, file.name), "utf8");
848
+ if (source.includes('"@chloejs/core/tools"') || source.includes('"../tools/')) reaching.push(file.name);
849
+ }
850
+ is("every job calls the work itself", reaching, []);
851
+ }
852
+
853
+ // Then whatever the repo that installed chloe tests about its own jobs. A
854
+ // file named `<job>.test.ts` anywhere in an agent's folder runs its cases as
855
+ // it loads, so there is no list of them to keep and nothing to register.
856
+ for (const agent of (await (await import("@chloejs/core")).loadAll()).values()) {
857
+ for (const found of await readdir(agent.folder, { recursive: true, withFileTypes: true })) {
858
+ if (!found.isFile() || !found.name.endsWith(".test.ts")) continue;
859
+ await import(pathToFileURL(join(found.parentPath, found.name)).href);
860
+ }
861
+ }
862
+
863
+ {
864
+ about("the folder the page reads and writes");
865
+
866
+ const { editable, open, save, tree } = await import("#chloe/serve/files.ts");
867
+ const { names } = await import("@chloejs/core");
868
+ const agent = (await names())[0];
869
+
870
+ const top = await tree(agent);
871
+ is("folders come before files", [...top].sort((a, b) => Number(b.dir) - Number(a.dir)), top);
872
+ is(
873
+ "a folder carries what is under it",
874
+ top.some((entry) => entry.dir && (entry.children?.length ?? 0) > 0),
875
+ true,
876
+ );
877
+
878
+ is(
879
+ "what a program made is not part of what an agent is",
880
+ JSON.stringify(top).includes("__pycache__"),
881
+ false,
882
+ );
883
+
884
+ is("markdown is written back", editable("skills/one.md"), true);
885
+ is("code is not", editable("units.ts"), false);
886
+
887
+ const refused = await save(agent, "units.ts", "//").then(() => null, (error: Error) => error.message);
888
+ is("and save refuses it rather than trusting the page", refused, "units.ts is not markdown.");
889
+
890
+ // The page hands over a path, so it is as untrusted as one a model wrote.
891
+ const out = await open(agent, "../../etc/passwd").then(() => null, (error: Error) => error.message);
892
+ is("a path out of the agent's folder does not open", out?.startsWith("Path is outside"), true);
893
+ }
894
+
895
+ {
896
+ about("what a request may send");
897
+
898
+ const { body, BadRequest } = await import("#chloe/serve/http.ts");
899
+ const shape = z.object({ text: z.string().trim().min(1) });
900
+ // A stand-in caller: whatever it is handed is the body of one request.
901
+ const server = createServer(async (request, response) => {
902
+ const answer = await body(request, shape).then(
903
+ (value) => ({ value }),
904
+ (error: Error) => ({ refused: error instanceof BadRequest, why: error.message }),
905
+ );
906
+ response.end(JSON.stringify(answer));
907
+ });
908
+ await new Promise<void>((done) => server.listen(0, "127.0.0.1", done));
909
+ const send = async (raw: string) =>
910
+ (await fetch(`http://127.0.0.1:${(server.address() as { port: number }).port}`, { method: "POST", body: raw })).json();
911
+
912
+ is("a body in the shape comes back typed", await send('{"text":" hi "}'), { value: { text: "hi" } });
913
+ is("one that does not fit is refused, saying which field", await send('{"text":5}'), {
914
+ refused: true,
915
+ why: "text Invalid input: expected string, received number",
916
+ });
917
+ is("so is one that is not JSON", await send("not json"), { refused: true, why: "Body is not valid JSON." });
918
+ server.close();
919
+ }
920
+
921
+ {
922
+ about("telegram");
923
+ const { listen } = await import("#chloe/channels/telegram.ts");
924
+
925
+ // A stand-in Telegram: each update is handed out once, a file is always the
926
+ // same four bytes, and everything the bot sends is written down.
927
+ const inbox: object[] = [];
928
+ const calls: { method: string; body: any; token: string }[] = [];
929
+ const telegram = createServer((request, response) => {
930
+ let raw = "";
931
+ request.on("data", (chunk) => (raw += chunk));
932
+ request.on("end", () => {
933
+ if (request.url!.startsWith("/file/")) return void response.end("PNG!");
934
+ const method = request.url!.split("/").pop()!;
935
+ calls.push({ method, body: JSON.parse(raw || "{}"), token: request.url!.split("/")[1].slice(3) });
936
+ const result =
937
+ method === "getUpdates" ? inbox.splice(0)
938
+ : method === "getMe" ? { id: 999, is_bot: true, username: "testbot" }
939
+ : method === "getFile" ? { file_path: "photos/one.png" }
940
+ : true;
941
+ setTimeout(() => response.end(JSON.stringify({ ok: true, result })), method === "getUpdates" ? 20 : 0);
942
+ });
943
+ });
944
+ await new Promise<void>((done) => telegram.listen(0, "127.0.0.1", done));
945
+ const api = `http://127.0.0.1:${(telegram.address() as { port: number }).port}`;
946
+ const said = () => calls.filter((c) => c.method === "sendMessage").map((c) => `${c.body.chat_id}: ${c.body.text}`);
947
+ const pause = (ms: number) => new Promise((done) => setTimeout(done, ms));
948
+ const settle = async (count: number) => {
949
+ for (let i = 0; i < 100 && said().length < count; i++) await pause(20);
950
+ await pause(50);
951
+ };
952
+ const me = { id: 7, first_name: "Me" };
953
+ const stranger = { id: 9, first_name: "Stranger" };
954
+ const privately = (id: number, from: object, text: string, more: object = {}) => ({
955
+ update_id: id,
956
+ message: { message_id: id, from, chat: { id: (from as { id: number }).id, type: "private" }, text, ...more },
957
+ });
958
+ const inGroup = (id: number, from: object, text: string, more: object = {}) => ({
959
+ update_id: id,
960
+ message: { message_id: id, from, chat: { id: -100, type: "group", title: "Friends" }, text, ...more },
961
+ });
962
+
963
+ const toldAgent: string[] = [];
964
+ const job = codeJob("unused", async () => ({}));
965
+ const agent = agentFor(job);
966
+
967
+ const first = listen({ name: "test", token: "t", api, agent: () => agent });
968
+ inbox.push(privately(1, stranger, "hi"));
969
+ await settle(1);
970
+ first.stop();
971
+ // A stopped reader's last poll can still reach the stand-in, which hands
972
+ // messages out once and for all, unlike Telegram. Let it land first.
973
+ await pause(100);
974
+ is("it clears a webhook first, or Telegram refuses to hand out messages", calls.some((c) => c.method === "deleteWebhook"), true);
975
+ is("with nobody allowed yet, a private message is told its user id", said()[0]?.startsWith("9: Your Telegram user id is 9."), true);
976
+
977
+ calls.length = 0;
978
+ answers.push("hello from the agent", "seen in the group", "a red square");
979
+ const second = listen({ name: "test", token: "t", api, allowFrom: [7], agent: () => agent });
980
+ // One at a time: two turns at once would take the stand-in model's answers in either order.
981
+ inbox.push(privately(5, stranger, "let me in"), privately(6, me, "hi"));
982
+ await settle(1);
983
+ inbox.push(inGroup(7, me, "just chatting"), inGroup(8, me, "@testbot what now", { entities: [{ type: "mention", offset: 0, length: 8 }] }));
984
+ await settle(2);
985
+ inbox.push(privately(9, me, "", { caption: "what is this?", photo: [{ file_id: "small" }, { file_id: "big", file_size: 4 }] }));
986
+ await settle(3);
987
+ second.stop();
988
+ await pause(100);
989
+ is(
990
+ "an allowed user is answered, in private and in a group that mentions the bot, and a stranger by nobody",
991
+ said(),
992
+ ["7: hello from the agent", "-100: seen in the group", "7: a red square"],
993
+ );
994
+ is("a group message that is not for the bot is left alone", said().length, 3);
995
+ is("an answer in a group replies to the message it answers", calls.find((c) => c.method === "sendMessage" && c.body.chat_id === -100)?.body.reply_parameters, { message_id: 8 });
996
+ is("a photo is fetched at its largest size", calls.find((c) => c.method === "getFile")?.body.file_id, "big");
997
+ const lastTurn = db.prepare("select prompt from runs where source = 'telegram' order by started desc limit 1").get() as { prompt: string };
998
+ is("the agent is told where the message came from", lastTurn.prompt.includes("<telegram_context>"), true);
999
+ is("and that a photo came with it", lastTurn.prompt.includes("(Attached: photo.jpg)"), true);
1000
+ is(
1001
+ "a bot started again carries on from where the last one got to",
1002
+ calls.filter((c) => c.method === "getUpdates")[0]?.body.offset,
1003
+ 2,
1004
+ );
1005
+
1006
+ // A job's question with answers that can be listed arrives as buttons, and a press answers it.
1007
+ calls.length = 0;
1008
+ const asking = codeJob("buttons", async ({ ask }) => ({ go: await ask("go?", { question: "Go?", answer: z.boolean(), who: "telegram:7" }) }));
1009
+ const withJob = agentFor(asking);
1010
+ const third = listen({ name: "test", token: "t", api, allowFrom: [7], agent: () => withJob });
1011
+ const parked = await work({ agent: withJob, job: asking });
1012
+ const question = calls.find((c) => c.method === "sendMessage");
1013
+ is("a yes or no question comes with two buttons", question?.body.reply_markup?.inline_keyboard?.[0]?.map((b: { text: string }) => b.text), ["yes", "no"]);
1014
+ inbox.push({
1015
+ update_id: 20,
1016
+ callback_query: {
1017
+ id: "q",
1018
+ from: me,
1019
+ data: "a:0",
1020
+ message: { message_id: 50, chat: { id: 7, type: "private" }, text: "Go?", reply_markup: question?.body.reply_markup },
1021
+ },
1022
+ });
1023
+ await settle(2);
1024
+ third.stop();
1025
+ await pause(100);
1026
+ is("pressing one answers the job", JSON.parse(row(parked.runId).reply), { go: true });
1027
+ is("and the buttons are taken away", calls.find((c) => c.method === "editMessageText")?.body.text, "Go?\n\n→ yes");
1028
+
1029
+ // The other way for messages to arrive: Telegram sends them, with a secret.
1030
+ calls.length = 0;
1031
+ answers.push("sent to me");
1032
+ const fourth = listen({
1033
+ name: "test",
1034
+ token: "w",
1035
+ api,
1036
+ allowFrom: [7],
1037
+ mode: "webhook",
1038
+ publicUrl: "https://example.com",
1039
+ credentials: { webhookSecretToken: "s3cret" },
1040
+ agent: () => agent,
1041
+ });
1042
+ await pause(50);
1043
+ is("in webhook mode it registers its own address", calls.find((c) => c.method === "setWebhook")?.body.url, "https://example.com/chloe/v1/test/telegram");
1044
+ const route = fourth.routes![0];
1045
+ const post = async (secret: string) => {
1046
+ let status = 0;
1047
+ const body = JSON.stringify(privately(30, me, "over the webhook"));
1048
+ const request = Object.assign(
1049
+ (async function* () {
1050
+ yield body;
1051
+ })(),
1052
+ { headers: { "x-telegram-bot-api-secret-token": secret } },
1053
+ );
1054
+ await route.handle(request as any, { writeHead: (s: number) => ((status = s), { end: () => {} }) } as any);
1055
+ return status;
1056
+ };
1057
+ is("a call without the secret is refused", await post("wrong"), 401);
1058
+ is("a call with it is taken", await post("s3cret"), 200);
1059
+ await settle(1);
1060
+ fourth.stop();
1061
+ is("and answered the same way", said(), ["7: sent to me"]);
1062
+ is("it never polls in webhook mode", calls.some((c) => c.token === "w" && c.method === "getUpdates"), false);
1063
+
1064
+ // A plain message a job answers goes to that job, not to the chat, and with
1065
+ // inGroups "always" a group message needs no mention.
1066
+ calls.length = 0;
1067
+ const { startClock } = await import("#chloe/core/clock.ts");
1068
+ const handed: string[] = [];
1069
+ const highlights = {
1070
+ ...codeJob("highlights", async ({ input }) => (handed.push(String(input.text)), { ok: true }), undefined, () => "Filed."),
1071
+ input: z.object({ text: z.string() }),
1072
+ reply: () => "Filed. " + "A reply that is longer than one line. ".repeat(8).trim(),
1073
+ answers: (text: string) => text.startsWith("\u201c"),
1074
+ } as Job;
1075
+ delete highlights.cron;
1076
+ const reader = agentFor(highlights);
1077
+ const ticking = startClock(() => new Map([["test", reader]]));
1078
+ const fifth = listen({ name: "test", token: "j", api, allowFrom: [7], inGroups: "always", agent: () => reader });
1079
+ inbox.push(inGroup(40, me, "\u201cA line from a book.\u201d \u2014 A Book"));
1080
+ await settle(1);
1081
+ fifth.stop();
1082
+ ticking.stop();
1083
+ await pause(100);
1084
+ is("a message a job answers goes to that job, whole", handed, ["\u201cA line from a book.\u201d \u2014 A Book"]);
1085
+ const whole = "Filed. " + "A reply that is longer than one line. ".repeat(8).trim();
1086
+ is("and its own reply is what the chat is sent, whole", said(), [`-100: ${whole}`]);
1087
+ const { recall: recalled } = await import("#chloe/model/memory.ts");
1088
+ is("and the exchange is kept in that chat's conversation", recalled("test/telegram--100").map((m) => m.content).slice(-2), ["\u201cA line from a book.\u201d \u2014 A Book", whole]);
1089
+ is("the / menu is the agent's jobs", calls.find((c) => c.method === "setMyCommands")?.body.commands, [{ command: "highlights", description: "highlights" }]);
1090
+ telegram.close();
1091
+
1092
+ }
1093
+
1094
+ {
1095
+ about("slack");
1096
+ const { listen } = await import("#chloe/channels/slack.ts");
1097
+ const { createHash } = await import("node:crypto");
1098
+
1099
+ // A stand-in Slack: its web methods over HTTP, and one socket that hands
1100
+ // chloe envelopes. Everything the bot sends and acknowledges is written down.
1101
+ const calls: { method: string; body: any }[] = [];
1102
+ const acked: string[] = [];
1103
+ let socket: import("node:stream").Duplex | undefined;
1104
+ const frame = (text: string) => {
1105
+ const data = Buffer.from(text);
1106
+ const head = data.length < 126 ? Buffer.from([0x81, data.length]) : Buffer.from([0x81, 126, data.length >> 8, data.length & 255]);
1107
+ return Buffer.concat([head, data]);
1108
+ };
1109
+ const slack = createServer((request, response) => {
1110
+ let raw = "";
1111
+ request.on("data", (chunk) => (raw += chunk));
1112
+ request.on("end", () => {
1113
+ const method = request.url!.split("/").pop()!;
1114
+ const body = request.headers["content-type"]?.startsWith("application/json") ? JSON.parse(raw || "{}") : Object.fromEntries(new URLSearchParams(raw));
1115
+ calls.push({ method, body });
1116
+ const port = (slack.address() as { port: number }).port;
1117
+ const result =
1118
+ method === "auth.test" ? { user_id: "UBOT" }
1119
+ : method === "apps.connections.open" ? { url: `ws://127.0.0.1:${port}/socket` }
1120
+ : method === "users.info" ? { user: { name: body.user === "U7" ? "me" : "stranger" } }
1121
+ : method === "conversations.info" ? { channel: { name: "friends" } }
1122
+ : {};
1123
+ response.end(JSON.stringify({ ok: true, ...result }));
1124
+ });
1125
+ });
1126
+ slack.on("upgrade", (request, sock) => {
1127
+ const accept = createHash("sha1").update(`${request.headers["sec-websocket-key"]}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest("base64");
1128
+ sock.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`);
1129
+ socket = sock;
1130
+ let buffered = Buffer.alloc(0);
1131
+ sock.on("data", (chunk) => {
1132
+ buffered = Buffer.concat([buffered, chunk]);
1133
+ // Frames from a client are masked, and these are all short.
1134
+ while (buffered.length >= 6) {
1135
+ const opcode = buffered[0] & 15;
1136
+ const length = buffered[1] & 127;
1137
+ if (buffered.length < 6 + length) break;
1138
+ const mask = buffered.subarray(2, 6);
1139
+ const data = Buffer.from(buffered.subarray(6, 6 + length).map((b, i) => b ^ mask[i % 4]));
1140
+ buffered = buffered.subarray(6 + length);
1141
+ if (opcode === 8) return void sock.end(Buffer.from([0x88, 0]));
1142
+ acked.push(JSON.parse(data.toString()).envelope_id);
1143
+ }
1144
+ });
1145
+ sock.on("error", () => {});
1146
+ });
1147
+ await new Promise<void>((done) => slack.listen(0, "127.0.0.1", done));
1148
+ const api = `http://127.0.0.1:${(slack.address() as { port: number }).port}`;
1149
+ const said = () => calls.filter((c) => c.method === "chat.postMessage").map((c) => `${c.body.channel}${c.body.thread_ts ? `/${c.body.thread_ts}` : ""}: ${c.body.text}`);
1150
+ const pause = (ms: number) => new Promise((done) => setTimeout(done, ms));
1151
+ const until = async (done: () => boolean) => {
1152
+ for (let i = 0; i < 100 && !done(); i++) await pause(20);
1153
+ await pause(50);
1154
+ };
1155
+ const settle = (count: number) => until(() => said().length >= count);
1156
+ let envelopes = 0;
1157
+ const push = (type: string, payload: object) => socket!.write(frame(JSON.stringify({ envelope_id: `e${++envelopes}`, type, payload })));
1158
+ const event = (event: object) => push("events_api", { event });
1159
+ const direct = (user: string, text: string, ts: string) => event({ type: "message", channel: "D1", channel_type: "im", user, text, ts });
1160
+ const inChannel = (user: string, text: string, ts: string, more: object = {}) => event({ type: "message", channel: "C1", channel_type: "channel", user, text, ts, ...more });
1161
+
1162
+ const agent = agentFor(codeJob("unused", async () => ({})));
1163
+
1164
+ const first = listen({ name: "test", token: "b", appToken: "a", api, agent: () => agent });
1165
+ await until(() => !!socket);
1166
+ direct("U9", "hi", "1.1");
1167
+ await settle(1);
1168
+ first.stop();
1169
+ await pause(50);
1170
+ is("it opens its connection with the app token", calls.some((c) => c.method === "apps.connections.open"), true);
1171
+ is("with nobody allowed yet, a direct message is told its member id", said()[0]?.startsWith("D1: Your Slack user id is U9."), true);
1172
+ is("every envelope is acknowledged, or Slack sends it again", acked, ["e1"]);
1173
+
1174
+ calls.length = 0;
1175
+ socket = undefined;
1176
+ answers.push("hello from the agent", "seen in the channel", "in the thread");
1177
+ const second = listen({ name: "test", token: "b", appToken: "a", api, allowFrom: ["U7"], agent: () => agent });
1178
+ await until(() => !!socket);
1179
+ direct("U9", "let me in", "2.1");
1180
+ direct("U7", "hi", "2.2");
1181
+ await settle(1);
1182
+ inChannel("U7", "just chatting", "2.3");
1183
+ inChannel("U7", "<@UBOT> what now", "2.4");
1184
+ // The same mention, as Slack also sends it.
1185
+ event({ type: "app_mention", channel: "C1", channel_type: "channel", user: "U7", text: "<@UBOT> what now", ts: "2.4" });
1186
+ await settle(2);
1187
+ inChannel("U7", "and this?", "2.6", { thread_ts: "2.5", parent_user_id: "UBOT" });
1188
+ await settle(3);
1189
+ second.stop();
1190
+ await pause(50);
1191
+ is(
1192
+ "an allowed user is answered in a direct message, in a channel that mentions the bot, and in the bot's thread, and a stranger by nobody",
1193
+ said(),
1194
+ ["D1: hello from the agent", "C1: seen in the channel", "C1/2.5: in the thread"],
1195
+ );
1196
+ is("a message Slack sends twice is answered once", said().length, 3);
1197
+ const turns = db.prepare("select prompt from runs where source = 'slack' order by started").all() as { prompt: string }[];
1198
+ is("the agent is told where the message came from", turns.at(-1)?.prompt.includes("<slack_context>"), true);
1199
+ is("and is not shown its own mention", turns.some((t) => t.prompt.includes("<@UBOT>")), false);
1200
+ is("while it works, the message is marked", calls.some((c) => c.method === "reactions.add" && c.body.timestamp === "2.2"), true);
1201
+
1202
+ // A job's question with answers that can be listed arrives as buttons, and a press answers it.
1203
+ calls.length = 0;
1204
+ socket = undefined;
1205
+ const asking = codeJob("buttons", async ({ ask }) => ({ go: await ask("go?", { question: "Go?", answer: z.boolean(), who: "slack:U7" }) }));
1206
+ const withJob = agentFor(asking);
1207
+ const third = listen({ name: "test", token: "b", appToken: "a", api, allowFrom: ["U7"], agent: () => withJob });
1208
+ await until(() => !!socket);
1209
+ const parked = await work({ agent: withJob, job: asking });
1210
+ const question = calls.find((c) => c.method === "chat.postMessage");
1211
+ is("a question goes to the person's direct message", question?.body.channel, "U7");
1212
+ is("a yes or no question comes with two buttons", question?.body.blocks?.[1]?.elements?.map((b: { value: string }) => b.value), ["yes", "no"]);
1213
+ push("interactive", { type: "block_actions", user: { id: "U7" }, channel: { id: "D1" }, message: { ts: "3.1", text: "Go?" }, actions: [{ value: "yes" }] });
1214
+ await until(() => calls.some((c) => c.method === "chat.update"));
1215
+ third.stop();
1216
+ await pause(50);
1217
+ is("pressing one answers the job", JSON.parse(row(parked.runId).reply), { go: true });
1218
+ is("and the buttons are taken away", calls.find((c) => c.method === "chat.update")?.body.text, "Go?\n\n→ yes");
1219
+
1220
+ // A slash command runs the job it names, and is answered where it was typed.
1221
+ calls.length = 0;
1222
+ socket = undefined;
1223
+ const { startClock } = await import("#chloe/core/clock.ts");
1224
+ const handed: string[] = [];
1225
+ const noted = { ...codeJob("note-it", async ({ input }) => (handed.push(String(input.text)), { ok: true })), input: z.object({ text: z.string() }), reply: () => "Noted." } as Job;
1226
+ delete noted.cron;
1227
+ const noter = agentFor(noted);
1228
+ const ticking = startClock(() => new Map([["test", noter]]));
1229
+ const replies: string[] = [];
1230
+ const hook = createServer((request, response) => {
1231
+ let raw = "";
1232
+ request.on("data", (chunk) => (raw += chunk));
1233
+ request.on("end", () => (replies.push(JSON.parse(raw).text), response.end()));
1234
+ });
1235
+ await new Promise<void>((done) => hook.listen(0, "127.0.0.1", done));
1236
+ const fourth = listen({ name: "test", token: "b", appToken: "a", api, allowFrom: ["U7"], agent: () => noter });
1237
+ await until(() => !!socket);
1238
+ push("slash_commands", { command: "/note_it", text: "buy milk", user_id: "U7", channel_id: "C1", response_url: `http://127.0.0.1:${(hook.address() as { port: number }).port}/` });
1239
+ await until(() => replies.length > 0);
1240
+ fourth.stop();
1241
+ ticking.stop();
1242
+ hook.close();
1243
+ is("a slash command runs the job it names, with the rest as its text", handed, ["buy milk"]);
1244
+ is("and is answered where it was typed", replies, ["Noted."]);
1245
+ slack.close();
1246
+ slack.closeAllConnections();
1247
+ }
1248
+
1249
+ {
1250
+ about("the api channel");
1251
+
1252
+ const { apiChannel } = await import("#chloe/channels/api.ts");
1253
+ const { recall } = await import("#chloe/model/memory.ts");
1254
+ const { serve } = await import("#chloe/serve/http.ts");
1255
+ const { makeToken, revokeToken, forgetTokens } = await import("#chloe/serve/tokens.ts");
1256
+
1257
+ // It listens to nothing. What binding it does is give a token permission to
1258
+ // reach that agent: the server answers the routes either way.
1259
+ const marker = apiChannel().start(() => undefined);
1260
+ is("the channel opens no path of its own", marker.routes, undefined);
1261
+ marker.stop();
1262
+
1263
+ const open = agentFor(codeJob("nightly", async () => ({})));
1264
+ open.channels = [apiChannel()];
1265
+ const closed = { ...agentFor(codeJob("nightly", async () => ({}))), name: "closed" };
1266
+
1267
+ const server = serve({
1268
+ host: "127.0.0.1",
1269
+ port: 0,
1270
+ agents: () => new Map([["test", open], ["closed", closed]]),
1271
+ clock: { fire() {}, running: () => [] } as unknown as import("#chloe/core/clock.ts").Clock,
1272
+ channels: () => [],
1273
+ });
1274
+ await new Promise<void>((done) => server.once("listening", done));
1275
+ const at = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
1276
+
1277
+ forgetTokens();
1278
+ const { secret, token } = makeToken("a test");
1279
+ const asToken = (path: string, body?: string) =>
1280
+ fetch(`${at}${path}`, {
1281
+ method: body === undefined ? "GET" : "POST",
1282
+ headers: { "content-type": "application/json", authorization: `Bearer ${secret}` },
1283
+ ...(body === undefined ? {} : { body }),
1284
+ });
1285
+
1286
+ is("a token reads the agents", (await asToken("/api/agents")).status, 200);
1287
+ is("and one agent's configuration", ((await (await asToken("/api/agents/test")).json()) as { api: boolean }).api, true);
1288
+ is("which says the other one is not on the api", ((await (await asToken("/api/agents/closed")).json()) as { api: boolean }).api, false);
1289
+
1290
+ answers.push("Three are late.");
1291
+ const first = await asToken("/api/agents/test/chat", '{"prompt":"how many orders are late?"}');
1292
+ const said = (await first.json()) as { runId: string; text: string; cost: number };
1293
+ is("a prompt comes back as what the agent said", [first.status, said.text], [200, "Three are late."]);
1294
+ is("with the run it was, and what it cost", [typeof said.runId, said.cost > 0], ["string", true]);
1295
+ is("and the run says where it came from", row(said.runId).source, "api");
1296
+
1297
+ // A thread the caller names is its own conversation, kept under this agent
1298
+ // so two callers naming the same one cannot land in each other's.
1299
+ answers.push("Four now.");
1300
+ await asToken("/api/agents/test/chat", '{"prompt":"and now?","thread":"mine"}');
1301
+ is("a named thread is remembered under the agent", recall("test/api-mine").map((m) => m.content), ["and now?", "Four now."]);
1302
+
1303
+ // The point of the whole arrangement: binding the channel is what opens it.
1304
+ const refused = await asToken("/api/agents/closed/chat", '{"prompt":"hello"}');
1305
+ is("an agent with no api channel is shut to a token", [refused.status, ((await refused.json()) as { error: string }).error.includes("no api channel")], [403, true]);
1306
+ is("and so is running one of its jobs", (await asToken("/api/agents/closed/job/nightly", "{}")).status, 403);
1307
+ is("while the one that binds it may be fired", (await asToken("/api/agents/test/job/nightly", "{}")).status, 200);
1308
+ is("a job it does not have is still a 404", (await asToken("/api/agents/test/job/nope", "{}")).status, 404);
1309
+
1310
+ // A token is for reading and for the agents that opted in. Everything else
1311
+ // is the account's, and saying so is the whole of the authorisation.
1312
+ is("a token cannot write a file", (await asToken("/api/agents/test/file", '{"path":"x.md","content":"hi"}')).status, 403);
1313
+ is("nor make another token", (await asToken("/api/tokens", '{"name":"sneaky"}')).status, 403);
1314
+ is("nor read an agent's memory", (await asToken("/api/agents/test/memory")).status, 403);
1315
+
1316
+ revokeToken(token.id);
1317
+ is("a revoked token stops working", (await asToken("/api/agents")).status, 401);
1318
+
1319
+ server.close();
1320
+ }
1321
+
1322
+ {
1323
+ about("runs a stop cut off");
1324
+
1325
+ const { closeCutOff } = await import("#chloe/core/db.ts");
1326
+ const insert = db.prepare(
1327
+ "insert into runs (id, agent, started, finished, source, model, prompt, parked) values (?, 'stopped', ?, ?, 'x', 'code', '', ?)",
1328
+ );
1329
+ const now = new Date().toISOString();
1330
+ // Earlier cases leave runs open in this database, and they are not what is being counted.
1331
+ db.prepare("update runs set finished = coalesce(finished, ?) where parked is null").run(now);
1332
+ insert.run("cut", now, null, null);
1333
+ insert.run("waiting", now, null, "{}");
1334
+ insert.run("done", now, now, null);
1335
+ is("one was cut off", closeCutOff(), 1);
1336
+ is("it ends, saying why", row("cut").error, "Cut off: the service stopped while this was running.");
1337
+ is("a run waiting on a person is left waiting", row("waiting").finished, null);
1338
+ is("a finished run is left as it was", row("done").error, null);
1339
+ }
1340
+
1341
+ {
1342
+ about("a conversation remembers which tools a reply used");
1343
+
1344
+ const { recall, remember } = await import("#chloe/model/memory.ts");
1345
+ remember("test/tools", "user", "What board am I on?");
1346
+ remember("test/tools", "assistant", "Board 210.", [
1347
+ { tool: "read_page", args: { url: "https://example.com/pairings" } },
1348
+ { tool: "write_notes", args: { path: "chess.html", content: "x".repeat(1000) } },
1349
+ ]);
1350
+ remember("test/tools", "assistant", "Anything else?");
1351
+ const told = recall("test/tools", { limit: 10, tools: true });
1352
+ is("the next turn sees the calls, then the reply", told.map((one) => one.role), ["user", "assistant", "tool", "tool", "assistant", "assistant"]);
1353
+ is("in the shape a turn's own calls take", told[1].tool_calls?.[0].function, { name: "read_page", arguments: '{"url":"https://example.com/pairings"}' });
1354
+ is("a whole file written is cut short", JSON.parse(told[1].tool_calls![1].function.arguments).content.length, 303);
1355
+ is("each call is answered, or a provider refuses the history", told[2].tool_call_id, told[1].tool_calls?.[0].id);
1356
+ is("the reply itself is left as it was", told[4].content, "Board 210.");
1357
+ is("a reply that called nothing is too", told[5].content, "Anything else?");
1358
+ is("the page shows only the words", recall("test/tools").map((one) => one.content), ["What board am I on?", "Board 210.", "Anything else?"]);
1359
+
1360
+ // How much of a conversation is shown: a count, and an age.
1361
+ const old = new Date(Date.now() - 40 * 86_400_000).toISOString();
1362
+ db.prepare("insert into messages (thread, role, content, at) values ('test/old', 'user', 'long ago', ?)").run(old);
1363
+ remember("test/old", "user", "yesterday-ish");
1364
+ remember("test/old", "assistant", "just now");
1365
+ is("the last few, oldest first", recall("test/old", { limit: 2 }).map((m) => m.content), ["yesterday-ish", "just now"]);
1366
+ is("and none older than the days given", recall("test/old", { days: 30 }).map((m) => m.content), ["yesterday-ish", "just now"]);
1367
+ is("which are still there when nothing limits the age", recall("test/old").length, 3);
1368
+
1369
+ // What a turn is shown is its channel's chatHistory.
1370
+ answers.push("Noted.");
1371
+ const { receive } = await import("#chloe/channels/shared.ts");
1372
+ const brief = agentFor(codeJob("unused", async () => ({})));
1373
+ await receive(brief, { channel: "test", chat: "c", thread: "test/old", from: { id: "1", name: "Me" }, text: "and today?", private: true }, { chatHistory: { messages: 1 } });
1374
+ const shownTo = lastAsked.filter((m) => m.role !== "system").map((m) => m.content);
1375
+ is("a channel's chatHistory is what a turn on it is shown", shownTo, ["just now", "and today?"]);
1376
+
1377
+ // What the model writes on its way to an answer is sent as it goes only when
1378
+ // the channel asks for it, and always before the answer.
1379
+ const look = { id: "1", type: "function", function: { name: "look_around", arguments: "{}" } };
1380
+ const onTheWay: string[] = [];
1381
+ const talk = (sendWhileWorking: boolean) =>
1382
+ receive(brief, { channel: "test", chat: "w", thread: "test/while", from: { id: "1", name: "Me" }, text: "how is it?", private: true },
1383
+ { sendWhileWorking }, { send: async (text) => void onTheWay.push(text) });
1384
+ answers.push({ content: "Let me check.", tool_calls: [look] }, "All fine.");
1385
+ const quiet = await talk(false);
1386
+ is("off, only the answer comes back", [onTheWay, quiet?.text], [[], "All fine."]);
1387
+ answers.push({ content: "Let me check.", tool_calls: [look] }, "All fine.");
1388
+ const chatty = await talk(true);
1389
+ is("on, what it said on the way is sent first", [onTheWay, chatty?.text], [["Let me check."], "All fine."]);
1390
+ is("and kept in the conversation", recall("test/while").map((m) => m.content).slice(-3), ["how is it?", "Let me check.", "All fine."]);
1391
+ }
1392
+
1393
+ {
1394
+ about("reading a web page");
1395
+
1396
+ const { htmlToText, isPrivate, readPage } = await import("@chloejs/core");
1397
+ const html =
1398
+ "<!doctype html><html><head><title>Wall &amp; chart</title><style>td{}</style></head><body>\n" +
1399
+ "<table>\n<tr><td><a href=\"report.php?section=Novice - under 900\">Novice</a></td>\n<td>239</td></tr>\n" +
1400
+ "<tr><td>Sapp,&nbspNalani</td><td>W&nbsp120</td></tr></table><script>alert(1)</script></body></html>";
1401
+ const read = htmlToText(html, "https://example.com/events/");
1402
+ is("the title is read", read.title, "Wall & chart");
1403
+ is(
1404
+ "a row is one line, a link keeps its full address, and scripts are gone",
1405
+ read.text,
1406
+ "[Novice](https://example.com/events/report.php?section=Novice%20-%20under%20900) | 239\nSapp, Nalani | W 120",
1407
+ );
1408
+ is("loopback is private", isPrivate("127.0.0.1"), true);
1409
+ is("a home network is private", isPrivate("192.168.1.20"), true);
1410
+ is("loopback written as IPv6 is private", isPrivate("::ffff:127.0.0.1"), true);
1411
+ is("loopback carried in IPv6 as hex is private", isPrivate("::ffff:7f00:1"), true);
1412
+ is("a home network translated to IPv6 is private", isPrivate("64:ff9b::c0a8:114"), true);
1413
+ is("link local IPv6 is private", isPrivate("fe80::1"), true);
1414
+ is("a public address is not", isPrivate("104.21.3.4"), false);
1415
+ is("a public IPv6 address is not", isPrivate("2606:4700::6810:84e5"), false);
1416
+ const hexLoopback = await readPage("http://[::ffff:7f00:1]:3067/").then(() => "read", (error: Error) => error.message);
1417
+ is("loopback in IPv6 hex is refused", hexLoopback, "[::ffff:7f00:1] is a private address, and those are not read.");
1418
+ const byName = await readPage("http://localhost:3067/").then(() => "read", (error: Error) => error.message);
1419
+ is("a name that resolves to loopback is refused when connecting", byName, "localhost is a private address, and those are not read.");
1420
+ const refused = await readPage("http://127.0.0.1:3067/").then(() => "read", (error: Error) => error.message);
1421
+ is("this box's own ports are refused", refused, "127.0.0.1 is a private address, and those are not read.");
1422
+ }
1423
+
1424
+ {
1425
+ about("a copy of the agents' database");
1426
+
1427
+ const { copyDatabase } = await import("@chloejs/core");
1428
+ const { DatabaseSync } = await import("node:sqlite");
1429
+ const { tmpdir } = await import("node:os");
1430
+ const to = join(tmpdir(), `copy-${process.pid}`, "agents.db");
1431
+ const copied = await copyDatabase(to);
1432
+ const opened = new DatabaseSync(to, { readOnly: true });
1433
+ const count = (from: typeof db) => (from.prepare("select count(*) as n from runs").get() as { n: number }).n;
1434
+ is("it is written where it was asked for", copied.path, to);
1435
+ is("it opens, and holds every run", count(opened), count(db));
1436
+ opened.close();
1437
+ await rm(join(tmpdir(), `copy-${process.pid}`), { recursive: true, force: true });
1438
+ }
1439
+
1440
+ {
1441
+ about("the login in front of the page");
1442
+
1443
+ const { createAccount, hasAccount, setCookie, signIn, signedIn } = await import("#chloe/serve/login.ts");
1444
+ const carrying = (cookie: string) => ({ headers: { cookie } }) as import("node:http").IncomingMessage;
1445
+
1446
+ is("a fresh copy has no account", hasAccount(), false);
1447
+ createAccount("somebody", "a long enough one");
1448
+ is("the first visit makes it", hasAccount(), true);
1449
+
1450
+ const refused = (() => {
1451
+ try {
1452
+ createAccount("nobody", "another long one");
1453
+ return "made a second";
1454
+ } catch (error) {
1455
+ return (error as Error).message;
1456
+ }
1457
+ })();
1458
+ is("and every visit after it is refused", refused, "An account already exists.");
1459
+
1460
+ const session = signIn("somebody", "a long enough one", "1.2.3.4");
1461
+ is("the right password signs in", signedIn(carrying(`chloe_session=${session}`)), true);
1462
+ is("a cookie somebody edited does not", signedIn(carrying(`chloe_session=${session.slice(0, -1)}x`)), false);
1463
+ is("no cookie does not", signedIn(carrying("")), false);
1464
+ is("signing out clears it", setCookie("", true).includes("Max-Age=0"), true);
1465
+
1466
+ // The same value said the other way, for a caller that is not a browser.
1467
+ const bearing = (authorization: string) => ({ headers: { authorization } }) as import("node:http").IncomingMessage;
1468
+ is("the same value as a bearer signs in", signedIn(bearing(`Bearer ${session}`)), true);
1469
+ is("and the word is not case sensitive", signedIn(bearing(`bearer ${session}`)), true);
1470
+ is("a bearer somebody edited does not", signedIn(bearing(`Bearer ${session.slice(0, -1)}x`)), false);
1471
+ is("an empty bearer does not", signedIn(bearing("Bearer ")), false);
1472
+ is("and another scheme does not", signedIn(bearing(`Basic ${session}`)), false);
1473
+
1474
+ const wrong = (() => {
1475
+ try {
1476
+ signIn("somebody", "not the password", "1.2.3.4");
1477
+ return "signed in";
1478
+ } catch (error) {
1479
+ return (error as Error).message;
1480
+ }
1481
+ })();
1482
+ is("the wrong password does not", wrong, "Wrong username or password.");
1483
+
1484
+ for (let tries = 0; tries < 5; tries++) {
1485
+ try {
1486
+ signIn("somebody", "not the password", "9.9.9.9");
1487
+ } catch {
1488
+ // Counting the failures is the point; the message is checked above.
1489
+ }
1490
+ }
1491
+ const locked = (() => {
1492
+ try {
1493
+ signIn("somebody", "a long enough one", "9.9.9.9");
1494
+ return "signed in";
1495
+ } catch (error) {
1496
+ return (error as Error).message;
1497
+ }
1498
+ })();
1499
+ is("guessing over and over locks that address out", locked.startsWith("Too many tries."), true);
1500
+ is("and only that one", Boolean(signIn("somebody", "a long enough one", "1.2.3.4")), true);
1501
+ }
1502
+
1503
+ {
1504
+ about("the API without a browser");
1505
+
1506
+ // About the runtime on its own, so the runtime's own site is the one being
1507
+ // asked. Whether a page package happens to be installed in this repo is not
1508
+ // what these are testing, and letting it decide would make them drift.
1509
+ process.env.CHLOE_PAGE = "builtin";
1510
+ const { serve } = await import("#chloe/serve/http.ts");
1511
+ const server = serve({
1512
+ host: "127.0.0.1",
1513
+ port: 0,
1514
+ agents: () => new Map(),
1515
+ clock: { fire() {} } as unknown as import("#chloe/core/clock.ts").Clock,
1516
+ channels: () => [],
1517
+ });
1518
+ await new Promise<void>((done) => server.once("listening", done));
1519
+ const at = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
1520
+
1521
+ const asked = await fetch(`${at}/api/account`);
1522
+ is("whether an account exists is answered with no session", [asked.status, await asked.json()], [200, { exists: true }]);
1523
+
1524
+ const shut = await fetch(`${at}/api/agents`);
1525
+ is("and everything else is still shut", [shut.status, await shut.json()], [401, { error: "Sign in first." }]);
1526
+
1527
+ const got = await fetch(`${at}/api/login`, {
1528
+ method: "POST",
1529
+ headers: { "content-type": "application/json" },
1530
+ body: JSON.stringify({ username: "somebody", password: "a long enough one" }),
1531
+ });
1532
+ const { token } = (await got.json()) as { token?: string };
1533
+ is("signing in hands back a token", typeof token === "string" && token.length > 0, true);
1534
+ is("and sets the cookie as well", (got.headers.get("set-cookie") ?? "").startsWith("chloe_session="), true);
1535
+
1536
+ const held = await fetch(`${at}/api/agents`, { headers: { authorization: `Bearer ${token ?? ""}` } });
1537
+ is("the token opens the door the cookie opens", held.status, 200);
1538
+
1539
+ const made = await fetch(`${at}/api/agents`, { headers: { authorization: "Bearer not.a.token" } });
1540
+ is("one this copy did not sign does not", made.status, 401);
1541
+
1542
+ // The site is the account's. A token opens the API and not a browser
1543
+ // session, so the page it would be shown is the way in instead.
1544
+ const root = await fetch(`${at}/`, { redirect: "manual" });
1545
+ is("the root sends somebody with no session to the way in", [root.status, root.headers.get("location")], [303, "/login"]);
1546
+ const header = await fetch(`${at}/`, { redirect: "manual", headers: { authorization: `Bearer ${token ?? ""}` } });
1547
+ is("the account's own session opens it, however it is carried", header.status, 200);
1548
+
1549
+ const signedIn = await fetch(`${at}/`, { redirect: "manual", headers: { cookie: `chloe_session=${token ?? ""}` } });
1550
+ is("as the cookie a browser sends", signedIn.status, 200);
1551
+ is("which says what is loaded", (await signedIn.text()).includes("agent"), true);
1552
+
1553
+ const docs = await fetch(`${at}/api`, { headers: { accept: "text/html" } });
1554
+ is("the docs are open, because they are about the API and not in it", docs.status, 200);
1555
+ const listed = (await (await fetch(`${at}/api`)).json()) as { path: string }[];
1556
+ is("and the same list comes back as JSON", listed.some((one) => one.path === "/api/agents/:name/chat"), true);
1557
+ is("every route it answers is in that list", listed.length > 15, true);
1558
+
1559
+ server.close();
1560
+ }
1561
+
1562
+ {
1563
+ about("a channel's own path, through the server");
1564
+
1565
+ process.env.CHLOE_PAGE = "builtin";
1566
+ const { serve } = await import("#chloe/serve/http.ts");
1567
+
1568
+ // A channel that is sent its messages, as telegram's webhook mode is, gets
1569
+ // its path handed to it before the login. The api channel is not one of
1570
+ // these any more, so this stands in for the shape rather than using it.
1571
+ let reached = 0;
1572
+ const reachable = agentFor(codeJob("unused", async () => ({})));
1573
+ const route = {
1574
+ path: "/chloe/v1/test/hook",
1575
+ async handle(_request: import("node:http").IncomingMessage, response: import("node:http").ServerResponse) {
1576
+ reached += 1;
1577
+ response.writeHead(200, { "content-type": "application/json" }).end("{}");
1578
+ },
1579
+ };
1580
+ const server = serve({
1581
+ host: "127.0.0.1",
1582
+ port: 0,
1583
+ agents: () => new Map([["test", reachable]]),
1584
+ clock: { fire() {}, running: () => [] } as unknown as import("#chloe/core/clock.ts").Clock,
1585
+ channels: () => [route],
1586
+ });
1587
+ await new Promise<void>((done) => server.once("listening", done));
1588
+ const at = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
1589
+
1590
+ is("the runtime hands a channel its path with no session at all", (await fetch(`${at}${route.path}`, { method: "POST", body: "{}" })).status, 200);
1591
+ is("and it really was the channel that answered", reached, 1);
1592
+
1593
+ // Only POST is handed over, so the same path asked any other way is not a
1594
+ // path this server has. It is not /api, so the site answers it.
1595
+ is("its path is not open to a GET", (await fetch(`${at}${route.path}`, { redirect: "manual" })).status, 303);
1596
+
1597
+ server.close();
1598
+ }
1599
+
1600
+ {
1601
+ about("what a job is started with");
1602
+
1603
+ const { work: runJob, checkInput, WrongInput } = await import("#chloe/core/steps.ts");
1604
+
1605
+ const takes = z.object({
1606
+ text: z.string().min(1),
1607
+ from: z.string().default("somewhere"),
1608
+ times: z.coerce.number().default(1),
1609
+ });
1610
+
1611
+ let saw: unknown;
1612
+ const reader = agentFor({
1613
+ ...codeJob("reading", async (w) => {
1614
+ saw = w.input;
1615
+ return { got: (w.input as { text: string }).text };
1616
+ }),
1617
+ input: takes,
1618
+ } as Job);
1619
+
1620
+ await runJob({ agent: reader, job: reader.jobs[0], input: { text: "a highlight" } });
1621
+ is("the job is handed what it was started with", saw, { text: "a highlight", from: "somewhere", times: 1 });
1622
+
1623
+ await runJob({ agent: reader, job: reader.jobs[0], input: { text: "x", from: "telegram", times: "3" } });
1624
+ is("a query string's strings are coerced by the shape", saw, { text: "x", from: "telegram", times: 3 });
1625
+
1626
+ // The point of checking before the run exists: the caller is told, rather
1627
+ // than left to read a failed run to find out.
1628
+ const refused = (sent: unknown) => {
1629
+ try {
1630
+ checkInput(reader.jobs[0], sent);
1631
+ return "allowed";
1632
+ } catch (error) {
1633
+ return error instanceof WrongInput ? "refused" : "wrong error";
1634
+ }
1635
+ };
1636
+ is("a missing required field is refused", refused({}), "refused");
1637
+ is("and so is the wrong type", refused({ text: 5 }), "refused");
1638
+ is("what fits is allowed", refused({ text: "fine" }), "allowed");
1639
+
1640
+ // A job that declares nothing takes nothing. Quietly dropping what somebody
1641
+ // sent would read as the job ignoring them.
1642
+ const plain = agentFor(codeJob("plain", async () => ({})));
1643
+ const sentAnyway = await runJob({ agent: plain, job: plain.jobs[0], input: { text: "hello" } })
1644
+ .then(() => "allowed")
1645
+ .catch((error: unknown) => (error instanceof WrongInput ? "refused" : "wrong error"));
1646
+ is("a job with no input shape is not started with one", sentAnyway, "refused");
1647
+ is("and starting it with nothing is fine", (await runJob({ agent: plain, job: plain.jobs[0] })).steps >= 0, true);
1648
+
1649
+ // Written on the run row rather than held in memory, which is what lets a
1650
+ // run that stopped to ask somebody come back to the same input.
1651
+ const kept = await runJob({ agent: reader, job: reader.jobs[0], input: { text: "kept" } });
1652
+ is("the run records what it was started with", JSON.parse(row(kept.runId).input), { text: "kept", from: "somewhere", times: 1 });
1653
+ is("and a run the clock started records nothing", row((await runJob({ agent: plain, job: plain.jobs[0] })).runId).input, "{}");
1654
+ }
1655
+
1656
+ {
1657
+ about("starting a job over the API");
1658
+
1659
+ const { serve } = await import("#chloe/serve/http.ts");
1660
+ const { apiChannel } = await import("#chloe/channels/api.ts");
1661
+ const { makeToken, forgetTokens } = await import("#chloe/serve/tokens.ts");
1662
+
1663
+ process.env.CHLOE_PAGE = "builtin";
1664
+ let started: unknown;
1665
+ const agent = agentFor({
1666
+ ...codeJob("reading", async (w) => {
1667
+ started = w.input;
1668
+ return {};
1669
+ }),
1670
+ input: z.object({ text: z.string().min(1), source: z.string().default("") }),
1671
+ } as Job);
1672
+ agent.channels = [apiChannel()];
1673
+
1674
+ const fired: { job: string; input: unknown; channel?: string }[] = [];
1675
+ const server = serve({
1676
+ host: "127.0.0.1",
1677
+ port: 0,
1678
+ agents: () => new Map([["test", agent]]),
1679
+ clock: {
1680
+ fire(_a: Agent, j: Job, input?: unknown, channel?: string) {
1681
+ fired.push({ job: j.id, input, channel });
1682
+ return Promise.resolve(undefined);
1683
+ },
1684
+ running: () => [],
1685
+ } as unknown as import("#chloe/core/clock.ts").Clock,
1686
+ channels: () => [],
1687
+ });
1688
+ await new Promise<void>((done) => server.once("listening", done));
1689
+ const at = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
1690
+
1691
+ forgetTokens();
1692
+ const { secret } = makeToken("a test");
1693
+ const start = (query: string, body?: string) =>
1694
+ fetch(`${at}/api/agents/test/job/reading${query}`, {
1695
+ method: "POST",
1696
+ headers: { "content-type": "application/json", authorization: `Bearer ${secret}` },
1697
+ ...(body === undefined ? {} : { body }),
1698
+ });
1699
+
1700
+ is("a query string starts it", (await start("?text=a+highlight&source=myapp")).status, 200);
1701
+ is("and is what the job is handed", fired.at(-1)?.input, { text: "a highlight", source: "myapp" });
1702
+ is("on the api channel", fired.at(-1)?.channel, "api");
1703
+
1704
+ is("a JSON body does too", (await start("", '{"text":"from a body"}')).status, 200);
1705
+ is("and wins where they overlap", (await start("?text=query", '{"text":"body"}')).status, 200);
1706
+ is("the body being the one that counts", (fired.at(-1)?.input as { text: string }).text, "body");
1707
+ const byHand = await fetch(`${at}/api/agents/test/job/reading?text=x`, {
1708
+ method: "POST",
1709
+ headers: { authorization: `Bearer ${secret}`, "x-chloe-channel": "terminal" },
1710
+ });
1711
+ is("npm run agent says it is the terminal", [byHand.status, fired.at(-1)?.channel], [200, "terminal"]);
1712
+ fired.pop();
1713
+
1714
+ // Started and not awaited, so a caller that sent the wrong thing has to be
1715
+ // told now or it never finds out.
1716
+ const wrong = await start("?source=myapp");
1717
+ is("input that does not fit is refused before anything runs", wrong.status, 400);
1718
+ is("with the reason", ((await wrong.json()) as { error: string }).error.includes("text"), true);
1719
+ is("and nothing was started", fired.length, 3);
1720
+
1721
+ is("a job that agent does not have is still a 404", (await start("").then(() => fetch(`${at}/api/agents/test/job/nope`, { method: "POST", headers: { authorization: `Bearer ${secret}` } }))).status, 404);
1722
+
1723
+ delete process.env.CHLOE_PAGE;
1724
+ server.close();
1725
+ }
1726
+
1727
+ {
1728
+ about("who a request is really from");
1729
+
1730
+ const { from } = await import("#chloe/serve/login.ts");
1731
+ const asking = (headers: Record<string, string>) =>
1732
+ from({ headers, socket: { remoteAddress: "127.0.0.1" } } as unknown as import("node:http").IncomingMessage);
1733
+
1734
+ is("with nothing in front, it is the socket", asking({}), "127.0.0.1");
1735
+
1736
+ // Every hop appends, so the end of the list is what the proxy in front saw
1737
+ // and the front of it is whatever the caller sent. Reading the front lets a
1738
+ // stranger choose which address gets locked out, including somebody else's.
1739
+ is("one proxy in front, and it is what that proxy saw", asking({ "x-forwarded-for": "203.0.113.7" }), "203.0.113.7");
1740
+ is("a chain reads from the end, not the start", asking({ "x-forwarded-for": "203.0.113.7, 172.68.1.1" }), "172.68.1.1");
1741
+ is("so a forged entry at the front is ignored", asking({ "x-forwarded-for": "1.2.3.4, 203.0.113.7" }), "203.0.113.7");
1742
+
1743
+ // Cloudflare overwrites this one rather than appending to it, so a client
1744
+ // cannot put anything in it. That makes it worth more than the list.
1745
+ is("Cloudflare's own header wins", asking({ "cf-connecting-ip": "203.0.113.9", "x-forwarded-for": "10.0.0.1, 172.68.1.1" }), "203.0.113.9");
1746
+ is("and a forged copy of it is still only the first entry of its own list", asking({ "cf-connecting-ip": "203.0.113.9, 1.2.3.4" }), "203.0.113.9");
1747
+ }
1748
+
1749
+ {
1750
+ about("an agent's memory, and the log of what was served");
1751
+
1752
+ const { mkdir: makeDir, writeFile: put, readFile: get } = await import("node:fs/promises");
1753
+ const { serve } = await import("#chloe/serve/http.ts");
1754
+ const { makeToken, forgetTokens } = await import("#chloe/serve/tokens.ts");
1755
+ const { memoryFolder } = await import("#chloe/load/load.ts");
1756
+
1757
+ const folder = `${process.env.AGENTS_STATE}/memory-under-test`;
1758
+ await makeDir(`${folder}/01_projects`, { recursive: true });
1759
+ await makeDir(`${folder}/static`, { recursive: true });
1760
+ await makeDir(`${folder}/.git`, { recursive: true });
1761
+ await put(
1762
+ `${folder}/01_projects/move.html`,
1763
+ '<!doctype html><link rel="stylesheet" href="/static/style.css"><a href="//elsewhere.example/x">x</a><h1>A project</h1>',
1764
+ );
1765
+ await put(`${folder}/static/style.css`, "h1 { color: red }");
1766
+ await put(`${folder}/.git/config`, "[core]");
1767
+ // An agent's own state folder can hold its credentials, and one here does.
1768
+ await makeDir(`${folder}/secrets`, { recursive: true });
1769
+ await put(`${folder}/secrets/key.txt`, "never shown");
1770
+ process.env.CHLOE_PAGE = "builtin";
1771
+
1772
+ // Every agent has a memory. Unsaid, it is the agent's own state folder,
1773
+ // which is where the memory tool has always written.
1774
+ is("unsaid, an agent's memory is its own state folder", memoryFolder("tempo"), `${process.env.AGENTS_STATE}/tempo`);
1775
+ is("said, it is wherever the agent says", memoryFolder("chloe", { folder: "/somewhere" }), "/somewhere");
1776
+
1777
+ const keeper: Agent = { ...agentFor(codeJob("unused", async () => ({}))), memory: { folder, label: "Private" } };
1778
+ const other: Agent = {
1779
+ ...agentFor(codeJob("unused", async () => ({}))),
1780
+ name: "other",
1781
+ memory: { folder: `${process.env.AGENTS_STATE}/other-has-never-written` },
1782
+ };
1783
+
1784
+ const server = serve({
1785
+ host: "127.0.0.1",
1786
+ port: 0,
1787
+ agents: () => new Map([["test", keeper], ["other", other]]),
1788
+ clock: { fire() {}, running: () => [] } as unknown as import("#chloe/core/clock.ts").Clock,
1789
+ channels: () => [],
1790
+ });
1791
+ await new Promise<void>((done) => server.once("listening", done));
1792
+ const at = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
1793
+
1794
+ const { token } = (await (
1795
+ await fetch(`${at}/api/login`, {
1796
+ method: "POST",
1797
+ headers: { "content-type": "application/json" },
1798
+ body: JSON.stringify({ username: "somebody", password: "a long enough one" }),
1799
+ })
1800
+ ).json()) as { token: string };
1801
+ const as = { cookie: `chloe_session=${token}` };
1802
+ const file = (path: string, who = "test") =>
1803
+ fetch(`${at}/api/agents/${who}/memory/file?path=${encodeURIComponent(path)}`, { headers: as });
1804
+
1805
+ // secrets/ is there and is left out, and the tree still comes back. It used
1806
+ // to stop the whole listing, which made a memory with credentials in it show
1807
+ // as a 500 and nothing else.
1808
+ is("the tree is what is in the folder, less what can never be opened", ((await (await fetch(`${at}/api/agents/test/memory`, { headers: as })).json()) as { name: string }[]).map((one) => one.name), ["01_projects", "static"]);
1809
+ is("a file reads back for editing", ((await (await file("01_projects/move.html")).json()) as { content: string }).content.includes("A project"), true);
1810
+ is("an agent says what it calls its memory", ((await (await fetch(`${at}/api/agents/test`, { headers: as })).json()) as { memory: string }).memory, "Private");
1811
+ is("and one that says nothing calls it Memory", ((await (await fetch(`${at}/api/agents/other`, { headers: as })).json()) as { memory: string }).memory, "Memory");
1812
+ is("an agent that has never written anything has an empty memory, not an error", await (await fetch(`${at}/api/agents/other/memory`, { headers: as })).json(), []);
1813
+
1814
+ // A path that tries to leave is answered exactly like one that is not there.
1815
+ const out = await file("../../../etc/passwd");
1816
+ is("a path out of the folder is refused", out.status, 404);
1817
+ is("and says nothing about where the folder is", ((await out.json()) as { error: string }).error.includes(folder), false);
1818
+ is("the same for .git inside it", (await file(".git/config")).status, 404);
1819
+ is("and for secrets/, by name as well", (await file("secrets/key.txt")).status, 404);
1820
+
1821
+ forgetTokens();
1822
+ const { secret } = makeToken("for a test");
1823
+ is("a token cannot read a memory at all", (await fetch(`${at}/api/agents/test/memory`, { headers: { authorization: `Bearer ${secret}` } })).status, 403);
1824
+ is("nor get a pass to one", (await fetch(`${at}/api/agents/test/memory/pass`, { headers: { authorization: `Bearer ${secret}` } })).status, 403);
1825
+
1826
+ // The frame. This is the part the whole viewer's safety rests on.
1827
+ const { at: under } = (await (await fetch(`${at}/api/agents/test/memory/pass`, { headers: as })).json()) as { at: string };
1828
+ const shown = await fetch(`${at}${under}/01_projects/move.html`);
1829
+ const policy = shown.headers.get("content-security-policy") ?? "";
1830
+ const html = await shown.text();
1831
+ is("a pass shows the file with no cookie at all", shown.status, 200);
1832
+ is("sandboxed, so its script cannot reach the page or the API", policy.startsWith("sandbox allow-scripts"), true);
1833
+ is("and it cannot open a connection to send anything out", policy.includes("connect-src 'none'"), true);
1834
+ is("only this site may frame it", policy.includes("frame-ancestors 'self'"), true);
1835
+ is("and nothing sniffs it into something it is not", shown.headers.get("x-content-type-options"), "nosniff");
1836
+ is("a root-relative link means the top of the memory, under the same pass", html.includes(`href="${under}/static/style.css"`), true);
1837
+ is("but a link to another host is left alone", html.includes('href="//elsewhere.example/x"'), true);
1838
+ is("which is where the stylesheet it links really is", (await (await fetch(`${at}${under}/static/style.css`)).text()), "h1 { color: red }");
1839
+
1840
+ is("a made-up pass is refused", (await fetch(`${at}/memory/not-a-pass/01_projects/move.html`)).status, 403);
1841
+ const theirs = (await (await fetch(`${at}/api/agents/other/memory/pass`, { headers: as })).json()) as { at: string };
1842
+ is("and one agent's pass does not open another's memory", (await fetch(`${at}${theirs.at}/01_projects/move.html`)).status, 404);
1843
+ // Sent as raw HTTP, because fetch resolves ".." itself before sending and
1844
+ // would ask for a different address altogether. Encoded dots are what an
1845
+ // attacker actually sends, since they arrive at the server intact.
1846
+ await put(`${process.env.AGENTS_STATE}/NOT-IN-MEMORY.txt`, "never shown");
1847
+ const { request: send } = await import("node:http");
1848
+ const port = (server.address() as { port: number }).port;
1849
+ const rawly = (path: string) =>
1850
+ new Promise<{ status: number; body: string }>((done) => {
1851
+ send({ host: "127.0.0.1", port, path }, (answer) => {
1852
+ let body = "";
1853
+ answer.on("data", (chunk) => (body += chunk));
1854
+ answer.on("end", () => done({ status: answer.statusCode ?? 0, body }));
1855
+ }).end();
1856
+ });
1857
+ for (const walk of ["%2e%2e%2fNOT-IN-MEMORY.txt", "..%2fNOT-IN-MEMORY.txt", "%2e%2e%2f%2e%2e%2fetc%2fpasswd"]) {
1858
+ const tried = await rawly(`${under}/${walk}`);
1859
+ is(`a pass does not walk out of the folder: ${walk}`, [tried.status, tried.body.includes("never shown")], [404, false]);
1860
+ }
1861
+
1862
+ // What was served is written down, before it is served. That includes what a
1863
+ // frame loaded, not only what somebody clicked.
1864
+ const log = (await (await fetch(`${at}/api/agents/test/memory/log`, { headers: as })).json()) as { what: string; path: string }[];
1865
+ is("every file read or served is in the log", log.filter((one) => one.what === "serve").map((one) => one.path), ["static/style.css", "01_projects/move.html"]);
1866
+ is("and a refused path put nothing in it", log.some((one) => one.path.includes("passwd")), false);
1867
+ is("the log is kept outside the memory it records", (await get(`${process.env.AGENTS_STATE}/memory-audit/test.jsonl`, "utf8")).length > 0, true);
1868
+
1869
+ // Moving and deleting stay inside too.
1870
+ await fetch(`${at}/api/agents/test/memory/rename`, {
1871
+ method: "POST",
1872
+ headers: { ...as, "content-type": "application/json" },
1873
+ body: JSON.stringify({ from: "01_projects/move.html", to: "04_archive/move.html" }),
1874
+ });
1875
+ is("a rename moves the file", (await file("04_archive/move.html")).status, 200);
1876
+ const escape = await fetch(`${at}/api/agents/test/memory/rename`, {
1877
+ method: "POST",
1878
+ headers: { ...as, "content-type": "application/json" },
1879
+ body: JSON.stringify({ from: "04_archive/move.html", to: "../../gone.html" }),
1880
+ });
1881
+ is("but not out of the folder", escape.status, 400);
1882
+ const whole = await fetch(`${at}/api/agents/test/memory/delete`, {
1883
+ method: "POST",
1884
+ headers: { ...as, "content-type": "application/json" },
1885
+ body: JSON.stringify({ path: "." }),
1886
+ });
1887
+ is("and the memory itself cannot be deleted from here", whole.status, 400);
1888
+
1889
+ // Source control. A memory that sits inside somebody else's repository is
1890
+ // not a repository itself, whatever git says when asked from inside it.
1891
+ const { execFileSync } = await import("node:child_process");
1892
+ const outer = `${process.env.AGENTS_STATE}/outer-repo`;
1893
+ await makeDir(`${outer}/agents`, { recursive: true });
1894
+ await makeDir(`${outer}/data/tempo`, { recursive: true });
1895
+ const quiet = { cwd: outer, stdio: "ignore" as const };
1896
+ execFileSync("git", ["init", "-q", "-b", "main"], quiet);
1897
+ execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", "--allow-empty", "-m", "start"], quiet);
1898
+ await put(`${outer}/agents/someone-elses-work.ts`, "half done");
1899
+ await put(`${outer}/data/tempo/journal.md`, "a day");
1900
+
1901
+ const inside: Agent = { ...agentFor(codeJob("unused", async () => ({}))), name: "inside", memory: { folder: `${outer}/data/tempo` } };
1902
+ const { memoryGit, memoryCommit } = await import("#chloe/serve/memory.ts");
1903
+ is("a memory inside another repo is not a repo", (await memoryGit(inside)).repo, false);
1904
+ const tried = await memoryCommit(inside, "tidy up", "test").then(() => "committed", (error: Error) => error.message);
1905
+ is("so commit refuses rather than committing that repo's work", tried, "This memory is not a git repository.");
1906
+ is("and nothing was committed in the other repo", execFileSync("git", ["rev-list", "--count", "HEAD"], { cwd: outer, encoding: "utf8" }).trim(), "1");
1907
+ is("whose work is still sitting there uncommitted", execFileSync("git", ["status", "--porcelain", "--untracked-files=all"], { cwd: outer, encoding: "utf8" }).includes("agents/someone-elses-work.ts"), true);
1908
+
1909
+ // One that is the top of its own repository is one.
1910
+ execFileSync("git", ["init", "-q", "-b", "main"], { cwd: `${outer}/data/tempo`, stdio: "ignore" });
1911
+ is("a memory that is the top of its own repo is one", (await memoryGit(inside)).repo, true);
1912
+
1913
+ delete process.env.CHLOE_PAGE;
1914
+ server.close();
1915
+ }
1916
+
1917
+ {
1918
+ about("a page a package offers");
1919
+
1920
+ const { installedPage, pageIn } = await import("#chloe/serve/page.ts");
1921
+
1922
+ // A node_modules of its own, holding one package that declares a page. The
1923
+ // runtime never names a package: it looks for the declaration.
1924
+ const modules = await mkdtemp(join(tmpdir(), "chloe-page-"));
1925
+ await mkdir(`${modules}/zod`, { recursive: true });
1926
+ await writeFile(`${modules}/zod/package.json`, JSON.stringify({ name: "zod" }));
1927
+ await mkdir(`${modules}/some-dashboard/dist`, { recursive: true });
1928
+ await writeFile(`${modules}/some-dashboard/package.json`, JSON.stringify({ name: "some-dashboard", chloePage: "dist" }));
1929
+ await writeFile(`${modules}/some-dashboard/dist/index.html`, '<div id="app"></div>');
1930
+ await writeFile(`${modules}/some-dashboard/dist/page.js`, "");
1931
+
1932
+ const found = pageIn(modules);
1933
+ is("it finds the package that declares one", found?.name, "some-dashboard");
1934
+ is("and serves the folder that package named", found?.dir.endsWith("/dist"), true);
1935
+ is("a folder with no packages offers nothing", pageIn(`${modules}/nowhere`), null);
1936
+
1937
+ process.env.CHLOE_PAGE = "builtin";
1938
+ is("and it can be told to use the runtime's own instead", installedPage(), null);
1939
+ delete process.env.CHLOE_PAGE;
1940
+
1941
+ // What it serves. A file that is there is the file. Everything else is one of
1942
+ // the page's own addresses, including one with a dot in it: an address inside
1943
+ // the page can name a file that lives somewhere else entirely.
1944
+ const { servePageFile } = await import("#chloe/serve/page.ts");
1945
+ const served = async (path: string) => {
1946
+ let type = "";
1947
+ let body = "";
1948
+ const response = {
1949
+ writeHead: (_status: number, headers: Record<string, string>) => ((type = headers["content-type"]), response),
1950
+ end: (chunk: Buffer | string) => void (body = String(chunk)),
1951
+ };
1952
+ await servePageFile(response as never, found!, path);
1953
+ return { type, page: body.includes('id="app"') };
1954
+ };
1955
+ is("its own script is its own script", (await served("/page.js")).type.startsWith("text/javascript"), true);
1956
+ is("an address of the page's is the page", (await served("/agents/chloe/log")).page, true);
1957
+ is("and so is one that names a file somewhere else", (await served("/agents/chloe/memory/02_areas/chess/curriculum.html")).page, true);
1958
+ is("but it will not hand out a file from outside its folder", (await served("/../../package.json")).page, true);
1959
+ await rm(modules, { recursive: true, force: true });
1960
+ }
1961
+
1962
+ {
1963
+ about("what the page adds to a note");
1964
+
1965
+ const { withHead } = await import("#chloe/serve/memory.ts");
1966
+ const add = '<link rel="stylesheet" href="/notes.css">';
1967
+ is("first inside the note's own head", withHead("<html><head><title>x</title></head></html>", add), `<html><head>\n${add}<title>x</title></head></html>`);
1968
+ is("not inside a header that is not a head", withHead("<!doctype html><header>h</header>", add), `<!doctype html>\n${add}<header>h</header>`);
1969
+ is("a head of its own when there is html and no head", withHead('<html lang="en"><p>x</p></html>', add), `<html lang="en">\n<head>${add}</head><p>x</p></html>`);
1970
+ is("and nothing at all when the page adds nothing", withHead("<p>x</p>", ""), "<p>x</p>");
1971
+ }
1972
+
1973
+ await rm(process.env.AGENTS_STATE, { recursive: true, force: true });
1974
+ gateway.close();
1975
+ console.log(failed() === 0 ? "\nAll clear." : `\n${failed()} to fix above.`);
1976
+ process.exit(failed() === 0 ? 0 : 1);