@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
package/src/config-readiness.mjs
CHANGED
|
@@ -80,6 +80,8 @@ export function buildConfigReadinessMatrix({
|
|
|
80
80
|
providerCatalogBody = null,
|
|
81
81
|
providerInventoryChecked = false,
|
|
82
82
|
providerInventoryBody = null,
|
|
83
|
+
providerHealthChecked = false,
|
|
84
|
+
providerHealthBody = null,
|
|
83
85
|
hubJobsChecked = false,
|
|
84
86
|
hubJobsBody = null,
|
|
85
87
|
} = {}) {
|
|
@@ -130,6 +132,14 @@ export function buildConfigReadinessMatrix({
|
|
|
130
132
|
evidence_source: 'provider-inventory',
|
|
131
133
|
};
|
|
132
134
|
}
|
|
135
|
+
|
|
136
|
+
if (providerHealthChecked && providerHealthBody?.ok === false) {
|
|
137
|
+
evidence.provider_health = {
|
|
138
|
+
status: 'FAIL',
|
|
139
|
+
reason_code: 'PROVIDER_HEALTH_UNAVAILABLE',
|
|
140
|
+
evidence_source: 'provider-health',
|
|
141
|
+
};
|
|
142
|
+
}
|
|
133
143
|
|
|
134
144
|
const matrix = buildReadinessMatrix({
|
|
135
145
|
platform,
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Independent lifecycle planning for credential references. Actual purge is
|
|
2
|
+
// deliberately adapter-gated: provider deletion never implies credential
|
|
3
|
+
// deletion, and no default adapter reads or mutates secret stores.
|
|
4
|
+
|
|
5
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
export const CREDENTIAL_PURGE_STATES = Object.freeze([
|
|
8
|
+
'PLANNED', 'REFERENCE_RECHECKED', 'PURGE_PENDING_CONFIRMATION', 'PURGED', 'VERIFIED', 'FAILED',
|
|
9
|
+
]);
|
|
10
|
+
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,191}$/;
|
|
11
|
+
const REVISION_PATTERN = /^[a-f0-9]{64}$/;
|
|
12
|
+
|
|
13
|
+
function text(value) { return typeof value === 'string' && value.trim() ? value.trim() : null; }
|
|
14
|
+
function digest(value) { return createHash('sha256').update(JSON.stringify(value), 'utf8').digest('hex'); }
|
|
15
|
+
|
|
16
|
+
export function planCredentialPurge({ inventory, referenceId, expectedRevision } = {}) {
|
|
17
|
+
const id = text(referenceId);
|
|
18
|
+
if (!id || !ID_PATTERN.test(id)) return { ok: false, code: 'CREDENTIAL_REFERENCE_INVALID' };
|
|
19
|
+
const record = (Array.isArray(inventory?.records) ? inventory.records : []).find((entry) => entry?.reference_id === id);
|
|
20
|
+
if (!record) return { ok: false, code: 'CREDENTIAL_REFERENCE_NOT_FOUND' };
|
|
21
|
+
if (record.orphan !== true || Number(record.consumer_count) !== 0 || (record.consumers?.length ?? 0) !== 0) {
|
|
22
|
+
return { ok: false, code: 'CREDENTIAL_REFERENCE_IN_USE' };
|
|
23
|
+
}
|
|
24
|
+
if (record.ownership !== 'crew-owned' || record.purge_capability !== 'eligible') {
|
|
25
|
+
return { ok: false, code: 'CREDENTIAL_PURGE_NOT_ALLOWED' };
|
|
26
|
+
}
|
|
27
|
+
const revision = digest(record);
|
|
28
|
+
if (expectedRevision !== undefined) {
|
|
29
|
+
if (!REVISION_PATTERN.test(expectedRevision ?? '')) return { ok: false, code: 'CREDENTIAL_REFERENCE_REVISION_INVALID' };
|
|
30
|
+
if (expectedRevision !== revision) return { ok: false, code: 'CREDENTIAL_REFERENCE_CHANGED' };
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
ok: true,
|
|
34
|
+
plan: {
|
|
35
|
+
plan_id: randomUUID(),
|
|
36
|
+
reference_id: id,
|
|
37
|
+
kind: text(record.kind),
|
|
38
|
+
name_or_handle: text(record.name_or_handle),
|
|
39
|
+
ownership: 'crew-owned',
|
|
40
|
+
purge_capability: 'eligible',
|
|
41
|
+
expected_revision: revision,
|
|
42
|
+
state: 'PLANNED',
|
|
43
|
+
irreversible: true,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function executeCredentialPurge(plan, hooks = {}) {
|
|
49
|
+
if (!plan?.plan_id || !plan?.reference_id) return { ok: false, code: 'CREDENTIAL_PURGE_PLAN_INVALID' };
|
|
50
|
+
if (plan.state !== 'PLANNED') return { ok: false, code: 'CREDENTIAL_PURGE_PLAN_STATE_INVALID' };
|
|
51
|
+
if (plan.ownership !== 'crew-owned' || plan.purge_capability !== 'eligible') {
|
|
52
|
+
return { ok: false, code: 'CREDENTIAL_PURGE_NOT_ALLOWED' };
|
|
53
|
+
}
|
|
54
|
+
if (plan.irreversible !== true) return { ok: false, code: 'CREDENTIAL_PURGE_PLAN_INVALID' };
|
|
55
|
+
if (!REVISION_PATTERN.test(plan.expected_revision ?? '')) return { ok: false, code: 'CREDENTIAL_REFERENCE_REVISION_INVALID' };
|
|
56
|
+
if (typeof hooks.recheck !== 'function') return { ok: false, code: 'CREDENTIAL_REFERENCE_RECHECK_REQUIRED' };
|
|
57
|
+
const current = await hooks.recheck(plan.reference_id);
|
|
58
|
+
if (current?.ok !== true || current.revision !== plan.expected_revision || current.orphan !== true
|
|
59
|
+
|| current.ownership !== 'crew-owned' || current.purge_capability !== 'eligible') {
|
|
60
|
+
return { ok: false, code: 'CREDENTIAL_REFERENCE_CHANGED' };
|
|
61
|
+
}
|
|
62
|
+
if (typeof hooks.purge !== 'function' || typeof hooks.verify !== 'function') {
|
|
63
|
+
return { ok: false, code: 'CREDENTIAL_PURGE_UNAVAILABLE', state: 'PURGE_PENDING_CONFIRMATION' };
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
await hooks.purge(plan);
|
|
67
|
+
const verified = await hooks.verify(plan);
|
|
68
|
+
if (verified?.ok !== true) return { ok: false, code: 'CREDENTIAL_PURGE_VERIFY_FAILED', state: 'PURGED' };
|
|
69
|
+
return { ok: true, state: 'VERIFIED', reference_id: plan.reference_id, transaction_id: plan.plan_id };
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return { ok: false, code: text(error?.code) ?? 'CREDENTIAL_PURGE_FAILED', state: 'FAILED' };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Crew-owned credential purge adapter.
|
|
2
|
+
//
|
|
3
|
+
// The Harness credential store is intentionally narrow: only scalar env refs
|
|
4
|
+
// in the Crew-owned `.credentials.yaml` are supported. The adapter never
|
|
5
|
+
// returns or logs values and refuses paths outside the isolated Crew home.
|
|
6
|
+
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
8
|
+
import { existsSync, lstatSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { basename, dirname, isAbsolute, relative, resolve, join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
12
|
+
|
|
13
|
+
function failure(code, message = code) {
|
|
14
|
+
return Object.assign(new Error(message), { code });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function inside(root, file) {
|
|
18
|
+
const rel = relative(resolve(root), resolve(file));
|
|
19
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function assertOwnedRegularStore(root, file) {
|
|
23
|
+
const rootPath = resolve(root);
|
|
24
|
+
const filePath = resolve(file);
|
|
25
|
+
if (!existsSync(rootPath)) throw failure('CREDENTIAL_STORE_PATH_UNSAFE');
|
|
26
|
+
const rootStat = lstatSync(rootPath);
|
|
27
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw failure('CREDENTIAL_STORE_PATH_UNSAFE');
|
|
28
|
+
const relativePath = relative(rootPath, filePath);
|
|
29
|
+
if (relativePath === '' || relativePath.startsWith('..') || isAbsolute(relativePath)) throw failure('CREDENTIAL_STORE_PATH_UNSAFE');
|
|
30
|
+
const segments = relativePath.split(/[\\/]/u).filter(Boolean);
|
|
31
|
+
let current = rootPath;
|
|
32
|
+
for (const segment of segments.slice(0, -1)) {
|
|
33
|
+
current = join(current, segment);
|
|
34
|
+
if (!existsSync(current)) throw failure('CREDENTIAL_STORE_PATH_UNSAFE');
|
|
35
|
+
const stat = lstatSync(current);
|
|
36
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw failure('CREDENTIAL_STORE_PATH_UNSAFE');
|
|
37
|
+
}
|
|
38
|
+
if (!existsSync(filePath)) throw failure('CREDENTIAL_STORE_MISSING');
|
|
39
|
+
const fileStat = lstatSync(filePath);
|
|
40
|
+
if (!fileStat.isFile() || fileStat.isSymbolicLink()) throw failure('CREDENTIAL_STORE_PATH_UNSAFE');
|
|
41
|
+
const realRoot = realpathSync(rootPath);
|
|
42
|
+
const realFile = realpathSync(filePath);
|
|
43
|
+
if (!inside(realRoot, realFile)) throw failure('CREDENTIAL_STORE_PATH_UNSAFE');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseRefs(source) {
|
|
47
|
+
const lines = String(source ?? '').split(/\r?\n/);
|
|
48
|
+
const entries = [];
|
|
49
|
+
let refs = false;
|
|
50
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
51
|
+
const line = lines[index];
|
|
52
|
+
const trimmed = line.trim();
|
|
53
|
+
if (trimmed === '' || trimmed.startsWith('#')) continue;
|
|
54
|
+
const indent = line.length - line.trimStart().length;
|
|
55
|
+
if (!refs) {
|
|
56
|
+
if (indent === 0 && /^refs:\s*(?:#.*)?$/u.test(line)) refs = true;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (indent === 0) break;
|
|
60
|
+
if (indent !== 2) throw failure('CREDENTIAL_STORE_MALFORMED', 'credential refs shape is unsupported');
|
|
61
|
+
const match = /^ {2}([A-Za-z_][A-Za-z0-9_]*):(?:\s|$)/u.exec(line);
|
|
62
|
+
if (!match) throw failure('CREDENTIAL_STORE_MALFORMED', 'credential refs shape is unsupported');
|
|
63
|
+
entries.push({ key: match[1], index });
|
|
64
|
+
}
|
|
65
|
+
return { lines, entries };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function planEnvName(plan) {
|
|
69
|
+
if (plan?.kind !== 'env' || typeof plan?.name_or_handle !== 'string' || !ENV_NAME.test(plan.name_or_handle)) {
|
|
70
|
+
throw failure('CREDENTIAL_PURGE_KIND_UNSUPPORTED');
|
|
71
|
+
}
|
|
72
|
+
if (plan.reference_id !== `env:${plan.name_or_handle}`) throw failure('CREDENTIAL_REFERENCE_CHANGED');
|
|
73
|
+
return plan.name_or_handle;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readStore(file, crewHome) {
|
|
77
|
+
assertOwnedRegularStore(crewHome, file);
|
|
78
|
+
let source;
|
|
79
|
+
try { source = readFileSync(file, 'utf8'); } catch { throw failure('CREDENTIAL_STORE_UNREADABLE'); }
|
|
80
|
+
return { source, ...parseRefs(source) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function writeAtomic(file, content, crewHome) {
|
|
84
|
+
const dir = dirname(file);
|
|
85
|
+
assertOwnedRegularStore(crewHome, file);
|
|
86
|
+
const temp = join(dir, `.${basename(file)}.${process.pid}.${Date.now()}.${randomUUID()}.dsh-crew.tmp`);
|
|
87
|
+
try {
|
|
88
|
+
writeFileSync(temp, content, 'utf8');
|
|
89
|
+
assertOwnedRegularStore(crewHome, file);
|
|
90
|
+
renameSync(temp, file);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
try { rmSync(temp, { force: true }); } catch {}
|
|
93
|
+
throw failure('CREDENTIAL_STORE_WRITE_FAILED', error?.code ?? 'CREDENTIAL_STORE_WRITE_FAILED');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function createCredentialPurgeFileHooks({ credentialsFile, crewHome } = {}) {
|
|
98
|
+
if (typeof credentialsFile !== 'string' || typeof crewHome !== 'string'
|
|
99
|
+
|| !isAbsolute(credentialsFile) || !isAbsolute(crewHome)
|
|
100
|
+
|| !inside(crewHome, credentialsFile)) {
|
|
101
|
+
return { ok: false, code: 'CREDENTIAL_STORE_PATH_UNSAFE' };
|
|
102
|
+
}
|
|
103
|
+
const file = resolve(credentialsFile);
|
|
104
|
+
const root = resolve(crewHome);
|
|
105
|
+
return {
|
|
106
|
+
ok: true,
|
|
107
|
+
async purge(plan) {
|
|
108
|
+
const name = planEnvName(plan);
|
|
109
|
+
const current = readStore(file, root);
|
|
110
|
+
const entry = current.entries.find((candidate) => candidate.key === name);
|
|
111
|
+
if (!entry) throw failure('CREDENTIAL_REFERENCE_NOT_FOUND');
|
|
112
|
+
const newline = current.source.includes('\r\n') ? '\r\n' : '\n';
|
|
113
|
+
const lines = [...current.lines];
|
|
114
|
+
lines.splice(entry.index, 1);
|
|
115
|
+
writeAtomic(file, lines.join(newline), root);
|
|
116
|
+
return { ok: true, reference_id: plan.reference_id };
|
|
117
|
+
},
|
|
118
|
+
async verify(plan) {
|
|
119
|
+
const name = planEnvName(plan);
|
|
120
|
+
const current = readStore(file, root);
|
|
121
|
+
if (current.entries.some((candidate) => candidate.key === name)) {
|
|
122
|
+
return { ok: false, absent: false, reference_id: plan.reference_id };
|
|
123
|
+
}
|
|
124
|
+
return { ok: true, absent: true, reference_id: plan.reference_id };
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Durable, secret-free audit state for irreversible Crew credential purges.
|
|
2
|
+
|
|
3
|
+
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,191}$/u;
|
|
4
|
+
const MAX_RECORDS = 128;
|
|
5
|
+
|
|
6
|
+
function validId(value) { return typeof value === 'string' && ID_PATTERN.test(value); }
|
|
7
|
+
|
|
8
|
+
export function normalizeCredentialPurgeState(input = {}) {
|
|
9
|
+
const purged = {};
|
|
10
|
+
const unverified = {};
|
|
11
|
+
const entries = input && typeof input === 'object' && !Array.isArray(input) && input.purged && typeof input.purged === 'object' && !Array.isArray(input.purged)
|
|
12
|
+
? Object.entries(input.purged) : [];
|
|
13
|
+
for (const [referenceId, record] of entries.slice(-MAX_RECORDS)) {
|
|
14
|
+
if (!validId(referenceId) || !record || typeof record !== 'object' || Array.isArray(record) || !validId(record.transaction_id)) continue;
|
|
15
|
+
purged[referenceId] = { transaction_id: record.transaction_id };
|
|
16
|
+
}
|
|
17
|
+
const pending = input && typeof input === 'object' && !Array.isArray(input) && input.unverified && typeof input.unverified === 'object' && !Array.isArray(input.unverified)
|
|
18
|
+
? Object.entries(input.unverified) : [];
|
|
19
|
+
for (const [referenceId, record] of pending.slice(-MAX_RECORDS)) {
|
|
20
|
+
if (!validId(referenceId) || !record || typeof record !== 'object' || Array.isArray(record)
|
|
21
|
+
|| !validId(record.transaction_id) || record.state !== 'PURGED') continue;
|
|
22
|
+
unverified[referenceId] = { transaction_id: record.transaction_id, state: 'PURGED' };
|
|
23
|
+
}
|
|
24
|
+
return { schema_version: 1, purged, unverified };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function markCredentialPurged(input, plan) {
|
|
28
|
+
const state = normalizeCredentialPurgeState(input);
|
|
29
|
+
if (!validId(plan?.reference_id) || !validId(plan?.plan_id)) return state;
|
|
30
|
+
const unverified = { ...state.unverified };
|
|
31
|
+
delete unverified[plan.reference_id];
|
|
32
|
+
return {
|
|
33
|
+
schema_version: 1,
|
|
34
|
+
purged: { ...state.purged, [plan.reference_id]: { transaction_id: plan.plan_id } },
|
|
35
|
+
unverified,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function markCredentialPurgeUnverified(input, plan) {
|
|
40
|
+
const state = normalizeCredentialPurgeState(input);
|
|
41
|
+
if (!validId(plan?.reference_id) || !validId(plan?.plan_id)) return state;
|
|
42
|
+
const purged = { ...state.purged };
|
|
43
|
+
delete purged[plan.reference_id];
|
|
44
|
+
return {
|
|
45
|
+
schema_version: 1,
|
|
46
|
+
purged,
|
|
47
|
+
unverified: { ...state.unverified, [plan.reference_id]: { transaction_id: plan.plan_id, state: 'PURGED' } },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function recordCredentialPurgeOutcome(input, plan, result) {
|
|
52
|
+
if (result?.state === 'VERIFIED') return markCredentialPurged(input, plan);
|
|
53
|
+
if (result?.state === 'PURGED') return markCredentialPurgeUnverified(input, plan);
|
|
54
|
+
return normalizeCredentialPurgeState(input);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function isCredentialPurged(input, referenceId) {
|
|
58
|
+
return Object.prototype.hasOwnProperty.call(normalizeCredentialPurgeState(input).purged, referenceId);
|
|
59
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Secret-free credential reference inventory. This module only handles
|
|
2
|
+
// provider reference names/handles and ownership metadata; it never resolves
|
|
3
|
+
// or reads credential values.
|
|
4
|
+
|
|
5
|
+
const OWNERSHIP = new Map([
|
|
6
|
+
['crew', 'crew-owned'],
|
|
7
|
+
['crew-owned', 'crew-owned'],
|
|
8
|
+
['user', 'user-owned'],
|
|
9
|
+
['user-owned', 'user-owned'],
|
|
10
|
+
['harness', 'harness-owned'],
|
|
11
|
+
['harness-owned', 'harness-owned'],
|
|
12
|
+
['external', 'external'],
|
|
13
|
+
['external-env', 'external'],
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
function text(value) {
|
|
17
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizeReference(value) {
|
|
21
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
22
|
+
const kind = text(value.kind);
|
|
23
|
+
const name = text(value.name_or_handle);
|
|
24
|
+
const ownership = OWNERSHIP.get(text(value.ownership)?.toLowerCase() ?? '');
|
|
25
|
+
if (!kind || !name || !ownership) return null;
|
|
26
|
+
return { kind, name_or_handle: name, ownership };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function capability(ownership, orphan) {
|
|
30
|
+
if (ownership !== 'crew-owned') return 'report-only';
|
|
31
|
+
return orphan ? 'eligible' : 'eligible-after-last-consumer';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildCredentialReferenceInventory({ providers = [], additional_refs: additionalRefs = [], purged_refs: purgedRefs = [] } = {}) {
|
|
35
|
+
const entries = new Map();
|
|
36
|
+
const purged = new Set(Array.isArray(purgedRefs) ? purgedRefs.filter((value) => typeof value === 'string' && value.trim()) : []);
|
|
37
|
+
const add = (ref, consumer) => {
|
|
38
|
+
const normalized = normalizeReference(ref);
|
|
39
|
+
if (!normalized) return;
|
|
40
|
+
const referenceId = `${normalized.kind}:${normalized.name_or_handle}`;
|
|
41
|
+
if (purged.has(referenceId)) return;
|
|
42
|
+
const current = entries.get(referenceId) ?? {
|
|
43
|
+
reference_id: referenceId,
|
|
44
|
+
kind: normalized.kind,
|
|
45
|
+
name_or_handle: normalized.name_or_handle,
|
|
46
|
+
ownership: normalized.ownership,
|
|
47
|
+
consumers: new Set(),
|
|
48
|
+
};
|
|
49
|
+
// Conflicting ownership is deliberately conservative: never upgrade a
|
|
50
|
+
// reference into a purge-eligible class based on one declaration.
|
|
51
|
+
if (current.ownership !== normalized.ownership) current.ownership = 'unknown';
|
|
52
|
+
if (typeof consumer === 'string' && consumer.trim()) current.consumers.add(consumer.trim());
|
|
53
|
+
entries.set(referenceId, current);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
for (const provider of Array.isArray(providers) ? providers : []) {
|
|
57
|
+
const providerId = text(provider?.id);
|
|
58
|
+
if (!providerId) continue;
|
|
59
|
+
for (const ref of Array.isArray(provider?.credential_refs) ? provider.credential_refs : []) add(ref, providerId);
|
|
60
|
+
}
|
|
61
|
+
for (const ref of Array.isArray(additionalRefs) ? additionalRefs : []) add(ref, null);
|
|
62
|
+
|
|
63
|
+
const records = [...entries.values()].map((entry) => {
|
|
64
|
+
const consumers = [...entry.consumers].sort();
|
|
65
|
+
const orphan = consumers.length === 0;
|
|
66
|
+
return {
|
|
67
|
+
reference_id: entry.reference_id,
|
|
68
|
+
kind: entry.kind,
|
|
69
|
+
name_or_handle: entry.name_or_handle,
|
|
70
|
+
ownership: entry.ownership,
|
|
71
|
+
consumers,
|
|
72
|
+
consumer_count: consumers.length,
|
|
73
|
+
orphan,
|
|
74
|
+
purge_capability: entry.ownership === 'unknown' ? 'report-only' : capability(entry.ownership, orphan),
|
|
75
|
+
};
|
|
76
|
+
}).sort((a, b) => a.reference_id.localeCompare(b.reference_id));
|
|
77
|
+
return { ok: true, schema_version: 1, records };
|
|
78
|
+
}
|
package/src/dsh-cli-runtime.mjs
CHANGED
|
@@ -17,9 +17,10 @@ import {
|
|
|
17
17
|
unlinkSync,
|
|
18
18
|
writeFileSync,
|
|
19
19
|
} from 'node:fs';
|
|
20
|
-
import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
21
|
-
import { homedir } from 'node:os';
|
|
22
|
-
import { crewDshHome, crewProfileDir } from './install/install.mjs';
|
|
20
|
+
import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
21
|
+
import { homedir } from 'node:os';
|
|
22
|
+
import { crewDshHome, crewProfileDir } from './install/install.mjs';
|
|
23
|
+
import { reconcileProviderDesiredState } from './provider-desired-state.mjs';
|
|
23
24
|
|
|
24
25
|
export const DSH_CLI_PACKAGE = '@deepseek-ai/dsh';
|
|
25
26
|
export const CREW_DSH_RUNTIME_DIRNAME = 'runtime';
|
|
@@ -313,15 +314,37 @@ function readProfileManifestForRegistration(profileRoot, { create = true, defaul
|
|
|
313
314
|
return { profileManifest, manifest, created: false };
|
|
314
315
|
}
|
|
315
316
|
|
|
316
|
-
function ensureProfileScaffold(profileRoot) {
|
|
317
|
+
function ensureProfileScaffold(profileRoot) {
|
|
317
318
|
mkdirSync(profileRoot, { recursive: true });
|
|
318
319
|
let changed = false;
|
|
319
320
|
const patchFile = join(profileRoot, 'cordis.patch.yml');
|
|
320
321
|
if (!existsSync(patchFile)) { writeFileSync(patchFile, PROFILE_PATCH_TEMPLATE); changed = true; }
|
|
321
322
|
const workspaceFile = join(profileRoot, 'pnpm-workspace.yaml');
|
|
322
323
|
if (!existsSync(workspaceFile)) { writeFileSync(workspaceFile, PROFILE_PNPM_WORKSPACE); changed = true; }
|
|
323
|
-
return changed;
|
|
324
|
-
}
|
|
324
|
+
return changed;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function readCrewTombstones(home) {
|
|
328
|
+
const lifecycleFile = join(dirname(crewDshHome({ home })), 'provider-lifecycle.json');
|
|
329
|
+
if (!existsSync(lifecycleFile)) return { ok: true, tombstones: {} };
|
|
330
|
+
try {
|
|
331
|
+
const state = JSON.parse(readFileSync(lifecycleFile, 'utf8'));
|
|
332
|
+
return { ok: true, tombstones: state?.tombstones && typeof state.tombstones === 'object' && !Array.isArray(state.tombstones) ? state.tombstones : {} };
|
|
333
|
+
} catch { return { ok: false, code: 'PROVIDER_LIFECYCLE_STATE_INVALID' }; }
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function reconcileCrewProfileProviders({ home, profileRoot }) {
|
|
337
|
+
const patchFile = join(profileRoot, 'cordis.patch.yml');
|
|
338
|
+
if (!existsSync(patchFile)) return { ok: true, changed: false, removed: [] };
|
|
339
|
+
const lifecycle = readCrewTombstones(home);
|
|
340
|
+
if (!lifecycle.ok) return lifecycle;
|
|
341
|
+
const source = readFileSync(patchFile, 'utf8');
|
|
342
|
+
if (source.trim() === '[]') return { ok: true, changed: false, removed: [] };
|
|
343
|
+
const reconciled = reconcileProviderDesiredState(source, { tombstones: lifecycle.tombstones });
|
|
344
|
+
if (!reconciled.ok) return reconciled;
|
|
345
|
+
if (reconciled.changed) writeFileSync(patchFile, reconciled.text);
|
|
346
|
+
return reconciled;
|
|
347
|
+
}
|
|
325
348
|
|
|
326
349
|
function ensureDirectoryLink(linkPath, targetRoot) {
|
|
327
350
|
let stat;
|
|
@@ -408,9 +431,13 @@ export function ensurePluginRegistration({
|
|
|
408
431
|
} catch { return { ok: false, code: 'CREW_PLUGIN_NOT_LOADABLE' }; }
|
|
409
432
|
}
|
|
410
433
|
|
|
411
|
-
export function ensureCrewPluginRegistration({ home = homedir(), root, name } = {}) {
|
|
412
|
-
|
|
413
|
-
|
|
434
|
+
export function ensureCrewPluginRegistration({ home = homedir(), root, name } = {}) {
|
|
435
|
+
const result = ensurePluginRegistration({ profileRoot: crewProfileDir({ home }), root, name });
|
|
436
|
+
if (!result.ok) return result;
|
|
437
|
+
const reconciled = reconcileCrewProfileProviders({ home, profileRoot: result.profileRoot });
|
|
438
|
+
if (!reconciled.ok) return reconciled;
|
|
439
|
+
return { ...result, changed: result.changed || reconciled.changed, provider_reconciliation: reconciled.removed ?? [] };
|
|
440
|
+
}
|
|
414
441
|
|
|
415
442
|
/**
|
|
416
443
|
* Remove one Crew plugin registration without invoking a package manager.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// describes only DSH Crew and deliberately never advertises itself as a top-
|
|
3
3
|
// level Executor or control plane.
|
|
4
4
|
|
|
5
|
-
export const EXTENSION_CONTRACT_SCHEMA_VERSION = 1;
|
|
5
|
+
export const EXTENSION_CONTRACT_SCHEMA_VERSION = 1;
|
|
6
6
|
|
|
7
7
|
function row(matrix, id) {
|
|
8
8
|
return Array.isArray(matrix?.rows) ? matrix.rows.find((entry) => entry?.id === id) : undefined;
|
|
@@ -34,14 +34,15 @@ function readinessFromRow(entry, { pass = 'READY', notRun = 'DEGRADED' } = {}) {
|
|
|
34
34
|
return component('UNAVAILABLE', entry.reason_code ?? 'CHECK_FAILED');
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
export function buildExtensionContract({ config = {}, readinessMatrix = {}, workspace = null, profiles = null, runtime = null } = {}) {
|
|
37
|
+
export function buildExtensionContract({ config = {}, readinessMatrix = {}, readinessSnapshot = null, workspace = null, profiles = null, runtime = null } = {}) {
|
|
38
|
+
const matrix = readinessSnapshot?.readiness_matrix ?? readinessMatrix;
|
|
38
39
|
const workerEnabled = config.subagents_enabled !== false && config.worker_state !== 'disabled';
|
|
39
40
|
const reviewerEnabled = config.subagents_enabled !== false && config.review_state !== 'disabled';
|
|
40
41
|
const realModelEvidence = ['worker_primary_callable', 'model_execution', 'worker_escalation_callable', 'deepseek_flash', 'deepseek_pro']
|
|
41
|
-
.map((id) => row(
|
|
42
|
+
.map((id) => row(matrix, id))
|
|
42
43
|
.find((entry) => entry?.status === 'PASS');
|
|
43
|
-
const catalogEvidence = row(
|
|
44
|
-
const lifecycleEvidence = row(
|
|
44
|
+
const catalogEvidence = row(matrix, 'provider_catalog');
|
|
45
|
+
const lifecycleEvidence = row(matrix, 'provider_lifecycle_consistent');
|
|
45
46
|
const modelReadiness = catalogEvidence?.status === 'FAIL'
|
|
46
47
|
? readinessFromRow(catalogEvidence)
|
|
47
48
|
: realModelEvidence
|
|
@@ -50,14 +51,14 @@ export function buildExtensionContract({ config = {}, readinessMatrix = {}, work
|
|
|
50
51
|
? component('DEGRADED', 'MODEL_CATALOG_ONLY')
|
|
51
52
|
: component('UNAVAILABLE', catalogEvidence?.reason_code ?? 'NO_EVIDENCE');
|
|
52
53
|
const components = {
|
|
53
|
-
harness: readinessFromRow(row(
|
|
54
|
+
harness: readinessFromRow(row(matrix, 'hub_compatibility')),
|
|
54
55
|
provider_lifecycle: lifecycleEvidence
|
|
55
56
|
? readinessFromRow(lifecycleEvidence, { notRun: 'DEGRADED' })
|
|
56
57
|
: component('DEGRADED', 'PROVIDER_LIFECYCLE_NOT_CHECKED'),
|
|
57
58
|
model: modelReadiness,
|
|
58
59
|
workspace: workspaceComponent(workspace),
|
|
59
60
|
reviewer: reviewerEnabled
|
|
60
|
-
? readinessFromRow(row(
|
|
61
|
+
? readinessFromRow(row(matrix, 'reviewer_pipeline'))
|
|
61
62
|
: component('DEGRADED', 'REVIEWER_DISABLED'),
|
|
62
63
|
};
|
|
63
64
|
const states = Object.values(components).map((entry) => entry.status);
|
|
@@ -65,7 +66,8 @@ export function buildExtensionContract({ config = {}, readinessMatrix = {}, work
|
|
|
65
66
|
return {
|
|
66
67
|
schema_version: EXTENSION_CONTRACT_SCHEMA_VERSION,
|
|
67
68
|
kind: 'dsh-crew-extension',
|
|
68
|
-
runtime: runtime ?? null,
|
|
69
|
+
runtime: runtime ?? null,
|
|
70
|
+
...(readinessSnapshot ? { readiness_snapshot: readinessSnapshot } : {}),
|
|
69
71
|
capabilities: {
|
|
70
72
|
'deepseek.worker': workerEnabled,
|
|
71
73
|
'deepseek.reviewer': reviewerEnabled,
|