Files
orca/src/cli/flags.ts
T
Neil 3af2c665c0 fix(cli): name PowerShell when it strips quotes from JSON flags (#17351)
* fix(cli): name PowerShell when it strips quotes from JSON flags

Windows PowerShell 5.1 does not escape inner quotes when building a native
command line, so `--options '["a","b"]'` reaches orca.exe as `--options [a,b]`.
The value is correct when printed and damaged by the time argv is parsed, so the
resulting "invalid JSON" error blamed the user's input rather than the shell.

#16743 recovered this for `--deps`, which is safe only because generated task IDs
have a fixed 12-hex grammar. The same mangling hits `--options`, `--payload` and
`--result`, and those are NOT safely recoverable: `["1","2"]` and `[1,2]` arrive
at argv identically, so a general repair would silently turn strings into numbers.

Detect instead. `getOptionalJsonFlag` rejects the damaged shape up front with an
error that names the shell and shows the workaround. It fires only when the value
is bracketed, quote-free, fails JSON.parse, AND consists entirely of bare tokens
that quoting would rescue, so valid JSON is untouched.

Also share the generated-id contract: `task-deps-flag` hardcoded
/^task_[0-9a-f]{12}$/i, which silently diverges if `generateId`'s byte count
changes. It now calls `isGeneratedId`, with a test pinning the two together.

Verified on a Windows host. Measured argv, which the new test pins as a fixture:
  PS_VALUE=["task_b2a580db74d8","task_c3b691ec85e9"]
  ARGV=["--deps","[task_b2a580db74d8,task_c3b691ec85e9]"]

Before: Invalid --options: must be a JSON array of strings
After:  --options arrived as [a,b], which is not valid JSON.
        Windows PowerShell 5.1 strips the inner quotes ...

* fix(cli): scope JSON-flag detection to genuinely JSON flags

Review found the detector wired to two flags that are not JSON:

- `orchestration ask --options` is documented `<csv>` and the runtime splits it
  on commas, so `--options [a,b]` was a legitimate value being rejected.
- `task-update --result` is stored verbatim and reused as dispatch failure text;
  existing tests pass free text, so a bracketed `[ok]` was being rejected.

Both revert to `getOptionalStringFlag`. Only `gate-create --options`
(`<json_array>`) and `send --payload` (`<json>`) are JSON-parsed and keep it.

Three further review fixes:

- Objects now require a `key:value` pair per entry. `{a,b}` and `{a:b,c}` were
  reported as quote-stripped although quoting them cannot produce valid JSON.
- The raw value is no longer echoed. A `--payload` can carry secrets and this
  message reaches `--json` output; the flag name and guidance are enough.
- The message hedges the shell attribution. Detection inspects only the value's
  shape, so it also fires when a macOS/Linux user forgets to quote, where
  PowerShell is not involved.

Verified against a Windows host, all six cases: both JSON flags fire on the
mangled shape and pass valid JSON through to the runtime; both non-JSON flags
now reach the runtime again; and the secret in `{token:hunter2}` appears zero
times in the error output.
2026-08-30 01:23:27 -07:00

139 lines
4.0 KiB
TypeScript

import { RuntimeClientError } from './runtime/types'
import { REPEATED_FLAG_SEPARATOR } from './args'
import { describeQuoteStrippedJsonFlag } from './quote-stripped-json-flag'
export function getRequiredStringFlag(flags: Map<string, string | boolean>, name: string): string {
const value = flags.get(name)
if (typeof value === 'string' && value.length > 0) {
return value
}
throw new RuntimeClientError('invalid_argument', `Missing required --${name}`)
}
export function getRequiredStringFlagAllowingEmpty(
flags: Map<string, string | boolean>,
name: string
): string {
const value = flags.get(name)
if (typeof value === 'string') {
return value
}
throw new RuntimeClientError('invalid_argument', `Missing required --${name}`)
}
export function getOptionalStringFlag(
flags: Map<string, string | boolean>,
name: string
): string | undefined {
const value = flags.get(name)
return typeof value === 'string' && value.length > 0 ? value : undefined
}
/**
* A JSON-valued flag, rejected up front when a native argv boundary stripped its quotes so the
* error names the shell instead of the user's value (#16706). The value itself is still parsed
* downstream; this only catches the damaged shape.
*/
export function getOptionalJsonFlag(
flags: Map<string, string | boolean>,
name: string
): string | undefined {
const value = getOptionalStringFlag(flags, name)
if (value === undefined) {
return undefined
}
const mangled = describeQuoteStrippedJsonFlag(name, value)
if (mangled) {
throw new RuntimeClientError('invalid_argument', mangled)
}
return value
}
export function getRepeatedStringFlag(
flags: Map<string, string | boolean>,
name: string
): string[] {
const value = getOptionalStringFlag(flags, name)
return value === undefined
? []
: value.split(REPEATED_FLAG_SEPARATOR).filter((entry) => entry.length > 0)
}
export function getOptionalNumberFlag(
flags: Map<string, string | boolean>,
name: string
): number | undefined {
const value = flags.get(name)
if (typeof value !== 'string' || value.length === 0) {
return undefined
}
const parsed = Number(value)
if (!Number.isFinite(parsed)) {
throw new RuntimeClientError('invalid_argument', `Invalid numeric value for --${name}`)
}
return parsed
}
export function getOptionalPositiveIntegerFlag(
flags: Map<string, string | boolean>,
name: string
): number | undefined {
const value = getOptionalNumberFlag(flags, name)
if (value === undefined) {
return undefined
}
if (!Number.isInteger(value) || value <= 0) {
throw new RuntimeClientError('invalid_argument', `Invalid positive integer for --${name}`)
}
return value
}
export function getOptionalNonNegativeIntegerFlag(
flags: Map<string, string | boolean>,
name: string
): number | undefined {
const value = getOptionalNumberFlag(flags, name)
if (value === undefined) {
return undefined
}
if (!Number.isInteger(value) || value < 0) {
throw new RuntimeClientError('invalid_argument', `Invalid non-negative integer for --${name}`)
}
return value
}
export function getRequiredPositiveNumber(
flags: Map<string, string | boolean>,
name: string
): number {
const raw = getRequiredStringFlag(flags, name)
const value = Number(raw)
if (!Number.isFinite(value) || value <= 0) {
throw new RuntimeClientError('invalid_argument', `--${name} must be a positive number`)
}
return value
}
export function getRequiredFiniteNumber(
flags: Map<string, string | boolean>,
name: string
): number {
const raw = getRequiredStringFlag(flags, name)
const value = Number(raw)
if (!Number.isFinite(value)) {
throw new RuntimeClientError('invalid_argument', `--${name} must be a valid number`)
}
return value
}
export function getOptionalNullableNumberFlag(
flags: Map<string, string | boolean>,
name: string
): number | null | undefined {
const value = flags.get(name)
if (value === 'null') {
return null
}
return getOptionalNumberFlag(flags, name)
}