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
21 changed files with 518 additions and 4015 deletions

No files matched your search

+1 -1
View File
@@ -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`) `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back) and `runtime/` (the Node.js and tmux `install.sh` downloaded on its no-admin route, linked from `~/.local/bin`; all three 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.
-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
];
/**
+1 -1
View File
@@ -1303,7 +1303,7 @@ On this route Node.js comes from nodejs.org (`latest-v<major>.x/SHASUMS256.txt`
⚠️ `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 only the CLI management routes clear that, so the app says so on its last screen and never restarts the service for it. ⚠️ `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...`).
**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.
+2 -2
View File
@@ -100,8 +100,8 @@ and a re-run with `--lan` or `--tailscale` keeps the password the service alread
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 can take a few
minutes to notice the new agent; reload the page if it does not show up.
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
+11
View File
@@ -860,6 +860,15 @@ cli_offerable() {
return 0
}
# Tell a running Codeman to look again: it remembers a missing CLI for up to 5
# minutes (cliResolveRetryDelayMs), and its Run button reads that memory. The name
# is CLI_INSTALLED_MARKER in src/utils/cli-executable-resolver.ts. With no data
# folder there is no Codeman to tell, which is why a failure here is not one.
mark_cli_installed() {
touch "$HOME/.codeman/cli-installed" 2>/dev/null || true
return 0
}
# run_offered_cli <index>: run the trusted command of one entry and look again
# at what is installed. What that means is the caller's to judge.
run_offered_cli() {
@@ -873,6 +882,7 @@ run_offered_cli() {
detect_all_clis
if [[ -n "${CLI_FOUND_PATH[$idx]}" ]]; then
success "${CLI_LABELS[$idx]} installed at ${CLI_FOUND_PATH[$idx]}"
mark_cli_installed
else
warn "${CLI_LABELS[$idx]} installation failed."
fi
@@ -907,6 +917,7 @@ agent_subcommand() {
idx=$(cli_index "$AI_CLI_CHOICE")
if [[ -n "${CLI_FOUND_PATH[$idx]}" ]]; then
success "${CLI_LABELS[$idx]} is already installed at ${CLI_FOUND_PATH[$idx]}"
mark_cli_installed
progress_phase done
return 0
fi
+23 -46
View File
@@ -28,18 +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). The tab also finds every Codeman server of yours that already runs on this Mac (with `ps` and `lsof`: a `codeman web --port 4000` in a terminal, a beta instance beside the installed one) and recommends connecting to it. Each one's address is worked out from its listening socket and its command line (`127.0.0.1` for a bind to loopback or to every interface, its own address for a bind to one, `https` with `--https`, the path of `--base-url`), and each is asked once whether Codeman answers there. When nothing answers at the usual address (the LaunchAgent's, or `http://127.0.0.1:3000` without one), *Open Codeman* is for the first found server that does; when none of them can be opened, the tab says *Codeman is already running on this Mac* and lists them. Next to a Codeman that answers, the others are listed under *Also running on this Mac*. A row shows the address, the version, and a *Connect* button, or the reason the app cannot open that one (HTTPS with Codeman's own certificate, no answer). *Connect* is the connect of the Another machine tab with that address filled in: the same check, the login saved for it, the same entry under recent servers. Finding a server connects to nothing by itself. The tab 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 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. |
| **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 (or connects to the one found here, when that is the tab's main button) 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. |
| **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)
@@ -55,12 +54,6 @@ 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
@@ -107,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
@@ -206,8 +199,7 @@ with Codeman stopped, whatever else listens there never gets it.
data directory and tmux socket attach to each other's live sessions, and
`codeman web -d` only notices a server it started itself or one at the same
address. So the app looks for running Codeman servers of this user (`ps`)
before it offers or runs a start, and offers to connect to the ones it finds
instead (`found_addresses` in `src/setup.rs`, unit-tested).
before it offers or runs a start, and opens the one it finds instead.
**Closing the main window quits the app.** Codeman's sessions keep running on
the server, so nothing is lost, and reopening takes about a second.
@@ -215,9 +207,7 @@ the server, so nothing is lost, and reopening takes about a second.
**One reachability probe per user action, and one to check a refused login.**
Codeman counts a request without valid credentials against its login rate
limit (10 per IP, 15 minutes; a successful login resets the count), so the app
never retries in a loop. A look at this Mac asks each Codeman server found
here once; the app has a login only for the one at the usual address, so the
others are asked without one. Once the server refuses the login in a challenge, the
never retries in a loop. Once the server refuses the login in a challenge, the
app sends it only once more, in the single probe that checks the refusal. If
that probe is refused or rate limited, the connect screen comes back; the
app does not send the refused login again. Keeping no copy of the login in WebKit has a cost here: after a server
@@ -230,12 +220,13 @@ and the Dock icon bounces.
## Known limits
- **A new agent can take a few minutes to show up in a running Codeman.** The
server remembers that a CLI is missing, for up to 5 minutes after it last looked
- **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
app cannot clear that from outside, so the screen after *Add a coding agent* says
so. An open Codeman page also needs a reload, because the list of installed
agents is part of the page.
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.**
@@ -243,17 +234,6 @@ and the Dock icon bounces.
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.
- **Only your own Codeman servers on this Mac are found, by their command
line.** A server another macOS user runs, or one in a container or a virtual
machine, is not listed: connect to it by address under Another machine. A
base path that a server started by hand has only in its environment
(`CODEMAN_BASE_URL`) is not visible either, so its row has the address
without the path; the LaunchAgent's base path is read from its job.
- **A found server that is slow to answer shows as not answering.** Next to a
Codeman that answers, each other server gets two seconds (five when nothing
answers at the usual address). A password-protected one counts the
look as a failed login, like any look at a server the app has no login for.
*Check again* asks again.
- **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
@@ -306,13 +286,11 @@ and the Dock icon bounces.
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.
@@ -330,10 +308,9 @@ and the Dock icon bounces.
- **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.
- **An agent is chosen during the install, or added later.** With Codeman
already installed, or after *Skip for now*, *Add a coding agent* on the This
Mac tab (*Add a Coding Agent to This Mac…* in the app menu) installs one. The
commands are also in [Installing an agent CLI](../../docs/wiki/Installation.md#installing-an-agent-cli).
- **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
+67 -332
View File
@@ -20,35 +20,22 @@
--ok: #4ade80;
}
* { box-sizing: border-box; }
html { height: 100%; }
html, body { height: 100%; margin: 0; }
body {
min-height: 100%; margin: 0;
background: radial-gradient(1200px 600px at 50% -10%, #111a2b 0%, var(--bg) 60%) fixed, var(--bg);
color: var(--text);
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif;
display: flex; flex-direction: column; align-items: center;
display: flex; justify-content: center;
-webkit-user-select: none; user-select: none;
}
/* The top margin centres the tallest everyday page (a saved server with its
login, 708px), so the header stays where it is while the card changes: a
taller card grows downward, and a window too small for the page scrolls it
from the top (centring with align-items would cut the top off, out of
reach). */
main { width: min(440px, calc(100vw - 48px)); margin: max(20px, calc((100vh - 708px) / 2)) 0 24px; }
/* margin:auto, not align-items:center: a flex child taller than the window
then starts at the top instead of being cut off above it. */
main { width: min(440px, calc(100vw - 48px)); margin: auto; padding: 24px 0; }
header { text-align: center; margin-bottom: 26px; }
header img { width: 76px; height: 76px; display: block; margin: 0 auto 12px; }
h1 { font-size: 24px; font-weight: 650; letter-spacing: -0.01em; margin: 0; }
header p { margin: 4px 0 0; color: var(--muted); }
@media (max-height: 640px) {
header { margin-bottom: 16px; }
header img { width: 48px; height: 48px; margin-bottom: 8px; }
header p { display: none; }
h1 { font-size: 20px; }
}
/* As tall as its usual content at least, with the two settings at its foot,
so switching tabs or a status arriving does not resize it. */
.card {
display: flex; flex-direction: column; min-height: 304px;
background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
padding: 20px; box-shadow: 0 20px 60px rgba(0,0,0,0.45);
}
@@ -82,23 +69,16 @@
button.primary, button.secondary {
height: 38px; border-radius: 9px; font: inherit; font-weight: 600; padding: 0 16px; cursor: default;
}
button.primary { flex: 1; border: 0; color: white; background: linear-gradient(180deg, #2f6fed 0%, #2563eb 100%); }
button.primary { flex: 1; border: 0; color: white; background: linear-gradient(180deg, var(--accent) 0%, var(--accent-2) 100%); }
button.primary:disabled { opacity: 0.55; }
button.secondary { border: 1px solid var(--line-strong); background: #16181d; color: var(--text); }
/* A press answers at once, before the app does. Disabled comes last, so a
button that cannot be pressed does not look as if it could. */
button.primary:hover { filter: brightness(1.07); }
button.primary:active { filter: brightness(0.9); }
button.secondary:hover { border-color: var(--accent-2); }
button.secondary:active { background: #1f232b; }
button.primary:disabled, button.secondary:disabled { opacity: 0.55; filter: none; }
button.secondary:disabled { border-color: var(--line-strong); background: #16181d; }
button:focus-visible, .check input:focus-visible, summary:focus-visible, footer a:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 2px; }
button.secondary:disabled { opacity: 0.55; }
button[hidden] { display: none; }
#status { margin-top: 14px; font-size: 13px; color: var(--muted); display: flex; gap: 8px; align-items: flex-start; }
#status { min-height: 20px; margin-top: 14px; font-size: 13px; color: var(--muted); display: flex; gap: 8px; align-items: flex-start; }
#status.bad { color: var(--bad); }
#status.warn { color: var(--warn); }
#status.ok { color: var(--ok); }
#status-text { min-width: 0; overflow-wrap: anywhere; -webkit-user-select: text; user-select: text; }
#status .detail { display: block; color: var(--muted); font-size: 12px; margin-top: 4px; white-space: pre-wrap; -webkit-user-select: text; user-select: text; }
#suggest { display: block; max-width: 100%; margin-top: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#suggest[hidden] { display: none; }
@@ -108,8 +88,7 @@
.spinner[hidden] { display: none; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spinner { animation-duration: 2.4s; } }
.prefs { margin-top: auto; }
.prefs::before { content: ''; display: block; margin-top: 16px; padding-top: 4px; border-top: 1px solid var(--line); }
.prefs { margin-top: 16px; }
.tabs { display: flex; gap: 4px; padding: 4px; margin: 0 0 18px; background: #0c0d10; border: 1px solid var(--line); border-radius: 11px; }
.tabs button { all: unset; flex: 1; text-align: center; height: 30px; line-height: 30px; border-radius: 8px; font-weight: 600; font-size: 13px; color: var(--muted); cursor: default; }
.tabs button:hover { color: var(--text); }
@@ -125,22 +104,7 @@
@media (prefers-reduced-motion: reduce) { .dot.busy { animation: none; } }
#local-title { font-weight: 600; }
.sub { color: var(--muted); font-size: 12px; margin-top: 3px; -webkit-user-select: text; user-select: text; overflow-wrap: anywhere; }
/* The Codeman servers found on this Mac: a row each, in the look of a choice
in the install, with the row's own Connect button. */
#local-found-note { margin: 8px 0 0; }
#local-also { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line); }
#local-also-head { color: var(--muted); font-size: 12px; margin: 0 0 6px; }
.found {
display: flex; gap: 10px; align-items: center; padding: 9px 11px; border-radius: 9px;
border: 1px solid var(--line-strong); background: #0f1013;
}
.found + .found { margin-top: 6px; }
.found > span { flex: 1; min-width: 0; overflow-wrap: anywhere; -webkit-user-select: text; user-select: text; }
.found b { display: block; font-weight: 600; }
.found small { display: block; margin-top: 1px; color: var(--muted); font-size: 12px; }
.found button { flex: none; }
#elsewhere { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line); }
#elsewhere[hidden] { display: none; }
#elsewhere { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--line); }
#elsewhere summary { color: var(--muted); font-size: 13px; cursor: default; list-style: none; }
#elsewhere summary::-webkit-details-marker { display: none; }
#elsewhere summary::before { content: '\25B8'; display: inline-block; width: 14px; transition: transform 0.15s; }
@@ -153,15 +117,14 @@
.oneliner { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
.oneliner code { flex: 1; min-width: 0; font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; background: #0c0d10; border: 1px solid var(--line-strong); border-radius: 8px; padding: 9px 10px; color: #c9d1d9; -webkit-user-select: all; user-select: all; overflow-x: auto; white-space: nowrap; }
.statusrow { display: flex; gap: 10px; align-items: flex-start; }
.statusrow[hidden] { display: none; }
.statusrow #status { flex: 1; }
.statusrow #cancel { margin-top: 10px; }
/* The graphical install takes over the card. Its steps differ in height, and
the page is anchored from the top (see `main`), so the logo stays put. */
/* The graphical install takes over the card, and its steps differ in height:
anchored from the top, so the logo does not jump from one to the next. */
main:has(.card.wizard-on) { margin-top: max(0px, calc(14vh - 60px)); margin-bottom: auto; }
main:has(.card.wizard-on) header p { display: none; }
.card.wizard-on > .tabs, .card.wizard-on > #pane-local, .card.wizard-on > #form,
.card.wizard-on > .statusrow, .card.wizard-on > #suggest, .card.wizard-on > #elsewhere,
.card.wizard-on > .prefs { display: none; }
.card.wizard-on > .statusrow, .card.wizard-on > #suggest, .card.wizard-on > .prefs { display: none; }
#wizard[hidden], #wizard [hidden] { display: none; }
.wiz-head { display: flex; align-items: center; gap: 9px; }
.wiz-head .spinner { margin-top: 0; }
@@ -232,8 +195,8 @@
white-space: pre-wrap; overflow-wrap: anywhere; -webkit-user-select: text; user-select: text;
}
#wizard .actions .wiz-busy { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 13px; }
footer { margin-top: 18px; text-align: center; color: var(--muted); font-size: 12px; }
footer a { color: #aab1bb; text-decoration: none; cursor: default; }
footer { margin-top: 18px; text-align: center; color: #5d626b; font-size: 12px; }
footer a { color: #7c8491; text-decoration: none; cursor: default; }
footer a:hover { color: var(--accent); }
kbd { font: 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace; border: 1px solid var(--line-strong); border-bottom-width: 2px; border-radius: 4px; padding: 2px 4px; color: #9aa1ab; }
</style>
@@ -296,13 +259,11 @@
<section id="pane-local" role="tabpanel" aria-labelledby="tab-local">
<div class="local">
<span class="dot busy" id="local-dot"></span>
<div role="status">
<div>
<div id="local-title">Looking for Codeman on this Mac…</div>
<div class="sub" id="local-detail"></div>
</div>
</div>
<div id="local-found" role="list" aria-labelledby="local-title" hidden></div>
<p class="sub" id="local-found-note" hidden>Starting another server here would take over its sessions.</p>
<div class="actions">
<button class="primary" id="local-open" type="button" hidden>Open Codeman</button>
<button class="primary" id="local-start" type="button" hidden>Start Codeman</button>
@@ -311,10 +272,6 @@
<button class="secondary" id="local-check" type="button" hidden>Check again</button>
</div>
<button class="wiz-link" id="local-agents" type="button" hidden>Add a coding agent</button>
<div id="local-also" hidden>
<div id="local-also-head">Also running on this Mac</div>
<div id="local-also-list" role="list" aria-labelledby="local-also-head"></div>
</div>
</section>
<form id="form" role="tabpanel" aria-labelledby="tab-remote" autocomplete="off" novalidate hidden>
@@ -322,7 +279,7 @@
<input id="url" type="text" spellcheck="false" autocapitalize="off"
placeholder="http://127.0.0.1:3000 or https://your-box.ts.net" list="recent-list">
<datalist id="recent-list"></datalist>
<div id="recent" role="group" aria-label="Recent servers"></div>
<div id="recent" aria-label="Recent servers"></div>
<div id="login" hidden>
<div class="row">
@@ -343,28 +300,26 @@
<button class="secondary" id="start" type="button" hidden>Start Codeman on this Mac</button>
</div>
</form>
<div class="statusrow" id="status-row" hidden>
<div id="status" role="status" aria-live="polite"><span class="spinner" id="spinner" hidden></span><span id="status-text"></span></div>
<button class="secondary" id="cancel" type="button" hidden>Cancel</button>
</div>
<button class="secondary" id="suggest" type="button" hidden></button>
<details id="elsewhere" hidden>
<details id="elsewhere">
<summary>Codeman is not installed there yet?</summary>
<label class="field" for="ssh">Install it over SSH</label>
<div class="inline">
<!-- 300 is what setup.rs takes for a login, and at most 900 bytes: under the cap of a message the app accepts. -->
<input id="ssh" type="text" maxlength="300" spellcheck="false" autocapitalize="off" placeholder="you@machine, or a name from ~/.ssh/config">
<input id="ssh" type="text" spellcheck="false" autocapitalize="off" placeholder="you@machine, or a name from ~/.ssh/config">
<button class="secondary" id="ssh-install" type="button">Install</button>
</div>
<p class="sub">Terminal opens, connects with ssh and runs Codeman’s installer there. Or run this in a terminal on that machine:</p>
<div class="oneliner"><code id="oneliner">curl -fsSL https://getcodeman.com/install | bash</code><button class="secondary" id="copy" type="button">Copy</button></div>
</details>
</form>
<div class="statusrow">
<div id="status" role="status" aria-live="polite"><span class="spinner" id="spinner" hidden></span><span id="status-text"></span></div>
<button class="secondary" id="cancel" type="button" hidden>Cancel</button>
</div>
<button class="secondary" id="suggest" type="button" hidden></button>
<div class="prefs">
<label class="check"><input id="auto" type="checkbox" checked> Connect automatically when the app opens</label>
<label class="check"><input id="auto" type="checkbox"> Connect automatically when the app opens</label>
<label class="check"><input id="fullscreen" type="checkbox"> Always open in full screen <kbd>&#8963;&#8984;F</kbd></label>
</div>
</div>
@@ -393,7 +348,7 @@
form: $('form'), url: $('url'), list: $('recent-list'), recent: $('recent'),
login: $('login'), username: $('username'), password: $('password'), remember: $('remember'),
connect: $('connect'), cancel: $('cancel'), start: $('start'), suggest: $('suggest'),
statusRow: $('status-row'), status: $('status'), spinner: $('spinner'), statusText: $('status-text'),
status: $('status'), spinner: $('spinner'), statusText: $('status-text'),
auto: $('auto'), fullscreen: $('fullscreen'), version: $('version'), webkit: $('webkit'),
tabLocal: $('tab-local'), tabRemote: $('tab-remote'), paneLocal: $('pane-local'),
localDot: $('local-dot'), localTitle: $('local-title'), localDetail: $('local-detail'),
@@ -406,28 +361,20 @@
wizTerminal: $('wiz-terminal'),
localOpen: $('local-open'), localStart: $('local-start'), localInstall: $('local-install'),
localStop: $('local-stop'), localCheck: $('local-check'), localAgents: $('local-agents'),
localFound: $('local-found'), localFoundNote: $('local-found-note'),
localAlso: $('local-also'), localAlsoList: $('local-also-list'),
elsewhere: $('elsewhere'), ssh: $('ssh'), sshInstall: $('ssh-install'), copy: $('copy')
};
var manual = /[?&]manual=1/.test(location.search);
// startUrl: the address the user asked to start Codeman for ('' when this
// page did not ask, e.g. it was reloaded while a start was running).
// suggestion: the address the suggest button connects to.
// tab: 'local' (This Mac) or 'remote' (Another machine). askedFrom: the tab
// the connect under way was asked from, where its answer belongs. local: the
// last This Mac status from the app. wait: set while an install runs in
// tab: 'local' (This Mac) or 'remote' (Another machine). local: the last
// This Mac status from the app. wait: set while an install runs in
// Terminal and this page polls for the server it brings up. gui: the
// graphical install while its screens are up (newWizard), null otherwise.
// reopen: a connect to this Mac was refused, so open it if it turns out to
// be starting. startingLooks: how often the page has looked again at a
// starting server. askedFound: the connect under way was asked from the row
// of a server found on this Mac.
var state = {
servers: [], busy: false, lastUrl: '', startUrl: '', suggestion: '',
tab: 'local', askedFrom: 'local', askedFound: false, local: null, localChecking: false, opening: false,
wait: null, waitTimer: 0, stoppedWaiting: false, gui: null, wantAgents: false,
reopen: false, startingLooks: 0, startingTimer: 0
tab: 'local', local: null, localChecking: false, opening: false, wait: null, waitTimer: 0,
stoppedWaiting: false, gui: null, wantAgents: false
};
// While an install runs: how often to look, how long a finished install may
// take to answer before Start is offered, and when to stop looking.
@@ -437,32 +384,10 @@
// The phases `install.sh --progress` reports, in its order.
var PHASES = ['runtime', 'agent', 'source', 'packages', 'build', 'service'];
var MAX_LOG_LINES = 600;
// A server that is still starting: how often to look again, and how many times.
var STARTING_POLL_MS = 1000;
var STARTING_MAX_LOOKS = 20;
// What the app takes in one message, in UTF-8 bytes (MAX_URL_LEN and
// MAX_FIELD_LEN in policy.rs). It drops a longer one without an answer, so
// the page has to be the one that says so.
var MAX_URL = 4096;
var MAX_FIELD = 1024;
function tooLong(text, max) {
var bytes = 0;
for (var i = 0; i < text.length; i++) {
var c = text.charCodeAt(i);
if (c < 0x80) bytes += 1;
else if (c < 0x800) bytes += 2;
else if (c >= 0xd800 && c < 0xdc00) { bytes += 4; i++; } // a surrogate pair: one character
else bytes += 3;
}
return bytes > max;
}
function post(msg) { try { window.ipc.postMessage(JSON.stringify(msg)); } catch (e) { /* not in the app */ } }
function setStatus(kind, text, detail) {
els.statusRow.hidden = !text;
// In a small window the line may be below the fold: bring it into view.
if (text && els.statusRow.scrollIntoView) els.statusRow.scrollIntoView({ block: 'nearest' });
els.status.className = kind || '';
els.spinner.hidden = kind !== 'busy';
els.statusText.textContent = text || '';
@@ -516,16 +441,14 @@
}
// The address as the app normalizes it (policy::normalize_server_url):
// http:// by default, https:// for a .ts.net name without a port, no query
// or fragment, a trailing slash. Only used to tell whether two addresses
// are one server.
// http:// by default, https:// for a .ts.net name, no query or fragment, a
// trailing slash. Only used to tell whether two addresses are one server.
function normalize(raw) {
var s = String(raw || '').trim();
if (!s) return '';
if (s.indexOf('://') < 0) {
var name = s.split(/[\/:]/)[0].toLowerCase();
var tailscaleServe = /\.ts\.net$/.test(name) && s.charAt(name.length) !== ':';
s = (tailscaleServe ? 'https' : 'http') + '://' + s;
s = (/\.ts\.net$/.test(name) ? 'https' : 'http') + '://' + s;
}
try {
var u = new URL(s);
@@ -565,7 +488,6 @@
x.className = 'x';
x.textContent = '×';
x.title = 'Forget this server' + (s.hasSavedPassword ? ' and its saved password' : '');
x.setAttribute('aria-label', 'Forget ' + host(s.url));
x.addEventListener('click', function () { if (!state.busy) post({ cmd: 'forget', url: s.url }); });
chip.appendChild(pick);
chip.appendChild(x);
@@ -586,29 +508,20 @@
function matchSaved() {
var match = state.servers.filter(function (s) { return s.url === els.url.value.trim(); })[0];
if (match) choose(match);
else els.password.placeholder = '';
}
// found: asked from the row of a server found on this Mac (connectFound).
function connect(found) {
function connect() {
if (state.busy || tooOld) return;
var url = els.url.value.trim();
if (!url) { setStatus('bad', 'Enter the address of your Codeman server.'); els.url.focus(); return; }
var username = els.login.hidden ? '' : els.username.value.trim();
var password = els.login.hidden ? '' : els.password.value;
if (tooLong(url, MAX_URL)) { setStatus('bad', 'That address is too long.'); els.url.focus(); return; }
if (tooLong(username, MAX_FIELD) || tooLong(password, MAX_FIELD)) {
setStatus('bad', 'That username or password is too long.');
return;
}
state.lastUrl = url;
state.askedFrom = state.tab;
state.askedFound = found === true;
setBusy(true);
setStatus('busy', 'Connecting to ' + url + '…');
var msg = { cmd: 'connect', url: url, remember: els.remember.checked };
if (username) msg.username = username;
if (password) msg.password = password;
if (!els.login.hidden) {
if (els.username.value.trim()) msg.username = els.username.value.trim();
if (els.password.value) msg.password = els.password.value;
}
post(msg);
}
@@ -634,7 +547,8 @@
unreachable: ['bad', 'This Mac cannot reach that address.', 'Check that the machine is on and on the same network (or that Tailscale is connected). ' + LOCAL_NETWORK],
unresolvable: ['bad', 'That host name could not be found.', 'Check the spelling, or that Tailscale is connected for a .ts.net name.'],
maybeHttps: ['bad', 'The server closed the connection without answering.', 'The port may be serving HTTPS: a Codeman started with --https only answers https://. Try the https:// address, or your tailnet name (https://….ts.net). Codeman’s own self-signed certificate names only localhost and 127.0.0.1, so the app cannot accept it at this address: serve Codeman through tailscale serve (a real ts.net certificate) or a reverse proxy with a real certificate.'],
tlsUntrusted: ['bad', 'The server’s HTTPS certificate is not trusted.', 'This is usually Codeman’s self-signed certificate (codeman web --https). It names only localhost and 127.0.0.1, so the app cannot accept it at this address. Serve Codeman through tailscale serve (a real https://….ts.net certificate) or a reverse proxy with a real certificate. For a server on this Mac, run Codeman without --https and use http://127.0.0.1:PORT.']
tlsUntrusted: ['bad', 'The server’s HTTPS certificate is not trusted.', 'This is usually Codeman’s self-signed certificate (codeman web --https). It names only localhost and 127.0.0.1, so the app cannot accept it at this address. Serve Codeman through tailscale serve (a real https://….ts.net certificate) or a reverse proxy with a real certificate. For a server on this Mac, run Codeman without --https and use http://127.0.0.1:PORT.'],
passwordChangeRequired: ['warn', 'Codeman asks for a new password for this account.', 'Open it in a browser to set one, then connect with the new password.']
};
// The same two at localhost or 127.0.0.1, the only names Codeman's
@@ -651,9 +565,6 @@
}
function onProbeResult(msg) {
// The answer belongs to the tab its connect was asked from, whichever one
// shows by now; with no connect under way, to the one in view.
var asked = state.busy ? state.askedFrom : state.tab;
setBusy(false);
hideSuggestion();
var o = msg.outcome || {};
@@ -662,16 +573,9 @@
// The login fields live on the Another machine tab.
selectTab('remote');
showLogin();
} else if (asked === 'local') {
// Opening the Codeman on this Mac failed: look at it again. Opening it
// once it is up is for the one the tab's first line shows (openLocal),
// never for a found server's row, which may be another server.
state.reopen = o.kind === 'refused' && !state.askedFound;
selectTab('local');
// selectTab starts the look itself when there has been none yet.
if (!state.localChecking) checkLocal('login');
} else {
selectTab('remote');
} else if (state.tab === 'local') {
// Opening the Codeman on this Mac failed: look at it again.
checkLocal('login');
}
if (o.kind === 'notCodeman') {
setStatus('bad', 'Something answered, but it is not a Codeman server (HTTP ' + o.status + ').', 'Check the port and, behind a proxy, the path Codeman is mounted under.');
@@ -694,9 +598,6 @@
}
if (explainLoopback[o.kind] && loopbackName(msg.url)) detail = explainLoopback[o.kind];
if (o.kind === 'tlsUntrusted' && o.detail) detail += '\n(' + o.detail + ')';
if ((o.kind === 'needsLogin' || o.kind === 'badCredentials') && /^http:/i.test(normalize(msg.url)) && !isLocalUrl(msg.url)) {
detail += ' This address is http://, so the password crosses the network unencrypted: fine on a tailnet or a network you trust.';
}
setStatus(e[0], e[1], detail);
if (o.kind === 'maybeHttps' && /^http:/i.test(msg.url || '')) suggest(String(msg.url).replace(/^http:/i, 'https:'));
} else {
@@ -723,13 +624,7 @@
return h === 'localhost' || /^127\./.test(h) || h === '[::1]';
}
// Look at this Mac, once, when the This Mac tab shows and nothing has yet.
function ensureLocalChecked() {
if (state.tab === 'local' && !state.local && !state.localChecking) checkLocal('login');
}
// later: leave the look at this Mac to whoever asked (ensureLocalChecked).
function selectTab(tab, later) {
function selectTab(tab) {
if (tab !== 'local' && tab !== 'remote') return;
var changed = state.tab !== tab;
state.tab = tab;
@@ -740,14 +635,15 @@
els.tabRemote.tabIndex = local ? -1 : 0;
els.paneLocal.hidden = !local;
els.form.hidden = local;
els.elsewhere.hidden = local;
if (changed && !state.busy) {
setStatus('', '');
hideSuggestion();
}
if (!later) ensureLocalChecked();
if (local && !state.local && !state.localChecking) checkLocal('login');
}
// quick: skip the login shell (used while an install runs, when only
// "does it answer yet" matters).
// How hard to look: 'quick' (files and processes, while an install runs),
// 'login' (also the login shell; every automatic look) or 'deep' (also the
// interactive profile, only on Check again).
@@ -768,66 +664,6 @@
els.localCheck.disabled = state.busy || state.localChecking;
els.localAgents.disabled = state.busy || tooOld || state.opening;
els.sshInstall.disabled = state.busy || state.opening;
Array.prototype.forEach.call(els.paneLocal.querySelectorAll('.found button'), function (b) {
b.disabled = state.busy || tooOld;
});
}
// A Codeman on this Mac that serves HTTPS, stopped or running.
var HTTPS_REFUSED = 'It is set up with HTTPS (codeman web --https), whose self-signed certificate this app refuses. Run it without --https to open it here.';
// The Codeman servers the app found on this Mac (setup.rs, inspect), other
// than the one the tab's first line is about.
function otherServers(s) {
return (s.found || []).filter(function (f) { return f && f.url !== s.url; });
}
function canConnect(f) {
return f.state === 'running' || f.state === 'needsLogin';
}
// What a found server's row says under its address: its version, or why the
// app cannot open it.
function foundNote(f) {
if (f.state === 'running') return f.version ? 'version ' + f.version : '';
if (f.state === 'needsLogin') return 'Asks for a login.';
if (f.state === 'certificate') return HTTPS_REFUSED;
return 'It does not answer at this address.';
}
// One row per found server: its address, a note, and Connect when the app
// can open it. primary: the first Connect is the tab's main action.
function renderFound(list, servers, primary) {
list.textContent = '';
servers.forEach(function (f) {
var url = String(f.url || '');
var row = document.createElement('div');
row.className = 'found';
row.setAttribute('role', 'listitem');
var text = document.createElement('span');
var where = document.createElement('b');
where.textContent = url;
text.appendChild(where);
var note = foundNote(f);
if (note) {
var small = document.createElement('small');
small.textContent = note;
text.appendChild(small);
}
row.appendChild(text);
if (canConnect(f)) {
var button = document.createElement('button');
button.type = 'button';
button.className = primary ? 'primary' : 'secondary';
primary = false;
button.textContent = 'Connect';
button.title = 'Connect to ' + url;
button.setAttribute('aria-label', 'Connect to ' + url);
button.addEventListener('click', function () { connectFound(url); });
row.appendChild(button);
}
list.appendChild(row);
});
}
function renderLocal() {
@@ -836,16 +672,12 @@
var dot = '';
var title = '';
var detail = '';
// Found servers: `main` when they are what the tab is about, `also` when
// they run beside the one its first line shows.
var main = [];
var also = [];
if (state.wait) {
dot = 'busy';
title = 'Installing Codeman in Terminal…';
detail = 'Answer the installer’s questions there. This window opens Codeman when it is done.';
show.stop = true;
} else if (!s) {
} else if (state.localChecking || !s) {
dot = 'busy';
title = 'Looking for Codeman on this Mac…';
} else if (s.installRunning && s.state !== 'running' && s.state !== 'needsLogin') {
@@ -853,40 +685,28 @@
title = 'Codeman is being installed in Terminal.';
detail = 'Finish the installer there, then Check again.';
show.check = true;
} else if (s.state === 'starting') {
dot = 'busy';
title = 'Codeman is starting on this Mac…';
detail = 'It will answer at ' + s.url + ' in a moment.';
show.check = true;
} else if (s.state === 'runningElsewhere') {
// Not at the address the app opens by default: each one gets a row.
main = (s.found || []).filter(Boolean);
dot = main.some(canConnect) ? 'ok' : 'warn';
title = 'Codeman is already running on this Mac.';
// No row: the app found the server's process, but no address to try.
if (!main.length) {
detail = 'The app could not find the address it listens at: connect to it under Another machine. ' +
'Starting another server here would take over its sessions.';
}
dot = 'warn';
title = 'A Codeman server is already running on this Mac.';
detail = 'It listens at ' + (s.elsewhere || []).join(', ') + ', which this app cannot open as ' + s.url +
'. Connect to its address under Another machine. Starting another server here would take over its sessions.';
show.check = true;
} else if (s.state === 'running') {
dot = 'ok';
title = 'Codeman is running on this Mac.';
detail = s.url + (s.version ? ' · version ' + s.version : '');
show.open = true;
also = otherServers(s);
} else if (s.state === 'needsLogin') {
dot = 'ok';
title = 'Codeman is running on this Mac.';
detail = s.url + ' asks for a login: Open asks for the password.';
show.open = true;
also = otherServers(s);
} else if (s.state === 'stopped') {
title = 'Codeman is installed on this Mac, but not running.';
if (s.daemon) {
detail = 'A system LaunchDaemon runs it, which an administrator starts: sudo launchctl kickstart -k system/com.codeman.web';
} else if (/^https:/i.test(s.url)) {
detail = HTTPS_REFUSED;
detail = 'It is set up with HTTPS (codeman web --https), whose self-signed certificate this app refuses. Run it without --https to open it here.';
} else {
detail = s.service
? 'It is set up to start when you log in, but nothing answers at ' + s.url + '. Start asks launchd to run it now.'
@@ -911,11 +731,9 @@
} else {
var o = s.outcome || {};
dot = 'warn';
also = otherServers(s);
if (o.kind === 'notCodeman') {
title = 'Something else answers at ' + s.url;
detail = 'It is not Codeman (HTTP ' + o.status + '). Stop what runs on that port, or connect to ' +
(also.some(canConnect) ? 'the Codeman listed below.' : 'Codeman’s real address under Another machine.');
detail = 'It is not Codeman (HTTP ' + o.status + '). Stop what runs on that port, or connect to Codeman’s real address under Another machine.';
} else {
var e = explain[o.kind];
title = e ? e[1] : 'Could not reach Codeman on this Mac.';
@@ -923,8 +741,6 @@
}
show.check = true;
}
// Looking again: what was found last stays on screen, so nothing jumps.
if (state.localChecking && s && !state.wait) dot = 'busy';
els.localDot.className = 'dot' + (dot ? ' ' + dot : '');
els.localTitle.textContent = title;
els.localDetail.textContent = detail;
@@ -933,11 +749,6 @@
els.localInstall.hidden = !show.install;
els.localStop.hidden = !show.stop;
els.localCheck.hidden = !show.check;
renderFound(els.localFound, main, true);
els.localFound.hidden = !main.length;
els.localFoundNote.hidden = !main.length;
renderFound(els.localAlsoList, also, false);
els.localAlso.hidden = !also.length;
// Codeman is here, in whatever state: adding an agent asks nothing of it.
els.localAgents.hidden = !s || !s.installed || !!state.wait || state.localChecking || (s.installRunning && !show.open);
syncLocalButtons();
@@ -968,38 +779,11 @@
onWaitStatus(state.local || {});
return;
}
if (followStarting(s)) return;
renderLocal();
if (wanted && s && s.installed && !state.gui) openAgentChooser();
else if (state.gui && state.gui.step === 'added') renderWizard();
}
// A Codeman that is still starting (its process runs, but listens nowhere
// yet: the first seconds after login, a restart): look again every second
// for a while. Once it is up, open it if a connect to this Mac was refused
// on the way here, which is what the user had asked for. True when it opened.
function followStarting(s) {
clearTimeout(state.startingTimer);
if (s && s.state === 'starting') {
if (state.startingLooks < STARTING_MAX_LOOKS) {
state.startingLooks++;
state.startingTimer = setTimeout(function () {
if (state.local && state.local.state === 'starting' && !state.wait) checkLocal('quick');
}, STARTING_POLL_MS);
}
return false;
}
var up = !!s && (s.state === 'running' || s.state === 'needsLogin');
var open = up && state.reopen && state.startingLooks > 0 && state.tab === 'local' && !state.busy;
state.startingLooks = 0;
state.reopen = false;
if (open) {
renderLocal();
openLocal();
}
return open;
}
// Point the Another machine form at `url` with only the login saved for it:
// never the username or a typed password of the server the form showed
// before, which connect() would otherwise send along.
@@ -1018,15 +802,6 @@
connect();
}
// Connect on a found server's row. It is the connect of an address typed
// under Another machine, and of Open: the app checks the address, uses the
// login saved for it and remembers the server the same way.
function connectFound(url) {
if (state.busy || tooOld) return;
useAddress(url);
connect(true);
}
function startLocal() {
if (state.busy || tooOld || !state.local) return;
useAddress(state.local.url);
@@ -1365,7 +1140,7 @@
: 'This app cannot install a coding agent on this Mac.'],
installing: ['Installing ' + name, installingLead],
added: [name === 'a coding agent' ? 'The coding agent is installed' : name + ' is installed',
'Codeman notices it within a few minutes: reload Codeman if it does not show up. You sign in the first time you start it.'],
'If Codeman does not list it yet, reload it, or give it a few minutes. You sign in the first time you start it.'],
failed: ['The install stopped', 'Its output is below. Try again, or follow the instructions on the agent’s own site.']
}[step] : {
preparing: ['Looking at this Mac', 'Getting the installer and checking what is already here.'],
@@ -1543,6 +1318,9 @@
if (state.gui && state.gui.step === 'finishing') closeWizard();
}
// One answer while an install runs. It stops at the first answer from a
// server: a password-protected one counts every unauthenticated look as a
// failed login.
function portOf(url) {
try {
var u = new URL(url);
@@ -1550,9 +1328,6 @@
} catch (e) { return 0; }
}
// One answer while an install runs. It stops at the first answer from a
// server: a password-protected one counts every unauthenticated look as a
// failed login.
function onWaitStatus(s) {
var w = state.wait;
var now = Date.now();
@@ -1629,26 +1404,21 @@
els.version.textContent = 'Codeman Desktop ' + (msg.version || '');
renderRecent();
if (state.servers[0]) choose(state.servers[0]);
var auto = !!(msg.autoConnect && !manual && !tooOld && !msg.starting && !msg.notice && state.servers[0]);
// The tab of the server used last; This Mac on a first run. An
// automatic connect opens Codeman without looking at this Mac first:
// that look runs a few processes and asks the server once more, and
// it is only needed when the connect fails, which then asks for it.
selectTab(state.servers[0] && !isLocalUrl(state.servers[0].url) ? 'remote' : 'local', auto);
// The tab of the server used last; This Mac on a first run.
selectTab(state.servers[0] && !isLocalUrl(state.servers[0].url) ? 'remote' : 'local');
if (msg.starting) {
setBusy(true, false);
setStatus('busy', 'Starting Codeman on this Mac…', msg.notice || '');
} else if (msg.notice) {
setStatus('warn', msg.notice);
}
if (auto) connect();
if (msg.autoConnect && !manual && !tooOld && !msg.starting && !msg.notice && state.servers[0]) connect();
else if (state.tab === 'remote') els.url.focus();
if (msg.addAgent) addAgent();
break;
case 'servers':
state.servers = msg.servers || [];
renderRecent();
if (!state.busy) matchSaved();
break;
case 'prefs':
if (typeof msg.startFullscreen === 'boolean') els.fullscreen.checked = msg.startFullscreen;
@@ -1664,18 +1434,14 @@
} else {
setStatus('ok', msg.version ? 'Opening Codeman ' + msg.version + '…' : 'Opening Codeman…');
}
// The server page is still loading: this is not a finished state.
els.spinner.hidden = false;
break;
case 'error':
setBusy(false);
// The tab first: switching it clears the status line.
setStatus('bad', msg.message || 'Something went wrong.');
if (msg.needsPassword) {
selectTab('remote');
showLogin();
}
setStatus('bad', msg.message || 'Something went wrong.');
ensureLocalChecked();
break;
case 'serverStarting':
setBusy(true, false);
@@ -1735,8 +1501,6 @@
case 'installFailed':
state.opening = false;
syncLocalButtons();
// The tab first: switching it clears the status line.
if (msg.remote) selectTab('remote');
setStatus('bad', msg.message || 'Could not open Terminal.');
break;
case 'copied':
@@ -1746,22 +1510,8 @@
}
};
// The second click of a double click acts only on the control that took the
// first: the first may have moved another one under the pointer.
var firstControl = null;
document.addEventListener('click', function (ev) {
var t = ev.target && ev.target.closest ? ev.target : null;
var c = t && (t.closest('label') || t.closest('button, summary, a, input'));
if (ev.detail < 2) { firstControl = c; return; }
if (c !== firstControl) { ev.preventDefault(); ev.stopPropagation(); }
}, true);
els.form.addEventListener('submit', function (ev) { ev.preventDefault(); connect(); });
els.cancel.addEventListener('click', function () {
post({ cmd: 'cancel' });
setBusy(false);
setStatus('', '');
ensureLocalChecked();
});
els.cancel.addEventListener('click', function () { post({ cmd: 'cancel' }); setBusy(false); setStatus('', ''); });
els.start.addEventListener('click', function () {
if (state.busy) return;
state.startUrl = state.lastUrl || els.url.value.trim();
@@ -1791,21 +1541,6 @@
(next === 'local' ? els.tabLocal : els.tabRemote).focus();
});
});
// Return opens or starts the Codeman on this Mac, or connects to the one
// found here when that is the tab's main action, and Esc cancels a connect,
// as in a Mac dialog. Install is left to a click: a stray Return must not
// start an installer.
document.addEventListener('keydown', function (ev) {
if (ev.defaultPrevented) return;
if (ev.key === 'Escape' && !els.cancel.hidden) { ev.preventDefault(); els.cancel.click(); return; }
if (ev.key !== 'Enter' || state.tab !== 'local') return;
if (ev.target && ev.target.closest && ev.target.closest('button, a, input, summary')) return;
// offsetParent: really on screen, whatever hides it (its own `hidden`, or
// a container that is not shown).
var main = [els.localOpen, els.localStart, els.localFound.querySelector('button.primary')]
.filter(function (b) { return b && b.offsetParent && !b.disabled; })[0];
if (main) { ev.preventDefault(); main.click(); }
});
els.localOpen.addEventListener('click', openLocal);
els.localStart.addEventListener('click', startLocal);
els.localInstall.addEventListener('click', openWizard);
-22
View File
@@ -282,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 -102
View File
@@ -25,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")]
@@ -43,17 +39,9 @@ pub struct Config {
pub auto_connect: bool,
/// Always open in full screen (otherwise the last state is restored).
pub start_fullscreen: bool,
/// 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())
}
impl Default for Config {
fn default() -> Self {
Config {
@@ -104,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];
@@ -655,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!(
+1 -43
View File
@@ -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 {
@@ -165,16 +161,10 @@ pub fn exec_bounded(mut cmd: Command, deadline: Instant, timed_out: &str) -> Res
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();
@@ -296,16 +286,6 @@ pub fn login_environment() -> Option<Vec<(String, String)>> {
capture_env(&shell, flags, Instant::now() + FIND_TIMEOUT).ok()?.ok()
}
/// 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);
}
}
/// `script` (SCRIPT or FIND_SCRIPT) under `/bin/sh` with exactly the captured
/// environment, so the server gets the profile's PATH, API keys and CODEMAN_*
/// settings and nothing of launchd's bare one.
@@ -313,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)
}
@@ -331,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)
}
@@ -443,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());
+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:");
}
}
+56 -331
View File
@@ -53,7 +53,7 @@ use wry::{NewWindowResponse, PageLoadEvent, PermissionResponse, WebView, WebView
use config::{Config, Rect, WindowFrame};
use credentials::Keychain;
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";
@@ -62,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.
@@ -73,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
@@ -97,7 +90,6 @@ 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 START_REFUSED: &str = "Codeman can only be started here for an http://127.0.0.1 or http://localhost address.";
mod menu_id {
@@ -168,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.
@@ -228,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 {
@@ -251,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>>,
@@ -336,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);
@@ -351,7 +332,6 @@ fn main() {
kind: Kind::Main,
committed: None,
session_reload_at: None,
content_process_gone_at: None,
},
);
@@ -360,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,
@@ -499,21 +479,21 @@ fn build_menu(config: &Config) -> muda::Result<AppMenu> {
})
}
/// 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
@@ -536,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,
));
}
}
}
@@ -620,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>>,
@@ -635,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
@@ -743,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)),
@@ -802,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(_));
@@ -817,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()
@@ -1013,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"),
}
}
@@ -1453,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()
@@ -1741,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}");
@@ -1778,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;
@@ -1805,7 +1728,6 @@ impl App {
},
committed: None,
session_reload_at: None,
content_process_gone_at: None,
},
);
true
@@ -1926,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) {
@@ -1961,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,
@@ -2072,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
);
}
}
+15 -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,
@@ -856,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/"
@@ -1061,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}");
@@ -1237,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"));
@@ -1273,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"));
@@ -1357,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/");
@@ -1442,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");
+8 -51
View File
@@ -9,7 +9,7 @@ use serde::Serialize;
use std::time::Duration;
use url::Url;
pub const TIMEOUT: Duration = Duration::from_secs(5);
const TIMEOUT: Duration = Duration::from_secs(5);
const MAX_BODY: u64 = 8 * 1024 * 1024;
/// The path the probe asks for, relative to the server's base URL.
@@ -228,11 +228,6 @@ 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)
}
/// `probe` with the time the whole exchange may take.
pub 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() },
@@ -241,7 +236,7 @@ pub fn probe_within(server: &Url, authorization: Option<&str>, timeout: Duration
.root_certs(ureq::tls::RootCerts::PlatformVerifier)
.build();
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(timeout))
.timeout_global(Some(TIMEOUT))
.http_status_as_error(false)
// Never follow: a 3xx comes back as an answer and is explained.
.max_redirects(0)
@@ -264,16 +259,12 @@ pub 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,
@@ -733,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);
}
}
+115 -733
View File
@@ -1,7 +1,6 @@
//! The connect screen's "This Mac" tab and its Terminal installs: where the
//! Codeman on this Mac listens, whether it is installed, its login, which
//! other Codeman servers of this user run here, and the installer runs in
//! Terminal for this Mac and for another machine over SSH.
//! 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 Terminal route is for what that one cannot do. `install.sh` asks for
@@ -94,62 +93,43 @@ pub fn parse_service(job: &serde_json::Value) -> ServiceConfig {
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
let mut port = var("CODEMAN_PORT");
let mut base = var("CODEMAN_BASE_URL");
let mut https = false;
// Only what follows the `web` subcommand: node's own flags come before it.
let after_web = args
.iter()
.position(|a| *a == "web")
.map_or(&[][..], |i| &args[i + 1..]);
let flags = WebFlags {
port: var("CODEMAN_PORT"),
base: var("CODEMAN_BASE_URL"),
https: false,
}
.read(after_web);
ServiceConfig {
port: flags
.port
.and_then(|p| p.trim().parse::<u16>().ok())
.filter(|p| *p > 0)
.unwrap_or(DEFAULT_PORT),
base_path: normalize_base_path(flags.base.as_deref().unwrap_or("")),
https: flags.https,
username: var("CODEMAN_USERNAME"),
password: var("CODEMAN_PASSWORD"),
daemon: false,
}
}
/// What Codeman's `web` command takes from the arguments after `web`
/// (src/cli.ts). The fields start as what the environment says, or empty.
#[derive(Debug, Default)]
struct WebFlags {
port: Option<String>,
base: Option<String>,
https: bool,
}
impl WebFlags {
fn read(mut self, after_web: &[&str]) -> Self {
let mut it = after_web.iter();
while let Some(arg) = it.next() {
match *arg {
"-p" | "--port" => self.port = it.next().map(|s| s.to_string()),
"--base-url" => self.base = it.next().map(|s| s.to_string()),
"--https" => self.https = true,
// Options that take a value: skip it, so it is never read as a flag.
"-H" | "--host" | "--title-hostname" => {
it.next();
}
other => {
if let Some(v) = other.strip_prefix("--port=") {
self.port = Some(v.to_string());
} else if let Some(v) = other.strip_prefix("--base-url=") {
self.base = Some(v.to_string());
}
let mut it = after_web.iter();
while let Some(arg) = it.next() {
match *arg {
"-p" | "--port" => port = it.next().map(|s| s.to_string()),
"--base-url" => base = it.next().map(|s| s.to_string()),
"--https" => https = true,
// Options that take a value: skip it, so it is never read as a flag.
"-H" | "--host" | "--title-hostname" => {
it.next();
}
other => {
if let Some(v) = other.strip_prefix("--port=") {
port = Some(v.to_string());
} else if let Some(v) = other.strip_prefix("--base-url=") {
base = Some(v.to_string());
}
}
}
self
}
ServiceConfig {
port: port
.and_then(|p| p.trim().parse::<u16>().ok())
.filter(|p| *p > 0)
.unwrap_or(DEFAULT_PORT),
base_path: normalize_base_path(base.as_deref().unwrap_or("")),
https,
username: var("CODEMAN_USERNAME"),
password: var("CODEMAN_PASSWORD"),
daemon: false,
}
}
@@ -345,26 +325,17 @@ fn parse_ps(text: &str) -> Vec<(u32, String)> {
/// `install.sh` clone's `dist/index.js web`, a source checkout's
/// `src/index.ts web`), not one of its short-lived control commands.
pub fn is_codeman_server(command: &str) -> bool {
server_args(command).is_some()
}
/// What follows `web` on a Codeman server's command line, or None for any
/// other command.
fn server_args(command: &str) -> Option<Vec<&str>> {
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())
.find(|&i| {
is_entry(words[i])
&& words.get(i + 1) == Some(&"web")
&& !words[i + 1..].iter().any(|w| matches!(*w, "--stop" | "--status"))
})
.map(|i| words[i + 2..].to_vec())
});
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
@@ -398,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)]
@@ -466,138 +427,15 @@ fn serves_loopback_port(found: &[Listener], port: u16) -> bool {
.any(|l| l.port == port && matches!(l.host.as_str(), "*" | "127.0.0.1" | "0.0.0.0"))
}
/// Where the app can try each of this user's Codeman servers, from their
/// command lines (`procs`) and listening sockets. `default` is the address the
/// app opens by default (local_url); it comes first, then the others by port.
///
/// One address per server and port: 127.0.0.1 when the server listens there
/// or on every interface, `[::1]` when its only loopback socket is the IPv6
/// one, else each address it is bound to. A socket beside a loopback one on
/// the same port is left out (the Docker bridge listener, which serves hook
/// endpoints only). `https` when the command line carries `--https`, and its
/// `--base-url` is the path. `ps` does not show a base path kept in the
/// environment, which is where install.sh's launchd job keeps it, so a server
/// at the default address with no `--base-url` of its own is the default one.
pub fn found_addresses(procs: &[(u32, String)], listeners: &[Listener], default: &Url) -> Vec<Url> {
let mut urls: Vec<Url> = Vec::new();
for (pid, command) in procs {
let Some(args) = server_args(command) else {
continue;
};
let flags = WebFlags::default().read(&args);
let scheme = if flags.https { "https" } else { "http" };
let base = flags.base.as_deref().map(normalize_base_path);
let own: Vec<&Listener> = listeners.iter().filter(|l| l.pid == *pid).collect();
for l in &own {
let beside = |hosts: &[&str]| own.iter().any(|o| o.port == l.port && hosts.contains(&o.host.as_str()));
let host = if beside(&["*", "0.0.0.0", "[::]", "127.0.0.1"]) {
"127.0.0.1"
} else if beside(&["[::1]"]) {
"[::1]"
} else {
l.host.as_str()
};
let path = base.as_deref().unwrap_or("");
let Ok(mut url) = Url::parse(&format!("{scheme}://{host}:{}{path}/", l.port)) else {
continue;
};
if base.is_none() && policy::same_origin(&url, default) {
url = default.clone();
}
if !urls.contains(&url) {
urls.push(url);
}
}
}
urls.sort_by_key(|u| (u != default, u.port_or_known_default(), u.to_string()));
urls
}
/// What the This Mac tab says about one found server.
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum FoundState {
/// Codeman answers there: Connect opens it.
Running,
/// Codeman answers there and wants a login.
NeedsLogin,
/// It serves HTTPS with a certificate the app refuses (Codeman's own
/// self-signed one, from `codeman web --https`).
Certificate,
/// Codeman does not answer there.
Unreachable,
}
/// One of this user's Codeman servers on this Mac, for the This Mac tab's
/// rows.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct FoundServer {
/// Where the app can try it (found_addresses).
pub url: String,
pub version: Option<String>,
pub state: FoundState,
/// It is at the address the app opens by default (local_url).
pub is_default: bool,
}
/// The row for the server at `url`, from what it answered.
fn found_server(url: &Url, answer: &ProbeOutcome, default: &Url) -> FoundServer {
let state = match answer {
ProbeOutcome::Ok { .. } | ProbeOutcome::PasswordChangeRequired => FoundState::Running,
_ if answers(answer) => FoundState::NeedsLogin,
// A Codeman started with --https closes a plain http connection.
ProbeOutcome::TlsUntrusted { .. } | ProbeOutcome::MaybeHttps => FoundState::Certificate,
_ => FoundState::Unreachable,
/// The address a listener is reached at: 127.0.0.1 for a loopback or
/// 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" => "127.0.0.1".to_string(),
h if h.starts_with('[') => h.to_string(),
h => h.to_string(),
};
FoundServer {
url: url.to_string(),
version: match answer {
ProbeOutcome::Ok { version } => Some(version.clone()),
_ => None,
},
state,
is_default: url == default,
}
}
/// How long a found server gets to answer when the tab only lists it.
const FOUND_TIMEOUT: Duration = Duration::from_secs(2);
/// Ask the servers at `urls`, all at the same time, so the look takes as long
/// as the slowest answer and no longer. Without a login: the one the app can
/// read belongs to the default address (login_for).
fn ask_found(urls: &[Url], patience: Duration) -> Vec<ProbeOutcome> {
std::thread::scope(|scope| {
let asking: Vec<_> = urls
.iter()
.map(|url| scope.spawn(move || probe::probe_within(url, None, patience)))
.collect();
asking
.into_iter()
.map(|a| {
a.join().unwrap_or_else(|_| ProbeOutcome::Failed {
detail: "The check stopped.".into(),
})
})
.collect()
})
}
/// The address the This Mac tab is about, with its answer: the default one,
/// or, when nothing answers there, the first found server that does (a
/// `codeman web --port 4000` in a terminal, a bind to one network address).
fn shown<'a>(
default: &'a Url,
answer: &'a ProbeOutcome,
found: &'a [(Url, ProbeOutcome)],
) -> (&'a Url, &'a ProbeOutcome) {
if *answer == ProbeOutcome::Refused {
if let Some((url, answer)) = found.iter().find(|(_, a)| answers(a)) {
return (url, answer);
}
}
(default, answer)
Url::parse(&format!("http://{host}:{}/", l.port)).ok()
}
/// What the This Mac tab shows.
@@ -608,11 +446,8 @@ 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 none answers at an address the
/// app can open (HTTPS with Codeman's own certificate, say): see `found`.
/// 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,
/// Nothing answers, and Codeman is installed.
Stopped,
@@ -650,40 +485,12 @@ pub struct LocalStatus {
/// 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,
/// Every Codeman server of this user that listens on this Mac, the one at
/// the default address first: the tab offers to connect to each.
pub found: Vec<FoundServer>,
/// 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 {
@@ -699,54 +506,74 @@ pub enum Depth {
pub fn inspect(depth: Depth) -> LocalStatus {
let home = dirs::home_dir().unwrap_or_default();
let service = read_service();
let default = local_url(service.as_ref());
let url = local_url(service.as_ref());
let procs = list_processes();
let servers = codeman_servers(&procs);
let listening = listeners(&servers);
let install = running_install(&procs);
let found = listeners(&servers);
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(&listening, default.port_or_known_default().unwrap_or(0)) {
login_for(&default, service.as_ref(), &read_env_file()).map(|(u, p)| policy::basic_auth_header(&u, &p))
let auth = if serves_loopback_port(&found, url.port_or_known_default().unwrap_or(0)) {
login_for(&url, service.as_ref(), &read_env_file()).map(|(u, p)| policy::basic_auth_header(&u, &p))
} else {
None
};
let answer = probe::probe(&default, auth.as_deref());
let mut outcome = probe::probe(&url, auth.as_deref());
let mut url = url;
let mut elsewhere = Vec::new();
// Every address one of this user's Codeman servers listens at. The one at
// the default address has just answered; the others are asked now, once
// each. With nothing at the default address, the first of them that
// answers is what the tab opens, as it always was, so each gets the
// probe's usual time; otherwise they are only listed, and get less.
let patience = if answer == ProbeOutcome::Refused {
probe::TIMEOUT
} else {
FOUND_TIMEOUT
};
let addresses = found_addresses(&procs, &listening, &default);
let others: Vec<Url> = addresses.iter().filter(|u| **u != default).cloned().collect();
let mut answered: Vec<(Url, ProbeOutcome)> = others.iter().cloned().zip(ask_found(&others, patience)).collect();
// found_addresses puts the default address first.
if addresses.first() == Some(&default) {
answered.insert(0, (default.clone(), answer.clone()));
// Nothing at the usual address, but a Codeman server of this user runs:
// find where (a `codeman web --port 4000` in a terminal, a bind to one
// network address), so the tab never offers to start a second server on
// the same sessions.
if outcome == ProbeOutcome::Refused && !servers.is_empty() {
let mut ports_seen = Vec::new();
for l in &found {
if ports_seen.contains(&(l.host.clone(), l.port)) {
continue;
}
ports_seen.push((l.host.clone(), l.port));
let Some(candidate) = listener_url(l) else {
continue;
};
let answer = probe::probe(&candidate, None);
if matches!(
answer,
ProbeOutcome::Ok { .. }
| ProbeOutcome::NeedsLogin
| ProbeOutcome::BadCredentials
| ProbeOutcome::RateLimited { .. }
| ProbeOutcome::PasswordChangeRequired
) {
url = candidate;
outcome = answer;
break;
}
elsewhere.push(format!("{}:{}", l.host, l.port));
}
if outcome == ProbeOutcome::Refused && elsewhere.is_empty() {
elsewhere = servers.iter().map(|pid| format!("process {pid}")).collect();
}
}
let found: Vec<FoundServer> = answered.iter().map(|(u, a)| found_server(u, a, &default)).collect();
// The tab never offers to start a second server on the same sessions: it
// opens the one that answers, or lists where they listen.
let (url, outcome) = shown(&default, &answer, &answered);
let (url, outcome) = (url.clone(), outcome.clone());
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 {
@@ -754,7 +581,16 @@ 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(), !listening.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();
}
let version = match &outcome {
ProbeOutcome::Ok { version } => Some(version.clone()),
_ => None,
@@ -771,7 +607,7 @@ pub fn inspect(depth: Depth) -> LocalStatus {
install_port: install.flatten(),
install_in_app,
agent_install_in_app,
found,
elsewhere,
outcome,
}
}
@@ -796,15 +632,6 @@ pub 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`
@@ -814,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}"
@@ -870,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())
}
@@ -1411,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\
@@ -1489,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],
@@ -1509,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);
@@ -1546,375 +1294,9 @@ mod tests {
assert!(serves_loopback_port(&found, 4000));
assert!(!serves_loopback_port(&found, 3999));
assert!(!serves_loopback_port(&found, 5000));
// The same four sockets as the addresses the app can try, by port.
assert_eq!(
addresses(&procs, &found, "http://127.0.0.1:3000/"),
[
"http://127.0.0.1:3000/",
"http://172.17.0.1:3999/",
"http://127.0.0.1:4000/",
"http://[::1]:5000/"
]
);
}
fn server(pid: u32, flags: &str) -> (u32, String) {
(pid, format!("node /opt/homebrew/bin/codeman web{flags}"))
}
fn listener(pid: u32, host: &str, port: u16) -> Listener {
Listener {
pid,
host: host.into(),
port,
}
}
/// found_addresses, as strings. The page sends one back in a `connect`,
/// so each must be an address the connect takes as it is: the app saves
/// a server, and finds its saved login, under that exact string.
fn addresses(procs: &[(u32, String)], listeners: &[Listener], default: &str) -> Vec<String> {
found_addresses(procs, listeners, &u(default))
.iter()
.map(|url| {
assert_eq!(policy::normalize_server_url(url.as_str()).as_ref(), Ok(url));
url.to_string()
})
.collect()
}
#[test]
fn a_found_server_becomes_an_address_the_app_can_try() {
let default = "http://127.0.0.1:3000/";
let one = |flags: &str, listeners: &[Listener]| addresses(&[server(1, flags)], listeners, default);
// Every interface and IPv4 loopback are reached at 127.0.0.1.
for host in ["*", "0.0.0.0", "[::]", "127.0.0.1"] {
assert_eq!(
one(" --port 4000", &[listener(1, host, 4000)]),
["http://127.0.0.1:4000/"],
"{host}"
);
}
// IPv6 loopback keeps its own address only when it is the server's
// only loopback socket.
assert_eq!(one("", &[listener(1, "[::1]", 5000)]), ["http://[::1]:5000/"]);
assert_eq!(
one("", &[listener(1, "[::1]", 5000), listener(1, "127.0.0.1", 5000)]),
["http://127.0.0.1:5000/"]
);
// A dual-stack bind shows as two sockets, both `*`: one address.
assert_eq!(
one("", &[listener(1, "*", 5000), listener(1, "*", 5000)]),
["http://127.0.0.1:5000/"]
);
// A bind to one address stays that address.
assert_eq!(
one(" --host 192.168.1.5", &[listener(1, "192.168.1.5", 3000)]),
["http://192.168.1.5:3000/"]
);
// --https and --base-url come from the command line, in both spellings.
assert_eq!(
one(" --https --port 4000 --base-url /codeman/", &[listener(1, "*", 4000)]),
["https://127.0.0.1:4000/codeman/"]
);
assert_eq!(
one(" -p 4000 --base-url=cm", &[listener(1, "127.0.0.1", 4000)]),
["http://127.0.0.1:4000/cm/"]
);
// The value of --host is never read as a flag, and a base path Codeman
// would refuse counts as none.
assert_eq!(
one(" --host --https -p 4000", &[listener(1, "127.0.0.1", 4000)]),
["http://127.0.0.1:4000/"]
);
assert_eq!(
one(" -p 4000 --base-url /a?b", &[listener(1, "127.0.0.1", 4000)]),
["http://127.0.0.1:4000/"]
);
// What comes before `web` is node's business.
assert_eq!(
addresses(
&[(1, "node --https /Users/k/.codeman/app/dist/index.js web".to_string())],
&[listener(1, "*", 4000)],
default
),
["http://127.0.0.1:4000/"]
);
// A server with two listeners. Beside a loopback socket on the same
// port, another one is the same server (the Docker bridge listener);
// on another port, or with no loopback socket, each is an address.
assert_eq!(
one("", &[listener(1, "127.0.0.1", 3001), listener(1, "172.17.0.1", 3001)]),
["http://127.0.0.1:3001/"]
);
assert_eq!(
one("", &[listener(1, "172.17.0.1", 3999), listener(1, "127.0.0.1", 3001)]),
["http://127.0.0.1:3001/", "http://172.17.0.1:3999/"]
);
assert_eq!(
one("", &[listener(1, "192.168.1.5", 3001), listener(1, "100.64.0.7", 3001)]),
["http://100.64.0.7:3001/", "http://192.168.1.5:3001/"]
);
// Only the sockets of Codeman servers count: not a control command's,
// another program's, or one whose process `ps` did not list.
let procs = [
server(1, " --port 4000"),
(2, "node /opt/homebrew/bin/codeman attach w1".to_string()),
(3, "python3 -m http.server 8000".to_string()),
];
let sockets = [
listener(2, "*", 4100),
listener(3, "*", 8000),
listener(9, "*", 9000),
listener(1, "*", 4000),
];
assert_eq!(addresses(&procs, &sockets, default), ["http://127.0.0.1:4000/"]);
assert!(addresses(&procs, &sockets[..3], default).is_empty());
// An address that makes no URL is left out.
assert!(one("", &[listener(1, "not a host", 3001)]).is_empty());
}
#[test]
fn found_addresses_start_with_the_default_one_then_go_by_port() {
// install.sh's launchd job: a bare `web`, the base path in its environment.
let default = "http://127.0.0.1:3001/codeman/";
let procs = [
(
10,
"node /x/tsx/cli.mjs /Users/k/codeman/src/index.ts web --port 5000".to_string(),
),
(
20,
"/opt/homebrew/bin/node /Users/k/.codeman/app/dist/index.js web".to_string(),
),
server(30, " -p 4000 --https"),
// The same address twice: a second process on the port of the first.
server(40, " --port 5000"),
];
let sockets = [
listener(10, "*", 5000),
listener(20, "*", 3001),
listener(30, "127.0.0.1", 4000),
listener(40, "127.0.0.1", 5000),
];
assert_eq!(
addresses(&procs, &sockets, default),
[
"http://127.0.0.1:3001/codeman/",
"https://127.0.0.1:4000/",
"http://127.0.0.1:5000/"
]
);
// The same list whatever order `ps` and `lsof` gave.
let mut reversed_procs = procs.to_vec();
reversed_procs.reverse();
let mut reversed_sockets = sockets.to_vec();
reversed_sockets.reverse();
assert_eq!(
addresses(&reversed_procs, &reversed_sockets, default),
addresses(&procs, &sockets, default)
);
// On the default port, a command line with its own base path or
// scheme is taken at its word: it may be another server than the job's.
let on_3001 = |flags: &str| addresses(&[server(1, flags)], &[listener(1, "*", 3001)], default);
assert_eq!(on_3001(" --port 3001"), [default]);
assert_eq!(on_3001(" --port 3001 --base-url /codeman"), [default]);
assert_eq!(
on_3001(" --port 3001 --base-url /other"),
["http://127.0.0.1:3001/other/"]
);
assert_eq!(on_3001(" --port 3001 --base-url /"), ["http://127.0.0.1:3001/"]);
assert_eq!(on_3001(" --port 3001 --https"), ["https://127.0.0.1:3001/"]);
// 127.0.0.1 only: a bind to another address is not the default one.
assert_eq!(
addresses(&[server(1, "")], &[listener(1, "192.168.1.5", 3001)], default),
["http://192.168.1.5:3001/"]
);
}
#[test]
fn a_found_server_row_says_what_answered() {
let default = u("http://127.0.0.1:3000/");
let at = u("http://127.0.0.1:4000/");
let row = |answer: ProbeOutcome| found_server(&at, &answer, &default);
assert_eq!(
row(ProbeOutcome::Ok {
version: "1.49.0".into()
}),
FoundServer {
url: "http://127.0.0.1:4000/".into(),
version: Some("1.49.0".into()),
state: FoundState::Running,
is_default: false,
}
);
let state = |answer: ProbeOutcome| {
let row = row(answer);
assert_eq!(row.version, None);
row.state
};
assert_eq!(state(ProbeOutcome::PasswordChangeRequired), FoundState::Running);
for login in [
ProbeOutcome::NeedsLogin,
ProbeOutcome::BadCredentials,
ProbeOutcome::RateLimited {
sent_credentials: false,
retry_after_secs: Some(60),
},
] {
assert_eq!(state(login), FoundState::NeedsLogin);
}
for certificate in [
ProbeOutcome::TlsUntrusted {
detail: "invalid peer certificate: UnknownIssuer".into(),
},
ProbeOutcome::MaybeHttps,
] {
assert_eq!(state(certificate), FoundState::Certificate);
}
for silent in [
ProbeOutcome::Refused,
ProbeOutcome::Timeout,
ProbeOutcome::Unreachable,
ProbeOutcome::HostNotAllowed,
ProbeOutcome::NotCodeman { status: 403 },
ProbeOutcome::Failed { detail: "x".into() },
] {
assert_eq!(state(silent), FoundState::Unreachable);
}
assert!(found_server(&default, &ProbeOutcome::Refused, &default).is_default);
// As the connect page reads it.
assert_eq!(
serde_json::to_value(row(ProbeOutcome::Ok {
version: "1.49.0".into()
}))
.unwrap(),
json!({ "url": "http://127.0.0.1:4000/", "version": "1.49.0", "state": "running", "isDefault": false })
);
assert_eq!(
serde_json::to_value(found_server(&default, &ProbeOutcome::NeedsLogin, &default)).unwrap(),
json!({ "url": "http://127.0.0.1:3000/", "version": null, "state": "needsLogin", "isDefault": true })
);
assert_eq!(
serde_json::to_value([FoundState::Certificate, FoundState::Unreachable]).unwrap(),
json!(["certificate", "unreachable"])
);
}
#[test]
fn the_tab_is_about_the_default_address_unless_nothing_answers_there() {
let default = u("http://127.0.0.1:3000/");
let ok = ProbeOutcome::Ok {
version: "1.49.0".into(),
};
let refused = ProbeOutcome::Refused;
let found = vec![
(
u("https://127.0.0.1:4000/"),
ProbeOutcome::TlsUntrusted { detail: "x".into() },
),
(u("http://127.0.0.1:5000/"), ProbeOutcome::NeedsLogin),
(u("http://127.0.0.1:6000/"), ok.clone()),
];
// Nothing at the default address: the first found server that
// answers, one that wants a login included.
assert_eq!(
shown(&default, &refused, &found),
(&found[1].0, &ProbeOutcome::NeedsLogin)
);
assert_eq!(shown(&default, &refused, &found[2..]), (&found[2].0, &ok));
// None of them answers: the tab stays on the default address, and
// local_state makes that RunningElsewhere.
assert_eq!(shown(&default, &refused, &found[..1]), (&default, &refused));
assert_eq!(shown(&default, &refused, &[]), (&default, &refused));
// Whatever else the default address says is what the tab shows; the
// servers found elsewhere are only listed.
for answer in [
ok.clone(),
ProbeOutcome::NeedsLogin,
ProbeOutcome::NotCodeman { status: 404 },
ProbeOutcome::Timeout,
ProbeOutcome::MaybeHttps,
] {
assert_eq!(shown(&default, &answer, &found), (&default, &answer));
}
}
/// Against real sockets: every address is asked, at the same time, and
/// the answers come back in the order of the addresses.
#[test]
fn found_servers_are_asked_together() {
use std::io::Read;
use std::net::TcpListener;
use std::sync::mpsc;
/// A server for one request: it reads it, and answers with `response`
/// if `ready` says so.
fn serve(response: String, ready: impl FnOnce() -> bool + Send + 'static) -> Url {
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 head = Vec::new();
let mut buf = [0u8; 4096];
while !head.windows(4).any(|w| w == b"\r\n\r\n") {
match s.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => head.extend_from_slice(&buf[..n]),
}
}
if ready() {
let _ = s.write_all(response.as_bytes());
}
}
});
u(&format!("http://127.0.0.1:{port}/"))
}
fn response(status_line: &str, body: &str) -> String {
format!(
"HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
}
// The first server answers only once the second has been asked. Asked
// one after the other, it would hang up without an answer.
let (asked, was_asked) = mpsc::channel::<()>();
let first = serve(
response("200 OK", r#"{"success":true,"data":{"version":"9.9.9"}}"#),
move || was_asked.recv_timeout(Duration::from_secs(4)).is_ok(),
);
let second = serve(response("401 Unauthorized", "Unauthorized"), move || {
asked.send(()).is_ok()
});
let closed = {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
u(&format!("http://127.0.0.1:{}/", l.local_addr().unwrap().port()))
};
assert_eq!(
ask_found(&[first, second, closed], probe::TIMEOUT),
[
ProbeOutcome::Ok {
version: "9.9.9".into()
},
ProbeOutcome::NeedsLogin,
ProbeOutcome::Refused,
]
);
// One that does not answer in the time it is given.
let slow = serve(String::new(), || {
std::thread::sleep(Duration::from_secs(2));
false
});
assert_eq!(ask_found(&[slow], Duration::from_millis(300)), [ProbeOutcome::Timeout]);
assert!(ask_found(&[], FOUND_TIMEOUT).is_empty());
assert_eq!(listener_url(&found[1]).unwrap().as_str(), "http://172.17.0.1:3999/");
assert_eq!(listener_url(&found[2]).unwrap().as_str(), "http://127.0.0.1:4000/");
assert_eq!(listener_url(&found[3]).unwrap().as_str(), "http://[::1]:5000/");
}
#[test]
+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) {
+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');
+6 -272
View File
@@ -1,13 +1,11 @@
/**
* @fileoverview The graphical install's screens in the Codeman Desktop connect page, and the This
* Mac tab's rows for the Codeman servers the app found on this Mac.
* @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`), src/gui_install.rs and src/setup.rs (the `found` list:
* `a_found_server_row_says_what_answered`), which do not build on Linux CI, so the spelling of
* every message the page sends, and of what it reads, is asserted here.
* (`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.
*/
@@ -47,7 +45,7 @@ const NOT_INSTALLED = {
installComplete: false,
installRunning: false,
installInApp: false,
found: [],
elsewhere: [],
outcome: { kind: 'refused' },
};
@@ -484,11 +482,7 @@ describe('adding a coding agent to a Mac that has Codeman', () => {
RUNNING,
STOPPED,
{ ...RUNNING, state: 'needsLogin' },
{
...RUNNING,
state: 'runningElsewhere',
found: [{ url: 'https://127.0.0.1:4000/', version: null, state: 'certificate', isDefault: false }],
},
{ ...RUNNING, state: 'runningElsewhere', elsewhere: ['127.0.0.1:4000'] },
]) {
t.native({ type: 'localStatus', status });
expect(t.visible('local-agents'), String(status.state)).toBe(true);
@@ -552,8 +546,7 @@ describe('adding a coding agent to a Mac that has Codeman', () => {
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('within a few minutes');
expect(t.$('wiz-lead').textContent).toContain('reload');
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);
@@ -741,262 +734,3 @@ describe('after Skip for now', () => {
expect(t.sentOf('connect')).toHaveLength(1);
});
});
describe('Codeman servers found on this Mac', () => {
type Found = { url: string; version: string | null; state: string; isDefault: boolean };
const found = (url: string, state = 'running', version: string | null = '1.49.0'): Found => ({
url,
version: state === 'running' ? version : null,
state,
isDefault: false,
});
const ELSEWHERE = {
...NOT_INSTALLED,
state: 'runningElsewhere',
installed: true,
installComplete: true,
found: [found('http://127.0.0.1:4000/'), found('http://192.168.1.5:5000/cm/', 'needsLogin')],
};
const RUNNING = {
...NOT_INSTALLED,
state: 'running',
version: '1.49.0',
installed: true,
installComplete: true,
outcome: { kind: 'ok', version: '1.49.0' },
found: [{ ...found('http://127.0.0.1:3000/'), isDefault: true }],
};
function at(status: Record<string, unknown>, first?: Record<string, unknown>) {
const t = open(first ?? { servers: [] });
t.native({ type: 'localStatus', status });
const rows = (list: string) =>
Array.from(t.$(list).querySelectorAll('.found')).map((row) => ({
address: row.querySelector('b')?.textContent,
note: row.querySelector('small')?.textContent ?? '',
button: row.querySelector('button'),
}));
return { ...t, rows };
}
it('recommends the server that runs at another address, a row each, with no trip to the other tab', () => {
const t = at(ELSEWHERE);
expect(t.$('local-title').textContent).toBe('Codeman is already running on this Mac.');
expect(t.$('local-detail').textContent).toBe('');
const rows = t.rows('local-found');
expect(rows.map((r) => [r.address, r.note])).toEqual([
['http://127.0.0.1:4000/', 'version 1.49.0'],
['http://192.168.1.5:5000/cm/', 'Asks for a login.'],
]);
expect(rows.map((r) => r.button?.textContent)).toEqual(['Connect', 'Connect']);
// The first row the app can open is the tab's main action.
expect(rows.map((r) => r.button?.className)).toEqual(['primary', 'secondary']);
expect(rows[0].button?.getAttribute('aria-label')).toBe('Connect to http://127.0.0.1:4000/');
expect(t.visible('local-found-note')).toBe(true);
expect(t.$('local-found-note').textContent).toBe('Starting another server here would take over its sessions.');
expect(t.$('pane-local').textContent).not.toContain('Another machine');
expect(t.visible('local-check')).toBe(true);
for (const id of ['local-open', 'local-start', 'local-install', 'local-also']) {
expect(t.visible(id), id).toBe(false);
}
expect(t.$('local-dot').className).toBe('dot ok');
// Finding a server connects to nothing by itself.
expect(t.sentOf('connect')).toHaveLength(0);
});
it('Connect is the connect of an address typed under Another machine', () => {
const t = at(ELSEWHERE);
t.rows('local-found')[1].button!.click();
expect(t.sentOf('connect')).toEqual([{ cmd: 'connect', url: 'http://192.168.1.5:5000/cm/', remember: true }]);
expect(t.input('url').value).toBe('http://192.168.1.5:5000/cm/');
expect(t.$('status-text').textContent).toContain('Connecting to http://192.168.1.5:5000/cm/');
// One request at a time, as everywhere on the page.
expect(t.rows('local-found').every((r) => r.button!.disabled)).toBe(true);
t.rows('local-found')[0].button!.click();
expect(t.sentOf('connect')).toHaveLength(1);
});
it('uses the login saved for that server, and never the one of the server shown before', () => {
const servers = [
{ url: 'https://box.ts.net/', username: 'other', hasSavedPassword: true },
{ url: 'http://127.0.0.1:4000/', username: 'kay', hasSavedPassword: true },
];
const t = at(ELSEWHERE, { servers });
t.click('tab-local');
t.rows('local-found')[0].button!.click();
expect(t.sentOf('connect')).toEqual([
{ cmd: 'connect', url: 'http://127.0.0.1:4000/', remember: true, username: 'kay' },
]);
const u = at(ELSEWHERE, { servers: servers.slice(0, 1) });
u.click('tab-local');
u.rows('local-found')[0].button!.click();
expect(u.sentOf('connect')).toEqual([{ cmd: 'connect', url: 'http://127.0.0.1:4000/', remember: true }]);
});
it('a server that asks for a login gets the login fields, as any connect does', () => {
const t = at(ELSEWHERE);
t.rows('local-found')[1].button!.click();
t.native({
type: 'probeResult',
url: 'http://192.168.1.5:5000/cm/',
outcome: { kind: 'needsLogin' },
canStart: false,
});
expect(t.$('tab-remote').getAttribute('aria-selected')).toBe('true');
expect(t.visible('login')).toBe(true);
expect(t.input('url').value).toBe('http://192.168.1.5:5000/cm/');
});
it('says why instead of offering Connect for a server the app cannot open', () => {
const t = at({
...ELSEWHERE,
found: [
found('https://127.0.0.1:4000/', 'certificate'),
found('https://192.168.1.5:4100/', 'certificate'),
found('http://[::1]:5000/', 'unreachable'),
found('http://127.0.0.1:6000/'),
],
});
const rows = t.rows('local-found');
expect(rows.map((r) => !!r.button)).toEqual([false, false, false, true]);
// The wording of a stopped Codeman that is set up with HTTPS.
const https =
'It is set up with HTTPS (codeman web --https), whose self-signed certificate this app refuses. Run it without --https to open it here.';
expect(rows.slice(0, 2).map((r) => r.note)).toEqual([https, https]);
expect(rows[2].note).toBe('It does not answer at this address.');
const stopped = at({ ...NOT_INSTALLED, state: 'stopped', installed: true, url: 'https://127.0.0.1:3000/' });
expect(stopped.$('local-detail').textContent).toBe(https);
// The first one it can open is still the main action.
expect(rows[3].button?.className).toBe('primary');
expect(t.$('local-dot').className).toBe('dot ok');
});
it('with no server it can open, offers no Connect and keeps Check again', () => {
const t = at({ ...ELSEWHERE, found: [found('https://127.0.0.1:4000/', 'certificate')] });
expect(t.$('pane-local').querySelectorAll('.found button')).toHaveLength(0);
expect(t.$('local-dot').className).toBe('dot warn');
expect(t.visible('local-found-note')).toBe(true);
expect(t.visible('local-check')).toBe(true);
t.click('local-check');
expect(t.sentOf('localStatus').at(-1)).toEqual({ cmd: 'localStatus', quick: false, deep: true });
});
it('a server process with no address to try still says what to do', () => {
const t = at({ ...ELSEWHERE, found: [] });
expect(t.$('local-title').textContent).toBe('Codeman is already running on this Mac.');
expect(t.$('local-detail').textContent).toContain('could not find the address it listens at');
expect(t.$('local-detail').textContent).toContain('take over its sessions');
expect(t.visible('local-found')).toBe(false);
expect(t.visible('local-found-note')).toBe(false);
// A status from before the list existed reads the same.
const old = at({ ...ELSEWHERE, found: undefined });
expect(old.$('local-detail').textContent).toContain('could not find the address it listens at');
});
it('keeps Open for the Codeman that answers, and lists the others under it', () => {
const t = at({
...RUNNING,
found: [
...RUNNING.found,
found('http://127.0.0.1:5000/', 'running', '1.50.0-beta'),
found('https://127.0.0.1:6000/', 'certificate'),
],
});
expect(t.$('local-title').textContent).toBe('Codeman is running on this Mac.');
expect(t.$('local-detail').textContent).toBe('http://127.0.0.1:3000/ · version 1.49.0');
expect(t.visible('local-open')).toBe(true);
expect(t.visible('local-also')).toBe(true);
expect(t.$('local-also-head').textContent).toBe('Also running on this Mac');
const rows = t.rows('local-also-list');
// Not the one the first line is about.
expect(rows.map((r) => [r.address, r.note.slice(0, 19)])).toEqual([
['http://127.0.0.1:5000/', 'version 1.50.0-beta'],
['https://127.0.0.1:6000/', 'It is set up with H'],
]);
// Open stays the main action: a row's Connect is never the primary one here.
expect(rows.map((r) => r.button?.className)).toEqual(['secondary', undefined]);
expect(t.visible('local-found')).toBe(false);
expect(t.visible('local-found-note')).toBe(false);
rows[0].button!.click();
expect(t.sentOf('connect')).toEqual([{ cmd: 'connect', url: 'http://127.0.0.1:5000/', remember: true }]);
});
it('lists them beside a Codeman that asks for a login, and beside something else on its port', () => {
const other = found('http://127.0.0.1:5000/');
const login = at({ ...RUNNING, state: 'needsLogin', outcome: { kind: 'needsLogin' }, found: [other] });
expect(login.visible('local-open')).toBe(true);
expect(login.rows('local-also-list').map((r) => r.address)).toEqual(['http://127.0.0.1:5000/']);
const taken = { ...RUNNING, state: 'other', version: null, outcome: { kind: 'notCodeman', status: 404 } };
const t = at({ ...taken, found: [other] });
expect(t.$('local-title').textContent).toBe('Something else answers at http://127.0.0.1:3000/');
expect(t.$('local-detail').textContent).toContain('or connect to the Codeman listed below.');
expect(t.rows('local-also-list').map((r) => r.button?.textContent)).toEqual(['Connect']);
// With nothing to connect to, the old pointer stays.
const none = at({ ...taken, found: [] });
expect(none.$('local-detail').textContent).toContain('under Another machine');
expect(none.visible('local-also')).toBe(false);
});
it('shows no list when this Mac runs only the Codeman the tab is about', () => {
const t = at(RUNNING);
expect(t.visible('local-also')).toBe(false);
expect(t.$('pane-local').querySelectorAll('.found')).toHaveLength(0);
// The rows of an earlier look go when the next one finds none.
t.native({ type: 'localStatus', status: ELSEWHERE });
expect(t.rows('local-found')).toHaveLength(2);
t.native({ type: 'localStatus', status: { ...NOT_INSTALLED, state: 'stopped', installed: true } });
expect(t.$('pane-local').querySelectorAll('.found')).toHaveLength(0);
expect(t.visible('local-found')).toBe(false);
expect(t.visible('local-found-note')).toBe(false);
});
it('shows what the app found as text, never as markup', () => {
const hostile = '"><img src=x onerror=alert(1)><script>alert(2)</script>';
const t = at({
...ELSEWHERE,
found: [{ url: hostile, version: '<b onclick=alert(3)>9</b>', state: 'running', isDefault: false }],
});
const rows = t.rows('local-found');
expect(t.$('pane-local').querySelector('img, script, small b')).toBeNull();
expect(rows[0].address).toBe(hostile);
expect(rows[0].note).toBe('version <b onclick=alert(3)>9</b>');
expect(rows[0].button?.title).toBe('Connect to ' + hostile);
// The app gets it as it is, and refuses what is not an address (policy::normalize_server_url).
rows[0].button!.click();
expect(t.sentOf('connect')).toEqual([{ cmd: 'connect', url: hostile, remember: true }]);
});
it('holds a found address to the length the app accepts (MAX_URL_LEN in policy.rs)', () => {
const long = 'http://127.0.0.1:4000/' + 'a'.repeat(4096);
const t = at({ ...ELSEWHERE, found: [found(long)] });
t.rows('local-found')[0].button!.click();
expect(t.sentOf('connect')).toHaveLength(0);
expect(t.$('status-text').textContent).toContain('too long');
});
it('looks again when the server it connected to refuses, and opens nothing by itself', () => {
const t = at(ELSEWHERE);
const looks = t.sentOf('localStatus').length;
t.rows('local-found')[0].button!.click();
t.native({ type: 'probeResult', url: 'http://127.0.0.1:4000/', outcome: { kind: 'refused' }, canStart: true });
expect(t.$('tab-local').getAttribute('aria-selected')).toBe('true');
expect(t.$('status-text').textContent).toContain('Nothing is answering at this address.');
expect(t.sentOf('localStatus')).toHaveLength(looks + 1);
// A server that then turns out to be starting is shown, not opened: the
// one that comes up may not be the one this row was about.
t.native({ type: 'localStatus', status: { ...ELSEWHERE, state: 'starting', found: [] } });
t.native({ type: 'localStatus', status: RUNNING });
expect(t.sentOf('connect')).toHaveLength(1);
expect(t.visible('local-open')).toBe(true);
});
it('still opens the Codeman of the first line once it is up, after Open was refused', () => {
const t = at(RUNNING);
t.click('local-open');
t.native({ type: 'probeResult', url: RUNNING.url, outcome: { kind: 'refused' }, canStart: true });
t.native({ type: 'localStatus', status: { ...RUNNING, state: 'starting', found: [] } });
t.native({ type: 'localStatus', status: RUNNING });
expect(t.sentOf('connect').map((m) => m.url)).toEqual([RUNNING.url, RUNNING.url]);
});
});
File diff suppressed because it is too large. Load diff
+35
View File
@@ -17,6 +17,7 @@ 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));
@@ -583,6 +584,40 @@ describe('install.sh agent, one AI CLI and nothing else', () => {
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);