Skip to content

feat(fill): read text from stdin with --text-stdin - #3351

Open
Metehan-Bicer wants to merge 6 commits into
callstack:mainfrom
Metehan-Bicer:feat/fill-text-stdin
Open

Metehan-Bicer wants to merge 6 commits into
callstack:mainfrom
Metehan-Bicer:feat/fill-text-stdin

Conversation

@Metehan-Bicer

@Metehan-Bicer Metehan-Bicer commented Oct 9, 2026 •

Copy link
Copy Markdown

Summary

fill can now take its text from stdin, so a secret never appears in the CLI's argv:

printf %s "$PASSWORD" | agent-device fill @e3 --text-stdin
printf %s "$PASSWORD" | agent-device fill 'id="password"' --text-stdin --record-as PASSWORD
  • Reads at most 64 KiB and strips exactly one trailing \n or \r\n. A text argument, a TTY, empty input and invalid UTF-8 are INVALID_ARGS with typed reasons (fill_text_source_conflict, fill_text_stdin_*); no message echoes the input.
  • Uses the existing sensitivity contract: the value is registered as a diagnostics secret in the CLI and daemon scopes, and stays out of session.actions unless --record-as parameterizes it. While recording is armed, --text-stdin needs --record-as or --no-record (fill_text_stdin_unparameterized_recording). Happy to switch this to recording the step without its text if you prefer.
  • Batch steps, MCP tools and env/config defaults cannot set it.

25 files; scope stays within the fill family. Closes #3260.

Validation

Tested at 9d2bc90 (includes the review fixes): pnpm check:affected --run passed (all runnable checks; device and toolchain lanes are GitHub-authoritative). CI pending.

Live run on an iPhone 16 simulator (iOS 18.6, Settings search field): stdin fill succeeded; an armed recording refused it without --record-as and published ${PASSWORD} with it. The value was absent from the daemon log, session events and the .ad script. It still appears in the native XCTest session log, which is #3261 and out of scope here.

View guided diff

fill can take its text from stdin instead of argv so a secret never
appears in the CLI's process arguments. The read is bounded to 64 KiB
and strips exactly one trailing LF or CRLF; a text argument, a terminal,
empty input and invalid UTF-8 are refused with typed reasons that never
echo the input.

The value goes through the existing sensitivity contract: it is
registered as a diagnostics secret in the CLI and daemon scopes, and the
daemon keeps it out of recorded actions unless --record-as parameterizes
it. While recording is armed the flag needs --record-as or --no-record.
Batch steps, MCP tools and env/config defaults cannot set it.

Closes callstack#3260

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 25 files

Reply to a comment to ask cubic a question or push back. It learns from your replies.

View guided diff | Re-trigger cubic

Comment thread src/commands/interaction/interactions.ts Outdated
Comment thread website/docs/docs/commands.md Outdated
Comment thread src/commands/schema/cli-help.ts Outdated
Comment thread src/commands/interaction/fill-text-stdin.ts Outdated
Comment thread src/commands/interaction/fill-text-stdin.test.ts Outdated
Comment thread website/docs/docs/commands.md Outdated
Comment thread src/daemon/__tests__/session-action-recorder.test.ts Outdated
Comment thread src/commands/interaction/fill-text-stdin.ts Outdated
Comment thread src/commands/interaction/index.ts Outdated
Comment thread src/commands/batch/projection.ts Outdated
- 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.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 10 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

Comment thread src/commands/interaction/fill-text-stdin.ts Outdated
Comment thread src/commands/interaction/fill-text-stdin.ts Outdated
- A string stream may split a surrogate pair across chunks, and the
  per-chunk lone-surrogate check refused valid input. A trailing high
  surrogate is now held back until the next string chunk; a byte chunk
  or the end of input still refuses it as invalid UTF-8.
- Size a string chunk with Buffer.byteLength before encoding it, so an
  oversized chunk is refused without being copied first.
@thymikee

thymikee commented Oct 9, 2026

Copy link
Copy Markdown
Member

fill --text-stdin still returns the literal text in its result unless --record-as is set. This is a problem in 0964704. --record-as needs an armed recording, so an unrecorded stdin fill, or an armed one using --no-record, gets extra: { text: params.text } back from interaction-touch-fill.ts:234. parameterizeFillPayloads at interaction-common.ts:100 only redacts when recordAs is a string. So printf %s "$PW" | agent-device fill @e3 --text-stdin --json prints "text":"<secret>" on stdout, and the Node client result has the same field. The secret stays out of argv but lands in the output the caller reads, and #3260 asks for input that is not echoed in results. The rule to enforce is that a fill whose text is marked sensitive (recordAs set or textStdin true) never returns its literal on any surface: response data, result, recorded action or diagnostics. Please define that predicate once in interaction-recorded-input.ts. Have parameterizeFillPayloads, registerParameterizedFillDiagnosticValue (request-router.ts:502) and the recorder skip (session-action-recorder.ts:63) all call it. When there is no recordAs, redact text to a fixed marker and drop value-carrying selectorChain candidates through parameterizeRecordedFillPayload. Please add a router-level test showing that a textStdin fill response contains no literal.

Would it be simpler to treat textStdin in the daemon as a sensitivity trait, not a transport detail? Three predicates (request-router.ts:502, session-action-recorder.ts:63, and interaction-common.ts:100, which ignores textStdin) each re-derive "this fill text is sensitive". One owner such as isSensitiveFillText(flags) would replace them and close the problem above without adding new code. The rest looked necessary: the reader, the batch, config and env exclusions, and the operator field.

Not blocking, and you can take or leave it: the armed-recording refusal and the extended diagnostic-registration predicate are only tested by calling the helpers directly, so one router-level test that sends an armed textStdin fill and checks for fill_text_stdin_unparameterized_recording would help, and the same test can cover the response.

Two bot threads on fill-text-stdin.ts (surrogate pairs, byte length) and one on interactions.ts (target parse errors) are fixed at this head, so you can resolve them.

CI is green and there are no conflicts. This read of 0964704 is from the code only, and I did not run the CLI or the tests. The earlier live iOS run was at 9d2bc90 and did not show the --json response contents. I also did not check whether error responses can echo the fill text on failure paths after admission, such as selector or ref errors. Before merge, the response, recorder and diagnostics need to share one sensitivity predicate. Please prove it with a router test and a live fill --text-stdin --json run whose stdout contains no part of the value.

A fill whose text is sensitive (--record-as set or --text-stdin) now
decides it through one predicate, isSensitiveFillText, for the response
payloads, the recorder skip and diagnostics registration. Without
--record-as, the response and result text read [REDACTED] and
value-carrying selector candidates are dropped, so an unrecorded or
--no-record stdin fill no longer echoes the value in --json output or
the client result.

An error leaving the router now passes through the request's registered
sensitive values, so a backend message that echoes the value after
admission is redacted as well.
@Metehan-Bicer

Copy link
Copy Markdown
Author

Thanks for the careful read. Addressed in 19d3ffd:

  • One predicate, isSensitiveFillText(flags) (recordAs set or textStdin true). It sits next to the other recorded-input helpers in @agent-device/ad-script instead of interaction-recorded-input.ts: request-router.ts and session-action-recorder.ts can't import the interaction internal tree, and all three call sites already import ad-script, so it adds no import edges. parameterizeFillPayloads, registerParameterizedFillDiagnosticValue and the recorder skip all call it.
  • Without recordAs, the response and result text become [REDACTED], and value-carrying selectorChain candidates are dropped through parameterizeRecordedFillPayload.
  • Failure paths after admission: they could echo it. A backend error like could not type "<value>" came back verbatim. The router's error exit now passes the error through the request's registered sensitive values (redactRegisteredSensitiveValues in host-kit), the same set diagnostics already redact. Success payloads keep the field-aware parameterization only.
  • request-router-fill-text-stdin.test.ts sends fills through the real request handler to a web session: an unrecorded stdin fill, an armed --no-record one, an armed one without --record-as (refused with fill_text_stdin_unparameterized_recording, nothing typed), and a backend error that echoes the value. The two leak cases and the error case fail without the change.
  • Live run on an iOS 18.6 simulator, Settings search field, at this head:
$ printf %s 'stdinLiveCheck42' | agent-device fill @e3 --text-stdin --json
{
  "success": true,
  "data": {
    "x": 197, "y": 169,
    "text": "[REDACTED]",
    "message": "Filled 16 chars",
    "targetKind": "ref", "ref": "e3", "refLabel": "Arayın",
    "selectorChain": ["role=\"searchfield\" label=\"Arayın\" editable=true", "label=\"Arayın\" editable=true", "value=\"Arayın\" editable=true"],
    ...
  }
}

No part of the value is in stdout, and a follow-up snapshot -i shows the field holding it. pnpm check:affected --run passes.

One question: message still reports the character count (Filled 16 chars). That predates this PR; should a sensitive fill leave the length out too?

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 8 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

Comment thread packages/ad-script/src/internal/recorded-input.ts
Comment thread src/daemon/__tests__/request-router-fill-text-stdin.test.ts
- recordAs and textStdin mark fill text as sensitive, but the request
  boundary only checks that flags is an object. A direct daemon request
  with textStdin: "true" was read as unmarked, so the value came back in
  data.text; recordAs: 42 failed later as an UNKNOWN TypeError. Both are
  now refused as INVALID_ARGS before any device work.
- Assert the --no-record response text is exactly [REDACTED].
@thymikee

Copy link
Copy Markdown
Member

The fixes from the earlier review at 0964704 are in at 68acc36, but one problem remains in the error path. The two earlier cubic-dev-ai threads are addressed at this commit: the malformed recordAs/textStdin refusal now runs before typing (#3351 (comment)), and the --no-record test now asserts data.text is [REDACTED] (#3351 (comment)). Please resolve both threads.

redactRegisteredSensitiveValues at https://github.com/callstack/agent-device/blob/68acc36/src/daemon/request-router.ts#L211 runs on every error response of every command. It replaces each value in the request's sensitive set across the whole error object: message, hint, details and logPath. That set is not limited to the stdin text. Maestro replay registers every inputText (daemon-runtime-port.ts#L134), and --record-as fills register their literal too. The replace is a plain substring replaceAll. So a Maestro flow with inputText: "1" that later fails, or a failing fill --record-as qty ... 1, now returns a message and a logPath with every "1" replaced by [REDACTED]. The user cannot open that path. This also changes behavior for all Maestro replays with inputText, and the PR does not list that or add a changelog or docs entry. The rule should be that an error response redacts only the text this request marked sensitive, and never rewrites daemon-owned identity fields. Please gate it on req.command === 'fill' && isSensitiveFillText(req.flags), redact only that fill's own text value, and leave logPath and diagnosticId untouched. A router test should cover a short registered value that appears in logPath, plus a non-fill (Maestro replay) error that comes back unchanged.

Not blocking, and fine to take or leave: in request-router-fill-text-stdin.test.ts the first import sits above the file's doc comment, so it can move below the header with the other imports.

CI is green, with the single reported check passing, and there are no conflicts. I read the code only and did not run the tests or a live replay. I also did not check whether nested batch steps that carry flags.textStdin get the router's per-request registration and the malformed-type refusal. That is outside this delta. Whether the "Filled N chars" message should drop the length is the maintainer's call, since #3260 only asks that the value not be echoed. Before merge, the error-response redaction needs the narrower scope described above.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support private stdin input for CLI fill without secret-bearing argv

2 participants