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:
@@ -139,6 +139,13 @@ describe("GET /api/pricing/cost daily split", () => {
|
||||
"2026-10-05": 3,
|
||||
});
|
||||
assert.equal(ict.body.total_cost, 7);
|
||||
|
||||
// `date=` prices that one local day, breakdown included.
|
||||
const day = await call("GET", "/api/pricing/cost?tz_offset=-420&date=2026-10-03");
|
||||
assert.equal(day.body.total_cost, 2);
|
||||
assert.equal(day.body.breakdown.length, 1);
|
||||
assert.equal(day.body.breakdown[0].input_tokens, 2 * M);
|
||||
assert.equal((await call("GET", "/api/pricing/cost?date=10/03")).status, 400);
|
||||
});
|
||||
|
||||
it("scales hours above the total down to it, so the days still add up", async () => {
|
||||
|
||||
@@ -41,6 +41,17 @@ describe("dashboard-runs", () => {
|
||||
assert.ok(old.ended_at);
|
||||
});
|
||||
|
||||
it("narrows a lane's history to one tool", () => {
|
||||
start("agenthub-lane-8", { laneId: 8, tool: "claude" });
|
||||
start("agenthub-lane-8-1", { laneId: 8, tool: "shell" });
|
||||
const shells = runs.listRuns({ laneId: 8, tool: "shell" });
|
||||
assert.deepEqual(
|
||||
shells.map((r) => r.id),
|
||||
["agenthub-lane-8-1"]
|
||||
);
|
||||
assert.equal(runs.listRuns({ laneId: 8 }).length, 2);
|
||||
});
|
||||
|
||||
it("flags as abandoned only the running rows whose pane is gone", () => {
|
||||
start("agenthub-lane-5", { laneId: 5 });
|
||||
start("agenthub-lane-6", { laneId: 6 });
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -129,6 +129,23 @@ describe("/api/lanes", () => {
|
||||
assert.equal(typeof r.body.counts.needs_you, "number");
|
||||
});
|
||||
|
||||
it("serves the same routes under /api/workspaces, the UI's word", async () => {
|
||||
const lanes = await request("GET", "/api/lanes");
|
||||
const list = await request("GET", "/api/workspaces");
|
||||
assert.equal(list.status, 200);
|
||||
assert.deepEqual(
|
||||
list.body.lanes.map((l) => l.id),
|
||||
lanes.body.lanes.map((l) => l.id)
|
||||
);
|
||||
const one = await request("GET", `/api/workspaces/${laneId}`);
|
||||
assert.equal(one.status, 200);
|
||||
assert.equal(one.body.lane.id, laneId);
|
||||
// Mounted through the sources router, which sits before the lanes router.
|
||||
const sources = await request("GET", `/api/workspaces/${laneId}/sources`);
|
||||
assert.equal(sources.status, 200);
|
||||
assert.deepEqual(sources.body, (await request("GET", `/api/lanes/${laneId}/sources`)).body);
|
||||
});
|
||||
|
||||
it("exposes pipeline templates", async () => {
|
||||
const r = await request("GET", "/api/lanes/pipelines");
|
||||
assert.equal(r.status, 200);
|
||||
@@ -220,6 +237,42 @@ describe("/api/lanes", () => {
|
||||
assert.equal((await request("DELETE", `/api/lanes/${id}`)).status, 200);
|
||||
});
|
||||
|
||||
it("opens the native picker only for a local, unproxied, same-origin browser", async () => {
|
||||
const nd = require("../lib/native-dialog");
|
||||
const desktop = require("../lib/desktop-env");
|
||||
const calls = [];
|
||||
nd.__setExecImpl(async (cmd) => {
|
||||
calls.push(cmd);
|
||||
return { code: 0, stdout: "/tmp/picked\n", stderr: "" };
|
||||
});
|
||||
desktop.__setExecImpl(() => "DISPLAY=:0\n");
|
||||
try {
|
||||
for (const headers of [
|
||||
{ Origin: "https://attacker.example" },
|
||||
{ "X-Forwarded-For": "203.0.113.9" },
|
||||
{ Forwarded: "for=203.0.113.9" },
|
||||
]) {
|
||||
const r = await request("POST", "/api/lanes/pick-path", { kind: "dir" }, headers);
|
||||
assert.equal(r.status, 403, JSON.stringify(headers));
|
||||
}
|
||||
assert.deepEqual(calls, []);
|
||||
if (process.platform === "linux") {
|
||||
const r = await request("POST", "/api/lanes/pick-path", { kind: "dir" });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.path, "/tmp/picked");
|
||||
nd.__setExecImpl(async () => ({ missing: true }));
|
||||
const none = await request("POST", "/api/lanes/pick-path", { kind: "file" });
|
||||
assert.equal(none.status, 501);
|
||||
assert.equal(none.body.error.code, "ENODIALOG");
|
||||
}
|
||||
} finally {
|
||||
nd.__setExecImpl(null);
|
||||
desktop.__setExecImpl(() => {
|
||||
throw new Error("no systemd user manager in this test");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("broadcasts lane_update on create", async () => {
|
||||
const wsUrl = `ws://127.0.0.1:${server.address().port}/ws`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
@@ -425,8 +478,13 @@ describe("lane start input", () => {
|
||||
assert.equal(r.body.run_id, `agenthub-lane-${id}-1`);
|
||||
assert.equal(created[created.indexOf("-s") + 1], `agenthub-lane-${id}-1`);
|
||||
assert.equal(created[created.indexOf("-c") + 1], cwd);
|
||||
// Nothing after `--`: tmux runs the user's own default shell.
|
||||
assert.equal(created[created.length - 1], "--");
|
||||
// After `--` only the fixed size-wait wrapper that execs the user's own
|
||||
// default shell (issue #55) — nothing from the request.
|
||||
const argv = created.slice(created.indexOf("--") + 1);
|
||||
assert.deepEqual(argv.slice(0, 2), ["sh", "-c"]);
|
||||
assert.equal(argv.length, 3);
|
||||
assert.match(argv[2], /exec "\$SHELL" -l$/);
|
||||
assert.ok(!created.includes("ignored"));
|
||||
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
|
||||
assert.equal(lane.run_id, null);
|
||||
assert.notEqual(lane.status, "running");
|
||||
@@ -1958,13 +2016,16 @@ describe("start: slot auto and a session already live", () => {
|
||||
assert.equal(r.body.run_id, `agenthub-lane-${id}-2`);
|
||||
});
|
||||
|
||||
it("answers ERUNLIVE when every slot is busy", async () => {
|
||||
live.add(`agenthub-lane-${id}-3`);
|
||||
it("answers ERUNLIVE when all ten slots are busy", async () => {
|
||||
for (let s = 3; s <= 8; s++) live.add(`agenthub-lane-${id}-${s}`);
|
||||
// Slot 9 is the tenth and last.
|
||||
const ninth = await request("POST", `/api/lanes/${id}/start`, { slot: "auto", shell: true });
|
||||
assert.equal(ninth.body.run_id, `agenthub-lane-${id}-9`);
|
||||
live.add(`agenthub-lane-${id}-9`);
|
||||
const r = await request("POST", `/api/lanes/${id}/start`, { slot: "auto" });
|
||||
assert.equal(r.status, 409);
|
||||
assert.equal(r.body.error.code, "ERUNLIVE");
|
||||
live.delete(`agenthub-lane-${id}-3`);
|
||||
live.delete(`agenthub-lane-${id}-2`);
|
||||
for (let s = 2; s <= 9; s++) live.delete(`agenthub-lane-${id}-${s}`);
|
||||
});
|
||||
|
||||
it("refuses to resume a session a live pane already holds, naming that pane", async () => {
|
||||
|
||||
@@ -193,6 +193,24 @@ after(() => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("agenthub workspaces", () => {
|
||||
it("is the lanes command under the UI's word", async () => {
|
||||
const [lanes, workspaces, workspace] = await Promise.all([
|
||||
cli(["lanes"], LANE_DIR),
|
||||
cli(["workspaces"], LANE_DIR),
|
||||
cli(["workspace"], LANE_DIR),
|
||||
]);
|
||||
assert.equal(workspaces.status, 0, workspaces.stderr);
|
||||
assert.match(workspaces.stdout, /CLI lane/);
|
||||
assert.equal(workspaces.stdout, lanes.stdout);
|
||||
assert.equal(workspace.stdout, lanes.stdout);
|
||||
// The old name still works but says so — on stderr, never in stdout.
|
||||
assert.match(lanes.stderr, /old name.*agenthub workspace/);
|
||||
assert.doesNotMatch(workspaces.stderr, /old name/);
|
||||
assert.doesNotMatch(lanes.stdout, /old name/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("agenthub stage", () => {
|
||||
it("reports a stage for the lane owning the current directory", async () => {
|
||||
const r = await cli(["stage", "review", "--evidence", "3 findings"], LANE_DIR);
|
||||
@@ -280,6 +298,17 @@ describe("agenthub stage in a directory shared by two lanes", () => {
|
||||
assert.notEqual(await stageOf(codexLane.id), "implement");
|
||||
});
|
||||
|
||||
it("--workspace names the lane like --lane", async () => {
|
||||
const r = await cli(
|
||||
["stage", "review", "--workspace", String(codexLane.id)],
|
||||
DUAL_DIR,
|
||||
NO_HARNESS
|
||||
);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(await stageOf(codexLane.id), "review");
|
||||
assert.notEqual(await stageOf(claudeLane.id), "review");
|
||||
});
|
||||
|
||||
it("fails loudly, naming both candidates, when nothing identifies the tool", async () => {
|
||||
const before = [await stageOf(claudeLane.id), await stageOf(codexLane.id)];
|
||||
const r = await cli(["stage", "done"], DUAL_DIR, NO_HARNESS);
|
||||
@@ -686,7 +715,7 @@ describe("agenthub lanes node", () => {
|
||||
assert.notEqual(r.status, 0);
|
||||
assert.match(r.stdout, /\bintake\b/);
|
||||
assert.match(r.stdout, /\bdone\b/);
|
||||
assert.match(r.stderr, /usage: agenthub lanes node/);
|
||||
assert.match(r.stderr, /usage: agenthub workspace node/);
|
||||
});
|
||||
|
||||
it("--print shows a slash-command node as a claude prompt, extra args appended", async () => {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* @file native-dialog.test.js
|
||||
* @description Unit tests for the server-side native folder/file picker: the
|
||||
* argv each platform gets, the fallback from zenity to kdialog, Cancel versus a
|
||||
* chosen path, and the "no dialog program" answer the client falls back on.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
const { describe, it, afterEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const os = require("node:os");
|
||||
const nd = require("../lib/native-dialog");
|
||||
const desktop = require("../lib/desktop-env");
|
||||
|
||||
const linux = process.platform === "linux";
|
||||
|
||||
describe("native-dialog", () => {
|
||||
afterEach(() => nd.__setExecImpl(null));
|
||||
|
||||
it("starts in the given folder, a file's folder, or home", () => {
|
||||
assert.equal(nd.startDir(os.tmpdir()), os.tmpdir());
|
||||
assert.equal(nd.startDir("/definitely/not/here/x"), os.homedir());
|
||||
assert.equal(nd.startDir("~"), os.homedir());
|
||||
assert.equal(nd.startDir(undefined), os.homedir());
|
||||
});
|
||||
|
||||
it("builds zenity then kdialog on Linux, the request's folder one argv element", () => {
|
||||
const [[z, zargs], [k, kargs]] = nd.dialogCommands("dir", "/tmp/a b", "linux");
|
||||
assert.equal(z, "zenity");
|
||||
assert.deepEqual(zargs, [
|
||||
"--file-selection",
|
||||
"--title=Select a folder",
|
||||
"--filename=/tmp/a b/",
|
||||
"--directory",
|
||||
]);
|
||||
assert.equal(k, "kdialog");
|
||||
assert.deepEqual(kargs.slice(0, 2), ["--getexistingdirectory", "/tmp/a b"]);
|
||||
assert.ok(!nd.dialogCommands("file", "/tmp", "linux")[0][1].includes("--directory"));
|
||||
});
|
||||
|
||||
it("passes the folder to osascript as an argument, never inside the script", () => {
|
||||
const [[cmd, args]] = nd.dialogCommands("file", '/x"; do shell script "id', "darwin");
|
||||
assert.equal(cmd, "osascript");
|
||||
assert.equal(args.at(-1), '/x"; do shell script "id');
|
||||
assert.ok(!args.slice(0, -2).join(" ").includes("shell script"));
|
||||
});
|
||||
|
||||
it(
|
||||
"answers the chosen path, null on Cancel, and falls through a missing program",
|
||||
{
|
||||
skip: !linux,
|
||||
},
|
||||
async () => {
|
||||
desktop.__setExecImpl(() => "DISPLAY=:0\n");
|
||||
const tried = [];
|
||||
nd.__setExecImpl(async (cmd) => {
|
||||
tried.push(cmd);
|
||||
return cmd === "zenity" ? { missing: true } : { code: 0, stdout: "/home/u/proj\n" };
|
||||
});
|
||||
assert.equal(await nd.pickPath("dir"), "/home/u/proj");
|
||||
assert.deepEqual(tried, ["zenity", "kdialog"]);
|
||||
|
||||
nd.__setExecImpl(async () => ({ code: 1, stdout: "" }));
|
||||
assert.equal(await nd.pickPath("file"), null);
|
||||
|
||||
nd.__setExecImpl(async () => ({ missing: true }));
|
||||
await assert.rejects(nd.pickPath("dir"), { code: "ENODIALOG" });
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
"answers ENODIALOG, not a Cancel, when the program could not reach the desktop",
|
||||
{ skip: !linux },
|
||||
async () => {
|
||||
desktop.__setExecImpl(() => "DISPLAY=:0\n");
|
||||
nd.__setExecImpl(async () => ({
|
||||
code: 1,
|
||||
stdout: "",
|
||||
stderr: "(zenity:1): Gtk-WARNING **: cannot open display: :0\n",
|
||||
}));
|
||||
await assert.rejects(nd.pickPath("dir"), { code: "ENODIALOG" });
|
||||
}
|
||||
);
|
||||
|
||||
it("passes the abort signal through to the dialog program", { skip: !linux }, async () => {
|
||||
desktop.__setExecImpl(() => "DISPLAY=:0\n");
|
||||
const ac = new AbortController();
|
||||
let seen;
|
||||
nd.__setExecImpl(async (cmd, args, env, signal) => {
|
||||
seen = signal;
|
||||
return { code: 1, stdout: "", stderr: "" };
|
||||
});
|
||||
assert.equal(await nd.pickPath("dir", undefined, ac.signal), null);
|
||||
assert.equal(seen, ac.signal);
|
||||
});
|
||||
|
||||
it("tells a Cancel from a failure per program", () => {
|
||||
assert.equal(nd.isCancel("zenity", ""), true);
|
||||
assert.equal(nd.isCancel("zenity", "Gtk-Message: GtkDialog mapped without a transient parent"), true);
|
||||
assert.equal(nd.isCancel("kdialog", "qt.qpa.xcb: could not connect to display :1"), false);
|
||||
assert.equal(nd.isCancel("osascript", "execution error: User canceled. (-128)"), true);
|
||||
assert.equal(nd.isCancel("osascript", "execution error: No user interaction allowed. (-1713)"), false);
|
||||
assert.equal(nd.isCancel("powershell.exe", ""), true);
|
||||
assert.equal(nd.isCancel("powershell.exe", "Add-Type : Cannot add type"), false);
|
||||
});
|
||||
});
|
||||
@@ -47,7 +47,7 @@ describe("plugin-doctor: tmux row", () => {
|
||||
const row = rowWithTmux(null);
|
||||
|
||||
assert.equal(row.level, "warn", "a missing tmux must not fail the doctor run");
|
||||
assert.match(row.detail, /lanes shell/, "the reader needs to know WHAT breaks");
|
||||
assert.match(row.detail, /workspace shell/, "the reader needs to know WHAT breaks");
|
||||
assert.match(row.detail, /install/i, "and how to fix it");
|
||||
});
|
||||
|
||||
|
||||
@@ -56,7 +56,9 @@ describe("pty-attach", () => {
|
||||
it("accepts a well-formed runId", () => {
|
||||
assert.doesNotThrow(() => ptyAttach.validateRunId("agenthub-lane-42"));
|
||||
assert.doesNotThrow(() => ptyAttach.validateRunId("agenthub-lane-42-3"));
|
||||
assert.throws(() => ptyAttach.validateRunId("agenthub-lane-42-4"), /EBADRUNID/);
|
||||
assert.doesNotThrow(() => ptyAttach.validateRunId("agenthub-lane-42-9"));
|
||||
assert.throws(() => ptyAttach.validateRunId("agenthub-lane-42-10"), /EBADRUNID/);
|
||||
assert.throws(() => ptyAttach.validateRunId("agenthub-lane-42-0"), /EBADRUNID/);
|
||||
});
|
||||
|
||||
it("turns tmux mouse mode on for the session it attaches to", () => {
|
||||
|
||||
@@ -58,7 +58,49 @@ describe("pty-run", () => {
|
||||
assert.ok(newSessionCall.includes("opus"));
|
||||
});
|
||||
|
||||
it("spawnRun names a split pane's session agenthub-lane-<id>-<slot>, and refuses a slot past 3", () => {
|
||||
it("spawnShell starts the login shell only once the pane has its real size (issue #55)", async () => {
|
||||
const calls = [];
|
||||
tmux.__setExecImpl((args) => {
|
||||
calls.push(args);
|
||||
return "";
|
||||
});
|
||||
pty.spawnShell({ laneId: 43, cwd: "/tmp/repo" });
|
||||
const call = calls.find((c) => c[0] === "new-session");
|
||||
const argv = call.slice(call.indexOf("--") + 1);
|
||||
assert.equal(argv[0], "sh");
|
||||
assert.equal(argv[1], "-c");
|
||||
// Run the wrapper with a stand-in shell and a fake `tmux` that reports no
|
||||
// client until a flag file appears: the shell must wait for the attach,
|
||||
// then start at once rather than at the 2 s cap.
|
||||
const { spawn } = require("node:child_process");
|
||||
const bin = fs.mkdtempSync(path.join(os.tmpdir(), "fake-tmux-"));
|
||||
const flag = path.join(bin, "attached");
|
||||
fs.writeFileSync(
|
||||
path.join(bin, "tmux"),
|
||||
`#!/bin/sh\n[ -e '${flag}' ] && echo 1 || echo 0\n`,
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
try {
|
||||
const t0 = Date.now();
|
||||
const child = spawn("sh", ["-c", argv[2]], {
|
||||
env: { ...process.env, SHELL: "/bin/echo", PATH: `${bin}:${process.env.PATH}` },
|
||||
});
|
||||
let out = "";
|
||||
let startedAt = 0;
|
||||
child.stdout.on("data", (d) => {
|
||||
out += d;
|
||||
startedAt ||= Date.now() - t0;
|
||||
});
|
||||
setTimeout(() => fs.writeFileSync(flag, ""), 400);
|
||||
await new Promise((r) => child.on("exit", r));
|
||||
assert.equal(out.trim(), "-l");
|
||||
assert.ok(startedAt >= 400 && startedAt < 1500, `shell started after ${startedAt} ms`);
|
||||
} finally {
|
||||
fs.rmSync(bin, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("spawnRun names a split pane's session agenthub-lane-<id>-<slot>, and refuses a slot past 9 (ten per workspace)", () => {
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session") {
|
||||
const e = new Error("no such session");
|
||||
@@ -72,8 +114,10 @@ describe("pty-run", () => {
|
||||
assert.equal(handle.laneId, 42);
|
||||
assert.equal(handle.slot, 2);
|
||||
assert.equal(pty.laneIdFromRunId("agenthub-lane-42-2"), 42);
|
||||
assert.equal(pty.spawnRun({ laneId: 42, slot: 9, cwd: "/tmp/repo" }).id, "agenthub-lane-42-9");
|
||||
assert.equal(pty.laneIdFromRunId("agenthub-lane-42-9"), 42);
|
||||
assert.throws(
|
||||
() => pty.spawnRun({ laneId: 42, slot: 4, cwd: "/tmp/repo" }),
|
||||
() => pty.spawnRun({ laneId: 42, slot: 10, cwd: "/tmp/repo" }),
|
||||
(err) => err.code === "EBADSLOT"
|
||||
);
|
||||
});
|
||||
|
||||
+7
-3
@@ -130,9 +130,13 @@ function createApp() {
|
||||
app.use("/api/git-credentials", gitCredentialsRouter);
|
||||
app.use("/api/ai-tools", aiToolsRouter);
|
||||
app.use("/api/run", runRouter);
|
||||
// Before the lanes router, whose `/:id/:action` would otherwise see these.
|
||||
app.use("/api/lanes", laneSourcesRouter);
|
||||
app.use("/api/lanes", lanesRouter);
|
||||
// `/api/workspaces` is the same router under the UI's word; `/api/lanes`
|
||||
// stays for every client and script that already names it (whether it ever
|
||||
// goes is step 4c of docs/plans/workspace-rename/README.md, default keep).
|
||||
// Sources first: the lanes router's `/:id/:action` would otherwise see them.
|
||||
const LANE_MOUNTS = ["/api/lanes", "/api/workspaces"];
|
||||
app.use(LANE_MOUNTS, laneSourcesRouter);
|
||||
app.use(LANE_MOUNTS, lanesRouter);
|
||||
app.use("/api/projects", projectsRouter);
|
||||
app.use("/api/locks", locksRouter);
|
||||
app.use("/api/pets", petsRouter);
|
||||
|
||||
@@ -68,6 +68,14 @@ const listByLaneStmt = db.prepare(`
|
||||
LIMIT @limit
|
||||
`);
|
||||
|
||||
const listByLaneToolStmt = db.prepare(`
|
||||
SELECT ${RUN_COLUMNS}
|
||||
FROM dashboard_runs
|
||||
WHERE lane_id = @laneId AND tool = @tool
|
||||
ORDER BY started_at DESC
|
||||
LIMIT @limit
|
||||
`);
|
||||
|
||||
const getStmt = db.prepare(`
|
||||
SELECT ${RUN_COLUMNS}
|
||||
FROM dashboard_runs WHERE id = @id
|
||||
@@ -144,10 +152,12 @@ function patchRun({ id, sessionId, status, exitCode, endedAt }) {
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {{limit?: number, laneId?: number|null}} [opts] laneId narrows to one lane's runs. */
|
||||
function listRuns({ limit = 50, laneId = null } = {}) {
|
||||
/** @param {{limit?: number, laneId?: number|null, tool?: string|null}} [opts]
|
||||
* laneId narrows to one lane's runs, and tool (with a laneId) to one tool's. */
|
||||
function listRuns({ limit = 50, laneId = null, tool = null } = {}) {
|
||||
try {
|
||||
const safeLimit = Math.max(1, Math.min(500, Math.floor(Number(limit) || 50)));
|
||||
if (laneId != null && tool) return listByLaneToolStmt.all({ limit: safeLimit, laneId, tool });
|
||||
if (laneId != null) return listByLaneStmt.all({ limit: safeLimit, laneId });
|
||||
return listStmt.all({ limit: safeLimit });
|
||||
} catch {
|
||||
|
||||
@@ -723,6 +723,60 @@ async function readFileAt(dir, requested, rev = "work") {
|
||||
return { path: file, rev, binary, truncated, lines, ...(sha ? { sha } : {}) };
|
||||
}
|
||||
|
||||
/** The image types the viewer shows, by extension. Raster and SVG only. */
|
||||
const IMAGE_TYPES = {
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
avif: "image/avif",
|
||||
bmp: "image/bmp",
|
||||
ico: "image/x-icon",
|
||||
svg: "image/svg+xml",
|
||||
};
|
||||
const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* One image's bytes at a revision, for the viewer's `<img>`: `readFileAt`'s
|
||||
* guards (lexical path, safe rev, secret, realpath-in-lane for `work`), plus
|
||||
* an extension allowlist that also picks the Content-Type and a size cap.
|
||||
*/
|
||||
async function readImageAt(dir, requested, rev = "work") {
|
||||
const file = lanePath(requested);
|
||||
const type = IMAGE_TYPES[path.extname(file).slice(1).toLowerCase()];
|
||||
if (!type) throw badRequest("ENOTIMAGE", "not an image file");
|
||||
if (rev !== "work" && !SAFE_REV.test(rev)) {
|
||||
throw badRequest("EBADREV", "rev is not a valid revision");
|
||||
}
|
||||
if (await isSecretOnDisk(dir, file)) {
|
||||
throw badRequest("ESECRET", "that file holds secrets and is not readable here");
|
||||
}
|
||||
const tooBig = () => badRequest("ETOOBIG", "image is larger than 20 MB");
|
||||
if (rev === "work") {
|
||||
const abs = await resolveInLane(dir, file);
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.promises.stat(abs);
|
||||
} catch {
|
||||
throw badRequest("ENOFILE", "no such file in the working tree");
|
||||
}
|
||||
if (!stat.isFile()) throw badRequest("ENOFILE", "no such file in the working tree");
|
||||
if (stat.size > MAX_IMAGE_BYTES) throw tooBig();
|
||||
return { type, buf: await fs.promises.readFile(abs) };
|
||||
}
|
||||
try {
|
||||
const { stdout } = await git(dir, ["show", `${rev}:./${file}`], {
|
||||
encoding: "buffer",
|
||||
maxBuffer: MAX_IMAGE_BYTES,
|
||||
});
|
||||
return { type, buf: stdout };
|
||||
} catch (err) {
|
||||
if (err.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") throw tooBig();
|
||||
throw badRequest("ENOFILE", "no such file at that revision");
|
||||
}
|
||||
}
|
||||
|
||||
/** The version stamp of a file's bytes, for a stale-save check. */
|
||||
function contentSha(buf) {
|
||||
return crypto.createHash("sha1").update(buf).digest("hex");
|
||||
@@ -983,6 +1037,7 @@ module.exports = {
|
||||
parsePorcelainZ,
|
||||
parseUnifiedDiff,
|
||||
readFileAt,
|
||||
readImageAt,
|
||||
resolveBaseRef,
|
||||
DEFAULT_CONTEXT,
|
||||
MAX_CONTEXT,
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @file native-dialog.js
|
||||
* @description Opens the operating system's own folder/file picker ON THE
|
||||
* SERVER'S DESKTOP and returns the absolute path the user chose. A browser's
|
||||
* native picker never hands a page an absolute path, and the dashboard is
|
||||
* local-first, so the server asks the desktop itself: zenity or kdialog on
|
||||
* Linux (with the graphical session's variables from `desktop-env.js`, which a
|
||||
* systemd user service lacks), `osascript` on macOS, PowerShell's WinForms
|
||||
* dialogs on Windows. Runs with `execFile` (never a shell, never sync — a
|
||||
* dialog waits on a human for minutes), one dialog at a time. Nothing from the
|
||||
* request reaches argv except the starting folder, resolved to an absolute
|
||||
* path first. macOS and Windows are code-only, untested on those systems.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { execFile } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { desktopEnv } = require("./desktop-env");
|
||||
|
||||
const TITLES = { dir: "Select a folder", file: "Select a file" };
|
||||
const TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
const defaultExec = (cmd, args, env, signal) =>
|
||||
new Promise((resolve) => {
|
||||
execFile(
|
||||
cmd,
|
||||
args,
|
||||
{ env, timeout: TIMEOUT_MS, windowsHide: true, encoding: "utf8", signal },
|
||||
(err, stdout, stderr) =>
|
||||
resolve({
|
||||
missing: err?.code === "ENOENT",
|
||||
code: err ? (typeof err.code === "number" ? err.code : -1) : 0,
|
||||
stdout: String(stdout || ""),
|
||||
stderr: String(stderr || ""),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
let execImpl = defaultExec;
|
||||
|
||||
/** Test seam — same pattern as desktop-env.js; `null` restores the real one. */
|
||||
function __setExecImpl(fn) {
|
||||
execImpl = fn || defaultExec;
|
||||
}
|
||||
|
||||
/** An existing folder to open the dialog in: `start` (or its parent when it is a file), else home. */
|
||||
function startDir(start) {
|
||||
if (typeof start === "string" && start.trim()) {
|
||||
const raw = start.trim().replace(/^~(?=$|[\\/])/, os.homedir());
|
||||
const abs = path.resolve(raw);
|
||||
for (const p of [abs, path.dirname(abs)]) {
|
||||
try {
|
||||
if (fs.statSync(p).isDirectory()) return p;
|
||||
} catch {
|
||||
// missing — try the parent, then home
|
||||
}
|
||||
}
|
||||
}
|
||||
return os.homedir();
|
||||
}
|
||||
|
||||
/** The candidate dialog commands for a platform, in order of preference. */
|
||||
function dialogCommands(kind, dir, platform = process.platform) {
|
||||
const title = TITLES[kind];
|
||||
if (platform === "darwin") {
|
||||
const what = kind === "dir" ? "folder" : "file";
|
||||
return [
|
||||
[
|
||||
"osascript",
|
||||
[
|
||||
"-e",
|
||||
"on run argv",
|
||||
"-e",
|
||||
`POSIX path of (choose ${what} with prompt (item 1 of argv) default location (POSIX file (item 2 of argv)))`,
|
||||
"-e",
|
||||
"end run",
|
||||
title,
|
||||
dir,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
if (platform === "win32") {
|
||||
const dialog =
|
||||
kind === "dir"
|
||||
? "$d = New-Object System.Windows.Forms.FolderBrowserDialog; $d.SelectedPath = $env:AGENTHUB_PICK_START; $d.Description = $env:AGENTHUB_PICK_TITLE"
|
||||
: "$d = New-Object System.Windows.Forms.OpenFileDialog; $d.InitialDirectory = $env:AGENTHUB_PICK_START; $d.Title = $env:AGENTHUB_PICK_TITLE";
|
||||
const result = kind === "dir" ? "$d.SelectedPath" : "$d.FileName";
|
||||
return [
|
||||
[
|
||||
"powershell.exe",
|
||||
[
|
||||
"-NoProfile",
|
||||
"-STA",
|
||||
"-Command",
|
||||
`[Console]::OutputEncoding = [Text.Encoding]::UTF8; Add-Type -AssemblyName System.Windows.Forms; ${dialog}; if ($d.ShowDialog() -eq 'OK') { [Console]::Out.Write(${result}) } else { exit 1 }`,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
const sep = dir.endsWith("/") ? "" : "/";
|
||||
return [
|
||||
[
|
||||
"zenity",
|
||||
[
|
||||
"--file-selection",
|
||||
`--title=${title}`,
|
||||
`--filename=${dir}${sep}`,
|
||||
...(kind === "dir" ? ["--directory"] : []),
|
||||
],
|
||||
],
|
||||
[
|
||||
"kdialog",
|
||||
[kind === "dir" ? "--getexistingdirectory" : "--getopenfilename", dir, "--title", title],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// A dialog that could not reach the desktop exits 1 too — a stale DISPLAY
|
||||
// handed over by `systemctl --user show-environment` after a logout, say.
|
||||
const NO_DISPLAY_RE =
|
||||
/cannot open display|could not connect to display|failed to open display|cannot connect to x server|unable to init server/i;
|
||||
|
||||
/**
|
||||
* Whether an exit status of 1 means the user pressed Cancel. zenity/kdialog
|
||||
* may print GTK/Qt warnings on a normal Cancel, so only a display failure
|
||||
* counts against it; osascript says Cancel as error -128 and nothing else.
|
||||
*/
|
||||
function isCancel(cmd, stderr = "") {
|
||||
if (cmd === "osascript") return /-128/.test(stderr);
|
||||
if (cmd === "powershell.exe") return !stderr.trim();
|
||||
return !NO_DISPLAY_RE.test(stderr);
|
||||
}
|
||||
|
||||
function fail(code, message) {
|
||||
return Object.assign(new Error(message), { code });
|
||||
}
|
||||
|
||||
let busy = false;
|
||||
|
||||
/**
|
||||
* Show the native picker and wait for the user.
|
||||
* @param {"dir"|"file"} kind
|
||||
* @param {string} [start] Folder (or file) to open the dialog at.
|
||||
* @param {AbortSignal} [signal] Closes the dialog — the browser went away.
|
||||
* @returns {Promise<string|null>} The chosen absolute path, or null when cancelled.
|
||||
* @throws `ENODIALOG` (no desktop or no dialog program), `EBUSY` (one is already open), `EDIALOG`.
|
||||
*/
|
||||
async function pickPath(kind, start, signal) {
|
||||
if (busy) throw fail("EBUSY", "a picker is already open on the desktop");
|
||||
const env = { ...process.env, ...desktopEnv() };
|
||||
if (process.platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY) {
|
||||
throw fail("ENODIALOG", "no graphical desktop session to show a picker on");
|
||||
}
|
||||
const dir = startDir(start);
|
||||
env.AGENTHUB_PICK_START = dir;
|
||||
env.AGENTHUB_PICK_TITLE = TITLES[kind];
|
||||
busy = true;
|
||||
try {
|
||||
for (const [cmd, args] of dialogCommands(kind, dir)) {
|
||||
const r = await execImpl(cmd, args, env, signal);
|
||||
if (r.missing) continue;
|
||||
if (r.code === 1) {
|
||||
if (isCancel(cmd, r.stderr)) return null; // every one of these exits 1 on Cancel
|
||||
throw fail("ENODIALOG", `${cmd} could not open a picker on the desktop`);
|
||||
}
|
||||
const out = r.stdout.replace(/[\r\n]+$/, "");
|
||||
if (r.code !== 0 || !out) throw fail("EDIALOG", `${cmd} exited with ${r.code}`);
|
||||
// osascript ends a folder with "/"; "/" itself stays.
|
||||
return out.replace(/\/+$/, "") || "/";
|
||||
}
|
||||
throw fail("ENODIALOG", "no native picker found (install zenity or kdialog)");
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { pickPath, dialogCommands, startDir, isCancel, __setExecImpl };
|
||||
@@ -32,7 +32,6 @@ const FEATURED_HOST = "https://codex-pets.net";
|
||||
/** Offered in the pet menu, installed only on a click. `id` is codex-pets.net's. */
|
||||
const FEATURED = [
|
||||
{ id: "goku", displayName: "Goku", by: "hackminit" },
|
||||
{ id: "bac-ho", displayName: "Bac Ho", by: "k0nkupa" },
|
||||
{ id: "border-collie", displayName: "Border Collie", by: "dev-minsoo" },
|
||||
{ id: "11", displayName: "11", by: "ct" },
|
||||
];
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
const fs = require("fs");
|
||||
const tmux = require("./tmux");
|
||||
|
||||
const RUN_ID_RE = /^agenthub-lane-\d+(?:-[1-3])?$/;
|
||||
const RUN_ID_RE = /^agenthub-lane-\d+(?:-[1-9])?$/;
|
||||
|
||||
/**
|
||||
* Reject anything that isn't exactly `agenthub-lane-<digits>[-<1-3>]` before it can ever
|
||||
* Reject anything that isn't exactly `agenthub-lane-<digits>[-<1-9>]` before it can ever
|
||||
* reach a tmux/PTY command — the trust boundary for this WS path, since a
|
||||
* validated runId is the only thing standing between an authenticated WS
|
||||
* client and naming an arbitrary session on the host.
|
||||
|
||||
+18
-6
@@ -22,8 +22,8 @@
|
||||
* Spawns `claude`, `codex` or `agy` (Antigravity) in the tmux pane based on
|
||||
* the tool parameter.
|
||||
*
|
||||
* A lane can hold up to four sessions, one per Workspace tab: slot 0 is
|
||||
* `agenthub-lane-<laneId>` (the lane's own `run_id`), slots 1–3 are
|
||||
* A lane can hold up to ten sessions (`MAX_PANE_SLOT`): slot 0 is
|
||||
* `agenthub-lane-<laneId>` (the lane's own `run_id`), slots 1–9 are
|
||||
* `agenthub-lane-<laneId>-<slot>`. A slot holds an AI tool (`spawnRun`) or a
|
||||
* plain shell (`spawnShell`, a Terminal tab).
|
||||
*
|
||||
@@ -43,9 +43,10 @@ try {
|
||||
/* db-less environment, skip persistence */
|
||||
}
|
||||
|
||||
const RUN_ID_RE = /^agenthub-lane-(\d+)(?:-([1-3]))?$/;
|
||||
/** Highest pane slot a lane may use (the Workspace's 4-pane grid is 0–3). */
|
||||
const MAX_PANE_SLOT = 3;
|
||||
const RUN_ID_RE = /^agenthub-lane-(\d+)(?:-([1-9]))?$/;
|
||||
/** Highest pane slot a lane may use: ten sessions per workspace, 0–9 (the
|
||||
* Workspace groups them into tabs of up to four split panes each). */
|
||||
const MAX_PANE_SLOT = 9;
|
||||
const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
||||
// `auto` is the CLI's own default-ish mode (`claude --permission-mode auto`):
|
||||
// it asks only for what its classifier reads as risky. `default` is kept for
|
||||
@@ -529,7 +530,18 @@ function spawnShell({ laneId, slot = 0, cwd }) {
|
||||
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
|
||||
...desktopEnv(),
|
||||
};
|
||||
tmux.newSession({ name: id, cwd, argv: [], env });
|
||||
// The session is born 80×24 and only takes the browser's size when the pane
|
||||
// attaches. A shell started at once printed its prompt at 80 columns (a long
|
||||
// cwd wrapped), and readline's redraw after the resize left the cursor on top
|
||||
// of the prompt (issue #55). So wait until a client is attached — 2 s at
|
||||
// most — then exec the login shell tmux would have run (tmux sets SHELL in the
|
||||
// pane). Not "the first SIGWINCH": the scrollbar `enableMouse` turns on
|
||||
// resizes the pane before any client is there. A tmux that cannot answer
|
||||
// ends the wait at once, i.e. the old behaviour.
|
||||
const waitForSize =
|
||||
"i=0; while [ \"$(tmux display -p -t \"$TMUX_PANE\" '#{session_attached}' 2>/dev/null)\" = 0 ]" +
|
||||
' && [ $i -lt 20 ]; do sleep 0.1; i=$((i+1)); done; exec "$SHELL" -l';
|
||||
tmux.newSession({ name: id, cwd, argv: ["sh", "-c", waitForSize], env });
|
||||
dashboardRuns?.recordRun({
|
||||
id,
|
||||
sessionId: null,
|
||||
|
||||
@@ -33,7 +33,14 @@ const STATUS_FILE_CAP = 20;
|
||||
async function git(
|
||||
cwd,
|
||||
args,
|
||||
{ timeout = 0, maxBuffer = 8 * 1024 * 1024, literal = false, env: extraEnv = {} } = {}
|
||||
{
|
||||
timeout = 0,
|
||||
maxBuffer = 8 * 1024 * 1024,
|
||||
literal = false,
|
||||
env: extraEnv = {},
|
||||
// "buffer" for a binary read (an image at a revision).
|
||||
encoding = "utf8",
|
||||
} = {}
|
||||
) {
|
||||
// Build a clean environment: copy process.env but scrub git hook variables
|
||||
// that could point to the outer repo's git directory or index.
|
||||
@@ -79,6 +86,7 @@ async function git(
|
||||
// passes one so an unreachable remote fails instead of pinning a request
|
||||
// open forever; GIT_TERMINAL_PROMPT=0 only covers the prompting case.
|
||||
timeout,
|
||||
encoding,
|
||||
});
|
||||
return { stdout: result.stdout, stderr: result.stderr };
|
||||
} catch (err) {
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
const tags = [
|
||||
{
|
||||
name: "Lanes",
|
||||
description: "Durable parallel-work lanes and dashboard-managed git worktrees",
|
||||
description:
|
||||
"Durable parallel-work workspaces (called lanes in the API) and dashboard-managed git worktrees. Every /api/lanes route is also mounted at /api/workspaces — the same handlers, the user-facing name.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -111,6 +112,45 @@ const schemas = {
|
||||
};
|
||||
|
||||
const paths = {
|
||||
"/api/lanes/pick-path": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Open the OS's own folder/file picker on the server's desktop",
|
||||
description:
|
||||
"Shows zenity/kdialog (Linux), osascript (macOS) or a WinForms dialog (Windows) on the SERVER's desktop and waits for the user (up to 10 minutes; closed when the request goes away). Only for a browser on the same machine: a non-loopback client, or a request carrying X-Forwarded-For/Forwarded, gets EREMOTE and should fall back to GET /api/lanes/browse. One dialog at a time. Nothing from the request reaches argv but the starting folder, resolved to an existing absolute directory first.",
|
||||
operationId: "pickLanePath",
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
kind: { type: "string", enum: ["dir", "file"], default: "dir" },
|
||||
start: { type: "string", description: "Folder (or file) to open the dialog at." },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "`{path}` — the chosen absolute path — or `{cancelled: true}`.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { path: { type: "string" }, cancelled: { type: "boolean" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
403: { description: "EBADORIGIN (not same-origin) or EREMOTE (not a local browser)." },
|
||||
409: { description: "EBUSY — a picker is already open." },
|
||||
500: { description: "EDIALOG — the dialog program failed." },
|
||||
501: { description: "ENODIALOG — no desktop session or no dialog program." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/ensure": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
@@ -551,6 +591,26 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/raw": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "One image file's bytes at a revision",
|
||||
description:
|
||||
"For the viewer's <img>. Same path, rev and secret guards as /git/file, plus an extension allowlist (png, jpg, jpeg, gif, webp, avif, bmp, ico, svg) that sets the Content-Type, and a 20 MB cap. Served with `Content-Security-Policy: …; sandbox` and `X-Content-Type-Options: nosniff`, so an SVG opened from this URL runs no script. Read-only, no same-origin guard.",
|
||||
operationId: "readLaneImage",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
{ name: "path", in: "query", required: true, schema: { type: "string" } },
|
||||
{ name: "rev", in: "query", schema: { type: "string", default: "work" } },
|
||||
],
|
||||
responses: {
|
||||
200: { description: "The image bytes, typed by extension." },
|
||||
400: { description: "EBADPATH, EBADREV, ENOFILE, ENOTIMAGE or ETOOBIG." },
|
||||
403: { description: "ESECRET." },
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/ls": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
|
||||
@@ -335,6 +335,27 @@ module.exports = {
|
||||
"for all sources. Narrows the aggregate cost to the given origins.",
|
||||
example: "local",
|
||||
},
|
||||
{
|
||||
name: "lane",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "integer" },
|
||||
description:
|
||||
"Only the sessions this workspace owns — the same rule as " +
|
||||
"GET /api/sessions?lane=. 400 INVALID_INPUT when not an id.",
|
||||
example: 5,
|
||||
},
|
||||
{
|
||||
name: "date",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string", format: "date" },
|
||||
description:
|
||||
"Only that local day (YYYY-MM-DD, in `tz_offset`'s timezone): the " +
|
||||
"total and breakdown count the tokens spent that day. 400 " +
|
||||
"INVALID_INPUT for any other shape.",
|
||||
example: "2026-10-06",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
|
||||
@@ -812,6 +812,15 @@ const paths = {
|
||||
"Return only the runs started through this lane (POST /api/lanes/{id}/start). Omit for every run; runs spawned from POST /api/run carry no lane and are excluded whenever this is set.",
|
||||
example: 5,
|
||||
},
|
||||
{
|
||||
name: "tool",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string" },
|
||||
description:
|
||||
"Return only the runs of this AI Tools id — `shell` for a plain terminal tab (the sidebar's recently closed Terminal tabs).",
|
||||
example: "shell",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
|
||||
@@ -1318,7 +1318,7 @@ const HOOK_TOOLS = { codex: codexHookInput, antigravity: antigravityHookInput };
|
||||
*/
|
||||
function bindRunSession(runId, sessionId, hookType, source, tool) {
|
||||
if (typeof sessionId !== "string" || !sessionId) return;
|
||||
if (typeof runId !== "string" || !/^agenthub-lane-\d+(-[1-3])?$/.test(runId)) {
|
||||
if (typeof runId !== "string" || !/^agenthub-lane-\d+(-[1-9])?$/.test(runId)) {
|
||||
// No pane named (a hook handler that predates AGENTHUB_RUN_ID): a run
|
||||
// that was started holding this very session id still owns it.
|
||||
const laneId = dashboardRuns.laneForSession(sessionId);
|
||||
|
||||
@@ -30,6 +30,7 @@ const runs = require("../lib/pty-run");
|
||||
const { resumeRouter } = require("../lib/router-apply");
|
||||
const { sameOriginGuard } = require("./run");
|
||||
const { writeLaneFile } = require("../lib/lane-file-write");
|
||||
const { pickPath } = require("../lib/native-dialog");
|
||||
const { preflight } = require("../lib/lane-preflight");
|
||||
const {
|
||||
LANES_ROOT,
|
||||
@@ -51,6 +52,7 @@ const {
|
||||
listTree,
|
||||
listTreeAll,
|
||||
readFileAt,
|
||||
readImageAt,
|
||||
} = require("../lib/git-diff");
|
||||
const { runGitAction } = require("../lib/git-ops");
|
||||
const { withLaneLock } = require("../lib/lane-lock");
|
||||
@@ -501,6 +503,40 @@ router.get("/browse", (req, res) => {
|
||||
res.json({ path: resolved, parent, entries });
|
||||
});
|
||||
|
||||
/**
|
||||
* The OS's own folder/file picker, shown on the SERVER's desktop
|
||||
* (`server/lib/native-dialog.js`), answering `{path}` or `{cancelled: true}`.
|
||||
* Only a client on this machine may ask — anywhere else the dialog would pop
|
||||
* up on a screen nobody is looking at (`EREMOTE`); that client, and a server
|
||||
* with no desktop (`ENODIALOG`), falls back to `GET /browse` above. A request
|
||||
* a reverse proxy forwarded arrives from loopback too, so a forwarding header
|
||||
* counts as remote. An SSH tunnel carries no such header and cannot be told
|
||||
* apart — the dialog is closed when the browser's request goes away.
|
||||
*/
|
||||
router.post("/pick-path", sameOriginGuard, async (req, res) => {
|
||||
const addr = req.socket.remoteAddress || "";
|
||||
const proxied = req.headers["x-forwarded-for"] || req.headers.forwarded;
|
||||
if (proxied || !["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(addr)) {
|
||||
return res.status(403).json({
|
||||
error: { code: "EREMOTE", message: "the native picker opens only for a local browser" },
|
||||
});
|
||||
}
|
||||
const kind = req.body?.kind === "file" ? "file" : "dir";
|
||||
const ac = new AbortController();
|
||||
res.on("close", () => {
|
||||
if (!res.writableFinished) ac.abort();
|
||||
});
|
||||
try {
|
||||
const picked = await pickPath(kind, req.body?.start, ac.signal);
|
||||
if (ac.signal.aborted) return;
|
||||
res.json(picked ? { path: picked } : { cancelled: true });
|
||||
} catch (err) {
|
||||
if (ac.signal.aborted) return;
|
||||
const status = err.code === "EBUSY" ? 409 : err.code === "ENODIALOG" ? 501 : 500;
|
||||
res.status(status).json({ error: { code: err.code || "EDIALOG", message: err.message } });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/:id", (req, res) => {
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
if (!lane)
|
||||
@@ -901,6 +937,40 @@ router.get("/:id/git/file", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* One image's bytes (`?path=&rev=work|HEAD|<sha>`) for the viewer's `<img>`:
|
||||
* `readImageAt`'s guards (the file read's, plus an image-extension allowlist
|
||||
* that picks the Content-Type). Served under a `sandbox` CSP with `nosniff`,
|
||||
* so an SVG opened straight from this URL runs no script on the dashboard's
|
||||
* origin.
|
||||
*/
|
||||
router.get("/:id/git/raw", async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
try {
|
||||
const { type, buf } = await readImageAt(
|
||||
t.cwd,
|
||||
typeof req.query.path === "string" ? req.query.path : "",
|
||||
typeof req.query.rev === "string" && req.query.rev ? req.query.rev : "work"
|
||||
);
|
||||
res.set({
|
||||
"Content-Type": type,
|
||||
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
res.send(buf);
|
||||
} catch (err) {
|
||||
if (err.code === "ESECRET") {
|
||||
return res.status(403).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
if (["EBADPATH", "EBADREV", "ENOFILE", "ENOTIMAGE", "ETOOBIG"].includes(err.code)) {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
res.status(500).json({ error: { code: "EREAD", message: "could not read the image" } });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Save one text file in the lane from the viewer's editor. Guards live in
|
||||
* lane-file-write.js (the read path's own, plus existing-textual-file-only and
|
||||
|
||||
@@ -477,6 +477,12 @@ router.get("/cost", (req, res) => {
|
||||
.status(400)
|
||||
.json({ error: { code: "INVALID_INPUT", message: "lane must be a lane id" } });
|
||||
}
|
||||
const date = req.query.date;
|
||||
if (date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(String(date))) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "INVALID_INPUT", message: "date must be YYYY-MM-DD" } });
|
||||
}
|
||||
const scope = sourceColumnClause(parseSources(req), "s.source");
|
||||
const params = [...scope.params];
|
||||
const where = scope.clause ? [scope.clause] : [];
|
||||
@@ -489,7 +495,9 @@ router.get("/cost", (req, res) => {
|
||||
}
|
||||
const whereClause = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
||||
|
||||
const dailyTokens = dailyTokenRows(tzModifier, whereClause, params);
|
||||
let dailyTokens = dailyTokenRows(tzModifier, whereClause, params);
|
||||
// `date=`: only that local day (the status bar's Today and its per-model list).
|
||||
if (date !== undefined) dailyTokens = dailyTokens.filter((r) => r.date === date);
|
||||
const rules = stmts.listPricing.all();
|
||||
// Price the date-split rows so each day's usage bills at the rate effective on
|
||||
// that date (e.g. Sonnet 5's intro discount before 2026-08-31, standard after).
|
||||
|
||||
@@ -84,7 +84,8 @@ router.get("/", (_req, res) => {
|
||||
*
|
||||
* Optional ?limit=<n> caps results (default 50, max 500). The most recent
|
||||
* runs are first. Optional ?laneId=<n> narrows to the runs started through one
|
||||
* lane, which is what the Workspace page's per-lane history shows.
|
||||
* lane, and ?tool=<id> (with a laneId) to one tool's — the sidebar reads a
|
||||
* workspace's closed Terminal tabs with `tool=shell`.
|
||||
*/
|
||||
// ── Drag & drop into the terminal ─────────────────────────────────────────
|
||||
// A local terminal answers a dropped image by typing its PATH, which is how
|
||||
@@ -149,6 +150,7 @@ router.get("/history", (req, res) => {
|
||||
const items = dr.listRuns({
|
||||
limit: Number.isFinite(limit) ? limit : 50,
|
||||
laneId: Number.isFinite(laneId) ? laneId : null,
|
||||
tool: typeof req.query.tool === "string" ? req.query.tool : null,
|
||||
});
|
||||
const liveIds = new Set(runs.listRuns().map((h) => h.id));
|
||||
res.json({
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ function initWebSocket(server) {
|
||||
}
|
||||
|
||||
let ptyWss = null;
|
||||
const PTY_PATH_RE = /^\/ws-pty\/(agenthub-lane-\d+(?:-[1-3])?)$/;
|
||||
const PTY_PATH_RE = /^\/ws-pty\/(agenthub-lane-\d+(?:-[1-9])?)$/;
|
||||
|
||||
/**
|
||||
* The attaching terminal's own size, off the attach URL's query. tmux resizes
|
||||
|
||||
Reference in New Issue
Block a user