Files
sanderling/internal/runner/redaction_test.go
T
pj 9ab59365b2 redact passwords only, and say what each step did in the log (#93)
* feat(hierarchy): name the route a native tree shows

The screen name was web-only: the Chrome driver stamps sanderling-screen on
the root and nothing else does, so every Android and iOS step recorded and
logged an empty screen. The route marker the tree already carries (the
resource id ending in Screen, the same one Transitional counts) names it.

* feat(runner): say what each step did in the step log

One line per step carried only an index and a node count. It now names the
screen, the action, its target and the typed value, the last through the
same redaction the trace and the prompt use. Emitted after the apply so the
line reports what actually happened, skip reason included.

* fix(sidecar): state on android whether a field is a secure entry

maestro's tree mapper copies a fixed attribute list off the device's XML and
password is not on it, so no android element ever reported the fact and the
conservative rule downstream redacted every typed value in the trace, the
prompt and the log. The XML still carries it: re-read it once per settled
snapshot and state the fact on the text fields it matches. A field it cannot
match stays unstated, which still reads as a credential.

* docs: correct the record that android never reports a secure field

Four places said android reports the fact for nothing and that every typed
value there is redacted. The sidecar now states it, so they described the
old behaviour.

* test(sidecar): fail the build if maestro renames the call the fact comes from

* fix(sidecar): state the fact on a field named by its hint alone

collectTextFields matched on class only, so a node the go side calls editable
off its hintText was left unstated and its typed value redacted.

* docs: record that ios and web state secure:false for compose password fields

Both derive the fact from a widget type a compose app never has, so the
value reaches the trace in the clear. Verified on folio on both targets.

* feat(android): read the application id out of an apk

parses the compiled AndroidManifest.xml rather than shelling out to
aapt2, which lives in the versioned build-tools directory that hosts
with only platform-tools never install.

Claude-Session: https://claude.ai/code/session_012PVErdr3ZzyUASeVQDWsUc

* feat(cli): let --android-app-path supply the bundle id

--bundle-id stays required everywhere else, and an explicit one still
wins, so the apk can never quietly override what was asked for.

Claude-Session: https://claude.ai/code/session_012PVErdr3ZzyUASeVQDWsUc

* docs: record that the apk can name the package itself

Claude-Session: https://claude.ai/code/session_012PVErdr3ZzyUASeVQDWsUc

* fix(testrun): pass the jvm the flag that silences the jdk 24 unsafe warning

* feat(folio): ask which android device to run on when none is named

* feat(folio): pin ios recipes to one simulator udid and ask when several match

* docs(ci): say how just ios lands on the simulator the boot step chose

* chore(folio): ignore run output anywhere under examples/folio

* refactor(hierarchy): name no screen for a tree Transitional calls a cross-fade

ScreenName kept its own reading of the route markers and disagreed with
Transitional on a marker repeated by a nested node: it named the screen on
a step the runner was skipping as unsettled. One reading now.

* refactor(sidecar): inline the one attempt passed to callViewHierarchy

A named constant and its own comment for a literal used once.

* test(sidecar): compare the whole tree when checking the annotation changes nothing else

The old assertions checked one id string and one bounds value, and passed
with every other attribute stripped off every node. Now the annotated tree
minus the two facts it stated must equal the input.

* fix(sidecar): match a field to its xml node by class as well as id and bounds

A wrapper drawn to the same bounds as the untagged field inside it shared
the field's key, both were dropped as ambiguous, and every value typed into
an untagged field was redacted. The class tells them apart.

* docs(runs): record what the android hierarchy re-read costs per step

Two 1m runs per binary on folio, same seed, before and after the re-read.
2026-09-05 23:54:12 +05:30

244 lines
9.3 KiB
Go

package runner
import (
"context"
"fmt"
"strings"
"testing"
"time"
mockdriver "github.com/priyanshujain/sanderling/internal/driver/mock"
"github.com/priyanshujain/sanderling/internal/verifier"
)
// typedCredential stands in for what a login setup types. Nothing the run
// records or sends may carry it.
const typedCredential = "fixture-passphrase-9f21"
// iosLoginTreeJSON is the shape internal/driver/ioscompanion produces for a
// login form: every editable field reports `secure`, so a field carrying
// `secure:false` is positively known not to be a credential entry.
const iosLoginTreeJSON = `{
"attributes": {"bounds": "[0,0,390,844]", "class": "Window"},
"children": [
{"attributes": {"identifier": "LoginEmail", "class": "TextField", "hintText": "Email", "bounds": "[20,200,370,244]"},
"editable": true, "enabled": true, "secure": false, "children": []},
{"attributes": {"identifier": "LoginPassword", "class": "SecureTextField", "hintText": "Password", "bounds": "[20,260,370,304]"},
"editable": true, "enabled": true, "secure": true, "children": []}
]
}`
// webLoginTreeJSON is the same form as the chrome dump reports it.
const webLoginTreeJSON = `{
"attributes": {"bounds": "[0,0,1280,800]", "tag": "html"},
"children": [
{"attributes": {"resource-id": "login-email", "tag": "input", "hintText": "Email", "bounds": "[20,200,400,240]"},
"editable": true, "enabled": true, "secure": false, "children": []},
{"attributes": {"resource-id": "login-password", "tag": "input", "hintText": "Password", "bounds": "[20,260,400,300]"},
"editable": true, "enabled": true, "secure": true, "children": []}
]
}`
// androidLoginTreeJSON is the same form with the fact missing from both fields,
// which is what an Android tree looks like when the sidecar could not match its
// text fields against the device's own view hierarchy.
const androidLoginTreeJSON = `{
"attributes": {"bounds": "[0,0,1080,2340]"},
"children": [
{"attributes": {"resource-id": "login_email", "class": "android.widget.EditText", "hintText": "Email", "bounds": "[20,200,1060,300]"},
"enabled": true, "children": []},
{"attributes": {"resource-id": "login_password", "class": "android.widget.EditText", "hintText": "Password", "bounds": "[20,320,1060,420]"},
"enabled": true, "children": []}
]
}`
func typeInto(selector string) verifier.Action {
return verifier.Action{Kind: verifier.ActionKindInputText, On: selector, Text: typedCredential}
}
func TestTraceActionForRedactsTypedValuesTheTargetCannotClear(t *testing.T) {
for _, testCase := range []struct {
name string
treeJSON string
selector string
}{
{"ios secure field", iosLoginTreeJSON, "id:LoginPassword"},
{"web secure field", webLoginTreeJSON, "id:login-password"},
{"field reported as neither", androidLoginTreeJSON, "id:login_email"},
{"password field reported as neither", androidLoginTreeJSON, "id:login_password"},
} {
t.Run(testCase.name, func(t *testing.T) {
traceAction := traceActionFor(typeInto(testCase.selector), mustParseTree(t, testCase.treeJSON))
if traceAction.Text != verifier.RedactedInputText {
t.Errorf("trace action text = %q, want it redacted", traceAction.Text)
}
if traceAction.Selector != testCase.selector {
t.Errorf("trace selector = %q, want %q so the record still shows which field was typed into",
traceAction.Selector, testCase.selector)
}
})
}
}
func TestTraceActionForKeepsTypedValuesForAFieldReportedNotSecure(t *testing.T) {
for _, testCase := range []struct {
name string
treeJSON string
selector string
}{
{"ios", iosLoginTreeJSON, "id:LoginEmail"},
{"web", webLoginTreeJSON, "id:login-email"},
} {
t.Run(testCase.name, func(t *testing.T) {
const address = "[email protected]"
action := verifier.Action{Kind: verifier.ActionKindInputText, On: testCase.selector, Text: address}
traceAction := traceActionFor(action, mustParseTree(t, testCase.treeJSON))
if traceAction.Text != address {
t.Errorf("trace action text = %q, want the typed value on a field reported not secure", traceAction.Text)
}
})
}
}
// The redaction is a rendering rule, never a change to what is dispatched: the
// app has to receive the keystrokes a user would have produced.
func TestApplyActionTypesTheRealValueIntoEveryField(t *testing.T) {
for _, testCase := range []struct {
name string
treeJSON string
selector string
}{
{"ios secure field", iosLoginTreeJSON, "id:LoginPassword"},
{"web secure field", webLoginTreeJSON, "id:login-password"},
{"android field", androidLoginTreeJSON, "id:login_password"},
} {
t.Run(testCase.name, func(t *testing.T) {
fastFocusSettle(t)
driverMock := mockdriver.New()
mustDispatch(t, driverMock, typeInto(testCase.selector), mustParseTree(t, testCase.treeJSON))
typed := ""
for _, recorded := range driverMock.Actions() {
if recorded.Kind == mockdriver.ActionInputText {
typed = recorded.Text
}
}
if typed != typedCredential {
t.Errorf("driver received %q, want the real typed value", typed)
}
})
}
}
// lastActionExtractorSpec is what examples/folio/sanderling/spec.ts does with
// the previous step's action: it extracts state.lastAction whole. Extractor
// values are written to the trace as extractor_changes, so a typed value that
// reaches state.lastAction.text lands in the run directory through the spec
// rather than through the runner.
const lastActionExtractorSpec = `
import { actions, always, extract, InputText } from "@sanderling/spec";
const reported = extract("lastAction", state => state.lastAction);
globalThis.properties = {
theActionReachesTheSpec: always(() => reported.current !== undefined),
};
globalThis.actions = actions(() => [InputText({ into: "%s", text: "` + typedCredential + `" })]);
`
func TestTheTraceNeverCarriesATypedSecretThroughALastActionExtractor(t *testing.T) {
for _, testCase := range []struct {
name string
treeJSON string
selector string
}{
{"ios secure field", iosLoginTreeJSON, "id:LoginPassword"},
{"web secure field", webLoginTreeJSON, "id:login-password"},
{"android field reported as neither", androidLoginTreeJSON, "id:login_email"},
} {
t.Run(testCase.name, func(t *testing.T) {
fastFocusSettle(t)
state := newHarnessWithSpec(t, fmt.Sprintf(lastActionExtractorSpec, testCase.selector))
state.mock.HierarchyJSON = testCase.treeJSON
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := Run(ctx, Options{
Duration: time.Hour,
IdleTimeout: 20 * time.Millisecond,
MaxSteps: 2,
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
}); err != nil {
t.Fatalf("Run: %v", err)
}
steps := traceSteps(t, state.writer.Directory())
if len(steps) < 2 {
t.Fatalf("trace holds %d step(s), want the step that reports the action back", len(steps))
}
change, ok := steps[1].ExtractorChanges["lastAction"]
if !ok {
t.Fatalf("step 2 recorded no reading of state.lastAction: %+v", steps[1])
}
if strings.Contains(string(change.Curr), typedCredential) {
t.Errorf("the trace carries the typed value through state.lastAction: %s", change.Curr)
}
if !strings.Contains(string(change.Curr), verifier.RedactedInputText) {
t.Errorf("state.lastAction reported %s, want the typed value redacted in place",
change.Curr)
}
})
}
}
const secureLoginSpec = `
import { llm, always, actions, InputText, taps, typing, weighted } from "@sanderling/spec";
globalThis.properties = { ok: always(() => true) };
globalThis.setup = actions(() => {
if (globalThis.__typed) return [];
globalThis.__typed = true;
return [InputText({ into: "id:LoginPassword", text: "` + typedCredential + `" })];
});
globalThis.actions = weighted([1, taps], [1, typing]);
globalThis.generator = llm({ model: "test/model" });
`
// The recorded call is llm-calls.jsonl: its user prompt carries the
// recent-action memory and its candidates carry the numbered list, which is
// where a login run put the account password in cleartext beside a screenshot
// of the same screen.
func TestLLMCallRecordKeepsTypedSecretsOutOfThePromptAndCandidates(t *testing.T) {
fake := newFakeOpenRouter(t)
source, verifierInstance := newLLMSourceWithSpec(t, fake, secureLoginSpec)
pushSnapshotTree(t, verifierInstance, iosLoginTreeJSON)
action, err := source.NextAction(context.Background(), 0)
if err != nil {
t.Fatalf("setup NextAction: %v", err)
}
if action.Text != typedCredential {
t.Fatalf("setup action text = %q, want the real value dispatched to the app", action.Text)
}
pushSnapshotTree(t, verifierInstance, iosLoginTreeJSON)
fake.choice = 1
fake.chosenAction = mustCandidates(t, verifierInstance, verifier.LabelSourceVisibleText)[0].Description
if _, err := source.NextAction(context.Background(), 1); err != nil {
t.Fatalf("llm NextAction: %v", err)
}
call := lastCall(t, source)
if strings.Contains(call.UserPrompt, typedCredential) {
t.Error("the recorded user prompt carries the typed value")
}
if !strings.Contains(call.UserPrompt, "InputText") {
t.Errorf("the recorded user prompt lost the recent-action memory entirely: %q", call.UserPrompt)
}
for _, candidate := range call.Candidates {
if strings.Contains(candidate.Description, typedCredential) {
t.Errorf("recorded candidate %d carries the typed value: %q", candidate.Index, candidate.Description)
}
}
}