feat(lanes,run,pricing): Changes sidebar editor, staged groups, image drop, auto pricing
- Changes panel at full screen: the whole Files view (finder, expand-all, tree) becomes a left sidebar and the Files tab is dropped; a working-copy file opens straight into the editor (Save/Cancel live once the text differs, Cancel restores it, Whole file toggles back to the diff), and the page is height-locked so the editor no longer collapses with no live run. - Overview splits into Staged Changes / Changes groups in the work range. - A changed file's whole tree row takes its status tint. - Drag an image onto the lane terminal: POST /api/run/drop-image saves it (png/jpeg/gif/webp, random name, 0600, pruned after a day) and the path is pasted into the pane, which is how claude attaches it. - Claude Opus 5.5 gets its own pricing row (4/20/0.2/5/8, fast 8/40); claude-opus-5% had been pricing it as Opus 5. - server/lib/pricing-sync.js: a model family used in token_usage with no row of its own is inserted from LiteLLM's public price list, never updated; off with AGENTHUB_PRICING_SYNC=0. repo-links allows exactly that one third-party GitHub raw URL.
This commit is contained in:
@@ -26,7 +26,11 @@ function fetchJson(p, opts = {}) {
|
||||
const url = new URL(p, BASE);
|
||||
const headers = { ...(opts.headers || {}) };
|
||||
let body;
|
||||
if (opts.body !== undefined) {
|
||||
if (opts.raw) {
|
||||
body = opts.raw.buf;
|
||||
headers["Content-Type"] = opts.raw.type;
|
||||
headers["Content-Length"] = body.length;
|
||||
} else if (opts.body !== undefined) {
|
||||
body = Buffer.from(JSON.stringify(opts.body));
|
||||
headers["Content-Type"] = "application/json";
|
||||
headers["Content-Length"] = body.length;
|
||||
@@ -203,3 +207,45 @@ describe("/api/run", () => {
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/run/drop-image", () => {
|
||||
let srv;
|
||||
before(async () => {
|
||||
srv = http.createServer(createApp());
|
||||
await new Promise((r) => srv.listen(0, r));
|
||||
BASE = `http://127.0.0.1:${srv.address().port}`;
|
||||
});
|
||||
after(() => new Promise((r) => srv.close(r)));
|
||||
|
||||
const PNG = Buffer.from("89504e470d0a1a0a0000", "hex");
|
||||
|
||||
it("saves the image under a random name, owner-only, and returns its path", async () => {
|
||||
const r = await fetchJson("/api/run/drop-image", {
|
||||
method: "POST",
|
||||
raw: { type: "image/png", buf: PNG },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.match(path.basename(r.body.path), /^[0-9a-f]{16}\.png$/);
|
||||
assert.ok(r.body.path.startsWith(os.tmpdir()));
|
||||
assert.deepEqual(fs.readFileSync(r.body.path), PNG);
|
||||
assert.equal(fs.statSync(r.body.path).mode & 0o777, 0o600);
|
||||
fs.unlinkSync(r.body.path);
|
||||
});
|
||||
|
||||
it("refuses anything that is not one of the image types", async () => {
|
||||
const r = await fetchJson("/api/run/drop-image", {
|
||||
method: "POST",
|
||||
raw: { type: "text/x-shellscript", buf: Buffer.from("rm -rf ~") },
|
||||
});
|
||||
assert.equal(r.status, 400);
|
||||
});
|
||||
|
||||
it("refuses a cross-origin browser request", async () => {
|
||||
const r = await fetchJson("/api/run/drop-image", {
|
||||
method: "POST",
|
||||
headers: { Origin: "https://evil.example" },
|
||||
raw: { type: "image/png", buf: PNG },
|
||||
});
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user