mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
2740a3ec38
> Built on top of #769, so this PR carries that commit too — the workflows only run for PRs targeting `main`, which is why it is based there rather than on the branch. #769 on its own is the minimal, low-risk fix for the currently-red job; if it merges first this shrinks to its own commit, and if this one merges first it closes #769 as well. ## What this does #572 landed the gate with 215 findings parked under `--max-issues`. That bounded the backlog but left it in place, and the first thing to touch it (#663) pushed the total to 218 and turned the job red. This clears the backlog and sets the ceiling to **0**, so the next unused export fails the job instead of consuming headroom nobody knew was there. | | before | after | | --- | ---: | ---: | | Unused exports | 47 | 0 | | Unused exported types | 156 | 0 | | Unused exported class members | 12 | 0 | | `--max-issues` | 215 | **0** | ## Scope check first Every workspace holding a finding is `private: true`. `@swmansion/argent` — the one published package — had **zero**, so nothing here moves a public API. ## Two shapes of fix **Still referenced inside its own file → drop `export`** (146 types, 36 exports). The declaration stays put; it just leaves the module's public surface. `tsc` keeps emitting these into the `.d.ts` as local declarations that the exported signatures reference. For the 146 **types** that is the whole story: they are erased, so the emitted `.js` is byte-identical. For the 36 **values** it is not. Dropping `export` from a `const`/`function`/`class` removes its `exports.X` binding from the emitted `dist/*.js` — 36 declarations across 26 files here. `packages/tool-server/dist/utils/device-info.js` no longer carries `exports.REMOTE_PREFIX` or `exports.VEGA_SERIAL_PREFIX`; `telemetry/dist/posthog.js` loses `exports.POSTHOG_PROJECT_TOKEN`; in the ESM-emitting `argent-installer`, `isTempRunnerPath` / `resolveLocalArgentDir` / `PACKAGE_ROOT` leave the module namespace object entirely. Nothing in the repo or in the `packages/argent-private` submodule at the pinned revision reads any of them by path, so nothing breaks. Flagging it because a `require()` of a built `dist/*.js` loses a dropped `export` exactly the way it lost the deleted `__resetAndroidDevtoolsInstallCache` this branch had to restore — so this half of the diff does deserve runtime scrutiny, not a skim. **Referenced nowhere → delete.** Orphaned test seams (`__test`, `_testValidators`), stale compatibility re-exports, and a few real orphans. The ones worth a reviewer's eye: - **`localSimctl`** — the `SimctlBackend` strategy is only ever constructed with `remoteSimctl`. What keeps the local iOS impl off it is the handler signature: `buildIosLaunchHandler` / `buildIosRestartHandler` read `services.nativeDevtools`, and `ios-remote` is the only platform that declares that service, so the local impl — which resolves native-devtools per device inside its own handler — has nothing to hand them and shells out to `xcrun` itself. - **`sendCharInsert`** — removing the `__sendCharInsert` test alias exposed the underlying helper as unreferenced. It is superseded, not unwired: the Chromium keyboard impl dispatches its own `type: "char"` event inline (`keyboard/platforms/chromium.ts:58`), which is exactly what this helper did. Chromium typing is unaffected. - **Error metadata fields** — `ServiceNotFoundError.serviceId`, `ToolNotFoundError.toolId` and their two siblings were never read. The `e.serviceId` in `registry-error-events.test.ts` is a field on the event object the handler pushes, not on the error class; the `err.toolId` in `http.ts` is `NotImplementedOnPlatformError`, a different class that keeps its fields. Flagging these in case they are wanted as deliberate diagnostics. Deleting `SourceMapsRegistry.toGeneratedPosition` / `findMatchingSource` cascaded into their only helper (`buildSourceCandidates`) and then into `projectRoot`, which had no remaining reader — so the constructor parameter goes too. `StubSourceMapsRegistry` existed only to pass `super("")`, so it goes with it. ## The three cross-workspace members `ArtifactStore.register`, `ArtifactStore.list` and `TypedEventEmitter.off` are all called from other workspaces — the artifact route and screenshot tools in `tool-server`, and `registry-listener` in `telemetry`. They are reported unused anyway, and the reason is the **unbuilt tree** the gate runs against: every workspace resolves `main`/`types` to a `dist/` that does not exist, so `@argent/registry` resolves to nothing and the cross-workspace edge never forms. Build the tree and all three findings disappear on their own; a control member that really is dead is still reported in the built run, so the pass is live either way. Nothing about this is specific to `classMembers` — every cross-workspace reference is invisible the same way. Each is exempted at the declaration with a `@public` JSDoc tag rather than by name in `knip.jsonc`, for one reason: **scope**. `ignoreMembers` matches a name across the whole workspace, so it would also hide a future dead `register`/`list`/`off` on any other class in `packages/registry`; `@public` binds to the member it is written on. What `@public` does **not** buy is a staleness check, and neither does `ignoreMembers`. Measured on a clean unbuilt worktree: a bogus `ignoreMembers` name and a redundant `@public` each leave the run at exit 0 with no hint, while a bogus `ignoreDependencies` name exits 1 with `Remove from ignoreDependencies`. `treatConfigHintsAsErrors` is live and simply has no producer for either exemption style — so when one of the three stops being earned, nothing says so, and the JSDoc at each member is the only record a re-audit has. Each one names its callers, and `off` records how to re-derive them (rename the member and read the `Property 'off' does not exist` errors; grepping `.off(` over-reports, since 14 of the 38 call-site hits are Node `EventEmitter`s). One correction to the commit message on that change: it says 35 hits where the exact count is 38 — 14 Node `EventEmitter`, 24 `TypedEventEmitter` (21 production, 3 in a test). The 14 and the 21 are right. `knip.jsonc`, `CONTRIBUTING.md` step 5 and the workflow's failure-explanation step all described a parked backlog and a ceiling to stay under. All three are rewritten for a gate that must simply come back empty, and all three now name the `@public` escape hatch — the remedy for a symbol whose only caller is out of knip's reach. ## Verification Run against a clean worktree with **no build output**, the way CI counts: - `npm run knip` — **exit 0, prints nothing**. Both passes empty, no config hints. - `npx tsc --build` — clean. - `npm run lint` (`eslint . --max-warnings 0`) — clean. - `npx prettier --check .` — clean. - `npm test --workspaces` — tool-server 329 files / 3857 passed; registry, telemetry, update-core, tools-client, mcp, cli green. - `npm run typecheck:tests --workspaces` — 13 workspaces define the script and all 13 are clean, but **the command itself exits 1**: npm errors `Missing script: "typecheck:tests"` for the three that do not define it rather than skipping them. Worth knowing before re-running this bullet and reading the exit code as a failure. End-to-end on a tool-server built from this branch, driven over its HTTP API: - **Chromium** (Electron smoke app) — `describe`, `screenshot`, `gesture-tap` (counter advanced 0 → 1), `POST /api/clipboard/text` read back out of the renderer, the WS `clipboardSync` command, and `debugger-connect` / `debugger-status` / `debugger-evaluate` (`sourceMapReady: true` with `StubSourceMapsRegistry` gone). - **iOS** — `boot-device`, `launch-app`, `describe` via ax-service, `screenshot`. - **tvOS** — `boot-device`, `launch-app` on an Apple TV udid, focus-driven `describe`. The registry snapshot then carries `NativeDevtools:<tvOS udid>`, confirming injection **is** resolved on tvOS through the local impl. - **Android** — `boot-device` on a dedicated AVD, `describe` via android-devtools, `gesture-tap`. - **Metro source maps** — the registry driven against a real 12.5 MB Metro `.map` over loopback, the way `Debugger.scriptParsed` drives it: allowlist passes and `waitForPending()` resolves on both, while retained heap after gc drops from 17.3 MB to 3.6 MB. ## Review follow-ups Five commits on top, one per finding. The base is back on `main`, so the workflows run again. - **`docs(launch-app)`** — the services comment explained the empty shape through `LaunchAppAndroidServices`, deleted here, and skipped `LaunchAppVegaServices`, which is still there. Same edit in `restart-app/types.ts`. - **`docs(registry)`** — the `@public` tag on `ArtifactStore.register` named 2 of 7 callers. Derived the full set the way the sibling tag on `off` prescribes: renamed the member, read the 16 `TS2339` errors `tsc --build` reports across flow-visual (5), screenshot (4), native-profiler-stop (2), screenshot-diff (2), native-profiler-analyze, react-profiler-analyze and screen-recording-stop. The re-derivation recipe is recorded too — `.register(` greps as badly as `.off(`. - **`docs(knip)`** — "the build, the tests and this gate all stay green on a wrong delete" held for argent-private and not for the cross-workspace case that justifies three of the four `@public` tags. Deleting `ArtifactStore.register` gives 16 `TS2339` errors and `9 failed | 2 passed` in `test/artifacts.test.ts`; only the gate stays green. Split in the workflow comment, the failure annotation and `CONTRIBUTING.md` step 5. - **`perf(debugger)`** — `doRegister`'s `data:` branch still base64-decoded and `JSON.parse`d a payload it dropped, inside a `catch` that swallows. Both arms returned the same `void`, so the branch collapses to an early return; `scriptUrl` / `scriptId` go with it, since their last reader was the deleted `this.maps.push(...)` and `doRegister` is private. - **`test(debugger)`** — the allowlist check moved to a top-level early return with nothing pinning it: delete the line and both SSRF files still pass 17/17. Two tests now assert `doRegister` consults it — four rejected URLs reach no `fetch`, and the loopback `*.map` Metro emits still does. Re-verified after: `npm run knip` exit 0 and empty on a `tsc --build --clean` tree, `tsc --build`, `eslint . --max-warnings 0`, `prettier --check .` all clean, and `npm test --workspaces` with tool-server at 329 files / 3854 passed. The one red test, `argent-installer`'s `globalPath returns ~/.config/opencode/opencode.json`, fails identically with these commits stashed — it reads the real `~/.config/opencode`, which holds a `.jsonc` on this machine. End-to-end on a tool-server run from this branch over its HTTP API, on every platform: - **Chromium** — `launch-app`, `describe`, `screenshot` (returned an artifact handle, `GET /artifacts/<id>` served the 137542-byte PNG back), `gesture-tap` (counter 0 → 1), `debugger-connect` / `debugger-status` (`sourceMapReady: true`) / `debugger-evaluate` (read `taps: 1` out of the page). - **iOS** — `launch-app`, `describe` via native-devtools, `restart-app`, `screenshot`. - **tvOS** — `launch-app`, focus-driven `describe` (12 focusables in TVSettings), `tv-remote`, `restart-app`. - **Android** — `launch-app`, `describe` via android-devtools, `restart-app`, `screenshot`. - **Vega** — `boot-device` on the `tv` VVD, `describe` via the automation toolkit. - **Metro** — the real `debugger-connect` / `debugger-status` / `debugger-evaluate` tools against a stand-in Metro (HTTP `/json/list` + CDP WebSocket) emitting three `Debugger.scriptParsed` events: a loopback `*.map` on a counting server, a 1.4 MB inline `data:` map, and `http://169.254.169.254/latest.map`. `sourceMapReady: true`, `loadedScripts: 3`, evaluate 42, the loopback map fetched exactly once and the metadata URL never. A malformed inline payload gives byte-identical output, which is the finding. ### Merge with `main` Putting the base back on `main` made CI resolve the merge, and the Unit Tests job went red on a conflict no side could see on its own: `#771` fixed the same red gate this branch's first commit did, the other way round — it made `component-names.test.ts` import `StrippedName`, `ComponentAnnotation` and `ComponentNameResolution` instead of dropping their `export`. Merged `main` in, reproduced it (`tsc --noEmit -p tsconfig.test.json` → three `TS2459`s) and took main's resolution: the three exports are back. The gate stays green because knip treats test files as entry points, so a type a test imports is not an unused export. All nine workflows are green on the head commit.
198 lines
7.5 KiB
TypeScript
198 lines
7.5 KiB
TypeScript
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
import {
|
|
drainCappedBody,
|
|
isAllowedSourceMapURL,
|
|
SourceMapsRegistry,
|
|
} from "../../src/utils/debugger/source-maps";
|
|
|
|
describe("isAllowedSourceMapURL", () => {
|
|
it("accepts a Metro localhost URL", () => {
|
|
expect(isAllowedSourceMapURL("http://localhost:8081/index.map")).toBe(true);
|
|
});
|
|
|
|
it("accepts http://127.0.0.1", () => {
|
|
expect(isAllowedSourceMapURL("http://127.0.0.1:8082/foo.map")).toBe(true);
|
|
});
|
|
|
|
it("accepts http://[::1]", () => {
|
|
expect(isAllowedSourceMapURL("http://[::1]:8081/foo.map")).toBe(true);
|
|
});
|
|
|
|
it("accepts https on loopback", () => {
|
|
expect(isAllowedSourceMapURL("https://localhost:8443/foo.map")).toBe(true);
|
|
});
|
|
|
|
it("rejects an attacker-controlled public host", () => {
|
|
expect(isAllowedSourceMapURL("http://attacker.example/leak")).toBe(false);
|
|
});
|
|
|
|
it("rejects the AWS metadata endpoint", () => {
|
|
expect(isAllowedSourceMapURL("http://169.254.169.254/latest/meta-data/")).toBe(false);
|
|
});
|
|
|
|
it("rejects a private-network host that is not loopback", () => {
|
|
expect(isAllowedSourceMapURL("http://10.0.0.1/secret.map")).toBe(false);
|
|
expect(isAllowedSourceMapURL("http://192.168.1.1/secret.map")).toBe(false);
|
|
});
|
|
|
|
it("rejects file:// URLs", () => {
|
|
expect(isAllowedSourceMapURL("file:///etc/passwd")).toBe(false);
|
|
});
|
|
|
|
it("rejects malformed URLs", () => {
|
|
expect(isAllowedSourceMapURL("not a url")).toBe(false);
|
|
expect(isAllowedSourceMapURL("")).toBe(false);
|
|
});
|
|
|
|
it("rejects an unbracketed IPv6 typo", () => {
|
|
expect(isAllowedSourceMapURL("http://::1:8081/")).toBe(false);
|
|
});
|
|
|
|
// PR #194 follow-up G: require a `.map` path so an attacker-set
|
|
// sourceMapURL can't make us GET an arbitrary loopback endpoint.
|
|
it("rejects a loopback URL whose path is not *.map", () => {
|
|
expect(isAllowedSourceMapURL("http://127.0.0.1:8081/json")).toBe(false);
|
|
expect(isAllowedSourceMapURL("http://localhost:8081/shutdown")).toBe(false);
|
|
expect(isAllowedSourceMapURL("http://localhost:8081/")).toBe(false);
|
|
});
|
|
|
|
it("still accepts a loopback *.map path (incl. with a query string)", () => {
|
|
expect(isAllowedSourceMapURL("http://localhost:8081/index.bundle.map")).toBe(true);
|
|
expect(isAllowedSourceMapURL("http://127.0.0.1:8081/index.bundle.map?platform=ios")).toBe(true);
|
|
});
|
|
});
|
|
|
|
// PR #194 follow-up F: source-map bodies are read under a cap.
|
|
describe("drainCappedBody (body cap)", () => {
|
|
it("rejects when content-length exceeds the cap", async () => {
|
|
const res = {
|
|
headers: { get: (n: string) => (n === "content-length" ? "999999999" : null) },
|
|
body: null,
|
|
};
|
|
await expect(drainCappedBody(res, 1024)).rejects.toThrow(/too large/);
|
|
});
|
|
|
|
it("returns without reading when no stream body is available", async () => {
|
|
const res = { headers: { get: () => null }, body: null };
|
|
await expect(drainCappedBody(res, 1024)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("aborts a streamed body that exceeds the cap", async () => {
|
|
const big = new Uint8Array(2048);
|
|
let sent = false;
|
|
const cancel = vi.fn(async () => {});
|
|
const res = {
|
|
headers: { get: () => null },
|
|
body: {
|
|
getReader: () => ({
|
|
read: async () =>
|
|
sent ? { done: true, value: undefined } : ((sent = true), { done: false, value: big }),
|
|
cancel,
|
|
}),
|
|
},
|
|
};
|
|
await expect(drainCappedBody(res, 1024)).rejects.toThrow(/exceeded/);
|
|
expect(cancel).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// The whole point of the rewrite: the bytes are counted, never accumulated
|
|
// and never parsed. A body that is not JSON at all must still drain clean,
|
|
// which is what tells a reader the parse is gone rather than just moved.
|
|
it("drains a body that is not JSON, under the cap", async () => {
|
|
const chunks = [new Uint8Array(400), new Uint8Array(400)];
|
|
let i = 0;
|
|
const res = {
|
|
headers: { get: () => null },
|
|
body: {
|
|
getReader: () => ({
|
|
read: async () =>
|
|
i < chunks.length
|
|
? { done: false, value: chunks[i++] }
|
|
: { done: true, value: undefined },
|
|
cancel: async () => {},
|
|
}),
|
|
},
|
|
};
|
|
await expect(drainCappedBody(res, 1024)).resolves.toBeUndefined();
|
|
expect(i).toBe(chunks.length);
|
|
});
|
|
});
|
|
|
|
// The tests above prove `isAllowedSourceMapURL` classifies correctly, but not
|
|
// that the fetch path asks it. Delete the guard from `doRegister` and every one
|
|
// of them still passes, so nothing pinned the call itself. These two do: the
|
|
// first fails if the guard goes, the second fails if it ever rejects the URLs
|
|
// Metro really emits.
|
|
describe("doRegister consults the allowlist", () => {
|
|
afterEach(() => vi.restoreAllMocks());
|
|
|
|
function stubFetch() {
|
|
return vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
new Response(JSON.stringify({ version: 3, sources: [], mappings: "" }), {
|
|
headers: { "content-type": "application/json" },
|
|
})
|
|
);
|
|
}
|
|
|
|
it("never fetches a sourceMapURL the allowlist rejects", async () => {
|
|
const fetchSpy = stubFetch();
|
|
const reg = new SourceMapsRegistry();
|
|
for (const url of [
|
|
"http://127.0.0.2:8081/evil.map", // loopback range, but not an allowed host
|
|
"http://169.254.169.254/latest.map", // cloud metadata, named in this file's header
|
|
"http://attacker.example/leak.map",
|
|
"http://localhost:8081/shutdown", // loopback, but not a *.map path
|
|
]) {
|
|
reg.registerFromScriptParsed("http://localhost:8081/index.bundle", "1", url);
|
|
}
|
|
await reg.waitForPending();
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// `sourceMapURL` is a bare cast over socket JSON — cdp-client reads
|
|
// `params.sourceMapURL as string | undefined` off a Debugger.scriptParsed
|
|
// frame and js-runtime-debugger forwards it unchecked, so a CDP peer can put
|
|
// a number there. Everything doRegister does with it must therefore sit
|
|
// inside the try. Hoisting the `data:` test out of it (866d90ce) let the
|
|
// TypeError escape as a rejected promise nothing awaits before the next
|
|
// tick, which index.ts turns into crashShutdown.
|
|
//
|
|
// The tick matters: waitForPending() attaches allSettled synchronously, so
|
|
// calling it straight after register hides the bug. Production has a real
|
|
// gap — scriptParsed fires during CDP message handling, the wait comes much
|
|
// later — which the timeout below reproduces.
|
|
it("skips a non-string sourceMapURL instead of rejecting out of doRegister", async () => {
|
|
const fetchSpy = stubFetch();
|
|
const reg = new SourceMapsRegistry();
|
|
const unhandled: unknown[] = [];
|
|
const onUnhandled = (reason: unknown) => unhandled.push(reason);
|
|
process.on("unhandledRejection", onUnhandled);
|
|
try {
|
|
reg.registerFromScriptParsed(
|
|
"http://localhost:8081/index.bundle",
|
|
"1",
|
|
12345 as unknown as string
|
|
);
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
await reg.waitForPending();
|
|
} finally {
|
|
process.off("unhandledRejection", onUnhandled);
|
|
}
|
|
expect(unhandled).toEqual([]);
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("still fetches the loopback *.map URL Metro emits", async () => {
|
|
const fetchSpy = stubFetch();
|
|
const reg = new SourceMapsRegistry();
|
|
reg.registerFromScriptParsed(
|
|
"http://localhost:8081/index.bundle",
|
|
"1",
|
|
"http://localhost:8081/index.bundle.map"
|
|
);
|
|
await reg.waitForPending();
|
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
expect(fetchSpy.mock.calls[0][0]).toBe("http://localhost:8081/index.bundle.map");
|
|
});
|
|
});
|