mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
06e0308009
This PR added `flow_name` as an alias for `name` on flow-execute and flow-read-prerequisite, on the grounds that it is the spelling callers reach for when the tool is called `flow-execute`. But the reason the wrong spelling cost whole turns was never the spelling — it was that Zod's raw issue array named the parameter the tool WANTED and never the one the caller SENT. That is fixed, in this same PR: the rejection now reads Pass exactly one flow source: name or flow_path. flow-execute needs the flow's name in `name` — it resolves <project_root>/.argent/flows/<name>.yaml. You sent: `flow_name`, `project_root`. which names the parameter, says where it resolves, and echoes the caller's own keys. One turn, no alias. A second accepted spelling on top of that buys nothing and costs a duplicated enumeration in both tools' schemas, their file-input specs, flow-add-step's nested-call validation, and every error string that had to spell out both. So: one parameter, one spelling. `unwrapWhenSet` goes back to a single key (registry's file-inputs.ts is now identical to main), `flow_file` back to one spec, and `resolveFlowName` — which existed only to fold the two together — is gone; `resolveFlowSource` reads `params.name` as it did before. One behaviour returns to main's: an empty `name` answers 500 rather than 400, since it now reaches `assertSafeFlowName` (FLOW_NAME_INVALID, error_kind "validation") instead of a leaf InvalidToolInputError. Mapping every validation FailureError to 400 is a boundary-wide decision, not an alias one. Verified end to end against a tool-server built from this branch: a `flow_name` call answers 400 with the message above over HTTP and through the MCP stdio adapter; `name`, `flow_path`, a client-derived `flow_file`, and a remote upload by name all still run; `name` + an unresolvable `flow_path` still answers the schema's 400 rather than a 422 about the file; and the recorder still captures a nested flow-execute as `run: <name>.yaml`.
542 lines
22 KiB
TypeScript
542 lines
22 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import supertest from "supertest";
|
|
import * as fs from "node:fs/promises";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { ArtifactStore, type Registry, type ToolContext } from "@argent/registry";
|
|
import { createHttpApp, type HttpAppHandle } from "../src/http";
|
|
import { createRunFlowTool } from "../src/tools/flows/flow-run";
|
|
import { flowReadPrerequisiteTool } from "../src/tools/flows/flow-read-prerequisite";
|
|
import { serializeFlow } from "../src/tools/flows/flow-utils";
|
|
|
|
vi.mock("../src/utils/update-checker", () => ({
|
|
getUpdateState: vi.fn(() => ({ updateInstallable: false, currentVersion: "1.0.0" })),
|
|
isUpdateNoteSuppressed: vi.fn(() => true),
|
|
suppressUpdateNote: vi.fn(),
|
|
}));
|
|
|
|
const DEVICE = "00000000-0000-0000-0000-0000000000ab";
|
|
|
|
/** The registry flow-execute dispatches its steps through — never the flow source. */
|
|
function stepRegistry(): Registry {
|
|
return {
|
|
invokeTool: vi.fn(async (id: string) => {
|
|
if (id === "list-devices") return { devices: [] };
|
|
return { ok: true };
|
|
}),
|
|
getTool: vi.fn(() => undefined),
|
|
resolveService: vi.fn(async () => ({
|
|
isConnected: () => true,
|
|
listConnectedBundleIds: () => [],
|
|
})),
|
|
} as unknown as Registry;
|
|
}
|
|
|
|
/**
|
|
* A registry exposing the REAL flow-execute and flow-read-prerequisite tools,
|
|
* so a POST exercises the whole chain a forged wrapper must traverse: HTTP
|
|
* file-input resolution → resolveFlowSource's boundary gate → step dispatch /
|
|
* prerequisite read. Both tools are here because they share the pre-flight
|
|
* contract: the file this suite proves flow-execute runs must be the one
|
|
* flow-read-prerequisite answers about.
|
|
*/
|
|
function httpRegistry(steps: Registry): Registry {
|
|
const tools: Record<
|
|
string,
|
|
ReturnType<typeof createRunFlowTool> | typeof flowReadPrerequisiteTool
|
|
> = {
|
|
"flow-execute": createRunFlowTool(steps),
|
|
"flow-read-prerequisite": flowReadPrerequisiteTool,
|
|
};
|
|
return {
|
|
getSnapshot: vi.fn(() => ({
|
|
services: new Map(),
|
|
namespaces: [],
|
|
tools: Object.keys(tools),
|
|
})),
|
|
getTool: vi.fn((id: string) => tools[id]),
|
|
invokeTool: vi.fn(async (id: string, args: unknown, opts?: Partial<ToolContext>) => {
|
|
const tool = tools[id];
|
|
if (!tool) throw new Error(`unexpected tool "${id}"`);
|
|
return tool.execute({}, args as never, {
|
|
artifacts: new ArtifactStore(),
|
|
...opts,
|
|
});
|
|
}),
|
|
} as unknown as Registry;
|
|
}
|
|
|
|
let tmpDir: string;
|
|
let projectRoot: string;
|
|
let flowPath: string;
|
|
let steps: Registry;
|
|
let handle: HttpAppHandle;
|
|
let originalToken: string | undefined;
|
|
|
|
beforeEach(async () => {
|
|
originalToken = process.env.ARGENT_AUTH_TOKEN;
|
|
delete process.env.ARGENT_AUTH_TOKEN; // dev mode — auth is covered elsewhere
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "http-flow-path-test-"));
|
|
// The YAML sits outside the declared project root — the reach a forged
|
|
// wrapper would gain over the name/flow_file branch's containment.
|
|
projectRoot = path.join(tmpDir, "project");
|
|
await fs.mkdir(projectRoot);
|
|
flowPath = path.join(tmpDir, "out-of-project.yaml");
|
|
await fs.writeFile(
|
|
flowPath,
|
|
serializeFlow({
|
|
executionPrerequisite: "",
|
|
steps: [
|
|
{ kind: "echo", message: "over the boundary" },
|
|
{ kind: "tool", name: "tap", args: { x: 0.5, y: 0.5 } },
|
|
],
|
|
}),
|
|
"utf8"
|
|
);
|
|
steps = stepRegistry();
|
|
handle = createHttpApp(httpRegistry(steps));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
handle?.dispose();
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
if (originalToken === undefined) delete process.env.ARGENT_AUTH_TOKEN;
|
|
else process.env.ARGENT_AUTH_TOKEN = originalToken;
|
|
});
|
|
|
|
describe("flow-execute flow_path over HTTP", () => {
|
|
it("rejects a hand-crafted stat-less wrapper without executing the YAML", async () => {
|
|
const res = await supertest(handle.app)
|
|
.post("/tools/flow-execute")
|
|
.send({
|
|
project_root: projectRoot,
|
|
device: DEVICE,
|
|
flow_path: { __argentFileInput: true, path: flowPath },
|
|
});
|
|
|
|
// The server's own stat succeeds (presentOnHost), but no client stat was
|
|
// matched — the gate must refuse before any step dispatches.
|
|
expect(res.status).toBe(500);
|
|
expect(res.body.error).toMatch(/flow_path file-input boundary/);
|
|
expect(steps.invokeTool).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects a size-only wrapper without executing the YAML", async () => {
|
|
const st = await fs.stat(flowPath);
|
|
const res = await supertest(handle.app)
|
|
.post("/tools/flow-execute")
|
|
.send({
|
|
project_root: projectRoot,
|
|
device: DEVICE,
|
|
flow_path: { __argentFileInput: true, path: flowPath, size: st.size },
|
|
});
|
|
|
|
// The size is the real file's, so the wrapper resolves in place — but a
|
|
// size is knowable without ever having statted the file, so half the
|
|
// client stat must not clear the boundary the stat-less wrapper cannot.
|
|
expect(res.status).toBe(500);
|
|
expect(res.body.error).toMatch(/flow_path file-input boundary/);
|
|
expect(steps.invokeTool).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects an mtime-only wrapper without executing the YAML", async () => {
|
|
const st = await fs.stat(flowPath);
|
|
const res = await supertest(handle.app)
|
|
.post("/tools/flow-execute")
|
|
.send({
|
|
project_root: projectRoot,
|
|
device: DEVICE,
|
|
flow_path: { __argentFileInput: true, path: flowPath, mtimeMs: st.mtimeMs },
|
|
});
|
|
|
|
// The mirror-image half: a matching mtime with no size on the wire is
|
|
// still not the both-fields evidence statVerified stands for.
|
|
expect(res.status).toBe(500);
|
|
expect(res.body.error).toMatch(/flow_path file-input boundary/);
|
|
expect(steps.invokeTool).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects a relative flow_path without blaming the boundary it cleared", async () => {
|
|
// The spelling `argent flow list` prints. Running the server from the
|
|
// flow's own directory is what makes this wrapper legitimate: it stats the
|
|
// relative path against this process's cwd, finds the file, and matches the
|
|
// client-recorded stat — presentOnHost and statVerified both hold, so the
|
|
// rejection must name the path's shape rather than the boundary.
|
|
const originalCwd = process.cwd();
|
|
process.chdir(tmpDir);
|
|
try {
|
|
const relPath = path.basename(flowPath);
|
|
const st = await fs.stat(relPath);
|
|
const res = await supertest(handle.app)
|
|
.post("/tools/flow-execute")
|
|
.send({
|
|
project_root: projectRoot,
|
|
device: DEVICE,
|
|
flow_path: { __argentFileInput: true, path: relPath, size: st.size, mtimeMs: st.mtimeMs },
|
|
});
|
|
|
|
expect(res.status).toBe(500);
|
|
expect(res.body.error).toMatch(/must be absolute/);
|
|
expect(res.body.error).not.toMatch(/file-input boundary/);
|
|
expect(steps.invokeTool).not.toHaveBeenCalled();
|
|
} finally {
|
|
process.chdir(originalCwd);
|
|
}
|
|
});
|
|
|
|
it("still validates project_root on the flow_path branch", async () => {
|
|
// `getFlowPath` validates the root, but only the `name` branch reaches it.
|
|
// Deleting `setActiveProjectRoot` — which ran unconditionally, ahead of
|
|
// both branches — left this branch with no check at all, so a relative or
|
|
// ".."-bearing root sailed through. Nothing reads project_root here today,
|
|
// which is exactly why the guardrail has to be pinned rather than assumed.
|
|
const st = await fs.stat(flowPath);
|
|
const wrapper = {
|
|
__argentFileInput: true,
|
|
path: flowPath,
|
|
size: st.size,
|
|
mtimeMs: st.mtimeMs,
|
|
};
|
|
|
|
for (const [root, expected] of [
|
|
["relative/root", /project_root must be an absolute path/],
|
|
[`${projectRoot}/../elsewhere`, /must not contain "\.\." segments/],
|
|
] as const) {
|
|
const res = await supertest(handle.app)
|
|
.post("/tools/flow-execute")
|
|
.send({ project_root: root, device: DEVICE, flow_path: wrapper });
|
|
|
|
expect(res.status).toBe(500);
|
|
expect(res.body.error).toMatch(expected);
|
|
expect(res.body.error_code).toBe("FLOW_PROJECT_ROOT_INVALID");
|
|
expect(steps.invokeTool).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it('rejects a ".." flow_path whose kernel and lexical resolutions disagree', async () => {
|
|
// <tmp>/link -> <tmp>/deep/inner, so the kernel reads <tmp>/deep/flow.yaml
|
|
// while path.dirname keeps "<tmp>/link/.." and path.join collapses it to
|
|
// <tmp> — the run: sibling and __baselines__ would come from the wrong
|
|
// directory. Both siblings exist so the two resolutions are distinguishable.
|
|
await fs.mkdir(path.join(tmpDir, "deep", "inner"), { recursive: true });
|
|
await fs.symlink(path.join(tmpDir, "deep", "inner"), path.join(tmpDir, "link"));
|
|
await fs.writeFile(
|
|
path.join(tmpDir, "deep", "flow.yaml"),
|
|
serializeFlow({ executionPrerequisite: "", steps: [{ kind: "run", flow: "sib" }] }),
|
|
"utf8"
|
|
);
|
|
for (const [dir, marker] of [
|
|
[path.join(tmpDir, "deep"), "true sibling"],
|
|
[tmpDir, "lexical sibling"],
|
|
]) {
|
|
await fs.writeFile(
|
|
path.join(dir, "sib.yaml"),
|
|
serializeFlow({ executionPrerequisite: "", steps: [{ kind: "echo", message: marker }] }),
|
|
"utf8"
|
|
);
|
|
}
|
|
|
|
// The wrapper is legitimate: this stat goes through the kernel, so size and
|
|
// mtime are the real file's and the boundary gate is satisfied.
|
|
const viaSymlink = [tmpDir, "link", "..", "flow.yaml"].join(path.sep);
|
|
const st = await fs.stat(viaSymlink);
|
|
const res = await supertest(handle.app)
|
|
.post("/tools/flow-execute")
|
|
.send({
|
|
project_root: projectRoot,
|
|
device: DEVICE,
|
|
flow_path: {
|
|
__argentFileInput: true,
|
|
path: viaSymlink,
|
|
size: st.size,
|
|
mtimeMs: st.mtimeMs,
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(500);
|
|
expect(res.body.error).toMatch(/must not contain "\.\." segments/);
|
|
expect(steps.invokeTool).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("diagnoses name + flow_path with the exactly-one rule when the saved flow does not exist", async () => {
|
|
// The old-client wire for the dual-source misuse: pre-skipWhenSet clients
|
|
// interpolate ${project_root}/.argent/flows/${name}.yaml whenever name is
|
|
// set — even alongside flow_path — and since "checkout" is not saved, the
|
|
// flow_file wrapper is path-only. Without the skip, the boundary 422s on
|
|
// that missing file before zod's exactly-one rule can run, telling the
|
|
// agent to re-create a flow it never asked for.
|
|
const st = await fs.stat(flowPath);
|
|
const res = await supertest(handle.app)
|
|
.post("/tools/flow-execute")
|
|
.send({
|
|
name: "checkout",
|
|
project_root: projectRoot,
|
|
device: DEVICE,
|
|
flow_path: { __argentFileInput: true, path: flowPath, size: st.size, mtimeMs: st.mtimeMs },
|
|
flow_file: {
|
|
__argentFileInput: true,
|
|
path: path.join(projectRoot, ".argent", "flows", "checkout.yaml"),
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/Pass exactly one flow source: name or flow_path\./);
|
|
expect(res.body.error).not.toMatch(/was not found on the tool-server host/);
|
|
expect(steps.invokeTool).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("diagnoses name + flow_path with the exactly-one rule when the saved flow exists", async () => {
|
|
// Same misuse, but the unused saved flow resolves cleanly — the diagnosis
|
|
// must be identical to the nonexistent-name case above.
|
|
const savedPath = path.join(projectRoot, ".argent", "flows", "good.yaml");
|
|
await fs.mkdir(path.dirname(savedPath), { recursive: true });
|
|
await fs.writeFile(
|
|
savedPath,
|
|
serializeFlow({ executionPrerequisite: "", steps: [{ kind: "echo", message: "saved" }] }),
|
|
"utf8"
|
|
);
|
|
const savedSt = await fs.stat(savedPath);
|
|
const st = await fs.stat(flowPath);
|
|
const res = await supertest(handle.app)
|
|
.post("/tools/flow-execute")
|
|
.send({
|
|
name: "good",
|
|
project_root: projectRoot,
|
|
device: DEVICE,
|
|
flow_path: { __argentFileInput: true, path: flowPath, size: st.size, mtimeMs: st.mtimeMs },
|
|
flow_file: {
|
|
__argentFileInput: true,
|
|
path: savedPath,
|
|
size: savedSt.size,
|
|
mtimeMs: savedSt.mtimeMs,
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/Pass exactly one flow source: name or flow_path\./);
|
|
expect(steps.invokeTool).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("diagnoses a dual-source call from a skipWhenSet-aware client the same way", async () => {
|
|
// A current client never derives flow_file alongside flow_path, so the
|
|
// wire carries both sources and no flow_file wrapper at all.
|
|
const st = await fs.stat(flowPath);
|
|
const res = await supertest(handle.app)
|
|
.post("/tools/flow-execute")
|
|
.send({
|
|
name: "checkout",
|
|
project_root: projectRoot,
|
|
device: DEVICE,
|
|
flow_path: { __argentFileInput: true, path: flowPath, size: st.size, mtimeMs: st.mtimeMs },
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/Pass exactly one flow source: name or flow_path\./);
|
|
expect(steps.invokeTool).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("diagnoses name + flow_path with the exactly-one rule when the flow_path file does not resolve", async () => {
|
|
// The reciprocal of the missing-saved-flow case: this time the
|
|
// CALLER-authored source is the one that cannot resolve. The wrapper is
|
|
// path-only — the client cannot stat (let alone inline) a file that never
|
|
// existed. Without the flow_path spec's unwrap the boundary would 422 on
|
|
// it before zod's exactly-one rule runs, telling the agent to re-create a
|
|
// file the call never needed.
|
|
const res = await supertest(handle.app)
|
|
.post("/tools/flow-execute")
|
|
.send({
|
|
name: "checkout",
|
|
project_root: projectRoot,
|
|
device: DEVICE,
|
|
flow_path: { __argentFileInput: true, path: path.join(tmpDir, "nope.yaml") },
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/Pass exactly one flow source: name or flow_path\./);
|
|
expect(res.body.error).not.toMatch(/was not found on the tool-server host/);
|
|
expect(steps.invokeTool).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("diagnoses an old-client dual-source wire where neither wrapper resolves", async () => {
|
|
// Worst case of the skew: a pre-skipWhenSet client derived flow_file for
|
|
// an unsaved name AND the caller mistyped flow_path, so both wrappers are
|
|
// path-only. Whichever spec ran first used to pick the error; the
|
|
// diagnosis must not consult either file.
|
|
const res = await supertest(handle.app)
|
|
.post("/tools/flow-execute")
|
|
.send({
|
|
name: "checkout",
|
|
project_root: projectRoot,
|
|
device: DEVICE,
|
|
flow_path: { __argentFileInput: true, path: path.join(tmpDir, "nope.yaml") },
|
|
flow_file: {
|
|
__argentFileInput: true,
|
|
path: path.join(projectRoot, ".argent", "flows", "checkout.yaml"),
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/Pass exactly one flow source: name or flow_path\./);
|
|
expect(res.body.error).not.toMatch(/was not found on the tool-server host/);
|
|
expect(steps.invokeTool).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("still fails the boundary when a lone flow_path does not resolve", async () => {
|
|
// No name on the wire, so the unwrap must not fire: a flow_path-only call
|
|
// whose file resolves nowhere is a genuine boundary failure, and the 422
|
|
// guidance about the missing file is the right diagnosis.
|
|
const res = await supertest(handle.app)
|
|
.post("/tools/flow-execute")
|
|
.send({
|
|
project_root: projectRoot,
|
|
device: DEVICE,
|
|
flow_path: { __argentFileInput: true, path: path.join(tmpDir, "nope.yaml") },
|
|
});
|
|
|
|
expect(res.status).toBe(422);
|
|
expect(res.body.error).toMatch(/was not found on the tool-server host/);
|
|
expect(steps.invokeTool).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("diagnoses a source-less call with the exactly-one rule at the validation layer", async () => {
|
|
// The other half of exactly-one: no name and no flow_path. Nothing on the
|
|
// wire is a wrapper, so file-input resolution passes through untouched and
|
|
// the schema's superRefine is the only guard left before execute — it must
|
|
// classify the miss as a 400 validation failure, not fall through to
|
|
// resolveFlowSource's in-tool copy and surface as a 500 tool error.
|
|
const res = await supertest(handle.app)
|
|
.post("/tools/flow-execute")
|
|
.send({ project_root: projectRoot, device: DEVICE });
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/Pass exactly one flow source: name or flow_path\./);
|
|
expect(steps.invokeTool).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("accepts the legitimate wrapper carrying the file's real stat and runs the flow", async () => {
|
|
const st = await fs.stat(flowPath);
|
|
const res = await supertest(handle.app)
|
|
.post("/tools/flow-execute")
|
|
.send({
|
|
project_root: projectRoot,
|
|
device: DEVICE,
|
|
flow_path: {
|
|
__argentFileInput: true,
|
|
path: flowPath,
|
|
size: st.size,
|
|
mtimeMs: st.mtimeMs,
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.data).toMatchObject({
|
|
flow: "out-of-project",
|
|
steps: [
|
|
{ kind: "echo", status: "pass" },
|
|
{ kind: "tool", status: "pass", tool: "tap" },
|
|
],
|
|
});
|
|
const dispatched = (steps.invokeTool as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0]);
|
|
expect(dispatched).toContain("tap");
|
|
});
|
|
});
|
|
|
|
describe("flow-read-prerequisite flow_path over HTTP", () => {
|
|
it("answers about the boundary-verified flow_path, not the saved flow of the same stem", async () => {
|
|
// Two flows share the stem "gate": the saved copy under the project root
|
|
// and the explicit file flow-execute would run for the same params. The
|
|
// documented pre-flight (read the prerequisite, then run) is only sound if
|
|
// this tool addresses the explicit file — answering with the saved copy's
|
|
// contract would have the agent satisfy the wrong prerequisite.
|
|
const savedPath = path.join(projectRoot, ".argent", "flows", "gate.yaml");
|
|
await fs.mkdir(path.dirname(savedPath), { recursive: true });
|
|
await fs.writeFile(
|
|
savedPath,
|
|
serializeFlow({ executionPrerequisite: "SAVED-COPY: HOME screen", steps: [] }),
|
|
"utf8"
|
|
);
|
|
const sharedPath = path.join(tmpDir, "elsewhere", "gate.yaml");
|
|
await fs.mkdir(path.dirname(sharedPath), { recursive: true });
|
|
await fs.writeFile(
|
|
sharedPath,
|
|
serializeFlow({ executionPrerequisite: "SHARED-COPY: DETAIL screen", steps: [] }),
|
|
"utf8"
|
|
);
|
|
|
|
const st = await fs.stat(sharedPath);
|
|
const res = await supertest(handle.app)
|
|
.post("/tools/flow-read-prerequisite")
|
|
.send({
|
|
project_root: projectRoot,
|
|
flow_path: {
|
|
__argentFileInput: true,
|
|
path: sharedPath,
|
|
size: st.size,
|
|
mtimeMs: st.mtimeMs,
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.data).toEqual({
|
|
flow: "gate",
|
|
executionPrerequisite: "SHARED-COPY: DETAIL screen",
|
|
});
|
|
});
|
|
|
|
it("rejects a hand-crafted stat-less wrapper without reading the YAML", async () => {
|
|
// The same gate flow-execute's suite pins above: presence on the host is
|
|
// not boundary evidence, and a read must not be softer than the run — a
|
|
// prerequisite handed out here would vouch for a file the run refuses.
|
|
const res = await supertest(handle.app)
|
|
.post("/tools/flow-read-prerequisite")
|
|
.send({
|
|
project_root: projectRoot,
|
|
flow_path: { __argentFileInput: true, path: flowPath },
|
|
});
|
|
|
|
expect(res.status).toBe(500);
|
|
expect(res.body.error).toMatch(/flow_path file-input boundary/);
|
|
});
|
|
|
|
it("diagnoses name + flow_path with the exactly-one rule when the flow_path file does not resolve", async () => {
|
|
// flow-execute's unwrap case, mirrored: the caller-authored flow_path must
|
|
// reach zod as a plain string so the dual-source misuse is diagnosed by
|
|
// the schema — not by a 422 about a file the call never needed, and not by
|
|
// silently answering for the saved flow.
|
|
const res = await supertest(handle.app)
|
|
.post("/tools/flow-read-prerequisite")
|
|
.send({
|
|
name: "checkout",
|
|
project_root: projectRoot,
|
|
flow_path: { __argentFileInput: true, path: path.join(tmpDir, "nope.yaml") },
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/Pass exactly one flow source: name or flow_path\./);
|
|
expect(res.body.error).not.toMatch(/was not found on the tool-server host/);
|
|
});
|
|
|
|
it("still reads a saved flow by name alone", async () => {
|
|
// name became optional to admit flow_path; a name-only wire (no wrapper at
|
|
// all — the shape a direct HTTP caller sends) must keep resolving to
|
|
// ${project_root}/.argent/flows/${name}.yaml exactly as before.
|
|
const savedPath = path.join(projectRoot, ".argent", "flows", "saved-only.yaml");
|
|
await fs.mkdir(path.dirname(savedPath), { recursive: true });
|
|
await fs.writeFile(
|
|
savedPath,
|
|
serializeFlow({ executionPrerequisite: "App on home screen", steps: [] }),
|
|
"utf8"
|
|
);
|
|
|
|
const res = await supertest(handle.app)
|
|
.post("/tools/flow-read-prerequisite")
|
|
.send({ name: "saved-only", project_root: projectRoot });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.data).toEqual({
|
|
flow: "saved-only",
|
|
executionPrerequisite: "App on home screen",
|
|
});
|
|
});
|
|
});
|