@frockbot/plugin-user-machine 0.0.0 → 0.1.1
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 +66 -0
- package/package.json +54 -6
- package/src/agent.test.ts +509 -0
- package/src/agent.ts +733 -0
- package/src/approval.test.ts +323 -0
- package/src/approval.ts +148 -0
- package/src/backend.test.ts +370 -0
- package/src/backend.ts +370 -0
- package/src/client/MachineSection.vue +97 -0
- package/src/client/MachineSurface.vue +308 -0
- package/src/client/index.test.ts +244 -0
- package/src/client/index.ts +180 -0
- package/src/client/state.ts +49 -0
- package/src/delivery.ts +145 -0
- package/src/desktop.test.ts +233 -0
- package/src/desktop.ts +238 -0
- package/src/device-runner.test.ts +326 -0
- package/src/device-runner.ts +205 -0
- package/src/device.test.ts +418 -0
- package/src/device.ts +707 -0
- package/src/env.d.ts +6 -0
- package/src/index.ts +13 -0
- package/src/intent.ts +325 -0
- package/src/manifest.ts +3 -0
- package/src/pairing.test.ts +85 -0
- package/src/pairing.ts +182 -0
- package/src/storage-keys.ts +102 -0
- package/src/store.test.ts +507 -0
- package/src/store.ts +638 -0
- package/src/target.ts +86 -0
- package/src/testing.ts +352 -0
- package/src/user.test.ts +182 -0
- package/src/user.ts +442 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// The routes, over the real User Contribution and real storage.
|
|
2
|
+
//
|
|
3
|
+
// The only thing faked here is the gateway itself: `route` and `publicRoute`
|
|
4
|
+
// are called the way `apps/cloudflare/src/gateway.ts` calls them, with a
|
|
5
|
+
// `userId` for the browser door and nothing at all for the machine's.
|
|
6
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import {
|
|
8
|
+
MACHINE_LIMITS_V1,
|
|
9
|
+
machineRoutePathV1,
|
|
10
|
+
mintMachineTokenV1,
|
|
11
|
+
} from "@frockbot/machine-protocol";
|
|
12
|
+
import {
|
|
13
|
+
createMachineBackendContribution,
|
|
14
|
+
type MachineBackendRouteContribution,
|
|
15
|
+
} from "./backend.ts";
|
|
16
|
+
import { MachineUserBackendContribution } from "./user.ts";
|
|
17
|
+
import { verifyMachinePairingCodeV1 } from "./pairing.ts";
|
|
18
|
+
import {
|
|
19
|
+
createMemoryMachineStorageV1,
|
|
20
|
+
MachineAgentDriverV1,
|
|
21
|
+
} from "./testing.ts";
|
|
22
|
+
|
|
23
|
+
const SECRET = "machine-route-secret-0123456789abcdef";
|
|
24
|
+
const USER = "route-user";
|
|
25
|
+
const ORIGIN = "https://bot.frockbot.com";
|
|
26
|
+
|
|
27
|
+
let authority: MachineUserBackendContribution;
|
|
28
|
+
let contribution: MachineBackendRouteContribution;
|
|
29
|
+
let now = Date.parse("2026-09-01T00:00:00.000Z");
|
|
30
|
+
|
|
31
|
+
/** One request through whichever door matches, as the gateway routes it. */
|
|
32
|
+
async function call(
|
|
33
|
+
method: string,
|
|
34
|
+
path: string,
|
|
35
|
+
init: {
|
|
36
|
+
userId?: string;
|
|
37
|
+
token?: string;
|
|
38
|
+
body?: unknown;
|
|
39
|
+
} = {},
|
|
40
|
+
): Promise<Response> {
|
|
41
|
+
const headers = new Headers();
|
|
42
|
+
if (init.token) headers.set("authorization", `Bearer ${init.token}`);
|
|
43
|
+
if (init.body !== undefined) headers.set("content-type", "application/json");
|
|
44
|
+
const request = new Request(`${ORIGIN}${path}`, {
|
|
45
|
+
method,
|
|
46
|
+
headers,
|
|
47
|
+
...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
|
|
48
|
+
});
|
|
49
|
+
const url = new URL(request.url);
|
|
50
|
+
const context = {
|
|
51
|
+
...(init.userId === undefined ? {} : { userId: init.userId }),
|
|
52
|
+
client: "browser" as const,
|
|
53
|
+
};
|
|
54
|
+
const machineDoor = await contribution.publicRoute?.(request, url, context);
|
|
55
|
+
return (
|
|
56
|
+
machineDoor ??
|
|
57
|
+
(await contribution.route(request, url, context)) ??
|
|
58
|
+
Response.json({ error: "no route" }, { status: 404 })
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function agent(
|
|
63
|
+
overrides: Partial<
|
|
64
|
+
ConstructorParameters<typeof MachineAgentDriverV1>[0]
|
|
65
|
+
> = {},
|
|
66
|
+
) {
|
|
67
|
+
return new MachineAgentDriverV1({
|
|
68
|
+
origin: ORIGIN,
|
|
69
|
+
fetch: async (input, requestInit) => {
|
|
70
|
+
const request = new Request(input, requestInit);
|
|
71
|
+
const url = new URL(request.url);
|
|
72
|
+
return (
|
|
73
|
+
(await contribution.publicRoute?.(request, url, {
|
|
74
|
+
client: "browser",
|
|
75
|
+
})) ??
|
|
76
|
+
(await contribution.route(request, url, { client: "browser" })) ??
|
|
77
|
+
Response.json({ error: "no route" }, { status: 404 })
|
|
78
|
+
);
|
|
79
|
+
},
|
|
80
|
+
now: () => now,
|
|
81
|
+
...overrides,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
now = Date.parse("2026-09-01T00:00:00.000Z");
|
|
87
|
+
const storage = createMemoryMachineStorageV1();
|
|
88
|
+
authority = new MachineUserBackendContribution({
|
|
89
|
+
storage,
|
|
90
|
+
readSecret: () => SECRET,
|
|
91
|
+
now: () => now,
|
|
92
|
+
sleep: () => Promise.resolve(),
|
|
93
|
+
});
|
|
94
|
+
contribution = createMachineBackendContribution({
|
|
95
|
+
machineTokenSecret: SECRET,
|
|
96
|
+
createMachinePairing: (userId, request) =>
|
|
97
|
+
authority.createPairing(userId, request),
|
|
98
|
+
enrollMachine: async (userId, input) =>
|
|
99
|
+
authority.enroll(
|
|
100
|
+
{ userId, machineId: input.machineId, nonce: "n" },
|
|
101
|
+
input.enrollment,
|
|
102
|
+
),
|
|
103
|
+
pollMachine: (_userId, callInput) =>
|
|
104
|
+
authority.poll(
|
|
105
|
+
callInput.claims,
|
|
106
|
+
callInput.tokenDigest,
|
|
107
|
+
callInput.machineId,
|
|
108
|
+
callInput.waitSeconds,
|
|
109
|
+
),
|
|
110
|
+
claimMachineCommand: (_userId, callInput) =>
|
|
111
|
+
authority.claim(
|
|
112
|
+
callInput.claims,
|
|
113
|
+
callInput.tokenDigest,
|
|
114
|
+
callInput.machineId,
|
|
115
|
+
callInput.commandId,
|
|
116
|
+
),
|
|
117
|
+
recordMachineResult: (_userId, callInput) =>
|
|
118
|
+
authority.recordResult(
|
|
119
|
+
callInput.claims,
|
|
120
|
+
callInput.tokenDigest,
|
|
121
|
+
callInput.machineId,
|
|
122
|
+
callInput.commandId,
|
|
123
|
+
callInput.result,
|
|
124
|
+
),
|
|
125
|
+
listMachines: () => authority.list(),
|
|
126
|
+
revokeMachine: (_userId, machineId) => authority.revoke(machineId),
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
async function pair(): Promise<{ code: string; machineId: string }> {
|
|
131
|
+
const response = await call("POST", machineRoutePathV1("pair"), {
|
|
132
|
+
userId: USER,
|
|
133
|
+
body: {},
|
|
134
|
+
});
|
|
135
|
+
expect(response.status).toBe(200);
|
|
136
|
+
const offer = (await response.json()) as { code: string; machineId: string };
|
|
137
|
+
return offer;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
describe("the browser door", () => {
|
|
141
|
+
test("pairing mints a one-time code that names its User and machine", async () => {
|
|
142
|
+
const offer = await pair();
|
|
143
|
+
expect(await verifyMachinePairingCodeV1(SECRET, offer.code)).toMatchObject({
|
|
144
|
+
userId: USER,
|
|
145
|
+
machineId: offer.machineId,
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("an unauthenticated browser route is not this Contribution's", async () => {
|
|
150
|
+
// No `userId` means the gateway has not authenticated anybody; `route`
|
|
151
|
+
// declines rather than answering, and the request falls through.
|
|
152
|
+
expect((await call("GET", machineRoutePathV1("list"))).status).toBe(404);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("the wrong method and a stray query parameter are refused", async () => {
|
|
156
|
+
expect(
|
|
157
|
+
(await call("GET", machineRoutePathV1("pair"), { userId: USER })).status,
|
|
158
|
+
).toBe(405);
|
|
159
|
+
expect(
|
|
160
|
+
(await call("POST", machineRoutePathV1("list"), { userId: USER })).status,
|
|
161
|
+
).toBe(405);
|
|
162
|
+
expect(
|
|
163
|
+
(await call("GET", `${machineRoutePathV1("list")}?q=1`, { userId: USER }))
|
|
164
|
+
.status,
|
|
165
|
+
).toBe(400);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("the registry reports connected, then not, on the presence TTL alone", async () => {
|
|
169
|
+
const offer = await pair();
|
|
170
|
+
const driver = agent();
|
|
171
|
+
await driver.enroll(offer.code);
|
|
172
|
+
const connected = (await (
|
|
173
|
+
await call("GET", machineRoutePathV1("list"), { userId: USER })
|
|
174
|
+
).json()) as { machines: Array<{ connected: boolean; label: string }> };
|
|
175
|
+
expect(connected.machines).toMatchObject([
|
|
176
|
+
{ connected: true, label: "Stub-Machine.local" },
|
|
177
|
+
]);
|
|
178
|
+
now += MACHINE_LIMITS_V1.presenceTtlMs + 1;
|
|
179
|
+
const offline = (await (
|
|
180
|
+
await call("GET", machineRoutePathV1("list"), { userId: USER })
|
|
181
|
+
).json()) as { machines: Array<{ connected: boolean }> };
|
|
182
|
+
expect(offline.machines).toMatchObject([{ connected: false }]);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("the machine door", () => {
|
|
187
|
+
test("enrollment answers a token, and the code is spent", async () => {
|
|
188
|
+
const offer = await pair();
|
|
189
|
+
const driver = agent();
|
|
190
|
+
const token = await driver.enroll(offer.code);
|
|
191
|
+
expect(token.length).toBeGreaterThan(0);
|
|
192
|
+
// A second enrollment with the same code is refused: the offer is gone.
|
|
193
|
+
expect(
|
|
194
|
+
await agent().attempt(machineRoutePathV1("enroll"), {
|
|
195
|
+
method: "POST",
|
|
196
|
+
token: offer.code,
|
|
197
|
+
body: JSON.stringify({
|
|
198
|
+
schemaVersion: 1,
|
|
199
|
+
code: offer.code,
|
|
200
|
+
label: "second.local",
|
|
201
|
+
platform: "macos",
|
|
202
|
+
agentVersion: "0.0.1",
|
|
203
|
+
capabilities: ["exec"],
|
|
204
|
+
}),
|
|
205
|
+
headers: { "content-type": "application/json" },
|
|
206
|
+
}),
|
|
207
|
+
).toBe(401);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("a code presented in the header but not the body is refused", async () => {
|
|
211
|
+
const offer = await pair();
|
|
212
|
+
const other = await pair();
|
|
213
|
+
expect(
|
|
214
|
+
await agent().attempt(machineRoutePathV1("enroll"), {
|
|
215
|
+
method: "POST",
|
|
216
|
+
token: offer.code,
|
|
217
|
+
body: JSON.stringify({
|
|
218
|
+
schemaVersion: 1,
|
|
219
|
+
code: other.code,
|
|
220
|
+
label: "mismatched.local",
|
|
221
|
+
platform: "macos",
|
|
222
|
+
agentVersion: "0.0.1",
|
|
223
|
+
capabilities: ["exec"],
|
|
224
|
+
}),
|
|
225
|
+
headers: { "content-type": "application/json" },
|
|
226
|
+
}),
|
|
227
|
+
).toBe(401);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("poll, claim and result refuse a missing, forged or foreign token", async () => {
|
|
231
|
+
const offer = await pair();
|
|
232
|
+
const driver = agent();
|
|
233
|
+
await driver.enroll(offer.code);
|
|
234
|
+
const machineId = driver.machineId!;
|
|
235
|
+
const poll = machineRoutePathV1("poll", { machineId });
|
|
236
|
+
expect(await driver.attempt(poll)).toBe(401);
|
|
237
|
+
expect(await driver.attempt(poll, { token: "not-a-token" })).toBe(401);
|
|
238
|
+
// A well-formed token for another machine, signed with the real secret:
|
|
239
|
+
// the path and the claims must agree.
|
|
240
|
+
const foreign = await mintMachineTokenV1(SECRET, {
|
|
241
|
+
u: USER,
|
|
242
|
+
m: crypto.randomUUID(),
|
|
243
|
+
v: 1,
|
|
244
|
+
});
|
|
245
|
+
expect(await driver.attempt(poll, { token: foreign })).toBe(401);
|
|
246
|
+
// …and one signed with another deployment's secret.
|
|
247
|
+
const elsewhere = await mintMachineTokenV1(
|
|
248
|
+
"another-deployment-secret-0123456789",
|
|
249
|
+
{ u: USER, m: machineId, v: 1 },
|
|
250
|
+
);
|
|
251
|
+
expect(await driver.attempt(poll, { token: elsewhere })).toBe(401);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("the wrong method and an unknown query parameter are refused", async () => {
|
|
255
|
+
const offer = await pair();
|
|
256
|
+
const driver = agent();
|
|
257
|
+
const token = await driver.enroll(offer.code);
|
|
258
|
+
const machineId = driver.machineId!;
|
|
259
|
+
expect(
|
|
260
|
+
await driver.attempt(machineRoutePathV1("poll", { machineId }), {
|
|
261
|
+
method: "POST",
|
|
262
|
+
token,
|
|
263
|
+
}),
|
|
264
|
+
).toBe(405);
|
|
265
|
+
expect(
|
|
266
|
+
await driver.attempt(
|
|
267
|
+
`${machineRoutePathV1("poll", { machineId })}?nope=1`,
|
|
268
|
+
{
|
|
269
|
+
token,
|
|
270
|
+
},
|
|
271
|
+
),
|
|
272
|
+
).toBe(400);
|
|
273
|
+
expect(
|
|
274
|
+
await driver.attempt(machineRoutePathV1("enroll"), { method: "GET" }),
|
|
275
|
+
).toBe(405);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("a poll, a claim, a result and a replay, end to end", async () => {
|
|
279
|
+
const offer = await pair();
|
|
280
|
+
const driver = agent();
|
|
281
|
+
await driver.enroll(offer.code);
|
|
282
|
+
const machineId = driver.machineId!;
|
|
283
|
+
await authority.dispatch({
|
|
284
|
+
schemaVersion: 1,
|
|
285
|
+
commandId: "tool:1:1:0",
|
|
286
|
+
machineId,
|
|
287
|
+
botId: "bot",
|
|
288
|
+
runId: "run",
|
|
289
|
+
turn: 1,
|
|
290
|
+
approvalId: "tool:1:1:0",
|
|
291
|
+
op: {
|
|
292
|
+
kind: "exec",
|
|
293
|
+
command: "git status",
|
|
294
|
+
timeoutMs: 30_000,
|
|
295
|
+
maxOutputBytes: 4_096,
|
|
296
|
+
},
|
|
297
|
+
issuedAt: new Date(now).toISOString(),
|
|
298
|
+
status: "queued",
|
|
299
|
+
});
|
|
300
|
+
const summary = await driver.runOnce();
|
|
301
|
+
expect(summary.delivered.map((command) => command.commandId)).toEqual([
|
|
302
|
+
"tool:1:1:0",
|
|
303
|
+
]);
|
|
304
|
+
expect(summary.claimed).toEqual(["tool:1:1:0"]);
|
|
305
|
+
expect(summary.reported).toEqual(["tool:1:1:0"]);
|
|
306
|
+
expect(await authority.readResult("tool:1:1:0")).toMatchObject({
|
|
307
|
+
outcome: "ok",
|
|
308
|
+
exitCode: 0,
|
|
309
|
+
});
|
|
310
|
+
// The queue is empty, and a second claim of a settled command is a 404.
|
|
311
|
+
expect(await driver.poll()).toEqual([]);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("a revoked machine's token fails every machine route", async () => {
|
|
315
|
+
const offer = await pair();
|
|
316
|
+
const driver = agent();
|
|
317
|
+
const token = await driver.enroll(offer.code);
|
|
318
|
+
const machineId = driver.machineId!;
|
|
319
|
+
expect(
|
|
320
|
+
(
|
|
321
|
+
await call("POST", machineRoutePathV1("revoke", { machineId }), {
|
|
322
|
+
userId: USER,
|
|
323
|
+
})
|
|
324
|
+
).status,
|
|
325
|
+
).toBe(200);
|
|
326
|
+
for (const path of [
|
|
327
|
+
machineRoutePathV1("poll", { machineId }),
|
|
328
|
+
machineRoutePathV1("claim", { machineId, commandId: "c" }),
|
|
329
|
+
machineRoutePathV1("result", { machineId, commandId: "c" }),
|
|
330
|
+
]) {
|
|
331
|
+
expect(
|
|
332
|
+
await driver.attempt(path, {
|
|
333
|
+
token,
|
|
334
|
+
method: path.endsWith("poll") ? "GET" : "POST",
|
|
335
|
+
body: path.endsWith("poll") ? undefined : JSON.stringify({}),
|
|
336
|
+
}),
|
|
337
|
+
).toBe(401);
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("without the deployment secret the machine door answers 503", async () => {
|
|
342
|
+
contribution = createMachineBackendContribution({
|
|
343
|
+
createMachinePairing: () => {
|
|
344
|
+
throw new Error("unreachable");
|
|
345
|
+
},
|
|
346
|
+
enrollMachine: () => {
|
|
347
|
+
throw new Error("unreachable");
|
|
348
|
+
},
|
|
349
|
+
pollMachine: () => {
|
|
350
|
+
throw new Error("unreachable");
|
|
351
|
+
},
|
|
352
|
+
claimMachineCommand: () => {
|
|
353
|
+
throw new Error("unreachable");
|
|
354
|
+
},
|
|
355
|
+
recordMachineResult: () => {
|
|
356
|
+
throw new Error("unreachable");
|
|
357
|
+
},
|
|
358
|
+
listMachines: () => {
|
|
359
|
+
throw new Error("unreachable");
|
|
360
|
+
},
|
|
361
|
+
revokeMachine: () => {
|
|
362
|
+
throw new Error("unreachable");
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
expect(
|
|
366
|
+
(await call("POST", machineRoutePathV1("enroll"), { token: "code" }))
|
|
367
|
+
.status,
|
|
368
|
+
).toBe(503);
|
|
369
|
+
});
|
|
370
|
+
});
|