mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 10:27:33 +00:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.)',
|
||||
|
||||
@@ -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.",
|
||||
"",
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user