@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,633 +1,633 @@
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 nextContentLine(lines, start, end = lines.length) {
15
- for (let index = start; index < end; index += 1) if (nonBlank(lines[index])) return index;
16
- return -1;
17
- }
18
- function unquoteScalar(value) {
19
- const input = typeof value === 'string' ? value.trim() : '';
20
- if (!input) return null;
21
- if (input.startsWith('"') && input.endsWith('"')) {
22
- try { return JSON.parse(input); } catch { return null; }
23
- }
24
- if (input.startsWith("'") && input.endsWith("'")) return input.slice(1, -1).replace(/''/gu, "'");
25
- return input;
26
- }
27
- function scalarField(lines, entry, field) {
28
- const indent = Number.isInteger(entry?.fieldIndent) ? entry.fieldIndent : null;
29
- const pattern = new RegExp(`^${indent === null ? '\\s+' : ` {${indent}}`}${field}:\\s*(.*?)\\s*$`);
30
- for (const line of lines.slice(entry.start, entry.end)) {
31
- const match = line.match(pattern);
32
- if (!match) continue;
33
- const value = (entry?.style === 'flow' ? match[1].replace(/,\s*$/u, '') : match[1]).trim();
34
- if (!value) return null;
35
- return unquoteScalar(value);
36
- }
37
- return null;
38
- }
39
-
40
- function parseDefaultModel(source) {
41
- if (typeof source !== 'string') return { ok: false, code: 'PROVIDER_SETTINGS_DEFAULT_UNSUPPORTED' };
42
- const lines = source.split(/\r?\n/u);
43
- const start = lines.findIndex((line) => /^agent-default-model:\s*$/u.test(line));
44
- if (start < 0) return { ok: false, code: 'PROVIDER_SETTINGS_DEFAULT_UNSUPPORTED' };
45
- let end = lines.length;
46
- for (let index = start + 1; index < lines.length; index += 1) {
47
- if (indentOf(lines[index]) === 0 && nonBlank(lines[index])) { end = index; break; }
48
- }
49
- const providerLine = lines.findIndex((line, index) => index > start && index < end && /^ {2}provider:\s*\S.*$/u.test(line));
50
- const modelLine = lines.findIndex((line, index) => index > start && index < end && /^ {2}model:\s*\S.*$/u.test(line));
51
- const provider = providerLine >= 0 ? lines[providerLine].replace(/^ {2}provider:\s*/u, '').trim() : null;
52
- const model = modelLine >= 0 ? lines[modelLine].replace(/^ {2}model:\s*/u, '').trim() : null;
53
- if (!provider || !model) return { ok: false, code: 'PROVIDER_SETTINGS_DEFAULT_UNSUPPORTED' };
54
- return { ok: true, lines, start, end, providerLine, modelLine, provider, model };
55
- }
56
-
57
- function parseProviderMap(source) {
58
- if (typeof source !== 'string') return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
59
- const lines = source.split(/\r?\n/u);
60
- const llmStart = lines.findIndex((line) => /^llm-pi-ai:\s*$/u.test(line));
61
- if (llmStart < 0) return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
62
- let blockEnd = lines.length;
63
- for (let index = llmStart + 1; index < lines.length; index += 1) {
64
- if (indentOf(lines[index]) === 0 && nonBlank(lines[index])) { blockEnd = index; break; }
65
- }
66
- const providersLine = lines.findIndex((line, index) => index > llmStart && index < blockEnd && /^ {2}providers:\s*(?:\{\s*\})?\s*$/u.test(line));
67
- if (providersLine < 0) return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
68
- const flowOpenLine = nextContentLine(lines, providersLine + 1, blockEnd);
69
- if (flowOpenLine >= 0 && /^ {4}\{\s*$/u.test(lines[flowOpenLine])) {
70
- let flowCloseLine = -1;
71
- const entries = [];
72
- let index = flowOpenLine + 1;
73
- while (index < blockEnd) {
74
- index = nextContentLine(lines, index, blockEnd);
75
- if (index < 0) break;
76
- if (/^ {4}\}\s*$/u.test(lines[index])) { flowCloseLine = index; break; }
77
- const match = lines[index].match(/^ {6}([A-Za-z0-9][A-Za-z0-9._-]*):\s*$/u);
78
- if (!match) return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
79
- const objectOpen = nextContentLine(lines, index + 1, blockEnd);
80
- if (objectOpen < 0 || !/^ {8}\{\s*$/u.test(lines[objectOpen])) return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
81
- let objectClose = -1;
82
- for (let cursor = objectOpen + 1; cursor < blockEnd; cursor += 1) {
83
- if (/^ {8}\},?\s*$/u.test(lines[cursor])) { objectClose = cursor; break; }
84
- }
85
- if (objectClose < 0) return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
86
- entries.push({ id: match[1], start: index, end: objectClose + 1, style: 'flow', fieldIndent: 10, objectOpen, objectClose });
87
- index = objectClose + 1;
88
- }
89
- if (flowCloseLine < 0) return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
90
- for (let entryIndex = 0; entryIndex < entries.length - 1; entryIndex += 1) {
91
- if (!/^ {8}\},\s*$/u.test(lines[entries[entryIndex].objectClose])) return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
92
- }
93
- if (entries.some((entry) => !validFlowEntryStructure(lines, entry))) {
94
- return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
95
- }
96
- return {
97
- ok: true, style: 'flow', lines, llmStart, blockEnd, providersLine,
98
- providersBlockEnd: flowCloseLine + 1, flowOpenLine, flowCloseLine, entries,
99
- };
100
- }
101
- let providersBlockEnd = blockEnd;
102
- for (let index = providersLine + 1; index < blockEnd; index += 1) {
103
- if (nonBlank(lines[index]) && indentOf(lines[index]) <= 2) { providersBlockEnd = index; break; }
104
- }
105
- const providerIndent = 4;
106
- const entries = [];
107
- for (let index = providersLine + 1; index < blockEnd; index += 1) {
108
- const line = lines[index];
109
- if (!nonBlank(line)) continue;
110
- const indent = indentOf(line);
111
- if (indent < providerIndent) {
112
- if (indent > 2) return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
113
- break;
114
- }
115
- if (indent !== providerIndent) continue;
116
- const match = line.match(/^ {4}([A-Za-z0-9][A-Za-z0-9._-]*):\s*$/u);
117
- if (!match) return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
118
- let end = index + 1;
119
- while (end < blockEnd) {
120
- const next = lines[end];
121
- if (nonBlank(next) && indentOf(next) <= providerIndent) break;
122
- end += 1;
123
- }
124
- entries.push({ id: match[1], start: index, end, style: 'block', fieldIndent: 6 });
125
- index = end - 1;
126
- }
127
- return { ok: true, style: 'block', lines, llmStart, blockEnd, providersLine, providersBlockEnd, entries };
128
- }
129
-
130
- function splitFlowItems(source) {
131
- const items = [];
132
- let start = 0;
133
- let braces = 0;
134
- let brackets = 0;
135
- let quote = null;
136
- let escaped = false;
137
- for (let index = 0; index < source.length; index += 1) {
138
- const char = source[index];
139
- if (quote) {
140
- if (quote === '"' && escaped) { escaped = false; continue; }
141
- if (quote === '"' && char === '\\') { escaped = true; continue; }
142
- if (char === quote) quote = null;
143
- continue;
144
- }
145
- if (char === '"' || char === "'") { quote = char; continue; }
146
- if (char === '{') braces += 1;
147
- else if (char === '}') braces -= 1;
148
- else if (char === '[') brackets += 1;
149
- else if (char === ']') brackets -= 1;
150
- else if (char === ',' && braces === 0 && brackets === 0) {
151
- items.push(source.slice(start, index).trim());
152
- start = index + 1;
153
- }
154
- if (braces < 0 || brackets < 0) return null;
155
- }
156
- if (quote || braces !== 0 || brackets !== 0) return null;
157
- const tail = source.slice(start).trim();
158
- if (tail) items.push(tail);
159
- return items;
160
- }
161
-
162
- function parseFlowObject(source, depth = 0) {
163
- if (depth > 8) return null;
164
- const input = source.trim().replace(/,\s*$/u, '');
165
- if (!input.startsWith('{') || !input.endsWith('}')) return null;
166
- const items = splitFlowItems(input.slice(1, -1));
167
- if (!items) return null;
168
- const fields = new Map();
169
- for (const item of items) {
170
- let splitAt = -1;
171
- let quote = null;
172
- let escaped = false;
173
- for (let index = 0; index < item.length; index += 1) {
174
- const char = item[index];
175
- if (quote) {
176
- if (quote === '"' && escaped) { escaped = false; continue; }
177
- if (quote === '"' && char === '\\') { escaped = true; continue; }
178
- if (char === quote) quote = null;
179
- } else if (char === '"' || char === "'") quote = char;
180
- else if (char === ':') { splitAt = index; break; }
181
- }
182
- if (splitAt < 1 || hasUnseparatedFlowMapping(item, splitAt)) return null;
183
- const key = item.slice(0, splitAt).trim();
184
- const value = item.slice(splitAt + 1).trim();
185
- if (!/^[A-Za-z][A-Za-z0-9_-]*$/u.test(key) || !value || fields.has(key)) return null;
186
- if (value.startsWith('{') && (!value.endsWith('}') || parseFlowObject(value, depth + 1) === null)) return null;
187
- fields.set(key, value);
188
- }
189
- return fields;
190
- }
191
-
192
- function hasUnseparatedFlowMapping(item, firstColon) {
193
- let braces = 0;
194
- let brackets = 0;
195
- let quote = null;
196
- let escaped = false;
197
- for (let index = firstColon + 1; index < item.length; index += 1) {
198
- const char = item[index];
199
- if (quote) {
200
- if (quote === '"' && escaped) { escaped = false; continue; }
201
- if (quote === '"' && char === '\\') { escaped = true; continue; }
202
- if (char === quote) quote = null;
203
- continue;
204
- }
205
- if (char === '"' || char === "'") { quote = char; continue; }
206
- if (char === '{') braces += 1;
207
- else if (char === '}') braces -= 1;
208
- else if (char === '[') brackets += 1;
209
- else if (char === ']') brackets -= 1;
210
- else if (char === ':' && braces === 0 && brackets === 0 && /\s/u.test(item[index + 1] ?? '')) return true;
211
- if (braces < 0 || brackets < 0) return true;
212
- }
213
- return quote !== null || braces !== 0 || brackets !== 0;
214
- }
215
-
216
- function validFlowEntryStructure(lines, entry) {
217
- const fields = [];
218
- let cursor = entry.objectOpen + 1;
219
- while (cursor < entry.objectClose) {
220
- cursor = nextContentLine(lines, cursor, entry.objectClose);
221
- if (cursor < 0) break;
222
- const line = lines[cursor];
223
- if (indentOf(line) !== entry.fieldIndent) return false;
224
- const field = line.match(/^\s+([A-Za-z][A-Za-z0-9_-]*):\s*(.*?)\s*$/u);
225
- if (!field) return false;
226
- let terminator = cursor;
227
- if (!field[2]) {
228
- const listOpen = nextContentLine(lines, cursor + 1, entry.objectClose);
229
- if (listOpen < 0 || indentOf(lines[listOpen]) !== entry.fieldIndent + 2 || !/^\s*\[\s*$/u.test(lines[listOpen])) return false;
230
- const items = [];
231
- let listClose = -1;
232
- for (let nested = listOpen + 1; nested < entry.objectClose; nested += 1) {
233
- if (!nonBlank(lines[nested])) continue;
234
- if (indentOf(lines[nested]) === entry.fieldIndent + 2 && /^\s*\]\s*,?\s*$/u.test(lines[nested])) {
235
- listClose = nested;
236
- break;
237
- }
238
- if (indentOf(lines[nested]) !== entry.fieldIndent + 4 || !parseFlowObject(lines[nested])) return false;
239
- items.push(nested);
240
- }
241
- if (listClose < 0) return false;
242
- for (let index = 0; index < items.length - 1; index += 1) if (!/,\s*$/u.test(lines[items[index]])) return false;
243
- terminator = listClose;
244
- cursor = listClose + 1;
245
- } else {
246
- cursor += 1;
247
- }
248
- fields.push({ terminator });
249
- }
250
- for (let index = 0; index < fields.length - 1; index += 1) {
251
- if (!/,\s*$/u.test(lines[fields[index].terminator])) return false;
252
- }
253
- return true;
254
- }
255
-
256
- function parseFlowModel(source) {
257
- const fields = parseFlowObject(source);
258
- if (!fields) return null;
259
- const allowed = new Set(['id', 'name', 'contextWindow', 'maxTokens', 'input', 'reasoningEfforts', 'compat']);
260
- if ([...fields.keys()].some((key) => !allowed.has(key))) return null;
261
- const id = unquoteScalar(fields.get('id'));
262
- if (!id || id.length > 256 || /[\r\n]/u.test(id)) return null;
263
- const model = { id };
264
- if (fields.has('name')) {
265
- const name = unquoteScalar(fields.get('name'));
266
- if (!name || name.length > 256 || /[\r\n]/u.test(name)) return null;
267
- model.name = name;
268
- }
269
- for (const [sourceKey, targetKey] of [['contextWindow', 'context_window'], ['maxTokens', 'max_tokens']]) {
270
- if (!fields.has(sourceKey)) continue;
271
- const value = Number(unquoteScalar(fields.get(sourceKey)));
272
- if (!Number.isSafeInteger(value) || value <= 0) return null;
273
- model[targetKey] = value;
274
- }
275
- if (fields.has('input')) {
276
- const input = fields.get('input').trim();
277
- if (!input.startsWith('[') || !input.endsWith(']')) return null;
278
- const modalities = splitFlowItems(input.slice(1, -1));
279
- if (!modalities) return null;
280
- const normalized = modalities.map(unquoteScalar);
281
- if (normalized.some((value) => !['text', 'image'].includes(value))) return null;
282
- model.input = [...new Set(normalized)];
283
- }
284
- if (fields.has('reasoningEfforts')) {
285
- const efforts = parseFlowObject(fields.get('reasoningEfforts'));
286
- if (!efforts) return null;
287
- const normalized = {};
288
- for (const [key, raw] of efforts) {
289
- const value = unquoteScalar(raw);
290
- if (raw === 'null' || raw === '~') normalized[key] = null;
291
- else if (!value || value.length > 256 || /[\r\n]/u.test(value)) return null;
292
- else normalized[key] = value;
293
- }
294
- model.reasoning_efforts = normalized;
295
- }
296
- if (fields.has('compat')) {
297
- if (fields.get('compat').trim() !== '{}') return null;
298
- model.compat = {};
299
- }
300
- return model;
301
- }
302
-
303
- function readFlowMaterialization(parsed, entry, file) {
304
- const directFields = new Set();
305
- for (const line of parsed.lines.slice(entry.objectOpen + 1, entry.objectClose)) {
306
- if (!nonBlank(line) || indentOf(line) !== entry.fieldIndent) continue;
307
- const match = line.match(/^\s+([A-Za-z][A-Za-z0-9_-]*):/u);
308
- if (!match || directFields.has(match[1])) return { ok: false, code: 'PROVIDER_MATERIALIZATION_UNSUPPORTED_FIELDS' };
309
- directFields.add(match[1]);
310
- }
311
- const allowed = new Set(['displayName', 'apiKeyEnv', 'api', 'baseURL', 'models']);
312
- if ([...directFields].some((field) => !allowed.has(field))) return { ok: false, code: 'PROVIDER_MATERIALIZATION_UNSUPPORTED_FIELDS' };
313
- const rawDisplayName = scalarField(parsed.lines, entry, 'displayName');
314
- const displayName = rawDisplayName && rawDisplayName.length <= 256 && !/[\r\n]/u.test(rawDisplayName) ? rawDisplayName : entry.id;
315
- const rawCredentialRef = scalarField(parsed.lines, entry, 'apiKeyEnv');
316
- const credential = classifyCredentialReference(rawCredentialRef, { kind: 'env' });
317
- if (credential.redacted) return { ok: false, code: 'PROVIDER_CREDENTIAL_REFERENCE_UNSAFE' };
318
- const api = scalarField(parsed.lines, entry, 'api');
319
- 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' };
320
- const rawBaseUrl = scalarField(parsed.lines, entry, 'baseURL');
321
- const baseUrl = rawBaseUrl && rawBaseUrl.length <= 2048 && !/[\r\n]/u.test(rawBaseUrl) ? rawBaseUrl : null;
322
- if (baseUrl) {
323
- try {
324
- const url = new URL(baseUrl);
325
- if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || !url.hostname) return { ok: false, code: 'PROVIDER_BASE_URL_UNSAFE' };
326
- } catch { return { ok: false, code: 'PROVIDER_BASE_URL_UNSAFE' }; }
327
- }
328
- const models = [];
329
- if (directFields.has('models')) {
330
- const modelsLine = parsed.lines.findIndex((line, index) => index > entry.objectOpen && index < entry.objectClose && /^ {10}models:\s*$/u.test(line));
331
- const listOpen = nextContentLine(parsed.lines, modelsLine + 1, entry.objectClose);
332
- if (modelsLine < 0 || listOpen < 0 || !/^ {12}\[\s*$/u.test(parsed.lines[listOpen])) return { ok: false, code: 'PROVIDER_MODEL_SCHEMA_UNSUPPORTED' };
333
- let listClose = -1;
334
- for (let index = listOpen + 1; index < entry.objectClose; index += 1) {
335
- if (/^ {12}\]\s*,?\s*$/u.test(parsed.lines[index])) { listClose = index; break; }
336
- if (!nonBlank(parsed.lines[index])) continue;
337
- if (indentOf(parsed.lines[index]) !== 14) return { ok: false, code: 'PROVIDER_MODEL_SCHEMA_UNSUPPORTED' };
338
- const model = parseFlowModel(parsed.lines[index]);
339
- if (!model || models.some((candidate) => candidate.id === model.id)) return { ok: false, code: 'PROVIDER_MODEL_SCHEMA_UNSUPPORTED' };
340
- models.push(model);
341
- }
342
- if (listClose < 0) return { ok: false, code: 'PROVIDER_MODEL_SCHEMA_UNSUPPORTED' };
343
- }
344
- return {
345
- ok: true,
346
- provider: {
347
- id: entry.id, display_name: displayName,
348
- ...(credential.value ? { credential_ref: credential.value } : {}),
349
- ...(api ? { api } : {}), ...(baseUrl ? { base_url: baseUrl } : {}),
350
- models: models.slice(0, 256), source_file: file,
351
- },
352
- };
353
- }
354
-
355
- const SENSITIVE_CREDENTIAL_FIELD = /(?:api[_-]?key|access[_-]?token|refresh[_-]?token|(?:^|[_-])token(?:$|[_-])|secret|password|authorization|credential|private[_-]?key|client[_-]?secret|cookie|bearer|webhook)/iu;
356
- const REFERENCE_FIELD_SUFFIX = /(?:env|ref|name|handle|id|file|path)$/iu;
357
-
358
- function isInlineCredentialLine(line) {
359
- const inlineKeys = [...line.matchAll(/["']?([A-Za-z][A-Za-z0-9_.-]*)["']?\s*:/gu)].map((match) => match[1]);
360
- if (inlineKeys.some((field) => SENSITIVE_CREDENTIAL_FIELD.test(field) && !REFERENCE_FIELD_SUFFIX.test(field.replace(/[_.-]/gu, '')))) return true;
361
- const match = line.match(/^\s*["']?([A-Za-z][A-Za-z0-9_.-]*)["']?\s*:\s*(.*?)\s*$/u);
362
- if (!match) return false;
363
- const [, field, value] = match;
364
- if (!value || /^(?:null|~|\{\}|\[\])$/u.test(value)) return false;
365
- const normalized = field.replace(/[_.-]/gu, '');
366
- if (SENSITIVE_CREDENTIAL_FIELD.test(field) && !REFERENCE_FIELD_SUFFIX.test(normalized)) return true;
367
- return /(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password|authorization|credential|private[_-]?key|client[_-]?secret|cookie|bearer|webhook)\s*["']?\s*:/iu.test(value);
368
- }
369
-
370
- export function hasInlineProviderCredentials(source, { providerIds = [] } = {}) {
371
- const parsed = parseProviderMap(source);
372
- if (!parsed.ok) return { ok: false, code: parsed.code };
373
- const requested = providerIds.length > 0 ? new Set(providerIds) : null;
374
- const providers = parsed.entries.filter((entry) => !requested || requested.has(entry.id));
375
- return { ok: true, inline: providers.some((entry) => parsed.lines.slice(entry.start, entry.end).some(isInlineCredentialLine)) };
376
- }
377
-
378
- export function inspectProviderSettings(source) {
379
- const revision = typeof source === 'string' ? sha256(source) : null;
380
- const parsed = parseProviderMap(source);
381
- if (!parsed.ok) return { ok: false, code: parsed.code, revision };
382
- return { ok: true, schema: 'harness-settings-llm-pi-ai-v1', providerIds: parsed.entries.map((entry) => entry.id), revision };
383
- }
384
-
385
- export function readProviderSettingsDeclarations(source, { file = 'harness/settings.yaml' } = {}) {
386
- const parsed = parseProviderMap(source);
387
- if (!parsed.ok) return { ok: false, code: parsed.code };
388
- return {
389
- ok: true,
390
- declarations: parsed.entries.map((entry) => {
391
- const rawCredentialRef = scalarField(parsed.lines, entry, 'apiKeyEnv');
392
- const credential = classifyCredentialReference(rawCredentialRef, { kind: 'env' });
393
- return {
394
- id: entry.id,
395
- display_name: scalarField(parsed.lines, entry, 'displayName') ?? entry.id,
396
- // settings.yaml is the Harness user layer. Crew profile/base
397
- // declarations are reported separately by provider-profile-store.
398
- origin: 'dynamic',
399
- ownership: 'dynamic-user',
400
- file,
401
- declaration_authority: { kind: 'harness-settings', locator: `llm-pi-ai.providers.${entry.id}` },
402
- ...(credential.value ? { credential_ref: { kind: 'env', name_or_handle: credential.value, ownership: 'unknown' } } : {}),
403
- ...(credential.redacted ? { credential_status: 'present-redacted' } : {}),
404
- };
405
- }),
406
- };
407
- }
408
-
409
- /** Return the same bounded provider projection as the profile parser, adapted
410
- * to the Harness settings indentation. This is used only for rollback CAS and
411
- * never exposes credential values. */
412
- export function readProviderSettingsMaterialization(source, { providerId, file = 'harness/settings.yaml' } = {}) {
413
- const parsed = parseProviderMap(source);
414
- if (!parsed.ok) return { ok: false, code: parsed.code };
415
- const entry = parsed.entries.find((candidate) => candidate.id === providerId);
416
- if (!entry) return { ok: false, code: 'PROVIDER_NOT_FOUND' };
417
- if (parsed.style === 'flow') return readFlowMaterialization(parsed, entry, file);
418
- const block = parsed.lines.slice(entry.start, entry.end).map((line) => ` ${line}`);
419
- const synthetic = ['- id: llm-pi-ai', ' config:', ' providers:', ...block, ''].join('\n');
420
- const result = readProviderMaterialization(synthetic, { providerId, file });
421
- return result.ok ? { ...result, provider: { ...result.provider, source_file: file } } : result;
422
- }
423
-
424
- export function readHarnessDefault(source) {
425
- const parsed = parseDefaultModel(source);
426
- if (!parsed.ok) return { ok: false, code: parsed.code };
427
- return { ok: true, provider: parsed.provider, model: parsed.model, locator: 'agent-default-model' };
428
- }
429
-
430
- function yamlScalar(value, max = 2048) {
431
- if (typeof value !== 'string' || !value.trim() || value.length > max || /[\r\n]/u.test(value)) return null;
432
- return JSON.stringify(value.trim());
433
- }
434
-
435
- function flowModelLine(model) {
436
- const fields = [`id: ${yamlScalar(model.id, 256)}`];
437
- const name = yamlScalar(model.name, 256);
438
- if (name) fields.push(`name: ${name}`);
439
- if (Number.isSafeInteger(model.context_window) && model.context_window > 0) fields.push(`contextWindow: ${model.context_window}`);
440
- if (Number.isSafeInteger(model.max_tokens) && model.max_tokens > 0) fields.push(`maxTokens: ${model.max_tokens}`);
441
- if (Array.isArray(model.input) && model.input.every((value) => value === 'text' || value === 'image')) fields.push(`input: [${[...new Set(model.input)].join(', ')}]`);
442
- if (model.reasoning_efforts && typeof model.reasoning_efforts === 'object' && !Array.isArray(model.reasoning_efforts)) {
443
- const efforts = [];
444
- for (const [key, value] of Object.entries(model.reasoning_efforts)) {
445
- if (/^[A-Za-z][A-Za-z0-9_-]*$/u.test(key) && (value === null || yamlScalar(value, 256))) efforts.push(`${key}: ${value === null ? 'null' : yamlScalar(value, 256)}`);
446
- }
447
- if (efforts.length > 0) fields.push(`reasoningEfforts: { ${efforts.join(', ')} }`);
448
- }
449
- if (model.compat && typeof model.compat === 'object' && !Array.isArray(model.compat) && Object.keys(model.compat).length === 0) fields.push('compat: {}');
450
- return `{ ${fields.join(', ')} }`;
451
- }
452
-
453
- function flowProviderLines(provider) {
454
- const displayName = yamlScalar(provider.display_name ?? provider.id, 256);
455
- if (!displayName) return null;
456
- const credential = provider.credential_ref ? classifyCredentialReference(provider.credential_ref, { kind: 'env' }).value : null;
457
- if (provider.credential_ref && !credential) return null;
458
- const api = yamlScalar(provider.api, 128);
459
- const baseUrl = yamlScalar(provider.base_url, 2048);
460
- const models = Array.isArray(provider.models)
461
- ? provider.models.filter((model) => model && typeof model.id === 'string' && yamlScalar(model.id, 256)).slice(0, 256)
462
- : [];
463
- const fields = [` displayName: ${displayName}`];
464
- if (api) fields.push(` api: ${api}`);
465
- if (baseUrl) fields.push(` baseURL: ${baseUrl}`);
466
- if (credential) fields.push(` apiKeyEnv: ${yamlScalar(credential, 256)}`);
467
- if (models.length === 0) {
468
- return [` ${provider.id}:`, ' {', ...fields.map((line, index) => `${line}${index < fields.length - 1 ? ',' : ''}`), ' }'];
469
- }
470
- const lines = [` ${provider.id}:`, ' {', ...fields.map((line) => `${line},`), ' models:', ' ['];
471
- for (let index = 0; index < models.length; index += 1) lines.push(` ${flowModelLine(models[index])}${index < models.length - 1 ? ',' : ''}`);
472
- lines.push(' ]', ' }');
473
- return lines;
474
- }
475
-
476
- /**
477
- * Add one already-sanitized provider projection to the Harness user layer.
478
- * The operation is pure and revision-bound; callers perform the atomic write.
479
- */
480
- export function addProviderSettings(source, { provider, expectedRevision } = {}) {
481
- const currentRevision = typeof source === 'string' ? sha256(source) : null;
482
- if (expectedRevision !== undefined && expectedRevision !== currentRevision) return { ok: false, code: 'PROVIDER_SETTINGS_CHANGED', revision: currentRevision };
483
- if (!provider || typeof provider !== 'object' || Array.isArray(provider) || typeof provider.id !== 'string' || !PROVIDER_ID.test(provider.id)) {
484
- return { ok: false, code: 'PROVIDER_MATERIALIZATION_INVALID', revision: currentRevision };
485
- }
486
- const parsed = parseProviderMap(source);
487
- if (!parsed.ok) return { ok: false, code: parsed.code, revision: currentRevision };
488
- if (parsed.entries.some((entry) => entry.id === provider.id)) return { ok: false, code: 'PROVIDER_SETTINGS_PROVIDER_EXISTS', revision: currentRevision };
489
- if (parsed.style === 'flow') {
490
- const providerLines = flowProviderLines(provider);
491
- if (!providerLines) return { ok: false, code: 'PROVIDER_MATERIALIZATION_INVALID', revision: currentRevision };
492
- const nextLines = [...parsed.lines];
493
- const previous = parsed.entries.at(-1);
494
- if (previous && !/,\s*$/u.test(nextLines[previous.objectClose])) nextLines[previous.objectClose] = `${nextLines[previous.objectClose]},`;
495
- nextLines.splice(parsed.flowCloseLine, 0, ...providerLines);
496
- const newline = source.includes('\r\n') ? '\r\n' : '\n';
497
- const text = nextLines.join(newline);
498
- return { ok: true, text, added: provider.id, revision: sha256(text) };
499
- }
500
- const displayName = yamlScalar(provider.display_name ?? provider.id, 256);
501
- if (!displayName) return { ok: false, code: 'PROVIDER_MATERIALIZATION_INVALID', revision: currentRevision };
502
- const lines = [` ${provider.id}:`, ` displayName: ${displayName}`];
503
- const api = yamlScalar(provider.api, 128);
504
- const baseUrl = yamlScalar(provider.base_url, 2048);
505
- const credential = provider.credential_ref ? classifyCredentialReference(provider.credential_ref, { kind: 'env' }).value : null;
506
- if (provider.credential_ref && !credential) return { ok: false, code: 'PROVIDER_CREDENTIAL_REFERENCE_UNSAFE', revision: currentRevision };
507
- if (api) lines.push(` api: ${api}`);
508
- if (baseUrl) lines.push(` baseURL: ${baseUrl}`);
509
- if (credential) lines.push(` apiKeyEnv: ${credential}`);
510
- 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) : [];
511
- if (models.length > 0) {
512
- lines.push(' models:');
513
- for (const model of models) {
514
- const id = yamlScalar(model.id, 256);
515
- if (!id) continue;
516
- lines.push(` - id: ${id}`);
517
- const name = yamlScalar(model.name, 256);
518
- if (name) lines.push(` name: ${name}`);
519
- if (Number.isSafeInteger(model.context_window) && model.context_window > 0) lines.push(` contextWindow: ${model.context_window}`);
520
- if (Number.isSafeInteger(model.max_tokens) && model.max_tokens > 0) lines.push(` maxTokens: ${model.max_tokens}`);
521
- if (Array.isArray(model.input) && model.input.every((value) => value === 'text' || value === 'image')) lines.push(` input: [${[...new Set(model.input)].join(', ')}]`);
522
- if (model.reasoning_efforts && typeof model.reasoning_efforts === 'object' && !Array.isArray(model.reasoning_efforts)) {
523
- lines.push(' reasoningEfforts:');
524
- 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)}`);
525
- }
526
- if (model.compat && typeof model.compat === 'object' && !Array.isArray(model.compat) && Object.keys(model.compat).length === 0) lines.push(' compat: {}');
527
- }
528
- }
529
- const nextLines = [...parsed.lines];
530
- const insertAt = parsed.blockEnd;
531
- if (/^ {2}providers:\s*\{\s*\}\s*$/u.test(nextLines[parsed.providersLine])) nextLines[parsed.providersLine] = ' providers:';
532
- nextLines.splice(insertAt, 0, ...lines);
533
- const newline = source.includes('\r\n') ? '\r\n' : '\n';
534
- const text = nextLines.join(newline);
535
- return { ok: true, text, added: provider.id, revision: sha256(text) };
536
- }
537
-
538
- export function replaceHarnessDefault(source, { provider, model, expectedRevision } = {}) {
539
- const currentRevision = typeof source === 'string' ? sha256(source) : null;
540
- if (expectedRevision !== undefined && expectedRevision !== currentRevision) return { ok: false, code: 'PROVIDER_SETTINGS_CHANGED', revision: currentRevision };
541
- if (typeof provider !== 'string' || !PROVIDER_ID.test(provider) || typeof model !== 'string' || !model.trim()) return { ok: false, code: 'PROVIDER_DEFAULT_REPLACEMENT_REQUIRED', revision: currentRevision };
542
- const parsed = parseDefaultModel(source);
543
- if (!parsed.ok) return { ok: false, code: parsed.code, revision: currentRevision };
544
- const lines = [...parsed.lines];
545
- lines[parsed.providerLine] = ` provider: ${provider.trim()}`;
546
- lines[parsed.modelLine] = ` model: ${model.trim()}`;
547
- const newline = source.includes('\r\n') ? '\r\n' : '\n';
548
- const text = lines.join(newline);
549
- return { ok: true, text, previous: { provider: parsed.provider, model: parsed.model }, revision: sha256(text) };
550
- }
551
-
552
- /**
553
- * Apply all Harness settings mutations needed by one provider-delete plan in
554
- * memory, then return one revision-bound text result for a single atomic write.
555
- * This prevents replacing agent-default-model and removing its provider from
556
- * racing each other on the same settings revision.
557
- */
558
- export function mutateProviderSettings(source, {
559
- providerId = null,
560
- removeProvider = false,
561
- replacementDefault = null,
562
- replacementModel = null,
563
- expectedRevision,
564
- } = {}) {
565
- const currentRevision = typeof source === 'string' ? sha256(source) : null;
566
- if (expectedRevision !== undefined && expectedRevision !== currentRevision) return { ok: false, code: 'PROVIDER_SETTINGS_CHANGED', revision: currentRevision };
567
- const parsed = removeProvider ? parseProviderMap(source) : { ok: true, lines: typeof source === 'string' ? source.split(/\r?\n/u) : [] };
568
- if (!parsed.ok) return { ok: false, code: parsed.code, revision: currentRevision };
569
- let providerEntry = null;
570
- if (removeProvider) {
571
- if (typeof providerId !== 'string' || !PROVIDER_ID.test(providerId)) return { ok: false, code: 'PROVIDER_NOT_FOUND', revision: currentRevision };
572
- providerEntry = parsed.entries.find((entry) => entry.id === providerId) ?? null;
573
- if (!providerEntry) return { ok: false, code: 'PROVIDER_NOT_FOUND', revision: currentRevision };
574
- }
575
- let defaultParsed = null;
576
- if (replacementDefault !== null || replacementModel !== null) {
577
- if (typeof replacementDefault !== 'string' || !PROVIDER_ID.test(replacementDefault) || typeof replacementModel !== 'string' || !replacementModel.trim()) return { ok: false, code: 'PROVIDER_DEFAULT_REPLACEMENT_REQUIRED', revision: currentRevision };
578
- defaultParsed = parseDefaultModel(source);
579
- if (!defaultParsed.ok) return { ok: false, code: defaultParsed.code, revision: currentRevision };
580
- if (providerId && defaultParsed.provider !== providerId) return { ok: false, code: 'PROVIDER_DEFAULT_AUTHORITY_CHANGED', revision: currentRevision };
581
- }
582
- const ranges = providerEntry ? [[providerEntry.start, providerEntry.end]] : [];
583
- const finalProvider = providerEntry && parsed.entries.length === 1;
584
- if (removeProvider && parsed.style === 'flow' && finalProvider) {
585
- const lines = [...parsed.lines];
586
- lines.splice(parsed.providersLine, parsed.flowCloseLine - parsed.providersLine + 1, ' providers: {}');
587
- if (defaultParsed) {
588
- const removedBeforeProvider = parsed.providersLine <= defaultParsed.providerLine ? parsed.flowCloseLine - parsed.providersLine : 0;
589
- const removedBeforeModel = parsed.providersLine <= defaultParsed.modelLine ? parsed.flowCloseLine - parsed.providersLine : 0;
590
- lines[defaultParsed.providerLine - removedBeforeProvider] = ` provider: ${replacementDefault.trim()}`;
591
- lines[defaultParsed.modelLine - removedBeforeModel] = ` model: ${replacementModel.trim()}`;
592
- }
593
- const newline = source.includes('\r\n') ? '\r\n' : '\n';
594
- const text = lines.join(newline);
595
- return {
596
- ok: true, text, removed: [providerId], remaining: [],
597
- ...(defaultParsed ? { previous_default: { provider: defaultParsed.provider, model: defaultParsed.model } } : {}),
598
- revision: sha256(text),
599
- };
600
- }
601
- const nextLines = parsed.lines.map((line, index) => {
602
- if (ranges.some(([start, end]) => index >= start && index < end)) return null;
603
- if (finalProvider && index === parsed.providersLine) return ' providers: {}';
604
- if (defaultParsed?.providerLine === index) return ` provider: ${replacementDefault.trim()}`;
605
- if (defaultParsed?.modelLine === index) return ` model: ${replacementModel.trim()}`;
606
- return line;
607
- }).filter((line) => line !== null);
608
- const newline = source.includes('\r\n') ? '\r\n' : '\n';
609
- const text = nextLines.join(newline);
610
- return {
611
- ok: true,
612
- text,
613
- ...(providerEntry ? { removed: [providerId], remaining: parsed.entries.filter((entry) => entry.id !== providerId).map((entry) => entry.id) } : { removed: [], remaining: parsed.entries?.map((entry) => entry.id) ?? [] }),
614
- ...(defaultParsed ? { previous_default: { provider: defaultParsed.provider, model: defaultParsed.model } } : {}),
615
- revision: sha256(text),
616
- };
617
- }
618
-
619
- export function removeProviderSettings(source, { providerIds = [], expectedRevision } = {}) {
620
- const currentRevision = typeof source === 'string' ? sha256(source) : null;
621
- if (expectedRevision !== undefined && expectedRevision !== currentRevision) return { ok: false, code: 'PROVIDER_SETTINGS_CHANGED', revision: currentRevision };
622
- const requested = [...new Set(providerIds.filter((id) => typeof id === 'string' && PROVIDER_ID.test(id)))];
623
- if (requested.length === 0 || requested.length !== providerIds.length) return { ok: false, code: 'PROVIDER_NOT_FOUND', revision: currentRevision };
624
- let next = source;
625
- const results = [];
626
- for (const id of requested) {
627
- const result = mutateProviderSettings(next, { providerId: id, removeProvider: true });
628
- if (!result.ok) return { ...result, revision: currentRevision };
629
- next = result.text;
630
- results.push(result);
631
- }
632
- return { ok: true, text: next, removed: requested, remaining: results.at(-1)?.remaining ?? [], revision: sha256(next) };
633
- }
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 nextContentLine(lines, start, end = lines.length) {
15
+ for (let index = start; index < end; index += 1) if (nonBlank(lines[index])) return index;
16
+ return -1;
17
+ }
18
+ function unquoteScalar(value) {
19
+ const input = typeof value === 'string' ? value.trim() : '';
20
+ if (!input) return null;
21
+ if (input.startsWith('"') && input.endsWith('"')) {
22
+ try { return JSON.parse(input); } catch { return null; }
23
+ }
24
+ if (input.startsWith("'") && input.endsWith("'")) return input.slice(1, -1).replace(/''/gu, "'");
25
+ return input;
26
+ }
27
+ function scalarField(lines, entry, field) {
28
+ const indent = Number.isInteger(entry?.fieldIndent) ? entry.fieldIndent : null;
29
+ const pattern = new RegExp(`^${indent === null ? '\\s+' : ` {${indent}}`}${field}:\\s*(.*?)\\s*$`);
30
+ for (const line of lines.slice(entry.start, entry.end)) {
31
+ const match = line.match(pattern);
32
+ if (!match) continue;
33
+ const value = (entry?.style === 'flow' ? match[1].replace(/,\s*$/u, '') : match[1]).trim();
34
+ if (!value) return null;
35
+ return unquoteScalar(value);
36
+ }
37
+ return null;
38
+ }
39
+
40
+ function parseDefaultModel(source) {
41
+ if (typeof source !== 'string') return { ok: false, code: 'PROVIDER_SETTINGS_DEFAULT_UNSUPPORTED' };
42
+ const lines = source.split(/\r?\n/u);
43
+ const start = lines.findIndex((line) => /^agent-default-model:\s*$/u.test(line));
44
+ if (start < 0) return { ok: false, code: 'PROVIDER_SETTINGS_DEFAULT_UNSUPPORTED' };
45
+ let end = lines.length;
46
+ for (let index = start + 1; index < lines.length; index += 1) {
47
+ if (indentOf(lines[index]) === 0 && nonBlank(lines[index])) { end = index; break; }
48
+ }
49
+ const providerLine = lines.findIndex((line, index) => index > start && index < end && /^ {2}provider:\s*\S.*$/u.test(line));
50
+ const modelLine = lines.findIndex((line, index) => index > start && index < end && /^ {2}model:\s*\S.*$/u.test(line));
51
+ const provider = providerLine >= 0 ? lines[providerLine].replace(/^ {2}provider:\s*/u, '').trim() : null;
52
+ const model = modelLine >= 0 ? lines[modelLine].replace(/^ {2}model:\s*/u, '').trim() : null;
53
+ if (!provider || !model) return { ok: false, code: 'PROVIDER_SETTINGS_DEFAULT_UNSUPPORTED' };
54
+ return { ok: true, lines, start, end, providerLine, modelLine, provider, model };
55
+ }
56
+
57
+ function parseProviderMap(source) {
58
+ if (typeof source !== 'string') return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
59
+ const lines = source.split(/\r?\n/u);
60
+ const llmStart = lines.findIndex((line) => /^llm-pi-ai:\s*$/u.test(line));
61
+ if (llmStart < 0) return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
62
+ let blockEnd = lines.length;
63
+ for (let index = llmStart + 1; index < lines.length; index += 1) {
64
+ if (indentOf(lines[index]) === 0 && nonBlank(lines[index])) { blockEnd = index; break; }
65
+ }
66
+ const providersLine = lines.findIndex((line, index) => index > llmStart && index < blockEnd && /^ {2}providers:\s*(?:\{\s*\})?\s*$/u.test(line));
67
+ if (providersLine < 0) return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
68
+ const flowOpenLine = nextContentLine(lines, providersLine + 1, blockEnd);
69
+ if (flowOpenLine >= 0 && /^ {4}\{\s*$/u.test(lines[flowOpenLine])) {
70
+ let flowCloseLine = -1;
71
+ const entries = [];
72
+ let index = flowOpenLine + 1;
73
+ while (index < blockEnd) {
74
+ index = nextContentLine(lines, index, blockEnd);
75
+ if (index < 0) break;
76
+ if (/^ {4}\}\s*$/u.test(lines[index])) { flowCloseLine = index; break; }
77
+ const match = lines[index].match(/^ {6}([A-Za-z0-9][A-Za-z0-9._-]*):\s*$/u);
78
+ if (!match) return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
79
+ const objectOpen = nextContentLine(lines, index + 1, blockEnd);
80
+ if (objectOpen < 0 || !/^ {8}\{\s*$/u.test(lines[objectOpen])) return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
81
+ let objectClose = -1;
82
+ for (let cursor = objectOpen + 1; cursor < blockEnd; cursor += 1) {
83
+ if (/^ {8}\},?\s*$/u.test(lines[cursor])) { objectClose = cursor; break; }
84
+ }
85
+ if (objectClose < 0) return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
86
+ entries.push({ id: match[1], start: index, end: objectClose + 1, style: 'flow', fieldIndent: 10, objectOpen, objectClose });
87
+ index = objectClose + 1;
88
+ }
89
+ if (flowCloseLine < 0) return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
90
+ for (let entryIndex = 0; entryIndex < entries.length - 1; entryIndex += 1) {
91
+ if (!/^ {8}\},\s*$/u.test(lines[entries[entryIndex].objectClose])) return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
92
+ }
93
+ if (entries.some((entry) => !validFlowEntryStructure(lines, entry))) {
94
+ return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
95
+ }
96
+ return {
97
+ ok: true, style: 'flow', lines, llmStart, blockEnd, providersLine,
98
+ providersBlockEnd: flowCloseLine + 1, flowOpenLine, flowCloseLine, entries,
99
+ };
100
+ }
101
+ let providersBlockEnd = blockEnd;
102
+ for (let index = providersLine + 1; index < blockEnd; index += 1) {
103
+ if (nonBlank(lines[index]) && indentOf(lines[index]) <= 2) { providersBlockEnd = index; break; }
104
+ }
105
+ const providerIndent = 4;
106
+ const entries = [];
107
+ for (let index = providersLine + 1; index < blockEnd; index += 1) {
108
+ const line = lines[index];
109
+ if (!nonBlank(line)) continue;
110
+ const indent = indentOf(line);
111
+ if (indent < providerIndent) {
112
+ if (indent > 2) return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
113
+ break;
114
+ }
115
+ if (indent !== providerIndent) continue;
116
+ const match = line.match(/^ {4}([A-Za-z0-9][A-Za-z0-9._-]*):\s*$/u);
117
+ if (!match) return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
118
+ let end = index + 1;
119
+ while (end < blockEnd) {
120
+ const next = lines[end];
121
+ if (nonBlank(next) && indentOf(next) <= providerIndent) break;
122
+ end += 1;
123
+ }
124
+ entries.push({ id: match[1], start: index, end, style: 'block', fieldIndent: 6 });
125
+ index = end - 1;
126
+ }
127
+ return { ok: true, style: 'block', lines, llmStart, blockEnd, providersLine, providersBlockEnd, entries };
128
+ }
129
+
130
+ function splitFlowItems(source) {
131
+ const items = [];
132
+ let start = 0;
133
+ let braces = 0;
134
+ let brackets = 0;
135
+ let quote = null;
136
+ let escaped = false;
137
+ for (let index = 0; index < source.length; index += 1) {
138
+ const char = source[index];
139
+ if (quote) {
140
+ if (quote === '"' && escaped) { escaped = false; continue; }
141
+ if (quote === '"' && char === '\\') { escaped = true; continue; }
142
+ if (char === quote) quote = null;
143
+ continue;
144
+ }
145
+ if (char === '"' || char === "'") { quote = char; continue; }
146
+ if (char === '{') braces += 1;
147
+ else if (char === '}') braces -= 1;
148
+ else if (char === '[') brackets += 1;
149
+ else if (char === ']') brackets -= 1;
150
+ else if (char === ',' && braces === 0 && brackets === 0) {
151
+ items.push(source.slice(start, index).trim());
152
+ start = index + 1;
153
+ }
154
+ if (braces < 0 || brackets < 0) return null;
155
+ }
156
+ if (quote || braces !== 0 || brackets !== 0) return null;
157
+ const tail = source.slice(start).trim();
158
+ if (tail) items.push(tail);
159
+ return items;
160
+ }
161
+
162
+ function parseFlowObject(source, depth = 0) {
163
+ if (depth > 8) return null;
164
+ const input = source.trim().replace(/,\s*$/u, '');
165
+ if (!input.startsWith('{') || !input.endsWith('}')) return null;
166
+ const items = splitFlowItems(input.slice(1, -1));
167
+ if (!items) return null;
168
+ const fields = new Map();
169
+ for (const item of items) {
170
+ let splitAt = -1;
171
+ let quote = null;
172
+ let escaped = false;
173
+ for (let index = 0; index < item.length; index += 1) {
174
+ const char = item[index];
175
+ if (quote) {
176
+ if (quote === '"' && escaped) { escaped = false; continue; }
177
+ if (quote === '"' && char === '\\') { escaped = true; continue; }
178
+ if (char === quote) quote = null;
179
+ } else if (char === '"' || char === "'") quote = char;
180
+ else if (char === ':') { splitAt = index; break; }
181
+ }
182
+ if (splitAt < 1 || hasUnseparatedFlowMapping(item, splitAt)) return null;
183
+ const key = item.slice(0, splitAt).trim();
184
+ const value = item.slice(splitAt + 1).trim();
185
+ if (!/^[A-Za-z][A-Za-z0-9_-]*$/u.test(key) || !value || fields.has(key)) return null;
186
+ if (value.startsWith('{') && (!value.endsWith('}') || parseFlowObject(value, depth + 1) === null)) return null;
187
+ fields.set(key, value);
188
+ }
189
+ return fields;
190
+ }
191
+
192
+ function hasUnseparatedFlowMapping(item, firstColon) {
193
+ let braces = 0;
194
+ let brackets = 0;
195
+ let quote = null;
196
+ let escaped = false;
197
+ for (let index = firstColon + 1; index < item.length; index += 1) {
198
+ const char = item[index];
199
+ if (quote) {
200
+ if (quote === '"' && escaped) { escaped = false; continue; }
201
+ if (quote === '"' && char === '\\') { escaped = true; continue; }
202
+ if (char === quote) quote = null;
203
+ continue;
204
+ }
205
+ if (char === '"' || char === "'") { quote = char; continue; }
206
+ if (char === '{') braces += 1;
207
+ else if (char === '}') braces -= 1;
208
+ else if (char === '[') brackets += 1;
209
+ else if (char === ']') brackets -= 1;
210
+ else if (char === ':' && braces === 0 && brackets === 0 && /\s/u.test(item[index + 1] ?? '')) return true;
211
+ if (braces < 0 || brackets < 0) return true;
212
+ }
213
+ return quote !== null || braces !== 0 || brackets !== 0;
214
+ }
215
+
216
+ function validFlowEntryStructure(lines, entry) {
217
+ const fields = [];
218
+ let cursor = entry.objectOpen + 1;
219
+ while (cursor < entry.objectClose) {
220
+ cursor = nextContentLine(lines, cursor, entry.objectClose);
221
+ if (cursor < 0) break;
222
+ const line = lines[cursor];
223
+ if (indentOf(line) !== entry.fieldIndent) return false;
224
+ const field = line.match(/^\s+([A-Za-z][A-Za-z0-9_-]*):\s*(.*?)\s*$/u);
225
+ if (!field) return false;
226
+ let terminator = cursor;
227
+ if (!field[2]) {
228
+ const listOpen = nextContentLine(lines, cursor + 1, entry.objectClose);
229
+ if (listOpen < 0 || indentOf(lines[listOpen]) !== entry.fieldIndent + 2 || !/^\s*\[\s*$/u.test(lines[listOpen])) return false;
230
+ const items = [];
231
+ let listClose = -1;
232
+ for (let nested = listOpen + 1; nested < entry.objectClose; nested += 1) {
233
+ if (!nonBlank(lines[nested])) continue;
234
+ if (indentOf(lines[nested]) === entry.fieldIndent + 2 && /^\s*\]\s*,?\s*$/u.test(lines[nested])) {
235
+ listClose = nested;
236
+ break;
237
+ }
238
+ if (indentOf(lines[nested]) !== entry.fieldIndent + 4 || !parseFlowObject(lines[nested])) return false;
239
+ items.push(nested);
240
+ }
241
+ if (listClose < 0) return false;
242
+ for (let index = 0; index < items.length - 1; index += 1) if (!/,\s*$/u.test(lines[items[index]])) return false;
243
+ terminator = listClose;
244
+ cursor = listClose + 1;
245
+ } else {
246
+ cursor += 1;
247
+ }
248
+ fields.push({ terminator });
249
+ }
250
+ for (let index = 0; index < fields.length - 1; index += 1) {
251
+ if (!/,\s*$/u.test(lines[fields[index].terminator])) return false;
252
+ }
253
+ return true;
254
+ }
255
+
256
+ function parseFlowModel(source) {
257
+ const fields = parseFlowObject(source);
258
+ if (!fields) return null;
259
+ const allowed = new Set(['id', 'name', 'contextWindow', 'maxTokens', 'input', 'reasoningEfforts', 'compat']);
260
+ if ([...fields.keys()].some((key) => !allowed.has(key))) return null;
261
+ const id = unquoteScalar(fields.get('id'));
262
+ if (!id || id.length > 256 || /[\r\n]/u.test(id)) return null;
263
+ const model = { id };
264
+ if (fields.has('name')) {
265
+ const name = unquoteScalar(fields.get('name'));
266
+ if (!name || name.length > 256 || /[\r\n]/u.test(name)) return null;
267
+ model.name = name;
268
+ }
269
+ for (const [sourceKey, targetKey] of [['contextWindow', 'context_window'], ['maxTokens', 'max_tokens']]) {
270
+ if (!fields.has(sourceKey)) continue;
271
+ const value = Number(unquoteScalar(fields.get(sourceKey)));
272
+ if (!Number.isSafeInteger(value) || value <= 0) return null;
273
+ model[targetKey] = value;
274
+ }
275
+ if (fields.has('input')) {
276
+ const input = fields.get('input').trim();
277
+ if (!input.startsWith('[') || !input.endsWith(']')) return null;
278
+ const modalities = splitFlowItems(input.slice(1, -1));
279
+ if (!modalities) return null;
280
+ const normalized = modalities.map(unquoteScalar);
281
+ if (normalized.some((value) => !['text', 'image'].includes(value))) return null;
282
+ model.input = [...new Set(normalized)];
283
+ }
284
+ if (fields.has('reasoningEfforts')) {
285
+ const efforts = parseFlowObject(fields.get('reasoningEfforts'));
286
+ if (!efforts) return null;
287
+ const normalized = {};
288
+ for (const [key, raw] of efforts) {
289
+ const value = unquoteScalar(raw);
290
+ if (raw === 'null' || raw === '~') normalized[key] = null;
291
+ else if (!value || value.length > 256 || /[\r\n]/u.test(value)) return null;
292
+ else normalized[key] = value;
293
+ }
294
+ model.reasoning_efforts = normalized;
295
+ }
296
+ if (fields.has('compat')) {
297
+ if (fields.get('compat').trim() !== '{}') return null;
298
+ model.compat = {};
299
+ }
300
+ return model;
301
+ }
302
+
303
+ function readFlowMaterialization(parsed, entry, file) {
304
+ const directFields = new Set();
305
+ for (const line of parsed.lines.slice(entry.objectOpen + 1, entry.objectClose)) {
306
+ if (!nonBlank(line) || indentOf(line) !== entry.fieldIndent) continue;
307
+ const match = line.match(/^\s+([A-Za-z][A-Za-z0-9_-]*):/u);
308
+ if (!match || directFields.has(match[1])) return { ok: false, code: 'PROVIDER_MATERIALIZATION_UNSUPPORTED_FIELDS' };
309
+ directFields.add(match[1]);
310
+ }
311
+ const allowed = new Set(['displayName', 'apiKeyEnv', 'api', 'baseURL', 'models']);
312
+ if ([...directFields].some((field) => !allowed.has(field))) return { ok: false, code: 'PROVIDER_MATERIALIZATION_UNSUPPORTED_FIELDS' };
313
+ const rawDisplayName = scalarField(parsed.lines, entry, 'displayName');
314
+ const displayName = rawDisplayName && rawDisplayName.length <= 256 && !/[\r\n]/u.test(rawDisplayName) ? rawDisplayName : entry.id;
315
+ const rawCredentialRef = scalarField(parsed.lines, entry, 'apiKeyEnv');
316
+ const credential = classifyCredentialReference(rawCredentialRef, { kind: 'env' });
317
+ if (credential.redacted) return { ok: false, code: 'PROVIDER_CREDENTIAL_REFERENCE_UNSAFE' };
318
+ const api = scalarField(parsed.lines, entry, 'api');
319
+ 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' };
320
+ const rawBaseUrl = scalarField(parsed.lines, entry, 'baseURL');
321
+ const baseUrl = rawBaseUrl && rawBaseUrl.length <= 2048 && !/[\r\n]/u.test(rawBaseUrl) ? rawBaseUrl : null;
322
+ if (baseUrl) {
323
+ try {
324
+ const url = new URL(baseUrl);
325
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || !url.hostname) return { ok: false, code: 'PROVIDER_BASE_URL_UNSAFE' };
326
+ } catch { return { ok: false, code: 'PROVIDER_BASE_URL_UNSAFE' }; }
327
+ }
328
+ const models = [];
329
+ if (directFields.has('models')) {
330
+ const modelsLine = parsed.lines.findIndex((line, index) => index > entry.objectOpen && index < entry.objectClose && /^ {10}models:\s*$/u.test(line));
331
+ const listOpen = nextContentLine(parsed.lines, modelsLine + 1, entry.objectClose);
332
+ if (modelsLine < 0 || listOpen < 0 || !/^ {12}\[\s*$/u.test(parsed.lines[listOpen])) return { ok: false, code: 'PROVIDER_MODEL_SCHEMA_UNSUPPORTED' };
333
+ let listClose = -1;
334
+ for (let index = listOpen + 1; index < entry.objectClose; index += 1) {
335
+ if (/^ {12}\]\s*,?\s*$/u.test(parsed.lines[index])) { listClose = index; break; }
336
+ if (!nonBlank(parsed.lines[index])) continue;
337
+ if (indentOf(parsed.lines[index]) !== 14) return { ok: false, code: 'PROVIDER_MODEL_SCHEMA_UNSUPPORTED' };
338
+ const model = parseFlowModel(parsed.lines[index]);
339
+ if (!model || models.some((candidate) => candidate.id === model.id)) return { ok: false, code: 'PROVIDER_MODEL_SCHEMA_UNSUPPORTED' };
340
+ models.push(model);
341
+ }
342
+ if (listClose < 0) return { ok: false, code: 'PROVIDER_MODEL_SCHEMA_UNSUPPORTED' };
343
+ }
344
+ return {
345
+ ok: true,
346
+ provider: {
347
+ id: entry.id, display_name: displayName,
348
+ ...(credential.value ? { credential_ref: credential.value } : {}),
349
+ ...(api ? { api } : {}), ...(baseUrl ? { base_url: baseUrl } : {}),
350
+ models: models.slice(0, 256), source_file: file,
351
+ },
352
+ };
353
+ }
354
+
355
+ const SENSITIVE_CREDENTIAL_FIELD = /(?:api[_-]?key|access[_-]?token|refresh[_-]?token|(?:^|[_-])token(?:$|[_-])|secret|password|authorization|credential|private[_-]?key|client[_-]?secret|cookie|bearer|webhook)/iu;
356
+ const REFERENCE_FIELD_SUFFIX = /(?:env|ref|name|handle|id|file|path)$/iu;
357
+
358
+ function isInlineCredentialLine(line) {
359
+ const inlineKeys = [...line.matchAll(/["']?([A-Za-z][A-Za-z0-9_.-]*)["']?\s*:/gu)].map((match) => match[1]);
360
+ if (inlineKeys.some((field) => SENSITIVE_CREDENTIAL_FIELD.test(field) && !REFERENCE_FIELD_SUFFIX.test(field.replace(/[_.-]/gu, '')))) return true;
361
+ const match = line.match(/^\s*["']?([A-Za-z][A-Za-z0-9_.-]*)["']?\s*:\s*(.*?)\s*$/u);
362
+ if (!match) return false;
363
+ const [, field, value] = match;
364
+ if (!value || /^(?:null|~|\{\}|\[\])$/u.test(value)) return false;
365
+ const normalized = field.replace(/[_.-]/gu, '');
366
+ if (SENSITIVE_CREDENTIAL_FIELD.test(field) && !REFERENCE_FIELD_SUFFIX.test(normalized)) return true;
367
+ return /(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password|authorization|credential|private[_-]?key|client[_-]?secret|cookie|bearer|webhook)\s*["']?\s*:/iu.test(value);
368
+ }
369
+
370
+ export function hasInlineProviderCredentials(source, { providerIds = [] } = {}) {
371
+ const parsed = parseProviderMap(source);
372
+ if (!parsed.ok) return { ok: false, code: parsed.code };
373
+ const requested = providerIds.length > 0 ? new Set(providerIds) : null;
374
+ const providers = parsed.entries.filter((entry) => !requested || requested.has(entry.id));
375
+ return { ok: true, inline: providers.some((entry) => parsed.lines.slice(entry.start, entry.end).some(isInlineCredentialLine)) };
376
+ }
377
+
378
+ export function inspectProviderSettings(source) {
379
+ const revision = typeof source === 'string' ? sha256(source) : null;
380
+ const parsed = parseProviderMap(source);
381
+ if (!parsed.ok) return { ok: false, code: parsed.code, revision };
382
+ return { ok: true, schema: 'harness-settings-llm-pi-ai-v1', providerIds: parsed.entries.map((entry) => entry.id), revision };
383
+ }
384
+
385
+ export function readProviderSettingsDeclarations(source, { file = 'harness/settings.yaml' } = {}) {
386
+ const parsed = parseProviderMap(source);
387
+ if (!parsed.ok) return { ok: false, code: parsed.code };
388
+ return {
389
+ ok: true,
390
+ declarations: parsed.entries.map((entry) => {
391
+ const rawCredentialRef = scalarField(parsed.lines, entry, 'apiKeyEnv');
392
+ const credential = classifyCredentialReference(rawCredentialRef, { kind: 'env' });
393
+ return {
394
+ id: entry.id,
395
+ display_name: scalarField(parsed.lines, entry, 'displayName') ?? entry.id,
396
+ // settings.yaml is the Harness user layer. Crew profile/base
397
+ // declarations are reported separately by provider-profile-store.
398
+ origin: 'dynamic',
399
+ ownership: 'dynamic-user',
400
+ file,
401
+ declaration_authority: { kind: 'harness-settings', locator: `llm-pi-ai.providers.${entry.id}` },
402
+ ...(credential.value ? { credential_ref: { kind: 'env', name_or_handle: credential.value, ownership: 'unknown' } } : {}),
403
+ ...(credential.redacted ? { credential_status: 'present-redacted' } : {}),
404
+ };
405
+ }),
406
+ };
407
+ }
408
+
409
+ /** Return the same bounded provider projection as the profile parser, adapted
410
+ * to the Harness settings indentation. This is used only for rollback CAS and
411
+ * never exposes credential values. */
412
+ export function readProviderSettingsMaterialization(source, { providerId, file = 'harness/settings.yaml' } = {}) {
413
+ const parsed = parseProviderMap(source);
414
+ if (!parsed.ok) return { ok: false, code: parsed.code };
415
+ const entry = parsed.entries.find((candidate) => candidate.id === providerId);
416
+ if (!entry) return { ok: false, code: 'PROVIDER_NOT_FOUND' };
417
+ if (parsed.style === 'flow') return readFlowMaterialization(parsed, entry, file);
418
+ const block = parsed.lines.slice(entry.start, entry.end).map((line) => ` ${line}`);
419
+ const synthetic = ['- id: llm-pi-ai', ' config:', ' providers:', ...block, ''].join('\n');
420
+ const result = readProviderMaterialization(synthetic, { providerId, file });
421
+ return result.ok ? { ...result, provider: { ...result.provider, source_file: file } } : result;
422
+ }
423
+
424
+ export function readHarnessDefault(source) {
425
+ const parsed = parseDefaultModel(source);
426
+ if (!parsed.ok) return { ok: false, code: parsed.code };
427
+ return { ok: true, provider: parsed.provider, model: parsed.model, locator: 'agent-default-model' };
428
+ }
429
+
430
+ function yamlScalar(value, max = 2048) {
431
+ if (typeof value !== 'string' || !value.trim() || value.length > max || /[\r\n]/u.test(value)) return null;
432
+ return JSON.stringify(value.trim());
433
+ }
434
+
435
+ function flowModelLine(model) {
436
+ const fields = [`id: ${yamlScalar(model.id, 256)}`];
437
+ const name = yamlScalar(model.name, 256);
438
+ if (name) fields.push(`name: ${name}`);
439
+ if (Number.isSafeInteger(model.context_window) && model.context_window > 0) fields.push(`contextWindow: ${model.context_window}`);
440
+ if (Number.isSafeInteger(model.max_tokens) && model.max_tokens > 0) fields.push(`maxTokens: ${model.max_tokens}`);
441
+ if (Array.isArray(model.input) && model.input.every((value) => value === 'text' || value === 'image')) fields.push(`input: [${[...new Set(model.input)].join(', ')}]`);
442
+ if (model.reasoning_efforts && typeof model.reasoning_efforts === 'object' && !Array.isArray(model.reasoning_efforts)) {
443
+ const efforts = [];
444
+ for (const [key, value] of Object.entries(model.reasoning_efforts)) {
445
+ if (/^[A-Za-z][A-Za-z0-9_-]*$/u.test(key) && (value === null || yamlScalar(value, 256))) efforts.push(`${key}: ${value === null ? 'null' : yamlScalar(value, 256)}`);
446
+ }
447
+ if (efforts.length > 0) fields.push(`reasoningEfforts: { ${efforts.join(', ')} }`);
448
+ }
449
+ if (model.compat && typeof model.compat === 'object' && !Array.isArray(model.compat) && Object.keys(model.compat).length === 0) fields.push('compat: {}');
450
+ return `{ ${fields.join(', ')} }`;
451
+ }
452
+
453
+ function flowProviderLines(provider) {
454
+ const displayName = yamlScalar(provider.display_name ?? provider.id, 256);
455
+ if (!displayName) return null;
456
+ const credential = provider.credential_ref ? classifyCredentialReference(provider.credential_ref, { kind: 'env' }).value : null;
457
+ if (provider.credential_ref && !credential) return null;
458
+ const api = yamlScalar(provider.api, 128);
459
+ const baseUrl = yamlScalar(provider.base_url, 2048);
460
+ const models = Array.isArray(provider.models)
461
+ ? provider.models.filter((model) => model && typeof model.id === 'string' && yamlScalar(model.id, 256)).slice(0, 256)
462
+ : [];
463
+ const fields = [` displayName: ${displayName}`];
464
+ if (api) fields.push(` api: ${api}`);
465
+ if (baseUrl) fields.push(` baseURL: ${baseUrl}`);
466
+ if (credential) fields.push(` apiKeyEnv: ${yamlScalar(credential, 256)}`);
467
+ if (models.length === 0) {
468
+ return [` ${provider.id}:`, ' {', ...fields.map((line, index) => `${line}${index < fields.length - 1 ? ',' : ''}`), ' }'];
469
+ }
470
+ const lines = [` ${provider.id}:`, ' {', ...fields.map((line) => `${line},`), ' models:', ' ['];
471
+ for (let index = 0; index < models.length; index += 1) lines.push(` ${flowModelLine(models[index])}${index < models.length - 1 ? ',' : ''}`);
472
+ lines.push(' ]', ' }');
473
+ return lines;
474
+ }
475
+
476
+ /**
477
+ * Add one already-sanitized provider projection to the Harness user layer.
478
+ * The operation is pure and revision-bound; callers perform the atomic write.
479
+ */
480
+ export function addProviderSettings(source, { provider, expectedRevision } = {}) {
481
+ const currentRevision = typeof source === 'string' ? sha256(source) : null;
482
+ if (expectedRevision !== undefined && expectedRevision !== currentRevision) return { ok: false, code: 'PROVIDER_SETTINGS_CHANGED', revision: currentRevision };
483
+ if (!provider || typeof provider !== 'object' || Array.isArray(provider) || typeof provider.id !== 'string' || !PROVIDER_ID.test(provider.id)) {
484
+ return { ok: false, code: 'PROVIDER_MATERIALIZATION_INVALID', revision: currentRevision };
485
+ }
486
+ const parsed = parseProviderMap(source);
487
+ if (!parsed.ok) return { ok: false, code: parsed.code, revision: currentRevision };
488
+ if (parsed.entries.some((entry) => entry.id === provider.id)) return { ok: false, code: 'PROVIDER_SETTINGS_PROVIDER_EXISTS', revision: currentRevision };
489
+ if (parsed.style === 'flow') {
490
+ const providerLines = flowProviderLines(provider);
491
+ if (!providerLines) return { ok: false, code: 'PROVIDER_MATERIALIZATION_INVALID', revision: currentRevision };
492
+ const nextLines = [...parsed.lines];
493
+ const previous = parsed.entries.at(-1);
494
+ if (previous && !/,\s*$/u.test(nextLines[previous.objectClose])) nextLines[previous.objectClose] = `${nextLines[previous.objectClose]},`;
495
+ nextLines.splice(parsed.flowCloseLine, 0, ...providerLines);
496
+ const newline = source.includes('\r\n') ? '\r\n' : '\n';
497
+ const text = nextLines.join(newline);
498
+ return { ok: true, text, added: provider.id, revision: sha256(text) };
499
+ }
500
+ const displayName = yamlScalar(provider.display_name ?? provider.id, 256);
501
+ if (!displayName) return { ok: false, code: 'PROVIDER_MATERIALIZATION_INVALID', revision: currentRevision };
502
+ const lines = [` ${provider.id}:`, ` displayName: ${displayName}`];
503
+ const api = yamlScalar(provider.api, 128);
504
+ const baseUrl = yamlScalar(provider.base_url, 2048);
505
+ const credential = provider.credential_ref ? classifyCredentialReference(provider.credential_ref, { kind: 'env' }).value : null;
506
+ if (provider.credential_ref && !credential) return { ok: false, code: 'PROVIDER_CREDENTIAL_REFERENCE_UNSAFE', revision: currentRevision };
507
+ if (api) lines.push(` api: ${api}`);
508
+ if (baseUrl) lines.push(` baseURL: ${baseUrl}`);
509
+ if (credential) lines.push(` apiKeyEnv: ${credential}`);
510
+ 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) : [];
511
+ if (models.length > 0) {
512
+ lines.push(' models:');
513
+ for (const model of models) {
514
+ const id = yamlScalar(model.id, 256);
515
+ if (!id) continue;
516
+ lines.push(` - id: ${id}`);
517
+ const name = yamlScalar(model.name, 256);
518
+ if (name) lines.push(` name: ${name}`);
519
+ if (Number.isSafeInteger(model.context_window) && model.context_window > 0) lines.push(` contextWindow: ${model.context_window}`);
520
+ if (Number.isSafeInteger(model.max_tokens) && model.max_tokens > 0) lines.push(` maxTokens: ${model.max_tokens}`);
521
+ if (Array.isArray(model.input) && model.input.every((value) => value === 'text' || value === 'image')) lines.push(` input: [${[...new Set(model.input)].join(', ')}]`);
522
+ if (model.reasoning_efforts && typeof model.reasoning_efforts === 'object' && !Array.isArray(model.reasoning_efforts)) {
523
+ lines.push(' reasoningEfforts:');
524
+ 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)}`);
525
+ }
526
+ if (model.compat && typeof model.compat === 'object' && !Array.isArray(model.compat) && Object.keys(model.compat).length === 0) lines.push(' compat: {}');
527
+ }
528
+ }
529
+ const nextLines = [...parsed.lines];
530
+ const insertAt = parsed.blockEnd;
531
+ if (/^ {2}providers:\s*\{\s*\}\s*$/u.test(nextLines[parsed.providersLine])) nextLines[parsed.providersLine] = ' providers:';
532
+ nextLines.splice(insertAt, 0, ...lines);
533
+ const newline = source.includes('\r\n') ? '\r\n' : '\n';
534
+ const text = nextLines.join(newline);
535
+ return { ok: true, text, added: provider.id, revision: sha256(text) };
536
+ }
537
+
538
+ export function replaceHarnessDefault(source, { provider, model, expectedRevision } = {}) {
539
+ const currentRevision = typeof source === 'string' ? sha256(source) : null;
540
+ if (expectedRevision !== undefined && expectedRevision !== currentRevision) return { ok: false, code: 'PROVIDER_SETTINGS_CHANGED', revision: currentRevision };
541
+ if (typeof provider !== 'string' || !PROVIDER_ID.test(provider) || typeof model !== 'string' || !model.trim()) return { ok: false, code: 'PROVIDER_DEFAULT_REPLACEMENT_REQUIRED', revision: currentRevision };
542
+ const parsed = parseDefaultModel(source);
543
+ if (!parsed.ok) return { ok: false, code: parsed.code, revision: currentRevision };
544
+ const lines = [...parsed.lines];
545
+ lines[parsed.providerLine] = ` provider: ${provider.trim()}`;
546
+ lines[parsed.modelLine] = ` model: ${model.trim()}`;
547
+ const newline = source.includes('\r\n') ? '\r\n' : '\n';
548
+ const text = lines.join(newline);
549
+ return { ok: true, text, previous: { provider: parsed.provider, model: parsed.model }, revision: sha256(text) };
550
+ }
551
+
552
+ /**
553
+ * Apply all Harness settings mutations needed by one provider-delete plan in
554
+ * memory, then return one revision-bound text result for a single atomic write.
555
+ * This prevents replacing agent-default-model and removing its provider from
556
+ * racing each other on the same settings revision.
557
+ */
558
+ export function mutateProviderSettings(source, {
559
+ providerId = null,
560
+ removeProvider = false,
561
+ replacementDefault = null,
562
+ replacementModel = null,
563
+ expectedRevision,
564
+ } = {}) {
565
+ const currentRevision = typeof source === 'string' ? sha256(source) : null;
566
+ if (expectedRevision !== undefined && expectedRevision !== currentRevision) return { ok: false, code: 'PROVIDER_SETTINGS_CHANGED', revision: currentRevision };
567
+ const parsed = removeProvider ? parseProviderMap(source) : { ok: true, lines: typeof source === 'string' ? source.split(/\r?\n/u) : [] };
568
+ if (!parsed.ok) return { ok: false, code: parsed.code, revision: currentRevision };
569
+ let providerEntry = null;
570
+ if (removeProvider) {
571
+ if (typeof providerId !== 'string' || !PROVIDER_ID.test(providerId)) return { ok: false, code: 'PROVIDER_NOT_FOUND', revision: currentRevision };
572
+ providerEntry = parsed.entries.find((entry) => entry.id === providerId) ?? null;
573
+ if (!providerEntry) return { ok: false, code: 'PROVIDER_NOT_FOUND', revision: currentRevision };
574
+ }
575
+ let defaultParsed = null;
576
+ if (replacementDefault !== null || replacementModel !== null) {
577
+ if (typeof replacementDefault !== 'string' || !PROVIDER_ID.test(replacementDefault) || typeof replacementModel !== 'string' || !replacementModel.trim()) return { ok: false, code: 'PROVIDER_DEFAULT_REPLACEMENT_REQUIRED', revision: currentRevision };
578
+ defaultParsed = parseDefaultModel(source);
579
+ if (!defaultParsed.ok) return { ok: false, code: defaultParsed.code, revision: currentRevision };
580
+ if (providerId && defaultParsed.provider !== providerId) return { ok: false, code: 'PROVIDER_DEFAULT_AUTHORITY_CHANGED', revision: currentRevision };
581
+ }
582
+ const ranges = providerEntry ? [[providerEntry.start, providerEntry.end]] : [];
583
+ const finalProvider = providerEntry && parsed.entries.length === 1;
584
+ if (removeProvider && parsed.style === 'flow' && finalProvider) {
585
+ const lines = [...parsed.lines];
586
+ lines.splice(parsed.providersLine, parsed.flowCloseLine - parsed.providersLine + 1, ' providers: {}');
587
+ if (defaultParsed) {
588
+ const removedBeforeProvider = parsed.providersLine <= defaultParsed.providerLine ? parsed.flowCloseLine - parsed.providersLine : 0;
589
+ const removedBeforeModel = parsed.providersLine <= defaultParsed.modelLine ? parsed.flowCloseLine - parsed.providersLine : 0;
590
+ lines[defaultParsed.providerLine - removedBeforeProvider] = ` provider: ${replacementDefault.trim()}`;
591
+ lines[defaultParsed.modelLine - removedBeforeModel] = ` model: ${replacementModel.trim()}`;
592
+ }
593
+ const newline = source.includes('\r\n') ? '\r\n' : '\n';
594
+ const text = lines.join(newline);
595
+ return {
596
+ ok: true, text, removed: [providerId], remaining: [],
597
+ ...(defaultParsed ? { previous_default: { provider: defaultParsed.provider, model: defaultParsed.model } } : {}),
598
+ revision: sha256(text),
599
+ };
600
+ }
601
+ const nextLines = parsed.lines.map((line, index) => {
602
+ if (ranges.some(([start, end]) => index >= start && index < end)) return null;
603
+ if (finalProvider && index === parsed.providersLine) return ' providers: {}';
604
+ if (defaultParsed?.providerLine === index) return ` provider: ${replacementDefault.trim()}`;
605
+ if (defaultParsed?.modelLine === index) return ` model: ${replacementModel.trim()}`;
606
+ return line;
607
+ }).filter((line) => line !== null);
608
+ const newline = source.includes('\r\n') ? '\r\n' : '\n';
609
+ const text = nextLines.join(newline);
610
+ return {
611
+ ok: true,
612
+ text,
613
+ ...(providerEntry ? { removed: [providerId], remaining: parsed.entries.filter((entry) => entry.id !== providerId).map((entry) => entry.id) } : { removed: [], remaining: parsed.entries?.map((entry) => entry.id) ?? [] }),
614
+ ...(defaultParsed ? { previous_default: { provider: defaultParsed.provider, model: defaultParsed.model } } : {}),
615
+ revision: sha256(text),
616
+ };
617
+ }
618
+
619
+ export function removeProviderSettings(source, { providerIds = [], expectedRevision } = {}) {
620
+ const currentRevision = typeof source === 'string' ? sha256(source) : null;
621
+ if (expectedRevision !== undefined && expectedRevision !== currentRevision) return { ok: false, code: 'PROVIDER_SETTINGS_CHANGED', revision: currentRevision };
622
+ const requested = [...new Set(providerIds.filter((id) => typeof id === 'string' && PROVIDER_ID.test(id)))];
623
+ if (requested.length === 0 || requested.length !== providerIds.length) return { ok: false, code: 'PROVIDER_NOT_FOUND', revision: currentRevision };
624
+ let next = source;
625
+ const results = [];
626
+ for (const id of requested) {
627
+ const result = mutateProviderSettings(next, { providerId: id, removeProvider: true });
628
+ if (!result.ok) return { ...result, revision: currentRevision };
629
+ next = result.text;
630
+ results.push(result);
631
+ }
632
+ return { ok: true, text: next, removed: requested, remaining: results.at(-1)?.remaining ?? [], revision: sha256(next) };
633
+ }