@webskill/sdk 0.29.0 → 0.30.0-next.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/dist/agent.d.ts +2 -2
- package/dist/agent.js +27 -760
- package/dist/browser.d.ts +3 -3
- package/dist/browser.js +2 -1
- package/dist/controller.d.ts +88 -0
- package/dist/controller.js +3 -0
- package/dist/device-DMUgqNzr.js +1782 -0
- package/dist/{dist-CTsxblSD.js → dist-Ww6TChcf.js} +2 -2
- package/dist/{geometry-QmrkJw1_.js → geometry-1xQbK4hB.js} +1 -76
- package/dist/governance.d.ts +3 -3
- package/dist/{index-C3t1jKAs.d.ts → index-1ASrUwzn.d.ts} +2 -2
- package/dist/{index-B5zHZDNA.d.ts → index-Cj2HedfL.d.ts} +3 -2
- package/dist/{index-Cngzg2WS.d.ts → index-UbNuzd2a.d.ts} +3 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/mcp.d.ts +2 -2
- package/dist/node.d.ts +3 -3
- package/dist/{openUiLibrary-BnhYUlqY.js → openUiLibrary-BjRDYyTe.js} +1 -1
- package/dist/{skillVersionStore-S4_HJ_Fl-AiNsSSdO.d.ts → skillVersionStore-S4_HJ_Fl-5K8jrAg_.d.ts} +1 -1
- package/dist/testing.d.ts +88 -2
- package/dist/testing.js +1287 -1
- package/dist/toolSource-DMCTnSVg.js +743 -0
- package/dist/types-BXRIoabj.js +76 -0
- package/dist/types-DmcXN3qR-Dy7ZDstc.d.ts +265 -0
- package/dist/{types-wKp3YBSM-D7w-ZbsB.d.ts → types-wKp3YBSM-CSeFUL9R.d.ts} +1 -1
- package/dist/ui-react.css +2687 -0
- package/dist/ui-react.d.ts +1045 -5
- package/dist/ui-react.js +150 -30
- package/dist/ui-vue.d.ts +1 -1
- package/dist/ui.d.ts +3 -3
- package/dist/ui.js +1 -1
- package/package.json +17 -4
package/dist/testing.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { t as MockLlmClient } from "./llm-eIQNO9tr.js";
|
|
2
2
|
import { t as MemoryArtifactStore } from "./memoryArtifactStore-Bs7glPqm.js";
|
|
3
3
|
import { n as loadGoogleConfigFromEnv, r as loadLlmConfigFromEnv, t as loadAnthropicConfigFromEnv } from "./env-Bj1MI2Ww.js";
|
|
4
|
+
import { a as createWebSocketControllerTransport, c as ControllerRpcError, d as CONTROLLER_PROTOCOL_VERSION, f as CONTROLLER_TOOL_NAMES, i as randomToken, l as controllerError, n as base64UrlToBytes, t as createControllerDevice, u as CONTROLLER_PROTOCOL_LIMITS } from "./device-DMUgqNzr.js";
|
|
4
5
|
|
|
5
6
|
//#region ../runtime/src/interaction/mockUiBridge.ts
|
|
6
7
|
/**
|
|
@@ -63,4 +64,1289 @@ var InMemoryStore = class {
|
|
|
63
64
|
};
|
|
64
65
|
|
|
65
66
|
//#endregion
|
|
66
|
-
|
|
67
|
+
//#region ../controller/src/testing/referenceController.ts
|
|
68
|
+
var ReferenceController = class {
|
|
69
|
+
url;
|
|
70
|
+
options;
|
|
71
|
+
instanceId;
|
|
72
|
+
controllerName;
|
|
73
|
+
server;
|
|
74
|
+
transports = /* @__PURE__ */ new Set();
|
|
75
|
+
authorizedTransports = /* @__PURE__ */ new Map();
|
|
76
|
+
pendingByCode = /* @__PURE__ */ new Map();
|
|
77
|
+
sessions = /* @__PURE__ */ new Map();
|
|
78
|
+
grants = /* @__PURE__ */ new Map();
|
|
79
|
+
nonces = /* @__PURE__ */ new Map();
|
|
80
|
+
errors = [];
|
|
81
|
+
readyResults = /* @__PURE__ */ new Map();
|
|
82
|
+
readyWaiters = /* @__PURE__ */ new Map();
|
|
83
|
+
pairingOutcomes = /* @__PURE__ */ new Map();
|
|
84
|
+
pairingWaiters = /* @__PURE__ */ new Map();
|
|
85
|
+
constructor(url, options = {}) {
|
|
86
|
+
this.url = url;
|
|
87
|
+
this.options = options;
|
|
88
|
+
this.instanceId = options.instanceId ?? "reference-controller";
|
|
89
|
+
this.controllerName = options.controllerName ?? "Reference Controller";
|
|
90
|
+
}
|
|
91
|
+
get grantedDevices() {
|
|
92
|
+
return [...this.grants.values()];
|
|
93
|
+
}
|
|
94
|
+
get observedErrors() {
|
|
95
|
+
return this.errors;
|
|
96
|
+
}
|
|
97
|
+
get connectionCount() {
|
|
98
|
+
return [...this.transports].filter((transport) => transport.connected).length;
|
|
99
|
+
}
|
|
100
|
+
mcpTransport() {
|
|
101
|
+
const transport = [...this.authorizedTransports.values()].at(-1);
|
|
102
|
+
if (!transport?.connected) throw controllerError("CONTROLLER_NOT_PAIRED", "No authorized device transport is available");
|
|
103
|
+
return transport;
|
|
104
|
+
}
|
|
105
|
+
deviceTransport() {
|
|
106
|
+
const transport = [...this.transports].at(-1);
|
|
107
|
+
if (!transport?.connected) throw controllerError("CONTROLLER_NOT_PAIRED", "No device transport is available");
|
|
108
|
+
return transport;
|
|
109
|
+
}
|
|
110
|
+
async waitForReady(sessionId = [...this.authorizedTransports.keys()].at(-1), timeoutMs = 5e3) {
|
|
111
|
+
if (!sessionId) throw controllerError("CONTROLLER_NOT_PAIRED", "No device pairing session is available");
|
|
112
|
+
const existing = this.readyResults.get(sessionId);
|
|
113
|
+
if (existing) return existing;
|
|
114
|
+
return await new Promise((resolve, reject) => {
|
|
115
|
+
const waiters = this.readyWaiters.get(sessionId) ?? [];
|
|
116
|
+
const timer = setTimeout(() => {
|
|
117
|
+
this.readyWaiters.delete(sessionId);
|
|
118
|
+
reject(/* @__PURE__ */ new Error("Device did not announce protocol readiness"));
|
|
119
|
+
}, timeoutMs);
|
|
120
|
+
waiters.push({
|
|
121
|
+
resolve,
|
|
122
|
+
timer
|
|
123
|
+
});
|
|
124
|
+
this.readyWaiters.set(sessionId, waiters);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
async waitForPairingOutcome(sessionId, outcome, timeoutMs = 5e3) {
|
|
128
|
+
const existing = this.pairingOutcomes.get(sessionId);
|
|
129
|
+
if (existing) {
|
|
130
|
+
if (existing !== outcome) throw new Error(`Pairing outcome was ${existing}, expected ${outcome}`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
await new Promise((resolve, reject) => {
|
|
134
|
+
const waiters = this.pairingWaiters.get(sessionId) ?? [];
|
|
135
|
+
const timer = setTimeout(() => {
|
|
136
|
+
this.pairingWaiters.delete(sessionId);
|
|
137
|
+
reject(/* @__PURE__ */ new Error(`Pairing outcome did not arrive: ${outcome}`));
|
|
138
|
+
}, timeoutMs);
|
|
139
|
+
waiters.push({
|
|
140
|
+
resolve,
|
|
141
|
+
timer
|
|
142
|
+
});
|
|
143
|
+
this.pairingWaiters.set(sessionId, waiters);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
async start() {
|
|
147
|
+
const { WebSocketServer } = await import("ws");
|
|
148
|
+
const target = new URL(this.url);
|
|
149
|
+
const server = new WebSocketServer({
|
|
150
|
+
host: target.hostname.replace(/^\[|\]$/g, ""),
|
|
151
|
+
port: Number(target.port || 0)
|
|
152
|
+
});
|
|
153
|
+
this.server = server;
|
|
154
|
+
server.on("connection", (socket) => this.accept(socket));
|
|
155
|
+
await new Promise((resolve, reject) => {
|
|
156
|
+
server.once("listening", () => resolve());
|
|
157
|
+
server.once("error", (error) => reject(error));
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
accept(socket) {
|
|
161
|
+
let authorizedSession;
|
|
162
|
+
const transport = createWebSocketControllerTransport({
|
|
163
|
+
url: this.url,
|
|
164
|
+
socket,
|
|
165
|
+
isAuthorized: () => {
|
|
166
|
+
if (!authorizedSession) return false;
|
|
167
|
+
const device = this.grants.get(authorizedSession);
|
|
168
|
+
return device !== void 0 && !device.revoked && Date.now() <= device.expiresAt;
|
|
169
|
+
},
|
|
170
|
+
onControlRequest: async (method, params) => {
|
|
171
|
+
if (method === "webskill/ping") return {};
|
|
172
|
+
if (method === "webskill/bye") {
|
|
173
|
+
await transport.close();
|
|
174
|
+
return {};
|
|
175
|
+
}
|
|
176
|
+
const result = await this.onRequest(method, params, transport, (sessionId) => {
|
|
177
|
+
authorizedSession = sessionId;
|
|
178
|
+
}, () => authorizedSession);
|
|
179
|
+
if (authorizedSession) this.authorizedTransports.set(authorizedSession, transport);
|
|
180
|
+
return result;
|
|
181
|
+
},
|
|
182
|
+
onControlNotification: async (method, params) => {
|
|
183
|
+
if (method === "webskill/ready" && !this.options.ignoreReady) this.noteReady(params, authorizedSession);
|
|
184
|
+
},
|
|
185
|
+
onclose: () => {
|
|
186
|
+
this.transports.delete(transport);
|
|
187
|
+
if (authorizedSession && this.authorizedTransports.get(authorizedSession) === transport) {
|
|
188
|
+
this.authorizedTransports.delete(authorizedSession);
|
|
189
|
+
this.readyResults.delete(authorizedSession);
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
onerror: (error) => this.errors.push(error)
|
|
193
|
+
});
|
|
194
|
+
this.transports.add(transport);
|
|
195
|
+
transport.connect().catch((error) => this.errors.push(error));
|
|
196
|
+
}
|
|
197
|
+
noteReady(params, authorizedSession) {
|
|
198
|
+
if (!authorizedSession || params.deviceEpoch !== authorizedSession) throw controllerError("CONTROLLER_MESSAGE_INVALID", "The ready notification does not match the authorized device epoch");
|
|
199
|
+
this.readyResults.set(authorizedSession, params);
|
|
200
|
+
for (const waiter of this.readyWaiters.get(authorizedSession) ?? []) {
|
|
201
|
+
clearTimeout(waiter.timer);
|
|
202
|
+
waiter.resolve(params);
|
|
203
|
+
}
|
|
204
|
+
this.readyWaiters.delete(authorizedSession);
|
|
205
|
+
}
|
|
206
|
+
onRequest(method, params, transport, authorize, currentSession) {
|
|
207
|
+
switch (method) {
|
|
208
|
+
case "webskill/hello": return this.hello(params);
|
|
209
|
+
case "webskill/pair.start": return this.pairStart(params, transport);
|
|
210
|
+
case "webskill/pair.confirm": return this.pairConfirm(params, authorize);
|
|
211
|
+
case "webskill/auth.present": return this.authPresent(params, authorize);
|
|
212
|
+
case "webskill/auth.challenge": return this.authChallenge(currentSession());
|
|
213
|
+
case "webskill/auth.renew": return this.authRenew(params, authorize);
|
|
214
|
+
default: throw controllerError("CONTROLLER_CAPABILITY_MISSING", `Unsupported controller request: ${method}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
hello(params) {
|
|
218
|
+
const selected = params.protocolVersions.find((version) => (this.options.allowedProtocolVersions ?? ["0.1"]).includes(version));
|
|
219
|
+
if (!selected) throw controllerError("CONTROLLER_PROTOCOL_UNSUPPORTED", "No controller protocol version is shared");
|
|
220
|
+
return {
|
|
221
|
+
protocolVersion: selected,
|
|
222
|
+
controllerInstanceId: this.instanceId,
|
|
223
|
+
controllerName: this.controllerName,
|
|
224
|
+
excludedOrigins: this.options.excludedOrigins ?? []
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
pairStart(params, transport) {
|
|
228
|
+
const sessionId = randomToken(18);
|
|
229
|
+
const code = randomCode();
|
|
230
|
+
const pending = {
|
|
231
|
+
sessionId,
|
|
232
|
+
publicKey: params.publicKey,
|
|
233
|
+
deviceName: params.deviceName,
|
|
234
|
+
code,
|
|
235
|
+
expiresAt: Date.now() + CONTROLLER_PROTOCOL_LIMITS.pairingCodeTtlMaxMs,
|
|
236
|
+
transport,
|
|
237
|
+
attempts: 0,
|
|
238
|
+
used: false
|
|
239
|
+
};
|
|
240
|
+
this.pendingByCode.set(code, pending);
|
|
241
|
+
this.sessions.set(sessionId, pending);
|
|
242
|
+
return {
|
|
243
|
+
sessionId,
|
|
244
|
+
code,
|
|
245
|
+
expiresAt: new Date(pending.expiresAt).toISOString()
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
async approveCode(code, options = {}) {
|
|
249
|
+
const pending = this.pendingByCode.get(code) ?? (options.sessionId ? this.sessions.get(options.sessionId) : void 0);
|
|
250
|
+
if (!pending || Date.now() > pending.expiresAt) throw controllerError("CONTROLLER_PAIRING_REJECTED", "The pairing code is unknown or expired");
|
|
251
|
+
pending.attempts += 1;
|
|
252
|
+
if (pending.attempts > CONTROLLER_PROTOCOL_LIMITS.pairingAttemptsMax) throw controllerError("CONTROLLER_PAIRING_RATE_LIMITED", "Too many pairing attempts");
|
|
253
|
+
if (code !== pending.code) throw controllerError("CONTROLLER_PAIRING_REJECTED", "The pairing code does not match this pairing session");
|
|
254
|
+
if (options.publicKeyOverride !== void 0 && options.publicKeyOverride !== pending.publicKey && !this.options.allowDeviceSubstitution) throw controllerError("CONTROLLER_PAIRING_REJECTED", "The pairing code is bound to a different device public key");
|
|
255
|
+
if (options.sessionId && options.sessionId !== pending.sessionId && !this.options.allowCrossSessionApproval) throw controllerError("CONTROLLER_PAIRING_REJECTED", "The pairing code belongs to a different session");
|
|
256
|
+
if (options.authenticated === false && !this.options.allowUnauthenticatedApproval) {
|
|
257
|
+
await transportNotify(pending.transport, "webskill/pair.rejected", {
|
|
258
|
+
sessionId: pending.sessionId,
|
|
259
|
+
code: "Unauthenticated approval"
|
|
260
|
+
});
|
|
261
|
+
throw controllerError("CONTROLLER_PAIRING_REJECTED", "Approval requires an authenticated controller session");
|
|
262
|
+
}
|
|
263
|
+
if (pending.used && !this.options.allowCodeReuse) throw controllerError("CONTROLLER_PAIRING_REJECTED", "The pairing code has already been used");
|
|
264
|
+
pending.used = true;
|
|
265
|
+
pending.approvalNonce = randomToken(18);
|
|
266
|
+
pending.principal = options.principal ?? "Test User";
|
|
267
|
+
const principal = pending.principal;
|
|
268
|
+
const notification = {
|
|
269
|
+
sessionId: pending.sessionId,
|
|
270
|
+
approvalNonce: pending.approvalNonce,
|
|
271
|
+
controllerInstanceId: this.instanceId,
|
|
272
|
+
...this.options.omitPrincipal ? {} : { principal: { displayName: principal } },
|
|
273
|
+
capabilities: ["browser"],
|
|
274
|
+
excludedOrigins: []
|
|
275
|
+
};
|
|
276
|
+
await transportNotify(pending.transport, "webskill/pair.approved", notification);
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
async rejectCode(code) {
|
|
280
|
+
const pending = this.pendingByCode.get(code);
|
|
281
|
+
if (!pending) throw controllerError("CONTROLLER_PAIRING_REJECTED", "The pairing code is unknown");
|
|
282
|
+
pending.used = true;
|
|
283
|
+
await transportNotify(pending.transport, "webskill/pair.rejected", {
|
|
284
|
+
sessionId: pending.sessionId,
|
|
285
|
+
code: "Rejected by user"
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
async pairConfirm(params, authorize) {
|
|
289
|
+
const pending = this.sessions.get(params.sessionId);
|
|
290
|
+
if (!pending || !pending.approvalNonce) throw controllerError("CONTROLLER_PAIRING_REJECTED", "The pairing session has not been approved");
|
|
291
|
+
if (!params.accepted) {
|
|
292
|
+
await transportNotify(pending.transport, "webskill/pair.rejected", {
|
|
293
|
+
sessionId: pending.sessionId,
|
|
294
|
+
code: "Rejected by device"
|
|
295
|
+
});
|
|
296
|
+
this.notePairingOutcome(pending.sessionId, "rejected");
|
|
297
|
+
return {};
|
|
298
|
+
}
|
|
299
|
+
if (!params.signature) throw controllerError("CONTROLLER_PAIRING_REJECTED", "The device confirmation signature is missing");
|
|
300
|
+
if (!this.options.skipDeviceSignatureVerification) {
|
|
301
|
+
if (!await verifyDeviceSignature(pending.publicKey, [
|
|
302
|
+
"webskill-controller/0.1",
|
|
303
|
+
"pair.confirm",
|
|
304
|
+
pending.sessionId,
|
|
305
|
+
pending.approvalNonce
|
|
306
|
+
].join("\n"), params.signature)) throw controllerError("CONTROLLER_PAIRING_REJECTED", "The device confirmation signature is invalid");
|
|
307
|
+
}
|
|
308
|
+
const expiresAt = Date.now() + CONTROLLER_PROTOCOL_LIMITS.grantTtlMaxMs;
|
|
309
|
+
const grant = randomToken(32);
|
|
310
|
+
const device = {
|
|
311
|
+
sessionId: pending.sessionId,
|
|
312
|
+
publicKey: pending.publicKey,
|
|
313
|
+
deviceName: pending.deviceName,
|
|
314
|
+
principal: pending.principal ?? "Test User",
|
|
315
|
+
grant,
|
|
316
|
+
expiresAt,
|
|
317
|
+
revoked: false
|
|
318
|
+
};
|
|
319
|
+
this.grants.set(pending.sessionId, device);
|
|
320
|
+
authorize(pending.sessionId);
|
|
321
|
+
this.notePairingOutcome(pending.sessionId, "confirmed");
|
|
322
|
+
return {
|
|
323
|
+
grant,
|
|
324
|
+
expiresAt: new Date(expiresAt).toISOString()
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
notePairingOutcome(sessionId, outcome) {
|
|
328
|
+
this.pairingOutcomes.set(sessionId, outcome);
|
|
329
|
+
for (const waiter of this.pairingWaiters.get(sessionId) ?? []) {
|
|
330
|
+
clearTimeout(waiter.timer);
|
|
331
|
+
waiter.resolve();
|
|
332
|
+
}
|
|
333
|
+
this.pairingWaiters.delete(sessionId);
|
|
334
|
+
}
|
|
335
|
+
authPresent(params, authorize) {
|
|
336
|
+
const device = [...this.grants.values()].find((candidate) => candidate.grant === params.grant);
|
|
337
|
+
if (!device) throw controllerError("CONTROLLER_NOT_PAIRED", "The device grant is unknown");
|
|
338
|
+
authorize(device.sessionId);
|
|
339
|
+
if (device.revoked || Date.now() > device.expiresAt) throw controllerError("CONTROLLER_NOT_PAIRED", "The device grant is expired or revoked");
|
|
340
|
+
return { expiresAt: new Date(device.expiresAt).toISOString() };
|
|
341
|
+
}
|
|
342
|
+
authChallenge(sessionId) {
|
|
343
|
+
if (!sessionId) throw controllerError("CONTROLLER_NOT_PAIRED", "No authorized device session is available");
|
|
344
|
+
const nonce = randomToken(18);
|
|
345
|
+
const expiresAt = Date.now() + 6e4;
|
|
346
|
+
this.nonces.set(nonce, {
|
|
347
|
+
sessionId,
|
|
348
|
+
expiresAt
|
|
349
|
+
});
|
|
350
|
+
return {
|
|
351
|
+
nonce,
|
|
352
|
+
expiresAt: new Date(expiresAt).toISOString()
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
async authRenew(params, authorize) {
|
|
356
|
+
const challenge = this.nonces.get(params.nonce);
|
|
357
|
+
if (!challenge || Date.now() > challenge.expiresAt) throw controllerError("CONTROLLER_NOT_PAIRED", "The renewal challenge is invalid or expired");
|
|
358
|
+
const device = this.grants.get(challenge.sessionId);
|
|
359
|
+
if (!device || device.revoked) throw controllerError("CONTROLLER_NOT_PAIRED", "The device grant is revoked");
|
|
360
|
+
if (!await verifyDeviceSignature(device.publicKey, [
|
|
361
|
+
"webskill-controller/0.1",
|
|
362
|
+
"auth.renew",
|
|
363
|
+
this.instanceId,
|
|
364
|
+
params.nonce
|
|
365
|
+
].join("\n"), params.signature)) throw controllerError("CONTROLLER_NOT_PAIRED", "The renewal signature is invalid");
|
|
366
|
+
this.nonces.delete(params.nonce);
|
|
367
|
+
device.grant = randomToken(32);
|
|
368
|
+
device.expiresAt = Date.now() + CONTROLLER_PROTOCOL_LIMITS.grantTtlMaxMs;
|
|
369
|
+
this.grants.set(device.sessionId, device);
|
|
370
|
+
authorize(device.sessionId);
|
|
371
|
+
return {
|
|
372
|
+
grant: device.grant,
|
|
373
|
+
expiresAt: new Date(device.expiresAt).toISOString()
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
expireGrants() {
|
|
377
|
+
for (const device of this.grants.values()) device.expiresAt = Date.now() - 1;
|
|
378
|
+
}
|
|
379
|
+
async revokeAll(reason = "Revoked by test") {
|
|
380
|
+
for (const device of this.grants.values()) device.revoked = true;
|
|
381
|
+
for (const transport of this.transports) await transportNotify(transport, "webskill/auth.revoked", { reason }).catch(() => void 0);
|
|
382
|
+
}
|
|
383
|
+
async stop() {
|
|
384
|
+
for (const transport of this.transports) await transport.close().catch(() => void 0);
|
|
385
|
+
this.transports.clear();
|
|
386
|
+
this.readyResults.clear();
|
|
387
|
+
for (const waiters of [...this.readyWaiters.values(), ...this.pairingWaiters.values()]) for (const waiter of waiters) clearTimeout(waiter.timer);
|
|
388
|
+
this.readyWaiters.clear();
|
|
389
|
+
this.pairingWaiters.clear();
|
|
390
|
+
const server = this.server;
|
|
391
|
+
this.server = void 0;
|
|
392
|
+
if (server) {
|
|
393
|
+
for (const client of server.clients) client.terminate();
|
|
394
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
async function transportNotify(transport, method, params) {
|
|
399
|
+
await transport.notify(method, params);
|
|
400
|
+
}
|
|
401
|
+
async function verifyDeviceSignature(publicKey, text, signature) {
|
|
402
|
+
const key = await crypto.subtle.importKey("spki", base64UrlToBytes(publicKey), {
|
|
403
|
+
name: "ECDSA",
|
|
404
|
+
namedCurve: "P-256"
|
|
405
|
+
}, false, ["verify"]);
|
|
406
|
+
return await crypto.subtle.verify({
|
|
407
|
+
name: "ECDSA",
|
|
408
|
+
hash: "SHA-256"
|
|
409
|
+
}, key, base64UrlToBytes(signature), new TextEncoder().encode(text));
|
|
410
|
+
}
|
|
411
|
+
function randomCode() {
|
|
412
|
+
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
413
|
+
const value = [...crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(8))].map((byte) => alphabet[byte % 32]).join("");
|
|
414
|
+
return `${value.slice(0, 4)}-${value.slice(4)}`;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
//#endregion
|
|
418
|
+
//#region ../controller/src/testing/conformance.ts
|
|
419
|
+
var MemoryStore = class {
|
|
420
|
+
keyPair;
|
|
421
|
+
pairing;
|
|
422
|
+
writeState;
|
|
423
|
+
async loadKeyPair() {
|
|
424
|
+
return this.keyPair;
|
|
425
|
+
}
|
|
426
|
+
async saveKeyPair(keyPair) {
|
|
427
|
+
this.keyPair = keyPair;
|
|
428
|
+
}
|
|
429
|
+
async loadPairing() {
|
|
430
|
+
return this.pairing;
|
|
431
|
+
}
|
|
432
|
+
async savePairing(record) {
|
|
433
|
+
this.pairing = record;
|
|
434
|
+
}
|
|
435
|
+
async loadWriteState() {
|
|
436
|
+
return this.writeState;
|
|
437
|
+
}
|
|
438
|
+
async saveWriteState(state) {
|
|
439
|
+
this.writeState = state;
|
|
440
|
+
}
|
|
441
|
+
async clear() {
|
|
442
|
+
this.keyPair = void 0;
|
|
443
|
+
this.pairing = void 0;
|
|
444
|
+
this.writeState = void 0;
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
var ToolFixture = class {
|
|
448
|
+
calls = [];
|
|
449
|
+
auditEvents = [];
|
|
450
|
+
authorization = "approve";
|
|
451
|
+
active = 0;
|
|
452
|
+
maxActive = 0;
|
|
453
|
+
count = 0;
|
|
454
|
+
delayMs = 0;
|
|
455
|
+
auditDelayMs = 0;
|
|
456
|
+
failCompletionAudit = false;
|
|
457
|
+
pairingAccepted = true;
|
|
458
|
+
largeRead = false;
|
|
459
|
+
confirmationGate;
|
|
460
|
+
source(name) {
|
|
461
|
+
return {
|
|
462
|
+
kind: "controller-fixture",
|
|
463
|
+
listToolSpecs: async () => [{
|
|
464
|
+
name,
|
|
465
|
+
description: `${name} fixture`,
|
|
466
|
+
inputSchema: {
|
|
467
|
+
type: "object",
|
|
468
|
+
properties: {},
|
|
469
|
+
additionalProperties: false
|
|
470
|
+
}
|
|
471
|
+
}],
|
|
472
|
+
systemPrompt: async () => `Use ${name} only when it is available.`,
|
|
473
|
+
canHandle: (candidate) => candidate === name,
|
|
474
|
+
argCaptureTrust: () => void 0,
|
|
475
|
+
call: async (tool) => await this.call(tool)
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
async call(name) {
|
|
479
|
+
this.calls.push(name);
|
|
480
|
+
if (name === "perceive_page" && this.largeRead) return {
|
|
481
|
+
ok: true,
|
|
482
|
+
content: [{
|
|
483
|
+
type: "json",
|
|
484
|
+
data: { oversized: "x".repeat(CONTROLLER_PROTOCOL_LIMITS.maxMessageBytes) }
|
|
485
|
+
}]
|
|
486
|
+
};
|
|
487
|
+
if (name === "act_on_page" || name === "focus_page_tab" || name === "close_page_tab") {
|
|
488
|
+
if (this.authorization !== "approve" && this.authorization !== "remember") return {
|
|
489
|
+
ok: false,
|
|
490
|
+
content: [],
|
|
491
|
+
error: {
|
|
492
|
+
code: "PAGE_ACTION_DECLINED",
|
|
493
|
+
message: `Authorization ${this.authorization}`,
|
|
494
|
+
data: { retryable: this.authorization !== "deny" }
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
this.active += 1;
|
|
498
|
+
this.maxActive = Math.max(this.maxActive, this.active);
|
|
499
|
+
if (this.delayMs > 0) await sleep(this.delayMs);
|
|
500
|
+
this.count += 1;
|
|
501
|
+
this.active -= 1;
|
|
502
|
+
}
|
|
503
|
+
const result = {
|
|
504
|
+
ok: true,
|
|
505
|
+
content: [{
|
|
506
|
+
type: "json",
|
|
507
|
+
data: {
|
|
508
|
+
name,
|
|
509
|
+
count: this.count
|
|
510
|
+
}
|
|
511
|
+
}]
|
|
512
|
+
};
|
|
513
|
+
if ([
|
|
514
|
+
"act_on_page",
|
|
515
|
+
"focus_page_tab",
|
|
516
|
+
"close_page_tab"
|
|
517
|
+
].includes(name)) return Object.assign(result, { authorization: this.authorization === "remember" ? "remembered" : "approved" });
|
|
518
|
+
return result;
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
var ConformanceRun = class {
|
|
522
|
+
checks = [];
|
|
523
|
+
only;
|
|
524
|
+
known = /* @__PURE__ */ new Set();
|
|
525
|
+
constructor(only) {
|
|
526
|
+
this.only = only === void 0 ? void 0 : new Set(only);
|
|
527
|
+
}
|
|
528
|
+
async check(id, work) {
|
|
529
|
+
this.known.add(id);
|
|
530
|
+
if (this.only && !this.only.has(id)) return;
|
|
531
|
+
try {
|
|
532
|
+
await work();
|
|
533
|
+
this.checks.push({
|
|
534
|
+
id,
|
|
535
|
+
passed: true
|
|
536
|
+
});
|
|
537
|
+
} catch (error) {
|
|
538
|
+
this.checks.push({
|
|
539
|
+
id,
|
|
540
|
+
passed: false,
|
|
541
|
+
message: error instanceof Error ? error.message : String(error)
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
skip(id, message) {
|
|
546
|
+
this.known.add(id);
|
|
547
|
+
if (this.only && !this.only.has(id)) return;
|
|
548
|
+
this.checks.push({
|
|
549
|
+
id,
|
|
550
|
+
passed: false,
|
|
551
|
+
skipped: true,
|
|
552
|
+
message
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
report() {
|
|
556
|
+
for (const id of this.only ?? []) if (!this.known.has(id)) this.checks.push({
|
|
557
|
+
id: "suite.only",
|
|
558
|
+
passed: false,
|
|
559
|
+
message: `Unknown conformance check: ${id}`
|
|
560
|
+
});
|
|
561
|
+
return {
|
|
562
|
+
passed: this.checks.every((check) => check.passed || check.skipped === true),
|
|
563
|
+
checks: this.checks
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
function assert(value, message) {
|
|
568
|
+
if (!value) throw new Error(message);
|
|
569
|
+
}
|
|
570
|
+
function sleep(ms) {
|
|
571
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
572
|
+
}
|
|
573
|
+
async function waitFor(work, message, timeoutMs = 1e4) {
|
|
574
|
+
const start = Date.now();
|
|
575
|
+
while (!await work()) {
|
|
576
|
+
if (Date.now() - start > timeoutMs) throw new Error(message);
|
|
577
|
+
await sleep(10);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
function mcpCaller(transport, epoch = () => void 0, handlers = {}) {
|
|
581
|
+
let sequence = 0;
|
|
582
|
+
let nextInvocationSequence = 0;
|
|
583
|
+
let sequenceEpoch;
|
|
584
|
+
const invocationSequences = handlers.sequenceHistory ?? /* @__PURE__ */ new Map();
|
|
585
|
+
const pending = /* @__PURE__ */ new Map();
|
|
586
|
+
transport.onmessage = (message) => {
|
|
587
|
+
if (message.id === void 0) return;
|
|
588
|
+
const entry = pending.get(message.id);
|
|
589
|
+
if (!entry) return;
|
|
590
|
+
pending.delete(message.id);
|
|
591
|
+
clearTimeout(entry.timeout);
|
|
592
|
+
if (message.error) {
|
|
593
|
+
const data = message.error.data;
|
|
594
|
+
entry.reject(new ControllerRpcError(data?.code ?? "CONTROLLER_MESSAGE_INVALID", message.error.message));
|
|
595
|
+
} else entry.resolve(message);
|
|
596
|
+
};
|
|
597
|
+
const invoke = async (method, params) => {
|
|
598
|
+
const id = ++sequence;
|
|
599
|
+
let callParams = params;
|
|
600
|
+
let writeCall = false;
|
|
601
|
+
let invocationId;
|
|
602
|
+
if (method === "tools/call") {
|
|
603
|
+
const input = params;
|
|
604
|
+
if ([
|
|
605
|
+
"act_on_page",
|
|
606
|
+
"focus_page_tab",
|
|
607
|
+
"close_page_tab"
|
|
608
|
+
].includes(input.name)) {
|
|
609
|
+
writeCall = true;
|
|
610
|
+
invocationId = String(input._meta?.["webskill/invocationId"] ?? `invocation-${invocationSequences.size + 1}`);
|
|
611
|
+
const callTool = async (tool, args) => {
|
|
612
|
+
return (await invoke("tools/call", {
|
|
613
|
+
name: tool,
|
|
614
|
+
arguments: args,
|
|
615
|
+
_meta: { "webskill/invocationId": `prepare-${invocationId}` }
|
|
616
|
+
})).result ?? {};
|
|
617
|
+
};
|
|
618
|
+
const args = await handlers.toolArguments?.(input.name, invocationId, callTool);
|
|
619
|
+
const suppliedSequence = input._meta?.["webskill/sequence"];
|
|
620
|
+
const knownSequence = invocationSequences.get(invocationId);
|
|
621
|
+
const suppliedEpoch = input._meta?.["webskill/deviceEpoch"];
|
|
622
|
+
const deviceEpoch = typeof suppliedEpoch === "string" ? suppliedEpoch : epoch();
|
|
623
|
+
if (!deviceEpoch) throw new Error("No paired device epoch is available");
|
|
624
|
+
if (suppliedSequence === void 0 && knownSequence === void 0 && sequenceEpoch !== deviceEpoch) {
|
|
625
|
+
const data = (await invoke("tools/call", {
|
|
626
|
+
name: "browser_status",
|
|
627
|
+
arguments: {},
|
|
628
|
+
_meta: { "webskill/invocationId": `status-${invocationId}` }
|
|
629
|
+
})).result?.structuredContent;
|
|
630
|
+
assert(data?.["deviceEpoch"] === deviceEpoch, "The device epoch changed while restoring the sequence allocator");
|
|
631
|
+
assert(data?.["writeStateRecoverable"] === true, "The device write state is not recoverable; pair the device again");
|
|
632
|
+
const watermark = data["writeHighWatermark"];
|
|
633
|
+
assert(typeof watermark === "number" && Number.isSafeInteger(watermark) && watermark >= 0, "The device returned an invalid write watermark");
|
|
634
|
+
nextInvocationSequence = watermark;
|
|
635
|
+
sequenceEpoch = deviceEpoch;
|
|
636
|
+
}
|
|
637
|
+
if (invocationId) await handlers.beforeWriteCall?.(invocationId);
|
|
638
|
+
const invocationSequence = typeof suppliedSequence === "number" ? suppliedSequence : knownSequence ?? ++nextInvocationSequence;
|
|
639
|
+
if (suppliedSequence === void 0) nextInvocationSequence = Math.max(nextInvocationSequence, invocationSequence);
|
|
640
|
+
invocationSequences.set(invocationId, invocationSequence);
|
|
641
|
+
callParams = {
|
|
642
|
+
...input,
|
|
643
|
+
...args === void 0 ? {} : { arguments: args },
|
|
644
|
+
_meta: {
|
|
645
|
+
...input._meta,
|
|
646
|
+
"webskill/invocationId": invocationId,
|
|
647
|
+
"webskill/deviceEpoch": deviceEpoch,
|
|
648
|
+
"webskill/sequence": invocationSequence
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
const result = new Promise((resolve, reject) => {
|
|
654
|
+
const timeout = setTimeout(() => {
|
|
655
|
+
pending.delete(id);
|
|
656
|
+
reject(/* @__PURE__ */ new Error(`MCP response timed out: ${method}`));
|
|
657
|
+
}, 1e4);
|
|
658
|
+
pending.set(id, {
|
|
659
|
+
resolve,
|
|
660
|
+
reject,
|
|
661
|
+
timeout
|
|
662
|
+
});
|
|
663
|
+
});
|
|
664
|
+
try {
|
|
665
|
+
await transport.send({
|
|
666
|
+
jsonrpc: "2.0",
|
|
667
|
+
id,
|
|
668
|
+
method,
|
|
669
|
+
params: callParams
|
|
670
|
+
});
|
|
671
|
+
} catch (error) {
|
|
672
|
+
const entry = pending.get(id);
|
|
673
|
+
if (entry) clearTimeout(entry.timeout);
|
|
674
|
+
pending.delete(id);
|
|
675
|
+
throw error;
|
|
676
|
+
}
|
|
677
|
+
if (writeCall && handlers.onWriteCall) {
|
|
678
|
+
await Promise.all([result, handlers.onWriteCall(result)]);
|
|
679
|
+
return await result;
|
|
680
|
+
}
|
|
681
|
+
return await result;
|
|
682
|
+
};
|
|
683
|
+
return invoke;
|
|
684
|
+
}
|
|
685
|
+
function makeDevice(url, store, fixtures, fixture, audit) {
|
|
686
|
+
return createControllerDevice({
|
|
687
|
+
url,
|
|
688
|
+
deviceName: fixtures.deviceName ?? "Conformance Device",
|
|
689
|
+
hostVersion: fixtures.hostVersion ?? "0.0.0",
|
|
690
|
+
store,
|
|
691
|
+
tools: CONTROLLER_TOOL_NAMES.filter((name) => name !== "browser_status").map((name) => fixture.source(name)),
|
|
692
|
+
confirmPairing: async () => {
|
|
693
|
+
const accepted = fixture.pairingAccepted;
|
|
694
|
+
await fixtures.confirmPairing?.(accepted);
|
|
695
|
+
await fixture.confirmationGate;
|
|
696
|
+
return accepted;
|
|
697
|
+
},
|
|
698
|
+
audit: async (event) => {
|
|
699
|
+
if (event.data["invocationId"] === "order-a" && event.data["phase"] === "started" && fixture.auditDelayMs > 0) await sleep(fixture.auditDelayMs);
|
|
700
|
+
if (fixture.failCompletionAudit && event.type === "controller.call" && event.data["invocationId"] === "incomplete-audit" && event.data["phase"] === void 0) throw new Error("Injected completion audit failure");
|
|
701
|
+
fixture.auditEvents.push(event);
|
|
702
|
+
audit?.(event);
|
|
703
|
+
},
|
|
704
|
+
onError: () => void 0
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
async function pairDevice(device, controller, fixtures, accepted, external = false) {
|
|
708
|
+
await device.connect();
|
|
709
|
+
const pairing = external ? await fixtures.startPairing() : await device.startPairing();
|
|
710
|
+
await controller.approveCode(pairing.code, { principal: fixtures.principal ?? "Conformance User" });
|
|
711
|
+
if (accepted) {
|
|
712
|
+
if (external) await fixtures.confirmPairing?.(true);
|
|
713
|
+
await controller.waitForReady(pairing.sessionId);
|
|
714
|
+
} else await controller.waitForPairingOutcome(pairing.sessionId, "rejected");
|
|
715
|
+
return pairing;
|
|
716
|
+
}
|
|
717
|
+
/** @experimental */
|
|
718
|
+
async function runControllerConformance(options) {
|
|
719
|
+
const run = new ConformanceRun(options.only);
|
|
720
|
+
if (options.role === "controller") await runControllerRole(run, options);
|
|
721
|
+
else await runDeviceRole(run, options);
|
|
722
|
+
return run.report();
|
|
723
|
+
}
|
|
724
|
+
async function runControllerRole(run, options) {
|
|
725
|
+
const fixtures = options.fixtures ?? {};
|
|
726
|
+
const external = options.device !== void 0;
|
|
727
|
+
if (external) {
|
|
728
|
+
const missing = [
|
|
729
|
+
"startPairing",
|
|
730
|
+
"confirmPairing",
|
|
731
|
+
"setHumanInControl",
|
|
732
|
+
"resolveAuthorization",
|
|
733
|
+
"toolArguments",
|
|
734
|
+
"pageCounter",
|
|
735
|
+
"maxActiveWrites",
|
|
736
|
+
"setWriteDelayMs",
|
|
737
|
+
"setOversizeRead",
|
|
738
|
+
"interruptConnection",
|
|
739
|
+
"hasPairingRecord",
|
|
740
|
+
"auditEvents",
|
|
741
|
+
"setAuditDelayMs",
|
|
742
|
+
"failCompletionAudit",
|
|
743
|
+
"auditWarning",
|
|
744
|
+
"userError",
|
|
745
|
+
"prepareAuthorization"
|
|
746
|
+
].filter((key) => fixtures[key] === void 0);
|
|
747
|
+
if (missing.length > 0) {
|
|
748
|
+
run.known.add("suite.device-fixtures");
|
|
749
|
+
for (const id of options.only ?? []) run.known.add(id);
|
|
750
|
+
run.checks.push({
|
|
751
|
+
id: "suite.device-fixtures",
|
|
752
|
+
passed: false,
|
|
753
|
+
message: `External device fixtures are required: ${missing.join(", ")}`
|
|
754
|
+
});
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
let controller = new ReferenceController(options.url, options.controllerOptions);
|
|
759
|
+
await controller.start();
|
|
760
|
+
const store = new MemoryStore();
|
|
761
|
+
const fixture = new ToolFixture();
|
|
762
|
+
let releaseRejectedConfirmation;
|
|
763
|
+
fixture.confirmationGate = new Promise((resolve) => {
|
|
764
|
+
releaseRejectedConfirmation = resolve;
|
|
765
|
+
});
|
|
766
|
+
let device = options.device ?? makeDevice(options.url, store, fixtures, fixture);
|
|
767
|
+
const epoch = () => external ? controller.grantedDevices.at(-1)?.sessionId : store.pairing?.sessionId;
|
|
768
|
+
const sequenceHistory = /* @__PURE__ */ new Map();
|
|
769
|
+
const makeCaller = () => {
|
|
770
|
+
if (external) {
|
|
771
|
+
assert(fixtures.toolArguments, "An external device requires real tool arguments");
|
|
772
|
+
assert(fixtures.resolveAuthorization, "An external device requires a real authorization driver");
|
|
773
|
+
}
|
|
774
|
+
return mcpCaller(controller.mcpTransport(), epoch, {
|
|
775
|
+
sequenceHistory,
|
|
776
|
+
...external ? { toolArguments: fixtures.toolArguments } : {},
|
|
777
|
+
...external && fixtures.prepareAuthorization ? { beforeWriteCall: async () => await fixtures.prepareAuthorization(fixture.authorization) } : {},
|
|
778
|
+
...external && fixtures.resolveAuthorization ? { onWriteCall: async (settled) => await fixtures.resolveAuthorization(fixture.authorization, settled) } : {}
|
|
779
|
+
});
|
|
780
|
+
};
|
|
781
|
+
const writeCount = async () => await fixtures.pageCounter?.() ?? fixture.count;
|
|
782
|
+
let transport;
|
|
783
|
+
try {
|
|
784
|
+
await run.check("handshake.unsupported-version", async () => {
|
|
785
|
+
transport = createWebSocketControllerTransport({ url: options.url });
|
|
786
|
+
let rejected;
|
|
787
|
+
try {
|
|
788
|
+
await transport.request("webskill/hello", {
|
|
789
|
+
protocolVersions: ["99.99"],
|
|
790
|
+
hostName: "Unsupported",
|
|
791
|
+
hostVersion: "0"
|
|
792
|
+
}, 3e3);
|
|
793
|
+
} catch (error) {
|
|
794
|
+
rejected = error;
|
|
795
|
+
}
|
|
796
|
+
assert(rejected instanceof ControllerRpcError && rejected.code === "CONTROLLER_PROTOCOL_UNSUPPORTED", "Unsupported protocol was not rejected");
|
|
797
|
+
await transport.close();
|
|
798
|
+
});
|
|
799
|
+
await run.check("handshake.version-negotiation", async () => {
|
|
800
|
+
await device.connect();
|
|
801
|
+
assert(controller.grantedDevices.length === 0, "Handshake unexpectedly created a device grant");
|
|
802
|
+
});
|
|
803
|
+
await run.check("auth.mcp-before-pairing", async () => {
|
|
804
|
+
await fixtures.breakMcpGate?.(device);
|
|
805
|
+
const caller = mcpCaller(controller.deviceTransport());
|
|
806
|
+
let rejected;
|
|
807
|
+
try {
|
|
808
|
+
await caller("tools/list");
|
|
809
|
+
} catch (error) {
|
|
810
|
+
rejected = error;
|
|
811
|
+
}
|
|
812
|
+
assert(rejected instanceof ControllerRpcError && rejected.code === "CONTROLLER_NOT_PAIRED", "MCP was available before pairing");
|
|
813
|
+
});
|
|
814
|
+
await run.check("pairing.device-confirmation-required", async () => {
|
|
815
|
+
const code = external ? await fixtures.startPairing() : await device.startPairing();
|
|
816
|
+
fixture.pairingAccepted = false;
|
|
817
|
+
await controller.approveCode(code.code, { principal: "Rejected User" });
|
|
818
|
+
assert(controller.grantedDevices.length === 0, "A grant was issued before device confirmation");
|
|
819
|
+
if (external) await fixtures.confirmPairing?.(false);
|
|
820
|
+
releaseRejectedConfirmation?.();
|
|
821
|
+
await controller.waitForPairingOutcome(code.sessionId, "rejected");
|
|
822
|
+
await device.unpair();
|
|
823
|
+
});
|
|
824
|
+
await run.check("pairing.grant-after-confirmation", async () => {
|
|
825
|
+
fixture.pairingAccepted = true;
|
|
826
|
+
fixture.confirmationGate = void 0;
|
|
827
|
+
if (!external) device = makeDevice(options.url, store, fixtures, fixture);
|
|
828
|
+
await pairDevice(device, controller, fixtures, true, external);
|
|
829
|
+
if (external) assert(controller.grantedDevices.length === 1, "No grant was issued after external device confirmation");
|
|
830
|
+
else assert(store.pairing?.grant, "No grant was stored after confirmation");
|
|
831
|
+
});
|
|
832
|
+
await run.check("protocol.ready-and-watermark", async () => {
|
|
833
|
+
const deviceEpoch = epoch();
|
|
834
|
+
assert(deviceEpoch, "No paired device epoch is available");
|
|
835
|
+
const ready = await controller.waitForReady(deviceEpoch);
|
|
836
|
+
assert(ready.deviceEpoch === deviceEpoch && ready.writeHighWatermark === 0 && ready.writeStateRecoverable === true, "Ready notification did not describe a new recoverable epoch");
|
|
837
|
+
assert(ready.hostVersion.length > 0, "Ready notification omitted the host version");
|
|
838
|
+
const statusCaller = makeCaller();
|
|
839
|
+
await statusCaller("initialize", {
|
|
840
|
+
protocolVersion: "2025-11-25",
|
|
841
|
+
capabilities: {},
|
|
842
|
+
clientInfo: {
|
|
843
|
+
name: "blackbox-ready",
|
|
844
|
+
version: "0"
|
|
845
|
+
}
|
|
846
|
+
});
|
|
847
|
+
const data = (await statusCaller("tools/call", {
|
|
848
|
+
name: "browser_status",
|
|
849
|
+
arguments: {},
|
|
850
|
+
_meta: { "webskill/invocationId": "blackbox-status" }
|
|
851
|
+
})).result?.structuredContent;
|
|
852
|
+
assert(data?.["deviceEpoch"] === deviceEpoch && data["writeHighWatermark"] === ready.writeHighWatermark && data["writeStateRecoverable"] === true, "browser_status does not agree with the ready notification");
|
|
853
|
+
});
|
|
854
|
+
let caller;
|
|
855
|
+
await run.check("tools.list-exact", async () => {
|
|
856
|
+
caller = makeCaller();
|
|
857
|
+
assert((await caller("initialize", {
|
|
858
|
+
protocolVersion: "2025-11-25",
|
|
859
|
+
capabilities: {},
|
|
860
|
+
clientInfo: {
|
|
861
|
+
name: "conformance",
|
|
862
|
+
version: "0"
|
|
863
|
+
}
|
|
864
|
+
})).result, "MCP initialize did not return a result");
|
|
865
|
+
const tools = (await caller("tools/list")).result.tools;
|
|
866
|
+
assert(tools.length === CONTROLLER_TOOL_NAMES.length, `Expected ${CONTROLLER_TOOL_NAMES.length} tools, got ${tools.length}`);
|
|
867
|
+
assert(tools.every((tool) => CONTROLLER_TOOL_NAMES.includes(tool.name)), "Unexpected controller tool");
|
|
868
|
+
});
|
|
869
|
+
await run.check("tools.names-in-text", async () => {
|
|
870
|
+
assert(caller, "MCP caller is unavailable");
|
|
871
|
+
const tools = (await caller("tools/list")).result.tools;
|
|
872
|
+
const names = new Set(tools.map((tool) => tool.name));
|
|
873
|
+
for (const tool of tools) for (const name of CONTROLLER_TOOL_NAMES) if (tool.description?.includes(name)) assert(names.has(name), `${name} is referenced but unavailable`);
|
|
874
|
+
});
|
|
875
|
+
await run.check("message.invalid-kept-alive", async () => {
|
|
876
|
+
assert(caller, "MCP caller is unavailable");
|
|
877
|
+
await controller.mcpTransport().send({
|
|
878
|
+
jsonrpc: "2.0",
|
|
879
|
+
id: "invalid-control",
|
|
880
|
+
method: "webskill/ping",
|
|
881
|
+
params: { unexpected: true }
|
|
882
|
+
});
|
|
883
|
+
assert((await caller("tools/call", {
|
|
884
|
+
name: "browser_status",
|
|
885
|
+
arguments: {},
|
|
886
|
+
_meta: { "webskill/invocationId": "still-alive" }
|
|
887
|
+
})).result, "An invalid control message closed the connection");
|
|
888
|
+
});
|
|
889
|
+
await run.check("error.envelope", async () => {
|
|
890
|
+
assert(caller, "MCP caller is unavailable");
|
|
891
|
+
let rejected;
|
|
892
|
+
try {
|
|
893
|
+
await caller("tools/call", {
|
|
894
|
+
name: "not_available",
|
|
895
|
+
arguments: {},
|
|
896
|
+
_meta: { "webskill/invocationId": "missing-tool" }
|
|
897
|
+
});
|
|
898
|
+
} catch (error) {
|
|
899
|
+
rejected = error;
|
|
900
|
+
}
|
|
901
|
+
assert(rejected instanceof ControllerRpcError && rejected.code === "CONTROLLER_CAPABILITY_MISSING", "Missing tool did not return the protocol error envelope");
|
|
902
|
+
if (external) assert(await fixtures.userError?.() === void 0, "A rejected controller request was surfaced as a device fault");
|
|
903
|
+
});
|
|
904
|
+
await run.check("message.oversize-result", async () => {
|
|
905
|
+
assert(caller, "MCP caller is unavailable");
|
|
906
|
+
fixture.largeRead = true;
|
|
907
|
+
if (external) await fixtures.setOversizeRead?.(true);
|
|
908
|
+
try {
|
|
909
|
+
const result = await caller("tools/call", {
|
|
910
|
+
name: "perceive_page",
|
|
911
|
+
arguments: {},
|
|
912
|
+
_meta: { "webskill/invocationId": "oversized-result" }
|
|
913
|
+
});
|
|
914
|
+
const error = result.result?._meta?.["webskill/error"];
|
|
915
|
+
assert(error?.code === "CONTROLLER_MESSAGE_INVALID", `Oversized tool result was not converted to a structured error: ${JSON.stringify(result.result?._meta ?? result.error ?? {})}`);
|
|
916
|
+
} finally {
|
|
917
|
+
fixture.largeRead = false;
|
|
918
|
+
if (external) await fixtures.setOversizeRead?.(false);
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
await run.check("call.write-idempotent", async () => {
|
|
922
|
+
assert(caller, "MCP caller is unavailable");
|
|
923
|
+
const params = {
|
|
924
|
+
name: "act_on_page",
|
|
925
|
+
arguments: {},
|
|
926
|
+
_meta: { "webskill/invocationId": "write-once" }
|
|
927
|
+
};
|
|
928
|
+
await caller("tools/call", params);
|
|
929
|
+
await caller("tools/call", params);
|
|
930
|
+
assert(await writeCount() === 1, `Write executed ${await writeCount()} times instead of once`);
|
|
931
|
+
});
|
|
932
|
+
await run.check("call.write-serialized", async () => {
|
|
933
|
+
assert(caller, "MCP caller is unavailable");
|
|
934
|
+
fixture.delayMs = 50;
|
|
935
|
+
if (external) await fixtures.setWriteDelayMs?.(50);
|
|
936
|
+
try {
|
|
937
|
+
await Promise.all([caller("tools/call", {
|
|
938
|
+
name: "act_on_page",
|
|
939
|
+
arguments: {},
|
|
940
|
+
_meta: { "webskill/invocationId": "serial-a" }
|
|
941
|
+
}), caller("tools/call", {
|
|
942
|
+
name: "act_on_page",
|
|
943
|
+
arguments: {},
|
|
944
|
+
_meta: { "webskill/invocationId": "serial-b" }
|
|
945
|
+
})]);
|
|
946
|
+
const maxActive = external ? await fixtures.maxActiveWrites?.() : fixture.maxActive;
|
|
947
|
+
assert(maxActive === 1, `Writes overlapped: maxActive=${maxActive}`);
|
|
948
|
+
} finally {
|
|
949
|
+
fixture.delayMs = 0;
|
|
950
|
+
if (external) await fixtures.setWriteDelayMs?.(0);
|
|
951
|
+
}
|
|
952
|
+
});
|
|
953
|
+
await run.check("call.transient-retry-new-id", async () => {
|
|
954
|
+
assert(caller, "MCP caller is unavailable");
|
|
955
|
+
fixture.authorization = "timeout";
|
|
956
|
+
const before = await writeCount();
|
|
957
|
+
const params = {
|
|
958
|
+
name: "act_on_page",
|
|
959
|
+
arguments: {},
|
|
960
|
+
_meta: { "webskill/invocationId": "transient-a" }
|
|
961
|
+
};
|
|
962
|
+
const first = await caller("tools/call", params);
|
|
963
|
+
const repeated = await caller("tools/call", params);
|
|
964
|
+
assert(first.result?.isError === true && repeated.result?.isError === true, "Transient authorization failure was not returned");
|
|
965
|
+
assert(await writeCount() === before, "Same ID retry executed a new write");
|
|
966
|
+
fixture.authorization = "approve";
|
|
967
|
+
await caller("tools/call", {
|
|
968
|
+
...params,
|
|
969
|
+
_meta: { "webskill/invocationId": "transient-b" }
|
|
970
|
+
});
|
|
971
|
+
assert(await writeCount() === before + 1, "New ID did not execute a new authorized attempt");
|
|
972
|
+
});
|
|
973
|
+
await run.check("call.authorization-declined", async () => {
|
|
974
|
+
assert(caller, "MCP caller is unavailable");
|
|
975
|
+
fixture.authorization = "deny";
|
|
976
|
+
const before = await writeCount();
|
|
977
|
+
const failure = (await caller("tools/call", {
|
|
978
|
+
name: "act_on_page",
|
|
979
|
+
arguments: {},
|
|
980
|
+
_meta: { "webskill/invocationId": "declined-write" }
|
|
981
|
+
})).result?._meta?.["webskill/error"];
|
|
982
|
+
assert(failure?.code === "PAGE_ACTION_DECLINED" && failure.retryable === false, "Declined authorization did not return a non-retryable error");
|
|
983
|
+
assert(await writeCount() === before, "A declined write executed on the page");
|
|
984
|
+
fixture.authorization = "approve";
|
|
985
|
+
});
|
|
986
|
+
await run.check("call.audit-authorization", async () => {
|
|
987
|
+
assert(caller, "MCP caller is unavailable");
|
|
988
|
+
await caller("tools/call", {
|
|
989
|
+
name: "act_on_page",
|
|
990
|
+
arguments: {},
|
|
991
|
+
_meta: {
|
|
992
|
+
"webskill/invocationId": "audit-write",
|
|
993
|
+
"webskill/context": { originInvocationId: "audit-parent" }
|
|
994
|
+
}
|
|
995
|
+
});
|
|
996
|
+
assert((await fixtures.auditEvents?.() ?? fixture.auditEvents).some((event) => event.type === "controller.call" && event.data["invocationId"] === "audit-write" && event.data["authorization"] === "approved" && JSON.stringify(event.data["context"]) === JSON.stringify({ originInvocationId: "audit-parent" })), "The controller audit did not correlate the invocation, local authorization source and context");
|
|
997
|
+
});
|
|
998
|
+
await run.check("call.admission-order", async () => {
|
|
999
|
+
assert(caller, "MCP caller is unavailable");
|
|
1000
|
+
const before = await writeCount();
|
|
1001
|
+
fixture.auditDelayMs = 30;
|
|
1002
|
+
if (external) await fixtures.setAuditDelayMs?.(30);
|
|
1003
|
+
try {
|
|
1004
|
+
const responses = await Promise.all([caller("tools/call", {
|
|
1005
|
+
name: "act_on_page",
|
|
1006
|
+
arguments: {},
|
|
1007
|
+
_meta: { "webskill/invocationId": "order-a" }
|
|
1008
|
+
}), caller("tools/call", {
|
|
1009
|
+
name: "act_on_page",
|
|
1010
|
+
arguments: {},
|
|
1011
|
+
_meta: { "webskill/invocationId": "order-b" }
|
|
1012
|
+
})]);
|
|
1013
|
+
for (const response of responses) assert(response.result?.isError !== true, `Adjacent write was rejected: ${JSON.stringify(response.error ?? response.result?._meta ?? {})}`);
|
|
1014
|
+
assert(await writeCount() === before + 2, "An asynchronous audit wait reordered adjacent write admission");
|
|
1015
|
+
} finally {
|
|
1016
|
+
fixture.auditDelayMs = 0;
|
|
1017
|
+
if (external) await fixtures.setAuditDelayMs?.(0);
|
|
1018
|
+
}
|
|
1019
|
+
});
|
|
1020
|
+
await run.check("call.watermark-resync", async () => {
|
|
1021
|
+
assert(caller, "MCP caller is unavailable");
|
|
1022
|
+
const before = await writeCount();
|
|
1023
|
+
caller = makeCaller();
|
|
1024
|
+
await caller("initialize", {
|
|
1025
|
+
protocolVersion: "2025-11-25",
|
|
1026
|
+
capabilities: {},
|
|
1027
|
+
clientInfo: {
|
|
1028
|
+
name: "resumed-controller",
|
|
1029
|
+
version: "0"
|
|
1030
|
+
}
|
|
1031
|
+
});
|
|
1032
|
+
await caller("tools/call", {
|
|
1033
|
+
name: "act_on_page",
|
|
1034
|
+
arguments: {},
|
|
1035
|
+
_meta: { "webskill/invocationId": "resumed-write" }
|
|
1036
|
+
});
|
|
1037
|
+
assert(await writeCount() === before + 1, "A restarted controller could not resume from the device watermark");
|
|
1038
|
+
});
|
|
1039
|
+
await run.check("call.forward-window-invalid", async () => {
|
|
1040
|
+
assert(caller, "MCP caller is unavailable");
|
|
1041
|
+
const before = await writeCount();
|
|
1042
|
+
let rejected;
|
|
1043
|
+
try {
|
|
1044
|
+
await caller("tools/call", {
|
|
1045
|
+
name: "act_on_page",
|
|
1046
|
+
arguments: {},
|
|
1047
|
+
_meta: {
|
|
1048
|
+
"webskill/invocationId": "forward-window",
|
|
1049
|
+
"webskill/sequence": Number.MAX_SAFE_INTEGER - 1
|
|
1050
|
+
}
|
|
1051
|
+
});
|
|
1052
|
+
} catch (error) {
|
|
1053
|
+
rejected = error;
|
|
1054
|
+
}
|
|
1055
|
+
assert(rejected instanceof ControllerRpcError && rejected.code === "CONTROLLER_MESSAGE_INVALID", "A forward-window violation was not rejected as an invalid message");
|
|
1056
|
+
assert(await writeCount() === before, "A forward-window violation executed a write");
|
|
1057
|
+
});
|
|
1058
|
+
await run.check("call.audit-incomplete", async () => {
|
|
1059
|
+
assert(caller, "MCP caller is unavailable");
|
|
1060
|
+
const before = await writeCount();
|
|
1061
|
+
fixture.failCompletionAudit = true;
|
|
1062
|
+
if (external) await fixtures.failCompletionAudit?.(true);
|
|
1063
|
+
const params = {
|
|
1064
|
+
name: "act_on_page",
|
|
1065
|
+
arguments: {},
|
|
1066
|
+
_meta: { "webskill/invocationId": "incomplete-audit" }
|
|
1067
|
+
};
|
|
1068
|
+
try {
|
|
1069
|
+
const first = await caller("tools/call", params);
|
|
1070
|
+
const repeated = await caller("tools/call", params);
|
|
1071
|
+
for (const response of [first, repeated]) {
|
|
1072
|
+
const result = response.result;
|
|
1073
|
+
assert(result?.isError !== true && result?._meta?.["webskill/auditIncomplete"] === true, "Post-execution audit failure did not preserve the real result");
|
|
1074
|
+
}
|
|
1075
|
+
assert(await writeCount() === before + 1, "An audit failure caused the write to execute more than once");
|
|
1076
|
+
if (external) {
|
|
1077
|
+
assert(await fixtures.auditWarning?.(), "The host did not expose the incomplete audit warning");
|
|
1078
|
+
fixture.failCompletionAudit = false;
|
|
1079
|
+
await fixtures.failCompletionAudit?.(false);
|
|
1080
|
+
assert((await caller("tools/call", {
|
|
1081
|
+
name: "browser_status",
|
|
1082
|
+
arguments: {},
|
|
1083
|
+
_meta: { "webskill/invocationId": "after-audit-recovery" }
|
|
1084
|
+
})).result, "The audit recovery call did not succeed");
|
|
1085
|
+
assert(await fixtures.auditWarning?.(), "A later successful audit cleared the warning without acknowledgement");
|
|
1086
|
+
}
|
|
1087
|
+
} finally {
|
|
1088
|
+
fixture.failCompletionAudit = false;
|
|
1089
|
+
if (external) await fixtures.failCompletionAudit?.(false);
|
|
1090
|
+
}
|
|
1091
|
+
});
|
|
1092
|
+
if (!external) run.skip("control.human-cancels-authorization", "A real host authorization UI is required to verify takeover cancellation");
|
|
1093
|
+
else await run.check("control.human-cancels-authorization", async () => {
|
|
1094
|
+
assert(caller, "MCP caller is unavailable");
|
|
1095
|
+
const before = await writeCount();
|
|
1096
|
+
fixture.authorization = "takeover";
|
|
1097
|
+
try {
|
|
1098
|
+
const failure = (await caller("tools/call", {
|
|
1099
|
+
name: "act_on_page",
|
|
1100
|
+
arguments: {},
|
|
1101
|
+
_meta: { "webskill/invocationId": "takeover-write" }
|
|
1102
|
+
})).result?._meta?.["webskill/error"];
|
|
1103
|
+
assert(failure?.code === "CONTROLLER_HUMAN_IN_CONTROL", "A pending authorization was not cancelled by human takeover");
|
|
1104
|
+
assert(await writeCount() === before, "A cancelled authorization executed a page write");
|
|
1105
|
+
} finally {
|
|
1106
|
+
fixture.authorization = "approve";
|
|
1107
|
+
await fixtures.setHumanInControl?.(false);
|
|
1108
|
+
}
|
|
1109
|
+
});
|
|
1110
|
+
await run.check("call.write-evicted", async () => {
|
|
1111
|
+
assert(caller, "MCP caller is unavailable");
|
|
1112
|
+
fixture.authorization = "remember";
|
|
1113
|
+
try {
|
|
1114
|
+
for (let index = 0; index <= CONTROLLER_PROTOCOL_LIMITS.idempotencyCacheEntries; index++) {
|
|
1115
|
+
if (external && index % 16 === 0) console.error(`Controller eviction progress: ${index}`);
|
|
1116
|
+
await caller("tools/call", {
|
|
1117
|
+
name: "act_on_page",
|
|
1118
|
+
arguments: {},
|
|
1119
|
+
_meta: { "webskill/invocationId": `evict-${index}` }
|
|
1120
|
+
});
|
|
1121
|
+
if (index === 1) assert((await fixtures.auditEvents?.() ?? fixture.auditEvents).some((event) => event.data["invocationId"] === "evict-1" && event.data["authorization"] === "remembered"), "The eviction scenario did not obtain remembered authorization through the real consent path");
|
|
1122
|
+
}
|
|
1123
|
+
} finally {
|
|
1124
|
+
fixture.authorization = "approve";
|
|
1125
|
+
}
|
|
1126
|
+
const result = await caller("tools/call", {
|
|
1127
|
+
name: "act_on_page",
|
|
1128
|
+
arguments: {},
|
|
1129
|
+
_meta: { "webskill/invocationId": "write-once" }
|
|
1130
|
+
});
|
|
1131
|
+
const error = result.result?._meta?.["webskill/error"];
|
|
1132
|
+
const payload = result.result;
|
|
1133
|
+
assert(error?.code === "CONTROLLER_INVOCATION_DUPLICATE", `An evicted invocation returned an unexpected result: ${JSON.stringify({
|
|
1134
|
+
rpcError: result.error,
|
|
1135
|
+
isError: payload?.isError,
|
|
1136
|
+
meta: payload?._meta,
|
|
1137
|
+
data: payload?.structuredContent
|
|
1138
|
+
})}`);
|
|
1139
|
+
});
|
|
1140
|
+
await run.check("control.human-in-control", async () => {
|
|
1141
|
+
if (external) await fixtures.setHumanInControl?.(true);
|
|
1142
|
+
else device.setHumanInControl(true);
|
|
1143
|
+
assert(caller, "MCP caller is unavailable");
|
|
1144
|
+
const error = ((await caller("tools/call", {
|
|
1145
|
+
name: "perceive_page",
|
|
1146
|
+
arguments: {},
|
|
1147
|
+
_meta: { "webskill/invocationId": "human-read" }
|
|
1148
|
+
})).result?._meta)?.["webskill/error"];
|
|
1149
|
+
assert(error?.code === "CONTROLLER_HUMAN_IN_CONTROL", "Human-control call was not rejected");
|
|
1150
|
+
if (external) await fixtures.setHumanInControl?.(false);
|
|
1151
|
+
else device.setHumanInControl(false);
|
|
1152
|
+
});
|
|
1153
|
+
await run.check("auth.renew-signature", async () => {
|
|
1154
|
+
const previous = external ? controller.grantedDevices[0]?.grant : store.pairing?.grant;
|
|
1155
|
+
assert(previous, "No grant available for renewal");
|
|
1156
|
+
if (!external && store.pairing) store.pairing = {
|
|
1157
|
+
...store.pairing,
|
|
1158
|
+
grantExpiresAt: (/* @__PURE__ */ new Date(Date.now() - 1)).toISOString()
|
|
1159
|
+
};
|
|
1160
|
+
controller.expireGrants();
|
|
1161
|
+
await device.disconnect();
|
|
1162
|
+
await device.connect();
|
|
1163
|
+
const renewed = external ? controller.grantedDevices[0]?.grant : store.pairing?.grant;
|
|
1164
|
+
assert(renewed && renewed !== previous, "Grant was not renewed with a device signature");
|
|
1165
|
+
});
|
|
1166
|
+
await run.check("auth.reconnect-after-disconnect", async () => {
|
|
1167
|
+
const sessionId = epoch();
|
|
1168
|
+
assert(sessionId, "No paired epoch is available for reconnect");
|
|
1169
|
+
if (external) await fixtures.interruptConnection?.();
|
|
1170
|
+
else await deviceTransport(device).close(3001, "Test reconnect");
|
|
1171
|
+
await waitFor(() => controller.connectionCount === 0, "The old device transport did not close");
|
|
1172
|
+
await controller.waitForReady(sessionId);
|
|
1173
|
+
caller = makeCaller();
|
|
1174
|
+
await caller("initialize", {
|
|
1175
|
+
protocolVersion: "2025-11-25",
|
|
1176
|
+
capabilities: {},
|
|
1177
|
+
clientInfo: {
|
|
1178
|
+
name: "reconnect",
|
|
1179
|
+
version: "0"
|
|
1180
|
+
}
|
|
1181
|
+
});
|
|
1182
|
+
assert((await caller("tools/call", {
|
|
1183
|
+
name: "browser_status",
|
|
1184
|
+
arguments: {},
|
|
1185
|
+
_meta: { "webskill/invocationId": "reconnected-call" }
|
|
1186
|
+
})).result, "MCP was not restored on the new transport");
|
|
1187
|
+
if (external) assert(await fixtures.auditWarning?.(), "Automatic reconnect cleared the audit warning");
|
|
1188
|
+
});
|
|
1189
|
+
await run.check("auth.revoke", async () => {
|
|
1190
|
+
await controller.revokeAll();
|
|
1191
|
+
if (external) {
|
|
1192
|
+
await waitFor(async () => !await fixtures.hasPairingRecord?.(), "Revoked external pairing record was not cleared");
|
|
1193
|
+
assert(await fixtures.auditWarning?.(), "Controller-initiated revocation cleared the audit warning");
|
|
1194
|
+
} else {
|
|
1195
|
+
await waitFor(() => !store.pairing, "Revoked pairing record was not cleared");
|
|
1196
|
+
assert(!store.writeState, "Revoked write state was not cleared");
|
|
1197
|
+
}
|
|
1198
|
+
});
|
|
1199
|
+
await run.check("message.oversize-closes", async () => {
|
|
1200
|
+
const socket = new WebSocket(options.url);
|
|
1201
|
+
await new Promise((resolve) => socket.addEventListener("open", () => resolve(), { once: true }));
|
|
1202
|
+
const payload = JSON.stringify({
|
|
1203
|
+
jsonrpc: "2.0",
|
|
1204
|
+
id: "oversize",
|
|
1205
|
+
method: "webskill/ping",
|
|
1206
|
+
params: { value: "x".repeat(17 * 1024 * 1024) }
|
|
1207
|
+
});
|
|
1208
|
+
for (let index = 0; index < 3; index++) socket.send(payload);
|
|
1209
|
+
const code = await new Promise((resolve) => socket.addEventListener("close", (event) => resolve(event.code), { once: true }));
|
|
1210
|
+
assert(code === 1009, `Expected oversize close code 1009, got ${code}`);
|
|
1211
|
+
});
|
|
1212
|
+
await run.check("auth.instance-mismatch", async () => {
|
|
1213
|
+
if (external) await pairDevice(device, controller, fixtures, true, true);
|
|
1214
|
+
await device.disconnect();
|
|
1215
|
+
if (!external) {
|
|
1216
|
+
store.pairing = {
|
|
1217
|
+
controllerUrl: options.url,
|
|
1218
|
+
controllerInstanceId: "previous-controller-instance",
|
|
1219
|
+
controllerName: "Previous Controller",
|
|
1220
|
+
principalDisplayName: "Previous User",
|
|
1221
|
+
sessionId: "previous-session",
|
|
1222
|
+
grant: "not-used-before-instance-check",
|
|
1223
|
+
grantExpiresAt: new Date(Date.now() + 6e4).toISOString(),
|
|
1224
|
+
declaredOrigins: [],
|
|
1225
|
+
pairedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1226
|
+
};
|
|
1227
|
+
device = makeDevice(options.url, store, fixtures, fixture);
|
|
1228
|
+
}
|
|
1229
|
+
await controller.stop();
|
|
1230
|
+
controller = new ReferenceController(options.url, {
|
|
1231
|
+
...options.controllerOptions,
|
|
1232
|
+
instanceId: "different-controller-instance"
|
|
1233
|
+
});
|
|
1234
|
+
await controller.start();
|
|
1235
|
+
let failed = false;
|
|
1236
|
+
try {
|
|
1237
|
+
await device.connect();
|
|
1238
|
+
} catch {
|
|
1239
|
+
failed = true;
|
|
1240
|
+
}
|
|
1241
|
+
assert(failed, "A changed controller instance did not fail closed");
|
|
1242
|
+
});
|
|
1243
|
+
} finally {
|
|
1244
|
+
await device.disconnect().catch(() => void 0);
|
|
1245
|
+
await controller.stop();
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
async function runDeviceRole(run, options) {
|
|
1249
|
+
const fixtures = options.fixtures ?? {};
|
|
1250
|
+
const store = new MemoryStore();
|
|
1251
|
+
const fixture = new ToolFixture();
|
|
1252
|
+
let releaseConfirmation;
|
|
1253
|
+
fixture.confirmationGate = new Promise((resolve) => {
|
|
1254
|
+
releaseConfirmation = resolve;
|
|
1255
|
+
});
|
|
1256
|
+
const device = makeDevice(options.url, store, fixtures, fixture);
|
|
1257
|
+
const freshPairing = async (attack) => {
|
|
1258
|
+
const probeStore = new MemoryStore();
|
|
1259
|
+
const probe = makeDevice(options.url, probeStore, fixtures, new ToolFixture());
|
|
1260
|
+
try {
|
|
1261
|
+
await probe.connect();
|
|
1262
|
+
await attack(await probe.startPairing());
|
|
1263
|
+
return false;
|
|
1264
|
+
} catch {
|
|
1265
|
+
return true;
|
|
1266
|
+
} finally {
|
|
1267
|
+
await probe.disconnect().catch(() => void 0);
|
|
1268
|
+
}
|
|
1269
|
+
};
|
|
1270
|
+
try {
|
|
1271
|
+
let pairing;
|
|
1272
|
+
await run.check("handshake.version-negotiation", async () => {
|
|
1273
|
+
await device.connect();
|
|
1274
|
+
assert(device.state === "unpaired", `Expected unpaired, got ${device.state}`);
|
|
1275
|
+
pairing = await device.startPairing();
|
|
1276
|
+
assert(pairing.code, "Pairing code was not issued");
|
|
1277
|
+
});
|
|
1278
|
+
await run.check("pairing.approval-identity", async () => {
|
|
1279
|
+
assert(pairing, "Pairing was not started");
|
|
1280
|
+
await fixtures.approve?.({
|
|
1281
|
+
code: pairing.code,
|
|
1282
|
+
principal: fixtures.principal ?? "Conformance User"
|
|
1283
|
+
});
|
|
1284
|
+
await waitFor(() => device.state === "awaiting-confirmation", "Approval identity was not delivered");
|
|
1285
|
+
});
|
|
1286
|
+
await run.check("pairing.device-confirmation-required", async () => {
|
|
1287
|
+
assert(device.state === "awaiting-confirmation", "Device confirmation did not remain pending");
|
|
1288
|
+
assert(store.pairing === void 0, "Grant existed before device confirmation");
|
|
1289
|
+
});
|
|
1290
|
+
await run.check("pairing.grant-after-confirmation", async () => {
|
|
1291
|
+
releaseConfirmation?.();
|
|
1292
|
+
await waitFor(() => device.state === "ready", "Grant was not issued after device confirmation");
|
|
1293
|
+
assert(store.pairing?.grant, "Grant was not persisted");
|
|
1294
|
+
});
|
|
1295
|
+
await run.check("pairing.code-bound-to-session", async () => {
|
|
1296
|
+
assert(await freshPairing(async (code) => {
|
|
1297
|
+
await fixtures.approve?.({
|
|
1298
|
+
code: code.code,
|
|
1299
|
+
sessionId: "wrong-session",
|
|
1300
|
+
principal: "Wrong Session User"
|
|
1301
|
+
});
|
|
1302
|
+
}), "A pairing code was accepted across sessions");
|
|
1303
|
+
});
|
|
1304
|
+
await run.check("pairing.code-bound-to-key", async () => {
|
|
1305
|
+
assert(await freshPairing(async (code) => {
|
|
1306
|
+
await fixtures.substituteDeviceKey?.(code.code);
|
|
1307
|
+
}), "A pairing code was accepted with a substituted device key");
|
|
1308
|
+
});
|
|
1309
|
+
await run.check("pairing.attempt-limit", async () => {
|
|
1310
|
+
assert(pairing, "Pairing was not started");
|
|
1311
|
+
let limited = false;
|
|
1312
|
+
try {
|
|
1313
|
+
for (let index = 0; index < 8; index++) await fixtures.attemptInvalidCode?.(pairing.sessionId);
|
|
1314
|
+
} catch {
|
|
1315
|
+
limited = true;
|
|
1316
|
+
}
|
|
1317
|
+
assert(limited, "Repeated invalid pairing attempts were not limited");
|
|
1318
|
+
});
|
|
1319
|
+
await run.check("pairing.code-single-use", async () => {
|
|
1320
|
+
assert(pairing, "Pairing was not started");
|
|
1321
|
+
let rejected = false;
|
|
1322
|
+
try {
|
|
1323
|
+
await fixtures.approve?.({
|
|
1324
|
+
code: pairing.code,
|
|
1325
|
+
principal: "Reuse User"
|
|
1326
|
+
});
|
|
1327
|
+
} catch {
|
|
1328
|
+
rejected = true;
|
|
1329
|
+
}
|
|
1330
|
+
assert(rejected, "A pairing code was accepted twice");
|
|
1331
|
+
});
|
|
1332
|
+
await run.check("pairing.unauthenticated-approval-refused", async () => {
|
|
1333
|
+
assert(await freshPairing(async (code) => {
|
|
1334
|
+
await fixtures.approveUnauthenticated?.({
|
|
1335
|
+
code: code.code,
|
|
1336
|
+
sessionId: code.sessionId
|
|
1337
|
+
});
|
|
1338
|
+
}), "Unauthenticated approval was not rejected");
|
|
1339
|
+
});
|
|
1340
|
+
} finally {
|
|
1341
|
+
releaseConfirmation?.();
|
|
1342
|
+
await device.disconnect().catch(() => void 0);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
function deviceTransport(device) {
|
|
1346
|
+
const transport = device.transport;
|
|
1347
|
+
if (!transport) throw new Error("Device transport is not connected");
|
|
1348
|
+
return transport;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
//#endregion
|
|
1352
|
+
export { InMemoryStore, MemoryArtifactStore, MockLlmClient, MockUiBridge, loadAnthropicConfigFromEnv, loadGoogleConfigFromEnv, loadLlmConfigFromEnv, runControllerConformance };
|