@huynle/hub 0.7.1 → 0.7.3
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 +87 -0
- package/Dockerfile +3 -0
- package/README.md +148 -11
- package/package.json +3 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/main.ts +4 -4
- package/packages/claude-code/src/wrapper.ts +3 -2
- package/packages/cli/dist/hub-cli.js +19334 -7849
- package/packages/cli/package.json +11 -0
- package/packages/cli/src/acting.ts +70 -0
- package/packages/cli/src/args.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
- package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
- package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
- package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
- package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
- package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
- package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
- package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
- package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
- package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
- package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
- package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
- package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
- package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
- package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
- package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
- package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
- package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
- package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
- package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
- package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
- package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
- package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
- package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
- package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
- package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
- package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
- package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
- package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
- package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
- package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
- package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
- package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
- package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
- package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
- package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
- package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
- package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
- package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
- package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
- package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
- package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
- package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
- package/packages/cli/src/bootstrap/preview/index.ts +5 -0
- package/packages/cli/src/bootstrap/preview/links.ts +31 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
- package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
- package/packages/cli/src/bootstrap/preview/render.ts +30 -0
- package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
- package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
- package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
- package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
- package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
- package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
- package/packages/cli/src/bootstrap/release/README.md +115 -0
- package/packages/cli/src/bootstrap/release/assets.ts +39 -0
- package/packages/cli/src/bootstrap/release/cli.ts +332 -0
- package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
- package/packages/cli/src/bootstrap/release/pins.ts +192 -0
- package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
- package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
- package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
- package/packages/cli/src/bootstrap/release/verify.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
- package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
- package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
- package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
- package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
- package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
- package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
- package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
- package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
- package/packages/cli/src/cli.ts +814 -0
- package/packages/cli/src/credentials.ts +75 -0
- package/packages/cli/src/decisions.ts +106 -0
- package/packages/cli/src/format.ts +185 -0
- package/packages/cli/src/grants.ts +105 -0
- package/packages/cli/src/http.ts +86 -0
- package/packages/cli/src/launches.ts +157 -0
- package/packages/cli/src/login.ts +160 -0
- package/packages/cli/src/main.ts +63 -0
- package/packages/cli/src/mcp.ts +879 -0
- package/packages/cli/src/messaging.ts +108 -0
- package/packages/cli/src/node.ts +279 -0
- package/packages/cli/src/openshell.ts +166 -0
- package/packages/cli/src/setup/commands.ts +809 -0
- package/packages/cli/src/setup/contributors.ts +171 -0
- package/packages/cli/src/setup/dispatch.ts +74 -0
- package/packages/cli/src/setup/launch-driver.ts +109 -0
- package/packages/cli/src/setup/runtime.ts +456 -0
- package/packages/cli/src/setup/spoke-probe.ts +120 -0
- package/packages/cli/src/setup/state.ts +244 -0
- package/packages/cli/src/setup/user-bin.ts +39 -0
- package/packages/cli/src/spoke-services.ts +109 -0
- package/packages/cli/src/watch.ts +28 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/main.ts +3 -2
- package/packages/codex/src/spoke.ts +4 -1
- package/packages/codex/src/wrapper.ts +7 -3
- package/packages/node/package.json +1 -1
- package/packages/node/src/harnesses.ts +39 -4
- package/packages/node/src/run.ts +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/harness-events.ts +2 -2
- package/packages/protocol/src/index.ts +70 -7
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +55 -4
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/sse.ts +4 -1
- package/packages/sdk/src/types.ts +59 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/enroll.ts +26 -4
- package/src/central/agent/app-tokens.ts +27 -17
- package/src/central/agent/archive-inactive.ts +66 -0
- package/src/central/agent/directory-routes.ts +155 -12
- package/src/central/agent/directory.ts +1 -1
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/event-cursor.ts +49 -0
- package/src/central/agent/event-writers.ts +181 -0
- package/src/central/agent/events-routes.ts +148 -0
- package/src/central/agent/events.ts +277 -0
- package/src/central/agent/names.ts +1 -1
- package/src/central/agent/read-routes.ts +32 -36
- package/src/central/agent/routes.ts +14 -11
- package/src/central/agent/watch.ts +65 -174
- package/src/central/agent/webhook-routes.ts +10 -2
- package/src/central/agent/webhooks.ts +49 -5
- package/src/central/auth.ts +127 -1
- package/src/central/backend/index.ts +3 -0
- package/src/central/backend/types.ts +2 -0
- package/src/central/http.ts +116 -32
- package/src/central/mcp-http.ts +116 -0
- package/src/central/push.ts +14 -1
- package/src/central/registry.ts +34 -5
- package/src/central/store.ts +60 -0
- package/src/config.ts +70 -7
- package/src/index.ts +90 -91
- package/src/spoke/launchers.ts +109 -3
- package/src/spoke/spoke.ts +8 -4
- package/src/spoke/timeline.ts +59 -3
- package/src/spoke/tracker.ts +59 -6
- package/src/status.ts +97 -6
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-Dm8OmkaO.css +1 -0
- package/web/dist/assets/index-FUJbj_Du.js +20 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +5 -5
- package/web/dist/index.html +10 -4
- package/web/dist/manifest.webmanifest +2 -2
- package/web/dist/sw.js +4 -1
- package/web/dist/assets/index-D9zMbxSg.css +0 -1
- package/web/dist/assets/index-db1z4duA.js +0 -20
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// File states as the journal records them: existence, digest and mode, never contents. A symlink's digest is the
|
|
2
|
+
// digest of its target text; a harness install root (a directory tree) can be digested as a whole, so recovery can
|
|
3
|
+
// tell "exactly as we left it" from "the user changed something inside". Files are digested in chunks (fileDigest):
|
|
4
|
+
// a harness binary is never read whole.
|
|
5
|
+
import { digestOf, sha256Hex, type FileState } from "../contracts/index.ts"
|
|
6
|
+
import type { TreeFile } from "./executor.ts"
|
|
7
|
+
import type { TxFs } from "./fs.ts"
|
|
8
|
+
import { fileDigest } from "./stream.ts"
|
|
9
|
+
|
|
10
|
+
export const sha256Of = (data: Uint8Array | string): string => `sha256:${sha256Hex(data)}`
|
|
11
|
+
|
|
12
|
+
export function fileState(fs: TxFs, path: string, options: { readonly tree?: boolean } = {}): FileState {
|
|
13
|
+
const info = fs.lstat(path)
|
|
14
|
+
if (!info) return { exists: false }
|
|
15
|
+
switch (info.kind) {
|
|
16
|
+
case "file":
|
|
17
|
+
return { exists: true, sha256: fileDigest(fs, path), mode: info.mode }
|
|
18
|
+
case "symlink":
|
|
19
|
+
return { exists: true, symlink: true, sha256: sha256Of(fs.readlink(path)) }
|
|
20
|
+
case "directory":
|
|
21
|
+
return options.tree ? { exists: true, sha256: treeDigest(fs, path), mode: info.mode } : { exists: true, mode: info.mode }
|
|
22
|
+
default:
|
|
23
|
+
return { exists: true }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Digest over every entry under `dir` (relative path, kind, mode, content or link digest), in sorted order. */
|
|
28
|
+
export function treeDigest(fs: TxFs, dir: string): string {
|
|
29
|
+
const entries: unknown[] = []
|
|
30
|
+
const walk = (abs: string, rel: string) => {
|
|
31
|
+
for (const name of fs.readdir(abs).sort()) {
|
|
32
|
+
const childAbs = `${abs}/${name}`
|
|
33
|
+
const childRel = rel ? `${rel}/${name}` : name
|
|
34
|
+
const info = fs.lstat(childAbs)
|
|
35
|
+
if (!info) continue
|
|
36
|
+
if (info.kind === "directory") {
|
|
37
|
+
entries.push({ rel: childRel, kind: "directory", mode: info.mode })
|
|
38
|
+
walk(childAbs, childRel)
|
|
39
|
+
} else if (info.kind === "file") entries.push({ rel: childRel, kind: "file", mode: info.mode, sha256: fileDigest(fs, childAbs) })
|
|
40
|
+
else if (info.kind === "symlink") entries.push({ rel: childRel, kind: "symlink", sha256: sha256Of(fs.readlink(childAbs)) })
|
|
41
|
+
else entries.push({ rel: childRel, kind: "other" })
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
walk(dir, "")
|
|
45
|
+
return digestOf({ kind: "hub.tree", entries })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The digest treeDigest would compute for a directory holding exactly `files` (directories implied by their paths,
|
|
50
|
+
* all with `dirMode`), so the journal can record a tree's state before it is staged.
|
|
51
|
+
*/
|
|
52
|
+
export function treeDigestOfFiles(files: readonly TreeFile[], dirMode: number): string {
|
|
53
|
+
type Dir = Map<string, Dir | { readonly sha256: string; readonly mode: number }>
|
|
54
|
+
const root: Dir = new Map()
|
|
55
|
+
for (const f of files) {
|
|
56
|
+
const parts = f.rel.split("/")
|
|
57
|
+
let dir = root
|
|
58
|
+
for (const part of parts.slice(0, -1)) {
|
|
59
|
+
let next = dir.get(part)
|
|
60
|
+
if (!(next instanceof Map)) {
|
|
61
|
+
next = new Map()
|
|
62
|
+
dir.set(part, next)
|
|
63
|
+
}
|
|
64
|
+
dir = next
|
|
65
|
+
}
|
|
66
|
+
dir.set(parts[parts.length - 1]!, { sha256: "bytes" in f ? sha256Of(f.bytes) : f.staged.sha256, mode: f.mode })
|
|
67
|
+
}
|
|
68
|
+
const entries: unknown[] = []
|
|
69
|
+
const walk = (dir: Dir, rel: string) => {
|
|
70
|
+
for (const name of [...dir.keys()].sort()) {
|
|
71
|
+
const child = dir.get(name)!
|
|
72
|
+
const childRel = rel ? `${rel}/${name}` : name
|
|
73
|
+
if (child instanceof Map) {
|
|
74
|
+
entries.push({ rel: childRel, kind: "directory", mode: dirMode })
|
|
75
|
+
walk(child, childRel)
|
|
76
|
+
} else entries.push({ rel: childRel, kind: "file", mode: child.mode, sha256: child.sha256 })
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
walk(root, "")
|
|
80
|
+
return digestOf({ kind: "hub.tree", entries })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The journal's notion of "the same file" (recoveryDecision uses the same rule). */
|
|
84
|
+
export const sameState = (a: FileState, b: FileState): boolean =>
|
|
85
|
+
a.exists === b.exists &&
|
|
86
|
+
(a.sha256 ?? null) === (b.sha256 ?? null) &&
|
|
87
|
+
(a.mode === undefined || b.mode === undefined || a.mode === b.mode) &&
|
|
88
|
+
Boolean(a.symlink) === Boolean(b.symlink)
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Where the applier keeps its own state, under the state root next to the lock (contract LOCK_PATH):
|
|
2
|
+
//
|
|
3
|
+
// state:hub/bootstrap/apply.lock the apply lock
|
|
4
|
+
// state:hub/bootstrap/journal/<txID>.json the transaction journal (digests and modes only)
|
|
5
|
+
// state:hub/bootstrap/journal/<txID>.plan.json the plan it applies (secret-free by contract), for resume/rollback
|
|
6
|
+
// state:hub/bootstrap/backups/<txID>/<bk_id> owner-only copies of files before they changed (never credentials)
|
|
7
|
+
// state:hub/bootstrap/staging/<txID>/ a step's scratch space for large downloads and what they unpack to
|
|
8
|
+
// (stream.ts); removed after each step, swept under the lock
|
|
9
|
+
//
|
|
10
|
+
// Every document is validated before it is written and after it is read, written atomically (temporary file,
|
|
11
|
+
// fsync, rename) with owner-only permissions, and reached only through symlink-checked paths.
|
|
12
|
+
import {
|
|
13
|
+
canonicalJSON,
|
|
14
|
+
LOCK_PATH,
|
|
15
|
+
planDigest,
|
|
16
|
+
validateJournal,
|
|
17
|
+
validateSetupPlan,
|
|
18
|
+
type ExecutionContext,
|
|
19
|
+
type RootMapping,
|
|
20
|
+
type SetupPlan,
|
|
21
|
+
type TransactionJournal,
|
|
22
|
+
} from "../contracts/index.ts"
|
|
23
|
+
import type { TxFs } from "./fs.ts"
|
|
24
|
+
import { resolveTarget } from "./resolve.ts"
|
|
25
|
+
import { errnoOf, TxError } from "./result.ts"
|
|
26
|
+
import { copyFile, fileDigest } from "./stream.ts"
|
|
27
|
+
|
|
28
|
+
const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes)
|
|
29
|
+
const STALE_TEMP = /\.tmp$|\.broken-/
|
|
30
|
+
|
|
31
|
+
export class TxStore {
|
|
32
|
+
readonly base: string
|
|
33
|
+
readonly lockPath: string
|
|
34
|
+
private readonly journalDir: string
|
|
35
|
+
private readonly backupDir: string
|
|
36
|
+
private readonly stagingRoot: string
|
|
37
|
+
|
|
38
|
+
constructor(
|
|
39
|
+
private readonly fs: TxFs,
|
|
40
|
+
roots: readonly RootMapping[],
|
|
41
|
+
context: ExecutionContext = "host",
|
|
42
|
+
) {
|
|
43
|
+
const lock = resolveTarget(LOCK_PATH, roots, fs, { context })
|
|
44
|
+
if (!lock.ok) throw new TxError(lock.code === "conflict" ? "conflict" : "invalid", `the bootstrap state directory is unusable: ${lock.message}`)
|
|
45
|
+
this.lockPath = lock.value
|
|
46
|
+
this.base = lock.value.slice(0, lock.value.lastIndexOf("/"))
|
|
47
|
+
this.journalDir = `${this.base}/journal`
|
|
48
|
+
this.backupDir = `${this.base}/backups`
|
|
49
|
+
this.stagingRoot = `${this.base}/staging`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The scratch directory of one transaction (created on first use by fsStaging). */
|
|
53
|
+
stagingDir(txID: string): string {
|
|
54
|
+
return `${this.stagingRoot}/${txID}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Creates the state directories (owner-only) and refuses if any of them is not a plain directory. */
|
|
58
|
+
init(): void {
|
|
59
|
+
for (const dir of [this.base, this.journalDir, this.backupDir]) {
|
|
60
|
+
const info = this.fs.lstat(dir)
|
|
61
|
+
if (!info) this.fs.mkdirp(dir, 0o700)
|
|
62
|
+
else if (info.kind !== "directory") throw new TxError("conflict", `${dir} is not a directory`)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Removes temporary files an interrupted writer left behind, and every staging directory (only while holding the
|
|
68
|
+
* lock: no step is running, and nothing in staging is ever needed to recover or resume).
|
|
69
|
+
*/
|
|
70
|
+
sweep(): void {
|
|
71
|
+
for (const dir of [this.base, this.journalDir]) {
|
|
72
|
+
for (const name of this.list(dir)) if (STALE_TEMP.test(name)) this.removeQuietly(`${dir}/${name}`)
|
|
73
|
+
}
|
|
74
|
+
const info = this.fs.lstat(this.stagingRoot)
|
|
75
|
+
if (info?.kind === "directory") for (const name of this.list(this.stagingRoot)) this.removeTree(`${this.stagingRoot}/${name}`)
|
|
76
|
+
else if (info) this.removeQuietly(this.stagingRoot)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private removeTree(path: string): void {
|
|
80
|
+
const info = this.fs.lstat(path)
|
|
81
|
+
if (!info) return
|
|
82
|
+
if (info.kind !== "directory") return this.removeQuietly(path)
|
|
83
|
+
for (const name of this.list(path)) this.removeTree(`${path}/${name}`)
|
|
84
|
+
this.fs.rmdir(path)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
journals(): TransactionJournal[] {
|
|
88
|
+
const out: TransactionJournal[] = []
|
|
89
|
+
for (const name of this.list(this.journalDir).sort()) {
|
|
90
|
+
if (!name.endsWith(".json") || name.endsWith(".plan.json")) continue
|
|
91
|
+
const path = `${this.journalDir}/${name}`
|
|
92
|
+
let parsed: unknown
|
|
93
|
+
try {
|
|
94
|
+
parsed = JSON.parse(decode(this.fs.readFile(path)))
|
|
95
|
+
} catch {
|
|
96
|
+
throw new TxError("recovery_required", `the journal ${path} cannot be read; inspect it before applying again`)
|
|
97
|
+
}
|
|
98
|
+
const checked = validateJournal(parsed)
|
|
99
|
+
if (!checked.ok) throw new TxError("recovery_required", `the journal ${path} is not valid (${checked.issues[0]!.message}); inspect it before applying again`)
|
|
100
|
+
out.push(checked.value)
|
|
101
|
+
}
|
|
102
|
+
return out
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
writeJournal(journal: TransactionJournal): void {
|
|
106
|
+
const checked = validateJournal(journal)
|
|
107
|
+
if (!checked.ok) throw new TxError("internal", `refusing to write an invalid journal: ${checked.issues[0]!.path} ${checked.issues[0]!.message}`)
|
|
108
|
+
this.writeAtomic(`${this.journalDir}/${journal.txID}.json`, `${canonicalJSON(journal)}\n`)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
writePlan(txID: string, plan: SetupPlan): void {
|
|
112
|
+
const checked = validateSetupPlan(plan)
|
|
113
|
+
if (!checked.ok) throw new TxError("invalid", `refusing to store an invalid plan: ${checked.issues[0]!.message}`)
|
|
114
|
+
this.writeAtomic(`${this.journalDir}/${txID}.plan.json`, `${canonicalJSON(plan)}\n`)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The stored plan of a transaction, if it is valid and is the one the journal pins. */
|
|
118
|
+
readPlan(journal: TransactionJournal): SetupPlan | undefined {
|
|
119
|
+
const path = `${this.journalDir}/${journal.txID}.plan.json`
|
|
120
|
+
if (!this.fs.lstat(path)) return undefined
|
|
121
|
+
try {
|
|
122
|
+
const checked = validateSetupPlan(JSON.parse(decode(this.fs.readFile(path))))
|
|
123
|
+
return checked.ok && planDigest(checked.value) === journal.planDigest ? checked.value : undefined
|
|
124
|
+
} catch {
|
|
125
|
+
return undefined
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
writeBackup(txID: string, id: string, bytes: Uint8Array | string): void {
|
|
130
|
+
const dir = `${this.backupDir}/${txID}`
|
|
131
|
+
if (!this.fs.lstat(dir)) this.fs.mkdir(dir, 0o700)
|
|
132
|
+
const path = `${dir}/${id}`
|
|
133
|
+
this.removeQuietly(path)
|
|
134
|
+
this.fs.writeExclusive(path, bytes, 0o600)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Backs up the regular file `from` by a streaming copy (a large file is never read whole). */
|
|
138
|
+
writeBackupFrom(txID: string, id: string, from: string): void {
|
|
139
|
+
const dir = `${this.backupDir}/${txID}`
|
|
140
|
+
if (!this.fs.lstat(dir)) this.fs.mkdir(dir, 0o700)
|
|
141
|
+
const path = `${dir}/${id}`
|
|
142
|
+
this.removeQuietly(path)
|
|
143
|
+
copyFile(this.fs, from, path, 0o600)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A file backup's path and the digest of what it holds now (undefined when it is missing). */
|
|
147
|
+
backupFile(txID: string, id: string): { readonly path: string; readonly sha256: string } | undefined {
|
|
148
|
+
const path = `${this.backupDir}/${txID}/${id}`
|
|
149
|
+
return this.fs.lstat(path)?.kind === "file" ? { path, sha256: fileDigest(this.fs, path) } : undefined
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
readBackup(txID: string, id: string): Uint8Array | undefined {
|
|
153
|
+
const path = `${this.backupDir}/${txID}/${id}`
|
|
154
|
+
const info = this.fs.lstat(path)
|
|
155
|
+
return info?.kind === "file" ? this.fs.readFile(path) : undefined
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
dropBackups(txID: string): void {
|
|
159
|
+
const dir = `${this.backupDir}/${txID}`
|
|
160
|
+
if (!this.fs.lstat(dir)) return
|
|
161
|
+
for (const name of this.list(dir)) this.fs.unlink(`${dir}/${name}`)
|
|
162
|
+
this.fs.rmdir(dir)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private writeAtomic(path: string, text: string): void {
|
|
166
|
+
const tmp = `${path}.tmp`
|
|
167
|
+
this.removeQuietly(tmp)
|
|
168
|
+
try {
|
|
169
|
+
this.fs.writeExclusive(tmp, text, 0o600)
|
|
170
|
+
this.fs.rename(tmp, path)
|
|
171
|
+
} catch (error) {
|
|
172
|
+
this.removeQuietly(tmp)
|
|
173
|
+
throw error
|
|
174
|
+
}
|
|
175
|
+
this.fs.fsyncDir(this.journalDir)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private list(dir: string): string[] {
|
|
179
|
+
return this.fs.lstat(dir)?.kind === "directory" ? this.fs.readdir(dir) : []
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private removeQuietly(path: string): void {
|
|
183
|
+
try {
|
|
184
|
+
if (this.fs.lstat(path)) this.fs.unlink(path)
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (!errnoOf(error)) throw error
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// Bounded-memory plumbing for large artifacts (a harness archive, what it unpacks to, hubctl itself). Nothing here
|
|
2
|
+
// ever holds a whole file: bytes arrive as chunks (ByteSource), are hashed while they are written to disk, and are
|
|
3
|
+
// read back in chunks (ByteReader). A 4 GB host installs a 350 MB harness with a few MiB of buffers.
|
|
4
|
+
//
|
|
5
|
+
// ByteSource what a download yields: a Uint8Array (small things, test fakes) or an async iterable of chunks
|
|
6
|
+
// StagingArea the transaction's private scratch space for one step (state:hub/bootstrap/staging/<txID>, owner
|
|
7
|
+
// only): write() streams a source into a new file (O_EXCL, no symlinks) and records the sha256 and
|
|
8
|
+
// size of what it wrote, so the executor checks the pin against the engine's own digest, never one a
|
|
9
|
+
// source reports; open() reads a staged file back by offset (the zip central directory) or as a
|
|
10
|
+
// stream (gunzip). The engine moves staged files into the install tree and removes the rest.
|
|
11
|
+
// fileDigest the sha256 of a file on disk, read in chunks
|
|
12
|
+
// copyFile a streaming copy (exclusive create, fsync) that returns the digest of what it copied
|
|
13
|
+
import { createHash } from "node:crypto"
|
|
14
|
+
import type { TxFs } from "./fs.ts"
|
|
15
|
+
|
|
16
|
+
/** The largest piece of a file the transaction layer reads or writes at once. */
|
|
17
|
+
export const CHUNK_BYTES = 1024 * 1024
|
|
18
|
+
|
|
19
|
+
export type ByteSource = Uint8Array | AsyncIterable<Uint8Array>
|
|
20
|
+
|
|
21
|
+
/** A source yielded more than allowed. */
|
|
22
|
+
export class TooLarge extends Error {
|
|
23
|
+
constructor(readonly maxBytes: number) {
|
|
24
|
+
super(`more than ${maxBytes} bytes`)
|
|
25
|
+
this.name = "TooLarge"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The chunks of a source, none larger than CHUNK_BYTES. */
|
|
30
|
+
export async function* chunksOf(source: ByteSource): AsyncGenerator<Uint8Array> {
|
|
31
|
+
if (source instanceof Uint8Array) {
|
|
32
|
+
for (let at = 0; at < source.byteLength; at += CHUNK_BYTES) yield source.subarray(at, Math.min(source.byteLength, at + CHUNK_BYTES))
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
for await (const chunk of source) {
|
|
36
|
+
for (let at = 0; at < chunk.byteLength; at += CHUNK_BYTES) yield chunk.subarray(at, Math.min(chunk.byteLength, at + CHUNK_BYTES))
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A small source, whole (a signature, a manifest); TooLarge beyond `maxBytes`. */
|
|
41
|
+
export async function collect(source: ByteSource, maxBytes: number): Promise<Uint8Array> {
|
|
42
|
+
if (source instanceof Uint8Array) {
|
|
43
|
+
if (source.byteLength > maxBytes) throw new TooLarge(maxBytes)
|
|
44
|
+
return source
|
|
45
|
+
}
|
|
46
|
+
const parts: Uint8Array[] = []
|
|
47
|
+
let total = 0
|
|
48
|
+
for await (const chunk of source) {
|
|
49
|
+
total += chunk.byteLength
|
|
50
|
+
if (total > maxBytes) throw new TooLarge(maxBytes)
|
|
51
|
+
parts.push(chunk)
|
|
52
|
+
}
|
|
53
|
+
const out = new Uint8Array(total)
|
|
54
|
+
let at = 0
|
|
55
|
+
for (const p of parts) {
|
|
56
|
+
out.set(p, at)
|
|
57
|
+
at += p.byteLength
|
|
58
|
+
}
|
|
59
|
+
return out
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A response body as chunks, cancelled (and failing with tooLarge()) as soon as it runs past `maxBytes`: what the
|
|
64
|
+
* https downloaders hand to a staging area.
|
|
65
|
+
*/
|
|
66
|
+
export async function* responseChunks(response: Response, maxBytes: number, tooLarge: () => Error): AsyncGenerator<Uint8Array> {
|
|
67
|
+
const reader = response.body?.getReader()
|
|
68
|
+
if (!reader) return
|
|
69
|
+
let total = 0
|
|
70
|
+
try {
|
|
71
|
+
for (;;) {
|
|
72
|
+
const { done, value } = await reader.read()
|
|
73
|
+
if (done) return
|
|
74
|
+
total += value.byteLength
|
|
75
|
+
if (total > maxBytes) {
|
|
76
|
+
await reader.cancel().catch(() => {})
|
|
77
|
+
throw tooLarge()
|
|
78
|
+
}
|
|
79
|
+
yield value
|
|
80
|
+
}
|
|
81
|
+
} finally {
|
|
82
|
+
reader.releaseLock()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A file in a staging area (or, for hubctl's own executable, a file elsewhere), with its digest and size. */
|
|
87
|
+
export interface StagedFile {
|
|
88
|
+
readonly path: string
|
|
89
|
+
readonly sha256: string
|
|
90
|
+
readonly size: number
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Random and sequential access to a staged file, in bounded pieces. */
|
|
94
|
+
export interface ByteReader {
|
|
95
|
+
readonly size: number
|
|
96
|
+
/** At most `length` bytes from `offset`. */
|
|
97
|
+
read(offset: number, length: number): Uint8Array
|
|
98
|
+
/** The bytes from `start` (default 0) to `end` (default the size), in chunks of at most CHUNK_BYTES. */
|
|
99
|
+
stream(start?: number, end?: number): AsyncGenerator<Uint8Array>
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function bytesReader(bytes: Uint8Array): ByteReader {
|
|
103
|
+
return {
|
|
104
|
+
size: bytes.byteLength,
|
|
105
|
+
read: (offset, length) => bytes.subarray(Math.max(0, offset), Math.max(0, Math.min(bytes.byteLength, offset + length))),
|
|
106
|
+
async *stream(start = 0, end = bytes.byteLength) {
|
|
107
|
+
yield* chunksOf(bytes.subarray(start, Math.min(end, bytes.byteLength)))
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function fileReader(fs: TxFs, path: string, size: number): ByteReader {
|
|
113
|
+
return {
|
|
114
|
+
size,
|
|
115
|
+
read: (offset, length) => (offset >= size || length <= 0 ? new Uint8Array() : fs.readRange(path, offset, Math.min(length, size - offset))),
|
|
116
|
+
async *stream(start = 0, end = size) {
|
|
117
|
+
for (let at = start; at < Math.min(end, size); ) {
|
|
118
|
+
const chunk = fs.readRange(path, at, Math.min(CHUNK_BYTES, end - at))
|
|
119
|
+
if (chunk.byteLength === 0) return
|
|
120
|
+
at += chunk.byteLength
|
|
121
|
+
yield chunk
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface StagingArea {
|
|
128
|
+
/**
|
|
129
|
+
* Streams `source` into a new private file named after `name` (its last path segment), hashing it on the way;
|
|
130
|
+
* TooLarge (and nothing left behind) beyond `maxBytes`. Errors of the source propagate the same way.
|
|
131
|
+
*/
|
|
132
|
+
write(name: string, source: ByteSource, options: { readonly maxBytes: number }): Promise<StagedFile>
|
|
133
|
+
/** Reads back a file this area wrote. */
|
|
134
|
+
open(file: StagedFile): ByteReader
|
|
135
|
+
/** The digest and size this area recorded while writing `file`; undefined for any file it did not write. */
|
|
136
|
+
written(file: StagedFile): { readonly sha256: string; readonly size: number } | undefined
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const safeName = (name: string) => (name.slice(name.lastIndexOf("/") + 1).replace(/[^A-Za-z0-9._-]/g, "_") || "file").slice(0, 100)
|
|
140
|
+
const hex = (hash: ReturnType<typeof createHash>) => `sha256:${hash.digest("hex")}`
|
|
141
|
+
|
|
142
|
+
/** A staging area in `dir` (created owner-only on first use, below an existing, symlink-checked parent). */
|
|
143
|
+
export function fsStaging(fs: TxFs, dir: string): StagingArea & { dispose(): void } {
|
|
144
|
+
const files = new Map<string, { sha256: string; size: number }>()
|
|
145
|
+
let count = 0
|
|
146
|
+
const ensure = () => {
|
|
147
|
+
const info = fs.lstat(dir)
|
|
148
|
+
if (!info) {
|
|
149
|
+
const parent = dir.slice(0, dir.lastIndexOf("/"))
|
|
150
|
+
if (!fs.lstat(parent)) fs.mkdir(parent, 0o700)
|
|
151
|
+
else if (fs.lstat(parent)?.kind !== "directory") throw new Error(`${parent} is not a directory`)
|
|
152
|
+
fs.mkdir(dir, 0o700)
|
|
153
|
+
} else if (info.kind !== "directory") throw new Error(`${dir} is not a directory`)
|
|
154
|
+
}
|
|
155
|
+
const remove = (path: string) => {
|
|
156
|
+
const info = fs.lstat(path)
|
|
157
|
+
if (!info) return
|
|
158
|
+
if (info.kind === "directory") {
|
|
159
|
+
for (const name of fs.readdir(path)) remove(`${path}/${name}`)
|
|
160
|
+
fs.rmdir(path)
|
|
161
|
+
} else fs.unlink(path)
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
async write(name, source, { maxBytes }) {
|
|
165
|
+
ensure()
|
|
166
|
+
const path = `${dir}/${++count}-${safeName(name)}`
|
|
167
|
+
const out = fs.createExclusive(path, 0o600)
|
|
168
|
+
const hash = createHash("sha256")
|
|
169
|
+
let size = 0
|
|
170
|
+
try {
|
|
171
|
+
for await (const chunk of chunksOf(source)) {
|
|
172
|
+
size += chunk.byteLength
|
|
173
|
+
if (size > maxBytes) throw new TooLarge(maxBytes)
|
|
174
|
+
hash.update(chunk)
|
|
175
|
+
out.write(chunk)
|
|
176
|
+
}
|
|
177
|
+
out.close()
|
|
178
|
+
} catch (error) {
|
|
179
|
+
out.abort()
|
|
180
|
+
throw error
|
|
181
|
+
}
|
|
182
|
+
const file = { path, sha256: hex(hash), size }
|
|
183
|
+
files.set(path, { sha256: file.sha256, size })
|
|
184
|
+
return file
|
|
185
|
+
},
|
|
186
|
+
open: (file) => {
|
|
187
|
+
const known = files.get(file.path)
|
|
188
|
+
if (!known) throw new Error(`${file.path} is not a file of this staging area`)
|
|
189
|
+
return fileReader(fs, file.path, known.size)
|
|
190
|
+
},
|
|
191
|
+
written: (file) => files.get(file.path),
|
|
192
|
+
dispose() {
|
|
193
|
+
files.clear()
|
|
194
|
+
remove(dir)
|
|
195
|
+
},
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** An in-memory staging area (tests, and the pure in-memory unpack helpers); `bytes` reads a file back whole. */
|
|
200
|
+
export function memoryStaging(): StagingArea & { bytes(file: StagedFile): Uint8Array } {
|
|
201
|
+
const files = new Map<string, Uint8Array>()
|
|
202
|
+
let count = 0
|
|
203
|
+
const get = (file: StagedFile) => {
|
|
204
|
+
const bytes = files.get(file.path)
|
|
205
|
+
if (!bytes) throw new Error(`${file.path} is not a file of this staging area`)
|
|
206
|
+
return bytes
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
async write(name, source, { maxBytes }) {
|
|
210
|
+
const path = `memory:/${++count}-${safeName(name)}`
|
|
211
|
+
const bytes = await collect(chunksOf(source), maxBytes)
|
|
212
|
+
files.set(path, bytes)
|
|
213
|
+
return { path, sha256: hex(createHash("sha256").update(bytes)), size: bytes.byteLength }
|
|
214
|
+
},
|
|
215
|
+
open: (file) => bytesReader(get(file)),
|
|
216
|
+
written: (file) => {
|
|
217
|
+
const bytes = files.get(file.path)
|
|
218
|
+
return bytes ? { sha256: hex(createHash("sha256").update(bytes)), size: bytes.byteLength } : undefined
|
|
219
|
+
},
|
|
220
|
+
bytes: get,
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The sha256 of the regular file at `path`, read in chunks. */
|
|
225
|
+
export function fileDigest(fs: TxFs, path: string): string {
|
|
226
|
+
const hash = createHash("sha256")
|
|
227
|
+
for (let at = 0; ; ) {
|
|
228
|
+
const chunk = fs.readRange(path, at, CHUNK_BYTES)
|
|
229
|
+
if (chunk.byteLength === 0) break
|
|
230
|
+
hash.update(chunk)
|
|
231
|
+
at += chunk.byteLength
|
|
232
|
+
}
|
|
233
|
+
return hex(hash)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Copies `from` into a new file `to` (exclusive, `mode`, fsynced) in chunks; returns the digest of what it copied. */
|
|
237
|
+
export function copyFile(fs: TxFs, from: string, to: string, mode: number): string {
|
|
238
|
+
const out = fs.createExclusive(to, mode)
|
|
239
|
+
const hash = createHash("sha256")
|
|
240
|
+
try {
|
|
241
|
+
for (let at = 0; ; ) {
|
|
242
|
+
const chunk = fs.readRange(from, at, CHUNK_BYTES)
|
|
243
|
+
if (chunk.byteLength === 0) break
|
|
244
|
+
hash.update(chunk)
|
|
245
|
+
out.write(chunk)
|
|
246
|
+
at += chunk.byteLength
|
|
247
|
+
}
|
|
248
|
+
out.close()
|
|
249
|
+
} catch (error) {
|
|
250
|
+
out.abort()
|
|
251
|
+
throw error
|
|
252
|
+
}
|
|
253
|
+
return hex(hash)
|
|
254
|
+
}
|