@huynle/hub 0.7.1 → 0.7.2
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 +70 -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/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 +115 -32
- package/src/central/mcp-http.ts +116 -0
- package/src/central/store.ts +53 -0
- package/src/config.ts +70 -7
- package/src/index.ts +90 -91
- package/src/spoke/launchers.ts +109 -3
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-DWnMhXk9.js +20 -0
- package/web/dist/assets/index-DmUXEnw9.css +1 -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/assets/index-D9zMbxSg.css +0 -1
- package/web/dist/assets/index-db1z4duA.js +0 -20
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// The Claude Code HarnessSource (transaction/builtin.ts installHarnessExecutor): where the pinned binary comes from,
|
|
2
|
+
// which upstream evidence is attempted for it, and how it becomes the install tree. The executor checks the pin and
|
|
3
|
+
// the downloaded bytes' sha256 itself (mandatory, contracts §3.6.1); this source only adds the optional evidence:
|
|
4
|
+
//
|
|
5
|
+
// gpg-manifest upstream's per-release manifest.json and its detached signature, checked in-house
|
|
6
|
+
// (openpgp.ts) against the pinned release-signing key; the signed manifest must list exactly
|
|
7
|
+
// these bytes for this platform and version. No gpg binary is needed.
|
|
8
|
+
// macos-notarization on macOS, /usr/bin/codesign (fixed argv, through the injected runner) on a private scratch
|
|
9
|
+
// copy of the downloaded bytes: signed by Anthropic PBC's Developer ID, then notarized.
|
|
10
|
+
//
|
|
11
|
+
// The binary streams into the step's staging area (never held whole: HBS-12); the signed manifest is compared with
|
|
12
|
+
// the digest the staging area recorded, and codesign gets a scratch copy written in chunks.
|
|
13
|
+
//
|
|
14
|
+
// Each is recorded as verified, unavailable (could not be checked here: no codesign, offline, unsigned release,
|
|
15
|
+
// unknown key) or failed (checked and wrong); a failure rejects the artifact (checkHarnessEvidence). Nothing here
|
|
16
|
+
// runs a shell, reads a credential or touches ~/.claude.
|
|
17
|
+
import type { EvidenceResult, HarnessID, InstallHarness, VerifiedManifest } from "../../contracts/index.ts"
|
|
18
|
+
import type { HarnessSource } from "../../transaction/builtin.ts"
|
|
19
|
+
import type { TreeFile } from "../../transaction/executor.ts"
|
|
20
|
+
import { TxError, txFail, txOk, type TxResult } from "../../transaction/result.ts"
|
|
21
|
+
import { collect, TooLarge, type ByteReader, type StagedFile } from "../../transaction/stream.ts"
|
|
22
|
+
import { CLAUDE_SIGNING_FINGERPRINT, verifyDetachedSignature } from "./openpgp.ts"
|
|
23
|
+
import { DownloadTooLarge, type Downloader, type ProbeRunner, type Scratch } from "./ports.ts"
|
|
24
|
+
|
|
25
|
+
export const UPSTREAM_RELEASES = "https://downloads.claude.ai/claude-code-releases"
|
|
26
|
+
export const UPSTREAM_KEY_URL = "https://downloads.claude.ai/keys/claude-code.asc"
|
|
27
|
+
/** Anthropic PBC's Apple Developer ID team (codesign -dv: "Developer ID Application: Anthropic PBC (Q6L2SF6YDW)"). */
|
|
28
|
+
export const ANTHROPIC_TEAM_ID = "Q6L2SF6YDW"
|
|
29
|
+
const IDENTITY = `anchor apple generic and certificate leaf[subject.OU] = "${ANTHROPIC_TEAM_ID}"`
|
|
30
|
+
|
|
31
|
+
/** The fixed codesign invocations; the scratch file's path is the last argument. */
|
|
32
|
+
export const CODESIGN = Object.freeze({
|
|
33
|
+
program: "/usr/bin/codesign",
|
|
34
|
+
identity: Object.freeze(["--verify", "--strict", `-R=${IDENTITY}`]),
|
|
35
|
+
notarized: Object.freeze(["--verify", "--strict", `-R=notarized and ${IDENTITY}`]),
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const MANIFEST_MAX = 1 << 20
|
|
39
|
+
const SIGNATURE_MAX = 64 << 10
|
|
40
|
+
const KEY_MAX = 64 << 10
|
|
41
|
+
const CODESIGN_TIMEOUT_MS = 60_000
|
|
42
|
+
const UPSTREAM_BINARY = /^https:\/\/downloads\.claude\.ai\/claude-code-releases\/([0-9A-Za-z.+-]+)\/([a-z0-9-]+)\/claude$/
|
|
43
|
+
|
|
44
|
+
/** Upstream's platform name for a hub target (the same names; upstream's musl builds are not hub targets). */
|
|
45
|
+
export function upstreamPlatform(target: string): string | undefined {
|
|
46
|
+
return ["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64"].includes(target) ? target : undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ClaudeSourceOptions {
|
|
50
|
+
readonly manifest: VerifiedManifest
|
|
51
|
+
readonly download: Downloader
|
|
52
|
+
/** codesign for macos-notarization; without it (or the scratch) notarization is unavailable. */
|
|
53
|
+
readonly runner?: ProbeRunner
|
|
54
|
+
readonly scratch?: Scratch
|
|
55
|
+
/** process.platform of this host. */
|
|
56
|
+
readonly platform: string
|
|
57
|
+
/** The release-signing key's pinned fingerprint (default CLAUDE_SIGNING_FINGERPRINT). */
|
|
58
|
+
readonly signingKey?: { readonly fingerprint: string }
|
|
59
|
+
/** What hub installed before (the history), for the downgrade policy. */
|
|
60
|
+
readonly installed?: { readonly version: string; readonly sha256: string }
|
|
61
|
+
readonly floor?: string
|
|
62
|
+
readonly allowDowngrade?: boolean
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const result = (kind: EvidenceResult["kind"], status: EvidenceResult["status"], detail: string): EvidenceResult => ({ kind, status, detail: detail.replace(/\s+/g, " ").slice(0, 200) })
|
|
66
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
67
|
+
|
|
68
|
+
async function gpgManifest(options: ClaudeSourceOptions, action: InstallHarness, file: StagedFile): Promise<EvidenceResult> {
|
|
69
|
+
const kind = "gpg-manifest"
|
|
70
|
+
const m = UPSTREAM_BINARY.exec(action.source.url)
|
|
71
|
+
const platform = upstreamPlatform(action.target)
|
|
72
|
+
if (!m || m[1] !== action.version || !platform || m[2] !== platform) return result(kind, "unavailable", "the pinned URL is not upstream's release bucket for this version and platform")
|
|
73
|
+
const base = `${UPSTREAM_RELEASES}/${action.version}`
|
|
74
|
+
let manifest: Uint8Array
|
|
75
|
+
let signature: Uint8Array
|
|
76
|
+
let key: Uint8Array
|
|
77
|
+
try {
|
|
78
|
+
const small = async (url: string, max: number) => collect(await options.download.get(url, max), max)
|
|
79
|
+
;[manifest, signature, key] = await Promise.all([small(`${base}/manifest.json`, MANIFEST_MAX), small(`${base}/manifest.json.sig`, SIGNATURE_MAX), small(UPSTREAM_KEY_URL, KEY_MAX)])
|
|
80
|
+
} catch {
|
|
81
|
+
return result(kind, "unavailable", `the signed manifest of ${action.version} could not be downloaded (upstream signs releases from 2.1.89 on)`)
|
|
82
|
+
}
|
|
83
|
+
const fingerprint = options.signingKey?.fingerprint ?? CLAUDE_SIGNING_FINGERPRINT
|
|
84
|
+
const signed = verifyDetachedSignature({ data: manifest, signature, publicKey: key, fingerprint })
|
|
85
|
+
if (signed.status !== "verified") return result(kind, signed.status, `manifest.json: ${signed.detail}`)
|
|
86
|
+
let doc: unknown
|
|
87
|
+
try {
|
|
88
|
+
doc = JSON.parse(new TextDecoder().decode(manifest))
|
|
89
|
+
} catch {
|
|
90
|
+
return result(kind, "failed", "the signed manifest.json is not JSON")
|
|
91
|
+
}
|
|
92
|
+
const entry = isRecord(doc) && isRecord(doc.platforms) ? doc.platforms[platform] : undefined
|
|
93
|
+
if (!isRecord(doc) || doc.version !== action.version) return result(kind, "failed", `the signed manifest is not for ${action.version}`)
|
|
94
|
+
const expected = action.sha256.replace(/^sha256:/, "")
|
|
95
|
+
if (!isRecord(entry) || entry.checksum !== expected || entry.size !== action.size || file.sha256 !== `sha256:${entry.checksum}`)
|
|
96
|
+
return result(kind, "failed", `the signed manifest lists other bytes for ${platform} than hub's pin`)
|
|
97
|
+
return result(kind, "verified", `upstream manifest.json signed by ${fingerprint} lists this binary`)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function notarization(options: ClaudeSourceOptions, action: InstallHarness, binary: ByteReader): Promise<EvidenceResult> {
|
|
101
|
+
const kind = "macos-notarization"
|
|
102
|
+
if (options.platform !== "darwin" || !action.target.startsWith("darwin-")) return result(kind, "unavailable", "notarization is checked on macOS only")
|
|
103
|
+
const { runner, scratch } = options
|
|
104
|
+
if (!runner || !scratch) return result(kind, "unavailable", "no codesign runner in this hubctl")
|
|
105
|
+
const run = (args: readonly string[], file: string) => runner.run(CODESIGN.program, [...args, file], { env: { PATH: "/usr/bin:/bin" }, timeoutMs: CODESIGN_TIMEOUT_MS, maxOutputBytes: 64 << 10 })
|
|
106
|
+
try {
|
|
107
|
+
return await scratch.withFile(binary.stream(), async (file) => {
|
|
108
|
+
const identity = await run(CODESIGN.identity, file)
|
|
109
|
+
if (identity.kind !== "exited") return result(kind, "unavailable", `codesign could not run (${identity.kind})`)
|
|
110
|
+
if (identity.code !== 0) return result(kind, "failed", `the binary is not validly signed by Anthropic PBC (${ANTHROPIC_TEAM_ID}); codesign exit ${identity.code}`)
|
|
111
|
+
const notarized = await run(CODESIGN.notarized, file)
|
|
112
|
+
if (notarized.kind === "exited" && notarized.code === 0) return result(kind, "verified", `signed by Anthropic PBC (${ANTHROPIC_TEAM_ID}) and notarized by Apple`)
|
|
113
|
+
return result(kind, "unavailable", `signed by Anthropic PBC (${ANTHROPIC_TEAM_ID}), but the notarization could not be confirmed (offline?)`)
|
|
114
|
+
})
|
|
115
|
+
} catch {
|
|
116
|
+
return result(kind, "unavailable", "no scratch copy for codesign")
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Mach-O (64-bit, either byte order, or universal) for macOS; ELF for Linux. */
|
|
121
|
+
function nativeFor(target: string, bytes: Uint8Array): boolean {
|
|
122
|
+
const head = [...bytes.subarray(0, 4)]
|
|
123
|
+
const is = (magic: readonly number[]) => magic.every((b, i) => head[i] === b)
|
|
124
|
+
if (target.startsWith("darwin-")) return [[0xcf, 0xfa, 0xed, 0xfe], [0xfe, 0xed, 0xfa, 0xcf], [0xca, 0xfe, 0xba, 0xbe]].some(is)
|
|
125
|
+
if (target.startsWith("linux-")) return is([0x7f, 0x45, 0x4c, 0x46])
|
|
126
|
+
return false
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function createClaudeHarnessSource(options: ClaudeSourceOptions): HarnessSource {
|
|
130
|
+
return {
|
|
131
|
+
manifest: options.manifest,
|
|
132
|
+
async fetch(action, staging) {
|
|
133
|
+
if (action.harness !== "claude-code") throw new Error(`the Claude Code source cannot install ${action.harness}`)
|
|
134
|
+
// One byte more than pinned is still fetched, so a longer file is an integrity mismatch, not a download error.
|
|
135
|
+
// The body streams into the staging area; a longer one fails as soon as it runs past the limit.
|
|
136
|
+
let file: StagedFile
|
|
137
|
+
try {
|
|
138
|
+
file = await staging.write("claude", await options.download.get(action.source.url, action.size + 1), { maxBytes: action.size + 1 })
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (error instanceof DownloadTooLarge || error instanceof TooLarge) throw new TxError("integrity_mismatch", `${action.source.url} is larger than the pinned ${action.size} bytes`)
|
|
141
|
+
throw new TxError("offline", `${action.source.url} could not be downloaded`)
|
|
142
|
+
}
|
|
143
|
+
const evidence: EvidenceResult[] = []
|
|
144
|
+
for (const kind of action.evidence) {
|
|
145
|
+
if (kind === "gpg-manifest") evidence.push(await gpgManifest(options, action, file))
|
|
146
|
+
else if (kind === "macos-notarization") evidence.push(await notarization(options, action, staging.open(file)))
|
|
147
|
+
}
|
|
148
|
+
return { file, evidence }
|
|
149
|
+
},
|
|
150
|
+
async unpack(action, file, staging): Promise<TxResult<readonly TreeFile[]>> {
|
|
151
|
+
if (action.source.file !== "claude") return txFail("invalid", `the Claude Code pin must be upstream's raw binary "claude", not ${JSON.stringify(action.source.file)}`)
|
|
152
|
+
if (!nativeFor(action.target, staging.open(file).read(0, 4))) return txFail("invalid", `the downloaded file is not a native ${action.target} executable`)
|
|
153
|
+
// The tree is the staged download itself: the engine moves it into place (no copy).
|
|
154
|
+
return txOk([{ rel: "claude", staged: file, mode: 0o755 }])
|
|
155
|
+
},
|
|
156
|
+
installed: (harness: HarnessID) => (harness === "claude-code" ? options.installed : undefined),
|
|
157
|
+
floor: (harness: HarnessID) => (harness === "claude-code" ? options.floor : undefined),
|
|
158
|
+
allowDowngrade: options.allowDowngrade,
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Claude Code readiness (contracts §3.10): REQUIRED_PROBES["claude-code"], each real, passing and fresh.
|
|
2
|
+
//
|
|
3
|
+
// version-exec the binary hub resolved (hub's current version, or the program a wrapper or link resolves to;
|
|
4
|
+
// never just the shim on PATH) runs `claude --version` (fixed argv, minimal environment, updates
|
|
5
|
+
// switched off) and prints the expected version.
|
|
6
|
+
// provider-auth `claude auth status` (fixed argv) exits 0 with a login method: the user's own login, made by
|
|
7
|
+
// the user on this machine (`claude auth login`). hubctl never reads, copies or keeps a Claude
|
|
8
|
+
// credential: the probe's output is parsed for the login method only and then dropped, and the
|
|
9
|
+
// child gets the user's environment minus hub's own variables.
|
|
10
|
+
// spoke-connected the injected spoke probe sees the claude-code hooks spoke connected, and the injected
|
|
11
|
+
// conformance probe passes. Both are required: without either, the harness is not ready.
|
|
12
|
+
//
|
|
13
|
+
// Not ready means no capability: claudeReadiness offers "claude-code" only when every probe passes. The
|
|
14
|
+
// verify-harness step classifies the probes (contract v1.2): an integrity failure (the binary) fails it with
|
|
15
|
+
// harness_not_ready (so the transaction rolls back); a login or spoke that waits on the user fails it only with
|
|
16
|
+
// requireReady (the default), and otherwise passes as "installed, not ready".
|
|
17
|
+
import { harnessReadiness, isHarnessReady, type AdapterContext, type HarnessVerification, type Probe } from "../../contracts/index.ts"
|
|
18
|
+
import type { ActionExecutor } from "../../transaction/executor.ts"
|
|
19
|
+
import { txFail, txOk } from "../../transaction/result.ts"
|
|
20
|
+
import { passesAsPending, type ReadinessGate } from "../readiness.ts"
|
|
21
|
+
import type { ProbeRunner, RunResult } from "./ports.ts"
|
|
22
|
+
|
|
23
|
+
export const VERSION_ARGV: readonly string[] = Object.freeze(["--version"])
|
|
24
|
+
export const AUTH_STATUS_ARGV: readonly string[] = Object.freeze(["auth", "status"])
|
|
25
|
+
/** Upstream's documented login methods (`claude auth status` JSON `authMethod`), "none" excluded. */
|
|
26
|
+
export const AUTH_METHODS: readonly string[] = ["claude.ai", "oauth_token", "api_key", "api_key_helper", "third_party"]
|
|
27
|
+
/** What the spoke must be (packages/claude-code daemon: harness claude-code, evidence hooks). */
|
|
28
|
+
export const CLAUDE_SPOKE = { harness: "claude-code", evidence: "hooks" } as const
|
|
29
|
+
|
|
30
|
+
const VERSION_TIMEOUT_MS = 15_000
|
|
31
|
+
const AUTH_TIMEOUT_MS = 30_000
|
|
32
|
+
const OUTPUT_MAX = 64 << 10
|
|
33
|
+
const NO_UPDATES = { DISABLE_AUTOUPDATER: "1", DISABLE_UPDATES: "1" } as const
|
|
34
|
+
|
|
35
|
+
/** The binary to verify, or why there is none. */
|
|
36
|
+
export type ClaudeBinary = { readonly path: string; readonly version?: string } | { readonly problem: string }
|
|
37
|
+
|
|
38
|
+
export interface SpokeHello {
|
|
39
|
+
readonly connected: boolean
|
|
40
|
+
readonly harness?: string
|
|
41
|
+
readonly evidence?: string
|
|
42
|
+
readonly features?: readonly string[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ClaudeProbes {
|
|
46
|
+
readonly runner: ProbeRunner
|
|
47
|
+
readonly binary: () => ClaudeBinary
|
|
48
|
+
/** Is the claude-code spoke connected to the hub (HBS-13: the daemon socket or the hub's machine view)? */
|
|
49
|
+
readonly spoke?: () => Promise<SpokeHello>
|
|
50
|
+
/** Does the connected spoke pass conformance? */
|
|
51
|
+
readonly conformance?: () => Promise<{ readonly ok: boolean; readonly detail?: string }>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const line = (s: string) => s.replace(/\s+/g, " ").trim().slice(0, 200)
|
|
55
|
+
const why = (r: RunResult) => (r.kind === "exited" ? `exit ${r.code}` : r.kind === "error" ? "could not start" : r.kind)
|
|
56
|
+
|
|
57
|
+
/** The user's environment for a probe, minus hub's own variables, with updates switched off. */
|
|
58
|
+
function userEnv(env: AdapterContext["env"]): Record<string, string> {
|
|
59
|
+
const out: Record<string, string> = {}
|
|
60
|
+
for (const [k, v] of Object.entries(env)) if (v !== undefined && !k.startsWith("HUB_") && k !== "BUN_BE_BUN") out[k] = v
|
|
61
|
+
return { ...out, ...NO_UPDATES }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function versionProbe(probes: ClaudeProbes, ctx: AdapterContext, bin: { path: string; version?: string }): Promise<Omit<Probe, "observedAt">> {
|
|
65
|
+
const env = { PATH: ctx.env.PATH ?? "/usr/bin:/bin", HOME: ctx.home, ...NO_UPDATES }
|
|
66
|
+
const r = await probes.runner.run(bin.path, VERSION_ARGV, { env, timeoutMs: VERSION_TIMEOUT_MS, maxOutputBytes: OUTPUT_MAX })
|
|
67
|
+
if (r.kind !== "exited" || r.code !== 0) return { kind: "version-exec", ok: false, detail: `${bin.path} --version did not run (${why(r)})` }
|
|
68
|
+
const version = /^\s*v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/.exec(r.stdout)?.[1]
|
|
69
|
+
if (!version) return { kind: "version-exec", ok: false, detail: `${bin.path} --version printed no version` }
|
|
70
|
+
if (bin.version && version !== bin.version) return { kind: "version-exec", ok: false, detail: `${bin.path} is ${version}, not the expected ${bin.version}` }
|
|
71
|
+
return { kind: "version-exec", ok: true, detail: `${version} at ${bin.path}` }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function authProbe(probes: ClaudeProbes, ctx: AdapterContext, bin: { path: string }): Promise<Omit<Probe, "observedAt">> {
|
|
75
|
+
const login = "not logged in: log in on this machine yourself (`claude auth login`, or run `claude`)"
|
|
76
|
+
const r = await probes.runner.run(bin.path, AUTH_STATUS_ARGV, { env: userEnv(ctx.env), timeoutMs: AUTH_TIMEOUT_MS, maxOutputBytes: OUTPUT_MAX })
|
|
77
|
+
if (r.kind !== "exited") return { kind: "provider-auth", ok: false, detail: `claude auth status did not run (${why(r)})` }
|
|
78
|
+
if (r.code !== 0) return { kind: "provider-auth", ok: false, detail: login }
|
|
79
|
+
// Only the login method is read; everything else the command printed is dropped here.
|
|
80
|
+
let method: unknown
|
|
81
|
+
try {
|
|
82
|
+
method = (JSON.parse(r.stdout) as { authMethod?: unknown }).authMethod
|
|
83
|
+
} catch {}
|
|
84
|
+
if (method === "none") return { kind: "provider-auth", ok: false, detail: login }
|
|
85
|
+
return { kind: "provider-auth", ok: true, detail: typeof method === "string" && AUTH_METHODS.includes(method) ? `logged in (${method})` : "logged in" }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function spokeProbe(probes: ClaudeProbes): Promise<Omit<Probe, "observedAt">> {
|
|
89
|
+
const fail = (detail: string) => ({ kind: "spoke-connected" as const, ok: false, detail })
|
|
90
|
+
if (!probes.spoke) return fail("no spoke probe in this hubctl")
|
|
91
|
+
if (!probes.conformance) return fail("no conformance probe in this hubctl")
|
|
92
|
+
let hello: SpokeHello
|
|
93
|
+
try {
|
|
94
|
+
hello = await probes.spoke()
|
|
95
|
+
} catch {
|
|
96
|
+
return fail("the claude-code spoke could not be reached")
|
|
97
|
+
}
|
|
98
|
+
if (!hello.connected) return fail("the claude-code spoke is not connected to the hub (is the daemon running and the machine enrolled?)")
|
|
99
|
+
if (hello.harness !== CLAUDE_SPOKE.harness || hello.evidence !== CLAUDE_SPOKE.evidence) return fail(`the connected spoke is ${hello.harness ?? "?"}/${hello.evidence ?? "?"}, not ${CLAUDE_SPOKE.harness}/${CLAUDE_SPOKE.evidence}`)
|
|
100
|
+
try {
|
|
101
|
+
const c = await probes.conformance()
|
|
102
|
+
if (!c.ok) return fail(`spoke conformance failed${c.detail ? `: ${c.detail}` : ""}`)
|
|
103
|
+
} catch {
|
|
104
|
+
return fail("spoke conformance could not run")
|
|
105
|
+
}
|
|
106
|
+
return { kind: "spoke-connected", ok: true, detail: "claude-code hooks spoke connected and conformant" }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Runs every required probe (passing or not) and reports each. */
|
|
110
|
+
export async function verifyClaude(probes: ClaudeProbes, ctx: AdapterContext): Promise<HarnessVerification> {
|
|
111
|
+
const bin = probes.binary()
|
|
112
|
+
const found: Omit<Probe, "observedAt">[] = []
|
|
113
|
+
if ("problem" in bin) {
|
|
114
|
+
found.push({ kind: "version-exec", ok: false, detail: bin.problem }, { kind: "provider-auth", ok: false, detail: `not checked: ${bin.problem}` })
|
|
115
|
+
} else {
|
|
116
|
+
found.push(await versionProbe(probes, ctx, bin), await authProbe(probes, ctx, bin))
|
|
117
|
+
}
|
|
118
|
+
found.push(await spokeProbe(probes))
|
|
119
|
+
const observedAt = ctx.now()
|
|
120
|
+
return { harness: "claude-code", probes: found.map((p) => ({ ...p, detail: line(p.detail ?? ""), observedAt })) }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface ClaudeReadiness {
|
|
124
|
+
readonly ready: boolean
|
|
125
|
+
/** Offered only when ready. */
|
|
126
|
+
readonly capability?: "claude-code"
|
|
127
|
+
readonly problems: readonly string[]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function claudeReadiness(verification: HarnessVerification, now: number): ClaudeReadiness {
|
|
131
|
+
const ready = verification.harness === "claude-code" && isHarnessReady(verification, now)
|
|
132
|
+
const problems = verification.probes.filter((p) => !p.ok).map((p) => `${p.kind}: ${p.detail ?? "failed"}`)
|
|
133
|
+
if (ready) return { ready, capability: "claude-code", problems: [] }
|
|
134
|
+
return { ready, problems: problems.length > 0 ? problems : ["the probes are missing or stale"] }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface ClaudeVerifyOptions extends ReadinessGate {
|
|
138
|
+
readonly verify: () => Promise<HarnessVerification>
|
|
139
|
+
readonly now: () => number
|
|
140
|
+
/** Sees every verification (ready or not), e.g. to record it or offer the capability. */
|
|
141
|
+
readonly onVerified?: (verification: HarnessVerification) => void
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The verify-harness step for claude-code: an external effect that fails unless ready (or, v1.2, only pending). */
|
|
145
|
+
export function claudeVerifyExecutor(options: ClaudeVerifyOptions): ActionExecutor<"verify-harness"> {
|
|
146
|
+
return {
|
|
147
|
+
kind: "verify-harness",
|
|
148
|
+
plan: () => ({ class: "external" }),
|
|
149
|
+
check: async (action) => (action.harness === "claude-code" ? txOk(undefined) : txFail("internal", `the Claude Code verifier cannot verify ${action.harness}`)),
|
|
150
|
+
change: async (action) =>
|
|
151
|
+
txOk({
|
|
152
|
+
type: "effect",
|
|
153
|
+
perform: async () => {
|
|
154
|
+
const verification = await options.verify()
|
|
155
|
+
options.onVerified?.(verification)
|
|
156
|
+
const classified = harnessReadiness(verification, options.now(), action.probes)
|
|
157
|
+
options.onReadiness?.(structuredClone(classified))
|
|
158
|
+
const readiness = claudeReadiness(verification, options.now())
|
|
159
|
+
if (readiness.ready) return txOk(undefined)
|
|
160
|
+
if (verification.harness === "claude-code" && passesAsPending(classified, options)) return txOk(undefined)
|
|
161
|
+
return txFail("harness_not_ready", `Claude Code is not ready: ${readiness.problems.join("; ")}`)
|
|
162
|
+
},
|
|
163
|
+
}),
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Unpacks a Codex release archive (.tar.gz) into the immutable install tree, without a tar binary, streaming: the
|
|
2
|
+
// package inflates to ~0.5 GB, so the archive is read from the staging area in chunks (archive-stream.ts) and each
|
|
3
|
+
// member is streamed into its own staging file, hashed as it is written (HBS-12; nothing is held whole). It only
|
|
4
|
+
// ever sees a download the install-harness executor has already checked against hub's pinned digest
|
|
5
|
+
// (CodexHarnessSource.unpack), and it is still strict, because the tree is installed as is:
|
|
6
|
+
//
|
|
7
|
+
// - regular files and directories only: symlinks, hard links, devices and FIFOs are refused;
|
|
8
|
+
// - every name must be a safe relative path (no "..", no absolute path, no empty segment), after pax and GNU
|
|
9
|
+
// long-name records; duplicates are refused; header checksums must match;
|
|
10
|
+
// - modes keep only the permission bits (no setuid, setgid or sticky);
|
|
11
|
+
// - two layouts are accepted: the upstream package (bin/codex plus its helpers, codex-package-<triple>.tar.gz,
|
|
12
|
+
// what Codex's own install.sh installs) and the standalone binary (one codex-<triple> file, which becomes
|
|
13
|
+
// bin/codex). Either way the tree has bin/codex.
|
|
14
|
+
import { isSafeRel } from "../../contracts/index.ts"
|
|
15
|
+
import type { TreeFile } from "../../transaction/executor.ts"
|
|
16
|
+
import { txFail, txOk, type TxResult } from "../../transaction/result.ts"
|
|
17
|
+
import { bytesReader, memoryStaging, type ByteReader, type StagedFile, type StagingArea } from "../../transaction/stream.ts"
|
|
18
|
+
import { ArchiveError, readTarGz } from "../archive-stream.ts"
|
|
19
|
+
|
|
20
|
+
/** The executable inside an installed Codex tree. */
|
|
21
|
+
export const CODEX_BINARY_REL = "bin/codex"
|
|
22
|
+
/** Upstream archives inflate to ~0.5 GB today; anything far beyond that is refused, as it inflates. */
|
|
23
|
+
export const MAX_UNPACKED_BYTES = 2 * 1024 ** 3 - 1
|
|
24
|
+
/** pax and GNU long-name records are small; a larger one is not a name. */
|
|
25
|
+
const MAX_NAME_RECORD = 64 * 1024
|
|
26
|
+
const STANDALONE = /^codex-[a-z0-9_]+-[a-z0-9_]+-[a-z0-9_]+(?:-[a-z0-9_]+)?$/
|
|
27
|
+
|
|
28
|
+
const bad = (why: string) => txFail("invalid", `the Codex archive is not acceptable: ${why}`)
|
|
29
|
+
|
|
30
|
+
function field(bytes: Uint8Array): string {
|
|
31
|
+
const end = bytes.indexOf(0)
|
|
32
|
+
return new TextDecoder().decode(end < 0 ? bytes : bytes.subarray(0, end))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** "<len> key=value\n" records of a pax extended header. */
|
|
36
|
+
function paxPath(body: Uint8Array): string | undefined {
|
|
37
|
+
const text = new TextDecoder().decode(body)
|
|
38
|
+
let path: string | undefined
|
|
39
|
+
let at = 0
|
|
40
|
+
while (at < text.length) {
|
|
41
|
+
const space = text.indexOf(" ", at)
|
|
42
|
+
const len = Number(text.slice(at, space))
|
|
43
|
+
if (space < 0 || !Number.isInteger(len) || len <= 0) break
|
|
44
|
+
const record = text.slice(space + 1, at + len - 1)
|
|
45
|
+
const eq = record.indexOf("=")
|
|
46
|
+
if (eq > 0 && record.slice(0, eq) === "path") path = record.slice(eq + 1)
|
|
47
|
+
at += len
|
|
48
|
+
}
|
|
49
|
+
return path
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const normalize = (name: string) => name.replace(/^(?:\.\/)+/, "").replace(/\/+$/, "")
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The install tree of a verified Codex .tar.gz read from `reader`: every file is streamed into `staging` (judged
|
|
56
|
+
* before its body is read). `maxBytes` (default MAX_UNPACKED_BYTES) bounds what the archive inflates to.
|
|
57
|
+
*/
|
|
58
|
+
export async function unpackCodexFile(reader: ByteReader, staging: StagingArea, options: { readonly maxBytes?: number } = {}): Promise<TxResult<TreeFile[]>> {
|
|
59
|
+
const files: { rel: string; staged: StagedFile; mode: number }[] = []
|
|
60
|
+
const seen = new Set<string>()
|
|
61
|
+
let nextName: string | undefined
|
|
62
|
+
try {
|
|
63
|
+
await readTarGz(reader, { maxBytes: options.maxBytes ?? MAX_UNPACKED_BYTES }, async (member) => {
|
|
64
|
+
const { type } = member.header
|
|
65
|
+
if (type === "x") {
|
|
66
|
+
nextName = paxPath(await member.bytes(MAX_NAME_RECORD)) ?? nextName
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
if (type === "g") return
|
|
70
|
+
if (type === "L") {
|
|
71
|
+
nextName = field(await member.bytes(MAX_NAME_RECORD))
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
const raw = nextName ?? member.header.name
|
|
75
|
+
nextName = undefined
|
|
76
|
+
if (raw.startsWith("/")) throw new ArchiveError(`${JSON.stringify(raw)} is an absolute path`)
|
|
77
|
+
const name = normalize(raw)
|
|
78
|
+
if (type === "5") {
|
|
79
|
+
if (name !== "" && !isSafeRel(name)) throw new ArchiveError(`${JSON.stringify(raw)} leaves the install directory`)
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
if (type !== "0" && type !== "7") throw new ArchiveError(`${JSON.stringify(raw)} is not a regular file (links and devices are never installed)`)
|
|
83
|
+
if (!isSafeRel(name)) throw new ArchiveError(`${JSON.stringify(raw)} leaves the install directory`)
|
|
84
|
+
if (seen.has(name)) throw new ArchiveError(`${name} appears twice`)
|
|
85
|
+
seen.add(name)
|
|
86
|
+
const staged = await staging.write(name, member.body(), { maxBytes: member.header.size })
|
|
87
|
+
files.push({ rel: name, staged, mode: (member.header.mode ?? 0o644) & 0o777 })
|
|
88
|
+
})
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (error instanceof ArchiveError) return bad(error.message)
|
|
91
|
+
throw error
|
|
92
|
+
}
|
|
93
|
+
if (files.length === 1 && STANDALONE.test(files[0]!.rel)) return txOk([{ rel: CODEX_BINARY_REL, staged: files[0]!.staged, mode: 0o755 }])
|
|
94
|
+
if (!seen.has(CODEX_BINARY_REL)) return bad(`it has no ${CODEX_BINARY_REL}`)
|
|
95
|
+
return txOk(files)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** unpackCodexFile over bytes in memory, the tree as bytes (small archives: tests, fixtures). */
|
|
99
|
+
export async function unpackCodexArchive(bytes: Uint8Array, options: { readonly maxBytes?: number } = {}): Promise<TxResult<(TreeFile & { readonly bytes: Uint8Array })[]>> {
|
|
100
|
+
const staging = memoryStaging()
|
|
101
|
+
const r = await unpackCodexFile(bytesReader(bytes), staging, options)
|
|
102
|
+
return r.ok ? txOk(r.value.map((f) => ({ rel: f.rel, mode: f.mode, bytes: "bytes" in f ? f.bytes : staging.bytes(f.staged) }))) : r
|
|
103
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// The Codex bootstrap adapter (HBS-09). Codex is the third harness in HARNESS_ORDER.
|
|
2
|
+
//
|
|
3
|
+
// createCodexAdapter the HarnessAdapter: discover (presence only), plan (planCodex: pinned install-harness,
|
|
4
|
+
// select-harness-version, verify-harness with real probes; no ~/.codex edit unless the
|
|
5
|
+
// opt-in TOML edit, which becomes a manual edit), verify (verifyCodex)
|
|
6
|
+
// createCodexHarnessSource the HarnessSource for the built-in install-harness executor (pinned download,
|
|
7
|
+
// sigstore evidence, unpack)
|
|
8
|
+
// codexVerifyExecutor the verify-harness executor (harness_not_ready unless advertised ready)
|
|
9
|
+
// codexCapabilities the explicit, probe-backed capability advertisement
|
|
10
|
+
//
|
|
11
|
+
// Hub wiring reuses packages/codex: the app-server handshake (openAppServer) and stdio channel the Codex spoke uses
|
|
12
|
+
// probe the app-server, and the spoke itself (hub codex-spoke serve) is what spoke-connected checks for.
|
|
13
|
+
import type { DiscoverySnapshot, HarnessAdapter, HarnessPresence, VerifiedManifest } from "../../contracts/index.ts"
|
|
14
|
+
import { codexPinFor, planCodex } from "./plan.ts"
|
|
15
|
+
import { codexBinaryPath, verifyCodex, type CodexProbes } from "./verify.ts"
|
|
16
|
+
|
|
17
|
+
export * from "./archive.ts"
|
|
18
|
+
export * from "./sigstore.ts"
|
|
19
|
+
export * from "./source.ts"
|
|
20
|
+
export * from "./plan.ts"
|
|
21
|
+
export * from "./verify.ts"
|
|
22
|
+
|
|
23
|
+
export interface CodexAdapterOptions {
|
|
24
|
+
/** The verified hub release manifest (its codex pins). */
|
|
25
|
+
readonly manifest: VerifiedManifest
|
|
26
|
+
/** The injected probe capabilities; the binary and expected version come from the snapshot and the pin. */
|
|
27
|
+
readonly probes: Omit<CodexProbes, "binary" | "expectedVersion"> & Partial<Pick<CodexProbes, "binary">>
|
|
28
|
+
/** The discovery snapshot of this host (discover reads presence from it). */
|
|
29
|
+
readonly snapshot: () => DiscoverySnapshot | undefined
|
|
30
|
+
/** The hub-installed Codex recorded before (profile history), for rollback records. */
|
|
31
|
+
readonly previous?: { readonly version: string; readonly sha256: string }
|
|
32
|
+
/** Opt-in: ask for features.daemon_auto_start in ~/.codex/config.toml (a TOML manual edit). */
|
|
33
|
+
readonly ensureDaemonFeature?: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CodexAdapter extends HarnessAdapter {
|
|
37
|
+
readonly options: CodexAdapterOptions
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const NOT_FOUND: HarnessPresence = { id: "codex", installed: false, health: { state: "unverified", probes: [] } }
|
|
41
|
+
|
|
42
|
+
export function createCodexAdapter(options: CodexAdapterOptions): CodexAdapter {
|
|
43
|
+
const pinFor = (snapshot: DiscoverySnapshot | undefined) => (snapshot ? codexPinFor(options.manifest, snapshot.host.target) : undefined)
|
|
44
|
+
return {
|
|
45
|
+
id: "codex",
|
|
46
|
+
options,
|
|
47
|
+
async discover() {
|
|
48
|
+
const found = options.snapshot()?.harnesses.find((h) => h.id === "codex")
|
|
49
|
+
// Presence only: health stays unverified until verify runs the real probes.
|
|
50
|
+
return found ? { ...found, health: { state: "unverified", probes: [] } } : NOT_FOUND
|
|
51
|
+
},
|
|
52
|
+
plan: (input) => planCodex(input, { manifest: options.manifest, ...(options.previous ? { previous: options.previous } : {}), ...(options.ensureDaemonFeature ? { ensureDaemonFeature: true } : {}) }),
|
|
53
|
+
async verify(context) {
|
|
54
|
+
const snapshot = options.snapshot()
|
|
55
|
+
const pin = pinFor(snapshot)
|
|
56
|
+
const binary = options.probes.binary ?? (() => (snapshot ? codexBinaryPath(snapshot, { preferHub: pin !== undefined }) : undefined))
|
|
57
|
+
return verifyCodex(context, { ...options.probes, binary, ...(pin ? { expectedVersion: pin.version } : {}) })
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// The Codex adapter's planner (HarnessAdapter.plan): pure in (snapshot, profile) and the adapter's fixed options.
|
|
2
|
+
//
|
|
3
|
+
// install-harness the artifact hub's verified manifest pins for Codex on this host's exact target, into
|
|
4
|
+
// its own version directory (harnessVersionRef); evidence: exactly what the pin lists
|
|
5
|
+
// (sigstore on Linux, none on macOS). No pin for the target: no install is planned (an
|
|
6
|
+
// unpinned Codex is never installed), only verification.
|
|
7
|
+
// select-harness-version makes that version current (harnessCurrentRef); a rollback makes the previous current.
|
|
8
|
+
// verify-harness real probes only: the binary runs (version-exec), its app-server starts and answers
|
|
9
|
+
// (api-health), its provider account is ready (provider-auth), and the hub sees the Codex
|
|
10
|
+
// spoke (spoke-connected).
|
|
11
|
+
//
|
|
12
|
+
// When hub's own install already has the pinned version current, only verification is planned. A Codex the user
|
|
13
|
+
// installed another way (brew, npm, Codex's install.sh into ~/.codex/packages) is never touched: hub installs and
|
|
14
|
+
// runs its own pinned copy.
|
|
15
|
+
//
|
|
16
|
+
// ~/.codex is preserved: hub's Codex wiring needs no edit of it (the spoke passes `-c` overrides to the app-server it
|
|
17
|
+
// starts, the wrapper passes `--enable daemon_auto_start` per run, and Codex ≥ 0.165 enables that feature by
|
|
18
|
+
// default). The one optional edit, `ensureDaemonFeature` (for a user who turned the feature off in config.toml), is
|
|
19
|
+
// a TOML merge-config, which the setup planner turns into an unmergeable-format manual edit: hub never writes TOML.
|
|
20
|
+
import {
|
|
21
|
+
executionContextOf,
|
|
22
|
+
harnessCurrentRef,
|
|
23
|
+
harnessVersionRef,
|
|
24
|
+
resolvePathRef,
|
|
25
|
+
type Action,
|
|
26
|
+
type DiscoverySnapshot,
|
|
27
|
+
type HarnessPin,
|
|
28
|
+
type PathRef,
|
|
29
|
+
type PlanInput,
|
|
30
|
+
type Precondition,
|
|
31
|
+
type ProbeKind,
|
|
32
|
+
type VerifiedManifest,
|
|
33
|
+
} from "../../contracts/index.ts"
|
|
34
|
+
|
|
35
|
+
/** The probes verify-codex runs; each must pass, fresh, before Codex is advertised as ready. */
|
|
36
|
+
export const CODEX_VERIFY_PROBES: readonly Exclude<ProbeKind, "presence">[] = ["version-exec", "api-health", "provider-auth", "spoke-connected"]
|
|
37
|
+
|
|
38
|
+
/** Codex's config file, as discovery reads it (home:.codex/config.toml). */
|
|
39
|
+
export const CODEX_CONFIG_REF: PathRef = { root: "home", rel: ".codex/config.toml" }
|
|
40
|
+
|
|
41
|
+
export interface CodexPlanOptions {
|
|
42
|
+
/** The verified hub release manifest; its codex pins are the only Codex artifacts ever installed. */
|
|
43
|
+
readonly manifest: VerifiedManifest
|
|
44
|
+
/** The hub-installed version and digest recorded before (profile history); used only if discovery agrees. */
|
|
45
|
+
readonly previous?: { readonly version: string; readonly sha256: string }
|
|
46
|
+
/** Opt-in: ask for `[features] daemon_auto_start = true` in ~/.codex/config.toml (a manual edit). */
|
|
47
|
+
readonly ensureDaemonFeature?: boolean
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const codexOf = (snapshot: DiscoverySnapshot) => snapshot.harnesses.find((h) => h.id === "codex")
|
|
51
|
+
|
|
52
|
+
/** The pin for Codex on exactly this target, if the manifest has one. */
|
|
53
|
+
export function codexPinFor(manifest: VerifiedManifest, target: string): HarnessPin | undefined {
|
|
54
|
+
return manifest.harnesses.find((h) => h.harness === "codex" && h.target === target)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function configPrecondition(snapshot: DiscoverySnapshot): Precondition | undefined {
|
|
58
|
+
const where = resolvePathRef(CODEX_CONFIG_REF, snapshot.roots, executionContextOf(snapshot))
|
|
59
|
+
if (!where.ok) return undefined
|
|
60
|
+
const found = snapshot.configs.find((c) => c.path === where.value)
|
|
61
|
+
return found?.sha256 ? { exists: true, sha256: found.sha256 } : { exists: false }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function planCodex(input: PlanInput, options: CodexPlanOptions): Action[] {
|
|
65
|
+
const { snapshot, profile } = input
|
|
66
|
+
if (!profile.harnesses.includes("codex")) return []
|
|
67
|
+
const actions: Action[] = []
|
|
68
|
+
const pin = codexPinFor(options.manifest, snapshot.host.target)
|
|
69
|
+
const found = codexOf(snapshot)
|
|
70
|
+
const hubVersion = found?.installed && found.installMethod === "hub" ? found.version : undefined
|
|
71
|
+
if (pin && hubVersion !== pin.version) {
|
|
72
|
+
const previous = hubVersion !== undefined && options.previous?.version === hubVersion ? { previous: options.previous } : {}
|
|
73
|
+
actions.push(
|
|
74
|
+
{
|
|
75
|
+
id: "install-codex",
|
|
76
|
+
kind: "install-harness",
|
|
77
|
+
escalation: "user",
|
|
78
|
+
summary: `Install Codex ${pin.version} for ${pin.target} (pinned by hub ${options.manifest.release})`,
|
|
79
|
+
harness: "codex",
|
|
80
|
+
version: pin.version,
|
|
81
|
+
target: pin.target,
|
|
82
|
+
source: { manifest: options.manifest.release, url: pin.url, file: pin.file },
|
|
83
|
+
size: pin.size,
|
|
84
|
+
sha256: pin.sha256,
|
|
85
|
+
evidence: [...pin.evidence],
|
|
86
|
+
path: harnessVersionRef("codex", pin.version),
|
|
87
|
+
owner: "hub",
|
|
88
|
+
rollback: "restore-previous",
|
|
89
|
+
...previous,
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: "select-codex",
|
|
93
|
+
kind: "select-harness-version",
|
|
94
|
+
escalation: "user",
|
|
95
|
+
summary: `Make Codex ${pin.version} the current version`,
|
|
96
|
+
harness: "codex",
|
|
97
|
+
version: pin.version,
|
|
98
|
+
path: harnessCurrentRef("codex"),
|
|
99
|
+
target: harnessVersionRef("codex", pin.version),
|
|
100
|
+
...previous,
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
if (options.ensureDaemonFeature) {
|
|
105
|
+
const precondition = configPrecondition(snapshot)
|
|
106
|
+
if (precondition)
|
|
107
|
+
actions.push({
|
|
108
|
+
id: "codex-daemon-feature",
|
|
109
|
+
kind: "merge-config",
|
|
110
|
+
escalation: "user",
|
|
111
|
+
summary: "Turn on Codex's shared app-server daemon (features.daemon_auto_start) so the hub sees TUI threads",
|
|
112
|
+
path: CODEX_CONFIG_REF,
|
|
113
|
+
format: "toml",
|
|
114
|
+
owner: "harness:codex",
|
|
115
|
+
precondition,
|
|
116
|
+
set: [{ pointer: "/features/daemon_auto_start", value: true }],
|
|
117
|
+
preserveUnknown: true,
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
actions.push({
|
|
121
|
+
id: "verify-codex",
|
|
122
|
+
kind: "verify-harness",
|
|
123
|
+
escalation: "user",
|
|
124
|
+
summary: "Verify Codex really runs, its app-server answers, its provider account is ready and its spoke reaches the hub",
|
|
125
|
+
harness: "codex",
|
|
126
|
+
probes: [...CODEX_VERIFY_PROBES],
|
|
127
|
+
})
|
|
128
|
+
return actions
|
|
129
|
+
}
|