owlrunkit 0.15.0 → 0.16.0

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 (76) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +274 -66
  3. package/integrations/codex/skills/owlcoda-runkit/SKILL.md +223 -117
  4. package/integrations/codex/skills/owlcoda-runkit/assets/templates/config.json +3 -3
  5. package/integrations/codex/skills/owlcoda-runkit/assets/templates/ready-for-commit-request.json +2 -2
  6. package/package.json +2 -2
  7. package/packages/attest/distribution-manifest-v1.json +10 -0
  8. package/packages/attest/src/quick.mjs +10 -0
  9. package/registry-release-policy-v1.json +8 -2
  10. package/sbom.spdx.json +5 -5
  11. package/scripts/runkit-contract/assurance-router.mjs +119 -0
  12. package/scripts/runkit-contract/core-contract.mjs +250 -115
  13. package/scripts/runkit-contract/core-successor.mjs +2043 -0
  14. package/scripts/runkit-contract/deployment-workflow.mjs +2040 -0
  15. package/scripts/runkit-contract/finalize.mjs +242 -21
  16. package/scripts/runkit-contract/fleet-discovery.mjs +456 -0
  17. package/scripts/runkit-contract/formal-workflow.mjs +850 -0
  18. package/scripts/runkit-contract/human-status.mjs +553 -0
  19. package/scripts/runkit-contract/inspect-presentation.mjs +25 -8
  20. package/scripts/runkit-contract/install-codex-skill.mjs +73 -30
  21. package/scripts/runkit-contract/lease-lifecycle.mjs +8 -2
  22. package/scripts/runkit-contract/lifecycle-orchestration.mjs +135 -12
  23. package/scripts/runkit-contract/onboarding-doctor.mjs +43 -90
  24. package/scripts/runkit-contract/owner-authority-trust.mjs +230 -0
  25. package/scripts/runkit-contract/owner-deployment-decision.mjs +385 -0
  26. package/scripts/runkit-contract/profile-onboarding.mjs +841 -13
  27. package/scripts/runkit-contract/project-cli-resolver.mjs +701 -0
  28. package/scripts/runkit-contract/project-control-parser.mjs +830 -0
  29. package/scripts/runkit-contract/project-control-state.mjs +28 -0
  30. package/scripts/runkit-contract/provenance-common.mjs +19 -2
  31. package/scripts/runkit-contract/ready-for-commit.mjs +330 -18
  32. package/scripts/runkit-contract/remote-deployment.mjs +1596 -0
  33. package/scripts/runkit-contract/remote-process-adapter.mjs +177 -0
  34. package/scripts/runkit-contract/runkit-bootstrap.mjs +131 -0
  35. package/scripts/runkit-contract/runkit-cli.mjs +1097 -21
  36. package/scripts/runkit-contract/schemas/assurance-route-v1.schema.json +26 -0
  37. package/scripts/runkit-contract/schemas/core-successor-batch-receipt-v1.schema.json +126 -0
  38. package/scripts/runkit-contract/schemas/core-successor-plan-v1.schema.json +155 -0
  39. package/scripts/runkit-contract/schemas/core-successor-project-success-receipt-v1.schema.json +72 -0
  40. package/scripts/runkit-contract/schemas/deployment-execute-request-v1.schema.json +63 -0
  41. package/scripts/runkit-contract/schemas/deployment-lineage-v1.schema.json +292 -0
  42. package/scripts/runkit-contract/schemas/deployment-prepare-receipt-v1.schema.json +140 -0
  43. package/scripts/runkit-contract/schemas/fleet-discovery-v1.schema.json +31 -0
  44. package/scripts/runkit-contract/schemas/fleet-manifest-v1.schema.json +20 -0
  45. package/scripts/runkit-contract/schemas/fleet-root-registry-v1.schema.json +34 -0
  46. package/scripts/runkit-contract/schemas/human-status-v1.schema.json +102 -0
  47. package/scripts/runkit-contract/schemas/owner-authority-trust-v1.schema.json +66 -0
  48. package/scripts/runkit-contract/schemas/owner-deployment-authority-v1.schema.json +46 -0
  49. package/scripts/runkit-contract/schemas/owner-deployment-authority-v2.schema.json +94 -0
  50. package/scripts/runkit-contract/schemas/owner-deployment-decision-v1.schema.json +102 -0
  51. package/scripts/runkit-contract/schemas/owner-migration-authority-v1.schema.json +33 -0
  52. package/scripts/runkit-contract/schemas/owner-migration-authority-v2.schema.json +67 -0
  53. package/scripts/runkit-contract/schemas/profile-apply-receipt-v1.schema.json +32 -0
  54. package/scripts/runkit-contract/schemas/profile-detection-v2.schema.json +145 -0
  55. package/scripts/runkit-contract/schemas/ready-for-commit-v2.schema.json +37 -0
  56. package/scripts/runkit-contract/schemas/remote-deployment-manifest-v1.schema.json +379 -0
  57. package/scripts/runkit-contract/schemas/remote-deployment-result-v1.schema.json +153 -0
  58. package/scripts/runkit-contract/schemas/remote-deployment-stage-journal-v1.schema.json +183 -0
  59. package/scripts/runkit-contract/schemas/remote-target-v1.schema.json +32 -0
  60. package/scripts/runkit-contract/schemas/source-candidate-v2.schema.json +152 -0
  61. package/scripts/runkit-contract/schemas/verification-receipt-v2.schema.json +13 -0
  62. package/scripts/runkit-contract/snapshot.mjs +1 -1
  63. package/scripts/runkit-contract/source-candidate.mjs +1382 -0
  64. package/scripts/runkit-contract/ssh-remote-adapter.mjs +1184 -0
  65. package/scripts/runkit-contract/sync-core016-release-identity.mjs +161 -0
  66. package/scripts/runkit-contract/templates/assurance-request.json +13 -0
  67. package/scripts/runkit-contract/templates/core-successor-owner-authority.json +15 -0
  68. package/scripts/runkit-contract/templates/deployment-execute-request.json +39 -0
  69. package/scripts/runkit-contract/templates/deployment-owner-authority.json +23 -0
  70. package/scripts/runkit-contract/templates/deployment-owner-decision.json +26 -0
  71. package/scripts/runkit-contract/templates/fleet-manifest.json +8 -0
  72. package/scripts/runkit-contract/templates/fleet-root-registry.json +8 -0
  73. package/scripts/runkit-contract/templates/owner-authority-trust.json +16 -0
  74. package/scripts/runkit-contract/templates/ready-for-commit-request.json +2 -2
  75. package/scripts/runkit-contract/templates/remote-deployment-manifest.json +108 -0
  76. package/scripts/runkit-contract/verification-receipt-gate.mjs +28 -0
@@ -0,0 +1,1596 @@
1
+ import { isIP } from "node:net";
2
+ import {
3
+ existsSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ readdirSync,
8
+ realpathSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import path from "node:path";
12
+
13
+ import {
14
+ assertAllowedKeys,
15
+ isRecord,
16
+ safeIdentifier,
17
+ safeRelativePath,
18
+ sha256,
19
+ } from "./provenance-common.mjs";
20
+ import {
21
+ validateOwnerDeploymentDecisionBindingV1,
22
+ } from "./owner-deployment-decision.mjs";
23
+
24
+ export const REMOTE_DEPLOYMENT_STAGES = Object.freeze([
25
+ "identity_preflight",
26
+ "upload",
27
+ "verify_remote_hashes",
28
+ "install",
29
+ "service",
30
+ "proxy",
31
+ "smoke",
32
+ ]);
33
+
34
+ export const SSH_REMOTE_HELPER_PROTOCOL_V1 =
35
+ "OwlCodaRunKitSshRemoteHelperV1";
36
+ export const SSH_REMOTE_HELPER_CAPABILITIES_V1 = Object.freeze([
37
+ "execute",
38
+ "reconcile",
39
+ ]);
40
+
41
+ const MANIFEST_KEYS = [
42
+ "schemaVersion",
43
+ "deploymentId",
44
+ "deploymentLineageSha256",
45
+ "mode",
46
+ "ownerDecision",
47
+ "serviceActivation",
48
+ "baselineCut",
49
+ "target",
50
+ "adapter",
51
+ "credentialRef",
52
+ "artifact",
53
+ "upload",
54
+ "priorDeployment",
55
+ "expectedRemoteFiles",
56
+ "deletionAllowlist",
57
+ ];
58
+ const TARGET_KEYS = [
59
+ "schemaVersion",
60
+ "targetId",
61
+ "environment",
62
+ "host",
63
+ "port",
64
+ "user",
65
+ "hostKeySha256",
66
+ "machineIdentitySha256",
67
+ ];
68
+ const PROCESS_ADAPTER_KEYS = [
69
+ "kind",
70
+ "adapterId",
71
+ "version",
72
+ "executable",
73
+ "sha256",
74
+ ];
75
+ const BUILTIN_SSH_ADAPTER_KEYS = [
76
+ "kind",
77
+ "adapterId",
78
+ "version",
79
+ "executable",
80
+ "sha256",
81
+ "knownHostsPath",
82
+ "sshExecutable",
83
+ "sshExecutableSha256",
84
+ "remoteHelper",
85
+ "authentication",
86
+ "stageContracts",
87
+ ];
88
+ const REMOTE_HELPER_KEYS = [
89
+ "path",
90
+ "protocol",
91
+ "version",
92
+ "capabilities",
93
+ ];
94
+ const AUTHENTICATION_KEYS = ["mode", "identityFile"];
95
+ const IDENTITY_FILE_KEYS = ["path", "sha256"];
96
+ const STAGE_CONTRACT_KEYS = ["install", "systemd", "nginx", "smoke"];
97
+ const INSTALL_CONTRACT_KEYS = [
98
+ "archiveFormat",
99
+ "releaseRoot",
100
+ "currentSymlink",
101
+ ];
102
+ const SYSTEMD_CONTRACT_KEYS = [
103
+ "unitName",
104
+ "unitFile",
105
+ "daemonReload",
106
+ "enable",
107
+ "restart",
108
+ ];
109
+ const NGINX_CONTRACT_KEYS = [
110
+ "siteName",
111
+ "configFile",
112
+ "enabledLinkPath",
113
+ "configTest",
114
+ "reload",
115
+ ];
116
+ const MANAGED_FILE_KEYS = ["sourcePath", "destinationPath", "sha256"];
117
+ const ARTIFACT_KEYS = ["path", "sha256", "size", "mediaType"];
118
+ const UPLOAD_KEYS = ["remotePath", "createOnly"];
119
+ const PRIOR_KEYS = ["receiptSha256", "artifactSha256"];
120
+ const EXPECTED_FILE_KEYS = ["path", "sha256"];
121
+ const DELETION_KEYS = ["path", "priorSha256"];
122
+ const SHA256 = /^[a-f0-9]{64}$/;
123
+ const SECRET_KEY = /(?:password|passwd|secret|token|private[_-]?key|credential)/i;
124
+ const CREDENTIAL_REF = /^(?:agent|keychain|vault):[A-Za-z0-9][A-Za-z0-9._/-]*$/;
125
+ const SAFE_HOSTNAME =
126
+ /^(?=.{1,253}\.?$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.?$/u;
127
+ const SAFE_REMOTE_PATH =
128
+ /^\/(?:[A-Za-z0-9._@%+=,-]+\/)*[A-Za-z0-9._@%+=,-]+$/u;
129
+ const SERVICE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.@-]*\.service$/u;
130
+ const IDEMPOTENT_REMOTE_STAGES = new Set([
131
+ "identity_preflight",
132
+ "upload",
133
+ "verify_remote_hashes",
134
+ "smoke",
135
+ ]);
136
+
137
+ function canonicalJson(value) {
138
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
139
+ if (value && typeof value === "object") {
140
+ return `{${Object.keys(value).sort()
141
+ .map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
142
+ .join(",")}}`;
143
+ }
144
+ const encoded = JSON.stringify(value);
145
+ if (encoded === undefined) throw new Error("Remote deployment value is not canonical JSON.");
146
+ return encoded;
147
+ }
148
+
149
+ function hashObject(value) {
150
+ return sha256(Buffer.from(canonicalJson(value), "utf8"));
151
+ }
152
+
153
+ function assertHash(value, label) {
154
+ if (typeof value !== "string" || !SHA256.test(value)) {
155
+ throw new Error(`${label} must be a lowercase SHA-256 digest.`);
156
+ }
157
+ return value;
158
+ }
159
+
160
+ function assertExactRemotePath(value, label) {
161
+ if (typeof value !== "string"
162
+ || !value.startsWith("/")
163
+ || value === "/"
164
+ || value.includes("\0")
165
+ || !SAFE_REMOTE_PATH.test(value)
166
+ || path.posix.normalize(value) !== value) {
167
+ throw new Error(
168
+ `${label} must be an exact remote path without shell metacharacters, globs, or traversal.`,
169
+ );
170
+ }
171
+ return value;
172
+ }
173
+
174
+ function hasSecretShapedField(value, { allowCredentialRef = false } = {}) {
175
+ if (Array.isArray(value)) {
176
+ return value.some(item => hasSecretShapedField(item, { allowCredentialRef }));
177
+ }
178
+ if (!isRecord(value)) return false;
179
+ return Object.entries(value).some(([key, nested]) => {
180
+ if (allowCredentialRef && key === "credentialRef") return false;
181
+ return SECRET_KEY.test(key)
182
+ || hasSecretShapedField(nested, { allowCredentialRef });
183
+ });
184
+ }
185
+
186
+ export function validateRemoteTarget(target) {
187
+ assertAllowedKeys(target, "Remote target", TARGET_KEYS);
188
+ if (target.schemaVersion !== "OwlCodaRunKitRemoteTargetV1") {
189
+ throw new Error("Unsupported remote target schemaVersion.");
190
+ }
191
+ safeIdentifier(target.targetId, "Remote targetId");
192
+ safeIdentifier(target.environment, "Remote environment");
193
+ if (typeof target.host !== "string"
194
+ || target.host.length === 0
195
+ || (isIP(target.host) === 0 && !SAFE_HOSTNAME.test(target.host))) {
196
+ throw new Error("Remote target host must be an exact host name or address.");
197
+ }
198
+ if (!Number.isInteger(target.port) || target.port < 1 || target.port > 65535) {
199
+ throw new Error("Remote target port must be an integer from 1 to 65535.");
200
+ }
201
+ if (typeof target.user !== "string"
202
+ || !/^[A-Za-z_][A-Za-z0-9._-]*$/.test(target.user)) {
203
+ throw new Error("Remote target user is invalid.");
204
+ }
205
+ assertHash(target.hostKeySha256, "Remote host key");
206
+ assertHash(target.machineIdentitySha256, "Remote machine identity");
207
+ return structuredClone(target);
208
+ }
209
+
210
+ export function validateSshRemoteHelperBindingV1(binding) {
211
+ if (binding === undefined || binding === null) {
212
+ throw new Error("SSH remote helper binding is required.");
213
+ }
214
+ assertAllowedKeys(
215
+ binding,
216
+ "SSH remote helper binding",
217
+ REMOTE_HELPER_KEYS,
218
+ );
219
+ const helperPath = assertExactRemotePath(
220
+ binding.path,
221
+ "SSH remote helper path",
222
+ );
223
+ if (path.posix.basename(helperPath) !== "owlrunkit-remote-helper") {
224
+ throw new Error(
225
+ "SSH remote helper executable name must be owlrunkit-remote-helper.",
226
+ );
227
+ }
228
+ if (binding.protocol !== SSH_REMOTE_HELPER_PROTOCOL_V1) {
229
+ throw new Error("SSH remote helper protocol is unsupported.");
230
+ }
231
+ if (
232
+ typeof binding.version !== "string"
233
+ || !/^\d+\.\d+\.\d+$/u.test(binding.version)
234
+ ) {
235
+ throw new Error("SSH remote helper version must be an exact semantic version.");
236
+ }
237
+ if (
238
+ !Array.isArray(binding.capabilities)
239
+ || binding.capabilities.length
240
+ !== SSH_REMOTE_HELPER_CAPABILITIES_V1.length
241
+ || binding.capabilities.some(
242
+ (capability, index) => (
243
+ capability !== SSH_REMOTE_HELPER_CAPABILITIES_V1[index]
244
+ ),
245
+ )
246
+ ) {
247
+ throw new Error(
248
+ "SSH remote helper capabilities must be exactly execute and reconcile.",
249
+ );
250
+ }
251
+ return {
252
+ path: helperPath,
253
+ protocol: binding.protocol,
254
+ version: binding.version,
255
+ capabilities: [...binding.capabilities],
256
+ };
257
+ }
258
+
259
+ function validateAdapterIdentityFields(adapter) {
260
+ safeIdentifier(adapter.adapterId, "Remote adapterId");
261
+ if (typeof adapter.version !== "string" || adapter.version.length === 0) {
262
+ throw new Error("Remote adapter version is required.");
263
+ }
264
+ if (typeof adapter.executable !== "string" || !path.isAbsolute(adapter.executable)) {
265
+ throw new Error("Remote adapter executable must be absolute.");
266
+ }
267
+ assertHash(adapter.sha256, "Remote adapter");
268
+ return {
269
+ adapterId: adapter.adapterId,
270
+ version: adapter.version,
271
+ executable: adapter.executable,
272
+ sha256: adapter.sha256,
273
+ };
274
+ }
275
+
276
+ function assertAbsoluteLocalPath(value, label) {
277
+ if (
278
+ typeof value !== "string"
279
+ || !path.isAbsolute(value)
280
+ || value.includes("\0")
281
+ || /[\r\n]/u.test(value)
282
+ || path.normalize(value) !== value
283
+ ) {
284
+ throw new Error(`${label} must be an exact absolute local path.`);
285
+ }
286
+ return value;
287
+ }
288
+
289
+ function validateManagedFile(value, label) {
290
+ assertAllowedKeys(value, label, MANAGED_FILE_KEYS);
291
+ return {
292
+ sourcePath: safeRelativePath(value.sourcePath, `${label} sourcePath`),
293
+ destinationPath: assertExactRemotePath(
294
+ value.destinationPath,
295
+ `${label} destinationPath`,
296
+ ),
297
+ sha256: assertHash(value.sha256, `${label} SHA-256`),
298
+ };
299
+ }
300
+
301
+ function validateSshStageContracts(contracts) {
302
+ assertAllowedKeys(contracts, "Built-in SSH stage contracts", STAGE_CONTRACT_KEYS);
303
+ assertAllowedKeys(
304
+ contracts.install,
305
+ "Built-in SSH install contract",
306
+ INSTALL_CONTRACT_KEYS,
307
+ );
308
+ if (contracts.install.archiveFormat !== "tar_gzip") {
309
+ throw new Error("Built-in SSH archiveFormat must be tar_gzip.");
310
+ }
311
+ const install = {
312
+ archiveFormat: "tar_gzip",
313
+ releaseRoot: assertExactRemotePath(
314
+ contracts.install.releaseRoot,
315
+ "Built-in SSH releaseRoot",
316
+ ),
317
+ currentSymlink: assertExactRemotePath(
318
+ contracts.install.currentSymlink,
319
+ "Built-in SSH currentSymlink",
320
+ ),
321
+ };
322
+ assertAllowedKeys(
323
+ contracts.systemd,
324
+ "Built-in SSH systemd contract",
325
+ SYSTEMD_CONTRACT_KEYS,
326
+ );
327
+ if (
328
+ typeof contracts.systemd.unitName !== "string"
329
+ || !SERVICE_NAME.test(contracts.systemd.unitName)
330
+ ) {
331
+ throw new Error("Built-in SSH systemd unitName is invalid.");
332
+ }
333
+ for (const key of ["daemonReload", "enable", "restart"]) {
334
+ if (typeof contracts.systemd[key] !== "boolean") {
335
+ throw new Error(`Built-in SSH systemd ${key} must be boolean.`);
336
+ }
337
+ }
338
+ const systemd = {
339
+ unitName: contracts.systemd.unitName,
340
+ unitFile: validateManagedFile(
341
+ contracts.systemd.unitFile,
342
+ "Built-in SSH systemd unit file",
343
+ ),
344
+ daemonReload: contracts.systemd.daemonReload,
345
+ enable: contracts.systemd.enable,
346
+ restart: contracts.systemd.restart,
347
+ };
348
+ assertAllowedKeys(
349
+ contracts.nginx,
350
+ "Built-in SSH Nginx contract",
351
+ NGINX_CONTRACT_KEYS,
352
+ );
353
+ safeIdentifier(contracts.nginx.siteName, "Built-in SSH Nginx siteName");
354
+ for (const key of ["configTest", "reload"]) {
355
+ if (typeof contracts.nginx[key] !== "boolean") {
356
+ throw new Error(`Built-in SSH Nginx ${key} must be boolean.`);
357
+ }
358
+ }
359
+ const nginx = {
360
+ siteName: contracts.nginx.siteName,
361
+ configFile: validateManagedFile(
362
+ contracts.nginx.configFile,
363
+ "Built-in SSH Nginx config file",
364
+ ),
365
+ enabledLinkPath: assertExactRemotePath(
366
+ contracts.nginx.enabledLinkPath,
367
+ "Built-in SSH Nginx enabledLinkPath",
368
+ ),
369
+ configTest: contracts.nginx.configTest,
370
+ reload: contracts.nginx.reload,
371
+ };
372
+ assertAllowedKeys(contracts.smoke, "Built-in SSH smoke contract", ["checks"]);
373
+ if (!Array.isArray(contracts.smoke.checks)
374
+ || contracts.smoke.checks.length === 0) {
375
+ throw new Error("Built-in SSH smoke contract requires checks.");
376
+ }
377
+ const checks = contracts.smoke.checks.map((check) => {
378
+ if (check?.kind === "systemd_active") {
379
+ assertAllowedKeys(check, "Built-in SSH systemd smoke check", [
380
+ "checkId",
381
+ "kind",
382
+ "unitName",
383
+ ]);
384
+ safeIdentifier(check.checkId, "Built-in SSH smoke checkId");
385
+ if (typeof check.unitName !== "string" || !SERVICE_NAME.test(check.unitName)) {
386
+ throw new Error("Built-in SSH smoke unitName is invalid.");
387
+ }
388
+ return structuredClone(check);
389
+ }
390
+ if (check?.kind === "http") {
391
+ assertAllowedKeys(check, "Built-in SSH HTTP smoke check", [
392
+ "checkId",
393
+ "kind",
394
+ "url",
395
+ "expectedStatus",
396
+ ]);
397
+ safeIdentifier(check.checkId, "Built-in SSH smoke checkId");
398
+ let parsed;
399
+ try {
400
+ parsed = new URL(check.url);
401
+ } catch {
402
+ throw new Error("Built-in SSH smoke URL is invalid.");
403
+ }
404
+ if (
405
+ parsed.protocol !== "http:"
406
+ || !new Set(["127.0.0.1", "[::1]", "localhost"]).has(parsed.hostname)
407
+ || parsed.username !== ""
408
+ || parsed.password !== ""
409
+ || parsed.search !== ""
410
+ || parsed.hash !== ""
411
+ || !/^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*$/u.test(parsed.pathname)
412
+ || !Number.isInteger(check.expectedStatus)
413
+ || check.expectedStatus < 100
414
+ || check.expectedStatus > 599
415
+ ) {
416
+ throw new Error("Built-in SSH HTTP smoke check is invalid.");
417
+ }
418
+ return structuredClone(check);
419
+ }
420
+ throw new Error("Built-in SSH smoke check kind is unsupported.");
421
+ });
422
+ if (new Set(checks.map(check => check.checkId)).size !== checks.length) {
423
+ throw new Error("Built-in SSH smoke checkId values must be unique.");
424
+ }
425
+ return {
426
+ install,
427
+ systemd,
428
+ nginx,
429
+ smoke: { checks },
430
+ };
431
+ }
432
+
433
+ function validateBuiltinSshAdapter(adapter) {
434
+ assertAllowedKeys(
435
+ adapter,
436
+ "Built-in SSH adapter declaration",
437
+ BUILTIN_SSH_ADAPTER_KEYS,
438
+ );
439
+ const identity = validateAdapterIdentityFields(adapter);
440
+ if (
441
+ adapter.kind !== "builtin_ssh"
442
+ || identity.adapterId !== "builtin-ssh-v1"
443
+ || identity.version !== "1.0.0"
444
+ ) {
445
+ throw new Error("Built-in SSH adapter identity is invalid.");
446
+ }
447
+ assertAllowedKeys(
448
+ adapter.authentication,
449
+ "Built-in SSH authentication binding",
450
+ AUTHENTICATION_KEYS,
451
+ );
452
+ let identityFile = null;
453
+ if (adapter.authentication.mode === "agent") {
454
+ if (adapter.authentication.identityFile !== null) {
455
+ throw new Error("Agent SSH authentication cannot declare an identity file.");
456
+ }
457
+ } else if (adapter.authentication.mode === "identity_file") {
458
+ assertAllowedKeys(
459
+ adapter.authentication.identityFile,
460
+ "Built-in SSH identity file binding",
461
+ IDENTITY_FILE_KEYS,
462
+ );
463
+ identityFile = {
464
+ path: assertAbsoluteLocalPath(
465
+ adapter.authentication.identityFile.path,
466
+ "Built-in SSH identity file",
467
+ ),
468
+ sha256: assertHash(
469
+ adapter.authentication.identityFile.sha256,
470
+ "Built-in SSH identity file",
471
+ ),
472
+ };
473
+ } else {
474
+ throw new Error("Built-in SSH authentication mode is invalid.");
475
+ }
476
+ return {
477
+ ...identity,
478
+ kind: "builtin_ssh",
479
+ knownHostsPath: assertAbsoluteLocalPath(
480
+ adapter.knownHostsPath,
481
+ "Built-in SSH known_hosts",
482
+ ),
483
+ sshExecutable: assertAbsoluteLocalPath(
484
+ adapter.sshExecutable,
485
+ "Built-in SSH executable",
486
+ ),
487
+ sshExecutableSha256: assertHash(
488
+ adapter.sshExecutableSha256,
489
+ "Built-in SSH executable",
490
+ ),
491
+ remoteHelper: validateSshRemoteHelperBindingV1(adapter.remoteHelper),
492
+ authentication: {
493
+ mode: adapter.authentication.mode,
494
+ identityFile,
495
+ },
496
+ stageContracts: validateSshStageContracts(adapter.stageContracts),
497
+ };
498
+ }
499
+
500
+ function validateAdapterDeclaration(adapter) {
501
+ if (adapter?.kind === "builtin_ssh") {
502
+ return validateBuiltinSshAdapter(adapter);
503
+ }
504
+ const allowed = adapter?.kind === "process"
505
+ ? PROCESS_ADAPTER_KEYS
506
+ : PROCESS_ADAPTER_KEYS.filter(key => key !== "kind");
507
+ assertAllowedKeys(adapter, "Remote process adapter identity", allowed);
508
+ if (adapter.kind !== undefined && adapter.kind !== "process") {
509
+ throw new Error("Remote adapter kind is unsupported.");
510
+ }
511
+ const identity = validateAdapterIdentityFields(adapter);
512
+ return adapter.kind === "process"
513
+ ? { kind: "process", ...identity }
514
+ : identity;
515
+ }
516
+
517
+ function adapterRuntimeIdentity(adapter) {
518
+ return validateAdapterIdentityFields(adapter);
519
+ }
520
+
521
+ function validateArtifact(artifact) {
522
+ assertAllowedKeys(artifact, "Remote artifact", ARTIFACT_KEYS);
523
+ safeRelativePath(artifact.path, "Remote artifact path");
524
+ assertHash(artifact.sha256, "Remote artifact");
525
+ if (!Number.isInteger(artifact.size) || artifact.size < 0) {
526
+ throw new Error("Remote artifact size must be a non-negative integer.");
527
+ }
528
+ if (typeof artifact.mediaType !== "string"
529
+ || !/^[A-Za-z0-9.+-]+\/[A-Za-z0-9.+-]+$/.test(artifact.mediaType)) {
530
+ throw new Error("Remote artifact mediaType is invalid.");
531
+ }
532
+ return structuredClone(artifact);
533
+ }
534
+
535
+ function validateExpectedFiles(entries) {
536
+ if (!Array.isArray(entries)) throw new Error("expectedRemoteFiles must be an array.");
537
+ const paths = new Set();
538
+ return entries.map((entry) => {
539
+ assertAllowedKeys(entry, "Expected remote file", EXPECTED_FILE_KEYS);
540
+ const remotePath = assertExactRemotePath(entry.path, "Expected remote file path");
541
+ if (paths.has(remotePath)) throw new Error(`Duplicate expected remote file: ${remotePath}`);
542
+ paths.add(remotePath);
543
+ return { path: remotePath, sha256: assertHash(entry.sha256, "Expected remote file") };
544
+ });
545
+ }
546
+
547
+ function validateDeletionAllowlist(entries, expectedRemoteFiles) {
548
+ if (!Array.isArray(entries)) throw new Error("deletionAllowlist must be an array.");
549
+ const expected = new Map(expectedRemoteFiles.map(entry => [entry.path, entry.sha256]));
550
+ const paths = new Set();
551
+ return entries.map((entry) => {
552
+ assertAllowedKeys(entry, "Deletion allowlist entry", DELETION_KEYS);
553
+ const remotePath = assertExactRemotePath(entry.path, "Deletion allowlist path");
554
+ if (paths.has(remotePath)) throw new Error(`Duplicate deletion allowlist path: ${remotePath}`);
555
+ paths.add(remotePath);
556
+ const priorSha256 = assertHash(entry.priorSha256, "Deletion prior hash");
557
+ if (expected.get(remotePath) !== priorSha256) {
558
+ throw new Error(`Deletion allowlist entry does not match the expected prior hash: ${remotePath}`);
559
+ }
560
+ return { path: remotePath, priorSha256 };
561
+ });
562
+ }
563
+
564
+ function normalizeRemoteDeploymentManifest(
565
+ manifest,
566
+ { allowUnboundLineage = false } = {},
567
+ ) {
568
+ if (hasSecretShapedField(manifest, { allowCredentialRef: true })) {
569
+ throw new Error("Remote deployment manifest contains secret material.");
570
+ }
571
+ assertAllowedKeys(manifest, "Remote deployment manifest", MANIFEST_KEYS);
572
+ if (manifest.schemaVersion !== "OwlCodaRunKitRemoteDeploymentManifestV1") {
573
+ throw new Error("Unsupported remote deployment manifest schemaVersion.");
574
+ }
575
+ safeIdentifier(manifest.deploymentId, "Remote deploymentId");
576
+ const deploymentLineageSha256 = allowUnboundLineage
577
+ && manifest.deploymentLineageSha256 === null
578
+ ? null
579
+ : assertHash(manifest.deploymentLineageSha256, "Deployment lineage");
580
+ if (!new Set(["first", "update"]).has(manifest.mode)) {
581
+ throw new Error("Remote deployment mode must be first or update.");
582
+ }
583
+ const target = validateRemoteTarget(manifest.target);
584
+ const adapter = validateAdapterDeclaration(manifest.adapter);
585
+ if (typeof manifest.credentialRef !== "string"
586
+ || !CREDENTIAL_REF.test(manifest.credentialRef)) {
587
+ throw new Error("Remote deployment credentialRef must be an opaque credential reference.");
588
+ }
589
+ const artifact = validateArtifact(manifest.artifact);
590
+ assertAllowedKeys(manifest.upload, "Remote upload", UPLOAD_KEYS);
591
+ const upload = {
592
+ remotePath: assertExactRemotePath(manifest.upload.remotePath, "Remote upload path"),
593
+ createOnly: manifest.upload.createOnly,
594
+ };
595
+ if (upload.createOnly !== true) {
596
+ throw new Error("Remote deployment upload must be create-only.");
597
+ }
598
+ const expectedRemoteFiles = validateExpectedFiles(manifest.expectedRemoteFiles);
599
+ let priorDeployment = null;
600
+ if (manifest.mode === "first") {
601
+ if (manifest.priorDeployment !== null) {
602
+ throw new Error("First deployment priorDeployment must be null.");
603
+ }
604
+ if (expectedRemoteFiles.length > 0 || manifest.deletionAllowlist.length > 0) {
605
+ throw new Error("First deployment cannot declare prior files or deletions.");
606
+ }
607
+ } else {
608
+ if (!isRecord(manifest.priorDeployment)) {
609
+ throw new Error("Update deployment priorDeployment must be an object.");
610
+ }
611
+ assertAllowedKeys(manifest.priorDeployment, "Prior deployment", PRIOR_KEYS);
612
+ priorDeployment = {
613
+ receiptSha256: assertHash(
614
+ manifest.priorDeployment.receiptSha256,
615
+ "Prior deployment receipt",
616
+ ),
617
+ artifactSha256: assertHash(
618
+ manifest.priorDeployment.artifactSha256,
619
+ "Prior deployment artifact",
620
+ ),
621
+ };
622
+ if (expectedRemoteFiles.length === 0) {
623
+ throw new Error("Update deployment requires expected prior remote file hashes.");
624
+ }
625
+ }
626
+ const deletionAllowlist = validateDeletionAllowlist(
627
+ manifest.deletionAllowlist,
628
+ expectedRemoteFiles,
629
+ );
630
+ const ownerDecision = manifest.ownerDecision === undefined
631
+ ? null
632
+ : validateOwnerDeploymentDecisionBindingV1(manifest.ownerDecision);
633
+ for (const field of ["serviceActivation", "baselineCut"]) {
634
+ if (manifest[field] !== undefined && typeof manifest[field] !== "boolean") {
635
+ throw new Error(`Remote deployment ${field} must be boolean when declared.`);
636
+ }
637
+ }
638
+ return {
639
+ status: "valid",
640
+ normalized: {
641
+ schemaVersion: manifest.schemaVersion,
642
+ deploymentId: manifest.deploymentId,
643
+ deploymentLineageSha256,
644
+ mode: manifest.mode,
645
+ ...(ownerDecision === null ? {} : { ownerDecision }),
646
+ ...(manifest.serviceActivation === undefined
647
+ ? {}
648
+ : { serviceActivation: manifest.serviceActivation }),
649
+ ...(manifest.baselineCut === undefined
650
+ ? {}
651
+ : { baselineCut: manifest.baselineCut }),
652
+ target,
653
+ adapter,
654
+ credentialRef: manifest.credentialRef,
655
+ artifact,
656
+ upload,
657
+ priorDeployment,
658
+ expectedRemoteFiles,
659
+ deletionAllowlist,
660
+ },
661
+ };
662
+ }
663
+
664
+ export function validateRemoteDeploymentManifest(manifest) {
665
+ return normalizeRemoteDeploymentManifest(manifest);
666
+ }
667
+
668
+ export function canonicalRemoteDeploymentIntentSha256(manifest) {
669
+ const { normalized } = normalizeRemoteDeploymentManifest(
670
+ manifest,
671
+ { allowUnboundLineage: true },
672
+ );
673
+ if (normalized.deploymentLineageSha256 !== null) {
674
+ throw new Error(
675
+ "Remote deployment intent must leave deploymentLineageSha256 unbound.",
676
+ );
677
+ }
678
+ const {
679
+ deploymentLineageSha256: _derivedLineage,
680
+ ...intent
681
+ } = normalized;
682
+ return hashObject(intent);
683
+ }
684
+
685
+ function identitiesEqual(left, right) {
686
+ return canonicalJson(left) === canonicalJson(right);
687
+ }
688
+
689
+ function writeCreateOnlyJson(filePath, value) {
690
+ const bytes = `${JSON.stringify(value, null, 2)}\n`;
691
+ writeFileSync(filePath, bytes, { flag: "wx" });
692
+ return sha256(Buffer.from(bytes, "utf8"));
693
+ }
694
+
695
+ function readJournalArtifact(filePath, label) {
696
+ const stat = lstatSync(filePath);
697
+ if (stat.isSymbolicLink() || !stat.isFile()) {
698
+ throw new Error(`${label} must be a regular create-only artifact.`);
699
+ }
700
+ const bytes = readFileSync(filePath);
701
+ let value;
702
+ try {
703
+ value = JSON.parse(bytes.toString("utf8"));
704
+ } catch {
705
+ throw new Error(`${label} must contain valid JSON.`);
706
+ }
707
+ return { value, bytes, sha256: sha256(bytes) };
708
+ }
709
+
710
+ function journalFileName(stage, operation, attempt, phase) {
711
+ const ordinal = String(REMOTE_DEPLOYMENT_STAGES.indexOf(stage) + 1)
712
+ .padStart(2, "0");
713
+ return `${ordinal}-${stage}-${operation}-attempt-${String(attempt)
714
+ .padStart(3, "0")}-${phase}.json`;
715
+ }
716
+
717
+ function assertJournalBinding(binding) {
718
+ assertAllowedKeys(binding, "Remote deployment journal binding", [
719
+ "deploymentId",
720
+ "deploymentLineageSha256",
721
+ "remoteManifestSha256",
722
+ "executeRequestSha256",
723
+ "adapterIdentity",
724
+ ]);
725
+ safeIdentifier(binding.deploymentId, "Remote deployment journal deploymentId");
726
+ assertHash(
727
+ binding.deploymentLineageSha256,
728
+ "Remote deployment journal lineage",
729
+ );
730
+ assertHash(
731
+ binding.remoteManifestSha256,
732
+ "Remote deployment journal manifest",
733
+ );
734
+ assertHash(
735
+ binding.executeRequestSha256,
736
+ "Remote deployment journal execute request",
737
+ );
738
+ const adapterIdentity = validateAdapterIdentityFields(binding.adapterIdentity);
739
+ const body = {
740
+ schemaVersion: "OwlCodaRunKitRemoteDeploymentJournalBindingV1",
741
+ deploymentId: binding.deploymentId,
742
+ deploymentLineageSha256: binding.deploymentLineageSha256,
743
+ remoteManifestSha256: binding.remoteManifestSha256,
744
+ executeRequestSha256: binding.executeRequestSha256,
745
+ adapterIdentity,
746
+ adapterIdentitySha256: hashObject(adapterIdentity),
747
+ authorizationGranted: false,
748
+ };
749
+ return { ...body, bindingSha256: hashObject(body) };
750
+ }
751
+
752
+ function invocationFiles(root, stage, operation) {
753
+ const prefix = `${String(REMOTE_DEPLOYMENT_STAGES.indexOf(stage) + 1)
754
+ .padStart(2, "0")}-${stage}-${operation}-attempt-`;
755
+ const names = readdirSync(root);
756
+ const beforeNames = names
757
+ .filter(name => name.startsWith(prefix) && name.endsWith("-before.json"));
758
+ const beforeNameSet = new Set(beforeNames);
759
+ for (const afterName of names.filter(
760
+ name => name.startsWith(prefix) && name.endsWith("-after.json"),
761
+ )) {
762
+ const beforeName = afterName.replace(/-after\.json$/u, "-before.json");
763
+ if (!beforeNameSet.has(beforeName)) {
764
+ throw new Error("Remote deployment stage journal has an orphan after artifact.");
765
+ }
766
+ }
767
+ const files = beforeNames
768
+ .sort()
769
+ .map((name) => {
770
+ const match = name.match(/-attempt-(\d{3})-before\.json$/u);
771
+ if (!match) throw new Error("Remote deployment journal filename is invalid.");
772
+ const attempt = Number.parseInt(match[1], 10);
773
+ const beforePath = path.join(root, name);
774
+ const afterPath = path.join(
775
+ root,
776
+ journalFileName(stage, operation, attempt, "after"),
777
+ );
778
+ return { attempt, beforePath, afterPath };
779
+ });
780
+ if (files.some((entry, index) => entry.attempt !== index + 1)) {
781
+ throw new Error("Remote deployment stage journal attempts are not contiguous.");
782
+ }
783
+ return files;
784
+ }
785
+
786
+ export function createRemoteDeploymentStageJournalV1({
787
+ journalRoot,
788
+ binding,
789
+ }) {
790
+ if (typeof journalRoot !== "string" || !path.isAbsolute(journalRoot)) {
791
+ throw new Error("Remote deployment journal root must be absolute.");
792
+ }
793
+ mkdirSync(journalRoot, { recursive: true });
794
+ const rootStat = lstatSync(journalRoot);
795
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
796
+ throw new Error("Remote deployment journal root must be a real directory.");
797
+ }
798
+ const root = realpathSync(journalRoot);
799
+ const normalizedBinding = assertJournalBinding(binding);
800
+ const bindingPath = path.join(root, "journal-binding.json");
801
+ if (existsSync(bindingPath)) {
802
+ const existing = readJournalArtifact(
803
+ bindingPath,
804
+ "Remote deployment journal binding",
805
+ ).value;
806
+ if (!identitiesEqual(existing, normalizedBinding)) {
807
+ throw new Error("Remote deployment journal binding drifted.");
808
+ }
809
+ } else {
810
+ writeCreateOnlyJson(bindingPath, normalizedBinding);
811
+ }
812
+
813
+ function validateBefore(value, stage, operation) {
814
+ assertAllowedKeys(value, "Remote deployment stage before journal", [
815
+ "schemaVersion",
816
+ "bindingSha256",
817
+ "deploymentId",
818
+ "deploymentLineageSha256",
819
+ "remoteManifestSha256",
820
+ "executeRequestSha256",
821
+ "adapterIdentitySha256",
822
+ "stage",
823
+ "stageOrdinal",
824
+ "operation",
825
+ "attempt",
826
+ "status",
827
+ "authorizationGranted",
828
+ "journalSha256",
829
+ ]);
830
+ if (
831
+ value?.schemaVersion !== "OwlCodaRunKitRemoteStageInvocationBeforeV1"
832
+ || value.bindingSha256 !== normalizedBinding.bindingSha256
833
+ || value.deploymentId !== normalizedBinding.deploymentId
834
+ || value.deploymentLineageSha256
835
+ !== normalizedBinding.deploymentLineageSha256
836
+ || value.remoteManifestSha256
837
+ !== normalizedBinding.remoteManifestSha256
838
+ || value.executeRequestSha256
839
+ !== normalizedBinding.executeRequestSha256
840
+ || value.adapterIdentitySha256
841
+ !== normalizedBinding.adapterIdentitySha256
842
+ || value.stage !== stage
843
+ || value.stageOrdinal !== REMOTE_DEPLOYMENT_STAGES.indexOf(stage) + 1
844
+ || value.operation !== operation
845
+ || !Number.isSafeInteger(value.attempt)
846
+ || value.attempt < 1
847
+ || value.status !== "invocation_started"
848
+ || value.authorizationGranted !== false
849
+ ) {
850
+ throw new Error("Remote deployment stage before journal is invalid.");
851
+ }
852
+ const { journalSha256, ...body } = value;
853
+ if (journalSha256 !== hashObject(body)) {
854
+ throw new Error("Remote deployment stage before journal hash drifted.");
855
+ }
856
+ }
857
+
858
+ function validateAfter(value, before, stage, operation) {
859
+ assertAllowedKeys(value, "Remote deployment stage after journal", [
860
+ "schemaVersion",
861
+ "bindingSha256",
862
+ "beforeJournalSha256",
863
+ "stage",
864
+ "operation",
865
+ "attempt",
866
+ "outcome",
867
+ "stageReceipt",
868
+ "recoveredByJournalSha256",
869
+ "authorizationGranted",
870
+ "journalSha256",
871
+ ]);
872
+ const { journalSha256, ...afterBody } = value;
873
+ if (
874
+ value?.schemaVersion !== "OwlCodaRunKitRemoteStageInvocationAfterV1"
875
+ || value.bindingSha256 !== normalizedBinding.bindingSha256
876
+ || value.beforeJournalSha256 !== before.journalSha256
877
+ || value.stage !== stage
878
+ || value.operation !== operation
879
+ || value.attempt !== before.attempt
880
+ || !new Set(["completed", "indeterminate"]).has(value.outcome)
881
+ || value.authorizationGranted !== false
882
+ || journalSha256 !== hashObject(afterBody)
883
+ ) {
884
+ throw new Error("Remote deployment stage after journal is invalid.");
885
+ }
886
+ }
887
+
888
+ function readValidatedInvocationHistory(stage, operation) {
889
+ const files = invocationFiles(root, stage, operation);
890
+ return files.map((entry) => {
891
+ const before = readJournalArtifact(
892
+ entry.beforePath,
893
+ "Remote deployment stage before journal",
894
+ ).value;
895
+ validateBefore(before, stage, operation);
896
+ let after = null;
897
+ if (existsSync(entry.afterPath)) {
898
+ after = readJournalArtifact(
899
+ entry.afterPath,
900
+ "Remote deployment stage after journal",
901
+ ).value;
902
+ validateAfter(after, before, stage, operation);
903
+ }
904
+ return { ...entry, before, after };
905
+ });
906
+ }
907
+
908
+ function readState(stage, operation = "execute") {
909
+ const validated = readValidatedInvocationHistory(stage, operation);
910
+ if (operation === "execute") {
911
+ readValidatedInvocationHistory(stage, "reconcile");
912
+ }
913
+ if (validated.length === 0) return { status: "none" };
914
+ const latest = validated.at(-1);
915
+ const before = latest.before;
916
+ if (operation === "execute") {
917
+ const recoveryPrefix = `${String(
918
+ REMOTE_DEPLOYMENT_STAGES.indexOf(stage) + 1,
919
+ ).padStart(2, "0")}-${stage}-recovery-attempt-`;
920
+ const recoveryNames = readdirSync(root)
921
+ .filter(name => (
922
+ name.startsWith(recoveryPrefix)
923
+ && name.endsWith(".json")
924
+ ))
925
+ .sort();
926
+ if (recoveryNames.length > 0) {
927
+ const recovery = readJournalArtifact(
928
+ path.join(root, recoveryNames.at(-1)),
929
+ "Remote deployment stage recovery journal",
930
+ ).value;
931
+ assertAllowedKeys(
932
+ recovery,
933
+ "Remote deployment stage recovery journal",
934
+ [
935
+ "schemaVersion",
936
+ "bindingSha256",
937
+ "stage",
938
+ "attempt",
939
+ "executeBeforeJournalSha256",
940
+ "reconciliationAfterJournalSha256",
941
+ "status",
942
+ "stageReceipt",
943
+ "authorizationGranted",
944
+ "journalSha256",
945
+ ],
946
+ );
947
+ const { journalSha256, ...recoveryBody } = recovery;
948
+ if (
949
+ recovery?.schemaVersion
950
+ !== "OwlCodaRunKitRemoteStageRecoveryV1"
951
+ || recovery.bindingSha256 !== normalizedBinding.bindingSha256
952
+ || recovery.stage !== stage
953
+ || recovery.executeBeforeJournalSha256 !== before.journalSha256
954
+ || !SHA256.test(recovery.reconciliationAfterJournalSha256)
955
+ || recovery.status !== "state_proven_consistent"
956
+ || recovery.authorizationGranted !== false
957
+ || journalSha256 !== hashObject(recoveryBody)
958
+ ) {
959
+ throw new Error("Remote deployment stage recovery journal is invalid.");
960
+ }
961
+ return {
962
+ status: "completed",
963
+ operation,
964
+ before,
965
+ recovery,
966
+ stageReceipt: structuredClone(recovery.stageReceipt),
967
+ };
968
+ }
969
+ }
970
+ if (latest.after === null) {
971
+ return {
972
+ status: "interrupted",
973
+ operation,
974
+ before,
975
+ };
976
+ }
977
+ const after = latest.after;
978
+ return {
979
+ status: after.outcome === "completed"
980
+ ? "completed"
981
+ : "indeterminate",
982
+ operation,
983
+ before,
984
+ after,
985
+ stageReceipt: structuredClone(after.stageReceipt),
986
+ };
987
+ }
988
+
989
+ return {
990
+ binding: structuredClone(normalizedBinding),
991
+ journalRoot: root,
992
+ readStageState(stage) {
993
+ if (!REMOTE_DEPLOYMENT_STAGES.includes(stage)) {
994
+ throw new Error("Remote deployment journal stage is invalid.");
995
+ }
996
+ return readState(stage, "execute");
997
+ },
998
+ beginInvocation({ stage, operation }) {
999
+ if (
1000
+ !REMOTE_DEPLOYMENT_STAGES.includes(stage)
1001
+ || !new Set(["execute", "reconcile"]).has(operation)
1002
+ ) {
1003
+ throw new Error("Remote deployment journal invocation is invalid.");
1004
+ }
1005
+ const attempt = invocationFiles(root, stage, operation).length + 1;
1006
+ const body = {
1007
+ schemaVersion: "OwlCodaRunKitRemoteStageInvocationBeforeV1",
1008
+ bindingSha256: normalizedBinding.bindingSha256,
1009
+ deploymentId: normalizedBinding.deploymentId,
1010
+ deploymentLineageSha256:
1011
+ normalizedBinding.deploymentLineageSha256,
1012
+ remoteManifestSha256: normalizedBinding.remoteManifestSha256,
1013
+ executeRequestSha256: normalizedBinding.executeRequestSha256,
1014
+ adapterIdentitySha256: normalizedBinding.adapterIdentitySha256,
1015
+ stage,
1016
+ stageOrdinal: REMOTE_DEPLOYMENT_STAGES.indexOf(stage) + 1,
1017
+ operation,
1018
+ attempt,
1019
+ status: "invocation_started",
1020
+ authorizationGranted: false,
1021
+ };
1022
+ const before = { ...body, journalSha256: hashObject(body) };
1023
+ writeCreateOnlyJson(
1024
+ path.join(root, journalFileName(stage, operation, attempt, "before")),
1025
+ before,
1026
+ );
1027
+ return before;
1028
+ },
1029
+ completeInvocation({
1030
+ stage,
1031
+ operation,
1032
+ before,
1033
+ outcome,
1034
+ stageReceipt,
1035
+ recoveredByJournalSha256 = null,
1036
+ }) {
1037
+ validateBefore(before, stage, operation);
1038
+ if (!new Set(["completed", "indeterminate"]).has(outcome)) {
1039
+ throw new Error("Remote deployment journal outcome is invalid.");
1040
+ }
1041
+ const body = {
1042
+ schemaVersion: "OwlCodaRunKitRemoteStageInvocationAfterV1",
1043
+ bindingSha256: normalizedBinding.bindingSha256,
1044
+ beforeJournalSha256: before.journalSha256,
1045
+ stage,
1046
+ operation,
1047
+ attempt: before.attempt,
1048
+ outcome,
1049
+ stageReceipt: structuredClone(stageReceipt),
1050
+ ...(recoveredByJournalSha256
1051
+ ? { recoveredByJournalSha256 }
1052
+ : {}),
1053
+ authorizationGranted: false,
1054
+ };
1055
+ const after = { ...body, journalSha256: hashObject(body) };
1056
+ writeCreateOnlyJson(
1057
+ path.join(
1058
+ root,
1059
+ journalFileName(stage, operation, before.attempt, "after"),
1060
+ ),
1061
+ after,
1062
+ );
1063
+ return after;
1064
+ },
1065
+ recoverInvocation({
1066
+ stage,
1067
+ executeBefore,
1068
+ reconciliationAfter,
1069
+ stageReceipt,
1070
+ }) {
1071
+ validateBefore(executeBefore, stage, "execute");
1072
+ if (
1073
+ reconciliationAfter?.schemaVersion
1074
+ !== "OwlCodaRunKitRemoteStageInvocationAfterV1"
1075
+ || reconciliationAfter.stage !== stage
1076
+ || reconciliationAfter.operation !== "reconcile"
1077
+ || reconciliationAfter.outcome !== "completed"
1078
+ || !SHA256.test(reconciliationAfter.journalSha256)
1079
+ ) {
1080
+ throw new Error(
1081
+ "Remote deployment recovery requires an exact completed reconciliation journal.",
1082
+ );
1083
+ }
1084
+ const recoveryPrefix = `${String(
1085
+ REMOTE_DEPLOYMENT_STAGES.indexOf(stage) + 1,
1086
+ ).padStart(2, "0")}-${stage}-recovery-attempt-`;
1087
+ const attempt = readdirSync(root)
1088
+ .filter(name => name.startsWith(recoveryPrefix))
1089
+ .length + 1;
1090
+ const body = {
1091
+ schemaVersion: "OwlCodaRunKitRemoteStageRecoveryV1",
1092
+ bindingSha256: normalizedBinding.bindingSha256,
1093
+ stage,
1094
+ attempt,
1095
+ executeBeforeJournalSha256: executeBefore.journalSha256,
1096
+ reconciliationAfterJournalSha256:
1097
+ reconciliationAfter.journalSha256,
1098
+ status: "state_proven_consistent",
1099
+ stageReceipt: structuredClone(stageReceipt),
1100
+ authorizationGranted: false,
1101
+ };
1102
+ const recovery = { ...body, journalSha256: hashObject(body) };
1103
+ writeCreateOnlyJson(
1104
+ path.join(
1105
+ root,
1106
+ `${recoveryPrefix}${String(attempt).padStart(3, "0")}.json`,
1107
+ ),
1108
+ recovery,
1109
+ );
1110
+ return recovery;
1111
+ },
1112
+ };
1113
+ }
1114
+
1115
+ function failure(stage, failureCode, evidenceSha256 = null) {
1116
+ return {
1117
+ ok: false,
1118
+ stageReceipt: {
1119
+ stage,
1120
+ status: "failed",
1121
+ failureCode,
1122
+ ...(evidenceSha256 ? { evidenceSha256 } : {}),
1123
+ },
1124
+ };
1125
+ }
1126
+
1127
+ function normalizeStageResult(stage, result, manifest) {
1128
+ if (!isRecord(result)) return failure(stage, "stage_result_invalid");
1129
+ if (hasSecretShapedField(result)) return failure(stage, "secret_material_forbidden");
1130
+ if (result.status === "indeterminate") {
1131
+ try {
1132
+ assertAllowedKeys(result, `${stage} indeterminate result`, [
1133
+ "status",
1134
+ "failureCode",
1135
+ "evidenceSha256",
1136
+ ]);
1137
+ safeIdentifier(result.failureCode, `${stage} failureCode`);
1138
+ if (result.evidenceSha256 !== undefined) {
1139
+ assertHash(result.evidenceSha256, `${stage} failure evidence`);
1140
+ }
1141
+ return {
1142
+ ...failure(stage, result.failureCode, result.evidenceSha256),
1143
+ indeterminate: true,
1144
+ };
1145
+ } catch {
1146
+ return failure(stage, "stage_result_invalid");
1147
+ }
1148
+ }
1149
+ if (result.status === "failed") {
1150
+ try {
1151
+ assertAllowedKeys(result, `${stage} result`, [
1152
+ "status",
1153
+ "failureCode",
1154
+ "evidenceSha256",
1155
+ ]);
1156
+ safeIdentifier(result.failureCode, `${stage} failureCode`);
1157
+ if (result.evidenceSha256 !== undefined) {
1158
+ assertHash(result.evidenceSha256, `${stage} failure evidence`);
1159
+ }
1160
+ return failure(stage, result.failureCode, result.evidenceSha256);
1161
+ } catch {
1162
+ return failure(stage, "stage_result_invalid");
1163
+ }
1164
+ }
1165
+ try {
1166
+ if (stage === "identity_preflight") {
1167
+ assertAllowedKeys(result, "identity_preflight result", [
1168
+ "status",
1169
+ "hostKeySha256",
1170
+ "machineIdentitySha256",
1171
+ "remoteHelper",
1172
+ ]);
1173
+ if (result.status !== "passed") return failure(stage, "target_identity_preflight_failed");
1174
+ assertHash(result.hostKeySha256, "Observed remote host key");
1175
+ assertHash(result.machineIdentitySha256, "Observed remote machine identity");
1176
+ if (result.hostKeySha256 !== manifest.target.hostKeySha256
1177
+ || result.machineIdentitySha256 !== manifest.target.machineIdentitySha256) {
1178
+ return failure(stage, "target_identity_mismatch");
1179
+ }
1180
+ const expectedRemoteHelper = manifest.adapter.kind === "builtin_ssh"
1181
+ ? manifest.adapter.remoteHelper
1182
+ : null;
1183
+ if (
1184
+ expectedRemoteHelper !== null
1185
+ && (
1186
+ !isRecord(result.remoteHelper)
1187
+ || canonicalJson(result.remoteHelper)
1188
+ !== canonicalJson(expectedRemoteHelper)
1189
+ )
1190
+ ) {
1191
+ return failure(stage, "remote_helper_capability_mismatch");
1192
+ }
1193
+ if (
1194
+ expectedRemoteHelper === null
1195
+ && result.remoteHelper !== undefined
1196
+ ) {
1197
+ return failure(stage, "stage_result_invalid");
1198
+ }
1199
+ return {
1200
+ ok: true,
1201
+ stageReceipt: {
1202
+ stage,
1203
+ status: "passed",
1204
+ hostKeySha256: result.hostKeySha256,
1205
+ machineIdentitySha256: result.machineIdentitySha256,
1206
+ ...(expectedRemoteHelper
1207
+ ? { remoteHelper: structuredClone(expectedRemoteHelper) }
1208
+ : {}),
1209
+ },
1210
+ };
1211
+ }
1212
+ if (stage === "upload") {
1213
+ assertAllowedKeys(result, "upload result", [
1214
+ "status",
1215
+ "remotePath",
1216
+ "sha256",
1217
+ "size",
1218
+ ]);
1219
+ if (!new Set(["created", "already_present"]).has(result.status)
1220
+ || result.remotePath !== manifest.upload.remotePath
1221
+ || result.sha256 !== manifest.artifact.sha256
1222
+ || result.size !== manifest.artifact.size) {
1223
+ return failure(stage, "create_only_upload_conflict");
1224
+ }
1225
+ return {
1226
+ ok: true,
1227
+ stageReceipt: {
1228
+ stage,
1229
+ status: result.status,
1230
+ remotePath: result.remotePath,
1231
+ sha256: result.sha256,
1232
+ size: result.size,
1233
+ },
1234
+ };
1235
+ }
1236
+ if (stage === "verify_remote_hashes") {
1237
+ assertAllowedKeys(result, "verify_remote_hashes result", [
1238
+ "status",
1239
+ "remotePath",
1240
+ "sha256",
1241
+ "size",
1242
+ ]);
1243
+ if (result.status !== "passed"
1244
+ || result.remotePath !== manifest.upload.remotePath
1245
+ || result.sha256 !== manifest.artifact.sha256
1246
+ || result.size !== manifest.artifact.size) {
1247
+ return failure(stage, "remote_artifact_hash_mismatch");
1248
+ }
1249
+ return {
1250
+ ok: true,
1251
+ stageReceipt: {
1252
+ stage,
1253
+ status: "passed",
1254
+ remotePath: result.remotePath,
1255
+ sha256: result.sha256,
1256
+ size: result.size,
1257
+ },
1258
+ };
1259
+ }
1260
+ assertAllowedKeys(result, `${stage} result`, ["status", "evidenceSha256"]);
1261
+ if (result.status !== "passed") return failure(stage, `${stage}_failed`);
1262
+ assertHash(result.evidenceSha256, `${stage} evidence`);
1263
+ return {
1264
+ ok: true,
1265
+ stageReceipt: {
1266
+ stage,
1267
+ status: "passed",
1268
+ evidenceSha256: result.evidenceSha256,
1269
+ },
1270
+ };
1271
+ } catch {
1272
+ return failure(stage, "stage_result_invalid");
1273
+ }
1274
+ }
1275
+
1276
+ function resultArtifact(manifest, adapterIdentity, state) {
1277
+ const status = state.reconciliationRequired
1278
+ ? "reconciliation_required"
1279
+ : state.failureCode
1280
+ ? "failed"
1281
+ : "deployed";
1282
+ const body = {
1283
+ schemaVersion: "OwlCodaRunKitRemoteDeploymentResultV1",
1284
+ deploymentId: manifest.deploymentId,
1285
+ deploymentLineageSha256: manifest.deploymentLineageSha256,
1286
+ mode: manifest.mode,
1287
+ target: manifest.target,
1288
+ adapter: adapterIdentity,
1289
+ artifact: manifest.artifact,
1290
+ status,
1291
+ completedStages: state.completedStages,
1292
+ stoppedAtStage: state.stoppedAtStage,
1293
+ ...(state.failureCode ? { failureCode: state.failureCode } : {}),
1294
+ stageReceipts: state.stageReceipts,
1295
+ deletionAllowlistSha256: hashObject(manifest.deletionAllowlist),
1296
+ authorizationGranted: false,
1297
+ };
1298
+ return { ...body, resultSha256: hashObject(body) };
1299
+ }
1300
+
1301
+ function remoteStageInput(stage, manifest, permissions) {
1302
+ return {
1303
+ stage,
1304
+ deploymentId: manifest.deploymentId,
1305
+ deploymentLineageSha256: manifest.deploymentLineageSha256,
1306
+ mode: manifest.mode,
1307
+ ...(manifest.ownerDecision === undefined
1308
+ ? {}
1309
+ : { ownerDecision: structuredClone(manifest.ownerDecision) }),
1310
+ ...(manifest.serviceActivation === undefined
1311
+ ? {}
1312
+ : { serviceActivation: manifest.serviceActivation }),
1313
+ ...(manifest.baselineCut === undefined
1314
+ ? {}
1315
+ : { baselineCut: manifest.baselineCut }),
1316
+ target: structuredClone(manifest.target),
1317
+ artifact: structuredClone(manifest.artifact),
1318
+ upload: structuredClone(manifest.upload),
1319
+ priorDeployment: structuredClone(manifest.priorDeployment),
1320
+ expectedRemoteFiles: structuredClone(manifest.expectedRemoteFiles),
1321
+ deletionAllowlist: structuredClone(manifest.deletionAllowlist),
1322
+ permissions: structuredClone(permissions),
1323
+ credentialRef: manifest.credentialRef,
1324
+ };
1325
+ }
1326
+
1327
+ function normalizePersistedStageReceipt(stage, receipt, manifest) {
1328
+ if (!isRecord(receipt) || receipt.stage !== stage) {
1329
+ return failure(stage, "stage_journal_receipt_invalid");
1330
+ }
1331
+ const { stage: _stage, ...rawResult } = receipt;
1332
+ return normalizeStageResult(stage, rawResult, manifest);
1333
+ }
1334
+
1335
+ function reconciliationRequiredArtifact({
1336
+ manifest,
1337
+ adapterIdentity,
1338
+ completedStages,
1339
+ stageReceipts,
1340
+ stage,
1341
+ }) {
1342
+ return resultArtifact(manifest, adapterIdentity, {
1343
+ completedStages,
1344
+ stoppedAtStage: stage,
1345
+ failureCode: "remote_stage_reconciliation_required",
1346
+ reconciliationRequired: true,
1347
+ stageReceipts: [
1348
+ ...stageReceipts,
1349
+ {
1350
+ stage,
1351
+ status: "failed",
1352
+ failureCode: "remote_stage_reconciliation_required",
1353
+ },
1354
+ ],
1355
+ });
1356
+ }
1357
+
1358
+ export async function executeRemoteDeployment({
1359
+ manifest,
1360
+ adapter,
1361
+ permissions,
1362
+ beforeStageGuard,
1363
+ stageJournal = null,
1364
+ }) {
1365
+ const validation = validateRemoteDeploymentManifest(manifest);
1366
+ const normalized = validation.normalized;
1367
+ assertAllowedKeys(permissions, "Deployment permissions", [
1368
+ "deploy",
1369
+ "destructive",
1370
+ ]);
1371
+ if (
1372
+ permissions.deploy !== true
1373
+ || typeof permissions.destructive !== "boolean"
1374
+ ) {
1375
+ throw new Error("Deployment permissions must explicitly grant deploy and state destructive permission.");
1376
+ }
1377
+ if (
1378
+ permissions.destructive === false
1379
+ && normalized.deletionAllowlist.length > 0
1380
+ ) {
1381
+ throw new Error("destructive=false forbids every declared remote deletion.");
1382
+ }
1383
+ if (!isRecord(adapter) || typeof adapter.runStage !== "function") {
1384
+ throw new Error("Remote deployment requires an injected typed adapter.");
1385
+ }
1386
+ if (typeof beforeStageGuard !== "function") {
1387
+ throw new Error("Remote deployment requires a control guard.");
1388
+ }
1389
+ const adapterIdentity = validateAdapterIdentityFields(adapter.identity);
1390
+ if (!identitiesEqual(adapterIdentity, adapterRuntimeIdentity(normalized.adapter))) {
1391
+ throw new Error("Injected remote adapter identity does not match the manifest.");
1392
+ }
1393
+ if (stageJournal !== null) {
1394
+ if (
1395
+ !isRecord(stageJournal)
1396
+ || typeof stageJournal.readStageState !== "function"
1397
+ || typeof stageJournal.beginInvocation !== "function"
1398
+ || typeof stageJournal.completeInvocation !== "function"
1399
+ || typeof stageJournal.recoverInvocation !== "function"
1400
+ || stageJournal.binding?.deploymentId !== normalized.deploymentId
1401
+ || stageJournal.binding?.deploymentLineageSha256
1402
+ !== normalized.deploymentLineageSha256
1403
+ || !identitiesEqual(
1404
+ stageJournal.binding?.adapterIdentity,
1405
+ adapterIdentity,
1406
+ )
1407
+ ) {
1408
+ throw new Error(
1409
+ "Remote deployment stage journal does not bind the exact deployment and adapter.",
1410
+ );
1411
+ }
1412
+ }
1413
+ const completedStages = [];
1414
+ const stageReceipts = [];
1415
+ for (const stage of REMOTE_DEPLOYMENT_STAGES) {
1416
+ try {
1417
+ await beforeStageGuard({ stage });
1418
+ } catch (error) {
1419
+ const failureCode = error?.code === "owner_decision_superseded"
1420
+ ? "owner_decision_superseded"
1421
+ : "deployment_control_guard_failed";
1422
+ return resultArtifact(normalized, adapterIdentity, {
1423
+ completedStages,
1424
+ stoppedAtStage: stage,
1425
+ failureCode,
1426
+ stageReceipts: [
1427
+ ...stageReceipts,
1428
+ {
1429
+ stage,
1430
+ status: "failed",
1431
+ failureCode,
1432
+ },
1433
+ ],
1434
+ });
1435
+ }
1436
+
1437
+ const priorState = stageJournal?.readStageState(stage) ?? {
1438
+ status: "none",
1439
+ };
1440
+ if (priorState.status === "completed") {
1441
+ const persisted = normalizePersistedStageReceipt(
1442
+ stage,
1443
+ priorState.stageReceipt,
1444
+ normalized,
1445
+ );
1446
+ stageReceipts.push(persisted.stageReceipt);
1447
+ if (!persisted.ok) {
1448
+ return resultArtifact(normalized, adapterIdentity, {
1449
+ completedStages,
1450
+ stoppedAtStage: stage,
1451
+ failureCode: persisted.stageReceipt.failureCode,
1452
+ stageReceipts,
1453
+ });
1454
+ }
1455
+ completedStages.push(stage);
1456
+ continue;
1457
+ }
1458
+
1459
+ const stageInput = remoteStageInput(stage, normalized, permissions);
1460
+ if (
1461
+ priorState.status === "interrupted"
1462
+ || priorState.status === "indeterminate"
1463
+ ) {
1464
+ let reconciliation = null;
1465
+ let reconciliationAfter = null;
1466
+ if (typeof adapter.reconcileStage === "function") {
1467
+ const reconciliationBefore = stageJournal.beginInvocation({
1468
+ stage,
1469
+ operation: "reconcile",
1470
+ });
1471
+ try {
1472
+ const rawReconciliation = await adapter.reconcileStage(stageInput);
1473
+ reconciliation = normalizeStageResult(
1474
+ stage,
1475
+ rawReconciliation,
1476
+ normalized,
1477
+ );
1478
+ reconciliationAfter = stageJournal.completeInvocation({
1479
+ stage,
1480
+ operation: "reconcile",
1481
+ before: reconciliationBefore,
1482
+ outcome: "completed",
1483
+ stageReceipt: reconciliation.stageReceipt,
1484
+ });
1485
+ } catch {
1486
+ reconciliation = null;
1487
+ reconciliationAfter = stageJournal.completeInvocation({
1488
+ stage,
1489
+ operation: "reconcile",
1490
+ before: reconciliationBefore,
1491
+ outcome: "indeterminate",
1492
+ stageReceipt: {
1493
+ stage,
1494
+ status: "failed",
1495
+ failureCode: "reconciliation_probe_threw",
1496
+ },
1497
+ });
1498
+ }
1499
+ }
1500
+ if (reconciliation?.ok) {
1501
+ stageJournal.recoverInvocation({
1502
+ stage,
1503
+ executeBefore: priorState.before,
1504
+ reconciliationAfter,
1505
+ stageReceipt: reconciliation.stageReceipt,
1506
+ });
1507
+ stageReceipts.push(reconciliation.stageReceipt);
1508
+ completedStages.push(stage);
1509
+ continue;
1510
+ }
1511
+ if (!IDEMPOTENT_REMOTE_STAGES.has(stage)) {
1512
+ return reconciliationRequiredArtifact({
1513
+ manifest: normalized,
1514
+ adapterIdentity,
1515
+ completedStages,
1516
+ stageReceipts,
1517
+ stage,
1518
+ });
1519
+ }
1520
+ }
1521
+
1522
+ const executionBefore = stageJournal?.beginInvocation({
1523
+ stage,
1524
+ operation: "execute",
1525
+ }) ?? null;
1526
+ let rawResult;
1527
+ try {
1528
+ rawResult = await adapter.runStage(stageInput);
1529
+ } catch {
1530
+ if (stageJournal) {
1531
+ stageJournal.completeInvocation({
1532
+ stage,
1533
+ operation: "execute",
1534
+ before: executionBefore,
1535
+ outcome: "indeterminate",
1536
+ stageReceipt: {
1537
+ stage,
1538
+ status: "failed",
1539
+ failureCode: "adapter_stage_threw",
1540
+ },
1541
+ });
1542
+ return reconciliationRequiredArtifact({
1543
+ manifest: normalized,
1544
+ adapterIdentity,
1545
+ completedStages,
1546
+ stageReceipts,
1547
+ stage,
1548
+ });
1549
+ }
1550
+ return resultArtifact(normalized, adapterIdentity, {
1551
+ completedStages,
1552
+ stoppedAtStage: stage,
1553
+ failureCode: "adapter_stage_threw",
1554
+ stageReceipts: [
1555
+ ...stageReceipts,
1556
+ { stage, status: "failed", failureCode: "adapter_stage_threw" },
1557
+ ],
1558
+ });
1559
+ }
1560
+ const checked = normalizeStageResult(stage, rawResult, normalized);
1561
+ if (stageJournal) {
1562
+ stageJournal.completeInvocation({
1563
+ stage,
1564
+ operation: "execute",
1565
+ before: executionBefore,
1566
+ outcome: checked.indeterminate ? "indeterminate" : "completed",
1567
+ stageReceipt: checked.stageReceipt,
1568
+ });
1569
+ }
1570
+ if (checked.indeterminate && stageJournal) {
1571
+ return reconciliationRequiredArtifact({
1572
+ manifest: normalized,
1573
+ adapterIdentity,
1574
+ completedStages,
1575
+ stageReceipts,
1576
+ stage,
1577
+ });
1578
+ }
1579
+ stageReceipts.push(checked.stageReceipt);
1580
+ if (!checked.ok) {
1581
+ return resultArtifact(normalized, adapterIdentity, {
1582
+ completedStages,
1583
+ stoppedAtStage: stage,
1584
+ failureCode: checked.stageReceipt.failureCode,
1585
+ stageReceipts,
1586
+ });
1587
+ }
1588
+ completedStages.push(stage);
1589
+ }
1590
+ return resultArtifact(normalized, adapterIdentity, {
1591
+ completedStages,
1592
+ stoppedAtStage: null,
1593
+ failureCode: null,
1594
+ stageReceipts,
1595
+ });
1596
+ }