@openparachute/agent 0.2.2 → 0.2.3-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.parachute/module.json +3 -3
- package/package.json +4 -1
- package/src/transports/vault.ts +40 -22
- package/web/ui/dist/assets/index-5KEwEhfi.js +60 -0
- package/web/ui/dist/index.html +1 -1
- package/src/_parked/interactive-spawn.test.ts +0 -324
- package/src/_parked/interactive-spawn.ts +0 -701
- package/src/agent-defs.test.ts +0 -1504
- package/src/agent-mcp-config.test.ts +0 -115
- package/src/agents.test.ts +0 -360
- package/src/auth.test.ts +0 -46
- package/src/backends/attached-queue.test.ts +0 -376
- package/src/backends/programmatic.test.ts +0 -1715
- package/src/backends/registry.test.ts +0 -1494
- package/src/backends/stream-json.test.ts +0 -570
- package/src/channel-backend-wiring.test.ts +0 -237
- package/src/credentials.test.ts +0 -274
- package/src/cron.test.ts +0 -342
- package/src/daemon-agent-def-api.test.ts +0 -166
- package/src/daemon-agent-defs-api.test.ts +0 -953
- package/src/daemon-agent-env-api.test.ts +0 -338
- package/src/daemon-attached-queue-store.test.ts +0 -65
- package/src/daemon-config-api.test.ts +0 -962
- package/src/daemon-jobs-api.test.ts +0 -271
- package/src/daemon-vault-chat.test.ts +0 -250
- package/src/daemon.test.ts +0 -746
- package/src/def-vaults.test.ts +0 -136
- package/src/delivery-state.test.ts +0 -110
- package/src/effective-env.test.ts +0 -114
- package/src/grants.test.ts +0 -638
- package/src/hub-jwt.test.ts +0 -161
- package/src/jobs.test.ts +0 -245
- package/src/mcp-http.test.ts +0 -265
- package/src/mint-token.test.ts +0 -152
- package/src/module-manifest.test.ts +0 -158
- package/src/programmatic-wiring.test.ts +0 -838
- package/src/registry.test.ts +0 -227
- package/src/resolve-port.test.ts +0 -64
- package/src/routing.test.ts +0 -184
- package/src/runner.test.ts +0 -506
- package/src/sandbox/config.test.ts +0 -150
- package/src/sandbox/egress.test.ts +0 -113
- package/src/sandbox/live-seatbelt.test.ts +0 -277
- package/src/sandbox/mounts.test.ts +0 -154
- package/src/sandbox/sandbox.test.ts +0 -168
- package/src/services-manifest.test.ts +0 -106
- package/src/spa-serve.test.ts +0 -116
- package/src/spawn-agent-cli.test.ts +0 -172
- package/src/spawn-agent.test.ts +0 -1218
- package/src/spawn-deps.test.ts +0 -54
- package/src/terminal-assets.test.ts +0 -50
- package/src/terminal.test.ts +0 -530
- package/src/transports/http-ui.test.ts +0 -455
- package/src/transports/telegram.test.ts +0 -174
- package/src/transports/vault.test.ts +0 -2011
- package/src/ui-kit.test.ts +0 -178
- package/web/ui/dist/assets/index-VFETBk0a.js +0 -60
- package/web/ui/tsconfig.json +0 -21
|
@@ -1,271 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Integration tests for the scheduled-jobs API (`/api/jobs*`) on the REAL daemon
|
|
3
|
-
* fetch handler (runner, design 2026-06-17). They cover:
|
|
4
|
-
*
|
|
5
|
-
* - auth: all routes require `agent:admin` (no token → 401; agent:read → 403);
|
|
6
|
-
* - GET /api/jobs → lists `#agent/job` notes across the vault channels;
|
|
7
|
-
* - POST /api/jobs → 400 on bad cron / unknown / non-vault channel;
|
|
8
|
-
* 200 + writes a #agent/job note on success;
|
|
9
|
-
* - POST /api/jobs/:id/run → fires now (injects an inbound #agent/message note);
|
|
10
|
-
* - DELETE /api/jobs/:id → deletes the job note.
|
|
11
|
-
*
|
|
12
|
-
* The vault REST API is stubbed via `globalThis.fetch` (no live vault); the hub
|
|
13
|
-
* JWT validator is stubbed (sentinel tokens → fixed scopes) so the accept paths
|
|
14
|
-
* run without a live hub/JWKS. Mirrors daemon-config-api.test.ts's approach.
|
|
15
|
-
*/
|
|
16
|
-
import { describe, test, expect, mock, afterEach } from "bun:test";
|
|
17
|
-
import { HubJwtError, looksLikeJwt } from "@openparachute/scope-guard";
|
|
18
|
-
|
|
19
|
-
const ADMIN_TOKEN = "test-admin-token";
|
|
20
|
-
const READ_TOKEN = "test-read-token";
|
|
21
|
-
mock.module("./hub-jwt.ts", () => ({
|
|
22
|
-
AGENT_AUDIENCE: "agent",
|
|
23
|
-
CHANNEL_AUDIENCE: "channel",
|
|
24
|
-
async validateHubJwt(token: string) {
|
|
25
|
-
const base = { sub: "test", aud: "agent", jti: undefined, clientId: undefined, vaultScope: undefined };
|
|
26
|
-
if (token === ADMIN_TOKEN) return { ...base, scopes: ["agent:admin"] };
|
|
27
|
-
if (token === READ_TOKEN) return { ...base, scopes: ["agent:read"] };
|
|
28
|
-
throw new HubJwtError("issuer", "invalid token");
|
|
29
|
-
},
|
|
30
|
-
HubJwtError,
|
|
31
|
-
looksLikeJwt,
|
|
32
|
-
resetJwksCache() {},
|
|
33
|
-
resetRevocationCache() {},
|
|
34
|
-
}));
|
|
35
|
-
|
|
36
|
-
import { createFetchHandler } from "./daemon.ts";
|
|
37
|
-
import { ClientRegistry } from "./routing.ts";
|
|
38
|
-
import { VaultTransport } from "./transports/vault.ts";
|
|
39
|
-
import type { Channel } from "./registry.ts";
|
|
40
|
-
import type { TransportContext } from "./transport.ts";
|
|
41
|
-
|
|
42
|
-
const realFetch = globalThis.fetch;
|
|
43
|
-
afterEach(() => {
|
|
44
|
-
globalThis.fetch = realFetch;
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
const adminAuth = { authorization: "Bearer " + ADMIN_TOKEN } as const;
|
|
48
|
-
const readAuth = { authorization: "Bearer " + READ_TOKEN } as const;
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* A live channels map: one vault channel ("eng") + one telegram-shaped channel
|
|
52
|
-
* stub ("tg", non-vault) so the non-vault rejection path is exercised. The vault
|
|
53
|
-
* REST calls are routed through the `vaultFetch` stub the caller installs.
|
|
54
|
-
*/
|
|
55
|
-
function buildServer() {
|
|
56
|
-
const registry = new ClientRegistry();
|
|
57
|
-
const channels = new Map<string, Channel>();
|
|
58
|
-
const eng = new VaultTransport({ vault: "default", vaultUrl: "http://127.0.0.1:1940", token: "vtok" });
|
|
59
|
-
const ctx: TransportContext = { channel: "eng", emit() {}, emitPermissionVerdict() {} };
|
|
60
|
-
void eng.start(ctx);
|
|
61
|
-
channels.set("eng", { name: "eng", transport: eng, entry: { name: "eng", transport: "vault", config: { vault: "default" } } });
|
|
62
|
-
|
|
63
|
-
// A minimal non-vault transport stub for the "non-vault channel rejected" case.
|
|
64
|
-
const tgTransport = {
|
|
65
|
-
kind: "telegram",
|
|
66
|
-
async start() {},
|
|
67
|
-
async stop() {},
|
|
68
|
-
async reply() { return { sent: [] }; },
|
|
69
|
-
};
|
|
70
|
-
channels.set("tg", { name: "tg", transport: tgTransport as unknown as Channel["transport"], entry: { name: "tg", transport: "telegram" } });
|
|
71
|
-
|
|
72
|
-
const srv = Bun.serve({ port: 0, hostname: "127.0.0.1", idleTimeout: 0, fetch: createFetchHandler(channels, registry) });
|
|
73
|
-
return { srv, base: `http://127.0.0.1:${srv.port}`, channels };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Install a fetch stub that intercepts ONLY vault REST calls (port 1940 — the
|
|
78
|
-
* VaultTransport's `vaultUrl`) and records them + returns canned responses.
|
|
79
|
-
* Requests to ANY other URL — crucially the test client's own calls to the
|
|
80
|
-
* loopback test server — pass through to the real fetch, so overriding
|
|
81
|
-
* `globalThis.fetch` doesn't swallow the request under test.
|
|
82
|
-
*/
|
|
83
|
-
function stubVault(handler: (url: string, init: RequestInit) => Response) {
|
|
84
|
-
const calls: { url: string; init: RequestInit }[] = [];
|
|
85
|
-
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
86
|
-
const u = String(url);
|
|
87
|
-
if (u.includes(":1940/")) {
|
|
88
|
-
calls.push({ url: u, init: init ?? {} });
|
|
89
|
-
return handler(u, init ?? {});
|
|
90
|
-
}
|
|
91
|
-
return realFetch(url as Parameters<typeof fetch>[0], init);
|
|
92
|
-
}) as typeof fetch;
|
|
93
|
-
return calls;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
describe("/api/jobs — auth", () => {
|
|
97
|
-
test("no token → 401", async () => {
|
|
98
|
-
const { srv, base } = buildServer();
|
|
99
|
-
try {
|
|
100
|
-
const res = await fetch(`${base}/api/jobs`);
|
|
101
|
-
expect(res.status).toBe(401);
|
|
102
|
-
} finally { srv.stop(true); }
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
test("agent:read (insufficient) → 403", async () => {
|
|
106
|
-
const { srv, base } = buildServer();
|
|
107
|
-
try {
|
|
108
|
-
const res = await fetch(`${base}/api/jobs`, { headers: readAuth });
|
|
109
|
-
expect(res.status).toBe(403);
|
|
110
|
-
} finally { srv.stop(true); }
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
describe("GET /api/jobs — list", () => {
|
|
115
|
-
test("lists #agent/job notes from the vault", async () => {
|
|
116
|
-
const { srv, base } = buildServer();
|
|
117
|
-
stubVault(() =>
|
|
118
|
-
new Response(
|
|
119
|
-
JSON.stringify([
|
|
120
|
-
{ id: "Channels/eng/jobs/m", content: "go", metadata: { channel: "eng", cron: "0 9 * * *", enabled: "true", createdAt: "t0" } },
|
|
121
|
-
]),
|
|
122
|
-
{ status: 200, headers: { "content-type": "application/json" } },
|
|
123
|
-
),
|
|
124
|
-
);
|
|
125
|
-
try {
|
|
126
|
-
const res = await fetch(`${base}/api/jobs`, { headers: adminAuth });
|
|
127
|
-
expect(res.status).toBe(200);
|
|
128
|
-
const body = await res.json();
|
|
129
|
-
expect(body.jobs).toHaveLength(1);
|
|
130
|
-
expect(body.jobs[0]).toMatchObject({ id: "Channels/eng/jobs/m", channel: "eng", message: "go" });
|
|
131
|
-
} finally { srv.stop(true); }
|
|
132
|
-
});
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
describe("POST /api/jobs — create + validation", () => {
|
|
136
|
-
test("bad cron → 400", async () => {
|
|
137
|
-
const { srv, base } = buildServer();
|
|
138
|
-
try {
|
|
139
|
-
const res = await fetch(`${base}/api/jobs`, {
|
|
140
|
-
method: "POST",
|
|
141
|
-
headers: { "content-type": "application/json", ...adminAuth },
|
|
142
|
-
body: JSON.stringify({ id: "x", channel: "eng", message: "m", schedule: { cron: "99 9 * * *" } }),
|
|
143
|
-
});
|
|
144
|
-
expect(res.status).toBe(400);
|
|
145
|
-
expect((await res.json()).error).toMatch(/cron/);
|
|
146
|
-
} finally { srv.stop(true); }
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
test("unknown channel → 400", async () => {
|
|
150
|
-
const { srv, base } = buildServer();
|
|
151
|
-
try {
|
|
152
|
-
const res = await fetch(`${base}/api/jobs`, {
|
|
153
|
-
method: "POST",
|
|
154
|
-
headers: { "content-type": "application/json", ...adminAuth },
|
|
155
|
-
body: JSON.stringify({ id: "x", channel: "ghost", message: "m", schedule: { cron: "0 9 * * *" } }),
|
|
156
|
-
});
|
|
157
|
-
expect(res.status).toBe(400);
|
|
158
|
-
expect((await res.json()).error).toMatch(/unknown channel/);
|
|
159
|
-
} finally { srv.stop(true); }
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
test("non-vault channel → 400", async () => {
|
|
163
|
-
const { srv, base } = buildServer();
|
|
164
|
-
try {
|
|
165
|
-
const res = await fetch(`${base}/api/jobs`, {
|
|
166
|
-
method: "POST",
|
|
167
|
-
headers: { "content-type": "application/json", ...adminAuth },
|
|
168
|
-
body: JSON.stringify({ id: "x", channel: "tg", message: "m", schedule: { cron: "0 9 * * *" } }),
|
|
169
|
-
});
|
|
170
|
-
expect(res.status).toBe(400);
|
|
171
|
-
expect((await res.json()).error).toMatch(/not a vault channel/);
|
|
172
|
-
} finally { srv.stop(true); }
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
test("valid → 200 + writes a #agent/job note", async () => {
|
|
176
|
-
const { srv, base } = buildServer();
|
|
177
|
-
const calls = stubVault((url, init) => {
|
|
178
|
-
// The job POST is to /api/notes; everything else (ensureSchema PUTs) is benign.
|
|
179
|
-
if (url.endsWith("/api/notes") && init.method === "POST") {
|
|
180
|
-
return new Response(JSON.stringify({ id: "Channels/eng/jobs/x" }), { status: 201, headers: { "content-type": "application/json" } });
|
|
181
|
-
}
|
|
182
|
-
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
|
|
183
|
-
});
|
|
184
|
-
try {
|
|
185
|
-
const res = await fetch(`${base}/api/jobs`, {
|
|
186
|
-
method: "POST",
|
|
187
|
-
headers: { "content-type": "application/json", ...adminAuth },
|
|
188
|
-
body: JSON.stringify({ id: "x", channel: "eng", message: " do it ", schedule: { cron: "0 9 * * *", tz: "UTC" } }),
|
|
189
|
-
});
|
|
190
|
-
expect(res.status).toBe(200);
|
|
191
|
-
const body = await res.json();
|
|
192
|
-
expect(body.ok).toBe(true);
|
|
193
|
-
expect(body.job.id).toBe("x"); // the operator slug stays the id
|
|
194
|
-
expect(body.job.noteId).toBe("Channels/eng/jobs/x"); // vault note id for addressing
|
|
195
|
-
const post = calls.find((c) => c.url.endsWith("/api/notes") && c.init.method === "POST")!;
|
|
196
|
-
const sent = JSON.parse(String(post.init.body));
|
|
197
|
-
expect(sent.tags).toEqual(["#agent/job"]);
|
|
198
|
-
expect(sent.content).toBe("do it"); // trimmed
|
|
199
|
-
expect(sent.metadata.enabled).toBe("true");
|
|
200
|
-
expect(sent.metadata.jobId).toBe("x");
|
|
201
|
-
} finally { srv.stop(true); }
|
|
202
|
-
});
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
describe("POST /api/jobs/:id/run — fire now", () => {
|
|
206
|
-
test("injects an inbound #agent/message note + returns ok", async () => {
|
|
207
|
-
const { srv, base } = buildServer();
|
|
208
|
-
const calls = stubVault((url, init) => {
|
|
209
|
-
if (url.includes("/api/notes") && (init.method ?? "GET") === "GET") {
|
|
210
|
-
// The store's listAll → return the job to find.
|
|
211
|
-
return new Response(
|
|
212
|
-
JSON.stringify([{ id: "Channels/eng/jobs/x", content: "do it", metadata: { channel: "eng", cron: "0 9 * * *", enabled: "true", createdAt: "t0" } }]),
|
|
213
|
-
{ status: 200, headers: { "content-type": "application/json" } },
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
// The inject POST (and any patch).
|
|
217
|
-
return new Response(JSON.stringify({ id: "inbound-1" }), { status: 201, headers: { "content-type": "application/json" } });
|
|
218
|
-
});
|
|
219
|
-
try {
|
|
220
|
-
// The "id" the route addresses is the vault NOTE id returned by the list.
|
|
221
|
-
const res = await fetch(`${base}/api/jobs/${encodeURIComponent("Channels/eng/jobs/x")}/run`, { method: "POST", headers: adminAuth });
|
|
222
|
-
expect(res.status).toBe(200);
|
|
223
|
-
expect((await res.json()).ok).toBe(true);
|
|
224
|
-
// The injected note is INBOUND with the #agent/message tags.
|
|
225
|
-
const inject = calls.find((c) => c.url.endsWith("/api/notes") && c.init.method === "POST")!;
|
|
226
|
-
const sent = JSON.parse(String(inject.init.body));
|
|
227
|
-
expect(sent.tags).toEqual(["#agent/message", "#agent/message/inbound"]);
|
|
228
|
-
expect(sent.metadata.sender).toBe("runner:Channels/eng/jobs/x");
|
|
229
|
-
} finally { srv.stop(true); }
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
test("unknown job id → 404", async () => {
|
|
233
|
-
const { srv, base } = buildServer();
|
|
234
|
-
stubVault(() => new Response(JSON.stringify([]), { status: 200, headers: { "content-type": "application/json" } }));
|
|
235
|
-
try {
|
|
236
|
-
const res = await fetch(`${base}/api/jobs/nope/run`, { method: "POST", headers: adminAuth });
|
|
237
|
-
expect(res.status).toBe(404);
|
|
238
|
-
} finally { srv.stop(true); }
|
|
239
|
-
});
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
describe("DELETE /api/jobs/:id", () => {
|
|
243
|
-
test("deletes the job note", async () => {
|
|
244
|
-
const { srv, base } = buildServer();
|
|
245
|
-
const calls = stubVault((url, init) => {
|
|
246
|
-
if (url.includes("/api/notes") && (init.method ?? "GET") === "GET") {
|
|
247
|
-
return new Response(
|
|
248
|
-
JSON.stringify([{ id: "Channels/eng/jobs/x", content: "go", metadata: { channel: "eng", cron: "0 9 * * *", enabled: "true" } }]),
|
|
249
|
-
{ status: 200, headers: { "content-type": "application/json" } },
|
|
250
|
-
);
|
|
251
|
-
}
|
|
252
|
-
return new Response(null, { status: 204 });
|
|
253
|
-
});
|
|
254
|
-
try {
|
|
255
|
-
const res = await fetch(`${base}/api/jobs/Channels%2Feng%2Fjobs%2Fx`, { method: "DELETE", headers: adminAuth });
|
|
256
|
-
expect(res.status).toBe(200);
|
|
257
|
-
expect((await res.json()).removed).toBe(true);
|
|
258
|
-
expect(calls.some((c) => c.init.method === "DELETE")).toBe(true);
|
|
259
|
-
} finally { srv.stop(true); }
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
test("deleting an absent job is idempotent (removed:false)", async () => {
|
|
263
|
-
const { srv, base } = buildServer();
|
|
264
|
-
stubVault(() => new Response(JSON.stringify([]), { status: 200, headers: { "content-type": "application/json" } }));
|
|
265
|
-
try {
|
|
266
|
-
const res = await fetch(`${base}/api/jobs/gone`, { method: "DELETE", headers: adminAuth });
|
|
267
|
-
expect(res.status).toBe(200);
|
|
268
|
-
expect((await res.json()).removed).toBe(false);
|
|
269
|
-
} finally { srv.stop(true); }
|
|
270
|
-
});
|
|
271
|
-
});
|
|
@@ -1,250 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Vault-backed chat — daemon route tests for the read/send the built-in chat uses
|
|
3
|
-
* against a VAULT-transport channel (Phase 4).
|
|
4
|
-
*
|
|
5
|
-
* - GET /api/channels/<ch>/messages (gate agent:read) →
|
|
6
|
-
* vault → loadTranscript() → { messages }
|
|
7
|
-
* http-ui → { messages: [] } (ephemeral transport, no durable store)
|
|
8
|
-
* unknown → 404
|
|
9
|
-
* - POST /api/channels/<ch>/send (gate agent:send) →
|
|
10
|
-
* vault → writeInbound(text, "operator") → { ok, id } (the WAKE path)
|
|
11
|
-
*
|
|
12
|
-
* Auth: the same sentinel-token `mock.module("./hub-jwt.ts")` harness the other
|
|
13
|
-
* daemon tests use — a `Bearer test-rw-token` validates with agent:read + send
|
|
14
|
-
* WITHOUT a live hub/JWKS; the no-token path still hits the real 401 short-circuit.
|
|
15
|
-
*
|
|
16
|
-
* The vault I/O is exercised through a REAL `VaultTransport` instance (the daemon
|
|
17
|
-
* branches on `instanceof VaultTransport`) whose `loadTranscript` / `writeInbound`
|
|
18
|
-
* are MONKEYPATCHED on the instance — so we assert the daemon's dispatch + shaping
|
|
19
|
-
* without writing to (or reading from) any real vault. NO live vault, NO uni-* writes.
|
|
20
|
-
*/
|
|
21
|
-
import { describe, test, expect, mock } from "bun:test";
|
|
22
|
-
|
|
23
|
-
const RW_TOKEN = "test-rw-token"; // agent:read + agent:send
|
|
24
|
-
import { HubJwtError, looksLikeJwt } from "@openparachute/scope-guard";
|
|
25
|
-
mock.module("./hub-jwt.ts", () => ({
|
|
26
|
-
AGENT_AUDIENCE: "agent",
|
|
27
|
-
CHANNEL_AUDIENCE: "channel",
|
|
28
|
-
async validateHubJwt(token: string) {
|
|
29
|
-
const base = { sub: "test", aud: "agent", jti: undefined, clientId: undefined, vaultScope: undefined };
|
|
30
|
-
if (token === RW_TOKEN) return { ...base, scopes: ["agent:read", "agent:send"] };
|
|
31
|
-
throw new HubJwtError("issuer", "invalid token");
|
|
32
|
-
},
|
|
33
|
-
HubJwtError,
|
|
34
|
-
looksLikeJwt,
|
|
35
|
-
resetJwksCache() {},
|
|
36
|
-
resetRevocationCache() {},
|
|
37
|
-
}));
|
|
38
|
-
|
|
39
|
-
import { createFetchHandler } from "./daemon.ts";
|
|
40
|
-
import { ClientRegistry } from "./routing.ts";
|
|
41
|
-
import { HttpUiTransport } from "./transports/http-ui.ts";
|
|
42
|
-
import { VaultTransport, type ChannelMessage } from "./transports/vault.ts";
|
|
43
|
-
import type { Channel } from "./registry.ts";
|
|
44
|
-
|
|
45
|
-
/** A VaultTransport whose vault I/O is stubbed on the instance (instanceof holds). */
|
|
46
|
-
function stubVault(opts: {
|
|
47
|
-
transcript?: ChannelMessage[];
|
|
48
|
-
loadThrows?: Error;
|
|
49
|
-
onWrite?: (text: string, sender?: string) => void;
|
|
50
|
-
writeThrows?: Error;
|
|
51
|
-
writeId?: string;
|
|
52
|
-
}): VaultTransport {
|
|
53
|
-
const t = new VaultTransport({ vault: "default", vaultUrl: "http://127.0.0.1:1940", token: "x" });
|
|
54
|
-
// Bind ctx without firing the real ensureSchema network call.
|
|
55
|
-
(t as unknown as { ctx: { channel: string } }).ctx = { channel: "eng" };
|
|
56
|
-
t.loadTranscript = async () => {
|
|
57
|
-
if (opts.loadThrows) throw opts.loadThrows;
|
|
58
|
-
return opts.transcript ?? [];
|
|
59
|
-
};
|
|
60
|
-
t.writeInbound = async (text: string, sender?: string) => {
|
|
61
|
-
if (opts.writeThrows) throw opts.writeThrows;
|
|
62
|
-
opts.onWrite?.(text, sender);
|
|
63
|
-
return { id: opts.writeId ?? "written-note-1" };
|
|
64
|
-
};
|
|
65
|
-
return t;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function serverWith(channels: Map<string, Channel>) {
|
|
69
|
-
const registry = new ClientRegistry();
|
|
70
|
-
const srv = Bun.serve({
|
|
71
|
-
port: 0,
|
|
72
|
-
hostname: "127.0.0.1",
|
|
73
|
-
idleTimeout: 0,
|
|
74
|
-
fetch: createFetchHandler(channels, registry),
|
|
75
|
-
});
|
|
76
|
-
return { srv, base: `http://127.0.0.1:${srv.port}` };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const auth = { authorization: `Bearer ${RW_TOKEN}` };
|
|
80
|
-
|
|
81
|
-
describe("GET /api/channels/<ch>/messages", () => {
|
|
82
|
-
test("vault channel → { messages } from loadTranscript()", async () => {
|
|
83
|
-
const transcript: ChannelMessage[] = [
|
|
84
|
-
{ id: "n-in", text: "hi", direction: "inbound", sender: "aaron", ts: "2026-06-08T00:00:01Z" },
|
|
85
|
-
{ id: "n-out", text: "hello", direction: "outbound", sender: "session", ts: "2026-06-08T00:00:02Z" },
|
|
86
|
-
];
|
|
87
|
-
const t = stubVault({ transcript });
|
|
88
|
-
const channels = new Map<string, Channel>([
|
|
89
|
-
["eng", { name: "eng", transport: t, entry: { name: "eng", transport: "vault" } }],
|
|
90
|
-
]);
|
|
91
|
-
const { srv, base } = serverWith(channels);
|
|
92
|
-
try {
|
|
93
|
-
const res = await fetch(`${base}/api/channels/eng/messages`, { headers: auth });
|
|
94
|
-
expect(res.status).toBe(200);
|
|
95
|
-
const body = (await res.json()) as { messages: ChannelMessage[] };
|
|
96
|
-
expect(body.messages).toHaveLength(2);
|
|
97
|
-
expect(body.messages[0]!.id).toBe("n-in");
|
|
98
|
-
expect(body.messages[0]!.direction).toBe("inbound");
|
|
99
|
-
expect(body.messages[1]!.direction).toBe("outbound");
|
|
100
|
-
} finally {
|
|
101
|
-
srv.stop(true);
|
|
102
|
-
}
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
test("http-ui channel → { messages: [] } (no durable transcript)", async () => {
|
|
106
|
-
const transport = new HttpUiTransport({ channel: "ui1" });
|
|
107
|
-
await transport.start({ channel: "ui1", emit: () => {}, emitPermissionVerdict: () => {} });
|
|
108
|
-
const channels = new Map<string, Channel>([
|
|
109
|
-
["ui1", { name: "ui1", transport, entry: { name: "ui1", transport: "http-ui" } }],
|
|
110
|
-
]);
|
|
111
|
-
const { srv, base } = serverWith(channels);
|
|
112
|
-
try {
|
|
113
|
-
const res = await fetch(`${base}/api/channels/ui1/messages`, { headers: auth });
|
|
114
|
-
expect(res.status).toBe(200);
|
|
115
|
-
expect(await res.json()).toEqual({ messages: [] });
|
|
116
|
-
} finally {
|
|
117
|
-
srv.stop(true);
|
|
118
|
-
}
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
test("unknown channel → 404", async () => {
|
|
122
|
-
const channels = new Map<string, Channel>();
|
|
123
|
-
const { srv, base } = serverWith(channels);
|
|
124
|
-
try {
|
|
125
|
-
const res = await fetch(`${base}/api/channels/nope/messages`, { headers: auth });
|
|
126
|
-
expect(res.status).toBe(404);
|
|
127
|
-
} finally {
|
|
128
|
-
srv.stop(true);
|
|
129
|
-
}
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
test("no token → 401 (gate present), does not call the transport", async () => {
|
|
133
|
-
let loaded = false;
|
|
134
|
-
const t = stubVault({ transcript: [] });
|
|
135
|
-
t.loadTranscript = async () => { loaded = true; return []; };
|
|
136
|
-
const channels = new Map<string, Channel>([
|
|
137
|
-
["eng", { name: "eng", transport: t, entry: { name: "eng", transport: "vault" } }],
|
|
138
|
-
]);
|
|
139
|
-
const { srv, base } = serverWith(channels);
|
|
140
|
-
try {
|
|
141
|
-
const res = await fetch(`${base}/api/channels/eng/messages`);
|
|
142
|
-
expect(res.status).toBe(401);
|
|
143
|
-
expect(loaded).toBe(false);
|
|
144
|
-
} finally {
|
|
145
|
-
srv.stop(true);
|
|
146
|
-
}
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
test("a vault read failure → 502 (chat shows an error, not a silent empty)", async () => {
|
|
150
|
-
const t = stubVault({ loadThrows: new Error("vault transport: load transcript failed (502)") });
|
|
151
|
-
const channels = new Map<string, Channel>([
|
|
152
|
-
["eng", { name: "eng", transport: t, entry: { name: "eng", transport: "vault" } }],
|
|
153
|
-
]);
|
|
154
|
-
const { srv, base } = serverWith(channels);
|
|
155
|
-
try {
|
|
156
|
-
const res = await fetch(`${base}/api/channels/eng/messages`, { headers: auth });
|
|
157
|
-
expect(res.status).toBe(502);
|
|
158
|
-
} finally {
|
|
159
|
-
srv.stop(true);
|
|
160
|
-
}
|
|
161
|
-
});
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
describe("POST /api/channels/<ch>/send — vault path (writeInbound = the wake)", () => {
|
|
165
|
-
test("vault channel → writeInbound(text, 'operator') → { ok, id }", async () => {
|
|
166
|
-
let wrote: { text: string; sender?: string } | undefined;
|
|
167
|
-
const t = stubVault({ onWrite: (text, sender) => { wrote = { text, sender }; }, writeId: "inbound-99" });
|
|
168
|
-
const channels = new Map<string, Channel>([
|
|
169
|
-
["eng", { name: "eng", transport: t, entry: { name: "eng", transport: "vault" } }],
|
|
170
|
-
]);
|
|
171
|
-
const { srv, base } = serverWith(channels);
|
|
172
|
-
try {
|
|
173
|
-
const res = await fetch(`${base}/api/channels/eng/send`, {
|
|
174
|
-
method: "POST",
|
|
175
|
-
headers: { ...auth, "content-type": "application/json" },
|
|
176
|
-
body: JSON.stringify({ text: "wake up" }),
|
|
177
|
-
});
|
|
178
|
-
expect(res.status).toBe(200);
|
|
179
|
-
expect(await res.json()).toEqual({ ok: true, id: "inbound-99" });
|
|
180
|
-
expect(wrote).toEqual({ text: "wake up", sender: "operator" });
|
|
181
|
-
} finally {
|
|
182
|
-
srv.stop(true);
|
|
183
|
-
}
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
test("vault send with no token → 401, does not write", async () => {
|
|
187
|
-
let wrote = false;
|
|
188
|
-
const t = stubVault({ onWrite: () => { wrote = true; } });
|
|
189
|
-
const channels = new Map<string, Channel>([
|
|
190
|
-
["eng", { name: "eng", transport: t, entry: { name: "eng", transport: "vault" } }],
|
|
191
|
-
]);
|
|
192
|
-
const { srv, base } = serverWith(channels);
|
|
193
|
-
try {
|
|
194
|
-
const res = await fetch(`${base}/api/channels/eng/send`, {
|
|
195
|
-
method: "POST",
|
|
196
|
-
headers: { "content-type": "application/json" },
|
|
197
|
-
body: JSON.stringify({ text: "wake up" }),
|
|
198
|
-
});
|
|
199
|
-
expect(res.status).toBe(401);
|
|
200
|
-
expect(wrote).toBe(false);
|
|
201
|
-
} finally {
|
|
202
|
-
srv.stop(true);
|
|
203
|
-
}
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
test("vault send with empty text → 400", async () => {
|
|
207
|
-
const t = stubVault({});
|
|
208
|
-
const channels = new Map<string, Channel>([
|
|
209
|
-
["eng", { name: "eng", transport: t, entry: { name: "eng", transport: "vault" } }],
|
|
210
|
-
]);
|
|
211
|
-
const { srv, base } = serverWith(channels);
|
|
212
|
-
try {
|
|
213
|
-
const res = await fetch(`${base}/api/channels/eng/send`, {
|
|
214
|
-
method: "POST",
|
|
215
|
-
headers: { ...auth, "content-type": "application/json" },
|
|
216
|
-
body: JSON.stringify({ text: "" }),
|
|
217
|
-
});
|
|
218
|
-
expect(res.status).toBe(400);
|
|
219
|
-
} finally {
|
|
220
|
-
srv.stop(true);
|
|
221
|
-
}
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
test("http-ui send is NOT intercepted by the vault path — its own ingestHttp handles it", async () => {
|
|
225
|
-
const emitted: string[] = [];
|
|
226
|
-
const transport = new HttpUiTransport({ channel: "ui1" });
|
|
227
|
-
await transport.start({
|
|
228
|
-
channel: "ui1",
|
|
229
|
-
emit: (m) => emitted.push(m.content),
|
|
230
|
-
emitPermissionVerdict: () => {},
|
|
231
|
-
});
|
|
232
|
-
const channels = new Map<string, Channel>([
|
|
233
|
-
["ui1", { name: "ui1", transport, entry: { name: "ui1", transport: "http-ui" } }],
|
|
234
|
-
]);
|
|
235
|
-
const { srv, base } = serverWith(channels);
|
|
236
|
-
try {
|
|
237
|
-
const res = await fetch(`${base}/api/channels/ui1/send`, {
|
|
238
|
-
method: "POST",
|
|
239
|
-
headers: { ...auth, "content-type": "application/json" },
|
|
240
|
-
body: JSON.stringify({ text: "hi http-ui" }),
|
|
241
|
-
});
|
|
242
|
-
expect(res.status).toBe(200);
|
|
243
|
-
expect(await res.json()).toEqual({ ok: true });
|
|
244
|
-
// http-ui's ingestHttp emit'd it (vault path returns { ok, id }, not { ok }).
|
|
245
|
-
expect(emitted).toEqual(["hi http-ui"]);
|
|
246
|
-
} finally {
|
|
247
|
-
srv.stop(true);
|
|
248
|
-
}
|
|
249
|
-
});
|
|
250
|
-
});
|