fix: per-user Today, native paste and drop on a local machine

- Today's spend in the status bar and the Claude Code statusline is the
  caller's own (`GET /api/pricing/cost?mine=1`, scope.ownSessionWhere), not
  every user's sum for an admin or analyst.
- A screenshot pasted into a local claude pane is sent as Claude Code's own
  image-paste key: Alt+V on Windows (Ctrl+V did nothing there), ^V elsewhere,
  and only when the machine can read its clipboard (wl-paste/xclip + a
  display on Linux), else it is uploaded (GET /api/run/native-paste {key}).
- A file manager's files pasted or dropped on a local browser go in by their
  own paths, read from the OS clipboard or the file manager's selection
  (server/lib/file-manager.js, POST /api/run/clipboard-files and
  /selected-files): no upload, no size limit. A copied image file is pasted
  by path, text that comes with a picture of it (spreadsheet cells) is
  pasted as text, Windows paths go in as `C:/…` mentions.
- Tab titles for Snapshot, Artifacts, Files and Administration.
- An npm tool AgentHub installed under ~/.local is updated there, not by its
  own updater into npm's global prefix.
This commit is contained in:
2026-10-26 11:18:05 +07:00
parent c59e196e41
commit f2f1cd9b6c
33 changed files with 1046 additions and 60 deletions
+31
View File
@@ -109,6 +109,13 @@ before(async () => {
})
);
write("selfy.json", tool("selfy", { update: { kind: "self", args: ["update", "--yes"] } }));
write(
"selfnpm.json",
tool("selfnpm", {
install: { kind: "npm", package: "selfnpm-pkg" },
update: { kind: "self", args: ["update"] },
})
);
write("plain.json", tool("plain"));
exe(path.join(shellBin, "npm"));
exe(path.join(serverBin, "npm"));
@@ -241,6 +248,30 @@ describe("a system-wide npm prefix", () => {
aiTools.refresh();
}
});
it("updates an npm tool with its own updater under ~/.local through npm --prefix", async () => {
const pkg = path.join(user, "lib", "node_modules", "selfnpm-pkg", "cli.js");
exe(pkg);
fs.symlinkSync(pkg, path.join(serverBin, "selfnpm"));
aiTools.refresh();
try {
assert.deepEqual(await jobs.stepsFor("selfnpm", "update"), [
{
argv: [
path.join(shellBin, "npm"),
"install",
"-g",
"--prefix",
user,
"selfnpm-pkg@latest",
],
},
]);
} finally {
fs.rmSync(path.join(serverBin, "selfnpm"));
aiTools.refresh();
}
});
});
describe("versions", () => {
+28 -1
View File
@@ -8,7 +8,7 @@
*/
const { describe, it, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const { desktopEnv, FORWARDED, __setExecImpl } = require("../lib/desktop-env");
const { desktopEnv, canReadClipboard, FORWARDED, __setExecImpl } = require("../lib/desktop-env");
const saved = {};
@@ -61,3 +61,30 @@ describe("desktopEnv", () => {
assert.equal(desktopEnv().WAYLAND_DISPLAY, undefined);
});
});
describe("canReadClipboard", () => {
const has =
(...names) =>
(n) =>
names.includes(n) ? `/usr/bin/${n}` : null;
it("needs wl-paste for a Wayland display, xclip for an X one", () => {
const wl = { WAYLAND_DISPLAY: "wayland-0" };
const x = { DISPLAY: ":0" };
assert.equal(canReadClipboard({ platform: "linux", env: wl, which: has("wl-paste") }), true);
assert.equal(canReadClipboard({ platform: "linux", env: wl, which: has("xclip") }), false);
assert.equal(canReadClipboard({ platform: "linux", env: x, which: has("xclip") }), true);
assert.equal(canReadClipboard({ platform: "linux", env: x, which: has() }), false);
});
it("is false with no display at all — a headless box", () => {
assert.equal(
canReadClipboard({ platform: "linux", env: {}, which: has("xclip", "wl-paste") }),
false
);
});
it("is true off Linux, where the clipboard tools are built in", () => {
assert.equal(canReadClipboard({ platform: "darwin", env: {}, which: has() }), true);
});
});
+124
View File
@@ -0,0 +1,124 @@
/**
* @file file-manager.test.js
* @description server/lib/file-manager.js: the files a file manager's Copy
* put on the OS clipboard (Windows GetFileDropList, macOS NSPasteboard, Linux
* text/uri-list / gnome-copied-files) and the ones selected in it — what a
* drag carries (Windows Shell.Application, macOS Finder) — kept only when
* they exist here, with their sizes; a missing reader is no files.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after, afterEach } = 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 {
clipboardFiles,
selectedFiles,
uriListPaths,
__setExecImpl,
} = require("../lib/file-manager");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "fm-files-"));
const big = path.join(TMP, "big file.mp4");
const dir = path.join(TMP, "folder");
before(() => {
fs.writeFileSync(big, Buffer.alloc(1234));
fs.mkdirSync(dir);
});
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
afterEach(() => __setExecImpl(async () => ""));
describe("uriListPaths", () => {
it("decodes file URIs and skips a gnome list's verb, comments and other hosts", () => {
assert.deepEqual(
uriListPaths("copy\nfile:///home/u/a%20b.png\r\n# c\nfile://other/x\nhttps://x/y\n"),
["/home/u/a b.png"]
);
});
});
describe("clipboardFiles", () => {
it("reads Windows' file drop list with powershell -Sta", async () => {
let seen;
__setExecImpl(async (cmd, args) => {
seen = [cmd, args];
return `${big}\r\n${dir}\r\n${path.join(TMP, "gone.txt")}\r\n`;
});
assert.deepEqual(await clipboardFiles("win32"), [
{ path: big, size: 1234, dir: false },
{ path: dir, size: null, dir: true },
]);
assert.equal(seen[0], "powershell");
assert.ok(seen[1].includes("-Sta"));
assert.match(seen[1].at(-1), /GetFileDropList/);
});
it("reads macOS file URLs through JXA", async () => {
let seen;
__setExecImpl(async (cmd, args) => {
seen = [cmd, args];
return `${big}\n`;
});
assert.deepEqual(await clipboardFiles("darwin"), [{ path: big, size: 1234, dir: false }]);
assert.equal(seen[0], "osascript");
assert.deepEqual(seen[1].slice(0, 2), ["-l", "JavaScript"]);
});
it("reads Linux text/uri-list with the desktop's display", async () => {
const saved = process.env.DISPLAY;
process.env.DISPLAY = ":0";
try {
const calls = [];
__setExecImpl(async (cmd, args, env) => {
calls.push([cmd, args.join(" "), env.DISPLAY]);
return cmd === "xclip" ? `file://${encodeURI(big)}\n` : "";
});
assert.deepEqual(await clipboardFiles("linux"), [{ path: big, size: 1234, dir: false }]);
assert.deepEqual(calls.at(-1), ["xclip", "-selection clipboard -t text/uri-list -o", ":0"]);
} finally {
if (saved === undefined) delete process.env.DISPLAY;
else process.env.DISPLAY = saved;
}
});
it("answers no files when the reader is missing or prints relative paths", async () => {
__setExecImpl(async () => "");
assert.deepEqual(await clipboardFiles("win32"), []);
__setExecImpl(async () => "relative/x.txt\n");
assert.deepEqual(await clipboardFiles("darwin"), []);
});
it("reads Explorer's selection (and the desktop's) for a drop, once per path", async () => {
let seen;
__setExecImpl(async (cmd, args) => {
seen = [cmd, args];
return `${big}\r\n${big}\r\n`;
});
assert.deepEqual(await selectedFiles("win32"), [{ path: big, size: 1234, dir: false }]);
assert.equal(seen[0], "powershell");
assert.match(seen[1].at(-1), /Shell\.Application[\s\S]*SelectedItems[\s\S]*FindWindowSW/);
});
it("reads Finder's selection on macOS, a folder with its trailing slash", async () => {
let seen;
__setExecImpl(async (cmd, args) => {
seen = [cmd, args];
return `${dir}/\n`;
});
assert.deepEqual(await selectedFiles("darwin"), [{ path: `${dir}/`, size: null, dir: true }]);
assert.equal(seen[0], "osascript");
assert.ok(seen[1].includes("repeat with a in (selection as alias list)"));
});
it("has no selection to read on Linux", async () => {
let called = false;
__setExecImpl(async () => {
called = true;
return big;
});
assert.deepEqual(await selectedFiles("linux"), []);
assert.equal(called, false);
});
});
+15
View File
@@ -82,6 +82,21 @@ describe("scope", () => {
}
});
it("narrows ownSessionWhere to the caller's own sessions whatever the role", () => {
const own = (req) => {
const w = scope.ownSessionWhere(req);
return db
.prepare(`SELECT id FROM sessions WHERE ${w.where} ORDER BY id`)
.all(...w.params)
.map((r) => r.id);
};
assert.deepEqual(own(member(1)), ["s-unstamped", "s1"]);
assert.deepEqual(own(admin), []);
assert.deepEqual(own({ user: { ...admin.user, id: 2 } }), ["s2"]);
assert.deepEqual(own(analyst), []);
assert.equal(own(local).length, 5);
});
it("answers per-id checks for lanes, sessions, agents and runs", () => {
const m = member(1);
assert.equal(scope.canSeeLane(m, 1), true);
+1 -1
View File
@@ -101,7 +101,7 @@ describe("todayCost", () => {
it("reads today's row and caches it", async () => {
const file = path.join(TMP, "cache-a.json");
assert.equal(await todayCost({ url, file }), 3.5);
assert.equal(lastUrl, `/api/pricing/cost?tz_offset=${new Date().getTimezoneOffset()}`);
assert.equal(lastUrl, `/api/pricing/cost?tz_offset=${new Date().getTimezoneOffset()}&mine=1`);
assert.equal(await todayCost({ url, file }), 3.5);
assert.equal(hits, 1);
// A different dashboard URL is a cache miss.