@ran-sh/dsh-crew 1.9.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/.claude-plugin/marketplace.json +16 -16
  2. package/.claude-plugin/plugin.json +14 -14
  3. package/LICENSE +21 -21
  4. package/README.de.md +359 -359
  5. package/README.es.md +359 -359
  6. package/README.fr.md +359 -359
  7. package/README.hi.md +359 -359
  8. package/README.id.md +359 -359
  9. package/README.ja.md +359 -359
  10. package/README.ko.md +359 -359
  11. package/README.md +126 -126
  12. package/README.pt.md +359 -359
  13. package/README.ru.md +359 -359
  14. package/README.th.md +359 -359
  15. package/README.tr.md +359 -359
  16. package/README.vi.md +359 -359
  17. package/README.zh-TW.md +359 -359
  18. package/README.zh.md +116 -116
  19. package/agents/ds-flash.md +24 -24
  20. package/agents/ds-pro.md +24 -24
  21. package/agents/ds-reviewer.md +23 -23
  22. package/agents/ds-worker.md +23 -23
  23. package/bin/dsh-crew.mjs +16 -16
  24. package/codex/agents/ds-flash.toml +34 -34
  25. package/codex/agents/ds-pro.toml +34 -34
  26. package/codex/agents/ds-reviewer.toml +32 -32
  27. package/codex/agents/ds-worker.toml +32 -32
  28. package/codex/prompts/dsh-config.md +19 -19
  29. package/codex/prompts/dsh-status.md +5 -5
  30. package/commands/config.md +23 -23
  31. package/commands/off.md +5 -5
  32. package/commands/on.md +5 -5
  33. package/commands/status.md +9 -9
  34. package/cordis.patch.yml +4 -4
  35. package/docs/gpt-relay-extension.md +103 -103
  36. package/docs/installation.md +138 -138
  37. package/docs/job-contracts.md +103 -103
  38. package/docs/readiness-matrix.md +85 -85
  39. package/docs/ui-surfaces.md +107 -107
  40. package/official-web-bridge/cordis.patch.yml +4 -4
  41. package/official-web-bridge/entry.mjs +1 -1
  42. package/official-web-bridge/overlay-entry.mjs +59 -59
  43. package/official-web-bridge/package.json +25 -25
  44. package/package.json +1 -1
  45. package/scripts/build-client.mjs +49 -49
  46. package/scripts/live-crew-smoke.mjs +39 -39
  47. package/scripts/live-policy-matrix.mjs +177 -177
  48. package/scripts/policy-probe.mjs +101 -101
  49. package/scripts/remove-legacy-official-bridge.ps1 +89 -89
  50. package/scripts/setup.mjs +393 -393
  51. package/scripts/smoke-real.mjs +110 -110
  52. package/scripts/smoke.mjs +78 -78
  53. package/scripts/verify-crew-ui-polish.mjs +145 -145
  54. package/scripts/verify-history-ui.mjs +97 -97
  55. package/scripts/verify-installer-fix.mjs +26 -26
  56. package/scripts/verify-npm-install.mjs +311 -311
  57. package/scripts/verify-official-bridge-e2e.mjs +192 -192
  58. package/src/adaptive-routing.mjs +260 -260
  59. package/src/client/activation-summary.tsx +64 -64
  60. package/src/client/collapsible-sections.mjs +55 -55
  61. package/src/client/history-panel.tsx +108 -108
  62. package/src/client/host-readiness.mjs +71 -71
  63. package/src/client/index.tsx +1711 -1711
  64. package/src/client/model-callability-view.mjs +17 -17
  65. package/src/client/panel-chrome.tsx +40 -40
  66. package/src/client/quick-entry.tsx +10 -10
  67. package/src/client/quick-panel.tsx +275 -275
  68. package/src/client/readiness-envelope.mjs +34 -34
  69. package/src/client/surface-detection.mjs +43 -43
  70. package/src/config-readiness.mjs +226 -226
  71. package/src/credential-reference.mjs +38 -38
  72. package/src/delivery.mjs +205 -205
  73. package/src/dsh-cli-runtime.mjs +1021 -1021
  74. package/src/dsh-cohort.mjs +20 -20
  75. package/src/extension-contract.mjs +104 -104
  76. package/src/failure-classification.mjs +201 -201
  77. package/src/history/admission-gate.mjs +67 -67
  78. package/src/history/archive-store.mjs +272 -272
  79. package/src/history/cleanup-plan.mjs +89 -89
  80. package/src/history/http.mjs +32 -32
  81. package/src/history/operation.mjs +86 -86
  82. package/src/history/runner-detach.mjs +34 -34
  83. package/src/history/runner.mjs +52 -52
  84. package/src/history/runtime.mjs +36 -36
  85. package/src/history/service.mjs +177 -177
  86. package/src/history/state.mjs +27 -27
  87. package/src/hub/entry.mjs +104 -104
  88. package/src/hub/index.mjs +2694 -2694
  89. package/src/hub-client.mjs +154 -154
  90. package/src/hub-compatibility.mjs +42 -42
  91. package/src/i18n.mjs +19 -19
  92. package/src/information-flow.mjs +67 -67
  93. package/src/install/cli.mjs +28 -28
  94. package/src/install/install-legacy.mjs +711 -711
  95. package/src/install/install.mjs +483 -483
  96. package/src/install/npx-lifecycle.mjs +3456 -3456
  97. package/src/install/official-frontend-assets.mjs +78 -78
  98. package/src/install/official-web.mjs +95 -95
  99. package/src/install/payload-content.mjs +88 -88
  100. package/src/install/windows-startup.mjs +236 -236
  101. package/src/install/windows-supervisor-adapter.mjs +443 -443
  102. package/src/install/windows-supervisor-lifecycle.mjs +782 -782
  103. package/src/install/zcode.mjs +397 -397
  104. package/src/job-contracts.mjs +255 -255
  105. package/src/local-request-guard.mjs +60 -60
  106. package/src/mcp-runtime.mjs +340 -340
  107. package/src/model-callability-contract.mjs +79 -79
  108. package/src/model-catalog.mjs +180 -180
  109. package/src/model-routing.mjs +586 -586
  110. package/src/model-schedule.mjs +207 -207
  111. package/src/official-web-bridge.mjs +447 -447
  112. package/src/policy.mjs +235 -235
  113. package/src/provider-delete-adapters.mjs +1934 -1934
  114. package/src/provider-health.mjs +130 -130
  115. package/src/provider-inventory.mjs +182 -182
  116. package/src/provider-layer-migration-adapters.mjs +759 -759
  117. package/src/provider-layer-migration.mjs +198 -198
  118. package/src/provider-lifecycle-state.mjs +103 -103
  119. package/src/provider-lifecycle.mjs +252 -252
  120. package/src/provider-profile-store.mjs +390 -390
  121. package/src/provider-settings-store.mjs +633 -633
  122. package/src/provider-store-lock.mjs +67 -67
  123. package/src/readiness-matrix.mjs +181 -181
  124. package/src/removable-waiter.mjs +29 -29
  125. package/src/role-profiles.mjs +107 -107
  126. package/src/runtime-controls.mjs +84 -84
  127. package/src/runtime-identity-contract.mjs +34 -34
  128. package/src/runtime-identity.mjs +235 -235
  129. package/src/runtime-readiness-snapshot.mjs +285 -285
  130. package/src/server.mjs +581 -581
  131. package/src/session-origins.mjs +60 -60
  132. package/src/standalone-sdk.mjs +23 -23
  133. package/src/status-shard.mjs +63 -63
  134. package/src/structured-error-code.mjs +38 -38
  135. package/src/supervisor/restart-request.mjs +256 -256
  136. package/src/workflow-runtime.mjs +739 -739
  137. package/src/workflow.mjs +155 -155
  138. package/src/workspace-audit.mjs +231 -231
  139. package/src/workspace-context.mjs +146 -146
  140. package/src/workspace-isolation.mjs +455 -455
  141. package/src/workspace-readiness.mjs +32 -32
  142. package/statusline/statusline.sh +14 -14
  143. package/statusline/worker-segment.sh +35 -35
  144. package/windows/start-dsh-crew.cmd +57 -57
  145. package/windows/start-dsh-crew.ps1 +1302 -1302
  146. package/windows/supervisor-control.ps1 +467 -467
  147. package/worker.cordis.yml +67 -67
  148. package/zcode/agents/ds-reviewer.md +31 -31
  149. package/zcode/agents/ds-worker.md +31 -31
  150. package/zcode/commands/dsh-config.md +17 -17
  151. package/zcode/commands/dsh-status.md +5 -5
@@ -1,447 +1,447 @@
1
- import { spawn } from 'node:child_process';
2
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
- import { homedir } from 'node:os';
4
- import { join } from 'node:path';
5
- import { crewDshHome } from './install/install.mjs';
6
- import { crewDshRuntimeModule } from './dsh-cli-runtime.mjs';
7
- import { isLocalHostname, isLoopbackAddress, localRequestCore, originAuthorityMatches } from './local-request-guard.mjs';
8
-
9
- export { isLoopbackAddress };
10
-
11
- export const CREW_BRIDGE_PREFIX = '/_dsh/dsh-crew';
12
- export const CREW_BRIDGE_TARGET = 'http://127.0.0.1:3210';
13
- const MAX_BODY_BYTES = 4 * 1024 * 1024;
14
- const HOP_BY_HOP = new Set([
15
- 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
16
- 'te', 'trailer', 'transfer-encoding', 'upgrade', 'host', 'content-length',
17
- ]);
18
-
19
- export function resolveCrewBridgeTarget() {
20
- // Production bridge routing is an invariant, not an environment setting.
21
- // Tests can inject dependencies directly into createCrewSidecarSupervisor.
22
- return CREW_BRIDGE_TARGET;
23
- }
24
-
25
- export function isTrustedLocalRequest(req) {
26
- if (!localRequestCore(req)) return false;
27
- const host = typeof req?.headers?.host === 'string' ? req.headers.host.trim().toLowerCase() : '';
28
- if (!host) return false;
29
- const origin = req?.headers?.origin;
30
- if (origin === undefined) return true;
31
- return originAuthorityMatches(origin, host);
32
- }
33
-
34
- function safeHeaders(source) {
35
- const result = {};
36
- for (const [rawName, rawValue] of Object.entries(source ?? {})) {
37
- const name = rawName.toLowerCase();
38
- if (HOP_BY_HOP.has(name) || rawValue === undefined) continue;
39
- result[name] = Array.isArray(rawValue) ? rawValue.join(', ') : String(rawValue);
40
- }
41
- return result;
42
- }
43
-
44
- function sendJson(res, status, value) {
45
- const body = Buffer.from(JSON.stringify(value));
46
- res.writeHead(status, {
47
- 'content-type': 'application/json; charset=utf-8',
48
- 'content-length': String(body.length),
49
- 'cache-control': 'no-store',
50
- 'x-content-type-options': 'nosniff',
51
- 'x-dsh-crew-bridge': '3080-to-3210',
52
- });
53
- res.end(body);
54
- }
55
-
56
- async function readBoundedBody(req, limit = MAX_BODY_BYTES) {
57
- const chunks = [];
58
- let size = 0;
59
- for await (const chunk of req) {
60
- const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
61
- size += value.length;
62
- if (size > limit) throw Object.assign(new Error('request too large'), { code: 'BODY_TOO_LARGE' });
63
- chunks.push(value);
64
- }
65
- return Buffer.concat(chunks);
66
- }
67
-
68
- export async function defaultRuntimeIdentity(fetchImpl = globalThis.fetch, bridgeTarget = CREW_BRIDGE_TARGET) {
69
- try {
70
- const response = await fetchImpl(`${bridgeTarget}${CREW_BRIDGE_PREFIX}/runtime`, {
71
- signal: AbortSignal.timeout(1_500),
72
- headers: { accept: 'application/json' },
73
- });
74
- if (!response.ok) return null;
75
- const body = await response.json();
76
- if (!(body?.ok === true
77
- && body.execution_plane === 'hub-3210'
78
- && body.profile === 'dsh-crew'
79
- && Number(body.listen_port) === 3210
80
- && typeof body.runtime_id === 'string'
81
- && body.runtime_id.trim().length > 0)) return null;
82
- return { execution_plane: body.execution_plane, profile: body.profile, listen_port: Number(body.listen_port), runtime_id: body.runtime_id.trim() };
83
- } catch { return null; }
84
- }
85
-
86
- export async function defaultHealthCheck(fetchImpl = globalThis.fetch, bridgeTarget = CREW_BRIDGE_TARGET) {
87
- return (await defaultRuntimeIdentity(fetchImpl, bridgeTarget)) !== null;
88
- }
89
-
90
- const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
91
-
92
- export function createCrewSidecarSupervisor({
93
- home = homedir(),
94
- exists = existsSync,
95
- bridgeTarget = resolveCrewBridgeTarget(),
96
- healthCheck = () => defaultHealthCheck(globalThis.fetch, bridgeTarget),
97
- spawnImpl = spawn,
98
- killImpl = (pid) => process.kill(pid),
99
- runtimeIdentity = () => defaultRuntimeIdentity(globalThis.fetch, bridgeTarget),
100
- wait = delay,
101
- maxAttempts = 120,
102
- pollInterval = 250,
103
- ownershipFile = join(crewDshHome({ home }), 'supervisor-ownership.json'),
104
- } = {}) {
105
- if (bridgeTarget !== CREW_BRIDGE_TARGET) throw new Error('Crew supervisor is fixed to the isolated 3210 Crew Hub');
106
- let starting = null;
107
- let restarting = null;
108
- let runningChild = null;
109
- const runtime = crewDshRuntimeModule({ home });
110
- const dshHome = crewDshHome({ home });
111
- const bridgePort = new URL(bridgeTarget).port;
112
-
113
- function readOwnership() {
114
- try {
115
- const record = JSON.parse(readFileSync(ownershipFile, 'utf8'));
116
- if (record?.schema_version !== 1
117
- || !Number.isInteger(record.pid) || record.pid < 1
118
- || record.profile !== 'dsh-crew'
119
- || record.execution_plane !== 'hub-3210'
120
- || Number(record.port) !== Number(bridgePort)) return null;
121
- return record;
122
- } catch { return null; }
123
- }
124
-
125
- function clearOwnership(expectedPid) {
126
- try {
127
- const current = readOwnership();
128
- if (expectedPid !== undefined && current?.pid !== expectedPid) return;
129
- rmSync(ownershipFile, { force: true });
130
- } catch {}
131
- }
132
-
133
- function persistOwnership(pid, identity = null, { state = 'owned' } = {}) {
134
- if (!Number.isInteger(pid) || pid < 1) return false;
135
- if (state === 'owned' && !(typeof identity?.runtime_id === 'string' && identity.runtime_id.length > 0)) return false;
136
- try {
137
- mkdirSync(dshHome, { recursive: true });
138
- const temp = `${ownershipFile}.${process.pid}.${Date.now()}.tmp`;
139
- writeFileSync(temp, JSON.stringify({
140
- schema_version: 1,
141
- state,
142
- pid,
143
- profile: 'dsh-crew',
144
- execution_plane: 'hub-3210',
145
- port: Number(bridgePort),
146
- runtime_id: typeof identity?.runtime_id === 'string' ? identity.runtime_id : null,
147
- }) + '\n');
148
- renameSync(temp, ownershipFile);
149
- return true;
150
- } catch { return false; }
151
- }
152
-
153
- function adoptedChild(pid) {
154
- return {
155
- pid,
156
- killed: false,
157
- exitCode: null,
158
- kill() {
159
- try {
160
- killImpl(pid);
161
- this.killed = true;
162
- this.exitCode = 0;
163
- clearOwnership(pid);
164
- return true;
165
- } catch { return false; }
166
- },
167
- unref() {},
168
- };
169
- }
170
-
171
- async function adoptPersistedChild() {
172
- if (runningChild && runningChild.killed !== true && runningChild.exitCode == null) return true;
173
- const record = readOwnership();
174
- if (!record || !(await healthCheck())) {
175
- if (record) clearOwnership(record.pid);
176
- return false;
177
- }
178
- // Kill authority requires a persisted non-empty runtime_id AND an exact
179
- // match with the live identity. A record without runtime_id (crash
180
- // between spawn and identity persistence, or PID reuse afterwards)
181
- // can never authorize a kill.
182
- if (typeof record.runtime_id !== 'string' || record.runtime_id.length === 0) {
183
- clearOwnership(record.pid);
184
- return false;
185
- }
186
- const identity = await runtimeIdentity();
187
- if (!identity || identity.runtime_id !== record.runtime_id) {
188
- clearOwnership(record.pid);
189
- return false;
190
- }
191
- runningChild = adoptedChild(record.pid);
192
- return true;
193
- }
194
-
195
- async function start() {
196
- const childAlive = runningChild && runningChild.killed !== true && runningChild.exitCode == null;
197
- if (await healthCheck()) {
198
- if (childAlive) return { ok: true, started: false, owned: true };
199
- if (await adoptPersistedChild()) return { ok: true, started: false, adopted: true, owned: true };
200
- return { ok: false, code: 'PORT_OWNERSHIP_CONFLICT' };
201
- }
202
- if (!exists(runtime)) return { ok: false, code: 'CREW_RUNTIME_NOT_INSTALLED' };
203
- if (!childAlive) {
204
- runningChild = spawnImpl(process.execPath, [
205
- runtime, '--profile', 'dsh-crew', '--host', '127.0.0.1', '--port', bridgePort,
206
- ], {
207
- cwd: dshHome,
208
- env: { ...process.env, DSH_HOME: dshHome },
209
- detached: true,
210
- stdio: 'ignore',
211
- windowsHide: true,
212
- });
213
- const ownedChild = runningChild;
214
- if (Number.isInteger(ownedChild?.pid) && !persistOwnership(ownedChild.pid, null, { state: 'starting' })) {
215
- try { ownedChild.kill?.(); } catch {}
216
- runningChild = null;
217
- return { ok: false, code: 'SUPERVISOR_OWNERSHIP_PERSIST_FAILED' };
218
- }
219
- ownedChild.once?.('exit', () => { if (runningChild === ownedChild) runningChild = null; });
220
- ownedChild.unref?.();
221
- }
222
- for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
223
- if (await healthCheck()) {
224
- if (Number.isInteger(runningChild?.pid)) {
225
- const identity = await runtimeIdentity();
226
- if (!identity) {
227
- try { runningChild?.kill?.(); } catch {}
228
- runningChild = null;
229
- return { ok: false, code: 'SUPERVISOR_RUNTIME_IDENTITY_UNAVAILABLE' };
230
- }
231
- if (!persistOwnership(runningChild.pid, identity)) {
232
- try { runningChild.kill?.(); } catch {}
233
- runningChild = null;
234
- return { ok: false, code: 'SUPERVISOR_OWNERSHIP_PERSIST_FAILED' };
235
- }
236
- }
237
- return { ok: true, started: true, owned: true };
238
- }
239
- await wait(pollInterval);
240
- }
241
- return { ok: false, code: 'CREW_BACKEND_START_TIMEOUT' };
242
- }
243
-
244
- async function restartOwnedBackend() {
245
- let owned = runningChild;
246
- let childAlive = owned && owned.killed !== true && owned.exitCode == null;
247
- if (!childAlive && await healthCheck()) {
248
- if (await adoptPersistedChild()) {
249
- owned = runningChild;
250
- childAlive = true;
251
- } else {
252
- return { ok: false, code: 'PORT_OWNERSHIP_CONFLICT' };
253
- }
254
- }
255
- if (!childAlive) {
256
- const started = await start();
257
- return started.ok ? { ...started, restarted: started.started === true } : started;
258
- }
259
- if (typeof owned.kill !== 'function' || owned.kill() !== true) {
260
- return { ok: false, code: 'CREW_BACKEND_RESTART_UNAVAILABLE' };
261
- }
262
- for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
263
- if (!await healthCheck()) break;
264
- await wait(pollInterval);
265
- }
266
- if (await healthCheck()) return { ok: false, code: 'CREW_BACKEND_STOP_TIMEOUT' };
267
- const started = await start();
268
- return started.ok ? { ...started, restarted: true } : started;
269
- }
270
-
271
- // Stop-only primitive for cohort migration: kills the owned 3210 child
272
- // (PID-identity verified) and waits until health disappears. Never
273
- // restarts: the caller swaps the runtime tree first, then calls
274
- // startOwnedBackend. Returns the stopped PID for audit.
275
- async function stopOwnedBackend() {
276
- let owned = runningChild;
277
- let childAlive = owned && owned.killed !== true && owned.exitCode == null;
278
- if (!childAlive && await healthCheck()) {
279
- if (await adoptPersistedChild()) {
280
- owned = runningChild;
281
- childAlive = true;
282
- } else {
283
- return { ok: false, code: 'PORT_OWNERSHIP_CONFLICT' };
284
- }
285
- }
286
- if (!childAlive) return { ok: true, stopped: false, pid: null };
287
- const pid = owned.pid;
288
- if (typeof owned.kill !== 'function' || owned.kill() !== true) {
289
- return { ok: false, code: 'CREW_BACKEND_STOP_UNAVAILABLE' };
290
- }
291
- for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
292
- if (!await healthCheck()) return { ok: true, stopped: true, pid };
293
- await wait(pollInterval);
294
- }
295
- return { ok: false, code: 'CREW_BACKEND_STOP_TIMEOUT' };
296
- }
297
-
298
- // Start-only primitive for cohort migration: boots the Crew-owned 3210
299
- // from the CURRENT live runtime tree and waits for health + identity.
300
- // Never stops anything first: the caller stops before swapping.
301
- async function startOwnedBackend() {
302
- const started = await start();
303
- return started.ok ? { ...started, started: true } : started;
304
- }
305
-
306
- return {
307
- execution_plane: 'hub-3210',
308
- profile: 'dsh-crew',
309
- listen_port: Number(bridgePort),
310
- ensure() {
311
- if (!starting) starting = start().finally(() => { starting = null; });
312
- return starting;
313
- },
314
- restartOwnedBackend() {
315
- if (!restarting) restarting = restartOwnedBackend().finally(() => { restarting = null; });
316
- return restarting;
317
- },
318
- stopOwnedBackend,
319
- startOwnedBackend,
320
- };
321
- }
322
-
323
- // NOTE: createCrewSidecarSupervisor (above) remains exported for unit
324
- // tests, but NO production path may spawn/kill the 3210 through it. The
325
- // Windows launcher supervisor (start-dsh-crew.ps1 watch mode) is the only
326
- // process authority; npx lifecycle reaches it through the durable
327
- // maintenance-request protocol (maintenance-stop/maintenance-start).
328
-
329
- // The 3080 quick surface must NEVER spawn or own the 3210 process: recovery
330
- // authority belongs exclusively to the Windows launcher supervisor. The
331
- // default backend gate therefore only PROBES 3210 health and fails closed.
332
- async function probeCrewBackend(fetchImpl = globalThis.fetch, bridgeTarget = resolveCrewBridgeTarget()) {
333
- try {
334
- const response = await fetchImpl(`${bridgeTarget}${CREW_BRIDGE_PREFIX}/runtime`, {
335
- headers: { accept: 'application/json' },
336
- signal: AbortSignal.timeout(3_000),
337
- });
338
- if (!response.ok) return { ok: false, code: 'CREW_BACKEND_UNREACHABLE' };
339
- const body = await response.json();
340
- return body?.ok === true ? { ok: true } : { ok: false, code: 'CREW_BACKEND_UNREADY' };
341
- } catch (error) {
342
- return { ok: false, code: 'CREW_BACKEND_UNREACHABLE', error: String(error?.message ?? error) };
343
- }
344
- }
345
-
346
- export async function proxyCrewRequest(req, res, {
347
- fetchImpl = globalThis.fetch,
348
- ensureBackend = () => probeCrewBackend(fetchImpl, resolveCrewBridgeTarget()),
349
- bridgeTarget = resolveCrewBridgeTarget(),
350
- } = {}) {
351
- if (!isTrustedLocalRequest(req)) {
352
- sendJson(res, 403, { ok: false, code: 'LOCAL_SAME_ORIGIN_ONLY' });
353
- return;
354
- }
355
- let pathname;
356
- try { pathname = new URL(req.url, 'http://127.0.0.1:3080').pathname; } catch { pathname = ''; }
357
- if (pathname !== CREW_BRIDGE_PREFIX && !pathname.startsWith(`${CREW_BRIDGE_PREFIX}/`)) {
358
- sendJson(res, 404, { ok: false, code: 'NOT_FOUND' });
359
- return;
360
- }
361
- try {
362
- const backend = await ensureBackend();
363
- if (backend?.ok === false) throw new Error('backend unavailable');
364
- const method = String(req.method ?? 'GET').toUpperCase();
365
- const bodyBuffer = method === 'GET' || method === 'HEAD' ? null : await readBoundedBody(req);
366
- const requestHeaders = safeHeaders(req.headers);
367
- for (const name of Object.keys(requestHeaders)) {
368
- if (name.startsWith('x-dsh-crew-')) delete requestHeaders[name];
369
- }
370
- requestHeaders['x-dsh-crew-bridge'] = '3080-to-3210';
371
- requestHeaders['x-dsh-crew-execution-plane'] = 'hub-3210';
372
- requestHeaders['x-dsh-crew-ingress'] = 'official-3080';
373
- const response = await fetchImpl(`${bridgeTarget}${req.url}`, {
374
- method,
375
- headers: requestHeaders,
376
- body: bodyBuffer === null ? undefined : new Blob([bodyBuffer]),
377
- signal: AbortSignal.timeout(120_000),
378
- });
379
- const responseBody = Buffer.from(await response.arrayBuffer());
380
- const headers = safeHeaders(Object.fromEntries(response.headers.entries()));
381
- for (const name of Object.keys(headers)) {
382
- if (name.startsWith('x-dsh-crew-')) delete headers[name];
383
- }
384
- headers['content-length'] = String(responseBody.length);
385
- headers['x-dsh-crew-bridge'] = '3080-to-3210';
386
- headers['x-dsh-crew-ingress'] = 'official-3080';
387
- res.writeHead(response.status, headers);
388
- res.end(responseBody);
389
- } catch (error) {
390
- if (error?.code === 'BODY_TOO_LARGE') sendJson(res, 413, { ok: false, code: 'REQUEST_TOO_LARGE' });
391
- else sendJson(res, 503, { ok: false, code: 'CREW_BACKEND_UNAVAILABLE' });
392
- }
393
- }
394
-
395
- export function registerOfficialWebBridge(ctx, options = {}) {
396
- return ctx.inject(['webServer'], (webCtx) => {
397
- const disposeStatus = webCtx.webServer.register({
398
- kind: 'exact',
399
- path: `${CREW_BRIDGE_PREFIX}/bridge-status`,
400
- handler: (req, res) => {
401
- if (!isTrustedLocalRequest(req)) return sendJson(res, 403, { ok: false, code: 'LOCAL_SAME_ORIGIN_ONLY' });
402
- return sendJson(res, 200, {
403
- ok: true,
404
- mode: 'official-3080-quick-controls',
405
- surface: 'official-bridge',
406
- ui_role: 'quick-controls',
407
- execution_plane: 'hub-3210',
408
- listen_port: 3210,
409
- full_control_plane_url: 'http://127.0.0.1:3210/',
410
- });
411
- },
412
- });
413
- // The legacy supervisor endpoint moved to the durable 3210 restart-
414
- // request channel executed by the Windows launcher. Report 410 Gone so
415
- // stale callers learn the new contract instead of failing opaquely.
416
- const disposeRestart = webCtx.webServer.register({
417
- kind: 'exact',
418
- path: `${CREW_BRIDGE_PREFIX}/supervisor/restart`,
419
- handler: (req, res) => {
420
- if (!isTrustedLocalRequest(req)) return sendJson(res, 403, { ok: false, code: 'LOCAL_SAME_ORIGIN_ONLY' });
421
- return sendJson(res, 410, { ok: false, code: 'SUPERVISOR_ENDPOINT_MOVED', control_plane: 'http://127.0.0.1:3210/' });
422
- },
423
- });
424
- // Least-privilege allowlist: the 3080 quick-controls surface may reach
425
- // ONLY the narrow quick endpoints on 3210. Full Crew API (providers,
426
- // credentials, install, workspaces, migration, rollback, quarantine)
427
- // is no longer proxied here — the native 3210 page is the full control
428
- // plane.
429
- const QUICK_ALLOWLIST = new Set([
430
- '/quick-config',
431
- '/quick-status',
432
- ]);
433
- const disposers = [];
434
- for (const path of QUICK_ALLOWLIST) {
435
- disposers.push(webCtx.webServer.register({
436
- kind: 'exact',
437
- path: `${CREW_BRIDGE_PREFIX}${path}`,
438
- handler: (req, res) => proxyCrewRequest(req, res, options),
439
- }));
440
- }
441
- return () => { for (const dispose of disposers) dispose?.(); disposeRestart?.(); disposeStatus?.(); };
442
- });
443
- }
444
-
445
- export async function apply(ctx) {
446
- registerOfficialWebBridge(ctx);
447
- }
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { crewDshHome } from './install/install.mjs';
6
+ import { crewDshRuntimeModule } from './dsh-cli-runtime.mjs';
7
+ import { isLocalHostname, isLoopbackAddress, localRequestCore, originAuthorityMatches } from './local-request-guard.mjs';
8
+
9
+ export { isLoopbackAddress };
10
+
11
+ export const CREW_BRIDGE_PREFIX = '/_dsh/dsh-crew';
12
+ export const CREW_BRIDGE_TARGET = 'http://127.0.0.1:3210';
13
+ const MAX_BODY_BYTES = 4 * 1024 * 1024;
14
+ const HOP_BY_HOP = new Set([
15
+ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
16
+ 'te', 'trailer', 'transfer-encoding', 'upgrade', 'host', 'content-length',
17
+ ]);
18
+
19
+ export function resolveCrewBridgeTarget() {
20
+ // Production bridge routing is an invariant, not an environment setting.
21
+ // Tests can inject dependencies directly into createCrewSidecarSupervisor.
22
+ return CREW_BRIDGE_TARGET;
23
+ }
24
+
25
+ export function isTrustedLocalRequest(req) {
26
+ if (!localRequestCore(req)) return false;
27
+ const host = typeof req?.headers?.host === 'string' ? req.headers.host.trim().toLowerCase() : '';
28
+ if (!host) return false;
29
+ const origin = req?.headers?.origin;
30
+ if (origin === undefined) return true;
31
+ return originAuthorityMatches(origin, host);
32
+ }
33
+
34
+ function safeHeaders(source) {
35
+ const result = {};
36
+ for (const [rawName, rawValue] of Object.entries(source ?? {})) {
37
+ const name = rawName.toLowerCase();
38
+ if (HOP_BY_HOP.has(name) || rawValue === undefined) continue;
39
+ result[name] = Array.isArray(rawValue) ? rawValue.join(', ') : String(rawValue);
40
+ }
41
+ return result;
42
+ }
43
+
44
+ function sendJson(res, status, value) {
45
+ const body = Buffer.from(JSON.stringify(value));
46
+ res.writeHead(status, {
47
+ 'content-type': 'application/json; charset=utf-8',
48
+ 'content-length': String(body.length),
49
+ 'cache-control': 'no-store',
50
+ 'x-content-type-options': 'nosniff',
51
+ 'x-dsh-crew-bridge': '3080-to-3210',
52
+ });
53
+ res.end(body);
54
+ }
55
+
56
+ async function readBoundedBody(req, limit = MAX_BODY_BYTES) {
57
+ const chunks = [];
58
+ let size = 0;
59
+ for await (const chunk of req) {
60
+ const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
61
+ size += value.length;
62
+ if (size > limit) throw Object.assign(new Error('request too large'), { code: 'BODY_TOO_LARGE' });
63
+ chunks.push(value);
64
+ }
65
+ return Buffer.concat(chunks);
66
+ }
67
+
68
+ export async function defaultRuntimeIdentity(fetchImpl = globalThis.fetch, bridgeTarget = CREW_BRIDGE_TARGET) {
69
+ try {
70
+ const response = await fetchImpl(`${bridgeTarget}${CREW_BRIDGE_PREFIX}/runtime`, {
71
+ signal: AbortSignal.timeout(1_500),
72
+ headers: { accept: 'application/json' },
73
+ });
74
+ if (!response.ok) return null;
75
+ const body = await response.json();
76
+ if (!(body?.ok === true
77
+ && body.execution_plane === 'hub-3210'
78
+ && body.profile === 'dsh-crew'
79
+ && Number(body.listen_port) === 3210
80
+ && typeof body.runtime_id === 'string'
81
+ && body.runtime_id.trim().length > 0)) return null;
82
+ return { execution_plane: body.execution_plane, profile: body.profile, listen_port: Number(body.listen_port), runtime_id: body.runtime_id.trim() };
83
+ } catch { return null; }
84
+ }
85
+
86
+ export async function defaultHealthCheck(fetchImpl = globalThis.fetch, bridgeTarget = CREW_BRIDGE_TARGET) {
87
+ return (await defaultRuntimeIdentity(fetchImpl, bridgeTarget)) !== null;
88
+ }
89
+
90
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
91
+
92
+ export function createCrewSidecarSupervisor({
93
+ home = homedir(),
94
+ exists = existsSync,
95
+ bridgeTarget = resolveCrewBridgeTarget(),
96
+ healthCheck = () => defaultHealthCheck(globalThis.fetch, bridgeTarget),
97
+ spawnImpl = spawn,
98
+ killImpl = (pid) => process.kill(pid),
99
+ runtimeIdentity = () => defaultRuntimeIdentity(globalThis.fetch, bridgeTarget),
100
+ wait = delay,
101
+ maxAttempts = 120,
102
+ pollInterval = 250,
103
+ ownershipFile = join(crewDshHome({ home }), 'supervisor-ownership.json'),
104
+ } = {}) {
105
+ if (bridgeTarget !== CREW_BRIDGE_TARGET) throw new Error('Crew supervisor is fixed to the isolated 3210 Crew Hub');
106
+ let starting = null;
107
+ let restarting = null;
108
+ let runningChild = null;
109
+ const runtime = crewDshRuntimeModule({ home });
110
+ const dshHome = crewDshHome({ home });
111
+ const bridgePort = new URL(bridgeTarget).port;
112
+
113
+ function readOwnership() {
114
+ try {
115
+ const record = JSON.parse(readFileSync(ownershipFile, 'utf8'));
116
+ if (record?.schema_version !== 1
117
+ || !Number.isInteger(record.pid) || record.pid < 1
118
+ || record.profile !== 'dsh-crew'
119
+ || record.execution_plane !== 'hub-3210'
120
+ || Number(record.port) !== Number(bridgePort)) return null;
121
+ return record;
122
+ } catch { return null; }
123
+ }
124
+
125
+ function clearOwnership(expectedPid) {
126
+ try {
127
+ const current = readOwnership();
128
+ if (expectedPid !== undefined && current?.pid !== expectedPid) return;
129
+ rmSync(ownershipFile, { force: true });
130
+ } catch {}
131
+ }
132
+
133
+ function persistOwnership(pid, identity = null, { state = 'owned' } = {}) {
134
+ if (!Number.isInteger(pid) || pid < 1) return false;
135
+ if (state === 'owned' && !(typeof identity?.runtime_id === 'string' && identity.runtime_id.length > 0)) return false;
136
+ try {
137
+ mkdirSync(dshHome, { recursive: true });
138
+ const temp = `${ownershipFile}.${process.pid}.${Date.now()}.tmp`;
139
+ writeFileSync(temp, JSON.stringify({
140
+ schema_version: 1,
141
+ state,
142
+ pid,
143
+ profile: 'dsh-crew',
144
+ execution_plane: 'hub-3210',
145
+ port: Number(bridgePort),
146
+ runtime_id: typeof identity?.runtime_id === 'string' ? identity.runtime_id : null,
147
+ }) + '\n');
148
+ renameSync(temp, ownershipFile);
149
+ return true;
150
+ } catch { return false; }
151
+ }
152
+
153
+ function adoptedChild(pid) {
154
+ return {
155
+ pid,
156
+ killed: false,
157
+ exitCode: null,
158
+ kill() {
159
+ try {
160
+ killImpl(pid);
161
+ this.killed = true;
162
+ this.exitCode = 0;
163
+ clearOwnership(pid);
164
+ return true;
165
+ } catch { return false; }
166
+ },
167
+ unref() {},
168
+ };
169
+ }
170
+
171
+ async function adoptPersistedChild() {
172
+ if (runningChild && runningChild.killed !== true && runningChild.exitCode == null) return true;
173
+ const record = readOwnership();
174
+ if (!record || !(await healthCheck())) {
175
+ if (record) clearOwnership(record.pid);
176
+ return false;
177
+ }
178
+ // Kill authority requires a persisted non-empty runtime_id AND an exact
179
+ // match with the live identity. A record without runtime_id (crash
180
+ // between spawn and identity persistence, or PID reuse afterwards)
181
+ // can never authorize a kill.
182
+ if (typeof record.runtime_id !== 'string' || record.runtime_id.length === 0) {
183
+ clearOwnership(record.pid);
184
+ return false;
185
+ }
186
+ const identity = await runtimeIdentity();
187
+ if (!identity || identity.runtime_id !== record.runtime_id) {
188
+ clearOwnership(record.pid);
189
+ return false;
190
+ }
191
+ runningChild = adoptedChild(record.pid);
192
+ return true;
193
+ }
194
+
195
+ async function start() {
196
+ const childAlive = runningChild && runningChild.killed !== true && runningChild.exitCode == null;
197
+ if (await healthCheck()) {
198
+ if (childAlive) return { ok: true, started: false, owned: true };
199
+ if (await adoptPersistedChild()) return { ok: true, started: false, adopted: true, owned: true };
200
+ return { ok: false, code: 'PORT_OWNERSHIP_CONFLICT' };
201
+ }
202
+ if (!exists(runtime)) return { ok: false, code: 'CREW_RUNTIME_NOT_INSTALLED' };
203
+ if (!childAlive) {
204
+ runningChild = spawnImpl(process.execPath, [
205
+ runtime, '--profile', 'dsh-crew', '--host', '127.0.0.1', '--port', bridgePort,
206
+ ], {
207
+ cwd: dshHome,
208
+ env: { ...process.env, DSH_HOME: dshHome },
209
+ detached: true,
210
+ stdio: 'ignore',
211
+ windowsHide: true,
212
+ });
213
+ const ownedChild = runningChild;
214
+ if (Number.isInteger(ownedChild?.pid) && !persistOwnership(ownedChild.pid, null, { state: 'starting' })) {
215
+ try { ownedChild.kill?.(); } catch {}
216
+ runningChild = null;
217
+ return { ok: false, code: 'SUPERVISOR_OWNERSHIP_PERSIST_FAILED' };
218
+ }
219
+ ownedChild.once?.('exit', () => { if (runningChild === ownedChild) runningChild = null; });
220
+ ownedChild.unref?.();
221
+ }
222
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
223
+ if (await healthCheck()) {
224
+ if (Number.isInteger(runningChild?.pid)) {
225
+ const identity = await runtimeIdentity();
226
+ if (!identity) {
227
+ try { runningChild?.kill?.(); } catch {}
228
+ runningChild = null;
229
+ return { ok: false, code: 'SUPERVISOR_RUNTIME_IDENTITY_UNAVAILABLE' };
230
+ }
231
+ if (!persistOwnership(runningChild.pid, identity)) {
232
+ try { runningChild.kill?.(); } catch {}
233
+ runningChild = null;
234
+ return { ok: false, code: 'SUPERVISOR_OWNERSHIP_PERSIST_FAILED' };
235
+ }
236
+ }
237
+ return { ok: true, started: true, owned: true };
238
+ }
239
+ await wait(pollInterval);
240
+ }
241
+ return { ok: false, code: 'CREW_BACKEND_START_TIMEOUT' };
242
+ }
243
+
244
+ async function restartOwnedBackend() {
245
+ let owned = runningChild;
246
+ let childAlive = owned && owned.killed !== true && owned.exitCode == null;
247
+ if (!childAlive && await healthCheck()) {
248
+ if (await adoptPersistedChild()) {
249
+ owned = runningChild;
250
+ childAlive = true;
251
+ } else {
252
+ return { ok: false, code: 'PORT_OWNERSHIP_CONFLICT' };
253
+ }
254
+ }
255
+ if (!childAlive) {
256
+ const started = await start();
257
+ return started.ok ? { ...started, restarted: started.started === true } : started;
258
+ }
259
+ if (typeof owned.kill !== 'function' || owned.kill() !== true) {
260
+ return { ok: false, code: 'CREW_BACKEND_RESTART_UNAVAILABLE' };
261
+ }
262
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
263
+ if (!await healthCheck()) break;
264
+ await wait(pollInterval);
265
+ }
266
+ if (await healthCheck()) return { ok: false, code: 'CREW_BACKEND_STOP_TIMEOUT' };
267
+ const started = await start();
268
+ return started.ok ? { ...started, restarted: true } : started;
269
+ }
270
+
271
+ // Stop-only primitive for cohort migration: kills the owned 3210 child
272
+ // (PID-identity verified) and waits until health disappears. Never
273
+ // restarts: the caller swaps the runtime tree first, then calls
274
+ // startOwnedBackend. Returns the stopped PID for audit.
275
+ async function stopOwnedBackend() {
276
+ let owned = runningChild;
277
+ let childAlive = owned && owned.killed !== true && owned.exitCode == null;
278
+ if (!childAlive && await healthCheck()) {
279
+ if (await adoptPersistedChild()) {
280
+ owned = runningChild;
281
+ childAlive = true;
282
+ } else {
283
+ return { ok: false, code: 'PORT_OWNERSHIP_CONFLICT' };
284
+ }
285
+ }
286
+ if (!childAlive) return { ok: true, stopped: false, pid: null };
287
+ const pid = owned.pid;
288
+ if (typeof owned.kill !== 'function' || owned.kill() !== true) {
289
+ return { ok: false, code: 'CREW_BACKEND_STOP_UNAVAILABLE' };
290
+ }
291
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
292
+ if (!await healthCheck()) return { ok: true, stopped: true, pid };
293
+ await wait(pollInterval);
294
+ }
295
+ return { ok: false, code: 'CREW_BACKEND_STOP_TIMEOUT' };
296
+ }
297
+
298
+ // Start-only primitive for cohort migration: boots the Crew-owned 3210
299
+ // from the CURRENT live runtime tree and waits for health + identity.
300
+ // Never stops anything first: the caller stops before swapping.
301
+ async function startOwnedBackend() {
302
+ const started = await start();
303
+ return started.ok ? { ...started, started: true } : started;
304
+ }
305
+
306
+ return {
307
+ execution_plane: 'hub-3210',
308
+ profile: 'dsh-crew',
309
+ listen_port: Number(bridgePort),
310
+ ensure() {
311
+ if (!starting) starting = start().finally(() => { starting = null; });
312
+ return starting;
313
+ },
314
+ restartOwnedBackend() {
315
+ if (!restarting) restarting = restartOwnedBackend().finally(() => { restarting = null; });
316
+ return restarting;
317
+ },
318
+ stopOwnedBackend,
319
+ startOwnedBackend,
320
+ };
321
+ }
322
+
323
+ // NOTE: createCrewSidecarSupervisor (above) remains exported for unit
324
+ // tests, but NO production path may spawn/kill the 3210 through it. The
325
+ // Windows launcher supervisor (start-dsh-crew.ps1 watch mode) is the only
326
+ // process authority; npx lifecycle reaches it through the durable
327
+ // maintenance-request protocol (maintenance-stop/maintenance-start).
328
+
329
+ // The 3080 quick surface must NEVER spawn or own the 3210 process: recovery
330
+ // authority belongs exclusively to the Windows launcher supervisor. The
331
+ // default backend gate therefore only PROBES 3210 health and fails closed.
332
+ async function probeCrewBackend(fetchImpl = globalThis.fetch, bridgeTarget = resolveCrewBridgeTarget()) {
333
+ try {
334
+ const response = await fetchImpl(`${bridgeTarget}${CREW_BRIDGE_PREFIX}/runtime`, {
335
+ headers: { accept: 'application/json' },
336
+ signal: AbortSignal.timeout(3_000),
337
+ });
338
+ if (!response.ok) return { ok: false, code: 'CREW_BACKEND_UNREACHABLE' };
339
+ const body = await response.json();
340
+ return body?.ok === true ? { ok: true } : { ok: false, code: 'CREW_BACKEND_UNREADY' };
341
+ } catch (error) {
342
+ return { ok: false, code: 'CREW_BACKEND_UNREACHABLE', error: String(error?.message ?? error) };
343
+ }
344
+ }
345
+
346
+ export async function proxyCrewRequest(req, res, {
347
+ fetchImpl = globalThis.fetch,
348
+ ensureBackend = () => probeCrewBackend(fetchImpl, resolveCrewBridgeTarget()),
349
+ bridgeTarget = resolveCrewBridgeTarget(),
350
+ } = {}) {
351
+ if (!isTrustedLocalRequest(req)) {
352
+ sendJson(res, 403, { ok: false, code: 'LOCAL_SAME_ORIGIN_ONLY' });
353
+ return;
354
+ }
355
+ let pathname;
356
+ try { pathname = new URL(req.url, 'http://127.0.0.1:3080').pathname; } catch { pathname = ''; }
357
+ if (pathname !== CREW_BRIDGE_PREFIX && !pathname.startsWith(`${CREW_BRIDGE_PREFIX}/`)) {
358
+ sendJson(res, 404, { ok: false, code: 'NOT_FOUND' });
359
+ return;
360
+ }
361
+ try {
362
+ const backend = await ensureBackend();
363
+ if (backend?.ok === false) throw new Error('backend unavailable');
364
+ const method = String(req.method ?? 'GET').toUpperCase();
365
+ const bodyBuffer = method === 'GET' || method === 'HEAD' ? null : await readBoundedBody(req);
366
+ const requestHeaders = safeHeaders(req.headers);
367
+ for (const name of Object.keys(requestHeaders)) {
368
+ if (name.startsWith('x-dsh-crew-')) delete requestHeaders[name];
369
+ }
370
+ requestHeaders['x-dsh-crew-bridge'] = '3080-to-3210';
371
+ requestHeaders['x-dsh-crew-execution-plane'] = 'hub-3210';
372
+ requestHeaders['x-dsh-crew-ingress'] = 'official-3080';
373
+ const response = await fetchImpl(`${bridgeTarget}${req.url}`, {
374
+ method,
375
+ headers: requestHeaders,
376
+ body: bodyBuffer === null ? undefined : new Blob([bodyBuffer]),
377
+ signal: AbortSignal.timeout(120_000),
378
+ });
379
+ const responseBody = Buffer.from(await response.arrayBuffer());
380
+ const headers = safeHeaders(Object.fromEntries(response.headers.entries()));
381
+ for (const name of Object.keys(headers)) {
382
+ if (name.startsWith('x-dsh-crew-')) delete headers[name];
383
+ }
384
+ headers['content-length'] = String(responseBody.length);
385
+ headers['x-dsh-crew-bridge'] = '3080-to-3210';
386
+ headers['x-dsh-crew-ingress'] = 'official-3080';
387
+ res.writeHead(response.status, headers);
388
+ res.end(responseBody);
389
+ } catch (error) {
390
+ if (error?.code === 'BODY_TOO_LARGE') sendJson(res, 413, { ok: false, code: 'REQUEST_TOO_LARGE' });
391
+ else sendJson(res, 503, { ok: false, code: 'CREW_BACKEND_UNAVAILABLE' });
392
+ }
393
+ }
394
+
395
+ export function registerOfficialWebBridge(ctx, options = {}) {
396
+ return ctx.inject(['webServer'], (webCtx) => {
397
+ const disposeStatus = webCtx.webServer.register({
398
+ kind: 'exact',
399
+ path: `${CREW_BRIDGE_PREFIX}/bridge-status`,
400
+ handler: (req, res) => {
401
+ if (!isTrustedLocalRequest(req)) return sendJson(res, 403, { ok: false, code: 'LOCAL_SAME_ORIGIN_ONLY' });
402
+ return sendJson(res, 200, {
403
+ ok: true,
404
+ mode: 'official-3080-quick-controls',
405
+ surface: 'official-bridge',
406
+ ui_role: 'quick-controls',
407
+ execution_plane: 'hub-3210',
408
+ listen_port: 3210,
409
+ full_control_plane_url: 'http://127.0.0.1:3210/',
410
+ });
411
+ },
412
+ });
413
+ // The legacy supervisor endpoint moved to the durable 3210 restart-
414
+ // request channel executed by the Windows launcher. Report 410 Gone so
415
+ // stale callers learn the new contract instead of failing opaquely.
416
+ const disposeRestart = webCtx.webServer.register({
417
+ kind: 'exact',
418
+ path: `${CREW_BRIDGE_PREFIX}/supervisor/restart`,
419
+ handler: (req, res) => {
420
+ if (!isTrustedLocalRequest(req)) return sendJson(res, 403, { ok: false, code: 'LOCAL_SAME_ORIGIN_ONLY' });
421
+ return sendJson(res, 410, { ok: false, code: 'SUPERVISOR_ENDPOINT_MOVED', control_plane: 'http://127.0.0.1:3210/' });
422
+ },
423
+ });
424
+ // Least-privilege allowlist: the 3080 quick-controls surface may reach
425
+ // ONLY the narrow quick endpoints on 3210. Full Crew API (providers,
426
+ // credentials, install, workspaces, migration, rollback, quarantine)
427
+ // is no longer proxied here — the native 3210 page is the full control
428
+ // plane.
429
+ const QUICK_ALLOWLIST = new Set([
430
+ '/quick-config',
431
+ '/quick-status',
432
+ ]);
433
+ const disposers = [];
434
+ for (const path of QUICK_ALLOWLIST) {
435
+ disposers.push(webCtx.webServer.register({
436
+ kind: 'exact',
437
+ path: `${CREW_BRIDGE_PREFIX}${path}`,
438
+ handler: (req, res) => proxyCrewRequest(req, res, options),
439
+ }));
440
+ }
441
+ return () => { for (const dispose of disposers) dispose?.(); disposeRestart?.(); disposeStatus?.(); };
442
+ });
443
+ }
444
+
445
+ export async function apply(ctx) {
446
+ registerOfficialWebBridge(ctx);
447
+ }