@frockbot/plugin-user-machine 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/src/desktop.ts ADDED
@@ -0,0 +1,238 @@
1
+ // The registered machine's device agent, as a `trusted-main` Contribution.
2
+ //
3
+ // The plan's open decision 6: the device agent is the FrockBot desktop app
4
+ // itself. It needs no new binary, no notarization and no installer, and it
5
+ // matches the register's own evidence that "registered machines" is a section
6
+ // of GrokBot's desktop Settings. `connected` reports exactly what is true —
7
+ // the app is running.
8
+ //
9
+ // This file is the wiring and nothing else. It imports no `electron` and no
10
+ // `node:*`, because the house rule is that Electron authority lives in one
11
+ // file in `apps/desktop` and reaches Packages as a cordis capability. What it
12
+ // does is:
13
+ //
14
+ // * build `MachineDeviceAgentV1` (the loop, in `./device.ts`) over the
15
+ // `desktopMachineHost` and `desktopSecretStore` capabilities,
16
+ // * register three desktop commands so the renderer can pair, unpair and
17
+ // read status, and
18
+ // * start the loop, and stop it on disposal.
19
+ //
20
+ // Everything decidable is in `./device.ts` and `./device-runner.ts`, both of
21
+ // which run under `bun test` with no Electron at all.
22
+
23
+ import type { DesktopCommand } from "@frockbot/desktop-core";
24
+ import {
25
+ MACHINE_LIMITS_V1,
26
+ type MachineCapabilityV1,
27
+ type MachinePlatformV1,
28
+ } from "@frockbot/machine-protocol";
29
+ import type { Plugin } from "cordis";
30
+ import {
31
+ createMachineDeviceRunnerV1,
32
+ type MachineMessagesOpRunnerV1,
33
+ } from "./device-runner.js";
34
+ import {
35
+ MachineDeviceAgentV1,
36
+ type MachineDeviceAgentStatusV1,
37
+ type MachineSecretStoreV1,
38
+ } from "./device.js";
39
+
40
+ /** The key the machine token rests under in the OS secure store. */
41
+ export const MACHINE_TOKEN_SECRET_KEY_V1 = "frockbot.machine-token";
42
+
43
+ export const MACHINE_AGENT_STATUS_COMMAND_V1 = "machine.agent.status";
44
+ export const MACHINE_AGENT_PAIR_COMMAND_V1 = "machine.agent.pair";
45
+ export const MACHINE_AGENT_UNPAIR_COMMAND_V1 = "machine.agent.unpair";
46
+
47
+ export interface MachineDesktopConfigV1 {
48
+ /** The deployment this laptop dials. Never a path — an origin. */
49
+ origin: string;
50
+ /** What the agent reports as its own version at enrollment. */
51
+ agentVersion: string;
52
+ /** Injected in tests; the platform's `fetch` otherwise. */
53
+ fetch?(input: string, init?: RequestInit): Promise<Response>;
54
+ /**
55
+ * Row 57g's Messages handlers, supplied by the Electron shell on macOS.
56
+ *
57
+ * Absent — every other platform, and a macOS build whose shell wired none —
58
+ * and this agent does not report the `messages` capability at all, so the
59
+ * backend never registers a Messages tool against it. Two halves of one gate:
60
+ * the enrollment decoder refuses `messages` from a non-macOS agent, and this
61
+ * refuses to claim it without something behind it.
62
+ */
63
+ messages?: MachineMessagesOpRunnerV1;
64
+ /** Started on mount unless a test wants to drive cycles by hand. */
65
+ autoStart?: boolean;
66
+ }
67
+
68
+ export interface MachinePairCommandInputV1 {
69
+ code: string;
70
+ }
71
+
72
+ export function decodeMachinePairCommandInputV1(
73
+ input: unknown,
74
+ ): MachinePairCommandInputV1 {
75
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
76
+ throw new Error("machine pairing input must be an object");
77
+ }
78
+ const value = input as Record<string, unknown>;
79
+ const keys = Reflect.ownKeys(value);
80
+ if (keys.length !== 1 || keys[0] !== "code") {
81
+ throw new Error("machine pairing input takes only a code");
82
+ }
83
+ const code = typeof value.code === "string" ? value.code.trim() : "";
84
+ if (!code || code.length > MACHINE_LIMITS_V1.pairingCode) {
85
+ throw new Error("machine pairing code is required");
86
+ }
87
+ return { code };
88
+ }
89
+
90
+ /** The two commands with no input at all take no input at all. */
91
+ export function decodeMachineEmptyCommandInputV1(
92
+ input: unknown,
93
+ ): Record<string, never> {
94
+ if (
95
+ typeof input !== "object" ||
96
+ input === null ||
97
+ Array.isArray(input) ||
98
+ Reflect.ownKeys(input).length > 0
99
+ ) {
100
+ throw new Error("this machine agent command takes no input");
101
+ }
102
+ return {};
103
+ }
104
+
105
+ /**
106
+ * The secret store, narrowed to one key.
107
+ *
108
+ * The capability is a general key-value store because the shell may hold other
109
+ * secrets later; the agent should not be able to read them, so it is handed a
110
+ * closure over its own key and nothing else.
111
+ */
112
+ export function machineSecretStoreV1(
113
+ store: {
114
+ read(key: string): Promise<string | undefined>;
115
+ write(key: string, value: string): Promise<void>;
116
+ clear(key: string): Promise<void>;
117
+ },
118
+ key: string = MACHINE_TOKEN_SECRET_KEY_V1,
119
+ ): MachineSecretStoreV1 {
120
+ return {
121
+ read: () => store.read(key),
122
+ write: (value) => store.write(key, value),
123
+ clear: () => store.clear(key),
124
+ };
125
+ }
126
+
127
+ /**
128
+ * What this agent reports it can do on every platform the shell runs on.
129
+ */
130
+ export const MACHINE_DESKTOP_CAPABILITIES_V1: readonly MachineCapabilityV1[] = [
131
+ "exec",
132
+ "files",
133
+ ];
134
+
135
+ /**
136
+ * What this agent reports, given its platform and what the shell wired.
137
+ *
138
+ * Row 57g's second gate, and it is deliberately conjunctive: `messages` is
139
+ * claimed only by a macOS agent that actually has handlers behind it. Pure, so
140
+ * the gate is asserted rather than inferred from a running Electron app.
141
+ */
142
+ export function machineDesktopCapabilitiesV1(
143
+ platform: MachinePlatformV1,
144
+ messages: boolean,
145
+ ): MachineCapabilityV1[] {
146
+ return [
147
+ ...MACHINE_DESKTOP_CAPABILITIES_V1,
148
+ ...(platform === "macos" && messages
149
+ ? (["messages"] as MachineCapabilityV1[])
150
+ : []),
151
+ ];
152
+ }
153
+
154
+ export const machineDesktopPlugin: Plugin.Function<MachineDesktopConfigV1> = (
155
+ ctx,
156
+ config,
157
+ ) => {
158
+ const identity = ctx.desktopMachineHost.identity();
159
+ const capabilities = machineDesktopCapabilitiesV1(
160
+ identity.platform,
161
+ config.messages !== undefined,
162
+ );
163
+ const agent = new MachineDeviceAgentV1({
164
+ origin: config.origin,
165
+ fetch: config.fetch ?? ((input, init) => fetch(input, init)),
166
+ secrets: machineSecretStoreV1(ctx.desktopSecretStore),
167
+ runner: createMachineDeviceRunnerV1({
168
+ host: ctx.desktopMachineHost,
169
+ capabilities,
170
+ ...(config.messages === undefined ? {} : { messages: config.messages }),
171
+ }),
172
+ label: identity.label,
173
+ platform: identity.platform,
174
+ agentVersion: config.agentVersion,
175
+ capabilities,
176
+ });
177
+
178
+ const status: DesktopCommand<
179
+ Record<string, never>,
180
+ MachineDeviceAgentStatusV1
181
+ > = {
182
+ id: MACHINE_AGENT_STATUS_COMMAND_V1,
183
+ decode: decodeMachineEmptyCommandInputV1,
184
+ execute: async () => {
185
+ await agent.paired();
186
+ return agent.status();
187
+ },
188
+ };
189
+
190
+ const pair: DesktopCommand<
191
+ MachinePairCommandInputV1,
192
+ MachineDeviceAgentStatusV1
193
+ > = {
194
+ id: MACHINE_AGENT_PAIR_COMMAND_V1,
195
+ decode: decodeMachinePairCommandInputV1,
196
+ execute: async (input) => {
197
+ const paired = await agent.pair(input.code);
198
+ // The loop is what makes the machine read `connected`, so pairing starts
199
+ // it rather than waiting for the next launch.
200
+ agent.start();
201
+ return paired;
202
+ },
203
+ };
204
+
205
+ const unpair: DesktopCommand<
206
+ Record<string, never>,
207
+ MachineDeviceAgentStatusV1
208
+ > = {
209
+ id: MACHINE_AGENT_UNPAIR_COMMAND_V1,
210
+ decode: decodeMachineEmptyCommandInputV1,
211
+ execute: () => agent.unpair(),
212
+ };
213
+
214
+ const registrations = [
215
+ ctx.desktopCommands.register(status),
216
+ ctx.desktopCommands.register(pair),
217
+ ctx.desktopCommands.register(unpair),
218
+ ];
219
+
220
+ if (config.autoStart !== false) {
221
+ // Only if this laptop already holds a token: an unpaired agent has nothing
222
+ // to poll, and the loop would otherwise idle against a door it has no key
223
+ // to.
224
+ void agent.paired().then((paired) => {
225
+ if (paired) agent.start();
226
+ });
227
+ }
228
+
229
+ return [...registrations, () => void agent.stop()];
230
+ };
231
+
232
+ machineDesktopPlugin.inject = [
233
+ "desktopCommands",
234
+ "desktopMachineHost",
235
+ "desktopSecretStore",
236
+ ];
237
+
238
+ export default machineDesktopPlugin;
@@ -0,0 +1,326 @@
1
+ // What a command becomes, with a fake laptop underneath.
2
+ //
3
+ // Every classification the agent makes about an op's result is here, which is
4
+ // the point of the split: `apps/desktop`'s host spawns and reads, and decides
5
+ // nothing, so the decisions are all provable in CI.
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import type {
9
+ DesktopMachineExecResult,
10
+ DesktopMachineFileResult,
11
+ } from "@frockbot/desktop-core";
12
+ import type { MachineCommandV1, MachineOpV1 } from "@frockbot/machine-protocol";
13
+ import {
14
+ createMachineDeviceRunnerV1,
15
+ machineRefusalV1,
16
+ type MachineDeviceHostV1,
17
+ } from "./device-runner.js";
18
+
19
+ const NOW = Date.parse("2026-09-01T00:00:05.000Z");
20
+
21
+ function commandFor(op: MachineOpV1): MachineCommandV1 {
22
+ return {
23
+ schemaVersion: 1,
24
+ commandId: "tool-0-1-0",
25
+ machineId: "m-1",
26
+ botId: "scout",
27
+ runId: "run-1",
28
+ turn: 1,
29
+ approvalId: "tool-0-1-0",
30
+ op,
31
+ issuedAt: "2026-09-01T00:00:00.000Z",
32
+ status: "claimed",
33
+ };
34
+ }
35
+
36
+ function host(overrides: Partial<MachineDeviceHostV1> = {}): {
37
+ host: MachineDeviceHostV1;
38
+ execCalls: unknown[];
39
+ readCalls: unknown[];
40
+ } {
41
+ const execCalls: unknown[] = [];
42
+ const readCalls: unknown[] = [];
43
+ return {
44
+ execCalls,
45
+ readCalls,
46
+ host: {
47
+ identity: () => ({ label: "Laptop", platform: "macos" }),
48
+ exec: (request) => {
49
+ execCalls.push(request);
50
+ return Promise.resolve({
51
+ exitCode: 0,
52
+ stdout: "",
53
+ stderr: "",
54
+ truncated: false,
55
+ timedOut: false,
56
+ } satisfies DesktopMachineExecResult);
57
+ },
58
+ readFile: (request) => {
59
+ readCalls.push(request);
60
+ return Promise.resolve({
61
+ bytesBase64: "aGk=",
62
+ truncated: false,
63
+ } satisfies DesktopMachineFileResult);
64
+ },
65
+ ...overrides,
66
+ },
67
+ };
68
+ }
69
+
70
+ function runner(
71
+ fake: MachineDeviceHostV1,
72
+ capabilities: Array<"exec" | "files"> = ["exec", "files"],
73
+ ): ReturnType<typeof createMachineDeviceRunnerV1> {
74
+ return createMachineDeviceRunnerV1({
75
+ host: fake,
76
+ capabilities,
77
+ now: () => NOW,
78
+ });
79
+ }
80
+
81
+ const signal = new AbortController().signal;
82
+
83
+ describe("machine device runner", () => {
84
+ test("an exec that exits zero is ok, and the bounds are the op's", async () => {
85
+ const requests: unknown[] = [];
86
+ const fake = host({
87
+ exec: (request) => {
88
+ requests.push(request);
89
+ return Promise.resolve({
90
+ exitCode: 0,
91
+ stdout: "hello\n",
92
+ stderr: "",
93
+ truncated: false,
94
+ timedOut: false,
95
+ });
96
+ },
97
+ });
98
+ const report = await runner(fake.host).run(
99
+ commandFor({
100
+ kind: "exec",
101
+ command: "echo hello",
102
+ cwd: "/tmp",
103
+ timeoutMs: 1_000,
104
+ maxOutputBytes: 64,
105
+ }),
106
+ signal,
107
+ );
108
+
109
+ expect(report).toEqual({
110
+ finishedAt: "2026-09-01T00:00:05.000Z",
111
+ outcome: "ok",
112
+ truncated: false,
113
+ exitCode: 0,
114
+ stdout: "hello\n",
115
+ stderr: "",
116
+ });
117
+ expect(requests[0]).toEqual({
118
+ command: "echo hello",
119
+ cwd: "/tmp",
120
+ timeoutMs: 1_000,
121
+ maxOutputBytes: 64,
122
+ });
123
+ });
124
+
125
+ test("a non-zero exit is an error, and a killed command is a timeout", async () => {
126
+ const failing = host({
127
+ exec: () =>
128
+ Promise.resolve({
129
+ exitCode: 2,
130
+ stdout: "",
131
+ stderr: "no such file\n",
132
+ truncated: false,
133
+ timedOut: false,
134
+ }),
135
+ });
136
+ expect(
137
+ await runner(failing.host).run(
138
+ commandFor({
139
+ kind: "exec",
140
+ command: "cat missing",
141
+ timeoutMs: 1_000,
142
+ maxOutputBytes: 64,
143
+ }),
144
+ signal,
145
+ ),
146
+ ).toMatchObject({
147
+ outcome: "error",
148
+ exitCode: 2,
149
+ stderr: "no such file\n",
150
+ });
151
+
152
+ const killed = host({
153
+ exec: () =>
154
+ Promise.resolve({
155
+ stdout: "partial",
156
+ stderr: "",
157
+ truncated: true,
158
+ timedOut: true,
159
+ }),
160
+ });
161
+ const report = await runner(killed.host).run(
162
+ commandFor({
163
+ kind: "exec",
164
+ command: "sleep 60",
165
+ timeoutMs: 25,
166
+ maxOutputBytes: 4,
167
+ }),
168
+ signal,
169
+ );
170
+ expect(report).toMatchObject({
171
+ outcome: "timeout",
172
+ truncated: true,
173
+ message: "the command was killed after 25ms",
174
+ });
175
+ // A killed process has no exit code, and one is not invented.
176
+ expect(report.exitCode).toBeUndefined();
177
+ });
178
+
179
+ test("a read answers base64 and carries truncation through", async () => {
180
+ const fake = host({
181
+ readFile: () =>
182
+ Promise.resolve({ bytesBase64: "dHJ1bmM=", truncated: true }),
183
+ });
184
+ const report = await runner(fake.host).run(
185
+ commandFor({ kind: "read", path: "/etc/hosts", maxBytes: 8 }),
186
+ signal,
187
+ );
188
+ expect(report).toEqual({
189
+ finishedAt: "2026-09-01T00:00:05.000Z",
190
+ outcome: "ok",
191
+ truncated: true,
192
+ bytesBase64: "dHJ1bmM=",
193
+ });
194
+ });
195
+
196
+ test("a copy to the Computer reads the machine's file", async () => {
197
+ const fake = host();
198
+ const report = await runner(fake.host).run(
199
+ commandFor({
200
+ kind: "copy-to-computer",
201
+ path: "/Users/tim/notes.txt",
202
+ workspacePath: "notes.txt",
203
+ }),
204
+ signal,
205
+ );
206
+ expect(report.outcome).toBe("ok");
207
+ expect(fake.readCalls[0]).toMatchObject({ path: "/Users/tim/notes.txt" });
208
+ });
209
+
210
+ test("a copy from the Computer refuses visibly, because v1 carries no bytes", async () => {
211
+ const fake = host();
212
+ const report = await runner(fake.host).run(
213
+ commandFor({
214
+ kind: "copy-from-computer",
215
+ path: "/Users/tim/notes.txt",
216
+ workspacePath: "notes.txt",
217
+ }),
218
+ signal,
219
+ );
220
+ expect(report.outcome).toBe("refused");
221
+ expect(report.message).toStartWith("Refused: ");
222
+ expect(fake.readCalls).toEqual([]);
223
+ expect(fake.execCalls).toEqual([]);
224
+ });
225
+
226
+ test("an op the agent never reported the capability for is refused, not run", async () => {
227
+ const fake = host();
228
+ const report = await runner(fake.host, ["files"]).run(
229
+ commandFor({
230
+ kind: "exec",
231
+ command: "rm -rf /",
232
+ timeoutMs: 1_000,
233
+ maxOutputBytes: 64,
234
+ }),
235
+ signal,
236
+ );
237
+ expect(report.message).toBe(
238
+ machineRefusalV1("this machine's agent does not offer shell execution"),
239
+ );
240
+ expect(fake.execCalls).toEqual([]);
241
+ });
242
+
243
+ test("a host that throws becomes an error result, never an escape", async () => {
244
+ const fake = host({
245
+ readFile: () => Promise.reject(new Error("ENOENT: no such file")),
246
+ });
247
+ expect(
248
+ await runner(fake.host).run(
249
+ commandFor({ kind: "read", path: "/nope", maxBytes: 8 }),
250
+ signal,
251
+ ),
252
+ ).toMatchObject({ outcome: "error", message: "ENOENT: no such file" });
253
+ });
254
+ });
255
+
256
+ describe("a messages op (register row 57g)", () => {
257
+ const messagesOp: MachineOpV1 = {
258
+ kind: "messages",
259
+ call: { kind: "activity", limit: 5 },
260
+ };
261
+
262
+ test("is handed to the handler the shell wired, verbatim", async () => {
263
+ const seen: unknown[] = [];
264
+ const runner = createMachineDeviceRunnerV1({
265
+ host: host().host,
266
+ capabilities: ["exec", "files", "messages"],
267
+ now: () => NOW,
268
+ messages: (call) => {
269
+ seen.push(call);
270
+ return Promise.resolve({
271
+ finishedAt: "2026-09-01T00:00:05.000Z",
272
+ outcome: "ok",
273
+ truncated: false,
274
+ stdout: '{"kind":"items","items":[]}',
275
+ });
276
+ },
277
+ });
278
+ const report = await runner.run(
279
+ commandFor(messagesOp),
280
+ new AbortController().signal,
281
+ );
282
+ expect(seen).toEqual([{ kind: "activity", limit: 5 }]);
283
+ expect(report.outcome).toBe("ok");
284
+ });
285
+
286
+ test("an agent with no handler refuses rather than answering an empty inbox", async () => {
287
+ const runner = createMachineDeviceRunnerV1({
288
+ host: host().host,
289
+ // The record says it can; this build cannot. Saying so is the only
290
+ // honest answer — an empty result would read as an empty Messages app.
291
+ capabilities: ["exec", "files", "messages"],
292
+ now: () => NOW,
293
+ });
294
+ const report = await runner.run(
295
+ commandFor(messagesOp),
296
+ new AbortController().signal,
297
+ );
298
+ expect(report.outcome).toBe("refused");
299
+ expect(report.message).toBe(
300
+ machineRefusalV1("this machine's agent does not offer Messages access"),
301
+ );
302
+ });
303
+
304
+ test("an agent that never reported the capability refuses before the handler", async () => {
305
+ let called = false;
306
+ const runner = createMachineDeviceRunnerV1({
307
+ host: host().host,
308
+ capabilities: ["exec", "files"],
309
+ now: () => NOW,
310
+ messages: () => {
311
+ called = true;
312
+ return Promise.resolve({
313
+ finishedAt: "2026-09-01T00:00:05.000Z",
314
+ outcome: "ok",
315
+ truncated: false,
316
+ });
317
+ },
318
+ });
319
+ const report = await runner.run(
320
+ commandFor(messagesOp),
321
+ new AbortController().signal,
322
+ );
323
+ expect(report.outcome).toBe("refused");
324
+ expect(called).toBe(false);
325
+ });
326
+ });