@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.
- package/README.md +6 -0
- package/README.zh.md +6 -0
- package/lib/client.js +223 -9
- package/official-web-bridge/lib/client.js +223 -9
- package/package.json +1 -1
- package/src/client/host-readiness.mjs +16 -3
- package/src/client/index.tsx +109 -8
- package/src/config-readiness.mjs +10 -0
- 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 +10 -8
- package/src/hub/index.mjs +175 -20
- package/src/hub-client.mjs +1 -1
- package/src/install/npx-lifecycle.mjs +241 -25
- package/src/job-contracts.mjs +9 -4
- package/src/official-web-bridge.mjs +6 -11
- package/src/provider-delete-adapters.mjs +28 -1
- package/src/provider-desired-state.mjs +37 -0
- package/src/provider-lifecycle-state.mjs +15 -1
- package/src/runtime-identity.mjs +24 -9
- package/src/runtime-readiness-snapshot.mjs +87 -0
- package/src/server.mjs +30 -5
|
@@ -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
|
|
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
|
|
1126
|
-
--expected-revision <sha256> with providers
|
|
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 ??
|
|
1141
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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 ??
|
|
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) {
|
package/src/job-contracts.mjs
CHANGED
|
@@ -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
|
-
'
|
|
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
|
-
|
|
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(
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
|
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);
|
package/src/runtime-identity.mjs
CHANGED
|
@@ -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.
|
|
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);
|