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
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: ci

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
strategy:
matrix:
node: [18, 22]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm test
- run: npm pack --dry-run

12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,18 @@ These map 1:1 to the `SHAPES_BRIDGE` tool in the Shapes app.
| `sysinfo` | harmless machine stats (os, cpu, mem, disk) | `GET /sysinfo` |
| `health` | check the bridge is alive (no token needed) | `GET /health` |

### Give your Shape a repository

Once connected, paste a GitHub repository URL and ask the Shape to run,
connect, or build it on this machine. The Shape uses the bridge—not its remote
sandbox—to clone the repo, then reads root `SHAPE.md`, `AGENTS.md`, `llms.txt`,
or `README.md` before acting.

For private repos, authenticate GitHub locally with `gh auth login`. Never
paste GitHub credentials into chat. [`shapesinc/form`](https://github.com/shapesinc/form)
ships a complete `SHAPE.md` contract for connecting to and co-building a
physical robot this way.

## Troubleshooting

- **Shape says it can't connect?** The terminal probably got closed. Run the
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
"bin": {
"shapes-bridge": "bin/cli.js"
},
"scripts": {
"test": "node --test"
},
"type": "commonjs",
"files": [
"bin",
Expand Down
36 changes: 30 additions & 6 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@ const http = require("node:http");
const os = require("node:os");
const fs = require("node:fs");
const path = require("node:path");
const crypto = require("node:crypto");
const { exec, spawn } = require("node:child_process");
const { URL } = require("node:url");

const MAX_OUTPUT = 20000;
const MAX_FILE_READ = 100000;
const MAX_COMMAND_BUFFER = 4 * 1024 * 1024;
const MAX_DIRECTORY_ENTRIES = 2000;

const CAPABILITIES = {
run: "run a shell command",
Expand Down Expand Up @@ -86,7 +89,7 @@ function runCommand({ cmd, cwd, timeout }) {
{
cwd: cwd || undefined,
timeout: seconds * 1000,
maxBuffer: 64 * 1024 * 1024,
maxBuffer: MAX_COMMAND_BUFFER,
windowsHide: true,
},
(err, stdout, stderr) => {
Expand Down Expand Up @@ -159,7 +162,10 @@ function sysinfo() {
function startServer({ port, token }) {
const authed = (req) => {
const provided = req.headers["x-token"];
return Boolean(token) && provided === token;
if (!token || typeof provided !== "string") return false;
const expected = Buffer.from(token);
const actual = Buffer.from(provided);
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
};

const server = http.createServer(async (req, res) => {
Expand Down Expand Up @@ -215,14 +221,28 @@ function startServer({ port, token }) {
const p = expand(url.searchParams.get("path"));
if (!p) return sendJson(res, 400, { error: "'path' is required" });
log(`READ: ${p}`);
const content = fs.readFileSync(p, "utf8").slice(0, MAX_FILE_READ);
return sendJson(res, 200, { path: p, content });
const stat = fs.statSync(p);
if (!stat.isFile()) return sendJson(res, 400, { error: "path is not a file" });
const size = Math.min(stat.size, MAX_FILE_READ);
const buffer = Buffer.alloc(size);
const fd = fs.openSync(p, "r");
try {
fs.readSync(fd, buffer, 0, size, 0);
} finally {
fs.closeSync(fd);
}
return sendJson(res, 200, {
path: p,
content: buffer.toString("utf8"),
truncated: stat.size > MAX_FILE_READ,
});
}

if (route === "/ls" && method === "GET") {
const p = expand(url.searchParams.get("path") || ".");
log(`LS: ${p}`);
const entries = fs.readdirSync(p).sort().map((name) => {
const allNames = fs.readdirSync(p).sort();
const entries = allNames.slice(0, MAX_DIRECTORY_ENTRIES).map((name) => {
const full = path.join(p, name);
let isDir = false;
let size = null;
Expand All @@ -235,7 +255,11 @@ function startServer({ port, token }) {
}
return { name, dir: isDir, size };
});
return sendJson(res, 200, { path: p, entries });
return sendJson(res, 200, {
path: p,
entries,
truncated: allNames.length > MAX_DIRECTORY_ENTRIES,
});
}

if (route === "/open" && method === "POST") {
Expand Down
93 changes: 93 additions & 0 deletions test/server.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"use strict";

const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { startServer } = require("../src/server");

const TOKEN = "test-token-with-fixed-length";

async function withServer(run) {
const server = await startServer({ port: 0, token: TOKEN });
const base = `http://127.0.0.1:${server.address().port}`;
try {
await run(base);
} finally {
await new Promise((resolve) => server.close(resolve));
}
}

function authed(init = {}) {
return {
...init,
headers: { "content-type": "application/json", "x-token": TOKEN, ...init.headers },
};
}

test("public health is reachable but machine actions require the token", async () => {
await withServer(async (base) => {
assert.deepEqual(await (await fetch(`${base}/health`)).json(), { ok: true });
assert.equal((await fetch(`${base}/sysinfo`)).status, 401);
assert.equal(
(await fetch(`${base}/sysinfo`, { headers: { "x-token": "wrong" } })).status,
401
);
assert.equal((await fetch(`${base}/sysinfo`, authed())).status, 200);
});
});

test("run returns structured output", async () => {
await withServer(async (base) => {
const response = await fetch(
`${base}/run`,
authed({ method: "POST", body: JSON.stringify({ cmd: "printf bridge-ok" }) })
);
const result = await response.json();
assert.equal(result.exit_code, 0);
assert.equal(result.stdout, "bridge-ok");
});
});

test("write and read round-trip through the token-locked API", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "shapes-bridge-test-"));
const file = path.join(dir, "hello.txt");
try {
await withServer(async (base) => {
const wrote = await fetch(
`${base}/write`,
authed({
method: "POST",
body: JSON.stringify({ path: file, content: "hello bridge" }),
})
);
assert.equal(wrote.status, 200);
const read = await (
await fetch(`${base}/read?path=${encodeURIComponent(file)}`, authed())
).json();
assert.equal(read.content, "hello bridge");
assert.equal(read.truncated, false);
});
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

test("large reads are bounded before loading the whole file", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "shapes-bridge-test-"));
const file = path.join(dir, "large.txt");
fs.writeFileSync(file, "x".repeat(150000));
try {
await withServer(async (base) => {
const read = await (
await fetch(`${base}/read?path=${encodeURIComponent(file)}`, authed())
).json();
assert.equal(read.content.length, 100000);
assert.equal(read.truncated, true);
});
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

Loading