@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.
Files changed (47) hide show
  1. package/README.md +20 -7
  2. package/README.zh.md +18 -7
  3. package/docs/installation.md +1 -1
  4. package/docs/ui-surfaces.md +65 -46
  5. package/lib/client.js +565 -28
  6. package/official-web-bridge/lib/client.js +565 -28
  7. package/package.json +1 -1
  8. package/src/client/collapsible-sections.mjs +3 -2
  9. package/src/client/host-readiness.mjs +16 -3
  10. package/src/client/index.tsx +331 -54
  11. package/src/config-readiness.mjs +66 -5
  12. package/src/credential-lifecycle.mjs +73 -0
  13. package/src/credential-purge-adapters.mjs +127 -0
  14. package/src/credential-purge-state.mjs +59 -0
  15. package/src/credential-reference-inventory.mjs +78 -0
  16. package/src/dsh-cli-runtime.mjs +36 -9
  17. package/src/extension-contract.mjs +16 -10
  18. package/src/hub/index.mjs +1004 -438
  19. package/src/hub-client.mjs +25 -5
  20. package/src/hub-compatibility.mjs +23 -7
  21. package/src/install/install.mjs +4 -4
  22. package/src/install/npx-lifecycle.mjs +495 -134
  23. package/src/job-contracts.mjs +27 -6
  24. package/src/jobs.mjs +2 -4
  25. package/src/local-request-guard.mjs +60 -0
  26. package/src/mcp-runtime.mjs +7 -5
  27. package/src/model-routing.mjs +141 -49
  28. package/src/multimodal.mjs +0 -0
  29. package/src/official-web-bridge.mjs +248 -95
  30. package/src/policy.mjs +62 -31
  31. package/src/provider-config-scrub.mjs +71 -0
  32. package/src/provider-delete-adapters.mjs +451 -0
  33. package/src/provider-desired-state.mjs +37 -0
  34. package/src/provider-health.mjs +124 -0
  35. package/src/provider-inventory.mjs +132 -0
  36. package/src/provider-lifecycle-state.mjs +95 -0
  37. package/src/provider-lifecycle.mjs +214 -0
  38. package/src/provider-profile-store.mjs +171 -0
  39. package/src/readiness-matrix.mjs +11 -5
  40. package/src/removable-waiter.mjs +29 -0
  41. package/src/runtime-identity.mjs +66 -21
  42. package/src/runtime-readiness-snapshot.mjs +87 -0
  43. package/src/server.mjs +64 -21
  44. package/src/status-shard.mjs +13 -2
  45. package/src/workflow-runtime.mjs +44 -10
  46. package/windows/start-dsh-crew.cmd +3 -3
  47. 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 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
- 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 (!isLoopbackAddress(req?.socket?.remoteAddress)) return false;
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 !== 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;
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 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
- }
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
- wait = delay,
108
- maxAttempts = 120,
109
- pollInterval = 250,
110
- } = {}) {
111
- let starting = null;
112
- 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;
113
109
  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) {
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.once?.('exit', () => { if (runningChild === ownedChild) runningChild = null; });
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()) return { ok: true, started: true };
137
- await wait(pollInterval);
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
- return {
143
- ensure() {
144
- if (!starting) starting = start().finally(() => { starting = null; });
145
- return starting;
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 response = await fetchImpl(`${bridgeTarget}${req.url}`, {
173
- method,
174
- headers: safeHeaders(req.headers),
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
- headers['content-length'] = String(responseBody.length);
181
- headers['x-dsh-crew-bridge'] = '3080-to-3210';
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 disposeStatus = webCtx.webServer.register({
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 disposeProxy = webCtx.webServer.register({
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 = 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
+ }