codex-chatgpt-control 0.5.1-alpha.2 → 0.5.1-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/README.md +43 -0
- package/contracts/v1/fixtures/backend-capabilities.json +40 -1
- package/contracts/v1/fixtures/backend-compatibility.json +21 -0
- package/contracts/v1/fixtures/backend-version.json +6 -1
- package/contracts/v1/fixtures/download-blocked-by-browser.json +22 -0
- package/contracts/v1/fixtures/download-receipt-failed.json +22 -0
- package/contracts/v1/fixtures/download-receipt-timeout.json +22 -0
- package/contracts/v1/fixtures/journal-rpc-indeterminate.json +32 -0
- package/contracts/v1/fixtures/journal-rpc-unsupported-platform.json +32 -0
- package/contracts/v1/fixtures/journal-runtime-unavailable.json +32 -0
- package/contracts/v1/fixtures/operation-action-prepared-event.json +42 -0
- package/contracts/v1/fixtures/operation-action.json +13 -0
- package/contracts/v1/fixtures/operation-artifact-receipt.json +14 -0
- package/contracts/v1/fixtures/operation-artifact-transfer-intent-event.json +24 -0
- package/contracts/v1/fixtures/operation-artifact-transfer-receipt-event.json +26 -0
- package/contracts/v1/fixtures/operation-artifact-transfer-state.json +178 -0
- package/contracts/v1/fixtures/operation-blocker.json +10 -0
- package/contracts/v1/fixtures/operation-collect-request.json +17 -0
- package/contracts/v1/fixtures/operation-collect-result.json +42 -0
- package/contracts/v1/fixtures/operation-control-receipt.json +13 -0
- package/contracts/v1/fixtures/operation-control-request.json +18 -0
- package/contracts/v1/fixtures/operation-control-result.json +31 -0
- package/contracts/v1/fixtures/operation-event.json +18 -0
- package/contracts/v1/fixtures/operation-handle.json +10 -0
- package/contracts/v1/fixtures/operation-inspect-request.json +13 -0
- package/contracts/v1/fixtures/operation-inspect-result.json +205 -0
- package/contracts/v1/fixtures/operation-ownership-baseline-event.json +34 -0
- package/contracts/v1/fixtures/operation-receipt.json +32 -0
- package/contracts/v1/fixtures/operation-recovery-decision.json +7 -0
- package/contracts/v1/fixtures/operation-recovery-observation.json +12 -0
- package/contracts/v1/fixtures/operation-request.json +33 -0
- package/contracts/v1/fixtures/operation-state.json +144 -0
- package/contracts/v1/fixtures/operation-submission-witness-event.json +20 -0
- package/contracts/v1/fixtures/operation-submission-witness.json +11 -0
- package/contracts/v1/fixtures/operation-submit-result.json +16 -0
- package/contracts/v1/fixtures/operation-target-established-event.json +21 -0
- package/contracts/v1/manifest.json +185 -1
- package/contracts/v1/parity-suite.json +106 -8
- package/contracts/v1/schemas/backend-compatibility.schema.json +65 -0
- package/contracts/v1/schemas/backend-request.schema.json +8 -2
- package/contracts/v1/schemas/capabilities.schema.json +81 -1
- package/contracts/v1/schemas/manifest.schema.json +57 -0
- package/contracts/v1/schemas/operation-action.schema.json +155 -0
- package/contracts/v1/schemas/operation-artifact-receipt.schema.json +89 -0
- package/contracts/v1/schemas/operation-blocker.schema.json +70 -0
- package/contracts/v1/schemas/operation-collect-request.schema.json +46 -0
- package/contracts/v1/schemas/operation-collect-result.schema.json +74 -0
- package/contracts/v1/schemas/operation-control-receipt.schema.json +108 -0
- package/contracts/v1/schemas/operation-control-request.schema.json +111 -0
- package/contracts/v1/schemas/operation-control-result.schema.json +112 -0
- package/contracts/v1/schemas/operation-event.schema.json +756 -0
- package/contracts/v1/schemas/operation-handle.schema.json +55 -0
- package/contracts/v1/schemas/operation-inspect-request.schema.json +42 -0
- package/contracts/v1/schemas/operation-inspect-result.schema.json +1662 -0
- package/contracts/v1/schemas/operation-receipt.schema.json +153 -0
- package/contracts/v1/schemas/operation-recovery.schema.json +314 -0
- package/contracts/v1/schemas/operation-request.schema.json +158 -0
- package/contracts/v1/schemas/operation-state.schema.json +691 -0
- package/contracts/v1/schemas/operation-submission-witness.schema.json +48 -0
- package/contracts/v1/schemas/operation-submit-result.schema.json +94 -0
- package/contracts/v1/surface-drift-policy.json +50 -0
- package/contracts/v1/vectors/operation-request-digest-v1.json +43 -0
- package/dist/codex-chatgpt-control-backend.mjs +45882 -9361
- package/dist/codex-chatgpt-control-journal.mjs +5547 -0
- package/dist/codex-chatgpt-control-live-smoke.bundle.mjs +51677 -0
- package/dist/codex-chatgpt-control-release-canary.bundle.mjs +52104 -0
- package/dist/codex-chatgpt-control.bundle.mjs +48392 -10260
- package/dist/src/backend/client.d.ts +103 -2
- package/dist/src/backend/client.js +1436 -97
- package/dist/src/backend/compatibility.d.ts +12 -0
- package/dist/src/backend/compatibility.js +208 -0
- package/dist/src/backend/protocol.d.ts +84 -1
- package/dist/src/backend/protocol.js +31 -5
- package/dist/src/backend/runtime-identity.d.ts +10 -0
- package/dist/src/backend/runtime-identity.js +141 -0
- package/dist/src/backend/session.d.ts +10 -2
- package/dist/src/backend/session.js +478 -7
- package/dist/src/backend/stdio-server.d.ts +4 -1
- package/dist/src/backend/stdio-server.js +240 -28
- package/dist/src/browser/active-composer-file-input.d.ts +9 -0
- package/dist/src/browser/active-composer-file-input.js +33 -0
- package/dist/src/browser/attach.d.ts +4 -1
- package/dist/src/browser/attach.js +136 -28
- package/dist/src/browser/chatgpt-url.d.ts +2 -0
- package/dist/src/browser/chatgpt-url.js +9 -0
- package/dist/src/browser/downloads.d.ts +17 -1
- package/dist/src/browser/downloads.js +150 -28
- package/dist/src/browser/page-state.js +6 -0
- package/dist/src/client.d.ts +43 -0
- package/dist/src/client.js +1279 -59
- package/dist/src/commands/artifacts.js +6 -4
- package/dist/src/commands/chat-popover.d.ts +37 -0
- package/dist/src/commands/chat-popover.js +537 -0
- package/dist/src/commands/configuration.d.ts +5 -0
- package/dist/src/commands/configuration.js +134 -21
- package/dist/src/commands/context.js +10 -3
- package/dist/src/commands/doctor.d.ts +1 -1
- package/dist/src/commands/doctor.js +27 -4
- package/dist/src/commands/experience.d.ts +5 -0
- package/dist/src/commands/experience.js +104 -21
- package/dist/src/commands/files.js +14 -33
- package/dist/src/commands/modes.js +85 -21
- package/dist/src/commands/power-discovery.d.ts +130 -0
- package/dist/src/commands/power-discovery.js +669 -0
- package/dist/src/commands/project-sources.js +325 -31
- package/dist/src/commands/sequence.js +59 -32
- package/dist/src/commands/session.js +19 -6
- package/dist/src/commands/work.js +31 -0
- package/dist/src/dom/composer-text.d.ts +6 -0
- package/dist/src/dom/composer-text.js +85 -0
- package/dist/src/errors.d.ts +9 -0
- package/dist/src/errors.js +22 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.js +9 -0
- package/dist/src/operations/artifact-output.d.ts +69 -0
- package/dist/src/operations/artifact-output.js +1748 -0
- package/dist/src/operations/artifact-stream.d.ts +19 -0
- package/dist/src/operations/artifact-stream.js +35 -0
- package/dist/src/operations/artifact-transfer.d.ts +149 -0
- package/dist/src/operations/artifact-transfer.js +1312 -0
- package/dist/src/operations/browser-adapter.d.ts +167 -0
- package/dist/src/operations/browser-adapter.js +2103 -0
- package/dist/src/operations/browser-observation.d.ts +110 -0
- package/dist/src/operations/browser-observation.js +1256 -0
- package/dist/src/operations/browser-target.d.ts +105 -0
- package/dist/src/operations/browser-target.js +531 -0
- package/dist/src/operations/canonical.d.ts +4 -0
- package/dist/src/operations/canonical.js +412 -0
- package/dist/src/operations/chatgpt-runtime.d.ts +72 -0
- package/dist/src/operations/chatgpt-runtime.js +1232 -0
- package/dist/src/operations/client.d.ts +138 -0
- package/dist/src/operations/client.js +1145 -0
- package/dist/src/operations/collector.d.ts +195 -0
- package/dist/src/operations/collector.js +1039 -0
- package/dist/src/operations/configuration-routing.d.ts +4 -0
- package/dist/src/operations/configuration-routing.js +14 -0
- package/dist/src/operations/control.d.ts +372 -0
- package/dist/src/operations/control.js +1498 -0
- package/dist/src/operations/file-identity.d.ts +33 -0
- package/dist/src/operations/file-identity.js +153 -0
- package/dist/src/operations/handle.d.ts +19 -0
- package/dist/src/operations/handle.js +727 -0
- package/dist/src/operations/index.d.ts +25 -0
- package/dist/src/operations/index.js +25 -0
- package/dist/src/operations/journal-authority.d.ts +12 -0
- package/dist/src/operations/journal-authority.js +1 -0
- package/dist/src/operations/journal-rpc-client.d.ts +12 -0
- package/dist/src/operations/journal-rpc-client.js +225 -0
- package/dist/src/operations/journal-rpc-protocol.d.ts +32 -0
- package/dist/src/operations/journal-rpc-protocol.js +214 -0
- package/dist/src/operations/journal-rpc-server.d.ts +15 -0
- package/dist/src/operations/journal-rpc-server.js +272 -0
- package/dist/src/operations/journal.d.ts +143 -0
- package/dist/src/operations/journal.js +1957 -0
- package/dist/src/operations/production-attachments.d.ts +118 -0
- package/dist/src/operations/production-attachments.js +1035 -0
- package/dist/src/operations/production-chatgpt-artifacts.d.ts +74 -0
- package/dist/src/operations/production-chatgpt-artifacts.js +1146 -0
- package/dist/src/operations/production-chatgpt-attachments.d.ts +80 -0
- package/dist/src/operations/production-chatgpt-attachments.js +1639 -0
- package/dist/src/operations/production-configuration.d.ts +42 -0
- package/dist/src/operations/production-configuration.js +1544 -0
- package/dist/src/operations/production-primitives.d.ts +53 -0
- package/dist/src/operations/production-primitives.js +856 -0
- package/dist/src/operations/production-work-steer.d.ts +203 -0
- package/dist/src/operations/production-work-steer.js +1218 -0
- package/dist/src/operations/recovery.d.ts +80 -0
- package/dist/src/operations/recovery.js +174 -0
- package/dist/src/operations/runtime-adapter.d.ts +126 -0
- package/dist/src/operations/runtime-adapter.js +802 -0
- package/dist/src/operations/send-once.d.ts +224 -0
- package/dist/src/operations/send-once.js +1081 -0
- package/dist/src/operations/service.d.ts +286 -0
- package/dist/src/operations/service.js +2831 -0
- package/dist/src/operations/staging.d.ts +136 -0
- package/dist/src/operations/staging.js +630 -0
- package/dist/src/operations/state-machine.d.ts +29 -0
- package/dist/src/operations/state-machine.js +2047 -0
- package/dist/src/operations/submission.d.ts +423 -0
- package/dist/src/operations/submission.js +1676 -0
- package/dist/src/operations/transactional-chat-power.d.ts +17 -0
- package/dist/src/operations/transactional-chat-power.js +212 -0
- package/dist/src/operations/turn-ownership.d.ts +202 -0
- package/dist/src/operations/turn-ownership.js +700 -0
- package/dist/src/operations/types.d.ts +454 -0
- package/dist/src/operations/types.js +20 -0
- package/dist/src/operations/wire-requests.d.ts +21 -0
- package/dist/src/operations/wire-requests.js +396 -0
- package/dist/src/operations/wire-results.d.ts +97 -0
- package/dist/src/operations/wire-results.js +818 -0
- package/dist/src/runner/responses.d.ts +3 -1
- package/dist/src/runner/responses.js +19 -1
- package/dist/src/runner/result.js +227 -55
- package/dist/src/runner/types.d.ts +12 -0
- package/dist/src/runtime/command-routing.d.ts +149 -0
- package/dist/src/runtime/command-routing.js +431 -0
- package/dist/src/runtime/coordinated-browser.d.ts +24 -0
- package/dist/src/runtime/coordinated-browser.js +315 -0
- package/dist/src/runtime/coordinated-page.d.ts +41 -0
- package/dist/src/runtime/coordinated-page.js +651 -0
- package/dist/src/runtime/operation-context.d.ts +142 -0
- package/dist/src/runtime/operation-context.js +410 -0
- package/dist/src/runtime/runtime-session.d.ts +95 -0
- package/dist/src/runtime/runtime-session.js +314 -0
- package/dist/src/runtime/tab-coordinator.d.ts +200 -0
- package/dist/src/runtime/tab-coordinator.js +1200 -0
- package/dist/src/runtime/value-boundaries.d.ts +18 -0
- package/dist/src/runtime/value-boundaries.js +49 -0
- package/dist/src/safety/blockers.js +8 -1
- package/dist/src/safety/untrusted-output.js +2 -1
- package/dist/src/scripts/backend-server.js +4 -1
- package/dist/src/scripts/capture-surface-profile.js +7 -2
- package/dist/src/scripts/journal-server.d.ts +2 -0
- package/dist/src/scripts/journal-server.js +37 -0
- package/dist/src/scripts/live-smoke/harness.js +79 -7
- package/dist/src/scripts/live-smoke/scenarios.d.ts +42 -1
- package/dist/src/scripts/live-smoke/scenarios.js +147 -30
- package/dist/src/scripts/live-smoke/transactional.d.ts +17 -0
- package/dist/src/scripts/live-smoke/transactional.js +254 -0
- package/dist/src/scripts/live-smoke/types.d.ts +3 -0
- package/dist/src/scripts/release-canary-module.js +17 -4
- package/dist/src/types.d.ts +38 -1
- package/package.json +8 -3
- package/references/2026-08-16-transactional-operations.md +459 -0
- package/references/2026-09-06-journal-service.md +140 -0
- package/references/agents-runner.md +34 -0
- package/references/backend-protocol.md +176 -0
- package/references/python-parity.md +137 -0
- package/references/responses-adapter.md +19 -0
- package/references/streaming.md +6 -0
|
@@ -0,0 +1,1748 @@
|
|
|
1
|
+
import { constants as fsConstants, unlinkSync } from "node:fs";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { lstat, open, opendir, realpath } from "node:fs/promises";
|
|
5
|
+
import { isByteArrayView } from "../runtime/value-boundaries.js";
|
|
6
|
+
import { isOwnedProviderChunk, MAX_PROVIDER_CHUNK_BYTES } from "./artifact-stream.js";
|
|
7
|
+
const OUTPUT_KEY_PREFIX = "artifact-";
|
|
8
|
+
const OUTPUT_KEY_DIGEST_LENGTH = 48;
|
|
9
|
+
const TEMP_TOKEN_BYTES = 16;
|
|
10
|
+
const MAX_HINT_LENGTH = 32;
|
|
11
|
+
const MAX_IDENTITY_BYTES = 4096;
|
|
12
|
+
const MAX_OUTPUT_DIRECTORY_BYTES = 4096;
|
|
13
|
+
const DEFAULT_MAX_ARTIFACT_BYTES = 128 * 1024 * 1024;
|
|
14
|
+
const MAX_MAX_ARTIFACT_BYTES = 512 * 1024 * 1024;
|
|
15
|
+
const MAX_TIMEOUT_MS = 30_000;
|
|
16
|
+
const MAX_DEADLINE_AT = Date.UTC(2100, 0, 1);
|
|
17
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
18
|
+
const PROVIDER_READ_TIMEOUT_MS = MAX_TIMEOUT_MS;
|
|
19
|
+
const PROVIDER_CLEANUP_TIMEOUT_MS = MAX_TIMEOUT_MS;
|
|
20
|
+
// Built-in 64 KiB adapters need at most 8,192 chunks for the 512 MiB artifact
|
|
21
|
+
// cap. A generous 8x margin still prevents an unbounded stream of tiny or
|
|
22
|
+
// empty chunks from consuming CPU forever while retaining O(1) state.
|
|
23
|
+
const MAX_PROVIDER_CHUNKS = 65_536;
|
|
24
|
+
// Crash-leftover discovery is an untrusted directory enumeration. Keep the
|
|
25
|
+
// hard ceiling independent of the artifact byte/chunk limits so an ordinary
|
|
26
|
+
// large destination remains usable without permitting an unbounded preflight.
|
|
27
|
+
const MAX_TEMP_SCAN_ENTRIES = 65_536;
|
|
28
|
+
const MAX_GRAPH_DEPTH = 16;
|
|
29
|
+
const MAX_GRAPH_NODES = 1_024;
|
|
30
|
+
const POSIX_FILE_MODE = 0o600;
|
|
31
|
+
const EMPTY_SHA256 = createHash("sha256").digest("hex");
|
|
32
|
+
export class ArtifactOutputError extends Error {
|
|
33
|
+
code;
|
|
34
|
+
constructor(code, message) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.code = code;
|
|
37
|
+
this.name = "ArtifactOutputError";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Derive one deterministic, path-safe output component from opaque identities.
|
|
42
|
+
* The only human-readable portion is the vetted public extension.
|
|
43
|
+
*/
|
|
44
|
+
export function deriveOperationOutputKey(input) {
|
|
45
|
+
const captured = snapshotKeyInput(input);
|
|
46
|
+
validateOpaqueIdentity(captured.operationId, "operationId");
|
|
47
|
+
validateOpaqueIdentity(captured.artifactIdentity, "artifactIdentity");
|
|
48
|
+
const extension = normalizeExtension(captured.extensionHint, captured.mimeTypeHint);
|
|
49
|
+
const digest = outputIdentityDigest(captured, extension);
|
|
50
|
+
return `${OUTPUT_KEY_PREFIX}${digest.slice(0, OUTPUT_KEY_DIGEST_LENGTH)}${extension === "" ? "" : `.${extension}`}`;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Stream one artifact into an operation-owned temporary file, then install it
|
|
54
|
+
* from the retained source descriptor into an exclusive destination file.
|
|
55
|
+
* Existing byte-identical finals are reconciled; any other existing target is
|
|
56
|
+
* never overwritten.
|
|
57
|
+
*/
|
|
58
|
+
export async function commitOperationOutput(options) {
|
|
59
|
+
const captured = snapshotCommitOptions(options);
|
|
60
|
+
const outputKey = deriveOperationOutputKey(keyInput(captured));
|
|
61
|
+
const empty = { bytes: 0, sha256: EMPTY_SHA256 };
|
|
62
|
+
validateCommitOptions(captured);
|
|
63
|
+
const runtime = createRuntime(captured);
|
|
64
|
+
let boundary = "preflight";
|
|
65
|
+
try {
|
|
66
|
+
checkRuntime(runtime);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
return result(outputKey, empty, "blocked", runtimeReason(error));
|
|
70
|
+
}
|
|
71
|
+
let directory;
|
|
72
|
+
try {
|
|
73
|
+
directory = await secureOutputDirectory(captured.outputDirectory, runtime);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
return result(outputKey, empty, "blocked", runtimeReason(error, "destination_invalid"));
|
|
77
|
+
}
|
|
78
|
+
const finalPath = safeChildPath(directory.canonical, outputKey);
|
|
79
|
+
const tempPrefix = `.${outputKey}.partial-`;
|
|
80
|
+
let preflight;
|
|
81
|
+
try {
|
|
82
|
+
preflight = await reconcilePreexistingOutput(directory, finalPath, tempPrefix, outputKey, captured.expected, captured.hooks, runtime);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
return result(outputKey, empty, "blocked", runtimeReason(error, "destination_invalid"));
|
|
86
|
+
}
|
|
87
|
+
if (preflight !== undefined) {
|
|
88
|
+
return preflight;
|
|
89
|
+
}
|
|
90
|
+
let temp;
|
|
91
|
+
let stream = empty;
|
|
92
|
+
let finalInstalled = false;
|
|
93
|
+
try {
|
|
94
|
+
boundary = "temp";
|
|
95
|
+
temp = await createOwnedTemp(directory, tempPrefix, captured.hooks, runtime);
|
|
96
|
+
try {
|
|
97
|
+
await inject(captured.hooks, "after_temp_open", runtime);
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
rethrowRuntimeFailure(error);
|
|
101
|
+
throw new ArtifactOutputError("temp_open_failed", "Operation-owned temporary output could not be created.");
|
|
102
|
+
}
|
|
103
|
+
boundary = "stream";
|
|
104
|
+
stream = await writeSource(temp, captured, runtime);
|
|
105
|
+
if (captured.expected !== undefined
|
|
106
|
+
&& (stream.bytes !== captured.expected.bytes || stream.sha256 !== captured.expected.sha256)) {
|
|
107
|
+
throw new StreamOutcome("blocked", "source_mismatch", stream.bytes, stream.sha256);
|
|
108
|
+
}
|
|
109
|
+
boundary = "link";
|
|
110
|
+
finalInstalled = await installOrReconcile(directory, finalPath, temp, stream, captured.hooks, runtime);
|
|
111
|
+
if (finalInstalled) {
|
|
112
|
+
const cleanup = await cleanupOwnedTemp(directory, temp, captured.hooks, runtime);
|
|
113
|
+
if (runtime.localEffectUncertain) {
|
|
114
|
+
return result(outputKey, stream, "blocked", "commit_indeterminate");
|
|
115
|
+
}
|
|
116
|
+
boundary = "directory";
|
|
117
|
+
try {
|
|
118
|
+
await syncDirectory(directory, captured.hooks, runtime);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return result(outputKey, stream, "blocked", "commit_indeterminate");
|
|
122
|
+
}
|
|
123
|
+
if (cleanup === "ambiguous") {
|
|
124
|
+
return result(outputKey, stream, "blocked", "temp_cleanup_ambiguous");
|
|
125
|
+
}
|
|
126
|
+
if (cleanup === "pending") {
|
|
127
|
+
// The final hard link is durable, but cleanup requires a later
|
|
128
|
+
// receipt-bound reconciliation before this destination is reused.
|
|
129
|
+
return result(outputKey, stream, "committed", "temp_cleanup_pending");
|
|
130
|
+
}
|
|
131
|
+
return result(outputKey, stream, "committed", "created");
|
|
132
|
+
}
|
|
133
|
+
// installOrReconcile returns false only when an existing target was
|
|
134
|
+
// reconciled or rejected; it communicates the exact result through the
|
|
135
|
+
// private marker below.
|
|
136
|
+
throw new ArtifactOutputError("internal_install_result", "Unexpected artifact install result.");
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (isInstallOutcome(error)) {
|
|
140
|
+
if (isStreamOutcome(error)) {
|
|
141
|
+
stream = { bytes: error.bytes, sha256: error.sha256 };
|
|
142
|
+
}
|
|
143
|
+
if (runtime.localEffectUncertain) {
|
|
144
|
+
return result(outputKey, stream, "blocked", "commit_indeterminate");
|
|
145
|
+
}
|
|
146
|
+
if (temp !== undefined) {
|
|
147
|
+
const cleanup = await cleanupOwnedTemp(directory, temp, captured.hooks, runtime, isStreamOutcome(error) && error.retainForRecovery);
|
|
148
|
+
try {
|
|
149
|
+
await syncDirectory(directory, captured.hooks, runtime);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return result(outputKey, stream, "blocked", "commit_indeterminate");
|
|
153
|
+
}
|
|
154
|
+
if (cleanup === "ambiguous")
|
|
155
|
+
return result(outputKey, stream, "blocked", "temp_cleanup_ambiguous");
|
|
156
|
+
// Preserve the primary install outcome. The temp remains as crash
|
|
157
|
+
// evidence, but replacing a useful source/collision reason with a
|
|
158
|
+
// cleanup detail would make the durable blocker less actionable.
|
|
159
|
+
if (cleanup === "pending") {
|
|
160
|
+
return result(outputKey, stream, error.status, runtime.providerUncertain ? "commit_indeterminate" : error.reason);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (runtime.providerUncertain)
|
|
164
|
+
return result(outputKey, stream, "blocked", "commit_indeterminate");
|
|
165
|
+
return result(outputKey, stream, error.status, error.reason);
|
|
166
|
+
}
|
|
167
|
+
const classified = classifyFailure(error);
|
|
168
|
+
if (runtime.localEffectUncertain) {
|
|
169
|
+
return result(outputKey, stream, "blocked", "commit_indeterminate");
|
|
170
|
+
}
|
|
171
|
+
if (temp !== undefined) {
|
|
172
|
+
const cleanup = await cleanupOwnedTemp(directory, temp, captured.hooks, runtime);
|
|
173
|
+
if (cleanup === "ambiguous") {
|
|
174
|
+
return result(outputKey, stream, "blocked", "temp_cleanup_ambiguous");
|
|
175
|
+
}
|
|
176
|
+
if (cleanup === "pending") {
|
|
177
|
+
return result(outputKey, stream, "blocked", runtime.providerUncertain ? "commit_indeterminate" : classified);
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
await syncDirectory(directory, captured.hooks, runtime);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return result(outputKey, stream, "blocked", "commit_indeterminate");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (runtime.providerUncertain)
|
|
187
|
+
return result(outputKey, stream, "blocked", "commit_indeterminate");
|
|
188
|
+
if (boundary === "link" || boundary === "directory" || finalInstalled) {
|
|
189
|
+
return result(outputKey, stream, "blocked", classified === "operation_timeout" || classified === "clock_invalid"
|
|
190
|
+
? "commit_indeterminate"
|
|
191
|
+
: classified);
|
|
192
|
+
}
|
|
193
|
+
return result(outputKey, stream, "blocked", classified);
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
// Installation and cleanup may return a durable blocker while the source
|
|
197
|
+
// capability still exists. Release the retained descriptor on every path;
|
|
198
|
+
// the pathname is intentionally not used as a fallback close mechanism.
|
|
199
|
+
if (temp !== undefined)
|
|
200
|
+
await closeTempHandle(temp);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
class InstallOutcome extends Error {
|
|
204
|
+
status;
|
|
205
|
+
reason;
|
|
206
|
+
constructor(status, reason) {
|
|
207
|
+
super(reason);
|
|
208
|
+
this.status = status;
|
|
209
|
+
this.reason = reason;
|
|
210
|
+
this.name = "InstallOutcome";
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
class StreamOutcome extends InstallOutcome {
|
|
214
|
+
bytes;
|
|
215
|
+
sha256;
|
|
216
|
+
retainForRecovery;
|
|
217
|
+
constructor(status, reason, bytes, sha256,
|
|
218
|
+
/** The retained temp contains a verified complete prefix suitable for
|
|
219
|
+
* receipt-bound recovery, even though the surrounding effect failed. */
|
|
220
|
+
retainForRecovery = false) {
|
|
221
|
+
super(status, reason);
|
|
222
|
+
this.bytes = bytes;
|
|
223
|
+
this.sha256 = sha256;
|
|
224
|
+
this.retainForRecovery = retainForRecovery;
|
|
225
|
+
this.name = "StreamOutcome";
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function result(outputKey, stream, status, reason) {
|
|
229
|
+
return { outputKey, bytes: stream.bytes, sha256: stream.sha256, status, reason };
|
|
230
|
+
}
|
|
231
|
+
function keyInput(value) {
|
|
232
|
+
return {
|
|
233
|
+
operationId: value.operationId,
|
|
234
|
+
artifactIdentity: value.artifactIdentity,
|
|
235
|
+
...(value.extensionHint === undefined ? {} : { extensionHint: value.extensionHint }),
|
|
236
|
+
...(value.mimeTypeHint === undefined ? {} : { mimeTypeHint: value.mimeTypeHint })
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
function snapshotKeyInput(value) {
|
|
240
|
+
const record = ownDataRecord(value, "key input", ["operationId", "artifactIdentity", "extensionHint", "mimeTypeHint"]);
|
|
241
|
+
const operationId = readData(record, "operationId");
|
|
242
|
+
const artifactIdentity = readData(record, "artifactIdentity");
|
|
243
|
+
const extensionHint = readData(record, "extensionHint");
|
|
244
|
+
const mimeTypeHint = readData(record, "mimeTypeHint");
|
|
245
|
+
if (typeof operationId !== "string" || typeof artifactIdentity !== "string"
|
|
246
|
+
|| (extensionHint !== undefined && typeof extensionHint !== "string")
|
|
247
|
+
|| (mimeTypeHint !== undefined && typeof mimeTypeHint !== "string")) {
|
|
248
|
+
throw new ArtifactOutputError("invalid_output_key_input", "Artifact output identity input is invalid.");
|
|
249
|
+
}
|
|
250
|
+
return Object.freeze({
|
|
251
|
+
operationId,
|
|
252
|
+
artifactIdentity,
|
|
253
|
+
...(extensionHint === undefined ? {} : { extensionHint }),
|
|
254
|
+
...(mimeTypeHint === undefined ? {} : { mimeTypeHint })
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
function snapshotCommitOptions(value) {
|
|
258
|
+
const record = ownDataRecord(value, "commit options", [
|
|
259
|
+
"operationId", "artifactIdentity", "extensionHint", "mimeTypeHint", "outputDirectory", "source",
|
|
260
|
+
"expected", "maxBytes", "signal", "hooks", "deadlineAt", "timeoutMs", "now"
|
|
261
|
+
]);
|
|
262
|
+
const operationId = readData(record, "operationId");
|
|
263
|
+
const artifactIdentity = readData(record, "artifactIdentity");
|
|
264
|
+
const extensionHint = readData(record, "extensionHint");
|
|
265
|
+
const mimeTypeHint = readData(record, "mimeTypeHint");
|
|
266
|
+
const outputDirectory = readData(record, "outputDirectory");
|
|
267
|
+
const source = readData(record, "source");
|
|
268
|
+
const expected = snapshotExpected(readData(record, "expected"));
|
|
269
|
+
const maxBytes = readData(record, "maxBytes");
|
|
270
|
+
const signal = readData(record, "signal");
|
|
271
|
+
const hooks = snapshotHooks(readData(record, "hooks"));
|
|
272
|
+
const deadlineAt = readData(record, "deadlineAt");
|
|
273
|
+
const timeoutMs = readData(record, "timeoutMs");
|
|
274
|
+
const now = readData(record, "now");
|
|
275
|
+
if (typeof operationId !== "string" || typeof artifactIdentity !== "string"
|
|
276
|
+
|| (extensionHint !== undefined && typeof extensionHint !== "string")
|
|
277
|
+
|| (mimeTypeHint !== undefined && typeof mimeTypeHint !== "string")
|
|
278
|
+
|| typeof outputDirectory !== "string"
|
|
279
|
+
|| !isAsyncIterable(source)
|
|
280
|
+
|| (maxBytes !== undefined && typeof maxBytes !== "number")
|
|
281
|
+
|| (signal !== undefined && !isGenuineAbortSignal(signal))
|
|
282
|
+
|| (deadlineAt !== undefined && typeof deadlineAt !== "number")
|
|
283
|
+
|| (timeoutMs !== undefined && typeof timeoutMs !== "number")
|
|
284
|
+
|| (now !== undefined && typeof now !== "function")) {
|
|
285
|
+
throw new ArtifactOutputError("invalid_commit_options", "Artifact output commit options are invalid.");
|
|
286
|
+
}
|
|
287
|
+
if (byteLength(operationId) > MAX_IDENTITY_BYTES || byteLength(artifactIdentity) > MAX_IDENTITY_BYTES
|
|
288
|
+
|| byteLength(outputDirectory) > MAX_OUTPUT_DIRECTORY_BYTES
|
|
289
|
+
|| (extensionHint !== undefined && byteLength(extensionHint) > MAX_HINT_LENGTH * 4)
|
|
290
|
+
|| (mimeTypeHint !== undefined && byteLength(mimeTypeHint) > 127 * 4)) {
|
|
291
|
+
throw new ArtifactOutputError("invalid_commit_options", "Artifact output commit options exceed their bounded limits.");
|
|
292
|
+
}
|
|
293
|
+
const captured = {
|
|
294
|
+
operationId,
|
|
295
|
+
artifactIdentity,
|
|
296
|
+
...(extensionHint === undefined ? {} : { extensionHint }),
|
|
297
|
+
...(mimeTypeHint === undefined ? {} : { mimeTypeHint }),
|
|
298
|
+
outputDirectory,
|
|
299
|
+
source: source,
|
|
300
|
+
...(expected === undefined ? {} : { expected }),
|
|
301
|
+
...(maxBytes === undefined ? {} : { maxBytes }),
|
|
302
|
+
...(signal === undefined ? {} : { signal }),
|
|
303
|
+
...(hooks === undefined ? {} : { hooks }),
|
|
304
|
+
...(deadlineAt === undefined ? {} : { deadlineAt }),
|
|
305
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
306
|
+
...(now === undefined ? {} : { now: now })
|
|
307
|
+
};
|
|
308
|
+
return Object.freeze(captured);
|
|
309
|
+
}
|
|
310
|
+
function snapshotExpected(value) {
|
|
311
|
+
if (value === undefined)
|
|
312
|
+
return undefined;
|
|
313
|
+
const record = ownDataRecord(value, "expected artifact", ["bytes", "sha256"]);
|
|
314
|
+
const bytes = readData(record, "bytes");
|
|
315
|
+
const sha256 = readData(record, "sha256");
|
|
316
|
+
if (typeof bytes !== "number" || typeof sha256 !== "string")
|
|
317
|
+
throw new ArtifactOutputError("invalid_expected_artifact", "Expected artifact receipt is invalid.");
|
|
318
|
+
return Object.freeze({ bytes, sha256 });
|
|
319
|
+
}
|
|
320
|
+
function snapshotHooks(value) {
|
|
321
|
+
if (value === undefined)
|
|
322
|
+
return undefined;
|
|
323
|
+
const record = ownDataRecord(value, "output hooks", ["entropy", "faultInjector"]);
|
|
324
|
+
const entropy = readData(record, "entropy");
|
|
325
|
+
const faultInjector = readData(record, "faultInjector");
|
|
326
|
+
if (entropy !== undefined && typeof entropy !== "function")
|
|
327
|
+
throw new ArtifactOutputError("invalid_output_hooks", "Artifact output hooks are invalid.");
|
|
328
|
+
if (faultInjector !== undefined && typeof faultInjector !== "function")
|
|
329
|
+
throw new ArtifactOutputError("invalid_output_hooks", "Artifact output hooks are invalid.");
|
|
330
|
+
const hooks = {};
|
|
331
|
+
if (entropy !== undefined)
|
|
332
|
+
hooks.entropy = entropy;
|
|
333
|
+
if (faultInjector !== undefined)
|
|
334
|
+
hooks.faultInjector = faultInjector;
|
|
335
|
+
return Object.freeze(hooks);
|
|
336
|
+
}
|
|
337
|
+
function ownDataRecord(value, label, allowedKeys) {
|
|
338
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
339
|
+
throw new ArtifactOutputError("invalid_output_options", `${label} is invalid.`);
|
|
340
|
+
let descriptors;
|
|
341
|
+
try {
|
|
342
|
+
descriptors = Object.getOwnPropertyDescriptors(value);
|
|
343
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
344
|
+
if (typeof key !== "string" || !allowedKeys.includes(key))
|
|
345
|
+
throw new ArtifactOutputError("invalid_output_options", `${label} contains unsupported fields.`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
if (isArtifactOutputError(error))
|
|
350
|
+
throw error;
|
|
351
|
+
throw new ArtifactOutputError("invalid_output_options", `${label} is invalid.`);
|
|
352
|
+
}
|
|
353
|
+
for (const [key, descriptor] of Object.entries(descriptors)) {
|
|
354
|
+
if (!allowedKeys.includes(key) || !("value" in descriptor))
|
|
355
|
+
throw new ArtifactOutputError("invalid_output_options", `${label} contains accessor-backed fields.`);
|
|
356
|
+
}
|
|
357
|
+
assertGraphBounds(value);
|
|
358
|
+
return value;
|
|
359
|
+
}
|
|
360
|
+
function readData(record, key) {
|
|
361
|
+
const descriptor = Object.getOwnPropertyDescriptor(record, key);
|
|
362
|
+
return descriptor !== undefined && "value" in descriptor ? descriptor.value : undefined;
|
|
363
|
+
}
|
|
364
|
+
function assertGraphBounds(value, seen = new Set(), depth = 0) {
|
|
365
|
+
if (value === null || typeof value !== "object" || typeof value === "function")
|
|
366
|
+
return;
|
|
367
|
+
if (seen.has(value))
|
|
368
|
+
return;
|
|
369
|
+
if (depth > MAX_GRAPH_DEPTH) {
|
|
370
|
+
throw new ArtifactOutputError("invalid_output_options", "Artifact output options exceed their graph bound.");
|
|
371
|
+
}
|
|
372
|
+
seen.add(value);
|
|
373
|
+
let descriptors;
|
|
374
|
+
try {
|
|
375
|
+
descriptors = Object.getOwnPropertyDescriptors(value);
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
throw new ArtifactOutputError("invalid_output_options", "Artifact output options are invalid.");
|
|
379
|
+
}
|
|
380
|
+
if (seen.size > MAX_GRAPH_NODES)
|
|
381
|
+
throw new ArtifactOutputError("invalid_output_options", "Artifact output options exceed their graph bound.");
|
|
382
|
+
for (const descriptor of Object.values(descriptors)) {
|
|
383
|
+
if (!("value" in descriptor))
|
|
384
|
+
throw new ArtifactOutputError("invalid_output_options", "Artifact output options contain accessor-backed data.");
|
|
385
|
+
assertGraphBounds(descriptor.value, seen, depth + 1);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
function isAsyncIterable(value) {
|
|
389
|
+
if (value === null || (typeof value !== "object" && typeof value !== "function"))
|
|
390
|
+
return false;
|
|
391
|
+
const method = findDataMethod(value, Symbol.asyncIterator);
|
|
392
|
+
return typeof method === "function";
|
|
393
|
+
}
|
|
394
|
+
function safeAsyncIterator(value) {
|
|
395
|
+
const method = findDataMethod(value, Symbol.asyncIterator);
|
|
396
|
+
if (typeof method !== "function")
|
|
397
|
+
throw new ArtifactOutputError("invalid_artifact_source", "Artifact source is invalid.");
|
|
398
|
+
const iterator = Reflect.apply(method, value, []);
|
|
399
|
+
if (iterator === null || (typeof iterator !== "object" && typeof iterator !== "function"))
|
|
400
|
+
throw new ArtifactOutputError("invalid_artifact_source", "Artifact source is invalid.");
|
|
401
|
+
const next = findDataMethod(iterator, "next");
|
|
402
|
+
if (typeof next !== "function")
|
|
403
|
+
throw new ArtifactOutputError("invalid_artifact_source", "Artifact source is invalid.");
|
|
404
|
+
const safe = {
|
|
405
|
+
next: (...args) => Reflect.apply(next, iterator, args)
|
|
406
|
+
};
|
|
407
|
+
const close = findDataMethod(iterator, "return");
|
|
408
|
+
if (typeof close === "function") {
|
|
409
|
+
safe.return = (...args) => Reflect.apply(close, iterator, args);
|
|
410
|
+
}
|
|
411
|
+
return safe;
|
|
412
|
+
}
|
|
413
|
+
function findDataMethod(value, key) {
|
|
414
|
+
let current = value;
|
|
415
|
+
while (current !== null) {
|
|
416
|
+
let descriptor;
|
|
417
|
+
try {
|
|
418
|
+
descriptor = Object.getOwnPropertyDescriptor(current, key);
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
return undefined;
|
|
422
|
+
}
|
|
423
|
+
if (descriptor !== undefined)
|
|
424
|
+
return "value" in descriptor ? descriptor.value : undefined;
|
|
425
|
+
try {
|
|
426
|
+
current = Object.getPrototypeOf(current);
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
return undefined;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return undefined;
|
|
433
|
+
}
|
|
434
|
+
function createRuntime(options) {
|
|
435
|
+
const now = options.now ?? Date.now;
|
|
436
|
+
let initial;
|
|
437
|
+
try {
|
|
438
|
+
initial = readClock(now);
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
const runtime = {
|
|
442
|
+
now,
|
|
443
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
444
|
+
clockFaulted: true,
|
|
445
|
+
localEffectInFlight: false,
|
|
446
|
+
localEffectUncertain: false,
|
|
447
|
+
providerUncertain: false,
|
|
448
|
+
localEffectPromise: undefined
|
|
449
|
+
};
|
|
450
|
+
if (options.deadlineAt !== undefined)
|
|
451
|
+
runtime.deadlineAt = options.deadlineAt;
|
|
452
|
+
return runtime;
|
|
453
|
+
}
|
|
454
|
+
let deadlineAt = options.deadlineAt;
|
|
455
|
+
if (options.timeoutMs !== undefined) {
|
|
456
|
+
const relative = initial + options.timeoutMs;
|
|
457
|
+
if (!Number.isFinite(relative) || relative > MAX_DEADLINE_AT) {
|
|
458
|
+
const runtime = {
|
|
459
|
+
now,
|
|
460
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
461
|
+
lastNow: initial,
|
|
462
|
+
clockFaulted: true,
|
|
463
|
+
localEffectInFlight: false,
|
|
464
|
+
localEffectUncertain: false,
|
|
465
|
+
providerUncertain: false,
|
|
466
|
+
localEffectPromise: undefined
|
|
467
|
+
};
|
|
468
|
+
if (deadlineAt !== undefined)
|
|
469
|
+
runtime.deadlineAt = deadlineAt;
|
|
470
|
+
return runtime;
|
|
471
|
+
}
|
|
472
|
+
deadlineAt = deadlineAt === undefined ? relative : Math.min(deadlineAt, relative);
|
|
473
|
+
}
|
|
474
|
+
const runtime = {
|
|
475
|
+
now,
|
|
476
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
477
|
+
lastNow: initial,
|
|
478
|
+
clockFaulted: false,
|
|
479
|
+
localEffectInFlight: false,
|
|
480
|
+
localEffectUncertain: false,
|
|
481
|
+
providerUncertain: false,
|
|
482
|
+
localEffectPromise: undefined
|
|
483
|
+
};
|
|
484
|
+
if (deadlineAt !== undefined)
|
|
485
|
+
runtime.deadlineAt = deadlineAt;
|
|
486
|
+
return runtime;
|
|
487
|
+
}
|
|
488
|
+
function checkRuntime(runtime) {
|
|
489
|
+
if (runtime.clockFaulted) {
|
|
490
|
+
throw new ArtifactOutputError("clock_invalid", "The operation clock is invalid.");
|
|
491
|
+
}
|
|
492
|
+
let current;
|
|
493
|
+
try {
|
|
494
|
+
current = readClock(runtime.now);
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
runtime.clockFaulted = true;
|
|
498
|
+
throw new ArtifactOutputError("clock_invalid", "The operation clock is invalid.");
|
|
499
|
+
}
|
|
500
|
+
if (runtime.lastNow !== undefined && current < runtime.lastNow) {
|
|
501
|
+
runtime.clockFaulted = true;
|
|
502
|
+
throw new ArtifactOutputError("clock_invalid", "The operation clock moved backwards.");
|
|
503
|
+
}
|
|
504
|
+
runtime.lastNow = current;
|
|
505
|
+
if (runtime.deadlineAt !== undefined && current >= runtime.deadlineAt) {
|
|
506
|
+
throw new ArtifactOutputError("operation_timeout", "The operation deadline expired.");
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
function readClock(now) {
|
|
510
|
+
const value = Reflect.apply(now, undefined, []);
|
|
511
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > MAX_DEADLINE_AT) {
|
|
512
|
+
throw new Error("invalid clock");
|
|
513
|
+
}
|
|
514
|
+
return value;
|
|
515
|
+
}
|
|
516
|
+
async function bounded(runtime, operation) {
|
|
517
|
+
checkRuntime(runtime);
|
|
518
|
+
try {
|
|
519
|
+
// Provider callbacks may mutate state after they resolve or reject. Keep
|
|
520
|
+
// effect authority until the callback has actually settled, then classify
|
|
521
|
+
// a crossed deadline rather than returning while it remains in flight.
|
|
522
|
+
const value = await Promise.resolve().then(operation);
|
|
523
|
+
checkRuntime(runtime);
|
|
524
|
+
return value;
|
|
525
|
+
}
|
|
526
|
+
catch (error) {
|
|
527
|
+
try {
|
|
528
|
+
checkRuntime(runtime);
|
|
529
|
+
}
|
|
530
|
+
catch (runtimeError) {
|
|
531
|
+
throw runtimeError;
|
|
532
|
+
}
|
|
533
|
+
throw error;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Install a real wake-up for provider boundaries. `now` remains the
|
|
538
|
+
* authoritative validation clock, while the timer ensures a provider that
|
|
539
|
+
* never settles cannot keep a request pending beyond an explicit deadline.
|
|
540
|
+
* Long absolute deadlines are scheduled in Node's bounded timer-sized
|
|
541
|
+
* pieces; a single oversized delay would otherwise wrap and fire early.
|
|
542
|
+
*/
|
|
543
|
+
function armProviderBoundary(runtime, fallbackTimeoutMs) {
|
|
544
|
+
const signal = runtime.signal;
|
|
545
|
+
let remaining;
|
|
546
|
+
if (runtime.deadlineAt !== undefined) {
|
|
547
|
+
const baseline = runtime.lastNow ?? readClock(runtime.now);
|
|
548
|
+
remaining = runtime.deadlineAt - baseline;
|
|
549
|
+
}
|
|
550
|
+
else if (fallbackTimeoutMs !== undefined) {
|
|
551
|
+
remaining = fallbackTimeoutMs;
|
|
552
|
+
}
|
|
553
|
+
if (signal === undefined && remaining === undefined)
|
|
554
|
+
return undefined;
|
|
555
|
+
let resolveBoundary;
|
|
556
|
+
let timer;
|
|
557
|
+
let cancelled = false;
|
|
558
|
+
const promise = new Promise(resolve => {
|
|
559
|
+
resolveBoundary = resolve;
|
|
560
|
+
});
|
|
561
|
+
const listener = () => trigger("aborted");
|
|
562
|
+
const removeListener = () => {
|
|
563
|
+
if (signal === undefined)
|
|
564
|
+
return;
|
|
565
|
+
try {
|
|
566
|
+
signal.removeEventListener("abort", listener);
|
|
567
|
+
}
|
|
568
|
+
catch { /* fail closed below */ }
|
|
569
|
+
};
|
|
570
|
+
const clearTimer = () => {
|
|
571
|
+
if (timer !== undefined)
|
|
572
|
+
clearTimeout(timer);
|
|
573
|
+
timer = undefined;
|
|
574
|
+
};
|
|
575
|
+
function trigger(boundary) {
|
|
576
|
+
if (cancelled)
|
|
577
|
+
return;
|
|
578
|
+
cancelled = true;
|
|
579
|
+
clearTimer();
|
|
580
|
+
removeListener();
|
|
581
|
+
runtime.providerBoundary = boundary;
|
|
582
|
+
runtime.providerUncertain = true;
|
|
583
|
+
resolveBoundary(boundary);
|
|
584
|
+
}
|
|
585
|
+
const schedule = (delay) => {
|
|
586
|
+
if (cancelled)
|
|
587
|
+
return;
|
|
588
|
+
const boundedDelay = Math.min(delay, MAX_TIMER_DELAY_MS);
|
|
589
|
+
timer = setTimeout(() => {
|
|
590
|
+
if (delay > MAX_TIMER_DELAY_MS) {
|
|
591
|
+
schedule(delay - MAX_TIMER_DELAY_MS);
|
|
592
|
+
}
|
|
593
|
+
else {
|
|
594
|
+
trigger("timeout");
|
|
595
|
+
}
|
|
596
|
+
}, boundedDelay);
|
|
597
|
+
};
|
|
598
|
+
if (signal !== undefined) {
|
|
599
|
+
if (isSignalAborted(signal)) {
|
|
600
|
+
trigger("aborted");
|
|
601
|
+
return { promise, cancel: () => undefined };
|
|
602
|
+
}
|
|
603
|
+
try {
|
|
604
|
+
signal.addEventListener("abort", listener, { once: true });
|
|
605
|
+
}
|
|
606
|
+
catch {
|
|
607
|
+
trigger("aborted");
|
|
608
|
+
return { promise, cancel: () => undefined };
|
|
609
|
+
}
|
|
610
|
+
// Close the race where abort happens between the state check and listener
|
|
611
|
+
// registration. A genuine signal is the only source of this value.
|
|
612
|
+
if (isSignalAborted(signal))
|
|
613
|
+
trigger("aborted");
|
|
614
|
+
}
|
|
615
|
+
if (!cancelled && remaining !== undefined) {
|
|
616
|
+
if (!Number.isFinite(remaining) || remaining <= 0)
|
|
617
|
+
trigger("timeout");
|
|
618
|
+
else
|
|
619
|
+
schedule(remaining);
|
|
620
|
+
}
|
|
621
|
+
return {
|
|
622
|
+
promise,
|
|
623
|
+
cancel: () => {
|
|
624
|
+
if (cancelled)
|
|
625
|
+
return;
|
|
626
|
+
cancelled = true;
|
|
627
|
+
clearTimer();
|
|
628
|
+
removeListener();
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
async function awaitProviderPromise(runtime, operationPromise, fallbackTimeoutMs) {
|
|
633
|
+
const boundary = armProviderBoundary(runtime, fallbackTimeoutMs);
|
|
634
|
+
const settled = Promise.resolve(operationPromise).then(value => ({ kind: "value", value }), error => ({ kind: "error", error }));
|
|
635
|
+
let outcome;
|
|
636
|
+
try {
|
|
637
|
+
outcome = boundary === undefined
|
|
638
|
+
? await settled
|
|
639
|
+
: await Promise.race([settled, boundary.promise]);
|
|
640
|
+
}
|
|
641
|
+
finally {
|
|
642
|
+
boundary?.cancel();
|
|
643
|
+
}
|
|
644
|
+
if (outcome === "timeout" || outcome === "aborted") {
|
|
645
|
+
throw new ArtifactOutputError(outcome === "timeout" ? "operation_timeout" : "source_aborted", outcome === "timeout" ? "The artifact source exceeded its operation deadline." : "The artifact source was aborted.");
|
|
646
|
+
}
|
|
647
|
+
if (outcome.kind === "error")
|
|
648
|
+
throw outcome.error;
|
|
649
|
+
checkRuntime(runtime);
|
|
650
|
+
return outcome.value;
|
|
651
|
+
}
|
|
652
|
+
async function providerEffect(runtime, operation, fallbackTimeoutMs) {
|
|
653
|
+
checkRuntime(runtime);
|
|
654
|
+
return await awaitProviderPromise(runtime, Promise.resolve().then(operation), fallbackTimeoutMs);
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Await a filesystem effect to settlement. A deadline crossing is observed
|
|
658
|
+
* only after the syscall settles, and the caller quarantines the residue
|
|
659
|
+
* before returning an indeterminate result.
|
|
660
|
+
*/
|
|
661
|
+
async function localEffect(runtime, operation) {
|
|
662
|
+
checkRuntime(runtime);
|
|
663
|
+
const operationPromise = Promise.resolve().then(operation);
|
|
664
|
+
runtime.localEffectInFlight = true;
|
|
665
|
+
runtime.localEffectPromise = operationPromise;
|
|
666
|
+
try {
|
|
667
|
+
const value = await operationPromise;
|
|
668
|
+
try {
|
|
669
|
+
checkRuntime(runtime);
|
|
670
|
+
}
|
|
671
|
+
catch (error) {
|
|
672
|
+
if (isRuntimeFailure(error))
|
|
673
|
+
runtime.localEffectUncertain = true;
|
|
674
|
+
throw error;
|
|
675
|
+
}
|
|
676
|
+
return value;
|
|
677
|
+
}
|
|
678
|
+
catch (error) {
|
|
679
|
+
if (isRuntimeFailure(error)) {
|
|
680
|
+
runtime.localEffectUncertain = true;
|
|
681
|
+
}
|
|
682
|
+
else {
|
|
683
|
+
try {
|
|
684
|
+
checkRuntime(runtime);
|
|
685
|
+
}
|
|
686
|
+
catch (runtimeError) {
|
|
687
|
+
if (isRuntimeFailure(runtimeError))
|
|
688
|
+
runtime.localEffectUncertain = true;
|
|
689
|
+
throw runtimeError;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
throw error;
|
|
693
|
+
}
|
|
694
|
+
finally {
|
|
695
|
+
runtime.localEffectInFlight = false;
|
|
696
|
+
if (runtime.localEffectPromise === operationPromise)
|
|
697
|
+
runtime.localEffectPromise = undefined;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
function isRuntimeFailure(error) {
|
|
701
|
+
return isRuntimeArtifactError(error);
|
|
702
|
+
}
|
|
703
|
+
async function nextWithDeadline(iterator, runtime) {
|
|
704
|
+
// A source read is an external/provider effect. Unlike local fsync/write
|
|
705
|
+
// calls, it must be interruptible at the configured boundary; a never
|
|
706
|
+
// settling `next()` is quarantined and cannot retain the transaction.
|
|
707
|
+
const raw = await providerEffect(runtime, () => iterator.next(), PROVIDER_READ_TIMEOUT_MS);
|
|
708
|
+
if (raw === null || (typeof raw !== "object" && typeof raw !== "function")) {
|
|
709
|
+
throw new ArtifactOutputError("invalid_artifact_source", "Artifact source iterator result is invalid.");
|
|
710
|
+
}
|
|
711
|
+
const done = readDataProperty(raw, "done");
|
|
712
|
+
if (!done.found || typeof done.value !== "boolean") {
|
|
713
|
+
throw new ArtifactOutputError("invalid_artifact_source", "Artifact source iterator result is invalid.");
|
|
714
|
+
}
|
|
715
|
+
if (done.value)
|
|
716
|
+
return { done: true, value: undefined };
|
|
717
|
+
const value = readDataProperty(raw, "value");
|
|
718
|
+
return { done: false, value: value.found ? value.value : undefined };
|
|
719
|
+
}
|
|
720
|
+
async function closeAsyncIterator(iterator, runtime) {
|
|
721
|
+
const close = iterator.return;
|
|
722
|
+
if (typeof close !== "function")
|
|
723
|
+
return;
|
|
724
|
+
const operation = Promise.resolve().then(() => close.call(iterator));
|
|
725
|
+
// If a preceding provider boundary already fired, issue best-effort close
|
|
726
|
+
// but do not await a provider that is now quarantined. The settlement
|
|
727
|
+
// observer prevents a late rejection from becoming an unhandled promise.
|
|
728
|
+
void operation.then(() => undefined, () => undefined);
|
|
729
|
+
if (runtime.providerBoundary !== undefined || runtime.clockFaulted)
|
|
730
|
+
return;
|
|
731
|
+
// A settled provider read can advance the injected clock beyond its
|
|
732
|
+
// deadline before this finally block runs. Give an already-queued close one
|
|
733
|
+
// microtask to settle, but never wait on a new provider effect after the
|
|
734
|
+
// deadline. This preserves the precise `operation_timeout` result for a
|
|
735
|
+
// source that did settle, while a genuinely pending close remains
|
|
736
|
+
// quarantined.
|
|
737
|
+
if (runtime.deadlineAt !== undefined && runtime.lastNow !== undefined && runtime.lastNow >= runtime.deadlineAt) {
|
|
738
|
+
let tick;
|
|
739
|
+
const closeSettled = operation.then(() => true, () => true);
|
|
740
|
+
const grace = new Promise(resolve => {
|
|
741
|
+
tick = setTimeout(() => resolve(false), 0);
|
|
742
|
+
});
|
|
743
|
+
const settled = await Promise.race([closeSettled, grace]);
|
|
744
|
+
if (tick !== undefined)
|
|
745
|
+
clearTimeout(tick);
|
|
746
|
+
if (!settled)
|
|
747
|
+
runtime.providerUncertain = true;
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
try {
|
|
751
|
+
// Cleanup has a bounded grace period even when the caller did not provide
|
|
752
|
+
// a deadline. When a request deadline exists, armProviderBoundary uses
|
|
753
|
+
// that earlier boundary instead of extending the operation.
|
|
754
|
+
await awaitProviderPromise(runtime, operation, PROVIDER_CLEANUP_TIMEOUT_MS);
|
|
755
|
+
}
|
|
756
|
+
catch {
|
|
757
|
+
// The primary source/output outcome remains authoritative. A timeout or
|
|
758
|
+
// cleanup failure is quarantined and never triggers a source retry.
|
|
759
|
+
runtime.providerUncertain = true;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
function readDataProperty(value, key) {
|
|
763
|
+
let current = value;
|
|
764
|
+
while (current !== null) {
|
|
765
|
+
let descriptor;
|
|
766
|
+
try {
|
|
767
|
+
descriptor = Object.getOwnPropertyDescriptor(current, key);
|
|
768
|
+
}
|
|
769
|
+
catch {
|
|
770
|
+
throw new ArtifactOutputError("invalid_artifact_source", "Artifact source iterator result is invalid.");
|
|
771
|
+
}
|
|
772
|
+
if (descriptor !== undefined) {
|
|
773
|
+
if (!("value" in descriptor))
|
|
774
|
+
throw new ArtifactOutputError("invalid_artifact_source", "Artifact source iterator result is invalid.");
|
|
775
|
+
return { found: true, value: descriptor.value };
|
|
776
|
+
}
|
|
777
|
+
try {
|
|
778
|
+
current = Object.getPrototypeOf(current);
|
|
779
|
+
}
|
|
780
|
+
catch {
|
|
781
|
+
throw new ArtifactOutputError("invalid_artifact_source", "Artifact source iterator result is invalid.");
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return { found: false };
|
|
785
|
+
}
|
|
786
|
+
function isSignalAborted(signal) {
|
|
787
|
+
if (signal === undefined)
|
|
788
|
+
return false;
|
|
789
|
+
try {
|
|
790
|
+
return signal.aborted === true;
|
|
791
|
+
}
|
|
792
|
+
catch {
|
|
793
|
+
// A genuine signal whose implementation is no longer readable is not a
|
|
794
|
+
// safe write authority; fail closed as an aborted stream.
|
|
795
|
+
return true;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
function isGenuineAbortSignal(value) {
|
|
799
|
+
try {
|
|
800
|
+
return typeof AbortSignal !== "undefined" && value instanceof AbortSignal;
|
|
801
|
+
}
|
|
802
|
+
catch {
|
|
803
|
+
return false;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
function isValidDeadline(value) {
|
|
807
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= MAX_DEADLINE_AT;
|
|
808
|
+
}
|
|
809
|
+
function isValidTimeout(value) {
|
|
810
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= MAX_TIMEOUT_MS;
|
|
811
|
+
}
|
|
812
|
+
function byteLength(value) {
|
|
813
|
+
return Buffer.byteLength(value, "utf8");
|
|
814
|
+
}
|
|
815
|
+
function validateCommitOptions(options) {
|
|
816
|
+
validateOpaqueIdentity(options.operationId, "operationId");
|
|
817
|
+
validateOpaqueIdentity(options.artifactIdentity, "artifactIdentity");
|
|
818
|
+
normalizeExtension(options.extensionHint, options.mimeTypeHint);
|
|
819
|
+
if (!isAbsolute(options.outputDirectory)) {
|
|
820
|
+
throw new ArtifactOutputError("output_directory_not_absolute", "Output directory must be absolute.");
|
|
821
|
+
}
|
|
822
|
+
if (byteLength(options.outputDirectory) > MAX_OUTPUT_DIRECTORY_BYTES || options.outputDirectory.includes("\u0000")) {
|
|
823
|
+
throw new ArtifactOutputError("invalid_output_directory", "Output directory is invalid.");
|
|
824
|
+
}
|
|
825
|
+
if (!isAsyncIterable(options.source)) {
|
|
826
|
+
throw new ArtifactOutputError("invalid_artifact_source", "Artifact source must be an async iterable of byte chunks.");
|
|
827
|
+
}
|
|
828
|
+
validateMaxBytes(options.maxBytes);
|
|
829
|
+
if (options.expected !== undefined) {
|
|
830
|
+
if (!Number.isSafeInteger(options.expected.bytes) || options.expected.bytes < 0
|
|
831
|
+
|| !/^[0-9a-f]{64}$/.test(options.expected.sha256)
|
|
832
|
+
|| options.expected.bytes > (options.maxBytes ?? DEFAULT_MAX_ARTIFACT_BYTES)) {
|
|
833
|
+
throw new ArtifactOutputError("invalid_expected_artifact", "Expected artifact receipt must contain a bounded byte count and lowercase SHA-256.");
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
if (options.signal !== undefined && !isGenuineAbortSignal(options.signal)) {
|
|
837
|
+
throw new ArtifactOutputError("invalid_abort_signal", "Abort signal is invalid.");
|
|
838
|
+
}
|
|
839
|
+
if (options.deadlineAt !== undefined && !isValidDeadline(options.deadlineAt)) {
|
|
840
|
+
throw new ArtifactOutputError("invalid_deadline", "deadlineAt must be a bounded timestamp.");
|
|
841
|
+
}
|
|
842
|
+
if (options.timeoutMs !== undefined && !isValidTimeout(options.timeoutMs)) {
|
|
843
|
+
throw new ArtifactOutputError("invalid_deadline", "timeoutMs must be a bounded duration.");
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
function validateMaxBytes(value) {
|
|
847
|
+
const max = value ?? DEFAULT_MAX_ARTIFACT_BYTES;
|
|
848
|
+
if (!Number.isSafeInteger(max) || max < 0 || max > MAX_MAX_ARTIFACT_BYTES) {
|
|
849
|
+
throw new ArtifactOutputError("invalid_artifact_byte_limit", "maxBytes must be a bounded non-negative safe integer.");
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
async function secureOutputDirectory(requested, runtime) {
|
|
853
|
+
checkRuntime(runtime);
|
|
854
|
+
let metadata;
|
|
855
|
+
try {
|
|
856
|
+
metadata = await lstat(requested, { bigint: true });
|
|
857
|
+
}
|
|
858
|
+
catch {
|
|
859
|
+
throw new ArtifactOutputError("destination_invalid", "Output directory is unavailable.");
|
|
860
|
+
}
|
|
861
|
+
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
|
862
|
+
throw new ArtifactOutputError("destination_invalid", "Output directory must be a real directory.");
|
|
863
|
+
}
|
|
864
|
+
let canonical;
|
|
865
|
+
try {
|
|
866
|
+
canonical = await realpath(requested);
|
|
867
|
+
}
|
|
868
|
+
catch {
|
|
869
|
+
throw new ArtifactOutputError("destination_invalid", "Output directory could not be resolved.");
|
|
870
|
+
}
|
|
871
|
+
const canonicalMetadata = await lstat(canonical, { bigint: true });
|
|
872
|
+
if (canonicalMetadata.isSymbolicLink() || !canonicalMetadata.isDirectory()) {
|
|
873
|
+
throw new ArtifactOutputError("destination_invalid", "Output directory must resolve to a real directory.");
|
|
874
|
+
}
|
|
875
|
+
checkRuntime(runtime);
|
|
876
|
+
return { requested, canonical, device: canonicalMetadata.dev, inode: canonicalMetadata.ino };
|
|
877
|
+
}
|
|
878
|
+
async function assertDirectoryStable(directory, runtime) {
|
|
879
|
+
checkRuntime(runtime);
|
|
880
|
+
let metadata;
|
|
881
|
+
try {
|
|
882
|
+
metadata = await lstat(directory.canonical, { bigint: true });
|
|
883
|
+
}
|
|
884
|
+
catch {
|
|
885
|
+
throw new ArtifactOutputError("destination_invalid", "Output directory is unavailable.");
|
|
886
|
+
}
|
|
887
|
+
if (metadata.isSymbolicLink() || !metadata.isDirectory() || metadata.dev !== directory.device || metadata.ino !== directory.inode) {
|
|
888
|
+
throw new ArtifactOutputError("destination_invalid", "Output directory changed during the local effect.");
|
|
889
|
+
}
|
|
890
|
+
checkRuntime(runtime);
|
|
891
|
+
}
|
|
892
|
+
function safeChildPath(directory, outputKey) {
|
|
893
|
+
const candidate = resolve(directory, outputKey);
|
|
894
|
+
const remainder = relative(directory, candidate);
|
|
895
|
+
if (remainder.length === 0 || remainder.startsWith(`..${sep}`) || remainder === ".." || isAbsolute(remainder) || remainder.includes(sep)) {
|
|
896
|
+
throw new ArtifactOutputError("unsafe_output_key", "Output key must resolve to one child beneath the destination.");
|
|
897
|
+
}
|
|
898
|
+
return candidate;
|
|
899
|
+
}
|
|
900
|
+
async function matchingTemps(directory, prefix, runtime) {
|
|
901
|
+
try {
|
|
902
|
+
checkScanRuntime(runtime);
|
|
903
|
+
await assertDirectoryStable(directory, runtime);
|
|
904
|
+
}
|
|
905
|
+
catch (error) {
|
|
906
|
+
rethrowRuntimeFailure(error);
|
|
907
|
+
return undefined;
|
|
908
|
+
}
|
|
909
|
+
let handle;
|
|
910
|
+
try {
|
|
911
|
+
handle = await opendir(directory.canonical);
|
|
912
|
+
}
|
|
913
|
+
catch (error) {
|
|
914
|
+
rethrowRuntimeFailure(error);
|
|
915
|
+
return undefined;
|
|
916
|
+
}
|
|
917
|
+
const matches = [];
|
|
918
|
+
let scannedEntries = 0;
|
|
919
|
+
try {
|
|
920
|
+
checkScanRuntime(runtime);
|
|
921
|
+
for await (const entry of handle) {
|
|
922
|
+
checkScanRuntime(runtime);
|
|
923
|
+
scannedEntries += 1;
|
|
924
|
+
if (scannedEntries > MAX_TEMP_SCAN_ENTRIES) {
|
|
925
|
+
// An overfull destination is ambiguous: do not inspect a target or
|
|
926
|
+
// consume the source after the bounded prefix. The finally block
|
|
927
|
+
// closes the directory capability before this safe blocker returns.
|
|
928
|
+
return undefined;
|
|
929
|
+
}
|
|
930
|
+
if (!entry.name.startsWith(prefix))
|
|
931
|
+
continue;
|
|
932
|
+
matches.push(safeChildPath(directory.canonical, entry.name));
|
|
933
|
+
if (matches.length > 1)
|
|
934
|
+
break;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
catch (error) {
|
|
938
|
+
rethrowRuntimeFailure(error);
|
|
939
|
+
return undefined;
|
|
940
|
+
}
|
|
941
|
+
finally {
|
|
942
|
+
await handle.close().catch(() => undefined);
|
|
943
|
+
}
|
|
944
|
+
try {
|
|
945
|
+
checkScanRuntime(runtime);
|
|
946
|
+
await assertDirectoryStable(directory, runtime);
|
|
947
|
+
}
|
|
948
|
+
catch (error) {
|
|
949
|
+
// A clock failure or deadline is not an ambiguous directory listing.
|
|
950
|
+
// Preserve it for the caller's redacted bounded outcome.
|
|
951
|
+
// (Other directory races remain the ambiguity blocker.)
|
|
952
|
+
rethrowRuntimeFailure(error);
|
|
953
|
+
return undefined;
|
|
954
|
+
}
|
|
955
|
+
return matches;
|
|
956
|
+
}
|
|
957
|
+
function checkScanRuntime(runtime) {
|
|
958
|
+
checkRuntime(runtime);
|
|
959
|
+
if (isSignalAborted(runtime.signal)) {
|
|
960
|
+
throw new ArtifactOutputError("source_aborted", "The artifact source was aborted.");
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
async function reconcilePreexistingOutput(directory, finalPath, tempPrefix, outputKey, expected, hooks, runtime) {
|
|
964
|
+
const temps = await matchingTemps(directory, tempPrefix, runtime);
|
|
965
|
+
const empty = { bytes: 0, sha256: EMPTY_SHA256 };
|
|
966
|
+
if (temps === undefined || temps.length > 1) {
|
|
967
|
+
return result(outputKey, expected ?? empty, "blocked", "ambiguous_temp");
|
|
968
|
+
}
|
|
969
|
+
const tempPath = temps[0];
|
|
970
|
+
if (tempPath === undefined) {
|
|
971
|
+
if (expected === undefined)
|
|
972
|
+
return undefined;
|
|
973
|
+
await assertDirectoryStable(directory, runtime);
|
|
974
|
+
const existing = await inspectExistingFinal(finalPath, expected, runtime);
|
|
975
|
+
if (existing === "same")
|
|
976
|
+
return result(outputKey, expected, "reconciled", "already_present");
|
|
977
|
+
if (existing === "different")
|
|
978
|
+
return result(outputKey, expected, "collision", "existing_mismatch");
|
|
979
|
+
if (existing === "not_regular")
|
|
980
|
+
return result(outputKey, expected, "blocked", "existing_target_not_regular");
|
|
981
|
+
return undefined;
|
|
982
|
+
}
|
|
983
|
+
// A crash-leftover filename is not enough to prove content ownership. It
|
|
984
|
+
// can only be reused when a durable upstream receipt supplies exact bytes
|
|
985
|
+
// and SHA-256 and the open-file snapshot remains stable while hashing.
|
|
986
|
+
if (expected === undefined) {
|
|
987
|
+
// A failed effect may retain a receipt-bound temp because no final was
|
|
988
|
+
// proven. If a regular final already exists, leave that evidence untouched
|
|
989
|
+
// and continue to normal exclusive collision reconciliation after consuming
|
|
990
|
+
// the new source. An orphan temp with no final remains ambiguous and must
|
|
991
|
+
// block rather than being guessed at.
|
|
992
|
+
const existing = await inspectExistingFinalKind(finalPath, runtime);
|
|
993
|
+
if (existing === "regular")
|
|
994
|
+
return undefined;
|
|
995
|
+
if (existing === "not_regular")
|
|
996
|
+
return result(outputKey, empty, "blocked", "existing_target_not_regular");
|
|
997
|
+
return result(outputKey, empty, "blocked", "ambiguous_temp");
|
|
998
|
+
}
|
|
999
|
+
let metadata;
|
|
1000
|
+
try {
|
|
1001
|
+
await assertDirectoryStable(directory, runtime);
|
|
1002
|
+
metadata = await lstat(tempPath, { bigint: true });
|
|
1003
|
+
}
|
|
1004
|
+
catch (error) {
|
|
1005
|
+
rethrowRuntimeFailure(error);
|
|
1006
|
+
return result(outputKey, expected, "blocked", "ambiguous_temp");
|
|
1007
|
+
}
|
|
1008
|
+
if (metadata.isSymbolicLink() || !metadata.isFile())
|
|
1009
|
+
return result(outputKey, expected, "blocked", "ambiguous_temp");
|
|
1010
|
+
let temp;
|
|
1011
|
+
try {
|
|
1012
|
+
const handle = await open(tempPath, fsConstants.O_RDWR | (fsConstants.O_NOFOLLOW ?? 0));
|
|
1013
|
+
const opened = await handle.stat({ bigint: true });
|
|
1014
|
+
if (!sameFileIdentity(metadata, opened) || !opened.isFile()) {
|
|
1015
|
+
await handle.close().catch(() => undefined);
|
|
1016
|
+
return result(outputKey, expected, "blocked", "ambiguous_temp");
|
|
1017
|
+
}
|
|
1018
|
+
temp = { path: tempPath, device: opened.dev, inode: opened.ino, handle };
|
|
1019
|
+
if (!(await retainedTempMatches(temp, expected, runtime))) {
|
|
1020
|
+
return result(outputKey, expected, "blocked", "ambiguous_temp");
|
|
1021
|
+
}
|
|
1022
|
+
let status;
|
|
1023
|
+
let reason;
|
|
1024
|
+
try {
|
|
1025
|
+
const installed = await installOrReconcile(directory, finalPath, temp, expected, hooks, runtime);
|
|
1026
|
+
if (!installed)
|
|
1027
|
+
return result(outputKey, expected, "blocked", "commit_indeterminate");
|
|
1028
|
+
status = "committed";
|
|
1029
|
+
reason = "recovered_after_crash";
|
|
1030
|
+
}
|
|
1031
|
+
catch (error) {
|
|
1032
|
+
if (!isInstallOutcome(error))
|
|
1033
|
+
return result(outputKey, expected, "blocked", "commit_indeterminate");
|
|
1034
|
+
status = error.status;
|
|
1035
|
+
reason = error.reason;
|
|
1036
|
+
}
|
|
1037
|
+
// Preserve a verified temp when the final target conflicts. Deleting it
|
|
1038
|
+
// would discard the only crash-recovery evidence and could make a later
|
|
1039
|
+
// user-resolved retry repeat the transfer.
|
|
1040
|
+
if (status !== "committed" && status !== "reconciled")
|
|
1041
|
+
return result(outputKey, expected, status, reason);
|
|
1042
|
+
const cleanup = await cleanupOwnedTemp(directory, temp, hooks, runtime);
|
|
1043
|
+
if (runtime.localEffectUncertain)
|
|
1044
|
+
return result(outputKey, expected, "blocked", "commit_indeterminate");
|
|
1045
|
+
try {
|
|
1046
|
+
await syncDirectory(directory, hooks, runtime);
|
|
1047
|
+
}
|
|
1048
|
+
catch {
|
|
1049
|
+
return result(outputKey, expected, "blocked", "commit_indeterminate");
|
|
1050
|
+
}
|
|
1051
|
+
if (cleanup === "ambiguous")
|
|
1052
|
+
return result(outputKey, expected, "blocked", "temp_cleanup_ambiguous");
|
|
1053
|
+
if (cleanup === "pending") {
|
|
1054
|
+
// The final copy is durable and receipt-bound, but cleanup was
|
|
1055
|
+
// intentionally quarantined (for example, a verified recovery temp).
|
|
1056
|
+
return result(outputKey, expected, status, "temp_cleanup_pending");
|
|
1057
|
+
}
|
|
1058
|
+
return result(outputKey, expected, status, reason);
|
|
1059
|
+
}
|
|
1060
|
+
catch {
|
|
1061
|
+
return result(outputKey, expected, "blocked", "ambiguous_temp");
|
|
1062
|
+
}
|
|
1063
|
+
finally {
|
|
1064
|
+
if (temp !== undefined)
|
|
1065
|
+
await closeTempHandle(temp);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
async function createOwnedTemp(directory, prefix, hooks, runtime) {
|
|
1069
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
1070
|
+
const token = await entropyHex(hooks?.entropy, runtime);
|
|
1071
|
+
const tempPath = safeChildPath(directory.canonical, `${prefix}${token}.tmp`);
|
|
1072
|
+
try {
|
|
1073
|
+
await inject(hooks, "before_temp_open", runtime);
|
|
1074
|
+
}
|
|
1075
|
+
catch (error) {
|
|
1076
|
+
rethrowRuntimeFailure(error);
|
|
1077
|
+
throw new ArtifactOutputError("temp_open_failed", "Operation-owned temporary output could not be created.");
|
|
1078
|
+
}
|
|
1079
|
+
let handle;
|
|
1080
|
+
try {
|
|
1081
|
+
await assertDirectoryStable(directory, runtime);
|
|
1082
|
+
handle = await open(tempPath, fsConstants.O_RDWR | fsConstants.O_CREAT | fsConstants.O_EXCL | (fsConstants.O_NOFOLLOW ?? 0), POSIX_FILE_MODE);
|
|
1083
|
+
}
|
|
1084
|
+
catch (error) {
|
|
1085
|
+
rethrowRuntimeFailure(error);
|
|
1086
|
+
if (isErrno(error, "EEXIST"))
|
|
1087
|
+
continue;
|
|
1088
|
+
throw new ArtifactOutputError("temp_open_failed", "Operation-owned temporary output could not be created.");
|
|
1089
|
+
}
|
|
1090
|
+
try {
|
|
1091
|
+
const stats = await handle.stat({ bigint: true });
|
|
1092
|
+
if (!stats.isFile()) {
|
|
1093
|
+
throw new ArtifactOutputError("temp_open_failed", "Operation-owned temporary output is not a regular file.");
|
|
1094
|
+
}
|
|
1095
|
+
// Keep this descriptor open through streaming and installation. The
|
|
1096
|
+
// temp pathname may be replaced after this point; the retained handle
|
|
1097
|
+
// remains the only source capability used by the commit phase.
|
|
1098
|
+
return { path: tempPath, device: stats.dev, inode: stats.ino, handle };
|
|
1099
|
+
}
|
|
1100
|
+
catch (error) {
|
|
1101
|
+
await handle.close().catch(() => undefined);
|
|
1102
|
+
throw error;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
throw new ArtifactOutputError("temp_open_failed", "Could not obtain a collision-resistant temporary output name.");
|
|
1106
|
+
}
|
|
1107
|
+
async function writeSource(temp, options, runtime) {
|
|
1108
|
+
const handle = temp.handle;
|
|
1109
|
+
const maxBytes = options.maxBytes ?? DEFAULT_MAX_ARTIFACT_BYTES;
|
|
1110
|
+
const digest = createHash("sha256");
|
|
1111
|
+
let bytes = 0;
|
|
1112
|
+
let chunkCount = 0;
|
|
1113
|
+
let iterator;
|
|
1114
|
+
let iteratorDone = false;
|
|
1115
|
+
try {
|
|
1116
|
+
checkRuntime(runtime);
|
|
1117
|
+
const opened = await handle.stat({ bigint: true });
|
|
1118
|
+
if (!opened.isFile() || opened.dev !== temp.device || opened.ino !== temp.inode) {
|
|
1119
|
+
throw new ArtifactOutputError("write_failed", "Operation-owned temporary output changed before writing.");
|
|
1120
|
+
}
|
|
1121
|
+
if (isSignalAborted(options.signal)) {
|
|
1122
|
+
throw new StreamOutcome("blocked", "source_aborted", bytes, digest.copy().digest("hex"));
|
|
1123
|
+
}
|
|
1124
|
+
iterator = safeAsyncIterator(options.source);
|
|
1125
|
+
while (true) {
|
|
1126
|
+
const next = await nextWithDeadline(iterator, runtime);
|
|
1127
|
+
if (next.done) {
|
|
1128
|
+
iteratorDone = true;
|
|
1129
|
+
break;
|
|
1130
|
+
}
|
|
1131
|
+
chunkCount += 1;
|
|
1132
|
+
if (chunkCount > MAX_PROVIDER_CHUNKS) {
|
|
1133
|
+
throw new StreamOutcome("blocked", "source_invalid", bytes, digest.copy().digest("hex"));
|
|
1134
|
+
}
|
|
1135
|
+
const chunk = next.value;
|
|
1136
|
+
if (isSignalAborted(options.signal)) {
|
|
1137
|
+
throw new StreamOutcome("blocked", "source_aborted", bytes, digest.copy().digest("hex"));
|
|
1138
|
+
}
|
|
1139
|
+
if (!isByteArrayView(chunk)) {
|
|
1140
|
+
throw new StreamOutcome("blocked", "source_invalid", bytes, digest.copy().digest("hex"));
|
|
1141
|
+
}
|
|
1142
|
+
// Reject an oversized provider allocation before copying it. The
|
|
1143
|
+
// artifact-wide limit is intentionally much larger, but allowing one
|
|
1144
|
+
// untrusted chunk to approach that limit defeats streaming's memory
|
|
1145
|
+
// bound and can trigger a second full-sized copy.
|
|
1146
|
+
if (chunk.byteLength > MAX_PROVIDER_CHUNK_BYTES) {
|
|
1147
|
+
throw new StreamOutcome("blocked", "source_invalid", bytes, digest.copy().digest("hex"));
|
|
1148
|
+
}
|
|
1149
|
+
if (chunk.byteLength > maxBytes - bytes) {
|
|
1150
|
+
throw new StreamOutcome("blocked", "byte_limit_exceeded", bytes, digest.copy().digest("hex"));
|
|
1151
|
+
}
|
|
1152
|
+
// Copy synchronously before the first hook/write await. A transfer
|
|
1153
|
+
// source has already crossed this ownership boundary in safeSource;
|
|
1154
|
+
// use its private copy directly to avoid a redundant full-chunk copy.
|
|
1155
|
+
// Direct callers still receive the defensive Buffer copy here.
|
|
1156
|
+
const buffer = isOwnedProviderChunk(chunk)
|
|
1157
|
+
? Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)
|
|
1158
|
+
: Buffer.from(chunk);
|
|
1159
|
+
try {
|
|
1160
|
+
await inject(options.hooks, "before_write", runtime);
|
|
1161
|
+
}
|
|
1162
|
+
catch (error) {
|
|
1163
|
+
rethrowRuntimeFailure(error);
|
|
1164
|
+
throw new StreamOutcome("blocked", "write_failed", bytes, digest.copy().digest("hex"));
|
|
1165
|
+
}
|
|
1166
|
+
let written = 0;
|
|
1167
|
+
while (written < buffer.byteLength) {
|
|
1168
|
+
let count;
|
|
1169
|
+
try {
|
|
1170
|
+
count = (await localEffect(runtime, () => handle.write(buffer, written, buffer.byteLength - written, bytes + written))).bytesWritten;
|
|
1171
|
+
}
|
|
1172
|
+
catch (error) {
|
|
1173
|
+
rethrowRuntimeFailure(error);
|
|
1174
|
+
throw new StreamOutcome("blocked", "write_failed", bytes, digest.copy().digest("hex"));
|
|
1175
|
+
}
|
|
1176
|
+
if (!Number.isSafeInteger(count) || count <= 0) {
|
|
1177
|
+
throw new StreamOutcome("blocked", "write_failed", bytes, digest.copy().digest("hex"));
|
|
1178
|
+
}
|
|
1179
|
+
written += count;
|
|
1180
|
+
}
|
|
1181
|
+
digest.update(buffer);
|
|
1182
|
+
bytes += buffer.byteLength;
|
|
1183
|
+
try {
|
|
1184
|
+
await inject(options.hooks, "after_write", runtime);
|
|
1185
|
+
}
|
|
1186
|
+
catch (error) {
|
|
1187
|
+
rethrowRuntimeFailure(error);
|
|
1188
|
+
throw new StreamOutcome("blocked", "write_failed", bytes, digest.copy().digest("hex"));
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
try {
|
|
1192
|
+
await inject(options.hooks, "before_file_sync", runtime);
|
|
1193
|
+
}
|
|
1194
|
+
catch (error) {
|
|
1195
|
+
rethrowRuntimeFailure(error);
|
|
1196
|
+
throw new StreamOutcome("blocked", "file_sync_failed", bytes, digest.copy().digest("hex"), true);
|
|
1197
|
+
}
|
|
1198
|
+
try {
|
|
1199
|
+
await localEffect(runtime, () => handle.sync());
|
|
1200
|
+
}
|
|
1201
|
+
catch (error) {
|
|
1202
|
+
rethrowRuntimeFailure(error);
|
|
1203
|
+
throw new StreamOutcome("blocked", "file_sync_failed", bytes, digest.copy().digest("hex"), true);
|
|
1204
|
+
}
|
|
1205
|
+
try {
|
|
1206
|
+
await inject(options.hooks, "after_file_sync", runtime);
|
|
1207
|
+
}
|
|
1208
|
+
catch (error) {
|
|
1209
|
+
rethrowRuntimeFailure(error);
|
|
1210
|
+
throw new StreamOutcome("blocked", "file_sync_failed", bytes, digest.copy().digest("hex"), true);
|
|
1211
|
+
}
|
|
1212
|
+
return { bytes, sha256: digest.digest("hex") };
|
|
1213
|
+
}
|
|
1214
|
+
catch (error) {
|
|
1215
|
+
if (isInstallOutcome(error) || isArtifactOutputError(error))
|
|
1216
|
+
throw error;
|
|
1217
|
+
throw new StreamOutcome("blocked", "source_read_failed", bytes, digest.copy().digest("hex"));
|
|
1218
|
+
}
|
|
1219
|
+
finally {
|
|
1220
|
+
if (iterator !== undefined && !iteratorDone)
|
|
1221
|
+
await closeAsyncIterator(iterator, runtime);
|
|
1222
|
+
// The descriptor is deliberately retained for installOrReconcile(). The
|
|
1223
|
+
// outer commit finally block closes it after cleanup/reconciliation.
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
async function installOrReconcile(directory, finalPath, temp, stream, hooks, runtime) {
|
|
1227
|
+
await assertDirectoryStable(directory, runtime);
|
|
1228
|
+
if (!(await retainedTempIsStable(temp, stream.bytes, runtime))) {
|
|
1229
|
+
throw new InstallOutcome("blocked", "commit_indeterminate");
|
|
1230
|
+
}
|
|
1231
|
+
try {
|
|
1232
|
+
await inject(hooks, "before_final_link", runtime);
|
|
1233
|
+
await assertDirectoryStable(directory, runtime);
|
|
1234
|
+
// Re-validate the retained descriptor after the hook. A replacement of
|
|
1235
|
+
// temp.path cannot affect the descriptor, while a source mutation is
|
|
1236
|
+
// detected before any final destination is opened.
|
|
1237
|
+
if (!(await retainedTempIsStable(temp, stream.bytes, runtime))) {
|
|
1238
|
+
throw new InstallOutcome("blocked", "commit_indeterminate");
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
catch {
|
|
1242
|
+
throw new InstallOutcome("blocked", "commit_indeterminate");
|
|
1243
|
+
}
|
|
1244
|
+
let destination;
|
|
1245
|
+
try {
|
|
1246
|
+
// The destination is created exclusively and never opened through a
|
|
1247
|
+
// pre-existing pathname. Copying from the retained source descriptor is
|
|
1248
|
+
// O(1) memory and cannot be redirected by replacing temp.path after the
|
|
1249
|
+
// source validation above.
|
|
1250
|
+
destination = await localEffect(runtime, () => open(finalPath, fsConstants.O_RDWR | fsConstants.O_CREAT | fsConstants.O_EXCL | (fsConstants.O_NOFOLLOW ?? 0), POSIX_FILE_MODE));
|
|
1251
|
+
// Opening through the canonical pathname is followed by another identity
|
|
1252
|
+
// check before any bytes are copied. If the parent directory changed in
|
|
1253
|
+
// the open race, fail closed and leave the unclaimed empty destination as
|
|
1254
|
+
// crash evidence rather than writing into an unverified directory.
|
|
1255
|
+
await assertDirectoryStable(directory, runtime);
|
|
1256
|
+
if (!(await destinationPathMatches(finalPath, destination, undefined, runtime))) {
|
|
1257
|
+
throw new InstallOutcome("blocked", "commit_indeterminate");
|
|
1258
|
+
}
|
|
1259
|
+
await copyRetainedTemp(temp, destination, stream, runtime);
|
|
1260
|
+
await inject(hooks, "after_final_link", runtime);
|
|
1261
|
+
await assertDirectoryStable(directory, runtime);
|
|
1262
|
+
// The hook is an adversarial boundary: a same-user replacement can occur
|
|
1263
|
+
// after the copy/fsync. Re-read the retained destination and prove that
|
|
1264
|
+
// finalPath still names that exact descriptor before reporting success.
|
|
1265
|
+
// Pure Node cannot atomically bind a pathname to this descriptor; a
|
|
1266
|
+
// separate same-UID process can still replace the path after this final
|
|
1267
|
+
// check, which is the documented residual platform boundary.
|
|
1268
|
+
await verifyDestination(destination, finalPath, stream, runtime);
|
|
1269
|
+
return true;
|
|
1270
|
+
}
|
|
1271
|
+
catch (error) {
|
|
1272
|
+
await destination?.close().catch(() => undefined);
|
|
1273
|
+
if (!isErrno(error, "EEXIST")) {
|
|
1274
|
+
throw new InstallOutcome("blocked", "commit_indeterminate");
|
|
1275
|
+
}
|
|
1276
|
+
const existing = await inspectExistingFinal(finalPath, stream, runtime);
|
|
1277
|
+
if (existing === "not_regular") {
|
|
1278
|
+
throw new InstallOutcome("blocked", "existing_target_not_regular");
|
|
1279
|
+
}
|
|
1280
|
+
if (existing === "same") {
|
|
1281
|
+
throw new InstallOutcome("reconciled", "already_present");
|
|
1282
|
+
}
|
|
1283
|
+
if (existing === "different") {
|
|
1284
|
+
throw new InstallOutcome("collision", "existing_mismatch");
|
|
1285
|
+
}
|
|
1286
|
+
throw new InstallOutcome("blocked", "commit_indeterminate");
|
|
1287
|
+
}
|
|
1288
|
+
finally {
|
|
1289
|
+
await destination?.close().catch(() => undefined);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
async function retainedTempIsStable(temp, expectedBytes, runtime) {
|
|
1293
|
+
try {
|
|
1294
|
+
checkRuntime(runtime);
|
|
1295
|
+
const metadata = await temp.handle.stat({ bigint: true });
|
|
1296
|
+
return metadata.isFile()
|
|
1297
|
+
&& !metadata.isSymbolicLink()
|
|
1298
|
+
&& metadata.dev === temp.device
|
|
1299
|
+
&& metadata.ino === temp.inode
|
|
1300
|
+
&& metadata.size === BigInt(expectedBytes);
|
|
1301
|
+
}
|
|
1302
|
+
catch (error) {
|
|
1303
|
+
rethrowRuntimeFailure(error);
|
|
1304
|
+
return false;
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
function sameFileIdentity(left, right) {
|
|
1308
|
+
return left.isFile()
|
|
1309
|
+
&& right.isFile()
|
|
1310
|
+
&& !left.isSymbolicLink()
|
|
1311
|
+
&& !right.isSymbolicLink()
|
|
1312
|
+
&& left.dev === right.dev
|
|
1313
|
+
&& left.ino === right.ino;
|
|
1314
|
+
}
|
|
1315
|
+
async function retainedTempMatches(temp, expected, runtime) {
|
|
1316
|
+
try {
|
|
1317
|
+
checkRuntime(runtime);
|
|
1318
|
+
const before = await temp.handle.stat({ bigint: true });
|
|
1319
|
+
if (!before.isFile()
|
|
1320
|
+
|| before.isSymbolicLink()
|
|
1321
|
+
|| before.dev !== temp.device
|
|
1322
|
+
|| before.ino !== temp.inode
|
|
1323
|
+
|| before.size !== BigInt(expected.bytes))
|
|
1324
|
+
return false;
|
|
1325
|
+
const digest = createHash("sha256");
|
|
1326
|
+
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, Math.max(1, expected.bytes)));
|
|
1327
|
+
let position = 0;
|
|
1328
|
+
while (position < expected.bytes) {
|
|
1329
|
+
const length = Math.min(buffer.byteLength, expected.bytes - position);
|
|
1330
|
+
const read = await temp.handle.read(buffer, 0, length, position);
|
|
1331
|
+
if (!Number.isSafeInteger(read.bytesRead) || read.bytesRead <= 0 || read.bytesRead > length)
|
|
1332
|
+
return false;
|
|
1333
|
+
digest.update(buffer.subarray(0, read.bytesRead));
|
|
1334
|
+
position += read.bytesRead;
|
|
1335
|
+
}
|
|
1336
|
+
const after = await temp.handle.stat({ bigint: true });
|
|
1337
|
+
checkRuntime(runtime);
|
|
1338
|
+
return sameFileIdentity(before, after)
|
|
1339
|
+
&& before.size === after.size
|
|
1340
|
+
&& before.mtimeNs === after.mtimeNs
|
|
1341
|
+
&& before.ctimeNs === after.ctimeNs
|
|
1342
|
+
&& position === expected.bytes
|
|
1343
|
+
&& digest.digest("hex") === expected.sha256;
|
|
1344
|
+
}
|
|
1345
|
+
catch (error) {
|
|
1346
|
+
rethrowRuntimeFailure(error);
|
|
1347
|
+
return false;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
async function closeTempHandle(temp) {
|
|
1351
|
+
await temp.handle.close().catch(() => undefined);
|
|
1352
|
+
}
|
|
1353
|
+
async function copyRetainedTemp(temp, destination, expected, runtime) {
|
|
1354
|
+
const source = temp.handle;
|
|
1355
|
+
const digest = createHash("sha256");
|
|
1356
|
+
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, Math.max(1, expected.bytes)));
|
|
1357
|
+
let position = 0;
|
|
1358
|
+
try {
|
|
1359
|
+
checkRuntime(runtime);
|
|
1360
|
+
while (position < expected.bytes) {
|
|
1361
|
+
const length = Math.min(buffer.byteLength, expected.bytes - position);
|
|
1362
|
+
const read = await localEffect(runtime, () => source.read(buffer, 0, length, position));
|
|
1363
|
+
if (!Number.isSafeInteger(read.bytesRead) || read.bytesRead <= 0 || read.bytesRead > length) {
|
|
1364
|
+
throw new ArtifactOutputError("copy_failed", "Operation-owned source could not be copied safely.");
|
|
1365
|
+
}
|
|
1366
|
+
let written = 0;
|
|
1367
|
+
while (written < read.bytesRead) {
|
|
1368
|
+
const result = await localEffect(runtime, () => destination.write(buffer, written, read.bytesRead - written, position + written));
|
|
1369
|
+
if (!Number.isSafeInteger(result.bytesWritten) || result.bytesWritten <= 0) {
|
|
1370
|
+
throw new ArtifactOutputError("copy_failed", "Final artifact copy did not complete.");
|
|
1371
|
+
}
|
|
1372
|
+
written += result.bytesWritten;
|
|
1373
|
+
}
|
|
1374
|
+
digest.update(buffer.subarray(0, read.bytesRead));
|
|
1375
|
+
position += read.bytesRead;
|
|
1376
|
+
}
|
|
1377
|
+
if (position !== expected.bytes || digest.digest("hex") !== expected.sha256) {
|
|
1378
|
+
throw new ArtifactOutputError("copy_mismatch", "Final artifact copy did not match the verified source.");
|
|
1379
|
+
}
|
|
1380
|
+
await localEffect(runtime, () => destination.sync());
|
|
1381
|
+
if (!(await retainedTempIsStable(temp, expected.bytes, runtime))) {
|
|
1382
|
+
throw new ArtifactOutputError("copy_source_changed", "Verified artifact source changed during final installation.");
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
catch (error) {
|
|
1386
|
+
rethrowRuntimeFailure(error);
|
|
1387
|
+
throw error;
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
async function inspectExistingFinalKind(finalPath, runtime) {
|
|
1391
|
+
try {
|
|
1392
|
+
checkRuntime(runtime);
|
|
1393
|
+
const metadata = await lstat(finalPath, { bigint: true });
|
|
1394
|
+
if (metadata.isSymbolicLink() || !metadata.isFile())
|
|
1395
|
+
return "not_regular";
|
|
1396
|
+
return "regular";
|
|
1397
|
+
}
|
|
1398
|
+
catch (error) {
|
|
1399
|
+
rethrowRuntimeFailure(error);
|
|
1400
|
+
return "unavailable";
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
async function destinationPathMatches(finalPath, destination, expectedBytes, runtime) {
|
|
1404
|
+
try {
|
|
1405
|
+
checkRuntime(runtime);
|
|
1406
|
+
const pathMetadata = await lstat(finalPath, { bigint: true });
|
|
1407
|
+
const descriptorMetadata = await destination.stat({ bigint: true });
|
|
1408
|
+
return sameFileIdentity(pathMetadata, descriptorMetadata)
|
|
1409
|
+
&& (expectedBytes === undefined || descriptorMetadata.size === BigInt(expectedBytes));
|
|
1410
|
+
}
|
|
1411
|
+
catch (error) {
|
|
1412
|
+
rethrowRuntimeFailure(error);
|
|
1413
|
+
return false;
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
async function verifyDestination(destination, finalPath, expected, runtime) {
|
|
1417
|
+
if (!(await destinationPathMatches(finalPath, destination, expected.bytes, runtime))) {
|
|
1418
|
+
throw new InstallOutcome("blocked", "commit_indeterminate");
|
|
1419
|
+
}
|
|
1420
|
+
const before = await destination.stat({ bigint: true });
|
|
1421
|
+
const digest = createHash("sha256");
|
|
1422
|
+
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, Math.max(1, expected.bytes)));
|
|
1423
|
+
let position = 0;
|
|
1424
|
+
while (position < expected.bytes) {
|
|
1425
|
+
const length = Math.min(buffer.byteLength, expected.bytes - position);
|
|
1426
|
+
const read = await destination.read(buffer, 0, length, position);
|
|
1427
|
+
if (!Number.isSafeInteger(read.bytesRead) || read.bytesRead <= 0 || read.bytesRead > length) {
|
|
1428
|
+
throw new InstallOutcome("blocked", "commit_indeterminate");
|
|
1429
|
+
}
|
|
1430
|
+
digest.update(buffer.subarray(0, read.bytesRead));
|
|
1431
|
+
position += read.bytesRead;
|
|
1432
|
+
}
|
|
1433
|
+
const after = await destination.stat({ bigint: true });
|
|
1434
|
+
const digestHex = digest.digest("hex");
|
|
1435
|
+
const pathMatches = await destinationPathMatches(finalPath, destination, expected.bytes, runtime);
|
|
1436
|
+
if (!sameFileIdentity(before, after)
|
|
1437
|
+
|| before.size !== after.size
|
|
1438
|
+
|| before.mtimeNs !== after.mtimeNs
|
|
1439
|
+
|| before.ctimeNs !== after.ctimeNs
|
|
1440
|
+
|| position !== expected.bytes
|
|
1441
|
+
|| digestHex !== expected.sha256
|
|
1442
|
+
|| !pathMatches) {
|
|
1443
|
+
throw new InstallOutcome("blocked", "commit_indeterminate");
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
async function inspectExistingFinal(finalPath, expected, runtime) {
|
|
1447
|
+
let metadata;
|
|
1448
|
+
try {
|
|
1449
|
+
checkRuntime(runtime);
|
|
1450
|
+
metadata = await lstat(finalPath, { bigint: true });
|
|
1451
|
+
}
|
|
1452
|
+
catch (error) {
|
|
1453
|
+
rethrowRuntimeFailure(error);
|
|
1454
|
+
return isErrno(error, "ENOENT") ? "unavailable" : "unavailable";
|
|
1455
|
+
}
|
|
1456
|
+
if (metadata.isSymbolicLink() || !metadata.isFile())
|
|
1457
|
+
return "not_regular";
|
|
1458
|
+
if (metadata.size < 0n || metadata.size > BigInt(Number.MAX_SAFE_INTEGER) || Number(metadata.size) !== expected.bytes)
|
|
1459
|
+
return "different";
|
|
1460
|
+
let handle;
|
|
1461
|
+
try {
|
|
1462
|
+
handle = await open(finalPath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
|
|
1463
|
+
}
|
|
1464
|
+
catch {
|
|
1465
|
+
return "unavailable";
|
|
1466
|
+
}
|
|
1467
|
+
try {
|
|
1468
|
+
const before = await handle.stat({ bigint: true });
|
|
1469
|
+
if (!before.isFile() || before.dev !== metadata.dev || before.ino !== metadata.ino)
|
|
1470
|
+
return "unavailable";
|
|
1471
|
+
const digest = createHash("sha256");
|
|
1472
|
+
let bytes = 0;
|
|
1473
|
+
const stream = handle.createReadStream({ autoClose: false, highWaterMark: 64 * 1024 });
|
|
1474
|
+
try {
|
|
1475
|
+
for await (const chunk of stream) {
|
|
1476
|
+
checkRuntime(runtime);
|
|
1477
|
+
const buffer = chunk;
|
|
1478
|
+
digest.update(buffer);
|
|
1479
|
+
bytes += buffer.byteLength;
|
|
1480
|
+
if (bytes > expected.bytes)
|
|
1481
|
+
return "different";
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
catch (error) {
|
|
1485
|
+
rethrowRuntimeFailure(error);
|
|
1486
|
+
return "unavailable";
|
|
1487
|
+
}
|
|
1488
|
+
const after = await handle.stat({ bigint: true });
|
|
1489
|
+
checkRuntime(runtime);
|
|
1490
|
+
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs) {
|
|
1491
|
+
return "unavailable";
|
|
1492
|
+
}
|
|
1493
|
+
return bytes === expected.bytes && digest.digest("hex") === expected.sha256 ? "same" : "different";
|
|
1494
|
+
}
|
|
1495
|
+
finally {
|
|
1496
|
+
await handle.close().catch(() => undefined);
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
async function cleanupOwnedTemp(directory, temp, hooks, runtime, preserveForRecovery = false) {
|
|
1500
|
+
if (runtime.localEffectInFlight || runtime.localEffectUncertain)
|
|
1501
|
+
return "pending";
|
|
1502
|
+
// Release the retained source descriptor before pathname cleanup. This is
|
|
1503
|
+
// required on Windows, where an open handle can prevent unlink; the handle
|
|
1504
|
+
// is no longer needed once installation/error classification has finished.
|
|
1505
|
+
await closeTempHandle(temp);
|
|
1506
|
+
try {
|
|
1507
|
+
await inject(hooks, "before_temp_cleanup", runtime);
|
|
1508
|
+
await assertDirectoryStable(directory, runtime);
|
|
1509
|
+
}
|
|
1510
|
+
catch {
|
|
1511
|
+
return "pending";
|
|
1512
|
+
}
|
|
1513
|
+
let metadata;
|
|
1514
|
+
try {
|
|
1515
|
+
metadata = await lstat(temp.path, { bigint: true });
|
|
1516
|
+
}
|
|
1517
|
+
catch (error) {
|
|
1518
|
+
if (isErrno(error, "ENOENT"))
|
|
1519
|
+
return "clean";
|
|
1520
|
+
return "pending";
|
|
1521
|
+
}
|
|
1522
|
+
if (metadata.isSymbolicLink() || !metadata.isFile() || metadata.dev !== temp.device || metadata.ino !== temp.inode) {
|
|
1523
|
+
return "ambiguous";
|
|
1524
|
+
}
|
|
1525
|
+
if (preserveForRecovery) {
|
|
1526
|
+
// The source bytes/hash were complete at the failed boundary. Keep this
|
|
1527
|
+
// verified temp for a later receipt-bound recovery instead of forcing an
|
|
1528
|
+
// unrepeatable provider transfer to run again.
|
|
1529
|
+
return "pending";
|
|
1530
|
+
}
|
|
1531
|
+
// Node does not expose unlinkat(2) through fs/promises. Use one synchronous
|
|
1532
|
+
// identity-check/unlink critical section so no same-process callback can
|
|
1533
|
+
// replace the pathname between the lstat and unlink. A separate same-UID
|
|
1534
|
+
// process can still race this pathname operation; that residual platform
|
|
1535
|
+
// boundary is intentionally not described as absolute TOCTOU protection.
|
|
1536
|
+
try {
|
|
1537
|
+
checkRuntime(runtime);
|
|
1538
|
+
unlinkSync(temp.path);
|
|
1539
|
+
await inject(hooks, "after_temp_cleanup", runtime);
|
|
1540
|
+
return "clean";
|
|
1541
|
+
}
|
|
1542
|
+
catch (error) {
|
|
1543
|
+
if (isErrno(error, "ENOENT"))
|
|
1544
|
+
return "clean";
|
|
1545
|
+
return "pending";
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
async function syncDirectory(directory, hooks, runtime) {
|
|
1549
|
+
await inject(hooks, "before_directory_sync", runtime);
|
|
1550
|
+
await assertDirectoryStable(directory, runtime);
|
|
1551
|
+
let handle;
|
|
1552
|
+
try {
|
|
1553
|
+
handle = await open(directory.canonical, fsConstants.O_RDONLY | (fsConstants.O_DIRECTORY ?? 0));
|
|
1554
|
+
}
|
|
1555
|
+
catch (error) {
|
|
1556
|
+
if (isUnsupportedDirectorySync(error))
|
|
1557
|
+
return;
|
|
1558
|
+
throw error;
|
|
1559
|
+
}
|
|
1560
|
+
try {
|
|
1561
|
+
try {
|
|
1562
|
+
await localEffect(runtime, () => handle.sync());
|
|
1563
|
+
}
|
|
1564
|
+
catch (error) {
|
|
1565
|
+
if (!isUnsupportedDirectorySync(error))
|
|
1566
|
+
throw error;
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
finally {
|
|
1570
|
+
// Do not release the directory handle until the fsync effect has settled.
|
|
1571
|
+
await handle.close().catch(() => undefined);
|
|
1572
|
+
}
|
|
1573
|
+
await inject(hooks, "after_directory_sync", runtime);
|
|
1574
|
+
await assertDirectoryStable(directory, runtime);
|
|
1575
|
+
}
|
|
1576
|
+
async function entropyHex(entropy, runtime) {
|
|
1577
|
+
try {
|
|
1578
|
+
checkRuntime(runtime);
|
|
1579
|
+
const value = entropy === undefined
|
|
1580
|
+
? randomBytes(TEMP_TOKEN_BYTES)
|
|
1581
|
+
: await bounded(runtime, () => entropy(TEMP_TOKEN_BYTES));
|
|
1582
|
+
if (!isByteArrayView(value) || value.byteLength < TEMP_TOKEN_BYTES) {
|
|
1583
|
+
throw new Error("invalid entropy");
|
|
1584
|
+
}
|
|
1585
|
+
return Buffer.from(value.subarray(0, TEMP_TOKEN_BYTES)).toString("hex");
|
|
1586
|
+
}
|
|
1587
|
+
catch (error) {
|
|
1588
|
+
rethrowRuntimeFailure(error);
|
|
1589
|
+
throw new ArtifactOutputError("entropy_failed", "Temporary output entropy could not be obtained.");
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
async function inject(hooks, point, runtime) {
|
|
1593
|
+
checkRuntime(runtime);
|
|
1594
|
+
const callback = hooks?.faultInjector;
|
|
1595
|
+
if (callback !== undefined)
|
|
1596
|
+
await bounded(runtime, () => callback(point));
|
|
1597
|
+
checkRuntime(runtime);
|
|
1598
|
+
}
|
|
1599
|
+
function outputIdentityDigest(input, extension) {
|
|
1600
|
+
const hash = createHash("sha256");
|
|
1601
|
+
hash.update("codex-chatgpt-control/artifact-output/v1\0", "utf8");
|
|
1602
|
+
updateLengthPrefixed(hash, input.operationId);
|
|
1603
|
+
updateLengthPrefixed(hash, input.artifactIdentity);
|
|
1604
|
+
updateLengthPrefixed(hash, extension);
|
|
1605
|
+
updateLengthPrefixed(hash, normalizeMime(input.mimeTypeHint) ?? "");
|
|
1606
|
+
return hash.digest("hex");
|
|
1607
|
+
}
|
|
1608
|
+
function updateLengthPrefixed(hash, value) {
|
|
1609
|
+
const bytes = Buffer.from(value, "utf8");
|
|
1610
|
+
hash.update(String(bytes.byteLength), "utf8");
|
|
1611
|
+
hash.update(":", "utf8");
|
|
1612
|
+
hash.update(bytes);
|
|
1613
|
+
hash.update("\0", "utf8");
|
|
1614
|
+
}
|
|
1615
|
+
function validateOpaqueIdentity(value, name) {
|
|
1616
|
+
if (typeof value !== "string" || value.length === 0 || byteLength(value) > MAX_IDENTITY_BYTES || /[\u0000-\u001f\u007f]/.test(value)) {
|
|
1617
|
+
throw new ArtifactOutputError("invalid_opaque_identity", `${name} must be a non-empty opaque identity.`);
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
function normalizeExtension(extensionHint, mimeTypeHint) {
|
|
1621
|
+
const normalizedMime = normalizeMime(mimeTypeHint);
|
|
1622
|
+
if (extensionHint !== undefined) {
|
|
1623
|
+
if (typeof extensionHint !== "string")
|
|
1624
|
+
throw new ArtifactOutputError("invalid_extension_hint", "Extension hint must be a string.");
|
|
1625
|
+
let extension = extensionHint.trim().toLowerCase();
|
|
1626
|
+
if (extension.startsWith("."))
|
|
1627
|
+
extension = extension.slice(1);
|
|
1628
|
+
if (extension.length === 0 || byteLength(extension) > MAX_HINT_LENGTH || !/^[a-z0-9](?:[a-z0-9-]{0,31})$/.test(extension)) {
|
|
1629
|
+
throw new ArtifactOutputError("invalid_extension_hint", "Extension hint is not a vetted public extension.");
|
|
1630
|
+
}
|
|
1631
|
+
return extension;
|
|
1632
|
+
}
|
|
1633
|
+
if (normalizedMime !== undefined)
|
|
1634
|
+
return MIME_EXTENSIONS[normalizedMime] ?? "";
|
|
1635
|
+
return "";
|
|
1636
|
+
}
|
|
1637
|
+
function normalizeMime(mimeTypeHint) {
|
|
1638
|
+
if (mimeTypeHint === undefined)
|
|
1639
|
+
return undefined;
|
|
1640
|
+
if (typeof mimeTypeHint !== "string")
|
|
1641
|
+
throw new ArtifactOutputError("invalid_mime_type_hint", "MIME type hint must be a string.");
|
|
1642
|
+
const mime = mimeTypeHint.trim().toLowerCase();
|
|
1643
|
+
if (mime.length === 0 || byteLength(mime) > 127 || !/^[a-z0-9][a-z0-9!#$&^_.+*-]{0,126}\/[a-z0-9][a-z0-9!#$&^_.+*-]{0,126}$/.test(mime)) {
|
|
1644
|
+
throw new ArtifactOutputError("invalid_mime_type_hint", "MIME type hint is not valid.");
|
|
1645
|
+
}
|
|
1646
|
+
return mime;
|
|
1647
|
+
}
|
|
1648
|
+
const MIME_EXTENSIONS = {
|
|
1649
|
+
"application/gzip": "gz",
|
|
1650
|
+
"application/json": "json",
|
|
1651
|
+
"application/pdf": "pdf",
|
|
1652
|
+
"application/zip": "zip",
|
|
1653
|
+
"audio/mpeg": "mp3",
|
|
1654
|
+
"image/gif": "gif",
|
|
1655
|
+
"image/jpeg": "jpg",
|
|
1656
|
+
"image/png": "png",
|
|
1657
|
+
"image/webp": "webp",
|
|
1658
|
+
"text/csv": "csv",
|
|
1659
|
+
"text/html": "html",
|
|
1660
|
+
"text/plain": "txt",
|
|
1661
|
+
"text/markdown": "md",
|
|
1662
|
+
"video/mp4": "mp4"
|
|
1663
|
+
};
|
|
1664
|
+
function classifyFailure(error) {
|
|
1665
|
+
if (isArtifactOutputError(error)) {
|
|
1666
|
+
switch (error.code) {
|
|
1667
|
+
case "operation_timeout": return "operation_timeout";
|
|
1668
|
+
case "clock_invalid": return "clock_invalid";
|
|
1669
|
+
case "source_aborted": return "source_aborted";
|
|
1670
|
+
case "destination_invalid": return "destination_invalid";
|
|
1671
|
+
case "invalid_artifact_source": return "source_invalid";
|
|
1672
|
+
case "file_sync_failed": return "file_sync_failed";
|
|
1673
|
+
case "write_failed":
|
|
1674
|
+
case "temp_open_failed": return "write_failed";
|
|
1675
|
+
case "entropy_failed": return "entropy_failed";
|
|
1676
|
+
default: return "source_read_failed";
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
if (isInstallOutcome(error))
|
|
1680
|
+
return error.reason;
|
|
1681
|
+
return "source_read_failed";
|
|
1682
|
+
}
|
|
1683
|
+
function runtimeReason(error, fallback = "destination_invalid") {
|
|
1684
|
+
if (isArtifactOutputError(error)) {
|
|
1685
|
+
if (error.code === "operation_timeout")
|
|
1686
|
+
return "operation_timeout";
|
|
1687
|
+
if (error.code === "clock_invalid")
|
|
1688
|
+
return "clock_invalid";
|
|
1689
|
+
if (error.code === "source_aborted")
|
|
1690
|
+
return "source_aborted";
|
|
1691
|
+
}
|
|
1692
|
+
return fallback;
|
|
1693
|
+
}
|
|
1694
|
+
function rethrowRuntimeFailure(error) {
|
|
1695
|
+
if (isRuntimeArtifactError(error) || (isArtifactOutputError(error) && error.code === "destination_invalid")) {
|
|
1696
|
+
throw error;
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
function isArtifactOutputError(error) {
|
|
1700
|
+
try {
|
|
1701
|
+
return error instanceof ArtifactOutputError;
|
|
1702
|
+
}
|
|
1703
|
+
catch {
|
|
1704
|
+
return false;
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
function isInstallOutcome(error) {
|
|
1708
|
+
try {
|
|
1709
|
+
return error instanceof InstallOutcome;
|
|
1710
|
+
}
|
|
1711
|
+
catch {
|
|
1712
|
+
return false;
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
function isStreamOutcome(error) {
|
|
1716
|
+
try {
|
|
1717
|
+
return error instanceof StreamOutcome;
|
|
1718
|
+
}
|
|
1719
|
+
catch {
|
|
1720
|
+
return false;
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
function isRuntimeArtifactError(error) {
|
|
1724
|
+
if (!isArtifactOutputError(error))
|
|
1725
|
+
return false;
|
|
1726
|
+
try {
|
|
1727
|
+
return error.code === "operation_timeout" || error.code === "clock_invalid" || error.code === "source_aborted";
|
|
1728
|
+
}
|
|
1729
|
+
catch {
|
|
1730
|
+
return false;
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
function isErrno(error, code) {
|
|
1734
|
+
if (typeof error !== "object" || error === null)
|
|
1735
|
+
return false;
|
|
1736
|
+
try {
|
|
1737
|
+
const descriptor = Object.getOwnPropertyDescriptor(error, "code");
|
|
1738
|
+
return descriptor !== undefined && "value" in descriptor && descriptor.value === code;
|
|
1739
|
+
}
|
|
1740
|
+
catch {
|
|
1741
|
+
return false;
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
function isUnsupportedDirectorySync(error) {
|
|
1745
|
+
if (isErrno(error, "EPERM"))
|
|
1746
|
+
return process.platform === "win32";
|
|
1747
|
+
return ["EINVAL", "ENOTSUP", "EISDIR", "ENOSYS", "EBADF"].some(code => isErrno(error, code));
|
|
1748
|
+
}
|