mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
feat(ssh): add bounded Windows relay staging control
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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 `
|
||||
|
||||
@@ -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',
|
||||
|
||||
+8
-1
@@ -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
|
||||
|
||||
+72
-5
@@ -8,8 +8,8 @@ work; keep exact commands, runner identities, hashes, metrics, and residual gaps
|
||||
|
||||
Date created: 2026-07-14<br>
|
||||
Last updated: 2026-07-15<br>
|
||||
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.<br>
|
||||
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.<br>
|
||||
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.<br>
|
||||
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.<br>
|
||||
Primary design: [SSH relay GitHub Release plan](./2026-07-14-ssh-relay-github-release-plan.html)<br>
|
||||
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
|
||||
|
||||
@@ -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<void>((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<Parameters<typeof runSshRelayRuntimeWindowsStagingControl>[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<SshRelayRuntimeCommandFileChannel> {
|
||||
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<void>((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')
|
||||
}
|
||||
}
|
||||
@@ -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<SshRelayRuntimeCommandFileChannel>
|
||||
}>
|
||||
|
||||
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<SshRelayRuntimeWindowsStagingOperation, number> = {
|
||||
'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<void> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user