Commit Graph

396 Commits

Author SHA1 Message Date
Ignacy Łątka dcf182ff21 fix(installer): suppress npm stderr in getLatestVersion to avoid EBADDEVENGINES noise 2026-06-03 17:09:17 +02:00
Ignacy Łątka 74c2065ca0 fix: multiple minor bugs (#194)
## Human Summary

This PR aims to fix bugs that are benign for now, could become serious
in the future:

- multiple CSRF via CORS misconfiguration on `tool-server`
- multiple SSRF that were possible due to the CSRF
- path traversal via flow creator
- multiple shell injections
- lack of auth on `MCP/CLI <-> tool-server` path (bug details discussed
in person). After the other fixes this is somewhat redundant, but
doesn't hurt and protects against theoretical access attempts from
within other processes running on the user's device.

## AI Summary

<details>
Bundles six independent fixes that together close a CSRF cluster on the
local tool-server. The vulnerabilities chain because the HTTP API has no
auth and serves wildcard CORS, so any web origin (Firefox
unconditionally; Chrome via PNA-disabled or loopback-served) can drive
the tool-server from a cross-origin tab. Each individual fix lives on
its own branch (linked below) and stands alone — this PR exists so the
cluster can be reviewed and shipped as one piece.

### Fixes

1. **`fix(http): require auth token + drop wildcard CORS`** —
per-process 32-byte hex Bearer token, generated by the launcher,
persisted in `~/.argent/tool-server.json` with mode `0600`, required on
every route except `/preview`. Constant-time compare. Wildcard CORS
dropped. Threaded through MCP server + CLI + tools-client.
Backwards-compat: when `ARGENT_AUTH_TOKEN` is unset (e.g. `npm run
dev`), the server logs a one-shot warning and skips the gate.
2. **`fix(http): reject non-loopback Host headers`** — middleware
(before the auth gate) returns 403 for any request whose Host is not
`127.0.0.1`, `localhost`, or `::1`. Closes DNS-rebinding bypass.
3. **`fix(ios-profiler-start): prevent shell injection via device_id`**
— `detectRunningApp(udid)` no longer interpolates `params.device_id`
into `execSync` template strings. Both `xcrun simctl spawn ${udid} …`
and the piped `xcrun simctl listapps ${udid} | plutil …` are rewritten
to `execFileSync` array form, with the pipe to `plutil` happening in JS
(input option) rather than the shell.
4. **`fix(flows): validate name to close path-traversal in
getFlowPath`** — `getFlowPath()` requires `^[A-Za-z0-9_\\-]+$` and
verifies `path.relative(getFlowsDir(), filePath)` does not start with
`..`. Centralizing the check in `getFlowPath` covers every caller
(start-recording, add-step, insert-echo, finish-recording, run,
read-prerequisite) without per-tool churn.
5. **`fix(source-resolver): reject paths outside projectRoot +
non-source extensions`** — `readSourceFragment()` previously accepted
any `file` field on the SourceLocation it was given (the field traces
back to a React fiber's `_debugSource.fileName`, which is
attacker-controllable). Now: containment check via
`path.relative(projectRoot, abs)` plus an extension allowlist
(`.js/.jsx/.ts/.tsx/.mjs/.cjs/.json`).
6. **`fix(source-maps): reject non-loopback URLs (close SSRF)`** —
`doRegister()` validates `sourceMapURL` before fetching: only
`http://`/`https://` URLs whose hostname is loopback. Closes a
blind-SSRF probe of the host network (cloud metadata at
`169.254.169.254`, intranet pages, etc.).

### Individual branches

- `fix/auth-token-drop-cors`
- `fix/host-header-validation`
- `fix/profiler-command-injection`
- `fix/flow-name-validation`
- `fix/source-resolver-traversal`
- `fix/source-map-ssrf`

If you want to land them as separate PRs, the integration branch can be
dropped after each lands.

## Test plan

Unit tests (already in CI):

- [x] `npm run build` passes (workspace tsc clean)
- [x] `npm run test -w @argent/tool-server` — **510 passed (was 473 on
`main`)**, +37 new tests across:
- `test/http-auth.test.ts` — 10 tests (auth, CORS removal, dev-mode
escape hatch)
- `test/http-host-validation.test.ts` — 7 tests (loopback allow,
DNS-rebinding reject)
- `test/flows/flow-utils.test.ts` — +5 tests (path-traversal name
rejection)
- `test/metro/source-resolver.test.ts` — +5 tests (containment +
extension allowlist)
- `test/metro/source-maps-ssrf.test.ts` — 10 tests (loopback-only allow)

Live end-to-end (run locally against this branch's bundled artifacts):

- [x] Launcher spawns tool-server; `~/.argent/tool-server.json` contains
a 64-char hex token and is mode `0600`
- [x] `GET /tools` without `Authorization` → **401**
- [x] `GET /tools` with wrong token → **401**
- [x] `GET /tools` with correct token → **200** (61 tools)
- [x] Request with `Host: attacker.example` → **403**
- [x] No `Access-Control-Allow-Origin` header on responses
- [x] Cross-origin command-injection probe via `device_id=\"x;touch
/tmp/marker;#\"` does NOT create the marker file (Vuln 4 closed)
- [x] `flow-start-recording` with `name=\"../../../tmp/escape\"`
rejected with `Invalid flow name`
- [x] `flow-start-recording` with a valid name still works
- [x] `tools-client.fetchTools()` and
`tools-client.callTool('list-simulators')` succeed via the auth-token
threading (the path the MCP server uses internally)
- [x] MCP-stdio entry point (`startMcpServer`) end-to-end: `initialize`
→ `tools/list` (61 tools) → `tools/call('list-simulators')` returns
content. Confirms the launcher → token → fetch → tool-server flow is
intact for the production `argent mcp` path.

## Notes for reviewers

- `/preview` is intentionally exempt from the auth gate (it was not
gated before; tightening it deserves a separate PR).
- The `ARGENT_AUTH_TOKEN`-unset escape hatch keeps `npm run dev`
workable; production `argent mcp` always passes a token via env, so the
warning never fires there.
- The auth bundle changes the public return shape of `ensureToolsServer`
from `Promise<string>` to `Promise<{ url, token }>`. The two in-tree
callers (`argent-mcp/mcp-server.ts`,
`argent-tools-client/tools-client.ts`) were updated; external consumers
using `ARGENT_TOOLS_URL` should also set `ARGENT_AUTH_TOKEN` to match
the server.

</details>
2026-06-03 15:57:42 +02:00
Ignacy Łątka 80661fad8f feat: Linux support (#249)
This PR makes Linux work.

# AI Summary 

<details>
Brings Argent up on Linux as a host: per-platform npm bundle, renderer
selection that actually works on hybrid-GPU / Wayland / containerized
Linux, hot-boot guarding that survives the SwiftShader composite gap, a
real Linux preflight, and a Wayland-headless E2E job in CI. macOS stays
on
the path it was on before (no `-gpu` flag was passed; AVD config
decided);
nothing about the iOS Simulator behavior changes.

The diff is broader than "ship the Linux artifact" — calling it out
below
because several pieces affect macOS too and reviewers should be primed
for
that rather than skim past expecting "just packaging."

### What's actually in here

**npm packaging (the original framing — still load-bearing)**

| File | Change |
|---|---|
| `packages/argent/scripts/argent-simulator-server.cjs` (new) | Node
dispatcher exposed as the `argent-simulator-server` bin entry. Joins
`process.platform` into the bin path and execs. Forwards
SIGTERM/SIGINT/SIGHUP parent→child so non-TTY supervisors can't orphan
the binary. |
| `packages/argent/scripts/bundle-tools.cjs` | Copies per-platform
binaries from `packages/native-devtools-ios/bin/<platform>/` into
`packages/argent/bin/<platform>/`. darwin required, linux best-effort. |
| `packages/argent/package.json` | `bin.argent-simulator-server` points
at the dispatcher. |
| `packages/native-devtools-ios/src/index.ts` | Resolver joins
`process.platform` into the simulator-server bin path. iOS-only dylibs
and `ax-service` throw `"requires a macOS host"` on Linux callers so
they get the root cause, not "file not found". |
| `scripts/download-simulator-server.sh` | Downloads
`simulator-server-argent-{macos,linux}` into platform-keyed subdirs.
**Purges each platform dir before its download attempt** so a failed
download can't leave stale binaries in place from a prior run. |
| `scripts/dev.cjs` | Local dev copies the current host's binary into
`bin/<process.platform>/` to match the resolver layout. |
| `.github/workflows/publish{,-next}.yml` | Replaces single `test -x
darwin/...` with a per-platform inventory loop. darwin always required;
Linux gated by `ARGENT_REQUIRE_LINUX_BINARY=1` (flip once radon#118
lands). |

**Linux runtime — emulator boot path**

| File | Change |
|---|---|
| `packages/tool-server/src/tools/devices/boot-device.ts` |
`selectGpuMode()`: Linux defaults to `-gpu swiftshader` (`auto` lands on
lavapipe with ~10× cold-boot slowdown; `-gpu host` produces silent
black-window failures on Optimus / NVIDIA+libglvnd / Wayland-on-hybrid /
containers). macOS keeps `auto` (ANGLE→Metal).
`ARGENT_EMULATOR_GPU_MODE` overrides, **validated against `emulator
-help-gpu`'s whitelist** so a typoed value fails fast instead of burning
the whole boot budget. `ARGENT_EMULATOR_NO_WINDOW=1` appends
`-no-window` for headless contexts (CI / containers / Wayland sessions
with no Qt wayland plugin). |
| `packages/tool-server/src/tools/devices/boot-device.ts` (cont.) |
**Cold-boot:** `awaitFirstRealFrame` polls SwiftShader's lockscreen
composite (PNG byte-count via `screencap -p`); blocks `booted:true`
until a real frame shows up. Without this, callers chaining `boot-device
→ screenshot` get an all-black PNG because `sys.boot_completed=1` fires
5–60 s before the first composite. |
| `packages/tool-server/src/tools/devices/boot-device.ts` (cont.) |
**Hot-boot:** `assertScreencapAlive` polls within an 8 s budget instead
of single-probing. The earlier single-probe caught the sticky-blank
state but also killed the emulator on every transient snapshot-restore
blank, forcing "every boot is cold" cycles. Sticky vs transient is
discriminated by whether the budget exhausts. |
| `packages/tool-server/src/utils/linux-preflight.ts` (new) |
`linuxBootDiagnostics()` checks `/dev/kvm` access. `diagnoseAvdSizing()`
warns when a target AVD's `hw.ramSize`/`vm.heapSize` is below the floor
an aggressive RN/Expo app needs (2G/228M avdmanager defaults wedge under
load). |
| `packages/tool-server/src/utils/android-binary.ts` |
`defaultAndroidRoots()` probes typical SDK install locations when
`ANDROID_HOME`/`ANDROID_SDK_ROOT` are unset — **cross-platform**,
benefits macOS launches from GUI-launched MCP hosts (Claude Code Desktop
etc.) where shell rc env doesn't propagate. |

**Cross-platform fixes that landed alongside**

| File | Change |
|---|---|
| `packages/argent-installer/src/utils.ts` | `getLatestVersion()` now
redirects `npm view`'s stderr to `ignore` so `EBADDEVENGINES` warnings
from a consumer project's `package.json` (e.g. Bluesky's pnpm pin) don't
leak to the user's terminal. **Cross-platform fix**, not Linux-specific.
|

**CI**

| File | Change |
|---|---|
| `.github/workflows/wayland-e2e.yml` (new) | Boots an AVD through the
tool-server under headless Weston; asserts `hw.gpu.mode = swiftshader`
and that screenshots + gesture-tap work without a display. Currently
runs on `pull_request` for the affected paths + `workflow_dispatch`.
Pinned to `radon-daily` for the simulator-server until radon#118 ships a
Linux asset to `radon-main`. |

### Tests added in this PR

- `packages/argent/test/dispatcher.test.ts` — e2e against tmpdir-staged
dispatcher + fake `<platform>/simulator-server`: missing-binary error
path, exit-code passthrough, argv passthrough, SIGTERM forwarding.
- `packages/tool-server/test/native-devtools-ios-resolver.test.ts` —
`requireDarwin` gates for dylibs and `ax-service` on Linux, per-platform
`simulator-server` path resolution, missing-binary error,
`ARGENT_NATIVE_DEVTOOLS_DIR` override.
- `packages/tool-server/test/linux-preflight.test.ts` — KVM diagnostic
shape + `diagnoseAvdSizing` (ram low, heap low, both, neither, missing
keys).
- `packages/tool-server/test/boot-device-hotboot.test.ts` — extended
with `it.each([linux→swiftshader, darwin→auto])` so a single CI runner
exercises both GPU branches (was runner-platform-dependent, regressions
could pass on both runners individually). New cases for the wedged-frame
fallback (under fake timers because the polling budget is short but
real-time enough to exceed vitest's 5 s default), the GPU-mode whitelist
rejection, and the cold-boot composite-wait timeout.
- `packages/tool-server/test/android-binary.test.ts` — extended for
`defaultAndroidRoots` probe ordering and HOME pinning.

### Verification

- `tsc --build`: clean apart from pre-existing
`@argent/native-devtools-android` import errors unrelated to this
branch.
- `vitest run` in `packages/tool-server` and `packages/argent`: 689 + 11
= 700 tests passing; the 4 pre-existing test-file load failures
(native-devtools-android missing) reproduce on `main` and are out of
scope.
- `npm pack -w @swmansion/argent`: produces `swmansion-argent-0.8.0.tgz`
with the per-platform layout.
- Wayland-e2e: triggered on this branch — link will be in a PR comment
once the run completes.
- Manually verified end-to-end on a Wayland-on-hybrid-GPU developer
laptop: AVD boots through the tool-server with `-gpu swiftshader`,
screencap returns real frames, gesture tools work.

### Dependencies / what still gates this

- **radon#118** must land before the Linux artifact ships through the
default channel. The download script tolerates the asset being absent
today (best-effort), so this PR is technically safe to merge before —
Linux installs just won't get a working `simulator-server` until the
upstream artifact is published. Once it lands:
  - Drop the `radon-daily` arg in `.github/workflows/wayland-e2e.yml`.
  - Flip `ARGENT_REQUIRE_LINUX_BINARY` to `1` in both publish workflows.
- **wayland-e2e is not a required status check** today (no branch
protection on `main` requires it). Worth turning on before merging
anything else that touches the Linux boot path.

### Related

- radon#118 — produces the `simulator-server-argent-linux` artifact this
PR consumes.
- argent-private#11 — cross-builds the iOS Simulator dylibs on Linux.
Strictly orthogonal: those dylibs are macOS-runtime-only regardless of
where they're built, and this PR keeps them under `dylibs/` with a clean
error path on Linux callers.

</details>

Fixes #246
2026-06-03 15:55:39 +02:00
filip131311 aed3978c10 fix(update): only refresh editors that have argent configured (#241)
## Summary

- `argent update` was probing the filesystem for editor dirs (`.gemini`,
`.cursor`, ...) and writing MCP entries, allowlists, rules, and agents
into every detected editor — including ones the user explicitly
unchecked during `init`.
- Switch update to read the actual config file for each adapter and only
refresh scopes where an argent MCP entry already exists. Editors without
one are skipped end-to-end: no MCP write, no allowlist, no rules/agents
copy.
- Adds `hasArgentEntry(configPath)` to every adapter and a
`findConfiguredAdapterScopes()` helper that drives the new branch.

Closes #195

## User experience & product implications

**Before:** running `npx @swmansion/argent update` could resurrect
editors the user had explicitly unchecked during `init`. For example, a
Claude Code-only user with `~/.gemini` on disk would find
`.gemini/settings.json`, `~/.gemini/rules/`, and `~/.gemini/agents/`
populated after every update.

**After:** update only touches editors where argent's MCP entry is
already present in that scope's config. The `init` choice is now
authoritative across runs.

**Alternatives considered:**
- *Persist the init selection in a side file.* Heavier — adds a new
state file, migration story for existing users, and a recovery story
when the file is missing. The MCP entry itself is already a perfect
record of "the user opted into this editor in this scope," so we use
that.
- *Keep `detectAdapters()` but invert the gemini/etc detection rule.*
Same root cause (treating editor-dir presence as consent); just shifts
the bug to other adapters.

**Backwards compatibility / schema changes:**
- No tool input/output schema changes. The interface change is internal
to the installer package.
- New `hasArgentEntry` method on `McpConfigAdapter` is required (not
optional). External consumers of `McpConfigAdapter` would need to
implement it, but the interface isn't part of the public package API.
- For an existing user who runs `update` *and has never run `init`*,
nothing happens to their editor configs (correct: nothing to refresh
until they opt in). They'll still get the global package version bump
and the skills lockfile refresh.

**Follow-ups this unlocks:**
- Same predicate could power a future `argent status` command that
summarizes which editors are configured.
- Opens the door to a `--add-editor <name>` flag on `update` for users
who want to opt new editors in without re-running `init`.

## Test plan

- [x] `npm test -w @argent/installer` — 257 tests pass, including 21 new
ones covering `hasArgentEntry` per adapter and the
`findConfiguredAdapterScopes` semantics.
- [x] `npm run build` — clean tsc build for the whole workspace.
- [x] `npx prettier --check` on touched files.
- [ ] Manual: in a workspace where `init` was run with gemini unchecked
but `~/.gemini` exists, run `argent update` and confirm
`~/.gemini/settings.json` is not modified and no `~/.gemini/rules/` or
`~/.gemini/agents/` directories are created.
- [ ] Manual: in a workspace where `init` configured Cursor + Claude
Code, run `argent update` and confirm both still get refreshed (MCP
entry, allowlist, rules, agents).
- [ ] Manual: run `argent update` in a workspace with no editor ever
configured — confirm it still performs the package upgrade and skills
refresh, just skips MCP/rules/agents.
2026-06-03 14:41:05 +02:00
Juliusz Wajgelt 800b781faf feat: make ax-service a socket client, add optional TCP connection to native tools (#270)
- flips the server-client relationship between `tool-server` and
`ax-service` launched on iOS simulators: now, `ax-service` connects to a
listener in `tool-server`
- adds an option to connect to `ax-service` / native devtools dylib via
TCP instead of UNIX sockets

Corresponds to [argent-private PR
#12](https://github.com/software-mansion/argent-private/pull/12)
2026-06-02 17:11:18 +02:00
Ignacy Łątka 4d8e3e45ed chore(bundle): purge stale Android assets and alias the Android source (#268)
## Summary

Two small follow-ups to #237's Android helper bundling.

1. **Stale-artifact purge.** `bundle-tools.cjs` purges `bin/`,
`dylibs/`, `skills/`, `rules/`, `agents/` at the top of every run, but
the two Android assets live elsewhere — `manifest.json` at the package
root and the APK inside the shared `dist/` folder — so they fall through
the cracks. A rebuild where the source APK is missing leaves the
previous manifest behind, and at runtime `helperManifest()` happily
reads it while `bundledHelperApkPath()` points at an APK that no longer
exists. Adds an explicit purge of both at startup so a clean tarball
either ships both files or neither.

2. **Android source alias.** Adds `@argent/native-devtools-android` to
the esbuild alias map so the tool-server bundle resolves from
`src/index.ts` directly, mirroring the iOS sibling. Removes the implicit
dependency on `tsc --build` having produced `dist/index.js` before
`bundle-tools.cjs` runs.

## Context

<details>

This came out of an E2E review of #237. Of the six findings flagged
there:

- H1 (missing keystore in the argent-private submodule) — **resolved on
main** by `software-mansion/argent-private@9770740` ("chore: update
.gitignore to remove Android signing keys and add debug.keystore").
- H2 (no APK in `argent-private-releases`) — **resolved on main**:
`argent-android-devtools.apk` is now present in `argent-main` and
`argent-v0.8.1`. `bash scripts/download-native-binaries.sh` succeeds
end-to-end.
- M1 (silent stale-manifest survival across builds) — **fixed by this
PR**.
- L3 (Android alias missing from esbuild map) — **fixed by this PR**.
- L2 (only `console.debug` surfaces the legacy-uiautomator fallback) —
the original author intentionally chose debug-level "without leaking
into the per-call result"; leaving as-is.
- L1 (no Java-side tests for `SnapshotInstrumentation`) — needs a test
harness in `argent-private`; out of scope here.

## Test plan
- [x] `npx prettier --check packages/argent/scripts/bundle-tools.cjs` —
clean
- [x] `npm run build` from the workspace root — `tsc --build` succeeds
- [x] `npx vitest run` in `packages/tool-server` — 718/718 pass
- [x] Repro before/after: built once with APK present, deleted source
APK, re-ran `bundle-tools.cjs` standalone — confirmed
`packages/argent/manifest.json` (210 B) and stale
`packages/argent/dist/argent-android-devtools-0.1.0.apk` are both
purged; resulting `npm pack` ships 44 files with no Android stragglers
(vs 46 with both present).
- [x] Alias check: deleted
`packages/native-devtools-android/dist/index.js` and re-ran
`bundle-tools.cjs` — bundling still succeeded (esbuild resolved from
`src/index.ts` via the new alias). With the alias removed, the same
delete causes `Failed to resolve entry for package
"@argent/native-devtools-android"` at bundle time.

</details>
2026-06-02 12:52:46 +02:00
filip131311 57dce88fbf chore: bump argent-private (Set-Cookie shared-storage fix) (#289)
Bumps the `packages/argent-private` submodule `6fe3607` → `cc22310` to
pull in
[software-mansion/argent-private#13](https://github.com/software-mansion/argent-private/pull/13).

That fix makes `NativeDevtoolsURLProtocol` copy `Set-Cookie` response
headers from the native network inspector's internal ephemeral
`NSURLSession` into `NSHTTPCookieStorage.sharedHTTPCookieStorage`, so
cookie-based sessions keep working while native network inspection is
active (mirrors radon-ide
[#122](https://github.com/software-mansion/radon/pull/122)).

The fix was validated on-device (before/after) against a real Expo/RN
app: with the unfixed dylib a freshly-issued `Set-Cookie` was dropped
and the next request sent a stale cookie; with the fix the new cookie
was propagated and echoed back on the subsequent request.
2026-06-02 11:37:38 +02:00
Ignacy Łątka c5c3f7af11 feat: feature flags (#260)
## Summary

- New top-level CLI commands `argent enable <flag>` / `argent disable
<flag>` / `argent flags`.
- Storage is a small JSON file at `~/.argent/flags.json` by default;
`--scope project` writes to `<project-root>/.argent/flags.json` instead.
- `enable` writes `true`. `disable` removes the entry at the chosen
scope (and deletes the file + empty `.argent/` when it becomes the last
flag).
- `isFlagEnabled` walks project → global, so a project entry shadows the
same key in the global file. To opt a single project out of a
globally-enabled flag, hand-edit `<project>/.argent/flags.json` to
`{"flags":{"name":false}}` — there is no CLI for an explicit override.
- The dispatcher routes the new commands to `@argent/cli` exports;
bundling needs no config changes (only `node:fs|path|os`).

## Behaviour

- `argent enable foo` → writes `{ "flags": { "foo": true } }` to global
file.
- `argent disable foo` → removes `foo` from the global file (clean round
trip with `enable`).
- `argent enable foo --scope project` → writes to
`<project-root>/.argent/flags.json`.
- `argent flags` → lists effective state with the winning scope;
`--json` for scripting.
- Project root is the nearest ancestor with `.argent`, `.git`, or
`package.json` — falls back to cwd when none found.
- POSIX `--` separator: `argent enable -- weird-name` is supported.
- Help (`--help` / `-h`), invalid `--scope`, invalid flag names, missing
arg, unknown flags all produce a clear stderr error and `exit 2`.
- Flag-name validation: `/^[a-zA-Z][a-zA-Z0-9._-]*$/`.
- Writes are atomic (tmp file + rename) so concurrent invocations cannot
tear the JSON.

## Reader API

`isFlagEnabled(name)` is exported from `@argent/cli` for future
consumers; it walks project → global and returns `false` when unset.

## Files

- `packages/argent-cli/src/flags.ts` (new) — storage + CLI handlers
- `packages/argent-cli/src/index.ts` — re-exports `enable` / `disable` /
`flags` / `isFlagEnabled` / `FlagScope`
- `packages/argent-cli/test/flags.test.ts` (new) — 41 tests
- `packages/argent-cli/vitest.config.ts` (new)
- `packages/argent-cli/package.json` — description bump
- `packages/argent/src/cli.ts` — dispatcher routes + help text
- `README.md`, `CONTRIBUTING.md` — command tables

## Test plan

- [x] `npm test --workspaces --if-present` → 1085 tests pass (41 in
`@argent/cli`).
- [x] `npm run typecheck:tests --workspaces --if-present` → clean.
- [x] `npm run build` (root `tsc --build`) → clean.
- [x] `npm run build -w @swmansion/argent` (dispatcher + esbuild
bundles) → clean.
- [x] `npx prettier --check .` → clean.
- [x] End-to-end against the built dispatcher in a tmpdir:
- `argent enable my-feature-flag` (global) writes to
`~/.argent/flags.json`.
- `argent enable proj-only --scope project` writes to
`<proj>/.argent/flags.json`.
- `argent disable my-feature-flag` removes the entry; round trip leaves
a clean tree (file + empty `.argent/` removed when no other state lives
there).
  - `argent disable foo` preserves sibling flags in the same file.
  - `argent flags` lists each flag with the winning scope.
- `argent enable --` errors cleanly; `argent enable -- name-after`
accepts the literal.
- `argent enable foo --scope project` walks up to `.git` /
`package.json` / `.argent`; stray editor configs (`.claude`, `.cursor`,
…) do NOT count as project roots.
- Error paths (`--scope bogus`, no name, `1bad-name`) exit `2` with a
clear message.
2026-06-01 23:22:10 +02:00
filip131311 dee49473c8 refactor(tool-server): remove ARGENT_DISABLE_UPDATE_NOTIFICATIONS opt-out (#282)
## What

Removes the `ARGENT_DISABLE_UPDATE_NOTIFICATIONS` environment variable
and everything tied to it: the `DISABLE_ENV` constant, the exported
`areUpdateNotificationsDisabled()` predicate, the early-return gate in
`startUpdateChecker()`, the reminder-note bullet that advertised it, and
the corresponding test.

## Why

The env var made `startUpdateChecker()` a no-op, which left
`getUpdateState()` stuck at its defaults (`updateAvailable: false`,
`installableVersion: null`). Because the `update-argent` tool reads that
shared state, **silencing the periodic reminder also blinded the
explicit, user-consented update path** — `update-argent` would report
*"Argent is already up to date"* even when a newer version existed on
npm. A user who'd quietened the nags but then explicitly asked the agent
to update was wrongly told there was nothing to do (only the CLI `npx
@swmansion/argent update` still worked).

Postponing/suppressing reminders is already handled by the time-based
`dismiss-update` tool, whose suppression persists in
`~/.argent/update-suppression.json` and does **not** disable the
underlying check — so the update state stays accurate. That makes the
env var both redundant and a footgun, so it's removed rather than worked
around.

## Behavior after this change

- The 24h update checker always runs; `getUpdateState()` reflects real
registry state.
- Reminders are still gated by `dismiss-update` (and the 30-minute
auto-suppress after each surfaced note).
- `update-argent` now sees accurate state regardless of reminder
suppression.

## Tests

- Removed the now-obsolete "does not check or notify when notifications
are disabled" test.
- `vitest run` for `update-checker`, `http-update-note`, and
`update-argent-tool` passes (42 tests); `tsc --noEmit` clean for
`tool-server`.
v0.9.0
2026-06-01 18:29:16 +02:00
filip131311 d670751db3 chore: bump all package versions to 0.9.0 (#281)
## Summary
- Bump all 11 workspace packages from `0.8.1` → `0.9.0` (`package.json`
+ `package-lock.json`)
- Includes `@argent/update-core`, which was added since the last bump
(#267)
- `@argent/ui` left at `0.0.1` (independent versioning)

## Notes
- Ran `npm install` to update `package-lock.json` — resolved cleanly
- Ran `npm run format` (prettier) — no files needed reformatting
- No `lint` script / eslint config exists in this repo, so there was
nothing to lint

## Test plan
- [ ] Verify `npm install` resolves cleanly
- [ ] Verify `argent --version` reports `0.9.0`
2026-06-01 17:23:13 +02:00
Ignacy Łątka cb8db6d4ac feat(boot-device): pass canonical launch-hardening flags (#269)
## Summary

argent's boot-device currently spawns `emulator -avd <name> -gpu auto`
plus the snapshot flags — nothing else. Five flags every MCP-driven boot
benefits from were missing; add them as `LAUNCH_HARDENING_ARGS` and
spread into the snapshot probe, hot boot, and cold boot.

### Performance (3 flags)

- `-noaudio` — guest skips pulseaudio init; saves one thread and ~50 MB
RSS.
- `-no-boot-anim` — skips the Pixel boot animation, a major CPU spike on
software-rendered GPU modes for ~30 s.
- `-netfast` — disables qemu network shaping; pure overhead for MCP use.

Measured on a 4-core i5-6300U with a 4096 MB / 228 MB-heap AVD,
warm-cache cold boot, no other workload, `-gpu swiftshader`:

| | Boot time | Δ |
|---|---|---|
| Baseline (no extras) | 66 s | — |
| + 3 perf flags | 49 s | −26% |
| + all 5 (incl. dialog suppression) | 47 s | −29% |

qemu RSS at +20 s post-boot drops ~190 MB (5%) with the perf flags.
`android-emulator-runner` (the canonical CI launcher) defaults to the
same three.

### Dialog suppression (2 flags)

- `-crash-report-mode never` — keeps emulator crashes from popping a Qt
consent dialog that blocks the next boot until a human dismisses it.
Crash dumps still land in `/tmp/android-unknown/emu-crash-*.db`; only
the modal popup is suppressed.
- `-no-metrics` — same for the metrics-collection consent dialog.
Unrelated to any argent profiler tool: those run guest-side via
Perfetto/simpleperf or via Metro CDP, none of which depend on the
emulator's host-side Google telemetry.

### Snapshot parity

`-noaudio` and `-netfast` change qemu device topology. The snapshot's
saved state has to match the runtime config the next boot uses or the
snapshot validator rejects perfectly loadable snapshots — the same class
of bug as the prior `-gpu auto` parity work that introduced
`RENDERER_ARGS`. Plumb the hardening args into the same three call sites
(probe `extraArgs`, hot spawn, cold spawn) so the probe agrees with both
boot paths.

## Test plan

- [x] Unit tests: extended `boot-device-hotboot.test.ts` with two new
cases asserting the flags reach the hot-boot and cold-boot spawn argv;
tightened the "same renderer args" parity test to also require
`-noaudio`/`-netfast` on the probe. **712 tests pass.**
- [x] Live-emulator verification on Linux: actual emulator boot with all
5 flags completes in 47 s, no flag-rejection warnings in the emulator
log.
- [ ] Confirm no regression on a macOS test host (please run locally
before merging — I can only verify Linux here).
- [ ] Confirm CI green.

Pre-existing test failure in `android-binary.test.ts` on `origin/main`
reproduces without this change — host-side `$PATH` leakage in the test
setup, not in scope for this PR.
2026-06-01 17:15:47 +02:00
filip131311 ee97c443b7 feat(tool-server): make the update reminder configurable and release-age aware (#276)
## Why

The tools-server shows an update reminder (a `note` appended to every
tool response) once a newer `@swmansion/argent` lands on npm. Two pain
points:

1. **No way to turn it off.** Suppression is in-memory only
(`dismiss-update`, 30-min auto), so it comes back on every server
restart.
2. **It nags about versions you can't install yet.** Registries with a
**minimum-release-age** (package-cooldown) security policy refuse to
install a version until it has been public for N days. Until then the
reminder repeats every day for the whole cooldown window with nothing
the user can do about it — exactly the "reminded for 5+ days" situation.

## What's in this PR

**1. A config option to disable the reminder**
- New env var **`ARGENT_DISABLE_UPDATE_NOTIFICATIONS=1`** (`"1"` or
`"true"`).
- When set, `startUpdateChecker()` is a no-op: **no registry request**
is made and **no note** is ever attached.
- The opt-out is also surfaced inside the reminder text itself, so the
agent can tell the user how to silence it permanently.

**2. Minimum-release-age awareness** (answers "detect the policy and
check the update is actually installable first")
- The checker now fetches the **npm packument**
(`registry.npmjs.org/@swmansion/argent`) instead of `/latest`, because
only the packument carries the `time` map of version → publish
timestamp.
- It detects the machine's policy and only reminds once the latest
version has **aged past** it. Notification is gated on a new
**`updateInstallable`** flag (newer **and** old enough) rather than
`updateAvailable`.
- `updateAvailable` keeps its pure version-comparison meaning; with no
policy, `updateInstallable === updateAvailable` (no behavior change for
users without a policy).

### Is detecting the user's min-age policy possible? Yes — partially.

Each package manager stores it differently, and the tools-server has no
reliable signal for *which* PM installed argent, so we probe every PM
present and take the **most restrictive** (largest) value. Erring toward
over-waiting is intentional: a slightly late reminder beats nagging
about a forbidden version.

| PM | Config key | Unit | Detect via | Min PM version |
|----|-----------|------|-----------|----------------|
| npm | `min-release-age` | days | `npm config get min-release-age` |
11.10.0 |
| pnpm | `minimumReleaseAge` | minutes | `pnpm config get
minimumReleaseAge` | 10.16 |
| yarn (berry) | `npmMinimalAgeGate` | minutes | `yarn config get
npmMinimalAgeGate` | 4.10 |
| bun | `minimumReleaseAge` (bunfig.toml `[install]`) | seconds | no
`config get` — not auto-probed | 1.3 |
| yarn classic | — (unsupported) | — | — | — |

Because bun has no `config get` for this and auto-detection is
best-effort, there's also an explicit override:

- **`ARGENT_MIN_RELEASE_AGE_DAYS`** — positive number of days; wins
outright and skips probing (useful for bun users, locked-down CI, or
pinning the value).

When a policy holds an update back, a single stderr line explains the
silence; no reminder reaches the agent.

## Conservative edge handling
- Network/registry failure → state unchanged (never crashes the server).
- Policy in effect but publish time missing/unparseable → treated as
**not** installable (stay silent rather than nag).
- PM probe errors / PM absent → treated as no policy (0).
- Probes shell out via PATH (`exec`, 3s timeout, constant commands only
— no caller input) so Windows `.cmd`/`.ps1` shims resolve.

## Tests
- `test/min-release-age.test.ts` (new): unit-level parsing, override
precedence, unit normalization, most-restrictive-wins, PM-absent.
- `test/update-checker.test.ts`: rewritten for the packument shape; new
cases for held-by-policy, aged-past-policy, unknown publish time, and
the disable flag (probe mocked so tests never spawn a real PM).
- `test/http-update-note.test.ts`: gate now keys off
`updateInstallable`; added a "available but held by policy → no note"
case.
- Full tool-server suite green (**732 tests**), `tsc --noEmit` and
`typecheck:tests` clean.

## Follow-up (not in this PR)
The env-var reference lives in the `argent-private` submodule
(`docs/environment-variables.md`), which is a separate repo — needs a
companion PR. Suggested rows for the tools-server table:

```
| `ARGENT_DISABLE_UPDATE_NOTIFICATIONS` | `"1"`/`"true"` or unset | disabled | When truthy, disables the update reminder entirely (no registry check, no note). |
| `ARGENT_MIN_RELEASE_AGE_DAYS`         | number                  | _(auto-detect)_ | Minimum age (days) a newer version must reach before it is advertised. Overrides auto-detection of the npm/pnpm/yarn minimum-release-age policy. |
```

## Open questions for review
- **Most-restrictive-across-PMs** vs. detecting a single PM — is "max
wins" the behavior we want, or should it follow `npm_config_user_agent`?
- Should an update held by policy stay fully silent (current), or emit a
softened "available, installable in ~N days" note?

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 17:15:12 +02:00
Ignacy Łątka f34ce22b9a fix(tool-server): persist update-note suppression across restarts (#273)
## Summary

`dismiss-update` (and the auto-suppression after delivering an update
note) only stored `suppressUntil` in a module-scoped variable, so the
dismissal evaporated the moment the tool-server process exited — which
the launcher does after 30 min of idleness, on machine reboot, and on
`npx @swmansion/argent update`. Users asking to silence the note for 24
h would see it reappear well within the window, contradicting the tool's
own description.

This PR persists `suppressUntil` to `~/.argent/update-suppression.json`
(alongside the existing `tool-server.json` / `tool-server.log`) and
reads it back on module init, so the dismissal survives restarts.

- `suppressUpdateNote` now writes through to disk and throws on write
failure (matches `dismiss-update`'s documented "Fails if … the
suppression state cannot be persisted").
- The HTTP auto-suppress call (after delivering a note alongside an
unrelated tool result) is wrapped in try/catch — a disk-write failure
there must not 500 the user's tool call. Worst case: the note repeats
next request.
- Missing/malformed/wrong-typed file all degrade to "no suppression" —
never crashes the server.

## Test plan

- [x] `npx vitest run test/update-checker.test.ts` — 19/19 pass (10
original + 9 new persistence tests).
- [x] `test/http-update-note.test.ts` + `test/http-tools-meta.test.ts` —
14/14 pass (mock update-checker; persistence is transparent).
- [x] Full `@argent/tool-server` suite — 726/727 pass. The single
failure (`test/android-binary.test.ts:73`, `/usr/bin/adb` resolution) is
pre-existing on `main` and unrelated.
- [x] `npm run typecheck:tests` clean, `npm run build` clean, `prettier
--check` clean.
- [x] Restart simulated via `vi.resetModules()` + re-import →
suppression loaded back from disk.
2026-06-01 15:18:12 +02:00
Hubert Gancarczyk de0dbbda66 feat(tools): implement screenshot-diff tool (#229)
## Summary

Adds a new `screenshot-diff` tool for comparing mobile screenshots
during UI QA and visual regression checks. The tool can compare two
saved PNGs, or compare one saved PNG against one live full-resolution
device capture, then returns a compact text summary and generated diff
artifacts.

## Context

Mobile UI validation often needs more than a single screenshot: small
layout, color, typography,
copy, clipping, and regression changes are easy to miss manually. This
feature gives agents a
deterministic before/after comparison path while keeping
`screenshot-diff` as supporting evidence.

## What Changed

- Registered a new `screenshot-diff` tool in the tool server.
- Supports saved PNG comparisons and one-sided live capture via
`captureBaseline` or
`captureCurrent`.
- Adds PNG diffing with fixed internal defaults, changed-region
detection, status-bar-band
ignoring, full-size diff output, and downscaled context diff output.
- Adds optional OCR/text analysis for moved, appeared, disappeared,
changed, and restyled text.
- Updates MCP content rendering to attach the downscaled context diff
image plus the text summary.

1. Capture a stable full-resolution baseline.
2. Perform an interaction or apply a UI change.
3. Compare the current screen against the baseline.
4. Review a summarized diff plus generated artifacts.
2026-06-01 15:04:25 +02:00
filip131311 05fb58552d chore: bump all package versions to 0.8.1 (#267)
## Summary
- Bump all 10 workspace packages to version `0.8.1` (`package.json` +
`package-lock.json`)
- Includes `native-devtools-android` (`0.7.1` → `0.8.1`), all others
from `0.8.0` → `0.8.1`
- `ui` package left at `0.0.1` (independent versioning)

## Test plan
- [ ] Verify `npm install` resolves cleanly
- [ ] Verify `argent --version` reports `0.8.1`
v0.8.1
2026-05-27 16:37:17 +02:00
Filip131311 c6f4995cfe chore: bump argent-private submodule to latest main 2026-05-27 16:30:52 +02:00
Ignacy Łątka 90c6081204 fix: prevent returning empty screenshots (#261)
## What broke
`toMcpContent` base64-encoded whatever bytes the simulator-server URL
fetch returned and labelled them `image/png` with no checks. A 404 on
the `/media/...` endpoint (file missing) returns an empty body — that
became `data: ""` shipped as a PNG, which the model API rejects with
"Image could not be processed". Claude Code then loops on the error and
burns the session limit (cf. anthropics/claude-code#60334). Reproduces
directly:

```
$ curl -sI http://127.0.0.1:<port>/media/missing.png
HTTP/1.1 404 Not Found
content-length: 0
```

## What changed
One helper added: `fetchPngBytes(url)` returns the bytes only if:
- `fetch` resolves (catches ECONNREFUSED / DNS / abort)
- `res.ok` is true
- the buffer starts with the 8-byte PNG signature

If any check fails, `toMcpContent` emits a single text placeholder
instead of an image block. Diff: **+103 / −27** across `content.ts` +
tests.

## E2E verification
Tested against the real MCP stdio JSON-RPC interface (not just unit
tests):

**Happy path** — real Android emulator, real tool-server:
```
[1] initialize: argent v0.5.3
[3a] screenshot returned 2 block(s)
     [0] image: mime=image/png dataLen=66968 validPngSig=true
     [1] text: Saved: /var/folders/.../media/430131000-...png
HAPPY PATH overall: PASS
```

**Failure path** — mock tool-server returning a URL pointing at a closed
port:
```
mock on :56529
returned 1 block(s)
  [0] text: (Screenshot unavailable: no valid PNG at http://127.0.0.1:1/stale.png. Take a new screenshot.)
hasImage=false, hasPlaceholder=true
FAILURE PATH: PASS
```

## Test plan
- [x] `npx vitest run` — 1048/1048 pass (5 new regression tests in
`content.test.ts` cover 404, non-PNG body, fetch-throw, and the no-url
fallback)
- [x] `npm run build` — tsc clean
- [x] `npx prettier --check` — clean
- [x] Bundled MCP server rebuilt and driven via real JSON-RPC stdio
against (a) a real Android screenshot and (b) a mock tool-server
reproducing the bug

---------

Co-authored-by: Filip131311 <contact@filipkaminski.com>
2026-05-27 16:25:08 +02:00
Ignacy Łątka 324d8c6049 fix(android): stop forcing cold boot on every emulator launch (#251)
## Summary

`hasDefaultBootSnapshot` enforced a 60s mtime-skew guard between
`snapshot.pb` and `ram.bin`. In practice the emulator updates
`snapshot.pb` metadata on every load (load count, last-loaded timestamp)
even with `-no-snapshot-save`, so the two mtimes drift apart by hours or
days after a few hot-boot sessions and the guard rejects every valid
snapshot — forcing a cold boot every time.

The bug hits macOS too; Linux just makes it painful because cold boot is
slow there. Confirmed against my own `Pixel_6_API_34`, whose
`snapshot.pb` is 4 days newer than `ram.bin` while the snapshot itself
is fully usable.

## What changed

- **Drop the mtime-skew guard.** `-check-snapshot-loadable` + the
hot-boot spawn's `-force-snapshot-load` already make truncated / corrupt
`ram.bin` fail loudly via `attemptBoot`'s `earlyExitError` race. The
on-disk pre-check only needs to confirm both files exist and `ram.bin`
is non-empty.
- **Resolve the `.avd` folder via the AVD's `.ini` `path=` line**,
matching what the emulator binary itself does
(`external/qemu/android/android-emu/android/avd/util.cpp`).
Snap-installed Studio on Linux and users who relocate AVDs to faster
disks both put `.avd` outside the convention root, where the old
`<root>/<name>.avd` assumption silently failed.
- **Honor `$ANDROID_USER_HOME`, `$XDG_CONFIG_HOME`,
`$ANDROID_SDK_HOME`** in priority order alongside `$ANDROID_AVD_HOME`
and `$HOME/.android/avd`, mirroring the emulator binary's own lookup
precedence.
- **Reject whitespace-only and relative `path=` values.** The regex's
non-greedy `(.+?)` still captures a single space for `path= `, which
would have leaked past `if (!avdPath)` as truthy; a relative `path=`
would have resolved against `process.cwd()` and silently mis-located
snapshots.

## Test plan

- [x] 18 new unit tests in `avd-snapshot.test.ts` covering: priority
order across all five env-var candidates, relocated AVDs, drifted mtimes
(4-day skew explicitly regression-tested), zero-length / missing /
directory-shaped files, whitespace-only paths, relative paths,
bad-then-good fallthrough.
- [x] Full `npm run test -w @argent/tool-server` — 703 passing.
- [x] `npm run typecheck:tests -w @argent/tool-server` — clean.
- [x] `npm run build` from workspace root — clean.
- [x] `npx prettier --check` on changed files — clean.
- [x] End-to-end repro against a real local AVD: drifted
`Pixel_3a_API_34`'s `snapshot.pb` mtime to 5 days newer than `ram.bin`
(the exact pathology in the wild), drove `boot-device` through this
branch's built code, observed `qemu-system-aarch64 ...
-force-snapshot-load -no-snapshot-save` in the spawned process — i.e.
the hot-boot path was taken with a snapshot the old check would have
rejected.

## Risk

Pre-check is now more lenient on disk shape. If `ram.bin` is non-empty
but truncated/corrupt, the hot-boot spawn still catches it via
`-force-snapshot-load` (loud early child-exit) and `attemptBoot` falls
back to cold boot. So the worst case has not regressed; it just surfaces
at spawn-time instead of pre-spawn.
2026-05-27 16:24:31 +02:00
Ignacy Łątka 11df817616 test: drop real-time timeouts from the slowest unit tests (#236)
The slowest unit tests were waiting on real wall-clock timers, not doing
work. This makes them deterministic and fast with **no production
behavior change and no production test seams**.

## Speedup

**Suite-level**

| Scope | Before | After | Faster |
|---|---|---|---|
| tool-server — wall clock | 2.72s | ~1.3s | ~2× |
| tool-server — aggregate test time | 9.70s | 4.03s | 2.4× |
| argent-mcp — aggregate test time | 0.90s | 0.28s | 3.2× |

**Per file**

| File | Before | After | Faster |
|---|---|---|---|
| simulator-server-blueprint.test.ts | 2.55s | 0.05s | ~50× |
| boot-device-hardening.test.ts | 2.17s | 0.18s | 12× |
| boot-device-spawn-error.test.ts | 1.11s | 0.005s | ~200× |
| boot-device-hotboot.test.ts | 1.01s | 0.06s | 16× |
| fetch-timeout.test.ts | 0.81s | 0.18s | 4.5× |
| flow-tools.test.ts (delayMs test) | 0.31s | 0.04s | ~7× |

**Worst individual tests, before → after**

| Test | Before | After |
|---|---|---|
| rejects the boot promise…when spawn emits error | 1.06s | ~1ms |
| reports the emulator crash error… | 1.00s | ~2ms |
| falls back to cold boot when hot-boot child exits early | 1.00s | ~1ms
|
| reports a signal-terminated emulator (SIGSEGV)… | 1.00s | ~1ms |
| 5× simulator-server-blueprint tests | ~0.50s ea | ~0.001–0.02s ea |
| throws after retries when POST never responds | 0.60s | 0.14s |
| sleeps the step's delayMs | 0.31s | 0.04s |

No test exceeds ~0.7s anymore (the slowest,
`workspace-reader-integration`, is genuine subprocess I/O — `node
--version` etc. — left as-is on purpose; it is not a timeout).

## What changed

- **`boot-device.ts` (only production change):** the three hardcoded
poll cadences are extracted into a single immutable
`BOOT_POLL_INTERVALS_MS` constant (values unchanged: 1500/1000/500ms)
and referenced at the four `setTimeout` sites. Pure readability
extraction — **no mutable state, no exported test hooks, no runtime
behavior change.**
- **Boot-device tests:** the timing-sensitive tests now use vitest fake
timers (`vi.useFakeTimers()` + `vi.advanceTimersByTimeAsync`).
`boot-device-hardening` / `-spawn-error` add the same partial
`vi.mock("../src/utils/adb")` already used by
`boot-device-hotboot.test.ts`, so pre-flight does no real filesystem I/O
and the timers drive deterministically.
- **`simulator-server-blueprint.test.ts`:** `signalReady` now emits
`stream_ready` then `api_ready` (matching the real streaming server's
ordering) so the blueprint resolves immediately instead of waiting out
the 500ms `STREAM_GRACE_MS` fallback. Adds a dedicated fake-timer test
that explicitly covers the non-streaming grace path (coverage gained,
not lost).
- **`fetch-timeout.test.ts` / `flow-tools.test.ts`:** smaller timing
knobs; same behaviors asserted.

## No regressions

- 1011/1011 tests pass (tool-server 656, argent-mcp 53, argent 6,
registry 64, tools-client 1, installer 231); `tsc --build` and Prettier
clean. The converted boot-device, simulator-server-blueprint, and
flow-tools tests are deterministic under fake timers (verified across
stress runs).
- No production behavior change; `simulator-server.ts` is byte-identical
to `main`; net +1 test.
- Heads-up: `argent-mcp/test/fetch-timeout.test.ts` has a
**pre-existing** macOS flake (~10–20% rate; `connect EADDRNOTAVAIL
127.0.0.1:<port> - Local (0.0.0.0:0)` — kernel source-port exhaustion
under rapid-fire loopback fetches). Stress-testing showed it on `main`'s
pristine file too (2/10 failures vs 1/10 on this branch), so this PR
does not introduce or worsen it. Fixing it (e.g., EADDRNOTAVAIL retry,
or moving the test off real loopback fetch) is worth a follow-up.
2026-05-27 16:24:07 +02:00
Juliusz Wajgelt 18cf0f12cd chore(deps): bump zod to v4 (#265) 2026-05-27 13:47:26 +02:00
Ignacy Łątka 8d17dc9b4b fix(init): stop cramming homedir into mcp config (#257)
`argent init` was writing `/Users/<username>/.argent/mcp-calls.log` into
every generated MCP config (Cursor, Claude Code, VS Code, Windsurf, Zed,
Gemini, Codex, Hermes, opencode). Those files get committed and shared,
so collaborators ended up with the original author's username pinned
into their environment.

Drop the env var from `buildMcpEntry` and have each adapter omit the
`env`/`environment` field when the entry carries no env vars. The MCP
server already falls back to `${homedir()}/.argent/mcp-calls.log` at
runtime, so logging keeps working for every user on their own machine.

Fixes #238
2026-05-27 12:27:36 +02:00
Paweł Fornagiel a96f1f1578 feat: refactor android describe to not rely on uiautomator (#237)
## Summary

- `formatDescribeTree` was branching on `source === "uiautomator"` to
pick nested rendering; the newer `android-devtools` source fell through
to flat mode and lost every node below depth 1. Extends the predicate to
cover both Android sources so the helper-service path renders with the
same hierarchy as the legacy `uiautomator dump` path.
- Bumps `packages/argent-private` to pick up the persistent Android
describe service that emits `source: "android-devtools"`, plus its
build-and-sign CI workflow.
- Wires the Android helper APK + `manifest.json` into the published
`@swmansion/argent` package via `bundle-tools.cjs`, and adds
`pack:mcp:local` / `build:android-binaries` so a local tarball can be
produced without going through `download:native-binaries`.
- Minor: tightens a couple of comments in `android-helper-install.ts`
and `format-tree.ts`.

This is connected to argent-private PR:
https://github.com/software-mansion/argent-private/pull/8
2026-05-27 11:46:12 +02:00
Paweł Fornagiel 5b147e9c25 fix: profiler attribution on older React DevTools backends (#262)
## Summary
- Resolve profiler display-name lookup across both
`getDisplayNameForElementID` and legacy `getDisplayNameForFiberID`
backends.
- Add a fallback diagnostic (`displayNameApiAvailable` /
`unattributed_reason`) so fully-unattributed captures are reported as an
API-surface mismatch instead of a transient lifecycle race.
- Extend multi-renderer script coverage for modern, legacy, mixed, and
missing display-name accessor scenarios.
2026-05-27 10:57:45 +02:00
filip131311 d091421e3a fix(describe): unlock iOS 26.5 SpringBoard-hosted dialogs via IgnoreAXServerEntitlements (#244)
## Summary

iOS 26.5 tightened cross-process AX access: SpringBoard's AX server
rejects MIG queries from non-UIApplication clients with kAXError
\`-25215\`. Our \`ax-service\` daemon is a \`simctl spawn\`-launched CLI
binary, so it fails the new check, and \`describe\` returns an empty
ROOT for any SpringBoard-hosted dialog — most visibly the TCC permission
prompts (\`Allow X to use your location?\`, camera, contacts, photos,
microphone, notifications). On iOS 26.4 the same daemon code works fine.

Apple ships a debug switch in \`AccessibilityUtilities\` for exactly
this case: the \`com.apple.Accessibility/IgnoreAXServerEntitlements\`
preference. When set, the server skips the entitlement check, and our
existing \`-[AXElement explorerElements]\` walk returns the dialog tree
as expected.

This PR wires the pref next to the existing \`AutomationEnabled\` write
in \`ensureAutomationEnabled\`, plus a one-time SpringBoard kickstart so
SB re-reads its AX-server config. The pref persists on the sim's disk,
so subsequent ax-service spawns detect it's already set and skip the
kickstart — avoiding the disruptive app-icon reflow on every argent run.

## Before / after on iOS 26.5

**Before** (raw socket dump for the Maps location prompt):
\`\`\`
{ \"alertVisible\": true, \"elements\": [], \"systemElements\": [], ...
}
\`\`\`
→ describe returns \`source: ax-service ROOT AXGroup\` with no children.

**After**:
\`\`\`
Source: ax-service
ROOT  AXGroup (0.000, 0.000, 1.000, 1.000)
  AXStaticText \"Allow 'Maps' to use your location?\"
AXStaticText \"Your location is used to show your position on the map,
...\"
  AXButton \"Allow Once\"
  AXButton \"Allow While Using App\"
  AXButton \"Don't Allow\"
  ...
\`\`\`

Matches iOS 26.4 behaviour. Same daemon binary, no entitlement claims,
no resigning.

## What I changed

Single file: \`packages/tool-server/src/blueprints/ax-service.ts\` —
added \~50 lines to \`ensureAutomationEnabled\` that:

1. Read the current value of \`IgnoreAXServerEntitlements\` from
\`com.apple.Accessibility\`.
2. If it isn't \`1\` already: \`defaults write\` it true, then
\`launchctl kickstart -k system/com.apple.SpringBoard\` so SB re-reads.
3. \`kickstart\` emits a warning about user/foreground service
identifier on iOS 26 — tolerated with \`.catch(() => undefined)\`; the
restart still happens.

## Cost characterisation

| Scenario | Cost |
|---|---|
| Fresh sim never touched by argent | One SpringBoard restart (~3-5s,
apps reflow, status bar blinks) |
| Sim with the pref already on disk (post-first-run, post-reboot, etc.)
| Zero — just one \`defaults read\` |
| Sim shut down when argent first targets it | Pref is written, SB picks
it up on next boot — no restart needed |
| iOS < 26.5 sim | Pref-write is harmless;
\`_shouldValidateEntitlements\` doesn't read it on older versions |

The kickstart fires at most once per sim, ever. The pref is per-sim in
CoreSimulator data; \`simctl erase\` wipes it (next run sets it again).

## Background — why this is the right layer

Briefly investigated:

- **Daemon code-path tweaks** (enumerating
\`currentApplicationsIgnoringSiri\`, fresh
\`_AXUIElementCreateAppElementWithPid\`, etc.) — all hit the same
server-side gate and return \`-25215\`. Cf. closed
software-mansion/argent-private#10 and software-mansion/argent#243.
- **Sign daemon with private AX entitlements** — sim AMFI rejects
(\`Security policy issue\` 163) for anything stricter than what we
already have.
- **Re-host daemon as UIApplication** — would pass the new check but is
a substantial refactor (Info.plist, scene config, background-task
management for the socket server, install/launch instead of spawn). Not
worth it when a one-line debug-pref unlocks the same outcome.

The IgnoreAXServerEntitlements path is the only one that's small,
contained, and reversible.

## Test plan

- [x] \`vitest run\` in tool-server passes (175/175)
- [x] \`tsc --noEmit\` clean on tool-server
- [x] Manual verify on iPhone Air iOS 26.5: Maps location prompt now
returns 5 AX elements from \`describe\` with \`source: ax-service\`
- [x] Manual verify on iPhone 17 Pro iOS 26.4: still works (pref-write
is a no-op for the gating logic on that version)
- [ ] CI / unit coverage for the new conditional kickstart path

## Files

- \`packages/tool-server/src/blueprints/ax-service.ts\` — extended
\`ensureAutomationEnabled\`
2026-05-26 17:00:13 +02:00
filip131311 69538e74a3 fix(ax-service): remove unnecessary AutomationEnabled pref write (#256)
## Summary
- Removes the `ensureAutomationEnabled` function and all its call sites
- The ax-service binary uses `AXRuntime`/`AXSpringBoardServer` private
frameworks directly and does not need the `AutomationEnabled` preference
on iOS 26.x simulators
- Writing it pre-boot into the Accessibility plist actively breaks the
AX subsystem by interfering with `cfprefsd` domain initialization,
causing `describe` queries to hang

## Investigation

Tested on erased sims across five device types (iPhone 17e, iPhone 17,
iPhone Air, iPad mini, iPad Pro 11-inch) on iOS 26.5 (runtime `23F77`):

1. **No plist at all** → `describe` returns full hierarchy including
SpringBoard-hosted TCC dialogs ✅
2. **Empty plist** → works ✅
3. **Plist with `AutomationEnabled=true`** → `describe` hangs
indefinitely ❌

The pref was originally added in #98 (April 2026) likely cargo-culted
from older iOS automation patterns. On iOS 26.x the
`AXSpringBoardServer` MIG interface accepts connections from the
ax-service binary without it.

## Test plan
- [x] TypeScript clean
- [x] All affected tests pass (simulator-server-blueprint,
describe-missing-xcrun-424)
- [x] E2E: erased sim → boot → launch Maps → `describe` returns TCC
dialog with all buttons

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-26 10:07:04 +02:00
filip131311 09a18a65ef bump to v0.8.0 (#245) v0.8.0 2026-05-21 17:46:28 +02:00
Ignacy Łątka 2180dd8e6a ci: typecheck test files in CI (#202)
We've had lots of type errors in our tests, scripts and other non-core
code despite all CI being green. This PR fixes that.

This PR also removes some redundant features like `@ts-check` inline
directives which have been replaced by proper ts config files.

<details>

## AI Summary

Audit of every CI workflow against every package's `package.json` and
`tsconfig` surfaced several gaps where CI did partial coverage. This PR
closes them and fixes every pre-existing type error the new gates
surfaced.

## Gaps closed

| # | Gap | Fix |
|---|---|---|
| 1 | `tsc --build` only covers `src/**`; test files (`test/**`,
`tests/**`) never typecheck. Vitest transforms tests with esbuild —
strips types instead of checking them. | New `tsconfig.test.json` per
test-bearing package (`composite: false`, `noEmit`, `rootDir: "."`,
includes `src/**` + `test/**` (or `tests/**`) + `vitest.config.ts`). New
`typecheck:tests` npm script per package. New CI step `npm run
typecheck:tests --workspaces --if-present`. |
| 2 | Build/publish scripts (`scripts/*.{cjs,mjs}`,
`packages/argent/scripts/*.cjs`, `packages/skills/scripts/install.js`) —
JS, never typechecked. `sync-readme.cjs` runs in `prepack`;
`postinstall.cjs` runs on every install; `bundle-tools.cjs` is
build-critical. A typo only surfaces at runtime. | Root
`tsconfig.scripts.json` with `allowJs` + `checkJs` + `noImplicitReturns`
covers all script paths. Root `typecheck:scripts` npm script. New CI
step. Per-file `// @ts-check` comments removed — tsconfig drives
inclusion. |
| 3 | `argent-cli` had no `typecheck:tests` script. When tests get added
there they'd silently bypass the gate. | Added `tsconfig.test.json` +
`typecheck:tests` script. `--if-present` would have skipped it anyway,
so this is future-proofing. |
| 4 | Local `prettier --check .` walked into the `argent-private`
submodule and reported violations there. CI didn't see this because
`actions/checkout@v4` doesn't init submodules — so local `prettier
--check` did not match CI behavior. | New `.prettierignore` excludes the
submodule plus `dist/`, `node_modules/`, lock files, `tsbuildinfo`.
No-op in CI; aligns local with remote. |

## Test type errors fixed

These were already present. The `npm test` passed because esbuild
stripped the offending types:

- **argent-installer**: widen `scope` literal type for
dead-code-elimination guard; non-null `addAllowlist!`/`removeAllowlist!`
(optional methods on adapter).
- **argent-mcp**: add `.js` extensions for NodeNext module resolution.
- **registry**: broaden `StaticBlueprintResult` blueprint api from `{
id; deps? }` to `Record<string, unknown>` so existing tests can pass
arbitrary api shapes.
- **tool-server**:
- non-null `zodSchema!` on tool defs that always carry one
(`launchAppTool`, `restartAppTool`, etc.)
- cast sentinel `"ignored"` strings to `DeviceInfo` in factory-rejection
tests
- match the 4-arg `dispatchByPlatform<IosServices, AndroidServices,
Params, Result>` signature (tests still passed the old 3-arg shape)
- `assertFlowRunResult` type guard for the `FlowRunResult |
FlowPrerequisiteNotice` union before `.steps` access
- swap broken `typeof import("supertest").default` for static `import
supertest` (supertest is `export = supertest`)
- fill in the 4 fields missing from a `NativeProfilerSessionApi` mock
(`xctraceProcess`, `recordingTimedOut`, `recordingExitedUnexpectedly`,
`lastExitInfo`)
- cast a vitest `Mock` to a plain function at one direct call site where
the `Procedure | Constructable` union loses callability
- fix `update-checker.test.ts` reading `../../package.json` (off-by-one
— went past tool-server; was relying on a Vite resolver quirk)
- replace one cross-package `../../../registry/src/index` import with
`@argent/registry` so registry's private `services` field isn't compared
between src and dist declarations

All fixes are type-level. Full `npm test --workspaces --if-present`
still reports **991 passed across 87 test files**.

</details>
2026-05-20 17:03:34 +02:00
filip131311 7f1c78104b fix(describe): surface dialogs hosted by remote-view services (#240)
## Summary

Bumps the \`argent-private\` submodule to pull in the ax-service daemon
fix in
[software-mansion/argent-private#9](https://github.com/software-mansion/argent-private/pull/9).
The radon-lite side of this PR is just the submodule pointer — the
actual source change is one if-statement in \`ax_service.m\`.

\`describe\` returns nothing when a \`_UIRemoteViewController\`-hosted
overlay is on top of the foreground app — most visibly, the
\`StoreKitUISceneService\` Apple ID auth sheet from the reporter's case.
Root cause: \`+[AXElement primaryApp]\` returns nil while the
remote-view service is frontmost, so the daemon never reaches
\`collectExplorerElements\`. The submodule PR adds a single fallback:
when \`primaryApp\` is nil, look up the AXElement via \`+[AXElement
applicationAtCoordinate:<screen-centre>]\` instead, which returns the
*hosting* app's AXElement regardless of which process owns the frontmost
view. AX bridges the XPC boundary, so the hosting app's
\`explorerElements\` walk surfaces the remote-view's content in the same
flat-leaf shape the daemon already emits.

## Why this PR vs. #234

I closed [#234](https://github.com/software-mansion/argent/pull/234)
after empirical reproduction showed the TS-side \`systemElements\`
fallback there had no effect on the reporter's case — the daemon's
\`isSystemAppShowingAnAlert\` gate doesn't fire for XPC-hosted sheets
either, so \`systemElements\` was empty too. The reachability gap was at
the daemon layer (\`primaryApp\` selector returning nil), not the TS
adapter. This PR addresses that root cause.

## Empirical verification

Built the patched daemon + ran against the reporter's exact trigger on
an erased iOS 26.5 sim (News → "Get News+" → "Get Started" → "Apple
Account Sign in Requested" sheet):

- **Old daemon** (md5 \`d5240e8…\`, currently shipped): raw describe
socket returned \`{ "alertVisible": false, "screenFrame": {…} }\` — no
\`elements\` field, agent has no labels.
- **Patched daemon** (md5 \`75fdffcc…\`, dev build): 42 elements, all
dialog content labelled: heading "Apple Account Sign in Requested",
email field, "Forgot password?", **"Sign In" at normalized (0.500,
0.414)**, **"Cancel" at (0.500, 0.482)**, plus 37 keyboard keys.

The agent can now tap by label.

## Risk

- No wire-format change, no TS/adapter change. \`@argent/tool-server\`
and the describe handler consume the same shape they did before.
- Fallback gates on \`primaryApp == nil\`, so every working case stays
on its existing path.
- If \`applicationAtCoordinate:\` also returns nil at the centre, the
daemon emits no \`elements\` — same as current behaviour. The existing
native-devtools fallback in \`describeIos\` still kicks in afterwards.

## Open items before un-drafting

- [ ] Merge https://github.com/software-mansion/argent-private/pull/9
(the source change lives there)
- [ ] Publish a new \`argent-main\` build to
\`software-mansion-labs/argent-private-releases\` so \`npm run
download:native-binaries\` picks up the patched \`bin/ax-service\`
- [ ] Re-verify \`argent run describe\` end-to-end against the same
dialog with the released binary
- [ ] One regression-style smoke test: an in-process dialog (e.g. Wallet
location prompt) to confirm the existing \`primaryApp\` path still fires
for the unchanged cases

## Notes

- Daemon binary lives in \`packages/native-devtools-ios/bin/ax-service\`
but is \`.gitignore\`d; the release pipeline supplies it. This PR cannot
ship the new binary in-repo for the same reason.
- I empirically introspected the AXRuntime surface inside the simulator
before committing to this approach — \`+systemWideElement\` and
\`+systemEventDispatchElement\` segfault from a non-UI process, so
coordinate lookup is the practical option that doesn't require a
daemon-process redesign.


Fixes #207
2026-05-20 15:46:24 +02:00
Ignacy Łątka 2977d2ca77 fix(describe): bump argent-private with WKWebView describe fix (#157)
The native ax-service now recurses into `primaryApp.children` beyond the
flat `explorerElements` pass, so WKWebView web content (labels from the
WebContent process) is surfaced alongside the host app's VoiceOver
targets.

Details: https://github.com/software-mansion/argent-private/pull/7
2026-05-20 13:19:24 +02:00
Paweł Fornagiel 4255b0bd66 fix: stop simctl spawn loop on iOS init failure (#233)
Fixes #210 — `argent mcp` pinning CPU to 100% and spawning hundreds of
`simctl` processes that survive `kill -9`.

## The problems

1. **No retry cap.** The simulator watcher polls every 10s; on any
failure it forgets the UDID and re-classifies it as "new" on the next
tick. Each retry runs 4–5 `xcrun simctl spawn` calls in series. A
persistently failing simulator produces an unbounded loop — hundreds of
`entered ERROR state` log lines and a steady process pile-up.

2. **No timeouts on `simctl spawn`.** A degraded `CoreSimulatorService`
makes simctl block forever. The factory `await`s the hung call, never
rejects, never retries, but the stuck child process stays alive.

## How this fixes it

- **Bounded retries with terminal "given up" state.** Each
`NativeDevtools` service instance now tracks `{attempts, lastError,
givenUp}`. After 3 consecutive init failures the service stops trying
and reports `init_failed` to any caller. The watcher keeps polling but
skips UDIDs in the given-up state.

- **10s timeout on every `simctl spawn` call** in `ensureEnv`,
`ensureAccessibilityEnabled`, and `ensureAutomationEnabled`. Healthy CSS
responds in ~300ms; 10s is well above any plausible legit latency and
well below "hung forever." A hung simctl now rejects cleanly, increments
the attempt counter, and feeds the bounded retry instead of accumulating
children.

- **Tools surface `init_failed` as a typed result** instead of throwing.
`launch-app`, `boot-device`, `native-devtools-status`, and the rest of
the native tools now return a structured error with the failure reason
and a hint to restart the simulator, instead of throwing an opaque
exception.

- **Factory no longer throws on first env-init failure.** Previously a
single failed `simctl` call during factory creation leaked the socket
server. The factory now tolerates init failure; the watcher's bounded
retry handles the recovery.

Verified on live simulator and against test simulating `simctl`
timeouts. Happy path works as before, `restart-needed` is still
signalled and the new failure path works as well.
2026-05-20 08:56:33 +02:00
Ignacy Łątka fbe7b087e7 chore(deps): batch-apply Dependabot updates (#214–#227) (#228)
This PR consolidates every open Dependabot PR that requires **no
source-code changes** into a single branch with one regenerated,
coherent lockfile.

| PR(s) | Update | Status |
|---|---|---|
| #214 | GitHub Actions group — checkout@v6, setup-node@v6,
setup-python@v6, github-script@v9, upload-artifact@v7 | ✅ Applied |
| #215 | prettier 3.8.3, @clack/prompts 1.4.0, semver 7.8.0, yaml 2.9.0,
@modelcontextprotocol/sdk 1.29.0, esbuild 0.28.0 | ✅ Applied |
| #216 / #221 / #224 / #227 | typescript 6.0.3 | ✅ Applied |
| #217 / #222 / #223 | @types/node 25.9.0 | ✅ Applied |
| #220 / #226 | vitest 4.1.6 | ✅ Applied |
| #218 / #225 | zod 4 | ⏸️ Excluded — requires code migration |
| #219 | express 5 | ⏸️ Excluded — requires code migration |
| #215 (partial) | tree-sitter 0.25.0 | ⏸️ Excluded — see below |

**tree-sitter 0.25.0** ships no prebuilt binaries and its `binding.gyp`
hard-codes `-std=c++17`, which fails to compile against Node 20+/24
C++20 `cppgc` headers — a hard `npm install` blocker (would also break
CI). Kept at `^0.21.1`; all other #215 updates applied.

zod 4 and express 5 are excluded here because they require real source
changes; tracked separately.

Verified: `tsc --build` clean, esbuild bundles, **1010 unit tests
pass**, **zero source files modified**.
2026-05-19 12:57:07 +02:00
Paweł Fornagiel 896e775e01 Fetch argent skills from GitHub for portable lockfiles (#230)
### Description

Install and sync argent skills from a GitHub-pinned source
(`software-mansion/argent/packages/skills/skills#v<version>`) instead of
the local bundle when possible, so `skills-lock.json` stays portable
across machines. Falls back to the bundled skills directory when offline
or the version is unknown.

### Fixes #208
2026-05-19 12:31:40 +02:00
Ignacy Łątka 95d2ffa0e4 fix(skills): remove iOS bias from skill/tool docs (#212)
We've had multiple instances of iOS-biased references in our skills.
This PR makes these references platform-agnostic.
 
<details>

## Summary

After Android emulator support landed (#148), several skill docs and
tool descriptions still treated iOS as the only/default platform. A QA
reported that asking the agent to "test … using argent" makes it spin up
an iOS simulator even when an Android emulator is already running.

These are **minimal de-biasing edits** — mostly one-word `simulator` →
`device` swaps. Android is **not** promoted above iOS; the incidental
iOS bias is just removed where it was unnecessary. Genuinely iOS-only
tools (`paste`, `native-*`, `native-profiler-*`) are left untouched
since the capability gate already rejects them on Android.

## Changes (14 files, +43/-29)

- **`argent.md` rule**: one-line `PLATFORM DETECTION` note (call
`list-devices` first, don't default to iOS); `simulator actions` →
`device actions`; mention `adb` next to `xcrun` in the escape-hatch
bullet. Routing order unchanged (iOS still listed first).
- **Skill docs**: `native iOS app navigation` → `app navigation`;
`simulator` → `device` where the action is platform-neutral; added the
missing `argent-android-emulator-setup` row to the RN-workflow Related
Skills table; `argent-ios-simulator-setup` description scoped to iOS.
- **`flow-run` / `flow-start-recording`**: `simulator interactions` →
`device interactions`.
- **`network-logs` / `network-request`**: `device_id` description `iOS
Simulator UDID` → `Device UDID`.
- **`list-devices`**: merged result now sorts by readiness so a booted
Android emulator is not buried beneath idle iOS simulators (the only
logic change).

## Test plan

- [x] `npx tsc --build` clean
- [x] `@argent/tool-server` 478 tests pass; `@argent/mcp` 51;
`@argent/installer` 231; `@argent/registry` 57
- [x] `prettier --check` clean
- [ ] Sanity-check that an agent with only an Android emulator booted no
longer reaches for an iOS simulator

</details>
2026-05-19 11:57:42 +02:00
filip131311 94fa867818 feat(describe): render tree as text to cut token cost (#197)
## Summary
- Move the JSON tree out of the describe tool's response and add an
in-between layer (`format-tree.ts`) that converts it to a sectioned text
rendering. On a typical iOS screen the new `description` field is ~6×
cheaper on the wire than the previous nested tree once braces, keys, and
indentation are counted.
- Public response shape becomes `{ description, source, should_restart?
}`. All existing pruning (Android v2 trimmer in `uiautomator-parser.ts`,
iOS frame clamp in `ios-ax-adapter.ts`) is untouched — the new layer
only re-presents what the platform adapters already produced.
- Header now spells out the coordinate space and the tap-point formula
so an agent can act on a frame without further docs.

## How it renders
Two modes picked automatically from the tree shape:
- **flat** (iOS ax-service): bucket children by `frame.y` into Top bar /
Body / Composer / Bottom; split the bottom zone into keyboard rows by
rounded y and classify each row (letters / shift / return /
globe-dictate).
- **nested** (Android uiautomator, iOS native-devtools fallback):
iterative DFS with depth indentation, preserving `clickable` /
`scrollable` / `scrollHidden` / `disabled` / `password` flags inline.

Example (Messages with keyboard up):
```
Source: ax-service
Mode: flat
Coordinates are normalized [0,1] fractions of the screen (x, y, width, height), not pixels — pass them straight to gesture-tap / gesture-swipe / gesture-pinch, which expect this same space. To tap an element, use its centre: tap_x = frame.x + frame.width / 2, tap_y = frame.y + frame.height / 2.

ROOT  AXGroup (0.000, 0.000, 1.000, 1.000)

— Top bar —
  AXButton     "Messages"  (0.040, 0.071, 0.109, 0.050)
  AXButton     "+1 (888) 555-1212"  (0.267, 0.134, 0.466, 0.037)

— Body / content —
  AXStaticText "iMessage  Encrypted"  (0.040, 0.156, 0.920, 0.030)
  ...

— Bottom row 1: letters —
  AXImage      "Q"  (0.012, 0.675, 0.098, 0.062)
  ...
```

## Files
- `packages/tool-server/src/tools/describe/contract.ts` — split into
`DescribeTreeData` (internal) and `DescribeResult` (public)
- `packages/tool-server/src/tools/describe/index.ts` —
`withDescription(data)` wrapper applied to both platform handlers
- `packages/tool-server/src/tools/describe/format-tree.ts` — the
renderer (new)
-
`packages/tool-server/src/tools/describe/platforms/{ios,android}/index.ts`
— return `DescribeTreeData`
- `packages/tool-server/test/describe-tool.test.ts` — assertions
migrated to `result.description` / `result.source` /
`result.should_restart`
- `packages/tool-server/test/describe-format-tree.test.ts` — renderer
unit tests (new): flat zoning, nested indentation + flags,
embedded-newline escaping, empty tree

## Test plan
- [x] `npm test` in `packages/tool-server` — 49 files / 482 tests
passing locally
- [x] `tsc --build` at workspace root — clean
- [x] Live MCP sanity check: `argent run describe --udid <booted>
--json` returns the new shape; tested against Messages (full keyboard
surface), Safari (chrome-only, expected), and a minimal in-process
WKWebView host. Existing pruning behaviour unchanged in every case.
2026-05-19 11:54:13 +02:00
Paweł Fornagiel a0a43522c8 docs(skills): device selection guidance for list-devices and RN runs (#203)
## Summary

This change improves how Argent skills tell agents to pick and use
devices before booting, running Metro, or driving UI. It reduces
wrong-platform assumptions and encourages explicit targets derived from
`list-devices`.

## Fixes #196

## What changed

- **`packages/skills/rules/argent.md`**: Added a
`<device_selection_rule>` block that instructs agents to call
`list-devices` first, then follow a clear decision order: explicit user
intent → prefer already-running devices → ask when both iOS and Android
are ready with no intent → boot or ask for cross-platform vs
single-platform projects using environment inspector signals.
- **`argent-react-native-app-workflow/SKILL.md`**: RN run commands now
require an explicit simulator name or Android `deviceId` from
`list-devices`; minor checklist wording tweak for booting devices.
- **`argent-test-ui-flow/SKILL.md`**: Clarifies resolving the test
device via `list-devices` and `<device_selection_rule>` before
per-platform setup.

## Motivation

Agents sometimes assumed a platform or picked an arbitrary device.
Aligning skills with `list-devices`-first behavior makes device choice
observable and consistent with multi-platform workflows.
2026-05-19 10:28:32 +02:00
filip131311 b75cb3bb42 chore: add Dependabot config (npm workspaces + GitHub Actions) (#213)
## Summary
- Adds `.github/dependabot.yml` to enable weekly version + security
updates.
- Covers the **npm workspaces monorepo** (`/` + `/packages/*`) and
**GitHub Actions** used in `.github/workflows`.
- Groups updates to keep PR noise low:
- `npm-minor-patch` — one PR/week bundling all minor/patch bumps across
the workspace.
- `npm-major` — separate grouped PR for majors (typically needs human
review).
  - `npm-security` — grouped security updates across all directories.
  - `github-actions` — one PR/week for all Actions bumps.
- Uses `directories:` (plural) with the `/packages/*` glob so every
workspace package is monitored; majors stay separate from minor/patch so
reviewers can triage breaking changes without blocking routine bumps.
- `gitsubmodule` ecosystem was intentionally **not** enabled —
`packages/argent-private` is an internal submodule maintained by the
same org, so SHA-bump PRs would be noise rather than signal.

## Notes
- Schedule: weekly, Monday 06:00 UTC.
- Commit prefixes follow the existing `chore(...)` convention used in
this repo (`chore(deps)`, `chore(deps-dev)`, `chore(ci)`).
- `open-pull-requests-limit` set to 10 (npm) / 5 (actions) to bound the
queue.

## Test plan
- [ ] Merge to `main`; confirm Dependabot picks up the file (visible
under repo *Insights → Dependency graph → Dependabot*).
- [ ] First scheduled run on the following Monday produces grouped PRs
(or trigger immediately via *Check for updates* in the Dependabot tab).
- [ ] Verify a sample PR's diff touches the expected workspace
`package.json` + root `package-lock.json`.
2026-05-18 17:22:22 +02:00
filip131311 cc9d5d035b Add Dependabot configuration file
Configure Dependabot for version updates with weekly schedule.
2026-05-18 17:15:38 +02:00
Ignacy Łątka 1bfe516fbd fix(deps): resolve dependabot alerts (#211)
Manual dependabot fixes.

## Details

<details>

**Lockfile-only.** `package.json` is unchanged; all bumps are within
parents' already-declared semver ranges (API-compatible by semver
contract). Refreshing the stale lock to the in-range patched versions:

- `fast-uri` 3.1.0 → **3.1.2** — *shipped*
- `path-to-regexp` 0.1.12 → **0.1.13** — *shipped* (upstream fix = two
`backtrack = ''` resets)
- `hono` 4.12.3 → 4.12.19, `@hono/node-server` 1.19.9 → 1.19.14,
`express-rate-limit` 8.2.1 → 8.5.2, `router`'s `path-to-regexp` 8.3.0 →
8.4.2 — *not shipped* (SDK HTTP/SSE transport; argent uses stdio only →
tree-shaken out of bundles). Same low-risk stale-lock class, bumped in
the same pass.
- `ip-address` 10.0.1 → 10.2.0 — benign in-range cascade from
`express-rate-limit@8.5.2`'s own manifest.

`npm audit` HIGH for shipped/runtime deps: **7 → 0**.

## Scope

In scope (the QA finding): the two shipped HIGH advisories — fixed and
proven present in freshly-built bundles.

Out of scope (deliberately): the remaining ~7 `npm audit` entries are
**exclusively the dev-only vitest toolchain** (`vite`, `vite-node`,
`esbuild`, `postcss`, `picomatch`, `@vitest/mocker`, `vitest`). Not
shipped, not runtime. Their fix requires a `vitest` **semver-major** — a
separate, breaking change.

## Verification

- `tsc --build` clean; **998 tests pass** across all 7 workspaces;
`prettier --check .` clean.
- Bundles rebuilt from the patched tree; MCP stdio + tool-server E2E
confirmed working.
- 4 independent reviews (correctness / scope / edge-cases / ripple): all
PASS. Edge-case review ran an **offline differential** of every
tool-server express route under 0.1.12 vs 0.1.13 (byte-identical
`RegExp` source + keys) and a **live E2E** on the running patched
tool-server — route matching is unchanged. `fast-uri` 3.1.2 only rejects
more malformed URIs; `ajv` uses it solely for schema `$id`/`$ref`, never
runtime data.
- `npm ci` is byte-stable against this lockfile (does not regenerate
it); `npm pack --dry-run -w @swmansion/argent` passes.

## Residual `npm audit`

7 vulns remain (2 high, 5 moderate) — all dev-only vitest toolchain,
tracked separately (needs `vitest` major).

</details>
2026-05-18 16:54:33 +02:00
pFornagiel 12f6ff1115 chore: bump to 0.7.1 v0.7.1 2026-05-15 16:55:57 +02:00
Paweł Fornagiel 8a3b5a1ff7 fix(react-profiler): self-bootstrap DevTools backend when no client is attached (#205)
## Summary

`react-profiler-start` failed in any RN bridgeless dev build with no
external React DevTools client (Fusebox React tab / `npx
react-devtools`) connected — returning the misleading error *"No React
renderer interface attached yet. Wait for the app to render its first
commit and retry."* even when the app had rendered. This PR makes the
tool self-bootstrap the DevTools backend so profiling works out of the
box, and replaces the one-size-fits-all error with mode-specific
actionable messages.

Companion to #199 (schema fix). With both landed, `react-profiler-start`
is end-to-end usable from MCP clients against a vanilla RN 0.76 dev app
with zero extra setup.

> [!IMPORTANT]
> The bootstrap path mutates global runtime state (Bridge + Agent + hook
subscriptions via `connectWithCustomMessagingProtocol`). Unit tests
cover every reason branch, but the live behavior — especially
coexistence with a later-attached `npx react-devtools` client and the
absence of subscription leaks across repeated start/stop cycles — must
be **smoke-tested against a working RN application** before this merges.

## Root cause

argent's profiler delegates React commit capture to the in-app DevTools
backend via `rendererInterface.startProfiling(...)`. The two maps on
`__REACT_DEVTOOLS_GLOBAL_HOOK__` look similar but are populated by
different actors:

| Map | Populated by | When |
|---|---|---|
| `hook.renderers` | React itself via `hook.inject(renderer)` | First
render |
| `hook.rendererInterfaces` | DevTools backend's `attach(hook, id,
renderer, global)` (in `react-devtools-shared/src/backend/renderer.js`)
| When `initBackend(hook, agent, global)` runs |

In an RN 0.76 bridgeless dev build, `setUpReactDevTools.js` runs at
bundle load and calls `react-devtools-core.connectToDevTools({ host:
'localhost', port: 8097, websocket })`. That opens a WebSocket;
`initBackend` only runs on `ws.onopen`. With no DevTools client
listening on 8097, the connection never opens, `initBackend` never runs,
`rendererInterfaces` stays empty — but `hook.renderers` is populated
because React doesn't care about the backend.

So `rendererInterfaces.size === 0` while `renderers.size === 2` is the
steady-state of a dev build with no DevTools client. argent's pre-flight
check saw the empty map and threw with a misleading message ("wait for
first commit") that conflated three distinct failure modes:
1. App hasn't rendered yet (legitimate wait).
2. App rendered but no DevTools backend attached (this PR — the common
case).
3. Production build / no `react-devtools-core` in bundle (different
recovery).

Direct CDP verification of the hook from the bug state:
```js
{ rIds: [1, 2], riIds: [], reactDevtoolsAgent: undefined }
```

## Fix

When the pre-flight detects `rendererInterfaceFound: false` with the
hook present, `react-profiler-start` now runs a self-bootstrap script
(`BOOTSTRAP_DEVTOOLS_BACKEND_SCRIPT`) that:

1. **Looks up `react-devtools-core` in the Metro module registry** by
scanning `__r.getModules()` entries for a `verboseName` matching
`react-devtools-core/dist/backend`. Handles both `Map<id, meta>` (modern
Metro) and array-of-meta (older Metro) shapes — mirrors the pattern in
`debugger-component-tree`.

2. **Calls `connectWithCustomMessagingProtocol`** (rdt-core ≥5.1) with
no-op `onSubscribe` / `onUnsubscribe` / `onMessage` handlers. The
function internally constructs a `Bridge` + `Agent` and runs
`initBackend(hook, agent, window)`, which iterates `hook.renderers` and
calls `attach()` on each — exactly the call path that populates
`rendererInterfaces`. The fake wall discards every outbound message,
which is fine because argent calls into the rendererInterface directly
and never reads from the bridge.

3. **Returns one of seven distinct reasons**, each mapping (via
`bootstrapFailureMessage` in
`utils/react-profiler/devtools-bootstrap.ts`) to its own actionable
error message:
   - `no-hook` → "production build" guidance
   - `no-renderers` → "wait for first commit"
   - `no-metro-modules` → bundle isn't Metro-style
- `no-rdt-module` → react-devtools-core stripped from bundle
(production)
- `unsupported-rdt-version` → rdt-core <5.1 (RN <0.74) → fall back to
`npx react-devtools`
- `metro-scan-error` / `bootstrap-threw` / `bootstrap-no-effect` →
unexpected, with verbatim error text

After a successful bootstrap, `REACT_NATIVE_PROFILER_SETUP_SCRIPT` is
**re-run** to install argent's `__argent_startWrapped__` wrappers on the
freshly-attached interfaces. Without this re-run, `buildStartScript`'s
post-start `__argent_isProfiling__` check would falsely report
`startProfiling-threw` (the wrapper sets the flag; an unwrapped
interface leaves it false even on a successful start).

### Idempotency / leak protection

`initBackend` adds subscriptions to `hook.sub('renderer', ...)` on every
call, so naive re-invocation would leak listeners. The script
short-circuits with `reason: 'already-attached'` when
`rendererInterfaces.size > 0`, so repeated `react-profiler-start` calls
within a session never re-bootstrap. Verified empirically: listener
counts stay flat across multiple start/stop cycles.

### Coexistence with external DevTools

If `npx react-devtools` is later started while argent has already
bootstrapped, RN's bundled reconnect path calls `connectToDevTools`
again, which goes through `attachRenderer` → sees `rendererInterface !=
null` (argent's already there) → re-emits `renderer-attached` without
re-attaching. Both backends coexist; argent talks to the renderer
interface directly, so the active "agent" is irrelevant to profiling.

## Files changed

- `packages/tool-server/src/utils/react-profiler/scripts.ts` — adds
`BOOTSTRAP_DEVTOOLS_BACKEND_SCRIPT` (the injected IIFE that does the
Metro scan + connect call).
- `packages/tool-server/src/utils/react-profiler/devtools-bootstrap.ts`
*(new)* — `BootstrapResult` type, `BootstrapReason` union, and the
`bootstrapFailureMessage()` helper that maps each reason to its
agent-facing error. Mirrors the `session-ownership.ts` convention (types
+ pure helpers as a sibling of `scripts.ts`, unit-testable without CDP /
vitest mocking).
-
`packages/tool-server/src/tools/profiler/react/react-profiler-start.ts`
— calls bootstrap on empty `rendererInterfaces`, re-runs setup, throws
via the imported `bootstrapFailureMessage`. Also replaces two
pre-existing error strings ("hook not present", `how_to_reclaim`) with
the same agent-friendly phrasing used elsewhere in this PR.
-
`packages/tool-server/test/react-profiler/bootstrap-devtools-backend.test.ts`
*(new)* — 12 unit tests covering every reason branch (Map + array Metro
registries, require() throws, getModules throws, connect throws, connect
is a no-op, idempotent already-attached path).

## Manual verification (iPhone Air, RN 0.76.7 bridgeless, no external
DevTools)

1. Reload bundle → confirm `rIds: [1,2], riIds: []` (bug state).
2. Pre-fix: `react-profiler-start` returns *"No React renderer interface
attached yet…"*
3. Post-fix: `react-profiler-start` returns `{ started_at,
hermes_version: "for RN 0.76.7", detected_architecture: "bridgeless" }`.
Hook now shows `riIds: [1,2]`, `__argent_startWrapped__: true` on each,
`reactDevtoolsAgent` set, `__argent_isProfiling__: true`.
4. Forced a React state-hook dispatch during the session and stopped:
`total_react_commits: 2`, `unattributed_ms: 8.41` — real commit data
captured through the bootstrapped backend.
5. Second `react-profiler-start` in same session returns success via
`already-attached` early-exit (verified directly against the script).
Hook listener counts (`renderer`, `renderer-attached`, `react-devtools`)
stable across multiple cycles — no subscription leak.
6. Simulated `no-renderers` by clearing `h.renderers` → got *"React has
not rendered yet, so there is nothing to profile. Ask the user to wait
until the app shows React content…"*
2026-05-15 16:52:56 +02:00
Copilot 74481155af fix: remove hardcoded model: haiku from argent-environment-inspector skill (#206)
Removes the `model: haiku` frontmatter field from
`packages/skills/agents/argent-environment-inspector.md`.

The hardcoded model caused non-Anthropic coding harnesses (e.g. Gemini)
to fail when loading the skill, since `haiku` is an Anthropic-specific
model identifier that those runtimes don't recognize. Dropping the field
lets each harness use its own default model.

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: kacperkapusciak <39658211+kacperkapusciak@users.noreply.github.com>
2026-05-15 16:44:38 +02:00
Ignacy Łątka bea43f555a feat: per-step delayMs on flow steps (#198)
- Tool steps in a flow YAML may carry an optional `delayMs` that sleeps
that long *before* the step runs; `flow-add-step` exposes the same
field.
- New `longRunning?: boolean` field on `ToolDefinition`; when set, the
MCP fetch wrapper bypasses its 30s per-request timeout (`fetchTimeoutMs:
null`) so long invocations aren't aborted. `run-sequence` and
`flow-execute` opt in.
2026-05-13 18:32:42 +02:00
Ignacy Łątka 8ce8f8e573 fix: 100% CPU usage on broken stdio (#191)
One of our users reported occasional 100% CPU usage stale MCP server
process.

I wasn't able to reproduce this bug directly in Codex. We only have
non-codex repros that are able to consistantly reproduce a bug that
*looks* like the one reported to us.

The bug in question is: Our error handler causes errors which throws it
into an infinite loop.
 
<img width="713" height="251" alt="image"
src="https://github.com/user-attachments/assets/4b9fad41-55d0-49ab-b19c-25b7f5c12d12"
/>

### More details:

When `argent mcp` runs as an orphaned process whose stderr pipe is
broken (e.g. parent died), every `stderr.write` from the
uncaughtException handler emits an async 'error' event. Without an
'error' listener that becomes another uncaughtException, runs the
handler again, and so on forever.

Move the handlers into `fatal-handlers.ts` and add three guards:
- 'error' listeners on stdout/stderr exit cleanly when stdio is broken,
before the failure becomes another uncaughtException
- try/catch around `stderr.write` so a sync write failure exits instead
of escaping
- try/catch around the formatter so a throwing `.stack` getter or
`toString` (the production trace pointed at defaultPrepareStackTrace)
can't take down the handler

Idempotent: a second `installFatalHandlers` call is a no-op.
2026-05-12 15:40:43 +02:00
Ignacy Łątka 837d088744 test: cover #148 (android) (#186)
This PR adds tests for #148.

## Summary

Moves the Android emulator branch's test coverage out of #148 so that PR
can be reviewed without the test churn. This branch adds the test files
back on top of the (now test-stripped) base branch.

Tests inseparable from source changes (`describe-tool`,
`workspace-reader`, `workspace-reader-integration`) and the
`boot-simulator.test.ts` deletion stayed on #148 - base PR's CI passes
on its own.

## Test plan

- [x] `npx tsc --build` — clean
- [x] `npx prettier --check .` — clean
- [x] `npm test --workspaces --if-present` — 585/585 tool-server tests
pass; all other workspaces green

---------

Co-authored-by: Paweł Fornagiel <pawel.fornagiel2@gmail.com>
2026-05-12 14:21:09 +02:00
filip131311 85e7aa9612 fix(registry): emit JSON Schema type for ZodDefault fields (#199)
## Summary

`zodTypeToJsonSchema` in `packages/registry/src/zod-to-json-schema.ts`
did not handle `z.ZodDefault`. Any tool input field declared as
`z.X().default(...)` fell through to `{}`, dropping the type.
Downstream, the MCP harness sent values for those fields as strings, and
the tool-server's strict Zod validators rejected them with `Expected
number, received string` / `Expected boolean, received string`.

Also fixes a related issue in the same file: `isOptional` returned
`false` for `ZodDefault`, so defaulted fields were pushed into
`required` even though the caller doesn't have to provide them.

Tools that misbehave in practice today because of this and start working
after this fix:
- `stop-metro` — `port`
- `react-profiler-start` — `port`, `force`, `sample_interval_us`
- `debugger-component-tree` — `onScreenOnly`, `includeSkipped`

(Reproduced against the published `argent 0.6.1` MCP server while
profiling an Expo app — the `argent run <tool> --args '<json>'` CLI path
is unaffected because it doesn't go through the published JSON Schema.)

## Changes

- `packages/registry/src/zod-to-json-schema.ts`: handle `ZodDefault`
(recurse into `_def.innerType`, attach `default: defaultValue()`); treat
`ZodDefault` as optional.
- `packages/registry/tests/zod-to-json-schema.test.ts`: new file. Covers
`z.number()`, `z.boolean()`, `z.string().optional()`,
`z.number().default(...)`, `z.boolean().default(...)`,
`z.coerce.number().int().positive().default(...)`, and a mixed-object
happy path.

## Test plan

- [x] `npx vitest run` in `packages/registry` — 64 passed, 0 failed (4
files; the new test file contributes 7 cases).
- [ ] Reviewer sanity check: load the rebuilt MCP server and confirm a
tool with a `.default(...)` numeric/boolean field now publishes the
correct \`type\` in its input schema.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:00:50 +02:00
pFornagiel 60ae7cb767 chore: bump versions to 0.7.0 v0.7.0 2026-05-11 16:44:03 +02:00
pFornagiel ad502a46ed chore: bump argent-private submodule 2026-05-11 16:36:10 +02:00
Ignacy Łątka d017ba340c feat: Android emulator support (#148)
### For reviewers - what to focus on:

The diff is ~6k LoC; this breakdown should help triage where to spend
attention:

| Bucket | Lines | Effort |
| --- | --- | --- |
| **Net-new Android logic**: `boot-device.ts` (660) + `adb.ts` (399) +
`uiautomator-parser.ts` (292) | 1351 | **Read carefully** |
| Blueprint `DeviceInfo` refactor: `simulator-server`, `ax-service`,
`native-devtools` | 365 | Read |
| Tool `index.ts` / `types.ts` sprawl across ~20 tools (capability
gates, `services()`, descriptions) | ~965 | Skim — watch for cross-file
consistency |
| `platforms/android.ts` impls (stub → real implementation) | ~426 |
Skim |
| Skill/docs prose, profiler rename ripple | ~1000 | Skim |
| **`platforms/ios.ts` deletions** — 12 files, 0 additions; logic moved
into unified handlers | 684 | **Skip — mechanical** |

## What works, what doesn't:

### Just works thanks to either sim server or metro:
- `gesture-*`
- `button`, `keyboard`
- `screenshot`
- `run-sequence`, `flow-*`
- `debugger-*`
- `react-profiler-*`

### Adjusted to work on android too:

| tool | android | ios |
| --- | --- | --- |
| `describe` | `uiautomator dump` → `DescribeNode` | `AXServiceApi` |
| `launch-app` | `cmd package resolve-activity` + `am start -W` |
`simctl launch` |
| `restart-app` | `cmd package resolve-activity` + `am force-stop` + `am
start -W` | `simctl terminate` + `simctl launch` |
| `reinstall-app` | `adb install -r` (+`-g`/`-d`) | `simctl uninstall` +
`simctl install` |
| `open-url` | `am start -a VIEW -d <url>` | `simctl openurl` |
| `list-devices` | `adb devices` + `emulator -list-avds` | `xcrun simctl
list --json` |
| `boot-device` | snapshot probe → `-force-snapshot-load` hot boot with
screencap-alive guard, falls back to cold boot on any failure | `simctl
boot` + `bootstatus -b` |

### Will not work on android for now:
- `native-devtools-*` (7 total)
- `native-profiler-*` (3 total — was `ios-profiler-*`)
- `profiler-stack-query`, `profiler-combined-report` (depend on
`native-profiler-*`)
- `paste` — unregistered on both platforms for now.

---------

Co-authored-by: Paweł Fornagiel <pawel.fornagiel2@gmail.com>
2026-05-11 11:39:10 +02:00
filip131311 626c3274e2 chore: bump package versions to 0.6.1 (#193)
## Summary
- Bumps all 9 published packages from `0.6.0` → `0.6.1`.
- Affected: `@swmansion/argent`, `@argent/cli`, `@argent/installer`,
`@argent/mcp`, `@argent/tools-client`, `@argent/native-devtools-ios`,
`@argent/registry`, `@argent/skills`, `@argent/tool-server`.
- `packages/ui` (`0.0.1`) is on a separate version track and left as-is.
- Lockfile updated via `npm install`; `npm run format` was a no-op.

## Test plan
- [ ] CI green
- [ ] Run the stable publish workflow against this commit and confirm
publish succeeds
v0.6.1
2026-05-08 12:48:23 +02:00
Ignacy Łątka 9cd453b857 fix(skills): prefix bare skill references with 'argent-' (#183)
All skills are currently exposed as `argent-***`. In the markdown
though, we refer to them as just `***`. This may confuse the agent when
it tries reading `skills/***` instead of `skills/argent-***`.

This PR changes all skill references in docs to the correct `argent-***`
form.

## Why?
There's no downside to this fix. Keeping it as-is potentially introduces
some confusion especially if when the user is running a weaker model or
if we encounter namespace conflicts.
2026-05-06 16:48:46 +02:00