@dsh-enhanced/plugin-control-plane 0.1.7 → 0.1.12
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 +153 -32
- package/bin/dsh-local-release-adapter.js +1381 -0
- package/cordis.patch.yml +1 -0
- package/lib/approval.d.ts +13 -0
- package/lib/approval.d.ts.map +1 -0
- package/lib/approval.js +79 -0
- package/lib/approval.js.map +1 -0
- package/lib/attestation.d.ts +14 -0
- package/lib/attestation.d.ts.map +1 -0
- package/lib/attestation.js +222 -0
- package/lib/attestation.js.map +1 -0
- package/lib/catalog-interpreter.d.ts +12 -0
- package/lib/catalog-interpreter.d.ts.map +1 -0
- package/lib/catalog-interpreter.js +100 -0
- package/lib/catalog-interpreter.js.map +1 -0
- package/lib/catalog.d.ts +95 -10
- package/lib/catalog.d.ts.map +1 -1
- package/lib/catalog.js +1031 -18
- package/lib/catalog.js.map +1 -1
- package/lib/cli.d.ts +9 -0
- package/lib/cli.d.ts.map +1 -1
- package/lib/cli.js +1160 -162
- package/lib/cli.js.map +1 -1
- package/lib/host-attestor.d.ts +13 -0
- package/lib/host-attestor.d.ts.map +1 -0
- package/lib/host-attestor.js +139 -0
- package/lib/host-attestor.js.map +1 -0
- package/lib/index.d.ts +9 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +9 -0
- package/lib/index.js.map +1 -1
- package/lib/lockfile.d.ts +7 -0
- package/lib/lockfile.d.ts.map +1 -0
- package/lib/lockfile.js +271 -0
- package/lib/lockfile.js.map +1 -0
- package/lib/release.d.ts +51 -0
- package/lib/release.d.ts.map +1 -0
- package/lib/release.js +1188 -0
- package/lib/release.js.map +1 -0
- package/lib/service.d.ts +9 -11
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +57 -29
- package/lib/service.js.map +1 -1
- package/lib/sqlite.d.ts +9 -0
- package/lib/sqlite.d.ts.map +1 -0
- package/lib/sqlite.js +705 -0
- package/lib/sqlite.js.map +1 -0
- package/lib/store.d.ts +258 -0
- package/lib/store.d.ts.map +1 -0
- package/lib/store.js +1848 -0
- package/lib/store.js.map +1 -0
- package/lib/tools.d.ts.map +1 -1
- package/lib/tools.js +23 -3
- package/lib/tools.js.map +1 -1
- package/lib/trust.d.ts +79 -0
- package/lib/trust.d.ts.map +1 -0
- package/lib/trust.js +477 -0
- package/lib/trust.js.map +1 -0
- package/lib/types.d.ts +740 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -0
- package/lib/version.d.ts +1 -1
- package/lib/version.d.ts.map +1 -1
- package/lib/version.js +1 -1
- package/lib/version.js.map +1 -1
- package/package.json +2 -2
package/lib/cli.js
CHANGED
|
@@ -1,15 +1,35 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { constants as fsConstants, lstatSync } from 'node:fs';
|
|
4
|
+
import { cp, lstat, mkdir, mkdtemp, open, readFile, realpath, rename, rm, stat } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
|
+
import { Ed25519ApprovalAuthority, loadPrivateApprovalInput, parseApprovalReceipt } from './approval.js';
|
|
9
|
+
import { Ed25519HostAttestationAuthority, parseHostAttestationReceipt } from './attestation.js';
|
|
10
|
+
import { invokeConfiguredHostAttestor, prepareConfiguredHostAttestation, prepareManualHostAttestation } from './host-attestor.js';
|
|
11
|
+
import { discover, loadCatalogWithMetadata, previewCatalogAdmission } from './catalog.js';
|
|
12
|
+
import { verifyApprovedPackagesInLockfile } from './lockfile.js';
|
|
13
|
+
import { Ed25519SourcePublishReconciliationAuthority, Ed25519SourceReleaseAuthority, Ed25519SourceReleaseAuthorizationAuthority, invokeSourcePublishReconciliationAdapter, invokeSourceReleaseAdapter, parseSourcePublishReconciliationReceipt, parseSourceReleaseAuthorization, parseSourceReleaseReceipt } from './release.js';
|
|
14
|
+
import { ControlPlaneStore, expectedSourceRelease } from './store.js';
|
|
15
|
+
import { inheritedEnvironment, loadTrustConfig, openTrustedExecutable, resolveTrustKey, verifyOpenTrustedExecutable } from './trust.js';
|
|
16
|
+
const pluginPattern = /^(?=.{1,64}$)[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
|
17
|
+
const leaseMs = 30_000;
|
|
18
|
+
const pluginCatalogScope = 'plugins/README.md';
|
|
19
|
+
const maximumActivationArtifactBytes = 268_435_456;
|
|
20
|
+
export class ControlPlaneCliError extends Error {
|
|
21
|
+
code;
|
|
22
|
+
constructor(code, message) {
|
|
23
|
+
super(`plugin-control-plane[${code}]: ${message}`);
|
|
24
|
+
this.code = code;
|
|
25
|
+
this.name = 'ControlPlaneCliError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
8
28
|
function option(argv, name) {
|
|
9
29
|
const index = argv.indexOf(name);
|
|
10
30
|
const value = index === -1 ? undefined : argv[index + 1];
|
|
11
31
|
if (value === undefined || value.startsWith('--'))
|
|
12
|
-
throw new
|
|
32
|
+
throw new ControlPlaneCliError('INVALID_ARGUMENT', `${name} is required`);
|
|
13
33
|
return value;
|
|
14
34
|
}
|
|
15
35
|
function optionalOption(argv, name) {
|
|
@@ -18,196 +38,1174 @@ function optionalOption(argv, name) {
|
|
|
18
38
|
return undefined;
|
|
19
39
|
const value = argv[index + 1];
|
|
20
40
|
if (value === undefined || value.startsWith('--'))
|
|
21
|
-
throw new
|
|
41
|
+
throw new ControlPlaneCliError('INVALID_ARGUMENT', `${name} requires a value`);
|
|
22
42
|
return value;
|
|
23
43
|
}
|
|
24
|
-
function
|
|
25
|
-
const
|
|
26
|
-
if (!
|
|
27
|
-
throw new
|
|
28
|
-
return
|
|
29
|
-
}
|
|
30
|
-
function exactPackages(candidate) {
|
|
31
|
-
return [
|
|
32
|
-
{ package: candidate.package, version: candidate.version, integrity: candidate.integrity },
|
|
33
|
-
...candidate.requires,
|
|
34
|
-
];
|
|
35
|
-
}
|
|
36
|
-
function validApproval(value) {
|
|
37
|
-
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
38
|
-
return false;
|
|
39
|
-
const approval = value;
|
|
40
|
-
return typeof approval.principal === 'string' && approval.principal.normalize('NFC').trim() !== ''
|
|
41
|
-
&& approval.principal.length <= 256 && Number.isSafeInteger(approval.approvedAt);
|
|
42
|
-
}
|
|
43
|
-
async function atomicJsonWrite(path, value) {
|
|
44
|
-
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
45
|
-
const temporary = `${path}.tmp-${process.pid}-${Math.random().toString(16).slice(2)}`;
|
|
46
|
-
try {
|
|
47
|
-
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
48
|
-
await rename(temporary, path);
|
|
49
|
-
}
|
|
50
|
-
finally {
|
|
51
|
-
await unlink(temporary).catch(() => undefined);
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
function run(command, args, cwd, env) {
|
|
55
|
-
const outcome = spawnSync(command, [...args], { cwd, env, encoding: 'utf8', maxBuffer: 1_048_576 });
|
|
56
|
-
if (outcome.status !== 0)
|
|
57
|
-
throw new Error(`plugin-control-plane: ${command} failed: ${(outcome.stderr || outcome.stdout || '').trim().slice(0, 1_000)}`);
|
|
58
|
-
return outcome.stdout;
|
|
59
|
-
}
|
|
60
|
-
async function planFromFile(path) {
|
|
61
|
-
const value = JSON.parse(await readFile(path, 'utf8'));
|
|
62
|
-
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
63
|
-
throw new Error('plugin-control-plane: invalid plan');
|
|
64
|
-
const plan = value;
|
|
65
|
-
const createdAt = plan.createdAt;
|
|
66
|
-
const expiresAt = plan.expiresAt;
|
|
67
|
-
if (plan.schemaVersion !== 1 || typeof plan.id !== 'string' || typeof plan.profile !== 'string'
|
|
68
|
-
|| typeof plan.digest !== 'string' || typeof createdAt !== 'number' || typeof expiresAt !== 'number'
|
|
69
|
-
|| !Number.isSafeInteger(createdAt) || !Number.isSafeInteger(expiresAt)) {
|
|
70
|
-
throw new Error('plugin-control-plane: invalid plan');
|
|
71
|
-
}
|
|
72
|
-
if (!planIdPattern.test(plan.id) || !profilePattern.test(plan.profile)
|
|
73
|
-
|| !/^[a-f0-9]{64}$/u.test(plan.digest)
|
|
74
|
-
|| expiresAt <= createdAt || expiresAt - createdAt > maxPlanLifetimeMs) {
|
|
75
|
-
throw new Error('plugin-control-plane: invalid plan');
|
|
76
|
-
}
|
|
77
|
-
const candidate = parseCatalog({ schemaVersion: 1, entries: [plan.candidate] }).entries[0];
|
|
78
|
-
if (candidate === undefined || candidateDigest(candidate, plan.profile) !== plan.digest)
|
|
79
|
-
throw new Error('plugin-control-plane: plan digest mismatch');
|
|
80
|
-
if (Date.now() > expiresAt)
|
|
81
|
-
throw new Error('plugin-control-plane: plan has expired');
|
|
82
|
-
if (plan.status !== 'pending-owner-approval' && plan.status !== 'owner-approved')
|
|
83
|
-
throw new Error('plugin-control-plane: invalid plan status');
|
|
84
|
-
if (plan.status === 'owner-approved' && !validApproval(plan.approval))
|
|
85
|
-
throw new Error('plugin-control-plane: invalid plan approval');
|
|
86
|
-
return {
|
|
87
|
-
schemaVersion: 1,
|
|
88
|
-
id: plan.id,
|
|
89
|
-
status: plan.status,
|
|
90
|
-
createdAt,
|
|
91
|
-
expiresAt,
|
|
92
|
-
profile: plan.profile,
|
|
93
|
-
candidate,
|
|
94
|
-
digest: plan.digest,
|
|
95
|
-
...(plan.approval === undefined ? {} : { approval: plan.approval }),
|
|
96
|
-
};
|
|
44
|
+
function integerOption(argv, name) {
|
|
45
|
+
const result = Number(option(argv, name));
|
|
46
|
+
if (!Number.isSafeInteger(result) || result < 1)
|
|
47
|
+
throw new ControlPlaneCliError('INVALID_ARGUMENT', `${name} must be a positive integer`);
|
|
48
|
+
return result;
|
|
97
49
|
}
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const plan = await planFromFile(path);
|
|
104
|
-
if (plan.status !== 'pending-owner-approval')
|
|
105
|
-
throw new Error('plugin-control-plane: plan is not pending approval');
|
|
106
|
-
const approved = { ...plan, status: 'owner-approved', approval: { principal, approvedAt: Date.now() } };
|
|
107
|
-
await atomicJsonWrite(path, approved);
|
|
108
|
-
process.stdout.write(`Approved ${approved.id}; activation remains a separate command.\n`);
|
|
50
|
+
function defaultDshHome() {
|
|
51
|
+
const value = process.env.DSH_HOME ?? join(process.env.HOME ?? '', '.dsh');
|
|
52
|
+
if (!isAbsolute(value))
|
|
53
|
+
throw new ControlPlaneCliError('INVALID_ARGUMENT', 'DSH_HOME must be absolute');
|
|
54
|
+
return resolve(value);
|
|
109
55
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (
|
|
116
|
-
throw new
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
56
|
+
function defaultCatalogPath() { return join(defaultDshHome(), 'plugin-control', 'catalog.json'); }
|
|
57
|
+
function defaultTrustPath(dshHome = defaultDshHome()) { return join(dshHome, 'plugin-control', 'trust.json'); }
|
|
58
|
+
function rejectCommandSuppliedTrust(argv) {
|
|
59
|
+
const forbidden = ['--trust', '--approval-public-key', '--authority', '--key-id', '--state', '--dsh-home', '--attestor',
|
|
60
|
+
'--attestor-path', '--credential', '--password', '--private-key', '--registry-token', '--signing-key', '--token'];
|
|
61
|
+
if (forbidden.some(name => argv.includes(name))) {
|
|
62
|
+
throw new ControlPlaneCliError('INVALID_ARGUMENT', 'command-supplied trust roots or ledgers are forbidden');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async function commandTrust(argv) {
|
|
66
|
+
rejectCommandSuppliedTrust(argv);
|
|
67
|
+
return loadTrustConfig(defaultTrustPath());
|
|
68
|
+
}
|
|
69
|
+
function exactPackages(plan) { return [...plan.dossier.packages]; }
|
|
70
|
+
function localArtifactReference(item) {
|
|
71
|
+
if (item.registry === undefined)
|
|
72
|
+
return undefined;
|
|
123
73
|
try {
|
|
74
|
+
return new URL(item.registry.reference).protocol === 'file:' ? item.registry.reference : undefined;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async function assertDirectory(path, allowMissing = false) {
|
|
81
|
+
try {
|
|
82
|
+
const value = await lstat(path);
|
|
83
|
+
if (!value.isDirectory() || value.isSymbolicLink() || await realpath(path) !== resolve(path))
|
|
84
|
+
throw new ControlPlaneCliError('FILESYSTEM_STATE', 'activation directory is not canonical');
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (allowMissing && typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT')
|
|
88
|
+
return;
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async function directoryExists(path) {
|
|
93
|
+
try {
|
|
94
|
+
await assertDirectory(path);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT')
|
|
99
|
+
return false;
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async function readSafeFile(path, maximum) {
|
|
104
|
+
const value = await lstat(path);
|
|
105
|
+
const uid = process.getuid?.();
|
|
106
|
+
if (!value.isFile() || value.isSymbolicLink() || value.nlink !== 1 || value.size > maximum
|
|
107
|
+
|| (uid !== undefined && value.uid !== uid && value.uid !== 0) || await realpath(path) !== resolve(path)) {
|
|
108
|
+
throw new ControlPlaneCliError('FILESYSTEM_STATE', 'input must be one bounded trusted regular file');
|
|
109
|
+
}
|
|
110
|
+
return readFile(path, 'utf8');
|
|
111
|
+
}
|
|
112
|
+
async function readOwnerPrivateFile(path, maximum) {
|
|
113
|
+
const value = await lstat(path);
|
|
114
|
+
const uid = process.getuid?.();
|
|
115
|
+
if (!value.isFile() || value.isSymbolicLink() || value.nlink !== 1 || (value.mode & 0o077) !== 0
|
|
116
|
+
|| (uid !== undefined && value.uid !== uid) || await realpath(path) !== resolve(path)) {
|
|
117
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'release input must be one canonical owner-private regular file');
|
|
118
|
+
}
|
|
119
|
+
const source = await readFile(path, 'utf8');
|
|
120
|
+
if (Buffer.byteLength(source) > maximum)
|
|
121
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'release input exceeds its size bound');
|
|
122
|
+
return source;
|
|
123
|
+
}
|
|
124
|
+
function processIsAlive(pid) {
|
|
125
|
+
if (!Number.isSafeInteger(pid) || pid < 1)
|
|
126
|
+
return true;
|
|
127
|
+
try {
|
|
128
|
+
process.kill(pid, 0);
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
return !(typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH');
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
async function acquireProfileLock(store, plan) {
|
|
136
|
+
if (plan.activation === undefined)
|
|
137
|
+
throw new ControlPlaneCliError('LOCK_CONFLICT', 'activation has no fence');
|
|
138
|
+
const lockPath = join(plan.target.dshHome, 'profiles', `.plugin-control-${plan.target.profile}.lock`);
|
|
139
|
+
const payload = `${JSON.stringify({ schemaVersion: 1, planId: plan.id, activationId: plan.activation.id,
|
|
140
|
+
fence: plan.activation.fence, pid: process.pid, nonce: randomUUID() })}\n`;
|
|
141
|
+
const create = async () => {
|
|
142
|
+
const handle = await open(lockPath, 'wx', 0o600);
|
|
124
143
|
try {
|
|
125
|
-
await
|
|
126
|
-
await
|
|
144
|
+
await handle.writeFile(payload, 'utf8');
|
|
145
|
+
await handle.sync();
|
|
146
|
+
const metadata = await handle.stat({ bigint: true });
|
|
147
|
+
return { path: lockPath, handle, payload, device: metadata.dev, inode: metadata.ino };
|
|
127
148
|
}
|
|
128
149
|
catch (error) {
|
|
129
|
-
|
|
130
|
-
|
|
150
|
+
await handle.close();
|
|
151
|
+
await rm(lockPath, { force: true });
|
|
152
|
+
throw error;
|
|
131
153
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
154
|
+
};
|
|
155
|
+
return store.withActivationFileSystemGuard({ planId: plan.id, expectedRevision: plan.revision,
|
|
156
|
+
fence: plan.activation.fence, status: plan.status, leaseMs }, async () => {
|
|
157
|
+
try {
|
|
158
|
+
return await create();
|
|
136
159
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
160
|
+
catch (error) {
|
|
161
|
+
if (!(typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST'))
|
|
162
|
+
throw error;
|
|
163
|
+
const metadata = await lstat(lockPath, { bigint: true });
|
|
164
|
+
const uid = process.getuid?.();
|
|
165
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.nlink !== 1n || (metadata.mode & 63n) !== 0n
|
|
166
|
+
|| (uid !== undefined && metadata.uid !== BigInt(uid)))
|
|
167
|
+
throw new ControlPlaneCliError('LOCK_CONFLICT', 'profile lock is unsafe');
|
|
168
|
+
let old;
|
|
169
|
+
try {
|
|
170
|
+
old = JSON.parse(await readFile(lockPath, 'utf8'));
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
throw new ControlPlaneCliError('LOCK_CONFLICT', 'profile lock is corrupt');
|
|
174
|
+
}
|
|
175
|
+
const pid = typeof old === 'object' && old !== null && 'pid' in old ? Number(old.pid) : Number.NaN;
|
|
176
|
+
if (!Number.isSafeInteger(pid) || processIsAlive(pid))
|
|
177
|
+
throw new ControlPlaneCliError('LOCK_CONFLICT', 'profile lock owner may still be executing');
|
|
178
|
+
const stale = `${lockPath}.stale-${randomUUID()}`;
|
|
179
|
+
await rename(lockPath, stale);
|
|
180
|
+
const moved = await lstat(stale, { bigint: true });
|
|
181
|
+
if (moved.dev !== metadata.dev || moved.ino !== metadata.ino)
|
|
182
|
+
throw new ControlPlaneCliError('LOCK_CONFLICT', 'profile lock identity changed during stale recovery');
|
|
183
|
+
try {
|
|
184
|
+
return await create();
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
await rm(stale, { force: true });
|
|
188
|
+
}
|
|
142
189
|
}
|
|
143
|
-
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
async function releaseProfileLock(store, lock) {
|
|
193
|
+
try {
|
|
194
|
+
await store.withExclusiveWrite(async () => {
|
|
195
|
+
const current = await lstat(lock.path, { bigint: true });
|
|
196
|
+
const held = await lock.handle.stat({ bigint: true });
|
|
197
|
+
if (current.dev !== held.dev || current.ino !== held.ino || current.dev !== lock.device || current.ino !== lock.inode
|
|
198
|
+
|| current.nlink !== 1n || await readFile(lock.path, 'utf8') !== lock.payload) {
|
|
199
|
+
throw new ControlPlaneCliError('LOCK_CONFLICT', 'profile lock identity changed before release');
|
|
200
|
+
}
|
|
201
|
+
await rm(lock.path);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
await lock.handle.close();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function assertPlanTrust(plan, trust) {
|
|
209
|
+
if (plan.installationId !== trust.installationId || plan.target.dshHome !== trust.dshHome
|
|
210
|
+
|| plan.executor.id !== trust.executor.id || plan.executor.version !== trust.executor.version
|
|
211
|
+
|| plan.executor.path !== trust.executor.path || plan.executor.sha256 !== trust.executor.sha256
|
|
212
|
+
|| plan.ledger.id !== trust.ledger.id || plan.ledger.path !== trust.ledger.path
|
|
213
|
+
|| plan.target.profilePath !== join(trust.dshHome, 'profiles', plan.profile)) {
|
|
214
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'plan does not match the registered installation, ledger, target, and executor');
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
async function openCurrentTrustedExecutable(path, trustedRunningNode) {
|
|
218
|
+
const canonical = await realpath(path);
|
|
219
|
+
const metadata = await lstat(canonical);
|
|
220
|
+
if (!metadata.isFile() || metadata.size > maximumActivationArtifactBytes) {
|
|
221
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'registered executor interpreter is not a bounded regular file');
|
|
222
|
+
}
|
|
223
|
+
if (!trustedRunningNode && metadata.uid !== 0) {
|
|
224
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'registered executor interpreter must be root-owned');
|
|
225
|
+
}
|
|
226
|
+
const expectedSha256 = createHash('sha256').update(await readFile(canonical)).digest('hex');
|
|
227
|
+
const executable = await openTrustedExecutable(canonical, expectedSha256);
|
|
228
|
+
if (trustedRunningNode) {
|
|
229
|
+
const running = await stat('/proc/self/exe', { bigint: true });
|
|
230
|
+
if (executable.snapshot.device !== running.dev || executable.snapshot.inode !== running.ino) {
|
|
231
|
+
await executable.handle.close();
|
|
232
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'registered executor Node interpreter is not the running trusted runtime');
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return executable;
|
|
236
|
+
}
|
|
237
|
+
async function verifyPinnedDescriptor(value) {
|
|
238
|
+
const metadata = await value.handle.stat({ bigint: true });
|
|
239
|
+
if (!metadata.isFile() || metadata.nlink > 1n || metadata.dev !== value.snapshot.device || metadata.ino !== value.snapshot.inode
|
|
240
|
+
|| metadata.size > BigInt(maximumActivationArtifactBytes) || (metadata.mode & 73n) === 0n || (metadata.mode & 18n) !== 0n) {
|
|
241
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'registered executable descriptor changed while a command was running');
|
|
242
|
+
}
|
|
243
|
+
const bytes = Buffer.alloc(Number(metadata.size));
|
|
244
|
+
let offset = 0;
|
|
245
|
+
while (offset < bytes.length) {
|
|
246
|
+
const result = await value.handle.read(bytes, offset, bytes.length - offset, offset);
|
|
247
|
+
if (result.bytesRead === 0)
|
|
248
|
+
break;
|
|
249
|
+
offset += result.bytesRead;
|
|
250
|
+
}
|
|
251
|
+
if (offset !== bytes.length || createHash('sha256').update(bytes).digest('hex') !== value.snapshot.sha256) {
|
|
252
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'registered executable descriptor bytes changed while a command was running');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
async function executorInterpreter(executable) {
|
|
256
|
+
const prefix = Buffer.alloc(512);
|
|
257
|
+
const { bytesRead } = await executable.handle.read(prefix, 0, prefix.length, 0);
|
|
258
|
+
const line = prefix.subarray(0, bytesRead).toString('utf8').split('\n', 1)[0] ?? '';
|
|
259
|
+
if (!line.startsWith('#!'))
|
|
260
|
+
return undefined;
|
|
261
|
+
const declaration = line.slice(2).trim();
|
|
262
|
+
const separator = declaration.search(/\s/u);
|
|
263
|
+
let path = separator === -1 ? declaration : declaration.slice(0, separator);
|
|
264
|
+
let argument = separator === -1 ? undefined : declaration.slice(separator).trim();
|
|
265
|
+
if (!isAbsolute(path) || path.includes('\0') || argument?.includes('\0')) {
|
|
266
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'registered executor has an unsupported interpreter declaration');
|
|
267
|
+
}
|
|
268
|
+
if (path === '/usr/bin/env') {
|
|
269
|
+
if (argument === 'node')
|
|
270
|
+
path = process.execPath;
|
|
271
|
+
else if (argument === 'bash')
|
|
272
|
+
path = '/usr/bin/bash';
|
|
273
|
+
else
|
|
274
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'registered executor env interpreter is unsupported');
|
|
275
|
+
argument = undefined;
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
path = await realpath(path);
|
|
279
|
+
}
|
|
280
|
+
const interpreter = await openCurrentTrustedExecutable(path, path === process.execPath);
|
|
281
|
+
const interpreterPrefix = Buffer.alloc(2);
|
|
282
|
+
const current = await interpreter.handle.read(interpreterPrefix, 0, 2, 0);
|
|
283
|
+
if (current.bytesRead === 2 && interpreterPrefix.toString('utf8') === '#!') {
|
|
284
|
+
await interpreter.handle.close();
|
|
285
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'registered executor interpreter must be a native executable');
|
|
286
|
+
}
|
|
287
|
+
return { executable: interpreter, arguments: argument === undefined ? [] : [argument] };
|
|
288
|
+
}
|
|
289
|
+
async function runBounded(input) {
|
|
290
|
+
const execute = async () => {
|
|
291
|
+
const ownedExecutable = input.plan === undefined || input.pinnedExecutable !== undefined ? undefined
|
|
292
|
+
: await openTrustedExecutable(input.executable, input.plan.executor.sha256);
|
|
293
|
+
const executable = input.pinnedExecutable ?? ownedExecutable;
|
|
294
|
+
let interpreter = input.pinnedInterpreter;
|
|
295
|
+
let result = '';
|
|
296
|
+
let failure;
|
|
144
297
|
try {
|
|
145
|
-
|
|
298
|
+
if (executable !== undefined && interpreter === undefined)
|
|
299
|
+
interpreter = await executorInterpreter(executable);
|
|
300
|
+
result = await new Promise((resolvePromise, reject) => {
|
|
301
|
+
let command = input.executable;
|
|
302
|
+
let stdio = ['ignore', 'pipe', 'ignore'];
|
|
303
|
+
if (executable !== undefined) {
|
|
304
|
+
assertDescriptorFileSystem('registered executor');
|
|
305
|
+
const executableFd = stdio.length;
|
|
306
|
+
stdio = [...stdio, executable.handle.fd];
|
|
307
|
+
if (interpreter === undefined)
|
|
308
|
+
command = `/proc/self/fd/${executableFd}`;
|
|
309
|
+
else {
|
|
310
|
+
const interpreterFd = stdio.length;
|
|
311
|
+
stdio = [...stdio, interpreter.executable.handle.fd];
|
|
312
|
+
command = `/proc/self/fd/${interpreterFd}`;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
let commandArguments = [...input.args];
|
|
316
|
+
if (executable !== undefined && interpreter !== undefined) {
|
|
317
|
+
commandArguments = [...interpreter.arguments, `/proc/self/fd/${3}`, ...commandArguments];
|
|
318
|
+
}
|
|
319
|
+
const child = spawn(command, commandArguments, { cwd: input.cwd, env: input.environment, stdio, shell: false });
|
|
320
|
+
const chunks = [];
|
|
321
|
+
let bytes = 0;
|
|
322
|
+
let outputLimit = false;
|
|
323
|
+
let timedOut = false;
|
|
324
|
+
child.stdout.on('data', (chunk) => {
|
|
325
|
+
if (!input.capture)
|
|
326
|
+
return;
|
|
327
|
+
bytes += chunk.length;
|
|
328
|
+
if (bytes > (input.maximumOutput ?? 65_536)) {
|
|
329
|
+
outputLimit = true;
|
|
330
|
+
child.kill('SIGKILL');
|
|
331
|
+
}
|
|
332
|
+
else
|
|
333
|
+
chunks.push(chunk);
|
|
334
|
+
});
|
|
335
|
+
const timeout = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 60_000);
|
|
336
|
+
child.once('error', () => { clearTimeout(timeout); reject(new ControlPlaneCliError('EXECUTOR_FAILED', 'registered executor could not start')); });
|
|
337
|
+
child.once('close', code => {
|
|
338
|
+
clearTimeout(timeout);
|
|
339
|
+
if (timedOut)
|
|
340
|
+
reject(new ControlPlaneCliError('EXECUTOR_TIMEOUT', 'registered executor exceeded its deadline'));
|
|
341
|
+
else if (outputLimit)
|
|
342
|
+
reject(new ControlPlaneCliError('EXECUTOR_OUTPUT_LIMIT', 'registered executor exceeded its output bound'));
|
|
343
|
+
else if (code !== 0)
|
|
344
|
+
reject(new ControlPlaneCliError('EXECUTOR_FAILED', 'registered executor returned a non-zero status'));
|
|
345
|
+
else
|
|
346
|
+
resolvePromise(input.capture ? Buffer.concat(chunks).toString('utf8') : '');
|
|
347
|
+
});
|
|
348
|
+
});
|
|
146
349
|
}
|
|
147
350
|
catch (error) {
|
|
148
|
-
|
|
149
|
-
|
|
351
|
+
failure = error;
|
|
352
|
+
}
|
|
353
|
+
try {
|
|
354
|
+
if (executable !== undefined)
|
|
355
|
+
await verifyPinnedDescriptor(executable);
|
|
356
|
+
if (interpreter !== undefined)
|
|
357
|
+
await verifyOpenTrustedExecutable(interpreter.executable);
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'registered executor changed while a command was running');
|
|
361
|
+
}
|
|
362
|
+
finally {
|
|
363
|
+
if (input.pinnedInterpreter === undefined && interpreter !== undefined)
|
|
364
|
+
await interpreter.executable.handle.close();
|
|
365
|
+
await ownedExecutable?.handle.close();
|
|
366
|
+
}
|
|
367
|
+
if (failure !== undefined)
|
|
368
|
+
throw failure;
|
|
369
|
+
return result;
|
|
370
|
+
};
|
|
371
|
+
return input.store !== undefined && input.plan !== undefined
|
|
372
|
+
? input.store.withActivationFileSystemGuard({ planId: input.plan.id, expectedRevision: input.plan.revision,
|
|
373
|
+
fence: input.plan.activation.fence, status: input.plan.status, leaseMs }, execute)
|
|
374
|
+
: execute();
|
|
375
|
+
}
|
|
376
|
+
function assertDescriptorFileSystem(label) {
|
|
377
|
+
if (process.platform !== 'linux')
|
|
378
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', `${label} requires Linux descriptor pinning`);
|
|
379
|
+
try {
|
|
380
|
+
const metadata = lstatSync('/proc/self/fd');
|
|
381
|
+
if (!metadata.isDirectory())
|
|
382
|
+
throw new Error('not a directory');
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', `${label} requires /proc/self/fd`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
function paths(plan) {
|
|
389
|
+
if (plan.activation === undefined)
|
|
390
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'activation identity is missing');
|
|
391
|
+
const suffix = plan.activation.id.replace(/[^A-Za-z0-9-]/gu, '').slice(-36);
|
|
392
|
+
const stageProfile = `stage-${suffix}`;
|
|
393
|
+
return { stageProfile, stagePath: join(plan.target.dshHome, 'profiles', stageProfile),
|
|
394
|
+
backupPath: join(plan.target.dshHome, 'profiles', `.${plan.profile}.plugin-backup-${suffix}`) };
|
|
395
|
+
}
|
|
396
|
+
function within(root, path) {
|
|
397
|
+
const suffix = relative(root, path);
|
|
398
|
+
return suffix !== '' && suffix !== '..' && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix);
|
|
399
|
+
}
|
|
400
|
+
async function syncDirectory(path) {
|
|
401
|
+
const directory = await open(path, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW);
|
|
402
|
+
try {
|
|
403
|
+
await directory.sync();
|
|
404
|
+
}
|
|
405
|
+
finally {
|
|
406
|
+
await directory.close();
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
function descriptorReference(handle) {
|
|
410
|
+
assertDescriptorFileSystem('local artifact activation');
|
|
411
|
+
return `/proc/${process.pid}/fd/${handle.fd}`;
|
|
412
|
+
}
|
|
413
|
+
async function verifyActivationArtifactSnapshot(snapshot) {
|
|
414
|
+
const before = await snapshot.handle.stat({ bigint: true });
|
|
415
|
+
if (!before.isFile() || before.nlink > 1n || before.dev !== snapshot.device || before.ino !== snapshot.inode
|
|
416
|
+
|| before.size !== snapshot.size || before.mtimeNs !== snapshot.mtimeNs || (before.mode & 146n) !== 0n) {
|
|
417
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'activation artifact changed while the executor was reading it');
|
|
418
|
+
}
|
|
419
|
+
const bytes = Buffer.alloc(Number(snapshot.size));
|
|
420
|
+
let offset = 0;
|
|
421
|
+
while (offset < bytes.length) {
|
|
422
|
+
const result = await snapshot.handle.read(bytes, offset, bytes.length - offset, offset);
|
|
423
|
+
if (result.bytesRead === 0)
|
|
424
|
+
break;
|
|
425
|
+
offset += result.bytesRead;
|
|
426
|
+
}
|
|
427
|
+
const after = await snapshot.handle.stat({ bigint: true });
|
|
428
|
+
if (after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size || after.mtimeNs !== before.mtimeNs
|
|
429
|
+
|| after.ctimeNs !== before.ctimeNs || after.mode !== before.mode || after.nlink !== before.nlink || BigInt(offset) !== snapshot.size
|
|
430
|
+
|| !createHash('sha512').update(bytes).digest().equals(snapshot.sha512)) {
|
|
431
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'activation artifact bytes changed while the executor was reading them');
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
async function closeActivationArtifactSnapshots(snapshots) {
|
|
435
|
+
await Promise.all(snapshots.map(async (snapshot) => snapshot.handle.close()));
|
|
436
|
+
}
|
|
437
|
+
async function snapshotLocalArtifact(plan, item) {
|
|
438
|
+
const registry = item.registry;
|
|
439
|
+
if (registry === undefined)
|
|
440
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'local artifact registry binding is missing');
|
|
441
|
+
let sourcePath;
|
|
442
|
+
let registryRoot;
|
|
443
|
+
try {
|
|
444
|
+
sourcePath = fileURLToPath(registry.reference);
|
|
445
|
+
registryRoot = fileURLToPath(registry.locator);
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'local artifact reference must be a canonical file URL');
|
|
449
|
+
}
|
|
450
|
+
if (pathToFileURL(sourcePath).href !== registry.reference || pathToFileURL(registryRoot).href !== registry.locator
|
|
451
|
+
|| !isAbsolute(sourcePath) || resolve(sourcePath) !== sourcePath || !isAbsolute(registryRoot) || resolve(registryRoot) !== registryRoot
|
|
452
|
+
|| !within(registryRoot, sourcePath)) {
|
|
453
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'local artifact escapes its bound registry');
|
|
454
|
+
}
|
|
455
|
+
const handle = await open(sourcePath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
456
|
+
let bytes;
|
|
457
|
+
try {
|
|
458
|
+
const before = await handle.stat({ bigint: true });
|
|
459
|
+
const uid = process.getuid?.();
|
|
460
|
+
if (!before.isFile() || before.nlink !== 1n || before.size < 1n || before.size > BigInt(maximumActivationArtifactBytes)
|
|
461
|
+
|| (before.mode & 18n) !== 0n || (uid !== undefined && before.uid !== BigInt(uid) && before.uid !== 0n)) {
|
|
462
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'local artifact is not one bounded non-writable regular file');
|
|
463
|
+
}
|
|
464
|
+
const parentPath = dirname(sourcePath);
|
|
465
|
+
const canonicalParent = await realpath(parentPath);
|
|
466
|
+
const parent = await lstat(parentPath);
|
|
467
|
+
if (canonicalParent !== parentPath || !parent.isDirectory() || parent.isSymbolicLink() || (parent.mode & 0o022) !== 0
|
|
468
|
+
|| (uid !== undefined && parent.uid !== uid && parent.uid !== 0)) {
|
|
469
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'local artifact parent directory is not trusted');
|
|
470
|
+
}
|
|
471
|
+
bytes = await handle.readFile();
|
|
472
|
+
const after = await handle.stat({ bigint: true });
|
|
473
|
+
const pathAfter = await lstat(sourcePath, { bigint: true });
|
|
474
|
+
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeNs !== after.mtimeNs
|
|
475
|
+
|| before.ctimeNs !== after.ctimeNs || pathAfter.isSymbolicLink() || pathAfter.dev !== before.dev || pathAfter.ino !== before.ino
|
|
476
|
+
|| BigInt(bytes.length) !== before.size)
|
|
477
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'local artifact changed during verification');
|
|
478
|
+
}
|
|
479
|
+
finally {
|
|
480
|
+
await handle.close();
|
|
481
|
+
}
|
|
482
|
+
const integrity = `sha512-${createHash('sha512').update(bytes).digest('base64')}`;
|
|
483
|
+
if (integrity !== item.integrity)
|
|
484
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'local artifact bytes do not match the approved integrity');
|
|
485
|
+
const cacheRoot = join(plan.target.dshHome, 'plugin-control', 'activation-artifacts');
|
|
486
|
+
await mkdir(cacheRoot, { recursive: true, mode: 0o700 });
|
|
487
|
+
await assertDirectory(cacheRoot);
|
|
488
|
+
const cacheMetadata = await lstat(cacheRoot);
|
|
489
|
+
const uid = process.getuid?.();
|
|
490
|
+
if ((cacheMetadata.mode & 0o077) !== 0 || (uid !== undefined && cacheMetadata.uid !== uid)) {
|
|
491
|
+
throw new ControlPlaneCliError('FILESYSTEM_STATE', 'activation artifact cache is not owner-private');
|
|
492
|
+
}
|
|
493
|
+
const activationId = plan.activation?.id;
|
|
494
|
+
if (activationId === undefined)
|
|
495
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'activation identity is missing');
|
|
496
|
+
const activationDirectory = join(cacheRoot, activationId.replace(/[^A-Za-z0-9-]/gu, ''));
|
|
497
|
+
try {
|
|
498
|
+
await mkdir(activationDirectory, { mode: 0o700 });
|
|
499
|
+
}
|
|
500
|
+
catch (error) {
|
|
501
|
+
if (error.code !== 'EEXIST')
|
|
502
|
+
throw error;
|
|
503
|
+
}
|
|
504
|
+
await assertDirectory(activationDirectory);
|
|
505
|
+
const directoryMetadata = await lstat(activationDirectory);
|
|
506
|
+
if ((directoryMetadata.mode & 0o077) !== 0 || (uid !== undefined && directoryMetadata.uid !== uid)) {
|
|
507
|
+
throw new ControlPlaneCliError('FILESYSTEM_STATE', 'activation artifact directory is not owner-private');
|
|
508
|
+
}
|
|
509
|
+
const destination = join(activationDirectory, `${createHash('sha256').update(item.package).digest('hex')}.tgz`);
|
|
510
|
+
let output;
|
|
511
|
+
try {
|
|
512
|
+
output = await open(destination, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o400);
|
|
513
|
+
await output.writeFile(bytes);
|
|
514
|
+
await output.sync();
|
|
515
|
+
await output.close();
|
|
516
|
+
output = undefined;
|
|
517
|
+
await syncDirectory(activationDirectory);
|
|
518
|
+
await syncDirectory(cacheRoot);
|
|
519
|
+
}
|
|
520
|
+
catch (error) {
|
|
521
|
+
await output?.close().catch(() => undefined);
|
|
522
|
+
if (error.code !== 'EEXIST') {
|
|
523
|
+
await rm(destination, { force: true });
|
|
524
|
+
throw error;
|
|
150
525
|
}
|
|
151
|
-
await
|
|
526
|
+
const existing = await open(destination, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
152
527
|
try {
|
|
153
|
-
|
|
528
|
+
const metadata = await existing.stat();
|
|
529
|
+
const current = await existing.readFile();
|
|
530
|
+
if (!metadata.isFile() || metadata.nlink !== 1 || (metadata.mode & 0o222) !== 0 || current.length !== bytes.length
|
|
531
|
+
|| !createHash('sha512').update(current).digest().equals(createHash('sha512').update(bytes).digest())) {
|
|
532
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'activation artifact cache conflicts with the approved bytes');
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
finally {
|
|
536
|
+
await existing.close();
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
const pinned = await open(destination, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
540
|
+
try {
|
|
541
|
+
const metadata = await pinned.stat({ bigint: true });
|
|
542
|
+
const current = await pinned.readFile();
|
|
543
|
+
if (!metadata.isFile() || metadata.nlink !== 1n || metadata.size !== BigInt(bytes.length) || (metadata.mode & 146n) !== 0n
|
|
544
|
+
|| !createHash('sha512').update(current).digest().equals(createHash('sha512').update(bytes).digest())) {
|
|
545
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'activation artifact cache does not retain the approved bytes');
|
|
546
|
+
}
|
|
547
|
+
const reference = pathToFileURL(descriptorReference(pinned)).href;
|
|
548
|
+
return { package: { ...item, registry: { ...registry, reference } }, handle: pinned, path: destination, device: metadata.dev,
|
|
549
|
+
inode: metadata.ino, size: metadata.size, mtimeNs: metadata.mtimeNs, ctimeNs: metadata.ctimeNs,
|
|
550
|
+
sha512: createHash('sha512').update(current).digest() };
|
|
551
|
+
}
|
|
552
|
+
catch (error) {
|
|
553
|
+
await pinned.close();
|
|
554
|
+
throw error;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
async function activationPackages(plan) {
|
|
558
|
+
const snapshots = [];
|
|
559
|
+
try {
|
|
560
|
+
for (const item of exactPackages(plan)) {
|
|
561
|
+
if (localArtifactReference(item) !== undefined)
|
|
562
|
+
snapshots.push(await snapshotLocalArtifact(plan, item));
|
|
563
|
+
}
|
|
564
|
+
const byPackage = new Map(snapshots.map(snapshot => [snapshot.package.package, snapshot]));
|
|
565
|
+
return { packages: exactPackages(plan).map(item => byPackage.get(item.package)?.package ?? item), snapshots };
|
|
566
|
+
}
|
|
567
|
+
catch (error) {
|
|
568
|
+
await closeActivationArtifactSnapshots(snapshots);
|
|
569
|
+
throw error;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
function installSpec(item) {
|
|
573
|
+
return localArtifactReference(item) ?? `${item.package}@${item.version}`;
|
|
574
|
+
}
|
|
575
|
+
async function verifyInstalledPackages(profilePath, packages) {
|
|
576
|
+
for (const item of packages) {
|
|
577
|
+
const manifestPath = join(profilePath, 'node_modules', ...item.package.split('/'), 'package.json');
|
|
578
|
+
let raw;
|
|
579
|
+
try {
|
|
580
|
+
const canonical = await realpath(manifestPath);
|
|
581
|
+
const modulesRoot = join(profilePath, 'node_modules');
|
|
582
|
+
if (!within(modulesRoot, canonical))
|
|
583
|
+
throw new Error('manifest escapes node_modules');
|
|
584
|
+
const handle = await open(canonical, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
585
|
+
try {
|
|
586
|
+
const before = await handle.stat({ bigint: true });
|
|
587
|
+
const uid = process.getuid?.();
|
|
588
|
+
if (!before.isFile() || before.size < 1n || before.size > 1048576n || (before.mode & 18n) !== 0n
|
|
589
|
+
|| (uid !== undefined && before.uid !== BigInt(uid) && before.uid !== 0n))
|
|
590
|
+
throw new Error('manifest is unsafe');
|
|
591
|
+
const bytes = await handle.readFile();
|
|
592
|
+
const after = await handle.stat({ bigint: true });
|
|
593
|
+
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeNs !== after.mtimeNs
|
|
594
|
+
|| before.ctimeNs !== after.ctimeNs || BigInt(bytes.length) !== before.size)
|
|
595
|
+
throw new Error('manifest changed');
|
|
596
|
+
raw = JSON.parse(bytes.toString('utf8'));
|
|
597
|
+
}
|
|
598
|
+
finally {
|
|
599
|
+
await handle.close();
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', `installed package ${item.package} has no trusted manifest`);
|
|
604
|
+
}
|
|
605
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw) || !('name' in raw) || !('version' in raw)
|
|
606
|
+
|| raw.name !== item.package || raw.version !== item.version) {
|
|
607
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', `installed package ${item.package} has the wrong name or version`);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
async function fencedMutation(store, plan, action) {
|
|
612
|
+
return store.withActivationFileSystemGuard({ planId: plan.id, expectedRevision: plan.revision,
|
|
613
|
+
fence: plan.activation.fence, status: plan.status, leaseMs }, action);
|
|
614
|
+
}
|
|
615
|
+
async function restoreTarget(store, plan, backupPath) {
|
|
616
|
+
const originallyExisted = plan.activation?.targetOriginallyExisted;
|
|
617
|
+
if (originallyExisted === undefined)
|
|
618
|
+
throw new ControlPlaneCliError('FILESYSTEM_STATE', 'activation target baseline is missing');
|
|
619
|
+
const backupExists = await directoryExists(backupPath);
|
|
620
|
+
const targetExists = await directoryExists(plan.target.profilePath);
|
|
621
|
+
if (originallyExisted) {
|
|
622
|
+
if (backupExists) {
|
|
623
|
+
await fencedMutation(store, plan, async () => {
|
|
624
|
+
await rm(plan.target.profilePath, { recursive: true, force: true });
|
|
625
|
+
await rename(backupPath, plan.target.profilePath);
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
else if (!targetExists)
|
|
629
|
+
throw new ControlPlaneCliError('FILESYSTEM_STATE', 'original target and its backup are both missing');
|
|
630
|
+
}
|
|
631
|
+
else {
|
|
632
|
+
if (backupExists)
|
|
633
|
+
throw new ControlPlaneCliError('FILESYSTEM_STATE', 'a backup exists for a target recorded as originally absent');
|
|
634
|
+
if (targetExists)
|
|
635
|
+
await fencedMutation(store, plan, () => rm(plan.target.profilePath, { recursive: true, force: true }));
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
function advance(store, plan, to, failureCode) {
|
|
639
|
+
return store.advanceActivation({ planId: plan.id, expectedRevision: plan.revision, fence: plan.activation.fence,
|
|
640
|
+
from: plan.status, to, ...(failureCode === undefined ? {} : { failureCode }) });
|
|
641
|
+
}
|
|
642
|
+
async function finishRollback(store, plan, lock) {
|
|
643
|
+
const activationPaths = paths(plan);
|
|
644
|
+
await restoreTarget(store, plan, activationPaths.backupPath);
|
|
645
|
+
await fencedMutation(store, plan, () => rm(activationPaths.stagePath, { recursive: true, force: true }));
|
|
646
|
+
const terminal = advance(store, plan, 'rolled-back');
|
|
647
|
+
await releaseProfileLock(store, lock);
|
|
648
|
+
return terminal;
|
|
649
|
+
}
|
|
650
|
+
async function approve(argv) {
|
|
651
|
+
if (argv.includes('--approved-by')) {
|
|
652
|
+
throw new ControlPlaneCliError('INVALID_ARGUMENT', 'approval trust roots cannot be supplied by the approving command');
|
|
653
|
+
}
|
|
654
|
+
const trust = await commandTrust(argv);
|
|
655
|
+
const store = new ControlPlaneStore({ path: trust.ledger.path });
|
|
656
|
+
try {
|
|
657
|
+
const receipt = parseApprovalReceipt(JSON.parse(await loadPrivateApprovalInput(resolve(option(argv, '--approval-receipt')), 32_768)));
|
|
658
|
+
const kind = option(argv, '--kind');
|
|
659
|
+
const common = { planId: option(argv, '--plan-id'), expectedRevision: integerOption(argv, '--expected-revision'), receipt,
|
|
660
|
+
resolveAuthority: (value) => { const key = resolveTrustKey(trust, 'approval', value.authority, value.keyId); return new Ed25519ApprovalAuthority(key.publicKeyPem, key.authority, key.keyId); },
|
|
661
|
+
idempotencyKey: `approval:${receipt.approvalId}` };
|
|
662
|
+
const result = kind === 'activation' ? await store.approve(common) : kind === 'source' ? await store.approveSource(common)
|
|
663
|
+
: (() => { throw new ControlPlaneCliError('INVALID_ARGUMENT', '--kind must be activation or source'); })();
|
|
664
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
665
|
+
}
|
|
666
|
+
finally {
|
|
667
|
+
store.close();
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
function activationApprovalAuthority(trust, receipt) {
|
|
671
|
+
const key = resolveTrustKey(trust, 'approval', receipt.authority, receipt.keyId);
|
|
672
|
+
return new Ed25519ApprovalAuthority(key.publicKeyPem, key.authority, key.keyId, () => receipt.decidedAt);
|
|
673
|
+
}
|
|
674
|
+
async function activate(argv) {
|
|
675
|
+
const trust = await commandTrust(argv);
|
|
676
|
+
const store = new ControlPlaneStore({ path: trust.ledger.path });
|
|
677
|
+
let lock;
|
|
678
|
+
try {
|
|
679
|
+
let plan = store.getPlan(option(argv, '--plan-id'));
|
|
680
|
+
assertPlanTrust(plan, trust);
|
|
681
|
+
if (plan.status === 'activated') {
|
|
682
|
+
process.stdout.write(`${JSON.stringify(plan)}\n`);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
plan = await store.claimActivation({ planId: plan.id, expectedRevision: integerOption(argv, '--expected-revision'), leaseMs,
|
|
686
|
+
resolveApprovalAuthority: receipt => activationApprovalAuthority(trust, receipt) });
|
|
687
|
+
lock = await acquireProfileLock(store, plan);
|
|
688
|
+
const activationPaths = paths(plan);
|
|
689
|
+
if (plan.status === 'rollback-pending') {
|
|
690
|
+
process.stdout.write(`${JSON.stringify(await finishRollback(store, plan, lock))}\n`);
|
|
691
|
+
lock = undefined;
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
if (plan.status === 'commit-pending') {
|
|
695
|
+
await fencedMutation(store, plan, () => rm(activationPaths.backupPath, { recursive: true, force: true }));
|
|
696
|
+
await fencedMutation(store, plan, () => rm(activationPaths.stagePath, { recursive: true, force: true }));
|
|
697
|
+
process.stdout.write(`${JSON.stringify(advance(store, plan, 'activated'))}\n`);
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
if (plan.status !== 'staging')
|
|
701
|
+
throw new ControlPlaneCliError('HOST_ATTESTATION_REQUIRED', 'activation is awaiting a signed Host attestation');
|
|
702
|
+
await assertDirectory(trust.dshHome);
|
|
703
|
+
await assertDirectory(join(trust.dshHome, 'profiles'));
|
|
704
|
+
await assertDirectory(plan.target.profilePath, true);
|
|
705
|
+
const environment = inheritedEnvironment(trust);
|
|
706
|
+
try {
|
|
707
|
+
if (plan.activation?.targetOriginallyExisted === undefined) {
|
|
708
|
+
if (await directoryExists(activationPaths.backupPath) || await directoryExists(activationPaths.stagePath)) {
|
|
709
|
+
throw new ControlPlaneCliError('FILESYSTEM_STATE', 'unbound activation residue requires owner recovery');
|
|
710
|
+
}
|
|
711
|
+
plan = store.recordActivationTargetBaseline({ planId: plan.id, expectedRevision: plan.revision,
|
|
712
|
+
fence: plan.activation.fence, existed: await directoryExists(plan.target.profilePath) });
|
|
713
|
+
}
|
|
714
|
+
await restoreTarget(store, plan, activationPaths.backupPath);
|
|
715
|
+
await fencedMutation(store, plan, () => rm(activationPaths.stagePath, { recursive: true, force: true }));
|
|
716
|
+
if (plan.activation.targetOriginallyExisted) {
|
|
717
|
+
await stat(plan.target.profilePath);
|
|
718
|
+
await fencedMutation(store, plan, () => cp(plan.target.profilePath, activationPaths.stagePath, { recursive: true, force: false, errorOnExist: true }));
|
|
719
|
+
}
|
|
720
|
+
const activationArtifacts = await fencedMutation(store, plan, () => activationPackages(plan));
|
|
721
|
+
let pinnedExecutor;
|
|
722
|
+
let pinnedInterpreter;
|
|
723
|
+
try {
|
|
724
|
+
pinnedExecutor = await openTrustedExecutable(trust.executor.path, plan.executor.sha256);
|
|
725
|
+
pinnedInterpreter = await executorInterpreter(pinnedExecutor);
|
|
726
|
+
const executor = { pinnedExecutable: pinnedExecutor, pinnedInterpreter };
|
|
727
|
+
const version = (await runBounded({ executable: trust.executor.path, args: ['--version'], environment, store, plan, capture: true, ...executor })).trim();
|
|
728
|
+
if (version !== plan.candidate.dshBaseline)
|
|
729
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'registered executor baseline differs from the approved dossier');
|
|
730
|
+
await runBounded({ executable: trust.executor.path, args: ['plugin', '--profile', activationPaths.stageProfile, 'add',
|
|
731
|
+
...activationArtifacts.packages.map(installSpec)], environment, store, plan, ...executor });
|
|
732
|
+
await Promise.all(activationArtifacts.snapshots.map(verifyActivationArtifactSnapshot));
|
|
733
|
+
const lockfileSource = await readSafeFile(join(activationPaths.stagePath, 'pnpm-lock.yaml'), 8 * 1024 * 1024);
|
|
734
|
+
verifyApprovedPackagesInLockfile(lockfileSource, activationArtifacts.packages, activationPaths.stagePath);
|
|
735
|
+
await verifyInstalledPackages(activationPaths.stagePath, activationArtifacts.packages);
|
|
736
|
+
// Configuration materialization is a staging integrity check only. It is
|
|
737
|
+
// deliberately not called readiness, reload, shadow, canary or health.
|
|
738
|
+
await runBounded({ executable: trust.executor.path, args: ['--profile', activationPaths.stageProfile, '--dump-config'],
|
|
739
|
+
environment, store, plan, ...executor });
|
|
740
|
+
}
|
|
741
|
+
finally {
|
|
742
|
+
await closeActivationArtifactSnapshots(activationArtifacts.snapshots);
|
|
743
|
+
if (pinnedInterpreter !== undefined)
|
|
744
|
+
await pinnedInterpreter.executable.handle.close();
|
|
745
|
+
await pinnedExecutor?.handle.close();
|
|
746
|
+
}
|
|
747
|
+
if (plan.activation.targetOriginallyExisted)
|
|
748
|
+
await fencedMutation(store, plan, () => rename(plan.target.profilePath, activationPaths.backupPath));
|
|
749
|
+
await fencedMutation(store, plan, () => rename(activationPaths.stagePath, plan.target.profilePath));
|
|
750
|
+
plan = advance(store, plan, 'awaiting-reload');
|
|
751
|
+
process.stdout.write(`${JSON.stringify(plan)}\n`);
|
|
154
752
|
}
|
|
155
753
|
catch (error) {
|
|
156
|
-
|
|
157
|
-
await
|
|
754
|
+
plan = advance(store, plan, 'rollback-pending', error instanceof ControlPlaneCliError ? error.code.toLowerCase().replaceAll('_', '-') : 'activation-failed');
|
|
755
|
+
await finishRollback(store, plan, lock);
|
|
756
|
+
lock = undefined;
|
|
158
757
|
throw error;
|
|
159
758
|
}
|
|
160
|
-
await rm(backupPath, { recursive: true, force: true });
|
|
161
|
-
process.stdout.write(`Activated ${packages.map(item => `${item.package}@${item.version}`).join(', ')} atomically; restart the resident service with scripts/install/restart.sh ${plan.profile}.\n`);
|
|
162
759
|
}
|
|
163
760
|
finally {
|
|
164
|
-
|
|
761
|
+
if (lock !== undefined)
|
|
762
|
+
await releaseProfileLock(store, lock);
|
|
763
|
+
store.close();
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
async function attest(argv) {
|
|
767
|
+
const trust = await commandTrust(argv);
|
|
768
|
+
const store = new ControlPlaneStore({ path: trust.ledger.path });
|
|
769
|
+
let lock;
|
|
770
|
+
try {
|
|
771
|
+
const receipt = parseHostAttestationReceipt(JSON.parse(await loadPrivateApprovalInput(resolve(option(argv, '--receipt')), 32_768)));
|
|
772
|
+
const planId = option(argv, '--plan-id');
|
|
773
|
+
const expectedRevision = integerOption(argv, '--expected-revision');
|
|
774
|
+
const expectedFence = integerOption(argv, '--expected-fence');
|
|
775
|
+
const initial = store.getPlan(planId);
|
|
776
|
+
assertPlanTrust(initial, trust);
|
|
777
|
+
const resolveAuthority = (value) => {
|
|
778
|
+
const key = resolveTrustKey(trust, 'host-attestation', value.authority, value.keyId);
|
|
779
|
+
return new Ed25519HostAttestationAuthority(key.publicKeyPem, key.authority, key.keyId);
|
|
780
|
+
};
|
|
781
|
+
await store.runHostAttestationOperation({ operationId: receipt.operationId, expectedRevision, expectedFence,
|
|
782
|
+
execute: async () => receipt, resolveAuthority });
|
|
783
|
+
const result = await store.applyHostAttestation({ planId, expectedRevision, expectedFence, receipt,
|
|
784
|
+
idempotencyKey: `host-attestation:${receipt.operationId}`, resolveAuthority });
|
|
785
|
+
let plan = store.getPlan(result.result.id);
|
|
786
|
+
assertPlanTrust(plan, trust);
|
|
787
|
+
if (plan.status === 'rollback-pending' || plan.status === 'commit-pending') {
|
|
788
|
+
plan = await store.claimActivation({ planId: plan.id, expectedRevision: plan.revision, leaseMs,
|
|
789
|
+
resolveApprovalAuthority: receipt => activationApprovalAuthority(trust, receipt) });
|
|
790
|
+
lock = await acquireProfileLock(store, plan);
|
|
791
|
+
if (plan.status === 'rollback-pending') {
|
|
792
|
+
plan = await finishRollback(store, plan, lock);
|
|
793
|
+
lock = undefined;
|
|
794
|
+
}
|
|
795
|
+
else {
|
|
796
|
+
const activationPaths = paths(plan);
|
|
797
|
+
await fencedMutation(store, plan, () => rm(activationPaths.backupPath, { recursive: true, force: true }));
|
|
798
|
+
await fencedMutation(store, plan, () => rm(activationPaths.stagePath, { recursive: true, force: true }));
|
|
799
|
+
plan = advance(store, plan, 'activated');
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
process.stdout.write(`${JSON.stringify({ ...result, result: plan })}\n`);
|
|
803
|
+
}
|
|
804
|
+
finally {
|
|
805
|
+
if (lock !== undefined)
|
|
806
|
+
await releaseProfileLock(store, lock);
|
|
807
|
+
store.close();
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
async function hostRequest(argv) {
|
|
811
|
+
const trust = await commandTrust(argv);
|
|
812
|
+
const store = new ControlPlaneStore({ path: trust.ledger.path });
|
|
813
|
+
try {
|
|
814
|
+
const plan = store.getPlan(option(argv, '--plan-id'));
|
|
815
|
+
assertPlanTrust(plan, trust);
|
|
816
|
+
if (plan.revision !== integerOption(argv, '--expected-revision') || plan.activation?.fence !== integerOption(argv, '--expected-fence')) {
|
|
817
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'manual Host request targets a stale revision/fence');
|
|
818
|
+
}
|
|
819
|
+
process.stdout.write(`${JSON.stringify(prepareManualHostAttestation(store, plan, trust).request)}\n`);
|
|
820
|
+
}
|
|
821
|
+
finally {
|
|
822
|
+
store.close();
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
async function probe(argv) {
|
|
826
|
+
const trust = await commandTrust(argv);
|
|
827
|
+
if (trust.hostAttestor === undefined)
|
|
828
|
+
throw new ControlPlaneCliError('HOST_ATTESTOR_NOT_CONFIGURED', 'deployment has no owner-configured Host attestor; activation remains awaiting its current phase');
|
|
829
|
+
const store = new ControlPlaneStore({ path: trust.ledger.path });
|
|
830
|
+
let lock;
|
|
831
|
+
try {
|
|
832
|
+
const plan = store.getPlan(option(argv, '--plan-id'));
|
|
833
|
+
assertPlanTrust(plan, trust);
|
|
834
|
+
const expectedRevision = integerOption(argv, '--expected-revision');
|
|
835
|
+
const expectedFence = integerOption(argv, '--expected-fence');
|
|
836
|
+
if (plan.revision !== expectedRevision || plan.activation?.fence !== expectedFence) {
|
|
837
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'configured Host probe targets a stale revision/fence');
|
|
838
|
+
}
|
|
839
|
+
const operation = prepareConfiguredHostAttestation(store, plan, trust);
|
|
840
|
+
const resolveAuthority = (value) => {
|
|
841
|
+
const key = resolveTrustKey(trust, 'host-attestation', value.authority, value.keyId);
|
|
842
|
+
return new Ed25519HostAttestationAuthority(key.publicKeyPem, key.authority, key.keyId);
|
|
843
|
+
};
|
|
844
|
+
const receipt = await store.runHostAttestationOperation({ operationId: operation.operationId, expectedRevision, expectedFence,
|
|
845
|
+
execute: request => invokeConfiguredHostAttestor(trust, request), resolveAuthority });
|
|
846
|
+
const result = await store.applyHostAttestation({ planId: plan.id, expectedRevision, expectedFence, receipt,
|
|
847
|
+
idempotencyKey: `host-attestation:${operation.operationId}`, resolveAuthority });
|
|
848
|
+
let output = result.result;
|
|
849
|
+
if (output.status === 'rollback-pending' || output.status === 'commit-pending') {
|
|
850
|
+
output = await store.claimActivation({ planId: output.id, expectedRevision: output.revision, leaseMs,
|
|
851
|
+
resolveApprovalAuthority: receipt => activationApprovalAuthority(trust, receipt) });
|
|
852
|
+
lock = await acquireProfileLock(store, output);
|
|
853
|
+
if (output.status === 'rollback-pending') {
|
|
854
|
+
output = await finishRollback(store, output, lock);
|
|
855
|
+
lock = undefined;
|
|
856
|
+
}
|
|
857
|
+
else {
|
|
858
|
+
const activationPaths = paths(output);
|
|
859
|
+
await fencedMutation(store, output, () => rm(activationPaths.backupPath, { recursive: true, force: true }));
|
|
860
|
+
await fencedMutation(store, output, () => rm(activationPaths.stagePath, { recursive: true, force: true }));
|
|
861
|
+
output = advance(store, output, 'activated');
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
process.stdout.write(`${JSON.stringify({ ...result, result: output })}\n`);
|
|
865
|
+
}
|
|
866
|
+
finally {
|
|
867
|
+
if (lock !== undefined)
|
|
868
|
+
await releaseProfileLock(store, lock);
|
|
869
|
+
store.close();
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
async function executable(command, environment) {
|
|
873
|
+
const candidates = command === 'git' ? ['/usr/bin/git', '/bin/git'] : [join(dirname(process.execPath), 'pnpm'),
|
|
874
|
+
...(environment.PATH ?? '').split(delimiter).filter(isAbsolute).map(directory => join(directory, 'pnpm'))];
|
|
875
|
+
const uid = process.getuid?.();
|
|
876
|
+
for (const candidate of candidates) {
|
|
877
|
+
try {
|
|
878
|
+
const canonical = await realpath(candidate);
|
|
879
|
+
const value = await lstat(canonical);
|
|
880
|
+
const directory = await lstat(dirname(canonical));
|
|
881
|
+
if (value.isFile() && !value.isSymbolicLink() && (value.mode & 0o111) !== 0 && (value.mode & 0o022) === 0
|
|
882
|
+
&& (uid === undefined || value.uid === 0 || value.uid === uid) && directory.isDirectory() && (directory.mode & 0o002) === 0)
|
|
883
|
+
return canonical;
|
|
884
|
+
}
|
|
885
|
+
catch { /* next */ }
|
|
886
|
+
}
|
|
887
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', `registered local ${command} executable is unavailable`);
|
|
888
|
+
}
|
|
889
|
+
async function localCommand(command, args, cwd, environment, capture = false, maximumOutput) {
|
|
890
|
+
return runBounded({ executable: await executable(command, environment), args, cwd, environment, capture,
|
|
891
|
+
...(maximumOutput === undefined ? {} : { maximumOutput }) });
|
|
892
|
+
}
|
|
893
|
+
function sourceScope(name) { return [pluginCatalogScope, `plugins/${name}`].sort(); }
|
|
894
|
+
function assertExactSourceScope(plan) {
|
|
895
|
+
const expected = sourceScope(plan.name);
|
|
896
|
+
if (plan.scope.length !== expected.length || plan.scope.some((value, index) => value !== expected[index])) {
|
|
897
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source plan scope does not match the exact generator outputs');
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
async function changedSourcePaths(worktree, baseCommit, environment) {
|
|
901
|
+
const tracked = await localCommand('git', ['--literal-pathspecs', '-c', 'core.quotepath=false', 'diff', '--no-renames',
|
|
902
|
+
'--name-only', '-z', baseCommit, '--'], worktree, environment, true, 8_388_608);
|
|
903
|
+
const untracked = await localCommand('git', ['--literal-pathspecs', '-c', 'core.quotepath=false', 'ls-files', '--others',
|
|
904
|
+
'--exclude-standard', '-z'], worktree, environment, true, 8_388_608);
|
|
905
|
+
return [...new Set(`${tracked}${untracked}`.split('\0').filter(Boolean))].sort();
|
|
906
|
+
}
|
|
907
|
+
function sourcePathAllowed(path, plan) {
|
|
908
|
+
const pluginRoot = `plugins/${plan.name}`;
|
|
909
|
+
return path === pluginCatalogScope || path === pluginRoot || path.startsWith(`${pluginRoot}/`);
|
|
910
|
+
}
|
|
911
|
+
export async function checkedSourceSnapshot(worktree, baseCommit, scopeInput, environment) {
|
|
912
|
+
const temporary = await mkdtemp(join(tmpdir(), 'dsh-plugin-control-index-'));
|
|
913
|
+
try {
|
|
914
|
+
const scope = [...new Set(scopeInput.map(value => value.normalize('NFC').trim()))].sort();
|
|
915
|
+
if (scope.length === 0 || scope.some(value => value === ''))
|
|
916
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'checked source scope is invalid');
|
|
917
|
+
const snapshotEnvironment = { ...environment, GIT_INDEX_FILE: join(temporary, 'index') };
|
|
918
|
+
await localCommand('git', ['read-tree', baseCommit], worktree, snapshotEnvironment);
|
|
919
|
+
await localCommand('git', ['--literal-pathspecs', 'add', '--all', '--', ...scope], worktree, snapshotEnvironment);
|
|
920
|
+
const tree = await localCommand('git', ['--literal-pathspecs', '-c', 'core.quotepath=false', 'ls-files', '--stage', '-z',
|
|
921
|
+
'--', ...scope], worktree, snapshotEnvironment, true, 8_388_608);
|
|
922
|
+
const patch = await localCommand('git', ['--literal-pathspecs', '-c', 'core.quotepath=false', 'diff', '--cached', '--binary',
|
|
923
|
+
'--full-index', '--no-color', baseCommit, '--', ...scope], worktree, snapshotEnvironment, true, 8_388_608);
|
|
924
|
+
const binding = `${baseCommit}\0${JSON.stringify(scope)}\0`;
|
|
925
|
+
return {
|
|
926
|
+
checkedTreeDigest: createHash('sha256').update('dsh-source-tree-v2\0').update(binding).update(tree).digest('hex'),
|
|
927
|
+
checkedPatchDigest: createHash('sha256').update('dsh-source-patch-v2\0').update(binding).update(patch).digest('hex'),
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
finally {
|
|
931
|
+
await rm(temporary, { recursive: true, force: true });
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
async function sourcePlan(argv) {
|
|
935
|
+
const trust = await commandTrust(argv);
|
|
936
|
+
const store = new ControlPlaneStore({ path: trust.ledger.path });
|
|
937
|
+
try {
|
|
938
|
+
const repository = await realpath(resolve(option(argv, '--repository')));
|
|
939
|
+
const worktree = await realpath(resolve(option(argv, '--worktree')));
|
|
940
|
+
const name = option(argv, '--name');
|
|
941
|
+
if (!pluginPattern.test(name))
|
|
942
|
+
throw new ControlPlaneCliError('INVALID_ARGUMENT', 'plugin name is invalid');
|
|
943
|
+
const environment = inheritedEnvironment(trust);
|
|
944
|
+
const worktrees = (await localCommand('git', ['worktree', 'list', '--porcelain'], repository, environment, true)).split('\n')
|
|
945
|
+
.filter(line => line.startsWith('worktree ')).map(line => resolve(line.slice('worktree '.length)));
|
|
946
|
+
if (worktrees.length < 2 || worktrees[0] === worktree || !worktrees.includes(worktree))
|
|
947
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source plan requires a linked non-primary worktree');
|
|
948
|
+
const baseCommit = (await localCommand('git', ['rev-parse', 'HEAD'], worktree, environment, true)).trim();
|
|
949
|
+
const generator = join(repository, 'scripts', 'create-plugin.mjs');
|
|
950
|
+
const generatorDigest = createHash('sha256').update(await readSafeFile(generator, 1_048_576)).digest('hex');
|
|
951
|
+
const output = store.createSourcePlan({ gapId: option(argv, '--gap-id'), repository, worktree, baseCommit, name,
|
|
952
|
+
generatorDigest, scope: sourceScope(name), ttlMs: 900_000, idempotencyKey: option(argv, '--idempotency-key') });
|
|
953
|
+
process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
954
|
+
}
|
|
955
|
+
finally {
|
|
956
|
+
store.close();
|
|
165
957
|
}
|
|
166
958
|
}
|
|
167
959
|
async function scaffold(argv) {
|
|
168
|
-
if (
|
|
169
|
-
throw new
|
|
170
|
-
const
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
960
|
+
if (argv.includes('--owner-approved'))
|
|
961
|
+
throw new ControlPlaneCliError('INVALID_ARGUMENT', 'source execution requires a signed source plan');
|
|
962
|
+
const trust = await commandTrust(argv);
|
|
963
|
+
const store = new ControlPlaneStore({ path: trust.ledger.path });
|
|
964
|
+
let plan;
|
|
965
|
+
try {
|
|
966
|
+
plan = store.getSourcePlan(option(argv, '--plan-id'));
|
|
967
|
+
if (plan.status !== 'approved' || plan.revision !== integerOption(argv, '--expected-revision'))
|
|
968
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'exact approved source plan revision is required');
|
|
969
|
+
assertExactSourceScope(plan);
|
|
970
|
+
if (await realpath(plan.repository) !== plan.repository || await realpath(plan.worktree) !== plan.worktree)
|
|
971
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source paths changed');
|
|
972
|
+
const environment = inheritedEnvironment(trust);
|
|
973
|
+
if ((await localCommand('git', ['rev-parse', 'HEAD'], plan.worktree, environment, true)).trim() !== plan.baseCommit
|
|
974
|
+
|| createHash('sha256').update(await readSafeFile(join(plan.repository, 'scripts', 'create-plugin.mjs'), 1_048_576)).digest('hex') !== plan.generatorDigest
|
|
975
|
+
|| (await localCommand('git', ['status', '--porcelain'], plan.worktree, environment, true)).trim() !== '')
|
|
976
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source plan base, generator or clean-worktree binding changed');
|
|
977
|
+
plan = store.beginSourceChecks({ planId: plan.id, expectedRevision: plan.revision });
|
|
978
|
+
try {
|
|
979
|
+
await localCommand('pnpm', ['create:plugin', plan.name], plan.worktree, environment);
|
|
980
|
+
await localCommand('pnpm', ['check'], plan.worktree, environment);
|
|
981
|
+
const changes = await changedSourcePaths(plan.worktree, plan.baseCommit, environment);
|
|
982
|
+
const outsideScope = changes.find(path => !sourcePathAllowed(path, plan));
|
|
983
|
+
if (outsideScope !== undefined)
|
|
984
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', `source generator changed files outside its approved scope: ${JSON.stringify(outsideScope)}`);
|
|
985
|
+
const checked = await checkedSourceSnapshot(plan.worktree, plan.baseCommit, plan.scope, environment);
|
|
986
|
+
plan = store.finishSourceChecks({ planId: plan.id, expectedRevision: plan.revision, succeeded: true, ...checked });
|
|
987
|
+
}
|
|
988
|
+
catch (error) {
|
|
989
|
+
plan = store.finishSourceChecks({ planId: plan.id, expectedRevision: plan.revision, succeeded: false });
|
|
990
|
+
throw error;
|
|
991
|
+
}
|
|
992
|
+
process.stdout.write(`${JSON.stringify(plan)}\n`);
|
|
993
|
+
}
|
|
994
|
+
finally {
|
|
995
|
+
store.close();
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
function assertReleaseCommand(argv) {
|
|
999
|
+
if (argv.includes('--phase'))
|
|
1000
|
+
throw new ControlPlaneCliError('INVALID_ARGUMENT', 'release phase is derived from durable plan status');
|
|
1001
|
+
}
|
|
1002
|
+
function assertSourceReleaseTrust(plan, trust) {
|
|
1003
|
+
if (trust.schemaVersion !== 4 || trust.releaseRegistry === undefined || plan.release === undefined) {
|
|
1004
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source release requires schema-v4 owner trust, exact catalog, registry, and durable release identity');
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
function releaseAuthority(trust, receipt) {
|
|
1008
|
+
const key = resolveTrustKey(trust, 'release', receipt.authority, receipt.keyId);
|
|
1009
|
+
return new Ed25519SourceReleaseAuthority(key.publicKeyPem, key.authority, key.keyId, Date.now, (authority, keyId) => {
|
|
1010
|
+
const verifier = trust.releaseAdapters?.['registry-verify'];
|
|
1011
|
+
if (verifier === undefined || verifier.authority !== authority || verifier.keyId !== keyId)
|
|
1012
|
+
return undefined;
|
|
1013
|
+
return resolveTrustKey(trust, 'release', authority, keyId).publicKeyPem;
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
function reconciliationAuthority(trust, receipt) {
|
|
1017
|
+
const key = resolveTrustKey(trust, 'release', receipt.authority, receipt.keyId);
|
|
1018
|
+
return new Ed25519SourcePublishReconciliationAuthority(key.publicKeyPem, key.authority, key.keyId);
|
|
1019
|
+
}
|
|
1020
|
+
function releaseAuthorizationAuthority(trust, authorization) {
|
|
1021
|
+
if (authorization === undefined)
|
|
1022
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source release authorization is missing');
|
|
1023
|
+
const key = resolveTrustKey(trust, 'release-authorization', authorization.authority, authorization.keyId);
|
|
1024
|
+
const authority = new Ed25519SourceReleaseAuthorizationAuthority(key.publicKeyPem, key.authority, key.keyId);
|
|
1025
|
+
return { verify: (value, plan) => {
|
|
1026
|
+
const { signatureDigest: _signatureDigest, ...signed } = value;
|
|
1027
|
+
return authority.verify(signed, plan);
|
|
1028
|
+
} };
|
|
1029
|
+
}
|
|
1030
|
+
async function prepareRelease(store, trust, plan) {
|
|
1031
|
+
assertSourceReleaseTrust(plan, trust);
|
|
1032
|
+
const expected = expectedSourceRelease(plan.status);
|
|
1033
|
+
if (expected === undefined)
|
|
1034
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source plan is not awaiting a release phase');
|
|
1035
|
+
const adapter = trust.releaseAdapters?.[expected.phase];
|
|
1036
|
+
if (adapter === undefined)
|
|
1037
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', `no owner-configured adapter is registered for ${expected.phase}`);
|
|
1038
|
+
const adapterIdentity = { id: adapter.id, version: adapter.version, path: adapter.path, sha256: adapter.sha256,
|
|
1039
|
+
interpreter: adapter.interpreter, authority: adapter.authority, keyId: adapter.keyId };
|
|
1040
|
+
let catalog = { id: trust.catalog.id, path: trust.catalog.path };
|
|
1041
|
+
if (expected.phase === 'catalog-admission') {
|
|
1042
|
+
const loaded = await loadCatalogWithMetadata(trust.catalog.path);
|
|
1043
|
+
const preview = previewCatalogAdmission(loaded.catalog, store.sourceReleaseCandidate(plan.id));
|
|
1044
|
+
catalog = { ...catalog, expectedBeforeDigest: preview.beforeCatalogDigest, expectedAfterDigest: preview.afterCatalogDigest };
|
|
1045
|
+
}
|
|
1046
|
+
return store.prepareSourceReleaseOperation({ planId: plan.id, expectedRevision: plan.revision, expectedFence: plan.release.fence,
|
|
1047
|
+
installationId: trust.installationId, ledger: trust.ledger, registry: trust.releaseRegistry, catalog, adapter: adapterIdentity,
|
|
1048
|
+
receiptTtlMs: trust.releaseReceiptTtlMs, resolveAuthorizationAuthority: value => releaseAuthorizationAuthority(trust, value) });
|
|
1049
|
+
}
|
|
1050
|
+
async function releaseStart(argv) {
|
|
1051
|
+
assertReleaseCommand(argv);
|
|
1052
|
+
const trust = await commandTrust(argv);
|
|
1053
|
+
const store = new ControlPlaneStore({ path: trust.ledger.path });
|
|
1054
|
+
try {
|
|
1055
|
+
const authorization = parseSourceReleaseAuthorization(JSON.parse(await readOwnerPrivateFile(resolve(option(argv, '--authorization')), 65_536)));
|
|
1056
|
+
const result = await store.startSourceRelease({ planId: option(argv, '--plan-id'), expectedRevision: integerOption(argv, '--expected-revision'),
|
|
1057
|
+
authorization, resolveAuthority: value => releaseAuthorizationAuthority(trust, value),
|
|
1058
|
+
idempotencyKey: `source-release-authorization:${authorization.authorizationId}` });
|
|
1059
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
1060
|
+
}
|
|
1061
|
+
finally {
|
|
1062
|
+
store.close();
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
async function releaseRequest(argv) {
|
|
1066
|
+
assertReleaseCommand(argv);
|
|
1067
|
+
const trust = await commandTrust(argv);
|
|
1068
|
+
const store = new ControlPlaneStore({ path: trust.ledger.path });
|
|
1069
|
+
try {
|
|
1070
|
+
const plan = store.getSourcePlan(option(argv, '--plan-id'));
|
|
1071
|
+
if (plan.revision !== integerOption(argv, '--expected-revision') || plan.release?.fence !== integerOption(argv, '--expected-fence')) {
|
|
1072
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'release request targets a stale revision/fence');
|
|
1073
|
+
}
|
|
1074
|
+
process.stdout.write(`${JSON.stringify((await prepareRelease(store, trust, plan)).request)}\n`);
|
|
1075
|
+
}
|
|
1076
|
+
finally {
|
|
1077
|
+
store.close();
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
async function releaseStep(argv) {
|
|
1081
|
+
assertReleaseCommand(argv);
|
|
1082
|
+
const trust = await commandTrust(argv);
|
|
1083
|
+
const store = new ControlPlaneStore({ path: trust.ledger.path });
|
|
1084
|
+
try {
|
|
1085
|
+
const plan = store.getSourcePlan(option(argv, '--plan-id'));
|
|
1086
|
+
const expectedRevision = integerOption(argv, '--expected-revision');
|
|
1087
|
+
const expectedFence = integerOption(argv, '--expected-fence');
|
|
1088
|
+
if (plan.revision !== expectedRevision || plan.release?.fence !== expectedFence)
|
|
1089
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'release step targets a stale revision/fence');
|
|
1090
|
+
const operation = await prepareRelease(store, trust, plan);
|
|
1091
|
+
const receipt = await store.runSourceReleaseOperation({ operationId: operation.operationId, expectedRevision, expectedFence,
|
|
1092
|
+
execute: request => invokeSourceReleaseAdapter(trust, request), resolveAuthority: value => releaseAuthority(trust, value),
|
|
1093
|
+
resolveAuthorizationAuthority: value => releaseAuthorizationAuthority(trust, value) });
|
|
1094
|
+
const result = await store.applySourceRelease({ planId: plan.id, expectedRevision, expectedFence, receipt,
|
|
1095
|
+
resolveAuthority: value => releaseAuthority(trust, value), idempotencyKey: `source-release:${operation.operationId}` });
|
|
1096
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
1097
|
+
}
|
|
1098
|
+
finally {
|
|
1099
|
+
store.close();
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
async function releaseAttest(argv) {
|
|
1103
|
+
assertReleaseCommand(argv);
|
|
1104
|
+
const trust = await commandTrust(argv);
|
|
1105
|
+
const store = new ControlPlaneStore({ path: trust.ledger.path });
|
|
1106
|
+
try {
|
|
1107
|
+
const plan = store.getSourcePlan(option(argv, '--plan-id'));
|
|
1108
|
+
const expectedRevision = integerOption(argv, '--expected-revision');
|
|
1109
|
+
const expectedFence = integerOption(argv, '--expected-fence');
|
|
1110
|
+
if (plan.revision !== expectedRevision || plan.release?.fence !== expectedFence)
|
|
1111
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'release receipt targets a stale revision/fence');
|
|
1112
|
+
const operation = await prepareRelease(store, trust, plan);
|
|
1113
|
+
const receipt = parseSourceReleaseReceipt(JSON.parse(await readOwnerPrivateFile(resolve(option(argv, '--receipt')), 262_144)));
|
|
1114
|
+
if (receipt.operationId !== operation.operationId)
|
|
1115
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'release receipt does not target the durable operation');
|
|
1116
|
+
await store.runSourceReleaseOperation({ operationId: operation.operationId, expectedRevision, expectedFence, execute: async () => receipt,
|
|
1117
|
+
resolveAuthority: value => releaseAuthority(trust, value),
|
|
1118
|
+
resolveAuthorizationAuthority: value => releaseAuthorizationAuthority(trust, value) });
|
|
1119
|
+
const result = await store.applySourceRelease({ planId: plan.id, expectedRevision, expectedFence, receipt,
|
|
1120
|
+
resolveAuthority: value => releaseAuthority(trust, value), idempotencyKey: `source-release:${operation.operationId}` });
|
|
1121
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
1122
|
+
}
|
|
1123
|
+
finally {
|
|
1124
|
+
store.close();
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
async function releaseReconcile(argv) {
|
|
1128
|
+
assertReleaseCommand(argv);
|
|
1129
|
+
if (argv.includes('--observation'))
|
|
1130
|
+
throw new ControlPlaneCliError('INVALID_ARGUMENT', 'unsigned registry observations are forbidden');
|
|
1131
|
+
const trust = await commandTrust(argv);
|
|
1132
|
+
const store = new ControlPlaneStore({ path: trust.ledger.path });
|
|
1133
|
+
try {
|
|
1134
|
+
const plan = store.getSourcePlan(option(argv, '--plan-id'));
|
|
1135
|
+
const expectedRevision = integerOption(argv, '--expected-revision');
|
|
1136
|
+
const expectedFence = integerOption(argv, '--expected-fence');
|
|
1137
|
+
assertSourceReleaseTrust(plan, trust);
|
|
1138
|
+
if (plan.revision !== expectedRevision || plan.release?.fence !== expectedFence || plan.status !== 'publish-ambiguous') {
|
|
1139
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'publish reconciliation targets a stale or non-ambiguous release');
|
|
1140
|
+
}
|
|
1141
|
+
const adapter = trust.releaseAdapters?.['registry-verify'];
|
|
1142
|
+
if (adapter === undefined)
|
|
1143
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'publish reconciliation requires the independent registry verifier');
|
|
1144
|
+
const adapterIdentity = { id: adapter.id, version: adapter.version, path: adapter.path, sha256: adapter.sha256,
|
|
1145
|
+
interpreter: adapter.interpreter, authority: adapter.authority, keyId: adapter.keyId };
|
|
1146
|
+
const operation = await store.prepareSourcePublishReconciliation({ planId: plan.id, expectedRevision, expectedFence,
|
|
1147
|
+
installationId: trust.installationId, ledger: trust.ledger, registry: trust.releaseRegistry, adapter: adapterIdentity,
|
|
1148
|
+
receiptTtlMs: trust.releaseReceiptTtlMs, resolveAuthorizationAuthority: value => releaseAuthorizationAuthority(trust, value) });
|
|
1149
|
+
const receiptPath = optionalOption(argv, '--receipt');
|
|
1150
|
+
const supplied = receiptPath === undefined ? undefined : parseSourcePublishReconciliationReceipt(JSON.parse(await readOwnerPrivateFile(resolve(receiptPath), 262_144)));
|
|
1151
|
+
if (supplied !== undefined && supplied.operationId !== operation.operationId) {
|
|
1152
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'publish reconciliation receipt does not target the durable operation');
|
|
1153
|
+
}
|
|
1154
|
+
const receipt = await store.runSourcePublishReconciliation({ operationId: operation.operationId, expectedRevision, expectedFence,
|
|
1155
|
+
execute: supplied === undefined ? request => invokeSourcePublishReconciliationAdapter(trust, request) : async () => supplied,
|
|
1156
|
+
resolveAuthority: value => reconciliationAuthority(trust, value),
|
|
1157
|
+
resolveAuthorizationAuthority: value => releaseAuthorizationAuthority(trust, value) });
|
|
1158
|
+
const result = await store.reconcileSourcePublish({ planId: plan.id, expectedRevision, expectedFence, receipt,
|
|
1159
|
+
resolveAuthority: value => reconciliationAuthority(trust, value), idempotencyKey: `source-publish-reconciliation:${operation.operationId}` });
|
|
1160
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
1161
|
+
}
|
|
1162
|
+
finally {
|
|
1163
|
+
store.close();
|
|
1164
|
+
}
|
|
195
1165
|
}
|
|
196
1166
|
export async function runPluginControl(argv = process.argv.slice(2)) {
|
|
1167
|
+
rejectCommandSuppliedTrust(argv);
|
|
197
1168
|
const command = argv[0];
|
|
198
1169
|
if (command === 'discover') {
|
|
199
|
-
const
|
|
200
|
-
process.stdout.write(`${JSON.stringify(discover(catalog, option(argv, '--capability'))
|
|
1170
|
+
const loaded = await loadCatalogWithMetadata(resolve(optionalOption(argv, '--catalog') ?? defaultCatalogPath()));
|
|
1171
|
+
process.stdout.write(`${JSON.stringify({ provenance: loaded.provenance, digest: loaded.digest, candidates: discover(loaded.catalog, option(argv, '--capability')) })}\n`);
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
if (command === 'show') {
|
|
1175
|
+
const trust = await commandTrust(argv);
|
|
1176
|
+
const store = new ControlPlaneStore({ path: trust.ledger.path });
|
|
1177
|
+
try {
|
|
1178
|
+
process.stdout.write(`${JSON.stringify(option(argv, '--kind') === 'source' ? store.getSourcePlan(option(argv, '--plan-id')) : store.getPlan(option(argv, '--plan-id')))}\n`);
|
|
1179
|
+
}
|
|
1180
|
+
finally {
|
|
1181
|
+
store.close();
|
|
1182
|
+
}
|
|
201
1183
|
return;
|
|
202
1184
|
}
|
|
203
1185
|
if (command === 'approve')
|
|
204
1186
|
return approve(argv);
|
|
205
1187
|
if (command === 'activate')
|
|
206
1188
|
return activate(argv);
|
|
1189
|
+
if (command === 'host-request')
|
|
1190
|
+
return hostRequest(argv);
|
|
1191
|
+
if (command === 'probe')
|
|
1192
|
+
return probe(argv);
|
|
1193
|
+
if (command === 'attest')
|
|
1194
|
+
return attest(argv);
|
|
1195
|
+
if (command === 'source-plan')
|
|
1196
|
+
return sourcePlan(argv);
|
|
207
1197
|
if (command === 'scaffold')
|
|
208
1198
|
return scaffold(argv);
|
|
209
|
-
if (command === '
|
|
210
|
-
return
|
|
211
|
-
|
|
1199
|
+
if (command === 'release-start')
|
|
1200
|
+
return releaseStart(argv);
|
|
1201
|
+
if (command === 'release-request')
|
|
1202
|
+
return releaseRequest(argv);
|
|
1203
|
+
if (command === 'release-step')
|
|
1204
|
+
return releaseStep(argv);
|
|
1205
|
+
if (command === 'release-attest')
|
|
1206
|
+
return releaseAttest(argv);
|
|
1207
|
+
if (command === 'release-reconcile')
|
|
1208
|
+
return releaseReconcile(argv);
|
|
1209
|
+
throw new ControlPlaneCliError('INVALID_ARGUMENT', 'usage: dsh-plugin-control <discover|show|approve|activate|host-request|probe|attest|source-plan|scaffold|release-start|release-request|release-step|release-attest|release-reconcile>');
|
|
212
1210
|
}
|
|
213
1211
|
//# sourceMappingURL=cli.js.map
|