fix: v2.7.4 — Changes matches JetBrains (#72), the chat says when Claude waits on a trust prompt (#73), Vietnamese Telex in Safari
- Changes (#72): a file git reports `MM` whose working copy equals HEAD again (a staged change since undone on disk) is no longer listed — JetBrains' GitChangesCollector drops it the same way. Mode-only changes stay listed. statusCounts (the dirty-tree safety checks) is unchanged. - Chat (#73): Claude Code runs no hook before a folder is trusted, so a new pane's session has no row; the chat now says the session has not started, offers Open terminal, and asks before typing a message or /model //effort into the pane (an Enter would pick the trust prompt's "No, exit"). - Terminal: Safari + macOS's own Vietnamese Telex replaces the whole word on each key (insertReplacementText), which xterm.js ignores — only each word's first letter reached the pane. The edit is now sent as DELs + the new tail. - Also: Ctrl+V pastes a screenshot into the terminal as a file path; the folder browser fallback lists files for a file field (`files=1`); the macOS native picker opens in front of the browser; a hidden tab is marked while a chat waits or once a turn ended; chat live line shows running calls, turn time and tokens, and "Done in …".
This commit is contained in:
+1
-1
@@ -764,7 +764,7 @@ HTTP surface for the tmux-backed terminal run: one session per workspace, named
|
||||
| `GET` | `/api/run/cwds` | Suggested cwds (dashboard, home, recent from sessions) |
|
||||
| `POST` | `/api/run/drop-image` | An image dropped on the Workspace terminal. Raw body, `Content-Type` one of `image/png\|jpeg\|gif\|webp`, ≤20 MB. Saved `0600` under `<data dir>/drops/` (`0700`, never a guessable name in the shared tmp; S3: the member's own `~/.agenthub/handoff/drops/`) with a RANDOM name (nothing from the request names the file); files older than a day are pruned on each write. Answers `{ path }`, which `TerminalView` pastes into the pane — what a local terminal does on a drop, and how `claude` attaches the image. 400 for any other type |
|
||||
| `POST` | `/api/run/drop-file?name=…` | Any file (or image) dropped on the Workspace terminal or pasted/dropped into a prompt box. Raw body of any type, ≤20 MB, same directory (S3: `?laneId=` saves it AS the account that workspace's panes run as, so the pane can read it), `0600` and day-old pruning as `drop-image`. The name is the file's own, reduced to a `[A-Za-z0-9._-]` basename behind a random 16-hex prefix (so it can neither leave the directory nor collide, and space-joined paths stay parseable). Answers `{ path }`; 400 for an empty body or a name that sanitizes to nothing. The client uses this for every drop; `drop-image` is kept for API callers |
|
||||
| `GET` | `/api/run/drop/:name` | A dropped IMAGE served back, so a chat turn shows the picture instead of its path. Only a name `drop-image`/`drop-file` made (16-hex prefix, no separator) with a png/jpg/gif/webp extension, only a regular file in the REQUESTER's drop dir (S3: their member account's, owned by that account — an admin viewing a member's session gets the name chip, not the image); under `nosniff` and a `sandbox` CSP like `/git/raw`. 404 otherwise, and once the day-old prune removed it |
|
||||
| `GET` | `/api/run/drop/:name` | A dropped IMAGE served back, so a chat turn shows the picture instead of its path. Only a name `drop-image`/`drop-file` made (16-hex prefix, no separator) with a png/jpg/gif/webp extension, only a regular file in the REQUESTER's drop dir (S3: their member account's, owned by that account — an admin viewing a member's session gets the name chip, not the image; an admin's GET without `?laneId=` also tries the HOST's drop dir, where their host-folder workspaces drop); under `nosniff` and a `sandbox` CSP like `/git/raw`. 404 otherwise, and once the day-old prune removed it |
|
||||
| `GET` | `/api/run/:id/permission-mode` | A live **claude** pane's permission mode, read off the mode line Claude Code prints under its prompt (`server/lib/pane-mode.js`): `{ mode }` — `default`, `acceptEdits`, `plan`, `auto`, `bypassPermissions`, `dontAsk`, or `null` while a menu hides it. 404 for a pane that is not running, 400 `EUNSUPPORTED` for a non-claude pane |
|
||||
| `POST` | `/api/run/:id/permission-mode` | `{ mode }` → steps the pane there with Shift+Tab, the only way a running session changes mode, reading every step back before the next key. `{ mode }` on success; 400 `EBADMODE` outside the five settable modes; 409 `EMODEUNKNOWN` (no mode line — nothing is pressed — or a step did not change it), `EMODEUNAVAILABLE` (not in this session's cycle, e.g. Bypass in a session not started with it; the pane is back on its starting mode), `EBUSY` (a change is already running on that pane) |
|
||||
| `GET` | `/api/run/files?cwd=…&q=…` | Fuzzy file search inside `cwd` for the run panel's `@`-file autocomplete. Skips `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc. Cwd is required and must exist; results are capped and ranked by basename match |
|
||||
|
||||
@@ -143,6 +143,34 @@ describe("collectChanges — work range", () => {
|
||||
assert.equal((await gitFacts(LANE)).untracked, 2);
|
||||
});
|
||||
|
||||
// #72: a change staged and then undone in the working tree is `MM` in
|
||||
// `git status` while the file on disk equals HEAD — JetBrains' Changes view
|
||||
// (GitChangesCollector) drops it after a `git diff HEAD`, and so does this.
|
||||
it("leaves out a staged change the working tree has reverted (#72)", async () => {
|
||||
write(LANE, "a.txt", "one\nTWO\nthree\n");
|
||||
g(LANE, "add", "a.txt");
|
||||
write(LANE, "a.txt", "one\ntwo\nthree\n"); // back to HEAD's content
|
||||
write(LANE, "keep.txt", "staged\n");
|
||||
g(LANE, "add", "keep.txt");
|
||||
write(LANE, "keep.txt", "staged, then edited\n"); // MM and still a change
|
||||
assert.deepEqual(porcelain(LANE).sort(), ["MM a.txt", "MM keep.txt"]);
|
||||
|
||||
const out = await collectChanges(LANE, { range: "work" });
|
||||
assert.deepEqual(
|
||||
out.files.map((f) => f.path),
|
||||
["keep.txt"]
|
||||
);
|
||||
});
|
||||
|
||||
it("still lists a file whose only change is its mode, as JetBrains does", async () => {
|
||||
fs.chmodSync(path.join(LANE, "a.txt"), 0o755);
|
||||
const out = await collectChanges(LANE, { range: "work" });
|
||||
assert.deepEqual(
|
||||
out.files.map((f) => [f.path, f.added, f.deleted]),
|
||||
[["a.txt", 0, 0]]
|
||||
);
|
||||
});
|
||||
|
||||
it("returns hunks for every file in full mode", async () => {
|
||||
write(LANE, "a.txt", "one\nTWO\nthree\n");
|
||||
const out = await collectChanges(LANE, { range: "work", full: true });
|
||||
|
||||
@@ -579,6 +579,25 @@ describe("GET /api/lanes/browse", () => {
|
||||
assert.equal(notADir.status, 400);
|
||||
assert.equal(notADir.body.error.code, "ENOTADIR");
|
||||
});
|
||||
|
||||
it("with files=1 lists files too, opening a file path at its folder", async () => {
|
||||
const filePath = path.join(ROOT, "b-file.txt");
|
||||
fs.writeFileSync(filePath, "hi\n");
|
||||
const response = await request(
|
||||
"GET",
|
||||
`/api/lanes/browse?files=1&path=${encodeURIComponent(filePath)}`
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.body.path, ROOT);
|
||||
const file = response.body.entries.find((e) => e.name === "b-file.txt");
|
||||
assert.equal(file?.file, true);
|
||||
assert.equal(file.path, filePath);
|
||||
const dir = response.body.entries.find((e) => e.name === "src-repo");
|
||||
assert.ok(dir && !dir.file);
|
||||
|
||||
const dirsOnly = await request("GET", `/api/lanes/browse?path=${encodeURIComponent(ROOT)}`);
|
||||
assert.ok(!dirsOnly.body.entries.some((e) => e.name === "b-file.txt"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("destructive lane lifecycle actions", () => {
|
||||
|
||||
@@ -44,6 +44,13 @@ describe("native-dialog", () => {
|
||||
assert.ok(!args.slice(0, -2).join(" ").includes("shell script"));
|
||||
});
|
||||
|
||||
it("brings osascript forward before the macOS dialog, so it does not open behind the browser", () => {
|
||||
const [[, args]] = nd.dialogCommands("dir", "/x", "darwin");
|
||||
const script = args.filter((_, i) => args[i - 1] === "-e");
|
||||
assert.ok(script.indexOf("activate") > -1);
|
||||
assert.ok(script.indexOf("activate") < script.findIndex((s) => s.includes("choose folder")));
|
||||
});
|
||||
|
||||
it("gives the Windows dialog a foreground TopMost owner, so it does not open behind the browser", () => {
|
||||
for (const kind of ["dir", "file"]) {
|
||||
const [[cmd, args]] = nd.dialogCommands(kind, "C:\\x", "win32");
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
* non-admin owns no host-folder workspace and a pane's cwd is its
|
||||
* workspace's account's; a pane token works only from its pane's uid; an
|
||||
* analyst's GET path stays in their own folder; a drop goes to the pane's
|
||||
* account. A fake passwd (every member = this test's uid) and server.conf
|
||||
* account; an image a session read is served only from a member session's
|
||||
* own home (a host session's from anywhere). A fake passwd (every member = this test's uid) and server.conf
|
||||
* make the members provisioned; fake sudo runs their commands as this
|
||||
* account — and a fake `sudo` on PATH that never returns stands in for a
|
||||
* stalled member, in a child process.
|
||||
@@ -674,7 +675,54 @@ describe("a dropped file", () => {
|
||||
const host = await drop("boss", hostLane);
|
||||
assert.equal(host.status, 200, JSON.stringify(host.body));
|
||||
assert.equal(path.dirname(host.body.path), path.join(DATA, "drops"));
|
||||
// …and the admin's chat shows it back, though the GET names no workspace.
|
||||
fs.mkdirSync(path.join(USERS, "boss", ".agenthub", "handoff"), { recursive: true });
|
||||
const shown = await fetch(`${BASE}/api/run/drop/${path.basename(host.body.path)}`, {
|
||||
headers: { cookie: cookies.boss },
|
||||
});
|
||||
assert.equal(shown.status, 200);
|
||||
assert.equal(await shown.text(), "hello");
|
||||
// Another member's workspace is not one to name.
|
||||
assert.equal((await drop("bob", miaLane)).status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("an image a session read (the Chat pane's Files read preview)", () => {
|
||||
const readBy = (sid, cwd, file) => {
|
||||
db.prepare("INSERT OR IGNORE INTO sessions (id, cwd) VALUES (?, ?)").run(sid, cwd);
|
||||
db.prepare(
|
||||
"INSERT INTO events (session_id, event_type, tool_name, data) VALUES (?, 'PostToolUse', 'Read', ?)"
|
||||
).run(sid, JSON.stringify({ tool_input: { file_path: file } }));
|
||||
};
|
||||
const get = (sid, file) =>
|
||||
fetch(`${BASE}/api/sessions/${sid}/image?path=${encodeURIComponent(file)}`, {
|
||||
headers: { cookie: cookies.boss },
|
||||
});
|
||||
|
||||
it("is served for the host's own session and a member's file in that member's home", async () => {
|
||||
const hostPng = path.join(TMP, "hostdir", "shot.png");
|
||||
const miaPng = path.join(MIA, "proj", "shot.png");
|
||||
fs.mkdirSync(path.dirname(hostPng), { recursive: true });
|
||||
fs.writeFileSync(hostPng, "HOSTPNG");
|
||||
fs.writeFileSync(miaPng, "MIAPNG");
|
||||
readBy("img-host", path.join(TMP, "hostdir"), hostPng);
|
||||
readBy("img-mia", path.join(MIA, "proj"), miaPng);
|
||||
const host = await get("img-host", hostPng);
|
||||
assert.equal(host.status, 200);
|
||||
assert.equal(await host.text(), "HOSTPNG");
|
||||
const mia = await get("img-mia", miaPng);
|
||||
assert.equal(mia.status, 200);
|
||||
assert.equal(await mia.text(), "MIAPNG");
|
||||
});
|
||||
|
||||
it("is refused when a member's session names a file outside that member's home", async () => {
|
||||
readBy("img-mia-out", path.join(MIA, "proj"), path.join(TMP, "hostdir", "shot.png"));
|
||||
readBy("img-mia-bob", path.join(MIA, "proj"), path.join(BOB, "b.png"));
|
||||
fs.writeFileSync(path.join(BOB, "b.png"), "BOBPNG");
|
||||
for (const f of [path.join(TMP, "hostdir", "shot.png"), path.join(BOB, "b.png")]) {
|
||||
const r = await get(f.includes("bob") ? "img-mia-bob" : "img-mia-out", f);
|
||||
assert.equal(r.status, 404, f);
|
||||
assert.ok(!/PNG/.test(await r.text()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -395,13 +395,17 @@ async function summarizeWork(dir) {
|
||||
const [entries, numstat] = await Promise.all([
|
||||
laneStatus(dir),
|
||||
// --relative: cwd-scoped, cwd-relative paths, so they key the status ones.
|
||||
git(dir, ["diff", "--numstat", "-z", "--relative", "HEAD"]).catch(() => ({ stdout: "" })),
|
||||
git(dir, ["diff", "--numstat", "-z", "--relative", "HEAD"]).catch(() => null),
|
||||
]);
|
||||
const stats = parseNumstat(numstat.stdout);
|
||||
const stats = parseNumstat(numstat?.stdout ?? "");
|
||||
const files = [];
|
||||
for (const { code, path: file } of entries) {
|
||||
const untracked = code === "??";
|
||||
const stat = stats.get(file);
|
||||
// `MM` with no line against HEAD: a staged change the working tree has
|
||||
// reverted — nothing to commit from the file on disk. JetBrains' Changes
|
||||
// view drops it the same way (#72). Only when the numstat answered.
|
||||
if (code === "MM" && numstat && !stat) continue;
|
||||
// One read per untracked file, not two: its line count and its
|
||||
// binary-ness are the same buffer answered twice.
|
||||
// Sequential on purpose: `normal` folds untracked dirs, so this is short.
|
||||
|
||||
@@ -85,6 +85,8 @@ function dialogCommands(kind, dir, platform = process.platform) {
|
||||
const title = TITLES[kind];
|
||||
if (platform === "darwin") {
|
||||
const what = kind === "dir" ? "folder" : "file";
|
||||
// osascript started by a background server is not the frontmost app, so
|
||||
// its dialog opened BEHIND the browser; `activate` brings it forward.
|
||||
return [
|
||||
[
|
||||
"osascript",
|
||||
@@ -92,6 +94,8 @@ function dialogCommands(kind, dir, platform = process.platform) {
|
||||
"-e",
|
||||
"on run argv",
|
||||
"-e",
|
||||
"activate",
|
||||
"-e",
|
||||
`POSIX path of (choose ${what} with prompt (item 1 of argv) default location (POSIX file (item 2 of argv)))`,
|
||||
"-e",
|
||||
"end run",
|
||||
|
||||
+11
-4
@@ -35,7 +35,7 @@ const { sameOriginGuard } = require("./run");
|
||||
const { isLocalRequest } = require("../lib/same-origin");
|
||||
const scope = require("../lib/scope");
|
||||
const { writeLaneFile } = require("../lib/lane-file-write");
|
||||
const { pickPath } = require("../lib/native-dialog");
|
||||
const { pickPath, startDir } = require("../lib/native-dialog");
|
||||
const { preflight } = require("../lib/lane-preflight");
|
||||
const {
|
||||
LANES_ROOT,
|
||||
@@ -564,15 +564,20 @@ router.post("/gc", sameOriginGuard, (req, res) => {
|
||||
* expose absolute filesystem paths from a native picker, so path selection is
|
||||
* done by browsing server-side instead. Read-only; registered ahead of
|
||||
* "/:id" so the literal "browse" segment is never captured as a lane id.
|
||||
* `files=1` (a file field's fallback) lists files too, and opens `path` the
|
||||
* way the native picker would (`startDir`: `~`, a file's folder, else home).
|
||||
*/
|
||||
router.get("/browse", (req, res) => {
|
||||
// A developer browses only their own folder (by real path: a link in it
|
||||
// cannot point the listing elsewhere) — the dashboard lists with its own
|
||||
// rights, CAP_DAC_READ_SEARCH under S3, not theirs.
|
||||
const own = scope.ownFolder(req);
|
||||
const files = req.query.files === "1";
|
||||
const raw =
|
||||
typeof req.query.path === "string" && req.query.path.trim()
|
||||
? req.query.path
|
||||
? files
|
||||
? startDir(req.query.path)
|
||||
: req.query.path
|
||||
: own || os.homedir();
|
||||
const resolved = own ? lanesLib.canonicalCwd(path.resolve(raw)) : path.resolve(raw);
|
||||
if (own && !scope.folderAllowed(req, resolved, { allowHome: true })) return refuseFolder(res);
|
||||
@@ -593,12 +598,14 @@ router.get("/browse", (req, res) => {
|
||||
try {
|
||||
entries = fs
|
||||
.readdirSync(resolved, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
||||
.filter((e) => (e.isDirectory() || (files && e.isFile())) && !e.name.startsWith("."))
|
||||
.map((e) => {
|
||||
const full = path.join(resolved, e.name);
|
||||
if (e.isFile()) return { name: e.name, path: full, isGitRepo: false, file: true };
|
||||
return { name: e.name, path: full, isGitRepo: fs.existsSync(path.join(full, ".git")) };
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
// Folders first, as every file picker lists them.
|
||||
.sort((a, b) => !!a.file - !!b.file || a.name.localeCompare(b.name));
|
||||
} catch {
|
||||
// An unreadable entry mid-listing is skipped, not a request failure.
|
||||
}
|
||||
|
||||
+17
-10
@@ -531,11 +531,19 @@ router.get("/drop/:name", (req, res) => {
|
||||
// for a link after the check): a regular file this account wrote. NOFOLLOW
|
||||
// refuses a link outright; NONBLOCK keeps a FIFO from hanging the loop.
|
||||
// S3: the requester's own drop dir, owned by their member account — so an
|
||||
// admin viewing a member's session gets the name chip, not the image.
|
||||
// admin viewing a member's session gets the name chip, not the image. An
|
||||
// admin's host-folder workspace drops into the HOST's dir (only an admin
|
||||
// may own one), and the chat's GET names no workspace: that dir too.
|
||||
const dirs = type ? [dropDir(req)] : [];
|
||||
if (dirs[0]?.member && req.user?.role === "admin" && !req.query?.laneId) {
|
||||
dirs.push({
|
||||
dir: path.join(require("../lib/claude-home").getDataDir(), "drops"),
|
||||
member: null,
|
||||
});
|
||||
}
|
||||
let fd = null;
|
||||
try {
|
||||
if (type) {
|
||||
const { dir, member } = dropDir(req);
|
||||
for (const { dir, member } of dirs) {
|
||||
try {
|
||||
fd = fs.openSync(
|
||||
path.join(dir, name),
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0)
|
||||
@@ -546,13 +554,12 @@ router.get("/drop/:name", (req, res) => {
|
||||
: typeof process.getuid === "function"
|
||||
? process.getuid()
|
||||
: st.uid;
|
||||
if (!st.isFile() || st.uid !== uid) {
|
||||
fs.closeSync(fd);
|
||||
fd = null;
|
||||
}
|
||||
if (st.isFile() && st.uid === uid) break;
|
||||
fs.closeSync(fd);
|
||||
fd = null;
|
||||
} catch {
|
||||
/* pruned, never dropped, or a link */
|
||||
}
|
||||
} catch {
|
||||
/* pruned, never dropped, or a link */
|
||||
}
|
||||
if (fd === null) {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such image" } });
|
||||
|
||||
@@ -480,8 +480,10 @@ router.get("/:id/outline", (req, res) => {
|
||||
// pane's Files read preview when it lies outside the workspace (a screenshot
|
||||
// under ~/.cache, /tmp). The path is authorized only by that event, so: raster
|
||||
// types only, under a sandbox CSP with nosniff; never for a pushed session
|
||||
// (another machine's paths), and not in server mode, where a member's hook
|
||||
// could name a host-only file that this process would then read as the host.
|
||||
// (another machine's paths). In server mode only with S3, where a member's hook
|
||||
// reports sessions in its own home only (hook-auth.js): a member's session gets
|
||||
// only a file inside that home and owned by them; a host session (a trusted
|
||||
// peer's) anything. Without S3 a member's hook could name a host-only file.
|
||||
const READ_IMAGE_TYPES = {
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
@@ -500,12 +502,18 @@ router.get("/:id/image", async (req, res) => {
|
||||
const fail = (status, code, message) => res.status(status).json({ error: { code, message } });
|
||||
const session = stmts.getSession.get(req.params.id);
|
||||
if (!session) return fail(404, "NOT_FOUND", "Session not found");
|
||||
if (isServerMode() || String(session.source || "").startsWith("push:")) {
|
||||
const s3 = require("../lib/linux-accounts").s3Enabled();
|
||||
if ((isServerMode() && !s3) || String(session.source || "").startsWith("push:")) {
|
||||
return fail(403, "EUNSUPPORTED", "not available for this session");
|
||||
}
|
||||
const file = typeof req.query.path === "string" ? req.query.path : "";
|
||||
const type = READ_IMAGE_TYPES[path.extname(file).slice(1).toLowerCase()];
|
||||
if (!path.isAbsolute(file) || !type) return fail(400, "ENOTIMAGE", "not an image path");
|
||||
const runAs = require("../lib/run-as");
|
||||
const member = s3 ? runAs.ownerForRead(session.cwd) : null;
|
||||
if (member && !require("../lib/hook-auth").inside(member.home, file)) {
|
||||
return fail(404, "ENOTFOUND", "the session never read that image");
|
||||
}
|
||||
if (!sessionReadFile.get(session.id, file)) {
|
||||
return fail(404, "ENOTFOUND", "the session never read that image");
|
||||
}
|
||||
@@ -514,7 +522,11 @@ router.get("/:id/image", async (req, res) => {
|
||||
// NONBLOCK: a FIFO named like an image must not hang the event loop.
|
||||
fh = await fs.promises.open(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0));
|
||||
const st = await fh.stat();
|
||||
if (!st.isFile()) return fail(404, "ENOTFOUND", "no such image");
|
||||
// A file in a member's home is read only when that member owns it (openSafe's rule).
|
||||
const fileOwner = s3 ? runAs.ownerForRead(file) : null;
|
||||
if (!st.isFile() || (fileOwner && st.uid !== fileOwner.uid)) {
|
||||
return fail(404, "ENOTFOUND", "no such image");
|
||||
}
|
||||
if (st.size > READ_IMAGE_MAX) return fail(400, "ETOOBIG", "image is larger than 20 MB");
|
||||
const buf = await fh.readFile();
|
||||
res.set({
|
||||
|
||||
Reference in New Issue
Block a user