fix(release): admit PTY consumer in SSH watcher isolation gate (#12754)
#12746 only delivers pty.data after an authenticated openClient grant. Update the macOS release harness to use a credentialed --connect client and open a legacy session-owner so the gate still exercises watcher isolation without timing out on the initial PTY echo.
This commit is contained in:
parent
9fb4dbe8eb
commit
d72daf8153
|
|
@ -1,5 +1,6 @@
|
|||
import { build } from 'esbuild'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { createRequire } from 'node:module'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
|
|
@ -9,6 +10,12 @@ import { dirname, join, resolve } from 'node:path'
|
|||
const WAIT_TIMEOUT_MS = 20_000
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
// Why: endpoint credentials are 32–256 base64url chars; openClient requires an authenticated
|
||||
// transport, and #12746 admits pty.data only after a consumer grant.
|
||||
function writeEndpointCredential(path) {
|
||||
return writeFile(path, randomBytes(32).toString('base64url'), 'utf8')
|
||||
}
|
||||
|
||||
function withTimeout(promise, label, stderr) {
|
||||
return new Promise((resolvePromise, rejectPromise) => {
|
||||
const timer = setTimeout(() => {
|
||||
|
|
@ -68,14 +75,61 @@ async function loadProtocol(bundleDir) {
|
|||
return require(outfile)
|
||||
}
|
||||
|
||||
function attachProcessStreams(proc) {
|
||||
let stderr = ''
|
||||
proc.stderr.on('data', (chunk) => {
|
||||
stderr = `${stderr}${String(chunk)}`.slice(-8_000)
|
||||
})
|
||||
return {
|
||||
stderr: () => stderr
|
||||
}
|
||||
}
|
||||
|
||||
function waitForStdoutSentinel(proc, protocol, stderr) {
|
||||
let stdoutBuffer = Buffer.alloc(0)
|
||||
return withTimeout(
|
||||
new Promise((resolvePromise, rejectPromise) => {
|
||||
let settled = false
|
||||
const onData = (chunk) => {
|
||||
stdoutBuffer = Buffer.concat([stdoutBuffer, chunk])
|
||||
const sentinel = Buffer.from(protocol.RELAY_SENTINEL)
|
||||
const index = stdoutBuffer.indexOf(sentinel)
|
||||
if (index < 0) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
proc.stdout.off('data', onData)
|
||||
proc.off('exit', onExit)
|
||||
resolvePromise(stdoutBuffer.subarray(index + sentinel.length))
|
||||
}
|
||||
const onExit = (code, signal) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
proc.stdout.off('data', onData)
|
||||
rejectPromise(
|
||||
new Error(
|
||||
`process exited before sentinel (code=${code}, signal=${signal})\n${stderr()}`
|
||||
)
|
||||
)
|
||||
}
|
||||
proc.stdout.on('data', onData)
|
||||
proc.once('exit', onExit)
|
||||
}),
|
||||
'relay sentinel',
|
||||
stderr
|
||||
)
|
||||
}
|
||||
|
||||
function createRelayClient(entryPath, args, env, protocol) {
|
||||
const proc = spawn(process.execPath, [entryPath, ...args], {
|
||||
cwd: dirname(entryPath),
|
||||
env,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
const streams = attachProcessStreams(proc)
|
||||
const messages = []
|
||||
let stderr = ''
|
||||
let nextSequence = 1
|
||||
let stdoutBuffer = Buffer.alloc(0)
|
||||
let ready = false
|
||||
|
|
@ -88,9 +142,6 @@ function createRelayClient(entryPath, args, env, protocol) {
|
|||
messages.push(protocol.parseJsonRpcMessage(frame.payload))
|
||||
}
|
||||
})
|
||||
proc.stderr.on('data', (chunk) => {
|
||||
stderr = `${stderr}${String(chunk)}`.slice(-8_000)
|
||||
})
|
||||
proc.stdout.on('data', (chunk) => {
|
||||
if (ready) {
|
||||
decoder.feed(chunk)
|
||||
|
|
@ -114,7 +165,7 @@ function createRelayClient(entryPath, args, env, protocol) {
|
|||
pollUntil(
|
||||
() => messages.slice(startIndex).find(predicate),
|
||||
label,
|
||||
() => stderr
|
||||
streams.stderr
|
||||
)
|
||||
|
||||
const request = async (method, params = {}) => {
|
||||
|
|
@ -141,7 +192,7 @@ function createRelayClient(entryPath, args, env, protocol) {
|
|||
proc,
|
||||
request,
|
||||
notify,
|
||||
sentinelReceived: withTimeout(sentinelReceived, 'relay sentinel', () => stderr),
|
||||
sentinelReceived: withTimeout(sentinelReceived, 'relay sentinel', streams.stderr),
|
||||
messageCount: () => messages.length,
|
||||
waitForNotification: (startIndex, method, predicate = () => true) =>
|
||||
waitForMessage(
|
||||
|
|
@ -149,7 +200,20 @@ function createRelayClient(entryPath, args, env, protocol) {
|
|||
(message) => message.method === method && predicate(message.params ?? {}),
|
||||
`${method} notification`
|
||||
),
|
||||
stderr: () => stderr
|
||||
stderr: streams.stderr
|
||||
}
|
||||
}
|
||||
|
||||
async function stopProcess(proc, stderr, label) {
|
||||
if (!proc || proc.exitCode !== null || proc.signalCode !== null) {
|
||||
return
|
||||
}
|
||||
proc.kill('SIGTERM')
|
||||
try {
|
||||
await withTimeout(waitForExit(proc), `${label} shutdown`, stderr)
|
||||
} catch {
|
||||
proc.kill('SIGKILL')
|
||||
await withTimeout(waitForExit(proc), `forced ${label} shutdown`, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -184,24 +248,64 @@ async function main() {
|
|||
}
|
||||
|
||||
let tempRoot
|
||||
let daemon
|
||||
let daemonStreams
|
||||
let relay
|
||||
try {
|
||||
tempRoot = await mkdtemp(join(tmpdir(), 'orca-relay-watcher-fault-'))
|
||||
const watchRoot = await realpath(tempRoot)
|
||||
const pidFile = join(tempRoot, 'watcher.pid')
|
||||
const credentialFile = join(tempRoot, 'endpoint.credential')
|
||||
const protocol = await loadProtocol(tempRoot)
|
||||
const socketPath =
|
||||
process.platform === 'win32'
|
||||
? `\\\\.\\pipe\\orca-relay-watcher-fault-${process.pid}-${Date.now()}`
|
||||
: join(tempRoot, 'relay.sock')
|
||||
await writeEndpointCredential(credentialFile)
|
||||
|
||||
// Why detached + --connect: the daemon primary stdio is unproved, so it cannot open a consumer
|
||||
// session; only an endpoint-credential socket client is admitted for pty.data after #12746.
|
||||
daemon = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
relayEntry,
|
||||
'--detached',
|
||||
'--grace-time',
|
||||
'0',
|
||||
'--sock-path',
|
||||
socketPath,
|
||||
'--endpoint-dir',
|
||||
join(tempRoot, 'agent-hooks'),
|
||||
'--credential-file',
|
||||
credentialFile
|
||||
],
|
||||
{
|
||||
cwd: dirname(relayEntry),
|
||||
env: { ...process.env, ORCA_WATCHER_CHILD_PID_FILE: pidFile },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}
|
||||
)
|
||||
daemonStreams = attachProcessStreams(daemon)
|
||||
await waitForStdoutSentinel(daemon, protocol, daemonStreams.stderr)
|
||||
|
||||
relay = createRelayClient(
|
||||
relayEntry,
|
||||
['--sock-path', socketPath, '--endpoint-dir', join(tempRoot, 'agent-hooks')],
|
||||
{ ...process.env, ORCA_WATCHER_CHILD_PID_FILE: pidFile },
|
||||
['--connect', '--sock-path', socketPath, '--credential-file', credentialFile],
|
||||
process.env,
|
||||
protocol
|
||||
)
|
||||
await relay.sentinelReceived
|
||||
|
||||
// Why no outputFlowControl: legacy owner grant admits plain pty.data without delivery tokens.
|
||||
const grant = await relay.request('pty.openClient', {
|
||||
protocolVersion: 1,
|
||||
clientInstanceId: `relay-watcher-fault-${process.pid}`,
|
||||
requestedRole: 'session-owner'
|
||||
})
|
||||
if (grant?.role !== 'session-owner') {
|
||||
throw new Error(`expected session-owner grant, got ${JSON.stringify(grant)}`)
|
||||
}
|
||||
|
||||
const spawned = await relay.request('pty.spawn', { cols: 80, rows: 24, cwd: watchRoot })
|
||||
const beforePtyMarker = `ORCA_PTY_BEFORE_${Date.now()}`
|
||||
let startIndex = relay.messageCount()
|
||||
|
|
@ -213,7 +317,9 @@ async function main() {
|
|||
)
|
||||
|
||||
await relay.request('fs.watch', { rootPath: watchRoot })
|
||||
const firstWatcherPid = await waitForWatcherPid(pidFile, undefined, relay.stderr)
|
||||
const firstWatcherPid = await waitForWatcherPid(pidFile, undefined, () =>
|
||||
`${daemonStreams.stderr()}\n${relay.stderr()}`
|
||||
)
|
||||
const beforePath = join(watchRoot, 'before.txt')
|
||||
startIndex = relay.messageCount()
|
||||
await writeFile(beforePath, 'before')
|
||||
|
|
@ -224,7 +330,9 @@ async function main() {
|
|||
const faultSignal = process.platform === 'win32' ? 'SIGTERM' : 'SIGSEGV'
|
||||
startIndex = relay.messageCount()
|
||||
process.kill(firstWatcherPid, faultSignal)
|
||||
const replacementWatcherPid = await waitForWatcherPid(pidFile, firstWatcherPid, relay.stderr)
|
||||
const replacementWatcherPid = await waitForWatcherPid(pidFile, firstWatcherPid, () =>
|
||||
`${daemonStreams.stderr()}\n${relay.stderr()}`
|
||||
)
|
||||
await relay.waitForNotification(startIndex, 'fs.changed', (params) =>
|
||||
Array.isArray(params.events)
|
||||
? params.events.some(
|
||||
|
|
@ -234,7 +342,7 @@ async function main() {
|
|||
)
|
||||
|
||||
const status = await relay.request('relay.status')
|
||||
if (status.pid !== relay.proc.pid) {
|
||||
if (status.pid !== daemon.pid) {
|
||||
throw new Error('relay.status did not come from the original surviving relay process')
|
||||
}
|
||||
const afterPtyMarker = `ORCA_PTY_AFTER_${Date.now()}`
|
||||
|
|
@ -257,7 +365,7 @@ async function main() {
|
|||
await relay.request('pty.shutdown', { id: spawned.id })
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
relayPid: relay.proc.pid,
|
||||
relayPid: daemon.pid,
|
||||
killedWatcherPid: firstWatcherPid,
|
||||
replacementWatcherPid,
|
||||
faultSignal,
|
||||
|
|
@ -268,15 +376,8 @@ async function main() {
|
|||
})
|
||||
)
|
||||
} finally {
|
||||
if (relay && relay.proc.exitCode === null && relay.proc.signalCode === null) {
|
||||
relay.proc.kill('SIGTERM')
|
||||
try {
|
||||
await withTimeout(waitForExit(relay.proc), 'relay shutdown', relay.stderr)
|
||||
} catch {
|
||||
relay.proc.kill('SIGKILL')
|
||||
await withTimeout(waitForExit(relay.proc), 'forced relay shutdown', relay.stderr)
|
||||
}
|
||||
}
|
||||
await stopProcess(relay?.proc, relay?.stderr ?? (() => ''), 'connect bridge')
|
||||
await stopProcess(daemon, daemonStreams?.stderr ?? (() => ''), 'relay daemon')
|
||||
if (tempRoot) {
|
||||
await rm(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue