@frockbot/plugin-audit 0.0.0 → 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/frockbot.json +37 -0
- package/package.json +43 -6
- package/src/backend.test.ts +193 -0
- package/src/backend.ts +148 -0
- package/src/bot.test.ts +250 -0
- package/src/bot.ts +312 -0
- package/src/classify.test.ts +174 -0
- package/src/classify.ts +151 -0
- package/src/client/AuditSection.vue +363 -0
- package/src/client/index.test.ts +160 -0
- package/src/client/index.ts +125 -0
- package/src/client/state.ts +35 -0
- package/src/env.d.ts +6 -0
- package/src/index.ts +24 -0
- package/src/manifest.ts +3 -0
- package/src/redact.test.ts +91 -0
- package/src/redact.ts +110 -0
- package/src/shared.ts +623 -0
- package/src/store.test.ts +168 -0
- package/src/store.ts +432 -0
- package/src/testing.ts +197 -0
- package/src/user.test.ts +139 -0
- package/src/user.ts +215 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/frockbot.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 4,
|
|
3
|
+
"id": "audit",
|
|
4
|
+
"displayName": "Audit",
|
|
5
|
+
"version": "0.0.1",
|
|
6
|
+
"compatibility": {
|
|
7
|
+
"frockbot": ">=0.0.1"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"flock": ">=0.0.1",
|
|
11
|
+
"shell": ">=0.0.1",
|
|
12
|
+
"ui-theme": ">=0.0.1"
|
|
13
|
+
},
|
|
14
|
+
"contributions": {
|
|
15
|
+
"backend": [
|
|
16
|
+
{
|
|
17
|
+
"entry": "./user",
|
|
18
|
+
"host": "user"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"entry": "./backend",
|
|
22
|
+
"host": "gateway"
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"client": {
|
|
26
|
+
"entry": "./client",
|
|
27
|
+
"mounts": [
|
|
28
|
+
{
|
|
29
|
+
"slot": "frockbot.bot-settings-sections",
|
|
30
|
+
"order": 20
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
"outlets": []
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"permissions": []
|
|
37
|
+
}
|
package/package.json
CHANGED
|
@@ -1,14 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-audit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./backend": "./src/backend.ts",
|
|
9
|
+
"./bot": "./src/bot.ts",
|
|
10
|
+
"./classify": "./src/classify.ts",
|
|
11
|
+
"./client": "./src/client/index.ts",
|
|
12
|
+
"./frockbot.json": "./frockbot.json",
|
|
13
|
+
"./manifest": "./src/manifest.ts",
|
|
14
|
+
"./package.json": "./package.json",
|
|
15
|
+
"./shared": "./src/shared.ts",
|
|
16
|
+
"./store": "./src/store.ts",
|
|
17
|
+
"./testing": "./src/testing.ts",
|
|
18
|
+
"./user": "./src/user.ts"
|
|
19
|
+
},
|
|
20
|
+
"frockbot": {
|
|
21
|
+
"manifest": "./frockbot.json"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "bun test src",
|
|
25
|
+
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@frockbot/client-core": "0.1.0",
|
|
29
|
+
"@frockbot/client-ui": "0.1.0",
|
|
30
|
+
"@frockbot/kernel-contracts": "0.1.0",
|
|
31
|
+
"@frockbot/plugin-shell": "0.1.0",
|
|
32
|
+
"@frockbot/secret-shapes": "0.1.0",
|
|
33
|
+
"cordis": "4.0.0-rc.8",
|
|
34
|
+
"vue": "3.5.41"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/bun": "1.4.0",
|
|
38
|
+
"@vitejs/plugin-vue": "6.0.8",
|
|
39
|
+
"typescript": "5.9.3",
|
|
40
|
+
"vite": "8.2.2",
|
|
41
|
+
"vue-tsc": "3.3.10"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
6
46
|
"repository": {
|
|
7
47
|
"type": "git",
|
|
8
48
|
"url": "git+https://github.com/timoconnellaus/frockbot.git",
|
|
9
49
|
"directory": "packages/plugin-audit"
|
|
10
|
-
},
|
|
11
|
-
"publishConfig": {
|
|
12
|
-
"access": "public"
|
|
13
50
|
}
|
|
14
51
|
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createAuditBackendContribution,
|
|
4
|
+
decodeAuditRequestQueryV1,
|
|
5
|
+
type AuditGatewayHost,
|
|
6
|
+
} from "./backend.ts";
|
|
7
|
+
import type {
|
|
8
|
+
AuditEntryV1,
|
|
9
|
+
AuditQueryV1,
|
|
10
|
+
AuditRebuildReceiptV1,
|
|
11
|
+
ClientAuditPageV1,
|
|
12
|
+
} from "./shared.ts";
|
|
13
|
+
|
|
14
|
+
function url(query: string): URL {
|
|
15
|
+
return new URL(`https://bot.frockbot.com/api/audit?${query}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("the audit query decoder", () => {
|
|
19
|
+
test("decodes exactly the parameters the route implements", () => {
|
|
20
|
+
expect(
|
|
21
|
+
decodeAuditRequestQueryV1(
|
|
22
|
+
url("botId=foreman&kind=shell&target=computer&before=p50&limit=20"),
|
|
23
|
+
),
|
|
24
|
+
).toEqual({
|
|
25
|
+
schemaVersion: 1,
|
|
26
|
+
botId: "foreman",
|
|
27
|
+
kind: "shell",
|
|
28
|
+
target: "computer",
|
|
29
|
+
before: "p50",
|
|
30
|
+
limit: 20,
|
|
31
|
+
});
|
|
32
|
+
expect(decodeAuditRequestQueryV1(url(""))).toEqual({ schemaVersion: 1 });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("refuses an unexpected parameter rather than ignoring it", () => {
|
|
36
|
+
// A client that means something the route does not implement finds out,
|
|
37
|
+
// instead of being handed a page it will misread as filtered.
|
|
38
|
+
expect(() => decodeAuditRequestQueryV1(url("userId=someone"))).toThrow(
|
|
39
|
+
"not allowed",
|
|
40
|
+
);
|
|
41
|
+
expect(() => decodeAuditRequestQueryV1(url("q=ls"))).toThrow("not allowed");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("refuses a repeated parameter", () => {
|
|
45
|
+
expect(() => decodeAuditRequestQueryV1(url("kind=shell&kind=mcp"))).toThrow(
|
|
46
|
+
"is repeated",
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("refuses a value the schema does not carry", () => {
|
|
51
|
+
expect(() => decodeAuditRequestQueryV1(url("kind=network"))).toThrow();
|
|
52
|
+
expect(() => decodeAuditRequestQueryV1(url("target=box"))).toThrow();
|
|
53
|
+
expect(() => decodeAuditRequestQueryV1(url("limit=0"))).toThrow();
|
|
54
|
+
expect(() => decodeAuditRequestQueryV1(url("limit=abc"))).toThrow();
|
|
55
|
+
expect(() =>
|
|
56
|
+
decodeAuditRequestQueryV1(url(`before=${"p".repeat(200)}`)),
|
|
57
|
+
).toThrow();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const ENTRY: AuditEntryV1 = {
|
|
62
|
+
schemaVersion: 1,
|
|
63
|
+
botId: "foreman",
|
|
64
|
+
runId: "run-1",
|
|
65
|
+
occurrenceId: "tool:1:1:0",
|
|
66
|
+
turn: 1,
|
|
67
|
+
step: 1,
|
|
68
|
+
ordinal: 0,
|
|
69
|
+
effectId: "tool:1:1:0",
|
|
70
|
+
at: "2026-08-31T00:00:00.000Z",
|
|
71
|
+
kind: "shell",
|
|
72
|
+
target: "computer",
|
|
73
|
+
toolName: "computer_exec",
|
|
74
|
+
argumentDigest: "a".repeat(64),
|
|
75
|
+
preview: "ls -la",
|
|
76
|
+
outcome: "ok",
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const RECEIPT: AuditRebuildReceiptV1 = {
|
|
80
|
+
schemaVersion: 1,
|
|
81
|
+
status: "rebuilt",
|
|
82
|
+
entries: 1,
|
|
83
|
+
bots: 1,
|
|
84
|
+
indexState: "ready",
|
|
85
|
+
unknownOutcomes: 0,
|
|
86
|
+
hostJournalDiscrepancies: 0,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
function host(
|
|
90
|
+
overrides: Partial<AuditGatewayHost> = {},
|
|
91
|
+
): AuditGatewayHost & { queries: AuditQueryV1[] } {
|
|
92
|
+
const queries: AuditQueryV1[] = [];
|
|
93
|
+
return {
|
|
94
|
+
queries,
|
|
95
|
+
readAudit: async (_userId, query) => {
|
|
96
|
+
queries.push(query);
|
|
97
|
+
const page: ClientAuditPageV1 = {
|
|
98
|
+
schemaVersion: 1,
|
|
99
|
+
entries: [ENTRY],
|
|
100
|
+
page: { truncated: false },
|
|
101
|
+
total: 1,
|
|
102
|
+
indexState: "ready",
|
|
103
|
+
};
|
|
104
|
+
return page;
|
|
105
|
+
},
|
|
106
|
+
rebuildAuditIndex: async () => RECEIPT,
|
|
107
|
+
...overrides,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function get(path: string): { request: Request; url: URL } {
|
|
112
|
+
const target = new URL(`https://bot.frockbot.com${path}`);
|
|
113
|
+
return { request: new Request(target), url: target };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
describe("the audit gateway route", () => {
|
|
117
|
+
const context = { userId: "alice", client: "browser" as const };
|
|
118
|
+
|
|
119
|
+
test("answers only its own paths, and only for an authenticated User", async () => {
|
|
120
|
+
const route = createAuditBackendContribution(host());
|
|
121
|
+
const other = get("/api/search?q=x");
|
|
122
|
+
expect(
|
|
123
|
+
await route.route(other.request, other.url, context),
|
|
124
|
+
).toBeUndefined();
|
|
125
|
+
const mine = get("/api/audit");
|
|
126
|
+
expect(
|
|
127
|
+
await route.route(mine.request, mine.url, {
|
|
128
|
+
client: "browser",
|
|
129
|
+
}),
|
|
130
|
+
).toBeUndefined();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("carries the decoded filters to the User Durable Object", async () => {
|
|
134
|
+
const gateway = host();
|
|
135
|
+
const route = createAuditBackendContribution(gateway);
|
|
136
|
+
const { request, url: target } = get("/api/audit?kind=mcp&botId=foreman");
|
|
137
|
+
const response = await route.route(request, target, context);
|
|
138
|
+
expect(response?.status).toBe(200);
|
|
139
|
+
expect(gateway.queries).toEqual([
|
|
140
|
+
{ schemaVersion: 1, botId: "foreman", kind: "mcp" },
|
|
141
|
+
]);
|
|
142
|
+
expect(await response!.json()).toMatchObject({ total: 1 });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("refuses an invalid query definitively, and a wrong method", async () => {
|
|
146
|
+
const route = createAuditBackendContribution(host());
|
|
147
|
+
const bad = get("/api/audit?userId=someone");
|
|
148
|
+
const refused = await route.route(bad.request, bad.url, context);
|
|
149
|
+
expect(refused?.status).toBe(400);
|
|
150
|
+
expect(await refused!.json()).toMatchObject({
|
|
151
|
+
code: "invalid-request",
|
|
152
|
+
definitive: true,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const posted = new URL("https://bot.frockbot.com/api/audit");
|
|
156
|
+
const wrong = await route.route(
|
|
157
|
+
new Request(posted, { method: "POST" }),
|
|
158
|
+
posted,
|
|
159
|
+
context,
|
|
160
|
+
);
|
|
161
|
+
expect(wrong?.status).toBe(405);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("rebuilds on POST and answers the receipt, discrepancies and all", async () => {
|
|
165
|
+
const route = createAuditBackendContribution(host());
|
|
166
|
+
const target = new URL("https://bot.frockbot.com/api/audit/rebuild");
|
|
167
|
+
const response = await route.route(
|
|
168
|
+
new Request(target, { method: "POST" }),
|
|
169
|
+
target,
|
|
170
|
+
context,
|
|
171
|
+
);
|
|
172
|
+
expect(response?.status).toBe(200);
|
|
173
|
+
expect(await response!.json()).toEqual(RECEIPT);
|
|
174
|
+
|
|
175
|
+
// A rebuild is a write; a GET must not perform one.
|
|
176
|
+
const read = await route.route(new Request(target), target, context);
|
|
177
|
+
expect(read?.status).toBe(405);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("turns an unexpected failure into a 500, not a leaked stack", async () => {
|
|
181
|
+
const route = createAuditBackendContribution(
|
|
182
|
+
host({
|
|
183
|
+
readAudit: () => Promise.reject(new Error("the User object is away")),
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
const { request, url: target } = get("/api/audit");
|
|
187
|
+
const response = await route.route(request, target, context);
|
|
188
|
+
expect(response?.status).toBe(500);
|
|
189
|
+
expect(await response!.json()).toEqual({
|
|
190
|
+
error: "the User object is away",
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
});
|
package/src/backend.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// The Audit Package's gateway Contribution.
|
|
2
|
+
//
|
|
3
|
+
// It sits on the same host as Flock's `/api/bots` and the Search Package's
|
|
4
|
+
// `/api/search`, for the same reason: the gateway is where an authenticated
|
|
5
|
+
// `userId` exists, and audit is User-scoped by construction — there is no
|
|
6
|
+
// cross-User table to leak from, and the User Durable Object refuses any RPC
|
|
7
|
+
// naming a User it is not.
|
|
8
|
+
//
|
|
9
|
+
// The route owns no state. It decodes the query string into the exact
|
|
10
|
+
// `AuditQueryV1` every other caller uses, asks the User Durable Object, and
|
|
11
|
+
// answers. An unexpected or repeated parameter is a refusal rather than
|
|
12
|
+
// something quietly ignored: a client that means something the route does not
|
|
13
|
+
// implement finds out, instead of being handed a page it will misread as
|
|
14
|
+
// filtered.
|
|
15
|
+
import type { Plugin } from "cordis";
|
|
16
|
+
import {
|
|
17
|
+
AUDIT_KINDS_V1,
|
|
18
|
+
AUDIT_MAX_CURSOR_LENGTH_V1,
|
|
19
|
+
AUDIT_MAX_RESULTS_V1,
|
|
20
|
+
AuditDecodeError,
|
|
21
|
+
decodeAuditQueryV1,
|
|
22
|
+
type AuditKindV1,
|
|
23
|
+
type AuditQueryV1,
|
|
24
|
+
type AuditRebuildReceiptV1,
|
|
25
|
+
type ClientAuditPageV1,
|
|
26
|
+
} from "./shared.js";
|
|
27
|
+
|
|
28
|
+
export interface AuditGatewayHost {
|
|
29
|
+
readAudit(userId: string, query: AuditQueryV1): Promise<ClientAuditPageV1>;
|
|
30
|
+
rebuildAuditIndex(userId: string): Promise<AuditRebuildReceiptV1>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AuditBackendRouteContribution {
|
|
34
|
+
packageId: string;
|
|
35
|
+
route(
|
|
36
|
+
request: Request,
|
|
37
|
+
url: URL,
|
|
38
|
+
context: { userId?: string; client: "browser" | "desktop" },
|
|
39
|
+
): Promise<Response | undefined>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ALLOWED_PARAMS = new Set(["botId", "kind", "target", "before", "limit"]);
|
|
43
|
+
|
|
44
|
+
/** The query string, decoded into the exact DTO. */
|
|
45
|
+
export function decodeAuditRequestQueryV1(url: URL): AuditQueryV1 {
|
|
46
|
+
for (const key of url.searchParams.keys()) {
|
|
47
|
+
if (!ALLOWED_PARAMS.has(key)) {
|
|
48
|
+
throw new AuditDecodeError(`audit query.${key} is not allowed`);
|
|
49
|
+
}
|
|
50
|
+
if (url.searchParams.getAll(key).length > 1) {
|
|
51
|
+
throw new AuditDecodeError(`audit query.${key} is repeated`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const botId = url.searchParams.get("botId");
|
|
55
|
+
const kind = url.searchParams.get("kind");
|
|
56
|
+
const target = url.searchParams.get("target");
|
|
57
|
+
const before = url.searchParams.get("before");
|
|
58
|
+
const limit = url.searchParams.get("limit");
|
|
59
|
+
if (kind !== null && !AUDIT_KINDS_V1.includes(kind as AuditKindV1)) {
|
|
60
|
+
throw new AuditDecodeError("audit query.kind is invalid");
|
|
61
|
+
}
|
|
62
|
+
if (before !== null && before.length > AUDIT_MAX_CURSOR_LENGTH_V1) {
|
|
63
|
+
throw new AuditDecodeError("audit query.before must be a bounded string");
|
|
64
|
+
}
|
|
65
|
+
if (limit !== null && !/^[0-9]{1,3}$/.test(limit)) {
|
|
66
|
+
throw new AuditDecodeError("audit query.limit must be a bounded integer");
|
|
67
|
+
}
|
|
68
|
+
return decodeAuditQueryV1({
|
|
69
|
+
schemaVersion: 1,
|
|
70
|
+
...(botId === null ? {} : { botId }),
|
|
71
|
+
...(kind === null ? {} : { kind }),
|
|
72
|
+
...(target === null ? {} : { target }),
|
|
73
|
+
...(before === null ? {} : { before }),
|
|
74
|
+
...(limit === null
|
|
75
|
+
? {}
|
|
76
|
+
: { limit: Math.min(Number(limit), AUDIT_MAX_RESULTS_V1) }),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function errorResponse(error: unknown): Response {
|
|
81
|
+
if (
|
|
82
|
+
error instanceof AuditDecodeError ||
|
|
83
|
+
(typeof error === "object" &&
|
|
84
|
+
error !== null &&
|
|
85
|
+
"name" in error &&
|
|
86
|
+
error.name === "AuditDecodeError")
|
|
87
|
+
) {
|
|
88
|
+
return Response.json(
|
|
89
|
+
{
|
|
90
|
+
error:
|
|
91
|
+
error instanceof Error ? error.message : "audit request is invalid",
|
|
92
|
+
code: "invalid-request",
|
|
93
|
+
definitive: true,
|
|
94
|
+
},
|
|
95
|
+
{ status: 400 },
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return Response.json(
|
|
99
|
+
{ error: error instanceof Error ? error.message : "audit read failed" },
|
|
100
|
+
{ status: 500 },
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function createAuditBackendContribution(
|
|
105
|
+
host: AuditGatewayHost,
|
|
106
|
+
): AuditBackendRouteContribution {
|
|
107
|
+
return {
|
|
108
|
+
packageId: "audit",
|
|
109
|
+
async route(request, url, context) {
|
|
110
|
+
if (!context.userId) return undefined;
|
|
111
|
+
const isRead = url.pathname === "/api/audit";
|
|
112
|
+
const isRebuild = url.pathname === "/api/audit/rebuild";
|
|
113
|
+
if (!isRead && !isRebuild) return undefined;
|
|
114
|
+
const userId = context.userId;
|
|
115
|
+
try {
|
|
116
|
+
if (isRebuild) {
|
|
117
|
+
if (request.method !== "POST") {
|
|
118
|
+
return Response.json(
|
|
119
|
+
{ error: "method not allowed" },
|
|
120
|
+
{ status: 405 },
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return Response.json(await host.rebuildAuditIndex(userId));
|
|
124
|
+
}
|
|
125
|
+
if (request.method !== "GET") {
|
|
126
|
+
return Response.json(
|
|
127
|
+
{ error: "method not allowed" },
|
|
128
|
+
{ status: 405 },
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return Response.json(
|
|
132
|
+
await host.readAudit(userId, decodeAuditRequestQueryV1(url)),
|
|
133
|
+
);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return errorResponse(error);
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export namespace createAuditBackendContribution {
|
|
142
|
+
export function plugin(
|
|
143
|
+
host: AuditGatewayHost,
|
|
144
|
+
lifecycle: { mount(value: AuditBackendRouteContribution): () => void },
|
|
145
|
+
): Plugin {
|
|
146
|
+
return () => lifecycle.mount(createAuditBackendContribution(host));
|
|
147
|
+
}
|
|
148
|
+
}
|
package/src/bot.test.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
AuditOutboxV1,
|
|
4
|
+
auditEntriesFromStoredRunV1,
|
|
5
|
+
type AuditProjectableRunV1,
|
|
6
|
+
} from "./bot.ts";
|
|
7
|
+
import { FakeAuditOutboxStorage } from "./testing.ts";
|
|
8
|
+
import { decodeAuditEntryV1, type AuditEntryV1 } from "./shared.ts";
|
|
9
|
+
|
|
10
|
+
const AT = "2026-08-31T02:00:00.000Z";
|
|
11
|
+
|
|
12
|
+
function call(
|
|
13
|
+
occurrenceId: string,
|
|
14
|
+
name: string,
|
|
15
|
+
input: unknown,
|
|
16
|
+
): AuditProjectableRunV1["events"][number] {
|
|
17
|
+
return { type: "tool/call", timestamp: AT, occurrenceId, name, input };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function result(
|
|
21
|
+
occurrenceId: string,
|
|
22
|
+
overrides: { content?: string; isError?: boolean; status?: string } = {},
|
|
23
|
+
): AuditProjectableRunV1["events"][number] {
|
|
24
|
+
return {
|
|
25
|
+
type: "tool/result",
|
|
26
|
+
timestamp: AT,
|
|
27
|
+
occurrenceId,
|
|
28
|
+
name: "computer_exec",
|
|
29
|
+
content: "",
|
|
30
|
+
isError: false,
|
|
31
|
+
status: "completed",
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function run(
|
|
37
|
+
events: AuditProjectableRunV1["events"],
|
|
38
|
+
status = "completed",
|
|
39
|
+
): AuditProjectableRunV1 {
|
|
40
|
+
return { runId: "run-1", status, events, acceptedAt: AT };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("projecting a settled run", () => {
|
|
44
|
+
test("audits the effects and ignores the rest", async () => {
|
|
45
|
+
const entries = await auditEntriesFromStoredRunV1(
|
|
46
|
+
"foreman",
|
|
47
|
+
run([
|
|
48
|
+
{ type: "run/admitted", timestamp: AT },
|
|
49
|
+
call("tool:1:1:0", "current_time", {}),
|
|
50
|
+
result("tool:1:1:0", { content: "02:00" }),
|
|
51
|
+
call("tool:1:2:0", "computer_exec", { command: "ls -la" }),
|
|
52
|
+
result("tool:1:2:0", { content: "a\nb" }),
|
|
53
|
+
call("tool:1:2:1", "mcp__example__echo", { message: "hi" }),
|
|
54
|
+
result("tool:1:2:1", { content: "hi" }),
|
|
55
|
+
]),
|
|
56
|
+
);
|
|
57
|
+
expect(entries.map((entry) => entry.toolName)).toEqual([
|
|
58
|
+
"computer_exec",
|
|
59
|
+
"mcp__example__echo",
|
|
60
|
+
]);
|
|
61
|
+
expect(entries[0]).toMatchObject({
|
|
62
|
+
botId: "foreman",
|
|
63
|
+
runId: "run-1",
|
|
64
|
+
occurrenceId: "tool:1:2:0",
|
|
65
|
+
// The occurrence id *is* the effect id: `plugin-shell` writes
|
|
66
|
+
// `occurrenceId: context.effectId`, so the Computer envelope joins here.
|
|
67
|
+
effectId: "tool:1:2:0",
|
|
68
|
+
turn: 1,
|
|
69
|
+
step: 2,
|
|
70
|
+
ordinal: 0,
|
|
71
|
+
kind: "shell",
|
|
72
|
+
target: "computer",
|
|
73
|
+
outcome: "ok",
|
|
74
|
+
preview: "ls -la",
|
|
75
|
+
bytesOut: 3,
|
|
76
|
+
});
|
|
77
|
+
expect(entries[1]).toMatchObject({
|
|
78
|
+
kind: "mcp",
|
|
79
|
+
target: "remote:example",
|
|
80
|
+
});
|
|
81
|
+
// Everything the table accepts, the decoder accepts.
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
expect(decodeAuditEntryV1(JSON.parse(JSON.stringify(entry)))).toEqual(
|
|
84
|
+
entry,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("never carries the arguments, only their digest", async () => {
|
|
90
|
+
const secret = "Bearer abcdefghijklmnopqrstuvwxyz0123";
|
|
91
|
+
const [entry] = await auditEntriesFromStoredRunV1(
|
|
92
|
+
"foreman",
|
|
93
|
+
run([
|
|
94
|
+
call("tool:1:1:0", "computer_exec", {
|
|
95
|
+
command: `curl -H '${secret}' https://api.example`,
|
|
96
|
+
env: { OPENAI_API_KEY: "sk-abcdefghijklmnopqrst" },
|
|
97
|
+
}),
|
|
98
|
+
result("tool:1:1:0"),
|
|
99
|
+
]),
|
|
100
|
+
);
|
|
101
|
+
const wire = JSON.stringify(entry);
|
|
102
|
+
expect(wire).not.toContain("abcdefghijklmnopqrstuvwxyz0123");
|
|
103
|
+
expect(wire).not.toContain("OPENAI_API_KEY");
|
|
104
|
+
expect(wire).not.toContain("env");
|
|
105
|
+
expect(entry!.preview).toContain("[redacted:bearer-token]");
|
|
106
|
+
expect(entry!.argumentDigest).toMatch(/^[0-9a-f]{64}$/);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("names each outcome the durable events actually support", async () => {
|
|
110
|
+
const outcomes = async (
|
|
111
|
+
overrides: Parameters<typeof result>[1] | undefined,
|
|
112
|
+
) => {
|
|
113
|
+
const events = [call("tool:1:1:0", "computer_exec", { command: "ls" })];
|
|
114
|
+
if (overrides) events.push(result("tool:1:1:0", overrides));
|
|
115
|
+
const [entry] = await auditEntriesFromStoredRunV1("foreman", run(events));
|
|
116
|
+
return entry?.outcome;
|
|
117
|
+
};
|
|
118
|
+
expect(await outcomes({ content: "ok" })).toBe("ok");
|
|
119
|
+
expect(await outcomes({ isError: true, content: "exit 1" })).toBe("error");
|
|
120
|
+
expect(
|
|
121
|
+
await outcomes({
|
|
122
|
+
isError: true,
|
|
123
|
+
content: "New calls are blocked while the user has taken control.",
|
|
124
|
+
}),
|
|
125
|
+
).toBe("refused");
|
|
126
|
+
expect(await outcomes({ status: "interrupted", content: "" })).toBe(
|
|
127
|
+
"interrupted",
|
|
128
|
+
);
|
|
129
|
+
// No result at all is `unknown`, never `error`: the durable log does not
|
|
130
|
+
// know how the effect ended, and inventing an answer is what the
|
|
131
|
+
// reconciliation rule forbids.
|
|
132
|
+
expect(await outcomes(undefined)).toBe("unknown");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("projects nothing until the run has settled, and is deterministic", async () => {
|
|
136
|
+
const events = [
|
|
137
|
+
call("tool:1:1:0", "computer_exec", { command: "ls" }),
|
|
138
|
+
result("tool:1:1:0"),
|
|
139
|
+
];
|
|
140
|
+
expect(
|
|
141
|
+
await auditEntriesFromStoredRunV1("foreman", run(events, "running")),
|
|
142
|
+
).toEqual([]);
|
|
143
|
+
const first = await auditEntriesFromStoredRunV1("foreman", run(events));
|
|
144
|
+
const second = await auditEntriesFromStoredRunV1("foreman", run(events));
|
|
145
|
+
expect(JSON.stringify(first)).toBe(JSON.stringify(second));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("skips a call whose occurrence id it cannot place", async () => {
|
|
149
|
+
expect(
|
|
150
|
+
await auditEntriesFromStoredRunV1(
|
|
151
|
+
"foreman",
|
|
152
|
+
run([
|
|
153
|
+
{
|
|
154
|
+
type: "tool/call",
|
|
155
|
+
timestamp: AT,
|
|
156
|
+
occurrenceId: "legacy-1",
|
|
157
|
+
name: "computer_exec",
|
|
158
|
+
input: { command: "ls" },
|
|
159
|
+
},
|
|
160
|
+
]),
|
|
161
|
+
),
|
|
162
|
+
).toEqual([]);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
function entries(count: number): AuditEntryV1[] {
|
|
167
|
+
return Array.from({ length: count }, (_, index) => ({
|
|
168
|
+
schemaVersion: 1,
|
|
169
|
+
botId: "foreman",
|
|
170
|
+
runId: "run-1",
|
|
171
|
+
occurrenceId: `tool:1:1:${index}`,
|
|
172
|
+
turn: 1,
|
|
173
|
+
step: 1,
|
|
174
|
+
ordinal: index,
|
|
175
|
+
effectId: `tool:1:1:${index}`,
|
|
176
|
+
at: AT,
|
|
177
|
+
kind: "shell",
|
|
178
|
+
target: "computer",
|
|
179
|
+
toolName: "computer_exec",
|
|
180
|
+
argumentDigest: "b".repeat(64),
|
|
181
|
+
preview: `ls ${index}`,
|
|
182
|
+
outcome: "ok",
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
describe("the Bot Durable Object's outbox", () => {
|
|
187
|
+
test("holds entries durably until a sink accepts them", async () => {
|
|
188
|
+
const outbox = new AuditOutboxV1(new FakeAuditOutboxStorage());
|
|
189
|
+
await outbox.append(entries(3));
|
|
190
|
+
expect(await outbox.state()).toEqual({ pending: 3, truncated: false });
|
|
191
|
+
|
|
192
|
+
// A sink that throws clears nothing. That is the entire point of a queue.
|
|
193
|
+
await expect(
|
|
194
|
+
outbox.drain({
|
|
195
|
+
indexEntries: () => Promise.reject(new Error("User object is away")),
|
|
196
|
+
}),
|
|
197
|
+
).rejects.toThrow("User object is away");
|
|
198
|
+
expect(await outbox.state()).toEqual({ pending: 3, truncated: false });
|
|
199
|
+
|
|
200
|
+
const delivered: AuditEntryV1[] = [];
|
|
201
|
+
const drain = await outbox.drain({
|
|
202
|
+
indexEntries: async (batch) => {
|
|
203
|
+
delivered.push(...batch);
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
expect(drain.delivered).toBe(3);
|
|
207
|
+
expect(delivered).toHaveLength(3);
|
|
208
|
+
expect(await outbox.state()).toEqual({ pending: 0, truncated: false });
|
|
209
|
+
// An empty outbox drains to a no-op rather than an empty delivery.
|
|
210
|
+
expect(
|
|
211
|
+
(await outbox.drain({ indexEntries: async () => {} })).delivered,
|
|
212
|
+
).toBe(0);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("appends idempotently, so a re-projected run queues nothing twice", async () => {
|
|
216
|
+
const outbox = new AuditOutboxV1(new FakeAuditOutboxStorage());
|
|
217
|
+
await outbox.append(entries(2));
|
|
218
|
+
await outbox.append(entries(2));
|
|
219
|
+
expect(await outbox.state()).toEqual({ pending: 2, truncated: false });
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("drops the oldest past its bound and records that it did", async () => {
|
|
223
|
+
const outbox = new AuditOutboxV1(new FakeAuditOutboxStorage(), {
|
|
224
|
+
maximum: 4,
|
|
225
|
+
});
|
|
226
|
+
await outbox.append(entries(6));
|
|
227
|
+
const state = await outbox.state();
|
|
228
|
+
expect(state).toEqual({ pending: 4, truncated: true });
|
|
229
|
+
|
|
230
|
+
const seen: string[] = [];
|
|
231
|
+
await outbox.drain({
|
|
232
|
+
indexEntries: async (batch) => {
|
|
233
|
+
seen.push(...batch.map((entry) => entry.occurrenceId));
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
// The newest four survived, and the loss outlives the drain: it describes
|
|
237
|
+
// what was lost, not what is pending.
|
|
238
|
+
expect(seen).toEqual([
|
|
239
|
+
"tool:1:1:2",
|
|
240
|
+
"tool:1:1:3",
|
|
241
|
+
"tool:1:1:4",
|
|
242
|
+
"tool:1:1:5",
|
|
243
|
+
]);
|
|
244
|
+
expect(await outbox.state()).toEqual({ pending: 0, truncated: true });
|
|
245
|
+
|
|
246
|
+
// Only a rebuild — which re-reads every run — may say the gap is closed.
|
|
247
|
+
await outbox.clearTruncation();
|
|
248
|
+
expect(await outbox.state()).toEqual({ pending: 0, truncated: false });
|
|
249
|
+
});
|
|
250
|
+
});
|