From 93e91690dba1241b17e4a6af1b9f4d4fb6f5235c Mon Sep 17 00:00:00 2001 From: d fei Date: Mon, 31 Aug 2026 01:30:47 -0700 Subject: [PATCH] feat(adopt): adopt tmux sessions a human started outside Codeman MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The home screen now lists tmux sessions Codeman did not start (a claude or codex running inside `tmux new -s work`, or just a shell); one click turns one into a tab you can keep working in. Adoption is a fourth LOCATION OVERLAY, structurally identical to remote/docker, and NOT a new SessionMode: the outer layer is still an ordinary codeman-<8hex> wrapper session on this instance's own socket, and only the pane inside it runs the attach. Session-name allowlisting, capture, input and the recovery chain are therefore untouched, and "detach, never kill the foreign session" becomes structural rather than a rule to remember — killSession can only ever reach our own wrapper. All three locations share one probe script, one parser and one classifier, and differ only in the shell around them (direct exec / docker exec / ssh). Pane mode is decided from the bounded process-tree argv of pane_pid, because claude and codex both report `node` as pane_current_command; anything unrecognised is treated as a shell. Things measured rather than assumed: - A grouped session buys only `status off` and an independent current window, not an independent size. Measured on tmux 3.3a: both a bare attach and a grouped one shrink the other client's 200x49 to 80x23. Only `window-size largest` preserves it, but that is a shared window option that survives our departure, so it is not set. - View reclamation: local relies on client death, ssh on SIGHUP, but a `docker exec` does not die with its client — the container-side view must be reclaimed explicitly on kill or every adoption leaks one. - The session name is chosen by someone else, while the local launch chain ends in `bash -c` plus JSON.stringify, which does not escape `$` or backticks, so the outer shell performs substitution before the inner single quotes close. Session names and socket paths therefore pass a character allowlist and are discarded during DISCOVERY, so a non-conforming candidate never gets an id. Capability degrades by "who started this process": an adopted session has no hooks, no envOverrides and no effort, and its working directory is merely the foreign pane's cwd at that moment (possibly not even on this host). Respawn, Ralph, the orchestrator, hook waits, and every watcher that tails the local filesystem by workingDir are refused or skipped, and the close dialog no longer offers a "kill the session" option it cannot honour. --- src/config/foreign-tmux.ts | 38 ++ src/foreign-tmux-discovery.ts | Bin 0 -> 12487 bytes src/foreign-tmux.ts | 597 ++++++++++++++++++++++++++ src/mux-interface.ts | 12 + src/session.ts | 54 ++- src/tmux-manager.ts | 144 ++++++- src/types/foreign-tmux.ts | 102 +++++ src/types/index.ts | 1 + src/types/session.ts | 74 ++++ src/web/public/app.js | 36 +- src/web/public/foreign-sessions.js | 359 ++++++++++++++++ src/web/public/index.html | 15 +- src/web/public/mobile-overview.js | 10 + src/web/public/mobile.css | 12 + src/web/public/styles.css | 170 ++++++++ src/web/public/terminal-ui.js | 8 + src/web/routes/mux-routes.ts | 63 ++- src/web/routes/ralph-routes.ts | 11 + src/web/routes/respawn-routes.ts | 33 ++ src/web/routes/session-routes.ts | 153 +++++++ src/web/schemas.ts | 22 + src/web/server.ts | 21 +- src/web/session-wait-registry.ts | 17 + test/docker-adopted-container.test.ts | 11 +- test/foreign-tmux.test.ts | 314 ++++++++++++++ 25 files changed, 2248 insertions(+), 29 deletions(-) create mode 100644 src/config/foreign-tmux.ts create mode 100644 src/foreign-tmux-discovery.ts create mode 100644 src/foreign-tmux.ts create mode 100644 src/types/foreign-tmux.ts create mode 100644 src/web/public/foreign-sessions.js create mode 100644 test/foreign-tmux.test.ts diff --git a/src/config/foreign-tmux.ts b/src/config/foreign-tmux.ts new file mode 100644 index 00000000..e4c838c2 --- /dev/null +++ b/src/config/foreign-tmux.ts @@ -0,0 +1,38 @@ +/** + * @fileoverview Bounds for FOREIGN tmux discovery (sessions a human started + * outside Codeman). + * + * Two facts drive every number here. First, the number of tmux sockets and panes + * on a machine is NOT under Codeman's control — a discovery walk with no ceiling + * is an unbounded loop over data someone else produces, so sockets and panes are + * both hard-capped. Second, an ssh handshake is an order of magnitude slower than + * a local `exec`; reusing the shared 5s `EXEC_TIMEOUT_MS` would classify every + * remote host as unreachable, so the probe gets its own timeout. + * + * @module config/foreign-tmux + */ + +/** How often the browser re-polls `/api/mux/foreign` while the home screen is visible. */ +export const FOREIGN_POLL_INTERVAL_MS = 8000; + +/** + * Server-side cache TTL for a LOCAL scan. This, not the poll interval, is what + * bounds the real cost: N open tabs polling at 8s still trigger at most one scan + * per TTL. + */ +export const FOREIGN_CACHE_TTL_MS = 5000; + +/** Timeout for one probe invocation (local exec, `docker exec`, or one ssh). */ +export const FOREIGN_PROBE_TIMEOUT_MS = 12000; + +/** Max tmux sockets inspected per location, oldest-first by directory order. */ +export const FOREIGN_MAX_SOCKETS = 16; + +/** Max pane rows parsed from one probe. Panes past this are dropped, not errors. */ +export const FOREIGN_MAX_PANES = 400; + +/** Max process rows parsed from one probe's `ps` snapshot. */ +export const FOREIGN_MAX_PROCS = 4000; + +/** Max bytes of probe stdout kept. A runaway `ps` must not become a heap problem. */ +export const FOREIGN_PROBE_MAX_BYTES = 2 * 1024 * 1024; diff --git a/src/foreign-tmux-discovery.ts b/src/foreign-tmux-discovery.ts new file mode 100644 index 0000000000000000000000000000000000000000..ab3c10174a8770ffb84a65cc960138c866279587 GIT binary patch literal 12487 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 *Dx(Vm6Y+b{ob0JpVE%_Sr7V0TRN=6aBcND zG}-aJ3YZa{3hm8lxOyTpgYk!R?H4>`bwpy;DqNUv^3O6{?u z!E`--dczc&6S?$7u`x&Ep(4dBo!EF4XK3zdIztP*ls3ysi*DYH@gA;_3pAJ~ZdZta z+fV{PJe%wFGv!#O*SHWwqecMZ0P~q0xneB2c-P;R?0G{TjtA{+ zMbPhuf_hxWMX*ATTF=J0h-mauu?gI!^NNOK)gT@PM;8t|)1L#iO3Wj+Pl8%(ttW7W zNC+_JmAs|MVH!%8c14|+^IzPh` z3)a}~#fHq_?bZ5Q4ZwM{6|{v<{Yj@Jh?IVB`@8di!`4 z(cE&65%a91Hr9OFW&YF~4)ENiHe?$goY)wTrI2S^?&K1kX$%>1#e?!u$>aME*wGTV zCGy&#DMFrU=cuZES`uCCbgo|op}JuDZ2#)(rKPW$64dYrrd>kdz=oU<&j`UK7V`}b z-W~ulxW`tzc-Nw!u;Q;CJzX=QEr*@ccG}55FW4ZWJF*Vn&6bQ1u8oR%Zfc`BlBTqf zwL#(f4Y;hnE($!$$_d`MRuP?60aBFj?H(QB1s9*1p>UM&j20j+38v{BZ|gGD=nP6z zK5)Y&g2Y(f)&YY{6>l5{WG&3gaovEw^ah%nYAn)(uwhe2N6qCuY>sqv4S%(CM!5Ls zG;z>3*F!r3C~jlhJu-8s7D=bP{$q#7J&)CPgoqU#3if8?9d2ljEubx0Z@lXFB@1tH z+efVDNJ?5%nQAegXJ}^#PYQ*dWSLBy`P2B(R_feiOZwA?YD`N6u3HjEILGUST9Bhr&g)rl#f-YQn6@Z=}+Zov1;yR93e9k}Aj9 zr0b?sL1}+(0QIwuD$Z3hN z@j`=3#XdB6d1|UOwcRiSrmN9@d6uHuwqsSe>8yWDxNFRxC#=TaCEQt0!W|4L*`qC* zl-WrZ)$Ah$bIEgae|rxAr1N3>?#e|M=vmEMbf4-eil@5($wEI#7pkG-7ov{?=WefS zgse;B&+s5$g#q-1)feH`cpV|HHoX@OZ}FUxy~C~p4WzdyQHI`yI~?gsObgues|&F6 zB!0j7R*ReLW|?YC^xM6SX@@T^^#Z2pLi++)*L+!ltkDi?+Hgq4!`)c$d9KwQBocPH VZihE;-G2OhS>Mi;vi@P~{{W ' + diff --git a/src/web/public/mobile-overview.js b/src/web/public/mobile-overview.js index df647ae9..ee265fa9 100644 --- a/src/web/public/mobile-overview.js +++ b/src/web/public/mobile-overview.js @@ -432,6 +432,16 @@ Object.assign(CodemanApp.prototype, { ) ); + // Sessions a human opened outside Codeman. Its own container, rebuilt by the + // ONE renderer in foreign-sessions.js — the phone must not grow a second row + // builder that could describe the same session differently from the desktop. + const foreign = document.createElement('div'); + foreign.className = 'foreign-sessions mobile-foreign-sessions'; + foreign.id = 'mobileForeignSessions'; + foreign.hidden = true; + el.appendChild(foreign); + this.renderForeignSessions?.(foreign); + el.appendChild( this._buildMobileOverviewSection( 'Past sessions', diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index c9b0838d..e4509444 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -3836,3 +3836,15 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close { transition: none; } } + +/* Foreign sessions block inside the phone overview (foreign-sessions.js). + The desktop block sits inside the welcome column; here it is a full-width + section between CURRENT and PAST, so it only needs the surrounding spacing — + every row style is shared with styles.css on purpose. */ +.mobile-foreign-sessions { + margin: 0.75rem 0.75rem 0; +} + +.mobile-foreign-sessions .foreign-list { + max-height: none; +} diff --git a/src/web/public/styles.css b/src/web/public/styles.css index dd3fb90a..72d90bf2 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -17618,3 +17618,173 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle transition: none; } } + +/* ═══════════════════════════════════════════════════════════════ + Foreign sessions — tmux sessions a human started outside Codeman + (foreign-sessions.js). Rendered on the welcome screen and, with the + same row builder, inside the phone overview. + + Colour vocabulary is deliberately the session-tab one: a mode dot on + the left, name over a dim meta line, action pinned right. A block that + invented its own language here would read as a different product. + ═══════════════════════════════════════════════════════════════ */ + +.welcome-foreign { + width: 100%; + margin-top: 0.75rem; +} + +.foreign-sessions { + display: flex; + flex-direction: column; + gap: 0.4rem; + text-align: left; +} + +/* `.foreign-sessions` is a flex container, so `[hidden]` needs re-asserting or + the module's only visibility lever does nothing (same trap as .home-sessions). */ +.foreign-sessions[hidden] { + display: none; +} + +.foreign-header { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.foreign-title { + font-size: 0.85rem; + color: var(--text-dim); + font-weight: 500; + text-align: left; +} + +.foreign-count { + font-size: 0.68rem; + color: var(--text-dim); + background: rgba(255, 255, 255, 0.05); + border-radius: 999px; + padding: 0.1rem 0.45rem; + white-space: nowrap; +} + +.foreign-scan-toggle { + margin-left: auto; + font-size: 0.68rem; + color: var(--text-dim); + background: transparent; + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.12rem 0.5rem; + cursor: pointer; +} + +.foreign-scan-toggle[aria-pressed='true'] { + color: var(--session-blue, #4a9eff); + border-color: var(--session-blue, #4a9eff); +} + +.foreign-list { + display: flex; + flex-direction: column; + gap: 0.3rem; + max-height: min(40vh, 320px); + overflow-y: auto; +} + +.foreign-row { + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.4rem 0.55rem; + border: 1px solid var(--border); + border-radius: 6px; + background: rgba(255, 255, 255, 0.02); + min-width: 0; +} + +.foreign-row--open { + opacity: 0.62; +} + +.foreign-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex: 0 0 auto; + background: var(--text-muted, #888); +} + +.foreign-dot--claude { + background: #d97757; +} +.foreign-dot--codex { + background: #9b8cff; +} +.foreign-dot--shell { + background: #4caf7d; +} + +.foreign-row-body { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1 1 auto; +} + +.foreign-row-name { + font-size: 0.82rem; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.foreign-row-sub { + font-size: 0.68rem; + color: var(--text-dim); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.foreign-open-btn { + flex: 0 0 auto; + font-size: 0.7rem; + padding: 0.22rem 0.6rem; + border-radius: 5px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.04); + color: var(--text); + cursor: pointer; +} + +.foreign-open-btn:hover:not(:disabled) { + border-color: var(--session-blue, #4a9eff); + color: var(--session-blue, #4a9eff); +} + +.foreign-open-btn:disabled { + opacity: 0.55; + cursor: default; +} + +.foreign-empty { + font-size: 0.72rem; + color: var(--text-dim); + padding: 0.3rem 0.1rem; +} + +.foreign-notes { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.foreign-note { + font-size: 0.66rem; + color: var(--text-dim); + opacity: 0.85; + line-height: 1.35; +} diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index bb83cc5a..da6a485e 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1985,6 +1985,9 @@ Object.assign(CodemanApp.prototype, { if (overlay) overlay.classList.remove('visible'); this.hideHomeSessions?.(); this.showMobileOverview(); + // The phone overview hosts the same list in its own container. + this.wireForeignSessions?.(); + this.startForeignPolling?.(); this._updateCjkInputState?.(); return; } @@ -1999,6 +2002,10 @@ Object.assign(CodemanApp.prototype, { // Open tabs down the left gutter. Self-gating: a window too narrow to hold // the column without overlapping the content leaves it hidden. this.showHomeSessions?.(); + // Sessions a human opened outside Codeman. Polls only while this screen is + // up (stopped in hideWelcome) — see foreign-sessions.js. + this.wireForeignSessions?.(); + this.startForeignPolling?.(); } // Home screen has no input target — hide the CJK textarea (activeSessionId // is null by the time we get here). Guarded: defined on the app object. @@ -2008,6 +2015,7 @@ Object.assign(CodemanApp.prototype, { hideWelcome() { this.hideMobileOverview?.(); this.hideHomeSessions?.(); + this.stopForeignPolling?.(); const overlay = document.getElementById('welcomeOverlay'); if (overlay) { overlay.classList.remove('visible'); diff --git a/src/web/routes/mux-routes.ts b/src/web/routes/mux-routes.ts index 12b22229..b4e42ce9 100644 --- a/src/web/routes/mux-routes.ts +++ b/src/web/routes/mux-routes.ts @@ -1,6 +1,13 @@ /** * @fileoverview Mux (tmux) session management routes. - * Provides mux session listing, killing, reconciliation, and stats control. + * Provides mux session listing, killing, reconciliation, stats control, and + * discovery of FOREIGN tmux sessions (ones a human started outside Codeman). + * + * Discovery lives here rather than beside the adopt endpoint on purpose: like + * every other route in this file it exposes cross-user process state — other + * people's session names, commands and working directories — so it inherits the + * admin gate this file already applies. Adoption is a session CREATE and stays in + * `session-routes.ts`, where the owner, capacity and case-space gates live. */ import { FastifyInstance } from 'fastify'; @@ -8,6 +15,8 @@ import type { InfraPort } from '../ports/index.js'; import { STATS_COLLECTION_INTERVAL_MS } from '../../config/server-timing.js'; import { requireAdmin } from '../route-helpers.js'; import { isMultiUserMode } from '../../config/multiuser.js'; +import { discoverForeignSessions, readAllDockerCases, readAllRemoteHosts } from '../../foreign-tmux-discovery.js'; +import { FOREIGN_POLL_INTERVAL_MS } from '../../config/foreign-tmux.js'; export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void { app.get('/api/mux-sessions', async (req, reply) => { @@ -36,6 +45,58 @@ export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void { return result; }); + /** + * Foreign tmux sessions available for adoption. + * + * LOCAL results are always included and are TTL-cached, because the home screen + * polls this endpoint while it is open. DOCKER and REMOTE are opt-in per + * request (`?docker=1`, `?remote=1`): each costs one `docker exec` or one ssh + * per target, and having the home page fan those out on every load is the one + * cost this design refuses to pay. + * + * `adoptedBy` is filled from the live mux sessions, so a target Codeman already + * wraps renders as "open" rather than offering a second wrapper. + */ + app.get('/api/mux/foreign', async (req, reply) => { + if (isMultiUserMode() && !requireAdmin(req, reply)) return; + const q = (req.query ?? {}) as Record; + const wantDocker = q.docker === '1' || q.docker === 'true'; + const wantRemote = q.remote === '1' || q.remote === 'true'; + + // Read the registries either way: `canScanWide` tells the browser whether the + // expensive scan has anywhere to go. Without it the UI hides an empty block — + // and with it the toggle that is the ONLY way to populate that block, which on + // a host with containers but no local tmux sessions made the feature invisible. + const dockerCases = await readAllDockerCases(); + const remoteHosts = await readAllRemoteHosts(); + + const result = await discoverForeignSessions({ + local: true, + force: q.force === '1', + dockerCases: wantDocker ? dockerCases : undefined, + remoteHosts: wantRemote ? remoteHosts : undefined, + }); + + // Match on the (socket, session) pair rather than on our opaque candidate id: + // the id encodes a host key that a restored wrapper does not carry, while the + // pair is exactly what the wrapper stores and what it re-attaches to. + const wrapped = new Map(); + for (const m of ctx.mux.getSessions()) { + if (m.adopt) wrapped.set(`${m.adopt.socketPath}\u0000${m.adopt.targetSession}`, m.sessionId); + } + + return { + sessions: result.sessions.map((f) => ({ + ...f, + adoptedBy: wrapped.get(`${f.socketPath}\u0000${f.sessionName}`), + })), + scannedAt: result.scannedAt, + notes: result.notes, + pollIntervalMs: FOREIGN_POLL_INTERVAL_MS, + canScanWide: dockerCases.length > 0 || remoteHosts.length > 0, + }; + }); + app.post('/api/mux-sessions/stats/start', async (req, reply) => { // Multi-user: process-wide stats collection toggle → admin-only. if (isMultiUserMode() && !requireAdmin(req, reply)) return; diff --git a/src/web/routes/ralph-routes.ts b/src/web/routes/ralph-routes.ts index 24b08a48..d7c9e27e 100644 --- a/src/web/routes/ralph-routes.ts +++ b/src/web/routes/ralph-routes.ts @@ -53,6 +53,17 @@ export function registerRalphRoutes( }; const session = findSessionOrFail(ctx, id, req); + // ⚠️ Adoption gate, kept SEPARATE from the external-CLI gate above: an adopted + // session can be `mode: 'claude'` and still be a process we never launched. + // Everything below drives the pane on the assumption Codeman owns what runs + // in it — sending `/clear`, killing and relaunching the agent — which against + // someone else's live session is destructive, not merely unsupported. + if (session.isAdopted) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'The Ralph tracker is not available for adopted sessions: Codeman did not start this agent and must not drive its lifecycle' + ); + } // Ralph tracker is not supported for external-CLI sessions (opencode/codex) if (isExternalCliMode(session.mode)) { return createErrorResponse( diff --git a/src/web/routes/respawn-routes.ts b/src/web/routes/respawn-routes.ts index 56543997..04902813 100644 --- a/src/web/routes/respawn-routes.ts +++ b/src/web/routes/respawn-routes.ts @@ -98,6 +98,17 @@ export function registerRespawnRoutes( } const session = findSessionOrFail(ctx, id, req); + // ⚠️ Adoption gate, kept SEPARATE from the external-CLI gate above: an adopted + // session can be `mode: 'claude'` and still be a process we never launched. + // Everything below drives the pane on the assumption Codeman owns what runs + // in it — sending `/clear`, killing and relaunching the agent — which against + // someone else's live session is destructive, not merely unsupported. + if (session.isAdopted) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'Respawn is not available for adopted sessions: Codeman did not start this agent and must not drive its lifecycle' + ); + } // Respawn is not supported for external-CLI sessions (opencode/codex) if (isExternalCliMode(session.mode)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`); @@ -241,6 +252,17 @@ export function registerRespawnRoutes( return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy'); } + // ⚠️ Adoption gate, kept SEPARATE from the external-CLI gate above: an adopted + // session can be `mode: 'claude'` and still be a process we never launched. + // Everything below drives the pane on the assumption Codeman owns what runs + // in it — sending `/clear`, killing and relaunching the agent — which against + // someone else's live session is destructive, not merely unsupported. + if (session.isAdopted) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'Respawn is not available for adopted sessions: Codeman did not start this agent and must not drive its lifecycle' + ); + } // Respawn is not supported for external-CLI sessions (opencode/codex) if (isExternalCliMode(session.mode)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`); @@ -310,6 +332,17 @@ export function registerRespawnRoutes( const body = reResult.data as { config?: Partial; durationMinutes?: number }; const session = findSessionOrFail(ctx, id, req); + // ⚠️ Adoption gate, kept SEPARATE from the external-CLI gate above: an adopted + // session can be `mode: 'claude'` and still be a process we never launched. + // Everything below drives the pane on the assumption Codeman owns what runs + // in it — sending `/clear`, killing and relaunching the agent — which against + // someone else's live session is destructive, not merely unsupported. + if (session.isAdopted) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'Respawn is not available for adopted sessions: Codeman did not start this agent and must not drive its lifecycle' + ); + } // Respawn is not supported for external-CLI sessions (opencode/codex) if (isExternalCliMode(session.mode)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`); diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index c838f1d0..5eb6f6ef 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -29,6 +29,16 @@ import { type DeepSeekConfig, type OmpConfig, } from '../../types.js'; +import { AdoptForeignSessionSchema } from '../schemas.js'; +import { + discoverForeignSessions, + invalidateForeignCache, + readAllDockerCases, + readAllRemoteHosts, +} from '../../foreign-tmux-discovery.js'; +import { foreignViewSessionName } from '../../foreign-tmux.js'; +import { requireAdmin } from '../route-helpers.js'; +import type { SessionAdopt } from '../../types/session.js'; import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../../session.js'; import { SseEvent } from '../sse-events.js'; import { @@ -1148,6 +1158,149 @@ export function registerSessionRoutes( return { session: lightState }; }); + // ========== Adopt a foreign tmux session ========== + + /** + * Wrap a tmux session a HUMAN started (local, in a container, or over ssh) in a + * Codeman session, so it appears as a tab and can be driven from the browser. + * + * Four things make this safe, and each is load-bearing: + * + * 1. **The body carries only an opaque id.** The socket path, session name and + * host are re-resolved by re-running discovery here. A browser therefore + * never supplies a fragment of the command we are about to run, which is the + * same rule that keeps docker-adopt and remote-attach injection-free. + * 2. **The candidate must still exist.** Discovery is re-run rather than cached, + * so a session that died between the listing and the click fails with a 404 + * instead of producing a wrapper attached to nothing. + * 3. **One wrapper per target.** Two wrappers on one foreign session would each + * create their own grouped view and each think they own the tab; the guard + * is here rather than in the button's in-flight lock, which only stops a + * double-click on one device. + * 4. **Admin-only under multi-user.** Discovery already is (it exposes other + * users' processes), and adopting someone's `shell` is arbitrary execution + * as the server account — which is exactly what the `can-bypass-permissions` + * grant gates elsewhere. The admin gate subsumes it, so there is deliberately + * no second grant check here. + */ + app.post('/api/sessions/adopt', async (req, reply) => { + if (isMultiUserMode() && !requireAdmin(req, reply)) return; + + const owner = ownerFor(req); + const capMsg = sessionCapacityMessage(ctx.sessions, owner); + if (capMsg) return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg); + + const body = parseBody(AdoptForeignSessionSchema, req.body, 'Invalid request body'); + + // Re-resolve rather than trust: point 1 and 2 above. + const found = await discoverForeignSessions({ + local: true, + force: true, + dockerCases: body.docker ? await readAllDockerCases() : undefined, + remoteHosts: body.remote ? await readAllRemoteHosts() : undefined, + }); + const target = found.sessions.find((f) => f.id === body.id); + if (!target) { + // ⚠️ "Not in the re-resolve" has two very different causes and they must not + // be reported as one. The session really being gone is the ordinary case; + // the OTHER case is a location we could not reach this time, which on a + // flaky link makes a perfectly live remote session read as deleted. Measured + // against a real VM whose ssh path dropped ~10% of connections: clicking + // Open failed with "no longer there" while the session was sitting right + // there. Discovery already knows which it was — it wrote a note — so say so. + const reach = found.notes.filter((n) => !/skipped/.test(n)); + return createErrorResponse( + ApiErrorCode.NOT_FOUND, + reach.length + ? `Could not reach it just now (${reach.join('; ')}). It may still be running — try again.` + : 'That tmux session is no longer there. Refresh the list and try again.' + ); + } + + // Point 3 — one wrapper per (socket, session). + const existing = ctx.mux + .getSessions() + .find((m) => m.adopt?.socketPath === target.socketPath && m.adopt?.targetSession === target.sessionName); + if (existing) { + const live = ctx.sessions.get(existing.sessionId); + if (live) return { session: ctx.getSessionStateWithRespawn(live), alreadyAdopted: true }; + } + + // Connection facts are copied onto the session rather than referenced by id: + // a wrapper restored after a server restart must be able to rebuild its + // command even if the host registry was edited in the meantime. + const adopt: SessionAdopt = { + location: target.location, + socketPath: target.socketPath, + targetSession: target.sessionName, + viewSession: '', + paneCurrentPath: target.workingDir, + }; + + if (target.location === 'docker') { + const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR); + const host = hosts.find((h) => h.id === target.hostId); + if (!target.containerName) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Container name missing for a docker candidate'); + } + adopt.docker = { + hostId: target.hostId ?? '', + label: target.hostLabel ?? target.containerName, + engine: host?.engine ?? 'docker', + containerName: target.containerName, + daemonHost: host?.daemonHost, + context: host?.context, + }; + } else if (target.location === 'remote') { + const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((h) => h.id === target.hostId); + if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found'); + adopt.remote = { + hostId: host.id, + label: host.label, + host: host.host, + username: host.username, + port: host.port, + identityFile: host.identityFile, + socksProxy: host.socksProxy, + jumpHost: host.jumpHost, + extraSshOptions: host.extraSshOptions, + }; + } + + const adoptHistoryConfig = await ctx.getTerminalHistoryConfig(); + + // ⚠️ `workingDir` for an adopted session is the FOREIGN pane's cwd, which may + // not exist on this host (a container path, a remote path). It is recorded as + // an observation for display; the wrapper pane is never `cd`'d into it, and + // the case-space confinement that guards a real workingDir does not apply + // because nothing is created there. + const session = new Session({ + workingDir: target.workingDir || process.cwd(), + mode: target.mode, + name: body.name || target.sessionName, + mux: ctx.mux, + useMux: true, + tmuxHistoryLimit: adoptHistoryConfig.tmuxHistoryLimit, + adopt, + owner, + parentSessionId: resolveParentSessionId(ctx, req, body.parentSessionId, owner), + }); + // The view session name is derived from the Codeman session id, so it can only + // be filled once the Session exists. + adopt.viewSession = foreignViewSessionName(session.id); + + await ctx.addSession(session); + ctx.store.incrementSessionsCreated(); + ctx.persistSessionState(session); + await ctx.setupSessionListeners(session); + getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name }); + invalidateForeignCache(); + + const lightState = ctx.getSessionStateWithRespawn(session); + ctx.broadcast(SseEvent.SessionCreated, lightState); + return { session: lightState, adopted: true }; + }); + // ========== Rename Session ========== app.put('/api/sessions/:id/name', async (req) => { diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 4cefad50..34fa6349 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1810,3 +1810,25 @@ export const WebviewUpdateSchema = WebviewBaseSchema.partial(); /** POST /api/webviews/probe: reachability + framing check for the editor's Test button. */ export const WebviewProbeSchema = z.object({ url: webviewUrlSchema }); + +/** + * Adopt a FOREIGN tmux session (one a human started outside Codeman). + * + * ⚠️ The body carries ONLY the opaque candidate id from `GET /api/mux/foreign`. + * The socket path, session name and host are re-resolved server-side by re-running + * discovery, so a browser can never hand the launch chain a path or a session name + * to interpolate. That is the same discipline that keeps the docker-adopt and + * remote-attach paths free of caller-supplied command fragments. + */ +export const AdoptForeignSessionSchema = z + .object({ + id: z.string().min(1).max(64), + /** Optional tab name; defaults to the foreign session's own name. */ + name: z.string().max(128).optional(), + /** Include docker locations in the re-resolve (must match the listing call). */ + docker: z.boolean().optional(), + /** Include remote locations in the re-resolve. */ + remote: z.boolean().optional(), + parentSessionId: z.string().max(64).optional(), + }) + .strict(); diff --git a/src/web/server.ts b/src/web/server.ts index 0cf318d6..88a27fe4 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1558,13 +1558,20 @@ export class WebServer extends EventEmitter { this.runSummaryTrackers.set(session.id, summaryTracker); summaryTracker.recordSessionStarted(session.mode, session.workingDir); - // Set working directory for Ralph tracker to auto-load @fix_plan.md (not supported for external CLIs) - if (!isExternalCliMode(session.mode)) { + // Set working directory for Ralph tracker to auto-load @fix_plan.md (not supported for external CLIs). + // ⚠️ Also skipped for an ADOPTED session, and for two reasons: Ralph is refused + // for one anyway, and its `workingDir` is the FOREIGN pane's cwd — a path that + // need not exist on this host at all. Watching it logged a caught ENOENT on + // every in-container adoption (`watch '/workspace/pythonserver'`), which is + // noise pointing at a real category error rather than a real failure. + if (!isExternalCliMode(session.mode) && !session.isAdopted) { session.ralphTracker.setWorkingDir(session.workingDir); } // Start watching for new images in this session's working directory (if enabled globally and per-session) - if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled) { + if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled && !session.isAdopted) { + // Same reason as the Ralph watcher above: an adopted session's workingDir is + // an observation about ANOTHER host's (or container's) filesystem. imageWatcher.watchSession(session.id, session.workingDir); } @@ -2809,6 +2816,14 @@ export class WebServer extends EventEmitter { // MuxSession.docker; state.json carries SessionState.docker), so recovery // rebuilds the `docker exec` launch instead of a broken local command. docker: muxSession.docker ?? savedState?.docker, + // Adoption metadata round-trips for the same reason remote/docker do, + // and one more: it is the ONLY thing that marks this session as + // wrapping a process Codeman never launched. Dropping it on recovery + // silently re-enabled respawn, Ralph and hook-backed waits against + // someone else's live tmux session after every server restart — + // measured, not hypothetical. The mux record is preferred because it + // is what `killSession`'s detach-not-kill guard already reads. + adopt: muxSession.adopt ?? savedState?.adopt, owner: recoveredOwner, // Tab lineage survives a restart. It is only decoration, so a parent // that did NOT come back is harmless: the frontend draws an edge only diff --git a/src/web/session-wait-registry.ts b/src/web/session-wait-registry.ts index 0592d952..c5f97b5c 100644 --- a/src/web/session-wait-registry.ts +++ b/src/web/session-wait-registry.ts @@ -189,6 +189,18 @@ export interface HookCapabilityOptions { * timeout on every turn. */ deepSeekBridgeUnreachable?: boolean; + /** + * True when the session is a WRAPPER around a tmux session a human started + * outside Codeman. + * + * This one overrides the mode entirely, and it has to: an adopted session can + * be `mode: 'claude'` and still have no hooks, because hooks are installed into + * a WORKSPACE at session-create time (`applyWorkspaceHooks`) and we never + * created this one. Answering from the mode there would promise `stop` and + * `blocked` for a process that can never post either — the exact + * infinite-wait-dressed-as-a-timeout this predicate exists to prevent. + */ + adopted?: boolean; } /** @@ -223,6 +235,9 @@ export interface HookCapabilityOptions { * function only about hook SIGNALS. */ export function hooksAvailableForMode(mode: SessionMode, options: HookCapabilityOptions = {}): boolean { + // Checked BEFORE the mode: adoption is about who launched the process, and no + // mode can vouch for a workspace Codeman never touched. See `adopted` above. + if (options.adopted) return false; if (mode === 'claude') return true; // `deepseek` earns this the same way `claude` does — by emitting DEFINITIVE // signals rather than having them inferred. The DeepSeek Harness terminal @@ -250,10 +265,12 @@ export function sessionHookOptions(session: { deepSeekStatusReporting?: boolean; docker?: unknown; remote?: unknown; + adopt?: unknown; }): HookCapabilityOptions { return { deepSeekStatusReporting: session.deepSeekStatusReporting, deepSeekBridgeUnreachable: Boolean(session.docker || session.remote), + adopted: Boolean(session.adopt), }; } diff --git a/test/docker-adopted-container.test.ts b/test/docker-adopted-container.test.ts index 62d26cfd..a522532a 100644 --- a/test/docker-adopted-container.test.ts +++ b/test/docker-adopted-container.test.ts @@ -212,8 +212,15 @@ describe('adopted container: the host is not required to have the CLI', () => { expect(unguarded).toHaveLength(0); }); - it('derives the flag from the docker metadata the session already carries', () => { - expect(src).toContain('const cliRunsInContainer = !!docker;'); + it('derives the flag from the location metadata the session already carries', () => { + // Adoption joined the condition for the same reason docker is in it: an + // adopted session's CLI was started by a human in a process Codeman never + // spawned, so the host binary is irrelevant there too — and demanding it + // would reject adopting a claude that lives in a container, on an ssh host, + // or simply outside the server process's PATH (the systemd/launchd case). + // What the assertion still pins is that the flag comes from the session's + // OWN metadata rather than from anything ambient. + expect(src).toContain('const cliRunsInContainer = !!docker || !!adopt;'); }); }); diff --git a/test/foreign-tmux.test.ts b/test/foreign-tmux.test.ts new file mode 100644 index 00000000..b11216f8 --- /dev/null +++ b/test/foreign-tmux.test.ts @@ -0,0 +1,314 @@ +/** + * Foreign tmux adoption — the pure core. + * + * These pin the properties that were established by MEASUREMENT against a real + * tmux (3.3a) while the feature was built, and that a plausible-looking refactor + * would quietly undo. Each one has a comment naming what actually went wrong. + */ + +import { describe, it, expect } from 'vitest'; +import { + buildForeignProbeScript, + parseForeignProbeOutput, + classifyForeignPaneMode, + isCodemanOwnedPane, + foreignSessionId, + foreignViewSessionName, + isAdoptableSessionName, + isAdoptableSocketPath, + buildForeignAttachCommand, + buildForeignDockerAttachCommand, + buildForeignRemoteAttachCommand, + buildForeignTmuxInvocation, +} from '../src/foreign-tmux.js'; + +// A probe transcript in exactly the shape a real run produces. The pane rows use +// the LITERAL backslash-t that tmux's `-F` emits (verified on next-3.7 and 3.3a), +// while the socket line is space-separated because `sh`'s builtin `echo` expands +// a backslash-t to a real TAB — two different meanings for one escape, two lines +// apart, which is why the socket marker carries no separator at all. +const PROBE = [ + 'CMFS /tmp/tmux-0/default', + 'CMFP\\t/tmp/tmux-0/default\\t631\\t0\\t1\\t1788092494\\t1\\t%0\\tclaude\\twork\\t/srv/app', + 'CMFP\\t/tmp/tmux-0/default\\t900\\t0\\t1\\t1788092500\\t0\\t%1\\tbash\\tscratch\\t/home/me', + 'CMFP\\t/tmp/tmux-0/default\\t950\\t0\\t2\\t1788092600\\t0\\t%2\\tnode\\tcodex-work\\t/srv/app', + 'CMFQ', + ' 631 630 -bash', + ' 4056 631 claude --dangerously-skip-permissions', + ' 4104 4056 /usr/local/bin/ortg --repo /ortg mcp', + ' 900 630 -bash', + ' 950 630 node /opt/homebrew/bin/codex', +].join('\n'); + +describe('buildForeignProbeScript', () => { + it('contains no single quote — it is wrapped in one to cross ssh and docker exec', () => { + // The script is embedded as `ssh host '