@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.
Files changed (199) hide show
  1. package/README.md +352 -4
  2. package/bin/dsh-local-release-adapter.js +80 -16
  3. package/bin/dsh-npm-registry-adapter.js +613 -0
  4. package/bin/dsh-source-adoption-authority.js +12 -0
  5. package/bin/dsh-source-approval-authority.js +16 -0
  6. package/bin/dsh-source-release-authority.js +12 -0
  7. package/bin/dsh-systemd-host-attestor.js +707 -0
  8. package/bin/dsh-task-observation-authority.js +15 -0
  9. package/lib/adapter-process.d.ts +23 -0
  10. package/lib/adapter-process.d.ts.map +1 -0
  11. package/lib/adapter-process.js +305 -0
  12. package/lib/adapter-process.js.map +1 -0
  13. package/lib/adoption-coordinator.d.ts +63 -0
  14. package/lib/adoption-coordinator.d.ts.map +1 -0
  15. package/lib/adoption-coordinator.js +241 -0
  16. package/lib/adoption-coordinator.js.map +1 -0
  17. package/lib/adoption-handoff.d.ts +16 -0
  18. package/lib/adoption-handoff.d.ts.map +1 -0
  19. package/lib/adoption-handoff.js +11 -0
  20. package/lib/adoption-handoff.js.map +1 -0
  21. package/lib/attestation.d.ts.map +1 -1
  22. package/lib/attestation.js +49 -1
  23. package/lib/attestation.js.map +1 -1
  24. package/lib/catalog.d.ts.map +1 -1
  25. package/lib/catalog.js +29 -2
  26. package/lib/catalog.js.map +1 -1
  27. package/lib/cli.d.ts +33 -9
  28. package/lib/cli.d.ts.map +1 -1
  29. package/lib/cli.js +624 -178
  30. package/lib/cli.js.map +1 -1
  31. package/lib/deployment-readiness.d.ts +38 -0
  32. package/lib/deployment-readiness.d.ts.map +1 -0
  33. package/lib/deployment-readiness.js +154 -0
  34. package/lib/deployment-readiness.js.map +1 -0
  35. package/lib/effect-blocked-replay.d.ts +81 -0
  36. package/lib/effect-blocked-replay.d.ts.map +1 -0
  37. package/lib/effect-blocked-replay.js +259 -0
  38. package/lib/effect-blocked-replay.js.map +1 -0
  39. package/lib/errors.d.ts +6 -0
  40. package/lib/errors.d.ts.map +1 -0
  41. package/lib/errors.js +9 -0
  42. package/lib/errors.js.map +1 -0
  43. package/lib/foreground-deployment-runtime.d.ts +20 -0
  44. package/lib/foreground-deployment-runtime.d.ts.map +1 -0
  45. package/lib/foreground-deployment-runtime.js +79 -0
  46. package/lib/foreground-deployment-runtime.js.map +1 -0
  47. package/lib/foreground-deployment.d.ts +17 -0
  48. package/lib/foreground-deployment.d.ts.map +1 -0
  49. package/lib/foreground-deployment.js +43 -0
  50. package/lib/foreground-deployment.js.map +1 -0
  51. package/lib/host-attestor.d.ts +1 -1
  52. package/lib/host-attestor.d.ts.map +1 -1
  53. package/lib/host-attestor.js +51 -57
  54. package/lib/host-attestor.js.map +1 -1
  55. package/lib/index.d.ts +25 -2
  56. package/lib/index.d.ts.map +1 -1
  57. package/lib/index.js +11 -2
  58. package/lib/index.js.map +1 -1
  59. package/lib/npm-publish.d.ts +25 -0
  60. package/lib/npm-publish.d.ts.map +1 -0
  61. package/lib/npm-publish.js +280 -0
  62. package/lib/npm-publish.js.map +1 -0
  63. package/lib/owner-task-gap-types.d.ts +21 -0
  64. package/lib/owner-task-gap-types.d.ts.map +1 -0
  65. package/lib/owner-task-gap-types.js +2 -0
  66. package/lib/owner-task-gap-types.js.map +1 -0
  67. package/lib/owner-task-gaps.d.ts +24 -0
  68. package/lib/owner-task-gaps.d.ts.map +1 -0
  69. package/lib/owner-task-gaps.js +74 -0
  70. package/lib/owner-task-gaps.js.map +1 -0
  71. package/lib/post-activation.d.ts +33 -0
  72. package/lib/post-activation.d.ts.map +1 -0
  73. package/lib/post-activation.js +219 -0
  74. package/lib/post-activation.js.map +1 -0
  75. package/lib/registry-fetch.d.ts +50 -0
  76. package/lib/registry-fetch.d.ts.map +1 -0
  77. package/lib/registry-fetch.js +267 -0
  78. package/lib/registry-fetch.js.map +1 -0
  79. package/lib/release.d.ts +4 -4
  80. package/lib/release.d.ts.map +1 -1
  81. package/lib/release.js +142 -56
  82. package/lib/release.js.map +1 -1
  83. package/lib/replay-endpoint-protocol.d.ts +65 -0
  84. package/lib/replay-endpoint-protocol.d.ts.map +1 -0
  85. package/lib/replay-endpoint-protocol.js +183 -0
  86. package/lib/replay-endpoint-protocol.js.map +1 -0
  87. package/lib/replay-endpoint.d.ts +7 -0
  88. package/lib/replay-endpoint.d.ts.map +1 -0
  89. package/lib/replay-endpoint.js +193 -0
  90. package/lib/replay-endpoint.js.map +1 -0
  91. package/lib/replay-grant.d.ts +62 -0
  92. package/lib/replay-grant.d.ts.map +1 -0
  93. package/lib/replay-grant.js +208 -0
  94. package/lib/replay-grant.js.map +1 -0
  95. package/lib/replay-journal.d.ts +25 -0
  96. package/lib/replay-journal.d.ts.map +1 -0
  97. package/lib/replay-journal.js +275 -0
  98. package/lib/replay-journal.js.map +1 -0
  99. package/lib/runtime-observer-protocol.d.ts +63 -0
  100. package/lib/runtime-observer-protocol.d.ts.map +1 -0
  101. package/lib/runtime-observer-protocol.js +280 -0
  102. package/lib/runtime-observer-protocol.js.map +1 -0
  103. package/lib/runtime-observer.d.ts +8 -0
  104. package/lib/runtime-observer.d.ts.map +1 -0
  105. package/lib/runtime-observer.js +182 -0
  106. package/lib/runtime-observer.js.map +1 -0
  107. package/lib/service.d.ts +145 -1
  108. package/lib/service.d.ts.map +1 -1
  109. package/lib/service.js +734 -6
  110. package/lib/service.js.map +1 -1
  111. package/lib/source-adoption-authority.d.ts +65 -0
  112. package/lib/source-adoption-authority.d.ts.map +1 -0
  113. package/lib/source-adoption-authority.js +271 -0
  114. package/lib/source-adoption-authority.js.map +1 -0
  115. package/lib/source-adoption-runner.d.ts +28 -0
  116. package/lib/source-adoption-runner.d.ts.map +1 -0
  117. package/lib/source-adoption-runner.js +197 -0
  118. package/lib/source-adoption-runner.js.map +1 -0
  119. package/lib/source-approval-authority.d.ts +50 -0
  120. package/lib/source-approval-authority.d.ts.map +1 -0
  121. package/lib/source-approval-authority.js +412 -0
  122. package/lib/source-approval-authority.js.map +1 -0
  123. package/lib/source-approval-client.d.ts +32 -0
  124. package/lib/source-approval-client.d.ts.map +1 -0
  125. package/lib/source-approval-client.js +175 -0
  126. package/lib/source-approval-client.js.map +1 -0
  127. package/lib/source-build.d.ts +60 -0
  128. package/lib/source-build.d.ts.map +1 -0
  129. package/lib/source-build.js +311 -0
  130. package/lib/source-build.js.map +1 -0
  131. package/lib/source-context.d.ts +24 -0
  132. package/lib/source-context.d.ts.map +1 -0
  133. package/lib/source-context.js +99 -0
  134. package/lib/source-context.js.map +1 -0
  135. package/lib/source-job-types.d.ts +87 -0
  136. package/lib/source-job-types.d.ts.map +1 -0
  137. package/lib/source-job-types.js +2 -0
  138. package/lib/source-job-types.js.map +1 -0
  139. package/lib/source-jobs.d.ts +113 -0
  140. package/lib/source-jobs.d.ts.map +1 -0
  141. package/lib/source-jobs.js +535 -0
  142. package/lib/source-jobs.js.map +1 -0
  143. package/lib/source-release-authority.d.ts +35 -0
  144. package/lib/source-release-authority.d.ts.map +1 -0
  145. package/lib/source-release-authority.js +271 -0
  146. package/lib/source-release-authority.js.map +1 -0
  147. package/lib/source-release-client.d.ts +10 -0
  148. package/lib/source-release-client.d.ts.map +1 -0
  149. package/lib/source-release-client.js +24 -0
  150. package/lib/source-release-client.js.map +1 -0
  151. package/lib/source-release-runner.d.ts +31 -0
  152. package/lib/source-release-runner.d.ts.map +1 -0
  153. package/lib/source-release-runner.js +125 -0
  154. package/lib/source-release-runner.js.map +1 -0
  155. package/lib/source-versioning.d.ts +24 -0
  156. package/lib/source-versioning.d.ts.map +1 -0
  157. package/lib/source-versioning.js +213 -0
  158. package/lib/source-versioning.js.map +1 -0
  159. package/lib/source-workspace.d.ts +117 -0
  160. package/lib/source-workspace.d.ts.map +1 -0
  161. package/lib/source-workspace.js +458 -0
  162. package/lib/source-workspace.js.map +1 -0
  163. package/lib/sqlite.d.ts +1 -1
  164. package/lib/sqlite.d.ts.map +1 -1
  165. package/lib/sqlite.js +509 -7
  166. package/lib/sqlite.js.map +1 -1
  167. package/lib/store.d.ts +229 -6
  168. package/lib/store.d.ts.map +1 -1
  169. package/lib/store.js +2431 -383
  170. package/lib/store.js.map +1 -1
  171. package/lib/task-observation-authority.d.ts +29 -0
  172. package/lib/task-observation-authority.d.ts.map +1 -0
  173. package/lib/task-observation-authority.js +370 -0
  174. package/lib/task-observation-authority.js.map +1 -0
  175. package/lib/task-observation-client.d.ts +4 -0
  176. package/lib/task-observation-client.d.ts.map +1 -0
  177. package/lib/task-observation-client.js +20 -0
  178. package/lib/task-observation-client.js.map +1 -0
  179. package/lib/task-observation-runtime.d.ts +59 -0
  180. package/lib/task-observation-runtime.d.ts.map +1 -0
  181. package/lib/task-observation-runtime.js +331 -0
  182. package/lib/task-observation-runtime.js.map +1 -0
  183. package/lib/task-observation-store.d.ts +17 -0
  184. package/lib/task-observation-store.d.ts.map +1 -0
  185. package/lib/task-observation-store.js +115 -0
  186. package/lib/task-observation-store.js.map +1 -0
  187. package/lib/task-observation-types.d.ts +67 -0
  188. package/lib/task-observation-types.d.ts.map +1 -0
  189. package/lib/task-observation-types.js +2 -0
  190. package/lib/task-observation-types.js.map +1 -0
  191. package/lib/trust.d.ts +3 -0
  192. package/lib/trust.d.ts.map +1 -1
  193. package/lib/trust.js +0 -0
  194. package/lib/trust.js.map +1 -1
  195. package/lib/types.d.ts +275 -8
  196. package/lib/types.d.ts.map +1 -1
  197. package/lib/version.d.ts +1 -1
  198. package/lib/version.js +1 -1
  199. 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, 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';
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
- 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
- }
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 ownedExecutable = input.plan === undefined || input.pinnedExecutable !== undefined ? undefined
292
- : await openTrustedExecutable(input.executable, input.plan.executor.sha256);
293
- const executable = input.pinnedExecutable ?? ownedExecutable;
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
- if (executable !== undefined && interpreter === undefined)
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
- result = await new Promise((resolvePromise, reject) => {
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
- 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
- });
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
- async function activationPackages(plan) {
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
- 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');
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
- async function activate(argv) {
675
- const trust = await commandTrust(argv);
676
- const store = new ControlPlaneStore({ path: trust.ledger.path });
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
- let plan = store.getPlan(option(argv, '--plan-id'));
964
+ throwIfAborted(signal);
965
+ let plan = store.getPlan(planId);
680
966
  assertPlanTrust(plan, trust);
681
967
  if (plan.status === 'activated') {
682
- process.stdout.write(`${JSON.stringify(plan)}\n`);
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
- plan = await store.claimActivation({ planId: plan.id, expectedRevision: integerOption(argv, '--expected-revision'), leaseMs,
686
- resolveApprovalAuthority: receipt => activationApprovalAuthority(trust, receipt) });
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
- process.stdout.write(`${JSON.stringify(await finishRollback(store, plan, lock))}\n`);
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 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;
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: await directoryExists(plan.target.profilePath) });
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
- const activationArtifacts = await fencedMutation(store, plan, () => activationPackages(plan));
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
- await closeActivationArtifactSnapshots(activationArtifacts.snapshots);
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
- process.stdout.write(`${JSON.stringify(plan)}\n`);
1054
+ return plan;
752
1055
  }
753
1056
  catch (error) {
754
- plan = advance(store, plan, 'rollback-pending', error instanceof ControlPlaneCliError ? error.code.toLowerCase().replaceAll('_', '-') : 'activation-failed');
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.runHostAttestationOperation({ operationId: receipt.operationId, expectedRevision, expectedFence,
782
- execute: async () => receipt, resolveAuthority });
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 => activationApprovalAuthority(trust, 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
- 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');
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 probe(argv) {
826
- const trust = await commandTrust(argv);
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
- const plan = store.getPlan(option(argv, '--plan-id'));
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 => activationApprovalAuthority(trust, 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
- 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');
1173
+ output = await finishCommit(store, output);
862
1174
  }
863
1175
  }
864
- process.stdout.write(`${JSON.stringify({ ...result, result: output })}\n`);
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 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;
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
- catch { /* next */ }
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
- 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');
1207
+ finally {
1208
+ store.close();
898
1209
  }
899
1210
  }
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();
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 sourcePathAllowed(path, plan) {
908
- const pluginRoot = `plugins/${plan.name}`;
909
- return path === pluginCatalogScope || path === pluginRoot || path.startsWith(`${pluginRoot}/`);
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
- export async function checkedSourceSnapshot(worktree, baseCommit, scopeInput, environment) {
912
- const temporary = await mkdtemp(join(tmpdir(), 'dsh-plugin-control-index-'));
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 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
- };
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
- await rm(temporary, { recursive: true, force: true });
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 localCommand('git', ['worktree', 'list', '--porcelain'], repository, environment, true)).split('\n')
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 localCommand('git', ['rev-parse', 'HEAD'], worktree, environment, true)).trim();
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 localCommand('git', ['rev-parse', 'HEAD'], plan.worktree, environment, true)).trim() !== plan.baseCommit
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 localCommand('git', ['status', '--porcelain'], plan.worktree, environment, true)).trim() !== '')
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
- await localCommand('pnpm', ['create:plugin', plan.name], plan.worktree, environment);
980
- await localCommand('pnpm', ['check'], plan.worktree, environment);
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 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 };
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: trust.releaseRegistry, catalog, adapter: adapterIdentity,
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.runSourceReleaseOperation({ operationId: operation.operationId, expectedRevision, expectedFence, execute: async () => receipt,
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, registry: trust.releaseRegistry, adapter: adapterIdentity,
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
- 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>');
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