Files
orca/src/relay/agent-hook-server.ts
T
f238952be2 Agent status over WSL: guest-resident hook relay + WSL-side hook installers (STA-1515) (#7903)
* docs: full design + context for agent status over WSL (STA-1515)

Why hooks don't work on Windows+WSL (loopback transport gap + WSL-side
installation gap), per-client transport map, the OMP-only fixes that
shipped (7642/7641) and why they don't generalize, the recommended
guest-resident relay over wsl.exe stdio mirroring the SSH relay plus
WSL-side hook installers, alternatives considered, validation facts and
gotchas from the 2026-07-08 Windows rig run, and acceptance criteria.

Co-authored-by: Orca <help@stably.ai>

* feat(agent-hooks): agent status over WSL — guest relay + WSL-side hook installers (STA-1515)

Agent hooks have never worked from inside WSL: under default NAT
networking, WSL's 127.0.0.1 is its own loopback, so every hook POST to
the Windows listener dies silently, and hook configs were only ever
written to the Windows home where WSL agents never see them.

Transport: a hooks-only guest relay (src/relay/wsl-agent-hook-relay.ts)
runs inside the distro, binds WSL loopback on the very port the clients
were already given (host-issued token; EADDRINUSE falls back to :0 with
endpoint-file re-coordination, which also covers mirrored networking),
and forwards parsed envelopes over its own wsl.exe stdio into
agentHookServer.ingestRemote — the same shape as the SSH relay. It exits
when stdin closes so a freed Windows port can never be forwarded into a
dead guest listener.

Installation: the unchanged SSH remote hook installers run against an
SFTP-shaped adapter whose primitives are home-scoped fs RPCs served by
the relay, so all 14 managed agents' hooks land in the WSL home over the
already-open channel with zero per-file wsl.exe spawns.

Lifecycle: per-distro manager ensured from buildPtyHostEnv on every WSL
PTY spawn (covers post-restart daemon reattach re-spawns), stale-bundle
reinstall via exit 42, no-node-43 cooldown, bounded retry for wsl.exe
'Catastrophic failure (E_UNEXPECTED)', breadcrumbed failures.

Zero per-client transport changes; listener stays Windows-loopback-only.

Co-authored-by: Orca <help@stably.ai>

* fix(agent-hooks): WSL relay link-death recovery + Codex runtime-home hook install (STA-1515)

Follow-ups from the first Windows-rig validation of PR #7903:

Link death: a mux protocol error or keepalive timeout could kill the
host<->guest link while the guest relay stayed alive returning 204s —
the manager stayed 'running' and every later envelope blackholed
silently (the exact observed signature: Claude hooks POST 204, store
never populates). wsl-hook-relay-link.ts now guarantees exactly-once
death handling from either signal (mux dispose OR child exit); the
manager breadcrumbs it, kills the child, and self-restarts after a
short cooldown since a live agent session produces no new PTY spawns
to re-trigger ensure. ORCA_WSL_HOOK_RELAY_DEBUG=1 traces each received
envelope pre-ingest. A live integration test pins the full host chain:
the real esbuild bundle over real child stdio through the real manager
into a real AgentHookServer.ingestRemote, exact Claude POST shape.

Codex: Orca launches WSL Codex with CODEX_HOME redirected to the
managed runtime home (~/.local/share/orca/codex-runtime-home/home), so
hooks installed to ~/.codex were never read. installRemote now accepts
an explicit codex home (flat layout), threaded from the relay manager;
the config.toml trust write is deferred while the file doesn't exist
(the launch path seeds it only-if-absent — creating it first would
cancel the seed), and the manager re-runs the byte-equality-idempotent
installers on later ensures (30s throttle) to upsert trust once the
seed lands.

Also: WSL test suites now run on Windows dev hosts (fs-backed suites
skip with rig coverage noted; manager suite uses a fixed POSIX home).

Co-authored-by: Orca <help@stably.ai>

* fix(agent-hooks): renderer ownership gate treats wsl:* connection ids as local (STA-1515)

Round-2 rig finding: with the link fixed, WSL hook envelopes reached
ingestRemote and the durable cache, but useIpcEvents.applyAgentStatus
drops any status whose stamped connectionId differs from the owning
repo's — 'wsl:<distro>' !== null for a local repo, so every WSL-relayed
status died before setAgentStatus and notifications.

wsl:* ids are transport provenance, not ownership: the gate now
normalizes them to local via isWslHookRelayConnectionId (shared
contract, also used by the relay link when stamping), while still
rejecting WSL-stamped events against SSH-owned repos. Provenance stays
stamped — it is what made this drop diagnosable.

Co-authored-by: Orca <help@stably.ai>

* fix(agent-hooks): adversarial-review hardening for the WSL hook relay (STA-1515)

Four independent review lenses over the branch; all confirmed findings
fixed before the next rig round:

Endpoint identity (4/4 reviewers): the guest endpoint dir was keyed by
the EPHEMERAL Windows hook port, so a daemon-surviving agent kept
sourcing the dead port-P1 file after an Orca restart — breaking the
restart-resume acceptance criterion and regressing shipped OMP
recovery. Now keyed by a restart-stable instance key (hash of the
Windows endpoint file path, crossed via ORCA_WSL_HOOK_INSTANCE): the
restarted instance's relay rewrites the SAME file, which is exactly
what re-coordinates survivors.

Restart policy: every failure arms the restart timer (one failed
relaunch no longer ends self-recovery), and the timer probes
wsl --list --running first — wsl -d BOOTS a stopped distro, so
recovery must never resurrect a VM the user shut down; stopped-distro
state is dropped instead. Failure counters reset only after 2min of
stable uptime, so connect-then-die loops escalate to the 10-min cap
instead of cycling every 10s. Timer policy extracted to
wsl-hook-relay-recovery.ts with direct tests.

Also: version-namespaced guest install dir (dev+prod instances no
longer reinstall over each other; PID-suffixed tmp files), 30s install
timeout (a wedged wsl.exe could pin the state machine at 'starting'
forever), per-candidate node version probing (apt node 12 on PATH no
longer masks nvm node 20 into a false no-node cooldown), WSL_UTF8=1 +
NUL-stripped stderr (catastrophic-failure matcher survives UTF-16LE),
ordered post-sentinel chunk handoff, port-fallback breadcrumb via the
home handshake, bad home reply now fails the connect, missing-bundle
warn-once, case-normalized distro keys, disposeAll wired to will-quit,
one-shot 60s reinstall timer for single-spawn Codex trust catch-up,
escaped + contract-derived spawn command.

Co-authored-by: Orca <help@stably.ai>

* docs: record round-3 rig validation status for agent status over WSL (STA-1515)

Co-authored-by: Orca <help@stably.ai>

* fix(agent-hooks): round-4 adversarial-review fixes for the WSL hook relay (STA-1515)

- dropState identity race: recovery re-checks state identity after the
  distro-running probe await, and the manager's dropState only deletes the
  exact state it was armed for — an ensure() landing mid-probe can no longer
  have its fresh relay orphaned outside the map.
- Distro-running probe fails CLOSED: a probe error no longer reports
  'running', so recovery can never wsl-d-boot a distro the user shut down.
- Relay spawns use --exec: bypasses the distro's default login shell
  (fish/nushell chsh) and passes argv verbatim, dropping the $-escape shim;
  same form as the Codex WSL login spawn.
- Post-sentinel chunk handoff rides a microtask so an envelope in the
  trailing bytes can no longer dispatch before the link's notification
  handler is registered.
- Guest relay mirrors the SSH relay's uncaughtException/unhandledRejection
  posture.
- Replay cache capped at 256 panes with recency eviction (the WSL relay has
  no per-pane teardown signal); meta map kept in lockstep.
- Launch script derives the stale-exit code from the shared contract
  constant; one-shot reinstall timer refuses to arm after dispose.
- New oracles: sentinel unit suite, fs-bridge scoping suite, fixed-token
  403/204, EADDRINUSE endpoint-file rewrite, cache-cap eviction, and the
  recovery/manager race regressions (verified to fail with fixes reverted).
- Doc: round-4 review section + revised curl.exe stance (kept as the
  no-node fallback — Codex is a native binary; fresh distros ship no node).

* docs: record round-4 pinned rig validation for agent status over WSL (STA-1515)

---------

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Brennan Benson <brennanbenson@Brennans-MacBook-Pro.local>
2026-07-10 00:19:45 -07:00

473 lines
18 KiB
TypeScript

/* eslint-disable max-lines -- Why: relay hook parsing, replay cache, endpoint
writing, and assistant-message retry state are one lifecycle unit; splitting
them would obscure cleanup ordering across remote PTY reconnects. */
// Why: relay-side adapter for the shared agent-hook listener pipeline. Hosts
// a loopback HTTP server (same shape as Orca's main-process server: bind
// 127.0.0.1:0, bearer-token auth, /hook/<source> routing) and forwards every
// parsed payload via a callback so `relay.ts` can re-emit it as an
// `agent.hook` JSON-RPC notification across the existing SSH channel.
//
// Per-instance state (warn-once Sets, last-status cache, last-prompt /
// last-tool caches) lives on `HookListenerState`. The cache is bounded to one
// entry per paneKey — see docs/design/agent-status-over-ssh.md §5 (Path 3,
// request-driven replay) for the rationale.
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import { randomUUID } from 'node:crypto'
import { basename, dirname, join } from 'node:path'
import { homedir } from 'node:os'
import { ORCA_HOOK_PROTOCOL_VERSION } from '../shared/agent-hook-types'
import {
clearAllListenerCaches,
clearPaneCacheState,
createHookListenerState,
getEndpointFileName,
hasPendingAgentResultText,
HOOK_REQUEST_SLOWLORIS_MS,
normalizeHookPayload,
readRequestBody,
resolveHookSource,
writeEndpointFile,
type AgentHookEventPayload,
type HookListenerState
} from '../shared/agent-hook-listener'
import {
REMOTE_AGENT_HOOK_ENV,
type AgentHookRelayEnvelope,
type AgentHookSource
} from '../shared/agent-hook-relay'
export type RelayHookForward = (envelope: AgentHookRelayEnvelope) => void
// Why: relay's userData equivalent. Lives under $HOME so each user on a
// shared dev box gets their own dir, owned 0o700. Mirrors RELAY_REMOTE_DIR
// from `ssh-relay-deploy.ts` but stays local to this module — the hook
// server is the only consumer.
const RELAY_HOOKS_DIR_NAME = '.orca-relay'
const RELAY_HOOKS_SUBDIR = 'agent-hooks'
const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5
const ASSISTANT_MESSAGE_RETRY_MS = 50
// Why: cap env/version metadata at 64 chars so a misbehaving agent CLI
// cannot grow lastEnvelopeMetaByPaneKey unboundedly per pane via the cache
// + replay path. Canonical values are short ('production'/'development',
// '1'/'999'); anything longer is treated as absent.
const MAX_HOOK_META_LEN = 64
// Why: the WSL relay has no per-pane teardown signal (PTYs live on the
// Windows host, so nothing calls clearPaneState), and the replay cache would
// otherwise grow for the relay's lifetime. Recency-cap it; backstop for the
// SSH relay too.
const MAX_CACHED_PANES = 256
function defaultEndpointDir(): string {
return join(homedir(), RELAY_HOOKS_DIR_NAME, RELAY_HOOKS_SUBDIR)
}
function isWindowsNamedPipePath(sockPath: string): boolean {
return /^\\\\[.?]\\pipe\\/i.test(sockPath)
}
function windowsNamedPipeEndpointName(sockPath: string): string {
return (
sockPath
.replace(/^\\\\[.?]\\pipe\\/i, '')
.split(/[\\/]/)
.findLast(Boolean) ?? 'relay'
)
}
export function endpointDirForRelaySocket(sockPath: string): string {
if (isWindowsNamedPipePath(sockPath)) {
return join(defaultEndpointDir(), windowsNamedPipeEndpointName(sockPath))
}
return join(dirname(sockPath), RELAY_HOOKS_SUBDIR, basename(sockPath))
}
export type RelayHookServerOptions = {
/** Where to put endpoint.env / endpoint.cmd. Defaults to `$HOME/.orca-relay/agent-hooks`. */
endpointDir?: string
/** Env tag forwarded into hook payloads. Defaults to "remote", a relay
* location marker that main excludes from dev-vs-prod mismatch warnings. */
env?: string
/** Fixed auth token. The WSL relay passes the host-issued token that
* already crossed into guest env via WSLENV, so unmodified hook clients
* authenticate without any re-coordination. Defaults to a fresh UUID. */
token?: string
/** Preferred bind port. The WSL relay passes the Windows listener's port —
* free inside the guest under NAT, so env-sourced client coords stay
* truthful. Occupied (e.g. mirrored networking) → fall back to :0 and rely
* on the endpoint file for re-coordination. Defaults to :0. */
preferredPort?: number
/** Called once per parsed payload. The relay wires this to
* `dispatcher.notify('agent.hook', envelope)`. */
forward: RelayHookForward
}
export type RelayHookServerStartOptions = {
publishEndpoint?: boolean
}
export class RelayAgentHookServer {
private server: ReturnType<typeof createServer> | null = null
private port = 0
private token = ''
private env: string
private endpointDir: string
private endpointFilePath: string
private endpointFileWritten = false
private state: HookListenerState = createHookListenerState()
// Why: the shared `HookListenerState.lastStatusByPaneKey` cache only stores
// `AgentHookEventPayload` (no wire-envelope fields). Replay must still emit
// the original `source`/`env`/`version` so Orca's warn-once diagnostics fire
// identically to the live POST path. Keep this as a per-instance sidecar map
// so the shared listener type stays unchanged. Invariant: every key present
// in `state.lastStatusByPaneKey` must also be present here — populated and
// cleared in lockstep on the live POST path, clearPaneState, and stop().
private lastEnvelopeMetaByPaneKey: Map<
string,
{ source: AgentHookSource; env?: string; version?: string }
> = new Map()
private assistantMessageRetryTimers = new Map<string, ReturnType<typeof setTimeout>>()
private forward: RelayHookForward
private fixedToken: string | undefined
private preferredPort: number
private portFallbackApplied = false
constructor(options: RelayHookServerOptions) {
this.env = options.env ?? REMOTE_AGENT_HOOK_ENV
this.endpointDir = options.endpointDir ?? defaultEndpointDir()
this.endpointFilePath = join(this.endpointDir, getEndpointFileName())
this.fixedToken = options.token
this.preferredPort = options.preferredPort ?? 0
this.forward = options.forward
}
async start(options: RelayHookServerStartOptions = {}): Promise<void> {
if (this.server) {
return
}
this.token = this.fixedToken ?? randomUUID()
this.endpointFileWritten = false
this.portFallbackApplied = false
try {
await this.listenOn(this.preferredPort)
} catch (err) {
// Why: the preferred port is best-effort (WSL relay: the Windows
// listener's port — occupied under mirrored networking, or by an
// unrelated guest process). Fall back to an ephemeral port; clients
// re-coordinate through the endpoint file.
if (this.preferredPort > 0 && (err as NodeJS.ErrnoException)?.code === 'EADDRINUSE') {
this.portFallbackApplied = true
await this.listenOn(0)
} else {
throw err
}
}
if (options.publishEndpoint !== false) {
this.publishEndpointFile()
}
}
/** True when the preferred port was occupied and the server fell back to
* an ephemeral bind — diagnostics for the host-side relay manager. */
get usedPortFallback(): boolean {
return this.portFallbackApplied
}
private listenOn(port: number): Promise<void> {
this.server = createServer((req, res) => this.handleRequest(req, res))
return new Promise<void>((resolve, reject) => {
const onStartupError = (err: Error): void => {
this.server?.off('listening', onListening)
// Why: null the server reference on bind failure so a subsequent
// start() can retry. Without this, a failed bind (e.g. EMFILE) leaves
// this.server populated and the early-return at the top of start()
// wedges the relay into a permanently broken state until stop() runs.
this.server = null
reject(err)
}
const onListening = (): void => {
this.server?.off('error', onStartupError)
this.server?.on('error', (err) => {
process.stderr.write(`[relay-hook-server] server error: ${err.message}\n`)
})
const address = this.server!.address()
if (address && typeof address === 'object') {
this.port = address.port
}
resolve()
}
this.server!.once('error', onStartupError)
// Why: loopback only — the agent CLI inside the same remote box reaches
// us via curl 127.0.0.1:PORT; nobody outside the box can.
this.server!.listen(port, '127.0.0.1', onListening)
})
}
publishEndpointFile(): boolean {
if (this.port <= 0 || !this.token) {
this.endpointFileWritten = false
return false
}
this.endpointFileWritten = writeEndpointFile(this.endpointDir, this.endpointFilePath, {
port: this.port,
token: this.token,
env: this.env,
version: ORCA_HOOK_PROTOCOL_VERSION
})
return this.endpointFileWritten
}
stop(): void {
this.server?.close()
this.server = null
this.port = 0
this.token = ''
this.endpointFileWritten = false
for (const timer of this.assistantMessageRetryTimers.values()) {
clearTimeout(timer)
}
this.assistantMessageRetryTimers.clear()
clearAllListenerCaches(this.state)
this.lastEnvelopeMetaByPaneKey.clear()
}
/** Request-driven replay: walks the per-paneKey last-payload cache and
* forwards each entry as a fresh notification. Called after Orca has
* re-wired its `agent.hook` handler on the new mux post-`--connect`.
* The relay-driver issues the replay forwards BEFORE returning from the
* request handler so the response strictly trails all replayed
* notifications on the dispatcher's single write callback. */
replayCachedPayloadsForPanes(): number {
let count = 0
for (const [paneKey, event] of this.state.lastStatusByPaneKey.entries()) {
const meta = this.lastEnvelopeMetaByPaneKey.get(paneKey)
// Why: invariant — every paneKey in the shared status cache is populated
// in lockstep with `lastEnvelopeMetaByPaneKey`. If meta is missing,
// something has drifted; skip rather than fall back to a guessed source
// that would mis-tag the event downstream.
if (!meta) {
continue
}
this.forwardEvent(event, meta.source, meta.env, meta.version, { isReplay: true })
count++
}
return count
}
/** Drop a paneKey's cached entries on PTY exit so a terminated pane never
* resurfaces as a ghost event on a later reconnect. Symmetric with the
* local server's clearPaneState on PTY teardown. */
clearPaneState(paneKey: string): void {
this.clearAssistantMessageRetry(paneKey)
clearPaneCacheState(this.state, paneKey)
this.lastEnvelopeMetaByPaneKey.delete(paneKey)
}
/** Env vars to inject into every relay-spawned PTY so the hook script /
* in-process plugin POSTs to this loopback server. */
buildPtyEnv(): Record<string, string> {
if (this.port <= 0 || !this.token) {
return {}
}
const env: Record<string, string> = {
ORCA_AGENT_HOOK_PORT: String(this.port),
ORCA_AGENT_HOOK_TOKEN: this.token,
ORCA_AGENT_HOOK_ENV: this.env,
ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION
}
if (this.endpointFileWritten) {
env.ORCA_AGENT_HOOK_ENDPOINT = this.endpointFilePath
}
return env
}
/** Test-only / diagnostics accessor. */
getCoordinates(): { port: number; token: string; endpointFilePath: string } {
return { port: this.port, token: this.token, endpointFilePath: this.endpointFilePath }
}
// ─── Private ──────────────────────────────────────────────────────
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
if (req.method !== 'POST') {
res.writeHead(404)
res.end()
return
}
if (req.headers['x-orca-agent-hook-token'] !== this.token) {
res.writeHead(403)
res.end()
return
}
req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => {
req.destroy()
})
try {
const body = await readRequestBody(req)
const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname
const source = resolveHookSource(pathname)
if (!source) {
res.writeHead(404)
res.end()
return
}
const event = normalizeHookPayload(this.state, source, body, this.env)
if (event) {
// TODO: once normalizeHookPayload returns validated env/version, drop
// bodyEnv/bodyVersion and source those from the listener result instead.
const env = this.bodyEnv(body)
const version = this.bodyVersion(body)
this.applyEvent(event, source, env, version)
this.scheduleAssistantMessageRetry(source, body, event, env, version)
}
res.writeHead(204)
res.end()
} catch (err) {
// Why: agent hooks must fail open — return success on parse / size /
// timeout errors so a buggy agent script never blocks the agent run.
// Log the swallowed error to stderr so future programmer bugs are not
// invisible (the 204 response would otherwise mask them entirely).
process.stderr.write(
`[relay-hook-server] hook request failed: ${err instanceof Error ? err.message : String(err)}\n`
)
res.writeHead(204)
res.end()
}
}
private forwardEvent(
event: AgentHookEventPayload,
source: AgentHookSource,
env?: string,
version?: string,
options: { isReplay?: boolean } = {}
): void {
const envelope: AgentHookRelayEnvelope = {
source,
paneKey: event.paneKey,
...(event.launchToken ? { launchToken: event.launchToken } : {}),
tabId: event.tabId,
worktreeId: event.worktreeId,
connectionId: null,
hasExplicitPrompt: event.hasExplicitPrompt,
promptInteractionKey: event.promptInteractionKey,
hookEventName: event.hookEventName,
toolUseId: event.toolUseId,
toolAgentId: event.toolAgentId,
toolAgentType: event.toolAgentType,
...(event.providerSession ? { providerSession: event.providerSession } : {}),
isReplay: options.isReplay === true ? true : undefined,
env,
version,
payload: event.payload
}
this.forward(envelope)
}
private applyEvent(
event: AgentHookEventPayload,
source: AgentHookSource,
env?: string,
version?: string
): void {
if (event.payload.state !== 'done' || event.payload.lastAssistantMessage) {
this.clearAssistantMessageRetry(event.paneKey)
}
// Why: delete-then-set keeps Map insertion order equal to last-update
// recency, so the cache cap below always evicts the longest-idle pane.
this.state.lastStatusByPaneKey.delete(event.paneKey)
this.state.lastStatusByPaneKey.set(event.paneKey, event)
this.lastEnvelopeMetaByPaneKey.delete(event.paneKey)
this.lastEnvelopeMetaByPaneKey.set(event.paneKey, { source, env, version })
while (this.state.lastStatusByPaneKey.size > MAX_CACHED_PANES) {
const oldest = this.state.lastStatusByPaneKey.keys().next().value
if (oldest === undefined) {
break
}
this.clearPaneState(oldest)
}
this.forwardEvent(event, source, env, version)
}
private clearAssistantMessageRetry(paneKey: string): void {
const timer = this.assistantMessageRetryTimers.get(paneKey)
if (!timer) {
return
}
clearTimeout(timer)
this.assistantMessageRetryTimers.delete(paneKey)
}
private scheduleAssistantMessageRetry(
source: AgentHookSource,
body: unknown,
original: AgentHookEventPayload,
env?: string,
version?: string,
attempt = 1
): void {
if (
original.payload.lastAssistantMessage ||
!hasPendingAgentResultText(source, body) ||
attempt > ASSISTANT_MESSAGE_RETRY_ATTEMPTS
) {
return
}
this.clearAssistantMessageRetry(original.paneKey)
const timer = setTimeout(() => {
try {
this.assistantMessageRetryTimers.delete(original.paneKey)
const current = this.state.lastStatusByPaneKey.get(original.paneKey)
if (
!current ||
current.payload.agentType !== original.payload.agentType ||
current.payload.prompt !== original.payload.prompt ||
current.payload.lastAssistantMessage
) {
return
}
const event = normalizeHookPayload(this.state, source, body, this.env)
if (!event?.payload.lastAssistantMessage) {
this.scheduleAssistantMessageRetry(source, body, original, env, version, attempt + 1)
return
}
// Why: the relay runs on SSH targets too; retry from a timer so a delayed
// transcript/chat-history write does not block the remote hook server.
this.applyEvent(event, source, env, version)
} catch (err) {
process.stderr.write(
`[relay-hook-server] assistant message retry failed: ${err instanceof Error ? err.message : String(err)}\n`
)
}
}, ASSISTANT_MESSAGE_RETRY_MS)
this.assistantMessageRetryTimers.set(original.paneKey, timer)
if (typeof timer.unref === 'function') {
timer.unref()
}
}
private bodyEnv(body: unknown): string | undefined {
if (typeof body !== 'object' || body === null) {
return undefined
}
const v = (body as Record<string, unknown>).env
if (typeof v !== 'string' || v.length === 0 || v.length > MAX_HOOK_META_LEN) {
return undefined
}
return v
}
private bodyVersion(body: unknown): string | undefined {
if (typeof body !== 'object' || body === null) {
return undefined
}
const v = (body as Record<string, unknown>).version
if (typeof v !== 'string' || v.length === 0 || v.length > MAX_HOOK_META_LEN) {
return undefined
}
return v
}
}