@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,456 @@
|
|
|
1
|
+
// The composition root of `hubctl setup` (HBS-13): one place that builds the harness adapters, the plan
|
|
2
|
+
// contributors and the apply executors from the independently tested bootstrap modules, so preview and apply use the
|
|
3
|
+
// SAME adapter registry, contributors and options (and so plan the same digest), and apply executes the fresh plan
|
|
4
|
+
// revalidateApply returns.
|
|
5
|
+
//
|
|
6
|
+
// adapters OpenCode, Claude Code, Codex (HARNESS_ORDER), each with its real probes, the machine-credential
|
|
7
|
+
// spoke probe (spoke-probe.ts) and its pinned-artifact source
|
|
8
|
+
// contributors hubctl itself (contributors.ts), the node services (node-services/plan.ts), a profile downgrade's
|
|
9
|
+
// typed service removal (profiles/upgrade.ts), inline enrollment
|
|
10
|
+
// executors builtinExecutors + the node-service executors + the per-harness dispatchers (dispatch.ts) + the
|
|
11
|
+
// enroll-machine and verify-runtime executors below
|
|
12
|
+
//
|
|
13
|
+
// Nothing here runs a shell: processes are started only by the adapters' fixed-argv runners and the service
|
|
14
|
+
// manager's fixed-call runner. Every capability that reaches outside is a port the tests replace.
|
|
15
|
+
import { realpathSync } from "node:fs"
|
|
16
|
+
import nodePath from "node:path"
|
|
17
|
+
import {
|
|
18
|
+
createAdapterRegistry,
|
|
19
|
+
executionContextOf,
|
|
20
|
+
HARNESS_ORDER,
|
|
21
|
+
ok,
|
|
22
|
+
resolvePathRef,
|
|
23
|
+
rootPathIn,
|
|
24
|
+
type AdapterContext,
|
|
25
|
+
type DiscoverySnapshot,
|
|
26
|
+
type HarnessAdapter,
|
|
27
|
+
type HarnessID,
|
|
28
|
+
type HarnessReadiness,
|
|
29
|
+
type HarnessVerification,
|
|
30
|
+
type PathRef,
|
|
31
|
+
type Profile,
|
|
32
|
+
type ProgramLayout,
|
|
33
|
+
type Result,
|
|
34
|
+
type RootMapping,
|
|
35
|
+
type ServiceManagerID,
|
|
36
|
+
type VerifiedManifest,
|
|
37
|
+
} from "../bootstrap/contracts/index.ts"
|
|
38
|
+
import { claudeVerifyExecutor, createClaudeAdapter, createClaudeHarnessSource, httpsDownloader as claudeDownloader, nodeRunner as claudeNodeRunner, tempScratch, type ProbeRunner as ClaudeProbeRunner } from "../bootstrap/adapters/claude/index.ts"
|
|
39
|
+
import { codexVerifyExecutor, createCodexAdapter, createCodexHarnessSource, httpsDownloader as codexDownloader, nodeProbeRunner as codexNodeRunner, stdioAppServerLauncher, type AppServerLauncher, type ProbeRunner as CodexProbeRunner } from "../bootstrap/adapters/codex/index.ts"
|
|
40
|
+
import {
|
|
41
|
+
createServiceControl,
|
|
42
|
+
nodeServiceExecutors,
|
|
43
|
+
planNodeServices,
|
|
44
|
+
serviceAssets,
|
|
45
|
+
spawnManagerRunner,
|
|
46
|
+
unitPathResolver,
|
|
47
|
+
type LaunchDriver,
|
|
48
|
+
type LaunchReport,
|
|
49
|
+
type ManagerRunner,
|
|
50
|
+
type NodeServiceControl,
|
|
51
|
+
type NodeServicesPlan,
|
|
52
|
+
} from "../bootstrap/adapters/node-services/index.ts"
|
|
53
|
+
import { createOpencodeHarness, type Download, type ProcessRunner } from "../bootstrap/adapters/opencode/index.ts"
|
|
54
|
+
import type { ReadinessGate } from "../bootstrap/adapters/readiness.ts"
|
|
55
|
+
import { discover } from "../bootstrap/discovery/discover.ts"
|
|
56
|
+
import { pathDirs, which } from "../bootstrap/discovery/executables.ts"
|
|
57
|
+
import type { DiscoveryProbe } from "../bootstrap/discovery/probe.ts"
|
|
58
|
+
import { machineEnroll, type EnrollPrompt } from "../bootstrap/enrollment/enroll.ts"
|
|
59
|
+
import type { ConfigCredentialStore } from "../bootstrap/enrollment/store.ts"
|
|
60
|
+
import type { PlanSources } from "../bootstrap/preview/plan.ts"
|
|
61
|
+
import { installedHarnesses, installedServiceUnits, rollbackHistory, type TransactionRecord } from "../bootstrap/profiles/history.ts"
|
|
62
|
+
import { planProfileChange } from "../bootstrap/profiles/upgrade.ts"
|
|
63
|
+
import { builtinExecutors, type HarnessSource, type HubctlSource } from "../bootstrap/transaction/builtin.ts"
|
|
64
|
+
import { createApplier, type Applier } from "../bootstrap/transaction/engine.ts"
|
|
65
|
+
import type { ActionExecutor, ExecutorMap } from "../bootstrap/transaction/executor.ts"
|
|
66
|
+
import { nodeFs, type TxFs } from "../bootstrap/transaction/fs.ts"
|
|
67
|
+
import { fileDigest } from "../bootstrap/transaction/stream.ts"
|
|
68
|
+
import { txFail, txOk } from "../bootstrap/transaction/result.ts"
|
|
69
|
+
import { TxStore } from "../bootstrap/transaction/store.ts"
|
|
70
|
+
import { checkOpenShell } from "../openshell.ts"
|
|
71
|
+
import { enrollmentContributor, harnessLinkContributor, HUBCTL_REF, hubctlContributor, refusing, type InstalledInspector, type PathInspector } from "./contributors.ts"
|
|
72
|
+
import { dispatchByHarness, dispatchHarnessSource } from "./dispatch.ts"
|
|
73
|
+
import { claudeSpokeProbes, codexSpokeStatus, opencodeSpokeProbe, type PresenceOptions } from "./spoke-probe.ts"
|
|
74
|
+
import type { BootstrapState, SetupChoices } from "./state.ts"
|
|
75
|
+
|
|
76
|
+
/** The capabilities that reach outside, replaced in tests. Production defaults are the adapters' own. */
|
|
77
|
+
export interface RuntimePorts {
|
|
78
|
+
readonly opencodeRunner?: ProcessRunner
|
|
79
|
+
readonly opencodeDownload?: Download
|
|
80
|
+
readonly claudeRunner?: ClaudeProbeRunner
|
|
81
|
+
readonly codexRunner?: CodexProbeRunner
|
|
82
|
+
readonly codexAppServer?: AppServerLauncher
|
|
83
|
+
/** The service manager's fixed-call runner (default: launchctl / systemctl --user, spawned without a shell). */
|
|
84
|
+
readonly serviceRunner?: ManagerRunner
|
|
85
|
+
/** The bounded verification launch (launch-session); without one a planned launch is refused at preflight. */
|
|
86
|
+
readonly launchDriver?: LaunchDriver
|
|
87
|
+
/** The bytes of this hubctl (install-artifact copies the running, digest-checked binary). */
|
|
88
|
+
readonly hubctlBytes?: () => Uint8Array | undefined
|
|
89
|
+
readonly fs?: TxFs
|
|
90
|
+
readonly uid?: number
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface RuntimeDeps {
|
|
94
|
+
readonly env: Readonly<Record<string, string | undefined>>
|
|
95
|
+
readonly home: string
|
|
96
|
+
readonly now: () => number
|
|
97
|
+
readonly probe: DiscoveryProbe
|
|
98
|
+
/** How this process runs (programLayout of process.execPath and argv[1]). */
|
|
99
|
+
readonly layout: ProgramLayout
|
|
100
|
+
/** hubctl's own version (the app-server client version). */
|
|
101
|
+
readonly version: string
|
|
102
|
+
readonly manifest?: VerifiedManifest
|
|
103
|
+
readonly store: ConfigCredentialStore
|
|
104
|
+
readonly state: BootstrapState
|
|
105
|
+
readonly fetch?: typeof fetch
|
|
106
|
+
readonly signal?: AbortSignal
|
|
107
|
+
readonly stderr: (s: string) => void
|
|
108
|
+
readonly ports?: RuntimePorts
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Everything one plan was made with: preview and apply build it from the same profile and choices. */
|
|
112
|
+
export interface PlanBundle {
|
|
113
|
+
readonly sources: PlanSources
|
|
114
|
+
/** The node-services plan of the last planning run (its units are what install-service writes). */
|
|
115
|
+
readonly node: () => NodeServicesPlan | undefined
|
|
116
|
+
readonly history: readonly TransactionRecord[]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The consent and readiness choices apply makes (never part of the plan, so never part of its digest). */
|
|
120
|
+
export interface ApplyChoices {
|
|
121
|
+
readonly allowDowngrade?: boolean
|
|
122
|
+
/**
|
|
123
|
+
* v1.2: fail (and roll back) unless every harness is ready (`--require-ready`). Without it a harness that only waits
|
|
124
|
+
* on the user (provider login, spoke) is committed as installed, not ready; an integrity failure still rolls back.
|
|
125
|
+
*/
|
|
126
|
+
readonly requireReady?: boolean
|
|
127
|
+
/** Harnesses that must be ready regardless (the --verify-launch harness: the launch needs it). */
|
|
128
|
+
readonly strict?: readonly HarnessID[]
|
|
129
|
+
/** Sees each harness's classified readiness (the apply report). */
|
|
130
|
+
readonly onReadiness?: (readiness: HarnessReadiness) => void
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The readiness gate of one harness's verify-harness step (contract v1.2): strict only when asked or launched. */
|
|
134
|
+
export function readinessGate(consent: ApplyChoices, harness: HarnessID): ReadinessGate {
|
|
135
|
+
return { requireReady: consent.requireReady === true || (consent.strict ?? []).includes(harness), ...(consent.onReadiness ? { onReadiness: consent.onReadiness } : {}) }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface SetupRuntime {
|
|
139
|
+
readonly deps: RuntimeDeps
|
|
140
|
+
discover(): Result<DiscoverySnapshot>
|
|
141
|
+
plan(profile: Profile, choices: SetupChoices): Promise<PlanBundle>
|
|
142
|
+
executors(bundle: PlanBundle, snapshot: DiscoverySnapshot, consent: ApplyChoices): ExecutorMap
|
|
143
|
+
applier(snapshot: DiscoverySnapshot, executors: ExecutorMap): Applier
|
|
144
|
+
verify(harnesses: readonly HarnessID[]): Promise<HarnessVerification[]>
|
|
145
|
+
/** This host's transactions (oldest first), from the journals and their plans. */
|
|
146
|
+
history(snapshot: DiscoverySnapshot): TransactionRecord[]
|
|
147
|
+
/** The hubctl the services and MCP clients run: user-bin:hubctl when the plan installs it, else this process. */
|
|
148
|
+
serviceLayout(snapshot: DiscoverySnapshot): ProgramLayout
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* What the files a plan would write hold now, read-only through the applier's filesystem: a regular file's digest
|
|
153
|
+
* (in chunks; hubctl is ~105 MB) and whether a path is a symlink to a target, in the snapshot's execution context.
|
|
154
|
+
*/
|
|
155
|
+
export function installedInspector(snapshot: DiscoverySnapshot, fs: TxFs): InstalledInspector {
|
|
156
|
+
const where = (ref: PathRef) => {
|
|
157
|
+
const r = resolvePathRef(ref, snapshot.roots, executionContextOf(snapshot))
|
|
158
|
+
return r.ok ? r.value : undefined
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
sha256: (ref) => {
|
|
162
|
+
const file = where(ref)
|
|
163
|
+
try {
|
|
164
|
+
return file && fs.lstat(file)?.kind === "file" ? fileDigest(fs, file) : undefined
|
|
165
|
+
} catch {
|
|
166
|
+
return undefined
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
linksTo: (ref, target) => {
|
|
170
|
+
const link = where(ref)
|
|
171
|
+
const to = where(target)
|
|
172
|
+
try {
|
|
173
|
+
return link !== undefined && to !== undefined && fs.lstat(link)?.kind === "symlink" && nodePath.resolve(nodePath.dirname(link), fs.readlink(link)) === to
|
|
174
|
+
} catch {
|
|
175
|
+
return false
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Paths and PATH as the probe sees them (read-only), in the snapshot's execution context. */
|
|
182
|
+
export function pathInspector(snapshot: DiscoverySnapshot, probe: DiscoveryProbe): PathInspector {
|
|
183
|
+
const where = (ref: PathRef) => {
|
|
184
|
+
const r = resolvePathRef(ref, snapshot.roots, executionContextOf(snapshot))
|
|
185
|
+
return r.ok ? r.value : undefined
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
where,
|
|
189
|
+
entry: (ref) => {
|
|
190
|
+
const p = where(ref)
|
|
191
|
+
return p ? probe.lstat(p) : "missing"
|
|
192
|
+
},
|
|
193
|
+
linkTarget: (ref) => {
|
|
194
|
+
const p = where(ref)
|
|
195
|
+
if (!p || probe.lstat(p) !== "symlink") return undefined
|
|
196
|
+
const text = probe.readlink(p)
|
|
197
|
+
return text === undefined ? undefined : nodePath.resolve(nodePath.dirname(p), text)
|
|
198
|
+
},
|
|
199
|
+
onPath: (name) => which(probe, pathDirs(probe), name),
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** A manifest that pins nothing (no --manifest): Codex is then only verified, never installed. */
|
|
204
|
+
const NO_PINS = { release: "0.0.0", harnesses: [], artifacts: [] } as unknown as VerifiedManifest
|
|
205
|
+
|
|
206
|
+
export function createSetupRuntime(deps: RuntimeDeps): SetupRuntime {
|
|
207
|
+
const ports = deps.ports ?? {}
|
|
208
|
+
const fs = ports.fs ?? nodeFs
|
|
209
|
+
let snapshot: DiscoverySnapshot | undefined
|
|
210
|
+
const context = (): AdapterContext => ({ env: deps.env, home: deps.home, platform: deps.probe.platform, arch: deps.probe.arch, now: deps.now, ...(deps.signal ? { signal: deps.signal } : {}) })
|
|
211
|
+
const presence: PresenceOptions = { credential: () => deps.store.read(), ...(deps.fetch ? { fetch: deps.fetch } : {}), ...(deps.signal ? { signal: deps.signal } : {}) }
|
|
212
|
+
const floors = deps.state.floors
|
|
213
|
+
|
|
214
|
+
const fresh = (): Result<DiscoverySnapshot> => {
|
|
215
|
+
const r = discover(deps.probe, { now: deps.now() })
|
|
216
|
+
if (r.ok) snapshot = r.value
|
|
217
|
+
return r
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function history(snap: DiscoverySnapshot): TransactionRecord[] {
|
|
221
|
+
try {
|
|
222
|
+
const store = new TxStore(fs, snap.roots, executionContextOf(snap))
|
|
223
|
+
const journals = store.journals()
|
|
224
|
+
const plans = journals.flatMap((j) => store.readPlan(j) ?? [])
|
|
225
|
+
const r = rollbackHistory(journals, { hostFingerprint: snap.host.fingerprint, plans })
|
|
226
|
+
return r.ok ? r.value : []
|
|
227
|
+
} catch {
|
|
228
|
+
return []
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function adapters(installed: ReturnType<typeof installedHarnesses>, allowDowngrade: boolean) {
|
|
233
|
+
const manifest = deps.manifest
|
|
234
|
+
const prior = (h: HarnessID) => (installed[h] ? { version: installed[h]!.version, sha256: installed[h]!.sha256 } : undefined)
|
|
235
|
+
const opencode = createOpencodeHarness({
|
|
236
|
+
probe: deps.probe,
|
|
237
|
+
...(manifest ? { manifest } : {}),
|
|
238
|
+
...(ports.opencodeRunner ? { runner: ports.opencodeRunner } : {}),
|
|
239
|
+
...(ports.opencodeDownload ? { download: ports.opencodeDownload } : {}),
|
|
240
|
+
spoke: opencodeSpokeProbe(presence),
|
|
241
|
+
installed: () => prior("opencode"),
|
|
242
|
+
floor: () => floors.harnesses.opencode,
|
|
243
|
+
allowDowngrade,
|
|
244
|
+
})
|
|
245
|
+
const claudeRunner = ports.claudeRunner ?? claudeNodeRunner()
|
|
246
|
+
const claudePrior = prior("claude-code")
|
|
247
|
+
const claudeFloor = floors.harnesses["claude-code"]
|
|
248
|
+
const claude = createClaudeAdapter({
|
|
249
|
+
...(manifest ? { manifest } : {}),
|
|
250
|
+
...(claudePrior ? { installed: claudePrior } : {}),
|
|
251
|
+
...(claudeFloor ? { floor: claudeFloor } : {}),
|
|
252
|
+
allowDowngrade,
|
|
253
|
+
probe: deps.probe,
|
|
254
|
+
probes: { runner: claudeRunner, ...claudeSpokeProbes(presence) },
|
|
255
|
+
})
|
|
256
|
+
const codexPrior = prior("codex")
|
|
257
|
+
const codex = createCodexAdapter({
|
|
258
|
+
manifest: manifest ?? NO_PINS,
|
|
259
|
+
probes: { runner: ports.codexRunner ?? codexNodeRunner, appServer: ports.codexAppServer ?? stdioAppServerLauncher(), spoke: codexSpokeStatus(presence), clientVersion: deps.version },
|
|
260
|
+
snapshot: () => snapshot,
|
|
261
|
+
...(codexPrior ? { previous: codexPrior } : {}),
|
|
262
|
+
})
|
|
263
|
+
const sources: Partial<Record<HarnessID, HarnessSource>> = {}
|
|
264
|
+
if (manifest) {
|
|
265
|
+
if (opencode.source) sources.opencode = opencode.source
|
|
266
|
+
sources["claude-code"] = createClaudeHarnessSource({
|
|
267
|
+
manifest,
|
|
268
|
+
download: claudeDownloader(),
|
|
269
|
+
runner: claudeRunner,
|
|
270
|
+
scratch: tempScratch(),
|
|
271
|
+
platform: deps.probe.platform,
|
|
272
|
+
...(claudePrior ? { installed: claudePrior } : {}),
|
|
273
|
+
...(claudeFloor ? { floor: claudeFloor } : {}),
|
|
274
|
+
allowDowngrade,
|
|
275
|
+
})
|
|
276
|
+
sources.codex = createCodexHarnessSource({ manifest, download: codexDownloader(), installed: () => prior("codex"), floor: () => floors.harnesses.codex, allowDowngrade })
|
|
277
|
+
}
|
|
278
|
+
return { opencode, claude, codex, sources }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function serviceLayout(snap: DiscoverySnapshot): ProgramLayout {
|
|
282
|
+
const root = snap.roots.find((r) => r.id === "user-bin")
|
|
283
|
+
const installs = deps.manifest?.artifacts.some((a) => a.target === snap.host.target)
|
|
284
|
+
if (root && installs) return { kind: "standalone", executable: `${rootPathIn(root, executionContextOf(snap))}/${HUBCTL_REF.rel}` }
|
|
285
|
+
return deps.layout
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function plan(profile: Profile, choices: SetupChoices): Promise<PlanBundle> {
|
|
289
|
+
const snap = snapshot ?? (fresh().ok ? snapshot : undefined)
|
|
290
|
+
const records = snap ? history(snap) : []
|
|
291
|
+
const { opencode, claude, codex } = adapters(installedHarnesses(records), choices.allowDowngrade === true)
|
|
292
|
+
const registry = createAdapterRegistry()
|
|
293
|
+
for (const a of [opencode.adapter, claude, codex] as HarnessAdapter[]) registry.register(a)
|
|
294
|
+
let enrolledWith: string | undefined
|
|
295
|
+
try {
|
|
296
|
+
enrolledWith = (await deps.store.read())?.server
|
|
297
|
+
} catch {}
|
|
298
|
+
let nodePlan: NodeServicesPlan | undefined
|
|
299
|
+
const contributors = [
|
|
300
|
+
hubctlContributor({ ...(deps.manifest ? { manifest: deps.manifest } : {}), ...(snap ? { installed: installedInspector(snap, fs) } : {}) }),
|
|
301
|
+
refusing((input) => {
|
|
302
|
+
const planned = planNodeServices(input, {
|
|
303
|
+
layout: serviceLayout(input.snapshot),
|
|
304
|
+
...(choices.node ? { node: choices.node } : {}),
|
|
305
|
+
...(choices.activate ? { activate: true } : {}),
|
|
306
|
+
environment: deps.env,
|
|
307
|
+
...(choices.launch ? { launch: choices.launch } : {}),
|
|
308
|
+
})
|
|
309
|
+
if (!planned.ok) return planned
|
|
310
|
+
nodePlan = planned.value
|
|
311
|
+
return ok(planned.value.actions)
|
|
312
|
+
}),
|
|
313
|
+
refusing((input) => {
|
|
314
|
+
const before = deps.state.profile
|
|
315
|
+
if (!before) return ok([])
|
|
316
|
+
const manager = input.snapshot.managers.service
|
|
317
|
+
const services = manager === "none" ? undefined : { manager: manager as ServiceManagerID, installed: installedServiceUnits(records, manager as ServiceManagerID) }
|
|
318
|
+
const change = planProfileChange(before, input.profile, { allowDowngrade: choices.allowDowngrade === true, ...(services ? { services } : {}) })
|
|
319
|
+
return change.ok ? ok(change.value.actions) : change
|
|
320
|
+
}),
|
|
321
|
+
...(snap ? [harnessLinkContributor(pathInspector(snap, deps.probe))] : []),
|
|
322
|
+
enrollmentContributor({ ...(choices.server ? { server: choices.server } : {}), machineName: choices.machineName ?? "machine", enrolledWith }),
|
|
323
|
+
]
|
|
324
|
+
return { sources: { adapters: registry, contributors, ...(choices.enrollment ? { enrollment: choices.enrollment } : {}) }, node: () => nodePlan, history: records }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function control(snap: DiscoverySnapshot): NodeServiceControl | undefined {
|
|
328
|
+
if (snap.managers.service === "none") return undefined
|
|
329
|
+
let run = ports.serviceRunner
|
|
330
|
+
if (!run) {
|
|
331
|
+
const systemctl = which(deps.probe, [...pathDirs(deps.probe), "/usr/bin", "/bin"], "systemctl") ?? "/usr/bin/systemctl"
|
|
332
|
+
run = spawnManagerRunner({ programs: { launchctl: "/bin/launchctl", systemctl }, env: deps.env })
|
|
333
|
+
}
|
|
334
|
+
return createServiceControl({ run, uid: ports.uid ?? deps.probe.uid, unitPath: unitPathResolver(snap.roots, executionContextOf(snap)) })
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function hubctlSource(): HubctlSource | undefined {
|
|
338
|
+
const manifest = deps.manifest
|
|
339
|
+
if (!manifest) return undefined
|
|
340
|
+
return {
|
|
341
|
+
manifest,
|
|
342
|
+
fetch: async () => {
|
|
343
|
+
const bytes = ports.hubctlBytes?.()
|
|
344
|
+
if (bytes) return bytes
|
|
345
|
+
// The standalone executable itself (~105 MB): the executor digests it and the engine copies it in chunks,
|
|
346
|
+
// checking the digest again while it copies; it is never read whole.
|
|
347
|
+
if (deps.layout.kind === "standalone") return { path: realpathSync(deps.layout.executable) }
|
|
348
|
+
throw new Error("this hubctl is not the standalone release binary; run the hubctl you downloaded from the release to install it")
|
|
349
|
+
},
|
|
350
|
+
...(floors.hubctl ? { floor: floors.hubctl } : {}),
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function enrollExecutor(): ActionExecutor<"enroll-machine"> {
|
|
355
|
+
const ref: PathRef = { root: "config", rel: "hub/config.json" }
|
|
356
|
+
return {
|
|
357
|
+
kind: "enroll-machine",
|
|
358
|
+
plan: () => ({ class: "credential", path: ref }),
|
|
359
|
+
check: async (_action, ctx) =>
|
|
360
|
+
ctx.target === deps.store.location ? txOk(undefined) : txFail("usage", `the machine credential lives in ${deps.store.location} (HUB_SPOKE_CONFIG); enroll it separately with hubctl machine enroll`),
|
|
361
|
+
change: async (action) =>
|
|
362
|
+
txOk({
|
|
363
|
+
type: "effect",
|
|
364
|
+
perform: async () => {
|
|
365
|
+
const r = await machineEnroll({
|
|
366
|
+
server: action.server,
|
|
367
|
+
machineName: action.machineName,
|
|
368
|
+
store: deps.store,
|
|
369
|
+
...(deps.fetch ? { fetch: deps.fetch } : {}),
|
|
370
|
+
...(deps.signal ? { signal: deps.signal } : {}),
|
|
371
|
+
onPrompt: (p: EnrollPrompt) => deps.stderr(enrollPromptText(p)),
|
|
372
|
+
})
|
|
373
|
+
return r.envelope.ok ? txOk(undefined) : txFail(r.envelope.error!.code, r.envelope.error!.message)
|
|
374
|
+
},
|
|
375
|
+
}),
|
|
376
|
+
undo: async () => {
|
|
377
|
+
try {
|
|
378
|
+
await deps.store.clear()
|
|
379
|
+
return txOk(undefined)
|
|
380
|
+
} catch (error) {
|
|
381
|
+
return txFail("internal", (error as Error).message)
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const verifyRuntime: ActionExecutor<"verify-runtime"> = {
|
|
388
|
+
kind: "verify-runtime",
|
|
389
|
+
plan: () => ({ class: "external" }),
|
|
390
|
+
change: async () =>
|
|
391
|
+
txOk({
|
|
392
|
+
type: "effect",
|
|
393
|
+
perform: async () => {
|
|
394
|
+
const report = await checkOpenShell({ env: deps.env as Record<string, string | undefined>, home: deps.home })
|
|
395
|
+
return report.ok ? txOk(undefined) : txFail("harness_not_ready", "OpenShell is not ready (`hubctl node openshell check` shows why)")
|
|
396
|
+
},
|
|
397
|
+
}),
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function executors(bundle: PlanBundle, snap: DiscoverySnapshot, consent: ApplyChoices): ExecutorMap {
|
|
401
|
+
const allowDowngrade = consent.allowDowngrade === true
|
|
402
|
+
const gate = (h: HarnessID) => readinessGate(consent, h)
|
|
403
|
+
const { opencode, claude, codex, sources } = adapters(installedHarnesses(bundle.history), allowDowngrade)
|
|
404
|
+
const services = control(snap)
|
|
405
|
+
const hubctl = hubctlSource()
|
|
406
|
+
const builtin = builtinExecutors({
|
|
407
|
+
// Lazy: the node plan exists once apply's re-plan ran the contributor; only its exact units are written.
|
|
408
|
+
renderService: (action) => {
|
|
409
|
+
const node = bundle.node()
|
|
410
|
+
return node ? serviceAssets(node).renderService(action) : undefined
|
|
411
|
+
},
|
|
412
|
+
...(services ? { services } : {}),
|
|
413
|
+
...(hubctl ? { hubctl } : {}),
|
|
414
|
+
...(deps.manifest ? { harness: dispatchHarnessSource({ manifest: deps.manifest, sources, allowDowngrade }) } : {}),
|
|
415
|
+
})
|
|
416
|
+
const oc = opencode.executors(context, gate("opencode"))
|
|
417
|
+
const launchProbe = ports.launchDriver ? { driver: ports.launchDriver, ...(deps.signal ? { signal: deps.signal } : {}), onReport: (r: LaunchReport) => deps.stderr(launchReportText(r)) } : undefined
|
|
418
|
+
return {
|
|
419
|
+
...builtin,
|
|
420
|
+
...nodeServiceExecutors({ ...(services ? { control: services } : {}), ...(launchProbe ? { probe: launchProbe } : {}) }),
|
|
421
|
+
"register-mcp-server": dispatchByHarness("register-mcp-server", { opencode: oc["register-mcp-server"]! }),
|
|
422
|
+
"verify-harness": dispatchByHarness("verify-harness", {
|
|
423
|
+
opencode: oc["verify-harness"]!,
|
|
424
|
+
"claude-code": claudeVerifyExecutor({ verify: () => claude.verify(context()), now: deps.now, ...gate("claude-code") }),
|
|
425
|
+
codex: codexVerifyExecutor({ verify: (ctx) => codex.verify(ctx), context, ...gate("codex") }),
|
|
426
|
+
}),
|
|
427
|
+
"enroll-machine": enrollExecutor(),
|
|
428
|
+
"verify-runtime": verifyRuntime,
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function applier(snap: DiscoverySnapshot, map: ExecutorMap): Applier {
|
|
433
|
+
return createApplier({ roots: snap.roots, context: executionContextOf(snap), host: { fingerprint: snap.host.fingerprint, target: snap.host.target }, executors: map, fs, now: deps.now, keepBackups: true })
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function verify(harnesses: readonly HarnessID[]): Promise<HarnessVerification[]> {
|
|
437
|
+
const snap = fresh()
|
|
438
|
+
const { opencode, claude, codex } = adapters(snap.ok ? installedHarnesses(history(snap.value)) : {}, false)
|
|
439
|
+
const by: Record<HarnessID, HarnessAdapter> = { opencode: opencode.adapter, "claude-code": claude, codex }
|
|
440
|
+
const out: HarnessVerification[] = []
|
|
441
|
+
for (const id of HARNESS_ORDER) if (harnesses.includes(id)) out.push(await by[id].verify(context()))
|
|
442
|
+
return out
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return { deps, discover: fresh, plan, executors, applier, verify, history, serviceLayout }
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** What `machine enroll` (and an inline enrollment) shows while it waits; never a credential. */
|
|
449
|
+
export function enrollPromptText(p: EnrollPrompt): string {
|
|
450
|
+
if (p.autoApproved) return "hubctl: the local hub approved this machine by itself\n"
|
|
451
|
+
return `hubctl: approve this machine on the hub: ${p.approveURL}\nhubctl: and check it shows the code ${p.userCode} (it expires at ${new Date(p.expiresAt).toISOString()})\n${p.warning ? `hubctl: warning: ${p.warning}\n` : ""}`
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export function launchReportText(r: LaunchReport): string {
|
|
455
|
+
return `${r.stages.map((s) => `hubctl: verification launch, ${s.stage}: ${s.ok ? "ok" : "failed"}${s.detail ? ` (${s.detail})` : ""}`).join("\n")}\n`
|
|
456
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// spoke-connected readiness (HBS-13 integration item 6): whether the hub sees a harness's spoke of this machine.
|
|
2
|
+
// One read-only GET /spoke/presence with the machine credential (src/central/http.ts spokePresence), under the
|
|
3
|
+
// enrollment policy: https, or plain http to a loopback hub only (checkServerURL); never across a redirect, never to
|
|
4
|
+
// an insecure stored hub, and nothing at all without a stored credential. The answer lists only the harness, the
|
|
5
|
+
// evidence level and the features of each connected spoke; details never carry the token.
|
|
6
|
+
import type { MachineCredential } from "../bootstrap/contracts/index.ts"
|
|
7
|
+
import type { SpokeHello } from "../bootstrap/adapters/claude/verify.ts"
|
|
8
|
+
import type { SpokeStatus } from "../bootstrap/adapters/codex/verify.ts"
|
|
9
|
+
import type { SpokeProbe } from "../bootstrap/adapters/opencode/verify.ts"
|
|
10
|
+
import { checkServerURL } from "../bootstrap/enrollment/policy.ts"
|
|
11
|
+
|
|
12
|
+
export interface ConnectedSpoke {
|
|
13
|
+
readonly harness: string
|
|
14
|
+
readonly evidence: string
|
|
15
|
+
readonly features: readonly string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type Presence = { readonly ok: true; readonly spokes: readonly ConnectedSpoke[] } | { readonly ok: false; readonly detail: string }
|
|
19
|
+
|
|
20
|
+
export interface PresenceOptions {
|
|
21
|
+
/** The stored machine credential (CredentialStore.read). */
|
|
22
|
+
readonly credential: () => Promise<MachineCredential | undefined>
|
|
23
|
+
readonly fetch?: typeof fetch
|
|
24
|
+
readonly timeoutMs?: number
|
|
25
|
+
readonly signal?: AbortSignal
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const TIMEOUT_MS = 10_000
|
|
29
|
+
const word = /^[A-Za-z0-9._-]{1,64}$/
|
|
30
|
+
|
|
31
|
+
function parse(body: unknown): readonly ConnectedSpoke[] | undefined {
|
|
32
|
+
const spokes = (body as { spokes?: unknown } | null)?.spokes
|
|
33
|
+
if (!Array.isArray(spokes)) return undefined
|
|
34
|
+
const out: ConnectedSpoke[] = []
|
|
35
|
+
for (const s of spokes) {
|
|
36
|
+
const { harness, evidence, features } = (s ?? {}) as Record<string, unknown>
|
|
37
|
+
if (typeof harness !== "string" || !word.test(harness) || typeof evidence !== "string" || !word.test(evidence)) return undefined
|
|
38
|
+
const list = Array.isArray(features) ? features.filter((f): f is string => typeof f === "string" && word.test(f)) : []
|
|
39
|
+
out.push({ harness, evidence, features: list })
|
|
40
|
+
}
|
|
41
|
+
return out
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The hub's answer to "which spokes of this machine are connected?". */
|
|
45
|
+
export async function spokePresence(options: PresenceOptions): Promise<Presence> {
|
|
46
|
+
let credential: MachineCredential | undefined
|
|
47
|
+
try {
|
|
48
|
+
credential = await options.credential()
|
|
49
|
+
} catch {
|
|
50
|
+
return { ok: false, detail: "the machine credential store cannot be read" }
|
|
51
|
+
}
|
|
52
|
+
if (!credential) return { ok: false, detail: "this machine is not enrolled (run `hubctl machine enroll`)" }
|
|
53
|
+
const server = checkServerURL(credential.server)
|
|
54
|
+
if (!server) return { ok: false, detail: "the stored hub URL is not https (plain http only on loopback), so the hub was not asked" }
|
|
55
|
+
const signals = [AbortSignal.timeout(options.timeoutMs ?? TIMEOUT_MS), ...(options.signal ? [options.signal] : [])]
|
|
56
|
+
let response: Response
|
|
57
|
+
try {
|
|
58
|
+
response = await (options.fetch ?? fetch)(`${server}/spoke/presence`, { method: "GET", headers: { authorization: `Bearer ${credential.token}` }, redirect: "manual", signal: AbortSignal.any(signals) })
|
|
59
|
+
} catch (cause) {
|
|
60
|
+
return { ok: false, detail: `could not reach ${server} (${(cause as { name?: string })?.name ?? "error"})` }
|
|
61
|
+
}
|
|
62
|
+
if (response.status !== 200) {
|
|
63
|
+
void response.body?.cancel().catch(() => {})
|
|
64
|
+
if (response.status === 401 || response.status === 403) return { ok: false, detail: `${server} rejected the machine credential (revoked or unknown)` }
|
|
65
|
+
if (response.status === 404) return { ok: false, detail: `${server} does not answer spoke presence queries (an older hub)` }
|
|
66
|
+
return { ok: false, detail: `${server} could not answer (HTTP ${response.status})` }
|
|
67
|
+
}
|
|
68
|
+
let body: unknown
|
|
69
|
+
try {
|
|
70
|
+
body = await response.json()
|
|
71
|
+
} catch {
|
|
72
|
+
return { ok: false, detail: `${server} sent an invalid presence answer` }
|
|
73
|
+
}
|
|
74
|
+
const spokes = parse(body)
|
|
75
|
+
return spokes ? { ok: true, spokes } : { ok: false, detail: `${server} sent an invalid presence answer` }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const LABEL: Readonly<Record<string, string>> = { opencode: "OpenCode", "claude-code": "Claude Code", codex: "Codex" }
|
|
79
|
+
|
|
80
|
+
async function find(options: PresenceOptions, harness: string): Promise<{ readonly spoke?: ConnectedSpoke; readonly detail: string }> {
|
|
81
|
+
const p = await spokePresence(options)
|
|
82
|
+
if (!p.ok) return { detail: p.detail }
|
|
83
|
+
const spoke = p.spokes.find((s) => s.harness === harness)
|
|
84
|
+
return spoke ? { spoke, detail: `the hub sees this machine's ${LABEL[harness]} spoke (${spoke.evidence})` } : { detail: `the hub does not see this machine's ${LABEL[harness]} spoke connected` }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** OpenCode's SpokeProbe. */
|
|
88
|
+
export function opencodeSpokeProbe(options: PresenceOptions): SpokeProbe {
|
|
89
|
+
return {
|
|
90
|
+
async connected() {
|
|
91
|
+
const r = await find(options, "opencode")
|
|
92
|
+
return { ok: r.spoke !== undefined, detail: r.detail }
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The Claude Code adapter's spoke and conformance probes: the hooks spoke must be connected as a hooks spoke. */
|
|
98
|
+
export function claudeSpokeProbes(options: PresenceOptions): { spoke(): Promise<SpokeHello>; conformance(): Promise<{ readonly ok: boolean; readonly detail?: string }> } {
|
|
99
|
+
return {
|
|
100
|
+
async spoke() {
|
|
101
|
+
const r = await find(options, "claude-code")
|
|
102
|
+
return r.spoke ? { connected: true, harness: r.spoke.harness, evidence: r.spoke.evidence, features: r.spoke.features } : { connected: false }
|
|
103
|
+
},
|
|
104
|
+
async conformance() {
|
|
105
|
+
const r = await find(options, "claude-code")
|
|
106
|
+
if (!r.spoke) return { ok: false, detail: r.detail }
|
|
107
|
+
return r.spoke.evidence === "hooks" ? { ok: true, detail: "connected as a hooks spoke" } : { ok: false, detail: `the Claude Code spoke reports ${r.spoke.evidence} evidence, not hooks` }
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The Codex adapter's SpokeStatus. */
|
|
113
|
+
export function codexSpokeStatus(options: PresenceOptions): SpokeStatus {
|
|
114
|
+
return {
|
|
115
|
+
async codex() {
|
|
116
|
+
const r = await find(options, "codex")
|
|
117
|
+
return { connected: r.spoke !== undefined, detail: r.detail }
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
}
|