@dharma-ai-labs/agent-fabric-skill-manager 0.1.2 → 0.2.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/dist/index.d.ts +50 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +455 -36
- package/dist/index.js.map +1 -1
- package/dist/index.test.js +392 -18
- package/dist/index.test.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -36,14 +36,43 @@ export function calculateBundleHash(bundle) {
|
|
|
36
36
|
return sha256(canonicalize(bundle));
|
|
37
37
|
}
|
|
38
38
|
export function verifySkillBundle(bundle, serverPublicKey, now = new Date()) {
|
|
39
|
-
if (bundle.
|
|
40
|
-
throw new Error('Skill bundle
|
|
39
|
+
if (bundle.schema !== 'dharma.skill-bundle/v2') {
|
|
40
|
+
throw new Error('Skill bundle schema is not authorized for installation or task execution.');
|
|
41
|
+
}
|
|
42
|
+
if (bundle.expiresAt) {
|
|
43
|
+
const expiresAt = Date.parse(bundle.expiresAt);
|
|
44
|
+
if (!Number.isFinite(expiresAt))
|
|
45
|
+
throw new Error('Skill bundle expiry is invalid.');
|
|
46
|
+
if (expiresAt <= now.getTime())
|
|
47
|
+
throw new Error('Skill bundle has expired.');
|
|
48
|
+
}
|
|
41
49
|
const { signature, ...unsigned } = bundle;
|
|
42
50
|
if (!verifyCanonicalObject(unsigned, signature, serverPublicKey))
|
|
43
51
|
throw new Error('Skill bundle signature is invalid.');
|
|
44
52
|
const { bundleHash, ...hashInput } = unsigned;
|
|
45
53
|
if (bundleHash !== calculateBundleHash(hashInput))
|
|
46
54
|
throw new Error('Skill bundle hash is invalid.');
|
|
55
|
+
const selectors = bundle.targetSelectors;
|
|
56
|
+
if (!selectors || typeof selectors !== 'object'
|
|
57
|
+
|| !Array.isArray(selectors.organizationAgentIds) || !Array.isArray(selectors.deviceIds)
|
|
58
|
+
|| !Array.isArray(selectors.workspaceIds) || !Array.isArray(selectors.providers)
|
|
59
|
+
|| selectors.organizationAgentIds.some((value) => typeof value !== 'string' || !value)
|
|
60
|
+
|| selectors.deviceIds.some((value) => typeof value !== 'string' || !value)
|
|
61
|
+
|| selectors.workspaceIds.some((value) => typeof value !== 'string' || !value)
|
|
62
|
+
|| selectors.providers.some((value) => !['codex', 'claude', 'agy'].includes(value))) {
|
|
63
|
+
throw new Error('Skill bundle target selectors are invalid.');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function assertTargetSelector(selector, value, label) {
|
|
67
|
+
if (selector.length > 0 && !selector.includes(value)) {
|
|
68
|
+
throw new Error(`Skill bundle is not authorized for this ${label}.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function assertBundleTargetsEndpoint(bundle, endpoint) {
|
|
72
|
+
assertTargetSelector(bundle.targetSelectors.organizationAgentIds, endpoint.organizationAgentId, 'repository agent');
|
|
73
|
+
assertTargetSelector(bundle.targetSelectors.deviceIds, endpoint.deviceId, 'device');
|
|
74
|
+
assertTargetSelector(bundle.targetSelectors.workspaceIds, endpoint.workspaceId, 'workspace');
|
|
75
|
+
assertTargetSelector(bundle.targetSelectors.providers, endpoint.provider, 'provider');
|
|
47
76
|
}
|
|
48
77
|
async function runSmoke(commandId, policy, cwd) {
|
|
49
78
|
const command = resolveRegisteredCommand(policy, commandId);
|
|
@@ -60,15 +89,152 @@ async function runSmoke(commandId, policy, cwd) {
|
|
|
60
89
|
});
|
|
61
90
|
});
|
|
62
91
|
}
|
|
63
|
-
async function
|
|
92
|
+
async function readActiveBundlePointer(root) {
|
|
93
|
+
let bundleId;
|
|
64
94
|
try {
|
|
65
|
-
|
|
95
|
+
bundleId = (await readFile(resolve(root, 'ACTIVE_BUNDLE'), 'utf8')).trim();
|
|
66
96
|
}
|
|
67
97
|
catch (error) {
|
|
68
98
|
if (error.code === 'ENOENT')
|
|
69
99
|
return null;
|
|
70
100
|
throw error;
|
|
71
101
|
}
|
|
102
|
+
if (!bundleId)
|
|
103
|
+
return null;
|
|
104
|
+
let manifest;
|
|
105
|
+
try {
|
|
106
|
+
manifest = JSON.parse(await readFile(resolve(root, 'active', 'BUNDLE.json'), 'utf8'));
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (error.code === 'ENOENT') {
|
|
110
|
+
throw new Error('Active bundle manifest is missing for the installed bundle pointer.');
|
|
111
|
+
}
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
if (manifest.bundleId !== bundleId) {
|
|
115
|
+
throw new Error('Active bundle pointer does not match the installed release manifest.');
|
|
116
|
+
}
|
|
117
|
+
return bundleId;
|
|
118
|
+
}
|
|
119
|
+
function verifyInstallReceipt(receipt, devicePublicKey, expected, now) {
|
|
120
|
+
const { signature, ...signed } = receipt;
|
|
121
|
+
if (!verifyCanonicalObject(signed, signature, devicePublicKey))
|
|
122
|
+
throw new Error('Active bundle install receipt signature is invalid.');
|
|
123
|
+
const { receiptHash, ...hashInput } = signed;
|
|
124
|
+
if (receiptHash !== sha256(canonicalize(hashInput)))
|
|
125
|
+
throw new Error('Active bundle install receipt hash is invalid.');
|
|
126
|
+
if (receipt.status !== 'active')
|
|
127
|
+
throw new Error('Active bundle install receipt is not active.');
|
|
128
|
+
if (receipt.bundleId !== expected.bundleId || receipt.organizationId !== expected.organizationId
|
|
129
|
+
|| receipt.deviceId !== expected.deviceId || receipt.workspaceId !== expected.workspaceId || receipt.provider !== expected.provider) {
|
|
130
|
+
throw new Error('Active bundle install receipt does not match the selected endpoint.');
|
|
131
|
+
}
|
|
132
|
+
const startedAt = Date.parse(receipt.startedAt);
|
|
133
|
+
const completedAt = Date.parse(receipt.completedAt);
|
|
134
|
+
if (!Number.isFinite(startedAt) || !Number.isFinite(completedAt) || completedAt < startedAt || completedAt > now.getTime() + 5_000) {
|
|
135
|
+
throw new Error('Active bundle install receipt has an invalid activation window.');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function workspaceManagedRoot(nativeSkillDirectory, workspaceId) {
|
|
139
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(workspaceId)) {
|
|
140
|
+
throw new Error('Workspace ID is invalid for managed skill state.');
|
|
141
|
+
}
|
|
142
|
+
return resolve(nativeSkillDirectory, '.dharma-managed', 'workspaces', workspaceId);
|
|
143
|
+
}
|
|
144
|
+
export async function getLegacySkillBundleIdForUpgrade(input) {
|
|
145
|
+
const root = workspaceManagedRoot(input.nativeSkillDirectory, input.workspaceId);
|
|
146
|
+
await recoverInterruptedSkillRollbacks(input.nativeSkillDirectory, input.workspaceId);
|
|
147
|
+
const bundleId = await readActiveBundlePointer(root);
|
|
148
|
+
if (!bundleId)
|
|
149
|
+
return null;
|
|
150
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(bundleId)) {
|
|
151
|
+
throw new Error('Legacy skill bundle identifier is invalid.');
|
|
152
|
+
}
|
|
153
|
+
let authorization;
|
|
154
|
+
let source = 'authorization';
|
|
155
|
+
try {
|
|
156
|
+
authorization = JSON.parse(await readFile(resolve(root, 'active', 'AUTHORIZATION.json'), 'utf8'));
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
if (error.code !== 'ENOENT')
|
|
160
|
+
throw error;
|
|
161
|
+
source = 'legacy_manifest';
|
|
162
|
+
authorization = JSON.parse(await readFile(resolve(root, 'active', 'BUNDLE.json'), 'utf8'));
|
|
163
|
+
}
|
|
164
|
+
if (authorization.bundleId !== bundleId)
|
|
165
|
+
return null;
|
|
166
|
+
if (source === 'authorization') {
|
|
167
|
+
if (authorization.schema !== 'dharma.skill-bundle/v1')
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
else if (authorization.schema !== undefined
|
|
171
|
+
|| authorization.workspaceId !== input.workspaceId
|
|
172
|
+
|| !Array.isArray(authorization.skillIds)
|
|
173
|
+
|| !authorization.skillIds.every((skillId) => typeof skillId === 'string' && skillId.length > 0)) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
return bundleId;
|
|
177
|
+
}
|
|
178
|
+
export async function getInstalledSkillBundleIdForRecovery(input) {
|
|
179
|
+
const root = workspaceManagedRoot(input.nativeSkillDirectory, input.workspaceId);
|
|
180
|
+
await recoverInterruptedSkillRollbacks(input.nativeSkillDirectory, input.workspaceId);
|
|
181
|
+
const bundleId = await readActiveBundlePointer(root);
|
|
182
|
+
if (!bundleId)
|
|
183
|
+
return null;
|
|
184
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(bundleId)) {
|
|
185
|
+
throw new Error('Installed skill bundle identifier is invalid.');
|
|
186
|
+
}
|
|
187
|
+
let manifest;
|
|
188
|
+
let source = 'authorization';
|
|
189
|
+
try {
|
|
190
|
+
manifest = JSON.parse(await readFile(resolve(root, 'active', 'AUTHORIZATION.json'), 'utf8'));
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
if (error.code !== 'ENOENT')
|
|
194
|
+
throw error;
|
|
195
|
+
source = 'legacy-manifest';
|
|
196
|
+
manifest = JSON.parse(await readFile(resolve(root, 'active', 'BUNDLE.json'), 'utf8'));
|
|
197
|
+
}
|
|
198
|
+
const authorizedManifest = source === 'authorization'
|
|
199
|
+
&& ['dharma.skill-bundle/v1', 'dharma.skill-bundle/v2'].includes(String(manifest.schema));
|
|
200
|
+
const legacyInstallManifest = source === 'legacy-manifest'
|
|
201
|
+
&& manifest.schema === undefined
|
|
202
|
+
&& manifest.workspaceId === input.workspaceId
|
|
203
|
+
&& Array.isArray(manifest.skillIds)
|
|
204
|
+
&& manifest.skillIds.every((skillId) => typeof skillId === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(skillId));
|
|
205
|
+
if ((!authorizedManifest && !legacyInstallManifest) || manifest.bundleId !== bundleId) {
|
|
206
|
+
throw new Error('Installed skill recovery metadata is invalid.');
|
|
207
|
+
}
|
|
208
|
+
return bundleId;
|
|
209
|
+
}
|
|
210
|
+
export async function getExpiredSkillBundleAuthorizationForReplacement(input) {
|
|
211
|
+
const root = workspaceManagedRoot(input.nativeSkillDirectory, input.workspaceId);
|
|
212
|
+
await recoverInterruptedSkillRollbacks(input.nativeSkillDirectory, input.workspaceId);
|
|
213
|
+
const bundleId = await readActiveBundlePointer(root);
|
|
214
|
+
if (!bundleId)
|
|
215
|
+
return null;
|
|
216
|
+
const bundle = JSON.parse(await readFile(resolve(root, 'active', 'AUTHORIZATION.json'), 'utf8'));
|
|
217
|
+
const receipt = JSON.parse(await readFile(resolve(root, 'active', 'INSTALL_RECEIPT.json'), 'utf8'));
|
|
218
|
+
const now = input.now ?? new Date();
|
|
219
|
+
const expiresAt = Date.parse(String(bundle.expiresAt || ''));
|
|
220
|
+
if (!Number.isFinite(expiresAt) || expiresAt > now.getTime()) {
|
|
221
|
+
throw new Error('Installed skill bundle is not an expired replacement candidate.');
|
|
222
|
+
}
|
|
223
|
+
if (bundle.bundleId !== bundleId)
|
|
224
|
+
throw new Error('Active bundle pointer does not match the signed release authorization.');
|
|
225
|
+
verifySkillBundle(bundle, input.serverPublicKey, new Date(expiresAt - 1));
|
|
226
|
+
assertBundleTargetsEndpoint(bundle, input);
|
|
227
|
+
verifyInstallReceipt(receipt, input.devicePublicKey, {
|
|
228
|
+
bundleId,
|
|
229
|
+
organizationId: input.organizationId,
|
|
230
|
+
deviceId: input.deviceId,
|
|
231
|
+
workspaceId: input.workspaceId,
|
|
232
|
+
provider: input.provider,
|
|
233
|
+
}, now);
|
|
234
|
+
if (receipt.receiptHash !== input.expectedReceiptHash) {
|
|
235
|
+
throw new Error('Active bundle receipt is not the current protected authorization.');
|
|
236
|
+
}
|
|
237
|
+
return { bundleId, activatedAt: receipt.completedAt, expiresAt: bundle.expiresAt ?? null };
|
|
72
238
|
}
|
|
73
239
|
async function pathExists(path) {
|
|
74
240
|
try {
|
|
@@ -101,6 +267,10 @@ async function activateNativeSkills(input) {
|
|
|
101
267
|
const marker = resolve(target, '.dharma-agent-fabric.json');
|
|
102
268
|
if (!await pathExists(marker))
|
|
103
269
|
throw new Error(`Refusing to replace unmanaged provider skill: ${skillId}`);
|
|
270
|
+
const ownership = JSON.parse(await readFile(marker, 'utf8'));
|
|
271
|
+
if (typeof ownership.workspaceId === 'string' && ownership.workspaceId !== input.workspaceId) {
|
|
272
|
+
throw new Error(`Refusing to replace a provider skill managed by another workspace: ${skillId}`);
|
|
273
|
+
}
|
|
104
274
|
}
|
|
105
275
|
}
|
|
106
276
|
const transactionRoot = assertContained(input.nativeSkillDirectory, resolve(input.nativeSkillDirectory, `.dharma-activation-${randomUUID()}`));
|
|
@@ -115,7 +285,7 @@ async function activateNativeSkills(input) {
|
|
|
115
285
|
const source = assertContained(input.release, resolve(input.release, skillId));
|
|
116
286
|
const staged = assertContained(stagedRoot, resolve(stagedRoot, skillId));
|
|
117
287
|
await cp(source, staged, { recursive: true, errorOnExist: true, force: false });
|
|
118
|
-
await writeFile(resolve(staged, '.dharma-agent-fabric.json'), `${JSON.stringify({ bundleId: input.bundleId, skillId })}\n`, { mode: 0o600 });
|
|
288
|
+
await writeFile(resolve(staged, '.dharma-agent-fabric.json'), `${JSON.stringify({ bundleId: input.bundleId, skillId, workspaceId: input.workspaceId })}\n`, { mode: 0o600 });
|
|
119
289
|
}
|
|
120
290
|
for (const skillId of affectedSkillIds) {
|
|
121
291
|
const target = assertContained(input.nativeSkillDirectory, resolve(input.nativeSkillDirectory, skillId));
|
|
@@ -146,14 +316,110 @@ async function activateNativeSkills(input) {
|
|
|
146
316
|
await rm(transactionRoot, { recursive: true, force: true });
|
|
147
317
|
}
|
|
148
318
|
}
|
|
149
|
-
|
|
150
|
-
|
|
319
|
+
async function nativeSkillIdsOwnedByWorkspace(nativeSkillDirectory, workspaceId) {
|
|
320
|
+
let entries;
|
|
321
|
+
try {
|
|
322
|
+
entries = await readdir(nativeSkillDirectory, { withFileTypes: true });
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
if (error.code === 'ENOENT')
|
|
326
|
+
return [];
|
|
327
|
+
throw error;
|
|
328
|
+
}
|
|
329
|
+
const owned = [];
|
|
330
|
+
for (const entry of entries) {
|
|
331
|
+
if (!entry.isDirectory() || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(entry.name))
|
|
332
|
+
continue;
|
|
333
|
+
try {
|
|
334
|
+
const marker = JSON.parse(await readFile(resolve(nativeSkillDirectory, entry.name, '.dharma-agent-fabric.json'), 'utf8'));
|
|
335
|
+
if (marker.workspaceId === workspaceId)
|
|
336
|
+
owned.push(entry.name);
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
if (error.code !== 'ENOENT')
|
|
340
|
+
throw error;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return owned;
|
|
344
|
+
}
|
|
345
|
+
async function recoverInterruptedSkillRollbacks(nativeSkillDirectory, workspaceId) {
|
|
346
|
+
const managedRoot = workspaceManagedRoot(nativeSkillDirectory, workspaceId);
|
|
347
|
+
let entries;
|
|
348
|
+
try {
|
|
349
|
+
entries = await readdir(managedRoot, { withFileTypes: true });
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
if (error.code === 'ENOENT')
|
|
353
|
+
return;
|
|
354
|
+
throw error;
|
|
355
|
+
}
|
|
356
|
+
for (const entry of entries.filter((candidate) => candidate.isDirectory() && candidate.name.startsWith('.rollback-'))) {
|
|
357
|
+
const transactionRoot = assertContained(managedRoot, resolve(managedRoot, entry.name));
|
|
358
|
+
const manifest = JSON.parse(await readFile(resolve(transactionRoot, 'ROLLBACK_RECOVERY.json'), 'utf8'));
|
|
359
|
+
if (manifest.schema !== 'dharma.skill-rollback-recovery/v1'
|
|
360
|
+
|| manifest.workspaceId !== workspaceId
|
|
361
|
+
|| !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(manifest.currentBundleId)
|
|
362
|
+
|| !Array.isArray(manifest.currentSkillIds)
|
|
363
|
+
|| manifest.currentSkillIds.some((skillId) => typeof skillId !== 'string'
|
|
364
|
+
|| !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(skillId))) {
|
|
365
|
+
throw new Error('Interrupted skill rollback recovery metadata is invalid.');
|
|
366
|
+
}
|
|
367
|
+
const releases = resolve(managedRoot, 'releases');
|
|
368
|
+
const currentRelease = assertContained(releases, resolve(releases, manifest.currentBundleId));
|
|
369
|
+
const active = resolve(managedRoot, 'active');
|
|
370
|
+
const backupActive = resolve(transactionRoot, 'backup-active');
|
|
371
|
+
if (await pathExists(backupActive)) {
|
|
372
|
+
await rm(active, { recursive: true, force: true });
|
|
373
|
+
await rename(backupActive, active);
|
|
374
|
+
}
|
|
375
|
+
else if (!await pathExists(active)) {
|
|
376
|
+
await cp(currentRelease, active, { recursive: true, errorOnExist: true, force: false });
|
|
377
|
+
}
|
|
378
|
+
await activateNativeSkills({
|
|
379
|
+
nativeSkillDirectory,
|
|
380
|
+
release: currentRelease,
|
|
381
|
+
bundleId: manifest.currentBundleId,
|
|
382
|
+
workspaceId,
|
|
383
|
+
skillIds: manifest.currentSkillIds,
|
|
384
|
+
removeSkillIds: await nativeSkillIdsOwnedByWorkspace(nativeSkillDirectory, workspaceId),
|
|
385
|
+
});
|
|
386
|
+
const pointerTemp = resolve(transactionRoot, 'ACTIVE_BUNDLE.recovery');
|
|
387
|
+
await writeFile(pointerTemp, `${manifest.currentBundleId}\n`, { mode: 0o600 });
|
|
388
|
+
await rename(pointerTemp, resolve(managedRoot, 'ACTIVE_BUNDLE'));
|
|
389
|
+
await rm(transactionRoot, { recursive: true, force: true });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
export async function getActiveSkillBundleAuthorization(input) {
|
|
393
|
+
const root = workspaceManagedRoot(input.nativeSkillDirectory, input.workspaceId);
|
|
394
|
+
await recoverInterruptedSkillRollbacks(input.nativeSkillDirectory, input.workspaceId);
|
|
395
|
+
const bundleId = await readActiveBundlePointer(root);
|
|
396
|
+
if (!bundleId)
|
|
397
|
+
return null;
|
|
398
|
+
const bundle = JSON.parse(await readFile(resolve(root, 'active', 'AUTHORIZATION.json'), 'utf8'));
|
|
399
|
+
const receipt = JSON.parse(await readFile(resolve(root, 'active', 'INSTALL_RECEIPT.json'), 'utf8'));
|
|
400
|
+
const now = input.now ?? new Date();
|
|
401
|
+
if (bundle.bundleId !== bundleId)
|
|
402
|
+
throw new Error('Active bundle pointer does not match the signed release authorization.');
|
|
403
|
+
verifySkillBundle(bundle, input.serverPublicKey, now);
|
|
404
|
+
assertBundleTargetsEndpoint(bundle, input);
|
|
405
|
+
verifyInstallReceipt(receipt, input.devicePublicKey, {
|
|
406
|
+
bundleId,
|
|
407
|
+
organizationId: input.organizationId,
|
|
408
|
+
deviceId: input.deviceId,
|
|
409
|
+
workspaceId: input.workspaceId,
|
|
410
|
+
provider: input.provider,
|
|
411
|
+
}, now);
|
|
412
|
+
if (receipt.receiptHash !== input.expectedReceiptHash) {
|
|
413
|
+
throw new Error('Active bundle receipt is not the current protected authorization.');
|
|
414
|
+
}
|
|
415
|
+
return { bundleId, activatedAt: receipt.completedAt, expiresAt: bundle.expiresAt ?? null };
|
|
151
416
|
}
|
|
152
417
|
export async function installSkillBundle(input) {
|
|
153
418
|
const startedAt = new Date().toISOString();
|
|
154
419
|
verifySkillBundle(input.bundle, input.serverPublicKey);
|
|
155
420
|
if (input.bundle.organizationId !== input.policy.organizationId)
|
|
156
421
|
throw new Error('Bundle organization does not match policy.');
|
|
422
|
+
assertBundleTargetsEndpoint(input.bundle, input);
|
|
157
423
|
if (input.bundle.operation === 'clear' && input.bundle.skills.length !== 0)
|
|
158
424
|
throw new Error('Clear bundles cannot contain skills.');
|
|
159
425
|
if (input.bundle.operation === 'install' && input.bundle.skills.length === 0)
|
|
@@ -163,12 +429,13 @@ export async function installSkillBundle(input) {
|
|
|
163
429
|
}
|
|
164
430
|
if (!input.policy.skills.automaticInstall)
|
|
165
431
|
throw new Error('Automatic skill installation is disabled by policy.');
|
|
166
|
-
const managedRoot =
|
|
432
|
+
const managedRoot = workspaceManagedRoot(input.nativeSkillDirectory, input.workspaceId);
|
|
433
|
+
await recoverInterruptedSkillRollbacks(input.nativeSkillDirectory, input.workspaceId);
|
|
167
434
|
const releases = resolve(managedRoot, 'releases');
|
|
168
435
|
const release = assertContained(releases, resolve(releases, input.bundle.bundleId));
|
|
169
436
|
const active = resolve(managedRoot, 'active');
|
|
170
437
|
const rollback = resolve(managedRoot, 'rollback');
|
|
171
|
-
const previousBundleId = await
|
|
438
|
+
const previousBundleId = await readActiveBundlePointer(managedRoot);
|
|
172
439
|
await mkdir(releases, { recursive: true, mode: 0o700 });
|
|
173
440
|
await rm(release, { recursive: true, force: true });
|
|
174
441
|
await mkdir(release, { recursive: true, mode: 0o700 });
|
|
@@ -184,7 +451,8 @@ export async function installSkillBundle(input) {
|
|
|
184
451
|
checks.push({ name: `content:${skill.skillId}`, status: 'pass', details: actualHash });
|
|
185
452
|
}
|
|
186
453
|
const skillIds = input.bundle.skills.map((skill) => skill.skillId);
|
|
187
|
-
await writeFile(resolve(release, 'BUNDLE.json'), `${JSON.stringify({ bundleId: input.bundle.bundleId, skillIds })}\n`, { mode: 0o600 });
|
|
454
|
+
await writeFile(resolve(release, 'BUNDLE.json'), `${JSON.stringify({ bundleId: input.bundle.bundleId, workspaceId: input.workspaceId, skillIds })}\n`, { mode: 0o600 });
|
|
455
|
+
await writeFile(resolve(release, 'AUTHORIZATION.json'), `${JSON.stringify(input.bundle)}\n`, { mode: 0o600 });
|
|
188
456
|
const previousRelease = previousBundleId ? resolve(releases, previousBundleId) : null;
|
|
189
457
|
const previousSkillIds = previousRelease ? await releaseSkillIds(previousRelease) : [];
|
|
190
458
|
await rm(rollback, { recursive: true, force: true });
|
|
@@ -200,42 +468,52 @@ export async function installSkillBundle(input) {
|
|
|
200
468
|
nativeSkillDirectory: input.nativeSkillDirectory,
|
|
201
469
|
release,
|
|
202
470
|
bundleId: input.bundle.bundleId,
|
|
471
|
+
workspaceId: input.workspaceId,
|
|
203
472
|
skillIds,
|
|
204
473
|
removeSkillIds: previousSkillIds,
|
|
205
474
|
});
|
|
206
475
|
let status = 'active';
|
|
476
|
+
const restorePreviousBundle = async () => {
|
|
477
|
+
await rm(active, { recursive: true, force: true });
|
|
478
|
+
let restored = 'failed';
|
|
479
|
+
try {
|
|
480
|
+
await rename(rollback, active);
|
|
481
|
+
restored = 'rolled_back';
|
|
482
|
+
}
|
|
483
|
+
catch (error) {
|
|
484
|
+
if (error.code !== 'ENOENT')
|
|
485
|
+
throw error;
|
|
486
|
+
}
|
|
487
|
+
await activateNativeSkills({
|
|
488
|
+
nativeSkillDirectory: input.nativeSkillDirectory,
|
|
489
|
+
release: previousRelease || release,
|
|
490
|
+
bundleId: previousBundleId || input.bundle.bundleId,
|
|
491
|
+
workspaceId: input.workspaceId,
|
|
492
|
+
skillIds: previousBundleId ? previousSkillIds : [],
|
|
493
|
+
removeSkillIds: skillIds,
|
|
494
|
+
});
|
|
495
|
+
return restored;
|
|
496
|
+
};
|
|
207
497
|
if (input.smokeCommandId) {
|
|
208
498
|
const check = await runSmoke(input.smokeCommandId, input.policy, active);
|
|
209
499
|
checks.push({ name: `smoke:${input.smokeCommandId}`, ...check });
|
|
210
500
|
if (check.status === 'fail') {
|
|
211
|
-
await
|
|
212
|
-
try {
|
|
213
|
-
await rename(rollback, active);
|
|
214
|
-
status = 'rolled_back';
|
|
215
|
-
}
|
|
216
|
-
catch (error) {
|
|
217
|
-
if (error.code !== 'ENOENT')
|
|
218
|
-
throw error;
|
|
219
|
-
status = 'failed';
|
|
220
|
-
}
|
|
221
|
-
await activateNativeSkills({
|
|
222
|
-
nativeSkillDirectory: input.nativeSkillDirectory,
|
|
223
|
-
release: previousRelease || release,
|
|
224
|
-
bundleId: previousBundleId || input.bundle.bundleId,
|
|
225
|
-
skillIds: previousBundleId ? previousSkillIds : [],
|
|
226
|
-
removeSkillIds: skillIds,
|
|
227
|
-
});
|
|
501
|
+
status = await restorePreviousBundle();
|
|
228
502
|
}
|
|
229
503
|
}
|
|
230
504
|
if (status === 'active') {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
505
|
+
try {
|
|
506
|
+
verifySkillBundle(input.bundle, input.serverPublicKey);
|
|
507
|
+
checks.push({ name: 'authorization:activation-window', status: 'pass', details: input.bundle.expiresAt || 'no-expiry' });
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
checks.push({
|
|
511
|
+
name: 'authorization:activation-window',
|
|
512
|
+
status: 'fail',
|
|
513
|
+
details: error instanceof Error ? error.message : String(error),
|
|
514
|
+
});
|
|
515
|
+
status = await restorePreviousBundle();
|
|
516
|
+
}
|
|
239
517
|
}
|
|
240
518
|
const receiptBase = {
|
|
241
519
|
schema: 'dharma.install-receipt/v1',
|
|
@@ -255,7 +533,148 @@ export async function installSkillBundle(input) {
|
|
|
255
533
|
};
|
|
256
534
|
const receiptHash = sha256(canonicalize(receiptBase));
|
|
257
535
|
const signature = signCanonicalObject({ ...receiptBase, receiptHash }, input.devicePrivateKey);
|
|
258
|
-
|
|
536
|
+
const receipt = { ...receiptBase, receiptHash, signature };
|
|
537
|
+
if (status === 'active') {
|
|
538
|
+
try {
|
|
539
|
+
await writeFile(resolve(release, 'INSTALL_RECEIPT.json'), `${JSON.stringify(receipt)}\n`, { mode: 0o600 });
|
|
540
|
+
await writeFile(resolve(active, 'INSTALL_RECEIPT.json'), `${JSON.stringify(receipt)}\n`, { mode: 0o600 });
|
|
541
|
+
await writeFile(resolve(managedRoot, 'ACTIVE_BUNDLE'), `${input.bundle.bundleId}\n`, { mode: 0o600 });
|
|
542
|
+
}
|
|
543
|
+
catch (error) {
|
|
544
|
+
const recoveryErrors = [];
|
|
545
|
+
try {
|
|
546
|
+
await restorePreviousBundle();
|
|
547
|
+
}
|
|
548
|
+
catch (recoveryError) {
|
|
549
|
+
recoveryErrors.push(recoveryError);
|
|
550
|
+
}
|
|
551
|
+
try {
|
|
552
|
+
if (previousBundleId) {
|
|
553
|
+
await writeFile(resolve(managedRoot, 'ACTIVE_BUNDLE'), `${previousBundleId}\n`, { mode: 0o600 });
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
await rm(resolve(managedRoot, 'ACTIVE_BUNDLE'), { force: true });
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
catch (recoveryError) {
|
|
560
|
+
recoveryErrors.push(recoveryError);
|
|
561
|
+
}
|
|
562
|
+
if (recoveryErrors.length) {
|
|
563
|
+
throw new AggregateError([error, ...recoveryErrors], 'Skill receipt persistence failed and local recovery was incomplete.');
|
|
564
|
+
}
|
|
565
|
+
throw error;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
else if (previousBundleId) {
|
|
569
|
+
await writeFile(resolve(managedRoot, 'ACTIVE_BUNDLE'), `${previousBundleId}\n`, { mode: 0o600 });
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
await rm(resolve(managedRoot, 'ACTIVE_BUNDLE'), { force: true });
|
|
573
|
+
}
|
|
574
|
+
return receipt;
|
|
575
|
+
}
|
|
576
|
+
export async function rollbackUnconfirmedSkillBundle(input) {
|
|
577
|
+
if (input.receipt.status !== 'active')
|
|
578
|
+
return;
|
|
579
|
+
const managedRoot = workspaceManagedRoot(input.nativeSkillDirectory, input.workspaceId);
|
|
580
|
+
await recoverInterruptedSkillRollbacks(input.nativeSkillDirectory, input.workspaceId);
|
|
581
|
+
const activeBundleId = await readActiveBundlePointer(managedRoot);
|
|
582
|
+
if (activeBundleId !== input.receipt.bundleId) {
|
|
583
|
+
throw new Error('Refusing to roll back an installation that is no longer the active local bundle.');
|
|
584
|
+
}
|
|
585
|
+
const releases = resolve(managedRoot, 'releases');
|
|
586
|
+
const active = resolve(managedRoot, 'active');
|
|
587
|
+
const currentRelease = assertContained(releases, resolve(releases, input.receipt.bundleId));
|
|
588
|
+
const currentSkillIds = await releaseSkillIds(currentRelease);
|
|
589
|
+
const previousBundleId = input.receipt.previousBundleId;
|
|
590
|
+
const previousRelease = previousBundleId
|
|
591
|
+
? assertContained(releases, resolve(releases, previousBundleId))
|
|
592
|
+
: null;
|
|
593
|
+
const previousSkillIds = previousRelease ? await releaseSkillIds(previousRelease) : [];
|
|
594
|
+
const transactionRoot = assertContained(managedRoot, resolve(managedRoot, `.rollback-${randomUUID()}`));
|
|
595
|
+
const stagedActive = resolve(transactionRoot, 'staged-active');
|
|
596
|
+
const backupActive = resolve(transactionRoot, 'backup-active');
|
|
597
|
+
const pointer = resolve(managedRoot, 'ACTIVE_BUNDLE');
|
|
598
|
+
const pointerTemp = resolve(transactionRoot, 'ACTIVE_BUNDLE.next');
|
|
599
|
+
let nativeChanged = false;
|
|
600
|
+
let activeMoved = false;
|
|
601
|
+
let stagedMoved = false;
|
|
602
|
+
let preserveRecovery = false;
|
|
603
|
+
await mkdir(transactionRoot, { recursive: true, mode: 0o700 });
|
|
604
|
+
await writeFile(resolve(transactionRoot, 'ROLLBACK_RECOVERY.json'), `${JSON.stringify({
|
|
605
|
+
schema: 'dharma.skill-rollback-recovery/v1',
|
|
606
|
+
workspaceId: input.workspaceId,
|
|
607
|
+
currentBundleId: input.receipt.bundleId,
|
|
608
|
+
currentSkillIds,
|
|
609
|
+
})}\n`, { mode: 0o600 });
|
|
610
|
+
if (previousRelease) {
|
|
611
|
+
await cp(previousRelease, stagedActive, { recursive: true, errorOnExist: true, force: false });
|
|
612
|
+
}
|
|
613
|
+
try {
|
|
614
|
+
await activateNativeSkills({
|
|
615
|
+
nativeSkillDirectory: input.nativeSkillDirectory,
|
|
616
|
+
release: previousRelease || currentRelease,
|
|
617
|
+
bundleId: previousBundleId || input.receipt.bundleId,
|
|
618
|
+
workspaceId: input.workspaceId,
|
|
619
|
+
skillIds: previousSkillIds,
|
|
620
|
+
removeSkillIds: currentSkillIds,
|
|
621
|
+
});
|
|
622
|
+
nativeChanged = true;
|
|
623
|
+
await rename(active, backupActive);
|
|
624
|
+
activeMoved = true;
|
|
625
|
+
if (previousRelease) {
|
|
626
|
+
await rename(stagedActive, active);
|
|
627
|
+
stagedMoved = true;
|
|
628
|
+
await writeFile(pointerTemp, `${previousBundleId}\n`, { mode: 0o600 });
|
|
629
|
+
await rename(pointerTemp, pointer);
|
|
630
|
+
}
|
|
631
|
+
else {
|
|
632
|
+
await rm(pointer, { force: true });
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
catch (error) {
|
|
636
|
+
const recoveryErrors = [];
|
|
637
|
+
if (activeMoved) {
|
|
638
|
+
try {
|
|
639
|
+
if (stagedMoved)
|
|
640
|
+
await rm(active, { recursive: true, force: true });
|
|
641
|
+
await rename(backupActive, active);
|
|
642
|
+
}
|
|
643
|
+
catch (recoveryError) {
|
|
644
|
+
recoveryErrors.push(recoveryError);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
if (nativeChanged) {
|
|
648
|
+
try {
|
|
649
|
+
await activateNativeSkills({
|
|
650
|
+
nativeSkillDirectory: input.nativeSkillDirectory,
|
|
651
|
+
release: currentRelease,
|
|
652
|
+
bundleId: input.receipt.bundleId,
|
|
653
|
+
workspaceId: input.workspaceId,
|
|
654
|
+
skillIds: currentSkillIds,
|
|
655
|
+
removeSkillIds: previousSkillIds,
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
catch (recoveryError) {
|
|
659
|
+
recoveryErrors.push(recoveryError);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
try {
|
|
663
|
+
await writeFile(pointer, `${input.receipt.bundleId}\n`, { mode: 0o600 });
|
|
664
|
+
}
|
|
665
|
+
catch (recoveryError) {
|
|
666
|
+
recoveryErrors.push(recoveryError);
|
|
667
|
+
}
|
|
668
|
+
if (recoveryErrors.length) {
|
|
669
|
+
preserveRecovery = true;
|
|
670
|
+
throw new AggregateError([error, ...recoveryErrors], 'Skill rollback failed and the active installation could not be fully restored.');
|
|
671
|
+
}
|
|
672
|
+
throw error;
|
|
673
|
+
}
|
|
674
|
+
finally {
|
|
675
|
+
if (!preserveRecovery)
|
|
676
|
+
await rm(transactionRoot, { recursive: true, force: true });
|
|
677
|
+
}
|
|
259
678
|
}
|
|
260
679
|
export { contentHash };
|
|
261
680
|
//# sourceMappingURL=index.js.map
|