Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions server/src/computer/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,41 @@ export function createComputerRoutes(
string,
unknown
> | null;
/*
* Shaped per gesture, like the Bot's acting routes shape theirs. Only scroll was checked:
* a click with `{"x": "ten"}`, a type with `{"text": 123}` or a key with `{}` travelled
* to the computer untouched, and the failure surfaced as whatever the computer returned
* for garbage — mapped here to a 500, or a 200 no-op. The shapes below are the ones the
* gateway's `HumanInput` type already promises the computer: coordinates in viewport
* pixels, text to enter, a key name. Scroll keeps its existing check, which allows an
* absent delta the computer reads as its own default distance.
*/
if (kind === "click") {
if (
typeof body?.x !== "number" ||
!Number.isFinite(body.x) ||
typeof body?.y !== "number" ||
!Number.isFinite(body.y)
) {
return context.json(
{ error: "A click needs numeric x and y coordinates." },
400,
);
}
}
if (kind === "type") {
if (typeof body?.text !== "string") {
return context.json({ error: "The text to enter is required." }, 400);
}
}
if (kind === "key") {
if (typeof body?.key !== "string" || !body.key) {
return context.json(
{ error: "A key name is required, such as Enter or Tab." },
400,
);
}
}
if (kind === "scroll" && !usableDeltaY(body?.deltaY)) {
return context.json(badDeltaY, 400);
}
Expand Down
142 changes: 142 additions & 0 deletions server/tests/computer-human-input.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { describe, expect, test } from "bun:test";
import type { ComputerGateway } from "../src/computer/gateway";
import type { PolicyStore } from "../src/computer/policy-store";
import { createComputerRoutes } from "../src/computer/routes";
import type { AppVariables } from "../src/auth/guards";
import type { MiddlewareHandler } from "hono";

/**
* A person's own mouse and keyboard, shaped before it travels.
*
* Only scroll was checked on this route. A click with `{"x": "ten"}`, a type with
* `{"text": 123}` or a key with `{}` travelled to the computer untouched, and the failure
* surfaced as whatever the computer returned for garbage — a 500 here, or a 200 no-op.
* The shapes below are the ones the gateway's `HumanInput` type already promises: `click`
* carries viewport-pixel coordinates, `type` carries text, `key` carries a key name.
*/

const member = {
id: "u1",
email: "member@openbot.test",
role: "user",
} as const;

function asActor(
actor: typeof member,
): MiddlewareHandler<{ Variables: AppVariables }> {
return async (context, next) => {
context.set("actor", { ...actor });
await next();
};
}

function recordingGateway() {
const calls: Array<{ botId: string; input: Record<string, unknown> }> = [];
const gateway = {
humanInput: async (botId: string, input: Record<string, unknown>) => {
calls.push({ botId, input });
return { ok: true };
},
} as unknown as ComputerGateway;
return {
calls,
app: createComputerRoutes(
gateway,
{} as PolicyStore,
asActor(member),
async () => true,
),
};
}

async function send(body: unknown, kind: string) {
const { app, calls } = recordingGateway();
const response = await app.request(
`http://openbot.test/bot-1/human/${kind}`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
},
);
return { response, calls };
}

describe("human input payloads", () => {
test("a click with coordinates still reaches the computer", async () => {
const { response, calls } = await send({ x: 10, y: 20 }, "click");

expect(response.status).toBe(200);
expect(calls).toHaveLength(1);
expect(calls[0]?.input).toMatchObject({ kind: "click", x: 10, y: 20 });
});

test("a type with text still reaches the computer", async () => {
const { response, calls } = await send({ text: "hello" }, "type");

expect(response.status).toBe(200);
expect(calls).toHaveLength(1);
});

test("a key with a name still reaches the computer", async () => {
const { response, calls } = await send({ key: "Enter" }, "key");

expect(response.status).toBe(200);
expect(calls).toHaveLength(1);
});

test.each([
["a string x", { x: "ten", y: 20 }],
["a missing x", { y: 20 }],
["a missing y", { x: 10 }],
["NaN x", { x: Number.NaN, y: 20 }],
["Infinity y", { x: 10, y: Number.POSITIVE_INFINITY }],
["null", null],
["an empty body", {}],
])("refuses a click with %s and never forwards it", async (_name, body) => {
const { response, calls } = await send(body, "click");

expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
error: "A click needs numeric x and y coordinates.",
});
expect(calls).toHaveLength(0);
});

test.each([
["a number", { text: 123 }],
["null", { text: null }],
["an object", { text: {} }],
["a missing text", {}],
])("refuses a type with %s and never forwards it", async (_name, body) => {
const { response, calls } = await send(body, "type");

expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
error: "The text to enter is required.",
});
expect(calls).toHaveLength(0);
});

test.each([
["a number", { key: 123 }],
["an empty name", { key: "" }],
["a missing key", {}],
["null", null],
])("refuses a key with %s and never forwards it", async (_name, body) => {
const { response, calls } = await send(body, "key");

expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
error: "A key name is required, such as Enter or Tab.",
});
expect(calls).toHaveLength(0);
});

test("a scroll without a delta still travels, as before", async () => {
const { response, calls } = await send({}, "scroll");

expect(response.status).toBe(200);
expect(calls).toHaveLength(1);
});
});
2 changes: 1 addition & 1 deletion server/tests/computer-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ describe("human input", () => {

test("a body naming its own kind cannot reach the shell either", async () => {
const { calls } = await send(
{ kind: "../exec", command: "cat /workspace/notes" },
{ kind: "../exec", text: "cat /workspace/notes" },
"type",
);

Expand Down
7 changes: 6 additions & 1 deletion server/tests/human-input-end-to-end.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,12 @@ describe("a person's input, end to end", () => {

test("the four gestures still reach their own endpoints", async () => {
for (const kind of ["click", "type", "key", "scroll"]) {
const { received } = await drive(kind, { x: 1, y: 1, text: "hello" });
const { received } = await drive(kind, {
x: 1,
y: 1,
text: "hello",
key: "Enter",
});

expect(received.map((request) => request.path)).toEqual([
`/human/${kind}`,
Expand Down