@ran-sh/dsh-crew 1.0.1 → 1.0.3
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 +28 -20
- package/README.zh.md +25 -18
- package/lib/client.js +336 -16
- package/official-web-bridge/lib/client.js +336 -16
- package/package.json +166 -166
- package/src/client/index.tsx +853 -716
- package/src/credential-reference.mjs +38 -0
- package/src/hub/index.mjs +890 -65
- package/src/install/install-legacy.mjs +184 -185
- package/src/install/npx-lifecycle.mjs +456 -398
- package/src/model-catalog.mjs +8 -1
- package/src/policy-legacy.mjs +2 -2
- package/src/provider-delete-adapters.mjs +1934 -451
- package/src/provider-inventory.mjs +184 -132
- package/src/provider-layer-migration-adapters.mjs +759 -0
- package/src/provider-layer-migration.mjs +198 -0
- package/src/provider-lifecycle-state.mjs +103 -95
- package/src/provider-lifecycle.mjs +252 -214
- package/src/provider-profile-store.mjs +378 -171
- package/src/provider-settings-store.mjs +291 -0
- package/src/provider-store-lock.mjs +67 -0
- package/src/runtime-identity.mjs +73 -72
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// Structure-aware access to the Crew-owned Harness settings.yaml provider map.
|
|
2
|
+
// Only the known llm-pi-ai.providers mapping is mutable; arbitrary YAML and
|
|
3
|
+
// credential values are never deserialized or returned.
|
|
4
|
+
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { classifyCredentialReference } from './credential-reference.mjs';
|
|
7
|
+
import { readProviderMaterialization } from './provider-profile-store.mjs';
|
|
8
|
+
|
|
9
|
+
const PROVIDER_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
|
|
10
|
+
|
|
11
|
+
function sha256(value) { return createHash('sha256').update(value, 'utf8').digest('hex'); }
|
|
12
|
+
function indentOf(line) { return (line.match(/^\s*/u) ?? [''])[0].length; }
|
|
13
|
+
function nonBlank(line) { return line.trim() !== '' && !line.trimStart().startsWith('#'); }
|
|
14
|
+
function scalarField(lines, entry, field) {
|
|
15
|
+
const pattern = new RegExp(`^\\s+${field}:\\s*(.*?)\\s*$`);
|
|
16
|
+
for (const line of lines.slice(entry.start, entry.end)) {
|
|
17
|
+
const match = line.match(pattern);
|
|
18
|
+
if (!match) continue;
|
|
19
|
+
const value = match[1].trim();
|
|
20
|
+
if (!value) return null;
|
|
21
|
+
return value.replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/u, '$1$2');
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseDefaultModel(source) {
|
|
27
|
+
if (typeof source !== 'string') return { ok: false, code: 'PROVIDER_SETTINGS_DEFAULT_UNSUPPORTED' };
|
|
28
|
+
const lines = source.split(/\r?\n/u);
|
|
29
|
+
const start = lines.findIndex((line) => /^agent-default-model:\s*$/u.test(line));
|
|
30
|
+
if (start < 0) return { ok: false, code: 'PROVIDER_SETTINGS_DEFAULT_UNSUPPORTED' };
|
|
31
|
+
let end = lines.length;
|
|
32
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
33
|
+
if (indentOf(lines[index]) === 0 && nonBlank(lines[index])) { end = index; break; }
|
|
34
|
+
}
|
|
35
|
+
const providerLine = lines.findIndex((line, index) => index > start && index < end && /^ {2}provider:\s*\S.*$/u.test(line));
|
|
36
|
+
const modelLine = lines.findIndex((line, index) => index > start && index < end && /^ {2}model:\s*\S.*$/u.test(line));
|
|
37
|
+
const provider = providerLine >= 0 ? lines[providerLine].replace(/^ {2}provider:\s*/u, '').trim() : null;
|
|
38
|
+
const model = modelLine >= 0 ? lines[modelLine].replace(/^ {2}model:\s*/u, '').trim() : null;
|
|
39
|
+
if (!provider || !model) return { ok: false, code: 'PROVIDER_SETTINGS_DEFAULT_UNSUPPORTED' };
|
|
40
|
+
return { ok: true, lines, start, end, providerLine, modelLine, provider, model };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseProviderMap(source) {
|
|
44
|
+
if (typeof source !== 'string') return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
|
|
45
|
+
const lines = source.split(/\r?\n/u);
|
|
46
|
+
const llmStart = lines.findIndex((line) => /^llm-pi-ai:\s*$/u.test(line));
|
|
47
|
+
if (llmStart < 0) return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
|
|
48
|
+
let blockEnd = lines.length;
|
|
49
|
+
for (let index = llmStart + 1; index < lines.length; index += 1) {
|
|
50
|
+
if (indentOf(lines[index]) === 0 && nonBlank(lines[index])) { blockEnd = index; break; }
|
|
51
|
+
}
|
|
52
|
+
const providersLine = lines.findIndex((line, index) => index > llmStart && index < blockEnd && /^ {2}providers:\s*(?:\{\s*\})?\s*$/u.test(line));
|
|
53
|
+
if (providersLine < 0) return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
|
|
54
|
+
let providersBlockEnd = blockEnd;
|
|
55
|
+
for (let index = providersLine + 1; index < blockEnd; index += 1) {
|
|
56
|
+
if (nonBlank(lines[index]) && indentOf(lines[index]) <= 2) { providersBlockEnd = index; break; }
|
|
57
|
+
}
|
|
58
|
+
const providerIndent = 4;
|
|
59
|
+
const entries = [];
|
|
60
|
+
for (let index = providersLine + 1; index < blockEnd; index += 1) {
|
|
61
|
+
const line = lines[index];
|
|
62
|
+
if (!nonBlank(line)) continue;
|
|
63
|
+
const indent = indentOf(line);
|
|
64
|
+
if (indent < providerIndent) {
|
|
65
|
+
if (indent > 2) return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
if (indent !== providerIndent) continue;
|
|
69
|
+
const match = line.match(/^ {4}([A-Za-z0-9][A-Za-z0-9._-]*):\s*$/u);
|
|
70
|
+
if (!match) return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
|
|
71
|
+
let end = index + 1;
|
|
72
|
+
while (end < blockEnd) {
|
|
73
|
+
const next = lines[end];
|
|
74
|
+
if (nonBlank(next) && indentOf(next) <= providerIndent) break;
|
|
75
|
+
end += 1;
|
|
76
|
+
}
|
|
77
|
+
entries.push({ id: match[1], start: index, end });
|
|
78
|
+
index = end - 1;
|
|
79
|
+
}
|
|
80
|
+
return { ok: true, lines, llmStart, blockEnd, providersLine, providersBlockEnd, entries };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const SENSITIVE_CREDENTIAL_FIELD = /(?:api[_-]?key|access[_-]?token|refresh[_-]?token|(?:^|[_-])token(?:$|[_-])|secret|password|authorization|credential|private[_-]?key|client[_-]?secret|cookie|bearer|webhook)/iu;
|
|
84
|
+
const REFERENCE_FIELD_SUFFIX = /(?:env|ref|name|handle|id|file|path)$/iu;
|
|
85
|
+
|
|
86
|
+
function isInlineCredentialLine(line) {
|
|
87
|
+
const inlineKeys = [...line.matchAll(/["']?([A-Za-z][A-Za-z0-9_.-]*)["']?\s*:/gu)].map((match) => match[1]);
|
|
88
|
+
if (inlineKeys.some((field) => SENSITIVE_CREDENTIAL_FIELD.test(field) && !REFERENCE_FIELD_SUFFIX.test(field.replace(/[_.-]/gu, '')))) return true;
|
|
89
|
+
const match = line.match(/^\s*["']?([A-Za-z][A-Za-z0-9_.-]*)["']?\s*:\s*(.*?)\s*$/u);
|
|
90
|
+
if (!match) return false;
|
|
91
|
+
const [, field, value] = match;
|
|
92
|
+
if (!value || /^(?:null|~|\{\}|\[\])$/u.test(value)) return false;
|
|
93
|
+
const normalized = field.replace(/[_.-]/gu, '');
|
|
94
|
+
if (SENSITIVE_CREDENTIAL_FIELD.test(field) && !REFERENCE_FIELD_SUFFIX.test(normalized)) return true;
|
|
95
|
+
return /(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password|authorization|credential|private[_-]?key|client[_-]?secret|cookie|bearer|webhook)\s*["']?\s*:/iu.test(value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function hasInlineProviderCredentials(source, { providerIds = [] } = {}) {
|
|
99
|
+
const parsed = parseProviderMap(source);
|
|
100
|
+
if (!parsed.ok) return { ok: false, code: parsed.code };
|
|
101
|
+
const requested = providerIds.length > 0 ? new Set(providerIds) : null;
|
|
102
|
+
const providers = parsed.entries.filter((entry) => !requested || requested.has(entry.id));
|
|
103
|
+
return { ok: true, inline: providers.some((entry) => parsed.lines.slice(entry.start, entry.end).some(isInlineCredentialLine)) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function inspectProviderSettings(source) {
|
|
107
|
+
const revision = typeof source === 'string' ? sha256(source) : null;
|
|
108
|
+
const parsed = parseProviderMap(source);
|
|
109
|
+
if (!parsed.ok) return { ok: false, code: parsed.code, revision };
|
|
110
|
+
return { ok: true, schema: 'harness-settings-llm-pi-ai-v1', providerIds: parsed.entries.map((entry) => entry.id), revision };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function readProviderSettingsDeclarations(source, { file = 'harness/settings.yaml' } = {}) {
|
|
114
|
+
const parsed = parseProviderMap(source);
|
|
115
|
+
if (!parsed.ok) return { ok: false, code: parsed.code };
|
|
116
|
+
return {
|
|
117
|
+
ok: true,
|
|
118
|
+
declarations: parsed.entries.map((entry) => {
|
|
119
|
+
const rawCredentialRef = scalarField(parsed.lines, entry, 'apiKeyEnv');
|
|
120
|
+
const credential = classifyCredentialReference(rawCredentialRef, { kind: 'env' });
|
|
121
|
+
return {
|
|
122
|
+
id: entry.id,
|
|
123
|
+
display_name: scalarField(parsed.lines, entry, 'displayName') ?? entry.id,
|
|
124
|
+
// settings.yaml is the Harness user layer. Crew profile/base
|
|
125
|
+
// declarations are reported separately by provider-profile-store.
|
|
126
|
+
origin: 'dynamic',
|
|
127
|
+
ownership: 'dynamic-user',
|
|
128
|
+
file,
|
|
129
|
+
declaration_authority: { kind: 'harness-settings', locator: `llm-pi-ai.providers.${entry.id}` },
|
|
130
|
+
...(credential.value ? { credential_ref: { kind: 'env', name_or_handle: credential.value, ownership: 'unknown' } } : {}),
|
|
131
|
+
...(credential.redacted ? { credential_status: 'present-redacted' } : {}),
|
|
132
|
+
};
|
|
133
|
+
}),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Return the same bounded provider projection as the profile parser, adapted
|
|
138
|
+
* to the Harness settings indentation. This is used only for rollback CAS and
|
|
139
|
+
* never exposes credential values. */
|
|
140
|
+
export function readProviderSettingsMaterialization(source, { providerId, file = 'harness/settings.yaml' } = {}) {
|
|
141
|
+
const parsed = parseProviderMap(source);
|
|
142
|
+
if (!parsed.ok) return { ok: false, code: parsed.code };
|
|
143
|
+
const entry = parsed.entries.find((candidate) => candidate.id === providerId);
|
|
144
|
+
if (!entry) return { ok: false, code: 'PROVIDER_NOT_FOUND' };
|
|
145
|
+
const block = parsed.lines.slice(entry.start, entry.end).map((line) => ` ${line}`);
|
|
146
|
+
const synthetic = ['- id: llm-pi-ai', ' config:', ' providers:', ...block, ''].join('\n');
|
|
147
|
+
const result = readProviderMaterialization(synthetic, { providerId, file });
|
|
148
|
+
return result.ok ? { ...result, provider: { ...result.provider, source_file: file } } : result;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function readHarnessDefault(source) {
|
|
152
|
+
const parsed = parseDefaultModel(source);
|
|
153
|
+
if (!parsed.ok) return { ok: false, code: parsed.code };
|
|
154
|
+
return { ok: true, provider: parsed.provider, model: parsed.model, locator: 'agent-default-model' };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function yamlScalar(value, max = 2048) {
|
|
158
|
+
if (typeof value !== 'string' || !value.trim() || value.length > max || /[\r\n]/u.test(value)) return null;
|
|
159
|
+
return JSON.stringify(value.trim());
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Add one already-sanitized provider projection to the Harness user layer.
|
|
164
|
+
* The operation is pure and revision-bound; callers perform the atomic write.
|
|
165
|
+
*/
|
|
166
|
+
export function addProviderSettings(source, { provider, expectedRevision } = {}) {
|
|
167
|
+
const currentRevision = typeof source === 'string' ? sha256(source) : null;
|
|
168
|
+
if (expectedRevision !== undefined && expectedRevision !== currentRevision) return { ok: false, code: 'PROVIDER_SETTINGS_CHANGED', revision: currentRevision };
|
|
169
|
+
if (!provider || typeof provider !== 'object' || Array.isArray(provider) || typeof provider.id !== 'string' || !PROVIDER_ID.test(provider.id)) {
|
|
170
|
+
return { ok: false, code: 'PROVIDER_MATERIALIZATION_INVALID', revision: currentRevision };
|
|
171
|
+
}
|
|
172
|
+
const parsed = parseProviderMap(source);
|
|
173
|
+
if (!parsed.ok) return { ok: false, code: parsed.code, revision: currentRevision };
|
|
174
|
+
if (parsed.entries.some((entry) => entry.id === provider.id)) return { ok: false, code: 'PROVIDER_SETTINGS_PROVIDER_EXISTS', revision: currentRevision };
|
|
175
|
+
const displayName = yamlScalar(provider.display_name ?? provider.id, 256);
|
|
176
|
+
if (!displayName) return { ok: false, code: 'PROVIDER_MATERIALIZATION_INVALID', revision: currentRevision };
|
|
177
|
+
const lines = [` ${provider.id}:`, ` displayName: ${displayName}`];
|
|
178
|
+
const api = yamlScalar(provider.api, 128);
|
|
179
|
+
const baseUrl = yamlScalar(provider.base_url, 2048);
|
|
180
|
+
const credential = provider.credential_ref ? classifyCredentialReference(provider.credential_ref, { kind: 'env' }).value : null;
|
|
181
|
+
if (provider.credential_ref && !credential) return { ok: false, code: 'PROVIDER_CREDENTIAL_REFERENCE_UNSAFE', revision: currentRevision };
|
|
182
|
+
if (api) lines.push(` api: ${api}`);
|
|
183
|
+
if (baseUrl) lines.push(` baseURL: ${baseUrl}`);
|
|
184
|
+
if (credential) lines.push(` apiKeyEnv: ${credential}`);
|
|
185
|
+
const models = Array.isArray(provider.models) ? provider.models.filter((model) => model && typeof model.id === 'string' && model.id.trim() && model.id.length <= 256).slice(0, 256) : [];
|
|
186
|
+
if (models.length > 0) {
|
|
187
|
+
lines.push(' models:');
|
|
188
|
+
for (const model of models) {
|
|
189
|
+
const id = yamlScalar(model.id, 256);
|
|
190
|
+
if (!id) continue;
|
|
191
|
+
lines.push(` - id: ${id}`);
|
|
192
|
+
const name = yamlScalar(model.name, 256);
|
|
193
|
+
if (name) lines.push(` name: ${name}`);
|
|
194
|
+
if (Number.isSafeInteger(model.context_window) && model.context_window > 0) lines.push(` contextWindow: ${model.context_window}`);
|
|
195
|
+
if (Number.isSafeInteger(model.max_tokens) && model.max_tokens > 0) lines.push(` maxTokens: ${model.max_tokens}`);
|
|
196
|
+
if (Array.isArray(model.input) && model.input.every((value) => value === 'text' || value === 'image')) lines.push(` input: [${[...new Set(model.input)].join(', ')}]`);
|
|
197
|
+
if (model.reasoning_efforts && typeof model.reasoning_efforts === 'object' && !Array.isArray(model.reasoning_efforts)) {
|
|
198
|
+
lines.push(' reasoningEfforts:');
|
|
199
|
+
for (const [key, value] of Object.entries(model.reasoning_efforts)) if (/^[A-Za-z][A-Za-z0-9_-]*$/u.test(key) && (value === null || yamlScalar(value, 256))) lines.push(` ${key}: ${value === null ? 'null' : yamlScalar(value, 256)}`);
|
|
200
|
+
}
|
|
201
|
+
if (model.compat && typeof model.compat === 'object' && !Array.isArray(model.compat) && Object.keys(model.compat).length === 0) lines.push(' compat: {}');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const nextLines = [...parsed.lines];
|
|
205
|
+
const insertAt = parsed.blockEnd;
|
|
206
|
+
if (/^ {2}providers:\s*\{\s*\}\s*$/u.test(nextLines[parsed.providersLine])) nextLines[parsed.providersLine] = ' providers:';
|
|
207
|
+
nextLines.splice(insertAt, 0, ...lines);
|
|
208
|
+
const newline = source.includes('\r\n') ? '\r\n' : '\n';
|
|
209
|
+
const text = nextLines.join(newline);
|
|
210
|
+
return { ok: true, text, added: provider.id, revision: sha256(text) };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function replaceHarnessDefault(source, { provider, model, expectedRevision } = {}) {
|
|
214
|
+
const currentRevision = typeof source === 'string' ? sha256(source) : null;
|
|
215
|
+
if (expectedRevision !== undefined && expectedRevision !== currentRevision) return { ok: false, code: 'PROVIDER_SETTINGS_CHANGED', revision: currentRevision };
|
|
216
|
+
if (typeof provider !== 'string' || !PROVIDER_ID.test(provider) || typeof model !== 'string' || !model.trim()) return { ok: false, code: 'PROVIDER_DEFAULT_REPLACEMENT_REQUIRED', revision: currentRevision };
|
|
217
|
+
const parsed = parseDefaultModel(source);
|
|
218
|
+
if (!parsed.ok) return { ok: false, code: parsed.code, revision: currentRevision };
|
|
219
|
+
const lines = [...parsed.lines];
|
|
220
|
+
lines[parsed.providerLine] = ` provider: ${provider.trim()}`;
|
|
221
|
+
lines[parsed.modelLine] = ` model: ${model.trim()}`;
|
|
222
|
+
const newline = source.includes('\r\n') ? '\r\n' : '\n';
|
|
223
|
+
const text = lines.join(newline);
|
|
224
|
+
return { ok: true, text, previous: { provider: parsed.provider, model: parsed.model }, revision: sha256(text) };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Apply all Harness settings mutations needed by one provider-delete plan in
|
|
229
|
+
* memory, then return one revision-bound text result for a single atomic write.
|
|
230
|
+
* This prevents replacing agent-default-model and removing its provider from
|
|
231
|
+
* racing each other on the same settings revision.
|
|
232
|
+
*/
|
|
233
|
+
export function mutateProviderSettings(source, {
|
|
234
|
+
providerId = null,
|
|
235
|
+
removeProvider = false,
|
|
236
|
+
replacementDefault = null,
|
|
237
|
+
replacementModel = null,
|
|
238
|
+
expectedRevision,
|
|
239
|
+
} = {}) {
|
|
240
|
+
const currentRevision = typeof source === 'string' ? sha256(source) : null;
|
|
241
|
+
if (expectedRevision !== undefined && expectedRevision !== currentRevision) return { ok: false, code: 'PROVIDER_SETTINGS_CHANGED', revision: currentRevision };
|
|
242
|
+
const parsed = removeProvider ? parseProviderMap(source) : { ok: true, lines: typeof source === 'string' ? source.split(/\r?\n/u) : [] };
|
|
243
|
+
if (!parsed.ok) return { ok: false, code: parsed.code, revision: currentRevision };
|
|
244
|
+
let providerEntry = null;
|
|
245
|
+
if (removeProvider) {
|
|
246
|
+
if (typeof providerId !== 'string' || !PROVIDER_ID.test(providerId)) return { ok: false, code: 'PROVIDER_NOT_FOUND', revision: currentRevision };
|
|
247
|
+
providerEntry = parsed.entries.find((entry) => entry.id === providerId) ?? null;
|
|
248
|
+
if (!providerEntry) return { ok: false, code: 'PROVIDER_NOT_FOUND', revision: currentRevision };
|
|
249
|
+
}
|
|
250
|
+
let defaultParsed = null;
|
|
251
|
+
if (replacementDefault !== null || replacementModel !== null) {
|
|
252
|
+
if (typeof replacementDefault !== 'string' || !PROVIDER_ID.test(replacementDefault) || typeof replacementModel !== 'string' || !replacementModel.trim()) return { ok: false, code: 'PROVIDER_DEFAULT_REPLACEMENT_REQUIRED', revision: currentRevision };
|
|
253
|
+
defaultParsed = parseDefaultModel(source);
|
|
254
|
+
if (!defaultParsed.ok) return { ok: false, code: defaultParsed.code, revision: currentRevision };
|
|
255
|
+
if (providerId && defaultParsed.provider !== providerId) return { ok: false, code: 'PROVIDER_DEFAULT_AUTHORITY_CHANGED', revision: currentRevision };
|
|
256
|
+
}
|
|
257
|
+
const ranges = providerEntry ? [[providerEntry.start, providerEntry.end]] : [];
|
|
258
|
+
const finalProvider = providerEntry && parsed.entries.length === 1;
|
|
259
|
+
const nextLines = parsed.lines.map((line, index) => {
|
|
260
|
+
if (ranges.some(([start, end]) => index >= start && index < end)) return null;
|
|
261
|
+
if (finalProvider && index === parsed.providersLine) return ' providers: {}';
|
|
262
|
+
if (defaultParsed?.providerLine === index) return ` provider: ${replacementDefault.trim()}`;
|
|
263
|
+
if (defaultParsed?.modelLine === index) return ` model: ${replacementModel.trim()}`;
|
|
264
|
+
return line;
|
|
265
|
+
}).filter((line) => line !== null);
|
|
266
|
+
const newline = source.includes('\r\n') ? '\r\n' : '\n';
|
|
267
|
+
const text = nextLines.join(newline);
|
|
268
|
+
return {
|
|
269
|
+
ok: true,
|
|
270
|
+
text,
|
|
271
|
+
...(providerEntry ? { removed: [providerId], remaining: parsed.entries.filter((entry) => entry.id !== providerId).map((entry) => entry.id) } : { removed: [], remaining: parsed.entries?.map((entry) => entry.id) ?? [] }),
|
|
272
|
+
...(defaultParsed ? { previous_default: { provider: defaultParsed.provider, model: defaultParsed.model } } : {}),
|
|
273
|
+
revision: sha256(text),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function removeProviderSettings(source, { providerIds = [], expectedRevision } = {}) {
|
|
278
|
+
const currentRevision = typeof source === 'string' ? sha256(source) : null;
|
|
279
|
+
if (expectedRevision !== undefined && expectedRevision !== currentRevision) return { ok: false, code: 'PROVIDER_SETTINGS_CHANGED', revision: currentRevision };
|
|
280
|
+
const requested = [...new Set(providerIds.filter((id) => typeof id === 'string' && PROVIDER_ID.test(id)))];
|
|
281
|
+
if (requested.length === 0 || requested.length !== providerIds.length) return { ok: false, code: 'PROVIDER_NOT_FOUND', revision: currentRevision };
|
|
282
|
+
let next = source;
|
|
283
|
+
const results = [];
|
|
284
|
+
for (const id of requested) {
|
|
285
|
+
const result = mutateProviderSettings(next, { providerId: id, removeProvider: true });
|
|
286
|
+
if (!result.ok) return { ...result, revision: currentRevision };
|
|
287
|
+
next = result.text;
|
|
288
|
+
results.push(result);
|
|
289
|
+
}
|
|
290
|
+
return { ok: true, text: next, removed: requested, remaining: results.at(-1)?.remaining ?? [], revision: sha256(next) };
|
|
291
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Persistent token-fenced lock shared by every lifecycle operation that can
|
|
2
|
+
// mutate the Harness profile/settings stores. A stale lock is reclaimed only
|
|
3
|
+
// while a serialized claim is held; malformed metadata fails closed.
|
|
4
|
+
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, resolve as resolvePath } from 'node:path';
|
|
8
|
+
|
|
9
|
+
function safeToken(value) { return typeof value === 'string' && /^[A-Za-z0-9-]{16,128}$/u.test(value); }
|
|
10
|
+
function readOwner(file) {
|
|
11
|
+
try {
|
|
12
|
+
const owner = JSON.parse(readFileSync(file, 'utf8'));
|
|
13
|
+
if (!owner || typeof owner !== 'object' || Array.isArray(owner) || !Number.isInteger(owner.pid) || owner.pid <= 0 || !safeToken(owner.token)) return { ok: false, malformed: true };
|
|
14
|
+
return { ok: true, owner };
|
|
15
|
+
} catch { return { ok: false, malformed: true }; }
|
|
16
|
+
}
|
|
17
|
+
function processAlive(pid) {
|
|
18
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error?.code === 'EPERM'; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function acquireProviderStoreLock(file) {
|
|
22
|
+
if (typeof file !== 'string' || !file.trim()) throw Object.assign(new Error('provider store lock path is required'), { code: 'PROVIDER_STORE_LOCK_UNAVAILABLE' });
|
|
23
|
+
const lockFile = resolvePath(file);
|
|
24
|
+
mkdirSync(dirname(lockFile), { recursive: true });
|
|
25
|
+
const claimFile = `${lockFile}.claim`;
|
|
26
|
+
const token = randomUUID();
|
|
27
|
+
let claimed = false;
|
|
28
|
+
try {
|
|
29
|
+
try { mkdirSync(claimFile); claimed = true; }
|
|
30
|
+
catch (error) { throw Object.assign(new Error('provider store lock is busy'), { code: error?.code === 'EEXIST' ? 'PROVIDER_STORE_LOCK_BUSY' : 'PROVIDER_STORE_LOCK_UNAVAILABLE' }); }
|
|
31
|
+
while (true) {
|
|
32
|
+
try {
|
|
33
|
+
writeFileSync(lockFile, JSON.stringify({ pid: process.pid, token, created_at: new Date().toISOString() }) + '\n', { flag: 'wx' });
|
|
34
|
+
return {
|
|
35
|
+
ok: true,
|
|
36
|
+
token,
|
|
37
|
+
release: async () => {
|
|
38
|
+
const current = readOwner(lockFile);
|
|
39
|
+
if (current.ok && current.owner.token === token) rmSync(lockFile, { force: true });
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error?.code !== 'EEXIST') throw Object.assign(new Error('provider store lock is unavailable'), { code: 'PROVIDER_STORE_LOCK_UNAVAILABLE' });
|
|
44
|
+
const current = readOwner(lockFile);
|
|
45
|
+
if (!current.ok) throw Object.assign(new Error('provider store lock metadata is invalid'), { code: 'PROVIDER_STORE_LOCK_BUSY' });
|
|
46
|
+
if (processAlive(current.owner.pid)) throw Object.assign(new Error('provider store lock is busy'), { code: 'PROVIDER_STORE_LOCK_BUSY' });
|
|
47
|
+
rmSync(lockFile, { force: true });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} finally { if (claimed) rmSync(claimFile, { recursive: true, force: true }); }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function recoverProviderStoreLock(file, { confirm = false } = {}) {
|
|
54
|
+
if (confirm !== true) return { ok: false, code: 'PROVIDER_STORE_LOCK_CONFIRM_REQUIRED' };
|
|
55
|
+
if (typeof file !== 'string' || !file.trim()) return { ok: false, code: 'PROVIDER_STORE_LOCK_UNAVAILABLE' };
|
|
56
|
+
const lockFile = resolvePath(file);
|
|
57
|
+
const claimFile = `${lockFile}.claim`;
|
|
58
|
+
try { mkdirSync(dirname(lockFile), { recursive: true }); mkdirSync(claimFile); }
|
|
59
|
+
catch (error) { return { ok: false, code: error?.code === 'EEXIST' ? 'PROVIDER_STORE_LOCK_BUSY' : 'PROVIDER_STORE_LOCK_UNAVAILABLE' }; }
|
|
60
|
+
try {
|
|
61
|
+
if (!existsSync(lockFile)) return { ok: true, recovered: false };
|
|
62
|
+
const current = readOwner(lockFile);
|
|
63
|
+
if (current.ok && processAlive(current.owner.pid)) return { ok: false, code: 'PROVIDER_STORE_LOCK_BUSY' };
|
|
64
|
+
rmSync(lockFile, { force: true });
|
|
65
|
+
return { ok: true, recovered: true };
|
|
66
|
+
} finally { rmSync(claimFile, { recursive: true, force: true }); }
|
|
67
|
+
}
|
package/src/runtime-identity.mjs
CHANGED
|
@@ -5,20 +5,20 @@
|
|
|
5
5
|
// - HUB_PROTOCOL_VERSION changes only when Hub <-> MCP wire semantics become
|
|
6
6
|
// incompatible.
|
|
7
7
|
//
|
|
8
|
-
// Keep this module dependency-light so Hub, MCP and tests all use the exact
|
|
9
|
-
// same compatibility rules.
|
|
10
|
-
|
|
11
|
-
import { randomUUID } from 'node:crypto';
|
|
12
|
-
|
|
13
|
-
// Production Crew execution is intentionally bound to the isolated 3210 Hub.
|
|
14
|
-
// These fields are public provenance only; no credential or session secret is
|
|
15
|
-
// included in the identity contract.
|
|
16
|
-
export const PRODUCTION_EXECUTION_PLANE = 'hub-3210';
|
|
17
|
-
export const PRODUCTION_PROFILE = 'dsh-crew';
|
|
18
|
-
export const PRODUCTION_LISTEN_PORT = 3210;
|
|
19
|
-
const RUNTIME_ID = randomUUID();
|
|
20
|
-
|
|
21
|
-
export const RUNTIME_VERSION = '1.0.
|
|
8
|
+
// Keep this module dependency-light so Hub, MCP and tests all use the exact
|
|
9
|
+
// same compatibility rules.
|
|
10
|
+
|
|
11
|
+
import { randomUUID } from 'node:crypto';
|
|
12
|
+
|
|
13
|
+
// Production Crew execution is intentionally bound to the isolated 3210 Hub.
|
|
14
|
+
// These fields are public provenance only; no credential or session secret is
|
|
15
|
+
// included in the identity contract.
|
|
16
|
+
export const PRODUCTION_EXECUTION_PLANE = 'hub-3210';
|
|
17
|
+
export const PRODUCTION_PROFILE = 'dsh-crew';
|
|
18
|
+
export const PRODUCTION_LISTEN_PORT = 3210;
|
|
19
|
+
const RUNTIME_ID = randomUUID();
|
|
20
|
+
|
|
21
|
+
export const RUNTIME_VERSION = '1.0.3';
|
|
22
22
|
export const HUB_PROTOCOL_VERSION = 1;
|
|
23
23
|
|
|
24
24
|
export const HUB_CAPABILITIES = Object.freeze([
|
|
@@ -33,17 +33,18 @@ 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
|
-
'provider-inventory',
|
|
40
|
-
'provider-
|
|
41
|
-
'provider-
|
|
42
|
-
'provider-
|
|
43
|
-
'
|
|
44
|
-
'credential-
|
|
45
|
-
'
|
|
46
|
-
|
|
36
|
+
'profiles',
|
|
37
|
+
'workspace-context',
|
|
38
|
+
'extension-contract',
|
|
39
|
+
'provider-inventory',
|
|
40
|
+
'provider-layer-migration-v1',
|
|
41
|
+
'provider-lifecycle-v1',
|
|
42
|
+
'provider-health-v1',
|
|
43
|
+
'provider-probe-stream-v1',
|
|
44
|
+
'credential-reference-inventory-v1',
|
|
45
|
+
'credential-purge-v1',
|
|
46
|
+
'runtime-provenance-v1',
|
|
47
|
+
]);
|
|
47
48
|
|
|
48
49
|
// Capabilities the current MCP workflow depends on for full Hub execution.
|
|
49
50
|
// Additional capabilities may be advertised without breaking compatibility.
|
|
@@ -60,12 +61,12 @@ export const HUB_COMPATIBILITY_CODES = Object.freeze({
|
|
|
60
61
|
UNREACHABLE: 'HUB_UNREACHABLE',
|
|
61
62
|
HTTP_ERROR: 'HUB_HTTP_ERROR',
|
|
62
63
|
SERVICE_MISMATCH: 'HUB_SERVICE_MISMATCH',
|
|
63
|
-
PROTOCOL_MISSING: 'HUB_PROTOCOL_MISSING',
|
|
64
|
-
PROTOCOL_MISMATCH: 'HUB_PROTOCOL_MISMATCH',
|
|
65
|
-
CAPABILITY_MISSING: 'HUB_CAPABILITY_MISSING',
|
|
66
|
-
PROVENANCE_MISSING: 'HUB_PROVENANCE_MISSING',
|
|
67
|
-
EXECUTION_PLANE_MISMATCH: 'HUB_EXECUTION_PLANE_MISMATCH',
|
|
68
|
-
LISTEN_PORT_MISMATCH: 'HUB_LISTEN_PORT_MISMATCH',
|
|
64
|
+
PROTOCOL_MISSING: 'HUB_PROTOCOL_MISSING',
|
|
65
|
+
PROTOCOL_MISMATCH: 'HUB_PROTOCOL_MISMATCH',
|
|
66
|
+
CAPABILITY_MISSING: 'HUB_CAPABILITY_MISSING',
|
|
67
|
+
PROVENANCE_MISSING: 'HUB_PROVENANCE_MISSING',
|
|
68
|
+
EXECUTION_PLANE_MISMATCH: 'HUB_EXECUTION_PLANE_MISMATCH',
|
|
69
|
+
LISTEN_PORT_MISMATCH: 'HUB_LISTEN_PORT_MISMATCH',
|
|
69
70
|
});
|
|
70
71
|
|
|
71
72
|
function normalizedCapabilities(value) {
|
|
@@ -74,17 +75,17 @@ function normalizedCapabilities(value) {
|
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
export function getHubRuntimeIdentity() {
|
|
77
|
-
return {
|
|
78
|
-
service: 'dsh-crew-hub',
|
|
79
|
-
surface: 'native-crew-harness',
|
|
80
|
-
ui_role: 'runtime',
|
|
81
|
-
runtime_version: RUNTIME_VERSION,
|
|
82
|
-
protocol_version: HUB_PROTOCOL_VERSION,
|
|
83
|
-
execution_plane: PRODUCTION_EXECUTION_PLANE,
|
|
84
|
-
profile: PRODUCTION_PROFILE,
|
|
85
|
-
listen_port: PRODUCTION_LISTEN_PORT,
|
|
86
|
-
runtime_id: RUNTIME_ID,
|
|
87
|
-
capabilities: [...HUB_CAPABILITIES],
|
|
78
|
+
return {
|
|
79
|
+
service: 'dsh-crew-hub',
|
|
80
|
+
surface: 'native-crew-harness',
|
|
81
|
+
ui_role: 'runtime',
|
|
82
|
+
runtime_version: RUNTIME_VERSION,
|
|
83
|
+
protocol_version: HUB_PROTOCOL_VERSION,
|
|
84
|
+
execution_plane: PRODUCTION_EXECUTION_PLANE,
|
|
85
|
+
profile: PRODUCTION_PROFILE,
|
|
86
|
+
listen_port: PRODUCTION_LISTEN_PORT,
|
|
87
|
+
runtime_id: RUNTIME_ID,
|
|
88
|
+
capabilities: [...HUB_CAPABILITIES],
|
|
88
89
|
};
|
|
89
90
|
}
|
|
90
91
|
|
|
@@ -95,51 +96,51 @@ export function getHubRuntimeIdentity() {
|
|
|
95
96
|
* `compatible` means the response is from dsh-crew and satisfies the current
|
|
96
97
|
* protocol + required capability contract.
|
|
97
98
|
*/
|
|
98
|
-
export function evaluateHubHandshake(body, { requiredCapabilities = REQUIRED_HUB_CAPABILITIES, strictProduction = false } = {}) {
|
|
99
|
+
export function evaluateHubHandshake(body, { requiredCapabilities = REQUIRED_HUB_CAPABILITIES, strictProduction = false } = {}) {
|
|
99
100
|
const capabilities = normalizedCapabilities(body?.capabilities);
|
|
100
101
|
const base = {
|
|
101
102
|
reachable: true,
|
|
102
103
|
compatible: false,
|
|
103
104
|
service: typeof body?.service === 'string' ? body.service : null,
|
|
104
|
-
runtime_version: typeof body?.runtime_version === 'string' ? body.runtime_version : null,
|
|
105
|
-
protocol_version: Number.isInteger(body?.protocol_version) ? body.protocol_version : null,
|
|
106
|
-
execution_plane: typeof body?.execution_plane === 'string' ? body.execution_plane : null,
|
|
107
|
-
profile: typeof body?.profile === 'string' ? body.profile : null,
|
|
108
|
-
listen_port: Number.isInteger(body?.listen_port) ? body.listen_port : null,
|
|
109
|
-
runtime_id: typeof body?.runtime_id === 'string' ? body.runtime_id : null,
|
|
105
|
+
runtime_version: typeof body?.runtime_version === 'string' ? body.runtime_version : null,
|
|
106
|
+
protocol_version: Number.isInteger(body?.protocol_version) ? body.protocol_version : null,
|
|
107
|
+
execution_plane: typeof body?.execution_plane === 'string' ? body.execution_plane : null,
|
|
108
|
+
profile: typeof body?.profile === 'string' ? body.profile : null,
|
|
109
|
+
listen_port: Number.isInteger(body?.listen_port) ? body.listen_port : null,
|
|
110
|
+
runtime_id: typeof body?.runtime_id === 'string' ? body.runtime_id : null,
|
|
110
111
|
capabilities,
|
|
111
112
|
missing_capabilities: [],
|
|
112
113
|
code: null,
|
|
113
114
|
};
|
|
114
115
|
|
|
115
|
-
if (body?.service !== 'dsh-crew-hub') {
|
|
116
|
-
return { ...base, code: HUB_COMPATIBILITY_CODES.SERVICE_MISMATCH };
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// An explicit execution identity that is not the isolated 3210 Crew plane
|
|
120
|
-
// must never be accepted as the production Hub. Missing legacy fields remain
|
|
121
|
-
// compatible with the older protocol contract and are handled below.
|
|
122
|
-
if (body?.execution_plane !== undefined && body.execution_plane !== PRODUCTION_EXECUTION_PLANE) {
|
|
123
|
-
return { ...base, code: HUB_COMPATIBILITY_CODES.EXECUTION_PLANE_MISMATCH };
|
|
124
|
-
}
|
|
125
|
-
if (body?.listen_port !== undefined && body.listen_port !== PRODUCTION_LISTEN_PORT) {
|
|
126
|
-
return { ...base, code: HUB_COMPATIBILITY_CODES.LISTEN_PORT_MISMATCH };
|
|
127
|
-
}
|
|
116
|
+
if (body?.service !== 'dsh-crew-hub') {
|
|
117
|
+
return { ...base, code: HUB_COMPATIBILITY_CODES.SERVICE_MISMATCH };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// An explicit execution identity that is not the isolated 3210 Crew plane
|
|
121
|
+
// must never be accepted as the production Hub. Missing legacy fields remain
|
|
122
|
+
// compatible with the older protocol contract and are handled below.
|
|
123
|
+
if (body?.execution_plane !== undefined && body.execution_plane !== PRODUCTION_EXECUTION_PLANE) {
|
|
124
|
+
return { ...base, code: HUB_COMPATIBILITY_CODES.EXECUTION_PLANE_MISMATCH };
|
|
125
|
+
}
|
|
126
|
+
if (body?.listen_port !== undefined && body.listen_port !== PRODUCTION_LISTEN_PORT) {
|
|
127
|
+
return { ...base, code: HUB_COMPATIBILITY_CODES.LISTEN_PORT_MISMATCH };
|
|
128
|
+
}
|
|
128
129
|
|
|
129
130
|
if (!Number.isInteger(body?.protocol_version)) {
|
|
130
131
|
return { ...base, code: HUB_COMPATIBILITY_CODES.PROTOCOL_MISSING };
|
|
131
132
|
}
|
|
132
133
|
|
|
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
|
-
}
|
|
134
|
+
if (body.protocol_version !== HUB_PROTOCOL_VERSION) {
|
|
135
|
+
return { ...base, code: HUB_COMPATIBILITY_CODES.PROTOCOL_MISMATCH };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (strictProduction && !(body?.execution_plane === PRODUCTION_EXECUTION_PLANE
|
|
139
|
+
&& body?.profile === PRODUCTION_PROFILE
|
|
140
|
+
&& Number.isInteger(body?.listen_port) && body.listen_port === PRODUCTION_LISTEN_PORT
|
|
141
|
+
&& typeof body?.runtime_id === 'string' && body.runtime_id.trim().length > 0)) {
|
|
142
|
+
return { ...base, code: HUB_COMPATIBILITY_CODES.PROVENANCE_MISSING };
|
|
143
|
+
}
|
|
143
144
|
|
|
144
145
|
const required = normalizedCapabilities(requiredCapabilities);
|
|
145
146
|
const advertised = new Set(capabilities);
|