@6reduk/workspace-pipeline 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +78 -0
  3. package/docs/collaboration.md +59 -0
  4. package/docs/config-fields.md +65 -0
  5. package/docs/contracts.md +149 -0
  6. package/docs/doctor.md +165 -0
  7. package/docs/launch.md +50 -0
  8. package/docs/lifecycle-cli.md +141 -0
  9. package/docs/lifecycle.md +42 -0
  10. package/docs/migrations/unity.md +288 -0
  11. package/docs/native-provider-format.md +149 -0
  12. package/docs/provider-bundles.md +81 -0
  13. package/docs/provider-resources.md +37 -0
  14. package/docs/release.md +25 -0
  15. package/docs/remove.md +70 -0
  16. package/docs/repair.md +80 -0
  17. package/docs/repositories.md +209 -0
  18. package/docs/repository-manual-recovery.md +102 -0
  19. package/docs/repository-observations.md +29 -0
  20. package/docs/repository-recovery.md +204 -0
  21. package/docs/repository-retention.md +46 -0
  22. package/docs/repository-transport-budgets.md +26 -0
  23. package/docs/retention.md +237 -0
  24. package/docs/source.md +50 -0
  25. package/docs/switch.md +412 -0
  26. package/package.json +39 -0
  27. package/schemas/common.schema.json +251 -0
  28. package/schemas/inventory.schema.json +15 -0
  29. package/schemas/operation.schema.json +286 -0
  30. package/schemas/pipeline.schema.json +317 -0
  31. package/schemas/state.schema.json +302 -0
  32. package/schemas/workspace.schema.json +67 -0
  33. package/src/cli.js +7 -0
  34. package/src/commands/adopt.js +2 -0
  35. package/src/commands/bootstrap-recovery.js +90 -0
  36. package/src/commands/dispatch.js +394 -0
  37. package/src/commands/init.js +84 -0
  38. package/src/commands/launch.js +69 -0
  39. package/src/commands/migration-apply.js +53 -0
  40. package/src/commands/migration.js +50 -0
  41. package/src/commands/repositories.js +61 -0
  42. package/src/commands/repository-abandon.js +16 -0
  43. package/src/commands/repository-ancestors.js +21 -0
  44. package/src/commands/repository-locks.js +77 -0
  45. package/src/contracts/parse.js +57 -0
  46. package/src/contracts/semantic.js +240 -0
  47. package/src/contracts/validate.js +21 -0
  48. package/src/launch/grok.js +20 -0
  49. package/src/migrations/legacy-unity-begin.js +35 -0
  50. package/src/migrations/legacy-unity-compensate.js +82 -0
  51. package/src/migrations/legacy-unity-deactivate.js +60 -0
  52. package/src/migrations/legacy-unity-deactivation-resume-apply.js +62 -0
  53. package/src/migrations/legacy-unity-deactivation-resume.js +81 -0
  54. package/src/migrations/legacy-unity-finalize.js +88 -0
  55. package/src/migrations/legacy-unity-install-recovery.js +76 -0
  56. package/src/migrations/legacy-unity-install-resume.js +60 -0
  57. package/src/migrations/legacy-unity-install.js +88 -0
  58. package/src/migrations/legacy-unity-lease.js +136 -0
  59. package/src/migrations/legacy-unity-preflight.js +67 -0
  60. package/src/migrations/legacy-unity-preview.js +91 -0
  61. package/src/migrations/legacy-unity-resume-apply.js +39 -0
  62. package/src/migrations/legacy-unity-resume.js +41 -0
  63. package/src/migrations/legacy-unity-resumed-evidence.js +88 -0
  64. package/src/migrations/legacy-unity.js +99 -0
  65. package/src/operations/apply.js +576 -0
  66. package/src/operations/backup.js +94 -0
  67. package/src/operations/bootstrap-lock.js +87 -0
  68. package/src/operations/bootstrap-owner-retirement.js +121 -0
  69. package/src/operations/bundle-update.js +54 -0
  70. package/src/operations/config-fields.js +24 -0
  71. package/src/operations/continuation-lifecycle.js +77 -0
  72. package/src/operations/doctor.js +214 -0
  73. package/src/operations/history.js +170 -0
  74. package/src/operations/installer-identity.js +49 -0
  75. package/src/operations/journal.js +142 -0
  76. package/src/operations/lifecycle.js +133 -0
  77. package/src/operations/lineage-guard.js +20 -0
  78. package/src/operations/lock.js +109 -0
  79. package/src/operations/maintenance.js +62 -0
  80. package/src/operations/migration-pending.js +22 -0
  81. package/src/operations/ownership.js +122 -0
  82. package/src/operations/plan.js +278 -0
  83. package/src/operations/reconciliation.js +112 -0
  84. package/src/operations/recovery-lease.js +66 -0
  85. package/src/operations/remove.js +140 -0
  86. package/src/operations/repair.js +188 -0
  87. package/src/operations/repository-abandon.js +192 -0
  88. package/src/operations/repository-ancestors.js +158 -0
  89. package/src/operations/repository-apply.js +122 -0
  90. package/src/operations/repository-authorization.js +48 -0
  91. package/src/operations/repository-bootstrap-continuation.js +190 -0
  92. package/src/operations/repository-bootstrap-reconcile.js +106 -0
  93. package/src/operations/repository-bootstrap-recover.js +114 -0
  94. package/src/operations/repository-bootstrap.js +82 -0
  95. package/src/operations/repository-clone.js +63 -0
  96. package/src/operations/repository-history.js +108 -0
  97. package/src/operations/repository-inputs.js +41 -0
  98. package/src/operations/repository-journal.js +127 -0
  99. package/src/operations/repository-lock-reconcile.js +401 -0
  100. package/src/operations/repository-pending.js +29 -0
  101. package/src/operations/repository-reconcile.js +182 -0
  102. package/src/operations/repository-resumption-approvals.js +77 -0
  103. package/src/operations/repository-retention-apply.js +75 -0
  104. package/src/operations/repository-retention.js +137 -0
  105. package/src/operations/repository-workspace.js +78 -0
  106. package/src/operations/retention-apply.js +133 -0
  107. package/src/operations/retention-combined-scan.js +30 -0
  108. package/src/operations/retention-combined.js +41 -0
  109. package/src/operations/retention-policy.js +65 -0
  110. package/src/operations/retention-receipts.js +126 -0
  111. package/src/operations/retention-scan.js +86 -0
  112. package/src/operations/retention.js +56 -0
  113. package/src/operations/state.js +210 -0
  114. package/src/operations/switch-activate.js +64 -0
  115. package/src/operations/switch-backups.js +29 -0
  116. package/src/operations/switch-continuation-journal.js +94 -0
  117. package/src/operations/switch-continuation-pending.js +60 -0
  118. package/src/operations/switch-continuation-records.js +109 -0
  119. package/src/operations/switch-continuation-recovery.js +91 -0
  120. package/src/operations/switch-continuation-runtime.js +135 -0
  121. package/src/operations/switch-continuation-store.js +120 -0
  122. package/src/operations/switch-continuation.js +76 -0
  123. package/src/operations/switch-execute.js +68 -0
  124. package/src/operations/switch-inspect.js +45 -0
  125. package/src/operations/switch-journal-store.js +109 -0
  126. package/src/operations/switch-journal.js +71 -0
  127. package/src/operations/switch-lifecycle.js +72 -0
  128. package/src/operations/switch-pending.js +43 -0
  129. package/src/operations/switch-preflight.js +73 -0
  130. package/src/operations/switch-prepare.js +75 -0
  131. package/src/operations/switch-records.js +60 -0
  132. package/src/operations/switch-recovery-store.js +74 -0
  133. package/src/operations/switch.js +52 -0
  134. package/src/operations/toml-fields.js +133 -0
  135. package/src/providers/bundles.js +42 -0
  136. package/src/providers/common-entry.js +16 -0
  137. package/src/providers/grok.js +26 -0
  138. package/src/providers/interface.js +25 -0
  139. package/src/providers/kimi.js +26 -0
  140. package/src/providers/native.js +155 -0
  141. package/src/providers/registry.js +10 -0
  142. package/src/providers/shared.js +51 -0
  143. package/src/providers/source.js +30 -0
  144. package/src/source/git.js +303 -0
  145. package/src/source/inventory.js +87 -0
  146. package/src/source/repository-budget.js +18 -0
  147. package/src/source/snapshot.js +37 -0
  148. package/src/workspace/paths.js +54 -0
  149. package/src/workspace/profiles.js +8 -0
  150. package/src/workspace/repositories.js +57 -0
  151. package/src/workspace/repository-inventory.js +77 -0
  152. package/src/workspace/repository-observation.js +38 -0
  153. package/src/workspace/repository-preflight.js +129 -0
  154. package/src/workspace/repository-preview.js +196 -0
  155. package/src/workspace/repository-tree.js +57 -0
  156. package/src/workspace/reserved.js +11 -0
  157. package/src/workspace/resolve.js +53 -0
@@ -0,0 +1,155 @@
1
+ import { posix } from 'node:path';
2
+ import { parseTOML } from 'toml-eslint-parser';
3
+ import { fail, parse, MAX_INPUT_BYTES } from '../contracts/parse.js';
4
+ import { portablePath } from '../contracts/semantic.js';
5
+ import { sourceText, snapshotFilePath } from './source.js';
6
+ import { legacyEntryReplay } from './common-entry.js';
7
+
8
+ const capabilities = Object.freeze(['skills', 'agents', 'mcp', 'entry-instructions']);
9
+ const safeName = name => typeof name === 'string' && /^[a-z][a-z0-9-]{0,62}$/.test(name) &&
10
+ !['constructor', 'prototype', 'default'].includes(name);
11
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
12
+ function shape(value, required, optional = []) {
13
+ if (!object(value) || required.some(key => !Object.hasOwn(value, key)) ||
14
+ Object.keys(value).some(key => ![...required, ...optional].includes(key))) fail('provider.format');
15
+ }
16
+ function text(value, limit = MAX_INPUT_BYTES) {
17
+ if (typeof value !== 'string' || !value.trim() || Buffer.byteLength(value) > limit || /\u0000/.test(value)) fail('provider.format');
18
+ return value;
19
+ }
20
+ function metadata(value) {
21
+ shape(value, ['name', 'description']);
22
+ if (!safeName(value.name)) fail('provider.name');
23
+ text(value.description, 1024);
24
+ return value;
25
+ }
26
+ function markdown(input) {
27
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/.exec(input);
28
+ if (!match) fail('provider.frontmatter');
29
+ const meta = metadata(parse(match[1])); text(match[2]);
30
+ return { ...meta, body: match[2] };
31
+ }
32
+ function tree(context, prefix) {
33
+ portablePath(prefix);
34
+ const entries = [...context.files.keys()].filter(name => name.startsWith(prefix + '/')).sort();
35
+ if (!entries.length) fail('provider.source-missing');
36
+ return entries;
37
+ }
38
+ const file = (owner, path, content) => ({ owner, path, kind: 'file', bytes: Buffer.from(content) });
39
+ function route(context, source, destination) {
40
+ const target = posix.relative(posix.dirname(destination), snapshotFilePath(context, source));
41
+ return `Read [the complete pipeline instruction](<${target}>) before acting.\n` +
42
+ 'Resolve its relative links from that source file, not from this routing file or the shell working directory.\n' +
43
+ 'The source is a resource location, not the project root. Use the wrapper AGENTS.md for project routing.\n' +
44
+ 'Follow its gates and scope; this routing file grants no approval.\n';
45
+ }
46
+ const providerRoot = id => {
47
+ const root = { codex: '.agents', claude: '.claude', kimi: '.kimi-code', grok: '.grok' }[id];
48
+ if (typeof root !== 'string') fail('provider.unsupported');
49
+ return root;
50
+ };
51
+ export function skills(context, id, prefix) {
52
+ const entries = tree(context, prefix), roots = new Map();
53
+ for (const source of entries) {
54
+ const relative = source.slice(prefix.length + 1), parts = relative.split('/');
55
+ if (!safeName(parts[0]) || parts.length < 2) fail('provider.skill-tree');
56
+ roots.set(parts[0], `${prefix}/${parts[0]}/SKILL.md`);
57
+ }
58
+ return [...roots].map(([name, source]) => {
59
+ const meta = markdown(sourceText(context, source));
60
+ if (meta.name !== name) fail('provider.name');
61
+ const destination = `${providerRoot(id)}/skills/${name}/SKILL.md`;
62
+ return file(id, destination, `---\nname: ${name}\ndescription: ${JSON.stringify(meta.description)}\n---\n\n` + route(context, source, destination));
63
+ });
64
+ }
65
+ export function agents(context, id, prefix) {
66
+ const requests = [], fields = [];
67
+ for (const source of tree(context, prefix)) {
68
+ const relative = source.slice(prefix.length + 1), extension = id === 'codex' ? '.toml' : '.md';
69
+ if (relative.includes('/') || !relative.endsWith(extension)) fail('provider.agent-tree');
70
+ const name = relative.slice(0, -extension.length), input = sourceText(context, source);
71
+ let meta;
72
+ if (id === 'codex') {
73
+ if (Buffer.byteLength(input) > MAX_INPUT_BYTES) fail('provider.format');
74
+ let ast;
75
+ try { ast = parseTOML(input, { tomlVersion: '1.0' }); }
76
+ catch { fail('provider.agent-toml'); }
77
+ const value = Object.create(null), pairs = ast.body[0].body;
78
+ if (pairs.length !== 3) fail('provider.format');
79
+ for (const pair of pairs) {
80
+ if (pair.type !== 'TOMLKeyValue' || pair.key.keys.length !== 1 || pair.value.type !== 'TOMLValue' || pair.value.kind !== 'string') fail('provider.format');
81
+ const key = pair.key.keys[0].name ?? pair.key.keys[0].value;
82
+ if (!['name', 'description', 'developer_instructions'].includes(key) || Object.hasOwn(value, key)) fail('provider.format');
83
+ value[key] = pair.value.value;
84
+ }
85
+ shape(value, ['name', 'description', 'developer_instructions']);
86
+ meta = metadata({ name: value.name, description: value.description });
87
+ text(value.developer_instructions);
88
+ if (meta.name !== name || name === 'enabled') fail('provider.name');
89
+ const destination = `.codex/agents/${name}.toml`;
90
+ // TOML basic strings use JSON-compatible escaping for accepted text.
91
+ const instructions = route(context, source, destination) + '\n' + value.developer_instructions;
92
+ if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(instructions)) fail('provider.format');
93
+ requests.push(file(id, destination, `developer_instructions = ${JSON.stringify(instructions)}\n`));
94
+ fields.push({ pointer: `/agents/${name}`, present: true,
95
+ value: { description: meta.description, config_file: `agents/${name}.toml` } });
96
+ } else {
97
+ meta = markdown(input);
98
+ if (meta.name !== name) fail('provider.name');
99
+ if (id === 'kimi' && ['agent', 'coder', 'explore', 'plan'].includes(name)) fail('provider.name');
100
+ if (id === 'grok' && ['general-purpose', 'explore', 'plan'].includes(name)) fail('provider.name');
101
+ const destination = `${providerRoot(id)}/agents/${name}.md`;
102
+ requests.push(file(id, destination, `---\nname: ${name}\ndescription: ${JSON.stringify(meta.description)}\n---\n\n` + route(context, source, destination)));
103
+ }
104
+ }
105
+ return { requests, fields };
106
+ }
107
+ export function mcp(context, id, source) {
108
+ const config = parse(sourceText(context, source), 'json'); shape(config, ['mcpServers']);
109
+ if (!object(config.mcpServers) || !Object.keys(config.mcpServers).length) fail('provider.mcp');
110
+ return Object.entries(config.mcpServers).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([name, server]) => {
111
+ if (!safeName(name)) fail('provider.name');
112
+ let value;
113
+ if (server?.type === 'stdio') {
114
+ shape(server, ['type', 'command'], ['args', 'env']); text(server.command, 4096);
115
+ if (server.args !== undefined && (!Array.isArray(server.args) || server.args.some(arg => typeof arg !== 'string' || arg.includes('\0')))) fail('provider.mcp');
116
+ if (server.env !== undefined && (!object(server.env) || Object.entries(server.env).some(([key, v]) =>
117
+ !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || typeof v !== 'string' || v.includes('\0')))) fail('provider.mcp');
118
+ const { type, ...stdio } = server; value = ['codex', 'kimi', 'grok'].includes(id) ? stdio : server;
119
+ } else if (server?.type === 'http') {
120
+ shape(server, ['type', 'url']);
121
+ text(server.url, 8192);
122
+ let url; try { url = new URL(server.url); } catch { fail('provider.mcp'); }
123
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) fail('provider.mcp');
124
+ value = ['codex', 'kimi', 'grok'].includes(id) ? { url: server.url } : server;
125
+ } else fail('provider.mcp');
126
+ return { pointer: `/${['codex', 'grok'].includes(id) ? 'mcp_servers' : 'mcpServers'}/${name}`, present: true, value };
127
+ });
128
+ }
129
+ function render(context, id) {
130
+ const declaration = context.pipeline.providers[id];
131
+ if (!declaration || declaration.requires.some(capability => !capabilities.includes(capability))) fail('provider.unsupported');
132
+ const requests = [], fields = [];
133
+ if (declaration.skills !== null) requests.push(...skills(context, id, declaration.skills));
134
+ if (declaration.agents !== null) {
135
+ const rendered = agents(context, id, declaration.agents); requests.push(...rendered.requests); fields.push(...rendered.fields);
136
+ }
137
+ if (declaration.mcp !== null) {
138
+ const entries = mcp(context, id, declaration.mcp);
139
+ if (id === 'codex') fields.push(...entries);
140
+ else requests.push({ owner: id, path: '.mcp.json', kind: 'json-fields', fields: entries });
141
+ }
142
+ if (id === 'codex' && fields.length) requests.push({ owner: id, path: '.codex/config.toml', kind: 'toml-fields', fields });
143
+ if (declaration.entryInstructions !== null) text(sourceText(context, declaration.entryInstructions));
144
+ // Codex reads common AGENTS.md; provider entry pointers are appended there.
145
+ if (id === 'claude' && !context.layout.bundles && legacyEntryReplay(context)) requests.push(file(id, 'CLAUDE.md', '@AGENTS.md\n' +
146
+ (declaration.entryInstructions === null ? '' : '\n' + route(context, declaration.entryInstructions, 'CLAUDE.md'))));
147
+ return requests;
148
+ }
149
+ function adapter(id) {
150
+ return Object.freeze({ id, version: id === 'claude' ? '2' : '1', capabilities,
151
+ async validate(context) { render(context, id); return { valid: true, runtime: 'not-run' }; },
152
+ async plan(context) { return render(context, id); } });
153
+ }
154
+ export const codexAdapter = adapter('codex');
155
+ export const claudeAdapter = adapter('claude');
@@ -0,0 +1,10 @@
1
+ import { codexAdapter, claudeAdapter } from './native.js';
2
+ import { sharedAdapter } from './shared.js';
3
+ import { kimiAdapter } from './kimi.js';
4
+ import { grokAdapter } from './grok.js';
5
+
6
+ // Compiled CLI code only. No registry/module names may be supplied by a package.
7
+ export const providerRegistry = Object.freeze({
8
+ adapters: Object.freeze({ codex: codexAdapter, claude: claudeAdapter, kimi: kimiAdapter, grok: grokAdapter }),
9
+ sharedAdapter
10
+ });
@@ -0,0 +1,51 @@
1
+ import { renderEntry, portablePath } from '../contracts/semantic.js';
2
+ import { fail } from '../contracts/parse.js';
3
+ import { sourceText, snapshotResourcePath, snapshotFilePath } from './source.js';
4
+ import { commonEntryPath, commonEntryText, needsCommonEntry, legacyEntryReplay } from './common-entry.js';
5
+
6
+ // Common entry has one owner, regardless of the number of selected providers.
7
+ // Ownership/conflict/approval policy remains in the ordinary planner, not here.
8
+ export const sharedAdapter = Object.freeze({
9
+ async plan(context) {
10
+ const selection = context.layout.agentsDocument;
11
+ let body;
12
+ if (selection.mode === 'source') body = sourceText(context, selection.path);
13
+ else if (selection.mode === 'default') body = '# Workspace instructions\n\n' +
14
+ 'Start harness sessions from this wrapper. Read the selected repository instructions before making changes.\n' +
15
+ 'Read documentation from its overview toward the relevant detailed artifacts; do not scan every document by default.\n' +
16
+ 'Do not infer approval, runtime readiness or task completion from installation metadata.\n';
17
+ else fail('entry.input');
18
+ body = renderEntry(body, context.layout.layout);
19
+ const lines = ['\n\n## Workspace routing\n',
20
+ 'Paths below are relative to this wrapper, not to the shell working directory.'];
21
+ for (const [id, repo] of Object.entries(context.layout.repositories).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
22
+ portablePath(repo.relative);
23
+ lines.push(`- Repository \`${id}\`: \`${repo.relative}\` (${repo.role}).`);
24
+ }
25
+ portablePath(context.layout.documentation.relative);
26
+ lines.push(`- Project documentation: \`${context.layout.documentation.relative}\`.`);
27
+ for (const [id, root] of Object.entries(context.layout.projectRoots).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
28
+ portablePath(root.relative);
29
+ lines.push(`- Project root \`${id}\`: \`${root.relative}\`.`);
30
+ }
31
+ lines.push(`- Pipeline resources: \`${snapshotResourcePath(context)}\`.`,
32
+ 'The snapshot identifier locates installed resources; it does not pin project documents or grant task approval.');
33
+ // Keep common entry stable when one installed provider is removed. These are
34
+ // available source routes, not a claim that all providers are enabled.
35
+ for (const id of Object.keys(context.pipeline.providers ?? {}).sort()) {
36
+ const entry = context.pipeline.providers[id].entryInstructions;
37
+ if (entry !== null) lines.push(`If using ${id}, read its [provider instructions](<${snapshotFilePath(context, entry)}>) before acting. This link does not enable that provider.`);
38
+ }
39
+ const bytes = Buffer.from(body + lines.join('\n') + '\n', 'utf8');
40
+ if (bytes.length > 2 * 1024 * 1024) fail('entry.input');
41
+ const requests = [{ path: 'AGENTS.md', owner: 'shared', kind: 'file', bytes }];
42
+ const bundle = Object.values(context.layout.bundles ?? {})[0];
43
+ if (bundle) {
44
+ const entry = renderEntry(sourceText(context, bundle.entry.source), context.layout.layout);
45
+ if (!entry.trim() || entry.includes('\0')) fail('entry.input');
46
+ requests.push({ path: bundle.entry.target, owner: 'shared', kind: 'file', bytes: Buffer.from(entry) });
47
+ } else if (needsCommonEntry(context.layout.providers ?? context.workspace.providers) && !legacyEntryReplay(context))
48
+ requests.push({ path: commonEntryPath, owner: 'shared', kind: 'file', bytes: Buffer.from(commonEntryText) });
49
+ return requests;
50
+ }
51
+ });
@@ -0,0 +1,30 @@
1
+ import { posix } from 'node:path';
2
+ import { fail } from '../contracts/parse.js';
3
+ import { portablePath } from '../contracts/semantic.js';
4
+ import { utf8 } from '../source/inventory.js';
5
+
6
+ // Reads exclusively from the already verified Git tree supplied by preparePlan.
7
+ // No checkout paths, network requests, source modules or filesystem fallback.
8
+ export function sourceBytes(context, name) {
9
+ portablePath(name);
10
+ const bytes = context.files.get(name);
11
+ if (!Buffer.isBuffer(bytes)) fail('provider.source-missing');
12
+ return Buffer.from(bytes);
13
+ }
14
+ export function sourceText(context, name) { return utf8(sourceBytes(context, name)); }
15
+
16
+ export function snapshotFilePath(context, name) {
17
+ snapshotResourcePath(context);
18
+ sourceBytes(context, name);
19
+ return posix.join(context.snapshot.path, name);
20
+ }
21
+
22
+ export function snapshotResourcePath(context) {
23
+ const { snapshot, pipeline } = context;
24
+ if (!snapshot || !/^sha256:[a-f0-9]{64}$/.test(snapshot.digest) ||
25
+ snapshot.path !== '.pipeline/snapshots/' + snapshot.digest.slice(7)) fail('provider.snapshot');
26
+ portablePath(pipeline.resources);
27
+ const prefix = pipeline.resources + '/';
28
+ if (![...context.files.keys()].some(name => name.startsWith(prefix))) fail('provider.resources');
29
+ return posix.join(snapshot.path, pipeline.resources);
30
+ }
@@ -0,0 +1,303 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdtemp, mkdir, readdir, stat, realpath, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { performance } from 'node:perf_hooks';
6
+ import { ContractError, fail } from '../contracts/parse.js';
7
+ import { validateStructure } from '../contracts/validate.js';
8
+ import { portablePath } from '../contracts/semantic.js';
9
+ import { LIMITS, cap, utf8, verifyPackage } from './inventory.js';
10
+ import { materialize } from './snapshot.js';
11
+ import { repositoryBudget } from './repository-budget.js';
12
+
13
+ const oid = value => /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value);
14
+ // Git for Windows understands /dev/null, but not Node's \\.\nul spelling.
15
+ const devNull = '/dev/null';
16
+ export function validateSource(source) {
17
+ // Reuse the reviewed Git union via its public workspace envelope, no new schema.
18
+ validateStructure('workspace', { schemaVersion: 1, pipeline: source, providers: ['codex'], profile: 'source-validation' });
19
+ if (source.subdirectory !== '.') portablePath(source.subdirectory);
20
+ if (source.ref !== 'HEAD' && !oid(source.ref)) {
21
+ if (source.ref.startsWith('refs/') && !/^refs\/(?:heads|tags)\//.test(source.ref)) fail('source.ref');
22
+ if (source.ref.endsWith('.') || source.ref.split('/').some(p => p.startsWith('.') || p.endsWith('.lock'))) fail('source.ref');
23
+ }
24
+ if (source.transport === 'remote') {
25
+ let url;
26
+ try { url = new URL(source.url); } catch { fail('source.url'); }
27
+ const rawAuthority = source.url.split('/')[2];
28
+ const port = /:(\d+)$/.exec(rawAuthority)?.[1];
29
+ if (port !== undefined && (+port < 1 || +port > 65535)) fail('source.port');
30
+ if (url.hostname.split('.').some(label => !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(label))) fail('source.host');
31
+ let decoded;
32
+ try { decoded = decodeURIComponent(url.pathname); } catch { fail('source.url'); }
33
+ if (/[%\\\s\u0000-\u001f\u007f\u0085\ufeff?#]/u.test(decoded)) fail('source.url');
34
+ }
35
+ return source;
36
+ }
37
+ export function environment(network, alternate) {
38
+ const env = {};
39
+ for (const [k, v] of Object.entries(process.env)) {
40
+ if (!/^GIT_/i.test(k) || ['GIT_SSH', 'GIT_SSH_COMMAND', 'GIT_SSH_VARIANT', 'GIT_ASKPASS',
41
+ ...(network ? ['GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM', 'GIT_CONFIG_NOSYSTEM'] : [])].includes(k.toUpperCase())) env[k] = v;
42
+ }
43
+ Object.assign(env, { GIT_OPTIONAL_LOCKS: '0', GIT_NO_REPLACE_OBJECTS: '1',
44
+ GIT_NO_LAZY_FETCH: '1', GIT_LITERAL_PATHSPECS: '1', GIT_TERMINAL_PROMPT: '0' });
45
+ // Local object reads need no authentication or global Git configuration.
46
+ if (!network) Object.assign(env, { GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: devNull });
47
+ // Git accepts C-quoted entries, preventing separators in a path from becoming
48
+ // additional object stores. This is internal discovery, never package input.
49
+ if (alternate) env.GIT_ALTERNATE_OBJECT_DIRECTORIES = JSON.stringify(alternate.replaceAll('\\', '/'));
50
+ return env;
51
+ }
52
+ async function bytesIn(directory) {
53
+ let size = 0;
54
+ for (const e of await readdir(directory, { withFileTypes: true })) {
55
+ const p = path.join(directory, e.name);
56
+ if (e.isSymbolicLink()) fail('source.preparation-link');
57
+ try { size += e.isDirectory() ? await bytesIn(p) : (await stat(p)).size; }
58
+ catch (e) { if (e.code !== 'ENOENT') throw e; } // pack temporary file renamed
59
+ }
60
+ return size;
61
+ }
62
+ export function gitArgs(cwd, args) {
63
+ return ['--no-pager', '--no-replace-objects', '--no-lazy-fetch', '--literal-pathspecs', '-C', cwd,
64
+ '-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=' + devNull,
65
+ '-c', 'maintenance.auto=false', '-c', 'gc.auto=0', '-c', 'fetch.writeCommitGraph=false',
66
+ '-c', 'protocol.ext.allow=never', ...args];
67
+ }
68
+ // Output and stored-object caps are independent. On Windows terminate the owned
69
+ // child process tree; never kill by process name or touch unrelated sessions.
70
+ export function runGit(cwd, args, options = {}, runtime = {}) {
71
+ return runBoundedGit(LIMITS, cwd, args, options, runtime);
72
+ }
73
+ export function runRepositoryGit(cwd, args, options = {}, runtime = {}) {
74
+ const budget = repositoryBudget(options);
75
+ return runBoundedGit({...LIMITS, blob:256*1024*1024, pack:budget.packLimit, gitMs:budget.gitMs, acquisitionMs:budget.acquisitionMs},
76
+ cwd, args, {...options, packLimit:budget.packLimit}, runtime);
77
+ }
78
+ async function runBoundedGit(limits, cwd, args, { deadline = performance.now() + limits.acquisitionMs,
79
+ outputLimit = LIMITS.metadata, network = false, objects, packLimit = LIMITS.pack, allowMissing = false, alternate,
80
+ diagnoseUnadvertised = false } = {}, runtime = {}) {
81
+ cap(packLimit, limits.pack, 'source.pack-limit');
82
+ cap(outputLimit, limits.blob, 'source.output-limit');
83
+ const timeout = Math.min(limits.gitMs, deadline - performance.now());
84
+ if (timeout <= 0) fail('source.timeout');
85
+ return new Promise((resolve, reject) => {
86
+ const launch = runtime.spawn ?? spawn;
87
+ const child = launch('git', gitArgs(cwd, args), {
88
+ shell: false, windowsHide: true, env: environment(network, alternate), stdio: ['ignore', 'pipe', 'pipe'],
89
+ detached: process.platform !== 'win32'
90
+ });
91
+ let failure, length = 0, stderrLength = 0, chunks = [], monitoring = false, closed = false, settled = false, terminationTimer;
92
+ let stderrTail = '', unadvertised = false;
93
+ const stop = code => {
94
+ if (failure || closed) return;
95
+ failure = code;
96
+ // A surviving descendant can retain the pipe after Git exits. Never wait
97
+ // indefinitely for close; report uncertainty rather than pretending it died.
98
+ terminationTimer = setTimeout(() => {
99
+ if (settled) return;
100
+ settled = true;
101
+ clearTimeout(timer); clearInterval(monitor);
102
+ child.stdout.destroy(); child.stderr.destroy(); child.unref();
103
+ const error = new ContractError('source.termination-unconfirmed');
104
+ error.processId = child.pid;
105
+ reject(error);
106
+ }, 2000);
107
+ if (child.pid) {
108
+ if ((runtime.platform ?? process.platform) === 'win32') {
109
+ const killer = launch('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore', shell: false });
110
+ killer.on('error', () => child.kill());
111
+ killer.on('exit', code => { if (code !== 0 && !closed) child.kill(); });
112
+ } else { try { process.kill(-child.pid, 'SIGKILL'); } catch { child.kill(); } }
113
+ }
114
+ };
115
+ const timer = setTimeout(() => stop('source.timeout'), timeout);
116
+ const monitor = objects ? setInterval(async () => {
117
+ if (monitoring || failure) return;
118
+ monitoring = true;
119
+ try { if (await bytesIn(objects) > packLimit) stop('source.pack'); }
120
+ catch { stop('source.pack-monitor'); }
121
+ finally { monitoring = false; }
122
+ }, 25) : null;
123
+ child.stdout.on('data', b => {
124
+ length += b.length;
125
+ if (length > outputLimit) stop('source.output');
126
+ else chunks.push(b);
127
+ });
128
+ child.stderr.on('data', b => {
129
+ stderrLength += b.length;
130
+ if (stderrLength > LIMITS.metadata) { stop('source.stderr'); return; }
131
+ if (diagnoseUnadvertised) {
132
+ // Recognize a fixed native diagnostic only. Never expose remote stderr,
133
+ // URLs or credentials; chunk boundaries must not change classification.
134
+ const text = stderrTail + b.toString('utf8');
135
+ unadvertised ||= /Server does not allow request for unadvertised object/i.test(text);
136
+ stderrTail = text.slice(-128);
137
+ }
138
+ });
139
+ child.on('error', () => { settled = true; clearTimeout(timer); clearTimeout(terminationTimer); clearInterval(monitor); reject(new ContractError('source.git-unavailable')); });
140
+ child.on('close', async code => {
141
+ closed = true;
142
+ clearTimeout(timer); clearTimeout(terminationTimer); clearInterval(monitor);
143
+ if (settled) return;
144
+ settled = true;
145
+ try {
146
+ if (objects && await bytesIn(objects) > packLimit) failure = 'source.pack';
147
+ if (failure) fail(failure);
148
+ if (code !== 0 && !(allowMissing && code === 1)) fail(unadvertised ? 'source.unadvertised-commit' : 'source.git-failed');
149
+ resolve({ code, bytes: Buffer.concat(chunks) });
150
+ } catch (e) { reject(e instanceof ContractError ? e : new ContractError('source.git-failed')); }
151
+ });
152
+ });
153
+ }
154
+ // Source Git is used only for bounded metadata discovery. Ref peeling and all
155
+ // object interpretation run in a fresh bare repo: no source config/index/hooks
156
+ // are copied. The temporary read-only alternate is never part of the snapshot.
157
+ async function localRepo(sourceRepo, ref, preparation, options) {
158
+ const inspect = async args => utf8((await runGit(sourceRepo, args, options)).bytes).trim();
159
+ const bare = await inspect(['rev-parse', '--is-bare-repository']) === 'true';
160
+ const root = await inspect(['rev-parse', bare ? '--absolute-git-dir' : '--show-toplevel']);
161
+ if (await realpath(root) !== sourceRepo) fail('source.repository-root');
162
+ const common = await inspect(['rev-parse', '--path-format=absolute', '--git-common-dir']);
163
+ const alternate = await realpath(path.join(common, 'objects'));
164
+ const format = await inspect(['rev-parse', '--show-object-format=storage']);
165
+ if (!['sha1', 'sha256'].includes(format)) fail('source.object-format');
166
+ const repo = path.join(preparation, 'objects.git');
167
+ await mkdir(repo);
168
+ await runGit(repo, ['init', '--bare', '--template=', '--object-format=' + format], options);
169
+ const names = ref === 'HEAD' || oid(ref) || ref.startsWith('refs/') ? [ref] : ['refs/heads/' + ref, 'refs/tags/' + ref];
170
+ for (const name of names) {
171
+ if (oid(name)) continue;
172
+ const found = await runGit(sourceRepo, ['rev-parse', '--verify', '--quiet', '--end-of-options', name], { ...options, allowMissing: true });
173
+ if (found.code !== 0) continue;
174
+ const value = utf8(found.bytes).trim();
175
+ if (!oid(value)) fail('source.ref-output');
176
+ // Packed refs and reftable are read by native Git, not guessed on disk.
177
+ const target = path.join(repo, ...name.split('/'));
178
+ await mkdir(path.dirname(target), { recursive: true });
179
+ await writeFile(target, value + '\n');
180
+ }
181
+ const isolatedOptions = { ...options, alternate };
182
+ const commit = await resolveLocal(repo, ref, isolatedOptions);
183
+ return { repo, commit, alternate };
184
+ }
185
+ async function resolveLocal(repo, ref, options) {
186
+ async function candidate(name) {
187
+ const r = await runGit(repo, ['rev-parse', '--verify', '--quiet', '--end-of-options', name + '^{commit}'], { ...options, allowMissing: true });
188
+ return r.code === 0 ? utf8(r.bytes).trim() : null;
189
+ }
190
+ let values;
191
+ if (ref === 'HEAD' || oid(ref) || ref.startsWith('refs/')) values = [await candidate(ref)];
192
+ else {
193
+ values = [await candidate('refs/heads/' + ref), await candidate('refs/tags/' + ref)];
194
+ if (values.filter(Boolean).length > 1) fail('source.ambiguous-ref');
195
+ }
196
+ const value = values.find(Boolean);
197
+ if (!value || !oid(value)) fail('source.missing-ref');
198
+ if (oid(ref) && value !== ref) fail('source.not-commit');
199
+ return value;
200
+ }
201
+ export function parseListing(buffer) {
202
+ const text = utf8(buffer);
203
+ if (text && !text.endsWith('\0')) fail('source.tree-output');
204
+ return text.split('\0').filter(Boolean).map(line => {
205
+ const match = /^(\d{6}) (blob|tree|commit) ([a-f0-9]{40}|[a-f0-9]{64})\s+(\d+|-)\t([\s\S]+)$/.exec(line);
206
+ if (!match) fail('source.tree-output');
207
+ return { mode: match[1], type: match[2], oid: match[3], size: match[4] === '-' ? 0 : Number(match[4]), path: match[5] };
208
+ });
209
+ }
210
+ const runBoundedSourceGit = runGit;
211
+ async function remoteRepo(source, options, preparation, runGit = runBoundedSourceGit) {
212
+ const repo = path.join(preparation, 'objects.git');
213
+ await mkdir(repo);
214
+ await runGit(repo, ['init', '--bare', '--template='], options);
215
+ const protocol = source.url.startsWith('https:') ? 'https' : 'ssh';
216
+ const transport = ['-c', 'protocol.allow=never', '-c', 'protocol.' + protocol + '.allow=always',
217
+ '-c', 'http.followRedirects=false'];
218
+ let wanted = source.ref;
219
+ if (!oid(wanted)) {
220
+ const refs = wanted === 'HEAD' || wanted.startsWith('refs/') ? [wanted] : ['refs/heads/' + wanted, 'refs/tags/' + wanted];
221
+ const listing = await runGit(repo, [...transport, 'ls-remote', '--', source.url, ...refs], { ...options, network: true });
222
+ const found = utf8(listing.bytes).trim().split('\n').filter(Boolean).map(l => l.split('\t')).filter(([, ref]) => refs.includes(ref));
223
+ if (found.length > 1) fail('source.ambiguous-ref');
224
+ if (found.length !== 1 || !oid(found[0][0])) fail('source.missing-ref');
225
+ wanted = found[0][0];
226
+ }
227
+ await runGit(repo, [...transport, '-c', 'fetch.unpackLimit=0', '-c', 'transfer.unpackLimit=0',
228
+ 'fetch', '--depth=1', '--no-tags', '--no-recurse-submodules', '--no-auto-maintenance',
229
+ '--no-write-fetch-head', '--keep', '--', source.url, wanted],
230
+ { ...options, network: true, objects: path.join(repo, 'objects'), diagnoseUnadvertised: oid(source.ref) });
231
+ const resolved = await runGit(repo, ['rev-parse', '--verify', '--end-of-options', wanted + '^{commit}'], options);
232
+ const commit = utf8(resolved.bytes).trim();
233
+ if (!oid(commit)) fail('source.missing-ref');
234
+ if (oid(source.ref) && commit !== source.ref) fail('source.not-commit');
235
+ return { repo, commit };
236
+ }
237
+ // S7 repository preparation reuses bounded transport, not package validation.
238
+ // Internal only: caller must keep tempRoot outside workspace/user repositories.
239
+ // Failure retains the owned preparation path; never delete user data as rollback.
240
+ export async function acquireRemoteRepository(source, { tempRoot, network = false, ...requestedBudget } = {}) {
241
+ validateSource(source);
242
+ if (source.transport !== 'remote' || source.subdirectory !== '.') fail('repository-source.kind');
243
+ if (network !== true) fail('source.network-required');
244
+ if (!path.isAbsolute(tempRoot ?? '')) fail('repository-source.temp-root');
245
+ const budget = repositoryBudget(requestedBudget);
246
+ let preparation;
247
+ const options = {...budget,deadline:performance.now()+budget.acquisitionMs};
248
+ try {
249
+ const parent = await realpath(tempRoot);
250
+ preparation = await mkdtemp(path.join(parent,'wpc-repository-'));
251
+ const {repo,commit} = await remoteRepo(source,options,preparation,runRepositoryGit);
252
+ await runRepositoryGit(repo,['fsck','--connectivity-only','--no-reflogs','--no-progress'],options);
253
+ return {source:structuredClone(source),resolvedSource:source.url,preparation,repo,commit,
254
+ ...budget,history:'shallow-depth-1',checkout:'not-performed',executionAuthorized:false};
255
+ } catch (cause) {
256
+ const error = cause instanceof ContractError ? cause : new ContractError('repository-source.io');
257
+ if (preparation) error.preparation=preparation;
258
+ throw error;
259
+ }
260
+ }
261
+
262
+ export async function acquire(source, { manifestBase, tempRoot = tmpdir(), network = false, packLimit = LIMITS.pack } = {}) {
263
+ validateSource(source);
264
+ cap(packLimit, LIMITS.pack, 'source.pack-limit');
265
+ if (!path.isAbsolute(manifestBase ?? '')) fail('source.manifest-base');
266
+ if (source.transport === 'remote' && !network) fail('source.network-required');
267
+ const deadline = performance.now() + LIMITS.acquisitionMs, options = { deadline, packLimit };
268
+ let preparation, snapshotPath;
269
+ try {
270
+ const parent = await realpath(tempRoot);
271
+ preparation = await mkdtemp(path.join(parent, 'wpc-git-'));
272
+ let repo, commit, resolvedSource;
273
+ if (source.transport === 'local') {
274
+ try { resolvedSource = await realpath(path.resolve(manifestBase, source.path)); }
275
+ catch (e) { fail(e.code === 'ENOENT' ? 'source.missing-repository' : 'source.repository-unavailable'); }
276
+ const local = await localRepo(resolvedSource, source.ref, preparation, options);
277
+ ({ repo, commit } = local);
278
+ options.alternate = local.alternate;
279
+ } else {
280
+ resolvedSource = source.url;
281
+ ({ repo, commit } = await remoteRepo(source, options, preparation));
282
+ }
283
+ const treeish = commit + ':' + (source.subdirectory === '.' ? '' : source.subdirectory);
284
+ const rootType = await runGit(repo, ['cat-file', '-t', treeish], options);
285
+ if (utf8(rootType.bytes).trim() !== 'tree') fail('source.package-root');
286
+ const listing = await runGit(repo, ['ls-tree', '-r', '-l', '-z', '--full-tree', treeish], options);
287
+ const verified = await verifyPackage(parseListing(listing.bytes), async entry => {
288
+ const r = await runGit(repo, ['cat-file', 'blob', entry.oid], { ...options, outputLimit: LIMITS.blob });
289
+ return r.bytes;
290
+ });
291
+ if (performance.now() > deadline) fail('source.timeout');
292
+ snapshotPath = await materialize(verified, parent);
293
+ if (performance.now() > deadline) fail('source.timeout');
294
+ return { source: structuredClone(source), resolvedSource, commit, preparation, snapshotPath,
295
+ manifest: verified.manifest, inventoryDigest: verified.inventoryDigest,
296
+ digest: verified.digest, fileHashes: verified.fileHashes, runtime: 'not-run' };
297
+ } catch (cause) {
298
+ const error = cause instanceof ContractError ? cause : new ContractError('source.io');
299
+ if (preparation) error.preparation = preparation;
300
+ if (snapshotPath) error.snapshotPath = snapshotPath;
301
+ throw error;
302
+ }
303
+ }
@@ -0,0 +1,87 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { parse, fail } from '../contracts/parse.js';
3
+ import { validateStructure } from '../contracts/validate.js';
4
+ import { portablePath, validateInventory, contractDigest } from '../contracts/semantic.js';
5
+ import { requiredCapabilities } from '../providers/interface.js';
6
+
7
+ export const LIMITS = Object.freeze({ files: 10000, blob: 8 * 1024 * 1024,
8
+ total: 128 * 1024 * 1024, manifest: 2 * 1024 * 1024, path: 240, segment: 100,
9
+ windowsPath: 240, posixPath: 1024, gitMs: 120000, acquisitionMs: 300000,
10
+ pack: 256 * 1024 * 1024, metadata: 4 * 1024 * 1024 });
11
+ export const sha256 = bytes => 'sha256:' + createHash('sha256').update(bytes).digest('hex');
12
+ export function cap(value, maximum, code) {
13
+ if (!Number.isSafeInteger(value) || value < 0 || value > maximum) fail(code);
14
+ }
15
+ export function utf8(bytes) {
16
+ try { return new TextDecoder('utf-8', { fatal: true }).decode(bytes); }
17
+ catch { fail('source.utf8'); }
18
+ }
19
+ export function checkEntries(entries) {
20
+ cap(entries.length, LIMITS.files, 'source.files');
21
+ let total = 0;
22
+ const names = new Map();
23
+ for (const e of entries) {
24
+ portablePath(e.path);
25
+ if (!['100644', '100755'].includes(e.mode) || e.type !== 'blob') fail('source.entry-type');
26
+ cap(e.size, LIMITS.blob, 'source.blob');
27
+ total += e.size; cap(total, LIMITS.total, 'source.total');
28
+ const parts = e.path.split('/');
29
+ for (let i = 1; i <= parts.length; i++) {
30
+ const spelling = parts.slice(0, i).join('/'), key = spelling.toLowerCase();
31
+ const kind = i === parts.length ? 'file' : 'directory';
32
+ const previous = names.get(key);
33
+ if (previous && (previous.spelling !== spelling || previous.kind !== kind || kind === 'file')) fail('source.collision');
34
+ names.set(key, { spelling, kind });
35
+ }
36
+ }
37
+ return total;
38
+ }
39
+ // Read only selected tree blobs, never a checked-out worktree or smudge filter.
40
+ export async function verifyPackage(entries, readBlob) {
41
+ checkEntries(entries);
42
+ const manifestEntries = entries.filter(e => ['pipeline.json', 'pipeline.yaml', 'pipeline.yml'].includes(e.path));
43
+ if (manifestEntries.length !== 1) fail('source.manifest');
44
+ const buffers = new Map();
45
+ async function read(e, limit = LIMITS.blob) {
46
+ if (!e) fail('source.missing');
47
+ cap(e.size, limit, 'source.metadata-size');
48
+ if (buffers.has(e.path)) return buffers.get(e.path);
49
+ const bytes = await readBlob(e);
50
+ if (!Buffer.isBuffer(bytes) || bytes.length !== e.size) fail('source.blob-size');
51
+ if (bytes.subarray(0, 200).toString('ascii').startsWith('version https://git-lfs.github.com/spec/v1')) fail('source.lfs');
52
+ buffers.set(e.path, bytes); return bytes;
53
+ }
54
+ const manifestEntry = manifestEntries[0];
55
+ const manifestBytes = await read(manifestEntry, LIMITS.manifest);
56
+ const manifest = parse(utf8(manifestBytes), manifestEntry.path.endsWith('.json') ? 'json' : 'yaml');
57
+ validateStructure('pipeline', manifest);
58
+ portablePath(manifest.inventory); portablePath(manifest.resources);
59
+ if (manifest.inventory === manifestEntry.path) fail('inventory.self');
60
+ const inventoryBytes = await read(entries.find(e => e.path === manifest.inventory), LIMITS.manifest);
61
+ const inventory = parse(utf8(inventoryBytes), 'json');
62
+ validateInventory(inventory, { inventoryPath: manifest.inventory, manifestPath: manifestEntry.path });
63
+ const expected = new Set([...Object.keys(inventory), manifest.inventory]);
64
+ if (expected.size !== entries.length || entries.some(e => !expected.has(e.path))) fail('inventory.files');
65
+ for (const e of entries) {
66
+ const bytes = await read(e);
67
+ if (e.path !== manifest.inventory && sha256(bytes) !== inventory[e.path]) fail('inventory.hash');
68
+ }
69
+ function component(p, directory) {
70
+ portablePath(p);
71
+ if (directory ? !entries.some(e => e.path.startsWith(p + '/')) : !buffers.has(p)) fail('source.component');
72
+ }
73
+ component(manifest.resources, true);
74
+ for (const bundle of Object.values(manifest.bundles ?? {})) {
75
+ component(bundle.entry.source, false);
76
+ for (const id of bundle.providers) if (!Object.hasOwn(manifest.providers, id)) fail('bundle.provider-absent');
77
+ }
78
+ for (const decl of Object.values(manifest.providers)) {
79
+ requiredCapabilities(decl);
80
+ for (const key of ['skills', 'agents', 'mcp', 'entryInstructions'])
81
+ if (decl[key] !== null) component(decl[key], key === 'skills' || key === 'agents');
82
+ }
83
+ if (manifest.agentsDocument.mode === 'source') component(manifest.agentsDocument.path, false);
84
+ const fileHashes = Object.fromEntries([...buffers].map(([p, b]) => [p, sha256(b)]));
85
+ return { manifest, manifestPath: manifestEntry.path, inventoryDigest: sha256(inventoryBytes),
86
+ digest: contractDigest(fileHashes), files: buffers, fileHashes };
87
+ }