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:
+109
-6
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user