@ran-sh/dsh-crew 0.5.6 → 0.5.7
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 +14 -7
- package/README.zh.md +12 -7
- package/docs/installation.md +1 -1
- package/docs/ui-surfaces.md +65 -46
- package/lib/client.js +345 -22
- package/official-web-bridge/lib/client.js +345 -22
- package/package.json +1 -1
- package/src/client/collapsible-sections.mjs +3 -2
- package/src/client/index.tsx +227 -51
- package/src/config-readiness.mjs +56 -5
- package/src/extension-contract.mjs +9 -5
- package/src/hub/index.mjs +854 -445
- package/src/hub-client.mjs +24 -4
- package/src/hub-compatibility.mjs +23 -7
- package/src/install/install.mjs +4 -4
- package/src/install/npx-lifecycle.mjs +263 -118
- package/src/job-contracts.mjs +19 -3
- package/src/jobs.mjs +2 -4
- package/src/local-request-guard.mjs +60 -0
- package/src/mcp-runtime.mjs +7 -5
- package/src/model-routing.mjs +141 -49
- package/src/multimodal.mjs +0 -0
- package/src/official-web-bridge.mjs +242 -84
- package/src/policy.mjs +62 -31
- package/src/provider-config-scrub.mjs +71 -0
- package/src/provider-delete-adapters.mjs +424 -0
- package/src/provider-health.mjs +124 -0
- package/src/provider-inventory.mjs +132 -0
- package/src/provider-lifecycle-state.mjs +81 -0
- package/src/provider-lifecycle.mjs +214 -0
- package/src/provider-profile-store.mjs +171 -0
- package/src/readiness-matrix.mjs +11 -5
- package/src/removable-waiter.mjs +29 -0
- package/src/runtime-identity.mjs +43 -13
- package/src/server.mjs +34 -16
- package/src/status-shard.mjs +13 -2
- package/src/workflow-runtime.mjs +44 -10
- package/windows/start-dsh-crew.cmd +3 -3
- package/windows/start-dsh-crew.ps1 +49 -5
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import { existsSync } 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';
|
|
6
6
|
import { crewDshRuntimeModule } from './dsh-cli-runtime.mjs';
|
|
7
|
+
import { isLocalHostname, isLoopbackAddress, localRequestCore, originAuthorityMatches } from './local-request-guard.mjs';
|
|
8
|
+
|
|
9
|
+
export { isLoopbackAddress };
|
|
7
10
|
|
|
8
11
|
export const CREW_BRIDGE_PREFIX = '/_dsh/dsh-crew';
|
|
9
12
|
export const CREW_BRIDGE_TARGET = 'http://127.0.0.1:3210';
|
|
@@ -13,14 +16,6 @@ const HOP_BY_HOP = new Set([
|
|
|
13
16
|
'te', 'trailer', 'transfer-encoding', 'upgrade', 'host', 'content-length',
|
|
14
17
|
]);
|
|
15
18
|
|
|
16
|
-
export function isLoopbackAddress(address) {
|
|
17
|
-
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function isLocalHostname(hostname) {
|
|
21
|
-
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1';
|
|
22
|
-
}
|
|
23
|
-
|
|
24
19
|
export function resolveCrewBridgeTarget(env = process.env) {
|
|
25
20
|
const raw = env?.DSH_CREW_BRIDGE_TARGET;
|
|
26
21
|
if (!raw) return CREW_BRIDGE_TARGET;
|
|
@@ -34,22 +29,12 @@ export function resolveCrewBridgeTarget(env = process.env) {
|
|
|
34
29
|
}
|
|
35
30
|
|
|
36
31
|
export function isTrustedLocalRequest(req) {
|
|
37
|
-
if (!
|
|
32
|
+
if (!localRequestCore(req)) return false;
|
|
38
33
|
const host = typeof req?.headers?.host === 'string' ? req.headers.host.trim().toLowerCase() : '';
|
|
39
34
|
if (!host) return false;
|
|
40
|
-
let authority;
|
|
41
|
-
try { authority = new URL(`http://${host}`); } catch { return false; }
|
|
42
|
-
if (!isLocalHostname(authority.hostname.toLowerCase())) return false;
|
|
43
|
-
const fetchSite = String(req?.headers?.['sec-fetch-site'] ?? '').toLowerCase();
|
|
44
|
-
if (fetchSite && fetchSite !== 'same-origin' && fetchSite !== 'none') return false;
|
|
45
35
|
const origin = req?.headers?.origin;
|
|
46
|
-
if (origin
|
|
47
|
-
|
|
48
|
-
let parsedOrigin;
|
|
49
|
-
try { parsedOrigin = new URL(origin); } catch { return false; }
|
|
50
|
-
if (!isLocalHostname(parsedOrigin.hostname.toLowerCase()) || parsedOrigin.host.toLowerCase() !== host) return false;
|
|
51
|
-
}
|
|
52
|
-
return true;
|
|
36
|
+
if (origin === undefined) return true;
|
|
37
|
+
return originAuthorityMatches(origin, host);
|
|
53
38
|
}
|
|
54
39
|
|
|
55
40
|
function safeHeaders(source) {
|
|
@@ -86,39 +71,131 @@ async function readBoundedBody(req, limit = MAX_BODY_BYTES) {
|
|
|
86
71
|
return Buffer.concat(chunks);
|
|
87
72
|
}
|
|
88
73
|
|
|
89
|
-
async function
|
|
90
|
-
try {
|
|
91
|
-
const response = await fetchImpl(`${bridgeTarget}${CREW_BRIDGE_PREFIX}/
|
|
92
|
-
signal: AbortSignal.timeout(1_500),
|
|
93
|
-
headers: { accept: 'application/json' },
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
74
|
+
export async function defaultRuntimeIdentity(fetchImpl = globalThis.fetch, bridgeTarget = CREW_BRIDGE_TARGET) {
|
|
75
|
+
try {
|
|
76
|
+
const response = await fetchImpl(`${bridgeTarget}${CREW_BRIDGE_PREFIX}/runtime`, {
|
|
77
|
+
signal: AbortSignal.timeout(1_500),
|
|
78
|
+
headers: { accept: 'application/json' },
|
|
79
|
+
});
|
|
80
|
+
if (!response.ok) return null;
|
|
81
|
+
const body = await response.json();
|
|
82
|
+
if (!(body?.ok === true
|
|
83
|
+
&& body.execution_plane === 'hub-3210'
|
|
84
|
+
&& body.profile === 'dsh-crew'
|
|
85
|
+
&& Number(body.listen_port) === 3210
|
|
86
|
+
&& typeof body.runtime_id === 'string'
|
|
87
|
+
&& body.runtime_id.trim().length > 0)) return null;
|
|
88
|
+
return { execution_plane: body.execution_plane, profile: body.profile, listen_port: Number(body.listen_port), runtime_id: body.runtime_id.trim() };
|
|
89
|
+
} catch { return null; }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function defaultHealthCheck(fetchImpl = globalThis.fetch, bridgeTarget = CREW_BRIDGE_TARGET) {
|
|
93
|
+
return (await defaultRuntimeIdentity(fetchImpl, bridgeTarget)) !== null;
|
|
94
|
+
}
|
|
98
95
|
|
|
99
96
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
100
97
|
|
|
101
|
-
export function createCrewSidecarSupervisor({
|
|
102
|
-
home = homedir(),
|
|
103
|
-
exists = existsSync,
|
|
104
|
-
bridgeTarget = resolveCrewBridgeTarget(),
|
|
105
|
-
healthCheck = () => defaultHealthCheck(globalThis.fetch, bridgeTarget),
|
|
106
|
-
spawnImpl = spawn,
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
98
|
+
export function createCrewSidecarSupervisor({
|
|
99
|
+
home = homedir(),
|
|
100
|
+
exists = existsSync,
|
|
101
|
+
bridgeTarget = resolveCrewBridgeTarget(),
|
|
102
|
+
healthCheck = () => defaultHealthCheck(globalThis.fetch, bridgeTarget),
|
|
103
|
+
spawnImpl = spawn,
|
|
104
|
+
killImpl = (pid) => process.kill(pid),
|
|
105
|
+
runtimeIdentity = () => defaultRuntimeIdentity(globalThis.fetch, bridgeTarget),
|
|
106
|
+
wait = delay,
|
|
107
|
+
maxAttempts = 120,
|
|
108
|
+
pollInterval = 250,
|
|
109
|
+
ownershipFile = join(crewDshHome({ home }), 'supervisor-ownership.json'),
|
|
110
|
+
} = {}) {
|
|
111
|
+
let starting = null;
|
|
112
|
+
let restarting = null;
|
|
113
|
+
let runningChild = null;
|
|
113
114
|
const runtime = crewDshRuntimeModule({ home });
|
|
114
|
-
const dshHome = crewDshHome({ home });
|
|
115
|
-
const bridgePort = new URL(bridgeTarget).port;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
115
|
+
const dshHome = crewDshHome({ home });
|
|
116
|
+
const bridgePort = new URL(bridgeTarget).port;
|
|
117
|
+
|
|
118
|
+
function readOwnership() {
|
|
119
|
+
try {
|
|
120
|
+
const record = JSON.parse(readFileSync(ownershipFile, 'utf8'));
|
|
121
|
+
if (record?.schema_version !== 1
|
|
122
|
+
|| !Number.isInteger(record.pid) || record.pid < 1
|
|
123
|
+
|| record.profile !== 'dsh-crew'
|
|
124
|
+
|| record.execution_plane !== 'hub-3210'
|
|
125
|
+
|| Number(record.port) !== Number(bridgePort)) return null;
|
|
126
|
+
return record;
|
|
127
|
+
} catch { return null; }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function clearOwnership(expectedPid) {
|
|
131
|
+
try {
|
|
132
|
+
const current = readOwnership();
|
|
133
|
+
if (expectedPid !== undefined && current?.pid !== expectedPid) return;
|
|
134
|
+
rmSync(ownershipFile, { force: true });
|
|
135
|
+
} catch {}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function persistOwnership(pid, identity = null) {
|
|
139
|
+
if (!Number.isInteger(pid) || pid < 1) return false;
|
|
140
|
+
try {
|
|
141
|
+
mkdirSync(dshHome, { recursive: true });
|
|
142
|
+
const temp = `${ownershipFile}.${process.pid}.${Date.now()}.tmp`;
|
|
143
|
+
writeFileSync(temp, JSON.stringify({
|
|
144
|
+
schema_version: 1,
|
|
145
|
+
pid,
|
|
146
|
+
profile: 'dsh-crew',
|
|
147
|
+
execution_plane: 'hub-3210',
|
|
148
|
+
port: Number(bridgePort),
|
|
149
|
+
runtime_id: typeof identity?.runtime_id === 'string' ? identity.runtime_id : null,
|
|
150
|
+
}) + '\n');
|
|
151
|
+
renameSync(temp, ownershipFile);
|
|
152
|
+
return true;
|
|
153
|
+
} catch { return false; }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function adoptedChild(pid) {
|
|
157
|
+
return {
|
|
158
|
+
pid,
|
|
159
|
+
killed: false,
|
|
160
|
+
exitCode: null,
|
|
161
|
+
kill() {
|
|
162
|
+
try {
|
|
163
|
+
killImpl(pid);
|
|
164
|
+
this.killed = true;
|
|
165
|
+
this.exitCode = 0;
|
|
166
|
+
clearOwnership(pid);
|
|
167
|
+
return true;
|
|
168
|
+
} catch { return false; }
|
|
169
|
+
},
|
|
170
|
+
unref() {},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function adoptPersistedChild() {
|
|
175
|
+
if (runningChild && runningChild.killed !== true && runningChild.exitCode == null) return true;
|
|
176
|
+
const record = readOwnership();
|
|
177
|
+
if (!record || !(await healthCheck())) {
|
|
178
|
+
if (record) clearOwnership(record.pid);
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
const identity = await runtimeIdentity();
|
|
182
|
+
if (!identity || (record.runtime_id && identity.runtime_id !== record.runtime_id)) {
|
|
183
|
+
clearOwnership(record.pid);
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
runningChild = adoptedChild(record.pid);
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function start() {
|
|
191
|
+
const childAlive = runningChild && runningChild.killed !== true && runningChild.exitCode == null;
|
|
192
|
+
if (await healthCheck()) {
|
|
193
|
+
if (childAlive) return { ok: true, started: false, owned: true };
|
|
194
|
+
if (await adoptPersistedChild()) return { ok: true, started: false, adopted: true, owned: true };
|
|
195
|
+
return { ok: false, code: 'PORT_OWNERSHIP_CONFLICT' };
|
|
196
|
+
}
|
|
197
|
+
if (!exists(runtime)) return { ok: false, code: 'CREW_RUNTIME_NOT_INSTALLED' };
|
|
198
|
+
if (!childAlive) {
|
|
122
199
|
runningChild = spawnImpl(process.execPath, [
|
|
123
200
|
runtime, '--profile', 'dsh-crew', '--host', '127.0.0.1', '--port', bridgePort,
|
|
124
201
|
], {
|
|
@@ -128,24 +205,78 @@ export function createCrewSidecarSupervisor({
|
|
|
128
205
|
stdio: 'ignore',
|
|
129
206
|
windowsHide: true,
|
|
130
207
|
});
|
|
131
|
-
const ownedChild = runningChild;
|
|
132
|
-
ownedChild
|
|
208
|
+
const ownedChild = runningChild;
|
|
209
|
+
if (Number.isInteger(ownedChild?.pid) && !persistOwnership(ownedChild.pid)) {
|
|
210
|
+
try { ownedChild.kill?.(); } catch {}
|
|
211
|
+
runningChild = null;
|
|
212
|
+
return { ok: false, code: 'SUPERVISOR_OWNERSHIP_PERSIST_FAILED' };
|
|
213
|
+
}
|
|
214
|
+
ownedChild.once?.('exit', () => { if (runningChild === ownedChild) runningChild = null; });
|
|
133
215
|
ownedChild.unref?.();
|
|
134
216
|
}
|
|
135
217
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
136
|
-
if (await healthCheck())
|
|
137
|
-
|
|
218
|
+
if (await healthCheck()) {
|
|
219
|
+
if (Number.isInteger(runningChild?.pid)) {
|
|
220
|
+
const identity = await runtimeIdentity();
|
|
221
|
+
if (!identity) {
|
|
222
|
+
try { runningChild?.kill?.(); } catch {}
|
|
223
|
+
runningChild = null;
|
|
224
|
+
return { ok: false, code: 'SUPERVISOR_RUNTIME_IDENTITY_UNAVAILABLE' };
|
|
225
|
+
}
|
|
226
|
+
if (!persistOwnership(runningChild.pid, identity)) {
|
|
227
|
+
try { runningChild.kill?.(); } catch {}
|
|
228
|
+
runningChild = null;
|
|
229
|
+
return { ok: false, code: 'SUPERVISOR_OWNERSHIP_PERSIST_FAILED' };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return { ok: true, started: true, owned: true };
|
|
233
|
+
}
|
|
234
|
+
await wait(pollInterval);
|
|
138
235
|
}
|
|
139
236
|
return { ok: false, code: 'CREW_BACKEND_START_TIMEOUT' };
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function restartOwnedBackend() {
|
|
240
|
+
let owned = runningChild;
|
|
241
|
+
let childAlive = owned && owned.killed !== true && owned.exitCode == null;
|
|
242
|
+
if (!childAlive && await healthCheck()) {
|
|
243
|
+
if (await adoptPersistedChild()) {
|
|
244
|
+
owned = runningChild;
|
|
245
|
+
childAlive = true;
|
|
246
|
+
} else {
|
|
247
|
+
return { ok: false, code: 'PORT_OWNERSHIP_CONFLICT' };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (!childAlive) {
|
|
251
|
+
const started = await start();
|
|
252
|
+
return started.ok ? { ...started, restarted: started.started === true } : started;
|
|
253
|
+
}
|
|
254
|
+
if (typeof owned.kill !== 'function' || owned.kill() !== true) {
|
|
255
|
+
return { ok: false, code: 'CREW_BACKEND_RESTART_UNAVAILABLE' };
|
|
256
|
+
}
|
|
257
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
258
|
+
if (!await healthCheck()) break;
|
|
259
|
+
await wait(pollInterval);
|
|
260
|
+
}
|
|
261
|
+
if (await healthCheck()) return { ok: false, code: 'CREW_BACKEND_STOP_TIMEOUT' };
|
|
262
|
+
const started = await start();
|
|
263
|
+
return started.ok ? { ...started, restarted: true } : started;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
execution_plane: 'hub-3210',
|
|
268
|
+
profile: 'dsh-crew',
|
|
269
|
+
listen_port: Number(bridgePort),
|
|
270
|
+
ensure() {
|
|
271
|
+
if (!starting) starting = start().finally(() => { starting = null; });
|
|
272
|
+
return starting;
|
|
273
|
+
},
|
|
274
|
+
restartOwnedBackend() {
|
|
275
|
+
if (!restarting) restarting = restartOwnedBackend().finally(() => { restarting = null; });
|
|
276
|
+
return restarting;
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
149
280
|
|
|
150
281
|
const processSupervisor = createCrewSidecarSupervisor();
|
|
151
282
|
|
|
@@ -167,18 +298,29 @@ export async function proxyCrewRequest(req, res, {
|
|
|
167
298
|
try {
|
|
168
299
|
const backend = await ensureBackend();
|
|
169
300
|
if (backend?.ok === false) throw new Error('backend unavailable');
|
|
170
|
-
const method = String(req.method ?? 'GET').toUpperCase();
|
|
171
|
-
const bodyBuffer = method === 'GET' || method === 'HEAD' ? null : await readBoundedBody(req);
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
301
|
+
const method = String(req.method ?? 'GET').toUpperCase();
|
|
302
|
+
const bodyBuffer = method === 'GET' || method === 'HEAD' ? null : await readBoundedBody(req);
|
|
303
|
+
const requestHeaders = safeHeaders(req.headers);
|
|
304
|
+
for (const name of Object.keys(requestHeaders)) {
|
|
305
|
+
if (name.startsWith('x-dsh-crew-')) delete requestHeaders[name];
|
|
306
|
+
}
|
|
307
|
+
requestHeaders['x-dsh-crew-bridge'] = '3080-to-3210';
|
|
308
|
+
requestHeaders['x-dsh-crew-execution-plane'] = 'hub-3210';
|
|
309
|
+
requestHeaders['x-dsh-crew-ingress'] = 'official-3080';
|
|
310
|
+
const response = await fetchImpl(`${bridgeTarget}${req.url}`, {
|
|
311
|
+
method,
|
|
312
|
+
headers: requestHeaders,
|
|
175
313
|
body: bodyBuffer === null ? undefined : new Blob([bodyBuffer]),
|
|
176
314
|
signal: AbortSignal.timeout(120_000),
|
|
177
315
|
});
|
|
178
|
-
const responseBody = Buffer.from(await response.arrayBuffer());
|
|
179
|
-
const headers = safeHeaders(Object.fromEntries(response.headers.entries()));
|
|
180
|
-
|
|
181
|
-
|
|
316
|
+
const responseBody = Buffer.from(await response.arrayBuffer());
|
|
317
|
+
const headers = safeHeaders(Object.fromEntries(response.headers.entries()));
|
|
318
|
+
for (const name of Object.keys(headers)) {
|
|
319
|
+
if (name.startsWith('x-dsh-crew-')) delete headers[name];
|
|
320
|
+
}
|
|
321
|
+
headers['content-length'] = String(responseBody.length);
|
|
322
|
+
headers['x-dsh-crew-bridge'] = '3080-to-3210';
|
|
323
|
+
headers['x-dsh-crew-ingress'] = 'official-3080';
|
|
182
324
|
res.writeHead(response.status, headers);
|
|
183
325
|
res.end(responseBody);
|
|
184
326
|
} catch (error) {
|
|
@@ -187,29 +329,45 @@ export async function proxyCrewRequest(req, res, {
|
|
|
187
329
|
}
|
|
188
330
|
}
|
|
189
331
|
|
|
190
|
-
export function registerOfficialWebBridge(ctx, options = {}) {
|
|
191
|
-
return ctx.inject(['webServer'], (webCtx) => {
|
|
192
|
-
const
|
|
332
|
+
export function registerOfficialWebBridge(ctx, options = {}) {
|
|
333
|
+
return ctx.inject(['webServer'], (webCtx) => {
|
|
334
|
+
const supervisor = options.supervisor ?? processSupervisor;
|
|
335
|
+
const disposeStatus = webCtx.webServer.register({
|
|
193
336
|
kind: 'exact',
|
|
194
337
|
path: `${CREW_BRIDGE_PREFIX}/bridge-status`,
|
|
195
338
|
handler: (req, res) => {
|
|
196
339
|
if (!isTrustedLocalRequest(req)) return sendJson(res, 403, { ok: false, code: 'LOCAL_SAME_ORIGIN_ONLY' });
|
|
197
|
-
return sendJson(res, 200, {
|
|
198
|
-
ok: true,
|
|
340
|
+
return sendJson(res, 200, {
|
|
341
|
+
ok: true,
|
|
199
342
|
mode: 'official-3080-isolated-3210',
|
|
200
343
|
surface: 'official-bridge',
|
|
201
344
|
ui_role: 'control-plane',
|
|
345
|
+
execution_plane: 'hub-3210',
|
|
346
|
+
listen_port: 3210,
|
|
202
347
|
});
|
|
203
348
|
},
|
|
204
|
-
});
|
|
205
|
-
const
|
|
349
|
+
});
|
|
350
|
+
const disposeRestart = webCtx.webServer.register({
|
|
351
|
+
kind: 'exact',
|
|
352
|
+
path: `${CREW_BRIDGE_PREFIX}/supervisor/restart`,
|
|
353
|
+
handler: async (req, res) => {
|
|
354
|
+
if (!isTrustedLocalRequest(req)) return sendJson(res, 403, { ok: false, code: 'LOCAL_SAME_ORIGIN_ONLY' });
|
|
355
|
+
if (String(req.method ?? 'GET').toUpperCase() !== 'POST') return sendJson(res, 405, { ok: false, code: 'POST_ONLY' });
|
|
356
|
+
let body;
|
|
357
|
+
try { body = JSON.parse((await readBoundedBody(req, 8 * 1024)).toString('utf8') || '{}'); } catch { return sendJson(res, 400, { ok: false, code: 'SUPERVISOR_REQUEST_INVALID' }); }
|
|
358
|
+
if (body?.confirm !== true) return sendJson(res, 400, { ok: false, code: 'SUPERVISOR_RESTART_CONFIRM_REQUIRED' });
|
|
359
|
+
const result = await supervisor.restartOwnedBackend();
|
|
360
|
+
return sendJson(res, result?.ok === true ? 200 : 409, { ...result });
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
const disposeProxy = webCtx.webServer.register({
|
|
206
364
|
kind: 'prefix',
|
|
207
365
|
path: CREW_BRIDGE_PREFIX,
|
|
208
366
|
handler: (req, res) => proxyCrewRequest(req, res, options),
|
|
209
367
|
});
|
|
210
|
-
return () => { disposeProxy?.(); disposeStatus?.(); };
|
|
211
|
-
});
|
|
212
|
-
}
|
|
368
|
+
return () => { disposeProxy?.(); disposeRestart?.(); disposeStatus?.(); };
|
|
369
|
+
});
|
|
370
|
+
}
|
|
213
371
|
|
|
214
372
|
export async function apply(ctx) {
|
|
215
373
|
registerOfficialWebBridge(ctx);
|
package/src/policy.mjs
CHANGED
|
@@ -10,15 +10,22 @@ export * from './policy-legacy.mjs';
|
|
|
10
10
|
import * as legacy from './policy-legacy.mjs';
|
|
11
11
|
import { normalizeAdaptiveRouting } from './adaptive-routing.mjs';
|
|
12
12
|
|
|
13
|
-
export { normalizeAdaptiveRouting };
|
|
14
|
-
export const CONFIG_SCHEMA_VERSION =
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
13
|
+
export { normalizeAdaptiveRouting };
|
|
14
|
+
export const CONFIG_SCHEMA_VERSION = 4;
|
|
15
|
+
const MIN_CANONICAL_SCHEMA_VERSION = 3;
|
|
16
|
+
|
|
17
|
+
function validObject(value) {
|
|
18
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const EXECUTION_TRANSPORTS = new Set(['hub-3210', 'standalone-legacy']);
|
|
22
|
+
|
|
23
|
+
function normalizeExecutionTransport(value) {
|
|
24
|
+
return EXECUTION_TRANSPORTS.has(value) ? value : 'hub-3210';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function hasCanonicalAuthority(raw) {
|
|
28
|
+
return Number(raw?.config_schema_version) >= MIN_CANONICAL_SCHEMA_VERSION
|
|
22
29
|
&& validObject(raw?.worker)
|
|
23
30
|
&& validObject(raw?.review)
|
|
24
31
|
&& validObject(raw?.execution);
|
|
@@ -64,31 +71,39 @@ function normalizeLegacySnapshot(raw, canonical) {
|
|
|
64
71
|
};
|
|
65
72
|
}
|
|
66
73
|
|
|
67
|
-
function modelPolicyWithAdaptive(basePolicy, rawPolicy) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
74
|
+
function modelPolicyWithAdaptive(basePolicy, rawPolicy) {
|
|
75
|
+
const adaptive = normalizeAdaptiveRouting(rawPolicy?.adaptive);
|
|
76
|
+
const ordering = rawPolicy?.ordering === 'health-aware' || rawPolicy?.ordering === 'manual'
|
|
77
|
+
? rawPolicy.ordering
|
|
78
|
+
: adaptive.enabled ? 'health-aware' : 'manual';
|
|
79
|
+
return {
|
|
80
|
+
...basePolicy,
|
|
81
|
+
adaptive: { ...adaptive, enabled: ordering === 'health-aware' },
|
|
82
|
+
ordering,
|
|
83
|
+
health_gate: rawPolicy?.health_gate === 'off' ? 'off' : 'hard-failures',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
73
86
|
|
|
74
87
|
function normalizeCanonical(raw) {
|
|
75
88
|
const base = legacy.getCanonical(raw);
|
|
76
89
|
const providerMode = legacy.normalizeWorkerProviderMode(base.worker?.provider_mode);
|
|
77
90
|
const canonical = {
|
|
78
91
|
...base,
|
|
79
|
-
execution: {
|
|
80
|
-
...base.execution,
|
|
92
|
+
execution: {
|
|
93
|
+
...base.execution,
|
|
81
94
|
// There is one global dispatch permission. `execution.enabled` is a
|
|
82
95
|
// canonical mirror of the top-level switch, never a second authority.
|
|
83
|
-
enabled: base.subagents_enabled,
|
|
84
|
-
|
|
96
|
+
enabled: base.subagents_enabled,
|
|
97
|
+
transport: normalizeExecutionTransport(raw?.execution?.transport ?? base.execution?.transport),
|
|
98
|
+
},
|
|
85
99
|
worker: {
|
|
86
100
|
...base.worker,
|
|
87
101
|
provider_mode: providerMode,
|
|
88
102
|
model_policy: modelPolicyWithAdaptive(base.worker.model_policy, raw?.worker?.model_policy),
|
|
89
103
|
},
|
|
90
|
-
review: {
|
|
91
|
-
...base.review,
|
|
104
|
+
review: {
|
|
105
|
+
...base.review,
|
|
106
|
+
gate: ['required', 'optional', 'off'].includes(raw?.review?.gate) ? raw.review.gate : 'required',
|
|
92
107
|
// v0.3 still exposes one Worker Provider selector. Until per-role
|
|
93
108
|
// provider modes become first-class, keep both roles aligned.
|
|
94
109
|
provider_mode: providerMode,
|
|
@@ -176,16 +191,32 @@ export function normalizeGlobalConfig(raw = {}) {
|
|
|
176
191
|
* adaptive sub-domain, so direct canonical-ish v0.2 callers retain the exact
|
|
177
192
|
* priority/escalation semantics they had before schema-v3 existed.
|
|
178
193
|
*/
|
|
179
|
-
export function resolveModelPolicy(config = {}, role = 'worker', context = {}) {
|
|
180
|
-
const base = legacy.resolveModelPolicy(config, role, context);
|
|
181
|
-
const rawPolicy = role === 'reviewer'
|
|
182
|
-
? config?.review?.model_policy
|
|
183
|
-
: config?.worker?.model_policy;
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
194
|
+
export function resolveModelPolicy(config = {}, role = 'worker', context = {}) {
|
|
195
|
+
const base = legacy.resolveModelPolicy(config, role, context);
|
|
196
|
+
const rawPolicy = role === 'reviewer'
|
|
197
|
+
? config?.review?.model_policy
|
|
198
|
+
: config?.worker?.model_policy;
|
|
199
|
+
const adaptive = normalizeAdaptiveRouting(rawPolicy?.adaptive ?? base.adaptive);
|
|
200
|
+
const ordering = rawPolicy?.ordering === 'health-aware' || rawPolicy?.ordering === 'manual'
|
|
201
|
+
? rawPolicy.ordering
|
|
202
|
+
: adaptive.enabled ? 'health-aware' : 'manual';
|
|
203
|
+
return {
|
|
204
|
+
...base,
|
|
205
|
+
adaptive: { ...adaptive, enabled: ordering === 'health-aware' },
|
|
206
|
+
ordering,
|
|
207
|
+
health_gate: rawPolicy?.health_gate === 'off' ? 'off' : 'hard-failures',
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Automatic review respects the v4 reviewer gate; session opt-out remains authoritative. */
|
|
212
|
+
export function shouldAutoReview(config = {}, session = {}) {
|
|
213
|
+
const normalized = normalizeGlobalConfig(config);
|
|
214
|
+
const gate = ['required', 'optional', 'off'].includes(config?.review?.gate)
|
|
215
|
+
? config.review.gate
|
|
216
|
+
: normalized.review?.gate;
|
|
217
|
+
if (gate === 'off') return false;
|
|
218
|
+
return legacy.shouldAutoReview(normalized, session);
|
|
219
|
+
}
|
|
189
220
|
|
|
190
221
|
/** Explicit legacy-import normalizer for the persistence layer. */
|
|
191
222
|
export function normalizeLegacyGlobalConfig(raw = {}) {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Pure policy-reference scrub used by provider deletion transactions.
|
|
2
|
+
// It removes only provider/model references, never credentials or unrelated
|
|
3
|
+
// settings, and keeps explicit `priorityConfigured` semantics intact.
|
|
4
|
+
|
|
5
|
+
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
6
|
+
|
|
7
|
+
function cloneConfig(value) {
|
|
8
|
+
if (typeof structuredClone === 'function') return structuredClone(value);
|
|
9
|
+
return JSON.parse(JSON.stringify(value));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function validId(value) {
|
|
13
|
+
return typeof value === 'string' && ID_PATTERN.test(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function ensureProviderIds(providerIds) {
|
|
17
|
+
if (!Array.isArray(providerIds)) throw new TypeError('provider ids must be an array');
|
|
18
|
+
const result = [];
|
|
19
|
+
const seen = new Set();
|
|
20
|
+
for (const id of providerIds) {
|
|
21
|
+
if (!validId(id)) throw new TypeError('provider id must be a bounded identifier');
|
|
22
|
+
if (seen.has(id)) continue;
|
|
23
|
+
seen.add(id);
|
|
24
|
+
result.push(id);
|
|
25
|
+
}
|
|
26
|
+
if (result.length === 0) throw new TypeError('provider ids must not be empty');
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function scrubList(list, blocked, removed) {
|
|
31
|
+
if (!Array.isArray(list)) return list;
|
|
32
|
+
return list.filter((entry) => {
|
|
33
|
+
const provider = entry && typeof entry === 'object' ? entry.provider : null;
|
|
34
|
+
if (!blocked.has(provider)) return true;
|
|
35
|
+
if (!removed.includes(provider)) removed.push(provider);
|
|
36
|
+
return false;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function scrubPolicy(policy, blocked, removed) {
|
|
41
|
+
if (!policy || typeof policy !== 'object' || Array.isArray(policy)) return;
|
|
42
|
+
for (const key of ['priority', 'escalation_priority']) {
|
|
43
|
+
if (Array.isArray(policy[key])) policy[key] = scrubList(policy[key], blocked, removed);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function scrubDefault(config, key, blocked, removed) {
|
|
48
|
+
const value = config[key];
|
|
49
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return;
|
|
50
|
+
if (!blocked.has(value.provider)) return;
|
|
51
|
+
if (!removed.includes(value.provider)) removed.push(value.provider);
|
|
52
|
+
config[key] = null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Remove references to provider ids from all known canonical/legacy policy mirrors. */
|
|
56
|
+
export function scrubProviderReferences(config, providerIds) {
|
|
57
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) throw new TypeError('config must be an object');
|
|
58
|
+
const ids = ensureProviderIds(providerIds);
|
|
59
|
+
const blocked = new Set(ids);
|
|
60
|
+
const next = cloneConfig(config);
|
|
61
|
+
const removed = [];
|
|
62
|
+
|
|
63
|
+
for (const key of ['flash_model_priority', 'pro_model_priority']) {
|
|
64
|
+
if (Array.isArray(next[key])) next[key] = scrubList(next[key], blocked, removed);
|
|
65
|
+
}
|
|
66
|
+
scrubPolicy(next.worker?.model_policy, blocked, removed);
|
|
67
|
+
scrubPolicy(next.review?.model_policy, blocked, removed);
|
|
68
|
+
for (const key of ['harness_default', 'agent_default_model', 'agentDefaultModel']) scrubDefault(next, key, blocked, removed);
|
|
69
|
+
|
|
70
|
+
return { config: next, removed };
|
|
71
|
+
}
|