From f2bad5cb6333535b53860f3014dc820e75fa9f5f Mon Sep 17 00:00:00 2001 From: kaceper11 <22818077+kaceper11@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:25:30 +0200 Subject: [PATCH 1/4] feat(forge): Azure DevOps integration (PRs, work items, detection) Adds az/azure-devops as a third forge alongside gh and glab: remote detection (dev.azure.com, *.visualstudio.com, ssh.dev.azure.com v3, vs-ssh), CLI + extension + Entra/PAT auth probing, PR status/policies/ comments, Azure Boards work-item listing and issue-task seeding, PR creation, IPC + frontend wiring, sandbox/Docker allowlisting, and docs. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CLAUDE.md | 2 +- README.md | 14 +- docs/data-model.md | 2 +- docs/e2e-coverage.md | 2 +- docs/sandbox.md | 8 +- docs/ui.md | 12 +- e2e/specs/settings.e2e.ts | 4 +- src-tauri/src/docker.rs | 28 +- src-tauri/src/forge.rs | 1272 +++++++++++++++++++- src-tauri/src/lib.rs | 110 +- src-tauri/src/sandbox.rs | 12 + src/App.tsx | 2 +- src/components/TaskPrBadge.tsx | 8 +- src/components/dialogs/CommandPalette.tsx | 4 +- src/components/dialogs/CreatePrDialog.tsx | 38 +- src/components/dialogs/NewTaskDialog.tsx | 113 +- src/components/dialogs/WelcomeDialog.tsx | 15 +- src/components/settings/DockerSection.tsx | 4 +- src/components/settings/GeneralSection.tsx | 16 +- src/components/task/PrCard.tsx | 33 +- src/lib/forge.test.ts | 126 ++ src/lib/forge.ts | 151 +++ src/lib/ipc.ts | 8 +- src/lib/issuePrompt.test.ts | 29 +- src/lib/issuePrompt.ts | 77 +- src/lib/types.ts | 32 +- src/locales/en/common.ts | 4 + src/locales/en/dialogs.ts | 25 +- src/locales/en/panels.ts | 3 +- src/locales/en/settings.ts | 6 +- src/locales/zh-CN/common.ts | 4 + src/locales/zh-CN/dialogs.ts | 25 +- src/locales/zh-CN/panels.ts | 3 +- src/locales/zh-CN/settings.ts | 6 +- src/store/pr.test.ts | 66 +- src/store/pr.ts | 122 +- 36 files changed, 2123 insertions(+), 263 deletions(-) create mode 100644 src/lib/forge.test.ts create mode 100644 src/lib/forge.ts diff --git a/CLAUDE.md b/CLAUDE.md index 6c767787..f929cf08 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ src/ ├── sidebar/ / settings/ / dialogs/ / ui/ / views/ └── UnifiedBar.tsx src-tauri/src/lib.rs ← ALL Rust (PTY, project/task IO, settings, scripts, git, sandbox, proxy) -src-tauri/src/forge.rs ← GitHub/GitLab PR-MR integration via the gh / glab CLIs (detection, status, create) +src-tauri/src/forge.rs ← GitHub/GitLab/Azure DevOps PR-MR integration via the gh / glab / az CLIs (detection, status, create) ``` ## Run / build diff --git a/README.md b/README.md index abdc80a7..574f6753 100644 --- a/README.md +++ b/README.md @@ -254,12 +254,14 @@ no metered markup, no backend daemon. Here's what the window gives you on top: (so a match ten pages back still comes up first), and **Compare**, every path that differs between any ref and your working tree in one tree, with mark-as-viewed and inline comments still live. -- **Pull requests.** Open a GitHub PR or GitLab MR from the task you built - it in, then watch it from the right panel: checks, review state, and - comments handed to the agent working in that worktree. Or start a task - FROM an issue, with the composed prompt dropped into the first-message box - for you to read before anything is sent. Self-hosted GitHub Enterprise and - GitLab work too, via `gh` / `glab`. +- **Pull requests.** Open a GitHub PR, GitLab MR, or Azure DevOps PR from + the task you built it in, then watch it from the right panel: checks, + review state, and comments handed to the agent working in that worktree. + Or start a task FROM an issue or Azure Boards work item, with the + composed prompt dropped into the first-message box for you to read + before anything is sent. Self-hosted GitHub Enterprise and GitLab work + too; Azure DevOps needs `az` with the `azure-devops` extension (cloud + only, not Server). All three ride their official CLI's login. - **Agent races.** Fire one prompt at several agents at once, each in its own fresh worktree, then compare their diffs N-up when they finish and adopt the winner into your main checkout. diff --git a/docs/data-model.md b/docs/data-model.md index 90fca9aa..d8746912 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -3,7 +3,7 @@ ## Directories Three directories, different owners: -- `~/Library/Application Support/termic/` — app-owned: `projects.json`, `tasks/`, `scratch/`, `settings.json`. With PROFILES (GH #280) those four are the ROOT profile's; every other profile keeps its own copy under `profiles//`, and `profiles.json` (the registry) sits alongside them and is ABSENT until the first profile is created. `lib.rs#global_dir()` is the machine-wide root (tokens, docker, `servers/`, `backups/`, and the sandbox deny rule, which must cover every profile); `lib.rs#profile_dir(id)` is one profile's. See [profiles.md](profiles.md). Docker mode adds three more here: `docker/` (the editable Dockerfile + build metadata), `docker-agents//` (one container config dir per agent, so a login survives `--rm`) and `docker-forge/{gh,glab}/` (the gh / glab login, SHARED by every agent and task, see [sandbox.md](sandbox.md)). Path via `dirs::data_local_dir().join("termic")` in `lib.rs#global_dir()` (renamed from `data_dir()` when profiles landed, so a caller that wants machine-wide state has to say so). +- `~/Library/Application Support/termic/` — app-owned: `projects.json`, `tasks/`, `scratch/`, `settings.json`. With PROFILES (GH #280) those four are the ROOT profile's; every other profile keeps its own copy under `profiles//`, and `profiles.json` (the registry) sits alongside them and is ABSENT until the first profile is created. `lib.rs#global_dir()` is the machine-wide root (tokens, docker, `servers/`, `backups/`, and the sandbox deny rule, which must cover every profile); `lib.rs#profile_dir(id)` is one profile's. See [profiles.md](profiles.md). Docker mode adds three more here: `docker/` (the editable Dockerfile + build metadata), `docker-agents//` (one container config dir per agent, so a login survives `--rm`) and `docker-forge/config/{gh,glab-cli}` + `docker-forge/azure/` (the gh / glab / az logins — the host layout mirrors each container path minus the leading dot, SHARED by every agent and task, see [sandbox.md](sandbox.md)). Path via `dirs::data_local_dir().join("termic")` in `lib.rs#global_dir()` (renamed from `data_dir()` when profiles landed, so a caller that wants machine-wide state has to say so). - `$TMPDIR/termic-attachments/` — files handed to an agent as FILES rather than as text: `/` for a staged drop, `clipboard/` for an image pasted into a terminal (`clipboard_image_save`, pruned after 7 days). Deliberately NOT under the app data dir, which the Seatbelt profile ends by denying outright (CLI token); `$TMPDIR` is on `builtin_runtime_paths` and is mounted read-only into every Docker container at the same absolute path. `lib.rs#attachments_dir()`. - `~/Library/Application Support/com.simion.termic/` — tauri-plugin-window-state owned (window position/size). Path from `tauri.conf.json#identifier`. - `~/.config/termic/themes/` — user-owned, hand-authored custom theme files ([docs/themes.md](themes.md)). `$XDG_CONFIG_HOME` respected; shared by release + dev builds (no `termic_dev` split). Path via `lib.rs#themes_dir_path()`. diff --git a/docs/e2e-coverage.md b/docs/e2e-coverage.md index b80d3ebb..0f2c5366 100644 --- a/docs/e2e-coverage.md +++ b/docs/e2e-coverage.md @@ -176,7 +176,7 @@ until `make e2e` is green and this file reflects it. | ✅ Clipboard image capture (ctrl+V) | `clipboard_image_capture` reads a real image off the macOS pasteboard (set by the spec, restored after), encodes it as PNG and returns a saved path; a text clipboard is refused so the caller can forward the keystroke | `agent.e2e.ts` | | ✅ Image paste (Docker only) | `clipboard_image_save` persists pasted bytes and names the file for its REAL format (non-image bytes are refused, not written as a fake `.png`); the terminal's capture-phase listener leaves BOTH pastes alone in an ordinary task (native ⌘V still reaches the agent, which reads the Mac clipboard itself) and consumes only the image paste once the task is flipped to Docker | `agent.e2e.ts` | | ✅ Dockerfile editor lifecycle | The Dockerfile section is collapsed by default and its CodeMirror instance exists only while open: expanding mounts it with the real content, collapsing destroys it, and a second expand brings it back (the init effect bails when a view already exists, so a destroy that did not clear the ref would leave an empty box) | `settings.e2e.ts` | -| ✅ Docker forge CLI login | The same preview proves the shared gh / glab config dir reaches the spawn argv: both container paths mounted, `GH_CONFIG_DIR`/`GLAB_CONFIG_DIR` set to match, host side under `docker-forge` (shared, not per-agent) and never the user's own `~/.config/gh` | `settings.e2e.ts` | +| ✅ Docker forge CLI login | The same preview proves the shared gh / glab / az config dirs reach the spawn argv: all three container paths mounted, `GH_CONFIG_DIR`/`GLAB_CONFIG_DIR`/`AZURE_CONFIG_DIR` set to match, host side under `docker-forge` (shared, not per-agent) and never the user's own `~/.config/gh` | `settings.e2e.ts` | | ✅ Setup script | Configure + launch a Setup tab that spawns | `run.e2e.ts` | | ✅ Sidebar layout | Sidebar width setter persists | `tabs-layout.e2e.ts` | | ✅ Code editor | Open a .py file → CodeMirror renders with highlight tokens | `editor.e2e.ts` | diff --git a/docs/sandbox.md b/docs/sandbox.md index 03c8a6bc..7d21a3f8 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -30,7 +30,7 @@ Four states, set per-task at create + editable later. `Enforce` is the full cage ## Layered model 1. `sandbox-exec -f ` — kernel seatbelt. Profile rendered to `$TMPDIR/termic-sandbox-.sb`. Allows broad `file-read*`, narrow `file-write*` on task + agent dirs + caches. Secrets (`~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.netrc`, `~/.docker/config.json`, `~/.kube`, `~/.config/gh/hosts.yml`, Keychains) ALWAYS denied. `(deny network*)` except loopback to the proxy — UNLESS `EnforceFs`, which emits `(allow network*)` instead. -2. Per-task **in-process CONNECT proxy** on an OS-assigned port (Rust thread inside Tauri binary). Regex hostname allowlist per CLI: claude→anthropic, gemini→google, codex→openai, muse→meta (`api.meta.ai` for both the Model API and the release channel, `auth.meta.com` + `accountscenter.meta.com` for the device-code login, `lookaside.facebook.com` for the launcher's binary download, so a blocked self-update reads as a stale version), devin→devin.ai + codeium (`api.devin.ai` for the API, `cli.devin.ai` for installs/updates, `app.devin.ai` for the web UI, plus the Codeium-lineage telemetry hosts the binary still dials: `server.codeium.com`, `unleash.codeium.com`, `*.sentry.io`, `openrouter.ai`) + baseline (github, npmjs, pypi, crates.io, CA OCSP) + task extras. Non-matching → HTTP 403. Stopped via `SandboxBundle::Drop` on PTY teardown. **Not started in `EnforceFs`** (no network sandbox). +2. Per-task **in-process CONNECT proxy** on an OS-assigned port (Rust thread inside Tauri binary). Regex hostname allowlist per CLI: claude→anthropic, gemini→google, codex→openai, muse→meta (`api.meta.ai` for both the Model API and the release channel, `auth.meta.com` + `accountscenter.meta.com` for the device-code login, `lookaside.facebook.com` for the launcher's binary download, so a blocked self-update reads as a stale version), devin→devin.ai + codeium (`api.devin.ai` for the API, `cli.devin.ai` for installs/updates, `app.devin.ai` for the web UI, plus the Codeium-lineage telemetry hosts the binary still dials: `server.codeium.com`, `unleash.codeium.com`, `*.sentry.io`, `openrouter.ai`) + baseline (github, gitlab, azure devops + its Entra token endpoint + the ARM host `az login` finishes through, npmjs, pypi, crates.io, CA OCSP) + task extras. Non-matching → HTTP 403. Stopped via `SandboxBundle::Drop` on PTY teardown. **Not started in `EnforceFs`** (no network sandbox). ## Key behaviors @@ -60,14 +60,14 @@ Four states, set per-task at create + editable later. `Enforce` is the full cage - **Activity monitor**: the host pid tree `procmon.rs` walks cannot see into a container - the pid it finds is the `docker run` client, which sits nearly idle regardless of how busy the agent is, because the real work happens inside the daemon's VM. `PtySlot.docker_container` (the `--name` from `DockerSpec`) lets `procmon_roots` mark which rows are Docker-sandboxed; `docker::merge_stats` (in `docker.rs`, run inside `procmon_start`/`procmon_sample`'s `spawn_blocking`, never on the IPC thread) then overwrites those rows with a single batched `docker stats --no-stream` query covering every live Docker task, and keeps its own cpu_pct history per row since the host-based numbers the platform sampler already baked in are the wrong ones. `ProcRow.is_docker` badges the row in the UI and explains why its `children` breakdown is always empty (the container's real process tree isn't visible from the host either). - **Cleanup**: `docker::cleanup_task` runs on task archive and on every `task_set_docker` call (turning Docker off OR staying on but editing `extra_args`/`extra_mounts`) - the latter closes a gap where `kill_task_ptys` only kills the local `docker run` client (attached foreground, no `-d`), never the container server-side, so the OLD container used to keep running until whatever tab this was happened to respawn on its own (which triggers `pty_spawn`'s own pre-spawn `cleanup_task`, belt-and-suspenders for exactly this). `docker::cleanup_all` runs on app quit AND on app startup (same reasoning: a crash or force-quit doesn't stop an attached container either, so a previous session's abandoned containers are reaped as soon as the next launch's `.setup()` runs, before anything in this session could plausibly own one). - **Agent support**: `agent_config()` in `docker.rs` maps agent id → container config-dir wiring, deriving its mount paths from `agent_dirs::state_dirs()` (`src-tauri/src/agent_dirs.rs`) rather than its own hardcoded table - that module is the single source for "where does this agent's state live", shared with Seatbelt's default `sandbox_allowed_paths` (`default_agents()` in `lib.rs`) so the two don't hand-maintain separate copies that can drift. Docker's set is the CONFIRMED-state subset of what Seatbelt allows: Seatbelt additionally allows macOS-only extras (`Library/Application Support/*`, defensive XDG paths, claude's regex-covered sidecar files) that have no Docker-container equivalent and stay hand-authored in `default_agents()`. grok is deferred in Docker regardless of what `agent_dirs` lists for it (binary + skills + config all live under `~/.grok` with no clean relocation env, see `docs/docker-sandbox/findings.md`) — a grok login done inside a Docker task is lost on the next container run, since no persistent config dir is mounted for it. devin is deferred for the same shape of reason: its versioned CLI binaries live under `~/.local/share/devin` next to `credentials.toml`, so mounting that directory to keep the login would shadow the binary the image baked in. -- **gh / glab inside the container**: `Dockerfile.default` installs both CLIs (gh from its official apt repo; glab from the latest release `.deb`, resolved through the GitLab API and deliberately NON-FATAL so a JSON-shape change cannot fail the build and with it every Docker task), and bakes `credential.https://github.com.helper = !gh auth git-credential` (plus the gist and gitlab.com equivalents) into the image's `/root/.gitconfig` so `git push` over HTTPS works the moment `gh auth login` does. `gh auth setup-git` would write that same config into the container's throwaway layer and have to be re-run in every single container. A Dockerfile saved before this shipped has neither CLI, so Settings → Docker Sandbox flags it (keyed on the `cli.github.com` apt line) and points at "Reset to default". **How "customised" is decided changed, because the obvious way is wrong.** It used to be `saved == DEFAULT_DOCKERFILE`, which answers a different question: the moment termic ships a new default (adding an agent to it), every untouched Dockerfile reports as the user's own work and the only remedy offered is "Reset to default" - the one button someone who genuinely customised theirs must not press. Provenance is stored instead, in `Dockerfile.origin` beside the file: the generation and the shipped default it was written FROM. A saved Dockerfile still equal to its recorded origin was never edited, whatever today's default says, so `read_dockerfile` upgrades it in place and a customised one is left alone. `DOCKERFILE_GENERATION` is the blunt instrument for a release where an old file cannot be left behind at all (a broken base image, a security fix): bumping it sweeps EVERY saved Dockerfile back to the shipped default, edits included. Generation 1 is the introduction of the mechanism, since a profile predating it has no way to tell an edit from an older termic's write. +- **gh / glab inside the container**: `Dockerfile.default` installs both CLIs (gh from its official apt repo; glab from the latest release `.deb`, resolved through the GitLab API and deliberately NON-FATAL so a JSON-shape change cannot fail the build and with it every Docker task), and bakes `credential.https://github.com.helper = !gh auth git-credential` (plus the gist and gitlab.com equivalents) into the image's `/root/.gitconfig` so `git push` over HTTPS works the moment `gh auth login` does. `gh auth setup-git` would write that same config into the container's throwaway layer and have to be re-run in every single container. The Azure DevOps CLI is deliberately NOT baked in (`az` is a heavyweight Python install; a user who wants it adds it to the Dockerfile's editable region), but `.azure` is already in the shared config dirs below, so an `az login` / `az devops login` run inside a container persists the same way. One hole worth knowing: `az` has no `auth git-credential` equivalent, so `az`-authenticated containers can run `az devops`/`az repos` but CANNOT `git push` to an HTTPS `dev.azure.com` remote - SSH remotes push fine. A related shadowing gotcha: `AZURE_CONFIG_DIR=/root/.azure` relocates `cliextensions/` under the mounted dir too, so an `azure-devops` extension baked at IMAGE BUILD time lands in `/root/.azure/cliextensions` and is then SHADOWED by the empty shared mount - install the extension at RUNTIME (`az extension add --name azure-devops` inside a container once; it writes into the shared dir and persists). A Dockerfile saved before this shipped has neither CLI, so Settings → Docker Sandbox flags it (keyed on the `cli.github.com` apt line) and points at "Reset to default". **How "customised" is decided changed, because the obvious way is wrong.** It used to be `saved == DEFAULT_DOCKERFILE`, which answers a different question: the moment termic ships a new default (adding an agent to it), every untouched Dockerfile reports as the user's own work and the only remedy offered is "Reset to default" - the one button someone who genuinely customised theirs must not press. Provenance is stored instead, in `Dockerfile.origin` beside the file: the generation and the shipped default it was written FROM. A saved Dockerfile still equal to its recorded origin was never edited, whatever today's default says, so `read_dockerfile` upgrades it in place and a customised one is left alone. `DOCKERFILE_GENERATION` is the blunt instrument for a release where an old file cannot be left behind at all (a broken base image, a security fix): bumping it sweeps EVERY saved Dockerfile back to the shipped default, edits included. Generation 1 is the introduction of the mechanism, since a profile predating it has no way to tell an edit from an older termic's write. - **Your git identity**: the container has no `~/.gitconfig` of yours, so before this a commit made inside one failed with "Author identity unknown. *** Please tell me who you are." and had to be answered with `git config` again, in every container, forever. `build_spec` step 4d reads the identity the host would use *for that worktree* (`git -C config --get user.name` / `user.email`, repo-scoped so git resolves the whole chain, including an `[includeIf "gitdir:~/work/"]` block that a read of `~/.gitconfig` alone would miss), writes the two keys to `/docker/gitconfig/` (one file per task, because two tasks can legitimately resolve different identities; temp + rename, so a container starting for one tab cannot read the file another tab's spawn is writing), and bind-mounts it READ-ONLY at git's XDG global config path. Nothing is mounted when the host has no identity. Two placements were rejected, and both are worth knowing because each is the obvious thing to try. Mounting the host's `~/.gitconfig` at `/root/.gitconfig` **shadows the image's own**, taking out `safe.directory = *` (without which git refuses every command in the bind-mounted worktree as "dubious ownership") and the gh/glab credential helpers in one go, and it drags `commit.gpgsign` and `credential.helper = osxkeychain` into a container that has neither. Injecting `GIT_AUTHOR_*` / `GIT_COMMITTER_*` env is less code and silently WRONG: env outranks repo-local config, so it rewrites the identity of every repo that deliberately sets its own. The XDG file is read at the GLOBAL level, which is below the repo's `.git/config` (mounted, step 2) and below the image's `~/.gitconfig` for any key that file sets, and it sets no `user.*`. Verified in the real image: with a repo-local `user.email`, the commit is attributed to the repo's, and without one, to the host's. One diagnostic trap, confirmed against the shipped image (git 2.39.5): `git config --list --global` does NOT list this file, only `~/.gitconfig`, despite `--global` being documented as reading both for read operations. Someone checking that way sees the image's four baked entries and concludes the identity never arrived. `git config --list --show-origin | grep user\.` and `git config --show-origin --get user.email` both show it, and name the file the value came from. Two visibility rules go with it, because a mount nobody can see is a mount nobody can audit. The read falls back to the HOME dir when the task path is not a directory, which is what makes the line appear in the SETTINGS-level command preview at all: that panel builds from `sample_preview_task()`, whose path is a deliberate placeholder, and it promises the reader that everything but the worktree path is what a real launch uses. And when no identity resolves from either place, `spec.warnings` gets the "No git identity found on this Mac" line, so the one case this feature cannot rescue says so in the preview instead of surfacing later as the agent's first commit failing. The XDG root follows a per-agent `XDG_CONFIG_HOME` when an agent sets one (git looks wherever that points, so a file at the default path would never be read), and the resulting target still goes through `persist_target_allowed`, so an `XDG_CONFIG_HOME` of `/etc` mounts nothing. -- **Shared config dirs**: `Settings.docker_shared_config_dirs` (Settings → Docker Sandbox → "Persisted directories & environment" → the **All agents** row at the top of the same list, seeded by `docker::default_shared_config_dirs` with `.config/gh` + `.config/glab-cli`). It lives IN that list rather than in a section of its own: "shared config dirs" and "persisted directories" were two names for one mechanism differing only in axis, and a reader had to work that out. Same chip UI (`DirChips`, shared with the per-agent rows so the two cannot drift into looking like different features), same patch-on-change save, no environment column because env is per agent by definition. +- **Shared config dirs**: `Settings.docker_shared_config_dirs` (Settings → Docker Sandbox → "Persisted directories & environment" → the **All agents** row at the top of the same list, seeded by `docker::default_shared_config_dirs` with `.config/gh` + `.config/glab-cli` + `.azure`). It lives IN that list rather than in a section of its own: "shared config dirs" and "persisted directories" were two names for one mechanism differing only in axis, and a reader had to work that out. Same chip UI (`DirChips`, shared with the per-agent rows so the two cannot drift into looking like different features), same patch-on-change save, no environment column because env is per agent by definition. - The list itself is container dirs mounted into EVERY container, for every agent, from one host dir each under `docker::forge_config_host_dir()` (`/docker-forge/`, host layout mirroring the container path). `build_spec` step 4b runs each entry through the same `sanitize_extra_dir` the per-agent list uses, and sets a config-dir env var when it recognises the CLI (`GH_CONFIG_DIR` / `GLAB_CONFIG_DIR`, keyed on the entry's BASENAME so moving gh's dir still points the var at wherever it landed). This is the opposite axis to the per-agent rows beneath it: what goes here belongs to the USER, not to an agent vendor. A GitHub token is the same token whichever agent pushes with it, and a per-agent copy would mean logging in once per agent AND once per clone of one. The dirs start EMPTY, so nothing crosses from the Mac until someone runs `gh auth login` inside a container; both CLIs fall back to a plaintext token file when no keyring is reachable, which is the case in the image, so that login is what survives `--rm`. A user who lists `.config/gh` in an agent's own extra dirs keeps the per-agent mount instead: the explicit choice outranks the shared default, and the env var still names the same container path. + The list itself is container dirs mounted into EVERY container, for every agent, from one host dir each under `docker::forge_config_host_dir()` (`/docker-forge/`, host layout mirroring the container path). `build_spec` step 4b runs each entry through the same `sanitize_extra_dir` the per-agent list uses, and sets a config-dir env var when it recognises the CLI (`GH_CONFIG_DIR` / `GLAB_CONFIG_DIR` / `AZURE_CONFIG_DIR`, keyed on the entry's BASENAME so moving gh's dir still points the var at wherever it landed). This is the opposite axis to the per-agent rows beneath it: what goes here belongs to the USER, not to an agent vendor. A GitHub token is the same token whichever agent pushes with it, and a per-agent copy would mean logging in once per agent AND once per clone of one. The dirs start EMPTY, so nothing crosses from the Mac until someone runs `gh auth login` inside a container; both CLIs fall back to a plaintext token file when no keyring is reachable, which is the case in the image, so that login is what survives `--rm`. A user who lists `.config/gh` in an agent's own extra dirs keeps the per-agent mount instead: the explicit choice outranks the shared default, and the env var still names the same container path. **Not a mount of `/root/.config`.** Nesting would work (Docker orders mounts by destination depth, so a per-agent `.config/opencode` still applies underneath a blanket mount, verified), but an empty dir over the whole tree SHADOWS what the image put there at build time (`/root/.config/fish/completions/grok.fish` today, whatever an unpinned installer drops there next) — the exact failure `agent_dirs.rs` documents for grok — and it would pool every credential any agent ever created into one directory every other agent reads. One named dir at a time, each a deliberate choice. diff --git a/docs/ui.md b/docs/ui.md index d1352bba..e1621193 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -7,7 +7,7 @@ - `CliIcon cli={...}` + `CLI_BRAND_COLOR[cli]` for claude/gemini/codex (orange/blue/green). - Tooltips default `delay: 0`. Override per-call. - `cn()` from `@/lib/utils` for class composition. -- **Dialog mode switches ride the TITLE line** (`titleAction` on `AppDialog`, spread `dialogTitleAction` onto the control). "Import a worktree", "From a GitHub issue", "Blank task instead" and "New worktree instead" change what KIND of thing the dialog is making, which is chrome, not a field, and as form rows they cost a `gap-4` row each on every open of a dialog most of whose opens have nothing to do with them. The title line is mostly empty, so they are free there. Two rules for anything you put in that slot: it is inside the window drag region, so it must carry the `data-tauri-drag-region="false"` + `WebkitAppRegion: "no-drag"` opt-out that `dialogTitleAction` provides (without it the control is not clickable at all), and the labels stay SHORT because worktree mode can show two switches at once. The row wraps rather than truncating, so the pathological case degrades to the row it used to cost instead of clipping. +- **Dialog mode switches ride the TITLE line** (`titleAction` on `AppDialog`, spread `dialogTitleAction` onto the control). "Import a worktree", "From a GitHub issue" (named per-forge: GitLab issue, Azure DevOps work item), "Blank task instead" and "New worktree instead" change what KIND of thing the dialog is making, which is chrome, not a field, and as form rows they cost a `gap-4` row each on every open of a dialog most of whose opens have nothing to do with them. The title line is mostly empty, so they are free there. Two rules for anything you put in that slot: it is inside the window drag region, so it must carry the `data-tauri-drag-region="false"` + `WebkitAppRegion: "no-drag"` opt-out that `dialogTitleAction` provides (without it the control is not clickable at all), and the labels stay SHORT because worktree mode can show two switches at once. The row wraps rather than truncating, so the pathological case degrades to the row it used to cost instead of clipping. - **Focus indicator: one rule, `src/index.css`, `@layer base`.** A single `:where(a[href], button, summary, input, select, textarea, [role="button"], [tabindex]:not([tabindex="-1"])):focus-visible` gives every control a 2px `--color-accent-soft` outline at `outline-offset: -2px`. The negative offset draws it INSIDE the border box, so a control sitting flush against a container edge cannot have it clipped (the sandbox picker's first card, which its dialog autofocuses, was the case that forced this). `:where()` makes it zero-specificity, so any component overrides it just by saying so. Do NOT add a per-component `focus-visible:ring-*`: `src/lib/focusRing.test.ts` fails if one appears. Text fields opt out with `outline-none` and signal focus with a border instead, as do Radix menu items (`data-highlighted`) and dialog containers (Radix focuses the content on open). - **A resize divider is a 9px grab strip, not the line it paints** (`components/ui/ResizeHandle.tsx`). The element IS the strip: 3px on the panel's side, the border pixel, 5px on the neighbour's, with `anchor` saying which edge of the parent it straddles. The painted 1px line is a child, so what lights up under the cursor and what takes the press are the same region. Do NOT go back to a thin element with a wider hit-area child hanging out of it: hit testing respects an ancestor's clip, and the sidebar's `overflow-hidden` reduced that arrangement to one grabbable pixel (measured, `tabs-layout.e2e.ts` "gives both dividers a grab strip"). For the same reason the sidebar's handle is a SIBLING of its `