@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.
- package/.claude-plugin/marketplace.json +16 -16
- package/.claude-plugin/plugin.json +14 -14
- package/LICENSE +21 -21
- package/README.de.md +359 -359
- package/README.es.md +359 -359
- package/README.fr.md +359 -359
- package/README.hi.md +359 -359
- package/README.id.md +359 -359
- package/README.ja.md +359 -359
- package/README.ko.md +359 -359
- package/README.md +126 -126
- package/README.pt.md +359 -359
- package/README.ru.md +359 -359
- package/README.th.md +359 -359
- package/README.tr.md +359 -359
- package/README.vi.md +359 -359
- package/README.zh-TW.md +359 -359
- package/README.zh.md +116 -116
- package/agents/ds-flash.md +24 -24
- package/agents/ds-pro.md +24 -24
- package/agents/ds-reviewer.md +23 -23
- package/agents/ds-worker.md +23 -23
- package/bin/dsh-crew.mjs +16 -16
- package/codex/agents/ds-flash.toml +34 -34
- package/codex/agents/ds-pro.toml +34 -34
- package/codex/agents/ds-reviewer.toml +32 -32
- package/codex/agents/ds-worker.toml +32 -32
- package/codex/prompts/dsh-config.md +19 -19
- package/codex/prompts/dsh-status.md +5 -5
- package/commands/config.md +23 -23
- package/commands/off.md +5 -5
- package/commands/on.md +5 -5
- package/commands/status.md +9 -9
- package/cordis.patch.yml +4 -4
- package/docs/gpt-relay-extension.md +103 -103
- package/docs/installation.md +138 -138
- package/docs/job-contracts.md +103 -103
- package/docs/readiness-matrix.md +85 -85
- package/docs/ui-surfaces.md +107 -107
- package/official-web-bridge/cordis.patch.yml +4 -4
- package/official-web-bridge/entry.mjs +1 -1
- package/official-web-bridge/overlay-entry.mjs +59 -59
- package/official-web-bridge/package.json +25 -25
- package/package.json +3 -2
- package/scripts/build-client.mjs +49 -49
- package/scripts/live-crew-smoke.mjs +39 -39
- package/scripts/live-policy-matrix.mjs +177 -177
- package/scripts/policy-probe.mjs +101 -101
- package/scripts/remove-legacy-official-bridge.ps1 +89 -89
- package/scripts/setup.mjs +393 -393
- package/scripts/smoke-real.mjs +110 -110
- package/scripts/smoke.mjs +78 -78
- package/scripts/verify-crew-ui-polish.mjs +145 -145
- package/scripts/verify-history-ui.mjs +97 -97
- package/scripts/verify-installer-fix.mjs +26 -26
- package/scripts/verify-npm-install.mjs +311 -311
- package/scripts/verify-official-bridge-e2e.mjs +192 -192
- package/src/adaptive-routing.mjs +260 -260
- package/src/client/activation-summary.tsx +64 -64
- package/src/client/collapsible-sections.mjs +55 -55
- package/src/client/history-panel.tsx +108 -108
- package/src/client/host-readiness.mjs +71 -71
- package/src/client/index.tsx +1711 -1711
- package/src/client/model-callability-view.mjs +17 -17
- package/src/client/panel-chrome.tsx +40 -40
- package/src/client/quick-entry.tsx +10 -10
- package/src/client/quick-panel.tsx +275 -275
- package/src/client/readiness-envelope.mjs +34 -34
- package/src/client/surface-detection.mjs +43 -43
- package/src/config-readiness.mjs +226 -226
- package/src/credential-reference.mjs +38 -38
- package/src/delivery.mjs +205 -205
- package/src/dsh-cli-runtime.mjs +1021 -1021
- package/src/dsh-cohort.mjs +20 -20
- package/src/extension-contract.mjs +104 -104
- package/src/failure-classification.mjs +201 -201
- package/src/history/admission-gate.mjs +67 -67
- package/src/history/archive-store.mjs +272 -272
- package/src/history/cleanup-plan.mjs +89 -89
- package/src/history/http.mjs +32 -32
- package/src/history/operation.mjs +86 -86
- package/src/history/runner-detach.mjs +34 -34
- package/src/history/runner.mjs +52 -52
- package/src/history/runtime.mjs +36 -36
- package/src/history/service.mjs +177 -177
- package/src/history/state.mjs +27 -27
- package/src/hub/entry.mjs +104 -104
- package/src/hub/index.mjs +2694 -2694
- package/src/hub-client.mjs +154 -154
- package/src/hub-compatibility.mjs +42 -42
- package/src/i18n.mjs +19 -19
- package/src/information-flow.mjs +67 -67
- package/src/install/cli.mjs +28 -28
- package/src/install/install-legacy.mjs +711 -711
- package/src/install/install.mjs +483 -483
- package/src/install/npx-lifecycle.mjs +3456 -3456
- package/src/install/official-frontend-assets.mjs +78 -78
- package/src/install/official-web.mjs +95 -95
- package/src/install/payload-content.mjs +88 -88
- package/src/install/windows-startup.mjs +236 -236
- package/src/install/windows-supervisor-adapter.mjs +443 -443
- package/src/install/windows-supervisor-lifecycle.mjs +782 -782
- package/src/install/zcode.mjs +397 -397
- package/src/job-contracts.mjs +255 -255
- package/src/local-request-guard.mjs +60 -60
- package/src/mcp-runtime.mjs +340 -340
- package/src/model-callability-contract.mjs +79 -79
- package/src/model-catalog.mjs +180 -180
- package/src/model-routing.mjs +586 -586
- package/src/model-schedule.mjs +207 -207
- package/src/official-web-bridge.mjs +447 -447
- package/src/policy.mjs +235 -235
- package/src/provider-delete-adapters.mjs +1934 -1934
- package/src/provider-health.mjs +130 -130
- package/src/provider-inventory.mjs +182 -182
- package/src/provider-layer-migration-adapters.mjs +759 -759
- package/src/provider-layer-migration.mjs +198 -198
- package/src/provider-lifecycle-state.mjs +103 -103
- package/src/provider-lifecycle.mjs +252 -252
- package/src/provider-profile-store.mjs +390 -390
- package/src/provider-settings-store.mjs +633 -633
- package/src/provider-store-lock.mjs +67 -67
- package/src/readiness-matrix.mjs +181 -181
- package/src/removable-waiter.mjs +29 -29
- package/src/role-profiles.mjs +107 -107
- package/src/runtime-controls.mjs +84 -84
- package/src/runtime-identity-contract.mjs +34 -34
- package/src/runtime-identity.mjs +235 -235
- package/src/runtime-readiness-snapshot.mjs +285 -285
- package/src/server.mjs +581 -581
- package/src/session-origins.mjs +60 -60
- package/src/standalone-sdk.mjs +23 -23
- package/src/status-shard.mjs +63 -63
- package/src/structured-error-code.mjs +38 -38
- package/src/supervisor/restart-request.mjs +256 -256
- package/src/workflow-runtime.mjs +739 -739
- package/src/workflow.mjs +155 -155
- package/src/workspace-audit.mjs +231 -231
- package/src/workspace-context.mjs +146 -146
- package/src/workspace-isolation.mjs +463 -455
- package/src/workspace-readiness.mjs +32 -32
- package/statusline/statusline.sh +14 -14
- package/statusline/worker-segment.sh +35 -35
- package/windows/start-dsh-crew.cmd +57 -57
- package/windows/start-dsh-crew.ps1 +1302 -1302
- package/windows/supervisor-control.ps1 +467 -467
- package/worker.cordis.yml +67 -67
- package/zcode/agents/ds-reviewer.md +31 -31
- package/zcode/agents/ds-worker.md +31 -31
- package/zcode/commands/dsh-config.md +17 -17
- 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
|
+
}
|