Commit Graph
9 Commits
Author SHA1 Message Date
Codeman maintainer 9442ea8cf4 fix: remove the em-dashes this release added (release review)
105 new em-dashes sat in comments, test names and one test fixture from the
foreign tmux adoption work. Replaced by hand with commas, colons or
parentheses, one reading at a time. The fixture now quotes the real refusal
text, which says "It may still be running, try again."

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:20:39 +01:00
Codeman maintainer 800b3f4658 fix: keep foreign probe parsing linear in what it reads (release review)
The parser and classifier run synchronously on the event loop over text a
pane, a container or an ssh host wrote, and one process could stall the
server for every session: 4 s for a 128 KiB argv of `/pi/` tokens, about a
minute for 2 MiB of socket rows.

The `cli.js` launcher rule leaves the signature regex for a per-word scan,
socket rows dedupe through a Set, the process row regex takes the `s` flag
(a bare CR made `$` fail and `\s+` give its spaces back, quadratic in a row),
argv and pane command are cut at FOREIGN_MAX_ARGV_CHARS, and one matcher per
scan answers each distinct argv once, since every pane of a hostile host can
claim the same process tree.

Measured on a private tmux server with the same 131 KiB argv: the largest
event-loop gap went from 3991 ms to 45 ms. The worst scan the caps allow
takes about 80 ms. Socket rows stay uncapped on purpose (see the docs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 07:10:49 +01:00
Codeman maintainer c65706a3e4 Merge #510 feat(adopt): adopt tmux sessions a human started, in all three locations
The attach line now takes the rule #770 gave remote and docker launches: bashCArg() single-quotes it at both respawn-pane sites, so it reaches bash verbatim. quoteAdoptLaunch is retired, since its double quotes kept the same $ and backtick safety but lost a real newline. #510's no-cd condition stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 05:36:20 +01:00
Codeman maintainer aa40cca343 fix: adopted tmux targets, hung servers and the attach chain (release review)
Names tmux reads as target syntax (`@1`, `%1`, `=eq`, a bare `~`) passed the
has-session guard by name and then joined something else in new-session -t:
another session, or a fresh shell on the foreign server. They are refused at
discovery now, and take the existing skipped note.

One stopped tmux server emptied the whole local list and left a stuck client
per scan. Each server now gets a bounded ping with stdout at /dev/null first,
because a tmux client hands dups of its stdout to the server: a stopped server
holds our pipe open after the client is dead, so a watchdog around list-panes
cannot work. The probe runs in its own process group, all three transports
keep the rows read before a failure, and a missed ping becomes a note.

The attach and the probe pass -u, so a POSIX-locale container no longer garbles
the tab or lists a non-ASCII name as underscores. A docker attach gets the same
TERM and COLORTERM an owned container's exec gets.

A missing tmux no longer reads as "session is gone", and the launch line's `$`
now reaches bash unexpanded, so the ssh attach's PATH line is expanded at the
far end, as the probe's already was.

fix-plan/write, fix-plan/read and ralph-prompt/write refuse an adopted session:
its workingDir is the neutral /tmp, so they read and wrote a predictable name
in a folder anyone can write to.

FOREIGN_MAX_SOCKETS is gone: it capped nothing, and a real cap in glob order
would never reach `default` on a box with many `codeman-*` sockets (this one
has 212). Closing an adopted tab re-reads the outside-sessions list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 04:33:42 +01:00
Codeman maintainer 1f6f21fd24 docs(adopt): document the routes and the overlay, correct stale comments
The two routes are public under /api/v1, so they get an API reference section.
The overlay gets an architecture note and a CLAUDE.md rule: an adopted session
has session.remote and session.docker undefined and a neutral workingDir, so any
new code that reads workingDir as a workspace must also test isAdopted. That is
the class of bug this round fixed a dozen times.

Records a measured limit: while the view exists an owner's kill-session leaves
the window alive under it until the tab closes.

Comments that still called workingDir the foreign pane's cwd now say what is
true since the wrapper got its neutral folder. Also drops a zh-CN key that
duplicated an existing one, and updates the two structure tests pinned on the
lines this round changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 03:29:35 +01:00
Codeman maintainer f464a22bc3 fix(adopt): bring the docker and ssh transports up to master
Docker discovery now spawns dockerEngineArgv(), not the shell-quoted
buildDockerBaseArgs(): execFile passed those quotes through, so docker
--context "'default'" failed and every container behind a named context or
remote daemon read as unreachable.

The ssh probe and attach gain withRemoteTmuxPath() like every other remote tmux
call, run in an explicit sh because the guard chain is POSIX and the remote
login shell may be fish, and the probe holds a slot of the remote-ssh limiter
and execs ssh so a timeout reaches it. Notes from a remote scan are no longer
dropped, and every note now opens with its location.

The adopted branch of killSession no longer blocks the event loop on docker or
tmux, kills the in-container view by exact name, and clears the pane-exit and
reconnect state the non-owned remote branch does. An adopted wrapper reports
no injected statusline, since nothing could carry it. Key tables on the foreign
server are left alone on purpose, and the invocation now says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 03:06:47 +01:00
d fei 09ccffd32e fix(adopt): never drive, write to, or answer for a session we did not start
Review items 3, 4 and the smaller ones on #510. An adopted session wraps a
process someone else launched, and its mode is whatever the probe saw — very
often `claude`. Every guard below is therefore its own `isAdopted` check rather
than a side effect of the external-CLI rule: the two ask different questions,
and merging them means the day the first loosens, Codeman starts acting on a
live conversation that is not ours.

- The boot workspace-hook sweep skipped only non-claude and `session.remote`.
  An adopted session keeps its connection on `adopt.remote` / `adopt.docker`,
  so a tab wrapping someone's in-container or over-ssh claude looked local and
  Codeman wrote `.claude/settings.local.json` into their repo on every restart.
- The workspace-trust auto-accept read the pane and pressed keys for 90s after
  `startInteractive()`. In an adopted pane that is someone else's dialog, and
  boot recovery re-opens the window on every restart.
- Auto-clear, auto-compact, auto-resume and the Ralph auto-enable in
  `POST /interactive` all drove the pane. Each now refuses with its own gate.
- The wrapper's `workingDir` was the foreign pane's cwd. That cwd can carry
  characters outside SAFE_PATH_PATTERN (`~/c++`, `Program Files (x86)`), which
  makes `createSession` throw so the tab never starts, and for a container or
  ssh candidate it is not a path on this host at all. It is now a neutral local
  `/tmp`, the same cwd `resolveMuxAttachCwd` already gives the pane; the foreign
  cwd stays on `adopt.paneCurrentPath` for display.
- The CLI signature table hardcoded ids, which CLAUDE.md forbids outside
  `stock.ts`, and had already gone stale: `omp` was missing, so a hand-started
  omp pane read as `shell`. It is now derived from each entry's
  `discovery.binaries` at call time, regex-escaped, longest name first. Stock
  entries only, since `SessionMode` is the closed union of stock ids.
- The descendant walk now passes `onTruncated`. A capped walk returns "nothing
  matched", which is byte-identical to the answer for a real shell.
- The home screen's wide scan is one-shot. Riding `docker exec` and a full ssh
  handshake per target, each with a ~12s timeout, on the 8s poll stacked
  connections on a slow host. Wide rows are kept apart and merged, because a
  local-only payload cannot contain them.
- Adoption failures report the server's own message. `_apiJson` folds a non-2xx
  to null, which collapsed "could not reach it just now" into "that session is
  gone" and reported a live remote session as deleted on every link flicker.

Tests: route tests for the refusals, the admin gate, the 404 re-resolve, the
one-wrapper-per-target reuse and the adopt round-trip; unit tests for the two
non-HTTP guards, the registry-derived signatures, truncation reporting and the
frontend's scan and error paths. Each guard was removed in turn to confirm its
own tests go red.
2026-09-30 23:29:54 -07:00
d fei aec4867821 fix(adopt): never create a session on someone else's tmux server
Review items 1 and 2 on #510.

`tmux new-session -t <name>` does not fail on a missing target. Measured on
3.3a: with only `work-prod` present it joined that session's group, with
nothing matching it opened a fresh shell, and on an empty socket it started a
tmux server that had none. The command re-runs whenever the wrapper pane is
respawned, and boot recovery does that for every dead pane, so after the owner
exits their session the next Codeman restart would put a new shell on their
default socket, in their container, or on their ssh host.

The invocation now begins with an exact-match `has-session -t '=<target>'` and
exits with a message when it misses. Measured: `has-session` does not start a
server on an empty socket, and the `=` prefix is what stops it matching
`work-prod` by prefix. `=` cannot go on `new-session -t` itself — tmux reads it
as part of the name and creates a group literally called `=work` — so it has to
be its own command.

The read-only `attach-session -r` fallback is gone rather than fixed. It hung
off `||`, so it fired on ANY `new-session` failure and not just an old tmux: a
leftover view from a previous adoption of the same Codeman session makes
`new-session` report `duplicate session` (measured), and the user then got a
read-only client showing the owner's status bar, with typed input dropped,
while the API reported success. Our own stale view is now removed first with a
`kill-session -t '=<our view>'` — the view name is derived from the Codeman
session id so it is provably ours, and measured, killing by name touches only
that session and leaves the owner's alone. Nothing to remove is the normal
case, so that step must not abort the chain.

`SessionAdopt.readOnly` goes with it. It was never set and never read, while
its comment claimed the UI surfaced it. Adoption now either attaches a writable
view or fails with a message; there is no third state.
2026-09-30 23:29:39 -07:00
d fei 245ee68c7c feat(adopt): adopt tmux sessions a human started, in all three locations
A tmux session someone opened by hand (`tmux new -s work`, then `claude`, or
just a shell) is invisible to Codeman: it lives on the default socket, not the
instance-scoped one Codeman owns. The home screen now lists those, and one
click wraps one in a Codeman tab. Local, in-container and over-ssh all work.

The shape is a WRAPPER session, not a direct attach: adoption creates an
ordinary `codeman-<8hex>` session on our own socket whose pane runs the
attach. That makes "detach, never kill the foreign session" structural rather
than a matter of discipline — `killSession` can only ever reach our own
wrapper, so no shape of caller bug reaches the foreign server. View reclamation
differs by location: local relies on client death and ssh on SIGHUP, but a
`docker exec` does NOT die with its client, so the in-container view has to be
reclaimed explicitly on kill or every adoption leaks one.

Measured facts, recorded in the code comments:

- A grouped session (`new-session -t`) buys `status off` and an independent
  current window. It does NOT buy an independent size. A window is one object
  with one size and a group shares it: on tmux 3.3a a bare attach and a grouped
  attach both shrink a 200x49 target to 80x23. Only `window-size largest` holds
  it, and that is a shared window option which survives our detach, so setting
  it would permanently rewrite the owner's configuration. Left unset.
- Session names come from other people, while the local launch chain ends in
  `bash -c` + `JSON.stringify`, which escapes neither `$` nor backticks — the
  outer shell performs command substitution before the inner single quotes are
  considered (verified: both substitutions inside the inner quotes ran). So
  session names and socket paths go through a character allowlist and are
  dropped at DISCOVERY, which means a non-conforming candidate never gets an
  id and the adopt endpoint fails closed when it re-resolves.
- `pane_current_command` is `node` for both claude and codex, so the mode can
  only come from a bounded process-tree walk of `pane_pid`'s argv; anything
  unrecognized is treated as a shell.

An adopted session has to say no to every assumption that Codeman owns the
pane: respawn, Ralph, the orchestrator and hook waits all refuse. The hook
check runs BEFORE the mode check, because adoption asks who launched the
process and no mode can vouch for a workspace Codeman never touched. `paneExit`
is forced to unknown, since the pane runs `tmux attach` rather than the agent
and its exiting with 0 only means the connection ended. Reboot restore refuses
too: all Codeman ever had was an attach command, and replaying it would build a
connection to a foreign session a reboot has almost certainly taken with it,
while presenting whatever it lands on as a session we restored.

Adoption is admin-only under multi-user mode: adopting someone's shell is
equivalent to arbitrary host execution. Discovery is read-only, never creates a
session, and degrades to an empty list whenever a probe cannot reach its target.
2026-09-29 18:01:47 -07:00