Files
orca/src/cli/quote-stripped-json-flag.test.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

54 lines
2.4 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { describeQuoteStrippedJsonFlag, looksQuoteStripped } from './quote-stripped-json-flag'
describe('quote-stripped JSON flag detection', () => {
it('flags the exact shape measured from Windows PowerShell 5.1', () => {
// Measured on a real Windows host: ConvertTo-Json emitted
// ["task_b2a580db74d8","task_c3b691ec85e9"] and argv received the value below.
expect(looksQuoteStripped('[task_b2a580db74d8,task_c3b691ec85e9]')).toBe(true)
expect(looksQuoteStripped('[a,b]')).toBe(true)
expect(looksQuoteStripped('{a:b}')).toBe(true)
})
it('leaves valid JSON alone', () => {
expect(looksQuoteStripped('["a","b"]')).toBe(false)
expect(looksQuoteStripped('{"a":"b"}')).toBe(false)
expect(looksQuoteStripped('[1,2]')).toBe(false)
expect(looksQuoteStripped('[]')).toBe(false)
expect(looksQuoteStripped('{}')).toBe(false)
})
it('does not claim mangling for values quoting would not rescue', () => {
expect(looksQuoteStripped('not json at all')).toBe(false)
expect(looksQuoteStripped('[a b, c]')).toBe(false)
expect(looksQuoteStripped('[a,,b]')).toBe(false)
})
it('requires a key:value pair per entry before calling an object stripped', () => {
// Quoting these cannot produce a valid object, so they are ordinary invalid JSON.
expect(looksQuoteStripped('{a,b}')).toBe(false)
expect(looksQuoteStripped('{a:b,c}')).toBe(false)
expect(looksQuoteStripped('{:b}')).toBe(false)
expect(looksQuoteStripped('{a:}')).toBe(false)
expect(looksQuoteStripped('{a:b}')).toBe(true)
expect(looksQuoteStripped('{a:b,c:d}')).toBe(true)
})
it('explains the likely cause without echoing the value', () => {
const message = describeQuoteStrippedJsonFlag('payload', '{secret:hunter2}')
expect(message).toContain('--payload is not valid JSON')
expect(message).toContain('PowerShell 5.1')
expect(message).toContain('$v')
// A payload can carry secrets, and this reaches --json output.
expect(message).not.toContain('hunter2')
expect(message).not.toContain('secret')
expect(describeQuoteStrippedJsonFlag('options', '["a","b"]')).toBeNull()
})
it('hedges the shell attribution, since it inspects only the value shape', () => {
// The same shape occurs when a macOS/Linux user simply forgets to quote.
expect(describeQuoteStrippedJsonFlag('options', '[a,b]')).toContain('If you ran this from')
})
})