Conflicts resolved as on test/desktop-macbook: install.sh usage() lists CODEMAN_NO_ADMIN=1 or 0 and #773's CODEMAN_AI_CLI and CODEMAN_PROGRESS=1; probe.rs keeps #781's agent() with TIMEOUT, MAX_BODY and probe_within all pub; the desktop README keeps both known-limits entries.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every Codeman release now carries the Mac app as Codeman.dmg and
Codeman.dmg.sha256, under a name that never changes, so
https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg always
serves the newest app.
.github/workflows/desktop-dmg.yml (workflow_call from Release with
tag codeman@<version>; workflow_dispatch with an optional tag) has three jobs,
so no job that holds a secret runs code from a crate:
- build (macos-26, contents: read, no secret, no cache) runs cargo test --locked
and bundle-macos.sh --universal ad hoc, with CODEMAN_DESKTOP_INSTALLER_URL,
CODEMAN_DESKTOP_INSTALL_BRANCH and SIGN_IDENTITY unset, checks both slices,
the signature and that no test installer URL or branch is baked in, and
uploads the app as a ditto zip.
- sign (macos-26, contents: read) refuses an artifact with an entry outside
Codeman.app, a ".." or a symlink, imports MACOS_CERT_P12 into a keychain under
RUNNER_TEMP with a random password, picks the one Developer ID Application
identity by its hash and checks it belongs to APPLE_TEAM_ID, runs sign-macos.sh
with the Apple ID login and uploads the signed pair as the codeman-dmg
artifact. Its last step deletes the keychain and the decoded .p12 whatever
happened before.
- attach (ubuntu, contents: write, no secret) runs only with a tag, on master
(Release itself, or a rebuild started there) and in Ark0N/Codeman; it uploads
the pair with --clobber and checks that the release's download link, and the
Latest one when this release holds Latest, serve those bytes.
A run without a tag is the dry run: everything but attach.
release.yml reads the Codeman version from changesets' publishedPackages
(published alone is also true for an xterm-zerolag-input-only release) and runs
the macOS app job only for a Codeman release, passing it the five secrets by
name, never secrets: inherit. A new step puts Latest back on the newest
codeman@ release after every publish, so a release that moved only
xterm-zerolag-input no longer takes the badge and breaks the stable link. Both
new steps sit after the retag and only warn on failure, so npm, the tag and the
MCP publish are never held up by them.
desktop-ci.yml checks pull requests and master pushes that touch
packages/desktop: cargo fmt --check, clippy -D warnings, cargo test --locked and
bash -n under macOS's bash 3.2, on macos-26 with a read-only token. Both
workflows pin Rust 1.99.1, what the macos-26 image and the Mac mini ran on
2026-11-08.
docs/releasing.md: step 6 holds a site change that links the app until the DMG
check passes, step 7 names the new job, and a new section covers the secrets,
the check, a dry run, a rebuild and what to do when notarization fails.
actionlint 1.7.12 (with shellcheck 0.11.0) reports nothing on the three
workflows, and master's test/release-notes.test.ts assertions hold on the new
release.yml.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch starts from feat/desktop-app (#763), which predates master's MCP
registry job and the release-notes body in release.yml and their pages in
docs/releasing.md. Both files are master's text verbatim here, so the Mac app
commit after this one changes them by additions only, and a rebase onto master
drops this commit as empty.
release.yml now names .github/workflows/publish-mcp.yml and
scripts/generate-release-notes.mjs, which exist on master but not on this
branch. The Release workflow runs only on pushes to master, so that matters
nowhere else.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 1.50.0 release attaches Codeman.dmg to every release, so
https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg
always serves the newest app. This puts that link where a Mac user
looks first:
- README.md: one line under the install one-liner, and a short
"On a Mac: the Codeman app" section in Quick Start, after Docker
Compose. What the app does, the download, universal (Apple Silicon
and Intel), macOS 11.3, the WebKit of Safari 16.4 for Codeman's page,
macOS 15 (or a tmux already there) for the install from the app, and
how to update it.
- README.zh-CN.md: the same, translated.
- docs/wiki/Installation.md: a matching section with a First launch
list (the one "downloaded from the internet" question, the Local
Network question on macOS 15) and the limits that matter on day one.
Its anchors, #on-a-mac-the-codeman-app and #first-launch, are what
the website links for first-launch help. Route A points to it.
The limits are copied from packages/desktop/README.md. The app is
signed with a Developer ID and notarized, so no Gatekeeper workaround
and no per-update Keychain prompt is described.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The runner header says what each caller passes (the job: the compiled stock
command; the route: the merged entry, behind cliManagementEnabled).
docs/cli-registry.md names both executors in its trust boundary. The
security architecture gains a section on the pinned runtime and its hash,
the loopback port without a login and the digest check, and the install
path without cliManagementEnabled; SECURITY.md lists the port as a known
limitation. The guides and the rules name exactly the paths the launch
guard covers, the session that keeps calling a stopped runtime's port, the
claim before the connect writes, and the outcome kept in memory.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
docs/local-models.md is the guide: the steps, the layout on disk, what is
written outside the folder, the failures and their next steps. The API
reference documents the install object and the five verbs, and how the list
is ordered. The rules for the server side are in CLAUDE.md and
architecture-invariants.md, the user-facing pages in the wiki.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Keep the 1.49.0 API meaning: liveWorktree and root=live stay inside the
session folder, and an outside worktree is published as outsideWorktree
and walked only with root=live-any.
Refuse a symlinked .git folder as the main worktree, sensitive files
inside a followed worktree, and any outside worktree while attachment
confinement is on or around a blocked tree. The file routes re-check
the guard settings on every request, and a throwing rule check refuses.
The panel keeps its Back to worktree button while pinned to the session
folder.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#772 made the Files panel follow an agent into a worktree inside the
session's folder. A worktree beside the repository (git worktree add
../feature, or an EnterWorktree into one that already exists there)
stayed unfollowed, since serving it needs a second confinement root.
findOutsideWorktree() answers that case without running git. The
nearest .git above the pane must name <common dir>/worktrees/<id> of the
session's own repository, and that registry entry must name the
candidate back (or the .git folder must be the common dir itself). It
refuses /, the home folder and above, any folder holding workingDir and
Docker case workspaces, and multi-user mode applies the owner's
folderAllowedForUser rule. The answer is liveWorktree with outside: true
and the folder's real path.
The file routes serve an absolute path inside that worktree, and only
while the folder still resolves to the recorded path. A relative path
still resolves against workingDir alone. The browser routes such paths
to the editable preview, and root-relative markdown refs resolve from
the top of the worktree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- docs/api-reference.md: GET /api/local-model is labeled experimental
until the step that sets a model up ships, so its payload is not a
stable /api/v1 contract one stage before that step changes it. The
section now describes the verdict as judged, the three sentences of
the memory reason, the notes, the speed rounding and which size to
show as the installed memory. The os-fallback description was wrong:
the free disk space is reported there too.
- docs/versioning-policy.md: the route is listed under the experimental
surfaces.
- docs/wiki/Settings-Reference.md: App Settings > Models > Model on this
Mac, in the words of a user.
- CLAUDE.md and src/web/CLAUDE.md: the invariants the code relies on
(read-only, the probe, harness rows as data, the meaning of limited,
the word Recommended, one unit, labels from the payload, when the
card and the group show).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A "Local model recommendation" section in the API reference: the read-only
route, its admin gate in multi-user mode, why a failed probe is a 200, and
every field of { hardware, recommendation, catalogUpdated }, including that a
size travels as bytes and as decimal GB text.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A running Codeman remembers a missing CLI for up to 5 minutes, so a status
poll never spawns a login shell, and its Run button reads that same memory.
So "Run fails, add the agent, Run again" kept saying the CLI was not
installed, right after the app had said it was ready.
install.sh now touches `cli-installed` in the data dir once a CLI has landed,
and resolve() leaves the backoff when that file's mtime changes. It is one
stat, inside the backoff branch, and buys one run of the chain, so a resolved
CLI never pays for it and a CLI that is still missing goes back to waiting.
Re-walking PATH during the backoff would have found hand installs too, but it
would put a synchronous stat of every PATH entry on the request path, where
one dead network mount freezes the server (#516), and execute candidates that
were already refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After "Skip for now" there is no other agent, so a failed add hit
install_offered_cli's gate first, and the app showed its first-install text
("re-run the installer") instead of naming the agent and its command. That
is the main path of adding an agent.
run_offered_cli now runs a trusted command and looks again; the gate stays in
install_offered_cli for the install, and `agent` judges its own run. The one
line that executes a trusted command is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The agent chooser only appeared during the install, and only on a Mac with
no agent. After "Skip for now", or with Codeman already installed, there was
no screen for it.
The same chooser now works after the install: "Add a coding agent" on the
This Mac tab, "Add a Coding Agent to This Mac..." in the app menu (it also
works from a Codeman page), and after a Skip the app says Codeman has no
agent yet before it opens Codeman.
`install.sh agent <id>` installs one agent and nothing else. It checks that
THAT agent landed: install_offered_cli is fatal only when no CLI is left, so
with another one present a failed install would have reported success.
The app runs it as a run of its own (`gui_install::Run::Agent`), in its own
temporary folder, so `ps` never reads it as "Codeman is being installed". It
may run next to a live server, which an install must refuse, and stops only
for another run. `--no-admin` goes to both `preflight` and `agent`: it decides
what counts as installable, so the two must agree.
CI: the bash 3.2 preflight check never tested anything. Its child run
inherited CODEMAN_INSTALL_SH_LIB and stopped at the sourcing guard, and a
false [[ ]] does not trip set -e on bash 3.2. Both are fixed for that check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
changesets/action copies a CHANGELOG.md section into the release as it stands,
so a whole release rendered as one bullet behind its commit hash, with
`### Thanks` as literal text and "Minor Changes" as the only real heading.
The Release workflow now rebuilds the body from that section: Thanks first,
then What's new with a heading per change, Fixes, Also in this release and a
compare link, with "Behaviour change:" and "Breaking:" in bold. The release is
titled `Codeman <x.y.z>`; the tag stays `codeman@<x.y.z>`, which is what the
updater and the update toast read.
This cannot be written into CHANGELOG.md instead: `### <Type> Changes` is
hardcoded upstream, and a `## ` line would end the section for changesets/action
and versionSection(). So the changelog keeps its format and the toast is
unaffected. npm has already published when the step runs, so a formatter failure
leaves the changesets text with a warning and the retag still happens.
SECTION_LABELS is now shared with whats-new.mjs so the toast and the page agree
on what counts as a headline. docs/releasing.md records the changeset shape.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The registry does not follow npm, so every release waited on a by-hand
mcp-publisher publish, behind a browser login that expires between
releases.
Release now calls publish-mcp.yml once npm took a release. It signs in
with the workflow's OIDC token, which the registry accepts for the
io.github.Ark0N/ namespace, so it needs no secret and no browser. It skips
a version the registry already has, waits until npm serves a new one, and
can be run by hand with gh workflow run publish-mcp.yml.
The id-token right sits on its own job, which runs nothing from the
repository: it reads server.json and runs one pinned, checksummed binary.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Quick-start wrote a selection's launchModel into the session's stored
<Mode>Config, which is what a clear falls back to, so a clear relaunched
the CLI with the endpoint's model id on its native backend. Only API
callers can reach that, since no page sends a clear.
The first launch now takes the forced model from the session's selection,
through the same _withCustomModelLaunchModel() the respawn path uses, so
the stored config keeps the model chosen at create. That also carries the
model onto a new tmux session for a selection applied to a running
session, which the create path used to lose.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner decision for 1.49.0: a container or ssh tab keeps using this
host's /tmp and paths, with gating planned as a follow-up.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A subfolder whose bounded probe answered `unknown` came back as `children: []`
with no `incomplete` and `truncated` false, so a dead mount, or a healthy one
slower than the 1.5 s probe on first touch, read as an empty folder and a
search missed it in silence. `unknown` is never `absent` (#516).
Such a folder now goes on the walk's `unread` list, which flags the node and
sets `truncated` in both walks. A folder the filesystem says is gone drops out
quietly as before. The row said "file limit reached", which would be false
here, so it now reads "Not fully scanned" for either reason.
Reproduced on a real disk with a parent at mode 0444, where the child's stat
answers EACCES: the API, the search and the panel all report it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
resolve_admin_mode keeps a non-admin Mac account on the system route when it
can write to Homebrew. The wiki and both READMEs left that out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The field had no mention. It opens the sandbox's network and does not touch
codex's approvals, so it sits beside the sentence about them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adopting a tmux session is admin-only and Codex Allow network access needs a
grant. The multi-user page listed neither.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A session named codeman-* or claudeman-* on any socket is dropped with no
note, which contradicted the docs. paneCwd is absent for an adopted session,
and only a claude wrapper reports statusLineExporter.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The adoption section said every git route answers unsupported, but git-diff
and git-fetch-settings answer 400, and the Git status section listed only
remote and docker. Edit history's `adopted` reason was documented nowhere.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clone Repo's picker now offers New folder, and with the field empty Browse
opens in the cases folder, so the first press there lands on this refusal.
It pointed at Add Case > Create New, which cannot clone. The sentence has to
read right from every picker that offers the button, so it names both tabs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clone Repo's custom folder became a parent that must already exist (#774),
while its picker still had no New folder button (#761 turned it on for
Create New only). A parent that does not exist yet was refused with no way
to make it from the form, so the same job worked on one tab and dead-ended
on the other.
The test pin that kept Clone off dated from when the field was the exact
target, which could be a folder not yet created. Clone moves to the on side
of that pin, so every picker in the file stays covered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a foreign session ended, remain-on-exit kept Codeman's own tmux client
on the wrapper's dead pane, so the tab kept its pid and busy, input went
nowhere, both attach routes refused it, and a new session of that name only
led back to the dead tab. paneExit stays out of it by design.
The reconnect watcher's tick now detaches a client from a dead wrapper. That
is an ordinary PTY exit with status 0, so the pid goes null and selecting the
tab runs the attach again: it joins the target if it is back and otherwise
prints the existing guard text. Checked on an isolated instance and in a
headless browser: boot recovery, target gone, target back, view detached, tab
closed, and no restart loop while the tab sits unselected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The remote file reads ran under the user's login shell, quoted for a POSIX
one. Under fish the probe was a parse error, so previews failed, and csh and
tcsh failed every multi-line read. Wrapping the probe in sh -c as the review
proposed would have opened a hole: fish reads \' inside single quotes as a
quote, so nested shell quoting flips, and a path holding ';touch x;' ran
touch x (measured, also on the existing folder listing). csh and tcsh run a
multi-line command line by line, so a newline in a path did the same.
buildRemoteFileCommand now sends every script as one word of printf text
with those bytes in octal, and only sh reads it. Probe, folder listing, read,
range read and transcript lookup gave the same result under bash, zsh, dash,
fish, tcsh and csh in a throwaway container ($SHELL -c, no real sshd), and 24
hostile paths ran nothing. Plain csh caps a word near 8 KB, noted in the docs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The parser and classifier run synchronously on the event loop over text a
pane, a container or an ssh host wrote, and one process could stall the
server for every session: 4 s for a 128 KiB argv of `/pi/` tokens, about a
minute for 2 MiB of socket rows.
The `cli.js` launcher rule leaves the signature regex for a per-word scan,
socket rows dedupe through a Set, the process row regex takes the `s` flag
(a bare CR made `$` fail and `\s+` give its spaces back, quadratic in a row),
argv and pane command are cut at FOREIGN_MAX_ARGV_CHARS, and one matcher per
scan answers each distinct argv once, since every pane of a hostile host can
claim the same process tree.
Measured on a private tmux server with the same 131 KiB argv: the largest
event-loop gap went from 3991 ms to 45 ms. The worst scan the caps allow
takes about 80 ms. Socket rows stay uncapped on purpose (see the docs).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`POST /api/sessions/:id/run` had no adopted gate, so while the wrapper had no
attached PTY it spawned `claude -p --dangerously-skip-permissions <prompt>`
in /tmp, a launch every other adopted route refuses.
The route answers INVALID_INPUT, and `runPrompt()` refuses too, which also
covers the legacy `sendInput()` path and leaves no direct spawn in Session
that an adopted session can reach.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A wrapper that could not start fell back to a direct PTY: `claude
--dangerously-skip-permissions` or a server-account shell in /tmp on this
host, under a tab still carrying the foreign session's name, so someone who
opened an ssh or container session ended up typing into this machine.
startInteractive and startShell now fail the start for an adopted session
and leave `_useMux` on, so selecting the tab tries the wrapper again. The
`error` event carries the reason, since the browser's re-attach does not
read the route's answer. A session with no mux at all is refused the same
way.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A claude session on its own CLAUDE_CONFIG_DIR launched on an endpoint with
<isolated>/projects linked to the server's tree. The resume pin looks there, so
apply found no transcript and started a new conversation, and on clear the
endpoint's transcript stayed behind in the server's tree.
The link now goes to <own dir>/projects when the session set one, so the
conversation and what the endpoint run adds stay on the session's own account
across an apply, a clear and a restart. It goes through linkRealHomeEntry(),
which also creates a missing projects folder instead of leaving a dangling
link, in the own dir and in ~/.claude/projects on the default path. A relative
CLAUDE_CONFIG_DIR of the server gets no link, since joining it would create
projects under the server's working directory.
Rule #516: guardSessionConfigDirHome() now covers configDirVar, so all three
apply sites (route, quick-start, boot recovery) probe the own dir bounded before
the apply touches it, and a dir that does not answer falls back to the server's
tree. Under test, HOME is a temp dir and CLAUDE_CONFIG_DIR is stripped by
test/setup.ts, which a new test pins.
Measured on a real Codeman server with a fake claude: apply and re-apply resume
under the isolated dir with the transcript landing in the own dir, a clear
resumes on the own dir, the isolated dir goes while the transcript stays, and the
same holds across a server restart. The documented limits come out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A remote host whose login shell is tcsh came up on 1.48.0, when the local
sh rewrote ${SHELL:-/bin/sh} to the Codeman host's shell. Since #770 the
launch reaches the far end verbatim and runs tcsh itself, and tcsh refuses
-i -l -c, -l -c and -i -l, so every stock launch left a dead pane.
The login flags are now picked on the far end, in the shape of
loginShellArgs(): set -- -i -l, cleared for csh and tcsh, then
exec "${SHELL:-/bin/sh}" "$@". The text is one brace group, so
cd <path> && { ...; } still starts nothing when the cd fails. bsd-csh takes
every flag combination, csh is listed because it is tcsh on macOS and the BSDs.
The version probe keeps its one statement: the login shell parses it, not
/bin/sh, so fish and tcsh refuse it either way, as they did on 1.48.0.
Measured with the real builder on a real sshd in a throwaway Debian container
(tcsh 6.24.07, bsd-csh, fish 3.6, bash) and through a fake sshd with
tcsh 6.24.10, fish, zsh, dash and bash, a real Codeman server on tcsh included.
The tcsh disclosure comes out of the docs and the changeset. They keep the two
limits 1.48.0 had too: tcsh has no export, so the PATH bootstrap only prints an
error, and the tmux probe needs a command executable, which Debian lacks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The attach line now takes the rule #770 gave remote and docker launches: bashCArg() single-quotes it at both respawn-pane sites, so it reaches bash verbatim. quoteAdoptLaunch is retired, since its double quotes kept the same $ and backtick safety but lost a real newline. #510's no-cd condition stays.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The "Will clone into" line named a folder the request would not use: the
preflight replaces the page's guessed case name with the server's (for a
URL such as .../repo?tab=readme the guess is repo-tab-readme and the server
says repo) and nothing refreshed the preview, so it stayed on the old name
while the field and the request moved on. It is now refreshed there too.
The Browse picker's title and the empty-field toast still said "Choose the
folder to clone into", the old exact-target meaning. Both now ask for the
parent folder, matching the field's label.
CLAUDE.md and the invariants paragraph split the two meanings: path on the
API is the exact folder, the Clone Repo tab's field is a parent and the page
sends <folder>/<case name>.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The wiki gains right-to-left notes for Last Response, the phone's Compose
editor and the rendered Markdown view, plus a Troubleshooting entry for the
#715 report: the terminal is xterm.js, which has no right-to-left support
(xtermjs/xterm.js#701). src/web/CLAUDE.md and the invariants now state the dir
pins, the logical-property rule and that the direction pass runs last.
The changeset claimed every file path stays left to right. It now says only
what holds: the paths the viewer links on its own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>