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
+4 -2
View File
@@ -375,7 +375,7 @@ graph TD
| `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `agenthub-lane-<lane.id>`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live. Every spawn also mirrors itself into `dashboard_runs` (cwd, model, prompt preview, `resume_session_id`, lane id, start time) — that row is the ONLY source `publicRun` has for those fields, since the handle itself is recomputed from tmux on every read, so a dropped insert renders as a live run with no cwd, model or start date. `mode` is NOT NULL in that table and `recordRun` swallows insert errors by design, which is why this module passes a literal `"interactive"` rather than null. **Create-or-adopt has one refusal:** a pane already running an agent is adopted silently only when the request asked for nothing but a pane — a `resumeSessionId` or an initial prompt cannot be typed over a live agent, so it throws `ERUNLIVE` (409) rather than reporting a resume that never spawned | | `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `agenthub-lane-<lane.id>`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live. Every spawn also mirrors itself into `dashboard_runs` (cwd, model, prompt preview, `resume_session_id`, lane id, start time) — that row is the ONLY source `publicRun` has for those fields, since the handle itself is recomputed from tmux on every read, so a dropped insert renders as a live run with no cwd, model or start date. `mode` is NOT NULL in that table and `recordRun` swallows insert errors by design, which is why this module passes a literal `"interactive"` rather than null. **Create-or-adopt has one refusal:** a pane already running an agent is adopted silently only when the request asked for nothing but a pane — a `resumeSessionId` or an initial prompt cannot be typed over a live agent, so it throws `ERUNLIVE` (409) rather than reporting a resume that never spawned |
| `lib/pty-attach.js` | Client-side PTY attachment via WebSocket. Establishes a `/ws-pty/:runId` connection, receives binary PTY frames, and feeds them to an xterm.js terminal instance. Handles reconnection, resize events (sending `TIOCSWINSZ` ioctl to the tmux pane), and cleanup on disconnect. A single tmux session can have many simultaneous PTY clients (browser Workspace, `agenthub lanes shell` CLI, etc.), all synced live The server side (`server/lib/pty-attach.js`) runs `tmux attach` with the LIVE tmux server's own binary (`/proc/<tmux.serverPid()>/exe`, else `tmux` on PATH): a tmux upgraded while its server runs leaves a newer client on PATH that the old server refuses with `open terminal failed: not a terminal`. | | `lib/pty-attach.js` | Client-side PTY attachment via WebSocket. Establishes a `/ws-pty/:runId` connection, receives binary PTY frames, and feeds them to an xterm.js terminal instance. Handles reconnection, resize events (sending `TIOCSWINSZ` ioctl to the tmux pane), and cleanup on disconnect. A single tmux session can have many simultaneous PTY clients (browser Workspace, `agenthub lanes shell` CLI, etc.), all synced live The server side (`server/lib/pty-attach.js`) runs `tmux attach` with the LIVE tmux server's own binary (`/proc/<tmux.serverPid()>/exe`, else `tmux` on PATH): a tmux upgraded while its server runs leaves a newer client on PATH that the old server refuses with `open terminal failed: not a terminal`. |
| `routes/run.js` | HTTP surface for the tmux+PTY Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). `GET /api/run` lists all live runs (computed fresh from tmux state via `tmux list-sessions`). `GET /api/run/tmux` reports whether `tmux` is installed and on PATH (required for the feature to work). `GET /api/run/binary` probes whether `claude` is on `PATH`. `GET /api/run/cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /api/run/history?laneId=...` returns persisted run history, optionally scoped to one lane. `GET /api/run/files?cwd=&q=` powers the Workspace page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /api/run/drop-image` saves an image dropped on the terminal (raw body, four image types, random name under `<data dir>/drops/` (a member's `~/.agenthub/handoff/drops/` under S3)) and returns the path the terminal pastes. `POST /api/run` requires `laneId` in the body and starts/attaches a tmux-backed run. `GET /api/run/:id` returns the run handle. `DELETE /api/run/:id` kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over `/ws-pty/:runId` as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. `tmux` must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements) | | `routes/run.js` | HTTP surface for the tmux+PTY Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). `GET /api/run` lists all live runs (computed fresh from tmux state via `tmux list-sessions`). `GET /api/run/tmux` reports whether `tmux` is installed and on PATH (required for the feature to work). `GET /api/run/binary` probes whether `claude` is on `PATH`. `GET /api/run/cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /api/run/history?laneId=...` returns persisted run history, optionally scoped to one lane. `GET /api/run/files?cwd=&q=` powers the Workspace page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /api/run/drop-image` saves an image dropped on the terminal (raw body, four image types, random name under `<data dir>/drops/` (a member's `~/.agenthub/handoff/drops/` under S3)) and returns the path the terminal pastes. `POST /api/run` requires `laneId` in the body and starts/attaches a tmux-backed run. `GET /api/run/:id` returns the run handle. `DELETE /api/run/:id` kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over `/ws-pty/:runId` as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. `tmux` must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements) |
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.agenthub/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. `GET /api/lanes/pipeline-stats` aggregates per-node pass counts over every lane and every archived feature. The git surface (#34/#35) sits under `/:id/git/*`, again before the catch-all: `GET /:id/git/changes` (ranges `work`/`branch`/`commit`, with `context`), `/:id/git/log`, `/:id/git/file`, `/:id/git/ls`, `/:id/git/tree` and `/:id/git/branches` are read-only and unguarded like `/:id/git`, while `POST /:id/git/:action` is same-origin guarded and runs under the lane lock — and its per-file actions (`stage`, `unstage`, `discard`) accept only paths `git status` reports as changed, with `discard` additionally requiring `confirm: true`. | | `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.agenthub/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. `GET /api/lanes/pipeline-stats` aggregates per-node pass counts over every lane and every archived feature. The git surface (#34/#35) sits under `/:id/git/*`, again before the catch-all: `GET /:id/git/changes` (ranges `work`/`branch`/`commit`, with `context`), `/:id/git/log`, `/:id/git/file`, `/:id/git/ls`, `/:id/git/tree`, `/:id/git/download` and `/:id/git/branches` are read-only and unguarded like `/:id/git`, while `POST /:id/git/upload` (create-only, `lane-file-transfer.js`) and `POST /:id/git/:action` are same-origin guarded and runs under the lane lock — and its per-file actions (`stage`, `unstage`, `discard`) accept only paths `git status` reports as changed, with `discard` additionally requiring `confirm: true`. |
| `lib/ports.js` | TCP probing for runtime allocation. `isListening(port)` connects rather than binds (binding races with the hook about to bind, and says nothing about a listener held by another user); a connect timeout counts as occupied. `listenerPids(port)` shells to `lsof`, falls back to `ss`, and returns `[]` with a one-time warning when neither exists — a missing tool must never fail a lane operation | | `lib/ports.js` | TCP probing for runtime allocation. `isListening(port)` connects rather than binds (binding races with the hook about to bind, and says nothing about a listener held by another user); a connect timeout counts as occupied. `listenerPids(port)` shells to `lsof`, falls back to `ss`, and returns `[]` with a one-time warning when neither exists — a missing tool must never fail a lane operation |
| `lib/lane-slots.js` | Slot and port allocation — the numbering Shipyard gets free from fixed `lane1..lane9` directories and AgentHub, keyed by `cwd`, must allocate. `allocateSlot` takes the lowest free of `LANE_MAX_SLOTS` (default 9) under the per-lane lock, with a partial unique index on `lanes.slot` as the backstop; allocation is **lazy**, so a lane that is only watched never consumes one. `releaseSlot` runs on remove but never on reset (moving a lane's ports mid-feature is a silent failure, not a fresh start). `resolvePorts` prefers `PORT_BASE_<name> + slot`, then steps `+100` at a time so the last digit still reads as the slot, skipping anything listening, recorded by another lane, or already taken in the same boot. `slotDirs` puts run/log state under `LANES_ROOT/.state/lane<slot>/` — outside the worktree, because `reset`'s `git clean -fd` would otherwise sweep live pid files. `dbName`/`dataFacts` (A2) derive the same kind of slot-based fact one layer up: database name, `DATABASE_URL`/`TEST_DATABASE_URL`, a Redis logical index, and the upload directory — each `null` when its owning profile declaration (`DB_PREFIX`, `REDIS`, `UPLOAD_SUBDIR`) is absent | | `lib/lane-slots.js` | Slot and port allocation — the numbering Shipyard gets free from fixed `lane1..lane9` directories and AgentHub, keyed by `cwd`, must allocate. `allocateSlot` takes the lowest free of `LANE_MAX_SLOTS` (default 9) under the per-lane lock, with a partial unique index on `lanes.slot` as the backstop; allocation is **lazy**, so a lane that is only watched never consumes one. `releaseSlot` runs on remove but never on reset (moving a lane's ports mid-feature is a silent failure, not a fresh start). `resolvePorts` prefers `PORT_BASE_<name> + slot`, then steps `+100` at a time so the last digit still reads as the slot, skipping anything listening, recorded by another lane, or already taken in the same boot. `slotDirs` puts run/log state under `LANES_ROOT/.state/lane<slot>/` — outside the worktree, because `reset`'s `git clean -fd` would otherwise sweep live pid files. `dbName`/`dataFacts` (A2) derive the same kind of slot-based fact one layer up: database name, `DATABASE_URL`/`TEST_DATABASE_URL`, a Redis logical index, and the upload directory — each `null` when its owning profile declaration (`DB_PREFIX`, `REDIS`, `UPLOAD_SUBDIR`) is absent |
| `lib/lane-profile.js` | The stack seam. Resolves `<repo>/.agenthub/profile/` from the lane's own working copy first (a branch that edits its boot command must boot with it), the source repo second. `profile.env` is **parsed, not sourced** — `$(…)` stays literal, because sourcing repo shell into the dashboard process would be a code-execution path. `runHook` spawns `bash <hook>` with an argv array through a wrapper that `export -f`s `harness_spawn`/`die`, exports Shipyard's env contract verbatim (`LANE` is the **slot**, plus `<NAME>_PORT` per declared port, plus A2's `DB_NAME`/`DATABASE_URL`/`TEST_DATABASE_URL`/`PG_HOST`/`PG_PORT`/`PG_USER`/`REDIS_URL`/`REDIS_HOST`/`REDIS_PORT`/`UPLOAD_DIR` when their profile declaration is present) so its profiles port unchanged, scrubs `GIT_*` exactly as `worktree.js:git()` does, redacts any `secrets.js` password from its output before it reaches the log file or the `lane_hook_output` broadcast, and streams the rest to both `$LOG_DIR/<hook>.log` and that broadcast. Hook names come from a fixed allowlist, never from a request | | `lib/lane-profile.js` | The stack seam. Resolves `<repo>/.agenthub/profile/` from the lane's own working copy first (a branch that edits its boot command must boot with it), the source repo second. `profile.env` is **parsed, not sourced** — `$(…)` stays literal, because sourcing repo shell into the dashboard process would be a code-execution path. `runHook` spawns `bash <hook>` with an argv array through a wrapper that `export -f`s `harness_spawn`/`die`, exports Shipyard's env contract verbatim (`LANE` is the **slot**, plus `<NAME>_PORT` per declared port, plus A2's `DB_NAME`/`DATABASE_URL`/`TEST_DATABASE_URL`/`PG_HOST`/`PG_PORT`/`PG_USER`/`REDIS_URL`/`REDIS_HOST`/`REDIS_PORT`/`UPLOAD_DIR` when their profile declaration is present) so its profiles port unchanged, scrubs `GIT_*` exactly as `worktree.js:git()` does, redacts any `secrets.js` password from its output before it reaches the log file or the `lane_hook_output` broadcast, and streams the rest to both `$LOG_DIR/<hook>.log` and that broadcast. Hook names come from a fixed allowlist, never from a request |
@@ -385,7 +385,8 @@ graph TD
| `lib/lane-env.js` | (A2) `seedEnv` copies a repo's real `.env` into a lane on first boot (or `--force`) and rewrites the declared `ENV_REWRITE` keys (`DATABASE_URL`/`REDIS_URL`/`UPLOAD_DIR`) in place, byte-identical otherwise. A `--force` refresh preserves `ENV_PRESERVE` keys (e.g. `JWT_SECRET`) from the lane's own existing file — swapping in the source's secret would 401 a running lane until reboot. Falls back to `.env.example` with a warning when the source is missing. Refuses on an adopted lane: that file is the user's real config | | `lib/lane-env.js` | (A2) `seedEnv` copies a repo's real `.env` into a lane on first boot (or `--force`) and rewrites the declared `ENV_REWRITE` keys (`DATABASE_URL`/`REDIS_URL`/`UPLOAD_DIR`) in place, byte-identical otherwise. A `--force` refresh preserves `ENV_PRESERVE` keys (e.g. `JWT_SECRET`) from the lane's own existing file — swapping in the source's secret would 401 a running lane until reboot. Falls back to `.env.example` with a warning when the source is missing. Refuses on an adopted lane: that file is the user's real config |
| `lib/lane-services.js` | (A2) `ensureDatabase`/`dropDatabase` call the profile's `db-create`/`db-drop` hooks — AgentHub stays stack-agnostic on purpose. A state-dir marker file tracks whether a slot's database was already created, since a plain `createdb` can't be re-run safely and AgentHub can't assume the hook is idempotent; this is also how `upLane` knows to seed only a freshly-created database. `dropDatabase` asserts the lane is `managed` and that the name being dropped is one this lane's own slot actually derives (itself or its `_test` sibling) before spawning anything | | `lib/lane-services.js` | (A2) `ensureDatabase`/`dropDatabase` call the profile's `db-create`/`db-drop` hooks — AgentHub stays stack-agnostic on purpose. A state-dir marker file tracks whether a slot's database was already created, since a plain `createdb` can't be re-run safely and AgentHub can't assume the hook is idempotent; this is also how `upLane` knows to seed only a freshly-created database. `dropDatabase` asserts the lane is `managed` and that the name being dropped is one this lane's own slot actually derives (itself or its `_test` sibling) before spawning anything |
| `lib/lane-features.js` | (B) Per-feature state and archive. `activateFeature` archives the lane's current active feature (if different) and restores the target's saved stage onto the live `lanes` row — the row stays the one live view every other reader already uses. `canonicalizeSlug` is a DELIBERATELY separate rule from `worktree.js:slugify` (drops a leading `feat/`, keeps `[A-Za-z0-9._-]`, does not lowercase) — the two must never be conflated. `clearLane` (`lib/lanes.js`) archives the active feature (if any) before resetting; a lane that never activated one is unaffected | | `lib/lane-features.js` | (B) Per-feature state and archive. `activateFeature` archives the lane's current active feature (if different) and restores the target's saved stage onto the live `lanes` row — the row stays the one live view every other reader already uses. `canonicalizeSlug` is a DELIBERATELY separate rule from `worktree.js:slugify` (drops a leading `feat/`, keeps `[A-Za-z0-9._-]`, does not lowercase) — the two must never be conflated. `clearLane` (`lib/lanes.js`) archives the active feature (if any) before resetting; a lane that never activated one is unaffected |
| `lib/git-diff.js` | (#34/#35) Reads a lane's code changes as structured hunks for the Workspace Changes panel — `work` (HEAD vs the working tree, untracked files included), `branch` (`<base>...HEAD`) or `commit` (one sha), at a caller-chosen `context` (default 3, clamped to 5000, which is how the viewer expands a diff towards the whole file). Also the four reads behind History, the whole-file view, quick-open and the file explorer: `listCommits`, `readFileAt` (the `work` revision is realpath-confined to the lane; anything else goes through `git show`), `listPaths` and `listTree` (both `git ls-files`, so `.gitignore` is honoured without a skip-list). Read-only, argv-only through `worktree.js:git()`. An untracked file's patch is SYNTHESIZED from the file itself rather than fetched with `git diff --no-index`, which exits non-zero on a difference and would read as a failure. Three caps (300 files, 200 KB per file, 1 MB per full read) keep a lane mid-build from shipping megabytes of patch into a browser; a cap that bit sets `truncated` | | `lib/git-diff.js` | (#34/#35) Reads a lane's code changes as structured hunks for the Workspace Changes panel — `work` (HEAD vs the working tree, untracked files included), `branch` (`<base>...HEAD`) or `commit` (one sha), at a caller-chosen `context` (default 3, clamped to 5000, which is how the viewer expands a diff towards the whole file). Also the four reads behind History, the whole-file view, quick-open and the file explorer: `listCommits`, `readFileAt` (the `work` revision is realpath-confined to the lane; anything else goes through `git show`), `listPaths` and `listTree` (both `git ls-files`, so `.gitignore` is honoured without a skip-list; in a folder that is not a git repo they, and `listTreeAll`, read the disk instead through `walkFolder` — realpath-confined, symlinks never followed, `.git` hidden, `node_modules` never entered, 20 000 entries at most). Read-only, argv-only through `worktree.js:git()`. An untracked file's patch is SYNTHESIZED from the file itself rather than fetched with `git diff --no-index`, which exits non-zero on a difference and would read as a failure. Three caps (300 files, 200 KB per file, 1 MB per full read) keep a lane mid-build from shipping megabytes of patch into a browser; a cap that bit sets `truncated` |
| `lib/lane-file-transfer.js` | (#78) The Files tab's download and upload. `openDownload` streams one working file of any type (up to 512 MB) behind the file read's guards (`lanePath`, `resolveInLane` realpath confinement, `isSecretOnDisk`, `openSafe` for a member's file); the route serves it as an `application/octet-stream` attachment under a `sandbox` CSP with `nosniff`. `saveUpload` only CREATES (`EEXISTS` for a taken name, a symlink included), into an existing folder, never under `.git`, never a secret name, at most 25 MB — under S3 as the folder's member through `write-as`, else with an exclusive `wx` create. Works in a folder that is not a git repo |
| `lib/git-ops.js` | (#34) The git WRITES a lane offers: `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`/`discard-hunk` (one hunk, matched by its `@@` line against a fresh diff — or one `block`, a run of +/- lines inside it, the others folded into context by `onlyBlock` in `git-diff.js` (#50); `discard-hunk` needs `confirm:true`), `commit` (everything, or `only:"staged"`), `switch`, `branch-create` (never switches) and `branch-delete` (`-d`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds), `publish` (`remote add origin -- <url>` + `push -u origin HEAD`, only when the repo has NO remote and a commit; the one write whose destination comes from the request, so the URL is allowlisted by `validateRemoteUrl` — `https://` without userinfo, `ssh://`, scp-like `user@host:path` — and `origin` is removed again when the push fails), and `merge`/`resolve`/`merge-commit`/`merge-abort` (issue #45: a named branch into a clean repo-root lane, conflicts resolved per file, abort behind `confirm:true`), `cherry-pick`/`revert` (a non-merge commit by sha, same guards and conflict flow — `merge-commit`/`merge-abort` finish whichever of the three is open, `operationInProgress` in worktree.js), and `undo-commit` (`reset --soft HEAD~1`, `confirm:true`) / `amend`, both refused once a remote-tracking branch holds HEAD (`EPUSHED`). A caller-supplied path is validated against `git status` before it reaches an argv, so only a path git already reports as changed is reachable; `discard` — the one irreversible action — additionally requires `confirm: true`. No rebase, hard reset or stash, because a dashboard button is the wrong place to start an operation whose recovery needs a terminal (a hard `reset` stays behind worktree.js's three-check destroy guard; `undo-commit`'s soft reset moves only an unpushed branch tip and keeps every change staged). `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request. Network actions carry a 60s timeout | | `lib/git-ops.js` | (#34) The git WRITES a lane offers: `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`/`discard-hunk` (one hunk, matched by its `@@` line against a fresh diff — or one `block`, a run of +/- lines inside it, the others folded into context by `onlyBlock` in `git-diff.js` (#50); `discard-hunk` needs `confirm:true`), `commit` (everything, or `only:"staged"`), `switch`, `branch-create` (never switches) and `branch-delete` (`-d`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds), `publish` (`remote add origin -- <url>` + `push -u origin HEAD`, only when the repo has NO remote and a commit; the one write whose destination comes from the request, so the URL is allowlisted by `validateRemoteUrl` — `https://` without userinfo, `ssh://`, scp-like `user@host:path` — and `origin` is removed again when the push fails), and `merge`/`resolve`/`merge-commit`/`merge-abort` (issue #45: a named branch into a clean repo-root lane, conflicts resolved per file, abort behind `confirm:true`), `cherry-pick`/`revert` (a non-merge commit by sha, same guards and conflict flow — `merge-commit`/`merge-abort` finish whichever of the three is open, `operationInProgress` in worktree.js), and `undo-commit` (`reset --soft HEAD~1`, `confirm:true`) / `amend`, both refused once a remote-tracking branch holds HEAD (`EPUSHED`). A caller-supplied path is validated against `git status` before it reaches an argv, so only a path git already reports as changed is reachable; `discard` — the one irreversible action — additionally requires `confirm: true`. No rebase, hard reset or stash, because a dashboard button is the wrong place to start an operation whose recovery needs a terminal (a hard `reset` stays behind worktree.js's three-check destroy guard; `undo-commit`'s soft reset moves only an unpushed branch tip and keeps every change staged). `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request. Network actions carry a 60s timeout |
| `lib/proof.js` | (C) Proof gallery: lists/serves/deletes QC screenshots grouped by feature slug (Task B's `lane_features`) and phase, under `<lane.cwd>/.playwright-mcp/proof/<slug>/<group>/`. Every path is resolved, realpath'd, and containment-checked before touching `fs` — the entire security surface. `ensureProofLink` ports Shipyard's `ensure_proof_link` (converge a stray clone-root `proof/` onto the canonical dir); never auto-invoked, exposed only as `agenthub lanes proof-link` | | `lib/proof.js` | (C) Proof gallery: lists/serves/deletes QC screenshots grouped by feature slug (Task B's `lane_features`) and phase, under `<lane.cwd>/.playwright-mcp/proof/<slug>/<group>/`. Every path is resolved, realpath'd, and containment-checked before touching `fs` — the entire security surface. `ensureProofLink` ports Shipyard's `ensure_proof_link` (converge a stray clone-root `proof/` onto the canonical dir); never auto-invoked, exposed only as `agenthub lanes proof-link` |
| `lib/named-lock.js` | (D) Cross-lane named locks — the OTHER axis from `lib/lane-lock.js`'s per-lane, in-process serialization, deliberately a separate module. `mkdir` is the atomicity primitive (EEXIST decides "already held" in one syscall, never check-then-create). `LOCK_MAX_HOLD` (default 2700s) breaks a stale holder on the next acquire, floored at 300s so the floor — not the configurable default — is the actual safety property: nothing can force-break a live holder by setting the env var low. Single-shot only; the CLI's `agenthub lock acquire` owns the polling loop, keeping the server side non-orchestrating like every other lane primitive | | `lib/named-lock.js` | (D) Cross-lane named locks — the OTHER axis from `lib/lane-lock.js`'s per-lane, in-process serialization, deliberately a separate module. `mkdir` is the atomicity primitive (EEXIST decides "already held" in one syscall, never check-then-create). `LOCK_MAX_HOLD` (default 2700s) breaks a stale holder on the next acquire, floored at 300s so the floor — not the configurable default — is the actual safety property: nothing can force-break a live holder by setting the env var low. Single-shot only; the CLI's `agenthub lock acquire` owns the polling loop, keeping the server side non-orchestrating like every other lane primitive |
@@ -757,6 +758,7 @@ erDiagram
TEXT metadata "JSON blob" TEXT metadata "JSON blob"
TEXT awaiting_input_since "ISO 8601 or NULL — set by waiting Notifications" TEXT awaiting_input_since "ISO 8601 or NULL — set by waiting Notifications"
TEXT awaiting_reason "notification|stop|session_start|interrupted or NULL" TEXT awaiting_reason "notification|stop|session_start|interrupted or NULL"
TEXT pinned_at "ISO 8601 or NULL — pinned to the top of its workspace's sidebar list"
TEXT source "Data source — 'local' or a remote source id; NOT NULL DEFAULT 'local'" TEXT source "Data source — 'local' or a remote source id; NOT NULL DEFAULT 'local'"
} }
+3 -2
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -52,7 +52,7 @@ The client is a single-page application (SPA) built with modern web technologies
- **React 18.3** - Component-based UI with hooks and concurrent features - **React 18.3** - Component-based UI with hooks and concurrent features
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts - **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds - **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount. The workspace terminal's font is the same kind of per-browser preference: `agenthub.terminal.font` = `jetbrains|maple|system` (`lib/terminalFont.ts`, default `jetbrains`), picked in Settings → Appearance and read when a terminal opens - **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount. The workspace terminal's font is the same kind of preference: `agenthub.terminal.font` = `jetbrains|maple|system` (`lib/terminalFont.ts`, default `jetbrains`) and `agenthub.terminal.fontSize` = 11|12|13|14|15|16|18|20 px (`lib/fontSize.ts`, default 13), picked in Settings → Appearance; a change fires `agenthub-font-size` on `window`, so an open terminal re-fits at once. Settings → Appearance → **Text size** (`#text-size`) sets `agenthub.ui.fontSize` = 14|16|17|18 (Small/Default/Large/Larger), the ROOT font size in px (`lib/fontSize.ts`; the index.html script applies it before first paint too); Tailwind's sizes are rem and `index.css` re-states every `text-[Npx]` class in rem, so all text scales with it. All of these keys are synced to the account like the theme (`PREF_KEYS` in `lib/userPrefs.ts` and `server/routes/me.js`)
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#eceef2`) and a `.card` is brighter than the page it sits on (`surface-3`, `#fdfdfe`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step. **The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#eceef2`) and a `.card` is brighter than the page it sits on (`surface-3`, `#fdfdfe`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
+5 -1
View File
@@ -2,7 +2,8 @@
<html lang="en" class="dark"> <html lang="en" class="dark">
<head> <head>
<!-- Apply the saved appearance before first paint (same keys as <!-- Apply the saved appearance before first paint (same keys as
src/hooks/useTheme.ts, which re-applies and validates on mount). --> src/hooks/useTheme.ts, which re-applies and validates on mount, and
src/lib/fontSize.ts). -->
<script> <script>
try { try {
var m = localStorage.getItem("theme"); var m = localStorage.getItem("theme");
@@ -17,6 +18,9 @@
r.classList.toggle("dark", d); r.classList.toggle("dark", d);
r.setAttribute("data-mode", d ? "dark" : "light"); r.setAttribute("data-mode", d ? "dark" : "light");
if (t && t !== "default") r.setAttribute("data-theme", t); if (t && t !== "default") r.setAttribute("data-theme", t);
// Text size (src/lib/fontSize.ts, UI_FONT_SIZES): the root font size.
var f = Number(localStorage.getItem("agenthub.ui.fontSize"));
if (f === 14 || f === 17 || f === 18) r.style.fontSize = f + "px";
} catch (e) {} } catch (e) {}
</script> </script>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
-42
View File
@@ -1204,9 +1204,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1362,9 +1359,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1379,9 +1373,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1396,9 +1387,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1413,9 +1401,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1430,9 +1415,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1447,9 +1429,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1464,9 +1443,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1481,9 +1457,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1498,9 +1471,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1515,9 +1485,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1532,9 +1499,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1549,9 +1513,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1566,9 +1527,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -154,7 +154,12 @@ describe("WorkspaceTree", () => {
expect(within(list).getByText("Live one")).toBeInTheDocument(); expect(within(list).getByText("Live one")).toBeInTheDocument();
// Its OWN sessions only — never the rest of its folder. // Its OWN sessions only — never the rest of its folder.
expect(api.sessions.list).toHaveBeenCalledTimes(1); expect(api.sessions.list).toHaveBeenCalledTimes(1);
expect(api.sessions.list).toHaveBeenCalledWith({ lane: 1, archived: false, limit: 6 }); expect(api.sessions.list).toHaveBeenCalledWith({
lane: 1,
archived: false,
pinnedFirst: true,
limit: 6,
});
expect(JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!).expanded).toEqual({ expect(JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!).expanded).toEqual({
"1": true, "1": true,
}); });
@@ -175,11 +180,21 @@ describe("WorkspaceTree", () => {
expect(within(list).getAllByTestId(/^workspace-session-s-\d+$/)).toHaveLength(6); expect(within(list).getAllByTestId(/^workspace-session-s-\d+$/)).toHaveLength(6);
expect(screen.getByTestId("workspace-sessions-more-1")).toHaveTextContent("See all (3 more)"); expect(screen.getByTestId("workspace-sessions-more-1")).toHaveTextContent("See all (3 more)");
await user.click(screen.getByTestId("workspace-sessions-more-1")); await user.click(screen.getByTestId("workspace-sessions-more-1"));
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 9 }); expect(api.sessions.list).toHaveBeenLastCalledWith({
lane: 1,
archived: false,
pinnedFirst: true,
limit: 9,
});
await screen.findByText("Session 8"); await screen.findByText("Session 8");
expect(screen.queryByTestId("workspace-sessions-more-1")).toBeNull(); expect(screen.queryByTestId("workspace-sessions-more-1")).toBeNull();
await user.click(screen.getByTestId("workspace-sessions-less-1")); await user.click(screen.getByTestId("workspace-sessions-less-1"));
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 6 }); expect(api.sessions.list).toHaveBeenLastCalledWith({
lane: 1,
archived: false,
pinnedFirst: true,
limit: 6,
});
expect(await screen.findByTestId("workspace-sessions-more-1")).toBeInTheDocument(); expect(await screen.findByTestId("workspace-sessions-more-1")).toBeInTheDocument();
expect(screen.queryByTestId("workspace-sessions-less-1")).toBeNull(); expect(screen.queryByTestId("workspace-sessions-less-1")).toBeNull();
}); });
@@ -197,6 +212,38 @@ describe("WorkspaceTree", () => {
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true }); expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
}); });
it("pins a session above the live ones, and unpins it from its row or menu", async () => {
vi.mocked(api.sessions.list).mockImplementation(async () => ({
sessions: [
session("s-live", "Live one"),
{ ...session("s-old", "Old one"), pinned_at: "2026-10-01T00:00:00Z" } as Session,
],
total: 2,
limit: 20,
offset: 0,
}));
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-toggle-1"));
const list = await screen.findByTestId("workspace-sessions-1");
await screen.findByTestId("workspace-session-pinned-s-old");
expect(
within(list)
.getAllByTestId(/^workspace-session-s-/)
.map((el) => el.dataset.testid)
).toEqual(["workspace-session-s-old", "workspace-session-s-live"]);
await user.click(screen.getByTestId("workspace-session-pin-s-old"));
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { pinned: false });
await user.click(screen.getByTestId("workspace-session-pin-s-live"));
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { pinned: true });
await user.pointer({
keys: "[MouseRight]",
target: screen.getByTestId("workspace-session-s-old"),
});
await user.click(within(screen.getByTestId("session-menu-s-old")).getByText("Unpin"));
expect(api.sessions.patch).toHaveBeenLastCalledWith("s-old", { pinned: false });
});
it("right-click on a session opens its menu: split, rename, archive, close its tab", async () => { it("right-click on a session opens its menu: split, rename, archive, close its tab", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderTree(); renderTree();
+185 -7
View File
@@ -1,12 +1,15 @@
/** /**
* @file AddLaneModal.tsx * @file AddLaneModal.tsx
* @description The "+ Add lane" flow, in one of two modes chosen with a * @description The "+ Add lane" flow, in one of three modes chosen with a
* segmented toggle — "Repo" by default; a branch prefill (the git bar's "new * segmented toggle — "Repo" by default; a branch prefill (the git bar's "new
* workspace from this branch") opens on "Worktree": "Repo" adopts an existing directory as-is via * workspace from this branch") opens on "Worktree": "Repo" adopts an existing directory as-is via
* `POST /api/lanes/ensure` (no worktree, no branch — the right mode for a * `POST /api/lanes/ensure` (no worktree, no branch — the right mode for a
* main repo you want stage detection on); "Worktree" provisions a * main repo you want stage detection on); "Worktree" provisions a
* dashboard-managed git worktree via `POST /api/lanes/worktree` with a * dashboard-managed git worktree via `POST /api/lanes/worktree` with a
* manually-typed branch name. The user also picks which CLI drives the lane * manually-typed branch name; "Clone" runs `POST /api/lanes/clone` (an
* https/ssh URL cloned into a folder of the user's, with their OWN SSH key or
* HTTPS login — a 409 `ECREDENTIALS` opens a login row, `ESSHAUTH` points to
* Settings → Git). The user also picks which CLI drives the lane
* (Claude Code or Codex) — this choice is immutable after creation. Either * (Claude Code or Codex) — this choice is immutable after creation. Either
* mode's path field can be filled by typing, by the CwdAutocomplete * mode's path field can be filled by typing, by the CwdAutocomplete
* suggestions, or by Browse (`PathPickButton`: the OS's own folder picker * suggestions, or by Browse (`PathPickButton`: the OS's own folder picker
@@ -48,6 +51,17 @@ async function waitForProvisioned(
} }
} }
/** The folder `git clone <url>` would make: the last path segment, no `.git`. */
export function cloneDirName(url: string): string {
return (
url
.trim()
.replace(/[/:]+$/, "")
.split(/[/:]/)
.pop() || ""
).replace(/\.git$/i, "");
}
/** One segment of a two-way inline choice, styled to match RunSetup's `Seg`. */ /** One segment of a two-way inline choice, styled to match RunSetup's `Seg`. */
function Seg({ function Seg({
active, active,
@@ -97,7 +111,18 @@ export function AddLaneModal({
cwdSuggestions: CwdSuggestion[]; cwdSuggestions: CwdSuggestion[];
}) { }) {
const { t } = useTranslation(["lanes"]); const { t } = useTranslation(["lanes"]);
const [mode, setMode] = useState<"repo" | "worktree">("repo"); const [mode, setMode] = useState<"repo" | "worktree" | "clone">("repo");
// Clone mode: the URL, the folder name (blank = the URL's), and — once the
// server asked for one — a login for the URL's https host.
const [cloneUrl, setCloneUrl] = useState("");
const [cloneName, setCloneName] = useState("");
const [login, setLogin] = useState<{
host: string;
username: string;
token: string;
remember: boolean;
} | null>(null);
const [sshRefused, setSshRefused] = useState(false);
const [sourceRepo, setSourceRepo] = useState(""); const [sourceRepo, setSourceRepo] = useState("");
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [branch, setBranch] = useState(""); const [branch, setBranch] = useState("");
@@ -141,6 +166,10 @@ export function AddLaneModal({
const reset = () => { const reset = () => {
setMode("repo"); setMode("repo");
setCloneUrl("");
setCloneName("");
setLogin(null);
setSshRefused(false);
setSourceRepo(""); setSourceRepo("");
setTitle(""); setTitle("");
setBranch(""); setBranch("");
@@ -213,6 +242,52 @@ export function AddLaneModal({
setBusy(true); setBusy(true);
setError(null); setError(null);
if (mode === "clone") {
const url = cloneUrl.trim();
if (!url) return;
setSshRefused(false);
try {
const result = await api.lanes.clone({
url,
parent: repo,
name: cloneName.trim() || undefined,
title: name || undefined,
tool,
...(login?.username.trim() && login.token
? {
credentials: {
username: login.username.trim(),
password: login.token,
remember: login.remember,
},
}
: {}),
});
onAdded(result.lane);
reset();
onClose();
} catch (err) {
const e = err as { code?: string; auth?: { host?: string }; message?: string };
const host = e.auth?.host || url;
if (e.code === "ECREDENTIALS") {
setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
setLogin((l) => ({
host,
username: l?.username ?? "",
token: "",
remember: l?.remember ?? true,
}));
} else if (e.code === "ESSHAUTH") {
setSshRefused(true);
setError(t("clone.sshRefused", { host }));
} else {
setError(err instanceof Error ? err.message : String(err));
}
setBusy(false);
}
return;
}
if (mode === "repo") { if (mode === "repo") {
try { try {
const result = await api.lanes.ensure({ const result = await api.lanes.ensure({
@@ -337,6 +412,8 @@ export function AddLaneModal({
const disabled = const disabled =
!done && !done &&
(!sourceRepo.trim() || (!sourceRepo.trim() ||
(mode === "clone" &&
(!cloneUrl.trim() || (!!login && (!login.username.trim() || !login.token)))) ||
(mode === "worktree" && (mode === "worktree" &&
(!title.trim() || !branches?.length || (branchMode === "existing" && !branch.trim())))); (!title.trim() || !branches?.length || (branchMode === "existing" && !branch.trim()))));
@@ -393,10 +470,38 @@ export function AddLaneModal({
title={t("mode.worktreeHint")} title={t("mode.worktreeHint")}
onClick={() => setMode("worktree")} onClick={() => setMode("worktree")}
/> />
<Seg
active={mode === "clone"}
label={t("mode.clone")}
title={t("mode.cloneHint")}
onClick={() => setMode("clone")}
/>
</div> </div>
{mode === "clone" && (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-url">
{t("clone.urlLabel")}
</label>
<input
id="add-lane-url"
value={cloneUrl}
onChange={(e) => {
setCloneUrl(e.target.value);
// A login belongs to the host it was asked for.
setLogin(null);
setSshRefused(false);
}}
placeholder="https://github.com/owner/repo.git"
spellCheck={false}
autoComplete="off"
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<p className="mt-1 text-[10px] text-fg-muted">{t("clone.urlHint")}</p>
</div>
)}
<div> <div>
<label className="mb-1 block text-xs text-fg-secondary">{t("addLaneToolHint")}</label>
{noTools ? ( {noTools ? (
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning"> <p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
{t("addLaneNoTools")}{" "} {t("addLaneNoTools")}{" "}
@@ -457,7 +562,11 @@ export function AddLaneModal({
<div> <div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo"> <label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")} {mode === "repo"
? t("addLaneRepoLabelAdopt")
: mode === "clone"
? t("clone.parentLabel")
: t("addLaneRepoLabel")}
</label> </label>
<div className="flex gap-1.5" data-tour="add-lane-folder"> <div className="flex gap-1.5" data-tour="add-lane-folder">
{/* A click into the path only focuses it — the picker opens from {/* A click into the path only focuses it — the picker opens from
@@ -473,10 +582,67 @@ export function AddLaneModal({
<PathPickButton start={sourceRepo} onPick={setSourceRepo} showLabel /> <PathPickButton start={sourceRepo} onPick={setSourceRepo} showLabel />
</div> </div>
<p className="mt-1 text-[10px] text-fg-muted"> <p className="mt-1 text-[10px] text-fg-muted">
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")} {mode === "clone"
? t("clone.parentHint", {
path: `${sourceRepo.trim().replace(/[\\/]+$/, "") || "…"}/${
cloneName.trim() || cloneDirName(cloneUrl) || "…"
}`,
})
: mode === "repo"
? t("addLaneRepoHintAdopt")
: t("addLaneRepoHint")}
</p> </p>
</div> </div>
{mode === "clone" && (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-clone-name">
{t("clone.nameLabel")}
</label>
<input
id="add-lane-clone-name"
value={cloneName}
onChange={(e) => setCloneName(e.target.value)}
placeholder={cloneDirName(cloneUrl)}
spellCheck={false}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
</div>
)}
{mode === "clone" && login && (
<div
data-testid="add-lane-clone-login"
className="grid gap-2 rounded-md border border-border-light bg-surface-0 p-2 sm:grid-cols-2"
>
<input
aria-label={t("clone.username")}
placeholder={t("clone.username")}
autoComplete="off"
value={login.username}
onChange={(e) => setLogin({ ...login, username: e.target.value })}
className="rounded-md border border-border-light bg-surface-1 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<input
type="password"
aria-label={t("clone.token")}
placeholder={t("clone.token")}
autoComplete="new-password"
value={login.token}
onChange={(e) => setLogin({ ...login, token: e.target.value })}
className="rounded-md border border-border-light bg-surface-1 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary sm:col-span-2">
<input
type="checkbox"
checked={login.remember}
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
/>
{t("clone.remember")}
</label>
</div>
)}
{/* What this repo already has checked out. Without it the form asked {/* What this repo already has checked out. Without it the form asked
for a branch in the dark: the one fact that decides whether a for a branch in the dark: the one fact that decides whether a
branch can be used at all is which worktree is holding it. */} branch can be used at all is which worktree is holding it. */}
@@ -690,6 +856,14 @@ export function AddLaneModal({
{error && ( {error && (
<p role="alert" className="text-xs text-status-danger"> <p role="alert" className="text-xs text-status-danger">
{error} {error}
{sshRefused && (
<>
{" "}
<a href="/settings/git#git-identity" className="underline">
{t("clone.sshSettings")}
</a>
</>
)}
</p> </p>
)} )}
</div> </div>
@@ -710,7 +884,11 @@ export function AddLaneModal({
data-tour="add-lane-create" data-tour="add-lane-create"
className="btn-primary text-xs disabled:opacity-50" className="btn-primary text-xs disabled:opacity-50"
> >
{done ? t("addLaneSetupClose") : t("add")} {done
? t("addLaneSetupClose")
: busy && mode === "clone"
? t("clone.cloning")
: t("add")}
</button> </button>
</div> </div>
</div> </div>
@@ -87,6 +87,7 @@ import {
ChevronsDownUp, ChevronsDownUp,
ChevronsUpDown, ChevronsUpDown,
Check, Check,
Download,
Code2, Code2,
Columns2, Columns2,
Eye, Eye,
@@ -101,6 +102,7 @@ import {
Sparkles, Sparkles,
Trash2, Trash2,
UnfoldVertical, UnfoldVertical,
Upload,
} from "lucide-react"; } from "lucide-react";
import { api, gitTarget } from "../../lib/api"; import { api, gitTarget } from "../../lib/api";
import { pasteToLane } from "../../lib/panePaste"; import { pasteToLane } from "../../lib/panePaste";
@@ -939,6 +941,13 @@ export function LaneChangesPanel({
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
// The commit row amends the last commit instead of making a new one. // The commit row amends the last commit instead of making a new one.
const [amend, setAmend] = useState(false); const [amend, setAmend] = useState(false);
// The last commit is already on its upstream: the server refuses to amend
// it (EPUSHED), so the box is not offered — asking first and failing after
// the confirm only costs the user a click.
const headPushed = !!git?.available && !!git.upstream && git.ahead === 0 && !!git.head;
useEffect(() => {
if (headPushed) setAmend(false);
}, [headPushed]);
// The files the commit takes (JetBrains' checkboxes). A path listed for the // The files the commit takes (JetBrains' checkboxes). A path listed for the
// first time arrives with its default; `seenPaths` is the last full list, // first time arrives with its default; `seenPaths` is the last full list,
// so a poll never re-ticks a file the user unticked. // so a poll never re-ticks a file the user unticked.
@@ -1614,8 +1623,22 @@ export function LaneChangesPanel({
<MenuItem onSelect={pick(treeCtx.close, () => showHistory(treeCtx.menu!.item.path))}> <MenuItem onSelect={pick(treeCtx.close, () => showHistory(treeCtx.menu!.item.path))}>
{t("changes.showHistory")} {t("changes.showHistory")}
</MenuItem> </MenuItem>
<MenuItem
testId="lane-changes-menu-download"
onSelect={pick(treeCtx.close, () => download(treeCtx.menu!.item.path))}
>
{t("changes.download")}
</MenuItem>
</> </>
)} )}
{treeCtx.menu.item.dir && (
<MenuItem
testId="lane-changes-menu-upload"
onSelect={pick(treeCtx.close, () => pickUpload(treeCtx.menu!.item.path))}
>
{t("changes.uploadHere")}
</MenuItem>
)}
<MenuItem onSelect={pick(treeCtx.close, () => copy(treeCtx.menu!.item.path))}> <MenuItem onSelect={pick(treeCtx.close, () => copy(treeCtx.menu!.item.path))}>
{t("changes.copyPath")} {t("changes.copyPath")}
</MenuItem> </MenuItem>
@@ -1861,6 +1884,57 @@ export function LaneChangesPanel({
}; };
const [rereading, runReread] = useSpin(rereadAll); const [rereading, runReread] = useSpin(rereadAll);
// Files tab transfers (issue #78): a download is a plain attachment link; an
// upload only creates, into the folder it was asked for ("" = the root).
const uploadInput = useRef<HTMLInputElement>(null);
const uploadDir = useRef("");
const [uploading, setUploading] = useState(false);
const download = (path: string) => {
const a = document.createElement("a");
a.href = api.lanes.gitDownloadUrl(target, path);
a.download = path.split("/").pop() || path;
a.click();
};
const pickUpload = (dir: string) => {
uploadDir.current = dir;
uploadInput.current?.click();
};
const upload = async (files: File[]) => {
if (!files.length) return;
const lane = laneId;
const dir = uploadDir.current;
setUploading(true);
let saved = 0;
try {
for (const f of files) {
try {
await api.lanes.gitUpload(target, dir, f);
saved++;
} catch (err) {
toast.error(
t("changes.uploadFailed", {
name: f.name,
message: err instanceof Error ? err.message : String(err),
})
);
}
}
} finally {
if (laneNow.current === lane) {
setUploading(false);
if (saved) {
toast.success(t("changes.uploaded", { count: saved, dir: dir || "/" }));
// Re-read the level it landed in, and open it.
void api.lanes
.gitTree(target, dir)
.then(forLane(lane, (r) => setTreeChildren((prev) => ({ ...prev, [dir]: r.entries }))))
.catch(() => undefined);
if (dir) setExpandedDirs((prev) => new Set(prev).add(dir));
}
}
}
};
const toggleCollapsed = (path: string) => const toggleCollapsed = (path: string) =>
setCollapsed((prev) => { setCollapsed((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -2047,6 +2121,21 @@ export function LaneChangesPanel({
aria-label={t("changes.quickOpen")} aria-label={t("changes.quickOpen")}
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary" className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/> />
<IconButton label={t("changes.upload")} disabled={uploading} onClick={() => pickUpload("")}>
<Upload className="h-3.5 w-3.5" />
</IconButton>
<input
ref={uploadInput}
type="file"
multiple
hidden
data-testid="lane-changes-upload-input"
onChange={(e) => {
const files = [...(e.target.files ?? [])];
e.target.value = "";
void upload(files);
}}
/>
{!hits && ( {!hits && (
<IconButton <IconButton
label={ label={
@@ -2566,13 +2655,16 @@ export function LaneChangesPanel({
</div> </div>
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
<label <label
title={t("changes.amendHint")} title={headPushed ? t("changes.amendPushed") : t("changes.amendHint")}
className="flex shrink-0 cursor-pointer items-center gap-1 text-[11px] text-fg-secondary" className={`flex shrink-0 items-center gap-1 text-[11px] text-fg-secondary ${
headPushed ? "cursor-not-allowed opacity-50" : "cursor-pointer"
}`}
> >
<input <input
type="checkbox" type="checkbox"
data-testid="lane-changes-amend" data-testid="lane-changes-amend"
checked={amend} checked={amend}
disabled={headPushed}
onChange={(e) => setAmend(e.target.checked)} onChange={(e) => setAmend(e.target.checked)}
/> />
{t("changes.amend")} {t("changes.amend")}
@@ -2758,6 +2850,18 @@ export function LaneChangesPanel({
{mdPreview ? t("changes.raw") : t("changes.preview")} {mdPreview ? t("changes.raw") : t("changes.preview")}
</button> </button>
)} )}
{range !== "commit" && openFile && (
<button
type="button"
data-testid="lane-file-download"
onClick={() => download(openFile)}
title={t("changes.download")}
className="flex shrink-0 items-center gap-1 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
>
<Download className="h-3 w-3" />
{t("changes.download")}
</button>
)}
{/* Only the working file is editable: a commit is history. */} {/* Only the working file is editable: a commit is history. */}
{/* A working file opens in the editor; the read-only diff {/* A working file opens in the editor; the read-only diff
(Whole file off) keeps an Edit button back into it. */} (Whole file off) keeps an Edit button back into it. */}
+18 -3
View File
@@ -242,13 +242,27 @@ export function LaneGitBar({
void run(action, body); void run(action, body);
} }
}; };
const push = () => const push = async () => {
confirmRun( let ahead = git.ahead ?? 0;
if (git.upstream && ahead === 0) {
// The facts can be up to 30 s old (an agent may have just committed):
// ask git again before saying there is nothing to push — and never
// ask to confirm pushing zero commits.
const read = await api.lanes.git(gitTarget(laneId, session, source)).catch(() => null);
const fresh = read?.available ? read : null;
ahead = fresh?.ahead ?? 0;
if (fresh?.upstream && ahead === 0) {
toast.info(t("gitBar.nothingToPush", { upstream: fresh.upstream }));
onChanged();
return;
}
}
return confirmRun(
git.upstream git.upstream
? { ? {
title: t("gitBar.push"), title: t("gitBar.push"),
message: t("gitBar.pushConfirm", { message: t("gitBar.pushConfirm", {
count: git.ahead ?? 0, count: ahead,
branch: git.branch, branch: git.branch,
upstream: git.upstream, upstream: git.upstream,
}), }),
@@ -259,6 +273,7 @@ export function LaneGitBar({
}, },
"push" "push"
); );
};
const publish = () => const publish = () =>
confirmRun( confirmRun(
{ {
+9 -1
View File
@@ -4,7 +4,7 @@
* the sidebar's **New session** row). Every item is something the row already * the sidebar's **New session** row). Every item is something the row already
* does: open it the way its click does (a live pane attaches, an ended session * does: open it the way its click does (a live pane attaches, an ended session
* opens its conversation — never a resume), open a live one in a split, * opens its conversation — never a resume), open a live one in a split,
* rename / archive / unarchive when the list offers them, copy its id, open * rename / pin / unpin / archive / unarchive when the list offers them, copy its id, open
* its analysis page, and close its live tab (`DELETE /api/run/:id`, what the * its analysis page, and close its live tab (`DELETE /api/run/:id`, what the
* tab's X does — after the same confirm for an AI tab, which may be mid-turn). * tab's X does — after the same confirm for an AI tab, which may be mid-turn).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -29,6 +29,7 @@ export function SessionMenu({
onRename, onRename,
onArchive, onArchive,
onUnarchive, onUnarchive,
onPin,
}: MenuPoint & { }: MenuPoint & {
laneId: number; laneId: number;
session: Session | null; session: Session | null;
@@ -38,6 +39,8 @@ export function SessionMenu({
onRename?: () => void; onRename?: () => void;
onArchive?: () => void; onArchive?: () => void;
onUnarchive?: () => void; onUnarchive?: () => void;
/** Pin or unpin (by `session.pinned_at`), where the list offers it. */
onPin?: () => void;
}) { }) {
const { t } = useTranslation("lanes"); const { t } = useTranslation("lanes");
const navigate = useNavigate(); const navigate = useNavigate();
@@ -84,6 +87,11 @@ export function SessionMenu({
</MenuItem> </MenuItem>
)} )}
{onRename && <MenuItem onSelect={pick(onRename)}>{t("menu.rename")}</MenuItem>} {onRename && <MenuItem onSelect={pick(onRename)}>{t("menu.rename")}</MenuItem>}
{onPin && session && (
<MenuItem onSelect={pick(onPin)}>
{t(session.pinned_at ? "sessionMenu.unpin" : "sessionMenu.pin")}
</MenuItem>
)}
{session && ( {session && (
<> <>
<MenuItem onSelect={pick(() => navigate(`/sessions/${session.id}`))}> <MenuItem onSelect={pick(() => navigate(`/sessions/${session.id}`))}>
@@ -1,12 +1,13 @@
/** /**
* @file WorkspaceSessionList — the sessions under an expanded workspace in the * @file WorkspaceSessionList — the sessions under an expanded workspace in the
* sidebar: the ones the workspace OWNS and has not archived * sidebar: the ones the workspace OWNS and has not archived
* (`GET /api/sessions?lane=<id>&archived=0`, newest first, the ones with a live * (`GET /api/sessions?lane=<id>&archived=0&pinned_first=1`, newest first —
* pane above the ended ones) — never the other * pinned ones on top in pin order, then the ones with a live pane, then the
* ended ones; a pin is `PATCH {pinned}`, issue #80) — never the other
* sessions that ran in its folder. A row's dot * sessions that ran in its folder. A row's dot
* shows its state (`sessionDot`: live pane, needs you, error) and its right * shows its state (`sessionDot`: live pane, needs you, error) and its right
* edge the AI tool the session ran on (`sessions.tool`); double-click * edge the AI tool the session ran on (`sessions.tool`); double-click
* renames it and its hover button archives it (`PATCH {name}` / `{archived}`, * renames it, its hover buttons pin/unpin and archive it (`PATCH {name}` / `{archived}`,
* which deletes nothing); a right-click opens the same actions and more * which deletes nothing); a right-click opens the same actions and more
* (`SessionMenu`). A session with a LIVE pane (a `GET /api/run` handle * (`SessionMenu`). A session with a LIVE pane (a `GET /api/run` handle
* carrying its session id) opens that tmux pane on the Workspace page * carrying its session id) opens that tmux pane on the Workspace page
@@ -33,7 +34,7 @@
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { confirmDialog } from "../../lib/confirm"; import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys"; import { isSubmitEnter } from "../../lib/keys";
import { Archive, GitBranch, SquareTerminal } from "lucide-react"; import { Archive, GitBranch, Pin, PinOff, SquareTerminal } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { api, type RunHandle } from "../../lib/api"; import { api, type RunHandle } from "../../lib/api";
@@ -278,17 +279,21 @@ export function WorkspaceSessionList({
3000 3000
); );
const refreshKey = `${hookKey}:${version}`; const refreshKey = `${hookKey}:${version}`;
const own = useSessionPage({ lane: lane.id, archived: false, limit }, refreshKey); const own = useSessionPage(
{ lane: lane.id, archived: false, pinnedFirst: true, limit },
refreshKey
);
const sessions = own?.sessions ?? (own === null ? null : []); const sessions = own?.sessions ?? (own === null ? null : []);
const more = own ? Math.max(0, own.total - own.sessions.length) : 0; const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
const runFor = (s: Session) => liveRunFor(lane, liveRuns, s.id); const runFor = (s: Session) => liveRunFor(lane, liveRuns, s.id);
// Live sessions first, ended ones under them — each group stays newest // Pinned sessions first, then live ones, then ended ones — each group in
// first (the sort is stable over the server's order). // the server's order (pin order, else newest first; the sort is stable).
const rank = (s: Session) => (s.pinned_at ? 0 : runFor(s) ? 1 : 2);
const shown = (sessions ?? []) const shown = (sessions ?? [])
.filter((s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q)) .filter((s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q))
.sort((a, b) => Number(!!runFor(b)) - Number(!!runFor(a))); .sort((a, b) => rank(a) - rank(b));
// A live tab whose session has no row yet: shown as a New session at the // A live tab whose session has no row yet: shown as a New session at the
// top, like Claude Desktop, until its first hook brings the real row. Not // top, like Claude Desktop, until its first hook brings the real row. Not
// during a search — it has no name to match. // during a search — it has no name to match.
@@ -336,7 +341,10 @@ export function WorkspaceSessionList({
if (count !== null) onMatches?.(count); if (count !== null) onMatches?.(count);
}, [count, onMatches]); }, [count, onMatches]);
const patch = async (s: Session, body: { name?: string; archived?: boolean }) => { const patch = async (
s: Session,
body: { name?: string; archived?: boolean; pinned?: boolean }
) => {
setError(null); setError(null);
try { try {
await api.sessions.patch(s.id, body); await api.sessions.patch(s.id, body);
@@ -533,6 +541,13 @@ export function WorkspaceSessionList({
/> />
<span className="sr-only">{t(`tree.state.${sessionDotLabel(s, !!run)}`)}</span> <span className="sr-only">{t(`tree.state.${sessionDotLabel(s, !!run)}`)}</span>
<span className="truncate">{s.name || s.id.slice(0, 8)}</span> <span className="truncate">{s.name || s.id.slice(0, 8)}</span>
{s.pinned_at && (
<Pin
data-testid={`workspace-session-pinned-${s.id}`}
aria-label={t("tree.pinned")}
className="h-3 w-3 shrink-0 text-fg-muted"
/>
)}
{sessionWorktreeName(lane, s) && ( {sessionWorktreeName(lane, s) && (
<GitBranch <GitBranch
data-testid={`workspace-session-worktree-${s.id}`} data-testid={`workspace-session-worktree-${s.id}`}
@@ -540,7 +555,9 @@ export function WorkspaceSessionList({
className="h-3 w-3 shrink-0 text-fg-muted" className="h-3 w-3 shrink-0 text-fg-muted"
/> />
)} )}
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted"> <span
className={`ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted ${readOnly ? "" : "group-hover:invisible"}`}
>
{timeAgo(s.last_activity ?? s.started_at, true)} {timeAgo(s.last_activity ?? s.started_at, true)}
</span> </span>
<AiToolIcon <AiToolIcon
@@ -548,6 +565,22 @@ export function WorkspaceSessionList({
className={`h-3.5 w-3.5 shrink-0 !p-0 opacity-60 ${readOnly ? "" : "group-hover:invisible"}`} className={`h-3.5 w-3.5 shrink-0 !p-0 opacity-60 ${readOnly ? "" : "group-hover:invisible"}`}
/> />
</button> </button>
{!readOnly && (
<button
type="button"
data-testid={`workspace-session-pin-${s.id}`}
onClick={() => void patch(s, { pinned: !s.pinned_at })}
title={t(s.pinned_at ? "tree.unpinSession" : "tree.pinSession")}
aria-label={t(s.pinned_at ? "tree.unpinSession" : "tree.pinSession")}
className="absolute right-6 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
>
{s.pinned_at ? (
<PinOff className="h-3.5 w-3.5" />
) : (
<Pin className="h-3.5 w-3.5" />
)}
</button>
)}
{!readOnly && ( {!readOnly && (
<button <button
type="button" type="button"
@@ -608,6 +641,9 @@ export function WorkspaceSessionList({
onClose={sessionCtx.close} onClose={sessionCtx.close}
onRename={() => startRename(sessionCtx.menu!.item)} onRename={() => startRename(sessionCtx.menu!.item)}
onArchive={() => void archive(sessionCtx.menu!.item)} onArchive={() => void archive(sessionCtx.menu!.item)}
onPin={() =>
void patch(sessionCtx.menu!.item, { pinned: !sessionCtx.menu!.item.pinned_at })
}
/> />
)} )}
</> </>
@@ -25,6 +25,7 @@ vi.mock("../../../lib/api", () => ({
pipelines: vi.fn(), pipelines: vi.fn(),
worktree: vi.fn(), worktree: vi.fn(),
ensure: vi.fn(), ensure: vi.fn(),
clone: vi.fn(),
browse: vi.fn(), browse: vi.fn(),
pickPath: vi.fn(), pickPath: vi.fn(),
// The folder browser's Places list the user's workspaces. // The folder browser's Places list the user's workspaces.
@@ -156,6 +157,7 @@ beforeEach(() => {
vi.mocked(api.lanes.branches).mockReset(); vi.mocked(api.lanes.branches).mockReset();
vi.mocked(api.lanes.worktree).mockReset(); vi.mocked(api.lanes.worktree).mockReset();
vi.mocked(api.lanes.ensure).mockReset(); vi.mocked(api.lanes.ensure).mockReset();
vi.mocked(api.lanes.clone).mockReset();
vi.mocked(api.lanes.browse).mockReset(); vi.mocked(api.lanes.browse).mockReset();
vi.mocked(api.lanes.pickPath).mockReset(); vi.mocked(api.lanes.pickPath).mockReset();
// Provisioning finishes instantly by default - tests that care about the // Provisioning finishes instantly by default - tests that care about the
@@ -693,7 +695,7 @@ describe("AddLaneModal — tool selection", () => {
const picker = await screen.findByRole("button", { name: /Claude Code.*Default/ }); const picker = await screen.findByRole("button", { name: /Claude Code.*Default/ });
expect(picker).toHaveAttribute("aria-expanded", "false"); expect(picker).toHaveAttribute("aria-expanded", "false");
await user.click(picker); await user.click(picker);
const list = screen.getByRole("group", { name: /Which CLI drives this workspace/ }); const list = screen.getByRole("group", { name: "Default AI tool" });
expect( expect(
within(list) within(list)
.getAllByRole("button") .getAllByRole("button")
@@ -779,3 +781,76 @@ describe("AddLaneModal — working straight on main (no worktree)", () => {
); );
}); });
}); });
describe("AddLaneModal — Clone", () => {
async function fillClone(user: ReturnType<typeof userEvent.setup>, url: string) {
renderModal();
fireEvent.click(screen.getByRole("button", { name: "Clone" }));
const urlField = screen.getByLabelText("Repository URL");
await focusField(user, urlField);
await user.type(urlField, url);
await user.type(screen.getByLabelText("Clone into folder"), "/home/an/code");
}
it("clones into <folder>/<repo name> and reports the new workspace", async () => {
const onAdded = vi.fn();
vi.mocked(api.lanes.clone).mockResolvedValue({
lane: laneFixture({ id: 5 }),
created: true,
path: "/home/an/code/repo",
});
const user = userEvent.setup();
renderModal({ onAdded });
fireEvent.click(screen.getByRole("button", { name: "Clone" }));
const urlField = screen.getByLabelText("Repository URL");
await focusField(user, urlField);
await user.type(urlField, "git@github.com:owner/repo.git");
await user.type(screen.getByLabelText("Clone into folder"), "/home/an/code");
expect(screen.getByText(/cloned to \/home\/an\/code\/repo/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() =>
expect(api.lanes.clone).toHaveBeenCalledWith(
expect.objectContaining({ url: "git@github.com:owner/repo.git", parent: "/home/an/code" })
)
);
expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].credentials).toBeUndefined();
await waitFor(() => expect(onAdded).toHaveBeenCalled());
});
it("asks for a login on ECREDENTIALS and sends it on the retry", async () => {
vi.mocked(api.lanes.clone)
.mockRejectedValueOnce(
Object.assign(new Error("needs a login"), {
code: "ECREDENTIALS",
auth: { protocol: "https", host: "git.example.com" },
})
)
.mockResolvedValueOnce({ lane: laneFixture({ id: 6 }), created: true, path: "/x" });
const user = userEvent.setup();
await fillClone(user, "https://git.example.com/o/r.git");
await user.click(screen.getByRole("button", { name: "Add workspace" }));
const login = await screen.findByTestId("add-lane-clone-login");
expect(screen.getByRole("alert")).toHaveTextContent("git.example.com needs a login");
await user.type(within(login).getByLabelText("Username"), "an");
await user.type(within(login).getByLabelText("Password or access token"), "tok");
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() =>
expect(vi.mocked(api.lanes.clone).mock.calls[1]?.[0].credentials).toEqual({
username: "an",
password: "tok",
remember: true,
})
);
});
it("points to Settings → Git when the SSH key is refused", async () => {
vi.mocked(api.lanes.clone).mockRejectedValue(
Object.assign(new Error("refused"), { code: "ESSHAUTH" })
);
const user = userEvent.setup();
await fillClone(user, "git@github.com:o/r.git");
await user.click(screen.getByRole("button", { name: "Add workspace" }));
const link = await screen.findByRole("link", { name: "Open Settings → Git" });
expect(link).toHaveAttribute("href", "/settings/git#git-identity");
});
});
@@ -15,7 +15,7 @@
*/ */
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act, within } from "@testing-library/react"; import { render, screen, waitFor, act, within, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { LaneChangesPanel, gitChangeFor } from "../LaneChangesPanel"; import { LaneChangesPanel, gitChangeFor } from "../LaneChangesPanel";
import { confirmDialog, confirmDiscard } from "../../../lib/confirm"; import { confirmDialog, confirmDiscard } from "../../../lib/confirm";
@@ -75,6 +75,9 @@ vi.mock("../../../lib/api", () => ({
gitTree: vi.fn(), gitTree: vi.fn(),
gitTreeAll: vi.fn(), gitTreeAll: vi.fn(),
gitBlame: vi.fn(), gitBlame: vi.fn(),
gitDownloadUrl: (id: number, path: string) =>
`/api/lanes/${id}/git/download?path=${encodeURIComponent(path)}`,
gitUpload: vi.fn(),
}, },
}, },
})); }));
@@ -545,6 +548,44 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByTestId("lane-file-edit")).toBeNull(); expect(screen.queryByTestId("lane-file-edit")).toBeNull();
}); });
it("uploads files into a folder from the tree menu, and downloads a file (issue #78)", async () => {
vi.mocked(api.lanes.gitTree).mockImplementation(async (_t, path = "") => ({
available: true,
path,
entries:
path === "out"
? [{ name: "report.md", path: "out/report.md", dir: false }]
: [{ name: "out", path: "out", dir: true }],
truncated: false,
}));
vi.mocked(api.lanes.gitUpload).mockResolvedValue({ path: "out/data.csv" });
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar");
const input = within(sidebar).getByTestId("lane-changes-upload-input") as HTMLInputElement;
const picked = vi.spyOn(input, "click").mockImplementation(() => {});
await user.pointer({ keys: "[MouseRight]", target: await within(sidebar).findByText("out") });
await user.click(screen.getByTestId("lane-changes-menu-upload"));
expect(picked).toHaveBeenCalled();
const file = new File(["a,b\n"], "data.csv", { type: "text/csv" });
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => expect(api.lanes.gitUpload).toHaveBeenCalledWith(7, "out", file));
// The folder it landed in is re-read and opened.
expect(await within(sidebar).findByText("report.md")).toBeInTheDocument();
const clicked: string[] = [];
const anchor = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (
this: HTMLAnchorElement
) {
clicked.push(this.getAttribute("href") ?? "");
});
await user.pointer({ keys: "[MouseRight]", target: within(sidebar).getByText("report.md") });
await user.click(screen.getByTestId("lane-changes-menu-download"));
expect(clicked).toEqual(["/api/lanes/7/git/download?path=out%2Freport.md"]);
anchor.mockRestore();
picked.mockRestore();
});
it("at full width shows the tree as a sidebar and opens a file straight in the editor", async () => { it("at full width shows the tree as a sidebar and opens a file straight in the editor", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({ vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true, available: true,
@@ -21,7 +21,7 @@ import type { LaneGitFacts } from "../../../lib/types";
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) })); vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
vi.mock("../../../lib/api", () => ({ vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane), gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn() } }, api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn(), git: vi.fn() } },
})); }));
function facts(over: Partial<Extract<LaneGitFacts, { available: true }>> = {}): LaneGitFacts { function facts(over: Partial<Extract<LaneGitFacts, { available: true }>> = {}): LaneGitFacts {
@@ -164,6 +164,29 @@ describe("LaneGitBar", () => {
expect(api.lanes.gitAction).not.toHaveBeenCalled(); expect(api.lanes.gitAction).not.toHaveBeenCalled();
}); });
it("says there is nothing to push instead of asking to push zero commits", async () => {
const user = userEvent.setup();
vi.mocked(confirmDialog).mockClear();
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts({ ahead: 0 }));
render(<LaneGitBar laneId={3} git={facts({ ahead: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /^Push$/ }));
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledWith(3));
expect(confirmDialog).not.toHaveBeenCalled();
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("re-reads stale facts: a commit made since still gets the push confirm", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts({ ahead: 1 }));
render(<LaneGitBar laneId={3} git={facts({ ahead: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /^Push$/ }));
await waitFor(() =>
expect(confirmDialog).toHaveBeenLastCalledWith(
expect.objectContaining({ message: expect.stringContaining("Push 1 commit") })
)
);
});
it("calls a first push of a branch with no upstream 'Publish branch'", async () => { it("calls a first push of a branch with no upstream 'Publish branch'", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render( render(
+14 -1
View File
@@ -64,6 +64,7 @@ import { writeClipboard } from "../../lib/clipboard";
import { notePaneFocus, registerPane } from "../../lib/panePaste"; import { notePaneFocus, registerPane } from "../../lib/panePaste";
import { attachImeInput } from "../../lib/imeInput"; import { attachImeInput } from "../../lib/imeInput";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont"; import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
type DropState = "hint" | "paste" | "uploading" | "failed"; type DropState = "hint" | "paste" | "uploading" | "failed";
@@ -284,7 +285,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
// left margin of the rows below. That is the torn left edge of issue #33, // left margin of the rows below. That is the torn left edge of issue #33,
// and why it only showed up once content scrolled. // and why it only showed up once content scrolled.
const term = new Terminal({ const term = new Terminal({
fontSize: 13, fontSize: readTerminalFontSize(),
cursorBlink: true, cursorBlink: true,
fontFamily: TERMINAL_FONTS[readTerminalFont()], fontFamily: TERMINAL_FONTS[readTerminalFont()],
// A step heavier than stock, like the rest of the UI (main.tsx loads these weights). // A step heavier than stock, like the rest of the UI (main.tsx loads these weights).
@@ -576,6 +577,17 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
refit(); refit();
}; };
document.fonts?.addEventListener("loadingdone", onFontLoaded); document.fonts?.addEventListener("loadingdone", onFontLoaded);
// Settings → Appearance changed the terminal's font or size: an open
// pane takes it now, re-measured and re-fitted like a font landing.
const onFontPref = () => {
const size = readTerminalFontSize();
const family = TERMINAL_FONTS[readTerminalFont()];
if (term.options.fontSize === size && term.options.fontFamily?.trimEnd() === family) return;
term.options.fontSize = size;
term.options.fontFamily = family;
refit();
};
window.addEventListener(FONT_SIZE_EVENT, onFontPref);
return () => { return () => {
if (retryTimer) clearTimeout(retryTimer); if (retryTimer) clearTimeout(retryTimer);
@@ -583,6 +595,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
searchRef.current = null; searchRef.current = null;
resizeObserver.disconnect(); resizeObserver.disconnect();
document.fonts?.removeEventListener("loadingdone", onFontLoaded); document.fonts?.removeEventListener("loadingdone", onFontLoaded);
window.removeEventListener(FONT_SIZE_EVENT, onFontPref);
term.textarea?.removeEventListener("focus", reassert); term.textarea?.removeEventListener("focus", reassert);
detachIme(); detachIme();
resizeDisposable.dispose(); resizeDisposable.dispose();
@@ -1,10 +1,13 @@
/** /**
* @file AppearanceSettings.tsx * @file AppearanceSettings.tsx
* @description Settings → Appearance (`/settings/appearance`): the per-user * @description Settings → Appearance (`/settings/appearance`), grouped so
* Chat UI default (`ChatUiSettings`, `#chat-ui`), whether the session view's * one card holds one subject: **Text & fonts** (`#fonts`) — the interface
* tabs hold every workspace's sessions (`lib/workspaceTabs.ts`, * language (`#language`), the text size (`lib/fontSize.ts`, `#text-size`,
* `#workspace-tabs`), the interface language, the * issue #79), the terminal font and its size (`lib/terminalFont.ts`,
* terminal font (`lib/terminalFont.ts`, with a preview line), the * `#terminal-font`, with a preview line); **Sessions** (`#sessions-view`) —
* the per-user Chat UI default (`ChatUiSettings`, `#chat-ui`) and whether the
* session view's tabs hold every workspace's sessions (`lib/workspaceTabs.ts`,
* `#workspace-tabs`); then the
* Dark / Light / System mode switch and one named-theme picker per mode, and the * Dark / Light / System mode switch and one named-theme picker per mode, and the
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet * corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
* pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in * pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in
@@ -34,6 +37,15 @@ import {
type TerminalFont, type TerminalFont,
} from "../../lib/terminalFont"; } from "../../lib/terminalFont";
import { readAllTabs, writeAllTabs } from "../../lib/workspaceTabs"; import { readAllTabs, writeAllTabs } from "../../lib/workspaceTabs";
import {
TERMINAL_FONT_SIZES,
UI_FONT_SIZES,
readTerminalFontSize,
readUiFontSize,
writeTerminalFontSize,
writeUiFontSize,
type UiFontSize,
} from "../../lib/fontSize";
export const THEME_LABELS: Record<string, string> = { export const THEME_LABELS: Record<string, string> = {
darcula: "Darcula", darcula: "Darcula",
@@ -82,6 +94,8 @@ export function AppearanceSettings() {
const { t, i18n } = useTranslation("settings"); const { t, i18n } = useTranslation("settings");
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme(); const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
const [termFont, setTermFont] = useState(readTerminalFont); const [termFont, setTermFont] = useState(readTerminalFont);
const [termSize, setTermSize] = useState(readTerminalFontSize);
const [uiSize, setUiSize] = useState(readUiFontSize);
const [allTabs, setAllTabs] = useState(readAllTabs); const [allTabs, setAllTabs] = useState(readAllTabs);
const pickTermFont = (font: TerminalFont) => { const pickTermFont = (font: TerminalFont) => {
writeTerminalFont(font); writeTerminalFont(font);
@@ -103,31 +117,11 @@ export function AppearanceSettings() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<ChatUiSettings /> <SettingsSection id="fonts" title={t("fonts.title")} description={t("fonts.description")}>
<SettingsSection
id="workspace-tabs"
title={t("workspaceTabs.title")}
description={t("workspaceTabs.description")}
>
<SettingsSwitchRow
label={t("workspaceTabs.label")}
description={t("workspaceTabs.labelDesc")}
checked={allTabs}
onChange={(on) => {
writeAllTabs(on);
setAllTabs(on);
}}
/>
</SettingsSection>
<SettingsSection
id="language"
title={t("language.title")}
description={t("language.description")}
>
<SettingsRow <SettingsRow
id="language"
label={t("language.label")} label={t("language.label")}
description={t("language.description")}
control={ control={
<SettingsSegmented <SettingsSegmented
ariaLabel={t("language.label")} ariaLabel={t("language.label")}
@@ -140,16 +134,34 @@ export function AppearanceSettings() {
/> />
} }
/> />
</SettingsSection>
<SettingsSection
id="terminal-font"
title={t("terminalFont.title")}
description={t("terminalFont.description")}
>
<SettingsRow <SettingsRow
label={t("terminalFont.label")} id="text-size"
description={termFont === "system" ? t("terminalFont.systemNote") : undefined} label={t("textSize.label")}
description={t("textSize.description")}
control={
<SettingsSegmented
ariaLabel={t("textSize.label")}
value={String(uiSize)}
onChange={(v) => {
const size = Number(v) as UiFontSize;
writeUiFontSize(size);
setUiSize(size);
}}
options={UI_FONT_SIZES.map((size) => ({
value: String(size),
label: t(`textSize.names.${size}`),
}))}
/>
}
/>
<SettingsRow
id="terminal-font"
label={t("terminalFont.title")}
description={
termFont === "system"
? `${t("terminalFont.description")} ${t("terminalFont.systemNote")}`
: t("terminalFont.description")
}
control={ control={
// A dropdown: five font names overflow a segmented row. // A dropdown: five font names overflow a segmented row.
<Select<TerminalFont> <Select<TerminalFont>
@@ -163,17 +175,57 @@ export function AppearanceSettings() {
/> />
} }
/> />
<SettingsRow
label={t("terminalFont.sizeLabel")}
control={
<Select
label={t("terminalFont.sizeLabel")}
value={String(termSize)}
onChange={(v) => {
writeTerminalFontSize(Number(v));
setTermSize(Number(v));
}}
options={TERMINAL_FONT_SIZES.map((size) => ({
value: String(size),
label: `${size}px`,
}))}
/>
}
/>
<p <p
data-testid="terminal-font-preview" data-testid="terminal-font-preview"
aria-label={t("terminalFont.preview")} aria-label={t("terminalFont.preview")}
className="mt-2 rounded-md border border-border bg-surface-0 px-3 py-2 text-[13px] text-fg-primary" className="mt-2 rounded-md border border-border bg-surface-0 px-3 py-2 text-fg-primary"
// No ligatures: xterm draws one glyph per cell, so the terminal has none. // No ligatures: xterm draws one glyph per cell, so the terminal has none.
style={{ fontFamily: TERMINAL_FONTS[termFont], fontVariantLigatures: "none" }} // In px like xterm's cells: the interface's text size does not scale it.
style={{
fontFamily: TERMINAL_FONTS[termFont],
fontSize: termSize,
fontVariantLigatures: "none",
}}
> >
❯ Tiếng Việt: ấ ế ố ồ ủ ữ — const x = {"{ ok: true }"}; 0O il1| ❯ Tiếng Việt: ấ ế ố ồ ủ ữ — const x = {"{ ok: true }"}; 0O il1|
</p> </p>
</SettingsSection> </SettingsSection>
<SettingsSection
id="sessions-view"
title={t("sessionsView.title")}
description={t("sessionsView.description")}
>
<ChatUiSettings />
<SettingsSwitchRow
id="workspace-tabs"
label={t("workspaceTabs.label")}
description={t("workspaceTabs.labelDesc")}
checked={allTabs}
onChange={(on) => {
writeAllTabs(on);
setAllTabs(on);
}}
/>
</SettingsSection>
<section id="theme" className="card p-5 space-y-5 scroll-mt-24"> <section id="theme" className="card p-5 space-y-5 scroll-mt-24">
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3> <h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
<SettingsRow <SettingsRow
@@ -1,7 +1,8 @@
/** /**
* @file ChatUiSettings — Settings → Appearance → Chat UI: whether a Claude or * @file ChatUiSettings — Settings → Appearance → Sessions, the Chat UI row
* Codex session opens as a chat like Claude Desktop (`PaneChat`) instead of * (`#chat-ui`): whether a Claude or Codex session opens as a chat like Claude
* its terminal. Saved per user on the server (`lib/chatUi.ts`), so each * Desktop (`PaneChat`) instead of its terminal. A row, not a card: it sits in
* the Sessions section beside the workspace-tabs switch. Saved per user on the server (`lib/chatUi.ts`), so each
* account on a shared dashboard keeps its own choice on any browser. * account on a shared dashboard keeps its own choice on any browser.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -9,25 +10,22 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { saveChatUi, useChatUi } from "../../lib/chatUi"; import { saveChatUi, useChatUi } from "../../lib/chatUi";
import { toast } from "../../lib/toast"; import { toast } from "../../lib/toast";
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow"; import { SettingsSwitchRow } from "./SettingsRow";
export function ChatUiSettings() { export function ChatUiSettings() {
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
const chatUi = useChatUi(); const chatUi = useChatUi();
return ( return (
<SettingsSection id="chat-ui" title={t("chatUi.title")} description={t("chatUi.description")}> <SettingsSwitchRow
<SettingsSwitchRow id="chat-ui"
label={t("chatUi.label")} label={t("chatUi.label")}
description={t("chatUi.labelDesc")} description={t("chatUi.labelDesc")}
checked={chatUi} checked={chatUi}
onChange={(on) => onChange={(on) =>
saveChatUi(on).catch((err: unknown) => saveChatUi(on).catch((err: unknown) =>
toast.error( toast.error(t("chatUi.saveFailed", { message: err instanceof Error ? err.message : "" }))
t("chatUi.saveFailed", { message: err instanceof Error ? err.message : "" }) )
) }
) />
}
/>
</SettingsSection>
); );
} }
@@ -0,0 +1,341 @@
/**
* @file GitIdentitySettings.tsx
* @description Settings → Git → **Your git identity** (`/api/me/git`): the
* commit author (name + email in the account's global git config — prefilled
* from Settings → Account while unset, since a member's commits fail without
* one), the SSH
* key a clone (Add workspace → Clone) and the pushes from that clone use —
* make one, copy its public half to add to GitHub/GitLab, replace or delete
* it — and, for a user with their own Linux account (S3), their own HTTPS
* logins. The host account's HTTPS logins are the shared Git credentials
* table above it, so this lists them only for a member. The private key and
* every token are write-only: the server never returns them. A host identity
* seen by a non-admin is read-only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Copy, KeyRound, Plus, RefreshCw, Trash2 } from "lucide-react";
import { api, type GitIdentity } from "../../lib/api";
import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
import { toast } from "../../lib/toast";
import { useAuth } from "../AuthGate";
const INPUT =
"w-full rounded border border-border-light bg-surface-0 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none";
const BTN =
"inline-flex items-center gap-1.5 rounded border border-border-light px-2.5 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-40";
export function GitIdentitySettings() {
const { t } = useTranslation("settings");
const [id, setId] = useState<GitIdentity | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [form, setForm] = useState<{ host: string; username: string; token: string } | null>(null);
const auth = useAuth().state;
const me = auth.mode === "server" ? auth.user : null;
// null = not edited: the field shows the saved value, else the account's.
const [author, setAuthor] = useState<{ name: string; email: string } | null>(null);
const load = useCallback(async () => {
try {
setId(await api.gitIdentity.get());
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
}, []);
useEffect(() => {
void load();
}, [load]);
const run = async (fn: () => Promise<GitIdentity>, done?: string) => {
setBusy(true);
setError(null);
try {
setId(await fn());
if (done) toast.success(done);
return true;
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
return false;
} finally {
setBusy(false);
}
};
const generate = async () => {
if (
id?.ssh &&
!(await confirmDialog({
title: t("gitIdentity.replaceConfirm"),
confirmLabel: t("gitIdentity.replace"),
}))
)
return;
await run(() => api.gitIdentity.generateKey(Boolean(id?.ssh)), t("gitIdentity.keyMade"));
};
const removeKey = async () => {
if (
!(await confirmDialog({
title: t("gitIdentity.removeKeyConfirm"),
confirmLabel: t("gitIdentity.remove"),
}))
)
return;
await run(() => api.gitIdentity.removeKey());
};
const saveLogin = async () => {
if (!form) return;
// A bare host is the usual entry; a pasted URL is reduced to its host.
let host = form.host.trim();
try {
if (/^https?:\/\//i.test(host)) host = new URL(host).host;
} catch {
/* the server answers EBADHOST */
}
const ok = await run(
() =>
api.gitIdentity.saveHttps({ host, username: form.username.trim(), password: form.token }),
t("gitCreds.saved", { host })
);
if (ok) setForm(null);
};
const removeLogin = async (host: string) => {
if (
!(await confirmDialog({
title: t("gitIdentity.removeLoginConfirm", { host }),
confirmLabel: t("gitIdentity.remove"),
}))
)
return;
await run(() => api.gitIdentity.removeHttps(host));
};
if (!id) {
return error ? (
<p className="text-xs text-status-danger">{error}</p>
) : (
<p className="text-xs text-fg-muted">{t("gitCreds.loading")}</p>
);
}
const member = id.account === "member";
const authorShown = author ?? {
name: id.author.name ?? me?.displayName ?? me?.username ?? "",
email: id.author.email ?? me?.email ?? "",
};
const authorUnset = !id.author.name || !id.author.email;
const authorDirty =
authorUnset ||
authorShown.name.trim() !== (id.author.name ?? "") ||
authorShown.email.trim() !== (id.author.email ?? "");
const saveAuthor = async () => {
const ok = await run(
() =>
api.gitIdentity.saveAuthor({
name: authorShown.name.trim(),
email: authorShown.email.trim(),
}),
t("gitIdentity.authorSaved")
);
if (ok) setAuthor(null);
};
return (
<div className="space-y-4">
<p className="max-w-2xl text-xs text-fg-muted">
{member ? t("gitIdentity.descriptionMember") : t("gitIdentity.descriptionHost")}
</p>
{error && (
<p className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger">
{error}
</p>
)}
<form
className="max-w-2xl space-y-2 rounded-lg border border-border p-3"
onSubmit={(e) => {
e.preventDefault();
void saveAuthor();
}}
>
<h3 className="text-xs font-semibold text-fg-primary">{t("gitIdentity.authorTitle")}</h3>
{authorUnset && (
<p className="text-[11px] text-status-warning">{t("gitIdentity.authorUnset")}</p>
)}
<div className="grid gap-2 sm:grid-cols-2">
<input
aria-label={t("gitIdentity.authorName")}
placeholder={t("gitIdentity.authorName")}
value={authorShown.name}
disabled={!id.writable}
onChange={(e) => setAuthor({ ...authorShown, name: e.target.value })}
className={INPUT}
/>
<input
type="email"
aria-label={t("gitIdentity.authorEmail")}
placeholder="name@example.com"
value={authorShown.email}
disabled={!id.writable}
onChange={(e) => setAuthor({ ...authorShown, email: e.target.value })}
className={INPUT}
/>
</div>
{id.writable && (
<button
type="submit"
disabled={busy || !authorDirty || !authorShown.name.trim() || !authorShown.email.trim()}
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
>
{t("gitCreds.save")}
</button>
)}
</form>
<div className="max-w-2xl space-y-2 rounded-lg border border-border p-3">
<div className="flex items-center gap-2">
<KeyRound className="h-4 w-4 text-fg-muted" aria-hidden />
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
{t("gitIdentity.sshTitle")}
</h3>
{id.writable && (
<button type="button" className={BTN} disabled={busy} onClick={() => void generate()}>
{id.ssh ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
{id.ssh ? t("gitIdentity.replace") : t("gitIdentity.generate")}
</button>
)}
</div>
{id.ssh ? (
<>
<textarea
readOnly
aria-label={t("gitIdentity.publicKey")}
value={id.ssh.publicKey}
rows={3}
onFocus={(e) => e.currentTarget.select()}
className={`${INPUT} resize-none break-all font-mono text-[11px]`}
/>
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
{id.ssh.fingerprint}
</span>
<button
type="button"
className={BTN}
onClick={() => void copyText(id.ssh?.publicKey ?? "")}
>
<Copy className="h-3.5 w-3.5" />
{t("gitIdentity.copy")}
</button>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
onClick={() => void removeKey()}
>
<Trash2 className="h-3.5 w-3.5" />
{t("gitIdentity.remove")}
</button>
)}
</div>
<p className="text-[10px] text-fg-muted">{t("gitIdentity.addKeyHint")}</p>
</>
) : (
<p className="text-[11px] text-fg-muted">{t("gitIdentity.noKey")}</p>
)}
</div>
{member && (
<div className="max-w-2xl space-y-2 rounded-lg border border-border p-3">
<div className="flex items-center gap-2">
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
{t("gitIdentity.httpsTitle")}
</h3>
<button
type="button"
className={BTN}
onClick={() => setForm({ host: "", username: "", token: "" })}
>
<Plus className="h-3.5 w-3.5" />
{t("gitCreds.add")}
</button>
</div>
{form && (
<form
className="grid gap-2 sm:grid-cols-3"
onSubmit={(e) => {
e.preventDefault();
void saveLogin();
}}
>
<input
aria-label={t("gitCreds.fieldHost")}
placeholder="github.com"
value={form.host}
onChange={(e) => setForm({ ...form, host: e.target.value })}
className={`${INPUT} font-mono`}
/>
<input
aria-label={t("gitCreds.fieldUser")}
placeholder={t("gitCreds.fieldUser")}
autoComplete="off"
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
className={INPUT}
/>
<input
type="password"
aria-label={t("gitCreds.fieldToken")}
placeholder={t("gitCreds.fieldToken")}
autoComplete="new-password"
value={form.token}
onChange={(e) => setForm({ ...form, token: e.target.value })}
className={INPUT}
/>
<div className="flex gap-2 sm:col-span-3">
<button
type="submit"
disabled={busy || !form.host.trim() || !form.username.trim() || !form.token}
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
>
{t("gitCreds.save")}
</button>
<button type="button" onClick={() => setForm(null)} className={BTN}>
{t("gitCreds.cancel")}
</button>
</div>
</form>
)}
{id.https.length === 0 ? (
<p className="text-[11px] text-fg-muted">{t("gitIdentity.noLogins")}</p>
) : (
<ul className="divide-y divide-border text-xs">
{id.https.map((c) => (
<li key={c.host} className="flex items-center gap-2 py-1.5">
<span className="font-mono text-[11px] text-fg-primary">{c.host}</span>
<span className="flex-1 text-fg-secondary">{c.username}</span>
<button
type="button"
aria-label={`${t("gitIdentity.remove")} ${c.host}`}
title={t("gitIdentity.remove")}
disabled={busy}
onClick={() => void removeLogin(c.host)}
className="rounded p-1 text-fg-muted hover:bg-surface-3 hover:text-status-danger"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
@@ -37,15 +37,19 @@ export function SettingsRow({
description, description,
control, control,
alignTop = false, alignTop = false,
id,
}: { }: {
label: ReactNode; label: ReactNode;
description?: ReactNode; description?: ReactNode;
control: ReactNode; control: ReactNode;
alignTop?: boolean; alignTop?: boolean;
/** A deep-link anchor (`#id`) for one row inside a grouped section. */
id?: string;
}) { }) {
return ( return (
<div <div
className={`flex justify-between gap-4 border-b border-border/60 last:border-b-0 ${ id={id}
className={`${id ? "scroll-mt-24 " : ""}flex justify-between gap-4 border-b border-border/60 last:border-b-0 ${
description ? "py-3" : "py-2" description ? "py-3" : "py-2"
} ${alignTop ? "items-start" : "items-center"}`} } ${alignTop ? "items-start" : "items-center"}`}
> >
@@ -64,15 +68,18 @@ export function SettingsSwitchRow({
checked, checked,
onChange, onChange,
disabled = false, disabled = false,
id,
}: { }: {
label: string; label: string;
description?: ReactNode; description?: ReactNode;
checked: boolean; checked: boolean;
onChange: (v: boolean) => void; onChange: (v: boolean) => void;
disabled?: boolean; disabled?: boolean;
id?: string;
}) { }) {
return ( return (
<SettingsRow <SettingsRow
id={id}
label={label} label={label}
description={description} description={description}
control={<Switch checked={checked} onChange={onChange} label={label} disabled={disabled} />} control={<Switch checked={checked} onChange={onChange} label={label} disabled={disabled} />}
@@ -1,6 +1,6 @@
/** /**
* @file AppearanceSettings.test.tsx * @file AppearanceSettings.test.tsx
* @description Tests for the Settings → Appearance section: the language switch, the terminal font picker, and the mode switch and the per-mode theme cards writing through useTheme to `<html>`. * @description Tests for the Settings → Appearance section: the language switch, the text size, the terminal font and size pickers, and the mode switch and the per-mode theme cards writing through useTheme to `<html>`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -20,6 +20,7 @@ describe("AppearanceSettings", () => {
root.classList.remove("dark"); root.classList.remove("dark");
root.removeAttribute("data-theme"); root.removeAttribute("data-theme");
root.removeAttribute("data-mode"); root.removeAttribute("data-mode");
root.style.removeProperty("font-size");
}); });
it("switches the interface language and holds the pet block", async () => { it("switches the interface language and holds the pet block", async () => {
@@ -102,4 +103,33 @@ describe("AppearanceSettings", () => {
expect(localStorage.getItem("agenthub.terminal.font")).toBe("system"); expect(localStorage.getItem("agenthub.terminal.font")).toBe("system");
expect(screen.getByText(/may lack some Vietnamese letters/)).toBeInTheDocument(); expect(screen.getByText(/may lack some Vietnamese letters/)).toBeInTheDocument();
}); });
it("sets the interface text size on <html>, the default leaving it unset", async () => {
const user = userEvent.setup();
render(<AppearanceSettings />);
const group = screen.getByRole("radiogroup", { name: "Interface text" });
expect(within(group).getByRole("radio", { name: "Default" })).toHaveAttribute(
"aria-checked",
"true"
);
await user.click(within(group).getByRole("radio", { name: "Larger" }));
expect(root.style.fontSize).toBe("18px");
expect(localStorage.getItem("agenthub.ui.fontSize")).toBe("18");
await user.click(within(group).getByRole("radio", { name: "Default" }));
expect(root.style.fontSize).toBe("");
});
it("picks the terminal font size and tells open terminals", async () => {
const user = userEvent.setup();
const heard = vi.fn();
window.addEventListener("agenthub-font-size", heard);
render(<AppearanceSettings />);
expect(screen.getByRole("button", { name: /^Size/ })).toHaveTextContent("13px");
await user.click(screen.getByRole("button", { name: /^Size/ }));
await user.click(screen.getByRole("option", { name: "16px" }));
expect(localStorage.getItem("agenthub.terminal.fontSize")).toBe("16");
expect(screen.getByTestId("terminal-font-preview").style.fontSize).toBe("16px");
expect(heard).toHaveBeenCalled();
window.removeEventListener("agenthub-font-size", heard);
});
}); });
@@ -0,0 +1,113 @@
/**
* @file Tests for Settings → Git → Your git identity: a user with no key
* makes one, a key shows only its public half and copies it, replacing asks
* first, and a member (S3) adds an HTTPS login of their own while the host's
* view leaves HTTPS to the shared Git credentials table.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { GitIdentitySettings } from "../GitIdentitySettings";
import { api, type GitIdentity } from "../../../lib/api";
import { confirmDialog } from "../../../lib/confirm";
import { copyText } from "../../../lib/clipboard";
vi.mock("../../../lib/api", () => ({
api: {
gitIdentity: {
get: vi.fn(),
generateKey: vi.fn(),
removeKey: vi.fn(),
saveHttps: vi.fn(),
saveAuthor: vi.fn(),
removeHttps: vi.fn(),
},
},
}));
vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(() => Promise.resolve(true)) }));
vi.mock("../../../lib/clipboard", () => ({ copyText: vi.fn(() => Promise.resolve(true)) }));
const KEY = { publicKey: "ssh-ed25519 AAAAC3Nz an@agenthub", fingerprint: "SHA256:abc" };
const view = (over: Partial<GitIdentity> = {}): GitIdentity => ({
account: "member",
writable: true,
ssh: null,
https: [],
author: { name: "An", email: "an@x.vn" },
...over,
});
beforeEach(() => {
vi.clearAllMocks();
});
describe("GitIdentitySettings", () => {
it("makes a key when there is none, without asking", async () => {
vi.mocked(api.gitIdentity.get).mockResolvedValue(view());
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(view({ ssh: KEY }));
render(<GitIdentitySettings />);
await userEvent.click(await screen.findByRole("button", { name: "Generate SSH key" }));
expect(confirmDialog).not.toHaveBeenCalled();
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false);
expect(await screen.findByLabelText("Public key")).toHaveValue(KEY.publicKey);
});
it("copies the public key and asks before replacing it", async () => {
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ ssh: KEY }));
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(view({ ssh: KEY }));
render(<GitIdentitySettings />);
await userEvent.click(await screen.findByRole("button", { name: "Copy public key" }));
expect(copyText).toHaveBeenCalledWith(KEY.publicKey);
await userEvent.click(screen.getByRole("button", { name: "Replace key" }));
expect(confirmDialog).toHaveBeenCalled();
await waitFor(() => expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(true));
});
it("lets a member save an https login of their own", async () => {
vi.mocked(api.gitIdentity.get).mockResolvedValue(view());
vi.mocked(api.gitIdentity.saveHttps).mockResolvedValue(
view({ https: [{ protocol: "https", host: "github.com", username: "an", hasToken: true }] })
);
render(<GitIdentitySettings />);
await userEvent.click(await screen.findByRole("button", { name: "Add login" }));
await userEvent.type(screen.getByLabelText("Server URL"), "https://github.com/o/r");
await userEvent.type(screen.getByLabelText("Username"), "an");
await userEvent.type(screen.getByLabelText("Password or access token"), "tok");
// The second Save: the first belongs to the commit-author form.
await userEvent.click(screen.getAllByRole("button", { name: "Save" })[1]!);
expect(api.gitIdentity.saveHttps).toHaveBeenCalledWith({
host: "github.com",
username: "an",
password: "tok",
});
expect(await screen.findByText("github.com")).toBeInTheDocument();
});
it("warns while the commit author is unset and saves it", async () => {
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ author: { name: null, email: null } }));
vi.mocked(api.gitIdentity.saveAuthor).mockResolvedValue(view());
render(<GitIdentitySettings />);
expect(
await screen.findByText(/commits from the dashboard and your agents fail/)
).toBeInTheDocument();
await userEvent.type(screen.getByLabelText("Name"), "An Nguyen");
await userEvent.type(screen.getByLabelText("Email"), "an@x.vn");
await userEvent.click(screen.getAllByRole("button", { name: "Save" })[0]!);
expect(api.gitIdentity.saveAuthor).toHaveBeenCalledWith({
name: "An Nguyen",
email: "an@x.vn",
});
});
it("shows the host's key read-only and no https list", async () => {
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({ account: "host", writable: false, ssh: KEY })
);
render(<GitIdentitySettings />);
expect(await screen.findByLabelText("Public key")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Replace key" })).toBeNull();
expect(screen.queryByRole("button", { name: "Add login" })).toBeNull();
});
});
+29 -1
View File
@@ -34,7 +34,7 @@
"addLaneToolDefaultBadge": "Default", "addLaneToolDefaultBadge": "Default",
"addLaneNoTools": "No AI tool is installed and enabled.", "addLaneNoTools": "No AI tool is installed and enabled.",
"addLaneNoToolsLink": "Open Settings → AI Tools", "addLaneNoToolsLink": "Open Settings → AI Tools",
"addLaneToolHint": "Which CLI drives this workspace. Cannot be changed later.", "addLaneToolHint": "Default AI tool",
"addLaneWorktreeDetached": "(detached)", "addLaneWorktreeDetached": "(detached)",
"addLaneWorktreesMore": "+{{count}} more", "addLaneWorktreesMore": "+{{count}} more",
"addLaneWorktreesTitle": "Worktrees of this repo ({{count}})", "addLaneWorktreesTitle": "Worktrees of this repo ({{count}})",
@@ -84,6 +84,12 @@
"changes.expand": "Show the changes panel", "changes.expand": "Show the changes panel",
"changes.expandAll": "Expand every file", "changes.expandAll": "Expand every file",
"changes.expandAllFolders": "Expand all folders", "changes.expandAllFolders": "Expand all folders",
"changes.download": "Download",
"changes.upload": "Upload files to the workspace root",
"changes.uploadHere": "Upload files here…",
"changes.uploaded_one": "Uploaded {{count}} file to {{dir}}",
"changes.uploaded_other": "Uploaded {{count}} files to {{dir}}",
"changes.uploadFailed": "Could not upload {{name}}: {{message}}",
"changes.expandContext": "Show more surrounding lines", "changes.expandContext": "Show more surrounding lines",
"changes.expandFile": "Expand {{path}}", "changes.expandFile": "Expand {{path}}",
"changes.fileGone": "That file no longer has changes in this range.", "changes.fileGone": "That file no longer has changes in this range.",
@@ -186,6 +192,7 @@
"gitBar.pull": "Pull", "gitBar.pull": "Pull",
"gitBar.pullHint": "git pull --ff-only — refuses instead of merging when the branch has diverged", "gitBar.pullHint": "git pull --ff-only — refuses instead of merging when the branch has diverged",
"gitBar.push": "Push", "gitBar.push": "Push",
"gitBar.nothingToPush": "Nothing to push — {{upstream}} already has every commit.",
"gitBar.pushHint": "git push -u origin HEAD", "gitBar.pushHint": "git push -u origin HEAD",
"gitBar.switch": "Branch", "gitBar.switch": "Branch",
"gitBar.switchApply": "Switch branch", "gitBar.switchApply": "Switch branch",
@@ -200,6 +207,21 @@
"mode.repoHint": "Adopt this directory as a workspace, as-is — no worktree, no new branch.", "mode.repoHint": "Adopt this directory as a workspace, as-is — no worktree, no new branch.",
"mode.worktree": "Worktree", "mode.worktree": "Worktree",
"mode.worktreeHint": "Provision a new git worktree + branch from a source repo.", "mode.worktreeHint": "Provision a new git worktree + branch from a source repo.",
"mode.clone": "Clone",
"mode.cloneHint": "Clone a repository over HTTPS or SSH into one of your folders, as a new workspace.",
"clone.urlLabel": "Repository URL",
"clone.urlHint": "https://host/owner/repo.git, git@host:owner/repo.git or ssh://… — SSH uses your own key (Settings → Git).",
"clone.parentLabel": "Clone into folder",
"clone.parentHint": "The repository is cloned to {{path}}",
"clone.nameLabel": "Folder name",
"clone.cloning": "Cloning…",
"clone.loginNeeded": "{{host}} needs a login: your username and a password or access token.",
"clone.loginRejected": "{{host}} rejected that username or token.",
"clone.username": "Username",
"clone.token": "Password or access token",
"clone.remember": "Remember this login for my account",
"clone.sshRefused": "{{host}} refused the SSH login. Add your public key to that host, or make one in Settings → Git.",
"clone.sshSettings": "Open Settings → Git",
"moreActions": "More actions", "moreActions": "More actions",
"pipelinePicker.label": "Pipeline template", "pipelinePicker.label": "Pipeline template",
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" matches no node in \"{{pipeline}}\" — declare one of: {{nodes}}", "pipelinePicker.stageMismatch": "Stage \"{{stage}}\" matches no node in \"{{pipeline}}\" — declare one of: {{nodes}}",
@@ -268,6 +290,7 @@
"changes.mergeAbortConfirm": "Click again to abort the {{op}}", "changes.mergeAbortConfirm": "Click again to abort the {{op}}",
"changes.discardShortConfirm": "Discard?", "changes.discardShortConfirm": "Discard?",
"changes.amend": "Amend", "changes.amend": "Amend",
"changes.amendPushed": "The last commit is already pushed; amending it would rewrite shared history. Revert it from History instead.",
"changes.amendHint": "Fold the ticked files into the last commit (only before it is pushed). Nothing ticked rewords it; leave the message empty to keep the old one.", "changes.amendHint": "Fold the ticked files into the last commit (only before it is pushed). Nothing ticked rewords it; leave the message empty to keep the old one.",
"changes.amendPlaceholder": "New message (empty = keep the old one)", "changes.amendPlaceholder": "New message (empty = keep the old one)",
"changes.historyOf": "History of branch", "changes.historyOf": "History of branch",
@@ -344,6 +367,9 @@
"tree.splitHint": "Live pane — Ctrl/Cmd+click opens it in a split beside the focused pane", "tree.splitHint": "Live pane — Ctrl/Cmd+click opens it in a split beside the focused pane",
"tree.renameSession": "Rename session", "tree.renameSession": "Rename session",
"tree.archiveSession": "Archive — hide from this workspace (deletes nothing)", "tree.archiveSession": "Archive — hide from this workspace (deletes nothing)",
"tree.pinSession": "Pin to the top of this workspace",
"tree.unpinSession": "Unpin",
"tree.pinned": "Pinned",
"tree.worktreeSession": "In its own worktree: .claude/worktrees/{{name}}", "tree.worktreeSession": "In its own worktree: .claude/worktrees/{{name}}",
"tree.archiveWorktreeTitle": "Archive and remove its worktree?", "tree.archiveWorktreeTitle": "Archive and remove its worktree?",
"tree.archiveWorktreeBody": "Like Claude Desktop, archiving removes the session's worktree .claude/worktrees/{{name}} and its branch. Git refuses if it holds uncommitted changes, and a branch with commits merged nowhere is kept — nothing unsaved is lost.", "tree.archiveWorktreeBody": "Like Claude Desktop, archiving removes the session's worktree .claude/worktrees/{{name}} and its branch. Git refuses if it holds uncommitted changes, and a branch with commits merged nowhere is kept — nothing unsaved is lost.",
@@ -423,6 +449,8 @@
"sessionMenu.details": "Session details", "sessionMenu.details": "Session details",
"sessionMenu.copyId": "Copy session ID", "sessionMenu.copyId": "Copy session ID",
"sessionMenu.archive": "Archive", "sessionMenu.archive": "Archive",
"sessionMenu.pin": "Pin",
"sessionMenu.unpin": "Unpin",
"panes.show": "Show {{name}} here", "panes.show": "Show {{name}} here",
"panes.splitNew.right": "Split right with a new terminal", "panes.splitNew.right": "Split right with a new terminal",
"panes.splitNew.bottom": "Split down with a new terminal", "panes.splitNew.bottom": "Split down with a new terminal",
+46 -2
View File
@@ -893,6 +893,30 @@
"pet": "Pet", "pet": "Pet",
"petDescription": "The corner mascot: show it, its size, speech bubbles, and which pet it wears." "petDescription": "The corner mascot: show it, its size, speech bubbles, and which pet it wears."
}, },
"gitIdentity": {
"authorTitle": "Commit author",
"authorName": "Name",
"authorEmail": "Email",
"authorUnset": "Not set yet: commits from the dashboard and your agents fail until a name and email are saved. Filled in from your account — check and save.",
"authorSaved": "Commit author saved.",
"title": "Your git identity",
"descriptionMember": "Your own SSH key and HTTPS logins, kept in your own home. Add workspace → Clone uses them, and the clone keeps them for its later fetch, pull and push.",
"descriptionHost": "The SSH key of the account this AgentHub runs as. Add workspace → Clone uses it, and the clone keeps it for its later fetch, pull and push. HTTPS logins for this account are the Git credentials above.",
"sshTitle": "SSH key",
"generate": "Generate SSH key",
"replace": "Replace key",
"replaceConfirm": "Replace the SSH key? Clones that use the old key stop working until the new public key is added to their host.",
"keyMade": "SSH key ready — add its public key to your git host.",
"remove": "Delete",
"removeKeyConfirm": "Delete this SSH key? Clones that use it can no longer fetch or push.",
"publicKey": "Public key",
"copy": "Copy public key",
"addKeyHint": "Add this public key to your account on the git host (GitHub: Settings → SSH and GPG keys; GitLab: Preferences → SSH Keys). The private key never leaves the server.",
"noKey": "No SSH key yet. Without one, an SSH clone uses the account's default ~/.ssh keys.",
"httpsTitle": "HTTPS logins",
"noLogins": "No HTTPS login saved. A clone asks for one when a host needs it.",
"removeLoginConfirm": "Delete the login for {{host}}? Clones from that host will ask for a login again."
},
"gitCreds": { "gitCreds": {
"title": "Git credentials", "title": "Git credentials",
"description": "One login per host, shared by everyone using this AgentHub. A token is never shown again after it is saved.", "description": "One login per host, shared by everyone using this AgentHub. A token is never shown again after it is saved.",
@@ -1061,6 +1085,14 @@
"agenthub": "AgentHub-managed worktrees", "agenthub": "AgentHub-managed worktrees",
"agenthubDesc": "Worktrees AgentHub created under its workspaces root." "agenthubDesc": "Worktrees AgentHub created under its workspaces root."
}, },
"fonts": {
"title": "Text & fonts",
"description": "Language, text size and the terminal's font. Saved to your account."
},
"sessionsView": {
"title": "Sessions",
"description": "How a new session opens and which sessions the session view's tab bar holds. Saved to your account."
},
"language": { "language": {
"title": "Language", "title": "Language",
"description": "The language of the whole dashboard, saved to your account.", "description": "The language of the whole dashboard, saved to your account.",
@@ -1076,9 +1108,20 @@
"label": "Tabs from every workspace", "label": "Tabs from every workspace",
"labelDesc": "Keep every workspace's live sessions as tabs, and mix them in one split. Focusing a tab selects its workspace. Off: only the selected workspace's sessions." "labelDesc": "Keep every workspace's live sessions as tabs, and mix them in one split. Focusing a tab selects its workspace. Off: only the selected workspace's sessions."
}, },
"textSize": {
"title": "Text size",
"description": "How large the dashboard's text is — menus, lists, chat and settings. Saved to your account. Ctrl + / Ctrl − in the browser still zooms everything on top of this.",
"label": "Interface text",
"names": {
"14": "Small",
"16": "Default",
"17": "Large",
"18": "Larger"
}
},
"terminalFont": { "terminalFont": {
"title": "Terminal font", "title": "Terminal font",
"description": "The font of the workspace terminals. Takes effect the next time a terminal opens. Saved to your account.", "description": "The font and size of the workspace terminals. Open terminals change at once. Saved to your account.",
"label": "Font", "label": "Font",
"names": { "names": {
"jetbrains": "JetBrains Mono", "jetbrains": "JetBrains Mono",
@@ -1086,7 +1129,8 @@
"system": "System font" "system": "System font"
}, },
"systemNote": "Your browser's fixed-width font. It may lack some Vietnamese letters (ấ, ế, ố…), which then draw out of line.", "systemNote": "Your browser's fixed-width font. It may lack some Vietnamese letters (ấ, ế, ố…), which then draw out of line.",
"preview": "Preview" "preview": "Preview",
"sizeLabel": "Size"
}, },
"rowMenu": { "rowMenu": {
"copyHost": "Copy host", "copyHost": "Copy host",
+29 -1
View File
@@ -34,7 +34,7 @@
"addLaneToolDefaultBadge": "Mặc định", "addLaneToolDefaultBadge": "Mặc định",
"addLaneNoTools": "Chưa có AI tool nào được cài và bật.", "addLaneNoTools": "Chưa có AI tool nào được cài và bật.",
"addLaneNoToolsLink": "Mở Cài đặt → AI Tools", "addLaneNoToolsLink": "Mở Cài đặt → AI Tools",
"addLaneToolHint": "CLI nào điều khiển workspace này. Không thể thay đổi sau này.", "addLaneToolHint": "AI tool mặc định",
"addLaneWorktreeDetached": "(detached)", "addLaneWorktreeDetached": "(detached)",
"addLaneWorktreesMore": "+{{count}} nữa", "addLaneWorktreesMore": "+{{count}} nữa",
"addLaneWorktreesTitle": "Worktree của repo này ({{count}})", "addLaneWorktreesTitle": "Worktree của repo này ({{count}})",
@@ -84,6 +84,12 @@
"changes.expand": "Mở bảng thay đổi", "changes.expand": "Mở bảng thay đổi",
"changes.expandAll": "Mở tất cả file", "changes.expandAll": "Mở tất cả file",
"changes.expandAllFolders": "Mở tất cả thư mục", "changes.expandAllFolders": "Mở tất cả thư mục",
"changes.download": "Tải xuống",
"changes.upload": "Tải file lên thư mục gốc của workspace",
"changes.uploadHere": "Tải file lên đây…",
"changes.uploaded_one": "Đã tải {{count}} file lên {{dir}}",
"changes.uploaded_other": "Đã tải {{count}} file lên {{dir}}",
"changes.uploadFailed": "Không tải lên được {{name}}: {{message}}",
"changes.expandContext": "Hiện thêm dòng xung quanh", "changes.expandContext": "Hiện thêm dòng xung quanh",
"changes.expandFile": "Mở {{path}}", "changes.expandFile": "Mở {{path}}",
"changes.fileGone": "File này không còn thay đổi nào trong phạm vi đang xem.", "changes.fileGone": "File này không còn thay đổi nào trong phạm vi đang xem.",
@@ -186,6 +192,7 @@
"gitBar.pull": "Pull", "gitBar.pull": "Pull",
"gitBar.pullHint": "git pull --ff-only — nhánh đã rẽ nhánh thì báo lỗi chứ không merge", "gitBar.pullHint": "git pull --ff-only — nhánh đã rẽ nhánh thì báo lỗi chứ không merge",
"gitBar.push": "Push", "gitBar.push": "Push",
"gitBar.nothingToPush": "Không có gì để push — {{upstream}} đã có mọi commit.",
"gitBar.pushHint": "git push -u origin HEAD", "gitBar.pushHint": "git push -u origin HEAD",
"gitBar.switch": "Nhánh", "gitBar.switch": "Nhánh",
"gitBar.switchApply": "Chuyển nhánh", "gitBar.switchApply": "Chuyển nhánh",
@@ -200,6 +207,21 @@
"mode.repoHint": "Gắn thẳng thư mục này làm workspace, nguyên trạng — không worktree, không nhánh mới.", "mode.repoHint": "Gắn thẳng thư mục này làm workspace, nguyên trạng — không worktree, không nhánh mới.",
"mode.worktree": "Worktree", "mode.worktree": "Worktree",
"mode.worktreeHint": "Tạo worktree git mới + nhánh mới từ repo nguồn.", "mode.worktreeHint": "Tạo worktree git mới + nhánh mới từ repo nguồn.",
"mode.clone": "Clone",
"mode.cloneHint": "Clone một repository qua HTTPS hoặc SSH vào thư mục của bạn, thành workspace mới.",
"clone.urlLabel": "URL repository",
"clone.urlHint": "https://host/owner/repo.git, git@host:owner/repo.git hoặc ssh://… — SSH dùng key riêng của bạn (Cài đặt → Git).",
"clone.parentLabel": "Clone vào thư mục",
"clone.parentHint": "Repository sẽ được clone vào {{path}}",
"clone.nameLabel": "Tên thư mục",
"clone.cloning": "Đang clone…",
"clone.loginNeeded": "{{host}} cần đăng nhập: username và mật khẩu hoặc access token.",
"clone.loginRejected": "{{host}} từ chối username hoặc token này.",
"clone.username": "Username",
"clone.token": "Mật khẩu hoặc access token",
"clone.remember": "Lưu đăng nhập này cho tài khoản của tôi",
"clone.sshRefused": "{{host}} từ chối đăng nhập SSH. Hãy thêm public key của bạn vào host đó, hoặc tạo key trong Cài đặt → Git.",
"clone.sshSettings": "Mở Cài đặt → Git",
"moreActions": "Thêm hành động", "moreActions": "Thêm hành động",
"pipelinePicker.label": "Mẫu pipeline", "pipelinePicker.label": "Mẫu pipeline",
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" không khớp node nào trong \"{{pipeline}}\" — khai báo một trong: {{nodes}}", "pipelinePicker.stageMismatch": "Stage \"{{stage}}\" không khớp node nào trong \"{{pipeline}}\" — khai báo một trong: {{nodes}}",
@@ -268,6 +290,7 @@
"changes.mergeAbortConfirm": "Bấm lần nữa để huỷ {{op}}", "changes.mergeAbortConfirm": "Bấm lần nữa để huỷ {{op}}",
"changes.discardShortConfirm": "Xoá?", "changes.discardShortConfirm": "Xoá?",
"changes.amend": "Amend", "changes.amend": "Amend",
"changes.amendPushed": "Commit cuối đã được push; amend sẽ viết lại lịch sử đã chia sẻ. Hãy revert nó trong History.",
"changes.amendHint": "Gộp các file đã tick vào commit cuối (chỉ khi chưa push). Không tick file nào thì chỉ sửa message; để trống message để giữ message cũ.", "changes.amendHint": "Gộp các file đã tick vào commit cuối (chỉ khi chưa push). Không tick file nào thì chỉ sửa message; để trống message để giữ message cũ.",
"changes.amendPlaceholder": "Message mới (trống = giữ message cũ)", "changes.amendPlaceholder": "Message mới (trống = giữ message cũ)",
"changes.historyOf": "Lịch sử của nhánh", "changes.historyOf": "Lịch sử của nhánh",
@@ -344,6 +367,9 @@
"tree.splitHint": "Pane đang chạy — Ctrl/Cmd+click để mở ở split cạnh pane đang focus", "tree.splitHint": "Pane đang chạy — Ctrl/Cmd+click để mở ở split cạnh pane đang focus",
"tree.renameSession": "Đổi tên session", "tree.renameSession": "Đổi tên session",
"tree.archiveSession": "Lưu trữ — ẩn khỏi workspace này (không xoá gì)", "tree.archiveSession": "Lưu trữ — ẩn khỏi workspace này (không xoá gì)",
"tree.pinSession": "Ghim lên đầu workspace này",
"tree.unpinSession": "Bỏ ghim",
"tree.pinned": "Đã ghim",
"tree.worktreeSession": "Trong worktree riêng: .claude/worktrees/{{name}}", "tree.worktreeSession": "Trong worktree riêng: .claude/worktrees/{{name}}",
"tree.archiveWorktreeTitle": "Lưu trữ và gỡ worktree của session?", "tree.archiveWorktreeTitle": "Lưu trữ và gỡ worktree của session?",
"tree.archiveWorktreeBody": "Giống Claude Desktop, lưu trữ sẽ gỡ worktree .claude/worktrees/{{name}} của session cùng branch của nó. Git sẽ từ chối nếu còn thay đổi chưa commit, và branch có commit chưa merge vào đâu sẽ được giữ lại — không mất gì chưa lưu.", "tree.archiveWorktreeBody": "Giống Claude Desktop, lưu trữ sẽ gỡ worktree .claude/worktrees/{{name}} của session cùng branch của nó. Git sẽ từ chối nếu còn thay đổi chưa commit, và branch có commit chưa merge vào đâu sẽ được giữ lại — không mất gì chưa lưu.",
@@ -423,6 +449,8 @@
"sessionMenu.details": "Chi tiết session", "sessionMenu.details": "Chi tiết session",
"sessionMenu.copyId": "Sao chép ID session", "sessionMenu.copyId": "Sao chép ID session",
"sessionMenu.archive": "Lưu trữ", "sessionMenu.archive": "Lưu trữ",
"sessionMenu.pin": "Ghim",
"sessionMenu.unpin": "Bỏ ghim",
"panes.show": "Hiện {{name}} ở đây", "panes.show": "Hiện {{name}} ở đây",
"panes.splitNew.right": "Chia sang phải với terminal mới", "panes.splitNew.right": "Chia sang phải với terminal mới",
"panes.splitNew.bottom": "Chia xuống dưới với terminal mới", "panes.splitNew.bottom": "Chia xuống dưới với terminal mới",
+46 -2
View File
@@ -893,6 +893,30 @@
"pet": "Pet", "pet": "Pet",
"petDescription": "Linh vật ở góc màn hình: hiện/ẩn, kích thước, bong bóng lời thoại và pet đang dùng." "petDescription": "Linh vật ở góc màn hình: hiện/ẩn, kích thước, bong bóng lời thoại và pet đang dùng."
}, },
"gitIdentity": {
"authorTitle": "Tác giả commit",
"authorName": "Tên",
"authorEmail": "Email",
"authorUnset": "Chưa đặt: commit từ dashboard và từ agent của bạn sẽ lỗi cho tới khi lưu tên và email. Đã điền sẵn từ tài khoản — kiểm tra rồi bấm Lưu.",
"authorSaved": "Đã lưu tác giả commit.",
"title": "Danh tính git của bạn",
"descriptionMember": "SSH key và đăng nhập HTTPS riêng của bạn, lưu trong home của chính bạn. Add workspace → Clone dùng chúng, và bản clone giữ lại cho các lần fetch, pull, push sau.",
"descriptionHost": "SSH key của tài khoản mà AgentHub đang chạy. Add workspace → Clone dùng key này, và bản clone giữ lại cho các lần fetch, pull, push sau. Đăng nhập HTTPS của tài khoản này là mục Git credentials ở trên.",
"sshTitle": "SSH key",
"generate": "Tạo SSH key",
"replace": "Thay key",
"replaceConfirm": "Thay SSH key? Các bản clone dùng key cũ sẽ không hoạt động cho tới khi public key mới được thêm vào host.",
"keyMade": "SSH key đã sẵn sàng — hãy thêm public key vào git host của bạn.",
"remove": "Xoá",
"removeKeyConfirm": "Xoá SSH key này? Các bản clone dùng nó sẽ không fetch/push được nữa.",
"publicKey": "Public key",
"copy": "Copy public key",
"addKeyHint": "Thêm public key này vào tài khoản trên git host (GitHub: Settings → SSH and GPG keys; GitLab: Preferences → SSH Keys). Private key không bao giờ rời khỏi server.",
"noKey": "Chưa có SSH key. Khi chưa có, clone qua SSH dùng key mặc định trong ~/.ssh của tài khoản.",
"httpsTitle": "Đăng nhập HTTPS",
"noLogins": "Chưa lưu đăng nhập HTTPS nào. Khi clone, host nào cần sẽ hỏi.",
"removeLoginConfirm": "Xoá đăng nhập của {{host}}? Clone từ host đó sẽ hỏi đăng nhập lại."
},
"gitCreds": { "gitCreds": {
"title": "Git credentials", "title": "Git credentials",
"description": "Mỗi host một tài khoản, dùng chung cho mọi người dùng AgentHub này. Token đã lưu sẽ không hiển thị lại.", "description": "Mỗi host một tài khoản, dùng chung cho mọi người dùng AgentHub này. Token đã lưu sẽ không hiển thị lại.",
@@ -1061,6 +1085,14 @@
"agenthub": "Worktree do AgentHub quản lý", "agenthub": "Worktree do AgentHub quản lý",
"agenthubDesc": "Worktree AgentHub tạo dưới thư mục gốc workspace của nó." "agenthubDesc": "Worktree AgentHub tạo dưới thư mục gốc workspace của nó."
}, },
"fonts": {
"title": "Chữ & font",
"description": "Ngôn ngữ, cỡ chữ và font của terminal. Lưu theo tài khoản của bạn."
},
"sessionsView": {
"title": "Phiên làm việc",
"description": "Cách mở một session mới và những session nào nằm trên thanh tab. Lưu theo tài khoản của bạn."
},
"language": { "language": {
"title": "Ngôn ngữ", "title": "Ngôn ngữ",
"description": "Ngôn ngữ của toàn bộ dashboard, được lưu vào tài khoản của bạn.", "description": "Ngôn ngữ của toàn bộ dashboard, được lưu vào tài khoản của bạn.",
@@ -1076,9 +1108,20 @@
"label": "Tab của mọi workspace", "label": "Tab của mọi workspace",
"labelDesc": "Giữ session đang chạy của mọi workspace thành tab, và trộn chúng trong một tab chia đôi. Chọn một tab sẽ chuyển sang workspace của nó. Tắt: chỉ session của workspace đang chọn." "labelDesc": "Giữ session đang chạy của mọi workspace thành tab, và trộn chúng trong một tab chia đôi. Chọn một tab sẽ chuyển sang workspace của nó. Tắt: chỉ session của workspace đang chọn."
}, },
"textSize": {
"title": "Cỡ chữ",
"description": "Độ lớn chữ của dashboard — menu, danh sách, chat và cài đặt. Lưu vào tài khoản của bạn. Ctrl + / Ctrl − của trình duyệt vẫn phóng to toàn bộ thêm trên mức này.",
"label": "Chữ giao diện",
"names": {
"14": "Nhỏ",
"16": "Mặc định",
"17": "Lớn",
"18": "Rất lớn"
}
},
"terminalFont": { "terminalFont": {
"title": "Font terminal", "title": "Font terminal",
"description": "Font của các terminal trong workspace. Áp dụng từ lần mở terminal tiếp theo. Lưu vào tài khoản của bạn.", "description": "Font và cỡ chữ của các terminal trong workspace. Terminal đang mở đổi ngay. Lưu vào tài khoản của bạn.",
"label": "Font", "label": "Font",
"names": { "names": {
"jetbrains": "JetBrains Mono", "jetbrains": "JetBrains Mono",
@@ -1086,7 +1129,8 @@
"system": "Font hệ thống" "system": "Font hệ thống"
}, },
"systemNote": "Font độ rộng cố định của trình duyệt. Font này có thể thiếu một số chữ tiếng Việt (ấ, ế, ố…), khi đó chữ sẽ bị lệch ô.", "systemNote": "Font độ rộng cố định của trình duyệt. Font này có thể thiếu một số chữ tiếng Việt (ấ, ế, ố…), khi đó chữ sẽ bị lệch ô.",
"preview": "Xem trước" "preview": "Xem trước",
"sizeLabel": "Cỡ chữ"
}, },
"rowMenu": { "rowMenu": {
"copyHost": "Sao chép host", "copyHost": "Sao chép host",
+38 -2
View File
@@ -563,7 +563,7 @@
reads it; no typography plugin. */ reads it; no typography plugin. */
.md-preview { .md-preview {
color: rgb(var(--fg-secondary)); color: rgb(var(--fg-secondary));
font-size: 13px; font-size: 0.8125rem;
line-height: 1.6; line-height: 1.6;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
@@ -650,7 +650,7 @@
.md-preview pre, .md-preview pre,
.md-preview .md-frontmatter { .md-preview .md-frontmatter {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px; font-size: 0.75rem;
line-height: 1.5; line-height: 1.5;
padding: 0.75em 1em; padding: 0.75em 1em;
border-radius: 6px; border-radius: 6px;
@@ -711,3 +711,39 @@
.xterm-viewport::-webkit-scrollbar { .xterm-viewport::-webkit-scrollbar {
display: none; display: none;
} }
/* Settings → Appearance → Text size (lib/fontSize.ts) sets the ROOT font size.
Tailwind's own sizes are rem and follow it; these are the `text-[Npx]`
classes the app uses, re-stated in rem (identical at the default 16px) so
the small labels grow with everything else. After `@tailwind utilities`,
same specificity, so these win. A new `text-[Npx]` value belongs here too. */
.text-\[9px\] {
font-size: 0.5625rem;
}
.text-\[10px\] {
font-size: 0.625rem;
}
.text-\[10\.5px\] {
font-size: 0.65625rem;
}
.text-\[11px\] {
font-size: 0.6875rem;
}
.text-\[12px\] {
font-size: 0.75rem;
}
.text-\[12\.5px\] {
font-size: 0.78125rem;
}
.text-\[13px\] {
font-size: 0.8125rem;
}
.text-\[15px\] {
font-size: 0.9375rem;
}
.text-\[16px\] {
font-size: 1rem;
}
.text-\[18px\] {
font-size: 1.125rem;
}
+77 -1
View File
@@ -987,6 +987,8 @@ export const api = {
lane?: number; lane?: number;
/** `false` = not archived, `true` = archived only; omitted = both. */ /** `false` = not archived, `true` = archived only; omitted = both. */
archived?: boolean; archived?: boolean;
/** Pinned sessions first (by pin time), before the limit. */
pinnedFirst?: boolean;
/** Only active sessions waiting on the user (`awaiting=1`). */ /** Only active sessions waiting on the user (`awaiting=1`). */
awaiting?: boolean; awaiting?: boolean;
/** ISO bounds on `started_at`. */ /** ISO bounds on `started_at`. */
@@ -1005,6 +1007,7 @@ export const api = {
if (params?.tool) qs.set("tool", params.tool); if (params?.tool) qs.set("tool", params.tool);
if (params?.lane !== undefined) qs.set("lane", String(params.lane)); if (params?.lane !== undefined) qs.set("lane", String(params.lane));
if (params?.archived !== undefined) qs.set("archived", params.archived ? "1" : "0"); if (params?.archived !== undefined) qs.set("archived", params.archived ? "1" : "0");
if (params?.pinnedFirst) qs.set("pinned_first", "1");
if (params?.awaiting) qs.set("awaiting", "1"); if (params?.awaiting) qs.set("awaiting", "1");
if (params?.from) qs.set("from", params.from); if (params?.from) qs.set("from", params.from);
if (params?.to) qs.set("to", params.to); if (params?.to) qs.set("to", params.to);
@@ -1066,7 +1069,7 @@ export const api = {
), ),
/** PATCH /api/sessions/:id — rename, or archive (hides it from its /** PATCH /api/sessions/:id — rename, or archive (hides it from its
* workspace, deletes nothing). */ * workspace, deletes nothing). */
patch: (id: string, body: { name?: string; archived?: boolean }) => patch: (id: string, body: { name?: string; archived?: boolean; pinned?: boolean }) =>
request<{ session: Session }>(`/sessions/${encodeURIComponent(id)}`, { request<{ session: Session }>(`/sessions/${encodeURIComponent(id)}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify(body), body: JSON.stringify(body),
@@ -2440,6 +2443,24 @@ export const api = {
{ method: "DELETE" } { method: "DELETE" }
), ),
}, },
/** The signed-in user's own git identity (`/api/me/git`, `server/routes/git-identity.js`). */
gitIdentity: {
get: () => request<GitIdentity>("/me/git"),
/** Make the SSH key; `replace` swaps an existing one for a new pair. */
generateKey: (replace = false) =>
request<GitIdentity>("/me/git/ssh-key", {
method: "POST",
body: JSON.stringify({ replace }),
}),
removeKey: () => request<GitIdentity>("/me/git/ssh-key", { method: "DELETE" }),
saveHttps: (body: { host: string; username: string; password: string }) =>
request<GitIdentity>("/me/git/https", { method: "POST", body: JSON.stringify(body) }),
removeHttps: (host: string) =>
request<GitIdentity>(`/me/git/https/${encodeURIComponent(host)}`, { method: "DELETE" }),
/** PUT /api/me/git/author — `user.name` / `user.email` for the account's commits. */
saveAuthor: (body: { name: string; email: string }) =>
request<GitIdentity>("/me/git/author", { method: "PUT", body: JSON.stringify(body) }),
},
/** Frozen session copies (`/api/snapshots`, `server/routes/snapshots.js`). */ /** Frozen session copies (`/api/snapshots`, `server/routes/snapshots.js`). */
snapshots: { snapshots: {
list: () => request<{ snapshots: SessionSnapshotInfo[] }>("/snapshots"), list: () => request<{ snapshots: SessionSnapshotInfo[] }>("/snapshots"),
@@ -3046,6 +3067,25 @@ export const api = {
method: "POST", method: "POST",
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
/**
* POST /api/lanes/clone — `git clone <url>` into `<parent>/<name>` as the
* folder's account, with that account's own SSH key / HTTPS login, then a
* workspace on it. 409 `ECREDENTIALS` (the error's `auth` names the host)
* asks for a login, sent again as `credentials`; 409 `ESSHAUTH` means the
* SSH key was refused.
*/
clone: (body: {
url: string;
parent: string;
name?: string;
title?: string;
tool?: LaneTool;
credentials?: { username: string; password: string; remember?: boolean };
}) =>
request<{ lane: Lane; created: boolean; path: string }>("/lanes/clone", {
method: "POST",
body: JSON.stringify(body),
}),
/** /**
* GET /api/lanes/pipelines — every pipeline template the server can render * GET /api/lanes/pipelines — every pipeline template the server can render
* a lane against, built-in plus any `DASHBOARD_PIPELINES_DIR` override. * a lane against, built-in plus any `DASHBOARD_PIPELINES_DIR` override.
@@ -3221,6 +3261,24 @@ export const api = {
/** URL of GET /api/lanes/:id/git/raw — an image's bytes, for an `<img>`. */ /** URL of GET /api/lanes/:id/git/raw — an image's bytes, for an `<img>`. */
gitRawUrl: (id: GitTarget, path: string, rev = "work") => gitRawUrl: (id: GitTarget, path: string, rev = "work") =>
`${BASE}${gitUrl(id, "/raw", new URLSearchParams({ path, rev }))}`, `${BASE}${gitUrl(id, "/raw", new URLSearchParams({ path, rev }))}`,
/** GET /api/lanes/:id/git/download — one working file as an attachment
* (any type; a plain, non-git folder too). For an `<a download>`. */
gitDownloadUrl: (id: GitTarget, path: string) =>
`${BASE}${gitUrl(id, "/download", new URLSearchParams({ path }))}`,
/**
* POST /api/lanes/:id/git/upload — create `file` in the folder `dir`
* ("" = the workspace root). Only creates: a taken name answers 409
* EEXISTS; at most 25 MB (413 ETOOBIG).
*/
gitUpload: (id: GitTarget, dir: string, file: File) =>
request<{ path: string }>(gitUrl(id, "/upload", new URLSearchParams({ dir })), {
method: "POST",
body: file,
headers: {
"Content-Type": "application/octet-stream",
"X-File-Name": encodeURIComponent(file.name),
},
}),
/** /**
* PUT /api/lanes/:id/git/file — save the editor's text over the working * PUT /api/lanes/:id/git/file — save the editor's text over the working
* file. `baseSha` is the `sha` it was opened at; a file that changed on * file. `baseSha` is the `sha` it was opened at; a file that changed on
@@ -3719,6 +3777,24 @@ export interface GitCredential {
hasToken: true; hasToken: true;
} }
/** `GET /api/me/git`: the user's own SSH key (public half only) and HTTPS logins. */
export interface GitIdentity {
/** `member`: the user's own Linux account (S3); `host`: the shared host account. */
account: "member" | "host";
/** False for the host's identity seen by a non-admin. */
writable: boolean;
ssh: { publicKey: string; fingerprint: string } | null;
/** The account's commit author (`git config --global`); null fields = unset. */
author: { name: string | null; email: string | null };
https: {
protocol: "http" | "https";
host: string;
username: string;
savedAt?: string;
hasToken: true;
}[];
}
export interface GitCredentialInput { export interface GitCredentialInput {
protocol: string; protocol: string;
host: string; host: string;
+71
View File
@@ -0,0 +1,71 @@
/**
* @file fontSize — the two text sizes Settings → Appearance offers (issue #79),
* viewer preferences kept in `localStorage` and synced to the account like the
* theme (`lib/userPrefs.ts` PREF_KEYS):
* - `agenthub.ui.fontSize`: the interface's size, the ROOT font size in px.
* Tailwind's sizes and spacing are rem, and `index.css` re-states every
* `text-[Npx]` class the app uses in rem, so the whole UI follows it.
* `index.html` applies it before first paint (same key, same range).
* Capped at 18px: the diff view pins its rows at `ROW_PX` (16px).
* - `agenthub.terminal.fontSize`: xterm's cell font size in px (default 13).
* A write fires `FONT_SIZE_EVENT`, which open terminals listen for to re-fit.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export const UI_FONT_SIZE_KEY = "agenthub.ui.fontSize";
export const TERMINAL_FONT_SIZE_KEY = "agenthub.terminal.fontSize";
/** Fired on `window` after either size changes (this tab or a hydrate). */
export const FONT_SIZE_EVENT = "agenthub-font-size";
/** The interface sizes, root px; 16 is the browser's own default. */
export const UI_FONT_SIZES = [14, 16, 17, 18] as const;
export type UiFontSize = (typeof UI_FONT_SIZES)[number];
export const DEFAULT_UI_FONT_SIZE: UiFontSize = 16;
export const TERMINAL_FONT_SIZES = [11, 12, 13, 14, 15, 16, 18, 20] as const;
export const DEFAULT_TERMINAL_FONT_SIZE = 13;
function read(key: string): string | null {
try {
return window.localStorage.getItem(key);
} catch {
return null;
}
}
function write(key: string, value: number) {
try {
window.localStorage.setItem(key, String(value));
} catch {
/* storage blocked — this page still applies it */
}
window.dispatchEvent(new Event(FONT_SIZE_EVENT));
}
export function readUiFontSize(): UiFontSize {
const n = Number(read(UI_FONT_SIZE_KEY));
return (UI_FONT_SIZES as readonly number[]).includes(n)
? (n as UiFontSize)
: DEFAULT_UI_FONT_SIZE;
}
/** Paint the stored interface size on `<html>` (the default leaves it unset). */
export function applyUiFontSize(size: UiFontSize = readUiFontSize()) {
const root = document.documentElement;
if (size === DEFAULT_UI_FONT_SIZE) root.style.removeProperty("font-size");
else root.style.fontSize = `${size}px`;
}
export function writeUiFontSize(size: UiFontSize) {
applyUiFontSize(size);
write(UI_FONT_SIZE_KEY, size);
}
export function readTerminalFontSize(): number {
const n = Number(read(TERMINAL_FONT_SIZE_KEY));
return (TERMINAL_FONT_SIZES as readonly number[]).includes(n) ? n : DEFAULT_TERMINAL_FONT_SIZE;
}
export function writeTerminalFontSize(size: number) {
write(TERMINAL_FONT_SIZE_KEY, size);
}
+24 -1
View File
@@ -72,9 +72,26 @@ export const SETTINGS_INDEX: {
section: "appearance", section: "appearance",
titleKey: "terminalFont.title", titleKey: "terminalFont.title",
descKey: "terminalFont.description", descKey: "terminalFont.description",
keywords: ["font", "phông", "maple", "jetbrains", "monospace", "rounded", "bo tròn"], keywords: [
"font",
"phông",
"maple",
"jetbrains",
"monospace",
"rounded",
"bo tròn",
"size",
"cỡ chữ",
],
to: "#terminal-font", to: "#terminal-font",
}, },
{
section: "appearance",
titleKey: "textSize.title",
descKey: "textSize.description",
keywords: ["font size", "text size", "zoom", "larger", "cỡ chữ", "chữ to", "phóng to"],
to: "#text-size",
},
{ {
section: "data", section: "data",
titleKey: "pushIngest.title", titleKey: "pushIngest.title",
@@ -314,6 +331,12 @@ export const SETTINGS_INDEX: {
{ section: "ai-proxy", titleKey: "aiProxy.title", keywords: ["router", "endpoint", "key"] }, { section: "ai-proxy", titleKey: "aiProxy.title", keywords: ["router", "endpoint", "key"] },
{ section: "pricing", titleKey: "pricing.title", keywords: ["cost", "price", "tokens"] }, { section: "pricing", titleKey: "pricing.title", keywords: ["cost", "price", "tokens"] },
{ section: "git", titleKey: "gitCreds.title", adminOnly: true, keywords: ["login", "token"] }, { section: "git", titleKey: "gitCreds.title", adminOnly: true, keywords: ["login", "token"] },
{
section: "git",
titleKey: "gitIdentity.title",
keywords: ["ssh", "key", "clone", "https"],
to: "#git-identity",
},
{ {
section: "git", section: "git",
titleKey: "worktrees.showUntracked", titleKey: "worktrees.showUntracked",
+5 -1
View File
@@ -4,10 +4,13 @@
* terminal opens. `jetbrains` (the default) and the rounded `maple` are * terminal opens. `jetbrains` (the default) and the rounded `maple` are
* bundled webfonts that cover every Vietnamese letter; `system` is the browser's own * bundled webfonts that cover every Vietnamese letter; `system` is the browser's own
* fixed-width font, which may not (DejaVu Sans Mono on Linux lacks ấ, ế, ố…, * fixed-width font, which may not (DejaVu Sans Mono on Linux lacks ấ, ế, ố…,
* and those glyphs then fall back to a face of another width). * and those glyphs then fall back to a face of another width). A change fires
* `FONT_SIZE_EVENT` (`lib/fontSize.ts`), so an open terminal takes it too.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { FONT_SIZE_EVENT } from "./fontSize";
export const TERMINAL_FONT_KEY = "agenthub.terminal.font"; export const TERMINAL_FONT_KEY = "agenthub.terminal.font";
export const TERMINAL_FONTS = { export const TERMINAL_FONTS = {
@@ -34,4 +37,5 @@ export function writeTerminalFont(font: TerminalFont) {
} catch { } catch {
/* storage blocked — terminals keep the default */ /* storage blocked — terminals keep the default */
} }
window.dispatchEvent(new Event(FONT_SIZE_EVENT));
} }
+3
View File
@@ -741,6 +741,9 @@ export interface Session {
/** ISO time the user archived the session out of its workspace's lists; /** ISO time the user archived the session out of its workspace's lists;
* null when not archived. Hides only. Maps to `sessions.archived_at`. */ * null when not archived. Hides only. Maps to `sessions.archived_at`. */
archived_at?: string | null; archived_at?: string | null;
/** ISO time the user pinned the session to the top of its workspace's
* sidebar list; null when not pinned. Maps to `sessions.pinned_at`. */
pinned_at?: string | null;
} }
/** /**
+5
View File
@@ -19,6 +19,7 @@ import { useSyncExternalStore } from "react";
import { api } from "./api"; import { api } from "./api";
import i18n from "../i18n"; import i18n from "../i18n";
import { applyStoredTheme } from "../hooks/useTheme"; import { applyStoredTheme } from "../hooks/useTheme";
import { FONT_SIZE_EVENT, applyUiFontSize } from "./fontSize";
/** The localStorage keys an account's preferences are made of (server/routes/me.js). */ /** The localStorage keys an account's preferences are made of (server/routes/me.js). */
export const PREF_KEYS = [ export const PREF_KEYS = [
@@ -27,6 +28,8 @@ export const PREF_KEYS = [
"agenthub.theme.light", "agenthub.theme.light",
"i18nextLng", "i18nextLng",
"agenthub.terminal.font", "agenthub.terminal.font",
"agenthub.terminal.fontSize",
"agenthub.ui.fontSize",
"agenthub.pet", "agenthub.pet",
"agenthub.pet.size", "agenthub.pet.size",
"agenthub.pet.names", "agenthub.pet.names",
@@ -107,6 +110,8 @@ function reapply(prefs: Record<string, string>) {
applyStoredTheme(); applyStoredTheme();
window.dispatchEvent(new Event("agenthub-theme")); window.dispatchEvent(new Event("agenthub-theme"));
window.dispatchEvent(new Event("sagi:prefs")); window.dispatchEvent(new Event("sagi:prefs"));
applyUiFontSize();
window.dispatchEvent(new Event(FONT_SIZE_EVENT));
const lng = prefs.i18nextLng; const lng = prefs.i18nextLng;
if (lng && lng !== i18n.language) void i18n.changeLanguage(lng); if (lng && lng !== i18n.language) void i18n.changeLanguage(lng);
} }
+10
View File
@@ -60,6 +60,7 @@ import { CodexConfig } from "./CodexConfig";
import { AiToolConfig } from "./AiToolConfig"; import { AiToolConfig } from "./AiToolConfig";
import { AiProxyConfig } from "./AiProxyConfig"; import { AiProxyConfig } from "./AiProxyConfig";
import { GitCredentialsConfig } from "./GitCredentialsConfig"; import { GitCredentialsConfig } from "./GitCredentialsConfig";
import { GitIdentitySettings } from "../components/settings/GitIdentitySettings";
import { AppearanceSettings } from "../components/settings/AppearanceSettings"; import { AppearanceSettings } from "../components/settings/AppearanceSettings";
import { AccountSettings } from "../components/settings/AccountSettings"; import { AccountSettings } from "../components/settings/AccountSettings";
import { SnapshotSettings } from "../components/settings/SnapshotSettings"; import { SnapshotSettings } from "../components/settings/SnapshotSettings";
@@ -400,6 +401,15 @@ export function SettingsHub({ section }: { section?: string } = {}) {
<GitCredentialsConfig /> <GitCredentialsConfig />
</section> </section>
)} )}
{/* The user's own SSH key / HTTPS logins (S3: their own home). */}
{(isAdmin || signedIn) && (
<section id="git-identity" className="scroll-mt-24">
<h2 className="mb-2 text-sm font-semibold text-fg-primary">
{t("gitIdentity.title")}
</h2>
<GitIdentitySettings />
</section>
)}
<div id="worktrees" className="scroll-mt-24"> <div id="worktrees" className="scroll-mt-24">
<WorktreeVisibilitySettings /> <WorktreeVisibilitySettings />
</div> </div>
+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/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 | | `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. `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 not at all without one (`403 EFOLDER`); a member's folder is made as the
member (S3). 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 #### Update a workspace
```http ```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/ls?q=<substring>
GET /api/lanes/:id/git/tree?path=<dir> GET /api/lanes/:id/git/tree?path=<dir>
GET /api/lanes/:id/git/tree-all 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 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 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, - **`/git/log`** → `{available, commits:[{sha, short, subject, author, date,
refs, parents}]}`, newest first in `--date-order` (no parent before its 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`, `??`, 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. `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. - **`/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 #### 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-create` | `git branch <branch> [<base>]` — never switches | `{"branch": "...", "base": "..."}` |
| `branch-delete` | `git branch -d <branch>` | `{"branch": "..."}` | | `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"}` | | `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"}` | | `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-commit` | `git commit --no-edit` — concludes the merge | — |
| `merge-abort` | `git merge --abort` (or `cherry-pick --abort` / `revert --abort`, whichever is open) | `{"confirm": true}` | | `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 only after git succeeded AND `remember` is true. Empty or multi-line values
are `400 EBADCRED`. 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 #### 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` | | `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 | | `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 | | `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) | | `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:** **Example Request:**
@@ -1519,6 +1617,7 @@ classDiagram
+string awaiting_reason "notification|stop|session_start|interrupted; null unless Waiting" +string awaiting_reason "notification|stop|session_start|interrupted; null unless Waiting"
+number lane_id "owning workspace; null if none" +number lane_id "owning workspace; null if none"
+string archived_at "null unless archived" +string archived_at "null unless archived"
+string pinned_at "null unless pinned"
+number cost +number cost
+number agent_count +number agent_count
} }
@@ -1534,9 +1633,13 @@ classDiagram
PATCH /api/sessions/:id 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`: `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`) (`lane_id`) is not patchable: `bindRunSession` (`server/routes/hooks.js`)
stamps the session a dashboard pane holds with that pane's lane, first writer 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 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: - 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`; - `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 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 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); - `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 | | `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` | | `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 | | `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:** **Constraints:**
- `status` must be one of the four enum values - `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) 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 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 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 - **Settings as one page per section** (`/settings/<section>`): a left nav
grouped General (Appearance — language, theme, pet — / Notifications / grouped General (Appearance — language, theme, pet — / Notifications /
System) / Agents (AI tools, one page per installed AI tool, AI Proxy) / Data 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. 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 It also picks the workspace terminals' font: JetBrains Mono (default), the
rounded Maple Mono, or the browser's own fixed-width 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 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. 700), and the Chat UI default sits at the top of the page.
- **Notification sounds.** Settings → Notifications picks a sound (None, Chime, - **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ở 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ó: ô 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à 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 - **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 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à 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 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à 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. `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 - **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). 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ó - **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.** - **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. - 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`. - 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: - **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 workspace prompt and its context files;
- the AI Proxy settings: no key, only that pane's own token for the gateway; - 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 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`. | | `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. | | `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 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 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. | | `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. |
+109 -4
View File
@@ -747,6 +747,12 @@ components:
metadata: metadata:
type: object type: object
additionalProperties: true additionalProperties: true
archived:
type: boolean
description: Sets or clears `archived_at` (hidden from its workspace's lists).
pinned:
type: boolean
description: Sets or clears `pinned_at` (top of its workspace's sidebar list). Pinning again keeps the first pin time; a pin-only PATCH leaves `updated_at` unchanged.
SessionUpdateResponse: SessionUpdateResponse:
type: object type: object
required: required:
@@ -4912,6 +4918,14 @@ paths:
format: date-time format: date-time
description: Inclusive upper bound on `started_at` (ISO 8601). An unparseable value is ignored. Applied before `limit`/`offset`. description: Inclusive upper bound on `started_at` (ISO 8601). An unparseable value is ignored. Applied before `limit`/`offset`.
example: '2026-10-05T23:59:59.999Z' example: '2026-10-05T23:59:59.999Z'
- name: pinned_first
in: query
schema:
type: string
enum:
- '1'
description: '`1` = pinned sessions first (by `pinned_at`, oldest pin first), then the normal order. Applied before `limit`/`offset`, so a pinned old session stays on the first page. Omitted = order unchanged.'
example: '1'
- $ref: '#/components/parameters/SourcesQuery' - $ref: '#/components/parameters/SourcesQuery'
example: local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11 example: local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11
- $ref: '#/components/parameters/LimitQuery' - $ref: '#/components/parameters/LimitQuery'
@@ -5122,7 +5136,7 @@ paths:
tags: tags:
- Sessions - Sessions
summary: Update session summary: Update session
description: Partially updates a session by `id`. Only `name`, `status`, `ended_at`, and `metadata` are accepted; any field omitted from the body is passed as null and the underlying UPDATE uses COALESCE, so a null leaves the existing column value unchanged (partial-update semantics) — you cannot clear a field to null through this endpoint. `metadata` is supplied as a JSON object but stored and returned as a JSON-encoded string. A successful update re-reads the row and broadcasts a `session_updated` websocket frame. Returns 404 with code `NOT_FOUND` when the session does not exist. description: Partially updates a session by `id`. `name`, `status`, `ended_at`, `metadata`, `archived` and `pinned` are accepted (`archived`/`pinned` must be booleans, else 400 `INVALID_INPUT`; they set or clear `archived_at`/`pinned_at`, and a pin-only PATCH leaves `updated_at` unchanged); any other field omitted from the body is passed as null and the underlying UPDATE uses COALESCE, so a null leaves the existing column value unchanged (partial-update semantics) — you cannot clear a field to null through this endpoint. `metadata` is supplied as a JSON object but stored and returned as a JSON-encoded string. A successful update re-reads the row and broadcasts a `session_updated` websocket frame. Returns 404 with code `NOT_FOUND` when the session does not exist.
operationId: updateSession operationId: updateSession
parameters: parameters:
- $ref: '#/components/parameters/SessionIdPath' - $ref: '#/components/parameters/SessionIdPath'
@@ -11789,7 +11803,7 @@ paths:
tags: tags:
- Lanes - Lanes
summary: Tracked paths matching a substring (quick-open) summary: Tracked paths matching a substring (quick-open)
description: Reads `git ls-files`, so ignored files never appear and no skip-list is maintained. Basename hits rank first. Capped at 500 paths. Read-only, no same-origin guard. description: Reads `git ls-files`, so ignored files never appear and no skip-list is maintained. Basename hits rank first. Capped at 500 paths. In a folder that is not a git repo it walks the disk instead (realpath-confined, no symlink followed, `.git` hidden, `node_modules` never entered, 20 000 entries at most). Read-only, no same-origin guard.
operationId: listLanePaths operationId: listLanePaths
parameters: parameters:
- name: id - name: id
@@ -11811,7 +11825,7 @@ paths:
tags: tags:
- Lanes - Lanes
summary: One directory level of tracked files summary: One directory level of tracked files
description: 'The file explorer''s expand-a-folder read: tracked entries under path=, folded into directories at that level. Read-only, no same-origin guard.' description: 'The file explorer''s expand-a-folder read: tracked entries under path=, folded into directories at that level. In a folder that is not a git repo the level is read off the disk instead (no status; `.git` hidden, `node_modules` listed as ignored and never entered). Read-only, no same-origin guard.'
operationId: listLaneTree operationId: listLaneTree
parameters: parameters:
- name: id - name: id
@@ -11836,7 +11850,7 @@ paths:
tags: tags:
- Lanes - Lanes
summary: Every level of the tracked tree in one read summary: Every level of the tracked tree in one read
description: 'The file explorer''s expand-all-folders read: ONE git ls-files, grouped into levels keyed by directory ("" is the root), each level shaped and capped like GET /api/lanes/{id}/git/tree''s entries. Read-only, no same-origin guard.' description: 'The file explorer''s expand-all-folders read: ONE git ls-files, grouped into levels keyed by directory ("" is the root), each level shaped and capped like GET /api/lanes/{id}/git/tree''s entries; a folder that is not a git repo is walked off the disk instead (at most 20 000 entries, then truncated). Read-only, no same-origin guard.'
operationId: listLaneTreeAll operationId: listLaneTreeAll
parameters: parameters:
- name: id - name: id
@@ -11849,6 +11863,97 @@ paths:
description: '{available, levels:{<dir>:[{name, path, dir}]}, truncated}.' description: '{available, levels:{<dir>:[{name, path, dir}]}, truncated}.'
'404': '404':
description: Lane not found. description: Lane not found.
/api/lanes/{id}/git/download:
get:
tags:
- Lanes
summary: Download one working file of the workspace
description: 'The Files tab''s Download: one working-copy file of any type, also in a folder that is not a git repo (server/lib/lane-file-transfer.js). Same guards as the /git/file read (lexical lane path, realpath confinement, secret refusal; under S3 a member''s file is opened through openSafe), at most 512 MB. Served as an `application/octet-stream` attachment (an ASCII filename plus `filename*=UTF-8''''…`) with `Content-Security-Policy: default-src ''none''; sandbox`, `X-Content-Type-Options: nosniff` and `Cache-Control: no-store`, so nothing in it renders or runs on the dashboard''s origin. Honours `?session=`/`?source=` like the other /git reads. Read-only, no same-origin guard.'
operationId: downloadLaneFile
parameters:
- name: id
in: path
required: true
schema:
type: integer
- name: path
in: query
required: true
schema:
type: string
- name: session
in: query
required: false
schema:
type: string
- name: source
in: query
required: false
schema:
type: integer
responses:
'200':
description: The file's bytes, as an attachment.
'400':
description: EBADPATH, ENOFILE or ETOOBIG.
'403':
description: 'ESECRET, or EFOREIGN (S3: the workspace folder resolves into another account''s files).'
'404':
description: Lane not found.
/api/lanes/{id}/git/upload:
post:
tags:
- Lanes
summary: Upload one new file into a folder of the workspace
description: 'The Files tab''s Upload. The body is the file''s raw bytes, its name the URI-encoded `X-File-Name` header, `dir` the workspace-relative folder ("" = the root). It only CREATES: a taken name (a symlink at it included) is 409 EEXISTS, never an overwrite. Writes into an existing folder only, never into or under `.git`, never a secret name (`settings.local.json`, a member''s home dot folder), at most 25 MB. Under S3 the file is written as the folder''s member through `write-as`; elsewhere with an exclusive create. Works in a folder that is not a git repo. Same-origin guarded.'
operationId: uploadLaneFile
parameters:
- name: id
in: path
required: true
schema:
type: integer
- name: dir
in: query
schema:
type: string
default: ''
- name: X-File-Name
in: header
required: true
schema:
type: string
description: 'The new file''s name, URI-encoded: one plain segment of at most 255 bytes.'
- name: session
in: query
required: false
schema:
type: string
- name: source
in: query
required: false
schema:
type: integer
requestBody:
required: true
content:
application/octet-stream:
schema:
type: string
format: binary
responses:
'201':
description: '{path} — the workspace-relative path written.'
'400':
description: EBADPATH (.git), EBADNAME, ENOFILE (no such folder) or EBADCONTENT.
'403':
description: ESECRET, EFOREIGN, or EBADORIGIN (cross-origin).
'404':
description: Lane not found.
'409':
description: EEXISTS — a file of that name is already there.
'413':
description: ETOOBIG — over 25 MB.
/api/lanes/{id}/git/branches: /api/lanes/{id}/git/branches:
get: get:
tags: tags:
+5 -3
View File
@@ -448,10 +448,10 @@ The OpenAPI spec is generated from `server/openapi.js` (`createOpenApiSpec()`),
| Method | Path | Description | | Method | Path | Description |
| ------- | ------------------- | ------------------------------------------------ | | ------- | ------------------- | ------------------------------------------------ |
| `GET` | `/api/health` | Server health check (`status`, `version`, `timestamp`) | | `GET` | `/api/health` | Server health check (`status`, `version`, `timestamp`) |
| `GET` | `/api/sessions` | List sessions (`status`, `limit`, `offset`; `tool=claude\|codex\|antigravity`, `awaiting=1` and `from`/`to` on `started_at` filter before the limit) | | `GET` | `/api/sessions` | List sessions (`status`, `limit`, `offset`; `tool=claude\|codex\|antigravity`, `awaiting=1` and `from`/`to` on `started_at` filter before the limit; `pinned_first=1` puts pinned sessions first, by `pinned_at`, before the limit) |
| `GET` | `/api/sessions/:id` | Session detail (includes `agents` + `events`) | | `GET` | `/api/sessions/:id` | Session detail (includes `agents` + `events`) |
| `POST` | `/api/sessions` | Create session (idempotent by `id`) | | `POST` | `/api/sessions` | Create session (idempotent by `id`) |
| `PATCH` | `/api/sessions/:id` | Update session | | `PATCH` | `/api/sessions/:id` | Update session (`name`, `status`, `ended_at`, `metadata`, `archived`, `pinned` — booleans else `400 INVALID_INPUT`; a pin-only PATCH keeps `updated_at`) |
| `GET` | `/api/sessions/:id/transcripts` | List the session's transcript files (main + sub-agents) | | `GET` | `/api/sessions/:id/transcripts` | List the session's transcript files (main + sub-agents) |
| `GET` | `/api/sessions/:id/transcript` | Cursor-paginated message stream for one transcript | | `GET` | `/api/sessions/:id/transcript` | Cursor-paginated message stream for one transcript |
| `GET` | `/api/agents` | List agents (`status`, `session_id`, pagination; `session_ids=a,b` — ≤ 200 — wins over the rest)| | `GET` | `/api/agents` | List agents (`status`, `session_id`, pagination; `session_ids=a,b` — ≤ 200 — wins over the rest)|
@@ -541,8 +541,10 @@ Request body shape:
| `GET` | `/api/lanes/:id/git/log?limit=&file=&ref=` | The workspace's recent commits (newest first, limit max 200), optionally only those touching one path, or another existing branch's (`ref=`, else `400 ENOBRANCH`). | | `GET` | `/api/lanes/:id/git/log?limit=&file=&ref=` | The workspace's recent commits (newest first, limit max 200), optionally only those touching one path, or another existing branch's (`ref=`, else `400 ENOBRANCH`). |
| `GET` | `/api/lanes/:id/git/file?path=&rev=` | One file's whole content at `work` (realpath-confined to the workspace), `HEAD` or a commit-ish. 1 MB cap. `400 EBADPATH`/`EBADREV`/`ENOFILE`. | | `GET` | `/api/lanes/:id/git/file?path=&rev=` | One file's whole content at `work` (realpath-confined to the workspace), `HEAD` or a commit-ish. 1 MB cap. `400 EBADPATH`/`EBADREV`/`ENOFILE`. |
| `PUT` | `/api/lanes/:id/git/file` | Same-origin guarded save from the whole-file editor: `{path, content, baseSha}`. An existing text file within the 1 MB cap, over exactly the version opened (`baseSha` = the read's `sha`, else `409 ESTALE`); `403 ESECRET`, `400 EBADPATH`/`ENOFILE`/`EBADCONTENT`/`ETOOBIG`/`EBINARY`. | | `PUT` | `/api/lanes/:id/git/file` | Same-origin guarded save from the whole-file editor: `{path, content, baseSha}`. An existing text file within the 1 MB cap, over exactly the version opened (`baseSha` = the read's `sha`, else `409 ESTALE`); `403 ESECRET`, `400 EBADPATH`/`ENOFILE`/`EBADCONTENT`/`ETOOBIG`/`EBINARY`. |
| `GET` | `/api/lanes/:id/git/ls?q=` | Tracked paths matching a substring, for quick-open. `git ls-files`, so ignored files never appear. Capped at 500. | | `GET` | `/api/lanes/:id/git/ls?q=` | Tracked paths matching a substring, for quick-open. `git ls-files`, so ignored files never appear. Capped at 500. In a folder that is not a git repo, `ls`, `tree` and `tree-all` read the disk instead (`walkFolder`: realpath-confined, no symlink followed, `.git` hidden, `node_modules` listed as ignored but never entered, 20 000 entries max → `truncated`). |
| `GET` | `/api/lanes/:id/git/tree?path=` | One directory level of tracked + untracked-not-ignored files, folded; changed files carry their porcelain `status`, folders holding one `changed: true`. What git ignores at that level (read off the disk, `.git` excepted — also inside an ignored folder) is listed too with `ignored: true`; `tree-all` adds them per level but gives an ignored folder no level of its own. `400 EBADPATH` for a path escaping the workspace. | | `GET` | `/api/lanes/:id/git/tree?path=` | One directory level of tracked + untracked-not-ignored files, folded; changed files carry their porcelain `status`, folders holding one `changed: true`. What git ignores at that level (read off the disk, `.git` excepted — also inside an ignored folder) is listed too with `ignored: true`; `tree-all` adds them per level but gives an ignored folder no level of its own. `400 EBADPATH` for a path escaping the workspace. |
| `GET` | `/api/lanes/:id/git/download?path=` | One working file of any type as an `application/octet-stream` attachment (ASCII + `filename*=UTF-8''` name) under `Content-Security-Policy: default-src 'none'; sandbox`, `nosniff`, `no-store` (`server/lib/lane-file-transfer.js`). The file read's guards, 512 MB cap. `403 ESECRET`/`EFOREIGN`, `400 EBADPATH`/`ENOFILE`/`ETOOBIG`. |
| `POST` | `/api/lanes/:id/git/upload?dir=` | Same-origin guarded. Raw body, name in the URI-encoded `X-File-Name` header, `dir` workspace-relative (`""` = root); `201 {path}`. Creates only (`409 EEXISTS`, a symlink at the name included), into an existing folder (`400 ENOFILE`), never into `.git` (`400 EBADPATH`), one plain name ≤ 255 bytes and not `.git` (`400 EBADNAME`), never a secret name (`403 ESECRET`), 25 MB cap (`413 ETOOBIG`), `403 EFOREIGN`. Under S3 written as the folder's member via `write-as`. |
| `GET` | `/api/lanes/:id/git/branches` | The workspace repo's own local branches + worktrees, for the branch switcher. The repo comes from the workspace row, never the query. | | `GET` | `/api/lanes/:id/git/branches` | The workspace repo's own local branches + worktrees, for the branch switcher. The repo comes from the workspace row, never the query. |
| `GET` | `/api/git-credentials` | AgentHub's own git logins (one per host, shared by every user), `hasToken` only — never a password. | | `GET` | `/api/git-credentials` | AgentHub's own git logins (one per host, shared by every user), `hasToken` only — never a password. |
| `POST` | `/api/git-credentials` | Same-origin guarded save/replace: `{protocol, host, username, password}`. `400 EBADHOST`/`EBADCRED`. | | `POST` | `/api/git-credentials` | Same-origin guarded save/replace: `{protocol, host, username, password}`. `400 EBADHOST`/`EBADCRED`. |
+25
View File
@@ -333,6 +333,31 @@ describe("Sessions API", () => {
assert.equal(res.body.session.archived_at, null); assert.equal(res.body.session.archived_at, null);
}); });
it("pins a session to the top of its workspace's list, before the limit", async () => {
const laneId = stmts.getSession.get("own-1").lane_id;
db.prepare("UPDATE sessions SET updated_at = '2026-09-28T00:00:00Z' WHERE id = 'own-1'").run();
db.prepare(
"UPDATE sessions SET updated_at = '2026-09-30T00:00:00Z' WHERE id = 'own-sub'"
).run();
const first = async (q = "") =>
(await fetch(`/api/sessions?lane=${laneId}&limit=1${q}`)).body.sessions.map((s) => s.id);
assert.deepEqual(await first(), ["own-sub"]);
let res = await patch("/api/sessions/own-1", { pinned: true });
assert.equal(res.status, 200);
const at = res.body.session.pinned_at;
assert.ok(at);
// The older pinned session wins the one row, but only when asked for.
assert.deepEqual(await first("&pinned_first=1"), ["own-1"]);
assert.deepEqual(await first(), ["own-sub"]);
// Pinning again keeps the first pin time (its place among the pinned).
res = await patch("/api/sessions/own-1", { pinned: true });
assert.equal(res.body.session.pinned_at, at);
assert.equal((await patch("/api/sessions/own-1", { pinned: 1 })).status, 400);
res = await patch("/api/sessions/own-1", { pinned: false });
assert.equal(res.body.session.pinned_at, null);
assert.deepEqual(await first("&pinned_first=1"), ["own-sub"]);
});
it("should paginate sessions", async () => { it("should paginate sessions", async () => {
const res = await fetch("/api/sessions?limit=1&offset=0"); const res = await fetch("/api/sessions?limit=1&offset=0");
assert.equal(res.body.sessions.length, 1); assert.equal(res.body.sessions.length, 1);
+385
View File
@@ -0,0 +1,385 @@
/**
* @file Tests for Add workspace → Clone and a user's own git identity:
* `POST /api/lanes/clone` (URL allowlist, name/parent checks, a clone that
* becomes a workspace, the SSH key persisted into the clone, 409
* ECREDENTIALS for a remote wanting a login), `/api/me/git` (key made once,
* only the public half returned, 0600 on disk), and the member (S3) path of
* `server/lib/git-identity.js` with run-as and git stubbed — what reaches
* git's argv, and that a login typed without "remember" leaves the member's
* store as it was.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const http = require("node:http");
const { execFileSync } = require("node:child_process");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "git-clone-route-"));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
process.env.GIT_CONFIG_NOSYSTEM = "1";
process.env.HOME = ROOT;
const { createApp } = require("../index");
const identity = require("../lib/git-identity");
let app;
let remote;
let base;
const WORK = path.join(ROOT, "work");
function request(method, p, body, headers = {}) {
return fetch(`${base}${p}`, {
method,
headers: { "Content-Type": "application/json", ...headers },
body: body === undefined ? undefined : JSON.stringify(body),
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
}
before(async () => {
// A local bare repo stands in for the forge: the test's own ~/.gitconfig
// rewrites the https and scp-like URLs to it (git reads HOME's config).
const src = path.join(ROOT, "src");
const bare = path.join(ROOT, "bare.git");
execFileSync("git", ["init", "-q", "-b", "main", src]);
fs.writeFileSync(path.join(src, "README.md"), "hi\n");
execFileSync("git", ["-C", src, "add", "."]);
execFileSync("git", [
"-C",
src,
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"-qm",
"init",
]);
execFileSync("git", ["clone", "-q", "--bare", src, bare]);
remote = http.createServer((_req, res) => {
res.writeHead(401, { "WWW-Authenticate": 'Basic realm="t"' });
res.end();
});
await new Promise((r) => remote.listen(0, "127.0.0.1", r));
const authHost = `127.0.0.1:${remote.address().port}`;
fs.writeFileSync(
path.join(ROOT, ".gitconfig"),
[
`[url "file://${bare}"]`,
"\tinsteadOf = https://forge.test/o/r.git",
"\tinsteadOf = git@forge.test:o/r.git",
`[url "file://${path.join(ROOT, "missing.git")}"]`,
"\tinsteadOf = git@forge.test:o/missing.git",
`[url "http://${authHost}/"]`,
"\tinsteadOf = https://private.test/",
"",
].join("\n")
);
fs.mkdirSync(WORK);
app = http.createServer(createApp());
await new Promise((r) => app.listen(0, "127.0.0.1", r));
base = `http://127.0.0.1:${app.address().port}`;
});
after(async () => {
await new Promise((r) => app.close(r));
await new Promise((r) => remote.close(r));
});
describe("POST /api/lanes/clone", () => {
it("refuses a URL outside the allowlist", async () => {
for (const url of [
"file:///etc",
"http://h/x",
"https://u:p@h/x",
"-oProxyCommand=x",
"ext::sh",
]) {
const r = await request("POST", "/api/lanes/clone", { url, parent: WORK });
assert.equal(r.status, 400, url);
assert.equal(r.body.error.code, "EBADURL", url);
}
});
it("refuses a name with a separator and a missing parent", async () => {
const bad = await request("POST", "/api/lanes/clone", {
url: "https://forge.test/o/r.git",
parent: WORK,
name: "a/b",
});
assert.equal(bad.body.error.code, "EBADNAME");
const gone = await request("POST", "/api/lanes/clone", {
url: "https://forge.test/o/r.git",
parent: path.join(ROOT, "nope"),
});
assert.equal(gone.body.error.code, "ENOTFOUND");
});
it("clones over https into <parent>/<repo> and makes it a workspace", async () => {
const r = await request("POST", "/api/lanes/clone", {
url: "https://forge.test/o/r.git",
parent: WORK,
tool: "claude",
});
assert.equal(r.status, 201, JSON.stringify(r.body));
assert.equal(r.body.lane.title, "r");
assert.equal(fs.readFileSync(path.join(WORK, "r", "README.md"), "utf8"), "hi\n");
const again = await request("POST", "/api/lanes/clone", {
url: "https://forge.test/o/r.git",
parent: WORK,
});
assert.equal(again.status, 409);
assert.equal(again.body.error.code, "EEXISTS");
});
it("clones over ssh with the user's key, kept in the clone's config", async () => {
const made = await request("POST", "/api/me/git/ssh-key", {});
assert.equal(made.status, 201);
const r = await request("POST", "/api/lanes/clone", {
url: "git@forge.test:o/r.git",
parent: WORK,
name: "via-ssh",
});
assert.equal(r.status, 201, JSON.stringify(r.body));
const cmd = execFileSync(
"git",
["-C", path.join(WORK, "via-ssh"), "config", "core.sshCommand"],
{
encoding: "utf8",
}
);
assert.match(cmd, /agenthub-ssh\/id_ed25519/);
assert.match(cmd, /IdentitiesOnly=yes/);
});
it("answers 409 ECREDENTIALS with the host when the remote wants a login", async () => {
const r = await request("POST", "/api/lanes/clone", {
url: "https://private.test/o/r.git",
parent: WORK,
name: "private",
});
assert.equal(r.status, 409, JSON.stringify(r.body));
assert.equal(r.body.error.code, "ECREDENTIALS");
assert.equal(r.body.error.auth.host, "private.test");
assert.ok(!fs.existsSync(path.join(WORK, "private")));
});
it("says a missing repository is missing, not that the SSH key was refused", async () => {
const r = await request("POST", "/api/lanes/clone", {
url: "git@forge.test:o/missing.git",
parent: WORK,
name: "missing",
});
assert.equal(r.status, 409, JSON.stringify(r.body));
assert.equal(r.body.error.code, "ENOREPO");
});
it("refuses a cross-origin request", async () => {
const r = await request(
"POST",
"/api/lanes/clone",
{ url: "https://forge.test/o/r.git", parent: WORK, name: "x" },
{ Origin: "https://evil.example" }
);
assert.equal(r.status, 403);
});
});
describe("/api/me/git", () => {
it("makes a key once, returns only its public half, and deletes it", async () => {
await request("DELETE", "/api/me/git/ssh-key");
const made = await request("POST", "/api/me/git/ssh-key", {});
assert.equal(made.status, 201);
assert.match(made.body.ssh.publicKey, /^ssh-ed25519 /);
assert.match(made.body.ssh.fingerprint, /^SHA256:/);
assert.ok(!JSON.stringify(made.body).includes("PRIVATE"));
const key = path.join(ROOT, ".claude", "agenthub-ssh", "id_ed25519");
assert.equal(fs.statSync(key).mode & 0o777, 0o600);
// ssh-keygen reads it back to the same public key.
const pub = execFileSync("ssh-keygen", ["-y", "-f", key], { encoding: "utf8" }).trim();
assert.equal(
pub.split(" ").slice(0, 2).join(" "),
made.body.ssh.publicKey.split(" ").slice(0, 2).join(" ")
);
const twice = await request("POST", "/api/me/git/ssh-key", {});
assert.equal(twice.status, 409);
const replaced = await request("POST", "/api/me/git/ssh-key", { replace: true });
assert.equal(replaced.status, 201);
assert.notEqual(replaced.body.ssh.publicKey, made.body.ssh.publicKey);
const del = await request("DELETE", "/api/me/git/ssh-key");
assert.equal(del.body.ssh, null);
});
it("reads and sets the commit author, refusing a bad one", async () => {
const r0 = await request("GET", "/api/me/git");
assert.deepEqual(r0.body.author, { name: null, email: null });
const bad = await request("PUT", "/api/me/git/author", { name: "a\nb", email: "x@y" });
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "EBADAUTHOR");
const ok = await request("PUT", "/api/me/git/author", { name: "Vi", email: "vi@example.com" });
assert.equal(ok.status, 200);
assert.deepEqual(ok.body.author, { name: "Vi", email: "vi@example.com" });
assert.match(fs.readFileSync(path.join(ROOT, ".gitconfig"), "utf8"), /email = vi@example.com/);
});
it("saves an https login without ever returning the token", async () => {
const r = await request("POST", "/api/me/git/https", {
host: "git.example.test",
username: "vi",
password: "tok-secret",
});
assert.equal(r.status, 200);
assert.equal(r.body.account, "host");
assert.equal(r.body.https[0].host, "git.example.test");
assert.ok(!JSON.stringify(r.body).includes("tok-secret"));
const del = await request("DELETE", "/api/me/git/https/git.example.test");
assert.deepEqual(del.body.https, []);
});
});
describe("git-identity, member account (S3, stubbed)", () => {
const runAs = require("../lib/run-as");
const worktree = require("../lib/worktree");
const HOME = path.join(ROOT, "users", "an");
// This process's own uid: the key is checked to be the member's own file.
const acct = { name: "an", login: "ah-an", home: HOME, uid: process.getuid() };
const saved = {};
let calls = [];
before(() => {
fs.mkdirSync(path.join(HOME, "proj"), { recursive: true });
for (const k of ["accountForPath", "readFileSafe", "mkdirAs", "writeAs", "removeAs"]) {
saved[k] = runAs[k];
}
saved.git = worktree.git;
const inHome = (p) => path.resolve(p).startsWith(HOME);
runAs.accountForPath = (p) => (inHome(p) ? acct : null);
runAs.readFileSafe = (f, enc) => fs.readFileSync(f, enc);
runAs.mkdirAs = (_a, d) => fs.mkdirSync(d, { recursive: true });
runAs.writeAs = (_a, f, mode, content) => {
if (mode === "remove") return fs.rmSync(f, { recursive: true, force: true });
fs.writeFileSync(f, content, { mode: mode === "private" ? 0o600 : 0o644 });
};
runAs.removeAs = (_a, f) => fs.rmSync(f, { recursive: true, force: true });
worktree.git = async (cwd, args) => {
calls.push({ cwd, args });
return { stdout: "", stderr: "" };
};
});
after(() => {
for (const [k, v] of Object.entries(saved)) {
if (k === "git") worktree.git = v;
else runAs[k] = v;
}
});
it("keeps the key and the logins in the member's own home", () => {
const account = identity.accountForDir(path.join(HOME, "proj"));
assert.equal(account.kind, "member");
identity.generateSshKey(account);
const key = path.join(HOME, ".config", "agenthub", "ssh", "id_ed25519");
assert.equal(fs.statSync(key).mode & 0o777, 0o600);
identity.saveHttps(account, { host: "gitlab.test", username: "an", password: "p@ss/1" });
const store = path.join(HOME, ".config", "agenthub", "git-credentials");
assert.equal(fs.statSync(store).mode & 0o777, 0o600);
assert.equal(fs.readFileSync(store, "utf8"), "https://an:p%40ss%2F1@gitlab.test\n");
assert.deepEqual(identity.listHttps(account), [
{ protocol: "https", host: "gitlab.test", username: "an", hasToken: true },
]);
});
it("clones over ssh with the member's key and persists it", async () => {
calls = [];
await identity.cloneRepo({
url: "git@gh.test:o/r.git",
parent: path.join(HOME, "proj"),
name: "r",
});
const { args } = calls[0];
const i = args.indexOf("clone");
assert.match(
args[i - 1],
/^core\.sshCommand=ssh -i '.*\/\.config\/agenthub\/ssh\/id_ed25519'.*BatchMode=yes/
);
assert.match(args[i + 2], /^core\.sshCommand=ssh -i /);
assert.deepEqual(args.slice(-3), ["--", "git@gh.test:o/r.git", "r"]);
});
it("gives a member's fetch their own store, and puts a typed login in it only for the call", () => {
const account = identity.accountForDir(path.join(HOME, "proj"));
const store = path.join(HOME, ".config", "agenthub", "git-credentials");
const before = fs.readFileSync(store, "utf8");
const hosts = [{ protocol: "https", host: "gitlab.test" }];
const saved = identity.memberHttpsConfig(account, hosts, null);
assert.match(saved.args.join(" "), /credential\.helper=store --file=/);
assert.deepEqual(saved.stored("https", "gitlab.test"), { username: "an" });
assert.throws(
() =>
identity.memberHttpsConfig(account, hosts, {
host: "evil.test",
username: "u",
password: "p",
}),
{ code: "EBADHOST" }
);
const typed = identity.memberHttpsConfig(account, hosts, {
host: "gitlab.test",
username: "an",
password: "once-only",
});
assert.ok(!typed.args.join(" ").includes("once-only"));
assert.match(fs.readFileSync(store, "utf8"), /once-only/);
typed.finish(true); // worked, but not remembered
assert.equal(fs.readFileSync(store, "utf8"), before);
});
it("refuses to rewrite a store it cannot read rather than dropping every login", () => {
const account = identity.accountForDir(path.join(HOME, "proj"));
const read = runAs.readFileSafe;
runAs.readFileSafe = () => {
throw Object.assign(new Error("denied"), { code: "EACCES" });
};
try {
assert.throws(
() => identity.saveHttps(account, { host: "x.test", username: "u", password: "p" }),
{
code: "ESTOREUNREADABLE",
}
);
} finally {
runAs.readFileSafe = read;
}
});
it("uses a typed https login through the member's store, never argv, and forgets it", async () => {
calls = [];
const store = path.join(HOME, ".config", "agenthub", "git-credentials");
const before = fs.readFileSync(store, "utf8");
worktree.git = async (cwd, args) => {
calls.push({ cwd, args, during: fs.readFileSync(store, "utf8") });
return { stdout: "", stderr: "" };
};
await identity.cloneRepo({
url: "https://new.test/o/r.git",
parent: path.join(HOME, "proj"),
name: "r2",
once: { username: "an", password: "typed-secret", remember: false },
});
const { args, during } = calls[0];
assert.ok(!args.join(" ").includes("typed-secret"));
assert.ok(args.some((a) => /^credential\.helper=store --file=/.test(a)));
assert.match(during, /typed-secret@new\.test/);
// Not remembered: the store is back as it was, and nothing is persisted.
assert.equal(fs.readFileSync(store, "utf8"), before);
assert.equal(args.slice(args.indexOf("clone") + 1).filter((a) => a === "-c").length, 0);
});
});
+14
View File
@@ -135,6 +135,20 @@ describe("finish or abort", () => {
await assert.rejects(runGitAction(REPO, "merge-commit"), { code: "ENOMERGE" }); await assert.rejects(runGitAction(REPO, "merge-commit"), { code: "ENOMERGE" });
}); });
it("names the merge after the branch, as git merge would — not its full refname", async () => {
await merge();
await runGitAction(REPO, "resolve", { path: "a.txt", side: "ours" });
await runGitAction(REPO, "merge-commit");
assert.equal(g("log", "-1", "--format=%s").trim(), "Merge branch 'feat'");
// Into a branch other than main/master, git adds where it went.
g("switch", "-qc", "side", "feat~1");
write("c.txt", "c\n");
g("add", "-A");
g("commit", "-qm", "side");
await runGitAction(REPO, "merge", { branch: "main" });
assert.equal(g("log", "-1", "--format=%s").trim(), "Merge branch 'main' into side");
});
it("aborts only with confirm, back to where it started", async () => { it("aborts only with confirm, back to where it started", async () => {
await merge(); await merge();
await assert.rejects(runGitAction(REPO, "merge-abort"), { code: "ECONFIRM" }); await assert.rejects(runGitAction(REPO, "merge-abort"), { code: "ECONFIRM" });
+139
View File
@@ -0,0 +1,139 @@
/**
* @file Tests for a workspace folder that is NOT a git repo (issue #78): the
* Files tab's tree, expand-all and quick-open read off the disk
* (server/lib/git-diff.js `walkFolder`), and the file download / upload of
* server/lib/lane-file-transfer.js with their guards — confined to the
* folder, no secrets, an upload that only creates and never touches `.git`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { inRepo, listPaths, listTree, listTreeAll, readFileAt } = require("../lib/git-diff");
const { openDownload, saveUpload, UPLOAD_MAX_BYTES } = require("../lib/lane-file-transfer");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-plainfolder-"));
const WS = path.join(ROOT, "ws");
const OUTSIDE = path.join(ROOT, "outside");
function write(file, body) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, body);
}
async function readAll(stream) {
const chunks = [];
for await (const c of stream) chunks.push(c);
return Buffer.concat(chunks).toString("utf8");
}
describe("a workspace folder that is not a git repo", () => {
before(() => {
write(path.join(WS, "sonar_issues.json"), '{"ok":true}\n');
write(path.join(WS, "autocoupon-api", "src", "main.py"), "print(1)\n");
write(path.join(WS, "node_modules", "dep", "index.js"), "x\n");
write(path.join(WS, ".claude", "settings.local.json"), '{"key":"sk"}\n');
write(path.join(OUTSIDE, "secret.txt"), "not yours\n");
fs.mkdirSync(path.join(WS, ".git-not"), { recursive: true });
fs.symlinkSync(OUTSIDE, path.join(WS, "out-link"));
});
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
it("is not a repo, and its tree lists the disk one level at a time", async () => {
assert.equal(await inRepo(WS), false);
const top = await listTree(WS);
assert.deepEqual(
top.entries.map((e) => `${e.dir ? "d" : "f"}:${e.path}${e.ignored ? "!" : ""}`),
[
"d:.claude",
"d:.git-not",
"d:autocoupon-api",
"d:node_modules!",
"f:out-link",
"f:sonar_issues.json",
]
);
const sub = await listTree(WS, { subdir: "autocoupon-api" });
assert.deepEqual(
sub.entries.map((e) => e.path),
["autocoupon-api/src"]
);
await assert.rejects(listTree(WS, { subdir: "../outside" }), { code: "EBADPATH" });
await assert.rejects(listTree(WS, { subdir: "out-link" }), { code: "EBADPATH" });
});
it("expands every level in one read, node_modules kept shut", async () => {
const { levels, truncated } = await listTreeAll(WS);
assert.equal(truncated, false);
assert.deepEqual(
levels["autocoupon-api/src"].map((e) => e.name),
["main.py"]
);
assert.equal(levels.node_modules, undefined);
assert.ok(levels[""].some((e) => e.path === "node_modules" && e.ignored));
});
it("quick-open finds a file by name, never inside node_modules or out of the folder", async () => {
assert.deepEqual((await listPaths(WS, { q: "main" })).paths, ["autocoupon-api/src/main.py"]);
assert.deepEqual((await listPaths(WS, { q: "index.js" })).paths, []);
assert.deepEqual((await listPaths(WS, { q: "secret" })).paths, []);
});
it("opens a file for viewing without git", async () => {
const f = await readFileAt(WS, "sonar_issues.json");
assert.deepEqual(f.lines, ['{"ok":true}']);
});
it("downloads a file, never a secret, a folder or one outside", async () => {
const d = await openDownload(WS, "autocoupon-api/src/main.py");
assert.equal(d.name, "main.py");
assert.equal(d.size, 9);
assert.equal(await readAll(d.stream), "print(1)\n");
await assert.rejects(openDownload(WS, ".claude/settings.local.json"), { code: "ESECRET" });
await assert.rejects(openDownload(WS, "../outside/secret.txt"), { code: "EBADPATH" });
await assert.rejects(openDownload(WS, "out-link/secret.txt"), { code: "EBADPATH" });
await assert.rejects(openDownload(WS, "autocoupon-api"), { code: "ENOFILE" });
await assert.rejects(openDownload(WS, "missing.txt"), { code: "ENOFILE" });
});
it("uploads a new file into an existing folder, and only creates", async () => {
const body = Buffer.from("report\n");
assert.deepEqual(await saveUpload(WS, "", "report.md", body), { path: "report.md" });
assert.equal(fs.readFileSync(path.join(WS, "report.md"), "utf8"), "report\n");
assert.deepEqual(await saveUpload(WS, "autocoupon-api", "notes.txt", body), {
path: "autocoupon-api/notes.txt",
});
await assert.rejects(saveUpload(WS, "", "report.md", Buffer.from("again")), {
code: "EEXISTS",
});
assert.equal(fs.readFileSync(path.join(WS, "report.md"), "utf8"), "report\n");
// A symlink planted at the name is "taken" too, never written through.
fs.symlinkSync(path.join(OUTSIDE, "planted.txt"), path.join(WS, "planted.txt"));
await assert.rejects(saveUpload(WS, "", "planted.txt", body), { code: "EEXISTS" });
assert.equal(fs.existsSync(path.join(OUTSIDE, "planted.txt")), false);
});
it("refuses an upload out of the folder, into .git, over a secret name or too big", async () => {
const body = Buffer.from("x");
await assert.rejects(saveUpload(WS, "../outside", "a.txt", body), { code: "EBADPATH" });
await assert.rejects(saveUpload(WS, "out-link", "a.txt", body), { code: "EBADPATH" });
await assert.rejects(saveUpload(WS, "", "../a.txt", body), { code: "EBADNAME" });
await assert.rejects(saveUpload(WS, "", "", body), { code: "EBADNAME" });
await assert.rejects(saveUpload(WS, ".git/hooks", "pre-commit", body), {
code: "EBADPATH",
});
await assert.rejects(saveUpload(WS, "", ".git", body), { code: "EBADNAME" });
await assert.rejects(saveUpload(WS, ".claude", "settings.local.json", body), {
code: "ESECRET",
});
await assert.rejects(saveUpload(WS, "no-such-dir", "a.txt", body), { code: "ENOFILE" });
await assert.rejects(saveUpload(WS, "", "big.bin", Buffer.alloc(UPLOAD_MAX_BYTES + 1)), {
code: "ETOOBIG",
});
assert.equal(fs.existsSync(path.join(OUTSIDE, "a.txt")), false);
});
});
+16 -4
View File
@@ -157,11 +157,23 @@ describe("git and files in a member's folder run as the member", () => {
host.remove(); host.remove();
}); });
it("never hands a member's git the credential store, nor a typed password", async () => { it("never hands a member's git the host's store, nor a typed password in argv or env", async () => {
assert.deepEqual(await gitConfigFor(HOME), { args: [], env: {} }); const cfg = await gitConfigFor(HOME);
// Nothing saved in the member's own store: no helper of ours, no env at all.
assert.deepEqual(cfg.args, []);
assert.deepEqual(cfg.env, {});
assert.equal(cfg.member, true);
// A typed login is only for one of the repo's own remotes (git-identity.js
// puts it in the member's store through write-as's stdin).
await assert.rejects( await assert.rejects(
gitConfigFor(HOME, { protocol: "https", host: "github.com", username: "u", password: "p" }), gitConfigFor(HOME, {
{ code: "EMEMBERCRED" } protocol: "https",
host: "github.com",
username: "u",
password: "p-secret",
}),
{ code: "EBADHOST" }
); );
assert.ok(!JSON.stringify(sudoCalls()).includes("p-secret"));
}); });
}); });
@@ -10,6 +10,9 @@
* capped per home; nobody reads the dot folders at the top of a member's home * capped per home; nobody reads the dot folders at the top of a member's home
* (logins, pane tokens), and the home itself is no workspace; a disabled * (logins, pane tokens), and the home itself is no workspace; a disabled
* member's session neither throws in a read nor stops the liveness reaper. * member's session neither throws in a read nor stops the liveness reaper.
* The Files tab's download and upload (issue #78) are held to the same: a
* link out is refused both ways, and a member's own upload is written AS the
* member, through write-as.
* A fake passwd (every member = this test's uid) and server.conf make the * A fake passwd (every member = this test's uid) and server.conf make the
* members provisioned; fake sudo runs their commands as this account. * members provisioned; fake sudo runs their commands as this account.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -167,6 +170,65 @@ describe("a workspace folder swapped for a link", () => {
}); });
}); });
describe("the Files tab's download and upload (issue #78)", () => {
it("are refused through a link out of the member's home", async () => {
const d = await call("mia", "GET", `/api/lanes/${lanes.tobob}/git/download?path=bob.txt`);
assert.equal(d.status, 403, JSON.stringify(d.body));
assert.equal(d.body.error.code, "EFOREIGN");
const { saveUpload } = require("../lib/lane-file-transfer");
await assert.rejects(saveUpload(path.join(MIA, "tohost"), "", "x.txt", Buffer.from("x")), {
code: "EFOREIGN",
});
assert.equal(fs.existsSync(path.join(HOSTDIR, "x.txt")), false);
});
it("downloads mia's own file, and uploads into her folder as mia", async () => {
const own = addLane(path.join(MIA, "proj"), "mia");
fs.writeFileSync(path.join(MIA, "proj", "out.json"), '{"a":1}\n');
const res = await fetch(`${BASE}/api/lanes/${own}/git/download?path=out.json`, {
headers: { cookie: cookies.mia },
});
assert.equal(res.status, 200);
assert.match(res.headers.get("content-disposition"), /attachment; filename="out.json"/);
assert.equal(await res.text(), '{"a":1}\n');
const sudo = fakeSudo();
runAs.__setExecImpl(sudo.exec);
try {
const up = await fetch(`${BASE}/api/lanes/${own}/git/upload?dir=`, {
method: "POST",
headers: {
cookie: cookies.mia,
"Content-Type": "application/octet-stream",
"X-File-Name": encodeURIComponent("báo cáo.md"),
},
body: "# hi\n",
});
assert.equal(up.status, 201, await up.clone().text());
assert.deepEqual(await up.json(), { path: "báo cáo.md" });
assert.equal(fs.readFileSync(path.join(MIA, "proj", "báo cáo.md"), "utf8"), "# hi\n");
// Through write-as, as ah-mia — the content on stdin, never in argv.
const call1 = sudo.calls.find((c) => c.includes("/opt/agenthub/libexec/write-as"));
assert.ok(call1, JSON.stringify(sudo.calls));
assert.ok(call1.includes("ah-mia"));
assert.ok(!call1.some((a) => String(a).includes("# hi")));
// bob may not upload into mia's workspace.
const bob = await fetch(`${BASE}/api/lanes/${own}/git/upload?dir=`, {
method: "POST",
headers: {
cookie: cookies.bob,
"Content-Type": "application/octet-stream",
"X-File-Name": "b.txt",
},
body: "b",
});
assert.equal(bob.status, 404);
} finally {
runAs.__setExecImpl(fakeSudo().exec);
}
});
});
describe("a member's git dir that names a path outside their home", () => { describe("a member's git dir that names a path outside their home", () => {
it("is neither read nor written by the host", () => { it("is neither read nor written by the host", () => {
const { excludeFromGit } = require("../lib/git-exclude"); const { excludeFromGit } = require("../lib/git-exclude");
+8
View File
@@ -1353,6 +1353,14 @@ try {
db.prepare("ALTER TABLE sessions ADD COLUMN archived_at TEXT").run(); db.prepare("ALTER TABLE sessions ADD COLUMN archived_at TEXT").run();
} }
// Migrate: add `pinned_at` to sessions — a session the user pinned to the top
// of its workspace's sidebar list (issue #80). Order only; NULL = not pinned.
try {
db.prepare("SELECT pinned_at FROM sessions LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE sessions ADD COLUMN pinned_at TEXT").run();
}
// How far each Codex rollout journal has been read (server/lib/codex-usage.js). // How far each Codex rollout journal has been read (server/lib/codex-usage.js).
// In the DB, not a side file, so a session the user deleted stays deleted // In the DB, not a side file, so a session the user deleted stays deleted
// until Codex writes to its journal again, while a wiped DB re-imports all. // until Codex writes to its journal again, while a wiped DB re-imports all.
+3
View File
@@ -108,6 +108,7 @@ const ccConfigRouter = require("./routes/cc-config");
const codexConfigRouter = require("./routes/codex-config"); const codexConfigRouter = require("./routes/codex-config");
const aiProxiesRouter = require("./routes/ai-proxies"); const aiProxiesRouter = require("./routes/ai-proxies");
const gitCredentialsRouter = require("./routes/git-credentials"); const gitCredentialsRouter = require("./routes/git-credentials");
const gitIdentityRouter = require("./routes/git-identity");
const aiToolsRouter = require("./routes/ai-tools"); const aiToolsRouter = require("./routes/ai-tools");
const runRouter = require("./routes/run"); const runRouter = require("./routes/run");
const alertsRouter = require("./routes/alerts"); const alertsRouter = require("./routes/alerts");
@@ -159,6 +160,8 @@ function createApp() {
app.use("/api", scopeGuard); app.use("/api", scopeGuard);
app.use("/api", auditGuard); app.use("/api", auditGuard);
app.use("/api/auth", authRouter); app.use("/api/auth", authRouter);
// Before /api/me: the user's own SSH key and HTTPS logins (git-identity.js).
app.use("/api/me/git", gitIdentityRouter);
app.use("/api/me", meRouter); app.use("/api/me", meRouter);
app.use("/api/accounts", accountsRouter); app.use("/api/accounts", accountsRouter);
app.use("/api/users", usersRouter); app.use("/api/users", usersRouter);
+15 -12
View File
@@ -13,7 +13,9 @@
* helper (git-credential-helper.js) the ONLY one, plus the env it reads. Only * helper (git-credential-helper.js) the ONLY one, plus the env it reads. Only
* ours, because a stale token in the machine's helper would win over the one * ours, because a stale token in the machine's helper would win over the one
* just typed, and git's post-success `store` would copy a use-once token into * just typed, and git's post-success `store` would copy a use-once token into
* the OS user's own helper. The secret itself never reaches argv. * the OS user's own helper. The secret itself never reaches argv. A member's
* folder (S3) gets the member's OWN store instead (`git-identity.js`), and an
* ssh remote the account's AgentHub key with no prompt (`sshConfigFor`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -153,17 +155,17 @@ const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
* remote hosts — a key typed for one server is never sent to another. * remote hosts — a key typed for one server is never sent to another.
*/ */
async function gitConfigFor(dir, once) { async function gitConfigFor(dir, once) {
const identity = require("./git-identity");
// An ssh remote: the account's own AgentHub key, and never a prompt.
const ssh = await identity.sshConfigFor(dir);
// S3: a member's git runs as the member (sudo), which can neither read this // S3: a member's git runs as the member (sudo), which can neither read this
// host-owned store nor carry a typed password (sudo logs its command line). // host-owned store nor carry a typed password (sudo logs its command line):
// Their own git login applies (`git credential`, `gh auth` in their terminal). // their OWN store in their home answers instead (git-identity.js), and a
if (require("./run-as").accountForPath(dir)) { // login typed for this call reaches it through write-as's stdin.
if (once) { const acct = require("./run-as").accountForPath(dir);
throw err( if (acct) {
"EMEMBERCRED", const m = identity.memberHttpsConfig({ kind: "member", acct }, await remoteHosts(dir), once);
"log in to this git host from your own terminal (git credential store or gh auth login)" return { args: [...ssh, ...m.args], env: {}, member: true, finish: m.finish, stored: m.stored };
);
}
return { args: [], env: {} };
} }
const hosts = await remoteHosts(dir); const hosts = await remoteHosts(dir);
const env = {}; const env = {};
@@ -178,7 +180,7 @@ async function gitConfigFor(dir, once) {
password: String(once.password || ""), password: String(once.password || ""),
}); });
} else if (!hosts.some((h) => find(h.protocol, h.host))) { } else if (!hosts.some((h) => find(h.protocol, h.host))) {
return { args: [], env: {} }; return { args: ssh, env: {} };
} }
// ponytail: while AgentHub answers for one host, a remote on ANOTHER host // ponytail: while AgentHub answers for one host, a remote on ANOTHER host
// gets no machine helper either (see the file header); chain the machine's // gets no machine helper either (see the file header); chain the machine's
@@ -186,6 +188,7 @@ async function gitConfigFor(dir, once) {
env.AGENTHUB_GIT_CRED_FILE = credentialsFile(); env.AGENTHUB_GIT_CRED_FILE = credentialsFile();
return { return {
args: [ args: [
...ssh,
"-c", "-c",
"credential.helper=", "credential.helper=",
"-c", "-c",
+138 -6
View File
@@ -21,6 +21,12 @@
* every path in and out is cwd-relative, status entries outside the cwd are * every path in and out is cwd-relative, status entries outside the cwd are
* dropped, and every git call treats a path as a literal file name, never a * dropped, and every git call treats a path as a literal file name, never a
* pathspec glob (`GIT_LITERAL_PATHSPECS`). * pathspec glob (`GIT_LITERAL_PATHSPECS`).
*
* The file explorer also serves a workspace folder that is NOT a git repo
* (issue #78): `listTree`, `listTreeAll` and `listPaths` then read the disk
* (`walkFolder` — confined to the folder's real path, never following a
* symlink, capped) instead of `git ls-files`, and the file reads above never
* needed git for the working copy.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -911,7 +917,11 @@ function contentSha(buf) {
* skip-list has to be maintained here. * skip-list has to be maintained here.
*/ */
async function listPaths(dir, { q = "", limit = MAX_PATHS } = {}) { async function listPaths(dir, { q = "", limit = MAX_PATHS } = {}) {
const { all, cut } = await lsFiles(dir, []); const { all, cut } = await lsFiles(dir, []).catch(async (err) => {
if (await inRepo(dir)) throw err;
const walk = await walkFolder(dir);
return { all: walk.files, cut: walk.cut };
});
const needle = String(q || "") const needle = String(q || "")
.toLowerCase() .toLowerCase()
.trim(); .trim();
@@ -940,6 +950,87 @@ async function lsFiles(dir, args) {
return { all: [...new Set(all.filter(Boolean))], cut }; return { all: [...new Set(all.filter(Boolean))], cut };
} }
/** Whether `dir` is inside a git work tree (false for a plain folder). */
async function inRepo(dir) {
try {
return (await git(dir, ["rev-parse", "--is-inside-work-tree"])).stdout.trim() === "true";
} catch {
return false;
}
}
/** Most entries one `walkFolder` visits before it stops and reports `cut`. */
const MAX_WALK = 20_000;
/** Folders a walk lists but never enters (a repo's tree shows them as ignored). */
const WALK_SHUT = new Set(["node_modules"]);
/**
* A plain folder's files and folders under `sub`, read off the disk, for a
* workspace that is not a git repo: `depth` levels down (1 = `sub`'s own
* entries), `.git` never listed, a `WALK_SHUT` folder listed but not entered,
* a symlink listed as a file and never followed, the start confined to the
* workspace's real path like `addIgnored`. Paths are workspace-relative.
*/
async function walkFolder(dir, sub = "", depth = Infinity) {
const root = await laneRoot(dir);
const start = await fs.promises.realpath(path.join(root, sub));
const r = path.relative(root, start);
if (r === ".." || r.startsWith(`..${path.sep}`) || path.isAbsolute(r)) {
throw badRequest("EBADPATH", "path escapes the workspace");
}
const files = [];
const dirs = [];
const shut = new Set();
let seen = 0;
let cut = false;
const queue = [[sub, 1]];
while (queue.length && !cut) {
const [rel, level] = queue.shift();
let dirents;
try {
dirents = await fs.promises.readdir(path.join(root, rel), { withFileTypes: true });
} catch {
continue;
}
for (const d of dirents) {
if (d.name === ".git") continue;
if (++seen > MAX_WALK) {
cut = true;
break;
}
const p = rel ? `${rel}/${d.name}` : d.name;
if (!d.isDirectory()) files.push(p);
else {
dirs.push(p);
if (WALK_SHUT.has(d.name)) shut.add(p);
else if (level < depth) queue.push([p, level + 1]);
}
}
}
return { files, dirs, shut, cut };
}
/** One explorer level of a plain folder (`listTree` off the disk). */
async function folderLevel(dir, sub) {
const { files, dirs, shut, cut } = await walkFolder(dir, sub, 1);
const prefix = sub ? `${sub}/` : "";
const name = (p) => p.slice(prefix.length);
const entries = [
...dirs
.map((p) => ({
name: name(p),
path: p,
dir: true,
...(shut.has(p) ? { ignored: true } : {}),
}))
.sort((a, b) => a.name.localeCompare(b.name)),
...files
.map((p) => ({ name: name(p), path: p, dir: false }))
.sort((a, b) => a.name.localeCompare(b.name)),
];
return { entries: entries.slice(0, MAX_PATHS), truncated: cut || entries.length > MAX_PATHS };
}
/** What the explorer lists: tracked files plus untracked ones git does not /** What the explorer lists: tracked files plus untracked ones git does not
* ignore — the new file an agent just wrote is the one you want to open. */ * ignore — the new file an agent just wrote is the one you want to open. */
const TREE_LS = ["--cached", "--others", "--exclude-standard"]; const TREE_LS = ["--cached", "--others", "--exclude-standard"];
@@ -1026,10 +1117,15 @@ async function listTree(dir, { subdir = "" } = {}) {
.replace(/^\/+|\/+$/g, "") .replace(/^\/+|\/+$/g, "")
.trim(); .trim();
if (clean.split("/").includes("..")) throw badRequest("EBADPATH", "path escapes the workspace"); if (clean.split("/").includes("..")) throw badRequest("EBADPATH", "path escapes the workspace");
const [{ all, cut }, st] = await Promise.all([ let listed;
lsFiles(dir, clean ? [...TREE_LS, "--", `${clean}/`] : TREE_LS), try {
treeStatus(dir), listed = await lsFiles(dir, clean ? [...TREE_LS, "--", `${clean}/`] : TREE_LS);
]); } catch (err) {
if (await inRepo(dir)) throw err;
return { path: clean, ...(await folderLevel(dir, clean)) };
}
const { all, cut } = listed;
const st = await treeStatus(dir);
const prefix = clean ? `${clean}/` : ""; const prefix = clean ? `${clean}/` : "";
const dirs = new Set(); const dirs = new Set();
const files = []; const files = [];
@@ -1171,7 +1267,15 @@ function onlyBlock(hunk, block, reverse) {
* per folder, which on a large repo is thousands of git processes. * per folder, which on a large repo is thousands of git processes.
*/ */
async function listTreeAll(dir) { async function listTreeAll(dir) {
const [{ all, cut }, st] = await Promise.all([lsFiles(dir, TREE_LS), treeStatus(dir)]); let listed;
try {
listed = await lsFiles(dir, TREE_LS);
} catch (err) {
if (await inRepo(dir)) throw err;
return plainTreeAll(dir);
}
const { all, cut } = listed;
const st = await treeStatus(dir);
const byDir = new Map(); const byDir = new Map();
const level = (d) => { const level = (d) => {
let l = byDir.get(d); let l = byDir.get(d);
@@ -1203,12 +1307,40 @@ async function listTreeAll(dir) {
return { levels, truncated }; return { levels, truncated };
} }
/** `listTreeAll` for a plain folder: one capped walk, every level from it. */
async function plainTreeAll(dir) {
const { files, dirs, shut, cut } = await walkFolder(dir);
const levels = { "": [] };
const parentOf = (p) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "");
const nameOf = (p) => p.slice(p.lastIndexOf("/") + 1);
// A shut folder (node_modules) gets no level of its own: it stays closed.
for (const p of dirs) if (!shut.has(p)) levels[p] = [];
for (const p of dirs) {
levels[parentOf(p)]?.push({
name: nameOf(p),
path: p,
dir: true,
...(shut.has(p) ? { ignored: true } : {}),
});
}
for (const p of files) levels[parentOf(p)]?.push({ name: nameOf(p), path: p, dir: false });
let truncated = cut;
for (const [d, entries] of Object.entries(levels)) {
entries.sort((a, b) => Number(b.dir) - Number(a.dir) || a.name.localeCompare(b.name));
if (entries.length > MAX_PATHS) truncated = true;
levels[d] = entries.slice(0, MAX_PATHS);
}
return { levels, truncated };
}
module.exports = { module.exports = {
collectChanges, collectChanges,
CONFLICT_CODES, CONFLICT_CODES,
contentSha, contentSha,
inRepo,
isSecretOnDisk, isSecretOnDisk,
lanePath, lanePath,
laneRoot,
looksBinary, looksBinary,
resolveInLane, resolveInLane,
MAX_FILE_VIEW_BYTES, MAX_FILE_VIEW_BYTES,
+570
View File
@@ -0,0 +1,570 @@
/**
* @file A user's OWN git identity — the SSH key and the HTTPS logins a clone
* (and every later fetch/pull/push in the cloned repo) authenticates with —
* and the clone itself (`cloneRepo`, behind `POST /api/lanes/clone`).
*
* Whose identity is decided by an ACCOUNT, never by the request:
* - a member (S3, `run-as.js`): everything lives in their own home, under
* `~/.config/agenthub/` — `ssh/id_ed25519` (+ `.pub`) and `git-credentials`
* (git's own `credential-store` format) — written only AS the member
* (`write-as`, 0600 for the secrets), read only through `readFileSafe`.
* Their git runs as them, so it reads these itself; no secret ever crosses
* sudo's argv or env (sudo logs both).
* - the host (local mode, an admin's folder outside the users root): the
* shared store of `git-credentials.js` for HTTPS, and one key under
* `<CLAUDE_HOME>/agenthub-ssh/` (0700/0600) for SSH.
*
* The key pair is made here (Node's ed25519) and written in OpenSSH's own
* format, because a member's sudoers allows git but not `ssh-keygen`. Only the
* PUBLIC half is ever returned. A clone persists the identity into the new
* repo's own config (`git clone -c`): `core.sshCommand` naming the key, or a
* `credential.helper=store --file=<the member's store>` — so an agent's push
* from a pane uses the same login without touching the user's global config.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const crypto = require("node:crypto");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { getClaudeHome } = require("./claude-home");
const gitCredentials = require("./git-credentials");
const runAs = require("./run-as");
const HOST_RE = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:\d{1,5})?$/;
/** A clone may take a while on a big repo; past this it is killed. */
const CLONE_TIMEOUT_MS = 10 * 60_000;
const HELPER = path.join(__dirname, "git-credential-helper.js");
const err = (code, message) => Object.assign(new Error(message), { code });
const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
// ── Accounts ───────────────────────────────────────────────────────────────
/** `{kind: "member", acct}` or `{kind: "host"}` for the folder `dir`. */
function accountForDir(dir) {
const acct = runAs.accountForPath(dir);
return acct ? { kind: "member", acct } : { kind: "host" };
}
/**
* The identity a signed-in user manages in Settings: their own Linux account
* under S3, else the host's — which only an admin (or local mode) may change.
*/
function accountForRequest(req) {
const user = req?.user;
if (user) {
const acct = runAs.accountForUser(user.username);
if (acct) return { kind: "member", acct };
}
return { kind: "host", writable: !user || user.role === "admin" };
}
function paths(account) {
if (account.kind === "member") {
const base = path.join(account.acct.home, ".config", "agenthub");
return {
base,
sshDir: path.join(base, "ssh"),
key: path.join(base, "ssh", "id_ed25519"),
store: path.join(base, "git-credentials"),
};
}
const sshDir = path.join(getClaudeHome(), "agenthub-ssh");
return { base: sshDir, sshDir, key: path.join(sshDir, "id_ed25519"), store: null };
}
/**
* A file's text, or null when it is missing. `strict`: any OTHER failure
* (unreadable, not the member's own file) throws — a store about to be
* rewritten must never be read as empty, or the rewrite drops every login.
*/
function readText(account, file, { strict = false } = {}) {
try {
return account.kind === "member"
? runAs.readFileSafe(file, "utf8", { max: 256 * 1024 })
: fs.readFileSync(file, "utf8");
} catch (e) {
if (strict && e.code !== "ENOENT") {
throw err("ESTOREUNREADABLE", `cannot read ${path.basename(file)}: ${e.code || e.message}`);
}
return null;
}
}
/** Whether `file` is a regular file (a member's: one they own) — never read. */
function isOwnFile(account, file) {
try {
const st = fs.lstatSync(file);
return st.isFile() && (account.kind !== "member" || st.uid === account.acct.uid);
} catch {
return false;
}
}
/** Write `content` 0600 (`private`) or 0644, making the folder first. */
function writeText(account, file, content, { secret }) {
if (account.kind === "member") {
runAs.mkdirAs(account.acct, path.dirname(file));
runAs.writeAs(account.acct, file, secret ? "private" : "replace", content);
return;
}
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
const tmp = `${file}.agenthub-tmp`;
fs.writeFileSync(tmp, content, { mode: secret ? 0o600 : 0o644 });
fs.renameSync(tmp, file);
}
function removeFile(account, file) {
if (account.kind === "member") runAs.removeAs(account.acct, file);
else fs.rmSync(file, { force: true });
}
// ── SSH key ────────────────────────────────────────────────────────────────
const u32 = (n) => {
const b = Buffer.alloc(4);
b.writeUInt32BE(n >>> 0);
return b;
};
const sshString = (buf) => Buffer.concat([u32(buf.length), Buffer.from(buf)]);
/**
* A fresh ed25519 pair in OpenSSH's formats: `{privateKey, publicKey}` text,
* unencrypted (`openssh-key-v1`, cipher none) — the dashboard has no terminal
* to type a passphrase into.
*/
function makeKeyPair(comment) {
const pair = crypto.generateKeyPairSync("ed25519");
const pub = Buffer.from(pair.publicKey.export({ format: "jwk" }).x, "base64url");
const seed = Buffer.from(pair.privateKey.export({ format: "jwk" }).d, "base64url");
const type = Buffer.from("ssh-ed25519");
const pubBlob = Buffer.concat([sshString(type), sshString(pub)]);
const check = crypto.randomBytes(4);
let priv = Buffer.concat([
check,
check,
sshString(type),
sshString(pub),
sshString(Buffer.concat([seed, pub])),
sshString(Buffer.from(comment, "utf8")),
]);
const pad = [];
for (let i = 1; (priv.length + pad.length) % 8 !== 0; i++) pad.push(i);
priv = Buffer.concat([priv, Buffer.from(pad)]);
const body = Buffer.concat([
Buffer.from("openssh-key-v1\0"),
sshString(Buffer.from("none")),
sshString(Buffer.from("none")),
sshString(Buffer.alloc(0)),
u32(1),
sshString(pubBlob),
sshString(priv),
]);
const b64 = body.toString("base64").replace(/.{70}/g, "$&\n");
return {
privateKey: `-----BEGIN OPENSSH PRIVATE KEY-----\n${b64.replace(/\n$/, "")}\n-----END OPENSSH PRIVATE KEY-----\n`,
publicKey: `ssh-ed25519 ${pubBlob.toString("base64")} ${comment}\n`,
};
}
/** `SHA256:…` as `ssh-keygen -l` prints it, from a public key line. */
function fingerprint(publicKey) {
const blob = Buffer.from(String(publicKey).trim().split(/\s+/)[1] || "", "base64");
return `SHA256:${crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/, "")}`;
}
/** `{publicKey, fingerprint}` of the account's key, or null — never the private half. */
function sshKeyInfo(account) {
const { key } = paths(account);
const publicKey = readText(account, `${key}.pub`);
if (!publicKey || !/^ssh-ed25519 [A-Za-z0-9+/=]+/.test(publicKey)) return null;
// The private half is only looked at, never read into this process.
if (!isOwnFile(account, key)) return null;
return { publicKey: publicKey.trim(), fingerprint: fingerprint(publicKey) };
}
/** Make the account's key; an existing one is replaced only with `replace`. */
function generateSshKey(account, { replace = false, comment } = {}) {
if (sshKeyInfo(account) && !replace) {
throw err("EEXISTS", "an SSH key already exists — replace it explicitly");
}
const label = String(comment || "agenthub")
.replace(/[^\w.@+-]/g, "")
.slice(0, 64);
const { privateKey, publicKey } = makeKeyPair(label || "agenthub");
const { key } = paths(account);
writeText(account, key, privateKey, { secret: true });
writeText(account, `${key}.pub`, publicKey, { secret: false });
return sshKeyInfo(account);
}
function removeSshKey(account) {
const { key } = paths(account);
if (!sshKeyInfo(account)) return false;
removeFile(account, key);
removeFile(account, `${key}.pub`);
return true;
}
// ── HTTPS logins ───────────────────────────────────────────────────────────
function hostKey(protocol, host) {
const p = String(protocol || "").toLowerCase();
const h = String(host || "").toLowerCase();
if (p !== "https" || !HOST_RE.test(h)) throw err("EBADHOST", "an https host[:port] is required");
return { protocol: p, host: h };
}
/** The member's store, parsed: `[{protocol, host, username, password}]`. */
function readStore(account, { strict = false } = {}) {
const text = readText(account, paths(account).store, { strict }) || "";
const rows = [];
for (const line of text.split("\n")) {
try {
const u = new URL(line.trim());
if (u.protocol !== "https:" || !u.password) continue;
rows.push({
protocol: "https",
host: u.host.toLowerCase(),
username: decodeURIComponent(u.username),
password: decodeURIComponent(u.password),
});
} catch {
/* not a credential line */
}
}
return rows;
}
function writeStore(account, rows) {
const enc = (s) => encodeURIComponent(s);
const text = rows
.map((r) => `https://${enc(r.username)}:${enc(r.password)}@${r.host}\n`)
.join("");
writeText(account, paths(account).store, text, { secret: true });
}
function listHttps(account) {
if (account.kind === "host") return gitCredentials.list();
return readStore(account).map(({ protocol, host, username }) => ({
protocol,
host,
username,
hasToken: true,
}));
}
function checkCred(username, password) {
if (typeof username !== "string" || !username.trim() || /[\r\n]/.test(username)) {
throw err("EBADCRED", "username is required");
}
if (typeof password !== "string" || !password || /[\r\n]/.test(password)) {
throw err("EBADCRED", "password or access token is required");
}
}
function saveHttps(account, { protocol = "https", host, username, password }) {
if (account.kind === "host") return gitCredentials.save({ protocol, host, username, password });
const k = hostKey(protocol, host);
checkCred(username, password);
const rows = readStore(account, { strict: true }).filter((r) => r.host !== k.host);
rows.push({ ...k, username: username.trim(), password });
writeStore(account, rows);
}
function removeHttps(account, protocol, host) {
if (account.kind === "host") return gitCredentials.remove(protocol, host);
const k = hostKey(protocol, host);
const rows = readStore(account, { strict: true });
const kept = rows.filter((r) => r.host !== k.host);
if (kept.length === rows.length) return false;
writeStore(account, kept);
return true;
}
// ── Commit author ──────────────────────────────────────────────────────────
/** The folder a `git config --global` for this account runs in. */
const homeOf = (account) => (account.kind === "member" ? account.acct.home : os.homedir());
/**
* `{name, email}` of the account's global git config (`user.name` /
* `user.email`, null when unset). As the member under S3: their own
* `~/.gitconfig`, which their panes' commits use too.
*/
async function getAuthor(account) {
const { git } = require("./worktree");
const get = (k) =>
git(homeOf(account), ["config", "--global", "--get", k]).then(
(r) => r.stdout.trim() || null,
() => null
);
return { name: await get("user.name"), email: await get("user.email") };
}
/**
* Set the account's commit author. Neither is a secret, so they may cross
* sudo's argv; each is one line, a name up to 100 and an email up to 254
* characters, the email `local@domain`.
*/
async function setAuthor(account, { name, email }) {
const n = typeof name === "string" ? name.trim() : "";
const e = typeof email === "string" ? email.trim() : "";
// eslint-disable-next-line no-control-regex
const ctl = /[\x00-\x1f\x7f]/;
if (!n || n.length > 100 || ctl.test(n)) throw err("EBADAUTHOR", "a name of 1–100 characters");
if (e.length > 254 || ctl.test(e) || !/^[^\s@]+@[^\s@]+$/.test(e)) {
throw err("EBADAUTHOR", "an email like name@example.com");
}
const { git } = require("./worktree");
await git(homeOf(account), ["config", "--global", "user.name", n]);
await git(homeOf(account), ["config", "--global", "user.email", e]);
return getAuthor(account);
}
// ── Network commands in an existing repo (git bar fetch/pull/push) ─────────
/**
* `-c core.sshCommand=…` for a repo with an ssh remote and no sshCommand of
* its own: the account's AgentHub key when there is one (the default keys
* are still tried — no IdentitiesOnly here), and never a prompt — no terminal
* answers one. A repo that sets its own sshCommand keeps it.
*/
async function sshConfigFor(dir) {
const { git } = require("./worktree");
const remotes = await git(dir, ["remote", "-v"]).then(
(r) => r.stdout,
() => ""
);
// Anything that is not http(s) or a local path is ssh to git.
const ssh = remotes
.split("\n")
.some((l) => /\t(ssh:\/\/|[^\s/:]+@[^\s:]+:)/i.test(l) && !/\t(https?|file):\/\//i.test(l));
if (!ssh) return [];
const own = await git(dir, ["config", "--get", "core.sshCommand"]).then(
(r) => r.stdout.trim(),
() => ""
);
if (own) return [];
let account;
try {
account = accountForDir(dir);
} catch {
return [];
}
const key = sshKeyInfo(account) ? paths(account).key : null;
return [
"-c",
`core.sshCommand=ssh${key ? ` -i ${shq(key)}` : ""} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`,
];
}
/**
* A member's (S3) https login for a fetch/pull/push: their own store, given
* to git as the only helper while it holds one of the repo's hosts. `once`
* (typed in the git bar, for one of `hosts` only) goes INTO that store
* through `write-as`'s stdin — the only way a secret reaches git running as
* them — and `finish(ok)` takes it out again unless it worked and
* `remember` was set. `stored(host)`: whether the store holds that host.
*/
function memberHttpsConfig(account, hosts, once) {
const before = readStore(account, { strict: true });
let finish = () => {};
if (once) {
const k = hostKey(once.protocol || "https", once.host);
if (!hosts.some((h) => h.host === k.host)) {
throw err("EBADHOST", `${k.host} is not a remote of this repository`);
}
checkCred(once.username, once.password);
saveHttps(account, { host: k.host, username: once.username, password: once.password });
finish = (ok) => {
if (ok && once.remember) return;
try {
writeStore(account, before);
} catch {
/* best effort: the store is the member's own */
}
};
}
const row = (host) => {
const r = before.find((x) => x.host === String(host).toLowerCase());
return r ? { username: r.username } : null;
};
const args =
once || hosts.some((h) => h.protocol === "https" && row(h.host))
? [
"-c",
"credential.helper=",
"-c",
`credential.helper=store --file=${shq(paths(account).store)}`,
]
: [];
return { args, finish, stored: (_protocol, host) => row(host) };
}
// ── Clone ──────────────────────────────────────────────────────────────────
/** `{scheme: "ssh"|"https", host}` of a URL `validateRemoteUrl` accepted. */
function remoteOf(url) {
if (url.startsWith("https://")) return { scheme: "https", host: new URL(url).host.toLowerCase() };
if (url.startsWith("ssh://")) return { scheme: "ssh", host: new URL(url).host.toLowerCase() };
return { scheme: "ssh", host: url.slice(url.indexOf("@") + 1, url.indexOf(":")).toLowerCase() };
}
/** The folder name a clone of `url` gets by default — git's own rule, roughly. */
function defaultName(url) {
const tail =
url
.replace(/[/:]+$/, "")
.split(/[/:]/)
.pop() || "";
return tail.replace(/\.git$/i, "");
}
/** git's wording for "I needed an https login and did not get a good one". */
function httpsFailure(stderr) {
return /could not read (Username|Password)|Authentication failed|terminal prompts disabled|Invalid username or password/i.test(
String(stderr || "")
);
}
/** The forge's "no such repository" — over ssh it ALSO says "Could not read
* from remote repository", which is not a refused key. */
function notFound(stderr) {
return /Repository not found|does not appear to be a git repository|repository '.*' not found|project you were looking for could not be found/i.test(
String(stderr || "")
);
}
/** git's wording for "the SSH login was refused or the host is unknown". */
function sshFailure(stderr) {
return /Permission denied \(publickey|Host key verification failed|Could not read from remote repository/i.test(
String(stderr || "")
);
}
/**
* Clone `url` into `<parent>/<name>` as the account owning `parent`, with
* that account's own identity. `once` is a login typed for this clone
* (`{username, password, remember}`) — https only, for this URL's host only.
* Resolves `{dir, account}`; rejects with `ECREDENTIALS` (`{protocol, host}`
* attached) when git wanted a login it did not get, `ESSHAUTH` when the key
* was refused, else the git error.
*/
async function cloneRepo({ url, parent, name, once = null }) {
const { git } = require("./worktree");
const account = accountForDir(parent);
const remote = remoteOf(url);
const pre = [];
const persist = [];
const env = {};
let restore = null;
if (remote.scheme === "ssh") {
if (once) throw err("EBADCRED", "a typed login is for https only — SSH uses your key");
const key = sshKeyInfo(account) ? paths(account).key : null;
const base = key ? `ssh -i ${shq(key)} -o IdentitiesOnly=yes` : "ssh";
// BatchMode: no terminal to answer a prompt; accept-new: first contact
// records the host key, a CHANGED one still fails.
pre.push("-c", `core.sshCommand=${base} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`);
if (key) persist.push("-c", `core.sshCommand=${base}`);
} else if (account.kind === "member") {
const k = hostKey("https", remote.host);
const before = readStore(account, { strict: true });
if (once) {
checkCred(once.username, once.password);
saveHttps(account, { host: k.host, username: once.username, password: once.password });
restore = () => writeStore(account, before);
}
if (once || before.some((r) => r.host === k.host)) {
const helper = `store --file=${shq(paths(account).store)}`;
pre.push("-c", "credential.helper=", "-c", `credential.helper=${helper}`);
if (!once || once.remember) persist.push("-c", `credential.helper=${helper}`);
}
} else {
const k = hostKey("https", remote.host);
if (once) {
checkCred(once.username, once.password);
env.AGENTHUB_GIT_ONCE = JSON.stringify({
...k,
username: once.username,
password: once.password,
});
}
if (once || gitCredentials.find(k.protocol, k.host)) {
env.AGENTHUB_GIT_CRED_FILE = gitCredentials.credentialsFile();
pre.push(
"-c",
"credential.helper=",
"-c",
`credential.helper=!${shq(process.execPath)} ${shq(HELPER)}`
);
}
}
let ok = false;
try {
await git(parent, [...pre, "clone", ...persist, "--", url, name], {
timeout: CLONE_TIMEOUT_MS,
env,
});
ok = true;
} catch (e) {
const stderr = e.git?.stderr;
if (remote.scheme === "https" && httpsFailure(stderr)) {
throw Object.assign(err("ECREDENTIALS", "this repository needs a login"), {
protocol: "https",
host: remote.host,
});
}
if (notFound(stderr)) {
throw err("ENOREPO", `no repository at ${url} (or this login cannot see it)`);
}
if (remote.scheme === "ssh" && sshFailure(stderr)) {
throw Object.assign(err("ESSHAUTH", "the SSH login was refused"), { host: remote.host });
}
throw err(
"ECLONE",
String(stderr || e.message)
.trim()
.slice(-800)
);
} finally {
// A login typed without "remember" — or one that did not work — leaves
// the member's store as it was.
if (restore && (!ok || !once.remember)) {
try {
restore();
} catch {
/* best effort: the store is the member's own */
}
}
}
if (ok && once?.remember && account.kind === "host") {
gitCredentials.save({ protocol: "https", host: remote.host, ...once });
}
return { dir: path.join(parent, name), account: account.kind };
}
module.exports = {
accountForDir,
accountForRequest,
makeKeyPair,
fingerprint,
sshKeyInfo,
generateSshKey,
removeSshKey,
listHttps,
saveHttps,
removeHttps,
remoteOf,
defaultName,
cloneRepo,
getAuthor,
setAuthor,
sshConfigFor,
memberHttpsConfig,
CLONE_TIMEOUT_MS,
};
+17 -3
View File
@@ -277,12 +277,15 @@ async function resolvePaths(dir, body) {
async function networkGit(dir, args, body = {}) { async function networkGit(dir, args, body = {}) {
const typed = body.credentials && typeof body.credentials === "object" ? body.credentials : null; const typed = body.credentials && typeof body.credentials === "object" ? body.credentials : null;
const cfg = await gitCredentials.gitConfigFor(dir, typed); const cfg = await gitCredentials.gitConfigFor(dir, typed);
let ok = false;
try { try {
const out = await git(dir, [...cfg.args, ...args], { const out = await git(dir, [...cfg.args, ...args], {
timeout: NETWORK_TIMEOUT_MS, timeout: NETWORK_TIMEOUT_MS,
env: cfg.env, env: cfg.env,
}); });
if (typed?.remember) gitCredentials.save(typed); ok = true;
// A member's typed login is already in their own store (finish keeps it).
if (typed?.remember && !cfg.member) gitCredentials.save(typed);
return out; return out;
} catch (e) { } catch (e) {
const auth = gitCredentials.authFailure(e.git?.stderr); const auth = gitCredentials.authFailure(e.git?.stderr);
@@ -290,7 +293,9 @@ async function networkGit(dir, args, body = {}) {
const remote = (await gitCredentials.remoteHosts(dir)).find( const remote = (await gitCredentials.remoteHosts(dir)).find(
(h) => h.protocol === auth.protocol && h.host === auth.host (h) => h.protocol === auth.protocol && h.host === auth.host
); );
const stored = gitCredentials.find(auth.protocol, auth.host); const stored = cfg.member
? cfg.stored(auth.protocol, auth.host)
: gitCredentials.find(auth.protocol, auth.host);
const wrong = Boolean(typed) || Boolean(stored); const wrong = Boolean(typed) || Boolean(stored);
throw Object.assign( throw Object.assign(
new Error( new Error(
@@ -307,6 +312,8 @@ async function networkGit(dir, args, body = {}) {
}, },
} }
); );
} finally {
cfg.finish?.(ok);
} }
} }
@@ -550,7 +557,14 @@ async function mergeAction(dir, action, body) {
// of a local or remote-tracking branch that exists, nothing else. // of a local or remote-tracking branch that exists, nothing else.
const ref = await branchRef(dir, branch); const ref = await branchRef(dir, branch);
if (!ref) throw bad("ENOBRANCH", `no branch ${branch}`); if (!ref) throw bad("ENOBRANCH", `no branch ${branch}`);
return startOperation(dir, action, ["merge", "--no-edit", ref], branch); // git names the merge after the ref it was GIVEN, so the full refname
// above made every subject "Merge branch 'refs/heads/x'". The message
// git's own `git merge x` writes, as one argv element.
const current = (await git(dir, ["branch", "--show-current"])).stdout.trim();
const into = current && !["main", "master"].includes(current) ? ` into ${current}` : "";
const kind = ref.startsWith("refs/remotes/") ? "remote-tracking branch" : "branch";
const message = `Merge ${kind} '${branch}'${into}`;
return startOperation(dir, action, ["merge", "--no-edit", "-m", message, ref], branch);
} }
if (action === "resolve") { if (action === "resolve") {
+145
View File
@@ -0,0 +1,145 @@
/**
* @file Downloads one file from a workspace folder, and uploads one file into
* it, for the Files tab (issue #78: a member on a server-mode install has no
* SSH to the host, so the dashboard is their only way to move a file in or
* out). Both work in a folder that is not a git repo too.
*
* The guards are the READ path's own (git-diff.js): a lexical `lanePath`, a
* realpath confined to the workspace (no `..`, no symlink out), and no
* `settings.local.json` or member dot-dir entry, by name or by what it
* resolves to. A download is opened through `openSafe` (a member's file: a
* regular file they own) and streamed, up to DOWNLOAD_MAX_BYTES.
*
* An upload only CREATES: it refuses a name that already exists (EEXISTS), so
* it can never replace a file an agent wrote — the editor's `baseSha` rule
* for the one write that does replace. It writes into an existing folder of
* the workspace (never makes one), never into or under `.git` (a hook there
* runs the next time the dashboard runs git), at most UPLOAD_MAX_BYTES, and —
* under S3 — as the folder's member through `write-as` (the host never writes
* inside a member's home itself); elsewhere with an exclusive create, which
* also refuses to follow a symlink planted at the name.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { isSecretOnDisk, lanePath, laneRoot, resolveInLane } = require("./git-diff");
/** The largest file one upload may carry (it rides one request body). */
const UPLOAD_MAX_BYTES = 25 * 1024 * 1024;
/** The largest file one download streams. */
const DOWNLOAD_MAX_BYTES = 512 * 1024 * 1024;
function bad(code, message) {
return Object.assign(new Error(message), { code });
}
const underGit = (rel) => rel.split("/").includes(".git");
/**
* Open `requested` in workspace `dir` for a download. Returns its base name,
* size and a read stream. Throws EBADPATH, ESECRET, ENOFILE or ETOOBIG.
*/
async function openDownload(dir, requested) {
const file = lanePath(requested);
if (await isSecretOnDisk(dir, file)) {
throw bad("ESECRET", "that file holds secrets and is not downloadable here");
}
const abs = await resolveInLane(dir, file);
const runAs = require("./run-as");
let fh;
try {
fh = await runAs.openSafeAsync(abs);
} catch {
throw bad("ENOFILE", "no such file in the working tree");
}
const st = await fh.stat();
if (!st.isFile()) {
await fh.close();
throw bad("ENOFILE", "no such file in the working tree");
}
if (st.size > DOWNLOAD_MAX_BYTES) {
await fh.close();
throw bad("ETOOBIG", "file is larger than 512 MB");
}
// Never past the size fstat gave: a file growing under the read is cut.
const stream = fh.createReadStream({ start: 0, end: Math.max(0, st.size - 1) });
return { name: path.basename(file), size: st.size, stream };
}
/** A file name an upload may create: one path segment, nothing special. */
function uploadName(name) {
const n = typeof name === "string" ? name.trim() : "";
if (
!n ||
n === "." ||
n === ".." ||
n === ".git" ||
/[/\\\0]/.test(n) ||
Buffer.byteLength(n) > 255
) {
throw bad("EBADNAME", "the file name must be a single, plain file name");
}
return n;
}
/**
* Create `name` in the folder `folder` ("" = the workspace root) of workspace
* `dir` with `content`. Returns its workspace-relative path. Throws EBADPATH,
* EBADNAME, ESECRET, ENOFILE (no such folder), EEXISTS or ETOOBIG.
*/
async function saveUpload(dir, folder, name, content) {
const base = uploadName(name);
const sub = folder ? lanePath(folder) : "";
const rel = sub ? `${sub}/${base}` : base;
if (underGit(rel)) throw bad("EBADPATH", "nothing is uploaded into .git");
if (!Buffer.isBuffer(content)) throw bad("EBADCONTENT", "the upload carried no file");
if (content.length > UPLOAD_MAX_BYTES) throw bad("ETOOBIG", "a file may be at most 25 MB");
if (await isSecretOnDisk(dir, rel)) {
throw bad("ESECRET", "that name is reserved for secrets and is not writable here");
}
// The folder must exist inside the workspace, by its REAL path.
const root = await laneRoot(dir);
let target;
if (sub) {
let real;
try {
real = await resolveInLane(dir, sub);
} catch (err) {
throw err.code === "ENOFILE" ? bad("ENOFILE", "no such folder in the workspace") : err;
}
const st = await fs.promises.stat(real).catch(() => null);
if (!st?.isDirectory()) throw bad("ENOFILE", "no such folder in the workspace");
target = path.join(real, base);
} else {
target = path.join(root, base);
}
// Re-checked on the resolved folder: a folder linked to a secret's place.
if (await isSecretOnDisk(root, path.relative(root, target))) {
throw bad("ESECRET", "that name is reserved for secrets and is not writable here");
}
const exists = () => bad("EEXISTS", `${rel} already exists — rename the file or remove that one`);
const taken = await fs.promises.lstat(target).then(
() => true,
() => false
);
if (taken) throw exists();
const runAs = require("./run-as");
const acct = runAs.accountForPath(target);
if (acct) {
// As the member (S3): write-as's `replace` is an atomic new file. The
// existence check above is the member's own race to lose, in their home.
runAs.writeAs(acct, target, "replace", content);
} else {
try {
// `wx`: O_CREAT|O_EXCL — fails on any existing name, a symlink included.
await fs.promises.writeFile(target, content, { flag: "wx" });
} catch (err) {
if (err.code === "EEXIST") throw exists();
throw err;
}
}
return { path: rel };
}
module.exports = { openDownload, saveUpload, UPLOAD_MAX_BYTES, DOWNLOAD_MAX_BYTES };
+6 -2
View File
@@ -36,7 +36,8 @@ const FEATURED_HOST = "https://codex-pets.net";
const FEATURED = [ const FEATURED = [
{ id: "goku", displayName: "Goku", by: "hackminit" }, { id: "goku", displayName: "Goku", by: "hackminit" },
{ id: "border-collie", displayName: "Border Collie", by: "dev-minsoo" }, { id: "border-collie", displayName: "Border Collie", by: "dev-minsoo" },
{ id: "11", displayName: "11", by: "ct" }, // codex-pets.net names it "11"; it is a silver-shaded British Shorthair.
{ id: "11", displayName: "British Shorthair", by: "ct" },
]; ];
const ID_RE = /^(?:p-[0-9a-f]{12}|cp-[a-z0-9-]{1,40})$/; const ID_RE = /^(?:p-[0-9a-f]{12}|cp-[a-z0-9-]{1,40})$/;
const IMAGE_TYPES = { const IMAGE_TYPES = {
@@ -127,9 +128,12 @@ function readMeta(id) {
} }
function view(id, meta) { function view(id, meta) {
// A featured pet shows the name it has in FEATURED now, so a rename there
// reaches pets installed before it.
const featured = meta.featuredId && FEATURED.find((f) => f.id === meta.featuredId);
return { return {
id, id,
displayName: meta.displayName, displayName: featured ? featured.displayName : meta.displayName,
description: meta.description || "", description: meta.description || "",
kind: meta.kind, kind: meta.kind,
source: meta.source, source: meta.source,
+30
View File
@@ -128,6 +128,7 @@ function canSeeRunRow(req, id) {
const LANE_WORDS = new Set([ const LANE_WORDS = new Set([
"branches", "branches",
"browse", "browse",
"clone",
"ensure", "ensure",
"gc", "gc",
"pick-path", "pick-path",
@@ -213,9 +214,38 @@ function scopeGuard(req, res, next) {
if (bad) return notFound(); if (bad) return notFound();
if (lanes.some((id) => !/^\d+$/.test(id) || !canSeeLane(req, Number(id)))) return notFound(); if (lanes.some((id) => !/^\d+$/.test(id) || !canSeeLane(req, Number(id)))) return notFound();
if (sessions.some((id) => !canSeeSession(req, id))) return notFound(); if (sessions.some((id) => !canSeeSession(req, id))) return notFound();
// S3: a WRITE to a workspace re-checks that its owner may still own that
// folder, by the LIVE role — an admin demoted since keeps owning their
// host-folder workspaces, and `/up`, `/hook`, a commit (git hooks) or a
// file write there would run as the HOST account. An owner that fits (every
// workspace was checked when it was made) never notices; reads stay open.
if (req.method !== "GET" && req.method !== "HEAD") {
const m = /^\/(?:lanes|workspaces)\/(\d+)(?:\/|$)/i.exec(p);
const ids = [...(m ? [m[1]] : []), ...lanes];
if (ids.some((id) => !ownerStillFits(Number(id)))) {
return res.status(403).json({
error: {
code: "EOWNERFOLDER",
message: "this workspace runs as the host account: only an admin may change it",
},
});
}
}
return next(); return next();
} }
/** Whether a workspace's owner may still own its folder (S3, live role). */
function ownerStillFits(laneId) {
const lane = sql("SELECT cwd, owner_id FROM lanes WHERE id = ?").get(laneId);
if (!lane) return true;
try {
require("./run-as").assertOwnerFits(lane.cwd, lane.owner_id);
return true;
} catch {
return false;
}
}
// ── WebSocket broadcasts ─────────────────────────────────────────────────── // ── WebSocket broadcasts ───────────────────────────────────────────────────
const STAFF = { staffOnly: true }; const STAFF = { staffOnly: true };
+65 -4
View File
@@ -5,7 +5,8 @@
* for the built-in Swagger and ReDoc surfaces, plus the read-only * for the built-in Swagger and ReDoc surfaces, plus the read-only
* `LaneStageDetectionFields` schema for the stage-detection fields every lane * `LaneStageDetectionFields` schema for the stage-detection fields every lane
* response carries, and the idempotent `POST /api/lanes/ensure` lookup the * response carries, and the idempotent `POST /api/lanes/ensure` lookup the
* Workspace page opens a directory with. * Workspace page opens a directory with, and the Files tab's reads (tree,
* quick-open, download — also in a folder that is not a git repo) and upload.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -688,7 +689,7 @@ const paths = {
tags: ["Lanes"], tags: ["Lanes"],
summary: "Tracked paths matching a substring (quick-open)", summary: "Tracked paths matching a substring (quick-open)",
description: description:
"Reads `git ls-files`, so ignored files never appear and no skip-list is maintained. Basename hits rank first. Capped at 500 paths. Read-only, no same-origin guard.", "Reads `git ls-files`, so ignored files never appear and no skip-list is maintained. Basename hits rank first. Capped at 500 paths. In a folder that is not a git repo it walks the disk instead (realpath-confined, no symlink followed, `.git` hidden, `node_modules` never entered, 20 000 entries at most). Read-only, no same-origin guard.",
operationId: "listLanePaths", operationId: "listLanePaths",
parameters: [ parameters: [
{ name: "id", in: "path", required: true, schema: { type: "integer" } }, { name: "id", in: "path", required: true, schema: { type: "integer" } },
@@ -705,7 +706,7 @@ const paths = {
tags: ["Lanes"], tags: ["Lanes"],
summary: "One directory level of tracked files", summary: "One directory level of tracked files",
description: description:
"The file explorer's expand-a-folder read: tracked entries under path=, folded into directories at that level. Read-only, no same-origin guard.", "The file explorer's expand-a-folder read: tracked entries under path=, folded into directories at that level. In a folder that is not a git repo the level is read off the disk instead (no status; `.git` hidden, `node_modules` listed as ignored and never entered). Read-only, no same-origin guard.",
operationId: "listLaneTree", operationId: "listLaneTree",
parameters: [ parameters: [
{ name: "id", in: "path", required: true, schema: { type: "integer" } }, { name: "id", in: "path", required: true, schema: { type: "integer" } },
@@ -723,7 +724,7 @@ const paths = {
tags: ["Lanes"], tags: ["Lanes"],
summary: "Every level of the tracked tree in one read", summary: "Every level of the tracked tree in one read",
description: description:
"The file explorer's expand-all-folders read: ONE git ls-files, grouped into levels keyed by directory (\"\" is the root), each level shaped and capped like GET /api/lanes/{id}/git/tree's entries. Read-only, no same-origin guard.", "The file explorer's expand-all-folders read: ONE git ls-files, grouped into levels keyed by directory (\"\" is the root), each level shaped and capped like GET /api/lanes/{id}/git/tree's entries; a folder that is not a git repo is walked off the disk instead (at most 20 000 entries, then truncated). Read-only, no same-origin guard.",
operationId: "listLaneTreeAll", operationId: "listLaneTreeAll",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }], parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
responses: { responses: {
@@ -732,6 +733,66 @@ const paths = {
}, },
}, },
}, },
"/api/lanes/{id}/git/download": {
get: {
tags: ["Lanes"],
summary: "Download one working file of the workspace",
description:
"The Files tab's Download: one working-copy file of any type, also in a folder that is not a git repo (server/lib/lane-file-transfer.js). Same guards as the /git/file read (lexical lane path, realpath confinement, secret refusal; under S3 a member's file is opened through openSafe), at most 512 MB. Served as an `application/octet-stream` attachment (an ASCII filename plus `filename*=UTF-8''…`) with `Content-Security-Policy: default-src 'none'; sandbox`, `X-Content-Type-Options: nosniff` and `Cache-Control: no-store`, so nothing in it renders or runs on the dashboard's origin. Honours `?session=`/`?source=` like the other /git reads. Read-only, no same-origin guard.",
operationId: "downloadLaneFile",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
{ name: "path", in: "query", required: true, schema: { type: "string" } },
{ name: "session", in: "query", required: false, schema: { type: "string" } },
{ name: "source", in: "query", required: false, schema: { type: "integer" } },
],
responses: {
200: { description: "The file's bytes, as an attachment." },
400: { description: "EBADPATH, ENOFILE or ETOOBIG." },
403: {
description:
"ESECRET, or EFOREIGN (S3: the workspace folder resolves into another account's files).",
},
404: { description: "Lane not found." },
},
},
},
"/api/lanes/{id}/git/upload": {
post: {
tags: ["Lanes"],
summary: "Upload one new file into a folder of the workspace",
description:
"The Files tab's Upload. The body is the file's raw bytes, its name the URI-encoded `X-File-Name` header, `dir` the workspace-relative folder (\"\" = the root). It only CREATES: a taken name (a symlink at it included) is 409 EEXISTS, never an overwrite. Writes into an existing folder only, never into or under `.git`, never a secret name (`settings.local.json`, a member's home dot folder), at most 25 MB. Under S3 the file is written as the folder's member through `write-as`; elsewhere with an exclusive create. Works in a folder that is not a git repo. Same-origin guarded.",
operationId: "uploadLaneFile",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
{ name: "dir", in: "query", schema: { type: "string", default: "" } },
{
name: "X-File-Name",
in: "header",
required: true,
schema: { type: "string" },
description: "The new file's name, URI-encoded: one plain segment of at most 255 bytes.",
},
{ name: "session", in: "query", required: false, schema: { type: "string" } },
{ name: "source", in: "query", required: false, schema: { type: "integer" } },
],
requestBody: {
required: true,
content: {
"application/octet-stream": { schema: { type: "string", format: "binary" } },
},
},
responses: {
201: { description: "{path} — the workspace-relative path written." },
400: { description: "EBADPATH (.git), EBADNAME, ENOFILE (no such folder) or EBADCONTENT." },
403: { description: "ESECRET, EFOREIGN, or EBADORIGIN (cross-origin)." },
404: { description: "Lane not found." },
409: { description: "EEXISTS — a file of that name is already there." },
413: { description: "ETOOBIG — over 25 MB." },
},
},
},
"/api/lanes/{id}/git/branches": { "/api/lanes/{id}/git/branches": {
get: { get: {
tags: ["Lanes"], tags: ["Lanes"],
@@ -170,6 +170,14 @@ const paths = {
"Inclusive upper bound on `started_at` (ISO 8601). An unparseable value is ignored. Applied before `limit`/`offset`.", "Inclusive upper bound on `started_at` (ISO 8601). An unparseable value is ignored. Applied before `limit`/`offset`.",
example: "2026-10-05T23:59:59.999Z", example: "2026-10-05T23:59:59.999Z",
}, },
{
name: "pinned_first",
in: "query",
schema: { type: "string", enum: ["1"] },
description:
"`1` = pinned sessions first (by `pinned_at`, oldest pin first), then the normal order. Applied before `limit`/`offset`, so a pinned old session stays on the first page. Omitted = order unchanged.",
example: "1",
},
{ {
$ref: "#/components/parameters/SourcesQuery", $ref: "#/components/parameters/SourcesQuery",
example: "local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11", example: "local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11",
@@ -300,7 +308,7 @@ const paths = {
tags: ["Sessions"], tags: ["Sessions"],
summary: "Update session", summary: "Update session",
description: description:
"Partially updates a session by `id`. Only `name`, `status`, `ended_at`, and `metadata` are accepted; any field omitted from the body is passed as null and the underlying UPDATE uses COALESCE, so a null leaves the existing column value unchanged (partial-update semantics) — you cannot clear a field to null through this endpoint. `metadata` is supplied as a JSON object but stored and returned as a JSON-encoded string. A successful update re-reads the row and broadcasts a `session_updated` websocket frame. Returns 404 with code `NOT_FOUND` when the session does not exist.", "Partially updates a session by `id`. `name`, `status`, `ended_at`, `metadata`, `archived` and `pinned` are accepted (`archived`/`pinned` must be booleans, else 400 `INVALID_INPUT`; they set or clear `archived_at`/`pinned_at`, and a pin-only PATCH leaves `updated_at` unchanged); any other field omitted from the body is passed as null and the underlying UPDATE uses COALESCE, so a null leaves the existing column value unchanged (partial-update semantics) — you cannot clear a field to null through this endpoint. `metadata` is supplied as a JSON object but stored and returned as a JSON-encoded string. A successful update re-reads the row and broadcasts a `session_updated` websocket frame. Returns 404 with code `NOT_FOUND` when the session does not exist.",
operationId: "updateSession", operationId: "updateSession",
parameters: [ parameters: [
{ {
+9
View File
@@ -638,6 +638,15 @@ function createOpenApiSpec() {
status: { type: "string", enum: ["active", "completed", "error", "abandoned"] }, status: { type: "string", enum: ["active", "completed", "error", "abandoned"] },
ended_at: { type: "string", format: "date-time" }, ended_at: { type: "string", format: "date-time" },
metadata: { type: "object", additionalProperties: true }, metadata: { type: "object", additionalProperties: true },
archived: {
type: "boolean",
description: "Sets or clears `archived_at` (hidden from its workspace's lists).",
},
pinned: {
type: "boolean",
description:
"Sets or clears `pinned_at` (top of its workspace's sidebar list). Pinning again keeps the first pin time; a pin-only PATCH leaves `updated_at` unchanged.",
},
}, },
}, },
SessionUpdateResponse: { SessionUpdateResponse: {
+125
View File
@@ -0,0 +1,125 @@
/**
* @file HTTP surface for a user's OWN git identity (`server/lib/git-identity.js`),
* mounted at `/api/me/git`: the SSH key a clone uses (make, replace, delete —
* only the PUBLIC key is ever returned) and the HTTPS logins (save, delete —
* `hasToken` only, never a password), and the commit author (`user.name` /
* `user.email` of the account's global git config — a member's commits fail
* without one). The account is the signed-in user's
* own Linux account under S3, else the host's, which only an admin (or local
* mode) may change (`EHOSTIDENTITY`). Nothing here takes a path from the
* request, and every write passes the same-origin guard.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const express = require("express");
const identity = require("../lib/git-identity");
const { sameOriginGuard } = require("../lib/same-origin");
const router = express.Router();
const STATUS = {
EBADHOST: 400,
EBADCRED: 400,
EEXISTS: 409,
EMEMBERGONE: 409,
EHOSTIDENTITY: 403,
EBADAUTHOR: 400,
ESTOREUNREADABLE: 409,
};
function fail(res, e) {
res
.status(STATUS[e.code] || 500)
.json({ error: { code: e.code || "EINTERNAL", message: e.message } });
}
async function view(account) {
return {
account: account.kind,
writable: account.kind === "member" || account.writable !== false,
ssh: identity.sshKeyInfo(account),
https: identity.listHttps(account),
author: await identity.getAuthor(account),
};
}
/** The request's account; a write to the host's identity needs an admin. */
function accountOf(req, { write = false } = {}) {
const account = identity.accountForRequest(req);
if (write && account.kind === "host" && !account.writable) {
throw Object.assign(new Error("the host's git identity is an admin's to change"), {
code: "EHOSTIDENTITY",
});
}
return account;
}
router.get("/", async (req, res) => {
try {
res.json(await view(accountOf(req)));
} catch (e) {
fail(res, e);
}
});
router.post("/ssh-key", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
identity.generateSshKey(account, {
replace: req.body?.replace === true,
comment: req.user?.username ? `${req.user.username}@agenthub` : "agenthub",
});
res.status(201).json(await view(account));
} catch (e) {
fail(res, e);
}
});
router.delete("/ssh-key", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
if (!identity.removeSshKey(account)) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no SSH key" } });
}
res.json(await view(account));
} catch (e) {
fail(res, e);
}
});
router.post("/https", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
const { host, username, password } = req.body || {};
identity.saveHttps(account, { protocol: "https", host, username, password });
res.json(await view(account));
} catch (e) {
fail(res, e);
}
});
router.delete("/https/:host", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
if (!identity.removeHttps(account, "https", req.params.host)) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such login" } });
}
res.json(await view(account));
} catch (e) {
fail(res, e);
}
});
/** The account's commit author (`git config --global user.name/email`). */
router.put("/author", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
await identity.setAuthor(account, req.body || {});
res.json(await view(account));
} catch (e) {
fail(res, e);
}
});
module.exports = router;
+196 -1
View File
@@ -8,7 +8,8 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
const { Router } = require("express"); const express = require("express");
const { Router } = express;
const fs = require("node:fs"); const fs = require("node:fs");
const os = require("node:os"); const os = require("node:os");
const path = require("node:path"); const path = require("node:path");
@@ -35,6 +36,7 @@ const { sameOriginGuard } = require("./run");
const { isLocalRequest } = require("../lib/same-origin"); const { isLocalRequest } = require("../lib/same-origin");
const scope = require("../lib/scope"); const scope = require("../lib/scope");
const { writeLaneFile } = require("../lib/lane-file-write"); const { writeLaneFile } = require("../lib/lane-file-write");
const { openDownload, saveUpload, UPLOAD_MAX_BYTES } = require("../lib/lane-file-transfer");
const { pickPath, startDir } = require("../lib/native-dialog"); const { pickPath, startDir } = require("../lib/native-dialog");
const { preflight } = require("../lib/lane-preflight"); const { preflight } = require("../lib/lane-preflight");
const { const {
@@ -716,6 +718,122 @@ router.post("/browse/mkdir", sameOriginGuard, (req, res) => {
res.status(201).json({ path: target }); res.status(201).json({ path: target });
}); });
/**
* Add workspace → Clone: `git clone <url>` into `<parent>/<name>` and make
* that folder a workspace (like `POST /ensure`). The URL passes the same
* allowlist as `publish` (`validateRemoteUrl`: https with no userinfo, ssh://,
* scp-like), `name` is one plain folder name that must not exist yet, and
* `parent` is held to the user's own folder exactly like New folder above.
* The clone runs AS the folder's account (S3: the member) with THAT
* account's own identity (`server/lib/git-identity.js`) — the user's SSH key
* or saved HTTPS login, or `credentials` typed for this one https host.
* 409 `ECREDENTIALS` (with `auth: {protocol, host}`) asks the browser for a
* login, 409 `ESSHAUTH` means the key was refused — 409 like the git bar's
* `EGITAUTH`, never 401, which the client reads as "signed out".
*/
router.post("/clone", sameOriginGuard, async (req, res) => {
const body = req.body || {};
const identity = require("../lib/git-identity");
let url;
try {
url = require("../lib/git-ops").validateRemoteUrl(
typeof body.url === "string" ? body.url.trim() : body.url
);
} catch (err) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
const name =
typeof body.name === "string" && body.name.trim()
? body.name.trim()
: identity.defaultName(url);
if (
!name ||
name === "." ||
name === ".." ||
/[/\\\0]/.test(name) ||
Buffer.byteLength(name) > 255
) {
return res
.status(400)
.json({ error: { code: "EBADNAME", message: "a folder name without '/' or '\\'" } });
}
if (typeof body.parent !== "string" || !path.isAbsolute(body.parent)) {
return res.status(400).json({ error: { code: "EBADPATH", message: "parent: absolute path" } });
}
if (body.tool !== undefined && !aiTools.has(body.tool)) {
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
}
const once = body.credentials;
if (once != null && (typeof once !== "object" || Array.isArray(once))) {
return res.status(400).json({ error: { code: "EBADCRED", message: "credentials: object" } });
}
const own = scope.ownFolder(req);
const limited = Boolean(req.user) && req.user.role !== "admin";
if (limited && !own) return refuseFolder(res);
const dir = own ? lanesLib.canonicalCwd(path.resolve(body.parent)) : path.resolve(body.parent);
if (own && !scope.folderAllowed(req, dir, { allowHome: true })) return refuseFolder(res);
try {
if (!fs.statSync(dir).isDirectory()) throw new Error("not a directory");
} catch {
return res
.status(400)
.json({ error: { code: "ENOTFOUND", message: "parent folder does not exist" } });
}
const target = path.join(dir, name);
if (fs.existsSync(target) || isSymlink(target)) {
return res.status(409).json({ error: { code: "EEXISTS", message: "that name is taken" } });
}
if (!scope.folderAllowed(req, target)) return refuseFolder(res);
if (scope.folderTakenByOther(req, target)) return refuseOwned(res);
const ownerId = scope.ownerFor(req);
try {
// Before the clone: a workspace this user may not own there, or a tool
// that is disabled, is refused up front — not after minutes of cloning,
// leaving a clone with no workspace on it.
lanesLib.validateTool(body.tool || "claude");
require("../lib/run-as").assertOwnerFits(target, ownerId);
await identity.cloneRepo({
url,
parent: dir,
name,
once: once
? { username: once.username, password: once.password, remember: once.remember === true }
: null,
});
} catch (err) {
const status = ["EBADCRED", "EBADHOST", "EBADTOOL", "EDISABLED"].includes(err.code)
? 400
: ["ECREDENTIALS", "ESSHAUTH", "ENOREPO", "EOWNERFOLDER", "EMEMBERGONE"].includes(err.code)
? 409
: 500;
return res.status(status).json({
error: {
code: err.code || "ECLONE",
message: err.message,
...(err.code === "ECREDENTIALS"
? { auth: { protocol: err.protocol, host: err.host } }
: {}),
},
});
}
try {
const lane = lanesLib.createLane({
cwd: lanesLib.canonicalCwd(target),
owner_id: ownerId,
title: typeof body.title === "string" && body.title.trim() ? body.title.trim() : name,
...(body.tool ? { tool: body.tool } : {}),
});
broadcastLane(lane.id);
return res.status(201).json({ lane: payload(lane), created: true, path: target });
} catch (err) {
// The clone stays: it is the user's now, and Repo mode can adopt it.
const status = ["EBADCWD", "EBADTOOL", "EDISABLED"].includes(err.code) ? 400 : 500;
return res
.status(status)
.json({ error: { code: err.code || "ELANE", message: err.message }, path: target });
}
});
function isSymlink(p) { function isSymlink(p) {
try { try {
return fs.lstatSync(p).isSymbolicLink(); return fs.lstatSync(p).isSymbolicLink();
@@ -1258,6 +1376,83 @@ router.put("/:id/git/file", sameOriginGuard, async (req, res) => {
} }
}); });
/**
* Download one file of the workspace (`?path=`), any type — lane-file-transfer.js's
* guards (the file read's own, `openSafe`, a size cap). Always an attachment
* under a `sandbox` CSP with `nosniff`, so nothing in it renders or runs on the
* dashboard's origin. A read, so unguarded like `/git/file`.
*/
router.get("/:id/git/download", async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
let file;
try {
file = await openDownload(t.cwd, typeof req.query.path === "string" ? req.query.path : "");
} catch (err) {
if (err.code === "ESECRET" || err.code === "EFOREIGN") {
return res.status(403).json({ error: { code: err.code, message: err.message } });
}
if (["EBADPATH", "ENOFILE", "ETOOBIG"].includes(err.code)) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
return res.status(500).json({ error: { code: "EREAD", message: "could not read the file" } });
}
// RFC 6266: an ASCII fallback plus the exact UTF-8 name.
const ascii = file.name.replace(/[^\x20-\x7e]|["\\]/g, "_");
res.set({
"Content-Type": "application/octet-stream",
"Content-Length": String(file.size),
"Content-Disposition": `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(file.name)}`,
"Content-Security-Policy": "default-src 'none'; sandbox",
"X-Content-Type-Options": "nosniff",
"Cache-Control": "no-store",
});
file.stream.on("error", () => res.destroy());
file.stream.pipe(res);
});
/**
* Upload one file into a folder of the workspace (`?dir=` — "" is its root —
* and the `X-File-Name` header, URI-encoded; the body is the raw bytes). It
* only CREATES (409 EEXISTS for a taken name), writes into an existing folder
* only, never under `.git`, as the folder's member under S3 —
* lane-file-transfer.js. Same-origin, like every lane write.
*/
const uploadBody = (req, res, next) =>
express.raw({ type: () => true, limit: UPLOAD_MAX_BYTES })(req, res, (err) => {
if (!err) return next();
const big = err.type === "entity.too.large";
res.status(big ? 413 : 400).json({
error: big
? { code: "ETOOBIG", message: "a file may be at most 25 MB" }
: { code: "EBADBODY", message: "could not read the upload" },
});
});
router.post("/:id/git/upload", sameOriginGuard, uploadBody, async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
let name = String(req.headers["x-file-name"] || "");
try {
name = decodeURIComponent(name);
} catch {
/* a header that is not URI-encoded is used as sent */
}
try {
const saved = await saveUpload(
t.cwd,
typeof req.query.dir === "string" ? req.query.dir : "",
name,
Buffer.isBuffer(req.body) ? req.body : null
);
res.status(201).json(saved);
} catch (err) {
const status =
{ ESECRET: 403, EFOREIGN: 403, EEXISTS: 409, ETOOBIG: 413, EMEMBERGONE: 403 }[err.code] ??
(["EBADPATH", "EBADNAME", "ENOFILE", "EBADCONTENT"].includes(err.code) ? 400 : 500);
res.status(status).json({ error: { code: err.code || "EWRITE", message: err.message } });
}
});
/** Tracked paths matching `?q=`, for quick-open. */ /** Tracked paths matching `?q=`, for quick-open. */
router.get("/:id/git/ls", async (req, res) => { router.get("/:id/git/ls", async (req, res) => {
const t = await gitTargetOr404(req, res); const t = await gitTargetOr404(req, res);
+2
View File
@@ -42,6 +42,8 @@ const PREF_KEYS = [
"agenthub.theme.light", "agenthub.theme.light",
"i18nextLng", "i18nextLng",
"agenthub.terminal.font", "agenthub.terminal.font",
"agenthub.terminal.fontSize",
"agenthub.ui.fontSize",
"agenthub.pet", "agenthub.pet",
"agenthub.pet.size", "agenthub.pet.size",
"agenthub.pet.names", "agenthub.pet.names",
+29 -7
View File
@@ -266,6 +266,12 @@ router.get("/", (req, res) => {
} else if (sortBy === "duration") { } else if (sortBy === "duration") {
orderSql = `(julianday(COALESCE(s.ended_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) - julianday(s.started_at)) ${sortDesc ? "DESC" : "ASC"}`; orderSql = `(julianday(COALESCE(s.ended_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) - julianday(s.started_at)) ${sortDesc ? "DESC" : "ASC"}`;
} }
// `pinned_first=1` (a workspace's sidebar list, issue #80): pinned sessions
// first, in the order they were pinned, BEFORE the limit — so a pinned old
// session is never pushed off the first page by newer ones.
if (req.query.pinned_first === "1") {
orderSql = `(s.pinned_at IS NULL), s.pinned_at ASC, ${orderSql}`;
}
rows = db rows = db
.prepare( .prepare(
@@ -718,14 +724,30 @@ router.patch("/:id", (req, res) => {
"UPDATE sessions SET archived_at = CASE WHEN ? THEN COALESCE(archived_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) END WHERE id = ?" "UPDATE sessions SET archived_at = CASE WHEN ? THEN COALESCE(archived_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) END WHERE id = ?"
).run(req.body.archived ? 1 : 0, req.params.id); ).run(req.body.archived ? 1 : 0, req.params.id);
} }
// `pinned`: to the top of its workspace's sidebar list, or back (issue #80).
if (Object.hasOwn(req.body, "pinned")) {
if (typeof req.body.pinned !== "boolean") {
return res
.status(400)
.json({ error: { code: "INVALID_INPUT", message: "pinned must be a boolean" } });
}
db.prepare(
"UPDATE sessions SET pinned_at = CASE WHEN ? THEN COALESCE(pinned_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) END WHERE id = ?"
).run(req.body.pinned ? 1 : 0, req.params.id);
}
stmts.updateSession.run( // A pin alone is not activity: it leaves `updated_at` (the sidebar's "last
name || null, // active", and the list's time order) where it was.
status || null, const pinOnly = Object.keys(req.body).every((k) => k === "pinned");
ended_at || null, if (!pinOnly) {
metadata ? JSON.stringify(metadata) : null, stmts.updateSession.run(
req.params.id name || null,
); status || null,
ended_at || null,
metadata ? JSON.stringify(metadata) : null,
req.params.id
);
}
const session = stmts.getSession.get(req.params.id); const session = stmts.getSession.get(req.params.id);
broadcast("session_updated", session); broadcast("session_updated", session);