feat: workspace aliases, tab groups of split panes, native path picker (v2.1.0)
- /api/workspaces and `agenthub workspace(s)` / `--workspace` as aliases of /api/lanes and `agenthub lanes` / `--lane`; old names print one stderr note. REPL `help lanes|workspace|workspaces` resolves through the aliases. - A workspace holds up to ten sessions (slots 0-9); every console tab is a group of 1-4 split panes (PaneGroups, agenthub.workspace.paneLayout); parseGroups keeps the stored active tab when an earlier one is dropped. - Browse button beside path fields: POST /api/lanes/pick-path opens the OS picker on the server's desktop — loopback only and no forwarding header (EREMOTE), a display failure is ENODIALOG not a Cancel, the dialog is killed when the request closes, a folder field falls back to the in-app browser on any failure. - Changes rail counts chip, image viewer (/git/raw), status bar session counts and per-day cost (`date=` on /api/pricing/cost). - /agenthub-open and /agenthub-doctor run from the INSTALLED build (supersededBy), doctor offers fixes behind two confirmations; plugin-open treats a locked bootstrap as non-fatal. - New-terminal shell waits for the first attach (issue #55). - repo-onboarding / debug-live-issue skills removed. - OpenAPI: pick-path, lane/date on /pricing/cost, tool on /run/history, /api/workspaces mount.
This commit is contained in:
@@ -15,7 +15,7 @@ const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const { collectChanges, parseNumstat, readFileAt } = require("../lib/git-diff");
|
||||
const { collectChanges, parseNumstat, readFileAt, readImageAt } = require("../lib/git-diff");
|
||||
const { runGitAction } = require("../lib/git-ops");
|
||||
const { gitFacts } = require("../lib/worktree");
|
||||
|
||||
@@ -179,6 +179,73 @@ describe("discard of a staged rename", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("readImageAt: an image's bytes for the viewer", () => {
|
||||
// Every byte value, so a utf8 round-trip anywhere would show.
|
||||
const bytes = Buffer.from(Array.from({ length: 256 }, (_, i) => i));
|
||||
|
||||
it("reads the working file and a committed one byte for byte", async () => {
|
||||
write(LANE, "img/logo.PNG", bytes);
|
||||
g(LANE, "add", "-A");
|
||||
g(LANE, "commit", "-qm", "img");
|
||||
const work = await readImageAt(LANE, "img/logo.PNG", "work");
|
||||
assert.equal(work.type, "image/png");
|
||||
assert.deepEqual(work.buf, bytes);
|
||||
const head = await readImageAt(LANE, "img/logo.PNG", "HEAD");
|
||||
assert.deepEqual(head.buf, bytes);
|
||||
});
|
||||
|
||||
it("refuses a non-image, a missing file and a symlink out of the lane", async () => {
|
||||
await assert.rejects(readImageAt(LANE, "a.txt", "work"), { code: "ENOTIMAGE" });
|
||||
await assert.rejects(readImageAt(LANE, "none.png", "work"), { code: "ENOFILE" });
|
||||
fs.writeFileSync(path.join(ROOT, "outside.png"), bytes);
|
||||
fs.symlinkSync(path.join(ROOT, "outside.png"), path.join(LANE, "leak.png"));
|
||||
await assert.rejects(readImageAt(LANE, "leak.png", "work"));
|
||||
await assert.rejects(readImageAt(LANE, "../outside.png", "work"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/lanes/:id/git/raw", () => {
|
||||
it("serves an image with its type, and an SVG sandboxed", async () => {
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "raw.db");
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
const { createApp, startServer } = require("../index");
|
||||
const server = await startServer(createApp(), 0);
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
try {
|
||||
write(LANE, "pic.png", Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 255]));
|
||||
write(
|
||||
LANE,
|
||||
"x.svg",
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'
|
||||
);
|
||||
const created = await fetch(`${base}/api/lanes`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ cwd: LANE, title: "raw" }),
|
||||
}).then((r) => r.json());
|
||||
const id = created.lane.id;
|
||||
const png = await fetch(`${base}/api/lanes/${id}/git/raw?path=pic.png`);
|
||||
assert.equal(png.status, 200);
|
||||
assert.equal(png.headers.get("content-type"), "image/png");
|
||||
assert.deepEqual(
|
||||
Buffer.from(await png.arrayBuffer()),
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 255])
|
||||
);
|
||||
const svg = await fetch(`${base}/api/lanes/${id}/git/raw?path=x.svg`);
|
||||
assert.equal(svg.headers.get("content-type"), "image/svg+xml");
|
||||
assert.match(svg.headers.get("content-security-policy"), /sandbox/);
|
||||
assert.equal(svg.headers.get("x-content-type-options"), "nosniff");
|
||||
const txt = await fetch(`${base}/api/lanes/${id}/git/raw?path=a.txt`);
|
||||
assert.equal(txt.status, 400);
|
||||
assert.equal((await txt.json()).error.code, "ENOTIMAGE");
|
||||
await fetch(`${base}/api/lanes/${id}`, { method: "DELETE" });
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/lanes/:id/git/file maps a secret to 403 ESECRET", () => {
|
||||
it("answers 403 for the router's settings file", async () => {
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "api.db");
|
||||
|
||||
Reference in New Issue
Block a user