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:
+4
-2
@@ -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'"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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
@@ -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" />
|
||||||
|
|||||||
Generated
-42
@@ -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();
|
||||||
|
|||||||
@@ -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. */}
|
||||||
|
|||||||
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
File diff suppressed because one or more lines are too long
+8
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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`. |
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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" });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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") {
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user