Files
software-mansion__argent/scripts/dev.cjs
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

369 lines
13 KiB
JavaScript

#!/usr/bin/env node
"use strict";
/**
* Full dev mode — no packing, no global install needed.
*
* What it does:
* 1. Builds native devtools dylibs (libArgentInjectionBootstrap, libNativeDevtoolsIos, libKeyboardPatch)
* 2. Builds the dispatcher TypeScript (tsc only, no esbuild bundles)
* 3. Sets up packages/argent/bin/ and packages/argent/dist/ for local use
* 4. Patches supported editor MCP configs to point argent at the local dist
* 5. Starts the tool-server from source via ts-node (no build needed)
* 6. Writes ~/.argent/tool-server.json so the local MCP picks it up
* 7. On exit: restores patched editor configs and stops the tool-server
*
* Usage:
* npm run dev
* PORT=4000 npm run dev
*/
const { execSync, spawn } = require("child_process");
const fs = require("fs");
const path = require("path");
const os = require("os");
const ROOT = path.resolve(__dirname, "..");
const ARGENT_PKG = path.join(ROOT, "packages", "argent");
const TOOL_SERVER_PKG = path.join(ROOT, "packages", "tool-server");
const NATIVE_DEVTOOLS_PKG = path.join(ROOT, "packages", "native-devtools-ios");
const STATE_DIR = path.join(os.homedir(), ".argent");
const STATE_FILE = path.join(STATE_DIR, "tool-server.json");
const CLAUDE_JSON = path.join(os.homedir(), ".claude.json");
const CURSOR_DIR = path.join(os.homedir(), ".cursor");
const CURSOR_MCP_JSON = path.join(CURSOR_DIR, "mcp.json");
const PORT = parseInt(process.env.PORT ?? "3001", 10);
// ── Helpers ───────────────────────────────────────────────────────────────────
function readJson(filePath) {
if (!fs.existsSync(filePath)) return {};
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function writeJson(filePath, data) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
}
function isProcessAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function waitForHttp(url, timeoutMs = 20_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(1000) });
if (res.ok) return true;
} catch {}
await new Promise((r) => setTimeout(r, 400));
}
return false;
}
// ── Step 1: Build native devtools dylibs (macOS only) ───────────────────────
// Dylibs are macOS-only at runtime (DYLD_INSERT_LIBRARIES into iOS Simulator)
// and macOS-only at build time (Xcode). Skip on Linux/Windows so a contributor
// without `argent-private` SSH access can still run `npm run dev`.
if (process.platform === "darwin") {
const DYLIBS_DIR = path.join(NATIVE_DEVTOOLS_PKG, "dylibs");
const DYLIBS_EXIST = fs.existsSync(path.join(DYLIBS_DIR, "libNativeDevtoolsIos.dylib"));
const PRIVATE_NATIVE_DEVTOOLS_SRC = path.join(
ROOT,
"packages",
"argent-private",
"packages",
"native-devtools-ios",
"Sources",
"NativeDevtoolsIos"
);
// Try to init the submodule and rebuild. Failure is non-fatal if pre-built
// dylibs are already present — developers without argent-private access can
// still work on Argent using the committed binaries.
let submoduleReady = false;
try {
// Preserve an existing argent-private checkout so local branch switches
// are not reset back to the superproject's recorded gitlink on every dev run.
if (!fs.existsSync(PRIVATE_NATIVE_DEVTOOLS_SRC)) {
execSync("git submodule update --init packages/argent-private", {
cwd: ROOT,
stdio: "pipe",
});
}
submoduleReady = true;
} catch {
if (DYLIBS_EXIST) {
console.warn("⚠ argent-private submodule unavailable — using pre-built dylibs\n");
} else {
console.error("✗ argent-private submodule unavailable and no pre-built dylibs found.");
console.error(" Grant SSH access to github.com/software-mansion-labs/argent-private");
console.error(
" or obtain pre-built dylibs and place them in packages/native-devtools-ios/dylibs/"
);
process.exit(1);
}
}
if (submoduleReady) {
console.log("Building native devtools dylibs...");
execSync("bash scripts/build.sh dev", {
cwd: NATIVE_DEVTOOLS_PKG,
stdio: "inherit",
});
console.log("✓ Native devtools dylibs built\n");
}
} else {
console.log(`⊘ Skipping native devtools dylibs build (macOS-only on ${process.platform})\n`);
}
// ── Step 2: Build MCP TypeScript ─────────────────────────────────────────────
console.log("Building dispatcher TypeScript...");
execSync("npm run build:dispatcher -w @swmansion/argent", {
cwd: ROOT,
stdio: "inherit",
});
console.log("✓ Dispatcher TypeScript built\n");
// ── Step 3: Set up packages/argent/bin/ and skills/rules/agents ──────────────
// Copy the simulator-server binary for the current host platform into the
// platform-keyed subdir the runtime resolver looks at. Mirrors bundle-tools.cjs
// but only for `process.platform` — dev iterations don't need every host's
// binary alongside.
const BIN_DIR = path.join(ARGENT_PKG, "bin", process.platform);
const BIN_SRC = path.join(NATIVE_DEVTOOLS_PKG, "bin", process.platform, "simulator-server");
const BIN_DEST = path.join(BIN_DIR, "simulator-server");
fs.mkdirSync(BIN_DIR, { recursive: true });
if (fs.existsSync(BIN_SRC)) {
fs.copyFileSync(BIN_SRC, BIN_DEST);
fs.chmodSync(BIN_DEST, 0o755);
console.log(`✓ Copied simulator-server binary (${process.platform})`);
} else {
console.warn(
`⚠ simulator-server binary not found at ${BIN_SRC} — gestures won't work. ` +
`Run: bash scripts/download-simulator-server.sh`
);
}
for (const [srcName, destName] of [
["skills/skills", "skills"],
["skills/rules", "rules"],
["skills/agents", "agents"],
]) {
const src = path.join(ROOT, "packages", srcName);
const dest = path.join(ARGENT_PKG, destName);
if (fs.existsSync(src)) {
fs.rmSync(dest, { recursive: true, force: true });
fs.cpSync(src, dest, { recursive: true });
}
}
console.log("✓ Copied skills/rules/agents");
// Stub tool-server.cjs — the launcher won't use it as long as the dev server
// is registered in the state file, but it needs to exist to avoid a crash.
const STUB = path.join(ARGENT_PKG, "dist", "tool-server.cjs");
if (!fs.existsSync(STUB)) {
fs.mkdirSync(path.dirname(STUB), { recursive: true });
fs.writeFileSync(
STUB,
"throw new Error('dev mode: tool-server stub — start npm run dev first');\n"
);
}
function restoreMcpEntry(configPath, originalEntry, existedBefore) {
const config = readJson(configPath);
if (!config.mcpServers) config.mcpServers = {};
if (originalEntry) {
config.mcpServers.argent = originalEntry;
} else {
delete config.mcpServers.argent;
}
if (config.mcpServers && Object.keys(config.mcpServers).length === 0) {
delete config.mcpServers;
}
if (!existedBefore && Object.keys(config).length === 0) {
try {
fs.unlinkSync(configPath);
} catch {}
return;
}
writeJson(configPath, config);
}
// ── Step 4: Patch editor MCP configs to use local MCP dist ───────────────────
const LOCAL_MCP_ENTRY = path.join(ARGENT_PKG, "dist", "cli.js");
const LOG_FILE = path.join(STATE_DIR, "mcp-calls.log");
const claudeConfigExists = fs.existsSync(CLAUDE_JSON);
const claudeConfig = readJson(CLAUDE_JSON);
const originalArgentEntry = claudeConfig?.mcpServers?.argent ?? null;
const shouldPatchCursor = fs.existsSync(CURSOR_DIR);
const cursorConfigExists = fs.existsSync(CURSOR_MCP_JSON);
const cursorConfig = shouldPatchCursor ? readJson(CURSOR_MCP_JSON) : {};
const originalCursorArgentEntry = cursorConfig?.mcpServers?.argent ?? null;
const devMcpEntry = {
type: "stdio",
command: "node",
args: [LOCAL_MCP_ENTRY, "mcp"],
// Point the dev MCP straight at the running dev tool-server. Without this
// it calls ensureToolsServer(), which now treats the tokenless dev state
// as stale, SIGTERMs the dev server and tries to spawn the throwing dev
// stub — breaking `npm run dev`. The dev server runs auth-disabled, so no
// ARGENT_AUTH_TOKEN is needed here.
env: { ARGENT_MCP_LOG: LOG_FILE, ARGENT_TOOLS_URL: `http://127.0.0.1:${PORT}` },
};
if (!claudeConfig.mcpServers) claudeConfig.mcpServers = {};
claudeConfig.mcpServers.argent = devMcpEntry;
writeJson(CLAUDE_JSON, claudeConfig);
console.log(`✓ Patched ~/.claude.json → node ${LOCAL_MCP_ENTRY} mcp`);
if (shouldPatchCursor) {
if (!cursorConfig.mcpServers) cursorConfig.mcpServers = {};
cursorConfig.mcpServers.argent = {
command: "node",
args: [LOCAL_MCP_ENTRY, "mcp"],
// Same env as the Claude entry (incl. ARGENT_TOOLS_URL) so Cursor's dev
// MCP also reuses the running dev tool-server instead of killing it.
env: devMcpEntry.env,
};
writeJson(CURSOR_MCP_JSON, cursorConfig);
console.log(`✓ Patched ~/.cursor/mcp.json → node ${LOCAL_MCP_ENTRY} mcp\n`);
} else {
console.log("• Skipped Cursor patch (no ~/.cursor directory found)\n");
}
// ── Cleanup on exit ───────────────────────────────────────────────────────────
let toolServerPid = null;
function cleanup() {
console.log("\nCleaning up...");
// Stop tool-server
if (toolServerPid && isProcessAlive(toolServerPid)) {
process.kill(toolServerPid, "SIGTERM");
}
try {
fs.unlinkSync(STATE_FILE);
} catch {}
// Restore editor configs
restoreMcpEntry(CLAUDE_JSON, originalArgentEntry, claudeConfigExists);
console.log("✓ Restored ~/.claude.json");
if (shouldPatchCursor) {
restoreMcpEntry(CURSOR_MCP_JSON, originalCursorArgentEntry, cursorConfigExists);
console.log("✓ Restored ~/.cursor/mcp.json");
}
console.log("Done.");
}
process.on("SIGINT", () => {
cleanup();
process.exit(0);
});
process.on("SIGTERM", () => {
cleanup();
process.exit(0);
});
process.on("exit", cleanup);
async function main() {
// ── Step 5: Kill any existing registered tool-server ───────────────────────
fs.mkdirSync(STATE_DIR, { recursive: true });
const existingState = readJson(STATE_FILE);
if (existingState.pid && isProcessAlive(existingState.pid)) {
console.log(`Stopping existing tool-server (PID ${existingState.pid})...`);
process.kill(existingState.pid, "SIGTERM");
await new Promise((r) => setTimeout(r, 600));
}
try {
fs.unlinkSync(STATE_FILE);
} catch {}
// ── Step 6: Start tool-server from source ──────────────────────────────────
console.log(`Starting dev tool-server on port ${PORT}...`);
const toolServer = spawn("npx", ["ts-node", "src/index.ts"], {
cwd: TOOL_SERVER_PKG,
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, PORT: String(PORT) },
});
toolServerPid = toolServer.pid;
const logStream = fs.createWriteStream(path.join(STATE_DIR, "tool-server.log"), { flags: "a" });
toolServer.stdout.pipe(logStream);
toolServer.stderr.pipe(logStream);
toolServer.on("exit", (code) => {
if (code !== 0 && code !== null) {
console.error(`\nTool-server exited with code ${code}. Check ~/.argent/tool-server.log`);
}
});
// ── Step 7: Wait for tool-server to be ready ───────────────────────────────
process.stdout.write("Waiting for tool-server");
const ready = await waitForHttp(`http://127.0.0.1:${PORT}/tools`);
if (!ready) {
console.error("\nTool-server failed to start. Check ~/.argent/tool-server.log");
cleanup();
process.exit(1);
}
console.log(" ready.");
// ── Step 8: Write state file ────────────────────────────────────────────────
writeJson(STATE_FILE, {
port: PORT,
pid: toolServerPid,
startedAt: new Date().toISOString(),
bundlePath: "dev",
});
// ── Done ────────────────────────────────────────────────────────────────────
console.log(`
✓ Dev environment ready
Tool-server: http://127.0.0.1:${PORT}/tools
MCP: ${LOCAL_MCP_ENTRY}
Logs: ~/.argent/tool-server.log
Start a new Claude Code or Cursor session to pick up the local MCP.
After tool-server code changes → Ctrl+C and re-run npm run dev
After MCP code changes → re-run npm run dev (rebuilds MCP automatically)
Press Ctrl+C to stop and restore global argent.
`);
// Keep alive until tool-server exits or Ctrl+C
await new Promise((resolve) => {
toolServer.on("exit", resolve);
});
}
main().catch((err) => {
console.error(err);
cleanup();
process.exit(1);
});