Compare commits

...

4 Commits

Author SHA1 Message Date
nntrivi2001 8d0477e0f7 chore: v2.8.48 — a folder upload skips .git with one notice instead of an error per file 2026-11-02 09:53:03 +07:00
nntrivi2001 e7e9cb20d8 fix(files): a folder upload leaves .git out with one notice
Uploading a cloned repo from the desktop sent every .git object, and the
server refused each one (nothing is uploaded into .git) — one error toast
per object, thousands of them. The drop walker no longer reads a .git
folder, and uploadAll drops any path inside .git before sending, saying
once how many were skipped.
2026-11-02 09:53:03 +07:00
nntrivi2001 ebfae9b891 chore: v2.8.47 — a Codex ChatGPT pool account reaches its pane even while an older login's daemon runs 2026-10-30 16:06:33 +07:00
nntrivi2001 3ab484ddc0 fix(pool): a Codex ChatGPT pool pane runs on the login placed for it
Codex >= 0.164 hands a plain TUI (no -c override) to the CODEX_HOME's
shared app-server daemon, which outlives every TUI and keeps the login it
started with. A ChatGPT pool account written into a member's auth.json
therefore reached no pane while their daemon still ran on the previous
login (reproduced on 0.164.0: the daemon kept sending the old key, a
--no-daemon TUI sent the new one). Such a pane now runs `codex
--no-daemon`, only when that binary's --help lists the flag (probed once
per path/size/mtime), so an older Codex still starts.
2026-10-30 16:06:20 +07:00
10 changed files with 109 additions and 9 deletions
+9 -1
View File
@@ -229,6 +229,8 @@ async function walkEntry(
out.push({ file, rel: `${prefix}${entry.name}` });
return;
}
// The server takes nothing into .git: never read one off the disk at all.
if (entry.name === ".git") return;
const reader = (entry as FileSystemDirectoryEntry).createReader();
// readEntries answers in pages until it answers [].
for (;;) {
@@ -614,8 +616,14 @@ export function FileBrowser({
};
/** Upload `files` (each with its path inside the pick/drop) under `dir`. */
const uploadAll = async (files: { file: File; rel: string }[], dir = path) => {
const uploadAll = async (picked: { file: File; rel: string }[], dir = path) => {
setMenu(null);
// A dropped/picked repo carries its .git, which the server refuses file by
// file — skip it here with ONE notice instead of a toast per object.
const files = picked.filter((f) => !f.rel.split("/").includes(".git"));
if (files.length < picked.length) {
toast.info(t("fx.gitSkipped", { count: picked.length - files.length }));
}
if (!files.length) return;
const bytes = files.reduce((n, f) => n + f.file.size, 0);
if (bytes > FX_BATCH_MAX_BYTES) {
@@ -3,7 +3,8 @@
* opens sub-folders, makes a folder inline, deletes into the Trash without a
* confirm, copies in one workspace and pastes into another (`fromLane`),
* asks Replace / Keep both / Skip when an upload's name is taken, refuses a
* batch over 500 MB before sending anything, restores from the user's own
* batch over 500 MB before sending anything, leaves a repo's .git out of a
* folder upload with one notice, restores from the user's own
* Trash, opens an admin's Trash on one folder per user (counted, an unreadable
* one saying why), opens each user's Trash from its folder (asking before a
* purge there, sending the item's store) and
@@ -269,6 +270,23 @@ describe("FileExplorer", () => {
expect(api.files.uploadBatch).not.toHaveBeenCalled();
});
it("leaves a picked repo's .git out of a folder upload, with one notice", async () => {
vi.mocked(api.files.upload).mockResolvedValue({ path: "x" });
open();
await screen.findByText("a.txt");
const mk = (rel: string) => {
const f = new File(["hi"], rel.split("/").pop()!);
Object.defineProperty(f, "webkitRelativePath", { value: rel });
return f;
};
const files = ["repo/.git/HEAD", "repo/.git/objects/d4/ab", "repo/src/a.js"].map(mk);
fireEvent.change(screen.getByTestId("fx-upload-folder"), { target: { files } });
await waitFor(() => expect(api.files.upload).toHaveBeenCalledTimes(1));
expect(vi.mocked(api.files.upload).mock.calls[0]![1]).toBe("repo/src/a.js");
expect(toast.info).toHaveBeenCalledTimes(1);
expect(toast.error).not.toHaveBeenCalled();
});
it("refuses a batch over 500 MB before sending anything", async () => {
open();
await screen.findByText("a.txt");
+1
View File
@@ -590,6 +590,7 @@
"fx.dropHint": "Drag files or folders here to upload · drag onto a folder to move (Ctrl: copy)",
"fx.uploading": "Uploading {{done}}/{{total}}…",
"fx.uploaded": "Uploaded {{count}} file(s).",
"fx.gitSkipped": "Skipped {{count}} file(s) inside .git — nothing is uploaded into .git.",
"fx.uploadFailed": "Could not upload {{name}}: {{message}}",
"fx.tooBig": "{{size}} is more than one upload may carry ({{max}}). Upload fewer files at a time.",
"fx.trashed": "Moved {{count}} item(s) to the Trash.",
+1
View File
@@ -590,6 +590,7 @@
"fx.dropHint": "Kéo file hoặc thư mục vào đây để tải lên · kéo vào một thư mục để di chuyển (Ctrl: sao chép)",
"fx.uploading": "Đang tải lên {{done}}/{{total}}…",
"fx.uploaded": "Đã tải lên {{count}} file.",
"fx.gitSkipped": "Đã bỏ qua {{count}} file trong .git — không tải gì vào .git.",
"fx.uploadFailed": "Không tải lên được {{name}}: {{message}}",
"fx.tooBig": "{{size}} vượt quá giới hạn một lần tải lên ({{max}}). Hãy tải ít file hơn mỗi lần.",
"fx.trashed": "Đã chuyển {{count}} mục vào Thùng rác.",
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.46 (rebuild client to match)
UI build v2.8.48 (rebuild client to match)
</span>
</p>
</div>
+4
View File
@@ -555,6 +555,10 @@ agenthub accounts pin team-key mia --tool codex # pins work as for Claude
is used only for a pane that runs as its developer (one Linux user per
account): AgentHub writes the account's `auth.json` into that developer's
`~/.codex` as them (0600) just before the pane starts.
- The pane runs `codex --no-daemon` (when that Codex lists the flag in
`--help`): Codex ≥ 0.164 otherwise hands a plain session to the home's
shared app-server daemon, which outlives every pane and keeps the login
it started with — the new `auth.json` would not be used.
- A host pane never gets one: its `CODEX_HOME` holds the host's own login.
- A developer who signed Codex in themselves keeps their own login.
- While a pane of theirs runs on a pool account, their next pane uses the
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.46",
"version": "2.8.48",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.46",
"version": "2.8.48",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.46",
"version": "2.8.48",
"bin": {
"agenthub": "bin/agenthub.js"
},
+27 -1
View File
@@ -243,6 +243,29 @@ describe("codex pool: spawn", () => {
assert.equal(row.account, "c");
});
it("runs a ChatGPT pane without the shared daemon, only when its Codex has the flag", () => {
// The home's app-server daemon keeps the login it started with: a placed
// login reaches only a pane that runs its own app server.
const fakeCodex = (name, help) => {
const file = path.join(TMP, name);
fs.writeFileSync(file, `#!/bin/sh\necho '${help}'\n`, { mode: 0o755 });
return file;
};
const prev = process.env.AGENTHUB_CODEX_BIN;
try {
pool.addAccount("c", { auth: AUTH("acct-c") });
process.env.AGENTHUB_CODEX_BIN = fakeCodex("codex-new", " --no-daemon Run without");
assert.deepEqual(pool.forSpawn(MIA).argv, ["--no-daemon"]);
process.env.AGENTHUB_CODEX_BIN = fakeCodex("codex-old", " --oss Use a local model");
assert.deepEqual(pool.forSpawn(MIA).argv, [], "an older Codex would refuse the flag");
process.env.AGENTHUB_CODEX_BIN = path.join(TMP, "no-such-codex");
assert.deepEqual(pool.forSpawn(MIA).argv, []);
} finally {
if (prev === undefined) delete process.env.AGENTHUB_CODEX_BIN;
else process.env.AGENTHUB_CODEX_BIN = prev;
}
});
it("never replaces a member's own login, and stays on the account their live pane uses", () => {
pool.addAccount("c", { auth: AUTH("acct-c") });
pool.addAccount("d", { auth: AUTH("acct-d") });
@@ -272,7 +295,10 @@ describe("codex pool: spawn", () => {
fs.writeFileSync(MIA_AUTH, JSON.stringify(own));
assert.equal(pool.pickAccount(MIA), null);
pty.spawnRun({ laneId: 50, cwd: path.join(MIA.home, "p"), tool: "codex" });
assert.equal(JSON.parse(fs.readFileSync(MIA_AUTH, "utf8")).tokens.refresh_token, "rt-mias-seat");
assert.equal(
JSON.parse(fs.readFileSync(MIA_AUTH, "utf8")).tokens.refresh_token,
"rt-mias-seat"
);
});
it("copies the newest auth.json back into the pool", () => {
+44 -2
View File
@@ -19,7 +19,9 @@
* home AgentHub placed that account in, never dated in the future, and
* only when its id_token names the same account (`mayCopyBack`); the 2026-10-08
* spike showed an old refresh token still refreshes, so panes sharing an
* account do not log each other out.
* account do not log each other out. Such a pane runs `--no-daemon`
* (when its Codex has the flag): the home's shared app-server daemon
* would keep serving the login it started with.
*
* Records live in `<CLAUDE_HOME>/agenthub-codex-accounts/<name>.json` (dir
* 0700, file 0600); no route returns a key or a token.
@@ -424,6 +426,46 @@ function liveInHome(name, member) {
});
}
// Codex ≥ 0.164 hands a plain TUI (no `-c` override) to the CODEX_HOME's shared
// app-server daemon, which outlives every TUI and keeps the login it started
// with — so a ChatGPT login placed in the home reached no pane while the
// member's daemon ran on the previous one (reproduced on 0.164.0). A pool pane
// runs its own app server instead. An older Codex has neither the daemon nor
// the flag (it would refuse to start), so the flag goes only to a binary whose
// --help lists it: probed once per binary (path, size, mtime).
const noDaemonCache = new Map();
function noDaemonArgv(member) {
const bin =
process.env.AGENTHUB_CODEX_BIN ||
(member
? require("./shared-tools").sharedPath("codex")
: require("./ai-tools").detect("codex").path);
if (!bin) return [];
let key;
try {
const st = fs.statSync(bin);
key = `${fs.realpathSync(bin)}:${st.size}:${st.mtimeMs}`;
} catch {
return [];
}
if (!noDaemonCache.has(key)) {
let help = "";
try {
help = require("node:child_process").execFileSync(bin, ["--help"], {
encoding: "utf8",
timeout: 5000,
stdio: ["ignore", "pipe", "ignore"],
windowsHide: true,
});
} catch {
/* no answer: leave the flag out, as before */
}
noDaemonCache.set(key, /--no-daemon\b/.test(help));
}
return noDaemonCache.get(key) ? ["--no-daemon"] : [];
}
/** `{name, kind}` of the account a new Codex pane would get, or null. */
function pickAccount(member = null, ownerId = null) {
const a = pick(member, ownerId);
@@ -449,7 +491,7 @@ function forSpawn(member = null, ownerId = null) {
}
return {
name: a.name,
argv: [],
argv: noDaemonArgv(member),
env: {},
place: () => {
syncHome(member.home); // a live pane may hold a newer login than ours