Files
Jakub Piasecki a644d52d39 feat(flows): run CLI flows from explicit paths (#568)
`argent flow run` now takes an explicit YAML path instead of a
saved-flow name:

```
argent flow run .argent/flows/checkout.yaml --platform ios
```

The path may point anywhere on the local filesystem; the filename stem
names the run's report/artifacts, so it must be `[A-Za-z0-9_-]+` with a
lowercase `.yaml` extension. A bare name fails with the exact
replacement command, and `argent flow list` prints paths instead of
names.

Because sibling `run:` files and `__baselines__` resolve beside the
YAML, the CLI now requires the auto-started local tool server and
refuses `ARGENT_TOOLS_URL` / `argent link` routing. The `flow-execute`
tool stays remotely callable via `name` + `project_root`, and gains a
`flow_path` parameter (exactly one of `name`/`flow_path`) that is only
honored when the file-input boundary verified it as a real host path —
uploads and boundary bypasses are rejected, so it can't execute
arbitrary server YAML.

Also: the flow-name pattern is now a single shared export from
`@argent/registry`, and the recorder composes a `flow_path` call into a
`run:` step when it targets a sibling of the recording.
2026-08-05 14:51:42 +02:00

343 lines
12 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import {
prepareFileInputs,
applyClientFileDirectives,
FILE_INPUT_MARKER,
CLIENT_FILE_MARKER,
type FileInputSpec,
type FileInputWire,
} from "../src/file-inputs.js";
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "client-file-inputs-"));
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
describe("prepareFileInputs", () => {
it("returns args unchanged when no specs apply", async () => {
const args = { udid: "ABC" };
const out = await prepareFileInputs(undefined, args, { includeContent: false });
expect(out).toBe(args);
});
it("wraps a file param with stat info, without content when local", async () => {
const filePath = path.join(tmpDir, "baseline.png");
await fs.writeFile(filePath, "png");
const st = await fs.stat(filePath);
const specs: FileInputSpec[] = [
{ target: "baselinePath", path: "${baselinePath}", kind: "file", optional: true },
];
const out = (await prepareFileInputs(
specs,
{ baselinePath: filePath, udid: "X" },
{ includeContent: false }
)) as Record<string, unknown>;
expect(out.udid).toBe("X");
expect(out.baselinePath).toEqual({
[FILE_INPUT_MARKER]: true,
path: filePath,
size: st.size,
mtimeMs: st.mtimeMs,
});
});
it("inlines base64 content when routed to a remote server", async () => {
const filePath = path.join(tmpDir, "baseline.png");
await fs.writeFile(filePath, "png-bytes");
const specs: FileInputSpec[] = [
{ target: "baselinePath", path: "${baselinePath}", kind: "file" },
];
const out = (await prepareFileInputs(
specs,
{ baselinePath: filePath },
{ includeContent: true }
)) as Record<string, FileInputWire>;
expect(Buffer.from(out.baselinePath!.content!, "base64").toString()).toBe("png-bytes");
});
it("marks oversize content as omitted instead of silently skipping it", async () => {
const filePath = path.join(tmpDir, "huge.bin");
await fs.writeFile(filePath, Buffer.alloc(32 * 1024 * 1024 + 1));
const st = await fs.stat(filePath);
const specs: FileInputSpec[] = [
{ target: "baselinePath", path: "${baselinePath}", kind: "file" },
];
const out = (await prepareFileInputs(
specs,
{ baselinePath: filePath },
{ includeContent: true }
)) as Record<string, FileInputWire>;
// Stat survives so a co-located copy still resolves in place; only the
// bytes are withheld, with the reason on the wire.
expect(out.baselinePath).toEqual({
[FILE_INPUT_MARKER]: true,
path: filePath,
size: st.size,
mtimeMs: st.mtimeMs,
contentOmitted: "size-limit",
});
});
it("derives a new target param from a multi-param template (flow_file)", async () => {
const flowsDir = path.join(tmpDir, ".argent", "flows");
await fs.mkdir(flowsDir, { recursive: true });
const flowPath = path.join(flowsDir, "my-flow.yaml");
await fs.writeFile(flowPath, "steps: []\n");
const specs: FileInputSpec[] = [
{ target: "flow_file", path: "${project_root}/.argent/flows/${name}.yaml", kind: "file" },
];
const out = (await prepareFileInputs(
specs,
{ name: "my-flow", project_root: tmpDir },
{ includeContent: true }
)) as Record<string, unknown>;
// Source params stay strings; the derived target carries the wrapper.
expect(out.project_root).toBe(tmpDir);
expect(out.name).toBe("my-flow");
expect(out.flow_file).toMatchObject({ [FILE_INPUT_MARKER]: true, path: flowPath });
expect((out.flow_file as FileInputWire).content).toBeDefined();
});
it("skips a spec whose referenced params are absent (live-capture mode)", async () => {
const specs: FileInputSpec[] = [
{ target: "baselinePath", path: "${baselinePath}", kind: "file", optional: true },
];
const args = { captureBaseline: true, udid: "X" };
const out = await prepareFileInputs(specs, args, { includeContent: true });
expect(out).toBe(args);
});
it("does not derive flow_file when the superseding flow_path source is set (skipWhenSet)", async () => {
const flowsDir = path.join(tmpDir, ".argent", "flows");
await fs.mkdir(flowsDir, { recursive: true });
const savedPath = path.join(flowsDir, "my-flow.yaml");
await fs.writeFile(savedPath, "steps: []\n");
const explicitPath = path.join(tmpDir, "explicit.yaml");
await fs.writeFile(explicitPath, "steps: []\n");
const specs: FileInputSpec[] = [
{ target: "flow_path", path: "${flow_path}", kind: "file", optional: true },
{
target: "flow_file",
path: "${project_root}/.argent/flows/${name}.yaml",
kind: "file",
skipWhenSet: "flow_path",
},
];
// Dual-source misuse: flow_file must not be derived — even though the
// saved flow exists — so the server's exactly-one validation, not this
// boundary's file resolution, diagnoses the call.
const both = (await prepareFileInputs(
specs,
{ name: "my-flow", project_root: tmpDir, flow_path: explicitPath },
{ includeContent: true }
)) as Record<string, unknown>;
expect(both.flow_file).toBeUndefined();
expect(both.flow_path).toMatchObject({ [FILE_INPUT_MARKER]: true, path: explicitPath });
// "" is still a provided source to the server's `=== undefined`
// dual-source check, so it must suppress the derivation the same way.
const emptySource = (await prepareFileInputs(
specs,
{ name: "my-flow", project_root: tmpDir, flow_path: "" },
{ includeContent: true }
)) as Record<string, unknown>;
expect(emptySource.flow_file).toBeUndefined();
// Name-only calls keep deriving the wrapper exactly as before.
const nameOnly = (await prepareFileInputs(
specs,
{ name: "my-flow", project_root: tmpDir },
{ includeContent: true }
)) as Record<string, unknown>;
expect(nameOnly.flow_file).toMatchObject({ [FILE_INPUT_MARKER]: true, path: savedPath });
});
it("respects an explicitly set derived target (server-side override)", async () => {
const specs: FileInputSpec[] = [
{ target: "flow_file", path: "${project_root}/.argent/flows/${name}.yaml", kind: "file" },
];
const out = (await prepareFileInputs(
specs,
{ name: "f", project_root: tmpDir, flow_file: "/server/side/flow.yaml" },
{ includeContent: true }
)) as Record<string, unknown>;
expect(out.flow_file).toBe("/server/side/flow.yaml");
});
it("still sends a path-only wrapper for an unreadable file", async () => {
const specs: FileInputSpec[] = [{ target: "p", path: "${p}", kind: "file" }];
const ghost = path.join(tmpDir, "ghost.png");
const out = (await prepareFileInputs(specs, { p: ghost }, { includeContent: true })) as Record<
string,
unknown
>;
expect(out.p).toEqual({ [FILE_INPUT_MARKER]: true, path: ghost });
});
});
describe("prepareFileInputs — tar-upload kind", () => {
const specs: FileInputSpec[] = [{ target: "appPath", path: "${appPath}", kind: "tar-upload" }];
afterEach(() => {
vi.unstubAllGlobals();
});
it("tars and uploads a local directory when routed to a remote server", async () => {
const appDir = path.join(tmpDir, "MyApp.app");
await fs.mkdir(appDir);
await fs.writeFile(path.join(appDir, "Info.plist"), "<plist/>");
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ uploadId: "upload-123" }),
}));
vi.stubGlobal("fetch", fetchMock);
const out = (await prepareFileInputs(
specs,
{ appPath: appDir },
{ includeContent: true, uploadEndpoint: { url: "https://sim.example", token: "tok" } }
)) as Record<string, FileInputWire>;
expect(out.appPath!.uploadId).toBe("upload-123");
expect(out.appPath!.contentHash).toMatch(/^[a-f0-9]{64}$/);
expect(out.appPath!.size).toBeTypeOf("number");
expect(fetchMock).toHaveBeenCalledWith(
"https://sim.example/upload",
expect.objectContaining({ method: "POST" })
);
});
it("tars and uploads a local file (e.g. an .apk) when routed to a remote server", async () => {
const apk = path.join(tmpDir, "app.apk");
await fs.writeFile(apk, "apk-bytes");
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ uploadId: "upload-apk" }),
}));
vi.stubGlobal("fetch", fetchMock);
const out = (await prepareFileInputs(
specs,
{ appPath: apk },
{ includeContent: true, uploadEndpoint: { url: "https://sim.example", token: "tok" } }
)) as Record<string, FileInputWire>;
expect(out.appPath!.uploadId).toBe("upload-apk");
expect(fetchMock).toHaveBeenCalledOnce();
});
it("propagates an upload failure", async () => {
const appDir = path.join(tmpDir, "MyApp.app");
await fs.mkdir(appDir);
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: false, status: 500, statusText: "Boom" }))
);
await expect(
prepareFileInputs(
specs,
{ appPath: appDir },
{ includeContent: true, uploadEndpoint: { url: "https://sim.example", token: "tok" } }
)
).rejects.toThrow(/failed/i);
});
it("skips upload and sends a path-only wrapper when the path is not local", async () => {
// A path that exists only on the remote host (e.g. pre-uploaded to the VM).
const remotePath = path.join(tmpDir, "not", "on", "this", "machine", "MyApp.app");
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const out = (await prepareFileInputs(
specs,
{ appPath: remotePath },
{ includeContent: true, uploadEndpoint: { url: "https://sim.example", token: "tok" } }
)) as Record<string, FileInputWire>;
expect(out.appPath).toEqual({ [FILE_INPUT_MARKER]: true, path: remotePath });
expect(fetchMock).not.toHaveBeenCalled();
});
it("does not upload for a co-located session (no upload endpoint)", async () => {
const appDir = path.join(tmpDir, "MyApp.app");
await fs.mkdir(appDir);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const out = (await prepareFileInputs(
specs,
{ appPath: appDir },
{ includeContent: false }
)) as Record<string, FileInputWire>;
expect(out.appPath).toMatchObject({
[FILE_INPUT_MARKER]: true,
path: appDir,
size: expect.any(Number),
mtimeMs: expect.any(Number),
});
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe("applyClientFileDirectives", () => {
function directive(p: string, content = "steps: []\n") {
return { [CLIENT_FILE_MARKER]: true, path: p, content };
}
it("writes an allowed flow path and rewrites the directive to it", async () => {
const flowPath = path.join(tmpDir, ".argent", "flows", "demo.yaml");
const result = { message: "ok", savedTo: directive(flowPath) };
const { result: rewritten, written } = await applyClientFileDirectives(result);
expect(written).toEqual([flowPath]);
expect((rewritten as { savedTo: string }).savedTo).toBe(flowPath);
expect(await fs.readFile(flowPath, "utf8")).toBe("steps: []\n");
});
it("passes results without directives through untouched", async () => {
const result = { a: 1, nested: { b: "x" } };
const { result: rewritten, written } = await applyClientFileDirectives(result);
expect(rewritten).toEqual(result);
expect(written).toEqual([]);
});
it.each([
["relative path", path.join(".argent", "flows", "x.yaml")],
["outside .argent/flows", path.join(os.tmpdir(), "x.yaml")],
// Built by concatenation: path.join would collapse the ".." segments
// before the validator ever saw them.
["traversal", `${os.tmpdir()}/.argent/flows/../../evil.yaml`],
["bad extension", path.join(os.tmpdir(), ".argent", "flows", "x.sh")],
["bad name charset", path.join(os.tmpdir(), ".argent", "flows", "x y.yaml")],
])("refuses to write %s and resolves the directive to null", async (_label, p) => {
const { result: rewritten, written } = await applyClientFileDirectives({
savedTo: directive(p),
});
expect(written).toEqual([]);
expect((rewritten as { savedTo: unknown }).savedTo).toBeNull();
});
});