@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,205 @@
|
|
|
1
|
+
// What one machine command becomes on the laptop.
|
|
2
|
+
//
|
|
3
|
+
// This is the half of the device agent the plan calls "everything but actual
|
|
4
|
+
// `child_process` execution": it turns a `MachineOpV1` into calls on the
|
|
5
|
+
// desktop host seam and turns what came back into a `MachineCommandResultV1`.
|
|
6
|
+
// Every classification decision lives here — which outcome a killed process
|
|
7
|
+
// gets, what `truncated` means for a file read, what a machine without the
|
|
8
|
+
// capability an op needs answers — so all of it runs in `bun test` against a
|
|
9
|
+
// fake host, and the Electron file it is wired to holds no policy at all.
|
|
10
|
+
//
|
|
11
|
+
// One refusal is stated rather than hidden. `copy-from-computer` names a
|
|
12
|
+
// Workspace path, and machine protocol v1 carries no Workspace bytes: the
|
|
13
|
+
// command DTO has `path` and `workspacePath` and nothing else, and there is no
|
|
14
|
+
// route on which an agent could fetch the file. So the agent refuses it
|
|
15
|
+
// visibly, in the vocabulary the audit classifier already reads, instead of
|
|
16
|
+
// inventing a transfer the protocol does not have. Widening the protocol is a
|
|
17
|
+
// version bump and belongs with whoever adds the route.
|
|
18
|
+
|
|
19
|
+
import type {
|
|
20
|
+
DesktopMachineExecResult,
|
|
21
|
+
DesktopMachineFileResult,
|
|
22
|
+
DesktopMachineExecRequest,
|
|
23
|
+
DesktopMachineFileRequest,
|
|
24
|
+
DesktopMachineIdentity,
|
|
25
|
+
} from "@frockbot/desktop-core";
|
|
26
|
+
import {
|
|
27
|
+
MACHINE_LIMITS_V1,
|
|
28
|
+
type MachineCapabilityV1,
|
|
29
|
+
type MachineCommandV1,
|
|
30
|
+
type MachineMessagesCallV1,
|
|
31
|
+
machineOpCapabilityV1,
|
|
32
|
+
} from "@frockbot/machine-protocol";
|
|
33
|
+
import type {
|
|
34
|
+
MachineCommandReportV1,
|
|
35
|
+
MachineCommandRunnerV1,
|
|
36
|
+
} from "./device.js";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The desktop host, structurally.
|
|
40
|
+
*
|
|
41
|
+
* `DesktopMachineHostCapability` satisfies this, and so does a plain object in
|
|
42
|
+
* a test. The runner never needs the cordis `Service` half of the capability,
|
|
43
|
+
* so it does not ask for it.
|
|
44
|
+
*/
|
|
45
|
+
export interface MachineDeviceHostV1 {
|
|
46
|
+
identity(): DesktopMachineIdentity;
|
|
47
|
+
exec(
|
|
48
|
+
request: DesktopMachineExecRequest,
|
|
49
|
+
signal: AbortSignal,
|
|
50
|
+
): Promise<DesktopMachineExecResult>;
|
|
51
|
+
readFile(
|
|
52
|
+
request: DesktopMachineFileRequest,
|
|
53
|
+
signal: AbortSignal,
|
|
54
|
+
): Promise<DesktopMachineFileResult>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* What runs one Messages.app call on this laptop (register row 57g).
|
|
59
|
+
*
|
|
60
|
+
* A seam rather than a branch, and typed in the protocol's own vocabulary, so
|
|
61
|
+
* this Package never learns what `chat.db` is: the Messages Package builds the
|
|
62
|
+
* handler, the Electron shell hands it in, and an agent given no handler
|
|
63
|
+
* reports no `messages` capability and refuses the op if one arrives anyway.
|
|
64
|
+
*/
|
|
65
|
+
export type MachineMessagesOpRunnerV1 = (
|
|
66
|
+
call: MachineMessagesCallV1,
|
|
67
|
+
signal: AbortSignal,
|
|
68
|
+
) => Promise<MachineCommandReportV1>;
|
|
69
|
+
|
|
70
|
+
export interface MachineDeviceRunnerOptionsV1 {
|
|
71
|
+
host: MachineDeviceHostV1;
|
|
72
|
+
/** What this agent told the backend it can do. An op outside it is refused. */
|
|
73
|
+
capabilities: readonly MachineCapabilityV1[];
|
|
74
|
+
/** Present only on a macOS agent whose shell wired the Messages handlers. */
|
|
75
|
+
messages?: MachineMessagesOpRunnerV1;
|
|
76
|
+
now?(): number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The refusal wording, once.
|
|
81
|
+
*
|
|
82
|
+
* It begins "Refused:" because `plugin-audit`'s `outcomeFor` classifies on
|
|
83
|
+
* that prefix: a refusal that reads as an error would show up in the audit as
|
|
84
|
+
* a failure of the machine rather than a decision by it.
|
|
85
|
+
*/
|
|
86
|
+
export function machineRefusalV1(reason: string): string {
|
|
87
|
+
return `Refused: ${reason}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const CAPABILITY_REASON: Record<MachineCapabilityV1, string> = {
|
|
91
|
+
exec: "this machine's agent does not offer shell execution",
|
|
92
|
+
files: "this machine's agent does not offer file access",
|
|
93
|
+
messages: "this machine's agent does not offer Messages access",
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The runner the desktop contribution hands to `MachineDeviceAgentV1`.
|
|
98
|
+
*
|
|
99
|
+
* It never throws: the agent wraps it anyway, but a runner that answers
|
|
100
|
+
* instead of throwing is the difference between a Bot reading "the file was
|
|
101
|
+
* not there" and a Bot reading a stack trace.
|
|
102
|
+
*/
|
|
103
|
+
export function createMachineDeviceRunnerV1(
|
|
104
|
+
options: MachineDeviceRunnerOptionsV1,
|
|
105
|
+
): MachineCommandRunnerV1 {
|
|
106
|
+
const now = (): string =>
|
|
107
|
+
new Date(options.now?.() ?? Date.now()).toISOString();
|
|
108
|
+
|
|
109
|
+
const refuse = (reason: string): MachineCommandReportV1 => ({
|
|
110
|
+
finishedAt: now(),
|
|
111
|
+
outcome: "refused",
|
|
112
|
+
truncated: false,
|
|
113
|
+
message: machineRefusalV1(reason),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const failed = (error: unknown): MachineCommandReportV1 => ({
|
|
117
|
+
finishedAt: now(),
|
|
118
|
+
outcome: "error",
|
|
119
|
+
truncated: false,
|
|
120
|
+
message: (error instanceof Error ? error.message : String(error)).slice(
|
|
121
|
+
0,
|
|
122
|
+
MACHINE_LIMITS_V1.message,
|
|
123
|
+
),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
async run(
|
|
128
|
+
command: MachineCommandV1,
|
|
129
|
+
signal: AbortSignal,
|
|
130
|
+
): Promise<MachineCommandReportV1> {
|
|
131
|
+
const op = command.op;
|
|
132
|
+
const needed = machineOpCapabilityV1(op);
|
|
133
|
+
// The backend refuses a tool call against a machine that never reported
|
|
134
|
+
// the capability; this is the same check on the other side of the wire,
|
|
135
|
+
// because a record can go stale between enrollment and dispatch.
|
|
136
|
+
if (!options.capabilities.includes(needed)) {
|
|
137
|
+
return refuse(CAPABILITY_REASON[needed]);
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
if (op.kind === "exec") {
|
|
141
|
+
const result = await options.host.exec(
|
|
142
|
+
{
|
|
143
|
+
command: op.command,
|
|
144
|
+
...(op.cwd === undefined ? {} : { cwd: op.cwd }),
|
|
145
|
+
timeoutMs: op.timeoutMs,
|
|
146
|
+
maxOutputBytes: op.maxOutputBytes,
|
|
147
|
+
},
|
|
148
|
+
signal,
|
|
149
|
+
);
|
|
150
|
+
return {
|
|
151
|
+
finishedAt: now(),
|
|
152
|
+
// A killed command is `timeout`, not `error`: the Bot's next move
|
|
153
|
+
// differs — raise the timeout, or stop asking — and the audit
|
|
154
|
+
// vocabulary distinguishes them for the same reason.
|
|
155
|
+
outcome: result.timedOut
|
|
156
|
+
? "timeout"
|
|
157
|
+
: result.exitCode === 0
|
|
158
|
+
? "ok"
|
|
159
|
+
: "error",
|
|
160
|
+
truncated: result.truncated,
|
|
161
|
+
...(result.exitCode === undefined
|
|
162
|
+
? {}
|
|
163
|
+
: { exitCode: result.exitCode }),
|
|
164
|
+
stdout: result.stdout,
|
|
165
|
+
stderr: result.stderr,
|
|
166
|
+
...(result.timedOut
|
|
167
|
+
? {
|
|
168
|
+
message: `the command was killed after ${op.timeoutMs}ms`,
|
|
169
|
+
}
|
|
170
|
+
: {}),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (op.kind === "messages") {
|
|
174
|
+
// The capability check above already refused a machine that never
|
|
175
|
+
// reported `messages`; this is the second half of the same fact —
|
|
176
|
+
// an agent that reported it and was wired no handler must say so
|
|
177
|
+
// rather than answer an empty result that reads like an empty inbox.
|
|
178
|
+
if (!options.messages) {
|
|
179
|
+
return refuse(CAPABILITY_REASON.messages);
|
|
180
|
+
}
|
|
181
|
+
return await options.messages(op.call, signal);
|
|
182
|
+
}
|
|
183
|
+
if (op.kind === "read" || op.kind === "copy-to-computer") {
|
|
184
|
+
const maxBytes =
|
|
185
|
+
op.kind === "read" ? op.maxBytes : MACHINE_LIMITS_V1.readBytes;
|
|
186
|
+
const file = await options.host.readFile(
|
|
187
|
+
{ path: op.path, maxBytes },
|
|
188
|
+
signal,
|
|
189
|
+
);
|
|
190
|
+
return {
|
|
191
|
+
finishedAt: now(),
|
|
192
|
+
outcome: "ok",
|
|
193
|
+
truncated: file.truncated,
|
|
194
|
+
bytesBase64: file.bytesBase64,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
return refuse(
|
|
198
|
+
"machine protocol v1 carries no Workspace bytes, so a copy onto the machine cannot be performed by the agent",
|
|
199
|
+
);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
return failed(error);
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
// The device agent's loop, proved without a laptop.
|
|
2
|
+
//
|
|
3
|
+
// The wire is real — every request is built by `machineRoutePathV1` and every
|
|
4
|
+
// answer decoded by the shipped decoders — and only the socket is a fake. What
|
|
5
|
+
// is asserted here is the behaviour the plan calls for in R4: backoff and
|
|
6
|
+
// jitter, output bounds and timeouts reaching the result, token load and store
|
|
7
|
+
// failure paths, and the two policies that are the agent's own (a 401 forgets
|
|
8
|
+
// the token; a lost claim does not run the command).
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from "bun:test";
|
|
11
|
+
import type { MachineCommandV1 } from "@frockbot/machine-protocol";
|
|
12
|
+
import {
|
|
13
|
+
MACHINE_AGENT_BACKOFF_V1,
|
|
14
|
+
MachineDeviceAgentV1,
|
|
15
|
+
createMemoryMachineSecretStoreV1,
|
|
16
|
+
decodeMachineDeviceAgentStatusV1,
|
|
17
|
+
decodeMachineEnrollmentStateV1,
|
|
18
|
+
machinePollBackoffV1,
|
|
19
|
+
type MachineCommandReportV1,
|
|
20
|
+
type MachineSecretStoreV1,
|
|
21
|
+
} from "./device.js";
|
|
22
|
+
|
|
23
|
+
const ORIGIN = "https://bot.example.com";
|
|
24
|
+
|
|
25
|
+
function command(overrides: Partial<MachineCommandV1> = {}): MachineCommandV1 {
|
|
26
|
+
return {
|
|
27
|
+
schemaVersion: 1,
|
|
28
|
+
commandId: "tool-0-1-0",
|
|
29
|
+
machineId: "m-1",
|
|
30
|
+
botId: "scout",
|
|
31
|
+
runId: "run-1",
|
|
32
|
+
turn: 3,
|
|
33
|
+
approvalId: "tool-0-1-0",
|
|
34
|
+
op: {
|
|
35
|
+
kind: "exec",
|
|
36
|
+
command: "echo hi",
|
|
37
|
+
timeoutMs: 5_000,
|
|
38
|
+
maxOutputBytes: 1_024,
|
|
39
|
+
},
|
|
40
|
+
issuedAt: "2026-09-01T00:00:00.000Z",
|
|
41
|
+
status: "queued",
|
|
42
|
+
...overrides,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface Call {
|
|
47
|
+
path: string;
|
|
48
|
+
method: string;
|
|
49
|
+
authorization: string | null;
|
|
50
|
+
body?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface Route {
|
|
54
|
+
status?: number;
|
|
55
|
+
json?: unknown;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A backend, as far as the agent can tell. */
|
|
59
|
+
function server(routes: (call: Call) => Route): {
|
|
60
|
+
fetch(input: string, init?: RequestInit): Promise<Response>;
|
|
61
|
+
calls: Call[];
|
|
62
|
+
} {
|
|
63
|
+
const calls: Call[] = [];
|
|
64
|
+
return {
|
|
65
|
+
calls,
|
|
66
|
+
fetch: (input: string, init?: RequestInit) => {
|
|
67
|
+
const url = new URL(input);
|
|
68
|
+
const headers = new Headers(init?.headers);
|
|
69
|
+
const call: Call = {
|
|
70
|
+
path: `${url.pathname}${url.search}`,
|
|
71
|
+
method: init?.method ?? "GET",
|
|
72
|
+
authorization: headers.get("authorization"),
|
|
73
|
+
...(typeof init?.body === "string" ? { body: init.body } : {}),
|
|
74
|
+
};
|
|
75
|
+
calls.push(call);
|
|
76
|
+
const route = routes(call);
|
|
77
|
+
return Promise.resolve(
|
|
78
|
+
new Response(
|
|
79
|
+
route.json === undefined ? "" : JSON.stringify(route.json),
|
|
80
|
+
{ status: route.status ?? 200 },
|
|
81
|
+
),
|
|
82
|
+
);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const ENROLLED = {
|
|
88
|
+
schemaVersion: 1,
|
|
89
|
+
machineId: "m-1",
|
|
90
|
+
token: "machine-token",
|
|
91
|
+
keyVersion: 1,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
function agent(options: {
|
|
95
|
+
fetch(input: string, init?: RequestInit): Promise<Response>;
|
|
96
|
+
secrets?: MachineSecretStoreV1;
|
|
97
|
+
run?(command: MachineCommandV1): Promise<MachineCommandReportV1>;
|
|
98
|
+
}): MachineDeviceAgentV1 {
|
|
99
|
+
return new MachineDeviceAgentV1({
|
|
100
|
+
origin: ORIGIN,
|
|
101
|
+
fetch: options.fetch,
|
|
102
|
+
secrets: options.secrets ?? createMemoryMachineSecretStoreV1(),
|
|
103
|
+
runner: {
|
|
104
|
+
run: (received) =>
|
|
105
|
+
options.run?.(received) ??
|
|
106
|
+
Promise.resolve({
|
|
107
|
+
finishedAt: "2026-09-01T00:00:01.000Z",
|
|
108
|
+
outcome: "ok",
|
|
109
|
+
truncated: false,
|
|
110
|
+
exitCode: 0,
|
|
111
|
+
stdout: "hi\n",
|
|
112
|
+
}),
|
|
113
|
+
},
|
|
114
|
+
label: "Tims-M5-MacBook-Pro.local",
|
|
115
|
+
platform: "macos",
|
|
116
|
+
agentVersion: "0.0.1",
|
|
117
|
+
capabilities: ["exec", "files"],
|
|
118
|
+
now: () => Date.parse("2026-09-01T00:00:02.000Z"),
|
|
119
|
+
sleep: () => Promise.resolve(),
|
|
120
|
+
random: () => 0.5,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
describe("machine device agent backoff", () => {
|
|
125
|
+
test("a working poll does not sleep, and failures grow to a ceiling", () => {
|
|
126
|
+
expect(machinePollBackoffV1(0, () => 0.5)).toBe(0);
|
|
127
|
+
// random() of 0.5 is the midpoint of the jitter window: no jitter at all,
|
|
128
|
+
// so the exponential itself is asserted rather than a range.
|
|
129
|
+
expect(machinePollBackoffV1(1, () => 0.5)).toBe(
|
|
130
|
+
MACHINE_AGENT_BACKOFF_V1.baseMs,
|
|
131
|
+
);
|
|
132
|
+
expect(machinePollBackoffV1(2, () => 0.5)).toBe(2_000);
|
|
133
|
+
expect(machinePollBackoffV1(3, () => 0.5)).toBe(4_000);
|
|
134
|
+
expect(machinePollBackoffV1(20, () => 0.5)).toBe(
|
|
135
|
+
MACHINE_AGENT_BACKOFF_V1.maxMs,
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("jitter spreads a delay either side and never below zero", () => {
|
|
140
|
+
expect(machinePollBackoffV1(1, () => 0)).toBe(800);
|
|
141
|
+
expect(machinePollBackoffV1(1, () => 0.999)).toBe(1_200);
|
|
142
|
+
expect(
|
|
143
|
+
machinePollBackoffV1(1, () => 0, { baseMs: 10, maxMs: 10, jitter: 4 }),
|
|
144
|
+
).toBe(0);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("machine device agent enrollment", () => {
|
|
149
|
+
test("pairing stores exactly the enrollment state and nothing else", async () => {
|
|
150
|
+
const secrets = createMemoryMachineSecretStoreV1();
|
|
151
|
+
const backend = server(() => ({ json: ENROLLED }));
|
|
152
|
+
const device = agent({ fetch: backend.fetch, secrets });
|
|
153
|
+
|
|
154
|
+
const status = await device.pair(" pairing-code ");
|
|
155
|
+
|
|
156
|
+
expect(status.enrolled).toBe(true);
|
|
157
|
+
expect(status.machineId).toBe("m-1");
|
|
158
|
+
expect(backend.calls[0]?.path).toBe("/api/machines/enroll");
|
|
159
|
+
expect(backend.calls[0]?.authorization).toBe("Bearer pairing-code");
|
|
160
|
+
const held = decodeMachineEnrollmentStateV1(
|
|
161
|
+
JSON.parse((await secrets.read()) ?? "{}"),
|
|
162
|
+
);
|
|
163
|
+
expect(held).toEqual({
|
|
164
|
+
schemaVersion: 1,
|
|
165
|
+
machineId: "m-1",
|
|
166
|
+
token: "machine-token",
|
|
167
|
+
origin: ORIGIN,
|
|
168
|
+
label: "Tims-M5-MacBook-Pro.local",
|
|
169
|
+
enrolledAt: "2026-09-01T00:00:02.000Z",
|
|
170
|
+
});
|
|
171
|
+
// The status a renderer may read carries no token.
|
|
172
|
+
expect(Object.values(status)).not.toContain("machine-token");
|
|
173
|
+
expect(() => decodeMachineDeviceAgentStatusV1(status)).not.toThrow();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("a token minted by another deployment is forgotten, not presented", async () => {
|
|
177
|
+
const secrets = createMemoryMachineSecretStoreV1(
|
|
178
|
+
JSON.stringify({
|
|
179
|
+
schemaVersion: 1,
|
|
180
|
+
machineId: "m-1",
|
|
181
|
+
token: "machine-token",
|
|
182
|
+
origin: "https://other.example.com",
|
|
183
|
+
label: "Elsewhere",
|
|
184
|
+
enrolledAt: "2026-09-01T00:00:00.000Z",
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
const backend = server(() => ({ json: { commands: [] } }));
|
|
188
|
+
const device = agent({ fetch: backend.fetch, secrets });
|
|
189
|
+
|
|
190
|
+
const cycle = await device.runOnce(0);
|
|
191
|
+
|
|
192
|
+
expect(cycle.paired).toBe(false);
|
|
193
|
+
expect(backend.calls).toEqual([]);
|
|
194
|
+
expect(await secrets.read()).toBeUndefined();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("an unreadable store leaves the agent unpaired and says why", async () => {
|
|
198
|
+
const secrets: MachineSecretStoreV1 = {
|
|
199
|
+
read: () => Promise.reject(new Error("the keychain is locked")),
|
|
200
|
+
write: () => Promise.resolve(),
|
|
201
|
+
clear: () => Promise.resolve(),
|
|
202
|
+
};
|
|
203
|
+
const backend = server(() => ({ json: { commands: [] } }));
|
|
204
|
+
const device = agent({ fetch: backend.fetch, secrets });
|
|
205
|
+
|
|
206
|
+
const cycle = await device.runOnce(0);
|
|
207
|
+
|
|
208
|
+
expect(cycle.paired).toBe(false);
|
|
209
|
+
expect(cycle.error).toContain("the keychain is locked");
|
|
210
|
+
expect(device.status().enrolled).toBe(false);
|
|
211
|
+
expect(backend.calls).toEqual([]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("stored nonsense is discarded rather than presented", async () => {
|
|
215
|
+
const secrets = createMemoryMachineSecretStoreV1("{not json");
|
|
216
|
+
const device = agent({
|
|
217
|
+
fetch: server(() => ({ json: { commands: [] } })).fetch,
|
|
218
|
+
secrets,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
expect((await device.runOnce(0)).paired).toBe(false);
|
|
222
|
+
expect(await secrets.read()).toBeUndefined();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
function pairedStore(): MachineSecretStoreV1 {
|
|
227
|
+
return createMemoryMachineSecretStoreV1(
|
|
228
|
+
JSON.stringify({
|
|
229
|
+
schemaVersion: 1,
|
|
230
|
+
machineId: "m-1",
|
|
231
|
+
token: "machine-token",
|
|
232
|
+
origin: ORIGIN,
|
|
233
|
+
label: "Tims-M5-MacBook-Pro.local",
|
|
234
|
+
enrolledAt: "2026-09-01T00:00:00.000Z",
|
|
235
|
+
}),
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
describe("machine device agent cycle", () => {
|
|
240
|
+
test("polls, claims, runs and reports one command in order", async () => {
|
|
241
|
+
const reports: string[] = [];
|
|
242
|
+
const backend = server((call) => {
|
|
243
|
+
if (call.path.startsWith("/api/machines/m-1/poll")) {
|
|
244
|
+
return {
|
|
245
|
+
json: {
|
|
246
|
+
schemaVersion: 1,
|
|
247
|
+
commands: [command()],
|
|
248
|
+
serverTime: "2026-09-01T00:00:00.000Z",
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
if (call.path.endsWith("/claim")) {
|
|
253
|
+
return {
|
|
254
|
+
json: {
|
|
255
|
+
schemaVersion: 1,
|
|
256
|
+
commandId: "tool-0-1-0",
|
|
257
|
+
status: "claimed",
|
|
258
|
+
leaseExpiresAt: "2026-09-01T00:02:00.000Z",
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
reports.push(call.body ?? "");
|
|
263
|
+
return {
|
|
264
|
+
json: { schemaVersion: 1, commandId: "tool-0-1-0", status: "recorded" },
|
|
265
|
+
};
|
|
266
|
+
});
|
|
267
|
+
const device = agent({ fetch: backend.fetch, secrets: pairedStore() });
|
|
268
|
+
|
|
269
|
+
const cycle = await device.runOnce(25);
|
|
270
|
+
|
|
271
|
+
expect(cycle).toMatchObject({
|
|
272
|
+
paired: true,
|
|
273
|
+
delivered: 1,
|
|
274
|
+
claimed: 1,
|
|
275
|
+
alreadyClaimed: 0,
|
|
276
|
+
reported: 1,
|
|
277
|
+
});
|
|
278
|
+
expect(backend.calls.map((call) => call.path)).toEqual([
|
|
279
|
+
"/api/machines/m-1/poll?wait=25",
|
|
280
|
+
"/api/machines/m-1/commands/tool-0-1-0/claim",
|
|
281
|
+
"/api/machines/m-1/commands/tool-0-1-0/result",
|
|
282
|
+
]);
|
|
283
|
+
expect(JSON.parse(reports[0] ?? "{}")).toMatchObject({
|
|
284
|
+
commandId: "tool-0-1-0",
|
|
285
|
+
outcome: "ok",
|
|
286
|
+
exitCode: 0,
|
|
287
|
+
stdout: "hi\n",
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("a claim that lost the race does not run the command", async () => {
|
|
292
|
+
let ran = 0;
|
|
293
|
+
const backend = server((call) => {
|
|
294
|
+
if (call.path.startsWith("/api/machines/m-1/poll")) {
|
|
295
|
+
return {
|
|
296
|
+
json: {
|
|
297
|
+
schemaVersion: 1,
|
|
298
|
+
commands: [command()],
|
|
299
|
+
serverTime: "2026-09-01T00:00:00.000Z",
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
json: {
|
|
305
|
+
schemaVersion: 1,
|
|
306
|
+
commandId: "tool-0-1-0",
|
|
307
|
+
status: "already-claimed",
|
|
308
|
+
leaseExpiresAt: "2026-09-01T00:02:00.000Z",
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
});
|
|
312
|
+
const device = agent({
|
|
313
|
+
fetch: backend.fetch,
|
|
314
|
+
secrets: pairedStore(),
|
|
315
|
+
run: () => {
|
|
316
|
+
ran += 1;
|
|
317
|
+
return Promise.resolve({
|
|
318
|
+
finishedAt: "2026-09-01T00:00:01.000Z",
|
|
319
|
+
outcome: "ok",
|
|
320
|
+
truncated: false,
|
|
321
|
+
});
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const cycle = await device.runOnce(0);
|
|
326
|
+
|
|
327
|
+
expect(ran).toBe(0);
|
|
328
|
+
expect(cycle.alreadyClaimed).toBe(1);
|
|
329
|
+
expect(cycle.reported).toBe(0);
|
|
330
|
+
expect(backend.calls.some((call) => call.path.endsWith("/result"))).toBe(
|
|
331
|
+
false,
|
|
332
|
+
);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("a runner that throws still answers, so the lease is never orphaned", async () => {
|
|
336
|
+
const bodies: string[] = [];
|
|
337
|
+
const backend = server((call) => {
|
|
338
|
+
if (call.path.startsWith("/api/machines/m-1/poll")) {
|
|
339
|
+
return {
|
|
340
|
+
json: {
|
|
341
|
+
schemaVersion: 1,
|
|
342
|
+
commands: [command()],
|
|
343
|
+
serverTime: "2026-09-01T00:00:00.000Z",
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
if (call.path.endsWith("/claim")) {
|
|
348
|
+
return {
|
|
349
|
+
json: {
|
|
350
|
+
schemaVersion: 1,
|
|
351
|
+
commandId: "tool-0-1-0",
|
|
352
|
+
status: "claimed",
|
|
353
|
+
leaseExpiresAt: "2026-09-01T00:02:00.000Z",
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
bodies.push(call.body ?? "");
|
|
358
|
+
return {
|
|
359
|
+
json: { schemaVersion: 1, commandId: "tool-0-1-0", status: "recorded" },
|
|
360
|
+
};
|
|
361
|
+
});
|
|
362
|
+
const device = agent({
|
|
363
|
+
fetch: backend.fetch,
|
|
364
|
+
secrets: pairedStore(),
|
|
365
|
+
run: () => Promise.reject(new Error("spawn ENOENT")),
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
expect((await device.runOnce(0)).reported).toBe(1);
|
|
369
|
+
expect(JSON.parse(bodies[0] ?? "{}")).toMatchObject({
|
|
370
|
+
outcome: "error",
|
|
371
|
+
message: "spawn ENOENT",
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("a 401 forgets the token and stops the loop", async () => {
|
|
376
|
+
const secrets = pairedStore();
|
|
377
|
+
const backend = server(() => ({
|
|
378
|
+
status: 401,
|
|
379
|
+
json: { error: "machine token is invalid" },
|
|
380
|
+
}));
|
|
381
|
+
const device = agent({ fetch: backend.fetch, secrets });
|
|
382
|
+
|
|
383
|
+
const cycle = await device.runOnce(0);
|
|
384
|
+
|
|
385
|
+
expect(cycle.unenrolled).toBe(true);
|
|
386
|
+
expect(await secrets.read()).toBeUndefined();
|
|
387
|
+
expect(device.status()).toMatchObject({
|
|
388
|
+
enrolled: false,
|
|
389
|
+
running: false,
|
|
390
|
+
lastError: "this machine was revoked; pair it again to reconnect",
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("an ordinary failure is counted, not forgotten", async () => {
|
|
395
|
+
const secrets = pairedStore();
|
|
396
|
+
const backend = server(() => ({ status: 503, json: { error: "closed" } }));
|
|
397
|
+
const device = agent({ fetch: backend.fetch, secrets });
|
|
398
|
+
|
|
399
|
+
await device.runOnce(0);
|
|
400
|
+
await device.runOnce(0);
|
|
401
|
+
|
|
402
|
+
expect(device.status().failures).toBe(2);
|
|
403
|
+
expect(await secrets.read()).not.toBeUndefined();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test("unpairing clears the token and leaves the registry alone", async () => {
|
|
407
|
+
const secrets = pairedStore();
|
|
408
|
+
const backend = server(() => ({ json: { commands: [] } }));
|
|
409
|
+
const device = agent({ fetch: backend.fetch, secrets });
|
|
410
|
+
|
|
411
|
+
const status = await device.unpair();
|
|
412
|
+
|
|
413
|
+
expect(status.enrolled).toBe(false);
|
|
414
|
+
expect(await secrets.read()).toBeUndefined();
|
|
415
|
+
// Nothing was asked of the backend: revocation is the browser's.
|
|
416
|
+
expect(backend.calls).toEqual([]);
|
|
417
|
+
});
|
|
418
|
+
});
|