Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ssh-windows-hosts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ on:
- 'config/scripts/relay-windows-process-tree-staging.mjs'
- 'config/scripts/windows-process-tree-gyp-rebuild.mjs'
- 'src/shared/relay-windows-breakaway-launch.ts'
- 'src/shared/windows-breakaway-launch*.ts'
- '!src/**/*.test.ts'
- 'src/main/ssh/ssh-relay-windows-host-lane.test.ts'
- 'config/ci/windows-ssh-provider/**'
Expand Down
10 changes: 10 additions & 0 deletions docs/reference/windows-edr-posture.md
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,16 @@ built without the addon or a job that refuses breakaway, and a refusal there is
reported as `ORCA_RELAY_LAUNCH_REFUSED`. The Windows SSH-host lanes run with no
WMI grant and assert the breakaway route.

`orcad.js` exposes the same launcher (`src/shared/windows-breakaway-launcher.ts`)
with **no** WMI fallback: a host that cannot break away refuses the orcad launch.
Windows orcad operations start **no PowerShell**: sshd's DefaultShell runs the
pinned `node.exe` directly with plain path arguments, against one content-addressed
host script staged beside the slots (`src/main/ssh/orcad-windows-host-script.ts`).
Only a `node.exe` path that itself needs quoting (a profile name with a space)
falls back to one unencoded `powershell.exe -Command`. The launch waits for
readiness host-side, one exec per 20 s at most, rather than re-running an exec
every 500 ms.

## Signing is not the gate

The most useful calibration in the whole incident set came from the reporter's
Expand Down
28 changes: 28 additions & 0 deletions src/main/orcad/main-preflight-order.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,32 @@ describe('orcad entry', () => {
expect(order).toEqual([`managed-stop:${flag} {}`])
}
)

it('runs the Windows breakaway launcher without preflights or a runtime', async () => {
vi.spyOn(process, 'argv', 'get').mockReturnValue([
'runtime',
'orcad.js',
'--windows-breakaway-launch',
'--stdout-file',
'out',
'--stderr-file',
'err',
'--orcad-args',
'--json'
])
const write = vi
.spyOn(process.stdout, 'write')
.mockImplementation((_chunk: unknown, callback?: unknown) => {
if (typeof callback === 'function') {
callback()
}
return true
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub only records the call; nothing reads its never return.
const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never)
await import('./main')
await vi.waitFor(() => expect(exit).toHaveBeenCalled())
expect(String(write.mock.calls[0]?.[0])).toMatch(/^ORCA_ORCAD_LAUNCH /u)
expect(order).toEqual([])
})
})
11 changes: 9 additions & 2 deletions src/main/orcad/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import {
ORCAD_CANCEL_MANAGED_STOP_FLAG,
ORCAD_COMPLETE_MANAGED_STOP_FLAG
} from '../../shared/orcad-stop-request'
import {
ORCAD_WINDOWS_BREAKAWAY_CONTRACT,
WINDOWS_BREAKAWAY_LAUNCH_FLAG
} from '../../shared/windows-breakaway-launch'
import { runWindowsBreakawayLaunchIfRequested } from '../../shared/windows-breakaway-launcher'

// Why exit before the preflight: reaching this line means the whole module graph resolved
// under plain Node, which is all the build guard needs to prove. Probing natives or
Expand All @@ -34,8 +39,10 @@ function failStartup(error: unknown): void {
process.exit(resolveOrcadExitCode(error))
}

// Why before the bundled handoff and preflights: completing a stop must not start a runtime.
if (
// Why before the bundled handoff and preflights: launching or completing a stop must not start a runtime.
if (process.argv[2] === WINDOWS_BREAKAWAY_LAUNCH_FLAG) {
runWindowsBreakawayLaunchIfRequested(ORCAD_WINDOWS_BREAKAWAY_CONTRACT, process.argv)
} else if (
process.argv[2] === ORCAD_COMPLETE_MANAGED_STOP_FLAG ||
process.argv[2] === ORCAD_CANCEL_MANAGED_STOP_FLAG
) {
Expand Down
14 changes: 13 additions & 1 deletion src/main/orcad/orcad-managed-stop-command.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
/**
* `orcad --complete-managed-stop <request-json>`: one stop, one JSON verdict line on stdout.
* `orcad --cancel-managed-stop <request-json>`: one cancellation, one JSON outcome line.
* Either takes `--request-file <path>` in place of the JSON.
*
* Exit 0 means a result was printed — read it, the exit code does not carry it. 64 is a
* malformed invocation; 1 is a failure before any result, which is never evidence of exit.
*/
import {
ORCAD_CANCEL_MANAGED_STOP_FLAG,
ORCAD_COMPLETE_MANAGED_STOP_FLAG,
ORCAD_MANAGED_STOP_REQUEST_FILE_FLAG,
OrcadManagedStopRequestSchema,
type OrcadManagedStopCancellation,
type OrcadManagedStopCompletion,
type OrcadManagedStopRequest
} from '../../shared/orcad-stop-request'
import { cancelOrcadManagedStop } from './orcad-managed-stop-cancellation'
import { readOrcadCompletedStopReceipt } from './orcad-completed-stop-receipt'
import { readOrcadManagedStopRequest } from './orcad-managed-stop-request'
import { ZodError } from 'zod'
import {
completeOrcadManagedStop,
Expand Down Expand Up @@ -56,7 +59,16 @@ export function runOrcadManagedStopCancelCommand(
}

function parseRequestArgument(argv: readonly string[], flag: string): OrcadManagedStopRequest {
if (argv.length !== 2 || argv[0] !== flag || !argv[1]) {
if (argv[0] !== flag) {
throw new Error('orcad_managed_stop_invalid_arguments')
}
if (argv[1] === ORCAD_MANAGED_STOP_REQUEST_FILE_FLAG) {
if (argv.length !== 3 || !argv[2]) {
throw new Error('orcad_managed_stop_invalid_arguments')
}
return readOrcadManagedStopRequest(argv[2])
}
if (argv.length !== 2 || !argv[1]) {
throw new Error('orcad_managed_stop_invalid_arguments')
}
return OrcadManagedStopRequestSchema.parse(JSON.parse(argv[1]))
Expand Down
18 changes: 18 additions & 0 deletions src/main/orcad/orcad-managed-stop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,24 @@ describe('completing a managed stop', () => {
'invalid_arguments'
)
})

it('reads the request from a staged file instead of argv', async () => {
const { lock, request } = running()
const staged = join(lock.path, '..', 'staged-request.json')
writeFileSync(staged, JSON.stringify(request))
vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
const completion = await runOrcadManagedStopCommand(
['--complete-managed-stop', '--request-file', staged],
options({ probeProcess: () => 'missing' })
)
expect(completion).toMatchObject({
transactionId: request.transactionId,
verdict: 'exited'
})
await expect(
runOrcadManagedStopCommand(['--complete-managed-stop', '--request-file'])
).rejects.toThrow('invalid_arguments')
})
})

describe('managed stops that retire the daemon', () => {
Expand Down
7 changes: 6 additions & 1 deletion src/main/ssh/orcad-activation-host-test-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ const MIGRATION = '|migrated-by-new'

export type CrashMode = 'before' | 'after'

/** The one-shot readiness read, or the launch loop's host-side wait. */
export function isReadinessRead(command: string): boolean {
return command.startsWith('head -c ') || command.includes('orcad_readiness_wait')
}

export class FakeOrcadHost {
record: string | null = null
journal: string | null = null
Expand Down Expand Up @@ -168,7 +173,7 @@ export class FakeOrcadHost {
return '9999'
})
}
if (command.startsWith('head -c ') && version) {
if (isReadinessRead(command) && version) {
return this.alive.has(version) ? readyLine(version) : ''
}
if (command.includes('echo PRESENT') && snapshot) {
Expand Down
73 changes: 67 additions & 6 deletions src/main/ssh/orcad-managed-remote-stop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,28 @@
* payload (runtime ID, PID, capability) and the data root's instance lock (PID, start time,
* nonce). The slot's own `orcad.js` then writes the request and proves exit on the host.
*/
import { randomUUID } from 'node:crypto'
import { shellEscape } from './ssh-connection-utils'
import { assertPosixOrcadHost } from './orcad-remote-host-support'
import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime'
import { parseOrcadReadinessOutput, readOrcadReadinessCommand } from './orcad-remote-launch'
import { execOrcadRemote } from './orcad-remote-runtime-control'
import { readBoundedOrcadRemoteRecord } from './orcad-remote-record-file'
import { orcadSlotDir, type OrcadSlotOptions } from './orcad-recovery-slot'
import { joinRemotePath } from './ssh-remote-platform'
import { isWindowsRemoteHost, joinRemotePath } from './ssh-remote-platform'
import { isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers'
import {
orcadWindowsBaseDir,
orcadWindowsHostOpCommand,
orcadWindowsNodeCommandLine,
readOrcadWindowsEncodedAnswer
} from './orcad-remote-windows-node'
import { ORCAD_WINDOWS_RUNTIME_MARKER } from './orcad-windows-host-script'
import { ORCAD_LOCK_FILE_NAME, parseOrcadInstanceLockRecord } from '../orcad/orcad-instance-lock'
import {
ORCAD_CANCEL_MANAGED_STOP_FLAG,
ORCAD_COMPLETE_MANAGED_STOP_FLAG,
ORCAD_MANAGED_STOP_REQUEST_FILE_FLAG,
ORCAD_STOP_REQUESTS_CAPABILITY,
OrcadManagedStopCancellationSchema,
OrcadManagedStopCompletionSchema,
Expand Down Expand Up @@ -104,11 +114,13 @@ async function runSlotCommand(
): Promise<unknown> {
const slotDir = orcadSlotDir(options, request.version)
const entry = joinRemotePath(options.host, slotDir, 'orcad.js')
const output = await execOrcadRemote(
options,
`${selectOrcadSlotRuntimeCommand(options.host, slotDir, options.nodePath)} && ` +
`"$orcad_runtime" ${shellEscape(entry)} ${flag} ${shellEscape(JSON.stringify(request))}`
)
const output = isWindowsRemoteHost(options.host)
? await runWindowsSlotCommand(options, slotDir, entry, request, flag)
: await execOrcadRemote(
options,
`${selectOrcadSlotRuntimeCommand(options.host, slotDir, options.nodePath)} && ` +
`"$orcad_runtime" ${shellEscape(entry)} ${flag} ${shellEscape(JSON.stringify(request))}`
)
const line = output
.trim()
.split('\n')
Expand All @@ -126,3 +138,52 @@ async function runSlotCommand(
}
return parsed
}

/** Windows passes the request as a staged file: JSON on a command line meets two quoting layers. */
async function runWindowsSlotCommand(
options: OrcadSlotOptions,
slotDir: string,
entry: string,
request: OrcadManagedStopRequest,
flag: string
): Promise<string> {
const baseDir = orcadWindowsBaseDir(options.host, slotDir)
const runtime = readOrcadWindowsEncodedAnswer(
await execOrcadRemote(
options,
orcadWindowsHostOpCommand(options.host, baseDir, 'slot-runtime', [slotDir])
),
ORCAD_WINDOWS_RUNTIME_MARKER
)
if (!runtime) {
throw new Error('The Windows host did not name the runtime this orcad slot needs.')
}
const staged = joinRemotePath(options.host, slotDir, `.orcad-stop-command-${randomUUID()}.json`)
let removeStaged = true
try {
await options.conn.writeFile(staged, JSON.stringify(request), {
hostPlatform: options.host,
signal: options.signal
})
return await execOrcadRemote(
options,
orcadWindowsNodeCommandLine(runtime, [
entry,
flag,
ORCAD_MANAGED_STOP_REQUEST_FILE_FLAG,
staged
])
)
} catch (error) {
// The command may still be reading it.
removeStaged = !isUnconfirmedSshCommandTermination(error)
throw error
} finally {
if (removeStaged) {
await execOrcadRemote(
options,
orcadWindowsHostOpCommand(options.host, baseDir, 'remove-file', [staged])
).catch(() => {})
}
}
}
16 changes: 11 additions & 5 deletions src/main/ssh/orcad-remote-build-hash.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
/** The installed slot's `orcad.js` identity, the same 16-hex prefix orcad reports in its health. */
import { shellEscape } from './ssh-connection-utils'
import { assertPosixOrcadHost } from './orcad-remote-host-support'
import { execOrcadRemote, type OrcadRemoteExecTarget } from './orcad-remote-runtime-control'
import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform'

const BUILD_HASH_MARKER = '__ORCAD_BUILD_HASH__'
import { orcadWindowsBaseDir, orcadWindowsHostOpCommand } from './orcad-remote-windows-node'
import { ORCAD_BUILD_HASH_MARKER as BUILD_HASH_MARKER } from './orcad-windows-host-script'
import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform'

export async function readRemoteOrcadBuildHash(
target: OrcadRemoteExecTarget,
Expand All @@ -25,7 +24,14 @@ export function remoteOrcadBuildHashCommand(
host: RemoteHostPlatform,
remoteInstallDir: string
): string {
assertPosixOrcadHost(host)
if (isWindowsRemoteHost(host)) {
return orcadWindowsHostOpCommand(
host,
orcadWindowsBaseDir(host, remoteInstallDir),
'build-hash',
[joinRemotePath(host, remoteInstallDir, 'orcad.js')]
)
}
const path = shellEscape(joinRemotePath(host, remoteInstallDir, 'orcad.js'))
// Why both tools: GNU/busybox ship sha256sum, macOS ships shasum; either prints the digest first.
return [
Expand Down
3 changes: 2 additions & 1 deletion src/main/ssh/orcad-remote-deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
} from './ssh-relay-versioned-install'
import { emptyOrcadActivationRecord, withActivatedVersion } from './orcad-activation-record'
import { getRemoteHostPlatform } from './ssh-remote-platform'
import { isReadinessRead } from './orcad-activation-host-test-harness'
import type { SshConnection } from './ssh-connection'
import { NODE_RUNTIME_PIN } from '../../shared/node-runtime-pin'

Expand Down Expand Up @@ -127,7 +128,7 @@ function scriptHost(script: HostScript): void {
if (text.includes('orcad.lock') && text.includes('orca-runtime.json')) {
return 'CLEAR'
}
if (text.includes('.orcad-readiness') && text.startsWith('head -c ')) {
if (text.includes('.orcad-readiness') && isReadinessRead(text)) {
if (script.readinessAtMs !== undefined && Date.now() < script.readinessAtMs) {
return ''
}
Expand Down
3 changes: 3 additions & 0 deletions src/main/ssh/orcad-remote-gc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
import type { RemoteHostPlatform } from './ssh-remote-platform'
import { gcRemoteNodeRuntimeStore } from './remote-node-runtime-store-gc'
import { readOrcadGcTransactionPins } from './orcad-gc-transaction-pins'
import { assertPosixOrcadHost } from './orcad-remote-host-support'

export type OrcadGcOptions = {
conn: SshConnection
Expand All @@ -52,6 +53,8 @@ export type OrcadGcOptions = {
}

export async function gcOldOrcadVersions(options: OrcadGcOptions): Promise<void> {
// Windows liveness answers now, but GC there (one probe per pass, not per dir) is not built yet.
assertPosixOrcadHost(options.host)
const transaction = await readOrcadGcTransactionPins(options)
if (transaction.state === 'keep-all') {
console.warn('[orcad-gc] An activation transaction is unreadable or unjournaled; skipping GC.')
Expand Down
19 changes: 14 additions & 5 deletions src/main/ssh/orcad-remote-host-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
* discovered at runtime.
*
* The install transaction is host-agnostic — it is the relay's, and the relay runs on
* Windows. The launch, liveness and stop path is not: it uses `nohup`, a redirected stdout,
* `kill -0` and `ps`. Emitting a PowerShell-shaped approximation of that would produce a
* deploy that reports success on a host where nothing is running.
* Windows. Launch, readiness, liveness, stop, build hash and record files have Windows
* branches; snapshots, the deploy and rollback drivers, preflight and managed stop do not yet,
* so the managed lifecycle still refuses Windows hosts at context resolution.
*/
import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform'

Expand All @@ -14,8 +14,8 @@ export class OrcadRemoteLaunchUnsupportedError extends Error {
constructor(hostLabel: string) {
super(
`Deploying orcad to a ${hostLabel} host is not implemented. The install transaction is ` +
'host-agnostic, but the launch and readiness path is POSIX-only: it uses nohup, a ' +
'redirected stdout and `kill -0` liveness. Use the relay for this host.'
'host-agnostic, but state snapshots, preflight, rollback and managed stop are ' +
'POSIX-only. Use the relay for this host.'
)
this.name = 'OrcadRemoteLaunchUnsupportedError'
}
Expand All @@ -27,8 +27,17 @@ export function assertPosixOrcadHost(host: RemoteHostPlatform): void {
}
}

/** Stdout of the launched candidate: exactly one `orca_server_ready` line, then nothing. */
export const ORCAD_READINESS_FILENAME = '.orcad-readiness'
/** Stderr, including the bind-exposure line and every supervision message. */
export const ORCAD_LOG_FILENAME = 'orcad.log'
// Why a cap: the readiness file is candidate-written stdout, and a runaway writer must not be read whole.
export const ORCAD_READINESS_MAX_BYTES = 256 * 1024

/** PID of the launched orcad, written into its own version dir at launch. */
export const ORCAD_PID_FILENAME = '.orcad-pid'
/** Windows' `.orcad-pid`: `{"pid":N,"creationTimeMs":M|null}`, since a PID alone is no identity there. */
export const ORCAD_WINDOWS_PROCESS_FILENAME = '.orcad-process.json'

/**
* A shell function answering whether a PID is a *running* process.
Expand Down
Loading
Loading