@ran-sh/dsh-crew 0.5.7 → 1.0.1

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.
@@ -49,9 +49,10 @@ import {
49
49
 
50
50
  export const CREW_APP_DIRNAME = 'app';
51
51
  export const RELEASES_DIRNAME = 'releases';
52
- export const CURRENT_POINTER_FILENAME = 'current.json';
53
- export const KEEP_RELEASES = 2;
54
- export const INCOMPLETE_MARKER = '.dsh-crew-incomplete';
52
+ export const CURRENT_POINTER_FILENAME = 'current.json';
53
+ export const KEEP_RELEASES = 2;
54
+ export const INCOMPLETE_MARKER = '.dsh-crew-incomplete';
55
+ const CREW_ROUTE_BASE = '/_dsh/dsh-crew';
55
56
 
56
57
  export function npmCliInvocation(args, {
57
58
  platform = process.platform,
@@ -477,7 +478,7 @@ function commitStagedRelease({ stageDir, manifest, home }) {
477
478
 
478
479
  const STALE_INCOMPLETE_MS = 24 * 60 * 60 * 1000;
479
480
 
480
- function gcOldReleases({ home, keep = KEEP_RELEASES }) {
481
+ function gcOldReleases({ home, keep = KEEP_RELEASES }) {
481
482
  const pointer = readCurrentPointer({ home });
482
483
  const releasesDir = crewReleasesDir({ home });
483
484
  if (!existsSync(releasesDir)) return;
@@ -506,9 +507,123 @@ function gcOldReleases({ home, keep = KEEP_RELEASES }) {
506
507
  try { rmSync(victim, { recursive: true, force: true }); removed.push(victim); } catch { /* best effort */ }
507
508
  }
508
509
  return removed;
509
- }
510
-
511
- function registrationLinkPath({ home, name }) {
510
+ }
511
+
512
+ export function listManagedReleases({ home = homedir() } = {}) {
513
+ const pointer = readCurrentPointer({ home });
514
+ const root = crewReleasesDir({ home });
515
+ if (!existsSync(root)) return [];
516
+ return readdirSync(root, { withFileTypes: true })
517
+ .filter((entry) => entry.isDirectory())
518
+ .map((entry) => {
519
+ const path = join(root, entry.name);
520
+ const manifest = readManifest(path);
521
+ if (!manifest?.name || !manifest?.version) return null;
522
+ const validation = validateInstalledPayload(path, { expectedName: manifest.name, expectedVersion: manifest.version });
523
+ return {
524
+ name: manifest.name,
525
+ version: manifest.version,
526
+ path,
527
+ current: pointer?.path === path,
528
+ healthy: validation.ok,
529
+ };
530
+ })
531
+ .filter(Boolean)
532
+ .sort((a, b) => compareVersions(b.version, a.version) || b.path.localeCompare(a.path));
533
+ }
534
+
535
+ async function restartOwnedRuntime(fetchImpl = globalThis.fetch) {
536
+ const response = await fetchImpl('http://127.0.0.1:3080/_dsh/dsh-crew/supervisor/restart', {
537
+ method: 'POST',
538
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
539
+ body: JSON.stringify({ confirm: true }),
540
+ });
541
+ const body = await response.json();
542
+ return response.ok && body?.ok === true ? body : { ok: false, code: body?.code ?? 'CREW_3210_RESTART_FAILED' };
543
+ }
544
+
545
+ async function verifyRuntimeVersion(version, fetchImpl = globalThis.fetch) {
546
+ const response = await fetchImpl('http://127.0.0.1:3210/_dsh/dsh-crew/runtime', { headers: { accept: 'application/json' } });
547
+ const body = await response.json();
548
+ return response.ok && body?.ok === true && body.runtime_version === version
549
+ ? { ok: true, runtime_version: body.runtime_version }
550
+ : { ok: false, code: 'RUNTIME_VERSION_MISMATCH' };
551
+ }
552
+
553
+ export async function npxReleases({ home = homedir(), log = console.log } = {}) {
554
+ const releases = listManagedReleases({ home });
555
+ log(JSON.stringify(releases, null, 2));
556
+ return { ok: true, releases };
557
+ }
558
+
559
+ export async function npxRollback({
560
+ home = homedir(),
561
+ version,
562
+ log = console.log,
563
+ installer = realInstaller,
564
+ ensureRuntime,
565
+ validatePayload = validateInstalledPayload,
566
+ activate,
567
+ restart = () => restartOwnedRuntime(),
568
+ verifyRuntime = (targetVersion) => verifyRuntimeVersion(targetVersion),
569
+ } = {}) {
570
+ const targetVersion = typeof version === 'string' ? version.trim() : '';
571
+ if (!targetVersion) return { ok: false, error: 'rollback requires a target version' };
572
+ const current = readCurrentPointer({ home });
573
+ if (!current?.path || !existsSync(current.path)) return { ok: false, error: 'no active Crew payload to roll back' };
574
+ const target = listManagedReleases({ home }).find((release) => release.version === targetVersion);
575
+ if (!target) return { ok: false, error: `retained release ${targetVersion} was not found` };
576
+ if (target.path === current.path) return { ok: true, idempotent: true, version: target.version, path: target.path };
577
+ const targetManifest = readManifest(target.path);
578
+ const validation = validatePayload(target.path, { expectedName: targetManifest?.name, expectedVersion: targetManifest?.version });
579
+ if (!validation.ok) return { ok: false, error: 'target release failed payload validation' };
580
+ const previousManifest = readManifest(current.path);
581
+ const activateReleaseFn = activate ?? (({ releaseDir, manifest }) => activateRelease({ home, releaseDir, manifest, log, installer, ensureRuntime }));
582
+ const switchPointer = (release) => writeCurrentPointer({ home, name: release.name, version: release.version, path: release.path });
583
+ try {
584
+ switchPointer(target);
585
+ if (!await activateReleaseFn({ releaseDir: target.path, manifest: targetManifest })) throw new Error('target release activation failed');
586
+ const restarted = await restart(target.version);
587
+ if (restarted?.ok === false) throw Object.assign(new Error('target runtime restart failed'), { code: restarted.code });
588
+ const runtime = await verifyRuntime(target.version);
589
+ if (runtime?.ok !== true) throw Object.assign(new Error('target runtime verification failed'), { code: runtime?.code ?? 'RUNTIME_VERSION_MISMATCH' });
590
+ log(`✓ rolled back Crew payload to ${target.version}`);
591
+ return { ok: true, rolled_back: true, version: target.version, path: target.path, restart: restarted, runtime };
592
+ } catch (error) {
593
+ const prior = { name: current.name, version: current.version, path: current.path };
594
+ let recovery;
595
+ try {
596
+ switchPointer(prior);
597
+ if (!previousManifest) throw Object.assign(new Error('previous release manifest unavailable'), { stage: 'activation' });
598
+ const activated = await activateReleaseFn({ releaseDir: prior.path, manifest: previousManifest });
599
+ if (activated !== true) throw Object.assign(new Error('previous release activation failed'), { stage: 'activation' });
600
+ const restarted = await restart(prior.version);
601
+ if (restarted?.ok !== true) throw Object.assign(new Error('previous runtime restart failed'), { stage: 'restart' });
602
+ const runtime = await verifyRuntime(prior.version);
603
+ if (runtime?.ok !== true) throw Object.assign(new Error('previous runtime verification failed'), { stage: 'verification' });
604
+ const restoredPointer = readCurrentPointer({ home });
605
+ if (restoredPointer?.path !== prior.path || restoredPointer.version !== prior.version) {
606
+ throw Object.assign(new Error('previous release pointer was not restored'), { stage: 'pointer' });
607
+ }
608
+ recovery = { ok: true, version: prior.version, path: prior.path };
609
+ } catch (recoveryError) {
610
+ recovery = {
611
+ ok: false,
612
+ code: 'RELEASE_ROLLBACK_RECOVERY_FAILED',
613
+ stage: ['activation', 'restart', 'verification', 'pointer'].includes(recoveryError?.stage) ? recoveryError.stage : 'unknown',
614
+ };
615
+ }
616
+ return {
617
+ ok: false,
618
+ error: error?.message ?? 'release rollback failed',
619
+ code: error?.code ?? 'RELEASE_ROLLBACK_FAILED',
620
+ restored: recovery.ok === true,
621
+ recovery,
622
+ };
623
+ }
624
+ }
625
+
626
+ function registrationLinkPath({ home, name }) {
512
627
  return join(crewProfileDir({ home }), 'node_modules', ...name.split('/'));
513
628
  }
514
629
 
@@ -1113,6 +1228,9 @@ Commands:
1113
1228
  inspect print the machine-readable extension capability/readiness contract
1114
1229
  jobs machine-first job API: list|get|watch|cancel|submit
1115
1230
  providers 3210 provider lifecycle API: list|delete-plan|delete|rollback|probe
1231
+ credentials 3210 credential references: list|purge-plan|purge (separate confirmation)
1232
+ releases list retained, validated Crew payload releases
1233
+ rollback switch to a retained payload version and verify the 3210 runtime
1116
1234
  update resolve the newest permitted package from the configured npm registry (or
1117
1235
  --candidate), stage and validate it, then activate; idempotent when current
1118
1236
  uninstall remove the Crew-managed payload, registration, and integrations (config kept)
@@ -1122,8 +1240,8 @@ Options:
1122
1240
  --after <sequence> with jobs watch/get: return canonical events after this cursor
1123
1241
  --detail <mode> with jobs get/watch: compact (default) or full
1124
1242
  --request <path> with jobs submit: JSON Job Request document
1125
- --plan <id> with providers delete: approved plan id
1126
- --expected-revision <sha256> with providers delete: profile revision from delete-plan
1243
+ --plan <id> with providers/credentials destructive actions: approved plan id
1244
+ --expected-revision <sha256> with providers/credentials actions: revision from plan
1127
1245
  --replacement-default <id> with providers delete-plan: replacement Harness Default provider
1128
1246
  --confirm with providers delete: confirm the destructive mutation
1129
1247
  --purge with uninstall: also remove ~/.config/dsh-crew config/backups (destructive)
@@ -1132,13 +1250,14 @@ Options:
1132
1250
  Primary install: npm install -g @ran-sh/dsh-crew (then run: dsh-crew install)
1133
1251
  Source checkouts use scripts/setup.mjs instead.`;
1134
1252
 
1135
- export async function npxInspect({
1253
+ export async function npxInspect({
1136
1254
  log = console.log,
1137
1255
  fetchImpl = globalThis.fetch,
1138
1256
  readConfig = realInstaller.readGlobalConfig,
1139
- } = {}) {
1140
- const hubUrl = readConfig()?.hub_url ?? 'http://127.0.0.1:3210';
1141
- const url = `${String(hubUrl).replace(/\/$/, '')}/_dsh/dsh-crew/extension`;
1257
+ } = {}) {
1258
+ const hubUrl = String(readConfig()?.hub_url ?? PRODUCTION_HUB_URL).replace(/\/$/, '');
1259
+ await assertProductionHub({ hubUrl, requiredCapabilities: INSPECT_CAPABILITIES, purpose: 'inspect', fetchImpl });
1260
+ const url = `${String(hubUrl).replace(/\/$/, '')}/_dsh/dsh-crew/extension`;
1142
1261
  const response = await fetchImpl(url, { headers: { accept: 'application/json' } });
1143
1262
  const body = await response.json();
1144
1263
  if (!response.ok || body?.ok !== true || !body.extension) {
@@ -1156,12 +1275,15 @@ export async function npxJobs({
1156
1275
  log = console.log,
1157
1276
  fetchImpl = globalThis.fetch,
1158
1277
  readConfig = realInstaller.readGlobalConfig,
1159
- } = {}) {
1160
- const hubUrl = String(readConfig()?.hub_url ?? 'http://127.0.0.1:3210').replace(/\/$/, '');
1161
- const base = `${hubUrl}/_dsh/dsh-crew/jobs`;
1162
- const action = args[0] ?? 'list';
1163
- const id = args[1];
1164
- let url = base;
1278
+ } = {}) {
1279
+ const hubUrl = String(readConfig()?.hub_url ?? 'http://127.0.0.1:3210').replace(/\/$/, '');
1280
+ const base = `${hubUrl}/_dsh/dsh-crew/jobs`;
1281
+ const action = args[0] ?? 'list';
1282
+ const id = args[1];
1283
+ const requiredCapabilities = JOB_CAPABILITY_REQUIREMENTS[action];
1284
+ if (!requiredCapabilities) throw new Error(`unknown jobs action: ${action}`);
1285
+ await assertProductionHub({ hubUrl, requiredCapabilities, purpose: 'job', fetchImpl });
1286
+ let url = base;
1165
1287
  let init = { headers: { accept: 'application/json' } };
1166
1288
  if (action === 'get' || action === 'watch') {
1167
1289
  if (!id) throw new Error(`jobs ${action} requires a job id`);
@@ -1174,9 +1296,7 @@ export async function npxJobs({
1174
1296
  if (!request) throw new Error('jobs submit requires --request <json-file>');
1175
1297
  const document = JSON.parse(readFileSync(resolve(request), 'utf8'));
1176
1298
  init = { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify(document) };
1177
- } else if (action !== 'list') {
1178
- throw new Error(`unknown jobs action: ${action}`);
1179
- }
1299
+ }
1180
1300
  const response = await fetchImpl(url, init);
1181
1301
  const body = await response.json();
1182
1302
  if (!response.ok || body?.ok === false) throw new Error(body?.error ?? 'Crew jobs API unavailable');
@@ -1184,6 +1304,60 @@ export async function npxJobs({
1184
1304
  return { ok: true, body };
1185
1305
  }
1186
1306
 
1307
+ const PROVIDER_CAPABILITY_REQUIREMENTS = Object.freeze({
1308
+ list: Object.freeze(['provider-inventory']),
1309
+ 'delete-plan': Object.freeze(['provider-inventory', 'provider-lifecycle-v1']),
1310
+ delete: Object.freeze(['provider-inventory', 'provider-lifecycle-v1']),
1311
+ rollback: Object.freeze(['provider-inventory', 'provider-lifecycle-v1']),
1312
+ probe: Object.freeze(['provider-inventory', 'provider-health-v1', 'provider-probe-stream-v1']),
1313
+ });
1314
+ const JOB_CAPABILITY_REQUIREMENTS = Object.freeze({
1315
+ list: Object.freeze(['jobs']),
1316
+ get: Object.freeze(['jobs', 'jobs-wait']),
1317
+ watch: Object.freeze(['jobs', 'jobs-wait']),
1318
+ cancel: Object.freeze(['jobs', 'jobs-cancel']),
1319
+ submit: Object.freeze(['jobs', 'roles', 'attempt-index', 'model-policy']),
1320
+ });
1321
+ const INSPECT_CAPABILITIES = Object.freeze(['extension-contract', 'evidence', 'runtime-provenance-v1']);
1322
+ const CREDENTIAL_CAPABILITY_REQUIREMENTS = Object.freeze({
1323
+ list: Object.freeze(['credential-reference-inventory-v1']),
1324
+ 'purge-plan': Object.freeze(['credential-reference-inventory-v1', 'credential-purge-v1']),
1325
+ purge: Object.freeze(['credential-reference-inventory-v1', 'credential-purge-v1']),
1326
+ });
1327
+ export const PRODUCTION_HUB_URL = 'http://127.0.0.1:3210';
1328
+
1329
+ export async function assertProductionHub({ hubUrl, requiredCapabilities = [], purpose = 'command', fetchImpl = globalThis.fetch } = {}) {
1330
+ if (hubUrl !== PRODUCTION_HUB_URL) throw new Error(`${purpose} requires the isolated 3210 Crew Hub`);
1331
+ const response = await fetchImpl(`${hubUrl}${CREW_ROUTE_BASE}/runtime`, { headers: { accept: 'application/json' } });
1332
+ let body;
1333
+ try { body = await response.json(); } catch { body = null; }
1334
+ const validIdentity = response.ok && body?.ok === true
1335
+ && body?.service === 'dsh-crew-hub'
1336
+ && body?.execution_plane === 'hub-3210'
1337
+ && body?.profile === 'dsh-crew'
1338
+ && Number(body?.listen_port) === 3210
1339
+ && typeof body?.runtime_id === 'string' && body.runtime_id.trim().length > 0;
1340
+ if (!validIdentity) throw new Error(`${purpose} requires a compatible isolated 3210 Crew Hub`);
1341
+ const advertised = new Set(Array.isArray(body.capabilities) ? body.capabilities.filter((value) => typeof value === 'string') : []);
1342
+ const missing = requiredCapabilities.filter((capability) => !advertised.has(capability));
1343
+ if (missing.length > 0) throw new Error(`missing ${purpose} capability: ${missing.join(', ')}`);
1344
+ return { ok: true, runtime: body, capabilities: [...advertised] };
1345
+ }
1346
+
1347
+ /**
1348
+ * Confirm the target is the live isolated Hub and advertises the lifecycle
1349
+ * surface before sending a provider request. This prevents a same-port stale
1350
+ * Hub from turning a later 404 into an ambiguous destructive failure.
1351
+ */
1352
+ export async function assertProviderHubCapabilities({ hubUrl, action, fetchImpl = globalThis.fetch } = {}) {
1353
+ return assertProductionHub({
1354
+ hubUrl,
1355
+ requiredCapabilities: PROVIDER_CAPABILITY_REQUIREMENTS[action] ?? PROVIDER_CAPABILITY_REQUIREMENTS.list,
1356
+ purpose: 'provider lifecycle',
1357
+ fetchImpl,
1358
+ });
1359
+ }
1360
+
1187
1361
  /**
1188
1362
  * Call the isolated 3210 provider lifecycle API. The CLI never parses or
1189
1363
  * edits Harness YAML itself; the Hub remains the sole mutation authority.
@@ -1199,8 +1373,7 @@ export async function npxProviders({
1199
1373
  fetchImpl = globalThis.fetch,
1200
1374
  readConfig = realInstaller.readGlobalConfig,
1201
1375
  } = {}) {
1202
- const configuredHubUrl = String(readConfig()?.hub_url ?? 'http://127.0.0.1:3210').replace(/\/$/, '');
1203
- if (configuredHubUrl !== 'http://127.0.0.1:3210') throw new Error('providers CLI requires the isolated 3210 Crew Hub');
1376
+ const configuredHubUrl = String(readConfig()?.hub_url ?? PRODUCTION_HUB_URL).replace(/\/$/, '');
1204
1377
  const hubUrl = configuredHubUrl;
1205
1378
  const base = `${hubUrl}/_dsh/dsh-crew/providers`;
1206
1379
  const action = args[0] ?? 'list';
@@ -1221,6 +1394,7 @@ export async function npxProviders({
1221
1394
  if (purgeOrphanCredentials === true || options.get('--purge-orphan-credentials') === true) {
1222
1395
  throw new Error('credential purge requires a separate explicit confirmation flow');
1223
1396
  }
1397
+ await assertProviderHubCapabilities({ hubUrl, action, fetchImpl });
1224
1398
  let url = base;
1225
1399
  let init = { headers: { accept: 'application/json' } };
1226
1400
  if (action === 'list') {
@@ -1293,6 +1467,42 @@ export async function npxProviders({
1293
1467
  return { ok: true, body };
1294
1468
  }
1295
1469
 
1470
+ /** Independent credential-reference inventory and irreversible purge CLI. */
1471
+ export async function npxCredentials({
1472
+ args = [],
1473
+ planId,
1474
+ expectedRevision,
1475
+ confirm = false,
1476
+ log = console.log,
1477
+ fetchImpl = globalThis.fetch,
1478
+ readConfig = realInstaller.readGlobalConfig,
1479
+ } = {}) {
1480
+ const hubUrl = String(readConfig()?.hub_url ?? PRODUCTION_HUB_URL).replace(/\/$/, '');
1481
+ const action = args[0] ?? 'list';
1482
+ const id = args[1];
1483
+ const requiredCapabilities = CREDENTIAL_CAPABILITY_REQUIREMENTS[action];
1484
+ if (!requiredCapabilities) throw new Error(`unknown credentials action: ${action}`);
1485
+ await assertProductionHub({ hubUrl, requiredCapabilities, purpose: 'credential', fetchImpl });
1486
+ const base = `${hubUrl}${CREW_ROUTE_BASE}/credential-references`;
1487
+ let url = base;
1488
+ let init = { headers: { accept: 'application/json' } };
1489
+ if (action === 'purge-plan') {
1490
+ if (!id) throw new Error('credentials purge-plan requires a reference id');
1491
+ url = `${base}/${encodeURIComponent(id)}/purge-plan`;
1492
+ init = { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ ...(expectedRevision ? { expected_revision: expectedRevision } : {}) }) };
1493
+ } else if (action === 'purge') {
1494
+ if (!id) throw new Error('credentials purge requires a reference id');
1495
+ if (!planId || !expectedRevision || confirm !== true) throw new Error('credentials purge requires --plan, --expected-revision and --confirm');
1496
+ url = `${base}/${encodeURIComponent(id)}`;
1497
+ init = { method: 'DELETE', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ plan_id: planId, expected_revision: expectedRevision, confirm: true }) };
1498
+ }
1499
+ const response = await fetchImpl(url, init);
1500
+ const body = await response.json();
1501
+ if (!response.ok || body?.ok === false) throw new Error(body?.code ?? body?.error ?? 'Crew credential API unavailable');
1502
+ log(JSON.stringify(body, null, 2));
1503
+ return { ok: true, body };
1504
+ }
1505
+
1296
1506
  function normalizeCommand(argv) {
1297
1507
  const flags = argv.slice(1);
1298
1508
  let candidate;
@@ -1372,7 +1582,7 @@ export async function runNpxCli({
1372
1582
  error(USAGE);
1373
1583
  return 1;
1374
1584
  }
1375
- if (unknown.length > 0 || !['install', 'integrate', 'detach', 'status', 'inspect', 'jobs', 'providers', 'update', 'uninstall'].includes(command)) {
1585
+ if (unknown.length > 0 || !['install', 'integrate', 'detach', 'status', 'inspect', 'jobs', 'providers', 'credentials', 'releases', 'rollback', 'update', 'uninstall'].includes(command)) {
1376
1586
  error(`unknown command: ${command ?? '<none>'}\n\n${USAGE}`);
1377
1587
  return 1;
1378
1588
  }
@@ -1385,6 +1595,9 @@ export async function runNpxCli({
1385
1595
  inspect: commands.inspect ?? npxInspect,
1386
1596
  jobs: commands.jobs ?? npxJobs,
1387
1597
  providers: commands.providers ?? npxProviders,
1598
+ credentials: commands.credentials ?? npxCredentials,
1599
+ releases: commands.releases ?? npxReleases,
1600
+ rollback: commands.rollback ?? npxRollback,
1388
1601
  update: commands.update ?? npxUpdate,
1389
1602
  uninstall: commands.uninstall ?? npxUninstall,
1390
1603
  };
@@ -1393,6 +1606,9 @@ export async function runNpxCli({
1393
1606
  else if (command === 'update') result = await actions.update({ candidate, log });
1394
1607
  else if (command === 'jobs') result = await actions.jobs({ args, after, detail, request, log });
1395
1608
  else if (command === 'providers') result = await actions.providers({ args, planId, expectedRevision, replacementDefault, confirm, purgeOrphanCredentials, log });
1609
+ else if (command === 'credentials') result = await actions.credentials({ args, planId, expectedRevision, confirm, log });
1610
+ else if (command === 'releases') result = await actions.releases({ args, log });
1611
+ else if (command === 'rollback') result = await actions.rollback({ version: args?.[0], args, log });
1396
1612
  else result = await actions[command]({ log });
1397
1613
  return result?.ok === false ? 1 : 0;
1398
1614
  } catch (err) {
@@ -8,9 +8,12 @@
8
8
  export const JOB_CONTRACT_SCHEMA_VERSION = 1;
9
9
 
10
10
  export const JOB_EVENT_TYPES = Object.freeze([
11
- 'job.created',
12
- 'job.started',
13
- 'model.selected',
11
+ 'job.created',
12
+ 'job.started',
13
+ 'runtime.bound',
14
+ 'model.selected',
15
+ 'model.admitted',
16
+ 'agent.created',
14
17
  'model.fallback',
15
18
  'worker.started',
16
19
  'worker.completed',
@@ -137,12 +140,14 @@ function boundedExecutionContext(value) {
137
140
  const profile = boundedText(value.profile, 64);
138
141
  const runtimeId = boundedText(value.runtime_id, 128);
139
142
  const port = Number.isInteger(value.listen_port) ? value.listen_port : null;
140
- if (!executionPlane && !profile && !runtimeId && port === null) return null;
143
+ const ingress = value.ingress === 'official-3080' || value.ingress === 'direct-3210' ? value.ingress : null;
144
+ if (!executionPlane && !profile && !runtimeId && port === null && !ingress) return null;
141
145
  return {
142
146
  execution_plane: executionPlane,
143
147
  profile,
144
148
  listen_port: port,
145
149
  runtime_id: runtimeId,
150
+ ...(ingress ? { ingress } : {}),
146
151
  };
147
152
  }
148
153
 
@@ -16,17 +16,11 @@ const HOP_BY_HOP = new Set([
16
16
  'te', 'trailer', 'transfer-encoding', 'upgrade', 'host', 'content-length',
17
17
  ]);
18
18
 
19
- export function resolveCrewBridgeTarget(env = process.env) {
20
- const raw = env?.DSH_CREW_BRIDGE_TARGET;
21
- if (!raw) return CREW_BRIDGE_TARGET;
22
- try {
23
- const target = new URL(raw);
24
- if (target.protocol !== 'http:' || !isLocalHostname(target.hostname.toLowerCase()) || target.pathname !== '/' || target.search || target.hash) return CREW_BRIDGE_TARGET;
25
- const port = Number(target.port);
26
- if (!Number.isInteger(port) || port < 1 || port > 65535) return CREW_BRIDGE_TARGET;
27
- return target.origin;
28
- } catch { return CREW_BRIDGE_TARGET; }
29
- }
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
+ }
30
24
 
31
25
  export function isTrustedLocalRequest(req) {
32
26
  if (!localRequestCore(req)) return false;
@@ -108,6 +102,7 @@ export function createCrewSidecarSupervisor({
108
102
  pollInterval = 250,
109
103
  ownershipFile = join(crewDshHome({ home }), 'supervisor-ownership.json'),
110
104
  } = {}) {
105
+ if (bridgeTarget !== CREW_BRIDGE_TARGET) throw new Error('Crew supervisor is fixed to the isolated 3210 Crew Hub');
111
106
  let starting = null;
112
107
  let restarting = null;
113
108
  let runningChild = null;
@@ -115,6 +115,10 @@ function configProjection(config) {
115
115
  return projection;
116
116
  }
117
117
 
118
+ function lifecycleProjection(state) {
119
+ return { tombstones: { ...(state?.tombstones ?? {}) } };
120
+ }
121
+
118
122
  function restoreConfigProjection(config, projection) {
119
123
  const next = typeof structuredClone === 'function' ? structuredClone(config) : JSON.parse(JSON.stringify(config));
120
124
  for (const key of ['flash_model_priority', 'pro_model_priority', 'harness_default', 'agent_default_model', 'agentDefaultModel']) {
@@ -229,6 +233,11 @@ export function createProviderDeleteFileHooks({
229
233
  replacement_default: plan.replacement_default ?? null,
230
234
  was_harness_default: plan.was_harness_default === true,
231
235
  replacement_default_model: plan.replacement_default_model ?? null,
236
+ credential_refs: (Array.isArray(plan.credential_refs) ? plan.credential_refs : []).map((ref) => ({
237
+ kind: typeof ref?.kind === 'string' ? ref.kind.trim() : 'unknown',
238
+ name_or_handle: typeof ref?.name_or_handle === 'string' ? ref.name_or_handle.trim() : 'unknown',
239
+ ownership: typeof ref?.ownership === 'string' ? ref.ownership.trim() : 'unknown',
240
+ })).filter((ref) => ref.kind && ref.name_or_handle && ref.ownership).slice(0, 32),
232
241
  },
233
242
  files: {},
234
243
  };
@@ -247,6 +256,8 @@ export function createProviderDeleteFileHooks({
247
256
  manifest.config_projection = configProjection(config);
248
257
  manifest.config_revision = sha256(JSON.stringify(config));
249
258
  manifest.lifecycle_revision = sha256(JSON.stringify(lifecycle));
259
+ manifest.routing_projection_digest = sha256(JSON.stringify(manifest.config_projection));
260
+ manifest.lifecycle_projection_digest = sha256(JSON.stringify(lifecycleProjection(lifecycle)));
250
261
  manifest.profile_revision = sha256(fs.readFileSync(profileFile, 'utf8'));
251
262
  activeBackup = { root, manifest };
252
263
  persistManifest();
@@ -381,8 +392,23 @@ export function createProviderDeleteFileHooks({
381
392
  const source = fs.readFileSync(profileFile, 'utf8');
382
393
  const parsed = readProviderDeclarations(source, { file: 'harness/profiles/dsh-crew/cordis.patch.yml' });
383
394
  const providerPresent = parsed.ok && parsed.declarations.some((declaration) => declaration.id === plan.provider_id);
395
+ const config = await readConfig();
384
396
  const state = normalizeProviderLifecycleState(readJson(lifecycleFile, {}));
385
- return { ok: providerPresent && state.tombstones[plan.provider_id] !== 'absent' };
397
+ const routingRestored = typeof activeBackup?.manifest?.routing_projection_digest === 'string'
398
+ && sha256(JSON.stringify(configProjection(config))) === activeBackup.manifest.routing_projection_digest;
399
+ const lifecycleRestored = typeof activeBackup?.manifest?.lifecycle_projection_digest === 'string'
400
+ && sha256(JSON.stringify(lifecycleProjection(state))) === activeBackup.manifest.lifecycle_projection_digest;
401
+ const declarationRestored = typeof activeBackup?.manifest?.profile_revision === 'string'
402
+ && sha256(source) === activeBackup.manifest.profile_revision;
403
+ return {
404
+ ok: providerPresent && state.tombstones[plan.provider_id] !== 'absent'
405
+ && routingRestored && lifecycleRestored && declarationRestored,
406
+ providerPresent,
407
+ tombstoneCleared: state.tombstones[plan.provider_id] !== 'absent',
408
+ routingRestored,
409
+ lifecycleRestored,
410
+ declarationRestored,
411
+ };
386
412
  };
387
413
 
388
414
  const recordTransaction = async (result, plan) => {
@@ -393,6 +419,7 @@ export function createProviderDeleteFileHooks({
393
419
  provider_id: result?.provider_id ?? plan?.provider_id,
394
420
  state: result?.state,
395
421
  expected_revision: plan?.expected_revision,
422
+ credential_refs: plan?.credential_refs,
396
423
  });
397
424
  if (result?.state === 'VERIFIED') {
398
425
  assertManagedPath(profileFile);
@@ -0,0 +1,37 @@
1
+ // Reconcile Crew-owned provider declarations with persisted lifecycle intent.
2
+ // Tombstones are lifecycle state, not a shadow catalog: they only prevent a
3
+ // previously deleted managed route from being re-seeded during install/update.
4
+
5
+ import {
6
+ inspectProviderProfile,
7
+ removeProviderDeclarations,
8
+ } from './provider-profile-store.mjs';
9
+
10
+ const PROVIDER_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
11
+
12
+ export function reconcileProviderDesiredState(source, { tombstones = {}, expectedRevision } = {}) {
13
+ const requested = Object.entries(tombstones ?? {})
14
+ .filter(([id, state]) => state === 'absent' && PROVIDER_ID.test(id))
15
+ .map(([id]) => id);
16
+ if (requested.length === 0) return { ok: true, changed: false, removed: [], text: source };
17
+
18
+ const inspected = inspectProviderProfile(source);
19
+ if (!inspected.ok) return { ok: false, code: inspected.code, revision: inspected.revision };
20
+ const declared = new Set(inspected.providerIds);
21
+ const toRemove = requested.filter((id) => declared.has(id));
22
+ if (toRemove.length === 0) return { ok: true, changed: false, removed: [], text: source, revision: inspected.revision };
23
+
24
+ const result = removeProviderDeclarations(source, {
25
+ providerIds: toRemove,
26
+ expectedRevision: expectedRevision ?? inspected.revision,
27
+ });
28
+ if (!result.ok) return result;
29
+ return {
30
+ ok: true,
31
+ changed: true,
32
+ removed: result.removed,
33
+ remaining: result.remaining,
34
+ text: result.text,
35
+ revision: result.revision,
36
+ };
37
+ }
@@ -17,6 +17,18 @@ function validRevision(value) {
17
17
  return typeof value === 'string' && REVISION_PATTERN.test(value);
18
18
  }
19
19
 
20
+ function normalizedCredentialRefs(value) {
21
+ if (!Array.isArray(value)) return undefined;
22
+ const refs = value.map((ref) => {
23
+ if (!ref || typeof ref !== 'object' || Array.isArray(ref)) return null;
24
+ const kind = typeof ref.kind === 'string' && ref.kind.trim() ? ref.kind.trim() : null;
25
+ const name = typeof ref.name_or_handle === 'string' && ref.name_or_handle.trim() ? ref.name_or_handle.trim() : null;
26
+ const ownership = typeof ref.ownership === 'string' && ref.ownership.trim() ? ref.ownership.trim() : null;
27
+ return kind && name && ownership ? { kind, name_or_handle: name, ownership } : null;
28
+ }).filter(Boolean).slice(0, 32);
29
+ return refs;
30
+ }
31
+
20
32
  function clone(value) {
21
33
  return JSON.parse(JSON.stringify(value));
22
34
  }
@@ -24,10 +36,12 @@ function clone(value) {
24
36
  function normalizedTransaction(value) {
25
37
  if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
26
38
  if (!validId(value.provider_id) || typeof value.state !== 'string' || !STATES.has(value.state)) return null;
39
+ const refs = normalizedCredentialRefs(value.credential_refs);
27
40
  return {
28
41
  provider_id: value.provider_id,
29
42
  state: value.state,
30
43
  ...(validRevision(value.expected_revision) ? { expected_revision: value.expected_revision } : {}),
44
+ ...(refs === undefined ? {} : { credential_refs: refs }),
31
45
  };
32
46
  }
33
47
 
@@ -70,7 +84,7 @@ export function clearProviderTombstone(state, providerId) {
70
84
  return out;
71
85
  }
72
86
 
73
- /** Record only non-secret transaction metadata; credential refs/values are ignored. */
87
+ /** Record non-secret transaction metadata and credential reference names only. */
74
88
  export function recordProviderTransaction(state, transaction = {}) {
75
89
  const transactionId = requireProviderId(transaction.transaction_id ?? transaction.plan_id);
76
90
  const normalized = normalizedTransaction(transaction);
@@ -18,7 +18,7 @@ export const PRODUCTION_PROFILE = 'dsh-crew';
18
18
  export const PRODUCTION_LISTEN_PORT = 3210;
19
19
  const RUNTIME_ID = randomUUID();
20
20
 
21
- export const RUNTIME_VERSION = '0.5.7';
21
+ export const RUNTIME_VERSION = '1.0.1';
22
22
  export const HUB_PROTOCOL_VERSION = 1;
23
23
 
24
24
  export const HUB_CAPABILITIES = Object.freeze([
@@ -33,10 +33,17 @@ export const HUB_CAPABILITIES = Object.freeze([
33
33
  'config',
34
34
  'canonical-events',
35
35
  'evidence',
36
- 'profiles',
37
- 'workspace-context',
38
- 'extension-contract',
39
- ]);
36
+ 'profiles',
37
+ 'workspace-context',
38
+ 'extension-contract',
39
+ 'provider-inventory',
40
+ 'provider-lifecycle-v1',
41
+ 'provider-health-v1',
42
+ 'provider-probe-stream-v1',
43
+ 'credential-reference-inventory-v1',
44
+ 'credential-purge-v1',
45
+ 'runtime-provenance-v1',
46
+ ]);
40
47
 
41
48
  // Capabilities the current MCP workflow depends on for full Hub execution.
42
49
  // Additional capabilities may be advertised without breaking compatibility.
@@ -56,6 +63,7 @@ export const HUB_COMPATIBILITY_CODES = Object.freeze({
56
63
  PROTOCOL_MISSING: 'HUB_PROTOCOL_MISSING',
57
64
  PROTOCOL_MISMATCH: 'HUB_PROTOCOL_MISMATCH',
58
65
  CAPABILITY_MISSING: 'HUB_CAPABILITY_MISSING',
66
+ PROVENANCE_MISSING: 'HUB_PROVENANCE_MISSING',
59
67
  EXECUTION_PLANE_MISMATCH: 'HUB_EXECUTION_PLANE_MISMATCH',
60
68
  LISTEN_PORT_MISMATCH: 'HUB_LISTEN_PORT_MISMATCH',
61
69
  });
@@ -87,7 +95,7 @@ export function getHubRuntimeIdentity() {
87
95
  * `compatible` means the response is from dsh-crew and satisfies the current
88
96
  * protocol + required capability contract.
89
97
  */
90
- export function evaluateHubHandshake(body, { requiredCapabilities = REQUIRED_HUB_CAPABILITIES } = {}) {
98
+ export function evaluateHubHandshake(body, { requiredCapabilities = REQUIRED_HUB_CAPABILITIES, strictProduction = false } = {}) {
91
99
  const capabilities = normalizedCapabilities(body?.capabilities);
92
100
  const base = {
93
101
  reachable: true,
@@ -122,9 +130,16 @@ export function evaluateHubHandshake(body, { requiredCapabilities = REQUIRED_HUB
122
130
  return { ...base, code: HUB_COMPATIBILITY_CODES.PROTOCOL_MISSING };
123
131
  }
124
132
 
125
- if (body.protocol_version !== HUB_PROTOCOL_VERSION) {
126
- return { ...base, code: HUB_COMPATIBILITY_CODES.PROTOCOL_MISMATCH };
127
- }
133
+ if (body.protocol_version !== HUB_PROTOCOL_VERSION) {
134
+ return { ...base, code: HUB_COMPATIBILITY_CODES.PROTOCOL_MISMATCH };
135
+ }
136
+
137
+ if (strictProduction && !(body?.execution_plane === PRODUCTION_EXECUTION_PLANE
138
+ && body?.profile === PRODUCTION_PROFILE
139
+ && Number.isInteger(body?.listen_port) && body.listen_port === PRODUCTION_LISTEN_PORT
140
+ && typeof body?.runtime_id === 'string' && body.runtime_id.trim().length > 0)) {
141
+ return { ...base, code: HUB_COMPATIBILITY_CODES.PROVENANCE_MISSING };
142
+ }
128
143
 
129
144
  const required = normalizedCapabilities(requiredCapabilities);
130
145
  const advertised = new Set(capabilities);