diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6ba8f2e --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 + diff --git a/README.md b/README.md index 2e9f3ec..fa67ae7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package.json b/package.json index b803933..00f3d7d 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,9 @@ "bin": { "shapes-bridge": "bin/cli.js" }, + "scripts": { + "test": "node --test" + }, "type": "commonjs", "files": [ "bin", diff --git a/src/server.js b/src/server.js index a9a2e02..012b7bd 100644 --- a/src/server.js +++ b/src/server.js @@ -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", @@ -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) => { @@ -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) => { @@ -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; @@ -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") { diff --git a/test/server.test.js b/test/server.test.js new file mode 100644 index 0000000..1c36ba8 --- /dev/null +++ b/test/server.test.js @@ -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 }); + } +}); +