@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,250 @@
|
|
|
1
|
+
// The Claude Code HarnessAdapter (contracts §3.10): a pure planner over the discovery snapshot and the verified hub
|
|
2
|
+
// manifest, discovery of the claude binary through the read-only DiscoveryProbe, and verification through real
|
|
3
|
+
// probes (verify.ts). It never gets a shell.
|
|
4
|
+
//
|
|
5
|
+
// Plan (in planSetup's phase order):
|
|
6
|
+
// install-harness the artifact hub's signed manifest pins for this target, into its own immutable
|
|
7
|
+
// directory data:hub/harnesses/claude-code/<version> (downgrade policy: decideHarnessInstall)
|
|
8
|
+
// select-harness-version makes that version current (rollback: the previous version is current again)
|
|
9
|
+
// merge-config hub's own config:hub/claude-code.json gets {"binary": <current/claude>}, the one
|
|
10
|
+
// hub-owned entry that points the existing spoke (daemon, `hub claude`, the plugin it
|
|
11
|
+
// writes) at the hub-installed binary. Created by hub, so a rollback removes it; a
|
|
12
|
+
// symlinked hub config dir becomes a manual edit (planSetup).
|
|
13
|
+
// verify-harness version-exec, provider-auth, spoke-connected (install is never readiness)
|
|
14
|
+
//
|
|
15
|
+
// ~/.claude and ~/.claude.json are never planned: the existing integration loads the hub plugin with --plugin-dir
|
|
16
|
+
// and never edits the user's Claude Code files (docs/harnesses.md). A Claude Code the user installed themselves is
|
|
17
|
+
// kept and only verified, unless the caller prefers a hub-managed install.
|
|
18
|
+
import {
|
|
19
|
+
checkHarnessPin,
|
|
20
|
+
decideHarnessInstall,
|
|
21
|
+
executionContextOf,
|
|
22
|
+
fail,
|
|
23
|
+
harnessCurrentRef,
|
|
24
|
+
harnessVersionRef,
|
|
25
|
+
ok,
|
|
26
|
+
REQUIRED_PROBES,
|
|
27
|
+
resolvePathRef,
|
|
28
|
+
rootPathIn,
|
|
29
|
+
type Action,
|
|
30
|
+
type DiscoverySnapshot,
|
|
31
|
+
type ExecutionContext,
|
|
32
|
+
type HarnessAdapter,
|
|
33
|
+
type HarnessPresence,
|
|
34
|
+
type InstallHarness,
|
|
35
|
+
type MergeConfig,
|
|
36
|
+
type PathRef,
|
|
37
|
+
type PlanInput,
|
|
38
|
+
type Precondition,
|
|
39
|
+
type Result,
|
|
40
|
+
type RootMapping,
|
|
41
|
+
type SelectHarnessVersion,
|
|
42
|
+
type VerifiedManifest,
|
|
43
|
+
type VerifyHarness,
|
|
44
|
+
} from "../../contracts/index.ts"
|
|
45
|
+
import { classifyExecutable, HARNESS_BINARIES, installOf, pathDirs, which } from "../../discovery/executables.ts"
|
|
46
|
+
import { discoverRoots } from "../../discovery/host.ts"
|
|
47
|
+
import type { DiscoveryProbe } from "../../discovery/probe.ts"
|
|
48
|
+
import type { ProbeRunner } from "./ports.ts"
|
|
49
|
+
import { verifyClaude, type ClaudeBinary, type ClaudeProbes } from "./verify.ts"
|
|
50
|
+
|
|
51
|
+
const HARNESS = "claude-code" as const
|
|
52
|
+
const BINARY = HARNESS_BINARIES[HARNESS]
|
|
53
|
+
|
|
54
|
+
/** Hub's own Claude Code integration config (hub-owned; never a Claude Code file). */
|
|
55
|
+
export const CLAUDE_CONFIG_REF: PathRef = Object.freeze({ root: "config", rel: "hub/claude-code.json" })
|
|
56
|
+
/** The key in it naming the claude binary the spoke runs. */
|
|
57
|
+
export const CLAUDE_CONFIG_POINTER = "/binary"
|
|
58
|
+
/** The hub-installed binary, inside the `current` link. */
|
|
59
|
+
export const currentBinaryRel = `${harnessCurrentRef(HARNESS).rel}/${BINARY}`
|
|
60
|
+
|
|
61
|
+
export type ClaudeDecision = "none" | "user-managed" | "keep" | "select" | "install" | "upgrade" | "downgrade"
|
|
62
|
+
|
|
63
|
+
export interface ClaudePlan {
|
|
64
|
+
readonly decision: ClaudeDecision
|
|
65
|
+
readonly actions: readonly Action[]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ClaudeAdapterOptions {
|
|
69
|
+
/** The hub release manifest, verified against the shipped trust root. Without it nothing is installed. */
|
|
70
|
+
readonly manifest?: VerifiedManifest
|
|
71
|
+
/** What hub installed before, from the history (installedHarnesses). */
|
|
72
|
+
readonly installed?: { readonly version: string; readonly sha256: string }
|
|
73
|
+
/** The highest Claude Code floor this machine has seen. */
|
|
74
|
+
readonly floor?: string
|
|
75
|
+
/** Explicit --allow-downgrade. */
|
|
76
|
+
readonly allowDowngrade?: boolean
|
|
77
|
+
/** existing (default): keep a Claude Code the user installed; hub: install the pinned one beside it. */
|
|
78
|
+
readonly prefer?: "existing" | "hub"
|
|
79
|
+
/** The state of a config file discovery does not record (config:hub/claude-code.json), by its path. */
|
|
80
|
+
readonly configState?: (path: string) => Precondition | undefined
|
|
81
|
+
/** Read-only host access for discover() and the binary readiness runs against. */
|
|
82
|
+
readonly probe?: DiscoveryProbe
|
|
83
|
+
/** The roots (default: discovered through the probe) and where hubctl runs (default host). */
|
|
84
|
+
readonly roots?: readonly RootMapping[]
|
|
85
|
+
readonly context?: ExecutionContext
|
|
86
|
+
/** Real probes (verify.ts); without a runner nothing can be verified. */
|
|
87
|
+
readonly probes?: { readonly runner: ProbeRunner } & Pick<ClaudeProbes, "spoke" | "conformance">
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const VERB = { install: "Install", upgrade: "Upgrade", downgrade: "Downgrade" } as const
|
|
91
|
+
|
|
92
|
+
const verifyAction = (): VerifyHarness => ({
|
|
93
|
+
id: `verify-${HARNESS}`,
|
|
94
|
+
kind: "verify-harness",
|
|
95
|
+
escalation: "user",
|
|
96
|
+
summary: "Verify Claude Code runs, is logged in, and its spoke connects to the hub",
|
|
97
|
+
harness: HARNESS,
|
|
98
|
+
probes: REQUIRED_PROBES[HARNESS].filter((p): p is Exclude<typeof p, "presence"> => p !== "presence"),
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
function selectAction(version: string, previous?: { readonly version: string; readonly sha256: string }): SelectHarnessVersion {
|
|
102
|
+
return {
|
|
103
|
+
id: `select-${HARNESS}`,
|
|
104
|
+
kind: "select-harness-version",
|
|
105
|
+
escalation: "user",
|
|
106
|
+
summary: `Make Claude Code ${version} the current version`,
|
|
107
|
+
harness: HARNESS,
|
|
108
|
+
version,
|
|
109
|
+
path: harnessCurrentRef(HARNESS),
|
|
110
|
+
target: harnessVersionRef(HARNESS, version),
|
|
111
|
+
...(previous ? { previous: { ...previous } } : {}),
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Where a root is for the context the plan runs in. */
|
|
116
|
+
function rootPath(snapshot: DiscoverySnapshot, id: string): string | undefined {
|
|
117
|
+
const root = snapshot.roots.find((r) => r.id === id)
|
|
118
|
+
return root ? rootPathIn(root, executionContextOf(snapshot)) : undefined
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function configAction(snapshot: DiscoverySnapshot, options: ClaudeAdapterOptions): Result<MergeConfig> {
|
|
122
|
+
const data = rootPath(snapshot, "data")
|
|
123
|
+
const where = resolvePathRef(CLAUDE_CONFIG_REF, snapshot.roots, executionContextOf(snapshot))
|
|
124
|
+
if (!data || !where.ok) return fail("/roots", "unmapped_root", "this host has no data or config root for the hub-installed Claude Code")
|
|
125
|
+
const known = snapshot.configs.find((c) => c.path === where.value)
|
|
126
|
+
const precondition: Precondition = known ? (known.sha256 ? { exists: true, sha256: known.sha256 } : { exists: false }) : (options.configState?.(where.value) ?? { exists: false })
|
|
127
|
+
return ok({
|
|
128
|
+
id: `${HARNESS}-config`,
|
|
129
|
+
kind: "merge-config",
|
|
130
|
+
escalation: "user",
|
|
131
|
+
summary: "Point hub's Claude Code spoke at the hub-installed claude",
|
|
132
|
+
path: CLAUDE_CONFIG_REF,
|
|
133
|
+
format: "json",
|
|
134
|
+
owner: "hub",
|
|
135
|
+
precondition,
|
|
136
|
+
set: [{ pointer: CLAUDE_CONFIG_POINTER, value: `${data}/${currentBinaryRel}` }],
|
|
137
|
+
preserveUnknown: true,
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Plans Claude Code for this snapshot and profile, with the reason when it cannot. Pure and deterministic. */
|
|
142
|
+
export function planClaude(input: PlanInput, options: ClaudeAdapterOptions): Result<ClaudePlan> {
|
|
143
|
+
const { snapshot, profile } = input
|
|
144
|
+
if (!profile.harnesses.includes(HARNESS)) return ok({ decision: "none", actions: [] })
|
|
145
|
+
const presence = snapshot.harnesses.find((h) => h.id === HARNESS)
|
|
146
|
+
const verify = verifyAction()
|
|
147
|
+
if (presence?.installed && presence.installMethod !== "hub" && options.prefer !== "hub") return ok({ decision: "user-managed", actions: [verify] })
|
|
148
|
+
const target = snapshot.host.target
|
|
149
|
+
const manifest = options.manifest
|
|
150
|
+
const pin = manifest?.harnesses.find((p) => p.harness === HARNESS && p.target === target)
|
|
151
|
+
if (!manifest || !pin) return fail("/harnesses/claude-code", "unpinned_artifact", manifest ? `hub ${manifest.release} pins no Claude Code for ${target}` : "no verified hub manifest to install Claude Code from")
|
|
152
|
+
const decided = decideHarnessInstall(pin, { ...(options.installed ? { installed: options.installed } : {}), ...(options.floor ? { floor: options.floor } : {}), allowDowngrade: options.allowDowngrade })
|
|
153
|
+
if (!decided.ok) return decided
|
|
154
|
+
const config = configAction(snapshot, options)
|
|
155
|
+
if (!config.ok) return config
|
|
156
|
+
const currentIsPinned = presence?.installed === true && presence.installMethod === "hub" && presence.version === pin.version
|
|
157
|
+
if (decided.value === "reinstall" || (currentIsPinned && !options.installed)) {
|
|
158
|
+
if (currentIsPinned) return ok({ decision: "keep", actions: [verify] })
|
|
159
|
+
return ok({ decision: "select", actions: [selectAction(pin.version), config.value, verify] })
|
|
160
|
+
}
|
|
161
|
+
const previous = options.installed
|
|
162
|
+
const install: InstallHarness = {
|
|
163
|
+
id: `install-${HARNESS}`,
|
|
164
|
+
kind: "install-harness",
|
|
165
|
+
escalation: "user",
|
|
166
|
+
summary: `${VERB[decided.value]} Claude Code ${pin.version} for ${pin.target} (pinned by hub ${manifest.release})`,
|
|
167
|
+
harness: HARNESS,
|
|
168
|
+
version: pin.version,
|
|
169
|
+
target: pin.target,
|
|
170
|
+
source: { manifest: manifest.release, url: pin.url, file: pin.file },
|
|
171
|
+
size: pin.size,
|
|
172
|
+
sha256: pin.sha256,
|
|
173
|
+
evidence: [...pin.evidence],
|
|
174
|
+
path: harnessVersionRef(HARNESS, pin.version),
|
|
175
|
+
owner: "hub",
|
|
176
|
+
rollback: "restore-previous",
|
|
177
|
+
...(previous ? { previous: { ...previous } } : {}),
|
|
178
|
+
}
|
|
179
|
+
const pinned = checkHarnessPin(manifest, install)
|
|
180
|
+
if (!pinned.ok) return pinned
|
|
181
|
+
return ok({ decision: decided.value, actions: [install, selectAction(pin.version, previous), config.value, verify] })
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface ClaudeAdapter extends HarnessAdapter {
|
|
185
|
+
/** The binary readiness runs against (hub's current version, else the program `claude` on PATH resolves to). */
|
|
186
|
+
binary(): ClaudeBinary
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function createClaudeAdapter(options: ClaudeAdapterOptions): ClaudeAdapter {
|
|
190
|
+
const context = options.context ?? "host"
|
|
191
|
+
const roots = (): readonly RootMapping[] => options.roots ?? (options.probe ? (discoverRoots(options.probe) ?? []) : [])
|
|
192
|
+
const at = (id: string) => {
|
|
193
|
+
const r = roots().find((x) => x.id === id)
|
|
194
|
+
return r ? rootPathIn(r, context) : undefined
|
|
195
|
+
}
|
|
196
|
+
const current = () => {
|
|
197
|
+
const data = at("data")
|
|
198
|
+
return data ? `${data}/${currentBinaryRel}` : undefined
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** `claude` on PATH (or in upstream's own install directories), as discovery finds it. */
|
|
202
|
+
const onPath = (probe: DiscoveryProbe, home: string) => {
|
|
203
|
+
const dirs = pathDirs(probe)
|
|
204
|
+
const known = [`${home}/.local/bin`, `${home}/.claude/local`].filter((d) => !dirs.includes(d))
|
|
205
|
+
const path = which(probe, [...dirs, ...known], BINARY)
|
|
206
|
+
return path ? classifyExecutable(probe, BINARY, path) : undefined
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const adapter: ClaudeAdapter = {
|
|
210
|
+
id: HARNESS,
|
|
211
|
+
async discover(ctx): Promise<HarnessPresence> {
|
|
212
|
+
const none: HarnessPresence = { id: HARNESS, installed: false, health: { state: "unverified", probes: [] } }
|
|
213
|
+
const probe = options.probe
|
|
214
|
+
if (!probe) return none
|
|
215
|
+
const home = at("home") ?? ctx.home
|
|
216
|
+
const shim = onPath(probe, home)
|
|
217
|
+
const hub = current()
|
|
218
|
+
const found = shim ?? (hub && probe.executable(hub) ? classifyExecutable(probe, BINARY, hub) : undefined)
|
|
219
|
+
if (!found) return none
|
|
220
|
+
const install = installOf(probe, HARNESS, found, roots(), context)
|
|
221
|
+
return { id: HARNESS, installed: true, path: found.path, ...(install.version ? { version: install.version } : {}), installMethod: install.installMethod, health: { state: "unverified", probes: [] } }
|
|
222
|
+
},
|
|
223
|
+
plan(input) {
|
|
224
|
+
const planned = planClaude(input, options)
|
|
225
|
+
// Unplannable (unpinned, refused downgrade): verify only, so the setup reports Claude Code as not ready.
|
|
226
|
+
return planned.ok ? planned.value.actions : input.profile.harnesses.includes(HARNESS) ? [verifyAction()] : []
|
|
227
|
+
},
|
|
228
|
+
async verify(ctx) {
|
|
229
|
+
const probes = options.probes
|
|
230
|
+
if (!probes) return verifyClaude({ runner: { run: async () => ({ kind: "error", message: "no runner" }) }, binary: () => ({ problem: "this hubctl has no probe runner" }) }, ctx)
|
|
231
|
+
return verifyClaude({ ...probes, binary: () => adapter.binary() }, ctx)
|
|
232
|
+
},
|
|
233
|
+
binary(): ClaudeBinary {
|
|
234
|
+
const probe = options.probe
|
|
235
|
+
if (!probe) return { problem: "no read-only host probe to find claude" }
|
|
236
|
+
const hub = current()
|
|
237
|
+
if (hub && probe.executable(hub)) {
|
|
238
|
+
const version = installOf(probe, HARNESS, classifyExecutable(probe, BINARY, hub), roots(), context).version
|
|
239
|
+
return { path: hub, ...(version ? { version } : {}) }
|
|
240
|
+
}
|
|
241
|
+
const shim = onPath(probe, at("home") ?? probe.home)
|
|
242
|
+
if (!shim) return { problem: "Claude Code is not installed (no claude on PATH or in hub's harness directory)" }
|
|
243
|
+
if (shim.kind === "container-shim") return { problem: `${shim.path} is a shim into a container; readiness is checked against a binary on this host` }
|
|
244
|
+
const path = shim.resolvesTo ?? shim.path
|
|
245
|
+
const version = installOf(probe, HARNESS, shim, roots(), context).version
|
|
246
|
+
return { path, ...(version ? { version } : {}) }
|
|
247
|
+
},
|
|
248
|
+
}
|
|
249
|
+
return adapter
|
|
250
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// The Claude Code bootstrap adapter (HBS-08): planning and discovery (adapter.ts), the pinned-artifact source with
|
|
2
|
+
// optional upstream evidence (source.ts, openpgp.ts), real readiness probes (verify.ts), and the injected ports
|
|
3
|
+
// (ports.ts) with their default Node implementations (node.ts). Integration into the CLI is HBS-13's.
|
|
4
|
+
import type { HarnessVerification } from "../../contracts/index.ts"
|
|
5
|
+
import { installHarnessExecutor, type HarnessSource } from "../../transaction/builtin.ts"
|
|
6
|
+
import type { ActionExecutor } from "../../transaction/executor.ts"
|
|
7
|
+
import type { ReadinessGate } from "../readiness.ts"
|
|
8
|
+
import { claudeVerifyExecutor } from "./verify.ts"
|
|
9
|
+
|
|
10
|
+
export * from "./adapter.ts"
|
|
11
|
+
export * from "./node.ts"
|
|
12
|
+
export * from "./openpgp.ts"
|
|
13
|
+
export * from "./ports.ts"
|
|
14
|
+
export * from "./source.ts"
|
|
15
|
+
export * from "./verify.ts"
|
|
16
|
+
|
|
17
|
+
export interface ClaudeExecutorOptions extends ReadinessGate {
|
|
18
|
+
/** createClaudeHarnessSource(...) */
|
|
19
|
+
readonly source: HarnessSource
|
|
20
|
+
/** adapter.verify(context) */
|
|
21
|
+
readonly verify: () => Promise<HarnessVerification>
|
|
22
|
+
readonly now: () => number
|
|
23
|
+
readonly onVerified?: (verification: HarnessVerification) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The executors the Claude Code plan needs beyond the built-in local ones: install-harness from the Claude Code
|
|
28
|
+
* source, and verify-harness for claude-code. The engine has one executor per kind, so with several harnesses the
|
|
29
|
+
* integrator dispatches these by `action.harness` (HBS-13).
|
|
30
|
+
*/
|
|
31
|
+
export function claudeExecutors(options: ClaudeExecutorOptions): { readonly "install-harness": ActionExecutor<"install-harness">; readonly "verify-harness": ActionExecutor<"verify-harness"> } {
|
|
32
|
+
return {
|
|
33
|
+
"install-harness": installHarnessExecutor(options.source),
|
|
34
|
+
"verify-harness": claudeVerifyExecutor({
|
|
35
|
+
verify: options.verify,
|
|
36
|
+
now: options.now,
|
|
37
|
+
...(options.onVerified ? { onVerified: options.onVerified } : {}),
|
|
38
|
+
...(options.requireReady !== undefined ? { requireReady: options.requireReady } : {}),
|
|
39
|
+
...(options.onReadiness ? { onReadiness: options.onReadiness } : {}),
|
|
40
|
+
}),
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Default Node implementations of the Claude Code adapter's ports (ports.ts). The integrator (HBS-13) passes them;
|
|
2
|
+
// tests inject fakes.
|
|
3
|
+
//
|
|
4
|
+
// nodeRunner spawn(file, args) with shell: false, exactly the given environment, stdin closed, stderr
|
|
5
|
+
// dropped, stdout capped, and a hard timeout (SIGKILL). The only process starter here.
|
|
6
|
+
// httpsDownloader https only (also after redirects), non-2xx refused, the body streamed and capped
|
|
7
|
+
// (DownloadTooLarge, as soon as it runs past the limit).
|
|
8
|
+
// tempScratch a 0700 directory under the system temp dir with one 0600 file (written as its content
|
|
9
|
+
// streams), removed afterwards.
|
|
10
|
+
import { spawn } from "node:child_process"
|
|
11
|
+
import { closeSync, mkdtempSync, openSync, rmSync, writeSync } from "node:fs"
|
|
12
|
+
import { tmpdir } from "node:os"
|
|
13
|
+
import { join } from "node:path"
|
|
14
|
+
import { chunksOf, responseChunks } from "../../transaction/stream.ts"
|
|
15
|
+
import { DownloadTooLarge, type Downloader, type ProbeRunner, type RunResult, type Scratch } from "./ports.ts"
|
|
16
|
+
|
|
17
|
+
export function nodeRunner(): ProbeRunner {
|
|
18
|
+
return {
|
|
19
|
+
run: (file, args, options) =>
|
|
20
|
+
new Promise<RunResult>((resolve) => {
|
|
21
|
+
let settled = false
|
|
22
|
+
const done = (r: RunResult) => {
|
|
23
|
+
if (settled) return
|
|
24
|
+
settled = true
|
|
25
|
+
clearTimeout(timer)
|
|
26
|
+
resolve(r)
|
|
27
|
+
}
|
|
28
|
+
let child: ReturnType<typeof spawn>
|
|
29
|
+
try {
|
|
30
|
+
child = spawn(file, [...args], { shell: false, env: { ...options.env }, stdio: ["ignore", "pipe", "ignore"], windowsHide: true })
|
|
31
|
+
} catch (error) {
|
|
32
|
+
return resolve({ kind: "error", message: error instanceof Error ? error.message : String(error) })
|
|
33
|
+
}
|
|
34
|
+
const timer = setTimeout(() => {
|
|
35
|
+
child.kill("SIGKILL")
|
|
36
|
+
done({ kind: "timeout" })
|
|
37
|
+
}, options.timeoutMs)
|
|
38
|
+
const chunks: Buffer[] = []
|
|
39
|
+
let size = 0
|
|
40
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
41
|
+
if (size >= options.maxOutputBytes) return
|
|
42
|
+
const part = chunk.subarray(0, options.maxOutputBytes - size)
|
|
43
|
+
chunks.push(part)
|
|
44
|
+
size += part.length
|
|
45
|
+
})
|
|
46
|
+
child.on("error", (error: NodeJS.ErrnoException) => done(error.code === "ENOENT" ? { kind: "not-found" } : { kind: "error", message: error.code ?? "spawn failed" }))
|
|
47
|
+
// Settle once the process exited and its stdout ended (whichever comes last), or on "close".
|
|
48
|
+
let exitCode: number | undefined
|
|
49
|
+
let ended = !child.stdout
|
|
50
|
+
const finish = () => {
|
|
51
|
+
if (exitCode !== undefined && ended) done({ kind: "exited", code: exitCode, stdout: Buffer.concat(chunks).toString("utf8") })
|
|
52
|
+
}
|
|
53
|
+
child.stdout?.on("end", () => ((ended = true), finish()))
|
|
54
|
+
child.stdout?.on("close", () => ((ended = true), finish()))
|
|
55
|
+
child.on("exit", (code, signal) => ((exitCode = code ?? (signal ? -1 : 0)), finish()))
|
|
56
|
+
child.on("close", (code) => ((exitCode ??= code ?? -1), (ended = true), finish()))
|
|
57
|
+
}),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface DownloaderOptions {
|
|
62
|
+
/** The fetch implementation (default: the global fetch). */
|
|
63
|
+
readonly fetch?: (url: string, init: RequestInit) => Promise<Response>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function httpsDownloader(options: DownloaderOptions = {}): Downloader {
|
|
67
|
+
const doFetch = options.fetch ?? ((url: string, init: RequestInit) => fetch(url, init))
|
|
68
|
+
return {
|
|
69
|
+
async get(url, maxBytes) {
|
|
70
|
+
if (!url.startsWith("https://")) throw new Error(`refusing a non-https download: ${url}`)
|
|
71
|
+
const response = await doFetch(url, { redirect: "follow" })
|
|
72
|
+
if (response.url && !response.url.startsWith("https://")) throw new Error(`${url} redirected away from https`)
|
|
73
|
+
if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`)
|
|
74
|
+
const declared = Number(response.headers.get("content-length") ?? NaN)
|
|
75
|
+
if (Number.isFinite(declared) && declared > maxBytes) throw new DownloadTooLarge(url, maxBytes)
|
|
76
|
+
return responseChunks(response, maxBytes, () => new DownloadTooLarge(url, maxBytes))
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function tempScratch(base: string = tmpdir()): Scratch {
|
|
82
|
+
return {
|
|
83
|
+
async withFile(content, use) {
|
|
84
|
+
const dir = mkdtempSync(join(base, "hubctl-claude-"))
|
|
85
|
+
try {
|
|
86
|
+
const file = join(dir, "claude")
|
|
87
|
+
const fd = openSync(file, "wx", 0o600)
|
|
88
|
+
try {
|
|
89
|
+
for await (const chunk of chunksOf(content)) for (let at = 0; at < chunk.byteLength; ) at += writeSync(fd, chunk, at)
|
|
90
|
+
} finally {
|
|
91
|
+
closeSync(fd)
|
|
92
|
+
}
|
|
93
|
+
return await use(file)
|
|
94
|
+
} finally {
|
|
95
|
+
rmSync(dir, { recursive: true, force: true })
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// An in-house check of one OpenPGP detached signature (RFC 4880 v4, RSA), enough to verify Claude Code's signed
|
|
2
|
+
// per-release manifest.json without a hard dependency on gpg (contracts §5, decision Q3). It is deliberately
|
|
3
|
+
// narrow: a v4 RSA public key pinned by its fingerprint, a v4 binary-document signature, SHA-2 hashes. Every
|
|
4
|
+
// outcome is one of
|
|
5
|
+
// verified the pinned key signed exactly these bytes;
|
|
6
|
+
// unavailable this check cannot vouch either way (another or unpinned key, a format this verifier does not
|
|
7
|
+
// read): recorded, never a pass;
|
|
8
|
+
// failed checked and wrong (a bad signature in the pinned key's name, a malformed signature packet).
|
|
9
|
+
// It never throws, and its detail is one short line with no input bytes in it.
|
|
10
|
+
import { createHash, createPublicKey, constants, verify, type KeyObject } from "node:crypto"
|
|
11
|
+
|
|
12
|
+
/** Anthropic's Claude Code release-signing key (upstream docs: `gpg --fingerprint security@anthropic.com`). */
|
|
13
|
+
export const CLAUDE_SIGNING_FINGERPRINT = "31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE"
|
|
14
|
+
|
|
15
|
+
export interface PgpOutcome {
|
|
16
|
+
readonly status: "verified" | "unavailable" | "failed"
|
|
17
|
+
readonly detail: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DetachedSignatureInput {
|
|
21
|
+
/** The signed bytes (manifest.json, exactly as downloaded). */
|
|
22
|
+
readonly data: Uint8Array
|
|
23
|
+
/** The detached signature, ASCII-armored or binary. */
|
|
24
|
+
readonly signature: string | Uint8Array
|
|
25
|
+
/** The signer's transferable public key, ASCII-armored or binary. */
|
|
26
|
+
readonly publicKey: string | Uint8Array
|
|
27
|
+
/** The pinned v4 fingerprint (40 hex digits) the key must have. */
|
|
28
|
+
readonly fingerprint: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class Malformed extends Error {}
|
|
32
|
+
|
|
33
|
+
const RSA_ALGORITHMS = new Set([1, 2, 3])
|
|
34
|
+
/** OpenPGP hash IDs this verifier accepts (SHA-1 and MD5 are not accepted). */
|
|
35
|
+
const HASHES: Readonly<Record<number, string>> = { 8: "sha256", 9: "sha384", 10: "sha512", 11: "sha224" }
|
|
36
|
+
const BASE64_LINE = /^[A-Za-z0-9+/]*={0,2}$/
|
|
37
|
+
|
|
38
|
+
const outcome = (status: PgpOutcome["status"], detail: string): PgpOutcome => ({ status, detail: detail.slice(0, 200) })
|
|
39
|
+
|
|
40
|
+
/** The binary packets of an armored or binary OpenPGP message. */
|
|
41
|
+
function bytesOf(input: string | Uint8Array): Uint8Array {
|
|
42
|
+
const text = typeof input === "string" ? input : input[0] === 0x2d ? new TextDecoder().decode(input) : undefined
|
|
43
|
+
if (text === undefined) return input as Uint8Array
|
|
44
|
+
const lines = text.split(/\r?\n/)
|
|
45
|
+
let i = lines.findIndex((l) => /^-----BEGIN PGP [A-Z ]+-----$/.test(l.trim()))
|
|
46
|
+
if (i < 0) throw new Malformed("not an armored OpenPGP block")
|
|
47
|
+
// Armor headers end at the first blank line.
|
|
48
|
+
for (i++; i < lines.length && lines[i]!.trim() !== ""; i++) if (!lines[i]!.includes(":")) break
|
|
49
|
+
if (lines[i]?.trim() === "") i++
|
|
50
|
+
const body: string[] = []
|
|
51
|
+
for (; i < lines.length; i++) {
|
|
52
|
+
const line = lines[i]!.trim()
|
|
53
|
+
if (line.startsWith("-----END ")) break
|
|
54
|
+
if (line.startsWith("=") && line.length === 5) continue // the armor checksum
|
|
55
|
+
if (!BASE64_LINE.test(line)) throw new Malformed("the armor is not base64")
|
|
56
|
+
body.push(line)
|
|
57
|
+
}
|
|
58
|
+
const bytes = Buffer.from(body.join(""), "base64")
|
|
59
|
+
if (bytes.length === 0) throw new Malformed("the armor is empty")
|
|
60
|
+
return new Uint8Array(bytes)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface Packet {
|
|
64
|
+
readonly tag: number
|
|
65
|
+
readonly body: Uint8Array
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Packets (RFC 4880 §4.2), old and new format; partial lengths are not used by keys or signatures. */
|
|
69
|
+
function packets(b: Uint8Array): Packet[] {
|
|
70
|
+
const out: Packet[] = []
|
|
71
|
+
let i = 0
|
|
72
|
+
const need = (n: number) => {
|
|
73
|
+
if (i + n > b.length) throw new Malformed("a packet is truncated")
|
|
74
|
+
}
|
|
75
|
+
while (i < b.length) {
|
|
76
|
+
const h = b[i]!
|
|
77
|
+
if (!(h & 0x80)) throw new Malformed("not an OpenPGP packet")
|
|
78
|
+
let tag: number
|
|
79
|
+
let len: number
|
|
80
|
+
let head: number
|
|
81
|
+
if (h & 0x40) {
|
|
82
|
+
tag = h & 0x3f
|
|
83
|
+
need(2)
|
|
84
|
+
const o = b[i + 1]!
|
|
85
|
+
if (o < 192) [len, head] = [o, 2]
|
|
86
|
+
else if (o < 224) {
|
|
87
|
+
need(3)
|
|
88
|
+
;[len, head] = [((o - 192) << 8) + b[i + 2]! + 192, 3]
|
|
89
|
+
} else if (o === 255) {
|
|
90
|
+
need(6)
|
|
91
|
+
;[len, head] = [((b[i + 2]! << 24) >>> 0) + (b[i + 3]! << 16) + (b[i + 4]! << 8) + b[i + 5]!, 6]
|
|
92
|
+
} else throw new Malformed("partial packet lengths are not used here")
|
|
93
|
+
} else {
|
|
94
|
+
tag = (h >> 2) & 0x0f
|
|
95
|
+
const type = h & 3
|
|
96
|
+
if (type === 3) throw new Malformed("indeterminate packet lengths are not used here")
|
|
97
|
+
head = [2, 3, 5][type]!
|
|
98
|
+
need(head)
|
|
99
|
+
len = type === 0 ? b[i + 1]! : type === 1 ? (b[i + 1]! << 8) + b[i + 2]! : ((b[i + 1]! << 24) >>> 0) + (b[i + 2]! << 16) + (b[i + 3]! << 8) + b[i + 4]!
|
|
100
|
+
}
|
|
101
|
+
need(head + len)
|
|
102
|
+
out.push({ tag, body: b.subarray(i + head, i + head + len) })
|
|
103
|
+
i += head + len
|
|
104
|
+
}
|
|
105
|
+
return out
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Reads MPIs from `body` starting at `at`. */
|
|
109
|
+
function mpis(body: Uint8Array, at: number, count: number): { values: Uint8Array[]; end: number } {
|
|
110
|
+
const values: Uint8Array[] = []
|
|
111
|
+
let p = at
|
|
112
|
+
for (let k = 0; k < count; k++) {
|
|
113
|
+
if (p + 2 > body.length) throw new Malformed("an MPI is truncated")
|
|
114
|
+
const bytes = Math.ceil(((body[p]! << 8) + body[p + 1]!) / 8)
|
|
115
|
+
if (bytes === 0 || p + 2 + bytes > body.length) throw new Malformed("an MPI is truncated")
|
|
116
|
+
values.push(body.subarray(p + 2, p + 2 + bytes))
|
|
117
|
+
p += 2 + bytes
|
|
118
|
+
}
|
|
119
|
+
return { values, end: p }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const hex = (b: Uint8Array) => Buffer.from(b).toString("hex").toUpperCase()
|
|
123
|
+
|
|
124
|
+
interface PublicKey {
|
|
125
|
+
readonly fingerprint: string
|
|
126
|
+
readonly key?: KeyObject
|
|
127
|
+
readonly modulusBytes: number
|
|
128
|
+
readonly problem?: string
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function readPublicKey(input: string | Uint8Array): PublicKey | undefined {
|
|
132
|
+
try {
|
|
133
|
+
const primary = packets(bytesOf(input)).find((p) => p.tag === 6)
|
|
134
|
+
if (!primary) return undefined
|
|
135
|
+
const body = primary.body
|
|
136
|
+
const fingerprint = createHash("sha1")
|
|
137
|
+
.update(Buffer.concat([Buffer.from([0x99, (body.length >> 8) & 0xff, body.length & 0xff]), body]))
|
|
138
|
+
.digest("hex")
|
|
139
|
+
.toUpperCase()
|
|
140
|
+
if (body[0] !== 4) return { fingerprint, modulusBytes: 0, problem: `a v${body[0]} key is not supported` }
|
|
141
|
+
if (body.length < 6) throw new Malformed("the key is truncated")
|
|
142
|
+
if (!RSA_ALGORITHMS.has(body[5]!)) return { fingerprint, modulusBytes: 0, problem: `key algorithm ${body[5]} is not supported (RSA only)` }
|
|
143
|
+
const [n, e] = mpis(body, 6, 2).values as [Uint8Array, Uint8Array]
|
|
144
|
+
const key = createPublicKey({ key: { kty: "RSA", n: Buffer.from(n).toString("base64url"), e: Buffer.from(e).toString("base64url") }, format: "jwk" })
|
|
145
|
+
return { fingerprint, key, modulusBytes: n.length }
|
|
146
|
+
} catch {
|
|
147
|
+
return undefined
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The v4 fingerprint of a public key (40 hex digits), or undefined when it cannot be read. */
|
|
152
|
+
export function publicKeyFingerprint(input: string | Uint8Array): string | undefined {
|
|
153
|
+
return readPublicKey(input)?.fingerprint
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Issuer fingerprint (33) and issuer key ID (16) subpackets. */
|
|
157
|
+
function issuers(area: Uint8Array): { fingerprint?: string; keyID?: string } {
|
|
158
|
+
const found: { fingerprint?: string; keyID?: string } = {}
|
|
159
|
+
let r = 0
|
|
160
|
+
while (r < area.length) {
|
|
161
|
+
let len = area[r]!
|
|
162
|
+
let o = 1
|
|
163
|
+
if (len >= 192 && len < 255) {
|
|
164
|
+
if (r + 1 >= area.length) throw new Malformed("a subpacket is truncated")
|
|
165
|
+
len = ((len - 192) << 8) + area[r + 1]! + 192
|
|
166
|
+
o = 2
|
|
167
|
+
} else if (len === 255) {
|
|
168
|
+
if (r + 5 > area.length) throw new Malformed("a subpacket is truncated")
|
|
169
|
+
len = ((area[r + 1]! << 24) >>> 0) + (area[r + 2]! << 16) + (area[r + 3]! << 8) + area[r + 4]!
|
|
170
|
+
o = 5
|
|
171
|
+
}
|
|
172
|
+
if (len < 1 || r + o + len > area.length) throw new Malformed("a subpacket is truncated")
|
|
173
|
+
const type = area[r + o]! & 0x7f
|
|
174
|
+
const value = area.subarray(r + o + 1, r + o + len)
|
|
175
|
+
if (type === 33 && value[0] === 4 && value.length === 21) found.fingerprint = hex(value.subarray(1))
|
|
176
|
+
if (type === 16 && value.length === 8) found.keyID = hex(value)
|
|
177
|
+
r += o + len
|
|
178
|
+
}
|
|
179
|
+
return found
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Verifies a detached signature over `data` by the pinned key. */
|
|
183
|
+
export function verifyDetachedSignature(input: DetachedSignatureInput): PgpOutcome {
|
|
184
|
+
const pinned = input.fingerprint.replace(/\s+/g, "").toUpperCase()
|
|
185
|
+
const key = readPublicKey(input.publicKey)
|
|
186
|
+
if (!key) return outcome("unavailable", "the published signing key cannot be read")
|
|
187
|
+
if (key.fingerprint !== pinned) return outcome("unavailable", `the published signing key ${key.fingerprint} is not the pinned key ${pinned}`)
|
|
188
|
+
if (!key.key) return outcome("unavailable", `the signing key cannot be used here: ${key.problem}`)
|
|
189
|
+
try {
|
|
190
|
+
const sig = packets(bytesOf(input.signature)).find((p) => p.tag === 2)?.body
|
|
191
|
+
if (!sig) throw new Malformed("no signature packet")
|
|
192
|
+
if (sig[0] !== 4) return outcome("unavailable", `a v${sig[0]} signature is not supported`)
|
|
193
|
+
if (sig.length < 6) throw new Malformed("the signature is truncated")
|
|
194
|
+
const [, type, algorithm, hashID] = sig as unknown as [number, number, number, number]
|
|
195
|
+
const hashedLen = (sig[4]! << 8) + sig[5]!
|
|
196
|
+
if (6 + hashedLen + 2 > sig.length) throw new Malformed("the signature is truncated")
|
|
197
|
+
const hashed = sig.subarray(6, 6 + hashedLen)
|
|
198
|
+
const unhashedLen = (sig[6 + hashedLen]! << 8) + sig[7 + hashedLen]!
|
|
199
|
+
const unhashedAt = 8 + hashedLen
|
|
200
|
+
if (unhashedAt + unhashedLen + 2 > sig.length) throw new Malformed("the signature is truncated")
|
|
201
|
+
const unhashed = sig.subarray(unhashedAt, unhashedAt + unhashedLen)
|
|
202
|
+
const [value] = mpis(sig, unhashedAt + unhashedLen + 2, 1).values as [Uint8Array]
|
|
203
|
+
if (type !== 0x00) return outcome("unavailable", `signature type 0x${type.toString(16).padStart(2, "0")} is not supported (binary documents only)`)
|
|
204
|
+
if (!RSA_ALGORITHMS.has(algorithm)) return outcome("unavailable", `signature algorithm ${algorithm} is not supported (RSA only)`)
|
|
205
|
+
const hash = HASHES[hashID]
|
|
206
|
+
if (!hash) return outcome("unavailable", `hash algorithm ${hashID} is not accepted`)
|
|
207
|
+
const ids = { ...issuers(unhashed), ...issuers(hashed) }
|
|
208
|
+
if (ids.fingerprint && ids.fingerprint !== pinned) return outcome("unavailable", `signed by another key ${ids.fingerprint}`)
|
|
209
|
+
if (!ids.fingerprint && ids.keyID && ids.keyID !== pinned.slice(-16)) return outcome("unavailable", `signed by another key ${ids.keyID}`)
|
|
210
|
+
const trailer = Buffer.alloc(6)
|
|
211
|
+
trailer[0] = 4
|
|
212
|
+
trailer[1] = 0xff
|
|
213
|
+
trailer.writeUInt32BE(6 + hashedLen, 2)
|
|
214
|
+
const signed = Buffer.concat([input.data, sig.subarray(0, 6 + hashedLen), trailer])
|
|
215
|
+
const padded = Buffer.concat([Buffer.alloc(Math.max(0, key.modulusBytes - value.length)), value])
|
|
216
|
+
const good = verify(hash, signed, { key: key.key, padding: constants.RSA_PKCS1_PADDING }, padded)
|
|
217
|
+
return good ? outcome("verified", `good signature by ${pinned}`) : outcome("failed", `the signature by ${pinned} does not match these bytes`)
|
|
218
|
+
} catch (error) {
|
|
219
|
+
return outcome("failed", `the signature is malformed: ${error instanceof Malformed ? error.message : "unreadable"}`)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// The Claude Code adapter's only ways to reach outside itself, all injected: an https downloader, a fixed-argv
|
|
2
|
+
// process runner (never a shell) and a private scratch file. node.ts has the default implementations; tests
|
|
3
|
+
// inject fakes. Bodies and scratch content are ByteSources: the ~200 MB binary streams, it is never held whole.
|
|
4
|
+
import type { ByteSource } from "../../transaction/stream.ts"
|
|
5
|
+
|
|
6
|
+
/** Thrown by a Downloader when the body is longer than allowed. */
|
|
7
|
+
export class DownloadTooLarge extends Error {
|
|
8
|
+
constructor(url: string, maxBytes: number) {
|
|
9
|
+
super(`${url} is larger than ${maxBytes} bytes`)
|
|
10
|
+
this.name = "DownloadTooLarge"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Fetches an https URL's body, refusing more than `maxBytes` (DownloadTooLarge, also while it streams); throws on
|
|
16
|
+
* any failure. The body is whole (small things, fakes) or chunks as they arrive.
|
|
17
|
+
*/
|
|
18
|
+
export interface Downloader {
|
|
19
|
+
get(url: string, maxBytes: number): Promise<ByteSource>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** How a fixed-argv probe ended. `stdout` is the probe's to parse and drop; it is never recorded. */
|
|
23
|
+
export type RunResult =
|
|
24
|
+
| { readonly kind: "exited"; readonly code: number; readonly stdout: string }
|
|
25
|
+
| { readonly kind: "not-found" }
|
|
26
|
+
| { readonly kind: "timeout" }
|
|
27
|
+
| { readonly kind: "error"; readonly message: string }
|
|
28
|
+
|
|
29
|
+
export interface RunOptions {
|
|
30
|
+
/** The child's whole environment (nothing else is inherited). */
|
|
31
|
+
readonly env: Readonly<Record<string, string>>
|
|
32
|
+
readonly timeoutMs: number
|
|
33
|
+
readonly maxOutputBytes: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Runs one program with an argument list (never a shell). The only way this adapter starts a process. */
|
|
37
|
+
export interface ProbeRunner {
|
|
38
|
+
run(file: string, args: readonly string[], options: RunOptions): Promise<RunResult>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A private (0700) scratch copy of some content (written as it streams) for the duration of `use`, removed afterwards. */
|
|
42
|
+
export interface Scratch {
|
|
43
|
+
withFile<T>(content: ByteSource, use: (path: string) => Promise<T>): Promise<T>
|
|
44
|
+
}
|