@ran-sh/dsh-crew 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +4 -0
  2. package/README.zh.md +4 -0
  3. package/codex/AGENTS.md +101 -92
  4. package/docs/ui-surfaces.md +48 -21
  5. package/lib/client.js +235 -34
  6. package/official-web-bridge/lib/client.js +368 -4532
  7. package/official-web-bridge/package.json +1 -2
  8. package/package.json +130 -67
  9. package/scripts/build-client.mjs +30 -15
  10. package/scripts/remove-legacy-official-bridge.ps1 +89 -0
  11. package/scripts/setup.mjs +152 -140
  12. package/scripts/verify-npm-install.mjs +311 -310
  13. package/scripts/verify-official-bridge-e2e.mjs +12 -1
  14. package/src/client/host-readiness.mjs +62 -59
  15. package/src/client/index.tsx +117 -24
  16. package/src/client/quick-entry.tsx +10 -0
  17. package/src/client/quick-panel.tsx +295 -0
  18. package/src/client/surface-detection.mjs +43 -30
  19. package/src/credential-reference.mjs +38 -0
  20. package/src/dsh-cli-runtime.mjs +500 -40
  21. package/src/dsh-cohort.mjs +20 -0
  22. package/src/hub/index.mjs +1602 -1016
  23. package/src/install/npx-lifecycle.mjs +1983 -468
  24. package/src/install/official-web.mjs +36 -73
  25. package/src/jobs.mjs +20 -24
  26. package/src/model-catalog.mjs +8 -1
  27. package/src/official-web-bridge.mjs +329 -249
  28. package/src/provider-delete-adapters.mjs +165 -17
  29. package/src/provider-inventory.mjs +17 -3
  30. package/src/provider-layer-migration-adapters.mjs +759 -0
  31. package/src/provider-layer-migration.mjs +198 -0
  32. package/src/provider-lifecycle-state.mjs +4 -1
  33. package/src/provider-profile-store.mjs +193 -4
  34. package/src/provider-settings-store.mjs +82 -6
  35. package/src/provider-store-lock.mjs +67 -0
  36. package/src/runtime-identity.mjs +24 -1
  37. package/src/supervisor/restart-request.mjs +202 -0
  38. package/windows/start-dsh-crew.ps1 +783 -370
  39. package/worker.cordis.yml +32 -46
  40. package/zcode/AGENTS.md +35 -26
@@ -0,0 +1,198 @@
1
+ // Pure planning for promoting legacy Crew profile providers into the Harness
2
+ // user settings layer. Planning never reads credentials or performs writes.
3
+
4
+ import { classifyCredentialReference } from './credential-reference.mjs';
5
+
6
+ const BUILTIN_PROVIDER_IDS = new Set(['deepseek-official']);
7
+
8
+ function text(value) {
9
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
10
+ }
11
+
12
+ function authorityKind(declaration) {
13
+ return text(declaration?.declaration_authority?.kind);
14
+ }
15
+
16
+ function providerId(declaration) {
17
+ return text(declaration?.id);
18
+ }
19
+
20
+ function safeCredentialRef(declaration) {
21
+ const ref = declaration?.credential_ref;
22
+ const kind = ref && typeof ref === 'object' && typeof ref.kind === 'string' ? ref.kind : 'env';
23
+ return classifyCredentialReference(ref, { kind }).value;
24
+ }
25
+
26
+ function safeProjection(declaration) {
27
+ return {
28
+ id: providerId(declaration),
29
+ display_name: text(declaration?.display_name) ?? providerId(declaration),
30
+ credential_ref: safeCredentialRef(declaration),
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Build a bounded, secret-free migration plan from composition/base provider
36
+ * declarations to Harness user settings. The caller supplies declarations
37
+ * already obtained from the two authoritative stores.
38
+ */
39
+ export function buildProviderLayerMigrationPlan({ declarations = [], catalogProviders = [], harnessDefault = null, routingReferences = [], tombstones = {}, recoveryTransactions = [], catalogEvidence = { ok: true, partial: false }, declarationEvidence = { ok: true }, lifecycleEvidence = { ok: true }, defaultEvidence = { ok: true } } = {}) {
40
+ const entries = Array.isArray(declarations) ? declarations : [];
41
+ const base = entries.filter((entry) => authorityKind(entry) === 'crew-profile' && providerId(entry));
42
+ const user = entries.filter((entry) => authorityKind(entry) === 'harness-settings' && providerId(entry));
43
+ const byBase = new Map(base.map((entry) => [providerId(entry), entry]));
44
+ const byUser = new Map(user.map((entry) => [providerId(entry), entry]));
45
+ const catalog = new Map((Array.isArray(catalogProviders) ? catalogProviders : [])
46
+ .map((entry) => [text(entry?.id), entry]).filter(([id]) => id));
47
+ const safeTombstones = tombstones && typeof tombstones === 'object' && !Array.isArray(tombstones) ? tombstones : {};
48
+ const safeRecoveryTransactions = Array.isArray(recoveryTransactions) ? recoveryTransactions : [];
49
+ const unresolvedRecovery = safeRecoveryTransactions.some((entry) => entry?.unresolved === true || !text(entry?.provider_id));
50
+ const pendingRecovery = safeRecoveryTransactions.length > 0;
51
+ const catalogUnavailable = catalogEvidence?.ok !== true || catalogEvidence?.partial === true;
52
+ const declarationUnavailable = declarationEvidence?.ok !== true;
53
+ const lifecycleUnavailable = lifecycleEvidence?.ok !== true;
54
+ const defaultUnavailable = defaultEvidence?.ok !== true;
55
+ const providers = [...new Set([...byBase.keys(), ...byUser.keys()])]
56
+ .filter((id) => !BUILTIN_PROVIDER_IDS.has(id))
57
+ .map((id) => {
58
+ const baseEntry = byBase.get(id) ?? null;
59
+ const userEntry = byUser.get(id) ?? null;
60
+ const catalogEntry = catalog.get(id) ?? null;
61
+ // Adapter ownership is a catalog fact, not a provider-name heuristic.
62
+ // An explicit true blocks the transition; missing metadata (including a
63
+ // missing catalog entry) also blocks a base-layer transition because
64
+ // reusing an unknown adapter id could hide the native Delete action.
65
+ const collision = catalogEntry?.adapter_owned === true;
66
+ const ownershipUnknown = baseEntry !== null && (catalogEntry === null || typeof catalogEntry.adapter_owned !== 'boolean');
67
+ const tombstoned = safeTombstones[id] === 'absent';
68
+ const recovery = safeRecoveryTransactions.find((entry) => text(entry?.provider_id) === id);
69
+ const blockedCode = lifecycleUnavailable
70
+ ? 'PROVIDER_LIFECYCLE_UNAVAILABLE'
71
+ : catalogUnavailable
72
+ ? 'MODEL_CATALOG_UNAVAILABLE'
73
+ : declarationUnavailable
74
+ ? 'PROVIDER_SOURCE_UNRESOLVED'
75
+ : defaultUnavailable
76
+ ? 'PROVIDER_DEFAULT_AUTHORITY_UNAVAILABLE'
77
+ : unresolvedRecovery
78
+ ? 'PROVIDER_DELETE_RECOVERY_UNRESOLVED'
79
+ : pendingRecovery
80
+ ? 'PROVIDER_DELETE_RECOVERY_PENDING'
81
+ : tombstoned
82
+ ? 'PROVIDER_ALREADY_ABSENT'
83
+ : null;
84
+ const ownershipCode = collision
85
+ ? 'HARNESS_PROVIDER_ID_COLLISION'
86
+ : ownershipUnknown ? 'HARNESS_PROVIDER_OWNERSHIP_UNAVAILABLE' : null;
87
+ const action = blockedCode ? 'blocked' : ownershipCode ? 'collision-review' : baseEntry && userEntry ? 'promote-existing-user' : baseEntry ? 'materialize-user' : 'none';
88
+ const currentNativeRemovable = userEntry !== null && baseEntry === null;
89
+ const migrationAction = action === 'promote-existing-user' || action === 'materialize-user';
90
+ return {
91
+ provider_id: id,
92
+ action,
93
+ current_native_removable: currentNativeRemovable,
94
+ target_native_removable: migrationAction ? true : currentNativeRemovable,
95
+ native_removable_after: migrationAction ? 'pending-verification' : ownershipCode || blockedCode ? 'unknown' : currentNativeRemovable,
96
+ requires_base_removal: baseEntry !== null,
97
+ target_user_layer: migrationAction || currentNativeRemovable,
98
+ source: { base: baseEntry ? safeProjection(baseEntry) : null, user: userEntry ? safeProjection(userEntry) : null },
99
+ credential_reference: safeCredentialRef(userEntry) ?? safeCredentialRef(baseEntry),
100
+ collision: ownershipCode ? { reason_code: ownershipCode, ...(ownershipCode === 'HARNESS_PROVIDER_ID_COLLISION' ? { requires_target_id: true } : {}) } : null,
101
+ ...(blockedCode ? { blocked_reason: blockedCode } : ownershipCode ? { blocked_reason: ownershipCode } : {}),
102
+ referenced_by: (Array.isArray(routingReferences) ? routingReferences : [])
103
+ .filter((ref) => ref?.provider === id).map((ref) => ({ provider: id, model: text(ref.model) })),
104
+ harness_default: harnessDefault?.provider === id,
105
+ };
106
+ });
107
+ return {
108
+ schema_version: 1,
109
+ kind: 'provider-layer-migration-plan',
110
+ requires_confirmation: providers.some((entry) => entry.action !== 'none'),
111
+ providers: providers.filter((entry) => entry.action !== 'none'),
112
+ blocked: providers.filter((entry) => entry.action === 'collision-review' || entry.action === 'blocked').map((entry) => ({
113
+ provider_id: entry.provider_id,
114
+ code: entry.blocked_reason ?? 'HARNESS_PROVIDER_ID_COLLISION',
115
+ })),
116
+ };
117
+ }
118
+
119
+ export function hasProviderLayerMigration(plan) {
120
+ return Array.isArray(plan?.providers) && plan.providers.length > 0;
121
+ }
122
+
123
+ const MIGRATION_STATES = Object.freeze(['PLANNED', 'APPLIED', 'RESTART_PENDING', 'VERIFYING', 'VERIFIED', 'FAILED']);
124
+
125
+ function migrationCode(error, fallback = 'PROVIDER_MIGRATION_FAILED') {
126
+ const code = text(error?.code);
127
+ return code && /^[A-Z][A-Z0-9_]{1,63}$/u.test(code) ? code : fallback;
128
+ }
129
+
130
+ /**
131
+ * Execute one confirmed provider-layer transition through explicit adapters.
132
+ * Adapters own filesystem/CAS/restart details; this state machine never reads
133
+ * credentials and never runs for a GET or an unconfirmed request.
134
+ */
135
+ export async function executeProviderLayerMigration(plan, hooks = {}, { confirm = false, deferRestart = false } = {}) {
136
+ if (confirm !== true) return { ok: false, state: 'BLOCKED', code: 'PROVIDER_MIGRATION_CONFIRM_REQUIRED', transaction_id: plan?.plan_id ?? null };
137
+ if (!plan?.plan_id || !text(plan.provider_id) || !['promote-existing-user', 'materialize-user'].includes(plan.action)) {
138
+ return { ok: false, state: 'BLOCKED', code: 'PROVIDER_MIGRATION_PLAN_INVALID', transaction_id: plan?.plan_id ?? null };
139
+ }
140
+ for (const name of ['backup', 'materialize', 'removeBase', 'rollback']) {
141
+ if (typeof hooks[name] !== 'function') return { ok: false, state: 'FAILED', code: 'PROVIDER_MIGRATION_HOOK_MISSING', transaction_id: plan.plan_id };
142
+ }
143
+ if (!deferRestart && (typeof hooks.restart !== 'function' || typeof hooks.verify !== 'function')) {
144
+ return { ok: false, state: 'FAILED', code: 'PROVIDER_MIGRATION_HOOK_MISSING', transaction_id: plan.plan_id };
145
+ }
146
+ let state = 'PLANNED';
147
+ const events = [{ state, at: new Date().toISOString() }];
148
+ let rollbackAttempted = false;
149
+ let rollbackError = null;
150
+ let verification = null;
151
+ try {
152
+ await hooks.backup(plan);
153
+ await hooks.materialize(plan);
154
+ await hooks.removeBase(plan);
155
+ state = 'APPLIED';
156
+ events.push({ state, at: new Date().toISOString() });
157
+ if (deferRestart) {
158
+ state = 'RESTART_PENDING';
159
+ events.push({ state, at: new Date().toISOString() });
160
+ } else {
161
+ const restarted = await hooks.restart(plan);
162
+ if (restarted?.ok === false) throw Object.assign(new Error('provider migration restart failed'), { code: restarted.code });
163
+ state = 'VERIFYING';
164
+ events.push({ state, at: new Date().toISOString() });
165
+ verification = await hooks.verify(plan);
166
+ if (verification?.nativeRemovable !== true || verification?.baseAbsent !== true || verification?.userPresent !== true) {
167
+ throw Object.assign(new Error('provider migration verification incomplete'), { code: 'PROVIDER_MIGRATION_VERIFY_FAILED' });
168
+ }
169
+ state = 'VERIFIED';
170
+ events.push({ state, at: new Date().toISOString() });
171
+ }
172
+ } catch (error) {
173
+ rollbackAttempted = true;
174
+ try { await hooks.rollback(plan); }
175
+ catch (rollbackErrorValue) { rollbackError = migrationCode(rollbackErrorValue, 'PROVIDER_MIGRATION_ROLLBACK_FAILED'); }
176
+ state = 'FAILED';
177
+ events.push({ state, at: new Date().toISOString() });
178
+ return {
179
+ ok: false,
180
+ state,
181
+ code: migrationCode(error),
182
+ transaction_id: plan.plan_id,
183
+ provider_id: plan.provider_id,
184
+ rollback_attempted: rollbackAttempted,
185
+ ...(rollbackError ? { rollback_error: rollbackError } : {}),
186
+ events,
187
+ };
188
+ }
189
+ return {
190
+ ok: state === 'VERIFIED' || state === 'RESTART_PENDING',
191
+ state,
192
+ transaction_id: plan.plan_id,
193
+ provider_id: plan.provider_id,
194
+ restart_required: true,
195
+ ...(verification ? { verification } : {}),
196
+ events,
197
+ };
198
+ }
@@ -2,6 +2,8 @@
2
2
  // Tombstones prevent an explicitly removed managed route from being re-seeded;
3
3
  // transactions retain only bounded audit metadata needed for recovery.
4
4
 
5
+ import { classifyCredentialReference } from './credential-reference.mjs';
6
+
5
7
  const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
6
8
  const REVISION_PATTERN = /^[a-f0-9]{64}$/;
7
9
  const STATES = new Set([
@@ -28,7 +30,8 @@ function normalizedCredentialRefs(value) {
28
30
  const kind = typeof ref.kind === 'string' && ref.kind.trim() ? ref.kind.trim() : null;
29
31
  const name = typeof ref.name_or_handle === 'string' && ref.name_or_handle.trim() ? ref.name_or_handle.trim() : null;
30
32
  const ownership = typeof ref.ownership === 'string' && ref.ownership.trim() ? ref.ownership.trim() : null;
31
- return kind && name && ownership ? { kind, name_or_handle: name, ownership } : null;
33
+ const safeName = classifyCredentialReference(name, { kind }).value;
34
+ return kind && safeName && ownership ? { kind, name_or_handle: safeName, ownership } : null;
32
35
  }).filter(Boolean).slice(0, 32);
33
36
  return refs;
34
37
  }
@@ -5,6 +5,7 @@
5
5
  // bounded, revision-checked text edits while preserving unrelated patch items.
6
6
 
7
7
  import { createHash } from 'node:crypto';
8
+ import { classifyCredentialReference } from './credential-reference.mjs';
8
9
 
9
10
  const PROVIDER_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
10
11
 
@@ -98,12 +99,12 @@ function scalarField(lines, entry, field) {
98
99
  if (!match) continue;
99
100
  const value = match[1].trim();
100
101
  if (!value) return null;
101
- return value.replace(/^(?:"([\\s\\S]*)"|'([\\s\\S]*)')$/, '$1$2');
102
+ return value.replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/u, '$1$2');
102
103
  }
103
104
  return null;
104
105
  }
105
106
 
106
- const SENSITIVE_CREDENTIAL_FIELD = /(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password|authorization|credential|private[_-]?key|client[_-]?secret|cookie|bearer|webhook)/iu;
107
+ const SENSITIVE_CREDENTIAL_FIELD = /(?:api[_-]?key|access[_-]?token|refresh[_-]?token|(?:^|[_-])token(?:$|[_-])|secret|password|authorization|credential|private[_-]?key|client[_-]?secret|cookie|bearer|webhook)/iu;
107
108
  const REFERENCE_FIELD_SUFFIX = /(?:env|ref|name|handle|id|file|path)$/iu;
108
109
 
109
110
  function isInlineCredentialLine(line) {
@@ -132,7 +133,8 @@ export function readProviderDeclarations(source, { file = 'profile.yml' } = {})
132
133
  if (!parsed.ok) return { ok: false, code: parsed.code };
133
134
  const declarations = parsed.entries.map((entry) => {
134
135
  const displayName = scalarField(parsed.lines, entry, 'displayName');
135
- const credentialRef = scalarField(parsed.lines, entry, 'apiKeyEnv');
136
+ const rawCredentialRef = scalarField(parsed.lines, entry, 'apiKeyEnv');
137
+ const credential = classifyCredentialReference(rawCredentialRef, { kind: 'env' });
136
138
  return {
137
139
  id: entry.id,
138
140
  display_name: displayName ?? entry.id,
@@ -140,12 +142,199 @@ export function readProviderDeclarations(source, { file = 'profile.yml' } = {})
140
142
  ownership: 'crew-managed-profile',
141
143
  file,
142
144
  declaration_authority: { kind: 'crew-profile', locator: `llm-pi-ai.config.providers.${entry.id}` },
143
- ...(credentialRef ? { credential_ref: credentialRef } : {}),
145
+ ...(credential.value ? { credential_ref: credential.value } : {}),
146
+ ...(credential.redacted ? { credential_status: 'present-redacted' } : {}),
144
147
  };
145
148
  });
146
149
  return { ok: true, declarations };
147
150
  }
148
151
 
152
+ function unquoteScalar(value) {
153
+ return typeof value === 'string'
154
+ ? value.trim().replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/u, '$1$2')
155
+ : null;
156
+ }
157
+
158
+ function providerModels(lines, entry) {
159
+ const modelsLine = lines.findIndex((line, index) => index >= entry.start && index < entry.end && /^\s+models:\s*$/u.test(line));
160
+ if (modelsLine < 0) return [];
161
+ const modelsIndent = (lines[modelsLine].match(/^\s*/u) ?? [''])[0].length;
162
+ const models = [];
163
+ const seenModelIds = new Set();
164
+ for (let index = modelsLine + 1; index < entry.end; index += 1) {
165
+ const line = lines[index];
166
+ if (!nonBlank(line)) continue;
167
+ const indent = indentOf(line);
168
+ if (indent <= modelsIndent) break;
169
+ const match = line.match(/^\s*-\s+id:\s*(.*?)\s*$/u);
170
+ if (!match) continue;
171
+ const id = unquoteScalar(match[1]);
172
+ if (!id || id.length > 256 || /[\r\n]/u.test(id) || seenModelIds.has(id)) return null;
173
+ seenModelIds.add(id);
174
+ const modelIndent = indentOf(line);
175
+ const model = { id };
176
+ const seenFields = new Set(['id']);
177
+ for (let child = index + 1; child < entry.end; child += 1) {
178
+ const childLine = lines[child];
179
+ if (!nonBlank(childLine)) continue;
180
+ const childIndent = indentOf(childLine);
181
+ if (childIndent <= modelsIndent || /^\s*-\s+id:\s*/u.test(childLine)) break;
182
+ if (childIndent !== modelIndent + 2) continue;
183
+ const fieldMatch = childLine.match(/^\s+([A-Za-z][A-Za-z0-9_-]*):\s*(.*?)\s*$/u);
184
+ if (!fieldMatch) return null;
185
+ const [, field, rawValue] = fieldMatch;
186
+ if (seenFields.has(field) || !['name', 'contextWindow', 'maxTokens', 'input', 'reasoningEfforts', 'compat'].includes(field)) return null;
187
+ seenFields.add(field);
188
+ if (field === 'name') {
189
+ const value = unquoteScalar(rawValue);
190
+ if (!value || value.length > 256 || /[\r\n]/u.test(value)) return null;
191
+ model.name = value;
192
+ } else if (field === 'contextWindow' || field === 'maxTokens') {
193
+ const value = Number(unquoteScalar(rawValue));
194
+ if (!Number.isSafeInteger(value) || value <= 0) return null;
195
+ model[field === 'contextWindow' ? 'context_window' : 'max_tokens'] = value;
196
+ } else if (field === 'input') {
197
+ const value = unquoteScalar(rawValue);
198
+ if (!/^\[[^\]\r\n]*\]$/u.test(value ?? '')) return null;
199
+ const modalities = value.slice(1, -1).split(',').map((item) => item.trim()).filter(Boolean);
200
+ if (modalities.some((item) => !['text', 'image'].includes(item))) return null;
201
+ model.input = [...new Set(modalities)];
202
+ } else if (field === 'reasoningEfforts') {
203
+ const efforts = {};
204
+ for (let nested = child + 1; nested < entry.end; nested += 1) {
205
+ const nestedLine = lines[nested];
206
+ if (!nonBlank(nestedLine)) continue;
207
+ const nestedIndent = indentOf(nestedLine);
208
+ if (nestedIndent <= childIndent) break;
209
+ if (nestedIndent !== childIndent + 2) return null;
210
+ const effortMatch = nestedLine.match(/^\s+([A-Za-z][A-Za-z0-9_-]*):\s*(.*?)\s*$/u);
211
+ if (!effortMatch || Object.hasOwn(efforts, effortMatch[1])) return null;
212
+ const effortValue = unquoteScalar(effortMatch[2]);
213
+ if (effortValue !== null && effortValue !== 'null' && effortValue !== '~' && (effortValue.length > 256 || /[\r\n]/u.test(effortValue))) return null;
214
+ efforts[effortMatch[1]] = effortValue === null || effortValue === 'null' || effortValue === '~' ? null : effortValue;
215
+ }
216
+ model.reasoning_efforts = efforts;
217
+ } else if (field === 'compat') {
218
+ if (rawValue.trim() !== '{}') return null;
219
+ model.compat = {};
220
+ }
221
+ }
222
+ models.push(model);
223
+ }
224
+ return models.slice(0, 256);
225
+ }
226
+
227
+ /**
228
+ * Return the bounded, non-secret fields needed to materialize one profile
229
+ * provider in Harness settings. This is read-only and intentionally separate
230
+ * from readProviderDeclarations so existing provenance consumers stay stable.
231
+ */
232
+ export function readProviderMaterialization(source, { providerId, file = 'profile.yml' } = {}) {
233
+ const parsed = parseProviderMap(source);
234
+ if (!parsed.ok) return { ok: false, code: parsed.code };
235
+ const entry = parsed.entries.find((candidate) => candidate.id === providerId);
236
+ if (!entry) return { ok: false, code: 'PROVIDER_NOT_FOUND' };
237
+ const providerIndent = indentOf(parsed.lines[entry.start]);
238
+ const directScalarField = (field) => {
239
+ const pattern = new RegExp(`^\\s{${providerIndent + 2}}${field}:\\s*(.*?)\\s*$`);
240
+ for (const line of parsed.lines.slice(entry.start, entry.end)) {
241
+ const match = line.match(pattern);
242
+ if (match) return unquoteScalar(match[1]);
243
+ }
244
+ return null;
245
+ };
246
+ const credentialRef = directScalarField('apiKeyEnv');
247
+ const credential = classifyCredentialReference(credentialRef, { kind: 'env' });
248
+ if (credential.redacted) return { ok: false, code: 'PROVIDER_CREDENTIAL_REFERENCE_UNSAFE' };
249
+ const readBounded = (field, max = 2048) => {
250
+ const value = directScalarField(field);
251
+ return value && value.length <= max && !/[\r\n]/u.test(value) ? value : null;
252
+ };
253
+ const knownFields = new Set(['displayName', 'apiKeyEnv', 'api', 'baseURL', 'models']);
254
+ const directFields = parsed.lines.slice(entry.start + 1, entry.end)
255
+ .filter((line) => nonBlank(line) && indentOf(line) === providerIndent + 2)
256
+ .map((line) => line.match(/^\s+([A-Za-z][A-Za-z0-9_-]*):/u)?.[1])
257
+ .filter(Boolean);
258
+ const unknownFields = directFields.filter((field) => !knownFields.has(field));
259
+ if (unknownFields.length > 0 || new Set(directFields).size !== directFields.length) return { ok: false, code: 'PROVIDER_MATERIALIZATION_UNSUPPORTED_FIELDS' };
260
+ const baseUrl = readBounded('baseURL');
261
+ if (baseUrl) {
262
+ try {
263
+ const parsedUrl = new URL(baseUrl);
264
+ if (!['http:', 'https:'].includes(parsedUrl.protocol) || parsedUrl.username || parsedUrl.password || parsedUrl.search || parsedUrl.hash || /[?#]/u.test(baseUrl) || !parsedUrl.hostname) {
265
+ return { ok: false, code: 'PROVIDER_BASE_URL_UNSAFE' };
266
+ }
267
+ } catch { return { ok: false, code: 'PROVIDER_BASE_URL_UNSAFE' }; }
268
+ }
269
+ const api = readBounded('api', 128);
270
+ if (api && !/^[A-Za-z][A-Za-z0-9._-]{0,127}$/u.test(api) || /^(?:sk|pk|rk|token|secret)[_-]/iu.test(api ?? '')) return { ok: false, code: 'PROVIDER_API_SCHEMA_UNSUPPORTED' };
271
+ const provider = {
272
+ id: entry.id,
273
+ display_name: readBounded('displayName', 256) ?? entry.id,
274
+ ...(credential.value ? { credential_ref: credential.value } : {}),
275
+ ...(api ? { api } : {}),
276
+ ...(baseUrl ? { base_url: baseUrl } : {}),
277
+ models: providerModels(parsed.lines, entry),
278
+ source_file: file,
279
+ };
280
+ if (provider.models === null) return { ok: false, code: 'PROVIDER_MODEL_SCHEMA_UNSUPPORTED' };
281
+ return { ok: true, provider };
282
+ }
283
+
284
+ function yamlScalar(value, max = 2048) {
285
+ if (typeof value !== 'string' || !value.trim() || value.length > max || /[\r\n]/u.test(value)) return null;
286
+ return JSON.stringify(value.trim());
287
+ }
288
+
289
+ /** Re-add one safe provider projection during an explicit migration rollback. */
290
+ export function addProviderDeclaration(source, { provider, expectedRevision } = {}) {
291
+ const currentRevision = typeof source === 'string' ? sha256(source) : null;
292
+ if (expectedRevision !== undefined && expectedRevision !== currentRevision) return { ok: false, code: 'PROVIDER_PROFILE_CHANGED', revision: currentRevision };
293
+ if (!provider || typeof provider !== 'object' || Array.isArray(provider) || !PROVIDER_ID.test(provider.id ?? '')) return { ok: false, code: 'PROVIDER_MATERIALIZATION_INVALID', revision: currentRevision };
294
+ const parsed = parseProviderMap(source);
295
+ if (!parsed.ok) return { ok: false, code: parsed.code, revision: currentRevision };
296
+ if (parsed.entries.some((entry) => entry.id === provider.id)) return { ok: false, code: 'PROVIDER_PROFILE_PROVIDER_EXISTS', revision: currentRevision };
297
+ const displayName = yamlScalar(provider.display_name ?? provider.id, 256);
298
+ if (!displayName) return { ok: false, code: 'PROVIDER_MATERIALIZATION_INVALID', revision: currentRevision };
299
+ const providerIndent = indentOf(parsed.lines[parsed.providersLine]) + 2;
300
+ const fieldIndent = providerIndent + 2;
301
+ const lines = [`${' '.repeat(providerIndent)}${provider.id}:`, `${' '.repeat(fieldIndent)}displayName: ${displayName}`];
302
+ const api = yamlScalar(provider.api, 128);
303
+ const baseUrl = yamlScalar(provider.base_url, 2048);
304
+ const credential = provider.credential_ref ? classifyCredentialReference(provider.credential_ref, { kind: 'env' }).value : null;
305
+ if (provider.credential_ref && !credential) return { ok: false, code: 'PROVIDER_CREDENTIAL_REFERENCE_UNSAFE', revision: currentRevision };
306
+ if (api) lines.push(`${' '.repeat(fieldIndent)}api: ${api}`);
307
+ if (baseUrl) lines.push(`${' '.repeat(fieldIndent)}baseURL: ${baseUrl}`);
308
+ if (credential) lines.push(`${' '.repeat(fieldIndent)}apiKeyEnv: ${credential}`);
309
+ 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) : [];
310
+ if (models.length > 0) {
311
+ lines.push(`${' '.repeat(fieldIndent)}models:`);
312
+ for (const model of models) {
313
+ const id = yamlScalar(model.id, 256);
314
+ if (!id) continue;
315
+ lines.push(`${' '.repeat(fieldIndent + 2)}- id: ${id}`);
316
+ const name = yamlScalar(model.name, 256);
317
+ if (name) lines.push(`${' '.repeat(fieldIndent + 4)}name: ${name}`);
318
+ if (Number.isSafeInteger(model.context_window) && model.context_window > 0) lines.push(`${' '.repeat(fieldIndent + 4)}contextWindow: ${model.context_window}`);
319
+ if (Number.isSafeInteger(model.max_tokens) && model.max_tokens > 0) lines.push(`${' '.repeat(fieldIndent + 4)}maxTokens: ${model.max_tokens}`);
320
+ if (Array.isArray(model.input) && model.input.every((value) => value === 'text' || value === 'image')) lines.push(`${' '.repeat(fieldIndent + 4)}input: [${[...new Set(model.input)].join(', ')}]`);
321
+ if (model.reasoning_efforts && typeof model.reasoning_efforts === 'object' && !Array.isArray(model.reasoning_efforts)) {
322
+ lines.push(`${' '.repeat(fieldIndent + 4)}reasoningEfforts:`);
323
+ 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(`${' '.repeat(fieldIndent + 6)}${key}: ${value === null ? 'null' : yamlScalar(value, 256)}`);
324
+ }
325
+ if (model.compat && typeof model.compat === 'object' && !Array.isArray(model.compat) && Object.keys(model.compat).length === 0) lines.push(`${' '.repeat(fieldIndent + 4)}compat: {}`);
326
+ }
327
+ }
328
+ const nextLines = [...parsed.lines];
329
+ if (/^\s+providers:\s*\{\s*\}\s*$/u.test(nextLines[parsed.providersLine])) {
330
+ nextLines[parsed.providersLine] = `${' '.repeat(indentOf(nextLines[parsed.providersLine]))}providers:`;
331
+ }
332
+ nextLines.splice(parsed.blockEnd, 0, ...lines);
333
+ const newline = source.includes('\r\n') ? '\r\n' : '\n';
334
+ const text = nextLines.join(newline);
335
+ return { ok: true, text, added: provider.id, revision: sha256(text) };
336
+ }
337
+
149
338
  /**
150
339
  * Remove provider declarations from a known profile patch after an optional
151
340
  * content-revision check. The returned text is safe to write atomically by the
@@ -3,6 +3,8 @@
3
3
  // credential values are never deserialized or returned.
4
4
 
5
5
  import { createHash } from 'node:crypto';
6
+ import { classifyCredentialReference } from './credential-reference.mjs';
7
+ import { readProviderMaterialization } from './provider-profile-store.mjs';
6
8
 
7
9
  const PROVIDER_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
8
10
 
@@ -16,7 +18,7 @@ function scalarField(lines, entry, field) {
16
18
  if (!match) continue;
17
19
  const value = match[1].trim();
18
20
  if (!value) return null;
19
- return value.replace(/^(?:"([\\s\\S]*)"|'([\\s\\S]*)')$/u, '$1$2');
21
+ return value.replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/u, '$1$2');
20
22
  }
21
23
  return null;
22
24
  }
@@ -78,7 +80,7 @@ function parseProviderMap(source) {
78
80
  return { ok: true, lines, llmStart, blockEnd, providersLine, providersBlockEnd, entries };
79
81
  }
80
82
 
81
- const SENSITIVE_CREDENTIAL_FIELD = /(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password|authorization|credential|private[_-]?key|client[_-]?secret|cookie|bearer|webhook)/iu;
83
+ const SENSITIVE_CREDENTIAL_FIELD = /(?:api[_-]?key|access[_-]?token|refresh[_-]?token|(?:^|[_-])token(?:$|[_-])|secret|password|authorization|credential|private[_-]?key|client[_-]?secret|cookie|bearer|webhook)/iu;
82
84
  const REFERENCE_FIELD_SUFFIX = /(?:env|ref|name|handle|id|file|path)$/iu;
83
85
 
84
86
  function isInlineCredentialLine(line) {
@@ -114,26 +116,100 @@ export function readProviderSettingsDeclarations(source, { file = 'harness/setti
114
116
  return {
115
117
  ok: true,
116
118
  declarations: parsed.entries.map((entry) => {
117
- const credentialRef = scalarField(parsed.lines, entry, 'apiKeyEnv');
119
+ const rawCredentialRef = scalarField(parsed.lines, entry, 'apiKeyEnv');
120
+ const credential = classifyCredentialReference(rawCredentialRef, { kind: 'env' });
118
121
  return {
119
122
  id: entry.id,
120
123
  display_name: scalarField(parsed.lines, entry, 'displayName') ?? entry.id,
121
- origin: 'profile-managed',
122
- ownership: 'crew-managed-profile',
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',
123
128
  file,
124
129
  declaration_authority: { kind: 'harness-settings', locator: `llm-pi-ai.providers.${entry.id}` },
125
- ...(credentialRef ? { credential_ref: { kind: 'env', name_or_handle: credentialRef, ownership: 'unknown' } } : {}),
130
+ ...(credential.value ? { credential_ref: { kind: 'env', name_or_handle: credential.value, ownership: 'unknown' } } : {}),
131
+ ...(credential.redacted ? { credential_status: 'present-redacted' } : {}),
126
132
  };
127
133
  }),
128
134
  };
129
135
  }
130
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
+
131
151
  export function readHarnessDefault(source) {
132
152
  const parsed = parseDefaultModel(source);
133
153
  if (!parsed.ok) return { ok: false, code: parsed.code };
134
154
  return { ok: true, provider: parsed.provider, model: parsed.model, locator: 'agent-default-model' };
135
155
  }
136
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
+
137
213
  export function replaceHarnessDefault(source, { provider, model, expectedRevision } = {}) {
138
214
  const currentRevision = typeof source === 'string' ? sha256(source) : null;
139
215
  if (expectedRevision !== undefined && expectedRevision !== currentRevision) return { ok: false, code: 'PROVIDER_SETTINGS_CHANGED', revision: currentRevision };
@@ -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
+ }