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:
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user