@dharma-ai-labs/agent-fabric 0.2.49 → 0.2.50
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 +88 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +826 -175
- package/dist/index.js.map +1 -1
- package/dist/index.test.js +2 -2
- package/dist/repositoryCandidateSync.d.ts +55 -0
- package/dist/repositoryCandidateSync.d.ts.map +1 -0
- package/dist/repositoryCandidateSync.js +177 -0
- package/dist/repositoryCandidateSync.js.map +1 -0
- package/dist/repositoryCandidateSync.test.d.ts +2 -0
- package/dist/repositoryCandidateSync.test.d.ts.map +1 -0
- package/dist/repositoryCandidateSync.test.js +141 -0
- package/dist/repositoryCandidateSync.test.js.map +1 -0
- package/dist/repositoryInstalledKnowledge.d.ts +13 -0
- package/dist/repositoryInstalledKnowledge.d.ts.map +1 -0
- package/dist/repositoryInstalledKnowledge.js +25 -0
- package/dist/repositoryInstalledKnowledge.js.map +1 -0
- package/dist/repositoryInstallerFiles.d.ts +6 -0
- package/dist/repositoryInstallerFiles.d.ts.map +1 -0
- package/dist/repositoryInstallerFiles.js +113 -0
- package/dist/repositoryInstallerFiles.js.map +1 -0
- package/dist/repositoryKnowledge.d.ts +119 -0
- package/dist/repositoryKnowledge.d.ts.map +1 -0
- package/dist/repositoryKnowledge.js +462 -0
- package/dist/repositoryKnowledge.js.map +1 -0
- package/dist/repositoryKnowledge.test.d.ts +2 -0
- package/dist/repositoryKnowledge.test.d.ts.map +1 -0
- package/dist/repositoryKnowledge.test.js +245 -0
- package/dist/repositoryKnowledge.test.js.map +1 -0
- package/dist/repositoryKnowledgeRetention.test.d.ts +2 -0
- package/dist/repositoryKnowledgeRetention.test.d.ts.map +1 -0
- package/dist/repositoryKnowledgeRetention.test.js +386 -0
- package/dist/repositoryKnowledgeRetention.test.js.map +1 -0
- package/dist/repositoryOnboardingCaller.test.d.ts +2 -0
- package/dist/repositoryOnboardingCaller.test.d.ts.map +1 -0
- package/dist/repositoryOnboardingCaller.test.js +197 -0
- package/dist/repositoryOnboardingCaller.test.js.map +1 -0
- package/dist/repositoryOnboardingFlow.test.d.ts +2 -0
- package/dist/repositoryOnboardingFlow.test.d.ts.map +1 -0
- package/dist/repositoryOnboardingFlow.test.js +47 -0
- package/dist/repositoryOnboardingFlow.test.js.map +1 -0
- package/dist/repositoryPackage.d.ts +115 -0
- package/dist/repositoryPackage.d.ts.map +1 -0
- package/dist/repositoryPackage.js +1095 -0
- package/dist/repositoryPackage.js.map +1 -0
- package/dist/repositoryPackage.test.d.ts +2 -0
- package/dist/repositoryPackage.test.d.ts.map +1 -0
- package/dist/repositoryPackage.test.js +709 -0
- package/dist/repositoryPackage.test.js.map +1 -0
- package/dist/repositoryPackageDelivery.d.ts +29 -0
- package/dist/repositoryPackageDelivery.d.ts.map +1 -0
- package/dist/repositoryPackageDelivery.js +140 -0
- package/dist/repositoryPackageDelivery.js.map +1 -0
- package/dist/repositoryPackageDelivery.test.d.ts +2 -0
- package/dist/repositoryPackageDelivery.test.d.ts.map +1 -0
- package/dist/repositoryPackageDelivery.test.js +787 -0
- package/dist/repositoryPackageDelivery.test.js.map +1 -0
- package/dist/repositoryPackageDeliveryCaller.test.d.ts +2 -0
- package/dist/repositoryPackageDeliveryCaller.test.d.ts.map +1 -0
- package/dist/repositoryPackageDeliveryCaller.test.js +18 -0
- package/dist/repositoryPackageDeliveryCaller.test.js.map +1 -0
- package/dist/repositoryPackageDeliveryPreflight.test.d.ts +2 -0
- package/dist/repositoryPackageDeliveryPreflight.test.d.ts.map +1 -0
- package/dist/repositoryPackageDeliveryPreflight.test.js +29 -0
- package/dist/repositoryPackageDeliveryPreflight.test.js.map +1 -0
- package/dist/repositoryPackageTransfer.d.ts +85 -0
- package/dist/repositoryPackageTransfer.d.ts.map +1 -0
- package/dist/repositoryPackageTransfer.js +233 -0
- package/dist/repositoryPackageTransfer.js.map +1 -0
- package/dist/repositoryPackageTransfer.test.d.ts +2 -0
- package/dist/repositoryPackageTransfer.test.d.ts.map +1 -0
- package/dist/repositoryPackageTransfer.test.js +173 -0
- package/dist/repositoryPackageTransfer.test.js.map +1 -0
- package/dist/repositoryPackageTransferV2.test.d.ts +2 -0
- package/dist/repositoryPackageTransferV2.test.d.ts.map +1 -0
- package/dist/repositoryPackageTransferV2.test.js +549 -0
- package/dist/repositoryPackageTransferV2.test.js.map +1 -0
- package/dist/repositoryPackageTransferV2Schema.test.d.ts +2 -0
- package/dist/repositoryPackageTransferV2Schema.test.d.ts.map +1 -0
- package/dist/repositoryPackageTransferV2Schema.test.js +48 -0
- package/dist/repositoryPackageTransferV2Schema.test.js.map +1 -0
- package/dist/repositoryReleaseMetadata.d.ts +37 -0
- package/dist/repositoryReleaseMetadata.d.ts.map +1 -0
- package/dist/repositoryReleaseMetadata.js +251 -0
- package/dist/repositoryReleaseMetadata.js.map +1 -0
- package/dist/repositoryReleaseMetadata.test.d.ts +2 -0
- package/dist/repositoryReleaseMetadata.test.d.ts.map +1 -0
- package/dist/repositoryReleaseMetadata.test.js +297 -0
- package/dist/repositoryReleaseMetadata.test.js.map +1 -0
- package/dist/repositoryRoleDerivation.d.ts +11 -0
- package/dist/repositoryRoleDerivation.d.ts.map +1 -0
- package/dist/repositoryRoleDerivation.js +59 -0
- package/dist/repositoryRoleDerivation.js.map +1 -0
- package/dist/repositoryRoleDerivation.test.d.ts +2 -0
- package/dist/repositoryRoleDerivation.test.d.ts.map +1 -0
- package/dist/repositoryRoleDerivation.test.js +33 -0
- package/dist/repositoryRoleDerivation.test.js.map +1 -0
- package/dist/repositoryRoleMetadata.d.ts +63 -0
- package/dist/repositoryRoleMetadata.d.ts.map +1 -0
- package/dist/repositoryRoleMetadata.js +203 -0
- package/dist/repositoryRoleMetadata.js.map +1 -0
- package/dist/repositoryRoleMetadata.test.d.ts +2 -0
- package/dist/repositoryRoleMetadata.test.d.ts.map +1 -0
- package/dist/repositoryRoleMetadata.test.js +296 -0
- package/dist/repositoryRoleMetadata.test.js.map +1 -0
- package/dist/repositoryRoleQuestion.d.ts +36 -0
- package/dist/repositoryRoleQuestion.d.ts.map +1 -0
- package/dist/repositoryRoleQuestion.js +68 -0
- package/dist/repositoryRoleQuestion.js.map +1 -0
- package/dist/repositoryRoleQuestion.test.d.ts +2 -0
- package/dist/repositoryRoleQuestion.test.d.ts.map +1 -0
- package/dist/repositoryRoleQuestion.test.js +66 -0
- package/dist/repositoryRoleQuestion.test.js.map +1 -0
- package/dist/repositorySourceAuthorization.d.ts +43 -0
- package/dist/repositorySourceAuthorization.d.ts.map +1 -0
- package/dist/repositorySourceAuthorization.js +164 -0
- package/dist/repositorySourceAuthorization.js.map +1 -0
- package/dist/repositorySourceCapture.test.d.ts +2 -0
- package/dist/repositorySourceCapture.test.d.ts.map +1 -0
- package/dist/repositorySourceCapture.test.js +303 -0
- package/dist/repositorySourceCapture.test.js.map +1 -0
- package/dist/repositorySourceSync.d.ts +54 -0
- package/dist/repositorySourceSync.d.ts.map +1 -0
- package/dist/repositorySourceSync.js +88 -0
- package/dist/repositorySourceSync.js.map +1 -0
- package/dist/repositorySourceSync.test.d.ts +2 -0
- package/dist/repositorySourceSync.test.d.ts.map +1 -0
- package/dist/repositorySourceSync.test.js +218 -0
- package/dist/repositorySourceSync.test.js.map +1 -0
- package/dist/schemas/repository-knowledge-retention-v1.schema.json +26 -0
- package/dist/schemas/repository-knowledge-v2.schema.json +74 -0
- package/dist/schemas/repository-knowledge.schema.json +63 -0
- package/dist/schemas/repository-package-chunk-v2.schema.json +15 -0
- package/dist/schemas/repository-package-chunk.schema.json +18 -0
- package/dist/schemas/repository-package-delivery-v1.schema.json +51 -0
- package/dist/schemas/repository-package-envelope-v1.schema.json +16 -0
- package/dist/schemas/repository-package-transfer-v2.schema.json +36 -0
- package/dist/schemas/repository-package-transfer.schema.json +34 -0
- package/dist/schemas/repository-package-v2.schema.json +491 -0
- package/dist/schemas/repository-package-v3.schema.json +36 -0
- package/dist/schemas/repository-package.schema.json +94 -0
- package/dist/schemas/repository-release-manifest.schema.json +34 -0
- package/dist/schemas/repository-role-metadata.schema.json +82 -0
- package/dist/schemas/skill-preparation-pointer-v1.schema.json +28 -0
- package/dist/schemas/skill-preparation-v1.schema.json +32 -0
- package/dist/skillPollResponse.test.d.ts +2 -0
- package/dist/skillPollResponse.test.d.ts.map +1 -0
- package/dist/skillPollResponse.test.js +37 -0
- package/dist/skillPollResponse.test.js.map +1 -0
- package/dist/skillPreparation.test.d.ts +2 -0
- package/dist/skillPreparation.test.d.ts.map +1 -0
- package/dist/skillPreparation.test.js +163 -0
- package/dist/skillPreparation.test.js.map +1 -0
- package/dist/skillPreparationCache.d.ts +34 -0
- package/dist/skillPreparationCache.d.ts.map +1 -0
- package/dist/skillPreparationCache.js +371 -0
- package/dist/skillPreparationCache.js.map +1 -0
- package/dist/skillPreparationCache.test.d.ts +2 -0
- package/dist/skillPreparationCache.test.d.ts.map +1 -0
- package/dist/skillPreparationCache.test.js +316 -0
- package/dist/skillPreparationCache.test.js.map +1 -0
- package/dist/skillPreparationCaller.test.d.ts +2 -0
- package/dist/skillPreparationCaller.test.d.ts.map +1 -0
- package/dist/skillPreparationCaller.test.js +32 -0
- package/dist/skillPreparationCaller.test.js.map +1 -0
- package/dist/skillPreparationPump.d.ts +11 -0
- package/dist/skillPreparationPump.d.ts.map +1 -0
- package/dist/skillPreparationPump.js +41 -0
- package/dist/skillPreparationPump.js.map +1 -0
- package/dist/skillPreparationPump.test.d.ts +2 -0
- package/dist/skillPreparationPump.test.d.ts.map +1 -0
- package/dist/skillPreparationPump.test.js +87 -0
- package/dist/skillPreparationPump.test.js.map +1 -0
- package/dist/skillPreparationRecord.d.ts +2 -0
- package/dist/skillPreparationRecord.d.ts.map +1 -0
- package/dist/skillPreparationRecord.js +36 -0
- package/dist/skillPreparationRecord.js.map +1 -0
- package/dist/skillPreparationTransaction.d.ts +13 -0
- package/dist/skillPreparationTransaction.d.ts.map +1 -0
- package/dist/skillPreparationTransaction.js +236 -0
- package/dist/skillPreparationTransaction.js.map +1 -0
- package/dist/skillPreparationTransaction.test.d.ts +2 -0
- package/dist/skillPreparationTransaction.test.d.ts.map +1 -0
- package/dist/skillPreparationTransaction.test.js +181 -0
- package/dist/skillPreparationTransaction.test.js.map +1 -0
- package/dist/skillSyncPolicy.test.d.ts +2 -0
- package/dist/skillSyncPolicy.test.d.ts.map +1 -0
- package/dist/skillSyncPolicy.test.js +156 -0
- package/dist/skillSyncPolicy.test.js.map +1 -0
- package/dist/usage.d.ts +1 -1
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +1 -1
- package/dist/usage.js.map +1 -1
- package/dist/windowsProcessPortability.test.d.ts +2 -0
- package/dist/windowsProcessPortability.test.d.ts.map +1 -0
- package/dist/windowsProcessPortability.test.js +14 -0
- package/dist/windowsProcessPortability.test.js.map +1 -0
- package/package.json +7 -4
|
@@ -0,0 +1,1095 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { link, lstat, mkdir, open, opendir, realpath, rename, rmdir, unlink } from 'node:fs/promises';
|
|
4
|
+
import { isAbsolute, posix, relative, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { canonicalize, validateContract } from '@dharma-ai-labs/agent-fabric-contracts';
|
|
7
|
+
import { redactValue } from '@dharma-ai-labs/agent-fabric-evidence-reduction';
|
|
8
|
+
import { readRepositoryKnowledgeSource, validateRepositoryKnowledgeCatalog, validateRepositoryKnowledgeRetention, REPOSITORY_KNOWLEDGE_CATALOG_PATH, REPOSITORY_KNOWLEDGE_RELEASE_MANIFEST_PATH } from './repositoryKnowledge.js';
|
|
9
|
+
import { repositorySourcePathAllowed, repositorySourcePathSafe, validateRepositorySourceAuthorization } from './repositorySourceAuthorization.js';
|
|
10
|
+
export const REPOSITORY_SKILL_ROOTS = ['.agents/skills', '.claude/skills', '.codex/skills', 'skills'];
|
|
11
|
+
const GENERATED_ROOT = '.agents/skills/dharma-agent-fabric';
|
|
12
|
+
const DEFAULT_LIMITS = { maximumEntries: 4096, maximumFiles: 512, maximumFileBytes: 262_144,
|
|
13
|
+
maximumTotalBytes: 4_194_304, maximumDepth: 12, maximumDependencies: 256 };
|
|
14
|
+
function digest(value) { return `sha256:${createHash('sha256').update(value).digest('hex')}`; }
|
|
15
|
+
function compare(a, b) { return a < b ? -1 : a > b ? 1 : 0; }
|
|
16
|
+
function missing(error) { return ['ENOENT', 'ENOTDIR'].includes(String(error.code)); }
|
|
17
|
+
function pathKey(value) {
|
|
18
|
+
if (!value || value.includes('\\') || isAbsolute(value) || /^[A-Za-z]:/.test(value))
|
|
19
|
+
throw new Error('Invalid repository package path.');
|
|
20
|
+
const parts = value.split('/');
|
|
21
|
+
if (parts.some(part => !part || part === '.' || part === '..' || !/^[A-Za-z0-9._ -]{1,160}$/.test(part))) {
|
|
22
|
+
throw new Error('Invalid repository package path.');
|
|
23
|
+
}
|
|
24
|
+
if (value.length > 500)
|
|
25
|
+
throw new Error('Repository package path limit exceeded.');
|
|
26
|
+
if (safeContent(Buffer.from(value)))
|
|
27
|
+
throw new Error('Unsafe repository package path.');
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
function generated(path) {
|
|
31
|
+
return REPOSITORY_SKILL_ROOTS.some(root => path === `${root}/dharma-agent-fabric` || path.startsWith(`${root}/dharma-agent-fabric/`));
|
|
32
|
+
}
|
|
33
|
+
function prohibited(path) {
|
|
34
|
+
return generated(path) || path.split('/').some(part => /^(?:\.env.*|\.netrc|\.npmrc|\.pypirc|\.git|\.dharma|node_modules|\.ssh|\.aws|\.gnupg|id_rsa|id_ed25519)$/i.test(part)
|
|
35
|
+
|| /(?:^|[._ -])(?:secrets?|credentials?|passwords?|private[-_ ]?keys?|keystore)(?:[._ -]|$)/i.test(part)
|
|
36
|
+
|| /\.(?:pem|key|p12|pfx|jks|kdbx)$/i.test(part));
|
|
37
|
+
}
|
|
38
|
+
function safeContent(bytes) {
|
|
39
|
+
const text = bytes.toString('utf8');
|
|
40
|
+
if (!Buffer.from(text).equals(bytes) || /[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(text))
|
|
41
|
+
return 'binary_content';
|
|
42
|
+
const stats = { classes: new Set(), redactedValues: 0, excludedPaths: 0,
|
|
43
|
+
inputBytes: bytes.length, outputBytes: bytes.length };
|
|
44
|
+
let value = text;
|
|
45
|
+
try {
|
|
46
|
+
value = JSON.parse(text);
|
|
47
|
+
}
|
|
48
|
+
catch { /* Non-JSON companions are scanned as text. */ }
|
|
49
|
+
redactValue(value, stats);
|
|
50
|
+
// Evidence's local-path heuristic also matches ordinary relative links. Keep
|
|
51
|
+
// its secret classes, and check absolute paths at an actual path boundary.
|
|
52
|
+
if ([...stats.classes].some(name => name !== 'local_path') || /-----BEGIN (?:[A-Z ]*PRIVATE KEY|OPENSSH PRIVATE KEY)-----/.test(text)
|
|
53
|
+
|| /\b(?:[A-Za-z0-9_]*(?:password|passwd|secret|api_key|access_token|refresh_token|credential)[A-Za-z0-9_]*|token)["']?\s*[:=]\s*["']?[^\s"',;]+/i.test(text))
|
|
54
|
+
return 'secret_content';
|
|
55
|
+
if (/(?:^|[\s"'(=])(?:\/(?:home|Users|root|mnt)\/|[A-Za-z]:[\\/]|\\\\)/m.test(text))
|
|
56
|
+
return 'local_path_content';
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
// Reject every symlink component, not just links escaping the repository.
|
|
60
|
+
async function checkedPath(workspace, path) {
|
|
61
|
+
pathKey(path);
|
|
62
|
+
let current = workspace;
|
|
63
|
+
for (const part of path.split('/')) {
|
|
64
|
+
current = resolve(current, part);
|
|
65
|
+
const metadata = await lstat(current);
|
|
66
|
+
if (metadata.isSymbolicLink())
|
|
67
|
+
throw new Error('Repository package symlink is excluded.');
|
|
68
|
+
}
|
|
69
|
+
const resolved = await realpath(current);
|
|
70
|
+
const route = relative(workspace, resolved);
|
|
71
|
+
if (route === '..' || route.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || isAbsolute(route)) {
|
|
72
|
+
throw new Error('Repository package path escapes its workspace.');
|
|
73
|
+
}
|
|
74
|
+
return current;
|
|
75
|
+
}
|
|
76
|
+
async function readStable(workspace, path, maximumBytes) {
|
|
77
|
+
const source = await checkedPath(workspace, path);
|
|
78
|
+
if (!(await lstat(source)).isFile())
|
|
79
|
+
throw new Error('Repository package requires regular files.');
|
|
80
|
+
const handle = await open(source, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
|
|
81
|
+
try {
|
|
82
|
+
const before = await handle.stat();
|
|
83
|
+
if (!before.isFile())
|
|
84
|
+
throw new Error('Repository package requires regular files.');
|
|
85
|
+
if (before.size > maximumBytes)
|
|
86
|
+
throw new Error('Repository package file byte limit exceeded.');
|
|
87
|
+
const buffer = Buffer.alloc(before.size + 1);
|
|
88
|
+
let size = 0;
|
|
89
|
+
while (size < buffer.length) {
|
|
90
|
+
const result = await handle.read(buffer, size, buffer.length - size, size);
|
|
91
|
+
if (!result.bytesRead)
|
|
92
|
+
break;
|
|
93
|
+
size += result.bytesRead;
|
|
94
|
+
}
|
|
95
|
+
const after = await handle.stat();
|
|
96
|
+
const current = await lstat(await checkedPath(workspace, path));
|
|
97
|
+
if (size !== before.size || before.size !== after.size || before.mtimeMs !== after.mtimeMs
|
|
98
|
+
|| before.ctimeMs !== after.ctimeMs || current.ino !== before.ino || current.dev !== before.dev
|
|
99
|
+
|| current.size !== after.size || current.mtimeMs !== after.mtimeMs)
|
|
100
|
+
throw new Error('Repository package source changed during snapshot.');
|
|
101
|
+
return buffer.subarray(0, size);
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
await handle.close();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function dependencies(text) {
|
|
108
|
+
const paths = new Set();
|
|
109
|
+
for (const match of text.matchAll(/\]\(([^\s)]+)(?:\s+[^)]*)?\)|`((?:\.\.?\/|references\/|scripts\/|assets\/)[^`\s]+)`/g)) {
|
|
110
|
+
const value = (match[1] || match[2] || '').split('#')[0].split('?')[0];
|
|
111
|
+
if (value && !/^[a-z][a-z0-9+.-]*:/i.test(value) && !value.startsWith('#'))
|
|
112
|
+
paths.add(value);
|
|
113
|
+
}
|
|
114
|
+
return [...paths].sort(compare);
|
|
115
|
+
}
|
|
116
|
+
function sourceContentType(path) {
|
|
117
|
+
return /\.(?:md|txt|rst|json|jsonl|csv|yaml|yml|toml|py|ts|tsx|js|jsx|mjs|cjs|sh|sql|go|rs|java|c|cpp|h|r)$/i.test(path)
|
|
118
|
+
|| /^(?:README|LICENSE|Makefile|Dockerfile)$/i.test(posix.basename(path));
|
|
119
|
+
}
|
|
120
|
+
export async function inventoryRepositoryPackage(input) {
|
|
121
|
+
if (![input.organizationId, input.workspaceId].every(value => /^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(value))) {
|
|
122
|
+
throw new Error('Repository package requires bounded organization and workspace identities.');
|
|
123
|
+
}
|
|
124
|
+
const authorization = input.sourceAuthorization === undefined ? undefined
|
|
125
|
+
: validateRepositorySourceAuthorization(input.sourceAuthorization, input, input.now || new Date());
|
|
126
|
+
let retained;
|
|
127
|
+
if (input.retainedKnowledge) {
|
|
128
|
+
if (!authorization || !input.repositoryAgentId)
|
|
129
|
+
throw new Error('Retained repository knowledge requires governed scope.');
|
|
130
|
+
const { catalogBytes, manifestBytes } = input.retainedKnowledge;
|
|
131
|
+
if (![catalogBytes, manifestBytes].every(value => Buffer.isBuffer(value) && value.length > 0 && value.length <= 262144)) {
|
|
132
|
+
throw new Error('Retained repository knowledge byte limit exceeded.');
|
|
133
|
+
}
|
|
134
|
+
retained = { catalogBytes: Buffer.from(catalogBytes), manifestBytes: Buffer.from(manifestBytes) };
|
|
135
|
+
}
|
|
136
|
+
const workspace = await realpath(input.workspace);
|
|
137
|
+
const limits = { ...DEFAULT_LIMITS, ...input.limits };
|
|
138
|
+
for (const key of Object.keys(DEFAULT_LIMITS)) {
|
|
139
|
+
if (!Number.isSafeInteger(limits[key]) || limits[key] < 1 || limits[key] > DEFAULT_LIMITS[key])
|
|
140
|
+
throw new Error('Invalid repository package limit.');
|
|
141
|
+
}
|
|
142
|
+
if (authorization) {
|
|
143
|
+
limits.maximumFileBytes = Math.min(limits.maximumFileBytes, authorization.policy.maximumFileBytes);
|
|
144
|
+
limits.maximumTotalBytes = Math.min(limits.maximumTotalBytes, authorization.policy.maximumSnapshotBytes);
|
|
145
|
+
}
|
|
146
|
+
const exclusions = new Map();
|
|
147
|
+
const affectedPaths = new Set();
|
|
148
|
+
const diskFiles = new Set();
|
|
149
|
+
const skillRoots = new Map();
|
|
150
|
+
let entries = 0;
|
|
151
|
+
function exclude(path, reason) {
|
|
152
|
+
affectedPaths.add(path);
|
|
153
|
+
const key = (() => { try {
|
|
154
|
+
return pathKey(path);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return `excluded-${digest(path).slice(7)}`;
|
|
158
|
+
} })();
|
|
159
|
+
exclusions.set(key, reason);
|
|
160
|
+
}
|
|
161
|
+
async function walk(path, depth, providerRoot) {
|
|
162
|
+
if (generated(path))
|
|
163
|
+
return;
|
|
164
|
+
if (++entries > limits.maximumEntries || depth > limits.maximumDepth)
|
|
165
|
+
throw new Error('Repository package traversal limit exceeded.');
|
|
166
|
+
try {
|
|
167
|
+
pathKey(path);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
exclude(path, 'unsupported_path');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (prohibited(path)) {
|
|
174
|
+
exclude(path, 'excluded_path');
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const source = await checkedPath(workspace, path);
|
|
179
|
+
const metadata = await lstat(source);
|
|
180
|
+
if (metadata.isDirectory()) {
|
|
181
|
+
const names = [];
|
|
182
|
+
const directory = await opendir(source);
|
|
183
|
+
for await (const entry of directory) {
|
|
184
|
+
names.push(entry.name);
|
|
185
|
+
if (names.length + entries > limits.maximumEntries)
|
|
186
|
+
throw new Error('Repository package directory entry limit exceeded.');
|
|
187
|
+
}
|
|
188
|
+
for (const name of names.sort(compare))
|
|
189
|
+
await walk(`${path}/${name}`, depth + 1, providerRoot);
|
|
190
|
+
}
|
|
191
|
+
else if (metadata.isFile()) {
|
|
192
|
+
diskFiles.add(path);
|
|
193
|
+
if (posix.basename(path) === 'SKILL.md')
|
|
194
|
+
skillRoots.set(posix.dirname(path), providerRoot);
|
|
195
|
+
}
|
|
196
|
+
else
|
|
197
|
+
exclude(path, 'not_regular_file');
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
if (missing(error))
|
|
201
|
+
return;
|
|
202
|
+
if (String(error).includes('symlink')) {
|
|
203
|
+
exclude(path, 'symlink');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (String(error).includes('limit'))
|
|
207
|
+
throw error;
|
|
208
|
+
if (['EACCES', 'EPERM'].includes(String(error.code))) {
|
|
209
|
+
exclude(path, 'unavailable');
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
for (const root of REPOSITORY_SKILL_ROOTS)
|
|
216
|
+
await walk(root, 0, root);
|
|
217
|
+
const approved = new Set((input.approvedOutputs || []).map(pathKey));
|
|
218
|
+
if (approved.size > limits.maximumFiles || [...approved].some(prohibited))
|
|
219
|
+
throw new Error('Approved repository outputs exceed scope or limit.');
|
|
220
|
+
if (authorization && [...approved].some(path => !repositorySourcePathAllowed(authorization, path, 'approved_outputs'))) {
|
|
221
|
+
throw new Error('Approved repository outputs exceed source authorization scope.');
|
|
222
|
+
}
|
|
223
|
+
async function governedSources() {
|
|
224
|
+
const scoped = new Map();
|
|
225
|
+
const witnesses = new Map();
|
|
226
|
+
let count = 0;
|
|
227
|
+
async function capture(path, depth, role) {
|
|
228
|
+
if (++count > limits.maximumEntries || depth > limits.maximumDepth)
|
|
229
|
+
throw new Error('Repository source traversal limit exceeded.');
|
|
230
|
+
if (path !== '.' && !repositorySourcePathSafe(path)) {
|
|
231
|
+
exclude(path, 'excluded_path');
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
let source;
|
|
235
|
+
try {
|
|
236
|
+
source = path === '.' ? workspace : await checkedPath(workspace, path);
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
if (missing(error)) {
|
|
240
|
+
witnesses.set(path, 'missing');
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (String(error).includes('symlink')) {
|
|
244
|
+
exclude(path, 'symlink');
|
|
245
|
+
witnesses.set(path, 'symlink');
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
const metadata = await lstat(source);
|
|
251
|
+
if (metadata.isDirectory()) {
|
|
252
|
+
const names = [];
|
|
253
|
+
const directory = await opendir(source);
|
|
254
|
+
for await (const entry of directory) {
|
|
255
|
+
if (names.length + count >= limits.maximumEntries)
|
|
256
|
+
throw new Error('Repository source directory entry limit exceeded.');
|
|
257
|
+
names.push(entry.name);
|
|
258
|
+
}
|
|
259
|
+
names.sort(compare);
|
|
260
|
+
const children = names.map(name => path === '.' ? name : `${path}/${name}`);
|
|
261
|
+
witnesses.set(path, canonicalize({ ino: metadata.ino, dev: metadata.dev,
|
|
262
|
+
children: children.filter(repositorySourcePathSafe) }));
|
|
263
|
+
for (const child of children)
|
|
264
|
+
await capture(child, depth + 1, role);
|
|
265
|
+
}
|
|
266
|
+
else if (metadata.isFile()) {
|
|
267
|
+
witnesses.set(path, canonicalize({ ino: metadata.ino, dev: metadata.dev, size: metadata.size,
|
|
268
|
+
mtime: metadata.mtimeMs, ctime: metadata.ctimeMs }));
|
|
269
|
+
if (!sourceContentType(path)) {
|
|
270
|
+
exclude(path, 'unsupported_content_type');
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (role === 'approved_output' || !scoped.has(path))
|
|
274
|
+
scoped.set(path, role);
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
exclude(path, 'not_regular_file');
|
|
278
|
+
witnesses.set(path, 'not_regular_file');
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
for (const path of authorization.policy.approvedRepositoryPaths)
|
|
282
|
+
await capture(path, 0, 'repository_content');
|
|
283
|
+
for (const path of authorization.policy.approvedOutputFolders)
|
|
284
|
+
await capture(path, 0, 'approved_output');
|
|
285
|
+
return { scoped, witnesses: [...witnesses].sort(([a], [b]) => compare(a, b)) };
|
|
286
|
+
}
|
|
287
|
+
const governed = authorization ? await governedSources() : undefined;
|
|
288
|
+
const files = new Map();
|
|
289
|
+
const blobs = new Map();
|
|
290
|
+
const texts = new Map();
|
|
291
|
+
const edges = new Map();
|
|
292
|
+
const queue = new Map();
|
|
293
|
+
for (const path of [...diskFiles].sort(compare)) {
|
|
294
|
+
if ([...skillRoots.keys()].some(root => path.startsWith(`${root}/`)))
|
|
295
|
+
queue.set(path, 'skill');
|
|
296
|
+
}
|
|
297
|
+
for (const path of [...approved].sort(compare))
|
|
298
|
+
queue.set(path, queue.get(path) || 'approved_output');
|
|
299
|
+
for (const [path, role] of [...(governed?.scoped || [])].sort(([a], [b]) => compare(a, b))) {
|
|
300
|
+
if (!queue.has(path))
|
|
301
|
+
queue.set(path, role);
|
|
302
|
+
}
|
|
303
|
+
const visited = new Set();
|
|
304
|
+
let totalBytes = 0;
|
|
305
|
+
let dependencyCount = 0;
|
|
306
|
+
while (queue.size) {
|
|
307
|
+
const [path, role] = queue.entries().next().value;
|
|
308
|
+
queue.delete(path);
|
|
309
|
+
if (visited.has(path))
|
|
310
|
+
continue;
|
|
311
|
+
visited.add(path);
|
|
312
|
+
if (visited.size > limits.maximumFiles)
|
|
313
|
+
throw new Error('Repository package file count limit exceeded.');
|
|
314
|
+
if (prohibited(path)) {
|
|
315
|
+
if (!generated(path))
|
|
316
|
+
exclude(path, 'excluded_path');
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (authorization && ((role === 'approved_output' || role === 'repository_content')
|
|
320
|
+
|| !repositorySourcePathAllowed(authorization, path, 'repository_skills')) && !sourceContentType(path)) {
|
|
321
|
+
exclude(path, 'unsupported_content_type');
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (authorization && !repositorySourcePathAllowed(authorization, path, 'repository_skills')
|
|
325
|
+
&& !repositorySourcePathAllowed(authorization, path, 'repository_content')
|
|
326
|
+
&& !repositorySourcePathAllowed(authorization, path, 'approved_outputs')) {
|
|
327
|
+
exclude(path, 'unauthorized_dependency');
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
let content;
|
|
331
|
+
try {
|
|
332
|
+
content = await readStable(workspace, path, limits.maximumFileBytes);
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
if (String(error).includes('limit') || String(error).includes('changed during'))
|
|
336
|
+
throw error;
|
|
337
|
+
exclude(path, missing(error) ? 'missing_dependency' : String(error).includes('symlink') ? 'symlink' : 'unavailable');
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const unsafe = safeContent(content);
|
|
341
|
+
if (unsafe) {
|
|
342
|
+
exclude(path, unsafe);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
totalBytes += content.length;
|
|
346
|
+
if (totalBytes > limits.maximumTotalBytes)
|
|
347
|
+
throw new Error('Repository package total byte limit exceeded.');
|
|
348
|
+
const hash = digest(content);
|
|
349
|
+
files.set(path, { path, role, sha256: hash, sizeBytes: content.length });
|
|
350
|
+
blobs.set(hash, content.toString('base64'));
|
|
351
|
+
texts.set(path, content.toString('utf8'));
|
|
352
|
+
// Outputs are opaque approved snapshots; they never trigger more collection.
|
|
353
|
+
if (role === 'approved_output' || role === 'repository_content')
|
|
354
|
+
continue;
|
|
355
|
+
for (const reference of dependencies(texts.get(path))) {
|
|
356
|
+
if (++dependencyCount > limits.maximumDependencies)
|
|
357
|
+
throw new Error('Repository package dependency limit exceeded.');
|
|
358
|
+
const target = posix.normalize(posix.join(posix.dirname(path), reference));
|
|
359
|
+
try {
|
|
360
|
+
if (isAbsolute(reference) || reference.includes('\\'))
|
|
361
|
+
throw new Error('Invalid dependency.');
|
|
362
|
+
pathKey(target);
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
exclude(path, 'invalid_dependency');
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
const targets = edges.get(path) || new Set();
|
|
369
|
+
targets.add(target);
|
|
370
|
+
edges.set(path, targets);
|
|
371
|
+
if (generated(target)) {
|
|
372
|
+
exclude(path, 'generated_dependency');
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (!visited.has(target) && !queue.has(target))
|
|
376
|
+
queue.set(target, approved.has(target) ? 'approved_output' : 'dependency');
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const skills = [];
|
|
380
|
+
for (const [path, providerRoot] of [...skillRoots].sort(([a], [b]) => compare(a, b))) {
|
|
381
|
+
const owned = new Set([...diskFiles].filter(file => file.startsWith(`${path}/`)));
|
|
382
|
+
const pending = [...owned];
|
|
383
|
+
const seen = new Set();
|
|
384
|
+
while (pending.length) {
|
|
385
|
+
const file = pending.pop();
|
|
386
|
+
if (seen.has(file))
|
|
387
|
+
continue;
|
|
388
|
+
seen.add(file);
|
|
389
|
+
for (const target of edges.get(file) || []) {
|
|
390
|
+
owned.add(target);
|
|
391
|
+
if (!seen.has(target))
|
|
392
|
+
pending.push(target);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const filePaths = [...owned].filter(file => files.has(file)).sort(compare);
|
|
396
|
+
const partial = [...owned].some(file => !files.has(file)) || [...affectedPaths].some(file => file === path || file.startsWith(`${path}/`) || owned.has(file));
|
|
397
|
+
skills.push({ path, managedPath: copyPath(path), providerRoot, entryPath: `${path}/SKILL.md`, filePaths,
|
|
398
|
+
contentHash: digest(canonicalize(filePaths.map(file => ({ path: file, sha256: files.get(file).sha256 })))),
|
|
399
|
+
availability: !files.has(`${path}/SKILL.md`) ? 'unavailable' : partial ? 'partial' : 'available',
|
|
400
|
+
observation: { state: 'not_observed', authority: 'caller_supplied_not_runtime_verified', references: [] } });
|
|
401
|
+
}
|
|
402
|
+
for (const path of new Set(skills.flatMap(skill => skill.filePaths)))
|
|
403
|
+
files.get(path).managedPath = copyPath(path);
|
|
404
|
+
if ((input.observations || []).length > 256)
|
|
405
|
+
throw new Error('Repository package observation limit exceeded.');
|
|
406
|
+
for (const reference of input.observations || []) {
|
|
407
|
+
const skill = skills.find(skill => skill.path === reference.skillPath);
|
|
408
|
+
const source = files.get(reference.sourcePath);
|
|
409
|
+
if (!skill || skill.availability !== 'available' || skill.contentHash !== reference.skillHash
|
|
410
|
+
|| !approved.has(reference.sourcePath) || source?.sha256 !== reference.sourceHash
|
|
411
|
+
|| Object.keys(reference).sort().join(',') !== 'skillHash,skillPath,sourceHash,sourcePath') {
|
|
412
|
+
throw new Error('Repository package observation is not bound to an available skill and approved source snapshot.');
|
|
413
|
+
}
|
|
414
|
+
skill.observation.state = 'reported_observed';
|
|
415
|
+
if (!skill.observation.references.some(item => canonicalize(item) === canonicalize(reference)))
|
|
416
|
+
skill.observation.references.push({ ...reference });
|
|
417
|
+
skill.observation.references.sort((a, b) => compare(canonicalize(a), canonicalize(b)));
|
|
418
|
+
}
|
|
419
|
+
let knowledge;
|
|
420
|
+
if (input.repositoryAgentId) {
|
|
421
|
+
const source = retained ? { kind: 'delivered_v2_requires_verified_release', ...retained,
|
|
422
|
+
...validateRepositoryKnowledgeRetention({ identity: { organizationId: input.organizationId,
|
|
423
|
+
repositoryAgentId: input.repositoryAgentId, repositoryBindingId: authorization.repositoryBindingId }, ...retained }) }
|
|
424
|
+
: await readRepositoryKnowledgeSource({ workspace, organizationId: input.organizationId,
|
|
425
|
+
repositoryAgentId: input.repositoryAgentId, workspaceId: input.workspaceId,
|
|
426
|
+
repositoryBindingId: authorization?.repositoryBindingId || input.repositoryBindingId });
|
|
427
|
+
if (source) {
|
|
428
|
+
const knowledgeFiles = [[REPOSITORY_KNOWLEDGE_CATALOG_PATH, source.catalogBytes]];
|
|
429
|
+
if (source.kind === 'local_v1_unsigned') {
|
|
430
|
+
for (const concept of source.catalog.concepts) {
|
|
431
|
+
const path = `${GENERATED_ROOT}/knowledge/${concept.relativePath}`;
|
|
432
|
+
knowledgeFiles.push([path, await readStable(workspace, path, limits.maximumFileBytes)]);
|
|
433
|
+
}
|
|
434
|
+
knowledge = { repositoryAgentId: source.catalog.repositoryAgentId, knowledgeBaseId: source.catalog.knowledgeBaseId,
|
|
435
|
+
catalogPath: REPOSITORY_KNOWLEDGE_CATALOG_PATH, catalogHash: source.catalog.catalogHash,
|
|
436
|
+
authority: 'locally_initialized_unsigned', atlasAssociation: 'local_scope_only' };
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
if (!authorization)
|
|
440
|
+
throw new Error('Repository knowledge retention requires governed source authorization.');
|
|
441
|
+
knowledgeFiles.push([REPOSITORY_KNOWLEDGE_RELEASE_MANIFEST_PATH, source.manifestBytes]);
|
|
442
|
+
knowledge = { repositoryAgentId: source.catalog.repositoryAgentId, knowledgeBaseId: source.catalog.knowledgeBaseId,
|
|
443
|
+
catalogPath: REPOSITORY_KNOWLEDGE_CATALOG_PATH, catalogHash: source.reference.catalogHash,
|
|
444
|
+
authority: 'unverified_prior_release_reference', atlasAssociation: 'requires_verified_release', priorRelease: source.reference };
|
|
445
|
+
}
|
|
446
|
+
for (const [path, content] of knowledgeFiles) {
|
|
447
|
+
pathKey(path);
|
|
448
|
+
if (content.length > limits.maximumFileBytes)
|
|
449
|
+
throw new Error('Repository knowledge file byte limit exceeded.');
|
|
450
|
+
if (safeContent(content))
|
|
451
|
+
throw new Error('Repository knowledge contains unsafe content.');
|
|
452
|
+
totalBytes += content.length;
|
|
453
|
+
const hash = digest(content);
|
|
454
|
+
files.set(path, { path, role: 'knowledge', sha256: hash, sizeBytes: content.length });
|
|
455
|
+
blobs.set(hash, content.toString('base64'));
|
|
456
|
+
}
|
|
457
|
+
if (files.size > limits.maximumFiles || totalBytes > limits.maximumTotalBytes)
|
|
458
|
+
throw new Error('Repository knowledge package limit exceeded.');
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (governed) {
|
|
462
|
+
if (!knowledge)
|
|
463
|
+
throw new Error('Governed repository package requires its initialized knowledge base.');
|
|
464
|
+
if (canonicalize(governed.witnesses) !== canonicalize((await governedSources()).witnesses)) {
|
|
465
|
+
throw new Error('Repository source changed during snapshot.');
|
|
466
|
+
}
|
|
467
|
+
for (const file of files.values()) {
|
|
468
|
+
if (file.role !== 'knowledge' && digest(await readStable(workspace, file.path, limits.maximumFileBytes)) !== file.sha256) {
|
|
469
|
+
throw new Error('Repository source changed during snapshot.');
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
const base = { schema: knowledge?.authority === 'unverified_prior_release_reference' ? 'dharma.repository-package/v3'
|
|
474
|
+
: authorization ? 'dharma.repository-package/v2' : 'dharma.repository-package/v1', organizationId: input.organizationId,
|
|
475
|
+
workspaceId: input.workspaceId, authority: 'local_inventory_not_signed', roots: [...REPOSITORY_SKILL_ROOTS],
|
|
476
|
+
files: [...files.values()].sort((a, b) => compare(a.path, b.path)), skills,
|
|
477
|
+
exclusions: [...exclusions].sort(([a], [b]) => compare(a, b)).map(([path, reason]) => ({ path, reason })),
|
|
478
|
+
...(knowledge ? { knowledge } : {}), ...(authorization ? { sourceAuthorization: authorization } : {}) };
|
|
479
|
+
const fingerprinted = authorization ? { ...base, sourceFingerprint: sourceFingerprint(base) } : base;
|
|
480
|
+
const snapshotHash = digest(canonicalize(fingerprinted));
|
|
481
|
+
return { schema: 'dharma.repository-package-snapshot/v1', capturedAt: (input.now || new Date()).toISOString(),
|
|
482
|
+
manifest: { ...fingerprinted, snapshotHash, snapshotId: `repository-package-${snapshotHash.slice(7)}` },
|
|
483
|
+
blobs: [...blobs].sort(([a], [b]) => compare(a, b)).map(([sha256, contentBase64]) => ({ sha256, contentBase64 })) };
|
|
484
|
+
}
|
|
485
|
+
function sourceFingerprint(manifest) {
|
|
486
|
+
const authorization = manifest.sourceAuthorization;
|
|
487
|
+
return digest(canonicalize({ organizationId: manifest.organizationId,
|
|
488
|
+
repositoryBindingId: authorization.repositoryBindingId, repositoryAgentId: authorization.repositoryAgentId,
|
|
489
|
+
generationId: authorization.generationId, policyHash: authorization.policyHash,
|
|
490
|
+
files: manifest.files.filter(file => file.role !== 'knowledge'), skills: manifest.skills }));
|
|
491
|
+
}
|
|
492
|
+
export function serializeRepositoryPackageSnapshot(snapshot) {
|
|
493
|
+
const { snapshotHash, snapshotId, ...base } = snapshot.manifest;
|
|
494
|
+
if (safeContent(Buffer.from(canonicalize(base))))
|
|
495
|
+
throw new Error('Repository package metadata contains unsafe content.');
|
|
496
|
+
if (snapshot.schema !== 'dharma.repository-package-snapshot/v1'
|
|
497
|
+
|| !['dharma.repository-package/v1', 'dharma.repository-package/v2', 'dharma.repository-package/v3'].includes(base.schema)
|
|
498
|
+
|| base.authority !== 'local_inventory_not_signed' || digest(canonicalize(base)) !== snapshotHash
|
|
499
|
+
|| snapshotId !== `repository-package-${snapshotHash.slice(7)}`)
|
|
500
|
+
throw new Error('Repository package manifest integrity failed.');
|
|
501
|
+
if (base.schema === 'dharma.repository-package/v2' || base.schema === 'dharma.repository-package/v3') {
|
|
502
|
+
if (!base.sourceAuthorization)
|
|
503
|
+
throw new Error('Repository source authorization is missing.');
|
|
504
|
+
validateRepositorySourceAuthorization(base.sourceAuthorization, { organizationId: base.organizationId,
|
|
505
|
+
workspaceId: base.workspaceId, repositoryAgentId: base.knowledge?.repositoryAgentId,
|
|
506
|
+
repositoryBindingId: base.sourceAuthorization.repositoryBindingId });
|
|
507
|
+
if (base.sourceFingerprint !== sourceFingerprint(base))
|
|
508
|
+
throw new Error('Repository source fingerprint integrity failed.');
|
|
509
|
+
}
|
|
510
|
+
else if (base.sourceAuthorization !== undefined || base.sourceFingerprint !== undefined
|
|
511
|
+
|| base.files.some(file => file.role === 'repository_content'))
|
|
512
|
+
throw new Error('Legacy repository package version mismatch.');
|
|
513
|
+
const blobs = new Map();
|
|
514
|
+
if (snapshot.blobs.length > DEFAULT_LIMITS.maximumFiles || base.files.length > DEFAULT_LIMITS.maximumFiles
|
|
515
|
+
|| base.skills.length > DEFAULT_LIMITS.maximumFiles || base.skills.some(skill => skill.filePaths.length > DEFAULT_LIMITS.maximumFiles)
|
|
516
|
+
|| Buffer.byteLength(canonicalize(snapshot.manifest)) + 1 > METADATA_LIMIT)
|
|
517
|
+
throw new Error('Repository package serialization limit exceeded.');
|
|
518
|
+
let bytes = 0;
|
|
519
|
+
for (const blob of snapshot.blobs) {
|
|
520
|
+
if (blob.contentBase64.length > Math.ceil(DEFAULT_LIMITS.maximumFileBytes / 3) * 4)
|
|
521
|
+
throw new Error('Repository package blob limit exceeded.');
|
|
522
|
+
const content = Buffer.from(blob.contentBase64, 'base64');
|
|
523
|
+
bytes += content.length;
|
|
524
|
+
if (blobs.has(blob.sha256) || content.toString('base64') !== blob.contentBase64 || digest(content) !== blob.sha256 || safeContent(content)) {
|
|
525
|
+
throw new Error('Repository package blob integrity failed.');
|
|
526
|
+
}
|
|
527
|
+
blobs.set(blob.sha256, content);
|
|
528
|
+
}
|
|
529
|
+
if (bytes > DEFAULT_LIMITS.maximumTotalBytes)
|
|
530
|
+
throw new Error('Repository package serialization byte limit exceeded.');
|
|
531
|
+
if (base.sourceAuthorization && (bytes > base.sourceAuthorization.policy.maximumSnapshotBytes
|
|
532
|
+
|| base.files.some(file => file.sizeBytes > base.sourceAuthorization.policy.maximumFileBytes))) {
|
|
533
|
+
throw new Error('Repository source policy byte limit exceeded.');
|
|
534
|
+
}
|
|
535
|
+
const paths = new Set();
|
|
536
|
+
const knowledgePaths = new Set();
|
|
537
|
+
if (base.knowledge) {
|
|
538
|
+
if (base.knowledge.catalogPath !== REPOSITORY_KNOWLEDGE_CATALOG_PATH)
|
|
539
|
+
throw new Error('Repository knowledge mapping integrity failed.');
|
|
540
|
+
const catalogFile = base.files.find(file => file.path === REPOSITORY_KNOWLEDGE_CATALOG_PATH && file.role === 'knowledge');
|
|
541
|
+
const bytes = catalogFile ? blobs.get(catalogFile.sha256) : undefined;
|
|
542
|
+
if (!bytes)
|
|
543
|
+
throw new Error('Repository knowledge catalog blob is missing.');
|
|
544
|
+
if (base.schema === 'dharma.repository-package/v3') {
|
|
545
|
+
if (base.knowledge.authority !== 'unverified_prior_release_reference'
|
|
546
|
+
|| base.knowledge.atlasAssociation !== 'requires_verified_release' || !base.knowledge.priorRelease || !base.sourceAuthorization) {
|
|
547
|
+
throw new Error('Repository knowledge retention mapping integrity failed.');
|
|
548
|
+
}
|
|
549
|
+
const manifestFile = base.files.find(file => file.path === REPOSITORY_KNOWLEDGE_RELEASE_MANIFEST_PATH && file.role === 'knowledge');
|
|
550
|
+
const manifestBytes = manifestFile ? blobs.get(manifestFile.sha256) : undefined;
|
|
551
|
+
if (!manifestBytes)
|
|
552
|
+
throw new Error('Repository knowledge retention manifest blob missing.');
|
|
553
|
+
const retained = validateRepositoryKnowledgeRetention({ identity: { organizationId: base.organizationId,
|
|
554
|
+
repositoryAgentId: base.knowledge.repositoryAgentId, repositoryBindingId: base.sourceAuthorization.repositoryBindingId },
|
|
555
|
+
catalogBytes: bytes, manifestBytes, reference: base.knowledge.priorRelease });
|
|
556
|
+
if (retained.catalog.knowledgeBaseId !== base.knowledge.knowledgeBaseId || digest(bytes) !== base.knowledge.catalogHash) {
|
|
557
|
+
throw new Error('Repository knowledge retention mapping integrity failed.');
|
|
558
|
+
}
|
|
559
|
+
knowledgePaths.add(REPOSITORY_KNOWLEDGE_CATALOG_PATH);
|
|
560
|
+
knowledgePaths.add(REPOSITORY_KNOWLEDGE_RELEASE_MANIFEST_PATH);
|
|
561
|
+
}
|
|
562
|
+
else {
|
|
563
|
+
if (base.knowledge.authority !== 'locally_initialized_unsigned' || base.knowledge.atlasAssociation !== 'local_scope_only'
|
|
564
|
+
|| Object.hasOwn(base.knowledge, 'priorRelease'))
|
|
565
|
+
throw new Error('Repository knowledge mapping integrity failed.');
|
|
566
|
+
const catalog = validateRepositoryKnowledgeCatalog(JSON.parse(bytes.toString('utf8')), { organizationId: base.organizationId, repositoryAgentId: base.knowledge.repositoryAgentId });
|
|
567
|
+
if (catalog.knowledgeBaseId !== base.knowledge.knowledgeBaseId || catalog.catalogHash !== base.knowledge.catalogHash) {
|
|
568
|
+
throw new Error('Repository knowledge mapping integrity failed.');
|
|
569
|
+
}
|
|
570
|
+
knowledgePaths.add(REPOSITORY_KNOWLEDGE_CATALOG_PATH);
|
|
571
|
+
for (const concept of catalog.concepts) {
|
|
572
|
+
const path = `${GENERATED_ROOT}/knowledge/${concept.relativePath}`;
|
|
573
|
+
const file = base.files.find(item => item.path === path && item.role === 'knowledge');
|
|
574
|
+
if (file?.sha256 !== concept.sha256)
|
|
575
|
+
throw new Error('Repository knowledge concept integrity failed.');
|
|
576
|
+
knowledgePaths.add(path);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
else if (base.schema === 'dharma.repository-package/v3')
|
|
581
|
+
throw new Error('Repository knowledge retention is missing.');
|
|
582
|
+
let logicalBytes = 0;
|
|
583
|
+
for (const file of base.files) {
|
|
584
|
+
pathKey(file.path);
|
|
585
|
+
const approvedKnowledge = file.role === 'knowledge' && knowledgePaths.has(file.path) && file.managedPath === undefined;
|
|
586
|
+
if ((file.role === 'knowledge' ? !approvedKnowledge : prohibited(file.path))
|
|
587
|
+
|| paths.has(file.path) || blobs.get(file.sha256)?.length !== file.sizeBytes)
|
|
588
|
+
throw new Error('Repository package file integrity failed.');
|
|
589
|
+
paths.add(file.path);
|
|
590
|
+
logicalBytes += file.sizeBytes;
|
|
591
|
+
if (logicalBytes > DEFAULT_LIMITS.maximumTotalBytes)
|
|
592
|
+
throw new Error('Repository package logical byte limit exceeded.');
|
|
593
|
+
if (base.sourceAuthorization && logicalBytes > base.sourceAuthorization.policy.maximumSnapshotBytes) {
|
|
594
|
+
throw new Error('Repository source policy logical byte limit exceeded.');
|
|
595
|
+
}
|
|
596
|
+
if (base.sourceAuthorization && file.role !== 'knowledge'
|
|
597
|
+
&& ((file.role === 'approved_output' || file.role === 'repository_content')
|
|
598
|
+
|| !repositorySourcePathAllowed(base.sourceAuthorization, file.path, 'repository_skills')) && !sourceContentType(file.path)) {
|
|
599
|
+
throw new Error('Repository source content type integrity failed.');
|
|
600
|
+
}
|
|
601
|
+
if (base.sourceAuthorization && file.role !== 'knowledge'
|
|
602
|
+
&& !repositorySourcePathAllowed(base.sourceAuthorization, file.path, file.role === 'approved_output' ? 'approved_outputs' : file.role === 'repository_content' ? 'repository_content' : 'repository_skills')
|
|
603
|
+
&& !(file.role === 'dependency' && (repositorySourcePathAllowed(base.sourceAuthorization, file.path, 'repository_content')
|
|
604
|
+
|| repositorySourcePathAllowed(base.sourceAuthorization, file.path, 'approved_outputs')))) {
|
|
605
|
+
throw new Error('Repository source file scope integrity failed.');
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
const copied = new Set(base.skills.flatMap(skill => skill.filePaths));
|
|
609
|
+
const byPath = new Map(base.files.map(file => [file.path, file]));
|
|
610
|
+
for (const skill of base.skills) {
|
|
611
|
+
pathKey(skill.path);
|
|
612
|
+
if (!REPOSITORY_SKILL_ROOTS.includes(skill.providerRoot)
|
|
613
|
+
|| !(skill.path === skill.providerRoot || skill.path.startsWith(`${skill.providerRoot}/`))
|
|
614
|
+
|| prohibited(skill.path) || skill.entryPath !== `${skill.path}/SKILL.md`
|
|
615
|
+
|| new Set(skill.filePaths).size !== skill.filePaths.length
|
|
616
|
+
|| skill.filePaths.some(path => !paths.has(path))
|
|
617
|
+
|| skill.contentHash !== digest(canonicalize(skill.filePaths.map(path => ({ path, sha256: byPath.get(path).sha256 }))))
|
|
618
|
+
|| (skill.managedPath !== undefined && skill.managedPath !== copyPath(skill.path))) {
|
|
619
|
+
throw new Error('Repository package skill mapping integrity failed.');
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
for (const file of base.files) {
|
|
623
|
+
if (file.managedPath !== undefined && (!copied.has(file.path) || file.managedPath !== copyPath(file.path))) {
|
|
624
|
+
throw new Error('Repository package file mapping integrity failed.');
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (blobs.size !== new Set(base.files.map(file => file.sha256)).size)
|
|
628
|
+
throw new Error('Repository package contains unreferenced blobs.');
|
|
629
|
+
// Capture time is envelope metadata, not part of the immutable CAS object.
|
|
630
|
+
return `${canonicalize({ manifest: snapshot.manifest, blobs: snapshot.blobs })}\n`;
|
|
631
|
+
}
|
|
632
|
+
async function persistSnapshot(input, workspace) {
|
|
633
|
+
const serialized = serializeRepositoryPackageSnapshot(input.snapshot);
|
|
634
|
+
const validated = await validateContract(fileURLToPath(new URL('./schemas/', import.meta.url)), `https://schemas.dharma-ai.io/repository-package/${input.snapshot.manifest.schema.split('/')[1]}`, input.snapshot.manifest);
|
|
635
|
+
if (!validated.ok)
|
|
636
|
+
throw new Error('Repository package manifest schema is invalid.');
|
|
637
|
+
const activeManifestPath = `${GENERATED_ROOT}/MANIFEST.json`;
|
|
638
|
+
const activeManifest = input.candidateOnly ? null : await optionalBytes(workspace, activeManifestPath);
|
|
639
|
+
// Recognizing a release prevents local mutation; it does not establish trust.
|
|
640
|
+
const candidateOnly = input.candidateOnly === true || input.snapshot.manifest.schema === 'dharma.repository-package/v3' || (activeManifest !== null
|
|
641
|
+
&& JSON.parse(activeManifest.toString('utf8')).schema === 'dharma.repository-release-manifest/v1');
|
|
642
|
+
const snapshotRoot = '.dharma/repository-source/snapshots';
|
|
643
|
+
const snapshotPath = `${snapshotRoot}/${input.snapshot.manifest.snapshotHash.slice(7)}.json`;
|
|
644
|
+
const candidateManifestPath = `${snapshotRoot}/${input.snapshot.manifest.snapshotHash.slice(7)}.manifest.json`;
|
|
645
|
+
const manifestPath = candidateOnly ? candidateManifestPath : activeManifestPath;
|
|
646
|
+
const existingSkill = await checkedPath(workspace, `${GENERATED_ROOT}/SKILL.md`).then(() => true, error => {
|
|
647
|
+
if (missing(error))
|
|
648
|
+
return false;
|
|
649
|
+
throw error;
|
|
650
|
+
});
|
|
651
|
+
if (existingSkill && !candidateOnly) {
|
|
652
|
+
let marker;
|
|
653
|
+
try {
|
|
654
|
+
marker = JSON.parse((await readStable(workspace, `${GENERATED_ROOT}/.dharma-agent-fabric.json`, 4096)).toString('utf8'));
|
|
655
|
+
}
|
|
656
|
+
catch (error) {
|
|
657
|
+
if (String(error).includes('symlink'))
|
|
658
|
+
throw error;
|
|
659
|
+
throw new Error('Refusing to write into an unmanaged repository skill.');
|
|
660
|
+
}
|
|
661
|
+
if (marker.managedBy !== 'dharma-agent-fabric')
|
|
662
|
+
throw new Error('Refusing to write into an unmanaged repository skill.');
|
|
663
|
+
}
|
|
664
|
+
let current = '';
|
|
665
|
+
for (const part of posix.dirname(snapshotPath).split('/')) {
|
|
666
|
+
current = current ? `${current}/${part}` : part;
|
|
667
|
+
try {
|
|
668
|
+
await mkdir(resolve(workspace, current), { mode: 0o700 });
|
|
669
|
+
}
|
|
670
|
+
catch (error) {
|
|
671
|
+
if (error.code !== 'EEXIST')
|
|
672
|
+
throw error;
|
|
673
|
+
}
|
|
674
|
+
await checkedPath(workspace, current);
|
|
675
|
+
}
|
|
676
|
+
await checkedPath(workspace, posix.dirname(snapshotPath));
|
|
677
|
+
for (const [path, content] of [[snapshotPath, serialized], [candidateManifestPath, `${canonicalize(input.snapshot.manifest)}\n`]]) {
|
|
678
|
+
const stagedSnapshot = `${snapshotRoot}/.snapshot-${randomUUID()}.tmp`;
|
|
679
|
+
try {
|
|
680
|
+
const handle = await open(resolve(workspace, stagedSnapshot), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
|
|
681
|
+
try {
|
|
682
|
+
await handle.writeFile(content);
|
|
683
|
+
await handle.sync();
|
|
684
|
+
}
|
|
685
|
+
finally {
|
|
686
|
+
await handle.close();
|
|
687
|
+
}
|
|
688
|
+
await checkedPath(workspace, snapshotRoot);
|
|
689
|
+
try {
|
|
690
|
+
await link(resolve(workspace, stagedSnapshot), resolve(workspace, path));
|
|
691
|
+
}
|
|
692
|
+
catch (error) {
|
|
693
|
+
if (error.code !== 'EEXIST')
|
|
694
|
+
throw error;
|
|
695
|
+
const existing = await readStable(workspace, path, 8_388_608);
|
|
696
|
+
if (existing.toString('utf8') !== content)
|
|
697
|
+
throw new Error('Repository package CAS conflict.');
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
finally {
|
|
701
|
+
await unlink(resolve(workspace, stagedSnapshot)).catch(error => { if (!missing(error))
|
|
702
|
+
throw error; });
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
await syncDirectory(workspace, posix.dirname(snapshotPath));
|
|
706
|
+
if (!candidateOnly) {
|
|
707
|
+
await directories(workspace, GENERATED_ROOT);
|
|
708
|
+
await withCopyLock(workspace, async () => {
|
|
709
|
+
await recoverCopies(workspace);
|
|
710
|
+
await prepareCopies(workspace, input.snapshot, input.onCopyCheckpoint);
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
return { manifestPath, snapshotPath, snapshotHash: input.snapshot.manifest.snapshotHash,
|
|
714
|
+
managedCopiesPath: candidateOnly ? null : `${GENERATED_ROOT}/skills/source`,
|
|
715
|
+
disposition: candidateOnly ? 'candidate_only' : 'local_bootstrap_inventory',
|
|
716
|
+
authority: 'local_inventory_not_signed' };
|
|
717
|
+
}
|
|
718
|
+
const writers = new Map();
|
|
719
|
+
export async function writeRepositoryPackageSnapshot(input) {
|
|
720
|
+
const workspace = await realpath(input.workspace);
|
|
721
|
+
const previous = writers.get(workspace) || Promise.resolve();
|
|
722
|
+
const operation = previous.catch(() => { }).then(() => persistSnapshot(input, workspace));
|
|
723
|
+
writers.set(workspace, operation);
|
|
724
|
+
try {
|
|
725
|
+
return await operation;
|
|
726
|
+
}
|
|
727
|
+
finally {
|
|
728
|
+
if (writers.get(workspace) === operation)
|
|
729
|
+
writers.delete(workspace);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
function copyPath(source) {
|
|
733
|
+
pathKey(source);
|
|
734
|
+
const path = `skills/source/${source}`;
|
|
735
|
+
pathKey(`${GENERATED_ROOT}/${path}`);
|
|
736
|
+
if (process.platform === 'win32' && source.split('/').some(part => /[. ]$/.test(part)
|
|
737
|
+
|| /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part)))
|
|
738
|
+
throw new Error('Unsupported Windows managed mapping.');
|
|
739
|
+
return path;
|
|
740
|
+
}
|
|
741
|
+
const INDEX_PATH = `${GENERATED_ROOT}/COPY-INDEX.json`;
|
|
742
|
+
const JOURNAL_PATH = `${GENERATED_ROOT}/COPY-JOURNAL.json`;
|
|
743
|
+
const LOCK_PATH = `${GENERATED_ROOT}/COPY-LOCK.json`;
|
|
744
|
+
const METADATA_LIMIT = 1_048_576;
|
|
745
|
+
function indexFor(manifest) {
|
|
746
|
+
const copied = new Set(manifest.skills.flatMap(skill => skill.filePaths));
|
|
747
|
+
const files = manifest.files.filter(file => copied.has(file.path)).map(file => ({
|
|
748
|
+
path: file.path, sha256: file.sha256, sizeBytes: file.sizeBytes,
|
|
749
|
+
})).sort((a, b) => compare(a.path, b.path));
|
|
750
|
+
if (process.platform === 'win32' && new Set(files.map(file => file.path.toLowerCase())).size !== files.length) {
|
|
751
|
+
throw new Error('Windows managed mapping collision.');
|
|
752
|
+
}
|
|
753
|
+
for (const file of files)
|
|
754
|
+
copyPath(file.path);
|
|
755
|
+
const base = { schema: 'dharma.repository-package-copies/v1', snapshotHash: manifest.snapshotHash,
|
|
756
|
+
organizationId: manifest.organizationId, workspaceId: manifest.workspaceId, files };
|
|
757
|
+
return { ...base, indexHash: digest(canonicalize(base)) };
|
|
758
|
+
}
|
|
759
|
+
async function optionalBytes(workspace, path, limit = METADATA_LIMIT) {
|
|
760
|
+
try {
|
|
761
|
+
return await readStable(workspace, path, limit);
|
|
762
|
+
}
|
|
763
|
+
catch (error) {
|
|
764
|
+
if (missing(error))
|
|
765
|
+
return null;
|
|
766
|
+
throw error;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
async function directories(workspace, path) {
|
|
770
|
+
let current = '';
|
|
771
|
+
for (const part of pathKey(path).split('/')) {
|
|
772
|
+
current = current ? `${current}/${part}` : part;
|
|
773
|
+
try {
|
|
774
|
+
await mkdir(resolve(workspace, current), { mode: 0o700 });
|
|
775
|
+
}
|
|
776
|
+
catch (error) {
|
|
777
|
+
if (error.code !== 'EEXIST')
|
|
778
|
+
throw error;
|
|
779
|
+
}
|
|
780
|
+
if (!(await lstat(await checkedPath(workspace, current))).isDirectory())
|
|
781
|
+
throw new Error('Managed mapping directory conflict.');
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
async function syncDirectory(workspace, path) {
|
|
785
|
+
if (process.platform === 'win32')
|
|
786
|
+
return; // Windows does not support opening directories for fsync.
|
|
787
|
+
const handle = await open(await checkedPath(workspace, path), constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
|
|
788
|
+
try {
|
|
789
|
+
await handle.sync();
|
|
790
|
+
}
|
|
791
|
+
finally {
|
|
792
|
+
await handle.close();
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
async function freshFile(workspace, path, bytes) {
|
|
796
|
+
await directories(workspace, posix.dirname(path));
|
|
797
|
+
const handle = await open(resolve(workspace, path), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
|
|
798
|
+
try {
|
|
799
|
+
await handle.writeFile(bytes);
|
|
800
|
+
await handle.sync();
|
|
801
|
+
}
|
|
802
|
+
finally {
|
|
803
|
+
await handle.close();
|
|
804
|
+
}
|
|
805
|
+
await syncDirectory(workspace, posix.dirname(path));
|
|
806
|
+
}
|
|
807
|
+
async function atomicMetadata(workspace, path, value) {
|
|
808
|
+
const temporary = `${GENERATED_ROOT}/.metadata-${randomUUID()}.tmp`;
|
|
809
|
+
await freshFile(workspace, temporary, `${canonicalize(value)}\n`);
|
|
810
|
+
try {
|
|
811
|
+
await checkedPath(workspace, GENERATED_ROOT);
|
|
812
|
+
await optionalBytes(workspace, path);
|
|
813
|
+
await rename(resolve(workspace, temporary), resolve(workspace, path));
|
|
814
|
+
await syncDirectory(workspace, GENERATED_ROOT);
|
|
815
|
+
}
|
|
816
|
+
finally {
|
|
817
|
+
await unlink(resolve(workspace, temporary)).catch(error => { if (!missing(error))
|
|
818
|
+
throw error; });
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
async function snapshotFor(workspace, hash) {
|
|
822
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(hash))
|
|
823
|
+
throw new Error('Managed copies snapshot integrity failed.');
|
|
824
|
+
const bytes = await optionalBytes(workspace, `.dharma/repository-source/snapshots/${hash.slice(7)}.json`, 8_388_608)
|
|
825
|
+
?? await readStable(workspace, `${GENERATED_ROOT}/snapshots/${hash.slice(7)}.json`, 8_388_608);
|
|
826
|
+
const snapshot = { ...JSON.parse(bytes.toString('utf8')), schema: 'dharma.repository-package-snapshot/v1', capturedAt: '' };
|
|
827
|
+
if (snapshot.manifest.snapshotHash !== hash || serializeRepositoryPackageSnapshot(snapshot) !== bytes.toString('utf8')) {
|
|
828
|
+
throw new Error('Managed copies snapshot integrity failed.');
|
|
829
|
+
}
|
|
830
|
+
const validated = await validateContract(fileURLToPath(new URL('./schemas/', import.meta.url)), `https://schemas.dharma-ai.io/repository-package/${snapshot.manifest.schema.split('/')[1]}`, snapshot.manifest);
|
|
831
|
+
if (!validated.ok)
|
|
832
|
+
throw new Error('Managed copies snapshot schema integrity failed.');
|
|
833
|
+
return snapshot;
|
|
834
|
+
}
|
|
835
|
+
async function verifyIndex(workspace, index) {
|
|
836
|
+
if (!index || canonicalize(index) !== canonicalize(indexFor((await snapshotFor(workspace, index.snapshotHash)).manifest))) {
|
|
837
|
+
throw new Error('Managed copies ownership index integrity failed.');
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
function changes(journal) {
|
|
841
|
+
const before = new Map((journal.previous?.files || []).map(file => [file.path, file]));
|
|
842
|
+
const after = new Map(journal.desired.files.map(file => [file.path, file]));
|
|
843
|
+
return [...new Set([...before.keys(), ...after.keys()])].sort(compare)
|
|
844
|
+
.filter(path => before.get(path)?.sha256 !== after.get(path)?.sha256)
|
|
845
|
+
.map(path => ({ path, before: before.get(path), after: after.get(path) }));
|
|
846
|
+
}
|
|
847
|
+
function stagePaths(journal, number) {
|
|
848
|
+
const root = `${GENERATED_ROOT}/.copy-transactions/${journal.transactionId}`;
|
|
849
|
+
return { root, staged: `${root}/new/${number}`, backup: `${root}/old/${number}` };
|
|
850
|
+
}
|
|
851
|
+
async function matches(workspace, path, file) {
|
|
852
|
+
const bytes = await optionalBytes(workspace, path, DEFAULT_LIMITS.maximumFileBytes);
|
|
853
|
+
if (bytes && (!file || bytes.length !== file.sizeBytes || digest(bytes) !== file.sha256))
|
|
854
|
+
throw new Error('Managed copies file conflict.');
|
|
855
|
+
return bytes !== null;
|
|
856
|
+
}
|
|
857
|
+
async function sameFile(workspace, a, b) {
|
|
858
|
+
const one = await lstat(await checkedPath(workspace, a));
|
|
859
|
+
const two = await lstat(await checkedPath(workspace, b));
|
|
860
|
+
return one.ino === two.ino && one.dev === two.dev && one.ino !== 0;
|
|
861
|
+
}
|
|
862
|
+
async function prepareCopies(workspace, snapshot, checkpoint) {
|
|
863
|
+
const desired = indexFor(snapshot.manifest);
|
|
864
|
+
const bytes = await optionalBytes(workspace, INDEX_PATH);
|
|
865
|
+
const previous = bytes ? JSON.parse(bytes.toString('utf8')) : null;
|
|
866
|
+
if (previous) {
|
|
867
|
+
await verifyIndex(workspace, previous);
|
|
868
|
+
if (previous.organizationId !== desired.organizationId || previous.workspaceId !== desired.workspaceId)
|
|
869
|
+
throw new Error('Managed copies identity conflict.');
|
|
870
|
+
}
|
|
871
|
+
const manifestBytes = await optionalBytes(workspace, `${GENERATED_ROOT}/MANIFEST.json`);
|
|
872
|
+
const previousManifestHash = manifestBytes ? JSON.parse(manifestBytes.toString('utf8')).snapshotHash : null;
|
|
873
|
+
if (manifestBytes) {
|
|
874
|
+
const prior = await snapshotFor(workspace, previousManifestHash);
|
|
875
|
+
if (manifestBytes.toString('utf8') !== `${canonicalize(prior.manifest)}\n`
|
|
876
|
+
|| prior.manifest.organizationId !== desired.organizationId || prior.manifest.workspaceId !== desired.workspaceId
|
|
877
|
+
|| (previous && previous.snapshotHash !== previousManifestHash))
|
|
878
|
+
throw new Error('Managed copies unmanaged manifest conflict.');
|
|
879
|
+
}
|
|
880
|
+
else if (previous)
|
|
881
|
+
throw new Error('Managed copies missing manifest conflict.');
|
|
882
|
+
const old = new Map((previous?.files || []).map(file => [file.path, file]));
|
|
883
|
+
for (const file of previous?.files || []) {
|
|
884
|
+
if (!await matches(workspace, `${GENERATED_ROOT}/${copyPath(file.path)}`, file))
|
|
885
|
+
throw new Error('Managed copies missing owned file conflict.');
|
|
886
|
+
}
|
|
887
|
+
for (const file of desired.files) {
|
|
888
|
+
if (!old.has(file.path) && await optionalBytes(workspace, `${GENERATED_ROOT}/${copyPath(file.path)}`, DEFAULT_LIMITS.maximumFileBytes)) {
|
|
889
|
+
throw new Error('Refusing unmanaged managed-copy destination conflict.');
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
const base = { schema: 'dharma.repository-package-copy-journal/v1', transactionId: randomUUID(), previous, previousManifestHash, desired };
|
|
893
|
+
const journal = { ...base, journalHash: digest(canonicalize(base)) };
|
|
894
|
+
const operations = changes(journal);
|
|
895
|
+
const blobs = new Map(snapshot.blobs.map(blob => [blob.sha256, Buffer.from(blob.contentBase64, 'base64')]));
|
|
896
|
+
for (const [number, operation] of operations.entries()) {
|
|
897
|
+
const { staged, backup } = stagePaths(journal, number);
|
|
898
|
+
await directories(workspace, posix.dirname(backup));
|
|
899
|
+
if (operation.after)
|
|
900
|
+
await freshFile(workspace, staged, blobs.get(operation.after.sha256));
|
|
901
|
+
}
|
|
902
|
+
await atomicMetadata(workspace, JOURNAL_PATH, journal);
|
|
903
|
+
await checkpoint?.('journal_prepared');
|
|
904
|
+
await applyJournal(workspace, journal, snapshot, checkpoint);
|
|
905
|
+
}
|
|
906
|
+
async function recoverCopies(workspace) {
|
|
907
|
+
const bytes = await optionalBytes(workspace, JOURNAL_PATH);
|
|
908
|
+
if (!bytes)
|
|
909
|
+
return;
|
|
910
|
+
const journal = JSON.parse(bytes.toString('utf8'));
|
|
911
|
+
const { journalHash, ...base } = journal;
|
|
912
|
+
if (journal.schema !== 'dharma.repository-package-copy-journal/v1'
|
|
913
|
+
|| !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(journal.transactionId)
|
|
914
|
+
|| journalHash !== digest(canonicalize(base)))
|
|
915
|
+
throw new Error('Managed copies journal integrity failed.');
|
|
916
|
+
await verifyIndex(workspace, journal.desired);
|
|
917
|
+
if (journal.previous) {
|
|
918
|
+
await verifyIndex(workspace, journal.previous);
|
|
919
|
+
if (journal.previous.organizationId !== journal.desired.organizationId || journal.previous.workspaceId !== journal.desired.workspaceId) {
|
|
920
|
+
throw new Error('Managed copies journal identity conflict.');
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
if (journal.previousManifestHash !== null) {
|
|
924
|
+
const prior = (await snapshotFor(workspace, journal.previousManifestHash)).manifest;
|
|
925
|
+
if (prior.organizationId !== journal.desired.organizationId || prior.workspaceId !== journal.desired.workspaceId
|
|
926
|
+
|| (journal.previous && journal.previous.snapshotHash !== journal.previousManifestHash))
|
|
927
|
+
throw new Error('Managed copies journal manifest conflict.');
|
|
928
|
+
}
|
|
929
|
+
else if (journal.previous)
|
|
930
|
+
throw new Error('Managed copies journal manifest integrity failed.');
|
|
931
|
+
await applyJournal(workspace, journal, await snapshotFor(workspace, journal.desired.snapshotHash));
|
|
932
|
+
}
|
|
933
|
+
async function verifyMetadata(workspace, journal, desiredManifest) {
|
|
934
|
+
const currentIndex = await optionalBytes(workspace, INDEX_PATH);
|
|
935
|
+
const currentManifest = await optionalBytes(workspace, `${GENERATED_ROOT}/MANIFEST.json`);
|
|
936
|
+
const previousManifest = journal.previousManifestHash === null ? null
|
|
937
|
+
: `${canonicalize((await snapshotFor(workspace, journal.previousManifestHash)).manifest)}\n`;
|
|
938
|
+
if (currentIndex && currentIndex.toString('utf8') !== `${canonicalize(journal.desired)}\n`
|
|
939
|
+
&& currentIndex.toString('utf8') !== `${canonicalize(journal.previous)}\n`)
|
|
940
|
+
throw new Error('Managed copies index conflict.');
|
|
941
|
+
if (!currentIndex && journal.previous)
|
|
942
|
+
throw new Error('Managed copies missing index conflict.');
|
|
943
|
+
if (currentManifest && currentManifest.toString('utf8') !== desiredManifest && currentManifest.toString('utf8') !== previousManifest) {
|
|
944
|
+
throw new Error('Managed copies unmanaged manifest conflict.');
|
|
945
|
+
}
|
|
946
|
+
if (!currentManifest && previousManifest)
|
|
947
|
+
throw new Error('Managed copies missing manifest conflict.');
|
|
948
|
+
return currentIndex?.toString('utf8') === `${canonicalize(journal.desired)}\n` && currentManifest?.toString('utf8') === desiredManifest;
|
|
949
|
+
}
|
|
950
|
+
async function verifyCopies(workspace, journal) {
|
|
951
|
+
for (const file of journal.desired.files) {
|
|
952
|
+
if (!await matches(workspace, `${GENERATED_ROOT}/${copyPath(file.path)}`, file))
|
|
953
|
+
throw new Error('Managed copies missing desired file conflict.');
|
|
954
|
+
}
|
|
955
|
+
for (const operation of changes(journal).filter(operation => !operation.after)) {
|
|
956
|
+
if (await optionalBytes(workspace, `${GENERATED_ROOT}/${copyPath(operation.path)}`, DEFAULT_LIMITS.maximumFileBytes)) {
|
|
957
|
+
throw new Error('Managed copies stale destination conflict.');
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
async function applyJournal(workspace, journal, snapshot, checkpoint) {
|
|
962
|
+
const desiredManifest = `${canonicalize(snapshot.manifest)}\n`;
|
|
963
|
+
const committed = await verifyMetadata(workspace, journal, desiredManifest);
|
|
964
|
+
const operations = changes(journal);
|
|
965
|
+
if (!committed) {
|
|
966
|
+
for (const [number, operation] of operations.entries()) {
|
|
967
|
+
const target = `${GENERATED_ROOT}/${copyPath(operation.path)}`;
|
|
968
|
+
const { staged, backup } = stagePaths(journal, number);
|
|
969
|
+
const saved = await matches(workspace, backup, operation.before);
|
|
970
|
+
const targetBytes = await optionalBytes(workspace, target, DEFAULT_LIMITS.maximumFileBytes);
|
|
971
|
+
const installed = !!operation.after && !!targetBytes && digest(targetBytes) === operation.after.sha256
|
|
972
|
+
&& await matches(workspace, staged, operation.after) && await sameFile(workspace, target, staged);
|
|
973
|
+
if (targetBytes && !installed) {
|
|
974
|
+
if (!operation.before || saved || !await matches(workspace, target, operation.before))
|
|
975
|
+
throw new Error('Managed copies unmanaged destination conflict.');
|
|
976
|
+
await rename(resolve(workspace, target), resolve(workspace, backup));
|
|
977
|
+
await syncDirectory(workspace, posix.dirname(target));
|
|
978
|
+
await syncDirectory(workspace, posix.dirname(backup));
|
|
979
|
+
await checkpoint?.('file_backed_up');
|
|
980
|
+
}
|
|
981
|
+
else if (operation.before && !saved && !installed)
|
|
982
|
+
throw new Error('Managed copies missing backup conflict.');
|
|
983
|
+
if (operation.after && !installed) {
|
|
984
|
+
if (!await matches(workspace, staged, operation.after))
|
|
985
|
+
throw new Error('Managed copies missing staged file conflict.');
|
|
986
|
+
await directories(workspace, posix.dirname(target));
|
|
987
|
+
try {
|
|
988
|
+
await link(resolve(workspace, staged), resolve(workspace, target));
|
|
989
|
+
}
|
|
990
|
+
catch (error) {
|
|
991
|
+
if (error.code === 'EEXIST')
|
|
992
|
+
throw new Error('Managed copies unmanaged destination conflict.');
|
|
993
|
+
throw error;
|
|
994
|
+
}
|
|
995
|
+
await syncDirectory(workspace, posix.dirname(target));
|
|
996
|
+
await checkpoint?.('file_installed');
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
await verifyCopies(workspace, journal);
|
|
1000
|
+
await verifyMetadata(workspace, journal, desiredManifest);
|
|
1001
|
+
await atomicMetadata(workspace, INDEX_PATH, journal.desired);
|
|
1002
|
+
await checkpoint?.('copies_index_written');
|
|
1003
|
+
await verifyMetadata(workspace, journal, desiredManifest);
|
|
1004
|
+
await atomicMetadata(workspace, `${GENERATED_ROOT}/MANIFEST.json`, snapshot.manifest);
|
|
1005
|
+
await checkpoint?.('manifest_written');
|
|
1006
|
+
}
|
|
1007
|
+
await verifyCopies(workspace, journal);
|
|
1008
|
+
for (const [number, operation] of operations.entries()) {
|
|
1009
|
+
const { staged, backup } = stagePaths(journal, number);
|
|
1010
|
+
for (const [path, file] of [[staged, operation.after], [backup, operation.before]]) {
|
|
1011
|
+
if (await matches(workspace, path, file)) {
|
|
1012
|
+
await unlink(resolve(workspace, path));
|
|
1013
|
+
await syncDirectory(workspace, posix.dirname(path));
|
|
1014
|
+
await checkpoint?.('cleanup_file_removed');
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
// Only empty known directories are removed; unmanaged neighbors remain untouched.
|
|
1019
|
+
const folders = new Set();
|
|
1020
|
+
for (const [number, operation] of operations.entries()) {
|
|
1021
|
+
const { root, staged, backup } = stagePaths(journal, number);
|
|
1022
|
+
for (const folder of [posix.dirname(staged), posix.dirname(backup), root])
|
|
1023
|
+
folders.add(folder);
|
|
1024
|
+
if (!operation.after) {
|
|
1025
|
+
let folder = posix.dirname(`${GENERATED_ROOT}/${copyPath(operation.path)}`);
|
|
1026
|
+
while (folder.startsWith(`${GENERATED_ROOT}/skills/source/`)) {
|
|
1027
|
+
folders.add(folder);
|
|
1028
|
+
folder = posix.dirname(folder);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
for (const folder of [...folders].sort((a, b) => b.split('/').length - a.split('/').length || compare(a, b))) {
|
|
1033
|
+
try {
|
|
1034
|
+
await rmdir(await checkedPath(workspace, folder));
|
|
1035
|
+
}
|
|
1036
|
+
catch (error) {
|
|
1037
|
+
if (!missing(error) && !['ENOTEMPTY', 'EEXIST'].includes(String(error.code)))
|
|
1038
|
+
throw error;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
const bytes = await readStable(workspace, JOURNAL_PATH, METADATA_LIMIT);
|
|
1042
|
+
if (bytes.toString('utf8') !== `${canonicalize(journal)}\n`)
|
|
1043
|
+
throw new Error('Managed copies journal conflict.');
|
|
1044
|
+
await unlink(resolve(workspace, JOURNAL_PATH));
|
|
1045
|
+
await syncDirectory(workspace, GENERATED_ROOT);
|
|
1046
|
+
}
|
|
1047
|
+
async function withCopyLock(workspace, action) {
|
|
1048
|
+
const lock = { schema: 'dharma.repository-package-copy-lock/v1', platform: process.platform, pid: process.pid, nonce: randomUUID() };
|
|
1049
|
+
const text = `${canonicalize(lock)}\n`;
|
|
1050
|
+
const temporary = `${GENERATED_ROOT}/.lock-${lock.nonce}.tmp`;
|
|
1051
|
+
await freshFile(workspace, temporary, text);
|
|
1052
|
+
let acquired = false;
|
|
1053
|
+
try {
|
|
1054
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1055
|
+
try {
|
|
1056
|
+
await link(resolve(workspace, temporary), resolve(workspace, LOCK_PATH));
|
|
1057
|
+
acquired = true;
|
|
1058
|
+
break;
|
|
1059
|
+
}
|
|
1060
|
+
catch (error) {
|
|
1061
|
+
if (error.code !== 'EEXIST')
|
|
1062
|
+
throw error;
|
|
1063
|
+
const bytes = await readStable(workspace, LOCK_PATH, 4096);
|
|
1064
|
+
const existing = JSON.parse(bytes.toString('utf8'));
|
|
1065
|
+
if (existing.schema !== lock.schema || existing.platform !== lock.platform || !Number.isSafeInteger(existing.pid) || existing.pid < 1) {
|
|
1066
|
+
throw new Error('Managed copies lock conflict.');
|
|
1067
|
+
}
|
|
1068
|
+
try {
|
|
1069
|
+
process.kill(existing.pid, 0);
|
|
1070
|
+
throw new Error('Managed copies writer is active; retry later.');
|
|
1071
|
+
}
|
|
1072
|
+
catch (error) {
|
|
1073
|
+
if (error.code !== 'ESRCH')
|
|
1074
|
+
throw error;
|
|
1075
|
+
}
|
|
1076
|
+
if (!(await readStable(workspace, LOCK_PATH, 4096)).equals(bytes))
|
|
1077
|
+
throw new Error('Managed copies lock conflict.');
|
|
1078
|
+
await unlink(resolve(workspace, LOCK_PATH));
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
if (!acquired)
|
|
1082
|
+
throw new Error('Managed copies lock conflict.');
|
|
1083
|
+
await action();
|
|
1084
|
+
}
|
|
1085
|
+
finally {
|
|
1086
|
+
if (acquired) {
|
|
1087
|
+
if ((await readStable(workspace, LOCK_PATH, 4096)).toString('utf8') !== text)
|
|
1088
|
+
throw new Error('Managed copies lock conflict.');
|
|
1089
|
+
await unlink(resolve(workspace, LOCK_PATH));
|
|
1090
|
+
await syncDirectory(workspace, GENERATED_ROOT);
|
|
1091
|
+
}
|
|
1092
|
+
await unlink(resolve(workspace, temporary));
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
//# sourceMappingURL=repositoryPackage.js.map
|