@ran-sh/dsh-crew 1.0.1 → 1.0.2

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.
@@ -1,171 +1,189 @@
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
-
9
- const PROVIDER_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
10
-
11
- function sha256(value) {
12
- return createHash('sha256').update(value, 'utf8').digest('hex');
13
- }
14
-
15
- function indentOf(line) {
16
- return (line.match(/^\s*/) ?? [''])[0].length;
17
- }
18
-
19
- function nonBlank(line) {
20
- return line.trim() !== '' && !line.trimStart().startsWith('#');
21
- }
22
-
23
- function parseProviderMap(source) {
24
- if (typeof source !== 'string') return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
25
- const lines = source.split(/\r?\n/);
26
- const llmStart = lines.findIndex((line) => /^-\s+id:\s*llm-pi-ai\s*$/.test(line));
27
- if (llmStart < 0) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
28
-
29
- let blockEnd = lines.length;
30
- for (let index = llmStart + 1; index < lines.length; index += 1) {
31
- // Only a true top-level sequence item can end the managed llm-pi-ai item;
32
- // nested list entries are part of a provider value and must be validated.
33
- if (indentOf(lines[index]) === 0 && /^-\s+/.test(lines[index])) {
34
- blockEnd = index;
35
- break;
36
- }
37
- }
38
-
39
- const providersLine = lines.findIndex((line, index) => index > llmStart && index < blockEnd && /^\s+providers:\s*$/.test(line));
40
- if (providersLine < 0) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
41
- const providersIndent = indentOf(lines[providersLine]);
42
- const providerIndent = providersIndent + 2;
43
- const entries = [];
44
-
45
- for (let index = providersLine + 1; index < blockEnd; index += 1) {
46
- const line = lines[index];
47
- if (!nonBlank(line)) continue;
48
- const indent = indentOf(line);
49
- if (indent <= providersIndent) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
50
- if (indent !== providerIndent) {
51
- if (/^\s*-\s+/.test(line)) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
52
- continue;
53
- }
54
- const match = line.match(/^\s*([A-Za-z0-9][A-Za-z0-9._-]*):\s*$/);
55
- if (!match) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
56
- const id = match[1];
57
- let end = index + 1;
58
- while (end < blockEnd) {
59
- const next = lines[end];
60
- if (nonBlank(next)) {
61
- const nextIndent = indentOf(next);
62
- if (nextIndent < providerIndent) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
63
- if (nextIndent === providerIndent) break;
64
- // Provider values may contain nested YAML sequences (for example the
65
- // `models:` list emitted by the live Harness profile). A sequence at
66
- // the same indentation as a provider field is malformed, however;
67
- // valid child items must be indented below that field. Keep the
68
- // parser structure-aware without attempting to deserialize values.
69
- if (/^\s*-\s+/.test(next) && nextIndent < providerIndent + 4) {
70
- return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
71
- }
72
- }
73
- end += 1;
74
- }
75
- entries.push({ id, start: index, end });
76
- index = end - 1;
77
- }
78
- if (entries.length === 0) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
79
- return { ok: true, lines, llmStart, blockEnd, providersLine, providerIndent, entries };
80
- }
81
-
82
- /** Return only bounded provider ids and a content revision; never values. */
83
- export function inspectProviderProfile(source) {
84
- const revision = typeof source === 'string' ? sha256(source) : null;
85
- const parsed = parseProviderMap(source);
86
- if (!parsed.ok) return { ok: false, code: parsed.code, revision };
87
- return {
88
- ok: true,
89
- schema: 'cordis-patch-llm-pi-ai-v1',
90
- providerIds: parsed.entries.map((entry) => entry.id),
91
- revision,
92
- };
93
- }
94
-
95
- function scalarField(lines, entry, field) {
96
- const pattern = new RegExp(`^\\s+${field}:\\s*(.*?)\\s*$`);
97
- for (const line of lines.slice(entry.start, entry.end)) {
98
- const match = line.match(pattern);
99
- if (!match) continue;
100
- const value = match[1].trim();
101
- if (!value) return null;
102
- return value.replace(/^(?:"([\\s\\S]*)"|'([\\s\\S]*)')$/, '$1$2');
103
- }
104
- return null;
105
- }
106
-
107
- /** Return profile provider provenance and credential reference names only. */
108
- export function readProviderDeclarations(source, { file = 'profile.yml' } = {}) {
109
- const parsed = parseProviderMap(source);
110
- if (!parsed.ok) return { ok: false, code: parsed.code };
111
- const declarations = parsed.entries.map((entry) => {
112
- const displayName = scalarField(parsed.lines, entry, 'displayName');
113
- const credentialRef = scalarField(parsed.lines, entry, 'apiKeyEnv');
114
- return {
115
- id: entry.id,
116
- display_name: displayName ?? entry.id,
117
- origin: 'profile-managed',
118
- ownership: 'crew-managed-profile',
119
- file,
120
- ...(credentialRef ? { credential_ref: credentialRef } : {}),
121
- };
122
- });
123
- return { ok: true, declarations };
124
- }
125
-
126
- /**
127
- * Remove provider declarations from a known profile patch after an optional
128
- * content-revision check. The returned text is safe to write atomically by the
129
- * lifecycle layer; this pure helper never touches the filesystem.
130
- */
131
- export function removeProviderDeclarations(source, { providerIds = [], expectedRevision } = {}) {
132
- const currentRevision = typeof source === 'string' ? sha256(source) : null;
133
- if (expectedRevision !== undefined && expectedRevision !== currentRevision) {
134
- return { ok: false, code: 'PROVIDER_PROFILE_CHANGED', revision: currentRevision };
135
- }
136
- const requested = [...new Set(providerIds.filter((id) => typeof id === 'string' && PROVIDER_ID.test(id)))];
137
- if (requested.length === 0 || requested.length !== providerIds.length) {
138
- return { ok: false, code: 'PROVIDER_NOT_FOUND', revision: currentRevision };
139
- }
140
- const parsed = parseProviderMap(source);
141
- if (!parsed.ok) return { ok: false, code: parsed.code, revision: currentRevision };
142
- const byId = new Map(parsed.entries.map((entry) => [entry.id, entry]));
143
- if (requested.some((id) => !byId.has(id))) {
144
- return { ok: false, code: 'PROVIDER_NOT_FOUND', revision: currentRevision };
145
- }
146
-
147
- const removedSet = new Set(requested);
148
- const remaining = parsed.entries.filter((entry) => !removedSet.has(entry.id)).map((entry) => entry.id);
149
- const removeRanges = [];
150
- if (remaining.length === 0) {
151
- // Avoid leaving an empty `llm-pi-ai.config.providers` mapping that could
152
- // fail the next profile parse. Remove the complete managed sequence item.
153
- removeRanges.push([parsed.llmStart, parsed.blockEnd]);
154
- } else {
155
- for (const id of requested) {
156
- const entry = byId.get(id);
157
- removeRanges.push([entry.start, entry.end]);
158
- }
159
- }
160
- const shouldRemove = (index) => removeRanges.some(([start, end]) => index >= start && index < end);
161
- const lines = parsed.lines.filter((_line, index) => !shouldRemove(index));
162
- const newline = source.includes('\r\n') ? '\r\n' : '\n';
163
- const text = lines.join(newline);
164
- return {
165
- ok: true,
166
- text,
167
- removed: requested,
168
- remaining,
169
- revision: sha256(text),
170
- };
171
- }
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
+
9
+ const PROVIDER_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
10
+
11
+ function sha256(value) {
12
+ return createHash('sha256').update(value, 'utf8').digest('hex');
13
+ }
14
+
15
+ function indentOf(line) {
16
+ return (line.match(/^\s*/) ?? [''])[0].length;
17
+ }
18
+
19
+ function nonBlank(line) {
20
+ return line.trim() !== '' && !line.trimStart().startsWith('#');
21
+ }
22
+
23
+ function parseProviderMap(source) {
24
+ if (typeof source !== 'string') return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
25
+ const lines = source.split(/\r?\n/);
26
+ const llmStart = lines.findIndex((line) => /^-\s+id:\s*llm-pi-ai\s*$/.test(line));
27
+ if (llmStart < 0) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
28
+
29
+ let blockEnd = lines.length;
30
+ for (let index = llmStart + 1; index < lines.length; index += 1) {
31
+ // Only a true top-level sequence item can end the managed llm-pi-ai item;
32
+ // nested list entries are part of a provider value and must be validated.
33
+ if (indentOf(lines[index]) === 0 && /^-\s+/.test(lines[index])) {
34
+ blockEnd = index;
35
+ break;
36
+ }
37
+ }
38
+
39
+ const providersLine = lines.findIndex((line, index) => index > llmStart && index < blockEnd && /^\s+providers:\s*(?:\{\s*\})?\s*$/.test(line));
40
+ if (providersLine < 0) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
41
+ const providersIndent = indentOf(lines[providersLine]);
42
+ const providerIndent = providersIndent + 2;
43
+ const entries = [];
44
+
45
+ for (let index = providersLine + 1; index < blockEnd; index += 1) {
46
+ const line = lines[index];
47
+ if (!nonBlank(line)) continue;
48
+ const indent = indentOf(line);
49
+ if (indent <= providersIndent) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
50
+ if (indent !== providerIndent) {
51
+ if (/^\s*-\s+/.test(line)) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
52
+ continue;
53
+ }
54
+ const match = line.match(/^\s*([A-Za-z0-9][A-Za-z0-9._-]*):\s*$/);
55
+ if (!match) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
56
+ const id = match[1];
57
+ let end = index + 1;
58
+ while (end < blockEnd) {
59
+ const next = lines[end];
60
+ if (nonBlank(next)) {
61
+ const nextIndent = indentOf(next);
62
+ if (nextIndent < providerIndent) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
63
+ if (nextIndent === providerIndent) break;
64
+ // Provider values may contain nested YAML sequences (for example the
65
+ // `models:` list emitted by the live Harness profile). A sequence at
66
+ // the same indentation as a provider field is malformed, however;
67
+ // valid child items must be indented below that field. Keep the
68
+ // parser structure-aware without attempting to deserialize values.
69
+ if (/^\s*-\s+/.test(next) && nextIndent < providerIndent + 4) {
70
+ return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
71
+ }
72
+ }
73
+ end += 1;
74
+ }
75
+ entries.push({ id, start: index, end });
76
+ index = end - 1;
77
+ }
78
+ return { ok: true, lines, llmStart, blockEnd, providersLine, providerIndent, entries };
79
+ }
80
+
81
+ /** Return only bounded provider ids and a content revision; never values. */
82
+ export function inspectProviderProfile(source) {
83
+ const revision = typeof source === 'string' ? sha256(source) : null;
84
+ const parsed = parseProviderMap(source);
85
+ if (!parsed.ok) return { ok: false, code: parsed.code, revision };
86
+ return {
87
+ ok: true,
88
+ schema: 'cordis-patch-llm-pi-ai-v1',
89
+ providerIds: parsed.entries.map((entry) => entry.id),
90
+ revision,
91
+ };
92
+ }
93
+
94
+ function scalarField(lines, entry, field) {
95
+ const pattern = new RegExp(`^\\s+${field}:\\s*(.*?)\\s*$`);
96
+ for (const line of lines.slice(entry.start, entry.end)) {
97
+ const match = line.match(pattern);
98
+ if (!match) continue;
99
+ const value = match[1].trim();
100
+ if (!value) return null;
101
+ return value.replace(/^(?:"([\\s\\S]*)"|'([\\s\\S]*)')$/, '$1$2');
102
+ }
103
+ return null;
104
+ }
105
+
106
+ const SENSITIVE_CREDENTIAL_FIELD = /(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password|authorization|credential|private[_-]?key|client[_-]?secret|cookie|bearer|webhook)/iu;
107
+ const REFERENCE_FIELD_SUFFIX = /(?:env|ref|name|handle|id|file|path)$/iu;
108
+
109
+ function isInlineCredentialLine(line) {
110
+ const inlineKeys = [...line.matchAll(/["']?([A-Za-z][A-Za-z0-9_.-]*)["']?\s*:/gu)].map((match) => match[1]);
111
+ if (inlineKeys.some((field) => SENSITIVE_CREDENTIAL_FIELD.test(field) && !REFERENCE_FIELD_SUFFIX.test(field.replace(/[_.-]/gu, '')))) return true;
112
+ const match = line.match(/^\s*["']?([A-Za-z][A-Za-z0-9_.-]*)["']?\s*:\s*(.*?)\s*$/u);
113
+ if (!match) return false;
114
+ const [, field, value] = match;
115
+ if (!value || /^(?:null|~|\{\}|\[\])$/u.test(value)) return false;
116
+ const normalized = field.replace(/[_.-]/gu, '');
117
+ if (SENSITIVE_CREDENTIAL_FIELD.test(field) && !REFERENCE_FIELD_SUFFIX.test(normalized)) return true;
118
+ return /(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password|authorization|credential|private[_-]?key|client[_-]?secret|cookie|bearer|webhook)\s*["']?\s*:/iu.test(value);
119
+ }
120
+
121
+ export function hasInlineProviderCredentials(source, { providerIds = [] } = {}) {
122
+ const parsed = parseProviderMap(source);
123
+ if (!parsed.ok) return { ok: false, code: parsed.code };
124
+ const requested = providerIds.length > 0 ? new Set(providerIds) : null;
125
+ const providers = parsed.entries.filter((entry) => !requested || requested.has(entry.id));
126
+ return { ok: true, inline: providers.some((entry) => parsed.lines.slice(entry.start, entry.end).some(isInlineCredentialLine)) };
127
+ }
128
+
129
+ /** Return profile provider provenance and credential reference names only. */
130
+ export function readProviderDeclarations(source, { file = 'profile.yml' } = {}) {
131
+ const parsed = parseProviderMap(source);
132
+ if (!parsed.ok) return { ok: false, code: parsed.code };
133
+ const declarations = parsed.entries.map((entry) => {
134
+ const displayName = scalarField(parsed.lines, entry, 'displayName');
135
+ const credentialRef = scalarField(parsed.lines, entry, 'apiKeyEnv');
136
+ return {
137
+ id: entry.id,
138
+ display_name: displayName ?? entry.id,
139
+ origin: 'profile-managed',
140
+ ownership: 'crew-managed-profile',
141
+ file,
142
+ declaration_authority: { kind: 'crew-profile', locator: `llm-pi-ai.config.providers.${entry.id}` },
143
+ ...(credentialRef ? { credential_ref: credentialRef } : {}),
144
+ };
145
+ });
146
+ return { ok: true, declarations };
147
+ }
148
+
149
+ /**
150
+ * Remove provider declarations from a known profile patch after an optional
151
+ * content-revision check. The returned text is safe to write atomically by the
152
+ * lifecycle layer; this pure helper never touches the filesystem.
153
+ */
154
+ export function removeProviderDeclarations(source, { providerIds = [], expectedRevision } = {}) {
155
+ const currentRevision = typeof source === 'string' ? sha256(source) : null;
156
+ if (expectedRevision !== undefined && expectedRevision !== currentRevision) {
157
+ return { ok: false, code: 'PROVIDER_PROFILE_CHANGED', revision: currentRevision };
158
+ }
159
+ const requested = [...new Set(providerIds.filter((id) => typeof id === 'string' && PROVIDER_ID.test(id)))];
160
+ if (requested.length === 0 || requested.length !== providerIds.length) {
161
+ return { ok: false, code: 'PROVIDER_NOT_FOUND', revision: currentRevision };
162
+ }
163
+ const parsed = parseProviderMap(source);
164
+ if (!parsed.ok) return { ok: false, code: parsed.code, revision: currentRevision };
165
+ const byId = new Map(parsed.entries.map((entry) => [entry.id, entry]));
166
+ if (requested.some((id) => !byId.has(id))) {
167
+ return { ok: false, code: 'PROVIDER_NOT_FOUND', revision: currentRevision };
168
+ }
169
+
170
+ const removedSet = new Set(requested);
171
+ const remaining = parsed.entries.filter((entry) => !removedSet.has(entry.id)).map((entry) => entry.id);
172
+ const removeRanges = requested.map((id) => { const entry = byId.get(id); return [entry.start, entry.end]; });
173
+ const shouldRemove = (index) => removeRanges.some(([start, end]) => index >= start && index < end);
174
+ const lines = parsed.lines.filter((_line, index) => !shouldRemove(index));
175
+ if (remaining.length === 0) {
176
+ const providersIndex = parsed.providersLine - removeRanges.filter(([start]) => start < parsed.providersLine).length;
177
+ const providersIndent = indentOf(parsed.lines[parsed.providersLine]);
178
+ lines[providersIndex] = `${' '.repeat(providersIndent)}providers: {}`;
179
+ }
180
+ const newline = source.includes('\r\n') ? '\r\n' : '\n';
181
+ const text = lines.join(newline);
182
+ return {
183
+ ok: true,
184
+ text,
185
+ removed: requested,
186
+ remaining,
187
+ revision: sha256(text),
188
+ };
189
+ }
@@ -0,0 +1,215 @@
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
+
7
+ const PROVIDER_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
8
+
9
+ function sha256(value) { return createHash('sha256').update(value, 'utf8').digest('hex'); }
10
+ function indentOf(line) { return (line.match(/^\s*/u) ?? [''])[0].length; }
11
+ function nonBlank(line) { return line.trim() !== '' && !line.trimStart().startsWith('#'); }
12
+ function scalarField(lines, entry, field) {
13
+ const pattern = new RegExp(`^\\s+${field}:\\s*(.*?)\\s*$`);
14
+ for (const line of lines.slice(entry.start, entry.end)) {
15
+ const match = line.match(pattern);
16
+ if (!match) continue;
17
+ const value = match[1].trim();
18
+ if (!value) return null;
19
+ return value.replace(/^(?:"([\\s\\S]*)"|'([\\s\\S]*)')$/u, '$1$2');
20
+ }
21
+ return null;
22
+ }
23
+
24
+ function parseDefaultModel(source) {
25
+ if (typeof source !== 'string') return { ok: false, code: 'PROVIDER_SETTINGS_DEFAULT_UNSUPPORTED' };
26
+ const lines = source.split(/\r?\n/u);
27
+ const start = lines.findIndex((line) => /^agent-default-model:\s*$/u.test(line));
28
+ if (start < 0) return { ok: false, code: 'PROVIDER_SETTINGS_DEFAULT_UNSUPPORTED' };
29
+ let end = lines.length;
30
+ for (let index = start + 1; index < lines.length; index += 1) {
31
+ if (indentOf(lines[index]) === 0 && nonBlank(lines[index])) { end = index; break; }
32
+ }
33
+ const providerLine = lines.findIndex((line, index) => index > start && index < end && /^ {2}provider:\s*\S.*$/u.test(line));
34
+ const modelLine = lines.findIndex((line, index) => index > start && index < end && /^ {2}model:\s*\S.*$/u.test(line));
35
+ const provider = providerLine >= 0 ? lines[providerLine].replace(/^ {2}provider:\s*/u, '').trim() : null;
36
+ const model = modelLine >= 0 ? lines[modelLine].replace(/^ {2}model:\s*/u, '').trim() : null;
37
+ if (!provider || !model) return { ok: false, code: 'PROVIDER_SETTINGS_DEFAULT_UNSUPPORTED' };
38
+ return { ok: true, lines, start, end, providerLine, modelLine, provider, model };
39
+ }
40
+
41
+ function parseProviderMap(source) {
42
+ if (typeof source !== 'string') return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
43
+ const lines = source.split(/\r?\n/u);
44
+ const llmStart = lines.findIndex((line) => /^llm-pi-ai:\s*$/u.test(line));
45
+ if (llmStart < 0) return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
46
+ let blockEnd = lines.length;
47
+ for (let index = llmStart + 1; index < lines.length; index += 1) {
48
+ if (indentOf(lines[index]) === 0 && nonBlank(lines[index])) { blockEnd = index; break; }
49
+ }
50
+ const providersLine = lines.findIndex((line, index) => index > llmStart && index < blockEnd && /^ {2}providers:\s*(?:\{\s*\})?\s*$/u.test(line));
51
+ if (providersLine < 0) return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
52
+ let providersBlockEnd = blockEnd;
53
+ for (let index = providersLine + 1; index < blockEnd; index += 1) {
54
+ if (nonBlank(lines[index]) && indentOf(lines[index]) <= 2) { providersBlockEnd = index; break; }
55
+ }
56
+ const providerIndent = 4;
57
+ const entries = [];
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 < providerIndent) {
63
+ if (indent > 2) return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
64
+ break;
65
+ }
66
+ if (indent !== providerIndent) continue;
67
+ const match = line.match(/^ {4}([A-Za-z0-9][A-Za-z0-9._-]*):\s*$/u);
68
+ if (!match) return { ok: false, code: 'PROVIDER_SETTINGS_SCHEMA_UNSUPPORTED' };
69
+ let end = index + 1;
70
+ while (end < blockEnd) {
71
+ const next = lines[end];
72
+ if (nonBlank(next) && indentOf(next) <= providerIndent) break;
73
+ end += 1;
74
+ }
75
+ entries.push({ id: match[1], start: index, end });
76
+ index = end - 1;
77
+ }
78
+ return { ok: true, lines, llmStart, blockEnd, providersLine, providersBlockEnd, entries };
79
+ }
80
+
81
+ const SENSITIVE_CREDENTIAL_FIELD = /(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password|authorization|credential|private[_-]?key|client[_-]?secret|cookie|bearer|webhook)/iu;
82
+ const REFERENCE_FIELD_SUFFIX = /(?:env|ref|name|handle|id|file|path)$/iu;
83
+
84
+ function isInlineCredentialLine(line) {
85
+ const inlineKeys = [...line.matchAll(/["']?([A-Za-z][A-Za-z0-9_.-]*)["']?\s*:/gu)].map((match) => match[1]);
86
+ if (inlineKeys.some((field) => SENSITIVE_CREDENTIAL_FIELD.test(field) && !REFERENCE_FIELD_SUFFIX.test(field.replace(/[_.-]/gu, '')))) return true;
87
+ const match = line.match(/^\s*["']?([A-Za-z][A-Za-z0-9_.-]*)["']?\s*:\s*(.*?)\s*$/u);
88
+ if (!match) return false;
89
+ const [, field, value] = match;
90
+ if (!value || /^(?:null|~|\{\}|\[\])$/u.test(value)) return false;
91
+ const normalized = field.replace(/[_.-]/gu, '');
92
+ if (SENSITIVE_CREDENTIAL_FIELD.test(field) && !REFERENCE_FIELD_SUFFIX.test(normalized)) return true;
93
+ return /(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password|authorization|credential|private[_-]?key|client[_-]?secret|cookie|bearer|webhook)\s*["']?\s*:/iu.test(value);
94
+ }
95
+
96
+ export function hasInlineProviderCredentials(source, { providerIds = [] } = {}) {
97
+ const parsed = parseProviderMap(source);
98
+ if (!parsed.ok) return { ok: false, code: parsed.code };
99
+ const requested = providerIds.length > 0 ? new Set(providerIds) : null;
100
+ const providers = parsed.entries.filter((entry) => !requested || requested.has(entry.id));
101
+ return { ok: true, inline: providers.some((entry) => parsed.lines.slice(entry.start, entry.end).some(isInlineCredentialLine)) };
102
+ }
103
+
104
+ export function inspectProviderSettings(source) {
105
+ const revision = typeof source === 'string' ? sha256(source) : null;
106
+ const parsed = parseProviderMap(source);
107
+ if (!parsed.ok) return { ok: false, code: parsed.code, revision };
108
+ return { ok: true, schema: 'harness-settings-llm-pi-ai-v1', providerIds: parsed.entries.map((entry) => entry.id), revision };
109
+ }
110
+
111
+ export function readProviderSettingsDeclarations(source, { file = 'harness/settings.yaml' } = {}) {
112
+ const parsed = parseProviderMap(source);
113
+ if (!parsed.ok) return { ok: false, code: parsed.code };
114
+ return {
115
+ ok: true,
116
+ declarations: parsed.entries.map((entry) => {
117
+ const credentialRef = scalarField(parsed.lines, entry, 'apiKeyEnv');
118
+ return {
119
+ id: entry.id,
120
+ display_name: scalarField(parsed.lines, entry, 'displayName') ?? entry.id,
121
+ origin: 'profile-managed',
122
+ ownership: 'crew-managed-profile',
123
+ file,
124
+ declaration_authority: { kind: 'harness-settings', locator: `llm-pi-ai.providers.${entry.id}` },
125
+ ...(credentialRef ? { credential_ref: { kind: 'env', name_or_handle: credentialRef, ownership: 'unknown' } } : {}),
126
+ };
127
+ }),
128
+ };
129
+ }
130
+
131
+ export function readHarnessDefault(source) {
132
+ const parsed = parseDefaultModel(source);
133
+ if (!parsed.ok) return { ok: false, code: parsed.code };
134
+ return { ok: true, provider: parsed.provider, model: parsed.model, locator: 'agent-default-model' };
135
+ }
136
+
137
+ export function replaceHarnessDefault(source, { provider, model, expectedRevision } = {}) {
138
+ const currentRevision = typeof source === 'string' ? sha256(source) : null;
139
+ if (expectedRevision !== undefined && expectedRevision !== currentRevision) return { ok: false, code: 'PROVIDER_SETTINGS_CHANGED', revision: currentRevision };
140
+ if (typeof provider !== 'string' || !PROVIDER_ID.test(provider) || typeof model !== 'string' || !model.trim()) return { ok: false, code: 'PROVIDER_DEFAULT_REPLACEMENT_REQUIRED', revision: currentRevision };
141
+ const parsed = parseDefaultModel(source);
142
+ if (!parsed.ok) return { ok: false, code: parsed.code, revision: currentRevision };
143
+ const lines = [...parsed.lines];
144
+ lines[parsed.providerLine] = ` provider: ${provider.trim()}`;
145
+ lines[parsed.modelLine] = ` model: ${model.trim()}`;
146
+ const newline = source.includes('\r\n') ? '\r\n' : '\n';
147
+ const text = lines.join(newline);
148
+ return { ok: true, text, previous: { provider: parsed.provider, model: parsed.model }, revision: sha256(text) };
149
+ }
150
+
151
+ /**
152
+ * Apply all Harness settings mutations needed by one provider-delete plan in
153
+ * memory, then return one revision-bound text result for a single atomic write.
154
+ * This prevents replacing agent-default-model and removing its provider from
155
+ * racing each other on the same settings revision.
156
+ */
157
+ export function mutateProviderSettings(source, {
158
+ providerId = null,
159
+ removeProvider = false,
160
+ replacementDefault = null,
161
+ replacementModel = null,
162
+ expectedRevision,
163
+ } = {}) {
164
+ const currentRevision = typeof source === 'string' ? sha256(source) : null;
165
+ if (expectedRevision !== undefined && expectedRevision !== currentRevision) return { ok: false, code: 'PROVIDER_SETTINGS_CHANGED', revision: currentRevision };
166
+ const parsed = removeProvider ? parseProviderMap(source) : { ok: true, lines: typeof source === 'string' ? source.split(/\r?\n/u) : [] };
167
+ if (!parsed.ok) return { ok: false, code: parsed.code, revision: currentRevision };
168
+ let providerEntry = null;
169
+ if (removeProvider) {
170
+ if (typeof providerId !== 'string' || !PROVIDER_ID.test(providerId)) return { ok: false, code: 'PROVIDER_NOT_FOUND', revision: currentRevision };
171
+ providerEntry = parsed.entries.find((entry) => entry.id === providerId) ?? null;
172
+ if (!providerEntry) return { ok: false, code: 'PROVIDER_NOT_FOUND', revision: currentRevision };
173
+ }
174
+ let defaultParsed = null;
175
+ if (replacementDefault !== null || replacementModel !== null) {
176
+ if (typeof replacementDefault !== 'string' || !PROVIDER_ID.test(replacementDefault) || typeof replacementModel !== 'string' || !replacementModel.trim()) return { ok: false, code: 'PROVIDER_DEFAULT_REPLACEMENT_REQUIRED', revision: currentRevision };
177
+ defaultParsed = parseDefaultModel(source);
178
+ if (!defaultParsed.ok) return { ok: false, code: defaultParsed.code, revision: currentRevision };
179
+ if (providerId && defaultParsed.provider !== providerId) return { ok: false, code: 'PROVIDER_DEFAULT_AUTHORITY_CHANGED', revision: currentRevision };
180
+ }
181
+ const ranges = providerEntry ? [[providerEntry.start, providerEntry.end]] : [];
182
+ const finalProvider = providerEntry && parsed.entries.length === 1;
183
+ const nextLines = parsed.lines.map((line, index) => {
184
+ if (ranges.some(([start, end]) => index >= start && index < end)) return null;
185
+ if (finalProvider && index === parsed.providersLine) return ' providers: {}';
186
+ if (defaultParsed?.providerLine === index) return ` provider: ${replacementDefault.trim()}`;
187
+ if (defaultParsed?.modelLine === index) return ` model: ${replacementModel.trim()}`;
188
+ return line;
189
+ }).filter((line) => line !== null);
190
+ const newline = source.includes('\r\n') ? '\r\n' : '\n';
191
+ const text = nextLines.join(newline);
192
+ return {
193
+ ok: true,
194
+ text,
195
+ ...(providerEntry ? { removed: [providerId], remaining: parsed.entries.filter((entry) => entry.id !== providerId).map((entry) => entry.id) } : { removed: [], remaining: parsed.entries?.map((entry) => entry.id) ?? [] }),
196
+ ...(defaultParsed ? { previous_default: { provider: defaultParsed.provider, model: defaultParsed.model } } : {}),
197
+ revision: sha256(text),
198
+ };
199
+ }
200
+
201
+ export function removeProviderSettings(source, { providerIds = [], expectedRevision } = {}) {
202
+ const currentRevision = typeof source === 'string' ? sha256(source) : null;
203
+ if (expectedRevision !== undefined && expectedRevision !== currentRevision) return { ok: false, code: 'PROVIDER_SETTINGS_CHANGED', revision: currentRevision };
204
+ const requested = [...new Set(providerIds.filter((id) => typeof id === 'string' && PROVIDER_ID.test(id)))];
205
+ if (requested.length === 0 || requested.length !== providerIds.length) return { ok: false, code: 'PROVIDER_NOT_FOUND', revision: currentRevision };
206
+ let next = source;
207
+ const results = [];
208
+ for (const id of requested) {
209
+ const result = mutateProviderSettings(next, { providerId: id, removeProvider: true });
210
+ if (!result.ok) return { ...result, revision: currentRevision };
211
+ next = result.text;
212
+ results.push(result);
213
+ }
214
+ return { ok: true, text: next, removed: requested, remaining: results.at(-1)?.remaining ?? [], revision: sha256(next) };
215
+ }