@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.
- package/LICENSE +202 -0
- package/NOTICE +24 -0
- package/README.md +62 -0
- package/core/src/artifact/README.md +1 -0
- package/core/src/artifact/collectStream.ts +49 -0
- package/core/src/artifact/download.ts +300 -0
- package/core/src/artifact/errors.ts +24 -0
- package/core/src/artifact/partialPath.ts +18 -0
- package/core/src/artifact/source.ts +59 -0
- package/core/src/artifact/staticManifestSource.ts +185 -0
- package/core/src/artifact/swap.ts +21 -0
- package/core/src/clock.ts +22 -0
- package/core/src/converge/lifecycle.ts +114 -0
- package/core/src/converge/predicates.ts +57 -0
- package/core/src/converge/report.ts +38 -0
- package/core/src/createUpgrader.ts +300 -0
- package/core/src/drive/README.md +1 -0
- package/core/src/index.ts +17 -0
- package/core/src/invariants.ts +243 -0
- package/core/src/lifecycle/hostAdapter.ts +77 -0
- package/core/src/liveness.ts +80 -0
- package/core/src/platform/README.md +1 -0
- package/core/src/platform/index.ts +11 -0
- package/core/src/platform/ops.ts +53 -0
- package/core/src/platform/posix.ts +99 -0
- package/core/src/platform/windows.ts +73 -0
- package/core/src/policy/README.md +1 -0
- package/core/src/progress.ts +54 -0
- package/core/src/provenance/journal.ts +216 -0
- package/core/src/status/report.ts +76 -0
- package/core/src/status/reportStore.ts +67 -0
- package/core/src/txn/effects.ts +40 -0
- package/core/src/txn/engine.ts +275 -0
- package/core/src/txn/fileEffects.ts +129 -0
- package/core/src/txn/hostCallBudget.ts +28 -0
- package/core/src/txn/lock.ts +124 -0
- package/core/src/txn/state.ts +77 -0
- package/core/src/txn/transitions.ts +49 -0
- package/core/src/upgrade/outcome.ts +53 -0
- package/core/src/upgrade/retire.ts +44 -0
- package/core/src/upgrader.ts +124 -0
- package/docs/design-v1.md +246 -0
- package/docs/harness-design.md +170 -0
- package/docs/integration.md +326 -0
- package/docs/prior-art.md +150 -0
- package/docs/test-plan.md +100 -0
- package/harness/README.md +20 -0
- package/harness/src/adapter/probeChecks.ts +93 -0
- package/harness/src/adapter/releaseKnob.ts +65 -0
- package/harness/src/adapter/serviceChecks.ts +260 -0
- package/harness/src/artifact/checks.ts +232 -0
- package/harness/src/artifact/downloadHoles.ts +300 -0
- package/harness/src/artifact/downloadHolesMutations.ts +170 -0
- package/harness/src/artifact/m1.ts +204 -0
- package/harness/src/artifact/m1Resume.ts +141 -0
- package/harness/src/artifact/m3.ts +296 -0
- package/harness/src/artifact/m3Hosts.ts +241 -0
- package/harness/src/artifact/m4.ts +201 -0
- package/harness/src/artifact/m5.ts +239 -0
- package/harness/src/artifact/m6.ts +274 -0
- package/harness/src/artifact/m6Drive.ts +296 -0
- package/harness/src/artifact/m6DriveMutations.ts +70 -0
- package/harness/src/artifact/m6Mutations.ts +136 -0
- package/harness/src/artifact/m6Status.ts +224 -0
- package/harness/src/artifact-factory/checks.ts +68 -0
- package/harness/src/artifact-factory/demo.ts +70 -0
- package/harness/src/artifact-factory/factory.ts +134 -0
- package/harness/src/artifact-factory/run.ts +107 -0
- package/harness/src/blackbox.ts +284 -0
- package/harness/src/cli.ts +281 -0
- package/harness/src/crash/enumerate.ts +66 -0
- package/harness/src/crash/runner.ts +162 -0
- package/harness/src/examples/checks.ts +286 -0
- package/harness/src/fake-host/checks.ts +188 -0
- package/harness/src/fake-host/daemon-entry.ts +39 -0
- package/harness/src/fake-host/daemon.ts +159 -0
- package/harness/src/fake-host/inproc.ts +256 -0
- package/harness/src/fake-server/manifest.ts +68 -0
- package/harness/src/fake-server/server.ts +205 -0
- package/harness/src/fake-server/store.ts +208 -0
- package/harness/src/fixtures/minimal-adapter.ts +108 -0
- package/harness/src/fixtures/service-adapter.ts +187 -0
- package/harness/src/index.ts +11 -0
- package/harness/src/receipt.ts +83 -0
- package/harness/src/runner.ts +268 -0
- package/harness/src/scenario/processScan.ts +55 -0
- package/harness/src/scenario/sandbox.ts +259 -0
- package/harness/src/scenario/virtualClock.ts +56 -0
- package/harness/src/selfverify/selfVerify.ts +136 -0
- package/harness/src/sim/checks.ts +46 -0
- package/harness/src/sim/corpus.ts +36 -0
- package/harness/src/sim/effectRuntime.ts +113 -0
- package/harness/src/sim/error.ts +14 -0
- package/harness/src/sim/prng.ts +29 -0
- package/harness/src/sim/record.ts +76 -0
- package/harness/src/sim/run.ts +148 -0
- package/harness/src/sim/scheduler.ts +72 -0
- package/harness/src/sim/world.ts +284 -0
- package/harness/src/target.ts +86 -0
- package/harness/src/targetCheck.ts +47 -0
- package/harness/src/teeth/artifact.ts +215 -0
- package/harness/src/teeth/artifactDownload.ts +136 -0
- package/harness/src/teeth/artifactFactory.ts +41 -0
- package/harness/src/teeth/artifactM5.ts +68 -0
- package/harness/src/teeth/artifactM6.ts +264 -0
- package/harness/src/teeth/blackbox.ts +22 -0
- package/harness/src/teeth/checks.ts +278 -0
- package/harness/src/teeth/examples.ts +57 -0
- package/harness/src/teeth/fakeHost.ts +120 -0
- package/harness/src/teeth/index.ts +16 -0
- package/harness/src/teeth/m0.ts +135 -0
- package/harness/src/teeth/registry.ts +267 -0
- package/harness/src/teeth/selfCheck.ts +45 -0
- package/harness/src/teeth/sim.ts +61 -0
- 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
|
+
}
|