From 68737239b98d88c5b60995935fcadadb60bf5b43 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 16 Jul 2026 15:50:36 -0400 Subject: [PATCH] feat(ssh): add bounded Windows relay staging control Co-authored-by: Orca --- .../workflows/ssh-relay-runtime-artifacts.yml | 2 + .../ssh-relay-runtime-workflow.test.mjs | 1 + ...elease-implementation-checklist-summary.md | 9 +- ...github-release-implementation-checklist.md | 77 ++++- ...ay-runtime-windows-staging-control.test.ts | 285 ++++++++++++++++++ ...h-relay-runtime-windows-staging-control.ts | 213 +++++++++++++ 6 files changed, 581 insertions(+), 6 deletions(-) create mode 100644 src/main/ssh/ssh-relay-runtime-windows-staging-control.test.ts create mode 100644 src/main/ssh/ssh-relay-runtime-windows-staging-control.ts diff --git a/.github/workflows/ssh-relay-runtime-artifacts.yml b/.github/workflows/ssh-relay-runtime-artifacts.yml index 060ed032bc6..d90f0de0a07 100644 --- a/.github/workflows/ssh-relay-runtime-artifacts.yml +++ b/.github/workflows/ssh-relay-runtime-artifacts.yml @@ -289,6 +289,7 @@ jobs: src/main/ssh/ssh-relay-runtime-posix-control-command.test.ts \ src/main/ssh/ssh-relay-runtime-posix-file-destination.test.ts \ src/main/ssh/ssh-relay-runtime-windows-file-destination.test.ts \ + src/main/ssh/ssh-relay-runtime-windows-staging-control.test.ts \ src/main/ssh/ssh-relay-runtime-posix-tree-transfer.test.ts \ src/main/ssh/ssh-relay-runtime-system-ssh-file-channel.test.ts \ src/main/ssh/ssh-relay-runtime-sftp-file-destination.test.ts \ @@ -1029,6 +1030,7 @@ jobs: src/main/ssh/ssh-relay-runtime-posix-control-command.test.ts ` src/main/ssh/ssh-relay-runtime-posix-file-destination.test.ts ` src/main/ssh/ssh-relay-runtime-windows-file-destination.test.ts ` + src/main/ssh/ssh-relay-runtime-windows-staging-control.test.ts ` src/main/ssh/ssh-relay-runtime-posix-tree-transfer.test.ts ` src/main/ssh/ssh-relay-runtime-system-ssh-file-channel.test.ts ` src/main/ssh/ssh-relay-runtime-sftp-file-destination.test.ts ` diff --git a/config/scripts/ssh-relay-runtime-workflow.test.mjs b/config/scripts/ssh-relay-runtime-workflow.test.mjs index a6fe6d77643..a5ee8115b4e 100644 --- a/config/scripts/ssh-relay-runtime-workflow.test.mjs +++ b/config/scripts/ssh-relay-runtime-workflow.test.mjs @@ -203,6 +203,7 @@ describe('SSH relay runtime artifact workflow', () => { 'ssh-relay-runtime-posix-control-command.test.ts', 'ssh-relay-runtime-posix-file-destination.test.ts', 'ssh-relay-runtime-windows-file-destination.test.ts', + 'ssh-relay-runtime-windows-staging-control.test.ts', 'ssh-relay-runtime-posix-tree-transfer.test.ts', 'ssh-relay-runtime-system-ssh-file-channel.test.ts', 'ssh-relay-runtime-sftp-file-destination.test.ts', diff --git a/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist-summary.md b/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist-summary.md index fc6fbf7b87c..2f9aa143edd 100644 --- a/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist-summary.md +++ b/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist-summary.md @@ -10,12 +10,19 @@ A checked box means the work has evidence in the detailed ledger. Design approva complete a box. Active checkpoint: **Milestone 6 / Work Package 5 bounded runtime transfer — disconnected Windows -system-SSH staging-control contract audit, 2026-07-15, Codex implementation owner.** +system-SSH staging control is locally green; native Windows CI is next, 2026-07-15, Codex +implementation owner.** The next slice is limited to fixed-command, bounded binary PowerShell 5.1 requests for exclusive staging-root creation, declared-directory creation, and owned-root cleanup. It must preserve a pre-existing root, bound and strictly decode paths, join cancellation/settlement, and remain disconnected. File/tree composition, live OpenSSH, product callers, legacy upload/fallback/default behavior, tuple enablement, publication, and SignPath remain out of scope. +`E-M6-WINDOWS-SYSTEM-SSH-CONTROL-LOCAL-RED-001` proves the native workflow oracle passes 9/9 and +the purpose suite fails solely because the audited production staging-control module is absent. +`E-M6-WINDOWS-SYSTEM-SSH-CONTROL-LOCAL-001` records 16/17 purpose cases locally with the real +PowerShell case honestly skipped, 207 focused cases, 686 broad relay cases, 282/282 release +contracts, typecheck, full lint, formatting, diff, protected-resolver, and no-product-consumer +gates. Native Windows x64/arm64 `powershell.exe` proof remains required. `E-M6-WINDOWS-SYSTEM-SSH-FILE-LOCAL-RED-001` proves the workflow oracle passes 9/9 and the new purpose suite fails solely because the audited production destination is absent. `E-M6-WINDOWS-SYSTEM-SSH-FILE-LOCAL-001` records 14/15 Windows purpose cases locally with the real diff --git a/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist.md b/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist.md index 0710dc08c23..ed966eee804 100644 --- a/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist.md +++ b/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist.md @@ -8,8 +8,8 @@ work; keep exact commands, runner identities, hashes, metrics, and residual gaps Date created: 2026-07-14
Last updated: 2026-07-15
-Current phase: Milestone 6 / Work Package 5 bounded runtime transfer — **In progress — 2026-07-15, Codex implementation owner: disconnected Windows system-SSH staging-control contract audit**. Keep every capability disconnected from every production/default SSH path. Real Apple/SignPath rehearsals remain an explicit late gate and are not on this package's critical path.
-Session checkpoint: **WINDOWS PER-FILE NATIVE PROOF CLOSED / STAGING CONTROL AUDIT ACTIVE — 2026-07-15, Codex implementation owner** — `E-M6-WINDOWS-SYSTEM-SSH-FILE-CI-001` proves the real PowerShell receiver on native Windows x64/arm64 and closes the adjacent watcher flake with a same-SHA rerun; only the declared hosted Windows arm64 build-26200 versus required-26100 floor rejection remains. Audit the next disconnected fixed-command staging-root/directory/cleanup contract before writing RED tests. Do not compose file streaming into a tree, connect a product caller, change legacy upload/fallback/default behavior, enable a tuple, publish an artifact, or perform SignPath work in this package. Merging to `main` remains prohibited, SignPath is deferred, and legacy remains the production default.
+Current phase: Milestone 6 / Work Package 5 bounded runtime transfer — **In progress — 2026-07-15, Codex implementation owner: disconnected Windows system-SSH staging-control native proof**. Keep every capability disconnected from every production/default SSH path. Real Apple/SignPath rehearsals remain an explicit late gate and are not on this package's critical path.
+Session checkpoint: **WINDOWS STAGING CONTROL LOCALLY GREEN / NATIVE WINDOWS CI NEXT — 2026-07-15, Codex implementation owner** — `E-M6-WINDOWS-SYSTEM-SSH-CONTROL-LOCAL-001` proves the fixed-command bounded request, strict rooted-path/descendant validation, exclusive root/directory creation, parent-first enforcement, owned-root removal primitive, 30-second ceiling, joined command-channel lifecycle, prior transfer regressions, and production isolation locally. The real PowerShell 5.1 case is skipped on macOS and earns no native claim; commit and push the exact package, then require native Windows x64/arm64 proof. Do not compose file streaming into a tree, connect a product caller, change legacy upload/fallback/default behavior, enable a tuple, publish an artifact, or perform SignPath work in this package. Merging to `main` remains prohibited, SignPath is deferred, and legacy remains the production default.
Primary design: [SSH relay GitHub Release plan](./2026-07-14-ssh-relay-github-release-plan.html)
Motivating issues: [#8450](https://github.com/stablyai/orca/issues/8450), [#1693](https://github.com/stablyai/orca/issues/1693) @@ -18286,6 +18286,73 @@ src/main/ssh/ssh-remote-node-resolution.ts src/main/ssh/ssh-remote-node-resoluti settings/fallback/tuple/publication/default behavior, or SignPath is included. Legacy remains the sole production/default path and every tuple stays disabled. +### E-M6-WINDOWS-SYSTEM-SSH-CONTROL-LOCAL-RED-001 — audited staging control is absent + +- Date/owner/base/runner: 2026-07-15, Codex implementation owner; purpose test, native-workflow + wiring, and checklist-only diff atop exact base + `d24f877d96f07d691508cc686c0791a86d3dd308` on Apple arm64, macOS 26.2 build 25C56, Node + 26.0.0, pnpm 10.24.0. +- Command: `pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 +src/main/ssh/ssh-relay-runtime-windows-staging-control.test.ts +config/scripts/ssh-relay-runtime-workflow.test.mjs`. +- Result: expected failure in 1.39 seconds. The workflow oracle passes 9/9 and proves the new purpose + suite occurs exactly once in both native artifact command families. The purpose suite fails before + collection solely because `./ssh-relay-runtime-windows-staging-control` does not exist; zero + production modules are added or changed at this boundary. +- Residual boundary: no PowerShell staging behavior or Windows runner claim follows from a missing + module. Purpose, native Windows, prior Windows/POSIX regression, broad relay/release, static, + isolation, and no-product-consumer gates remain required. + +### E-M6-WINDOWS-SYSTEM-SSH-CONTROL-LOCAL-001 — bounded staging control is locally green + +- Date/owner/base/runner: 2026-07-15, Codex implementation owner; uncommitted implementation, + purpose-test, native-workflow, and checklist package atop exact base + `d24f877d96f07d691508cc686c0791a86d3dd308` on Apple arm64, macOS 26.2 build 25C56, Node + 26.0.0, pnpm 10.24.0. The local Node version is newer than the repository's Node 24 contract and + the real Windows PowerShell case is skipped off Windows, so exact-head native proof remains + mandatory. +- Implementation: add one disconnected fixed encoded PowerShell receiver with an at-most + 65,556-byte request, two independently bounded 32-KiB strict-UTF-8 path fields, operation byte and + zeroed reserved bytes. Client validation rejects non-rooted, reserved, traversal, non-descendant, + and oversized paths before channel open. The receiver rejects malformed framing/extra bytes, + requires each direct parent to exist, uses fail-on-existing `New-Item` for root/directories, and + removes only a root whose ownership is tracked by a later tree caller. Each operation has a + 30-second ceiling and reuses the proven 250 ms graceful/2-second forced channel settlement. +- Focused command: `/usr/bin/time -l pnpm exec vitest run --config config/vitest.config.ts +--maxWorkers=1` with the staging-control, Windows/POSIX file/control/tree, system-channel, + source-tree/scan/stream, system-fallback, connection, and workflow-oracle suites. Result: 11 files, + 207 passed and three declared platform skips out of 210; 8.85 seconds real, 184,500,224-byte + maximum RSS, 96,818,624-byte peak footprint, zero swaps and block I/O. The staging-control suite + contributes 16 passed plus one macOS-skipped real-PowerShell case; the workflow oracle passes 9/9. +- Broad relay command: `/usr/bin/time -l pnpm exec vitest run --config config/vitest.config.ts +--maxWorkers=1 src/main/ssh/ssh-relay-*.test.ts`. Result: 52 files passed, five files skipped; 686 + passed and nine declared skips out of 695; 113.77 seconds real, 257,605,632-byte maximum RSS, + 97,572,312-byte peak footprint, zero swaps and block I/O. This and the release/lint commands ran + concurrently, so their wall times are recorded but are not standalone performance baselines. +- Release-contract command: `/usr/bin/time -l pnpm exec vitest run --config +config/vitest.config.ts --maxWorkers=1 config/scripts/ssh-relay-runtime-*.test.mjs`. Result: 50 files + and 282/282 cases pass; 81.81 seconds real, 189,317,120-byte maximum RSS, 96,146,808-byte peak + footprint, zero swaps and block I/O. Two prior quoted-wildcard invocations found no literal file; + they were command errors and were immediately replaced by these exact checklist commands. +- Static evidence: `pnpm typecheck` passes. `/usr/bin/time -l pnpm lint` passes in 78.78 seconds real + with 2,106,277,888-byte maximum RSS and 96,081,152-byte peak footprint; its 26 warnings are + pre-existing outside this diff. Full lint proves switch exhaustiveness, 41 reliability gates, the + 355-entry max-lines ratchet with no new bypass, bundled guides, and localization checks. Targeted + `oxlint` and `oxfmt --check` pass. +- Isolation evidence: `git diff --check` passes; `git diff --exit-code +d24f877d96f07d691508cc686c0791a86d3dd308 -- src/main/ssh/ssh-remote-node-resolution.ts +src/main/ssh/ssh-remote-node-resolution.test.ts` is empty. A non-test reference search finds the + staging-control export only in its owning production module. The intended diff is limited to the + disconnected staging-control module/purpose test, native workflow/oracle wiring, and both + checklist documents. No existing file receiver, POSIX/SFTP transfer, legacy uploader, product + importer, settings, Beta, fallback, tuple, publication, default-path, or signing behavior changes. +- Residual boundary: local execution does not prove Windows PowerShell 5.1 parsing, exclusive + directory semantics, or cleanup on native Windows. Exact-head native Windows x64/arm64 purpose + proof and all-six adjacent native regression cells remain required. File/tree composition, live + Windows OpenSSH, full-size runtime transfer, long paths, endpoint-protection faults, capability + classification/cache, bundled-Node verification/install/publish/launch, product/Beta/fallback/ + default behavior, tuple enablement, publication, Apple signing, and SignPath remain absent. + ## Accepted Gaps No product gap is accepted merely because it appears in this list. Each entry requires explicit @@ -18344,9 +18411,9 @@ The project is not complete until every applicable item below is checked with ev ## Next Required Action -Write and run the purpose-named RED suite and native-workflow oracle for -`E-M6-WINDOWS-SYSTEM-SSH-CONTROL-AUDIT-001`, proving failure solely because the audited production -module is absent. Do not check the broad Milestone 6 Windows system-SSH item yet. Do not compose file +Commit and push the exact `E-M6-WINDOWS-SYSTEM-SSH-CONTROL-LOCAL-001` package, then require the real +PowerShell 5.1 process oracle on native Windows x64/arm64 plus all-six native and adjacent repository +regression cells. Do not check the broad Milestone 6 Windows system-SSH item yet. Do not compose file streaming into a remote-tree owner or add an Electron/startup/product importer, per-target mode wiring, fallback, tuple enablement, release publication, or default behavior. Keep Node upstream `.tar.xz` inputs, Windows ZIP, `ORCA_RELAY_PATH`, existing desktop required-assets diff --git a/src/main/ssh/ssh-relay-runtime-windows-staging-control.test.ts b/src/main/ssh/ssh-relay-runtime-windows-staging-control.test.ts new file mode 100644 index 00000000000..1338f22b712 --- /dev/null +++ b/src/main/ssh/ssh-relay-runtime-windows-staging-control.test.ts @@ -0,0 +1,285 @@ +import { spawn } from 'node:child_process' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { SshRelayRuntimeCommandFileChannel } from './ssh-relay-runtime-command-file-destination' +import { + runSshRelayRuntimeWindowsStagingControl, + SSH_RELAY_RUNTIME_WINDOWS_STAGING_CONTROL_LIMITS +} from './ssh-relay-runtime-windows-staging-control' + +type Callback = (error?: Error) => void + +function createChannel() { + let resolveSettled: () => void = () => {} + let rejectSettled: (error: Error) => void = () => {} + const settled = new Promise((resolve, reject) => { + resolveSettled = resolve + rejectSettled = reject + }) + const channel = { + write: vi.fn((_chunk: Buffer, callback: Callback) => callback()), + end: vi.fn(), + settled, + requestClose: vi.fn(), + forceClose: vi.fn() + } + return { channel, resolve: resolveSettled, reject: rejectSettled } +} + +function decodeRequest(frame: Buffer) { + const rootLength = frame.readUInt32LE(12) + const pathLength = frame.readUInt32LE(16) + const rootStart = 20 + const pathStart = rootStart + rootLength + return { + magic: frame.subarray(0, 8).toString('ascii'), + operation: frame.readUInt8(8), + reserved: frame.subarray(9, 12), + remoteRoot: frame.subarray(rootStart, pathStart).toString('utf8'), + remotePath: frame.subarray(pathStart, pathStart + pathLength).toString('utf8') + } +} + +function decodePowerShellCommand(command: string): string { + const encoded = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)$/u)?.[1] + if (!encoded) { + throw new Error('PowerShell staging control command is not encoded') + } + return Buffer.from(encoded, 'base64').toString('utf16le') +} + +function runControl( + channel: SshRelayRuntimeCommandFileChannel, + overrides: Partial[0]> = {} +) { + return runSshRelayRuntimeWindowsStagingControl({ + operation: 'create-directory', + remoteRoot: 'C:/Users/测试/.orca-remote/stage', + remotePath: 'C:/Users/测试/.orca-remote/stage/bin/native', + signal: new AbortController().signal, + openChannel: vi.fn(async () => channel), + ...overrides + }) +} + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('SSH relay runtime Windows system-SSH staging control', () => { + it.each([ + ['create-root', 1, ''], + ['create-directory', 2, 'C:/Users/测试/.orca-remote/stage/bin/native'], + ['remove-root', 3, ''] + ] as const)( + 'sends one fixed bounded binary %s request', + async (operation, opcode, remotePath) => { + const fixture = createChannel() + const openChannel = vi.fn(async (_command: string, _signal: AbortSignal) => fixture.channel) + const running = runControl(fixture.channel, { + operation, + remotePath: remotePath || undefined, + openChannel + }) + + await vi.waitFor(() => expect(fixture.channel.end).toHaveBeenCalledOnce()) + expect(openChannel).toHaveBeenCalledOnce() + const command = openChannel.mock.calls[0]?.[0] ?? '' + expect(command).toMatch(/^powershell\.exe .* -EncodedCommand [A-Za-z0-9+/=]+$/u) + expect(command).not.toContain('测试') + const script = decodePowerShellCommand(command) + expect(script).toContain('[Console]::OpenStandardInput()') + expect(script).toContain('New-Item -ItemType Directory -Path') + expect(script).toContain('[IO.Path]::GetDirectoryName') + expect(script).toContain('[IO.Directory]::Delete') + expect(script).toContain('$inputStream.ReadByte() -ne -1') + for (const forbidden of ['ReadToEnd', 'FromBase64String', 'ConvertFrom-Json']) { + expect(script).not.toContain(forbidden) + } + + const frame = fixture.channel.write.mock.calls[0]?.[0] + expect(Buffer.isBuffer(frame)).toBe(true) + expect(frame.length).toBeLessThanOrEqual( + SSH_RELAY_RUNTIME_WINDOWS_STAGING_CONTROL_LIMITS.maximumRequestBytes + ) + expect(decodeRequest(frame)).toEqual({ + magic: 'ORCACTL1', + operation: opcode, + reserved: Buffer.alloc(3), + remoteRoot: 'C:/Users/测试/.orca-remote/stage', + remotePath + }) + fixture.resolve() + await expect(running).resolves.toBeUndefined() + } + ) + + it.each([ + ['create-root', 'relative/stage', undefined], + ['create-root', 'C:/', undefined], + ['create-root', 'C:/owned/../stage', undefined], + ['create-root', 'C:/owned/NUL', undefined], + [ + 'create-root', + `C:/${'a'.repeat(SSH_RELAY_RUNTIME_WINDOWS_STAGING_CONTROL_LIMITS.maximumPathBytes)}`, + undefined + ], + ['create-root', 'C:/owned/stage', 'C:/owned/stage/extra'], + ['create-directory', 'C:/owned/stage', undefined], + ['create-directory', 'C:/owned/stage', 'C:/outside'], + ['create-directory', 'C:/owned/stage', 'C:/owned/stage'], + ['create-directory', 'C:/owned/stage', 'C:/owned/stage/report.txt:stream'] + ] as const)( + 'rejects hostile %s root %j path %j before channel open', + async (operation, remoteRoot, remotePath) => { + const fixture = createChannel() + const openChannel = vi.fn(async () => fixture.channel) + await expect( + runControl(fixture.channel, { operation, remoteRoot, remotePath, openChannel }) + ).rejects.toThrow(/invalid/i) + expect(openChannel).not.toHaveBeenCalled() + } + ) + + it('settles retained-request cancellation before rejecting', async () => { + const fixture = createChannel() + const controller = new AbortController() + const reason = new Error('cancelled retained Windows control request') + fixture.channel.write.mockImplementationOnce(() => {}) + fixture.channel.requestClose.mockImplementation(() => fixture.resolve()) + const running = runControl(fixture.channel, { signal: controller.signal }) + + await vi.waitFor(() => expect(fixture.channel.write).toHaveBeenCalledOnce()) + controller.abort(reason) + await expect(running).rejects.toBe(reason) + expect(fixture.channel.requestClose).toHaveBeenCalledOnce() + expect(fixture.channel.forceClose).not.toHaveBeenCalled() + }) + + it('propagates remote failure only after request EOF and settlement', async () => { + const fixture = createChannel() + const running = runControl(fixture.channel) + await vi.waitFor(() => expect(fixture.channel.end).toHaveBeenCalledOnce()) + const error = new Error('PowerShell staging control failed') + fixture.reject(error) + await expect(running).rejects.toBe(error) + }) + + it('turns the command ceiling into bounded cancellation and settlement', async () => { + vi.useFakeTimers() + const fixture = createChannel() + fixture.channel.requestClose.mockImplementation(() => fixture.resolve()) + const running = runControl(fixture.channel) + const rejection = expect(running).rejects.toThrow(/timed out/i) + + await vi.advanceTimersByTimeAsync( + SSH_RELAY_RUNTIME_WINDOWS_STAGING_CONTROL_LIMITS.commandTimeoutMs + ) + await rejection + expect(fixture.channel.requestClose).toHaveBeenCalledOnce() + expect(fixture.channel.forceClose).not.toHaveBeenCalled() + }) + + it.skipIf(process.platform !== 'win32')( + 'proves exclusive root ownership, Unicode directories, and owned cleanup in PowerShell 5.1', + async () => { + const parent = await mkdtemp(path.join(tmpdir(), 'orca-relay-win-control-')) + const remoteRoot = path.join(parent, '测试 stage') + const remoteParent = path.join(remoteRoot, 'bin') + const remotePath = path.join(remoteParent, 'native') + try { + await runSshRelayRuntimeWindowsStagingControl({ + operation: 'create-root', + remoteRoot, + signal: new AbortController().signal, + openChannel: openPowerShellChannel + }) + const marker = path.join(remoteRoot, 'original.txt') + await writeFile(marker, 'preserve') + await expect( + runSshRelayRuntimeWindowsStagingControl({ + operation: 'create-root', + remoteRoot, + signal: new AbortController().signal, + openChannel: openPowerShellChannel + }) + ).rejects.toThrow(/PowerShell staging control failed/i) + expect(await readFile(marker, 'utf8')).toBe('preserve') + + await expect( + runSshRelayRuntimeWindowsStagingControl({ + operation: 'create-directory', + remoteRoot, + remotePath, + signal: new AbortController().signal, + openChannel: openPowerShellChannel + }) + ).rejects.toThrow(/PowerShell staging control failed/i) + await runSshRelayRuntimeWindowsStagingControl({ + operation: 'create-directory', + remoteRoot, + remotePath: remoteParent, + signal: new AbortController().signal, + openChannel: openPowerShellChannel + }) + await runSshRelayRuntimeWindowsStagingControl({ + operation: 'create-directory', + remoteRoot, + remotePath, + signal: new AbortController().signal, + openChannel: openPowerShellChannel + }) + await runSshRelayRuntimeWindowsStagingControl({ + operation: 'remove-root', + remoteRoot, + signal: new AbortController().signal, + openChannel: openPowerShellChannel + }) + await expect(readFile(marker)).rejects.toMatchObject({ code: 'ENOENT' }) + } finally { + await rm(parent, { recursive: true, force: true }) + } + }, + 30_000 + ) +}) + +async function openPowerShellChannel(command: string): Promise { + const encoded = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)$/u)?.[1] + if (!encoded) { + throw new Error('PowerShell staging control command is not encoded') + } + const child = spawn( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded], + { stdio: ['pipe', 'ignore', 'pipe'], windowsHide: true } + ) + let diagnostic = '' + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => { + diagnostic = `${diagnostic}${chunk}`.slice(-4_096) + }) + const settled = new Promise((resolve, reject) => { + child.once('error', reject) + child.once('close', (code, signal) => { + if (code === 0) { + resolve() + } else { + reject(new Error(`PowerShell staging control failed (${code ?? signal}): ${diagnostic}`)) + } + }) + }) + return { + write: (chunk: Buffer, callback: Callback) => { + child.stdin.write(chunk, (error) => callback(error ?? undefined)) + }, + end: () => child.stdin.end(), + settled, + requestClose: () => child.kill(), + forceClose: () => child.kill('SIGKILL') + } +} diff --git a/src/main/ssh/ssh-relay-runtime-windows-staging-control.ts b/src/main/ssh/ssh-relay-runtime-windows-staging-control.ts new file mode 100644 index 00000000000..0de0db3c57c --- /dev/null +++ b/src/main/ssh/ssh-relay-runtime-windows-staging-control.ts @@ -0,0 +1,213 @@ +import { assertSafeRemotePathSegment, normalizeWindowsRemotePath } from './ssh-remote-platform' +import { powerShellCommand } from './ssh-remote-powershell' +import { + openSshRelayRuntimeCommandFileDestination, + type SshRelayRuntimeCommandFileChannel +} from './ssh-relay-runtime-command-file-destination' + +export type SshRelayRuntimeWindowsStagingOperation = + | 'create-root' + | 'create-directory' + | 'remove-root' + +export type RunSshRelayRuntimeWindowsStagingControlOptions = Readonly<{ + operation: SshRelayRuntimeWindowsStagingOperation + remoteRoot: string + remotePath?: string + signal: AbortSignal + openChannel: (command: string, signal: AbortSignal) => Promise +}> + +const REQUEST_MAGIC = Buffer.from('ORCACTL1', 'ascii') +const FIXED_REQUEST_BYTES = 20 +const MAXIMUM_PATH_BYTES = 32 * 1024 +const COMMAND_TIMEOUT_MS = 30_000 + +const OPERATION_CODES: Record = { + 'create-root': 1, + 'create-directory': 2, + 'remove-root': 3 +} + +export const SSH_RELAY_RUNTIME_WINDOWS_STAGING_CONTROL_LIMITS = Object.freeze({ + commandTimeoutMs: COMMAND_TIMEOUT_MS, + maximumPathBytes: MAXIMUM_PATH_BYTES, + maximumRequestBytes: FIXED_REQUEST_BYTES + MAXIMUM_PATH_BYTES * 2 +}) + +const RECEIVER_SCRIPT = ` +$ErrorActionPreference = 'Stop' +function Read-Exact([System.IO.Stream]$Stream, [byte[]]$Buffer, [int]$Count) { + $offset = 0 + while ($offset -lt $Count) { + $read = $Stream.Read($Buffer, $offset, $Count - $offset) + if ($read -eq 0) { throw 'SSH relay runtime Windows staging control early EOF' } + $offset += $read + } +} +$inputStream = [Console]::OpenStandardInput() +try { + [byte[]]$header = New-Object byte[] ${FIXED_REQUEST_BYTES} + Read-Exact $inputStream $header ${FIXED_REQUEST_BYTES} + if ([Text.Encoding]::ASCII.GetString($header, 0, 8) -ne 'ORCACTL1') { throw 'bad header' } + [byte]$operation = $header[8] + if ($header[9] -ne 0 -or $header[10] -ne 0 -or $header[11] -ne 0) { throw 'bad reserved bytes' } + [uint32]$rootLength = [BitConverter]::ToUInt32($header, 12) + [uint32]$pathLength = [BitConverter]::ToUInt32($header, 16) + if ($rootLength -eq 0 -or $rootLength -gt ${MAXIMUM_PATH_BYTES}) { throw 'bad root length' } + if ($pathLength -gt ${MAXIMUM_PATH_BYTES}) { throw 'bad path length' } + if (($operation -eq 2 -and $pathLength -eq 0) -or ($operation -ne 2 -and $pathLength -ne 0)) { throw 'bad operation path' } + if ($operation -lt 1 -or $operation -gt 3) { throw 'bad operation' } + [byte[]]$rootBytes = New-Object byte[] ([int]$rootLength) + [byte[]]$pathBytes = New-Object byte[] ([int]$pathLength) + Read-Exact $inputStream $rootBytes ([int]$rootLength) + Read-Exact $inputStream $pathBytes ([int]$pathLength) + if ($inputStream.ReadByte() -ne -1) { throw 'extra byte' } + $utf8 = New-Object Text.UTF8Encoding($false, $true) + $root = $utf8.GetString($rootBytes) + $path = if ($pathLength -eq 0) { '' } else { $utf8.GetString($pathBytes) } + if (-not [IO.Path]::IsPathRooted($root) -or $root.IndexOf([char]0) -ge 0) { throw 'bad root' } + if ($operation -eq 2) { + if (-not [IO.Path]::IsPathRooted($path) -or $path.IndexOf([char]0) -ge 0 -or -not $path.StartsWith($root + '/', [StringComparison]::Ordinal)) { throw 'bad child path' } + } + switch ($operation) { + 1 { + $parent = [IO.Path]::GetDirectoryName($root) + if ([string]::IsNullOrEmpty($parent) -or -not [IO.Directory]::Exists($parent)) { throw 'missing root parent' } + New-Item -ItemType Directory -Path $root -ErrorAction Stop | Out-Null + } + 2 { + $parent = [IO.Path]::GetDirectoryName($path) + if ([string]::IsNullOrEmpty($parent) -or -not [IO.Directory]::Exists($parent)) { throw 'missing directory parent' } + New-Item -ItemType Directory -Path $path -ErrorAction Stop | Out-Null + } + 3 { + if ([IO.File]::Exists($root)) { throw 'staging root became a file' } + if ([IO.Directory]::Exists($root)) { [IO.Directory]::Delete($root, $true) } + } + } +} catch { + throw 'SSH relay runtime Windows staging control failed' +} +`.trim() + +const RECEIVER_COMMAND = powerShellCommand(RECEIVER_SCRIPT) + +function normalizeAbsolutePath(value: string): string { + if ( + typeof value !== 'string' || + value === '' || + value.includes('\0') || + value.includes('\r') || + value.includes('\n') + ) { + throw new Error('SSH relay runtime Windows staging control path is invalid') + } + const normalized = normalizeWindowsRemotePath(value) + const driveRooted = /^[A-Za-z]:\//u.test(normalized) + const uncRooted = /^\/\/[^/]+\/[^/]+\//u.test(normalized) + if ((!driveRooted && !uncRooted) || normalized.endsWith('/')) { + throw new Error('SSH relay runtime Windows staging control path is invalid') + } + const segments = driveRooted ? normalized.slice(3).split('/') : normalized.slice(2).split('/') + try { + for (const segment of segments) { + assertSafeRemotePathSegment(segment, 'windows') + } + } catch { + throw new Error('SSH relay runtime Windows staging control path is invalid') + } + if (Buffer.byteLength(normalized, 'utf8') > MAXIMUM_PATH_BYTES) { + throw new Error('SSH relay runtime Windows staging control path is invalid') + } + return normalized +} + +function validateOptions(options: RunSshRelayRuntimeWindowsStagingControlOptions): { + operation: SshRelayRuntimeWindowsStagingOperation + remoteRoot: string + remotePath: string +} { + if ( + !options || + typeof options.operation !== 'string' || + !Object.hasOwn(OPERATION_CODES, options.operation) || + !options.signal || + typeof options.openChannel !== 'function' + ) { + throw new Error('SSH relay runtime Windows staging control input is invalid') + } + const remoteRoot = normalizeAbsolutePath(options.remoteRoot) + if (options.operation !== 'create-directory') { + if (options.remotePath !== undefined) { + throw new Error('SSH relay runtime Windows staging control input is invalid') + } + return { operation: options.operation, remoteRoot, remotePath: '' } + } + const remotePath = normalizeAbsolutePath(options.remotePath as string) + // Why: control requests may create only a strict descendant of the root owned by the tree caller. + if (!remotePath.startsWith(`${remoteRoot}/`)) { + throw new Error('SSH relay runtime Windows staging control path is invalid') + } + return { operation: options.operation, remoteRoot, remotePath } +} + +function buildRequest( + operation: SshRelayRuntimeWindowsStagingOperation, + remoteRoot: string, + remotePath: string +): Buffer { + const rootBytes = Buffer.from(remoteRoot, 'utf8') + const pathBytes = Buffer.from(remotePath, 'utf8') + const request = Buffer.alloc(FIXED_REQUEST_BYTES + rootBytes.length + pathBytes.length) + REQUEST_MAGIC.copy(request, 0) + request.writeUInt8(OPERATION_CODES[operation], 8) + request.writeUInt32LE(rootBytes.length, 12) + request.writeUInt32LE(pathBytes.length, 16) + rootBytes.copy(request, FIXED_REQUEST_BYTES) + pathBytes.copy(request, FIXED_REQUEST_BYTES + rootBytes.length) + return request +} + +export async function runSshRelayRuntimeWindowsStagingControl( + options: RunSshRelayRuntimeWindowsStagingControlOptions +): Promise { + const { operation, remoteRoot, remotePath } = validateOptions(options) + const request = buildRequest(operation, remoteRoot, remotePath) + const commandController = new AbortController() + const onCallerAbort = (): void => commandController.abort(options.signal.reason) + options.signal.addEventListener('abort', onCallerAbort, { once: true }) + if (options.signal.aborted) { + onCallerAbort() + } + const timeout = setTimeout( + () => commandController.abort(new Error('SSH relay runtime Windows staging control timed out')), + COMMAND_TIMEOUT_MS + ) + try { + const destination = await openSshRelayRuntimeCommandFileDestination({ + command: RECEIVER_COMMAND, + fileKind: 'Windows', + signal: commandController.signal, + openChannel: options.openChannel + }) + try { + await destination.write(request) + await destination.close() + } catch (error) { + try { + // Why: a retained request buffer must settle before this bounded control operation returns. + await destination.abort(error) + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + 'SSH relay runtime Windows staging control cleanup failed' + ) + } + throw error + } + } finally { + clearTimeout(timeout) + options.signal.removeEventListener('abort', onCallerAbort) + } +}