@dsh-enhanced/plugin-control-plane 0.1.48 → 0.1.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +187 -15
- package/bin/dsh-live-qualification-authority.js +15 -0
- package/bin/dsh-local-release-adapter.js +331 -9
- package/bin/dsh-systemd-host-attestor.js +359 -9
- package/bin/dsh-systemd-host-authority.js +12 -0
- package/lib/adoption-coordinator.d.ts +3 -1
- package/lib/adoption-coordinator.d.ts.map +1 -1
- package/lib/adoption-coordinator.js +24 -9
- package/lib/adoption-coordinator.js.map +1 -1
- package/lib/cli.d.ts.map +1 -1
- package/lib/cli.js +33 -14
- package/lib/cli.js.map +1 -1
- package/lib/creation-capability-feedback.d.ts +23 -0
- package/lib/creation-capability-feedback.d.ts.map +1 -0
- package/lib/creation-capability-feedback.js +132 -0
- package/lib/creation-capability-feedback.js.map +1 -0
- package/lib/creation-capability-journal.d.ts +66 -0
- package/lib/creation-capability-journal.d.ts.map +1 -0
- package/lib/creation-capability-journal.js +775 -0
- package/lib/creation-capability-journal.js.map +1 -0
- package/lib/creation-capability-runtime.d.ts +63 -0
- package/lib/creation-capability-runtime.d.ts.map +1 -0
- package/lib/creation-capability-runtime.js +578 -0
- package/lib/creation-capability-runtime.js.map +1 -0
- package/lib/creation-capability-source.d.ts +37 -0
- package/lib/creation-capability-source.d.ts.map +1 -0
- package/lib/creation-capability-source.js +328 -0
- package/lib/creation-capability-source.js.map +1 -0
- package/lib/creation-capability-types.d.ts +265 -0
- package/lib/creation-capability-types.d.ts.map +1 -0
- package/lib/creation-capability-types.js +2 -0
- package/lib/creation-capability-types.js.map +1 -0
- package/lib/deployment-readiness.d.ts +16 -0
- package/lib/deployment-readiness.d.ts.map +1 -1
- package/lib/deployment-readiness.js +48 -1
- package/lib/deployment-readiness.js.map +1 -1
- package/lib/effect-blocked-replay.js +2 -2
- package/lib/effect-blocked-replay.js.map +1 -1
- package/lib/foreground-deployment-runtime.d.ts +3 -0
- package/lib/foreground-deployment-runtime.d.ts.map +1 -1
- package/lib/foreground-deployment-runtime.js +55 -13
- package/lib/foreground-deployment-runtime.js.map +1 -1
- package/lib/foreground-deployment.d.ts.map +1 -1
- package/lib/foreground-deployment.js +5 -0
- package/lib/foreground-deployment.js.map +1 -1
- package/lib/host-attestor.d.ts +2 -0
- package/lib/host-attestor.d.ts.map +1 -1
- package/lib/host-attestor.js +8 -1
- package/lib/host-attestor.js.map +1 -1
- package/lib/host-inputs.d.ts +13 -0
- package/lib/host-inputs.d.ts.map +1 -0
- package/lib/host-inputs.js +162 -0
- package/lib/host-inputs.js.map +1 -0
- package/lib/host-maintenance.d.ts +61 -0
- package/lib/host-maintenance.d.ts.map +1 -0
- package/lib/host-maintenance.js +158 -0
- package/lib/host-maintenance.js.map +1 -0
- package/lib/host-readiness.d.ts +5 -0
- package/lib/host-readiness.d.ts.map +1 -0
- package/lib/host-readiness.js +22 -0
- package/lib/host-readiness.js.map +1 -0
- package/lib/index.d.ts +16 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +7 -0
- package/lib/index.js.map +1 -1
- package/lib/live-qualification-authority.d.ts +32 -0
- package/lib/live-qualification-authority.d.ts.map +1 -0
- package/lib/live-qualification-authority.js +280 -0
- package/lib/live-qualification-authority.js.map +1 -0
- package/lib/live-qualification-client.d.ts +4 -0
- package/lib/live-qualification-client.d.ts.map +1 -0
- package/lib/live-qualification-client.js +22 -0
- package/lib/live-qualification-client.js.map +1 -0
- package/lib/live-qualification-runtime.d.ts +78 -0
- package/lib/live-qualification-runtime.d.ts.map +1 -0
- package/lib/live-qualification-runtime.js +367 -0
- package/lib/live-qualification-runtime.js.map +1 -0
- package/lib/live-qualification.d.ts +73 -0
- package/lib/live-qualification.d.ts.map +1 -0
- package/lib/live-qualification.js +164 -0
- package/lib/live-qualification.js.map +1 -0
- package/lib/native-source-build-worker.d.ts +4 -0
- package/lib/native-source-build-worker.d.ts.map +1 -0
- package/lib/native-source-build-worker.js +87 -0
- package/lib/native-source-build-worker.js.map +1 -0
- package/lib/native-source-build.d.ts +36 -0
- package/lib/native-source-build.d.ts.map +1 -0
- package/lib/native-source-build.js +143 -0
- package/lib/native-source-build.js.map +1 -0
- package/lib/native-source-pack.d.ts +11 -0
- package/lib/native-source-pack.d.ts.map +1 -0
- package/lib/native-source-pack.js +83 -0
- package/lib/native-source-pack.js.map +1 -0
- package/lib/owner-task-gaps.d.ts +2 -0
- package/lib/owner-task-gaps.d.ts.map +1 -1
- package/lib/owner-task-gaps.js +9 -1
- package/lib/owner-task-gaps.js.map +1 -1
- package/lib/runtime-epoch.d.ts +69 -0
- package/lib/runtime-epoch.d.ts.map +1 -0
- package/lib/runtime-epoch.js +139 -0
- package/lib/runtime-epoch.js.map +1 -0
- package/lib/runtime-observer-protocol.d.ts +3 -3
- package/lib/runtime-observer-protocol.js +5 -5
- package/lib/runtime-observer-protocol.js.map +1 -1
- package/lib/runtime-observer.d.ts.map +1 -1
- package/lib/runtime-observer.js +42 -6
- package/lib/runtime-observer.js.map +1 -1
- package/lib/service.d.ts +248 -4
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +1431 -64
- package/lib/service.js.map +1 -1
- package/lib/source-adoption-authority.d.ts +3 -0
- package/lib/source-adoption-authority.d.ts.map +1 -1
- package/lib/source-adoption-authority.js +13 -3
- package/lib/source-adoption-authority.js.map +1 -1
- package/lib/source-adoption-runner.d.ts +3 -0
- package/lib/source-adoption-runner.d.ts.map +1 -1
- package/lib/source-adoption-runner.js +16 -4
- package/lib/source-adoption-runner.js.map +1 -1
- package/lib/source-approval-authority.d.ts.map +1 -1
- package/lib/source-approval-authority.js +1 -0
- package/lib/source-approval-authority.js.map +1 -1
- package/lib/source-baseline.d.ts +37 -0
- package/lib/source-baseline.d.ts.map +1 -0
- package/lib/source-baseline.js +250 -0
- package/lib/source-baseline.js.map +1 -0
- package/lib/source-build.d.ts +43 -9
- package/lib/source-build.d.ts.map +1 -1
- package/lib/source-build.js +670 -57
- package/lib/source-build.js.map +1 -1
- package/lib/source-context.d.ts +18 -0
- package/lib/source-context.d.ts.map +1 -1
- package/lib/source-context.js +85 -11
- package/lib/source-context.js.map +1 -1
- package/lib/source-creation.d.ts +106 -0
- package/lib/source-creation.d.ts.map +1 -0
- package/lib/source-creation.js +664 -0
- package/lib/source-creation.js.map +1 -0
- package/lib/source-job-types.d.ts +28 -1
- package/lib/source-job-types.d.ts.map +1 -1
- package/lib/source-jobs.d.ts +33 -3
- package/lib/source-jobs.d.ts.map +1 -1
- package/lib/source-jobs.js +420 -43
- package/lib/source-jobs.js.map +1 -1
- package/lib/source-maintenance.d.ts +66 -0
- package/lib/source-maintenance.d.ts.map +1 -0
- package/lib/source-maintenance.js +177 -0
- package/lib/source-maintenance.js.map +1 -0
- package/lib/source-revision.d.ts +57 -0
- package/lib/source-revision.d.ts.map +1 -0
- package/lib/source-revision.js +175 -0
- package/lib/source-revision.js.map +1 -0
- package/lib/source-workspace.d.ts +19 -14
- package/lib/source-workspace.d.ts.map +1 -1
- package/lib/source-workspace.js +90 -17
- package/lib/source-workspace.js.map +1 -1
- package/lib/sqlite.d.ts +13 -3
- package/lib/sqlite.d.ts.map +1 -1
- package/lib/sqlite.js +362 -1
- package/lib/sqlite.js.map +1 -1
- package/lib/store.d.ts +258 -3
- package/lib/store.d.ts.map +1 -1
- package/lib/store.js +2257 -96
- package/lib/store.js.map +1 -1
- package/lib/systemd-host-authority.d.ts +68 -0
- package/lib/systemd-host-authority.d.ts.map +1 -0
- package/lib/systemd-host-authority.js +424 -0
- package/lib/systemd-host-authority.js.map +1 -0
- package/lib/task-observation-evidence.d.ts +32 -0
- package/lib/task-observation-evidence.d.ts.map +1 -0
- package/lib/task-observation-evidence.js +62 -0
- package/lib/task-observation-evidence.js.map +1 -0
- package/lib/task-observation-runtime.d.ts +2 -3
- package/lib/task-observation-runtime.d.ts.map +1 -1
- package/lib/task-observation-runtime.js +7 -39
- package/lib/task-observation-runtime.js.map +1 -1
- package/lib/task-observation-store.js +1 -1
- package/lib/trust.d.ts.map +1 -1
- package/lib/trust.js +0 -0
- package/lib/trust.js.map +1 -1
- package/lib/types.d.ts +57 -7
- package/lib/types.d.ts.map +1 -1
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +22 -14
package/lib/source-build.js
CHANGED
|
@@ -1,27 +1,509 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { chmod, lstat, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises';
|
|
4
|
-
import { tmpdir } from 'node:os';
|
|
5
|
-
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { homedir, tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
6
6
|
import { ControlPlaneCliError } from './errors.js';
|
|
7
|
-
import {
|
|
7
|
+
import { verifyRevisedPluginWorkspace } from './source-revision.js';
|
|
8
|
+
import { verifyCreatedPluginWorkspace } from './source-creation.js';
|
|
9
|
+
import { checkedSourceSnapshot, runLocalBuffer, runLocalCommand } from './source-workspace.js';
|
|
10
|
+
/** Keep Config synchronous and independent of the Mac executor package: Linux
|
|
11
|
+
* authority runtimes copy this module without assistant-isolation. The native
|
|
12
|
+
* executor repeats this shape check and verifies the approved bytes at dispatch. */
|
|
13
|
+
function validNativeRuntimeShape(value) {
|
|
14
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
15
|
+
return false;
|
|
16
|
+
const runtime = value;
|
|
17
|
+
const hex = (part) => typeof part === 'string' && /^[0-9a-f]{64}$/u.test(part);
|
|
18
|
+
const canonical = (part) => typeof part === 'string' && isAbsolute(part)
|
|
19
|
+
&& resolve(part) === part && !part.includes('\0') && !part.includes('\r') && !part.includes('\n');
|
|
20
|
+
const within = (path, root) => path === root || path.startsWith(root + sep);
|
|
21
|
+
return Object.keys(runtime).sort().join() === 'modulesPath,nodePath,nodeSha256,platform,roots,schemaVersion,versions'
|
|
22
|
+
&& runtime.schemaVersion === 1 && runtime.platform === `darwin-${process.arch}` && canonical(runtime.nodePath)
|
|
23
|
+
&& hex(runtime.nodeSha256) && canonical(runtime.modulesPath) && Array.isArray(runtime.roots)
|
|
24
|
+
&& runtime.roots.length >= 1 && runtime.roots.length <= 32 && !!runtime.versions && typeof runtime.versions === 'object'
|
|
25
|
+
&& Object.keys(runtime.versions).sort().join() === 'cordis,node,systemPrompt,tools,typescript'
|
|
26
|
+
&& Object.values(runtime.versions).every(version => typeof version === 'string' && version.length >= 1 && version.length <= 128)
|
|
27
|
+
&& runtime.roots.every(root => !!root && Object.keys(root).sort().join() === 'dev,ino,path,sha256'
|
|
28
|
+
&& canonical(root.path) && root.path !== '/' && Number.isSafeInteger(root.dev) && root.dev >= 0
|
|
29
|
+
&& Number.isSafeInteger(root.ino) && root.ino >= 0 && hex(root.sha256))
|
|
30
|
+
&& new Set(runtime.roots.map(root => root.path)).size === runtime.roots.length
|
|
31
|
+
&& runtime.roots.some(root => within(runtime.nodePath, root.path) && runtime.nodePath !== root.path)
|
|
32
|
+
&& runtime.roots.some(root => within(runtime.modulesPath, root.path));
|
|
33
|
+
}
|
|
8
34
|
const imageDigest = /^(?:[a-z0-9][a-z0-9._:/-]*@)?sha256:[a-f0-9]{64}$/u;
|
|
9
35
|
const marker = /^DSH_PREPARED_PACK\t([^\t\n]+)\t([0-9]+)\t([a-f0-9]{64})\t([^\t\n]+)\t([^\t\n]+)$/mu;
|
|
36
|
+
const packFrame = 'DSH_PREPARED_PACK_BYTES_V1\t';
|
|
37
|
+
const maximumPackBytes = 32 * 1024 * 1024;
|
|
38
|
+
const maximumPackBase64Bytes = Math.ceil(maximumPackBytes / 3) * 4;
|
|
10
39
|
const repositorySeccompSha256 = 'b1e4b5b709578785bd2aff4a3a344301997571ad0e8ae5747aec176571ddc342';
|
|
11
40
|
/** Exact in-container command recorded in prepared source evidence. */
|
|
12
|
-
export const PREPARED_SOURCE_BUILD_SCRIPT =
|
|
41
|
+
export const PREPARED_SOURCE_BUILD_SCRIPT = String.raw `set -eu
|
|
42
|
+
checked() {
|
|
43
|
+
phase="$1"; shift
|
|
44
|
+
if "$@" >"/tmp/$phase.log" 2>&1; then return 0; else
|
|
45
|
+
code=$?
|
|
46
|
+
printf 'source build phase failed: %s\n' "$phase" >&2
|
|
47
|
+
tail -c 8192 "/tmp/$phase.log" >&2
|
|
48
|
+
return "$code"
|
|
49
|
+
fi
|
|
50
|
+
}
|
|
51
|
+
umask 077
|
|
52
|
+
mkdir -p /workspace
|
|
53
|
+
archive="$(mktemp /tmp/dsh-source-input.XXXXXX)"
|
|
54
|
+
cat >"$archive"
|
|
55
|
+
archive_sha="$(sha256sum "$archive" | cut -d ' ' -f1)"
|
|
56
|
+
tar -xf "$archive" -C /workspace --no-same-owner --no-same-permissions
|
|
57
|
+
verify_input() {
|
|
58
|
+
current_sha="$(sha256sum "$archive" | cut -d ' ' -f1)"
|
|
59
|
+
if [ "$current_sha" != "$archive_sha" ]; then
|
|
60
|
+
printf 'source build input archive changed\n' >&2
|
|
61
|
+
return 1
|
|
62
|
+
fi
|
|
63
|
+
NODE_OPTIONS= NODE_PATH= node - "$archive" /workspace "$1" <<'DSH_SOURCE_VERIFY'
|
|
64
|
+
const fs = require('node:fs')
|
|
65
|
+
const path = require('node:path')
|
|
66
|
+
const archive = fs.openSync(process.argv[2], 'r')
|
|
67
|
+
const root = path.resolve(process.argv[3])
|
|
68
|
+
const initializing = process.argv[4] === 'init'
|
|
69
|
+
if (!initializing && process.argv[4] !== 'verify') throw new Error('invalid source verification phase')
|
|
70
|
+
const length = fs.fstatSync(archive).size
|
|
71
|
+
const fail = reason => { console.error('source build input changed: ' + reason); process.exit(1) }
|
|
72
|
+
const block = (position, size) => {
|
|
73
|
+
const out = Buffer.alloc(size)
|
|
74
|
+
if (fs.readSync(archive, out, 0, size, position) !== size) fail('archive truncated')
|
|
75
|
+
return out
|
|
76
|
+
}
|
|
77
|
+
const text = bytes => {
|
|
78
|
+
const end = bytes.indexOf(0)
|
|
79
|
+
const slice = end < 0 ? bytes : bytes.subarray(0, end)
|
|
80
|
+
const result = slice.toString('utf8')
|
|
81
|
+
if (!Buffer.from(result, 'utf8').equals(slice)) fail('archive path is not UTF-8')
|
|
82
|
+
return result
|
|
83
|
+
}
|
|
84
|
+
const octal = bytes => {
|
|
85
|
+
const value = text(bytes).trim()
|
|
86
|
+
if (!/^[0-7]+$/.test(value)) fail('unsupported archive number')
|
|
87
|
+
const parsed = Number.parseInt(value, 8)
|
|
88
|
+
if (!Number.isSafeInteger(parsed)) fail('archive number exceeds safe bound')
|
|
89
|
+
return parsed
|
|
90
|
+
}
|
|
91
|
+
const pax = bytes => {
|
|
92
|
+
const values = {}
|
|
93
|
+
for (let at = 0; at < bytes.length;) {
|
|
94
|
+
const space = bytes.indexOf(32, at)
|
|
95
|
+
if (space < 0) fail('malformed archive extension')
|
|
96
|
+
const size = Number(bytes.subarray(at, space).toString('ascii'))
|
|
97
|
+
if (!Number.isSafeInteger(size) || size <= space - at + 2 || at + size > bytes.length || bytes[at + size - 1] !== 10) fail('malformed archive extension')
|
|
98
|
+
const value = bytes.subarray(space + 1, at + size - 1).toString('utf8')
|
|
99
|
+
const equal = value.indexOf('=')
|
|
100
|
+
if (equal < 1) fail('malformed archive extension')
|
|
101
|
+
values[value.slice(0, equal)] = value.slice(equal + 1)
|
|
102
|
+
at += size
|
|
103
|
+
}
|
|
104
|
+
return values
|
|
105
|
+
}
|
|
106
|
+
const archiveKey = raw => {
|
|
107
|
+
const name = raw.startsWith('./') ? raw.slice(2) : raw
|
|
108
|
+
const parts = name.replace(/\/$/, '').split('/')
|
|
109
|
+
if (parts.length === 0 || parts.some(part => part === '' || part === '.' || part === '..' || part.includes('\\') || part.includes('\0'))) fail('unsafe archive path')
|
|
110
|
+
return parts.join('/')
|
|
111
|
+
}
|
|
112
|
+
const checkedPath = raw => {
|
|
113
|
+
const parts = archiveKey(raw).split('/')
|
|
114
|
+
let current = root
|
|
115
|
+
for (let index = 0; index < parts.length - 1; index += 1) {
|
|
116
|
+
current = path.join(current, parts[index])
|
|
117
|
+
const stat = fs.lstatSync(current)
|
|
118
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) fail('archive parent replaced by a link')
|
|
119
|
+
}
|
|
120
|
+
const full = path.resolve(root, ...parts)
|
|
121
|
+
if (!full.startsWith(root + path.sep)) fail('archive path escaped workspace')
|
|
122
|
+
return full
|
|
123
|
+
}
|
|
124
|
+
let at = 0
|
|
125
|
+
let extension = {}
|
|
126
|
+
const members = []
|
|
127
|
+
const byName = new Map()
|
|
128
|
+
while (at + 512 <= length) {
|
|
129
|
+
const header = block(at, 512)
|
|
130
|
+
if (header.every(byte => byte === 0)) break
|
|
131
|
+
const size = octal(header.subarray(124, 136))
|
|
132
|
+
const mode = octal(header.subarray(100, 108)) & 0o7777
|
|
133
|
+
const kind = String.fromCharCode(header[156] || 48)
|
|
134
|
+
const end = at + 512 + size
|
|
135
|
+
if (!Number.isSafeInteger(end) || end > length) fail('archive member truncated')
|
|
136
|
+
if (kind === 'g' || kind === 'x') {
|
|
137
|
+
if (size > 1_048_576) fail('archive extension exceeds bound')
|
|
138
|
+
const parsed = pax(block(at + 512, size))
|
|
139
|
+
if (kind === 'x') extension = parsed
|
|
140
|
+
} else {
|
|
141
|
+
const prefix = text(header.subarray(345, 500))
|
|
142
|
+
const headerName = text(header.subarray(0, 100))
|
|
143
|
+
const name = extension.path || (prefix ? prefix + '/' + headerName : headerName)
|
|
144
|
+
const key = archiveKey(name)
|
|
145
|
+
if (byName.has(key)) fail('duplicate archive member: ' + name)
|
|
146
|
+
const member = { name, key, kind, size, mode, offset: at + 512,
|
|
147
|
+
link: extension.linkpath || text(header.subarray(157, 257)) }
|
|
148
|
+
members.push(member)
|
|
149
|
+
byName.set(key, member)
|
|
150
|
+
extension = {}
|
|
151
|
+
}
|
|
152
|
+
at = Math.ceil(end / 512) * 512
|
|
153
|
+
}
|
|
154
|
+
if (members.length === 0) fail('archive had no source files')
|
|
155
|
+
// Only immutable package manifests can authorize pnpm 11's pre-install bin
|
|
156
|
+
// normalization. A bin must already have Git's owner executable bit.
|
|
157
|
+
const bins = new Set()
|
|
158
|
+
for (const member of members) {
|
|
159
|
+
if (member.key !== 'package.json' && !/^(?:plugins|packages)\/[a-z0-9][a-z0-9-]*\/package\.json$/.test(member.key)) continue
|
|
160
|
+
if ((member.kind !== '0' && member.kind !== '7') || member.size > 1_048_576) fail('unsafe source package manifest')
|
|
161
|
+
const bytes = block(member.offset, member.size)
|
|
162
|
+
const content = bytes.toString('utf8')
|
|
163
|
+
if (!Buffer.from(content, 'utf8').equals(bytes)) fail('source package manifest is not UTF-8')
|
|
164
|
+
let manifest
|
|
165
|
+
try { manifest = JSON.parse(content) } catch { fail('invalid source package manifest') }
|
|
166
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest) || manifest.directories?.bin !== undefined) fail('unsupported source package bin')
|
|
167
|
+
const declared = manifest.bin
|
|
168
|
+
if (declared === undefined) continue
|
|
169
|
+
if (typeof declared !== 'string' && (!declared || typeof declared !== 'object' || Array.isArray(declared))) fail('invalid source package bin')
|
|
170
|
+
const paths = typeof declared === 'string' ? [declared] : Object.values(declared)
|
|
171
|
+
const packageRoot = member.key === 'package.json' ? '' : member.key.slice(0, -'/package.json'.length) + '/'
|
|
172
|
+
for (const raw of paths) {
|
|
173
|
+
if (typeof raw !== 'string' || raw.endsWith('/')) fail('invalid source package bin path')
|
|
174
|
+
const relative = archiveKey(raw)
|
|
175
|
+
const targetKey = packageRoot + relative
|
|
176
|
+
const parts = targetKey.split('/')
|
|
177
|
+
for (let index = 1; index < parts.length; index += 1) {
|
|
178
|
+
const parent = byName.get(parts.slice(0, index).join('/'))
|
|
179
|
+
if (parent && parent.kind !== '5') fail('source package bin parent is not a directory: ' + raw)
|
|
180
|
+
}
|
|
181
|
+
const target = byName.get(targetKey)
|
|
182
|
+
if (target === undefined) {
|
|
183
|
+
if (relative.startsWith('lib/')) continue
|
|
184
|
+
fail('source package bin target is missing: ' + raw)
|
|
185
|
+
}
|
|
186
|
+
if ((target.kind !== '0' && target.kind !== '7') || !(target.mode & 0o100)) fail('source package bin target is not an executable regular input: ' + raw)
|
|
187
|
+
bins.add(target.key)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const verifiedBins = new Map()
|
|
191
|
+
for (const member of members) {
|
|
192
|
+
const { name, key, kind, size, mode, link } = member
|
|
193
|
+
const full = checkedPath(name)
|
|
194
|
+
const before = fs.lstatSync(full)
|
|
195
|
+
if (kind === '0' || kind === '7') {
|
|
196
|
+
const expectedMode = !initializing && bins.has(key) ? 0o755 : mode & 0o700
|
|
197
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size !== size || (before.mode & 0o7777) !== expectedMode) fail('original file replaced: ' + name)
|
|
198
|
+
const input = fs.openSync(full, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW)
|
|
199
|
+
try {
|
|
200
|
+
const opened = fs.fstatSync(input)
|
|
201
|
+
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)
|
|
202
|
+
for (let offset = 0; offset < size; offset += 65_536) {
|
|
203
|
+
const count = Math.min(65_536, size - offset)
|
|
204
|
+
const expected = block(member.offset + offset, count)
|
|
205
|
+
const actual = Buffer.alloc(count)
|
|
206
|
+
if (fs.readSync(input, actual, 0, count, offset) !== count || !actual.equals(expected)) fail('original file content changed: ' + name)
|
|
207
|
+
}
|
|
208
|
+
const after = fs.fstatSync(input)
|
|
209
|
+
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)
|
|
210
|
+
if (bins.has(key)) verifiedBins.set(key, { dev: after.dev, ino: after.ino, size: after.size, mtimeMs: after.mtimeMs, ctimeMs: after.ctimeMs })
|
|
211
|
+
} finally { fs.closeSync(input) }
|
|
212
|
+
} else if (kind === '5') {
|
|
213
|
+
if (!before.isDirectory() || before.isSymbolicLink() || (before.mode & 0o7777) !== (mode & 0o700)) fail('original directory replaced: ' + name)
|
|
214
|
+
} else if (kind === '2') {
|
|
215
|
+
if (!before.isSymbolicLink() || fs.readlinkSync(full) !== link) fail('original link replaced: ' + name)
|
|
216
|
+
} else fail('unsupported archive member type: ' + name)
|
|
217
|
+
}
|
|
218
|
+
if (initializing) {
|
|
219
|
+
for (const key of bins) {
|
|
220
|
+
const member = byName.get(key)
|
|
221
|
+
const full = checkedPath(member.name)
|
|
222
|
+
const input = fs.openSync(full, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW)
|
|
223
|
+
try {
|
|
224
|
+
const before = fs.fstatSync(input)
|
|
225
|
+
const verified = verifiedBins.get(key)
|
|
226
|
+
if (!verified || !before.isFile() || before.nlink !== 1 || before.dev !== verified.dev || before.ino !== verified.ino
|
|
227
|
+
|| before.size !== verified.size || before.mtimeMs !== verified.mtimeMs || before.ctimeMs !== verified.ctimeMs
|
|
228
|
+
|| (before.mode & 0o7777) !== (member.mode & 0o700)) fail('source package bin changed before normalization: ' + key)
|
|
229
|
+
fs.fchmodSync(input, 0o755)
|
|
230
|
+
if ((fs.fstatSync(input).mode & 0o7777) !== 0o755) fail('source package bin normalization failed: ' + key)
|
|
231
|
+
} finally { fs.closeSync(input) }
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
fs.closeSync(archive)
|
|
235
|
+
DSH_SOURCE_VERIFY
|
|
236
|
+
current_sha="$(sha256sum "$archive" | cut -d ' ' -f1)"
|
|
237
|
+
if [ "$current_sha" != "$archive_sha" ]; then
|
|
238
|
+
printf 'source build input archive changed\n' >&2
|
|
239
|
+
return 1
|
|
240
|
+
fi
|
|
241
|
+
}
|
|
242
|
+
cd /workspace
|
|
243
|
+
verify_input init
|
|
244
|
+
if [ "$(printenv DSH_SOURCE_TRUST_LOCKFILE 2>/dev/null || true)" = true ]; then
|
|
245
|
+
test "$(pnpm --version)" = 11.7.0 || { printf 'source creation requires pinned pnpm\n' >&2; exit 1; }
|
|
246
|
+
test -f /opt/dsh-source-baseline/pnpm-lock.yaml || { printf 'source creation baseline lock is absent\n' >&2; exit 1; }
|
|
247
|
+
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; }
|
|
248
|
+
checked install pnpm install --offline --frozen-lockfile --ignore-scripts --trust-lockfile
|
|
249
|
+
else
|
|
250
|
+
checked install pnpm install --offline --frozen-lockfile --ignore-scripts
|
|
251
|
+
fi
|
|
252
|
+
checked check /bin/sh -ceu 'umask 022; exec pnpm check'
|
|
253
|
+
verify_input verify
|
|
254
|
+
mkdir -p /workspace/.dsh-pack
|
|
255
|
+
cd "$PLUGIN_ROOT"
|
|
256
|
+
checked pack pnpm pack --pack-destination /workspace/.dsh-pack
|
|
257
|
+
verify_input verify
|
|
258
|
+
set -- /workspace/.dsh-pack/*.tgz
|
|
259
|
+
test "$#" = 1
|
|
260
|
+
pack="$1"
|
|
261
|
+
NODE_OPTIONS= NODE_PATH= node - "$archive" "$pack" "$PLUGIN_ROOT" "$archive_sha" <<'DSH_SOURCE_PACK_VERIFY'
|
|
262
|
+
const fs = require('node:fs')
|
|
263
|
+
const crypto = require('node:crypto')
|
|
264
|
+
const childProcess = require('node:child_process')
|
|
265
|
+
const path = require('node:path')
|
|
266
|
+
const zlib = require('node:zlib')
|
|
267
|
+
const fail = reason => { console.error('source packed artifact changed: ' + reason); process.exit(1) }
|
|
268
|
+
if (fs.statSync(process.argv[2]).size > 64 * 1024 * 1024) fail('source archive exceeds verification bound')
|
|
269
|
+
const source = fs.readFileSync(process.argv[2])
|
|
270
|
+
if (crypto.createHash('sha256').update(source).digest('hex') !== process.argv[5]) fail('immutable source archive changed')
|
|
271
|
+
const packedPath = process.argv[3]
|
|
272
|
+
const pluginRoot = process.argv[4]
|
|
273
|
+
if (!/^plugins\/[a-z0-9][a-z0-9-]*$/.test(pluginRoot)) fail('invalid plugin root')
|
|
274
|
+
const packedStat = fs.lstatSync(packedPath)
|
|
275
|
+
if (!packedStat.isFile() || packedStat.isSymbolicLink() || packedStat.nlink !== 1) fail('packed artifact is not a regular file')
|
|
276
|
+
if (packedStat.size > 32 * 1024 * 1024) fail('compressed artifact exceeds bound')
|
|
277
|
+
const packedFd = fs.openSync(packedPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW)
|
|
278
|
+
const opened = fs.fstatSync(packedFd)
|
|
279
|
+
if (opened.dev !== packedStat.dev || opened.ino !== packedStat.ino || opened.size !== packedStat.size) fail('packed artifact changed while opened')
|
|
280
|
+
const packed = fs.readFileSync(packedFd)
|
|
281
|
+
fs.closeSync(packedFd)
|
|
282
|
+
if (packed.length > 32 * 1024 * 1024) fail('compressed artifact exceeds bound')
|
|
283
|
+
let tar
|
|
284
|
+
try { tar = zlib.gunzipSync(packed, { maxOutputLength: 128 * 1024 * 1024 }) }
|
|
285
|
+
catch { fail('invalid or oversized gzip artifact') }
|
|
286
|
+
const string = value => {
|
|
287
|
+
const end = value.indexOf(0)
|
|
288
|
+
const bytes = end < 0 ? value : value.subarray(0, end)
|
|
289
|
+
const result = bytes.toString('utf8')
|
|
290
|
+
if (!Buffer.from(result, 'utf8').equals(bytes)) fail('invalid UTF-8 tar name')
|
|
291
|
+
return result
|
|
292
|
+
}
|
|
293
|
+
const octal = value => {
|
|
294
|
+
const raw = string(value).trim()
|
|
295
|
+
if (!/^[0-7]+$/.test(raw)) fail('invalid tar size')
|
|
296
|
+
const parsed = Number.parseInt(raw, 8)
|
|
297
|
+
if (!Number.isSafeInteger(parsed)) fail('oversized tar member')
|
|
298
|
+
return parsed
|
|
299
|
+
}
|
|
300
|
+
const pax = bytes => {
|
|
301
|
+
const values = {}
|
|
302
|
+
for (let at = 0; at < bytes.length;) {
|
|
303
|
+
const space = bytes.indexOf(32, at)
|
|
304
|
+
if (space < 0) fail('malformed tar extension')
|
|
305
|
+
const size = Number(bytes.subarray(at, space).toString('ascii'))
|
|
306
|
+
if (!Number.isSafeInteger(size) || size <= space - at + 2 || at + size > bytes.length || bytes[at + size - 1] !== 10) fail('malformed tar extension')
|
|
307
|
+
const value = bytes.subarray(space + 1, at + size - 1).toString('utf8')
|
|
308
|
+
const equal = value.indexOf('=')
|
|
309
|
+
if (equal < 1 || Object.hasOwn(values, value.slice(0, equal))) fail('malformed tar extension')
|
|
310
|
+
values[value.slice(0, equal)] = value.slice(equal + 1)
|
|
311
|
+
at += size
|
|
312
|
+
}
|
|
313
|
+
return values
|
|
314
|
+
}
|
|
315
|
+
const entries = (buffer, prefix) => {
|
|
316
|
+
const result = new Map()
|
|
317
|
+
let at = 0; let extension = {}; let global = {}
|
|
318
|
+
while (at + 512 <= buffer.length) {
|
|
319
|
+
const header = buffer.subarray(at, at + 512)
|
|
320
|
+
if (header.every(byte => byte === 0)) break
|
|
321
|
+
const checksum = octal(header.subarray(148, 156))
|
|
322
|
+
let actualChecksum = 0
|
|
323
|
+
for (let index = 0; index < 512; index += 1) actualChecksum += index >= 148 && index < 156 ? 32 : header[index]
|
|
324
|
+
if (checksum !== actualChecksum) fail('invalid tar header checksum')
|
|
325
|
+
const size = octal(header.subarray(124, 136))
|
|
326
|
+
const end = at + 512 + size
|
|
327
|
+
if (!Number.isSafeInteger(end) || end > buffer.length) fail('truncated tar member')
|
|
328
|
+
const kind = String.fromCharCode(header[156] || 48)
|
|
329
|
+
if (kind === 'g' || kind === 'x') {
|
|
330
|
+
if (size > 1_048_576) fail('oversized tar extension')
|
|
331
|
+
const parsed = pax(buffer.subarray(at + 512, end))
|
|
332
|
+
if (kind === 'g') global = { ...global, ...parsed }; else extension = parsed
|
|
333
|
+
} else {
|
|
334
|
+
const attributes = { ...global, ...extension }
|
|
335
|
+
if (Object.keys(attributes).some(key => !['path', 'mtime', 'atime', 'ctime', 'comment', 'uid', 'gid', 'uname', 'gname', 'mode'].includes(key))) fail('unsupported tar extension')
|
|
336
|
+
const headerName = string(header.subarray(0, 100))
|
|
337
|
+
const pathPrefix = string(header.subarray(345, 500))
|
|
338
|
+
const raw = attributes.path || (pathPrefix ? pathPrefix + '/' + headerName : headerName)
|
|
339
|
+
const name = raw.endsWith('/') ? raw.slice(0, -1) : raw
|
|
340
|
+
if (!name || name.startsWith('/') || name.includes('\\') || name.includes('\0') || name.split('/').some(part => !part || part === '.' || part === '..')) fail('unsafe tar member path')
|
|
341
|
+
if (result.has(name)) fail('duplicate tar member: ' + name)
|
|
342
|
+
if (kind !== '0' && kind !== '5' && kind !== '7') fail('unsupported tar member type: ' + name)
|
|
343
|
+
if (prefix === 'package' && name !== 'package' && !name.startsWith('package/')) fail('tar member outside package')
|
|
344
|
+
result.set(name, { kind, bytes: buffer.subarray(at + 512, end) })
|
|
345
|
+
extension = {}
|
|
346
|
+
}
|
|
347
|
+
at = Math.ceil(end / 512) * 512
|
|
348
|
+
}
|
|
349
|
+
if (at + 1024 > buffer.length || !buffer.subarray(at).every(byte => byte === 0)) fail('tar has no valid trailer')
|
|
350
|
+
return result
|
|
351
|
+
}
|
|
352
|
+
const original = entries(source, 'source')
|
|
353
|
+
const artifact = entries(tar, 'package')
|
|
354
|
+
const required = ['package.json', 'README.md', 'LICENSE', 'cordis.patch.yml']
|
|
355
|
+
for (const relative of required) {
|
|
356
|
+
const expected = original.get(pluginRoot + '/' + relative)
|
|
357
|
+
const actual = artifact.get('package/' + relative)
|
|
358
|
+
if (!expected || expected.kind !== '0' || !actual || actual.kind !== '0') fail('missing fixed package file: ' + relative)
|
|
359
|
+
}
|
|
360
|
+
const json = bytes => { try { return JSON.parse(bytes.toString('utf8')) } catch { fail('invalid package manifest') } }
|
|
361
|
+
const originalManifest = json(original.get(pluginRoot + '/package.json').bytes)
|
|
362
|
+
const actualManifest = json(artifact.get('package/package.json').bytes)
|
|
363
|
+
const expectedName = '@dsh-enhanced/' + pluginRoot.slice('plugins/'.length)
|
|
364
|
+
if (originalManifest.name !== expectedName || actualManifest.name !== expectedName
|
|
365
|
+
|| originalManifest.dsh?.bundle?.patch !== './cordis.patch.yml'
|
|
366
|
+
|| actualManifest.dsh?.bundle?.patch !== './cordis.patch.yml') fail('package identity or bundle patch changed')
|
|
367
|
+
const catalogs = new Map()
|
|
368
|
+
const workspaceYaml = original.get('pnpm-workspace.yaml')
|
|
369
|
+
if (!workspaceYaml || workspaceYaml.kind !== '0') fail('immutable workspace catalog is missing')
|
|
370
|
+
let catalogSection = ''
|
|
371
|
+
let catalogName = ''
|
|
372
|
+
for (const line of workspaceYaml.bytes.toString('utf8').split(/\r?\n/)) {
|
|
373
|
+
if (line === 'catalog:') { catalogSection = 'default'; catalogName = ''; continue }
|
|
374
|
+
if (line === 'catalogs:') { catalogSection = 'named'; catalogName = ''; continue }
|
|
375
|
+
if (/^[^\s#][^:]*:/u.test(line)) { catalogSection = ''; catalogName = ''; continue }
|
|
376
|
+
if (catalogSection === 'named') {
|
|
377
|
+
const heading = /^ ([a-z][a-z0-9-]*):\s*$/u.exec(line)
|
|
378
|
+
if (heading) { catalogName = heading[1]; continue }
|
|
379
|
+
}
|
|
380
|
+
const pattern = catalogSection === 'default' ? /^ (?:'([^']+)'|"([^"]+)"|([^\s:]+)):\s+(.+?)\s*$/u
|
|
381
|
+
: catalogSection === 'named' && catalogName ? /^ (?:'([^']+)'|"([^"]+)"|([^\s:]+)):\s+(.+?)\s*$/u : null
|
|
382
|
+
const matched = pattern?.exec(line)
|
|
383
|
+
if (!matched) continue
|
|
384
|
+
const name = matched[1] || matched[2] || matched[3]
|
|
385
|
+
const raw = matched[4]
|
|
386
|
+
const value = /^(['"])(.*)\1$/u.exec(raw)?.[2] ?? raw
|
|
387
|
+
const key = (catalogSection === 'named' ? catalogName : '') + '\0' + name
|
|
388
|
+
if (catalogs.has(key) || !value || value.includes('#')) fail('ambiguous immutable workspace catalog')
|
|
389
|
+
catalogs.set(key, value)
|
|
390
|
+
}
|
|
391
|
+
const workspaceVersions = new Map()
|
|
392
|
+
for (const [name, member] of original) {
|
|
393
|
+
if (!/^(?:plugins|packages)\/[^/]+\/package\.json$/u.test(name) || member.kind !== '0') continue
|
|
394
|
+
const manifest = json(member.bytes)
|
|
395
|
+
if (typeof manifest.name !== 'string' || typeof manifest.version !== 'string' || workspaceVersions.has(manifest.name)) fail('ambiguous workspace package identity')
|
|
396
|
+
workspaceVersions.set(manifest.name, manifest.version)
|
|
397
|
+
}
|
|
398
|
+
const dependencies = new Set(['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'])
|
|
399
|
+
for (const key of dependencies) {
|
|
400
|
+
const originalDependencies = originalManifest[key] || {}
|
|
401
|
+
const packedDependencies = actualManifest[key] || {}
|
|
402
|
+
if (!originalDependencies || typeof originalDependencies !== 'object' || Array.isArray(originalDependencies)
|
|
403
|
+
|| !packedDependencies || typeof packedDependencies !== 'object' || Array.isArray(packedDependencies)
|
|
404
|
+
|| JSON.stringify(Object.keys(originalDependencies).sort()) !== JSON.stringify(Object.keys(packedDependencies).sort())) fail('package dependency names differ')
|
|
405
|
+
for (const [name, value] of Object.entries(originalDependencies)) {
|
|
406
|
+
const packedValue = packedDependencies[name]
|
|
407
|
+
if (typeof value !== 'string' || typeof packedValue !== 'string' || packedValue.length > 256) fail('package dependency value differs')
|
|
408
|
+
let expected = value
|
|
409
|
+
if (value.startsWith('catalog:')) {
|
|
410
|
+
const catalogName = value.slice('catalog:'.length)
|
|
411
|
+
expected = catalogs.get(catalogName + '\0' + name)
|
|
412
|
+
if (typeof expected !== 'string') fail('unresolved immutable workspace catalog')
|
|
413
|
+
} else if (value.startsWith('workspace:')) {
|
|
414
|
+
const selector = value.slice('workspace:'.length)
|
|
415
|
+
const version = workspaceVersions.get(name)
|
|
416
|
+
if (typeof version !== 'string' || !/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/u.test(version)
|
|
417
|
+
|| !['*', '^', '~'].includes(selector)) fail('unsupported workspace dependency locator')
|
|
418
|
+
expected = selector === '*' ? version : selector + version
|
|
419
|
+
}
|
|
420
|
+
if (packedValue !== expected) fail('package dependency value differs')
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
// pnpm 11.7.0 omits these fields, then appends its filtered scripts map.
|
|
424
|
+
// Derive that exact published order from the immutable archive, not the pack workspace.
|
|
425
|
+
const expectedManifest = {}
|
|
426
|
+
for (const [key, value] of Object.entries(originalManifest)) {
|
|
427
|
+
if (!['scripts', 'packageManager', 'pnpm'].includes(key)) expectedManifest[key] = value
|
|
428
|
+
}
|
|
429
|
+
if (originalManifest.scripts != null) {
|
|
430
|
+
if (typeof originalManifest.scripts !== 'object' || Array.isArray(originalManifest.scripts)) fail('invalid source package scripts')
|
|
431
|
+
const scripts = {}
|
|
432
|
+
const omitted = new Set(['prepublishOnly', 'prepack', 'prepare', 'postpack', 'publish', 'postpublish'])
|
|
433
|
+
for (const [key, value] of Object.entries(originalManifest.scripts)) {
|
|
434
|
+
if (!omitted.has(key)) scripts[key] = value
|
|
435
|
+
}
|
|
436
|
+
expectedManifest.scripts = scripts
|
|
437
|
+
}
|
|
438
|
+
if (!actualManifest || typeof actualManifest !== 'object' || Array.isArray(actualManifest)) fail('invalid package manifest')
|
|
439
|
+
for (const key of dependencies) { delete expectedManifest[key]; delete actualManifest[key] }
|
|
440
|
+
if (JSON.stringify(expectedManifest) !== JSON.stringify(actualManifest)) fail('fixed manifest fields differ')
|
|
441
|
+
const requirePublished = (entry, field) => {
|
|
442
|
+
if (typeof entry === 'string') {
|
|
443
|
+
if (!entry.startsWith('./') || entry.includes('*') || entry.split('/').some(part => part === '..')
|
|
444
|
+
|| !artifact.has('package/' + entry.slice(2)) || artifact.get('package/' + entry.slice(2)).kind !== '0') fail('published entry is missing: ' + field)
|
|
445
|
+
} else if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
|
446
|
+
for (const [key, nested] of Object.entries(entry)) requirePublished(nested, field + '.' + key)
|
|
447
|
+
} else fail('unsupported published entry: ' + field)
|
|
448
|
+
}
|
|
449
|
+
for (const field of ['main', 'types', 'exports', 'imports', 'bin']) {
|
|
450
|
+
if (originalManifest[field] !== undefined) requirePublished(originalManifest[field], field)
|
|
451
|
+
}
|
|
452
|
+
for (const [name, actual] of artifact) {
|
|
453
|
+
if (actual.kind !== '0' && actual.kind !== '7') continue
|
|
454
|
+
const expected = original.get(pluginRoot + name.slice('package'.length))
|
|
455
|
+
if (expected && name !== 'package/package.json' && !actual.bytes.equals(expected.bytes)) fail('fixed package file differs: ' + name)
|
|
456
|
+
if (!expected && !name.startsWith('package/lib/')) fail('unexpected package file: ' + name)
|
|
457
|
+
}
|
|
458
|
+
const pnpmVersion = childProcess.execFileSync('pnpm', ['--version'], { encoding: 'utf8', timeout: 5000 }).trim()
|
|
459
|
+
const digest = crypto.createHash('sha256').update(packed).digest('hex')
|
|
460
|
+
console.log(['DSH_PREPARED_PACK', path.basename(packedPath), packed.length, digest, process.version, pnpmVersion].join('\t'))
|
|
461
|
+
if (process.env.DSH_SOURCE_CAPTURE_PACK === 'true') process.stdout.write('DSH_PREPARED_PACK_BYTES_V1\t' + packed.toString('base64') + '\n')
|
|
462
|
+
DSH_SOURCE_PACK_VERIFY
|
|
463
|
+
`;
|
|
13
464
|
function sourceBuildLimits(config) {
|
|
14
465
|
const profile = config.profile ?? 'standard';
|
|
15
|
-
return { profile, temporaryMiB: config.temporaryMiB ?? (profile === '
|
|
466
|
+
return { profile, temporaryMiB: config.temporaryMiB ?? (profile === 'standard' ? 32 : 2_048) };
|
|
16
467
|
}
|
|
17
468
|
export function validateSourceBuildConfig(config) {
|
|
18
|
-
if (config === null || typeof config !== 'object' || Array.isArray(config)
|
|
469
|
+
if (config === null || typeof config !== 'object' || Array.isArray(config)) {
|
|
470
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source build configuration is invalid');
|
|
471
|
+
}
|
|
472
|
+
if ('backend' in config) {
|
|
473
|
+
const allowed = new Set(['backend', 'runtime', 'stateRoot', 'authorityDigest', 'expiresAt', 'maxRuns',
|
|
474
|
+
'maxTotalDurationMs', 'timeoutMs', 'outputBytes', 'versioning']);
|
|
475
|
+
if (config.backend !== 'macos-seatbelt' || Object.keys(config).some(key => !allowed.has(key))
|
|
476
|
+
|| typeof config.stateRoot !== 'string' || !config.stateRoot.startsWith('/') || resolve(config.stateRoot) !== config.stateRoot
|
|
477
|
+
|| !/^[a-f0-9]{64}$/u.test(config.authorityDigest)
|
|
478
|
+
|| !Number.isSafeInteger(config.expiresAt) || config.expiresAt <= 0
|
|
479
|
+
|| !Number.isSafeInteger(config.maxRuns) || config.maxRuns < 1 || config.maxRuns > 1000
|
|
480
|
+
|| !Number.isSafeInteger(config.maxTotalDurationMs) || config.maxTotalDurationMs < 60_000 || config.maxTotalDurationMs > 86_400_000
|
|
481
|
+
|| !Number.isSafeInteger(config.timeoutMs) || config.timeoutMs < 1_000 || config.timeoutMs > 300_000
|
|
482
|
+
|| !Number.isSafeInteger(config.outputBytes) || config.outputBytes < 4096 || config.outputBytes > 1_048_576
|
|
483
|
+
|| (config.versioning !== undefined && config.versioning !== 'patch')) {
|
|
484
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'native source build configuration is invalid');
|
|
485
|
+
}
|
|
486
|
+
try {
|
|
487
|
+
if (!validNativeRuntimeShape(config.runtime))
|
|
488
|
+
throw new Error('invalid native runtime');
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'native source runtime configuration is invalid');
|
|
492
|
+
}
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
if (config === null || typeof config !== 'object' || Array.isArray(config) || (config.profile !== undefined && config.profile !== 'standard' && config.profile !== 'repository' && config.profile !== 'portable-repository')
|
|
19
496
|
|| (config.versioning !== undefined && config.versioning !== 'patch')
|
|
20
497
|
|| (config.temporaryMiB !== undefined && !Number.isSafeInteger(config.temporaryMiB))
|
|
21
|
-
|| (config.
|
|
498
|
+
|| (config.profile === 'portable-repository' && config.repositorySandbox === undefined)
|
|
499
|
+
|| (config.repositorySandbox !== undefined && (config.profile !== 'repository' && config.profile !== 'portable-repository' || typeof config.repositorySandbox !== 'object' || config.repositorySandbox === null
|
|
22
500
|
|| Array.isArray(config.repositorySandbox) || Object.getPrototypeOf(config.repositorySandbox) !== Object.prototype
|
|
23
501
|
|| Object.keys(config.repositorySandbox).length !== 1 || Object.keys(config.repositorySandbox)[0] !== 'seccompPath'
|
|
24
502
|
|| typeof config.repositorySandbox.seccompPath !== 'string' || !config.repositorySandbox.seccompPath.startsWith('/') || config.repositorySandbox.seccompPath.includes('\0')))
|
|
503
|
+
|| (config.profile === 'portable-repository' ? typeof config.socketPath !== 'string' : config.socketPath !== undefined)
|
|
504
|
+
|| (config.profile === 'portable-repository' ? typeof config.daemonId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9:._-]{7,127}$/u.test(config.daemonId) || !/^sha256:[a-f0-9]{64}$/u.test(config.image) : config.daemonId !== undefined)
|
|
505
|
+
|| (config.socketPath !== undefined && (typeof config.socketPath !== 'string' || !config.socketPath.startsWith('/')
|
|
506
|
+
|| resolve(config.socketPath) !== config.socketPath || /[\p{Cc},]/u.test(config.socketPath) || config.socketPath.includes('://')))
|
|
25
507
|
|| typeof config.image !== 'string' || config.image.length > 256 || !imageDigest.test(config.image) || typeof config.dockerPath !== 'string' || !config.dockerPath.startsWith('/') || config.dockerPath.includes('\0')
|
|
26
508
|
|| !Number.isSafeInteger(config.timeoutMs) || config.timeoutMs < 60_000
|
|
27
509
|
|| !Number.isSafeInteger(config.memoryMiB) || config.memoryMiB < 128
|
|
@@ -32,7 +514,7 @@ export function validateSourceBuildConfig(config) {
|
|
|
32
514
|
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source build configuration is invalid');
|
|
33
515
|
}
|
|
34
516
|
const limits = sourceBuildLimits(config);
|
|
35
|
-
const repository = limits.profile
|
|
517
|
+
const repository = limits.profile !== 'standard';
|
|
36
518
|
if (config.timeoutMs > (repository ? 1_800_000 : 240_000)
|
|
37
519
|
|| config.memoryMiB > (repository ? 16_384 : 4_096)
|
|
38
520
|
|| config.pidsLimit > (repository ? 1_024 : 512)
|
|
@@ -46,8 +528,10 @@ async function snapshotRepositorySeccomp(config, temporary) {
|
|
|
46
528
|
const sandbox = config.repositorySandbox;
|
|
47
529
|
if (sandbox === undefined)
|
|
48
530
|
return undefined;
|
|
49
|
-
if (process.platform
|
|
50
|
-
|
|
531
|
+
if (!(process.platform === 'linux' && process.arch === 'x64')
|
|
532
|
+
&& !(config.profile === 'portable-repository' && process.platform === 'darwin' && ['x64', 'arm64'].includes(process.arch))) {
|
|
533
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'repository seccomp requires an approved native Host');
|
|
534
|
+
}
|
|
51
535
|
const canonical = await realpath(sandbox.seccompPath);
|
|
52
536
|
if (canonical !== resolve(sandbox.seccompPath))
|
|
53
537
|
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'repository seccomp profile must be canonical');
|
|
@@ -84,6 +568,26 @@ async function verifiedDockerPath(path) {
|
|
|
84
568
|
}
|
|
85
569
|
return canonical;
|
|
86
570
|
}
|
|
571
|
+
async function verifiedPortableSocket(config) {
|
|
572
|
+
if (config.profile !== 'portable-repository')
|
|
573
|
+
return undefined;
|
|
574
|
+
if (process.platform !== 'darwin' || !['x64', 'arm64'].includes(process.arch)) {
|
|
575
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'portable repository requires native macOS');
|
|
576
|
+
}
|
|
577
|
+
const expected = join(homedir(), '.docker', 'run', 'docker.sock');
|
|
578
|
+
if (config.socketPath !== expected || await realpath(expected) !== expected) {
|
|
579
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'portable Docker socket must be the owner local socket');
|
|
580
|
+
}
|
|
581
|
+
const socket = await lstat(expected), parent = await lstat(dirname(expected));
|
|
582
|
+
if (!socket.isSocket() || socket.uid !== process.getuid?.() || !parent.isDirectory()
|
|
583
|
+
|| parent.uid !== process.getuid?.() || (parent.mode & 0o022) !== 0) {
|
|
584
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'portable Docker socket is not owner-safe');
|
|
585
|
+
}
|
|
586
|
+
return expected;
|
|
587
|
+
}
|
|
588
|
+
function portableArgs(socket, configDirectory, args) {
|
|
589
|
+
return socket === undefined ? args : ['--config', configDirectory, '-H', `unix://${socket}`, ...args];
|
|
590
|
+
}
|
|
87
591
|
async function sourceTree(worktree, baseCommit, scope, environment) {
|
|
88
592
|
const temporary = await mkdtemp(join(tmpdir(), 'dsh-source-build-index-'));
|
|
89
593
|
try {
|
|
@@ -106,19 +610,67 @@ async function sourceTree(worktree, baseCommit, scope, environment) {
|
|
|
106
610
|
* value, or Host path crosses the boundary: the sole input is tar on stdin.
|
|
107
611
|
*/
|
|
108
612
|
export async function runDockerPreparedChecks(input) {
|
|
613
|
+
if (input.capturePack !== undefined && input.capturePack !== true)
|
|
614
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source pack capture request is invalid');
|
|
109
615
|
if (input.sourceJob !== undefined && (!/^source-job-[a-f0-9]{64}$/u.test(input.sourceJob.id)
|
|
110
616
|
|| input.sourceJob.containerName !== `dsh-${input.sourceJob.id}`))
|
|
111
617
|
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'invalid durable container identity');
|
|
112
618
|
validateSourceBuildConfig(input.config);
|
|
113
619
|
const limits = sourceBuildLimits(input.config);
|
|
114
620
|
const dockerPath = await verifiedDockerPath(input.config.dockerPath);
|
|
621
|
+
const socket = await verifiedPortableSocket(input.config);
|
|
115
622
|
await input.assertCurrent();
|
|
116
|
-
if (input.config.repositorySandbox !== undefined) {
|
|
623
|
+
if (input.config.profile === 'repository' && input.config.repositorySandbox !== undefined) {
|
|
117
624
|
const version = await dockerControl(dockerPath, ['version', '--format', '{{.Server.Version}}/{{.Server.Os}}/{{.Server.Arch}}'], input.signal);
|
|
118
625
|
if (version.code !== 0 || version.stdout.trim() !== '29.4.1/linux/amd64')
|
|
119
626
|
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'repository seccomp requires approved Docker runtime 29.4.1/linux/amd64');
|
|
120
627
|
await input.assertCurrent();
|
|
121
628
|
}
|
|
629
|
+
if (socket !== undefined) {
|
|
630
|
+
const version = await dockerControl(dockerPath, ['version', '--format', '{{.Server.Version}}/{{.Server.Os}}/{{.Server.Arch}}'], input.signal, socket);
|
|
631
|
+
const architecture = process.arch === 'arm64' ? 'arm64' : 'amd64';
|
|
632
|
+
if (version.code !== 0 || version.stdout.trim() !== `29.4.1/linux/${architecture}`) {
|
|
633
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'portable repository requires approved matching Linux Docker runtime');
|
|
634
|
+
}
|
|
635
|
+
const daemon = await dockerControl(dockerPath, ['info', '--format', '{{.ID}}'], input.signal, socket);
|
|
636
|
+
if (daemon.code !== 0 || daemon.stdout.trim() !== input.config.daemonId) {
|
|
637
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'portable repository Docker daemon identity differs');
|
|
638
|
+
}
|
|
639
|
+
const image = await dockerControl(dockerPath, ['image', 'inspect', '--format', '{{.Id}}/{{.Os}}/{{.Architecture}}', input.config.image], input.signal, socket);
|
|
640
|
+
if (image.code !== 0 || image.stdout.trim() !== `${input.config.image}/linux/${architecture}`) {
|
|
641
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'portable repository image ID or platform differs');
|
|
642
|
+
}
|
|
643
|
+
await input.assertCurrent();
|
|
644
|
+
}
|
|
645
|
+
let creationSnapshot;
|
|
646
|
+
let baseLockDigest;
|
|
647
|
+
const verifyCreation = async () => {
|
|
648
|
+
if (input.creation === undefined && input.revision === undefined)
|
|
649
|
+
return;
|
|
650
|
+
if (input.creation !== undefined && input.revision !== undefined)
|
|
651
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source build has conflicting preparation authorities');
|
|
652
|
+
if ((input.creation?.grant ?? input.revision.binding.grant).expiresAt <= Date.now())
|
|
653
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation grant expired before build');
|
|
654
|
+
const verified = input.revision === undefined ? await verifyCreatedPluginWorkspace({ worktree: input.worktree, baseCommit: input.baseCommit,
|
|
655
|
+
name: input.name, environment: input.environment, signal: input.signal, assertCurrent: input.assertCurrent,
|
|
656
|
+
creation: input.creation }) : await verifyRevisedPluginWorkspace({ worktree: input.worktree, baseCommit: input.baseCommit,
|
|
657
|
+
name: input.name, environment: input.environment, signal: input.signal, assertCurrent: input.assertCurrent,
|
|
658
|
+
revision: input.revision.binding, parentSource: input.revision.parentSource, parentCertificate: input.revision.parentCertificate, files: input.revision.files });
|
|
659
|
+
const expected = [...input.scope].sort();
|
|
660
|
+
const actual = [...verified.scope].sort();
|
|
661
|
+
if (expected.length !== actual.length || expected.some((path, index) => path !== actual[index])) {
|
|
662
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation build scope differs from verified scope');
|
|
663
|
+
}
|
|
664
|
+
await input.assertCurrent();
|
|
665
|
+
if ((input.creation?.grant ?? input.revision.binding.grant).expiresAt <= Date.now())
|
|
666
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation grant expired before build');
|
|
667
|
+
};
|
|
668
|
+
if (input.creation !== undefined || input.revision !== undefined) {
|
|
669
|
+
await verifyCreation();
|
|
670
|
+
creationSnapshot = await checkedSourceSnapshot(input.worktree, input.baseCommit, input.scope, input.environment, undefined, input.signal);
|
|
671
|
+
const baseLock = await runLocalBuffer('git', ['show', `${input.baseCommit}:pnpm-lock.yaml`], input.worktree, input.environment, { maximumOutput: 8_388_608, signal: input.signal });
|
|
672
|
+
baseLockDigest = createHash('sha256').update(baseLock).digest('hex');
|
|
673
|
+
}
|
|
122
674
|
const snapshot = await sourceTree(input.worktree, input.baseCommit, input.scope, input.environment);
|
|
123
675
|
let before;
|
|
124
676
|
let packageVersion;
|
|
@@ -126,6 +678,16 @@ export async function runDockerPreparedChecks(input) {
|
|
|
126
678
|
try {
|
|
127
679
|
seccomp = await snapshotRepositorySeccomp(input.config, snapshot.temporary);
|
|
128
680
|
before = await checkedSourceSnapshot(input.worktree, input.baseCommit, input.scope, input.environment, snapshot.tree);
|
|
681
|
+
if (creationSnapshot !== undefined) {
|
|
682
|
+
if (before.checkedTreeDigest !== creationSnapshot.checkedTreeDigest || before.checkedPatchDigest !== creationSnapshot.checkedPatchDigest) {
|
|
683
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation staged tree differs from verified workspace');
|
|
684
|
+
}
|
|
685
|
+
await verifyCreation();
|
|
686
|
+
const current = await checkedSourceSnapshot(input.worktree, input.baseCommit, input.scope, input.environment, undefined, input.signal);
|
|
687
|
+
if (current.checkedTreeDigest !== creationSnapshot.checkedTreeDigest || current.checkedPatchDigest !== creationSnapshot.checkedPatchDigest) {
|
|
688
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation changed after verification');
|
|
689
|
+
}
|
|
690
|
+
}
|
|
129
691
|
const packageJson = await runLocalCommand('git', ['show', `${snapshot.tree}:plugins/${input.name}/package.json`], input.worktree, input.environment, { capture: true, maximumOutput: 65_536 });
|
|
130
692
|
const value = JSON.parse(packageJson)?.version;
|
|
131
693
|
if (typeof value !== 'string' || !/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(value)) {
|
|
@@ -142,7 +704,7 @@ export async function runDockerPreparedChecks(input) {
|
|
|
142
704
|
const container = input.sourceJob?.containerName ?? `dsh-source-prepare-${randomUUID()}`;
|
|
143
705
|
if (input.sourceJob !== undefined) {
|
|
144
706
|
try {
|
|
145
|
-
const existing = await dockerControl(dockerPath, ['container', 'ls', '--all', '--quiet', '--filter', `name=^/${container}$`], input.signal);
|
|
707
|
+
const existing = await dockerControl(dockerPath, ['container', 'ls', '--all', '--quiet', '--filter', `name=^/${container}$`], input.signal, socket);
|
|
146
708
|
if (existing.code !== 0 || existing.stdout.trim() !== '')
|
|
147
709
|
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'durable source container already exists or cannot be inspected');
|
|
148
710
|
}
|
|
@@ -151,26 +713,35 @@ export async function runDockerPreparedChecks(input) {
|
|
|
151
713
|
throw error;
|
|
152
714
|
}
|
|
153
715
|
}
|
|
716
|
+
if ((input.creation !== undefined || input.revision !== undefined) && (input.creation?.grant ?? input.revision.binding.grant).expiresAt <= Date.now()) {
|
|
717
|
+
await snapshot.cleanup();
|
|
718
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation grant expired before build');
|
|
719
|
+
}
|
|
154
720
|
const script = PREPARED_SOURCE_BUILD_SCRIPT;
|
|
155
721
|
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
722
|
'--security-opt', 'no-new-privileges', '--user', '65534:65534', '--pids-limit', String(input.config.pidsLimit),
|
|
157
723
|
'--memory', `${input.config.memoryMiB}m`, '--memory-swap', `${input.config.memoryMiB}m`, '--cpus', String(input.config.cpus),
|
|
158
|
-
'--tmpfs', `/workspace:rw,nosuid,nodev,mode=1777,size=${input.config.workspaceMiB}m${limits.profile
|
|
724
|
+
'--tmpfs', `/workspace:rw,nosuid,nodev,mode=1777,size=${input.config.workspaceMiB}m${limits.profile !== 'standard' ? ',exec' : ''}`, '--tmpfs', `/tmp:rw,nosuid,nodev,mode=1777,size=${limits.temporaryMiB}m${limits.profile !== 'standard' ? ',exec' : ''}`,
|
|
159
725
|
'--workdir', '/workspace', '--env', `PLUGIN_ROOT=plugins/${input.name}`, '--env', 'HOME=/tmp', '--env', 'PATH=/usr/local/bin:/usr/bin:/bin',
|
|
160
|
-
...(
|
|
726
|
+
...(input.capturePack ? ['--env', 'DSH_SOURCE_CAPTURE_PACK=true'] : []),
|
|
727
|
+
...(baseLockDigest === undefined ? [] : ['--env', 'DSH_SOURCE_TRUST_LOCKFILE=true', '--env', `DSH_SOURCE_BASE_LOCK_SHA256=${baseLockDigest}`]),
|
|
728
|
+
...(limits.profile !== 'standard' ? ['--env', 'CI=true', '--env', `VITEST_MAX_WORKERS=${Math.max(1, Math.min(4, Math.floor(input.config.cpus)))}`] : []),
|
|
161
729
|
// Docker's masked proc submounts prevent procfs mounts in a child user
|
|
162
730
|
// namespace. This opt-in removes those system-path masks; an empty /sys
|
|
163
731
|
// compensates for sysfs exposure, but does not restore the /proc masks.
|
|
164
732
|
...(seccomp === undefined ? [] : ['--security-opt', `seccomp=${seccomp.path}`,
|
|
165
733
|
'--security-opt', 'systempaths=unconfined', '--tmpfs', '/sys:ro,nosuid,nodev,noexec,size=1m']),
|
|
166
734
|
'--entrypoint', '/bin/sh', input.config.image, '-ceu', script];
|
|
735
|
+
const dockerConfig = socket === undefined ? undefined : await mkdtemp(join(tmpdir(), 'dsh-source-docker-config-'));
|
|
167
736
|
const git = spawn('/usr/bin/git', ['archive', '--format=tar', snapshot.tree], { cwd: input.worktree, env: input.environment, stdio: ['ignore', 'pipe', 'pipe'], shell: false });
|
|
168
|
-
const docker = spawn(dockerPath, args, { stdio: ['pipe', 'pipe', 'pipe'], shell: false,
|
|
737
|
+
const docker = spawn(dockerPath, portableArgs(socket, dockerConfig, args), { stdio: ['pipe', 'pipe', 'pipe'], shell: false,
|
|
738
|
+
env: socket === undefined ? { PATH: '/usr/bin:/bin' } : { PATH: '/usr/bin:/bin', HOME: dockerConfig, DOCKER_CONFIG: dockerConfig } });
|
|
169
739
|
git.stderr.resume();
|
|
170
740
|
docker.stdin.on('error', () => { });
|
|
171
741
|
git.stdout.pipe(docker.stdin);
|
|
172
742
|
const dockerDone = new Promise((resolvePromise, reject) => { docker.once('error', reject); docker.once('close', resolvePromise); });
|
|
173
743
|
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}`))); });
|
|
744
|
+
const stdoutLimit = input.config.outputBytes + (input.capturePack ? packFrame.length + maximumPackBase64Bytes + 1 : 0);
|
|
174
745
|
let stdout = '';
|
|
175
746
|
let stdoutBytes = 0;
|
|
176
747
|
let stderr = '';
|
|
@@ -181,11 +752,11 @@ export async function runDockerPreparedChecks(input) {
|
|
|
181
752
|
stderr = (stderr + chunk.toString('utf8')).slice(-4096);
|
|
182
753
|
return;
|
|
183
754
|
}
|
|
184
|
-
const remaining =
|
|
755
|
+
const remaining = stdoutLimit - stdoutBytes;
|
|
185
756
|
if (remaining > 0)
|
|
186
757
|
stdout += chunk.subarray(0, remaining).toString('utf8');
|
|
187
758
|
stdoutBytes += chunk.length;
|
|
188
|
-
if (stdoutBytes >
|
|
759
|
+
if (stdoutBytes > stdoutLimit) {
|
|
189
760
|
overflow = true;
|
|
190
761
|
stop();
|
|
191
762
|
}
|
|
@@ -209,10 +780,28 @@ export async function runDockerPreparedChecks(input) {
|
|
|
209
780
|
if (code !== 0)
|
|
210
781
|
throw new ControlPlaneCliError('EXECUTOR_FAILED', `isolated source build failed (${code}): ${stderr.trimEnd()}`);
|
|
211
782
|
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
783
|
if (match === null)
|
|
215
784
|
throw new ControlPlaneCliError('EXECUTOR_FAILED', 'isolated source build did not emit its pack evidence marker');
|
|
785
|
+
let packArtifact;
|
|
786
|
+
if (input.capturePack) {
|
|
787
|
+
const prefix = `${match[0]}\n${packFrame}`;
|
|
788
|
+
if (!stdout.startsWith(prefix) || !stdout.endsWith('\n'))
|
|
789
|
+
throw new ControlPlaneCliError('EXECUTOR_FAILED', 'isolated source build emitted an invalid pack capture frame');
|
|
790
|
+
const encoded = stdout.slice(prefix.length, -1);
|
|
791
|
+
const expectedSize = Number(match[2]);
|
|
792
|
+
if (!Number.isSafeInteger(expectedSize) || expectedSize < 1 || expectedSize > maximumPackBytes
|
|
793
|
+
|| encoded.length !== Math.ceil(expectedSize / 3) * 4 || !/^[A-Za-z0-9+/]+={0,2}$/u.test(encoded)) {
|
|
794
|
+
throw new ControlPlaneCliError('EXECUTOR_FAILED', 'isolated source build emitted an invalid pack capture frame');
|
|
795
|
+
}
|
|
796
|
+
packArtifact = Buffer.from(encoded, 'base64');
|
|
797
|
+
if (packArtifact.length !== expectedSize || packArtifact.toString('base64') !== encoded
|
|
798
|
+
|| createHash('sha256').update(packArtifact).digest('hex') !== match[3]) {
|
|
799
|
+
throw new ControlPlaneCliError('EXECUTOR_FAILED', 'isolated source build pack capture differs from verified evidence');
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
else if (stdout.trim() !== match[0]) {
|
|
803
|
+
throw new ControlPlaneCliError('EXECUTOR_FAILED', 'isolated source build emitted unexpected evidence output');
|
|
804
|
+
}
|
|
216
805
|
const checked = await checkedSourceSnapshot(input.worktree, input.baseCommit, input.scope, input.environment);
|
|
217
806
|
if (checked.checkedTreeDigest !== before.checkedTreeDigest || checked.checkedPatchDigest !== before.checkedPatchDigest) {
|
|
218
807
|
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'prepared source changed while its isolated build was running');
|
|
@@ -220,9 +809,10 @@ export async function runDockerPreparedChecks(input) {
|
|
|
220
809
|
const logDigest = createHash('sha256').update(stdout).update('\0').update(stderr).digest('hex');
|
|
221
810
|
const checkedAt = Date.now();
|
|
222
811
|
return { treeDigest: checked.checkedTreeDigest, patchDigest: checked.checkedPatchDigest, checkedAt,
|
|
812
|
+
...(packArtifact === undefined ? {} : { packArtifact }),
|
|
223
813
|
evidence: Object.freeze({ schemaVersion: 1, kind: 'dsh-source-prepared-evidence',
|
|
224
814
|
environment: Object.freeze({ npmConfigIgnoreScripts: true, frozenLockfile: true, offline: true, nodeVersion: match[4], pnpmVersion: match[5] }),
|
|
225
|
-
commands: Object.freeze([{ command: 'docker', args: Object.freeze(
|
|
815
|
+
commands: Object.freeze([{ command: 'docker', args: Object.freeze(portableArgs(socket, dockerConfig, args)), exitCode: 0, durationMs: Date.now() - started, logDigest }]),
|
|
226
816
|
pack: Object.freeze({ name: match[1], sizeBytes: Number(match[2]), sha256: match[3], version: packageVersion }), preparedAt: input.preparedAt }) };
|
|
227
817
|
}
|
|
228
818
|
finally {
|
|
@@ -234,12 +824,14 @@ export async function runDockerPreparedChecks(input) {
|
|
|
234
824
|
// Failed inspect is ambiguous (daemon loss also exits nonzero). Require
|
|
235
825
|
// a successful exact-name listing from the daemon to prove absence.
|
|
236
826
|
if (input.sourceJob === undefined)
|
|
237
|
-
await ensureContainerRemoved(dockerPath, container);
|
|
827
|
+
await ensureContainerRemoved(dockerPath, container, socket, input.config.daemonId);
|
|
238
828
|
else
|
|
239
829
|
await removeSourceJobContainer(input.config, input.sourceJob);
|
|
240
830
|
}
|
|
241
831
|
finally {
|
|
242
832
|
await snapshot.cleanup();
|
|
833
|
+
if (dockerConfig !== undefined)
|
|
834
|
+
await rm(dockerConfig, { recursive: true, force: true });
|
|
243
835
|
}
|
|
244
836
|
}
|
|
245
837
|
}
|
|
@@ -249,61 +841,82 @@ export async function removeSourceJobContainer(config, job) {
|
|
|
249
841
|
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'invalid durable container identity');
|
|
250
842
|
validateSourceBuildConfig(config);
|
|
251
843
|
const path = await verifiedDockerPath(config.dockerPath);
|
|
252
|
-
const
|
|
844
|
+
const socket = await verifiedPortableSocket(config);
|
|
845
|
+
if (socket !== undefined) {
|
|
846
|
+
const daemon = await dockerControl(path, ['info', '--format', '{{.ID}}'], undefined, socket);
|
|
847
|
+
if (daemon.code !== 0 || daemon.stdout.trim() !== config.daemonId) {
|
|
848
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'durable container Docker daemon identity differs');
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
const list = () => dockerControl(path, ['container', 'ls', '--all', '--quiet', '--filter', `name=^/${job.containerName}$`], undefined, socket);
|
|
253
852
|
const before = await list();
|
|
254
853
|
if (before.code !== 0)
|
|
255
854
|
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'durable container absence is unproven');
|
|
256
855
|
if (before.stdout.trim() !== '') {
|
|
257
|
-
const inspected = await dockerControl(path, ['inspect', '--format', '[{{json .Id}},{{json .Name}},{{json .Config.Image}},{{json (index .Config.Labels "dsh.source.job")}}]', job.containerName]);
|
|
856
|
+
const inspected = await dockerControl(path, ['inspect', '--format', '[{{json .Id}},{{json .Name}},{{json .Config.Image}},{{json (index .Config.Labels "dsh.source.job")}}]', job.containerName], undefined, socket);
|
|
258
857
|
if (inspected.code !== 0)
|
|
259
858
|
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'durable container identity is unproven');
|
|
260
859
|
const value = JSON.parse(inspected.stdout);
|
|
261
860
|
if (!Array.isArray(value) || value.length !== 4 || typeof value[0] !== 'string' || !/^[a-f0-9]{64}$/u.test(value[0])
|
|
262
861
|
|| value[1] !== `/${job.containerName}` || value[2] !== config.image || value[3] !== job.id)
|
|
263
862
|
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'durable container ownership mismatch');
|
|
264
|
-
await dockerControl(path, ['rm', '-f', value[0]]);
|
|
863
|
+
await dockerControl(path, ['rm', '-f', value[0]], undefined, socket);
|
|
265
864
|
}
|
|
266
865
|
const after = await list();
|
|
267
866
|
if (after.code !== 0 || after.stdout.trim() !== '')
|
|
268
867
|
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'durable container cleanup is unproven');
|
|
269
868
|
}
|
|
270
|
-
async function dockerControl(path, args, signal) {
|
|
869
|
+
async function dockerControl(path, args, signal, socket) {
|
|
271
870
|
signal?.throwIfAborted();
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
abort();
|
|
281
|
-
|
|
282
|
-
const cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', abort); };
|
|
283
|
-
child.stdout.on('data', (chunk) => {
|
|
284
|
-
bytes += chunk.length;
|
|
285
|
-
if (bytes > 4_096) {
|
|
286
|
-
failed = true;
|
|
287
|
-
child.kill('SIGKILL');
|
|
288
|
-
}
|
|
289
|
-
else
|
|
290
|
-
stdout += chunk.toString('utf8');
|
|
291
|
-
});
|
|
292
|
-
child.once('error', () => { cleanup(); reject(new ControlPlaneCliError('EXECUTOR_FAILED', 'source build control client could not start')); });
|
|
293
|
-
child.once('close', code => {
|
|
294
|
-
cleanup();
|
|
871
|
+
const configDirectory = socket === undefined ? undefined : await mkdtemp(join(tmpdir(), 'dsh-source-docker-config-'));
|
|
872
|
+
try {
|
|
873
|
+
return await new Promise((resolvePromise, reject) => {
|
|
874
|
+
const child = spawn(path, portableArgs(socket, configDirectory, args), { stdio: ['ignore', 'pipe', 'ignore'], shell: false,
|
|
875
|
+
env: socket === undefined ? { PATH: '/usr/bin:/bin' } : { PATH: '/usr/bin:/bin', HOME: configDirectory, DOCKER_CONFIG: configDirectory } });
|
|
876
|
+
let stdout = '';
|
|
877
|
+
let bytes = 0;
|
|
878
|
+
let failed = false;
|
|
879
|
+
const abort = () => { child.kill('SIGKILL'); };
|
|
880
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
295
881
|
if (signal?.aborted)
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
882
|
+
abort();
|
|
883
|
+
const timer = setTimeout(() => { failed = true; child.kill('SIGKILL'); }, 5_000);
|
|
884
|
+
const cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', abort); };
|
|
885
|
+
child.stdout.on('data', (chunk) => {
|
|
886
|
+
bytes += chunk.length;
|
|
887
|
+
if (bytes > 4_096) {
|
|
888
|
+
failed = true;
|
|
889
|
+
child.kill('SIGKILL');
|
|
890
|
+
}
|
|
891
|
+
else
|
|
892
|
+
stdout += chunk.toString('utf8');
|
|
893
|
+
});
|
|
894
|
+
child.once('error', () => { cleanup(); reject(new ControlPlaneCliError('EXECUTOR_FAILED', 'source build control client could not start')); });
|
|
895
|
+
child.once('close', code => {
|
|
896
|
+
cleanup();
|
|
897
|
+
if (signal?.aborted)
|
|
898
|
+
reject(new ControlPlaneCliError('EXECUTOR_TIMEOUT', 'source build control query was cancelled'));
|
|
899
|
+
else if (failed)
|
|
900
|
+
reject(new ControlPlaneCliError('EXECUTOR_FAILED', 'source build control query exceeded its resource bound'));
|
|
901
|
+
else
|
|
902
|
+
resolvePromise({ code, stdout });
|
|
903
|
+
});
|
|
301
904
|
});
|
|
302
|
-
}
|
|
905
|
+
}
|
|
906
|
+
finally {
|
|
907
|
+
if (configDirectory !== undefined)
|
|
908
|
+
await rm(configDirectory, { recursive: true, force: true });
|
|
909
|
+
}
|
|
303
910
|
}
|
|
304
|
-
async function ensureContainerRemoved(path, container) {
|
|
305
|
-
|
|
306
|
-
|
|
911
|
+
async function ensureContainerRemoved(path, container, socket, daemonId) {
|
|
912
|
+
if (socket !== undefined) {
|
|
913
|
+
const daemon = await dockerControl(path, ['info', '--format', '{{.ID}}'], undefined, socket);
|
|
914
|
+
if (daemon.code !== 0 || daemon.stdout.trim() !== daemonId) {
|
|
915
|
+
throw new ControlPlaneCliError('EXECUTOR_FAILED', 'isolated source build daemon changed before cleanup');
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
await dockerControl(path, ['rm', '-f', container], undefined, socket);
|
|
919
|
+
const remaining = await dockerControl(path, ['container', 'ls', '--all', '--quiet', '--filter', `name=^/${container}$`], undefined, socket);
|
|
307
920
|
if (remaining.code !== 0 || remaining.stdout.trim() !== '') {
|
|
308
921
|
throw new ControlPlaneCliError('EXECUTOR_FAILED', 'isolated source build container cleanup could not prove quiescence');
|
|
309
922
|
}
|