Files
Ignacy Łątka 86a2668fb0 test: stop the suite and the process environment leaking into each other (#1058)
Test env leakage, both directions.

**Out** — six `argent-tools-client` suites pointed `HOME`/`USERPROFILE`
at a temp dir, deleted the dir, and never restored, so a `--no-isolate`
run handed every later file a home that no longer exists. Lifted the
redirect into a shared `test/helpers/home-redirect.ts`; the three suites
that already hand-rolled the save/restore moved onto it too.

**In** — `packages/telemetry` had no `setupFiles` at all, so its unit
tests read the developer's shell. Added
`test/setup/clear-telemetry-env.ts` (ARGENT_ prefix sweep +
`DO_NOT_TRACK` + the cloud-agent detector's markers), wired via
`setupFiles`, following the `@argent/tool-server` precedent — and
dropped the per-file guards it replaces.

**Pinned, not just fixed** — a `test/setup/assert-env-restored.ts` in
each of the three packages fails any suite that leaves a watched
variable changed, so dropping a restore can no longer pass. Each watches
`HOME` and `USERPROFILE`, plus `PATH` (`argent-tools-client`,
`tool-server`) or `CI` and the five `OTEL_EXPORTER_OTLP_*` (`telemetry`)
— the ones the sweep deliberately leaves ambient and individual tests
then set by hand. Each of the three `clear-*-env` setup files has a pin
test that re-imports it against planted sentinels;
`packages/tool-server`'s existing copy gains the mixed-case one it was
missing.

`packages/tool-server` is in the diff because it has the same two gaps:
it originated the prefix sweep (both pre-existing copies were
case-sensitive, so they are fixed together) and it has the largest
redirect surface of the three — 12 files hand-rolling the redirect plus
17 going through `scopeTempHome` — with no guard. It is already green
under one; deleting `scopeTempHome`'s restore turns 17 files red. Two of
its suites restored `HOME` by assigning back a saved value that can be
`undefined`, writing the string `"undefined"`; the guard caught both on
a host with no ambient `HOME`, and they are fixed here.

`windows-e2e.yml` runs seven `tool-server` files through the config this
branch edits, but neither that config nor `test/setup/**` was in its
`paths:` filter — so the new setup file would have reached
windows-latest unverified. Added both.

No docs change: test infrastructure only, no user-facing behaviour.

Fixes #1028
Fixes #839

Same-class problems found while reviewing this and filed rather than
folded in: #1068 (`argent-cli` fails under `DO_NOT_TRACK=1`), #1069 (the
`ios-device-runner` suites pin only `HOME`, so the redirect is inert on
Windows — its second half, the restore that wrote the string
`"undefined"`, is fixed here because the new guard caught it), #1070
(`useTempHome` shares one saved-`HOME` slot, so a nested `scopeHome()`
restores a deleted dir), #1071 (`tool-server`'s sweep is prefix-only, so
an exported `ADB_SERVER_SOCKET` reddens its suite), #1072
(`external-devices.test.ts` reads the developer's real
`~/.argent/providers`) and #1073 (`flow-script-environment.test.ts`
fails with no ambient `HOME`).

<details>
<summary>#1028 — probe results, six files</summary>

`test/zz-home-probe.test.ts` from the issue body, run per suite.
Vitest's sequencer orders by cached duration and falls back to file
size, so the cache is cleared before each run to keep the tiny probe
last:

```
rm -rf node_modules/.vite/vitest ../../node_modules/.vite/vitest
npx vitest run test/<suite>.test.ts test/zz-home-probe.test.ts --no-isolate --no-file-parallelism
```

| suite | before | after |
| --- | --- | --- |
| `launcher-state` | 1 failed \| 23 passed — `argent-state-test-…
exists=false` | 24 passed |
| `launcher-spawn` | 1 failed \| 8 passed — `argent-spawn-test-…
exists=false` | 9 passed |
| `launcher-version-gate` | 1 failed \| 7 passed —
`argent-version-gate-test-… exists=false` | 8 passed |
| `launcher-duplicate-spawn` | 1 failed \| 10 passed —
`argent-dup-test-… exists=false` | 11 passed |
| `launcher-exit` | 1 failed \| 1 passed — `argent-exit-test-…
exists=false` | 2 passed |
| `link-config` | 1 failed \| 43 passed — `argent-link-config-test-…
exists=false` | 44 passed |
| `launcher-sweep` (control, fixed in #713) | passed | passed |
| `launcher-ps-guard` (control, fixed in #713) | passed | passed |

The probe itself is a diagnostic and is not part of the diff —
`test/setup/assert-env-restored.ts` is the durable guard, and it needs
no `--no-isolate` to fire.
</details>

<details>
<summary>#839 — the stated repro is stale; here is what is actually
left</summary>

The issue was filed 2026-08-18. #714 landed 2026-08-19 and
hand-enumerated the variables inside `index.test.ts` (a
`CONSENT_ENV_KEYS` `beforeEach`) and `base-props.test.ts` (eight
`delete`s). On `aa90873b5` all three named repros are green:

```
$ DO_NOT_TRACK=1            npx vitest run --root packages/telemetry   → 319 passed
$ ARGENT_TELEMETRY=0        npx vitest run --root packages/telemetry   → 319 passed
$ CURSOR_AGENT_WORKER_ID=x  npx vitest run --root packages/telemetry   → 319 passed
```

What remains is the structural gap the issue names: the protection is
per-file and per-variable, nothing pins it, and `vitest.config.ts` still
has no `setupFiles`. This PR moves the property to the setup file and
deletes the two hand-rolled guards, so the setup file is what holds it —
see the mutation section, where removing the `setupFiles` line
reproduces the issue's 17 and 1 exactly.

Post-change sweep, one hostile value per run, 329 tests each:
`DO_NOT_TRACK=1`, `ARGENT_TELEMETRY=0`, `ARGENT_TELEMETRY_DEBUG=1`,
`CLAUDE_CODE_ENVIRONMENT_KIND=byoc`, `CLAUDE_CODE_ENTRYPOINT=remote`,
`CLAUDE_CODE_REMOTE_SESSION_ID=x`, `CURSOR_AGENT_WORKER_ID=x`,
`CURSOR_WORKER_POOL_NAME=x`, `GITHUB_ACTIONS=true`,
`GITHUB_ACTOR=copilot`,
`GITHUB_WORKFLOW_REF=…copilot-swe-agent.yml@refs/heads/main`,
`REPLIT_AGENT=1`, `ARGENT_HOST`/`ARGENT_PORT`/`ARGENT_AUTH_TOKEN`,
`CI=1`, `CI=false`, `OTEL_EXPORTER_OTLP_ENDPOINT`,
`OTEL_EXPORTER_OTLP_HEADERS`, the real CI combination, and all eleven at
once — 329 passed every time.

`GITHUB_ACTIONS` is a ci-info vendor input as well as a copilot-agent
marker, so clearing it does move `isCi()` — but only its GitHub Actions
branch, and an Actions run also sets `CI`, which `isCi()` answers ahead
of any vendor check. `GITHUB_ACTOR` and `GITHUB_WORKFLOW_REF` appear in
neither `ci-info/vendors.json` nor `ci-detect.ts`'s generic list, so
they cost nothing. The rest of the ci-info surface and `OTEL_*` are
deliberately left alone; the setup file says so.
</details>

<details>
<summary>Mutation testing — every guard, applied by hand, verified to
fail, reverted</summary>

| mutation | result |
| --- | --- |
| drop `setupFiles` from `packages/telemetry/vitest.config.ts` |
`DO_NOT_TRACK=1` → 19 failed (**17 in `index.test.ts`**, matching the
issue, + the two registration pins) |
| drop `CURSOR_WORKER_POOL_NAME` from `CLEARED_ENV_VARS` | drift test
red |
| move an `env.` read into `src/detect/vendor.ts` | drift test red,
naming `detect/vendor.ts` |
| rewrite a read as `env["X"]`, `const { X } = env`, `const { X }:
NodeJS.ProcessEnv = env` or `const { X = "d" } = env` | drift test red |
| add `cloud-agent-detect.ts` to `UNSWEPT_SRC_FILES` | drift test red |
| revert `toUpperCase()` in any of the three sweeps | that package's
mixed-case case red |
| drop `restoreHome()` from one suite / from all nine | 1 file / 9 files
red, each naming its deleted dir |
| drop `scopeTempHome()`'s restore in `tool-server` | 17 files red |
| drop the hand-rolled `CI` restore in `base-props.test.ts` | red,
naming that file (green before; under `--no-isolate`, 7 unrelated
failures in `otel-endpoint.test.ts`) |
| narrow either guard's filter to variables that were already set |
`assert-env-restored.test.ts` red |
| replace the leaked half of the guard's message with a constant |
`assert-env-restored.test.ts` red |
| rename one function's `env` parameter in `cloud-agent-detect.ts` |
drift test red — the read is found under the new binding |
| narrow any guard's filter to a subset of `SCOPED_ENV_VARS` |
`assert-env-restored.test.ts` red — the pin plants every entry |
| drop the hand-rolled OTLP restore in `otel-endpoint-live.test.ts` |
red, naming all four leaked variables |
| move the guard's `AMBIENT` snapshot inside `assertEnvRestored` |
`assert-env-restored.test.ts` red in all three packages |
| delete the guard's `afterAll(...)` registration |
`assert-env-restored.test.ts` red in all three packages |
| drop `restoreHome()` from `artifacts.test.ts`'s two `afterEach` | red
— the file-level guard catches a per-test omission |
| drop ps-guard's `PATH` restore | red, naming the file |
| empty `leakedEnvVars`, or drop a name from `SCOPED_ENV_VARS` |
`assert-env-restored.test.ts` red |
| replace the helper's delete branch with a plain assignment |
`home-redirect.test.ts` red |
| drop `USERPROFILE` from the helper | `home-redirect.test.ts` red |

</details>

<details>
<summary>Local CI parity</summary>

```
npx vitest run --root packages/argent-tools-client               213 passed | 1 skipped
npx vitest run --root packages/argent-tools-client --no-isolate  213 passed | 1 skipped
npx vitest run --root packages/telemetry                         329 passed
npx vitest run --root packages/telemetry --no-isolate            329 passed
npx vitest run --root packages/tool-server                       6166 passed | 1 skipped
npx tsc --noEmit -p packages/{argent-tools-client,telemetry,tool-server}/tsconfig.test.json   clean
npm run format / npm run lint / npm run knip                                                 clean
```
</details>


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **Tests**
- Improved test isolation by consistently restoring temporary
environment settings after test runs.
- Added safeguards that detect leaked environment settings and flag
incomplete cleanup.
- Added coverage to ensure telemetry-related environment variables are
cleared before tests and do not affect results.
- Added cross-platform checks for environment variable handling,
including mixed-case names.
- Expanded validation for home-directory redirection and restoration
behavior.
- Added checks confirming environment cleanup runs in the correct order
across test suites.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-09 12:38:51 +02:00

1062 lines
42 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { mkdtemp, mkdir, rm, readFile, writeFile, stat, symlink, readdir } from "node:fs/promises";
import { execFileSync } from "node:child_process";
import {
materializeArtifacts,
isArtifactHandle,
getDeviceIdFromArgs,
artifactDir,
durableSaveTarget,
ARTIFACT_MARKER,
type ArtifactHandle,
} from "../src/artifacts.js";
import { redirectHomeTo } from "./helpers/home-redirect.js";
function handle(id: string, filename: string, mimeType: string): ArtifactHandle {
return { [ARTIFACT_MARKER]: true, id, filename, mimeType, size: 0 };
}
const PNG = [0x89, 0x50, 0x4e, 0x47, 0x01, 0x02];
function fakeFetch(map: Record<string, number[]>): typeof fetch {
return (async (url: string) => {
const id = url.split("/artifacts/")[1]!;
const bytes = map[id];
if (!bytes) return { ok: false, arrayBuffer: async () => new ArrayBuffer(0) };
return { ok: true, arrayBuffer: async () => new Uint8Array(bytes).buffer };
}) as unknown as typeof fetch;
}
describe("isArtifactHandle", () => {
it("recognizes a handle and rejects plain objects", () => {
expect(isArtifactHandle(handle("a", "x.png", "image/png"))).toBe(true);
expect(isArtifactHandle({ id: "a" })).toBe(false);
expect(isArtifactHandle(null)).toBe(false);
expect(isArtifactHandle("string")).toBe(false);
});
});
describe("getDeviceIdFromArgs", () => {
it("reads udid then device_id", () => {
expect(getDeviceIdFromArgs({ udid: "U1" })).toBe("U1");
expect(getDeviceIdFromArgs({ device_id: "D1" })).toBe("D1");
expect(getDeviceIdFromArgs({})).toBeUndefined();
expect(getDeviceIdFromArgs(null)).toBeUndefined();
});
});
describe("materializeArtifacts", () => {
let root: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "argent-artifacts-"));
process.env.ARGENT_ARTIFACTS_DIR = root;
});
afterEach(async () => {
delete process.env.ARGENT_ARTIFACTS_DIR;
await rm(root, { recursive: true, force: true });
});
it("downloads an image handle, rewrites it to a local path, and collects the image", async () => {
const h = handle("img1", "shot.png", "image/png");
const { result, images } = await materializeArtifacts(
{ image: h },
{ toolsUrl: "http://remote:3001", deviceId: "DEV-1", fetchImpl: fakeFetch({ img1: PNG }) }
);
const localPath = (result as { image: string }).image;
expect(typeof localPath).toBe("string");
// Lives under the structured cache: <root>/<project>/<session>/<device>/
expect(localPath.startsWith(artifactDir("DEV-1"))).toBe(true);
expect(localPath.endsWith("shot.png")).toBe(true);
expect(Buffer.from(await readFile(localPath))).toEqual(Buffer.from(PNG));
expect(images).toHaveLength(1);
expect(images[0]!.mimeType).toBe("image/png");
expect(images[0]!.localPath).toBe(localPath);
});
it("sends the auth token as a Bearer header when downloading (remote authenticated server)", async () => {
const seen: Array<RequestInit | undefined> = [];
const recordingFetch = (async (_url: string, init?: RequestInit) => {
seen.push(init);
return { ok: true, arrayBuffer: async () => new Uint8Array(PNG).buffer };
}) as unknown as typeof fetch;
await materializeArtifacts(
{ image: handle("img1", "shot.png", "image/png") },
{ toolsUrl: "http://remote:3001", authToken: "secret-token", fetchImpl: recordingFetch }
);
expect(seen).toHaveLength(1);
expect((seen[0]!.headers as Record<string, string>).Authorization).toBe("Bearer secret-token");
});
it("omits the Authorization header when no auth token is present", async () => {
const seen: Array<RequestInit | undefined> = [];
const recordingFetch = (async (_url: string, init?: RequestInit) => {
seen.push(init);
return { ok: true, arrayBuffer: async () => new Uint8Array(PNG).buffer };
}) as unknown as typeof fetch;
await materializeArtifacts(
{ image: handle("img1", "shot.png", "image/png") },
{ toolsUrl: "http://remote:3001", fetchImpl: recordingFetch }
);
expect((seen[0]!.headers as Record<string, string>).Authorization).toBeUndefined();
});
it("walks nested handles (e.g. exportedFiles) and leaves non-handles untouched", async () => {
const result = {
exportedFiles: {
cpu: handle("cpu1", "cpu.xml", "application/xml"),
hangs: null,
},
duration_ms: 1234,
};
const { result: out, images } = await materializeArtifacts(result, {
toolsUrl: "http://remote:3001",
fetchImpl: fakeFetch({ cpu1: [60, 61, 62] }),
});
const o = out as { exportedFiles: { cpu: string; hangs: null }; duration_ms: number };
expect(o.duration_ms).toBe(1234);
expect(o.exportedFiles.hangs).toBeNull();
expect(o.exportedFiles.cpu.endsWith("cpu.xml")).toBe(true);
// XML is not an image — no inline render.
expect(images).toHaveLength(0);
});
it("rewrites a handle to null when its download fails", async () => {
const { result } = await materializeArtifacts(
{ image: handle("missing", "x.png", "image/png") },
{ toolsUrl: "http://remote:3001", fetchImpl: fakeFetch({}) }
);
expect((result as { image: null }).image).toBeNull();
});
it("rewrites a handle to null when the downloaded byte count doesn't match size", async () => {
const h: ArtifactHandle = {
[ARTIFACT_MARKER]: true,
id: "trunc",
filename: "data.xml",
mimeType: "application/xml",
size: 99, // server announced 99 bytes…
};
const { result } = await materializeArtifacts(
{ file: h },
{ toolsUrl: "http://remote:3001", fetchImpl: fakeFetch({ trunc: [1, 2, 3] }) } // …delivered 3
);
expect((result as { file: null }).file).toBeNull();
});
it("scopes the cache dir by device and omits the device segment when absent", () => {
expect(artifactDir("DEV-9").endsWith("DEV-9")).toBe(true);
expect(artifactDir()).not.toContain("undefined");
});
});
// ── gate: local short-circuit (co-located tool-server) ───────────────
describe("materializeArtifacts local short-circuit", () => {
let root: string;
let hostDir: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "argent-artifacts-"));
hostDir = await mkdtemp(join(tmpdir(), "argent-host-"));
process.env.ARGENT_ARTIFACTS_DIR = root;
});
afterEach(async () => {
delete process.env.ARGENT_ARTIFACTS_DIR;
await rm(root, { recursive: true, force: true });
await rm(hostDir, { recursive: true, force: true });
});
// Writes a real file and returns a handle pointing at it with matching
// integrity metadata — i.e. what a co-located tool-server would emit.
async function localFileHandle(
id: string,
filename: string,
mimeType: string,
bytes: number[]
): Promise<ArtifactHandle> {
const hostPath = join(hostDir, filename);
await writeFile(hostPath, Buffer.from(bytes));
const st = await stat(hostPath);
return {
[ARTIFACT_MARKER]: true,
id,
filename,
mimeType,
size: st.size,
hostPath,
mtimeMs: st.mtimeMs,
};
}
const throwingFetch: typeof fetch = (async () => {
throw new Error("fetch must not be called when the file is already local");
}) as unknown as typeof fetch;
it("uses hostPath directly without downloading and reads image bytes in place", async () => {
const h = await localFileHandle("img1", "shot.png", "image/png", PNG);
const { result, images } = await materializeArtifacts(
{ image: h },
{ toolsUrl: "http://localhost:3001", deviceId: "DEV-1", fetchImpl: throwingFetch }
);
// Resolved to the original host path — no copy under the temp cache.
expect((result as { image: string }).image).toBe(h.hostPath);
expect((result as { image: string }).image.startsWith(artifactDir("DEV-1"))).toBe(false);
expect(images).toHaveLength(1);
expect(images[0]!.localPath).toBe(h.hostPath);
expect(images[0]!.data).toEqual(Buffer.from(PNG));
});
it("uses hostPath for non-image artifacts without reading or copying", async () => {
const h = await localFileHandle("cpu1", "cpu.xml", "application/xml", [60, 61, 62]);
const { result, images } = await materializeArtifacts(
{ exportedFiles: { cpu: h } },
{ toolsUrl: "http://localhost:3001", fetchImpl: throwingFetch }
);
expect((result as { exportedFiles: { cpu: string } }).exportedFiles.cpu).toBe(h.hostPath);
expect(images).toHaveLength(0);
});
it("falls back to download when the local file no longer matches the recorded size", async () => {
// Local file is stale (2 bytes) but the handle records the authoritative
// size (PNG.length), so the gate misses and re-downloads. The server serves
// bytes matching the recorded size, so the download's integrity check passes.
const hostPath = join(hostDir, "shot.png");
await writeFile(hostPath, Buffer.from([0x01, 0x02]));
const h: ArtifactHandle = {
[ARTIFACT_MARKER]: true,
id: "img2",
filename: "shot.png",
mimeType: "image/png",
size: PNG.length,
hostPath,
};
const { result, images } = await materializeArtifacts(
{ image: h },
{ toolsUrl: "http://remote:3001", fetchImpl: fakeFetch({ img2: PNG }) }
);
const out = (result as { image: string }).image;
expect(out).not.toBe(hostPath);
expect(out.startsWith(artifactDir())).toBe(true); // downloaded into the temp cache
expect(Buffer.from(await readFile(out))).toEqual(Buffer.from(PNG));
expect(images).toHaveLength(1);
});
it("falls back to download when hostPath does not exist locally (remote host)", async () => {
const h: ArtifactHandle = {
[ARTIFACT_MARKER]: true,
id: "img3",
filename: "shot.png",
mimeType: "image/png",
size: PNG.length,
hostPath: join(hostDir, "does-not-exist.png"),
mtimeMs: 123,
};
const { result } = await materializeArtifacts(
{ image: h },
{ toolsUrl: "http://remote:3001", fetchImpl: fakeFetch({ img3: PNG }) }
);
expect((result as { image: string }).image.startsWith(artifactDir())).toBe(true);
});
});
// ── directory bundles (e.g. .trace) delivered as tar.gz ──────────────
function fakeFetchBuffer(map: Record<string, Buffer>): typeof fetch {
return (async (url: string) => {
const id = url.split("/artifacts/")[1]!;
const buf = map[id];
if (!buf) return { ok: false, arrayBuffer: async () => new ArrayBuffer(0) };
return {
ok: true,
arrayBuffer: async () => buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength),
};
}) as unknown as typeof fetch;
}
describe("materializeArtifacts directory bundles", () => {
let root: string; // ARGENT_ARTIFACTS_DIR
let hostDir: string; // stands in for the tool-server host
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "argent-artifacts-"));
hostDir = await mkdtemp(join(tmpdir(), "argent-host-"));
process.env.ARGENT_ARTIFACTS_DIR = root;
});
afterEach(async () => {
delete process.env.ARGENT_ARTIFACTS_DIR;
await rm(root, { recursive: true, force: true });
await rm(hostDir, { recursive: true, force: true });
});
// Build a real .trace-like bundle and a gzipped tar of it (as the server would
// stream on demand), so the client extraction runs against genuine tar output.
async function makeBundle(): Promise<{ bundlePath: string; tarGz: Buffer }> {
const bundlePath = join(hostDir, "session.trace");
await mkdir(join(bundlePath, "sub"), { recursive: true });
await writeFile(join(bundlePath, "top.txt"), "top");
await writeFile(join(bundlePath, "sub", "nested.txt"), "nested");
const tarPath = join(hostDir, "session.tar.gz");
execFileSync("tar", ["-czf", tarPath, "-C", hostDir, "session.trace"]);
const tarGz = await readFile(tarPath);
await rm(tarPath, { force: true });
return { bundlePath, tarGz };
}
function archiveHandle(id: string, hostPath: string): ArtifactHandle {
return {
[ARTIFACT_MARKER]: true,
id,
filename: "session.trace",
mimeType: "application/octet-stream",
size: 0,
hostPath,
archive: "tar.gz",
};
}
const throwingFetch: typeof fetch = (async () => {
throw new Error("fetch must not be called for a co-located directory bundle");
}) as unknown as typeof fetch;
it("co-located: uses the bundle directory in place, no download", async () => {
const { bundlePath } = await makeBundle();
const { result, images } = await materializeArtifacts(
{ traceFile: archiveHandle("t1", bundlePath) },
{ toolsUrl: "http://localhost:3001", fetchImpl: throwingFetch }
);
expect((result as { traceFile: string }).traceFile).toBe(bundlePath);
expect(images).toHaveLength(0);
});
it("remote: downloads the tar.gz and unpacks it back into a directory", async () => {
const { tarGz } = await makeBundle();
const { result } = await materializeArtifacts(
// hostPath absent locally → gate miss → download + extract.
{ traceFile: archiveHandle("t2", join(hostDir, "gone.trace")) },
{ toolsUrl: "http://remote:3001", fetchImpl: fakeFetchBuffer({ t2: tarGz }) }
);
const extracted = (result as { traceFile: string }).traceFile;
expect(extracted).toBe(join(artifactDir(), "session.trace"));
expect((await stat(extracted)).isDirectory()).toBe(true);
expect(await readFile(join(extracted, "top.txt"), "utf8")).toBe("top");
expect(await readFile(join(extracted, "sub", "nested.txt"), "utf8")).toBe("nested");
});
it("remote: a failed bundle download rewrites to null", async () => {
const { result } = await materializeArtifacts(
{ traceFile: archiveHandle("missing", join(hostDir, "gone.trace")) },
{ toolsUrl: "http://remote:3001", fetchImpl: fakeFetchBuffer({}) }
);
expect((result as { traceFile: null }).traceFile).toBeNull();
});
});
// ── durable destination (saveDir, e.g. `.argent/recordings`) ─────────
//
// The base a `saveDir` resolves against is the client's project root (nearest
// ancestor with `.git`/`package.json`/`.argent`), or its home dir when not in a
// project. These suites drive cwd into a marker-bearing temp dir and redirect
// HOME so the global-fallback branch never touches the real `~/.argent`.
// Write a `recordings.directory` value into `<rootDir>/.argent/config.json` —
// `rootDir` being a project root (project scope) or a redirected HOME (global
// scope), which is how the schema-driven reader finds each scope's document.
async function writeScopedConfig(rootDir: string, directory: string): Promise<void> {
await mkdir(join(rootDir, ".argent"), { recursive: true });
await writeFile(
join(rootDir, ".argent", "config.json"),
JSON.stringify({ recordings: { directory } }, null, 2)
);
}
describe("durableSaveTarget", () => {
let projectRoot: string;
let home: string;
let originalCwd: string;
let restoreHome: () => void;
beforeEach(async () => {
projectRoot = await mkdtemp(join(tmpdir(), "argent-proj-"));
await writeFile(join(projectRoot, "package.json"), "{}"); // the project marker
home = await mkdtemp(join(tmpdir(), "argent-home-"));
originalCwd = process.cwd();
process.chdir(projectRoot);
restoreHome = redirectHomeTo(home);
projectRoot = process.cwd(); // resolve /var → /private/var for assertions
});
afterEach(async () => {
process.chdir(originalCwd);
restoreHome();
await rm(projectRoot, { recursive: true, force: true });
await rm(home, { recursive: true, force: true });
});
it("returns null when no saveDir is set (⇒ disposable temp cache)", () => {
expect(durableSaveTarget(handle("a", "x.mp4", "video/mp4"))).toBeNull();
});
it("returns null (never throws) for a non-string saveDir from the wire", () => {
// `saveDir` is unvalidated JSON; a truthy non-string must not reach
// `normalize()` (which throws ERR_INVALID_ARG_TYPE on a non-string).
for (const bad of [1, 0, true, false, {}, [], null, undefined]) {
const h = { ...handle("a", "x.mp4", "video/mp4"), saveDir: bad } as unknown as ArtifactHandle;
expect(() => durableSaveTarget(h), JSON.stringify(bad)).not.toThrow();
expect(durableSaveTarget(h), JSON.stringify(bad)).toBeNull();
}
});
it("resolves saveDir under the project root with the sanitized filename", () => {
const h: ArtifactHandle = {
...handle("a", "clip name.mp4", "video/mp4"),
saveDir: ".argent/recordings",
};
const target = durableSaveTarget(h)!;
expect(target).not.toBeNull();
expect(target.dir).toBe(join(projectRoot, ".argent/recordings"));
// Space in the filename is sanitized to an underscore.
expect(target.path).toBe(join(projectRoot, ".argent/recordings", "clip_name.mp4"));
});
it("anchors at the project root even from a subdirectory", async () => {
const sub = join(projectRoot, "packages", "app");
await mkdir(sub, { recursive: true });
process.chdir(sub);
const h: ArtifactHandle = {
...handle("a", "clip.mp4", "video/mp4"),
saveDir: ".argent/recordings",
};
// Not join(sub, …) — the one project-level `.argent`, walked up to.
expect(durableSaveTarget(h)!.dir).toBe(join(projectRoot, ".argent/recordings"));
});
it("falls back to the global ~/.argent when not inside a project", async () => {
const outside = await mkdtemp(join(tmpdir(), "argent-noproj-"));
process.chdir(outside);
try {
const h: ArtifactHandle = {
...handle("a", "clip.mp4", "video/mp4"),
saveDir: ".argent/recordings",
};
const target = durableSaveTarget(h)!;
expect(target.dir).toBe(join(home, ".argent/recordings"));
expect(target.path).toBe(join(home, ".argent/recordings", "clip.mp4"));
} finally {
process.chdir(projectRoot);
await rm(outside, { recursive: true, force: true });
}
});
it("rejects an absolute saveDir (falls back to null → temp cache)", () => {
const h: ArtifactHandle = { ...handle("a", "x.mp4", "video/mp4"), saveDir: "/etc" };
expect(durableSaveTarget(h)).toBeNull();
});
it("rejects a `..`-escaping saveDir", () => {
for (const evil of ["..", "../outside", "a/../../b", "./../x"]) {
const h: ArtifactHandle = { ...handle("a", "x.mp4", "video/mp4"), saveDir: evil };
expect(durableSaveTarget(h), evil).toBeNull();
}
});
it("rejects an unlisted in-tree saveDir (only the client's allowlist is honored)", () => {
// Relative, non-`..`, yet still inside the project root — where `.git`,
// sources, and argent's own config live. A hostile tool-server must not be
// able to steer a durable write to any of these by picking `saveDir`.
for (const evil of [".git", ".", "", "src", ".argent", ".argent/flags", "recordings"]) {
const h: ArtifactHandle = {
...handle("a", "config", "application/octet-stream"),
saveDir: evil,
};
expect(durableSaveTarget(h), evil).toBeNull();
}
});
it("excludes directory bundles (archive) — durable persistence is for single files", () => {
const h: ArtifactHandle = {
...handle("a", "session.trace", "application/octet-stream"),
saveDir: ".argent/recordings",
archive: "tar.gz",
};
expect(durableSaveTarget(h)).toBeNull();
});
// ── `recordings.directory` configuration ────────────────────────────
// The wire hint stays `.argent/recordings`; the config redirects where that
// hint lands. Scope documents live at `<projectRoot>/.argent/config.json`
// (project) and `<HOME>/.argent/config.json` (global) — both sandboxed here.
const recordingHandle = (): ArtifactHandle => ({
...handle("a", "clip.mp4", "video/mp4"),
saveDir: ".argent/recordings",
});
it("redirects to an absolute global-config directory (base = configured dir, rel = '')", async () => {
const custom = join(home, "Movies", "argent");
await writeScopedConfig(home, custom);
const target = durableSaveTarget(recordingHandle())!;
expect(target.dir).toBe(custom);
expect(target.path).toBe(join(custom, "clip.mp4"));
// rel is empty so the post-mkdir realpath check verifies the configured dir
// resolves to itself rather than requiring a `.argent/recordings` suffix.
expect(target.base).toBe(custom);
expect(target.rel).toBe("");
});
it("project config wins over global when both are set", async () => {
await writeScopedConfig(home, join(home, "global-recordings"));
await writeScopedConfig(projectRoot, join(projectRoot, "project-recordings"));
expect(durableSaveTarget(recordingHandle())!.dir).toBe(join(projectRoot, "project-recordings"));
});
it("falls back to the global config when the project scope is unset", async () => {
await writeScopedConfig(home, join(home, "global-recordings"));
expect(durableSaveTarget(recordingHandle())!.dir).toBe(join(home, "global-recordings"));
});
it("expands a leading ~ to the user's home", async () => {
await writeScopedConfig(projectRoot, "~/Movies/argent");
expect(durableSaveTarget(recordingHandle())!.dir).toBe(join(home, "Movies", "argent"));
});
it("anchors a relative configured path at the project root", async () => {
await writeScopedConfig(projectRoot, "recordings/out");
expect(durableSaveTarget(recordingHandle())!.dir).toBe(join(projectRoot, "recordings", "out"));
});
it("anchors a relative global config at home when not inside a project", async () => {
await writeScopedConfig(home, "captures");
const outside = await mkdtemp(join(tmpdir(), "argent-noproj-"));
process.chdir(outside);
try {
expect(durableSaveTarget(recordingHandle())!.dir).toBe(join(home, "captures"));
} finally {
process.chdir(projectRoot);
await rm(outside, { recursive: true, force: true });
}
});
it("a blank configured value reads as unset — default location kept", async () => {
await writeScopedConfig(projectRoot, " ");
expect(durableSaveTarget(recordingHandle())!.dir).toBe(join(projectRoot, ".argent/recordings"));
});
it("config never resurrects a hostile wire saveDir — the allowlist still gates first", async () => {
await writeScopedConfig(projectRoot, join(projectRoot, "anywhere"));
for (const evil of [".git", "..", "/etc", ".argent", "recordings"]) {
const h: ArtifactHandle = { ...handle("a", "x.mp4", "video/mp4"), saveDir: evil };
expect(durableSaveTarget(h), evil).toBeNull();
}
});
});
describe("materializeArtifacts durable destination", () => {
let root: string; // ARGENT_ARTIFACTS_DIR (temp cache)
let hostDir: string; // stands in for the tool-server host
let projectRoot: string; // the client's project (marker-bearing) working dir
let home: string; // redirected HOME for the global-fallback branch
let originalCwd: string;
let restoreHome: () => void;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "argent-artifacts-"));
hostDir = await mkdtemp(join(tmpdir(), "argent-host-"));
projectRoot = await mkdtemp(join(tmpdir(), "argent-proj-"));
await writeFile(join(projectRoot, "package.json"), "{}"); // the project marker
home = await mkdtemp(join(tmpdir(), "argent-home-"));
process.env.ARGENT_ARTIFACTS_DIR = root;
originalCwd = process.cwd();
process.chdir(projectRoot);
restoreHome = redirectHomeTo(home);
// On macOS the temp dir is under a /var → /private/var symlink; the
// materializer resolves cwd to the real path, so mirror that for assertions.
projectRoot = process.cwd();
});
afterEach(async () => {
process.chdir(originalCwd);
delete process.env.ARGENT_ARTIFACTS_DIR;
restoreHome();
await rm(root, { recursive: true, force: true });
await rm(hostDir, { recursive: true, force: true });
await rm(projectRoot, { recursive: true, force: true });
await rm(home, { recursive: true, force: true });
});
const MP4 = [0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]; // ftyp box header-ish
it("remote: downloads the video into <project>/.argent/recordings/, not the temp cache", async () => {
const h: ArtifactHandle = {
[ARTIFACT_MARKER]: true,
id: "vid1",
filename: "screen-recording-DEV-1-42.mp4",
mimeType: "video/mp4",
size: MP4.length,
saveDir: ".argent/recordings",
};
const { result, images } = await materializeArtifacts(
{ video: h },
{ toolsUrl: "http://remote:3001", authToken: "tok", fetchImpl: fakeFetch({ vid1: MP4 }) }
);
const out = (result as { video: string }).video;
const expected = join(projectRoot, ".argent/recordings", "screen-recording-DEV-1-42.mp4");
expect(out).toBe(expected);
expect(out.startsWith(artifactDir())).toBe(false); // NOT in the temp cache
expect(Buffer.from(await readFile(out))).toEqual(Buffer.from(MP4));
// A video is not an image — no inline render.
expect(images).toHaveLength(0);
});
it("co-located: copies the host video into <project>/.argent/recordings/ and leaves the original", async () => {
const hostPath = join(hostDir, "argent-screen-recording-DEV-1-42.mp4");
await writeFile(hostPath, Buffer.from(MP4));
const st = await stat(hostPath);
const h: ArtifactHandle = {
[ARTIFACT_MARKER]: true,
id: "vid2",
filename: "screen-recording-DEV-1-42.mp4", // server strips the `argent-` prefix
mimeType: "video/mp4",
size: st.size,
hostPath,
mtimeMs: st.mtimeMs,
saveDir: ".argent/recordings",
};
const throwingFetch: typeof fetch = (async () => {
throw new Error("fetch must not be called when the file is already local");
}) as unknown as typeof fetch;
const { result } = await materializeArtifacts(
{ video: h },
{ toolsUrl: "http://localhost:3001", fetchImpl: throwingFetch }
);
const out = (result as { video: string }).video;
expect(out).toBe(join(projectRoot, ".argent/recordings", "screen-recording-DEV-1-42.mp4"));
expect(out).not.toBe(hostPath); // durable copy, not the temp original
expect(Buffer.from(await readFile(out))).toEqual(Buffer.from(MP4));
// Original host file is untouched.
expect(Buffer.from(await readFile(hostPath))).toEqual(Buffer.from(MP4));
});
it("not in a project: downloads into the global ~/.argent/recordings/", async () => {
const outside = await mkdtemp(join(tmpdir(), "argent-noproj-"));
process.chdir(outside);
try {
const h: ArtifactHandle = {
[ARTIFACT_MARKER]: true,
id: "vid5",
filename: "clip.mp4",
mimeType: "video/mp4",
size: MP4.length,
saveDir: ".argent/recordings",
};
const { result } = await materializeArtifacts(
{ video: h },
{ toolsUrl: "http://remote:3001", fetchImpl: fakeFetch({ vid5: MP4 }) }
);
const out = (result as { video: string }).video;
expect(out).toBe(join(home, ".argent/recordings", "clip.mp4"));
expect(Buffer.from(await readFile(out))).toEqual(Buffer.from(MP4));
} finally {
process.chdir(projectRoot);
await rm(outside, { recursive: true, force: true });
}
});
it("unsafe saveDir falls back to the temp cache instead of writing outside the base", async () => {
const h: ArtifactHandle = {
[ARTIFACT_MARKER]: true,
id: "vid3",
filename: "clip.mp4",
mimeType: "video/mp4",
size: MP4.length,
saveDir: "../escape",
};
const { result } = await materializeArtifacts(
{ video: h },
{ toolsUrl: "http://remote:3001", fetchImpl: fakeFetch({ vid3: MP4 }) }
);
const out = (result as { video: string }).video;
expect(out.startsWith(artifactDir())).toBe(true); // fell back to temp cache
expect(out).not.toContain("escape");
});
it("a hostile in-tree saveDir never overwrites a project file (e.g. .git/config)", async () => {
// A remote/compromised tool-server tags a result with saveDir `.git` +
// filename `config` + chosen bytes (size 0 skips the length check). Writing
// there would poison `.git/config` ⇒ code execution on the next git command.
const gitDir = join(projectRoot, ".git");
await mkdir(gitDir, { recursive: true });
const original = "[core]\n\trepositoryformatversion = 0\n";
await writeFile(join(gitDir, "config"), original);
const h: ArtifactHandle = {
[ARTIFACT_MARKER]: true,
id: "evil",
filename: "config",
mimeType: "application/octet-stream",
size: 0,
saveDir: ".git",
};
const payload = [...Buffer.from("[core]\n\tpager = touch /tmp/pwned\n")];
const { result } = await materializeArtifacts(
{ video: h },
{ toolsUrl: "http://remote:3001", fetchImpl: fakeFetch({ evil: payload }) }
);
// .git/config untouched, and the write fell back to the disposable cache.
expect(await readFile(join(gitDir, "config"), "utf8")).toBe(original);
expect((result as { video: string }).video.startsWith(artifactDir())).toBe(true);
});
it("a truncated durable download rewrites to null (integrity holds)", async () => {
const h: ArtifactHandle = {
[ARTIFACT_MARKER]: true,
id: "vid4",
filename: "clip.mp4",
mimeType: "video/mp4",
size: 99, // announced 99…
saveDir: ".argent/recordings",
};
const { result } = await materializeArtifacts(
{ video: h },
{ toolsUrl: "http://remote:3001", fetchImpl: fakeFetch({ vid4: MP4 }) } // …delivered 8
);
expect((result as { video: null }).video).toBeNull();
});
it("refuses a size:0 durable download — an unverifiable body is never persisted", async () => {
// A durable file survives temp-cache GC, so a length that can't be checked
// (size 0) must not be persisted. A remote server that announces size:0 and
// streams an arbitrary-length body is dropped, not written under `.argent/`.
const big = Array.from({ length: 40000 }, (_, i) => i % 256);
const h: ArtifactHandle = {
[ARTIFACT_MARKER]: true,
id: "vid0",
filename: "clip.mp4",
mimeType: "video/mp4",
size: 0,
saveDir: ".argent/recordings",
};
const { result } = await materializeArtifacts(
{ video: h },
{ toolsUrl: "http://remote:3001", fetchImpl: fakeFetch({ vid0: big }) }
);
expect((result as { video: null }).video).toBeNull();
await expect(readFile(join(projectRoot, ".argent/recordings", "clip.mp4"))).rejects.toThrow();
});
it("caps a durable download that over-streams past its declared size", async () => {
// The handle declares 8 bytes but the server streams 200_000. The bounded
// reader aborts the stream once it passes the declared size (the cap), so an
// under-declared body can't drive unbounded memory use / disk fill; the
// artifact resolves to null and nothing is left under `.argent/recordings/`.
const overStreamingFetch = (async () => ({
ok: true,
headers: { get: () => null }, // no Content-Length announced
body: new ReadableStream<Uint8Array>({
start(controller) {
for (let i = 0; i < 100; i++) controller.enqueue(new Uint8Array(2000));
controller.close();
},
}),
})) as unknown as typeof fetch;
const h: ArtifactHandle = {
[ARTIFACT_MARKER]: true,
id: "vidflood",
filename: "clip.mp4",
mimeType: "video/mp4",
size: MP4.length, // declares 8, delivers 200_000
saveDir: ".argent/recordings",
};
const { result } = await materializeArtifacts(
{ video: h },
{ toolsUrl: "http://remote:3001", fetchImpl: overStreamingFetch }
);
expect((result as { video: null }).video).toBeNull();
await expect(readFile(join(projectRoot, ".argent/recordings", "clip.mp4"))).rejects.toThrow();
});
it("refuses a durable write through a symlinked recordings directory (no escape into .git)", async () => {
// A malicious symlink pre-planted in the victim's checkout: .argent/recordings
// → .git. The lexical allowlist can't see it and the exclusive leaf write only
// covers the file — so without the realpath guard the durable write would land
// inside .git (a fresh file under hooks/ ⇒ code execution).
await mkdir(join(projectRoot, ".git", "hooks"), { recursive: true });
await mkdir(join(projectRoot, ".argent"), { recursive: true });
await symlink(join(projectRoot, ".git"), join(projectRoot, ".argent", "recordings"));
const h: ArtifactHandle = {
[ARTIFACT_MARKER]: true,
id: "evilsym",
filename: "pwned.mp4",
mimeType: "video/mp4",
size: MP4.length,
saveDir: ".argent/recordings",
};
const { result } = await materializeArtifacts(
{ video: h },
{ toolsUrl: "http://remote:3001", fetchImpl: fakeFetch({ evilsym: MP4 }) }
);
// Nothing escaped into the real .git dir, and the durable write was refused.
expect(await readdir(join(projectRoot, ".git"))).not.toContain("pwned.mp4");
expect((result as { video: null }).video).toBeNull();
});
it("never overwrites an existing durable recording — lands the new file alongside", async () => {
// A colliding filename must not clobber a recording already on disk. Both
// the co-located copy and the remote download create exclusively, so the new
// file lands as `clip (2).mp4` and the original is preserved intact.
const dir = join(projectRoot, ".argent/recordings");
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "clip.mp4"), Buffer.from("EXISTING"));
const h: ArtifactHandle = {
[ARTIFACT_MARKER]: true,
id: "viddup",
filename: "clip.mp4",
mimeType: "video/mp4",
size: MP4.length,
saveDir: ".argent/recordings",
};
const { result } = await materializeArtifacts(
{ video: h },
{ toolsUrl: "http://remote:3001", fetchImpl: fakeFetch({ viddup: MP4 }) }
);
const out = (result as { video: string }).video;
expect(out).toBe(join(dir, "clip (2).mp4"));
expect(Buffer.from(await readFile(out))).toEqual(Buffer.from(MP4));
// The original is untouched.
expect(await readFile(join(dir, "clip.mp4"), "utf8")).toBe("EXISTING");
});
// ── #551 review follow-ups (hardening + coverage) ─────────────
const absent = async (p: string) => {
try {
await readFile(p);
return false;
} catch {
return true;
}
};
// A remote server that streams `maxChunks` chunks on demand, counting pulls so
// a test can prove the client aborted early instead of draining the whole body.
const countingStream = (chunkBytes: number, maxChunks: number, contentLength?: number) => {
const state = { pulls: 0 };
const impl = (async () => ({
ok: true,
headers: {
get: (k: string) =>
k.toLowerCase() === "content-length" && contentLength != null
? String(contentLength)
: null,
},
body: new ReadableStream<Uint8Array>({
pull(c) {
if (state.pulls >= maxChunks) return c.close();
state.pulls++;
c.enqueue(new Uint8Array(chunkBytes));
},
}),
})) as unknown as typeof fetch;
return { impl, state };
};
it("refuses a durable download with a non-numeric/absent size (the memory cap can't be NaN-bypassed)", async () => {
// `size` is unvalidated JSON from a possibly hostile server; an absent or
// non-numeric value must not make the readCapped cap `NaN` (which never
// trips) and let an unbounded body buffer into memory.
for (const badSize of [undefined, "x", {}, NaN, 1.5, -3]) {
const { impl, state } = countingStream(64 * 1024, 300);
const h = {
[ARTIFACT_MARKER]: true,
id: "x",
filename: "clip.mp4",
mimeType: "video/mp4",
saveDir: ".argent/recordings",
size: badSize,
} as unknown as ArtifactHandle;
const { result } = await materializeArtifacts(
{ video: h },
{ toolsUrl: "http://remote:3001", fetchImpl: impl }
);
expect((result as { video: string | null }).video, String(badSize)).toBeNull();
expect(state.pulls, String(badSize)).toBe(0); // rejected before any download — body never touched
expect(await absent(join(projectRoot, ".argent/recordings", "clip.mp4"))).toBe(true);
}
});
it("aborts the stream early when the body over-streams past the declared size (the cap binds, not just the length check)", async () => {
// 64 KiB chunks with size:8 — the cap (= size) is exceeded on the first
// chunk, so the reader is cancelled after ~1 pull rather than draining 300.
// If MAX/reader.cancel were removed, all 300 would be buffered yet the test
// would still go green via the length-mismatch check — so assert the abort.
const { impl, state } = countingStream(64 * 1024, 300);
const h: ArtifactHandle = {
[ARTIFACT_MARKER]: true,
id: "over",
filename: "o.mp4",
mimeType: "video/mp4",
size: 8,
saveDir: ".argent/recordings",
};
const { result } = await materializeArtifacts(
{ video: h },
{ toolsUrl: "http://remote:3001", fetchImpl: impl }
);
expect((result as { video: string | null }).video).toBeNull();
expect(state.pulls).toBeLessThan(5); // aborted early, NOT fully drained (would be 300)
expect(await absent(join(projectRoot, ".argent/recordings", "o.mp4"))).toBe(true);
});
it("rejects a durable download whose Content-Length already exceeds the cap, before reading the body", async () => {
const { impl, state } = countingStream(8, 300, 5_000_000_000); // CL = 5 GB, size = 1 KiB
const h: ArtifactHandle = {
[ARTIFACT_MARKER]: true,
id: "cl",
filename: "cl.mp4",
mimeType: "video/mp4",
size: 1024,
saveDir: ".argent/recordings",
};
const { result } = await materializeArtifacts(
{ video: h },
{ toolsUrl: "http://remote:3001", fetchImpl: impl }
);
expect((result as { video: string | null }).video).toBeNull();
expect(state.pulls).toBeLessThanOrEqual(1); // header check fired; the body is not drained
});
it("co-located copy never clobbers an existing recording — lands as `name (2).ext`", async () => {
const dir = join(projectRoot, ".argent/recordings");
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "clip.mp4"), Buffer.from("EXISTING"));
const hostDir = await mkdtemp(join(tmpdir(), "argent-host-"));
const hostPath = join(hostDir, "argent-clip.mp4");
await writeFile(hostPath, Buffer.from(MP4));
const st = await stat(hostPath);
try {
const h: ArtifactHandle = {
[ARTIFACT_MARKER]: true,
id: "colo",
filename: "clip.mp4",
mimeType: "video/mp4",
size: st.size,
hostPath,
mtimeMs: st.mtimeMs,
saveDir: ".argent/recordings",
};
const throwingFetch: typeof fetch = (async () => {
throw new Error("fetch must not be called when the file is already local");
}) as unknown as typeof fetch;
const { result } = await materializeArtifacts(
{ video: h },
{ toolsUrl: "http://localhost:3001", fetchImpl: throwingFetch }
);
const out = (result as { video: string }).video;
expect(out).toBe(join(dir, "clip (2).mp4")); // co-located copy also lands alongside
expect(Buffer.from(await readFile(out))).toEqual(Buffer.from(MP4));
expect(await readFile(join(dir, "clip.mp4"), "utf8")).toBe("EXISTING"); // original intact
} finally {
await rm(hostDir, { recursive: true, force: true });
}
});
it("honors the recordings.directory config on the remote download path", async () => {
const custom = join(projectRoot, "my-videos");
await writeScopedConfig(projectRoot, custom);
const h: ArtifactHandle = {
[ARTIFACT_MARKER]: true,
id: "vidcfg",
filename: "clip.mp4",
mimeType: "video/mp4",
size: MP4.length,
saveDir: ".argent/recordings",
};
const { result } = await materializeArtifacts(
{ video: h },
{ toolsUrl: "http://remote:3001", fetchImpl: fakeFetch({ vidcfg: MP4 }) }
);
expect((result as { video: string }).video).toBe(join(custom, "clip.mp4"));
expect(Buffer.from(await readFile(join(custom, "clip.mp4")))).toEqual(Buffer.from(MP4));
// Nothing under the default location.
expect(await absent(join(projectRoot, ".argent/recordings", "clip.mp4"))).toBe(true);
});
it("honors the recordings.directory config on the co-located copy path", async () => {
const custom = join(projectRoot, "clips");
await writeScopedConfig(projectRoot, custom);
const hostPath = join(hostDir, "argent-clip.mp4");
await writeFile(hostPath, Buffer.from(MP4));
const st = await stat(hostPath);
const h: ArtifactHandle = {
[ARTIFACT_MARKER]: true,
id: "vidcfgl",
filename: "clip.mp4",
mimeType: "video/mp4",
size: st.size,
hostPath,
mtimeMs: st.mtimeMs,
saveDir: ".argent/recordings",
};
const throwingFetch: typeof fetch = (async () => {
throw new Error("fetch must not be called when the file is already local");
}) as unknown as typeof fetch;
const { result } = await materializeArtifacts(
{ video: h },
{ toolsUrl: "http://localhost:3001", fetchImpl: throwingFetch }
);
expect((result as { video: string }).video).toBe(join(custom, "clip.mp4"));
expect(Buffer.from(await readFile(join(custom, "clip.mp4")))).toEqual(Buffer.from(MP4));
});
it("a non-string saveDir degrades that handle without crashing the whole result", async () => {
// A hostile server tags one handle with a non-string saveDir; materialize
// must NOT reject (which would lose every sibling artifact) — the bad handle
// degrades to the temp cache and the good one still materializes.
for (const bad of [1, {}, [], true]) {
const evil = {
[ARTIFACT_MARKER]: true,
id: "e",
filename: "clip.mp4",
mimeType: "video/mp4",
size: MP4.length,
saveDir: bad,
} as unknown as ArtifactHandle;
const good: ArtifactHandle = {
[ARTIFACT_MARKER]: true,
id: "g",
filename: "ok.png",
mimeType: "image/png",
size: MP4.length,
};
const { result } = await materializeArtifacts(
{ evil, good },
{ toolsUrl: "http://remote:3001", fetchImpl: fakeFetch({ e: MP4, g: MP4 }) }
);
const r = result as { evil: string | null; good: string | null };
expect(typeof r.good, JSON.stringify(bad)).toBe("string"); // sibling survived
expect(r.evil, JSON.stringify(bad)).not.toBeUndefined(); // handled, not thrown
}
});
});