@easonwumac/computer-linker 0.1.2

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.
Files changed (82) hide show
  1. package/CHANGELOG.md +230 -0
  2. package/LICENSE +21 -0
  3. package/README.md +539 -0
  4. package/SECURITY.md +48 -0
  5. package/dist/api.d.ts +2 -0
  6. package/dist/api.js +360 -0
  7. package/dist/audit.d.ts +70 -0
  8. package/dist/audit.js +102 -0
  9. package/dist/capabilities.d.ts +98 -0
  10. package/dist/capabilities.js +718 -0
  11. package/dist/capability-policy.d.ts +22 -0
  12. package/dist/capability-policy.js +103 -0
  13. package/dist/chatgpt.d.ts +167 -0
  14. package/dist/chatgpt.js +561 -0
  15. package/dist/cli.d.ts +2 -0
  16. package/dist/cli.js +4621 -0
  17. package/dist/client-smoke.d.ts +44 -0
  18. package/dist/client-smoke.js +639 -0
  19. package/dist/client.d.ts +217 -0
  20. package/dist/client.js +357 -0
  21. package/dist/codex-runs.d.ts +35 -0
  22. package/dist/codex-runs.js +66 -0
  23. package/dist/computer-contract.d.ts +33 -0
  24. package/dist/computer-contract.js +384 -0
  25. package/dist/computer-operation-registry.d.ts +45 -0
  26. package/dist/computer-operation-registry.js +179 -0
  27. package/dist/config-diagnostics.d.ts +11 -0
  28. package/dist/config-diagnostics.js +185 -0
  29. package/dist/config.d.ts +10 -0
  30. package/dist/config.js +69 -0
  31. package/dist/history-insights.d.ts +132 -0
  32. package/dist/history-insights.js +457 -0
  33. package/dist/http-auth.d.ts +3 -0
  34. package/dist/http-auth.js +15 -0
  35. package/dist/mcp-surface.d.ts +5 -0
  36. package/dist/mcp-surface.js +25 -0
  37. package/dist/oauth-provider.d.ts +52 -0
  38. package/dist/oauth-provider.js +325 -0
  39. package/dist/package-metadata.d.ts +7 -0
  40. package/dist/package-metadata.js +24 -0
  41. package/dist/permissions.d.ts +43 -0
  42. package/dist/permissions.js +150 -0
  43. package/dist/platform-shell.d.ts +28 -0
  44. package/dist/platform-shell.js +124 -0
  45. package/dist/processes.d.ts +50 -0
  46. package/dist/processes.js +178 -0
  47. package/dist/profile.d.ts +159 -0
  48. package/dist/profile.js +416 -0
  49. package/dist/screenshot.d.ts +47 -0
  50. package/dist/screenshot.js +302 -0
  51. package/dist/search.d.ts +34 -0
  52. package/dist/search.js +340 -0
  53. package/dist/security.d.ts +10 -0
  54. package/dist/security.js +108 -0
  55. package/dist/sensitive-files.d.ts +4 -0
  56. package/dist/sensitive-files.js +96 -0
  57. package/dist/server.d.ts +9 -0
  58. package/dist/server.js +713 -0
  59. package/dist/service.d.ts +125 -0
  60. package/dist/service.js +486 -0
  61. package/dist/sessions.d.ts +26 -0
  62. package/dist/sessions.js +34 -0
  63. package/dist/tunnels.d.ts +161 -0
  64. package/dist/tunnels.js +1243 -0
  65. package/dist/workspace-operations.d.ts +170 -0
  66. package/dist/workspace-operations.js +3219 -0
  67. package/dist/workspaces.d.ts +61 -0
  68. package/dist/workspaces.js +353 -0
  69. package/docs/agent-instructions.md +65 -0
  70. package/docs/alpha-evidence.example.json +54 -0
  71. package/docs/api-compatibility.md +56 -0
  72. package/docs/architecture.md +561 -0
  73. package/docs/chatgpt-setup.md +397 -0
  74. package/docs/client-recipes.md +98 -0
  75. package/docs/client-sdk.md +163 -0
  76. package/docs/computer-operation-v1.schema.json +143 -0
  77. package/docs/manual-test-plan.md +322 -0
  78. package/docs/product-spec.md +911 -0
  79. package/docs/release-checklist.md +285 -0
  80. package/docs/service-mode.md +99 -0
  81. package/examples/minimal-mcp-client.mjs +114 -0
  82. package/package.json +87 -0
@@ -0,0 +1,44 @@
1
+ import type { LocalPortConfig } from "./permissions.js";
2
+ export type WorkspaceLinkerClientSmokeStatus = "pass" | "warn" | "fail";
3
+ export type WorkspaceLinkerClientSmokeCheckId = "base-url" | "auth" | "healthz" | "api-capabilities" | "api-computer-info" | "api-read-only-operation" | "mcp-initialize" | "mcp-list-tools" | "mcp-get-computer-info" | "mcp-read-only-operation" | "mcp-operation-history";
4
+ export interface WorkspaceLinkerClientSmokeCheck {
5
+ id: WorkspaceLinkerClientSmokeCheckId;
6
+ status: WorkspaceLinkerClientSmokeStatus;
7
+ message: string;
8
+ url?: string;
9
+ statusCode?: number;
10
+ detail?: string;
11
+ durationMs?: number;
12
+ }
13
+ export interface WorkspaceLinkerClientSmokeOptions {
14
+ timeoutMs?: number;
15
+ includeSecret?: boolean;
16
+ }
17
+ export interface WorkspaceLinkerMcpClientSmokeOptions extends WorkspaceLinkerClientSmokeOptions {
18
+ url?: string;
19
+ token?: string;
20
+ allowHttp?: boolean;
21
+ clientName?: string;
22
+ fetchImpl?: typeof fetch;
23
+ }
24
+ export interface WorkspaceLinkerSdkClientSmokeOptions extends WorkspaceLinkerClientSmokeOptions {
25
+ apiBaseUrl: URL;
26
+ ownerToken?: string;
27
+ fetchImpl: typeof fetch;
28
+ }
29
+ export interface WorkspaceLinkerClientSmokeReport {
30
+ kind: "computer-linker-client-smoke";
31
+ schemaVersion: 1;
32
+ ready: boolean;
33
+ baseUrl: string | null;
34
+ apiBaseUrl: string | null;
35
+ mcpServerUrl: string | null;
36
+ authHeader: string;
37
+ checks: WorkspaceLinkerClientSmokeCheck[];
38
+ blockingReasons: string[];
39
+ warnings: string[];
40
+ nextActions: string[];
41
+ }
42
+ export declare function runWorkspaceLinkerMcpClientSmoke(config: Pick<LocalPortConfig, "host" | "port" | "ownerToken" | "publicBaseUrl">, options?: WorkspaceLinkerMcpClientSmokeOptions): Promise<WorkspaceLinkerClientSmokeReport>;
43
+ export declare function runWorkspaceLinkerSdkClientSmoke(options: WorkspaceLinkerSdkClientSmokeOptions): Promise<WorkspaceLinkerClientSmokeReport>;
44
+ export declare function formatWorkspaceLinkerClientSmoke(report: WorkspaceLinkerClientSmokeReport): string;
@@ -0,0 +1,639 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3
+ export async function runWorkspaceLinkerMcpClientSmoke(config, options = {}) {
4
+ const checks = [];
5
+ const fetchImpl = options.fetchImpl ?? fetch;
6
+ const timeoutMs = options.timeoutMs ?? 8000;
7
+ const token = options.token ?? config.ownerToken;
8
+ let baseUrl;
9
+ try {
10
+ baseUrl = smokeBaseUrl(config, options.url);
11
+ }
12
+ catch (error) {
13
+ checks.push({
14
+ id: "base-url",
15
+ status: "fail",
16
+ message: "Smoke URL must be a valid URL.",
17
+ detail: error instanceof Error ? error.message : String(error),
18
+ });
19
+ }
20
+ if (!baseUrl && !checks.some((check) => check.id === "base-url")) {
21
+ checks.push({
22
+ id: "base-url",
23
+ status: "fail",
24
+ message: "No URL was provided and publicBaseUrl is not configured.",
25
+ detail: "Use --url https://... or run config set-public-url first.",
26
+ });
27
+ }
28
+ else if (baseUrl && !options.allowHttp && baseUrl.protocol !== "https:") {
29
+ checks.push({
30
+ id: "base-url",
31
+ status: "fail",
32
+ message: "MCP client smoke URL must use https://.",
33
+ url: baseUrl.href,
34
+ detail: "Use --allow-http only for local loopback testing.",
35
+ });
36
+ }
37
+ else if (baseUrl) {
38
+ checks.push({
39
+ id: "base-url",
40
+ status: options.allowHttp && baseUrl.protocol === "http:" ? "warn" : "pass",
41
+ message: options.allowHttp && baseUrl.protocol === "http:"
42
+ ? "HTTP URL accepted for local smoke testing only."
43
+ : "Base URL is usable for MCP client smoke testing.",
44
+ url: baseUrl.href,
45
+ });
46
+ }
47
+ if (!token) {
48
+ checks.push({
49
+ id: "auth",
50
+ status: "fail",
51
+ message: "ownerToken is required for authenticated API and MCP smoke tests.",
52
+ });
53
+ }
54
+ else {
55
+ checks.push({
56
+ id: "auth",
57
+ status: "pass",
58
+ message: "Bearer token is available.",
59
+ });
60
+ }
61
+ const localHttpSmoke = Boolean(baseUrl && options.allowHttp && baseUrl.protocol === "http:");
62
+ if (baseUrl && !checks.some((check) => check.status === "fail" && (check.id === "base-url" || check.id === "auth"))) {
63
+ if (localHttpSmoke) {
64
+ const apiBaseUrl = new URL("api/v1/", baseUrl);
65
+ checks.push(await smokeGet(fetchImpl, new URL("healthz", baseUrl), "healthz", undefined, timeoutMs));
66
+ checks.push(await smokeGet(fetchImpl, new URL("capabilities", apiBaseUrl), "api-capabilities", token, timeoutMs));
67
+ const computerInfo = await smokeComputerInfo(fetchImpl, apiBaseUrl, token, timeoutMs);
68
+ checks.push(computerInfo.check);
69
+ checks.push(await smokeReadOnlyOperation(fetchImpl, apiBaseUrl, token, computerInfo.data, timeoutMs));
70
+ }
71
+ checks.push(...await smokeMcpToolFlow(fetchImpl, new URL("mcp", baseUrl), token, timeoutMs, options.clientName ?? "computer-linker-client-smoke"));
72
+ }
73
+ return finalizeSmokeReport({
74
+ baseUrl: baseUrl?.href ?? null,
75
+ apiBaseUrl: baseUrl && localHttpSmoke ? new URL("api/v1/", baseUrl).href : null,
76
+ mcpServerUrl: baseUrl ? new URL("mcp", baseUrl).href : null,
77
+ authHeader: token && options.includeSecret ? `Authorization: Bearer ${token}` : token ? "Authorization: Bearer <ownerToken>" : "none",
78
+ checks,
79
+ publicMode: true,
80
+ });
81
+ }
82
+ export async function runWorkspaceLinkerSdkClientSmoke(options) {
83
+ const timeoutMs = options.timeoutMs ?? 8000;
84
+ const serviceRoot = serviceRootUrlFromApiBaseUrl(options.apiBaseUrl);
85
+ const mcpServerUrl = new URL("mcp", serviceRoot);
86
+ const checks = [
87
+ await smokeGet(options.fetchImpl, new URL("healthz", serviceRoot), "healthz", undefined, timeoutMs),
88
+ await smokeGet(options.fetchImpl, new URL("capabilities", options.apiBaseUrl), "api-capabilities", options.ownerToken, timeoutMs),
89
+ ];
90
+ const computerInfo = await smokeComputerInfo(options.fetchImpl, options.apiBaseUrl, options.ownerToken, timeoutMs);
91
+ checks.push(computerInfo.check);
92
+ checks.push(await smokeReadOnlyOperation(options.fetchImpl, options.apiBaseUrl, options.ownerToken, computerInfo.data, timeoutMs));
93
+ checks.push(...await smokeMcpToolFlow(options.fetchImpl, mcpServerUrl, options.ownerToken, timeoutMs, "computer-linker-sdk-smoke"));
94
+ return finalizeSmokeReport({
95
+ baseUrl: serviceRoot.href,
96
+ apiBaseUrl: options.apiBaseUrl.href,
97
+ mcpServerUrl: mcpServerUrl.href,
98
+ authHeader: options.ownerToken
99
+ ? options.includeSecret ? `Authorization: Bearer ${options.ownerToken}` : "Authorization: Bearer <ownerToken>"
100
+ : "none",
101
+ checks,
102
+ publicMode: false,
103
+ });
104
+ }
105
+ export function formatWorkspaceLinkerClientSmoke(report) {
106
+ return [
107
+ "Computer Linker MCP client smoke",
108
+ `ready: ${report.ready ? "yes" : "no"}`,
109
+ `baseUrl: ${report.baseUrl ?? "not configured"}`,
110
+ `mcpServerUrl: ${report.mcpServerUrl ?? "not configured"}`,
111
+ `authHeader: ${report.authHeader}`,
112
+ "checks:",
113
+ ...report.checks.map((check) => ` [${check.status}] ${check.id}: ${check.message}${check.statusCode ? ` (${check.statusCode})` : ""}${check.durationMs !== undefined ? ` ${check.durationMs}ms` : ""}`),
114
+ "next actions:",
115
+ ...report.nextActions.map((action) => ` - ${action}`),
116
+ ].join("\n") + "\n";
117
+ }
118
+ function finalizeSmokeReport(input) {
119
+ const blockingReasons = input.checks
120
+ .filter((check) => check.status === "fail")
121
+ .map((check) => `${check.id}: ${check.message}`);
122
+ const warnings = input.checks
123
+ .filter((check) => check.status === "warn")
124
+ .map((check) => `${check.id}: ${check.message}`);
125
+ return {
126
+ kind: "computer-linker-client-smoke",
127
+ schemaVersion: 1,
128
+ ready: blockingReasons.length === 0,
129
+ baseUrl: input.baseUrl,
130
+ apiBaseUrl: input.apiBaseUrl,
131
+ mcpServerUrl: input.mcpServerUrl,
132
+ authHeader: input.authHeader,
133
+ checks: input.checks,
134
+ blockingReasons,
135
+ warnings,
136
+ nextActions: smokeNextActions(blockingReasons, warnings, input.publicMode),
137
+ };
138
+ }
139
+ function smokeBaseUrl(config, value) {
140
+ const raw = value ?? config.publicBaseUrl;
141
+ if (!raw)
142
+ return undefined;
143
+ const parsed = new URL(raw);
144
+ return new URL(parsed.origin);
145
+ }
146
+ function serviceRootUrlFromApiBaseUrl(apiBaseUrl) {
147
+ const root = new URL(apiBaseUrl.href);
148
+ const path = root.pathname.replace(/\/+$/, "");
149
+ const apiSuffix = "/api/v1";
150
+ if (path.endsWith(apiSuffix)) {
151
+ const rootPath = path.slice(0, -apiSuffix.length) || "/";
152
+ root.pathname = rootPath.endsWith("/") ? rootPath : `${rootPath}/`;
153
+ }
154
+ else {
155
+ root.pathname = "/";
156
+ }
157
+ root.search = "";
158
+ root.hash = "";
159
+ return root;
160
+ }
161
+ async function smokeGet(fetchImpl, url, id, token, timeoutMs) {
162
+ const started = Date.now();
163
+ try {
164
+ const response = await fetchWithTimeout(fetchImpl, url, {
165
+ method: "GET",
166
+ headers: token ? { authorization: `Bearer ${token}` } : undefined,
167
+ }, timeoutMs);
168
+ const text = await response.text();
169
+ const apiPayloadInvalid = id === "api-capabilities" && !jsonApiPayloadSucceeded(text);
170
+ if (!response.ok || apiPayloadInvalid) {
171
+ return {
172
+ id,
173
+ status: "fail",
174
+ message: apiPayloadInvalid
175
+ ? `${url.pathname} did not return a valid Computer Linker JSON API response.`
176
+ : `${url.pathname} returned HTTP ${response.status}.`,
177
+ url: url.href,
178
+ statusCode: response.status,
179
+ detail: textPreview(text),
180
+ durationMs: Date.now() - started,
181
+ };
182
+ }
183
+ return {
184
+ id,
185
+ status: "pass",
186
+ message: `${url.pathname} responded successfully.`,
187
+ url: url.href,
188
+ statusCode: response.status,
189
+ durationMs: Date.now() - started,
190
+ };
191
+ }
192
+ catch (error) {
193
+ return {
194
+ id,
195
+ status: "fail",
196
+ message: `${url.pathname} request failed.`,
197
+ url: url.href,
198
+ detail: error instanceof Error ? error.message : String(error),
199
+ durationMs: Date.now() - started,
200
+ };
201
+ }
202
+ }
203
+ async function smokeComputerInfo(fetchImpl, apiBaseUrl, token, timeoutMs) {
204
+ const started = Date.now();
205
+ const url = new URL("control", apiBaseUrl);
206
+ try {
207
+ const response = await fetchWithTimeout(fetchImpl, url, {
208
+ method: "POST",
209
+ headers: {
210
+ "content-type": "application/json",
211
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
212
+ },
213
+ body: JSON.stringify({ action: "get_computer_info" }),
214
+ }, timeoutMs);
215
+ const text = await response.text();
216
+ const payload = parseJsonApiData(text);
217
+ const data = payload.data;
218
+ if (!response.ok || !payload.ok || data?.kind !== "computer-linker-computer-info") {
219
+ return {
220
+ check: {
221
+ id: "api-computer-info",
222
+ status: "fail",
223
+ message: "/api/v1/control get_computer_info did not return a valid computer info response.",
224
+ url: url.href,
225
+ statusCode: response.status,
226
+ detail: textPreview(text),
227
+ durationMs: Date.now() - started,
228
+ },
229
+ };
230
+ }
231
+ return {
232
+ data,
233
+ check: {
234
+ id: "api-computer-info",
235
+ status: "pass",
236
+ message: "/api/v1/control get_computer_info returned computer identity and scopes.",
237
+ url: url.href,
238
+ statusCode: response.status,
239
+ durationMs: Date.now() - started,
240
+ },
241
+ };
242
+ }
243
+ catch (error) {
244
+ return {
245
+ check: {
246
+ id: "api-computer-info",
247
+ status: "fail",
248
+ message: "/api/v1/control get_computer_info request failed.",
249
+ url: url.href,
250
+ detail: error instanceof Error ? error.message : String(error),
251
+ durationMs: Date.now() - started,
252
+ },
253
+ };
254
+ }
255
+ }
256
+ async function smokeReadOnlyOperation(fetchImpl, apiBaseUrl, token, computerInfo, timeoutMs) {
257
+ const started = Date.now();
258
+ const url = new URL("control", apiBaseUrl);
259
+ const scope = readableScope(computerInfo);
260
+ if (!scope) {
261
+ return {
262
+ id: "api-read-only-operation",
263
+ status: "fail",
264
+ message: "No readable scope is available for a read-only operation smoke test.",
265
+ url: url.href,
266
+ durationMs: Date.now() - started,
267
+ };
268
+ }
269
+ try {
270
+ const response = await fetchWithTimeout(fetchImpl, url, {
271
+ method: "POST",
272
+ headers: {
273
+ "content-type": "application/json",
274
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
275
+ },
276
+ body: JSON.stringify({
277
+ action: "computer_operation",
278
+ scope,
279
+ op: "file.list",
280
+ target: ".",
281
+ input: {},
282
+ options: { maxEntries: 1 },
283
+ }),
284
+ }, timeoutMs);
285
+ const text = await response.text();
286
+ const payload = parseJsonApiData(text);
287
+ const operation = payload.data && typeof payload.data === "object"
288
+ ? payload.data
289
+ : undefined;
290
+ if (!response.ok || !payload.ok || operation?.ok !== true) {
291
+ return {
292
+ id: "api-read-only-operation",
293
+ status: "fail",
294
+ message: `Read-only computer_operation file.list failed for scope ${scope}.`,
295
+ url: url.href,
296
+ statusCode: response.status,
297
+ detail: typeof operation?.error?.message === "string" ? operation.error.message : textPreview(text),
298
+ durationMs: Date.now() - started,
299
+ };
300
+ }
301
+ return {
302
+ id: "api-read-only-operation",
303
+ status: "pass",
304
+ message: `Read-only computer_operation file.list succeeded for scope ${scope}.`,
305
+ url: url.href,
306
+ statusCode: response.status,
307
+ durationMs: Date.now() - started,
308
+ };
309
+ }
310
+ catch (error) {
311
+ return {
312
+ id: "api-read-only-operation",
313
+ status: "fail",
314
+ message: `Read-only computer_operation file.list request failed for scope ${scope}.`,
315
+ url: url.href,
316
+ detail: error instanceof Error ? error.message : String(error),
317
+ durationMs: Date.now() - started,
318
+ };
319
+ }
320
+ }
321
+ async function smokeMcpToolFlow(fetchImpl, url, token, timeoutMs, clientName) {
322
+ const started = Date.now();
323
+ const checks = [];
324
+ const client = new Client({ name: clientName, version: "0.1.0" });
325
+ const transport = new StreamableHTTPClientTransport(url, {
326
+ requestInit: {
327
+ headers: token ? { authorization: `Bearer ${token}` } : undefined,
328
+ },
329
+ fetch: (input, init) => fetchWithTimeout(fetchImpl, input, init ?? {}, timeoutMs),
330
+ reconnectionOptions: {
331
+ maxReconnectionDelay: timeoutMs,
332
+ initialReconnectionDelay: timeoutMs,
333
+ reconnectionDelayGrowFactor: 1,
334
+ maxRetries: 0,
335
+ },
336
+ });
337
+ try {
338
+ await withSmokeTimeout(client.connect(transport), timeoutMs, "MCP initialize timed out.");
339
+ checks.push({
340
+ id: "mcp-initialize",
341
+ status: "pass",
342
+ message: "/mcp initialize succeeded through the MCP SDK transport.",
343
+ url: url.href,
344
+ durationMs: Date.now() - started,
345
+ });
346
+ }
347
+ catch (error) {
348
+ try {
349
+ await closeMcpSmokeClient(client, transport);
350
+ }
351
+ catch {
352
+ // Close is best-effort after a failed initialize.
353
+ }
354
+ return [
355
+ {
356
+ id: "mcp-initialize",
357
+ status: "fail",
358
+ message: "/mcp initialize failed through the MCP SDK transport.",
359
+ url: url.href,
360
+ detail: error instanceof Error ? error.message : String(error),
361
+ durationMs: Date.now() - started,
362
+ },
363
+ ];
364
+ }
365
+ try {
366
+ const tools = await withSmokeTimeout(client.listTools(), timeoutMs, "MCP tools/list timed out.");
367
+ const toolNames = tools.tools.map((tool) => tool.name);
368
+ const missingTools = ["get_computer_info", "computer_operation", "get_operation_history"].filter((tool) => !toolNames.includes(tool));
369
+ if (missingTools.length > 0) {
370
+ checks.push({
371
+ id: "mcp-list-tools",
372
+ status: "fail",
373
+ message: `MCP tools/list is missing required tools: ${missingTools.join(", ")}.`,
374
+ url: url.href,
375
+ });
376
+ }
377
+ else {
378
+ checks.push({
379
+ id: "mcp-list-tools",
380
+ status: "pass",
381
+ message: "MCP tools/list returned the generic Computer Linker tool surface.",
382
+ url: url.href,
383
+ });
384
+ }
385
+ }
386
+ catch (error) {
387
+ checks.push({
388
+ id: "mcp-list-tools",
389
+ status: "fail",
390
+ message: "MCP tools/list request failed.",
391
+ url: url.href,
392
+ detail: error instanceof Error ? error.message : String(error),
393
+ });
394
+ await closeMcpSmokeClient(client, transport);
395
+ return checks;
396
+ }
397
+ let computerInfo;
398
+ try {
399
+ const result = await withSmokeTimeout(client.callTool({ name: "get_computer_info", arguments: {} }), timeoutMs, "MCP get_computer_info timed out.");
400
+ const data = mcpToolData(result);
401
+ if (data?.kind !== "computer-linker-computer-info") {
402
+ checks.push({
403
+ id: "mcp-get-computer-info",
404
+ status: "fail",
405
+ message: "MCP get_computer_info did not return computer identity and scopes.",
406
+ url: url.href,
407
+ detail: textPreview(JSON.stringify(data ?? null)),
408
+ });
409
+ }
410
+ else {
411
+ computerInfo = data;
412
+ checks.push({
413
+ id: "mcp-get-computer-info",
414
+ status: "pass",
415
+ message: "MCP get_computer_info returned computer identity and scopes.",
416
+ url: url.href,
417
+ });
418
+ }
419
+ }
420
+ catch (error) {
421
+ checks.push({
422
+ id: "mcp-get-computer-info",
423
+ status: "fail",
424
+ message: "MCP get_computer_info request failed.",
425
+ url: url.href,
426
+ detail: error instanceof Error ? error.message : String(error),
427
+ });
428
+ await closeMcpSmokeClient(client, transport);
429
+ return checks;
430
+ }
431
+ const scope = readableScope(computerInfo);
432
+ if (!scope) {
433
+ checks.push({
434
+ id: "mcp-read-only-operation",
435
+ status: "fail",
436
+ message: "No readable scope is available for an MCP computer_operation smoke test.",
437
+ url: url.href,
438
+ });
439
+ await closeMcpSmokeClient(client, transport);
440
+ return checks;
441
+ }
442
+ try {
443
+ try {
444
+ const result = await withSmokeTimeout(client.callTool({
445
+ name: "computer_operation",
446
+ arguments: {
447
+ scope,
448
+ op: "file.list",
449
+ target: ".",
450
+ input: {},
451
+ options: { maxEntries: 1 },
452
+ },
453
+ }), timeoutMs, "MCP computer_operation timed out.");
454
+ const operation = mcpToolData(result);
455
+ if (operation?.ok !== true) {
456
+ checks.push({
457
+ id: "mcp-read-only-operation",
458
+ status: "fail",
459
+ message: `MCP computer_operation file.list failed for scope ${scope}.`,
460
+ url: url.href,
461
+ detail: typeof operation?.error?.message === "string" ? operation.error.message : textPreview(JSON.stringify(operation ?? null)),
462
+ });
463
+ }
464
+ else {
465
+ checks.push({
466
+ id: "mcp-read-only-operation",
467
+ status: "pass",
468
+ message: `MCP computer_operation file.list succeeded for scope ${scope}.`,
469
+ url: url.href,
470
+ });
471
+ }
472
+ }
473
+ catch (error) {
474
+ checks.push({
475
+ id: "mcp-read-only-operation",
476
+ status: "fail",
477
+ message: `MCP computer_operation file.list request failed for scope ${scope}.`,
478
+ url: url.href,
479
+ detail: error instanceof Error ? error.message : String(error),
480
+ });
481
+ }
482
+ try {
483
+ const result = await withSmokeTimeout(client.callTool({
484
+ name: "get_operation_history",
485
+ arguments: {
486
+ view: "last",
487
+ limit: 5,
488
+ },
489
+ }), timeoutMs, "MCP get_operation_history timed out.");
490
+ const history = mcpToolData(result);
491
+ if (!history || history.view !== "last" || (!("last" in history) && !("summary" in history))) {
492
+ checks.push({
493
+ id: "mcp-operation-history",
494
+ status: "fail",
495
+ message: "MCP get_operation_history did not return a last-history response.",
496
+ url: url.href,
497
+ detail: textPreview(JSON.stringify(history ?? null)),
498
+ });
499
+ }
500
+ else {
501
+ checks.push({
502
+ id: "mcp-operation-history",
503
+ status: "pass",
504
+ message: "MCP get_operation_history returned redacted recent history.",
505
+ url: url.href,
506
+ });
507
+ }
508
+ }
509
+ catch (error) {
510
+ checks.push({
511
+ id: "mcp-operation-history",
512
+ status: "fail",
513
+ message: "MCP get_operation_history request failed.",
514
+ url: url.href,
515
+ detail: error instanceof Error ? error.message : String(error),
516
+ });
517
+ }
518
+ }
519
+ finally {
520
+ await closeMcpSmokeClient(client, transport);
521
+ }
522
+ return checks;
523
+ }
524
+ async function closeMcpSmokeClient(client, transport) {
525
+ try {
526
+ if (transport.sessionId)
527
+ await transport.terminateSession();
528
+ }
529
+ catch {
530
+ // Explicit session termination is best-effort; close still releases local resources.
531
+ }
532
+ try {
533
+ await client.close();
534
+ }
535
+ catch {
536
+ // Session cleanup is best-effort; smoke checks already captured failures.
537
+ }
538
+ }
539
+ async function withSmokeTimeout(promise, timeoutMs, message) {
540
+ let timer;
541
+ try {
542
+ return await Promise.race([
543
+ promise,
544
+ new Promise((_, reject) => {
545
+ timer = setTimeout(() => reject(new Error(message)), timeoutMs);
546
+ }),
547
+ ]);
548
+ }
549
+ finally {
550
+ if (timer)
551
+ clearTimeout(timer);
552
+ }
553
+ }
554
+ async function fetchWithTimeout(fetchImpl, input, init, timeoutMs) {
555
+ const controller = new AbortController();
556
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
557
+ try {
558
+ return await fetchImpl(input, { ...init, signal: controller.signal });
559
+ }
560
+ finally {
561
+ clearTimeout(timer);
562
+ }
563
+ }
564
+ function mcpToolData(result) {
565
+ const structuredContent = result.structuredContent;
566
+ if (structuredContent && typeof structuredContent === "object" && !Array.isArray(structuredContent)) {
567
+ return structuredContent;
568
+ }
569
+ const content = result.content;
570
+ const text = content?.find((item) => item.type === "text" && typeof item.text === "string")?.text;
571
+ if (!text)
572
+ return undefined;
573
+ try {
574
+ return JSON.parse(text);
575
+ }
576
+ catch {
577
+ return undefined;
578
+ }
579
+ }
580
+ function jsonApiPayloadSucceeded(text) {
581
+ const payload = parseJsonApiData(text);
582
+ return payload.ok && payload.data !== undefined;
583
+ }
584
+ function parseJsonApiData(text) {
585
+ try {
586
+ const payload = JSON.parse(text);
587
+ return { ok: payload.ok === true, data: payload.data };
588
+ }
589
+ catch {
590
+ return { ok: false };
591
+ }
592
+ }
593
+ function textPreview(value) {
594
+ return value.replace(/\s+/g, " ").trim().slice(0, 240);
595
+ }
596
+ function smokeNextActions(blockingReasons, warnings, publicMode) {
597
+ const actions = new Set();
598
+ if (blockingReasons.some((reason) => reason.includes("base-url"))) {
599
+ actions.add("Set publicBaseUrl or rerun with `--url https://...`; use `--allow-http` only for local testing.");
600
+ }
601
+ if (blockingReasons.some((reason) => reason.includes("auth"))) {
602
+ actions.add("Run `computer-linker init` or pass `--token <ownerToken>` for the smoke test.");
603
+ }
604
+ if (blockingReasons.some((reason) => reason.includes("api-capabilities"))) {
605
+ actions.add(publicMode
606
+ ? "Confirm the local API is reachable during loopback smoke testing and that the owner token is correct."
607
+ : "Verify the SDK baseUrl points to the local or trusted-private /api/v1 endpoint and that ownerToken is correct.");
608
+ }
609
+ if (blockingReasons.some((reason) => reason.includes("api-computer-info"))) {
610
+ actions.add(publicMode
611
+ ? "Confirm authenticated JSON API access works during loopback smoke testing."
612
+ : "Confirm the SDK ownerToken can call get_computer_info on /api/v1/control.");
613
+ }
614
+ if (blockingReasons.some((reason) => reason.includes("api-read-only-operation"))) {
615
+ actions.add("Configure at least one readable scope and confirm computer_operation file.list is allowed.");
616
+ }
617
+ if (blockingReasons.some((reason) => reason.includes("healthz") || reason.includes("mcp-initialize"))) {
618
+ actions.add(publicMode
619
+ ? "Confirm the HTTP server is running and the tunnel routes to this machine."
620
+ : "Confirm the Computer Linker HTTP server is running and reachable at the same service origin.");
621
+ }
622
+ if (blockingReasons.some((reason) => reason.includes("mcp-list-tools") || reason.includes("mcp-get-computer-info") || reason.includes("mcp-read-only-operation") || reason.includes("mcp-operation-history"))) {
623
+ actions.add("Confirm the MCP server exposes the generic tool surface and that at least one readable workspace scope is configured.");
624
+ }
625
+ if (warnings.some((warning) => warning.includes("HTTP URL"))) {
626
+ actions.add("Use an HTTPS tunnel URL before configuring a cloud MCP client.");
627
+ }
628
+ if (actions.size === 0) {
629
+ actions.add("Use the MCP server URL and Authorization bearer token in your MCP client setup.");
630
+ }
631
+ return [...actions];
632
+ }
633
+ function readableScope(computerInfo) {
634
+ return computerInfo?.scopes?.find((scope) => (typeof scope.id === "string" &&
635
+ (scope.permissions?.read === true ||
636
+ scope.allowedOperations?.includes("list_details") ||
637
+ scope.allowedOperations?.includes("read") ||
638
+ scope.allowedOperations?.includes("search_text"))))?.id;
639
+ }