Skip to content

Commit 9d2bc90

Browse files
committed
fix(fill): harden --text-stdin input handling and docs
- Mask target parse failures under --text-stdin: a stray positional may be the secret, and the generic target error echoed it. - Keep a leading byte order mark and refuse lone surrogates in string chunks instead of replacing them. - Only refuse batch steps that set textStdin to true. - Clarify help and docs: passing neither --record-as nor --no-record while recording is an error, invalid UTF-8 is refused, and printf %s is the verbatim way to pipe a value. - Tighten tests to assert each case's own input is never echoed.
1 parent 1839fb3 commit 9d2bc90

10 files changed

Lines changed: 109 additions & 31 deletions

File tree

‎src/commands/batch/cli.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,3 +448,14 @@ test('batch rejects a fill step that asks for stdin text before daemon dispatch'
448448
assert.equal(result.calls.length, 0);
449449
assert.match(result.stderr, /Batch step 1: fill textStdin is not supported/);
450450
});
451+
452+
test('batch passes an explicit fill textStdin false through to dispatch', async () => {
453+
const result = await runCliCapture([
454+
'batch',
455+
'--steps',
456+
'[{"command":"fill","input":{"target":{"kind":"ref","ref":"@e3"},"text":"x","textStdin":false}}]',
457+
]);
458+
459+
assert.equal(result.calls.length, 1);
460+
assert.doesNotMatch(result.stderr, /textStdin is not supported/);
461+
});

‎src/commands/batch/projection.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ function assertNoStdinFillStep(
8282
input: CommandInput,
8383
stepNumber: number,
8484
): void {
85-
if (command !== PUBLIC_COMMANDS.fill || input.textStdin === undefined) return;
85+
if (command !== PUBLIC_COMMANDS.fill || input.textStdin !== true) return;
8686
throw new AppError(
8787
'INVALID_ARGS',
8888
`Batch step ${stepNumber}: fill textStdin is not supported because batch steps have no stdin of their own. Run that fill on its own with --text-stdin.`,

‎src/commands/interaction/fill-text-stdin.test.ts‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,20 +38,38 @@ test('accepts input exactly at the byte limit', async () => {
3838
expect(await readFillTextFromStdin(pipe(text))).toBe(text);
3939
});
4040

41+
test('keeps a leading byte order mark', async () => {
42+
expect(await readFillTextFromStdin(pipe(Buffer.from([0xef, 0xbb, 0xbf, 0x61])))).toBe('\uFEFFa');
43+
});
44+
4145
test.each([
42-
['a terminal', () => terminal(), 'fill_text_stdin_tty'],
43-
['empty input', () => pipe(), 'fill_text_stdin_empty'],
44-
['input that is only a newline', () => pipe('\n'), 'fill_text_stdin_empty'],
46+
['a terminal', () => terminal(), 'fill_text_stdin_tty', undefined],
47+
['empty input', () => pipe(), 'fill_text_stdin_empty', undefined],
48+
['input that is only a newline', () => pipe('\n'), 'fill_text_stdin_empty', undefined],
4549
[
4650
'input over the byte limit',
4751
() => pipe('s3cret'.repeat(FILL_TEXT_STDIN_MAX_BYTES)),
4852
'fill_text_stdin_too_large',
53+
's3cret',
54+
],
55+
[
56+
'invalid UTF-8 bytes',
57+
() => pipe(Buffer.from('bad-bytes-'), Buffer.from([0xff])),
58+
'fill_text_stdin_invalid_utf8',
59+
'bad-bytes-',
4960
],
50-
['invalid UTF-8', () => pipe(Buffer.from([0x73, 0x33, 0xff])), 'fill_text_stdin_invalid_utf8'],
51-
])('refuses %s with a typed reason and no echoed input', async (_name, stdin, reason) => {
61+
[
62+
'a string chunk with a lone surrogate',
63+
() => pipe('lone-surrogate-\uD800'),
64+
'fill_text_stdin_invalid_utf8',
65+
'lone-surrogate-',
66+
],
67+
])('refuses %s with a typed reason and no echoed input', async (_name, stdin, reason, input) => {
5268
const error = await readError(stdin());
5369

5470
expect(error.code).toBe('INVALID_ARGS');
5571
expect(error.details?.reason).toBe(reason);
56-
expect(JSON.stringify({ message: error.message, details: error.details })).not.toMatch(/s3cret/);
72+
if (input !== undefined) {
73+
expect(JSON.stringify({ message: error.message, details: error.details })).not.toContain(input);
74+
}
5775
});

‎src/commands/interaction/fill-text-stdin.ts‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ export const FILL_TEXT_STDIN_MAX_BYTES = 64 * 1024;
66
type StdinSource = AsyncIterable<Uint8Array | string> & { isTTY?: boolean };
77

88
/**
9-
* Reads the `fill --text-stdin` value. Exactly one trailing `\n` or `\r\n` is removed so
10-
* `echo "$X"` and `printf '%s' "$X"` send the same text; all other whitespace is kept.
11-
* The value may be a secret: no error raised here includes any of the bytes read.
9+
* Reads the `fill --text-stdin` value. Exactly one trailing `\n` or `\r\n` is removed so a value
10+
* piped with or without a final newline sends the same text; all other whitespace, including a
11+
* leading byte order mark, is kept. The value may be a secret: no error raised here includes any
12+
* of the bytes read.
1213
*/
1314
export async function readFillTextFromStdin(stdin: StdinSource): Promise<string> {
1415
if (stdin.isTTY) {
@@ -24,7 +25,7 @@ export async function readFillTextFromStdin(stdin: StdinSource): Promise<string>
2425
const chunks: Uint8Array[] = [];
2526
let byteLength = 0;
2627
for await (const chunk of stdin) {
27-
const bytes = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk;
28+
const bytes = typeof chunk === 'string' ? encodeWellFormed(chunk) : chunk;
2829
byteLength += bytes.byteLength;
2930
if (byteLength > FILL_TEXT_STDIN_MAX_BYTES) {
3031
throw new AppError(
@@ -45,16 +46,28 @@ export async function readFillTextFromStdin(stdin: StdinSource): Promise<string>
4546
return text;
4647
}
4748

49+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
50+
51+
/** `Buffer.from` would turn a lone surrogate into U+FFFD; refuse it like an invalid byte. */
52+
function encodeWellFormed(chunk: string): Uint8Array {
53+
if (LONE_SURROGATE.test(chunk)) throw invalidUtf8Error();
54+
return Buffer.from(chunk, 'utf8');
55+
}
56+
4857
function decodeUtf8(bytes: Uint8Array): string {
4958
try {
50-
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
59+
return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
5160
} catch {
52-
throw new AppError('INVALID_ARGS', 'fill --text-stdin input is not valid UTF-8.', {
53-
reason: 'fill_text_stdin_invalid_utf8',
54-
});
61+
throw invalidUtf8Error();
5562
}
5663
}
5764

65+
function invalidUtf8Error(): AppError {
66+
return new AppError('INVALID_ARGS', 'fill --text-stdin input is not valid UTF-8.', {
67+
reason: 'fill_text_stdin_invalid_utf8',
68+
});
69+
}
70+
5871
function stripOneTrailingNewline(text: string): string {
5972
if (text.endsWith('\r\n')) return text.slice(0, -2);
6073
if (text.endsWith('\n')) return text.slice(0, -1);

‎src/commands/interaction/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -426,7 +426,7 @@ const interactionCliSchemas = {
426426
},
427427
fill: {
428428
usageOverride:
429-
'fill <x> <y> <text> | fill <@ref|selector> <text> | fill <x> <y>|<@ref|selector> --text-stdin',
429+
'fill <x> <y> <text> | fill <@ref|selector> <text> | fill <x y|@ref|selector> --text-stdin',
430430
usageFlags: [],
431431
positionalArgs: ['targetOrX', 'yOrText', 'text?'],
432432
allowsExtraPositionals: true,

‎src/commands/interaction/interactions.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,21 @@ test('fill --text-stdin reads only the target and leaves the text to stdin', ()
8383
}
8484
});
8585

86+
test('fill --text-stdin refuses an unreadable target without echoing it', () => {
87+
for (const positionals of [['stray-secret'], ['e3stray'], ['stray secret words']]) {
88+
let caught: unknown;
89+
try {
90+
interactionCliReaders.fill(positionals, { ...BASE_FLAGS, textStdin: true });
91+
} catch (error) {
92+
caught = error;
93+
}
94+
expect(caught, positionals.join(' ')).toBeInstanceOf(AppError);
95+
const error = caught as AppError;
96+
expect(error.details?.reason).toBe('fill_text_stdin_target_invalid');
97+
expect(JSON.stringify({ message: error.message, details: error.details })).not.toMatch(/stray/);
98+
}
99+
});
100+
86101
test('fill --text-stdin refuses a text argument without echoing it', () => {
87102
for (const positionals of [
88103
['@e57', 'argv-secret'],

‎src/commands/interaction/interactions.ts‎

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -84,18 +84,9 @@ export const interactionCliReaders = {
8484
delayMs: flags.delayMs,
8585
}),
8686
fill: (positionals, flags) => {
87-
const decoded = readFillTargetFromPositionals(positionals);
88-
if (flags.textStdin && decoded.text !== undefined) {
89-
// The message names neither source: the positional may already be the secret.
90-
throw new AppError(
91-
'INVALID_ARGS',
92-
'fill --text-stdin reads the text from stdin, so it cannot also take a text argument.',
93-
{
94-
reason: 'fill_text_source_conflict',
95-
hint: 'Pass only the target, for example: printf %s "$PASSWORD" | agent-device fill @e3 --text-stdin. Quote a multi-word selector so no part of it is read as text.',
96-
},
97-
);
98-
}
87+
const decoded = flags.textStdin
88+
? readStdinFillTarget(positionals)
89+
: readFillTargetFromPositionals(positionals);
9990
return {
10091
...commonInputFromFlags(flags),
10192
...selectorSnapshotInputFromFlags(flags),
@@ -166,6 +157,36 @@ export const interactionDaemonWriters = {
166157
]),
167158
} satisfies Record<string, DaemonWriter>;
168159

160+
/**
161+
* Reads only the target for `fill --text-stdin`. A stray positional may be the secret itself
162+
* (`fill --text-stdin "$PASSWORD"`), so neither a parse failure nor the conflict echoes any of them.
163+
*/
164+
function readStdinFillTarget(
165+
positionals: string[],
166+
): ReturnType<typeof readFillTargetFromPositionals> {
167+
let decoded: ReturnType<typeof readFillTargetFromPositionals>;
168+
try {
169+
decoded = readFillTargetFromPositionals(positionals);
170+
} catch (error) {
171+
if (!(error instanceof AppError)) throw error;
172+
throw new AppError('INVALID_ARGS', 'fill --text-stdin could not read the target.', {
173+
reason: 'fill_text_stdin_target_invalid',
174+
hint: 'Pass only the target: an @ref, a quoted selector, or x y coordinates. The text itself goes to stdin.',
175+
});
176+
}
177+
if (decoded.text !== undefined) {
178+
throw new AppError(
179+
'INVALID_ARGS',
180+
'fill --text-stdin reads the text from stdin, so it cannot also take a text argument.',
181+
{
182+
reason: 'fill_text_source_conflict',
183+
hint: 'Pass only the target, for example: printf %s "$PASSWORD" | agent-device fill @e3 --text-stdin. Quote a multi-word selector so no part of it is read as text.',
184+
},
185+
);
186+
}
187+
return decoded;
188+
}
189+
169190
function readLongPressTargetFromPositionals(positionals: string[]): LongPressOptions {
170191
const targetPositionals = readLongPressTargetPositionals(positionals);
171192
return {

‎src/commands/schema/cli-help.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ Reusable open-to-destination scripts:
180180
The live app receives the value; recording state and the published script contain only \${PASSWORD}. Reuse the same name for repeated values; --record-as is fill-only, requires an armed recording, and is mutually exclusive with --no-record. Replay with AD_VAR_PASSWORD still set, or pass --env PASSWORD=<value>. Do not record passwords/tokens without --record-as; their literal text is written to the .ad target.
181181
To keep the value out of the CLI's argv as well, pipe it in with --text-stdin instead of a text argument:
182182
printf %s "$AD_VAR_PASSWORD" | agent-device fill 'id="password"' --text-stdin --record-as PASSWORD
183-
--text-stdin reads at most 64 KiB, removes exactly one trailing newline, and refuses a terminal, empty input, or an extra text argument. While recording is armed it needs --record-as or --no-record; otherwise the step is left out of recorded actions. It is not available inside batch steps.
183+
--text-stdin reads at most 64 KiB, removes exactly one trailing newline, and refuses a terminal, empty input, invalid UTF-8, or an extra text argument. While recording is armed, --record-as publishes \${VAR} and --no-record leaves the step out; passing neither is an error. Without an armed recording the step is not recorded. It is not available inside batch steps.
184184
185185
Replay divergence and repair:
186186
A failing replay/test step returns REPLAY_DIVERGENCE with a bounded report (screen digest, ranked selector suggestions, resume). Fix app state, then resume with replay --from <n> --plan-digest <sha256> (both from the report's resume field) to continue without re-running earlier steps; resume never re-executes skipped steps, so app state there is the caller's responsibility. --from is replay-only; test rejects it. The digest binds the script, includes, effective --platform/--target, and per-action runtime/identity; native .ad interpolation is late-bound so changing only its values keeps the digest, while Maestro environment substitution can change it.

‎src/daemon/__tests__/session-action-recorder.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -787,7 +787,7 @@ test('a stdin fill without --record-as never enters recording state', () => {
787787

788788
expect(action).toBeUndefined();
789789
expect(session.actions).toHaveLength(0);
790-
expect(JSON.stringify(session)).not.toContain(secret);
790+
expect(session.recordedFillLiterals).toBeUndefined();
791791
});
792792

793793
test('a stdin fill with --record-as is recorded as its ${VAR} placeholder', () => {

‎website/docs/docs/commands.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,7 @@ agent-device gesture transform 200 420 80 -40 2 35 700 # combined pan, zoom, and
465465
```
466466
467467
`fill` clears then types. `type` does not clear.
468-
`fill --text-stdin` reads the text from stdin instead of a text argument, so a secret never appears in the CLI's process arguments. It reads at most 64 KiB, removes exactly one trailing newline (`\n` or `\r\n`, so `echo` and `printf %s` send the same value), and refuses a terminal, empty input, or an additional text argument. The value is registered as sensitive for diagnostics. While script recording is armed, combine it with `--record-as <VAR>` to publish `${VAR}`, or `--no-record` to leave the step out; otherwise the step is not recorded. Batch steps do not accept it.
468+
`fill --text-stdin` reads the text from stdin instead of a text argument, so a secret never appears in the CLI's process arguments. Pipe the value with `printf %s`, which sends it verbatim. It reads at most 64 KiB, removes exactly one trailing newline (`\n` or `\r\n`), and refuses a terminal, empty input, invalid UTF-8, or an additional text argument. The value is registered as sensitive for diagnostics. While script recording is armed, combine it with `--record-as <VAR>` to publish `${VAR}`, or `--no-record` to leave the step out; passing neither is an error. Without an armed recording the step is not recorded. Batch steps do not accept it.
469469
When an interaction fails, see [Retry after a failed command](#retry-after-a-failed-command) before you retry.
470470
`type` accepts text only. Do not pass `@ref` to `type`; use `fill @ref "text"` to target a field directly, or `press @ref` then `type "text"` to append in the focused field.
471471
If `type` reports `TEXT_INPUT_NOT_FOCUSED`, focus a visible text input and retry; when accessibility does not expose the input, use a coordinate focus command before typing.

0 commit comments

Comments
 (0)