@anna-ai/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +149 -0
- package/dist/bridge-CzEs7jaN.js +145 -0
- package/dist/bridge-t2Qqu3hC.js +3 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +121 -0
- package/dist/dashboard.html +426 -0
- package/dist/dev-Bgku5ngL.js +163 -0
- package/dist/doctor-D3z4YslL.js +69 -0
- package/dist/fixture-BGjMtqWA.js +278 -0
- package/dist/server-B6-Qdluv.js +255 -0
- package/dist/test/index.d.ts +151 -0
- package/dist/test/index.js +260 -0
- package/package.json +53 -0
- package/templates/minimal/README.md +9 -0
- package/templates/minimal/app.json +7 -0
- package/templates/minimal/bundle/app.js +36 -0
- package/templates/minimal/bundle/index.html +14 -0
- package/templates/minimal/executas/__SLUG__/plugin.py +60 -0
- package/templates/minimal/executas/__SLUG__/pyproject.toml +12 -0
- package/templates/minimal/manifest.json +38 -0
- package/vendor/anna-app-schema/README.md +22 -0
- package/vendor/anna-app-schema/dispatcher_version.txt +1 -0
- package/vendor/anna-app-schema/events/AnnaAppEvent.json +38 -0
- package/vendor/anna-app-schema/host_api/methods.json +170 -0
- package/vendor/anna-app-schema/manifest/AppManifest.json +471 -0
- package/vendor/anna-app-schema/manifest/UiManifestSection.json +273 -0
- package/vendor/anna-app-schema/package.json +25 -0
- package/vendor/anna-app-schema/pyproject.toml +13 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import kleur from "kleur";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
//#region src/fixture/parse.ts
|
|
6
|
+
const KNOWN_KINDS = new Set([
|
|
7
|
+
"meta",
|
|
8
|
+
"auth.refresh",
|
|
9
|
+
"event",
|
|
10
|
+
"req",
|
|
11
|
+
"res"
|
|
12
|
+
]);
|
|
13
|
+
async function parseFixtureFile(path) {
|
|
14
|
+
const raw = await readFile(path, "utf8");
|
|
15
|
+
return parseFixtureText(raw);
|
|
16
|
+
}
|
|
17
|
+
function parseFixtureText(text) {
|
|
18
|
+
const out = {
|
|
19
|
+
lines: [],
|
|
20
|
+
errors: []
|
|
21
|
+
};
|
|
22
|
+
const rows = text.split("\n");
|
|
23
|
+
rows.forEach((row, idx) => {
|
|
24
|
+
const lineNo = idx + 1;
|
|
25
|
+
const trimmed = row.trim();
|
|
26
|
+
if (!trimmed) return;
|
|
27
|
+
let parsed;
|
|
28
|
+
try {
|
|
29
|
+
parsed = JSON.parse(trimmed);
|
|
30
|
+
} catch (e) {
|
|
31
|
+
out.errors.push({
|
|
32
|
+
line: lineNo,
|
|
33
|
+
message: `not valid JSON: ${e.message}`
|
|
34
|
+
});
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
38
|
+
out.errors.push({
|
|
39
|
+
line: lineNo,
|
|
40
|
+
message: "expected JSON object"
|
|
41
|
+
});
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const obj = parsed;
|
|
45
|
+
const kind = obj.kind;
|
|
46
|
+
if (!kind || !KNOWN_KINDS.has(kind)) {
|
|
47
|
+
out.errors.push({
|
|
48
|
+
line: lineNo,
|
|
49
|
+
message: `unknown kind: ${kind ?? "(missing)"}`
|
|
50
|
+
});
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (typeof obj.t !== "number" || !Number.isFinite(obj.t)) {
|
|
54
|
+
out.errors.push({
|
|
55
|
+
line: lineNo,
|
|
56
|
+
message: "missing or non-numeric 't'"
|
|
57
|
+
});
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
out.lines.push(obj);
|
|
61
|
+
});
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
function verifyFixture(parsed) {
|
|
65
|
+
const errs = [...parsed.errors];
|
|
66
|
+
let lastT = -Infinity;
|
|
67
|
+
parsed.lines.forEach((ln, idx) => {
|
|
68
|
+
if (ln.t < lastT) errs.push({
|
|
69
|
+
line: idx + 1,
|
|
70
|
+
message: `non-monotonic t (${ln.t} < ${lastT})`
|
|
71
|
+
});
|
|
72
|
+
lastT = ln.t;
|
|
73
|
+
});
|
|
74
|
+
if (parsed.lines.length > 0 && parsed.lines[0].kind !== "meta") errs.push({
|
|
75
|
+
line: 1,
|
|
76
|
+
message: "first line should be a 'meta' record"
|
|
77
|
+
});
|
|
78
|
+
const seenIds = new Set();
|
|
79
|
+
parsed.lines.forEach((ln, idx) => {
|
|
80
|
+
const lineNo = idx + 1;
|
|
81
|
+
switch (ln.kind) {
|
|
82
|
+
case "auth.refresh":
|
|
83
|
+
if (typeof ln.token !== "string" || !ln.token) errs.push({
|
|
84
|
+
line: lineNo,
|
|
85
|
+
message: "auth.refresh missing token"
|
|
86
|
+
});
|
|
87
|
+
break;
|
|
88
|
+
case "event":
|
|
89
|
+
if (typeof ln.event !== "string" || !ln.event) errs.push({
|
|
90
|
+
line: lineNo,
|
|
91
|
+
message: "event missing 'event' name"
|
|
92
|
+
});
|
|
93
|
+
break;
|
|
94
|
+
case "req":
|
|
95
|
+
if (ln.ns == null || ln.method == null || ln.id == null) errs.push({
|
|
96
|
+
line: lineNo,
|
|
97
|
+
message: "req missing ns/method/id"
|
|
98
|
+
});
|
|
99
|
+
else {
|
|
100
|
+
const k = `${ln.id}`;
|
|
101
|
+
if (seenIds.has(k)) errs.push({
|
|
102
|
+
line: lineNo,
|
|
103
|
+
message: `duplicate req id: ${k}`
|
|
104
|
+
});
|
|
105
|
+
seenIds.add(k);
|
|
106
|
+
}
|
|
107
|
+
break;
|
|
108
|
+
case "res":
|
|
109
|
+
if (ln.id == null || typeof ln.ok !== "boolean") errs.push({
|
|
110
|
+
line: lineNo,
|
|
111
|
+
message: "res missing id/ok"
|
|
112
|
+
});
|
|
113
|
+
break;
|
|
114
|
+
case "meta": break;
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
const reqIds = new Set(parsed.lines.filter((l) => l.kind === "req").map((l) => `${l.id}`));
|
|
118
|
+
for (const ln of parsed.lines) if (ln.kind === "res" && !reqIds.has(`${ln.id}`)) errs.push({
|
|
119
|
+
line: 0,
|
|
120
|
+
message: `res with id=${ln.id} has no matching req`
|
|
121
|
+
});
|
|
122
|
+
return errs;
|
|
123
|
+
}
|
|
124
|
+
function summarizeFixture(parsed) {
|
|
125
|
+
const meta = parsed.lines.find((l) => l.kind === "meta") ?? null;
|
|
126
|
+
const events_by_name = {};
|
|
127
|
+
const reqs_by_method = {};
|
|
128
|
+
let reqs_total = 0;
|
|
129
|
+
let res_total = 0;
|
|
130
|
+
let res_errors = 0;
|
|
131
|
+
let auth_refresh_count = 0;
|
|
132
|
+
const reqIds = new Map();
|
|
133
|
+
const respondedIds = new Set();
|
|
134
|
+
let lastT = 0;
|
|
135
|
+
for (const ln of parsed.lines) {
|
|
136
|
+
if (ln.t > lastT) lastT = ln.t;
|
|
137
|
+
switch (ln.kind) {
|
|
138
|
+
case "event":
|
|
139
|
+
events_by_name[ln.event] = (events_by_name[ln.event] ?? 0) + 1;
|
|
140
|
+
break;
|
|
141
|
+
case "req": {
|
|
142
|
+
reqs_total += 1;
|
|
143
|
+
const key = `${ln.ns}.${ln.method}`;
|
|
144
|
+
reqs_by_method[key] = (reqs_by_method[key] ?? 0) + 1;
|
|
145
|
+
reqIds.set(`${ln.id}`, ln);
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
case "res":
|
|
149
|
+
res_total += 1;
|
|
150
|
+
if (!ln.ok) res_errors += 1;
|
|
151
|
+
respondedIds.add(`${ln.id}`);
|
|
152
|
+
break;
|
|
153
|
+
case "auth.refresh":
|
|
154
|
+
auth_refresh_count += 1;
|
|
155
|
+
break;
|
|
156
|
+
case "meta": break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const unmatched_req_ids = [];
|
|
160
|
+
for (const [id, req] of reqIds) if (!respondedIds.has(id)) unmatched_req_ids.push(req.id);
|
|
161
|
+
return {
|
|
162
|
+
duration_ms: lastT,
|
|
163
|
+
total_lines: parsed.lines.length,
|
|
164
|
+
meta,
|
|
165
|
+
events_by_name,
|
|
166
|
+
reqs_by_method,
|
|
167
|
+
reqs_total,
|
|
168
|
+
res_total,
|
|
169
|
+
res_errors,
|
|
170
|
+
auth_refresh_count,
|
|
171
|
+
unmatched_req_ids
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
//#endregion
|
|
176
|
+
//#region src/commands/fixture.ts
|
|
177
|
+
async function runFixtureVerify(opts) {
|
|
178
|
+
const path = resolve(opts.file);
|
|
179
|
+
const parsed = await parseFixtureFile(path);
|
|
180
|
+
const errors = verifyFixture(parsed);
|
|
181
|
+
if (opts.json) process.stdout.write(`${JSON.stringify({
|
|
182
|
+
ok: errors.length === 0,
|
|
183
|
+
errors,
|
|
184
|
+
lines: parsed.lines.length
|
|
185
|
+
}, null, 2)}\n`);
|
|
186
|
+
else if (errors.length === 0) process.stdout.write(`${kleur.green("✓")} ${path} — ${parsed.lines.length} records, no errors\n`);
|
|
187
|
+
else {
|
|
188
|
+
process.stdout.write(`${kleur.red("✗")} ${path} — ${errors.length} error(s):\n`);
|
|
189
|
+
for (const e of errors) {
|
|
190
|
+
const loc = e.line > 0 ? `:${e.line}` : "";
|
|
191
|
+
process.stdout.write(` ${kleur.red("•")} ${path}${loc} ${e.message}\n`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return errors.length === 0 ? 0 : 1;
|
|
195
|
+
}
|
|
196
|
+
async function runFixtureSummarize(opts) {
|
|
197
|
+
const path = resolve(opts.file);
|
|
198
|
+
const parsed = await parseFixtureFile(path);
|
|
199
|
+
const sum = summarizeFixture(parsed);
|
|
200
|
+
if (opts.json) {
|
|
201
|
+
process.stdout.write(`${JSON.stringify(sum, null, 2)}\n`);
|
|
202
|
+
return 0;
|
|
203
|
+
}
|
|
204
|
+
const out = [];
|
|
205
|
+
out.push(kleur.bold(`Fixture: ${path}`));
|
|
206
|
+
out.push(` records: ${sum.total_lines}`);
|
|
207
|
+
out.push(` duration: ${(sum.duration_ms / 1e3).toFixed(2)}s`);
|
|
208
|
+
if (sum.meta) out.push(` recorded by: harness ${sum.meta.harness_version ?? "?"} session=${sum.meta.session_id ?? "?"}`);
|
|
209
|
+
out.push(` requests: ${sum.reqs_total} (responses: ${sum.res_total}, errors: ${sum.res_errors})`);
|
|
210
|
+
out.push(` events: ${Object.values(sum.events_by_name).reduce((a, b) => a + b, 0)}`);
|
|
211
|
+
out.push(` auth refresh: ${sum.auth_refresh_count}`);
|
|
212
|
+
if (sum.unmatched_req_ids.length) out.push(` ${kleur.yellow("⚠ unmatched req ids")}: ${sum.unmatched_req_ids.slice(0, 8).join(", ")}${sum.unmatched_req_ids.length > 8 ? " …" : ""}`);
|
|
213
|
+
if (sum.reqs_total > 0) {
|
|
214
|
+
out.push(" by ns.method:");
|
|
215
|
+
for (const [k, v] of Object.entries(sum.reqs_by_method).sort((a, b) => b[1] - a[1])) out.push(` ${v.toString().padStart(4)} ${k}`);
|
|
216
|
+
}
|
|
217
|
+
if (Object.keys(sum.events_by_name).length > 0) {
|
|
218
|
+
out.push(" events:");
|
|
219
|
+
for (const [k, v] of Object.entries(sum.events_by_name).sort((a, b) => b[1] - a[1])) out.push(` ${v.toString().padStart(4)} ${k}`);
|
|
220
|
+
}
|
|
221
|
+
process.stdout.write(`${out.join("\n")}\n`);
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
async function runFixtureReplay(opts) {
|
|
225
|
+
const path = resolve(opts.file);
|
|
226
|
+
const parsed = await parseFixtureFile(path);
|
|
227
|
+
const errors = verifyFixture(parsed);
|
|
228
|
+
if (errors.length > 0) {
|
|
229
|
+
process.stdout.write(`${kleur.red("✗")} fixture has ${errors.length} verification error(s); fix before replay\n`);
|
|
230
|
+
return 1;
|
|
231
|
+
}
|
|
232
|
+
const manifest = opts.manifest ? await readManifest(opts.manifest) : null;
|
|
233
|
+
process.stdout.write(`${kleur.bold("replay plan")} ${path}` + (manifest ? ` (manifest: ${opts.manifest})` : " (no manifest)") + "\n");
|
|
234
|
+
let i = 0;
|
|
235
|
+
for (const ln of parsed.lines) {
|
|
236
|
+
i += 1;
|
|
237
|
+
const tag = i.toString().padStart(4);
|
|
238
|
+
const ts = `+${(ln.t / 1e3).toFixed(3)}s`.padStart(9);
|
|
239
|
+
switch (ln.kind) {
|
|
240
|
+
case "event": {
|
|
241
|
+
const ev = ln;
|
|
242
|
+
process.stdout.write(` ${tag} ${ts} ${kleur.cyan("→ event")} ${ev.event}\n`);
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
case "req": {
|
|
246
|
+
const r = ln;
|
|
247
|
+
process.stdout.write(` ${tag} ${ts} ${kleur.yellow("← req ")} ${r.ns}.${r.method} id=${r.id}\n`);
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
case "res": {
|
|
251
|
+
const r = ln;
|
|
252
|
+
const tone = r.ok ? kleur.green : kleur.red;
|
|
253
|
+
process.stdout.write(` ${tag} ${ts} ${tone("→ res ")} id=${r.id} ok=${r.ok}\n`);
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
case "auth.refresh":
|
|
257
|
+
process.stdout.write(` ${tag} ${ts} ${kleur.magenta("→ auth ")} refresh\n`);
|
|
258
|
+
break;
|
|
259
|
+
case "meta":
|
|
260
|
+
process.stdout.write(` ${tag} ${ts} ${kleur.gray("· meta ")} start\n`);
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
process.stdout.write("\n" + kleur.gray("live replay against `anna-app dev` harness is tracked as a Phase 6 follow-up; this MVP performs structural replay only.") + "\n");
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
async function readManifest(path) {
|
|
268
|
+
try {
|
|
269
|
+
const raw = await readFile(resolve(path), "utf8");
|
|
270
|
+
return JSON.parse(raw);
|
|
271
|
+
} catch (e) {
|
|
272
|
+
process.stderr.write(`failed to read manifest at ${path}: ${e.message}\n`);
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
//#endregion
|
|
278
|
+
export { runFixtureReplay, runFixtureSummarize, runFixtureVerify };
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { createReadStream, statSync, watch } from "node:fs";
|
|
2
|
+
import { dirname, join, normalize, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { createServer } from "node:http";
|
|
6
|
+
import { WebSocketServer } from "ws";
|
|
7
|
+
|
|
8
|
+
//#region src/harness/server.ts
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = dirname(__filename);
|
|
11
|
+
const MIME = {
|
|
12
|
+
".html": "text/html; charset=utf-8",
|
|
13
|
+
".js": "application/javascript; charset=utf-8",
|
|
14
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
15
|
+
".css": "text/css; charset=utf-8",
|
|
16
|
+
".json": "application/json; charset=utf-8",
|
|
17
|
+
".svg": "image/svg+xml",
|
|
18
|
+
".png": "image/png",
|
|
19
|
+
".jpg": "image/jpeg",
|
|
20
|
+
".jpeg": "image/jpeg",
|
|
21
|
+
".gif": "image/gif",
|
|
22
|
+
".woff": "font/woff",
|
|
23
|
+
".woff2": "font/woff2",
|
|
24
|
+
".map": "application/json"
|
|
25
|
+
};
|
|
26
|
+
var HarnessServer = class {
|
|
27
|
+
server = createServer((req, res) => this.handle(req, res));
|
|
28
|
+
wss = null;
|
|
29
|
+
sessionId = null;
|
|
30
|
+
liveSockets = new Set();
|
|
31
|
+
watchers = [];
|
|
32
|
+
reloadDebounce = null;
|
|
33
|
+
constructor(cfg, bridge) {
|
|
34
|
+
this.cfg = cfg;
|
|
35
|
+
this.bridge = bridge;
|
|
36
|
+
}
|
|
37
|
+
async listen() {
|
|
38
|
+
if (this.cfg.executas && this.cfg.executas.length > 0) await this.bridge.call("executas.register", { executas: this.cfg.executas.map((e) => ({
|
|
39
|
+
tool_id: e.tool_id,
|
|
40
|
+
project_dir: e.project_dir,
|
|
41
|
+
command: e.command ?? null
|
|
42
|
+
})) });
|
|
43
|
+
await new Promise((res, rej) => this.server.listen(this.cfg.port, () => res()).once("error", rej));
|
|
44
|
+
this.wss = new WebSocketServer({ noServer: true });
|
|
45
|
+
this.server.on("upgrade", (req, socket, head) => {
|
|
46
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
47
|
+
if (url.pathname !== "/ws") {
|
|
48
|
+
socket.destroy();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
this.wss.handleUpgrade(req, socket, head, (ws) => {
|
|
52
|
+
const sid = url.searchParams.get("session_id");
|
|
53
|
+
if (!sid || sid !== this.sessionId) {
|
|
54
|
+
ws.close(1008, "unknown session_id");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
this.liveSockets.add(ws);
|
|
58
|
+
const timer = setInterval(async () => {
|
|
59
|
+
try {
|
|
60
|
+
const out = await this.bridge.call("session.drain_events", { session_id: sid });
|
|
61
|
+
for (const ev of out.events) ws.send(JSON.stringify({
|
|
62
|
+
kind: "event",
|
|
63
|
+
...ev
|
|
64
|
+
}));
|
|
65
|
+
} catch (e) {
|
|
66
|
+
ws.close(1011, `drain failed: ${e.message}`);
|
|
67
|
+
clearInterval(timer);
|
|
68
|
+
}
|
|
69
|
+
}, 200);
|
|
70
|
+
ws.on("close", () => {
|
|
71
|
+
this.liveSockets.delete(ws);
|
|
72
|
+
clearInterval(timer);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
if (this.cfg.watch) this.startWatcher();
|
|
77
|
+
}
|
|
78
|
+
async close() {
|
|
79
|
+
for (const w of this.watchers) w.close();
|
|
80
|
+
this.watchers = [];
|
|
81
|
+
if (this.reloadDebounce) clearTimeout(this.reloadDebounce);
|
|
82
|
+
if (this.wss) {
|
|
83
|
+
for (const c of this.wss.clients) c.terminate();
|
|
84
|
+
this.wss.close();
|
|
85
|
+
}
|
|
86
|
+
await new Promise((res) => this.server.close(() => res()));
|
|
87
|
+
}
|
|
88
|
+
startWatcher() {
|
|
89
|
+
const broadcastReload = (path) => {
|
|
90
|
+
if (this.reloadDebounce) clearTimeout(this.reloadDebounce);
|
|
91
|
+
this.reloadDebounce = setTimeout(() => {
|
|
92
|
+
const env = JSON.stringify({
|
|
93
|
+
kind: "reload",
|
|
94
|
+
path
|
|
95
|
+
});
|
|
96
|
+
for (const ws of this.liveSockets) if (ws.readyState === ws.OPEN) ws.send(env);
|
|
97
|
+
}, 100);
|
|
98
|
+
};
|
|
99
|
+
try {
|
|
100
|
+
this.watchers.push(watch(this.cfg.bundleDir, { recursive: true }, (_evt, filename) => {
|
|
101
|
+
if (filename) broadcastReload(`bundle/${filename}`);
|
|
102
|
+
}));
|
|
103
|
+
} catch (e) {
|
|
104
|
+
process.stderr.write(`[harness] watcher failed to attach to ${this.cfg.bundleDir}: ${e.message}\n`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async handle(req, res) {
|
|
108
|
+
try {
|
|
109
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
110
|
+
const method = req.method ?? "GET";
|
|
111
|
+
if (method === "GET" && (url.pathname === "/" || url.pathname === "/dashboard")) return await this.serveDashboard(res);
|
|
112
|
+
if (method === "GET" && url.pathname === "/api/config") return this.json(res, 200, {
|
|
113
|
+
app_slug: this.cfg.slug,
|
|
114
|
+
view: this.cfg.view ?? null,
|
|
115
|
+
bundle_base: `/anna-apps/${this.cfg.slug}/dev/${this.cfg.bundleEntry}`,
|
|
116
|
+
executas: (this.cfg.executas ?? []).map((e) => e.tool_id),
|
|
117
|
+
watch: !!this.cfg.watch
|
|
118
|
+
});
|
|
119
|
+
if (method === "POST" && url.pathname === "/api/session/create") return await this.createSession(res);
|
|
120
|
+
if (method === "POST" && url.pathname === "/api/session/call") return await this.proxyCall(req, res);
|
|
121
|
+
if (method === "POST" && url.pathname === "/api/session/refresh-token") return await this.refreshToken(res);
|
|
122
|
+
if (method === "GET" && url.pathname.startsWith("/static/anna-apps/_sdk/")) return await this.serveSdk(url.pathname, res);
|
|
123
|
+
if (method === "GET" && url.pathname.startsWith(`/anna-apps/${this.cfg.slug}/dev/`)) {
|
|
124
|
+
const rel = url.pathname.replace(`/anna-apps/${this.cfg.slug}/dev/`, "");
|
|
125
|
+
return await this.serveBundleAsset(rel, res);
|
|
126
|
+
}
|
|
127
|
+
this.text(res, 404, `not found: ${url.pathname}`);
|
|
128
|
+
} catch (e) {
|
|
129
|
+
this.text(res, 500, `harness error: ${e.message}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async serveDashboard(res) {
|
|
133
|
+
const file = join(__dirname, "dashboard.html");
|
|
134
|
+
const html = await readFile(file, "utf-8");
|
|
135
|
+
res.writeHead(200, {
|
|
136
|
+
"content-type": MIME[".html"],
|
|
137
|
+
"cache-control": "no-store"
|
|
138
|
+
});
|
|
139
|
+
res.end(html);
|
|
140
|
+
}
|
|
141
|
+
async createSession(res) {
|
|
142
|
+
if (this.sessionId) {
|
|
143
|
+
try {
|
|
144
|
+
await this.bridge.call("session.close", { session_id: this.sessionId });
|
|
145
|
+
} catch {}
|
|
146
|
+
this.sessionId = null;
|
|
147
|
+
}
|
|
148
|
+
const out = await this.bridge.call("session.create", {
|
|
149
|
+
user_id: this.cfg.userId,
|
|
150
|
+
manifest: this.cfg.manifest,
|
|
151
|
+
view: this.cfg.view,
|
|
152
|
+
entry_payload: this.cfg.entryPayload ?? {},
|
|
153
|
+
app_slug: this.cfg.slug
|
|
154
|
+
});
|
|
155
|
+
this.sessionId = out.session_id;
|
|
156
|
+
this.json(res, 200, out);
|
|
157
|
+
}
|
|
158
|
+
async proxyCall(req, res) {
|
|
159
|
+
const body = await readBody(req);
|
|
160
|
+
let parsed;
|
|
161
|
+
try {
|
|
162
|
+
parsed = JSON.parse(body);
|
|
163
|
+
} catch {
|
|
164
|
+
return this.json(res, 400, {
|
|
165
|
+
ok: false,
|
|
166
|
+
error: {
|
|
167
|
+
code: "bad_request",
|
|
168
|
+
message: "invalid json body"
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
const out = await this.bridge.call("session.call", {
|
|
174
|
+
session_id: parsed.session_id,
|
|
175
|
+
ns: parsed.ns,
|
|
176
|
+
method: parsed.method,
|
|
177
|
+
args: parsed.args ?? {}
|
|
178
|
+
});
|
|
179
|
+
this.json(res, 200, out);
|
|
180
|
+
} catch (e) {
|
|
181
|
+
this.json(res, 200, {
|
|
182
|
+
ok: false,
|
|
183
|
+
error: {
|
|
184
|
+
code: "transport",
|
|
185
|
+
message: e.message
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
async refreshToken(res) {
|
|
191
|
+
if (!this.sessionId) return this.json(res, 400, { error: "no active session" });
|
|
192
|
+
try {
|
|
193
|
+
const out = await this.bridge.call("session.refresh_token", { session_id: this.sessionId });
|
|
194
|
+
this.json(res, 200, out);
|
|
195
|
+
} catch (e) {
|
|
196
|
+
this.json(res, 500, { error: e.message });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async serveSdk(pathname, res) {
|
|
200
|
+
const root = this.cfg.matrixNexusRoot;
|
|
201
|
+
if (!root) return this.text(res, 404, "SDK assets not available in uvx mode");
|
|
202
|
+
const rel = pathname.replace(/^\/static\//, "");
|
|
203
|
+
const abs = resolve(root, "static", rel);
|
|
204
|
+
if (!abs.startsWith(resolve(root, "static"))) return this.text(res, 403, "forbidden");
|
|
205
|
+
return this.serveFile(abs, res);
|
|
206
|
+
}
|
|
207
|
+
async serveBundleAsset(rel, res) {
|
|
208
|
+
const abs = resolve(this.cfg.bundleDir, normalize(rel));
|
|
209
|
+
if (!abs.startsWith(resolve(this.cfg.bundleDir))) return this.text(res, 403, "forbidden");
|
|
210
|
+
return this.serveFile(abs, res);
|
|
211
|
+
}
|
|
212
|
+
async serveFile(abs, res) {
|
|
213
|
+
let stat;
|
|
214
|
+
try {
|
|
215
|
+
stat = statSync(abs);
|
|
216
|
+
} catch {
|
|
217
|
+
return this.text(res, 404, `not found: ${abs}`);
|
|
218
|
+
}
|
|
219
|
+
if (!stat.isFile()) return this.text(res, 404, "not a file");
|
|
220
|
+
const ext = abs.slice(abs.lastIndexOf("."));
|
|
221
|
+
res.writeHead(200, {
|
|
222
|
+
"content-type": MIME[ext] ?? "application/octet-stream",
|
|
223
|
+
"cache-control": "no-store",
|
|
224
|
+
"content-length": String(stat.size)
|
|
225
|
+
});
|
|
226
|
+
createReadStream(abs).pipe(res);
|
|
227
|
+
}
|
|
228
|
+
json(res, status, body) {
|
|
229
|
+
const text = JSON.stringify(body);
|
|
230
|
+
res.writeHead(status, {
|
|
231
|
+
"content-type": MIME[".json"],
|
|
232
|
+
"content-length": String(Buffer.byteLength(text)),
|
|
233
|
+
"cache-control": "no-store"
|
|
234
|
+
});
|
|
235
|
+
res.end(text);
|
|
236
|
+
}
|
|
237
|
+
text(res, status, body) {
|
|
238
|
+
res.writeHead(status, {
|
|
239
|
+
"content-type": "text/plain; charset=utf-8",
|
|
240
|
+
"content-length": String(Buffer.byteLength(body))
|
|
241
|
+
});
|
|
242
|
+
res.end(body);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
function readBody(req) {
|
|
246
|
+
return new Promise((resolve$1, reject) => {
|
|
247
|
+
const chunks = [];
|
|
248
|
+
req.on("data", (c) => chunks.push(c));
|
|
249
|
+
req.on("end", () => resolve$1(Buffer.concat(chunks).toString("utf-8")));
|
|
250
|
+
req.on("error", reject);
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
//#endregion
|
|
255
|
+
export { HarnessServer };
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
//#region src/test/host-api-acl.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Derive a host-API ACL from `manifest.ui.host_api`, matching the
|
|
4
|
+
* production gate in matrix-nexus (`src/services/anna_app_validator.py` and
|
|
5
|
+
* the runtime gate enforced inside `LocalDispatcher.dispatch`).
|
|
6
|
+
*
|
|
7
|
+
* The shape returned here mirrors what production allows so that bundle
|
|
8
|
+
* authors get the same DENIED errors locally as they would in production.
|
|
9
|
+
*/
|
|
10
|
+
interface HostApi {
|
|
11
|
+
tools?: string[];
|
|
12
|
+
chat?: string[];
|
|
13
|
+
artifact?: string[];
|
|
14
|
+
llm?: string[];
|
|
15
|
+
fs?: string[];
|
|
16
|
+
storage?: string[];
|
|
17
|
+
prefs?: string[];
|
|
18
|
+
window?: string[];
|
|
19
|
+
}
|
|
20
|
+
interface ManifestForAcl {
|
|
21
|
+
required_executas?: Array<{
|
|
22
|
+
tool_id: string;
|
|
23
|
+
}>;
|
|
24
|
+
optional_executas?: Array<{
|
|
25
|
+
tool_id: string;
|
|
26
|
+
}>;
|
|
27
|
+
ui?: {
|
|
28
|
+
host_api?: HostApi | null;
|
|
29
|
+
} | null;
|
|
30
|
+
}
|
|
31
|
+
interface Acl {
|
|
32
|
+
/** ns.method strings (e.g. "storage.set"). */
|
|
33
|
+
allowed: Set<string>;
|
|
34
|
+
/** Tools the bundle may invoke; "*" for any. */
|
|
35
|
+
allowedTools: Set<string>;
|
|
36
|
+
/** Whether `tools.invoke` is allowed at all. */
|
|
37
|
+
toolsWildcard: boolean;
|
|
38
|
+
}
|
|
39
|
+
declare function deriveAcl(manifest: ManifestForAcl): Acl;
|
|
40
|
+
declare function isToolAllowed(acl: Acl, toolId: string): boolean; //#endregion
|
|
41
|
+
//#region src/test/runtime.d.ts
|
|
42
|
+
interface CallRecord {
|
|
43
|
+
/** Monotonic sequence number assigned by the harness. */
|
|
44
|
+
seq: number;
|
|
45
|
+
/** ms since harness started. */
|
|
46
|
+
t: number;
|
|
47
|
+
/** Namespace ("storage", "tools", ...). */
|
|
48
|
+
ns: string;
|
|
49
|
+
/** Method ("set", "invoke", ...). */
|
|
50
|
+
method: string;
|
|
51
|
+
/** Arguments passed by the bundle. */
|
|
52
|
+
args: unknown;
|
|
53
|
+
/** Outcome — `null` while still pending. */
|
|
54
|
+
outcome: "ok" | "error" | "denied" | null;
|
|
55
|
+
/** Result value (when outcome === "ok"). */
|
|
56
|
+
result?: unknown;
|
|
57
|
+
/** Error code (when outcome === "error" | "denied"). */
|
|
58
|
+
errorCode?: string;
|
|
59
|
+
/** Error message (when outcome === "error" | "denied"). */
|
|
60
|
+
errorMessage?: string;
|
|
61
|
+
}
|
|
62
|
+
interface CallLog {
|
|
63
|
+
/** All calls in arrival order. */
|
|
64
|
+
all(): CallRecord[];
|
|
65
|
+
/** Filter helper: `byNs("storage.set")` or `byNs("storage")`. */
|
|
66
|
+
byNs(prefix: string): CallRecord[];
|
|
67
|
+
/** Last call (or null). */
|
|
68
|
+
last(): CallRecord | null;
|
|
69
|
+
/** Last call matching `prefix`. */
|
|
70
|
+
lastOf(prefix: string): CallRecord | null;
|
|
71
|
+
/** Clear the log. */
|
|
72
|
+
clear(): void;
|
|
73
|
+
}
|
|
74
|
+
type MockHandler = (args: unknown, ctx: {
|
|
75
|
+
ns: string;
|
|
76
|
+
method: string;
|
|
77
|
+
}) => unknown | Promise<unknown>;
|
|
78
|
+
type MockMap = Record<string, MockHandler>;
|
|
79
|
+
interface EventBus {
|
|
80
|
+
/** Push an event into the runtime, as if the host server sent it. */
|
|
81
|
+
emit(name: string, payload?: unknown): void;
|
|
82
|
+
/** Register a listener (mirrors `runtime.on`). */
|
|
83
|
+
on(name: string, fn: (payload: unknown) => void): () => void;
|
|
84
|
+
}
|
|
85
|
+
interface MockRuntime {
|
|
86
|
+
hello: {
|
|
87
|
+
wid: string;
|
|
88
|
+
t: string;
|
|
89
|
+
user_id: number;
|
|
90
|
+
};
|
|
91
|
+
call<T = unknown>(ns: string, method: string, args?: unknown): Promise<T>;
|
|
92
|
+
on(name: string, fn: (payload: unknown) => void): () => void;
|
|
93
|
+
tools: {
|
|
94
|
+
invoke<T = unknown>(args: {
|
|
95
|
+
tool_id: string;
|
|
96
|
+
method: string;
|
|
97
|
+
args?: unknown;
|
|
98
|
+
}): Promise<T>;
|
|
99
|
+
};
|
|
100
|
+
storage: {
|
|
101
|
+
get<T = unknown>(key: string): Promise<T>;
|
|
102
|
+
set(key: string, value: unknown): Promise<void>;
|
|
103
|
+
};
|
|
104
|
+
chat: {
|
|
105
|
+
write_message(text: string, opts?: Record<string, unknown>): Promise<void>;
|
|
106
|
+
};
|
|
107
|
+
artifact: {
|
|
108
|
+
create(args: Record<string, unknown>): Promise<unknown>;
|
|
109
|
+
};
|
|
110
|
+
llm: {
|
|
111
|
+
complete(args: Record<string, unknown>): Promise<unknown>;
|
|
112
|
+
};
|
|
113
|
+
fs: Record<string, (args?: unknown) => Promise<unknown>>;
|
|
114
|
+
prefs: {
|
|
115
|
+
get(key: string): Promise<unknown>;
|
|
116
|
+
set(key: string, value: unknown): Promise<void>;
|
|
117
|
+
};
|
|
118
|
+
window: {
|
|
119
|
+
set_title(title: string): Promise<void>;
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
interface MountedHarness {
|
|
123
|
+
runtime: MockRuntime;
|
|
124
|
+
calls: CallLog;
|
|
125
|
+
events: EventBus;
|
|
126
|
+
acl: Acl;
|
|
127
|
+
/** Replace / add a mock at any time. */
|
|
128
|
+
mock(key: string, handler: MockHandler): void;
|
|
129
|
+
/** Sleep helper for tests that need to flush microtasks. */
|
|
130
|
+
wait(ms: number): Promise<void>;
|
|
131
|
+
}
|
|
132
|
+
interface MountOptions {
|
|
133
|
+
manifest: ManifestForAcl & Record<string, unknown>;
|
|
134
|
+
/** Map keyed by `"ns.method"` (e.g. `"storage.set"`). */
|
|
135
|
+
mocks?: MockMap;
|
|
136
|
+
/** wid override (default: random). */
|
|
137
|
+
wid?: string;
|
|
138
|
+
/** Initial token (default: random). */
|
|
139
|
+
token?: string;
|
|
140
|
+
/** user_id passed to the SDK hello (default: 1). */
|
|
141
|
+
userId?: number;
|
|
142
|
+
/** Token TTL in ms; defaults to 5 minutes (mirrors production hint). */
|
|
143
|
+
tokenTtlMs?: number;
|
|
144
|
+
}
|
|
145
|
+
declare function mountBundle(opts: MountOptions): Promise<MountedHarness>;
|
|
146
|
+
/** Error class thrown for ACL denials or missing mocks. */
|
|
147
|
+
declare class HostApiError extends Error {
|
|
148
|
+
code: string;
|
|
149
|
+
constructor(code: string, message: string);
|
|
150
|
+
} //#endregion
|
|
151
|
+
export { Acl, CallLog, CallRecord, EventBus, HostApi, HostApiError, ManifestForAcl, MockHandler, MockMap, MockRuntime, MountOptions, MountedHarness, deriveAcl, isToolAllowed, mountBundle };
|