@dsh-enhanced/plugin-control-plane 0.1.47 → 0.1.49

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 (167) hide show
  1. package/README.md +183 -15
  2. package/bin/dsh-live-qualification-authority.js +15 -0
  3. package/bin/dsh-local-release-adapter.js +328 -8
  4. package/bin/dsh-systemd-host-attestor.js +359 -9
  5. package/bin/dsh-systemd-host-authority.js +12 -0
  6. package/lib/adoption-coordinator.d.ts +3 -1
  7. package/lib/adoption-coordinator.d.ts.map +1 -1
  8. package/lib/adoption-coordinator.js +24 -9
  9. package/lib/adoption-coordinator.js.map +1 -1
  10. package/lib/cli.d.ts.map +1 -1
  11. package/lib/cli.js +33 -14
  12. package/lib/cli.js.map +1 -1
  13. package/lib/creation-capability-feedback.d.ts +23 -0
  14. package/lib/creation-capability-feedback.d.ts.map +1 -0
  15. package/lib/creation-capability-feedback.js +132 -0
  16. package/lib/creation-capability-feedback.js.map +1 -0
  17. package/lib/creation-capability-journal.d.ts +66 -0
  18. package/lib/creation-capability-journal.d.ts.map +1 -0
  19. package/lib/creation-capability-journal.js +751 -0
  20. package/lib/creation-capability-journal.js.map +1 -0
  21. package/lib/creation-capability-runtime.d.ts +63 -0
  22. package/lib/creation-capability-runtime.d.ts.map +1 -0
  23. package/lib/creation-capability-runtime.js +564 -0
  24. package/lib/creation-capability-runtime.js.map +1 -0
  25. package/lib/creation-capability-source.d.ts +37 -0
  26. package/lib/creation-capability-source.d.ts.map +1 -0
  27. package/lib/creation-capability-source.js +328 -0
  28. package/lib/creation-capability-source.js.map +1 -0
  29. package/lib/creation-capability-types.d.ts +252 -0
  30. package/lib/creation-capability-types.d.ts.map +1 -0
  31. package/lib/creation-capability-types.js +2 -0
  32. package/lib/creation-capability-types.js.map +1 -0
  33. package/lib/deployment-readiness.d.ts +16 -0
  34. package/lib/deployment-readiness.d.ts.map +1 -1
  35. package/lib/deployment-readiness.js +48 -1
  36. package/lib/deployment-readiness.js.map +1 -1
  37. package/lib/effect-blocked-replay.js +2 -2
  38. package/lib/effect-blocked-replay.js.map +1 -1
  39. package/lib/foreground-deployment-runtime.d.ts +3 -0
  40. package/lib/foreground-deployment-runtime.d.ts.map +1 -1
  41. package/lib/foreground-deployment-runtime.js +55 -13
  42. package/lib/foreground-deployment-runtime.js.map +1 -1
  43. package/lib/foreground-deployment.d.ts.map +1 -1
  44. package/lib/foreground-deployment.js +5 -0
  45. package/lib/foreground-deployment.js.map +1 -1
  46. package/lib/host-attestor.d.ts +2 -0
  47. package/lib/host-attestor.d.ts.map +1 -1
  48. package/lib/host-attestor.js +8 -1
  49. package/lib/host-attestor.js.map +1 -1
  50. package/lib/host-inputs.d.ts +13 -0
  51. package/lib/host-inputs.d.ts.map +1 -0
  52. package/lib/host-inputs.js +162 -0
  53. package/lib/host-inputs.js.map +1 -0
  54. package/lib/host-maintenance.d.ts +61 -0
  55. package/lib/host-maintenance.d.ts.map +1 -0
  56. package/lib/host-maintenance.js +158 -0
  57. package/lib/host-maintenance.js.map +1 -0
  58. package/lib/host-readiness.d.ts +5 -0
  59. package/lib/host-readiness.d.ts.map +1 -0
  60. package/lib/host-readiness.js +22 -0
  61. package/lib/host-readiness.js.map +1 -0
  62. package/lib/index.d.ts +16 -0
  63. package/lib/index.d.ts.map +1 -1
  64. package/lib/index.js +7 -0
  65. package/lib/index.js.map +1 -1
  66. package/lib/live-qualification-authority.d.ts +32 -0
  67. package/lib/live-qualification-authority.d.ts.map +1 -0
  68. package/lib/live-qualification-authority.js +280 -0
  69. package/lib/live-qualification-authority.js.map +1 -0
  70. package/lib/live-qualification-client.d.ts +4 -0
  71. package/lib/live-qualification-client.d.ts.map +1 -0
  72. package/lib/live-qualification-client.js +22 -0
  73. package/lib/live-qualification-client.js.map +1 -0
  74. package/lib/live-qualification-runtime.d.ts +78 -0
  75. package/lib/live-qualification-runtime.d.ts.map +1 -0
  76. package/lib/live-qualification-runtime.js +367 -0
  77. package/lib/live-qualification-runtime.js.map +1 -0
  78. package/lib/live-qualification.d.ts +73 -0
  79. package/lib/live-qualification.d.ts.map +1 -0
  80. package/lib/live-qualification.js +164 -0
  81. package/lib/live-qualification.js.map +1 -0
  82. package/lib/owner-task-gaps.d.ts +2 -0
  83. package/lib/owner-task-gaps.d.ts.map +1 -1
  84. package/lib/owner-task-gaps.js +9 -1
  85. package/lib/owner-task-gaps.js.map +1 -1
  86. package/lib/runtime-epoch.d.ts +69 -0
  87. package/lib/runtime-epoch.d.ts.map +1 -0
  88. package/lib/runtime-epoch.js +139 -0
  89. package/lib/runtime-epoch.js.map +1 -0
  90. package/lib/runtime-observer.d.ts.map +1 -1
  91. package/lib/runtime-observer.js +42 -6
  92. package/lib/runtime-observer.js.map +1 -1
  93. package/lib/service.d.ts +247 -4
  94. package/lib/service.d.ts.map +1 -1
  95. package/lib/service.js +1332 -27
  96. package/lib/service.js.map +1 -1
  97. package/lib/source-adoption-authority.d.ts +3 -0
  98. package/lib/source-adoption-authority.d.ts.map +1 -1
  99. package/lib/source-adoption-authority.js +13 -3
  100. package/lib/source-adoption-authority.js.map +1 -1
  101. package/lib/source-adoption-runner.d.ts +3 -0
  102. package/lib/source-adoption-runner.d.ts.map +1 -1
  103. package/lib/source-adoption-runner.js +16 -4
  104. package/lib/source-adoption-runner.js.map +1 -1
  105. package/lib/source-baseline.d.ts +37 -0
  106. package/lib/source-baseline.d.ts.map +1 -0
  107. package/lib/source-baseline.js +250 -0
  108. package/lib/source-baseline.js.map +1 -0
  109. package/lib/source-build.d.ts +17 -1
  110. package/lib/source-build.d.ts.map +1 -1
  111. package/lib/source-build.js +501 -7
  112. package/lib/source-build.js.map +1 -1
  113. package/lib/source-context.d.ts +18 -0
  114. package/lib/source-context.d.ts.map +1 -1
  115. package/lib/source-context.js +85 -11
  116. package/lib/source-context.js.map +1 -1
  117. package/lib/source-creation.d.ts +106 -0
  118. package/lib/source-creation.d.ts.map +1 -0
  119. package/lib/source-creation.js +664 -0
  120. package/lib/source-creation.js.map +1 -0
  121. package/lib/source-job-types.d.ts +19 -0
  122. package/lib/source-job-types.d.ts.map +1 -1
  123. package/lib/source-jobs.d.ts +30 -3
  124. package/lib/source-jobs.d.ts.map +1 -1
  125. package/lib/source-jobs.js +382 -40
  126. package/lib/source-jobs.js.map +1 -1
  127. package/lib/source-maintenance.d.ts +66 -0
  128. package/lib/source-maintenance.d.ts.map +1 -0
  129. package/lib/source-maintenance.js +177 -0
  130. package/lib/source-maintenance.js.map +1 -0
  131. package/lib/source-revision.d.ts +57 -0
  132. package/lib/source-revision.d.ts.map +1 -0
  133. package/lib/source-revision.js +175 -0
  134. package/lib/source-revision.js.map +1 -0
  135. package/lib/source-workspace.d.ts +19 -14
  136. package/lib/source-workspace.d.ts.map +1 -1
  137. package/lib/source-workspace.js +90 -17
  138. package/lib/source-workspace.js.map +1 -1
  139. package/lib/sqlite.d.ts +13 -3
  140. package/lib/sqlite.d.ts.map +1 -1
  141. package/lib/sqlite.js +362 -1
  142. package/lib/sqlite.js.map +1 -1
  143. package/lib/store.d.ts +258 -3
  144. package/lib/store.d.ts.map +1 -1
  145. package/lib/store.js +2211 -95
  146. package/lib/store.js.map +1 -1
  147. package/lib/systemd-host-authority.d.ts +68 -0
  148. package/lib/systemd-host-authority.d.ts.map +1 -0
  149. package/lib/systemd-host-authority.js +424 -0
  150. package/lib/systemd-host-authority.js.map +1 -0
  151. package/lib/task-observation-evidence.d.ts +32 -0
  152. package/lib/task-observation-evidence.d.ts.map +1 -0
  153. package/lib/task-observation-evidence.js +62 -0
  154. package/lib/task-observation-evidence.js.map +1 -0
  155. package/lib/task-observation-runtime.d.ts +2 -3
  156. package/lib/task-observation-runtime.d.ts.map +1 -1
  157. package/lib/task-observation-runtime.js +7 -39
  158. package/lib/task-observation-runtime.js.map +1 -1
  159. package/lib/task-observation-store.js +1 -1
  160. package/lib/trust.d.ts.map +1 -1
  161. package/lib/trust.js +0 -0
  162. package/lib/trust.js.map +1 -1
  163. package/lib/types.d.ts +43 -6
  164. package/lib/types.d.ts.map +1 -1
  165. package/lib/version.d.ts +1 -1
  166. package/lib/version.js +1 -1
  167. package/package.json +21 -14
@@ -0,0 +1,664 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
+ import { constants as fsConstants } from 'node:fs';
4
+ import { lstat, mkdir, open, readdir, realpath, writeFile } from 'node:fs/promises';
5
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
6
+ import { tmpdir } from 'node:os';
7
+ import { mkdtemp, rm } from 'node:fs/promises';
8
+ import { ControlPlaneCliError } from './errors.js';
9
+ import { awaitSourceSignal } from './source-context.js';
10
+ import { changedSourcePaths, linkedWorktrees, runLocalBuffer, runLocalCommand, writeCreatedPluginFiles, validateScopedPluginFiles } from './source-workspace.js';
11
+ const NAME = /^(?=.{1,64}$)[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
12
+ const GRANT_ID = /^(?=.{1,128}$)[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
13
+ const DIGEST = /^[a-f0-9]{64}$/u;
14
+ const COMMIT = /^[a-f0-9]{40}$/u;
15
+ const MAX_FILE_BYTES = 65_536;
16
+ const MAX_TOTAL_BYTES = 262_144;
17
+ const MAX_OUTPUT_BYTES = 8_192;
18
+ const CREATE_SCOPE = (name) => Object.freeze([`plugins/${name}`, 'plugins/README.md', 'pnpm-lock.yaml']);
19
+ const HOST_INPUTS = ['scripts/create-plugin.mjs', 'LICENSE', 'plugins/README.md', 'package.json',
20
+ 'pnpm-workspace.yaml', 'pnpm-lock.yaml', 'tsconfig.base.json'];
21
+ const RESERVED_OUTPUTS = ['package.json', 'cordis.patch.yml', 'LICENSE', 'tsconfig.json',
22
+ 'tsconfig.build.json', 'src/version.ts'];
23
+ function record(value) {
24
+ return value !== null && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype;
25
+ }
26
+ /** Shape validation remains valid for historical, already-expired grants. */
27
+ export function validateSourceCreationGrant(value) {
28
+ if (!record(value) || Object.keys(value).sort().join(',') !== 'expiresAt,id,maxCreates,namePrefix'
29
+ || typeof value.id !== 'string' || !GRANT_ID.test(value.id) || value.id.normalize('NFC') !== value.id
30
+ || typeof value.expiresAt !== 'number' || !Number.isSafeInteger(value.expiresAt) || value.expiresAt <= 0
31
+ || typeof value.maxCreates !== 'number' || !Number.isSafeInteger(value.maxCreates) || value.maxCreates < 1 || value.maxCreates > 1_000
32
+ || typeof value.namePrefix !== 'string' || value.namePrefix.length < 2 || value.namePrefix.length > 48
33
+ || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*-$/u.test(value.namePrefix)) {
34
+ throw new ControlPlaneCliError('INVALID_ARGUMENT', 'source creation grant is invalid');
35
+ }
36
+ }
37
+ /** Candidate authority is limited to runtime source, documentation, and tests. */
38
+ export function validateSourceCreationFiles(files) {
39
+ validateScopedPluginFiles(files);
40
+ for (const file of files) {
41
+ if (file.path !== file.path.normalize('NFC')
42
+ || !(file.path === 'README.md' || /^(?:src|tests)\/(?:[a-zA-Z0-9][a-zA-Z0-9._-]*\/)*[a-zA-Z0-9][a-zA-Z0-9._-]*$/u.test(file.path))
43
+ || file.path === 'src/version.ts' || file.path.endsWith('/package.json')
44
+ || file.path.endsWith('/cordis.patch.yml') || file.path.endsWith('/LICENSE')
45
+ || file.path.endsWith('/tsconfig.json') || file.path.endsWith('/tsconfig.build.json')) {
46
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', `source creation file is outside candidate authority: ${JSON.stringify(file.path)}`);
47
+ }
48
+ }
49
+ }
50
+ function creationName(name, grant) {
51
+ validateSourceCreationGrant(grant);
52
+ if (name.normalize('NFC') !== name || !NAME.test(name) || !name.startsWith(grant.namePrefix)
53
+ || name.length <= grant.namePrefix.length) {
54
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'new plugin name is outside the owner creation namespace');
55
+ }
56
+ }
57
+ async function checked(signal, assertCurrent, operation) {
58
+ signal.throwIfAborted();
59
+ await awaitSourceSignal(signal, assertCurrent);
60
+ const result = await operation();
61
+ signal.throwIfAborted();
62
+ await awaitSourceSignal(signal, assertCurrent);
63
+ return result;
64
+ }
65
+ async function canonicalGitRoot(path, environment, signal) {
66
+ const repository = await realpath(path);
67
+ if (repository !== resolve(path))
68
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source repository must be canonical');
69
+ const top = (await runLocalCommand('git', ['rev-parse', '--show-toplevel'], repository, environment, { capture: true, maximumOutput: 4_096, timeoutMs: 15_000, signal })).trim();
70
+ if (top !== repository)
71
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source repository must be a Git top-level');
72
+ return repository;
73
+ }
74
+ async function baseAssets(input) {
75
+ const repository = await checked(input.signal, input.assertCurrent, () => canonicalGitRoot(input.repository, input.environment, input.signal));
76
+ if (input.baseCommit !== undefined && !COMMIT.test(input.baseCommit)
77
+ || input.baselineCommit !== undefined && !COMMIT.test(input.baselineCommit)) {
78
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation base commit is invalid');
79
+ }
80
+ const baseCommit = (await checked(input.signal, input.assertCurrent, () => runLocalCommand('git', ['rev-parse', '--verify', `${input.baselineCommit ?? 'HEAD'}^{commit}`], repository, input.environment, { capture: true, maximumOutput: 4_096, timeoutMs: 15_000, signal: input.signal }))).trim();
81
+ if (!COMMIT.test(baseCommit) || input.baseCommit !== undefined && input.baseCommit !== baseCommit
82
+ || input.baselineCommit !== undefined && input.baselineCommit !== baseCommit) {
83
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation base commit changed');
84
+ }
85
+ const listed = await checked(input.signal, input.assertCurrent, () => runLocalCommand('git', ['--literal-pathspecs', 'ls-tree', '-r', '-l', '-z', baseCommit, '--',
86
+ ...HOST_INPUTS, 'templates/plugin'], repository, input.environment, { capture: true, maximumOutput: 65_536, timeoutMs: 15_000, signal: input.signal }));
87
+ const blobs = new Map();
88
+ for (const entry of listed.split('\0').filter(Boolean)) {
89
+ const match = /^(100644|100755) blob ([a-f0-9]{40}) +([0-9]+)\t(.+)$/u.exec(entry);
90
+ if (match === null || blobs.has(match[4]) || Number(match[3]) > 8_388_608
91
+ || !HOST_INPUTS.includes(match[4]) && !match[4].startsWith('templates/plugin/')) {
92
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation base inputs are unsafe');
93
+ }
94
+ blobs.set(match[4], { path: match[4], hash: match[2], mode: match[1], bytes: Number(match[3]) });
95
+ }
96
+ if (HOST_INPUTS.some(path => !blobs.has(path)) || ![...blobs.keys()].some(path => path.startsWith('templates/plugin/'))
97
+ || [...blobs.keys()].some(path => path.startsWith('templates/plugin/') && !path.endsWith('.tpl'))) {
98
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation template or Host inputs are missing');
99
+ }
100
+ const manifest = JSON.parse((await gitBlob(repository, blobs.get('package.json'), input.environment, input.signal)).toString('utf8'));
101
+ if (manifest.scripts?.['create:plugin'] !== 'node ./scripts/create-plugin.mjs') {
102
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'base public generator command changed');
103
+ }
104
+ const digest = createHash('sha256').update('dsh-prepared-create-generator-v1\0');
105
+ for (const blob of [...blobs.values()].sort((a, b) => a.path.localeCompare(b.path))) {
106
+ digest.update(blob.path).update('\0').update(blob.mode).update('\0').update(blob.hash).update('\0');
107
+ }
108
+ return { repository, baseCommit, generatorDigest: digest.digest('hex'), blobs };
109
+ }
110
+ async function gitBlob(repository, blob, environment, signal) {
111
+ const bytes = await runLocalBuffer('git', ['cat-file', 'blob', blob.hash], repository, environment, { maximumOutput: blob.bytes + 1, timeoutMs: 15_000, signal });
112
+ if (bytes.length !== blob.bytes)
113
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation base blob changed');
114
+ return bytes;
115
+ }
116
+ async function safeBytes(path, maxBytes) {
117
+ const expected = resolve(path);
118
+ if (await realpath(expected) !== expected)
119
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation file is not canonical');
120
+ const before = await lstat(expected);
121
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size > maxBytes) {
122
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation file is not a bounded regular file');
123
+ }
124
+ const handle = await open(expected, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
125
+ try {
126
+ const bytes = await handle.readFile();
127
+ const after = await lstat(expected);
128
+ if (bytes.length !== before.size || after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size) {
129
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation file changed while read');
130
+ }
131
+ return bytes;
132
+ }
133
+ finally {
134
+ await handle.close();
135
+ }
136
+ }
137
+ async function assertBaseFiles(worktree, assets, environment, signal, generated = false) {
138
+ for (const blob of assets.blobs.values()) {
139
+ if (generated && (blob.path === 'plugins/README.md' || blob.path === 'pnpm-lock.yaml'))
140
+ continue;
141
+ signal.throwIfAborted();
142
+ const path = resolve(worktree, blob.path);
143
+ if (!path.startsWith(`${worktree}/`) || dirname(path) === worktree && blob.path.includes('..')) {
144
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation base file escapes worktree');
145
+ }
146
+ const current = await safeBytes(path, blob.bytes);
147
+ const baseline = await gitBlob(assets.repository, blob, environment, signal);
148
+ if (!current.equals(baseline))
149
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', `source creation base input changed: ${blob.path}`);
150
+ }
151
+ }
152
+ async function assertCleanBase(worktree, assets, environment, signal) {
153
+ const root = await canonicalGitRoot(worktree, environment, signal);
154
+ if (root !== worktree || (await runLocalCommand('git', ['rev-parse', 'HEAD'], worktree, environment, { capture: true, maximumOutput: 4_096, timeoutMs: 15_000, signal })).trim() !== assets.baseCommit
155
+ || (await changedSourcePaths(worktree, assets.baseCommit, environment, signal)).length !== 0) {
156
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation worktree is not clean at the exact base');
157
+ }
158
+ await assertBaseFiles(worktree, assets, environment, signal);
159
+ }
160
+ async function assertFreshName(assets, name, environment, signal) {
161
+ const listed = await runLocalCommand('git', ['--literal-pathspecs', 'ls-tree', '-r', '-l', '-z', assets.baseCommit,
162
+ '--', `plugins/${name}`, `packages/${name}`], assets.repository, environment, { capture: true, maximumOutput: 4_096, timeoutMs: 15_000, signal });
163
+ if (listed !== '')
164
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'new plugin name already exists at base');
165
+ const catalog = (await gitBlob(assets.repository, assets.blobs.get('plugins/README.md'), environment, signal)).toString('utf8');
166
+ if (catalog.includes(`@dsh-enhanced/${name}`) || catalog.includes(`](${name})`)) {
167
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'new plugin identity collides with catalog');
168
+ }
169
+ // A published package can retain its identity after its directory is renamed.
170
+ // Check the pinned tree rather than relying on the directory or catalog alone.
171
+ const tree = await runLocalCommand('git', ['--literal-pathspecs', 'ls-tree', '-r', '-l', '-z', assets.baseCommit,
172
+ '--', 'plugins', 'packages'], assets.repository, environment, { capture: true, maximumOutput: 1_048_576, timeoutMs: 15_000, signal });
173
+ for (const entry of tree.split('\0').filter(Boolean)) {
174
+ const separator = entry.indexOf('\t');
175
+ const path = entry.slice(separator + 1);
176
+ if (!/^(?:plugins|packages)\/[^/]+\/package\.json$/u.test(path))
177
+ continue;
178
+ const match = /^(100644|100755) blob ([a-f0-9]{40}) +([0-9]+)$/u.exec(entry.slice(0, separator));
179
+ if (separator < 0 || match === null || Number(match[3]) > 65_536) {
180
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'base package manifest is unsafe');
181
+ }
182
+ const manifest = JSON.parse((await gitBlob(assets.repository, { path, mode: match[1], hash: match[2], bytes: Number(match[3]) }, environment, signal)).toString('utf8'));
183
+ if (manifest?.name === `@dsh-enhanced/${name}`) {
184
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'new plugin package identity already exists at base');
185
+ }
186
+ }
187
+ }
188
+ async function runPublicGenerator(worktree, name, signal) {
189
+ await new Promise((resolvePromise, reject) => {
190
+ signal.throwIfAborted();
191
+ const child = spawn(process.execPath, ['./scripts/create-plugin.mjs', name], {
192
+ cwd: worktree, shell: false, stdio: ['ignore', 'pipe', 'pipe'],
193
+ env: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8', TZ: 'UTC', HOME: worktree, NODE_OPTIONS: '' },
194
+ });
195
+ let bytes = 0;
196
+ let tail = '';
197
+ let limited = false;
198
+ let timedOut = false;
199
+ const collect = (chunk) => {
200
+ bytes += chunk.length;
201
+ tail = `${tail}${chunk.toString('utf8')}`.slice(-1_024);
202
+ if (bytes > MAX_OUTPUT_BYTES) {
203
+ limited = true;
204
+ child.kill('SIGKILL');
205
+ }
206
+ };
207
+ child.stdout.on('data', collect);
208
+ child.stderr.on('data', collect);
209
+ const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 15_000);
210
+ const abort = () => { child.kill('SIGKILL'); };
211
+ signal.addEventListener('abort', abort, { once: true });
212
+ child.once('error', error => { clearTimeout(timer); signal.removeEventListener('abort', abort); reject(error); });
213
+ child.once('close', code => {
214
+ clearTimeout(timer);
215
+ signal.removeEventListener('abort', abort);
216
+ if (signal.aborted)
217
+ reject(signal.reason);
218
+ else if (code !== 0 || timedOut || limited)
219
+ reject(new ControlPlaneCliError('EXECUTOR_FAILED', `fixed public generator failed (${timedOut ? 'timeout' : limited ? 'output limit' : `exit ${code}`}): ${tail}`));
220
+ else
221
+ resolvePromise();
222
+ });
223
+ });
224
+ }
225
+ async function generatedFiles(worktree, name, bounds = { files: 64, bytes: MAX_TOTAL_BYTES }) {
226
+ const root = resolve(worktree, 'plugins', name);
227
+ const rootStat = await lstat(root);
228
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink() || await realpath(root) !== root)
229
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'generated plugin root is unsafe');
230
+ const output = [];
231
+ let nodes = 0;
232
+ const visit = async (directory, prefix, depth) => {
233
+ if (depth > 32)
234
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'generated plugin directory depth exceeds bound');
235
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
236
+ const path = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
237
+ if (++nodes > 256 || Buffer.byteLength(path, 'utf8') > 1_024) {
238
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'generated plugin path or entry count exceeds bound');
239
+ }
240
+ if (entry.isDirectory())
241
+ await visit(join(directory, entry.name), path, depth + 1);
242
+ else if (entry.isFile()) {
243
+ const bytes = await safeBytes(join(directory, entry.name), MAX_FILE_BYTES);
244
+ if (bytes.includes(0))
245
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'generated plugin contains binary data');
246
+ output.push({ path, bytes: bytes.length, content: new TextDecoder('utf-8', { fatal: true }).decode(bytes) });
247
+ }
248
+ else
249
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'generated plugin contains non-regular entry');
250
+ if (output.length > bounds.files)
251
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'generated plugin exceeds file bound');
252
+ }
253
+ };
254
+ await visit(root, '', 0);
255
+ if (RESERVED_OUTPUTS.some(path => !output.some(file => file.path === path))
256
+ || !output.some(file => file.path === 'README.md') || !output.some(file => file.path === 'tests/index.spec.ts')
257
+ || output.reduce((total, file) => total + file.bytes, 0) > bounds.bytes) {
258
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'public generator output is incomplete or exceeds bounds');
259
+ }
260
+ return Object.freeze(output.sort((a, b) => a.path.localeCompare(b.path)));
261
+ }
262
+ async function generateChecked(worktree, name, assets, environment, signal) {
263
+ await assertCleanBase(worktree, assets, environment, signal);
264
+ const target = resolve(worktree, 'plugins', name);
265
+ if (await lstat(target).then(() => true, (error) => error.code !== 'ENOENT')) {
266
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'new plugin root already exists');
267
+ }
268
+ await runPublicGenerator(worktree, name, signal);
269
+ const changed = await changedSourcePaths(worktree, assets.baseCommit, environment, signal);
270
+ if (changed.length < 2 || changed.some(path => path !== 'plugins/README.md' && !path.startsWith(`plugins/${name}/`))) {
271
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'public generator changed files outside new plugin and catalog');
272
+ }
273
+ return generatedFiles(worktree, name);
274
+ }
275
+ async function isolatedGenerator(assets, name, environment, signal, action) {
276
+ // The public generator runs against only approved Git blobs in a disposable
277
+ // capsule. Inspect never changes the source repository or its worktree list.
278
+ const stateRoot = await mkdtemp(join(tmpdir(), 'dsh-create-inspect-'));
279
+ try {
280
+ for (const blob of assets.blobs.values()) {
281
+ signal.throwIfAborted();
282
+ const target = resolve(stateRoot, blob.path);
283
+ if (!target.startsWith(`${stateRoot}/`))
284
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'generator capsule input escapes');
285
+ await mkdir(dirname(target), { recursive: true });
286
+ await writeFile(target, await gitBlob(assets.repository, blob, environment, signal), { flag: 'wx', mode: 0o600 });
287
+ }
288
+ await mkdir(join(stateRoot, 'plugins'), { recursive: true });
289
+ await runPublicGenerator(stateRoot, name, signal);
290
+ const files = await generatedFiles(stateRoot, name);
291
+ return await action(stateRoot, files);
292
+ }
293
+ finally {
294
+ await rm(stateRoot, { recursive: true, force: true });
295
+ }
296
+ }
297
+ export async function inspectSourceCreationContext(input) {
298
+ creationName(input.name, input.grant);
299
+ if (input.grant.expiresAt <= Date.now())
300
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation grant expired');
301
+ if (!Array.isArray(input.paths) || input.paths.length > 64 || input.paths.length !== new Set(input.paths).size) {
302
+ throw new ControlPlaneCliError('INVALID_ARGUMENT', 'source creation context paths are invalid');
303
+ }
304
+ for (const path of input.paths) {
305
+ if (typeof path !== 'string' || path !== path.normalize('NFC') || path === '' || isAbsolute(path)
306
+ || path.includes('\\') || path.split('/').some(part => part === '' || part === '.' || part === '..')) {
307
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation context path is invalid');
308
+ }
309
+ }
310
+ const assets = await baseAssets(input);
311
+ await assertFreshName(assets, input.name, input.environment, input.signal);
312
+ return checked(input.signal, input.assertCurrent, async () => isolatedGenerator(assets, input.name, input.environment, input.signal, async (_worktree, generated) => {
313
+ const selected = input.paths.map(path => {
314
+ const file = generated.find(item => item.path === path);
315
+ if (!file)
316
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', `new plugin context path is unavailable: ${JSON.stringify(path)}`);
317
+ return Object.freeze({ path, content: file.content });
318
+ });
319
+ if (selected.reduce((total, file) => total + Buffer.byteLength(file.content), 0) > MAX_TOTAL_BYTES) {
320
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'new plugin context exceeds content bound');
321
+ }
322
+ return Object.freeze({ name: input.name, baseCommit: assets.baseCommit, generatorDigest: assets.generatorDigest,
323
+ files: Object.freeze(generated.map(file => Object.freeze({ path: file.path, bytes: file.bytes }))),
324
+ contents: Object.freeze(selected) });
325
+ }));
326
+ }
327
+ function lockImporter(base, manifest, name) {
328
+ if (Buffer.byteLength(base) > 8_388_608 || base.includes('\t') || base.includes('\r')
329
+ || !base.startsWith("lockfileVersion: '9.0'\n")) {
330
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'base lockfile has unsupported format');
331
+ }
332
+ const start = base.indexOf('\nimporters:\n');
333
+ const end = base.indexOf('\npackages:\n');
334
+ if (start < 0 || end <= start || base.indexOf('\nimporters:\n', start + 1) !== -1) {
335
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'base lockfile importer section is ambiguous');
336
+ }
337
+ const importers = base.slice(start + '\nimporters:\n'.length, end);
338
+ const newImporter = `plugins/${name}`;
339
+ const needed = new Map();
340
+ for (const group of ['dependencies', 'devDependencies', 'optionalDependencies']) {
341
+ const dependencies = manifest[group];
342
+ if (dependencies === undefined)
343
+ continue;
344
+ if (!record(dependencies))
345
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'generated manifest dependencies are invalid');
346
+ for (const [packageName, specifier] of Object.entries(dependencies)) {
347
+ if (needed.has(packageName) || typeof specifier !== 'string' || specifier !== 'catalog:') {
348
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'new plugin dependency is not a fixed catalog selection');
349
+ }
350
+ needed.set(packageName, { group, specifier });
351
+ }
352
+ }
353
+ const scalar = (raw) => {
354
+ if (raw.startsWith("'")) {
355
+ if (!/^'(?:[^']|'')*'$/u.test(raw))
356
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'base lock scalar is ambiguous');
357
+ return raw.slice(1, -1).replaceAll("''", "'");
358
+ }
359
+ if (raw.startsWith('"')) {
360
+ let value;
361
+ try {
362
+ value = JSON.parse(raw);
363
+ }
364
+ catch {
365
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'base lock scalar is ambiguous');
366
+ }
367
+ if (typeof value !== 'string')
368
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'base lock scalar is ambiguous');
369
+ return value;
370
+ }
371
+ if (!/^[a-zA-Z0-9@._/+()-]+$/u.test(raw)
372
+ && !/^(?:workspace:(?:\*|\^)|link:[a-zA-Z0-9./_-]+|[~^][0-9][0-9.]+)$/u.test(raw)) {
373
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'base lock scalar is ambiguous');
374
+ }
375
+ return raw;
376
+ };
377
+ const candidate = new Map();
378
+ const importersSeen = new Set();
379
+ const groupsSeen = new Set();
380
+ const dependenciesSeen = new Set();
381
+ let importer = '';
382
+ let group = '';
383
+ let dependency = '';
384
+ let specifier;
385
+ let version;
386
+ const finishDependency = () => {
387
+ if (dependency !== '' && needed.has(dependency) && specifier === needed.get(dependency).specifier) {
388
+ if (version === undefined || !/^[a-zA-Z0-9][a-zA-Z0-9@.+()/_-]*$/u.test(version)) {
389
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', `base lock resolution for ${dependency} is incomplete or unsafe`);
390
+ }
391
+ const selected = candidate.get(dependency) ?? new Set();
392
+ selected.add(version);
393
+ candidate.set(dependency, selected);
394
+ }
395
+ dependency = '';
396
+ specifier = undefined;
397
+ version = undefined;
398
+ };
399
+ for (const line of importers.split('\n')) {
400
+ if (line === '')
401
+ continue;
402
+ const importerMatch = /^ ([^ ].*):$/u.exec(line);
403
+ if (importerMatch) {
404
+ finishDependency();
405
+ importer = scalar(importerMatch[1]);
406
+ group = '';
407
+ groupsSeen.clear();
408
+ dependenciesSeen.clear();
409
+ if (importersSeen.has(importer) || importer === newImporter) {
410
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'base lock importer is duplicate or already exists');
411
+ }
412
+ importersSeen.add(importer);
413
+ continue;
414
+ }
415
+ const groupMatch = /^ ([^ ].*):$/u.exec(line);
416
+ if (groupMatch) {
417
+ finishDependency();
418
+ group = scalar(groupMatch[1]);
419
+ dependenciesSeen.clear();
420
+ if (importer === '' || groupsSeen.has(group)
421
+ || !['dependencies', 'devDependencies', 'optionalDependencies'].includes(group)) {
422
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'base lock importer group is unsupported or duplicate');
423
+ }
424
+ groupsSeen.add(group);
425
+ continue;
426
+ }
427
+ const dependencyMatch = /^ ([^ ].*):$/u.exec(line);
428
+ if (dependencyMatch) {
429
+ finishDependency();
430
+ dependency = scalar(dependencyMatch[1]);
431
+ if (group === '' || dependenciesSeen.has(dependency)) {
432
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'base lock importer dependency is duplicate or misplaced');
433
+ }
434
+ dependenciesSeen.add(dependency);
435
+ continue;
436
+ }
437
+ const fieldMatch = /^ (specifier|version): (.+)$/u.exec(line);
438
+ if (!fieldMatch || dependency === '')
439
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'base lock importer syntax is unsupported');
440
+ if (fieldMatch[1] === 'specifier') {
441
+ if (specifier !== undefined)
442
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'base lock importer repeats specifier');
443
+ specifier = scalar(fieldMatch[2]);
444
+ }
445
+ else {
446
+ if (version !== undefined)
447
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'base lock importer repeats version');
448
+ version = scalar(fieldMatch[2]);
449
+ }
450
+ }
451
+ finishDependency();
452
+ for (const key of needed.keys()) {
453
+ if (candidate.get(key)?.size !== 1)
454
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', `base lock resolution for ${key} is absent or ambiguous`);
455
+ }
456
+ let block = ` ${newImporter}:\n`;
457
+ for (const selectedGroup of ['dependencies', 'devDependencies', 'optionalDependencies']) {
458
+ const entries = [...needed].filter(([, item]) => item.group === selectedGroup).sort(([a], [b]) => a.localeCompare(b));
459
+ if (entries.length === 0)
460
+ continue;
461
+ block += ` ${selectedGroup}:\n`;
462
+ for (const [key, value] of entries) {
463
+ block += ` '${key.replaceAll("'", "''")}':\n specifier: '${value.specifier}'\n version: ${JSON.stringify([...candidate.get(key)][0])}\n`;
464
+ }
465
+ }
466
+ const result = `${base.slice(0, end)}\n${block}${base.slice(end)}`;
467
+ if (result.replace(`\n${block}`, '') !== base)
468
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'new lock importer projection changed existing lock data');
469
+ return result;
470
+ }
471
+ async function assertOnlyCreationChanges(worktree, baseCommit, name, environment, signal) {
472
+ const changed = await changedSourcePaths(worktree, baseCommit, environment, signal);
473
+ if (changed.some(path => path !== 'plugins/README.md' && path !== 'pnpm-lock.yaml' && !path.startsWith(`plugins/${name}/`))) {
474
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation changed outside exact scope');
475
+ }
476
+ }
477
+ /** Reconstruct Host-owned output with the same base generator in a disposable
478
+ * capsule, then compare bytes. This is also called after Docker checks so a
479
+ * candidate cannot change its manifest, patch, catalog, lock or scaffold
480
+ * config between preflight and durable plan creation. */
481
+ export async function verifyCreatedPluginWorkspace(input) {
482
+ creationName(input.name, input.creation.grant);
483
+ if (!DIGEST.test(input.creation.generatorDigest))
484
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation generator digest is invalid');
485
+ if (input.files !== undefined)
486
+ validateSourceCreationFiles(input.files);
487
+ const signal = AbortSignal.any([input.signal, AbortSignal.timeout(120_000)]);
488
+ const assets = await baseAssets({ repository: input.worktree, baseCommit: input.baseCommit,
489
+ environment: input.environment, signal, assertCurrent: input.assertCurrent });
490
+ if (assets.generatorDigest !== input.creation.generatorDigest)
491
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation generator inputs changed');
492
+ const worktree = assets.repository;
493
+ await checked(signal, input.assertCurrent, () => assertBaseFiles(worktree, assets, input.environment, signal, true));
494
+ await assertOnlyCreationChanges(worktree, assets.baseCommit, input.name, input.environment, signal);
495
+ const expected = await isolatedGenerator(assets, input.name, input.environment, signal, async (capsule, generated) => ({
496
+ files: generated, catalog: await safeBytes(join(capsule, 'plugins', 'README.md'), MAX_FILE_BYTES),
497
+ }));
498
+ // The generated scaffold counts against the same final candidate budget.
499
+ const observed = await generatedFiles(worktree, input.name);
500
+ const existing = new Map(observed.map(file => [file.path, file]));
501
+ const initial = new Map(expected.files.map(file => [file.path, file]));
502
+ for (const path of RESERVED_OUTPUTS) {
503
+ if (existing.get(path)?.content !== initial.get(path)?.content) {
504
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', `Host-owned generated file changed: ${path}`);
505
+ }
506
+ }
507
+ const catalog = await safeBytes(join(worktree, 'plugins', 'README.md'), MAX_FILE_BYTES);
508
+ if (!catalog.equals(expected.catalog))
509
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'generated catalog changed');
510
+ const manifest = JSON.parse(initial.get('package.json').content);
511
+ const baseLock = (await gitBlob(worktree, assets.blobs.get('pnpm-lock.yaml'), input.environment, signal)).toString('utf8');
512
+ const expectedLock = lockImporter(baseLock, manifest, input.name);
513
+ if (!(await safeBytes(join(worktree, 'pnpm-lock.yaml'), 8_388_608)).equals(Buffer.from(expectedLock))) {
514
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'Host-owned new lock importer changed');
515
+ }
516
+ for (const file of observed) {
517
+ if (!initial.has(file.path) && !creationCandidatePath(file.path)) {
518
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', `new plugin contains unauthorized file: ${file.path}`);
519
+ }
520
+ }
521
+ if (input.files !== undefined) {
522
+ for (const file of input.files) {
523
+ if (existing.get(file.path)?.content !== file.content) {
524
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', `source creation proposed file changed: ${file.path}`);
525
+ }
526
+ }
527
+ }
528
+ const changed = await changedSourcePaths(worktree, assets.baseCommit, input.environment, signal);
529
+ for (const path of changed) {
530
+ if (path === 'plugins/README.md' || path === 'pnpm-lock.yaml')
531
+ continue;
532
+ const prefix = `plugins/${input.name}/`;
533
+ if (!path.startsWith(prefix))
534
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation changed outside exact scope');
535
+ const within = path.slice(prefix.length);
536
+ if (!initial.has(within) && !creationCandidatePath(within)
537
+ || initial.has(within) && !creationCandidatePath(within) && existing.get(within)?.content !== initial.get(within)?.content) {
538
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', `source creation changed protected file: ${within}`);
539
+ }
540
+ }
541
+ await checked(signal, input.assertCurrent, () => Promise.resolve());
542
+ return Object.freeze({ scope: CREATE_SCOPE(input.name), generatorDigest: assets.generatorDigest });
543
+ }
544
+ function creationCandidatePath(path) {
545
+ try {
546
+ validateSourceCreationFiles([{ path, content: '' }]);
547
+ return true;
548
+ }
549
+ catch {
550
+ return false;
551
+ }
552
+ }
553
+ async function hostScaffold(assets, name, environment, signal) {
554
+ return isolatedGenerator(assets, name, environment, signal, async (capsule, files) => {
555
+ const manifest = JSON.parse(files.find(file => file.path === 'package.json').content);
556
+ if (manifest.name !== `@dsh-enhanced/${name}` || typeof manifest.version !== 'string'
557
+ || !record(manifest.dsh) || !record(manifest.dsh.bundle) || manifest.dsh.bundle.patch !== './cordis.patch.yml') {
558
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'public generator emitted invalid plugin identity');
559
+ }
560
+ const baseLock = (await gitBlob(assets.repository, assets.blobs.get('pnpm-lock.yaml'), environment, signal)).toString('utf8');
561
+ return Object.freeze({ baseCommit: assets.baseCommit, generatorDigest: assets.generatorDigest, files,
562
+ catalog: await safeBytes(join(capsule, 'plugins', 'README.md'), MAX_FILE_BYTES),
563
+ lock: lockImporter(baseLock, manifest, name) });
564
+ });
565
+ }
566
+ /** Reconstruct expected Host bytes without touching the repository. */
567
+ export async function inspectCreationHostScaffold(input) {
568
+ if (!NAME.test(input.name) || input.name.normalize('NFC') !== input.name) {
569
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation Host plugin name is invalid');
570
+ }
571
+ const assets = await baseAssets(input);
572
+ await checked(input.signal, input.assertCurrent, () => assertFreshName(assets, input.name, input.environment, input.signal));
573
+ return checked(input.signal, input.assertCurrent, () => hostScaffold(assets, input.name, input.environment, input.signal));
574
+ }
575
+ /** Build only the fixed Host scaffold in a clean private linked worktree. */
576
+ export async function prepareCreationHostScaffold(input) {
577
+ if (!NAME.test(input.name) || input.name.normalize('NFC') !== input.name || !DIGEST.test(input.expectedGeneratorDigest)) {
578
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation Host name or generator digest is invalid');
579
+ }
580
+ const assets = await baseAssets({ ...input, repository: input.worktree });
581
+ if (assets.generatorDigest !== input.expectedGeneratorDigest)
582
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation generator inputs changed');
583
+ const linked = await linkedWorktrees(assets.repository, input.environment);
584
+ const privateParent = await lstat(dirname(assets.repository));
585
+ if (linked.length < 2 || linked[0] === assets.repository || !linked.includes(assets.repository)
586
+ || !privateParent.isDirectory() || privateParent.isSymbolicLink() || (privateParent.mode & 0o022) !== 0) {
587
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation requires a private linked non-primary worktree');
588
+ }
589
+ await checked(input.signal, input.assertCurrent, () => assertFreshName(assets, input.name, input.environment, input.signal));
590
+ const expected = await checked(input.signal, input.assertCurrent, () => hostScaffold(assets, input.name, input.environment, input.signal));
591
+ await checked(input.signal, input.assertCurrent, () => generateChecked(assets.repository, input.name, assets, input.environment, input.signal));
592
+ await checked(input.signal, input.assertCurrent, () => writeFile(join(assets.repository, 'pnpm-lock.yaml'), expected.lock, { flag: 'w' }));
593
+ return expected;
594
+ }
595
+ /** Recheck generated metadata, catalog and lock while exposing the complete
596
+ * actual plugin file set, including ignored extras, to the revision verifier. */
597
+ export async function verifyCreationHostScaffold(input) {
598
+ if (!NAME.test(input.name) || input.name.normalize('NFC') !== input.name || !DIGEST.test(input.expectedGeneratorDigest)) {
599
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation Host name or generator digest is invalid');
600
+ }
601
+ const assets = await baseAssets({ ...input, repository: input.worktree });
602
+ if (assets.generatorDigest !== input.expectedGeneratorDigest)
603
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation generator inputs changed');
604
+ await checked(input.signal, input.assertCurrent, () => assertFreshName(assets, input.name, input.environment, input.signal));
605
+ await checked(input.signal, input.assertCurrent, () => assertBaseFiles(assets.repository, assets, input.environment, input.signal, true));
606
+ await assertOnlyCreationChanges(assets.repository, assets.baseCommit, input.name, input.environment, input.signal);
607
+ const expected = await checked(input.signal, input.assertCurrent, () => hostScaffold(assets, input.name, input.environment, input.signal));
608
+ const listed = await generatedFiles(assets.repository, input.name);
609
+ const actual = await Promise.all(listed.map(async (file) => {
610
+ const bytes = await safeBytes(join(assets.repository, 'plugins', input.name, file.path), MAX_FILE_BYTES);
611
+ if (bytes.includes(0))
612
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation plugin contains binary data');
613
+ return Object.freeze({ path: file.path, bytes: bytes.length,
614
+ content: new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes) });
615
+ }));
616
+ const existing = new Map(actual.map(file => [file.path, file]));
617
+ for (const file of expected.files.filter(file => RESERVED_OUTPUTS.includes(file.path))) {
618
+ if (existing.get(file.path)?.content !== file.content) {
619
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', `Host-owned generated file changed: ${file.path}`);
620
+ }
621
+ }
622
+ if (!(await safeBytes(join(assets.repository, 'plugins', 'README.md'), MAX_FILE_BYTES)).equals(expected.catalog)) {
623
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'generated catalog changed');
624
+ }
625
+ if (!(await safeBytes(join(assets.repository, 'pnpm-lock.yaml'), 8_388_608)).equals(Buffer.from(expected.lock))) {
626
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'Host-owned new lock importer changed');
627
+ }
628
+ await checked(input.signal, input.assertCurrent, () => Promise.resolve());
629
+ return Object.freeze({ expected, actual: Object.freeze(actual) });
630
+ }
631
+ export async function prepareCreatedPluginWorkspace(input) {
632
+ creationName(input.name, input.creation.grant);
633
+ validateSourceCreationFiles(input.files);
634
+ if (!DIGEST.test(input.creation.generatorDigest) || input.creation.grant.expiresAt <= Date.now()) {
635
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation binding expired or has invalid generator digest');
636
+ }
637
+ const signal = AbortSignal.any([input.signal, AbortSignal.timeout(120_000)]);
638
+ const assets = await baseAssets({ repository: input.worktree, baseCommit: input.baseCommit,
639
+ environment: input.environment, signal, assertCurrent: input.assertCurrent });
640
+ if (assets.generatorDigest !== input.creation.generatorDigest)
641
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation generator inputs changed');
642
+ const worktree = assets.repository;
643
+ const linked = await linkedWorktrees(worktree, input.environment);
644
+ const privateParent = await lstat(dirname(worktree));
645
+ if (linked.length < 2 || linked[0] === worktree || !linked.includes(worktree)
646
+ || !privateParent.isDirectory() || privateParent.isSymbolicLink() || (privateParent.mode & 0o022) !== 0) {
647
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation requires a private linked non-primary worktree');
648
+ }
649
+ await assertFreshName(assets, input.name, input.environment, signal);
650
+ await checked(signal, input.assertCurrent, () => assertCleanBase(worktree, assets, input.environment, signal));
651
+ const generated = await checked(signal, input.assertCurrent, () => generateChecked(worktree, input.name, assets, input.environment, signal));
652
+ const manifest = JSON.parse(generated.find(file => file.path === 'package.json').content);
653
+ if (manifest.name !== `@dsh-enhanced/${input.name}` || typeof manifest.version !== 'string'
654
+ || !record(manifest.dsh) || !record(manifest.dsh.bundle) || manifest.dsh.bundle.patch !== './cordis.patch.yml') {
655
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'public generator emitted invalid plugin identity');
656
+ }
657
+ const lockBase = (await gitBlob(worktree, assets.blobs.get('pnpm-lock.yaml'), input.environment, signal)).toString('utf8');
658
+ const lock = lockImporter(lockBase, manifest, input.name);
659
+ await checked(signal, input.assertCurrent, () => writeFile(join(worktree, 'pnpm-lock.yaml'), lock, { flag: 'w' }));
660
+ await checked(signal, input.assertCurrent, () => assertOnlyCreationChanges(worktree, assets.baseCommit, input.name, input.environment, signal));
661
+ await checked(signal, input.assertCurrent, () => writeCreatedPluginFiles({ worktree, name: input.name, files: input.files }));
662
+ return verifyCreatedPluginWorkspace({ ...input, worktree, signal });
663
+ }
664
+ //# sourceMappingURL=source-creation.js.map