@ran-sh/dsh-crew 0.5.6 → 1.0.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 +20 -7
- package/README.zh.md +18 -7
- package/docs/installation.md +1 -1
- package/docs/ui-surfaces.md +65 -46
- package/lib/client.js +565 -28
- package/official-web-bridge/lib/client.js +565 -28
- package/package.json +1 -1
- package/src/client/collapsible-sections.mjs +3 -2
- package/src/client/host-readiness.mjs +16 -3
- package/src/client/index.tsx +331 -54
- package/src/config-readiness.mjs +66 -5
- package/src/credential-lifecycle.mjs +73 -0
- package/src/credential-purge-adapters.mjs +127 -0
- package/src/credential-purge-state.mjs +59 -0
- package/src/credential-reference-inventory.mjs +78 -0
- package/src/dsh-cli-runtime.mjs +36 -9
- package/src/extension-contract.mjs +16 -10
- package/src/hub/index.mjs +1004 -438
- package/src/hub-client.mjs +25 -5
- package/src/hub-compatibility.mjs +23 -7
- package/src/install/install.mjs +4 -4
- package/src/install/npx-lifecycle.mjs +495 -134
- package/src/job-contracts.mjs +27 -6
- 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 +248 -95
- package/src/policy.mjs +62 -31
- package/src/provider-config-scrub.mjs +71 -0
- package/src/provider-delete-adapters.mjs +451 -0
- package/src/provider-desired-state.mjs +37 -0
- package/src/provider-health.mjs +124 -0
- package/src/provider-inventory.mjs +132 -0
- package/src/provider-lifecycle-state.mjs +95 -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 +66 -21
- package/src/runtime-readiness-snapshot.mjs +87 -0
- package/src/server.mjs +64 -21
- 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,43 +16,19 @@ const HOP_BY_HOP = new Set([
|
|
|
13
16
|
'te', 'trailer', 'transfer-encoding', 'upgrade', 'host', 'content-length',
|
|
14
17
|
]);
|
|
15
18
|
|
|
16
|
-
export function
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1';
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function resolveCrewBridgeTarget(env = process.env) {
|
|
25
|
-
const raw = env?.DSH_CREW_BRIDGE_TARGET;
|
|
26
|
-
if (!raw) return CREW_BRIDGE_TARGET;
|
|
27
|
-
try {
|
|
28
|
-
const target = new URL(raw);
|
|
29
|
-
if (target.protocol !== 'http:' || !isLocalHostname(target.hostname.toLowerCase()) || target.pathname !== '/' || target.search || target.hash) return CREW_BRIDGE_TARGET;
|
|
30
|
-
const port = Number(target.port);
|
|
31
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) return CREW_BRIDGE_TARGET;
|
|
32
|
-
return target.origin;
|
|
33
|
-
} catch { return CREW_BRIDGE_TARGET; }
|
|
34
|
-
}
|
|
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
|
+
}
|
|
35
24
|
|
|
36
25
|
export function isTrustedLocalRequest(req) {
|
|
37
|
-
if (!
|
|
26
|
+
if (!localRequestCore(req)) return false;
|
|
38
27
|
const host = typeof req?.headers?.host === 'string' ? req.headers.host.trim().toLowerCase() : '';
|
|
39
28
|
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
29
|
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;
|
|
30
|
+
if (origin === undefined) return true;
|
|
31
|
+
return originAuthorityMatches(origin, host);
|
|
53
32
|
}
|
|
54
33
|
|
|
55
34
|
function safeHeaders(source) {
|
|
@@ -86,39 +65,132 @@ async function readBoundedBody(req, limit = MAX_BODY_BYTES) {
|
|
|
86
65
|
return Buffer.concat(chunks);
|
|
87
66
|
}
|
|
88
67
|
|
|
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
|
-
|
|
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
|
+
}
|
|
98
89
|
|
|
99
90
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
100
91
|
|
|
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
|
-
|
|
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;
|
|
113
109
|
const runtime = crewDshRuntimeModule({ home });
|
|
114
|
-
const dshHome = crewDshHome({ home });
|
|
115
|
-
const bridgePort = new URL(bridgeTarget).port;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
+
try {
|
|
136
|
+
mkdirSync(dshHome, { recursive: true });
|
|
137
|
+
const temp = `${ownershipFile}.${process.pid}.${Date.now()}.tmp`;
|
|
138
|
+
writeFileSync(temp, JSON.stringify({
|
|
139
|
+
schema_version: 1,
|
|
140
|
+
pid,
|
|
141
|
+
profile: 'dsh-crew',
|
|
142
|
+
execution_plane: 'hub-3210',
|
|
143
|
+
port: Number(bridgePort),
|
|
144
|
+
runtime_id: typeof identity?.runtime_id === 'string' ? identity.runtime_id : null,
|
|
145
|
+
}) + '\n');
|
|
146
|
+
renameSync(temp, ownershipFile);
|
|
147
|
+
return true;
|
|
148
|
+
} catch { return false; }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function adoptedChild(pid) {
|
|
152
|
+
return {
|
|
153
|
+
pid,
|
|
154
|
+
killed: false,
|
|
155
|
+
exitCode: null,
|
|
156
|
+
kill() {
|
|
157
|
+
try {
|
|
158
|
+
killImpl(pid);
|
|
159
|
+
this.killed = true;
|
|
160
|
+
this.exitCode = 0;
|
|
161
|
+
clearOwnership(pid);
|
|
162
|
+
return true;
|
|
163
|
+
} catch { return false; }
|
|
164
|
+
},
|
|
165
|
+
unref() {},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function adoptPersistedChild() {
|
|
170
|
+
if (runningChild && runningChild.killed !== true && runningChild.exitCode == null) return true;
|
|
171
|
+
const record = readOwnership();
|
|
172
|
+
if (!record || !(await healthCheck())) {
|
|
173
|
+
if (record) clearOwnership(record.pid);
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
const identity = await runtimeIdentity();
|
|
177
|
+
if (!identity || (record.runtime_id && identity.runtime_id !== record.runtime_id)) {
|
|
178
|
+
clearOwnership(record.pid);
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
runningChild = adoptedChild(record.pid);
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function start() {
|
|
186
|
+
const childAlive = runningChild && runningChild.killed !== true && runningChild.exitCode == null;
|
|
187
|
+
if (await healthCheck()) {
|
|
188
|
+
if (childAlive) return { ok: true, started: false, owned: true };
|
|
189
|
+
if (await adoptPersistedChild()) return { ok: true, started: false, adopted: true, owned: true };
|
|
190
|
+
return { ok: false, code: 'PORT_OWNERSHIP_CONFLICT' };
|
|
191
|
+
}
|
|
192
|
+
if (!exists(runtime)) return { ok: false, code: 'CREW_RUNTIME_NOT_INSTALLED' };
|
|
193
|
+
if (!childAlive) {
|
|
122
194
|
runningChild = spawnImpl(process.execPath, [
|
|
123
195
|
runtime, '--profile', 'dsh-crew', '--host', '127.0.0.1', '--port', bridgePort,
|
|
124
196
|
], {
|
|
@@ -128,24 +200,78 @@ export function createCrewSidecarSupervisor({
|
|
|
128
200
|
stdio: 'ignore',
|
|
129
201
|
windowsHide: true,
|
|
130
202
|
});
|
|
131
|
-
const ownedChild = runningChild;
|
|
132
|
-
ownedChild
|
|
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; });
|
|
133
210
|
ownedChild.unref?.();
|
|
134
211
|
}
|
|
135
212
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
136
|
-
if (await healthCheck())
|
|
137
|
-
|
|
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);
|
|
138
230
|
}
|
|
139
231
|
return { ok: false, code: 'CREW_BACKEND_START_TIMEOUT' };
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
+
}
|
|
149
275
|
|
|
150
276
|
const processSupervisor = createCrewSidecarSupervisor();
|
|
151
277
|
|
|
@@ -167,18 +293,29 @@ export async function proxyCrewRequest(req, res, {
|
|
|
167
293
|
try {
|
|
168
294
|
const backend = await ensureBackend();
|
|
169
295
|
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
|
-
|
|
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,
|
|
175
308
|
body: bodyBuffer === null ? undefined : new Blob([bodyBuffer]),
|
|
176
309
|
signal: AbortSignal.timeout(120_000),
|
|
177
310
|
});
|
|
178
|
-
const responseBody = Buffer.from(await response.arrayBuffer());
|
|
179
|
-
const headers = safeHeaders(Object.fromEntries(response.headers.entries()));
|
|
180
|
-
|
|
181
|
-
|
|
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';
|
|
182
319
|
res.writeHead(response.status, headers);
|
|
183
320
|
res.end(responseBody);
|
|
184
321
|
} catch (error) {
|
|
@@ -187,29 +324,45 @@ export async function proxyCrewRequest(req, res, {
|
|
|
187
324
|
}
|
|
188
325
|
}
|
|
189
326
|
|
|
190
|
-
export function registerOfficialWebBridge(ctx, options = {}) {
|
|
191
|
-
return ctx.inject(['webServer'], (webCtx) => {
|
|
192
|
-
const
|
|
327
|
+
export function registerOfficialWebBridge(ctx, options = {}) {
|
|
328
|
+
return ctx.inject(['webServer'], (webCtx) => {
|
|
329
|
+
const supervisor = options.supervisor ?? processSupervisor;
|
|
330
|
+
const disposeStatus = webCtx.webServer.register({
|
|
193
331
|
kind: 'exact',
|
|
194
332
|
path: `${CREW_BRIDGE_PREFIX}/bridge-status`,
|
|
195
333
|
handler: (req, res) => {
|
|
196
334
|
if (!isTrustedLocalRequest(req)) return sendJson(res, 403, { ok: false, code: 'LOCAL_SAME_ORIGIN_ONLY' });
|
|
197
|
-
return sendJson(res, 200, {
|
|
198
|
-
ok: true,
|
|
335
|
+
return sendJson(res, 200, {
|
|
336
|
+
ok: true,
|
|
199
337
|
mode: 'official-3080-isolated-3210',
|
|
200
338
|
surface: 'official-bridge',
|
|
201
339
|
ui_role: 'control-plane',
|
|
340
|
+
execution_plane: 'hub-3210',
|
|
341
|
+
listen_port: 3210,
|
|
202
342
|
});
|
|
203
343
|
},
|
|
204
|
-
});
|
|
205
|
-
const
|
|
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({
|
|
206
359
|
kind: 'prefix',
|
|
207
360
|
path: CREW_BRIDGE_PREFIX,
|
|
208
361
|
handler: (req, res) => proxyCrewRequest(req, res, options),
|
|
209
362
|
});
|
|
210
|
-
return () => { disposeProxy?.(); disposeStatus?.(); };
|
|
211
|
-
});
|
|
212
|
-
}
|
|
363
|
+
return () => { disposeProxy?.(); disposeRestart?.(); disposeStatus?.(); };
|
|
364
|
+
});
|
|
365
|
+
}
|
|
213
366
|
|
|
214
367
|
export async function apply(ctx) {
|
|
215
368
|
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
|
+
}
|