diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index 98cbb42ee90..ff646d206d9 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -193,7 +193,7 @@ The check covers the intent folder and record. It does not protect against repla On the Home, run `ocx link probe child-a --json` to inspect the offered SSH fingerprint, key type and OpenCodex version. Verify the fingerprint through a trusted source. Then run `ocx link add child-a --expect-fingerprint SHA256:… --json`. The command probes again and refuses a mismatch before storing trust or issuing a key. Without the fingerprint flag, it requires an interactive terminal and an explicit `yes` after showing the fingerprint. It never prints the link key. -Home-initiated connections now restart the Child after saving its connection, including capable older Child installations. The Child must already serve standalone OpenCodex on its configured port and expose bound restart support. POSIX Children need curl; Windows uses a bounded loopback HTTP request through .NET. Update or disconnect a Child that fails this preflight. Desktop and system OpenCodex installations take precedence over generic PATH shims; broken candidates are skipped. Windows x64 Children use encoded PowerShell commands without consuming key stdin. +Home-initiated connections now restart the Child after saving its connection, including capable older Child installations. The Child must already serve standalone OpenCodex on its configured port and expose bound restart support. POSIX Children need curl; Windows uses a bounded loopback HTTP request through .NET. Update or disconnect a Child that fails this preflight. Desktop and system OpenCodex installations take precedence over generic PATH shims; broken candidates are skipped. Windows x64 Children use encoded PowerShell commands without consuming key stdin. The preflight reads `ocx config show --json` under the runner's 4 MiB capture limit, so a Child with a large saved configuration still accepts a link; output past that limit answers `child_preflight_output_limit` and names the command to trim. ### Ephemeral Child machines (`ocx-child`) diff --git a/src/cli/link-enrollment.ts b/src/cli/link-enrollment.ts index 09dd12898bd..adee88bfbae 100644 --- a/src/cli/link-enrollment.ts +++ b/src/cli/link-enrollment.ts @@ -204,6 +204,7 @@ export async function runLinkEnrollment(command: "probe" | "add", args: string[] const code = record(error.body.error) ? error.body.error.code : undefined; if (code === "remote_platform_unsupported" && error.body.residual !== true) throw new Error("The remote shell cannot run this Remote Link workflow. Update OpenCodex on Home and check PowerShell on the Child."); if (code === "child_port_busy") throw new Error("The target port on the Child is already in use by another process; no Child files were modified."); + if (code === "child_preflight_output_limit") throw new Error("The Child's ocx config show --json output exceeded the Remote Link preflight limit; trim the Child's saved configuration and retry."); if (code === "child_external_provider_conflict") throw new Error("The Child's ~/.codex/config.toml is already managed by another model provider or custom base URL; no Child files were modified."); const phase = typeof code === "string" && Object.hasOwn(PHASES, code) ? PHASES[code] : undefined; const message = phase ? "Remote Link failed during " + phase : error.message; diff --git a/src/server/management/link-child-preflight.ts b/src/server/management/link-child-preflight.ts new file mode 100644 index 00000000000..6bcedc8ce90 --- /dev/null +++ b/src/server/management/link-child-preflight.ts @@ -0,0 +1,21 @@ +import { SSH_MAX_OUTPUT_BYTES } from "../../link/ssh-runner"; + +/** + * Home enrollment reads one Child command before it may restart that Child: `ocx config show + * --json`, for the effective `port`, `runtimeRole` and (ephemeral) `ephemeralChild` fields. + * + * There is no smaller command that answers on every Child this Home supports. Full `ocx` has + * `config get ` since 2.66.0, but resolving only fields that happen to be persisted + * cannot tell an absent `runtimeRole` (standalone by default) from an unreadable one, and it + * fails outright for an absent key. The lightweight `ocx-child` binary answers only `config show + * --json`. A Child is therefore allowed the runner's maximum capture here: a saved configuration + * of ordinary size stays well under it, and the 64 KiB default rejected a large-but-valid one + * before preflight could read the two fields it needs (#8356). + */ +export const CHILD_PREFLIGHT_MAX_OUTPUT_BYTES = SSH_MAX_OUTPUT_BYTES; + +export const CHILD_CONFIG_TOO_LARGE_MESSAGE = "The Child configuration is too large for the Remote Link preflight."; + +/** The operator-facing hint for a Child whose config output exceeded even the raised cap. */ +export const CHILD_CONFIG_TOO_LARGE_HINT = + `ocx config show --json on the Child exceeded ${CHILD_PREFLIGHT_MAX_OUTPUT_BYTES} bytes; trim the Child's saved configuration and retry`; diff --git a/src/server/management/link-enrollment.ts b/src/server/management/link-enrollment.ts index e6397ea4b94..c157e964fa9 100644 --- a/src/server/management/link-enrollment.ts +++ b/src/server/management/link-enrollment.ts @@ -14,6 +14,7 @@ import { withConfigMutationLockSync } from "../../config/mutation-lock"; import type { ManagementContext } from "./context"; import { readManagementJsonBodyOr } from "./body"; import { fail, exactBody, port, isRecord, runnerFor, readStoreFor, writeStoreFor, knownHostsFile, putKnownHost, issueKey, compensateNewLink, type LinkRouteState } from "./link-route-state"; +import { CHILD_CONFIG_TOO_LARGE_HINT, CHILD_CONFIG_TOO_LARGE_MESSAGE, CHILD_PREFLIGHT_MAX_OUTPUT_BYTES } from "./link-child-preflight"; const PROBE_TTL_MS = 5 * 60_000; const MIN_REMOTE_OCX = [2, 66, 0] as const; @@ -285,7 +286,7 @@ export async function apply(ctx: ManagementContext, state: LinkRouteState): Prom const remotePort = parsePort(chosen.stdout); if (!remotePort) return await failed(fail("remote_port_failed", "The remote link port could not be determined.", 502)); phase = "child_preflight"; - const configured = await run(ocx(["config", "show", "--json"])); + const configured = await run(ocx(["config", "show", "--json"]), budget, CHILD_PREFLIGHT_MAX_OUTPUT_BYTES); let childConfig: unknown = null; try { if (configured.code === 0) childConfig = JSON.parse(configured.stdout); } catch { /* fail closed */ } if (!isRecord(childConfig) || !port(childConfig.port)) return await failed(fail("child_preflight_failed", "The Child effective configuration could not be read.", 502)); @@ -350,6 +351,9 @@ export async function apply(ctx: ManagementContext, state: LinkRouteState): Prom return Response.json({ linkId: id }, { status: 202 }); } finally { unsubscribe(); } } catch (error) { + if (phase === "child_preflight" && error instanceof SshRunnerError && error.code === "output_limit") { + return await failed(fail("child_preflight_output_limit", CHILD_CONFIG_TOO_LARGE_MESSAGE, 502, CHILD_CONFIG_TOO_LARGE_HINT)); + } return await failed(fail(phase + (error instanceof SshRunnerError && error.code === "timeout" ? "_timeout" : "_failed"), "Remote Link failed during " + phase.replaceAll("_", " ") + ".", 502, sshRunnerErrorHint(error))); } finally { input?.fill(0); rmSync(dir, { recursive: true, force: true }); } }); diff --git a/structure/remote-link.md b/structure/remote-link.md index 19ffddc9e5b..1d6ff2f2784 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -152,7 +152,7 @@ A deadline still rejects late output. `src/server/management/link-enrollment.ts` owns probe, host confirmation and Home apply; `src/server/management/link-route-state.ts` supplies shared authorization and lifecycle compensation. The three routes accept the existing dashboard authority or trusted loopback admin authority; Tailscale identity sessions remain refused. Join remains operator-paired only. CLI `ocx link probe ` requests the optional `includeVersion: true` offer; legacy dashboard offers retain their three fields. Version probing executes against the temporary learned key and persists no trust. Confirm rechecks the pending generation and five-minute expiry after its SSH await, then updates the latest known_hosts atomically under the config lock. Same-alias operations fail fast on overlap; apply pins a private immutable host file and consumes confirmation before issuing a key. -`src/link/remote-probe.ts` separates command budget (15 seconds plus four times measured SSH setup, capped at 60 seconds) from a fresh 15-second relay deadline. Restart adds the existing 60-second drain and 70-second replacement windows. The runner's one deadline covers stdin writes, process exit and output drains; failure cancels readers and kills the local SSH child without an unbounded cleanup wait. Ordinary stdout/stderr cap at 64 KiB; only the catalog command permits 4 MiB stdout. Coverage: `tests/clients/link-ssh-runner.test.ts`. +`src/link/remote-probe.ts` separates command budget (15 seconds plus four times measured SSH setup, capped at 60 seconds) from a fresh 15-second relay deadline. Restart adds the existing 60-second drain and 70-second replacement windows. The runner's one deadline covers stdin writes, process exit and output drains; failure cancels readers and kills the local SSH child without an unbounded cleanup wait. Ordinary stdout and stderr cap at 64 KiB. The catalog command and the Child `config show --json` preflight in `src/server/management/link-child-preflight.ts` permit 4 MiB stdout, because no smaller command reports `port`, `runtimeRole` and `ephemeralChild` on every supported Child: full `ocx` has `config get`, the lightweight `ocx-child` binary answers only `config show --json`. A Child exceeding even that cap answers `502 child_preflight_output_limit` instead of a generic preflight failure. Coverage: `tests/clients/link-ssh-runner.test.ts`, `tests/server/link-enrollment.test.ts`. Before issuance, apply requires curl and effective standalone Child configuration plus a native Child health response on its configured port with `restartCapability: v1`. Already-client, mismatched-port and pre-capability runtimes are refused without enrollment. Thus a capable older standalone Child can be linked by a newer Home. Home calls existing `ocx restart` exactly once after `connect --key-stdin`; no raw stop/start or timeout replay. After client-role health, Home registers a fresh expected-key observer and asks Child's credential-free loopback `/v1/catalog`. Both a complete HTTP 200 JSON catalog and matching authenticated Home callback must occur within the relay window. Enrollment admission cannot satisfy this check. Curl ignores curlrc, proxies and redirects. Phase-specific failures compensate Home state; after possible remote commit, `residual: true` and fixed Child disconnect/restart guidance remain even if compensation fails. This is not remote rollback. diff --git a/tests/server/link-enrollment.test.ts b/tests/server/link-enrollment.test.ts index 159926acfb4..acaab8848eb 100644 --- a/tests/server/link-enrollment.test.ts +++ b/tests/server/link-enrollment.test.ts @@ -6,7 +6,7 @@ import { handleLinkRoutes, type LinkRouteState } from "../../src/server/manageme import type { ManagementContext } from "../../src/server/management/context"; import type { OcxConfig } from "../../src/types"; import type { LinkStore } from "../../src/link/store"; -import { SshRunnerError, type SshRunner, type SshRunResult } from "../../src/link/ssh-runner"; +import { SshRunnerError, SSH_MAX_OUTPUT_BYTES, SSH_OUTPUT_CAP_BYTES, type SshRunner, type SshRunResult } from "../../src/link/ssh-runner"; import { quoteRemote, remoteOcxArgv, WINDOWS_OS_PROBE } from "../../src/link/ssh-argv"; import { windowsScriptData } from "../../src/link/remote-windows"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -108,25 +108,32 @@ function harness(remotePlatform: "posix" | "windows" = "posix") { // Enrollment itself hits Home's catalog; this is deliberately before the fresh watcher. emit("enrollment"); } - if (overrides[step]) return overrides[step]!(call); - switch (step) { - case "probe": { - const path = candidateFile(argv); - writeFileSync(path, `${aliasOf(argv)} ssh-ed25519 AAAA\n`); - return result("ocx-link-probe\n"); + const fixture = (): SshRunResult => { + switch (step) { + case "probe": { + const path = candidateFile(argv); + writeFileSync(path, `${aliasOf(argv)} ssh-ed25519 AAAA\n`); + return result("ocx-link-probe\n"); + } + case "uname": return result("Linux\n"); + case "windows": return remotePlatform === "windows" ? result("Windows_NT\r\n") : { code: 127, stdout: "", stderr: "sh: cmd: not found" }; + case "fingerprint": return result(`256 ${fingerprint} child (ED25519)`); + case "version": return result("opencodex 2.83.0\n"); + case "port": return result('{"port":2200}'); + case "config": return result('{"port":10100}\n'); + case "curl": case "connect": return result(); + case "before": return health("standalone"); + case "restart": return result(); + case "health": return health("client"); + case "catalog": emit("catalog"); return http({ models: [{ id: "fixture-model" }] }); } - case "uname": return result("Linux\n"); - case "windows": return remotePlatform === "windows" ? result("Windows_NT\r\n") : { code: 127, stdout: "", stderr: "sh: cmd: not found" }; - case "fingerprint": return result(`256 ${fingerprint} child (ED25519)`); - case "version": return result("opencodex 2.83.0\n"); - case "port": return result('{"port":2200}'); - case "config": return result('{"port":10100}\n'); - case "curl": case "connect": return result(); - case "before": return health("standalone"); - case "restart": return result(); - case "health": return health("client"); - case "catalog": emit("catalog"); return http({ models: [{ id: "fixture-model" }] }); - } + }; + // Mirror the real runner: a stream over the requested (or default) cap is an output_limit + // failure, not a silently truncated result. + const produced = overrides[step] ? await overrides[step]!(call) : fixture(); + const cap = options?.maxOutputBytes ?? SSH_OUTPUT_CAP_BYTES; + if (Buffer.byteLength(produced.stdout) > cap) throw new SshRunnerError("output_limit", `ssh output exceeded ${cap} bytes`); + return produced; }, spawnTunnel() { throw new Error("The route must use its supplied supervisor"); }, }; @@ -310,6 +317,28 @@ describe("Home enrollment of a standalone Child", () => { expect(timeouts.get("catalog")).toBe(15_000); }); + test("a Child whose config show exceeds the ordinary SSH output cap still passes preflight", async () => { + const h = harness(); h.confirmed(); + // Reproduces #8356: a Child with a large saved configuration printed far more than the + // runner's 64 KiB default, so readOutput threw output_limit before preflight could read port. + const padding = "x".repeat(SSH_OUTPUT_CAP_BYTES + 8 * 1024); + h.overrides.config = () => result(JSON.stringify({ port: 10100, runtimeRole: "standalone", padding }) + "\n"); + const response = await h.call("apply", { alias: "child" }); + expect(response.status).toBe(202); + expect(await response.json()).toEqual({ linkId: h.store.links[0]!.id }); + const config = h.calls.find(call => call.step === "config")!; + expect(config.options?.maxOutputBytes).toBe(SSH_MAX_OUTPUT_BYTES); + }); + + test("an output limit in Child preflight names the cap instead of failing generically", async () => { + const h = harness(); h.confirmed(); + h.overrides.config = () => { throw new SshRunnerError("output_limit", `ssh output exceeded ${SSH_MAX_OUTPUT_BYTES} bytes`); }; + const payload = await denied(await h.call("apply", { alias: "child" }), "child_preflight_output_limit") as { error: { hint: string } }; + expect(payload.error.hint).toContain("config show --json"); + expect(payload.error.hint).toContain(String(SSH_MAX_OUTPUT_BYTES)); + expect(payload.residual).toBeUndefined(); noEnrollmentEffects(h); + }); + test("huge measured latency caps ordinary work at 60s and restart at 190s", async () => { const h = harness(); h.confirmed("child", Number.MAX_VALUE); expect((await h.call("apply", { alias: "child" })).status).toBe(202);