@ran-sh/dsh-crew 1.0.2 → 1.1.0
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/README.md +4 -0
- package/README.zh.md +4 -0
- package/codex/AGENTS.md +101 -92
- package/docs/ui-surfaces.md +48 -21
- package/lib/client.js +235 -34
- package/official-web-bridge/lib/client.js +368 -4532
- package/official-web-bridge/package.json +1 -2
- package/package.json +130 -67
- package/scripts/build-client.mjs +30 -15
- package/scripts/remove-legacy-official-bridge.ps1 +89 -0
- package/scripts/setup.mjs +152 -140
- package/scripts/verify-npm-install.mjs +311 -310
- package/scripts/verify-official-bridge-e2e.mjs +12 -1
- package/src/client/host-readiness.mjs +62 -59
- package/src/client/index.tsx +117 -24
- package/src/client/quick-entry.tsx +10 -0
- package/src/client/quick-panel.tsx +295 -0
- package/src/client/surface-detection.mjs +43 -30
- package/src/credential-reference.mjs +38 -0
- package/src/dsh-cli-runtime.mjs +500 -40
- package/src/dsh-cohort.mjs +20 -0
- package/src/hub/index.mjs +1602 -1016
- package/src/install/npx-lifecycle.mjs +1983 -468
- package/src/install/official-web.mjs +36 -73
- package/src/jobs.mjs +20 -24
- package/src/model-catalog.mjs +8 -1
- package/src/official-web-bridge.mjs +329 -249
- package/src/provider-delete-adapters.mjs +165 -17
- package/src/provider-inventory.mjs +17 -3
- package/src/provider-layer-migration-adapters.mjs +759 -0
- package/src/provider-layer-migration.mjs +198 -0
- package/src/provider-lifecycle-state.mjs +4 -1
- package/src/provider-profile-store.mjs +193 -4
- package/src/provider-settings-store.mjs +82 -6
- package/src/provider-store-lock.mjs +67 -0
- package/src/runtime-identity.mjs +24 -1
- package/src/supervisor/restart-request.mjs +202 -0
- package/windows/start-dsh-crew.ps1 +783 -370
- package/worker.cordis.yml +32 -46
- package/zcode/AGENTS.md +35 -26
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { crewDshHome } from './install/install.mjs';
|
|
@@ -16,11 +16,11 @@ const HOP_BY_HOP = new Set([
|
|
|
16
16
|
'te', 'trailer', 'transfer-encoding', 'upgrade', 'host', 'content-length',
|
|
17
17
|
]);
|
|
18
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
|
-
}
|
|
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
24
|
|
|
25
25
|
export function isTrustedLocalRequest(req) {
|
|
26
26
|
if (!localRequestCore(req)) return false;
|
|
@@ -65,132 +65,142 @@ async function readBoundedBody(req, limit = MAX_BODY_BYTES) {
|
|
|
65
65
|
return Buffer.concat(chunks);
|
|
66
66
|
}
|
|
67
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
|
-
}
|
|
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
89
|
|
|
90
90
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
91
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;
|
|
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
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) {
|
|
134
|
-
if (!Number.isInteger(pid) || pid < 1) return false;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
if (
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
if (
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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) {
|
|
194
204
|
runningChild = spawnImpl(process.execPath, [
|
|
195
205
|
runtime, '--profile', 'dsh-crew', '--host', '127.0.0.1', '--port', bridgePort,
|
|
196
206
|
], {
|
|
@@ -200,84 +210,142 @@ export function createCrewSidecarSupervisor({
|
|
|
200
210
|
stdio: 'ignore',
|
|
201
211
|
windowsHide: true,
|
|
202
212
|
});
|
|
203
|
-
const ownedChild = runningChild;
|
|
204
|
-
if (Number.isInteger(ownedChild?.pid) && !persistOwnership(ownedChild.pid)) {
|
|
205
|
-
try { ownedChild.kill?.(); } catch {}
|
|
206
|
-
runningChild = null;
|
|
207
|
-
return { ok: false, code: 'SUPERVISOR_OWNERSHIP_PERSIST_FAILED' };
|
|
208
|
-
}
|
|
209
|
-
ownedChild.once?.('exit', () => { if (runningChild === ownedChild) runningChild = null; });
|
|
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; });
|
|
210
220
|
ownedChild.unref?.();
|
|
211
221
|
}
|
|
212
222
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
213
|
-
if (await healthCheck()) {
|
|
214
|
-
if (Number.isInteger(runningChild?.pid)) {
|
|
215
|
-
const identity = await runtimeIdentity();
|
|
216
|
-
if (!identity) {
|
|
217
|
-
try { runningChild?.kill?.(); } catch {}
|
|
218
|
-
runningChild = null;
|
|
219
|
-
return { ok: false, code: 'SUPERVISOR_RUNTIME_IDENTITY_UNAVAILABLE' };
|
|
220
|
-
}
|
|
221
|
-
if (!persistOwnership(runningChild.pid, identity)) {
|
|
222
|
-
try { runningChild.kill?.(); } catch {}
|
|
223
|
-
runningChild = null;
|
|
224
|
-
return { ok: false, code: 'SUPERVISOR_OWNERSHIP_PERSIST_FAILED' };
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
return { ok: true, started: true, owned: true };
|
|
228
|
-
}
|
|
229
|
-
await wait(pollInterval);
|
|
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);
|
|
230
240
|
}
|
|
231
241
|
return { ok: false, code: 'CREW_BACKEND_START_TIMEOUT' };
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
async function restartOwnedBackend() {
|
|
235
|
-
let owned = runningChild;
|
|
236
|
-
let childAlive = owned && owned.killed !== true && owned.exitCode == null;
|
|
237
|
-
if (!childAlive && await healthCheck()) {
|
|
238
|
-
if (await adoptPersistedChild()) {
|
|
239
|
-
owned = runningChild;
|
|
240
|
-
childAlive = true;
|
|
241
|
-
} else {
|
|
242
|
-
return { ok: false, code: 'PORT_OWNERSHIP_CONFLICT' };
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
if (!childAlive) {
|
|
246
|
-
const started = await start();
|
|
247
|
-
return started.ok ? { ...started, restarted: started.started === true } : started;
|
|
248
|
-
}
|
|
249
|
-
if (typeof owned.kill !== 'function' || owned.kill() !== true) {
|
|
250
|
-
return { ok: false, code: 'CREW_BACKEND_RESTART_UNAVAILABLE' };
|
|
251
|
-
}
|
|
252
|
-
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
253
|
-
if (!await healthCheck()) break;
|
|
254
|
-
await wait(pollInterval);
|
|
255
|
-
}
|
|
256
|
-
if (await healthCheck()) return { ok: false, code: 'CREW_BACKEND_STOP_TIMEOUT' };
|
|
257
|
-
const started = await start();
|
|
258
|
-
return started.ok ? { ...started, restarted: true } : started;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
return {
|
|
262
|
-
execution_plane: 'hub-3210',
|
|
263
|
-
profile: 'dsh-crew',
|
|
264
|
-
listen_port: Number(bridgePort),
|
|
265
|
-
ensure() {
|
|
266
|
-
if (!starting) starting = start().finally(() => { starting = null; });
|
|
267
|
-
return starting;
|
|
268
|
-
},
|
|
269
|
-
restartOwnedBackend() {
|
|
270
|
-
if (!restarting) restarting = restartOwnedBackend().finally(() => { restarting = null; });
|
|
271
|
-
return restarting;
|
|
272
|
-
},
|
|
273
|
-
};
|
|
274
|
-
}
|
|
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
|
+
}
|
|
275
270
|
|
|
276
|
-
|
|
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
|
+
}
|
|
277
345
|
|
|
278
346
|
export async function proxyCrewRequest(req, res, {
|
|
279
347
|
fetchImpl = globalThis.fetch,
|
|
280
|
-
ensureBackend = () =>
|
|
348
|
+
ensureBackend = () => probeCrewBackend(fetchImpl, resolveCrewBridgeTarget()),
|
|
281
349
|
bridgeTarget = resolveCrewBridgeTarget(),
|
|
282
350
|
} = {}) {
|
|
283
351
|
if (!isTrustedLocalRequest(req)) {
|
|
@@ -293,29 +361,29 @@ export async function proxyCrewRequest(req, res, {
|
|
|
293
361
|
try {
|
|
294
362
|
const backend = await ensureBackend();
|
|
295
363
|
if (backend?.ok === false) throw new Error('backend unavailable');
|
|
296
|
-
const method = String(req.method ?? 'GET').toUpperCase();
|
|
297
|
-
const bodyBuffer = method === 'GET' || method === 'HEAD' ? null : await readBoundedBody(req);
|
|
298
|
-
const requestHeaders = safeHeaders(req.headers);
|
|
299
|
-
for (const name of Object.keys(requestHeaders)) {
|
|
300
|
-
if (name.startsWith('x-dsh-crew-')) delete requestHeaders[name];
|
|
301
|
-
}
|
|
302
|
-
requestHeaders['x-dsh-crew-bridge'] = '3080-to-3210';
|
|
303
|
-
requestHeaders['x-dsh-crew-execution-plane'] = 'hub-3210';
|
|
304
|
-
requestHeaders['x-dsh-crew-ingress'] = 'official-3080';
|
|
305
|
-
const response = await fetchImpl(`${bridgeTarget}${req.url}`, {
|
|
306
|
-
method,
|
|
307
|
-
headers: requestHeaders,
|
|
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,
|
|
308
376
|
body: bodyBuffer === null ? undefined : new Blob([bodyBuffer]),
|
|
309
377
|
signal: AbortSignal.timeout(120_000),
|
|
310
378
|
});
|
|
311
|
-
const responseBody = Buffer.from(await response.arrayBuffer());
|
|
312
|
-
const headers = safeHeaders(Object.fromEntries(response.headers.entries()));
|
|
313
|
-
for (const name of Object.keys(headers)) {
|
|
314
|
-
if (name.startsWith('x-dsh-crew-')) delete headers[name];
|
|
315
|
-
}
|
|
316
|
-
headers['content-length'] = String(responseBody.length);
|
|
317
|
-
headers['x-dsh-crew-bridge'] = '3080-to-3210';
|
|
318
|
-
headers['x-dsh-crew-ingress'] = 'official-3080';
|
|
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';
|
|
319
387
|
res.writeHead(response.status, headers);
|
|
320
388
|
res.end(responseBody);
|
|
321
389
|
} catch (error) {
|
|
@@ -324,45 +392,57 @@ export async function proxyCrewRequest(req, res, {
|
|
|
324
392
|
}
|
|
325
393
|
}
|
|
326
394
|
|
|
327
|
-
export function registerOfficialWebBridge(ctx, options = {}) {
|
|
328
|
-
return ctx.inject(['webServer'], (webCtx) => {
|
|
329
|
-
const
|
|
330
|
-
const disposeStatus = webCtx.webServer.register({
|
|
395
|
+
export function registerOfficialWebBridge(ctx, options = {}) {
|
|
396
|
+
return ctx.inject(['webServer'], (webCtx) => {
|
|
397
|
+
const disposeStatus = webCtx.webServer.register({
|
|
331
398
|
kind: 'exact',
|
|
332
399
|
path: `${CREW_BRIDGE_PREFIX}/bridge-status`,
|
|
333
400
|
handler: (req, res) => {
|
|
334
401
|
if (!isTrustedLocalRequest(req)) return sendJson(res, 403, { ok: false, code: 'LOCAL_SAME_ORIGIN_ONLY' });
|
|
335
402
|
return sendJson(res, 200, {
|
|
336
403
|
ok: true,
|
|
337
|
-
mode: 'official-3080-
|
|
338
|
-
surface: 'official-bridge',
|
|
339
|
-
ui_role: '
|
|
340
|
-
execution_plane: 'hub-3210',
|
|
341
|
-
listen_port: 3210,
|
|
342
|
-
|
|
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/' });
|
|
343
422
|
},
|
|
344
|
-
});
|
|
345
|
-
const disposeRestart = webCtx.webServer.register({
|
|
346
|
-
kind: 'exact',
|
|
347
|
-
path: `${CREW_BRIDGE_PREFIX}/supervisor/restart`,
|
|
348
|
-
handler: async (req, res) => {
|
|
349
|
-
if (!isTrustedLocalRequest(req)) return sendJson(res, 403, { ok: false, code: 'LOCAL_SAME_ORIGIN_ONLY' });
|
|
350
|
-
if (String(req.method ?? 'GET').toUpperCase() !== 'POST') return sendJson(res, 405, { ok: false, code: 'POST_ONLY' });
|
|
351
|
-
let body;
|
|
352
|
-
try { body = JSON.parse((await readBoundedBody(req, 8 * 1024)).toString('utf8') || '{}'); } catch { return sendJson(res, 400, { ok: false, code: 'SUPERVISOR_REQUEST_INVALID' }); }
|
|
353
|
-
if (body?.confirm !== true) return sendJson(res, 400, { ok: false, code: 'SUPERVISOR_RESTART_CONFIRM_REQUIRED' });
|
|
354
|
-
const result = await supervisor.restartOwnedBackend();
|
|
355
|
-
return sendJson(res, result?.ok === true ? 200 : 409, { ...result });
|
|
356
|
-
},
|
|
357
|
-
});
|
|
358
|
-
const disposeProxy = webCtx.webServer.register({
|
|
359
|
-
kind: 'prefix',
|
|
360
|
-
path: CREW_BRIDGE_PREFIX,
|
|
361
|
-
handler: (req, res) => proxyCrewRequest(req, res, options),
|
|
362
423
|
});
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
+
'/runtime/restart-request',
|
|
433
|
+
'/runtime/restart-status',
|
|
434
|
+
]);
|
|
435
|
+
const disposers = [];
|
|
436
|
+
for (const path of QUICK_ALLOWLIST) {
|
|
437
|
+
disposers.push(webCtx.webServer.register({
|
|
438
|
+
kind: 'exact',
|
|
439
|
+
path: `${CREW_BRIDGE_PREFIX}${path}`,
|
|
440
|
+
handler: (req, res) => proxyCrewRequest(req, res, options),
|
|
441
|
+
}));
|
|
442
|
+
}
|
|
443
|
+
return () => { for (const dispose of disposers) dispose?.(); disposeRestart?.(); disposeStatus?.(); };
|
|
444
|
+
});
|
|
445
|
+
}
|
|
366
446
|
|
|
367
447
|
export async function apply(ctx) {
|
|
368
448
|
registerOfficialWebBridge(ctx);
|