Files
orca/src/shared/runtime-environment-store-file.ts
T
NeilandClaude 19a73ff0b4 fix(wsl-host): keep a host's launch markers out of its shells; a corrupt WSL store hides only WSL hosts (#36772)
- ORCA_WSL_ORCAD_HOST and ORCA_WSL_DISTRO_GUID joined the server launch env both pane spawn
  paths scrub (one shared list with the activation root and idle window), so a user's own orcad
  started in a host shell no longer acts as a WSL host.
- An unreadable orca-wsl-host-deployments.json (corrupt, or a newer envelope) used to fail the
  whole server list closed while hosts were served, hiding every SSH and paired server. It now
  hides only the servers that could be WSL hosts, logs why, and writes still refuse the file so
  a newer build's copy is kept.

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-31 14:39:16 -07:00

159 lines
6.2 KiB
TypeScript

import {
JsonStringifyByteLimitError,
stringifyJsonWithinByteLimit
} from './node-bounded-json-stringify'
import { writeSecureFile } from './secure-file'
import {
RuntimeEnvironmentStoreSchema,
type KnownRuntimeEnvironment,
type RuntimeEnvironmentStore
} from './runtime-environments'
import {
overlayRuntimeEnvironmentSidecar,
readRuntimeEnvironmentSidecar,
RuntimeEnvironmentSidecarInvalidError
} from './runtime-environment-sidecar'
import {
getEnvironmentStorePath,
MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES,
readEnvironmentStoreRecords,
backUpBlankEnvironmentStore,
remoteServerPairingStoreError,
RuntimeEnvironmentStoreError,
type EnvironmentStoreRecordsReadOptions,
warnUnreadableEnvironmentState
} from './runtime-environment-store-records'
import {
overlayWslHostDeployments,
readWslHostDeployments,
WslHostDeploymentStoreInvalidError
} from './wsl-host-deployment-store'
import { isWslHostsServing } from './wsl-host-internal-gate'
export {
getEnvironmentStorePath,
MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES,
RuntimeEnvironmentStoreError,
type RuntimeEnvironmentStoreErrorCode
} from './runtime-environment-store-records'
/** Readable records only; writeEnvironmentStore preserves unreadable corrupt/newer records verbatim. */
export function readPersistedEnvironmentStore(
userDataPath: string,
options: EnvironmentStoreRecordsReadOptions = {}
): RuntimeEnvironmentStore {
const { environments, unreadable } = readEnvironmentStoreRecords(userDataPath, options)
if (unreadable.length > 0) {
warnUnreadableEnvironmentState(
`Skipping ${unreadable.length} unreadable server record(s) in ${getEnvironmentStorePath(userDataPath)} (ids: ${unreadable.map((record) => record.id ?? '<none>').join(', ')}); they may need a newer version of Orca and are kept on disk.`
)
}
return {
version: 1,
environments: environments.sort((a, b) => a.name.localeCompare(b.name))
}
}
/**
* Unreadable WSL state never hides SSH or paired servers. While WSL hosts are served, an unreadable
* file hides only the servers that could be WSL hosts (it may pin their keys); writes still refuse
* it, so a newer build's file is kept as it is. With hosts off it hides nothing.
*/
function readWslHostDeploymentsForRegistry(
userDataPath: string
): ReturnType<typeof readWslHostDeployments> {
const gateOpen = isWslHostsServing(process.env)
try {
const read = readWslHostDeployments(userDataPath)
return gateOpen ? read : { deployments: read.deployments, unreadableEnvironmentIds: new Set() }
} catch (error) {
if (!(error instanceof WslHostDeploymentStoreInvalidError)) {
throw error
}
warnUnreadableEnvironmentState(error.message)
return { deployments: [], unreadableEnvironmentIds: new Set(), fileUnreadable: gateOpen }
}
}
export function readEnvironmentStore(
userDataPath: string,
options: EnvironmentStoreRecordsReadOptions = {}
): {
version: 1
environments: KnownRuntimeEnvironment[]
} {
const store = readPersistedEnvironmentStore(userDataPath, options)
let sidecar: ReturnType<typeof readRuntimeEnvironmentSidecar>
let wslDeployments: ReturnType<typeof readWslHostDeployments>
try {
sidecar = readRuntimeEnvironmentSidecar(userDataPath)
wslDeployments = readWslHostDeploymentsForRegistry(userDataPath)
} catch (error) {
// Fail closed like the main file: a link we cannot read may pin a host key we must not drop.
if (
error instanceof RuntimeEnvironmentSidecarInvalidError ||
error instanceof WslHostDeploymentStoreInvalidError
) {
throw new RuntimeEnvironmentStoreError('runtime_error', error.message)
}
throw error
}
// Hide unreadable overlays: connecting/editing without them could silently discard SSH pins or deployment ownership.
const isHidden = (id: string): boolean => Object.hasOwn(sidecar.unreadable, id)
const hidden = store.environments.filter((environment) => isHidden(environment.id))
if (hidden.length > 0) {
warnUnreadableEnvironmentState(
`Hiding ${hidden.length} server(s) whose links cannot be read (ids: ${hidden.map((environment) => environment.id).join(', ')}); they may need a newer version of Orca.`
)
}
return {
version: 1,
environments: overlayWslHostDeployments(
store.environments
.filter((environment) => !isHidden(environment.id))
.map((environment) =>
overlayRuntimeEnvironmentSidecar(environment, sidecar.entries[environment.id])
),
wslDeployments
)
}
}
/** Write persisted fields only; preserve unreadable on-disk records verbatim/in order. Sidecar writes stay separate. */
export function writeEnvironmentStore(userDataPath: string, store: RuntimeEnvironmentStore): void {
const path = getEnvironmentStorePath(userDataPath)
const environments = RuntimeEnvironmentStoreSchema.parse(store).environments
// The new registry replaces a blank file in one atomic write, so no reader sees it emptied early.
const onDisk = readEnvironmentStoreRecords(userDataPath, { recoverBlankStore: true })
const readableIds = new Set(onDisk.environments.map((environment) => environment.id))
const unreadableIds = new Set(onDisk.unreadable.map((record) => record.id))
// A new readable record must not shadow one a newer build owns under the same id.
if (environments.some(({ id }) => unreadableIds.has(id) && !readableIds.has(id))) {
throw remoteServerPairingStoreError('idNeedsNewerOrca')
}
let serialized: string
try {
serialized = stringifyJsonWithinByteLimit(
{
version: 1,
environments: [...environments, ...onDisk.unreadable.map((record) => record.raw)]
},
MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES
).serialized
} catch (error) {
if (error instanceof JsonStringifyByteLimitError) {
throw new RuntimeEnvironmentStoreError(
'runtime_error',
`Could not write Orca environments at ${path}; the store exceeds its durable capacity.`
)
}
throw error
}
// Copied only once the write is certain to be attempted, so rejected saves leave no copies.
if (onDisk.blankFile) {
backUpBlankEnvironmentStore(userDataPath)
}
// Without fsync before the rename, a crash can publish the name over NUL-filled data (#18766).
writeSecureFile(path, serialized, { durable: true })
}