mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
afb6754453dbc5953abc72adbc006120f4e61a57
10
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e49c48145b |
fix(files): fail closed on remote symlinks, guard PUT for remote cases, bound ssh fan-out
Follow-up to #421 (remote-case file reads over ssh), addressing the review. Symlink escape on a host without `readlink -f` (blocker). The probe's portable fallback canonicalized only the directory chain and returned the final component unresolved, so on macOS < 12.3 `ws/notes.txt -> ~/.ssh/id_rsa` came back as `.../ws/notes.txt` (with the target's size), passed every containment and blocklist check that runs on `realPath`, and `cat` followed the link. The fallback now walks the directory chain with `cd -P`/`pwd -P` and follows the LAST component with plain `readlink` for a bounded number of hops, and anything it cannot fully resolve (a loop, a readlink failure, the hop cap) is reported with an `x` marker that parses as null, i.e. 404. It never returns the unresolved string. Measured on a real /bin/sh with `readlink -f` shadowed: the pre-fix script reports `/ws/notes.txt`, the fixed one `/secret/id_rsa`; both branches (native and fallback) now agree. `PUT /api/sessions/:id/file-content` never had the remote guard the PR described. It sits ahead of `validateSessionFilePath`, which resolves against the LOCAL filesystem, because with a same-named directory on the Codeman host (an sshfs mount of the remote tree, the documented stop-gap) the write landed on the local twin while the viewer believed it edited the remote file. ssh fan-out is bounded. `src/remote-ssh-limiter.ts` is a document-conversion-limiter-shaped semaphore (default 4, env `CODEMAN_MAX_REMOTE_FILE_SSH`) around every probe and buffered read; the attachment-history list resolves its whole history in ONE batched probe (`probeRemoteAttachmentHistory`, threaded into `registerExternalAttachment({remoteProbes})` so the guards run unchanged) instead of one handshake per entry; and probes chunk at 40 paths because the whole script is one argv string. Terminal output in a remote session is written on the remote host, so a prompt-injected agent printing hundreds of `codeman://attach` links forked one ssh per link, each holding a 20 s timeout, and a 100-entry history re-listed on every attachment:detected tripped OpenSSH's default MaxStartups. Streams are deliberately not counted (one per browser request, held for a whole playback, and gated behind a counted probe anyway). Smaller items from the same review: probe records are NUL-terminated and index-keyed after a leading NUL (a newline in a filename can no longer shift the alignment, and the banner is fenced off without last-N-lines guessing); size comes from `stat -c %s || stat -f %z`; the three IO functions refuse under VITEST instead of opening a connection; an unreachable host now reads as unknown (missing: false) for detected AND external history entries, where external used to fold its 502 into missing; a client that aborted during the guard probe has its body's ssh child reaped (`reply.raw.destroyed` is checked before the close listener is attached); `describeExecError` never returns Node's `Command failed: <ssh line>` message, which carried the identity path and the probe script into a 502 body; and the docs note that `isSensitivePath`'s three home-anchored entries resolve against the Codeman host's home, not the remote one. Tests: the probe script runs on a real /bin/sh with a `readlink` shim that rejects `-f` (the escape, a relative chain through a symlinked directory, a loop, a newline filename, banner chatter that itself looks like a record), the limiter's cap and FIFO order, and route tests for the PUT guard (local twin untouched, no connection), the single batched history probe, the unreachable-host alignment and the aborted-client reap. All four route tests fail against the pre-fix file-routes.ts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
63aafdf274 |
fix(files): serve remote-case attachments, the path a click takes outside the case
A clicked path that points OUTSIDE the case directory goes through the attachment routes (the frontend's `_isExternalPreviewPath` sends every absolute path not under `workingDir` to `POST /attachments`), and those had the same local-`fs` assumption as file-raw: `realpathSync`/`fs.stat` on a path that only exists on the remote host, so the file never opened — the case the #415 report was actually about. - `registerExternalAttachment()` accepts `remote` and resolves through `remoteProbePaths` (canonical path, size/mtime, kind, plus the workspace root for the confinement check). Everything around it — blocklist, extension allowlist, workspace confinement, registry/dedupe — is now shared by both branches, so the remote path cannot drift from the local one. - The by-id routes (`raw`, `preview`, `thumbnail`), the metadata poll and the attachment history list resolve over ssh too. `raw` streams with the same Range contract as file-raw; `preview` (office) and `thumbnail` answer 400 for a remote record; an unreachable host answers 502, a vanished file 404. - Which host a record is read from follows the SESSION, never the path string: the same absolute path is a different file on each host, and a remote session never falls back to a local file with that name. - Codex generated artifacts keep force-workspace confinement for a remote case: the well-known artifact directories are anchored at THIS host's home, so only a file inside the remote workspace is trusted. Still local-only by design: writes, office conversion, thumbnails, the file tree/picker and tail-file. |
||
|
|
013a5d9cc8 |
fix(files): read remote-case file previews and downloads over ssh
A remote case's workingDir is an absolute path on the remote host, but the file read routes resolved it with local `fs`: `validateSessionFilePath`'s realpathSync fails for a path that does not exist on the Codeman host, so every preview of an agent-written file answered "File not found" (#415). Add src/remote-files.ts as the single remote-read layer, built on the same buildSshConnectionArgs() the launch uses: - remoteProbePaths(): ONE round trip returning realpath + stat for the requested path AND the workspace root, so containment is checked against a remotely canonicalized root (a symlinked remotePath is ordinary). - remoteCreateReadStream(): streams the body (cat, or tail -c +N | head -c L for a Range) with nothing buffered in memory, and reaps the ssh child when the response ends so an aborted download cannot orphan it. - remoteReadFile(): bounded read for file-content. file-raw, file-content, file-preview and file-thumbnail now share one local/ remote target resolution. Guards keep their local strength: lexical pre-check, remote realpath, workspace containment, sensitive-path blocklist, and the size cap applied to the remote size before any bytes are read. An unreachable host answers 502 with the remote reason instead of a misleading 404. Nothing is ever copied to the Codeman host and there is NO local fallback (an sshfs mount of the same tree must not shadow the remote bytes). Deliberately unchanged: writes (edit=1 / PUT now answer 400 explicitly while the viewer hides its Edit affordance), office previews, thumbnails, file tree, picker, external attachment registration and tail-file stay local-only. |
||
|
|
02b0e27898 |
fix: merge-time follow-ups for #400, #401, #362 and #388
Each item is from the pre-merge review of the PR it names, applied on master rather than by pushing to a contributor branch. #400 (response viewer, shenlvkang-collab) - The brief view opened at `scrollTop = 0`, right when it was a single card holding the last row. Now that it renders the whole turn, the top is the turn's first narration line and the answer can be screens below it, while loadFullContext already scrolls to the bottom of the same turn. A multi-row turn now opens at its newest text; a single card still opens at the top. #401 (loopback links as web tabs, shenlvkang-collab) - Drop `*.localhost` from the auto-route set. Every other member is an address literal that can only mean this box; a `*.localhost` DNS name is not one, and a resolver with a search domain retries `evil.localhost` as `evil.localhost.<search domain>`. The link source is agent-written terminal output, so that set is the whole confinement on a tap that makes Codeman fetch a URL server-side and persist it. The page-side test stays broader (`isOnBoxHostname`), where a false positive only declines to proxy. - A link to the origin root navigated nothing: the path was flattened to '', which openWebview reads as "no deep link", leaving an open frame where it was. - `this.webviews` being set does not mean it is loaded. initWebviews() assigns a truthy empty map and only then awaits the list, so a tap during page load found nothing to reuse and POSTed a duplicate record. Join the in-flight refresh instead. - One dashboard per dev server rather than per host spelling, which is what the method's own comment already promised. - Toast on the auto-create: it writes webviews.json, broadcasts over SSE and adds a Run-dropdown row on every signed-in device, with a new tab as its only previous signal. #362 (remote omp continuation, timkjr) - Accept the allowlisted `mode === 'omp'` arm as-is; a blanket registry render would hand deepseek a locally-resolved --profile and bypass claude's own overlay. A registry-declared switch is the follow-up if a third mode needs it. - Revert the whole-file Prettier reformat of docs/remote-sessions.md (docs/ is hand-formatted and outside `npm run format`), keeping only the two new sections. - Correct three stale passages: architecture-invariants' `exec claude --dangerously-skip-permissions`, the `exec <cli>` paragraph (claude and omp now have their own arms, and the claude pane's PID is the login shell), and omp-integration's `-c 'omp'`. RemoteCommandMode gains deepseek and omp. - Add the missing `_maybeCaptureOmpSessionId` remote-guard test; the sibling guard in `_pinOmpRespawnId` had one and this path runs earlier, on the first idle turn. #388 (keyCode 229 recovery, aakhter) - Gate notifyCanonicalData on shouldSuppressTerminalQueryResponse and isTerminalFocusOrMouseReport. onData also carries the DA/DSR/CPR/OSC replies xterm answers during Ink redraws and its SGR mouse and focus reports; any of those landing between the keydown and the candidate's resolution was read as "xterm spoke for this keystroke", standing the recovery down and leaving the character dropped, worst on a busy agent pane. Reached through window.CodemanTerminalInput: the predicates live in a module IIFE that closes long before this call site, so bare references would throw into the surrounding try/catch and stop the notify from ever running. Every fix has a test that fails without it (verified by reverting each). Full gate green on the combined tree: 358 files, 6849 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
797f0d387c |
fix(remote): address review feedback on omp/claude respawn continuity
- Remote omp command now renders through buildSpawnCommandFromRegistry (the mode-agnostic engine local/docker spawns use) instead of the buildOmpCommand() the CLI-registry refactor deleted. - Session._pinOmpRespawnId()/_maybeCaptureOmpSessionId() now skip host-local ~/.omp resolution entirely for a remote session and fall back to --continue: that resolver only ever reads THIS host's filesystem, which is meaningless (and could wrongly alias an unrelated local conversation) for a conversation that lives on the remote host. - Remote-claude launch now honors an explicit resumeSessionId distinct from sessionId (mirrors claudeDockerPaneCommand's shape), and validates sessionId the same way that sibling does before interpolating it into the remote shell command. - Add the still-missing header-cwd half of the trailing-slash test, and document respawn/reattach continuation + auto-reconnect-vs- clean-exit in docs/remote-sessions.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
3f8c8e99d1 |
feat(grok): add Grok Build (xAI) as a seventh CLI run mode
SessionMode gains 'grok', a first-class backend alongside Claude Code,
shell, OpenCode, Codex, Gemini, Antigravity and Pi: its own PTY, tmux
session, charcoal tab identity ('gk' badge), welcome button, run-mode
entry, cron agentType, Docker and remote-SSH command defaults, and
clone-repo Brain option. Flag surface verified live against grok 1.0.5.
Grok mixes two existing shapes and the wiring follows from that:
- Codex-shaped on permissions: the bypass switch is GrokConfig.alwaysApprove
(--always-approve, grok's bypassPermissions mode; config-level deny rules
still apply on top). The Run button sends it true, like runAntigravity(),
and clampExternalCliBypassForOwner() puts grok in the only-if-sent branch:
a bare grok spawn is grok's own ask-mode default, which is already safe,
so only a sent config needs the flag forced off. Cron needs nothing for
the same reason.
- OpenCode-shaped on rendering: grok is a fullscreen alternate-screen TUI
with mouse support, so it stays OUT of isAltScreenStripMode() and lands
on the narrow tmux-attach strip and the 'buffer' local-echo fallthrough
(unmeasured against an authenticated composer; documented fallback is the
'off' branch).
- Pi-shaped on resolution: 'grok' has npm squatters (@vibe-kit/grok-cli
also installs a grok bin), so grok-cli-resolver.ts version-probes every
candidate (grok --version, killSignal SIGKILL, VITEST-gated) and
GET /api/grok/status surfaces path AND version; GROK_VERSION_REGEX is
shared with the dependency registry so doctor and run mode cannot drift.
Env allowlist gains GROK_* plus the XAI_* vendor namespace (XAI_API_KEY is
grok's documented headless auth var), the same narrow-vendor reasoning as
GOOGLE_* for gemini. Resume is id-regexed on purpose: grok's own --resume
also matches session titles, which are arbitrary user strings that must
never reach the bash -c spawn line.
Docker: grok is not on npm, so the agent image installs it in its own step
(xAI's installer has no --dir override; the binary is copied to
/usr/local/bin and root's ~/.grok dropped in the same layer), and
credentials are seeded per-file (auth.json, config.toml, pager.toml; the
dir also holds sessions/, memory/ and the ~160MB binary). Remote SSH routes
through the login-shell wrapper like the other agent CLIs.
Verified end to end on an isolated CODEMAN_INSTANCE with grok 1.0.5
installed: /api/grok/status resolves and reports the probed version,
quick-start spawns a pane whose command line ends in 'grok
--always-approve', the real TUI renders (OAuth device screen on an
unauthenticated box), and grokConfig round-trips through state.json.
Docs: docs/grok-integration.md (user guide) + docs/grok-integration-plan.md
(decisions, verification record, follow-ups).
Tests: test/grok-mode.test.ts, test/grok-cli-resolver.test.ts, plus
extended clamp/system-routes/render-index-html/run-mode-ui/mobile-overview/
local-echo-gating coverage. npm test (the CI gate) green: 5910 tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
c5b59633d8 |
feat(pi): add Pi (pi.dev) as a sixth CLI run mode (#206)
SessionMode gains 'pi', a first-class backend alongside Claude Code, OpenCode, Codex, Gemini and Antigravity: its own PTY, tmux session, rose tab identity, welcome button, run-mode entry, cron agentType, Docker and remote-SSH command defaults, and clone-repo Brain option. Pi is a different shape of CLI from the other four, and three decisions follow from that: - It has NO permission prompts and no sandbox, so there is no --dangerously-skip-permissions analog and none was invented. The privilege-shaped knob is the tri-state approveProjectTrust, which makes pi load and EXECUTE repo-local .pi/extensions TypeScript and install missing project packages. clampExternalCliBypassForOwner() therefore puts pi in the MATERIALIZE branch: a non-granted multi-user owner gets --no-approve even when no config was sent, because pi's own default is a prompt the session user could answer themselves. That helper had zero test coverage; it now has coverage for all four CLIs. - Only the PI_ prefix joins the env allowlist. Pi's ~34 provider key vars share no prefix and ALLOWED_ENV_PREFIXES is one global list with no mode context, so admitting them would widen the allowlist for every mode at once. Auth goes through pi's /login or the server's own environment. --api-key is deliberately never wired: it would put a provider secret on the spawn command line. - pi stays OUT of isAltScreenStripMode(). Its default TUI renders into the main screen with terminal-owned scrollback, and its 0.84.0 fullscreen mode is runtime-switchable via /settings; that flip was measured to put the pane into the alt screen, which the strip would have corrupted. pi-cli-resolver.ts additionally sanity-probes `pi --version` and requires semver-shaped output, because `pi` is a short generic name a stray binary can shadow; GET /api/pi/status surfaces path and version so a misresolution is diagnosable rather than presenting as a broken mode. Docker installs pi in its own --ignore-scripts step so that flag cannot affect the other four CLIs, and seeds its credentials per-file rather than whole-dir (~/.pi/agent also holds sessions, extensions and package trees). Verified end to end against pi 0.84.1 on an isolated instance: resolver search-dir fallback, flag construction, piConfig persistence across a full server restart, the trust prompt and its --no-approve suppression, the rose Run button on the default daylight-blue skin (the nested skin block eats per-mode gradients unless the rule lives inside it), and the buffer local-echo policy, which pi tolerates where codex did not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0d0b772619 |
feat: make Antigravity a first-class CLI across docs, installer and UI
Antigravity (agy) was wired into the session layer but never propagated to the surfaces around it, while Gemini CLI stayed documented as a consumer product despite being enterprise-only since Google's cutover. Gemini keeps full support; Antigravity now sits beside it everywhere. Functional fixes: - docker/agent.Dockerfile never installed agy, so a docker case with mode 'antigravity' died on command-not-found. agy is not on npm, so it gets its own installer step. --dir /usr/local/bin is load-bearing: the default $HOME/.local/bin resolves to root's home at build time and is unreachable by the `agent` user the container runs as. Verified inside codeman/agent:base (v1.1.10, reachable as `agent`). Note the binary is ~190MB, the largest layer in the image. - Welcome screen gained a Run Antigravity action, gated on agy being present like the other CLI buttons, with a cyan identity matching the toolbar run button and run-mode dot. - install.sh now detects agy (search paths mirroring the resolver), counts it as a satisfying AI CLI, and recommends it over Gemini in the install hints. Detection only, no new auto-install path. Docs corrected where they were factually wrong: - architecture-invariants documented isExternalCliMode() as opencode/codex/gemini when the code has included antigravity for a while, said "all three modes", and omitted ANTIGRAVITY_ from the env prefix allowlist row. - cron-guide's agentType enum, cron-discovery's SessionMode, and remote-sessions' RemoteCommandMode were all stale. Also: README + README.zh-CN (five CLIs, Gemini marked enterprise-only), package.json keyword, and comment drift in 8 places. test/run-mode-ui.test.ts now covers the new welcome button; verified it fails without the settings-ui wiring. Antigravity nests its whole state under ~/.gemini/antigravity-cli/, not ~/.antigravity, so the existing .gemini docker credential seed already covers it. Recorded as a comment so nobody adds dead config later. isAltScreenStripMode() deliberately still excludes antigravity: whether its TUI needs the alt-screen strip is a behavioural question that needs a real agy session, not a guess. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5181c9abb0 |
docs: sync remote-sessions.md launch section with the shipped socket/naming
The durable-launch section predated the #145 consolidation: owned launches use the dedicated -L codeman-remote socket with codeman-ssh-<id8> names and per-session set -t options (never -g). Discovery/attach (COD-105) genuinely target the canonical -L codeman socket; the asymmetry is now called out. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
115ada1e9e |
docs: cover COD-105 remote discover/attach + detach-not-kill in remote-sessions.md
55f5ada (COD-105) added Phase 2 of the remote-tmux arc: discover codeman-* sessions on a host and attach to non-owned ones, with detach-not-kill on close. - Data model: SessionRemote.owned/remoteSessionName + RemoteSessionInfo; toSessionRemote (owned:true) vs toAttachedSessionRemote (owned:false). - New Ownership section: discovery (listRemoteCodemanSessions, the literal-\t parse quirk, never-throws/VITEST), attach-vs-launch selection (buildRemoteSessionCommand), and the killSession detach-not-kill guarantee. - API: GET /api/remote-hosts/:hostId/sessions + the attachRemoteSession create path. - CLAUDE.md Remote Key Pattern notes discover/attach + detach-not-kill. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> (cherry picked from commit f321e1200a9a7c1e58c69ba936b680200fd53275) |