@frockbot/machine-protocol 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/package.json +22 -6
- package/src/index.ts +4 -0
- package/src/messages.test.ts +200 -0
- package/src/protocol.test.ts +406 -0
- package/src/protocol.ts +1540 -0
- package/src/quota.test.ts +156 -0
- package/src/quota.ts +152 -0
- package/src/routes.test.ts +105 -0
- package/src/routes.ts +161 -0
- package/src/token.test.ts +149 -0
- package/src/token.ts +241 -0
- package/tsconfig.json +14 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,14 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/machine-protocol",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./package.json": "./package.json",
|
|
9
|
+
"./protocol": "./src/protocol.ts",
|
|
10
|
+
"./quota": "./src/quota.ts",
|
|
11
|
+
"./routes": "./src/routes.ts",
|
|
12
|
+
"./token": "./src/token.ts"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "bun test src",
|
|
16
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/bun": "1.4.0",
|
|
20
|
+
"typescript": "5.9.3"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
6
25
|
"repository": {
|
|
7
26
|
"type": "git",
|
|
8
27
|
"url": "git+https://github.com/timoconnellaus/frockbot.git",
|
|
9
28
|
"directory": "packages/machine-protocol"
|
|
10
|
-
},
|
|
11
|
-
"publishConfig": {
|
|
12
|
-
"access": "public"
|
|
13
29
|
}
|
|
14
30
|
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Row 57g's DTOs: the seven calls, the permission report, and the one place
|
|
2
|
+
// the backend turns a machine's answer into a fact it will act on.
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import {
|
|
5
|
+
MACHINE_MESSAGES_CALL_KINDS_V1,
|
|
6
|
+
MACHINE_MESSAGES_LIMITS_V1,
|
|
7
|
+
MACHINE_OP_KINDS_V1,
|
|
8
|
+
MachineDecodeError,
|
|
9
|
+
decodeMachineMessagesCallV1,
|
|
10
|
+
decodeMachineMessagesPermissionsV1,
|
|
11
|
+
decodeMachineOpV1,
|
|
12
|
+
machineMessagesCallIsReadV1,
|
|
13
|
+
machineMessagesPermissionsFromResultV1,
|
|
14
|
+
machineMessagesPermittedV1,
|
|
15
|
+
machineOpCapabilityV1,
|
|
16
|
+
type MachineMessagesCallV1,
|
|
17
|
+
type MachineMessagesPermissionsV1,
|
|
18
|
+
} from "./protocol.js";
|
|
19
|
+
|
|
20
|
+
const permissions: MachineMessagesPermissionsV1 = {
|
|
21
|
+
schemaVersion: 1,
|
|
22
|
+
fullDiskAccess: true,
|
|
23
|
+
automation: true,
|
|
24
|
+
checkedAt: "2026-09-01T00:00:00.000Z",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe("messages calls", () => {
|
|
28
|
+
test("every call kind round-trips through its decoder", () => {
|
|
29
|
+
const calls: MachineMessagesCallV1[] = [
|
|
30
|
+
{ kind: "check-permissions" },
|
|
31
|
+
{ kind: "find-chats", limit: 20 },
|
|
32
|
+
{ kind: "find-chats", query: "mum", limit: 5 },
|
|
33
|
+
{ kind: "chat-items", chatId: "iMessage;-;+61400000000", limit: 50 },
|
|
34
|
+
{ kind: "chat-items", chatId: "chat123", limit: 10, beforeRowId: 900 },
|
|
35
|
+
{ kind: "search", query: "dinner", limit: 25 },
|
|
36
|
+
{ kind: "activity", limit: 10 },
|
|
37
|
+
{ kind: "fetch-attachment", attachmentId: "42", maxBytes: 1024 },
|
|
38
|
+
{ kind: "send", to: "+61400000000", text: "on my way" },
|
|
39
|
+
];
|
|
40
|
+
for (const call of calls) {
|
|
41
|
+
expect(decodeMachineMessagesCallV1(call)).toEqual(call);
|
|
42
|
+
}
|
|
43
|
+
expect([...MACHINE_MESSAGES_CALL_KINDS_V1].sort()).toEqual(
|
|
44
|
+
[...new Set(calls.map((call) => call.kind))].sort(),
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("a field the schema does not declare is a refusal", () => {
|
|
49
|
+
expect(() =>
|
|
50
|
+
decodeMachineMessagesCallV1({ kind: "activity", limit: 5, all: true }),
|
|
51
|
+
).toThrow(MachineDecodeError);
|
|
52
|
+
expect(() => decodeMachineMessagesCallV1({ kind: "listen" })).toThrow(
|
|
53
|
+
MachineDecodeError,
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("bounds are the protocol's, not the caller's", () => {
|
|
58
|
+
expect(() =>
|
|
59
|
+
decodeMachineMessagesCallV1({
|
|
60
|
+
kind: "activity",
|
|
61
|
+
limit: MACHINE_MESSAGES_LIMITS_V1.rows + 1,
|
|
62
|
+
}),
|
|
63
|
+
).toThrow(/between 1 and/);
|
|
64
|
+
expect(() =>
|
|
65
|
+
decodeMachineMessagesCallV1({
|
|
66
|
+
kind: "send",
|
|
67
|
+
to: "+61400000000",
|
|
68
|
+
text: "x".repeat(MACHINE_MESSAGES_LIMITS_V1.text + 1),
|
|
69
|
+
}),
|
|
70
|
+
).toThrow(/exceeds/);
|
|
71
|
+
expect(() =>
|
|
72
|
+
decodeMachineMessagesCallV1({
|
|
73
|
+
kind: "fetch-attachment",
|
|
74
|
+
attachmentId: "1",
|
|
75
|
+
maxBytes: MACHINE_MESSAGES_LIMITS_V1.attachmentBytes + 1,
|
|
76
|
+
}),
|
|
77
|
+
).toThrow(/between 1 and/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("only send is not a read", () => {
|
|
81
|
+
expect(
|
|
82
|
+
MACHINE_MESSAGES_CALL_KINDS_V1.filter(
|
|
83
|
+
(kind) =>
|
|
84
|
+
!machineMessagesCallIsReadV1({ kind } as MachineMessagesCallV1),
|
|
85
|
+
),
|
|
86
|
+
).toEqual(["send"]);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("the messages op", () => {
|
|
91
|
+
test("is an op like any other, and needs the messages capability", () => {
|
|
92
|
+
const op = decodeMachineOpV1({
|
|
93
|
+
kind: "messages",
|
|
94
|
+
call: { kind: "activity", limit: 5 },
|
|
95
|
+
});
|
|
96
|
+
expect(op).toEqual({
|
|
97
|
+
kind: "messages",
|
|
98
|
+
call: { kind: "activity", limit: 5 },
|
|
99
|
+
});
|
|
100
|
+
expect(machineOpCapabilityV1(op)).toBe("messages");
|
|
101
|
+
expect(MACHINE_OP_KINDS_V1).toContain("messages");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("an undecodable call is an undecodable op", () => {
|
|
105
|
+
expect(() =>
|
|
106
|
+
decodeMachineOpV1({ kind: "messages", call: { kind: "send", to: "x" } }),
|
|
107
|
+
).toThrow(MachineDecodeError);
|
|
108
|
+
expect(() =>
|
|
109
|
+
decodeMachineOpV1({
|
|
110
|
+
kind: "messages",
|
|
111
|
+
call: { kind: "activity", limit: 1 },
|
|
112
|
+
extra: 1,
|
|
113
|
+
}),
|
|
114
|
+
).toThrow(MachineDecodeError);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("permissions", () => {
|
|
119
|
+
test("the report decodes exact-key", () => {
|
|
120
|
+
expect(decodeMachineMessagesPermissionsV1(permissions)).toEqual(
|
|
121
|
+
permissions,
|
|
122
|
+
);
|
|
123
|
+
expect(() =>
|
|
124
|
+
decodeMachineMessagesPermissionsV1({ ...permissions, granted: true }),
|
|
125
|
+
).toThrow(MachineDecodeError);
|
|
126
|
+
expect(() =>
|
|
127
|
+
decodeMachineMessagesPermissionsV1({ ...permissions, checkedAt: "soon" }),
|
|
128
|
+
).toThrow(MachineDecodeError);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("absent is a refusal, never a grant", () => {
|
|
132
|
+
const read: MachineMessagesCallV1 = { kind: "activity", limit: 5 };
|
|
133
|
+
const send: MachineMessagesCallV1 = { kind: "send", to: "x", text: "y" };
|
|
134
|
+
expect(machineMessagesPermittedV1(read, undefined)).toBe(false);
|
|
135
|
+
expect(machineMessagesPermittedV1(send, undefined)).toBe(false);
|
|
136
|
+
// The check itself is how a machine stops being unknown, so it always runs.
|
|
137
|
+
expect(
|
|
138
|
+
machineMessagesPermittedV1({ kind: "check-permissions" }, undefined),
|
|
139
|
+
).toBe(true);
|
|
140
|
+
expect(machineMessagesPermittedV1(read, permissions)).toBe(true);
|
|
141
|
+
expect(
|
|
142
|
+
machineMessagesPermittedV1(read, {
|
|
143
|
+
...permissions,
|
|
144
|
+
fullDiskAccess: false,
|
|
145
|
+
}),
|
|
146
|
+
).toBe(false);
|
|
147
|
+
// Reading is Full Disk Access; sending additionally needs Automation.
|
|
148
|
+
expect(
|
|
149
|
+
machineMessagesPermittedV1(send, { ...permissions, automation: false }),
|
|
150
|
+
).toBe(false);
|
|
151
|
+
expect(
|
|
152
|
+
machineMessagesPermittedV1(read, { ...permissions, automation: false }),
|
|
153
|
+
).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("a report is read off an ok permission check and nothing else", () => {
|
|
157
|
+
const op = {
|
|
158
|
+
kind: "messages",
|
|
159
|
+
call: { kind: "check-permissions" },
|
|
160
|
+
} as const;
|
|
161
|
+
const stdout = JSON.stringify({ kind: "permissions", permissions });
|
|
162
|
+
expect(
|
|
163
|
+
machineMessagesPermissionsFromResultV1(op, { outcome: "ok", stdout }),
|
|
164
|
+
).toEqual(permissions);
|
|
165
|
+
// Not a check, not ok, not JSON, not a report, not decodable: all undefined.
|
|
166
|
+
expect(
|
|
167
|
+
machineMessagesPermissionsFromResultV1(
|
|
168
|
+
{ kind: "messages", call: { kind: "activity", limit: 1 } },
|
|
169
|
+
{ outcome: "ok", stdout },
|
|
170
|
+
),
|
|
171
|
+
).toBeUndefined();
|
|
172
|
+
expect(
|
|
173
|
+
machineMessagesPermissionsFromResultV1(op, {
|
|
174
|
+
outcome: "refused",
|
|
175
|
+
stdout,
|
|
176
|
+
}),
|
|
177
|
+
).toBeUndefined();
|
|
178
|
+
expect(
|
|
179
|
+
machineMessagesPermissionsFromResultV1(op, {
|
|
180
|
+
outcome: "ok",
|
|
181
|
+
stdout: "{",
|
|
182
|
+
}),
|
|
183
|
+
).toBeUndefined();
|
|
184
|
+
expect(
|
|
185
|
+
machineMessagesPermissionsFromResultV1(op, {
|
|
186
|
+
outcome: "ok",
|
|
187
|
+
stdout: JSON.stringify({ kind: "chats", chats: [] }),
|
|
188
|
+
}),
|
|
189
|
+
).toBeUndefined();
|
|
190
|
+
expect(
|
|
191
|
+
machineMessagesPermissionsFromResultV1(op, {
|
|
192
|
+
outcome: "ok",
|
|
193
|
+
stdout: JSON.stringify({
|
|
194
|
+
kind: "permissions",
|
|
195
|
+
permissions: { schemaVersion: 1, fullDiskAccess: true },
|
|
196
|
+
}),
|
|
197
|
+
}),
|
|
198
|
+
).toBeUndefined();
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
MACHINE_LIMITS_V1,
|
|
4
|
+
MACHINE_PRESENCE_TTL_MS,
|
|
5
|
+
MachineDecodeError,
|
|
6
|
+
decodeMachineClaimReceiptV1,
|
|
7
|
+
decodeMachineCommandResultV1,
|
|
8
|
+
decodeMachineCommandV1,
|
|
9
|
+
decodeMachineEnrollmentReceiptV1,
|
|
10
|
+
decodeMachineEnrollmentV1,
|
|
11
|
+
decodeMachineIdV1,
|
|
12
|
+
decodeMachineListEntryV1,
|
|
13
|
+
decodeMachineListViewV1,
|
|
14
|
+
decodeMachineOpV1,
|
|
15
|
+
decodeMachinePairingOfferV1,
|
|
16
|
+
decodeMachinePairingRequestV1,
|
|
17
|
+
decodeMachinePathV1,
|
|
18
|
+
decodeMachinePollResultV1,
|
|
19
|
+
decodeMachineRecordV1,
|
|
20
|
+
decodeMachineResultReceiptV1,
|
|
21
|
+
machineConnectedV1,
|
|
22
|
+
machineListEntryV1,
|
|
23
|
+
machineOpCapabilityV1,
|
|
24
|
+
type MachineRecordV1,
|
|
25
|
+
} from "./protocol.ts";
|
|
26
|
+
|
|
27
|
+
const MACHINE_ID = "994dc2ee-3f42-4a4d-9f2a-0a3f6f0d1b77";
|
|
28
|
+
const DIGEST = "a".repeat(64);
|
|
29
|
+
const NOW = "2026-09-01T00:00:00.000Z";
|
|
30
|
+
|
|
31
|
+
const op = {
|
|
32
|
+
kind: "exec",
|
|
33
|
+
command: "git status",
|
|
34
|
+
timeoutMs: 30_000,
|
|
35
|
+
maxOutputBytes: 65_536,
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
const command = {
|
|
39
|
+
schemaVersion: 1,
|
|
40
|
+
commandId: "tool:3:1:0",
|
|
41
|
+
machineId: MACHINE_ID,
|
|
42
|
+
botId: "foreman",
|
|
43
|
+
runId: "run-1",
|
|
44
|
+
turn: 3,
|
|
45
|
+
approvalId: "tool:3:1:0",
|
|
46
|
+
op,
|
|
47
|
+
issuedAt: NOW,
|
|
48
|
+
status: "queued",
|
|
49
|
+
} as const;
|
|
50
|
+
|
|
51
|
+
const record: MachineRecordV1 = {
|
|
52
|
+
schemaVersion: 1,
|
|
53
|
+
machineId: MACHINE_ID,
|
|
54
|
+
userId: "user-1",
|
|
55
|
+
label: "Tims-M5-MacBook-Pro.local",
|
|
56
|
+
platform: "macos",
|
|
57
|
+
agentVersion: "0.1.0",
|
|
58
|
+
capabilities: ["exec", "files"],
|
|
59
|
+
registeredAt: "2026-08-30T00:00:00.000Z",
|
|
60
|
+
lastSeenAt: NOW,
|
|
61
|
+
keyVersion: 1,
|
|
62
|
+
tokenDigest: DIGEST,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Every DTO the protocol carries, with one accepted value each. The table is
|
|
67
|
+
* what makes "exact-key at every seam" a property of the package rather than a
|
|
68
|
+
* habit: a decoder added without its row is a decoder nobody proved refuses an
|
|
69
|
+
* undeclared field.
|
|
70
|
+
*/
|
|
71
|
+
const DTOS: {
|
|
72
|
+
name: string;
|
|
73
|
+
decode: (input: unknown) => unknown;
|
|
74
|
+
valid: Record<string, unknown>;
|
|
75
|
+
}[] = [
|
|
76
|
+
{
|
|
77
|
+
name: "pairing request",
|
|
78
|
+
decode: decodeMachinePairingRequestV1,
|
|
79
|
+
valid: { label: "Tims-M5-MacBook-Pro.local" },
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: "pairing offer",
|
|
83
|
+
decode: decodeMachinePairingOfferV1,
|
|
84
|
+
valid: {
|
|
85
|
+
schemaVersion: 1,
|
|
86
|
+
code: "AB12-CD34-EF56",
|
|
87
|
+
machineId: MACHINE_ID,
|
|
88
|
+
expiresAt: NOW,
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: "enrollment",
|
|
93
|
+
decode: decodeMachineEnrollmentV1,
|
|
94
|
+
valid: {
|
|
95
|
+
schemaVersion: 1,
|
|
96
|
+
code: "AB12-CD34-EF56",
|
|
97
|
+
label: "Tims-M5-MacBook-Pro.local",
|
|
98
|
+
platform: "macos",
|
|
99
|
+
agentVersion: "0.1.0",
|
|
100
|
+
capabilities: ["exec", "files", "messages"],
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: "enrollment receipt",
|
|
105
|
+
decode: decodeMachineEnrollmentReceiptV1,
|
|
106
|
+
valid: {
|
|
107
|
+
schemaVersion: 1,
|
|
108
|
+
machineId: MACHINE_ID,
|
|
109
|
+
token: "payload.signature",
|
|
110
|
+
keyVersion: 1,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{ name: "op", decode: decodeMachineOpV1, valid: { ...op } },
|
|
114
|
+
{ name: "command", decode: decodeMachineCommandV1, valid: { ...command } },
|
|
115
|
+
{
|
|
116
|
+
name: "poll result",
|
|
117
|
+
decode: decodeMachinePollResultV1,
|
|
118
|
+
valid: { schemaVersion: 1, commands: [{ ...command }], serverTime: NOW },
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: "claim receipt",
|
|
122
|
+
decode: decodeMachineClaimReceiptV1,
|
|
123
|
+
valid: {
|
|
124
|
+
schemaVersion: 1,
|
|
125
|
+
status: "claimed",
|
|
126
|
+
commandId: "tool:3:1:0",
|
|
127
|
+
leaseExpiresAt: NOW,
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: "command result",
|
|
132
|
+
decode: decodeMachineCommandResultV1,
|
|
133
|
+
valid: {
|
|
134
|
+
schemaVersion: 1,
|
|
135
|
+
commandId: "tool:3:1:0",
|
|
136
|
+
finishedAt: NOW,
|
|
137
|
+
outcome: "ok",
|
|
138
|
+
truncated: false,
|
|
139
|
+
exitCode: 0,
|
|
140
|
+
stdout: "clean",
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: "result receipt",
|
|
145
|
+
decode: decodeMachineResultReceiptV1,
|
|
146
|
+
valid: {
|
|
147
|
+
schemaVersion: 1,
|
|
148
|
+
status: "recorded",
|
|
149
|
+
commandId: "tool:3:1:0",
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
{ name: "record", decode: decodeMachineRecordV1, valid: { ...record } },
|
|
153
|
+
{
|
|
154
|
+
name: "list entry",
|
|
155
|
+
decode: decodeMachineListEntryV1,
|
|
156
|
+
valid: machineListEntryV1(record, Date.parse(NOW)) as unknown as Record<
|
|
157
|
+
string,
|
|
158
|
+
unknown
|
|
159
|
+
>,
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: "list view",
|
|
163
|
+
decode: decodeMachineListViewV1,
|
|
164
|
+
valid: {
|
|
165
|
+
schemaVersion: 1,
|
|
166
|
+
machines: [machineListEntryV1(record, Date.parse(NOW))],
|
|
167
|
+
serverTime: NOW,
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
describe("machine protocol decoders", () => {
|
|
173
|
+
for (const dto of DTOS) {
|
|
174
|
+
test(`${dto.name} round-trips and refuses an undeclared field`, () => {
|
|
175
|
+
expect(dto.decode(dto.valid)).toEqual(dto.valid);
|
|
176
|
+
expect(() => dto.decode({ ...dto.valid, smuggled: true })).toThrow(
|
|
177
|
+
/unknown field: smuggled/,
|
|
178
|
+
);
|
|
179
|
+
expect(() => dto.decode([dto.valid])).toThrow(/must be an object/);
|
|
180
|
+
expect(() => dto.decode(null)).toThrow(/must be an object/);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
test("a decoder never returns a field the input did not carry", () => {
|
|
185
|
+
const decoded = decodeMachineCommandV1({ ...command });
|
|
186
|
+
expect(Object.hasOwn(decoded, "claimedAt")).toBe(false);
|
|
187
|
+
expect(Object.hasOwn(decoded, "leaseExpiresAt")).toBe(false);
|
|
188
|
+
expect(decodeMachinePairingRequestV1({})).toEqual({});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("an unsupported schemaVersion is refused, not upgraded", () => {
|
|
192
|
+
expect(() =>
|
|
193
|
+
decodeMachineRecordV1({ ...record, schemaVersion: 2 }),
|
|
194
|
+
).toThrow(/schemaVersion is unsupported/);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("machine identifiers and paths", () => {
|
|
199
|
+
test("a machine id is opaque and audit-compatible", () => {
|
|
200
|
+
expect(decodeMachineIdV1(MACHINE_ID)).toBe(MACHINE_ID);
|
|
201
|
+
// The same rule `plugin-audit/src/classify.ts` applies to the tail of a
|
|
202
|
+
// `machine:<id>` target, so every id minted here can be audited.
|
|
203
|
+
expect(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(MACHINE_ID)).toBe(true);
|
|
204
|
+
for (const bad of ["", "-leading", "has space", "tool:1:1:0", "a/b"]) {
|
|
205
|
+
expect(() => decodeMachineIdV1(bad)).toThrow(MachineDecodeError);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("a command id may carry the effectId's colons", () => {
|
|
210
|
+
expect(decodeMachineCommandV1({ ...command }).commandId).toBe("tool:3:1:0");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("a machine path refuses control characters but allows a home path", () => {
|
|
214
|
+
expect(decodeMachinePathV1("~/Documents/notes.md")).toBe(
|
|
215
|
+
"~/Documents/notes.md",
|
|
216
|
+
);
|
|
217
|
+
expect(decodeMachinePathV1("C:\\Users\\tim\\notes.md")).toBe(
|
|
218
|
+
"C:\\Users\\tim\\notes.md",
|
|
219
|
+
);
|
|
220
|
+
expect(() => decodeMachinePathV1("/tmp/a\u0000b")).toThrow(
|
|
221
|
+
/control characters/,
|
|
222
|
+
);
|
|
223
|
+
expect(() => decodeMachinePathV1("")).toThrow(/non-empty/);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("bounds", () => {
|
|
228
|
+
test("every declared bound refuses one past it, with limit-exceeded", () => {
|
|
229
|
+
const over = (input: unknown, decode: (value: unknown) => unknown) => {
|
|
230
|
+
try {
|
|
231
|
+
decode(input);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
return error as MachineDecodeError;
|
|
234
|
+
}
|
|
235
|
+
throw new Error("expected a refusal");
|
|
236
|
+
};
|
|
237
|
+
expect(
|
|
238
|
+
over(
|
|
239
|
+
{ ...op, command: "x".repeat(MACHINE_LIMITS_V1.command + 1) },
|
|
240
|
+
decodeMachineOpV1,
|
|
241
|
+
).code,
|
|
242
|
+
).toBe("limit-exceeded");
|
|
243
|
+
expect(
|
|
244
|
+
over(
|
|
245
|
+
{ ...op, maxOutputBytes: MACHINE_LIMITS_V1.outputBytes + 1 },
|
|
246
|
+
decodeMachineOpV1,
|
|
247
|
+
).code,
|
|
248
|
+
).toBe("limit-exceeded");
|
|
249
|
+
expect(
|
|
250
|
+
over(
|
|
251
|
+
{ ...op, timeoutMs: MACHINE_LIMITS_V1.execTimeoutMs + 1 },
|
|
252
|
+
decodeMachineOpV1,
|
|
253
|
+
).code,
|
|
254
|
+
).toBe("limit-exceeded");
|
|
255
|
+
expect(
|
|
256
|
+
over(
|
|
257
|
+
{
|
|
258
|
+
kind: "read",
|
|
259
|
+
path: "/tmp/a",
|
|
260
|
+
maxBytes: MACHINE_LIMITS_V1.readBytes + 1,
|
|
261
|
+
},
|
|
262
|
+
decodeMachineOpV1,
|
|
263
|
+
).code,
|
|
264
|
+
).toBe("limit-exceeded");
|
|
265
|
+
expect(
|
|
266
|
+
over(
|
|
267
|
+
{
|
|
268
|
+
schemaVersion: 1,
|
|
269
|
+
commands: Array.from(
|
|
270
|
+
{ length: MACHINE_LIMITS_V1.maxQueue + 1 },
|
|
271
|
+
() => ({
|
|
272
|
+
...command,
|
|
273
|
+
}),
|
|
274
|
+
),
|
|
275
|
+
serverTime: NOW,
|
|
276
|
+
},
|
|
277
|
+
decodeMachinePollResultV1,
|
|
278
|
+
).code,
|
|
279
|
+
).toBe("limit-exceeded");
|
|
280
|
+
expect(
|
|
281
|
+
over(
|
|
282
|
+
{
|
|
283
|
+
schemaVersion: 1,
|
|
284
|
+
machines: Array.from(
|
|
285
|
+
{ length: MACHINE_LIMITS_V1.maxMachinesPerUser + 1 },
|
|
286
|
+
() => machineListEntryV1(record, Date.parse(NOW)),
|
|
287
|
+
),
|
|
288
|
+
serverTime: NOW,
|
|
289
|
+
},
|
|
290
|
+
decodeMachineListViewV1,
|
|
291
|
+
).code,
|
|
292
|
+
).toBe("limit-exceeded");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("the exec bounds accept exactly their ceiling", () => {
|
|
296
|
+
expect(
|
|
297
|
+
decodeMachineOpV1({
|
|
298
|
+
...op,
|
|
299
|
+
timeoutMs: MACHINE_LIMITS_V1.execTimeoutMs,
|
|
300
|
+
maxOutputBytes: MACHINE_LIMITS_V1.outputBytes,
|
|
301
|
+
}),
|
|
302
|
+
).toMatchObject({ maxOutputBytes: MACHINE_LIMITS_V1.outputBytes });
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("a result's base64 payload must actually be base64", () => {
|
|
306
|
+
const result = {
|
|
307
|
+
schemaVersion: 1,
|
|
308
|
+
commandId: "tool:3:1:0",
|
|
309
|
+
finishedAt: NOW,
|
|
310
|
+
outcome: "ok",
|
|
311
|
+
truncated: true,
|
|
312
|
+
bytesBase64: "aGVsbG8=",
|
|
313
|
+
};
|
|
314
|
+
expect(decodeMachineCommandResultV1(result)).toMatchObject({
|
|
315
|
+
bytesBase64: "aGVsbG8=",
|
|
316
|
+
});
|
|
317
|
+
expect(() =>
|
|
318
|
+
decodeMachineCommandResultV1({ ...result, bytesBase64: "not base64!" }),
|
|
319
|
+
).toThrow(/not valid base64/);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe("capabilities", () => {
|
|
324
|
+
test("only a macos agent may report messages", () => {
|
|
325
|
+
const enrollment = {
|
|
326
|
+
schemaVersion: 1,
|
|
327
|
+
code: "AB12",
|
|
328
|
+
label: "box",
|
|
329
|
+
platform: "linux",
|
|
330
|
+
agentVersion: "0.1.0",
|
|
331
|
+
capabilities: ["exec", "messages"],
|
|
332
|
+
};
|
|
333
|
+
expect(() => decodeMachineEnrollmentV1(enrollment)).toThrow(
|
|
334
|
+
/only report messages on macos/,
|
|
335
|
+
);
|
|
336
|
+
expect(
|
|
337
|
+
decodeMachineEnrollmentV1({ ...enrollment, platform: "macos" })
|
|
338
|
+
.capabilities,
|
|
339
|
+
).toEqual(["exec", "messages"]);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("a repeated capability is refused rather than deduplicated", () => {
|
|
343
|
+
expect(() =>
|
|
344
|
+
decodeMachineRecordV1({ ...record, capabilities: ["exec", "exec"] }),
|
|
345
|
+
).toThrow(/repeats exec/);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("an op names the capability it needs", () => {
|
|
349
|
+
expect(machineOpCapabilityV1(op)).toBe("exec");
|
|
350
|
+
expect(
|
|
351
|
+
machineOpCapabilityV1({ kind: "read", path: "/tmp/a", maxBytes: 10 }),
|
|
352
|
+
).toBe("files");
|
|
353
|
+
expect(
|
|
354
|
+
machineOpCapabilityV1({
|
|
355
|
+
kind: "copy-to-computer",
|
|
356
|
+
path: "/tmp/a",
|
|
357
|
+
workspacePath: "notes.md",
|
|
358
|
+
}),
|
|
359
|
+
).toBe("files");
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
describe("presence is arithmetic, not a stored flag", () => {
|
|
364
|
+
const seen = Date.parse(NOW);
|
|
365
|
+
|
|
366
|
+
test("reads connected up to the TTL and disconnected one millisecond past it", () => {
|
|
367
|
+
expect(machineConnectedV1(record, seen)).toBe(true);
|
|
368
|
+
expect(machineConnectedV1(record, seen + MACHINE_PRESENCE_TTL_MS)).toBe(
|
|
369
|
+
true,
|
|
370
|
+
);
|
|
371
|
+
expect(machineConnectedV1(record, seen + MACHINE_PRESENCE_TTL_MS + 1)).toBe(
|
|
372
|
+
false,
|
|
373
|
+
);
|
|
374
|
+
expect(machineConnectedV1(record, new Date(seen + 1_000))).toBe(true);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("tolerates ordinary clock skew but not a wildly future last-seen", () => {
|
|
378
|
+
expect(machineConnectedV1(record, seen - 1_000)).toBe(true);
|
|
379
|
+
expect(machineConnectedV1(record, seen - MACHINE_PRESENCE_TTL_MS - 1)).toBe(
|
|
380
|
+
false,
|
|
381
|
+
);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("a revoked machine is never connected, however fresh its poll", () => {
|
|
385
|
+
expect(machineConnectedV1({ ...record, revokedAt: NOW }, seen)).toBe(false);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("an unparseable last-seen reads disconnected rather than throwing", () => {
|
|
389
|
+
expect(machineConnectedV1({ lastSeenAt: "never" }, seen)).toBe(false);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("the list projection carries presence and no proof of anything", () => {
|
|
393
|
+
const entry = machineListEntryV1(
|
|
394
|
+
record,
|
|
395
|
+
seen + MACHINE_PRESENCE_TTL_MS + 1,
|
|
396
|
+
);
|
|
397
|
+
expect(entry.connected).toBe(false);
|
|
398
|
+
expect(JSON.stringify(entry)).not.toContain(DIGEST);
|
|
399
|
+
expect(JSON.stringify(entry)).not.toContain("user-1");
|
|
400
|
+
expect(Object.hasOwn(entry, "keyVersion")).toBe(false);
|
|
401
|
+
// Pure: the same record and the same clock give the same row.
|
|
402
|
+
expect(machineListEntryV1(record, seen)).toEqual(
|
|
403
|
+
machineListEntryV1(record, seen),
|
|
404
|
+
);
|
|
405
|
+
});
|
|
406
|
+
});
|