Compare commits

..
Author SHA1 Message Date
Codeman maintainer d6226385c0 fix(cli): a running server notices a CLI the installer just added
A running Codeman remembers a missing CLI for up to 5 minutes, so a status
poll never spawns a login shell, and its Run button reads that same memory.
So "Run fails, add the agent, Run again" kept saying the CLI was not
installed, right after the app had said it was ready.

install.sh now touches `cli-installed` in the data dir once a CLI has landed,
and resolve() leaves the backoff when that file's mtime changes. It is one
stat, inside the backoff branch, and buys one run of the chain, so a resolved
CLI never pays for it and a CLI that is still missing goes back to waiting.
Re-walking PATH during the backoff would have found hand installs too, but it
would put a synchronous stat of every PATH entry on the request path, where
one dead network mount freezes the server (#516), and execute candidates that
were already refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:25:34 +01:00
Codeman maintainer 1157aa9ecd fix(install): say why an added agent failed on a Mac with no other agent
After "Skip for now" there is no other agent, so a failed add hit
install_offered_cli's gate first, and the app showed its first-install text
("re-run the installer") instead of naming the agent and its command. That
is the main path of adding an agent.

run_offered_cli now runs a trusted command and looks again; the gate stays in
install_offered_cli for the install, and `agent` judges its own run. The one
line that executes a trusted command is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:46:31 +01:00
Codeman maintainer dd07126dd8 feat(desktop): add a coding agent once Codeman is installed
The agent chooser only appeared during the install, and only on a Mac with
no agent. After "Skip for now", or with Codeman already installed, there was
no screen for it.

The same chooser now works after the install: "Add a coding agent" on the
This Mac tab, "Add a Coding Agent to This Mac..." in the app menu (it also
works from a Codeman page), and after a Skip the app says Codeman has no
agent yet before it opens Codeman.

`install.sh agent <id>` installs one agent and nothing else. It checks that
THAT agent landed: install_offered_cli is fatal only when no CLI is left, so
with another one present a failed install would have reported success.

The app runs it as a run of its own (`gui_install::Run::Agent`), in its own
temporary folder, so `ps` never reads it as "Codeman is being installed". It
may run next to a live server, which an install must refuse, and stops only
for another run. `--no-admin` goes to both `preflight` and `agent`: it decides
what counts as installable, so the two must agree.

CI: the bash 3.2 preflight check never tested anything. Its child run
inherited CODEMAN_INSTALL_SH_LIB and stopped at the sourcing guard, and a
false [[ ]] does not trip set -e on bash 3.2. Both are fixed for that check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:23:56 +01:00
Codeman maintainer b51632e54b feat(desktop): install Codeman from the app, with no Terminal and no password
The This Mac tab runs install.sh --no-admin itself. The two questions only a
person can answer move into the app: which coding agent to install, shown only
when this Mac has none, and who may reach Codeman. The installer's output goes
to a file, so a running install survives the app quitting and is picked up
again when it reopens.

install.sh gains --ai-cli, --progress and a preflight subcommand, which is what
the app reads its choices from, so it keeps no copy of the CLI catalogue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:51:00 +01:00
Codeman maintainer ed74ae41bc merge feat/no-admin-install into the graphical installer branch
The graphical install runs install.sh --no-admin, so this branch needs both
PR #763 (the app) and PR #768 (the route that needs no password).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:01:55 +01:00
Codeman maintainer ae28534406 feat(install): install without administrator rights (--no-admin)
Codeman itself always lived under $HOME; administrator rights were only
spent on what it needs first (system packages for Node.js, tmux and git,
Homebrew on a Mac). An account without those rights could not install it:
the installer died in ensure_sudo, or in Homebrew's own installer.

install.sh now has a no-admin route, taken by itself when the account has
no administrator rights (no sudo binary; outside sudo/wheel/admin and
`sudo -n true` fails; a refused sudo password; a Mac account outside the
admin group with no writable Homebrew) and forced with --no-admin or
CODEMAN_NO_ADMIN=1:

- Node.js: the official tarball from nodejs.org, checked against the
  SHASUMS256.txt published beside it, into ~/.codeman/runtime/node.
- tmux: the tmux project's own build (tmux/tmux-builds 3.7c), checked
  against a SHA-256 pinned in the installer, into ~/.codeman/runtime/bin.
- Both are linked from ~/.local/bin (never over a file that is not ours),
  and the runtime is on PATH and on the LaunchAgent's PATH.
- git is never installed. Without a working git the source comes as an
  archive, the install is marked with .install-source, and it updates by
  re-running the installer: the new tree is built beside the old one and
  swapped in only after the build succeeded. A later re-run on a machine
  that has gained git turns it back into a clone.
- Nothing on this route runs sudo, Homebrew or `xcode-select --install`,
  and the route is never decided by `sudo -v`.

What it cannot supply is checked before the first download: a C/C++
toolchain on Linux (node-pty 1.1.0 compiles there), musl, and macOS below
15 (the tmux build's deployment target).

Along the way:

- check_git no longer trusts `command -v git`. On a Mac without the Xcode
  Command Line Tools /usr/bin/git is a stub that opens Apple's install
  dialog; it is judged by `xcode-select -p` and never executed. Any other
  git is asked for its version.
- scripts/git-hooks.mjs answers "not a checkout" without running git, for
  the same reason: postinstall calls it on every npm install.
- The in-app updater knows the install kind `archive` and points such an
  install at the installer instead of `npm i -g aicodeman@latest`.
- download() and download_to_stdout() resolve DOWNLOADER themselves. Only
  main() used to set it, so `install.sh cloudflared` died on `set -u` with
  the message swallowed by the step log.
- The AI CLI menu leaves out a Homebrew-only install command where there
  is no Homebrew.

Verified with the real installer: a bare Ubuntu 24.04 container as an
unprivileged user (install, a PTY round trip through the API, update by
re-run, cloudflared, uninstall), the same without a compiler (stops before
any download), with a broken then working git, with sudo present but not
usable, and on macOS 26 (arm64) in an isolated HOME with sudo, brew, git,
the compilers, launchctl and tailscale replaced by tripwires. Not verified
on a real standard macOS account: that account was an administrator.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-05 18:50:58 +01:00
Codeman maintainer 46b85fa6d0 chore: bump Playwright to 1.64.1 and unpin the browser suite
Playwright supports Ubuntu 26.04 from 1.61.0, so the browser suite can go
back to ubuntu-latest. Raises the floor in package.json as well, because
the old ^1.58.0 still admitted versions that refuse to install on the
image every workflow now runs on. Bundled Chromium moves from 148 to 156.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:22:41 +01:00
Codeman maintainer aa463d9d28 ci: pin the browser suite to ubuntu-24.04
ubuntu-latest moved to 26.04 and the locked Playwright 1.60.0 refuses to
install chromium there (support starts at 1.61.0), so the nightly stopped
before running a test. Pin to the image of the last green run, so a failure
now points at our code rather than at a new OS and Chromium. Unpin when
Playwright is bumped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:15:43 +01:00
Codeman maintainer 9eac5ecf01 test: wait for the first read before starting the other edit history sessions
The gate belonged to whichever read arrived first, and sessions load their stores
independently, so under CI timing another session took it, the loop never reached
release() and the test timed out (master, CI run 41930703100). The code cannot block
one session on another: sync() only waits on its own slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 00:21:12 +01:00
37 changed files with 5448 additions and 5106 deletions

No files matched your search

+84
View File
@@ -80,6 +80,35 @@ jobs:
CLI_DETECT_DONE=""; detect_all_clis
offer_ai_cli_install >/dev/null 2>&1
echo "bash $BASH_VERSION: skipping the AI CLI install menu continues"
# --ai-cli answers the same menu with no terminal: `none` carries on, and a
# name runs that one entry (bash 3.2 cannot expand an empty offer list under
# set -u, which is the guard the loop over it has to get right).
has_tty() { return 1; }
AI_CLI_CHOICE=none
offer_ai_cli_install >/dev/null 2>&1
AI_CLI_CHOICE=codex
k=0; while [[ $k -lt ${#CLI_INSTALL_CMD_TRUSTED[@]} ]]; do
CLI_INSTALL_CMD_TRUSTED[$k]=""
k=$((k + 1))
done
offer_ai_cli_install >/dev/null 2>&1
echo "bash $BASH_VERSION: --ai-cli answers the menu, also with nothing to offer"
# What the desktop app runs: preflight on stdout, and only JSON there. These
# child runs must not inherit CODEMAN_INSTALL_SH_LIB, or they stop at the
# sourcing guard and print nothing. Each check ends in `|| exit 1`, because
# bash 3.2 does not let a false [[ ]] trip set -e.
out=$(env -u CODEMAN_INSTALL_SH_LIB bash /w/install.sh preflight --no-admin)
[[ "$out" == "{\"schema\":1,"* && "$out" == *"]}" ]] || { echo "preflight printed: $out"; exit 1; }
json_string "a\"b\\c" >/tmp/json-out
[[ "$(cat /tmp/json-out)" == "\"a\\\"b\\\\c\"" ]] || { echo "json_string printed: $(cat /tmp/json-out)"; exit 1; }
echo "bash $BASH_VERSION: preflight prints JSON"
# How the desktop app adds an agent: a real run of the subcommand, which stops
# on the missing npm before it could install anything.
if out=$(env -u CODEMAN_INSTALL_SH_LIB bash /w/install.sh agent codex --no-admin --progress 2>&1); then
echo "agent ran with no npm to run"; exit 1
fi
[[ "$out" == *"@@codeman phase agent"* && "$out" != *"@@codeman phase done"* ]] || { echo "agent printed: $out"; exit 1; }
echo "bash $BASH_VERSION: agent stops without saying done"
'
# Issue #382: the dsh identity probe builds an OPTIONAL `timeout` prefix as an
# array, and on stock macOS there is no `timeout`, so the array is empty and the
@@ -151,6 +180,61 @@ jobs:
RECONFIGURE=0; parse_flags --port 4001; [[ "$RECONFIGURE" == "1" ]]
echo "bash $BASH_VERSION: question phase (flags, launch default, occupied :443, rename opt-in, kept password, start line) ok"
'
# No-admin route: the helpers that decide what gets downloaded and whether it is
# trusted (the Node.js checksum pick, the tmux digest table, the verify step), the
# stop-before-download check, and an archive install plus its replacement, all
# in bash 3.2. This image is Alpine, so it is also the one place the musl branch
# runs for real: a machine the route cannot serve must stop BEFORE a download.
docker run --rm -v "$PWD":/w -w /w -e CODEMAN_INSTALL_SH_LIB=1 -e HOME=/tmp/h bash:3.2 bash -c '
set -euo pipefail
mkdir -p /tmp/h /tmp/src/Codeman-1/src
. /w/install.sh
sha=$(printf "%064d" 7)
sums=$(printf "%s node-v22.1.0-linux-x64.tar.gz\nnothex node-v22.1.0-linux-arm64.tar.gz\n" "$sha")
[[ "$(printf "%s\n" "$sums" | pick_node_tarball linux-x64)" == "$sha node-v22.1.0-linux-x64.tar.gz" ]]
if printf "%s\n" "$sums" | pick_node_tarball linux-arm64 >/dev/null; then
echo "accepted a checksum that is not 64 hex digits" >&2; exit 1
fi
for p in linux-x86_64 linux-arm64 macos-arm64 macos-x86_64; do
d=$(tmux_build_sha256 "$p"); [[ ${#d} -eq 64 ]]
done
printf codeman > /tmp/f; good=$(sha256_file /tmp/f); [[ ${#good} -eq 64 ]]
verify_sha256 /tmp/f "$good"
if verify_sha256 /tmp/f "${good%?}x" || verify_sha256 /tmp/f ""; then
echo "accepted a wrong or empty checksum" >&2; exit 1
fi
is_musl_libc
NO_ADMIN=1; NONINTERACTIVE=1
download() { echo DOWNLOADED; }; download_to_stdout() { echo DOWNLOADED; }
check_node() { return 1; }; check_tmux() { return 1; }; check_git() { return 1; }
check_build_tools() { return 0; }
if out=$( (ask_dependencies_no_admin linux) 2>&1 ); then
echo "did not stop on a musl system" >&2; exit 1
fi
case "$out" in
*DOWNLOADED*) echo "downloaded before stopping: $out" >&2; exit 1 ;;
*musl*) ;;
*) echo "stopped for the wrong reason: $out" >&2; exit 1 ;;
esac
# An archive install, then its replacement beside it (busybox tar, bash 3.2).
printf "{ \"version\": \"1.0.0\" }\n" > /tmp/src/Codeman-1/package.json
tar -czf /tmp/one.tar.gz -C /tmp/src Codeman-1
printf "{ \"version\": \"2.0.0\" }\n" > /tmp/src/Codeman-1/package.json
tar -czf /tmp/two.tar.gz -C /tmp/src Codeman-1
INSTALL_DIR=/tmp/h/app; LOG_FILE=/dev/null
download() { cp "${1#file://}" "$2"; }; run_step() { shift; "$@"; }
CODEMAN_TARBALL_URL=file:///tmp/one.tar.gz; install_or_update_repo >/dev/null 2>&1; finish_source_swap
[[ -f "$INSTALL_DIR/$SOURCE_MARKER" && -z "$SOURCE_STAGING" ]]
grep -q 1.0.0 "$INSTALL_DIR/package.json"
touch "$INSTALL_DIR/from-the-old-build"
CODEMAN_TARBALL_URL=file:///tmp/two.tar.gz; install_or_update_repo >/dev/null 2>&1
[[ "$SOURCE_STAGING" == "$INSTALL_DIR.new" ]] && grep -q 1.0.0 "$INSTALL_DIR/package.json"
finish_source_swap
grep -q 2.0.0 "$INSTALL_DIR/package.json"
[[ ! -e "$INSTALL_DIR/from-the-old-build" && ! -e "$INSTALL_DIR.new" && ! -e "$INSTALL_DIR.old" ]]
NO_ADMIN=""; resolve_admin_mode; [[ "$NO_ADMIN" == "0" ]]
echo "bash $BASH_VERSION: no-admin route (checksums, stop before download, archive install and swap) ok"
'
- name: CLI catalogue artifacts are in sync with stock.ts
run: npm run generate:cli-catalog -- --check
+4 -4
View File
@@ -103,7 +103,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
- **Default bind is loopback-only; non-loopback without a password starts but warns** — the server defaults to `--host 127.0.0.1`. Binding non-loopback (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` starts anyway but prints a loud warning; `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges it. ⚠️ The production systemd unit passes no `--host`, so prod binds **localhost only**: reach it via `tailscale serve`/tunnel to `127.0.0.1`. A loopback bind is reachable through a same-host tunnel but NOT by a browser hitting the box's LAN IP. `install.sh` is separate and prompts for the binding (defaulting to LAN + a password), and preserves the existing binding on re-runs. → [architecture-invariants#default-bind-and-the-non-loopback-warning-path](docs/architecture-invariants.md#default-bind-and-the-non-loopback-warning-path), `docs/security-architecture.md`
- **Instance isolation / multi-instance attach danger** — the data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts`. ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions**, resizing and mutating them. `$HOME` isolation is NOT enough because tmux is system-global. To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes dir + socket together), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually; `scripts/run-beta.sh` does this for a beta alongside prod. **Any new `~/.codeman/...` path MUST go through `dataPath()`**, never `join(homedir(), '.codeman', …)`, and **any new `tmux -L` caller through `resolveTmuxSocketName()`** (both in `config/instance.ts`): the TUI shells out to tmux from a second process, and a hardcoded `codeman` there would point a beta instance at prod's panes. → [architecture-invariants#instance-isolation-and-the-multi-instance-attach-danger](docs/architecture-invariants.md#instance-isolation-and-the-multi-instance-attach-danger)
- **The prefix table on Codeman's socket holds only `<prefix> send-prefix`** — browser tabs are tmux attach clients, and tmux's stock bindings let Ctrl+B `)`/`s` move a tab onto another session. `createSession` resets the table, and `reconcileSessions()` at boot for sessions that survived an upgrade (`src/tmux-prefix-table.ts`); `root` and `C-<letter> detach-client` (the TUI attach's way out) are left alone, so F1 and that alias reach browser tabs for the length of an attach. Alt+1..9 are `if-shell` bindings that `switch-client` only for the TUI's own client (`client_tty` plus a one-shot pid hook), so a browser tab's Esc-then-digit does not move it. Docker and owned-remote nested servers get the same reduction (`C-q send-prefix` only) in their create chain. ⚠️ So `Ctrl+B d` does not exist there: anything attaching a user's terminal claims its own detach key (F1 in `root`, as `codeman tui` and `scripts/tmux-manager.sh` do). → [architecture-invariants#the-prefix-table-on-codemans-socket-holds-only-send-prefix](docs/architecture-invariants.md#the-prefix-table-on-codemans-socket-holds-only-send-prefix)
- **node-pty's macOS `spawn-helper` ships without `+x`** (issues #6, #204): `node-pty@1.1.0` publishes `prebuilds/darwin-<arch>/spawn-helper` as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with `Error: posix_spawnp failed.` **Linux can never reproduce it**: `spawn-helper` is an `OS=="mac"` gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ The flip side of that: since Linux has no prebuild, `npm install` **needs a C/C++ toolchain there** (`make`, `g++`, `python3`), so `install.sh` checks for and installs one alongside Node/tmux/git — a stock Ubuntu 24 server has none and died inside node-gyp with `not found: make`. Do not drop that step. ⚠️ Look in **`prebuilds/<platform>-<arch>/`**, not just `build/Release/`, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes `prebuilds/` before compiling): `npm run fix:node-pty` chmods every helper then proves it by really opening a PTY. `spawnPtyWithHelperRepair()` (`utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts` and self-heals a broken install on the first failure. → [architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable](docs/architecture-invariants.md#node-ptys-macos-spawn-helper-must-be-executable)
- **node-pty's macOS `spawn-helper` ships without `+x`** (issues #6, #204): `node-pty@1.1.0` publishes `prebuilds/darwin-<arch>/spawn-helper` as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with `Error: posix_spawnp failed.` **Linux can never reproduce it**: `spawn-helper` is an `OS=="mac"` gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ The flip side of that: since Linux has no prebuild, `npm install` **needs a C/C++ toolchain there** (`make`, `g++`, `python3`), so `install.sh` checks for and installs one alongside Node/tmux/git — a stock Ubuntu 24 server has none and died inside node-gyp with `not found: make`. Do not drop that step. (The installer's no-admin route cannot install one, so there it stops before downloading anything and names what is missing.) ⚠️ Look in **`prebuilds/<platform>-<arch>/`**, not just `build/Release/`, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes `prebuilds/` before compiling): `npm run fix:node-pty` chmods every helper then proves it by really opening a PTY. `spawnPtyWithHelperRepair()` (`utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts` and self-heals a broken install on the first failure. → [architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable](docs/architecture-invariants.md#node-ptys-macos-spawn-helper-must-be-executable)
- **User-chosen paths are probed BOUNDED, never with `existsSync`/`statSync`** (#516): a linked case or a `workingDir` can sit on a network mount that stopped answering. A synchronous check there freezes the whole server, and an unbounded async `stat`/`lstat`/`readFile` holds one of libuv's threadpool workers (4 by default, shared with every `fs`, `dns.lookup` and `crypto` call) until the mount returns. On a request or spawn path use `probePath()`/`probePathKind()` (`utils/bounded-path-probe.ts`, read its `@fileoverview`). ⚠️ `unknown` is NEVER `absent`: nothing is created, scaffolded or 404'd on it. Bulk scans keep the stall cap; a request for ONE path the user named may pass `{ pastCap: true }`, which still stops at the ceiling that keeps one worker free; and a helper that would otherwise touch the path skips whatever is still `unknown`. User-facing errors for it go through `describeUnknownPath()`, so a refused probe is not reported as a broken folder.
- **Headless screenshots: `deviceScaleFactor` MUST be 1, and write unique filenames** — under DSF=2 xterm's WebGL renderer draws glyphs at ~2× nominal size while still *reporting* nominal cell dims, so only the pixels reveal it and only the terminal font looks wrong. And overwriting a fixed output path leaves OS image viewers showing the old render, which reads as "the fix didn't work"; `scripts/capture-real-overview.mjs` mints a timestamped filename per run. Seed the per-device `localStorage` keys (`codeman:skin`, `codeman-font-size`, `codeman-app-settings`) so the capture matches a real device. → [architecture-invariants#headless-screenshot-capture](docs/architecture-invariants.md#headless-screenshot-capture)
@@ -220,7 +220,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode and copilot get the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward only with observed `cliMouseTracking`: **Claude ≥ 2.1.187, tracked Codex, or tracked Copilot**. Codex 0.163.0 fullscreen was measured with `alternate_on=1`, `mouse_any_flag=1`: one SGR report moves one transcript row; see the fresh measurement in architecture-invariants. Inline/untracked Codex retains local history or the capability-gated paging fallback. Copilot has no version gate; a stale/false tracking flag falls back to PageUp/PageDown. Touch alone has one more branch (`_shouldForwardTouchToApp`): in any mode, a drag also forwards as SGR wheel reports while xterm ITSELF tracks the mouse with SGR encoding (an app in the pane asked for it; a tracking mode other than `x10` and `coreMouseService.activeEncoding === 'SGR'`, so DEFAULT, X10 and SGR-pixel apps fail closed), because xterm encodes a wheel there but the touch listeners consume the drag. ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. ⚠️ The hollow-buffer PageUp/PageDown fallback (`_maybePageCliTranscript`) stands down when the last capture reported tmux scrollback (`paneHistoryLines` > 0: a tail replayed after a server restart carries only the attach repaint), so the gesture scrolls locally and the scroll-to-top pull loads `full=1`; 0 and unknown keep paging. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
**Detached start + service install**: `codeman web -d` relaunches the same entry script `detached:true` (setsid); `nohup` is not what makes it survive. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile + `/api/status` probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll `/api/status` until the child answers or dies. `--stop` must verify the pid still looks like Codeman (`ps -o command=`) before signalling. Unit/label names live only in `config/service-names.ts`. `service install` bakes the installing shell's PATH into the unit and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install)
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ An install with no git checkout (`install.sh` on a machine without a working git leaves the marker `.install-source`) is kind `archive`: it cannot update in place, and both the check and the UI tell it to re-run the installer, never the npm line. ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
**Reverse-proxy base path** (`--base-url` / `CODEMAN_BASE_URL`, default `/`; pure single source `src/config/base-path.ts`, normalized to `''` or `/foo`): mounts Codeman under a sub-path behind a proxy that forwards the prefix unchanged. Few choke points: `stripBasePath()` in Fastify's `rewriteUrl` (routes stay prefix-agnostic; unprefixed requests still answer), one `onSend` hook rebasing `Location`, `renderIndexHtml` rewriting `<base href>` + injecting `window.__CODEMAN_BASE__`, and `CodemanBase.url()` (constants.js) for runtime URLs. ⚠️ Keep template asset refs RELATIVE, and route every root-absolute frontend URL (EventSource/WebSocket/`window.open`/src) through `CodemanBase.url()`. ⚠️ Web-tab proxy egress goes through `proxyPrefixFor(cap, basePath)`; ingress parsers stay base-agnostic. ⚠️ `--base-url` must ride `buildWebArgs` and `resolveServicePlan`. Tests: `test/base-path.test.ts`. → [architecture-invariants#reverse-proxy-base-path](docs/architecture-invariants.md#reverse-proxy-base-path)
@@ -319,7 +319,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/`
## State Files
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `edit-history/<id>.json` (Edit history panel, one store per session, every diff an agent wrote, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `edit-history/<id>.json` (Edit history panel, one store per session, every diff an agent wrote, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back), `cli-installed` (empty, touched by `install.sh` when it installs a CLI: a running server reads only its mtime, and a change ends that CLI's negative-cache backoff) and `runtime/` (the Node.js and tmux `install.sh` downloaded on its no-admin route, linked from `~/.local/bin`; all four installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
**Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored.
@@ -384,6 +384,6 @@ Two constraints worth knowing before you touch them: the env-derived PTY buffer
## Scripts & Tunnel
**`install.sh`** (repo root) is the public `curl | bash` installer: it installs Node/tmux/git/build tools, clones to `~/.codeman/app`, builds, and offers a systemd/launchd service, asking every question BEFORE the unattended build (log in `~/.codeman/install.log`). Network access is Tailscale / LAN / local-only; re-runs preserve the existing binding AND password (`read_existing_binding()`). ⚠️ Compose the hand-start env only in `start_command_hint`/`export_bind_env`, and call `stop_background_helpers` before `exec`. ⚠️ Tailscale rename is opt-in (default NO, never under `--yes`/non-interactive); NEVER `tailscale serve reset`, touch a mapping it did not create, run `tailscale funnel` or advertise a Service (`test/install-sh-invariants.test.ts`). ⚠️ Stay **bash 3.2** clean (no `declare -A`, `mapfile`, namerefs, `${x,,}`, here-strings, empty-array expansion under `set -u`). ⚠️ Execute only commands from the generated CLI block (`CLI_INSTALL_CMD_TRUSTED`, `npm run generate:cli-catalog`). → [architecture-invariants#installsh-the-public-installer](docs/architecture-invariants.md#installsh-the-public-installer)
**`install.sh`** (repo root) is the public `curl | bash` installer: it installs Node/tmux/git/build tools, clones to `~/.codeman/app`, builds, and offers a systemd/launchd service, asking every question BEFORE the unattended build (log in `~/.codeman/install.log`). Network access is Tailscale / LAN / local-only; re-runs preserve the existing binding AND password (`read_existing_binding()`). ⚠️ Compose the hand-start env only in `start_command_hint`/`export_bind_env`, and call `stop_background_helpers` before `exec`. ⚠️ Tailscale rename is opt-in (default NO, never under `--yes`/non-interactive); NEVER `tailscale serve reset`, touch a mapping it did not create, run `tailscale funnel` or advertise a Service (`test/install-sh-invariants.test.ts`). ⚠️ **No-admin route** (`--no-admin` / `CODEMAN_NO_ADMIN`, taken by itself when the account has no administrator rights): Node.js (nodejs.org, checked against `SHASUMS256.txt`) and tmux (`tmux/tmux-builds`, digests pinned in `tmux_build_sha256`: bump `TMUX_BUILD_VERSION` and all four together) go into `~/.codeman/runtime`, and without a working git the source is an archive that updates by a re-run (built beside the old tree, swapped in after the build). Nothing in that section may run `sudo`, Homebrew or `xcode-select --install`, a download is verified before it is unpacked, the route is never decided by `sudo -v`, `/usr/bin/git` on a Mac is never executed before `xcode-select -p` proves the developer tools, and `no_admin_blocker` stops BEFORE the first download for what it cannot supply (a Linux toolchain, macOS below 15). ⚠️ Stay **bash 3.2** clean (no `declare -A`, `mapfile`, namerefs, `${x,,}`, here-strings, empty-array expansion under `set -u`). ⚠️ Execute only commands from the generated CLI block (`CLI_INSTALL_CMD_TRUSTED`, `npm run generate:cli-catalog`). ⚠️ Codeman Desktop drives it with no terminal (`--no-admin --progress --ai-cli <id>|none`, `install.sh preflight`, `install.sh agent <id>` to add one agent next to a running Codeman, `packages/desktop/src/gui_install.rs`): `cli_offerable` is the one rule for what can be installed, `preflight` stdout is JSON only, `agent` says `done` only when THAT agent landed, and the `progress_phase` order is pinned against `connect.html`, with `done` last. → [architecture-invariants#installsh-the-public-installer](docs/architecture-invariants.md#installsh-the-public-installer)
Other key scripts: `scripts/tmux-manager.sh` (safe tmux mgmt), `scripts/tunnel.sh [quick|named] start|stop|status|url` (quick = random trycloudflare URL, default; `named setup|enable` = fixed-hostname tunnel via `scripts/codeman-tunnel-named.service`; bare `start|stop|url` still means quick), `scripts/run-beta.sh` (isolated beta instance), `scripts/build-agent-image.mjs` (docker base image), `scripts/self-update.sh` (detached updater). Production services: `scripts/codeman-web.service`, `scripts/codeman-tunnel.service`. **Always set `CODEMAN_PASSWORD`** before exposing via tunnel.
+1
View File
@@ -69,6 +69,7 @@ This installs Node.js, tmux and a build toolchain if missing (node-pty ships no
- **How it's reachable, your choice.** **Tailscale** (loopback bind fronted by `tailscale serve`, so you get `https://<machine>.<tailnet>.ts.net` with a real certificate and your tailnet as the login, no password needed), **any device on your network** (`0.0.0.0`, with a strongly recommended password prompt), or **this machine only** (`127.0.0.1`, safest). Skipping the password on a network bind requires an explicit confirmation and ends with a loud warning. The highlighted default reflects what is already on the machine (Tailscale when it is already connected, your existing binding on a re-run), and a bare Enter never pulls in new software. If another app already owns `:443` on your node, Codeman goes under `https://<machine>.<tailnet>.ts.net/codeman` or on a second port instead of replacing it. A bare `codeman web` started by hand still defaults to loopback.
- **The name is yours to choose.** By default the URL uses the machine's existing tailnet name. Answering yes to the second question renames the machine to `codeman-<hostname>` (which also renames it for SSH, so the default is no); `install.sh name` does it later.
- **Re-run to update.** The same one-liner updates a finished install in place: local changes in `~/.codeman/app` are stashed (never discarded), and a running service is restarted and verified. If a first install was interrupted, re-running resumes the full setup instead. `install.sh status` prints the URLs and the QR code again; `install.sh update`, `install.sh tailscale` and `install.sh uninstall` also exist.
- **No administrator rights needed.** On an account without `sudo` (or a Mac account that is not an administrator), the installer downloads Node.js from nodejs.org and the tmux project's own build into `~/.codeman/runtime`, each checked against its SHA-256, and takes the source as an archive when there is no working git. `--no-admin` forces that route. Two limits: Linux needs make, a C++ compiler and python3 to be there already, and the tmux download needs macOS 15 or newer. Details in the [wiki](https://github.com/Ark0N/Codeman/wiki/Installation#without-administrator-rights).
- **Flags for the impatient.** `curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service` answers the questions from the command line (`--lan`, `--local`, `--run`, `--no-start`, `--name <n>`, `--port <n>`, `--yes` too). **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently; set `CODEMAN_NONINTERACTIVE=1` to approve them for automation.
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), [OMP](https://github.com/can1357/oh-my-pi), or [GitHub Copilot CLI](https://github.com/github/copilot-cli) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the ten is present; if none is found, it offers to install any of them from a menu (DeepSeek excepted, since its npm package installs only a launcher with no runnable profile), or you can skip and install one yourself later. After install:
-1
View File
@@ -60,7 +60,6 @@ export const BROWSER_TEST_GLOBS = [
'test/fullscreen-tab-switch-capture.browser.test.ts',
'test/terminal-touch-swipe-axis.browser.test.ts',
'test/phone-press-feedback.browser.test.ts',
'test/desktop-connect-page.browser.test.ts', // no server: the page is served by request interception
];
/**
+10
View File
@@ -1297,6 +1297,16 @@ Its CLI knowledge is a GENERATED block (`npm run generate:cli-catalog`, markers
⚠️ It executes ONLY commands from the embedded block (`CLI_INSTALL_CMD_TRUSTED`) — there is no network fetch of the catalogue at install time to worry about at all.
**No-admin route** (`--no-admin` / `CODEMAN_NO_ADMIN`, 2026-11-05). Everything Codeman writes was always under `$HOME`; administrator rights were only ever spent on prerequisites. `resolve_admin_mode` decides once per run whether those rights may be asked for: root yes; a Mac account outside the `admin` group no (unless a Homebrew it can write to exists); on Linux no without a `sudo` binary, or when the account is outside `sudo`/`wheel`/`admin` AND `sudo -n true` fails. ⚠️ It is never decided by `sudo -v`: that prompts, and a refused password from an account outside sudoers is reported to the administrator. A refused password on the system route offers this route instead of ending the run (`sudo_session_try`).
On this route Node.js comes from nodejs.org (`latest-v<major>.x/SHASUMS256.txt` names the tarball AND carries its checksum, one fetch, no JSON parsing) into `~/.codeman/runtime/node`, and tmux from the tmux project's `tmux-builds` release into `~/.codeman/runtime/bin`. ⚠️ `tmux-builds` publishes no checksum file, so `tmux_build_sha256` pins all four digests of `TMUX_BUILD_VERSION`; bump the version and the digests together (the GitHub release API returns them as `digest`). ⚠️ A download is verified BEFORE it is unpacked or run, and an empty expected digest never passes. `node`, `npm`, `npx` and `tmux` are linked from `~/.local/bin` (`link_into_local_bin` refuses to replace a file that does not already point into the runtime, and uninstall removes only such links): the `codeman` launcher is `#!/usr/bin/env node`, and a non-interactive shell never reads the PATH line written to an interactive profile. The runtime's `node/bin` also goes on PATH and on the LaunchAgent's `svc_path`, since `npm install -g` puts AI CLIs beside that Node.js.
⚠️ `no_admin_blocker` runs before the first download and stops for what this route cannot supply: a C/C++ toolchain on Linux (node-pty 1.1.0 compiles there; a node-pty with Linux prebuilds is what would lift this, and it is a dependency decision, not an installer one), musl, a platform nodejs.org or tmux-builds has no build for, and macOS below 15 (the tmux build's deployment target). ⚠️ Nothing between the "No-admin route" header and "End of the no-admin route" may run `sudo`, `ensure_sudo`, `run_as_root`, Homebrew or `xcode-select --install`; `run_as_root` itself refuses while `NO_ADMIN=1`, so a missed call site fails instead of prompting.
**Driving the installer from a program** (`--ai-cli`, `--progress`, `install.sh preflight`, 2026-11-06). Codeman Desktop installs with no terminal: it runs `install.sh --no-admin --service --progress --ai-cli <id>|none` with `CODEMAN_NONINTERACTIVE=1` and its stdin closed, and asks the two questions in its own window (`packages/desktop/src/gui_install.rs`). `--ai-cli` answers the agent menu, so the one thing `CODEMAN_NONINTERACTIVE` used to decide by itself (the first entry of the catalogue) is the user's. ⚠️ `cli_offerable` is the ONE rule for what can be installed here: the menu, `--ai-cli` and `preflight`'s `installable` all read it, so the app's list and the menu cannot disagree. A name that is not offerable here warns and carries on, and never falls back to another CLI; a typo dies in `parse_flags` before anything runs. `install.sh agent <id>` installs one CLI and nothing else, for Codeman Desktop's *Add a coding agent*: no service, no source, no build, so it may run next to a live server, and an agent already there is a success that runs nothing. ⚠️ It calls `run_offered_cli`, checks `CLI_FOUND_PATH` of that one entry itself and only then prints `done`: `install_offered_cli`'s gate is fatal only when NO CLI is left, which is right for a first install and would report success here, where another CLI is usually present. ⚠️ The app passes `--no-admin` to both `preflight` and `agent`, because that flag decides what `cli_offerable` allows; pinned in `gui_install.rs`. ⚠️ The app runs it from a folder of its own (`Run::Agent`), so `ps` never reads an agent run as "Codeman is being installed". A running server remembers a missing CLI for up to 5 minutes (`cliResolveRetryDelayMs`), and Run reads that memory, so `mark_cli_installed` touches `cli-installed` in the data dir and `resolve()` leaves the backoff when that file's mtime changes. ⚠️ That check is one `stat` inside the backoff branch and buys one run of the chain. Never replace it with a re-walk of PATH during the backoff: that puts a synchronous stat of every PATH entry on the request path (#516) and executes candidates already refused. The app never restarts the service for this. Only `install.sh` writes the marker, always under `~/.codeman`, so a CLI installed by hand, or another `CODEMAN_INSTANCE`, waits out the backoff as before, and so does a server older than this. ⚠️ `preflight` prints only JSON on stdout and changes nothing; `schema` moves only when a field changes meaning, because the app ships on its own and refuses a number it does not know. Fields may be added freely. ⚠️ `progress_phase` writes to stderr, not stdout, because a function can run inside a `$( )` that captures stdout. The phases must stay in the order of `PHASES` in `connect.html` (pinned by test), `done` must stay the last thing `main()` reports, since a run that ends without it failed, and an unknown phase is ignored by the app, so a new phase needs no new app. ⚠️ `error()` carries the text as `@@codeman error`, and the app shows the FIRST one: the ones after it are consequences (`Installation failed. Partial...`).
⚠️ **git is judged by whether it works, never by `command -v`.** On a Mac without the Xcode Command Line Tools `/usr/bin/git` is a stub that opens Apple's install dialog, so `check_git` judges that path by `xcode-select -p` and never executes it; any other git is asked for `--version`. `scripts/git-hooks.mjs` has the matching guard (no `.git`, no `git` call), because postinstall runs it on every `npm install`. Without a working git the source is an archive (`fetch_source_archive`, GitHub's `archive/<ref>.tar.gz` or `CODEMAN_TARBALL_URL`) and the install directory carries the marker `.install-source`, which `detectInstallKind` reads as `archive` (the name is pinned across both files by `test/install-sh-invariants.test.ts`). ⚠️ An archive install is replaced as a WHOLE, never patched in place: nothing says which files a newer version dropped, and a stale `src/*.ts` would be compiled. The new tree is fetched and built in `<install dir>.new` and `finish_source_swap` moves it into place only after `npm run build` succeeded, so a failed update leaves the running install untouched; a re-run on a machine that has gained a working git stages a clone instead, which turns the install back into a git one. ⚠️ `download`/`download_to_stdout` resolve `DOWNLOADER` themselves (`require_downloader`): only `main()` used to set it, so a subcommand that downloads died on `set -u` with the message swallowed by the step log.
## Agent inspection and guarded submission
These three operations are experimental across both CLI and HTTP, including the
+59 -2
View File
@@ -78,6 +78,8 @@ install.sh uninstall # remove (offers to undo a rename it performed)
install.sh tailscale # retrofit Tailscale access onto an existing install
install.sh name [<n>] # rename this machine on your tailnet (default codeman-<hostname>)
install.sh cloudflared # install cloudflared for the in-app Cloudflare tunnel
install.sh preflight # print what is on this machine as JSON; changes nothing
install.sh agent <id> # install one agent CLI and nothing else, also next to a running Codeman
```
**Flags** answer the questions from the command line and pipe through `bash -s --`:
@@ -94,14 +96,65 @@ the name, `--service` / `--run` / `--no-start` the last one. `--yes` takes every
`--port <n>` moves Codeman off 3000; the service file and the serve mapping follow it. On an
existing install, `--port` and `--password` re-run the setup so the service file picks them up,
and a re-run with `--lan` or `--tailscale` keeps the password the service already has.
`--ai-cli <id>` answers the agent menu that appears when no agent CLI is found, with no
terminal needed (`--ai-cli claude`, `--ai-cli codex`, ...), and `--ai-cli none` skips it.
To add an agent later, `install.sh agent codex` installs that one and changes nothing else:
it does not touch Codeman or its service, and an agent that is already there is left alone.
Codeman Desktop does the same from *Add a coding agent*. A running Codeman notices the new
agent at once; reload an open page to see it.
**Automation and CI**: with no terminal attached, any step that would change the system
aborts with instructions instead of running silently. Set `CODEMAN_NONINTERACTIVE=1` to
approve those steps. `CODEMAN_TAILSCALE=1` preselects the Tailscale answer, and never
approve those steps. With nothing else said, that also installs the first agent CLI of the
catalogue when none is found; `--ai-cli` (or `CODEMAN_AI_CLI`) says which one, or none.
`CODEMAN_TAILSCALE=1` preselects the Tailscale answer, and never
installs Tailscale itself non-interactively; a non-interactive run never renames the
machine and never starts a service. Everything the unattended steps print goes to
`~/.codeman/install.log`, and the last lines of it are shown when a step fails.
### Without administrator rights
Codeman itself always lived in your home directory. What used `sudo` (or Homebrew on a
Mac) were the things it needs first. On an account that has no administrator rights the
installer says so in its summary (`Admin not used (...)`) and gets those another way:
| Needed | With administrator rights | Without |
| ----------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| Node.js 22 | system package or Homebrew | The official tarball from nodejs.org, checked against the `SHASUMS256.txt` published beside it, unpacked to `~/.codeman/runtime/node`. |
| tmux | system package or Homebrew | The tmux project's own build ([tmux-builds](https://github.com/tmux/tmux-builds)), checked against a SHA-256 pinned in the installer, in `~/.codeman/runtime/bin`. |
| git | system package or Xcode tools | Not installed. Without a working git, the source is downloaded as an archive instead of cloned. |
| Build toolchain (Linux) | system package | Cannot be supplied. See below. |
This route is taken by itself when `sudo` is not installed, when the account is outside the
`sudo`, `wheel` and `admin` groups and `sudo` will not run without a password, when the
`sudo` password is refused, and on a Mac for an account that is not an administrator.
`--no-admin` (or `CODEMAN_NO_ADMIN=1`) forces it on an account that does have the rights;
`CODEMAN_NO_ADMIN=0` switches the detection off. Nothing on this route runs `sudo`,
Homebrew or the Xcode tools installer.
What to know about it:
- **Linux needs make, a C++ compiler and python3 to be there already.** node-pty compiles
from source on Linux, and a compiler is a system package. The installer checks this
before it downloads anything and names what is missing. macOS needs no compiler.
- **The tmux download needs macOS 15 or newer** (and x86_64 or arm64 on either system).
On an older Mac an administrator has to install tmux once (`brew install tmux`); the
rest then works without one.
- **An install without git updates by re-running the install line**, not from App
Settings. The re-run builds the new version beside the old one and swaps it in only
after the build succeeded, so a failed download or build leaves the install as it was.
Once the machine has a working git, the next re-run turns the install into a normal
clone and in-app updates work again. On a Mac, "a working git" means the Xcode Command
Line Tools are installed: without them `/usr/bin/git` only opens Apple's install dialog,
and the installer never runs it.
- **Tailscale works where it is already installed and your account may configure it.** On
Linux, installing it, logging in and granting serve rights each need an administrator
(`sudo tailscale set --operator=$USER`, once). Local network and this-machine access
need nothing, and `install.sh cloudflared` puts `cloudflared` in `~/.local/bin`.
- `node`, `npm`, `npx` and `tmux` are linked from `~/.local/bin`, and
`~/.codeman/runtime/node/bin` is added to your PATH, so a CLI installed with
`npm install -g` is found as well.
## Route B: npm
```bash
@@ -194,6 +247,7 @@ If the dashboard loads and **+ New Session** opens, you are done. Continue to
| Path | What |
| ----------------------- | ------------------------------------------------------------------------------------------------ |
| `~/.codeman/app` | The installed code (installer route only). |
| `~/.codeman/runtime` | Node.js and tmux, when the installer downloaded them instead of using system packages ([no administrator rights](#without-administrator-rights)). |
| `~/.codeman/` | All state: `state.json`, settings, session history, push keys, TLS certs. See [Core Concepts](Core-Concepts). |
| `~/codeman-cases/` | Cases created from scratch. Linked cases stay wherever they already are. |
| `~/.codeman/web.log` | Log for a detached (`-d`) server. |
@@ -217,6 +271,7 @@ Full detail, including logs and the self-updater, is in
| Install route | How to update |
| ------------- | ----------------------------------------------------------------- |
| Installer | Re-run the one-liner, or **App Settings → System → Updates** in the UI. |
| Installer, without git | Re-run the one-liner. The in-app updater says so instead of offering an update. |
| npm | `npm update -g aicodeman` |
| git clone | `git pull && npm install && npm run build`, then restart. |
| Docker Compose | Re-run `Start-Codeman.sh`. The in-app updater works too, and refuses a release that changes the container definition until you re-run the script. |
@@ -232,7 +287,9 @@ install.sh uninstall # installer route
npm uninstall -g aicodeman # npm route
```
Neither removes `~/.codeman/` or `~/codeman-cases/`. Delete those by hand if you want the
The installer route asks before it removes `~/.codeman/runtime` (the Node.js and tmux it
downloaded, if any), and keeps it by default when you installed CLIs there with
`npm install -g`. Neither removes `~/.codeman/` or `~/codeman-cases/`. Delete those by hand if you want the
state and your case folders gone as well, and check `~/codeman-cases/` first: linked cases
point at directories you already had, but cases created from scratch have their only copy
there.
+1100 -77
View File
File diff suppressed because it is too large. Load diff
+10 -28
View File
@@ -63,7 +63,7 @@
"exceljs": "4.4.0",
"fflate": "0.8.3",
"pixelmatch": "^6.0.0",
"playwright": "^1.58.0",
"playwright": "^1.64.1",
"pngjs": "^7.0.0",
"postcss": "^8.5.15",
"prettier": "^3.4.0",
@@ -9736,50 +9736,32 @@
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
"playwright-core": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
"node": ">=20"
}
},
"node_modules/pngjs": {
+1 -1
View File
@@ -133,7 +133,7 @@
"exceljs": "4.4.0",
"fflate": "0.8.3",
"pixelmatch": "^6.0.0",
"playwright": "^1.58.0",
"playwright": "^1.64.1",
"pngjs": "^7.0.0",
"postcss": "^8.5.15",
"prettier": "^3.4.0",
-1
View File
@@ -164,7 +164,6 @@ dependencies = [
"base64 0.22.1",
"block2",
"dirs 6.0.0",
"libc",
"muda",
"objc2",
"objc2-app-kit",
-6
View File
@@ -30,9 +30,6 @@ ureq = { version = "3", features = ["platform-verifier"] }
[target.'cfg(target_os = "macos")'.dependencies]
security-framework = "3"
# Ignoring signals in the logo blinker (src/logo.rs). Already in the tree
# through wry and tao, so no new crate is added.
libc = "0.2"
# JavaScript dialogs, HTTP Basic challenges, clearing cookies and the window's
# content size (src/macos.rs). The versions wry already pulls in, so no new
# crate is added.
@@ -42,9 +39,7 @@ objc2-foundation = { version = "0.3.2", default-features = false, features = [
"std",
"objc2-core-foundation",
"block2",
"NSBundle",
"NSDate",
"NSDictionary",
"NSGeometry",
"NSObject",
"NSOperation",
@@ -58,7 +53,6 @@ objc2-foundation = { version = "0.3.2", default-features = false, features = [
"NSURLRequest",
"NSURLResponse",
"NSURLSession",
"NSValue",
] }
objc2-app-kit = { version = "0.3.2", default-features = false, features = [
"std",
+92 -96
View File
@@ -13,8 +13,8 @@ Windows and Linux later.
- **Stack:** Rust with [tao](https://crates.io/crates/tao) (window and event loop),
[wry](https://crates.io/crates/wry) (the system web view: WKWebView on macOS) and
[muda](https://crates.io/crates/muda) (native menus), plus [objc2](https://crates.io/crates/objc2)
for what wry leaves out on macOS (JavaScript dialogs, HTTP Basic login) and for
the lid logo. No bundled browser and no Node in the app.
for the two things wry leaves out on macOS (JavaScript dialogs, HTTP Basic login).
No bundled browser and no Node in the app.
- **Server:** unchanged. The app is a client of Codeman's web UI, like a browser tab.
## Why it is cheap
@@ -28,19 +28,17 @@ cannot do.
| | |
| --- | --- |
| **This Mac** | The connect screen's first tab looks for Codeman on this Mac and offers what fits: *Open Codeman* when it runs, *Start Codeman* when it is installed but stopped, *Install Codeman on this Mac* when it is not there. The address comes from Codeman's LaunchAgent (port, sub-path, HTTPS). When nothing answers there but one of your own Codeman servers runs (found with `ps` and `lsof`: a `codeman web --port 4000` in a terminal, say), the tab opens that one, or says where it listens; it never offers to start a second server, which would take over the first one's sessions, and Start refuses while one runs. A server that runs but listens nowhere yet (the first seconds after login, a restart) shows as *starting*: the tab looks again every second and, when a connect to this Mac had just been refused, opens Codeman once it is up. Start asks launchd to run the LaunchAgent (`kickstart`, or `bootstrap` when it is not loaded), so the server keeps its own settings and supervision; without a LaunchAgent it runs `codeman web -d`. The login comes from where Codeman keeps it: the LaunchAgent's `CODEMAN_USERNAME`/`CODEMAN_PASSWORD`, else `~/.codeman/.env` (what Codeman's command-line clients read), `admin` by default. It goes only to `127.0.0.1` on that Codeman's port, only while one of your own Codeman processes holds that port, is never saved to the Keychain, and never reaches the page. Whether Codeman is installed is looked up in its files and processes, then the login shell; the interactive profile (nvm in `.zshrc`, say) only on *Check again*. Install opens Terminal with Codeman's installer (`install.sh --local --service --port N`): it may ask for the Mac password (Homebrew, Node.js, tmux) and which AI coding tools to set up, then Codeman runs as a LaunchAgent, so it keeps running when the app quits and starts at login. *Also reach it from my phone and other computers* drops `--local`, and the installer asks how (Tailscale, or the local network with a password). Port 3000 is used unless something else answers there, then the next free one. While the installer runs, the app looks every 3 seconds and opens Codeman as soon as it answers; it stops looking after the first answer, after 30 minutes, or 90 seconds after a finished install whose server never answered (then it offers Start). An install still running in Terminal is recognized by its process, so the tab waits for it instead of offering Install twice. |
| **This Mac** | The connect screen's first tab looks for Codeman on this Mac and offers what fits: *Open Codeman* when it runs, *Start Codeman* when it is installed but stopped, *Install Codeman on this Mac* when it is not there. The address comes from Codeman's LaunchAgent (port, sub-path, HTTPS). When nothing answers there but one of your own Codeman servers runs (found with `ps` and `lsof`: a `codeman web --port 4000` in a terminal, say), the tab opens that one, or says where it listens; it never offers to start a second server, which would take over the first one's sessions, and Start refuses while one runs. Start asks launchd to run the LaunchAgent (`kickstart`, or `bootstrap` when it is not loaded), so the server keeps its own settings and supervision; without a LaunchAgent it runs `codeman web -d`. The login comes from where Codeman keeps it: the LaunchAgent's `CODEMAN_USERNAME`/`CODEMAN_PASSWORD`, else `~/.codeman/.env` (what Codeman's command-line clients read), `admin` by default. It goes only to `127.0.0.1` on that Codeman's port, only while one of your own Codeman processes holds that port, is never saved to the Keychain, and never reaches the page. Whether Codeman is installed is looked up in its files and processes, then the login shell; the interactive profile (nvm in `.zshrc`, say) only on *Check again*. Install sets Codeman up from the app itself, with no Terminal and no password (see Decisions). It fetches the installer, asks it what is on this Mac, then asks two things: which coding agent to install, only when this Mac has none (the list comes from the installer, and *Skip for now* is there), and who can reach Codeman (this Mac only, or the local network with a password). A checklist follows the install, the installer's output is one click away, and Cancel stops it; installing again picks up where it stopped. Codeman runs as a LaunchAgent, so it keeps running when the app quits and starts at login. Port 3000 is used unless something else answers there, then the next free one. Closing the app does not stop an install: it runs on its own, and the app picks it up when it opens again. When it is done the app looks every 3 seconds and opens Codeman as soon as it answers; it stops looking after the first answer, after 30 minutes, or 90 seconds after a finished install whose server never answered (then it offers Start). *Install in Terminal instead* runs the installer in a Terminal window, for what the app cannot do (see Known limits): it may ask for the Mac password (Homebrew) and asks its own questions there. The app offers it by itself on a Mac where the graphical install cannot finish. An install still running in Terminal is recognized by its process, so the tab waits for it instead of offering Install twice. *Add a coding agent*, on this tab and in the app menu whenever Codeman is on this Mac, is the same chooser after the install: pick one agent, watch it install, open Codeman. It needs no password, leaves a running Codeman alone, and can be cancelled or left running like an install. After *Skip for now* the app says Codeman has no agent yet and offers this before it opens Codeman. |
| **Another machine** | The second tab connects to a server by address (below). *Codeman is not installed there yet?* opens Terminal, connects with `ssh -t` and runs Codeman's installer on that machine, which asks its questions there; the app then suggests `http://<host>:3000` and the installer prints the real address at the end. The SSH login is checked to the characters of a user and host name (no option can be slipped in) and may carry a `:port`. The remote command runs under `bash -o pipefail`, so a failed download fails the install instead of reporting success. The same tab shows the one-line install command, with a Copy button, to run on a machine by hand. |
| **Connect screen** | Server address, recent servers, a login for password-protected servers (password kept in the macOS Keychain), auto-connect at launch. An address typed without `http://` or `https://` gets `http://`, except a tailnet name with no port (`machine.tailnet.ts.net`, which is `tailscale serve`), which gets `https://`. It explains each failure: nothing listening; wrong password; too many failed logins (the password field stays open, since a single-user Codeman accepts the right password even then); unknown host name; an address this Mac cannot reach (with a pointer to the Local Network setting); an `http://` address on a port that only speaks HTTPS; an untrusted certificate; a redirect, with a button for the suggested `https://` address; not a Codeman server. A multi-user account that must change its password connects, and Codeman's own dialog asks for the new one. If the server page has not started showing within 15 seconds, or you press Cancel while it opens, the connect screen comes back with a notice. A login that would cross the network over plain `http://` to another machine says so. Return opens or starts the Codeman on this Mac and Esc cancels a connect, as in a Mac dialog (Install always takes a click). On a Mac whose WebKit is too old for Codeman it says so and Connect stays off (see Known limits). |
| **Connect screen** | Server address, recent servers, a login for password-protected servers (password kept in the macOS Keychain), auto-connect at launch. It explains each failure: nothing listening; wrong password; too many failed logins (the password field stays open, since a single-user Codeman accepts the right password even then); unknown host name; an address this Mac cannot reach (with a pointer to the Local Network setting); an `http://` address on a port that only speaks HTTPS; an untrusted certificate; a redirect, with a button for the suggested `https://` address; not a Codeman server. A multi-user account that must change its password connects, and Codeman's own dialog asks for the new one. If the server page has not started showing within 15 seconds, or you press Cancel while it opens, the connect screen comes back with a notice. On a Mac whose WebKit is too old for Codeman it says so and Connect stays off (see Known limits). |
| **Start Codeman** | On the This Mac tab, and when `http://127.0.0.1:<port>` or `http://localhost:<port>` answers nothing, one click runs `codeman web -d` (or `~/.codeman/app`) with your login shell's environment, so the server gets your real PATH (Homebrew, nvm) for the agents it spawns. zsh, bash and other POSIX shells run the start themselves; fish, nu, xonsh, tcsh and other shells only report their environment, and the start runs under `/bin/sh` with it. Offered for those two addresses only: `codeman web -d` listens on 127.0.0.1. |
| **Login** | The app answers the server's HTTP Basic login requests itself, with the login you connected with, so the page keeps working after the server restarts (a restart forgets every session cookie). It answers only for Codeman's own pages under the server's base path: a web tab or a rendered HTML file that asks for a Basic login of its own is left to WebKit and never gets your Codeman password. WebKit keeps no copy of the login, so every request for it gets the current one. Each connect first clears the app's cookies, so connecting to the same server as another user really switches users (local storage, where Codeman keeps its per-device settings, stays; a dashboard in a web tab that keeps its own login in a cookie may ask for it again). A page that still gets a 401 reloads once. If the server refuses the login (the password was changed, say), the app does not keep sending it: it checks it with a single probe, and only if the server refuses that too does the connect screen come back with a notice. That does not take focus from the app you are in: if Codeman is in the background, its Dock icon bounces. |
| **Dialogs** | Codeman's `confirm()`, `prompt()` and `alert()` (Delete case, Kill all sessions, Update Codeman, discarding unsaved edits, ...) show as native macOS dialogs. wry implements none of them, and without them WKWebView answers Cancel without showing anything. Each dialog's first line names the page it comes from, and says so when that is an embedded frame (a web tab, a rendered HTML preview), so a frame cannot pass its text off as Codeman's. |
| **Dialogs** | Codeman's `confirm()`, `prompt()` and `alert()` (Delete case, Kill all sessions, Update Codeman, discarding unsaved edits, ...) show as native macOS dialogs. wry implements none of them, and without them WKWebView answers Cancel without showing anything. |
| **Full screen** | `⌃⌘F` or the green button. The last state is restored at launch; *Always Open in Full Screen* forces it. Leaving full screen returns to the saved window size and position. |
| **Menus** | A real Edit menu (without one, `⌘C`/`⌘V` do nothing in a WKWebView), Reload `⌘R`, Switch Server `⇧⌘O`, Developer Tools `⌥⌘I`. The page sees every key first, so Codeman's own shortcuts keep working. If the main window ends up showing a raw file instead of Codeman, `⌘R` takes it back to Codeman. |
| **Pop-outs** | Implements Codeman's existing native-wrapper bridge, `window.CodemanHost` (`openInHostWindow` in `src/web/public/app.js`), so popping out a session or a file preview opens a real app window, and the pop-out icon is on by default. Session and rendered-HTML pop-outs come only from Codeman's own pop-out button. A `window.open` from page script opens only Codeman's file and download windows, without taking keyboard focus, at most 3 at once and then 1 per second. Only Codeman's own pop-out pages become app windows (see Decisions); when the app refuses one, Codeman shows a toast. Each pop-out opens a step down and to the right of the window in use, never exactly on top of another one. A web tab's *Open in new tab* opens the dashboard in your default browser, as it would from a browser tab. |
| **Links** | On Codeman's own pages (the dashboard and session windows), a link to another site opens in your default browser, also on `⌘`-click or a middle click. Pages can send at most 3 links to the browser at once, then 1 per second. Inside a rendered HTML preview or a web tab, a plain link to another site does nothing, while one that asks for a new window (`target="_blank"`, `window.open`) opens in your default browser within the same budget (see Known limits). |
| **Pop-outs** | Implements Codeman's existing native-wrapper bridge, `window.CodemanHost` (`openInHostWindow` in `src/web/public/app.js`), so popping out a session or a file preview opens a real app window, and the pop-out icon is on by default. Session and rendered-HTML pop-outs come only from Codeman's own pop-out button. A `window.open` from page script opens only Codeman's file and download windows, without taking keyboard focus, at most 3 at once and then 1 per second. Only Codeman's own pop-out pages become app windows (see Decisions); when the app refuses one, Codeman shows a toast. A web tab's *Open in new tab* opens the dashboard in your default browser, as it would from a browser tab. |
| **Links** | On Codeman's own pages (the dashboard and session windows), a link to another site opens in your default browser, also on `⌘`-click or a middle click. Pages can send at most 3 links to the browser at once, then 1 per second. Links to other sites inside rendered HTML previews and web tabs do not open (see Known limits). |
| **Downloads** | Codeman's Download links, and downloads Codeman opens in a new window, save the file to `~/Downloads` whatever its type, and a toast in Codeman names the file. Files the web view cannot display are saved the same way. |
| **A page that stops** | When the web content process of a window ends (a WebKit crash, or macOS taking the process back under memory pressure), the window loads its page again instead of staying blank. A second time within 30 seconds, the main window goes back to the connect screen and a pop-out closes, so a page that cannot stay up never becomes a reload loop. |
| **Apple logo** | On a MacBook Pro with a PROLED XDR display, the lit Apple logo on the lid says what the agents are doing, with a blink style of your own for each state: **View > Blink Apple Logo > While Working**, **Waiting for You (Yellow)** and **Needs Permission (Red)**. Off, Calm, Slow, Wink, Heartbeat, Fast, or Alarm, three sharp flashes for a state that needs you. All three are off until you choose. When several states hold, the most urgent one with a style shows: red, then yellow, then work. A style chosen in an earlier version stays as the one for work, and yellow and red start as Wink and Alarm. The menu only exists on a Mac with that logo. No password is needed. Shell tabs do not count. The app asks the server every two seconds, and a change has to hold for three seconds before the logo follows it, so an agent between two turns does not flicker it. It keeps working while the window is hidden, and with any Codeman server. A logo you switched off yourself stays off, and if the setting changes while a blink runs, the blink ends and leaves it as it found it. While the app is closed nothing blinks, and macOS keeps the logo dark while the display sleeps or the lid is closed, so the blink pauses then and writes nothing. |
| **Window** | Size, position and full-screen state are remembered. A saved window that hangs off the screen it opens on (it was last on a larger display that is gone, say) is pulled back on and shrunk to fit; one whose title bar is on no screen at all opens at the default size instead. Clicking the Dock icon brings back a minimized window. |
## Build (on a Mac)
@@ -56,11 +54,10 @@ cargo test # unit tests
open dist/Codeman.app
```
The connect page has its own test, which needs no Mac: from the repository
root, `npm run test:browser -- test/desktop-connect-page.browser.test.ts` drives
`assets/connect.html` in Chromium through every state of both tabs, under the
Content-Security-Policy the app serves it with. It is part of the nightly
browser suite, not of `npm test`.
To try a change to `install.sh` before it is merged, build with
`CODEMAN_DESKTOP_INSTALLER_URL` (where to get the installer) and
`CODEMAN_DESKTOP_INSTALL_BRANCH` (the branch it installs from) set. Both are
read at compile time: a shipped app cannot be pointed at another installer.
The script builds with `--locked` against the committed `Cargo.lock`, targets
the macOS version in `macos/Info.plist` (`LSMinimumSystemVersion`, 11.3), and
@@ -103,8 +100,8 @@ classes of wry's own delegates, so wry stays an unmodified crates.io dependency.
- An app window opens only for Codeman's own pop-out pages under the server's
base path, checked after decoding the path once, and which ones depends on
who asks. `window.CodemanHost` (the app shell only) may open sessions,
rendered HTML views, the raw, preview, attachment and folder file routes, the
path picker's preview and `api/download`. A `window.open` or `target=_blank` link, which WebKit passes
rendered HTML views, the raw, preview, attachment and folder file routes, and
`api/download`. A `window.open` or `target=_blank` link, which WebKit passes
on from any frame without saying which, gets only the file routes and
`api/download`, the pages Codeman itself opens that way; otherwise a rendered
HTML file could open copies of itself as top-level windows that pass for the
@@ -141,19 +138,52 @@ classes of wry's own delegates, so wry stays an unmodified crates.io dependency.
- `codeman web -d` gets the port and base path as separate arguments, never as
shell text.
**The installer runs in Terminal, not inside the app.** `install.sh` asks for
the administrator password on a terminal (Homebrew, its sudo keep-alive), the
Xcode tools arrive as a system dialog, and ssh asks about host keys and
passwords on a terminal too, so an install without one fails on the first
fresh Mac. The app writes a short script to the per-user temporary folder
(mode 0700, created fresh, deleted by the script as it starts) and opens it
with `open -b com.apple.Terminal`, which needs no permission; scripting
Terminal with osascript would ask for Automation access. The script prints
what it runs. A This Mac install downloads the installer to a temporary file
and runs it from there, so the installer's input is the terminal. Everything in
the scripts is built and unit-tested in `src/setup.rs`; the page sends only
which install it wants, whether other devices should reach it, and the SSH
login, which is checked before it is quoted.
**The app runs the installer itself; Terminal is the fallback.** `install.sh
--no-admin` puts Node.js and tmux in the home folder, so a This Mac install needs
no password and therefore no terminal, and the two questions only a person can
answer arrive as flags (`src/gui_install.rs`). The app downloads the installer
once to a private folder, asks it what is on this Mac (`install.sh preflight`,
which prints JSON and changes nothing) and draws its choices from that answer.
So the app has no list of coding agents of its own: a new one appears without a
new app, and the list always matches what the installer can really install
here. The installer runs with the login shell's environment, so it finds what
this person already has. The page sends only the agent's id, whether other
devices may reach Codeman, and that password; the password reaches the
installer through its environment, never its command line, where `ps` shows it.
**The install outlives the app.** Its output goes to a file, never a pipe, and
it runs in a process group of its own. With a pipe, quitting the app would kill
the installer at its next write; in the app's group, launchd would kill it with
the app. The file holds the whole run, so a page that was reloaded, or an app
that was opened again, finds the process in `ps` and replays the file from the
first line. That is why the app keeps no state about a run. A run that ends
without the installer's last `done` line failed, and the first error it reported
is the one shown: the ones after it are consequences.
**Adding an agent is a run of its own.** `install.sh agent <id>` installs one
agent and touches neither Codeman nor its service, so it may run next to a live
server, which an install must refuse: the installer's service step would replace
that server under its sessions. It has its own temporary folder, so `ps` tells the
two apart: everything that reads a running install takes it to mean "Codeman is
being installed", which an agent run does not. One run goes at a time, since both
can write to the runtime folder. It asks `preflight` for the list like an install
does, and both pass `--no-admin`: that flag decides which agents count as
installable, so without it on both the page could offer one the run refuses. The
menu item is on the connect page because that is where the app may run things:
from a Codeman page it leaves the page the way *Switch Server* does.
**Terminal stays for what the app cannot do.** The route with Homebrew asks for
the administrator password on a terminal, the Xcode tools arrive as a system
dialog, and ssh asks about host keys and passwords on a terminal too. The app
writes a short script to the per-user temporary folder (mode 0700, created
fresh, deleted by the script as it starts) and opens it with `open -b
com.apple.Terminal`, which needs no permission; scripting Terminal with
osascript would ask for Automation access. The script prints what it runs. A
This Mac install downloads the installer to a temporary file and runs it from
there, so the installer's input is the terminal. Everything in the scripts is
built and unit-tested in `src/setup.rs`; the page sends only which install it
wants, whether other devices should reach it, and the SSH login, which is
checked before it is quoted.
**This Mac's own login comes from where Codeman keeps it.** A Codeman
installed with a password keeps it in its LaunchAgent (`install.sh`) or in
@@ -188,68 +218,22 @@ instead of being answered. A refusal that lands while you are in another app
does not bring Codeman forward: the connect screen loads where the window is,
and the Dock icon bounces.
**The logo blinks from a child process, through CoreBrightness.** Apple documents
one control for the logo ([support.apple.com/en-us/149248](https://support.apple.com/en-us/149248)):
`sudo defaults write com.apple.CoreBrightness LEBacklightMode -int 0`. Doing that
for every blink would need a root helper and an administrator password. The same
setting is also a property of `BrightnessSystemClient` in the private
CoreBrightness framework, and a process of the logged-in user can set it there,
which is what `src/logo.rs` does. Measured on a Mac17,13 with macOS 27.2 from an
ad-hoc signed binary: `corebrightnessd` logs the logo dark about a millisecond
after the write, with no fade, and the NVRAM variable `bklb-backlight-enabled`
keeps its value while the logo is dark.
The setting is system-wide and survives a reboot, so the app must not end with the
logo dark, and the release profile has `panic = "abort"`, so no cleanup code of the
app can be relied on. So all of it runs as this same binary with
`--logo-blink`, for as long as any style is chosen and a server is open, tied to the app
by nothing but a pipe: when the pipe closes, however the app ended, it puts the logo
back and exits. Switching it off closes the same pipe, so the path a crash
takes is the one used every day. A marker file
(`logo-blink`, next to `desktop.json`) covers what the child cannot survive itself:
at the next launch the app puts back a logo a blink left dark. The framework is
loaded at run time and never linked, and `LEBacklightMode` reading nil (a Mac mini
answers nil) is what hides the option, so a macOS that drops it loses this feature
and nothing else.
**Red and yellow come from the server's approvals list.** `GET /api/approvals` is what the
page reads to rebuild its red and yellow tabs after a reload, and it follows the same rules:
`permission` and `question` items are red, an `idle` item is yellow until someone has looked
at it (`acknowledgedAt`). So the logo stops when the tab's colour goes. The request is skipped
when neither alert has a style, and a server from before the list reads as no alerts. Each
poll with a dialog on screen costs that server one tmux capture, because the list re-checks
the dialog, which also clears red soon after a dialog is answered in the terminal.
**The agent count is asked for natively, because the page cannot be trusted with it.**
The first version read the page's own event stream, which needs no second connection
and no login. On a real Mac it failed in both directions as soon as the window was
covered, because WebKit stalls the stream of a hidden page: a turn that started while
it was stalled was never seen and the logo stayed still, and in another run the logo
kept blinking 39 seconds after the agent finished, until the page's watchdog reopened
the stream. A hidden window is the normal case for a signal meant to be read from
across the room, so `src/agents.rs` asks `api/sessions` itself, from the child process,
every two seconds. That needs care with the login. A request with a good password and
no session cookie makes Codeman create a login session, it keeps 100 of them, and the
oldest goes first, so polling with the password alone would log every browser out
within minutes. The poller therefore sends the cookie of the first answer with every
request, which Codeman reads first, so one session is created per server start. A
refused login ends the polling for good, because Codeman counts each refusal against
the login rate limit. The app and the child share nothing but the pipe the login
crosses, so the login is never on a command line.
## Known limits
- **The logo blink rests on a private macOS interface.** Apple documents the
setting, not the way the app reaches it, so a macOS update can take it away;
the menu then disappears. Every change of the logo writes `LEBacklightMode`,
from about once every two seconds (*Slow*, *Wink*) to about three times a
second (*Fast*), which `corebrightnessd` keeps in root's preferences. The
setting is on or off, so these styles are rhythms of hard changes. After a blink
the key stays there rather than being absent as on a fresh Mac. That reads the same to macOS; `sudo defaults delete
com.apple.CoreBrightness LEBacklightMode` removes it. After a power cut in a
dark phase the logo stays dark until the app next starts. `api/sessions` does
not say which turns are Codeman's own, so with prompt-cache keep-warm on, a
keep-alive turn blinks too.
- **A new agent can take a few minutes to show up in an older Codeman.** The server
remembers that a CLI is missing, for up to 5 minutes after it last looked
(`cliResolveRetryDelayMs`), so that a status poll never spawns a login shell. The
installer's `cli-installed` marker ends that at once, but only for a server that
knows the marker, so the screen after *Add a coding agent* still mentions it. An
open Codeman page always needs a reload, because the list of installed agents is
part of the page.
- **Agents the app can add are the ones `--no-admin` can install.** OMP's macOS
install is Homebrew, so it is not offered, also on a Mac that has Homebrew.
- **Agents that install with npm use whichever Node.js the login shell finds.**
With the Node.js the installer downloaded, or Homebrew's, that folder is yours.
The nodejs.org installer leaves `/usr/local/lib/node_modules` to root, so those
agents would be expected to fail there, with npm's permission error shown, while
the ones with their own installer still work. This has not been tried.
- **Self-signed HTTPS is refused.** A server started with `codeman web --https`
uses a self-signed certificate, and WKWebView rejects it (wry has no trust
override). Use `http://127.0.0.1:3000` for a server on this Mac. For another
@@ -302,13 +286,11 @@ crosses, so the login is never on a command line.
or later installed. On an older WebKit the connect screen says so and does
not connect. Session pop-outs also need BroadcastChannel (Safari 15.4),
which the same requirement covers.
- **Plain links in rendered HTML and web tabs do not open.** A link to another
site inside a rendered HTML preview or a web tab does nothing, in the
preview overlay, and for a rendered HTML file in its pop-out window too: the
app blocks navigation to other sites in every frame (see Decisions). A link
there that asks for a new window (`target="_blank"`, or a script's
`window.open`) does open, in your default browser, within the budget of 3 at
once and then 1 per second.
- **Links in rendered HTML and web tabs do not open.** A link to another site
inside a rendered HTML preview or a web tab does nothing, in the preview
overlay, and for a rendered HTML file in its pop-out window too. The app
sends links out only from Codeman's own pages, and blocks navigation to
other sites in every frame (see Decisions).
- **No login dialog for a web tab's own HTTP Basic login.** The app answers
only Codeman's own login, and WebKit shows no dialog for any other, so a
dashboard in a web tab that asks for HTTP Basic shows its 401 page.
@@ -319,6 +301,20 @@ crosses, so the login is never on a command line.
- **No desktop notifications yet.** WKWebView has no Web Notification API.
Native notifications are the first item on the list below.
- **No service worker.** Codeman works without it; it is only an offline cache.
- **The graphical install needs macOS 15, or a tmux that is already there.**
tmux comes from the tmux project's own builds, which need macOS 15. On an
older Mac without tmux the app says so and offers Terminal, where Homebrew
installs it (that takes an administrator once).
- **Who can reach it stops at the local network.** Tailscale needs its own
login and setup, which the installer does at a terminal. Choose *Install in
Terminal instead*, or run `install.sh tailscale` later.
- **The agent is chosen during the install.** With Codeman already installed,
or after *Skip for now*, an agent is added by hand: its commands are in the
installer's closing notes and in [Installing an agent CLI](../../docs/wiki/Installation.md#installing-an-agent-cli).
- **An agent only your interactive profile finds counts as missing.** The app
reads the login shell's environment, without `.zshrc`, so a CLI on an nvm
PATH set up there is offered again as if it were not installed. Installing
it again is harmless; *Skip for now* leaves it as it is.
- **Installing from the app is macOS only so far**, and installing on another
machine needs ssh access to it and `curl` or `wget` there. The app does not
know when a remote install is done or which address it chose: the installer
File diff suppressed because it is too large. Load diff
-462
View File
@@ -1,462 +0,0 @@
//! What the agents on a server are doing, asked from outside the web view: at
//! work, finished with nobody having looked yet (a yellow tab), or stopped on a
//! permission or a question (a red tab).
//!
//! The page knows this too, but it cannot be relied on for it: WebKit stalls the
//! event stream of a hidden page. Measured on macOS 27.2 with the page hidden: a
//! `session:idle` the server sent never reached it, its own watchdog reopened the
//! stream 39 seconds later, and in another run a whole turn went unseen. A hidden
//! window is the normal case for this question, so it is asked natively, of
//! `api/sessions`, a stable route that works with any Codeman, and of
//! `api/approvals`, the list the page itself builds its red and yellow tabs from
//! when it loads. A server from before that list has no such route, which reads
//! as no alerts.
//!
//! A login has a price here that a single request does not show. A request with
//! a good password and no session cookie makes Codeman create a login session,
//! it keeps 100 of them, and the oldest goes first, so asking every two seconds
//! with the password alone would log every browser out within minutes. So the
//! cookie of the first answer goes along from then on, and Codeman reads the
//! cookie first: the password is only used when the cookie has lapsed (a server
//! restart), and then it creates the one new session that replaces it. Sending
//! both also means a stale cookie costs no failed login. A refused login ends
//! the polling for good, since Codeman counts each one against the login rate
//! limit (see `probe`).
use serde::Deserialize;
use std::time::Duration;
use url::Url;
/// How long the logo can lag behind the server. The list is cached for a
/// second on the server's side and is a few kilobytes.
pub const POLL_EVERY: Duration = Duration::from_secs(2);
const TIMEOUT: Duration = Duration::from_secs(5);
const SESSIONS_PATH: &str = "api/sessions";
const APPROVALS_PATH: &str = "api/approvals";
const COOKIE_NAME: &str = "codeman_session";
/// What the agents on a server are doing. Any number of these can be true.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Activity {
/// At least one agent is at work.
pub working: bool,
/// An agent finished and nobody has looked at it yet: a yellow tab.
pub waiting: bool,
/// An agent stopped on a permission or a question: a red tab.
pub needs_you: bool,
}
#[derive(Debug, PartialEq, Eq)]
pub enum Poll {
Seen(Activity),
/// No answer this time. Worth asking again.
Failed,
/// The server turned the login down. Never ask again.
Refused,
}
#[derive(Deserialize)]
struct Session {
#[serde(default)]
status: String,
#[serde(default)]
mode: String,
}
/// Codeman wraps the list in its API envelope, and older servers sent it bare.
#[derive(Deserialize)]
#[serde(untagged)]
enum Sessions {
Envelope { data: Vec<Session> },
Bare(Vec<Session>),
}
/// Agents only: a shell tab with a `tail -f` is a run of output, which the
/// server may call busy, and that is nobody's agent.
fn count_working(body: &str) -> Option<u32> {
let (Sessions::Envelope { data: sessions } | Sessions::Bare(sessions)) = serde_json::from_str(body).ok()?;
let working = sessions.iter().filter(|s| s.status == "busy" && s.mode != "shell");
u32::try_from(working.count()).ok()
}
#[derive(Deserialize)]
struct Approval {
#[serde(default)]
kind: String,
#[serde(default, rename = "acknowledgedAt")]
acknowledged_at: Option<f64>,
}
#[derive(Deserialize)]
struct Approvals {
approvals: Vec<Approval>,
}
#[derive(Deserialize)]
struct ApprovalsAnswer {
data: Approvals,
}
/// The two alerts, by the page's own rules: `permission_prompt` and a question
/// make a tab red, an idle prompt makes it yellow until someone has looked at it,
/// and an acknowledged item stays listed but alerts nobody.
fn read_alerts(body: &str) -> Option<(bool, bool)> {
let answer: ApprovalsAnswer = serde_json::from_str(body).ok()?;
let items = &answer.data.approvals;
let waiting = items.iter().any(|i| i.kind == "idle" && i.acknowledged_at.is_none());
let needs_you = items.iter().any(|i| i.kind == "permission" || i.kind == "question");
Some((waiting, needs_you))
}
/// `name=value` out of a `Set-Cookie` header, for Codeman's session cookie.
fn session_cookie(header: &str) -> Option<&str> {
let pair = header.split(';').next()?.trim();
let (name, value) = pair.split_once('=')?;
(name == COOKIE_NAME && !value.is_empty()).then_some(pair)
}
/// What a status code says about asking again.
fn answer(status: u16, body: impl FnOnce() -> Option<String>) -> Poll {
match status {
200 => body()
.as_deref()
.and_then(count_working)
.map_or(Poll::Failed, |working| {
Poll::Seen(Activity {
working: working > 0,
..Activity::default()
})
}),
401 | 403 | 429 => Poll::Refused,
_ => Poll::Failed,
}
}
pub struct Poller {
agent: ureq::Agent,
sessions_url: String,
/// None when no alert has a style: that request is then never made.
approvals_url: Option<String>,
authorization: Option<String>,
cookie: Option<String>,
/// The last alerts seen, kept through a request that failed.
alerts: (bool, bool),
}
impl Poller {
pub fn new(server: &Url, authorization: Option<String>, alerts: bool) -> Option<Self> {
let approvals_url = match alerts {
true => Some(server.join(APPROVALS_PATH).ok()?.to_string()),
false => None,
};
Some(Poller {
agent: crate::probe::agent(TIMEOUT),
sessions_url: server.join(SESSIONS_PATH).ok()?.to_string(),
approvals_url,
authorization,
cookie: None,
alerts: (false, false),
})
}
/// One GET with the login, and the status with the body of a 200.
fn ask(&mut self, url: &str) -> Option<(u16, Option<String>)> {
let mut request = self.agent.get(url).header("Accept", "application/json");
if let Some(authorization) = &self.authorization {
request = request.header("Authorization", authorization);
}
if let Some(cookie) = &self.cookie {
request = request.header("Cookie", cookie);
}
let mut response = request.call().ok()?;
let fresh = response
.headers()
.get_all("set-cookie")
.iter()
.filter_map(|value| value.to_str().ok())
.find_map(session_cookie)
.map(str::to_owned);
if fresh.is_some() {
self.cookie = fresh;
}
let status = response.status().as_u16();
let body = (status == 200).then(|| {
let body = response.body_mut().with_config();
body.limit(crate::probe::MAX_BODY).read_to_string().ok()
});
Some((status, body.flatten()))
}
pub fn poll(&mut self) -> Poll {
let url = self.sessions_url.clone();
let Some((status, body)) = self.ask(&url) else {
return Poll::Failed;
};
let mut activity = match answer(status, || body) {
Poll::Seen(activity) => activity,
other => return other,
};
if let Some(url) = self.approvals_url.clone() {
match self.ask(&url) {
Some((200, body)) => {
if let Some(alerts) = body.as_deref().and_then(read_alerts) {
self.alerts = alerts;
}
}
Some((401 | 403 | 429, _)) => return Poll::Refused,
// A server from before the list has no alerts to report.
Some((404, _)) => self.alerts = (false, false),
_ => {}
}
}
(activity.waiting, activity.needs_you) = self.alerts;
Poll::Seen(activity)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use std::sync::{Arc, Mutex};
/// The start of a real answer of `GET /api/sessions`, captured from a
/// server: other fields around the two that matter, in Codeman's envelope.
const REAL_ANSWER: &str = r#"{"success":true,"data":[{"id":"0f8d0215-661e-4a1c-bfad-d7f6fd2ed4b5",
"pid":61017,"status":"busy","workingDir":"/Users/lee/codeman-cases/testcase",
"paneCwd":"/Users/lee/codeman-cases/testcase","currentTaskId":null,"mode":"opencode"}]}"#;
#[test]
fn reads_the_answer_a_real_server_gives() {
assert_eq!(count_working(REAL_ANSWER), Some(1));
assert_eq!(count_working(r#"{"success":true,"data":[]}"#), Some(0));
}
#[test]
fn counts_agents_at_work_and_nothing_else() {
let body = r#"{"success":true,"data":[
{"id":"a","status":"busy","mode":"claude"},
{"id":"b","status":"busy","mode":"codex"},
{"id":"c","status":"idle","mode":"claude"},
{"id":"d","status":"busy","mode":"shell"},
{"id":"e","status":"stopped","mode":"claude"},
{"id":"f","status":"error","mode":"opencode"}
]}"#;
assert_eq!(count_working(body), Some(2));
}
#[test]
fn reads_the_bare_list_of_an_older_server() {
assert_eq!(count_working(r#"[{"status":"busy","mode":"claude"}]"#), Some(1));
assert_eq!(count_working("[]"), Some(0));
}
#[test]
fn a_session_without_a_mode_counts_and_one_without_a_status_does_not() {
assert_eq!(
count_working(r#"{"data":[{"status":"busy"},{"mode":"claude"}]}"#),
Some(1)
);
}
#[test]
fn a_body_that_is_not_the_list_is_no_answer() {
assert_eq!(count_working("<html>"), None);
assert_eq!(count_working(r#"{"success":false,"error":"nope"}"#), None);
assert_eq!(answer(200, || Some("<html>".into())), Poll::Failed);
assert_eq!(answer(200, || None), Poll::Failed);
}
#[test]
fn only_a_login_refusal_ends_the_polling() {
for status in [401, 403, 429] {
assert_eq!(answer(status, || None), Poll::Refused, "{status}");
}
for status in [301, 404, 500, 502, 503] {
assert_eq!(answer(status, || None), Poll::Failed, "{status}");
}
}
#[test]
fn finds_codemans_cookie_in_a_set_cookie_header() {
assert_eq!(
session_cookie("codeman_session=abc123; Max-Age=86400; Path=/; HttpOnly; SameSite=Lax"),
Some("codeman_session=abc123")
);
assert_eq!(session_cookie("codeman_session=abc123"), Some("codeman_session=abc123"));
assert_eq!(session_cookie("other=1; Path=/"), None);
assert_eq!(session_cookie("codeman_session=; Max-Age=0"), None);
assert_eq!(session_cookie(""), None);
}
/// A server that answers each connection with the next canned response
/// and records the headers it was sent.
fn serve(responses: Vec<String>) -> (u16, Arc<Mutex<Vec<Vec<String>>>>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let seen = Arc::new(Mutex::new(Vec::new()));
let log = seen.clone();
std::thread::spawn(move || {
for response in responses {
let (mut stream, _) = listener.accept().unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut headers = Vec::new();
let mut line = String::new();
while reader.read_line(&mut line).is_ok() && line.trim() != "" {
headers.push(line.trim().to_ascii_lowercase());
line.clear();
}
log.lock().unwrap().push(headers);
stream.write_all(response.as_bytes()).unwrap();
}
});
(port, seen)
}
const EMPTY: &str = r#"{"success":true,"data":[]}"#;
fn plain(working: bool) -> Poll {
Poll::Seen(Activity {
working,
..Activity::default()
})
}
/// The shape of a real `GET /api/approvals`, with the frame text cut.
fn approvals(items: &[&str]) -> String {
format!(r#"{{"success":true,"data":{{"approvals":[{}]}}}}"#, items.join(","))
}
const IDLE: &str = r#"{"id":"s:1","sessionId":"s","sessionName":"w4","kind":"idle","createdAt":1,"context":"x"}"#;
const LOOKED_AT: &str = r#"{"id":"s:2","sessionId":"s","kind":"idle","createdAt":1,"acknowledgedAt":2}"#;
const PERMISSION: &str = r#"{"id":"s:3","sessionId":"s","kind":"permission","createdAt":1,"toolName":"Bash"}"#;
const QUESTION: &str = r#"{"id":"s:4","sessionId":"s","kind":"question","createdAt":1}"#;
#[test]
fn the_alerts_follow_the_pages_rules() {
assert_eq!(read_alerts(&approvals(&[])), Some((false, false)));
assert_eq!(read_alerts(&approvals(&[IDLE])), Some((true, false)));
assert_eq!(read_alerts(&approvals(&[LOOKED_AT])), Some((false, false)), "seen");
assert_eq!(read_alerts(&approvals(&[PERMISSION])), Some((false, true)));
assert_eq!(read_alerts(&approvals(&[QUESTION])), Some((false, true)));
assert_eq!(read_alerts(&approvals(&[IDLE, PERMISSION])), Some((true, true)));
assert_eq!(read_alerts("not json"), None);
assert_eq!(read_alerts(EMPTY), None);
}
#[test]
fn reads_all_three_states_from_one_poll() {
let busy = r#"{"success":true,"data":[{"status":"busy","mode":"claude"}]}"#;
let (port, seen_requests) = serve(vec![ok("", busy), ok("", &approvals(&[IDLE, PERMISSION]))]);
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
let mut poller = Poller::new(&server, None, true).unwrap();
let everything = Activity {
working: true,
waiting: true,
needs_you: true,
};
assert_eq!(poller.poll(), Poll::Seen(everything));
assert!(seen_requests.lock().unwrap()[1][0].starts_with("get /api/approvals "));
}
fn status(code: u16) -> String {
format!("HTTP/1.1 {code} X\r\nConnection: close\r\nContent-Length: 0\r\n\r\n")
}
#[test]
fn a_server_without_the_list_still_shows_work() {
let busy = r#"{"data":[{"status":"busy","mode":"claude"}]}"#;
let (port, _) = serve(vec![ok("", busy), status(404)]);
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
assert_eq!(Poller::new(&server, None, true).unwrap().poll(), plain(true));
}
#[test]
fn a_refused_list_ends_the_polling_and_a_failed_one_keeps_the_last_answer() {
let (port, _) = serve(vec![ok("", EMPTY), status(401)]);
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
assert_eq!(Poller::new(&server, None, true).unwrap().poll(), Poll::Refused);
let (port, _) = serve(vec![
ok("", EMPTY),
ok("", &approvals(&[PERMISSION])),
ok("", EMPTY),
status(500),
]);
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
let mut poller = Poller::new(&server, None, true).unwrap();
let red = Poll::Seen(Activity {
needs_you: true,
..Activity::default()
});
assert_eq!(poller.poll(), red);
assert_eq!(
poller.poll(),
Poll::Seen(Activity {
needs_you: true,
..Activity::default()
})
);
}
#[test]
fn no_alert_with_a_style_means_no_second_request() {
let (port, seen_requests) = serve(vec![ok("", EMPTY)]);
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
assert_eq!(Poller::new(&server, None, false).unwrap().poll(), plain(false));
assert_eq!(seen_requests.lock().unwrap().len(), 1);
}
fn ok(extra_headers: &str, body: &str) -> String {
format!(
"HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: {}\r\n{extra_headers}\r\n{body}",
body.len()
)
}
#[test]
fn keeps_one_login_session_across_polls() {
let body = r#"{"success":true,"data":[{"status":"busy","mode":"claude"}]}"#;
let (port, seen) = serve(vec![
ok("Set-Cookie: codeman_session=first; Max-Age=86400; Path=/\r\n", body),
ok("Set-Cookie: codeman_session=first; Max-Age=86400; Path=/\r\n", body),
ok("Set-Cookie: codeman_session=second; Max-Age=86400; Path=/\r\n", EMPTY),
ok("", EMPTY),
]);
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
let mut poller = Poller::new(&server, Some("Basic abc".into()), false).unwrap();
assert_eq!(poller.poll(), plain(true));
assert_eq!(poller.poll(), plain(true));
assert_eq!(poller.poll(), plain(false));
assert_eq!(poller.poll(), plain(false));
let seen = seen.lock().unwrap();
let cookie = |n: usize| seen[n].iter().find(|h| h.starts_with("cookie:")).cloned();
assert_eq!(cookie(0), None);
assert_eq!(cookie(1).as_deref(), Some("cookie: codeman_session=first"));
assert_eq!(cookie(2).as_deref(), Some("cookie: codeman_session=first"));
// A server restart hands out a new one; a response without one keeps it.
assert_eq!(cookie(3).as_deref(), Some("cookie: codeman_session=second"));
assert!(seen.iter().all(|h| h.contains(&"authorization: basic abc".to_string())));
assert!(seen[0][0].starts_with("get /api/sessions "), "{:?}", seen[0][0]);
}
#[test]
fn asks_under_the_servers_base_path() {
let (port, seen) = serve(vec![ok("", EMPTY)]);
let server = Url::parse(&format!("http://127.0.0.1:{port}/codeman/")).unwrap();
assert_eq!(Poller::new(&server, None, false).unwrap().poll(), plain(false));
let seen = seen.lock().unwrap();
assert!(seen[0][0].starts_with("get /codeman/api/sessions "), "{:?}", seen[0][0]);
assert!(!seen[0].iter().any(|h| h.starts_with("authorization:")));
}
#[test]
fn a_server_that_answers_nothing_is_only_a_failed_poll() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
assert_eq!(Poller::new(&server, None, false).unwrap().poll(), Poll::Failed);
}
}
+5 -31
View File
@@ -184,21 +184,17 @@ pub const HOST_SHIM: &str = r#"(function () {
}
})();"#;
/// A toast inside Codeman (its own `showToast`; nothing happens on a page
/// without it). `kind` is one of Codeman's fixed toast kinds.
pub fn toast_script(text: &str, kind: &str) -> String {
let text = serde_json::to_string(text).unwrap_or_else(|_| "\"\"".into());
format!("try {{ window.app && window.app.showToast && window.app.showToast({text}, \"{kind}\"); }} catch (e) {{}}")
}
/// A toast inside Codeman when a download finished.
/// A toast inside Codeman when a download finished (Codeman's own
/// `showToast`; nothing happens on a page without it).
pub fn download_toast_script(file_name: &str, ok: bool) -> String {
let text = if ok {
format!("Downloaded {file_name} to Downloads")
} else {
format!("Download of {file_name} failed")
};
toast_script(&text, if ok { "success" } else { "error" })
let text = serde_json::to_string(&text).unwrap_or_else(|_| "\"Download finished\"".into());
let kind = if ok { "\"success\"" } else { "\"error\"" };
format!("try {{ window.app && window.app.showToast && window.app.showToast({text}, {kind}); }} catch (e) {{}}")
}
/// Runs in the window that asked for a window the native side did not open
@@ -286,28 +282,6 @@ mod tests {
assert!(!HOST_SHIM.contains("decodeURIComponent"));
}
/// The connect page refuses what the app would drop without an answer, so
/// its two limits have to be the app's own.
#[test]
fn connect_page_keeps_the_message_limits() {
let page = String::from_utf8_lossy(CONNECT_HTML);
for needle in [
format!("var MAX_URL = {};", crate::policy::MAX_URL_LEN),
format!("var MAX_FIELD = {};", crate::policy::MAX_FIELD_LEN),
] {
assert!(page.contains(&needle), "{needle}");
}
}
/// Copy puts setup::INSTALL_ONE_LINER on the clipboard, and the page shows
/// the command as text of its own: the two have to be the same command.
#[test]
fn connect_page_shows_the_command_that_copy_copies() {
let page = String::from_utf8_lossy(CONNECT_HTML);
let shown = format!("<code id=\"oneliner\">{}</code>", crate::setup::INSTALL_ONE_LINER);
assert!(page.contains(&shown), "{shown}");
}
#[test]
fn toast_escapes_file_names() {
let js = download_toast_script("a\"); alert(1); (\".txt", true);
+2 -139
View File
@@ -3,7 +3,6 @@
//! (`~/Library/Application Support/Codeman/desktop.json` on macOS). Passwords
//! never go here; they live in the Keychain (see `credentials`).
use crate::logo::BlinkStyles;
use serde::{Deserialize, Serialize};
use std::fs;
use std::io;
@@ -26,14 +25,10 @@ const TITLE_ABOVE_Y: f64 = if cfg!(target_os = "macos") { TITLE_BAR } else { 0.0
/// How much of the title bar must be on a screen to grab it.
const MIN_GRIP: f64 = 80.0;
/// Room `fitted_to` leaves when it shrinks a window. Screens are full display
/// bounds, so the menu bar and the Dock are inside them.
/// bounds, so the menu bar and the Dock are inside them; these are the margins
/// main.rs uses for the centred default frame.
const FIT_MARGIN_X: f64 = 40.0;
const FIT_MARGIN_Y: f64 = 80.0;
/// Room a first-run window leaves above its content (the menu bar, taller on
/// a display with a notch, and its own title bar) and below it (a Dock of the
/// default size): `Rect` is the whole display, so neither is taken off yet.
const FIRST_RUN_ROOM_ABOVE: f64 = 66.0;
const FIRST_RUN_ROOM_BELOW: f64 = 90.0;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default, rename_all = "camelCase")]
@@ -44,33 +39,15 @@ pub struct Config {
pub auto_connect: bool,
/// Always open in full screen (otherwise the last state is restored).
pub start_fullscreen: bool,
/// What the lit Apple logo does for each state of the agents (logo.rs). The
/// menu only shows on a Mac that has one. A value this version does not know
/// reads as off: a bad style must not make the whole file unreadable.
#[serde(deserialize_with = "lenient_blink")]
pub logo_blink: BlinkStyles,
/// Anything that is not a whole frame reads as None: a bad frame must not
/// make the whole file unreadable, which would cost the server list.
#[serde(deserialize_with = "lenient_window")]
pub window: Option<WindowFrame>,
}
fn lenient_window<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<WindowFrame>, D::Error> {
let value = serde_json::Value::deserialize(d)?;
Ok(serde_json::from_value(value).ok())
}
fn lenient_blink<'de, D: serde::Deserializer<'de>>(d: D) -> Result<BlinkStyles, D::Error> {
Ok(BlinkStyles::read(&serde_json::Value::deserialize(d)?))
}
impl Default for Config {
fn default() -> Self {
Config {
servers: Vec::new(),
auto_connect: true,
start_fullscreen: false,
logo_blink: BlinkStyles::default(),
window: None,
}
}
@@ -115,23 +92,6 @@ pub struct Rect {
}
impl WindowFrame {
/// The frame of a first-run window on `screen`: `size` or what fits, never
/// less than `min`, centred across and in the height between the menu bar
/// and the Dock. Centring in the whole display put the bottom of a window
/// under the Dock on a 900 pt high one.
pub fn first_run(screen: &Rect, size: (f64, f64), min: (f64, f64)) -> WindowFrame {
let width = size.0.min(screen.width - FIT_MARGIN_X).max(min.0);
let room = screen.height - FIRST_RUN_ROOM_ABOVE - FIRST_RUN_ROOM_BELOW;
let height = size.1.min(room).max(min.1);
WindowFrame {
x: screen.x + (screen.width - width) / 2.0,
y: screen.y + FIRST_RUN_ROOM_ABOVE + ((room - height) / 2.0).max(0.0),
width,
height,
fullscreen: false,
}
}
/// The frame clamped to sane sizes, or None when it holds junk.
pub fn sanitized(self) -> Option<WindowFrame> {
let vals = [self.x, self.y, self.width, self.height];
@@ -410,32 +370,6 @@ mod tests {
assert!(can_save);
}
#[test]
fn the_logo_blink_styles_round_trip_and_a_bad_one_reads_as_off() {
use crate::logo::BlinkStyle;
let mut c = Config::default();
assert_eq!(c.logo_blink, BlinkStyles::default());
c.logo_blink.needs_you = BlinkStyle::Alarm;
let text = serde_json::to_string(&c).unwrap();
assert!(text.contains(r#""needsYou":"alarm""#), "{text}");
assert_eq!(serde_json::from_str::<Config>(&text).unwrap().logo_blink, c.logo_blink);
for bad in [
r#"{"logoBlink":3}"#,
r#"{"logoBlink":null}"#,
r#"{"logoBlink":"strobe"}"#,
] {
let c: Config = serde_json::from_str(bad).unwrap();
assert_eq!(c.logo_blink, BlinkStyles::default(), "{bad}");
}
}
#[test]
fn the_style_a_mac_saved_before_there_were_states_is_kept() {
use crate::logo::BlinkStyle;
let c: Config = serde_json::from_str(r#"{"logoBlink":"heartbeat"}"#).unwrap();
assert_eq!(c.logo_blink.working, BlinkStyle::Heartbeat);
}
#[test]
fn round_trip_and_atomic_save() {
let dir = tmp_dir("roundtrip");
@@ -692,77 +626,6 @@ mod tests {
assert_eq!(c.servers[0].username, None);
}
#[test]
fn a_bad_window_frame_keeps_the_servers() {
for (name, window) in [
("missing-height", r#"{"x":1,"y":2,"width":3}"#),
("not-a-frame", r#""maximized""#),
("null-number", r#"{"x":null,"y":2,"width":3,"height":4}"#),
] {
let dir = tmp_dir(&format!("bad-window-{name}"));
fs::create_dir_all(&dir).unwrap();
let path = dir.join("desktop.json");
fs::write(
&path,
format!(r#"{{"servers":[{{"url":"http://a/"}}],"autoConnect":false,"window":{window}}}"#),
)
.unwrap();
let (c, can_save) = Config::load_from(&path);
assert!(can_save, "{name}");
assert_eq!(c.servers.len(), 1, "{name}");
assert!(!c.auto_connect, "{name}");
assert_eq!(c.window, None, "{name}");
// The file was read, so it stays where it is.
assert!(path.is_file(), "{name}");
assert!(!path.with_extension("json.corrupt").exists(), "{name}");
}
// A whole frame and an absent one read as before.
let c: Config =
serde_json::from_str(r#"{"window":{"x":1,"y":2,"width":900,"height":600,"fullscreen":true}}"#).unwrap();
assert_eq!(c.window.map(|f| (f.width, f.fullscreen)), Some((900.0, true)));
assert_eq!(serde_json::from_str::<Config>("{}").unwrap().window, None);
assert_eq!(
serde_json::from_str::<Config>(r#"{"window":null}"#).unwrap().window,
None
);
}
#[test]
fn first_run_window_sits_between_the_menu_bar_and_the_dock() {
let screen = |width: f64, height: f64| Rect {
x: 0.0,
y: 0.0,
width,
height,
};
let size = (1280.0, 820.0);
let min = (480.0, 360.0);
// A 13 inch MacBook: the window is shortened, and ends above the Dock.
let f = WindowFrame::first_run(&screen(1440.0, 900.0), size, min);
assert_eq!((f.width, f.height), (1280.0, 744.0));
assert_eq!((f.x, f.y), (80.0, 66.0));
assert!(f.y + f.height <= 900.0 - 90.0);
// A large display: the full size, centred across and in the free height.
let f = WindowFrame::first_run(&screen(2560.0, 1440.0), size, min);
assert_eq!((f.width, f.height), (1280.0, 820.0));
assert_eq!(f.x, 640.0);
assert_eq!(f.y - 66.0, 1440.0 - 90.0 - (f.y + f.height));
// A second display to the left of the first.
let left = Rect {
x: -1920.0,
y: 120.0,
width: 1920.0,
height: 1080.0,
};
let f = WindowFrame::first_run(&left, size, min);
assert_eq!(f.x, -1920.0 + 320.0);
assert!(f.y >= 120.0 + 66.0 && f.y + f.height <= 120.0 + 1080.0 - 90.0);
// Never smaller than the minimum, on a display too small for it too.
let f = WindowFrame::first_run(&screen(500.0, 400.0), size, min);
assert_eq!((f.width, f.height), min);
assert!(!f.fullscreen);
}
#[test]
fn frame_sanitizing_and_reachability() {
assert!(frame(f64::NAN, 0.0, 800.0, 600.0).sanitized().is_none());
+11 -21
View File
@@ -189,11 +189,9 @@ mod imp {
}
}
/// A Keychain on an in-memory store, for these tests and for the tests of the
/// code that uses a Keychain (main.rs).
#[cfg(test)]
pub(crate) mod memory {
use super::{start, Backend, Keychain};
mod tests {
use super::{start, Backend, Job, Keychain};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
@@ -202,7 +200,7 @@ pub(crate) mod memory {
/// purpose: a queue that did not keep order would let the next job
/// overtake it, and the tests would see that.
#[derive(Clone, Default)]
pub(crate) struct Memory {
struct Memory {
items: Arc<Mutex<HashMap<String, String>>>,
log: Arc<Mutex<Vec<String>>>,
}
@@ -211,8 +209,7 @@ pub(crate) mod memory {
fn note(&self, entry: String) {
self.log.lock().unwrap().push(entry);
}
/// The jobs the store ran, in order.
pub(crate) fn log(&self) -> Vec<String> {
fn log(&self) -> Vec<String> {
self.log.lock().unwrap().clone()
}
}
@@ -237,20 +234,6 @@ pub(crate) mod memory {
}
}
/// A worker on a fresh store, and the store to look into.
pub(crate) fn keychain() -> (Keychain, Memory) {
let memory = Memory::default();
let (keychain, worker) = start(Box::new(memory.clone()));
assert!(worker.is_some(), "the worker thread starts");
(keychain, memory)
}
}
#[cfg(test)]
mod tests {
use super::memory::{keychain, Memory};
use super::{start, Backend, Job, Keychain};
/// A store whose load fails the way a crashed worker would.
struct Crashes;
@@ -264,6 +247,13 @@ mod tests {
fn delete(&mut self, _account: &str) {}
}
fn keychain() -> (Keychain, Memory) {
let memory = Memory::default();
let (keychain, worker) = start(Box::new(memory.clone()));
assert!(worker.is_some(), "the worker thread starts");
(keychain, memory)
}
#[test]
fn account_names_are_per_user_and_server() {
assert_eq!(
File diff suppressed because it is too large. Load diff
+27 -48
View File
@@ -91,10 +91,10 @@ fn flavor(shell: &Path) -> Flavor {
}
/// What one process printed, stdout and stderr kept apart.
struct Output {
code: Option<i32>,
stdout: String,
stderr: String,
pub struct Output {
pub code: Option<i32>,
pub stdout: String,
pub stderr: String,
}
/// What `codeman` (or `SCRIPT` failing to find it) said, for the connect page.
@@ -107,10 +107,6 @@ impl From<Output> for Run {
fn from(out: Output) -> Self {
let mut output = out.stdout;
if !out.stderr.trim().is_empty() {
// On a line of its own, also when stdout ended without a newline.
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
output.push_str(&out.stderr);
}
Run {
@@ -143,8 +139,18 @@ fn pump<R: Read + Send + 'static>(pipe: Option<R>, stream: usize, tx: mpsc::Send
});
}
/// Run `cmd` to completion with stdin closed, or kill it at `deadline`.
fn exec(mut cmd: Command, deadline: Instant) -> Result<Output, String> {
/// `exec_bounded` for a start.
fn exec(cmd: Command, deadline: Instant) -> Result<Output, String> {
exec_bounded(
cmd,
deadline,
"Starting Codeman took longer than a minute; it was stopped.",
)
}
/// Run `cmd` to completion with stdin closed, or kill it at `deadline` and
/// answer `timed_out`.
pub fn exec_bounded(mut cmd: Command, deadline: Instant, timed_out: &str) -> Result<Output, String> {
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd
.spawn()
@@ -155,20 +161,14 @@ fn exec(mut cmd: Command, deadline: Instant) -> Result<Output, String> {
pump(child.stdout.take(), 0, tx.clone());
pump(child.stderr.take(), 1, tx);
// A login shell usually answers within tens of milliseconds: look often
// at first, then back off.
let mut nap = Duration::from_millis(5);
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if Instant::now() < deadline => {
std::thread::sleep(nap);
nap = (nap * 2).min(Duration::from_millis(100));
}
Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(100)),
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
return Err("Starting Codeman took longer than a minute; it was stopped.".into());
return Err(timed_out.to_string());
}
Err(e) => return Err(e.to_string()),
}
@@ -275,14 +275,15 @@ fn capture_env(
}))
}
/// Run `cmd` in the home folder, where a terminal and the LaunchAgent start
/// Codeman too. The server keeps the directory it was started in and uses it
/// as the default for a session that names none; an app opened from the Finder
/// or the Dock runs in `/`.
fn in_home(cmd: &mut Command) {
if let Some(home) = dirs::home_dir() {
cmd.current_dir(home);
}
/// The login shell's environment, for a process the app runs without it (the
/// graphical install). `None` when the shell reports none.
pub fn login_environment() -> Option<Vec<(String, String)>> {
let shell = login_shell();
let flags: &[&str] = match flavor(&shell) {
Flavor::Posix => &["-l", "-c"],
Flavor::Capture(attempts) => attempts.first().copied().unwrap_or(&["-c"]),
};
capture_env(&shell, flags, Instant::now() + FIND_TIMEOUT).ok()?.ok()
}
/// `script` (SCRIPT or FIND_SCRIPT) under `/bin/sh` with exactly the captured
@@ -292,7 +293,6 @@ fn run_with_env(script: &str, env: &[(String, String)], args: &[String], deadlin
let mut cmd = Command::new("/bin/sh");
cmd.arg("-c").arg(script).arg("codeman-desktop").args(args);
cmd.env_clear().envs(env.iter().map(|(k, v)| (k, v)));
in_home(&mut cmd);
exec(cmd, deadline).map(Run::from)
}
@@ -310,7 +310,6 @@ fn run_in_shell(
cmd.arg("-i");
}
cmd.arg("-c").arg(script).arg("codeman-desktop").args(args);
in_home(&mut cmd);
exec(cmd, deadline).map(Run::from)
}
@@ -422,26 +421,6 @@ mod tests {
assert_eq!(last_lines("", 3), "");
}
#[test]
fn stderr_starts_on_a_line_of_its_own() {
let said = |stdout: &str, stderr: &str| {
Run::from(Output {
code: Some(1),
stdout: stdout.into(),
stderr: stderr.into(),
})
.output
};
// stdout that ended without a newline used to be glued to stderr.
assert_eq!(
said("Starting Codeman", "Error: port 3000 is in use\n"),
"Starting Codeman\nError: port 3000 is in use"
);
assert_eq!(said("Starting\n", "Error\n"), "Starting\nError");
assert_eq!(said("", "Error\n"), "Error");
assert_eq!(said("Started\n", " \n"), "Started");
}
#[test]
fn refuses_non_local_or_https() {
assert!(start(&Url::parse("https://127.0.0.1:3000/").unwrap()).is_err());
File diff suppressed because it is too large. Load diff
+24 -30
View File
@@ -161,17 +161,14 @@ fn origin_label(host: &str, port: i64) -> Option<String> {
}
/// The bold first line of a dialog, like Safari's, so a frame cannot pass its
/// text off as the app's own: the page's text always goes below it. `frame` is
/// the host that names the frame and whether it is embedded, which a sandboxed
/// frame is (an HTML file preview, an untrusted web tab): its origin is opaque,
/// so the host is only the server it was loaded from, and it must not read as
/// Codeman speaking. `None` is a frame nothing names: an opaque origin whose
/// document has no host either (`about:srcdoc`, a `data:` or `blob:` URL).
fn dialog_title(frame: Option<(&str, bool)>) -> String {
match frame {
Some((origin, false)) => format!("The page at {origin} says:"),
Some((origin, true)) => format!("An embedded page on {origin} says:"),
None => "An embedded page says:".to_string(),
/// text off as the app's own. `embedded` is a sandboxed frame (an HTML file
/// preview, an untrusted web tab): its origin is opaque, so `origin` is only
/// the server it was loaded from, and it must not read as Codeman speaking.
fn dialog_title(origin: &str, embedded: bool) -> String {
if embedded {
format!("An embedded page on {origin} says:")
} else {
format!("The page at {origin} says:")
}
}
@@ -577,11 +574,13 @@ mod imp {
Some(message) => clipped(message),
None => NSString::from_str(""),
};
// The bold line is always the app's own; the page's text goes below.
let named = frame.and_then(frame_origin);
let title = dialog_title(named.as_ref().map(|(origin, embedded)| (origin.as_str(), *embedded)));
alert.setMessageText(&NSString::from_str(&title));
alert.setInformativeText(&body);
match frame.and_then(frame_origin) {
Some((origin, embedded)) => {
alert.setMessageText(&NSString::from_str(&dialog_title(&origin, embedded)));
alert.setInformativeText(&body);
}
None => alert.setMessageText(&body),
}
let _ = alert.addButtonWithTitle(&NSString::from_str("OK"));
if panel != Panel::Alert {
let _ = alert.addButtonWithTitle(&NSString::from_str("Cancel"));
@@ -624,18 +623,16 @@ mod imp {
}
/// The host (and port) of the frame that opened a dialog: its security
/// origin, else its request URL's host. The flag says "embedded". It is
/// set for every sub-frame, whatever its origin (a trusted web tab is
/// proxied under Codeman's own origin and would read as Codeman
/// otherwise), and whenever the origin names no host, which is an opaque
/// origin (a sandboxed frame) or no origin at all: a frame the app is
/// unsure about never speaks as the page.
/// origin, else its request URL's host. The flag is set when the origin is
/// opaque (a sandboxed frame), where only the URL names a host.
fn frame_origin(frame: &WKFrameInfo) -> Option<(String, bool)> {
autoreleasepool(|_| {
// SAFETY (every read here): property reads on objects WebKit handed
// us, with nil handled by the `Option` return types.
let sub_frame = !unsafe { frame.isMainFrame() };
let origin: Option<Retained<WKSecurityOrigin>> = unsafe { msg_send![frame, securityOrigin] };
// An origin without a host is opaque; when unsure, say "embedded"
// rather than let a frame speak as the page.
let opaque = origin.is_some();
if let Some(origin) = origin {
let host: Option<Retained<NSString>> = unsafe { msg_send![&*origin, host] };
let port: NSInteger = unsafe { msg_send![&*origin, port] };
@@ -644,7 +641,7 @@ mod imp {
.and_then(rust_string)
.and_then(|h| origin_label(&h, port as i64))
{
return Some((label, sub_frame));
return Some((label, false));
}
}
let request: Option<Retained<NSURLRequest>> = unsafe { msg_send![frame, request] };
@@ -653,7 +650,7 @@ mod imp {
let url = url?;
let host: Option<Retained<NSString>> = unsafe { msg_send![&*url, host] };
let host = host?;
Some((origin_label(&rust_string(&host)?, 0)?, true))
Some((origin_label(&rust_string(&host)?, 0)?, opaque))
})
}
@@ -1045,15 +1042,12 @@ mod tests {
assert_eq!(origin_label("::1", 3000).as_deref(), Some("[::1]:3000"));
assert_eq!(origin_label(" ", 3000), None);
assert_eq!(
dialog_title(Some(("localhost:3000", false))),
dialog_title("localhost:3000", false),
"The page at localhost:3000 says:"
);
assert_eq!(
dialog_title(Some(("localhost:3000", true))),
dialog_title("localhost:3000", true),
"An embedded page on localhost:3000 says:"
);
// A frame nothing names (about:srcdoc under an opaque origin) still
// gets a first line of the app's own, never its own text in bold.
assert_eq!(dialog_title(None), "An embedded page says:");
}
}
+177 -408
View File
@@ -6,9 +6,8 @@
//! installs Codeman on this machine (`setup`, `local_server`) and installs it
//! on another one over SSH, real full screen, native menus, pop-outs as app
//! windows (Codeman's `window.CodemanHost` bridge), downloads to ~/Downloads,
//! passwords in the Keychain, on macOS the JavaScript dialogs and HTTP Basic
//! challenges wry leaves out (`macos`), and on a MacBook Pro with a lit Apple
//! logo a blink that says what the agents are doing (`logo`).
//! passwords in the Keychain, and on macOS the JavaScript dialogs and HTTP
//! Basic challenges wry leaves out (`macos`).
//!
//! Every URL and message decision is in `policy` (pure, unit-tested); this file
//! only wires those decisions to windows. The main window moves through
@@ -20,12 +19,11 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod agents;
mod assets;
mod config;
mod credentials;
mod gui_install;
mod local_server;
mod logo;
#[cfg(target_os = "macos")]
mod macos;
mod policy;
@@ -37,7 +35,8 @@ use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use std::process::Command;
use std::rc::Rc;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use muda::accelerator::{Accelerator, Code, Modifiers};
@@ -54,8 +53,7 @@ use wry::{NewWindowResponse, PageLoadEvent, PermissionResponse, WebView, WebView
use config::{Config, Rect, WindowFrame};
use credentials::Keychain;
use logo::{BlinkStyle, Signal};
use policy::{ConnectCmd, HostCmd, Ipc, Navigation, NewWindow, OpenBudget, Phase, Recovery, Via};
use policy::{ConnectCmd, HostCmd, Ipc, Navigation, NewWindow, OpenBudget, Phase, Via};
use probe::ProbeOutcome;
const APP_NAME: &str = "Codeman";
@@ -64,9 +62,6 @@ const DEFAULT_SIZE: (f64, f64) = (1280.0, 820.0);
const POPOUT_SIZE: (f64, f64) = (960.0, 680.0);
const MIN_SIZE: (f64, f64) = (480.0, 360.0);
const MAX_POPOUTS: usize = 24;
/// How far a pop-out opens down and to the right of the window in use, per
/// pop-out already open (it starts over after eight).
const POPOUT_STEP: f64 = 28.0;
const SAVE_DEBOUNCE: Duration = Duration::from_secs(1);
/// How long a server load may take to commit before the app gives up on it
/// and brings the connect page back.
@@ -75,10 +70,6 @@ const LOAD_TIMEOUT: Duration = Duration::from_secs(15);
/// page reported that its session is gone, so a login the server keeps
/// refusing can never turn into a reload loop.
const SESSION_RELOAD_COOLDOWN: Duration = Duration::from_secs(60);
/// A window loads its page again at most once in this long after its web
/// content process ended, so a page that cannot stay up never becomes a reload
/// loop.
const CRASH_RELOAD_COOLDOWN: Duration = Duration::from_secs(30);
/// When the app first looks whether the main window made it into the full
/// screen it asked for at launch, how often it asks again, and how many times.
/// tao retries a failed entry only for a window BUILT in full screen, which
@@ -99,12 +90,11 @@ const AUTH_REJECTED: &str = "The server did not accept the saved login. Enter th
const AUTH_RATE_LIMITED: &str = "Too many failed logins from this address. The saved password may be wrong: \
enter it again (a single-user Codeman accepts the right password at once), or wait up to 15 minutes.";
const KEYCHAIN_UNREADABLE: &str = "The saved password could not be read from the Keychain. Enter it again.";
const PAGE_KEEPS_STOPPING: &str = "Codeman's page stopped twice in a row, so it was closed. Connect again.";
const LOGO_SWITCHED_OFF: &str = "The Apple logo is switched off on this Mac, so it cannot blink.";
const START_REFUSED: &str = "Codeman can only be started here for an http://127.0.0.1 or http://localhost address.";
mod menu_id {
pub const SWITCH: &str = "switch-server";
pub const ADD_AGENT: &str = "add-agent";
pub const RELOAD: &str = "reload";
pub const START_FULLSCREEN: &str = "start-fullscreen";
pub const DEVTOOLS: &str = "devtools";
@@ -170,12 +160,6 @@ enum UserEvent {
/// The challenge handler sent its login and got challenged again (macOS).
/// Not trusted on its own: recheck_login asks the server first.
AuthRejected,
/// The web content process of a window ended (macOS): a crash, or the
/// system taking it back under memory pressure. The window is blank until
/// something loads in it again (on_content_process_gone).
ContentProcessGone {
window: WindowId,
},
/// A background step for the connect page finished (This Mac status, an
/// install opened in Terminal, the install command copied): the message
/// goes to the page if it is still the one choosing a server.
@@ -230,8 +214,6 @@ struct AppWindow {
committed: Option<Url>,
/// When the window last reloaded itself because its session expired.
session_reload_at: Option<Instant>,
/// When the window's web content process last ended.
content_process_gone_at: Option<Instant>,
}
struct AppMenu {
@@ -239,9 +221,6 @@ struct AppMenu {
window_menu: Submenu,
help_menu: Submenu,
start_fullscreen: CheckMenuItem,
/// One item per state and style. Empty on a Mac without a lit logo, which has no
/// such menu.
logo_styles: Vec<(Signal, BlinkStyle, CheckMenuItem)>,
}
enum Load<'a> {
@@ -256,10 +235,7 @@ struct App {
/// aside, in which case it must not be overwritten.
config_path: Option<PathBuf>,
config_dirty: bool,
/// The settings are written SAVE_DEBOUNCE after this: the last write, or
/// the last move or resize of the main window, so a drag writes the file
/// once when it ends, not once a second while it goes on.
save_held_since: Instant,
last_save: Instant,
/// Where the main window is in reaching a server. Shared with the web view
/// handlers, which run on this same thread.
phase: Rc<RefCell<Phase>>,
@@ -294,19 +270,17 @@ struct App {
menu: AppMenu,
/// The one Keychain worker; every Keychain job goes through its queue.
keychain: Keychain,
/// Which reader of the install's output is the current one.
install_reader: Arc<AtomicU64>,
/// The connect page opens the agent chooser when it next loads: the menu
/// asked for it from a Codeman page.
add_agent_on_load: bool,
/// While the main window may still have to be put into the full screen it
/// asked for at launch: when to look next, and how many retries were made.
fullscreen_retry: Option<(Instant, u8)>,
/// The child that watches the connected server and blinks the logo.
logo_watch: logo::Watcher,
}
fn main() {
// The blinker is this binary without a window (logo.rs).
if std::env::args().nth(1).as_deref() == Some(logo::BLINK_ARG) {
logo::blink_main();
}
let event_loop = EventLoopBuilder::<UserEvent>::with_user_event().build();
let proxy = event_loop.create_proxy();
@@ -335,7 +309,6 @@ fn main() {
};
let config_path = config_path.filter(|_| can_save);
let notice = (!can_save).then(|| SETTINGS_UNREADABLE.to_string());
logo::recover_at_launch();
let menu = build_menu(&config).unwrap_or_else(|e| fatal(&format!("Could not build the menu: {e}")));
let phase = Rc::new(RefCell::new(Phase::Connect));
// Started once: the probe workers and the event loop share this queue,
@@ -344,7 +317,7 @@ fn main() {
let (window, wants_fullscreen) =
create_main_window(&event_loop, &config).unwrap_or_else(|e| fatal(&format!("Could not open a window: {e}")));
let webview = build_webview(&window, true, true, Load::ConnectPage { manual: false }, &proxy, &phase)
let webview = build_webview(&window, true, Load::ConnectPage { manual: false }, &proxy, &phase)
.unwrap_or_else(|e| fatal(&format!("Could not create the web view: {e}")));
#[cfg(target_os = "macos")]
macos::install(&webview);
@@ -359,7 +332,6 @@ fn main() {
kind: Kind::Main,
committed: None,
session_reload_at: None,
content_process_gone_at: None,
},
);
@@ -368,7 +340,7 @@ fn main() {
config,
config_path,
config_dirty: false,
save_held_since: Instant::now(),
last_save: Instant::now(),
phase,
loading_deadline: None,
auth: None,
@@ -386,8 +358,9 @@ fn main() {
downloads: HashMap::new(),
menu,
keychain,
install_reader: Arc::new(AtomicU64::new(0)),
add_agent_on_load: false,
fullscreen_retry: wants_fullscreen.then(|| (Instant::now() + FULLSCREEN_FIRST_CHECK, 0)),
logo_watch: logo::Watcher::default(),
};
event_loop.run(move |event, target, control_flow| app.handle(event, target, control_flow));
@@ -423,6 +396,7 @@ fn build_menu(config: &Config) -> muda::Result<AppMenu> {
&about,
&PredefinedMenuItem::separator(),
&switch,
&MenuItem::with_id(menu_id::ADD_AGENT, "Add a Coding Agent to This Mac\u{2026}", true, None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::services(None),
&PredefinedMenuItem::separator(),
@@ -476,26 +450,6 @@ fn build_menu(config: &Config) -> muda::Result<AppMenu> {
),
],
)?;
// Only a Mac with a lit Apple logo has this menu at all.
let mut logo_styles = Vec::new();
if logo::available() {
let submenu = Submenu::new("Blink Apple Logo", true);
for signal in Signal::ALL {
let group = Submenu::new(signal.label(), true);
for style in BlinkStyle::ALL {
let chosen = style == config.logo_blink.get(signal);
let item = CheckMenuItem::with_id(style.menu_id(signal), style.label(), true, chosen, None);
group.append(&item)?;
if style == BlinkStyle::Off {
group.append(&PredefinedMenuItem::separator())?;
}
logo_styles.push((signal, style, item));
}
submenu.append(&group)?;
}
// Right under "Always Open in Full Screen", before the separator.
view.insert(&submenu, 4)?;
}
let window_menu = Submenu::with_items(
"Window",
true,
@@ -522,25 +476,24 @@ fn build_menu(config: &Config) -> muda::Result<AppMenu> {
window_menu,
help_menu,
start_fullscreen,
logo_styles,
})
}
/// A display's whole area in points (the menu bar and the Dock are inside it).
fn display_rect(monitor: &tao::monitor::MonitorHandle) -> Rect {
let sf = monitor.scale_factor();
let p = monitor.position().to_logical::<f64>(sf);
let s = monitor.size().to_logical::<f64>(sf);
Rect {
x: p.x,
y: p.y,
width: s.width,
height: s.height,
}
}
fn monitors(target: &EventLoopWindowTarget<UserEvent>) -> Vec<Rect> {
target.available_monitors().map(|m| display_rect(&m)).collect()
target
.available_monitors()
.map(|m| {
let sf = m.scale_factor();
let p = m.position().to_logical::<f64>(sf);
let s = m.size().to_logical::<f64>(sf);
Rect {
x: p.x,
y: p.y,
width: s.width,
height: s.height,
}
})
.collect()
}
/// The main window, and whether it was asked into full screen (App's
@@ -563,10 +516,17 @@ fn create_main_window(
None => {
builder = builder.with_inner_size(LogicalSize::new(DEFAULT_SIZE.0, DEFAULT_SIZE.1));
if let Some(m) = target.primary_monitor() {
let f = WindowFrame::first_run(&display_rect(&m), DEFAULT_SIZE, MIN_SIZE);
let sf = m.scale_factor();
let p = m.position().to_logical::<f64>(sf);
let s = m.size().to_logical::<f64>(sf);
let w = DEFAULT_SIZE.0.min(s.width - 40.0);
let h = DEFAULT_SIZE.1.min(s.height - 80.0);
builder = builder
.with_inner_size(LogicalSize::new(f.width, f.height))
.with_position(LogicalPosition::new(f.x, f.y));
.with_inner_size(LogicalSize::new(w.max(MIN_SIZE.0), h.max(MIN_SIZE.1)))
.with_position(LogicalPosition::new(
p.x + (s.width - w) / 2.0,
p.y + (s.height - h) / 2.0,
));
}
}
}
@@ -647,13 +607,9 @@ fn loaded_on_commit(phase: &Phase, committed: &str) -> Option<Phase> {
policy::same_origin(&url, server).then(|| Phase::Loaded(server.clone()))
}
/// `focused`: the web view takes the keyboard and brings the app to the front
/// (wry's default). False for a window a script opened: it comes up without
/// pulling the user out of the app they are typing in.
fn build_webview(
window: &Window,
is_main: bool,
focused: bool,
load: Load<'_>,
proxy: &EventLoopProxy<UserEvent>,
phase: &Rc<RefCell<Phase>>,
@@ -662,7 +618,6 @@ fn build_webview(
let mut builder = WebViewBuilder::new()
.with_initialization_script(assets::HOST_SHIM)
.with_devtools(true)
.with_focused(focused)
.with_accept_first_mouse(true)
.with_back_forward_navigation_gestures(false)
// false = let WebKit take the drop, so Codeman's own HTML5 drop
@@ -770,17 +725,6 @@ fn build_webview(
});
});
// wry answers WebKit's "web content process ended" notice itself, which
// turns WebKit's own reload off: without a handler the window stays blank.
#[cfg(target_os = "macos")]
{
use wry::WebViewBuilderExtDarwin;
let gone_proxy = proxy.clone();
builder = builder.with_on_web_content_process_terminate_handler(move || {
let _ = gone_proxy.send_event(UserEvent::ContentProcessGone { window: id });
});
}
builder = match load {
Load::ConnectPage { manual: false } => builder.with_url(policy::CONNECT_PAGE),
Load::ConnectPage { manual: true } => builder.with_url(format!("{}?manual=1", policy::CONNECT_PAGE)),
@@ -829,7 +773,7 @@ impl App {
/// so it is only decided here, never reset per event.
fn on_events_cleared(&mut self, control_flow: &mut ControlFlow) {
let now = Instant::now();
if self.config_dirty && now >= self.save_held_since + SAVE_DEBOUNCE {
if self.config_dirty && now >= self.last_save + SAVE_DEBOUNCE {
self.save_config();
}
let loading = matches!(*self.phase.borrow(), Phase::Loading(_));
@@ -844,7 +788,7 @@ impl App {
if matches!(*control_flow, ControlFlow::ExitWithCode(_)) {
return;
}
let save_due = self.config_dirty.then(|| self.save_held_since + SAVE_DEBOUNCE);
let save_due = self.config_dirty.then(|| self.last_save + SAVE_DEBOUNCE);
let fullscreen_due = self.fullscreen_retry.map(|(due, _)| due);
*control_flow = match [save_due, self.loading_deadline, fullscreen_due]
.into_iter()
@@ -963,7 +907,6 @@ impl App {
if let Some(next) = next {
*self.phase.borrow_mut() = next;
}
self.sync_logo_watch();
}
if let Some(w) = self.windows.get_mut(&window) {
w.committed = Url::parse(&url).ok();
@@ -1041,45 +984,6 @@ impl App {
}
}
UserEvent::AuthRejected => self.recheck_login(),
UserEvent::ContentProcessGone { window } => self.on_content_process_gone(window),
}
}
/// The web content process of `window` ended, so the window is blank
/// (policy::recovery decides what it does about it). A second end within
/// CRASH_RELOAD_COOLDOWN counts as the same page failing again.
fn on_content_process_gone(&mut self, window: WindowId) {
let now = Instant::now();
let Some(w) = self.windows.get_mut(&window) else {
return;
};
// A pop-out hidden for its download shows no page, and the download
// does not run in that process: loading again would fetch it twice.
if matches!(
w.kind,
Kind::Popout {
hidden_for_download: true,
..
}
) {
return;
}
let again = w
.content_process_gone_at
.is_some_and(|at| now.saturating_duration_since(at) < CRASH_RELOAD_COOLDOWN);
w.content_process_gone_at = Some(now);
let on_server = self.phase.borrow().loaded().is_some();
match policy::recovery(window == self.main_id, on_server, again) {
Recovery::Reload => {
if let Some((url, with_auth)) = self.reload_target(window) {
self.load_in(window, &url, with_auth);
}
}
Recovery::ConnectPage { gave_up } => {
self.show_connect_page(gave_up.then(|| PAGE_KEEPS_STOPPING.to_string()), false);
}
Recovery::Close => self.remove_window(window),
Recovery::Nothing => eprintln!("codeman-desktop: a page stopped again and was left as it is"),
}
}
@@ -1141,44 +1045,10 @@ impl App {
}
}
/// The logo is watched while any style is chosen and a server is open.
fn sync_logo_watch(&mut self) {
let styles = self.config.logo_blink;
let phase = self.phase.borrow();
let server = phase.loaded().filter(|_| styles.any() && logo::available());
self.logo_watch
.sync(server.map(|url| (url.as_str(), self.auth.as_deref(), styles)));
}
fn choose_logo_blink(&mut self, signal: Signal, style: BlinkStyle) {
// A click flips the item it landed on, the chosen one included, so all
// of that state's are set.
for (item_signal, item_style, item) in &self.menu.logo_styles {
if *item_signal == signal {
item.set_checked(*item_style == style);
}
}
let was_off = !self.config.logo_blink.any();
self.config.logo_blink.set(signal, style);
self.config_dirty = true;
self.sync_logo_watch();
// Only from off: with a style running, a dark logo may be our own.
if was_off && style != BlinkStyle::Off && logo::switched_off() {
if let Some(main) = self.windows.get(&self.main_id) {
let _ = main
.webview
.evaluate_script(&assets::toast_script(LOGO_SWITCHED_OFF, "warning"));
}
}
}
fn on_menu(&mut self, id: &str) {
if let Some((signal, style)) = BlinkStyle::from_menu_id(id) {
self.choose_logo_blink(signal, style);
return;
}
match id {
menu_id::SWITCH => self.show_connect_page(None, true),
menu_id::ADD_AGENT => self.add_agent(),
menu_id::RELOAD => self.reload_focused(),
menu_id::START_FULLSCREEN => {
let value = self.menu.start_fullscreen.is_checked();
@@ -1316,6 +1186,76 @@ impl App {
});
}
}
ConnectCmd::InstallPrepare => {
if choosing {
self.in_background(|| match gui_install::prepare() {
Ok(plan) => json!({ "type": "installPlan", "plan": plan }),
Err(message) => json!({ "type": "installPrepareFailed", "message": message }),
});
}
}
ConnectCmd::InstallStart {
network_password,
ai_cli,
} => {
if choosing {
let proxy = self.proxy.clone();
let stale = self.next_install_reader();
std::thread::spawn(move || {
let plan = gui_install::Plan {
port: setup::pick_install_port(),
network_password,
ai_cli,
};
let send = |msg| {
let _ = proxy.send_event(UserEvent::ToConnectPage(msg));
};
match gui_install::start(&plan) {
Ok(()) => {
send(json!({ "type": "installRunning", "port": plan.port }));
gui_install::follow(gui_install::Run::Codeman, stale, send);
}
Err(message) => send(json!({ "type": "installStartFailed", "message": message })),
}
});
}
}
ConnectCmd::InstallAttach => {
if choosing {
self.attach_to_run(gui_install::Run::Codeman);
}
}
ConnectCmd::InstallCancel => {
std::thread::spawn(gui_install::cancel);
}
ConnectCmd::AgentPrepare => {
if choosing {
self.in_background(|| match gui_install::prepare_agent() {
Ok(plan) => json!({ "type": "installPlan", "plan": plan }),
Err(message) => json!({ "type": "installPrepareFailed", "message": message }),
});
}
}
ConnectCmd::AgentStart { ai_cli } => {
if choosing {
let proxy = self.proxy.clone();
let stale = self.next_install_reader();
std::thread::spawn(move || {
let send = |msg| {
let _ = proxy.send_event(UserEvent::ToConnectPage(msg));
};
match gui_install::start_agent(&ai_cli) {
Ok(()) => gui_install::follow(gui_install::Run::Agent, stale, send),
Err(message) => send(json!({ "type": "installStartFailed", "message": message })),
}
});
}
}
ConnectCmd::AgentAttach => {
if choosing {
self.attach_to_run(gui_install::Run::Agent);
}
}
ConnectCmd::InstallRemote { target } => {
if choosing {
match setup::SshTarget::parse(&target) {
@@ -1347,6 +1287,40 @@ impl App {
}
}
/// A new reader of the install's output replaces the one before it: the
/// answer is how that new reader learns it was replaced in turn.
/// Report a run that is going already to the page, from its first line.
fn attach_to_run(&self, run: gui_install::Run) {
let proxy = self.proxy.clone();
let stale = self.next_install_reader();
std::thread::spawn(move || {
gui_install::follow(run, stale, |msg| {
let _ = proxy.send_event(UserEvent::ToConnectPage(msg));
});
});
}
/// The agent chooser lives on the connect page: from a Codeman page, this
/// leaves it the way Switch Server does.
fn add_agent(&mut self) {
if matches!(*self.phase.borrow(), Phase::Connect) {
if let Some(main) = self.windows.get(&self.main_id) {
main.window.set_visible(true);
main.window.set_focus();
}
self.to_connect_page(json!({ "type": "addAgent" }));
} else {
self.add_agent_on_load = true;
self.show_connect_page(None, true);
}
}
fn next_install_reader(&self) -> impl Fn() -> bool + Send + 'static {
let generation = self.install_reader.clone();
let mine = generation.fetch_add(1, Ordering::SeqCst) + 1;
move || generation.load(Ordering::SeqCst) != mine
}
/// Run `work` off the event loop (it may run a login shell, plutil,
/// Terminal or a probe) and hand what it returns to the connect page.
fn in_background(&self, work: impl FnOnce() -> serde_json::Value + Send + 'static) {
@@ -1358,6 +1332,7 @@ impl App {
fn send_init(&mut self) {
let notice = self.notice.take();
let add_agent = std::mem::take(&mut self.add_agent_on_load);
self.to_connect_page(json!({
"type": "init",
"servers": self.config.servers,
@@ -1366,6 +1341,7 @@ impl App {
"version": VERSION,
"starting": self.start_running(),
"notice": notice,
"addAgent": add_agent,
}));
}
@@ -1409,8 +1385,25 @@ impl App {
.as_ref()
.and_then(|u| keychain.load_blocking(&credentials::account(&key, u))),
};
let (user, password, source) =
choose_login(user, typed_password, saved, expect_saved, || setup::local_login(&url));
let (user, password, source) = match typed_password {
Some(p) => (user, Some(p), PasswordSource::Typed),
None => match saved {
Some(p) => (user, Some(p), PasswordSource::Keychain),
// The Codeman on this Mac keeps its login where Codeman's
// own clients find it (setup::local_login: its launchd
// job, then ~/.codeman/.env), and only answers it at its
// own loopback address. Used when nothing was typed and
// the Keychain has nothing for this server, for the same
// username only, and never saved anywhere.
None if !expect_saved => match setup::local_login(&url) {
Some((u, p)) if user.as_deref().is_none_or(|current| current == u) => {
(Some(u), Some(p), PasswordSource::LocalServer)
}
_ => (user, None, PasswordSource::Absent),
},
None => (user, None, PasswordSource::Absent),
},
};
// Codeman's default username when only a password was given.
let username = password
.as_ref()
@@ -1697,33 +1690,14 @@ impl App {
}
let opened_url = policy::webkit_safe_url(url);
let auth = self.auth.clone().filter(|_| policy::within_base(url, &server));
// tao centres a window that is given no position, so every pop-out
// would open exactly on top of the one before. Each one steps off the
// window in use instead.
let position = self
.focused_window_id()
.or(Some(self.main_id))
.and_then(|id| self.windows.get(&id))
.and_then(|w| {
// The corner tao's with_position places (see record_main_frame).
#[cfg(target_os = "macos")]
let corner = w.window.inner_position();
#[cfg(not(target_os = "macos"))]
let corner = w.window.outer_position();
let p = corner.ok()?.to_logical::<f64>(w.window.scale_factor());
let step = POPOUT_STEP * (1 + popouts % 8) as f64;
Some(LogicalPosition::new(p.x + step, p.y + step))
});
let mut builder = WindowBuilder::new()
let window = match WindowBuilder::new()
.with_title(APP_NAME)
.with_theme(Some(Theme::Dark))
.with_inner_size(LogicalSize::new(POPOUT_SIZE.0, POPOUT_SIZE.1))
.with_min_inner_size(LogicalSize::new(MIN_SIZE.0, MIN_SIZE.1))
.with_focused(focused);
if let Some(position) = position {
builder = builder.with_position(position);
}
let window = match builder.build(target) {
.with_focused(focused)
.build(target)
{
Ok(window) => window,
Err(e) => {
eprintln!("codeman-desktop: could not open a window: {e}");
@@ -1734,15 +1708,8 @@ impl App {
url: &opened_url,
auth: auth.as_deref(),
};
let webview = match build_webview(&window, false, focused, load, &self.proxy, &self.phase) {
Ok(webview) => {
// Still the one that takes the keys once the user comes to
// this window: first responder in its own window only.
if !focused {
let _ = webview.focus();
}
webview
}
let webview = match build_webview(&window, false, load, &self.proxy, &self.phase) {
Ok(webview) => webview,
Err(e) => {
eprintln!("codeman-desktop: could not create a web view: {e}");
return false;
@@ -1761,7 +1728,6 @@ impl App {
},
committed: None,
session_reload_at: None,
content_process_gone_at: None,
},
);
true
@@ -1882,12 +1848,11 @@ impl App {
if self.config.window != Some(frame) {
self.config.window = Some(frame);
self.config_dirty = true;
self.save_held_since = Instant::now();
}
}
fn save_config(&mut self) {
self.save_held_since = Instant::now();
self.last_save = Instant::now();
self.config_dirty = false;
if let Some(path) = &self.config_path {
if let Err(e) = self.config.save_to(path) {
@@ -1917,38 +1882,6 @@ impl App {
}
/// A login the probe just tried, and what the user asked for it.
/// The login a connect goes out with, and where its password came from. A
/// typed password wins, then the one the Keychain holds for this user. Last
/// comes the login of the Codeman on this Mac, which keeps it where its own
/// clients find it (setup::local_login: its launchd job, then
/// ~/.codeman/.env) and only answers it at its own loopback address. That one
/// is used only when the config expected no password in the Keychain
/// (`expect_saved`: one that should be there and is not is reported, not
/// papered over), only for the username in use when there is one, and it is
/// never saved anywhere. `local` is asked only then: it runs processes.
fn choose_login(
user: Option<String>,
typed_password: Option<String>,
saved: Option<String>,
expect_saved: bool,
local: impl FnOnce() -> Option<(String, String)>,
) -> (Option<String>, Option<String>, PasswordSource) {
if let Some(password) = typed_password {
return (user, Some(password), PasswordSource::Typed);
}
if let Some(password) = saved {
return (user, Some(password), PasswordSource::Keychain);
}
if !expect_saved {
if let Some((local_user, password)) = local() {
if user.as_deref().is_none_or(|current| current == local_user) {
return (Some(local_user), Some(password), PasswordSource::LocalServer);
}
}
}
(user, None, PasswordSource::Absent)
}
struct ProbedLogin<'a> {
/// The server, as the config and the Keychain accounts key it.
server: &'a str,
@@ -2028,167 +1961,3 @@ fn update_keychain(keychain: &Keychain, outcome: &ProbeOutcome, login: &ProbedLo
update.saved = saved;
update
}
#[cfg(test)]
mod tests {
use super::*;
use crate::credentials::memory;
fn none() -> Option<(String, String)> {
panic!("the local login must not be looked up here");
}
#[test]
fn the_login_a_connect_uses() {
let s = |v: &str| Some(v.to_string());
let local = || Some(("admin".to_string(), "from-this-mac".to_string()));
// A typed password wins over everything, and nothing else is asked.
assert_eq!(
choose_login(s("kay"), s("typed"), s("kept"), true, none),
(s("kay"), s("typed"), PasswordSource::Typed)
);
// Then the Keychain's.
assert_eq!(
choose_login(s("kay"), None, s("kept"), true, none),
(s("kay"), s("kept"), PasswordSource::Keychain)
);
// The Keychain should hold one and does not: reported as absent, the
// local login is not tried in its place.
assert_eq!(
choose_login(s("kay"), None, None, true, none),
(s("kay"), None, PasswordSource::Absent)
);
// Nothing typed, nothing expected: the Codeman on this Mac's own
// login, for no username or the same one.
assert_eq!(
choose_login(None, None, None, false, local),
(s("admin"), s("from-this-mac"), PasswordSource::LocalServer)
);
assert_eq!(
choose_login(s("admin"), None, None, false, local),
(s("admin"), s("from-this-mac"), PasswordSource::LocalServer)
);
// Never for another user: the names are compared exactly.
assert_eq!(
choose_login(s("kay"), None, None, false, local),
(s("kay"), None, PasswordSource::Absent)
);
assert_eq!(
choose_login(s("Admin"), None, None, false, local),
(s("Admin"), None, PasswordSource::Absent)
);
// No local login either.
assert_eq!(
choose_login(None, None, None, false, || None),
(None, None, PasswordSource::Absent)
);
}
/// update_keychain for one probe answer; the log is what the Keychain did,
/// in order (the last load only waits for the queue to drain).
fn keychain_after(outcome: ProbeOutcome, login: ProbedLogin<'_>) -> (KeychainUpdate, Vec<String>) {
let (keychain, store) = memory::keychain();
let update = update_keychain(&keychain, &outcome, &login);
keychain.load_blocking("done");
let mut log = store.log();
assert_eq!(log.pop().as_deref(), Some("load done"));
(update, log)
}
fn login<'a>(
previous_user: Option<&'a str>,
username: Option<&'a str>,
source: PasswordSource,
remember: bool,
) -> ProbedLogin<'a> {
ProbedLogin {
server: "http://a/",
previous_user,
username,
password: (source != PasswordSource::Absent).then_some("secret"),
source,
remember,
}
}
#[test]
fn what_the_keychain_keeps_after_a_connect() {
let ok = || ProbeOutcome::Ok {
version: "1.48.0".into(),
};
let item = |user: &str| credentials::account("http://a/", user);
// A typed password is saved when Remember is on, deleted when it is off.
let (update, log) = keychain_after(ok(), login(Some("kay"), Some("kay"), PasswordSource::Typed, true));
assert!(update.saved && update.deleted.is_empty());
assert_eq!(log, [format!("save {}", item("kay"))]);
let (update, log) = keychain_after(ok(), login(Some("kay"), Some("kay"), PasswordSource::Typed, false));
assert!(!update.saved);
assert_eq!(update.deleted, ["kay"]);
assert_eq!(log, [format!("delete {}", item("kay"))]);
// One that came from the Keychain is there already: nothing is written.
let (update, log) = keychain_after(ok(), login(Some("kay"), Some("kay"), PasswordSource::Keychain, true));
assert!(update.saved && log.is_empty());
// The local Codeman's own login never gets a copy, Remember or not.
let (update, log) = keychain_after(ok(), login(None, Some("admin"), PasswordSource::LocalServer, true));
assert!(!update.saved && update.deleted.is_empty() && log.is_empty());
// Another user than before: the old user's item goes FIRST, then the save.
let (update, log) = keychain_after(ok(), login(Some("old"), Some("new"), PasswordSource::Typed, true));
assert!(update.saved);
assert_eq!(update.deleted, ["old"]);
assert_eq!(
log,
[format!("delete {}", item("old")), format!("save {}", item("new"))]
);
// A password the server wants changed is never kept.
let (update, log) = keychain_after(
ProbeOutcome::PasswordChangeRequired,
login(Some("kay"), Some("kay"), PasswordSource::Typed, true),
);
assert!(!update.saved);
assert_eq!(update.deleted, ["kay"]);
assert_eq!(log, [format!("delete {}", item("kay"))]);
// A login the server did not accept changes nothing.
for refused in [
ProbeOutcome::BadCredentials,
ProbeOutcome::NeedsLogin,
ProbeOutcome::Refused,
ProbeOutcome::Timeout,
] {
let (update, log) = keychain_after(refused, login(Some("old"), Some("new"), PasswordSource::Typed, true));
assert!(!update.saved && update.deleted.is_empty() && log.is_empty());
}
}
#[test]
fn a_commit_from_the_loading_server_is_the_loaded_page() {
let server = Url::parse("https://box.ts.net/codeman/").unwrap();
let loading = Phase::Loading(server.clone());
assert_eq!(
loaded_on_commit(&loading, "https://box.ts.net/codeman/"),
Some(Phase::Loaded(server.clone()))
);
// Any page of that origin counts: the server may redirect within it.
assert_eq!(
loaded_on_commit(&loading, "https://box.ts.net/codeman/session/abc"),
Some(Phase::Loaded(server.clone()))
);
// Another origin, the connect page, or junk: still loading.
assert_eq!(loaded_on_commit(&loading, "https://other.ts.net/codeman/"), None);
assert_eq!(loaded_on_commit(&loading, "http://box.ts.net/codeman/"), None);
assert_eq!(loaded_on_commit(&loading, policy::CONNECT_PAGE), None);
assert_eq!(loaded_on_commit(&loading, "not a url"), None);
// Only a load under way can finish.
assert_eq!(loaded_on_commit(&Phase::Connect, "https://box.ts.net/codeman/"), None);
assert_eq!(
loaded_on_commit(&Phase::Loaded(server), "https://box.ts.net/codeman/"),
None
);
}
}
+187 -134
View File
@@ -19,8 +19,8 @@ pub const APP_SCHEME: &str = "codemanapp";
pub const CONNECT_PAGE: &str = "codemanapp://localhost/index.html";
/// Caps on what a page may hand the native side.
pub const MAX_URL_LEN: usize = 4096;
pub const MAX_FIELD_LEN: usize = 1024;
const MAX_URL_LEN: usize = 4096;
const MAX_FIELD_LEN: usize = 1024;
/// Where the main window is in reaching a server. The gates below take it
/// instead of "is a server set", because what may talk to the native side
@@ -61,14 +61,9 @@ impl Phase {
}
}
/// Turn what the user typed into a server URL: scheme added when missing,
/// query and fragment dropped, and the path ending in `/` so `api/status`
/// joins below a base path. The scheme is `https` for a tailnet `.ts.net`
/// name on its own, which is `tailscale serve`, and `http` otherwise. A
/// `.ts.net` name with a port is Codeman's own listener, which speaks plain
/// http: guessing https there ended in a TLS error with no way forward, while
/// the other mistake (http to an https port) gets a button for the https
/// address (ProbeOutcome::MaybeHttps).
/// Turn what the user typed into a server URL: scheme added when missing
/// (`https` for a tailnet `.ts.net` name, `http` otherwise), query and fragment
/// dropped, and the path ending in `/` so `api/status` joins below a base path.
pub fn normalize_server_url(input: &str) -> Result<Url, String> {
let s = input.trim();
if s.is_empty() {
@@ -80,8 +75,8 @@ pub fn normalize_server_url(input: &str) -> Result<Url, String> {
let with_scheme = if s.contains("://") {
s.to_string()
} else {
let (host, rest) = s.split_at(s.find(['/', ':']).unwrap_or(s.len()));
let scheme = if host.to_ascii_lowercase().ends_with(".ts.net") && !rest.starts_with(':') {
let host = s.split(['/', ':']).next().unwrap_or("");
let scheme = if host.to_ascii_lowercase().ends_with(".ts.net") {
"https"
} else {
"http"
@@ -117,15 +112,9 @@ pub fn same_origin(a: &Url, b: &Url) -> bool {
/// Basic `Authorization` header goes only to such URLs, so another app sharing
/// the origin (`/grafana/` next to `/codeman/`) never receives the password.
/// `server.path()` always ends in `/` (normalize_server_url), so `/codemanx/`
/// is not below `/codeman/`. The part below the base is decoded once, as
/// is_popout_target does, and may hold no `.` or `..` segment: the url crate
/// resolves a literal `..`, but `..%2Fgrafana/` reaches a proxy that decodes
/// before it routes as a way out of the base. A path that does not decode is
/// not below it either (fail closed).
/// is not below `/codeman/`.
pub fn within_base(url: &Url, server: &Url) -> bool {
same_origin(url, server)
&& below_base_decoded(url, server)
.is_some_and(|rest| !rest.split(['/', '\\']).any(|segment| segment == "." || segment == ".."))
same_origin(url, server) && url.path().starts_with(server.path())
}
/// A server on this machine: `localhost`, `127.0.0.0/8` or `::1`.
@@ -197,9 +186,8 @@ pub fn trusted_frame(frame: &Url, server: &Url) -> bool {
/// (`session/<id>`), a rendered HTML file (`html-view/...`, served with a
/// sandbox CSP), a detached file preview or attachment (`api/sessions/<id>/`
/// `file-raw`, `file-preview`, `attachments/<id>/raw|preview`,
/// `folders/<id>/files/...`, and the path picker's `api/filesystem/preview`)
/// and a download (`api/download`). A script's `window.open` gets only the
/// last two kinds (new_window, Via::Script).
/// `folders/<id>/files/...`) and a download (`api/download`). A script's
/// `window.open` gets only the last two kinds (new_window, Via::Script).
///
/// Everything else on the origin is refused, a proxied dashboard under
/// `webview/` above all. A browser keeps the opener frame's sandbox on a popup;
@@ -234,8 +222,8 @@ enum Popout {
Session(String),
/// `html-view/...`: a rendered HTML file.
HtmlView,
/// A detached file preview, an attachment, a file in a granted folder,
/// the path picker's preview, or a download (`api/download`).
/// A detached file preview, an attachment, a file in a granted folder, or
/// a download (`api/download`).
File,
}
@@ -257,14 +245,14 @@ fn popout_kind(url: &Url, server: &Url) -> Option<Popout> {
}
match segments.as_slice() {
["session", id] if !id.is_empty() => Some(Popout::Session((*id).to_string())),
["api", "download"] | ["api", "filesystem", "preview"] => Some(Popout::File),
["api", "download"] => Some(Popout::File),
["api", "sessions", id, "file-raw" | "file-preview"] if !id.is_empty() => Some(Popout::File),
["api", "sessions", id, "attachments", attachment, "raw" | "preview"]
if !id.is_empty() && !attachment.is_empty() =>
{
Some(Popout::File)
}
["api", "sessions", id, "folders", folder, "files", name, "raw" | "preview"]
["api", "sessions", id, "folders", folder, "files", name, ..]
if !id.is_empty() && !folder.is_empty() && !name.is_empty() =>
{
Some(Popout::File)
@@ -426,38 +414,6 @@ impl Default for OpenBudget {
}
}
/// What a window does once its web content process has ended: a crash, or the
/// system taking the process back under memory pressure. The window is blank
/// from then on, since nothing reloads it by itself.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Recovery {
/// Load the window's page again.
Reload,
/// Show the connect page: with the reason, when a server page was given up.
ConnectPage { gave_up: bool },
/// Close the pop-out.
Close,
/// Leave the window as it is.
Nothing,
}
/// How a window recovers from the end of its web content process. `on_server`:
/// a server page is the main window's document (pop-outs only exist then).
/// `again`: the same window lost its process a moment ago already, so its page
/// cannot stay up and loading it once more would only repeat that.
pub fn recovery(is_main: bool, on_server: bool, again: bool) -> Recovery {
match (is_main, on_server, again) {
(_, true, false) => Recovery::Reload,
(true, true, true) => Recovery::ConnectPage { gave_up: true },
// The connect page itself, or a server load that had not committed.
(true, false, false) => Recovery::ConnectPage { gave_up: false },
(true, false, true) => Recovery::Nothing,
(false, _, true) => Recovery::Close,
// A pop-out with no server page behind it is on its way out anyway.
(false, false, false) => Recovery::Nothing,
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum Navigation {
Allow,
@@ -609,6 +565,27 @@ pub enum ConnectCmd {
InstallLocal {
network: bool,
},
/// Fetch the installer and ask it what is on this Mac (gui_install).
InstallPrepare,
/// Run the graphical install. `network_password`: reachable from other
/// devices, with this password. `ai_cli`: an id in the shape of the
/// installer's catalogue, which checks it again.
InstallStart {
network_password: Option<String>,
ai_cli: Option<String>,
},
/// Show the graphical install that is running already.
InstallAttach,
/// Stop whichever graphical run is going.
InstallCancel,
/// Fetch the installer to add a coding agent to a Mac that has Codeman.
AgentPrepare,
/// Add this agent: an id in the shape of the installer's catalogue.
AgentStart {
ai_cli: String,
},
/// Show the agent install that is running already.
AgentAttach,
/// Run the installer on another machine over SSH, in Terminal. `target`
/// is what the user typed; setup::SshTarget::parse checks it.
InstallRemote {
@@ -663,6 +640,23 @@ enum RawConnect {
#[serde(default)]
network: bool,
},
InstallPrepare,
InstallStart {
// rename_all above reaches variant names only, and deny_unknown_fields
// would refuse the page's spelling of these.
#[serde(default, rename = "networkPassword")]
network_password: Option<String>,
#[serde(default, rename = "aiCli")]
ai_cli: Option<String>,
},
InstallAttach,
InstallCancel,
AgentPrepare,
AgentStart {
#[serde(rename = "aiCli")]
ai_cli: String,
},
AgentAttach,
InstallRemote {
target: String,
},
@@ -679,6 +673,17 @@ enum RawHost {
SessionExpired,
}
/// The shape of a CLI catalogue id. It becomes one argument of `install.sh`,
/// so it could not be read as a flag even without this: it must start with a
/// letter.
fn is_cli_id(id: &str) -> bool {
id.len() <= 32
&& id.starts_with(|c: char| c.is_ascii_lowercase())
&& id
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
fn bounded(s: Option<String>, max: usize) -> Option<Option<String>> {
match s {
Some(v) if v.len() > max => None,
@@ -736,6 +741,24 @@ pub fn parse_ipc(frame_url: &str, body: &str, phase: &Phase, is_main_window: boo
RawConnect::OpenExternal { url } if url.len() <= MAX_URL_LEN => ConnectCmd::OpenExternal(url),
RawConnect::LocalStatus { quick, deep } => ConnectCmd::LocalStatus { quick, deep },
RawConnect::InstallLocal { network } => ConnectCmd::InstallLocal { network },
RawConnect::InstallPrepare => ConnectCmd::InstallPrepare,
RawConnect::InstallStart {
network_password,
ai_cli,
} => {
if ai_cli.as_deref().is_some_and(|id| !is_cli_id(id)) {
return None;
}
ConnectCmd::InstallStart {
network_password: bounded(network_password, MAX_FIELD_LEN)?.filter(|p| !p.is_empty()),
ai_cli,
}
}
RawConnect::InstallAttach => ConnectCmd::InstallAttach,
RawConnect::InstallCancel => ConnectCmd::InstallCancel,
RawConnect::AgentPrepare => ConnectCmd::AgentPrepare,
RawConnect::AgentStart { ai_cli } if is_cli_id(&ai_cli) => ConnectCmd::AgentStart { ai_cli },
RawConnect::AgentAttach => ConnectCmd::AgentAttach,
RawConnect::InstallRemote { target } if target.len() <= MAX_FIELD_LEN => {
ConnectCmd::InstallRemote { target }
}
@@ -789,26 +812,6 @@ mod tests {
normalize_server_url("box.tailf80371.ts.net").unwrap().as_str(),
"https://box.tailf80371.ts.net/"
);
assert_eq!(
normalize_server_url("Box.Tailf80371.TS.NET/codeman").unwrap().as_str(),
"https://box.tailf80371.ts.net/codeman/"
);
// With a port it is Codeman's own listener, not tailscale serve.
assert_eq!(
normalize_server_url("box.tailf80371.ts.net:3000").unwrap().as_str(),
"http://box.tailf80371.ts.net:3000/"
);
assert_eq!(
normalize_server_url("box.tailf80371.ts.net:8443/x").unwrap().as_str(),
"http://box.tailf80371.ts.net:8443/x/"
);
// A scheme that was typed is never second-guessed.
assert_eq!(
normalize_server_url("https://box.tailf80371.ts.net:8443")
.unwrap()
.as_str(),
"https://box.tailf80371.ts.net:8443/"
);
assert_eq!(
normalize_server_url("https://host/codeman").unwrap().as_str(),
"https://host/codeman/"
@@ -994,7 +997,6 @@ mod tests {
"http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/a.png/raw",
"http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/my%20notes.md/preview",
"http://127.0.0.1:3000/api/download?path=x",
"http://127.0.0.1:3000/api/filesystem/preview?path=%2Fhome%2Fu%2Fa.md",
] {
assert_eq!(script(s), NewWindow::AppWindow(u(s)), "{s}");
assert_eq!(host(s), NewWindow::AppWindow(u(s)), "{s}");
@@ -1170,10 +1172,6 @@ mod tests {
"http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/my%20notes.md/preview"
));
assert!(ok("http://127.0.0.1:3000/api/download?path=x"));
// The path picker's preview, which its Open link shows in a window.
assert!(ok(
"http://127.0.0.1:3000/api/filesystem/preview?path=%2Fhome%2Fu%2Fa.md&sessionId=s1"
));
// A single decode is how the server's router sees the path.
assert!(ok("http://127.0.0.1:3000/%73ession/abc"));
@@ -1206,18 +1204,7 @@ mod tests {
assert!(!ok("http://127.0.0.1:3000/api/sessions//file-raw"));
assert!(!ok("http://127.0.0.1:3000/api/sessions/s1/attachments/att1/delete"));
assert!(!ok("http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/"));
assert!(!ok("http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/a.png"));
assert!(!ok(
"http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/a.png/thumbnail"
));
assert!(!ok(
"http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/a.png/raw/x"
));
assert!(!ok("http://127.0.0.1:3000/api/download/x"));
// The picker's other routes list and create folders: never a window.
assert!(!ok("http://127.0.0.1:3000/api/filesystem/browse?path=%2F"));
assert!(!ok("http://127.0.0.1:3000/api/filesystem/mkdir"));
assert!(!ok("http://127.0.0.1:3000/api/filesystem/preview/x"));
assert!(!ok("http://127.0.0.1:3000/api/status"));
// Other origins and userinfo.
assert!(!ok("http://127.0.0.1:3001/session/abc"));
@@ -1290,27 +1277,6 @@ mod tests {
assert!(!within_base(&u("https://host/grafana/"), &srv));
assert!(!within_base(&u("https://host/"), &srv));
assert!(!within_base(&u("https://host/codeman/../grafana/"), &srv));
// The same way out, spelled so that only a decoder sees it.
for escape in [
"https://host/codeman/..%2Fgrafana/",
"https://host/codeman/..%2fgrafana/x",
"https://host/codeman/%2e%2e%2Fgrafana/",
"https://host/codeman/..%5Cgrafana/",
"https://host/codeman/a/%2E%2E%2F%2E%2E%2Fgrafana/",
"https://host/codeman/session/a%zz",
] {
assert!(!within_base(&u(escape), &srv), "{escape}");
assert!(!challenge_in_scope(&u(escape), &srv), "{escape}");
}
// Encoded characters that are part of a name stay inside.
for inside in [
"https://host/codeman/api/sessions/s1/folders/f1/files/50%25.txt/raw",
"https://host/codeman/api/sessions/s1/file-raw?path=..%2Fx",
"https://host/codeman/session/a%20b",
"https://host/codeman/a..b/c",
] {
assert!(within_base(&u(inside), &srv), "{inside}");
}
assert!(!within_base(&u("http://host/codeman/"), &srv));
assert!(!within_base(&u("https://other/codeman/"), &srv));
let root = u("http://127.0.0.1:3000/");
@@ -1375,24 +1341,6 @@ mod tests {
assert!(!b.allow(later));
}
#[test]
fn a_window_recovers_from_a_lost_content_process_once() {
// The first time, every window that shows a server page loads it again.
assert_eq!(recovery(true, true, false), Recovery::Reload);
assert_eq!(recovery(false, true, false), Recovery::Reload);
// The connect page is shown anew, which also ends a load under way.
assert_eq!(recovery(true, false, false), Recovery::ConnectPage { gave_up: false });
// A second time in a row never reloads: that would loop.
assert_eq!(recovery(true, true, true), Recovery::ConnectPage { gave_up: true });
assert_eq!(recovery(false, true, true), Recovery::Close);
assert_eq!(recovery(true, false, true), Recovery::Nothing);
for is_main in [true, false] {
for on_server in [true, false] {
assert_ne!(recovery(is_main, on_server, true), Recovery::Reload);
}
}
}
#[test]
fn basic_auth() {
assert_eq!(basic_auth_header("admin", "secret"), "Basic YWRtaW46c2VjcmV0");
@@ -1599,6 +1547,111 @@ mod tests {
assert_eq!(parse_ipc(page, r#"{"cmd":"installLocal"}"#, &loaded, true), None);
}
#[test]
fn ipc_graphical_install_commands() {
let page = "codemanapp://localhost/index.html";
let c = |body: &str| parse_ipc(page, body, &Phase::Connect, true);
assert_eq!(
c(r#"{"cmd":"installPrepare"}"#),
Some(Ipc::Connect(ConnectCmd::InstallPrepare))
);
assert_eq!(
c(r#"{"cmd":"installAttach"}"#),
Some(Ipc::Connect(ConnectCmd::InstallAttach))
);
assert_eq!(
c(r#"{"cmd":"installCancel"}"#),
Some(Ipc::Connect(ConnectCmd::InstallCancel))
);
// The spelling connect.html sends (test/desktop-connect-install.test.ts
// pins the page's side).
assert_eq!(
c(r#"{"cmd":"installStart","networkPassword":"s3cret","aiCli":"claude"}"#),
Some(Ipc::Connect(ConnectCmd::InstallStart {
network_password: Some("s3cret".into()),
ai_cli: Some("claude".into()),
}))
);
assert_eq!(
c(r#"{"cmd":"installStart"}"#),
Some(Ipc::Connect(ConnectCmd::InstallStart {
network_password: None,
ai_cli: None,
}))
);
// An empty password is no password: never a network install without one.
assert_eq!(
c(r#"{"cmd":"installStart","networkPassword":""}"#),
Some(Ipc::Connect(ConnectCmd::InstallStart {
network_password: None,
ai_cli: None,
}))
);
// Only the shape of an id: nothing that could read as a flag or more.
for id in ["--yes", "-y", "Claude", "a b", "a;b", "", "a/b"] {
assert_eq!(c(&format!(r#"{{"cmd":"installStart","aiCli":"{id}"}}"#)), None, "{id}");
}
let long = "a".repeat(33);
assert_eq!(c(&format!(r#"{{"cmd":"installStart","aiCli":"{long}"}}"#)), None);
let long = "a".repeat(MAX_FIELD_LEN + 1);
assert_eq!(
c(&format!(r#"{{"cmd":"installStart","networkPassword":"{long}"}}"#)),
None
);
// Nothing else rides along.
assert_eq!(c(r#"{"cmd":"installStart","args":["--yes"]}"#), None);
assert_eq!(c(r#"{"cmd":"installStart","ai_cli":"claude"}"#), None);
// Only the connect page, only in the main window, never a server page.
assert_eq!(
parse_ipc(page, r#"{"cmd":"installStart"}"#, &Phase::Connect, false),
None
);
let loaded = Phase::Loaded(u("http://127.0.0.1:3000/"));
assert_eq!(
parse_ipc("http://127.0.0.1:3000/", r#"{"cmd":"installStart"}"#, &loaded, true),
None
);
assert_eq!(parse_ipc(page, r#"{"cmd":"installCancel"}"#, &loaded, true), None);
}
#[test]
fn ipc_adding_a_coding_agent() {
let page = "codemanapp://localhost/index.html";
let c = |body: &str| parse_ipc(page, body, &Phase::Connect, true);
assert_eq!(
c(r#"{"cmd":"agentPrepare"}"#),
Some(Ipc::Connect(ConnectCmd::AgentPrepare))
);
assert_eq!(
c(r#"{"cmd":"agentAttach"}"#),
Some(Ipc::Connect(ConnectCmd::AgentAttach))
);
assert_eq!(
c(r#"{"cmd":"agentStart","aiCli":"codex"}"#),
Some(Ipc::Connect(ConnectCmd::AgentStart { ai_cli: "codex".into() }))
);
// It names one agent, in the shape of an id, and nothing else.
assert_eq!(c(r#"{"cmd":"agentStart"}"#), None);
for id in ["--service", "-y", "Codex", "a b", "a;b", "", "a/b"] {
assert_eq!(c(&format!(r#"{{"cmd":"agentStart","aiCli":"{id}"}}"#)), None, "{id}");
}
assert_eq!(c(r#"{"cmd":"agentStart","aiCli":"codex","port":3000}"#), None);
assert_eq!(
parse_ipc(page, r#"{"cmd":"agentStart","aiCli":"codex"}"#, &Phase::Connect, false),
None
);
let loaded = Phase::Loaded(u("http://127.0.0.1:3000/"));
assert_eq!(
parse_ipc(
"http://127.0.0.1:3000/",
r#"{"cmd":"agentStart","aiCli":"codex"}"#,
&loaded,
true
),
None
);
}
#[test]
fn ipc_from_connect_page() {
let page = "codemanapp://localhost/index.html";
+15 -65
View File
@@ -10,7 +10,7 @@ use std::time::Duration;
use url::Url;
const TIMEOUT: Duration = Duration::from_secs(5);
pub const MAX_BODY: u64 = 8 * 1024 * 1024;
const MAX_BODY: u64 = 8 * 1024 * 1024;
/// The path the probe asks for, relative to the server's base URL.
const STATUS_PATH: &str = "api/status";
@@ -228,35 +228,23 @@ fn transport_error(err: &ureq::Error, plain_http: bool) -> TransportError {
/// Ask the server. Blocking: call it from a worker thread.
pub fn probe(server: &Url, authorization: Option<&str>) -> ProbeOutcome {
probe_within(server, authorization, TIMEOUT)
}
/// The app's one way of asking a server something: the OS's certificate trust,
/// a bound on the whole exchange, and status codes as answers.
pub fn agent(timeout: Duration) -> ureq::Agent {
let status_url = match server.join(STATUS_PATH) {
Ok(u) => u,
Err(e) => return ProbeOutcome::Failed { detail: e.to_string() },
};
let tls = ureq::tls::TlsConfig::builder()
.root_certs(ureq::tls::RootCerts::PlatformVerifier)
.build();
ureq::Agent::config_builder()
.timeout_global(Some(timeout))
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(TIMEOUT))
.http_status_as_error(false)
// Never follow: a 3xx comes back as an answer and is explained.
.max_redirects(0)
.tls_config(tls)
.user_agent(concat!("CodemanDesktop/", env!("CARGO_PKG_VERSION")))
.build()
.into()
}
/// `probe` with the time the whole exchange may take.
fn probe_within(server: &Url, authorization: Option<&str>, timeout: Duration) -> ProbeOutcome {
let status_url = match server.join(STATUS_PATH) {
Ok(u) => u,
Err(e) => return ProbeOutcome::Failed { detail: e.to_string() },
};
let mut req = agent(timeout)
.get(status_url.as_str())
.header("Accept", "application/json");
.into();
let mut req = agent.get(status_url.as_str()).header("Accept", "application/json");
if let Some(auth) = authorization {
req = req.header("Authorization", auth);
}
@@ -271,16 +259,12 @@ fn probe_within(server: &Url, authorization: Option<&str>, timeout: Duration) ->
};
let location = header("location");
let retry_after = header("retry-after");
let body = match resp.body_mut().with_config().limit(MAX_BODY).read_to_string() {
Ok(body) => body,
// The answer began but did not finish in time. Where the body
// decides what answered (200, 403), that is a timeout, not
// "something else answers here".
Err(e) if matches!(status, 200 | 403) && transport_error(&e, false) == TransportError::Timeout => {
return ProbeOutcome::Timeout;
}
Err(_) => String::new(),
};
let body = resp
.body_mut()
.with_config()
.limit(MAX_BODY)
.read_to_string()
.unwrap_or_default();
classify_response(
&status_url,
status,
@@ -740,38 +724,4 @@ mod tests {
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
assert_eq!(probe(&url, None), ProbeOutcome::MaybeHttps);
}
/// A Codeman that sends its status headers and then stalls (a blocked
/// event loop, a lossy link) did not answer in time. It is not "something
/// else on this port", which is what an empty body read as before.
#[test]
fn a_body_that_stalls_is_a_timeout() {
use std::io::{Read, Write};
use std::net::TcpListener;
/// Send `head` and the start of a body, then hold the connection open.
fn serve_stalling(status_line: &'static str) -> u16 {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
std::thread::spawn(move || {
if let Ok((mut s, _)) = l.accept() {
let mut buf = [0u8; 4096];
let _ = s.read(&mut buf);
let _ = s.write_all(
format!("HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: 4096\r\n\r\n{{\"success\":true,").as_bytes(),
);
std::thread::sleep(Duration::from_secs(3));
}
});
port
}
let short = Duration::from_millis(400);
let url = Url::parse(&format!("http://127.0.0.1:{}/", serve_stalling("200 OK"))).unwrap();
assert_eq!(probe_within(&url, None, short), ProbeOutcome::Timeout);
// A 401 has said all it needs to with its status line.
let url = Url::parse(&format!("http://127.0.0.1:{}/", serve_stalling("401 Unauthorized"))).unwrap();
assert_eq!(probe_within(&url, None, short), ProbeOutcome::NeedsLogin);
}
}
+66 -161
View File
@@ -1,12 +1,12 @@
//! The connect screen's "This Mac" tab and its installs: where the Codeman on
//! this Mac listens, whether it is installed, its login, and the installer runs
//! for this Mac and for another machine over SSH.
//! The connect screen's "This Mac" tab and its Terminal installs: where the
//! Codeman on this Mac listens, whether it is installed, its login, and the
//! installer runs in Terminal for this Mac and for another machine over SSH.
//! The install most people get runs in the app, in `gui_install.rs`.
//!
//! The installer always runs in Terminal, never inside the app. `install.sh`
//! asks for the administrator password on a terminal (Homebrew, its sudo
//! keep-alive), the Xcode tools arrive as a system dialog, and ssh asks about
//! host keys and passwords on a terminal too: a run without one fails on the
//! first fresh Mac. So the app writes a small script that deletes itself and
//! The Terminal route is for what that one cannot do. `install.sh` asks for
//! the administrator password on a terminal (Homebrew, its sudo keep-alive),
//! the Xcode tools arrive as a system dialog, and ssh asks about host keys and
//! passwords on a terminal too. So the app writes a small script that deletes itself and
//! hands it to Terminal (`open -b com.apple.Terminal`), which needs no
//! permission, unlike scripting Terminal through osascript (an Automation
//! prompt). Everything the scripts contain is built here and unit-tested; the
@@ -24,12 +24,22 @@ use std::time::Duration;
use serde::Serialize;
use url::Url;
use crate::gui_install;
use crate::local_server;
use crate::policy;
use crate::probe::{self, ProbeOutcome};
/// Codeman's published installer (a redirect to `install.sh` on master).
pub const INSTALLER_URL: &str = "https://getcodeman.com/install";
/// Codeman's published installer (a redirect to `install.sh` on master). A test
/// build of an installer change sets `CODEMAN_DESKTOP_INSTALLER_URL` when it is
/// compiled; a shipped app has no way to point this anywhere else, at runtime
/// or otherwise.
pub const INSTALLER_URL: &str = match option_env!("CODEMAN_DESKTOP_INSTALLER_URL") {
Some(url) => url,
None => "https://getcodeman.com/install",
};
/// The branch that test build installs Codeman from (`CODEMAN_BRANCH`).
pub const INSTALL_BRANCH: Option<&str> = option_env!("CODEMAN_DESKTOP_INSTALL_BRANCH");
/// What to paste into a terminal on another machine; the SSH install runs the
/// same thing there.
@@ -289,7 +299,7 @@ pub fn local_login(url: &Url) -> Option<(String, String)> {
}
/// This user's processes, as `ps` lists them: (pid, command line).
fn list_processes() -> Vec<(u32, String)> {
pub fn list_processes() -> Vec<(u32, String)> {
let Ok(out) = Command::new("/bin/ps")
.args(["-x", "-ww", "-o", "pid=", "-o", "command="])
.stdin(Stdio::null())
@@ -316,22 +326,21 @@ fn parse_ps(text: &str) -> Vec<(u32, String)> {
/// `src/index.ts web`), not one of its short-lived control commands.
pub fn is_codeman_server(command: &str) -> bool {
let words: Vec<&str> = command.split_whitespace().collect();
let is_entry = |w: &str| {
let entry = words.iter().position(|w| {
let name = w.rsplit('/').next().unwrap_or(w);
w.ends_with("dist/index.js") || w.ends_with("src/index.ts") || name == "codeman" || name == "aicodeman"
};
// Any entry word that `web` follows: an earlier word may only look like
// one (a folder named codeman in `tsx watch --cwd ~/codeman src/index.ts web`).
(0..words.len()).any(|i| {
is_entry(words[i])
&& words.get(i + 1) == Some(&"web")
&& !words[i + 1..].iter().any(|w| matches!(*w, "--stop" | "--status"))
})
});
match entry {
Some(i) => {
words.get(i + 1) == Some(&"web") && !words[i + 1..].iter().any(|w| matches!(*w, "--stop" | "--status"))
}
None => false,
}
}
/// Whether a command line is an install the app started on this Mac (the
/// Terminal script, or the installer it downloaded), and the port it asked
/// for once the installer runs.
/// Terminal script, the installer it downloaded, or the graphical install), and
/// the port it asked for once the installer runs.
pub fn install_in_progress(command: &str) -> Option<Option<u16>> {
let words: Vec<&str> = command.split_whitespace().collect();
let name = |w: &&str| w.rsplit('/').next().unwrap_or(w).to_string();
@@ -341,7 +350,7 @@ pub fn install_in_progress(command: &str) -> Option<Option<u16>> {
}) {
return Some(None);
}
if words.iter().any(|w| name(w).starts_with("codeman-install.")) {
if words.iter().any(|w| name(w).starts_with("codeman-install.")) || gui_install::Run::Codeman.matches(command) {
let port = words
.iter()
.position(|w| *w == "--port")
@@ -360,16 +369,6 @@ fn codeman_servers(procs: &[(u32, String)]) -> Vec<u32> {
.collect()
}
/// The install the app started on this Mac, if one runs, with the port it
/// asked for as soon as one of its processes names it. The Terminal script
/// that wraps the installer names none and is the older process, so the first
/// match alone would report no port for the whole install.
fn running_install(procs: &[(u32, String)]) -> Option<Option<u16>> {
let mut installs = procs.iter().filter_map(|(_, c)| install_in_progress(c)).peekable();
installs.peek()?;
Some(installs.flatten().next())
}
/// A listening TCP socket: its pid, address (`*`, `127.0.0.1`, `[::1]`, a
/// specific IP) and port.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -432,8 +431,9 @@ fn serves_loopback_port(found: &[Listener], port: u16) -> bool {
/// all-interfaces bind, else its own address.
fn listener_url(l: &Listener) -> Option<Url> {
let host = match l.host.as_str() {
"*" | "0.0.0.0" => "127.0.0.1",
h => h,
"*" | "0.0.0.0" | "127.0.0.1" => "127.0.0.1".to_string(),
h if h.starts_with('[') => h.to_string(),
h => h.to_string(),
};
Url::parse(&format!("http://{host}:{}/", l.port)).ok()
}
@@ -446,9 +446,6 @@ pub enum LocalState {
Running,
/// Codeman answers but wants a login the app does not have.
NeedsLogin,
/// A Codeman server of this user runs but listens nowhere yet: it is
/// still starting (the LaunchAgent at login, a restart).
Starting,
/// A Codeman server of this user runs, but not at an address the app can
/// open (bound to one network address, say): see `elsewhere`.
RunningElsewhere,
@@ -478,43 +475,22 @@ pub struct LocalStatus {
pub installed: bool,
/// `install.sh` finished its work (its `.install-complete` marker).
pub install_complete: bool,
/// An install the app started is still running in Terminal, and the
/// port it asked for once that is known.
/// An install the app started is still running, and the port it asked
/// for once that is known.
pub install_running: bool,
pub install_port: Option<u16>,
/// That install is the graphical one, not a Terminal window: the page can
/// show it (gui_install::follow), also after the app was quit.
pub install_in_app: bool,
/// A coding agent is being added the same way. Not an install: it says
/// nothing about Codeman, which may well be running.
pub agent_install_in_app: bool,
/// For RunningElsewhere: the addresses that Codeman listens on.
pub elsewhere: Vec<String>,
/// The probe's outcome, for the connect page's explanations.
pub outcome: ProbeOutcome,
}
/// Whether the probe got an answer from a Codeman server, logged in or not.
fn answers(outcome: &ProbeOutcome) -> bool {
matches!(
outcome,
ProbeOutcome::Ok { .. }
| ProbeOutcome::PasswordChangeRequired
| ProbeOutcome::NeedsLogin
| ProbeOutcome::BadCredentials
| ProbeOutcome::RateLimited { .. }
)
}
/// What the This Mac tab shows for what `inspect` found. `server_runs`: one of
/// this user's Codeman server processes exists. `server_listens`: one of them
/// has a listening socket. `installed`: Codeman is on this Mac.
fn local_state(outcome: &ProbeOutcome, server_runs: bool, server_listens: bool, installed: bool) -> LocalState {
match outcome {
ProbeOutcome::Ok { .. } | ProbeOutcome::PasswordChangeRequired => LocalState::Running,
_ if answers(outcome) => LocalState::NeedsLogin,
ProbeOutcome::Refused if server_runs && !server_listens => LocalState::Starting,
ProbeOutcome::Refused if server_runs => LocalState::RunningElsewhere,
ProbeOutcome::Refused if installed => LocalState::Stopped,
ProbeOutcome::Refused => LocalState::NotInstalled,
_ => LocalState::Other,
}
}
/// How hard `inspect` looks for an installed Codeman when nothing answers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Depth {
@@ -534,7 +510,9 @@ pub fn inspect(depth: Depth) -> LocalStatus {
let procs = list_processes();
let servers = codeman_servers(&procs);
let found = listeners(&servers);
let install = running_install(&procs);
let install = procs.iter().find_map(|(_, c)| install_in_progress(c));
let install_in_app = procs.iter().any(|(_, c)| gui_install::Run::Codeman.matches(c));
let agent_install_in_app = procs.iter().any(|(_, c)| gui_install::Run::Agent.matches(c));
// The login goes only to this user's own Codeman on that port.
let auth = if serves_loopback_port(&found, url.port_or_known_default().unwrap_or(0)) {
@@ -582,13 +560,20 @@ pub fn inspect(depth: Depth) -> LocalStatus {
let app = home.join(".codeman/app");
let install_complete = app.join(".install-complete").is_file();
let answering = match &outcome {
ProbeOutcome::Ok { .. } | ProbeOutcome::PasswordChangeRequired => Some(LocalState::Running),
ProbeOutcome::NeedsLogin | ProbeOutcome::BadCredentials | ProbeOutcome::RateLimited { .. } => {
Some(LocalState::NeedsLogin)
}
_ => None,
};
// Something else on Codeman's port (a dev server on 3000, say) says
// nothing about whether Codeman is installed, so look either way.
let on_disk = install_complete
|| app.join("dist/index.js").is_file()
|| agent_path(&home).is_file()
|| daemon_path().is_file();
let installed = answers(&outcome)
let installed = answering.is_some()
|| !servers.is_empty()
|| on_disk
|| match depth {
@@ -596,7 +581,13 @@ pub fn inspect(depth: Depth) -> LocalStatus {
Depth::Login => local_server::installed(false),
Depth::Interactive => local_server::installed(true),
};
let state = local_state(&outcome, !servers.is_empty(), !found.is_empty(), installed);
let state = match (answering, &outcome) {
(Some(state), _) => state,
(None, ProbeOutcome::Refused) if !servers.is_empty() => LocalState::RunningElsewhere,
(None, ProbeOutcome::Refused) if installed => LocalState::Stopped,
(None, ProbeOutcome::Refused) => LocalState::NotInstalled,
(None, _) => LocalState::Other,
};
if state != LocalState::RunningElsewhere {
elsewhere.clear();
}
@@ -614,6 +605,8 @@ pub fn inspect(depth: Depth) -> LocalStatus {
install_complete,
install_running: install.is_some(),
install_port: install.flatten(),
install_in_app,
agent_install_in_app,
elsewhere,
outcome,
}
@@ -624,7 +617,7 @@ pub fn inspect(depth: Depth) -> LocalStatus {
/// attach to the first one's live sessions (src/config/instance.ts), and
/// `codeman web -d` only notices a server it started itself or one at the
/// same address.
fn already_running() -> Option<String> {
pub fn already_running() -> Option<String> {
let servers = codeman_servers(&list_processes());
let pid = servers.first()?;
let found = listeners(&servers);
@@ -639,15 +632,6 @@ fn already_running() -> Option<String> {
))
}
/// Whether `url` is where the launchd job `service` answers: its scheme and
/// port on this Mac, under `localhost` as much as 127.0.0.1. The path is left
/// out: a job with a base path answers below `/` too, and a second server on
/// its port would run without the job's settings.
fn served_by(service: &ServiceConfig, url: &Url) -> bool {
let at = local_url(Some(service));
policy::is_loopback(url) && url.scheme() == at.scheme() && url.port_or_known_default() == at.port_or_known_default()
}
/// Start the Codeman on this Mac for `url`. A LaunchAgent that serves that
/// address is started through launchd, so it runs with its own settings
/// (host, password) and stays supervised; anything else gets `codeman web -d`
@@ -657,7 +641,7 @@ pub fn start_local(url: &Url) -> Result<String, String> {
return Err(why);
}
match read_service() {
Some(s) if served_by(&s, url) => {
Some(s) if local_url(Some(&s)) == *url => {
if s.daemon {
return Err(format!(
"A system LaunchDaemon runs Codeman on this Mac; an administrator starts it: sudo launchctl kickstart -k system/{SERVICE_LABEL}"
@@ -713,7 +697,7 @@ fn start_launch_agent(port: u16) -> Result<String, String> {
if !port_free(port) {
return Ok(format!("launchd started Codeman ({SERVICE_LABEL})."));
}
std::thread::sleep(Duration::from_millis(100));
std::thread::sleep(Duration::from_millis(500));
}
Err("launchd started Codeman, but it did not answer within 30 seconds. Its log is /tmp/codeman.log for an install.sh setup.".into())
}
@@ -1254,71 +1238,6 @@ mod tests {
);
}
#[test]
fn a_start_goes_to_the_launchd_job_that_serves_the_address() {
let job = parse_service(&json!({
"ProgramArguments": ["node", "i.js", "web"],
"EnvironmentVariables": { "CODEMAN_PORT": "3001", "CODEMAN_BASE_URL": "/codeman" }
}));
// The app's own address for it, and what a user types for the same server.
assert!(served_by(&job, &u("http://127.0.0.1:3001/codeman/")));
assert!(served_by(&job, &u("http://localhost:3001/codeman/")));
assert!(served_by(&job, &u("http://LOCALHOST:3001/")));
// Another port, scheme or machine is not this job's server.
assert!(!served_by(&job, &u("http://127.0.0.1:3000/codeman/")));
assert!(!served_by(&job, &u("https://127.0.0.1:3001/codeman/")));
assert!(!served_by(&job, &u("http://192.168.1.5:3001/codeman/")));
assert!(!served_by(&job, &u("http://localhost.example.com:3001/")));
let https = parse_service(&json!({ "ProgramArguments": ["node", "i.js", "web", "--https"] }));
assert!(served_by(&https, &u("https://localhost:3000/")));
assert!(!served_by(&https, &u("http://localhost:3000/")));
}
#[test]
fn this_mac_state_for_what_was_found() {
let ok = ProbeOutcome::Ok {
version: "1.48.0".into(),
};
// Whatever else is true, a Codeman that answers is what the tab shows.
for (runs, listens, installed) in [(false, false, false), (true, true, true), (true, false, false)] {
assert_eq!(local_state(&ok, runs, listens, installed), LocalState::Running);
assert_eq!(
local_state(&ProbeOutcome::PasswordChangeRequired, runs, listens, installed),
LocalState::Running
);
for login in [
ProbeOutcome::NeedsLogin,
ProbeOutcome::BadCredentials,
ProbeOutcome::RateLimited {
sent_credentials: false,
retry_after_secs: None,
},
] {
assert_eq!(local_state(&login, runs, listens, installed), LocalState::NeedsLogin);
}
}
// Nothing answers. A server process with no socket yet is starting,
// which comes before "runs somewhere else": the app must not send the
// user to another address for a server that is two seconds from up.
let refused = ProbeOutcome::Refused;
assert_eq!(local_state(&refused, true, false, true), LocalState::Starting);
assert_eq!(local_state(&refused, true, false, false), LocalState::Starting);
assert_eq!(local_state(&refused, true, true, true), LocalState::RunningElsewhere);
assert_eq!(local_state(&refused, false, false, true), LocalState::Stopped);
assert_eq!(local_state(&refused, false, false, false), LocalState::NotInstalled);
// Something else holds the port, or the look itself failed.
for other in [
ProbeOutcome::NotCodeman { status: 404 },
ProbeOutcome::Timeout,
ProbeOutcome::MaybeHttps,
] {
assert_eq!(local_state(&other, true, true, true), LocalState::Other);
assert_eq!(local_state(&other, false, false, false), LocalState::Other);
}
assert!(answers(&ProbeOutcome::BadCredentials) && !answers(&refused));
}
#[test]
fn processes_and_listeners() {
let ps = " 101 /opt/homebrew/bin/node /Users/k/.codeman/app/dist/index.js web\n\
@@ -1332,15 +1251,6 @@ mod tests {
909 bash /var/folders/x/T/codeman-install.Ab12Cd --local --service --port 3001\n\
1001 /bin/bash /var/folders/x/T/install-codeman-remote-55-1.command\n\
garbage line\n";
// A folder named like the command comes before the real entry point.
assert!(is_codeman_server(
"node /x/tsx/cli.mjs watch --cwd /Users/k/codeman src/index.ts web"
));
assert!(is_codeman_server(
"node --watch-path /Users/k/codeman /Users/k/codeman/src/index.ts web --port 3001"
));
assert!(!is_codeman_server("node /Users/k/codeman/scripts/build.mjs web"));
assert!(!is_codeman_server("/opt/homebrew/bin/codeman attach web"));
let procs = parse_ps(ps);
assert_eq!(
procs[0],
@@ -1352,11 +1262,6 @@ mod tests {
assert_eq!(codeman_servers(&procs), [101, 202, 303]);
let installs: Vec<Option<u16>> = procs.iter().filter_map(|(_, c)| install_in_progress(c)).collect();
assert_eq!(installs, [None, Some(3001)]);
// The Terminal script comes first and names no port: the installer's wins.
assert_eq!(running_install(&procs), Some(Some(3001)));
// Before the installer is downloaded, only the script runs.
assert_eq!(running_install(&procs[..8]), Some(None));
assert_eq!(running_install(&procs[..7]), None);
let lsof = "p101\nf21\nn127.0.0.1:3000\nf22\nn172.17.0.1:3999\np202\nf9\nn*:4000\np303\nn[::1]:5000\nnbogus\n";
let found = parse_lsof(lsof);
+5
View File
@@ -218,6 +218,11 @@ function canonicalPath(path) {
* @returns {string | null}
*/
export function resolveGitHooksDir(repoRoot) {
// Not a checkout at all (a source-archive or npm install): answer without running
// git. On a Mac without the Xcode Command Line Tools `/usr/bin/git` is a stub that
// opens Apple's "install the developer tools" dialog, and this runs on every
// `npm install`. `.git` is a directory in a clone and a file in a worktree.
if (!existsSync(join(repoRoot, '.git'))) return null;
try {
const top = git(repoRoot, ['rev-parse', '--show-toplevel']);
if (!top || realpathSync(top) !== realpathSync(repoRoot)) return null;
+4 -1
View File
@@ -31,8 +31,11 @@ export type SupervisorKind = 'systemd' | 'launchd' | 'launchd-daemon' | 'docker-
* How Codeman was installed. `git` and `docker-compose` can self-update in
* place; `docker-compose` is a git checkout bind-mounted into the container, so
* the pull/build happen on the host filesystem and survive container recreation.
* `archive` is what `install.sh` leaves on a machine with no working git (its
* no-admin route): the source unpacked from an archive, with nothing for the
* in-app updater to pull into, so it updates by re-running the installer.
*/
export type InstallKind = 'git' | 'docker-compose' | 'npm' | 'unknown';
export type InstallKind = 'git' | 'docker-compose' | 'npm' | 'archive' | 'unknown';
/**
* Why an in-place container update is refused. Each is derived mechanically from
+37 -2
View File
@@ -18,6 +18,15 @@
* cache, a missing CLI re-ran the whole chain and stalled the event loop for up
* to 5s on every request, forever.
*
* One thing ends a backoff early: `install.sh` touches `CLI_INSTALLED_MARKER` in
* the data dir when it has installed a CLI, because Run reads this same answer, so
* "Run fails, add the agent, Run again" would otherwise fail twice. The check is
* one `stat` of that file, inside the backoff branch, so a resolved CLI never pays
* for it. It is deliberately not a re-walk of PATH during the backoff: that would
* put a synchronous stat of every PATH entry on the request path, where one dead
* network mount freezes the server (#516), and execute candidates that were
* already refused. A CLI installed some other way waits out the backoff as before.
*
* Test hermeticity: under vitest (`process.env.VITEST`) the production host
* short-circuits — IO primitives that were not injected become inert stubs, so
* a suite can never scan the machine's PATH or spawn login shells (the same
@@ -34,6 +43,7 @@ import { execFileSync } from 'node:child_process';
import { accessSync, constants, statSync } from 'node:fs';
import { basename, delimiter, dirname, isAbsolute, join } from 'node:path';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import { dataPath } from '../config/instance.js';
import { loginShellArgs, resolveLocalShell } from './shell-resolver.js';
const SAFE_BINARY_NAME = /^[a-z0-9][a-z0-9._-]*$/i;
@@ -42,6 +52,12 @@ const LOGIN_SHELL_END_MARKER = '__CODEMAN_CLI_RESOLVE_END__';
/** Maximum rendered length of each bounded diagnostic field, excluding its label. */
const DIAGNOSTIC_FIELD_MAX_LENGTH = 1024;
/**
* The file `install.sh` touches in the data dir once it has installed a CLI (`mark_cli_installed`).
* Its mtime is all that is read; test/install-sh-invariants.test.ts pins the name on both sides.
*/
export const CLI_INSTALLED_MARKER = 'cli-installed';
/** First retry window after a full-chain resolution miss. */
const RESOLVE_RETRY_BASE_MS = 60_000;
/**
@@ -79,6 +95,8 @@ export interface CliResolverHost {
findOnProcessPath(binary: string): string | null;
findInLoginShell(binary: string): string | null;
exists(path: string): boolean;
/** A number that changes when the installer has installed a CLI; 0 when it never did. */
installStamp?(): number;
}
export interface CandidateValidation<T> {
@@ -113,6 +131,8 @@ export interface ProductionCliResolverHostOptions {
shellArgs?: string[];
runCommand?: CliResolverCommandRunner;
isExecutableFile?: (path: string) => boolean;
/** Where the installer's marker is. Defaults to `CLI_INSTALLED_MARKER` in the data dir. */
installMarkerPath?: string;
/**
* Test-only escape hatch: keep the REAL IO primitives even under vitest.
* For tests that exercise `isExecutableRegularFile` against their own temp
@@ -202,6 +222,14 @@ export function createProductionCliResolverHost(options: ProductionCliResolverHo
return candidate && isExecutableFile(candidate) ? candidate : null;
},
exists: isExecutableFile,
installStamp: () => {
if (inert) return 0;
try {
return statSync(options.installMarkerPath ?? dataPath(CLI_INSTALLED_MARKER)).mtimeMs;
} catch {
return 0;
}
},
};
}
@@ -260,6 +288,8 @@ export function createCliExecutableResolver<T = undefined>(
let lastFailureAt = 0;
/** The invalidation generation the cached state above belongs to. */
let generation = binaryGenerations.get(options.binary) ?? 0;
/** The installer's stamp at the last run of the chain: only a CHANGE is news. */
let installStampSeen = 0;
const accept = (path: string | null, source: CliResolutionSource): CliResolution<T> | null => {
if (!path || !isAbsolute(path) || !host.exists(path)) return null;
const validation = options.validateCandidate?.(path) ?? ({ accepted: true } as CandidateValidation<T>);
@@ -285,8 +315,13 @@ export function createCliExecutableResolver<T = undefined>(
// Negative cache: a miss is remembered and the chain — whose login-shell
// tail is a synchronous 5s-bounded spawn — is not re-run until the
// backoff elapses. Without this, every status poll and Run click against
// a missing CLI froze the event loop for the full probe, forever.
if (failures > 0 && now() - lastFailureAt < cliResolveRetryDelayMs(failures)) return null;
// a missing CLI froze the event loop for the full probe, forever. A changed
// installer stamp is the one early way out (see the @fileoverview), and buys
// one run of the chain.
const installStamp = host.installStamp?.() ?? 0;
const installedSince = installStamp !== installStampSeen;
if (failures > 0 && !installedSince && now() - lastFailureAt < cliResolveRetryDelayMs(failures)) return null;
installStampSeen = installStamp;
cached = accept(host.findOnProcessPath(options.binary), 'process-path');
if (!cached) {
+5 -1
View File
@@ -1940,10 +1940,14 @@ Object.assign(CodemanApp.prototype, {
// `docker-compose` self-updates in place like `git` does — the container
// restarts itself. Anything else cannot.
if (data.installKind && data.installKind !== 'git' && data.installKind !== 'docker-compose') {
// `archive` = installed by install.sh on a machine with no working git: the
// installer replaces it with a fresh build, so point there, not at npm.
const hint =
data.supervisor === 'docker-compose'
? 'Update from the Docker host with <code>docker/Start-Codeman.sh</code>.'
: 'Update with <code>npm i -g aicodeman@latest</code>.';
: data.installKind === 'archive'
? 'It was installed without git. Re-run the installer to update: <code>curl -fsSL https://getcodeman.com/install | bash</code>'
: 'Update with <code>npm i -g aicodeman@latest</code>.';
this._setUpdateResult(`This install can't update itself (${escapeHtml(data.installKind)}). ${hint}`);
return;
}
+21 -3
View File
@@ -462,12 +462,26 @@ export function isRunningInContainer(): boolean {
return process.env.CODEMAN_IN_CONTAINER === '1' || existsSync('/.dockerenv');
}
function detectInstallKind(dir: string): InstallKind {
/**
* Marker `install.sh` writes into an install directory whose source came from an
* archive instead of a git clone (`SOURCE_MARKER` there; the two names are pinned
* to each other by `test/install-sh-invariants.test.ts`).
*/
export const ARCHIVE_INSTALL_MARKER = '.install-source';
/** How an `archive` install updates: the installer replaces it with a fresh build. */
export const ARCHIVE_UPDATE_HINT =
'This install has no git checkout, so it cannot update in place. Re-run the installer to update: ' +
'curl -fsSL https://getcodeman.com/install | bash';
export function detectInstallKind(dir: string): InstallKind {
// A container whose code is a bind-mounted checkout updates in place (the pull
// and build land on the host filesystem and survive container recreation). A
// container WITHOUT that mount runs a baked image copy — a pull there would go
// to the writable layer and vanish on the next `up`, so it is not updatable.
if (existsSync(join(dir, '.git'))) return isRunningInContainer() ? 'docker-compose' : 'git';
// install.sh on a machine with no working git: the source from an archive.
if (existsSync(join(dir, ARCHIVE_INSTALL_MARKER))) return 'archive';
// Global npm install ships only dist/ (no src/, no .git).
if (!existsSync(join(dir, 'src'))) return 'npm';
return 'unknown';
@@ -705,7 +719,9 @@ export async function checkForUpdate(): Promise<UpdateCheckResult> {
error:
info.installKind === 'unknown' && isRunningInContainer()
? 'This container runs a baked image copy with no repository mounted — self-update is unavailable. See docs/docker-self-update.md.'
: 'Not a git install — self-update is unavailable.',
: info.installKind === 'archive'
? ARCHIVE_UPDATE_HINT
: 'Not a git install — self-update is unavailable.',
};
}
@@ -829,7 +845,9 @@ export async function startUpdate(): Promise<StartUpdateResult> {
code: 'not-git',
message: isRunningInContainer()
? 'This container has no repository mounted. Update from the host with docker/Start-Codeman.sh.'
: 'This is not a git install. Update with: npm i -g aicodeman@latest',
: info.installKind === 'archive'
? ARCHIVE_UPDATE_HINT
: 'This is not a git install. Update with: npm i -g aicodeman@latest',
};
}
const existing = readCurrentUpdateStatus();
+103 -1
View File
@@ -1,9 +1,10 @@
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { chmodSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { EXEC_TIMEOUT_MS } from '../src/config/exec-timeout.js';
import {
CLI_INSTALLED_MARKER,
cliResolveRetryDelayMs,
createCliExecutableResolver,
createProductionCliResolverHost,
@@ -130,6 +131,107 @@ describe('createCliExecutableResolver', () => {
expect(findInLoginShell).toHaveBeenCalledTimes(2);
});
describe('the installer says a CLI was installed', () => {
// install.sh touches a marker when it installs a CLI, and the stamp is that file's
// mtime. A running server must see the new CLI at the next request, because Run reads
// this same answer: "Run fails, add the agent, Run again" must not fail twice.
function waiting() {
const clock = { now: 0, stamp: 1000 };
const findInLoginShell = vi.fn<() => string | null>().mockReturnValue(null);
const onPath = vi.fn<() => string | null>().mockReturnValue(null);
const installStamp = vi.fn(() => clock.stamp);
const h = host({
findInLoginShell,
findOnProcessPath: onPath,
exists: vi.fn((path) => path === '/new/bin/codex'),
installStamp,
});
const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: [], now: () => clock.now }, h);
expect(resolver.resolve()).toBeNull();
return { clock, findInLoginShell, onPath, installStamp, resolver };
}
it('ends the backoff at once when the stamp changes', () => {
const { clock, findInLoginShell, resolver } = waiting();
clock.now = 1;
findInLoginShell.mockReturnValue('/new/bin/codex');
clock.stamp = 2000;
expect(resolver.resolve()?.binaryPath).toBe('/new/bin/codex');
});
it('keeps the backoff, with nothing spawned, while the stamp stands still', () => {
const { clock, findInLoginShell, onPath, resolver } = waiting();
clock.now = cliResolveRetryDelayMs(1) - 1;
for (let i = 0; i < 50; i++) expect(resolver.resolve()).toBeNull();
expect(findInLoginShell).toHaveBeenCalledTimes(1);
expect(onPath).toHaveBeenCalledTimes(1);
});
it('a marker older than the server is not news: it does not cost a second chain', () => {
// A stamp present from the first look on must not read as a change at the second.
const { clock, findInLoginShell, resolver } = waiting();
clock.now = 1;
expect(resolver.resolve()).toBeNull();
expect(findInLoginShell).toHaveBeenCalledTimes(1);
});
it('one change buys one look: a CLI that is still missing goes back to waiting', () => {
const { clock, findInLoginShell, resolver } = waiting();
clock.now = 1;
clock.stamp = 2000;
expect(resolver.resolve()).toBeNull();
expect(resolver.resolve()).toBeNull();
expect(resolver.resolve()).toBeNull();
expect(findInLoginShell).toHaveBeenCalledTimes(2);
});
it('never looks at the marker once a CLI is resolved', () => {
const { clock, findInLoginShell, installStamp, resolver } = waiting();
findInLoginShell.mockReturnValue('/new/bin/codex');
clock.stamp = 2000;
expect(resolver.resolve()?.binaryPath).toBe('/new/bin/codex');
installStamp.mockClear();
for (let i = 0; i < 10; i++) resolver.resolve();
expect(installStamp).not.toHaveBeenCalled();
});
it('a stamp going backwards is a change too, or a restored data folder would hide one', () => {
const { clock, findInLoginShell, resolver } = waiting();
clock.now = 1;
clock.stamp = 500;
resolver.resolve();
expect(findInLoginShell).toHaveBeenCalledTimes(2);
});
it('reads the real marker file, and treats a missing one as no news', () => {
const dir = mkdtempSync(join(tmpdir(), 'codeman-cli-marker-'));
const marker = join(dir, CLI_INSTALLED_MARKER);
try {
const h = createProductionCliResolverHost({ allowRealIoUnderVitest: true, installMarkerPath: marker });
expect(h.installStamp?.()).toBe(0);
writeFileSync(marker, '');
utimesSync(marker, 1000, 1000);
const first = h.installStamp?.();
expect(first).toBeGreaterThan(0);
utimesSync(marker, 2000, 2000);
expect(h.installStamp?.()).not.toBe(first);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('reads no marker under vitest, even one that is there', () => {
const dir = mkdtempSync(join(tmpdir(), 'codeman-cli-marker-'));
const marker = join(dir, CLI_INSTALLED_MARKER);
try {
writeFileSync(marker, '');
expect(createProductionCliResolverHost({ installMarkerPath: marker }).installStamp?.()).toBe(0);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
it('invalidation drops a negative-cache backoff immediately (a CLI installed from Settings)', () => {
let now = 0;
const findInLoginShell = vi.fn<() => string | null>().mockReturnValueOnce(null).mockReturnValue('/new/bin/grokx');
+736
View File
@@ -0,0 +1,736 @@
/**
* @fileoverview The graphical install's screens in the Codeman Desktop connect page.
*
* Runs the real packages/desktop/assets/connect.html in jsdom with a fake native side:
* `window.ipc` records what the page asks the app for, and `onNative` is what the app
* calls. The Rust side of the same messages is pinned in packages/desktop/src/policy.rs
* (`ipc_graphical_install_commands`) and src/gui_install.rs, which do not build on Linux
* CI, so the spelling of every message the page sends is asserted here.
*
* Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { JSDOM } from 'jsdom';
import { afterEach, describe, expect, it } from 'vitest';
const HTML = readFileSync(resolve(import.meta.dirname, '../packages/desktop/assets/connect.html'), 'utf8');
type Sent = { cmd: string; [key: string]: unknown };
type Cli = { id: string; label: string; installed: boolean; installable: boolean; docs: string };
const cli = (id: string, label: string, over: Partial<Cli> = {}): Cli => ({
id,
label,
installed: false,
installable: true,
docs: `https://example.org/${id}`,
...over,
});
const plan = (over: Record<string, unknown> = {}) => ({
schema: 1,
os: 'macos',
arch: 'arm64',
blocker: '',
needs: { node: true, tmux: true, git: false },
clis: [cli('claude', 'Claude Code'), cli('codex', 'Codex'), cli('deepseek', 'DeepSeek', { installable: false })],
...over,
});
const NOT_INSTALLED = {
url: 'http://127.0.0.1:3000/',
state: 'notInstalled',
installed: false,
installComplete: false,
installRunning: false,
installInApp: false,
elsewhere: [],
outcome: { kind: 'refused' },
};
let dom: JSDOM | null = null;
/** `first`: what the app says to a page that just loaded, with no look at this Mac yet. */
function open(first?: Record<string, unknown>) {
const sent: Sent[] = [];
dom = new JSDOM(HTML, {
url: 'codemanapp://localhost/index.html',
runScripts: 'dangerously',
beforeParse(window) {
(window as unknown as { ipc: unknown }).ipc = { postMessage: (m: string) => sent.push(JSON.parse(m)) };
},
});
const window = dom.window as unknown as Window & {
__codemanDesktop: { onNative(m: unknown): void };
};
const doc = window.document;
const $ = (id: string) => doc.getElementById(id) as HTMLElement;
const input = (id: string) => $(id) as HTMLInputElement;
const native = (m: unknown) => window.__codemanDesktop.onNative(m);
const click = (id: string) => $(id).click();
const visible = (id: string) => {
const el = $(id);
if (el.hidden) return false;
for (let p = el.parentElement; p; p = p.parentElement) if (p.hidden) return false;
return true;
};
const rail = () =>
Array.from(doc.querySelectorAll('#wiz-rail li')).map((li) => [li.querySelector('span')?.textContent, li.className]);
const sentOf = (cmd: string) => sent.filter((m) => m.cmd === cmd);
native({ type: 'init', servers: [], autoConnect: false, startFullscreen: false, version: '0.1.0', ...first });
if (!first) native({ type: 'localStatus', status: NOT_INSTALLED });
return { sent, sentOf, doc, $, input, native, click, visible, rail };
}
/** Open the wizard and answer its first question with `p`. */
function toPlan(p = plan()) {
const t = open();
t.click('local-install');
t.native({ type: 'installPlan', plan: p });
return t;
}
afterEach(() => {
dom?.window.close();
dom = null;
});
describe('starting the graphical install', () => {
it('asks the app for the installer instead of opening Terminal', () => {
const t = open();
t.click('local-install');
expect(t.sentOf('installPrepare')).toHaveLength(1);
expect(t.sentOf('installLocal')).toHaveLength(0);
expect(t.$('wiz-title').textContent).toBe('Looking at this Mac');
expect(t.visible('wizard')).toBe(true);
});
it('hands the whole card to the install', () => {
const t = open();
t.click('local-install');
expect(t.doc.querySelector('.card')?.classList.contains('wizard-on')).toBe(true);
});
it('goes back to the tabs from any question', () => {
const t = toPlan();
t.click('wiz-back');
expect(t.visible('wizard')).toBe(false);
expect(t.doc.querySelector('.card')?.classList.contains('wizard-on')).toBe(false);
});
it('offers no way out while preparing, so two prepares never share the folder', () => {
const t = open();
t.click('local-install');
expect(t.visible('wiz-back')).toBe(false);
expect(t.visible('wiz-next')).toBe(false);
});
it('explains a failed download and offers Try again and Terminal', () => {
const t = open();
t.click('local-install');
t.native({ type: 'installPrepareFailed', message: 'Could not download the installer.' });
expect(t.$('wiz-message').textContent).toBe('Could not download the installer.');
expect(t.visible('wiz-terminal')).toBe(true);
t.click('wiz-next');
expect(t.sentOf('installPrepare')).toHaveLength(2);
});
});
describe('choosing a coding agent', () => {
it('asks when this Mac has none, listing what the installer can install, first one chosen', () => {
const t = toPlan();
expect(t.$('wiz-title').textContent).toBe('Choose a coding agent');
const radios = Array.from(t.doc.querySelectorAll<HTMLInputElement>('#wiz-agents input'));
expect(radios.map((r) => r.value)).toEqual(['claude', 'codex']);
expect(radios.map((r) => r.checked)).toEqual([true, false]);
});
it('does not ask when one is already there, and names it instead', () => {
const t = toPlan(
plan({ clis: [cli('claude', 'Claude Code', { installed: true, installable: false }), cli('codex', 'Codex')] })
);
expect(t.$('wiz-title').textContent).toBe('Who can reach Codeman?');
expect(t.$('wiz-summary').textContent).toContain('Uses Claude Code, already on this Mac');
t.click('wiz-back');
expect(t.visible('wizard')).toBe(false);
});
it('sends no agent then, so the installer never adds one', () => {
const t = toPlan(
plan({ clis: [cli('claude', 'Claude Code', { installed: true, installable: false }), cli('codex', 'Codex')] })
);
t.click('wiz-next');
expect(t.sentOf('installStart')[0].aiCli).toBeUndefined();
});
it('skips the question when nothing can be installed', () => {
const t = toPlan(plan({ clis: [cli('deepseek', 'DeepSeek', { installable: false })] }));
expect(t.$('wiz-title').textContent).toBe('Who can reach Codeman?');
expect(t.$('wiz-summary').textContent).toContain('No coding agent yet');
});
it('installs the one that was picked', () => {
const t = toPlan();
const codex = t.doc.querySelector<HTMLInputElement>('#wiz-agents input[value=codex]')!;
codex.click();
expect(t.$('wiz-agent-note').textContent).toContain('You sign in to Codex');
t.click('wiz-next');
expect(t.$('wiz-summary').textContent).toContain('Installs Codex');
t.click('wiz-next');
expect(t.sentOf('installStart')).toEqual([{ cmd: 'installStart', aiCli: 'codex' }]);
});
it('Skip for now installs none, and says what that means', () => {
const t = toPlan();
t.click('wiz-skip');
expect(t.$('wiz-summary').textContent).toContain('No coding agent yet');
t.click('wiz-next');
expect(t.sentOf('installStart')).toEqual([{ cmd: 'installStart' }]);
});
it('Back from the next question returns to the choice, and keeps it', () => {
const t = toPlan();
t.doc.querySelector<HTMLInputElement>('#wiz-agents input[value=codex]')!.click();
t.click('wiz-next');
t.click('wiz-back');
expect(t.$('wiz-title').textContent).toBe('Choose a coding agent');
expect(t.doc.querySelector<HTMLInputElement>('#wiz-agents input:checked')?.value).toBe('codex');
});
it('opens an agent docs link through the app, and only https', () => {
const t = toPlan();
t.doc.querySelector<HTMLAnchorElement>('#wiz-agent-note a')!.click();
expect(t.sentOf('openExternal')).toEqual([{ cmd: 'openExternal', url: 'https://example.org/claude' }]);
const u = toPlan(plan({ clis: [cli('x', 'X', { docs: 'javascript:alert(1)' })] }));
expect(u.doc.querySelector('#wiz-agent-note a')).toBeNull();
});
it('shows a label from the installer as text, never as markup', () => {
const t = toPlan(plan({ clis: [cli('x', '<img src=x onerror=alert(1)>')] }));
expect(t.doc.querySelector('#wiz-agents img')).toBeNull();
expect(t.$('wiz-agents').textContent).toContain('<img');
});
});
describe('choosing who can reach Codeman', () => {
const toReach = () => {
const t = toPlan();
t.click('wiz-next');
return t;
};
const network = (t: ReturnType<typeof open>) =>
t.doc.querySelector<HTMLInputElement>('input[name=reach][value=network]')!.click();
it('is this Mac alone unless changed, with no password', () => {
const t = toReach();
expect(t.visible('wiz-password')).toBe(false);
t.click('wiz-next');
expect(t.sentOf('installStart')[0].networkPassword).toBeUndefined();
});
it('refuses the network without a password', () => {
const t = toReach();
network(t);
expect(t.visible('wiz-password')).toBe(true);
t.click('wiz-next');
expect(t.sentOf('installStart')).toHaveLength(0);
expect(t.$('wiz-message').textContent).toContain('Set a password');
});
it('refuses two different passwords', () => {
const t = toReach();
network(t);
t.input('wiz-pw').value = 'one';
t.input('wiz-pw2').value = 'two';
t.click('wiz-next');
expect(t.sentOf('installStart')).toHaveLength(0);
expect(t.$('wiz-message').textContent).toContain('different');
});
it('sends the password once and clears the fields', () => {
const t = toReach();
network(t);
t.input('wiz-pw').value = 's3cret';
t.input('wiz-pw2').value = 's3cret';
t.click('wiz-next');
expect(t.sentOf('installStart')).toEqual([{ cmd: 'installStart', networkPassword: 's3cret', aiCli: 'claude' }]);
expect(t.input('wiz-pw').value).toBe('');
expect(t.input('wiz-pw2').value).toBe('');
});
it('never sends a password a person typed before going back to this Mac', () => {
const t = toReach();
network(t);
t.input('wiz-pw').value = 's3cret';
t.input('wiz-pw2').value = 's3cret';
t.doc.querySelector<HTMLInputElement>('input[name=reach][value=local]')!.click();
t.click('wiz-next');
expect(t.sentOf('installStart')[0].networkPassword).toBeUndefined();
});
it('keeps the Terminal route, carrying the choice', () => {
const t = toReach();
network(t);
t.click('wiz-terminal');
expect(t.sentOf('installLocal')).toEqual([{ cmd: 'installLocal', network: true }]);
expect(t.visible('wizard')).toBe(false);
});
});
describe('when the no-admin route cannot finish here', () => {
it('says why and sends the person to Terminal', () => {
const t = toPlan(plan({ blocker: 'The tmux project’s build needs macOS 15 or newer.' }));
expect(t.$('wiz-message').textContent).toContain('macOS 15');
expect(t.$('wiz-next').textContent).toBe('Install in Terminal');
expect(t.visible('wiz-agent-step')).toBe(false);
t.click('wiz-next');
expect(t.sentOf('installLocal')).toHaveLength(1);
expect(t.sentOf('installStart')).toHaveLength(0);
});
});
describe('following the install', () => {
const toRun = (p = plan()) => {
const t = toPlan(p);
t.click('wiz-next');
t.click('wiz-next');
t.native({ type: 'installRunning', port: 3001 });
return t;
};
it('draws only the phases this install has', () => {
const noDownloads = toRun(plan({ needs: { node: false, tmux: false, git: false } }));
expect(noDownloads.rail().map((r) => r[0])).toEqual([
'Installing Claude Code',
'Downloading Codeman',
'Installing packages',
'Building Codeman',
'Starting Codeman',
]);
const tmuxOnly = toRun(plan({ needs: { node: false, tmux: true, git: false } }));
expect(tmuxOnly.rail()[0][0]).toBe('Getting tmux');
});
it('moves the rail along as the installer reports, and ignores a phase it does not know', () => {
const t = toRun();
t.native({ type: 'installUpdate', phase: 'runtime', log: [] });
t.native({ type: 'installUpdate', phase: 'packages', log: ['==> Installing dependencies...'] });
t.native({ type: 'installUpdate', phase: 'somethingnew', log: [] });
expect(t.rail()).toEqual([
['Getting Node.js and tmux', 'done'],
['Installing Claude Code', 'done'],
['Downloading Codeman', 'done'],
['Installing packages', 'now'],
['Building Codeman', ''],
['Starting Codeman', ''],
]);
expect(t.$('wiz-log').textContent).toContain('Installing dependencies');
expect(t.$('wiz-clock')).not.toBeNull();
});
it('asks the app to cancel, once', () => {
const t = toRun();
t.click('wiz-cancel');
t.click('wiz-cancel');
expect(t.sentOf('installCancel')).toHaveLength(1);
});
it('a cancelled run goes back to the tabs and looks again', () => {
const t = toRun();
t.native({ type: 'installFinished', ok: false, cancelled: true, error: null, log: [] });
expect(t.visible('wizard')).toBe(false);
expect(t.sentOf('localStatus').length).toBeGreaterThan(0);
});
it('a failed run shows the cause on its phase, opens the output and offers Try again', () => {
const t = toRun();
t.native({ type: 'installUpdate', phase: 'build', log: [] });
t.native({
type: 'installFinished',
ok: false,
cancelled: false,
error: 'Building Codeman failed (exit 1).',
log: ['npm error'],
});
expect(t.$('wiz-title').textContent).toBe('The install stopped');
expect(t.$('wiz-message').textContent).toBe('Building Codeman failed (exit 1).');
expect(t.rail().find((r) => r[1] === 'failed')?.[0]).toBe('Building Codeman');
expect((t.$('wiz-details') as HTMLDetailsElement).open).toBe(true);
expect(t.$('wiz-log').textContent).toContain('npm error');
t.click('wiz-next');
expect(t.sentOf('installPrepare')).toHaveLength(2);
});
it('Try again keeps what was chosen', () => {
const t = toPlan();
t.doc.querySelector<HTMLInputElement>('#wiz-agents input[value=codex]')!.click();
t.click('wiz-next');
t.click('wiz-next');
t.native({ type: 'installFinished', ok: false, cancelled: false, error: 'x', log: [] });
t.click('wiz-next');
t.native({ type: 'installPlan', plan: plan() });
expect(t.doc.querySelector<HTMLInputElement>('#wiz-agents input:checked')?.value).toBe('codex');
});
it('a run with no error text still says something', () => {
const t = toRun();
t.native({ type: 'installFinished', ok: false, cancelled: false, error: null, log: [] });
expect(t.$('wiz-message').textContent).toContain('without saying why');
});
it('a start the app refused is shown as a failure', () => {
const t = toPlan();
t.click('wiz-next');
t.click('wiz-next');
t.native({ type: 'installStartFailed', message: 'Codeman is already being installed.' });
expect(t.$('wiz-message').textContent).toBe('Codeman is already being installed.');
});
it('a finished run opens Codeman at its own port, not at whatever answers on 3000', () => {
const t = toRun();
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
expect(t.$('wiz-title').textContent).toBe('Starting Codeman');
expect(t.rail().every((r) => r[1] === 'done')).toBe(true);
expect(t.sentOf('localStatus').at(-1)?.quick).toBe(true);
const running = { ...NOT_INSTALLED, state: 'running', version: '1.48.0', installed: true };
t.native({ type: 'localStatus', status: { ...running, url: 'http://127.0.0.1:3000/' } });
expect(t.sentOf('connect')).toHaveLength(0);
t.native({ type: 'localStatus', status: { ...running, url: 'http://127.0.0.1:3001/' } });
expect(t.sentOf('connect')).toEqual([expect.objectContaining({ url: 'http://127.0.0.1:3001/' })]);
expect(t.visible('wizard')).toBe(false);
});
});
describe('an install that was already running', () => {
it('is picked up from the app, with no question asked', () => {
const t = open();
t.native({
type: 'localStatus',
status: { ...NOT_INSTALLED, installRunning: true, installInApp: true, installPort: 3002 },
});
expect(t.sentOf('installAttach')).toHaveLength(1);
expect(t.$('wiz-title').textContent).toBe('Installing Codeman');
expect(t.visible('wiz-cancel')).toBe(true);
});
it('asks only once, however many looks arrive', () => {
const t = open();
const status = { ...NOT_INSTALLED, installRunning: true, installInApp: true };
t.native({ type: 'localStatus', status });
t.native({ type: 'localStatus', status });
expect(t.sentOf('installAttach')).toHaveLength(1);
});
it('draws what the installer reports, since it has no plan', () => {
const t = open();
t.native({ type: 'localStatus', status: { ...NOT_INSTALLED, installRunning: true, installInApp: true } });
t.native({ type: 'installUpdate', phase: 'agent', log: [] });
t.native({ type: 'installUpdate', phase: 'build', log: [] });
expect(t.rail().map((r) => r[0])).toContain('Installing a coding agent');
expect(t.rail().find((r) => r[1] === 'now')?.[0]).toBe('Building Codeman');
});
it('never takes a graphical install for one in Terminal', () => {
const t = open();
t.native({ type: 'localStatus', status: { ...NOT_INSTALLED, installRunning: true, installInApp: true } });
expect(t.$('local-title').textContent).not.toContain('Terminal');
expect(t.visible('local-stop')).toBe(false);
});
it('an install still running in Terminal keeps the old wait', () => {
const t = open();
t.native({ type: 'localStatus', status: { ...NOT_INSTALLED, installRunning: true, installInApp: false } });
expect(t.sentOf('installAttach')).toHaveLength(0);
expect(t.$('local-title').textContent).toContain('Terminal');
});
});
describe('adding a coding agent to a Mac that has Codeman', () => {
const RUNNING = { ...NOT_INSTALLED, state: 'running', version: '1.50.0', installed: true, installComplete: true };
const STOPPED = { ...NOT_INSTALLED, state: 'stopped', installed: true, installComplete: true };
const AGENTS = {
clis: [
cli('claude', 'Claude Code', { installed: true, installable: false }),
cli('codex', 'Codex'),
cli('copilot', 'GitHub Copilot'),
cli('deepseek', 'DeepSeek', { installable: false }),
],
};
function chooser(status: Record<string, unknown> = RUNNING, over: Record<string, unknown> = AGENTS) {
const t = open();
t.native({ type: 'localStatus', status });
t.click('local-agents');
t.native({ type: 'installPlan', plan: plan({ needs: { node: false, tmux: false, git: false }, ...over }) });
return t;
}
function installing(status: Record<string, unknown> = RUNNING) {
const t = chooser(status);
t.click('wiz-next');
t.native({ type: 'installUpdate', phase: 'agent', log: ['Installing Codex...'] });
return t;
}
it('is offered whenever Codeman is here, and never when it is not', () => {
const t = open();
t.native({ type: 'localStatus', status: NOT_INSTALLED });
expect(t.visible('local-agents')).toBe(false);
for (const status of [
RUNNING,
STOPPED,
{ ...RUNNING, state: 'needsLogin' },
{ ...RUNNING, state: 'runningElsewhere', elsewhere: ['127.0.0.1:4000'] },
]) {
t.native({ type: 'localStatus', status });
expect(t.visible('local-agents'), String(status.state)).toBe(true);
}
});
it('is not offered while an install in Terminal still runs', () => {
const t = open();
t.native({ type: 'localStatus', status: { ...STOPPED, installRunning: true } });
expect(t.visible('local-agents')).toBe(false);
});
it('asks the app for the agent route, never the install route', () => {
const t = open();
t.native({ type: 'localStatus', status: RUNNING });
t.click('local-agents');
expect(t.sentOf('agentPrepare')).toHaveLength(1);
expect(t.sentOf('installPrepare')).toHaveLength(0);
expect(t.$('wiz-title').textContent).toBe('Looking at this Mac');
expect(t.visible('wiz-back')).toBe(false); // A second prepare would clear the first one's folder.
});
it('lists what can be added first, what is here after it, and leaves out the rest', () => {
const t = chooser();
expect(t.$('wiz-title').textContent).toBe('Add a coding agent');
const rows = [...t.$('wiz-agents').querySelectorAll('label')];
expect(rows.map((r) => r.querySelector('b')?.textContent)).toEqual(['Codex', 'GitHub Copilot', 'Claude Code']);
const inputs = rows.map((r) => r.querySelector('input') as HTMLInputElement);
expect(inputs.map((i) => i.disabled)).toEqual([false, false, true]);
expect(rows[2].textContent).toContain('Installed');
expect(inputs[0].checked).toBe(true);
});
it('asks nothing about Codeman, whatever the installer says about installing it', () => {
const t = chooser(RUNNING, { ...AGENTS, blocker: 'macOS is too old for the tmux download.' });
expect(t.$('wiz-title').textContent).toBe('Add a coding agent');
expect(t.visible('wiz-reach-step')).toBe(false);
expect(t.visible('wiz-skip')).toBe(false);
expect(t.visible('wiz-terminal')).toBe(false);
});
it('sends the one agent chosen, and names it on the button', () => {
const t = chooser();
expect(t.$('wiz-next').textContent).toBe('Install Codex');
const copilot = t.$('wiz-agents').querySelector('input[value=copilot]') as HTMLInputElement;
copilot.click();
expect(t.$('wiz-next').textContent).toBe('Install GitHub Copilot');
t.click('wiz-next');
expect(t.sentOf('agentStart')).toEqual([{ cmd: 'agentStart', aiCli: 'copilot' }]);
expect(t.sentOf('installStart')).toHaveLength(0);
expect(t.$('wiz-title').textContent).toBe('Installing GitHub Copilot');
});
it('draws one row, for that agent', () => {
const t = installing();
expect(t.rail()).toEqual([['Installing Codex', 'now']]);
expect(t.visible('wiz-cancel')).toBe(true);
});
it('ends on what happened, and opens Codeman only when asked', () => {
const t = installing();
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
expect(t.$('wiz-title').textContent).toBe('Codex is installed');
expect(t.$('wiz-lead').textContent).toContain('reload it, or give it a few minutes');
expect(t.rail()).toEqual([['Installing Codex', 'done']]);
expect(t.sentOf('localStatus').length).toBeGreaterThan(0);
expect(t.sentOf('connect')).toHaveLength(0);
t.native({ type: 'localStatus', status: RUNNING });
expect(t.$('wiz-title').textContent).toBe('Codex is installed');
expect(t.$('wiz-next').textContent).toBe('Open Codeman');
expect(t.$('wiz-back').textContent).toBe('Done');
t.click('wiz-next');
expect(t.sentOf('connect')).toEqual([expect.objectContaining({ url: RUNNING.url })]);
expect(t.visible('wizard')).toBe(false);
});
it('has nothing to open when Codeman is stopped, and Done goes back to This Mac', () => {
const t = installing(STOPPED);
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
t.native({ type: 'localStatus', status: STOPPED });
expect(t.visible('wiz-next')).toBe(false);
t.click('wiz-back');
expect(t.visible('wizard')).toBe(false);
expect(t.visible('local-start')).toBe(true);
expect(t.visible('local-agents')).toBe(true);
});
it('offers another only when there is another, and asks the installer again', () => {
const t = installing();
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
expect(t.$('wiz-skip').textContent).toBe('Add another');
t.click('wiz-skip');
expect(t.sentOf('agentPrepare')).toHaveLength(2);
expect(t.$('wiz-title').textContent).toBe('Looking at this Mac');
const last = chooser(RUNNING, { clis: [cli('codex', 'Codex')] });
last.click('wiz-next');
last.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
expect(last.visible('wiz-skip')).toBe(false);
});
it('says so when there is nothing left to add', () => {
const t = chooser(RUNNING, { clis: [cli('claude', 'Claude Code', { installed: true, installable: false })] });
expect(t.$('wiz-lead').textContent).toContain('already on this Mac');
expect(t.visible('wiz-next')).toBe(false);
expect(t.visible('wiz-back')).toBe(true);
t.click('wiz-back');
expect(t.visible('wizard')).toBe(false);
});
it('a failure shows the reason, and Try again asks again with the same choice', () => {
const t = chooser();
(t.$('wiz-agents').querySelector('input[value=copilot]') as HTMLInputElement).click();
t.click('wiz-next');
t.native({
type: 'installFinished',
ok: false,
cancelled: false,
error: 'GitHub Copilot did not install.',
log: ['npm ERR! nope'],
});
expect(t.$('wiz-title').textContent).toBe('The install stopped');
expect(t.$('wiz-message').textContent).toBe('GitHub Copilot did not install.');
expect(t.$('wiz-log').textContent).toContain('npm ERR! nope');
expect(t.visible('wiz-terminal')).toBe(false);
expect(t.visible('wiz-back')).toBe(true);
t.click('wiz-next');
expect(t.sentOf('agentPrepare')).toHaveLength(2);
t.native({ type: 'installPlan', plan: plan(AGENTS) });
expect(t.$('wiz-next').textContent).toBe('Install GitHub Copilot');
});
it('a list it could not get says so, with no Terminal route to Codeman', () => {
const t = open();
t.native({ type: 'localStatus', status: RUNNING });
t.click('local-agents');
t.native({ type: 'installPrepareFailed', message: 'No network.' });
expect(t.$('wiz-title').textContent).toBe('Could not get the list of coding agents');
expect(t.$('wiz-message').textContent).toBe('No network.');
expect(t.visible('wiz-terminal')).toBe(false);
t.click('wiz-next');
expect(t.sentOf('agentPrepare')).toHaveLength(2);
});
it('Cancel goes through the same command and leaves This Mac as it was', () => {
const t = installing();
t.click('wiz-cancel');
expect(t.sentOf('installCancel')).toHaveLength(1);
t.native({ type: 'installFinished', ok: false, cancelled: true, error: null, log: [] });
expect(t.visible('wizard')).toBe(false);
expect(t.$('status-text').textContent).toContain('Stopped installing Codex.');
});
it('is picked up after the app was reopened, as an agent run and never as an install', () => {
const t = open();
t.native({ type: 'localStatus', status: { ...RUNNING, agentInstallInApp: true } });
expect(t.sentOf('agentAttach')).toHaveLength(1);
expect(t.sentOf('installAttach')).toHaveLength(0);
expect(t.$('wiz-title').textContent).toBe('Installing a coding agent');
t.native({ type: 'installUpdate', phase: 'agent', log: [] });
expect(t.rail()).toEqual([['Installing a coding agent', 'now']]);
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
expect(t.$('wiz-title').textContent).toBe('The coding agent is installed');
});
});
describe('the menu item that adds a coding agent', () => {
const RUNNING = { ...NOT_INSTALLED, state: 'running', version: '1.50.0', installed: true, installComplete: true };
it('opens the chooser on a page that is up', () => {
const t = open();
t.native({ type: 'localStatus', status: RUNNING });
t.native({ type: 'addAgent' });
expect(t.sentOf('agentPrepare')).toHaveLength(1);
});
it('waits for the first look when it left a Codeman page to get here', () => {
const t = open({ servers: [{ url: 'http://example.org:3000/' }], addAgent: true });
expect(t.sentOf('agentPrepare')).toHaveLength(0);
t.native({ type: 'localStatus', status: RUNNING });
expect(t.sentOf('agentPrepare')).toHaveLength(1);
expect(t.$('tab-local').getAttribute('aria-selected')).toBe('true');
});
it('leaves a Mac without Codeman on This Mac, with Install', () => {
const t = open({ addAgent: true });
t.native({ type: 'localStatus', status: NOT_INSTALLED });
expect(t.sentOf('agentPrepare')).toHaveLength(0);
expect(t.visible('wizard')).toBe(false);
expect(t.visible('local-install')).toBe(true);
});
it('never replaces a run on screen', () => {
const t = open();
t.native({ type: 'localStatus', status: { ...RUNNING, agentInstallInApp: true } });
t.native({ type: 'addAgent' });
expect(t.sentOf('agentPrepare')).toHaveLength(0);
});
});
describe('after Skip for now', () => {
function skipped() {
const t = open();
t.native({ type: 'localStatus', status: NOT_INSTALLED });
t.click('local-install');
t.native({ type: 'installPlan', plan: plan() });
t.click('wiz-skip');
t.click('wiz-next');
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
t.native({
type: 'localStatus',
status: { ...NOT_INSTALLED, state: 'running', installed: true, version: '1.50.0' },
});
return t;
}
it('stops at Codeman being ready instead of opening it with nothing to run', () => {
const t = skipped();
expect(t.$('wiz-title').textContent).toBe('Codeman is ready');
expect(t.$('wiz-lead').textContent).toContain('no coding agent');
expect(t.sentOf('connect')).toHaveLength(0);
});
it('Open Codeman opens it anyway', () => {
const t = skipped();
t.click('wiz-next');
expect(t.sentOf('connect')).toHaveLength(1);
});
it('Add a coding agent goes to the chooser', () => {
const t = skipped();
expect(t.$('wiz-skip').textContent).toBe('Add a coding agent');
t.click('wiz-skip');
expect(t.sentOf('agentPrepare')).toHaveLength(1);
});
it('an install that got an agent still opens Codeman by itself', () => {
const t = open();
t.native({ type: 'localStatus', status: NOT_INSTALLED });
t.click('local-install');
t.native({ type: 'installPlan', plan: plan() });
t.click('wiz-next');
t.click('wiz-next');
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
t.native({
type: 'localStatus',
status: { ...NOT_INSTALLED, state: 'running', installed: true, version: '1.50.0' },
});
expect(t.sentOf('connect')).toHaveLength(1);
});
});
File diff suppressed because it is too large. Load diff
+6
View File
@@ -428,14 +428,20 @@ describe('EditHistorySync', () => {
host.append('main', editLine('t1', T0, '/w/a.ts'));
const orig = host.read;
let first = true;
let inRead!: () => void;
const reachedRead = new Promise<void>((r) => (inRead = r));
host.read = async (...a) => {
if (first) {
first = false;
inRead();
await gate;
}
return orig(...a);
};
const slow = sync.sync(local);
// Only once s1 holds the gate: the sessions load their stores independently, so a later one
// could reach the read first, take the gate and hang the loop below before `release()`.
await reachedRead;
for (let i = 1; i <= MAX_LOADED_STORES + 1; i++) await sync.sync({ ...local, id: `s1-${i}` });
release();
expect(await slow).toEqual({ state: 'ok' });
+21
View File
@@ -18,6 +18,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { execFileSync, spawnSync } from 'node:child_process';
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
@@ -171,6 +172,26 @@ describe('resolveGitHooksDir (temp repos)', () => {
expect(resolveGitHooksDir(dir)).toBeNull();
});
it('never runs git for a directory that has no .git', () => {
// A source-archive or npm install. On a Mac without the Xcode Command Line Tools
// /usr/bin/git is a stub that opens Apple's install dialog, and postinstall calls this
// on every `npm install`, so "not a checkout" must be answered without spawning git.
const dir = join(scratch, `plain-${++counter}`);
const bin = join(scratch, `fakebin-${counter}`);
const ran = join(scratch, `git-ran-${counter}`);
mkdirSync(dir);
mkdirSync(bin);
writeFileSync(join(bin, 'git'), `#!/bin/sh\necho ran > "${ran}"\nexit 1\n`, { mode: 0o755 });
const path = process.env.PATH;
process.env.PATH = `${bin}:${path}`;
try {
expect(resolveGitHooksDir(dir)).toBeNull();
} finally {
process.env.PATH = path;
}
expect(existsSync(ran), 'git was executed for a directory with no .git').toBe(false);
});
it('returns null when a repo-local core.hooksPath points outside the repo', () => {
const repo = newRepo();
const outside = join(scratch, `shared-hooks-${counter}`);
+802 -1
View File
@@ -12,9 +12,13 @@
import { describe, expect, it } from 'vitest';
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
import { CLI_INSTALLED_MARKER } from '../src/utils/cli-executable-resolver.js';
import { ARCHIVE_INSTALL_MARKER } from '../src/web/self-update.js';
const INSTALL_SH = fileURLToPath(new URL('../install.sh', import.meta.url));
const SOURCE = readFileSync(INSTALL_SH, 'utf-8');
@@ -369,6 +373,396 @@ describe('install.sh AI CLI install menu', () => {
});
});
describe('install.sh --ai-cli answers the menu up front', () => {
// The desktop app installs with no terminal, so the agent is chosen in its window and
// arrives as a flag. Everything here runs the real functions in a real bash, with
// detection pointed at nothing, every trusted command rewritten to record which entry
// ran, and no tty, which is what the app's child process has.
const DRIVER = `
set -euo pipefail
export CODEMAN_INSTALL_SH_LIB=1
. "$1"
set +e
parse_flags "\${@:2}" || exit 9
set -e
k=0; while [[ $k -lt \${#CLI_ALL_BINS[@]} ]]; do CLI_ALL_BINS[$k]="codeman-test-no-such-bin-$k"; k=$((k + 1)); done
k=0; while [[ $k -lt \${#CLI_ALL_PATHS[@]} ]]; do CLI_ALL_PATHS[$k]="/nonexistent/codeman-test/$k"; k=$((k + 1)); done
k=0; while [[ $k -lt \${#CLI_INSTALL_CMD_TRUSTED[@]} ]]; do
if [[ -n "\${CLI_INSTALL_CMD_TRUSTED[$k]}" ]]; then
if [[ "\${KEEP_BREW:-}" == "1" && "\${CLI_INSTALL_CMD_TRUSTED[$k]}" == brew\\ * ]]; then :; else
CLI_INSTALL_CMD_TRUSTED[$k]="echo RAN=\${CLI_IDS[$k]}"
fi
fi
k=$((k + 1))
done
CLI_DETECT_DONE=""
detect_all_clis
NONINTERACTIVE=\${FORCE_NONINTERACTIVE:-0}
DOWNLOADER=curl
has_tty() { return 1; }
offer_ai_cli_install
echo "REACHED THE STEP AFTER THE MENU"
`;
function drive(args: string[], env: Record<string, string> = {}) {
const result = spawnSync('bash', ['-c', DRIVER, 'bash', INSTALL_SH, ...args], {
encoding: 'utf-8',
timeout: 30_000,
env: { ...process.env, ...env },
});
const stdout = result.stdout ?? '';
// Anchored: the skip hints print each command's text, so `echo RAN=x` there ran nothing.
const ran = [...stdout.matchAll(/^RAN=(.+)$/gm)].map((m) => m[1]);
return { status: result.status, stdout, stderr: result.stderr ?? '', ran };
}
it('none installs nothing and carries on, even with CODEMAN_NONINTERACTIVE', () => {
const run = drive(['--ai-cli', 'none'], { FORCE_NONINTERACTIVE: '1' });
expect(run.stdout, run.stderr).toContain('REACHED THE STEP AFTER THE MENU');
expect(run.ran).toEqual([]);
expect(run.stderr).toContain('Skipping AI CLI install');
});
it('a named CLI runs that entry and no other, without the menu and without a terminal', () => {
const run = drive(['--ai-cli=codex']);
expect(run.ran).toEqual(['codex']);
expect(run.stdout).not.toContain('Which AI CLI');
expect(run.status).toBe(1); // The stand-in command leaves no binary: the gate below still holds.
expect(run.stderr).toContain('The selected AI CLI failed to install');
});
it('takes the same answer from CODEMAN_AI_CLI', () => {
expect(drive([], { CODEMAN_AI_CLI: 'gemini' }).ran).toEqual(['gemini']);
});
it('a CLI this host cannot install warns and carries on, never installing another', () => {
const run = drive(['--no-admin', '--ai-cli', 'omp'], { KEEP_BREW: '1' });
if (process.platform === 'darwin') {
expect(run.ran).toEqual([]);
expect(run.stderr).toContain('omp cannot be installed automatically here');
expect(run.stdout).toContain('REACHED THE STEP AFTER THE MENU');
} else {
expect(run.ran).toEqual(['omp']); // Linux installs omp with curl, not brew.
}
});
it('deepseek has no command to run, so it warns and carries on', () => {
const run = drive(['--ai-cli', 'deepseek']);
expect(run.ran).toEqual([]);
expect(run.stderr).toContain('deepseek cannot be installed automatically here');
expect(run.stdout).toContain('REACHED THE STEP AFTER THE MENU');
});
it.each([['cluade'], ['shell'], ['--yes']])('refuses %s before anything runs', (id) => {
const run = drive([`--ai-cli=${id}`]);
expect(run.status).not.toBe(0);
expect(run.stderr).toContain('Unknown AI CLI');
expect(run.ran).toEqual([]);
});
it('a bare run with no terminal and no answer is still refused by the guard', () => {
const run = drive([]);
expect(run.status).toBe(1);
expect(run.stderr).toContain('No interactive terminal');
expect(run.ran).toEqual([]);
});
});
describe('install.sh preflight', () => {
function preflight(args: string[], env: Record<string, string> = {}) {
const home = mkdtempSync(join(tmpdir(), 'codeman-preflight-'));
try {
const result = spawnSync('bash', [INSTALL_SH, 'preflight', ...args], {
encoding: 'utf-8',
timeout: 30_000,
env: { ...process.env, HOME: home, ...env },
});
expect(result.status, result.stderr).toBe(0);
return { stdout: result.stdout, home };
} finally {
rmSync(home, { recursive: true, force: true });
}
}
it('prints one JSON object in the shape the desktop app reads', () => {
const { stdout } = preflight(['--no-admin']);
const found = JSON.parse(stdout);
expect(found.schema).toBe(1);
expect(found.noAdmin).toBe(true);
expect(typeof found.blocker).toBe('string');
expect(Object.keys(found.needs).sort()).toEqual(['git', 'node', 'tmux']);
for (const key of ['node', 'tmux', 'git']) expect(typeof found.needs[key]).toBe('boolean');
// Exactly what src/gui_install.rs deserializes, or the app calls the answer unreadable.
for (const cli of found.clis) {
expect(Object.keys(cli).sort()).toEqual(['docs', 'id', 'installable', 'installed', 'label']);
}
});
it('lists every agent of the catalogue, in its order, and never the shell', () => {
const ids = JSON.parse(preflight(['--no-admin']).stdout).clis.map((c: { id: string }) => c.id);
const agents = STOCK_CLIS.filter((c) => c.discovery.binaries.length > 0).sort((a, b) => a.order - b.order);
expect(ids).toEqual(agents.map((c) => c.id));
});
it('never calls an installed CLI installable, and agrees with the menu on the rest', () => {
for (const cli of JSON.parse(preflight(['--no-admin']).stdout).clis) {
if (cli.installed) expect(cli.installable, cli.id).toBe(false);
}
const deepseek = JSON.parse(preflight(['--no-admin']).stdout).clis.find((c: { id: string }) => c.id === 'deepseek');
if (!deepseek.installed) expect(deepseek.installable).toBe(false); // A launcher alone cannot drive a pane.
});
it('writes nothing into the home folder', () => {
const home = mkdtempSync(join(tmpdir(), 'codeman-preflight-'));
try {
const result = spawnSync('bash', [INSTALL_SH, 'preflight', '--no-admin'], {
encoding: 'utf-8',
timeout: 30_000,
env: { ...process.env, HOME: home },
});
expect(result.status).toBe(0);
expect(readdirSync(home)).toEqual([]);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
it('json_string survives what a path or a message can hold', () => {
const driver = `
export CODEMAN_INSTALL_SH_LIB=1
. "$1"
set +e
json_string "$2"
`;
const text = 'quote " back\\slash tab\t line\nfeed bell\u0007 é ✓';
const result = spawnSync('bash', ['-c', driver, 'bash', INSTALL_SH, text], { encoding: 'utf-8', timeout: 30_000 });
expect(JSON.parse(result.stdout)).toBe(text.replace('\u0007', ''));
});
});
describe('install.sh agent, one AI CLI and nothing else', () => {
// The real subcommand in a real bash, with detection pointed at nothing and every
// trusted command rewritten: LEAVES names the binary a stand-in install leaves
// behind (on PATH, in the run's own folder), FOUND is a CLI already here.
const DRIVER = `
set -euo pipefail
export CODEMAN_INSTALL_SH_LIB=1
. "$1"
set +e
parse_flags "\${@:2}" || exit 9
set -e
bin=$(mktemp -d); trap 'rm -rf "$bin"' EXIT; export PATH="$bin:$PATH"
k=0; while [[ $k -lt \${#CLI_ALL_BINS[@]} ]]; do CLI_ALL_BINS[$k]="codeman-test-no-such-bin-$k"; k=$((k + 1)); done
k=0; while [[ $k -lt \${#CLI_ALL_PATHS[@]} ]]; do CLI_ALL_PATHS[$k]="/nonexistent/codeman-test/$k"; k=$((k + 1)); done
k=0; while [[ $k -lt \${#CLI_INSTALL_CMD_TRUSTED[@]} ]]; do
if [[ "\${KEEP_COMMANDS:-}" != "1" && -n "\${CLI_INSTALL_CMD_TRUSTED[$k]}" ]]; then
CLI_INSTALL_CMD_TRUSTED[$k]="echo RAN=\${CLI_IDS[$k]}; if [[ \${CLI_IDS[$k]} == \\"\${LEAVES:-}\\" ]]; then printf '#!/bin/sh\\n' > $bin/codeman-test-left-$k; chmod +x $bin/codeman-test-left-$k; fi"
fi
if [[ "\${CLI_IDS[$k]}" == "\${FOUND:-}" ]]; then
printf '#!/bin/sh\n' > "$bin/codeman-test-found"; chmod +x "$bin/codeman-test-found"
off=\${CLI_BIN_OFF[$k]}; CLI_ALL_BINS[$off]=codeman-test-found
fi
if [[ "\${CLI_IDS[$k]}" == "\${LEAVES:-}" ]]; then
off=\${CLI_BIN_OFF[$k]}; CLI_ALL_BINS[$off]=codeman-test-left-$k
fi
k=$((k + 1))
done
if [[ -n "\${NO_NPM:-}" ]]; then check_npm() { return 1; }; fi
CLI_DETECT_DONE=""
agent_subcommand
echo "REACHED THE END"
`;
function agent(args: string[], env: Record<string, string> = {}) {
const result = spawnSync('bash', ['-c', DRIVER, 'bash', INSTALL_SH, ...args], {
encoding: 'utf-8',
timeout: 30_000,
env: { ...process.env, CODEMAN_PROGRESS: '', ...env },
});
const stdout = result.stdout ?? '';
const stderr = result.stderr ?? '';
return { status: result.status, stdout, stderr, ran: [...stdout.matchAll(/^RAN=(.+)$/gm)].map((m) => m[1]) };
}
/** The same, in a home folder of its own, reporting whether the marker was written. */
function agentInHome(args: string[], env: Record<string, string> = {}) {
const home = mkdtempSync(join(tmpdir(), 'codeman-agent-marker-'));
try {
mkdirSync(join(home, '.codeman'));
const run = agent(args, { HOME: home, ...env });
return { ...run, marked: existsSync(join(home, '.codeman', CLI_INSTALLED_MARKER)) };
} finally {
rmSync(home, { recursive: true, force: true });
}
}
it('tells a running server when the agent landed, and only then', () => {
expect(agentInHome(['--ai-cli', 'codex', '--no-admin'], { LEAVES: 'codex' }).marked).toBe(true);
expect(agentInHome(['--ai-cli', 'codex', '--no-admin']).marked).toBe(false);
// Installed by hand a minute ago, and the server may still remember it as missing.
expect(agentInHome(['--ai-cli', 'codex', '--no-admin'], { FOUND: 'codex' }).marked).toBe(true);
});
it('a home folder with no Codeman in it is no failure', () => {
const home = mkdtempSync(join(tmpdir(), 'codeman-agent-marker-'));
try {
const run = agent(['--ai-cli', 'codex', '--no-admin'], { HOME: home, LEAVES: 'codex' });
expect(run.status, run.stderr).toBe(0);
expect(readdirSync(home)).toEqual([]);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
it('writes the file the server reads', () => {
expect(SOURCE).toContain(`"$HOME/.codeman/${CLI_INSTALLED_MARKER}"`);
});
it('installs the one it was told to, and says done', () => {
const run = agent(['--no-admin', '--progress', '--ai-cli', 'codex'], { LEAVES: 'codex' });
expect(run.status, run.stderr).toBe(0);
expect(run.ran).toEqual(['codex']);
expect(run.stderr).toContain('@@codeman phase agent');
expect(run.stderr).toContain('@@codeman phase done');
});
it('never says done when that agent did not land, even with another one here', () => {
const run = agent(['--no-admin', '--progress', '--ai-cli', 'codex'], { FOUND: 'copilot' });
expect(run.ran).toEqual(['codex']);
expect(run.status).toBe(1);
expect(run.stderr).toContain('@@codeman error Codex did not install.');
expect(run.stderr).not.toContain('@@codeman phase done');
expect(run.stdout).not.toContain('REACHED THE END');
});
it('says the same with no other agent here, the way a Mac is after Skip for now', () => {
const run = agent(['--no-admin', '--progress', '--ai-cli', 'codex']);
expect(run.ran).toEqual(['codex']);
expect(run.status).toBe(1);
expect(run.stderr).toContain('@@codeman error Codex did not install.');
expect(run.stderr).not.toContain('re-run the installer');
expect(run.stderr).not.toContain('@@codeman phase done');
});
it('installs nothing for an agent that is here already, and that is a success', () => {
const run = agent(['--no-admin', '--progress', '--ai-cli', 'codex'], { FOUND: 'codex' });
expect(run.status, run.stderr).toBe(0);
expect(run.ran).toEqual([]);
expect(run.stderr).toContain('@@codeman phase done');
});
it('names what is wrong when npm cannot be found, before running anything', () => {
const run = agent(['--no-admin', '--progress', '--ai-cli', 'codex'], { KEEP_COMMANDS: '1', NO_NPM: '1' });
expect(run.status).toBe(1);
expect(run.stderr).toContain('Codex installs with npm, and this run cannot find it');
expect(run.stderr).toContain('npm install -g @openai/codex');
expect(run.stderr).not.toContain('@@codeman phase done');
});
it('refuses an agent it cannot install, and installs no other in its place', () => {
const run = agent(['--no-admin', '--progress', '--ai-cli', 'deepseek'], { KEEP_COMMANDS: '1' });
expect(run.status).toBe(1);
expect(run.stderr).toContain('DeepSeek cannot be installed automatically here');
expect(run.stderr).not.toContain('@@codeman phase done');
});
it('it takes the name as a plain word, as a person would type it', () => {
const driver = `
export CODEMAN_INSTALL_SH_LIB=1
. "$1"
set +e
parse_flags "\${@:2}" || exit 9
echo "sub=$SUBCOMMAND cli=$AI_CLI_CHOICE"
`;
const parsed = (...args: string[]) =>
spawnSync('bash', ['-c', driver, 'bash', INSTALL_SH, ...args], { encoding: 'utf-8', timeout: 30_000 });
expect(parsed('agent', 'codex', '--no-admin').stdout).toContain('sub=agent cli=codex');
expect(parsed('agent', '--ai-cli', 'codex').stdout).toContain('sub=agent cli=codex');
expect(parsed('agent', 'codx').stderr).toContain('Unknown AI CLI: codx');
expect(parsed('agent', 'shell').stderr).toContain('Unknown AI CLI: shell');
expect(parsed('agent', 'codex', 'copilot').stderr).toContain('Unexpected argument: copilot');
expect(parsed('codex').stderr).toContain('Unexpected argument: codex');
});
it('wants a name, and none is not one', () => {
expect(agent([]).stderr).toContain('Name the AI CLI to install');
expect(agent(['--ai-cli', 'none']).stderr).toContain('Name the AI CLI to install');
});
it('leaves Codeman alone: nothing in it touches the service, the source or the build', () => {
const body = SOURCE.slice(
SOURCE.indexOf('\nagent_subcommand() {'),
SOURCE.indexOf('\n# Offer to install one AI CLI')
);
for (const word of ['launchctl', 'systemctl', 'setup_launchd', 'install_or_update_repo', 'npm run', 'sudo']) {
expect(body, word).not.toContain(word);
}
expect(body).toContain('run_offered_cli "$idx"');
});
});
describe('install.sh --progress, the protocol the desktop app reads', () => {
const CONNECT_HTML = readFileSync(
fileURLToPath(new URL('../packages/desktop/assets/connect.html', import.meta.url)),
'utf-8'
);
function mainBody() {
const start = SOURCE.indexOf('main() {');
const end = SOURCE.indexOf('# Phase 0: look', start);
return SOURCE.slice(start, end);
}
it('reports its phases in the order the app draws them, ending on done', () => {
const phases = [...mainBody().matchAll(/^\s*progress_phase (\w+)$/gm)].map((m) => m[1]);
const drawn = /var PHASES = \[([^\]]*)\]/.exec(CONNECT_HTML)?.[1].match(/\w+/g);
expect(phases).toEqual([...(drawn ?? []), 'done']);
});
it('reports done after the service step and before a foreground start replaces the shell', () => {
const main = mainBody();
expect(main.indexOf('progress_phase done')).toBeGreaterThan(main.indexOf('print_done_screen'));
expect(main.indexOf('progress_phase done')).toBeLessThan(main.indexOf('exec node'));
});
function driveProtocol(env: Record<string, string>) {
const driver = `
export CODEMAN_INSTALL_SH_LIB=1
. "$1"
set +e
progress_phase build
error "first
second"
`;
return spawnSync('bash', ['-c', driver, 'bash', INSTALL_SH], {
encoding: 'utf-8',
timeout: 30_000,
env: { ...process.env, ...env },
});
}
it('prints protocol lines on stderr, one line each, so a $( ) capture stays clean', () => {
const run = driveProtocol({ CODEMAN_PROGRESS: '1' });
expect(run.stdout).toBe('');
expect(run.stderr).toContain('@@codeman phase build\n');
expect(run.stderr).toContain('@@codeman error first second\n');
});
it('says nothing of it to a person at a terminal', () => {
expect(driveProtocol({ CODEMAN_PROGRESS: '0' }).stderr).not.toContain('@@codeman');
});
it('never prints the password', () => {
expect(mainBody()).not.toMatch(/progress_phase.*PASSWORD/);
expect(fnBody('error')).not.toMatch(/PASSWORD/);
});
function fnBody(name: string) {
const start = SOURCE.indexOf(`${name}() {`);
return SOURCE.slice(start, SOURCE.indexOf('\n}\n', start));
}
});
describe('install.sh detect_all_clis and a disabled entry', () => {
// No stock entry ships disabled today, so this is characterization rather than a regression
// pin on real data: it drives the real function in a real bash with entry 0 fabricated
@@ -536,3 +930,410 @@ it('install.sh never contains the unauthenticated tunnel opt-in', () => {
expect(SOURCE).not.toContain('CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL');
expect(SOURCE).not.toContain('--allow-unauthenticated-tunnel');
});
describe('install.sh no-admin route', () => {
// The route that installs without administrator rights: Node.js and tmux are downloaded
// into the home folder, and a machine with no working git gets the source as an archive.
// What can go wrong here is quiet: a sudo prompt that cannot succeed, a binary unpacked
// before its checksum was checked, Apple's git stub opening a dialog. So each is pinned.
const fn = (name: string, until: string) => {
const start = SOURCE.indexOf(`${name}() {`);
expect(start, `${name}() missing`).toBeGreaterThan(-1);
const end = SOURCE.indexOf(until, start);
expect(end, `${until} missing after ${name}()`).toBeGreaterThan(start);
return SOURCE.slice(start, end);
};
const sectionStart = SOURCE.indexOf('# No-admin route: everything into the home folder');
const sectionEnd = SOURCE.indexOf('# ---- End of the no-admin route');
const section = SOURCE.slice(sectionStart, sectionEnd);
const sectionCode = section.split('\n').filter((line) => !/^\s*#/.test(line));
/** Run a snippet with install.sh sourced as a library, in a real bash. */
const drive = (script: string, env: NodeJS.ProcessEnv = {}) => {
const clean = { ...process.env, ...env };
for (const key of ['CODEMAN_NO_ADMIN', 'CODEMAN_TARBALL_URL', 'CODEMAN_REPO_URL', 'CODEMAN_BRANCH']) {
if (!(key in env)) delete clean[key];
}
const full = `set -euo pipefail\nexport CODEMAN_INSTALL_SH_LIB=1\n. "$1"\n${script}`;
return spawnSync('bash', ['-c', full, 'bash', INSTALL_SH], { encoding: 'utf-8', timeout: 30_000, env: clean });
};
it('has its section markers, in order', () => {
expect(sectionStart).toBeGreaterThan(-1);
expect(sectionEnd).toBeGreaterThan(sectionStart);
expect(sectionCode.length).toBeGreaterThan(100);
});
it('never runs sudo, Homebrew or the Xcode tools installer', () => {
// Command position only: the messages in this section name `brew install tmux` and
// `apt-get install` as things an administrator can run, which is prose.
const banned =
/(?:^|[;&|]|\b(?:if|then|else|do|while|until)\s|!\s)\s*(?:sudo|brew|ensure_sudo|run_as_root|ensure_homebrew|sudo_session_start|sudo_session_try)\b/;
const offenders = sectionCode.filter((line) => banned.test(line) || /xcode-select\s+--install/.test(line));
expect(offenders, `privileged call on the no-admin route:\n ${offenders.join('\n ')}`).toEqual([]);
});
it('pins a SHA-256 for every tmux build it can download', () => {
const digests = fn('tmux_build_sha256', '\n}\n');
for (const platform of ['linux-x86_64', 'linux-arm64', 'macos-arm64', 'macos-x86_64']) {
expect(digests, `no digest for ${platform}`).toMatch(new RegExp(`${platform}\\)\\s+printf '[0-9a-f]{64}'`));
// Every platform the name mapper can produce has a digest, and nothing else does.
expect(fn('userspace_tmux_platform', '\n}\n')).toContain(`printf '${platform}'`);
}
expect(digests.match(/[0-9a-f]{64}/g)?.length).toBe(4);
// The asset name and URL are built from the same version the digests were taken for.
const install = fn('install_tmux_userspace', '\n}\n');
expect(install).toContain('name="tmux-$TMUX_BUILD_VERSION-$platform.tar.gz"');
expect(install).toContain('tmux-builds/releases/download/v$TMUX_BUILD_VERSION/$name');
});
it('checks a download before unpacking or running it', () => {
for (const name of ['install_node_userspace', 'install_tmux_userspace']) {
const body = fn(name, '\n}\n');
const verify = body.indexOf('verify_sha256 "$tmp/$name" "$sha"');
expect(verify, `${name} never verifies its download`).toBeGreaterThan(-1);
expect(body.indexOf('tar -xzf'), `${name} unpacks before verifying`).toBeGreaterThan(verify);
}
// An empty expected digest must never pass (a lookup that came back with nothing).
expect(fn('verify_sha256', '\n}\n')).toContain('[[ -n "$want" ]] || return 1');
});
it('never executes /usr/bin/git on a Mac before the developer tools are proven', () => {
// Without the Command Line Tools that path is a stub that opens Apple's install dialog.
const body = fn('check_git', '\n}\n');
const apple = body.indexOf('"$git_bin" == "/usr/bin/git"');
const gate = body.indexOf('xcode-select -p');
const run = body.indexOf('"$git_bin" --version');
expect(apple).toBeGreaterThan(-1);
expect(gate).toBeGreaterThan(apple);
// The Apple branch returns on its own evidence; only other gits are executed.
expect(body.slice(gate, run)).toMatch(/\n\s+return 1\n\s+fi\n/);
});
it('judges git by whether it runs', () => {
const result = drive(`
bin=$(mktemp -d); trap 'rm -rf "$bin"' EXIT
printf '#!/bin/sh\\nexit 127\\n' > "$bin/git"; chmod +x "$bin/git"
PATH="$bin:$PATH"; hash -r
check_git && echo "broken=present" || echo "broken=missing"
printf '#!/bin/sh\\nexit 0\\n' > "$bin/git"
check_git && echo "working=present" || echo "working=missing"
# A Mac whose only git is Apple's stub, with and without the developer tools.
uname() { echo Darwin; }
command() { if [[ "$1" == "-v" && "$2" == "git" ]]; then echo /usr/bin/git; else builtin command "$@"; fi; }
xcode-select() { return 2; }
check_git && echo "stub=present" || echo "stub=missing"
mkdir -p "$bin/dev/usr/bin"; cp "$bin/git" "$bin/dev/usr/bin/git"
xcode-select() { echo "$bin/dev"; }
check_git && echo "tools=present" || echo "tools=missing"
`);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain('broken=missing');
expect(result.stdout).toContain('working=present');
expect(result.stdout).toContain('stub=missing');
expect(result.stdout).toContain('tools=present');
});
it('decides the route from what the account may do, without asking for a password', () => {
// `sudo -v` would prompt, and a refused password is reported to the administrator.
expect(fn('resolve_admin_mode', '\n}\n')).not.toMatch(/sudo -v/);
const result = drive(`
[[ $EUID -ne 0 ]] || { echo "skipped=root"; exit 0; }
has_sudo=1
command() { if [[ "$1" == "-v" && "$2" == "sudo" ]]; then [[ "$has_sudo" == "1" ]]; elif [[ "$1" == "-v" && "$2" == "brew" ]]; then return 1; else builtin command "$@"; fi; }
groups="users"; id() { echo "$groups"; }
sudo_ok=1; sudo() { return "$sudo_ok"; }
probe() { NO_ADMIN="$1"; NO_ADMIN_REASON=""; resolve_admin_mode; echo "$2=$NO_ADMIN"; }
uname() { echo Linux; }
has_sudo=0; probe "" linux_no_sudo; has_sudo=1
probe "" linux_not_in_group
groups="users sudo"; probe "" linux_sudo_group
groups="users wheel"; probe "" linux_wheel_group
groups="users"; sudo_ok=0; probe "" linux_passwordless; sudo_ok=1
probe 0 forced_off
groups="users sudo"; probe 1 forced_on
uname() { echo Darwin; }
groups="staff everyone"; probe "" mac_standard
groups="staff admin"; probe "" mac_admin
`);
expect(result.status, result.stderr).toBe(0);
if (result.stdout.includes('skipped=root')) return;
for (const expected of [
'linux_no_sudo=1',
'linux_not_in_group=1',
'linux_sudo_group=0',
'linux_wheel_group=0',
'linux_passwordless=0',
'forced_off=0',
'forced_on=1',
'mac_standard=1',
'mac_admin=0',
]) {
expect(result.stdout, result.stdout).toContain(expected);
}
});
it('stops before the first download when the machine cannot finish the install', () => {
// No compiler on Linux, or a Mac older than the tmux build supports: say so while it
// costs nothing, not after 50 MB of Node.js.
const stop = (env: Record<string, string>) => {
const r = drive(
`
NONINTERACTIVE=1; NO_ADMIN=1; NO_ADMIN_REASON=test
download() { echo "DOWNLOAD $1"; }
download_to_stdout() { echo "DOWNLOAD $1"; }
check_node() { return 1; }; check_tmux() { return 1; }; check_git() { return 0; }
detect_arch() { echo "$DRIVE_ARCH"; }
check_build_tools() { [[ "$DRIVE_TOOLS" == "1" ]]; }; missing_build_tools() { printf 'make, g++'; }
macos_major_version() { printf '%s' "$DRIVE_MACOS"; }
is_musl_libc() { [[ "$DRIVE_MUSL" == "1" ]]; }
ask_dependencies_no_admin "$DRIVE_OS"
`,
{ DRIVE_ARCH: 'x64', DRIVE_TOOLS: '1', DRIVE_MACOS: '26', DRIVE_MUSL: '0', ...env }
);
expect(r.status, 'the installer must stop').toBe(1);
expect(r.stdout + r.stderr, 'something was downloaded before the stop').not.toContain('DOWNLOAD');
return r.stderr;
};
expect(stop({ DRIVE_OS: 'linux', DRIVE_TOOLS: '0' })).toMatch(/make, g\+\+[\s\S]*administrator/);
expect(stop({ DRIVE_OS: 'macos', DRIVE_ARCH: 'arm64', DRIVE_MACOS: '14' })).toMatch(/macOS 15 or newer/);
expect(stop({ DRIVE_OS: 'linux', DRIVE_MUSL: '1' })).toMatch(/musl/);
expect(stop({ DRIVE_OS: 'linux', DRIVE_ARCH: 'armv7' })).toMatch(/no Node\.js download/);
});
it('picks the right Node.js tarball out of SHASUMS256.txt', () => {
const sha = (c: string) => c.repeat(64);
const sums = [
`${sha('a')} node-v22.23.4-aix-ppc64.tar.gz`,
`${sha('b')} node-v22.23.4-darwin-arm64.tar.gz`,
`${sha('c')} node-v22.23.4-darwin-arm64.tar.xz`,
`${sha('d')} node-v22.23.4-linux-x64-musl.tar.gz`,
`${sha('e')} node-v22.23.4-linux-x64.tar.gz`,
`nothex node-v22.23.4-linux-arm64.tar.gz`,
].join('\n');
const result = drive(
`
printf '%s\\n' "$SUMS" | pick_node_tarball linux-x64
printf '%s\\n' "$SUMS" | pick_node_tarball darwin-arm64
printf '%s\\n' "$SUMS" | pick_node_tarball linux-arm64 || echo "arm64=refused"
printf '%s\\n' "$SUMS" | pick_node_tarball linux-s390x || echo "s390x=none"
`,
{ SUMS: sums }
);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain(`${sha('e')} node-v22.23.4-linux-x64.tar.gz`);
expect(result.stdout).toContain(`${sha('b')} node-v22.23.4-darwin-arm64.tar.gz`);
expect(result.stdout).not.toContain('musl');
// A line whose checksum is not 64 hex digits is not a checksum to trust.
expect(result.stdout).toContain('arm64=refused');
expect(result.stdout).toContain('s390x=none');
});
it('refuses a download whose checksum does not match', () => {
const result = drive(`
f=$(mktemp); trap 'rm -f "$f"' EXIT
printf 'codeman' > "$f"
good=$(sha256_file "$f")
echo "len=\${#good}"
verify_sha256 "$f" "$good" && echo "match=ok"
verify_sha256 "$f" "\${good%?}x" && echo "tampered=accepted" || echo "tampered=refused"
verify_sha256 "$f" "" && echo "empty=accepted" || echo "empty=refused"
`);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain('len=64');
expect(result.stdout).toContain('match=ok');
expect(result.stdout).toContain('empty=refused');
// The last digit swapped for a character no digest contains.
expect(result.stdout).toContain('tampered=refused');
});
it('installs from an archive without git, then replaces that install beside itself', () => {
const scratch = mkdtempSync(join(tmpdir(), 'codeman-archive-install-'));
try {
const make = (version: string) => {
const tree = join(scratch, `src-${version}`, `Codeman-${version}`);
mkdirSync(join(tree, 'src'), { recursive: true });
writeFileSync(join(tree, 'package.json'), `{ "version": "${version}" }\n`);
writeFileSync(join(tree, 'src', `only-in-${version}.ts`), '');
const tarball = join(scratch, `${version}.tar.gz`);
const tar = spawnSync('tar', ['-czf', tarball, '-C', join(scratch, `src-${version}`), `Codeman-${version}`]);
expect(tar.status, String(tar.stderr)).toBe(0);
return tarball;
};
const first = make('1.0.0');
const second = make('2.0.0');
const app = join(scratch, 'app');
const script = `
LOG_FILE=/dev/null
check_git() { return 1; }
download() { cp "\${1#file://}" "$2"; }
run_step() { shift; "$@"; }
install_or_update_repo
echo "staging=[$SOURCE_STAGING] cwd=$(pwd)"
finish_source_swap
echo "after=$(pwd)"
`;
const run = (tarball: string) =>
drive(script, { CODEMAN_INSTALL_DIR: app, CODEMAN_TARBALL_URL: `file://${tarball}`, HOME: scratch });
// First install: straight into the install directory, marked as an archive install.
const fresh = run(first);
expect(fresh.status, fresh.stderr).toBe(0);
expect(fresh.stdout).toContain('staging=[]');
expect(readFileSync(join(app, 'package.json'), 'utf-8')).toContain('1.0.0');
expect(existsSync(join(app, ARCHIVE_INSTALL_MARKER))).toBe(true);
// Second run: the new tree is built BESIDE the old one, which stays whole until the swap.
writeFileSync(join(app, 'built-by-1.0.0'), '');
const update = run(second);
expect(update.status, update.stderr).toBe(0);
expect(update.stdout).toContain(`staging=[${app}.new] cwd=${app}.new`);
expect(update.stdout).toContain(`after=${app}`);
expect(readFileSync(join(app, 'package.json'), 'utf-8')).toContain('2.0.0');
// A whole replacement: what the old version had and the new one dropped is gone.
expect(existsSync(join(app, 'src', 'only-in-1.0.0.ts'))).toBe(false);
expect(existsSync(join(app, 'built-by-1.0.0'))).toBe(false);
expect(existsSync(join(app, ARCHIVE_INSTALL_MARKER))).toBe(true);
expect(existsSync(`${app}.new`)).toBe(false);
expect(existsSync(`${app}.old`)).toBe(false);
// A folder that is neither a checkout nor one of ours is never written into.
const foreign = join(scratch, 'foreign');
mkdirSync(foreign);
writeFileSync(join(foreign, 'notes.txt'), 'mine');
const refused = drive(script, {
CODEMAN_INSTALL_DIR: foreign,
CODEMAN_TARBALL_URL: `file://${first}`,
HOME: scratch,
});
expect(refused.status).toBe(1);
expect(refused.stderr).toContain('is not a Codeman install');
expect(readFileSync(join(foreign, 'notes.txt'), 'utf-8')).toBe('mine');
} finally {
rmSync(scratch, { recursive: true, force: true });
}
});
it('downloads from a subcommand that never ran the full flow', () => {
// DOWNLOADER used to be set only by main(), so `install.sh cloudflared` (and now the
// update of an archive install) died inside download() on an unbound variable.
const result = drive(`
unset DOWNLOADER
curl() { echo "FETCH $*"; }
wget() { echo "FETCH $*"; }
download https://example.invalid/a /tmp/never-written
unset DOWNLOADER
download_to_stdout https://example.invalid/b
`);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toMatch(/FETCH .*example\.invalid\/a/);
expect(result.stdout).toMatch(/FETCH .*example\.invalid\/b/);
});
it('builds the archive URL from the repository and branch, on GitHub only', () => {
const url = (env: NodeJS.ProcessEnv) => drive('source_tarball_url || echo REFUSED', env).stdout.trim();
expect(url({})).toBe('https://github.com/Ark0N/Codeman/archive/master.tar.gz');
expect(url({ CODEMAN_REPO_URL: 'https://github.com/someone/fork.git', CODEMAN_BRANCH: 'feat/x' })).toBe(
'https://github.com/someone/fork/archive/feat/x.tar.gz'
);
expect(url({ CODEMAN_REPO_URL: 'https://gitlab.com/someone/fork.git' })).toBe('REFUSED');
expect(url({ CODEMAN_REPO_URL: 'git@github.com:someone/fork.git' })).toBe('REFUSED');
expect(url({ CODEMAN_TARBALL_URL: 'https://mirror.example/codeman.tar.gz' })).toBe(
'https://mirror.example/codeman.tar.gz'
);
});
it('shares the archive marker name with the in-app updater', () => {
// install.sh writes it, src/web/self-update.ts reads it to tell such an install how to
// update. A rename on one side would send archive installs back to the npm advice.
expect(SOURCE).toContain(`SOURCE_MARKER="${ARCHIVE_INSTALL_MARKER}"`);
});
it('treats a completed archive install as an install: quiet update on a re-run', () => {
const dispatch = SOURCE.slice(SOURCE.indexOf('\nparse_flags "$@"'));
expect(dispatch).toMatch(
/-f "\$INSTALL_DIR\/\.install-complete" \]\] && \[\[ -d "\$INSTALL_DIR\/\.git" \|\| -f "\$INSTALL_DIR\/\$SOURCE_MARKER" \]\]/
);
const update = fn('update', '\nuninstall() {');
expect(update).toContain('! -f "$INSTALL_DIR/$SOURCE_MARKER"');
// The swap happens only after the build has succeeded.
expect(update.indexOf('finish_source_swap')).toBeGreaterThan(update.indexOf('npm run build'));
const main = fn(
'main',
'\n# ----------------------------------------------------------------------------\n# Phase 0'
);
expect(main.indexOf('finish_source_swap')).toBeGreaterThan(main.indexOf('npm run build'));
expect(main.indexOf('finish_source_swap')).toBeLessThan(main.indexOf('install_symlink'));
});
it('documents --no-admin where people look for flags, and resolves the route before any work', () => {
expect(fn('usage', '\n}\n')).toContain('--no-admin');
const dispatch = SOURCE.slice(SOURCE.indexOf('\nparse_flags "$@"'));
expect(dispatch.indexOf('resolve_admin_mode')).toBeGreaterThan(-1);
expect(dispatch.indexOf('resolve_admin_mode')).toBeLessThan(dispatch.lastIndexOf('case "$SUBCOMMAND" in'));
expect(dispatch.indexOf('adopt_userspace_runtime')).toBeLessThan(dispatch.indexOf('resolve_admin_mode'));
});
it('puts the downloaded runtime on the LaunchAgent PATH', () => {
// The agent's PATH is a fixed list (Homebrew, /usr/local, ~/.local/bin, the system). A
// Node.js in the home folder is outside it, and so is everything `npm install -g` puts
// beside that Node.js: the AI CLIs, whose launchers start with `#!/usr/bin/env node`.
// A machine with a real LaunchDaemon makes the function stand down, so skip there.
if (existsSync('/Library/LaunchDaemons/com.codeman.web.plist')) return;
const home = mkdtempSync(join(tmpdir(), 'codeman-agent-path-'));
const result = drive(
`
mkdir -p "$RUNTIME_DIR/node/bin" "$RUNTIME_DIR/bin" "$HOME/.local/bin"
printf '#!/bin/sh\n' > "$RUNTIME_DIR/node/bin/node"; chmod +x "$RUNTIME_DIR/node/bin/node"
printf '#!/bin/sh\n' > "$RUNTIME_DIR/bin/tmux"; chmod +x "$RUNTIME_DIR/bin/tmux"
ln -s "$RUNTIME_DIR/node/bin/node" "$HOME/.local/bin/node"
PATH="$HOME/.local/bin:$PATH"; hash -r
launchctl() { return 0; }
sleep() { :; }
BIND_HOST=127.0.0.1
setup_launchd_service >/dev/null 2>&1
cat "$HOME/Library/LaunchAgents/com.codeman.web.plist"
`,
{ HOME: home }
);
rmSync(home, { recursive: true, force: true });
expect(result.status, result.stderr).toBe(0);
const path = /<key>PATH<\/key>\s*<string>([^<]*)<\/string>/.exec(result.stdout)?.[1] ?? '';
const dirs = path.split(':');
expect(dirs).toContain(`${home}/.codeman/runtime/node/bin`);
expect(dirs).toContain(`${home}/.codeman/runtime/bin`);
expect(dirs).toContain(`${home}/.local/bin`);
expect(dirs).toContain('/usr/bin');
expect(new Set(dirs).size, `duplicate PATH entries: ${path}`).toBe(dirs.length);
expect(result.stdout).toContain(`<string>${home}/.local/bin/node</string>`);
});
it('links only into its own runtime, and removes only its own links', () => {
// ~/.local/bin is the user's folder: a tmux or node they put there is never replaced on
// install and never deleted on uninstall.
const home = mkdtempSync(join(tmpdir(), 'codeman-links-'));
const result = drive(
`
mkdir -p "$HOME/.local/bin" "$RUNTIME_DIR/bin"
touch "$RUNTIME_DIR/bin/tmux" "$RUNTIME_DIR/bin/node"
printf 'mine' > "$HOME/.local/bin/tmux"
link_into_local_bin "$RUNTIME_DIR/bin/tmux" tmux && echo "foreign=replaced" || echo "foreign=kept"
link_into_local_bin "$RUNTIME_DIR/bin/node" node && echo "fresh=linked"
link_into_local_bin "$RUNTIME_DIR/bin/node" node && echo "ours=relinked"
echo "tmux=$(cat "$HOME/.local/bin/tmux") node=$(readlink "$HOME/.local/bin/node")"
`,
{ HOME: home }
);
rmSync(home, { recursive: true, force: true });
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain('foreign=kept');
expect(result.stdout).toContain('fresh=linked');
expect(result.stdout).toContain('ours=relinked');
expect(result.stdout).toMatch(/tmux=mine node=.*\/\.codeman\/runtime\/bin\/node/);
const body = fn('uninstall', '\nusage() {');
const rm = body.indexOf('rm -f "$symlink_dir/$runtime_tool"');
expect(rm).toBeGreaterThan(-1);
expect(body.slice(body.lastIndexOf('if [[', rm), rm)).toContain('== "$RUNTIME_DIR/"*');
});
});
+45 -1
View File
@@ -6,8 +6,16 @@
* npm test -- test/self-update.test.ts
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, afterAll } from 'vitest';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
ARCHIVE_INSTALL_MARKER,
ARCHIVE_UPDATE_HINT,
canSelfUpdateInPlace,
detectInstallKind,
isRunningInContainer,
parseVersionFromTag,
compareVersions,
isNewerStableVersion,
@@ -205,3 +213,39 @@ describe('expireStalledStatus (runtime staleness backstop)', () => {
expect(expireStalledStatus(base({ phase: 'failed', updatedAt: NOW - 60 * MIN }), NOW)).toBeNull();
});
});
describe('detectInstallKind', () => {
const scratch = mkdtempSync(join(tmpdir(), 'codeman-install-kind-'));
afterAll(() => rmSync(scratch, { recursive: true, force: true }));
let n = 0;
const tree = (entries: string[]) => {
const dir = join(scratch, `install-${++n}`);
mkdirSync(dir);
for (const entry of entries) {
if (entry.endsWith('/')) mkdirSync(join(dir, entry));
else writeFileSync(join(dir, entry), 'archive\n');
}
return dir;
};
it('reads a source-archive install (install.sh without a working git) as archive', () => {
const dir = tree(['src/', 'dist/', ARCHIVE_INSTALL_MARKER]);
expect(detectInstallKind(dir)).toBe('archive');
// Nothing to pull into, so it never self-updates in place; the hint names the installer.
expect(canSelfUpdateInPlace('archive')).toBe(false);
expect(ARCHIVE_UPDATE_HINT).toContain('getcodeman.com/install');
expect(ARCHIVE_UPDATE_HINT).not.toContain('npm i -g');
});
it('lets a real checkout win over a leftover marker', () => {
// A re-run of the installer on a machine that gained git replaces the archive tree with
// a clone; were a marker ever left beside .git, the checkout is still what counts.
const dir = tree(['.git/', 'src/', ARCHIVE_INSTALL_MARKER]);
expect(detectInstallKind(dir)).toBe(isRunningInContainer() ? 'docker-compose' : 'git');
});
it('keeps npm and unknown as they were', () => {
expect(detectInstallKind(tree(['dist/']))).toBe('npm');
expect(detectInstallKind(tree(['dist/', 'src/']))).toBe('unknown');
});
});