@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,718 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { arch, cpus, platform, release, totalmem, type } from "node:os";
3
+ import { basename } from "node:path";
4
+ import { workspaceCapabilityPolicy } from "./capability-policy.js";
5
+ import { computerOperationContract, publicComputerOperationRegistry } from "./computer-operation-registry.js";
6
+ import { configDiagnostics } from "./config-diagnostics.js";
7
+ import { loadConfig } from "./config.js";
8
+ import { workspaceLinkerVersion } from "./package-metadata.js";
9
+ import { executableCommand, findExecutableCommand, windowsVerbatimArgumentsOption } from "./platform-shell.js";
10
+ import { connectionProfile } from "./profile.js";
11
+ import { screenshotCapability } from "./screenshot.js";
12
+ import { securityDiagnostics } from "./security.js";
13
+ import { serviceStatus } from "./service.js";
14
+ import { listTunnelProcesses, tunnelDiagnostics } from "./tunnels.js";
15
+ import { WorkspaceRegistry } from "./workspaces.js";
16
+ import { exposedMcpTools, mcpToolSurface } from "./mcp-surface.js";
17
+ import { allowedWorkspaceOperations, publicWorkspaceOperationRegistry, workspaceOperationCatalog, workspaceOperationContract, workspaceOperationNames, workspaceOperationSafety } from "./workspace-operations.js";
18
+ export function getLocalPortCapabilities() {
19
+ const config = loadConfig();
20
+ const registry = new WorkspaceRegistry(config);
21
+ const localTools = localToolCapabilities();
22
+ const toolReadiness = localToolReadiness(localTools);
23
+ const configFindings = configDiagnostics(config);
24
+ const rawSecurityFindings = securityDiagnostics(config);
25
+ const tunnel = tunnelDiagnostics({
26
+ localPort: config.port ?? 3939,
27
+ publicBaseUrl: config.publicBaseUrl,
28
+ tunnels: listTunnelProcesses(),
29
+ });
30
+ const securityFindings = securityFindingsForTunnelMode(rawSecurityFindings, tunnel);
31
+ const exposure = exposureReadiness(config, tunnel, securityFindings);
32
+ const startup = startupReadiness(config);
33
+ const releaseStatus = releaseReadiness(config, {
34
+ toolReadiness,
35
+ startup,
36
+ configFindings,
37
+ securityFindings,
38
+ });
39
+ const activeMcpToolSurface = mcpToolSurface();
40
+ return {
41
+ name: "computer-linker",
42
+ version: workspaceLinkerVersion(),
43
+ machineId: config.machineId,
44
+ machineName: config.machineName,
45
+ auth: {
46
+ ownerTokenConfigured: Boolean(config.ownerToken),
47
+ httpModeWithoutOwnerToken: config.ownerToken ? "owner-token-or-oauth" : "loopback-only",
48
+ },
49
+ machine: {
50
+ id: config.machineId,
51
+ hostname: config.machineName,
52
+ os: type(),
53
+ platform: platform(),
54
+ arch: arch(),
55
+ release: release(),
56
+ nodeVersion: process.version,
57
+ shell: process.env.SHELL ? basename(process.env.SHELL) : undefined,
58
+ cpuCount: cpus().length,
59
+ totalMemoryBytes: totalmem(),
60
+ },
61
+ connectionProfile: connectionProfile(config, false),
62
+ workspaces: registry.listDefinedWorkspaces().map((workspace) => ({
63
+ id: workspace.id,
64
+ name: workspace.name,
65
+ path: workspace.path,
66
+ permissions: workspace.permissions,
67
+ policy: workspace.policy ?? {},
68
+ capabilityPolicy: workspaceCapabilityPolicy(workspace.permissions),
69
+ allowedOperations: allowedWorkspaceOperations(workspace.permissions),
70
+ })),
71
+ mcpToolSurface: {
72
+ active: activeMcpToolSurface,
73
+ default: "generic",
74
+ compatibilityOptIn: "COMPUTER_LINKER_MCP_TOOL_SURFACE=compatibility",
75
+ },
76
+ mcpTools: exposedMcpTools(activeMcpToolSurface),
77
+ jsonApi: {
78
+ basePath: "/api/v1",
79
+ unifiedEndpoint: "POST /control",
80
+ actions: ["get_computer_info", "client_setup", "computer_operation", "get_operation_history", "get_capabilities", "doctor", "list_workspaces", "history", "history_insight", "operation_registry", "computer_operation_registry", "workspace_operation_registry", "workspace_operation", "operation"],
81
+ endpoints: ["GET /health", "GET /capabilities", "GET /workspaces", "GET /history", "POST /workspace-operation", "POST /control"],
82
+ },
83
+ clientGuidance: {
84
+ recommendedFlow: ["get_computer_info", "client_setup", "computer_operation", "get_operation_history"],
85
+ preferredControlShape: {
86
+ action: "computer_operation",
87
+ scope: "app",
88
+ op: "file.read",
89
+ target: "README.md",
90
+ input: {},
91
+ options: { maxBytes: 65536 },
92
+ },
93
+ preferredWorkspaceOperationShape: {
94
+ op: "read",
95
+ target: "README.md",
96
+ input: {},
97
+ options: { maxBytes: 65536 },
98
+ },
99
+ examples: [
100
+ { purpose: "Check MCP client setup", control: { action: "client_setup" } },
101
+ { purpose: "Start code orientation", operation: { op: "code.context", target: ".", options: { maxDepth: 2, maxEntries: 100 } } },
102
+ { purpose: "Review recent agent activity", operation: { op: "history.last", options: { maxResults: 50 } } },
103
+ { purpose: "Search code text", operation: { op: "file.search", target: ".", input: { query: "TODO", glob: "*.ts" }, options: { maxResults: 20 } } },
104
+ { purpose: "Read a bounded file", operation: { op: "file.read", target: "README.md", options: { maxBytes: 65536 } } },
105
+ { purpose: "Review current diff", operation: { op: "git.diff", target: ".", options: { maxBytes: 65536 } } },
106
+ { purpose: "Ask Codex for a local review", operation: { op: "codex.run", target: ".", input: { prompt: "Review the current diff and summarize concrete risks." }, options: { timeoutSeconds: 1800 } } },
107
+ ],
108
+ safety: "Use allowedOperations and computerOperationRegistry before selecting write, shell, process, or codex operations. Use operation_registry with contract=workspace only for compatibility clients.",
109
+ },
110
+ toolReadiness,
111
+ configDiagnostics: configDiagnosticsSummary(configFindings),
112
+ releaseReadiness: releaseStatus,
113
+ screenshot: screenshotCapability(),
114
+ exposure,
115
+ startup,
116
+ workspaceOperations: workspaceOperationNames,
117
+ computerOperationContract,
118
+ computerOperationRegistry: publicComputerOperationRegistry(),
119
+ operationContract: workspaceOperationContract,
120
+ operationCatalog: workspaceOperationCatalog,
121
+ operationSafety: workspaceOperationSafety,
122
+ operationRegistry: publicWorkspaceOperationRegistry(),
123
+ capabilityPolicy: {
124
+ version: 1,
125
+ source: "derived-from-workspace-permissions",
126
+ supportedCapabilities: [
127
+ "fs:read",
128
+ "fs:write",
129
+ "search:read",
130
+ "history:read",
131
+ "git:read",
132
+ "git:write",
133
+ "package:run",
134
+ "process:manage",
135
+ "shell:run",
136
+ "codex:readOnly",
137
+ "codex:write",
138
+ "screen:capture",
139
+ "network:false",
140
+ ],
141
+ guidance: "Use workspace.capabilityPolicy and computerOperationRegistry[].capabilities before selecting package, process, Git write, shell, screen, or Codex operations.",
142
+ },
143
+ codingCapabilities: {
144
+ workspaceBoundary: true,
145
+ fileOperations: true,
146
+ fastSearch: toolAvailable(localTools, "rg"),
147
+ agentSkills: true,
148
+ shellExecution: config.workspaces.some((workspace) => workspace.permissions.shell),
149
+ codexExecution: config.workspaces.some((workspace) => workspace.permissions.codex) && toolAvailable(localTools, "codex"),
150
+ gitWorktrees: config.workspaces.some((workspace) => workspace.permissions.write) && toolAvailable(localTools, "git"),
151
+ tunnelExposure: tunnel.tools.some((tool) => tool.available),
152
+ durableHistory: true,
153
+ },
154
+ localTools,
155
+ security: {
156
+ boundaryModel: {
157
+ workspacePathEnforced: "Computer Linker validates file, search, patch, git, and workspace metadata paths before executing those operations.",
158
+ workspaceCwdOnly: "Shell, long-running process, and Codex operations start in the workspace but are not OS filesystem sandboxes.",
159
+ durableAudit: "Operations are recorded without file contents, write payloads, or token values.",
160
+ },
161
+ findings: securityFindings,
162
+ },
163
+ tunnels: tunnel,
164
+ };
165
+ }
166
+ export function getLocalPortDoctor() {
167
+ const config = loadConfig();
168
+ const localTools = localToolCapabilities();
169
+ const toolReadiness = localToolReadiness(localTools);
170
+ const configFindings = configDiagnostics(config);
171
+ const rawSecurityFindings = securityDiagnostics(config);
172
+ const tunnel = tunnelDiagnostics({
173
+ localPort: config.port ?? 3939,
174
+ publicBaseUrl: config.publicBaseUrl,
175
+ tunnels: listTunnelProcesses(),
176
+ });
177
+ const securityFindings = securityFindingsForTunnelMode(rawSecurityFindings, tunnel);
178
+ const exposure = exposureReadiness(config, tunnel, securityFindings);
179
+ const criticalFindings = securityFindings.filter((finding) => finding.severity === "critical");
180
+ const warningFindings = securityFindings.filter((finding) => finding.severity === "warning");
181
+ const service = serviceStatus(config);
182
+ const startup = startupReadiness(config, service);
183
+ const releaseStatus = releaseReadiness(config, {
184
+ toolReadiness,
185
+ startup,
186
+ configFindings,
187
+ securityFindings,
188
+ });
189
+ return {
190
+ machineId: config.machineId,
191
+ machineName: config.machineName,
192
+ machine: {
193
+ id: config.machineId,
194
+ hostname: config.machineName,
195
+ os: type(),
196
+ platform: platform(),
197
+ arch: arch(),
198
+ release: release(),
199
+ nodeVersion: process.version,
200
+ shell: process.env.SHELL ? basename(process.env.SHELL) : undefined,
201
+ cpuCount: cpus().length,
202
+ totalMemoryBytes: totalmem(),
203
+ },
204
+ runtime: {
205
+ host: config.host ?? "127.0.0.1",
206
+ port: config.port ?? 3939,
207
+ localMcpUrl: `http://${config.host ?? "127.0.0.1"}:${config.port ?? 3939}/mcp`,
208
+ localApiUrl: `http://${config.host ?? "127.0.0.1"}:${config.port ?? 3939}/api/v1`,
209
+ startCommands: {
210
+ start: "computer-linker start",
211
+ serveHttp: "computer-linker start",
212
+ serveStdio: "computer-linker serve --transport stdio",
213
+ },
214
+ },
215
+ startup,
216
+ readyForTunnel: exposure.readyForTunnel,
217
+ auth: {
218
+ ownerTokenConfigured: Boolean(config.ownerToken),
219
+ mode: exposure.authMode,
220
+ localOnly: exposure.localOnly,
221
+ },
222
+ exposure: {
223
+ publicMcpUrl: config.publicBaseUrl ? new URL("/mcp", config.publicBaseUrl).href : undefined,
224
+ publicBaseUrl: config.publicBaseUrl,
225
+ publicBaseUrlConfigured: exposure.publicBaseUrlConfigured,
226
+ tunnelToolsAvailable: exposure.tunnelToolsAvailable,
227
+ blockingReasons: exposure.blockingReasons,
228
+ warnings: exposure.warnings,
229
+ },
230
+ workspaces: {
231
+ total: config.workspaces.length,
232
+ writable: config.workspaces.filter((workspace) => workspace.permissions.write).length,
233
+ shellEnabled: config.workspaces.filter((workspace) => workspace.permissions.shell).length,
234
+ codexEnabled: config.workspaces.filter((workspace) => workspace.permissions.codex).length,
235
+ },
236
+ security: {
237
+ criticalCount: criticalFindings.length,
238
+ warningCount: warningFindings.length,
239
+ findings: securityFindings,
240
+ },
241
+ configDiagnostics: configDiagnosticsSummary(configFindings),
242
+ releaseReadiness: releaseStatus,
243
+ tunnels: {
244
+ tools: tunnel.tools,
245
+ commands: tunnel.commands,
246
+ },
247
+ service: {
248
+ platform: service.platform,
249
+ serviceName: service.serviceName,
250
+ label: service.label,
251
+ manifestPath: service.manifestPath,
252
+ manifestExists: service.manifestExists,
253
+ command: service.commandDisplay,
254
+ statusCommands: service.statusCommands,
255
+ profileCommand: `computer-linker service profile --platform ${service.platform}`,
256
+ profileBundleCommand: `computer-linker service profile --platform ${service.platform} --output-dir ./service-profile`,
257
+ installDryRunCommand: `computer-linker service install --dry-run --platform ${service.platform}`,
258
+ uninstallDryRunCommand: `computer-linker service uninstall --dry-run --platform ${service.platform}`,
259
+ notes: service.notes,
260
+ },
261
+ localTools,
262
+ toolReadiness,
263
+ nextActions: doctorNextActions(exposure.blockingReasons, securityFindings, releaseStatus),
264
+ };
265
+ }
266
+ export function startupReadiness(config, service = serviceStatus(config)) {
267
+ const host = config.host ?? "127.0.0.1";
268
+ const port = config.port ?? 3939;
269
+ const localMcpUrl = `http://${host}:${port}/mcp`;
270
+ const localApiUrl = `http://${host}:${port}/api/v1`;
271
+ const profileCommand = `computer-linker service profile --platform ${service.platform}`;
272
+ const profileBundleCommand = `computer-linker service profile --platform ${service.platform} --output-dir ./service-profile`;
273
+ const installDryRunCommand = `computer-linker service install --dry-run --platform ${service.platform}`;
274
+ const uninstallDryRunCommand = `computer-linker service uninstall --dry-run --platform ${service.platform}`;
275
+ const modes = [
276
+ {
277
+ id: "start",
278
+ title: "Local HTTP MCP server",
279
+ command: "computer-linker start",
280
+ persistent: false,
281
+ useWhen: "Foreground local MCP/API server. This does not expose the server to the public internet.",
282
+ },
283
+ {
284
+ id: "tunnel-cloudflare",
285
+ title: "Cloudflare tunnel",
286
+ command: "computer-linker start <workspace-path> --tunnel cloudflare",
287
+ persistent: false,
288
+ useWhen: "First public setup for one folder through cloudflared when a cloud MCP client must connect.",
289
+ },
290
+ {
291
+ id: "tunnel-tailscale",
292
+ title: "Tailscale Funnel",
293
+ command: "computer-linker start <workspace-path> --tunnel tailscale",
294
+ persistent: false,
295
+ useWhen: "First public setup for one folder with a Funnel URL and automatic publicBaseUrl detection.",
296
+ },
297
+ {
298
+ id: "tunnel-openai",
299
+ title: "OpenAI Secure MCP Tunnel",
300
+ command: "computer-linker start <workspace-path> --tunnel openai --tunnel-id tunnel_...",
301
+ persistent: false,
302
+ useWhen: "First public setup for one folder through OpenAI Tunnel mode. ChatGPT uses the tunnel id instead of a public MCP URL.",
303
+ },
304
+ {
305
+ id: "stdio",
306
+ title: "stdio MCP server",
307
+ command: "computer-linker serve --transport stdio",
308
+ persistent: false,
309
+ useWhen: "Local MCP clients that launch Computer Linker as a child process.",
310
+ },
311
+ {
312
+ id: "service",
313
+ title: "OS service",
314
+ command: service.commandDisplay,
315
+ persistent: true,
316
+ useWhen: "Persistent startup on macOS, Linux, or Windows after reviewing the generated service profile.",
317
+ },
318
+ ];
319
+ const checks = [
320
+ {
321
+ id: "node-runtime",
322
+ status: process.version ? "pass" : "fail",
323
+ message: process.version ? "Node runtime is available." : "Node runtime was not detected.",
324
+ detail: process.version,
325
+ },
326
+ {
327
+ id: "local-http",
328
+ status: "pass",
329
+ message: "Local HTTP MCP and API URLs can be derived from config.",
330
+ detail: localMcpUrl,
331
+ },
332
+ {
333
+ id: "service-profile",
334
+ status: "pass",
335
+ message: "A cross-platform service profile can be generated.",
336
+ detail: profileBundleCommand,
337
+ },
338
+ {
339
+ id: "service-installed",
340
+ status: service.manifestExists ? "pass" : "warn",
341
+ message: service.manifestExists
342
+ ? "The service manifest appears to be installed."
343
+ : service.manifestExists === null
344
+ ? "Windows service installation must be checked with the status commands."
345
+ : "No service manifest was found at the expected path.",
346
+ detail: service.manifestPath,
347
+ },
348
+ ];
349
+ const nextActions = [
350
+ "Run `computer-linker start <workspace-path>` for one-command coding setup and startup.",
351
+ "Use `computer-linker start <workspace-path> --read-only` when the client should inspect without editing or running project commands.",
352
+ "Run `computer-linker doctor --fix` to apply safe local config repairs.",
353
+ `Run \`${profileBundleCommand}\` to generate persistent startup files for ${service.platform}.`,
354
+ `Run \`${installDryRunCommand}\` before applying any OS service changes.`,
355
+ ];
356
+ return {
357
+ kind: "computer-linker-startup-readiness",
358
+ schemaVersion: 1,
359
+ ready: checks.every((check) => check.status !== "fail"),
360
+ platform: service.platform,
361
+ recommendedMode: service.manifestExists ? "service" : "start",
362
+ localMcpUrl,
363
+ localApiUrl,
364
+ modes,
365
+ service: {
366
+ platform: service.platform,
367
+ serviceName: service.serviceName,
368
+ label: service.label,
369
+ command: service.commandDisplay,
370
+ manifestPath: service.manifestPath,
371
+ manifestExists: service.manifestExists,
372
+ statusCommands: service.statusCommands,
373
+ profileCommand,
374
+ profileBundleCommand,
375
+ installDryRunCommand,
376
+ uninstallDryRunCommand,
377
+ },
378
+ checks,
379
+ nextActions,
380
+ };
381
+ }
382
+ export function releaseReadiness(config, input) {
383
+ const criticalConfig = input.configFindings.filter((finding) => finding.severity === "critical");
384
+ const warningConfig = input.configFindings.filter((finding) => finding.severity === "warning");
385
+ const criticalSecurity = input.securityFindings.filter((finding) => finding.severity === "critical");
386
+ const warningSecurity = input.securityFindings.filter((finding) => finding.severity === "warning");
387
+ const executionScopesWithoutAllowlist = config.workspaces.filter((workspace) => ((workspace.permissions.shell || workspace.permissions.codex) &&
388
+ !workspace.policy?.allowedCommands?.length));
389
+ const checks = [
390
+ {
391
+ id: "node-runtime",
392
+ status: nodeVersionAtLeast(process.version, 20, 12) ? "pass" : "fail",
393
+ message: nodeVersionAtLeast(process.version, 20, 12)
394
+ ? "Node runtime satisfies the supported engine range."
395
+ : "Node 20.12 or newer is required.",
396
+ detail: process.version,
397
+ },
398
+ {
399
+ id: "config",
400
+ status: criticalConfig.length > 0 ? "fail" : warningConfig.length > 0 ? "warn" : "pass",
401
+ message: criticalConfig.length > 0
402
+ ? "Configuration has blocking issues."
403
+ : warningConfig.length > 0
404
+ ? "Configuration has warnings to review."
405
+ : "Configuration baseline is valid.",
406
+ detail: summarizeFindingIds(criticalConfig.length > 0 ? criticalConfig : warningConfig),
407
+ },
408
+ {
409
+ id: "security",
410
+ status: criticalSecurity.length > 0 ? "fail" : warningSecurity.length > 0 ? "warn" : "pass",
411
+ message: criticalSecurity.length > 0
412
+ ? "Security diagnostics have critical findings."
413
+ : warningSecurity.length > 0
414
+ ? "Security diagnostics have warnings to review."
415
+ : "Security baseline is ready.",
416
+ detail: summarizeFindingIds(criticalSecurity.length > 0 ? criticalSecurity : warningSecurity),
417
+ },
418
+ {
419
+ id: "tool-readiness",
420
+ status: input.toolReadiness.ready ? "pass" : "fail",
421
+ message: input.toolReadiness.ready
422
+ ? "Required local tools are available."
423
+ : "Required local tools are missing.",
424
+ detail: input.toolReadiness.requiredMissing.join(", ") || undefined,
425
+ },
426
+ {
427
+ id: "startup",
428
+ status: input.startup.ready ? "pass" : "fail",
429
+ message: input.startup.ready
430
+ ? "Startup modes can be derived for this machine."
431
+ : "Startup readiness has failing checks.",
432
+ detail: input.startup.checks.filter((check) => check.status === "fail").map((check) => check.id).join(", ") || undefined,
433
+ },
434
+ {
435
+ id: "workspace-scopes",
436
+ status: config.workspaces.length > 0 ? "pass" : "fail",
437
+ message: config.workspaces.length > 0
438
+ ? `${config.workspaces.length} workspace scope(s) configured.`
439
+ : "At least one workspace scope must be configured.",
440
+ },
441
+ {
442
+ id: "command-policy",
443
+ status: executionScopesWithoutAllowlist.length > 0 ? "warn" : "pass",
444
+ message: executionScopesWithoutAllowlist.length > 0
445
+ ? "Some execution-enabled scopes do not have an allowedCommands policy."
446
+ : "Execution-enabled scopes have command allowlists or no local execution is enabled.",
447
+ detail: executionScopesWithoutAllowlist.map((workspace) => workspace.id).join(", ") || undefined,
448
+ },
449
+ ];
450
+ const blockingReasons = checks
451
+ .filter((check) => check.status === "fail")
452
+ .map((check) => `${check.id}: ${check.message}${check.detail ? ` (${check.detail})` : ""}`);
453
+ const warnings = checks
454
+ .filter((check) => check.status === "warn")
455
+ .map((check) => `${check.id}: ${check.message}${check.detail ? ` (${check.detail})` : ""}`);
456
+ const status = blockingReasons.length > 0
457
+ ? "blocked"
458
+ : warnings.length > 0
459
+ ? "needs_attention"
460
+ : "ready";
461
+ return {
462
+ kind: "computer-linker-release-readiness",
463
+ schemaVersion: 1,
464
+ ready: blockingReasons.length === 0,
465
+ status,
466
+ checks,
467
+ blockingReasons,
468
+ warnings,
469
+ recommendedGate: "npm run product:check",
470
+ };
471
+ }
472
+ function summarizeFindingIds(findings) {
473
+ return findings.length ? findings.map((finding) => finding.id).join(", ") : undefined;
474
+ }
475
+ function configDiagnosticsSummary(findings) {
476
+ return {
477
+ criticalCount: findings.filter((finding) => finding.severity === "critical").length,
478
+ warningCount: findings.filter((finding) => finding.severity === "warning").length,
479
+ findings,
480
+ };
481
+ }
482
+ function securityFindingsForTunnelMode(findings, tunnel) {
483
+ if (!openAiSecureTunnelActive(tunnel))
484
+ return findings;
485
+ return findings.filter((finding) => finding.id !== "public-base-url-missing");
486
+ }
487
+ function openAiSecureTunnelActive(tunnel) {
488
+ return tunnel.providers.some((provider) => provider.provider === "openai" && provider.running);
489
+ }
490
+ function nodeVersionAtLeast(version, major, minor) {
491
+ const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(version);
492
+ if (!match)
493
+ return false;
494
+ const actualMajor = Number.parseInt(match[1], 10);
495
+ const actualMinor = Number.parseInt(match[2], 10);
496
+ return actualMajor > major || (actualMajor === major && actualMinor >= minor);
497
+ }
498
+ function doctorNextActions(blockingReasons, securityFindings, release) {
499
+ const actions = new Set();
500
+ if (blockingReasons.some((reason) => reason.includes("ownerToken"))) {
501
+ actions.add("Generate or configure an owner token before exposing this machine.");
502
+ }
503
+ if (blockingReasons.some((reason) => reason.includes("tunnel provider"))) {
504
+ actions.add("Install cloudflared or tailscale, configure a trusted reverse proxy, or use `computer-linker start <workspace-path> --tunnel openai --tunnel-id tunnel_...` with CONTROL_PLANE_API_KEY.");
505
+ }
506
+ if (securityFindings.some((finding) => finding.id === "public-base-url-missing")) {
507
+ actions.add("Set publicBaseUrl to the stable tunnel origin before OAuth client setup.");
508
+ }
509
+ if (securityFindings.some((finding) => finding.id === "public-base-url-not-https")) {
510
+ actions.add("Use an HTTPS publicBaseUrl before connecting cloud-hosted MCP clients.");
511
+ }
512
+ if (securityFindings.some((finding) => finding.id === "shell-broad-access" || finding.id === "codex-broad-access")) {
513
+ actions.add("Review workspaces with shell or codex permission; these are cwd-bound local execution, not filesystem sandboxes.");
514
+ }
515
+ if (release?.status === "blocked") {
516
+ actions.add("Resolve releaseReadiness.blockingReasons before packaging or exposing Computer Linker.");
517
+ }
518
+ if (release?.status === "needs_attention") {
519
+ actions.add("Review releaseReadiness.warnings before tagging an alpha release.");
520
+ }
521
+ if (actions.size === 0) {
522
+ actions.add("No immediate action required.");
523
+ }
524
+ return [...actions];
525
+ }
526
+ function exposureReadiness(config, tunnel, securityFindings) {
527
+ const host = config.host ?? "127.0.0.1";
528
+ const ownerTokenConfigured = Boolean(config.ownerToken);
529
+ const publicBaseUrlConfigured = Boolean(config.publicBaseUrl);
530
+ const tunnelToolsAvailable = tunnel.tools.filter((tool) => tool.available).map((tool) => tool.name);
531
+ const openAiActive = openAiSecureTunnelActive(tunnel);
532
+ const blockingReasons = [];
533
+ if (!ownerTokenConfigured) {
534
+ blockingReasons.push("ownerToken is required before exposing Computer Linker beyond loopback");
535
+ }
536
+ if (tunnelToolsAvailable.length === 0 && !process.env.CONTROL_PLANE_API_KEY && !process.env.OPENAI_API_KEY) {
537
+ blockingReasons.push("install a tunnel provider or configure OpenAI Secure MCP Tunnel credentials");
538
+ }
539
+ if (securityFindings.some((finding) => finding.severity === "critical")) {
540
+ blockingReasons.push("resolve critical security findings before exposure");
541
+ }
542
+ const warnings = securityFindings
543
+ .filter((finding) => finding.severity === "warning")
544
+ .map((finding) => finding.id);
545
+ if (ownerTokenConfigured && !publicBaseUrlConfigured && !openAiActive) {
546
+ warnings.push("publicBaseUrl should be configured to the tunnel origin for OAuth clients");
547
+ }
548
+ return {
549
+ localOnly: isLoopbackHost(host) && !ownerTokenConfigured,
550
+ readyForTunnel: blockingReasons.length === 0,
551
+ authMode: ownerTokenConfigured ? "owner-token-or-oauth" : "loopback-only",
552
+ publicBaseUrlConfigured,
553
+ tunnelToolsAvailable,
554
+ blockingReasons,
555
+ warnings: [...new Set(warnings)],
556
+ };
557
+ }
558
+ function localToolCapabilities() {
559
+ return [
560
+ commandCapability(toolDefinition("codex", ["--version"], "agent", "recommended", ["Codex-assisted plan, review, fix, test, and continuation workflows"], {
561
+ macos: "Install the Codex CLI, then confirm `codex --version` works in the same shell.",
562
+ linux: "Install the Codex CLI, then confirm `codex --version` works in the same shell.",
563
+ windows: "Install the Codex CLI, then confirm `codex --version` works in PowerShell.",
564
+ docs: "https://developers.openai.com/codex",
565
+ })),
566
+ commandCapability(toolDefinition("rg", ["--version"], "search", "recommended", ["Fast universal text search inside exposed workspaces"], {
567
+ macos: "brew install ripgrep",
568
+ linux: "sudo apt-get install ripgrep",
569
+ windows: "winget install BurntSushi.ripgrep.MSVC",
570
+ docs: "https://github.com/BurntSushi/ripgrep",
571
+ })),
572
+ commandCapability(toolDefinition("git", ["--version"], "vcs", "recommended", ["Repository status, diffs, logs, commits, and worktrees"], {
573
+ macos: "brew install git",
574
+ linux: "sudo apt-get install git",
575
+ windows: "winget install Git.Git",
576
+ docs: "https://git-scm.com/downloads",
577
+ })),
578
+ commandCapability(toolDefinition("node", ["--version"], "runtime", "required", ["Computer Linker runtime"], {
579
+ macos: "Install Node.js 20.12+ from nodejs.org, nvm, fnm, or Homebrew.",
580
+ linux: "Install Node.js 20.12+ from nodejs.org, nvm, fnm, or your distribution package manager.",
581
+ windows: "Install Node.js 20.12+ from nodejs.org or winget.",
582
+ docs: "https://nodejs.org/",
583
+ })),
584
+ commandCapability(toolDefinition("npm", ["--version"], "package-manager", "recommended", ["Install and run Node package scripts"], {
585
+ macos: "Install Node.js; npm is bundled with standard Node installers.",
586
+ linux: "Install Node.js; npm is bundled with standard Node installers.",
587
+ windows: "Install Node.js; npm is bundled with standard Node installers.",
588
+ docs: "https://nodejs.org/",
589
+ })),
590
+ commandCapability(toolDefinition("pnpm", ["--version"], "package-manager", "optional", ["Run pnpm-based package workflows"], {
591
+ macos: "corepack enable pnpm",
592
+ linux: "corepack enable pnpm",
593
+ windows: "corepack enable pnpm",
594
+ docs: "https://pnpm.io/installation",
595
+ })),
596
+ commandCapability(toolDefinition("bun", ["--version"], "runtime", "optional", ["Run Bun-based workspaces when configured"], {
597
+ macos: "Install Bun from bun.sh.",
598
+ linux: "Install Bun from bun.sh.",
599
+ windows: "Install Bun from bun.sh.",
600
+ docs: "https://bun.sh/",
601
+ })),
602
+ commandCapability(toolDefinition("python3", ["--version"], "runtime", "optional", ["Run Python project tooling from workspace commands"], {
603
+ macos: "Install Python 3 from python.org or Homebrew.",
604
+ linux: "Install python3 with your distribution package manager.",
605
+ windows: "Install Python 3 from python.org or Microsoft Store.",
606
+ docs: "https://www.python.org/downloads/",
607
+ })),
608
+ commandCapability(toolDefinition("bash", ["--version"], "shell", "optional", ["POSIX-style shell commands on macOS/Linux and Git Bash environments"], {
609
+ linux: "Install bash with your distribution package manager.",
610
+ windows: "Install Git for Windows if Bash compatibility is needed.",
611
+ })),
612
+ commandCapability(toolDefinition("zsh", ["--version"], "shell", "optional", ["zsh shell commands on macOS/Linux"], {
613
+ macos: "zsh is bundled with modern macOS.",
614
+ linux: "Install zsh with your distribution package manager.",
615
+ })),
616
+ commandCapability(toolDefinition("cmd", ["/c", "ver"], "shell", "optional", ["Windows cmd.exe shell command support"], {
617
+ windows: "cmd.exe is bundled with Windows.",
618
+ })),
619
+ commandCapability(toolDefinition("powershell", ["-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"], "shell", "optional", ["Windows PowerShell command support"], {
620
+ windows: "Windows PowerShell is bundled with Windows.",
621
+ })),
622
+ commandCapability(toolDefinition("pwsh", ["-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"], "shell", "optional", ["PowerShell 7 command support"], {
623
+ macos: "brew install --cask powershell",
624
+ linux: "Install PowerShell from Microsoft packages for your distribution.",
625
+ windows: "winget install Microsoft.PowerShell",
626
+ docs: "https://learn.microsoft.com/powershell/",
627
+ })),
628
+ commandCapability(toolDefinition("docker", ["--version"], "container", "optional", ["Containerized project tooling when invoked by workspace commands"], {
629
+ macos: "Install Docker Desktop.",
630
+ linux: "Install Docker Engine or Docker Desktop.",
631
+ windows: "Install Docker Desktop.",
632
+ docs: "https://docs.docker.com/get-docker/",
633
+ })),
634
+ ];
635
+ }
636
+ function localToolReadiness(tools) {
637
+ const requiredMissing = tools
638
+ .filter((tool) => tool.importance === "required" && !tool.available)
639
+ .map((tool) => tool.name);
640
+ const recommendedMissing = tools
641
+ .filter((tool) => tool.importance === "recommended" && !tool.available)
642
+ .map((tool) => tool.name);
643
+ return {
644
+ kind: "computer-linker-tool-readiness",
645
+ schemaVersion: 1,
646
+ ready: requiredMissing.length === 0,
647
+ requiredMissing,
648
+ recommendedMissing,
649
+ availableRecommended: tools
650
+ .filter((tool) => tool.importance === "recommended" && tool.available)
651
+ .map((tool) => tool.name),
652
+ installHints: tools
653
+ .filter((tool) => !tool.available && tool.importance !== "optional")
654
+ .map((tool) => ({
655
+ name: tool.name,
656
+ importance: tool.importance,
657
+ usedFor: tool.usedFor,
658
+ install: tool.install,
659
+ })),
660
+ };
661
+ }
662
+ function toolDefinition(name, args, category, importance, usedFor, install) {
663
+ return { name, args, category, importance, usedFor, install };
664
+ }
665
+ function commandCapability(definition) {
666
+ const path = findExecutableCommand(definition.name);
667
+ const base = {
668
+ name: definition.name,
669
+ category: definition.category,
670
+ importance: definition.importance,
671
+ path,
672
+ usedFor: definition.usedFor,
673
+ install: definition.install,
674
+ };
675
+ if (!path) {
676
+ return {
677
+ ...base,
678
+ available: false,
679
+ error: "Not found on PATH.",
680
+ };
681
+ }
682
+ if (definition.importance === "optional") {
683
+ return {
684
+ ...base,
685
+ available: true,
686
+ };
687
+ }
688
+ const command = executableCommand(definition.name, definition.args);
689
+ try {
690
+ const output = execFileSync(command.command, command.args, {
691
+ encoding: "utf8",
692
+ timeout: 1500,
693
+ stdio: ["ignore", "pipe", "pipe"],
694
+ ...windowsVerbatimArgumentsOption(command),
695
+ }).trim();
696
+ return {
697
+ ...base,
698
+ available: true,
699
+ version: firstLine(output),
700
+ };
701
+ }
702
+ catch (error) {
703
+ return {
704
+ ...base,
705
+ available: false,
706
+ error: error instanceof Error ? error.message : String(error),
707
+ };
708
+ }
709
+ }
710
+ function toolAvailable(tools, name) {
711
+ return tools.some((tool) => tool.name === name && tool.available);
712
+ }
713
+ function firstLine(value) {
714
+ return value.split(/\r?\n/)[0] ?? value;
715
+ }
716
+ function isLoopbackHost(host) {
717
+ return host === "127.0.0.1" || host === "localhost" || host === "::1";
718
+ }