@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.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.expiresAt && Date.parse(bundle.expiresAt) <= now.getTime())
40
- throw new Error('Skill bundle has expired.');
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 readActiveBundleId(root) {
92
+ async function readActiveBundlePointer(root) {
93
+ let bundleId;
64
94
  try {
65
- return (await readFile(resolve(root, 'ACTIVE_BUNDLE'), 'utf8')).trim() || null;
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
- export async function getActiveSkillBundleId(nativeSkillDirectory) {
150
- return readActiveBundleId(resolve(nativeSkillDirectory, '.dharma-managed'));
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 = resolve(input.nativeSkillDirectory, '.dharma-managed');
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 readActiveBundleId(managedRoot);
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 rm(active, { recursive: true, force: true });
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
- await rm(rollback, { recursive: true, force: true });
232
- await writeFile(resolve(managedRoot, 'ACTIVE_BUNDLE'), `${input.bundle.bundleId}\n`, { mode: 0o600 });
233
- }
234
- else if (previousBundleId) {
235
- await writeFile(resolve(managedRoot, 'ACTIVE_BUNDLE'), `${previousBundleId}\n`, { mode: 0o600 });
236
- }
237
- else {
238
- await rm(resolve(managedRoot, 'ACTIVE_BUNDLE'), { force: true });
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
- return { ...receiptBase, receiptHash, signature };
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