feat: Discard all, claude-style image pastes, large local drops, per-account / list
- Changes panel: **Discard all** beside Commit discards every non-conflicted file in one `discard`, after a dialog naming how many untracked files go. - Terminal: a `claude` pane gets dropped/pasted images as bare paths (shown as `[Image #n]`) and other files as `@` mentions (`claudePastes`); a local browser's copied `file://` paths and text-drag file lists paste by path. - `POST /api/run/drop-file`: a loopback browser outside server mode may drop up to 2 GB, streamed to disk; remote stays 20 MB. - cc-config skills/commands/plugins read the USER scope of the account the pane runs as (S3 member home by `cwd`), never another user's or the host's for a non-admin without an account; the run form passes its cwd to plugins.
This commit is contained in:
@@ -354,7 +354,19 @@ describe("POST /api/run/drop-image", () => {
|
||||
});
|
||||
assert.equal(status, 413);
|
||||
assert.equal(body.error.code, "ETOOBIG");
|
||||
assert.equal(body.error.max_bytes, 20 * 1024 * 1024); // the UI shows the server's limit
|
||||
// The UI shows the server's limit: a loopback browser's, here.
|
||||
assert.equal(body.error.max_bytes, 2 * 1024 ** 3);
|
||||
});
|
||||
|
||||
it("streams a loopback drop over the remote limit to disk", async () => {
|
||||
const buf = Buffer.alloc(21 * 1024 * 1024, 7);
|
||||
const r = await fetchJson("/api/run/drop-file?name=big.bin", {
|
||||
method: "POST",
|
||||
raw: { type: "application/octet-stream", buf },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.match(r.body.path, /[\\/]drops[\\/][0-9a-f]{16}-big\.bin$/);
|
||||
assert.equal(fs.statSync(r.body.path).size, buf.length);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -437,6 +437,82 @@ describe("the top of a member's home", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the run form's `/` list", () => {
|
||||
it("reads the user skills, commands and plugins of the account the pane runs as", async () => {
|
||||
const skill = (claude, name) => {
|
||||
fs.mkdirSync(path.join(claude, "skills", name), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(claude, "skills", name, "SKILL.md"),
|
||||
`---\nname: ${name}\n---\nx\n`
|
||||
);
|
||||
};
|
||||
const hostClaude = process.env.CLAUDE_HOME;
|
||||
skill(hostClaude, "host-only");
|
||||
skill(path.join(MIA, ".claude"), "mia-skill");
|
||||
skill(path.join(BOB, ".claude"), "bob-skill");
|
||||
fs.mkdirSync(path.join(MIA, ".claude", "commands"), { recursive: true });
|
||||
fs.writeFileSync(path.join(MIA, ".claude", "commands", "mia-cmd.md"), "do it\n");
|
||||
fs.mkdirSync(path.join(MIA, ".claude", "plugins"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(MIA, ".claude", "plugins", "installed_plugins.json"),
|
||||
JSON.stringify({ plugins: { "mp@market": [{ scope: "user" }] } })
|
||||
);
|
||||
const names = (r) => r.body.items.map((i) => i.name);
|
||||
|
||||
const mia = await call("mia", "GET", "/api/cc-config/skills?scope=user");
|
||||
assert.deepEqual(names(mia), ["mia-skill"], JSON.stringify(mia.body));
|
||||
const cmds = await call("mia", "GET", "/api/cc-config/commands?scope=user");
|
||||
assert.deepEqual(names(cmds), ["mia-cmd"]);
|
||||
// Plugins stay staff-only; an admin's run form in a member folder lists that member's.
|
||||
const plugins = await call(
|
||||
"boss",
|
||||
"GET",
|
||||
`/api/cc-config/plugins?cwd=${encodeURIComponent(path.join(MIA, "proj"))}`
|
||||
);
|
||||
assert.deepEqual(
|
||||
plugins.body.plugins.map((p) => p.key),
|
||||
["mp@market"],
|
||||
JSON.stringify(plugins.body)
|
||||
);
|
||||
// A folder picks its own account's home.
|
||||
const bob = await call(
|
||||
"bob",
|
||||
"GET",
|
||||
`/api/cc-config/skills?scope=user&cwd=${encodeURIComponent(BOB)}`
|
||||
);
|
||||
assert.deepEqual(names(bob), ["bob-skill"], JSON.stringify(bob.body));
|
||||
// An admin's Config Explorer view, no folder named, stays the host's.
|
||||
const boss = await call("boss", "GET", "/api/cc-config/skills?scope=user");
|
||||
assert.deepEqual(names(boss), ["host-only"]);
|
||||
});
|
||||
|
||||
it("never lists another member's or the host's skills as someone's own", async () => {
|
||||
const names = (r) => r.body.items.map((i) => i.name);
|
||||
const bob = await call("bob", "GET", "/api/cc-config/skills?scope=user");
|
||||
assert.deepEqual(names(bob), ["bob-skill"]);
|
||||
const intoMia = await call(
|
||||
"bob",
|
||||
"GET",
|
||||
`/api/cc-config/skills?cwd=${encodeURIComponent(path.join(MIA, "proj"))}`
|
||||
);
|
||||
assert.equal(intoMia.status, 403);
|
||||
assert.equal(intoMia.body.error.code, "EFOLDER");
|
||||
// A developer with no Linux account of their own gets nothing, not the host's.
|
||||
await auth.createUser("eve", "eve-pass-123", "developer");
|
||||
const login = await fetch(`${BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "eve", password: "eve-pass-123" }),
|
||||
});
|
||||
cookies.eve = login.headers.get("set-cookie").split(";")[0];
|
||||
for (const kind of ["skills", "commands"]) {
|
||||
const r = await call("eve", "GET", `/api/cc-config/${kind}`);
|
||||
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||
assert.deepEqual(r.body.items, [], kind);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("a disabled member's session", () => {
|
||||
it("is still read from their home, and does not stop the liveness reaper", () => {
|
||||
db.prepare("UPDATE users SET disabled = 1 WHERE username = 'dan'").run();
|
||||
|
||||
Reference in New Issue
Block a user