mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
* fix(relay): diagnose why node-pty will not load instead of hedging The relay could only say "terminals are unavailable" and then list three remedies for four different faults, none of which the user could verify (#17830). Two things were destroying the evidence: - `loadPtyUncached` caught the load error into bare `catch {}` blocks (pty-handler.ts:539, :551) and returned null. The only cause anyone had was discarded on the spot. - node-pty's own loader walks three directories and rethrows only the LAST failure, so even an uncaught error arrives as `Cannot find module '../prebuilds/...'` — the GLIBC/ABI/arch sentence is already gone. The relay now keeps the load error, recovers the real dlopen message with an out-of-process load of the file node-pty would have opened, reads what node-gyp configured the binding for (`build/config.gypi`), captures the host's Node ABI, arch and glibc, and probes the toolchain only when nothing was compiled. Each fault gets its own message naming values the user can check: toolchain_missing, dependency_missing, abi_mismatch, arch_mismatch, libc_floor, shared_library_missing, load_crashed, and load_failed which quotes the loader verbatim. A probe that did not answer stays `unverifiable` and prescribes nothing. The classification is now also structured data on the error, so a client can repair the host instead of printing a paragraph: an additive, schema-validated `data` field on an existing JSON-RPC error, with `repairable` true only for a proved fault that recompiling on the host actually fixes. Reuses orcad's loader-message parsers and out-of-process probe rather than adding a second copy; `classifyLoaderMessage` moves to a shared module and gains architecture and missing-shared-library cases, which the orcad boot precondition picks up too. * fix(ssh): repair a rebuildable node-pty failure once, instead of asking the user to reconnect
88 lines
4.0 KiB
TypeScript
88 lines
4.0 KiB
TypeScript
/**
|
|
* The machine-readable half of "remote terminals are unavailable".
|
|
*
|
|
* Why this exists: the fault is proved on the relay, at spawn time, and the machinery
|
|
* that can repair it (`repairInstalledNativeDeps`) lives on the client, at connect time.
|
|
* Until now the only thing that crossed the wire was prose, so the client could not tell
|
|
* a rebuildable ABI flip from a host whose glibc will never satisfy the binary — and the
|
|
* message had to hedge across all of them.
|
|
*
|
|
* Wire compatibility (docs/reference/remote-wire-compatibility.md): this rides as the
|
|
* optional `data` of an existing JSON-RPC error, so it is Rule 1 — additive. A client
|
|
* that does not read it still renders `error.message`, which is exactly today's
|
|
* behaviour, so no capability negotiation is needed.
|
|
*
|
|
* `repairable` is the field with teeth: it is true only for a fault that was PROVED and
|
|
* that rebuilding node-pty on the host actually fixes. An `unverifiable` cause is never
|
|
* repairable — a probe that did not answer must not trigger a destructive repair, which
|
|
* is the #14830 lesson recorded in docs/reference/ssh-execution-boundary.md.
|
|
*/
|
|
import { z } from 'zod'
|
|
import { TERMINAL_UNAVAILABLE_ERROR_CODE } from './runtime-capability-degradation'
|
|
|
|
export const TERMINAL_UNAVAILABLE_RPC_ERROR_CODE = TERMINAL_UNAVAILABLE_ERROR_CODE
|
|
|
|
const TerminalUnavailableHostSchema = z
|
|
.object({
|
|
platform: z.string().min(1).max(32),
|
|
arch: z.string().min(1).max(32),
|
|
libc: z.enum(['glibc', 'musl', 'none']),
|
|
/** Absent, not null, when the host reports no version — see native-host-abi.ts. */
|
|
glibcVersion: z.string().min(1).max(32).optional(),
|
|
/** `NODE_MODULE_VERSION` the remote runtime accepts. */
|
|
nodeAbi: z.string().min(1).max(16),
|
|
nodeVersion: z.string().min(1).max(32)
|
|
})
|
|
.strict()
|
|
|
|
export const TerminalUnavailableCauseSchema = z
|
|
.object({
|
|
/** `blocked` — proved. `unverifiable` — nothing answered; never act on it. */
|
|
status: z.enum(['blocked', 'unverifiable']),
|
|
/**
|
|
* Open vocabulary, deliberately `string` rather than an enum: a newer relay may name a
|
|
* reason this client has never heard of, and a strict enum would drop the whole cause
|
|
* (including `repairable`) rather than the one field it cannot interpret.
|
|
*/
|
|
reason: z.string().min(1).max(64),
|
|
detail: z.string().max(400),
|
|
/** Proved, and rebuilding node-pty on the host is the fix. */
|
|
repairable: z.boolean(),
|
|
host: TerminalUnavailableHostSchema,
|
|
/** The dynamic loader's own words, when they were recovered. */
|
|
rawError: z.string().max(1000).optional()
|
|
})
|
|
.strict()
|
|
|
|
export type TerminalUnavailableCause = z.infer<typeof TerminalUnavailableCauseSchema>
|
|
|
|
/** Null for anything that does not validate; a malformed cause must never be acted on. */
|
|
export function parseTerminalUnavailableCause(value: unknown): TerminalUnavailableCause | null {
|
|
const parsed = TerminalUnavailableCauseSchema.safeParse(value)
|
|
return parsed.success ? parsed.data : null
|
|
}
|
|
|
|
/**
|
|
* The cause carried by a rejected JSON-RPC call, or null when there is none to act on.
|
|
*
|
|
* Reads `data`, never `code`: the dispatcher coerces a non-numeric error code to -32000 on the
|
|
* way out, so the string code does not survive the wire. The strict schema is the whole gate —
|
|
* no other published `data` shape validates against it.
|
|
*/
|
|
export function terminalUnavailableCauseFromError(error: unknown): TerminalUnavailableCause | null {
|
|
if (typeof error !== 'object' || error === null || !('data' in error)) {
|
|
return null
|
|
}
|
|
return parseTerminalUnavailableCause((error as { data: unknown }).data)
|
|
}
|
|
|
|
/**
|
|
* Whether the client may rewrite the host's `node_modules` on the strength of this cause.
|
|
*
|
|
* Deliberately re-derived here rather than trusting `repairable` alone: the flag arrives
|
|
* from a peer, and only a `blocked` status is evidence of anything.
|
|
*/
|
|
export function mayRepairFromCause(cause: TerminalUnavailableCause | null): boolean {
|
|
return cause !== null && cause.status === 'blocked' && cause.repairable
|
|
}
|