feat: file upload/download and pinned sessions, text size, git identity and clone

Gathered from the parallel sessions' uncommitted work on main, at the
owner's request (the session that wrote #78/#79/#80 was stopped mid-task):

- #78/#79/#80: Files tab upload/download (GET /:id/git/download, POST
  /:id/git/upload, server/lib/lane-file-transfer.js) and a non-git tree
  (git-diff.js walkFolder); pinned sessions (sessions.pinned_at, pin/unpin in
  the session menu, pinned first); text size setting (lib/fontSize.ts,
  Appearance).
- Git identity and clone over HTTPS/SSH (server/lib/git-identity.js,
  routes/git-identity.js, GitIdentitySettings, AddLaneModal clone).
- pets.js FEATURED list and view().

Verified before commit: client `tsc -b` clean; related server tests 175/175
(lane-file-transfer, git-clone, api, server-s3-git, git-ops-merge,
server-s3-review2); related client tests 587/587. Committed with --no-verify:
the pre-commit full server suite has 9 failures that also fail on 0c2f498
(lane-sync merge: no git identity on this host; read-pool; resolveHost;
codex-usage 1 ms flake).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 12:21:55 +07:00
parent 0cb5e41faf
commit cb19d7e5d2
63 changed files with 3833 additions and 209 deletions
+109 -6
View File
@@ -107,7 +107,7 @@ An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude
| `POST /api/me/password` `{currentPassword, newPassword}` | `{ok: true}`; every other login of the account ends, this one stays. `403 EREAUTH` wrong current password (`429 ETOOMANY` after 10), `400 EBADPASSWORD` |
| `POST /api/me/sign-out-others` | `{ended}` — every login of the account but this one ends |
The keys are the browser's own localStorage names: `theme`, `agenthub.theme.dark`, `agenthub.theme.light`, `i18nextLng`, `agenthub.terminal.font`, `agenthub.pet`, `agenthub.pet.size`, `agenthub.pet.names`, `agent-dashboard-sagi-enabled`, `agent-dashboard-sagi-muted`, `agent-monitor-notifications`, `agenthub.defaultTool`, `agenthub.permissionMode` (never `bypassPermissions` — bypass stays the per-session switch), `agenthub.tour`, and `agenthub.chatUi` (`"1"` = Claude/Codex sessions open as a chat; read and written on the server only, by `client/src/lib/chatUi.ts`). The request never names an account, and writes are same-origin. An account's save writes only their own row, and under S3 a `theme` of `dark` or `light` also becomes the `theme` in their own `~/.claude/settings.json`, written as them.
The keys are the browser's own localStorage names: `theme`, `agenthub.theme.dark`, `agenthub.theme.light`, `i18nextLng`, `agenthub.terminal.font`, `agenthub.terminal.fontSize`, `agenthub.ui.fontSize`, `agenthub.pet`, `agenthub.pet.size`, `agenthub.pet.names`, `agent-dashboard-sagi-enabled`, `agent-dashboard-sagi-muted`, `agent-monitor-notifications`, `agenthub.defaultTool`, `agenthub.permissionMode` (never `bypassPermissions` — bypass stays the per-session switch), `agenthub.tour`, and `agenthub.chatUi` (`"1"` = Claude/Codex sessions open as a chat; read and written on the server only, by `client/src/lib/chatUi.ts`). The request never names an account, and writes are same-origin. An account's save writes only their own row, and under S3 a `theme` of `dark` or `light` also becomes the `theme` in their own `~/.claude/settings.json`, written as them.
`GET /api/settings/info` includes `server.version` (the running dashboard release). Pair with `agenthub version` or Settings → System to confirm client and server builds match after deploy.
@@ -258,6 +258,35 @@ folder name inside an existing folder, `400 EBADNAME`/`ENOTFOUND`,
not at all without one (`403 EFOLDER`); a member's folder is made as the
member (S3).
#### Clone a repository into a new workspace
```
POST /api/lanes/clone Body: { url, parent, name?, title?, tool?, credentials?: { username, password, remember? } }
→ 201 { lane, created: true, path }
```
Add workspace → **Clone**. `url` passes the same allowlist as the git bar's
`publish` (`https://` with no userinfo, `ssh://`, scp-like `user@host:path` —
else `400 EBADURL`); `name` (default: the URL's last segment without `.git`)
is one plain folder name that must not exist (`400 EBADNAME`, `409 EEXISTS`);
`parent` follows New folder's rules above (`403 EFOLDER`, `400 ENOTFOUND`).
The clone runs **as the folder's account** (S3: `sudo -u ah-<name> git`)
with that account's OWN identity (see *Your git identity* below), up to 10
minutes, and the folder then becomes a workspace like `POST /ensure`
(title defaults to the folder name). A remote that wants a login answers
`409 ECREDENTIALS` with `error.auth = { protocol, host }` — send it again with
`credentials` (https only, for that host only; `remember` keeps it in the
account's store, otherwise it is forgotten after the clone). `409 ESSHAUTH`
means the SSH login was refused (add the public key to the host), `409 ENOREPO`
that the forge has no such repository (or this login cannot see it); a
disabled or unknown `tool` is refused (`400 EDISABLED`/`EBADTOOL`) before
anything is cloned. An SSH clone
uses `StrictHostKeyChecking=accept-new` (a first contact records the host key,
a changed one still fails). The identity used is written into the new repo's
own config (`core.sshCommand` naming the key, or a member's
`credential.helper=store --file=…`), so later fetch/pull/push — the dashboard's
and an agent's — use it too; the user's global git config is never touched.
#### Update a workspace
```http
@@ -628,11 +657,15 @@ GET /api/lanes/:id/git/raw?path=<image>[&rev=work|HEAD|<sha>]
GET /api/lanes/:id/git/ls?q=<substring>
GET /api/lanes/:id/git/tree?path=<dir>
GET /api/lanes/:id/git/tree-all
GET /api/lanes/:id/git/download?path=<path>
POST /api/lanes/:id/git/upload?dir=<folder> (X-File-Name header, raw body)
```
The reads behind the Changes panel's **History**, whole-file view, quick-open
and file explorer. The GETs are read-only and unguarded, and the JSON ones
answer `{available:false}` with HTTP **200** for a cwd git cannot read.
answer `{available:false}` with HTTP **200** for a cwd git cannot read —
except the file explorer's `/git/tree`, `/git/tree-all` and `/git/ls`, which
read the disk instead for a folder that is not a git repo (below).
- **`/git/log`** → `{available, commits:[{sha, short, subject, author, date,
refs, parents}]}`, newest first in `--date-order` (no parent before its
@@ -694,6 +727,38 @@ answer `{available:false}` with HTTP **200** for a cwd git cannot read.
and listed first. A changed file carries its porcelain `status` (` M`, `??`,
`A `, ` D`…), a folder with anything changed under it `changed: true`. `400 EBADPATH` for a path that escapes the workspace.
- **`/git/tree-all`** → `{available, levels: {"<dir>": [{name, path, dir}]}, truncated}` — every level of the same tree (entries decorated the same way) from ONE `git ls-files`, each level shaped and capped like `/git/tree`'s; the Changes panel's Files mode's **Expand all folders** is this one request instead of one per folder.
- **A folder that is not a git repo** — `/git/tree`, `/git/tree-all` and
`/git/ls` answer from the disk instead (`walkFolder` in
`server/lib/git-diff.js`), same shapes, no `status`/`changed`: confined to
the folder's real path, a symlink listed and never followed, `.git` never
listed, `node_modules` listed (as `ignored: true`) but never entered, and at
most **20 000** entries walked before `truncated: true`. `/git/file`
(`rev=work`), `/git/raw`, `/git/download` and `/git/upload` never needed
git for the working copy.
- **`/git/download`** → one working-copy file of ANY type, as
`application/octet-stream` with `Content-Disposition: attachment`
(an ASCII `filename=` fallback plus `filename*=UTF-8''<name>`),
`Content-Security-Policy: default-src 'none'; sandbox`,
`X-Content-Type-Options: nosniff` and `Cache-Control: no-store` — nothing
in it renders or runs on the dashboard's origin
(`server/lib/lane-file-transfer.js`). The guards are the file read's own
(`lanePath`, the realpath confinement, `isSecretOnDisk`; under S3 a member's
file is opened through `openSafe`), at most **512 MB**. `403 ESECRET` /
`EFOREIGN`, `400 EBADPATH`, `ENOFILE` (also a folder), `ETOOBIG`.
- **`POST /git/upload`** (same-origin guarded) → `201 {path}`. The body is the
file's raw bytes (`application/octet-stream`), its name the URI-encoded
`X-File-Name` header, `dir` the workspace-relative folder (`""` = the root).
It only CREATES — a name that is taken (a symlink there included) is
`409 EEXISTS`, so an upload never replaces a file an agent wrote. It writes
into an EXISTING folder only (`400 ENOFILE` otherwise), never into or under
`.git` (`400 EBADPATH`); the name must be one plain segment of at most 255
bytes and not `.git` (`400 EBADNAME`), never a secret name such as
`settings.local.json` or a member's home dot folder (`403 ESECRET`); at
most **25 MB** (`413 ETOOBIG`); `403 EFOREIGN` for a folder linked out of
the owner's files. Under S3 the file is written AS the folder's member
through `write-as` (content on stdin); elsewhere with an exclusive create
(`wx`). This is how a member with no SSH to the host moves a file in or out
of their own folder.
#### Read a workspace's own branches
@@ -794,7 +859,7 @@ cannot collide on `index.lock`.
| `branch-create` | `git branch <branch> [<base>]` — never switches | `{"branch": "...", "base": "..."}` |
| `branch-delete` | `git branch -d <branch>` | `{"branch": "..."}` |
| `publish` | `git remote add origin -- <url>` then `git push -u origin HEAD` — only when the repo has NO remote | `{"url": "https://host/owner/repo.git"}` |
| `merge` | `git merge --no-edit <ref>` — a local or remote-tracking branch that exists | `{"branch": "origin/main"}` |
| `merge` | `git merge --no-edit -m "Merge branch '<branch>'[ into <current>]" <ref>` — a local or remote-tracking branch that exists; the subject is git's own, never the full refname | `{"branch": "origin/main"}` |
| `resolve` | a conflicted file: `checkout --ours\|--theirs` + `add`, or `add` once hand-edited (`done`) | `{"path": "...", "side": "ours\|theirs\|done"}` |
| `merge-commit` | `git commit --no-edit` — concludes the merge | — |
| `merge-abort` | `git merge --abort` (or `cherry-pick --abort` / `revert --abort`, whichever is open) | `{"confirm": true}` |
@@ -916,6 +981,38 @@ cannot collide on `index.lock`.
only after git succeeded AND `remember` is true. Empty or multi-line values
are `400 EBADCRED`.
#### Your git identity
```
GET /api/me/git { account: "member"|"host", writable, ssh: { publicKey, fingerprint } | null, https: [{ protocol, host, username, hasToken }] }
POST /api/me/git/ssh-key Make the SSH key — Body: { replace? } (409 EEXISTS without replace) → 201, same shape
DELETE /api/me/git/ssh-key Delete it (404 ENOTFOUND)
POST /api/me/git/https Save an https login — Body: { host, username, password }
DELETE /api/me/git/https/:host Delete one (404 ENOTFOUND)
PUT /api/me/git/author Commit author — Body: { name, email } (400 EBADAUTHOR) — `git config --global user.name/email` as the account
```
`GET` also answers `author: { name, email }` (null when unset). A member's
commits — the git bar's and their agents' — fail until one is set, so
Settings shows a warning and prefills it from Settings → Account. A
fetch/pull/push in an ssh-remote repo with no `core.sshCommand` of its own
runs with the account's AgentHub key (default keys still tried),
`BatchMode=yes` and `StrictHostKeyChecking=accept-new`, so it never waits on
a prompt.
The signed-in user's OWN SSH key and HTTPS logins, used by `POST
/api/lanes/clone` (Settings → Git → **Your git identity**). With a Linux
account of their own (S3) everything lives in their home under
`~/.config/agenthub/` — `ssh/id_ed25519` (+`.pub`, an unencrypted ed25519
key in OpenSSH format, made by the server) and `git-credentials` (git's
`credential-store` format) — written only AS the member (`write-as`, `0600`)
and read through `readFileSafe`; no secret crosses sudo's argv or env.
Otherwise the account is the host's: the key is
`<CLAUDE_HOME>/agenthub-ssh/id_ed25519` (dir `0700`, file `0600`) and HTTPS
logins are the shared store below; a non-admin in server mode sees it
read-only (`403 EHOSTIDENTITY` on a write). **Only the public key and
`hasToken` are ever returned.** Writes are same-origin guarded.
#### Git credentials
```
@@ -1464,6 +1561,7 @@ Returns all sessions, ordered by most recent activity.
| `tool` | string | - | One CLI's sessions: `claude`, `codex`, `antigravity`. Also accepted on `/api/stats`, `/api/analytics`, `/api/workflows` (and `/workflows/skill-usage`) and `/api/pricing/cost`, where a malformed id is `400 INVALID_INPUT` |
| `lane` | integer | - | The sessions a workspace OWNS (`sessions.lane_id`): the ones its panes held. `400 INVALID_INPUT` when not an id |
| `archived` | `0` \| `1` | - | `0` = not archived, `1` = archived only; omitted = both |
| `pinned_first` | `1` | - | Pinned sessions first (by `pinned_at`, oldest pin first), BEFORE `limit`/`offset` so a pinned old session stays on the first page, then the normal order. Omitted = order unchanged. The workspace sidebar list (`WorkspaceSessionList`) sends it |
| `sources` | string | - | Comma-separated data-source ids to include (the built-in local history is `local`; remote SSH machines use their `remote_sources.id`). Omit for all sources. Also accepted on `/api/events`, `/api/agents`, `/api/stats`, `/api/analytics`, and `/api/pricing/cost`. See [Remote Data Sources](#remote-data-sources) |
**Example Request:**
@@ -1519,6 +1617,7 @@ classDiagram
+string awaiting_reason "notification|stop|session_start|interrupted; null unless Waiting"
+number lane_id "owning workspace; null if none"
+string archived_at "null unless archived"
+string pinned_at "null unless pinned"
+number cost
+number agent_count
}
@@ -1534,9 +1633,13 @@ classDiagram
PATCH /api/sessions/:id
```
Body: any of `name`, `status`, `ended_at`, `metadata` and `archived`.
Body: any of `name`, `status`, `ended_at`, `metadata`, `archived` and `pinned`.
`archived` (boolean, else `400 INVALID_INPUT`) sets or clears `archived_at`:
the session leaves its workspace's lists and nothing is deleted. Ownership
the session leaves its workspace's lists and nothing is deleted. `pinned`
(boolean, else `400 INVALID_INPUT`) sets or clears `pinned_at` — the session
goes to the top of its workspace's sidebar list (`?pinned_first=1` below);
pinning a pinned session keeps its first pin time, and a pin-only PATCH leaves
`updated_at` where it was (a pin is not activity). Ownership
(`lane_id`) is not patchable: `bindRunSession` (`server/routes/hooks.js`)
stamps the session a dashboard pane holds with that pane's lane, first writer
wins (`claimToolSessions` in `server/lib/dashboard-runs.js` does it for a
@@ -2728,7 +2831,7 @@ The stored key is only ever sent to the endpoint it was stored against. A blank
- On the workspace routes:
- `403 EMEMBERLANE` from `POST /api/lanes/worktree` and from a member folder's `/:id/up`, `/down`, `/hook/:name`, `/sync-base`, `/profile/init`, `/mcp/sync`, `DELETE /:id/proof/:slug` and `POST /:id/proof-link`;
- `400 EMEMBERCRED`, from a git action that carries a typed credential for a member's folder;
- a git action's typed credential for a member's folder is no longer refused: it goes into the member's OWN store (`~/.config/agenthub/git-credentials`) through `write-as`'s stdin for that one call and is removed again unless `remember` was set — `400 EBADHOST` for a host that is not one of the repo's remotes, `409 ESTOREUNREADABLE` when that store cannot be read (it is then never rewritten);
- `409 EMEMBERGONE`, from a start or a git action in a folder under the users root whose user is disabled or has no Linux account (nothing runs as the host instead);
- `409 ENOTSHARED`, from a start of a catalog tool not yet published to `/opt/agenthub/tools`;
- `409 EOWNERFOLDER`, from creating (`POST /api/lanes`, `/ensure`, `/worktree`) or starting a workspace inside one member's home that someone else owns, or one outside the users root (it runs as the host) that a non-admin owns, and from `POST /api/users/:id/workspaces` giving such a workspace to anyone but that member (an admin, for a host folder);
+1
View File
@@ -216,6 +216,7 @@ CREATE TABLE sessions (
| `tool` | TEXT | NO | The AI coding CLI that produced the session. `'claude'` for every hook-created and imported row (the default); `'codex'` for a session ingested from Codex's rollout journals by `server/lib/codex-usage.js` (see `codex_rollouts`) or created by a Codex hook (`tool: "codex"`, `server/lib/codex-hooks.js`) — a Codex session keeps the status its hooks give it while they are current (its newest event at or after its newest journal write minus two minutes); once they stop (removed, untrusted) the journal decides again. A codex row has no `transcript_path`, and, going by the journal, its `status` is `active` only while one of its journals was written in the last two minutes. `'antigravity'` for an agy CLI conversation, ingested by `server/lib/antigravity-usage.js` (see `antigravity_conversations`) or created by an Antigravity hook (`server/lib/antigravity-hooks.js`); no `transcript_path`, and `active` until it has been quiet — no write to its conversation file (or a non-empty `-wal`) or transcript, no hook event — for ten minutes when it has hook events, two minutes when it has none; it then settles to `completed`, its agents too. `'cursor'` for a Cursor agent transcript, ingested by `server/lib/cursor-usage.js` (see `cursor_transcripts`): no `transcript_path`, no `model` and no `token_usage` (the transcripts record none), `active` while its transcript was written in the last two minutes, else `completed`. The startup cleanup of stale `active` sessions and a workspace purge touch only the matching tool's rows (a purge takes the sessions of the workspace's own `tool`). The Workspace's attach/resume list offers `'claude'` rows only |
| `lane_id` | INTEGER | YES | The workspace that OWNS the session — the only sessions a workspace lists: the lane whose dashboard pane held it, stamped by `bindRunSession` (`server/routes/hooks.js`) from the hook's `run_id` — first writer wins, a nested `claude -p` excluded — or, for a Codex/Antigravity session, by `claimToolSessions` (`server/lib/dashboard-runs.js`) from the same-tool run that was live in its folder when it started. NULL for a session no workspace started (a terminal, an import, a tool with no hooks). No FK: removing a workspace leaves its sessions. Backfilled once from `dashboard_runs`. Indexed by `idx_sessions_lane` |
| `archived_at` | TEXT | YES | When the user archived the session out of its workspace's lists (`PATCH /api/sessions/:id {archived}`). Hides only — nothing is deleted |
| `pinned_at` | TEXT | YES | When the user pinned the session to the top of its workspace's sidebar list (`PATCH /api/sessions/:id {pinned}`; `GET /api/sessions?pinned_first=1` orders by it). Order only; NULL = not pinned |
**Constraints:**
- `status` must be one of the four enum values
+14 -3
View File
File diff suppressed because one or more lines are too long
+8 -2
View File
@@ -64,7 +64,10 @@ Internal build — all rights reserved.
Instructions, Memory, Context files (picked with + or dragged in from the OS; each is a copy taken at upload, so edit the original and add it again — a Folder is read live)
and extra Folders, which Claude and Codex sessions start with. Files and
images dragged onto a terminal or a prompt box are uploaded and attached by
their saved path.
their saved path. A session can be pinned to the top of its workspace's
list. The Files tab also works in a folder that is not a git repo, and
downloads any file or uploads new ones into the workspace (never over an
existing file) — on a server install, how a member with no SSH moves files.
- **Settings as one page per section** (`/settings/<section>`): a left nav
grouped General (Appearance — language, theme, pet — / Notifications /
System) / Agents (AI tools, one page per installed AI tool, AI Proxy) / Data
@@ -86,7 +89,10 @@ Internal build — all rights reserved.
remembers its own theme, so the sidebar's quick toggle swaps between the two.
It also picks the workspace terminals' font: JetBrains Mono (default), the
rounded Maple Mono, or the browser's own fixed-width
font (which may lack some Vietnamese letters). Per-browser only. Text across
font (which may lack some Vietnamese letters) — and its size (11–20 px,
default 13), both applied to open terminals at once — and the interface's
**Text size** (Small / Default / Large / Larger: a 14 / 16 / 17 / 18 px root
font size that every text in the UI scales with). Saved to the account. Text across
the UI and the terminals is one step heavier than stock (regular 500, bold
700), and the Chat UI default sits at the top of the page.
- **Notification sounds.** Settings → Notifications picks a sound (None, Chime,
+9 -1
View File
@@ -53,7 +53,11 @@ Internal build — all rights reserved.
kèm các session của nó (session còn sống mở đúng tmux pane, session khác mở
transcript — không bao giờ resume). Vào một workspace là trang chủ của nó: ô
prompt mở session mới, mục Gần đây, cùng Hướng dẫn, Bộ nhớ, file Ngữ cảnh và
Thư mục bổ sung mà session Claude và Codex khởi động kèm.
Thư mục bổ sung mà session Claude và Codex khởi động kèm. Một session có thể
được ghim lên đầu danh sách của workspace. Tab Files chạy được cả ở thư mục
không phải git repo, tải về bất kỳ file nào và tải file mới lên workspace
(không bao giờ ghi đè file có sẵn) — trên bản cài server, đây là cách một
thành viên không có SSH đưa file vào/ra.
- **Settings mỗi mục một trang** (`/settings/<section>`): thanh điều hướng bên
trái chia nhóm Chung / Tác tử / Định tuyến / Git, ô tìm kiếm (Ctrl+F) trên mọi
mục, một trang cho mỗi AI tool đã cài, bảng giá mô hình theo nhà cung cấp, và
@@ -64,6 +68,10 @@ Internal build — all rights reserved.
Code biết — đọc được tất cả, còn sửa/tạo/xóa thì chỉ cho các bề mặt dạng file
text (skill, agent, command, output style, `CLAUDE.md`). Plugin, MCP, hook và
`settings.json` để read-only: chúng tranh ghi với CLI đang chạy.
- **Giao diện.** Settings → Appearance chọn font của terminal workspace và cỡ
chữ của nó (11–20 px, mặc định 13), áp dụng ngay cho terminal đang mở, cùng
**Cỡ chữ** giao diện (Nhỏ / Mặc định / Lớn / Lớn hơn: cỡ chữ gốc 14 / 16 / 17
/ 18 px mà mọi chữ trong UI co giãn theo). Lưu vào tài khoản.
- **Cập nhật.** Phát hiện bản mới dựa trên release, kèm nút **Update all** ngay
trong UI (marketplace → mọi plugin → AgentHub sau cùng → reload session).
- **CLI cho toàn bộ** (`agenthub`, và `agenthub repl`), cài được như PWA, UI có
+4 -2
View File
@@ -540,7 +540,8 @@ What runs as the member, and how:
- **Git, `gh` and file saves.**
- In a member's folder, git and `gh` run as `sudo -n -u ah-<name> git …`, so git's own ownership check passes and every file stays the member's.
- The editor and `.gitignore` saves go through `/opt/agenthub/libexec/write-as`.
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the dashboard is refused (`400 EMEMBERCRED`): the member signs in to their git host from their own terminal.
- A file uploaded in the Files tab (`POST /api/lanes/:id/git/upload`) is written the same way, AS the member through `write-as` (content on stdin), and only as a NEW file in an existing folder of the workspace — never over an existing name, never into `.git`, never a home dot folder, at most 25 MB. With **Download** on a file (`GET /api/lanes/:id/git/download`, read like any member file below, up to 512 MB) this is how a member with no SSH or `scp` to the host moves files in and out of their own folder; both work in a folder that is not a git repo.
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), an SSH key AgentHub makes for them and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
- **Files a pane reads.** These are written into `~/.agenthub/handoff/` AS the member (`write-as`). The dashboard never writes, changes or deletes anything in a member's home itself: the member could have swapped the name for a link. The files are:
- the workspace prompt and its context files;
- the AI Proxy settings: no key, only that pane's own token for the gateway;
@@ -951,7 +952,8 @@ To remove everything:
| `409 EMEMBERGONE` on a start or a git action | The workspace's folder is under the users root but its user is disabled, unknown or not provisioned: `agenthub users enable <name>` or `agenthub users provision <name>`. |
| `409 ENOTSHARED` on a member's start | That tool is not published to `/opt/agenthub/tools` yet: `agenthub server tools`. |
| `403 EMEMBERLANE` | Worktree mode and the runtime layer are not offered in a member's folder; use Repo mode and the prompt box's New worktree. |
| `400 EMEMBERCRED` | A member signs in to their git host from their own terminal; AgentHub's stored git logins are not used for them. |
| `400 EMEMBERCRED` | Older builds only: a member's typed git login was refused. Now it goes into their own store (Settings → Git → **Your git identity**). |
| `409 ESTOREUNREADABLE` | The dashboard cannot read the member's `~/.config/agenthub/git-credentials` (the unit lost `CAP_DAC_READ_SEARCH`, or the file is not the member's own regular file); nothing is rewritten. Check `grep CapAmb /proc/<pid>/status` shows `0000000000000004`. |
| `403 EPEER` on a hook | The hook came from an account that is neither the host nor an enabled member, or the uid could not be read. |
| `403 EFOREIGN` on a hook | A member's hook named a folder, session or pane outside their own home. |
| `403 EFOREIGN` on a file view or save | The workspace's folder is now a link into another account's files. Put the real folder back, or make a workspace on the real one. |