@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,102 @@
|
|
|
1
|
+
// The User Durable Object storage keys the registered-machine Package owns.
|
|
2
|
+
//
|
|
3
|
+
// "The User's Durable Object is the authority for everything User-scoped:
|
|
4
|
+
// Package availability, Connections, credentials, the Computer assignment,
|
|
5
|
+
// User settings, quotas" — a machine is a User asset, so its registry, its
|
|
6
|
+
// command queue and its results live here and nowhere else. The keys live in
|
|
7
|
+
// the Package rather than in `@frockbot/kernel-do` for the same reason
|
|
8
|
+
// `plugin-routines/src/storage-keys.ts` does: the kernel holds no product
|
|
9
|
+
// policy and imports no Package.
|
|
10
|
+
//
|
|
11
|
+
// None of these prefixes collide with the landed set (`user:`, `memory:`,
|
|
12
|
+
// `settings:`, `connection:`, `credential:`, `routine-*`, `template-*`).
|
|
13
|
+
|
|
14
|
+
/** One `MachineRecordV1`: the registry row. */
|
|
15
|
+
export const MACHINE_PREFIX = "machine:";
|
|
16
|
+
/** One queued `MachineCommandV1`, oldest first. */
|
|
17
|
+
export const MACHINE_QUEUE_PREFIX = "machine-queue:";
|
|
18
|
+
/** One `MachineCommandResultV1`, keyed by the command it answers. */
|
|
19
|
+
export const MACHINE_RESULT_PREFIX = "machine-result:";
|
|
20
|
+
/** One unspent `MachinePairingRecordV1`. */
|
|
21
|
+
export const MACHINE_PAIRING_PREFIX = "machine-pair:";
|
|
22
|
+
/**
|
|
23
|
+
* How many times one command's lease has expired.
|
|
24
|
+
*
|
|
25
|
+
* It is a key of its own rather than a field on the command because
|
|
26
|
+
* `MachineCommandV1` is the wire DTO, decoded exact-key at three runtimes: a
|
|
27
|
+
* recovery counter is the backend's bookkeeping and has no business being
|
|
28
|
+
* something a device agent is handed, or could send.
|
|
29
|
+
*/
|
|
30
|
+
export const MACHINE_REQUEUE_PREFIX = "machine-requeue:";
|
|
31
|
+
/**
|
|
32
|
+
* One day's dispatch count. The per-day quota is a rate, and a rate needs a
|
|
33
|
+
* durable counter rather than a listing: the queue drains, so counting what is
|
|
34
|
+
* in it would let a Bot spend the day's budget many times over.
|
|
35
|
+
*/
|
|
36
|
+
export const MACHINE_USAGE_PREFIX = "machine-usage:";
|
|
37
|
+
|
|
38
|
+
export function machineKeyV1(machineId: string): string {
|
|
39
|
+
return `${MACHINE_PREFIX}${machineId}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function machinePairingKeyV1(machineId: string): string {
|
|
43
|
+
return `${MACHINE_PAIRING_PREFIX}${machineId}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function machineResultKeyV1(commandId: string): string {
|
|
47
|
+
return `${MACHINE_RESULT_PREFIX}${commandId}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function machineRequeueKeyV1(commandId: string): string {
|
|
51
|
+
return `${MACHINE_REQUEUE_PREFIX}${commandId}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function machineQueuePrefixV1(machineId: string): string {
|
|
55
|
+
return `${MACHINE_QUEUE_PREFIX}${machineId}:`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Queue keys ascend, so a prefix listing answers oldest-first: a machine that
|
|
60
|
+
* has been asleep drains its commands in the order they were approved.
|
|
61
|
+
*/
|
|
62
|
+
const QUEUE_SEQUENCE_CEILING = 1_000_000_000;
|
|
63
|
+
|
|
64
|
+
export function machineQueueKeyV1(machineId: string, seq: number): string {
|
|
65
|
+
if (!Number.isSafeInteger(seq) || seq < 0 || seq >= QUEUE_SEQUENCE_CEILING) {
|
|
66
|
+
throw new Error("machine queue sequence is out of range");
|
|
67
|
+
}
|
|
68
|
+
return `${machineQueuePrefixV1(machineId)}${String(seq).padStart(10, "0")}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The sequence the next queued command takes, given the keys already held. */
|
|
72
|
+
export function nextMachineQueueSequenceV1(keys: readonly string[]): number {
|
|
73
|
+
let highest = -1;
|
|
74
|
+
for (const key of keys) {
|
|
75
|
+
const encoded = Number(key.slice(key.lastIndexOf(":") + 1));
|
|
76
|
+
if (Number.isSafeInteger(encoded)) highest = Math.max(highest, encoded);
|
|
77
|
+
}
|
|
78
|
+
return highest + 1;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The UTC day a dispatch counts against. Machines cross time zones; the quota does not. */
|
|
82
|
+
/**
|
|
83
|
+
* One finished command waiting to be told to the Bot that asked for it.
|
|
84
|
+
*
|
|
85
|
+
* A durable outbox rather than a call: this object holds the queue, and the
|
|
86
|
+
* Bot Durable Object namespace belongs to the adapter. The Worker that just
|
|
87
|
+
* answered the machine drains it, which keeps a Durable Object from holding a
|
|
88
|
+
* reference to another one open across a request.
|
|
89
|
+
*/
|
|
90
|
+
export const MACHINE_DELIVERY_PREFIX = "machine-delivery:";
|
|
91
|
+
|
|
92
|
+
export function machineDeliveryKeyV1(commandId: string): string {
|
|
93
|
+
return `${MACHINE_DELIVERY_PREFIX}${commandId}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function machineUsageDayV1(now: number | Date): string {
|
|
97
|
+
return new Date(now).toISOString().slice(0, 10);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function machineUsageKeyV1(now: number | Date): string {
|
|
101
|
+
return `${MACHINE_USAGE_PREFIX}${machineUsageDayV1(now)}`;
|
|
102
|
+
}
|
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
MACHINE_LIMITS_V1,
|
|
4
|
+
machineConnectedV1,
|
|
5
|
+
type MachineCommandV1,
|
|
6
|
+
} from "@frockbot/machine-protocol";
|
|
7
|
+
import {
|
|
8
|
+
claimMachineCommandV1,
|
|
9
|
+
dispatchMachineCommandV1,
|
|
10
|
+
enrollMachineV1,
|
|
11
|
+
listMachineRecordsV1,
|
|
12
|
+
pendingMachineCommandsV1,
|
|
13
|
+
readMachineRecordV1,
|
|
14
|
+
readMachineResultV1,
|
|
15
|
+
recordMachineResultV1,
|
|
16
|
+
revokeMachineV1,
|
|
17
|
+
sweepMachineLeasesV1,
|
|
18
|
+
touchMachineV1,
|
|
19
|
+
writeMachinePairingV1,
|
|
20
|
+
MachineRegistryError,
|
|
21
|
+
} from "./store.ts";
|
|
22
|
+
import { createMemoryMachineStorageV1 } from "./testing.ts";
|
|
23
|
+
import { machineQueuePrefixV1, machineResultKeyV1 } from "./storage-keys.ts";
|
|
24
|
+
|
|
25
|
+
const USER = "store-user";
|
|
26
|
+
/** A `SHA-256` shaped digest: the record decoder insists on one. */
|
|
27
|
+
const digestFor = (seed: string): string =>
|
|
28
|
+
[...seed]
|
|
29
|
+
.reduce((hash, ch) => hash + ch.charCodeAt(0), 0)
|
|
30
|
+
.toString(16)
|
|
31
|
+
.padStart(2, "0")
|
|
32
|
+
.repeat(32)
|
|
33
|
+
.slice(0, 64);
|
|
34
|
+
const T0 = Date.parse("2026-09-01T00:00:00.000Z");
|
|
35
|
+
|
|
36
|
+
let storage = createMemoryMachineStorageV1();
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
storage = createMemoryMachineStorageV1();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
async function register(
|
|
43
|
+
machineId: string,
|
|
44
|
+
options: {
|
|
45
|
+
now?: number;
|
|
46
|
+
capabilities?: ("exec" | "files" | "messages")[];
|
|
47
|
+
} = {},
|
|
48
|
+
) {
|
|
49
|
+
const now = options.now ?? T0;
|
|
50
|
+
await writeMachinePairingV1(storage, {
|
|
51
|
+
userId: USER,
|
|
52
|
+
machineId,
|
|
53
|
+
codeDigest: digestFor(`code-${machineId}`),
|
|
54
|
+
now,
|
|
55
|
+
});
|
|
56
|
+
return enrollMachineV1(storage, {
|
|
57
|
+
userId: USER,
|
|
58
|
+
machineId,
|
|
59
|
+
enrollment: {
|
|
60
|
+
schemaVersion: 1,
|
|
61
|
+
code: `code-${machineId}`,
|
|
62
|
+
label: `${machineId}.local`,
|
|
63
|
+
platform: "macos",
|
|
64
|
+
agentVersion: "0.0.1",
|
|
65
|
+
capabilities: options.capabilities ?? ["exec", "files"],
|
|
66
|
+
},
|
|
67
|
+
codeDigest: digestFor(`code-${machineId}`),
|
|
68
|
+
tokenDigest: digestFor(`token-${machineId}`),
|
|
69
|
+
now,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function command(
|
|
74
|
+
machineId: string,
|
|
75
|
+
commandId: string,
|
|
76
|
+
overrides: Partial<MachineCommandV1> = {},
|
|
77
|
+
): MachineCommandV1 {
|
|
78
|
+
return {
|
|
79
|
+
schemaVersion: 1,
|
|
80
|
+
commandId,
|
|
81
|
+
machineId,
|
|
82
|
+
botId: "bot",
|
|
83
|
+
runId: "run",
|
|
84
|
+
turn: 1,
|
|
85
|
+
approvalId: commandId,
|
|
86
|
+
op: {
|
|
87
|
+
kind: "exec",
|
|
88
|
+
command: "git status",
|
|
89
|
+
timeoutMs: 30_000,
|
|
90
|
+
maxOutputBytes: 4_096,
|
|
91
|
+
},
|
|
92
|
+
issuedAt: new Date(T0).toISOString(),
|
|
93
|
+
status: "queued",
|
|
94
|
+
...overrides,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe("the registry", () => {
|
|
99
|
+
test("enrollment spends the pairing offer exactly once", async () => {
|
|
100
|
+
const machineId = "m-1";
|
|
101
|
+
const record = await register(machineId);
|
|
102
|
+
expect(record).toMatchObject({ machineId, userId: USER, keyVersion: 1 });
|
|
103
|
+
// The offer is gone, so the same code cannot register a second machine.
|
|
104
|
+
await expect(
|
|
105
|
+
enrollMachineV1(storage, {
|
|
106
|
+
userId: USER,
|
|
107
|
+
machineId,
|
|
108
|
+
enrollment: {
|
|
109
|
+
schemaVersion: 1,
|
|
110
|
+
code: `code-${machineId}`,
|
|
111
|
+
label: "again.local",
|
|
112
|
+
platform: "macos",
|
|
113
|
+
agentVersion: "0.0.1",
|
|
114
|
+
capabilities: ["exec"],
|
|
115
|
+
},
|
|
116
|
+
codeDigest: digestFor(`code-${machineId}`),
|
|
117
|
+
tokenDigest: digestFor("token-again"),
|
|
118
|
+
now: T0,
|
|
119
|
+
}),
|
|
120
|
+
).rejects.toThrow(/invalid or has expired/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("an expired offer, another User's, and a different code are all refused", async () => {
|
|
124
|
+
await writeMachinePairingV1(storage, {
|
|
125
|
+
userId: USER,
|
|
126
|
+
machineId: "m-expired",
|
|
127
|
+
codeDigest: digestFor("digest"),
|
|
128
|
+
now: T0,
|
|
129
|
+
});
|
|
130
|
+
const enrollment = {
|
|
131
|
+
schemaVersion: 1 as const,
|
|
132
|
+
code: "code",
|
|
133
|
+
label: "late.local",
|
|
134
|
+
platform: "macos" as const,
|
|
135
|
+
agentVersion: "0.0.1",
|
|
136
|
+
capabilities: ["exec" as const],
|
|
137
|
+
};
|
|
138
|
+
const attempt = (input: {
|
|
139
|
+
userId?: string;
|
|
140
|
+
codeDigest?: string;
|
|
141
|
+
now?: number;
|
|
142
|
+
}) =>
|
|
143
|
+
enrollMachineV1(storage, {
|
|
144
|
+
userId: input.userId ?? USER,
|
|
145
|
+
machineId: "m-expired",
|
|
146
|
+
enrollment,
|
|
147
|
+
codeDigest: input.codeDigest ?? digestFor("digest"),
|
|
148
|
+
tokenDigest: digestFor("token"),
|
|
149
|
+
now: input.now ?? T0,
|
|
150
|
+
});
|
|
151
|
+
await expect(
|
|
152
|
+
attempt({ now: T0 + MACHINE_LIMITS_V1.pairingTtlMs + 1 }),
|
|
153
|
+
).rejects.toThrow(MachineRegistryError);
|
|
154
|
+
await expect(attempt({ userId: "someone-else" })).rejects.toThrow(
|
|
155
|
+
MachineRegistryError,
|
|
156
|
+
);
|
|
157
|
+
await expect(attempt({ codeDigest: digestFor("another") })).rejects.toThrow(
|
|
158
|
+
MachineRegistryError,
|
|
159
|
+
);
|
|
160
|
+
// …and the untouched offer still works, so none of the refusals spent it.
|
|
161
|
+
await expect(attempt({})).resolves.toMatchObject({
|
|
162
|
+
machineId: "m-expired",
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("the machine quota refuses the ninth registration", async () => {
|
|
167
|
+
for (let index = 0; index < MACHINE_LIMITS_V1.maxMachinesPerUser; index++) {
|
|
168
|
+
await register(`m-${index}`);
|
|
169
|
+
}
|
|
170
|
+
await expect(register("m-over")).rejects.toThrow(/^Refused: /);
|
|
171
|
+
// A revoked machine frees its slot: the row stays as evidence, the quota
|
|
172
|
+
// counts what is live.
|
|
173
|
+
await revokeMachineV1(storage, "m-0", T0);
|
|
174
|
+
await expect(register("m-freed")).resolves.toMatchObject({
|
|
175
|
+
machineId: "m-freed",
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("presence is arithmetic over lastSeenAt, and a revoked machine is never connected", async () => {
|
|
180
|
+
await register("m-live");
|
|
181
|
+
const fresh = await readMachineRecordV1(storage, "m-live");
|
|
182
|
+
expect(machineConnectedV1(fresh!, T0)).toBe(true);
|
|
183
|
+
expect(
|
|
184
|
+
machineConnectedV1(fresh!, T0 + MACHINE_LIMITS_V1.presenceTtlMs + 1),
|
|
185
|
+
).toBe(false);
|
|
186
|
+
await touchMachineV1(storage, "m-live", T0 + 60_000);
|
|
187
|
+
const touched = await readMachineRecordV1(storage, "m-live");
|
|
188
|
+
expect(machineConnectedV1(touched!, T0 + 60_000)).toBe(true);
|
|
189
|
+
const revoked = await revokeMachineV1(storage, "m-live", T0 + 60_000);
|
|
190
|
+
expect(revoked.keyVersion).toBe(2);
|
|
191
|
+
expect(machineConnectedV1(revoked, T0 + 60_000)).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("revocation purges the queue and leaves the row", async () => {
|
|
195
|
+
await register("m-revoke");
|
|
196
|
+
await dispatchMachineCommandV1(storage, command("m-revoke", "c-1"), T0);
|
|
197
|
+
await revokeMachineV1(storage, "m-revoke", T0);
|
|
198
|
+
expect(
|
|
199
|
+
storage
|
|
200
|
+
.keys()
|
|
201
|
+
.filter((key) => key.startsWith(machineQueuePrefixV1("m-revoke"))),
|
|
202
|
+
).toEqual([]);
|
|
203
|
+
expect(await listMachineRecordsV1(storage)).toHaveLength(1);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("the queue", () => {
|
|
208
|
+
test("a dispatch is idempotent on commandId", async () => {
|
|
209
|
+
await register("m-q");
|
|
210
|
+
const first = await dispatchMachineCommandV1(
|
|
211
|
+
storage,
|
|
212
|
+
command("m-q", "c-1"),
|
|
213
|
+
T0,
|
|
214
|
+
);
|
|
215
|
+
const second = await dispatchMachineCommandV1(
|
|
216
|
+
storage,
|
|
217
|
+
command("m-q", "c-1"),
|
|
218
|
+
T0,
|
|
219
|
+
);
|
|
220
|
+
expect(first.status).toBe("queued");
|
|
221
|
+
expect(second.status).toBe("duplicate");
|
|
222
|
+
expect(await pendingMachineCommandsV1(storage, "m-q")).toHaveLength(1);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("the queue refuses visibly at its depth limit", async () => {
|
|
226
|
+
await register("m-full");
|
|
227
|
+
for (let index = 0; index < MACHINE_LIMITS_V1.maxQueue; index++) {
|
|
228
|
+
const outcome = await dispatchMachineCommandV1(
|
|
229
|
+
storage,
|
|
230
|
+
command("m-full", `c-${index}`),
|
|
231
|
+
T0,
|
|
232
|
+
);
|
|
233
|
+
expect(outcome.status).toBe("queued");
|
|
234
|
+
}
|
|
235
|
+
const refused = await dispatchMachineCommandV1(
|
|
236
|
+
storage,
|
|
237
|
+
command("m-full", "c-over"),
|
|
238
|
+
T0,
|
|
239
|
+
);
|
|
240
|
+
expect(refused).toMatchObject({ status: "refused" });
|
|
241
|
+
// The wording is what `plugin-audit` classifies `refused` rather than
|
|
242
|
+
// `error`, so it is asserted rather than assumed.
|
|
243
|
+
expect(refused.status === "refused" ? refused.reason : "").toMatch(
|
|
244
|
+
/^Refused: /,
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("a machine that is unknown, revoked or lacks the capability is refused", async () => {
|
|
249
|
+
expect(
|
|
250
|
+
await dispatchMachineCommandV1(storage, command("m-none", "c"), T0),
|
|
251
|
+
).toMatchObject({ status: "refused" });
|
|
252
|
+
await register("m-files", { capabilities: ["files"] });
|
|
253
|
+
expect(
|
|
254
|
+
await dispatchMachineCommandV1(storage, command("m-files", "c"), T0),
|
|
255
|
+
).toMatchObject({ status: "refused" });
|
|
256
|
+
await register("m-gone");
|
|
257
|
+
await revokeMachineV1(storage, "m-gone", T0);
|
|
258
|
+
expect(
|
|
259
|
+
await dispatchMachineCommandV1(storage, command("m-gone", "c"), T0),
|
|
260
|
+
).toMatchObject({ status: "refused" });
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("the daily quota counts dispatches rather than the queue's depth", async () => {
|
|
264
|
+
await register("m-day");
|
|
265
|
+
// Drain after each dispatch, so the queue is never deep: only a durable
|
|
266
|
+
// counter can notice a Bot that has asked five hundred times today.
|
|
267
|
+
for (let index = 0; index < 4; index++) {
|
|
268
|
+
await dispatchMachineCommandV1(
|
|
269
|
+
storage,
|
|
270
|
+
command("m-day", `c-${index}`),
|
|
271
|
+
T0,
|
|
272
|
+
);
|
|
273
|
+
await claimMachineCommandV1(storage, "m-day", `c-${index}`, T0);
|
|
274
|
+
await recordMachineResultV1(
|
|
275
|
+
storage,
|
|
276
|
+
"m-day",
|
|
277
|
+
{
|
|
278
|
+
schemaVersion: 1,
|
|
279
|
+
commandId: `c-${index}`,
|
|
280
|
+
finishedAt: new Date(T0).toISOString(),
|
|
281
|
+
outcome: "ok",
|
|
282
|
+
truncated: false,
|
|
283
|
+
exitCode: 0,
|
|
284
|
+
},
|
|
285
|
+
T0,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
const usage = storage
|
|
289
|
+
.keys()
|
|
290
|
+
.filter((key) => key.startsWith("machine-usage:"));
|
|
291
|
+
expect(usage).toHaveLength(1);
|
|
292
|
+
expect(await storage.get(usage[0]!)).toMatchObject({ count: 4 });
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe("claims, results and leases", () => {
|
|
297
|
+
test("the second claim answers already-claimed with the first claim's lease", async () => {
|
|
298
|
+
await register("m-claim");
|
|
299
|
+
await dispatchMachineCommandV1(storage, command("m-claim", "c-1"), T0);
|
|
300
|
+
const first = await claimMachineCommandV1(storage, "m-claim", "c-1", T0);
|
|
301
|
+
const second = await claimMachineCommandV1(
|
|
302
|
+
storage,
|
|
303
|
+
"m-claim",
|
|
304
|
+
"c-1",
|
|
305
|
+
T0 + 5,
|
|
306
|
+
);
|
|
307
|
+
expect(first.status).toBe("claimed");
|
|
308
|
+
expect(second).toMatchObject({
|
|
309
|
+
status: "already-claimed",
|
|
310
|
+
leaseExpiresAt: first.leaseExpiresAt,
|
|
311
|
+
});
|
|
312
|
+
// A claimed command is not offered to the next poll.
|
|
313
|
+
expect(await pendingMachineCommandsV1(storage, "m-claim")).toEqual([]);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("a result is recorded once and replayed thereafter", async () => {
|
|
317
|
+
await register("m-result");
|
|
318
|
+
await dispatchMachineCommandV1(storage, command("m-result", "c-1"), T0);
|
|
319
|
+
await claimMachineCommandV1(storage, "m-result", "c-1", T0);
|
|
320
|
+
const result = {
|
|
321
|
+
schemaVersion: 1 as const,
|
|
322
|
+
commandId: "c-1",
|
|
323
|
+
finishedAt: new Date(T0 + 1_000).toISOString(),
|
|
324
|
+
outcome: "ok" as const,
|
|
325
|
+
truncated: false,
|
|
326
|
+
exitCode: 0,
|
|
327
|
+
stdout: "clean",
|
|
328
|
+
};
|
|
329
|
+
const first = await recordMachineResultV1(storage, "m-result", result, T0);
|
|
330
|
+
expect(first.receipt.status).toBe("recorded");
|
|
331
|
+
const replay = await recordMachineResultV1(
|
|
332
|
+
storage,
|
|
333
|
+
"m-result",
|
|
334
|
+
{ ...result, stdout: "different" },
|
|
335
|
+
T0,
|
|
336
|
+
);
|
|
337
|
+
expect(replay.receipt.status).toBe("replayed");
|
|
338
|
+
// The replay changed nothing: the first answer is still the answer.
|
|
339
|
+
expect(await readMachineResultV1(storage, "c-1")).toMatchObject({
|
|
340
|
+
stdout: "clean",
|
|
341
|
+
});
|
|
342
|
+
expect(
|
|
343
|
+
storage
|
|
344
|
+
.keys()
|
|
345
|
+
.filter((key) => key.startsWith(machineQueuePrefixV1("m-result"))),
|
|
346
|
+
).toEqual([]);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("a lease expiry re-queues once and then terminates as unknown", async () => {
|
|
350
|
+
await register("m-lease");
|
|
351
|
+
await dispatchMachineCommandV1(storage, command("m-lease", "c-1"), T0);
|
|
352
|
+
await claimMachineCommandV1(storage, "m-lease", "c-1", T0);
|
|
353
|
+
|
|
354
|
+
const early = await sweepMachineLeasesV1(
|
|
355
|
+
storage,
|
|
356
|
+
"m-lease",
|
|
357
|
+
T0 + MACHINE_LIMITS_V1.leaseMs - 1,
|
|
358
|
+
);
|
|
359
|
+
expect(early).toMatchObject({ requeued: [], terminated: [] });
|
|
360
|
+
|
|
361
|
+
const first = await sweepMachineLeasesV1(
|
|
362
|
+
storage,
|
|
363
|
+
"m-lease",
|
|
364
|
+
T0 + MACHINE_LIMITS_V1.leaseMs + 1,
|
|
365
|
+
);
|
|
366
|
+
expect(first.requeued).toEqual(["c-1"]);
|
|
367
|
+
expect(await pendingMachineCommandsV1(storage, "m-lease")).toHaveLength(1);
|
|
368
|
+
|
|
369
|
+
const second = T0 + MACHINE_LIMITS_V1.leaseMs + 2;
|
|
370
|
+
await claimMachineCommandV1(storage, "m-lease", "c-1", second);
|
|
371
|
+
const terminal = await sweepMachineLeasesV1(
|
|
372
|
+
storage,
|
|
373
|
+
"m-lease",
|
|
374
|
+
second + MACHINE_LIMITS_V1.leaseMs + 1,
|
|
375
|
+
);
|
|
376
|
+
expect(terminal).toMatchObject({ requeued: [], terminated: ["c-1"] });
|
|
377
|
+
expect(await pendingMachineCommandsV1(storage, "m-lease")).toEqual([]);
|
|
378
|
+
// The terminal fact is durable and says the outcome is unknown, rather
|
|
379
|
+
// than the command quietly disappearing off somebody's laptop.
|
|
380
|
+
expect(await storage.get(machineResultKeyV1("c-1"))).toMatchObject({
|
|
381
|
+
outcome: "error",
|
|
382
|
+
message: expect.stringContaining("unknown"),
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("a claim or a result for a command that is not queued is a 404", async () => {
|
|
387
|
+
await register("m-missing");
|
|
388
|
+
await expect(
|
|
389
|
+
claimMachineCommandV1(storage, "m-missing", "nope", T0),
|
|
390
|
+
).rejects.toThrow(MachineRegistryError);
|
|
391
|
+
await expect(
|
|
392
|
+
recordMachineResultV1(
|
|
393
|
+
storage,
|
|
394
|
+
"m-missing",
|
|
395
|
+
{
|
|
396
|
+
schemaVersion: 1,
|
|
397
|
+
commandId: "nope",
|
|
398
|
+
finishedAt: new Date(T0).toISOString(),
|
|
399
|
+
outcome: "ok",
|
|
400
|
+
truncated: false,
|
|
401
|
+
},
|
|
402
|
+
T0,
|
|
403
|
+
),
|
|
404
|
+
).rejects.toThrow(MachineRegistryError);
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
describe("the Messages permission report (register row 57g)", () => {
|
|
409
|
+
const permissions = {
|
|
410
|
+
schemaVersion: 1 as const,
|
|
411
|
+
fullDiskAccess: true,
|
|
412
|
+
automation: false,
|
|
413
|
+
checkedAt: "2026-09-01T00:00:00.000Z",
|
|
414
|
+
detail: "Automation over Messages.app has not been granted",
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
async function answerCheck(
|
|
418
|
+
outcome: "ok" | "refused",
|
|
419
|
+
stdout: string,
|
|
420
|
+
): Promise<void> {
|
|
421
|
+
await register("mac-1", { capabilities: ["exec", "files", "messages"] });
|
|
422
|
+
await dispatchMachineCommandV1(
|
|
423
|
+
storage,
|
|
424
|
+
command("mac-1", "c-messages", {
|
|
425
|
+
op: { kind: "messages", call: { kind: "check-permissions" } },
|
|
426
|
+
}),
|
|
427
|
+
T0,
|
|
428
|
+
);
|
|
429
|
+
await recordMachineResultV1(
|
|
430
|
+
storage,
|
|
431
|
+
"mac-1",
|
|
432
|
+
{
|
|
433
|
+
schemaVersion: 1,
|
|
434
|
+
commandId: "c-messages",
|
|
435
|
+
finishedAt: "2026-09-01T00:00:01.000Z",
|
|
436
|
+
outcome,
|
|
437
|
+
truncated: false,
|
|
438
|
+
stdout,
|
|
439
|
+
},
|
|
440
|
+
T0,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
test("an answered check updates the registry row, and only it can", async () => {
|
|
445
|
+
await answerCheck(
|
|
446
|
+
"ok",
|
|
447
|
+
JSON.stringify({ kind: "permissions", permissions }),
|
|
448
|
+
);
|
|
449
|
+
expect(
|
|
450
|
+
(await readMachineRecordV1(storage, "mac-1"))?.messagesPermissions,
|
|
451
|
+
).toEqual(permissions);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("a check the machine refused grants nothing", async () => {
|
|
455
|
+
await answerCheck(
|
|
456
|
+
"refused",
|
|
457
|
+
JSON.stringify({ kind: "permissions", permissions }),
|
|
458
|
+
);
|
|
459
|
+
expect(
|
|
460
|
+
(await readMachineRecordV1(storage, "mac-1"))?.messagesPermissions,
|
|
461
|
+
).toBeUndefined();
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test("a result that is not a permission report leaves the row alone", async () => {
|
|
465
|
+
await register("mac-2", { capabilities: ["exec", "files", "messages"] });
|
|
466
|
+
await dispatchMachineCommandV1(
|
|
467
|
+
storage,
|
|
468
|
+
command("mac-2", "c-read", {
|
|
469
|
+
op: { kind: "messages", call: { kind: "activity", limit: 5 } },
|
|
470
|
+
}),
|
|
471
|
+
T0,
|
|
472
|
+
);
|
|
473
|
+
await recordMachineResultV1(
|
|
474
|
+
storage,
|
|
475
|
+
"mac-2",
|
|
476
|
+
{
|
|
477
|
+
schemaVersion: 1,
|
|
478
|
+
commandId: "c-read",
|
|
479
|
+
finishedAt: "2026-09-01T00:00:01.000Z",
|
|
480
|
+
outcome: "ok",
|
|
481
|
+
truncated: false,
|
|
482
|
+
// A read cannot grant itself a permission by saying it has one.
|
|
483
|
+
stdout: JSON.stringify({ kind: "permissions", permissions }),
|
|
484
|
+
},
|
|
485
|
+
T0,
|
|
486
|
+
);
|
|
487
|
+
expect(
|
|
488
|
+
(await readMachineRecordV1(storage, "mac-2"))?.messagesPermissions,
|
|
489
|
+
).toBeUndefined();
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("a machine that never reported messages cannot be sent a messages op", async () => {
|
|
493
|
+
await register("mac-3");
|
|
494
|
+
expect(
|
|
495
|
+
await dispatchMachineCommandV1(
|
|
496
|
+
storage,
|
|
497
|
+
command("mac-3", "c-x", {
|
|
498
|
+
op: { kind: "messages", call: { kind: "activity", limit: 5 } },
|
|
499
|
+
}),
|
|
500
|
+
T0,
|
|
501
|
+
),
|
|
502
|
+
).toEqual({
|
|
503
|
+
status: "refused",
|
|
504
|
+
reason: "Refused: this machine does not report the messages capability.",
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
});
|