Compare commits

..
Author SHA1 Message Date
Codeman maintainer d25cc08606 feat(local-model): Ornith 1.0 9B, the first model a 16 GB Mac can hold
A 16 GB Mac got no model: the two 9B builds of the catalogue need 10.2 GB at
65,536 tokens, 0.987 of the estimated budget, just past the 0.98 a tight fit
may reach.

New row `ornith:9b-q4_k_m` (Ornith 1.0 9B, MIT), a fine-tune of Qwen3.5-9B for
coding agents whose GGUF build has no vision projector: 5.63 GB against 6.55 GB,
so 9.3 GB at 65,536 tokens, 0.898 of that budget. SWE-bench Verified 69.4,
SWE-bench Pro 42.9, Terminal-Bench 2.1 43.1 on Ornith's card. Its config.json
and its GGUF header are those of Qwen3.5-9B (no prediction layer), so it shares
the figures of the two other 9B rows. Never run on a Mac: `estimated`.

- What changes for a Mac: 16 GB goes from no model to `limited` with this row,
  tight. That is the only pick that changes: the row is ranked under MiMo V2.6
  9B, so every Mac that could already run a 9B keeps its pick and gains an
  alternative. With the budget a 16 GB Mac mini measured (10.7 GB) the pick is
  MiMo as before.
- Why under MiMo although its own card's numbers are higher: the two vendors'
  tables put their common base 7 points apart, and runtime issue 16992 (open)
  was filed on the 35B sibling, which has the same renderer and parser. The
  35B stays set aside.
- The 16 GB rule itself is untouched: same budget estimate, same 0.98, same
  eight context checkpoints.
- Ranks renumbered 1 to 10; the pins of test/local-model-recommend.test.ts and
  the table of docs/local-models.md follow.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 23:36:45 +01:00
Codeman maintainer 9608a17d99 feat(local-model): list every model that fits, and add Muse Glimmer 30B
The "Run a local model" chooser showed the pick and four more models. It now
lists every catalogue model that fits the Mac, and the install accepts every
listed row.

- MAX_ALTERNATIVES goes from 4 to 11, above what the catalogue holds. It stays
  a bound: test/local-model-catalog.test.ts fails for a catalogue that outgrows
  it, so nothing drops off the list unseen.
- New row `muse-glimmer:30b-q4_k_m` (Meta, Apache-2.0): SWE-bench Verified 76.0,
  SWE-bench Pro 51.2, Terminal-Bench 2.1 51.7 on Meta's card. Its K/V cache is
  the smallest of the 27B class, so it is the first model of that class a 32 GB
  Mac holds: 21.2 GB at 65,536 tokens, 0.970 of the estimated budget. Figures
  computed from its config.json and its GGUF header by the llamacpp rule (13
  full-attention layers, 39 sliding-window layers of 2,048 tokens, no
  prediction layer); the sliding-window cache rule was read again in llama.cpp
  b12111 (src/llama-kv-cache-iswa.cpp). Never run on a Mac: `estimated`.
- What changes for a Mac: 32 GB is no longer `limited`. On an M1 Max or M2 Max
  the pick is Muse Glimmer; on a base or Pro chip the dense model is slow and
  the pick stays MiMo V2.6 9B, now with the small-pick note. Every other pick
  is unchanged.
- The "set aside" list was read again at its sources on 2026-11-10 and gained
  ripple-preview:65b (every build requires runtime 0.40.16, one above the pin),
  Ornith 1.0 (runtime issue 16992), nemotron-3.5-super:120b, qwen3.5:4b,
  qwen3-coder-next and the MLX and DFlash tags of Muse Glimmer.
- The header and the guide now say that runtime issue 16992 has one diagnosed
  cause in the tool parser every Qwen-family row shares (a tool argument that
  quotes a whole tool call is cut off; the fix is runtime PR 17021, open).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 23:33:59 +01:00
Codeman maintainer f62c1f7e2f fix(local-model): a view read while the job ends no longer shows paused
The install window polls GET /api/local-model every second. A read of the
state file that was still out while the job wrote its last state and let
go of this.job came back with the old `installing`, and settling that with
no job in the process read it as an interrupted job: `paused` with Resume
for one tick before `failed`. Under CI load this made
test/local-model-install-e2e.test.ts fail on a different failure case per
run (master 86502b69, PR #790).

currentState() now reads again when the job ended during the read. The job
writes before it lets go, so the second read has the final state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 02:51:07 +01:00
Ark0N 86502b6904 Merge pull request #789 from Ark0N/chore/desktop-cleanup
refactor(desktop): clean up the Mac app's code, no behavior change
2026-11-09 02:27:43 +01:00
Codeman maintainer 9a1bed449d docs(desktop): the probe's waits end at an answer, not quickly
An install wait can last 30 minutes, so "short" was the wrong word.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 00:05:53 +01:00
Codeman maintainer 43a86eee39 docs(desktop): the app's size as it builds now
5.3 MB for the universal app and 3.5 MB for the DMG, measured on the Mac
mini from this branch; the README still had the first build's 4.6 and 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 00:03:56 +01:00
Codeman maintainer 404930d55e refactor(desktop): policy, logo and the connect page, simpler and the same
No behavior change: the connect page renders pixel-identical in eight
states (Chromium, before and after), posts the same messages, and passes
its jsdom and browser tests.

- policy.rs: the connect commands' camelCase fields come from serde's
  rename_all_fields instead of three per-field renames; parse_ipc's
  length and id checks are match guards like its other arms, with the
  message cap named (MAX_MESSAGE_LEN); new_window reads its URL through
  external_url, which is the same parse-and-http(s) check.
- logo.rs: the stubs for other platforms are one module, and Logo,
  Marker and recover are private to the file.
- connect.html: answers() is the one test for a server that answers
  (six copies), the running and needs-login rows of This Mac share their
  branch, prepareWizard, needsAgent, newWait and openTerminal each
  replace two or three copies, a finished install and a finished agent
  share their path, host() is displayAddress(), state.clock is in the
  state literal, three CSS rules that matched nothing are gone, and the
  state comments say what wait and gui hold now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 00:02:54 +01:00
Codeman maintainer 4bde072355 refactor(desktop): setup, install and probe code: shared helpers, honest docs
No behavior change.

- ProbeOutcome::version replaces two copies of the same match in setup.rs,
  and classify_response reads the status JSON with serde_json's indexing
  (same answer for every body, a missing key is Null).
- local_server::is_env_name is the one test for a variable name, used by
  the login shell's environment and by ~/.codeman/.env.
- setup.rs has basename() for its two command-line scans; the install
  scan no longer formats the script prefix once per word.
- agents.rs: refused() names the statuses that end the polling, and
  answer() takes the body it already has instead of a closure.
- Small simplifications in gui_install.rs (Run::matches, follow, the
  download error text) and a let-else in local_server::launch.
- Docs that no longer matched the code: probe.rs said the probe never runs
  in a loop, but the This Mac tab re-asks while it waits for an install
  (every 3 s) or for a starting server (every second); local_server.rs
  covered only Start; FIND_TIMEOUT and exec named one caller of several.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 23:59:30 +01:00
Codeman maintainer ad5455044a refactor(desktop): main.rs says each repeated step once
No behavior change. What was written out two to four times now has one
name:

- AppWindow::new and AppWindow::hidden_for_download, window_builder,
  placed_position (the corner tao places, which the frame save and the
  pop-out cascade both read; cfg! instead of two attribute pairs),
  file_name_of and UNNAMED_FILE for the download toast.
- page_sender, which in_background, attach_to_run and both install
  starts use; plan_message and remote_install_failed for the connect
  page's answers; open_external_raw for the two OpenExternal commands;
  choosing() for the four phase checks.
- An accepted probe (Ok, or a password to change) runs one set of steps.
- macos::install runs inside build_webview, so no web view can be built
  without the dialog and login handlers.
- ProbeDone no longer carries the Authorization header beside the
  username and password it is made from; basic_auth makes it.
- Load::ConnectPage loses its manual flag, which was never true there.
- MIN_SIZE reads config's limits, the default username is
  setup::DEFAULT_USERNAME, and the repository and wiki addresses live
  in menu.rs.
- The module doc names the modules as they are now, and two doc comments
  that had slid onto the wrong item are back on theirs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 23:57:28 +01:00
Codeman maintainer db6fb53f5e refactor(desktop): move code out of main.rs and setup.rs into modules
Pure moves, no function body changed (git diff --color-moved shows it):

- terminal_install.rs: the Terminal and SSH installs (the scripts, the
  ssh arguments, open_in_terminal, copy_to_clipboard) and their tests,
  out of setup.rs, which keeps one subject: the Codeman on this Mac.
- login.rs: choose_login, update_keychain and their types and tests,
  out of main.rs.
- menu.rs: the menu bar, its ids and AppMenu, out of main.rs.
- policy.rs: loaded_on_commit and its test, a pure Phase decision like
  the rest of policy.

What changed besides the moves: module paths, pub on the moved items,
imports, the module docs, and the plutil test's temporary folder name,
which no longer draws on the Terminal scripts' counter. main.rs goes from
2312 to 1881 lines and setup.rs from 2160 to 1625.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 23:55:00 +01:00
Codeman maintainer f3087a12c0 docs(desktop): README tidy-up
The feature table no longer says v0.1, a few paragraphs that had grown
past the wrap width are rewrapped, and the Gatekeeper limit now starts
with the signed and notarized release DMG, so the ad hoc steps read as
what they are: for a build of your own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 23:55:00 +01:00
Codeman maintainer 0c3a70676e chore(desktop): one copy of base64 and dirs
ureq and wry already pull in base64 0.23 and dirs 7, so the app's own
base64 0.22 and dirs 6 were compiled beside them. The app now asks for the
versions already in the tree. base64 is declared as ureq declares it,
without the default SIMD engines, so the feature set compiled is the one
ureq had before. dirs 7 resolves config_dir and home_dir on macOS exactly
as 6 did (both go through dirs-sys 0.5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 23:55:00 +01:00
Codeman maintainer ccb76c3325 docs(readme): drop the Awesome AI Agents badge, smaller Mac download button
The "Featured in Awesome AI Agents" badge pointed at a list with far fewer
stars than this repository, so it added nothing. The Download for Mac button
under the tagline is a fifth smaller in both READMEs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:24:21 +01:00
Codeman maintainer 01b4150672 docs(readme): a Download for Mac button at the top of both READMEs
The only DMG link above the fold was a plain link in the sentence under the
install command, easy to read past. Both READMEs now open with a large
"Download for Mac" button right under the tagline, pointing at the latest
release's Codeman.dmg, with one line on what the app is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:08:17 +01:00
30 changed files with 1907 additions and 1550 deletions

No files matched your search

+1 -1
View File
@@ -192,7 +192,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. The floor also divides that context by the model's parallel slot count (`modelSlotCounts`: `--parallel`/`-np` from `cmd`, else `/props` `total_slots`; `perSlotContext()`), and `/props` only ever RAISES that warning: it supplies the context only when `cmd` states none and the count only when `cmd` has no `--parallel`, never a larger pool. `/props` is probed once per launch command (it refreshes llama-swap's last-use time, so a `ttl` would never expire), and a `/props` context beside an explicit `--parallel N` is already per slot, so it is not divided again. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
**Local model recommendation** (`GET /api/local-model`, `src/local-model/`, `src/config/local-model-catalog.ts`, `src/types/local-model.ts`; EXPERIMENTAL): the server looks at the Mac it runs on and says which local coding model fits it and with which agent CLI. ⚠️ The READ side stays read-only: the probe, the catalogue and `recommend()` download nothing, install nothing and write nothing; everything that changes the machine is the install below, behind an explicit request. Apple silicon macOS only: anywhere else the answer is a `200` with verdict `none` (reasons `not-macos`, `intel`, `memory`, `disk`, `macos`, `no-harness`), and a failed probe is an answer, never a 500. The probe (`hardware.ts`) is ONE bounded async spawn of `/usr/sbin/sysctl` WITHOUT `-n`, parsed as `key: value` lines by the pure `parseSysctl()`, plus `statfs` of the data dir (probed bounded first, #516) and `node:os` fallbacks, cached 60 s; ⚠️ never `system_profiler`, never a second spawn, and `iogpu.wired_limit_mb` of 0 means not set. The catalogue is a compiled constant validated by a Zod schema: ⚠️ no override file and no runtime data, and a model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`, so an id can never name another registry host). ⚠️ A harness is a catalogue ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`) plus a `HarnessFact` built from the CLI registry at call time (`harness-facts.ts`); no code compares a CLI id (both id-branching guards scan these files). `recommend()` (`recommend.ts`) is pure. ⚠️ A model's need is its PEAK (`bytes + heldBytesPerToken * context + overheadBytes + peakExtraBytes`, measured on a Mac for one row and marked `estimated` on the others), and every row names its `runner` (`mlx` or `llamacpp`, the build a Mac really pulls) and carries figures of its OWN: computed from its model's config.json by that runner's rule (catalogue header, MEMORY), never borrowed from another row; `test/local-model-catalog.test.ts` recomputes them, and the `llamacpp` rows assume the runtime's `OLLAMA_KV_CACHE_TYPE=q8_0`, `LLAMA_ARG_CACHE_RAM=1024` and `LLAMA_ARG_CTX_CHECKPOINTS=8` (`runtimeEnv()`) and count what llama-server keeps beside the cache (8 context checkpoints of the recurrent or sliding-window state, and for a GGUF with a prediction layer one rollback copy of the recurrent state per drafted token and that layer's cache in f16), so change the two together. The need is judged against a budget that is `estimated` until the runtime ran and `measured` from then on (`RecommendOptions.measuredTotalBytes`). ⚠️ Ranking, lexicographic: tested on a Mac like this first, then speed `ok` before `unknown` before `slow`, then model rank, harness rank, larger context; the fit grade decides whether a row is listed and what it says, it does NOT sort, so a model is offered at the largest context it fits at. ⚠️ `limited` is a statement about the MAC (no model with 12 billion parameters or more fits), judged before the disk filter and whatever the pick is; a small pick under `ok` carries `noteSmallPick`, and a model left out for disk space is named in `noteDiskDropped`, never dropped in silence. ⚠️ The word "Recommended" exists only in `headlineTested`, and the tag "Tested on a Mac like this" only on a row whose model has a `tested` entry for exactly this chip, memory size and harness. ⚠️ Speed is `ok`/`slow`/`unknown`, graded on the unrounded figure, and a chip in no chips row is `unknown`: never guess a bandwidth. ⚠️ ONE unit for every computed size a person reads: decimal GB text from `formatGb()` (`format.ts`), sent beside the bytes, and the browser never divides bytes; the one exception is the installed memory, shown as `memClassGb` (the size the Mac is sold as, 48 and not 51.5). ⚠️ Every sentence the server chooses is a `LOCAL_MODEL_LABELS`, `LOCAL_MODEL_TAG_LABELS` or `LOCAL_MODEL_INSTALL_LABELS` text with `{placeholder}` slots filled from `vars` (no apostrophe in a text: the zh-CN key is single-quoted), and each needs a zh-CN entry. Admin only in multi-user mode (403 before anything is probed). Tests: `test/local-model-*.test.ts`, `test/routes/local-model-routes.test.ts`. Window rules: `src/web/CLAUDE.md` (Run a model on this Mac). → `docs/api-reference.md` (Local model), `docs/versioning-policy.md`
**Local model recommendation** (`GET /api/local-model`, `src/local-model/`, `src/config/local-model-catalog.ts`, `src/types/local-model.ts`; EXPERIMENTAL): the server looks at the Mac it runs on and says which local coding model fits it and with which agent CLI. ⚠️ The READ side stays read-only: the probe, the catalogue and `recommend()` download nothing, install nothing and write nothing; everything that changes the machine is the install below, behind an explicit request. Apple silicon macOS only: anywhere else the answer is a `200` with verdict `none` (reasons `not-macos`, `intel`, `memory`, `disk`, `macos`, `no-harness`), and a failed probe is an answer, never a 500. The probe (`hardware.ts`) is ONE bounded async spawn of `/usr/sbin/sysctl` WITHOUT `-n`, parsed as `key: value` lines by the pure `parseSysctl()`, plus `statfs` of the data dir (probed bounded first, #516) and `node:os` fallbacks, cached 60 s; ⚠️ never `system_profiler`, never a second spawn, and `iogpu.wired_limit_mb` of 0 means not set. The catalogue is a compiled constant validated by a Zod schema: ⚠️ no override file and no runtime data, and a model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`, so an id can never name another registry host). ⚠️ A harness is a catalogue ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`) plus a `HarnessFact` built from the CLI registry at call time (`harness-facts.ts`); no code compares a CLI id (both id-branching guards scan these files). `recommend()` (`recommend.ts`) is pure. ⚠️ A model's need is its PEAK (`bytes + heldBytesPerToken * context + overheadBytes + peakExtraBytes`, measured on a Mac for one row and marked `estimated` on the others), and every row names its `runner` (`mlx` or `llamacpp`, the build a Mac really pulls) and carries figures of its OWN: computed from its model's config.json by that runner's rule (catalogue header, MEMORY), never borrowed from another row; `test/local-model-catalog.test.ts` recomputes them, and the `llamacpp` rows assume the runtime's `OLLAMA_KV_CACHE_TYPE=q8_0`, `LLAMA_ARG_CACHE_RAM=1024` and `LLAMA_ARG_CTX_CHECKPOINTS=8` (`runtimeEnv()`) and count what llama-server keeps beside the cache (8 context checkpoints of the recurrent or sliding-window state, and for a GGUF with a prediction layer one rollback copy of the recurrent state per drafted token and that layer's cache in f16), so change the two together. The need is judged against a budget that is `estimated` until the runtime ran and `measured` from then on (`RecommendOptions.measuredTotalBytes`). ⚠️ Ranking, lexicographic: tested on a Mac like this first, then speed `ok` before `unknown` before `slow`, then model rank, harness rank, larger context; the fit grade decides whether a row is listed and what it says, it does NOT sort, so a model is offered at the largest context it fits at. ⚠️ The answer lists EVERY model that fits, and the install accepts only a listed row: `MAX_ALTERNATIVES` is a bound above the catalogue's size (`test/local-model-catalog.test.ts` fails for a catalogue that outgrows it), never a way to shorten the list. ⚠️ `limited` is a statement about the MAC (no model with 12 billion parameters or more fits), judged before the disk filter and whatever the pick is; a small pick under `ok` carries `noteSmallPick`, and a model left out for disk space is named in `noteDiskDropped`, never dropped in silence. ⚠️ The word "Recommended" exists only in `headlineTested`, and the tag "Tested on a Mac like this" only on a row whose model has a `tested` entry for exactly this chip, memory size and harness. ⚠️ Speed is `ok`/`slow`/`unknown`, graded on the unrounded figure, and a chip in no chips row is `unknown`: never guess a bandwidth. ⚠️ ONE unit for every computed size a person reads: decimal GB text from `formatGb()` (`format.ts`), sent beside the bytes, and the browser never divides bytes; the one exception is the installed memory, shown as `memClassGb` (the size the Mac is sold as, 48 and not 51.5). ⚠️ Every sentence the server chooses is a `LOCAL_MODEL_LABELS`, `LOCAL_MODEL_TAG_LABELS` or `LOCAL_MODEL_INSTALL_LABELS` text with `{placeholder}` slots filled from `vars` (no apostrophe in a text: the zh-CN key is single-quoted), and each needs a zh-CN entry. Admin only in multi-user mode (403 before anything is probed). Tests: `test/local-model-*.test.ts`, `test/routes/local-model-routes.test.ts`. Window rules: `src/web/CLAUDE.md` (Run a model on this Mac). → `docs/api-reference.md` (Local model), `docs/versioning-policy.md`
**Local model install** (`POST /api/local-model/install|cancel|resume|runtime`, `DELETE /api/local-model`; `src/local-model/{install-job,runtime,ollama-client,connect,state-store,paths,developer-tools}.ts`; EXPERIMENTAL, admin only in multi-user mode on EVERY verb; guide `docs/local-models.md`): installs the chosen model on the Mac the server runs on and ends on a session of the coding agent running on it. ONE job of five idempotent steps (`runtime`, `model`, `verify`, `agent`, `connect`; `agent` only when the coding agent is missing), a pure reducer (`reduceInstallJob()`) over three tables in `install-job.ts` driven by one service per process (`getLocalModelService()`); the window polls the GET once a second, no SSE event. ⚠️ Layout only through `localModelPaths()` (all `dataPath()`): `local-model/runtime/ollama-<version>/`, `local-model/models/`, `local-model/home/` (the runtime's PRIVATE HOME, so the user's `~/.ollama` is never touched) and the state file `local-model.json` (0600, ONE serialized writer, total reader, holds NO path); nothing is written or deleted outside `local-model/` except that file and the two writes of the connect step. ⚠️ The runtime pin (`OLLAMA_RUNTIME_PIN`: version, URL, size, sha256) is a COMPILED constant no file, setting or request may supply; the hash is compared BEFORE anything is unpacked. ⚠️ The runtime is a NON-detached child whose environment comes from the `runtimeEnv()` ALLOWLIST, never `process.env` (no `CODEMAN_*`, none of the user's `OLLAMA_*`); the port is chosen ONCE by binding (from 11435, never 11434) and stored; a listener on it is adopted or replaced only when its stored pid runs a binary under OUR runtime folder, anything else is `port-in-use`; started at boot when installed and enabled, max 3 restarts, SIGTERM at shutdown beside `stopDeepSeekWeb()`; under vitest nothing spawns or fetches. ⚠️ Consent is bound to the size: `acknowledgedBytes` must equal the server's own total (`Candidate.installBytes`) or the answer is 409 `size-changed`; the model, context and CLI must be a row the current recommendation offers (400). ⚠️ ONE job at a time (409 `job-running`); a job a restart interrupted comes back `paused` and NEVER continues without a click (so `shutdown()` writes nothing), and an interrupted step is never recorded as failed. ⚠️ An event the transition table does not list is refused (same object back, 409 `wrong-state`): add a row to the table, never a special case. ⚠️ The budget is MEASURED from the runtime's "inference compute" log line and the model judged against it BEFORE it is downloaded. ⚠️ The pull stream is parsed incrementally (about 1.4 million lines for 23 GB): never buffer it, never write state per line. ⚠️ A failure is a code with ONE next step (`LOCAL_MODEL_ERROR_NEXT`), never a bare "failed"; a probe that ended on its token limit is `probe-unfinished`, not `no-tool-call`. ⚠️ The coding agent is installed through the shared runner `src/cli-install-runner.ts` (extracted from `cli-registry-routes.ts`, ONE install slot per CLI for both callers) with `stockInstallCommand()`, the COMPILED stock command, never the `clis.json`-merged one (this path has no `cliManagementEnabled` gate); only for a catalogue harness row, after the consent click that showed the literal command, audited (`cli_install`, `via: 'local-model'`); afterwards `forgetResolvedCli()` and a fresh lookup, and still missing is `harness-failed`. ⚠️ Connect writes ONE endpoint record under the RESERVED id `local-ollama` through `mutateCustomModelHosts()` and `customModelEndpointsEnabled: true` through `mutateSettingsFile()`, remembering the flag's first value; a record with that id that is not ours is never overwritten, and while it is ours it is MANAGED: the endpoint routes refuse to edit or delete it and the discovery sweep skips it (the runtime's port has no login, so its `models` must stay the one model that was checked). ⚠️ The model digest is stored and read again at boot and before every START of a coding agent on that endpoint (`launchRefusalMessage()`): the two custom-model launch routes in `session-routes.ts` ask it before anything is written, the reboot-restore rebuild asks it before it builds (reason `local-model-not-ready`, left on offer), and `Session`'s launch guard (`setLaunchGuard()`, set in `setupSessionListeners()`) asks it right before a NEW agent process starts (a fresh pane, the respawn of a dead one, `restartCli()`), never for an attach to an agent that runs; a refusal is a `LaunchRefusedError` that never falls back to a direct PTY. A changed or missing model, a runtime Codeman did not start or that did not start, or another model id refuses the launch with one sentence. ⚠️ The connection is CLAIMED in the state file (`connection`, with the flag's value read by `inspectLocalEndpoint()`) BEFORE the endpoint record and the settings flag are written, so a state write that fails or a kill between the two never leaveLine truncated
+6 -1
View File
@@ -8,6 +8,12 @@
<em>Claude Code &bull; OpenCode &bull; Codex &bull; Antigravity &bull; Gemini &bull; Pi &bull; Grok &bull; DeepSeek &bull; OMP &bull; Copilot &bull; Terminal - One Dashboard &bull; Any Device</em>
</p>
<p align="center">
<a href="https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg"><img src="https://img.shields.io/badge/Download_for_Mac-Codeman.dmg-2f6fe0?style=for-the-badge&logo=apple&logoColor=white&labelColor=000000" alt="Download Codeman for Mac (Codeman.dmg)" width="354"></a>
<br>
<sub>The native Mac app: signed and notarized, Apple Silicon and Intel, macOS 11.3 or later. <a href="#on-a-mac-the-codeman-app">What it does</a></sub>
</p>
<p align="center">
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-1e3a5f?style=flat-square" alt="License: MIT"></a>
<a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-22%2B-22c55e?style=flat-square&logo=node.js&logoColor=white" alt="Node.js 22+"></a>
@@ -17,7 +23,6 @@
<a href="https://github.com/Ark0N/Codeman/stargazers"><img src="https://img.shields.io/github/stars/Ark0N/Codeman?style=flat-square&color=eab308" alt="GitHub stars"></a>
<a href="https://github.com/Ark0N/Codeman/graphs/contributors"><img src="https://img.shields.io/github/contributors/Ark0N/Codeman?style=flat-square&color=3b82f6" alt="Contributors"></a>
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
<a href="https://github.com/NipunaRanasinghe/awesome-ai-agents"><img src="https://img.shields.io/badge/Featured_in-Awesome_AI_Agents-2ea44f?style=flat-square" alt="Featured in Awesome AI Agents"></a>
</p>
<p align="center">
+6 -1
View File
@@ -8,6 +8,12 @@
<em>Claude Code &bull; OpenCode &bull; Codex &bull; Antigravity &bull; Gemini &bull; Pi &bull; Grok &bull; DeepSeek &bull; OMP &bull; Copilot &bull; 终端 —— 统一仪表盘 &bull; 任意设备</em>
</p>
<p align="center">
<a href="https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg"><img src="https://img.shields.io/badge/%E4%B8%8B%E8%BD%BD_Mac_%E7%89%88-Codeman.dmg-2f6fe0?style=for-the-badge&logo=apple&logoColor=white&labelColor=000000" alt="下载 Mac 版 Codeman(Codeman.dmg)" width="281"></a>
<br>
<sub>原生 Mac 应用:已签名并经过 Apple 公证,支持 Apple 芯片和 Intel,macOS 11.3 或更高版本。<a href="#在-mac-上codeman-应用">它能做什么</a></sub>
</p>
<p align="center">
<a href="README.md">English</a> &bull; <strong>简体中文</strong>
</p>
@@ -21,7 +27,6 @@
<a href="https://github.com/Ark0N/Codeman/stargazers"><img src="https://img.shields.io/github/stars/Ark0N/Codeman?style=flat-square&color=eab308" alt="GitHub stars"></a>
<a href="https://github.com/Ark0N/Codeman/graphs/contributors"><img src="https://img.shields.io/github/contributors/Ark0N/Codeman?style=flat-square&color=3b82f6" alt="Contributors"></a>
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
<a href="https://github.com/NipunaRanasinghe/awesome-ai-agents"><img src="https://img.shields.io/badge/Featured_in-Awesome_AI_Agents-2ea44f?style=flat-square" alt="Featured in Awesome AI Agents"></a>
</p>
<p align="center">
+1 -1
View File
@@ -1766,7 +1766,7 @@ A probe that fails is an answer, not an error: a server that is not on macOS, or
- `verdict`: `ok`, `limited` or `none`. `limited` is a statement about the Mac: no model with 12 billion parameters or more fits its memory, so what it can run handles small edits and questions and does not replace a cloud model. It is judged on every model that fits, before the free disk space is looked at and whatever the pick is. With `ok` the pick can still be a small model (a larger one may be slow, have no speed figure or have no room on the disk); `notes` then says so.
- `reason`, present only with `none`: `not-macos`, `intel`, `memory`, `disk`, `macos` (too old) or `no-harness` (no agent CLI that can be connected to a local model is installed or installable). `memory` has three sentences: nothing fits at all (slot `memory`, the size the Mac is sold as), a model fits but only below the context length an agent CLI needs (slots `budget` and `need`, so a Mac that misses by a little reads as that), and the memory size could not be read (no slot).
- `headline` and `notes[]`: `{ text, vars? }`. `text` is an English sentence that may hold `{placeholder}` slots, and `vars` carries the slot values as text. Notes, in order: the pick is a small model although larger ones fit (verdict `ok` only); a model that fits the memory was left out because its download would not leave 10 GiB free on the disk (slots `model` and `missing`: the best-ranked such model and the GB to free up); nobody tested the pick on a Mac like this one.
- `pick` (absent with `none`) and `alternatives[]` (at most four, best first; empty with `none`), each one model at one context length with one agent CLI. The order: a pair tested on a Mac like this one first, then speed (`ok` before `unknown` before `slow`), then the model's rank, the agent CLI's rank and the larger context. The memory fit decides whether a row is listed and what it says (`fit`, `tag`), it does not sort, so a model is offered at the largest context it fits at. A model whose download would leave less than 10 GiB free on the disk is not listed (an unknown free disk space excludes nothing):
- `pick` (absent with `none`) and `alternatives[]` (every other model of the catalogue that fits, best first; empty with `none`), each one model at one context length with one agent CLI. An install is accepted only for a row of this list. The order: a pair tested on a Mac like this one first, then speed (`ok` before `unknown` before `slow`), then the model's rank, the agent CLI's rank and the larger context. The memory fit decides whether a row is listed and what it says (`fit`, `tag`), it does not sort, so a model is offered at the largest context it fits at. A model whose download would leave less than 10 GiB free on the disk is not listed (an unknown free disk space excludes nothing):
- `modelId` (`name:tag`), `modelLabel`, `license`, `paramsB` (parameters in billions), `context` (tokens);
- `cli`, `cliLabel`, `cliInstalled` (`false`: the CLI is not on this machine yet but can be installed without administrator rights), `slowFirstTurn?`;
- `needBytes` and `needGb` (memory the model needs at that context, at its PEAK: the weights, what stays held per token of context, what the runner holds beside both, and what it takes for a moment while a long prompt is processed), `needSource` (`measured`: the figures were measured for this model on a Mac; `estimated`: they are another model's), `downloadBytes` and `downloadGb`, `budgetShare` (need divided by `budgetBytes`, rounded to three decimals);
+35 -22
View File
@@ -17,28 +17,29 @@ in [architecture-invariants.md](architecture-invariants.md#local-model-install).
## Which model fits which Mac
The window offers the best model of the catalogue (`src/config/local-model-catalog.ts`) that
fits this Mac's memory at a context a coding agent can work with, and up to four more. What
the catalogue answers today, before the runtime measured anything (the budget is then an
estimate: 2/3 of the memory below 36 GB, 3/4 from 36 GB, less 1 GiB, and never more than the
memory less 6, 8 or 12 GiB for macOS, Codeman, a browser and the agent):
fits this Mac's memory at a context a coding agent can work with, and under it every other
model of the catalogue that fits (ten models today: a 16 GB Mac sees one of them, a 512 GB
Mac all ten). What the catalogue answers today, before the runtime measured anything (the
budget is then an estimate: 2/3 of the memory below 36 GB, 3/4 from 36 GB, less 1 GiB, and
never more than the memory less 6, 8 or 12 GiB for macOS, Codeman, a browser and the agent):
| Memory | Budget | What it gets |
| ---------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 8 GB | 2.1 GB | Nothing. The smallest model a coding agent can work with needs 10.2 GB at 65,536 tokens |
| 16 GB | 10.4 GB | Nothing. MiMo V2.6 9B at 65,536 tokens needs 10.2 GB, 98.7% of the budget, just past the 98% a tight fit may reach (it is 95% of the 10.7 GB a 16 GB Mac mini measured). On the edge either way: only a run on such a Mac settles it |
| 18 GB | 11.8 GB | A small model (`limited`): MiMo V2.6 9B at 131,072 tokens, 11.4 GB, tight. Its base, Qwen3.5 9B, is the one alternative |
| 24 GB | 16.1 GB | The same, with room. On a base M2 or M3 (100 GB/s) it computes to 9.3 tokens per second: slow |
| 32 GB | 21.8 GB | The same. North Mini Code 1.0 needs 23.2 GB at 65,536 tokens; once the runtime measures about 3/4 of the memory, as it did on a 16 and a 48 GB Mac, the Mac gets it at 131,072 tokens, or Qwen3.8 27B at 65,536 tokens on a Max chip |
| 36 GB | 27.9 GB | Qwen3.8 27B at 131,072 tokens on an M4 or M5 Max (27.0 GB, 97%); on a slower chip, where the dense 27B computes to under 10 tokens per second, Qwen3.6 35B-A3B as a GGUF build (27.1 GB, 97%). Both tight |
| 48 GB | 37.6 GB | Qwen3.6 35B-A3B (MLX) at 131,072 tokens: the pair we tested, "Recommended" on an Apple M5 Pro with 48 GB. On a Max chip Qwen3.8 27B at 262,144 tokens (32.1 GB, tight). The test was a short task: in a long session MLX may keep up to 8 GiB more, which brings this pair to the edge of the budget, and past it while a long prompt is processed |
| 64 GB | 50.5 GB | Qwen3.8 27B at 262,144 tokens on a Max chip; Qwen3.6 35B-A3B (MLX) on a Pro chip |
| 96 GB | 76.2 GB | Qwen3.8 27B at 262,144 tokens; on an M3 Max, Qwen3.6 35B-A3B (MLX) |
| 128 GB | 102 GB | Ornith 1.5 122B-A10B (MLX) at 131,072 tokens, 97.4 GB, tight |
| 192, 256, 512 GB | 154 GB+ | Ornith 1.5 122B-A10B at 262,144 tokens |
| Memory | Budget | What it gets |
| ---------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 8 GB | 2.1 GB | Nothing. The smallest model a coding agent can work with needs 9.3 GB at 65,536 tokens |
| 16 GB | 10.4 GB | A small model (`limited`): Ornith 1.0 9B at 65,536 tokens, 9.3 GB, tight (90%). Its build has no vision projector, which makes it 0.9 GB smaller than the other two 9B builds: MiMo V2.6 9B needs 10.2 GB there, 98.7% of the budget, just past the 98% a tight fit may reach. Once the runtime measures what a 16 GB Mac mini measured (10.7 GB), all three fit and MiMo is the pick. No 9B was run on such a Mac by us: only a run settles it |
| 18 GB | 11.8 GB | A small model (`limited`): MiMo V2.6 9B at 131,072 tokens, 11.4 GB, tight. Ornith 1.0 9B (10.5 GB) and Qwen3.5 9B are the alternatives |
| 24 GB | 16.1 GB | The same, with room. On a base M2 or M3 (100 GB/s) it computes to 9.3 tokens per second: slow |
| 32 GB | 21.8 GB | No longer a small-model Mac: Muse Glimmer 30B fits at 65,536 tokens (21.2 GB, 97%, tight). It is a dense model. On an M1 Max or M2 Max it computes to 11.8 tokens per second and is the pick; on a base or Pro chip it computes to under 6 and is listed as slow, and the pick stays MiMo V2.6 9B at 131,072 tokens with the note that it is a small model. North Mini Code 1.0 needs 23.2 GB at 65,536 tokens; once the runtime measures about 3/4 of the memory, as it did on a 16 and a 48 GB Mac, the Mac gets it at 131,072 tokens, or Qwen3.8 27B at 65,536 tokens on a Max chip |
| 36 GB | 27.9 GB | Qwen3.8 27B at 131,072 tokens on an M4 or M5 Max (27.0 GB, 97%); on a slower chip, where the dense 27B computes to under 10 tokens per second, Qwen3.6 35B-A3B as a GGUF build (27.1 GB, 97%). Both tight. Muse Glimmer 30B at 131,072 tokens (21.6 GB, 78%) is the alternative with room to spare |
| 48 GB | 37.6 GB | Qwen3.6 35B-A3B (MLX) at 131,072 tokens: the pair we tested, "Recommended" on an Apple M5 Pro with 48 GB. On a Max chip Qwen3.8 27B at 262,144 tokens (32.1 GB, tight). The test was a short task: in a long session MLX may keep up to 8 GiB more, which brings this pair to the edge of the budget, and past it while a long prompt is processed |
| 64 GB | 50.5 GB | Qwen3.8 27B at 262,144 tokens on a Max chip; Qwen3.6 35B-A3B (MLX) on a Pro chip |
| 96 GB | 76.2 GB | Qwen3.8 27B at 262,144 tokens; on an M3 Max, Qwen3.6 35B-A3B (MLX) |
| 128 GB | 102 GB | Ornith 1.5 122B-A10B (MLX) at 131,072 tokens, 97.4 GB, tight |
| 192, 256, 512 GB | 154 GB+ | Ornith 1.5 122B-A10B at 262,144 tokens |
"Tight" means the need is between 85% and 98% of the budget; above 98% a model is not
offered. A model that computes to under 10 tokens per second on the chip is "slow" and ranks
after the fast ones, which is why the chip changes the pick at 36 to 96 GB. Every model is
after the fast ones, which is why the chip changes the pick at 32 to 96 GB. Every model is
offered at the largest context it fits at, and never below 65,536 tokens: OpenCode's own
prompt is 8,000 to 10,000 tokens, and at 32,768 it would compact after less than one default
file read. `test/local-model-recommend.test.ts` ("every memory size") pins this table.
@@ -77,8 +78,10 @@ Where every figure comes from:
| Model | Runner | Memory figures | Speed (tokens per second per 100 GB/s) |
| ---------------------------- | --------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------- |
| `qwen3.5:9b-q4_k_m` | llama.cpp | computed from the Qwen3.5-9B config | 9.3: a published run on an M1 Max |
| `ornith:9b-q4_k_m` | llama.cpp | the same (a fine-tune of Qwen3.5-9B; its config and its GGUF header say so) | 9.3: the same architecture and weights size |
| `mimo-v2.6:9b-q4_k_m` | llama.cpp | the same (a fine-tune of Qwen3.5-9B) | 9.3: the same architecture and size |
| `north-mini-code-1.0:q4_k_m` | llama.cpp | computed from its config | 13.6: the 35B-A3B's, the same 3 billion active parameters |
| `muse-glimmer:30b-q4_k_m` | llama.cpp | computed from its config: 13 full-attention and 39 sliding-window layers | 2.95: the dense 27B's, whose weights are the same size |
| `qwen3.6:35b-a3b-mtp-q4_k_m` | llama.cpp | computed from the Qwen3.6-35B-A3B config, prediction layer included | 13.6: a published run on an M1 Max |
| `qwen3.6:35b-a3b` | MLX | **measured** on an Apple M5 Pro with 48 GB | 16.9: a published run; 67.7 measured on the tested Mac |
| `qwen3.8:27b-q8_0` | llama.cpp | computed from the Qwen3.8-27B config, prediction layer included | 1.7: the row below scaled by file size |
@@ -86,13 +89,16 @@ Where every figure comes from:
| `ornith-1.5:122b` | MLX | computed from the Qwen3.5-122B-A10B config, scaled by the measured MLX row, + 8 GiB | 5.0: the 35B-A3B's, x 3/10 active parameters |
Sizes are what the registry serves a Mac for each tag (every layer plus the config), read on
2026-11-08. Only one row was ever run on a Mac, so every other answer in the table above is a
calculation. For a llama.cpp build the check after the download catches a load that was
2026-11-08 (the Ornith 1.0 and Muse Glimmer rows on 2026-11-10). Only one row was ever run on
a Mac, so every other answer in the table above is a calculation. For a llama.cpp build the check after the download catches a load that was
judged too kindly, but not what grows after it in host memory (the checkpoints and the prompt
cache, which are counted, not checked); for an MLX build it catches neither (see "The check"
below). The measured row's figures come from single long prompts, not a session of many
requests, in which MLX may keep up to 8 GiB more. The catalogue header lists the models left out on
purpose (no evidence of working tool calls, a restrictive licence, a known bug) and why.
requests, in which MLX may keep up to 8 GiB more. The catalogue header lists the models left
out on purpose (no evidence of working tool calls, a restrictive licence, a known bug, a
build that needs a newer runtime than the pinned one) and why. The strongest of those today
is Ripple 65B Preview, published on 2026-11-09: every build of it requires Ollama 0.40.16,
one above the pin.
A new row needs its model's architecture (attention layers, K/V heads, head size, and its
linear-attention or sliding-window layers, from its `config.json`) in the table of
@@ -355,6 +361,13 @@ or `python3`.
others are computed from their architecture, and the runtime settings they assume (an 8-bit
K/V cache, flash attention, a 1 GiB prompt cache) were read in its source, not yet observed
on a Mac. "Recommended" appears only for the pair we ran, on the Mac we ran it on.
- **A tool call that quotes a tool call can be cut off.** The pinned runtime reads the tool
calls of every Qwen-family model in the catalogue (the Ornith, MiMo and Qwen rows, the pair
we ran included) with one parser, and that parser ends a call at the first closing tag it
sees. An argument that itself contains a whole tool call, such as a prompt handed to a
sub-agent that shows one, arrives cut off and the rest as text (runtime issue 16992, filed
on `ornith:35b`; the fix, runtime pull request 17021, is open). The check after a download
asks for one plain tool call and does not see this.
- **The model stays in memory for ten minutes** after the last request, then the runtime
unloads it.
+6 -21
View File
@@ -37,12 +37,6 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
@@ -161,9 +155,9 @@ checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
name = "codeman-desktop"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"base64",
"block2",
"dirs 6.0.0",
"dirs",
"libc",
"muda",
"objc2",
@@ -353,15 +347,6 @@ dependencies = [
"crypto-common",
]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs"
version = "7.0.0"
@@ -2460,7 +2445,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b"
dependencies = [
"base64 0.23.1",
"base64",
"flate2",
"log",
"percent-encoding",
@@ -2478,7 +2463,7 @@ version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922"
dependencies = [
"base64 0.23.1",
"base64",
"http",
"httparse",
"log",
@@ -2981,11 +2966,11 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "759c50ddad0ef9913bb5496cb9d509a50433a3b78cb0238d61105526589cf0e9"
dependencies = [
"base64 0.23.1",
"base64",
"block2",
"cookie",
"crossbeam-channel",
"dirs 7.0.0",
"dirs",
"dom_query",
"dpi",
"dunce",
+4 -2
View File
@@ -22,8 +22,10 @@ muda = "0.21"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
url = "2"
base64 = "0.22"
dirs = "6"
# The versions ureq and wry already pull in, so neither crate is built twice
# (ureq builds base64 without its default SIMD engines, and so do we).
base64 = { version = "0.23", default-features = false, features = ["std"] }
dirs = "7"
# The reachability probe. platform-verifier = the OS trust store, the same one
# WKWebView uses, so the probe and the window agree on which certificates pass.
ureq = { version = "3", features = ["platform-verifier"] }
+21 -19
View File
@@ -9,7 +9,7 @@ macOS first: the app runs on 11.3 or later, and Codeman's own page needs the
WebKit of Safari 16.4 (see Known limits). The same code is meant to build for
Windows and Linux later.
- **Size:** 4.6 MB for the universal `Codeman.app` (Apple Silicon + Intel), 3 MB as a `.dmg` (measured on the first build).
- **Size:** 5.3 MB for the universal `Codeman.app` (Apple Silicon + Intel), 3.5 MB as a `.dmg` (measured 2026-11-09).
- **Stack:** Rust with [tao](https://crates.io/crates/tao) (window and event loop),
[wry](https://crates.io/crates/wry) (the system web view: WKWebView on macOS) and
[muda](https://crates.io/crates/muda) (native menus), plus [objc2](https://crates.io/crates/objc2)
@@ -24,7 +24,7 @@ operating system's own web view. An Electron build of the same thing would ship
a whole Chromium (around 150 MB). The native side only adds what a browser tab
cannot do.
## What it does (v0.1)
## What it does
| | |
| --- | --- |
@@ -109,8 +109,9 @@ classes of wry's own delegates, so wry stays an unmodified crates.io dependency.
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
on from any frame without saying which, gets only the file routes and
path picker's preview 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
app's own. Such a window does not take keyboard focus, and these windows
@@ -218,11 +219,12 @@ 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
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
restart, each request that arrives without a cookie gets a 401 before the
others are asked without one. 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 restart, each request that arrives
without a cookie gets a 401 before the
login goes out, and those count until one succeeds. Many windows reconnecting
at once can push a request past 10, and that request then fails with a 429
instead of being answered. A refusal that lands while you are in another app
@@ -305,15 +307,15 @@ crosses, so the login is never on a command line.
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.
- **The logo blink rests on a private macOS interface.** Apple documents the
setting, not the way the app reaches it, so a macOS update can take it away;
the menu then disappears. Every change of the logo writes `LEBacklightMode`,
from about once every two seconds (*Slow*, *Wink*) to about three times a
second (*Fast*), which `corebrightnessd` keeps in root's preferences. The
setting is on or off, so these styles are rhythms of hard changes. After a blink
the key stays there rather than being absent as on a fresh Mac. That reads the same to macOS; `sudo defaults delete
com.apple.CoreBrightness LEBacklightMode` removes it. After a power cut in a
setting is on or off, so these styles are rhythms of hard changes. After a
blink the key stays there rather than being absent as on a fresh Mac. That
reads the same to macOS; `sudo defaults delete com.apple.CoreBrightness
LEBacklightMode` removes it. After a power cut in a
dark phase the logo stays dark until the app next starts. `api/sessions` does
not say which turns are Codeman's own, so with prompt-cache keep-warm on, a
keep-alive turn blinks too.
@@ -335,16 +337,16 @@ crosses, so the login is never on a command line.
`SIGN_IDENTITY` (a self-made code-signing certificate from Keychain Access
is enough). Microphone and camera permissions are asked again for the same
reason.
- **Gatekeeper.** Without a Developer ID the app is signed ad hoc. A build you
made yourself opens normally; a copy that arrived by download or AirDrop is
- **Gatekeeper, for a build of your own.** The `Codeman.dmg` on each GitHub
release is signed with a Developer ID and notarized by CI
(`.github/workflows/desktop-dmg.yml`), so it opens like any downloaded app.
A build without a Developer ID is signed ad hoc: it opens normally on the Mac
that built it, while a copy that arrived by download or AirDrop is
quarantined. On macOS 15 and later, try to open it once, then click *Open
Anyway* in System Settings > Privacy & Security (it asks for an administrator
password). On macOS 14 and earlier, right-click it and choose *Open* once.
Either way, `xattr -dr com.apple.quarantine /Applications/Codeman.app` on the
copy in Applications works too. The `Codeman.dmg` on each GitHub release is
signed with a Developer ID and notarized by CI
(`.github/workflows/desktop-dmg.yml`), so it skips all of this. To do the
same by hand:
copy in Applications works too. To sign and notarize by hand:
`SIGN_IDENTITY="Developer ID Application: ..." NOTARY_PROFILE=<profile> ./scripts/bundle-macos.sh --universal --notarize`,
where `scripts/sign-macos.sh` lists what it needs.
- **Local Network permission (macOS 15 and later).** The first connection to an
+79 -71
View File
@@ -53,7 +53,7 @@
padding: 20px; box-shadow: 0 20px 60px rgba(0,0,0,0.45);
}
label.field { display: block; font-size: 12px; color: var(--muted); margin: 0 0 6px; }
input[type=text], input[type=password], input[type=url] {
input[type=text], input[type=password] {
width: 100%; height: 38px; padding: 0 12px; border-radius: 9px;
border: 1px solid var(--line-strong); background: #0c0d10; color: var(--text);
font: inherit; outline: none; -webkit-user-select: text; user-select: text;
@@ -61,7 +61,6 @@
input:focus { border-color: var(--accent-2); box-shadow: 0 0 0 3px rgba(59,130,246,0.25); }
.row { display: flex; gap: 10px; }
.row > * { flex: 1; }
.gap { height: 14px; }
#recent { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
#recent:empty { display: none; }
.chip {
@@ -128,7 +127,7 @@
/* 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, #elsewhere { 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;
@@ -139,7 +138,6 @@
.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 summary { color: var(--muted); font-size: 13px; cursor: default; list-style: none; }
#elsewhere summary::-webkit-details-marker { display: none; }
@@ -231,7 +229,6 @@
border: 1px solid var(--line); color: #aab1bb; font: 11px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace;
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 a:hover { color: var(--accent); }
@@ -416,9 +413,11 @@
// 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
// Terminal and this page polls for the server it brings up. gui: the
// graphical install while its screens are up (newWizard), null otherwise.
// last This Mac status from the app. wait: set while this page polls for the
// server an install brings up (one in Terminal, or a graphical one that
// finished; newWait). gui: the graphical install or the agent chooser while
// its screens are up (newWizard), null otherwise. clock: the timer of the
// running phase's clock.
// 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
@@ -427,7 +426,7 @@
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
reopen: false, startingLooks: 0, startingTimer: 0, clock: 0
};
// 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.
@@ -511,14 +510,15 @@
els.suggest.hidden = true;
}
function host(url) {
function displayAddress(url) {
try { var u = new URL(url); return u.host + (u.pathname === '/' ? '' : u.pathname.replace(/\/$/, '')); } catch (e) { return url; }
}
// 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.
// or fragment, a trailing slash. Used to tell whether two addresses are one
// server, and which scheme and host a typed address ends up with
// (isLocalUrl, the http:// warning).
function normalize(raw) {
var s = String(raw || '').trim();
if (!s) return '';
@@ -553,7 +553,7 @@
chip.className = 'chip';
var pick = document.createElement('button');
pick.type = 'button';
pick.textContent = host(s.url) + (s.username ? ' (' + s.username + ')' : '');
pick.textContent = displayAddress(s.url) + (s.username ? ' (' + s.username + ')' : '');
pick.title = 'Connect to ' + s.url;
pick.addEventListener('click', function () {
if (state.busy) return;
@@ -565,7 +565,7 @@
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.setAttribute('aria-label', 'Forget ' + displayAddress(s.url));
x.addEventListener('click', function () { if (!state.busy) post({ cmd: 'forget', url: s.url }); });
chip.appendChild(pick);
chip.appendChild(x);
@@ -782,8 +782,11 @@
return (s.found || []).filter(function (f) { return f && f.url !== s.url; });
}
function canConnect(f) {
return f.state === 'running' || f.state === 'needsLogin';
// Codeman answers there, with or without a login, so the app can connect
// to it. Read the same way for a found server's row and for the This Mac
// status.
function answers(s) {
return s.state === 'running' || s.state === 'needsLogin';
}
// What a found server's row says under its address: its version, or why the
@@ -815,7 +818,7 @@
text.appendChild(small);
}
row.appendChild(text);
if (canConnect(f)) {
if (answers(f)) {
var button = document.createElement('button');
button.type = 'button';
button.className = primary ? 'primary' : 'secondary';
@@ -848,7 +851,7 @@
} else if (!s) {
dot = 'busy';
title = 'Looking for Codeman on this Mac…';
} else if (s.installRunning && s.state !== 'running' && s.state !== 'needsLogin') {
} else if (s.installRunning && !answers(s)) {
dot = 'busy';
title = 'Codeman is being installed in Terminal.';
detail = 'Finish the installer there, then Check again.';
@@ -861,7 +864,7 @@
} 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';
dot = main.some(answers) ? '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) {
@@ -869,16 +872,12 @@
'Starting another server here would take over its sessions.';
}
show.check = true;
} else if (s.state === 'running') {
} else if (answers(s)) {
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.';
detail = s.state === 'needsLogin'
? s.url + ' asks for a login: Open asks for the password.'
: s.url + (s.version ? ' · version ' + s.version : '');
show.open = true;
also = otherServers(s);
} else if (s.state === 'stopped') {
@@ -915,7 +914,7 @@
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.');
(also.some(answers) ? 'the Codeman listed below.' : 'Codeman’s real address under Another machine.');
} else {
var e = explain[o.kind];
title = e ? e[1] : 'Could not reach Codeman on this Mac.';
@@ -959,8 +958,8 @@
}
// An install from this app is still running (this page was shown again
// while it ran): wait for it, unless the user stopped waiting.
if (!state.wait && s && s.installRunning && !s.installInApp && !state.stoppedWaiting && s.state !== 'running' && s.state !== 'needsLogin') {
state.wait = { since: Date.now(), finishedAt: 0, port: s.installPort || 3000 };
if (!state.wait && s && s.installRunning && !s.installInApp && !state.stoppedWaiting && !answers(s)) {
state.wait = newWait(s.installPort || 3000);
renderLocal();
scheduleWait();
}
@@ -989,7 +988,7 @@
}
return false;
}
var up = !!s && (s.state === 'running' || s.state === 'needsLogin');
var up = !!s && answers(s);
var open = up && state.reopen && state.startingLooks > 0 && state.tab === 'local' && !state.busy;
state.startingLooks = 0;
state.reopen = false;
@@ -1041,10 +1040,15 @@
var network = !!state.gui && state.gui.reach === 'network';
closeWizard();
selectTab('local');
openTerminal({ cmd: 'installLocal', network: network });
}
// Both Terminal installs: no second one while Terminal opens.
function openTerminal(msg) {
state.opening = true;
syncLocalButtons();
setStatus('busy', 'Opening Terminal…');
post({ cmd: 'installLocal', network: network });
post(msg);
}
// mode 'install': Codeman itself. mode 'agent': one coding agent for a Mac
@@ -1056,17 +1060,22 @@
};
}
// The first screen of the graphical install ('install') or of adding an
// agent ('agent'), which asks the installer for its plan.
function prepareWizard(mode) {
state.gui = newWizard('preparing', mode);
post({ cmd: mode === 'agent' ? 'agentPrepare' : 'installPrepare' });
}
function openWizard() {
if (state.busy || tooOld || state.opening) return;
state.gui = newWizard('preparing');
post({ cmd: 'installPrepare' });
prepareWizard('install');
renderWizard();
}
function openAgentChooser() {
if (state.busy || tooOld || state.opening) return;
state.gui = newWizard('preparing', 'agent');
post({ cmd: 'agentPrepare' });
prepareWizard('agent');
renderWizard();
}
@@ -1086,14 +1095,14 @@
renderLocal();
}
// Try again keeps what was chosen.
function retryWizard() {
var reach = state.gui ? state.gui.reach : 'local';
var agent = state.gui ? state.gui.agent : '';
var mode = state.gui ? state.gui.mode : 'install';
state.gui = newWizard('preparing', mode);
state.gui.reach = reach;
state.gui.agent = agent;
post({ cmd: mode === 'agent' ? 'agentPrepare' : 'installPrepare' });
var old = state.gui;
prepareWizard(old ? old.mode : 'install');
if (old) {
state.gui.reach = old.reach;
state.gui.agent = old.agent;
}
renderWizard();
}
@@ -1111,6 +1120,12 @@
return offeredAgents().filter(function (c) { return c.id === id; })[0] || null;
}
// This Mac has no coding agent and the installer offers one, so an install
// asks which one.
function needsAgent() {
return !installedAgents().length && offeredAgents().length > 0;
}
// A run this page did not start has no plan, so no name for the agent.
function agentName() {
var c = agentById(state.gui.agent);
@@ -1129,7 +1144,7 @@
g.step = 'agent';
} else if (plan.blocker) {
g.step = 'blocked';
} else if (!installedAgents().length && offeredAgents().length) {
} else if (needsAgent()) {
// Kept across Try again; otherwise the first of the catalogue's order.
if (!agentById(g.agent)) g.agent = offeredAgents()[0].id;
g.step = 'agent';
@@ -1229,7 +1244,7 @@
PHASES.forEach(function (id) {
var label = {
runtime: 'Getting Node.js and tmux',
agent: 'Installing ' + (chosen ? chosen.label : 'a coding agent'),
agent: 'Installing ' + agentName(),
source: 'Downloading Codeman',
packages: 'Installing packages',
build: 'Building Codeman',
@@ -1313,21 +1328,14 @@
if (!g) return;
appendLog(msg.log);
clearInterval(state.clock);
if (msg.ok && g.mode === 'agent') {
g.ok = true;
g.step = 'added';
renderWizard();
// Whether there is a Codeman to open from here.
checkLocal('quick');
return;
}
if (msg.ok) {
g.ok = true;
g.step = 'finishing';
g.step = g.mode === 'agent' ? 'added' : 'finishing';
renderWizard();
// The same watch as a Terminal install: it opens Codeman at the first
// answer, and offers Start when none comes.
state.wait = { since: Date.now(), finishedAt: 0, port: g.port };
// An install gets the same watch as a Terminal install: it opens Codeman
// at the first answer, and offers Start when none comes. An added agent
// only asks whether there is a Codeman to open from here.
if (g.mode !== 'agent') state.wait = newWait(g.port);
checkLocal('quick');
return;
}
@@ -1346,7 +1354,8 @@
renderLog();
}
// Each screen of the graphical install, drawn from state.gui alone.
// Each screen of the graphical install and of adding an agent, drawn from
// state.gui (and state.local for Open Codeman).
function renderWizard() {
var g = state.gui;
els.card.classList.toggle('wizard-on', !!g);
@@ -1418,10 +1427,6 @@
else els.wizTitle.focus();
}
function localAnswers() {
return !!state.local && (state.local.state === 'running' || state.local.state === 'needsLogin');
}
function renderWizardButtons() {
var g = state.gui;
var step = g.step;
@@ -1430,7 +1435,7 @@
var next = {
agent: agentMode ? (chosen ? 'Install ' + chosen.label : '') : 'Continue',
reach: 'Install Codeman', failed: 'Try again', prepareFailed: 'Try again', blocked: 'Install in Terminal',
ready: 'Open Codeman', added: localAnswers() ? 'Open Codeman' : ''
ready: 'Open Codeman', added: state.local && answers(state.local) ? 'Open Codeman' : ''
}[step];
els.wizNext.hidden = !next;
els.wizNext.textContent = next || '';
@@ -1478,7 +1483,7 @@
els.wizPw.value = '';
els.wizPw2.value = '';
// Skipped: the first thing to say once Codeman answers.
g.noAgent = !installedAgents().length && !msg.aiCli && offeredAgents().length > 0;
g.noAgent = needsAgent() && !msg.aiCli;
beginRun();
post(msg);
}
@@ -1524,7 +1529,7 @@
function wizardBack() {
var g = state.gui;
if (!g) return;
if (g.step === 'reach' && !installedAgents().length && offeredAgents().length && !g.plan.blocker) {
if (g.step === 'reach' && needsAgent() && !g.plan.blocker) {
g.step = 'agent';
renderWizard();
} else {
@@ -1532,6 +1537,12 @@
}
}
// A wait for the server an install brings up, on `port` until the
// installer says otherwise.
function newWait(port) {
return { since: Date.now(), finishedAt: 0, port: port };
}
function scheduleWait() {
clearTimeout(state.waitTimer);
state.waitTimer = setTimeout(function () { if (state.wait) checkLocal('quick'); }, WAIT_POLL_MS);
@@ -1560,7 +1571,7 @@
// Until the installer writes its LaunchAgent, the app looks at port 3000,
// where something else may answer when the install took another port.
var ours = portOf(s.url) === w.port;
if (ours && (s.state === 'running' || s.state === 'needsLogin')) {
if (ours && answers(s)) {
var g = state.gui;
if (g && g.step === 'finishing' && g.noAgent) {
clearTimeout(state.waitTimer);
@@ -1608,10 +1619,7 @@
els.ssh.focus();
return;
}
state.opening = true;
syncLocalButtons();
setStatus('busy', 'Opening Terminal…');
post({ cmd: 'installRemote', target: target });
openTerminal({ cmd: 'installRemote', target: target });
}
window.__codemanDesktop = {
@@ -1690,7 +1698,7 @@
case 'installStarted':
state.opening = false;
setStatus('', '');
state.wait = { since: Date.now(), finishedAt: 0, port: Number(msg.port) || 3000 };
state.wait = newWait(Number(msg.port) || 3000);
selectTab('local');
renderLocal();
scheduleWait();
+20 -17
View File
@@ -115,19 +115,22 @@ fn session_cookie(header: &str) -> Option<&str> {
(name == COOKIE_NAME && !value.is_empty()).then_some(pair)
}
/// A refused login: Codeman counts each against the login rate limit, so the
/// polling ends.
fn refused(status: u16) -> bool {
matches!(status, 401 | 403 | 429)
}
/// What a status code says about asking again.
fn answer(status: u16, body: impl FnOnce() -> Option<String>) -> Poll {
fn answer(status: u16, body: Option<&str>) -> Poll {
match status {
200 => body()
.as_deref()
.and_then(count_working)
.map_or(Poll::Failed, |working| {
Poll::Seen(Activity {
working: working > 0,
..Activity::default()
})
}),
401 | 403 | 429 => Poll::Refused,
200 => body.and_then(count_working).map_or(Poll::Failed, |working| {
Poll::Seen(Activity {
working: working > 0,
..Activity::default()
})
}),
s if refused(s) => Poll::Refused,
_ => Poll::Failed,
}
}
@@ -192,7 +195,7 @@ impl Poller {
let Some((status, body)) = self.ask(&url) else {
return Poll::Failed;
};
let mut activity = match answer(status, || body) {
let mut activity = match answer(status, body.as_deref()) {
Poll::Seen(activity) => activity,
other => return other,
};
@@ -203,7 +206,7 @@ impl Poller {
self.alerts = alerts;
}
}
Some((401 | 403 | 429, _)) => return Poll::Refused,
Some((s, _)) if refused(s) => return Poll::Refused,
// A server from before the list has no alerts to report.
Some((404, _)) => self.alerts = (false, false),
_ => {}
@@ -264,17 +267,17 @@ mod tests {
fn a_body_that_is_not_the_list_is_no_answer() {
assert_eq!(count_working("<html>"), None);
assert_eq!(count_working(r#"{"success":false,"error":"nope"}"#), None);
assert_eq!(answer(200, || Some("<html>".into())), Poll::Failed);
assert_eq!(answer(200, || None), Poll::Failed);
assert_eq!(answer(200, Some("<html>")), Poll::Failed);
assert_eq!(answer(200, None), Poll::Failed);
}
#[test]
fn only_a_login_refusal_ends_the_polling() {
for status in [401, 403, 429] {
assert_eq!(answer(status, || None), Poll::Refused, "{status}");
assert_eq!(answer(status, None), Poll::Refused, "{status}");
}
for status in [301, 404, 500, 502, 503] {
assert_eq!(answer(status, || None), Poll::Failed, "{status}");
assert_eq!(answer(status, None), Poll::Failed, "{status}");
}
}
+2 -2
View File
@@ -13,8 +13,8 @@ const MAX_SERVERS: usize = 8;
/// How many recovery copies of an unreadable config can sit side by side:
/// `desktop.json.corrupt`, then `desktop.json.corrupt.1` to `.9`.
const ASIDE_SLOTS: usize = 10;
const MIN_WIDTH: f64 = 480.0;
const MIN_HEIGHT: f64 = 360.0;
pub const MIN_WIDTH: f64 = 480.0;
pub const MIN_HEIGHT: f64 = 360.0;
const MAX_EDGE: f64 = 20_000.0;
/// Height of a standard title bar, in points.
+1 -3
View File
@@ -88,9 +88,7 @@ impl Keychain {
password: password.to_owned(),
reply,
};
if self.jobs.send(job).is_err() {
return Err(WORKER_GONE.into());
}
self.jobs.send(job).map_err(|_| WORKER_GONE.to_string())?;
answer.recv().unwrap_or_else(|_| Err(WORKER_GONE.into()))
}
+8 -8
View File
@@ -6,8 +6,8 @@
//! tmux in the home folder, so nothing in it asks for a password, and the two
//! questions only a person can answer (which coding agent, who may reach
//! Codeman) are asked by the page and passed as flags. The Terminal route in
//! `setup.rs` stays for what this one cannot do, a Mac older than macOS 15 with
//! no tmux first of all.
//! `terminal_install.rs` stays for what this one cannot do, a Mac older than
//! macOS 15 with no tmux first of all.
//!
//! `prepare` downloads the installer once into a private folder and asks it
//! what is on this Mac (`install.sh preflight`); `start` runs the same file with
@@ -100,8 +100,8 @@ impl Run {
/// not one: it has no `--progress`.
pub fn matches(self, command: &str) -> bool {
let suffix = format!("/{}/{SCRIPT_FILE}", self.folder());
let mut words = command.split_whitespace();
words.any(|w| w.ends_with(&suffix)) && command.split_whitespace().any(|w| w == "--progress")
command.split_whitespace().any(|w| w.ends_with(&suffix))
&& command.split_whitespace().any(|w| w == "--progress")
}
/// A process of this run, if it is running.
@@ -269,11 +269,12 @@ fn download_installer(script: &Path) -> Result<(), String> {
if out.code == Some(0) && script.is_file() {
return Ok(());
}
const FAILED: &str = "Could not download the installer. Check the internet connection, then try again.";
let said = out.stderr.trim();
Err(if said.is_empty() {
"Could not download the installer. Check the internet connection, then try again.".to_string()
FAILED.to_string()
} else {
format!("Could not download the installer. Check the internet connection, then try again.\n{said}")
format!("{FAILED}\n{said}")
})
}
@@ -571,9 +572,8 @@ pub fn follow(run: Run, stale: impl Fn() -> bool, send: impl Fn(Value)) {
return;
}
let running = run.process().is_some();
let log = progress.feed(&read_more(&mut file, &path, &mut offset));
let mut log = progress.feed(&read_more(&mut file, &path, &mut offset));
if !running || progress.done {
let mut log = log;
log.extend(progress.finish());
let cancelled = CANCELLED.load(Ordering::SeqCst);
if progress.done {
+22 -18
View File
@@ -1,4 +1,7 @@
//! "Start Codeman on this Mac": `codeman web -d` through the user's login shell.
//! The same login-shell machinery answers whether Codeman is installed
//! (`installed`) and hands its environment to the graphical install
//! (`login_environment`); `exec_bounded` is the crate's bounded process runner.
//!
//! A double-clicked app gets launchd's bare PATH (`/usr/bin:/bin:...`), which
//! finds neither `codeman` nor `node`, and the server inherits whatever
@@ -143,7 +146,8 @@ fn pump<R: Read + Send + 'static>(pipe: Option<R>, stream: usize, tx: mpsc::Send
});
}
/// `exec_bounded` for a start.
/// `exec_bounded` with the start's timeout message. The installed check and
/// `login_environment` run through it too, and drop its error.
fn exec(cmd: Command, deadline: Instant) -> Result<Output, String> {
exec_bounded(
cmd,
@@ -248,13 +252,16 @@ fn parse_env(output: &str) -> Option<Vec<(String, String)>> {
None
}
/// A portable variable name: a letter or `_`, then letters, digits or `_`.
pub fn is_env_name(name: &str) -> bool {
let mut chars = name.chars();
chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn env_entry(line: &str) -> Option<(&str, &str)> {
let (name, value) = line.split_once('=')?;
let mut chars = name.chars();
let first = chars.next()?;
let portable =
(first.is_ascii_alphabetic() || first == '_') && chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
portable.then_some((name, value))
is_env_name(name).then_some((name, value))
}
/// Ask `shell` for its environment with `flags`. The outer error ends the
@@ -339,16 +346,13 @@ fn run_in_shell(
/// 127 means "no codeman here" and moves on to the next way of asking; the
/// interactive profile is asked only when `interactive` allows it.
fn launch(script: &str, shell: &Path, args: &[String], deadline: Instant, interactive: bool) -> Result<Run, String> {
let attempts = match flavor(shell) {
Flavor::Posix => {
let result = run_in_shell(script, shell, false, args, deadline)?;
if result.code != Some(127) || !interactive {
return Ok(result);
}
// Not on the login PATH; try the interactive profile too.
return run_in_shell(script, shell, true, args, deadline);
let Flavor::Capture(attempts) = flavor(shell) else {
let result = run_in_shell(script, shell, false, args, deadline)?;
if result.code != Some(127) || !interactive {
return Ok(result);
}
Flavor::Capture(attempts) => attempts,
// Not on the login PATH; try the interactive profile too.
return run_in_shell(script, shell, true, args, deadline);
};
let mut last_run = None;
let mut capture_error = None;
@@ -386,8 +390,8 @@ const FIND_SCRIPT: &str = r#"if command -v codeman >/dev/null 2>&1; then exit 0;
if [ -f "$HOME/.codeman/app/dist/index.js" ] && command -v node >/dev/null 2>&1; then exit 0; fi
exit 127"#;
/// How long the This Mac tab waits for the login shell to answer whether
/// Codeman is installed.
/// How long one look at the login shell may take: whether Codeman is
/// installed (the This Mac tab), or its environment for the graphical install.
const FIND_TIMEOUT: Duration = Duration::from_secs(15);
/// `installed` with the shell given, so tests need not touch `$SHELL`.
@@ -453,7 +457,7 @@ mod tests {
})
.output
};
// stdout that ended without a newline used to be glued to stderr.
// stdout that ends without a newline: stderr still starts a line of its own.
assert_eq!(
said("Starting Codeman", "Error: port 3000 is in use\n"),
"Starting Codeman\nError: port 3000 is in use"
+256
View File
@@ -0,0 +1,256 @@
//! The login a connect goes out with, and what the Keychain keeps once the
//! server has answered it.
use crate::credentials::{self, Keychain};
use crate::probe::ProbeOutcome;
/// 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.
pub 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)
}
/// A login the probe just tried, and what the user asked for it.
pub struct ProbedLogin<'a> {
/// The server, as the config and the Keychain accounts key it.
pub server: &'a str,
/// The username the config named for this server before the connect.
pub previous_user: Option<&'a str>,
pub username: Option<&'a str>,
pub password: Option<&'a str>,
/// Where the password came from.
pub source: PasswordSource,
pub remember: bool,
}
/// Where a connect's password came from, which decides what the Keychain
/// does with it (update_keychain).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PasswordSource {
/// Typed on the connect page this time: saved if Remember is on.
Typed,
/// From the Keychain: there already.
Keychain,
/// The local Codeman's own login (setup::local_login): it stays where it
/// is, and the Keychain never gets a copy.
LocalServer,
/// No password.
Absent,
}
/// What update_keychain did: whether the Keychain now holds the login's
/// password, and whose items it deleted.
#[derive(Default)]
pub struct KeychainUpdate {
pub saved: bool,
pub deleted: Vec<String>,
}
/// Bring the Keychain in line with a probe the server answered, on the probe
/// worker. Only an accepted login changes anything (Ok, or a password the
/// server wants changed), since only then does the config get the new entry.
///
/// When that entry names another user than the config did (compared exactly,
/// as the account string is), the old user's item is deleted FIRST, before any
/// save: the Keychain worker runs jobs in order, so a Keychain that treats two
/// spellings of a name as one item cannot lose the new password to that
/// delete. Then the typed password is saved when Remember is on and deleted
/// when it is off, and a password the server wants changed is never kept.
pub fn update_keychain(keychain: &Keychain, outcome: &ProbeOutcome, login: &ProbedLogin<'_>) -> KeychainUpdate {
let mut update = KeychainUpdate::default();
if !matches!(outcome, ProbeOutcome::Ok { .. } | ProbeOutcome::PasswordChangeRequired) {
return update;
}
let mut delete = |user: &str| {
keychain.delete(&credentials::account(login.server, user));
update.deleted.push(user.to_string());
};
if let Some(old) = login.previous_user.filter(|old| login.username != Some(*old)) {
delete(old);
}
let saved = match (outcome, login.username, login.password) {
(ProbeOutcome::Ok { .. }, Some(user), Some(password)) if login.source == PasswordSource::Typed => {
if login.remember {
keychain
.save_blocking(&credentials::account(login.server, user), password)
.is_ok()
} else {
delete(user);
false
}
}
// A password that came from the Keychain is saved there already.
(ProbeOutcome::Ok { .. }, Some(_), Some(_)) => login.source == PasswordSource::Keychain,
(ProbeOutcome::PasswordChangeRequired, Some(user), _) => {
delete(user);
false
}
_ => false,
};
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());
}
}
}
+15 -16
View File
@@ -270,7 +270,7 @@ const SETTLE: Duration = Duration::from_secs(3);
const LOOK_EVERY: Duration = Duration::from_millis(250);
/// What a blink needs from the machine, so its decisions can be tested.
pub trait Logo {
trait Logo {
/// The current mode, None if it could not be read.
fn mode(&self) -> Option<i64>;
/// Set the mode; false if the daemon did not take it.
@@ -281,7 +281,7 @@ pub trait Logo {
}
/// The file that says a blink may have left the logo dark.
pub struct Marker(Option<PathBuf>);
struct Marker(Option<PathBuf>);
impl Marker {
fn default_location() -> Self {
@@ -363,7 +363,7 @@ fn blink(logo: &impl Logo, marker: &Marker, pattern: &[Beat], mut wait: impl FnM
}
/// Put back a logo that a blink could not finish with.
pub fn recover(logo: &impl Logo, marker: &Marker) {
fn recover(logo: &impl Logo, marker: &Marker) {
if !marker.is_set() {
return;
}
@@ -507,25 +507,24 @@ fn spawn_child(connection: Connection) -> Option<(Connection, ChildStdin)> {
Some((connection, stdin))
}
#[cfg(target_os = "macos")]
pub use system::{available, blink_main, recover_at_launch, switched_off};
/// No lit logo to blink outside macOS.
#[cfg(not(target_os = "macos"))]
pub fn available() -> bool {
false
}
mod system {
pub fn available() -> bool {
false
}
#[cfg(not(target_os = "macos"))]
pub fn blink_main() -> ! {
std::process::exit(0)
}
pub fn blink_main() -> ! {
std::process::exit(0)
}
#[cfg(not(target_os = "macos"))]
pub fn recover_at_launch() {}
pub fn recover_at_launch() {}
#[cfg(not(target_os = "macos"))]
pub fn switched_off() -> bool {
false
pub fn switched_off() -> bool {
false
}
}
#[cfg(target_os = "macos")]
File diff suppressed because it is too large. Load diff
+161
View File
@@ -0,0 +1,161 @@
//! The menu bar.
use muda::accelerator::{Accelerator, Code, Modifiers};
use muda::{AboutMetadata, CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu};
use crate::config::Config;
use crate::logo::{self, BlinkStyle, Signal};
use crate::{APP_NAME, VERSION};
/// Codeman's repository and its documentation (the Help menu, About).
pub const REPO_URL: &str = "https://github.com/Ark0N/Codeman";
pub const DOCS_URL: &str = "https://github.com/Ark0N/Codeman/wiki";
pub mod menu_id {
pub const SWITCH: &str = "switch-server";
pub const ADD_AGENT: &str = "add-agent";
pub const RELOAD: &str = "reload";
pub const START_FULLSCREEN: &str = "start-fullscreen";
pub const DEVTOOLS: &str = "devtools";
pub const HELP_DOCS: &str = "help-docs";
pub const HELP_GITHUB: &str = "help-github";
}
pub struct AppMenu {
pub bar: Menu,
pub window_menu: Submenu,
pub help_menu: Submenu,
pub start_fullscreen: CheckMenuItem,
/// One item per state and style. Empty on a Mac without a lit logo, which has no
/// such menu.
pub logo_styles: Vec<(Signal, BlinkStyle, CheckMenuItem)>,
}
pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
let bar = Menu::new();
let about = PredefinedMenuItem::about(
Some("About Codeman"),
Some(AboutMetadata {
name: Some(APP_NAME.into()),
version: Some(VERSION.into()),
comments: Some("A native window for your Codeman server.".into()),
website: Some(REPO_URL.into()),
..Default::default()
}),
);
let switch = MenuItem::with_id(
menu_id::SWITCH,
"Switch Server\u{2026}",
true,
Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyO)),
);
let app_menu = Submenu::with_items(
APP_NAME,
true,
&[
&about,
&PredefinedMenuItem::separator(),
&switch,
&MenuItem::with_id(menu_id::ADD_AGENT, "Add a Coding Agent to This Mac\u{2026}", true, None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::services(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::hide(None),
&PredefinedMenuItem::hide_others(None),
&PredefinedMenuItem::show_all(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::quit(None),
],
)?;
// Without these items Cmd+C / Cmd+V do nothing inside a WKWebView.
let edit = Submenu::with_items(
"Edit",
true,
&[
&PredefinedMenuItem::undo(None),
&PredefinedMenuItem::redo(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::cut(None),
&PredefinedMenuItem::copy(None),
&PredefinedMenuItem::paste(None),
&PredefinedMenuItem::select_all(None),
],
)?;
let start_fullscreen = CheckMenuItem::with_id(
menu_id::START_FULLSCREEN,
"Always Open in Full Screen",
true,
config.start_fullscreen,
None,
);
let view = Submenu::with_items(
"View",
true,
&[
&MenuItem::with_id(
menu_id::RELOAD,
"Reload",
true,
Some(Accelerator::new(Modifiers::META, Code::KeyR)),
),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::fullscreen(None),
&start_fullscreen,
&PredefinedMenuItem::separator(),
&MenuItem::with_id(
menu_id::DEVTOOLS,
"Developer Tools",
true,
Some(Accelerator::new(Modifiers::META | Modifiers::ALT, Code::KeyI)),
),
],
)?;
// Only a Mac with a lit Apple logo has this menu at all.
let mut logo_styles = Vec::new();
if logo::available() {
let submenu = Submenu::new("Blink Apple Logo", true);
for signal in Signal::ALL {
let group = Submenu::new(signal.label(), true);
for style in BlinkStyle::ALL {
let chosen = style == config.logo_blink.get(signal);
let item = CheckMenuItem::with_id(style.menu_id(signal), style.label(), true, chosen, None);
group.append(&item)?;
if style == BlinkStyle::Off {
group.append(&PredefinedMenuItem::separator())?;
}
logo_styles.push((signal, style, item));
}
submenu.append(&group)?;
}
// Right under "Always Open in Full Screen", before the separator.
view.insert(&submenu, 4)?;
}
let window_menu = Submenu::with_items(
"Window",
true,
&[
&PredefinedMenuItem::minimize(None),
&PredefinedMenuItem::maximize(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::close_window(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::bring_all_to_front(None),
],
)?;
let help_menu = Submenu::with_items(
"Help",
true,
&[
&MenuItem::with_id(menu_id::HELP_DOCS, "Codeman Documentation", true, None),
&MenuItem::with_id(menu_id::HELP_GITHUB, "Codeman on GitHub", true, None),
],
)?;
bar.append_items(&[&app_menu, &edit, &view, &window_menu, &help_menu])?;
Ok(AppMenu {
bar,
window_menu,
help_menu,
start_fullscreen,
logo_styles,
})
}
+58 -32
View File
@@ -21,6 +21,7 @@ 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_MESSAGE_LEN: usize = 16 * 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,6 +62,16 @@ impl Phase {
}
}
/// The phase a commit of `committed` in the main window moves to: Loaded once
/// a page from the server that is loading is the document.
pub fn loaded_on_commit(phase: &Phase, committed: &str) -> Option<Phase> {
let Phase::Loading(server) = phase else {
return None;
};
let url = Url::parse(committed).ok()?;
same_origin(&url, server).then(|| Phase::Loaded(server.clone()))
}
/// 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`
@@ -542,12 +553,9 @@ pub fn new_window(target: &str, phase: &Phase, via: Via) -> NewWindow {
if target.len() > MAX_URL_LEN {
return NewWindow::Deny;
}
let Ok(url) = Url::parse(target) else {
let Some(url) = external_url(target) else {
return NewWindow::Deny;
};
if !matches!(url.scheme(), "http" | "https") {
return NewWindow::Deny;
}
let Some(srv) = phase.loaded() else {
return NewWindow::Deny;
};
@@ -631,7 +639,7 @@ pub enum ConnectCmd {
/// Show the agent install that is running already.
AgentAttach,
/// Run the installer on another machine over SSH, in Terminal. `target`
/// is what the user typed; setup::SshTarget::parse checks it.
/// is what the user typed; terminal_install::SshTarget::parse checks it.
InstallRemote {
target: String,
},
@@ -646,7 +654,12 @@ pub enum Ipc {
}
#[derive(Deserialize)]
#[serde(tag = "cmd", rename_all = "camelCase", deny_unknown_fields)]
#[serde(
tag = "cmd",
rename_all = "camelCase",
rename_all_fields = "camelCase",
deny_unknown_fields
)]
enum RawConnect {
Ready,
Connect {
@@ -686,18 +699,15 @@ enum RawConnect {
},
InstallPrepare,
InstallStart {
// rename_all above reaches variant names only, and deny_unknown_fields
// would refuse the page's spelling of these.
#[serde(default, rename = "networkPassword")]
#[serde(default)]
network_password: Option<String>,
#[serde(default, rename = "aiCli")]
#[serde(default)]
ai_cli: Option<String>,
},
InstallAttach,
InstallCancel,
AgentPrepare,
AgentStart {
#[serde(rename = "aiCli")]
ai_cli: String,
},
AgentAttach,
@@ -751,7 +761,7 @@ fn is_connect_page(frame: &Url) -> bool {
/// - A server page is listened to only once it committed, and only from a
/// frame running Codeman's own app shell (trusted_frame).
pub fn parse_ipc(frame_url: &str, body: &str, phase: &Phase, is_main_window: bool) -> Option<Ipc> {
if body.len() > 16 * 1024 {
if body.len() > MAX_MESSAGE_LEN {
return None;
}
let frame = Url::parse(frame_url).ok()?;
@@ -766,17 +776,12 @@ pub fn parse_ipc(frame_url: &str, body: &str, phase: &Phase, is_main_window: boo
username,
password,
remember,
} => {
if url.len() > MAX_URL_LEN {
return None;
}
ConnectCmd::Connect {
url,
username: bounded(username, MAX_FIELD_LEN)?,
password: bounded(password, MAX_FIELD_LEN)?,
remember,
}
}
} if url.len() <= MAX_URL_LEN => ConnectCmd::Connect {
url,
username: bounded(username, MAX_FIELD_LEN)?,
password: bounded(password, MAX_FIELD_LEN)?,
remember,
},
RawConnect::Cancel => ConnectCmd::Cancel,
RawConnect::StartServer { url } if url.len() <= MAX_URL_LEN => ConnectCmd::StartServer { url },
RawConnect::Forget { url } if url.len() <= MAX_URL_LEN => ConnectCmd::Forget { url },
@@ -789,15 +794,10 @@ pub fn parse_ipc(frame_url: &str, body: &str, phase: &Phase, is_main_window: boo
RawConnect::InstallStart {
network_password,
ai_cli,
} => {
if ai_cli.as_deref().is_some_and(|id| !is_cli_id(id)) {
return None;
}
ConnectCmd::InstallStart {
network_password: bounded(network_password, MAX_FIELD_LEN)?.filter(|p| !p.is_empty()),
ai_cli,
}
}
} if ai_cli.as_deref().is_none_or(is_cli_id) => ConnectCmd::InstallStart {
network_password: bounded(network_password, MAX_FIELD_LEN)?.filter(|p| !p.is_empty()),
ai_cli,
},
RawConnect::InstallAttach => ConnectCmd::InstallAttach,
RawConnect::InstallCancel => ConnectCmd::InstallCancel,
RawConnect::AgentPrepare => ConnectCmd::AgentPrepare,
@@ -959,6 +959,32 @@ mod tests {
assert!(!Phase::Loaded(srv).connect_page_live());
}
#[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, 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
);
}
#[test]
fn navigation_policy() {
let srv = u("http://127.0.0.1:3000/");
+20 -10
View File
@@ -2,8 +2,10 @@
//!
//! One `GET <server>/api/status`, five seconds at most, run off the UI thread.
//! The answer is classified into something the connect page can explain. It is
//! asked once per user action, never in a loop: Codeman counts a request
//! without valid credentials against the login rate limit (10 per IP).
//! asked on a user action, or by one of the This Mac tab's waits (for an
//! install to bring Codeman up, for a server that is starting), which end at
//! the first answer, never as a standing poll: Codeman counts a request without
//! valid credentials against the login rate limit (10 per IP).
use serde::Serialize;
use std::time::Duration;
@@ -82,6 +84,16 @@ pub enum ProbeOutcome {
},
}
impl ProbeOutcome {
/// The server's version, when it answered as Codeman.
pub fn version(&self) -> Option<String> {
match self {
ProbeOutcome::Ok { version } => Some(version.clone()),
_ => None,
}
}
}
/// Classify an HTTP answer to `<server>/api/status`. `request` is the URL that
/// was asked, `location` and `retry_after` are those response headers.
pub fn classify_response(
@@ -94,12 +106,10 @@ pub fn classify_response(
) -> ProbeOutcome {
match status {
200 => {
let parsed: Option<serde_json::Value> = serde_json::from_str(body).ok();
let version = parsed.as_ref().and_then(|v| {
(v.get("success")? == &serde_json::Value::Bool(true))
.then(|| v.get("data")?.get("version")?.as_str().map(str::to_string))
.flatten()
});
let version = serde_json::from_str::<serde_json::Value>(body)
.ok()
.filter(|v| v["success"] == true)
.and_then(|v| v["data"]["version"].as_str().map(str::to_string));
match version {
Some(version) => ProbeOutcome::Ok { version },
None => ProbeOutcome::NotCodeman { status },
@@ -742,8 +752,8 @@ mod tests {
}
/// 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.
/// event loop, a lossy link) did not answer in time: a Timeout, not
/// NotCodeman ("something else on this port").
#[test]
fn a_body_that_stalls_is_a_timeout() {
use std::io::{Read, Write};
+29 -566
View File
@@ -1,25 +1,12 @@
//! 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.
//! 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
//! the administrator password on a terminal (Homebrew, its sudo keep-alive),
//! the Xcode tools arrive as a system dialog, and ssh asks about host keys and
//! passwords on a terminal too. So the app writes a small script that deletes itself and
//! hands it to Terminal (`open -b com.apple.Terminal`), which needs no
//! permission, unlike scripting Terminal through osascript (an Automation
//! prompt). Everything the scripts contain is built here and unit-tested; the
//! only text from the user is the SSH login, which `SshTarget::parse` limits to
//! the characters of a user and host name before it is quoted.
//! The connect screen's "This Mac" tab: where the Codeman on this Mac listens,
//! whether it is installed, its login, and which other Codeman servers of this
//! user run here. The install most people get runs in the app
//! (`gui_install.rs`); the Terminal installs are in `terminal_install.rs`.
use std::collections::HashMap;
use std::io::Write;
use std::net::{Ipv4Addr, SocketAddr, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use serde::Serialize;
@@ -29,6 +16,7 @@ use crate::gui_install;
use crate::local_server;
use crate::policy;
use crate::probe::{self, ProbeOutcome};
use crate::terminal_install::LOCAL_SCRIPT_NAME;
/// Codeman's published installer (a redirect to `install.sh` on master). A test
/// build of an installer change sets `CODEMAN_DESKTOP_INSTALLER_URL` when it is
@@ -49,10 +37,9 @@ pub const INSTALL_ONE_LINER: &str = "curl -fsSL https://getcodeman.com/install |
/// Codeman's default port, and the first one a fresh install tries.
pub const DEFAULT_PORT: u16 = 3000;
/// The Terminal scripts' file names (`<name>-<pid>-<n>.command`). The local
/// one is also how `inspect` recognizes an install that is still running.
pub const LOCAL_SCRIPT_NAME: &str = "install-codeman-local";
pub const REMOTE_SCRIPT_NAME: &str = "install-codeman-remote";
/// The username Codeman logs in with when only a password is set
/// (`CODEMAN_USERNAME` unset).
pub const DEFAULT_USERNAME: &str = "admin";
/// How many ports above 3000 a fresh install tries when 3000 is taken.
const PORT_SEARCH: u16 = 100;
@@ -195,10 +182,7 @@ pub fn parse_env_file(text: &str) -> HashMap<String, String> {
let Some((name, value)) = line.split_once('=') else {
continue;
};
let mut chars = name.chars();
let portable = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
if !portable {
if !local_server::is_env_name(name) {
continue;
}
let mut value = value.trim();
@@ -246,13 +230,13 @@ pub fn login_for(
}
if let Some(s) = service {
if let Some(password) = &s.password {
let username = s.username.clone().unwrap_or_else(|| "admin".into());
let username = s.username.clone().unwrap_or_else(|| DEFAULT_USERNAME.into());
return Some((username, password.clone()));
}
}
let from_file = |key: &str| env_file.get(key).filter(|v| !v.is_empty()).cloned();
let password = from_file("CODEMAN_PASSWORD")?;
let username = from_file("CODEMAN_USERNAME").unwrap_or_else(|| "admin".into());
let username = from_file("CODEMAN_USERNAME").unwrap_or_else(|| DEFAULT_USERNAME.into());
Some((username, password))
}
@@ -348,12 +332,17 @@ pub fn is_codeman_server(command: &str) -> bool {
server_args(command).is_some()
}
/// The last path component of a command-line word.
fn basename(word: &str) -> &str {
word.rsplit('/').next().unwrap_or(word)
}
/// 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 name = w.rsplit('/').next().unwrap_or(w);
let name = basename(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
@@ -372,14 +361,14 @@ fn server_args(command: &str) -> Option<Vec<&str>> {
/// the port it asked for once the installer runs.
pub fn install_in_progress(command: &str) -> Option<Option<u16>> {
let words: Vec<&str> = command.split_whitespace().collect();
let name = |w: &&str| w.rsplit('/').next().unwrap_or(w).to_string();
let script = format!("{LOCAL_SCRIPT_NAME}-");
if words.iter().any(|w| {
let n = name(w);
n.starts_with(&format!("{LOCAL_SCRIPT_NAME}-")) && n.ends_with(".command")
let n = basename(w);
n.starts_with(&script) && n.ends_with(".command")
}) {
return Some(None);
}
if words.iter().any(|w| name(w).starts_with("codeman-install.")) || gui_install::Run::Codeman.matches(command) {
if words.iter().any(|w| basename(w).starts_with("codeman-install.")) || gui_install::Run::Codeman.matches(command) {
let port = words
.iter()
.position(|w| *w == "--port")
@@ -552,10 +541,7 @@ fn found_server(url: &Url, answer: &ProbeOutcome, default: &Url) -> FoundServer
};
FoundServer {
url: url.to_string(),
version: match answer {
ProbeOutcome::Ok { version } => Some(version.clone()),
_ => None,
},
version: answer.version(),
state,
is_default: url == default,
}
@@ -715,11 +701,10 @@ pub fn inspect(depth: Depth) -> LocalStatus {
};
let answer = probe::probe(&default, auth.as_deref());
// 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.
// The other addresses this user's Codeman servers listen at, asked once
// each. With nothing at the default address, the first that answers is
// what the tab opens (`shown`), 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 {
@@ -755,10 +740,7 @@ pub fn inspect(depth: Depth) -> LocalStatus {
Depth::Interactive => local_server::installed(true),
};
let state = local_state(&outcome, !servers.is_empty(), !listening.is_empty(), installed);
let version = match &outcome {
ProbeOutcome::Ok { version } => Some(version.clone()),
_ => None,
};
let version = outcome.version();
LocalStatus {
url: url.to_string(),
state,
@@ -896,290 +878,6 @@ fn pick_port(free: impl Fn(u16) -> bool) -> u16 {
.unwrap_or(DEFAULT_PORT)
}
/// `s` as one bash word.
pub fn sh_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
/// What the This Mac install asks `install.sh` for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LocalInstall {
pub port: u16,
/// Let the installer ask how other devices reach Codeman (Tailscale, the
/// local network with a password). Off: this Mac only (`--local`).
pub network: bool,
}
/// `install.sh`'s flags for a This Mac install. Always `--service`: a
/// LaunchAgent runs Codeman, so it keeps running when the app quits and starts
/// again at login, and the app finds it there (read_service). No `--yes`: the
/// user answers the installer's questions (Homebrew, AI coding tools).
pub fn local_install_args(opts: LocalInstall) -> Vec<String> {
let mut args = Vec::new();
if !opts.network {
args.push("--local".to_string());
}
args.push("--service".to_string());
args.push("--port".to_string());
args.push(opts.port.to_string());
args
}
/// The Terminal script for a This Mac install. It downloads the installer to a
/// temporary file and runs it from there, so the installer's input is the
/// terminal itself.
pub fn local_install_script(opts: LocalInstall) -> String {
let args = local_install_args(opts);
let shown = format!("bash install.sh {}", args.join(" "));
let quoted: Vec<String> = args.iter().map(|a| sh_quote(a)).collect();
format!(
r#"#!/bin/bash
rm -f -- "$0"
printf '\033]0;%s\007' 'Install Codeman'
cat <<'CODEMAN_MSG'
Installing Codeman on this Mac.
The installer may ask for your Mac password (it installs Homebrew, Node.js and
tmux when they are missing) and which AI coding tools to set up. Codeman then
runs in the background and starts when you log in, so your sessions keep
running when the app is closed. The Codeman app opens it by itself when the
install is done.
CODEMAN_MSG
tmp=$(mktemp "${{TMPDIR:-/tmp}}/codeman-install.XXXXXX") || exit 1
trap 'rm -f "$tmp"' EXIT
echo {from}
if ! curl -fsSL {url} -o "$tmp"; then
echo
echo 'Could not download the installer. Check the internet connection, then try again from the Codeman app.'
exit 1
fi
echo {shown}
echo
bash "$tmp" {args}
status=$?
echo
if [ "$status" -eq 0 ]; then
echo 'Done. You can close this window.'
else
echo "The installer stopped (exit $status). Its log is ~/.codeman/install.log"
fi
exit "$status"
"#,
from = sh_quote(&format!("Downloading the installer from {INSTALLER_URL}")),
url = sh_quote(INSTALLER_URL),
shown = sh_quote(&format!("$ {shown}")),
args = quoted.join(" "),
)
}
/// A machine to install Codeman on, as ssh takes it: `[user@]host`, plus a
/// `:port` the app passes as `-p`. The host may be a name from `~/.ssh/config`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SshTarget {
pub user: Option<String>,
pub host: String,
pub port: Option<u16>,
}
const SSH_FORMAT: &str =
"Enter the machine the way ssh takes it: user@host (for example kay@build-box), or a name from your ssh config.";
impl SshTarget {
/// Only the characters of a user and host name, never a leading `-` (ssh
/// would read it as an option) and never an IPv6 literal (ambiguous with
/// the port; a host name or an IPv4 address works).
pub fn parse(input: &str) -> Result<Self, String> {
let s = input.trim();
if s.is_empty() || s.len() > 300 {
return Err(SSH_FORMAT.into());
}
let (user, rest) = match s.split_once('@') {
Some((u, r)) => (Some(u), r),
None => (None, s),
};
let (host, port) = match rest.split_once(':') {
Some((h, p)) => {
let port = p.parse::<u16>().ok().filter(|p| *p > 0).ok_or_else(|| {
"The part after the colon must be the SSH port, for example kay@build-box:2222.".to_string()
})?;
(h, Some(port))
}
None => (rest, None),
};
let name_ok = |v: &str, max: usize| {
!v.is_empty()
&& v.len() <= max
&& !v.starts_with(['-', '.'])
&& v.chars().all(|c| c.is_ascii_alphanumeric() || "._-".contains(c))
};
if let Some(u) = user {
if !name_ok(u, 64) {
return Err(SSH_FORMAT.into());
}
}
if !name_ok(host, 253) {
return Err(SSH_FORMAT.into());
}
Ok(SshTarget {
user: user.map(str::to_string),
host: host.to_string(),
port,
})
}
/// `user@host`, or `host`.
pub fn destination(&self) -> String {
match &self.user {
Some(u) => format!("{u}@{}", self.host),
None => self.host.clone(),
}
}
/// The address the connect screen suggests once the install is done: the
/// installer's default port on that host. Tailscale and a sub-path give
/// another address, which the installer prints at the end.
pub fn suggested_url(&self) -> String {
format!("http://{}:{DEFAULT_PORT}", self.host)
}
}
/// What the remote login shell runs. It may be fish or tcsh, so the work is
/// one single-quoted `bash -c` word with nothing in it those shells treat
/// specially (no `!`, `$`, backslash or single quote). `curl | bash` is the
/// documented install and the installer asks its questions on ssh's terminal
/// (`-t`); `pipefail` makes a failed download fail the whole command, where a
/// plain pipe would report bash's success on an empty script. The installer
/// needs bash anyway.
pub fn remote_command() -> String {
format!(
"bash -o pipefail -c 'if command -v curl >/dev/null 2>&1; then curl -fsSL {u} | bash; \
elif command -v wget >/dev/null 2>&1; then wget -qO- {u} | bash; \
else echo \"Codeman needs curl or wget on this machine.\" >&2; exit 1; fi'",
u = INSTALLER_URL
)
}
/// `ssh`'s arguments for an install on `target`: a terminal (`-t`) for the
/// installer's questions, the port, and `--` before the destination.
pub fn ssh_args(target: &SshTarget) -> Vec<String> {
let mut args = vec!["-t".to_string()];
if let Some(port) = target.port {
args.push("-p".into());
args.push(port.to_string());
}
args.push("--".into());
args.push(target.destination());
args.push(remote_command());
args
}
/// The Terminal script for an install on another machine over SSH.
pub fn remote_install_script(target: &SshTarget) -> String {
let args: Vec<String> = ssh_args(target).iter().map(|a| sh_quote(a)).collect();
let dest = target.destination();
format!(
r#"#!/bin/bash
rm -f -- "$0"
printf '\033]0;%s\007' {title}
echo {what}
cat <<'CODEMAN_MSG'
ssh may ask you to confirm the machine and for its password. Then the installer
asks how Codeman should be reached (Tailscale, the local network with a password,
or that machine only) and whether to run it in the background. At the end it
prints the address of Codeman there: connect to it in the Codeman app, under
Another machine.
CODEMAN_MSG
ssh {args}
status=$?
echo
if [ "$status" -eq 0 ]; then
echo 'Done. You can close this window.'
else
echo "ssh or the installer stopped (exit $status)."
fi
exit "$status"
"#,
title = sh_quote(&format!("Install Codeman on {}", target.host)),
what = sh_quote(&format!("Installing Codeman on {dest} over SSH.")),
args = args.join(" "),
)
}
static SCRIPT_COUNTER: AtomicU64 = AtomicU64::new(0);
/// Run `script` in a new Terminal window. The script file is private to this
/// user (the per-user temporary folder, mode 0700, created fresh) and deletes
/// itself as it starts.
#[cfg(target_os = "macos")]
pub fn open_in_terminal(name: &str, script: &str) -> Result<(), String> {
use std::os::unix::fs::OpenOptionsExt;
let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("{name}-{}-{n}.command", std::process::id()));
let written = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o700)
.open(&path)
.and_then(|mut f| f.write_all(script.as_bytes()));
if let Err(e) = written {
let _ = std::fs::remove_file(&path);
return Err(format!("Could not write the install script: {e}"));
}
let out = Command::new("/usr/bin/open")
.args(["-b", "com.apple.Terminal"])
.arg(&path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.output();
match out {
Ok(o) if o.status.success() => Ok(()),
Ok(o) => {
let _ = std::fs::remove_file(&path);
let said = String::from_utf8_lossy(&o.stderr).trim().to_string();
Err(if said.is_empty() {
"Could not open Terminal.".into()
} else {
format!("Could not open Terminal: {said}")
})
}
Err(e) => {
let _ = std::fs::remove_file(&path);
Err(format!("Could not open Terminal: {e}"))
}
}
}
#[cfg(not(target_os = "macos"))]
pub fn open_in_terminal(_name: &str, _script: &str) -> Result<(), String> {
let _ = &SCRIPT_COUNTER;
Err("Installing from the app is only available on macOS so far.".into())
}
/// Put `text` on the clipboard (`pbcopy`). The connect page is not a secure
/// context, so it cannot use the web clipboard API itself.
pub fn copy_to_clipboard(text: &str) -> Result<(), String> {
let mut child = Command::new("/usr/bin/pbcopy")
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Could not copy: {e}"))?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(text.as_bytes())
.map_err(|e| format!("Could not copy: {e}"))?;
}
match child.wait() {
Ok(s) if s.success() => Ok(()),
_ => Err("Could not copy the command.".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1217,8 +915,7 @@ mod tests {
#[cfg(target_os = "macos")]
#[test]
fn plutil_reads_both_launch_agent_shapes() {
let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("codeman-plist-test-{}-{n}", std::process::id()));
let dir = std::env::temp_dir().join(format!("codeman-plist-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let install_sh = r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -1850,7 +1547,7 @@ mod tests {
/// the answers come back in the order of the addresses.
#[test]
fn found_servers_are_asked_together() {
use std::io::Read;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;
@@ -1923,238 +1620,4 @@ mod tests {
assert_eq!(pick_port(|p| p >= 3002), 3002);
assert_eq!(pick_port(|_| false), 3000);
}
#[test]
fn quoting() {
assert_eq!(sh_quote("plain"), "'plain'");
assert_eq!(sh_quote("it's"), r"'it'\''s'");
assert_eq!(sh_quote(""), "''");
}
#[test]
fn local_install_flags() {
assert_eq!(
local_install_args(LocalInstall {
port: 3000,
network: false
}),
["--local", "--service", "--port", "3000"]
);
assert_eq!(
local_install_args(LocalInstall {
port: 3007,
network: true
}),
["--service", "--port", "3007"]
);
}
/// Run a script under bash with `curl` and `bash`... replaced, and report
/// what it did. The real installer never runs in a test.
#[cfg(unix)]
fn run_script(script: &str, fakes: &[(&str, &str)]) -> (String, i32, bool) {
use std::os::unix::fs::PermissionsExt;
let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("codeman-setup-test-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("bin")).unwrap();
for (name, body) in fakes {
let p = dir.join("bin").join(name);
std::fs::write(&p, body.replace("DIR", &dir.display().to_string())).unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let file = dir.join("run.command");
std::fs::write(&file, script).unwrap();
let out = Command::new("/bin/bash")
.arg(&file)
.env("PATH", format!("{}:/usr/bin:/bin", dir.join("bin").display()))
.env("TMPDIR", &dir)
.stdin(Stdio::null())
.output()
.unwrap();
let log = std::fs::read_to_string(dir.join("log")).unwrap_or_default();
let self_deleted = !file.exists();
let _ = std::fs::remove_dir_all(&dir);
(log, out.status.code().unwrap_or(-1), self_deleted)
}
#[cfg(unix)]
#[test]
fn local_script_downloads_then_runs_the_installer_with_its_flags() {
let script = local_install_script(LocalInstall {
port: 3001,
network: false,
});
let (log, code, self_deleted) = run_script(
&script,
&[(
"curl",
r#"#!/bin/sh
echo "curl $*" >> DIR/log
out=""
while [ $# -gt 0 ]; do [ "$1" = -o ] && out="$2"; shift; done
cat > "$out" <<'INSTALLER'
for a in "$@"; do printf '[%s]' "$a" >> DIR/log; done
echo >> DIR/log
exit 7
INSTALLER
"#,
)],
);
assert!(self_deleted, "the script must delete itself");
assert!(
log.starts_with("curl -fsSL https://getcodeman.com/install -o "),
"{log}"
);
assert!(log.contains("\n[--local][--service][--port][3001]\n"), "{log}");
assert_eq!(code, 7, "the installer's exit status is the script's");
}
#[cfg(unix)]
#[test]
fn local_script_stops_when_the_download_fails() {
let script = local_install_script(LocalInstall {
port: 3000,
network: true,
});
let (log, code, _) = run_script(&script, &[("curl", "#!/bin/sh\necho curl >> DIR/log\nexit 22\n")]);
assert_eq!(log, "curl\n");
assert_eq!(code, 1);
}
#[test]
fn ssh_targets() {
assert_eq!(
SshTarget::parse(" kay@build-box ").unwrap(),
SshTarget {
user: Some("kay".into()),
host: "build-box".into(),
port: None
}
);
assert_eq!(
SshTarget::parse("pi@192.168.1.20:2222").unwrap(),
SshTarget {
user: Some("pi".into()),
host: "192.168.1.20".into(),
port: Some(2222)
}
);
assert_eq!(SshTarget::parse("macmini").unwrap().destination(), "macmini");
assert_eq!(
SshTarget::parse("a.b@tnode.tailnet.ts.net").unwrap().destination(),
"a.b@tnode.tailnet.ts.net"
);
for bad in [
"",
"-oProxyCommand=touch /tmp/x",
"kay@-oProxyCommand=x",
"-l@host",
"kay@host name",
"kay@host;rm",
"kay@host$(x)",
"k'y@host",
"kay@",
"@host",
"kay@host:",
"kay@host:0",
"kay@host:x",
"kay@fe80::1",
"kay@[::1]",
"a@b@c",
".hidden",
] {
assert!(SshTarget::parse(bad).is_err(), "accepted {bad:?}");
}
assert_eq!(SshTarget::parse("kay@box").unwrap().suggested_url(), "http://box:3000");
}
#[test]
fn ssh_arguments() {
let t = SshTarget::parse("kay@box:2222").unwrap();
let args = ssh_args(&t);
assert_eq!(&args[..5], ["-t", "-p", "2222", "--", "kay@box"]);
assert_eq!(args.len(), 6);
let remote = &args[5];
let head = "bash -o pipefail -c '";
assert!(remote.starts_with(head) && remote.ends_with('\''));
// One word for fish and tcsh: no quote inside, nothing they expand.
let inner = &remote[head.len()..remote.len() - 1];
assert!(!inner.contains(['\'', '!', '$', '\\', '`']), "{inner}");
assert!(inner.contains("curl -fsSL https://getcodeman.com/install | bash"));
assert_eq!(ssh_args(&SshTarget::parse("box").unwrap())[..3], ["-t", "--", "box"]);
}
#[cfg(unix)]
#[test]
fn remote_script_runs_ssh_with_exactly_those_arguments() {
let t = SshTarget::parse("kay@box:2222").unwrap();
let (log, code, self_deleted) = run_script(
&remote_install_script(&t),
&[(
"ssh",
"#!/bin/sh\nfor a in \"$@\"; do printf '[%s]' \"$a\" >> DIR/log; done\necho >> DIR/log\nexit 3\n",
)],
);
assert!(self_deleted);
let expected: String = ssh_args(&t).iter().map(|a| format!("[{a}]")).collect();
assert_eq!(log, format!("{expected}\n"));
assert_eq!(code, 3);
}
/// The remote command does what it says under a POSIX shell: curl first,
/// wget without curl, and a clear error with neither.
#[cfg(unix)]
#[test]
fn remote_command_picks_a_downloader() {
use std::os::unix::fs::PermissionsExt;
let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("codeman-remote-cmd-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("bin")).unwrap();
let exe = |name: &str, body: &str| {
let p = dir.join("bin").join(name);
std::fs::write(&p, body).unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
};
// The outer `bash -o pipefail` is the real one; the `bash` the
// download is piped into only reports what it was fed.
exe(
"bash",
"#!/bin/sh\nif [ \"$1\" = -o ]; then exec /bin/bash \"$@\"; fi\nprintf 'bash got: '; /bin/cat\n",
);
exe("wget", "#!/bin/sh\necho \"wget $*\"\n");
let run = || {
let out = Command::new("/bin/sh")
.arg("-c")
.arg(remote_command())
.env("PATH", dir.join("bin"))
.output()
.unwrap();
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.code(),
)
};
let (out, _, _) = run();
assert_eq!(out, "bash got: wget -qO- https://getcodeman.com/install\n");
exe("curl", "#!/bin/sh\necho \"curl $*\"\n");
let (out, _, _) = run();
assert_eq!(out, "bash got: curl -fsSL https://getcodeman.com/install\n");
// A failed download fails the command (pipefail), not "Done".
exe(
"curl",
"#!/bin/sh\necho 'curl: (6) Could not resolve host' >&2\nexit 6\n",
);
let (_, _, code) = run();
assert_ne!(code, Some(0));
std::fs::remove_file(dir.join("bin/curl")).unwrap();
std::fs::remove_file(dir.join("bin/wget")).unwrap();
let (out, err, code) = run();
assert_eq!(out, "");
assert!(err.contains("needs curl or wget"), "{err}");
assert_eq!(code, Some(1));
let _ = std::fs::remove_dir_all(&dir);
}
}
+547
View File
@@ -0,0 +1,547 @@
//! The installs that run in Terminal: on this Mac, and on another machine over
//! SSH.
//!
//! The Terminal route is for what the in-app install (`gui_install.rs`) cannot
//! do. `install.sh` asks for the administrator password on a terminal
//! (Homebrew, its sudo keep-alive), the Xcode tools arrive as a system dialog,
//! and ssh asks about host keys and passwords on a terminal too. So the app
//! writes a small script that deletes itself and hands it to Terminal
//! (`open -b com.apple.Terminal`), which needs no permission, unlike scripting
//! Terminal through osascript (an Automation prompt). Everything the scripts
//! contain is built here and unit-tested; the only text from the user is the
//! SSH login, which `SshTarget::parse` limits to the characters of a user and
//! host name before it is quoted.
use std::io::Write;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use crate::setup::{DEFAULT_PORT, INSTALLER_URL};
/// The Terminal scripts' file names (`<name>-<pid>-<n>.command`). The local
/// one is also how `inspect` recognizes an install that is still running.
pub const LOCAL_SCRIPT_NAME: &str = "install-codeman-local";
pub const REMOTE_SCRIPT_NAME: &str = "install-codeman-remote";
/// `s` as one bash word.
pub fn sh_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
/// What the This Mac install asks `install.sh` for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LocalInstall {
pub port: u16,
/// Let the installer ask how other devices reach Codeman (Tailscale, the
/// local network with a password). Off: this Mac only (`--local`).
pub network: bool,
}
/// `install.sh`'s flags for a This Mac install. Always `--service`: a
/// LaunchAgent runs Codeman, so it keeps running when the app quits and starts
/// again at login, and the app finds it there (read_service). No `--yes`: the
/// user answers the installer's questions (Homebrew, AI coding tools).
pub fn local_install_args(opts: LocalInstall) -> Vec<String> {
let mut args = Vec::new();
if !opts.network {
args.push("--local".to_string());
}
args.push("--service".to_string());
args.push("--port".to_string());
args.push(opts.port.to_string());
args
}
/// The Terminal script for a This Mac install. It downloads the installer to a
/// temporary file and runs it from there, so the installer's input is the
/// terminal itself.
pub fn local_install_script(opts: LocalInstall) -> String {
let args = local_install_args(opts);
let shown = format!("bash install.sh {}", args.join(" "));
let quoted: Vec<String> = args.iter().map(|a| sh_quote(a)).collect();
format!(
r#"#!/bin/bash
rm -f -- "$0"
printf '\033]0;%s\007' 'Install Codeman'
cat <<'CODEMAN_MSG'
Installing Codeman on this Mac.
The installer may ask for your Mac password (it installs Homebrew, Node.js and
tmux when they are missing) and which AI coding tools to set up. Codeman then
runs in the background and starts when you log in, so your sessions keep
running when the app is closed. The Codeman app opens it by itself when the
install is done.
CODEMAN_MSG
tmp=$(mktemp "${{TMPDIR:-/tmp}}/codeman-install.XXXXXX") || exit 1
trap 'rm -f "$tmp"' EXIT
echo {from}
if ! curl -fsSL {url} -o "$tmp"; then
echo
echo 'Could not download the installer. Check the internet connection, then try again from the Codeman app.'
exit 1
fi
echo {shown}
echo
bash "$tmp" {args}
status=$?
echo
if [ "$status" -eq 0 ]; then
echo 'Done. You can close this window.'
else
echo "The installer stopped (exit $status). Its log is ~/.codeman/install.log"
fi
exit "$status"
"#,
from = sh_quote(&format!("Downloading the installer from {INSTALLER_URL}")),
url = sh_quote(INSTALLER_URL),
shown = sh_quote(&format!("$ {shown}")),
args = quoted.join(" "),
)
}
/// A machine to install Codeman on, as ssh takes it: `[user@]host`, plus a
/// `:port` the app passes as `-p`. The host may be a name from `~/.ssh/config`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SshTarget {
pub user: Option<String>,
pub host: String,
pub port: Option<u16>,
}
const SSH_FORMAT: &str =
"Enter the machine the way ssh takes it: user@host (for example kay@build-box), or a name from your ssh config.";
impl SshTarget {
/// Only the characters of a user and host name, never a leading `-` (ssh
/// would read it as an option) and never an IPv6 literal (ambiguous with
/// the port; a host name or an IPv4 address works).
pub fn parse(input: &str) -> Result<Self, String> {
let s = input.trim();
if s.is_empty() || s.len() > 300 {
return Err(SSH_FORMAT.into());
}
let (user, rest) = match s.split_once('@') {
Some((u, r)) => (Some(u), r),
None => (None, s),
};
let (host, port) = match rest.split_once(':') {
Some((h, p)) => {
let port = p.parse::<u16>().ok().filter(|p| *p > 0).ok_or_else(|| {
"The part after the colon must be the SSH port, for example kay@build-box:2222.".to_string()
})?;
(h, Some(port))
}
None => (rest, None),
};
let name_ok = |v: &str, max: usize| {
!v.is_empty()
&& v.len() <= max
&& !v.starts_with(['-', '.'])
&& v.chars().all(|c| c.is_ascii_alphanumeric() || "._-".contains(c))
};
if let Some(u) = user {
if !name_ok(u, 64) {
return Err(SSH_FORMAT.into());
}
}
if !name_ok(host, 253) {
return Err(SSH_FORMAT.into());
}
Ok(SshTarget {
user: user.map(str::to_string),
host: host.to_string(),
port,
})
}
/// `user@host`, or `host`.
pub fn destination(&self) -> String {
match &self.user {
Some(u) => format!("{u}@{}", self.host),
None => self.host.clone(),
}
}
/// The address the connect screen suggests once the install is done: the
/// installer's default port on that host. Tailscale and a sub-path give
/// another address, which the installer prints at the end.
pub fn suggested_url(&self) -> String {
format!("http://{}:{DEFAULT_PORT}", self.host)
}
}
/// What the remote login shell runs. It may be fish or tcsh, so the work is
/// one single-quoted `bash -c` word with nothing in it those shells treat
/// specially (no `!`, `$`, backslash or single quote). `curl | bash` is the
/// documented install and the installer asks its questions on ssh's terminal
/// (`-t`); `pipefail` makes a failed download fail the whole command, where a
/// plain pipe would report bash's success on an empty script. The installer
/// needs bash anyway.
pub fn remote_command() -> String {
format!(
"bash -o pipefail -c 'if command -v curl >/dev/null 2>&1; then curl -fsSL {u} | bash; \
elif command -v wget >/dev/null 2>&1; then wget -qO- {u} | bash; \
else echo \"Codeman needs curl or wget on this machine.\" >&2; exit 1; fi'",
u = INSTALLER_URL
)
}
/// `ssh`'s arguments for an install on `target`: a terminal (`-t`) for the
/// installer's questions, the port, and `--` before the destination.
pub fn ssh_args(target: &SshTarget) -> Vec<String> {
let mut args = vec!["-t".to_string()];
if let Some(port) = target.port {
args.push("-p".into());
args.push(port.to_string());
}
args.push("--".into());
args.push(target.destination());
args.push(remote_command());
args
}
/// The Terminal script for an install on another machine over SSH.
pub fn remote_install_script(target: &SshTarget) -> String {
let args: Vec<String> = ssh_args(target).iter().map(|a| sh_quote(a)).collect();
let dest = target.destination();
format!(
r#"#!/bin/bash
rm -f -- "$0"
printf '\033]0;%s\007' {title}
echo {what}
cat <<'CODEMAN_MSG'
ssh may ask you to confirm the machine and for its password. Then the installer
asks how Codeman should be reached (Tailscale, the local network with a password,
or that machine only) and whether to run it in the background. At the end it
prints the address of Codeman there: connect to it in the Codeman app, under
Another machine.
CODEMAN_MSG
ssh {args}
status=$?
echo
if [ "$status" -eq 0 ]; then
echo 'Done. You can close this window.'
else
echo "ssh or the installer stopped (exit $status)."
fi
exit "$status"
"#,
title = sh_quote(&format!("Install Codeman on {}", target.host)),
what = sh_quote(&format!("Installing Codeman on {dest} over SSH.")),
args = args.join(" "),
)
}
static SCRIPT_COUNTER: AtomicU64 = AtomicU64::new(0);
/// Run `script` in a new Terminal window. The script file is private to this
/// user (the per-user temporary folder, mode 0700, created fresh) and deletes
/// itself as it starts.
#[cfg(target_os = "macos")]
pub fn open_in_terminal(name: &str, script: &str) -> Result<(), String> {
use std::os::unix::fs::OpenOptionsExt;
let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("{name}-{}-{n}.command", std::process::id()));
let written = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o700)
.open(&path)
.and_then(|mut f| f.write_all(script.as_bytes()));
if let Err(e) = written {
let _ = std::fs::remove_file(&path);
return Err(format!("Could not write the install script: {e}"));
}
let out = Command::new("/usr/bin/open")
.args(["-b", "com.apple.Terminal"])
.arg(&path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.output();
match out {
Ok(o) if o.status.success() => Ok(()),
Ok(o) => {
let _ = std::fs::remove_file(&path);
let said = String::from_utf8_lossy(&o.stderr).trim().to_string();
Err(if said.is_empty() {
"Could not open Terminal.".into()
} else {
format!("Could not open Terminal: {said}")
})
}
Err(e) => {
let _ = std::fs::remove_file(&path);
Err(format!("Could not open Terminal: {e}"))
}
}
}
#[cfg(not(target_os = "macos"))]
pub fn open_in_terminal(_name: &str, _script: &str) -> Result<(), String> {
let _ = &SCRIPT_COUNTER;
Err("Installing from the app is only available on macOS so far.".into())
}
/// Put `text` on the clipboard (`pbcopy`). The connect page is not a secure
/// context, so it cannot use the web clipboard API itself.
pub fn copy_to_clipboard(text: &str) -> Result<(), String> {
let mut child = Command::new("/usr/bin/pbcopy")
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Could not copy: {e}"))?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(text.as_bytes())
.map_err(|e| format!("Could not copy: {e}"))?;
}
match child.wait() {
Ok(s) if s.success() => Ok(()),
_ => Err("Could not copy the command.".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quoting() {
assert_eq!(sh_quote("plain"), "'plain'");
assert_eq!(sh_quote("it's"), r"'it'\''s'");
assert_eq!(sh_quote(""), "''");
}
#[test]
fn local_install_flags() {
assert_eq!(
local_install_args(LocalInstall {
port: 3000,
network: false
}),
["--local", "--service", "--port", "3000"]
);
assert_eq!(
local_install_args(LocalInstall {
port: 3007,
network: true
}),
["--service", "--port", "3007"]
);
}
/// Run a script under bash with `curl` and `bash`... replaced, and report
/// what it did. The real installer never runs in a test.
#[cfg(unix)]
fn run_script(script: &str, fakes: &[(&str, &str)]) -> (String, i32, bool) {
use std::os::unix::fs::PermissionsExt;
let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("codeman-setup-test-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("bin")).unwrap();
for (name, body) in fakes {
let p = dir.join("bin").join(name);
std::fs::write(&p, body.replace("DIR", &dir.display().to_string())).unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let file = dir.join("run.command");
std::fs::write(&file, script).unwrap();
let out = Command::new("/bin/bash")
.arg(&file)
.env("PATH", format!("{}:/usr/bin:/bin", dir.join("bin").display()))
.env("TMPDIR", &dir)
.stdin(Stdio::null())
.output()
.unwrap();
let log = std::fs::read_to_string(dir.join("log")).unwrap_or_default();
let self_deleted = !file.exists();
let _ = std::fs::remove_dir_all(&dir);
(log, out.status.code().unwrap_or(-1), self_deleted)
}
#[cfg(unix)]
#[test]
fn local_script_downloads_then_runs_the_installer_with_its_flags() {
let script = local_install_script(LocalInstall {
port: 3001,
network: false,
});
let (log, code, self_deleted) = run_script(
&script,
&[(
"curl",
r#"#!/bin/sh
echo "curl $*" >> DIR/log
out=""
while [ $# -gt 0 ]; do [ "$1" = -o ] && out="$2"; shift; done
cat > "$out" <<'INSTALLER'
for a in "$@"; do printf '[%s]' "$a" >> DIR/log; done
echo >> DIR/log
exit 7
INSTALLER
"#,
)],
);
assert!(self_deleted, "the script must delete itself");
assert!(
log.starts_with("curl -fsSL https://getcodeman.com/install -o "),
"{log}"
);
assert!(log.contains("\n[--local][--service][--port][3001]\n"), "{log}");
assert_eq!(code, 7, "the installer's exit status is the script's");
}
#[cfg(unix)]
#[test]
fn local_script_stops_when_the_download_fails() {
let script = local_install_script(LocalInstall {
port: 3000,
network: true,
});
let (log, code, _) = run_script(&script, &[("curl", "#!/bin/sh\necho curl >> DIR/log\nexit 22\n")]);
assert_eq!(log, "curl\n");
assert_eq!(code, 1);
}
#[test]
fn ssh_targets() {
assert_eq!(
SshTarget::parse(" kay@build-box ").unwrap(),
SshTarget {
user: Some("kay".into()),
host: "build-box".into(),
port: None
}
);
assert_eq!(
SshTarget::parse("pi@192.168.1.20:2222").unwrap(),
SshTarget {
user: Some("pi".into()),
host: "192.168.1.20".into(),
port: Some(2222)
}
);
assert_eq!(SshTarget::parse("macmini").unwrap().destination(), "macmini");
assert_eq!(
SshTarget::parse("a.b@tnode.tailnet.ts.net").unwrap().destination(),
"a.b@tnode.tailnet.ts.net"
);
for bad in [
"",
"-oProxyCommand=touch /tmp/x",
"kay@-oProxyCommand=x",
"-l@host",
"kay@host name",
"kay@host;rm",
"kay@host$(x)",
"k'y@host",
"kay@",
"@host",
"kay@host:",
"kay@host:0",
"kay@host:x",
"kay@fe80::1",
"kay@[::1]",
"a@b@c",
".hidden",
] {
assert!(SshTarget::parse(bad).is_err(), "accepted {bad:?}");
}
assert_eq!(SshTarget::parse("kay@box").unwrap().suggested_url(), "http://box:3000");
}
#[test]
fn ssh_arguments() {
let t = SshTarget::parse("kay@box:2222").unwrap();
let args = ssh_args(&t);
assert_eq!(&args[..5], ["-t", "-p", "2222", "--", "kay@box"]);
assert_eq!(args.len(), 6);
let remote = &args[5];
let head = "bash -o pipefail -c '";
assert!(remote.starts_with(head) && remote.ends_with('\''));
// One word for fish and tcsh: no quote inside, nothing they expand.
let inner = &remote[head.len()..remote.len() - 1];
assert!(!inner.contains(['\'', '!', '$', '\\', '`']), "{inner}");
assert!(inner.contains("curl -fsSL https://getcodeman.com/install | bash"));
assert_eq!(ssh_args(&SshTarget::parse("box").unwrap())[..3], ["-t", "--", "box"]);
}
#[cfg(unix)]
#[test]
fn remote_script_runs_ssh_with_exactly_those_arguments() {
let t = SshTarget::parse("kay@box:2222").unwrap();
let (log, code, self_deleted) = run_script(
&remote_install_script(&t),
&[(
"ssh",
"#!/bin/sh\nfor a in \"$@\"; do printf '[%s]' \"$a\" >> DIR/log; done\necho >> DIR/log\nexit 3\n",
)],
);
assert!(self_deleted);
let expected: String = ssh_args(&t).iter().map(|a| format!("[{a}]")).collect();
assert_eq!(log, format!("{expected}\n"));
assert_eq!(code, 3);
}
/// The remote command does what it says under a POSIX shell: curl first,
/// wget without curl, and a clear error with neither.
#[cfg(unix)]
#[test]
fn remote_command_picks_a_downloader() {
use std::os::unix::fs::PermissionsExt;
let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("codeman-remote-cmd-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("bin")).unwrap();
let exe = |name: &str, body: &str| {
let p = dir.join("bin").join(name);
std::fs::write(&p, body).unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
};
// The outer `bash -o pipefail` is the real one; the `bash` the
// download is piped into only reports what it was fed.
exe(
"bash",
"#!/bin/sh\nif [ \"$1\" = -o ]; then exec /bin/bash \"$@\"; fi\nprintf 'bash got: '; /bin/cat\n",
);
exe("wget", "#!/bin/sh\necho \"wget $*\"\n");
let run = || {
let out = Command::new("/bin/sh")
.arg("-c")
.arg(remote_command())
.env("PATH", dir.join("bin"))
.output()
.unwrap();
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.code(),
)
};
let (out, _, _) = run();
assert_eq!(out, "bash got: wget -qO- https://getcodeman.com/install\n");
exe("curl", "#!/bin/sh\necho \"curl $*\"\n");
let (out, _, _) = run();
assert_eq!(out, "bash got: curl -fsSL https://getcodeman.com/install\n");
// A failed download fails the command (pipefail), not "Done".
exe(
"curl",
"#!/bin/sh\necho 'curl: (6) Could not resolve host' >&2\nexit 6\n",
);
let (_, _, code) = run();
assert_ne!(code, Some(0));
std::fs::remove_file(dir.join("bin/curl")).unwrap();
std::fs::remove_file(dir.join("bin/wget")).unwrap();
let (out, err, code) = run();
assert_eq!(out, "");
assert!(err.contains("needs curl or wget"), "{err}");
assert_eq!(code, Some(1));
let _ = std::fs::remove_dir_all(&dir);
}
}
+118 -25
View File
@@ -24,8 +24,9 @@
* throws on a bad row. test/local-model-catalog.test.ts pins that the stock constant parses,
* so a bad row fails the gate and never reaches the route.
*
* Where each figure of the stock rows comes from, and what was checked (2026-11-08). [V] read
* at the source that day, [M] measured on a Mac, [C] computed from [V] inputs:
* Where each figure of the stock rows comes from, and what was checked (2026-11-08; the two rows
* added on 2026-11-10, `ornith:9b-q4_k_m` and `muse-glimmer:30b-q4_k_m`, were read that day).
* [V] read at the source that day, [M] measured on a Mac, [C] computed from [V] inputs:
*
* | Figure | Source | Checked |
* |----------------------|-----------------------------------------------------------|--------------------------------------|
@@ -74,8 +75,10 @@
* src/local-model/runtime.ts, q8_0: 34 bytes per 32 values), and does not grow after that.
* "MTP" below is a GGUF with a multi-token-prediction layer (`nextn_predict_layers` 1 in
* its header, read on 2026-11-08 from the first bytes of each model layer: the two `-mtp`
* tags and `qwen3.8:27b-q8_0` have it, the two 9B tags and North Mini Code do not). For
* such a model the runtime always passes `--spec-type draft-mtp --spec-draft-n-max N`, N
* tags and `qwen3.8:27b-q8_0` have it, the two 9B tags and North Mini Code do not; read
* the same way on 2026-11-10, `ornith:9b-q4_k_m` and `muse-glimmer:30b-q4_k_m` do not
* either). For such a model the runtime always passes
* `--spec-type draft-mtp --spec-draft-n-max N`, N
* being the tag's own `draft_num_predict` (its params layer: 4 for
* `qwen3.8:27b-mtp-q4_k_m`, 2 for `qwen3.6:35b-a3b-mtp-q4_k_m`) or the runtime's default
* of 4 (`qwen3.8:27b-q8_0` sets none).
@@ -83,9 +86,11 @@
* adds its prediction layer, whose cache llama-server keeps in a draft context of the
* same length in the draft's own cache type, f16 by default (2 bytes per value).
* - overhead = the fixed caches (the f32 recurrent state of the linear-attention layers,
* or the sliding-window cache of `n_swa + 512` cells), held 1 + N times for an MTP
* model (llama.cpp keeps one recurrent state per drafted token for rollback,
* `need_n_rs_seq()`), + the context checkpoints: `LLAMA_ARG_CTX_CHECKPOINTS` host copies
* or the sliding-window cache of `n_swa + 512` cells: `size_swa` in
* src/llama-kv-cache-iswa.cpp, the cache every model with a sliding window gets), held
* 1 + N times for an MTP model (llama.cpp keeps one recurrent state per drafted token
* for rollback, `need_n_rs_seq()`), + the context checkpoints:
* `LLAMA_ARG_CTX_CHECKPOINTS` host copies
* (`RUNTIME_CTX_CHECKPOINTS`, 8) of the state that cannot be rolled back (the recurrent
* state, or the sliding-window cache), + 1.0 GB for the runner's own buffers (calibrated
* on a third-party table of a 27B model on llama.cpp: 8.4 GB at 4,096 tokens with
@@ -125,26 +130,59 @@
* checked. For an `mlx` row it proves only the weights (issue 19417): the figures above are
* all that stands between such a row and swap.
*
* Models set aside on purpose (a weekly review should look at them again):
* Models set aside on purpose (a weekly review should look at them again). Every reason was
* read again at its source on 2026-11-10:
* - `ripple-preview:65b` (Ricursive, Apache-2.0, a dense 65B published on 2026-11-09;
* Terminal-Bench 2.1 80.5 and SWE-bench Verified 76.5 on its card; 38.8 GB as a GGUF build,
* 45.8 GB as the MLX build a Mac pulls): every build requires runtime 0.40.16, one above
* the pin. Computed from its config.json by the llamacpp rule, its GGUF build would be the
* first row between the 27B and the 122B (47 GB at 131,072 tokens: 64 and 96 GB Macs, slow
* below an Ultra chip). Look at it the day the pin moves.
* - `bonsai-2:27b`, `bonsai-2:9b` (ternary builds of Qwen3.8-27B and Qwen3.5-9B; the 27B
* would make a 24 GB Mac `ok`): the vendor reports no SWE-bench and no agent loop, and
* whether 2-bit weights keep tool calls reliable was never measured.
* would make a 24 GB Mac `ok`): the vendor reports no SWE-bench and no agent loop (its card
* of 2026-11-04 still lists BFCL v3 only), and whether 2-bit weights keep tool calls
* reliable was never measured.
* - `ornith-1.5:9b`, `ornith-1.5:35b`, `ornith-1.5:397b` (GGUF only): strong cards, but their
* manifests name no renderer and no tool parser, so whether tool calls work through the
* runtime is unknown (runtime pull request 17965 is open). `ornith-1.5:122b` has both.
* runtime is unknown (runtime pull request 17965 is still open). `ornith-1.5:122b` has
* both.
* - `ornith:35b` (Ornith 1.0 35B-A3B, MIT, with a renderer and a tool parser; SWE-bench
* Verified 75.6 against 73.4 for Qwen3.6 35B-A3B on Ornith's card): runtime issue 16992,
* open, reports its tool calls arriving as text in long agent chains, and its size is
* served by the pair we ran. Its 9B sibling `ornith:9b-q4_k_m` is listed: see that row
* for why.
* - `gpt-oss:20b`: weak in agentic coding (SWE-bench Verified 52.4, Terminal-Bench 2.1 15.2
* in NVIDIA's table).
* - `flint:40b`: runtime issue 19191 breaks integer tool arguments after the first call.
* - `flint:40b`: runtime issue 19191 (open) breaks integer tool arguments after the first
* call.
* - `lfm2.5:24b-a2b`: its licence allows commercial use only below USD 10M of revenue.
* - `laguna-xs-2.1`: the runtime's own page names an open problem with it on macOS.
* `laguna-s-2.1`: open runtime issues 17602 and 17632.
* - `qwen3.8-flash-next:125b`: the Qwen Community License, not an open licence.
* - `qwen3.8-flash-next:125b`: the Qwen Community License, not an open licence, and its
* builds require runtime 0.40.16 since 2026-11-09.
* - `nemotron-3.5-super:120b` (79.6 GB as the MLX build a Mac pulls): Terminal-Bench 2.1
* 60.7 and SWE-bench Verified 71.1 in NVIDIA's table, below `ornith-1.5:122b` at a larger
* download, so it adds no choice a 128 GB Mac would take.
* - `qwen3.5:4b-q4_k_m` (3.3 GB, it would fit a 16 GB Mac with room): runtime issue 18916,
* open, has it answer with thinking only in a long conversation at 65,536 tokens.
* - `qwen3-coder-next` (51.7 GB): the generation before Qwen3.6. Its card shows its results
* as pictures only, so there is no figure to rank it by against the rows listed here.
* - `muse-glimmer:30b-mlx` and the `-dflash` tags of that model: open runtime issues 17656
* and 18269 on the MLX build, and no rule here covers the memory of a separate draft
* model. Its GGUF build `muse-glimmer:30b-q4_k_m` is listed.
* - The plain tags `qwen3.5:9b`, `mimo-v2.6:9b` and `qwen3.8:27b`: a Mac pulls their MLX
* builds, whose 8 GiB of prefix snapshots do not fit the Macs a 9B or a 27B is for. Their
* GGUF builds are listed instead.
* - `qwen3.5:122b-a10b`: replaced by `ornith-1.5:122b`, a fine-tune of it (SWE-bench
* Verified 84.5 against 72.0) whose MLX build carries a renderer and a tool parser.
*
* One open issue touches rows that ARE listed: runtime issue 16992 (above). Its one diagnosed
* cause sits in the tool parser itself (it closes a call at the first closing tag, so a tool
* argument that quotes a whole tool call is cut off; the fix, runtime pull request 17021, is
* open). The names `ornith` and `qwen3.5` map to that one parser in the pinned runtime
* (model/parsers/parsers.go), so every Qwen-family row here shares it, the pair we ran
* included. The check after a download asks for one plain tool call and cannot see this.
*
* `tested` has one entry: `qwen3.6:35b-a3b` with OpenCode on an Apple M5 Pro with 48 GB,
* 2026-11-06, 67.7 tokens per second. Only a Mac of exactly that chip and memory size is told
* "Recommended" and shown that speed in place of the estimate. Every other row was never run
@@ -336,7 +374,7 @@ const MLX_PREFIX_CACHE_BYTES = 8 * 1024 * 1024 * 1024;
*/
export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
schema: 1,
updated: '2026-11-08',
updated: '2026-11-10',
// The oldest macOS the model runtime supports.
minMacosMajor: 14,
// Ordered by rank. The higher rank wins among models that fit and whose speed is graded
@@ -344,7 +382,7 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
// that are left out and why.
models: [
{
// Small Macs (16 to 32 GB, `limited`): the base model of the row below.
// Small Macs (18 to 32 GB): the base model of the two rows below.
id: 'qwen3.5:9b-q4_k_m',
label: 'Qwen3.5 9B',
license: 'Apache-2.0',
@@ -365,7 +403,35 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
tested: [],
},
{
// Small Macs (16 to 32 GB, `limited`). MiMo-V2.6-Distill-Qwen-9B, a fine-tune of
// The model a 16 GB Mac can hold: 9.3 GB at 65,536 tokens, tight. Ornith 1.0 9B, a
// fine-tune of Qwen3.5-9B for coding agents, as a GGUF build without a vision projector,
// so 0.9 GB smaller than the rows around it. SWE-bench Verified 69.4, SWE-bench Pro 42.9
// and Terminal-Bench 2.1 43.1, against 53.2, 31.3 and 21.3 for its base (Ornith's card,
// which puts that base 7 points under Xiaomi's figure for it: the two cards do not
// compare). Ranked under MiMo for that, and because runtime issue 16992 was filed on its
// 35B sibling, which has the same renderer and parser (see the header): it is the pick
// only where nothing else fits. Text only.
id: 'ornith:9b-q4_k_m',
label: 'Ornith 1.0 9B',
license: 'MIT',
paramsB: 9,
runner: 'llamacpp',
bytes: 5_629_110_568,
// The architecture of the row above (its config.json and its GGUF header say so), so
// its figures.
heldBytesPerToken: 17_408,
overheadBytes: 52_690_944 * (1 + LLAMACPP_CTX_CHECKPOINTS) + LLAMACPP_RUNNER_BYTES + LLAMACPP_PROMPT_CACHE_BYTES,
peakExtraBytes: 0,
figures: 'estimated',
contexts: [131072, 65536],
rank: 2,
// Not measured: the 9.3 of the row above, the same architecture and the same weights file
// size (5.63 GB without the projector).
speedPer100Gbps: 9.3,
tested: [],
},
{
// Small Macs (18 to 32 GB). MiMo-V2.6-Distill-Qwen-9B, a fine-tune of
// Qwen3.5-9B for code and agents: SWE-bench Verified 61.1 and SWE-bench Pro 44.6,
// against 60.0 and 32.0 for its base (Xiaomi's card). Requires exactly the pinned
// runtime, 0.40.15.
@@ -375,14 +441,14 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
paramsB: 9,
runner: 'llamacpp',
bytes: 6_550_811_033,
// The architecture and the quantization of the row above, so its figures.
// The architecture and the quantization of the base model's row, so its figures.
heldBytesPerToken: 17_408,
overheadBytes: 52_690_944 * (1 + LLAMACPP_CTX_CHECKPOINTS) + LLAMACPP_RUNNER_BYTES + LLAMACPP_PROMPT_CACHE_BYTES,
peakExtraBytes: 0,
figures: 'estimated',
contexts: [131072, 65536],
rank: 2,
// Not measured: the 9.3 of the row above, the same architecture and file size.
rank: 3,
// Not measured: the 9.3 of the base model's row, the same architecture and file size.
speedPer100Gbps: 9.3,
tested: [],
},
@@ -404,12 +470,39 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
peakExtraBytes: 0,
figures: 'estimated',
contexts: [262144, 131072, 65536],
rank: 3,
rank: 4,
// Not measured: the 13.6 measured for Qwen3.6 35B-A3B on llama.cpp, which has the same
// 3 billion active parameters.
speedPer100Gbps: 13.6,
tested: [],
},
{
// Meta's 30B for local agents, a dense 27.9B language model with a vision encoder:
// SWE-bench Verified 76.0, SWE-bench Pro 51.2, Terminal-Bench 2.1 51.7 (Meta's card). Its
// K/V cache is the smallest of the 27B class (2 K/V heads of 128, and three layers in
// four look at a window of 2,048 tokens), so it is the first model of that class a 32 GB
// Mac can hold: 21.2 GB at 65,536 tokens, tight. Dense, so it is slow below a Max chip.
// Its own window ends at 131,072 tokens.
id: 'muse-glimmer:30b-q4_k_m',
label: 'Muse Glimmer 30B',
license: 'Apache-2.0',
paramsB: 29.6,
runner: 'llamacpp',
bytes: 18_157_010_252,
// 13 full-attention layers x 2 K/V heads x 128 x 2 x 34/32.
heldBytesPerToken: 7_072,
// 39 sliding-window layers: (2,048 + 512) cells x 2 x 128 x 2 x 34/32 = 54,312,960, live
// and in each context checkpoint.
overheadBytes: 54_312_960 * (1 + LLAMACPP_CTX_CHECKPOINTS) + LLAMACPP_RUNNER_BYTES + LLAMACPP_PROMPT_CACHE_BYTES,
peakExtraBytes: 0,
figures: 'estimated',
contexts: [131072, 65536],
rank: 5,
// Not measured: the 2.95 of Qwen3.8 27B, a dense model whose weights are the same size on
// llama.cpp (16.8 GB each without the projector).
speedPer100Gbps: 2.95,
tested: [],
},
{
// Macs of 36 GB on a slower chip: the model of the tested pair, as a GGUF build whose
// memory does not grow after it loaded. With its multi-token-prediction layer.
@@ -430,7 +523,7 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
peakExtraBytes: 0,
figures: 'estimated',
contexts: [262144, 131072, 65536],
rank: 4,
rank: 6,
// Measured: 54.5 tokens per second on 400 GB/s (llama.cpp, without prediction).
speedPer100Gbps: 13.6,
tested: [],
@@ -448,7 +541,7 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
...MEASURED_MEMORY,
figures: 'measured',
contexts: [131072, 65536],
rank: 5,
rank: 7,
// Measured: 67.8 tokens per second on 400 GB/s with MLX. The one Mac we tested ran it at
// 67.7 on 307 GB/s, so on other chips the estimate is on the low side.
speedPer100Gbps: 16.9,
@@ -473,7 +566,7 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
peakExtraBytes: 0,
figures: 'estimated',
contexts: [262144, 131072, 65536],
rank: 6,
rank: 8,
// Not measured: the 2.95 of the row below scaled by file size.
speedPer100Gbps: 1.7,
tested: [],
@@ -497,7 +590,7 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
peakExtraBytes: 0,
figures: 'estimated',
contexts: [262144, 131072, 65536],
rank: 7,
rank: 9,
// Measured: 11.8 tokens per second on 400 GB/s (llama.cpp, Q4_K_M, without prediction).
// This build predicts several tokens at a time, so it is faster than this figure says.
speedPer100Gbps: 2.95,
@@ -521,7 +614,7 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
peakExtraBytes: MEASURED_MEMORY.peakExtraBytes,
figures: 'estimated',
contexts: [262144, 131072],
rank: 8,
rank: 10,
// Not measured: the 16.9 of Qwen3.6 35B-A3B on MLX x 3/10, its active parameters
// against these, rounded down.
speedPer100Gbps: 5,
+9 -2
View File
@@ -1053,9 +1053,16 @@ class LocalModelServiceImpl implements LocalModelService {
return progress ? { ...next, progress } : next;
}
/** The stored state as a view or a verb must see it: settled, unless a verb is just starting a job. */
/**
* The stored state as a view or a verb must see it: settled, unless a verb is just starting a
* job. A job writes its last state BEFORE it lets go of `this.job`, so when the job ended while
* this read was out, the read may hold the `installing` from before that write; it is read
* again rather than settled, or it would show as an interrupted (`paused`) job for a tick.
*/
private async currentState(): Promise<LocalModelStateFile> {
const state = await this.deps.readState();
const jobAtRead = this.job;
let state = await this.deps.readState();
if (jobAtRead && !this.job) state = await this.deps.readState();
return this.jobStarting ? state : this.settled(state);
}
+11 -5
View File
@@ -28,9 +28,10 @@
* gated, installed or installable, and accepting the model id.
* - Ranking, lexicographic: tested on a Mac like this before not tested (a measurement beats
* a formula); ok before unknown before slow; the higher model rank; the higher harness
* rank; the larger context. The pick is the first candidate, the alternatives are the next
* four MODELS, each at its own best context and harness, so a model is always offered at
* the largest context it fits at.
* rank; the larger context. The pick is the first candidate, the alternatives are EVERY
* other model that fits (`MAX_ALTERNATIVES` is above what the catalogue holds), each at its
* own best context and harness, so a model is always offered at the largest context it
* fits at.
* - Disk: a model whose download would leave less than 10 GiB free is not offered. It is not
* dropped in silence: the best-ranked model left out that way is named in `noteDiskDropped`
* with what is missing.
@@ -109,8 +110,13 @@ export const SLOW_BELOW_TOKENS_PER_SECOND = 10;
export const LIMITED_BELOW_PARAMS_B = 12;
/** What must stay free on the disk after the model download. */
export const DISK_HEADROOM_BYTES = 10 * GIB;
/** How many other models are listed under the pick. */
export const MAX_ALTERNATIVES = 4;
/**
* How many other models are listed under the pick, at most. Above what the stock catalogue
* holds (10 models: 9 beside the pick), so a Mac is shown every model that fits it and the
* install accepts every one of them. Still a bound: test/local-model-catalog.test.ts fails for
* a catalogue that outgrows it, so nothing drops out of the list unseen.
*/
export const MAX_ALTERNATIVES = 11;
/** A memory size of at least this many GB (as sold) gets 3/4 of its memory as device limit, below it 2/3. */
const LARGE_LIMIT_FROM_GB = 36;
+4 -4
View File
@@ -577,10 +577,10 @@ export interface Recommendation {
/** Present exactly when `verdict` is `ok` or `limited`. */
pick?: Candidate;
/**
* The next models that fit and have room on the disk (at most four), best first: tested
* before not tested, then ok before unknown before slow speed, then the higher model rank.
* Each is at the largest context it fits at, with the best harness that can run it there.
* Empty when `verdict` is `none`.
* Every other model that fits and has room on the disk (`MAX_ALTERNATIVES` is above what the
* catalogue holds), best first: tested before not tested, then ok before unknown before
* slow speed, then the higher model rank. Each is at the largest context it fits at, with
* the best harness that can run it there. Empty when `verdict` is `none`.
*/
alternatives: Candidate[];
/** `DeviceBudget.budgetBytes`. 0 when no budget is computed: not macOS, or a Mac without Apple silicon. */
+50 -12
View File
@@ -31,6 +31,7 @@ import {
LocalModelCatalogSchema,
STOCK_LOCAL_MODEL_CATALOG,
} from '../src/config/local-model-catalog.js';
import { MAX_ALTERNATIVES } from '../src/local-model/recommend.js';
import {
RUNTIME_CTX_CHECKPOINTS,
RUNTIME_HOST_PROMPT_CACHE_MIB,
@@ -63,9 +64,11 @@ const GIB = 1024 * MIB;
/**
* The architecture of every model the stock catalogue lists, read from its config.json on
* Hugging Face (2026-11-08): full-attention layers, their K/V heads and head size, and the
* layers whose cache does not grow with the context (Gated DeltaNet layers with their value
* and key heads, or sliding-window layers with their window). Fine-tunes share their base's.
* Hugging Face (2026-11-08; Muse Glimmer on 2026-11-10): full-attention layers, their K/V heads
* and head size, and the layers whose cache does not grow with the context (Gated DeltaNet
* layers with their value and key heads, or sliding-window layers with their window).
* Fine-tunes share their base's: the config.json of Ornith 1.0 9B (2026-11-10) is that of
* Qwen3.5-9B in every field below, and the GGUF header of its build says the same.
*/
const ARCHITECTURES = {
'Qwen3.5-9B': { attentionLayers: 8, kvHeads: 4, headDim: 256, linear: { layers: 24, vHeads: 32, kHeads: 16 } },
@@ -78,6 +81,8 @@ const ARCHITECTURES = {
linear: { layers: 36, vHeads: 64, kHeads: 16 },
},
'North-Mini-Code-1.0': { attentionLayers: 13, kvHeads: 4, headDim: 128, sliding: { layers: 36, window: 4096 } },
// 52 layers, three in four on a sliding window (the GGUF header's `sliding_window_pattern`).
'Muse-Glimmer-30B': { attentionLayers: 13, kvHeads: 2, headDim: 128, sliding: { layers: 39, window: 2048 } },
} as const;
type ArchitectureName = keyof typeof ARCHITECTURES;
@@ -86,7 +91,8 @@ type Architecture = (typeof ARCHITECTURES)[ArchitectureName];
/**
* Which architecture each stock row runs, and how many tokens the runtime drafts per step with
* its multi-token-prediction layer: 0 when the GGUF has none (`nextn_predict_layers` in the GGUF
* header, read on 2026-11-08 from the first bytes of each model layer), else the tag's own
* header, read on 2026-11-08 from the first bytes of each model layer; on 2026-11-10 for the
* Ornith 1.0 and Muse Glimmer rows), else the tag's own
* `draft_num_predict` (its params layer) or the runtime's default of 4 when the tag sets none.
* Prediction costs a cache for that layer and one rollback copy of the recurrent state per
* drafted token (see the catalogue header). A row the catalogue gains must be added here, or the
@@ -94,8 +100,12 @@ type Architecture = (typeof ARCHITECTURES)[ArchitectureName];
*/
const ROW_ARCHITECTURE: Record<string, { architecture: ArchitectureName; draftTokens: number }> = {
'qwen3.5:9b-q4_k_m': { architecture: 'Qwen3.5-9B', draftTokens: 0 },
// A fine-tune of Qwen3.5-9B; its GGUF has 32 blocks and no prediction layer.
'ornith:9b-q4_k_m': { architecture: 'Qwen3.5-9B', draftTokens: 0 },
'mimo-v2.6:9b-q4_k_m': { architecture: 'Qwen3.5-9B', draftTokens: 0 },
'north-mini-code-1.0:q4_k_m': { architecture: 'North-Mini-Code-1.0', draftTokens: 0 },
// No prediction layer; the tags with a separate draft model (`-dflash`) are not listed.
'muse-glimmer:30b-q4_k_m': { architecture: 'Muse-Glimmer-30B', draftTokens: 0 },
// Its params layer sets `draft_num_predict` 2.
'qwen3.6:35b-a3b-mtp-q4_k_m': { architecture: 'Qwen3.6-35B-A3B', draftTokens: 2 },
'qwen3.6:35b-a3b': { architecture: 'Qwen3.6-35B-A3B', draftTokens: 0 },
@@ -183,20 +193,22 @@ describe('the stock catalogue', () => {
expect(getLocalModelCatalog()).toBe(first);
});
it('lists eight models, by rank, and none of those set aside in the header', () => {
it('lists ten models, by rank, and none of those set aside in the header', () => {
expect(models.map((m) => m.id)).toEqual([
'qwen3.5:9b-q4_k_m',
'ornith:9b-q4_k_m',
'mimo-v2.6:9b-q4_k_m',
'north-mini-code-1.0:q4_k_m',
'muse-glimmer:30b-q4_k_m',
'qwen3.6:35b-a3b-mtp-q4_k_m',
'qwen3.6:35b-a3b',
'qwen3.8:27b-q8_0',
'qwen3.8:27b-mtp-q4_k_m',
'ornith-1.5:122b',
]);
expect(models.map((m) => m.rank)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
expect(models.map((m) => m.rank)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
const setAside =
/^(bonsai-2|gpt-oss|flint|lfm2\.5|laguna|qwen3\.8-flash-next|qwen3\.5:122b)|^ornith-1\.5:(9b|35b|397b)/;
/^(bonsai-2|gpt-oss|flint|lfm2\.5|laguna|qwen3\.8-flash-next|qwen3\.5:(122b|4b)|ripple-preview|nemotron-3\.5-super|qwen3-coder-next)|^ornith-1\.5:(9b|35b|397b)|^ornith:35b|^muse-glimmer:.*(mlx|dflash)/;
expect(models.filter((m) => setAside.test(m.id)).map((m) => m.id)).toEqual([]);
// The plain 9B and 27B tags are MLX builds on a Mac: their GGUF builds are listed instead.
for (const plain of ['qwen3.5:9b', 'mimo-v2.6:9b', 'qwen3.8:27b']) {
@@ -207,8 +219,10 @@ describe('the stock catalogue', () => {
it('names the runner a Mac uses for each tag', () => {
expect(Object.fromEntries(models.map((m) => [m.id, m.runner]))).toEqual({
'qwen3.5:9b-q4_k_m': 'llamacpp',
'ornith:9b-q4_k_m': 'llamacpp',
'mimo-v2.6:9b-q4_k_m': 'llamacpp',
'north-mini-code-1.0:q4_k_m': 'llamacpp',
'muse-glimmer:30b-q4_k_m': 'llamacpp',
'qwen3.6:35b-a3b-mtp-q4_k_m': 'llamacpp',
// Manifest lists: a Mac takes the MLX child.
'qwen3.6:35b-a3b': 'mlx',
@@ -222,8 +236,12 @@ describe('the stock catalogue', () => {
expect(Object.fromEntries(models.map((m) => [m.id, m.bytes]))).toEqual({
// The registry's manifests as fetched on 2026-11-08, in lowercase.
'qwen3.5:9b-q4_k_m': 6_550_825_550,
// Fetched on 2026-11-10. No vision projector: the weights, a system prompt, the licence.
'ornith:9b-q4_k_m': 5_629_110_568,
'mimo-v2.6:9b-q4_k_m': 6_550_811_033,
'north-mini-code-1.0:q4_k_m': 18_593_967_008,
// Fetched on 2026-11-10: 16.76 GB of weights and a 1.40 GB projector.
'muse-glimmer:30b-q4_k_m': 18_157_010_252,
'qwen3.6:35b-a3b-mtp-q4_k_m': 22_621_314_381,
// The MLX child of the list, and the total of a real pull on a Mac, byte for byte.
'qwen3.6:35b-a3b': 23_105_798_956,
@@ -254,8 +272,11 @@ describe('the stock catalogue', () => {
it('gives every model a parameter count, an id of the default registry and a license', () => {
expect(Object.fromEntries(models.map((m) => [m.id, [m.paramsB, m.license]]))).toEqual({
'qwen3.5:9b-q4_k_m': [9, 'Apache-2.0'],
'ornith:9b-q4_k_m': [9, 'MIT'],
'mimo-v2.6:9b-q4_k_m': [9, 'MIT'],
'north-mini-code-1.0:q4_k_m': [30, 'Apache-2.0'],
// The card's total: a 27.9B language model and a vision encoder.
'muse-glimmer:30b-q4_k_m': [29.6, 'Apache-2.0'],
'qwen3.6:35b-a3b-mtp-q4_k_m': [35, 'Apache-2.0'],
'qwen3.6:35b-a3b': [35, 'Apache-2.0'],
'qwen3.8:27b-q8_0': [27.8, 'Apache-2.0'],
@@ -263,8 +284,12 @@ describe('the stock catalogue', () => {
'ornith-1.5:122b': [122, 'MIT'],
});
for (const model of models) expect(model.id).toMatch(LOCAL_MODEL_ID_PATTERN);
// The two 9B rows are the ones under the 12 billion line of the `limited` verdict.
expect(models.filter((m) => m.paramsB < 12).map((m) => m.id)).toEqual(['qwen3.5:9b-q4_k_m', 'mimo-v2.6:9b-q4_k_m']);
// The three 9B rows are the ones under the 12 billion line of the `limited` verdict.
expect(models.filter((m) => m.paramsB < 12).map((m) => m.id)).toEqual([
'qwen3.5:9b-q4_k_m',
'ornith:9b-q4_k_m',
'mimo-v2.6:9b-q4_k_m',
]);
});
it('offers no model below the 65536 tokens a harness needs, nor beyond its own window', () => {
@@ -276,11 +301,14 @@ describe('the stock catalogue', () => {
it('has a speed figure only where one was measured or scaled from a measurement', () => {
expect(Object.fromEntries(models.map((m) => [m.id, m.speedPer100Gbps ?? null]))).toEqual({
// 37.1 tokens per second on 400 GB/s, and the same for the fine-tune of the same size.
// 37.1 tokens per second on 400 GB/s, and the same for the two fine-tunes of the same size.
'qwen3.5:9b-q4_k_m': 9.3,
'ornith:9b-q4_k_m': 9.3,
'mimo-v2.6:9b-q4_k_m': 9.3,
// The 35B-A3B's llama.cpp figure: both have 3 billion active parameters.
'north-mini-code-1.0:q4_k_m': 13.6,
// The dense 27B's figure: the weights of the two are the same size.
'muse-glimmer:30b-q4_k_m': 2.95,
// 54.5 on 400 GB/s with llama.cpp.
'qwen3.6:35b-a3b-mtp-q4_k_m': 13.6,
// 67.8 on 400 GB/s with MLX.
@@ -302,6 +330,13 @@ describe('the stock catalogue', () => {
]);
});
it('holds no more models than one window lists: the pick and MAX_ALTERNATIVES others', () => {
// recommend() cuts the list at MAX_ALTERNATIVES. While the catalogue stays under it, a Mac
// is shown every model that fits and the install accepts every one of them. A catalogue
// that outgrows the bound must fail here, so the bound is raised (or kept) on purpose.
expect(models.length - 1).toBeLessThanOrEqual(MAX_ALTERNATIVES);
});
it('names the oldest macOS and its schema version', () => {
expect(STOCK_LOCAL_MODEL_CATALOG.minMacosMajor).toBe(14);
expect(STOCK_LOCAL_MODEL_CATALOG.schema).toBe(1);
@@ -348,9 +383,12 @@ describe('the memory figures of the stock rows: each row has its own', () => {
expect(Object.fromEntries(models.map((m) => [m.id, figuresOf(m)]))).toEqual({
// State x (1 live + 8 checkpoints) + 1.0 GB + 1 GiB.
'qwen3.5:9b-q4_k_m': [17_408, 2_547_960_320, 0],
'ornith:9b-q4_k_m': [17_408, 2_547_960_320, 0],
'mimo-v2.6:9b-q4_k_m': [17_408, 2_547_960_320, 0],
// The sliding window x (1 live + 8 checkpoints) + 1.0 GB + 1 GiB.
'north-mini-code-1.0:q4_k_m': [14_144, 3_698_117_120, 0],
// The same rule: 39 layers x 2,560 cells x 544 bytes = 54,312,960 per copy.
'muse-glimmer:30b-q4_k_m': [7_072, 2_562_558_464, 0],
// State x (1 live + 2 rollback copies + 8 checkpoints) + 1.0 GB + 1 GiB; the prediction
// layer's cache in f16 per token. The 27B rows below draft 4 tokens: 4 rollback copies.
'qwen3.6:35b-a3b-mtp-q4_k_m': [12_928, 2_798_242_304, 0],
@@ -374,9 +412,9 @@ describe('the memory figures of the stock rows: each row has its own', () => {
byFigures.set(key, [...(byFigures.get(key) ?? []), model]);
}
const shared = [...byFigures.values()].filter((group) => group.length > 1);
// The two 9B rows (a fine-tune and its base) and the two 27B rows (two quantizations of one model).
// The three 9B rows (a base and two fine-tunes of it) and the two 27B rows (two quantizations of one model).
expect(shared.map((group) => group.map((m) => m.id))).toEqual([
['qwen3.5:9b-q4_k_m', 'mimo-v2.6:9b-q4_k_m'],
['qwen3.5:9b-q4_k_m', 'ornith:9b-q4_k_m', 'mimo-v2.6:9b-q4_k_m'],
['qwen3.8:27b-q8_0', 'qwen3.8:27b-mtp-q4_k_m'],
]);
for (const group of shared) {
+34 -6
View File
@@ -12,9 +12,9 @@
*/
import { describe, expect, it } from 'vitest';
import { getLocalModelCatalog } from '../src/config/local-model-catalog.js';
import { getLocalModelCatalog, LocalModelCatalogSchema } from '../src/config/local-model-catalog.js';
import { formatGb, memClassGb } from '../src/local-model/format.js';
import { modelFitsBudget, recommend } from '../src/local-model/recommend.js';
import { MAX_ALTERNATIVES, modelFitsBudget, recommend } from '../src/local-model/recommend.js';
import type { HardwareFacts, HarnessFact } from '../src/types.js';
/** A Mac with Apple silicon and `gb` GB of memory as sold. */
@@ -72,18 +72,46 @@ describe('modelFitsBudget', () => {
}
});
it('does not depend on the ranking: a model that fits and is listed nowhere still fits', () => {
// On a 128 GB Mac more models fit than the recommendation lists.
const hw = mac(128, 'Apple M5 Max');
it('does not depend on what the recommendation lists: a model left out for disk space still fits', () => {
// The recommendation lists every model that fits the memory, unless its download would
// leave under 10 GiB on the disk. With 40 GB free on a 128 GB Mac that leaves out the two
// largest downloads, which fit the memory all the same.
const hw = { ...mac(128, 'Apple M5 Max'), freeDiskBytes: 40_000_000_000 };
const offered = recommend(hw, catalog, harnessFacts());
const listed = new Set([offered.pick, ...offered.alternatives].map((row) => row?.modelId));
const unlisted = catalog.models.filter((model) => !listed.has(model.id));
expect(unlisted.length).toBeGreaterThan(0);
expect(unlisted.map((model) => model.id)).toEqual(['qwen3.8:27b-q8_0', 'ornith-1.5:122b']);
for (const model of unlisted) {
expect(modelFitsBudget(hw, catalog, model.id, model.contexts.at(-1) as number), model.id).toBe(true);
}
});
it('does not depend on the ranking: a model cut off by the length of the list still fits', () => {
// A catalogue longer than the list: the stock rows plus six more copies of its smallest.
const smallest = catalog.models[0];
const long = LocalModelCatalogSchema.parse({
...structuredClone(catalog),
models: [
...structuredClone(catalog.models),
...Array.from({ length: 6 }, (_, index) => ({
...structuredClone(smallest),
id: `extra-${index + 1}:9b`,
rank: -1 - index,
})),
],
});
expect(long.models.length).toBeGreaterThan(1 + MAX_ALTERNATIVES);
const hw = mac(512, 'Apple M5 Max');
const offered = recommend(hw, long, harnessFacts());
const listed = new Set([offered.pick, ...offered.alternatives].map((row) => row?.modelId));
expect(listed.size).toBe(1 + MAX_ALTERNATIVES);
const unlisted = long.models.filter((model) => !listed.has(model.id));
expect(unlisted.length).toBe(long.models.length - 1 - MAX_ALTERNATIVES);
for (const model of unlisted) {
expect(modelFitsBudget(hw, long, model.id, model.contexts.at(-1) as number), model.id).toBe(true);
}
});
it('rules out a model that is too large, and one the catalogue does not have', () => {
expect(modelFitsBudget(mac(48), catalog, 'ornith-1.5:122b', 131072)).toBe(false);
expect(modelFitsBudget(mac(48), catalog, 'no-such:1b', 65536)).toBe(false);
+51
View File
@@ -1938,6 +1938,57 @@ describe('view()', () => {
await h.service.cancel();
});
it('a view whose read began while the job ran shows how the job ended, never paused', async () => {
// The window polls every second, so a read of the state file can be under way while the
// job writes its last state and lets go. That read returns the old `installing`, and
// settling it with no job in this process read it as an interrupted job: the window (and
// the e2e test, under CI load) saw `paused` with Resume for one tick instead of `failed`.
const slow = {
read: undefined as undefined | (() => Promise<LocalModelStateFile>),
hold: undefined as undefined | Promise<void>,
entered: undefined as undefined | (() => void),
};
const h = setup({
overrides: {
readState: async () => {
const snapshot = await slow.read!();
const hold = slow.hold;
slow.hold = undefined;
if (hold) {
slow.entered?.();
await hold;
}
return snapshot;
},
},
});
slow.read = h.store.readState;
let failVerify!: () => void;
const verifying = new Promise<void>((resolve) => (failVerify = resolve));
h.deps.verifyModel.mockImplementationOnce(async () => {
await verifying;
return { ok: false, code: 'no-tool-call' } as never;
});
await h.service.install(REQUEST, ADMIN);
await vi.waitFor(() => expect(h.deps.verifyModel).toHaveBeenCalled());
// A view starts reading while the job is still in the verify step...
let releaseRead!: () => void;
slow.hold = new Promise<void>((resolve) => (releaseRead = resolve));
const entered = new Promise<void>((resolve) => (slow.entered = resolve));
const viewing = h.service.view();
await entered;
// ...the job fails, writes it and ends while that read is still out...
failVerify();
await h.service.idle();
expect(h.store.get().state).toBe('failed');
// ...and only then does the read come back, with the file as it was before.
releaseRead();
const view = await viewing;
expect(view).toMatchObject({ state: 'failed', step: 'verify', error: { code: 'no-tool-call' } });
});
it('names the model to try next only when the catalogue still has it', async () => {
const state: LocalModelStateFile = {
...jobAt('verify', 'paused'),
+136 -49
View File
@@ -487,8 +487,12 @@ describe('needBytes', () => {
it('states the need of every stock row at its largest context', () => {
expect(Object.fromEntries(STOCK.models.map((m) => [m.id, needBytes(m, m.contexts[0])]))).toEqual({
'qwen3.5:9b-q4_k_m': 11_380_487_246,
// 0.92 GB under the other two 9B builds: it has no vision projector.
'ornith:9b-q4_k_m': 10_458_772_264,
'mimo-v2.6:9b-q4_k_m': 11_380_472_729,
'north-mini-code-1.0:q4_k_m': 25_999_848_864,
// At its own window of 131072 tokens: 18.2 GB + 0.9 GB of K/V cache + 2.6 GB.
'muse-glimmer:30b-q4_k_m': 21_646_509_900,
'qwen3.6:35b-a3b-mtp-q4_k_m': 28_808_554_317,
'qwen3.6:35b-a3b': 35_955_462_956,
'qwen3.8:27b-q8_0': 44_292_142_594,
@@ -544,6 +548,9 @@ describe('the fit grade', () => {
// ---------- 2. The two real Macs ----------
describe('the MacBook Pro (Mac17,13: Apple M5 Pro, 48 GB)', () => {
/** The same Mac without the probe: 48 GiB and the chip are all the fit and the speed depend on. */
const hw48 = mac(48, 'Apple M5 Pro');
/**
* Expected: Qwen3.6 35B-A3B at 131,072 tokens with OpenCode, a TIGHT fit, 67.7 tokens per
* second, "Recommended for this Mac".
@@ -642,7 +649,7 @@ describe('the MacBook Pro (Mac17,13: Apple M5 Pro, 48 GB)', () => {
expect(result.notes).toEqual([]);
});
it('lists four other models that fit, each at the largest context it fits at, the fast ones first', async () => {
it('lists every other model that fits, each at the largest context it fits at, the fast ones first', async () => {
const result = recommend(await macbook(), STOCK, ALL_INSTALLED);
expect(
result.alternatives.map((c) => [c.modelId, c.context, c.fit, c.speed, c.tokensPerSecond ?? null, c.tag?.kind])
@@ -651,16 +658,21 @@ describe('the MacBook Pro (Mac17,13: Apple M5 Pro, 48 GB)', () => {
['qwen3.6:35b-a3b-mtp-q4_k_m', 262144, 'good', 'ok', 41.8, undefined],
['north-mini-code-1.0:q4_k_m', 262144, 'good', 'ok', 41.8, undefined],
['mimo-v2.6:9b-q4_k_m', 131072, 'good', 'ok', 28.6, 'small'],
['ornith:9b-q4_k_m', 131072, 'good', 'ok', 28.6, 'small'],
['qwen3.5:9b-q4_k_m', 131072, 'good', 'ok', 28.6, 'small'],
// The dense models compute to 2.95 x 3.07 = 9.1 tokens per second on this chip (5.2 in
// 8 bits): slow, so they come after every fast model, in the order of their rank.
['qwen3.8:27b-mtp-q4_k_m', 262144, 'tight', 'slow', 9.1, 'tight'],
['qwen3.8:27b-q8_0', 65536, 'tight', 'slow', 5.2, 'tight'],
['muse-glimmer:30b-q4_k_m', 131072, 'good', 'slow', 9.1, 'slow'],
]);
// Only the tested row rests on a measurement of that model.
expect(result.alternatives.map((c) => c.needSource)).toEqual(['estimated', 'estimated', 'estimated', 'estimated']);
// The dense 27B fits too (32.1 GB at 262144) but computes to 2.95 x 3.07 = 9.1 tokens per
// second here: slow, so it ranks after four fast models and is not listed.
expect(modelFitsBudget(await macbook(), STOCK, 'qwen3.8:27b-mtp-q4_k_m', 262144)).toBe(true);
expect(order(result).some((entry) => entry.startsWith('qwen3.8:27b'))).toBe(false);
expect(new Set(result.alternatives.map((c) => c.needSource))).toEqual(new Set(['estimated']));
// Every model of the catalogue that fits is on the list: nothing is cut off after four.
const fitting = STOCK.models.filter((m) => m.contexts.some((ctx) => modelFitsBudget(hw48, STOCK, m.id, ctx)));
expect(rows(result).map((c) => c.modelId).sort()).toEqual(fitting.map((m) => m.id).sort());
// The 122B fits at no context.
expect(order(result).some((entry) => entry.startsWith('ornith'))).toBe(false);
expect(order(result).some((entry) => entry.startsWith('ornith-1.5'))).toBe(false);
});
it('would get the same model as "Best fit", with the estimated speed, had nobody tested it', async () => {
@@ -692,41 +704,68 @@ describe('the MacBook Pro (Mac17,13: Apple M5 Pro, 48 GB)', () => {
['qwen3.8:27b-mtp-q4_k_m', 'unknown'],
['qwen3.8:27b-q8_0', 'unknown'],
['qwen3.6:35b-a3b-mtp-q4_k_m', 'unknown'],
['muse-glimmer:30b-q4_k_m', 'unknown'],
['north-mini-code-1.0:q4_k_m', 'unknown'],
['mimo-v2.6:9b-q4_k_m', 'unknown'],
['ornith:9b-q4_k_m', 'unknown'],
['qwen3.5:9b-q4_k_m', 'unknown'],
]);
});
});
describe('the Mac mini (Mac16,10: Apple M4, 16 GB)', () => {
/**
* Expected: verdict `none` (reason `memory`) on the estimated budget, and `limited` with
* MiMo V2.6 9B (a GGUF build) at 65,536 tokens, a TIGHT fit, once the runtime measured its
* budget.
* Expected: `limited` with Ornith 1.0 9B at 65,536 tokens, a TIGHT fit, on the estimated
* budget, and MiMo V2.6 9B at 65,536 tokens, also tight, once the runtime measured its budget.
*
* Why. The estimated budget is 9.67 GiB (10.4 GB: 2/3 of 16 GiB minus 1). The 9B on
* llama.cpp needs 6.55 GB of weights + 1.14 GB for a q8_0 K/V cache of 65,536 tokens +
* 2.55 GB beside them (its recurrent state live and in 8 context checkpoints, the runner, and
* the 1 GiB prompt cache the runtime allows) = 10.2 GB: 0.987 of the estimate, just past the
* 0.98 a tight fit may reach. The runtime measured 11.8 GiB on this Mac; the 6 GiB reserve
* leaves 10 GiB (10.7 GB) of it, and the same 10.2 GB is 0.954 of that.
* Why. The estimated budget is 9.67 GiB (10.4 GB: 2/3 of 16 GiB minus 1). A 9B on llama.cpp
* needs its weights + 1.14 GB for a q8_0 K/V cache of 65,536 tokens + 2.55 GB beside them (its
* recurrent state live and in 8 context checkpoints, the runner, and the 1 GiB prompt cache
* the runtime allows). MiMo and its base are 6.55 GB (a vision projector of 0.92 GB
* included): 10.2 GB, 0.987 of the estimate, just past the 0.98 a tight fit may reach. The
* Ornith build has no projector, 5.63 GB: 9.3 GB, 0.898 of the estimate. The runtime measured
* 11.8 GiB on this Mac; the 6 GiB reserve leaves 10 GiB (10.7 GB) of it, which admits all
* three, and the rank puts MiMo first.
*
* Every figure of this row is computed from the model's architecture, none was measured
* (`needSource: 'estimated'`), so this Mac is on the edge either way. Only a run of the 9B on
* it settles which side.
* Every figure of these rows is computed from the model's architecture, none was measured
* (`needSource: 'estimated'`), and no 9B was ever run on such a Mac by us.
*/
it('gets no model on the estimated budget: the 9B needs 0.987 of it', async () => {
it('gets Ornith 1.0 9B at 65536 tokens on the estimated budget, tight: the other 9B builds need 0.987 of it', async () => {
const hw = await macMini();
expect(hw.chip).toBe('Apple M4');
expect(hw.memClassGb).toBe(16);
const result = recommend(hw, STOCK, ALL_INSTALLED);
expect(result).toMatchObject({ verdict: 'none', reason: 'memory', budgetSource: 'estimated' });
expect(result.headline).toEqual({ text: LOCAL_MODEL_LABELS.noneMemory, vars: { memory: '16' } });
expect(result.pick).toBeUndefined();
expect(result).toMatchObject({ verdict: 'limited', budgetSource: 'estimated' });
expect(result.headline).toEqual({ text: LOCAL_MODEL_LABELS.headlineLimited });
expect(result.notes).toEqual([{ text: LOCAL_MODEL_LABELS.noteUntested }]);
// floor(17,179,869,184 x 2/3) - 1 GiB.
expect(result.budgetBytes).toBe(10_379_504_298);
expect(result.budgetGb).toBe('10.4');
expect(result.pick).toMatchObject({
modelId: 'ornith:9b-q4_k_m',
modelLabel: 'Ornith 1.0 9B',
license: 'MIT',
context: 65536,
cli: 'opencode',
// 5,629,110,568 + 17,408 x 65,536 + 2,547,960,320.
needBytes: 9_317_921_576,
needGb: '9.3',
memory: { modelBytes: 8_177_070_888, contextBytes: 1_140_850_688, peakBytes: 0 },
downloadGb: '5.6',
budgetShare: 0.898,
fit: 'tight',
// 9.3 x 120 / 100.
speed: 'ok',
tokensPerSecond: 11.2,
tested: false,
needSource: 'estimated',
tag: { kind: 'best-tight', label: 'Best fit, tight' },
});
// It is the only model that fits, and only at this context: at 131072 it needs 10.5 GB.
expect(order(result)).toEqual(['ornith:9b-q4_k_m@65536']);
expect(modelFitsBudget(hw, STOCK, 'ornith:9b-q4_k_m', 131072)).toBe(false);
const nineB = stockModel('mimo-v2.6:9b-q4_k_m');
// 6,550,811,033 + 17,408 x 65,536 + 2,547,960,320.
expect(needBytes(nineB, 65536)).toBe(10_239_622_041);
@@ -734,7 +773,7 @@ describe('the Mac mini (Mac16,10: Apple M4, 16 GB)', () => {
expect(needBytes(nineB, 65536) / result.budgetBytes).toBeLessThan(0.99);
});
it('would need 11.4 GB at 131072 tokens, more than the whole budget', async () => {
it('would need 11.4 GB for MiMo at 131072 tokens, more than the whole budget', async () => {
const hw = await macMini();
const nineB = stockModel('mimo-v2.6:9b-q4_k_m');
const budget = deviceBudget(hw).budgetBytes;
@@ -744,7 +783,7 @@ describe('the Mac mini (Mac16,10: Apple M4, 16 GB)', () => {
expect(modelFitsBudget(hw, STOCK, 'mimo-v2.6:9b-q4_k_m', 65536)).toBe(false);
});
it('gets a small model with the budget its runtime measured: the 9B at 65536 tokens, tight', async () => {
it('gets MiMo with the budget its runtime measured: at 65536 tokens, tight', async () => {
// The runtime reported 11.8 GiB here; the 6 GiB reserve leaves 10 GiB (10.7 GB) of it.
const result = recommend(await macMini(), STOCK, ALL_INSTALLED, { measuredTotalBytes: MACMINI_MEASURED_TOTAL });
expect(result).toMatchObject({
@@ -773,13 +812,20 @@ describe('the Mac mini (Mac16,10: Apple M4, 16 GB)', () => {
needSource: 'estimated',
tag: { kind: 'best-tight', label: 'Best fit, tight' },
});
// Its base model, the same figures, is the one alternative.
expect(order(result)).toEqual(['mimo-v2.6:9b-q4_k_m@65536', 'qwen3.5:9b-q4_k_m@65536']);
// The other two 9B builds follow by rank. The Ornith build is small enough for 131072
// tokens here (10.5 GB, 0.974), and a model is listed at the largest context it fits at.
expect(order(result)).toEqual([
'mimo-v2.6:9b-q4_k_m@65536',
'ornith:9b-q4_k_m@131072',
'qwen3.5:9b-q4_k_m@65536',
]);
});
it('gets the same with Claude Code as its only coding agent, which also needs 65536 tokens', async () => {
const hw = await macMini();
expect(recommend(hw, STOCK, [fact('claude')])).toMatchObject({ verdict: 'none', reason: 'memory' });
const estimated = recommend(hw, STOCK, [fact('claude')]);
expect(estimated.verdict).toBe('limited');
expect(estimated.pick).toMatchObject({ modelId: 'ornith:9b-q4_k_m', context: 65536, cli: 'claude' });
const measured = recommend(hw, STOCK, [fact('claude')], { measuredTotalBytes: MACMINI_MEASURED_TOTAL });
expect(measured.verdict).toBe('limited');
expect(measured.pick).toMatchObject({ modelId: 'mimo-v2.6:9b-q4_k_m', context: 65536, cli: 'claude' });
@@ -791,22 +837,26 @@ describe('every memory size, with the stock catalogue', () => {
// size. A change of a catalogue row or of a threshold that moves one of these answers has to
// change this table on purpose. docs/local-models.md has the same table for people.
const EXPECTED: [soldGb: number, chip: string, answer: string][] = [
// The smallest need at 65536 tokens is 10.2 GB; the budget is 2.1 GB.
// The smallest need at 65536 tokens is 9.3 GB; the budget is 2.1 GB.
[8, 'Apple M2', 'none memory'],
// The 9B needs 10.2 GB at 65536 tokens, 0.987 of the 10.4 GB estimate: just out. A runtime
// that measured the budget a real 16 GB Mac measured admits it (see the Mac mini above).
[16, 'Apple M4', 'none memory'],
[16, 'Apple M1', 'none memory'],
// The 9B build without a vision projector needs 9.3 GB at 65536 tokens, 0.898 of the 10.4 GB
// estimate. The other two need 10.2 GB, 0.987: just out, until a runtime measures what a
// real 16 GB Mac measured (see the Mac mini above). On an M1 (68 GB/s) a 9B is slow: 6.3.
[16, 'Apple M4', 'limited ornith:9b-q4_k_m@65536 tight ok'],
[16, 'Apple M1', 'limited ornith:9b-q4_k_m@65536 tight slow'],
// 11.4 GB at 131072 tokens, 0.964 of 11.8 GB, and nothing of 12 billion fits: `limited`.
[18, 'Apple M3 Pro', 'limited mimo-v2.6:9b-q4_k_m@131072 tight ok'],
// On a base chip with 100 GB/s the 9B computes to 9.3 tokens per second: slow.
[24, 'Apple M3', 'limited mimo-v2.6:9b-q4_k_m@131072 good slow'],
[24, 'Apple M4', 'limited mimo-v2.6:9b-q4_k_m@131072 good ok'],
[24, 'Apple M4 Pro', 'limited mimo-v2.6:9b-q4_k_m@131072 good ok'],
// North Mini Code needs 23.2 GB at 65536 tokens: 1.06 of the 21.8 GB estimate. A runtime
// that measures about 3/4 of the memory admits it (see below).
[32, 'Apple M4', 'limited mimo-v2.6:9b-q4_k_m@131072 good ok'],
[32, 'Apple M1 Max', 'limited mimo-v2.6:9b-q4_k_m@131072 good ok'],
// Muse Glimmer 30B fits at 65536 tokens: 21.2 GB, 0.970 of the 21.8 GB estimate. So a 32 GB
// Mac is no small-model Mac (`ok`). The model is dense: on a base chip it computes to 3.5
// tokens per second and the fast 9B stays the pick, on a Max chip (11.8) it is the pick.
// North Mini Code needs 23.2 GB at 65536 tokens, 1.06 of the estimate: a runtime that
// measures about 3/4 of the memory admits it (see below).
[32, 'Apple M4', 'ok mimo-v2.6:9b-q4_k_m@131072 good ok'],
[32, 'Apple M1 Max', 'ok muse-glimmer:30b-q4_k_m@65536 tight ok'],
// A Max chip runs the dense 27B fast enough (12.1); an M3 Max (300 GB/s) does not (8.9),
// and takes the 35B-A3B as a GGUF build, whose memory does not grow after it loaded. Both
// at 131072 tokens, 0.966 and 0.971 of the budget.
@@ -846,18 +896,25 @@ describe('every memory size, with the stock catalogue', () => {
expect(result.headline).toEqual({ text: LOCAL_MODEL_LABELS.noneMemory, vars: { memory: '8' } });
// The smallest model a harness can work with needs more than four times the budget.
const smallest = Math.min(...STOCK.models.map((m) => needBytes(m, 65536)));
expect(formatGb(smallest)).toBe('10.2');
expect(formatGb(smallest)).toBe('9.3');
expect(smallest / result.budgetBytes).toBeGreaterThan(4);
});
it('gives no model to 8 and 16 GB, a small model to every Mac from 18 to 32 GB, and every larger one a model of 27 billion or more', () => {
it('gives no model to 8 GB, a small model to every Mac from 16 to 24 GB, and every Mac from 36 GB a model of 27 billion or more', () => {
for (const hw of SWEEP) {
const result = recommend(hw, STOCK, ALL_INSTALLED);
const label = `${hw.memClassGb} GB ${hw.chip}`;
if (hw.memClassGb <= 16) expect(result.reason, label).toBe('memory');
else if (hw.memClassGb <= 32) {
if (hw.memClassGb <= 8) expect(result.reason, label).toBe('memory');
else if (hw.memClassGb <= 24) {
expect(result.verdict, label).toBe('limited');
expect(result.pick?.paramsB, label).toBe(9);
} else if (hw.memClassGb === 32) {
// A model of 30 billion fits, so the Mac is not `limited`. It is dense: the pick only
// where it is not slow (a chip this file knows is fast, or one it knows nothing about).
expect(result.verdict, label).toBe('ok');
expect(rows(result).map((c) => c.modelId), label).toContain('muse-glimmer:30b-q4_k_m');
expect(result.pick?.paramsB, label).toBe(hw.chip === 'Apple M4' ? 9 : 29.6);
if (hw.chip === 'Apple M4') expect(result.notes[0], label).toEqual({ text: LOCAL_MODEL_LABELS.noteSmallPick });
} else {
expect(result.verdict, label).toBe('ok');
expect(result.pick?.paramsB, label).toBeGreaterThanOrEqual(27);
@@ -1620,11 +1677,13 @@ describe('the ranking', () => {
expect(result.pick).toMatchObject({ context: 1024, fit: 'tight', budgetShare: 0.92 });
});
it('lists each model once, never the pick again, and at most four alternatives', () => {
expect(MAX_ALTERNATIVES).toBe(4);
// Seven models fit a 128 GB Mac; the pick plus four are shown.
it('lists each model once, never the pick again, and every model that fits', () => {
// The bound is above what either catalogue holds, so nothing that fits is cut off.
expect(MAX_ALTERNATIVES).toBe(11);
for (const [, catalog] of BOTH) expect(catalog.models.length - 1).toBeLessThanOrEqual(MAX_ALTERNATIVES);
// Seven models fit a 128 GB Mac: the pick and all six others are shown.
const result = recommend(mac(128, 'Apple M5 Ultra'), RULES, ALL_INSTALLED);
expect(result.alternatives).toHaveLength(4);
expect(result.alternatives).toHaveLength(6);
const ids = rows(result).map((c) => c.modelId);
expect(new Set(ids).size).toBe(ids.length);
for (const [, catalog] of BOTH) {
@@ -1633,9 +1692,26 @@ describe('the ranking', () => {
const sweptIds = rows(swept).map((c) => c.modelId);
expect(new Set(sweptIds).size).toBe(sweptIds.length);
expect(swept.alternatives.length).toBeLessThanOrEqual(MAX_ALTERNATIVES);
// Every model that fits at a context a harness works at is on the list (the sweep's
// Macs have 500 GB of free disk, so none is left out for room).
const fitting = catalog.models.filter((m) =>
m.contexts.some((ctx) => ctx >= 65536 && modelFitsBudget(hw, catalog, m.id, ctx))
);
expect([...sweptIds].sort(), `${hw.memClassGb} GB ${hw.chip}`).toEqual(fitting.map((m) => m.id).sort());
}
}
});
it('still cuts a list longer than the bound, and the install can only take what is listed', () => {
// Fourteen models that all fit: the pick and MAX_ALTERNATIVES others, best rank first.
const many = catalogOf(
Array.from({ length: 14 }, (_, index) => modelNeeding(`m${index + 1}:x`, GIB, { rank: index + 1 }))
);
const result = recommend(HUNDRED_GIB_MAC, many, [fact('alpha')]);
expect(rows(result)).toHaveLength(1 + MAX_ALTERNATIVES);
expect(rows(result)[0].modelId).toBe('m14:x');
expect(rows(result).at(-1)?.modelId).toBe('m3:x');
});
});
describe('harnesses', () => {
@@ -2064,6 +2140,8 @@ describe('what each row of the chooser carries', () => {
'qwen3.5:9b-q8_0': 'estimated',
'qwen3.5:9b': 'estimated',
'gpt-oss:20b': 'estimated',
// Slow on this chip, so it was the sixth row: listed since the list holds every model.
'qwen3.8:27b-q8_0': 'estimated',
});
});
});
@@ -2075,10 +2153,19 @@ describe('the measured budget, end to end', () => {
const measured = recommend(hw, STOCK, ALL_INSTALLED, { measuredTotalBytes: MACBOOK_MEASURED_TOTAL });
expect([estimated.budgetSource, estimated.budgetGb]).toEqual(['estimated', '37.6']);
expect([measured.budgetSource, measured.budgetGb]).toEqual(['measured', '40.2']);
// The same rows in the same order; only the share of each changes.
expect(order(measured)).toEqual(order(estimated));
expect(rows(estimated).map((c) => c.budgetShare)).toEqual([0.957, 0.767, 0.692, 0.303, 0.303]);
expect(rows(measured).map((c) => c.budgetShare)).toEqual([0.895, 0.717, 0.647, 0.283, 0.283]);
// The same models in the same order. The share of each changes, and one model steps up a
// context: the 27B in 8 bits fits 65536 tokens under the estimate (0.975) and 131072 under
// what the runtime reported (0.976).
expect(rows(measured).map((c) => c.modelId)).toEqual(rows(estimated).map((c) => c.modelId));
const stepped = order(estimated).filter((entry, index) => order(measured)[index] !== entry);
expect(stepped).toEqual(['qwen3.8:27b-q8_0@65536']);
expect(order(measured)).toContain('qwen3.8:27b-q8_0@131072');
expect(rows(estimated).map((c) => c.budgetShare)).toEqual([
0.957, 0.767, 0.692, 0.303, 0.278, 0.303, 0.853, 0.975, 0.576,
]);
expect(rows(measured).map((c) => c.budgetShare)).toEqual([
0.895, 0.717, 0.647, 0.283, 0.26, 0.283, 0.798, 0.976, 0.539,
]);
for (const candidate of rows(measured)) {
expect(candidate.budgetShare).toBeCloseTo(candidate.needBytes / MACBOOK_MEASURED_TOTAL, 3);
}