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