@hellolynk-ai/codex-plugin 0.0.0-stage → 0.2.100

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 (123) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/LICENSE +92 -0
  3. package/README.md +101 -2
  4. package/bin/hellolynk-client-setup.mjs +2748 -0
  5. package/bin/hellolynk-mcp-config-shape.mjs +23 -0
  6. package/hellolynk-client-pack-manifest.json +619 -0
  7. package/hellolynk-client-pack-pin.json +4 -0
  8. package/package.json +58 -4
  9. package/plugins/hellolynk-codex/.app.json +3 -0
  10. package/plugins/hellolynk-codex/.codex-plugin/plugin.json +38 -0
  11. package/plugins/hellolynk-codex/.mcp.json +19 -0
  12. package/plugins/hellolynk-codex/assets/hellolynk-mark.png +0 -0
  13. package/plugins/hellolynk-codex/package.json +11 -0
  14. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-account.mjs +180 -0
  15. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-core.mjs +770 -0
  16. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-profile.mjs +191 -0
  17. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-auth.mjs +347 -0
  18. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-protected-store.mjs +2721 -0
  19. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-local-process-lock.mjs +157 -0
  20. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-neutral-host-installation-store.mjs +765 -0
  21. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-platform-credential-store.mjs +773 -0
  22. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-runtime-safety.cjs +471 -0
  23. package/plugins/hellolynk-codex/runtime/bootstrap/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
  24. package/plugins/hellolynk-codex/runtime/connect-activate.mjs +837 -0
  25. package/plugins/hellolynk-codex/runtime/core/hellolynk-agent-configurator.mjs +701 -0
  26. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-account.mjs +180 -0
  27. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-core.mjs +770 -0
  28. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-profile.mjs +191 -0
  29. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-entry.mjs +31 -0
  30. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-runtime.mjs +600 -0
  31. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update.mjs +453 -0
  32. package/plugins/hellolynk-codex/runtime/core/hellolynk-contact-request-e2ee.mjs +867 -0
  33. package/plugins/hellolynk-codex/runtime/core/hellolynk-device-enrollment.mjs +435 -0
  34. package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee-client.mjs +1964 -0
  35. package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee.mjs +1207 -0
  36. package/plugins/hellolynk-codex/runtime/core/hellolynk-host-auth.mjs +347 -0
  37. package/plugins/hellolynk-codex/runtime/core/hellolynk-host-protected-store.mjs +2721 -0
  38. package/plugins/hellolynk-codex/runtime/core/hellolynk-host-transport-client.mjs +946 -0
  39. package/plugins/hellolynk-codex/runtime/core/hellolynk-host.mjs +2374 -0
  40. package/plugins/hellolynk-codex/runtime/core/hellolynk-human-confirmation-ledger.mjs +256 -0
  41. package/plugins/hellolynk-codex/runtime/core/hellolynk-human-relationship-controller.mjs +1234 -0
  42. package/plugins/hellolynk-codex/runtime/core/hellolynk-inbox-read-state.mjs +11 -0
  43. package/plugins/hellolynk-codex/runtime/core/hellolynk-indexed-message-store.mjs +1558 -0
  44. package/plugins/hellolynk-codex/runtime/core/hellolynk-instance-conversation.mjs +185 -0
  45. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contact-requests.mjs +1733 -0
  46. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contacts.mjs +1228 -0
  47. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-migration.mjs +766 -0
  48. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-protection.mjs +747 -0
  49. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-identity-trust.mjs +274 -0
  50. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-messaging.mjs +3043 -0
  51. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-peer-transport.mjs +103 -0
  52. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-process-lock.mjs +157 -0
  53. package/plugins/hellolynk-codex/runtime/core/hellolynk-model-inbox.mjs +230 -0
  54. package/plugins/hellolynk-codex/runtime/core/hellolynk-model-notices.cjs +60 -0
  55. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-addressing-tools.cjs +231 -0
  56. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-conversations.mjs +280 -0
  57. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-human-confirmation-broker.mjs +1036 -0
  58. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-inbox-runtime.mjs +553 -0
  59. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-notice.mjs +50 -0
  60. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-reply-routes.mjs +96 -0
  61. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-worker-lease.mjs +84 -0
  62. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-assembly.mjs +955 -0
  63. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-installation-store.mjs +765 -0
  64. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-launcher.mjs +665 -0
  65. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-process.mjs +4374 -0
  66. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-store-adapters.mjs +548 -0
  67. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-workbench-read-model.mjs +1365 -0
  68. package/plugins/hellolynk-codex/runtime/core/hellolynk-platform-credential-store.mjs +773 -0
  69. package/plugins/hellolynk-codex/runtime/core/hellolynk-relay-client-cli.mjs +232 -0
  70. package/plugins/hellolynk-codex/runtime/core/hellolynk-release-retention.mjs +277 -0
  71. package/plugins/hellolynk-codex/runtime/core/hellolynk-runtime-safety.cjs +471 -0
  72. package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-action-grants.mjs +457 -0
  73. package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-connection.mjs +234 -0
  74. package/plugins/hellolynk-codex/runtime/neutral-host-bootstrap.mjs +64 -0
  75. package/plugins/hellolynk-codex/runtime/neutral-host-release-entry.mjs +70 -0
  76. package/plugins/hellolynk-codex/runtime/neutral-host-release-loader.mjs +156 -0
  77. package/plugins/hellolynk-codex/runtime/neutral-host-release-manifest.json +358 -0
  78. package/plugins/hellolynk-codex/runtime/neutral-host-release-pin.json +4 -0
  79. package/plugins/hellolynk-codex/runtime/platform/windows/configure-agent.ps1 +281 -0
  80. package/plugins/hellolynk-codex/runtime/platform/windows/credential-store.ps1 +844 -0
  81. package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
  82. package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-human-confirmation-ledger.ps1 +155 -0
  83. package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-local-data-key.ps1 +300 -0
  84. package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-native-human-confirmation-broker.ps1 +216 -0
  85. package/plugins/hellolynk-codex/runtime/workbench/candidate-bootstrap.mjs +54 -0
  86. package/plugins/hellolynk-codex/runtime/workbench/contract.mjs +1314 -0
  87. package/plugins/hellolynk-codex/runtime/workbench/hellolynk-workbench-build.json +215 -0
  88. package/plugins/hellolynk-codex/runtime/workbench/host-capabilities.mjs +80 -0
  89. package/plugins/hellolynk-codex/runtime/workbench/i18n.mjs +1127 -0
  90. package/plugins/hellolynk-codex/runtime/workbench/loopback-surface.mjs +740 -0
  91. package/plugins/hellolynk-codex/runtime/workbench/mcp-app-bundle.mjs +97 -0
  92. package/plugins/hellolynk-codex/runtime/workbench/service.mjs +485 -0
  93. package/plugins/hellolynk-codex/runtime/workbench/state.mjs +421 -0
  94. package/plugins/hellolynk-codex/runtime/workbench/transports.mjs +171 -0
  95. package/plugins/hellolynk-codex/runtime/workbench/ui/app.mjs +6120 -0
  96. package/plugins/hellolynk-codex/runtime/workbench/ui/connection-form.mjs +155 -0
  97. package/plugins/hellolynk-codex/runtime/workbench/ui/hellolynk-mark.png +0 -0
  98. package/plugins/hellolynk-codex/runtime/workbench/ui/http-transport.mjs +247 -0
  99. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/arrow-left.svg +3 -0
  100. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/close.svg +3 -0
  101. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/contacts.svg +3 -0
  102. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/inbox.svg +3 -0
  103. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/refresh.svg +3 -0
  104. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/search.svg +3 -0
  105. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/security.svg +3 -0
  106. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/settings.svg +3 -0
  107. package/plugins/hellolynk-codex/runtime/workbench/ui/index.html +139 -0
  108. package/plugins/hellolynk-codex/runtime/workbench/ui/loopback-entry.mjs +26 -0
  109. package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-client.mjs +151 -0
  110. package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-entry.mjs +452 -0
  111. package/plugins/hellolynk-codex/runtime/workbench/ui/styles.css +686 -0
  112. package/plugins/hellolynk-codex/server/codex-neutral-production.mjs +520 -0
  113. package/plugins/hellolynk-codex/server/mcp-proxy.mjs +306 -0
  114. package/plugins/hellolynk-codex/server/mcp-server.mjs +1193 -0
  115. package/plugins/hellolynk-codex/server/native-conversation-directory.mjs +33 -0
  116. package/plugins/hellolynk-codex/server/native-reply-routes.mjs +32 -0
  117. package/plugins/hellolynk-codex/server/native-task-client.mjs +382 -0
  118. package/plugins/hellolynk-codex/server/native-task-runtime.mjs +8 -0
  119. package/plugins/hellolynk-codex/server/neutral-host-adapter.mjs +649 -0
  120. package/plugins/hellolynk-codex/server/workbench-runtime.mjs +180 -0
  121. package/plugins/hellolynk-codex/skills/hellolynk-open/SKILL.md +11 -0
  122. package/plugins/hellolynk-codex/skills/hellolynk-open/agents/openai.yaml +4 -0
  123. package/tools/hellolynk-activate-neutral-host-clients.mjs +837 -0
@@ -0,0 +1,740 @@
1
+ import crypto from "node:crypto";
2
+ import http from "node:http";
3
+ import { readFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ import {
8
+ validateWorkbenchCommand,
9
+ validateWorkbenchCommandResult,
10
+ validateWorkbenchReadRequest,
11
+ validateWorkbenchSnapshot,
12
+ } from "./contract.mjs";
13
+
14
+ const LOOPBACK = "127.0.0.1";
15
+ const NAVIGATION_WINDOW_MS = 120_000;
16
+ const SESSION_TTL_MS = 15 * 60_000;
17
+ const EVENT_HEARTBEAT_MS = 15_000;
18
+ const ACTION_GRANT = /^[A-Za-z0-9][A-Za-z0-9._:-]{31,511}$/;
19
+ const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
20
+
21
+ const STATIC_ASSETS = Object.freeze({
22
+ "/assets/app.mjs": ["app.mjs", "text/javascript; charset=utf-8"],
23
+ "/assets/connection-form.mjs": ["connection-form.mjs", "text/javascript; charset=utf-8"],
24
+ "/assets/hellolynk-mark.png": ["hellolynk-mark.png", "image/png"],
25
+ "/assets/http-transport.mjs": ["http-transport.mjs", "text/javascript; charset=utf-8"],
26
+ "/assets/icons/arrow-left.svg": ["icons/arrow-left.svg", "image/svg+xml"],
27
+ "/assets/icons/close.svg": ["icons/close.svg", "image/svg+xml"],
28
+ "/assets/icons/contacts.svg": ["icons/contacts.svg", "image/svg+xml"],
29
+ "/assets/icons/inbox.svg": ["icons/inbox.svg", "image/svg+xml"],
30
+ "/assets/icons/refresh.svg": ["icons/refresh.svg", "image/svg+xml"],
31
+ "/assets/icons/search.svg": ["icons/search.svg", "image/svg+xml"],
32
+ "/assets/icons/security.svg": ["icons/security.svg", "image/svg+xml"],
33
+ "/assets/icons/settings.svg": ["icons/settings.svg", "image/svg+xml"],
34
+ "/assets/styles.css": ["styles.css", "text/css; charset=utf-8"],
35
+ "/modules/contract.mjs": ["../contract.mjs", "text/javascript; charset=utf-8"],
36
+ "/modules/state.mjs": ["../state.mjs", "text/javascript; charset=utf-8"],
37
+ "/modules/i18n.mjs": ["../i18n.mjs", "text/javascript; charset=utf-8"],
38
+ });
39
+
40
+ export class HelloLynkWorkbenchSurfaceError extends Error {
41
+ constructor(code, status = 500) {
42
+ super(code);
43
+ this.name = "HelloLynkWorkbenchSurfaceError";
44
+ this.code = code;
45
+ this.status = status;
46
+ }
47
+ }
48
+
49
+ // This surface is deliberately transport-only. The caller injects the one
50
+ // Neutral Host service; this module never opens a store, messenger or receiver.
51
+ export function createWorkbenchLoopbackSurface(options = {}) {
52
+ const adapter = validateAdapter(options.adapter);
53
+ const moduleDir = options.workbenchRoot ? path.resolve(options.workbenchRoot) : MODULE_DIR;
54
+ const uiRoot = path.join(moduleDir, "ui");
55
+ const pagePath = normalizePagePath(options.pagePath);
56
+ const syntheticPreview = options.explicitSyntheticPreview === true;
57
+ const syntheticMarker = options.explicitSyntheticMarker === true || syntheticPreview;
58
+ const allowSameSiteTopLevelNavigation = options.allowSameSiteTopLevelNavigation === true;
59
+ const trustedParentOrigin = normalizeTrustedParentOrigin(options.trustedParentOrigin);
60
+ const deferInitialNavigationArm = options.deferInitialNavigationArm === true;
61
+ const expectedSource = syntheticPreview
62
+ ? { mode: "synthetic-preview", transport: "synthetic-memory" }
63
+ : { mode: "neutral-host", transport: "loopback-http" };
64
+ const servedFiles = Object.freeze({
65
+ [pagePath]: ["index.html", "text/html; charset=utf-8"],
66
+ ...STATIC_ASSETS,
67
+ "/assets/loopback-entry.mjs": [
68
+ syntheticPreview ? "synthetic-preview-entry.mjs" : "loopback-entry.mjs",
69
+ "text/javascript; charset=utf-8",
70
+ ],
71
+ });
72
+ const port = integer(options.port, 0, 0, 65_535, "workbench_surface_port_invalid");
73
+ const navigationWindowMs = integer(
74
+ options.navigationWindowMs,
75
+ NAVIGATION_WINDOW_MS,
76
+ 10_000,
77
+ NAVIGATION_WINDOW_MS,
78
+ "workbench_surface_navigation_window_invalid",
79
+ );
80
+ const sessions = new Map();
81
+ const eventClients = new Set();
82
+ let navigationArmedUntil = 0;
83
+ let origin = null;
84
+ let sessionCookieName = null;
85
+ let closed = false;
86
+ let staticFiles = null;
87
+ let ipv6Server = null;
88
+ const changePollMs = integer(options.changePollMs, 1_000, 10, 60_000, "workbench_surface_change_poll_invalid");
89
+ let changeCursor = 0;
90
+ let changeGeneration = null;
91
+ let changeTimer = null;
92
+ let changeBusy = false;
93
+
94
+ async function handleRequest(request, response) {
95
+ try {
96
+ const url = parseUrl(request, origin);
97
+ if (url.pathname.startsWith("/api/v1/")) return await api(request, response, url);
98
+ if (request.method === "GET" && servedFiles[url.pathname]) return await staticFile(request, response, url.pathname);
99
+ if (request.method === "GET" && url.pathname === "/favicon.ico") return empty(response, 204);
100
+ return json(response, 404, { error: "workbench_surface_route_not_found" });
101
+ } catch (error) {
102
+ if (!response.headersSent) json(response, Number(error?.status) || 500, { error: publicCode(error) });
103
+ else response.destroy();
104
+ }
105
+ }
106
+ const server = http.createServer(handleRequest);
107
+
108
+ async function api(request, response, url) {
109
+ assertHost(request, origin);
110
+ const session = requireSession(request);
111
+ if (request.method === "GET") assertReadOrigin(request);
112
+ else assertSameOrigin(request);
113
+
114
+ if (request.method === "GET" && url.pathname === "/api/v1/account") {
115
+ return json(response, 200, options.readAccountState ? await options.readAccountState() : { revision: null, canDisconnect: false });
116
+ }
117
+ const accountRevision = typeof options.readAccountState === "function"
118
+ ? (await options.readAccountState()).revision : null;
119
+ const assertAccount = async () => {
120
+ if (accountRevision !== null && (
121
+ request.headers["x-hellolynk-account-revision"] !== accountRevision
122
+ || (await options.readAccountState()).revision !== accountRevision)) throw fail("workbench_account_changed", 409);
123
+ };
124
+ const mutateAccount = (operation) => options.runForAccount && accountRevision !== null
125
+ ? options.runForAccount(accountRevision, operation) : operation();
126
+ // Reads also carry the page's account revision. Existing test/injected
127
+ // surfaces without account management retain their original contract.
128
+ if (["/api/v1/snapshot", "/api/v1/connect", "/api/v1/action-grants", "/api/v1/command"].includes(url.pathname)) await assertAccount();
129
+ if (request.method === "POST" && url.pathname === "/api/v1/disconnect") {
130
+ assertCsrf(request, session);
131
+ if (typeof options.disconnectAccount !== "function") throw fail("workbench_disconnect_unavailable", 503);
132
+ let input;
133
+ try { input = exactPrivateEnvelope(JSON.parse(await readRequestBody(request)), ["revision"]); }
134
+ catch { throw fail("workbench_disconnect_input_invalid", 400); }
135
+ return json(response, 200, await options.disconnectAccount(input));
136
+ }
137
+
138
+ if (request.method === "GET" && url.pathname === "/api/v1/csrf") {
139
+ return json(response, 200, { csrf: session.csrf });
140
+ }
141
+ if (request.method === "GET" && url.pathname === "/api/v1/session") {
142
+ if (url.search) throw fail("workbench_surface_read_request_invalid", 400);
143
+ renewSession(request, response, session);
144
+ return json(response, 200, { ok: true });
145
+ }
146
+ if (request.method === "GET" && url.pathname === "/api/v1/snapshot") {
147
+ const allowed = new Set(["threadId", "threadCursor", "messageCursor", "contactRequestCursor", "query", "filter", "historyBefore"]);
148
+ for (const key of url.searchParams.keys()) {
149
+ if (!allowed.has(key) || url.searchParams.getAll(key).length !== 1) {
150
+ throw fail("workbench_surface_read_request_invalid", 400);
151
+ }
152
+ }
153
+ let input;
154
+ try {
155
+ const rawInput = Object.fromEntries(url.searchParams);
156
+ if (Object.hasOwn(rawInput, "contactRequestCursor") && rawInput.contactRequestCursor === "") {
157
+ rawInput.contactRequestCursor = null;
158
+ }
159
+ if (Object.hasOwn(rawInput, "historyBefore") && rawInput.historyBefore === "") {
160
+ rawInput.historyBefore = null;
161
+ }
162
+ input = validateWorkbenchReadRequest(rawInput);
163
+ } catch {
164
+ throw fail("workbench_surface_read_request_invalid", 400);
165
+ }
166
+ const snapshot = validateWorkbenchSnapshot(await adapter.readSnapshot(input));
167
+ await assertAccount();
168
+ if (snapshot.source.mode !== expectedSource.mode || snapshot.source.transport !== expectedSource.transport) {
169
+ throw fail("workbench_surface_source_mismatch", 502);
170
+ }
171
+ renewSession(request, response, session);
172
+ return json(response, 200, { snapshot });
173
+ }
174
+ if (request.method === "GET" && url.pathname === "/api/v1/events") {
175
+ return openEventStream(request, response, session);
176
+ }
177
+ if (request.method === "POST" && url.pathname === "/api/v1/connect") {
178
+ assertCsrf(request, session);
179
+ if (typeof options.connectAccount !== "function") throw fail("workbench_connect_unavailable", 503);
180
+ let input;
181
+ try { input = exactPrivateEnvelope(JSON.parse(await readRequestBody(request)), ["hellolynkId", "instanceName"]); }
182
+ catch { throw fail("workbench_connect_input_invalid", 400); }
183
+ const result = await options.connectAccount(input, accountRevision ?? undefined);
184
+ renewSession(request, response, session);
185
+ return json(response, 200, result);
186
+ }
187
+ if (request.method === "POST" && url.pathname === "/api/v1/open-account") {
188
+ assertCsrf(request, session);
189
+ if (typeof options.openAccount !== "function") throw fail("workbench_portal_unavailable", 503);
190
+ let input;
191
+ try { input = exactPrivateEnvelope(JSON.parse(await readRequestBody(request)), ["url"]); }
192
+ catch { throw fail("workbench_portal_target_invalid", 400); }
193
+ const result = await options.openAccount(input);
194
+ renewSession(request, response, session);
195
+ return json(response, 200, result);
196
+ }
197
+ if (request.method === "POST" && url.pathname === "/api/v1/action-grants") {
198
+ assertCsrf(request, session);
199
+ if (typeof adapter.issueActionGrant !== "function" || typeof adapter.executeCommand !== "function") {
200
+ throw fail("workbench_surface_commands_not_enabled", 503);
201
+ }
202
+ let command;
203
+ try {
204
+ const body = exactPrivateEnvelope(JSON.parse(await readRequestBody(request)), ["command"]);
205
+ command = validateWorkbenchCommand(body.command);
206
+ if (command.command === "message.send-status") throw fail("workbench_surface_action_grant_invalid", 400);
207
+ } catch (error) {
208
+ if (error instanceof HelloLynkWorkbenchSurfaceError) throw error;
209
+ throw fail("workbench_surface_command_invalid", 400);
210
+ }
211
+ const issued = await mutateAccount(() => adapter.issueActionGrant(command));
212
+ const grantEnvelope = exactPrivateEnvelope(issued, ["actionGrant"]);
213
+ return json(response, 200, { actionGrant: privateActionGrant(grantEnvelope.actionGrant) });
214
+ }
215
+ if (request.method === "POST" && url.pathname === "/api/v1/command") {
216
+ assertCsrf(request, session);
217
+ if (typeof adapter.issueActionGrant !== "function" || typeof adapter.executeCommand !== "function") {
218
+ throw fail("workbench_surface_commands_not_enabled", 503);
219
+ }
220
+ let command;
221
+ let actionGrant;
222
+ let statusRead = false;
223
+ try {
224
+ const body = JSON.parse(await readRequestBody(request));
225
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("workbench_surface_command_invalid");
226
+ command = validateWorkbenchCommand(body.command);
227
+ statusRead = command.command === "message.send-status";
228
+ const expectedKeys = statusRead ? ["command"] : ["actionGrant", "command"];
229
+ exactPrivateEnvelope(body, expectedKeys);
230
+ actionGrant = statusRead ? null : privateActionGrant(body.actionGrant);
231
+ } catch (error) {
232
+ if (error instanceof HelloLynkWorkbenchSurfaceError) throw error;
233
+ throw fail("workbench_surface_command_invalid", 400);
234
+ }
235
+ const result = validateWorkbenchCommandResult(
236
+ await mutateAccount(() => adapter.executeCommand(
237
+ command,
238
+ statusRead ? Object.freeze({}) : Object.freeze({ actionGrant }),
239
+ )),
240
+ command.command,
241
+ );
242
+ return json(response, 200, { result });
243
+ }
244
+ throw fail("workbench_surface_route_not_found", 404);
245
+ }
246
+
247
+ async function staticFile(request, response, pathname) {
248
+ assertHost(request, origin);
249
+ const bytes = staticFiles.get(pathname);
250
+ if (!bytes) throw fail("workbench_surface_static_unavailable");
251
+ const [, type] = servedFiles[pathname];
252
+ const extra = pathname === pagePath ? ensureNavigationSession(request) : {};
253
+ response.writeHead(200, { ...securityHeaders(type, pathname === pagePath ? trustedParentOrigin : null), ...extra });
254
+ response.end(bytes);
255
+ }
256
+
257
+ function openEventStream(request, response, session) {
258
+ response.writeHead(200, {
259
+ ...securityHeaders("text/event-stream; charset=utf-8"),
260
+ connection: "keep-alive",
261
+ "cache-control": "no-store, no-transform",
262
+ });
263
+ response.write(": hellolynk-workbench\n\n");
264
+ const client = { response, session, needsReady: true };
265
+ eventClients.add(client);
266
+ if (typeof adapter.readChanges === "function") void pollChanges();
267
+ // Comments keep idle proxy connections alive, without renewing authority.
268
+ const heartbeat = setInterval(() => {
269
+ if (response.writableEnded || response.destroyed) return;
270
+ if (session.expiresAt < Date.now()) response.end();
271
+ else response.write(": keepalive\n\n");
272
+ }, EVENT_HEARTBEAT_MS);
273
+ heartbeat.unref();
274
+ response.once("close", () => {
275
+ clearInterval(heartbeat);
276
+ eventClients.delete(client);
277
+ if (!eventClients.size) { clearTimeout(changeTimer); changeTimer = null; }
278
+ });
279
+ }
280
+
281
+ // The Host already exposes an authorized cursor query. Poll only while a
282
+ // browser is listening; never read message bodies or create another Host.
283
+ async function pollChanges() {
284
+ if (closed || changeBusy || !eventClients.size) return;
285
+ clearTimeout(changeTimer);
286
+ changeTimer = null;
287
+ changeBusy = true;
288
+ try {
289
+ const batch = await adapter.readChanges({ afterCursor: changeCursor });
290
+ if (closed || !eventClients.size) return;
291
+ const generation = batch?.hostGeneration ?? null;
292
+ const replacedHost = generation !== null && changeGeneration !== null && generation !== changeGeneration;
293
+ if (!batch || (generation !== null && !/^[a-f0-9]{48}$/.test(generation))
294
+ || !Number.isSafeInteger(batch.cursor) || batch.cursor < 0 || (!replacedHost && batch.cursor < changeCursor)
295
+ || typeof batch.snapshotRequired !== "boolean" || !Array.isArray(batch.events) || batch.events.length > 256) {
296
+ throw fail("workbench_surface_change_response_invalid", 502);
297
+ }
298
+ changeCursor = batch.cursor;
299
+ changeGeneration = generation;
300
+ // Deliver a change before recovery, so the UI's one active read absorbs
301
+ // both signals instead of starting an immediately obsolete first read.
302
+ if (replacedHost || batch.snapshotRequired || batch.events.length) {
303
+ notifyChange({ hostGeneration: generation, revision: null, changeCursor: String(changeCursor), snapshotRequired: true });
304
+ }
305
+ for (const client of eventClients) {
306
+ if (client.needsReady && client.session.expiresAt >= Date.now()
307
+ && !client.response.writableEnded && !client.response.destroyed) {
308
+ client.response.write("event: source-ready\ndata: {}\n\n");
309
+ client.needsReady = false;
310
+ }
311
+ }
312
+ } catch (error) {
313
+ // A replaced Host may restart its cursor. The next read starts from zero;
314
+ // no command, grant or message is replayed as part of recovery.
315
+ if (error?.code === "host_app_state_conflict") changeCursor = 0;
316
+ const payload = JSON.stringify({ code: publicCode(error) });
317
+ if (!closed) for (const client of eventClients) {
318
+ if (client.response.writableEnded || client.response.destroyed) continue;
319
+ if (client.session.expiresAt < Date.now()) { client.response.end(); continue; }
320
+ client.needsReady = true;
321
+ client.response.write(`event: source-error\ndata: ${payload}\n\n`);
322
+ }
323
+ } finally {
324
+ changeBusy = false;
325
+ if (!closed && eventClients.size) {
326
+ changeTimer = setTimeout(pollChanges, changePollMs);
327
+ changeTimer.unref?.();
328
+ }
329
+ }
330
+ }
331
+
332
+ function notifyChange(change = {}) {
333
+ const payload = JSON.stringify({
334
+ hostGeneration: change.hostGeneration == null ? null : opaque(change.hostGeneration),
335
+ revision: change.revision == null ? null : opaque(change.revision),
336
+ changeCursor: opaque(change.changeCursor),
337
+ snapshotRequired: change.snapshotRequired !== false,
338
+ });
339
+ for (const client of eventClients) {
340
+ if (client.response.writableEnded || client.response.destroyed) {
341
+ eventClients.delete(client);
342
+ continue;
343
+ }
344
+ if (client.session.expiresAt < Date.now()) {
345
+ client.response.end();
346
+ eventClients.delete(client);
347
+ continue;
348
+ }
349
+ client.response.write(`event: changed\ndata: ${payload}\n\n`);
350
+ }
351
+ }
352
+
353
+ function ensureNavigationSession(request) {
354
+ // An existing browser cookie must not turn a framed navigation into a
355
+ // bypass of the exact parent check. Rejected frames never consume the arm.
356
+ const knownFrameSession = sessions.get(cookies(request.headers.cookie).get(sessionCookieName));
357
+ const allowFrameSelfReload = Boolean(knownFrameSession?.expiresAt >= Date.now());
358
+ if (request.headers["sec-fetch-dest"] === "iframe") {
359
+ assertTopLevelNavigation(request, origin, allowSameSiteTopLevelNavigation, trustedParentOrigin, allowFrameSelfReload);
360
+ }
361
+ expireSessions();
362
+ const now = Date.now();
363
+ const existingId = cookies(request.headers.cookie).get(sessionCookieName);
364
+ const existing = existingId && sessions.get(existingId);
365
+ if (existing?.expiresAt >= now) {
366
+ if (navigationArmedUntil >= now) {
367
+ assertTopLevelNavigation(request, origin, allowSameSiteTopLevelNavigation, trustedParentOrigin, allowFrameSelfReload);
368
+ navigationArmedUntil = 0;
369
+ }
370
+ return {};
371
+ }
372
+ if (existingId) sessions.delete(existingId);
373
+ assertTopLevelNavigation(request, origin, allowSameSiteTopLevelNavigation, trustedParentOrigin);
374
+ if (now > navigationArmedUntil) throw fail("workbench_surface_navigation_not_armed", 403);
375
+ if (sessions.size) throw fail("workbench_surface_session_already_active", 403);
376
+ navigationArmedUntil = 0;
377
+ const sessionId = randomSecret();
378
+ sessions.set(sessionId, { csrf: randomSecret(), expiresAt: now + SESSION_TTL_MS });
379
+ return { "set-cookie": sessionCookie(sessionCookieName, sessionId) };
380
+ }
381
+
382
+ function requireSession(request) {
383
+ expireSessions();
384
+ const id = cookies(request.headers.cookie).get(sessionCookieName);
385
+ const session = id && sessions.get(id);
386
+ if (!session) throw fail("workbench_surface_session_invalid", 401);
387
+ return session;
388
+ }
389
+
390
+ function renewSession(request, response, session) {
391
+ const id = cookies(request.headers.cookie).get(sessionCookieName);
392
+ if (sessions.get(id) !== session || session.expiresAt < Date.now()) {
393
+ throw fail("workbench_surface_session_invalid", 401);
394
+ }
395
+ // Only the same valid browser session renews itself. This never arms a new
396
+ // navigation, creates a session, or changes the Host's authorization.
397
+ session.expiresAt = Date.now() + SESSION_TTL_MS;
398
+ response.setHeader("set-cookie", sessionCookie(sessionCookieName, id));
399
+ }
400
+
401
+ function armNavigation() {
402
+ if (!origin || closed) throw fail("workbench_surface_not_startable");
403
+ navigationArmedUntil = Date.now() + navigationWindowMs;
404
+ return Object.freeze({ origin, cleanUrl: `${origin}${pagePath}`, expiresAtMs: navigationArmedUntil });
405
+ }
406
+
407
+ async function start() {
408
+ if (origin || closed) throw fail("workbench_surface_not_startable");
409
+ staticFiles = new Map(await Promise.all(Object.entries(servedFiles).map(async ([pathname, [relative]] ) => {
410
+ const file = path.resolve(uiRoot, relative);
411
+ const allowedRoot = moduleDir;
412
+ if (file !== allowedRoot && !file.startsWith(`${allowedRoot}${path.sep}`)) throw fail("workbench_surface_static_path_invalid");
413
+ let bytes = await readFile(file);
414
+ if (pathname === pagePath && typeof options.openAccount === "function") {
415
+ bytes = Buffer.from(bytes.toString("utf8").replace("<body>", '<body data-native-account-links="true">'));
416
+ }
417
+ if (pathname === pagePath && syntheticMarker) bytes = markSyntheticPreview(bytes);
418
+ return [pathname, bytes];
419
+ })));
420
+ const surfaceHostname = trustedParentOrigin ? new URL(trustedParentOrigin).hostname : LOOPBACK;
421
+ let address;
422
+ for (let attempt = 0; ; attempt += 1) {
423
+ await listenOnLoopback(server, port, LOOPBACK);
424
+ address = server.address();
425
+ if (!address || typeof address === "string" || address.address !== LOOPBACK) {
426
+ await closeServer(server);
427
+ throw fail("workbench_surface_loopback_binding_failed");
428
+ }
429
+ // localhost can resolve to either loopback family. Both listeners must
430
+ // share one port and session store, without opening a LAN interface.
431
+ if (surfaceHostname === "localhost") {
432
+ ipv6Server = http.createServer(handleRequest);
433
+ try {
434
+ await listenOnLoopback(ipv6Server, address.port, "::1");
435
+ } catch (error) {
436
+ ipv6Server = null;
437
+ if (!["EAFNOSUPPORT", "EPROTONOSUPPORT", "EADDRNOTAVAIL", "ENODEV"].includes(error.code)) {
438
+ await closeServer(server);
439
+ // An ephemeral IPv4 port can already be occupied on IPv6.
440
+ if (port === 0 && error.code === "EADDRINUSE" && attempt < 2) continue;
441
+ throw error;
442
+ }
443
+ }
444
+ }
445
+ break;
446
+ }
447
+ // Preserve the actual Gateway hostname for SameSite cookies and framing.
448
+ origin = `http://${surfaceHostname}:${address.port}`;
449
+ // Cookies are shared across ports on one host. Keep each client surface's
450
+ // session separate without changing its navigation or authorization rules.
451
+ sessionCookieName = `__Host-hellolynk_workbench_${address.port}`;
452
+ const cleanUrl = `${origin}${pagePath}`;
453
+ if (!deferInitialNavigationArm) armNavigation();
454
+ return Object.freeze({ origin, cleanUrl, host: LOOPBACK, port: address.port });
455
+ }
456
+
457
+ async function close() {
458
+ closed = true;
459
+ clearTimeout(changeTimer);
460
+ sessions.clear();
461
+ for (const client of eventClients) client.response.end();
462
+ eventClients.clear();
463
+ await Promise.all([closeServer(server), closeServer(ipv6Server)]);
464
+ }
465
+
466
+ function status() {
467
+ expireSessions();
468
+ return Object.freeze({
469
+ origin,
470
+ boundToLoopback: Boolean(origin),
471
+ navigationArmed: navigationArmedUntil >= Date.now(),
472
+ liveSessionCount: sessions.size,
473
+ eventClientCount: eventClients.size,
474
+ syntheticPreview,
475
+ syntheticMarker,
476
+ pagePath,
477
+ allowSameSiteTopLevelNavigation,
478
+ });
479
+ }
480
+
481
+ function expireSessions() {
482
+ const now = Date.now();
483
+ for (const [id, session] of sessions) if (session.expiresAt < now) sessions.delete(id);
484
+ }
485
+
486
+ return Object.freeze({ start, armNavigation, notifyChange, close, status });
487
+ }
488
+
489
+ function normalizePagePath(value) {
490
+ const pagePath = value ?? "/workbench";
491
+ if (!["/workbench", "/inbox"].includes(pagePath)) throw fail("workbench_surface_page_path_invalid");
492
+ return pagePath;
493
+ }
494
+
495
+ function normalizeTrustedParentOrigin(value) {
496
+ if (value === undefined || value === null) return null;
497
+ if (typeof value !== "string" || value.length > 128) throw fail("workbench_surface_parent_origin_invalid");
498
+ let url;
499
+ try { url = new URL(value); } catch { throw fail("workbench_surface_parent_origin_invalid"); }
500
+ // Match a complete loopback origin, never a URL prefix or page suffix.
501
+ // The surface advertises this same hostname while binding only to loopback.
502
+ if (url.protocol !== "http:" || ![LOOPBACK, "localhost"].includes(url.hostname)
503
+ || url.username || url.password || url.pathname !== "/" || url.search || url.hash
504
+ || url.origin !== value || Number(url.port || 80) < 1) {
505
+ throw fail("workbench_surface_parent_origin_invalid");
506
+ }
507
+ return url.origin;
508
+ }
509
+
510
+ function markSyntheticPreview(bytes) {
511
+ let output = bytes.toString("utf8");
512
+ for (const [expected, replacement] of [
513
+ [
514
+ '<div id="synthetic-banner" class="synthetic-banner" hidden role="status"></div>',
515
+ '<div id="synthetic-banner" class="synthetic-banner" role="status">固定合成测试数据 · 不是真实 HelloLynk 数据 / Fixed synthetic test data · not real HelloLynk data</div>',
516
+ ],
517
+ ['<span class="host-dot" aria-hidden="true"></span>', '<span class="host-dot is-synthetic" aria-hidden="true"></span>'],
518
+ ['<span id="host-status">正在打开</span>', '<span id="host-status">合成数据 / Synthetic data</span>'],
519
+ ['<h2 id="state-title">正在打开 HelloLynk</h2>', '<h2 id="state-title">正在打开合成预览 / Opening synthetic preview</h2>'],
520
+ [
521
+ '<p id="state-message">正在读取这台电脑上的消息。</p>',
522
+ '<p id="state-message">正在读取固定合成数据,不会连接本机 Host。 / Reading fixed synthetic data without connecting to the local Host.</p>',
523
+ ],
524
+ ]) {
525
+ if (!output.includes(expected)) throw fail("workbench_surface_synthetic_marker_missing");
526
+ output = output.replace(expected, replacement);
527
+ }
528
+ return Buffer.from(output);
529
+ }
530
+
531
+ function validateAdapter(value) {
532
+ if (!value || typeof value.readSnapshot !== "function") {
533
+ throw fail("workbench_surface_adapter_required");
534
+ }
535
+ const hasIssuer = typeof value.issueActionGrant === "function";
536
+ const hasExecutor = typeof value.executeCommand === "function";
537
+ if (hasIssuer !== hasExecutor) throw fail("workbench_surface_action_grant_adapter_required");
538
+ return value;
539
+ }
540
+
541
+ function exactPrivateEnvelope(value, keys) {
542
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw fail("workbench_surface_command_invalid", 400);
543
+ const actual = Object.keys(value).sort();
544
+ const expected = [...keys].sort();
545
+ if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
546
+ throw fail("workbench_surface_command_invalid", 400);
547
+ }
548
+ return value;
549
+ }
550
+
551
+ function privateActionGrant(value) {
552
+ const grant = String(value || "");
553
+ if (!ACTION_GRANT.test(grant)) throw fail("workbench_surface_action_grant_invalid", 403);
554
+ return grant;
555
+ }
556
+
557
+ async function readRequestBody(request) {
558
+ const chunks = [];
559
+ let size = 0;
560
+ for await (const chunk of request) {
561
+ size += chunk.length;
562
+ if (size > 64 * 1024) throw fail("workbench_surface_command_too_large", 413);
563
+ chunks.push(chunk);
564
+ }
565
+ if (size === 0) throw fail("workbench_surface_command_invalid", 400);
566
+ return Buffer.concat(chunks).toString("utf8");
567
+ }
568
+
569
+ function parseUrl(request, origin) {
570
+ const host = String(request.headers.host || "");
571
+ return new URL(request.url || "/", origin || `http://${host || LOOPBACK}`);
572
+ }
573
+
574
+ function assertHost(request, origin) {
575
+ const remote = String(request.socket.remoteAddress || "");
576
+ if (!origin || String(request.headers.host || "") !== new URL(origin).host || ![LOOPBACK, "::1", "::ffff:127.0.0.1"].includes(remote)) {
577
+ throw fail("workbench_surface_host_not_allowed", 403);
578
+ }
579
+ }
580
+
581
+ function listenOnLoopback(server, port, host) {
582
+ return new Promise((resolve, reject) => {
583
+ const onError = (error) => {
584
+ server.off("listening", onListening);
585
+ reject(error);
586
+ };
587
+ const onListening = () => {
588
+ server.off("error", onError);
589
+ resolve();
590
+ };
591
+ server.once("error", onError);
592
+ server.once("listening", onListening);
593
+ server.listen({ port, host, ipv6Only: true });
594
+ });
595
+ }
596
+
597
+ async function closeServer(server) {
598
+ if (server?.listening) await new Promise((resolve) => server.close(resolve));
599
+ }
600
+
601
+ function assertTopLevelNavigation(request, origin, allowSameSiteTopLevelNavigation = false, trustedParentOrigin = null, allowFrameSelfReload = false) {
602
+ if (request.method !== "GET" || request.headers.origin) throw fail("workbench_surface_navigation_not_allowed", 403);
603
+ const site = String(request.headers["sec-fetch-site"] || "");
604
+ const mode = String(request.headers["sec-fetch-mode"] || "");
605
+ const destination = String(request.headers["sec-fetch-dest"] || "");
606
+ const user = String(request.headers["sec-fetch-user"] || "");
607
+ if (destination === "iframe" && trustedParentOrigin) {
608
+ const referer = String(request.headers.referer || "");
609
+ let parent;
610
+ try { parent = new URL(referer); } catch { throw fail("workbench_surface_navigation_not_allowed", 403); }
611
+ // A frame reloading itself sends its own origin as Referer. It needs the
612
+ // already-issued session and same-origin fetch metadata; first navigation
613
+ // still needs the exact Gateway parent. CSP checks the full ancestor chain.
614
+ const expectedParent = parent.origin === trustedParentOrigin
615
+ || (allowFrameSelfReload && site === "same-origin" && parent.origin === origin);
616
+ if (!["same-origin", "same-site"].includes(site) || mode !== "navigate" || (user && user !== "?1")
617
+ || !expectedParent || parent.username || parent.password || parent.hash) {
618
+ throw fail("workbench_surface_navigation_not_allowed", 403);
619
+ }
620
+ return;
621
+ }
622
+ const allowedSites = allowSameSiteTopLevelNavigation ? ["none", "same-origin", "same-site"] : ["none", "same-origin"];
623
+ if (!allowedSites.includes(site) || mode !== "navigate" || destination !== "document" || (user && user !== "?1")) {
624
+ throw fail("workbench_surface_navigation_not_allowed", 403);
625
+ }
626
+ const referer = String(request.headers.referer || "");
627
+ if (referer) {
628
+ try {
629
+ if (new URL(referer).origin !== origin) throw fail("workbench_surface_navigation_not_allowed", 403);
630
+ } catch (error) {
631
+ if (error instanceof HelloLynkWorkbenchSurfaceError) throw error;
632
+ throw fail("workbench_surface_navigation_not_allowed", 403);
633
+ }
634
+ }
635
+ }
636
+
637
+ function assertSameOrigin(request) {
638
+ const expected = `http://${request.headers.host}`;
639
+ if (String(request.headers.origin || "") !== expected || String(request.headers["sec-fetch-site"] || "same-origin") !== "same-origin") {
640
+ throw fail("workbench_surface_origin_not_allowed", 403);
641
+ }
642
+ }
643
+
644
+ function assertReadOrigin(request) {
645
+ const origin = String(request.headers.origin || "");
646
+ const site = String(request.headers["sec-fetch-site"] || "");
647
+ if ((origin && origin !== `http://${request.headers.host}`) || ["cross-site", "same-site"].includes(site)) {
648
+ throw fail("workbench_surface_origin_not_allowed", 403);
649
+ }
650
+ }
651
+
652
+ function assertCsrf(request, session) {
653
+ if (!sameSecret(request.headers["x-hellolynk-csrf"], session.csrf)) throw fail("workbench_surface_csrf_invalid", 403);
654
+ }
655
+
656
+ function securityHeaders(contentType, trustedParentOrigin = null) {
657
+ const headers = {
658
+ "content-type": contentType,
659
+ "cache-control": "no-store, max-age=0",
660
+ "content-security-policy": "default-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; font-src 'self';",
661
+ "cross-origin-opener-policy": "same-origin",
662
+ "cross-origin-resource-policy": "same-origin",
663
+ "referrer-policy": "no-referrer",
664
+ "x-content-type-options": "nosniff",
665
+ "x-frame-options": "DENY",
666
+ };
667
+ if (trustedParentOrigin) {
668
+ headers["content-security-policy"] = headers["content-security-policy"].replace("frame-ancestors 'none'", `frame-ancestors ${trustedParentOrigin}`);
669
+ headers["referrer-policy"] = "origin";
670
+ delete headers["x-frame-options"];
671
+ }
672
+ return headers;
673
+ }
674
+
675
+ function json(response, status, value) {
676
+ response.writeHead(status, securityHeaders("application/json; charset=utf-8"));
677
+ response.end(`${JSON.stringify(value)}\n`);
678
+ }
679
+
680
+ function empty(response, status) {
681
+ response.writeHead(status, securityHeaders("text/plain; charset=utf-8"));
682
+ response.end();
683
+ }
684
+
685
+ function cookies(value) {
686
+ const result = new Map();
687
+ for (const part of String(value || "").split(";")) {
688
+ const at = part.indexOf("=");
689
+ if (at > 0) result.set(part.slice(0, at).trim(), part.slice(at + 1).trim());
690
+ }
691
+ return result;
692
+ }
693
+
694
+ function sameSecret(left, right) {
695
+ const a = Buffer.from(String(left || ""));
696
+ const b = Buffer.from(String(right || ""));
697
+ return a.length > 0 && a.length === b.length && crypto.timingSafeEqual(a, b);
698
+ }
699
+
700
+ function sessionCookie(name, id) {
701
+ return `${name}=${id}; Secure; HttpOnly; SameSite=Strict; Path=/; Max-Age=${Math.floor(SESSION_TTL_MS / 1_000)}`;
702
+ }
703
+
704
+ function randomSecret() {
705
+ return crypto.randomBytes(32).toString("base64url");
706
+ }
707
+
708
+ function opaque(value) {
709
+ if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,254}$/.test(value)) {
710
+ throw fail("workbench_surface_change_invalid", 400);
711
+ }
712
+ return value;
713
+ }
714
+
715
+ function integer(value, fallback, minimum, maximum, code) {
716
+ const candidate = value ?? fallback;
717
+ if (!Number.isSafeInteger(candidate) || candidate < minimum || candidate > maximum) throw fail(code);
718
+ return candidate;
719
+ }
720
+
721
+ function publicCode(error) {
722
+ const networkCode = error?.cause?.code || error?.code;
723
+ if (error?.name === "TimeoutError" || ["ETIMEDOUT", "UND_ERR_CONNECT_TIMEOUT"].includes(networkCode)) return "workbench_surface_host_timeout";
724
+ if (["ECONNREFUSED", "ECONNRESET", "EPIPE", "UND_ERR_SOCKET"].includes(networkCode)) {
725
+ return "workbench_surface_host_unreachable";
726
+ }
727
+ const code = String(error?.code || "workbench_surface_failed");
728
+ return (/^workbench_(surface|snapshot|command|result|revision|unknown|connect|disconnect|account|portal)[a-z0-9_:-]{1,120}$/.test(code)
729
+ || /^(?:host|neutral_host)_[a-z0-9_]{1,100}$/.test(code) || ["hellolynk_node_22_16_required", "powershell7_missing", "credential_helper_unavailable",
730
+ "device_enrollment_name_invalid", "device_enrollment_hellolynk_id_invalid", "device_enrollment_existing_profile_requires_setup",
731
+ "agent_instance_name_invalid", "agent_instance_name_conflict", "device_enrollment_busy",
732
+ "device_enrollment_request_timeout", "device_enrollment_request_failed",
733
+ "device_enrollment_intent_conflict", "device_enrollment_authorization_expired", "device_enrollment_owner_conflict"].includes(code))
734
+ ? code
735
+ : "workbench_surface_failed";
736
+ }
737
+
738
+ function fail(code, status = 500) {
739
+ return new HelloLynkWorkbenchSurfaceError(code, status);
740
+ }