@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.
Files changed (39) hide show
  1. package/README.md +14 -7
  2. package/README.zh.md +12 -7
  3. package/docs/installation.md +1 -1
  4. package/docs/ui-surfaces.md +65 -46
  5. package/lib/client.js +345 -22
  6. package/official-web-bridge/lib/client.js +345 -22
  7. package/package.json +1 -1
  8. package/src/client/collapsible-sections.mjs +3 -2
  9. package/src/client/index.tsx +227 -51
  10. package/src/config-readiness.mjs +56 -5
  11. package/src/extension-contract.mjs +9 -5
  12. package/src/hub/index.mjs +854 -445
  13. package/src/hub-client.mjs +24 -4
  14. package/src/hub-compatibility.mjs +23 -7
  15. package/src/install/install.mjs +4 -4
  16. package/src/install/npx-lifecycle.mjs +263 -118
  17. package/src/job-contracts.mjs +19 -3
  18. package/src/jobs.mjs +2 -4
  19. package/src/local-request-guard.mjs +60 -0
  20. package/src/mcp-runtime.mjs +7 -5
  21. package/src/model-routing.mjs +141 -49
  22. package/src/multimodal.mjs +0 -0
  23. package/src/official-web-bridge.mjs +242 -84
  24. package/src/policy.mjs +62 -31
  25. package/src/provider-config-scrub.mjs +71 -0
  26. package/src/provider-delete-adapters.mjs +424 -0
  27. package/src/provider-health.mjs +124 -0
  28. package/src/provider-inventory.mjs +132 -0
  29. package/src/provider-lifecycle-state.mjs +81 -0
  30. package/src/provider-lifecycle.mjs +214 -0
  31. package/src/provider-profile-store.mjs +171 -0
  32. package/src/readiness-matrix.mjs +11 -5
  33. package/src/removable-waiter.mjs +29 -0
  34. package/src/runtime-identity.mjs +43 -13
  35. package/src/server.mjs +34 -16
  36. package/src/status-shard.mjs +13 -2
  37. package/src/workflow-runtime.mjs +44 -10
  38. package/windows/start-dsh-crew.cmd +3 -3
  39. 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 (!isLoopbackAddress(req?.socket?.remoteAddress)) return false;
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 !== undefined) {
47
- if (typeof origin !== 'string') return false;
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 defaultHealthCheck(fetchImpl = globalThis.fetch, bridgeTarget = CREW_BRIDGE_TARGET) {
90
- try {
91
- const response = await fetchImpl(`${bridgeTarget}${CREW_BRIDGE_PREFIX}/ping`, {
92
- signal: AbortSignal.timeout(1_500),
93
- headers: { accept: 'application/json' },
94
- });
95
- return response.ok;
96
- } catch { return false; }
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
- wait = delay,
108
- maxAttempts = 120,
109
- pollInterval = 250,
110
- } = {}) {
111
- let starting = null;
112
- let runningChild = null;
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
- async function start() {
118
- if (await healthCheck()) return { ok: true, started: false };
119
- if (!exists(runtime)) return { ok: false, code: 'CREW_RUNTIME_NOT_INSTALLED' };
120
- const childAlive = runningChild && runningChild.killed !== true && runningChild.exitCode == null;
121
- if (!childAlive) {
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.once?.('exit', () => { if (runningChild === ownedChild) runningChild = null; });
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()) return { ok: true, started: true };
137
- await wait(pollInterval);
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
- return {
143
- ensure() {
144
- if (!starting) starting = start().finally(() => { starting = null; });
145
- return starting;
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 response = await fetchImpl(`${bridgeTarget}${req.url}`, {
173
- method,
174
- headers: safeHeaders(req.headers),
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
- headers['content-length'] = String(responseBody.length);
181
- headers['x-dsh-crew-bridge'] = '3080-to-3210';
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 disposeStatus = webCtx.webServer.register({
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 disposeProxy = webCtx.webServer.register({
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 = 3;
15
-
16
- function validObject(value) {
17
- return !!value && typeof value === 'object' && !Array.isArray(value);
18
- }
19
-
20
- function hasCanonicalAuthority(raw) {
21
- return Number(raw?.config_schema_version) >= CONFIG_SCHEMA_VERSION
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
- return {
69
- ...basePolicy,
70
- adaptive: normalizeAdaptiveRouting(rawPolicy?.adaptive),
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
- return {
185
- ...base,
186
- adaptive: normalizeAdaptiveRouting(rawPolicy?.adaptive ?? base.adaptive),
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
+ }