chati-dev 4.5.16 → 4.5.27
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 +8 -3
- package/bin/chati.js +124 -66
- package/framework/agents/build/dev.md +5 -5
- package/framework/agents/deploy/devops.md +7 -7
- package/framework/agents/discover/brief.md +4 -4
- package/framework/agents/discover/brownfield-wu.md +3 -3
- package/framework/agents/discover/greenfield-wu.md +3 -3
- package/framework/agents/plan/architect.md +2 -2
- package/framework/agents/plan/detail.md +4 -4
- package/framework/agents/plan/phases.md +2 -2
- package/framework/agents/plan/tasks.md +2 -2
- package/framework/agents/plan/ux.md +2 -2
- package/framework/agents/quality/qa-implementation.md +11 -10
- package/framework/agents/quality/qa-planning.md +3 -3
- package/framework/agents/quality/qa-visual.md +1 -1
- package/framework/config.yaml +3 -3
- package/framework/constitution.md +18 -18
- package/framework/context/protocols.md +1 -1
- package/framework/context/quality.md +1 -1
- package/framework/context/root.md +4 -4
- package/framework/data/entity-registry.yaml +1 -1
- package/framework/domains/agents/orchestrator.yaml +2 -2
- package/framework/domains/constitution.yaml +1 -1
- package/framework/hooks/advance-trigger.js +4 -6
- package/framework/hooks/git-push-authority.js +45 -37
- package/framework/hooks/mode-governance.js +149 -40
- package/framework/hooks/model-governance.js +13 -20
- package/framework/hooks/prism-engine.js +74 -92
- package/framework/hooks/reasoning-escalator.js +23 -40
- package/framework/hooks/session-digest.js +12 -13
- package/framework/hooks/session-reader.js +224 -0
- package/framework/hooks/session-writer.js +195 -0
- package/framework/hooks/team-quality-gate.js +34 -24
- package/framework/i18n/en.yaml +2 -2
- package/framework/i18n/es.yaml +2 -2
- package/framework/i18n/fr.yaml +2 -2
- package/framework/i18n/pt.yaml +2 -2
- package/framework/intelligence/context-engine.md +4 -4
- package/framework/intelligence/memory-layer.md +1 -1
- package/framework/manifest.json +129 -119
- package/framework/manifest.sig +1 -1
- package/framework/orchestrator/chati-router.js +252 -25
- package/framework/orchestrator/chati.md +238 -70
- package/framework/schemas/session.schema.json +1 -1
- package/framework/tasks/orchestrator-deviation.md +1 -1
- package/framework/tasks/orchestrator-escalate.md +1 -1
- package/framework/tasks/orchestrator-handoff.md +6 -6
- package/framework/tasks/orchestrator-health.md +5 -9
- package/framework/tasks/orchestrator-mode-switch.md +3 -7
- package/framework/tasks/orchestrator-resume.md +10 -14
- package/framework/tasks/orchestrator-route.md +3 -3
- package/framework/tasks/orchestrator-spawn-terminal.md +1 -1
- package/framework/tasks/orchestrator-status.md +9 -9
- package/framework/tasks/orchestrator-suggest-mode.md +1 -1
- package/framework/tasks/qa-impl-consolidate.md +2 -2
- package/framework/tasks/qa-impl-performance-test.md +4 -4
- package/framework/tasks/qa-impl-regression-check.md +4 -4
- package/framework/tasks/qa-impl-sast-scan.md +1 -1
- package/framework/tasks/qa-impl-test-execute.md +1 -1
- package/framework/tasks/qa-impl-verdict.md +2 -2
- package/framework/tasks/qa-planning-consolidate.md +3 -3
- package/framework/tasks/qa-planning-coverage-plan.md +2 -2
- package/framework/tasks/qa-planning-gate-define.md +4 -4
- package/framework/tasks/qa-planning-risk-matrix.md +4 -4
- package/framework/tasks/qa-planning-test-strategy.md +2 -2
- package/node_modules/@chati/browser-capability/src/index.js +12 -2
- package/node_modules/@chati/planning/src/index.js +24 -4
- package/node_modules/@chati/provider-registry/src/index.js +11 -0
- package/node_modules/@chati/rail/src/index.js +1967 -83
- package/node_modules/@chati/release-lane/README.md +12 -8
- package/node_modules/@chati/release-lane/package.json +1 -1
- package/node_modules/@chati/release-lane/src/index.js +1426 -58
- package/node_modules/@chati/review-council/src/index.js +63 -0
- package/node_modules/@chati/tracking-clickup/README.md +13 -0
- package/node_modules/@chati/tracking-clickup/src/index.js +690 -30
- package/package-artifact-manifest.json +1 -0
- package/package-artifact-manifest.sig +1 -0
- package/package.json +16 -7
- package/scripts/verify-real-harness-e2e.js +1581 -0
- package/src/config/framework-adapter.js +4 -4
- package/src/installer/core.js +148 -53
- package/src/installer/manifest.js +140 -9
- package/src/installer/package-artifact.js +249 -0
- package/src/installer/templates.js +65 -20
- package/src/installer-v2/catalog-client.js +531 -23
- package/src/installer-v2/index.js +91 -34
- package/src/installer-v2/installation-authority.js +327 -0
- package/src/installer-v2/provider-executable.js +235 -0
- package/src/installer-v2/wizard-installation.js +1 -1
- package/src/orchestrator/browser-runtime.js +44 -13
- package/src/orchestrator/cli.js +1545 -174
- package/src/orchestrator/clickup-projection.js +43 -1
- package/src/orchestrator/clickup-runtime.js +355 -39
- package/src/orchestrator/doctor.js +16 -3
- package/src/orchestrator/index.js +16 -0
- package/src/orchestrator/planning-runtime.js +20 -2
- package/src/orchestrator/rail-adjudication-evidence.js +234 -0
- package/src/orchestrator/rail-evidence-authority.js +147 -0
- package/src/orchestrator/rail-execution-evidence.js +45 -0
- package/src/orchestrator/rail-runtime.js +836 -56
- package/src/orchestrator/release-runtime.js +65 -6
- package/src/orchestrator/review-runtime.js +186 -41
- package/src/orchestrator/runtime-installation-v2.js +236 -20
- package/src/orchestrator/session-manager.js +1167 -53
- package/src/terminal/adapters/claude-adapter.js +3 -1
- package/src/terminal/adapters/codex-adapter.js +2 -0
- package/src/terminal/adapters/grok-adapter.js +7 -4
- package/src/terminal/handoff-parser.js +19 -1
- package/src/terminal/prompt-builder.js +9 -1
- package/src/terminal/provider-preflight.js +36 -3
- package/src/terminal/rail-execution-worktree.js +213 -0
- package/src/terminal/rail-prompts.js +169 -0
- package/src/terminal/rail-readonly-workspace.js +324 -0
- package/src/terminal/run-agent.js +384 -24
- package/src/terminal/run-parallel.js +5 -0
- package/src/terminal/run-rail-adjudication.js +323 -0
- package/src/terminal/run-rail-review.js +291 -0
- package/src/terminal/run-rail-rework.js +325 -0
- package/src/terminal/run-rail-task.js +380 -0
- package/src/terminal/run-team.js +5 -0
- package/src/terminal/spawner.js +1225 -77
- package/src/wizard/index.js +3 -2
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { createPublicKey, verify as cryptoVerify } from 'node:crypto';
|
|
2
|
-
import {
|
|
3
|
-
|
|
1
|
+
import { createPublicKey, randomUUID, verify as cryptoVerify } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
closeSync, constants, fstatSync, fsyncSync, linkSync, lstatSync,
|
|
4
|
+
mkdirSync, openSync, readFileSync, readSync, realpathSync, renameSync, unlinkSync, writeSync,
|
|
5
|
+
} from 'node:fs';
|
|
6
|
+
import { basename, dirname, join, parse, relative, resolve, sep } from 'node:path';
|
|
4
7
|
import { isIP } from 'node:net';
|
|
5
8
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { canonicalSerialize, ContractError } from '@chati/core';
|
|
9
|
+
import { canonicalize, canonicalSerialize, ContractError, sha256 } from '@chati/core';
|
|
7
10
|
import { validateCapabilitySnapshot } from '@chati/provider-registry';
|
|
8
11
|
|
|
9
12
|
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
@@ -13,11 +16,406 @@ export const CATALOG_CACHE_PATH = '.chati/v2/cache/model-catalog.json';
|
|
|
13
16
|
export const BUNDLED_CATALOG_PATH = join(moduleDir, 'model-catalog.json');
|
|
14
17
|
export const BUNDLED_CATALOG_SIGNATURE_PATH = join(moduleDir, 'model-catalog.sig');
|
|
15
18
|
const PUBLIC_KEY_PATH = join(moduleDir, '..', 'installer', 'signing-public-key.pem');
|
|
19
|
+
const INSTALLATION_MAX_BYTES = 4 * 1024 * 1024;
|
|
20
|
+
const CATALOG_CACHE_MAX_BYTES = 4 * 1024 * 1024;
|
|
21
|
+
const INSTALLATION_STORAGE_SECURITY_TEST_HOOKS = Symbol.for('chati.installation-storage.security-test-hooks');
|
|
22
|
+
|
|
23
|
+
function runInstallationStorageSecurityTestHook(name, details) {
|
|
24
|
+
const hook = globalThis[INSTALLATION_STORAGE_SECURITY_TEST_HOOKS]?.[name];
|
|
25
|
+
if (typeof hook === 'function') hook(details);
|
|
26
|
+
}
|
|
16
27
|
|
|
17
28
|
function catalogError(code, message, details = {}) {
|
|
18
29
|
return new ContractError(code, message, details);
|
|
19
30
|
}
|
|
20
31
|
|
|
32
|
+
function sameIdentity(left, right) {
|
|
33
|
+
return left?.dev === right?.dev && left?.ino === right?.ino;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function sameSnapshot(left, right) {
|
|
37
|
+
return sameIdentity(left, right)
|
|
38
|
+
&& left?.size === right?.size
|
|
39
|
+
&& left?.mode === right?.mode
|
|
40
|
+
&& left?.nlink === right?.nlink
|
|
41
|
+
&& left?.mtimeNs === right?.mtimeNs
|
|
42
|
+
&& left?.ctimeNs === right?.ctimeNs;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function descriptorDirectoryPath(descriptor) {
|
|
46
|
+
return process.platform === 'linux' ? `/proc/self/fd/${descriptor}` : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function linuxDescriptorAnchor(absolute) {
|
|
50
|
+
if (process.platform !== 'linux') return null;
|
|
51
|
+
const match = absolute.match(/^\/proc\/self\/fd\/([1-9][0-9]*)(?:\/(.*))?$/);
|
|
52
|
+
if (!match) return null;
|
|
53
|
+
const descriptor = Number(match[1]);
|
|
54
|
+
let stat;
|
|
55
|
+
try { stat = fstatSync(descriptor); }
|
|
56
|
+
catch { throw catalogError('UNSAFE_INSTALLATION_DIRECTORY', 'installation storage directory anchor is not open'); }
|
|
57
|
+
if (!stat.isDirectory()) {
|
|
58
|
+
throw catalogError('UNSAFE_INSTALLATION_DIRECTORY', 'installation storage directory anchor must be a real directory');
|
|
59
|
+
}
|
|
60
|
+
return Object.freeze({ descriptor, segments: (match[2] || '').split(sep).filter(Boolean) });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function linuxWorkingDirectoryAnchor(absolute) {
|
|
64
|
+
if (process.platform !== 'linux') return null;
|
|
65
|
+
let descriptor;
|
|
66
|
+
try {
|
|
67
|
+
descriptor = openSync('.', constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
68
|
+
const workingDirectory = realpathSync(`/proc/self/fd/${descriptor}`);
|
|
69
|
+
const remainder = relative(workingDirectory, absolute);
|
|
70
|
+
if (remainder === '..' || remainder.startsWith(`..${sep}`)) {
|
|
71
|
+
closeSync(descriptor);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
return Object.freeze({ descriptor, segments: remainder.split(sep).filter(Boolean) });
|
|
75
|
+
} catch {
|
|
76
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function openPinnedInstallationDirectory(path, { create = false, allowDirectDirectoryFallback = false } = {}) {
|
|
82
|
+
const absolute = resolve(path);
|
|
83
|
+
const root = parse(absolute).root;
|
|
84
|
+
const descriptorAnchor = linuxDescriptorAnchor(absolute);
|
|
85
|
+
const workingDirectoryAnchor = descriptorAnchor ? null : linuxWorkingDirectoryAnchor(absolute);
|
|
86
|
+
const anchor = descriptorAnchor || workingDirectoryAnchor;
|
|
87
|
+
const segments = anchor ? anchor.segments : absolute.slice(root.length).split(sep).filter(Boolean);
|
|
88
|
+
if (process.platform === 'linux') {
|
|
89
|
+
let descriptor;
|
|
90
|
+
let directDirectoryFallback = false;
|
|
91
|
+
try {
|
|
92
|
+
if (anchor) {
|
|
93
|
+
descriptor = openSync(descriptorDirectoryPath(anchor.descriptor), constants.O_RDONLY | (constants.O_DIRECTORY || 0));
|
|
94
|
+
} else {
|
|
95
|
+
try {
|
|
96
|
+
descriptor = openSync(root, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
97
|
+
} catch (error) {
|
|
98
|
+
// A strict sandbox may grant a pre-authorized external directory
|
|
99
|
+
// without granting the root descriptor needed for a normal
|
|
100
|
+
// component-by-component traversal. Keep this narrowly opt-in: the
|
|
101
|
+
// final directory remains O_NOFOLLOW-pinned, and malformed chains
|
|
102
|
+
// never take the fallback.
|
|
103
|
+
if (!allowDirectDirectoryFallback || error?.code !== 'EACCES') throw error;
|
|
104
|
+
descriptor = openSync(absolute, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
105
|
+
directDirectoryFallback = true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} finally {
|
|
109
|
+
if (workingDirectoryAnchor) closeSync(workingDirectoryAnchor.descriptor);
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
for (const segment of directDirectoryFallback ? [] : segments) {
|
|
113
|
+
const parentPath = descriptorDirectoryPath(descriptor);
|
|
114
|
+
const childPath = join(parentPath, segment);
|
|
115
|
+
let childDescriptor;
|
|
116
|
+
try {
|
|
117
|
+
childDescriptor = openSync(childPath, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (error?.code !== 'ENOENT' || !create) {
|
|
120
|
+
if (['ELOOP', 'ENOTDIR'].includes(error?.code)) {
|
|
121
|
+
throw catalogError('UNSAFE_INSTALLATION_DIRECTORY', 'installation storage directory chain must contain only real directories');
|
|
122
|
+
}
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
try { mkdirSync(childPath, { mode: 0o700 }); }
|
|
126
|
+
catch (mkdirError) { if (mkdirError?.code !== 'EEXIST') throw mkdirError; }
|
|
127
|
+
childDescriptor = openSync(childPath, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
128
|
+
}
|
|
129
|
+
const childStat = fstatSync(childDescriptor);
|
|
130
|
+
if (!childStat.isDirectory()) {
|
|
131
|
+
closeSync(childDescriptor);
|
|
132
|
+
throw catalogError('UNSAFE_INSTALLATION_DIRECTORY', 'installation storage directory chain must contain only real directories');
|
|
133
|
+
}
|
|
134
|
+
closeSync(descriptor);
|
|
135
|
+
descriptor = childDescriptor;
|
|
136
|
+
}
|
|
137
|
+
return Object.freeze({
|
|
138
|
+
absolute,
|
|
139
|
+
descriptor,
|
|
140
|
+
identity: fstatSync(descriptor),
|
|
141
|
+
reference: descriptorDirectoryPath(descriptor),
|
|
142
|
+
});
|
|
143
|
+
} catch (error) {
|
|
144
|
+
closeSync(descriptor);
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
let current = root;
|
|
149
|
+
for (const segment of segments) {
|
|
150
|
+
current = resolve(current, segment);
|
|
151
|
+
let stat;
|
|
152
|
+
try { stat = lstatSync(current); }
|
|
153
|
+
catch (error) {
|
|
154
|
+
if (error?.code !== 'ENOENT' || !create) throw error;
|
|
155
|
+
try { mkdirSync(current, { mode: 0o700 }); }
|
|
156
|
+
catch (mkdirError) { if (mkdirError?.code !== 'EEXIST') throw mkdirError; }
|
|
157
|
+
stat = lstatSync(current);
|
|
158
|
+
}
|
|
159
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw catalogError('UNSAFE_INSTALLATION_DIRECTORY', 'installation storage directory chain must contain only real directories');
|
|
160
|
+
}
|
|
161
|
+
const descriptor = openSync(absolute, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
162
|
+
return Object.freeze({ absolute, descriptor, identity: fstatSync(descriptor), reference: absolute });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function assertPinnedInstallationDirectory(directory) {
|
|
166
|
+
const current = fstatSync(directory.descriptor);
|
|
167
|
+
if (!current.isDirectory() || !sameIdentity(current, directory.identity)) {
|
|
168
|
+
throw catalogError('UNSAFE_INSTALLATION_DIRECTORY', 'installation storage directory descriptor changed');
|
|
169
|
+
}
|
|
170
|
+
if (directory.reference === directory.absolute) {
|
|
171
|
+
const pathStat = lstatSync(directory.absolute);
|
|
172
|
+
if (pathStat.isSymbolicLink() || !pathStat.isDirectory() || !sameIdentity(pathStat, directory.identity)) {
|
|
173
|
+
throw catalogError('UNSAFE_INSTALLATION_DIRECTORY', 'installation storage directory changed during the operation');
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function installationEntryPath(directory, name) {
|
|
179
|
+
if (basename(name) !== name || name === '.' || name === '..') {
|
|
180
|
+
throw catalogError('UNSAFE_INSTALLATION_FILE', 'installation storage entry must be a single path segment');
|
|
181
|
+
}
|
|
182
|
+
return join(directory.reference, name);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function readSafeInstallationEntry(directory, name, {
|
|
186
|
+
allowMissing = false,
|
|
187
|
+
unsafeCode = 'UNSAFE_INSTALLATION_FILE',
|
|
188
|
+
sizeCode = 'INSTALLATION_ARTIFACT_SIZE_LIMIT',
|
|
189
|
+
label = 'installation artifact',
|
|
190
|
+
maxBytes = INSTALLATION_MAX_BYTES,
|
|
191
|
+
encoding = 'utf8',
|
|
192
|
+
} = {}) {
|
|
193
|
+
let descriptor;
|
|
194
|
+
const path = installationEntryPath(directory, name);
|
|
195
|
+
try {
|
|
196
|
+
descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (allowMissing && error?.code === 'ENOENT') return null;
|
|
199
|
+
if (['ELOOP', 'ENXIO', 'EISDIR'].includes(error?.code)) {
|
|
200
|
+
throw catalogError(unsafeCode, `${label} must not be a symlink, FIFO or directory`);
|
|
201
|
+
}
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
const stat = fstatSync(descriptor, { bigint: true });
|
|
206
|
+
if (!stat.isFile() || stat.nlink !== 1n) {
|
|
207
|
+
throw catalogError(unsafeCode, `${label} must be a regular single-link file`);
|
|
208
|
+
}
|
|
209
|
+
if (stat.size > BigInt(maxBytes)) {
|
|
210
|
+
throw catalogError(sizeCode, `${label} exceeds the 4 MiB safety limit`);
|
|
211
|
+
}
|
|
212
|
+
runInstallationStorageSecurityTestHook('afterFileStat', { path, label, maxBytes });
|
|
213
|
+
const chunks = [];
|
|
214
|
+
let total = 0;
|
|
215
|
+
while (true) {
|
|
216
|
+
const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes + 1 - total));
|
|
217
|
+
const bytesRead = readSync(descriptor, chunk, 0, chunk.length, null);
|
|
218
|
+
if (bytesRead === 0) break;
|
|
219
|
+
total += bytesRead;
|
|
220
|
+
if (total > maxBytes) throw catalogError(sizeCode, `${label} exceeds the 4 MiB safety limit`);
|
|
221
|
+
chunks.push(chunk.subarray(0, bytesRead));
|
|
222
|
+
}
|
|
223
|
+
const after = fstatSync(descriptor, { bigint: true });
|
|
224
|
+
if (!sameSnapshot(stat, after)) {
|
|
225
|
+
throw catalogError('INSTALLATION_STORAGE_CONFLICT', `${label} changed while it was being read`);
|
|
226
|
+
}
|
|
227
|
+
const bytes = Buffer.concat(chunks, total);
|
|
228
|
+
return { content: encoding === null ? bytes : bytes.toString(encoding), stat };
|
|
229
|
+
} finally { closeSync(descriptor); }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function readSafeInstallationFile(path, options = {}) {
|
|
233
|
+
let directory;
|
|
234
|
+
try {
|
|
235
|
+
directory = openPinnedInstallationDirectory(dirname(path), {
|
|
236
|
+
allowDirectDirectoryFallback: options.allowDirectDirectoryFallback === true,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
if (options.allowMissing && error?.code === 'ENOENT') return null;
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
try { return readSafeInstallationEntry(directory, basename(path), options); }
|
|
244
|
+
finally { closeSync(directory.descriptor); }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function writeAll(descriptor, bytes) {
|
|
248
|
+
let offset = 0;
|
|
249
|
+
while (offset < bytes.length) offset += writeSync(descriptor, bytes, offset, bytes.length - offset);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function persistImmutableInstallationBackup(path, content, {
|
|
253
|
+
conflictCode = 'INSTALLATION_BACKUP_CONFLICT',
|
|
254
|
+
label = 'installation backup',
|
|
255
|
+
maxBytes = INSTALLATION_MAX_BYTES,
|
|
256
|
+
} = {}) {
|
|
257
|
+
const bytes = Buffer.isBuffer(content) ? content : Buffer.from(content);
|
|
258
|
+
if (bytes.length > maxBytes) {
|
|
259
|
+
throw catalogError('INSTALLATION_ARTIFACT_SIZE_LIMIT', `${label} exceeds the 4 MiB safety limit`);
|
|
260
|
+
}
|
|
261
|
+
const directory = openPinnedInstallationDirectory(dirname(path), { create: true });
|
|
262
|
+
const name = basename(path);
|
|
263
|
+
const existing = readSafeInstallationEntry(directory, name, { allowMissing: true, encoding: null, label, maxBytes });
|
|
264
|
+
if (existing !== null) {
|
|
265
|
+
closeSync(directory.descriptor);
|
|
266
|
+
if (!existing.content.equals(bytes)) throw catalogError(conflictCode, `${label} exists with different material`);
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
const temporaryName = `${name}.${process.pid}.${randomUUID()}.tmp`;
|
|
270
|
+
const temporary = installationEntryPath(directory, temporaryName);
|
|
271
|
+
const destination = installationEntryPath(directory, name);
|
|
272
|
+
let temporaryDescriptor;
|
|
273
|
+
try {
|
|
274
|
+
temporaryDescriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
|
|
275
|
+
writeAll(temporaryDescriptor, bytes);
|
|
276
|
+
fsyncSync(temporaryDescriptor);
|
|
277
|
+
closeSync(temporaryDescriptor);
|
|
278
|
+
temporaryDescriptor = undefined;
|
|
279
|
+
runInstallationStorageSecurityTestHook('beforeImmutableCommit', { path, directory: directory.absolute });
|
|
280
|
+
assertPinnedInstallationDirectory(directory);
|
|
281
|
+
try { linkSync(temporary, destination); }
|
|
282
|
+
catch (error) {
|
|
283
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
284
|
+
const raced = readSafeInstallationEntry(directory, name, { encoding: null, label, maxBytes });
|
|
285
|
+
if (!raced.content.equals(bytes)) throw catalogError(conflictCode, `${label} raced with different material`);
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
return true;
|
|
289
|
+
} finally {
|
|
290
|
+
if (temporaryDescriptor !== undefined) closeSync(temporaryDescriptor);
|
|
291
|
+
try { unlinkSync(temporary); } catch { /* preserve the primary result */ }
|
|
292
|
+
fsyncSync(directory.descriptor);
|
|
293
|
+
closeSync(directory.descriptor);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function replaceInstallationArtifact(path, content, source) {
|
|
298
|
+
const bytes = Buffer.from(content);
|
|
299
|
+
if (bytes.length > INSTALLATION_MAX_BYTES) {
|
|
300
|
+
throw catalogError('INSTALLATION_ARTIFACT_SIZE_LIMIT', 'upgraded installation artifact exceeds the 4 MiB safety limit');
|
|
301
|
+
}
|
|
302
|
+
const directory = openPinnedInstallationDirectory(dirname(path));
|
|
303
|
+
const name = basename(path);
|
|
304
|
+
const current = readSafeInstallationEntry(directory, name);
|
|
305
|
+
if (!sameIdentity(current.stat, source.stat) || current.content !== source.content) {
|
|
306
|
+
closeSync(directory.descriptor);
|
|
307
|
+
throw catalogError('INSTALLATION_STORAGE_CONFLICT', 'installation artifact changed before atomic upgrade');
|
|
308
|
+
}
|
|
309
|
+
const temporaryName = `${name}.${process.pid}.${randomUUID()}.tmp`;
|
|
310
|
+
const temporary = installationEntryPath(directory, temporaryName);
|
|
311
|
+
const destination = installationEntryPath(directory, name);
|
|
312
|
+
let temporaryDescriptor;
|
|
313
|
+
try {
|
|
314
|
+
temporaryDescriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
|
|
315
|
+
writeAll(temporaryDescriptor, bytes);
|
|
316
|
+
fsyncSync(temporaryDescriptor);
|
|
317
|
+
closeSync(temporaryDescriptor);
|
|
318
|
+
temporaryDescriptor = undefined;
|
|
319
|
+
runInstallationStorageSecurityTestHook('beforeReplacementCommit', { path, directory: directory.absolute });
|
|
320
|
+
assertPinnedInstallationDirectory(directory);
|
|
321
|
+
const currentArtifact = readSafeInstallationEntry(directory, name);
|
|
322
|
+
if (!sameIdentity(currentArtifact.stat, source.stat) || currentArtifact.content !== source.content) {
|
|
323
|
+
throw catalogError('INSTALLATION_STORAGE_CONFLICT', 'installation storage changed during atomic upgrade');
|
|
324
|
+
}
|
|
325
|
+
renameSync(temporary, destination);
|
|
326
|
+
fsyncSync(directory.descriptor);
|
|
327
|
+
} finally {
|
|
328
|
+
if (temporaryDescriptor !== undefined) closeSync(temporaryDescriptor);
|
|
329
|
+
try { unlinkSync(temporary); } catch { /* preserve the primary result */ }
|
|
330
|
+
closeSync(directory.descriptor);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function readSafeInstallationStorageFile(path, options = {}) {
|
|
335
|
+
return readSafeInstallationFile(path, options);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function persistImmutableInstallationFile(path, content, options = {}) {
|
|
339
|
+
return persistImmutableInstallationBackup(path, content, options);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function replaceInstallationStorageFile(path, content, source) {
|
|
343
|
+
return replaceInstallationArtifact(path, content, source);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function readInstallationArtifactFile(projectDir, { allowMissing = true } = {}) {
|
|
347
|
+
const directory = resolve(projectDir, '.chati/v2');
|
|
348
|
+
return readSafeInstallationFile(join(directory, 'installation.json'), { allowMissing });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function readCatalogCacheFile(projectDir, { allowMissing = true } = {}) {
|
|
352
|
+
const directory = resolve(projectDir, '.chati/v2/cache');
|
|
353
|
+
return readSafeInstallationFile(join(directory, 'model-catalog.json'), {
|
|
354
|
+
allowMissing,
|
|
355
|
+
unsafeCode: 'UNSAFE_CATALOG_CACHE_FILE',
|
|
356
|
+
sizeCode: 'CATALOG_CACHE_SIZE_LIMIT',
|
|
357
|
+
label: 'catalog cache',
|
|
358
|
+
maxBytes: CATALOG_CACHE_MAX_BYTES,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function persistCatalogCache(projectDir, payload) {
|
|
363
|
+
const content = `${JSON.stringify(payload, null, 2)}\n`;
|
|
364
|
+
const bytes = Buffer.from(content);
|
|
365
|
+
if (bytes.length > CATALOG_CACHE_MAX_BYTES) {
|
|
366
|
+
throw catalogError('CATALOG_CACHE_SIZE_LIMIT', 'catalog cache exceeds the 4 MiB safety limit');
|
|
367
|
+
}
|
|
368
|
+
const path = resolve(projectDir, CATALOG_CACHE_PATH);
|
|
369
|
+
const directory = openPinnedInstallationDirectory(dirname(path), { create: true });
|
|
370
|
+
const name = basename(path);
|
|
371
|
+
const existing = readSafeInstallationEntry(directory, name, {
|
|
372
|
+
allowMissing: true,
|
|
373
|
+
unsafeCode: 'UNSAFE_CATALOG_CACHE_FILE',
|
|
374
|
+
sizeCode: 'CATALOG_CACHE_SIZE_LIMIT',
|
|
375
|
+
label: 'catalog cache',
|
|
376
|
+
maxBytes: CATALOG_CACHE_MAX_BYTES,
|
|
377
|
+
});
|
|
378
|
+
const temporaryName = `${name}.${process.pid}.${randomUUID()}.tmp`;
|
|
379
|
+
const temporary = installationEntryPath(directory, temporaryName);
|
|
380
|
+
const destination = installationEntryPath(directory, name);
|
|
381
|
+
let temporaryDescriptor;
|
|
382
|
+
try {
|
|
383
|
+
temporaryDescriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
|
|
384
|
+
writeAll(temporaryDescriptor, bytes);
|
|
385
|
+
fsyncSync(temporaryDescriptor);
|
|
386
|
+
closeSync(temporaryDescriptor);
|
|
387
|
+
temporaryDescriptor = undefined;
|
|
388
|
+
runInstallationStorageSecurityTestHook('beforeCatalogCacheCommit', { path, directory: directory.absolute });
|
|
389
|
+
assertPinnedInstallationDirectory(directory);
|
|
390
|
+
if (existing !== null) {
|
|
391
|
+
const current = readSafeInstallationEntry(directory, name, {
|
|
392
|
+
unsafeCode: 'UNSAFE_CATALOG_CACHE_FILE', sizeCode: 'CATALOG_CACHE_SIZE_LIMIT',
|
|
393
|
+
label: 'catalog cache', maxBytes: CATALOG_CACHE_MAX_BYTES,
|
|
394
|
+
});
|
|
395
|
+
if (!sameSnapshot(current.stat, existing.stat) || current.content !== existing.content) {
|
|
396
|
+
throw catalogError('CATALOG_CACHE_STORAGE_CONFLICT', 'catalog cache changed during atomic replacement');
|
|
397
|
+
}
|
|
398
|
+
} else {
|
|
399
|
+
try {
|
|
400
|
+
readSafeInstallationEntry(directory, name, {
|
|
401
|
+
unsafeCode: 'UNSAFE_CATALOG_CACHE_FILE', sizeCode: 'CATALOG_CACHE_SIZE_LIMIT',
|
|
402
|
+
label: 'catalog cache', maxBytes: CATALOG_CACHE_MAX_BYTES,
|
|
403
|
+
});
|
|
404
|
+
throw catalogError('CATALOG_CACHE_STORAGE_CONFLICT', 'catalog cache appeared during atomic creation');
|
|
405
|
+
} catch (error) {
|
|
406
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
renameSync(temporary, destination);
|
|
410
|
+
fsyncSync(directory.descriptor);
|
|
411
|
+
} finally {
|
|
412
|
+
if (temporaryDescriptor !== undefined) closeSync(temporaryDescriptor);
|
|
413
|
+
try { unlinkSync(temporary); } catch { /* preserve the primary result */ }
|
|
414
|
+
closeSync(directory.descriptor);
|
|
415
|
+
}
|
|
416
|
+
return CATALOG_CACHE_PATH;
|
|
417
|
+
}
|
|
418
|
+
|
|
21
419
|
function parseSignedPayload(value) {
|
|
22
420
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
23
421
|
throw catalogError('INVALID_SIGNED_CATALOG', 'signed catalog payload must be an object');
|
|
@@ -39,12 +437,22 @@ export function assertCatalogUrl(value) {
|
|
|
39
437
|
if (url.protocol !== 'https:' || url.username || url.password) throw catalogError('CATALOG_URL_INVALID', 'catalog URL must use HTTPS without embedded credentials');
|
|
40
438
|
const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
41
439
|
const ipKind = isIP(hostname);
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
440
|
+
const mappedIpv4 = (() => {
|
|
441
|
+
const dotted = hostname.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
|
|
442
|
+
if (dotted) return dotted[1];
|
|
443
|
+
const hexadecimal = hostname.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
444
|
+
if (!hexadecimal) return null;
|
|
445
|
+
const high = Number.parseInt(hexadecimal[1], 16);
|
|
446
|
+
const low = Number.parseInt(hexadecimal[2], 16);
|
|
447
|
+
return [high >>> 8, high & 0xff, low >>> 8, low & 0xff].join('.');
|
|
448
|
+
})();
|
|
449
|
+
const effectiveIpv4 = ipKind === 4 ? hostname : mappedIpv4;
|
|
450
|
+
const blockedIpv4 = effectiveIpv4 !== null && (/^10\./.test(effectiveIpv4)
|
|
451
|
+
|| /^127\./.test(effectiveIpv4)
|
|
452
|
+
|| /^169\.254\./.test(effectiveIpv4)
|
|
453
|
+
|| /^192\.168\./.test(effectiveIpv4)
|
|
454
|
+
|| /^172\.(1[6-9]|2\d|3[01])\./.test(effectiveIpv4)
|
|
455
|
+
|| /^0\./.test(effectiveIpv4));
|
|
48
456
|
const blockedIpv6 = ipKind === 6 && (hostname === '::1' || hostname === '::' || /^(fc|fd|fe8|fe9|fea|feb)/.test(hostname));
|
|
49
457
|
if (hostname === 'localhost' || hostname.endsWith('.localhost') || blockedIpv4 || blockedIpv6) {
|
|
50
458
|
throw catalogError('CATALOG_URL_FORBIDDEN', 'catalog URL may not target a loopback, link-local, or private host');
|
|
@@ -64,17 +472,117 @@ export function verifySignedCapabilityCatalog(payload, { clock = () => new Date(
|
|
|
64
472
|
return validateCapabilitySnapshot(catalog, { clock });
|
|
65
473
|
}
|
|
66
474
|
|
|
67
|
-
|
|
68
|
-
|
|
475
|
+
const PROJECTED_MODEL_FIELDS = Object.freeze([
|
|
476
|
+
'provider_id', 'model_id', 'actions', 'adjudication_priority',
|
|
477
|
+
'highest_reasoning_configuration', 'routing_priority', 'tier', 'natural_role',
|
|
478
|
+
]);
|
|
479
|
+
|
|
480
|
+
function projectCatalogModel(model) {
|
|
481
|
+
return Object.fromEntries(PROJECTED_MODEL_FIELDS
|
|
482
|
+
.filter((field) => model[field] !== undefined)
|
|
483
|
+
.map((field) => [field, model[field]]));
|
|
69
484
|
}
|
|
70
485
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
486
|
+
/**
|
|
487
|
+
* Proves that a wizard projection still matches a catalog signed by CHATI.
|
|
488
|
+
* Runtime never trusts a locally recomputed installation digest as catalog
|
|
489
|
+
* authority. The signed source may be the project cache or bundled release.
|
|
490
|
+
*/
|
|
491
|
+
export function verifyInstallationCatalogProvenance(projectDir, snapshot, { clock = () => new Date(), publicKeyPem } = {}) {
|
|
492
|
+
if (!snapshot?.source_catalog_ref) {
|
|
493
|
+
throw catalogError('CATALOG_PROVENANCE_REQUIRED', 'installation model projection requires a signed source catalog reference');
|
|
494
|
+
}
|
|
495
|
+
const payloads = [];
|
|
496
|
+
const cache = projectDir ? readCatalogCacheFile(projectDir) : null;
|
|
497
|
+
if (cache !== null) {
|
|
498
|
+
try { payloads.push(parseSignedPayload(JSON.parse(cache.content))); } catch { /* invalid cache cannot establish provenance */ }
|
|
499
|
+
}
|
|
500
|
+
try { payloads.push(readBundledPayload()); } catch { /* invalid bundle cannot establish provenance */ }
|
|
501
|
+
for (const payload of payloads) {
|
|
502
|
+
let catalog;
|
|
503
|
+
try { catalog = verifySignedCapabilityCatalog(payload, { clock, publicKeyPem }); } catch { continue; }
|
|
504
|
+
if (catalog.snapshot_id !== snapshot.source_catalog_ref
|
|
505
|
+
|| Date.parse(catalog.expires_at) !== Date.parse(snapshot.expires_at)) continue;
|
|
506
|
+
const valid = snapshot.models.every((selected) => {
|
|
507
|
+
const source = catalog.models.find((candidate) =>
|
|
508
|
+
candidate.provider_id === selected.provider_id && candidate.model_id === selected.model_id);
|
|
509
|
+
return source && canonicalSerialize(projectCatalogModel(source)) === canonicalSerialize(projectCatalogModel(selected));
|
|
510
|
+
});
|
|
511
|
+
if (valid) return true;
|
|
512
|
+
}
|
|
513
|
+
throw catalogError('CATALOG_PROVENANCE_INVALID', 'installation model projection does not match an available signed source catalog');
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function availableSignedPayloads(projectDir) {
|
|
517
|
+
const payloads = [];
|
|
518
|
+
const cache = projectDir ? readCatalogCacheFile(projectDir) : null;
|
|
519
|
+
if (cache !== null) {
|
|
520
|
+
try { payloads.push(parseSignedPayload(JSON.parse(cache.content))); } catch { /* invalid cache is never authority */ }
|
|
521
|
+
}
|
|
522
|
+
try { payloads.push(readBundledPayload()); } catch { /* invalid bundle is never authority */ }
|
|
523
|
+
return payloads;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function projectionMatchesCatalog(snapshot, catalog) {
|
|
527
|
+
if (Date.parse(catalog.expires_at) !== Date.parse(snapshot.expires_at)) return false;
|
|
528
|
+
return snapshot.models.every((selected) => {
|
|
529
|
+
const source = catalog.models.find((candidate) =>
|
|
530
|
+
candidate.provider_id === selected.provider_id && candidate.model_id === selected.model_id);
|
|
531
|
+
if (!source) return false;
|
|
532
|
+
return Object.entries(projectCatalogModel(selected)).every(([field, value]) =>
|
|
533
|
+
canonicalSerialize(source[field]) === canonicalSerialize(value));
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* One-time compatibility repair for early v2 wizard artifacts that projected
|
|
539
|
+
* a signed catalog but omitted source_catalog_ref. It never infers models from
|
|
540
|
+
* vendor APIs: the exact projection must match an available signed CHATI
|
|
541
|
+
* catalog. Original bytes are preserved before the atomic replacement.
|
|
542
|
+
*/
|
|
543
|
+
export function backfillInstallationCatalogProvenance(projectDir, artifact, {
|
|
544
|
+
clock = () => new Date(), publicKeyPem, source = null,
|
|
545
|
+
} = {}) {
|
|
546
|
+
if (artifact?.capability_snapshot?.source_catalog_ref) return Object.freeze(artifact);
|
|
547
|
+
if (!artifact || typeof artifact !== 'object' || Array.isArray(artifact)) {
|
|
548
|
+
throw catalogError('INVALID_INSTALLATION_ARTIFACT', 'legacy installation artifact must be an object');
|
|
549
|
+
}
|
|
550
|
+
const { digest: legacyDigest, installation_artifact_id, ...legacyCore } = artifact;
|
|
551
|
+
if (typeof legacyDigest !== 'string' || !/^[a-f0-9]{64}$/.test(legacyDigest)
|
|
552
|
+
|| sha256(legacyCore) !== legacyDigest) {
|
|
553
|
+
throw catalogError('INSTALLATION_ARTIFACT_DRIFT', 'legacy installation artifact digest does not match its content');
|
|
554
|
+
}
|
|
555
|
+
const catalog = availableSignedPayloads(projectDir).map((payload) => {
|
|
556
|
+
try { return verifySignedCapabilityCatalog(payload, { clock, publicKeyPem }); } catch { return null; }
|
|
557
|
+
}).filter(Boolean).find((candidate) => projectionMatchesCatalog(artifact?.capability_snapshot ?? {}, candidate));
|
|
558
|
+
if (!catalog) {
|
|
559
|
+
throw catalogError(
|
|
560
|
+
'CATALOG_PROVENANCE_UPGRADE_REQUIRED',
|
|
561
|
+
'legacy v2 installation cannot be matched to a signed catalog; run the installer with --reconfigure',
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
const upgradedCore = canonicalize({
|
|
565
|
+
...legacyCore,
|
|
566
|
+
capability_snapshot: { ...legacyCore.capability_snapshot, source_catalog_ref: catalog.snapshot_id },
|
|
567
|
+
});
|
|
568
|
+
const upgraded = canonicalize({
|
|
569
|
+
...upgradedCore,
|
|
570
|
+
installation_artifact_id,
|
|
571
|
+
digest: sha256(upgradedCore),
|
|
572
|
+
});
|
|
573
|
+
const artifactPath = join(projectDir, '.chati/v2/installation.json');
|
|
574
|
+
const original = source ?? readInstallationArtifactFile(projectDir, { allowMissing: false });
|
|
575
|
+
let originalArtifact;
|
|
576
|
+
try { originalArtifact = JSON.parse(original.content); }
|
|
577
|
+
catch { throw catalogError('INVALID_INSTALLATION_ARTIFACT', 'legacy installation backup source is not valid JSON'); }
|
|
578
|
+
if (sha256(originalArtifact) !== sha256(artifact)) {
|
|
579
|
+
throw catalogError('INSTALLATION_STORAGE_CONFLICT', 'legacy installation changed before catalog provenance upgrade');
|
|
580
|
+
}
|
|
581
|
+
const backupDir = join(projectDir, '.chati/v2/backups', `catalog-provenance-${legacyDigest}`);
|
|
582
|
+
const backupPath = join(backupDir, 'installation.json');
|
|
583
|
+
persistImmutableInstallationBackup(backupPath, original.content);
|
|
584
|
+
replaceInstallationArtifact(artifactPath, `${canonicalSerialize(upgraded)}\n`, original);
|
|
585
|
+
return Object.freeze(upgraded);
|
|
78
586
|
}
|
|
79
587
|
|
|
80
588
|
function readBundledPayload() {
|
|
@@ -124,10 +632,10 @@ export async function resolveCapabilityCatalog({
|
|
|
124
632
|
}
|
|
125
633
|
|
|
126
634
|
if (projectDir) {
|
|
127
|
-
const
|
|
128
|
-
if (
|
|
635
|
+
const cache = readCatalogCacheFile(projectDir);
|
|
636
|
+
if (cache !== null) {
|
|
129
637
|
try {
|
|
130
|
-
const payload =
|
|
638
|
+
const payload = parseSignedPayload(JSON.parse(cache.content));
|
|
131
639
|
const catalog = verifySignedCapabilityCatalog(payload, { clock, publicKeyPem });
|
|
132
640
|
candidates.push({ source: 'cache', cache_path: CATALOG_CACHE_PATH, payload, catalog });
|
|
133
641
|
} catch (error) {
|
|
@@ -154,7 +662,7 @@ export async function resolveCapabilityCatalog({
|
|
|
154
662
|
);
|
|
155
663
|
const selected = candidates[0];
|
|
156
664
|
const cache_path = selected.source === 'remote' && projectDir
|
|
157
|
-
?
|
|
665
|
+
? persistCatalogCache(projectDir, selected.payload)
|
|
158
666
|
: (selected.cache_path ?? null);
|
|
159
667
|
return Object.freeze({
|
|
160
668
|
source: selected.source,
|