diff --git a/CLAUDE.md b/CLAUDE.md index 4f350fdb..66bee256 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,6 +175,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Docker cases**: a case can point at a **container** running any CLI mode inside it, a **LOCATION OVERLAY on cases, never a `SessionMode`**. One long-lived container **per case**, shared by its sessions: killing a session kills only its in-container tmux, **never** `docker stop` while siblings remain. The workspace is bind-mounted at the **same absolute path**. Credentials are **seeded**, never shared RW. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** A drifted config (label hash) REFUSES the launch. ⚠️ An **adopted** container (`DockerCase.owned === false`) is only `exec`ed into: never create, start, stop, restart, remove, `docker commit` or `docker pause` it; fail closed. Test `owned === false`, never truthiness. ⚠️ Apply `owned` AFTER `dockerConfigHash`. ⚠️ Run modes come from the CONTAINER (`availableModes`), and a failed probe is normal for an OWNED case. ⚠️ Root exec user drops the bypass flag via the registry's `overlays.docker.rootCommand`, never a branch. ⚠️ Adoption is admin-only in multi-user mode. ⚠️ Loopback prod needs `CODEMAN_DOCKER_BRIDGE_HOOKS=1` for in-container hooks. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md` +**Adopted tmux sessions** (`src/foreign-tmux.ts` pure, `foreign-tmux-discovery.ts`, `foreign-sessions.js`, `POST /api/sessions/adopt`, admin-only in multi-user mode): a tmux session a human started (local, in a container, over ssh) opens as a tab through a wrapper session on OUR socket, a LOCATION OVERLAY like remote and Docker, never a `SessionMode`. ⚠️ `session.remote` and `session.docker` are undefined on one (the connection is on `adopt.*`) and its `workingDir` is the neutral `/tmp`, so anything that treats `workingDir` as the session's workspace, or branches on remote or docker to mean "not here", must also test `session.isAdopted`. ⚠️ `isAdopted` asks who launched the process, so it is never folded into `isExternalCliMode()`. ⚠️ `foreignTargetKey()` is the ONE identity of a target; a socket and a name collide across containers and hosts. ⚠️ Never set anything on the foreign server beyond our own `codeman-view-*`: no `window-size`, no key tables. ⚠️ Session names are someone else's text: they pass the allowlist at discovery, so a browser only ever sends an opaque id. → [architecture-invariants#adopted-foreign-tmux-sessions](docs/architecture-invariants.md#adopted-foreign-tmux-sessions), `docs/api-reference.md` + **Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` and `~/.local/bin` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md` **CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine`, `capabilities.workDetect.composerPopupLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts` (the one exception: a config-backup restore of `clis.json`, which then calls `reloadCliRegistry()`), which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md` diff --git a/docs/api-reference.md b/docs/api-reference.md index 9a269ad3..aaed4292 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1085,6 +1085,71 @@ the caller's case space, a linked case's exact path, or a Claude worktree of eit session, the create response carries `tabLayout` as it does for `tabGroupId`. Deleting the case forgets the value; existing sessions never move. +## Adopting a foreign tmux session + +A tmux session someone started by hand (`tmux new -s work`) lives on its own socket, +where Codeman does not look for its own sessions. These two routes list such sessions +and open one as a tab. Admin-only in multi-user mode (403), since the list shows other +people's processes and adopting a shell is arbitrary execution as the server account. + +`GET /api/v1/mux/foreign` lists candidates. Local sessions are always scanned, and +cached for 5 s. `?docker=1` and `?remote=1` add the containers behind the saved Docker +cases and the saved ssh hosts, one `docker exec` or ssh connection per target, so the +browser sends them only on an explicit scan. `?force=1` skips the local cache. + +```json +{ + "sessions": [ + { + "id": "fa9b76d36", + "location": "local", + "socketPath": "/tmp/tmux-1000/default", + "sessionName": "work", + "windows": 1, + "attached": false, + "createdAt": 1793931275000, + "mode": "claude", + "command": "claude", + "workingDir": "/home/me/project", + "adoptedBy": "961d0a82-6888-425d-a3ee-dab38ecbb598" + } + ], + "scannedAt": 1793931277916, + "notes": ["remote Build box: unreachable, or no tmux"], + "pollIntervalMs": 8000, + "canScanWide": true +} +``` + +`hostId`, `hostLabel` and `containerName` appear on a docker or remote candidate. +`adoptedBy` names the session already wrapping that target. `notes` says why a location +gave nothing, each opening with where it came from (`local`, `docker `, +`remote