@dsh-enhanced/plugin-control-plane 0.1.32 → 0.1.34
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 +352 -4
- package/bin/dsh-local-release-adapter.js +80 -16
- package/bin/dsh-npm-registry-adapter.js +613 -0
- package/bin/dsh-source-adoption-authority.js +12 -0
- package/bin/dsh-source-approval-authority.js +16 -0
- package/bin/dsh-source-release-authority.js +12 -0
- package/bin/dsh-systemd-host-attestor.js +707 -0
- package/bin/dsh-task-observation-authority.js +15 -0
- package/lib/adapter-process.d.ts +23 -0
- package/lib/adapter-process.d.ts.map +1 -0
- package/lib/adapter-process.js +305 -0
- package/lib/adapter-process.js.map +1 -0
- package/lib/adoption-coordinator.d.ts +63 -0
- package/lib/adoption-coordinator.d.ts.map +1 -0
- package/lib/adoption-coordinator.js +241 -0
- package/lib/adoption-coordinator.js.map +1 -0
- package/lib/adoption-handoff.d.ts +16 -0
- package/lib/adoption-handoff.d.ts.map +1 -0
- package/lib/adoption-handoff.js +11 -0
- package/lib/adoption-handoff.js.map +1 -0
- package/lib/attestation.d.ts.map +1 -1
- package/lib/attestation.js +49 -1
- package/lib/attestation.js.map +1 -1
- package/lib/catalog.d.ts.map +1 -1
- package/lib/catalog.js +29 -2
- package/lib/catalog.js.map +1 -1
- package/lib/cli.d.ts +33 -9
- package/lib/cli.d.ts.map +1 -1
- package/lib/cli.js +624 -178
- package/lib/cli.js.map +1 -1
- package/lib/deployment-readiness.d.ts +38 -0
- package/lib/deployment-readiness.d.ts.map +1 -0
- package/lib/deployment-readiness.js +154 -0
- package/lib/deployment-readiness.js.map +1 -0
- package/lib/effect-blocked-replay.d.ts +81 -0
- package/lib/effect-blocked-replay.d.ts.map +1 -0
- package/lib/effect-blocked-replay.js +259 -0
- package/lib/effect-blocked-replay.js.map +1 -0
- package/lib/errors.d.ts +6 -0
- package/lib/errors.d.ts.map +1 -0
- package/lib/errors.js +9 -0
- package/lib/errors.js.map +1 -0
- package/lib/foreground-deployment-runtime.d.ts +20 -0
- package/lib/foreground-deployment-runtime.d.ts.map +1 -0
- package/lib/foreground-deployment-runtime.js +79 -0
- package/lib/foreground-deployment-runtime.js.map +1 -0
- package/lib/foreground-deployment.d.ts +17 -0
- package/lib/foreground-deployment.d.ts.map +1 -0
- package/lib/foreground-deployment.js +43 -0
- package/lib/foreground-deployment.js.map +1 -0
- package/lib/host-attestor.d.ts +1 -1
- package/lib/host-attestor.d.ts.map +1 -1
- package/lib/host-attestor.js +51 -57
- package/lib/host-attestor.js.map +1 -1
- package/lib/index.d.ts +25 -2
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +11 -2
- package/lib/index.js.map +1 -1
- package/lib/npm-publish.d.ts +25 -0
- package/lib/npm-publish.d.ts.map +1 -0
- package/lib/npm-publish.js +280 -0
- package/lib/npm-publish.js.map +1 -0
- package/lib/owner-task-gap-types.d.ts +21 -0
- package/lib/owner-task-gap-types.d.ts.map +1 -0
- package/lib/owner-task-gap-types.js +2 -0
- package/lib/owner-task-gap-types.js.map +1 -0
- package/lib/owner-task-gaps.d.ts +24 -0
- package/lib/owner-task-gaps.d.ts.map +1 -0
- package/lib/owner-task-gaps.js +74 -0
- package/lib/owner-task-gaps.js.map +1 -0
- package/lib/post-activation.d.ts +33 -0
- package/lib/post-activation.d.ts.map +1 -0
- package/lib/post-activation.js +219 -0
- package/lib/post-activation.js.map +1 -0
- package/lib/registry-fetch.d.ts +50 -0
- package/lib/registry-fetch.d.ts.map +1 -0
- package/lib/registry-fetch.js +267 -0
- package/lib/registry-fetch.js.map +1 -0
- package/lib/release.d.ts +4 -4
- package/lib/release.d.ts.map +1 -1
- package/lib/release.js +142 -56
- package/lib/release.js.map +1 -1
- package/lib/replay-endpoint-protocol.d.ts +65 -0
- package/lib/replay-endpoint-protocol.d.ts.map +1 -0
- package/lib/replay-endpoint-protocol.js +183 -0
- package/lib/replay-endpoint-protocol.js.map +1 -0
- package/lib/replay-endpoint.d.ts +7 -0
- package/lib/replay-endpoint.d.ts.map +1 -0
- package/lib/replay-endpoint.js +193 -0
- package/lib/replay-endpoint.js.map +1 -0
- package/lib/replay-grant.d.ts +62 -0
- package/lib/replay-grant.d.ts.map +1 -0
- package/lib/replay-grant.js +208 -0
- package/lib/replay-grant.js.map +1 -0
- package/lib/replay-journal.d.ts +25 -0
- package/lib/replay-journal.d.ts.map +1 -0
- package/lib/replay-journal.js +275 -0
- package/lib/replay-journal.js.map +1 -0
- package/lib/runtime-observer-protocol.d.ts +63 -0
- package/lib/runtime-observer-protocol.d.ts.map +1 -0
- package/lib/runtime-observer-protocol.js +280 -0
- package/lib/runtime-observer-protocol.js.map +1 -0
- package/lib/runtime-observer.d.ts +8 -0
- package/lib/runtime-observer.d.ts.map +1 -0
- package/lib/runtime-observer.js +182 -0
- package/lib/runtime-observer.js.map +1 -0
- package/lib/service.d.ts +145 -1
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +734 -6
- package/lib/service.js.map +1 -1
- package/lib/source-adoption-authority.d.ts +65 -0
- package/lib/source-adoption-authority.d.ts.map +1 -0
- package/lib/source-adoption-authority.js +271 -0
- package/lib/source-adoption-authority.js.map +1 -0
- package/lib/source-adoption-runner.d.ts +28 -0
- package/lib/source-adoption-runner.d.ts.map +1 -0
- package/lib/source-adoption-runner.js +197 -0
- package/lib/source-adoption-runner.js.map +1 -0
- package/lib/source-approval-authority.d.ts +50 -0
- package/lib/source-approval-authority.d.ts.map +1 -0
- package/lib/source-approval-authority.js +412 -0
- package/lib/source-approval-authority.js.map +1 -0
- package/lib/source-approval-client.d.ts +32 -0
- package/lib/source-approval-client.d.ts.map +1 -0
- package/lib/source-approval-client.js +175 -0
- package/lib/source-approval-client.js.map +1 -0
- package/lib/source-build.d.ts +60 -0
- package/lib/source-build.d.ts.map +1 -0
- package/lib/source-build.js +311 -0
- package/lib/source-build.js.map +1 -0
- package/lib/source-context.d.ts +24 -0
- package/lib/source-context.d.ts.map +1 -0
- package/lib/source-context.js +99 -0
- package/lib/source-context.js.map +1 -0
- package/lib/source-job-types.d.ts +87 -0
- package/lib/source-job-types.d.ts.map +1 -0
- package/lib/source-job-types.js +2 -0
- package/lib/source-job-types.js.map +1 -0
- package/lib/source-jobs.d.ts +113 -0
- package/lib/source-jobs.d.ts.map +1 -0
- package/lib/source-jobs.js +535 -0
- package/lib/source-jobs.js.map +1 -0
- package/lib/source-release-authority.d.ts +35 -0
- package/lib/source-release-authority.d.ts.map +1 -0
- package/lib/source-release-authority.js +271 -0
- package/lib/source-release-authority.js.map +1 -0
- package/lib/source-release-client.d.ts +10 -0
- package/lib/source-release-client.d.ts.map +1 -0
- package/lib/source-release-client.js +24 -0
- package/lib/source-release-client.js.map +1 -0
- package/lib/source-release-runner.d.ts +31 -0
- package/lib/source-release-runner.d.ts.map +1 -0
- package/lib/source-release-runner.js +125 -0
- package/lib/source-release-runner.js.map +1 -0
- package/lib/source-versioning.d.ts +24 -0
- package/lib/source-versioning.d.ts.map +1 -0
- package/lib/source-versioning.js +213 -0
- package/lib/source-versioning.js.map +1 -0
- package/lib/source-workspace.d.ts +117 -0
- package/lib/source-workspace.d.ts.map +1 -0
- package/lib/source-workspace.js +458 -0
- package/lib/source-workspace.js.map +1 -0
- package/lib/sqlite.d.ts +1 -1
- package/lib/sqlite.d.ts.map +1 -1
- package/lib/sqlite.js +509 -7
- package/lib/sqlite.js.map +1 -1
- package/lib/store.d.ts +229 -6
- package/lib/store.d.ts.map +1 -1
- package/lib/store.js +2431 -383
- package/lib/store.js.map +1 -1
- package/lib/task-observation-authority.d.ts +29 -0
- package/lib/task-observation-authority.d.ts.map +1 -0
- package/lib/task-observation-authority.js +370 -0
- package/lib/task-observation-authority.js.map +1 -0
- package/lib/task-observation-client.d.ts +4 -0
- package/lib/task-observation-client.d.ts.map +1 -0
- package/lib/task-observation-client.js +20 -0
- package/lib/task-observation-client.js.map +1 -0
- package/lib/task-observation-runtime.d.ts +59 -0
- package/lib/task-observation-runtime.d.ts.map +1 -0
- package/lib/task-observation-runtime.js +331 -0
- package/lib/task-observation-runtime.js.map +1 -0
- package/lib/task-observation-store.d.ts +17 -0
- package/lib/task-observation-store.d.ts.map +1 -0
- package/lib/task-observation-store.js +115 -0
- package/lib/task-observation-store.js.map +1 -0
- package/lib/task-observation-types.d.ts +67 -0
- package/lib/task-observation-types.d.ts.map +1 -0
- package/lib/task-observation-types.js +2 -0
- package/lib/task-observation-types.js.map +1 -0
- package/lib/trust.d.ts +3 -0
- 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 +275 -8
- package/lib/types.d.ts.map +1 -1
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +40 -5
package/lib/cli.js
CHANGED
|
@@ -1,30 +1,41 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { spawn } from 'node:child_process';
|
|
3
2
|
import { constants as fsConstants, lstatSync } from 'node:fs';
|
|
4
|
-
import { cp, lstat, mkdir,
|
|
5
|
-
import {
|
|
6
|
-
import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { cp, lstat, mkdir, open, readFile, realpath, rename, rm, stat } from 'node:fs/promises';
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
7
5
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
6
|
import { Ed25519ApprovalAuthority, loadPrivateApprovalInput, parseApprovalReceipt } from './approval.js';
|
|
9
7
|
import { Ed25519HostAttestationAuthority, parseHostAttestationReceipt } from './attestation.js';
|
|
10
8
|
import { invokeConfiguredHostAttestor, prepareConfiguredHostAttestation, prepareManualHostAttestation } from './host-attestor.js';
|
|
9
|
+
import { Ed25519ActivationRetractionAuthority, Ed25519PostActivationObservationAuthority, parseActivationRetraction, parsePostActivationObservation } from './post-activation.js';
|
|
11
10
|
import { discover, loadCatalogWithMetadata, previewCatalogAdmission } from './catalog.js';
|
|
11
|
+
import { fetchRegistryArtifact, RegistryFetchError } from './registry-fetch.js';
|
|
12
12
|
import { verifyApprovedPackagesInLockfile } from './lockfile.js';
|
|
13
13
|
import { Ed25519SourcePublishReconciliationAuthority, Ed25519SourceReleaseAuthority, Ed25519SourceReleaseAuthorizationAuthority, invokeSourcePublishReconciliationAdapter, invokeSourceReleaseAdapter, parseSourcePublishReconciliationReceipt, parseSourceReleaseAuthorization, parseSourceReleaseReceipt } from './release.js';
|
|
14
|
-
import { ControlPlaneStore, expectedSourceRelease } from './store.js';
|
|
14
|
+
import { ControlPlaneStore, controlPlaneDigest, expectedSourceRelease } from './store.js';
|
|
15
|
+
import { ControlPlaneCliError } from './errors.js';
|
|
16
|
+
import { ControlledProcessError, executeControlledProcess } from './adapter-process.js';
|
|
17
|
+
import { changedSourcePaths, checkedSourceSnapshot, gcPreparedModifyWorktrees, runLocalCommand, sourcePathAllowed, verifyPreparedSourceWorktree } from './source-workspace.js';
|
|
15
18
|
import { inheritedEnvironment, loadTrustConfig, openTrustedExecutable, resolveTrustKey, verifyOpenTrustedExecutable } from './trust.js';
|
|
16
19
|
const pluginPattern = /^(?=.{1,64}$)[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
|
17
20
|
const leaseMs = 30_000;
|
|
18
21
|
const pluginCatalogScope = 'plugins/README.md';
|
|
19
22
|
const maximumActivationArtifactBytes = 268_435_456;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
class ActivationCancelledError extends Error {
|
|
24
|
+
constructor() { super('plugin activation was cancelled'); this.name = 'ActivationCancelledError'; }
|
|
25
|
+
}
|
|
26
|
+
function throwIfAborted(signal) {
|
|
27
|
+
if (signal?.aborted)
|
|
28
|
+
throw new ActivationCancelledError();
|
|
29
|
+
}
|
|
30
|
+
async function checked(signal, work) {
|
|
31
|
+
throwIfAborted(signal);
|
|
32
|
+
const value = await work();
|
|
33
|
+
throwIfAborted(signal);
|
|
34
|
+
return value;
|
|
27
35
|
}
|
|
36
|
+
// checkedSourceSnapshot stays importable from this module for the CLI test
|
|
37
|
+
// suite; the implementation now lives in source-workspace.ts.
|
|
38
|
+
export { checkedSourceSnapshot };
|
|
28
39
|
function option(argv, name) {
|
|
29
40
|
const index = argv.indexOf(name);
|
|
30
41
|
const value = index === -1 ? undefined : argv[index + 1];
|
|
@@ -109,6 +120,59 @@ async function readSafeFile(path, maximum) {
|
|
|
109
120
|
}
|
|
110
121
|
return readFile(path, 'utf8');
|
|
111
122
|
}
|
|
123
|
+
const rollbackCoreFiles = ['package.json', 'pnpm-lock.yaml', 'cordis.patch.yml'];
|
|
124
|
+
async function captureRollbackBaseline(plan) {
|
|
125
|
+
if (!plan.activation?.targetOriginallyExisted)
|
|
126
|
+
return [];
|
|
127
|
+
return Promise.all(rollbackCoreFiles.map(async (name) => {
|
|
128
|
+
const path = join(plan.target.profilePath, name);
|
|
129
|
+
let observed = false;
|
|
130
|
+
try {
|
|
131
|
+
const pathname = await lstat(path, { bigint: true });
|
|
132
|
+
observed = true;
|
|
133
|
+
const uid = process.getuid?.();
|
|
134
|
+
if (!pathname.isFile() || pathname.nlink !== 1n || pathname.size > 16n * 1024n * 1024n || (pathname.mode & 18n) !== 0n
|
|
135
|
+
|| (uid !== undefined && pathname.uid !== 0n && pathname.uid !== BigInt(uid)) || await realpath(path) !== path) {
|
|
136
|
+
throw new ControlPlaneCliError('FILESYSTEM_STATE', 'rollback baseline file is unsafe');
|
|
137
|
+
}
|
|
138
|
+
const handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
|
|
139
|
+
try {
|
|
140
|
+
const before = await handle.stat({ bigint: true });
|
|
141
|
+
if (!before.isFile() || before.dev !== pathname.dev || before.ino !== pathname.ino || before.size !== pathname.size
|
|
142
|
+
|| before.mtimeNs !== pathname.mtimeNs || before.ctimeNs !== pathname.ctimeNs) {
|
|
143
|
+
throw new ControlPlaneCliError('FILESYSTEM_STATE', 'rollback baseline pathname changed');
|
|
144
|
+
}
|
|
145
|
+
const bytes = await handle.readFile();
|
|
146
|
+
const after = await handle.stat({ bigint: true });
|
|
147
|
+
const finalPath = await lstat(path, { bigint: true });
|
|
148
|
+
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeNs !== after.mtimeNs
|
|
149
|
+
|| before.ctimeNs !== after.ctimeNs || BigInt(bytes.length) !== before.size || before.dev !== finalPath.dev
|
|
150
|
+
|| before.ino !== finalPath.ino || await realpath(path) !== path) {
|
|
151
|
+
throw new ControlPlaneCliError('FILESYSTEM_STATE', 'rollback baseline file changed during read');
|
|
152
|
+
}
|
|
153
|
+
return { path, sha256: createHash('sha256').update(bytes).digest('hex') };
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
await handle.close();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
if (!observed && typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT')
|
|
161
|
+
return { path, sha256: null };
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
async function verifyRollbackBaseline(plan) {
|
|
167
|
+
const baseline = plan.activation?.targetBaselineFiles;
|
|
168
|
+
if (baseline === undefined || baseline.length !== (plan.activation?.targetOriginallyExisted ? 3 : 0)) {
|
|
169
|
+
throw new ControlPlaneCliError('FILESYSTEM_STATE', 'activation rollback baseline is missing');
|
|
170
|
+
}
|
|
171
|
+
const actual = await captureRollbackBaseline(plan);
|
|
172
|
+
if (JSON.stringify(actual) !== JSON.stringify(baseline)) {
|
|
173
|
+
throw new ControlPlaneCliError('FILESYSTEM_STATE', 'restored profile does not match its durable rollback baseline');
|
|
174
|
+
}
|
|
175
|
+
}
|
|
112
176
|
async function readOwnerPrivateFile(path, maximum) {
|
|
113
177
|
const value = await lstat(path);
|
|
114
178
|
const uid = process.getuid?.();
|
|
@@ -214,6 +278,34 @@ function assertPlanTrust(plan, trust) {
|
|
|
214
278
|
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'plan does not match the registered installation, ledger, target, and executor');
|
|
215
279
|
}
|
|
216
280
|
}
|
|
281
|
+
const profilePattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;
|
|
282
|
+
// Mirrors the service-layer canonical target rule: the profiles directory and
|
|
283
|
+
// the target profile must be one canonical non-symlinked directory tree under
|
|
284
|
+
// the owner-bound DSH_HOME (a still-missing profile is accepted only when its
|
|
285
|
+
// parent directory is itself canonical).
|
|
286
|
+
async function canonicalProfileTarget(trust, profile) {
|
|
287
|
+
if (!profilePattern.test(profile) || profile.normalize('NFC').trim() !== profile) {
|
|
288
|
+
throw new ControlPlaneCliError('INVALID_ARGUMENT', 'profile must already be bounded canonical text');
|
|
289
|
+
}
|
|
290
|
+
const profiles = join(trust.dshHome, 'profiles');
|
|
291
|
+
if (await realpath(profiles) !== resolve(profiles))
|
|
292
|
+
throw new ControlPlaneCliError('FILESYSTEM_STATE', 'profiles directory is not canonical');
|
|
293
|
+
const profilePath = join(profiles, profile);
|
|
294
|
+
try {
|
|
295
|
+
const metadata = await lstat(profilePath);
|
|
296
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink() || await realpath(profilePath) !== resolve(profilePath)) {
|
|
297
|
+
throw new ControlPlaneCliError('FILESYSTEM_STATE', 'target profile must be a canonical directory');
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
if (!(typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT'))
|
|
302
|
+
throw error;
|
|
303
|
+
if (await realpath(dirname(profilePath)) !== resolve(dirname(profilePath)) || basename(profilePath) !== profile) {
|
|
304
|
+
throw new ControlPlaneCliError('FILESYSTEM_STATE', 'missing target profile parent is not canonical');
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return Object.freeze({ dshHome: trust.dshHome, profile, profilePath });
|
|
308
|
+
}
|
|
217
309
|
async function openCurrentTrustedExecutable(path, trustedRunningNode) {
|
|
218
310
|
const canonical = await realpath(path);
|
|
219
311
|
const metadata = await lstat(canonical);
|
|
@@ -288,16 +380,25 @@ async function executorInterpreter(executable) {
|
|
|
288
380
|
}
|
|
289
381
|
async function runBounded(input) {
|
|
290
382
|
const execute = async () => {
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
383
|
+
const activationPlan = input.plan;
|
|
384
|
+
let ownedExecutable;
|
|
385
|
+
let executable;
|
|
294
386
|
let interpreter = input.pinnedInterpreter;
|
|
295
387
|
let result = '';
|
|
296
388
|
let failure;
|
|
297
389
|
try {
|
|
298
|
-
|
|
390
|
+
throwIfAborted(input.signal);
|
|
391
|
+
if (activationPlan !== undefined && input.pinnedExecutable === undefined) {
|
|
392
|
+
ownedExecutable = await openTrustedExecutable(input.executable, activationPlan.executor.sha256);
|
|
393
|
+
throwIfAborted(input.signal);
|
|
394
|
+
}
|
|
395
|
+
executable = input.pinnedExecutable ?? ownedExecutable;
|
|
396
|
+
if (executable !== undefined && interpreter === undefined) {
|
|
299
397
|
interpreter = await executorInterpreter(executable);
|
|
300
|
-
|
|
398
|
+
throwIfAborted(input.signal);
|
|
399
|
+
}
|
|
400
|
+
throwIfAborted(input.signal);
|
|
401
|
+
result = await executeControlledProcess((() => {
|
|
301
402
|
let command = input.executable;
|
|
302
403
|
let stdio = ['ignore', 'pipe', 'ignore'];
|
|
303
404
|
if (executable !== undefined) {
|
|
@@ -316,36 +417,11 @@ async function runBounded(input) {
|
|
|
316
417
|
if (executable !== undefined && interpreter !== undefined) {
|
|
317
418
|
commandArguments = [...interpreter.arguments, `/proc/self/fd/${3}`, ...commandArguments];
|
|
318
419
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
});
|
|
420
|
+
return { command, args: commandArguments, env: input.environment, stdio,
|
|
421
|
+
stdin: '', timeoutMs: 60_000, maximumOutput: input.maximumOutput ?? 65_536,
|
|
422
|
+
...(input.signal === undefined ? {} : { signal: input.signal }) };
|
|
423
|
+
})());
|
|
424
|
+
throwIfAborted(input.signal);
|
|
349
425
|
}
|
|
350
426
|
catch (error) {
|
|
351
427
|
failure = error;
|
|
@@ -364,9 +440,20 @@ async function runBounded(input) {
|
|
|
364
440
|
await interpreter.executable.handle.close();
|
|
365
441
|
await ownedExecutable?.handle.close();
|
|
366
442
|
}
|
|
443
|
+
throwIfAborted(input.signal);
|
|
444
|
+
if (failure instanceof ControlledProcessError) {
|
|
445
|
+
if (failure.code === 'ABORTED')
|
|
446
|
+
throw new ActivationCancelledError();
|
|
447
|
+
if (failure.code === 'TIMEOUT')
|
|
448
|
+
throw new ControlPlaneCliError('EXECUTOR_TIMEOUT', 'registered executor exceeded its deadline');
|
|
449
|
+
if (failure.code === 'OUTPUT_LIMIT')
|
|
450
|
+
throw new ControlPlaneCliError('EXECUTOR_OUTPUT_LIMIT', 'registered executor exceeded its output bound');
|
|
451
|
+
throw new ControlPlaneCliError('EXECUTOR_FAILED', failure.code === 'NON_ZERO'
|
|
452
|
+
? 'registered executor returned a non-zero status' : 'registered executor could not start or be safely reclaimed');
|
|
453
|
+
}
|
|
367
454
|
if (failure !== undefined)
|
|
368
455
|
throw failure;
|
|
369
|
-
return result;
|
|
456
|
+
return input.capture ? result : '';
|
|
370
457
|
};
|
|
371
458
|
return input.store !== undefined && input.plan !== undefined
|
|
372
459
|
? input.store.withActivationFileSystemGuard({ planId: input.plan.id, expectedRevision: input.plan.revision,
|
|
@@ -482,6 +569,14 @@ async function snapshotLocalArtifact(plan, item) {
|
|
|
482
569
|
const integrity = `sha512-${createHash('sha512').update(bytes).digest('base64')}`;
|
|
483
570
|
if (integrity !== item.integrity)
|
|
484
571
|
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'local artifact bytes do not match the approved integrity');
|
|
572
|
+
return await pinActivationArtifactBytes(plan, item, registry, bytes);
|
|
573
|
+
}
|
|
574
|
+
// Shared terminus for both artifact source paths (a local file URL and a
|
|
575
|
+
// downloaded registry object): the approved bytes are materialized into an
|
|
576
|
+
// owner-private 0400 cache, then reopened through a pinned file descriptor so
|
|
577
|
+
// the package manager only ever reads /proc/self/fd/N while the control plane
|
|
578
|
+
// re-verifies the bytes after the executor returns.
|
|
579
|
+
async function pinActivationArtifactBytes(plan, item, registry, bytes) {
|
|
485
580
|
const cacheRoot = join(plan.target.dshHome, 'plugin-control', 'activation-artifacts');
|
|
486
581
|
await mkdir(cacheRoot, { recursive: true, mode: 0o700 });
|
|
487
582
|
await assertDirectory(cacheRoot);
|
|
@@ -554,12 +649,48 @@ async function snapshotLocalArtifact(plan, item) {
|
|
|
554
649
|
throw error;
|
|
555
650
|
}
|
|
556
651
|
}
|
|
557
|
-
|
|
652
|
+
// Remote counterpart of snapshotLocalArtifact. The catalog-approved package
|
|
653
|
+
// is fetched from the single owner-bound release registry over pinned TLS,
|
|
654
|
+
// authorized with a bearer token read from the owner process (never from the
|
|
655
|
+
// trust file or the executor allowlist), and accepted solely on equality with
|
|
656
|
+
// the catalog-approved sha512 integrity. The verified bytes then take the
|
|
657
|
+
// identical 0400-cache + file-descriptor path as a local artifact.
|
|
658
|
+
async function downloadRegistryArtifact(trust, plan, item) {
|
|
659
|
+
const registry = item.registry;
|
|
660
|
+
if (registry === undefined)
|
|
661
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'package registry binding is missing');
|
|
662
|
+
const bound = trust.releaseRegistry;
|
|
663
|
+
if (bound === undefined || bound.id !== registry.id || bound.locator !== registry.locator) {
|
|
664
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'package registry is not the owner-bound release registry');
|
|
665
|
+
}
|
|
666
|
+
let bytes;
|
|
667
|
+
try {
|
|
668
|
+
const fetched = await fetchRegistryArtifact({ registry: bound, packageName: item.package, version: item.version, expectedIntegrity: item.integrity }, process.env);
|
|
669
|
+
bytes = fetched.bytes;
|
|
670
|
+
if (registry.reference.startsWith('https:') && fetched.reference !== registry.reference) {
|
|
671
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'remote artifact reference does not match the approved exact HTTPS reference');
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
catch (error) {
|
|
675
|
+
if (error instanceof ControlPlaneCliError)
|
|
676
|
+
throw error;
|
|
677
|
+
if (error instanceof RegistryFetchError)
|
|
678
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', error.message);
|
|
679
|
+
throw error;
|
|
680
|
+
}
|
|
681
|
+
const integrity = `sha512-${createHash('sha512').update(bytes).digest('base64')}`;
|
|
682
|
+
if (integrity !== item.integrity)
|
|
683
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'remote artifact bytes do not match the approved integrity');
|
|
684
|
+
return await pinActivationArtifactBytes(plan, item, registry, bytes);
|
|
685
|
+
}
|
|
686
|
+
async function activationPackages(trust, plan) {
|
|
558
687
|
const snapshots = [];
|
|
559
688
|
try {
|
|
560
689
|
for (const item of exactPackages(plan)) {
|
|
561
690
|
if (localArtifactReference(item) !== undefined)
|
|
562
691
|
snapshots.push(await snapshotLocalArtifact(plan, item));
|
|
692
|
+
else if (item.registry !== undefined)
|
|
693
|
+
snapshots.push(await downloadRegistryArtifact(trust, plan, item));
|
|
563
694
|
}
|
|
564
695
|
const byPackage = new Map(snapshots.map(snapshot => [snapshot.package.package, snapshot]));
|
|
565
696
|
return { packages: exactPackages(plan).map(item => byPackage.get(item.package)?.package ?? item), snapshots };
|
|
@@ -641,12 +772,155 @@ function advance(store, plan, to, failureCode) {
|
|
|
641
772
|
}
|
|
642
773
|
async function finishRollback(store, plan, lock) {
|
|
643
774
|
const activationPaths = paths(plan);
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
775
|
+
const observed = plan.activation?.failureCode?.startsWith('post-activation-') === true;
|
|
776
|
+
if (observed) {
|
|
777
|
+
await restoreObservedTarget(store, plan);
|
|
778
|
+
}
|
|
779
|
+
else
|
|
780
|
+
await restoreTarget(store, plan, activationPaths.backupPath);
|
|
781
|
+
await fencedMutation(store, plan, () => verifyRollbackBaseline(plan));
|
|
782
|
+
if (!observed)
|
|
783
|
+
await fencedMutation(store, plan, () => rm(activationPaths.stagePath, { recursive: true, force: true }));
|
|
784
|
+
const terminal = plan.activation?.hostRecoveryRequired
|
|
785
|
+
? store.markRollbackProfileRestored({ planId: plan.id, expectedRevision: plan.revision, fence: plan.activation.fence })
|
|
786
|
+
: advance(store, plan, 'rolled-back');
|
|
787
|
+
if (observed)
|
|
788
|
+
await cleanupRestoredStage(store, terminal);
|
|
647
789
|
await releaseProfileLock(store, lock);
|
|
648
790
|
return terminal;
|
|
649
791
|
}
|
|
792
|
+
async function profileFilesAt(plan, path) {
|
|
793
|
+
await assertDirectory(path);
|
|
794
|
+
const files = await captureRollbackBaseline({ ...plan, target: { ...plan.target, profilePath: path },
|
|
795
|
+
activation: { ...plan.activation, targetOriginallyExisted: true } });
|
|
796
|
+
return files.map((file, index) => ({ path: join(plan.target.profilePath, rollbackCoreFiles[index]), sha256: file.sha256 }));
|
|
797
|
+
}
|
|
798
|
+
/** A closed quality watch may restore only the exact deployment it observed. */
|
|
799
|
+
async function restoreObservedTarget(store, plan) {
|
|
800
|
+
const { backupPath, stagePath } = paths(plan);
|
|
801
|
+
await fencedMutation(store, plan, async () => {
|
|
802
|
+
const installed = store.getActivationInstalledBaseline(plan.id);
|
|
803
|
+
const baseline = plan.activation?.targetBaselineFiles;
|
|
804
|
+
if (installed === undefined || baseline === undefined) {
|
|
805
|
+
throw new ControlPlaneCliError('FILESYSTEM_STATE', 'post-activation rollback checkpoints are unavailable');
|
|
806
|
+
}
|
|
807
|
+
const matches = async (path, expected) => {
|
|
808
|
+
if (JSON.stringify(await profileFilesAt(plan, path)) !== JSON.stringify(expected)) {
|
|
809
|
+
throw new ControlPlaneCliError('FILESYSTEM_STATE', 'post-activation profile files changed; rollback requires reconciliation');
|
|
810
|
+
}
|
|
811
|
+
};
|
|
812
|
+
const backupExists = await directoryExists(backupPath);
|
|
813
|
+
const targetExists = await directoryExists(plan.target.profilePath);
|
|
814
|
+
const staged = await directoryExists(stagePath);
|
|
815
|
+
// The old target is moved aside before its replacement. A crash between
|
|
816
|
+
// either rename can resume without deleting an unverified profile.
|
|
817
|
+
if (plan.activation.targetOriginallyExisted) {
|
|
818
|
+
if (!backupExists) {
|
|
819
|
+
if (!targetExists || !staged)
|
|
820
|
+
throw new ControlPlaneCliError('FILESYSTEM_STATE', 'retained rollback backup is missing');
|
|
821
|
+
await matches(plan.target.profilePath, baseline);
|
|
822
|
+
if (staged)
|
|
823
|
+
await matches(stagePath, installed);
|
|
824
|
+
await syncDirectory(join(plan.target.dshHome, 'profiles'));
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
await matches(backupPath, baseline);
|
|
828
|
+
}
|
|
829
|
+
else if (backupExists)
|
|
830
|
+
throw new ControlPlaneCliError('FILESYSTEM_STATE', 'unexpected rollback backup for an absent baseline');
|
|
831
|
+
if (staged)
|
|
832
|
+
await matches(stagePath, installed);
|
|
833
|
+
if (targetExists) {
|
|
834
|
+
await matches(plan.target.profilePath, installed);
|
|
835
|
+
if (staged)
|
|
836
|
+
throw new ControlPlaneCliError('FILESYSTEM_STATE', 'both rollback target and staged candidate exist');
|
|
837
|
+
await rename(plan.target.profilePath, stagePath);
|
|
838
|
+
}
|
|
839
|
+
else if (!staged) {
|
|
840
|
+
throw new ControlPlaneCliError('FILESYSTEM_STATE', 'observed deployment and recovery stage are both missing');
|
|
841
|
+
}
|
|
842
|
+
if (plan.activation.targetOriginallyExisted)
|
|
843
|
+
await rename(backupPath, plan.target.profilePath);
|
|
844
|
+
await syncDirectory(join(plan.target.dshHome, 'profiles'));
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
async function cleanupRestoredStage(store, plan) {
|
|
848
|
+
await store.withExclusiveWrite(async () => {
|
|
849
|
+
store.assertNoClaimedHostAttestation(plan.id);
|
|
850
|
+
const current = store.getPlan(plan.id);
|
|
851
|
+
if (!current.activation?.rollbackProfileRestored || current.activation.fence !== plan.activation?.fence) {
|
|
852
|
+
throw new ControlPlaneCliError('FILESYSTEM_STATE', 'rollback restoration marker changed before stage cleanup');
|
|
853
|
+
}
|
|
854
|
+
const { stagePath } = paths(current);
|
|
855
|
+
if (!await directoryExists(stagePath))
|
|
856
|
+
return;
|
|
857
|
+
const installed = store.getActivationInstalledBaseline(current.id);
|
|
858
|
+
if (installed === undefined || JSON.stringify(await profileFilesAt(current, stagePath)) !== JSON.stringify(installed)) {
|
|
859
|
+
throw new ControlPlaneCliError('FILESYSTEM_STATE', 'restored rollback stage changed; cleanup requires reconciliation');
|
|
860
|
+
}
|
|
861
|
+
await rm(stagePath, { recursive: true, force: true });
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
async function cleanupRetiredBackups(store, plan) {
|
|
865
|
+
await store.withExclusiveWrite(async () => {
|
|
866
|
+
for (const retired of store.listRetiredActivationBackups(plan.id)) {
|
|
867
|
+
const { backupPath } = paths(retired);
|
|
868
|
+
if (await directoryExists(backupPath))
|
|
869
|
+
await rm(backupPath, { recursive: true, force: true });
|
|
870
|
+
}
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
async function finishCommit(store, plan) {
|
|
874
|
+
const baselineFiles = await fencedMutation(store, plan, () => profileFilesAt(plan, plan.target.profilePath));
|
|
875
|
+
store.recordActivationInstalledBaseline({ planId: plan.id, expectedRevision: plan.revision, fence: plan.activation.fence, baselineFiles });
|
|
876
|
+
await fencedMutation(store, plan, () => rm(paths(plan).stagePath, { recursive: true, force: true }));
|
|
877
|
+
const committed = advance(store, plan, 'activated');
|
|
878
|
+
await cleanupRetiredBackups(store, committed);
|
|
879
|
+
return committed;
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* Complete physical recovery after a closed post-activation watch. The caller
|
|
883
|
+
* owns the Store lifetime. Once recovery has claimed the activation, rollback
|
|
884
|
+
* deliberately drains its filesystem work even when the caller is cancelled;
|
|
885
|
+
* cancellation only interrupts the configured attestor subprocess.
|
|
886
|
+
*/
|
|
887
|
+
export async function rollbackPluginWatch(input) {
|
|
888
|
+
const { store, trust, planId, signal } = input;
|
|
889
|
+
throwIfAborted(signal);
|
|
890
|
+
let plan = store.getPlan(planId);
|
|
891
|
+
assertPlanTrust(plan, trust);
|
|
892
|
+
plan = store.beginPostActivationRollback({ planId, expectedRevision: plan.revision });
|
|
893
|
+
if (plan.activation?.rollbackProfileRestored)
|
|
894
|
+
await cleanupRestoredStage(store, plan);
|
|
895
|
+
if (plan.status === 'rolled-back')
|
|
896
|
+
return plan;
|
|
897
|
+
if (!plan.activation?.rollbackProfileRestored) {
|
|
898
|
+
plan = await store.claimActivation({ planId, expectedRevision: plan.revision, leaseMs,
|
|
899
|
+
resolveApprovalAuthority: receipt => activationClaimAuthority(trust, plan, receipt) });
|
|
900
|
+
let lock = await acquireProfileLock(store, plan);
|
|
901
|
+
try {
|
|
902
|
+
plan = await finishRollback(store, plan, lock);
|
|
903
|
+
lock = undefined;
|
|
904
|
+
}
|
|
905
|
+
finally {
|
|
906
|
+
if (lock !== undefined)
|
|
907
|
+
await releaseProfileLock(store, lock);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
if (trust.hostAttestor === undefined)
|
|
911
|
+
return plan;
|
|
912
|
+
throwIfAborted(signal);
|
|
913
|
+
const operation = prepareConfiguredHostAttestation(store, plan, trust);
|
|
914
|
+
const resolveAuthority = (receipt) => {
|
|
915
|
+
const key = resolveTrustKey(trust, 'host-attestation', receipt.authority, receipt.keyId);
|
|
916
|
+
return new Ed25519HostAttestationAuthority(key.publicKeyPem, key.authority, key.keyId);
|
|
917
|
+
};
|
|
918
|
+
const receipt = await store.runHostAttestationOperation({ operationId: operation.operationId,
|
|
919
|
+
expectedRevision: plan.revision, expectedFence: plan.activation.fence,
|
|
920
|
+
execute: request => invokeConfiguredHostAttestor(trust, request, signal), resolveAuthority });
|
|
921
|
+
return (await store.applyHostAttestation({ planId, expectedRevision: plan.revision, expectedFence: plan.activation.fence,
|
|
922
|
+
receipt, idempotencyKey: `host-attestation:${operation.operationId}`, resolveAuthority })).result;
|
|
923
|
+
}
|
|
650
924
|
async function approve(argv) {
|
|
651
925
|
if (argv.includes('--approved-by')) {
|
|
652
926
|
throw new ControlPlaneCliError('INVALID_ARGUMENT', 'approval trust roots cannot be supplied by the approving command');
|
|
@@ -669,89 +943,119 @@ async function approve(argv) {
|
|
|
669
943
|
}
|
|
670
944
|
function activationApprovalAuthority(trust, receipt) {
|
|
671
945
|
const key = resolveTrustKey(trust, 'approval', receipt.authority, receipt.keyId);
|
|
946
|
+
return new Ed25519ApprovalAuthority(key.publicKeyPem, key.authority, key.keyId);
|
|
947
|
+
}
|
|
948
|
+
function recoveryActivationApprovalAuthority(trust, receipt) {
|
|
949
|
+
const key = resolveTrustKey(trust, 'approval', receipt.authority, receipt.keyId);
|
|
950
|
+
// A rollback is physical recovery for a previously admitted activation. It
|
|
951
|
+
// verifies the original bounded receipt at its decision time and does not
|
|
952
|
+
// authorize a new forward deployment after that receipt has expired.
|
|
672
953
|
return new Ed25519ApprovalAuthority(key.publicKeyPem, key.authority, key.keyId, () => receipt.decidedAt);
|
|
673
954
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
955
|
+
function activationClaimAuthority(trust, plan, receipt) {
|
|
956
|
+
return plan.status === 'rollback-pending'
|
|
957
|
+
? recoveryActivationApprovalAuthority(trust, receipt)
|
|
958
|
+
: activationApprovalAuthority(trust, receipt);
|
|
959
|
+
}
|
|
960
|
+
export async function activatePluginPlan(input) {
|
|
961
|
+
const { store, trust, planId, expectedRevision, signal } = input;
|
|
677
962
|
let lock;
|
|
678
963
|
try {
|
|
679
|
-
|
|
964
|
+
throwIfAborted(signal);
|
|
965
|
+
let plan = store.getPlan(planId);
|
|
680
966
|
assertPlanTrust(plan, trust);
|
|
681
967
|
if (plan.status === 'activated') {
|
|
682
|
-
|
|
683
|
-
return;
|
|
968
|
+
await checked(signal, () => cleanupRetiredBackups(store, plan));
|
|
969
|
+
return plan;
|
|
970
|
+
}
|
|
971
|
+
if (plan.status === 'rollback-pending' && plan.activation?.rollbackProfileRestored) {
|
|
972
|
+
if (plan.revision !== expectedRevision)
|
|
973
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'activation targets a stale revision');
|
|
974
|
+
return plan;
|
|
684
975
|
}
|
|
685
|
-
|
|
686
|
-
|
|
976
|
+
// Claim and lock form one durable recovery boundary. Do not let an abort
|
|
977
|
+
// between their awaits escape before the staging try/catch owns rollback.
|
|
978
|
+
plan = await store.claimActivation({ planId: plan.id, expectedRevision, leaseMs,
|
|
979
|
+
resolveApprovalAuthority: receipt => activationClaimAuthority(trust, plan, receipt) });
|
|
687
980
|
lock = await acquireProfileLock(store, plan);
|
|
688
981
|
const activationPaths = paths(plan);
|
|
689
982
|
if (plan.status === 'rollback-pending') {
|
|
690
|
-
|
|
983
|
+
const output = await finishRollback(store, plan, lock);
|
|
691
984
|
lock = undefined;
|
|
692
|
-
return;
|
|
985
|
+
return output;
|
|
693
986
|
}
|
|
694
987
|
if (plan.status === 'commit-pending') {
|
|
695
|
-
await
|
|
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;
|
|
988
|
+
return await finishCommit(store, plan);
|
|
699
989
|
}
|
|
700
990
|
if (plan.status !== 'staging')
|
|
701
991
|
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
992
|
try {
|
|
993
|
+
await checked(signal, () => assertDirectory(trust.dshHome));
|
|
994
|
+
await checked(signal, () => assertDirectory(join(trust.dshHome, 'profiles')));
|
|
995
|
+
await checked(signal, () => assertDirectory(plan.target.profilePath, true));
|
|
996
|
+
const environment = inheritedEnvironment(trust);
|
|
707
997
|
if (plan.activation?.targetOriginallyExisted === undefined) {
|
|
708
|
-
if (await directoryExists(activationPaths.backupPath) || await directoryExists(activationPaths.stagePath)) {
|
|
998
|
+
if (await checked(signal, () => directoryExists(activationPaths.backupPath)) || await checked(signal, () => directoryExists(activationPaths.stagePath))) {
|
|
709
999
|
throw new ControlPlaneCliError('FILESYSTEM_STATE', 'unbound activation residue requires owner recovery');
|
|
710
1000
|
}
|
|
1001
|
+
const existed = await checked(signal, () => directoryExists(plan.target.profilePath));
|
|
1002
|
+
const baselineFiles = existed ? await checked(signal, () => fencedMutation(store, plan, () => captureRollbackBaseline({ ...plan, activation: { ...plan.activation, targetOriginallyExisted: true } }))) : [];
|
|
711
1003
|
plan = store.recordActivationTargetBaseline({ planId: plan.id, expectedRevision: plan.revision,
|
|
712
|
-
fence: plan.activation.fence, existed
|
|
1004
|
+
fence: plan.activation.fence, existed, baselineFiles });
|
|
713
1005
|
}
|
|
714
|
-
await restoreTarget(store, plan, activationPaths.backupPath);
|
|
715
|
-
await fencedMutation(store, plan, () => rm(activationPaths.stagePath, { recursive: true, force: true }));
|
|
1006
|
+
await checked(signal, () => restoreTarget(store, plan, activationPaths.backupPath));
|
|
1007
|
+
await checked(signal, () => fencedMutation(store, plan, () => rm(activationPaths.stagePath, { recursive: true, force: true })));
|
|
716
1008
|
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 }));
|
|
1009
|
+
await checked(signal, () => stat(plan.target.profilePath));
|
|
1010
|
+
await checked(signal, () => fencedMutation(store, plan, () => cp(plan.target.profilePath, activationPaths.stagePath, { recursive: true, force: false, errorOnExist: true })));
|
|
719
1011
|
}
|
|
720
|
-
|
|
1012
|
+
let activationArtifacts;
|
|
721
1013
|
let pinnedExecutor;
|
|
722
1014
|
let pinnedInterpreter;
|
|
723
1015
|
try {
|
|
1016
|
+
// Retain newly opened artifact descriptors before the post-await abort
|
|
1017
|
+
// check so this disposer owns them on cancellation.
|
|
1018
|
+
throwIfAborted(signal);
|
|
1019
|
+
await fencedMutation(store, plan, async () => { activationArtifacts = await activationPackages(trust, plan); });
|
|
1020
|
+
throwIfAborted(signal);
|
|
1021
|
+
throwIfAborted(signal);
|
|
724
1022
|
pinnedExecutor = await openTrustedExecutable(trust.executor.path, plan.executor.sha256);
|
|
1023
|
+
throwIfAborted(signal);
|
|
725
1024
|
pinnedInterpreter = await executorInterpreter(pinnedExecutor);
|
|
1025
|
+
throwIfAborted(signal);
|
|
726
1026
|
const executor = { pinnedExecutable: pinnedExecutor, pinnedInterpreter };
|
|
727
|
-
const version = (await runBounded({ executable: trust.executor.path, args: ['--version'], environment, store, plan, capture: true, ...executor })).trim();
|
|
1027
|
+
const version = (await runBounded({ executable: trust.executor.path, args: ['--version'], environment, store, plan, capture: true, signal, ...executor })).trim();
|
|
728
1028
|
if (version !== plan.candidate.dshBaseline)
|
|
729
1029
|
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'registered executor baseline differs from the approved dossier');
|
|
730
1030
|
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);
|
|
1031
|
+
...activationArtifacts.packages.map(installSpec)], environment, store, plan, signal, ...executor });
|
|
1032
|
+
await checked(signal, () => Promise.all(activationArtifacts.snapshots.map(verifyActivationArtifactSnapshot)));
|
|
1033
|
+
const lockfileSource = await checked(signal, () => readSafeFile(join(activationPaths.stagePath, 'pnpm-lock.yaml'), 8 * 1024 * 1024));
|
|
734
1034
|
verifyApprovedPackagesInLockfile(lockfileSource, activationArtifacts.packages, activationPaths.stagePath);
|
|
735
|
-
await verifyInstalledPackages(activationPaths.stagePath, activationArtifacts.packages);
|
|
1035
|
+
await checked(signal, () => verifyInstalledPackages(activationPaths.stagePath, activationArtifacts.packages));
|
|
736
1036
|
// Configuration materialization is a staging integrity check only. It is
|
|
737
1037
|
// deliberately not called readiness, reload, shadow, canary or health.
|
|
738
1038
|
await runBounded({ executable: trust.executor.path, args: ['--profile', activationPaths.stageProfile, '--dump-config'],
|
|
739
|
-
environment, store, plan, ...executor });
|
|
1039
|
+
environment, store, plan, signal, ...executor });
|
|
740
1040
|
}
|
|
741
1041
|
finally {
|
|
742
|
-
|
|
1042
|
+
if (activationArtifacts !== undefined)
|
|
1043
|
+
await closeActivationArtifactSnapshots(activationArtifacts.snapshots);
|
|
743
1044
|
if (pinnedInterpreter !== undefined)
|
|
744
1045
|
await pinnedInterpreter.executable.handle.close();
|
|
745
1046
|
await pinnedExecutor?.handle.close();
|
|
746
1047
|
}
|
|
1048
|
+
plan = store.markActivationHostExposure({ planId: plan.id, expectedRevision: plan.revision, fence: plan.activation.fence });
|
|
1049
|
+
throwIfAborted(signal);
|
|
747
1050
|
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));
|
|
1051
|
+
await checked(signal, () => fencedMutation(store, plan, () => rename(plan.target.profilePath, activationPaths.backupPath)));
|
|
1052
|
+
await checked(signal, () => fencedMutation(store, plan, () => rename(activationPaths.stagePath, plan.target.profilePath)));
|
|
750
1053
|
plan = advance(store, plan, 'awaiting-reload');
|
|
751
|
-
|
|
1054
|
+
return plan;
|
|
752
1055
|
}
|
|
753
1056
|
catch (error) {
|
|
754
|
-
plan = advance(store, plan, 'rollback-pending', error instanceof
|
|
1057
|
+
plan = advance(store, plan, 'rollback-pending', error instanceof ActivationCancelledError ? 'activation-cancelled'
|
|
1058
|
+
: error instanceof ControlPlaneCliError ? error.code.toLowerCase().replaceAll('_', '-') : 'activation-failed');
|
|
755
1059
|
await finishRollback(store, plan, lock);
|
|
756
1060
|
lock = undefined;
|
|
757
1061
|
throw error;
|
|
@@ -760,6 +1064,16 @@ async function activate(argv) {
|
|
|
760
1064
|
finally {
|
|
761
1065
|
if (lock !== undefined)
|
|
762
1066
|
await releaseProfileLock(store, lock);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
async function activate(argv) {
|
|
1070
|
+
const trust = await commandTrust(argv);
|
|
1071
|
+
const store = new ControlPlaneStore({ path: trust.ledger.path });
|
|
1072
|
+
try {
|
|
1073
|
+
const result = await activatePluginPlan({ store, trust, planId: option(argv, '--plan-id'), expectedRevision: integerOption(argv, '--expected-revision') });
|
|
1074
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
1075
|
+
}
|
|
1076
|
+
finally {
|
|
763
1077
|
store.close();
|
|
764
1078
|
}
|
|
765
1079
|
}
|
|
@@ -774,29 +1088,29 @@ async function attest(argv) {
|
|
|
774
1088
|
const expectedFence = integerOption(argv, '--expected-fence');
|
|
775
1089
|
const initial = store.getPlan(planId);
|
|
776
1090
|
assertPlanTrust(initial, trust);
|
|
1091
|
+
if (receipt.planId !== planId || receipt.planDigest !== initial.digest) {
|
|
1092
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'Host receipt does not target the requested activation plan');
|
|
1093
|
+
}
|
|
777
1094
|
const resolveAuthority = (value) => {
|
|
778
1095
|
const key = resolveTrustKey(trust, 'host-attestation', value.authority, value.keyId);
|
|
779
1096
|
return new Ed25519HostAttestationAuthority(key.publicKeyPem, key.authority, key.keyId);
|
|
780
1097
|
};
|
|
781
|
-
await store.
|
|
782
|
-
|
|
1098
|
+
await store.acceptHostAttestationReceipt({ operationId: receipt.operationId, expectedRevision, expectedFence,
|
|
1099
|
+
receipt, resolveAuthority });
|
|
783
1100
|
const result = await store.applyHostAttestation({ planId, expectedRevision, expectedFence, receipt,
|
|
784
1101
|
idempotencyKey: `host-attestation:${receipt.operationId}`, resolveAuthority });
|
|
785
1102
|
let plan = store.getPlan(result.result.id);
|
|
786
1103
|
assertPlanTrust(plan, trust);
|
|
787
1104
|
if (plan.status === 'rollback-pending' || plan.status === 'commit-pending') {
|
|
788
1105
|
plan = await store.claimActivation({ planId: plan.id, expectedRevision: plan.revision, leaseMs,
|
|
789
|
-
resolveApprovalAuthority: receipt =>
|
|
1106
|
+
resolveApprovalAuthority: receipt => activationClaimAuthority(trust, plan, receipt) });
|
|
790
1107
|
lock = await acquireProfileLock(store, plan);
|
|
791
1108
|
if (plan.status === 'rollback-pending') {
|
|
792
1109
|
plan = await finishRollback(store, plan, lock);
|
|
793
1110
|
lock = undefined;
|
|
794
1111
|
}
|
|
795
1112
|
else {
|
|
796
|
-
|
|
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');
|
|
1113
|
+
plan = await finishCommit(store, plan);
|
|
800
1114
|
}
|
|
801
1115
|
}
|
|
802
1116
|
process.stdout.write(`${JSON.stringify({ ...result, result: plan })}\n`);
|
|
@@ -822,113 +1136,145 @@ async function hostRequest(argv) {
|
|
|
822
1136
|
store.close();
|
|
823
1137
|
}
|
|
824
1138
|
}
|
|
825
|
-
async function
|
|
826
|
-
const trust =
|
|
1139
|
+
async function probePluginPlanResult(input) {
|
|
1140
|
+
const { store, trust, planId, expectedRevision, expectedFence, signal } = input;
|
|
827
1141
|
if (trust.hostAttestor === undefined)
|
|
828
1142
|
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
1143
|
let lock;
|
|
831
1144
|
try {
|
|
832
|
-
|
|
1145
|
+
throwIfAborted(signal);
|
|
1146
|
+
const plan = store.getPlan(planId);
|
|
833
1147
|
assertPlanTrust(plan, trust);
|
|
834
|
-
const expectedRevision = integerOption(argv, '--expected-revision');
|
|
835
|
-
const expectedFence = integerOption(argv, '--expected-fence');
|
|
836
1148
|
if (plan.revision !== expectedRevision || plan.activation?.fence !== expectedFence) {
|
|
837
1149
|
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'configured Host probe targets a stale revision/fence');
|
|
838
1150
|
}
|
|
839
1151
|
const operation = prepareConfiguredHostAttestation(store, plan, trust);
|
|
1152
|
+
throwIfAborted(signal);
|
|
840
1153
|
const resolveAuthority = (value) => {
|
|
841
1154
|
const key = resolveTrustKey(trust, 'host-attestation', value.authority, value.keyId);
|
|
842
1155
|
return new Ed25519HostAttestationAuthority(key.publicKeyPem, key.authority, key.keyId);
|
|
843
1156
|
};
|
|
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 });
|
|
1157
|
+
const receipt = await checked(signal, () => store.runHostAttestationOperation({ operationId: operation.operationId, expectedRevision, expectedFence,
|
|
1158
|
+
execute: request => invokeConfiguredHostAttestor(trust, request, signal), resolveAuthority }));
|
|
1159
|
+
const result = await checked(signal, () => store.applyHostAttestation({ planId: plan.id, expectedRevision, expectedFence, receipt,
|
|
1160
|
+
idempotencyKey: `host-attestation:${operation.operationId}`, resolveAuthority }));
|
|
848
1161
|
let output = result.result;
|
|
849
|
-
if (output.status === 'rollback-pending' || output.status === 'commit-pending') {
|
|
1162
|
+
if (output.status === 'rollback-pending' || (output.status === 'commit-pending' && !input.deferCommit)) {
|
|
1163
|
+
// Once physical recovery/commit is durable, complete it under the
|
|
1164
|
+
// profile lock instead of leaving a claimed plan on a late abort.
|
|
850
1165
|
output = await store.claimActivation({ planId: output.id, expectedRevision: output.revision, leaseMs,
|
|
851
|
-
resolveApprovalAuthority: receipt =>
|
|
1166
|
+
resolveApprovalAuthority: receipt => activationClaimAuthority(trust, output, receipt) });
|
|
852
1167
|
lock = await acquireProfileLock(store, output);
|
|
853
1168
|
if (output.status === 'rollback-pending') {
|
|
854
1169
|
output = await finishRollback(store, output, lock);
|
|
855
1170
|
lock = undefined;
|
|
856
1171
|
}
|
|
857
1172
|
else {
|
|
858
|
-
|
|
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');
|
|
1173
|
+
output = await finishCommit(store, output);
|
|
862
1174
|
}
|
|
863
1175
|
}
|
|
864
|
-
|
|
1176
|
+
return { plan: output, result };
|
|
865
1177
|
}
|
|
866
1178
|
finally {
|
|
867
1179
|
if (lock !== undefined)
|
|
868
1180
|
await releaseProfileLock(store, lock);
|
|
869
|
-
store.close();
|
|
870
1181
|
}
|
|
871
1182
|
}
|
|
872
|
-
async function
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
1183
|
+
export async function probePluginPlan(input) {
|
|
1184
|
+
return (await probePluginPlanResult(input)).plan;
|
|
1185
|
+
}
|
|
1186
|
+
async function probe(argv) {
|
|
1187
|
+
const trust = await commandTrust(argv);
|
|
1188
|
+
if (trust.hostAttestor === undefined)
|
|
1189
|
+
throw new ControlPlaneCliError('HOST_ATTESTOR_NOT_CONFIGURED', 'deployment has no owner-configured Host attestor; activation remains awaiting its current phase');
|
|
1190
|
+
const store = new ControlPlaneStore({ path: trust.ledger.path });
|
|
1191
|
+
try {
|
|
1192
|
+
const planId = option(argv, '--plan-id');
|
|
1193
|
+
const expectedRevision = integerOption(argv, '--expected-revision');
|
|
1194
|
+
const expectedFence = integerOption(argv, '--expected-fence');
|
|
1195
|
+
if (argv.includes('--prepare-only')) {
|
|
1196
|
+
const plan = store.getPlan(planId);
|
|
1197
|
+
assertPlanTrust(plan, trust);
|
|
1198
|
+
if (plan.revision !== expectedRevision || plan.activation?.fence !== expectedFence) {
|
|
1199
|
+
throw new ControlPlaneCliError('ACTIVATION_BINDING', 'configured Host probe targets a stale revision/fence');
|
|
1200
|
+
}
|
|
1201
|
+
process.stdout.write(`${JSON.stringify(prepareConfiguredHostAttestation(store, plan, trust).request)}\n`);
|
|
1202
|
+
return;
|
|
884
1203
|
}
|
|
885
|
-
|
|
1204
|
+
const outcome = await probePluginPlanResult({ store, trust, planId, expectedRevision, expectedFence });
|
|
1205
|
+
process.stdout.write(`${JSON.stringify({ ...outcome.result, result: outcome.plan })}\n`);
|
|
886
1206
|
}
|
|
887
|
-
|
|
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');
|
|
1207
|
+
finally {
|
|
1208
|
+
store.close();
|
|
898
1209
|
}
|
|
899
1210
|
}
|
|
900
|
-
async function
|
|
901
|
-
const
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1211
|
+
async function watchObserve(argv) {
|
|
1212
|
+
const trust = await commandTrust(argv);
|
|
1213
|
+
const store = new ControlPlaneStore({ path: trust.ledger.path });
|
|
1214
|
+
try {
|
|
1215
|
+
const receipt = parsePostActivationObservation(JSON.parse(await loadPrivateApprovalInput(resolve(option(argv, '--receipt')), 32_768)));
|
|
1216
|
+
assertPlanTrust(store.getPlan(receipt.planId), trust);
|
|
1217
|
+
const result = await store.recordPostActivationObservation({
|
|
1218
|
+
receipt,
|
|
1219
|
+
...(argv.includes('--expected-revision') ? { expectedRevision: integerOption(argv, '--expected-revision') } : {}),
|
|
1220
|
+
resolveAuthority: (value) => {
|
|
1221
|
+
const key = resolveTrustKey(trust, 'host-attestation', value.authority, value.keyId);
|
|
1222
|
+
return new Ed25519PostActivationObservationAuthority(key.publicKeyPem, key.authority, key.keyId);
|
|
1223
|
+
},
|
|
1224
|
+
idempotencyKey: `post-activation-observation:${receipt.observationId}`
|
|
1225
|
+
});
|
|
1226
|
+
const activation = receipt.disposition === 'regressed' ? await rollbackPluginWatch({ store, trust, planId: receipt.planId }) : undefined;
|
|
1227
|
+
process.stdout.write(`${JSON.stringify({ ...result, ...(activation === undefined ? {} : { activation }) })}\n`);
|
|
1228
|
+
}
|
|
1229
|
+
finally {
|
|
1230
|
+
store.close();
|
|
1231
|
+
}
|
|
906
1232
|
}
|
|
907
|
-
function
|
|
908
|
-
const
|
|
909
|
-
|
|
1233
|
+
async function watchRetract(argv) {
|
|
1234
|
+
const trust = await commandTrust(argv);
|
|
1235
|
+
const store = new ControlPlaneStore({ path: trust.ledger.path });
|
|
1236
|
+
try {
|
|
1237
|
+
const receipt = parseActivationRetraction(JSON.parse(await loadPrivateApprovalInput(resolve(option(argv, '--receipt')), 32_768)));
|
|
1238
|
+
assertPlanTrust(store.getPlan(receipt.planId), trust);
|
|
1239
|
+
const result = await store.retractActivation({
|
|
1240
|
+
receipt,
|
|
1241
|
+
...(argv.includes('--expected-revision') ? { expectedRevision: integerOption(argv, '--expected-revision') } : {}),
|
|
1242
|
+
resolveAuthority: (value) => {
|
|
1243
|
+
const key = resolveTrustKey(trust, 'approval', value.authority, value.keyId);
|
|
1244
|
+
return new Ed25519ActivationRetractionAuthority(key.publicKeyPem, key.authority, key.keyId);
|
|
1245
|
+
},
|
|
1246
|
+
idempotencyKey: `activation-retraction:${receipt.retractionId}`
|
|
1247
|
+
});
|
|
1248
|
+
const activation = await rollbackPluginWatch({ store, trust, planId: receipt.planId });
|
|
1249
|
+
process.stdout.write(`${JSON.stringify({ ...result, activation })}\n`);
|
|
1250
|
+
}
|
|
1251
|
+
finally {
|
|
1252
|
+
store.close();
|
|
1253
|
+
}
|
|
910
1254
|
}
|
|
911
|
-
|
|
912
|
-
const
|
|
1255
|
+
async function watchShow(argv) {
|
|
1256
|
+
const trust = await commandTrust(argv);
|
|
1257
|
+
const store = new ControlPlaneStore({ path: trust.ledger.path });
|
|
913
1258
|
try {
|
|
914
|
-
const
|
|
915
|
-
if (
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
'--'
|
|
922
|
-
|
|
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
|
-
};
|
|
1259
|
+
const planId = optionalOption(argv, '--plan-id');
|
|
1260
|
+
if (planId !== undefined) {
|
|
1261
|
+
assertPlanTrust(store.getPlan(planId), trust);
|
|
1262
|
+
process.stdout.write(`${JSON.stringify({ watch: store.getActivationWatch(planId),
|
|
1263
|
+
evidence: store.listActivationWatchEvidence(planId) })}\n`);
|
|
1264
|
+
}
|
|
1265
|
+
else {
|
|
1266
|
+
process.stdout.write(`${JSON.stringify({ watches: store.listActivationWatches(argv.includes('--limit') ? integerOption(argv, '--limit') : undefined) })}\n`);
|
|
1267
|
+
}
|
|
929
1268
|
}
|
|
930
1269
|
finally {
|
|
931
|
-
|
|
1270
|
+
store.close();
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
function sourceScope(name) { return [pluginCatalogScope, `plugins/${name}`].sort(); }
|
|
1274
|
+
function assertExactSourceScope(plan) {
|
|
1275
|
+
const expected = sourceScope(plan.name);
|
|
1276
|
+
if (plan.scope.length !== expected.length || plan.scope.some((value, index) => value !== expected[index])) {
|
|
1277
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source plan scope does not match the exact generator outputs');
|
|
932
1278
|
}
|
|
933
1279
|
}
|
|
934
1280
|
async function sourcePlan(argv) {
|
|
@@ -941,11 +1287,11 @@ async function sourcePlan(argv) {
|
|
|
941
1287
|
if (!pluginPattern.test(name))
|
|
942
1288
|
throw new ControlPlaneCliError('INVALID_ARGUMENT', 'plugin name is invalid');
|
|
943
1289
|
const environment = inheritedEnvironment(trust);
|
|
944
|
-
const worktrees = (await
|
|
1290
|
+
const worktrees = (await runLocalCommand('git', ['worktree', 'list', '--porcelain'], repository, environment, { capture: true })).split('\n')
|
|
945
1291
|
.filter(line => line.startsWith('worktree ')).map(line => resolve(line.slice('worktree '.length)));
|
|
946
1292
|
if (worktrees.length < 2 || worktrees[0] === worktree || !worktrees.includes(worktree))
|
|
947
1293
|
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source plan requires a linked non-primary worktree');
|
|
948
|
-
const baseCommit = (await
|
|
1294
|
+
const baseCommit = (await runLocalCommand('git', ['rev-parse', 'HEAD'], worktree, environment, { capture: true })).trim();
|
|
949
1295
|
const generator = join(repository, 'scripts', 'create-plugin.mjs');
|
|
950
1296
|
const generatorDigest = createHash('sha256').update(await readSafeFile(generator, 1_048_576)).digest('hex');
|
|
951
1297
|
const output = store.createSourcePlan({ gapId: option(argv, '--gap-id'), repository, worktree, baseCommit, name,
|
|
@@ -964,22 +1310,26 @@ async function scaffold(argv) {
|
|
|
964
1310
|
let plan;
|
|
965
1311
|
try {
|
|
966
1312
|
plan = store.getSourcePlan(option(argv, '--plan-id'));
|
|
1313
|
+
if (plan.mode !== 'create')
|
|
1314
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'scaffold only serves create source plans; modify plans are verified, not locally scaffolded');
|
|
967
1315
|
if (plan.status !== 'approved' || plan.revision !== integerOption(argv, '--expected-revision'))
|
|
968
1316
|
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'exact approved source plan revision is required');
|
|
969
1317
|
assertExactSourceScope(plan);
|
|
970
1318
|
if (await realpath(plan.repository) !== plan.repository || await realpath(plan.worktree) !== plan.worktree)
|
|
971
1319
|
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source paths changed');
|
|
972
1320
|
const environment = inheritedEnvironment(trust);
|
|
973
|
-
if ((await
|
|
1321
|
+
if ((await runLocalCommand('git', ['rev-parse', 'HEAD'], plan.worktree, environment, { capture: true })).trim() !== plan.baseCommit
|
|
974
1322
|
|| createHash('sha256').update(await readSafeFile(join(plan.repository, 'scripts', 'create-plugin.mjs'), 1_048_576)).digest('hex') !== plan.generatorDigest
|
|
975
|
-
|| (await
|
|
1323
|
+
|| (await runLocalCommand('git', ['status', '--porcelain'], plan.worktree, environment, { capture: true })).trim() !== '')
|
|
976
1324
|
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source plan base, generator or clean-worktree binding changed');
|
|
977
1325
|
plan = store.beginSourceChecks({ planId: plan.id, expectedRevision: plan.revision });
|
|
978
1326
|
try {
|
|
979
|
-
|
|
980
|
-
|
|
1327
|
+
// Uncaptured commands: stdout is drained and discarded without a byte
|
|
1328
|
+
// bound, exactly like the legacy localCommand scaffold path.
|
|
1329
|
+
await runLocalCommand('pnpm', ['create:plugin', plan.name], plan.worktree, environment);
|
|
1330
|
+
await runLocalCommand('pnpm', ['check'], plan.worktree, environment);
|
|
981
1331
|
const changes = await changedSourcePaths(plan.worktree, plan.baseCommit, environment);
|
|
982
|
-
const outsideScope = changes.find(path => !sourcePathAllowed(path, plan));
|
|
1332
|
+
const outsideScope = changes.find(path => !sourcePathAllowed(path, plan.name, plan.mode));
|
|
983
1333
|
if (outsideScope !== undefined)
|
|
984
1334
|
throw new ControlPlaneCliError('SOURCE_BOUNDARY', `source generator changed files outside its approved scope: ${JSON.stringify(outsideScope)}`);
|
|
985
1335
|
const checked = await checkedSourceSnapshot(plan.worktree, plan.baseCommit, plan.scope, environment);
|
|
@@ -995,6 +1345,43 @@ async function scaffold(argv) {
|
|
|
995
1345
|
store.close();
|
|
996
1346
|
}
|
|
997
1347
|
}
|
|
1348
|
+
async function sourceVerifyPrepared(argv) {
|
|
1349
|
+
const trust = await commandTrust(argv);
|
|
1350
|
+
const store = new ControlPlaneStore({ path: trust.ledger.path });
|
|
1351
|
+
try {
|
|
1352
|
+
const planId = option(argv, '--plan-id');
|
|
1353
|
+
const expectedRevision = integerOption(argv, '--expected-revision');
|
|
1354
|
+
const plan = store.getSourcePlan(planId);
|
|
1355
|
+
if (plan.mode !== 'modify')
|
|
1356
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'verify-prepared only serves modify source plans; create plans are locally scaffolded');
|
|
1357
|
+
if (plan.status !== 'approved' || plan.revision !== expectedRevision) {
|
|
1358
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'exact approved modify source plan revision is required');
|
|
1359
|
+
}
|
|
1360
|
+
if (plan.sourceCheck === undefined)
|
|
1361
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'approved modify plan is missing its bound checked digests');
|
|
1362
|
+
const checked = await verifyPreparedSourceWorktree(plan, inheritedEnvironment(trust));
|
|
1363
|
+
const receipt = store.verifyPreparedSourcePlan({ planId: plan.id, expectedRevision: plan.revision,
|
|
1364
|
+
recheckedTreeDigest: checked.checkedTreeDigest, recheckedPatchDigest: checked.checkedPatchDigest });
|
|
1365
|
+
process.stdout.write(`${JSON.stringify(receipt)}\n`);
|
|
1366
|
+
}
|
|
1367
|
+
finally {
|
|
1368
|
+
store.close();
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
async function sourceGc(argv) {
|
|
1372
|
+
const trust = await commandTrust(argv);
|
|
1373
|
+
const store = new ControlPlaneStore({ path: trust.ledger.path });
|
|
1374
|
+
try {
|
|
1375
|
+
// The ledger path is owner-bound to <statePath>/control.sqlite; command
|
|
1376
|
+
// callers are forbidden from supplying a state root, so derive it here.
|
|
1377
|
+
const result = await gcPreparedModifyWorktrees({ store, statePath: dirname(trust.ledger.path),
|
|
1378
|
+
environment: inheritedEnvironment(trust), now: Date.now() });
|
|
1379
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
1380
|
+
}
|
|
1381
|
+
finally {
|
|
1382
|
+
store.close();
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
998
1385
|
function assertReleaseCommand(argv) {
|
|
999
1386
|
if (argv.includes('--phase'))
|
|
1000
1387
|
throw new ControlPlaneCliError('INVALID_ARGUMENT', 'release phase is derived from durable plan status');
|
|
@@ -1039,12 +1426,22 @@ async function prepareRelease(store, trust, plan) {
|
|
|
1039
1426
|
interpreter: adapter.interpreter, authority: adapter.authority, keyId: adapter.keyId };
|
|
1040
1427
|
let catalog = { id: trust.catalog.id, path: trust.catalog.path };
|
|
1041
1428
|
if (expected.phase === 'catalog-admission') {
|
|
1042
|
-
const
|
|
1043
|
-
|
|
1044
|
-
|
|
1429
|
+
const prior = store.findSourceReleaseOperation(plan.id, expected.phase, plan.release.fence);
|
|
1430
|
+
if (prior?.request.phase === 'catalog-admission') {
|
|
1431
|
+
catalog = { ...catalog, expectedBeforeDigest: prior.request.input.expectedBeforeCatalogDigest,
|
|
1432
|
+
expectedAfterDigest: prior.request.input.expectedAfterCatalogDigest };
|
|
1433
|
+
}
|
|
1434
|
+
else {
|
|
1435
|
+
const loaded = await loadCatalogWithMetadata(trust.catalog.path);
|
|
1436
|
+
const preview = previewCatalogAdmission(loaded.catalog, store.previewSourceReleaseCandidate(plan.id));
|
|
1437
|
+
catalog = { ...catalog, expectedBeforeDigest: preview.beforeCatalogDigest, expectedAfterDigest: preview.afterCatalogDigest };
|
|
1438
|
+
}
|
|
1045
1439
|
}
|
|
1440
|
+
// Only {id, locator} leaves the owner host: caPins/tokenEnvironment are
|
|
1441
|
+
// activation-side local trust roots and must never reach a release adapter.
|
|
1442
|
+
const releaseRegistry = { id: trust.releaseRegistry.id, locator: trust.releaseRegistry.locator };
|
|
1046
1443
|
return store.prepareSourceReleaseOperation({ planId: plan.id, expectedRevision: plan.revision, expectedFence: plan.release.fence,
|
|
1047
|
-
installationId: trust.installationId, ledger: trust.ledger, registry:
|
|
1444
|
+
installationId: trust.installationId, ledger: trust.ledger, registry: releaseRegistry, catalog, adapter: adapterIdentity,
|
|
1048
1445
|
receiptTtlMs: trust.releaseReceiptTtlMs, resolveAuthorizationAuthority: value => releaseAuthorizationAuthority(trust, value) });
|
|
1049
1446
|
}
|
|
1050
1447
|
async function releaseStart(argv) {
|
|
@@ -1113,7 +1510,7 @@ async function releaseAttest(argv) {
|
|
|
1113
1510
|
const receipt = parseSourceReleaseReceipt(JSON.parse(await readOwnerPrivateFile(resolve(option(argv, '--receipt')), 262_144)));
|
|
1114
1511
|
if (receipt.operationId !== operation.operationId)
|
|
1115
1512
|
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'release receipt does not target the durable operation');
|
|
1116
|
-
await store.
|
|
1513
|
+
await store.acceptSourceReleaseReceipt({ operationId: operation.operationId, expectedRevision, expectedFence, receipt,
|
|
1117
1514
|
resolveAuthority: value => releaseAuthority(trust, value),
|
|
1118
1515
|
resolveAuthorizationAuthority: value => releaseAuthorizationAuthority(trust, value) });
|
|
1119
1516
|
const result = await store.applySourceRelease({ planId: plan.id, expectedRevision, expectedFence, receipt,
|
|
@@ -1144,7 +1541,8 @@ async function releaseReconcile(argv) {
|
|
|
1144
1541
|
const adapterIdentity = { id: adapter.id, version: adapter.version, path: adapter.path, sha256: adapter.sha256,
|
|
1145
1542
|
interpreter: adapter.interpreter, authority: adapter.authority, keyId: adapter.keyId };
|
|
1146
1543
|
const operation = await store.prepareSourcePublishReconciliation({ planId: plan.id, expectedRevision, expectedFence,
|
|
1147
|
-
installationId: trust.installationId, ledger: trust.ledger,
|
|
1544
|
+
installationId: trust.installationId, ledger: trust.ledger,
|
|
1545
|
+
registry: { id: trust.releaseRegistry.id, locator: trust.releaseRegistry.locator }, adapter: adapterIdentity,
|
|
1148
1546
|
receiptTtlMs: trust.releaseReceiptTtlMs, resolveAuthorizationAuthority: value => releaseAuthorizationAuthority(trust, value) });
|
|
1149
1547
|
const receiptPath = optionalOption(argv, '--receipt');
|
|
1150
1548
|
const supplied = receiptPath === undefined ? undefined : parseSourcePublishReconciliationReceipt(JSON.parse(await readOwnerPrivateFile(resolve(receiptPath), 262_144)));
|
|
@@ -1163,6 +1561,39 @@ async function releaseReconcile(argv) {
|
|
|
1163
1561
|
store.close();
|
|
1164
1562
|
}
|
|
1165
1563
|
}
|
|
1564
|
+
async function activationPlan(argv) {
|
|
1565
|
+
const trust = await commandTrust(argv);
|
|
1566
|
+
const store = new ControlPlaneStore({ path: trust.ledger.path });
|
|
1567
|
+
try {
|
|
1568
|
+
const sourcePlanId = option(argv, '--source-plan');
|
|
1569
|
+
const profile = option(argv, '--profile');
|
|
1570
|
+
const idempotencyKey = option(argv, '--idempotency-key');
|
|
1571
|
+
const ttlMs = optionalOption(argv, '--ttl-ms') === undefined ? 900_000 : integerOption(argv, '--ttl-ms');
|
|
1572
|
+
const source = store.getSourcePlan(sourcePlanId);
|
|
1573
|
+
if (source.status !== 'release-complete') {
|
|
1574
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'activation plan requires a release-complete source plan');
|
|
1575
|
+
}
|
|
1576
|
+
const released = store.sourceReleaseCandidate(sourcePlanId);
|
|
1577
|
+
const loaded = await loadCatalogWithMetadata(trust.catalog.path);
|
|
1578
|
+
const admitted = loaded.catalog.entries.find(entry => entry.id === released.id);
|
|
1579
|
+
if (admitted === undefined
|
|
1580
|
+
|| controlPlaneDigest({ schemaVersion: 1, entries: [admitted] }) !== controlPlaneDigest({ schemaVersion: 1, entries: [released] })) {
|
|
1581
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'released candidate is not the exact admitted owner catalog entry');
|
|
1582
|
+
}
|
|
1583
|
+
if (!discover(loaded.catalog, source.gapSnapshot.capability).some(entry => entry.id === released.id)) {
|
|
1584
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'admitted candidate does not match the released gap capability');
|
|
1585
|
+
}
|
|
1586
|
+
const target = await canonicalProfileTarget(trust, profile);
|
|
1587
|
+
const receipt = store.createPlan({ candidate: admitted, catalog: { digest: loaded.digest, provenance: loaded.provenance },
|
|
1588
|
+
matchedCapabilities: admitted.capabilities, profile, target, installationId: trust.installationId, ledger: trust.ledger,
|
|
1589
|
+
executor: { id: trust.executor.id, version: trust.executor.version, path: trust.executor.path, sha256: trust.executor.sha256 },
|
|
1590
|
+
ttlMs, gapId: source.gapId, idempotencyKey });
|
|
1591
|
+
process.stdout.write(`${JSON.stringify(receipt)}\n`);
|
|
1592
|
+
}
|
|
1593
|
+
finally {
|
|
1594
|
+
store.close();
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1166
1597
|
export async function runPluginControl(argv = process.argv.slice(2)) {
|
|
1167
1598
|
rejectCommandSuppliedTrust(argv);
|
|
1168
1599
|
const command = argv[0];
|
|
@@ -1192,10 +1623,23 @@ export async function runPluginControl(argv = process.argv.slice(2)) {
|
|
|
1192
1623
|
return probe(argv);
|
|
1193
1624
|
if (command === 'attest')
|
|
1194
1625
|
return attest(argv);
|
|
1626
|
+
if (command === 'watch-observe')
|
|
1627
|
+
return watchObserve(argv);
|
|
1628
|
+
if (command === 'watch-retract')
|
|
1629
|
+
return watchRetract(argv);
|
|
1630
|
+
if (command === 'watch-show')
|
|
1631
|
+
return watchShow(argv);
|
|
1195
1632
|
if (command === 'source-plan')
|
|
1196
1633
|
return sourcePlan(argv);
|
|
1197
1634
|
if (command === 'scaffold')
|
|
1198
1635
|
return scaffold(argv);
|
|
1636
|
+
if (command === 'source') {
|
|
1637
|
+
if (argv[1] === 'verify-prepared')
|
|
1638
|
+
return sourceVerifyPrepared(argv);
|
|
1639
|
+
if (argv[1] === 'gc')
|
|
1640
|
+
return sourceGc(argv);
|
|
1641
|
+
throw new ControlPlaneCliError('INVALID_ARGUMENT', 'usage: dsh-plugin-control source <verify-prepared|gc>');
|
|
1642
|
+
}
|
|
1199
1643
|
if (command === 'release-start')
|
|
1200
1644
|
return releaseStart(argv);
|
|
1201
1645
|
if (command === 'release-request')
|
|
@@ -1206,6 +1650,8 @@ export async function runPluginControl(argv = process.argv.slice(2)) {
|
|
|
1206
1650
|
return releaseAttest(argv);
|
|
1207
1651
|
if (command === 'release-reconcile')
|
|
1208
1652
|
return releaseReconcile(argv);
|
|
1209
|
-
|
|
1653
|
+
if (command === 'activation-plan')
|
|
1654
|
+
return activationPlan(argv);
|
|
1655
|
+
throw new ControlPlaneCliError('INVALID_ARGUMENT', 'usage: dsh-plugin-control <discover|show|approve|activate|host-request|probe|attest|watch-observe|watch-retract|watch-show|source-plan|scaffold|source verify-prepared|source gc|release-start|release-request|release-step|release-attest|release-reconcile|activation-plan>');
|
|
1210
1656
|
}
|
|
1211
1657
|
//# sourceMappingURL=cli.js.map
|