@ran-sh/dsh-crew 1.8.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.
- package/.claude-plugin/marketplace.json +16 -16
- package/.claude-plugin/plugin.json +14 -14
- package/LICENSE +21 -21
- package/README.de.md +359 -359
- package/README.es.md +359 -359
- package/README.fr.md +359 -359
- package/README.hi.md +359 -359
- package/README.id.md +359 -359
- package/README.ja.md +359 -359
- package/README.ko.md +359 -359
- package/README.md +126 -126
- package/README.pt.md +359 -359
- package/README.ru.md +359 -359
- package/README.th.md +359 -359
- package/README.tr.md +359 -359
- package/README.vi.md +359 -359
- package/README.zh-TW.md +359 -359
- package/README.zh.md +116 -116
- package/agents/ds-flash.md +24 -24
- package/agents/ds-pro.md +24 -24
- package/agents/ds-reviewer.md +23 -23
- package/agents/ds-worker.md +23 -23
- package/bin/dsh-crew.mjs +16 -16
- package/codex/agents/ds-flash.toml +34 -34
- package/codex/agents/ds-pro.toml +34 -34
- package/codex/agents/ds-reviewer.toml +32 -32
- package/codex/agents/ds-worker.toml +32 -32
- package/codex/prompts/dsh-config.md +19 -19
- package/codex/prompts/dsh-status.md +5 -5
- package/commands/config.md +23 -23
- package/commands/off.md +5 -5
- package/commands/on.md +5 -5
- package/commands/status.md +9 -9
- package/cordis.patch.yml +4 -4
- package/docs/gpt-relay-extension.md +103 -103
- package/docs/installation.md +138 -138
- package/docs/job-contracts.md +103 -103
- package/docs/readiness-matrix.md +85 -85
- package/docs/ui-surfaces.md +107 -107
- package/official-web-bridge/cordis.patch.yml +4 -4
- package/official-web-bridge/entry.mjs +1 -1
- package/official-web-bridge/overlay-entry.mjs +59 -59
- package/official-web-bridge/package.json +25 -25
- package/package.json +1 -1
- package/scripts/build-client.mjs +49 -49
- package/scripts/live-crew-smoke.mjs +39 -39
- package/scripts/live-policy-matrix.mjs +177 -177
- package/scripts/policy-probe.mjs +101 -101
- package/scripts/remove-legacy-official-bridge.ps1 +89 -89
- package/scripts/setup.mjs +393 -393
- package/scripts/smoke-real.mjs +110 -110
- package/scripts/smoke.mjs +78 -78
- package/scripts/verify-crew-ui-polish.mjs +145 -145
- package/scripts/verify-history-ui.mjs +97 -97
- package/scripts/verify-installer-fix.mjs +26 -26
- package/scripts/verify-npm-install.mjs +311 -311
- package/scripts/verify-official-bridge-e2e.mjs +192 -192
- package/src/adaptive-routing.mjs +260 -260
- package/src/client/activation-summary.tsx +64 -64
- package/src/client/collapsible-sections.mjs +55 -55
- package/src/client/history-panel.tsx +108 -108
- package/src/client/host-readiness.mjs +71 -71
- package/src/client/index.tsx +1711 -1711
- package/src/client/model-callability-view.mjs +17 -17
- package/src/client/panel-chrome.tsx +40 -40
- package/src/client/quick-entry.tsx +10 -10
- package/src/client/quick-panel.tsx +275 -275
- package/src/client/readiness-envelope.mjs +34 -34
- package/src/client/surface-detection.mjs +43 -43
- package/src/config-readiness.mjs +226 -226
- package/src/credential-reference.mjs +38 -38
- package/src/delivery.mjs +205 -205
- package/src/dsh-cli-runtime.mjs +1021 -1021
- package/src/dsh-cohort.mjs +20 -20
- package/src/extension-contract.mjs +104 -104
- package/src/failure-classification.mjs +201 -201
- package/src/history/admission-gate.mjs +67 -67
- package/src/history/archive-store.mjs +272 -272
- package/src/history/cleanup-plan.mjs +89 -89
- package/src/history/http.mjs +32 -32
- package/src/history/operation.mjs +86 -86
- package/src/history/runner-detach.mjs +34 -34
- package/src/history/runner.mjs +52 -52
- package/src/history/runtime.mjs +36 -36
- package/src/history/service.mjs +177 -177
- package/src/history/state.mjs +27 -27
- package/src/hub/entry.mjs +104 -104
- package/src/hub/index.mjs +2694 -2694
- package/src/hub-client.mjs +154 -154
- package/src/hub-compatibility.mjs +42 -42
- package/src/i18n.mjs +19 -19
- package/src/information-flow.mjs +67 -67
- package/src/install/cli.mjs +28 -28
- package/src/install/install-legacy.mjs +711 -711
- package/src/install/install.mjs +483 -483
- package/src/install/npx-lifecycle.mjs +3456 -3456
- package/src/install/official-frontend-assets.mjs +78 -78
- package/src/install/official-web.mjs +95 -95
- package/src/install/payload-content.mjs +88 -88
- package/src/install/windows-startup.mjs +236 -236
- package/src/install/windows-supervisor-adapter.mjs +443 -443
- package/src/install/windows-supervisor-lifecycle.mjs +782 -782
- package/src/install/zcode.mjs +397 -397
- package/src/job-contracts.mjs +255 -255
- package/src/local-request-guard.mjs +60 -60
- package/src/mcp-runtime.mjs +340 -332
- package/src/model-callability-contract.mjs +79 -79
- package/src/model-catalog.mjs +180 -180
- package/src/model-routing.mjs +586 -586
- package/src/model-schedule.mjs +207 -207
- package/src/official-web-bridge.mjs +447 -447
- package/src/policy.mjs +235 -235
- package/src/provider-delete-adapters.mjs +1934 -1934
- package/src/provider-health.mjs +130 -130
- package/src/provider-inventory.mjs +182 -182
- package/src/provider-layer-migration-adapters.mjs +759 -759
- package/src/provider-layer-migration.mjs +198 -198
- package/src/provider-lifecycle-state.mjs +103 -103
- package/src/provider-lifecycle.mjs +252 -252
- package/src/provider-profile-store.mjs +390 -390
- package/src/provider-settings-store.mjs +633 -633
- package/src/provider-store-lock.mjs +67 -67
- package/src/readiness-matrix.mjs +181 -181
- package/src/removable-waiter.mjs +29 -29
- package/src/role-profiles.mjs +107 -107
- package/src/runtime-controls.mjs +84 -84
- package/src/runtime-identity-contract.mjs +34 -34
- package/src/runtime-identity.mjs +235 -235
- package/src/runtime-readiness-snapshot.mjs +285 -285
- package/src/server.mjs +581 -581
- package/src/session-origins.mjs +60 -60
- package/src/standalone-sdk.mjs +23 -23
- package/src/status-shard.mjs +63 -63
- package/src/structured-error-code.mjs +38 -38
- package/src/supervisor/restart-request.mjs +256 -256
- package/src/workflow-runtime.mjs +739 -739
- package/src/workflow.mjs +155 -155
- package/src/workspace-audit.mjs +231 -231
- package/src/workspace-context.mjs +146 -146
- package/src/workspace-isolation.mjs +455 -401
- package/src/workspace-readiness.mjs +32 -32
- package/statusline/statusline.sh +14 -14
- package/statusline/worker-segment.sh +35 -35
- package/windows/start-dsh-crew.cmd +57 -57
- package/windows/start-dsh-crew.ps1 +1302 -1302
- package/windows/supervisor-control.ps1 +467 -467
- package/worker.cordis.yml +67 -67
- package/zcode/agents/ds-reviewer.md +31 -31
- package/zcode/agents/ds-worker.md +31 -31
- package/zcode/commands/dsh-config.md +17 -17
- 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
|
+
}
|