mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 16:02:35 +00:00
* fix(web): keep Remote Web loading over plain HTTP without crypto.randomUUID
Browsers hide crypto.randomUUID outside secure contexts, so Remote Web over
http://<lan-or-tailnet-ip> threw while importing the store and never painted.
createAgentStatusAuthorityId now takes its UUID source (renderer passes
createBrowserUuid, main passes node:crypto randomUUID), and the other
unguarded renderer calls go through createBrowserUuid.
* refactor(renderer): route remaining randomUUID fallbacks through createBrowserUuid
Replaces five hand-rolled crypto?.randomUUID?.() fallbacks (including a copy
of the browser-uuid fallback in mint-stable-pane-id) with createBrowserUuid,
and adds an oxlint no-restricted-properties rule so renderer code cannot call
randomUUID directly again.
* refactor(shared): move the non-secure-context UUID generator into src/shared
The white screen came from src/shared, so the fix belongs there. src/shared had
three hand-rolled copies of the same randomUUID-then-getRandomValues-then-Math.random
ladder (nested-repo-telemetry, project-groups, setup-agent-sequencing) because there
was nothing in that layer to import; createBrowserUuid lived one directory over in
the renderer.
createNonSecureContextUuid() now holds the single implementation, @/lib/browser-uuid
re-exports it under the renderer's existing name so no renderer import site changes,
and the three duplicates call it.
That also lets createAgentStatusAuthorityId go back to one argument. The injected
randomUuid source was justified as keeping browser APIs out of shared code, but this
generator is runtime-agnostic — it works unchanged in Node. Injecting it bought no
layering and made the safe choice a parameter every future caller had to get right,
unguarded: a caller could pass () => globalThis.crypto.randomUUID() and restore the
white screen with lint and tests green.
* fix(lint): ban crypto.randomUUID in src/shared and scope the escape hatch
vite.web.config.ts compiles src/shared straight into the web bundle, but the new
randomUUID ban only covered src/renderer/src — so the exact module that white-screened
the app sat outside the guard it shipped with, and the regression could come back with
a green lint. The override now covers src/shared/**/*.ts too; it costs zero diagnostics
because the duplicates it would have flagged are gone. `import { randomUUID } from
'node:crypto'` is untouched, so main-only shared modules keep working.
Both blanket "off" overrides are gone. no-restricted-properties is keyed by property
name, so the moment a second property joins the renderer block those overrides would
have silently exempted it — in the one file that is the escape hatch, and in every test
in the repo. Tests are where people copy patterns from, so they stay covered; the four
real uses carry line-scoped disables with a reason.
* fix(terminal): keep render-desync capture ids inside main's 120-char cap
createCaptureId builds `${Date.now()}-${panePart}-${nonce}`. A real paneKey is
`${tabId}:${leafId}` — two UUIDs, 73 chars after sanitizing — so with a 36-char UUID
nonce the id is 124 chars and main rejects it with 'Invalid render-desync capture id'.
persistHealedReference swallows that into console.error, so it shows up as diagnostics
that silently never appear.
This was already broken on the desktop app, where randomUUID is available; routing the
non-secure path through the same generator would have made it unconditional, including
on the plain-HTTP web client this branch exists to repair.
Bound the pane part rather than the nonce: keep the trailing 40 chars, which is the
whole leaf id (the identifying half, unique on its own) and drop the tab-id prefix, so
ids stay unique and traceable at 91 chars. The 120-char contract now lives in
src/shared next to the IPC args, imported by both sides, so the renderer cannot mint an
id main will reject without the test noticing.
* test(web): cover the whole store graph and the Vault token without randomUUID
The reported stack was the store chunk, not two named modules, so the repro test now
evaluates the store root under the stubbed non-secure crypto. Any new import-time
secure-context call anywhere in that graph fails here, not just the one this branch
removed.
Also ports the request-token regression from #20465, the one piece of coverage the
competing branches for this bug contributed that this one lacked. Both cases fail with
"randomUUID is not a function" when their production change is reverted.
* test(web): restore the real crypto.randomUUID after the non-secure Vault case
randomUUID lives on Crypto.prototype, so stubbing it as an own property of
globalThis.crypto left the restore branch with an undefined descriptor and a
leaked own `randomUUID: undefined`. Swap the whole crypto own property instead,
through one shared stub the repro suite already needed.
---------
Co-authored-by: Neil <neil@stably.ai>
314 lines
12 KiB
TypeScript
314 lines
12 KiB
TypeScript
import { encodePowerShellCommand } from './powershell-command-encoding'
|
|
import {
|
|
nativeWindowsPathToPosixShellPath,
|
|
resolveSetupRunnerCommand,
|
|
type SetupRunnerCommandPlatform,
|
|
type SetupRunnerCommandShell,
|
|
type SetupRunnerShell
|
|
} from './setup-runner-command'
|
|
import { createNonSecureContextUuid } from './non-secure-context-uuid'
|
|
|
|
const DEFAULT_WAIT_TIMEOUT_SECONDS = 2 * 60 * 60
|
|
// Exported so the gate and its tests share one definition.
|
|
export const SETUP_COMPLETE_MESSAGE = 'Setup finished; starting agent.'
|
|
export const SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV = 'ORCA_SEQUENCED_STARTUP_COMMAND'
|
|
export const SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV = 'ORCA_SEQUENCED_STARTUP_SCRIPT'
|
|
|
|
export type SequencedSetupAgentCommands = {
|
|
setupCommand: string
|
|
startupCommand: string
|
|
startupEnv?: Record<string, string>
|
|
}
|
|
|
|
export function resolveSetupAgentSequenceLaunchCommand(
|
|
env: Record<string, string | undefined>,
|
|
fallbackCommand: string | undefined
|
|
): string | undefined {
|
|
const sequencedStartup = env[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]?.trim()
|
|
return sequencedStartup || fallbackCommand
|
|
}
|
|
|
|
export function createSetupAgentSequenceNonce(): string {
|
|
return createNonSecureContextUuid()
|
|
}
|
|
|
|
export function createSequencedSetupAgentCommands(args: {
|
|
runnerScriptPath: string
|
|
startupCommand: string
|
|
platform: SetupRunnerCommandPlatform
|
|
shell?: SetupRunnerShell
|
|
nonce?: string
|
|
waitTimeoutSeconds?: number
|
|
}): SequencedSetupAgentCommands {
|
|
const nonce = args.nonce ?? createSetupAgentSequenceNonce()
|
|
const resolution = resolveSetupRunnerCommand(args.runnerScriptPath, args.platform, args.shell)
|
|
// Why: the gate is typed into the terminal pane and `startupCommand` is already quoted for that
|
|
// pane, so a batch runner launched from a Git Bash pane still needs the bash gate — PowerShell's
|
|
// `Invoke-Expression` cannot parse the POSIX `'\''` escaping the pane's quoting produces. The
|
|
// runner itself still launches through `resolution.command`, never through bash.
|
|
const posixGateForWindowsRunner = resolution.shell === 'windows' && args.shell?.family === 'posix'
|
|
const markerBasePath = posixGateForWindowsRunner
|
|
? nativeWindowsPathToPosixShellPath(resolution.runnerScriptPathForShell)
|
|
: resolution.runnerScriptPathForShell
|
|
// Why: overlapping gated launches of the same setup runner must not race on
|
|
// a shared completion marker.
|
|
const markerPath = `${markerBasePath}.${nonce}.done`
|
|
const waitTimeoutSeconds = args.waitTimeoutSeconds ?? DEFAULT_WAIT_TIMEOUT_SECONDS
|
|
|
|
if (resolution.shell === 'windows' && !posixGateForWindowsRunner) {
|
|
return {
|
|
setupCommand: buildWindowsSetupCommand(
|
|
resolution.runnerScriptPathForShell,
|
|
markerPath,
|
|
nonce
|
|
),
|
|
startupCommand: buildWindowsStartupCommand(markerPath, nonce, waitTimeoutSeconds),
|
|
startupEnv: {
|
|
[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: args.startupCommand
|
|
}
|
|
}
|
|
}
|
|
|
|
const startupScript = buildPosixStartupScript(
|
|
args.startupCommand,
|
|
markerPath,
|
|
nonce,
|
|
waitTimeoutSeconds
|
|
)
|
|
return {
|
|
setupCommand: buildPosixSetupCommand(resolution.command, markerPath, nonce),
|
|
// Why: long worktree paths can push the gate past a PTY's canonical input cap and drop its submit byte.
|
|
startupCommand: `bash -lc 'eval "$${SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV}"'`,
|
|
startupEnv: {
|
|
[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: args.startupCommand,
|
|
[SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV]: startupScript
|
|
}
|
|
}
|
|
}
|
|
|
|
function buildPosixSetupCommand(setupCommand: string, markerPath: string, nonce: string): string {
|
|
const marker = quotePosixArg(markerPath)
|
|
const tmp = quotePosixArg(`${markerPath}.tmp`)
|
|
const nonceValue = quotePosixArg(nonce)
|
|
|
|
const script = [
|
|
`rm -f ${marker} ${tmp} 2>/dev/null`,
|
|
`( ${setupCommand} )`,
|
|
'status=$?',
|
|
`printf '%s:%s\\n' ${nonceValue} "$status" > ${tmp}`,
|
|
`mv -f ${tmp} ${marker}`,
|
|
'exit "$status"'
|
|
].join('; ')
|
|
|
|
return `bash -lc ${quotePosixArg(script)}`
|
|
}
|
|
|
|
function buildPosixStartupScript(
|
|
startupCommand: string,
|
|
markerPath: string,
|
|
nonce: string,
|
|
waitTimeoutSeconds: number
|
|
): string {
|
|
const marker = quotePosixArg(markerPath)
|
|
const tmp = quotePosixArg(`${markerPath}.tmp`)
|
|
const nonceValue = quotePosixArg(nonce)
|
|
const timeout = Math.max(1, Math.floor(waitTimeoutSeconds))
|
|
const startupSuccessCommand = buildPosixStartupSuccessCommand(startupCommand)
|
|
// Why: the PTY launch path feeds this command through an interactive shell,
|
|
// so keeping the wrapper on one line avoids visible `quote>` continuation
|
|
// prompts while still preserving valid `while`/`if` shell syntax.
|
|
const script = [
|
|
`deadline=$((SECONDS + ${timeout}));`,
|
|
'echo "Waiting for setup to finish before starting agent..." >&2;',
|
|
'while :; do',
|
|
`if [ -f ${marker} ]; then`,
|
|
`IFS=: read -r seen status < ${marker} || true;`,
|
|
`if [ "$seen" = ${nonceValue} ]; then`,
|
|
`rm -f ${marker} ${tmp} 2>/dev/null;`,
|
|
// Why: failure and timeout announce themselves; a silent success left
|
|
// "Waiting for setup..." as the pane's last line forever.
|
|
`if [ "$status" = "0" ]; then echo ${quotePosixArg(SETUP_COMPLETE_MESSAGE)} >&2; if [ -n "\${${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}:-}" ]; then eval "\$${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}"; exit "$?"; else ${startupSuccessCommand}; fi; fi;`,
|
|
'echo "Setup failed; skipping agent startup." >&2;',
|
|
'exit "${status:-1}";',
|
|
'fi;',
|
|
'fi;',
|
|
'if [ "$SECONDS" -ge "$deadline" ]; then',
|
|
'echo "Timed out waiting for setup before starting agent." >&2;',
|
|
'exit 124;',
|
|
'fi;',
|
|
'sleep 1;',
|
|
'done'
|
|
].join(' ')
|
|
|
|
return script
|
|
}
|
|
|
|
function buildPosixStartupSuccessCommand(startupCommand: string): string {
|
|
if (
|
|
hasUnquotedPosixCommandSeparator(startupCommand) ||
|
|
hasLeadingPosixEnvAssignment(startupCommand)
|
|
) {
|
|
return `eval ${quotePosixArg(startupCommand)}; exit "$?"`
|
|
}
|
|
return `exec ${startupCommand}`
|
|
}
|
|
|
|
function hasLeadingPosixEnvAssignment(command: string): boolean {
|
|
return /^[A-Za-z_][A-Za-z0-9_]*=/.test(command.trimStart())
|
|
}
|
|
|
|
function hasUnquotedPosixCommandSeparator(command: string): boolean {
|
|
let quote: "'" | '"' | null = null
|
|
let escaped = false
|
|
for (const char of command) {
|
|
if (escaped) {
|
|
escaped = false
|
|
continue
|
|
}
|
|
if (char === '\\') {
|
|
escaped = true
|
|
continue
|
|
}
|
|
if (quote) {
|
|
if (char === quote) {
|
|
quote = null
|
|
}
|
|
continue
|
|
}
|
|
if (char === "'" || char === '"') {
|
|
quote = char
|
|
continue
|
|
}
|
|
if (char === ';' || char === '&' || char === '|' || char === '\n' || char === '\r') {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
function buildWindowsSetupCommand(
|
|
runnerScriptPath: string,
|
|
markerPath: string,
|
|
nonce: string
|
|
): string {
|
|
// Why: delayed expansion keeps path metacharacters as data when cmd invokes the batch runner.
|
|
const script = [
|
|
`$runner = ${quotePowerShellString(runnerScriptPath)}`,
|
|
`$marker = ${quotePowerShellString(markerPath)}`,
|
|
'$tmp = $marker + ".tmp"',
|
|
`$nonce = ${quotePowerShellString(nonce)}`,
|
|
'Remove-Item -LiteralPath $marker, $tmp -Force -ErrorAction SilentlyContinue',
|
|
'$processInfo = [System.Diagnostics.ProcessStartInfo]::new()',
|
|
'$processInfo.FileName = $env:ComSpec',
|
|
'$processInfo.Arguments = \'/d /s /v:on /c ""!ORCA_SETUP_RUNNER!""\'',
|
|
'$processInfo.UseShellExecute = $false',
|
|
'$processInfo.EnvironmentVariables["ORCA_SETUP_RUNNER"] = $runner',
|
|
'$process = [System.Diagnostics.Process]::Start($processInfo)',
|
|
'$process.WaitForExit()',
|
|
'$setupStatus = $process.ExitCode',
|
|
'$utf8 = [System.Text.UTF8Encoding]::new($false)',
|
|
'[System.IO.File]::WriteAllText($tmp, ($nonce + ":" + $setupStatus + [Environment]::NewLine), $utf8)',
|
|
'Move-Item -LiteralPath $tmp -Destination $marker -Force',
|
|
'exit $setupStatus'
|
|
].join('; ')
|
|
|
|
return encodePowerShellInvocation(script)
|
|
}
|
|
|
|
function buildWindowsStartupCommand(
|
|
markerPath: string,
|
|
nonce: string,
|
|
waitTimeoutSeconds: number
|
|
): string {
|
|
const timeout = Math.max(1, Math.floor(waitTimeoutSeconds))
|
|
// Why: native Windows setup runners launch through cmd.exe, but PowerShell
|
|
// gives us safe bounded file polling/parsing without a fragile batch label loop.
|
|
const script = [
|
|
// Why: the startup command is user-authored and may invoke a `.ps1`, which IS
|
|
// execution-policy gated even though `-EncodedCommand` is not. This is the in-payload
|
|
// stand-in for the `-ExecutionPolicy Bypass` switch dropped from the command line
|
|
// (same trade as the agent-hooks launcher). Progress must be silenced first and
|
|
// restored after: Set-ExecutionPolicy autoloads a module whose "Preparing modules for
|
|
// first use." record would otherwise land on the stderr this gate writes to.
|
|
//
|
|
// The failure is reported rather than swallowed. Autoload can fail for reasons that
|
|
// have nothing to do with policy -- a 5.1 install with duplicate extended type data
|
|
// fails every cmdlet in Microsoft.PowerShell.Security -- and the old
|
|
// `-ErrorAction SilentlyContinue` plus empty `catch` hid that completely, leaving the
|
|
// user with an execution-policy refusal from their own script and no trace that the
|
|
// relief had been attempted. `-ErrorAction Stop` is what routes a non-terminating
|
|
// failure into the catch at all. Still never throws: a diagnostic is worth a line of
|
|
// stderr, but not the startup this gate exists to run.
|
|
"$orcaProgress = $ProgressPreference; $ProgressPreference = 'SilentlyContinue'",
|
|
'try { Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force -ErrorAction Stop } ' +
|
|
'catch { [Console]::Error.WriteLine("Orca: could not relax the execution policy for this " + ' +
|
|
'"session (" + $_.FullyQualifiedErrorId + "). A startup command that runs a .ps1 " + ' +
|
|
'"may be blocked.") }',
|
|
'$ProgressPreference = $orcaProgress',
|
|
`$marker = ${quotePowerShellString(markerPath)}`,
|
|
'if ([string]::IsNullOrWhiteSpace($marker)) {',
|
|
' [Console]::Error.WriteLine("Missing setup marker path.")',
|
|
' exit 1',
|
|
'}',
|
|
'$tmp = $marker + ".tmp"',
|
|
`$nonce = ${quotePowerShellString(nonce)}`,
|
|
`$deadline = (Get-Date).AddSeconds(${timeout})`,
|
|
'[Console]::Error.WriteLine("Waiting for setup to finish before starting agent...")',
|
|
'while ($true) {',
|
|
' if (Test-Path -LiteralPath $marker) {',
|
|
' $content = Get-Content -LiteralPath $marker -TotalCount 1',
|
|
' if ($content -match "^([0-9A-Za-z_-]+):([0-9]+)$" -and $Matches[1] -eq $nonce) {',
|
|
' $setupStatus = [int]$Matches[2]',
|
|
' Remove-Item -LiteralPath $marker, $tmp -Force -ErrorAction SilentlyContinue',
|
|
' if ($setupStatus -ne 0) {',
|
|
' [Console]::Error.WriteLine("Setup failed; skipping agent startup.")',
|
|
' exit $setupStatus',
|
|
' }',
|
|
` $startup = $env:${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}`,
|
|
' if ([string]::IsNullOrWhiteSpace($startup)) {',
|
|
' [Console]::Error.WriteLine("Missing sequenced startup command.")',
|
|
' exit 1',
|
|
' }',
|
|
` [Console]::Error.WriteLine(${quotePowerShellString(SETUP_COMPLETE_MESSAGE)})`,
|
|
' Invoke-Expression $startup',
|
|
' if ($global:LASTEXITCODE -ne $null) { exit $global:LASTEXITCODE }',
|
|
' if (-not $?) { exit 1 }',
|
|
' exit 0',
|
|
' }',
|
|
' }',
|
|
' if ((Get-Date) -ge $deadline) {',
|
|
' [Console]::Error.WriteLine("Timed out waiting for setup before starting agent.")',
|
|
' exit 124',
|
|
' }',
|
|
' Start-Sleep -Seconds 1',
|
|
'}'
|
|
].join('; ')
|
|
|
|
return encodePowerShellInvocation(script)
|
|
}
|
|
|
|
// Why: `-EncodedCommand` is not execution-policy gated (only `-File` is), so `-ExecutionPolicy
|
|
// Bypass` was a no-op — and it is one of the most heavily EDR-flagged PowerShell tokens. The
|
|
// base64 stays: these strings are typed into a terminal pane and re-parsed by its shell.
|
|
function encodePowerShellInvocation(script: string): string {
|
|
return `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encodePowerShellCommand(script)}`
|
|
}
|
|
|
|
function quotePosixArg(value: string): string {
|
|
if (/^[A-Za-z0-9_./:-]+$/.test(value)) {
|
|
return value
|
|
}
|
|
return `'${value.replace(/'/g, `'\\''`)}'`
|
|
}
|
|
|
|
function quotePowerShellString(value: string): string {
|
|
return `'${value.replace(/'/g, "''")}'`
|
|
}
|
|
|
|
export function getSetupAgentSequenceShellForTests(
|
|
runnerScriptPath: string,
|
|
platform: SetupRunnerCommandPlatform
|
|
): SetupRunnerCommandShell {
|
|
return resolveSetupRunnerCommand(runnerScriptPath, platform).shell
|
|
}
|