@huynle/hub 0.8.4 → 0.8.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/README.md +40 -3
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +5288 -4415
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/bootstrap/PHASE2-CONTRACT.md +87 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +11 -43
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +19 -12
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +42 -6
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +18 -7
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +36 -19
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +19 -3
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +2 -5
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +37 -6
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +49 -26
- package/packages/cli/src/bootstrap/adapters/process-tree.ts +263 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +7 -4
- package/packages/cli/src/bootstrap/contracts/platform.ts +9 -5
- package/packages/cli/src/bootstrap/contracts/validate.ts +20 -4
- package/packages/cli/src/bootstrap/discovery/discover.ts +3 -1
- package/packages/cli/src/bootstrap/discovery/executables.ts +49 -14
- package/packages/cli/src/bootstrap/discovery/host.ts +16 -8
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +41 -5
- package/packages/cli/src/bootstrap/discovery/probe.ts +7 -3
- package/packages/cli/src/bootstrap/discovery/util.ts +42 -5
- package/packages/cli/src/bootstrap/enrollment/store.ts +13 -6
- package/packages/cli/src/bootstrap/preview/plan.ts +3 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +10 -3
- package/packages/cli/src/bootstrap/release/README.md +63 -9
- package/packages/cli/src/bootstrap/release/cli.ts +94 -16
- package/packages/cli/src/bootstrap/release/harness-pins.pending.json +24 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +3 -0
- package/packages/cli/src/bootstrap/release/pins.ts +60 -9
- package/packages/cli/src/bootstrap/transaction/builtin.ts +18 -6
- package/packages/cli/src/bootstrap/transaction/engine.ts +166 -12
- package/packages/cli/src/bootstrap/transaction/executor.ts +5 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +142 -3
- package/packages/cli/src/bootstrap/transaction/shim.ts +31 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +11 -6
- package/packages/cli/src/credentials.ts +5 -2
- package/packages/cli/src/main.ts +3 -2
- package/packages/cli/src/setup/commands.ts +96 -18
- package/packages/cli/src/setup/contributors.ts +45 -10
- package/packages/cli/src/setup/runtime.ts +25 -6
- package/packages/cli/src/setup/self-aside.ts +130 -0
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/opencode-warm.ts +12 -4
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- 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/spoke-sdk/package.json +1 -1
- package/src/central/config.ts +12 -0
- package/src/central/http.ts +10 -0
- package/src/central/install/CONTRACT.md +66 -0
- package/src/central/install/install.ps1 +349 -0
- package/src/central/install/install.sh +266 -0
- package/src/central/install/render.ts +146 -0
- package/src/central/install/templates.ts +6 -0
- package/src/central/install/text-assets.d.ts +9 -0
- package/src/config.ts +12 -7
- package/src/spoke/env-inject.ts +17 -2
- package/src/spoke/launchers.ts +145 -9
- package/src/win-acl.ts +154 -0
- package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +1 -0
- package/web/dist/assets/{ArtifactPane-XTnwXl-Y.js → ArtifactPane-DKwbMTX9.js} +1 -1
- package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +6 -0
- package/web/dist/assets/Library-D6-Go6Cu.js +2 -0
- package/web/dist/assets/{ProfilesDestination-dZa3vioh.js → ProfilesDestination-BOML2Dgf.js} +1 -1
- package/web/dist/assets/{TeamsPage-DJt-Kk84.js → TeamsPage-CNBTE8gB.js} +1 -1
- package/web/dist/assets/Watchtower-Dor1Ubjy.js +2 -0
- package/web/dist/assets/Watchtower-RphjcL0_.css +1 -0
- package/web/dist/assets/hub-CrbItiwP.js +13 -0
- package/web/dist/assets/index-82Ce0IAf.css +1 -0
- package/web/dist/assets/index-D9SP3_0z.js +22 -0
- package/web/dist/assets/{live-D-RZbeUi.js → live-BNJgy0vg.js} +3 -3
- package/web/dist/assets/{pane-layout-Csk-vPNe.js → pane-layout-EdR1kRrG.js} +1 -1
- package/web/dist/assets/preload-helper-DqrJTq0t.js +1 -0
- package/web/dist/assets/{renderer2d-DILxLFEq.js → renderer2d-CYag1CnR.js} +1 -1
- package/web/dist/assets/{renderer3d-PvN9Qbt2.js → renderer3d-BRB6rgDJ.js} +1 -1
- package/web/dist/assets/{safe-area-BS6ii3M7.js → safe-area-fOy0AOuG.js} +1 -1
- package/web/dist/assets/{source-BFLJQ43_.js → source-Wbq35qGp.js} +1 -1
- package/web/dist/assets/{ui-Qo2Pd9-O.js → ui-j5FFk90B.js} +1 -1
- package/web/dist/index.html +6 -4
- package/web/dist/assets/ArtifactPage-Cb1DcrQf.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-C37tZZ3e.js +0 -6
- package/web/dist/assets/Library-CZSSHvOJ.js +0 -2
- package/web/dist/assets/Watchtower-BXaUaXO1.js +0 -2
- package/web/dist/assets/Watchtower-CTgqHk5g.css +0 -1
- package/web/dist/assets/index-DVSkeAvn.css +0 -1
- package/web/dist/assets/index-DifgVuKS.js +0 -34
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
resolvePathRef,
|
|
23
23
|
rootPathIn,
|
|
24
24
|
executionContextOf,
|
|
25
|
+
toNativePath,
|
|
25
26
|
validateEnvelope,
|
|
26
27
|
type DiscoverySnapshot,
|
|
27
28
|
type EnrollmentMode,
|
|
@@ -36,11 +37,12 @@ import {
|
|
|
36
37
|
type SetupPlan,
|
|
37
38
|
type VerifiedManifest,
|
|
38
39
|
} from "../bootstrap/contracts/index.ts"
|
|
39
|
-
import { verifyManifest } from "../bootstrap/contracts/index.ts"
|
|
40
|
+
import { verifyManifest, type TransactionJournal } from "../bootstrap/contracts/index.ts"
|
|
40
41
|
import { OpencodePlanError } from "../bootstrap/adapters/opencode/plan.ts"
|
|
41
42
|
import { parseJsonc } from "../bootstrap/transaction/json-merge.ts"
|
|
42
43
|
import { renderRoleVars } from "../bootstrap/ansible/render.ts"
|
|
43
44
|
import { createNodeProbe } from "../bootstrap/discovery/node-probe.ts"
|
|
45
|
+
import { normalizeAbs } from "../bootstrap/discovery/util.ts"
|
|
44
46
|
import { credentialStoreFor, defaultMachineName, machineEnroll, machineStatus, renderMachineResult, sharingOffWarning } from "../bootstrap/enrollment/index.ts"
|
|
45
47
|
import { previewSetup, renderPreview, revalidateApply } from "../bootstrap/preview/index.ts"
|
|
46
48
|
import { defaultProfile } from "../bootstrap/profiles/catalog.ts"
|
|
@@ -63,6 +65,8 @@ import { hubLaunchDriver } from "./launch-driver.ts"
|
|
|
63
65
|
import { errorCodeOf, issuesText, PlanRefusal } from "./contributors.ts"
|
|
64
66
|
import { createSetupRuntime, enrollPromptText, type RuntimePorts, type SetupRuntime } from "./runtime.ts"
|
|
65
67
|
import { BootstrapStateStore, raiseFloors, validateSetupRequest, type BootstrapState, type SetupChoices, type SetupRequest } from "./state.ts"
|
|
68
|
+
import { defaultWinAcl, writeOwnerOnlyFile } from "../../../../src/win-acl.ts"
|
|
69
|
+
import { nodeSelfAsideIo, removeSelfAside, selfIdentity, type SelfAsideIo, type SelfAsideOutcome, type SelfIdentity } from "./self-aside.ts"
|
|
66
70
|
|
|
67
71
|
/** Test seams (never set by main.ts): fakes for everything that reaches outside the process. */
|
|
68
72
|
export interface BootstrapSeams {
|
|
@@ -75,6 +79,8 @@ export interface BootstrapSeams {
|
|
|
75
79
|
readonly trustRoot?: string
|
|
76
80
|
/** Floor of the enrollment poll interval. */
|
|
77
81
|
readonly minIntervalMs?: number
|
|
82
|
+
/** Windows: the platform calls that delete this hubctl.exe's aside after exit (self-aside.ts). */
|
|
83
|
+
readonly selfAside?: SelfAsideIo
|
|
78
84
|
}
|
|
79
85
|
|
|
80
86
|
export interface SetupIo {
|
|
@@ -380,6 +386,12 @@ export function pathHint(snapshot: DiscoverySnapshot, plan: SetupPlan, env: Read
|
|
|
380
386
|
const bin = snapshot.roots.find((r) => r.id === "user-bin")
|
|
381
387
|
if (!bin) return undefined
|
|
382
388
|
const dir = rootPathIn(bin, executionContextOf(snapshot))
|
|
389
|
+
if (snapshot.host.platform === "win32") {
|
|
390
|
+
// Windows: PATH is ";"-separated, case-insensitive; install.ps1 adds user-bin to the user PATH, which only new
|
|
391
|
+
// terminals see.
|
|
392
|
+
const onPath = (env.PATH ?? "").split(";").some((d) => normalizeAbs(d.trim())?.toLowerCase() === dir.toLowerCase())
|
|
393
|
+
return onPath ? undefined : `${toNativePath(dir, "win32")} is not on your PATH, so ${linked.join(", ")} will not be found by name; install.ps1 adds it to your user PATH: open a new terminal (or add it to PATH yourself)`
|
|
394
|
+
}
|
|
383
395
|
const onPath = (env.PATH ?? "").split(":").some((d) => d.replace(/\/+$/, "") === dir)
|
|
384
396
|
return onPath ? undefined : `${dir} is not on your PATH, so ${linked.join(", ")} will not be found by name; add it to PATH (for example in ~/.profile)`
|
|
385
397
|
}
|
|
@@ -411,9 +423,13 @@ async function preview(io: SetupIo, flags: Parsed["flags"], json: boolean): Prom
|
|
|
411
423
|
if (out) {
|
|
412
424
|
const request: SetupRequest = { schema: "hub.setup-request", version: 1, preview: r.value, profile, choices }
|
|
413
425
|
const file = path.resolve(io.cwd ?? process.cwd(), out)
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
|
|
426
|
+
const text = `${JSON.stringify(request, null, 2)}\n`
|
|
427
|
+
if (snapshot.host.platform === "win32") writeOwnerOnlyFile(file, text, defaultWinAcl())
|
|
428
|
+
else {
|
|
429
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
430
|
+
writeFileSync(tmp, text, { mode: 0o600 })
|
|
431
|
+
renameSync(tmp, file)
|
|
432
|
+
}
|
|
417
433
|
}
|
|
418
434
|
const warnings = previewWarnings(snapshot, r.value.plan, bundle.node()?.notes ?? [], io.env)
|
|
419
435
|
return emit(io, json, envelope("setup preview", true, { data: r.value, warnings }), () =>
|
|
@@ -686,7 +702,50 @@ async function drift(io: SetupIo, flags: Parsed["flags"], json: boolean): Promis
|
|
|
686
702
|
)
|
|
687
703
|
}
|
|
688
704
|
|
|
705
|
+
/** Where user-bin is on this host (the only place a hubctl.exe's aside is deleted after exit). */
|
|
706
|
+
function userBinOf(snapshot: DiscoverySnapshot): string | undefined {
|
|
707
|
+
const root = snapshot.roots.find((r) => r.id === "user-bin")
|
|
708
|
+
return root ? rootPathIn(root, executionContextOf(snapshot)) : undefined
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const selfAsideIo = (io: SetupIo) => io.bootstrap?.selfAside ?? nodeSelfAsideIo(io.env)
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Windows, after a rollback or recovery: when it removed the hubctl.exe running it, the aside it left is deleted now
|
|
715
|
+
* or once this process exits (self-aside.ts). The text says what happened, so the user knows hubctl is gone.
|
|
716
|
+
*/
|
|
717
|
+
function selfRemoval(io: SetupIo, self: SelfIdentity | undefined, snapshot: DiscoverySnapshot): { outcome: SelfAsideOutcome; text?: string; warning?: string } {
|
|
718
|
+
let outcome: SelfAsideOutcome
|
|
719
|
+
try {
|
|
720
|
+
outcome = removeSelfAside(self, userBinOf(snapshot), selfAsideIo(io))
|
|
721
|
+
} catch (error) {
|
|
722
|
+
return { outcome: { state: "none" }, warning: `could not check for this hubctl's leftover file: ${(error as Error).message}` }
|
|
723
|
+
}
|
|
724
|
+
if (outcome.state === "none") return { outcome }
|
|
725
|
+
const exe = toNativePath(self!.path, "win32")
|
|
726
|
+
const aside = toNativePath(outcome.aside, "win32")
|
|
727
|
+
const gone = `this rolled back hubctl's own install: ${exe} is removed (install hubctl again to run hubctl setup)`
|
|
728
|
+
if (outcome.state === "left") return { outcome, text: gone, warning: `${aside} could not be deleted (${outcome.reason}); delete it once this command has exited` }
|
|
729
|
+
return { outcome, text: outcome.state === "scheduled" ? `${gone}; ${aside} is deleted once this command exits` : gone }
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** What to do after a recovery_required outcome of rollback or recover. */
|
|
733
|
+
const recoveryHint = (code: string): { hint?: string } => (code === "recovery_required" ? { hint: "Run hubctl setup recover (once nothing runs from the paths named) to finish it" } : {})
|
|
734
|
+
|
|
735
|
+
/** After a rollback: the state describes `target` (a committed transaction this host keeps), or nothing. */
|
|
736
|
+
function writeRolledBackState(snapshot: DiscoverySnapshot, state: BootstrapState, target: TransactionJournal | undefined): void {
|
|
737
|
+
const request = target ? stateStore(snapshot).readRequest(target.planDigest) : undefined
|
|
738
|
+
stateStore(snapshot).write({
|
|
739
|
+
schema: "hub.bootstrap-state",
|
|
740
|
+
version: 1,
|
|
741
|
+
...(request ? { profile: request.profile, choices: request.choices, ...(request.profile.revision ? { catalogRevision: request.profile.revision } : {}), txID: target!.txID, planDigest: target!.planDigest } : {}),
|
|
742
|
+
floors: state.floors,
|
|
743
|
+
})
|
|
744
|
+
}
|
|
745
|
+
|
|
689
746
|
async function rollback(io: SetupIo, flags: Parsed["flags"], json: boolean): Promise<number> {
|
|
747
|
+
// Before anything moves: the executable running this (a rollback may remove it, see selfRemoval).
|
|
748
|
+
const self = flags["dry-run"] === true ? undefined : selfIdentity(io.program.execPath, selfAsideIo(io))
|
|
690
749
|
const snapshot = discovered(io)
|
|
691
750
|
const to = str(flags, "to")
|
|
692
751
|
const store = new TxStore(nodeFs, snapshot.roots, executionContextOf(snapshot))
|
|
@@ -703,25 +762,33 @@ async function rollback(io: SetupIo, flags: Parsed["flags"], json: boolean): Pro
|
|
|
703
762
|
const runtime = runtimeFor(io, undefined, snapshot)
|
|
704
763
|
const profile = state.profile ?? unwrapProfile(defaultProfile("session-only"))
|
|
705
764
|
const bundle = await runtime.plan(profile, state.choices ?? {})
|
|
765
|
+
const committed = new Set(store.journals().filter((j) => j.state === "committed" && j.hostFingerprint === snapshot.host.fingerprint).map((j) => j.txID))
|
|
706
766
|
const outcome = await runtime.applier(snapshot, runtime.executors(bundle, snapshot, {})).rollback(to ? { to } : {})
|
|
707
|
-
if (!outcome.ok)
|
|
767
|
+
if (!outcome.ok) {
|
|
768
|
+
noteKept(io, json, outcome)
|
|
769
|
+
const report = reportData(outcome)
|
|
770
|
+
return emit(io, json, envelope("setup rollback", false, { ...(report ? { data: report } : {}), error: { code: outcome.code, message: outcome.message, ...recoveryHint(outcome.code) } }))
|
|
771
|
+
}
|
|
708
772
|
if (!outcome.report) return emit(io, json, envelope("setup rollback", true, { data: { rolledBack: false } }), () => "nothing to roll back")
|
|
773
|
+
// Newest first, as they were undone.
|
|
774
|
+
const undone = store
|
|
775
|
+
.journals()
|
|
776
|
+
.filter((j) => committed.has(j.txID) && j.state === "rolled-back")
|
|
777
|
+
.sort((a, b) => b.startedAt - a.startedAt)
|
|
778
|
+
.map((j) => j.txID)
|
|
709
779
|
// The state now describes the target transaction (or nothing, after a full rollback).
|
|
710
|
-
|
|
711
|
-
const request = target ? stateStore(snapshot).readRequest(target.planDigest) : undefined
|
|
712
|
-
stateStore(snapshot).write({
|
|
713
|
-
schema: "hub.bootstrap-state",
|
|
714
|
-
version: 1,
|
|
715
|
-
...(request ? { profile: request.profile, choices: request.choices, ...(request.profile.revision ? { catalogRevision: request.profile.revision } : {}), txID: target!.txID, planDigest: target!.planDigest } : {}),
|
|
716
|
-
floors: state.floors,
|
|
717
|
-
})
|
|
780
|
+
writeRolledBackState(snapshot, state, to ? store.journals().find((j) => j.txID === to) : undefined)
|
|
718
781
|
noteKept(io, json, outcome)
|
|
719
|
-
const
|
|
720
|
-
const
|
|
721
|
-
|
|
782
|
+
const removal = selfRemoval(io, self, snapshot)
|
|
783
|
+
const data = { rolledBack: true, ...reportData(outcome), transactions: undone, ...(removal.outcome.state !== "none" ? { selfRemoved: removal.outcome } : {}) }
|
|
784
|
+
const conflicts = outcome.report.conflicts.map((c) => `${c.actionID}: ${c.reason}`)
|
|
785
|
+
const warnings = [...conflicts, ...(removal.warning ? [removal.warning] : [])]
|
|
786
|
+
const summary = `rolled back ${undone.length} transaction(s)${to ? ` to ${to}` : ""}${undone.length ? `: ${undone.join(", ")}` : ""}${conflicts.length ? ` (${conflicts.length} file(s) kept because they changed since)` : ""}`
|
|
787
|
+
return emit(io, json, envelope("setup rollback", true, { data, warnings }), () => [summary, ...(removal.text ? [removal.text] : [])].join("\n"))
|
|
722
788
|
}
|
|
723
789
|
|
|
724
790
|
async function recover(io: SetupIo, flags: Parsed["flags"], json: boolean): Promise<number> {
|
|
791
|
+
const self = selfIdentity(io.program.execPath, selfAsideIo(io))
|
|
725
792
|
const snapshot = discovered(io)
|
|
726
793
|
const store = new TxStore(nodeFs, snapshot.roots, executionContextOf(snapshot))
|
|
727
794
|
let pending
|
|
@@ -747,9 +814,20 @@ async function recover(io: SetupIo, flags: Parsed["flags"], json: boolean): Prom
|
|
|
747
814
|
consent: { escalate: flags.escalate === true, confirmActivation: flags["confirm-activation"] === true },
|
|
748
815
|
})
|
|
749
816
|
noteKept(io, json, outcome)
|
|
750
|
-
if (!outcome.ok) return emit(io, json, envelope("setup recover", false, { ...(outcome.report ? { data: reportData(outcome) } : {}), error: { code: outcome.code, message: outcome.message } }))
|
|
817
|
+
if (!outcome.ok) return emit(io, json, envelope("setup recover", false, { ...(outcome.report ? { data: reportData(outcome) } : {}), error: { code: outcome.code, message: outcome.message, ...recoveryHint(outcome.code) } }))
|
|
751
818
|
if (!outcome.report) return emit(io, json, envelope("setup recover", true, { data: { recovered: false } }), () => "nothing to recover")
|
|
752
|
-
|
|
819
|
+
// A recovered rollback of the transaction the state describes (a `setup rollback` a busy path interrupted): the
|
|
820
|
+
// state now describes the newest transaction this host still has committed, or nothing.
|
|
821
|
+
const journals = store.journals()
|
|
822
|
+
if (state.txID && journals.find((j) => j.txID === state.txID)?.state === "rolled-back") {
|
|
823
|
+
const kept = journals.filter((j) => j.state === "committed" && j.hostFingerprint === snapshot.host.fingerprint).sort((a, b) => b.startedAt - a.startedAt)[0]
|
|
824
|
+
writeRolledBackState(snapshot, state, kept)
|
|
825
|
+
}
|
|
826
|
+
const removal = selfRemoval(io, self, snapshot)
|
|
827
|
+
const data = { recovered: true, mode, ...reportData(outcome), ...(removal.outcome.state !== "none" ? { selfRemoved: removal.outcome } : {}) }
|
|
828
|
+
return emit(io, json, envelope("setup recover", true, { data, ...(removal.warning ? { warnings: [removal.warning] } : {}) }), () =>
|
|
829
|
+
[`recovered ${outcome.report!.txID} (${mode}): ${outcome.report!.state}`, ...(removal.text ? [removal.text] : [])].join("\n"),
|
|
830
|
+
)
|
|
753
831
|
}
|
|
754
832
|
|
|
755
833
|
async function ansible(io: SetupIo, flags: Parsed["flags"], json: boolean): Promise<number> {
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
//
|
|
5
5
|
// hubctlContributor the standalone hubctl this verified manifest pins for the host's exact target, into
|
|
6
6
|
// user-bin:hubctl (the stable authority services and MCP clients run), and the `hub` alias
|
|
7
|
-
// next to it (one command surface; the name grants nothing).
|
|
7
|
+
// next to it (one command surface; the name grants nothing). On Windows (PHASE2-CONTRACT.md)
|
|
8
|
+
// user-bin:hubctl.exe and user-bin:hub.cmd (the applier writes the link as a cmd shim).
|
|
8
9
|
// enrollmentContributor inline enrollment: one enroll-machine for the hub the user named, only while this machine
|
|
9
10
|
// is not enrolled with it (the device code is approved by a person on the hub).
|
|
10
11
|
// refusing a contributor that can refuse: planSetup has no refusal channel for contributors, so a
|
|
@@ -16,6 +17,17 @@ import { userBinLinkDecision } from "./user-bin.ts"
|
|
|
16
17
|
export const HUBCTL_REF = { root: "user-bin", rel: "hubctl" } as const
|
|
17
18
|
export const HUB_ALIAS_REF = { root: "user-bin", rel: "hub" } as const
|
|
18
19
|
|
|
20
|
+
const isWindowsTarget = (target: string) => target.startsWith("windows-")
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* hubctl and its `hub` alias on `target`: user-bin:hubctl and user-bin:hub; on Windows user-bin:hubctl.exe and
|
|
24
|
+
* user-bin:hub.cmd (link-executable there is a cmd shim, PHASE2-CONTRACT.md "Install primitives on win32").
|
|
25
|
+
*/
|
|
26
|
+
export function hubctlRefs(target: string): { readonly hubctl: PathRef; readonly alias: PathRef } {
|
|
27
|
+
if (isWindowsTarget(target)) return { hubctl: { root: "user-bin", rel: "hubctl.exe" }, alias: { root: "user-bin", rel: "hub.cmd" } }
|
|
28
|
+
return { hubctl: { ...HUBCTL_REF }, alias: { ...HUB_ALIAS_REF } }
|
|
29
|
+
}
|
|
30
|
+
|
|
19
31
|
/** A plan that cannot be made, with the envelope error code it maps to. */
|
|
20
32
|
export class PlanRefusal extends Error {
|
|
21
33
|
override readonly name = "PlanRefusal"
|
|
@@ -62,21 +74,22 @@ export function hubctlContributor(options: { readonly manifest?: VerifiedManifes
|
|
|
62
74
|
const artifact = selectArtifact(manifest, snapshot.host.target)
|
|
63
75
|
if (!artifact.ok) return []
|
|
64
76
|
const installed = options.installed
|
|
77
|
+
const refs = hubctlRefs(snapshot.host.target)
|
|
65
78
|
const actions: Action[] = []
|
|
66
|
-
if (installed?.sha256(
|
|
79
|
+
if (installed?.sha256(refs.hubctl) !== artifact.value.sha256)
|
|
67
80
|
actions.push({
|
|
68
81
|
id: "install-hubctl",
|
|
69
82
|
kind: "install-artifact",
|
|
70
83
|
escalation: "user",
|
|
71
|
-
summary: `Install hubctl ${manifest.release} for ${snapshot.host.target} as ~/.local/bin
|
|
84
|
+
summary: `Install hubctl ${manifest.release} for ${snapshot.host.target} as ~/.local/bin/${refs.hubctl.rel} (pinned by the signed release manifest)`,
|
|
72
85
|
artifact: "hubctl",
|
|
73
86
|
version: manifest.release,
|
|
74
87
|
target: snapshot.host.target,
|
|
75
88
|
sha256: artifact.value.sha256,
|
|
76
|
-
path: { ...
|
|
89
|
+
path: { ...refs.hubctl },
|
|
77
90
|
})
|
|
78
|
-
if (!installed?.linksTo(
|
|
79
|
-
actions.push({ id: "link-hub", kind: "link-executable", escalation: "user", summary: "Link hub to hubctl (the same command surface)", path: { ...
|
|
91
|
+
if (!installed?.linksTo(refs.alias, refs.hubctl))
|
|
92
|
+
actions.push({ id: "link-hub", kind: "link-executable", escalation: "user", summary: "Link hub to hubctl (the same command surface)", path: { ...refs.alias }, target: { ...refs.hubctl } })
|
|
80
93
|
return actions
|
|
81
94
|
}
|
|
82
95
|
}
|
|
@@ -100,6 +113,27 @@ export const HARNESS_EXECUTABLES: Readonly<Record<HarnessID, { readonly name: st
|
|
|
100
113
|
codex: { name: "codex", rel: "bin/codex", label: "Codex" },
|
|
101
114
|
})
|
|
102
115
|
|
|
116
|
+
export interface HarnessExecutable {
|
|
117
|
+
/** The command name (what PATH is searched for). */
|
|
118
|
+
readonly name: string
|
|
119
|
+
/** The link's name in user-bin: the command name; on Windows `<name>.cmd` (a cmd shim). */
|
|
120
|
+
readonly link: string
|
|
121
|
+
/** The binary inside the install root. */
|
|
122
|
+
readonly rel: string
|
|
123
|
+
readonly label: string
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A harness's executable on `target`. On Windows only OpenCode has a known layout (bin/opencode.exe, Phase 2); the
|
|
128
|
+
* others are undefined there, so no link is planned for them.
|
|
129
|
+
*/
|
|
130
|
+
export function harnessExecutable(harness: HarnessID, target: string): HarnessExecutable | undefined {
|
|
131
|
+
const exe = HARNESS_EXECUTABLES[harness]
|
|
132
|
+
if (!isWindowsTarget(target)) return { name: exe.name, link: exe.name, rel: exe.rel, label: exe.label }
|
|
133
|
+
if (harness !== "opencode") return undefined
|
|
134
|
+
return { name: exe.name, link: `${exe.name}.cmd`, rel: `${exe.rel}.exe`, label: exe.label }
|
|
135
|
+
}
|
|
136
|
+
|
|
103
137
|
/**
|
|
104
138
|
* Contract amendment A6 (HBS-12 E4): a hub-installed harness goes on PATH through a typed link in user-bin
|
|
105
139
|
* (`~/.local/bin/opencode` → `data:hub/harnesses/opencode/current/bin/opencode`), journaled and rolled back like any
|
|
@@ -108,16 +142,17 @@ export const HARNESS_EXECUTABLES: Readonly<Record<HarnessID, { readonly name: st
|
|
|
108
142
|
* hub's harness directory): a user's binary is never shadowed or replaced.
|
|
109
143
|
*/
|
|
110
144
|
export function harnessLinkContributor(inspector: PathInspector): PlanContributor {
|
|
111
|
-
return ({ profile, planned = [] }) => {
|
|
145
|
+
return ({ snapshot, profile, planned = [] }) => {
|
|
112
146
|
const actions: Action[] = []
|
|
113
147
|
for (const harness of HARNESS_ORDER) {
|
|
114
148
|
if (!profile.harnesses.includes(harness)) continue
|
|
115
|
-
const exe =
|
|
149
|
+
const exe = harnessExecutable(harness, snapshot.host.target)
|
|
150
|
+
if (!exe) continue
|
|
116
151
|
const current = harnessCurrentRef(harness)
|
|
117
152
|
const target: PathRef = { root: current.root, rel: `${current.rel}/${exe.rel}` }
|
|
118
153
|
const selected = planned.some((a) => a.kind === "select-harness-version" && a.harness === harness)
|
|
119
154
|
if (!selected && inspector.entry(target) !== "file") continue
|
|
120
|
-
const link: PathRef = { root: "user-bin", rel: exe.
|
|
155
|
+
const link: PathRef = { root: "user-bin", rel: exe.link }
|
|
121
156
|
const linkPath = inspector.where(link)
|
|
122
157
|
const targetPath = inspector.where(target)
|
|
123
158
|
const ownDir = inspector.where({ root: current.root, rel: `hub/harnesses/${harness}` })
|
|
@@ -137,7 +172,7 @@ export function harnessLinkContributor(inspector: PathInspector): PlanContributo
|
|
|
137
172
|
id: `link-${harness}`,
|
|
138
173
|
kind: "link-executable",
|
|
139
174
|
escalation: "user",
|
|
140
|
-
summary: `Put the hub-installed ${exe.label} on PATH: link ~/.local/bin/${exe.
|
|
175
|
+
summary: `Put the hub-installed ${exe.label} on PATH: link ~/.local/bin/${exe.link} to its current version`,
|
|
141
176
|
path: link,
|
|
142
177
|
target,
|
|
143
178
|
})
|
|
@@ -64,11 +64,12 @@ import { builtinExecutors, type HarnessSource, type HubctlSource } from "../boot
|
|
|
64
64
|
import { createApplier, type Applier } from "../bootstrap/transaction/engine.ts"
|
|
65
65
|
import type { ActionExecutor, ExecutorMap } from "../bootstrap/transaction/executor.ts"
|
|
66
66
|
import { nodeFs, type TxFs } from "../bootstrap/transaction/fs.ts"
|
|
67
|
+
import { parseCmdShim } from "../bootstrap/transaction/shim.ts"
|
|
67
68
|
import { fileDigest } from "../bootstrap/transaction/stream.ts"
|
|
68
69
|
import { txFail, txOk } from "../bootstrap/transaction/result.ts"
|
|
69
70
|
import { TxStore } from "../bootstrap/transaction/store.ts"
|
|
70
71
|
import { checkOpenShell } from "../openshell.ts"
|
|
71
|
-
import { enrollmentContributor, harnessLinkContributor,
|
|
72
|
+
import { enrollmentContributor, harnessLinkContributor, hubctlContributor, hubctlRefs, refusing, type InstalledInspector, type PathInspector } from "./contributors.ts"
|
|
72
73
|
import { dispatchByHarness, dispatchHarnessSource } from "./dispatch.ts"
|
|
73
74
|
import { claudeSpokeProbes, codexSpokeStatus, opencodeSpokeProbe, type PresenceOptions } from "./spoke-probe.ts"
|
|
74
75
|
import type { BootstrapState, SetupChoices } from "./state.ts"
|
|
@@ -151,6 +152,7 @@ export interface SetupRuntime {
|
|
|
151
152
|
/**
|
|
152
153
|
* What the files a plan would write hold now, read-only through the applier's filesystem: a regular file's digest
|
|
153
154
|
* (in chunks; hubctl is ~105 MB) and whether a path is a symlink to a target, in the snapshot's execution context.
|
|
155
|
+
* On a win32 host a hub cmd shim (link-executable's stand-in for a symlink there, shim.ts) is the link it names.
|
|
154
156
|
*/
|
|
155
157
|
export function installedInspector(snapshot: DiscoverySnapshot, fs: TxFs): InstalledInspector {
|
|
156
158
|
const where = (ref: PathRef) => {
|
|
@@ -170,7 +172,9 @@ export function installedInspector(snapshot: DiscoverySnapshot, fs: TxFs): Insta
|
|
|
170
172
|
const link = where(ref)
|
|
171
173
|
const to = where(target)
|
|
172
174
|
try {
|
|
173
|
-
|
|
175
|
+
if (link === undefined || to === undefined) return false
|
|
176
|
+
if (snapshot.host.platform === "win32" && fs.lstat(link)?.kind === "file") return parseCmdShim(fs.readRange(link, 0, SHIM_MAX)) === to
|
|
177
|
+
return fs.lstat(link)?.kind === "symlink" && nodePath.resolve(nodePath.dirname(link), fs.readlink(link)) === to
|
|
174
178
|
} catch {
|
|
175
179
|
return false
|
|
176
180
|
}
|
|
@@ -178,21 +182,36 @@ export function installedInspector(snapshot: DiscoverySnapshot, fs: TxFs): Insta
|
|
|
178
182
|
}
|
|
179
183
|
}
|
|
180
184
|
|
|
181
|
-
/**
|
|
185
|
+
/** More than any cmd shim hub writes (`@"<path>" %*`). */
|
|
186
|
+
const SHIM_MAX = 4097
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Paths and PATH as the probe sees them (read-only), in the snapshot's execution context. On a win32 host a hub cmd
|
|
190
|
+
* shim is reported as a symlink to its target, so the user-bin link rule treats it as hub's own link.
|
|
191
|
+
*/
|
|
182
192
|
export function pathInspector(snapshot: DiscoverySnapshot, probe: DiscoveryProbe): PathInspector {
|
|
183
193
|
const where = (ref: PathRef) => {
|
|
184
194
|
const r = resolvePathRef(ref, snapshot.roots, executionContextOf(snapshot))
|
|
185
195
|
return r.ok ? r.value : undefined
|
|
186
196
|
}
|
|
197
|
+
const shimTarget = (p: string) => {
|
|
198
|
+
if (snapshot.host.platform !== "win32" || probe.lstat(p) !== "file") return undefined
|
|
199
|
+
const bytes = probe.readFile(p, SHIM_MAX)
|
|
200
|
+
return bytes ? parseCmdShim(bytes) : undefined
|
|
201
|
+
}
|
|
187
202
|
return {
|
|
188
203
|
where,
|
|
189
204
|
entry: (ref) => {
|
|
190
205
|
const p = where(ref)
|
|
191
|
-
|
|
206
|
+
if (!p) return "missing"
|
|
207
|
+
return shimTarget(p) !== undefined ? "symlink" : probe.lstat(p)
|
|
192
208
|
},
|
|
193
209
|
linkTarget: (ref) => {
|
|
194
210
|
const p = where(ref)
|
|
195
|
-
if (!p
|
|
211
|
+
if (!p) return undefined
|
|
212
|
+
const shim = shimTarget(p)
|
|
213
|
+
if (shim !== undefined) return shim
|
|
214
|
+
if (probe.lstat(p) !== "symlink") return undefined
|
|
196
215
|
const text = probe.readlink(p)
|
|
197
216
|
return text === undefined ? undefined : nodePath.resolve(nodePath.dirname(p), text)
|
|
198
217
|
},
|
|
@@ -281,7 +300,7 @@ export function createSetupRuntime(deps: RuntimeDeps): SetupRuntime {
|
|
|
281
300
|
function serviceLayout(snap: DiscoverySnapshot): ProgramLayout {
|
|
282
301
|
const root = snap.roots.find((r) => r.id === "user-bin")
|
|
283
302
|
const installs = deps.manifest?.artifacts.some((a) => a.target === snap.host.target)
|
|
284
|
-
if (root && installs) return { kind: "standalone", executable: `${rootPathIn(root, executionContextOf(snap))}/${
|
|
303
|
+
if (root && installs) return { kind: "standalone", executable: `${rootPathIn(root, executionContextOf(snap))}/${hubctlRefs(snap.host.target).hubctl.rel}` }
|
|
285
304
|
return deps.layout
|
|
286
305
|
}
|
|
287
306
|
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Windows: the setup commands delete, once this process has exited, the aside of the hubctl.exe that is running them.
|
|
2
|
+
//
|
|
3
|
+
// A rollback that undoes hubctl's own install (`~\.local\bin\hubctl.exe setup rollback`) removes the very executable
|
|
4
|
+
// that runs it. The engine moves it aside (`<name>.old-<txid>-<seq>-undo`, Phase 2 contract "rename-aside"), but a
|
|
5
|
+
// running image cannot be deleted on Windows, and a later apply or recover would sweep it only if some hubctl were
|
|
6
|
+
// left to run one. So the command hands the delete to a detached, hidden `cmd.exe` that retries it once a second
|
|
7
|
+
// (bounded) until the file is gone, which is right after this process exits. Only for:
|
|
8
|
+
// - the aside that is this executable: the same file ID as the hubctl.exe this process started from;
|
|
9
|
+
// - inside user-bin, with the aside name pattern;
|
|
10
|
+
// - a path cmd takes literally (printable ASCII without " % ^ & | < > ! ( )); the command line is fixed otherwise,
|
|
11
|
+
// so nothing from outside reaches cmd's parser.
|
|
12
|
+
// Anything else is left as it was, and the command says so. POSIX unlinks a running executable directly: nothing here.
|
|
13
|
+
// MoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT) would need FFI (and admin for the PendingFileRenameOperations key).
|
|
14
|
+
import { spawn } from "node:child_process"
|
|
15
|
+
import { readdirSync, statSync, unlinkSync } from "node:fs"
|
|
16
|
+
import { toNativePath } from "../bootstrap/contracts/index.ts"
|
|
17
|
+
import { normalizeAbs } from "../bootstrap/discovery/util.ts"
|
|
18
|
+
|
|
19
|
+
/** The platform calls, injectable (tests). */
|
|
20
|
+
export interface SelfAsideIo {
|
|
21
|
+
readonly platform: string
|
|
22
|
+
/** %SystemRoot% (C:\Windows): where cmd.exe and PING.EXE are run from, never through PATH. */
|
|
23
|
+
readonly systemRoot: string
|
|
24
|
+
/** The file ID of `path`, undefined when it is not there. */
|
|
25
|
+
stat(path: string): { readonly ino: bigint } | undefined
|
|
26
|
+
readdir(path: string): string[]
|
|
27
|
+
unlink(path: string): void
|
|
28
|
+
/** Starts `file` with exactly `line` as its arguments (no quoting added), detached and hidden, not waited for. */
|
|
29
|
+
spawnDetached(file: string, line: string, cwd: string): void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The executable this process runs from, as it was when the command started. */
|
|
33
|
+
export interface SelfIdentity {
|
|
34
|
+
/** Canonical (C:/… on Windows). */
|
|
35
|
+
readonly path: string
|
|
36
|
+
readonly ino: bigint
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type SelfAsideOutcome =
|
|
40
|
+
| { readonly state: "none" }
|
|
41
|
+
| { readonly state: "deleted" | "scheduled"; readonly aside: string }
|
|
42
|
+
| { readonly state: "left"; readonly aside: string; readonly reason: string }
|
|
43
|
+
|
|
44
|
+
export const nodeSelfAsideIo = (env: Readonly<Record<string, string | undefined>> = process.env): SelfAsideIo => ({
|
|
45
|
+
platform: process.platform,
|
|
46
|
+
systemRoot: env.SystemRoot ?? env.SYSTEMROOT ?? "C:\\Windows",
|
|
47
|
+
stat: (path) => {
|
|
48
|
+
try {
|
|
49
|
+
return { ino: statSync(path, { bigint: true }).ino }
|
|
50
|
+
} catch {
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
readdir: (path) => readdirSync(path),
|
|
55
|
+
unlink: (path) => unlinkSync(path),
|
|
56
|
+
spawnDetached: (file, line, cwd) => {
|
|
57
|
+
const child = spawn(file, [line], { cwd, detached: true, stdio: "ignore", windowsHide: true, windowsVerbatimArguments: true })
|
|
58
|
+
child.on("error", () => {})
|
|
59
|
+
child.unref()
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
/** The aside names rename-aside gives `base` (engine.ts asidePath). */
|
|
64
|
+
const ASIDE = /\.old-tx_[A-Za-z0-9]{1,64}-\d{1,9}(-undo)?$/
|
|
65
|
+
/** What cmd takes literally inside double quotes, outside any variable expansion or grouping. */
|
|
66
|
+
const CMD_SAFE = /^[\x20-\x7e]+$/
|
|
67
|
+
const CMD_SPECIAL = /["%^&|<>!()]/
|
|
68
|
+
|
|
69
|
+
const key = (p: string) => p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase()
|
|
70
|
+
const parent = (p: string) => p.slice(0, p.lastIndexOf("/")) || "/"
|
|
71
|
+
const nameOf = (p: string) => p.slice(p.lastIndexOf("/") + 1)
|
|
72
|
+
|
|
73
|
+
function cmdLiteral(path: string, what: string): string {
|
|
74
|
+
if (!CMD_SAFE.test(path) || CMD_SPECIAL.test(path)) throw new Error(`${what} ${JSON.stringify(path)} is not a path cmd takes literally (printable ASCII without " % ^ & | < > ! ( ))`)
|
|
75
|
+
return path
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The fixed cmd.exe call that deletes `aside` once it is no longer running: about two minutes of retries at most. */
|
|
79
|
+
export function deleteAfterExitCommand(aside: string, systemRoot: string): { file: string; line: string } {
|
|
80
|
+
if (!ASIDE.test(aside)) throw new Error(`${JSON.stringify(aside)} is not a rename-aside name`)
|
|
81
|
+
const p = cmdLiteral(toNativePath(aside, "win32"), "the aside")
|
|
82
|
+
const root = cmdLiteral(systemRoot.replace(/\//g, "\\").replace(/\\+$/, ""), "SystemRoot")
|
|
83
|
+
return {
|
|
84
|
+
file: `${root}\\System32\\cmd.exe`,
|
|
85
|
+
line: `/d /s /c "for /l %i in (1,1,120) do @if exist "${p}" ("${root}\\System32\\PING.EXE" -n 2 127.0.0.1 >nul & del /f /q "${p}" >nul 2>&1)"`,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Records the executable this process runs from (before a command may move it aside). */
|
|
90
|
+
export function selfIdentity(execPath: string, io: SelfAsideIo = nodeSelfAsideIo()): SelfIdentity | undefined {
|
|
91
|
+
const path = normalizeAbs(execPath)
|
|
92
|
+
if (!path) return undefined
|
|
93
|
+
const st = io.stat(path)
|
|
94
|
+
return st ? { path, ino: st.ino } : undefined
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* After a command: when `self` (this process's executable, inside `userBin`) was moved aside, deletes the aside, or
|
|
99
|
+
* when it is still running (it is: this process), schedules its deletion for after this process exits.
|
|
100
|
+
*/
|
|
101
|
+
export function removeSelfAside(self: SelfIdentity | undefined, userBin: string | undefined, io: SelfAsideIo = nodeSelfAsideIo()): SelfAsideOutcome {
|
|
102
|
+
if (io.platform !== "win32" || !self || !userBin) return { state: "none" }
|
|
103
|
+
const dir = parent(self.path)
|
|
104
|
+
if (key(dir) !== key(userBin)) return { state: "none" }
|
|
105
|
+
if (io.stat(self.path)?.ino === self.ino) return { state: "none" }
|
|
106
|
+
let names: string[]
|
|
107
|
+
try {
|
|
108
|
+
names = io.readdir(dir)
|
|
109
|
+
} catch {
|
|
110
|
+
return { state: "none" }
|
|
111
|
+
}
|
|
112
|
+
const base = nameOf(self.path).toLowerCase()
|
|
113
|
+
for (const name of names) {
|
|
114
|
+
if (!name.toLowerCase().startsWith(`${base}.old-`) || !ASIDE.test(name) || name.length - base.length !== name.match(ASIDE)![0].length) continue
|
|
115
|
+
const aside = `${dir}/${name}`
|
|
116
|
+
if (io.stat(aside)?.ino !== self.ino) continue
|
|
117
|
+
try {
|
|
118
|
+
io.unlink(aside)
|
|
119
|
+
return { state: "deleted", aside }
|
|
120
|
+
} catch {}
|
|
121
|
+
try {
|
|
122
|
+
const { file, line } = deleteAfterExitCommand(aside, io.systemRoot)
|
|
123
|
+
io.spawnDetached(file, line, file.slice(0, file.lastIndexOf("\\")))
|
|
124
|
+
return { state: "scheduled", aside }
|
|
125
|
+
} catch (error) {
|
|
126
|
+
return { state: "left", aside, reason: error instanceof Error ? `${(error as NodeJS.ErrnoException).code ?? ""} ${error.message}`.trim() : String(error) }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { state: "none" }
|
|
130
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-codex",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.5",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "The Codex app-server spoke: attaches to Codex's app-server (its own over stdio, and the shared daemon's socket when present), and the `hub codex` wrapper (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-node",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.5",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "`hub node`: the per-machine daemon that prepares, collects and cleans up launch workspaces (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync, statSync, type Stats } from "node:fs"
|
|
18
18
|
import path from "node:path"
|
|
19
19
|
import { listServiceSessions, type ListingClient, type RawProject, type RawSession } from "../../../src/spoke/listing.ts"
|
|
20
|
+
import { defaultWinAcl, isWin32, nativeWinPath, type SecretFileOptions } from "../../../src/win-acl.ts"
|
|
20
21
|
|
|
21
22
|
/** How often the daemon asks (a restarted server is warmed within this). */
|
|
22
23
|
export const WARM_INTERVAL_MS = 30_000
|
|
@@ -290,9 +291,11 @@ const PSEUDO_ROOTS = ["/proc", "/sys", "/dev"]
|
|
|
290
291
|
* A server password from a file (its first line): only a private regular file (not a link, FIFO, device or socket,
|
|
291
292
|
* nothing under /proc, /sys or /dev, no symbolic link in its directory path, no group or other access, owned by this
|
|
292
293
|
* user) of 1 byte to 4 KiB. Never blocks: the file is checked with lstat before it is opened, and opened with
|
|
293
|
-
* O_NONBLOCK and O_NOFOLLOW (then checked again on the descriptor). Problems never include the content.
|
|
294
|
+
* O_NONBLOCK and O_NOFOLLOW (then checked again on the descriptor). Problems never include the content. On Windows
|
|
295
|
+
* (no modes, no uid) "private" is an access list that allows no one but this user, SYSTEM and Administrators
|
|
296
|
+
* (src/win-acl.ts); `options` injects the platform and the ACL helper.
|
|
294
297
|
*/
|
|
295
|
-
export function readPasswordFile(file: string): { password: string } | { problem: string } {
|
|
298
|
+
export function readPasswordFile(file: string, options?: SecretFileOptions): { password: string } | { problem: string } {
|
|
296
299
|
if (!path.isAbsolute(file)) return { problem: `password file ${file} is not an absolute path` }
|
|
297
300
|
const resolved = path.resolve(file)
|
|
298
301
|
if (PSEUDO_ROOTS.some((root) => resolved === root || resolved.startsWith(`${root}/`))) return { problem: `password file ${file} is not a regular file` }
|
|
@@ -320,8 +323,13 @@ export function readPasswordFile(file: string): { password: string } | { problem
|
|
|
320
323
|
try {
|
|
321
324
|
const stat = fstatSync(fd)
|
|
322
325
|
if (!stat.isFile() || stat.dev !== before.dev || stat.ino !== before.ino) return { problem: `password file ${file} is not a regular file` }
|
|
323
|
-
if ((
|
|
324
|
-
|
|
326
|
+
if (isWin32(options)) {
|
|
327
|
+
const acl = (options?.acl ?? defaultWinAcl()).check(nativeWinPath(resolved))
|
|
328
|
+
if (!acl.ok) return { problem: `password file ${file} is readable by others (${acl.problem})` }
|
|
329
|
+
} else {
|
|
330
|
+
if ((stat.mode & 0o077) !== 0) return { problem: `password file ${file} is readable by others (chmod 600 it)` }
|
|
331
|
+
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) return { problem: `password file ${file} is not owned by this user` }
|
|
332
|
+
}
|
|
325
333
|
if (stat.size === 0) return { problem: `password file ${file} is empty` }
|
|
326
334
|
if (stat.size > PASSWORD_FILE_MAX) return { problem: `password file ${file} is too large` }
|
|
327
335
|
const buffer = Buffer.alloc(PASSWORD_FILE_MAX + 1)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-preset",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.5",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "The preset spoke: drives a harness without a spoke of its own through a terminal pane (tmux), described by a data-only preset; official presets in presets/ (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-runtime-openshell",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.5",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "The `openshell` runtime of `hub node`: launches in NVIDIA OpenShell sandboxes, driven through the openshell CLI (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-spoke-sdk",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.5",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Harness-neutral spoke building blocks: hub connection, enrollment, tokens, idempotent deliver, prompt references, HarnessSpoke (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|