@ran-sh/dsh-crew 1.0.2 → 1.1.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/README.md +4 -0
- package/README.zh.md +4 -0
- package/codex/AGENTS.md +101 -92
- package/docs/ui-surfaces.md +48 -21
- package/lib/client.js +235 -34
- package/official-web-bridge/lib/client.js +368 -4532
- package/official-web-bridge/package.json +1 -2
- package/package.json +130 -67
- package/scripts/build-client.mjs +30 -15
- package/scripts/remove-legacy-official-bridge.ps1 +89 -0
- package/scripts/setup.mjs +152 -140
- package/scripts/verify-npm-install.mjs +311 -310
- package/scripts/verify-official-bridge-e2e.mjs +12 -1
- package/src/client/host-readiness.mjs +62 -59
- package/src/client/index.tsx +117 -24
- package/src/client/quick-entry.tsx +10 -0
- package/src/client/quick-panel.tsx +295 -0
- package/src/client/surface-detection.mjs +43 -30
- package/src/credential-reference.mjs +38 -0
- package/src/dsh-cli-runtime.mjs +500 -40
- package/src/dsh-cohort.mjs +20 -0
- package/src/hub/index.mjs +1602 -1016
- package/src/install/npx-lifecycle.mjs +1983 -468
- package/src/install/official-web.mjs +36 -73
- package/src/jobs.mjs +20 -24
- package/src/model-catalog.mjs +8 -1
- package/src/official-web-bridge.mjs +329 -249
- package/src/provider-delete-adapters.mjs +165 -17
- package/src/provider-inventory.mjs +17 -3
- package/src/provider-layer-migration-adapters.mjs +759 -0
- package/src/provider-layer-migration.mjs +198 -0
- package/src/provider-lifecycle-state.mjs +4 -1
- package/src/provider-profile-store.mjs +193 -4
- package/src/provider-settings-store.mjs +82 -6
- package/src/provider-store-lock.mjs +67 -0
- package/src/runtime-identity.mjs +24 -1
- package/src/supervisor/restart-request.mjs +202 -0
- package/windows/start-dsh-crew.ps1 +783 -370
- package/worker.cordis.yml +32 -46
- package/zcode/AGENTS.md +35 -26
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Pure planning for promoting legacy Crew profile providers into the Harness
|
|
2
|
+
// user settings layer. Planning never reads credentials or performs writes.
|
|
3
|
+
|
|
4
|
+
import { classifyCredentialReference } from './credential-reference.mjs';
|
|
5
|
+
|
|
6
|
+
const BUILTIN_PROVIDER_IDS = new Set(['deepseek-official']);
|
|
7
|
+
|
|
8
|
+
function text(value) {
|
|
9
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function authorityKind(declaration) {
|
|
13
|
+
return text(declaration?.declaration_authority?.kind);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function providerId(declaration) {
|
|
17
|
+
return text(declaration?.id);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function safeCredentialRef(declaration) {
|
|
21
|
+
const ref = declaration?.credential_ref;
|
|
22
|
+
const kind = ref && typeof ref === 'object' && typeof ref.kind === 'string' ? ref.kind : 'env';
|
|
23
|
+
return classifyCredentialReference(ref, { kind }).value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function safeProjection(declaration) {
|
|
27
|
+
return {
|
|
28
|
+
id: providerId(declaration),
|
|
29
|
+
display_name: text(declaration?.display_name) ?? providerId(declaration),
|
|
30
|
+
credential_ref: safeCredentialRef(declaration),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build a bounded, secret-free migration plan from composition/base provider
|
|
36
|
+
* declarations to Harness user settings. The caller supplies declarations
|
|
37
|
+
* already obtained from the two authoritative stores.
|
|
38
|
+
*/
|
|
39
|
+
export function buildProviderLayerMigrationPlan({ declarations = [], catalogProviders = [], harnessDefault = null, routingReferences = [], tombstones = {}, recoveryTransactions = [], catalogEvidence = { ok: true, partial: false }, declarationEvidence = { ok: true }, lifecycleEvidence = { ok: true }, defaultEvidence = { ok: true } } = {}) {
|
|
40
|
+
const entries = Array.isArray(declarations) ? declarations : [];
|
|
41
|
+
const base = entries.filter((entry) => authorityKind(entry) === 'crew-profile' && providerId(entry));
|
|
42
|
+
const user = entries.filter((entry) => authorityKind(entry) === 'harness-settings' && providerId(entry));
|
|
43
|
+
const byBase = new Map(base.map((entry) => [providerId(entry), entry]));
|
|
44
|
+
const byUser = new Map(user.map((entry) => [providerId(entry), entry]));
|
|
45
|
+
const catalog = new Map((Array.isArray(catalogProviders) ? catalogProviders : [])
|
|
46
|
+
.map((entry) => [text(entry?.id), entry]).filter(([id]) => id));
|
|
47
|
+
const safeTombstones = tombstones && typeof tombstones === 'object' && !Array.isArray(tombstones) ? tombstones : {};
|
|
48
|
+
const safeRecoveryTransactions = Array.isArray(recoveryTransactions) ? recoveryTransactions : [];
|
|
49
|
+
const unresolvedRecovery = safeRecoveryTransactions.some((entry) => entry?.unresolved === true || !text(entry?.provider_id));
|
|
50
|
+
const pendingRecovery = safeRecoveryTransactions.length > 0;
|
|
51
|
+
const catalogUnavailable = catalogEvidence?.ok !== true || catalogEvidence?.partial === true;
|
|
52
|
+
const declarationUnavailable = declarationEvidence?.ok !== true;
|
|
53
|
+
const lifecycleUnavailable = lifecycleEvidence?.ok !== true;
|
|
54
|
+
const defaultUnavailable = defaultEvidence?.ok !== true;
|
|
55
|
+
const providers = [...new Set([...byBase.keys(), ...byUser.keys()])]
|
|
56
|
+
.filter((id) => !BUILTIN_PROVIDER_IDS.has(id))
|
|
57
|
+
.map((id) => {
|
|
58
|
+
const baseEntry = byBase.get(id) ?? null;
|
|
59
|
+
const userEntry = byUser.get(id) ?? null;
|
|
60
|
+
const catalogEntry = catalog.get(id) ?? null;
|
|
61
|
+
// Adapter ownership is a catalog fact, not a provider-name heuristic.
|
|
62
|
+
// An explicit true blocks the transition; missing metadata (including a
|
|
63
|
+
// missing catalog entry) also blocks a base-layer transition because
|
|
64
|
+
// reusing an unknown adapter id could hide the native Delete action.
|
|
65
|
+
const collision = catalogEntry?.adapter_owned === true;
|
|
66
|
+
const ownershipUnknown = baseEntry !== null && (catalogEntry === null || typeof catalogEntry.adapter_owned !== 'boolean');
|
|
67
|
+
const tombstoned = safeTombstones[id] === 'absent';
|
|
68
|
+
const recovery = safeRecoveryTransactions.find((entry) => text(entry?.provider_id) === id);
|
|
69
|
+
const blockedCode = lifecycleUnavailable
|
|
70
|
+
? 'PROVIDER_LIFECYCLE_UNAVAILABLE'
|
|
71
|
+
: catalogUnavailable
|
|
72
|
+
? 'MODEL_CATALOG_UNAVAILABLE'
|
|
73
|
+
: declarationUnavailable
|
|
74
|
+
? 'PROVIDER_SOURCE_UNRESOLVED'
|
|
75
|
+
: defaultUnavailable
|
|
76
|
+
? 'PROVIDER_DEFAULT_AUTHORITY_UNAVAILABLE'
|
|
77
|
+
: unresolvedRecovery
|
|
78
|
+
? 'PROVIDER_DELETE_RECOVERY_UNRESOLVED'
|
|
79
|
+
: pendingRecovery
|
|
80
|
+
? 'PROVIDER_DELETE_RECOVERY_PENDING'
|
|
81
|
+
: tombstoned
|
|
82
|
+
? 'PROVIDER_ALREADY_ABSENT'
|
|
83
|
+
: null;
|
|
84
|
+
const ownershipCode = collision
|
|
85
|
+
? 'HARNESS_PROVIDER_ID_COLLISION'
|
|
86
|
+
: ownershipUnknown ? 'HARNESS_PROVIDER_OWNERSHIP_UNAVAILABLE' : null;
|
|
87
|
+
const action = blockedCode ? 'blocked' : ownershipCode ? 'collision-review' : baseEntry && userEntry ? 'promote-existing-user' : baseEntry ? 'materialize-user' : 'none';
|
|
88
|
+
const currentNativeRemovable = userEntry !== null && baseEntry === null;
|
|
89
|
+
const migrationAction = action === 'promote-existing-user' || action === 'materialize-user';
|
|
90
|
+
return {
|
|
91
|
+
provider_id: id,
|
|
92
|
+
action,
|
|
93
|
+
current_native_removable: currentNativeRemovable,
|
|
94
|
+
target_native_removable: migrationAction ? true : currentNativeRemovable,
|
|
95
|
+
native_removable_after: migrationAction ? 'pending-verification' : ownershipCode || blockedCode ? 'unknown' : currentNativeRemovable,
|
|
96
|
+
requires_base_removal: baseEntry !== null,
|
|
97
|
+
target_user_layer: migrationAction || currentNativeRemovable,
|
|
98
|
+
source: { base: baseEntry ? safeProjection(baseEntry) : null, user: userEntry ? safeProjection(userEntry) : null },
|
|
99
|
+
credential_reference: safeCredentialRef(userEntry) ?? safeCredentialRef(baseEntry),
|
|
100
|
+
collision: ownershipCode ? { reason_code: ownershipCode, ...(ownershipCode === 'HARNESS_PROVIDER_ID_COLLISION' ? { requires_target_id: true } : {}) } : null,
|
|
101
|
+
...(blockedCode ? { blocked_reason: blockedCode } : ownershipCode ? { blocked_reason: ownershipCode } : {}),
|
|
102
|
+
referenced_by: (Array.isArray(routingReferences) ? routingReferences : [])
|
|
103
|
+
.filter((ref) => ref?.provider === id).map((ref) => ({ provider: id, model: text(ref.model) })),
|
|
104
|
+
harness_default: harnessDefault?.provider === id,
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
return {
|
|
108
|
+
schema_version: 1,
|
|
109
|
+
kind: 'provider-layer-migration-plan',
|
|
110
|
+
requires_confirmation: providers.some((entry) => entry.action !== 'none'),
|
|
111
|
+
providers: providers.filter((entry) => entry.action !== 'none'),
|
|
112
|
+
blocked: providers.filter((entry) => entry.action === 'collision-review' || entry.action === 'blocked').map((entry) => ({
|
|
113
|
+
provider_id: entry.provider_id,
|
|
114
|
+
code: entry.blocked_reason ?? 'HARNESS_PROVIDER_ID_COLLISION',
|
|
115
|
+
})),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function hasProviderLayerMigration(plan) {
|
|
120
|
+
return Array.isArray(plan?.providers) && plan.providers.length > 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const MIGRATION_STATES = Object.freeze(['PLANNED', 'APPLIED', 'RESTART_PENDING', 'VERIFYING', 'VERIFIED', 'FAILED']);
|
|
124
|
+
|
|
125
|
+
function migrationCode(error, fallback = 'PROVIDER_MIGRATION_FAILED') {
|
|
126
|
+
const code = text(error?.code);
|
|
127
|
+
return code && /^[A-Z][A-Z0-9_]{1,63}$/u.test(code) ? code : fallback;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Execute one confirmed provider-layer transition through explicit adapters.
|
|
132
|
+
* Adapters own filesystem/CAS/restart details; this state machine never reads
|
|
133
|
+
* credentials and never runs for a GET or an unconfirmed request.
|
|
134
|
+
*/
|
|
135
|
+
export async function executeProviderLayerMigration(plan, hooks = {}, { confirm = false, deferRestart = false } = {}) {
|
|
136
|
+
if (confirm !== true) return { ok: false, state: 'BLOCKED', code: 'PROVIDER_MIGRATION_CONFIRM_REQUIRED', transaction_id: plan?.plan_id ?? null };
|
|
137
|
+
if (!plan?.plan_id || !text(plan.provider_id) || !['promote-existing-user', 'materialize-user'].includes(plan.action)) {
|
|
138
|
+
return { ok: false, state: 'BLOCKED', code: 'PROVIDER_MIGRATION_PLAN_INVALID', transaction_id: plan?.plan_id ?? null };
|
|
139
|
+
}
|
|
140
|
+
for (const name of ['backup', 'materialize', 'removeBase', 'rollback']) {
|
|
141
|
+
if (typeof hooks[name] !== 'function') return { ok: false, state: 'FAILED', code: 'PROVIDER_MIGRATION_HOOK_MISSING', transaction_id: plan.plan_id };
|
|
142
|
+
}
|
|
143
|
+
if (!deferRestart && (typeof hooks.restart !== 'function' || typeof hooks.verify !== 'function')) {
|
|
144
|
+
return { ok: false, state: 'FAILED', code: 'PROVIDER_MIGRATION_HOOK_MISSING', transaction_id: plan.plan_id };
|
|
145
|
+
}
|
|
146
|
+
let state = 'PLANNED';
|
|
147
|
+
const events = [{ state, at: new Date().toISOString() }];
|
|
148
|
+
let rollbackAttempted = false;
|
|
149
|
+
let rollbackError = null;
|
|
150
|
+
let verification = null;
|
|
151
|
+
try {
|
|
152
|
+
await hooks.backup(plan);
|
|
153
|
+
await hooks.materialize(plan);
|
|
154
|
+
await hooks.removeBase(plan);
|
|
155
|
+
state = 'APPLIED';
|
|
156
|
+
events.push({ state, at: new Date().toISOString() });
|
|
157
|
+
if (deferRestart) {
|
|
158
|
+
state = 'RESTART_PENDING';
|
|
159
|
+
events.push({ state, at: new Date().toISOString() });
|
|
160
|
+
} else {
|
|
161
|
+
const restarted = await hooks.restart(plan);
|
|
162
|
+
if (restarted?.ok === false) throw Object.assign(new Error('provider migration restart failed'), { code: restarted.code });
|
|
163
|
+
state = 'VERIFYING';
|
|
164
|
+
events.push({ state, at: new Date().toISOString() });
|
|
165
|
+
verification = await hooks.verify(plan);
|
|
166
|
+
if (verification?.nativeRemovable !== true || verification?.baseAbsent !== true || verification?.userPresent !== true) {
|
|
167
|
+
throw Object.assign(new Error('provider migration verification incomplete'), { code: 'PROVIDER_MIGRATION_VERIFY_FAILED' });
|
|
168
|
+
}
|
|
169
|
+
state = 'VERIFIED';
|
|
170
|
+
events.push({ state, at: new Date().toISOString() });
|
|
171
|
+
}
|
|
172
|
+
} catch (error) {
|
|
173
|
+
rollbackAttempted = true;
|
|
174
|
+
try { await hooks.rollback(plan); }
|
|
175
|
+
catch (rollbackErrorValue) { rollbackError = migrationCode(rollbackErrorValue, 'PROVIDER_MIGRATION_ROLLBACK_FAILED'); }
|
|
176
|
+
state = 'FAILED';
|
|
177
|
+
events.push({ state, at: new Date().toISOString() });
|
|
178
|
+
return {
|
|
179
|
+
ok: false,
|
|
180
|
+
state,
|
|
181
|
+
code: migrationCode(error),
|
|
182
|
+
transaction_id: plan.plan_id,
|
|
183
|
+
provider_id: plan.provider_id,
|
|
184
|
+
rollback_attempted: rollbackAttempted,
|
|
185
|
+
...(rollbackError ? { rollback_error: rollbackError } : {}),
|
|
186
|
+
events,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
ok: state === 'VERIFIED' || state === 'RESTART_PENDING',
|
|
191
|
+
state,
|
|
192
|
+
transaction_id: plan.plan_id,
|
|
193
|
+
provider_id: plan.provider_id,
|
|
194
|
+
restart_required: true,
|
|
195
|
+
...(verification ? { verification } : {}),
|
|
196
|
+
events,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// Tombstones prevent an explicitly removed managed route from being re-seeded;
|
|
3
3
|
// transactions retain only bounded audit metadata needed for recovery.
|
|
4
4
|
|
|
5
|
+
import { classifyCredentialReference } from './credential-reference.mjs';
|
|
6
|
+
|
|
5
7
|
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
6
8
|
const REVISION_PATTERN = /^[a-f0-9]{64}$/;
|
|
7
9
|
const STATES = new Set([
|
|
@@ -28,7 +30,8 @@ function normalizedCredentialRefs(value) {
|
|
|
28
30
|
const kind = typeof ref.kind === 'string' && ref.kind.trim() ? ref.kind.trim() : null;
|
|
29
31
|
const name = typeof ref.name_or_handle === 'string' && ref.name_or_handle.trim() ? ref.name_or_handle.trim() : null;
|
|
30
32
|
const ownership = typeof ref.ownership === 'string' && ref.ownership.trim() ? ref.ownership.trim() : null;
|
|
31
|
-
|
|
33
|
+
const safeName = classifyCredentialReference(name, { kind }).value;
|
|
34
|
+
return kind && safeName && ownership ? { kind, name_or_handle: safeName, ownership } : null;
|
|
32
35
|
}).filter(Boolean).slice(0, 32);
|
|
33
36
|
return refs;
|
|
34
37
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// bounded, revision-checked text edits while preserving unrelated patch items.
|
|
6
6
|
|
|
7
7
|
import { createHash } from 'node:crypto';
|
|
8
|
+
import { classifyCredentialReference } from './credential-reference.mjs';
|
|
8
9
|
|
|
9
10
|
const PROVIDER_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
10
11
|
|
|
@@ -98,12 +99,12 @@ function scalarField(lines, entry, field) {
|
|
|
98
99
|
if (!match) continue;
|
|
99
100
|
const value = match[1].trim();
|
|
100
101
|
if (!value) return null;
|
|
101
|
-
return value.replace(/^(?:"([
|
|
102
|
+
return value.replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/u, '$1$2');
|
|
102
103
|
}
|
|
103
104
|
return null;
|
|
104
105
|
}
|
|
105
106
|
|
|
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 SENSITIVE_CREDENTIAL_FIELD = /(?:api[_-]?key|access[_-]?token|refresh[_-]?token|(?:^|[_-])token(?:$|[_-])|secret|password|authorization|credential|private[_-]?key|client[_-]?secret|cookie|bearer|webhook)/iu;
|
|
107
108
|
const REFERENCE_FIELD_SUFFIX = /(?:env|ref|name|handle|id|file|path)$/iu;
|
|
108
109
|
|
|
109
110
|
function isInlineCredentialLine(line) {
|
|
@@ -132,7 +133,8 @@ export function readProviderDeclarations(source, { file = 'profile.yml' } = {})
|
|
|
132
133
|
if (!parsed.ok) return { ok: false, code: parsed.code };
|
|
133
134
|
const declarations = parsed.entries.map((entry) => {
|
|
134
135
|
const displayName = scalarField(parsed.lines, entry, 'displayName');
|
|
135
|
-
const
|
|
136
|
+
const rawCredentialRef = scalarField(parsed.lines, entry, 'apiKeyEnv');
|
|
137
|
+
const credential = classifyCredentialReference(rawCredentialRef, { kind: 'env' });
|
|
136
138
|
return {
|
|
137
139
|
id: entry.id,
|
|
138
140
|
display_name: displayName ?? entry.id,
|
|
@@ -140,12 +142,199 @@ export function readProviderDeclarations(source, { file = 'profile.yml' } = {})
|
|
|
140
142
|
ownership: 'crew-managed-profile',
|
|
141
143
|
file,
|
|
142
144
|
declaration_authority: { kind: 'crew-profile', locator: `llm-pi-ai.config.providers.${entry.id}` },
|
|
143
|
-
...(
|
|
145
|
+
...(credential.value ? { credential_ref: credential.value } : {}),
|
|
146
|
+
...(credential.redacted ? { credential_status: 'present-redacted' } : {}),
|
|
144
147
|
};
|
|
145
148
|
});
|
|
146
149
|
return { ok: true, declarations };
|
|
147
150
|
}
|
|
148
151
|
|
|
152
|
+
function unquoteScalar(value) {
|
|
153
|
+
return typeof value === 'string'
|
|
154
|
+
? value.trim().replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/u, '$1$2')
|
|
155
|
+
: null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function providerModels(lines, entry) {
|
|
159
|
+
const modelsLine = lines.findIndex((line, index) => index >= entry.start && index < entry.end && /^\s+models:\s*$/u.test(line));
|
|
160
|
+
if (modelsLine < 0) return [];
|
|
161
|
+
const modelsIndent = (lines[modelsLine].match(/^\s*/u) ?? [''])[0].length;
|
|
162
|
+
const models = [];
|
|
163
|
+
const seenModelIds = new Set();
|
|
164
|
+
for (let index = modelsLine + 1; index < entry.end; index += 1) {
|
|
165
|
+
const line = lines[index];
|
|
166
|
+
if (!nonBlank(line)) continue;
|
|
167
|
+
const indent = indentOf(line);
|
|
168
|
+
if (indent <= modelsIndent) break;
|
|
169
|
+
const match = line.match(/^\s*-\s+id:\s*(.*?)\s*$/u);
|
|
170
|
+
if (!match) continue;
|
|
171
|
+
const id = unquoteScalar(match[1]);
|
|
172
|
+
if (!id || id.length > 256 || /[\r\n]/u.test(id) || seenModelIds.has(id)) return null;
|
|
173
|
+
seenModelIds.add(id);
|
|
174
|
+
const modelIndent = indentOf(line);
|
|
175
|
+
const model = { id };
|
|
176
|
+
const seenFields = new Set(['id']);
|
|
177
|
+
for (let child = index + 1; child < entry.end; child += 1) {
|
|
178
|
+
const childLine = lines[child];
|
|
179
|
+
if (!nonBlank(childLine)) continue;
|
|
180
|
+
const childIndent = indentOf(childLine);
|
|
181
|
+
if (childIndent <= modelsIndent || /^\s*-\s+id:\s*/u.test(childLine)) break;
|
|
182
|
+
if (childIndent !== modelIndent + 2) continue;
|
|
183
|
+
const fieldMatch = childLine.match(/^\s+([A-Za-z][A-Za-z0-9_-]*):\s*(.*?)\s*$/u);
|
|
184
|
+
if (!fieldMatch) return null;
|
|
185
|
+
const [, field, rawValue] = fieldMatch;
|
|
186
|
+
if (seenFields.has(field) || !['name', 'contextWindow', 'maxTokens', 'input', 'reasoningEfforts', 'compat'].includes(field)) return null;
|
|
187
|
+
seenFields.add(field);
|
|
188
|
+
if (field === 'name') {
|
|
189
|
+
const value = unquoteScalar(rawValue);
|
|
190
|
+
if (!value || value.length > 256 || /[\r\n]/u.test(value)) return null;
|
|
191
|
+
model.name = value;
|
|
192
|
+
} else if (field === 'contextWindow' || field === 'maxTokens') {
|
|
193
|
+
const value = Number(unquoteScalar(rawValue));
|
|
194
|
+
if (!Number.isSafeInteger(value) || value <= 0) return null;
|
|
195
|
+
model[field === 'contextWindow' ? 'context_window' : 'max_tokens'] = value;
|
|
196
|
+
} else if (field === 'input') {
|
|
197
|
+
const value = unquoteScalar(rawValue);
|
|
198
|
+
if (!/^\[[^\]\r\n]*\]$/u.test(value ?? '')) return null;
|
|
199
|
+
const modalities = value.slice(1, -1).split(',').map((item) => item.trim()).filter(Boolean);
|
|
200
|
+
if (modalities.some((item) => !['text', 'image'].includes(item))) return null;
|
|
201
|
+
model.input = [...new Set(modalities)];
|
|
202
|
+
} else if (field === 'reasoningEfforts') {
|
|
203
|
+
const efforts = {};
|
|
204
|
+
for (let nested = child + 1; nested < entry.end; nested += 1) {
|
|
205
|
+
const nestedLine = lines[nested];
|
|
206
|
+
if (!nonBlank(nestedLine)) continue;
|
|
207
|
+
const nestedIndent = indentOf(nestedLine);
|
|
208
|
+
if (nestedIndent <= childIndent) break;
|
|
209
|
+
if (nestedIndent !== childIndent + 2) return null;
|
|
210
|
+
const effortMatch = nestedLine.match(/^\s+([A-Za-z][A-Za-z0-9_-]*):\s*(.*?)\s*$/u);
|
|
211
|
+
if (!effortMatch || Object.hasOwn(efforts, effortMatch[1])) return null;
|
|
212
|
+
const effortValue = unquoteScalar(effortMatch[2]);
|
|
213
|
+
if (effortValue !== null && effortValue !== 'null' && effortValue !== '~' && (effortValue.length > 256 || /[\r\n]/u.test(effortValue))) return null;
|
|
214
|
+
efforts[effortMatch[1]] = effortValue === null || effortValue === 'null' || effortValue === '~' ? null : effortValue;
|
|
215
|
+
}
|
|
216
|
+
model.reasoning_efforts = efforts;
|
|
217
|
+
} else if (field === 'compat') {
|
|
218
|
+
if (rawValue.trim() !== '{}') return null;
|
|
219
|
+
model.compat = {};
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
models.push(model);
|
|
223
|
+
}
|
|
224
|
+
return models.slice(0, 256);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Return the bounded, non-secret fields needed to materialize one profile
|
|
229
|
+
* provider in Harness settings. This is read-only and intentionally separate
|
|
230
|
+
* from readProviderDeclarations so existing provenance consumers stay stable.
|
|
231
|
+
*/
|
|
232
|
+
export function readProviderMaterialization(source, { providerId, file = 'profile.yml' } = {}) {
|
|
233
|
+
const parsed = parseProviderMap(source);
|
|
234
|
+
if (!parsed.ok) return { ok: false, code: parsed.code };
|
|
235
|
+
const entry = parsed.entries.find((candidate) => candidate.id === providerId);
|
|
236
|
+
if (!entry) return { ok: false, code: 'PROVIDER_NOT_FOUND' };
|
|
237
|
+
const providerIndent = indentOf(parsed.lines[entry.start]);
|
|
238
|
+
const directScalarField = (field) => {
|
|
239
|
+
const pattern = new RegExp(`^\\s{${providerIndent + 2}}${field}:\\s*(.*?)\\s*$`);
|
|
240
|
+
for (const line of parsed.lines.slice(entry.start, entry.end)) {
|
|
241
|
+
const match = line.match(pattern);
|
|
242
|
+
if (match) return unquoteScalar(match[1]);
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
245
|
+
};
|
|
246
|
+
const credentialRef = directScalarField('apiKeyEnv');
|
|
247
|
+
const credential = classifyCredentialReference(credentialRef, { kind: 'env' });
|
|
248
|
+
if (credential.redacted) return { ok: false, code: 'PROVIDER_CREDENTIAL_REFERENCE_UNSAFE' };
|
|
249
|
+
const readBounded = (field, max = 2048) => {
|
|
250
|
+
const value = directScalarField(field);
|
|
251
|
+
return value && value.length <= max && !/[\r\n]/u.test(value) ? value : null;
|
|
252
|
+
};
|
|
253
|
+
const knownFields = new Set(['displayName', 'apiKeyEnv', 'api', 'baseURL', 'models']);
|
|
254
|
+
const directFields = parsed.lines.slice(entry.start + 1, entry.end)
|
|
255
|
+
.filter((line) => nonBlank(line) && indentOf(line) === providerIndent + 2)
|
|
256
|
+
.map((line) => line.match(/^\s+([A-Za-z][A-Za-z0-9_-]*):/u)?.[1])
|
|
257
|
+
.filter(Boolean);
|
|
258
|
+
const unknownFields = directFields.filter((field) => !knownFields.has(field));
|
|
259
|
+
if (unknownFields.length > 0 || new Set(directFields).size !== directFields.length) return { ok: false, code: 'PROVIDER_MATERIALIZATION_UNSUPPORTED_FIELDS' };
|
|
260
|
+
const baseUrl = readBounded('baseURL');
|
|
261
|
+
if (baseUrl) {
|
|
262
|
+
try {
|
|
263
|
+
const parsedUrl = new URL(baseUrl);
|
|
264
|
+
if (!['http:', 'https:'].includes(parsedUrl.protocol) || parsedUrl.username || parsedUrl.password || parsedUrl.search || parsedUrl.hash || /[?#]/u.test(baseUrl) || !parsedUrl.hostname) {
|
|
265
|
+
return { ok: false, code: 'PROVIDER_BASE_URL_UNSAFE' };
|
|
266
|
+
}
|
|
267
|
+
} catch { return { ok: false, code: 'PROVIDER_BASE_URL_UNSAFE' }; }
|
|
268
|
+
}
|
|
269
|
+
const api = readBounded('api', 128);
|
|
270
|
+
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' };
|
|
271
|
+
const provider = {
|
|
272
|
+
id: entry.id,
|
|
273
|
+
display_name: readBounded('displayName', 256) ?? entry.id,
|
|
274
|
+
...(credential.value ? { credential_ref: credential.value } : {}),
|
|
275
|
+
...(api ? { api } : {}),
|
|
276
|
+
...(baseUrl ? { base_url: baseUrl } : {}),
|
|
277
|
+
models: providerModels(parsed.lines, entry),
|
|
278
|
+
source_file: file,
|
|
279
|
+
};
|
|
280
|
+
if (provider.models === null) return { ok: false, code: 'PROVIDER_MODEL_SCHEMA_UNSUPPORTED' };
|
|
281
|
+
return { ok: true, provider };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function yamlScalar(value, max = 2048) {
|
|
285
|
+
if (typeof value !== 'string' || !value.trim() || value.length > max || /[\r\n]/u.test(value)) return null;
|
|
286
|
+
return JSON.stringify(value.trim());
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Re-add one safe provider projection during an explicit migration rollback. */
|
|
290
|
+
export function addProviderDeclaration(source, { provider, expectedRevision } = {}) {
|
|
291
|
+
const currentRevision = typeof source === 'string' ? sha256(source) : null;
|
|
292
|
+
if (expectedRevision !== undefined && expectedRevision !== currentRevision) return { ok: false, code: 'PROVIDER_PROFILE_CHANGED', revision: currentRevision };
|
|
293
|
+
if (!provider || typeof provider !== 'object' || Array.isArray(provider) || !PROVIDER_ID.test(provider.id ?? '')) return { ok: false, code: 'PROVIDER_MATERIALIZATION_INVALID', revision: currentRevision };
|
|
294
|
+
const parsed = parseProviderMap(source);
|
|
295
|
+
if (!parsed.ok) return { ok: false, code: parsed.code, revision: currentRevision };
|
|
296
|
+
if (parsed.entries.some((entry) => entry.id === provider.id)) return { ok: false, code: 'PROVIDER_PROFILE_PROVIDER_EXISTS', revision: currentRevision };
|
|
297
|
+
const displayName = yamlScalar(provider.display_name ?? provider.id, 256);
|
|
298
|
+
if (!displayName) return { ok: false, code: 'PROVIDER_MATERIALIZATION_INVALID', revision: currentRevision };
|
|
299
|
+
const providerIndent = indentOf(parsed.lines[parsed.providersLine]) + 2;
|
|
300
|
+
const fieldIndent = providerIndent + 2;
|
|
301
|
+
const lines = [`${' '.repeat(providerIndent)}${provider.id}:`, `${' '.repeat(fieldIndent)}displayName: ${displayName}`];
|
|
302
|
+
const api = yamlScalar(provider.api, 128);
|
|
303
|
+
const baseUrl = yamlScalar(provider.base_url, 2048);
|
|
304
|
+
const credential = provider.credential_ref ? classifyCredentialReference(provider.credential_ref, { kind: 'env' }).value : null;
|
|
305
|
+
if (provider.credential_ref && !credential) return { ok: false, code: 'PROVIDER_CREDENTIAL_REFERENCE_UNSAFE', revision: currentRevision };
|
|
306
|
+
if (api) lines.push(`${' '.repeat(fieldIndent)}api: ${api}`);
|
|
307
|
+
if (baseUrl) lines.push(`${' '.repeat(fieldIndent)}baseURL: ${baseUrl}`);
|
|
308
|
+
if (credential) lines.push(`${' '.repeat(fieldIndent)}apiKeyEnv: ${credential}`);
|
|
309
|
+
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) : [];
|
|
310
|
+
if (models.length > 0) {
|
|
311
|
+
lines.push(`${' '.repeat(fieldIndent)}models:`);
|
|
312
|
+
for (const model of models) {
|
|
313
|
+
const id = yamlScalar(model.id, 256);
|
|
314
|
+
if (!id) continue;
|
|
315
|
+
lines.push(`${' '.repeat(fieldIndent + 2)}- id: ${id}`);
|
|
316
|
+
const name = yamlScalar(model.name, 256);
|
|
317
|
+
if (name) lines.push(`${' '.repeat(fieldIndent + 4)}name: ${name}`);
|
|
318
|
+
if (Number.isSafeInteger(model.context_window) && model.context_window > 0) lines.push(`${' '.repeat(fieldIndent + 4)}contextWindow: ${model.context_window}`);
|
|
319
|
+
if (Number.isSafeInteger(model.max_tokens) && model.max_tokens > 0) lines.push(`${' '.repeat(fieldIndent + 4)}maxTokens: ${model.max_tokens}`);
|
|
320
|
+
if (Array.isArray(model.input) && model.input.every((value) => value === 'text' || value === 'image')) lines.push(`${' '.repeat(fieldIndent + 4)}input: [${[...new Set(model.input)].join(', ')}]`);
|
|
321
|
+
if (model.reasoning_efforts && typeof model.reasoning_efforts === 'object' && !Array.isArray(model.reasoning_efforts)) {
|
|
322
|
+
lines.push(`${' '.repeat(fieldIndent + 4)}reasoningEfforts:`);
|
|
323
|
+
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)}`);
|
|
324
|
+
}
|
|
325
|
+
if (model.compat && typeof model.compat === 'object' && !Array.isArray(model.compat) && Object.keys(model.compat).length === 0) lines.push(`${' '.repeat(fieldIndent + 4)}compat: {}`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const nextLines = [...parsed.lines];
|
|
329
|
+
if (/^\s+providers:\s*\{\s*\}\s*$/u.test(nextLines[parsed.providersLine])) {
|
|
330
|
+
nextLines[parsed.providersLine] = `${' '.repeat(indentOf(nextLines[parsed.providersLine]))}providers:`;
|
|
331
|
+
}
|
|
332
|
+
nextLines.splice(parsed.blockEnd, 0, ...lines);
|
|
333
|
+
const newline = source.includes('\r\n') ? '\r\n' : '\n';
|
|
334
|
+
const text = nextLines.join(newline);
|
|
335
|
+
return { ok: true, text, added: provider.id, revision: sha256(text) };
|
|
336
|
+
}
|
|
337
|
+
|
|
149
338
|
/**
|
|
150
339
|
* Remove provider declarations from a known profile patch after an optional
|
|
151
340
|
* content-revision check. The returned text is safe to write atomically by the
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// credential values are never deserialized or returned.
|
|
4
4
|
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
|
+
import { classifyCredentialReference } from './credential-reference.mjs';
|
|
7
|
+
import { readProviderMaterialization } from './provider-profile-store.mjs';
|
|
6
8
|
|
|
7
9
|
const PROVIDER_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
|
|
8
10
|
|
|
@@ -16,7 +18,7 @@ function scalarField(lines, entry, field) {
|
|
|
16
18
|
if (!match) continue;
|
|
17
19
|
const value = match[1].trim();
|
|
18
20
|
if (!value) return null;
|
|
19
|
-
return value.replace(/^(?:"([
|
|
21
|
+
return value.replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/u, '$1$2');
|
|
20
22
|
}
|
|
21
23
|
return null;
|
|
22
24
|
}
|
|
@@ -78,7 +80,7 @@ function parseProviderMap(source) {
|
|
|
78
80
|
return { ok: true, lines, llmStart, blockEnd, providersLine, providersBlockEnd, entries };
|
|
79
81
|
}
|
|
80
82
|
|
|
81
|
-
const SENSITIVE_CREDENTIAL_FIELD = /(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password|authorization|credential|private[_-]?key|client[_-]?secret|cookie|bearer|webhook)/iu;
|
|
83
|
+
const SENSITIVE_CREDENTIAL_FIELD = /(?:api[_-]?key|access[_-]?token|refresh[_-]?token|(?:^|[_-])token(?:$|[_-])|secret|password|authorization|credential|private[_-]?key|client[_-]?secret|cookie|bearer|webhook)/iu;
|
|
82
84
|
const REFERENCE_FIELD_SUFFIX = /(?:env|ref|name|handle|id|file|path)$/iu;
|
|
83
85
|
|
|
84
86
|
function isInlineCredentialLine(line) {
|
|
@@ -114,26 +116,100 @@ export function readProviderSettingsDeclarations(source, { file = 'harness/setti
|
|
|
114
116
|
return {
|
|
115
117
|
ok: true,
|
|
116
118
|
declarations: parsed.entries.map((entry) => {
|
|
117
|
-
const
|
|
119
|
+
const rawCredentialRef = scalarField(parsed.lines, entry, 'apiKeyEnv');
|
|
120
|
+
const credential = classifyCredentialReference(rawCredentialRef, { kind: 'env' });
|
|
118
121
|
return {
|
|
119
122
|
id: entry.id,
|
|
120
123
|
display_name: scalarField(parsed.lines, entry, 'displayName') ?? entry.id,
|
|
121
|
-
|
|
122
|
-
|
|
124
|
+
// settings.yaml is the Harness user layer. Crew profile/base
|
|
125
|
+
// declarations are reported separately by provider-profile-store.
|
|
126
|
+
origin: 'dynamic',
|
|
127
|
+
ownership: 'dynamic-user',
|
|
123
128
|
file,
|
|
124
129
|
declaration_authority: { kind: 'harness-settings', locator: `llm-pi-ai.providers.${entry.id}` },
|
|
125
|
-
...(
|
|
130
|
+
...(credential.value ? { credential_ref: { kind: 'env', name_or_handle: credential.value, ownership: 'unknown' } } : {}),
|
|
131
|
+
...(credential.redacted ? { credential_status: 'present-redacted' } : {}),
|
|
126
132
|
};
|
|
127
133
|
}),
|
|
128
134
|
};
|
|
129
135
|
}
|
|
130
136
|
|
|
137
|
+
/** Return the same bounded provider projection as the profile parser, adapted
|
|
138
|
+
* to the Harness settings indentation. This is used only for rollback CAS and
|
|
139
|
+
* never exposes credential values. */
|
|
140
|
+
export function readProviderSettingsMaterialization(source, { providerId, file = 'harness/settings.yaml' } = {}) {
|
|
141
|
+
const parsed = parseProviderMap(source);
|
|
142
|
+
if (!parsed.ok) return { ok: false, code: parsed.code };
|
|
143
|
+
const entry = parsed.entries.find((candidate) => candidate.id === providerId);
|
|
144
|
+
if (!entry) return { ok: false, code: 'PROVIDER_NOT_FOUND' };
|
|
145
|
+
const block = parsed.lines.slice(entry.start, entry.end).map((line) => ` ${line}`);
|
|
146
|
+
const synthetic = ['- id: llm-pi-ai', ' config:', ' providers:', ...block, ''].join('\n');
|
|
147
|
+
const result = readProviderMaterialization(synthetic, { providerId, file });
|
|
148
|
+
return result.ok ? { ...result, provider: { ...result.provider, source_file: file } } : result;
|
|
149
|
+
}
|
|
150
|
+
|
|
131
151
|
export function readHarnessDefault(source) {
|
|
132
152
|
const parsed = parseDefaultModel(source);
|
|
133
153
|
if (!parsed.ok) return { ok: false, code: parsed.code };
|
|
134
154
|
return { ok: true, provider: parsed.provider, model: parsed.model, locator: 'agent-default-model' };
|
|
135
155
|
}
|
|
136
156
|
|
|
157
|
+
function yamlScalar(value, max = 2048) {
|
|
158
|
+
if (typeof value !== 'string' || !value.trim() || value.length > max || /[\r\n]/u.test(value)) return null;
|
|
159
|
+
return JSON.stringify(value.trim());
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Add one already-sanitized provider projection to the Harness user layer.
|
|
164
|
+
* The operation is pure and revision-bound; callers perform the atomic write.
|
|
165
|
+
*/
|
|
166
|
+
export function addProviderSettings(source, { provider, expectedRevision } = {}) {
|
|
167
|
+
const currentRevision = typeof source === 'string' ? sha256(source) : null;
|
|
168
|
+
if (expectedRevision !== undefined && expectedRevision !== currentRevision) return { ok: false, code: 'PROVIDER_SETTINGS_CHANGED', revision: currentRevision };
|
|
169
|
+
if (!provider || typeof provider !== 'object' || Array.isArray(provider) || typeof provider.id !== 'string' || !PROVIDER_ID.test(provider.id)) {
|
|
170
|
+
return { ok: false, code: 'PROVIDER_MATERIALIZATION_INVALID', revision: currentRevision };
|
|
171
|
+
}
|
|
172
|
+
const parsed = parseProviderMap(source);
|
|
173
|
+
if (!parsed.ok) return { ok: false, code: parsed.code, revision: currentRevision };
|
|
174
|
+
if (parsed.entries.some((entry) => entry.id === provider.id)) return { ok: false, code: 'PROVIDER_SETTINGS_PROVIDER_EXISTS', revision: currentRevision };
|
|
175
|
+
const displayName = yamlScalar(provider.display_name ?? provider.id, 256);
|
|
176
|
+
if (!displayName) return { ok: false, code: 'PROVIDER_MATERIALIZATION_INVALID', revision: currentRevision };
|
|
177
|
+
const lines = [` ${provider.id}:`, ` displayName: ${displayName}`];
|
|
178
|
+
const api = yamlScalar(provider.api, 128);
|
|
179
|
+
const baseUrl = yamlScalar(provider.base_url, 2048);
|
|
180
|
+
const credential = provider.credential_ref ? classifyCredentialReference(provider.credential_ref, { kind: 'env' }).value : null;
|
|
181
|
+
if (provider.credential_ref && !credential) return { ok: false, code: 'PROVIDER_CREDENTIAL_REFERENCE_UNSAFE', revision: currentRevision };
|
|
182
|
+
if (api) lines.push(` api: ${api}`);
|
|
183
|
+
if (baseUrl) lines.push(` baseURL: ${baseUrl}`);
|
|
184
|
+
if (credential) lines.push(` apiKeyEnv: ${credential}`);
|
|
185
|
+
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) : [];
|
|
186
|
+
if (models.length > 0) {
|
|
187
|
+
lines.push(' models:');
|
|
188
|
+
for (const model of models) {
|
|
189
|
+
const id = yamlScalar(model.id, 256);
|
|
190
|
+
if (!id) continue;
|
|
191
|
+
lines.push(` - id: ${id}`);
|
|
192
|
+
const name = yamlScalar(model.name, 256);
|
|
193
|
+
if (name) lines.push(` name: ${name}`);
|
|
194
|
+
if (Number.isSafeInteger(model.context_window) && model.context_window > 0) lines.push(` contextWindow: ${model.context_window}`);
|
|
195
|
+
if (Number.isSafeInteger(model.max_tokens) && model.max_tokens > 0) lines.push(` maxTokens: ${model.max_tokens}`);
|
|
196
|
+
if (Array.isArray(model.input) && model.input.every((value) => value === 'text' || value === 'image')) lines.push(` input: [${[...new Set(model.input)].join(', ')}]`);
|
|
197
|
+
if (model.reasoning_efforts && typeof model.reasoning_efforts === 'object' && !Array.isArray(model.reasoning_efforts)) {
|
|
198
|
+
lines.push(' reasoningEfforts:');
|
|
199
|
+
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)}`);
|
|
200
|
+
}
|
|
201
|
+
if (model.compat && typeof model.compat === 'object' && !Array.isArray(model.compat) && Object.keys(model.compat).length === 0) lines.push(' compat: {}');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const nextLines = [...parsed.lines];
|
|
205
|
+
const insertAt = parsed.blockEnd;
|
|
206
|
+
if (/^ {2}providers:\s*\{\s*\}\s*$/u.test(nextLines[parsed.providersLine])) nextLines[parsed.providersLine] = ' providers:';
|
|
207
|
+
nextLines.splice(insertAt, 0, ...lines);
|
|
208
|
+
const newline = source.includes('\r\n') ? '\r\n' : '\n';
|
|
209
|
+
const text = nextLines.join(newline);
|
|
210
|
+
return { ok: true, text, added: provider.id, revision: sha256(text) };
|
|
211
|
+
}
|
|
212
|
+
|
|
137
213
|
export function replaceHarnessDefault(source, { provider, model, expectedRevision } = {}) {
|
|
138
214
|
const currentRevision = typeof source === 'string' ? sha256(source) : null;
|
|
139
215
|
if (expectedRevision !== undefined && expectedRevision !== currentRevision) return { ok: false, code: 'PROVIDER_SETTINGS_CHANGED', revision: currentRevision };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Persistent token-fenced lock shared by every lifecycle operation that can
|
|
2
|
+
// mutate the Harness profile/settings stores. A stale lock is reclaimed only
|
|
3
|
+
// while a serialized claim is held; malformed metadata fails closed.
|
|
4
|
+
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, resolve as resolvePath } from 'node:path';
|
|
8
|
+
|
|
9
|
+
function safeToken(value) { return typeof value === 'string' && /^[A-Za-z0-9-]{16,128}$/u.test(value); }
|
|
10
|
+
function readOwner(file) {
|
|
11
|
+
try {
|
|
12
|
+
const owner = JSON.parse(readFileSync(file, 'utf8'));
|
|
13
|
+
if (!owner || typeof owner !== 'object' || Array.isArray(owner) || !Number.isInteger(owner.pid) || owner.pid <= 0 || !safeToken(owner.token)) return { ok: false, malformed: true };
|
|
14
|
+
return { ok: true, owner };
|
|
15
|
+
} catch { return { ok: false, malformed: true }; }
|
|
16
|
+
}
|
|
17
|
+
function processAlive(pid) {
|
|
18
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error?.code === 'EPERM'; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function acquireProviderStoreLock(file) {
|
|
22
|
+
if (typeof file !== 'string' || !file.trim()) throw Object.assign(new Error('provider store lock path is required'), { code: 'PROVIDER_STORE_LOCK_UNAVAILABLE' });
|
|
23
|
+
const lockFile = resolvePath(file);
|
|
24
|
+
mkdirSync(dirname(lockFile), { recursive: true });
|
|
25
|
+
const claimFile = `${lockFile}.claim`;
|
|
26
|
+
const token = randomUUID();
|
|
27
|
+
let claimed = false;
|
|
28
|
+
try {
|
|
29
|
+
try { mkdirSync(claimFile); claimed = true; }
|
|
30
|
+
catch (error) { throw Object.assign(new Error('provider store lock is busy'), { code: error?.code === 'EEXIST' ? 'PROVIDER_STORE_LOCK_BUSY' : 'PROVIDER_STORE_LOCK_UNAVAILABLE' }); }
|
|
31
|
+
while (true) {
|
|
32
|
+
try {
|
|
33
|
+
writeFileSync(lockFile, JSON.stringify({ pid: process.pid, token, created_at: new Date().toISOString() }) + '\n', { flag: 'wx' });
|
|
34
|
+
return {
|
|
35
|
+
ok: true,
|
|
36
|
+
token,
|
|
37
|
+
release: async () => {
|
|
38
|
+
const current = readOwner(lockFile);
|
|
39
|
+
if (current.ok && current.owner.token === token) rmSync(lockFile, { force: true });
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error?.code !== 'EEXIST') throw Object.assign(new Error('provider store lock is unavailable'), { code: 'PROVIDER_STORE_LOCK_UNAVAILABLE' });
|
|
44
|
+
const current = readOwner(lockFile);
|
|
45
|
+
if (!current.ok) throw Object.assign(new Error('provider store lock metadata is invalid'), { code: 'PROVIDER_STORE_LOCK_BUSY' });
|
|
46
|
+
if (processAlive(current.owner.pid)) throw Object.assign(new Error('provider store lock is busy'), { code: 'PROVIDER_STORE_LOCK_BUSY' });
|
|
47
|
+
rmSync(lockFile, { force: true });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} finally { if (claimed) rmSync(claimFile, { recursive: true, force: true }); }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function recoverProviderStoreLock(file, { confirm = false } = {}) {
|
|
54
|
+
if (confirm !== true) return { ok: false, code: 'PROVIDER_STORE_LOCK_CONFIRM_REQUIRED' };
|
|
55
|
+
if (typeof file !== 'string' || !file.trim()) return { ok: false, code: 'PROVIDER_STORE_LOCK_UNAVAILABLE' };
|
|
56
|
+
const lockFile = resolvePath(file);
|
|
57
|
+
const claimFile = `${lockFile}.claim`;
|
|
58
|
+
try { mkdirSync(dirname(lockFile), { recursive: true }); mkdirSync(claimFile); }
|
|
59
|
+
catch (error) { return { ok: false, code: error?.code === 'EEXIST' ? 'PROVIDER_STORE_LOCK_BUSY' : 'PROVIDER_STORE_LOCK_UNAVAILABLE' }; }
|
|
60
|
+
try {
|
|
61
|
+
if (!existsSync(lockFile)) return { ok: true, recovered: false };
|
|
62
|
+
const current = readOwner(lockFile);
|
|
63
|
+
if (current.ok && processAlive(current.owner.pid)) return { ok: false, code: 'PROVIDER_STORE_LOCK_BUSY' };
|
|
64
|
+
rmSync(lockFile, { force: true });
|
|
65
|
+
return { ok: true, recovered: true };
|
|
66
|
+
} finally { rmSync(claimFile, { recursive: true, force: true }); }
|
|
67
|
+
}
|