@botiverse/k-carrier 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +24 -0
  3. package/README.md +62 -0
  4. package/core/src/artifact/README.md +1 -0
  5. package/core/src/artifact/collectStream.ts +49 -0
  6. package/core/src/artifact/download.ts +300 -0
  7. package/core/src/artifact/errors.ts +24 -0
  8. package/core/src/artifact/partialPath.ts +18 -0
  9. package/core/src/artifact/source.ts +59 -0
  10. package/core/src/artifact/staticManifestSource.ts +185 -0
  11. package/core/src/artifact/swap.ts +21 -0
  12. package/core/src/clock.ts +22 -0
  13. package/core/src/converge/lifecycle.ts +114 -0
  14. package/core/src/converge/predicates.ts +57 -0
  15. package/core/src/converge/report.ts +38 -0
  16. package/core/src/createUpgrader.ts +300 -0
  17. package/core/src/drive/README.md +1 -0
  18. package/core/src/index.ts +17 -0
  19. package/core/src/invariants.ts +243 -0
  20. package/core/src/lifecycle/hostAdapter.ts +77 -0
  21. package/core/src/liveness.ts +80 -0
  22. package/core/src/platform/README.md +1 -0
  23. package/core/src/platform/index.ts +11 -0
  24. package/core/src/platform/ops.ts +53 -0
  25. package/core/src/platform/posix.ts +99 -0
  26. package/core/src/platform/windows.ts +73 -0
  27. package/core/src/policy/README.md +1 -0
  28. package/core/src/progress.ts +54 -0
  29. package/core/src/provenance/journal.ts +216 -0
  30. package/core/src/status/report.ts +76 -0
  31. package/core/src/status/reportStore.ts +67 -0
  32. package/core/src/txn/effects.ts +40 -0
  33. package/core/src/txn/engine.ts +275 -0
  34. package/core/src/txn/fileEffects.ts +129 -0
  35. package/core/src/txn/hostCallBudget.ts +28 -0
  36. package/core/src/txn/lock.ts +124 -0
  37. package/core/src/txn/state.ts +77 -0
  38. package/core/src/txn/transitions.ts +49 -0
  39. package/core/src/upgrade/outcome.ts +53 -0
  40. package/core/src/upgrade/retire.ts +44 -0
  41. package/core/src/upgrader.ts +124 -0
  42. package/docs/design-v1.md +246 -0
  43. package/docs/harness-design.md +170 -0
  44. package/docs/integration.md +326 -0
  45. package/docs/prior-art.md +150 -0
  46. package/docs/test-plan.md +100 -0
  47. package/harness/README.md +20 -0
  48. package/harness/src/adapter/probeChecks.ts +93 -0
  49. package/harness/src/adapter/releaseKnob.ts +65 -0
  50. package/harness/src/adapter/serviceChecks.ts +260 -0
  51. package/harness/src/artifact/checks.ts +232 -0
  52. package/harness/src/artifact/downloadHoles.ts +300 -0
  53. package/harness/src/artifact/downloadHolesMutations.ts +170 -0
  54. package/harness/src/artifact/m1.ts +204 -0
  55. package/harness/src/artifact/m1Resume.ts +141 -0
  56. package/harness/src/artifact/m3.ts +296 -0
  57. package/harness/src/artifact/m3Hosts.ts +241 -0
  58. package/harness/src/artifact/m4.ts +201 -0
  59. package/harness/src/artifact/m5.ts +239 -0
  60. package/harness/src/artifact/m6.ts +274 -0
  61. package/harness/src/artifact/m6Drive.ts +296 -0
  62. package/harness/src/artifact/m6DriveMutations.ts +70 -0
  63. package/harness/src/artifact/m6Mutations.ts +136 -0
  64. package/harness/src/artifact/m6Status.ts +224 -0
  65. package/harness/src/artifact-factory/checks.ts +68 -0
  66. package/harness/src/artifact-factory/demo.ts +70 -0
  67. package/harness/src/artifact-factory/factory.ts +134 -0
  68. package/harness/src/artifact-factory/run.ts +107 -0
  69. package/harness/src/blackbox.ts +284 -0
  70. package/harness/src/cli.ts +281 -0
  71. package/harness/src/crash/enumerate.ts +66 -0
  72. package/harness/src/crash/runner.ts +162 -0
  73. package/harness/src/examples/checks.ts +286 -0
  74. package/harness/src/fake-host/checks.ts +188 -0
  75. package/harness/src/fake-host/daemon-entry.ts +39 -0
  76. package/harness/src/fake-host/daemon.ts +159 -0
  77. package/harness/src/fake-host/inproc.ts +256 -0
  78. package/harness/src/fake-server/manifest.ts +68 -0
  79. package/harness/src/fake-server/server.ts +205 -0
  80. package/harness/src/fake-server/store.ts +208 -0
  81. package/harness/src/fixtures/minimal-adapter.ts +108 -0
  82. package/harness/src/fixtures/service-adapter.ts +187 -0
  83. package/harness/src/index.ts +11 -0
  84. package/harness/src/receipt.ts +83 -0
  85. package/harness/src/runner.ts +268 -0
  86. package/harness/src/scenario/processScan.ts +55 -0
  87. package/harness/src/scenario/sandbox.ts +259 -0
  88. package/harness/src/scenario/virtualClock.ts +56 -0
  89. package/harness/src/selfverify/selfVerify.ts +136 -0
  90. package/harness/src/sim/checks.ts +46 -0
  91. package/harness/src/sim/corpus.ts +36 -0
  92. package/harness/src/sim/effectRuntime.ts +113 -0
  93. package/harness/src/sim/error.ts +14 -0
  94. package/harness/src/sim/prng.ts +29 -0
  95. package/harness/src/sim/record.ts +76 -0
  96. package/harness/src/sim/run.ts +148 -0
  97. package/harness/src/sim/scheduler.ts +72 -0
  98. package/harness/src/sim/world.ts +284 -0
  99. package/harness/src/target.ts +86 -0
  100. package/harness/src/targetCheck.ts +47 -0
  101. package/harness/src/teeth/artifact.ts +215 -0
  102. package/harness/src/teeth/artifactDownload.ts +136 -0
  103. package/harness/src/teeth/artifactFactory.ts +41 -0
  104. package/harness/src/teeth/artifactM5.ts +68 -0
  105. package/harness/src/teeth/artifactM6.ts +264 -0
  106. package/harness/src/teeth/blackbox.ts +22 -0
  107. package/harness/src/teeth/checks.ts +278 -0
  108. package/harness/src/teeth/examples.ts +57 -0
  109. package/harness/src/teeth/fakeHost.ts +120 -0
  110. package/harness/src/teeth/index.ts +16 -0
  111. package/harness/src/teeth/m0.ts +135 -0
  112. package/harness/src/teeth/registry.ts +267 -0
  113. package/harness/src/teeth/selfCheck.ts +45 -0
  114. package/harness/src/teeth/sim.ts +61 -0
  115. package/package.json +43 -0
package/LICENSE ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
package/NOTICE ADDED
@@ -0,0 +1,24 @@
1
+ k-carrier
2
+ Copyright 2026 Botiverse
3
+
4
+ This product is licensed under the Apache License, Version 2.0 (see LICENSE).
5
+
6
+ Design influences — concepts studied, no code copied
7
+ -----------------------------------------------------
8
+ k-carrier's design was informed by studying two mature, unrelated updaters.
9
+ Only architectural concepts were borrowed; no source code was copied, and the
10
+ implementation here is original.
11
+
12
+ - Two-slot upgrade transaction (stable/experiment slots with promote/rollback)
13
+ and the "the installer is itself a managed package" fleet model are inspired
14
+ by Datadog's fleet installer (DataDog/datadog-agent, pkg/fleet). Concept only.
15
+
16
+ - Static-file signed distribution (offline root keys + rotating signing keys)
17
+ was studied from Tailscale's distsign (tailscale/tailscale, clientupdate/
18
+ distsign). Concept only. NOTE: k-carrier does NOT currently implement
19
+ signature verification — it verifies artifact integrity via sha256 + size
20
+ only. No distsign / signing code is present in this repository.
21
+
22
+ See docs/prior-art.md for the full analysis, including what each project does
23
+ and where k-carrier deliberately differs (fail-closed restart, post-upgrade
24
+ convergence read-back, and rollback).
package/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # K (k-carrier)
2
+
3
+ **Self-upgrade framework for programs that must prove they came back up.**
4
+
5
+ CLI self-update libraries stop at replacing bytes; fleet updaters assume a machine someone else administers. K covers what neither does: **an upgrade that is a transaction and can prove it happened** — two slots with rollback, crash-safe at every step, handoff of a live process with its workloads intact, and convergence proven from the live process and named OS surfaces (a version string is never accepted as proof). Consent and notification are built in, because on a machine someone owns personally, changing behaviour silently is not acceptable — but nothing here is limited to personal machines.
6
+
7
+ **Two process models, defined by how many live incarnations K manages** — `swap` (**0**: K replaces bytes and touches no process; a one-shot CLI and an hours-long agent session are the same case) and `service` (**1**: K stops the old, starts the new, and proves it). OS lifecycle convergence and fleet drive are capabilities you opt into on top, not a third model. Proof is executable: a runnable example per case, and a claim without a green example does not exist.
8
+
9
+ ## Delivery and guarantee are different axes
10
+
11
+ Most updaters are compared on one vague axis called "complexity". There are
12
+ two, and they are independent:
13
+
14
+ | | delivery — how hard is it to put the bytes in place | guarantee — what is promised afterwards |
15
+ |-----------------------|---------------------------------------|-----------------------------|
16
+ | `rustup self update` | low: replace one file, exit | low: none |
17
+ | `electron-updater` | **high** | low: none |
18
+ | **K** | low: one binary | **high**: transaction, readback, rollback |
19
+
20
+ Measured, not asserted: of electron-updater 6.8.9's ~4,200 lines, ~1,170 are
21
+ per-platform installation (Squirrel.Mac, NSIS, deb/rpm/pacman, AppImage),
22
+ ~1,200 orchestration and policy, ~960 feed providers, and ~860 differential
23
+ download. Downloading is not the hard part — **installing is, because
24
+ installing is not yours to do**: you hand off to a system component with its
25
+ own rules (Squirrel.Mac accepts only a URL, so the updater serves the file it
26
+ already downloaded back to itself over a local HTTP server; NSIS may need
27
+ elevation; dpkg needs root). Grep that codebase for rollback and you find none,
28
+ and nothing checks health after the install.
29
+
30
+ K deliberately does not compete on the delivery axis — platform packaging
31
+ belongs to platform tools. It exists on the other one, and specifically for the
32
+ consequence those tools all share and none of them handle: **because something
33
+ else installs your bytes, something else replaces and restarts your process.**
34
+ The process driving the upgrade dies on the *success* path, so the successor
35
+ must be able to tell "the handover worked" from "we crashed" — by evidence,
36
+ never by a flag saying the restart was planned.
37
+
38
+ ## Start here
39
+
40
+ - **[`docs/integration.md`](docs/integration.md)** — from-zero guide: the problem in plain words, concept primer, tiered adoption with code.
41
+ - [`docs/design-v1.md`](docs/design-v1.md) — full design: six layers, architecture, decision record.
42
+ - [`docs/harness-design.md`](docs/harness-design.md) — the test framework, designed first: harness as executable spec (teeth registry, real-process crash injection, adversarial self-verification).
43
+ - [`docs/test-plan.md`](docs/test-plan.md) — executable test plan (M0–M6, must-red per cell).
44
+ - [`docs/prior-art.md`](docs/prior-art.md) — the source-level survey this design stands on (Tailscale / Datadog), and the license-defense record behind `NOTICE`.
45
+
46
+ ## Repo layout
47
+
48
+ ```
49
+ core/ the framework — zero host-specific concepts (shells live in their
50
+ product's repo and consume core as a dependency)
51
+ harness/ generic acceptance bed: fake-host daemon + profile-tiered teeth
52
+ examples/ one runnable demo per profile (swap-tool / service-daemon / hosted-service)
53
+ docs/ guides + design + test plan + prior art
54
+ ```
55
+
56
+ **Platform support today:** Linux and macOS are implemented and gate CI.
57
+ Windows platform operations (replacing a *running* .exe, process liveness) are
58
+ deliberately unimplemented — they throw a typed `PLATFORM_UNSUPPORTED` rather
59
+ than approximating POSIX behaviour and corrupting an install. The Windows CI
60
+ job runs and reports, but does not gate, until those land.
61
+
62
+ Status: incubating. TypeScript first. License: **Apache-2.0**.
@@ -0,0 +1 @@
1
+ # artifact — see docs/design-v1.md for this layer's spec. Interfaces land here next.
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Read a response body into memory while keeping chunk boundaries visible.
3
+ *
4
+ * Split out of download.ts for the line budget. The boundaries are the point:
5
+ * `res.arrayBuffer()` is one opaque await, so neither a stall timer nor a
6
+ * progress sink can observe anything through it.
7
+ */
8
+ import { ArtifactError } from "./errors.ts";
9
+
10
+ export async function collectStream(
11
+ url: string,
12
+ body: ReadableStream<Uint8Array> | null,
13
+ total: number | undefined,
14
+ onProgress: ((downloaded: number, total: number) => void) | undefined,
15
+ onChunk: () => void,
16
+ ): Promise<Uint8Array> {
17
+ // A response with no readable body is a failure to READ, not an empty
18
+ // download. Returning zero bytes here would hand the caller a valid-looking
19
+ // empty artifact whose only symptom is a sha256 mismatch against the digest
20
+ // of the empty string -- an error that describes the consequence and hides
21
+ // the cause. Same rule as everywhere else: the failure path must not produce
22
+ // the empty success value.
23
+ if (!body) {
24
+ throw new ArtifactError("DOWNLOAD_FAILED", `response has no readable body: ${url}`);
25
+ }
26
+ const parts: Uint8Array[] = [];
27
+ let got = 0;
28
+ onProgress?.(0, total ?? 0);
29
+ const reader = body.getReader();
30
+ {
31
+ for (;;) {
32
+ const { done, value } = await reader.read();
33
+ if (done) break;
34
+ if (value === undefined) continue;
35
+ parts.push(value);
36
+ got += value.byteLength;
37
+ onChunk();
38
+ onProgress?.(got, total ?? 0);
39
+ }
40
+ }
41
+ const all = new Uint8Array(got);
42
+ let at = 0;
43
+ for (const part of parts) {
44
+ all.set(part, at);
45
+ at += part.byteLength;
46
+ }
47
+ return all;
48
+ }
49
+
@@ -0,0 +1,300 @@
1
+ /**
2
+ * L0 download + INTEGRITY only, with RESUME
3
+ * (断点续传 — computer's 150MB SEA case: a process that dies mid-download
4
+ * must not restart from zero).
5
+ *
6
+ * Streams the body to a partial file in `resumeDir` (keyed by the release
7
+ * URL); a later attempt resumes from it via `Range`. The FULL assembled bytes
8
+ * are verified against sha256 + size before being returned, so a corrupted
9
+ * partial is refused rather than trusted for having been "already downloaded".
10
+ * Interruptions leave the partial in place — that is the point; only a
11
+ * completed-but-invalid assembly deletes it. Time goes through the injected
12
+ * Clock, and the deadline is RACED rather than merely signalled (an injected
13
+ * fetch that ignores AbortSignal must not be able to outlive it).
14
+ *
15
+ * ⚠️ K verifies INTEGRITY, never AUTHENTICITY. sha256 + size prove the bytes
16
+ * are the ones the manifest described; they cannot prove WHO produced them,
17
+ * because the digest travels with the artifact from the same place. Signing
18
+ * (a trust root of our own) was implemented and then REMOVED on 2026-08-06 —
19
+ * see docs/design-v1.md §L0.5 for the decision and for why OS code signing
20
+ * (Authenticode / codesign) is a different guarantee, not a substitute.
21
+ *
22
+ * Consequence worth stating where it is used: if the release bucket itself
23
+ * serves wrong bytes — leaked CI credentials, a misconfigured bucket, a
24
+ * poisoned publish pipeline — this check passes and every client installs
25
+ * them.
26
+ */
27
+ import { promises as fs } from "node:fs";
28
+ import * as path from "node:path";
29
+ import { createHash } from "node:crypto";
30
+ import { type Clock, systemClock } from "../clock.ts";
31
+ import { ArtifactError } from "./errors.ts";
32
+ import type { Release } from "./source.ts";
33
+ import { collectStream } from "./collectStream.ts";
34
+ import { partialPathFor } from "./partialPath.ts";
35
+ export { partialPathFor } from "./partialPath.ts";
36
+
37
+ export interface DownloadOptions {
38
+ /**
39
+ * Byte progress. `downloaded` counts bytes ON DISK including any resumed
40
+ * prefix, so a resumed download never appears to restart at zero.
41
+ */
42
+ onProgress?: (downloaded: number, total: number) => void;
43
+ clock?: Clock;
44
+ /** Abort the download after this many ms (0 = no timeout). Default 10000. */
45
+ timeoutMs?: number;
46
+ /**
47
+ * Abort after this many ms with NO bytes arriving (0 = off). Default 0.
48
+ *
49
+ * Distinct from `timeoutMs`, and for large artifacts the more useful of the
50
+ * two: a total budget must either be big enough for the slowest acceptable
51
+ * download of a 150MB binary -- in which case a wedged connection holds for
52
+ * just as long -- or small enough to kill a slow one that was making steady
53
+ * progress. Bounding SILENCE instead follows liveness, so the limit does not
54
+ * have to encode a guess about size or bandwidth.
55
+ */
56
+ stallTimeoutMs?: number;
57
+ /**
58
+ * HTTP client for the artifact bytes. `staticManifestSource` already takes
59
+ * one; without the same seam here an adopter can point K at their own server
60
+ * for the MANIFEST but not for the BYTES, which is half a seam and surprising
61
+ * in exactly the place it matters (proxies, custom agents, and an adopter's
62
+ * own integration tests all need both).
63
+ */
64
+ fetchImpl?: typeof fetch;
65
+ /**
66
+ * Directory for partial-download state. When set, an interrupted download
67
+ * leaves its prefix here and the next attempt resumes via Range.
68
+ */
69
+ resumeDir?: string;
70
+ }
71
+
72
+ /** Fetch and verify one release's bytes, resuming from a partial if present. */
73
+ export async function downloadVerified(
74
+ release: Release,
75
+ opts: DownloadOptions = {},
76
+ ): Promise<Uint8Array> {
77
+ const url = release.url;
78
+ const clock = opts.clock ?? systemClock;
79
+ const timeoutMs = opts.timeoutMs ?? 10000;
80
+
81
+ const partialPath = opts.resumeDir ? partialPathFor(opts.resumeDir, url) : null;
82
+
83
+ let partialSize = 0;
84
+ if (partialPath) {
85
+ try {
86
+ partialSize = (await fs.stat(partialPath)).size;
87
+ } catch {
88
+ // no partial yet
89
+ }
90
+ if (partialSize >= release.size) partialSize = 0; // complete/over-long is not a resume point
91
+ }
92
+
93
+ const bytes = await fetchAndAppend(
94
+ url, partialPath, partialSize, clock, timeoutMs, opts.onProgress, release.size,
95
+ opts.stallTimeoutMs ?? 0, opts.fetchImpl ?? fetch,
96
+ );
97
+
98
+ const sha = sha256Hex(bytes);
99
+ if (sha !== release.sha256) {
100
+ // A completed-but-invalid assembly is useless and must not be resumed:
101
+ // delete the partial so the next attempt starts clean.
102
+ if (partialPath) await fs.rm(partialPath, { force: true }).catch(() => {});
103
+ throw new ArtifactError(
104
+ "SHA256_MISMATCH",
105
+ `sha256 of ${url} does not match the release (got ${sha.slice(0, 12)}…, expected ${release.sha256.slice(0, 12)}…)`,
106
+ );
107
+ }
108
+ if (bytes.length !== release.size) {
109
+ if (partialPath) await fs.rm(partialPath, { force: true }).catch(() => {});
110
+ throw new ArtifactError(
111
+ "SIZE_MISMATCH",
112
+ `size of ${url} (${bytes.length}) does not match manifest (${release.size})`,
113
+ );
114
+ }
115
+ return bytes;
116
+ }
117
+
118
+ /**
119
+ * Fetch and stream into the partial file (appending when resuming). On
120
+ * interruption the partial is left in place; the file handle is always
121
+ * closed. 206 = the server honored the Range; 200 = it ignored it (a fresh
122
+ * download, so the partial is discarded).
123
+ */
124
+ async function fetchAndAppend(
125
+ url: string,
126
+ partialPath: string | null,
127
+ partialSize: number,
128
+ clock: Clock,
129
+ timeoutMs: number,
130
+ onProgress?: (downloaded: number, total: number) => void,
131
+ total?: number,
132
+ stallTimeoutMs = 0,
133
+ doFetch: typeof fetch = fetch,
134
+ ): Promise<Uint8Array> {
135
+ const controller = new AbortController();
136
+ // Rearmed on every chunk; fires only if the gap between chunks exceeds the
137
+ // budget. `stalled` records WHY we aborted, because the abort itself cannot
138
+ // say -- a stall and a total-timeout abort look identical at the signal.
139
+ let stalled = false;
140
+ let stallTimer: (() => void) | undefined;
141
+ // Invoked when a deadline fires so the pending fetch cannot outlive it.
142
+ let aborted: (() => void) | undefined;
143
+ // Which phase we were in when the deadline fired. Reporting "awaiting
144
+ // response" for a stall that happened mid-body sends the reader looking at
145
+ // the wrong end of the transfer.
146
+ let responded = false;
147
+ const abortReason = (u: string): string =>
148
+ stalled
149
+ ? `download stalled: nothing received for ${stallTimeoutMs}ms ` +
150
+ `(${responded ? "mid-body" : "awaiting response"}): ${u}`
151
+ : `download timed out after ${timeoutMs}ms: ${u}`;
152
+ // Declared AFTER `aborted`: an immediate/virtual clock fires this callback
153
+ // synchronously inside `clock.after`, so a timer created earlier would reach
154
+ // `aborted` in its temporal dead zone. Real clocks hide that ordering; the
155
+ // test clock does not.
156
+ const cancel =
157
+ timeoutMs > 0
158
+ ? clock.after(timeoutMs, () => {
159
+ controller.abort();
160
+ aborted?.();
161
+ })
162
+ : undefined;
163
+ const armStall = (): void => {
164
+ if (stallTimeoutMs <= 0) return;
165
+ stallTimer?.();
166
+ stallTimer = clock.after(stallTimeoutMs, () => {
167
+ stalled = true;
168
+ controller.abort();
169
+ aborted?.();
170
+ });
171
+ };
172
+ try {
173
+ const headers: Record<string, string> = {};
174
+ if (partialSize > 0) headers["Range"] = `bytes=${partialSize}-`;
175
+ let res: Response;
176
+ armStall(); // the response headers themselves must not hang forever
177
+ try {
178
+ // Race the abort, do not merely signal it. `AbortSignal` only works if the
179
+ // fetch implementation honours it, and `fetchImpl` is an adopter-supplied
180
+ // seam -- a custom client that ignores the signal would leave every
181
+ // timeout here silently inert, with no way to tell that from a fetch that
182
+ // is simply still working. The deadline has to be enforced by the side
183
+ // that promises it.
184
+ res = await Promise.race([
185
+ doFetch(url, { signal: controller.signal, headers }),
186
+ new Promise<never>((_, reject) => {
187
+ aborted = () => {
188
+ reject(new ArtifactError("DOWNLOAD_FAILED", abortReason(url)));
189
+ };
190
+ }),
191
+ ]);
192
+ } catch (err) {
193
+ const timedOut = controller.signal.aborted;
194
+ throw new ArtifactError(
195
+ "DOWNLOAD_FAILED",
196
+ stalled
197
+ ? `download stalled: nothing received for ${stallTimeoutMs}ms (awaiting response): ${url}`
198
+ : timedOut
199
+ ? `download timed out after ${timeoutMs}ms: ${url}`
200
+ : `fetch failed: ${url}`,
201
+ { cause: err },
202
+ );
203
+ }
204
+ if (!res.ok) {
205
+ throw new ArtifactError("DOWNLOAD_FAILED", `GET ${url} -> HTTP ${res.status}`);
206
+ }
207
+ // Headers arriving IS activity: the silence budget covers the gap until the
208
+ // next thing happens, and the response is that thing. Without this rearm a
209
+ // server that answers slowly and then streams normally is killed by a
210
+ // deadline that started before the request was even answered -- and the
211
+ // failure reports "awaiting response" while bytes were on their way.
212
+ responded = true;
213
+ armStall();
214
+
215
+ if (partialPath === null) {
216
+ // Stream even with nowhere to resume to. `res.arrayBuffer()` is one
217
+ // opaque await: no chunk boundaries, so neither the stall timer nor the
218
+ // progress sink can observe anything -- a download without a resumeDir
219
+ // silently had no byte progress and no stall detection at all, while
220
+ // both looked configured.
221
+ try {
222
+ return await collectStream(url, res.body, total, onProgress, armStall);
223
+ } catch (err) {
224
+ // Same classification as the resume path. Without this the in-memory
225
+ // branch reported a bare stream error, so an abort we ourselves caused
226
+ // (stall or deadline) came back as an anonymous transport failure --
227
+ // the caller could not tell "we gave up on purpose" from "the network
228
+ // broke".
229
+ if (err instanceof ArtifactError) throw err;
230
+ throw new ArtifactError("DOWNLOAD_FAILED", abortReason(url), { cause: err });
231
+ }
232
+ }
233
+
234
+ // Resume semantics: only a 206 proves the server honored the Range.
235
+ if (partialSize > 0 && res.status !== 206) {
236
+ await fs.rm(partialPath, { force: true });
237
+ partialSize = 0;
238
+ }
239
+ await fs.mkdir(path.dirname(partialPath), { recursive: true });
240
+ const fh = await fs.open(partialPath, partialSize > 0 ? "a" : "w");
241
+ try {
242
+ // Same rule as the in-memory branch: no readable body is a failure to
243
+ // READ, not an empty download — never hide the cause behind a sha256 mismatch.
244
+ if (!res.body) {
245
+ throw new ArtifactError("DOWNLOAD_FAILED", `response has no readable body: ${url}`);
246
+ }
247
+ // Count from the resumed prefix, never from zero: a bar that restarts
248
+ // reads as "it lost my download" to the person watching it.
249
+ let onDisk = partialSize;
250
+ onProgress?.(onDisk, total ?? 0);
251
+ armStall();
252
+ await streamToFile(res.body, fh, (chunk) => {
253
+ onDisk += chunk;
254
+ armStall();
255
+ onProgress?.(onDisk, total ?? 0);
256
+ });
257
+ await fh.sync();
258
+ } catch (err) {
259
+ // A typed error we raised (no readable body) stays typed, like the
260
+ // in-memory branch; only genuine interruptions become "interrupted".
261
+ if (err instanceof ArtifactError) throw err;
262
+ const timedOut = controller.signal.aborted;
263
+ throw new ArtifactError(
264
+ "DOWNLOAD_FAILED",
265
+ stalled
266
+ ? `download stalled: nothing received for ${stallTimeoutMs}ms (mid-body): ${url}`
267
+ : timedOut
268
+ ? `download timed out after ${timeoutMs}ms: ${url}`
269
+ : `download interrupted: ${url}`,
270
+ { cause: err },
271
+ );
272
+ } finally {
273
+ await fh.close();
274
+ }
275
+ return new Uint8Array(await fs.readFile(partialPath));
276
+ } finally {
277
+ cancel?.();
278
+ stallTimer?.();
279
+ }
280
+ }
281
+
282
+ async function streamToFile(
283
+ body: ReadableStream<Uint8Array>,
284
+ fh: Awaited<ReturnType<typeof fs.open>>,
285
+ onBytes?: (chunk: number) => void,
286
+ ): Promise<void> {
287
+ const reader = body.getReader();
288
+ for (;;) {
289
+ const { done, value } = await reader.read();
290
+ if (done) break;
291
+ if (value !== undefined) {
292
+ await fh.write(value);
293
+ onBytes?.(value.byteLength);
294
+ }
295
+ }
296
+ }
297
+
298
+ function sha256Hex(data: Uint8Array): string {
299
+ return createHash("sha256").update(data).digest("hex");
300
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * L0 artifact layer errors — every failure is typed (fail-closed: an
3
+ * a refused release, a tampered artifact, a half-written swap all carry a
4
+ * machine-readable code, never a silent pass).
5
+ */
6
+ export type ArtifactErrorCode =
7
+ | "UNREGISTERED_SURFACE"
8
+ | "MANIFEST_INVALID"
9
+ | "PINNED_VERSION_MISMATCH"
10
+ | "UNSUPPORTED_PLATFORM"
11
+ | "DOWNLOAD_FAILED"
12
+ | "SHA256_MISMATCH"
13
+ | "SIZE_MISMATCH"
14
+ | "SWAP_FAILED";
15
+
16
+ export class ArtifactError extends Error {
17
+ readonly code: ArtifactErrorCode;
18
+
19
+ constructor(code: ArtifactErrorCode, message: string, options?: { cause?: unknown }) {
20
+ super(`[${code}] ${message}`, options);
21
+ this.name = "ArtifactError";
22
+ this.code = code;
23
+ }
24
+ }