@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
@@ -4,12 +4,439 @@ import { chmod, lstat, mkdtemp, readFile, realpath, rm, writeFile } from 'node:f
4
4
  import { tmpdir } from 'node:os';
5
5
  import { dirname, join, resolve } from 'node:path';
6
6
  import { ControlPlaneCliError } from './errors.js';
7
- import { checkedSourceSnapshot, runLocalCommand } from './source-workspace.js';
7
+ import { verifyRevisedPluginWorkspace } from './source-revision.js';
8
+ import { verifyCreatedPluginWorkspace } from './source-creation.js';
9
+ import { checkedSourceSnapshot, runLocalBuffer, runLocalCommand } from './source-workspace.js';
8
10
  const imageDigest = /^(?:[a-z0-9][a-z0-9._:/-]*@)?sha256:[a-f0-9]{64}$/u;
9
11
  const marker = /^DSH_PREPARED_PACK\t([^\t\n]+)\t([0-9]+)\t([a-f0-9]{64})\t([^\t\n]+)\t([^\t\n]+)$/mu;
12
+ const packFrame = 'DSH_PREPARED_PACK_BYTES_V1\t';
13
+ const maximumPackBytes = 32 * 1024 * 1024;
14
+ const maximumPackBase64Bytes = Math.ceil(maximumPackBytes / 3) * 4;
10
15
  const repositorySeccompSha256 = 'b1e4b5b709578785bd2aff4a3a344301997571ad0e8ae5747aec176571ddc342';
11
16
  /** Exact in-container command recorded in prepared source evidence. */
12
- export const PREPARED_SOURCE_BUILD_SCRIPT = 'set -eu; checked() { phase="$1"; shift; if "$@" >"/tmp/$phase.log" 2>&1; then return 0; else code=$?; printf "source build phase failed: %s\\n" "$phase" >&2; tail -c 8192 "/tmp/$phase.log" >&2; return "$code"; fi; }; umask 077; mkdir -p /workspace; tar -x -C /workspace; cd /workspace; checked install pnpm install --offline --frozen-lockfile --ignore-scripts; checked check pnpm check; mkdir -p /workspace/.dsh-pack; cd "$PLUGIN_ROOT"; checked pack pnpm pack --pack-destination /workspace/.dsh-pack; set -- /workspace/.dsh-pack/*.tgz; test "$#" = 1; pack="$1"; printf "DSH_PREPARED_PACK\\t%s\\t%s\\t%s\\t%s\\t%s\\n" "${pack##*/}" "$(wc -c < "$pack" | tr -d " ")" "$(sha256sum "$pack" | cut -d " " -f1)" "$(node --version)" "$(pnpm --version)"';
17
+ export const PREPARED_SOURCE_BUILD_SCRIPT = String.raw `set -eu
18
+ checked() {
19
+ phase="$1"; shift
20
+ if "$@" >"/tmp/$phase.log" 2>&1; then return 0; else
21
+ code=$?
22
+ printf 'source build phase failed: %s\n' "$phase" >&2
23
+ tail -c 8192 "/tmp/$phase.log" >&2
24
+ return "$code"
25
+ fi
26
+ }
27
+ umask 077
28
+ mkdir -p /workspace
29
+ archive="$(mktemp /tmp/dsh-source-input.XXXXXX)"
30
+ cat >"$archive"
31
+ archive_sha="$(sha256sum "$archive" | cut -d ' ' -f1)"
32
+ tar -xf "$archive" -C /workspace --no-same-owner --no-same-permissions
33
+ verify_input() {
34
+ current_sha="$(sha256sum "$archive" | cut -d ' ' -f1)"
35
+ if [ "$current_sha" != "$archive_sha" ]; then
36
+ printf 'source build input archive changed\n' >&2
37
+ return 1
38
+ fi
39
+ NODE_OPTIONS= NODE_PATH= node - "$archive" /workspace "$1" <<'DSH_SOURCE_VERIFY'
40
+ const fs = require('node:fs')
41
+ const path = require('node:path')
42
+ const archive = fs.openSync(process.argv[2], 'r')
43
+ const root = path.resolve(process.argv[3])
44
+ const initializing = process.argv[4] === 'init'
45
+ if (!initializing && process.argv[4] !== 'verify') throw new Error('invalid source verification phase')
46
+ const length = fs.fstatSync(archive).size
47
+ const fail = reason => { console.error('source build input changed: ' + reason); process.exit(1) }
48
+ const block = (position, size) => {
49
+ const out = Buffer.alloc(size)
50
+ if (fs.readSync(archive, out, 0, size, position) !== size) fail('archive truncated')
51
+ return out
52
+ }
53
+ const text = bytes => {
54
+ const end = bytes.indexOf(0)
55
+ const slice = end < 0 ? bytes : bytes.subarray(0, end)
56
+ const result = slice.toString('utf8')
57
+ if (!Buffer.from(result, 'utf8').equals(slice)) fail('archive path is not UTF-8')
58
+ return result
59
+ }
60
+ const octal = bytes => {
61
+ const value = text(bytes).trim()
62
+ if (!/^[0-7]+$/.test(value)) fail('unsupported archive number')
63
+ const parsed = Number.parseInt(value, 8)
64
+ if (!Number.isSafeInteger(parsed)) fail('archive number exceeds safe bound')
65
+ return parsed
66
+ }
67
+ const pax = bytes => {
68
+ const values = {}
69
+ for (let at = 0; at < bytes.length;) {
70
+ const space = bytes.indexOf(32, at)
71
+ if (space < 0) fail('malformed archive extension')
72
+ const size = Number(bytes.subarray(at, space).toString('ascii'))
73
+ if (!Number.isSafeInteger(size) || size <= space - at + 2 || at + size > bytes.length || bytes[at + size - 1] !== 10) fail('malformed archive extension')
74
+ const value = bytes.subarray(space + 1, at + size - 1).toString('utf8')
75
+ const equal = value.indexOf('=')
76
+ if (equal < 1) fail('malformed archive extension')
77
+ values[value.slice(0, equal)] = value.slice(equal + 1)
78
+ at += size
79
+ }
80
+ return values
81
+ }
82
+ const archiveKey = raw => {
83
+ const name = raw.startsWith('./') ? raw.slice(2) : raw
84
+ const parts = name.replace(/\/$/, '').split('/')
85
+ if (parts.length === 0 || parts.some(part => part === '' || part === '.' || part === '..' || part.includes('\\') || part.includes('\0'))) fail('unsafe archive path')
86
+ return parts.join('/')
87
+ }
88
+ const checkedPath = raw => {
89
+ const parts = archiveKey(raw).split('/')
90
+ let current = root
91
+ for (let index = 0; index < parts.length - 1; index += 1) {
92
+ current = path.join(current, parts[index])
93
+ const stat = fs.lstatSync(current)
94
+ if (!stat.isDirectory() || stat.isSymbolicLink()) fail('archive parent replaced by a link')
95
+ }
96
+ const full = path.resolve(root, ...parts)
97
+ if (!full.startsWith(root + path.sep)) fail('archive path escaped workspace')
98
+ return full
99
+ }
100
+ let at = 0
101
+ let extension = {}
102
+ const members = []
103
+ const byName = new Map()
104
+ while (at + 512 <= length) {
105
+ const header = block(at, 512)
106
+ if (header.every(byte => byte === 0)) break
107
+ const size = octal(header.subarray(124, 136))
108
+ const mode = octal(header.subarray(100, 108)) & 0o7777
109
+ const kind = String.fromCharCode(header[156] || 48)
110
+ const end = at + 512 + size
111
+ if (!Number.isSafeInteger(end) || end > length) fail('archive member truncated')
112
+ if (kind === 'g' || kind === 'x') {
113
+ if (size > 1_048_576) fail('archive extension exceeds bound')
114
+ const parsed = pax(block(at + 512, size))
115
+ if (kind === 'x') extension = parsed
116
+ } else {
117
+ const prefix = text(header.subarray(345, 500))
118
+ const headerName = text(header.subarray(0, 100))
119
+ const name = extension.path || (prefix ? prefix + '/' + headerName : headerName)
120
+ const key = archiveKey(name)
121
+ if (byName.has(key)) fail('duplicate archive member: ' + name)
122
+ const member = { name, key, kind, size, mode, offset: at + 512,
123
+ link: extension.linkpath || text(header.subarray(157, 257)) }
124
+ members.push(member)
125
+ byName.set(key, member)
126
+ extension = {}
127
+ }
128
+ at = Math.ceil(end / 512) * 512
129
+ }
130
+ if (members.length === 0) fail('archive had no source files')
131
+ // Only immutable package manifests can authorize pnpm 11's pre-install bin
132
+ // normalization. A bin must already have Git's owner executable bit.
133
+ const bins = new Set()
134
+ for (const member of members) {
135
+ if (member.key !== 'package.json' && !/^(?:plugins|packages)\/[a-z0-9][a-z0-9-]*\/package\.json$/.test(member.key)) continue
136
+ if ((member.kind !== '0' && member.kind !== '7') || member.size > 1_048_576) fail('unsafe source package manifest')
137
+ const bytes = block(member.offset, member.size)
138
+ const content = bytes.toString('utf8')
139
+ if (!Buffer.from(content, 'utf8').equals(bytes)) fail('source package manifest is not UTF-8')
140
+ let manifest
141
+ try { manifest = JSON.parse(content) } catch { fail('invalid source package manifest') }
142
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest) || manifest.directories?.bin !== undefined) fail('unsupported source package bin')
143
+ const declared = manifest.bin
144
+ if (declared === undefined) continue
145
+ if (typeof declared !== 'string' && (!declared || typeof declared !== 'object' || Array.isArray(declared))) fail('invalid source package bin')
146
+ const paths = typeof declared === 'string' ? [declared] : Object.values(declared)
147
+ const packageRoot = member.key === 'package.json' ? '' : member.key.slice(0, -'/package.json'.length) + '/'
148
+ for (const raw of paths) {
149
+ if (typeof raw !== 'string' || raw.endsWith('/')) fail('invalid source package bin path')
150
+ const relative = archiveKey(raw)
151
+ const targetKey = packageRoot + relative
152
+ const parts = targetKey.split('/')
153
+ for (let index = 1; index < parts.length; index += 1) {
154
+ const parent = byName.get(parts.slice(0, index).join('/'))
155
+ if (parent && parent.kind !== '5') fail('source package bin parent is not a directory: ' + raw)
156
+ }
157
+ const target = byName.get(targetKey)
158
+ if (target === undefined) {
159
+ if (relative.startsWith('lib/')) continue
160
+ fail('source package bin target is missing: ' + raw)
161
+ }
162
+ if ((target.kind !== '0' && target.kind !== '7') || !(target.mode & 0o100)) fail('source package bin target is not an executable regular input: ' + raw)
163
+ bins.add(target.key)
164
+ }
165
+ }
166
+ const verifiedBins = new Map()
167
+ for (const member of members) {
168
+ const { name, key, kind, size, mode, link } = member
169
+ const full = checkedPath(name)
170
+ const before = fs.lstatSync(full)
171
+ if (kind === '0' || kind === '7') {
172
+ const expectedMode = !initializing && bins.has(key) ? 0o755 : mode & 0o700
173
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size !== size || (before.mode & 0o7777) !== expectedMode) fail('original file replaced: ' + name)
174
+ const input = fs.openSync(full, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW)
175
+ try {
176
+ const opened = fs.fstatSync(input)
177
+ if (opened.dev !== before.dev || opened.ino !== before.ino || opened.size !== size || opened.nlink !== 1 || (opened.mode & 0o7777) !== expectedMode) fail('original file changed while opened: ' + name)
178
+ for (let offset = 0; offset < size; offset += 65_536) {
179
+ const count = Math.min(65_536, size - offset)
180
+ const expected = block(member.offset + offset, count)
181
+ const actual = Buffer.alloc(count)
182
+ if (fs.readSync(input, actual, 0, count, offset) !== count || !actual.equals(expected)) fail('original file content changed: ' + name)
183
+ }
184
+ const after = fs.fstatSync(input)
185
+ if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs || after.nlink !== 1 || (after.mode & 0o7777) !== expectedMode) fail('original file changed while compared: ' + name)
186
+ if (bins.has(key)) verifiedBins.set(key, { dev: after.dev, ino: after.ino, size: after.size, mtimeMs: after.mtimeMs, ctimeMs: after.ctimeMs })
187
+ } finally { fs.closeSync(input) }
188
+ } else if (kind === '5') {
189
+ if (!before.isDirectory() || before.isSymbolicLink() || (before.mode & 0o7777) !== (mode & 0o700)) fail('original directory replaced: ' + name)
190
+ } else if (kind === '2') {
191
+ if (!before.isSymbolicLink() || fs.readlinkSync(full) !== link) fail('original link replaced: ' + name)
192
+ } else fail('unsupported archive member type: ' + name)
193
+ }
194
+ if (initializing) {
195
+ for (const key of bins) {
196
+ const member = byName.get(key)
197
+ const full = checkedPath(member.name)
198
+ const input = fs.openSync(full, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW)
199
+ try {
200
+ const before = fs.fstatSync(input)
201
+ const verified = verifiedBins.get(key)
202
+ if (!verified || !before.isFile() || before.nlink !== 1 || before.dev !== verified.dev || before.ino !== verified.ino
203
+ || before.size !== verified.size || before.mtimeMs !== verified.mtimeMs || before.ctimeMs !== verified.ctimeMs
204
+ || (before.mode & 0o7777) !== (member.mode & 0o700)) fail('source package bin changed before normalization: ' + key)
205
+ fs.fchmodSync(input, 0o755)
206
+ if ((fs.fstatSync(input).mode & 0o7777) !== 0o755) fail('source package bin normalization failed: ' + key)
207
+ } finally { fs.closeSync(input) }
208
+ }
209
+ }
210
+ fs.closeSync(archive)
211
+ DSH_SOURCE_VERIFY
212
+ current_sha="$(sha256sum "$archive" | cut -d ' ' -f1)"
213
+ if [ "$current_sha" != "$archive_sha" ]; then
214
+ printf 'source build input archive changed\n' >&2
215
+ return 1
216
+ fi
217
+ }
218
+ cd /workspace
219
+ verify_input init
220
+ if [ "$(printenv DSH_SOURCE_TRUST_LOCKFILE 2>/dev/null || true)" = true ]; then
221
+ test "$(pnpm --version)" = 11.7.0 || { printf 'source creation requires pinned pnpm\n' >&2; exit 1; }
222
+ test -f /opt/dsh-source-baseline/pnpm-lock.yaml || { printf 'source creation baseline lock is absent\n' >&2; exit 1; }
223
+ test "$(sha256sum /opt/dsh-source-baseline/pnpm-lock.yaml | cut -d ' ' -f1)" = "$(printenv DSH_SOURCE_BASE_LOCK_SHA256 2>/dev/null || true)" || { printf 'source creation baseline lock differs\n' >&2; exit 1; }
224
+ checked install pnpm install --offline --frozen-lockfile --ignore-scripts --trust-lockfile
225
+ else
226
+ checked install pnpm install --offline --frozen-lockfile --ignore-scripts
227
+ fi
228
+ checked check /bin/sh -ceu 'umask 022; exec pnpm check'
229
+ verify_input verify
230
+ mkdir -p /workspace/.dsh-pack
231
+ cd "$PLUGIN_ROOT"
232
+ checked pack pnpm pack --pack-destination /workspace/.dsh-pack
233
+ verify_input verify
234
+ set -- /workspace/.dsh-pack/*.tgz
235
+ test "$#" = 1
236
+ pack="$1"
237
+ NODE_OPTIONS= NODE_PATH= node - "$archive" "$pack" "$PLUGIN_ROOT" "$archive_sha" <<'DSH_SOURCE_PACK_VERIFY'
238
+ const fs = require('node:fs')
239
+ const crypto = require('node:crypto')
240
+ const childProcess = require('node:child_process')
241
+ const path = require('node:path')
242
+ const zlib = require('node:zlib')
243
+ const fail = reason => { console.error('source packed artifact changed: ' + reason); process.exit(1) }
244
+ if (fs.statSync(process.argv[2]).size > 64 * 1024 * 1024) fail('source archive exceeds verification bound')
245
+ const source = fs.readFileSync(process.argv[2])
246
+ if (crypto.createHash('sha256').update(source).digest('hex') !== process.argv[5]) fail('immutable source archive changed')
247
+ const packedPath = process.argv[3]
248
+ const pluginRoot = process.argv[4]
249
+ if (!/^plugins\/[a-z0-9][a-z0-9-]*$/.test(pluginRoot)) fail('invalid plugin root')
250
+ const packedStat = fs.lstatSync(packedPath)
251
+ if (!packedStat.isFile() || packedStat.isSymbolicLink() || packedStat.nlink !== 1) fail('packed artifact is not a regular file')
252
+ if (packedStat.size > 32 * 1024 * 1024) fail('compressed artifact exceeds bound')
253
+ const packedFd = fs.openSync(packedPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW)
254
+ const opened = fs.fstatSync(packedFd)
255
+ if (opened.dev !== packedStat.dev || opened.ino !== packedStat.ino || opened.size !== packedStat.size) fail('packed artifact changed while opened')
256
+ const packed = fs.readFileSync(packedFd)
257
+ fs.closeSync(packedFd)
258
+ if (packed.length > 32 * 1024 * 1024) fail('compressed artifact exceeds bound')
259
+ let tar
260
+ try { tar = zlib.gunzipSync(packed, { maxOutputLength: 128 * 1024 * 1024 }) }
261
+ catch { fail('invalid or oversized gzip artifact') }
262
+ const string = value => {
263
+ const end = value.indexOf(0)
264
+ const bytes = end < 0 ? value : value.subarray(0, end)
265
+ const result = bytes.toString('utf8')
266
+ if (!Buffer.from(result, 'utf8').equals(bytes)) fail('invalid UTF-8 tar name')
267
+ return result
268
+ }
269
+ const octal = value => {
270
+ const raw = string(value).trim()
271
+ if (!/^[0-7]+$/.test(raw)) fail('invalid tar size')
272
+ const parsed = Number.parseInt(raw, 8)
273
+ if (!Number.isSafeInteger(parsed)) fail('oversized tar member')
274
+ return parsed
275
+ }
276
+ const pax = bytes => {
277
+ const values = {}
278
+ for (let at = 0; at < bytes.length;) {
279
+ const space = bytes.indexOf(32, at)
280
+ if (space < 0) fail('malformed tar extension')
281
+ const size = Number(bytes.subarray(at, space).toString('ascii'))
282
+ if (!Number.isSafeInteger(size) || size <= space - at + 2 || at + size > bytes.length || bytes[at + size - 1] !== 10) fail('malformed tar extension')
283
+ const value = bytes.subarray(space + 1, at + size - 1).toString('utf8')
284
+ const equal = value.indexOf('=')
285
+ if (equal < 1 || Object.hasOwn(values, value.slice(0, equal))) fail('malformed tar extension')
286
+ values[value.slice(0, equal)] = value.slice(equal + 1)
287
+ at += size
288
+ }
289
+ return values
290
+ }
291
+ const entries = (buffer, prefix) => {
292
+ const result = new Map()
293
+ let at = 0; let extension = {}; let global = {}
294
+ while (at + 512 <= buffer.length) {
295
+ const header = buffer.subarray(at, at + 512)
296
+ if (header.every(byte => byte === 0)) break
297
+ const checksum = octal(header.subarray(148, 156))
298
+ let actualChecksum = 0
299
+ for (let index = 0; index < 512; index += 1) actualChecksum += index >= 148 && index < 156 ? 32 : header[index]
300
+ if (checksum !== actualChecksum) fail('invalid tar header checksum')
301
+ const size = octal(header.subarray(124, 136))
302
+ const end = at + 512 + size
303
+ if (!Number.isSafeInteger(end) || end > buffer.length) fail('truncated tar member')
304
+ const kind = String.fromCharCode(header[156] || 48)
305
+ if (kind === 'g' || kind === 'x') {
306
+ if (size > 1_048_576) fail('oversized tar extension')
307
+ const parsed = pax(buffer.subarray(at + 512, end))
308
+ if (kind === 'g') global = { ...global, ...parsed }; else extension = parsed
309
+ } else {
310
+ const attributes = { ...global, ...extension }
311
+ if (Object.keys(attributes).some(key => !['path', 'mtime', 'atime', 'ctime', 'comment', 'uid', 'gid', 'uname', 'gname', 'mode'].includes(key))) fail('unsupported tar extension')
312
+ const headerName = string(header.subarray(0, 100))
313
+ const pathPrefix = string(header.subarray(345, 500))
314
+ const raw = attributes.path || (pathPrefix ? pathPrefix + '/' + headerName : headerName)
315
+ const name = raw.endsWith('/') ? raw.slice(0, -1) : raw
316
+ if (!name || name.startsWith('/') || name.includes('\\') || name.includes('\0') || name.split('/').some(part => !part || part === '.' || part === '..')) fail('unsafe tar member path')
317
+ if (result.has(name)) fail('duplicate tar member: ' + name)
318
+ if (kind !== '0' && kind !== '5' && kind !== '7') fail('unsupported tar member type: ' + name)
319
+ if (prefix === 'package' && name !== 'package' && !name.startsWith('package/')) fail('tar member outside package')
320
+ result.set(name, { kind, bytes: buffer.subarray(at + 512, end) })
321
+ extension = {}
322
+ }
323
+ at = Math.ceil(end / 512) * 512
324
+ }
325
+ if (at + 1024 > buffer.length || !buffer.subarray(at).every(byte => byte === 0)) fail('tar has no valid trailer')
326
+ return result
327
+ }
328
+ const original = entries(source, 'source')
329
+ const artifact = entries(tar, 'package')
330
+ const required = ['package.json', 'README.md', 'LICENSE', 'cordis.patch.yml']
331
+ for (const relative of required) {
332
+ const expected = original.get(pluginRoot + '/' + relative)
333
+ const actual = artifact.get('package/' + relative)
334
+ if (!expected || expected.kind !== '0' || !actual || actual.kind !== '0') fail('missing fixed package file: ' + relative)
335
+ }
336
+ const json = bytes => { try { return JSON.parse(bytes.toString('utf8')) } catch { fail('invalid package manifest') } }
337
+ const originalManifest = json(original.get(pluginRoot + '/package.json').bytes)
338
+ const actualManifest = json(artifact.get('package/package.json').bytes)
339
+ const expectedName = '@dsh-enhanced/' + pluginRoot.slice('plugins/'.length)
340
+ if (originalManifest.name !== expectedName || actualManifest.name !== expectedName
341
+ || originalManifest.dsh?.bundle?.patch !== './cordis.patch.yml'
342
+ || actualManifest.dsh?.bundle?.patch !== './cordis.patch.yml') fail('package identity or bundle patch changed')
343
+ const catalogs = new Map()
344
+ const workspaceYaml = original.get('pnpm-workspace.yaml')
345
+ if (!workspaceYaml || workspaceYaml.kind !== '0') fail('immutable workspace catalog is missing')
346
+ let catalogSection = ''
347
+ let catalogName = ''
348
+ for (const line of workspaceYaml.bytes.toString('utf8').split(/\r?\n/)) {
349
+ if (line === 'catalog:') { catalogSection = 'default'; catalogName = ''; continue }
350
+ if (line === 'catalogs:') { catalogSection = 'named'; catalogName = ''; continue }
351
+ if (/^[^\s#][^:]*:/u.test(line)) { catalogSection = ''; catalogName = ''; continue }
352
+ if (catalogSection === 'named') {
353
+ const heading = /^ ([a-z][a-z0-9-]*):\s*$/u.exec(line)
354
+ if (heading) { catalogName = heading[1]; continue }
355
+ }
356
+ const pattern = catalogSection === 'default' ? /^ (?:'([^']+)'|"([^"]+)"|([^\s:]+)):\s+(.+?)\s*$/u
357
+ : catalogSection === 'named' && catalogName ? /^ (?:'([^']+)'|"([^"]+)"|([^\s:]+)):\s+(.+?)\s*$/u : null
358
+ const matched = pattern?.exec(line)
359
+ if (!matched) continue
360
+ const name = matched[1] || matched[2] || matched[3]
361
+ const raw = matched[4]
362
+ const value = /^(['"])(.*)\1$/u.exec(raw)?.[2] ?? raw
363
+ const key = (catalogSection === 'named' ? catalogName : '') + '\0' + name
364
+ if (catalogs.has(key) || !value || value.includes('#')) fail('ambiguous immutable workspace catalog')
365
+ catalogs.set(key, value)
366
+ }
367
+ const workspaceVersions = new Map()
368
+ for (const [name, member] of original) {
369
+ if (!/^(?:plugins|packages)\/[^/]+\/package\.json$/u.test(name) || member.kind !== '0') continue
370
+ const manifest = json(member.bytes)
371
+ if (typeof manifest.name !== 'string' || typeof manifest.version !== 'string' || workspaceVersions.has(manifest.name)) fail('ambiguous workspace package identity')
372
+ workspaceVersions.set(manifest.name, manifest.version)
373
+ }
374
+ const dependencies = new Set(['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'])
375
+ for (const key of dependencies) {
376
+ const originalDependencies = originalManifest[key] || {}
377
+ const packedDependencies = actualManifest[key] || {}
378
+ if (!originalDependencies || typeof originalDependencies !== 'object' || Array.isArray(originalDependencies)
379
+ || !packedDependencies || typeof packedDependencies !== 'object' || Array.isArray(packedDependencies)
380
+ || JSON.stringify(Object.keys(originalDependencies).sort()) !== JSON.stringify(Object.keys(packedDependencies).sort())) fail('package dependency names differ')
381
+ for (const [name, value] of Object.entries(originalDependencies)) {
382
+ const packedValue = packedDependencies[name]
383
+ if (typeof value !== 'string' || typeof packedValue !== 'string' || packedValue.length > 256) fail('package dependency value differs')
384
+ let expected = value
385
+ if (value.startsWith('catalog:')) {
386
+ const catalogName = value.slice('catalog:'.length)
387
+ expected = catalogs.get(catalogName + '\0' + name)
388
+ if (typeof expected !== 'string') fail('unresolved immutable workspace catalog')
389
+ } else if (value.startsWith('workspace:')) {
390
+ const selector = value.slice('workspace:'.length)
391
+ const version = workspaceVersions.get(name)
392
+ if (typeof version !== 'string' || !/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/u.test(version)
393
+ || !['*', '^', '~'].includes(selector)) fail('unsupported workspace dependency locator')
394
+ expected = selector === '*' ? version : selector + version
395
+ }
396
+ if (packedValue !== expected) fail('package dependency value differs')
397
+ }
398
+ }
399
+ // pnpm 11.7.0 omits these fields, then appends its filtered scripts map.
400
+ // Derive that exact published order from the immutable archive, not the pack workspace.
401
+ const expectedManifest = {}
402
+ for (const [key, value] of Object.entries(originalManifest)) {
403
+ if (!['scripts', 'packageManager', 'pnpm'].includes(key)) expectedManifest[key] = value
404
+ }
405
+ if (originalManifest.scripts != null) {
406
+ if (typeof originalManifest.scripts !== 'object' || Array.isArray(originalManifest.scripts)) fail('invalid source package scripts')
407
+ const scripts = {}
408
+ const omitted = new Set(['prepublishOnly', 'prepack', 'prepare', 'postpack', 'publish', 'postpublish'])
409
+ for (const [key, value] of Object.entries(originalManifest.scripts)) {
410
+ if (!omitted.has(key)) scripts[key] = value
411
+ }
412
+ expectedManifest.scripts = scripts
413
+ }
414
+ if (!actualManifest || typeof actualManifest !== 'object' || Array.isArray(actualManifest)) fail('invalid package manifest')
415
+ for (const key of dependencies) { delete expectedManifest[key]; delete actualManifest[key] }
416
+ if (JSON.stringify(expectedManifest) !== JSON.stringify(actualManifest)) fail('fixed manifest fields differ')
417
+ const requirePublished = (entry, field) => {
418
+ if (typeof entry === 'string') {
419
+ if (!entry.startsWith('./') || entry.includes('*') || entry.split('/').some(part => part === '..')
420
+ || !artifact.has('package/' + entry.slice(2)) || artifact.get('package/' + entry.slice(2)).kind !== '0') fail('published entry is missing: ' + field)
421
+ } else if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
422
+ for (const [key, nested] of Object.entries(entry)) requirePublished(nested, field + '.' + key)
423
+ } else fail('unsupported published entry: ' + field)
424
+ }
425
+ for (const field of ['main', 'types', 'exports', 'imports', 'bin']) {
426
+ if (originalManifest[field] !== undefined) requirePublished(originalManifest[field], field)
427
+ }
428
+ for (const [name, actual] of artifact) {
429
+ if (actual.kind !== '0' && actual.kind !== '7') continue
430
+ const expected = original.get(pluginRoot + name.slice('package'.length))
431
+ if (expected && name !== 'package/package.json' && !actual.bytes.equals(expected.bytes)) fail('fixed package file differs: ' + name)
432
+ if (!expected && !name.startsWith('package/lib/')) fail('unexpected package file: ' + name)
433
+ }
434
+ const pnpmVersion = childProcess.execFileSync('pnpm', ['--version'], { encoding: 'utf8', timeout: 5000 }).trim()
435
+ const digest = crypto.createHash('sha256').update(packed).digest('hex')
436
+ console.log(['DSH_PREPARED_PACK', path.basename(packedPath), packed.length, digest, process.version, pnpmVersion].join('\t'))
437
+ if (process.env.DSH_SOURCE_CAPTURE_PACK === 'true') process.stdout.write('DSH_PREPARED_PACK_BYTES_V1\t' + packed.toString('base64') + '\n')
438
+ DSH_SOURCE_PACK_VERIFY
439
+ `;
13
440
  function sourceBuildLimits(config) {
14
441
  const profile = config.profile ?? 'standard';
15
442
  return { profile, temporaryMiB: config.temporaryMiB ?? (profile === 'repository' ? 2_048 : 32) };
@@ -106,6 +533,8 @@ async function sourceTree(worktree, baseCommit, scope, environment) {
106
533
  * value, or Host path crosses the boundary: the sole input is tar on stdin.
107
534
  */
108
535
  export async function runDockerPreparedChecks(input) {
536
+ if (input.capturePack !== undefined && input.capturePack !== true)
537
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source pack capture request is invalid');
109
538
  if (input.sourceJob !== undefined && (!/^source-job-[a-f0-9]{64}$/u.test(input.sourceJob.id)
110
539
  || input.sourceJob.containerName !== `dsh-${input.sourceJob.id}`))
111
540
  throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'invalid durable container identity');
@@ -119,6 +548,35 @@ export async function runDockerPreparedChecks(input) {
119
548
  throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'repository seccomp requires approved Docker runtime 29.4.1/linux/amd64');
120
549
  await input.assertCurrent();
121
550
  }
551
+ let creationSnapshot;
552
+ let baseLockDigest;
553
+ const verifyCreation = async () => {
554
+ if (input.creation === undefined && input.revision === undefined)
555
+ return;
556
+ if (input.creation !== undefined && input.revision !== undefined)
557
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source build has conflicting preparation authorities');
558
+ if ((input.creation?.grant ?? input.revision.binding.grant).expiresAt <= Date.now())
559
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation grant expired before build');
560
+ const verified = input.revision === undefined ? await verifyCreatedPluginWorkspace({ worktree: input.worktree, baseCommit: input.baseCommit,
561
+ name: input.name, environment: input.environment, signal: input.signal, assertCurrent: input.assertCurrent,
562
+ creation: input.creation }) : await verifyRevisedPluginWorkspace({ worktree: input.worktree, baseCommit: input.baseCommit,
563
+ name: input.name, environment: input.environment, signal: input.signal, assertCurrent: input.assertCurrent,
564
+ revision: input.revision.binding, parentSource: input.revision.parentSource, parentCertificate: input.revision.parentCertificate, files: input.revision.files });
565
+ const expected = [...input.scope].sort();
566
+ const actual = [...verified.scope].sort();
567
+ if (expected.length !== actual.length || expected.some((path, index) => path !== actual[index])) {
568
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation build scope differs from verified scope');
569
+ }
570
+ await input.assertCurrent();
571
+ if ((input.creation?.grant ?? input.revision.binding.grant).expiresAt <= Date.now())
572
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation grant expired before build');
573
+ };
574
+ if (input.creation !== undefined || input.revision !== undefined) {
575
+ await verifyCreation();
576
+ creationSnapshot = await checkedSourceSnapshot(input.worktree, input.baseCommit, input.scope, input.environment, undefined, input.signal);
577
+ const baseLock = await runLocalBuffer('git', ['show', `${input.baseCommit}:pnpm-lock.yaml`], input.worktree, input.environment, { maximumOutput: 8_388_608, signal: input.signal });
578
+ baseLockDigest = createHash('sha256').update(baseLock).digest('hex');
579
+ }
122
580
  const snapshot = await sourceTree(input.worktree, input.baseCommit, input.scope, input.environment);
123
581
  let before;
124
582
  let packageVersion;
@@ -126,6 +584,16 @@ export async function runDockerPreparedChecks(input) {
126
584
  try {
127
585
  seccomp = await snapshotRepositorySeccomp(input.config, snapshot.temporary);
128
586
  before = await checkedSourceSnapshot(input.worktree, input.baseCommit, input.scope, input.environment, snapshot.tree);
587
+ if (creationSnapshot !== undefined) {
588
+ if (before.checkedTreeDigest !== creationSnapshot.checkedTreeDigest || before.checkedPatchDigest !== creationSnapshot.checkedPatchDigest) {
589
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation staged tree differs from verified workspace');
590
+ }
591
+ await verifyCreation();
592
+ const current = await checkedSourceSnapshot(input.worktree, input.baseCommit, input.scope, input.environment, undefined, input.signal);
593
+ if (current.checkedTreeDigest !== creationSnapshot.checkedTreeDigest || current.checkedPatchDigest !== creationSnapshot.checkedPatchDigest) {
594
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation changed after verification');
595
+ }
596
+ }
129
597
  const packageJson = await runLocalCommand('git', ['show', `${snapshot.tree}:plugins/${input.name}/package.json`], input.worktree, input.environment, { capture: true, maximumOutput: 65_536 });
130
598
  const value = JSON.parse(packageJson)?.version;
131
599
  if (typeof value !== 'string' || !/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(value)) {
@@ -151,13 +619,19 @@ export async function runDockerPreparedChecks(input) {
151
619
  throw error;
152
620
  }
153
621
  }
622
+ if ((input.creation !== undefined || input.revision !== undefined) && (input.creation?.grant ?? input.revision.binding.grant).expiresAt <= Date.now()) {
623
+ await snapshot.cleanup();
624
+ throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation grant expired before build');
625
+ }
154
626
  const script = PREPARED_SOURCE_BUILD_SCRIPT;
155
627
  const args = ['run', '-i', '--pull', 'never', '--name', container, '--label', `dsh.source.tree=${snapshot.tree}`, ...(input.sourceJob === undefined ? [] : ['--label', `dsh.source.job=${input.sourceJob.id}`]), ...(seccomp === undefined ? [] : ['--label', `dsh.source.seccomp.sha256=${seccomp.digest}`]), '--network', 'none', '--read-only', '--cap-drop', 'ALL',
156
628
  '--security-opt', 'no-new-privileges', '--user', '65534:65534', '--pids-limit', String(input.config.pidsLimit),
157
629
  '--memory', `${input.config.memoryMiB}m`, '--memory-swap', `${input.config.memoryMiB}m`, '--cpus', String(input.config.cpus),
158
630
  '--tmpfs', `/workspace:rw,nosuid,nodev,mode=1777,size=${input.config.workspaceMiB}m${limits.profile === 'repository' ? ',exec' : ''}`, '--tmpfs', `/tmp:rw,nosuid,nodev,mode=1777,size=${limits.temporaryMiB}m${limits.profile === 'repository' ? ',exec' : ''}`,
159
631
  '--workdir', '/workspace', '--env', `PLUGIN_ROOT=plugins/${input.name}`, '--env', 'HOME=/tmp', '--env', 'PATH=/usr/local/bin:/usr/bin:/bin',
160
- ...(limits.profile === 'repository' ? ['--env', 'CI=true', '--env', 'VITEST_MAX_WORKERS=1'] : []),
632
+ ...(input.capturePack ? ['--env', 'DSH_SOURCE_CAPTURE_PACK=true'] : []),
633
+ ...(baseLockDigest === undefined ? [] : ['--env', 'DSH_SOURCE_TRUST_LOCKFILE=true', '--env', `DSH_SOURCE_BASE_LOCK_SHA256=${baseLockDigest}`]),
634
+ ...(limits.profile === 'repository' ? ['--env', 'CI=true', '--env', `VITEST_MAX_WORKERS=${Math.max(1, Math.min(4, Math.floor(input.config.cpus)))}`] : []),
161
635
  // Docker's masked proc submounts prevent procfs mounts in a child user
162
636
  // namespace. This opt-in removes those system-path masks; an empty /sys
163
637
  // compensates for sysfs exposure, but does not restore the /proc masks.
@@ -171,6 +645,7 @@ export async function runDockerPreparedChecks(input) {
171
645
  git.stdout.pipe(docker.stdin);
172
646
  const dockerDone = new Promise((resolvePromise, reject) => { docker.once('error', reject); docker.once('close', resolvePromise); });
173
647
  const gitDone = new Promise((resolvePromise, reject) => { git.once('error', reject); git.once('close', code => code === 0 ? resolvePromise() : reject(new ControlPlaneCliError('EXECUTOR_FAILED', `source archive exited ${code}`))); });
648
+ const stdoutLimit = input.config.outputBytes + (input.capturePack ? packFrame.length + maximumPackBase64Bytes + 1 : 0);
174
649
  let stdout = '';
175
650
  let stdoutBytes = 0;
176
651
  let stderr = '';
@@ -181,11 +656,11 @@ export async function runDockerPreparedChecks(input) {
181
656
  stderr = (stderr + chunk.toString('utf8')).slice(-4096);
182
657
  return;
183
658
  }
184
- const remaining = input.config.outputBytes - stdoutBytes;
659
+ const remaining = stdoutLimit - stdoutBytes;
185
660
  if (remaining > 0)
186
661
  stdout += chunk.subarray(0, remaining).toString('utf8');
187
662
  stdoutBytes += chunk.length;
188
- if (stdoutBytes > input.config.outputBytes) {
663
+ if (stdoutBytes > stdoutLimit) {
189
664
  overflow = true;
190
665
  stop();
191
666
  }
@@ -209,10 +684,28 @@ export async function runDockerPreparedChecks(input) {
209
684
  if (code !== 0)
210
685
  throw new ControlPlaneCliError('EXECUTOR_FAILED', `isolated source build failed (${code}): ${stderr.trimEnd()}`);
211
686
  const match = marker.exec(stdout);
212
- if (match !== null && stdout.trim() !== match[0])
213
- throw new ControlPlaneCliError('EXECUTOR_FAILED', 'isolated source build emitted unexpected evidence output');
214
687
  if (match === null)
215
688
  throw new ControlPlaneCliError('EXECUTOR_FAILED', 'isolated source build did not emit its pack evidence marker');
689
+ let packArtifact;
690
+ if (input.capturePack) {
691
+ const prefix = `${match[0]}\n${packFrame}`;
692
+ if (!stdout.startsWith(prefix) || !stdout.endsWith('\n'))
693
+ throw new ControlPlaneCliError('EXECUTOR_FAILED', 'isolated source build emitted an invalid pack capture frame');
694
+ const encoded = stdout.slice(prefix.length, -1);
695
+ const expectedSize = Number(match[2]);
696
+ if (!Number.isSafeInteger(expectedSize) || expectedSize < 1 || expectedSize > maximumPackBytes
697
+ || encoded.length !== Math.ceil(expectedSize / 3) * 4 || !/^[A-Za-z0-9+/]+={0,2}$/u.test(encoded)) {
698
+ throw new ControlPlaneCliError('EXECUTOR_FAILED', 'isolated source build emitted an invalid pack capture frame');
699
+ }
700
+ packArtifact = Buffer.from(encoded, 'base64');
701
+ if (packArtifact.length !== expectedSize || packArtifact.toString('base64') !== encoded
702
+ || createHash('sha256').update(packArtifact).digest('hex') !== match[3]) {
703
+ throw new ControlPlaneCliError('EXECUTOR_FAILED', 'isolated source build pack capture differs from verified evidence');
704
+ }
705
+ }
706
+ else if (stdout.trim() !== match[0]) {
707
+ throw new ControlPlaneCliError('EXECUTOR_FAILED', 'isolated source build emitted unexpected evidence output');
708
+ }
216
709
  const checked = await checkedSourceSnapshot(input.worktree, input.baseCommit, input.scope, input.environment);
217
710
  if (checked.checkedTreeDigest !== before.checkedTreeDigest || checked.checkedPatchDigest !== before.checkedPatchDigest) {
218
711
  throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'prepared source changed while its isolated build was running');
@@ -220,6 +713,7 @@ export async function runDockerPreparedChecks(input) {
220
713
  const logDigest = createHash('sha256').update(stdout).update('\0').update(stderr).digest('hex');
221
714
  const checkedAt = Date.now();
222
715
  return { treeDigest: checked.checkedTreeDigest, patchDigest: checked.checkedPatchDigest, checkedAt,
716
+ ...(packArtifact === undefined ? {} : { packArtifact }),
223
717
  evidence: Object.freeze({ schemaVersion: 1, kind: 'dsh-source-prepared-evidence',
224
718
  environment: Object.freeze({ npmConfigIgnoreScripts: true, frozenLockfile: true, offline: true, nodeVersion: match[4], pnpmVersion: match[5] }),
225
719
  commands: Object.freeze([{ command: 'docker', args: Object.freeze([...args]), exitCode: 0, durationMs: Date.now() - started, logDigest }]),