@ran-sh/dsh-crew 1.9.0 → 1.10.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 (151) hide show
  1. package/.claude-plugin/marketplace.json +16 -16
  2. package/.claude-plugin/plugin.json +14 -14
  3. package/LICENSE +21 -21
  4. package/README.de.md +359 -359
  5. package/README.es.md +359 -359
  6. package/README.fr.md +359 -359
  7. package/README.hi.md +359 -359
  8. package/README.id.md +359 -359
  9. package/README.ja.md +359 -359
  10. package/README.ko.md +359 -359
  11. package/README.md +126 -126
  12. package/README.pt.md +359 -359
  13. package/README.ru.md +359 -359
  14. package/README.th.md +359 -359
  15. package/README.tr.md +359 -359
  16. package/README.vi.md +359 -359
  17. package/README.zh-TW.md +359 -359
  18. package/README.zh.md +116 -116
  19. package/agents/ds-flash.md +24 -24
  20. package/agents/ds-pro.md +24 -24
  21. package/agents/ds-reviewer.md +23 -23
  22. package/agents/ds-worker.md +23 -23
  23. package/bin/dsh-crew.mjs +16 -16
  24. package/codex/agents/ds-flash.toml +34 -34
  25. package/codex/agents/ds-pro.toml +34 -34
  26. package/codex/agents/ds-reviewer.toml +32 -32
  27. package/codex/agents/ds-worker.toml +32 -32
  28. package/codex/prompts/dsh-config.md +19 -19
  29. package/codex/prompts/dsh-status.md +5 -5
  30. package/commands/config.md +23 -23
  31. package/commands/off.md +5 -5
  32. package/commands/on.md +5 -5
  33. package/commands/status.md +9 -9
  34. package/cordis.patch.yml +4 -4
  35. package/docs/gpt-relay-extension.md +103 -103
  36. package/docs/installation.md +138 -138
  37. package/docs/job-contracts.md +103 -103
  38. package/docs/readiness-matrix.md +85 -85
  39. package/docs/ui-surfaces.md +107 -107
  40. package/official-web-bridge/cordis.patch.yml +4 -4
  41. package/official-web-bridge/entry.mjs +1 -1
  42. package/official-web-bridge/overlay-entry.mjs +59 -59
  43. package/official-web-bridge/package.json +25 -25
  44. package/package.json +3 -2
  45. package/scripts/build-client.mjs +49 -49
  46. package/scripts/live-crew-smoke.mjs +39 -39
  47. package/scripts/live-policy-matrix.mjs +177 -177
  48. package/scripts/policy-probe.mjs +101 -101
  49. package/scripts/remove-legacy-official-bridge.ps1 +89 -89
  50. package/scripts/setup.mjs +393 -393
  51. package/scripts/smoke-real.mjs +110 -110
  52. package/scripts/smoke.mjs +78 -78
  53. package/scripts/verify-crew-ui-polish.mjs +145 -145
  54. package/scripts/verify-history-ui.mjs +97 -97
  55. package/scripts/verify-installer-fix.mjs +26 -26
  56. package/scripts/verify-npm-install.mjs +311 -311
  57. package/scripts/verify-official-bridge-e2e.mjs +192 -192
  58. package/src/adaptive-routing.mjs +260 -260
  59. package/src/client/activation-summary.tsx +64 -64
  60. package/src/client/collapsible-sections.mjs +55 -55
  61. package/src/client/history-panel.tsx +108 -108
  62. package/src/client/host-readiness.mjs +71 -71
  63. package/src/client/index.tsx +1711 -1711
  64. package/src/client/model-callability-view.mjs +17 -17
  65. package/src/client/panel-chrome.tsx +40 -40
  66. package/src/client/quick-entry.tsx +10 -10
  67. package/src/client/quick-panel.tsx +275 -275
  68. package/src/client/readiness-envelope.mjs +34 -34
  69. package/src/client/surface-detection.mjs +43 -43
  70. package/src/config-readiness.mjs +226 -226
  71. package/src/credential-reference.mjs +38 -38
  72. package/src/delivery.mjs +205 -205
  73. package/src/dsh-cli-runtime.mjs +1021 -1021
  74. package/src/dsh-cohort.mjs +20 -20
  75. package/src/extension-contract.mjs +104 -104
  76. package/src/failure-classification.mjs +201 -201
  77. package/src/history/admission-gate.mjs +67 -67
  78. package/src/history/archive-store.mjs +272 -272
  79. package/src/history/cleanup-plan.mjs +89 -89
  80. package/src/history/http.mjs +32 -32
  81. package/src/history/operation.mjs +86 -86
  82. package/src/history/runner-detach.mjs +34 -34
  83. package/src/history/runner.mjs +52 -52
  84. package/src/history/runtime.mjs +36 -36
  85. package/src/history/service.mjs +177 -177
  86. package/src/history/state.mjs +27 -27
  87. package/src/hub/entry.mjs +104 -104
  88. package/src/hub/index.mjs +2694 -2694
  89. package/src/hub-client.mjs +154 -154
  90. package/src/hub-compatibility.mjs +42 -42
  91. package/src/i18n.mjs +19 -19
  92. package/src/information-flow.mjs +67 -67
  93. package/src/install/cli.mjs +28 -28
  94. package/src/install/install-legacy.mjs +711 -711
  95. package/src/install/install.mjs +483 -483
  96. package/src/install/npx-lifecycle.mjs +3456 -3456
  97. package/src/install/official-frontend-assets.mjs +78 -78
  98. package/src/install/official-web.mjs +95 -95
  99. package/src/install/payload-content.mjs +88 -88
  100. package/src/install/windows-startup.mjs +236 -236
  101. package/src/install/windows-supervisor-adapter.mjs +443 -443
  102. package/src/install/windows-supervisor-lifecycle.mjs +782 -782
  103. package/src/install/zcode.mjs +397 -397
  104. package/src/job-contracts.mjs +255 -255
  105. package/src/local-request-guard.mjs +60 -60
  106. package/src/mcp-runtime.mjs +340 -340
  107. package/src/model-callability-contract.mjs +79 -79
  108. package/src/model-catalog.mjs +180 -180
  109. package/src/model-routing.mjs +586 -586
  110. package/src/model-schedule.mjs +207 -207
  111. package/src/official-web-bridge.mjs +447 -447
  112. package/src/policy.mjs +235 -235
  113. package/src/provider-delete-adapters.mjs +1934 -1934
  114. package/src/provider-health.mjs +130 -130
  115. package/src/provider-inventory.mjs +182 -182
  116. package/src/provider-layer-migration-adapters.mjs +759 -759
  117. package/src/provider-layer-migration.mjs +198 -198
  118. package/src/provider-lifecycle-state.mjs +103 -103
  119. package/src/provider-lifecycle.mjs +252 -252
  120. package/src/provider-profile-store.mjs +390 -390
  121. package/src/provider-settings-store.mjs +633 -633
  122. package/src/provider-store-lock.mjs +67 -67
  123. package/src/readiness-matrix.mjs +181 -181
  124. package/src/removable-waiter.mjs +29 -29
  125. package/src/role-profiles.mjs +107 -107
  126. package/src/runtime-controls.mjs +84 -84
  127. package/src/runtime-identity-contract.mjs +34 -34
  128. package/src/runtime-identity.mjs +235 -235
  129. package/src/runtime-readiness-snapshot.mjs +285 -285
  130. package/src/server.mjs +581 -581
  131. package/src/session-origins.mjs +60 -60
  132. package/src/standalone-sdk.mjs +23 -23
  133. package/src/status-shard.mjs +63 -63
  134. package/src/structured-error-code.mjs +38 -38
  135. package/src/supervisor/restart-request.mjs +256 -256
  136. package/src/workflow-runtime.mjs +739 -739
  137. package/src/workflow.mjs +155 -155
  138. package/src/workspace-audit.mjs +231 -231
  139. package/src/workspace-context.mjs +146 -146
  140. package/src/workspace-isolation.mjs +463 -455
  141. package/src/workspace-readiness.mjs +32 -32
  142. package/statusline/statusline.sh +14 -14
  143. package/statusline/worker-segment.sh +35 -35
  144. package/windows/start-dsh-crew.cmd +57 -57
  145. package/windows/start-dsh-crew.ps1 +1302 -1302
  146. package/windows/supervisor-control.ps1 +467 -467
  147. package/worker.cordis.yml +67 -67
  148. package/zcode/agents/ds-reviewer.md +31 -31
  149. package/zcode/agents/ds-worker.md +31 -31
  150. package/zcode/commands/dsh-config.md +17 -17
  151. package/zcode/commands/dsh-status.md +5 -5
@@ -1,390 +1,390 @@
1
- // Safe, structure-aware mutations for the Crew profile's provider patch.
2
- //
3
- // This module deliberately does not parse or return credential values. It only
4
- // recognizes the known llm-pi-ai -> config -> providers mapping and performs
5
- // bounded, revision-checked text edits while preserving unrelated patch items.
6
-
7
- import { createHash } from 'node:crypto';
8
- import { classifyCredentialReference } from './credential-reference.mjs';
9
-
10
- const PROVIDER_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
11
-
12
- function sha256(value) {
13
- return createHash('sha256').update(value, 'utf8').digest('hex');
14
- }
15
-
16
- function indentOf(line) {
17
- return (line.match(/^\s*/) ?? [''])[0].length;
18
- }
19
-
20
- function nonBlank(line) {
21
- return line.trim() !== '' && !line.trimStart().startsWith('#');
22
- }
23
-
24
- function parseProviderMap(source) {
25
- if (typeof source !== 'string') return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
26
- const lines = source.split(/\r?\n/);
27
- if (source.trim() === '[]') {
28
- return {
29
- ok: true,
30
- style: 'empty-profile',
31
- lines,
32
- llmStart: -1,
33
- blockEnd: lines.length,
34
- providersLine: -1,
35
- providerIndent: 0,
36
- entries: [],
37
- };
38
- }
39
- const llmStart = lines.findIndex((line) => /^-\s+id:\s*llm-pi-ai\s*$/.test(line));
40
- if (llmStart < 0) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
41
-
42
- let blockEnd = lines.length;
43
- for (let index = llmStart + 1; index < lines.length; index += 1) {
44
- // Only a true top-level sequence item can end the managed llm-pi-ai item;
45
- // nested list entries are part of a provider value and must be validated.
46
- if (indentOf(lines[index]) === 0 && /^-\s+/.test(lines[index])) {
47
- blockEnd = index;
48
- break;
49
- }
50
- }
51
-
52
- const providersLine = lines.findIndex((line, index) => index > llmStart && index < blockEnd && /^\s+providers:\s*(?:\{\s*\})?\s*$/.test(line));
53
- if (providersLine < 0) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
54
- const providersIndent = indentOf(lines[providersLine]);
55
- const providerIndent = providersIndent + 2;
56
- const entries = [];
57
-
58
- for (let index = providersLine + 1; index < blockEnd; index += 1) {
59
- const line = lines[index];
60
- if (!nonBlank(line)) continue;
61
- const indent = indentOf(line);
62
- if (indent <= providersIndent) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
63
- if (indent !== providerIndent) {
64
- if (/^\s*-\s+/.test(line)) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
65
- continue;
66
- }
67
- const match = line.match(/^\s*([A-Za-z0-9][A-Za-z0-9._-]*):\s*$/);
68
- if (!match) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
69
- const id = match[1];
70
- let end = index + 1;
71
- while (end < blockEnd) {
72
- const next = lines[end];
73
- if (nonBlank(next)) {
74
- const nextIndent = indentOf(next);
75
- if (nextIndent < providerIndent) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
76
- if (nextIndent === providerIndent) break;
77
- // Provider values may contain nested YAML sequences (for example the
78
- // `models:` list emitted by the live Harness profile). A sequence at
79
- // the same indentation as a provider field is malformed, however;
80
- // valid child items must be indented below that field. Keep the
81
- // parser structure-aware without attempting to deserialize values.
82
- if (/^\s*-\s+/.test(next) && nextIndent < providerIndent + 4) {
83
- return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
84
- }
85
- }
86
- end += 1;
87
- }
88
- entries.push({ id, start: index, end });
89
- index = end - 1;
90
- }
91
- return { ok: true, lines, llmStart, blockEnd, providersLine, providerIndent, entries };
92
- }
93
-
94
- /** Return only bounded provider ids and a content revision; never values. */
95
- export function inspectProviderProfile(source) {
96
- const revision = typeof source === 'string' ? sha256(source) : null;
97
- const parsed = parseProviderMap(source);
98
- if (!parsed.ok) return { ok: false, code: parsed.code, revision };
99
- return {
100
- ok: true,
101
- schema: 'cordis-patch-llm-pi-ai-v1',
102
- providerIds: parsed.entries.map((entry) => entry.id),
103
- revision,
104
- };
105
- }
106
-
107
- function scalarField(lines, entry, field) {
108
- const pattern = new RegExp(`^\\s+${field}:\\s*(.*?)\\s*$`);
109
- for (const line of lines.slice(entry.start, entry.end)) {
110
- const match = line.match(pattern);
111
- if (!match) continue;
112
- const value = match[1].trim();
113
- if (!value) return null;
114
- return value.replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/u, '$1$2');
115
- }
116
- return null;
117
- }
118
-
119
- const SENSITIVE_CREDENTIAL_FIELD = /(?:api[_-]?key|access[_-]?token|refresh[_-]?token|(?:^|[_-])token(?:$|[_-])|secret|password|authorization|credential|private[_-]?key|client[_-]?secret|cookie|bearer|webhook)/iu;
120
- const REFERENCE_FIELD_SUFFIX = /(?:env|ref|name|handle|id|file|path)$/iu;
121
-
122
- function isInlineCredentialLine(line) {
123
- const inlineKeys = [...line.matchAll(/["']?([A-Za-z][A-Za-z0-9_.-]*)["']?\s*:/gu)].map((match) => match[1]);
124
- if (inlineKeys.some((field) => SENSITIVE_CREDENTIAL_FIELD.test(field) && !REFERENCE_FIELD_SUFFIX.test(field.replace(/[_.-]/gu, '')))) return true;
125
- const match = line.match(/^\s*["']?([A-Za-z][A-Za-z0-9_.-]*)["']?\s*:\s*(.*?)\s*$/u);
126
- if (!match) return false;
127
- const [, field, value] = match;
128
- if (!value || /^(?:null|~|\{\}|\[\])$/u.test(value)) return false;
129
- const normalized = field.replace(/[_.-]/gu, '');
130
- if (SENSITIVE_CREDENTIAL_FIELD.test(field) && !REFERENCE_FIELD_SUFFIX.test(normalized)) return true;
131
- return /(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password|authorization|credential|private[_-]?key|client[_-]?secret|cookie|bearer|webhook)\s*["']?\s*:/iu.test(value);
132
- }
133
-
134
- export function hasInlineProviderCredentials(source, { providerIds = [] } = {}) {
135
- const parsed = parseProviderMap(source);
136
- if (!parsed.ok) return { ok: false, code: parsed.code };
137
- const requested = providerIds.length > 0 ? new Set(providerIds) : null;
138
- const providers = parsed.entries.filter((entry) => !requested || requested.has(entry.id));
139
- return { ok: true, inline: providers.some((entry) => parsed.lines.slice(entry.start, entry.end).some(isInlineCredentialLine)) };
140
- }
141
-
142
- /** Return profile provider provenance and credential reference names only. */
143
- export function readProviderDeclarations(source, { file = 'profile.yml' } = {}) {
144
- const parsed = parseProviderMap(source);
145
- if (!parsed.ok) return { ok: false, code: parsed.code };
146
- const declarations = parsed.entries.map((entry) => {
147
- const displayName = scalarField(parsed.lines, entry, 'displayName');
148
- const rawCredentialRef = scalarField(parsed.lines, entry, 'apiKeyEnv');
149
- const credential = classifyCredentialReference(rawCredentialRef, { kind: 'env' });
150
- return {
151
- id: entry.id,
152
- display_name: displayName ?? entry.id,
153
- origin: 'profile-managed',
154
- ownership: 'crew-managed-profile',
155
- file,
156
- declaration_authority: { kind: 'crew-profile', locator: `llm-pi-ai.config.providers.${entry.id}` },
157
- ...(credential.value ? { credential_ref: credential.value } : {}),
158
- ...(credential.redacted ? { credential_status: 'present-redacted' } : {}),
159
- };
160
- });
161
- return { ok: true, declarations };
162
- }
163
-
164
- function unquoteScalar(value) {
165
- return typeof value === 'string'
166
- ? value.trim().replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/u, '$1$2')
167
- : null;
168
- }
169
-
170
- function providerModels(lines, entry) {
171
- const modelsLine = lines.findIndex((line, index) => index >= entry.start && index < entry.end && /^\s+models:\s*$/u.test(line));
172
- if (modelsLine < 0) return [];
173
- const modelsIndent = (lines[modelsLine].match(/^\s*/u) ?? [''])[0].length;
174
- const models = [];
175
- const seenModelIds = new Set();
176
- for (let index = modelsLine + 1; index < entry.end; index += 1) {
177
- const line = lines[index];
178
- if (!nonBlank(line)) continue;
179
- const indent = indentOf(line);
180
- if (indent <= modelsIndent) break;
181
- const match = line.match(/^\s*-\s+id:\s*(.*?)\s*$/u);
182
- if (!match) continue;
183
- const id = unquoteScalar(match[1]);
184
- if (!id || id.length > 256 || /[\r\n]/u.test(id) || seenModelIds.has(id)) return null;
185
- seenModelIds.add(id);
186
- const modelIndent = indentOf(line);
187
- const model = { id };
188
- const seenFields = new Set(['id']);
189
- for (let child = index + 1; child < entry.end; child += 1) {
190
- const childLine = lines[child];
191
- if (!nonBlank(childLine)) continue;
192
- const childIndent = indentOf(childLine);
193
- if (childIndent <= modelsIndent || /^\s*-\s+id:\s*/u.test(childLine)) break;
194
- if (childIndent !== modelIndent + 2) continue;
195
- const fieldMatch = childLine.match(/^\s+([A-Za-z][A-Za-z0-9_-]*):\s*(.*?)\s*$/u);
196
- if (!fieldMatch) return null;
197
- const [, field, rawValue] = fieldMatch;
198
- if (seenFields.has(field) || !['name', 'contextWindow', 'maxTokens', 'input', 'reasoningEfforts', 'compat'].includes(field)) return null;
199
- seenFields.add(field);
200
- if (field === 'name') {
201
- const value = unquoteScalar(rawValue);
202
- if (!value || value.length > 256 || /[\r\n]/u.test(value)) return null;
203
- model.name = value;
204
- } else if (field === 'contextWindow' || field === 'maxTokens') {
205
- const value = Number(unquoteScalar(rawValue));
206
- if (!Number.isSafeInteger(value) || value <= 0) return null;
207
- model[field === 'contextWindow' ? 'context_window' : 'max_tokens'] = value;
208
- } else if (field === 'input') {
209
- const value = unquoteScalar(rawValue);
210
- if (!/^\[[^\]\r\n]*\]$/u.test(value ?? '')) return null;
211
- const modalities = value.slice(1, -1).split(',').map((item) => item.trim()).filter(Boolean);
212
- if (modalities.some((item) => !['text', 'image'].includes(item))) return null;
213
- model.input = [...new Set(modalities)];
214
- } else if (field === 'reasoningEfforts') {
215
- const efforts = {};
216
- for (let nested = child + 1; nested < entry.end; nested += 1) {
217
- const nestedLine = lines[nested];
218
- if (!nonBlank(nestedLine)) continue;
219
- const nestedIndent = indentOf(nestedLine);
220
- if (nestedIndent <= childIndent) break;
221
- if (nestedIndent !== childIndent + 2) return null;
222
- const effortMatch = nestedLine.match(/^\s+([A-Za-z][A-Za-z0-9_-]*):\s*(.*?)\s*$/u);
223
- if (!effortMatch || Object.hasOwn(efforts, effortMatch[1])) return null;
224
- const effortValue = unquoteScalar(effortMatch[2]);
225
- if (effortValue !== null && effortValue !== 'null' && effortValue !== '~' && (effortValue.length > 256 || /[\r\n]/u.test(effortValue))) return null;
226
- efforts[effortMatch[1]] = effortValue === null || effortValue === 'null' || effortValue === '~' ? null : effortValue;
227
- }
228
- model.reasoning_efforts = efforts;
229
- } else if (field === 'compat') {
230
- if (rawValue.trim() !== '{}') return null;
231
- model.compat = {};
232
- }
233
- }
234
- models.push(model);
235
- }
236
- return models.slice(0, 256);
237
- }
238
-
239
- /**
240
- * Return the bounded, non-secret fields needed to materialize one profile
241
- * provider in Harness settings. This is read-only and intentionally separate
242
- * from readProviderDeclarations so existing provenance consumers stay stable.
243
- */
244
- export function readProviderMaterialization(source, { providerId, file = 'profile.yml' } = {}) {
245
- const parsed = parseProviderMap(source);
246
- if (!parsed.ok) return { ok: false, code: parsed.code };
247
- const entry = parsed.entries.find((candidate) => candidate.id === providerId);
248
- if (!entry) return { ok: false, code: 'PROVIDER_NOT_FOUND' };
249
- const providerIndent = indentOf(parsed.lines[entry.start]);
250
- const directScalarField = (field) => {
251
- const pattern = new RegExp(`^\\s{${providerIndent + 2}}${field}:\\s*(.*?)\\s*$`);
252
- for (const line of parsed.lines.slice(entry.start, entry.end)) {
253
- const match = line.match(pattern);
254
- if (match) return unquoteScalar(match[1]);
255
- }
256
- return null;
257
- };
258
- const credentialRef = directScalarField('apiKeyEnv');
259
- const credential = classifyCredentialReference(credentialRef, { kind: 'env' });
260
- if (credential.redacted) return { ok: false, code: 'PROVIDER_CREDENTIAL_REFERENCE_UNSAFE' };
261
- const readBounded = (field, max = 2048) => {
262
- const value = directScalarField(field);
263
- return value && value.length <= max && !/[\r\n]/u.test(value) ? value : null;
264
- };
265
- const knownFields = new Set(['displayName', 'apiKeyEnv', 'api', 'baseURL', 'models']);
266
- const directFields = parsed.lines.slice(entry.start + 1, entry.end)
267
- .filter((line) => nonBlank(line) && indentOf(line) === providerIndent + 2)
268
- .map((line) => line.match(/^\s+([A-Za-z][A-Za-z0-9_-]*):/u)?.[1])
269
- .filter(Boolean);
270
- const unknownFields = directFields.filter((field) => !knownFields.has(field));
271
- if (unknownFields.length > 0 || new Set(directFields).size !== directFields.length) return { ok: false, code: 'PROVIDER_MATERIALIZATION_UNSUPPORTED_FIELDS' };
272
- const baseUrl = readBounded('baseURL');
273
- if (baseUrl) {
274
- try {
275
- const parsedUrl = new URL(baseUrl);
276
- if (!['http:', 'https:'].includes(parsedUrl.protocol) || parsedUrl.username || parsedUrl.password || parsedUrl.search || parsedUrl.hash || /[?#]/u.test(baseUrl) || !parsedUrl.hostname) {
277
- return { ok: false, code: 'PROVIDER_BASE_URL_UNSAFE' };
278
- }
279
- } catch { return { ok: false, code: 'PROVIDER_BASE_URL_UNSAFE' }; }
280
- }
281
- const api = readBounded('api', 128);
282
- 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' };
283
- const provider = {
284
- id: entry.id,
285
- display_name: readBounded('displayName', 256) ?? entry.id,
286
- ...(credential.value ? { credential_ref: credential.value } : {}),
287
- ...(api ? { api } : {}),
288
- ...(baseUrl ? { base_url: baseUrl } : {}),
289
- models: providerModels(parsed.lines, entry),
290
- source_file: file,
291
- };
292
- if (provider.models === null) return { ok: false, code: 'PROVIDER_MODEL_SCHEMA_UNSUPPORTED' };
293
- return { ok: true, provider };
294
- }
295
-
296
- function yamlScalar(value, max = 2048) {
297
- if (typeof value !== 'string' || !value.trim() || value.length > max || /[\r\n]/u.test(value)) return null;
298
- return JSON.stringify(value.trim());
299
- }
300
-
301
- /** Re-add one safe provider projection during an explicit migration rollback. */
302
- export function addProviderDeclaration(source, { provider, expectedRevision } = {}) {
303
- const currentRevision = typeof source === 'string' ? sha256(source) : null;
304
- if (expectedRevision !== undefined && expectedRevision !== currentRevision) return { ok: false, code: 'PROVIDER_PROFILE_CHANGED', revision: currentRevision };
305
- if (!provider || typeof provider !== 'object' || Array.isArray(provider) || !PROVIDER_ID.test(provider.id ?? '')) return { ok: false, code: 'PROVIDER_MATERIALIZATION_INVALID', revision: currentRevision };
306
- const parsed = parseProviderMap(source);
307
- if (!parsed.ok) return { ok: false, code: parsed.code, revision: currentRevision };
308
- if (parsed.entries.some((entry) => entry.id === provider.id)) return { ok: false, code: 'PROVIDER_PROFILE_PROVIDER_EXISTS', revision: currentRevision };
309
- const displayName = yamlScalar(provider.display_name ?? provider.id, 256);
310
- if (!displayName) return { ok: false, code: 'PROVIDER_MATERIALIZATION_INVALID', revision: currentRevision };
311
- const providerIndent = indentOf(parsed.lines[parsed.providersLine]) + 2;
312
- const fieldIndent = providerIndent + 2;
313
- const lines = [`${' '.repeat(providerIndent)}${provider.id}:`, `${' '.repeat(fieldIndent)}displayName: ${displayName}`];
314
- const api = yamlScalar(provider.api, 128);
315
- const baseUrl = yamlScalar(provider.base_url, 2048);
316
- const credential = provider.credential_ref ? classifyCredentialReference(provider.credential_ref, { kind: 'env' }).value : null;
317
- if (provider.credential_ref && !credential) return { ok: false, code: 'PROVIDER_CREDENTIAL_REFERENCE_UNSAFE', revision: currentRevision };
318
- if (api) lines.push(`${' '.repeat(fieldIndent)}api: ${api}`);
319
- if (baseUrl) lines.push(`${' '.repeat(fieldIndent)}baseURL: ${baseUrl}`);
320
- if (credential) lines.push(`${' '.repeat(fieldIndent)}apiKeyEnv: ${credential}`);
321
- 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) : [];
322
- if (models.length > 0) {
323
- lines.push(`${' '.repeat(fieldIndent)}models:`);
324
- for (const model of models) {
325
- const id = yamlScalar(model.id, 256);
326
- if (!id) continue;
327
- lines.push(`${' '.repeat(fieldIndent + 2)}- id: ${id}`);
328
- const name = yamlScalar(model.name, 256);
329
- if (name) lines.push(`${' '.repeat(fieldIndent + 4)}name: ${name}`);
330
- if (Number.isSafeInteger(model.context_window) && model.context_window > 0) lines.push(`${' '.repeat(fieldIndent + 4)}contextWindow: ${model.context_window}`);
331
- if (Number.isSafeInteger(model.max_tokens) && model.max_tokens > 0) lines.push(`${' '.repeat(fieldIndent + 4)}maxTokens: ${model.max_tokens}`);
332
- if (Array.isArray(model.input) && model.input.every((value) => value === 'text' || value === 'image')) lines.push(`${' '.repeat(fieldIndent + 4)}input: [${[...new Set(model.input)].join(', ')}]`);
333
- if (model.reasoning_efforts && typeof model.reasoning_efforts === 'object' && !Array.isArray(model.reasoning_efforts)) {
334
- lines.push(`${' '.repeat(fieldIndent + 4)}reasoningEfforts:`);
335
- 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)}`);
336
- }
337
- if (model.compat && typeof model.compat === 'object' && !Array.isArray(model.compat) && Object.keys(model.compat).length === 0) lines.push(`${' '.repeat(fieldIndent + 4)}compat: {}`);
338
- }
339
- }
340
- const nextLines = [...parsed.lines];
341
- if (/^\s+providers:\s*\{\s*\}\s*$/u.test(nextLines[parsed.providersLine])) {
342
- nextLines[parsed.providersLine] = `${' '.repeat(indentOf(nextLines[parsed.providersLine]))}providers:`;
343
- }
344
- nextLines.splice(parsed.blockEnd, 0, ...lines);
345
- const newline = source.includes('\r\n') ? '\r\n' : '\n';
346
- const text = nextLines.join(newline);
347
- return { ok: true, text, added: provider.id, revision: sha256(text) };
348
- }
349
-
350
- /**
351
- * Remove provider declarations from a known profile patch after an optional
352
- * content-revision check. The returned text is safe to write atomically by the
353
- * lifecycle layer; this pure helper never touches the filesystem.
354
- */
355
- export function removeProviderDeclarations(source, { providerIds = [], expectedRevision } = {}) {
356
- const currentRevision = typeof source === 'string' ? sha256(source) : null;
357
- if (expectedRevision !== undefined && expectedRevision !== currentRevision) {
358
- return { ok: false, code: 'PROVIDER_PROFILE_CHANGED', revision: currentRevision };
359
- }
360
- const requested = [...new Set(providerIds.filter((id) => typeof id === 'string' && PROVIDER_ID.test(id)))];
361
- if (requested.length === 0 || requested.length !== providerIds.length) {
362
- return { ok: false, code: 'PROVIDER_NOT_FOUND', revision: currentRevision };
363
- }
364
- const parsed = parseProviderMap(source);
365
- if (!parsed.ok) return { ok: false, code: parsed.code, revision: currentRevision };
366
- const byId = new Map(parsed.entries.map((entry) => [entry.id, entry]));
367
- if (requested.some((id) => !byId.has(id))) {
368
- return { ok: false, code: 'PROVIDER_NOT_FOUND', revision: currentRevision };
369
- }
370
-
371
- const removedSet = new Set(requested);
372
- const remaining = parsed.entries.filter((entry) => !removedSet.has(entry.id)).map((entry) => entry.id);
373
- const removeRanges = requested.map((id) => { const entry = byId.get(id); return [entry.start, entry.end]; });
374
- const shouldRemove = (index) => removeRanges.some(([start, end]) => index >= start && index < end);
375
- const lines = parsed.lines.filter((_line, index) => !shouldRemove(index));
376
- if (remaining.length === 0) {
377
- const providersIndex = parsed.providersLine - removeRanges.filter(([start]) => start < parsed.providersLine).length;
378
- const providersIndent = indentOf(parsed.lines[parsed.providersLine]);
379
- lines[providersIndex] = `${' '.repeat(providersIndent)}providers: {}`;
380
- }
381
- const newline = source.includes('\r\n') ? '\r\n' : '\n';
382
- const text = lines.join(newline);
383
- return {
384
- ok: true,
385
- text,
386
- removed: requested,
387
- remaining,
388
- revision: sha256(text),
389
- };
390
- }
1
+ // Safe, structure-aware mutations for the Crew profile's provider patch.
2
+ //
3
+ // This module deliberately does not parse or return credential values. It only
4
+ // recognizes the known llm-pi-ai -> config -> providers mapping and performs
5
+ // bounded, revision-checked text edits while preserving unrelated patch items.
6
+
7
+ import { createHash } from 'node:crypto';
8
+ import { classifyCredentialReference } from './credential-reference.mjs';
9
+
10
+ const PROVIDER_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
11
+
12
+ function sha256(value) {
13
+ return createHash('sha256').update(value, 'utf8').digest('hex');
14
+ }
15
+
16
+ function indentOf(line) {
17
+ return (line.match(/^\s*/) ?? [''])[0].length;
18
+ }
19
+
20
+ function nonBlank(line) {
21
+ return line.trim() !== '' && !line.trimStart().startsWith('#');
22
+ }
23
+
24
+ function parseProviderMap(source) {
25
+ if (typeof source !== 'string') return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
26
+ const lines = source.split(/\r?\n/);
27
+ if (source.trim() === '[]') {
28
+ return {
29
+ ok: true,
30
+ style: 'empty-profile',
31
+ lines,
32
+ llmStart: -1,
33
+ blockEnd: lines.length,
34
+ providersLine: -1,
35
+ providerIndent: 0,
36
+ entries: [],
37
+ };
38
+ }
39
+ const llmStart = lines.findIndex((line) => /^-\s+id:\s*llm-pi-ai\s*$/.test(line));
40
+ if (llmStart < 0) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
41
+
42
+ let blockEnd = lines.length;
43
+ for (let index = llmStart + 1; index < lines.length; index += 1) {
44
+ // Only a true top-level sequence item can end the managed llm-pi-ai item;
45
+ // nested list entries are part of a provider value and must be validated.
46
+ if (indentOf(lines[index]) === 0 && /^-\s+/.test(lines[index])) {
47
+ blockEnd = index;
48
+ break;
49
+ }
50
+ }
51
+
52
+ const providersLine = lines.findIndex((line, index) => index > llmStart && index < blockEnd && /^\s+providers:\s*(?:\{\s*\})?\s*$/.test(line));
53
+ if (providersLine < 0) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
54
+ const providersIndent = indentOf(lines[providersLine]);
55
+ const providerIndent = providersIndent + 2;
56
+ const entries = [];
57
+
58
+ for (let index = providersLine + 1; index < blockEnd; index += 1) {
59
+ const line = lines[index];
60
+ if (!nonBlank(line)) continue;
61
+ const indent = indentOf(line);
62
+ if (indent <= providersIndent) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
63
+ if (indent !== providerIndent) {
64
+ if (/^\s*-\s+/.test(line)) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
65
+ continue;
66
+ }
67
+ const match = line.match(/^\s*([A-Za-z0-9][A-Za-z0-9._-]*):\s*$/);
68
+ if (!match) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
69
+ const id = match[1];
70
+ let end = index + 1;
71
+ while (end < blockEnd) {
72
+ const next = lines[end];
73
+ if (nonBlank(next)) {
74
+ const nextIndent = indentOf(next);
75
+ if (nextIndent < providerIndent) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
76
+ if (nextIndent === providerIndent) break;
77
+ // Provider values may contain nested YAML sequences (for example the
78
+ // `models:` list emitted by the live Harness profile). A sequence at
79
+ // the same indentation as a provider field is malformed, however;
80
+ // valid child items must be indented below that field. Keep the
81
+ // parser structure-aware without attempting to deserialize values.
82
+ if (/^\s*-\s+/.test(next) && nextIndent < providerIndent + 4) {
83
+ return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
84
+ }
85
+ }
86
+ end += 1;
87
+ }
88
+ entries.push({ id, start: index, end });
89
+ index = end - 1;
90
+ }
91
+ return { ok: true, lines, llmStart, blockEnd, providersLine, providerIndent, entries };
92
+ }
93
+
94
+ /** Return only bounded provider ids and a content revision; never values. */
95
+ export function inspectProviderProfile(source) {
96
+ const revision = typeof source === 'string' ? sha256(source) : null;
97
+ const parsed = parseProviderMap(source);
98
+ if (!parsed.ok) return { ok: false, code: parsed.code, revision };
99
+ return {
100
+ ok: true,
101
+ schema: 'cordis-patch-llm-pi-ai-v1',
102
+ providerIds: parsed.entries.map((entry) => entry.id),
103
+ revision,
104
+ };
105
+ }
106
+
107
+ function scalarField(lines, entry, field) {
108
+ const pattern = new RegExp(`^\\s+${field}:\\s*(.*?)\\s*$`);
109
+ for (const line of lines.slice(entry.start, entry.end)) {
110
+ const match = line.match(pattern);
111
+ if (!match) continue;
112
+ const value = match[1].trim();
113
+ if (!value) return null;
114
+ return value.replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/u, '$1$2');
115
+ }
116
+ return null;
117
+ }
118
+
119
+ const SENSITIVE_CREDENTIAL_FIELD = /(?:api[_-]?key|access[_-]?token|refresh[_-]?token|(?:^|[_-])token(?:$|[_-])|secret|password|authorization|credential|private[_-]?key|client[_-]?secret|cookie|bearer|webhook)/iu;
120
+ const REFERENCE_FIELD_SUFFIX = /(?:env|ref|name|handle|id|file|path)$/iu;
121
+
122
+ function isInlineCredentialLine(line) {
123
+ const inlineKeys = [...line.matchAll(/["']?([A-Za-z][A-Za-z0-9_.-]*)["']?\s*:/gu)].map((match) => match[1]);
124
+ if (inlineKeys.some((field) => SENSITIVE_CREDENTIAL_FIELD.test(field) && !REFERENCE_FIELD_SUFFIX.test(field.replace(/[_.-]/gu, '')))) return true;
125
+ const match = line.match(/^\s*["']?([A-Za-z][A-Za-z0-9_.-]*)["']?\s*:\s*(.*?)\s*$/u);
126
+ if (!match) return false;
127
+ const [, field, value] = match;
128
+ if (!value || /^(?:null|~|\{\}|\[\])$/u.test(value)) return false;
129
+ const normalized = field.replace(/[_.-]/gu, '');
130
+ if (SENSITIVE_CREDENTIAL_FIELD.test(field) && !REFERENCE_FIELD_SUFFIX.test(normalized)) return true;
131
+ return /(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password|authorization|credential|private[_-]?key|client[_-]?secret|cookie|bearer|webhook)\s*["']?\s*:/iu.test(value);
132
+ }
133
+
134
+ export function hasInlineProviderCredentials(source, { providerIds = [] } = {}) {
135
+ const parsed = parseProviderMap(source);
136
+ if (!parsed.ok) return { ok: false, code: parsed.code };
137
+ const requested = providerIds.length > 0 ? new Set(providerIds) : null;
138
+ const providers = parsed.entries.filter((entry) => !requested || requested.has(entry.id));
139
+ return { ok: true, inline: providers.some((entry) => parsed.lines.slice(entry.start, entry.end).some(isInlineCredentialLine)) };
140
+ }
141
+
142
+ /** Return profile provider provenance and credential reference names only. */
143
+ export function readProviderDeclarations(source, { file = 'profile.yml' } = {}) {
144
+ const parsed = parseProviderMap(source);
145
+ if (!parsed.ok) return { ok: false, code: parsed.code };
146
+ const declarations = parsed.entries.map((entry) => {
147
+ const displayName = scalarField(parsed.lines, entry, 'displayName');
148
+ const rawCredentialRef = scalarField(parsed.lines, entry, 'apiKeyEnv');
149
+ const credential = classifyCredentialReference(rawCredentialRef, { kind: 'env' });
150
+ return {
151
+ id: entry.id,
152
+ display_name: displayName ?? entry.id,
153
+ origin: 'profile-managed',
154
+ ownership: 'crew-managed-profile',
155
+ file,
156
+ declaration_authority: { kind: 'crew-profile', locator: `llm-pi-ai.config.providers.${entry.id}` },
157
+ ...(credential.value ? { credential_ref: credential.value } : {}),
158
+ ...(credential.redacted ? { credential_status: 'present-redacted' } : {}),
159
+ };
160
+ });
161
+ return { ok: true, declarations };
162
+ }
163
+
164
+ function unquoteScalar(value) {
165
+ return typeof value === 'string'
166
+ ? value.trim().replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/u, '$1$2')
167
+ : null;
168
+ }
169
+
170
+ function providerModels(lines, entry) {
171
+ const modelsLine = lines.findIndex((line, index) => index >= entry.start && index < entry.end && /^\s+models:\s*$/u.test(line));
172
+ if (modelsLine < 0) return [];
173
+ const modelsIndent = (lines[modelsLine].match(/^\s*/u) ?? [''])[0].length;
174
+ const models = [];
175
+ const seenModelIds = new Set();
176
+ for (let index = modelsLine + 1; index < entry.end; index += 1) {
177
+ const line = lines[index];
178
+ if (!nonBlank(line)) continue;
179
+ const indent = indentOf(line);
180
+ if (indent <= modelsIndent) break;
181
+ const match = line.match(/^\s*-\s+id:\s*(.*?)\s*$/u);
182
+ if (!match) continue;
183
+ const id = unquoteScalar(match[1]);
184
+ if (!id || id.length > 256 || /[\r\n]/u.test(id) || seenModelIds.has(id)) return null;
185
+ seenModelIds.add(id);
186
+ const modelIndent = indentOf(line);
187
+ const model = { id };
188
+ const seenFields = new Set(['id']);
189
+ for (let child = index + 1; child < entry.end; child += 1) {
190
+ const childLine = lines[child];
191
+ if (!nonBlank(childLine)) continue;
192
+ const childIndent = indentOf(childLine);
193
+ if (childIndent <= modelsIndent || /^\s*-\s+id:\s*/u.test(childLine)) break;
194
+ if (childIndent !== modelIndent + 2) continue;
195
+ const fieldMatch = childLine.match(/^\s+([A-Za-z][A-Za-z0-9_-]*):\s*(.*?)\s*$/u);
196
+ if (!fieldMatch) return null;
197
+ const [, field, rawValue] = fieldMatch;
198
+ if (seenFields.has(field) || !['name', 'contextWindow', 'maxTokens', 'input', 'reasoningEfforts', 'compat'].includes(field)) return null;
199
+ seenFields.add(field);
200
+ if (field === 'name') {
201
+ const value = unquoteScalar(rawValue);
202
+ if (!value || value.length > 256 || /[\r\n]/u.test(value)) return null;
203
+ model.name = value;
204
+ } else if (field === 'contextWindow' || field === 'maxTokens') {
205
+ const value = Number(unquoteScalar(rawValue));
206
+ if (!Number.isSafeInteger(value) || value <= 0) return null;
207
+ model[field === 'contextWindow' ? 'context_window' : 'max_tokens'] = value;
208
+ } else if (field === 'input') {
209
+ const value = unquoteScalar(rawValue);
210
+ if (!/^\[[^\]\r\n]*\]$/u.test(value ?? '')) return null;
211
+ const modalities = value.slice(1, -1).split(',').map((item) => item.trim()).filter(Boolean);
212
+ if (modalities.some((item) => !['text', 'image'].includes(item))) return null;
213
+ model.input = [...new Set(modalities)];
214
+ } else if (field === 'reasoningEfforts') {
215
+ const efforts = {};
216
+ for (let nested = child + 1; nested < entry.end; nested += 1) {
217
+ const nestedLine = lines[nested];
218
+ if (!nonBlank(nestedLine)) continue;
219
+ const nestedIndent = indentOf(nestedLine);
220
+ if (nestedIndent <= childIndent) break;
221
+ if (nestedIndent !== childIndent + 2) return null;
222
+ const effortMatch = nestedLine.match(/^\s+([A-Za-z][A-Za-z0-9_-]*):\s*(.*?)\s*$/u);
223
+ if (!effortMatch || Object.hasOwn(efforts, effortMatch[1])) return null;
224
+ const effortValue = unquoteScalar(effortMatch[2]);
225
+ if (effortValue !== null && effortValue !== 'null' && effortValue !== '~' && (effortValue.length > 256 || /[\r\n]/u.test(effortValue))) return null;
226
+ efforts[effortMatch[1]] = effortValue === null || effortValue === 'null' || effortValue === '~' ? null : effortValue;
227
+ }
228
+ model.reasoning_efforts = efforts;
229
+ } else if (field === 'compat') {
230
+ if (rawValue.trim() !== '{}') return null;
231
+ model.compat = {};
232
+ }
233
+ }
234
+ models.push(model);
235
+ }
236
+ return models.slice(0, 256);
237
+ }
238
+
239
+ /**
240
+ * Return the bounded, non-secret fields needed to materialize one profile
241
+ * provider in Harness settings. This is read-only and intentionally separate
242
+ * from readProviderDeclarations so existing provenance consumers stay stable.
243
+ */
244
+ export function readProviderMaterialization(source, { providerId, file = 'profile.yml' } = {}) {
245
+ const parsed = parseProviderMap(source);
246
+ if (!parsed.ok) return { ok: false, code: parsed.code };
247
+ const entry = parsed.entries.find((candidate) => candidate.id === providerId);
248
+ if (!entry) return { ok: false, code: 'PROVIDER_NOT_FOUND' };
249
+ const providerIndent = indentOf(parsed.lines[entry.start]);
250
+ const directScalarField = (field) => {
251
+ const pattern = new RegExp(`^\\s{${providerIndent + 2}}${field}:\\s*(.*?)\\s*$`);
252
+ for (const line of parsed.lines.slice(entry.start, entry.end)) {
253
+ const match = line.match(pattern);
254
+ if (match) return unquoteScalar(match[1]);
255
+ }
256
+ return null;
257
+ };
258
+ const credentialRef = directScalarField('apiKeyEnv');
259
+ const credential = classifyCredentialReference(credentialRef, { kind: 'env' });
260
+ if (credential.redacted) return { ok: false, code: 'PROVIDER_CREDENTIAL_REFERENCE_UNSAFE' };
261
+ const readBounded = (field, max = 2048) => {
262
+ const value = directScalarField(field);
263
+ return value && value.length <= max && !/[\r\n]/u.test(value) ? value : null;
264
+ };
265
+ const knownFields = new Set(['displayName', 'apiKeyEnv', 'api', 'baseURL', 'models']);
266
+ const directFields = parsed.lines.slice(entry.start + 1, entry.end)
267
+ .filter((line) => nonBlank(line) && indentOf(line) === providerIndent + 2)
268
+ .map((line) => line.match(/^\s+([A-Za-z][A-Za-z0-9_-]*):/u)?.[1])
269
+ .filter(Boolean);
270
+ const unknownFields = directFields.filter((field) => !knownFields.has(field));
271
+ if (unknownFields.length > 0 || new Set(directFields).size !== directFields.length) return { ok: false, code: 'PROVIDER_MATERIALIZATION_UNSUPPORTED_FIELDS' };
272
+ const baseUrl = readBounded('baseURL');
273
+ if (baseUrl) {
274
+ try {
275
+ const parsedUrl = new URL(baseUrl);
276
+ if (!['http:', 'https:'].includes(parsedUrl.protocol) || parsedUrl.username || parsedUrl.password || parsedUrl.search || parsedUrl.hash || /[?#]/u.test(baseUrl) || !parsedUrl.hostname) {
277
+ return { ok: false, code: 'PROVIDER_BASE_URL_UNSAFE' };
278
+ }
279
+ } catch { return { ok: false, code: 'PROVIDER_BASE_URL_UNSAFE' }; }
280
+ }
281
+ const api = readBounded('api', 128);
282
+ 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' };
283
+ const provider = {
284
+ id: entry.id,
285
+ display_name: readBounded('displayName', 256) ?? entry.id,
286
+ ...(credential.value ? { credential_ref: credential.value } : {}),
287
+ ...(api ? { api } : {}),
288
+ ...(baseUrl ? { base_url: baseUrl } : {}),
289
+ models: providerModels(parsed.lines, entry),
290
+ source_file: file,
291
+ };
292
+ if (provider.models === null) return { ok: false, code: 'PROVIDER_MODEL_SCHEMA_UNSUPPORTED' };
293
+ return { ok: true, provider };
294
+ }
295
+
296
+ function yamlScalar(value, max = 2048) {
297
+ if (typeof value !== 'string' || !value.trim() || value.length > max || /[\r\n]/u.test(value)) return null;
298
+ return JSON.stringify(value.trim());
299
+ }
300
+
301
+ /** Re-add one safe provider projection during an explicit migration rollback. */
302
+ export function addProviderDeclaration(source, { provider, expectedRevision } = {}) {
303
+ const currentRevision = typeof source === 'string' ? sha256(source) : null;
304
+ if (expectedRevision !== undefined && expectedRevision !== currentRevision) return { ok: false, code: 'PROVIDER_PROFILE_CHANGED', revision: currentRevision };
305
+ if (!provider || typeof provider !== 'object' || Array.isArray(provider) || !PROVIDER_ID.test(provider.id ?? '')) return { ok: false, code: 'PROVIDER_MATERIALIZATION_INVALID', revision: currentRevision };
306
+ const parsed = parseProviderMap(source);
307
+ if (!parsed.ok) return { ok: false, code: parsed.code, revision: currentRevision };
308
+ if (parsed.entries.some((entry) => entry.id === provider.id)) return { ok: false, code: 'PROVIDER_PROFILE_PROVIDER_EXISTS', revision: currentRevision };
309
+ const displayName = yamlScalar(provider.display_name ?? provider.id, 256);
310
+ if (!displayName) return { ok: false, code: 'PROVIDER_MATERIALIZATION_INVALID', revision: currentRevision };
311
+ const providerIndent = indentOf(parsed.lines[parsed.providersLine]) + 2;
312
+ const fieldIndent = providerIndent + 2;
313
+ const lines = [`${' '.repeat(providerIndent)}${provider.id}:`, `${' '.repeat(fieldIndent)}displayName: ${displayName}`];
314
+ const api = yamlScalar(provider.api, 128);
315
+ const baseUrl = yamlScalar(provider.base_url, 2048);
316
+ const credential = provider.credential_ref ? classifyCredentialReference(provider.credential_ref, { kind: 'env' }).value : null;
317
+ if (provider.credential_ref && !credential) return { ok: false, code: 'PROVIDER_CREDENTIAL_REFERENCE_UNSAFE', revision: currentRevision };
318
+ if (api) lines.push(`${' '.repeat(fieldIndent)}api: ${api}`);
319
+ if (baseUrl) lines.push(`${' '.repeat(fieldIndent)}baseURL: ${baseUrl}`);
320
+ if (credential) lines.push(`${' '.repeat(fieldIndent)}apiKeyEnv: ${credential}`);
321
+ 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) : [];
322
+ if (models.length > 0) {
323
+ lines.push(`${' '.repeat(fieldIndent)}models:`);
324
+ for (const model of models) {
325
+ const id = yamlScalar(model.id, 256);
326
+ if (!id) continue;
327
+ lines.push(`${' '.repeat(fieldIndent + 2)}- id: ${id}`);
328
+ const name = yamlScalar(model.name, 256);
329
+ if (name) lines.push(`${' '.repeat(fieldIndent + 4)}name: ${name}`);
330
+ if (Number.isSafeInteger(model.context_window) && model.context_window > 0) lines.push(`${' '.repeat(fieldIndent + 4)}contextWindow: ${model.context_window}`);
331
+ if (Number.isSafeInteger(model.max_tokens) && model.max_tokens > 0) lines.push(`${' '.repeat(fieldIndent + 4)}maxTokens: ${model.max_tokens}`);
332
+ if (Array.isArray(model.input) && model.input.every((value) => value === 'text' || value === 'image')) lines.push(`${' '.repeat(fieldIndent + 4)}input: [${[...new Set(model.input)].join(', ')}]`);
333
+ if (model.reasoning_efforts && typeof model.reasoning_efforts === 'object' && !Array.isArray(model.reasoning_efforts)) {
334
+ lines.push(`${' '.repeat(fieldIndent + 4)}reasoningEfforts:`);
335
+ 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)}`);
336
+ }
337
+ if (model.compat && typeof model.compat === 'object' && !Array.isArray(model.compat) && Object.keys(model.compat).length === 0) lines.push(`${' '.repeat(fieldIndent + 4)}compat: {}`);
338
+ }
339
+ }
340
+ const nextLines = [...parsed.lines];
341
+ if (/^\s+providers:\s*\{\s*\}\s*$/u.test(nextLines[parsed.providersLine])) {
342
+ nextLines[parsed.providersLine] = `${' '.repeat(indentOf(nextLines[parsed.providersLine]))}providers:`;
343
+ }
344
+ nextLines.splice(parsed.blockEnd, 0, ...lines);
345
+ const newline = source.includes('\r\n') ? '\r\n' : '\n';
346
+ const text = nextLines.join(newline);
347
+ return { ok: true, text, added: provider.id, revision: sha256(text) };
348
+ }
349
+
350
+ /**
351
+ * Remove provider declarations from a known profile patch after an optional
352
+ * content-revision check. The returned text is safe to write atomically by the
353
+ * lifecycle layer; this pure helper never touches the filesystem.
354
+ */
355
+ export function removeProviderDeclarations(source, { providerIds = [], expectedRevision } = {}) {
356
+ const currentRevision = typeof source === 'string' ? sha256(source) : null;
357
+ if (expectedRevision !== undefined && expectedRevision !== currentRevision) {
358
+ return { ok: false, code: 'PROVIDER_PROFILE_CHANGED', revision: currentRevision };
359
+ }
360
+ const requested = [...new Set(providerIds.filter((id) => typeof id === 'string' && PROVIDER_ID.test(id)))];
361
+ if (requested.length === 0 || requested.length !== providerIds.length) {
362
+ return { ok: false, code: 'PROVIDER_NOT_FOUND', revision: currentRevision };
363
+ }
364
+ const parsed = parseProviderMap(source);
365
+ if (!parsed.ok) return { ok: false, code: parsed.code, revision: currentRevision };
366
+ const byId = new Map(parsed.entries.map((entry) => [entry.id, entry]));
367
+ if (requested.some((id) => !byId.has(id))) {
368
+ return { ok: false, code: 'PROVIDER_NOT_FOUND', revision: currentRevision };
369
+ }
370
+
371
+ const removedSet = new Set(requested);
372
+ const remaining = parsed.entries.filter((entry) => !removedSet.has(entry.id)).map((entry) => entry.id);
373
+ const removeRanges = requested.map((id) => { const entry = byId.get(id); return [entry.start, entry.end]; });
374
+ const shouldRemove = (index) => removeRanges.some(([start, end]) => index >= start && index < end);
375
+ const lines = parsed.lines.filter((_line, index) => !shouldRemove(index));
376
+ if (remaining.length === 0) {
377
+ const providersIndex = parsed.providersLine - removeRanges.filter(([start]) => start < parsed.providersLine).length;
378
+ const providersIndent = indentOf(parsed.lines[parsed.providersLine]);
379
+ lines[providersIndex] = `${' '.repeat(providersIndent)}providers: {}`;
380
+ }
381
+ const newline = source.includes('\r\n') ? '\r\n' : '\n';
382
+ const text = lines.join(newline);
383
+ return {
384
+ ok: true,
385
+ text,
386
+ removed: requested,
387
+ remaining,
388
+ revision: sha256(text),
389
+ };
390
+ }