diff --git a/devlog/_plan/261107_orphaned_child_link/010_design.md b/devlog/_plan/261107_orphaned_child_link/010_design.md index f2db889e312..fd7bf854535 100644 --- a/devlog/_plan/261107_orphaned_child_link/010_design.md +++ b/devlog/_plan/261107_orphaned_child_link/010_design.md @@ -37,3 +37,16 @@ therefore does not establish that the present token belongs to the sidecar, and followed by compare-and-swap alone only proves that the token did not change during cleanup. A recovery implementation needs an independent owner witness and a safe path for legacy orphans; it must not interpret sidecar presence as proof of token ownership. + +## Explicit legacy recovery + +The operator can instead authorize preservation of the unowned files explicitly with +`ocx disconnect --orphaned-token --yes`. This is a recovery action, not a connection +reconstruction: it moves the service token and any rotation backup to a private directory and +prints its location and restoration guidance. Under the existing start ownership lease and client +lifecycle lock, check that no client runtime is live, treating unknown as a refusal; under the +config mutation lock recheck disconnected state, pending-connect marker, disconnect receipt, and +token file identities immediately before moving. Hub role, admission-secret use and installed or +unverified service definitions refuse recovery. On Windows, private hard links retain file identity +while source handles close before rename. Refuse unsafe paths and preserve all backup bytes if any +part fails. The normal disconnect, sync and enrollment paths retain their ownership checks. diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index b116d444fca..f01da20e03f 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -77,6 +77,8 @@ On a Child, `GET /healthz` only says that OpenCodex is running. `GET /readyz` (a The reason `client_state_changed` means the running Child's saved connection is missing or now names another owner or link. Its machine and link status endpoints also return `503` with this reason. Stop or restart the Child and check its connection before re-linking; this readiness result does not itself disconnect the relay. +If `ocx connect status --json` reports `disconnected` but a leftover service token prevents a new link, inspect the situation on the Child. After confirming that preserving the existing token files is intended, run `ocx disconnect --orphaned-token --yes`. It refuses a connected or pending client and any live client runtime or unverified runtime state. It also refuses a Hub, a non-loopback bind, a configured or matching admission secret, and an installed or unverified service: these can use the token for admission. On a Hub, manage the credential through the Hub service; otherwise inspect the bind, API keys, token environment and `ocx service status`, and remove or migrate a service only if intended before retrying. It moves the token and any rotation backup into a private directory under the OpenCodex config directory, prints the location, and does not infer that either token belonged to the link. On Windows, recovery requires hard-link support to preserve file identity while closing handles; if it reports an incomplete backup, inspect the reported directory and original files before retrying. Stop OpenCodex to restore: move the saved files back only when the original names are absent. The command does not revoke a Home key or restore missing client configuration. + To confirm which Home is serving a Child, compare the `x-opencodex-served-by` header on the Child's `GET /v1/models` with the `instanceId` in the Home's `GET /healthz`. The Child passes the Home's header through unchanged, so the two match while the link works. The value is an opaque id, never a hostname; see [Home identity and the served-by header](/reference/management-api/#home-identity-and-the-served-by-header). ## What a Child shows and manages @@ -89,6 +91,8 @@ A Child runs on the Home's providers, accounts and settings, so the Home is wher When OpenCodex Desktop or a background service supervises the Child runtime, sync succeeds after applying settings and reports that the runtime restart is deferred. The settings take effect when that supervisor restarts the runtime: use **Quit** in the OpenCodex tray menu, then reopen OpenCodex, or run `ocx service restart` for a service. - **CLI.** Commands that only read, such as `ocx account list`, `ocx provider quota` and `ocx usage`, read the Home. They show what the Home has already observed: a read from a Child never makes the Home probe a provider, refresh a token or start a process, so quota and account details are as fresh as the Home's own activity keeps them. Reads that only exist to run provider discovery or a forced refresh, such as `ocx models live`, `ocx models preset show` and `ocx account list --quota`, are refused like changes. Commands that change configuration are refused with one message that names the Home and the link and says no request was sent. `ocx status` describes the Home and the link. `ocx provider list` and `ocx config get` still show this computer's own configuration, labelled as local and inactive while the computer is linked. `ocx config set`, `ocx config unset` and `ocx config import` still write locally, each with the same note, and `ocx provider add`, `remove` and `set-default` are refused because those providers are not used while linked. +An import that would replace or remove an active client connection is refused. Disconnect that client first, or import a file that preserves its connection record exactly. + These reads need an OpenCodex version with the Remote Link management relay on both the Home and the Child. An older Home answers with a message asking you to upgrade it. ## Remove a Child diff --git a/scripts/generate-ocx-skill-surface.ts b/scripts/generate-ocx-skill-surface.ts index 4d7727104e3..750ea21e566 100644 --- a/scripts/generate-ocx-skill-surface.ts +++ b/scripts/generate-ocx-skill-surface.ts @@ -22,7 +22,7 @@ const DOMAINS = [ { name: "agents-routing", roots: ["agent", "combo", "route", "fallback", "v2", "effort", "memory", "message", "advisor"] }, { name: "integrations", roots: ["claude", "integration", "commandcode", "grok", "codex-shim"] }, { name: "observe-system", roots: ["companion", "usage", "logs", "storage", "inspect", "system", "observe", "debug", "export", "import", "cost", "update", "config", "tray"] }, - { name: "access-remote", roots: ["link", "remote-workspace", "hub", "connect", "api", "access"] }, + { name: "access-remote", roots: ["link", "remote-workspace", "hub", "connect", "disconnect", "api", "access"] }, { name: "lab", roots: ["lab"] }, ]; diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 4a55a25f971..148dd2e93a4 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -31,7 +31,7 @@ These answer in the CLI head and never reach the proxy, so they work with nothin | [agents-routing](01_surface_agents-routing.md) | 68 | | [integrations](01_surface_integrations.md) | 41 | | [observe-system](01_surface_observe-system.md) | 99 | -| [access-remote](01_surface_access-remote.md) | 33 | +| [access-remote](01_surface_access-remote.md) | 34 | | [lab](01_surface_lab.md) | 21 | ## Read-only capabilities @@ -1367,6 +1367,10 @@ Original invocation order. These headings preserve links to the previous single- [Read-oriented task](01_surface_observe-system.md#ocx-companion-timeline) +### `ocx disconnect` + +[State-changing task](01_surface_access-remote.md#ocx-disconnect) + ### `ocx hub disable` [State-changing task](01_surface_access-remote.md#ocx-hub-disable) @@ -1521,6 +1525,6 @@ Original invocation order. These headings preserve links to the previous single- ## Counts -- declared capabilities: 368 -- of those, state-changing: 226 +- declared capabilities: 369 +- of those, state-changing: 227 - head-resolved invocations: 2 diff --git a/skills/ocx/references/01_surface_access-remote.md b/skills/ocx/references/01_surface_access-remote.md index 64a16ac0e1a..3a9df271275 100644 --- a/skills/ocx/references/01_surface_access-remote.md +++ b/skills/ocx/references/01_surface_access-remote.md @@ -8,7 +8,7 @@ Use these declarations to choose a task, then check its flags and authority before execution. Non-mutating probes may still contact providers, consume quota or refresh caches. -Declared capabilities: 33. +Declared capabilities: 34. ### `ocx link probe` @@ -326,6 +326,26 @@ State-changing: yes. JSON mode: `payload`. - A bare invocation reads and never writes. - A setting flag changes the operator's config; run it only when the operator asks for that change. +### `ocx disconnect` + +Usage: `ocx disconnect [--keep-catalog] [--json] | ocx disconnect --orphaned-token --yes [--json]` + +Disconnect a client or preserve orphaned token files for recovery. + +State-changing: yes. JSON mode: `payload`. + +Drives no management route. + +| Flag | Value | Meaning | +|---|---|---| +| `--keep-catalog` | boolean | Keep the connected client's catalog during ordinary disconnect. | +| `--orphaned-token` | boolean | Preserve unowned service-token files in a private backup when disconnected. | +| `--yes` | boolean | Explicitly confirm orphan-token recovery; requires --orphaned-token. | +| `--json` | boolean | Emit the local result and backup location as JSON. | + +- Orphan recovery requires a disconnected durable state, no pending connect or disconnect, no Hub or admission-secret use, no installed or unverified service, and verified absence of a client runtime. It preserves rather than deletes token files and does not establish their ownership. +- If refused, inspect the bind, API keys, token environment and service status; manage a Hub secret through its service. Stop OpenCodex and move saved files back only when their original names are absent. Never print their contents. + ### `ocx hub disable` Usage: `ocx hub disable [--revoke-clients --client-key ...] [--json]` diff --git a/skills/ocx/references/05_remote_hub.md b/skills/ocx/references/05_remote_hub.md index 001a6a38912..153136d249d 100644 --- a/skills/ocx/references/05_remote_hub.md +++ b/skills/ocx/references/05_remote_hub.md @@ -370,6 +370,16 @@ It ends a browser session; it does not disconnect a client or revoke a key. ### When `disconnect` refuses, that is the safety property +When the operator has explicitly authorized recovery of a disconnected installation with a +leftover token, `ocx disconnect --orphaned-token --yes` preserves the service-token files in a +private backup. It refuses pending or connected client state, a Hub, admission-token use, +installed or unverified service definitions, and live or unverified client runtimes. Inspect the +bind, API keys, token environment and `ocx service status`; manage a Hub credential through the +Hub service. It does not prove the tokens belonged to a link or restore lost connection settings. +Report the printed backup location; to restore, stop OpenCodex and move the saved files back only +if their original names are absent. Never read or print their contents. Do not add `--yes` without +the operator's explicit authorization. + Do not work around these. Each one means unwinding would damage state that `disconnect` cannot prove is safe to touch. diff --git a/src/cli/capabilities-access-remote.ts b/src/cli/capabilities-access-remote.ts index dde0a70871c..add2702b22f 100644 --- a/src/cli/capabilities-access-remote.ts +++ b/src/cli/capabilities-access-remote.ts @@ -2,6 +2,19 @@ import type { Capability } from "./capability-types"; /** Verified existing handlers only; local operations never claim management routes. */ export const ACCESS_REMOTE_CAPABILITIES: readonly Capability[] = [ + { + command: ["disconnect"], summary: "Disconnect a client or preserve orphaned token files for recovery.", + usage: "ocx disconnect [--keep-catalog] [--json] | ocx disconnect --orphaned-token --yes [--json]", + routes: [], + flags: [ + { name: "--keep-catalog", value: "boolean", summary: "Keep the connected client's catalog during ordinary disconnect." }, + { name: "--orphaned-token", value: "boolean", summary: "Preserve unowned service-token files in a private backup when disconnected." }, + { name: "--yes", value: "boolean", summary: "Explicitly confirm orphan-token recovery; requires --orphaned-token." }, + { name: "--json", value: "boolean", summary: "Emit the local result and backup location as JSON." }, + ], + mutates: true, json: "payload", + details: ["Orphan recovery requires a disconnected durable state, no pending connect or disconnect, no Hub or admission-secret use, no installed or unverified service, and verified absence of a client runtime. It preserves rather than deletes token files and does not establish their ownership.", "If refused, inspect the bind, API keys, token environment and service status; manage a Hub secret through its service. Stop OpenCodex and move saved files back only when their original names are absent. Never print their contents."], + }, { command: ["hub", "disable"], summary: "Return a hub to standalone in one validated config write.", usage: "ocx hub disable [--revoke-clients --client-key ...] [--json]", diff --git a/src/cli/codex-shim-autorestore.ts b/src/cli/codex-shim-autorestore.ts index 9b259cd914a..5762e1f187f 100644 --- a/src/cli/codex-shim-autorestore.ts +++ b/src/cli/codex-shim-autorestore.ts @@ -24,6 +24,8 @@ export function skipsCodexShimAutoRestore(command: string | undefined, args: str if (command === "update" && isOrchestratedUpdateArgv(args)) return true; if (command === "sync") return true; if (command === "status" || command === "doctor") return true; + // Explicit orphan-token recovery must not mutate Codex before its guards run. + if (command === "disconnect" && args.includes("--orphaned-token")) return true; // The launcher owns shim handoff after classifying routing, including native fast paths. if (command === "codex") return true; // Exec admission only observes the selected target; it must never repair a shim first. diff --git a/src/cli/config-command.ts b/src/cli/config-command.ts index b0f8d26b75d..8cd643a8d8e 100644 --- a/src/cli/config-command.ts +++ b/src/cli/config-command.ts @@ -1,6 +1,7 @@ import { readFileSync, writeFileSync } from "node:fs"; import { clearCodexAccountPin } from "../codex/account-priority"; -import { getConfigPath, mutatePersistedConfig, readConfigDiagnostics, sanitizeModelCostsForDisplay, saveConfig, validateConfigCandidate } from "../config"; +import { getConfigPath, mutatePersistedConfig, readConfigDiagnostics, sanitizeModelCostsForDisplay, saveConfig, validateConfigCandidate, withConfigMutationLockSync } from "../config"; +import { readClientConnectionState } from "../client/state"; import { getConfigDir, withConfigDirHardeningSettled } from "../config/paths"; import { readCliConfigForMutation } from "./config-mutation"; import { VISION_REASONING_EFFORTS, isVisionReasoningEffort } from "../reasoning-effort"; @@ -331,7 +332,15 @@ export async function handleConfigCommand(argv: string[]): Promise { rejectArgs(args, USAGE); await withConfigDirHardeningSettled(getConfigDir(), () => { readCliConfigForMutation(); - saveConfig(validate(loadInput(path))); + const imported = validate(loadInput(path)); + withConfigMutationLockSync(() => { + const client = readClientConnectionState(); + if (client.kind === "connected" && (imported.runtimeRole !== "client" + || JSON.stringify(imported.client) !== JSON.stringify(client.value))) { + throw new Error("config import refused: disconnect the active client before replacing its connection"); + } + saveConfig(imported); + }); }); printData({ ok: true, source: path }, wantsJson, [`Imported config from ${path}. Restart or run ocx sync if needed.`]); return; diff --git a/src/cli/connect.ts b/src/cli/connect.ts index bb248d8c31c..b3986f7df24 100644 --- a/src/cli/connect.ts +++ b/src/cli/connect.ts @@ -20,6 +20,7 @@ import { teardownClientLink } from "../client/link-teardown"; import { reapOrphanTunnel } from "../client/link-tunnel"; import { readServiceApiTokenState } from "../lib/service-secrets"; import type { ClientLifecycleLockDeps } from "../client/lifecycle-lock"; +import { recoverOrphanedTokens, type OrphanTokenRecoveryDeps } from "../client/orphan-token-recovery"; import { linkKnownHostsPath } from "../link/paths"; import { createSshRunner, type SshRunner } from "../link/ssh-runner"; import type { OrphanTunnelResult } from "../client/link-tunnel"; @@ -83,7 +84,8 @@ export const CONNECT_USAGE = `Usage: ocx connect revoke --admin-token-stdin [--json]`; export const DISCONNECT_USAGE = `Usage: - ocx disconnect [--keep-catalog] [--json]`; + ocx disconnect [--keep-catalog] [--json] + ocx disconnect --orphaned-token --yes [--json]`; export type ClientConnectionStatus = { state: "disconnected" | "connected" | "invalid" | "mismatched"; @@ -529,13 +531,26 @@ export async function handleConnectCommand(argv: string[], deps: ClientCommandDe export async function handleDisconnectCommand( argv: string[], - deps: Pick = {}, + deps: Pick & { orphanRecoveryDeps?: OrphanTokenRecoveryDeps } = {}, ): Promise { return runCliAction(async () => { const args = [...argv]; const keepCatalog = takeFlag(args, "--keep-catalog"); const wantsJson = takeFlag(args, "--json"); + const orphanedToken = takeFlag(args, "--orphaned-token"); + const yes = takeFlag(args, "--yes"); rejectArgs(args, DISCONNECT_USAGE, { redactValues: true }); + if (orphanedToken) { + if (!yes || keepCatalog) throw new CliUsageError("--orphaned-token requires --yes and cannot be combined with --keep-catalog", DISCONNECT_USAGE); + const backup = await recoverOrphanedTokens({ ...deps.orphanRecoveryDeps, lifecycleLockDeps: deps.lifecycleLockDeps }); + const restore = "Stop OpenCodex and move the saved files back into its config directory only if the destination names are absent."; + printData({ ...backup, restore }, wantsJson, [ + `Preserved token files in ${backup.backupDir}.`, + `To restore: ${restore}`, + ]); + return; + } + if (yes) throw new CliUsageError("--yes requires --orphaned-token", DISCONNECT_USAGE); const teardown = await teardownClientLink({ readSidecar: readClientLinkState, connectedLinkId: () => { diff --git a/src/client/connect.ts b/src/client/connect.ts index 00bb058891c..af6bf711eaf 100644 --- a/src/client/connect.ts +++ b/src/client/connect.ts @@ -556,6 +556,9 @@ function assertConnectingState(expectedTokenFingerprint?: string): void { const token = readServiceApiTokenState(); if (expectedTokenFingerprint === undefined ? token.kind !== "absent" : token.kind !== "present" || token.fingerprint !== expectedTokenFingerprint) { + if (expectedTokenFingerprint === undefined && token.kind === "present") { + throw new Error("client_token_changed: if this is not a Hub and no admission secret or installed service uses the token, inspect it and use ocx disconnect --orphaned-token --yes before retrying"); + } throw new Error("client_token_changed"); } } @@ -1121,7 +1124,7 @@ export async function disconnectClient( writeDesktopDisconnectReceipt(held, previous, receipt); } } else if (state.kind !== "disconnected" || !receipt || !disconnectAtLeast(receipt, "clearing_connection")) { - throw new Error("disconnect refused: no recoverable connected state"); + throw new Error("disconnect refused: no recoverable connected state; if this is not a Hub and no admission secret or installed service uses the token, inspect it and use ocx disconnect --orphaned-token --yes"); } if (!receipt || receipt.keepCatalog !== keepCatalog) throw new Error("client_disconnect_options_changed"); return { receipt, connection }; diff --git a/src/client/orphan-token-recovery.ts b/src/client/orphan-token-recovery.ts new file mode 100644 index 00000000000..b3b83ca0f4f --- /dev/null +++ b/src/client/orphan-token-recovery.ts @@ -0,0 +1,307 @@ +import { createHash } from "node:crypto"; +import { chmodSync, closeSync, constants, fchmodSync, fstatSync, linkSync, lstatSync, mkdtempSync, openSync, readFileSync, readSync, renameSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { getConfigDir, getConfigPath, readConfigDiagnostics, withConfigMutationLockSync } from "../config"; +import { readConfigGenerationInCurrentMutationTransaction } from "../config/mutation-lock"; +import { withConfigDirHardeningSettled } from "../config/paths"; +import { readAlivePid, readRuntimePort } from "../config/process-state"; +import { readDesktopDisconnectReceipt } from "../claude/desktop-remote-store"; +import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; +import { hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl"; +import { readServiceApiTokenState, serviceApiTokenFingerprint } from "../lib/service-secrets"; +import { createLocalAttestationChallenge, LOCAL_ATTESTATION_CHALLENGE_HEADER, LOCAL_ATTESTATION_PROOF_HEADER, verifyLocalAttestationProof } from "../lib/local-management-attestation"; +import { LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION } from "../lib/local-provider-reload-contract"; +import { loopbackFamilyAvailability } from "../lib/loopback-family"; +import { acquireOwnershipMutationLease } from "../service/ownership-mutation-lease.mjs"; +import { serviceStatePaths } from "../service/state"; +import { classifyHealthz, DEFAULT_PROBE_TIMEOUT_MS, loopbackProbeHosts, observeProxyLiveness, probeEndpointLiveness, probeHostname, proveLiveProxyOwnedByHome, START_OWNERSHIP_LIVENESS, type LiveProxy } from "../server/proxy-liveness"; +import { directLocalHttpFetch } from "../server/direct-local-http"; +import { configuredApiAuthToken, isApiAuthRequired } from "../server/auth-cors"; +import { defaultProbeRunner, defaultRawProbeRunner, inspectServiceManagerInstallation, type ProbeDeps } from "../service-manager-probe"; +import { endpointsToProve, everyEndpointProvenDownAsync } from "../cli/uninstall-plan"; +import { withClientLifecycle, type ClientLifecycleLockDeps } from "./lifecycle-lock"; +import { pendingClientConnectMayOwnToken, readClientConnectionState } from "./state"; + +export interface OrphanTokenRecoveryDeps { + lifecycleLockDeps?: ClientLifecycleLockDeps; + acquireLease?: () => { release(): void }; + /** Test seam: production uses the attested runtime and tri-state endpoint probes below. */ + runtimeSafe?: () => Promise; + /** Test seam: production checks installed and indeterminate service definitions. */ + inspectServiceManager?: typeof inspectServiceManagerInstallation; + /** Exercise Windows handle ordering on other platforms; never set by the CLI. */ + windowsMoveForTest?: boolean; + onWindowsHandlesClosed?: (fds: readonly number[]) => void; +} + +function assertDisconnected(): void { + if (readClientConnectionState().kind !== "disconnected") throw new Error("orphan recovery refused: client state is not disconnected"); + const diagnostics = readConfigDiagnostics(); + if (diagnostics.source === "fallback") throw new Error("orphan recovery refused: admission configuration is unverified; repair the configuration before retrying"); + if (diagnostics.config.runtimeRole === "hub") throw new Error("orphan recovery refused: this is a Hub; manage its admission secret through the Hub service instead"); + const runtime = readRuntimePort(); + if (isApiAuthRequired(diagnostics.config) || (runtime?.hostname && isApiAuthRequired({ hostname: runtime.hostname })) + || configuredApiAuthToken() || process.env.OCX_API_TOKEN_FILE?.trim()) { + throw new Error("orphan recovery refused: this installation may use an admission secret; inspect its bind and token configuration before retrying"); + } + const token = readServiceApiTokenState(); + if (token.kind === "present" && diagnostics.config.apiKeys?.some(entry => + [entry.key, entry.pendingRotation?.key].some(value => value && serviceApiTokenFingerprint(value.trim()) === token.fingerprint))) { + throw new Error("orphan recovery refused: the token matches a configured admission secret; inspect the API keys before retrying"); + } + if (pendingClientConnectMayOwnToken()) throw new Error("orphan recovery refused: client connect is pending"); + const receipt = readDesktopDisconnectReceipt(); + if (receipt.kind === "unsafe" || (receipt.kind === "valid" && receipt.value.phase !== "complete")) { + throw new Error("orphan recovery refused: disconnect is pending or unsafe"); + } +} + +function assertNoServiceAdmission(inspect: typeof inspectServiceManagerInstallation): void { + let service; + try { service = inspect(); } + catch { throw new Error("orphan recovery refused: service admission use is unverified; inspect the service configuration before retrying"); } + if (service.kind !== "absent") { + throw new Error("orphan recovery refused: an installed or unverified service may use the admission secret; inspect 'ocx service status' and remove or migrate the service before retrying"); + } +} + +/** Unlike generic discovery, failure to launch a manager probe cannot prove token ownership absent. */ +export function inspectOrphanServiceAdmission(deps: ProbeDeps = {}): ReturnType { + const run = deps.run ?? defaultProbeRunner; + const runRaw = deps.runRaw ?? defaultRawProbeRunner; + return inspectServiceManagerInstallation({ ...deps, + run: (...args) => { const result = run(...args); if (result.spawnFailed) throw new Error("service manager unavailable"); return result; }, + runRaw: (...args) => { const result = runRaw(...args); if (result.spawnFailed) throw new Error("service manager unavailable"); return result; }, + }); +} + +function configWitness(): { generation: number; digest: string | null } { + let digest: string | null; + try { digest = createHash("sha256").update(readFileSync(getConfigPath())).digest("hex"); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + digest = null; + } + return { generation: readConfigGenerationInCurrentMutationTransaction().value, digest }; +} + +/** No null liveness result is treated as evidence of absence. The caller holds the start lease. */ +async function runtimeIsSafe(): Promise { + const diagnostics = readConfigDiagnostics(); + if (diagnostics.source === "fallback") return false; + const runtime = readRuntimePort(); + const observation = await observeProxyLiveness(START_OWNERSHIP_LIVENESS); + const endpoints = endpointsToProve(runtime, diagnostics.config); + if (observation.kind === "unknown") return false; + if (observation.kind === "absent") return readAlivePid() === null && everyEndpointProvenDownAsync(endpoints, probeEndpointLiveness); + const live = observation.live; + if (!live) return false; + if (live.role === "client" || !runtime || !runtime.hostname?.trim() || runtime.siblingOfPort !== undefined) return false; + const observed = { ...live, hostname: live.probeHostname ?? live.hostname }; + if (await proveLiveProxyOwnedByHome(observed, START_OWNERSHIP_LIVENESS) !== "proven") return false; + if (!await proveStandalone(observed, runtime)) return false; + for (const host of loopbackProbeHosts(live.hostname)) { + if (host === probeHostname(observed.hostname) || loopbackFamilyAvailability(host) === "unavailable") continue; + const answer = await classifyHealthz(`http://${host}:${live.port}/healthz`, directLocalHttpFetch, START_OWNERSHIP_LIVENESS.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS); + if (answer === "unknown" || (answer === "live" && !await proveStandalone({ ...observed, hostname: host }, runtime))) return false; + } + const otherEndpoints = endpoints.filter(endpoint => endpoint.port !== live.port || probeHostname(endpoint.hostname) !== probeHostname(live.hostname)); + return otherEndpoints.length === 0 || everyEndpointProvenDownAsync(otherEndpoints, probeEndpointLiveness); +} + +/** The client listener omits this standalone-only capability, including on legacy health bodies. */ +async function proveStandalone(live: LiveProxy, runtime: NonNullable>): Promise { + if (live.pid === null || runtime.pid !== live.pid || runtime.port !== live.port || !runtime.attestationSecret) return false; + try { + const challenge = createLocalAttestationChallenge(); + const response = await directLocalHttpFetch(`http://${probeHostname(live.hostname)}:${live.port}/healthz`, { + headers: { [LOCAL_ATTESTATION_CHALLENGE_HEADER]: challenge }, signal: AbortSignal.timeout(START_OWNERSHIP_LIVENESS.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS), + }); + const body = await response.json() as { service?: unknown; pid?: unknown; port?: unknown; role?: unknown; providerReloadCapability?: unknown }; + return response.ok && body.service === "opencodex" && body.pid === live.pid && body.port === live.port + && body.role !== "client" && body.providerReloadCapability === LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION + && verifyLocalAttestationProof(runtime.attestationSecret, challenge, live.pid, live.port, response.headers.get(LOCAL_ATTESTATION_PROOF_HEADER)) + && JSON.stringify(readRuntimePort()) === JSON.stringify(runtime); + } catch { return false; } +} + +type OpenSource = { path: string; name: string; fd: number | null; dev: number; ino: number; hash: string }; +type Sources = { home: string; dev: number; ino: number; entries: { path: string; name: string; source: OpenSource | null }[] }; + +function fingerprint(fd: number): string { + // Read by descriptor with a hard cap even if a non-cooperating writer grows the file. + const bytes = Buffer.alloc(4097); + try { + let used = 0; + while (used < bytes.length) { + const count = readSync(fd, bytes, used, bytes.length - used, used); + if (count === 0) break; + used += count; + } + if (used > 4096 || fstatSync(fd).size > 4096) throw new Error("orphan recovery refused: unsafe token file"); + return createHash("sha256").update(bytes.subarray(0, used)).digest("hex"); + } finally { bytes.fill(0); } +} + +function openSource(path: string, name: string): OpenSource | null { + let entry; + try { entry = lstatSync(path); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; } + if (!entry.isFile() || entry.isSymbolicLink() || entry.nlink !== 1 || entry.size > 4096 + || (process.platform !== "win32" && entry.uid !== process.getuid?.())) throw new Error("orphan recovery refused: unsafe token file"); + const fd = openSync(path, constants.O_RDONLY | (process.platform === "win32" ? 0 : constants.O_NOFOLLOW | constants.O_NONBLOCK)); + try { + const opened = fstatSync(fd); + if (!opened.isFile() || opened.nlink !== 1 || opened.dev !== entry.dev || opened.ino !== entry.ino) { + throw new Error("orphan recovery refused: token file changed"); + } + return { path, name, fd, dev: opened.dev, ino: opened.ino, hash: fingerprint(fd) }; + } catch (error) { closeSync(fd); throw error; } +} + +function readSources(): Sources { + const home = getConfigDir(); + assertNotRealHomeUnderTest(home); + const dirStat = lstatSync(home); + if (!dirStat.isDirectory() || dirStat.isSymbolicLink() || (process.platform !== "win32" && dirStat.uid !== process.getuid?.())) { + throw new Error("orphan recovery refused: unsafe config directory"); + } + const entries: Sources["entries"] = []; + try { + for (const name of ["service-api-token", "service-api-token.prev"]) { + const path = join(home, name); + entries.push({ path, name, source: openSource(path, name) }); + } + if (!entries.some(entry => entry.source)) throw new Error("orphan recovery refused: no token files found"); + return { home, dev: dirStat.dev, ino: dirStat.ino, entries }; + } catch (error) { + for (const entry of entries) if (entry.source?.fd != null) closeSync(entry.source.fd); + throw error; + } +} + +function verifyPinnedFile(path: string, source: OpenSource): void { + const entry = lstatSync(path); + if (!entry.isFile() || entry.isSymbolicLink() || entry.nlink !== 2 || entry.dev !== source.dev || entry.ino !== source.ino) { + throw new Error("orphan recovery refused: token identity witness changed"); + } + const fd = openSync(path, constants.O_RDONLY | (process.platform === "win32" ? 0 : constants.O_NOFOLLOW | constants.O_NONBLOCK)); + try { + const opened = fstatSync(fd); + if (!opened.isFile() || opened.nlink !== 2 || opened.dev !== source.dev || opened.ino !== source.ino || fingerprint(fd) !== source.hash) { + throw new Error("orphan recovery refused: token identity witness changed"); + } + } finally { closeSync(fd); } +} + +function moveToPrivateBackup(sources: Sources, deps: OrphanTokenRecoveryDeps): { backupDir: string; files: string[] } { + const home = getConfigDir(); + let backupDir: string | undefined; + try { + const homeStat = lstatSync(home); + if (home !== sources.home || !homeStat.isDirectory() || homeStat.isSymbolicLink() + || homeStat.dev !== sources.dev || homeStat.ino !== sources.ino) throw new Error("orphan recovery refused: config directory changed"); + // Reopen and compare both names, including absence, after the asynchronous liveness probe. + for (const entry of sources.entries) { + const now = openSource(entry.path, entry.name); + try { + if (Boolean(now) !== Boolean(entry.source) || (now && entry.source + && (now.dev !== entry.source.dev || now.ino !== entry.source.ino || now.hash !== entry.source.hash))) { + throw new Error("orphan recovery refused: token file changed"); + } + } finally { if (now?.fd != null) closeSync(now.fd); } + } + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + backupDir = mkdtempSync(join(home, `orphaned-token-${stamp}-`)); + if (process.platform === "win32") hardenSecretDir(backupDir, { required: true }); + else chmodSync(backupDir, 0o700); + const windowsMove = process.platform === "win32" || deps.windowsMoveForTest === true; + const pins = new Map(); + if (windowsMove) { + // A private hard link keeps each original file identity allocated after its Windows handle + // closes. Thus the rename cannot substitute a recycled file ID without detection. + for (const { source } of sources.entries) { + if (!source || source.fd === null) continue; + const pin = join(backupDir, `.${source.name}.identity`); + linkSync(source.path, pin); + if (process.platform === "win32") hardenSecretPath(pin, { required: true }); + const pinned = lstatSync(pin); + const original = fstatSync(source.fd); + if (!pinned.isFile() || pinned.nlink !== 2 || pinned.dev !== source.dev || pinned.ino !== source.ino + || original.dev !== source.dev || original.ino !== source.ino || original.nlink !== 2) { + throw new Error("orphan recovery refused: token file changed before move"); + } + verifyPinnedFile(pin, source); + pins.set(source.name, pin); + } + const closed: number[] = []; + for (const { source } of sources.entries) { + if (source?.fd == null) continue; + closeSync(source.fd); + closed.push(source.fd); + source.fd = null; + } + deps.onWindowsHandlesClosed?.(closed); + } + for (const { source } of sources.entries) { + if (!source) continue; + if (process.platform !== "win32" && source.fd !== null) fchmodSync(source.fd, 0o600); + const current = lstatSync(source.path); + if (!current.isFile() || current.nlink !== (windowsMove ? 2 : 1) || current.dev !== source.dev || current.ino !== source.ino) { + throw new Error("orphan recovery refused: token file changed"); + } + const destination = join(backupDir, source.name); + renameSync(source.path, destination); + const moved = lstatSync(destination); + if (!moved.isFile() || moved.dev !== source.dev || moved.ino !== source.ino) throw new Error("orphan recovery refused: token file changed during move"); + const pin = pins.get(source.name); + if (pin) { + const witness = lstatSync(pin); + if (!witness.isFile() || witness.dev !== source.dev || witness.ino !== source.ino || witness.nlink !== 2 || moved.nlink !== 2) { + throw new Error("orphan recovery refused: token identity witness changed"); + } + verifyPinnedFile(destination, source); + unlinkSync(pin); + } + if (process.platform === "win32") hardenSecretPath(destination, { required: true }); + const verified = openSource(destination, source.name); + try { + if (!verified || verified.dev !== source.dev || verified.ino !== source.ino || verified.hash !== source.hash) { + throw new Error("orphan recovery refused: token file changed during move"); + } + } finally { if (verified?.fd != null) closeSync(verified.fd); } + } + return { backupDir, files: sources.entries.filter(entry => entry.source).map(entry => entry.name) }; + } catch (error) { + if (backupDir) throw new Error(`orphan recovery incomplete; inspect private backup at ${JSON.stringify(backupDir)} before retrying`, { cause: error }); + throw error; + } +} + +/** Preserve explicitly selected orphan candidates without asserting they belong to a link. */ +export async function recoverOrphanedTokens(deps: OrphanTokenRecoveryDeps = {}): Promise<{ backupDir: string; files: string[] }> { + const lease = (deps.acquireLease ?? (() => acquireOwnershipMutationLease(serviceStatePaths())))(); + try { + return await withClientLifecycle(async () => { + const prepared = withConfigMutationLockSync(() => { + assertDisconnected(); + return { witness: configWitness(), runtime: JSON.stringify(readRuntimePort()), pid: readAlivePid(), sources: readSources() }; + }); + try { + assertNoServiceAdmission(deps.inspectServiceManager ?? inspectOrphanServiceAdmission); + if (!await (deps.runtimeSafe ?? runtimeIsSafe)()) throw new Error("orphan recovery refused: client runtime is live or runtime state is unverified"); + return await withConfigDirHardeningSettled(getConfigDir(), () => { + assertNoServiceAdmission(deps.inspectServiceManager ?? inspectOrphanServiceAdmission); + return withConfigMutationLockSync(() => { + assertDisconnected(); + if (JSON.stringify(configWitness()) !== JSON.stringify(prepared.witness)) throw new Error("orphan recovery refused: config changed during liveness check"); + if (JSON.stringify(readRuntimePort()) !== prepared.runtime || readAlivePid() !== prepared.pid) throw new Error("orphan recovery refused: runtime changed during liveness check"); + return moveToPrivateBackup(prepared.sources, deps); + }); + }); + } finally { + for (const entry of prepared.sources.entries) if (entry.source?.fd != null) closeSync(entry.source.fd); + } + }, deps.lifecycleLockDeps); + } finally { lease.release(); } +} diff --git a/structure/runtime.md b/structure/runtime.md index c4f4922f4cc..69b58f8db91 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -180,7 +180,7 @@ until shutdown. Standalone shutdown restores native Codex; connected hub clients uninstall still restore. Native client signal cleanup follows the [served-connection and supervisor boundaries](remote-link.md); client update stops use full parent restoration because client start cannot discharge standalone handover receipts. `src/service/cli.ts` removes the service token on uninstall only when persisted client state is disconnected and no pending connect marker owns the newly issued key. `src/client/connect.ts` publishes that fingerprint marker before the key, then clears it with the connection commit or rollback under the client lifecycle and config mutation locks. Connected, invalid, or mismatched client state retains an existing token. A valid pending marker retains only its matching fingerprint; an older marker does not own a replacement service key. An absent token is reported as absent; unsafe, malformed, or unreadable markers and lock, state-read, or deletion failures leave cleanup unverified. `src/cli/tray-proxy.ts` coordinates `ocx restart` and tray restart. A second start requires proof the prior attempt never launched or its child exited, plus confirmed absence under a fresh bounded discovery window; a missing health response alone is insufficient. After an accepted restart, polling continues through uncertain observations until its deadline and requires a new runtime PID on the same port. If the old proxy then disappears, explicit recovery starts it regardless of the Codex autostart preference; a skipped recovery fails rather than reporting success. The Windows tray startup-health probe in `src/tray/windows-tray.ps1` requests termination at its 30-second deadline without waiting on the UI thread. It retains the process through later ticks until actual exit and both redirected readers settle; replacements cannot start while that reference is held. Launch setup failures follow the same cleanup. Tray shutdown alone waits at most 500 ms before disposal. -`ocx restart` also restarts a connected client runtime, which serves the same bound `POST /api/system/restart` from its client listener; the CLI sends that request only while the client's 90-second restart budget still fits before replacement observation ends (`CLIENT_RESTART_REQUEST_MIN_REMAINING_MS`), and reports a supervised client's `409 restart_supervised` as "not performed, runtime kept". See [Remote Link](remote-link.md) for the rollback. The Remote Link Child listener checks its startup owner and link against durable client state before and after readiness probing. If they differ, `/readyz` and authenticated machine/link status answer 503 with `client_state_changed`. This reports divergence; it does not disconnect the relay or reconstruct a connection. `tests/clients/client-machine-listener.test.ts` covers missing and replaced records. +`ocx restart` also restarts a connected client runtime, which serves the same bound `POST /api/system/restart` from its client listener; the CLI sends that request only while the client's 90-second restart budget still fits before replacement observation ends (`CLIENT_RESTART_REQUEST_MIN_REMAINING_MS`), and reports a supervised client's `409 restart_supervised` as "not performed, runtime kept". See [Remote Link](remote-link.md) for the rollback. The Remote Link Child listener checks its startup owner and link against durable client state before and after readiness probing. If they differ, `/readyz` and authenticated machine/link status answer 503 with `client_state_changed`. This reports divergence; it does not disconnect the relay or reconstruct a connection. `tests/clients/client-machine-listener.test.ts` covers missing and replaced records. Explicit `ocx disconnect --orphaned-token --yes` in `src/client/orphan-token-recovery.ts` preserves unowned token files only after the start lease, lifecycle lock, liveness proof and config-lock revalidation; Hub, admission-token use and installed or unverified services refuse. Windows closes source handles before rename while private hard links pin identity. Missing ownership is never reconstructed. `ocx config import` refuses to replace a connected client record under the config lock. `tests/clients/client-link-connect.test.ts` covers these refusals and local liveness. The package-tree integrity fence for live package replacement follows the [update transaction contract](ops/docs-and-release.md#package-tree-integrity-fence). After client journal reconciliation, `ocx ensure` in `src/cli/dispatch.ts` treats validated connected-client state as a successful no-op without local startup or a remote health probe; invalid or mismatched state still fails, and disconnected state keeps local ensure. `tests/cli/cli-ensure-client-state.test.ts` pins this dispatch contract so the Codex shim does not warn merely because its proxy is delegated to a hub. Startup journal reconciliation, including replacement after restart/drain, follows the [Codex-home recovery contract](codex-home.md#codex-home): already-original recovery is silent, incomplete recovery retains its journal and warns without claiming restoration, and only complete recovery that rewrites or removes an artifact reports a restore; restart admission, drain deadlines and replacement ownership are unchanged. `src/cli/start-owner-fence.ts` holds the ownership mutation lease through recovery for ordinary and supervised starts. After an early negative probe, `start` and `ensure` recheck with `START_OWNERSHIP_LIVENESS` under that lease through `observeProxyLiveness` in `src/server/proxy-liveness.ts`, probing both available loopback families concurrently for every loopback or wildcard spelling classified after the same startup normalization (including `localhost.` and case variants) within the existing per-family retry ceilings and shared deadline. Only definitive absence permits PID deletion and journal reconciliation: no alive recorded PID unless fresh readable command-line evidence confirms it is foreign, no answering runtime owner, and only unanimous aggregate connection refusals or clean foreign identities at probed endpoints. Port zero names no existing listener. Timeouts, resets, unreadable/non-200 responses, PID mismatches and exhausted deadlines remain unknown; startup refuses with a fixed message while preserving PID, journal and shared config. Unreadable/empty PID command lines and any OpenCodex invocation remain unknown; a confirmed foreign PID is never signalled. A discovered live owner preserves both records and its runtime/config bind scope in `LiveProxy.hostname`; omitted or blank hostnames become explicit `127.0.0.1` in both early and recovery discovery, so a later config change cannot replace a default loopback bind. Explicit wildcard scope remains intact, and the concrete responding address is separate `probeHostname` metadata. Integration policy uses the bind scope, so loopback discovery of a wildcard listener does not authorize tokenless Grok registration. Grok start and ensure/restart synchronization also recheck current desired-state admission after catalog discovery, immediately before the synchronous writer in `src/grok/sync.ts`; start additionally rejects work after shutdown begins or cleanup runs. This closes the same-process await gap without turning the desired-state and Grok files into a cross-process transaction. Ensure carries `probeHostname` separately through `src/cli/ensure-desired-integrations.ts` and `src/grok/sync.ts` to `src/grok/inject.ts` for the written destination; an explicit secondary loopback listener keeps its own address and port; after lease release, start applies `decideStartWithLiveOwner` and ensure follows its existing live-owner path. Cross-home sibling marking still precedes journal recovery. The bind/publication fence remains separate, and supervised recovery checks durable service ownership before liveness or mutation. `tests/cli/cli-start-journal-order.test.ts` covers an early miss; `tests/cli/cli-start-owner-absence.test.ts` covers consecutive misses and dead-owner recovery; `tests/server/proxy-liveness-recovery.test.ts` binds classification and existing budgets; `tests/server/proxy-liveness-bind-scope.test.ts` follows a missed early probe through desired-state reconciliation and the real Grok writer for runtime, orphan and configured-port discovery; `tests/server/proxy-liveness-owner-evidence.test.ts` and `tests/cli/cli-start-owner-evidence.test.ts` cover aggregate children, both listener families, shared deadlines and foreign-PID recovery. A busy preferred port is never resolved by starting somewhere else. Both questions a start asks diff --git a/tests/ci-workflows/skill-ocx-generated.test.ts b/tests/ci-workflows/skill-ocx-generated.test.ts index ef2d5ec32f7..bfa715af2e0 100644 --- a/tests/ci-workflows/skill-ocx-generated.test.ts +++ b/tests/ci-workflows/skill-ocx-generated.test.ts @@ -15,7 +15,7 @@ const GROUPS: Record = { "providers-models": ["provider", "models", "alias"], accounts: ["account", "auth", "login", "logout"], "agents-routing": ["agent", "combo", "route", "fallback", "v2", "effort", "memory", "message", "advisor"], integrations: ["claude", "integration", "commandcode", "grok", "codex-shim"], "observe-system": ["companion", "usage", "logs", "storage", "inspect", "system", "observe", "debug", "export", "import", "cost", "update", "config", "tray"], - "access-remote": ["link", "remote-workspace", "hub", "connect", "api", "access"], lab: ["lab"], + "access-remote": ["link", "remote-workspace", "hub", "connect", "disconnect", "api", "access"], lab: ["lab"], }; const FILENAMES = [INDEX, ...Object.keys(GROUPS).map(domain => `01_surface_${domain}.md`)]; const SKILL_DIR = repoPath("skills", "ocx"); diff --git a/tests/clients/client-link-connect.test.ts b/tests/clients/client-link-connect.test.ts index 25e59266a14..214d56882b2 100644 --- a/tests/clients/client-link-connect.test.ts +++ b/tests/clients/client-link-connect.test.ts @@ -1,6 +1,6 @@ import { describe, expect, spyOn, test } from "bun:test"; import { EventEmitter } from "node:events"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, fstatSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getDefaultConfig, saveConfig } from "../../src/config"; @@ -13,11 +13,20 @@ import { connectClient, routingTarget } from "../../src/client/connect"; import { readServiceApiTokenState } from "../../src/lib/service-secrets"; import { isLinkConnection, readClientConnectionState } from "../../src/client/state"; import { DEFAULT_CATALOG_PATH } from "../../src/codex/paths"; +import { createLocalAttestationProof, createLocalAttestationSecret, LOCAL_ATTESTATION_CHALLENGE_HEADER, LOCAL_ATTESTATION_PROOF_HEADER } from "../../src/lib/local-management-attestation"; +import { skipsCodexShimAutoRestore } from "../../src/cli/codex-shim-autorestore"; +import { withClientLifecycleSync } from "../../src/client/lifecycle-lock"; +import { handleConfigCommand } from "../../src/cli/config-command"; +import { acquireOwnershipMutationLease } from "../../src/service/ownership-mutation-lease.mjs"; +import { inspectServiceManagerInstallation } from "../../src/service-manager-probe"; +import { buildServiceShellCommand } from "../../src/service/definition"; +import { inspectOrphanServiceAdmission } from "../../src/client/orphan-token-recovery"; import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; import { setSyntheticWindowsPrincipalForTests } from "../../src/lib/windows-user-principal"; const linkId = "lnk_0123456789abcdef"; const key = `ocx_data_${"a".repeat(40)}`; +const noService = () => ({ kind: "absent" as const }); function client(overrides: Record = {}) { return { @@ -265,6 +274,443 @@ describe("client link connection contracts", () => { }); }); + test("confirmed orphan recovery preserves both tokens privately without exposing their bytes", async () => { + await withLinkHome(async home => { + const tokenPath = join(home, "service-api-token"); + writeFileSync(tokenPath, `${key}\n`, { mode: 0o600 }); + writeFileSync(`${tokenPath}.prev`, "previous-private-key\n", { mode: 0o600 }); + const log = spyOn(console, "log").mockImplementation(() => {}); + const err = spyOn(console, "error").mockImplementation(() => {}); + try { + const result = await handleDisconnectCommand(["--orphaned-token", "--yes", "--json"], { + lifecycleLockDeps: { lockPath: join(home, "lifecycle.sqlite") }, + orphanRecoveryDeps: { inspectServiceManager: noService, acquireLease: () => ({ release() {} }), runtimeSafe: async () => true }, + } as never); + expect(result).toBe(0); + const output = log.mock.calls.flat().join(" "); + expect(output).not.toContain(key); + expect(output).not.toContain("previous-private-key"); + const payload = JSON.parse(output) as { backupDir: string }; + expect(readFileSync(join(payload.backupDir, "service-api-token"), "utf8")).toBe(`${key}\n`); + expect(readFileSync(join(payload.backupDir, "service-api-token.prev"), "utf8")).toBe("previous-private-key\n"); + expect(existsSync(tokenPath)).toBe(false); + expect(existsSync(`${tokenPath}.prev`)).toBe(false); + if (process.platform !== "win32") { + expect(lstatSync(payload.backupDir).mode & 0o777).toBe(0o700); + expect(lstatSync(join(payload.backupDir, "service-api-token")).mode & 0o777).toBe(0o600); + } + } finally { log.mockRestore(); err.mockRestore(); } + }); + }); + + test.each(["hub", "non-loopback", "configured-token", "configured-token-file", "matching-api-key", "matching-pending-key", "service-exported", "service-unknown", "service-probe-failed"])( + "orphan recovery refuses %s admission ownership", async scenario => { + await withLinkHome(async home => { + const tokenPath = join(home, "service-api-token"); + writeFileSync(tokenPath, `${key}\n`, { mode: 0o600 }); + const config = getDefaultConfig(); + if (scenario === "hub") config.runtimeRole = "hub"; + if (scenario === "non-loopback") config.hostname = "0.0.0.0"; + if (scenario === "matching-api-key") config.apiKeys = [{ id: "fixture", name: "fixture", key, createdAt: "2026-01-01T00:00:00.000Z" }]; + if (scenario === "matching-pending-key") config.apiKeys = [{ id: "fixture", name: "fixture", key: "different-key", createdAt: "2026-01-01T00:00:00.000Z", pendingRotation: { id: "rotation", key, createdAt: "2026-01-01T00:00:00.000Z", expiresAt: "2030-01-01T00:00:00.000Z" } }]; + saveConfig(config); + const previousToken = process.env.OPENCODEX_API_AUTH_TOKEN; + const previousTokenFile = process.env.OCX_API_TOKEN_FILE; + if (scenario === "configured-token") process.env.OPENCODEX_API_AUTH_TOKEN = key; + if (scenario === "configured-token-file") process.env.OCX_API_TOKEN_FILE = tokenPath; + let inspectServiceManager = noService; + if (scenario === "service-exported") { + const serviceHome = join(home, "service-user"); + const directory = join(serviceHome, ".config", "systemd", "user"); + mkdirSync(directory, { recursive: true }); + const definition = join(directory, "opencodex-proxy.service"); + const command = buildServiceShellCommand("/usr/bin/bun", null, 12345, tokenPath); + writeFileSync(definition, `[Service]\nEnvironment="OPENCODEX_HOME=${home}"\nExecStart=${command}\n`); + expect(command).toContain("OPENCODEX_API_AUTH_TOKEN"); + expect(command).toContain(tokenPath); + const inspection = inspectServiceManagerInstallation({ platform: "linux", home: serviceHome, + run: () => ({ status: 0, stdout: `LoadState=loaded\nActiveState=inactive\nFragmentPath=${definition}\nNeedDaemonReload=no\n`, stderr: "", timedOut: false, spawnFailed: false }) }); + expect(inspection.kind).toBe("present"); + inspectServiceManager = (() => inspection) as typeof noService; + } + if (scenario === "service-unknown") inspectServiceManager = (() => ({ kind: "unknown", reason: "fixture" })) as unknown as typeof noService; + if (scenario === "service-probe-failed") inspectServiceManager = (() => inspectOrphanServiceAdmission({ platform: "linux", home, + run: () => ({ status: null, stdout: "", stderr: "", timedOut: false, spawnFailed: true }) })) as typeof noService; + const err = spyOn(console, "error").mockImplementation(() => {}); + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + const result = await handleDisconnectCommand(["--orphaned-token", "--yes"], { + lifecycleLockDeps: { lockPath: join(home, "lifecycle.sqlite") }, + orphanRecoveryDeps: { inspectServiceManager, acquireLease: () => ({ release() {} }), runtimeSafe: async () => true }, + } as never); + expect(result).toBe(1); + expect(err.mock.calls.flat().join(" ").toLowerCase()).toContain(scenario === "hub" ? "hub" : "admission"); + expect(readFileSync(tokenPath, "utf8")).toBe(`${key}\n`); + } finally { + if (previousToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; else process.env.OPENCODEX_API_AUTH_TOKEN = previousToken; + if (previousTokenFile === undefined) delete process.env.OCX_API_TOKEN_FILE; else process.env.OCX_API_TOKEN_FILE = previousTokenFile; + err.mockRestore(); log.mockRestore(); + } + }); + }, + ); + + test("orphan recovery permits a completed disconnect receipt", async () => { + await withLinkHome(async home => { + writeFileSync(join(home, "service-api-token"), `${key}\n`, { mode: 0o600 }); + mkdirSync(join(home, "desktop-remote"), { mode: 0o700 }); + const receipt = { version: 1, owner: { serverUrl: "http://127.0.0.1:34567", apiKeyId: "key-1", connectedAt: "2026-09-25T00:00:00.000Z" }, + tokenFingerprint: "a".repeat(64), keepCatalog: false, phase: "complete" }; + writeFileSync(join(home, "desktop-remote", "disconnect.json"), JSON.stringify(receipt), { mode: 0o600 }); + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(await handleDisconnectCommand(["--orphaned-token", "--yes"], { + lifecycleLockDeps: { lockPath: join(home, "lifecycle.sqlite") }, + orphanRecoveryDeps: { inspectServiceManager: noService, acquireLease: () => ({ release() {} }), runtimeSafe: async () => true }, + } as never)).toBe(0); + } finally { log.mockRestore(); } + }); + }); + + test("Windows-style recovery closes source handles before moving and retains the identity witness", async () => { + await withLinkHome(async home => { + const tokenPath = join(home, "service-api-token"); + writeFileSync(tokenPath, `${key}\n`, { mode: 0o600 }); + writeFileSync(`${tokenPath}.prev`, "previous-private-key\n", { mode: 0o600 }); + const tokenInode = lstatSync(tokenPath).ino; + let observed = false; + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + const result = await handleDisconnectCommand(["--orphaned-token", "--yes", "--json"], { + lifecycleLockDeps: { lockPath: join(home, "lifecycle.sqlite") }, + orphanRecoveryDeps: { inspectServiceManager: noService, acquireLease: () => ({ release() {} }), runtimeSafe: async () => true, + windowsMoveForTest: true, onWindowsHandlesClosed: (fds: readonly number[]) => { + expect(fds.length).toBe(2); + for (const fd of fds) expect(() => fstatSync(fd)).toThrow(); + observed = true; + } }, + } as never); + expect(result).toBe(0); + expect(observed).toBe(true); + const backup = JSON.parse(log.mock.calls.flat().join(" ")) as { backupDir: string }; + expect(lstatSync(join(backup.backupDir, "service-api-token")).ino).toBe(tokenInode); + expect(readFileSync(join(backup.backupDir, "service-api-token"), "utf8")).toBe(`${key}\n`); + expect(readFileSync(join(backup.backupDir, "service-api-token.prev"), "utf8")).toBe("previous-private-key\n"); + expect(readdirSync(backup.backupDir).sort()).toEqual(["service-api-token", "service-api-token.prev"]); + } finally { log.mockRestore(); } + }); + }); + + test("orphan recovery rechecks service admission after liveness", async () => { + await withLinkHome(async home => { + const tokenPath = join(home, "service-api-token"); + writeFileSync(tokenPath, `${key}\n`, { mode: 0o600 }); + let checks = 0; + const err = spyOn(console, "error").mockImplementation(() => {}); + try { + const result = await handleDisconnectCommand(["--orphaned-token", "--yes"], { + lifecycleLockDeps: { lockPath: join(home, "lifecycle.sqlite") }, + orphanRecoveryDeps: { acquireLease: () => ({ release() {} }), runtimeSafe: async () => true, + inspectServiceManager: () => ++checks === 1 ? { kind: "absent" } : { kind: "unknown", reason: "fixture" } }, + } as never); + expect(result).toBe(1); + expect(checks).toBe(2); + expect(err.mock.calls.flat().join(" ")).toContain("admission secret"); + expect(readFileSync(tokenPath, "utf8")).toBe(`${key}\n`); + } finally { err.mockRestore(); } + }); + }); + + test("orphan recovery rechecks the admission bind after liveness", async () => { + await withLinkHome(async home => { + const tokenPath = join(home, "service-api-token"); + writeFileSync(tokenPath, `${key}\n`, { mode: 0o600 }); + const err = spyOn(console, "error").mockImplementation(() => {}); + try { + const result = await handleDisconnectCommand(["--orphaned-token", "--yes"], { + lifecycleLockDeps: { lockPath: join(home, "lifecycle.sqlite") }, + orphanRecoveryDeps: { inspectServiceManager: noService, acquireLease: () => ({ release() {} }), runtimeSafe: async () => { + const config = getDefaultConfig(); config.hostname = "0.0.0.0"; saveConfig(config); return true; + } }, + } as never); + expect(result).toBe(1); + expect(err.mock.calls.flat().join(" ")).toContain("admission secret"); + expect(readFileSync(tokenPath, "utf8")).toBe(`${key}\n`); + } finally { err.mockRestore(); } + }); + }); + + test("orphan recovery rechecks an admission-token environment change after liveness", async () => { + await withLinkHome(async home => { + const tokenPath = join(home, "service-api-token"); + writeFileSync(tokenPath, `${key}\n`, { mode: 0o600 }); + const previous = process.env.OPENCODEX_API_AUTH_TOKEN; + const err = spyOn(console, "error").mockImplementation(() => {}); + try { + const result = await handleDisconnectCommand(["--orphaned-token", "--yes"], { + lifecycleLockDeps: { lockPath: join(home, "lifecycle.sqlite") }, + orphanRecoveryDeps: { inspectServiceManager: noService, acquireLease: () => ({ release() {} }), runtimeSafe: async () => { + process.env.OPENCODEX_API_AUTH_TOKEN = key; return true; + } }, + } as never); + expect(result).toBe(1); + expect(err.mock.calls.flat().join(" ")).toContain("admission secret"); + expect(readFileSync(tokenPath, "utf8")).toBe(`${key}\n`); + } finally { + if (previous === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; else process.env.OPENCODEX_API_AUTH_TOKEN = previous; + err.mockRestore(); + } + }); + }); + + test("Windows-style identity pin preserves the original if the source name is replaced", async () => { + await withLinkHome(async home => { + const tokenPath = join(home, "service-api-token"); + writeFileSync(tokenPath, `${key}\n`, { mode: 0o600 }); + const err = spyOn(console, "error").mockImplementation(() => {}); + try { + const result = await handleDisconnectCommand(["--orphaned-token", "--yes"], { + lifecycleLockDeps: { lockPath: join(home, "lifecycle.sqlite") }, + orphanRecoveryDeps: { inspectServiceManager: noService, acquireLease: () => ({ release() {} }), runtimeSafe: async () => true, + windowsMoveForTest: true, onWindowsHandlesClosed: () => { + renameSync(tokenPath, join(home, "moved-original")); + writeFileSync(tokenPath, "different-private-key\n", { mode: 0o600 }); + } }, + } as never); + expect(result).toBe(1); + expect(readFileSync(tokenPath, "utf8")).toBe("different-private-key\n"); + expect(readFileSync(join(home, "moved-original"), "utf8")).toBe(`${key}\n`); + const backupName = readdirSync(home).find(name => name.startsWith("orphaned-token-")); + expect(backupName).toBeDefined(); + expect(readFileSync(join(home, backupName!, ".service-api-token.identity"), "utf8")).toBe(`${key}\n`); + expect(err.mock.calls.flat().join(" ")).toContain("orphan recovery incomplete"); + } finally { err.mockRestore(); } + }); + }); + + test("orphan recovery refuses a held start ownership lease", async () => { + await withLinkHome(async home => { + const tokenPath = join(home, "service-api-token"); + writeFileSync(tokenPath, `${key}\n`, { mode: 0o600 }); + const statePaths = [join(home, "service-state.json")]; + const held = acquireOwnershipMutationLease(statePaths, { isolated: true, waitMs: 0 }); + const err = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(await handleDisconnectCommand(["--orphaned-token", "--yes"], { + lifecycleLockDeps: { lockPath: join(home, "lifecycle.sqlite") }, + orphanRecoveryDeps: { inspectServiceManager: noService, acquireLease: () => acquireOwnershipMutationLease(statePaths, { isolated: true, waitMs: 0 }), runtimeSafe: async () => true }, + } as never)).toBe(1); + expect(err.mock.calls.flat().join(" ")).toContain("mutation lease"); + expect(readFileSync(tokenPath, "utf8")).toBe(`${key}\n`); + } finally { held.release(); err.mockRestore(); } + }); + }); + + test("orphan recovery bypasses automatic shim repair even without confirmation", () => { + expect(skipsCodexShimAutoRestore("disconnect", ["disconnect", "--orphaned-token"])).toBe(true); + }); + + test("orphan token refusal and link enrollment name the explicit recovery command", async () => { + await withLinkHome(async home => { + writeFileSync(join(home, "service-api-token"), `${key}\n`, { mode: 0o600 }); + const err = spyOn(console, "error").mockImplementation(() => {}); + try { + const result = await handleDisconnectCommand([], { + lifecycleLockDeps: { lockPath: join(home, "lifecycle.sqlite") }, + linkTeardownDeps: { reapOrphanTunnel: async () => ({ tunnel: "absent" }) }, + }); + expect(result).toBe(1); + expect(err.mock.calls.flat().join(" ")).toContain("ocx disconnect --orphaned-token --yes"); + await expect(connectClient(linkOptions(), { + lifecycleLockDeps: { lockPath: join(home, "lifecycle.sqlite") }, + })).rejects.toThrow("ocx disconnect --orphaned-token --yes"); + expect(readFileSync(join(home, "service-api-token"), "utf8")).toBe(`${key}\n`); + } finally { err.mockRestore(); } + }); + }); + + test("a new link enrollment succeeds after explicitly preserving the orphan token", async () => { + await withLinkHome(async home => { + const tokenPath = join(home, "service-api-token"); + writeFileSync(tokenPath, `${key}\n`, { mode: 0o600 }); + const lockPath = join(home, "lifecycle.sqlite"); + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(await handleDisconnectCommand(["--orphaned-token", "--yes", "--json"], { + lifecycleLockDeps: { lockPath }, + orphanRecoveryDeps: { inspectServiceManager: noService, acquireLease: () => ({ release() {} }), runtimeSafe: async () => true }, + } as never)).toBe(0); + const backup = JSON.parse(log.mock.calls.flat().join(" ")) as { backupDir: string }; + const newKey = `ocx_data_${"b".repeat(40)}`; + const connection = await connectClient({ ...linkOptions(), credential: { kind: "link", apiKeyId: "new-key", key: newKey } }, { + lifecycleLockDeps: { lockPath }, + fetchImpl: async input => String(input).endsWith("/readyz") + ? Response.json({ service: "opencodex", version: "0.0.0", uptime: 1, pid: 1, port: 34567, status: "ready", protocol: 1, minimumClientProtocol: 1, managementUrl: "http://127.0.0.1:34567" }) + : Response.json({ models: [] }), + }); + expect(connection.apiKeyId).toBe("new-key"); + expect(readFileSync(tokenPath, "utf8")).toBe(`${newKey}\n`); + expect(readFileSync(join(backup.backupDir, "service-api-token"), "utf8")).toBe(`${key}\n`); + } finally { log.mockRestore(); } + }); + }); + + test("config import refuses to replace an active connection record", async () => { + await withLinkHome(async home => { + const configPath = join(home, "config.json"); + const original = JSON.stringify({ ...getDefaultConfig(), runtimeRole: "client", client: client() }); + writeFileSync(configPath, original); + const input = join(home, "replacement.json"); + writeFileSync(input, JSON.stringify(getDefaultConfig())); + const log = spyOn(console, "log").mockImplementation(() => {}); + const err = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(await handleConfigCommand(["import", input, "--yes"])).not.toBe(0); + expect(err.mock.calls.flat().join(" ")).toContain("disconnect"); + expect(readFileSync(configPath, "utf8")).toBe(original); + writeFileSync(input, original); + expect(await handleConfigCommand(["import", input, "--yes"])).toBe(0); + expect(readClientConnectionState().kind).toBe("connected"); + } finally { log.mockRestore(); err.mockRestore(); } + }); + }); + + test("orphan recovery holds the client lifecycle lock while checking liveness", async () => { + await withLinkHome(async home => { + writeFileSync(join(home, "service-api-token"), `${key}\n`, { mode: 0o600 }); + const lockPath = join(home, "lifecycle.sqlite"); + let proceed!: () => void; + let observed!: () => void; + const started = new Promise(resolve => { observed = resolve; }); + const gate = new Promise(resolve => { proceed = resolve; }); + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + const pending = handleDisconnectCommand(["--orphaned-token", "--yes"], { + lifecycleLockDeps: { lockPath }, + orphanRecoveryDeps: { inspectServiceManager: noService, acquireLease: () => ({ release() {} }), runtimeSafe: async () => { observed(); await gate; return true; } }, + } as never); + await started; + expect(() => withClientLifecycleSync(() => {}, { lockPath })).toThrow("client_lifecycle_busy"); + proceed(); + expect(await pending).toBe(0); + } finally { proceed(); log.mockRestore(); } + }); + }); + + test.each(["missing-confirmation", "pending-connect", "pending-appeared", "disconnect-receipt", "disconnect-in-progress", "connected", "connection-appeared", "config-changed", "config-aba", "client-runtime", "unknown-runtime", "token-replaced", "backup-appeared"])( + "orphan recovery refuses %s without changing the token", async scenario => { + await withLinkHome(async home => { + const tokenPath = join(home, "service-api-token"); + writeFileSync(tokenPath, `${key}\n`, { mode: 0o600 }); + if (scenario === "pending-connect") writeFileSync(join(home, "client-connect-pending"), `${"a".repeat(64)}\n`, { mode: 0o600 }); + if (scenario === "disconnect-receipt" || scenario === "disconnect-in-progress") { + mkdirSync(join(home, "desktop-remote"), { mode: 0o700 }); + const receipt = scenario === "disconnect-receipt" ? {} : { + version: 1, owner: { serverUrl: "http://127.0.0.1:34567", apiKeyId: "key-1", connectedAt: "2026-09-25T00:00:00.000Z" }, + tokenFingerprint: "a".repeat(64), keepCatalog: false, phase: "prepared", + }; + writeFileSync(join(home, "desktop-remote", "disconnect.json"), JSON.stringify(receipt), { mode: 0o600 }); + } + if (scenario === "connected") { + writeFileSync(join(home, "config.json"), JSON.stringify({ ...getDefaultConfig(), runtimeRole: "client", client: client() })); + } + const log = spyOn(console, "log").mockImplementation(() => {}); + const err = spyOn(console, "error").mockImplementation(() => {}); + try { + const args = ["--orphaned-token", ...(scenario === "missing-confirmation" ? [] : ["--yes"])]; + const result = await handleDisconnectCommand(args, { + lifecycleLockDeps: { lockPath: join(home, "lifecycle.sqlite") }, + orphanRecoveryDeps: { inspectServiceManager: noService, acquireLease: () => ({ release() {} }), runtimeSafe: async () => { + if (scenario === "token-replaced") writeFileSync(tokenPath, "replacement-private-key\n", { mode: 0o600 }); + if (scenario === "backup-appeared") writeFileSync(`${tokenPath}.prev`, "new-backup\n", { mode: 0o600 }); + if (scenario === "pending-appeared") writeFileSync(join(home, "client-connect-pending"), `${"a".repeat(64)}\n`, { mode: 0o600 }); + if (scenario === "connection-appeared") writeFileSync(join(home, "config.json"), JSON.stringify({ ...getDefaultConfig(), runtimeRole: "client", client: client() })); + if (scenario === "config-changed") writeFileSync(join(home, "config.json"), JSON.stringify({ ...getDefaultConfig(), port: 45678 })); + if (scenario === "config-aba") { + const changed = getDefaultConfig(); changed.port = 45678; saveConfig(changed); + const restored = getDefaultConfig(); restored.port = 10100; saveConfig(restored); + } + return scenario !== "client-runtime" && scenario !== "unknown-runtime"; + } }, + } as never); + expect(result).not.toBe(0); + const message = err.mock.calls.flat().join(" "); + const reason: Record = { + "missing-confirmation": "requires --yes", "pending-connect": "client connect is pending", + "pending-appeared": "client connect is pending", + "disconnect-receipt": "disconnect is pending or unsafe", connected: "client state is not disconnected", + "disconnect-in-progress": "disconnect is pending or unsafe", "connection-appeared": "client state is not disconnected", + "config-changed": "config changed", + "config-aba": "config changed", + "client-runtime": "client runtime is live or runtime state is unverified", + "unknown-runtime": "client runtime is live or runtime state is unverified", + "token-replaced": "token file changed", "backup-appeared": "token file changed", + }; + expect(message).toContain(reason[scenario]!); + expect(readFileSync(tokenPath, "utf8")).toBe(scenario === "token-replaced" ? "replacement-private-key\n" : `${key}\n`); + if (scenario === "backup-appeared") expect(readFileSync(`${tokenPath}.prev`, "utf8")).toBe("new-backup\n"); + } finally { log.mockRestore(); err.mockRestore(); } + }); + }, + ); + + test.each(["client", "unknown", "absent", "standalone", "live-public-bind", "live-unknown-bind", "dual-client", "legacy-live", "live-pid-no-endpoint"])("orphan recovery checks real local liveness: %s", async scenario => { + await withLinkHome(async home => { + const tokenPath = join(home, "service-api-token"); + writeFileSync(tokenPath, `${key}\n`, { mode: 0o600 }); + const secret = createLocalAttestationSecret(); + const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: req => { + if (scenario === "unknown") return new Response("not ready", { status: 503 }); + const response = Response.json({ service: "opencodex", version: "1.0.0", ...(["standalone", "dual-client", "live-public-bind", "live-unknown-bind"].includes(scenario) ? { providerReloadCapability: "v1" } : scenario === "legacy-live" ? {} : { role: "client" }), pid: process.pid, port: server.port, uptime: 1 }); + const challenge = req.headers.get(LOCAL_ATTESTATION_CHALLENGE_HEADER); + const proof = challenge && createLocalAttestationProof(secret, challenge, process.pid, server.port!); + if (proof) response.headers.set(LOCAL_ATTESTATION_PROOF_HEADER, proof); + return response; + } }); + const port = server.port!; + const second = scenario === "dual-client" ? Bun.serve({ port, hostname: "::1", fetch: () => Response.json({ service: "opencodex", version: "1.0.0", role: "client", pid: process.pid, port, uptime: 1 }) }) : null; + writeFileSync(join(home, "config.json"), JSON.stringify({ ...getDefaultConfig(), port, hostname: "127.0.0.1" })); + if (["standalone", "dual-client", "legacy-live", "live-pid-no-endpoint", "live-public-bind", "live-unknown-bind"].includes(scenario)) { + writeFileSync(join(home, "ocx.pid"), String(process.pid)); + if (scenario !== "live-pid-no-endpoint") writeFileSync(join(home, "runtime-port.json"), JSON.stringify({ pid: process.pid, port, + ...(scenario === "live-unknown-bind" ? {} : { hostname: scenario === "live-public-bind" ? "0.0.0.0" : "127.0.0.1" }), attestationSecret: secret })); + } + if (scenario === "absent" || scenario === "live-pid-no-endpoint") await server.stop(true); + const log = spyOn(console, "log").mockImplementation(() => {}); + const err = spyOn(console, "error").mockImplementation(() => {}); + try { + const result = await handleDisconnectCommand(["--orphaned-token", "--yes", "--json"], { + lifecycleLockDeps: { lockPath: join(home, "lifecycle.sqlite") }, + orphanRecoveryDeps: { inspectServiceManager: noService, acquireLease: () => ({ release() {} }) }, + } as never); + const allowed = scenario === "absent" || scenario === "standalone"; + expect(result).toBe(allowed ? 0 : 1); + expect(existsSync(tokenPath)).toBe(!allowed); + if (!allowed) expect(err.mock.calls.flat().join(" ")).toContain(scenario === "live-public-bind" ? "admission secret" : "runtime is live or runtime state is unverified"); + } finally { log.mockRestore(); err.mockRestore(); await second?.stop(true); await server.stop(true); } + }); + }); + + test.each(["symlink", "hardlink"])("orphan recovery preserves an unsafe %s token", async kind => { + await withLinkHome(async home => { + const target = join(home, "external-token"); + const token = join(home, "service-api-token"); + writeFileSync(target, `${key}\n`, { mode: 0o600 }); + if (kind === "symlink") symlinkSync(target, token); else linkSync(target, token); + const err = spyOn(console, "error").mockImplementation(() => {}); + try { + const result = await handleDisconnectCommand(["--orphaned-token", "--yes"], { + lifecycleLockDeps: { lockPath: join(home, "lifecycle.sqlite") }, + orphanRecoveryDeps: { inspectServiceManager: noService, acquireLease: () => ({ release() {} }), runtimeSafe: async () => true }, + } as never); + expect(result).toBe(1); + expect(err.mock.calls.flat().join(" ")).toContain("unsafe token file"); + expect(readFileSync(target, "utf8")).toBe(`${key}\n`); + expect(existsSync(token)).toBe(true); + } finally { err.mockRestore(); } + }); + }); + test("does not echo malformed link keys from stdin", async () => { const input = new EventEmitter() as EventEmitter & { readableEnded?: boolean }; input.readableEnded = false;