feat(cli): let orca fill read its value from stdin (#29850)

Add `orca fill --value-stdin` so passwords and other secrets stay out of
argv and shell history. Fill drops one trailing newline from stdin (secret
readers end with one) and rejects empty stdin so a failed reader cannot
silently clear the field; `--value ''` still clears.

Share one flag-or-stdin reader with `orca computer type`/`set-value` and
the `--body-file -` reader. Computer commands keep their existing
semantics: no newline trimming, set-value still accepts empty stdin, and
`--text-stdin=true` style keeps reading stdin.

Fixes #29849

Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
Prateek Rungta
2026-11-15 01:23:31 -08:00
committed by GitHub
co-authored by Neil
parent 3301055c8f
commit a2bcfef327
10 changed files with 176 additions and 51 deletions
@@ -32,6 +32,7 @@ ORCA full-screenshot --page <browserPageId>
ORCA pdf --page <browserPageId> --json
ORCA click --page <browserPageId> --element <ref> --json
ORCA fill --page <browserPageId> --element <ref> --value <text> --json
<secret reader> | ORCA fill --page <browserPageId> --element <ref> --value-stdin --json
ORCA type --page <browserPageId> --input <text> --json
ORCA select --page <browserPageId> --element <ref> --value <value> --json
ORCA check --page <browserPageId> --element <ref> --json
@@ -83,6 +84,7 @@ Browser rules:
- Anything not listed above goes through `ORCA exec --page <browserPageId> --command "<agent-browser command>"`, which takes agent-browser's own command syntax (for example `"get text @e3"` or `"is visible @e3"`) and prints JSON. It has no `help` command.
- `upload` takes the file input itself or the button that opens a file picker, in any frame, including cross-origin iframes; Orca catches the picker and sets the files on its input. Use `ORCA upload`, not `ORCA exec --command "upload ..."`. On server-hosted pages pass the file input's ref.
- `fill` sets a field's whole value in one step, at any size. `type` presses real keys into the focused element, so `minlength`/`maxlength`, key handlers, Enter and Tab act as they would for a person; `ORCA focus` the element first. Use `type` when the page reacts to keystrokes.
- Fill passwords and other secrets with `--value-stdin`, not `--value`: argv is visible to `ps` and shell history. One trailing newline is dropped.
- On server-hosted pages `fill` and `type` still insert text the engine's own way. If either does nothing on a custom input there, try `ORCA focus --page <browserPageId> --element @e1 --json` then `ORCA inserttext --page <browserPageId> --text "text" --json`.
- A client-hosted page renders in the paired desktop's browser engine, so every command against it needs that desktop online and returns `browser_host_unavailable` while it is closed, asleep, or disconnected. Server-hosted pages run with no desktop attached; prefer them for long or unattended automation.
+2 -8
View File
@@ -1,5 +1,6 @@
import { readFile } from 'node:fs/promises'
import { isAbsolute, join } from 'node:path'
import { readStdin } from './flag-or-stdin-payload'
import { getRequiredStringFlag, getRequiredStringFlagAllowingEmpty } from './flags'
import { RuntimeClientError } from '../runtime/connection/types'
@@ -66,12 +67,5 @@ async function readBodyFile(path: string, cwd: string): Promise<string> {
if (path !== '-') {
return readFile(isAbsolute(path) ? path : join(cwd, path), 'utf8')
}
if (process.stdin.isTTY) {
throw new RuntimeClientError('invalid_argument', 'stdin body requested but stdin is a TTY')
}
const chunks: Buffer[] = []
for await (const chunk of process.stdin) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)))
}
return Buffer.concat(chunks).toString('utf8')
return readStdin('body')
}
@@ -0,0 +1,44 @@
import { getRequiredStringFlag, getRequiredStringFlagAllowingEmpty } from './flags'
import { RuntimeClientError } from '../runtime/connection/types'
/** Reads `--<name>`, or stdin for `--<name>-stdin` so secrets stay out of argv and shell history. */
export async function getFlagOrStdinPayload(
flags: Map<string, string | boolean>,
name: 'text' | 'value',
{
allowEmptyStdin = false,
trimTrailingNewline = false
}: { allowEmptyStdin?: boolean; trimTrailingNewline?: boolean } = {}
): Promise<string> {
const stdinFlag = `${name}-stdin`
if (flags.has(stdinFlag)) {
if (flags.has(name)) {
throw new RuntimeClientError(
'invalid_argument',
`Use either --${name} or --${stdinFlag}, not both`
)
}
const raw = await readStdin('payload')
// Secret readers (`pass`, `op read`, `echo`) end with a newline that breaks number/date inputs.
const payload = trimTrailingNewline ? raw.replace(/\r?\n$/, '') : raw
// An empty pipe usually means the secret reader failed; don't silently clear the field.
if (payload.length === 0 && !allowEmptyStdin) {
throw new RuntimeClientError('invalid_argument', `Missing ${name} from stdin`)
}
return payload
}
return name === 'value'
? getRequiredStringFlagAllowingEmpty(flags, name)
: getRequiredStringFlag(flags, name)
}
export async function readStdin(label: string): Promise<string> {
if (process.stdin.isTTY) {
throw new RuntimeClientError('invalid_argument', `stdin ${label} requested but stdin is a TTY`)
}
const chunks: Buffer[] = []
for await (const chunk of process.stdin) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)))
}
return Buffer.concat(chunks).toString('utf8')
}
+113
View File
@@ -0,0 +1,113 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { callMock, runtimeClientConstructorMock, serveOrcaAppMock, getDefaultUserDataPathMock } =
vi.hoisted(() => ({
callMock: vi.fn(),
runtimeClientConstructorMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data')
}))
vi.mock('../runtime/client', async () => {
const { createRuntimeClientModuleMock } = await import('../index-test-harness.js')
return createRuntimeClientModuleMock({
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock
})
})
vi.mock('../runtime/launch', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
serveOrcaApp: serveOrcaAppMock
}))
vi.mock('../runtime/profile/metadata', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
getDefaultUserDataPath: getDefaultUserDataPathMock
}))
import { main } from '../index'
import { okFixture } from '../runtime/fixtures/runtime-rpc-fixtures'
function mockStdin(chunks: string[]): { restore: () => void } {
const stdin = process.stdin
const previous = [
['isTTY', Object.getOwnPropertyDescriptor(stdin, 'isTTY')],
[Symbol.asyncIterator, Object.getOwnPropertyDescriptor(stdin, Symbol.asyncIterator)]
] as const
Object.defineProperty(stdin, 'isTTY', { configurable: true, value: false })
Object.defineProperty(stdin, Symbol.asyncIterator, {
configurable: true,
value: async function* () {
for (const chunk of chunks) {
yield Buffer.from(chunk)
}
}
})
return {
restore: () => {
for (const [key, descriptor] of previous) {
if (descriptor) {
Object.defineProperty(stdin, key, descriptor)
} else {
Reflect.deleteProperty(stdin, key)
}
}
}
}
}
describe('orca fill --value-stdin', () => {
beforeEach(() => {
callMock.mockReset()
callMock.mockImplementation(async () => okFixture('req_fill', { filled: '@e3' }))
process.exitCode = undefined
})
afterEach(() => {
vi.restoreAllMocks()
process.exitCode = undefined
})
it('sends the stdin payload minus one trailing newline as the fill value', async () => {
vi.spyOn(console, 'log').mockImplementation(() => {})
const stdin = mockStdin(['hunter', '2\n\r\n'])
try {
await main(
['fill', '--element', '@e3', '--value-stdin', '--page', 'page-1', '--json'],
'/tmp/not-an-orca-worktree'
)
} finally {
stdin.restore()
}
expect(callMock).toHaveBeenCalledWith(
'browser.fill',
expect.objectContaining({ element: '@e3', value: 'hunter2\n', page: 'page-1' })
)
expect(process.exitCode).toBeUndefined()
})
it.each([
[['--value', 'secret', '--value-stdin'], 'Use either --value or --value-stdin, not both'],
[['--value-stdin'], 'Missing value from stdin']
])('rejects %j before calling the runtime', async (valueFlags, message) => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const stdin = mockStdin(['\n'])
try {
await main(
['fill', '--element', '@e3', ...valueFlags, '--page', 'page-1'],
'/tmp/not-an-orca-worktree'
)
} finally {
stdin.restore()
}
expect(callMock).not.toHaveBeenCalledWith('browser.fill', expect.anything())
expect(errorSpy.mock.calls.flat().join('\n')).toContain(message)
expect(errorSpy.mock.calls.flat().join('\n')).not.toContain('secret')
expect(process.exitCode).toBe(1)
})
})
+2 -1
View File
@@ -13,6 +13,7 @@ import type {
BrowserUploadResult
} from '../../shared/browser/runtime/runtime-browser-contracts'
import type { CommandHandler } from '../dispatch/dispatch'
import { getFlagOrStdinPayload } from '../arguments/flag-or-stdin-payload'
import { resolveRepoPathArgument } from '../projects/repo-path-arguments'
import { printResult } from '../output/command-result-output'
import {
@@ -52,7 +53,7 @@ export const BROWSER_INTERACT_HANDLERS: Record<string, CommandHandler> = {
},
fill: async ({ flags, client, cwd, json }) => {
const element = getRequiredStringFlag(flags, 'element')
const value = getRequiredStringFlagAllowingEmpty(flags, 'value')
const value = await getFlagOrStdinPayload(flags, 'value', { trimTrailingNewline: true })
const target = await getBrowserCommandTarget(flags, cwd, client)
const result = await client.call<BrowserFillResult>('browser.fill', {
element,
+4 -38
View File
@@ -8,9 +8,9 @@ import {
getOptionalNumberFlag,
getOptionalPositiveIntegerFlag,
getOptionalStringFlag,
getRequiredStringFlag,
getRequiredStringFlagAllowingEmpty
getRequiredStringFlag
} from '../arguments/flags'
import { getFlagOrStdinPayload } from '../arguments/flag-or-stdin-payload'
import { RuntimeClientError } from '../runtime/connection/types'
import {
validateDragTarget,
@@ -140,7 +140,7 @@ export function getComputerDragActionFlags(flags: Map<string, string | boolean>)
export async function getComputerTextActionFlags(flags: Map<string, string | boolean>): Promise<{
text: string
}> {
return { text: await getTextPayload(flags, 'text') }
return { text: await getFlagOrStdinPayload(flags, 'text') }
}
export async function getComputerTypeTextActionFlags(
@@ -190,44 +190,10 @@ export async function getComputerSetValueActionFlags(
}> {
return {
elementIndex: getRequiredNonNegativeIntegerFlag(flags, 'element-index'),
value: await getTextPayload(flags, 'value')
value: await getFlagOrStdinPayload(flags, 'value', { allowEmptyStdin: true })
}
}
async function getTextPayload(
flags: Map<string, string | boolean>,
name: 'text' | 'value'
): Promise<string> {
const stdinFlag = `${name}-stdin`
if (flags.has(stdinFlag)) {
if (flags.has(name)) {
throw new RuntimeClientError(
'invalid_argument',
`Use either --${name} or --${stdinFlag}, not both`
)
}
const payload = await readStdin()
if (name === 'text' && payload.length === 0) {
throw new RuntimeClientError('invalid_argument', 'Missing text from stdin')
}
return payload
}
return name === 'value'
? getRequiredStringFlagAllowingEmpty(flags, name)
: getRequiredStringFlag(flags, name)
}
async function readStdin(): Promise<string> {
if (process.stdin.isTTY) {
throw new RuntimeClientError('invalid_argument', 'stdin payload requested but stdin is a TTY')
}
const chunks: Buffer[] = []
for await (const chunk of process.stdin) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)))
}
return Buffer.concat(chunks).toString('utf8')
}
function getOptionalPositiveNumberFlag(
flags: Map<string, string | boolean>,
name: string
+2 -2
View File
@@ -80,7 +80,7 @@ export const FLAG_HELP_TEXT: Record<string, string> = {
shell: '--shell <shell> Windows shell the terminal itself runs as',
terminal: '--terminal <handle> Runtime-issued terminal handle',
text: '--text <text> Text payload to send or type',
'text-stdin': '--text-stdin Read text payload from stdin',
'text-stdin': '--text-stdin Read text payload from stdin',
'task-id': '--task-id <id> Task id to include in orchestration payload JSON',
'task-title': '--task-title <text> Concise title for an orchestration task',
'dispatch-id': '--dispatch-id <id> Dispatch id to include in orchestration payload JSON',
@@ -124,7 +124,7 @@ export const FLAG_HELP_TEXT: Record<string, string> = {
'--extra-agent-args=<args> Extra agent arguments for fresh runs; empty clears',
'workspace-mode': '--workspace-mode <mode> existing or new-per-run',
'missed-run-grace-minutes': '--missed-run-grace-minutes <n> Missed-run grace window',
'value-stdin': '--value-stdin Read set-value payload from stdin',
'value-stdin': '--value-stdin Read value payload from stdin',
'window-id': '--window-id <id> Target a window id from list-windows',
'window-index': '--window-index <n> Target a window index from list-windows',
element: '--element <ref> Element ref from snapshot (e.g. e3)',
@@ -144,6 +144,7 @@ export const ROOT_HELP_TEXT_SECONDARY = [
' --element <ref> Element ref from snapshot (e.g. @e3)',
' --url <url> URL to navigate to',
' --value <text> Value to fill or select',
' --value-stdin Read the fill value from stdin (keeps secrets out of argv)',
' --input <text> Text to type at current focus (no element needed)',
' --expression <js> JavaScript expression to evaluate',
' --key <key> Key to press (Enter, Tab, Escape, Control+a, etc.)',
+2
View File
@@ -705,6 +705,7 @@ const ORCA_CLI_BROWSER_REFERENCE_MARKDOWN = [
"ORCA pdf --page <browserPageId> --json",
"ORCA click --page <browserPageId> --element <ref> --json",
"ORCA fill --page <browserPageId> --element <ref> --value <text> --json",
"<secret reader> | ORCA fill --page <browserPageId> --element <ref> --value-stdin --json",
"ORCA type --page <browserPageId> --input <text> --json",
"ORCA select --page <browserPageId> --element <ref> --value <value> --json",
"ORCA check --page <browserPageId> --element <ref> --json",
@@ -756,6 +757,7 @@ const ORCA_CLI_BROWSER_REFERENCE_MARKDOWN = [
"- Anything not listed above goes through `ORCA exec --page <browserPageId> --command \"<agent-browser command>\"`, which takes agent-browser's own command syntax (for example `\"get text @e3\"` or `\"is visible @e3\"`) and prints JSON. It has no `help` command.",
"- `upload` takes the file input itself or the button that opens a file picker, in any frame, including cross-origin iframes; Orca catches the picker and sets the files on its input. Use `ORCA upload`, not `ORCA exec --command \"upload ...\"`. On server-hosted pages pass the file input's ref.",
"- `fill` sets a field's whole value in one step, at any size. `type` presses real keys into the focused element, so `minlength`/`maxlength`, key handlers, Enter and Tab act as they would for a person; `ORCA focus` the element first. Use `type` when the page reacts to keystrokes.",
"- Fill passwords and other secrets with `--value-stdin`, not `--value`: argv is visible to `ps` and shell history. One trailing newline is dropped.",
"- On server-hosted pages `fill` and `type` still insert text the engine's own way. If either does nothing on a custom input there, try `ORCA focus --page <browserPageId> --element @e1 --json` then `ORCA inserttext --page <browserPageId> --text \"text\" --json`.",
"- A client-hosted page renders in the paired desktop's browser engine, so every command against it needs that desktop online and returns `browser_host_unavailable` while it is closed, asleep, or disconnected. Server-hosted pages run with no desktop attached; prefer them for long or unattended automation.",
"",
+4 -2
View File
@@ -63,8 +63,10 @@ export const BROWSER_BASIC_COMMAND_SPECS: CommandSpec[] = withBrowserPageTarget(
{
path: ['fill'],
summary: 'Clear and fill a browser input by ref',
usage: 'orca fill --element <ref> --value <text> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'element', 'value', 'worktree']
usage:
'orca fill --element <ref> (--value <text> | --value-stdin) [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'element', 'value', 'value-stdin', 'worktree'],
notes: ['--value-stdin drops one trailing newline and rejects empty input.']
},
{
path: ['type'],