Compare commits

...
Author SHA1 Message Date
Codeman maintainer 7c5920d3b9 chore: version packages 2026-06-14 23:06:32 +02:00
Ark0N e1e670594b Merge PR #128: auto-wrap desktop session tabs on overflow + resize re-eval
Auto-wrap desktop session tabs to a second row on overflow
2026-06-14 22:43:43 +02:00
Ark0N 2e28e17834 Merge PR #123: hide CJK textarea on welcome screen + mobile test update
fix(cjk): hide CJK textarea on welcome screen and fix vertical centering
2026-06-14 22:43:17 +02:00
Ark0N 90f18438ff Merge PR #127: require hook-event secret unconditionally + stale-config self-heal
Require the hook-event secret unconditionally (drop managed-tunnel gating)
2026-06-14 22:43:13 +02:00
Ark0N 5b62f397ec Merge PR #129: macOS Option/physical-key session shortcuts + terminal-ui ESC-leak fix
Make Option/Alt session shortcuts work on macOS (physical key codes)
2026-06-14 22:43:08 +02:00
Ark0N 1e54ebcdf4 Merge PR #125: add codeman doctor dependency checker + accuracy review fixes
Add `codeman doctor` tool-dependency checker
2026-06-14 22:43:04 +02:00
Ark0N 0364bea166 Merge PR #126: harden markdown sanitizer with DOMPurify (mXSS) + allowlist/test review fixes
Harden markdown HTML sanitizer with vendored DOMPurify (mXSS)
2026-06-14 22:42:59 +02:00
Claude (Codeman maintainer) c7e8ff616f fix(tabs): re-evaluate auto-wrap on resize and on every full tab rebuild
Review polish on the desktop tab auto-wrap:

- Auto-wrap is purely width-driven, but updateTabOverflowMode() was only called at the
  tail of _renderSessionTabsImmediate (SSE content renders). Window resize — the primary
  trigger for tabs crossing the one-row overflow threshold — never re-evaluated it, so
  narrowing/widening the window left the wrap state stale until an unrelated status event
  fired a render. Call it from the debounced window-resize handler (no-op on
  mobile/tablet, where the method bails).

- Move the re-evaluation into _fullRenderSessionTabs() as well, so the incremental
  branch's two early `_fullRenderSessionTabs(); return;` paths (badge add/remove, which
  change tab width) and the manual two-rows toggle (applyTabWrapSettings → _fullRender…)
  re-evaluate too. The latter also fixes a transient where enabling manual two-rows while
  auto-wrap was on left both classes set (clipping folder tabs to 96px) until the next
  render.

- Add boundary cases to the policy test: exact fit and the +1 sub-pixel tolerance (no
  wrap), 2px over (wrap), and a single overflowing tab (no wrap).

Verified: tab-overflow test passes; tsc, check:frontend-syntax, check:public-assets,
prettier all clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:38:19 +02:00
Claude (Codeman maintainer) 21fbff4d8a fix(hooks): self-heal stale pre-secret hook configs so COD-91 doesn't 401 them
Making the hook-event secret unconditionally required closes the own-loopback-proxy gap,
but it would also silently 401 the hook curls baked into cases created BEFORE the secret
header existed (COD-54, 2026-06-10): writeHooksConfig only runs at case CREATION, so an
existing/linked case on a password-protected install keeps secret-less curls that the new
gate rejects (degrading idle/stop/teammate/task signalling with no error surfaced).
No-password installs are unaffected — the gate isn't registered without CODEMAN_PASSWORD.

Add `refreshStaleHookSecret(casePath)` and call it on Claude-mode spawns in
POST /api/sessions and POST /api/quick-start (existing-case branch). It regenerates the
hooks block ONLY when settings.local.json already holds Codeman's own hook curls (they
target /api/hook-event) that lack the X-Codeman-Hook-Secret header — a no-op when the
hooks are absent, not ours, or already current, so it never clobbers user customizations
and is cheap on every spawn. Fresh cases are unaffected (writeHooksConfig already wrote
the secret). withSettingsLock serializes it with the model/statusLine writers.

Verified: new test/hook-secret-selfheal.test.ts 5/5 (heal + key-preservation + no-op on
current/foreign/absent/malformed); the PR's cod54 + auth-security suites still pass
(36); tsc, lint, format:check, and npm run build all clean (symbol present in dist).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:35:29 +02:00
Claude (Codeman maintainer) 8ffb2b0644 test(cjk): update the mobile server-override test for the welcome-screen gate
The PR gates CJK textarea visibility on an active session
(`showCjk = cjkUserEnabled && !!activeSessionId`) so the fixed-position textarea no
longer floats over the welcome overlay. That intentionally changes the behavior the
existing `shows the CJK textarea on mobile only for server override` test asserted —
it set `_serverCjkOverride = true` on a fresh page (no active session) and expected the
textarea visible, which now (correctly) resolves to hidden. The test lives in
test/mobile/** (excluded from CI), so it wasn't caught by the PR's green CI.

Update the test to verify the new, intended behavior: with the server override on it
stays hidden on the welcome screen (no active session) and is revealed once a session
is active. This is a co-authored review fix; the original change is TeigenZhang's.

Verified: tsc, check:frontend-syntax, check:public-assets, prettier all clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:30:09 +02:00
Claude (Codeman maintainer) 80ebf8b549 fix(shortcuts): stop Alt/Option nav keys leaking ESC sequences into the terminal
The PR migrated the app.js tab-nav handler to physical e.code but left xterm's
pass-through gate (terminal-ui.js) matching ev.key digits. Consequences:

- Alt+[ / Alt+] (the new bindings) were never in the gate, so xterm sent ESC[ / ESC]
  to the PTY on every platform AS WELL AS switching the session.
- Alt+digit on a remapped macOS Option layout (Option+1 -> "¡") didn't match the
  ev.key '0'-'9' gate either, so xterm injected ESC<char> — on exactly the layouts
  this PR exists to fix.

Update the xterm gate to mirror app.js exactly: suppress when
`ev.altKey && !ctrl && !shift && /^(Digit[1-9]|BracketLeft|BracketRight)$/.test(ev.code)`.
Returning false there tells xterm not to write to the PTY, so the shortcut switches
the tab with no stray escape sequence.

Also: relabel the docs Alt/Option (the mechanism is layout/OS-independent, so the
shortcut works for Linux/Windows Alt users too — "Option" alone was Mac-only wording),
and add a keyboard-shortcuts test asserting terminal-ui.js gates on the same physical
codes so this desync can't regress (a grep the original test missed).

Verified: keyboard-shortcuts test 4/4, check:frontend-syntax, check:public-assets,
format:check all clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:27:36 +02:00
Claude (Codeman maintainer) c101cc8716 fix(doctor): correct Node minimum, drop phantom gemini, add pdftoppm, validate --category
Review fixes on top of the `codeman doctor` checker:

- Node minVersion 18.0.0 -> 22.0.0. package.json engines is ">=22.0.0" and the docs/CI
  require Node 22+, so doctor was green-lighting Node 18-21 (a false pass).
- Remove the phantom `gemini` registry entry. Codeman has no Gemini backend
  (SessionMode = 'claude' | 'shell' | 'opencode' | 'codex'); the entry advertised a
  dependency that nothing uses.
- Add `pdftoppm` (poppler) to the office group. document-thumbnailer.ts calls pdftoppm
  with no fallback as the sole PDF/Office first-page thumbnail renderer, yet it was
  absent from the registry, so doctor never reported it missing.
- Fix the `--category` mismatch: the help advertised `documents|media` categories that
  the ToolCategory type/registry never defined, and an unknown category silently
  produced an empty "all healthy" table. Introduce TOOL_CATEGORIES as the single source
  of truth (type + help + validation); an invalid `--category` now errors with the
  valid list and exits 2.

Verified: tsc, lint, format:check all clean; both dependency tests pass (20);
`doctor` runs correctly (Node 22.22 ok, pdftoppm detected, no gemini), `--category media`
errors with exit 2, `--category office` lists libreoffice/pdftoppm/msoffice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:24:46 +02:00
Claude (Codeman maintainer) cceb24ed8f fix(sanitizer): enforce the curated allowlist + make the test run order-independently
Review fixes on top of the DOMPurify mXSS hardening:

- Remove `USE_PROFILES: { html: true }` from the sanitize-html.js config. DOMPurify
  treats USE_PROFILES and ALLOWED_TAGS/ALLOWED_ATTR as mutually exclusive — with a
  profile set it resets the allow-lists to the full HTML profile and silently ignores
  the curated lists, so the tight markdown-only allowlist was dead config (still
  XSS-safe via FORBID + core, but far broader than intended: <button>/<input>/
  <details>/<audio>/<select>/<label> all survived). Dropping USE_PROFILES puts the
  curated ALLOWED_TAGS/ALLOWED_ATTR back in force; FORBID_TAGS/FORBID_ATTR stay as
  defense-in-depth and DOMPurify keeps its default safe-URI handling.

- Rewrite test/markdown-sanitizer.test.ts to run in the default node environment with
  an in-test jsdom window instead of a per-file jsdom environment. That environment
  externalizes node:fs/node:path under vite, so the suite failed to load in isolation
  ("No such built-in module: node:") and only survived the full CI run because an
  earlier node-env test happened to pre-cache node:fs — order-dependent and fragile.
  The rewrite is order-robust and adds an "allowlist is actually enforced" block
  (non-markdown tags must be dropped) that fails if USE_PROFILES is reintroduced.

Verified: 25/25 tests pass standalone under config/vitest.ci.config.ts; tsc, lint,
format:check, check:frontend-syntax, check:public-assets, and npm run build all clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:21:13 +02:00
Aamer Akhter 60dab7ce3f Make Option/Alt session shortcuts work on macOS (physical key codes)
Tab-switch shortcuts matched e.key, so on macOS Option+1 emits a special
character ('¡', not '1') and the shortcut silently failed. Switch to physical
e.code (Digit1-9), which is layout-independent. Also adds Option+[ / Option+]
for previous / next session. Help modal + README updated.

Test: test/keyboard-shortcuts.test.ts.
2026-06-14 15:54:50 -04:00
Aamer Akhter a5263b3252 Auto-wrap desktop session tabs to a second row on overflow
When desktop session tabs overflow one row, wrap them to a second row instead
of horizontal scroll — unless the user has pinned the manual two-row layout
(tabTwoRows). Mobile/tablet keep horizontal scroll. The wrap policy
(shouldAutoWrapTabs) lives in constants.js as a pure, unit-testable function;
updateTabOverflowMode() measures overflow after each tab render and toggles
.tabs-auto-wrap.

Test: test/tab-overflow.test.ts (vm-loads constants.js, asserts the policy).
2026-06-14 15:49:12 -04:00
Aamer Akhter f0f43ddbad Require the hook-event secret unconditionally, not only under a managed tunnel
COD-54 gated the /api/hook-event + /api/status-telemetry localhost bypass
behind the shared X-Codeman-Hook-Secret only WHILE a managed tunnel was
running, keeping a plain localhost bypass otherwise. But Codeman can't detect
a user's OWN loopback reverse proxy (their own `cloudflared --url`,
`tailscale serve`, nginx -> 127.0.0.1), which proxies internet traffic into
the loopback origin with req.ip === 127.0.0.1 — so that setup kept the unsafe
plain bypass.

Require the secret on the loopback bypass unconditionally. Managed-session
hooks already always present it (X-Codeman-Hook-Secret from
$CODEMAN_HOOK_SECRET_FILE, generated for every instance), so the legitimate
hook channel is unaffected; only the previously-unguarded own-proxy path is
now rejected. Drops the now-unused getTunnelRunning param from
registerAuthMiddleware.

Tests: cod54-hook-event-auth (tunnel-down now also requires the secret, plus
a good-secret positive case); auth-security (hook tests present the secret to
reach schema validation).
2026-06-14 12:46:58 -04:00
Aamer Akhter ea53916adc Replace markdown denylist sanitizer with vendored DOMPurify (mXSS hardening)
The previous _sanitizeHtml was a denylist over agent/transcript markdown
rendered via innerHTML; it missed style attributes and the svg/math mXSS
namespaces — e.g. <svg><style><img src=x onerror=alert(1)></style></svg>
re-serialized into a live <img onerror>.

Vendor DOMPurify 3.4.8 (allowlist) following the existing marked.min.js
vendor pattern (same-origin, CSP script-src 'self'; not in package.json so
no lockfile drift). New sanitize-html.js wires a hardened allowlist config
(FORBID style/svg/math/script/iframe/object/embed/form; no data attrs);
app.js _sanitizeHtml delegates to it with a fail-closed escape-all fallback.
index.html loads dompurify -> sanitize-html -> app.js (defer); build.mjs
minifies + content-hashes sanitize-html.js.

Test: test/markdown-sanitizer.test.ts (jsdom, real shipping artifacts) —
mXSS payloads neutralized + legit markdown preserved.
2026-06-14 12:33:48 -04:00
Aamer Akhter 585127deb2 Add codeman doctor tool-dependency checker (COD-45)
Environment-aware dependency probe (linux|darwin|win32|wsl) with a static
registry, an injectable ProbeHost seam for testing, grouped table + `--json`
output, and a non-zero exit when a required dependency is missing/outdated.
Node and tmux are the only hard-required tools; the agent CLIs and document
converters (LibreOffice / MS Office via WSL interop) are optional. CI-safe
unit tests (no tmux, injected host).
2026-06-14 12:25:49 -04:00
Teigen 41a209e96d fix(cjk): hide CJK textarea on welcome screen and fix vertical centering
- Guard `_updateCjkInputState()` with `activeSessionId` check so the
  `position: fixed` CJK textarea doesn't float over the welcome overlay
- Call `_updateCjkInputState()` in `showWelcome()`/`hideWelcome()` to
  sync CJK visibility on session enter/leave
- Add `padding: 12px 10px` to `.cjk-input-visible textarea` for proper
  vertical centering of input text
2026-06-13 18:49:57 +08:00
31 changed files with 1563 additions and 80 deletions
+12
View File
@@ -1,5 +1,17 @@
# aicodeman
## 1.1.1
### Patch Changes
- Six reviewed contributor PRs (all adversarially reviewed and fixed before merge):
- **Markdown sanitizer hardened against mutation-XSS (#126).** The denylist `_sanitizeHtml` is replaced with vendored DOMPurify 3.4.8 (authentic, byte-matched to the official dist) wired via a new `sanitize-html.js` allowlist, with a fail-closed escape fallback. The curated allowlist is genuinely enforced (no `USE_PROFILES` override) so non-markdown tags and svg/math/style/script/event-handler/`javascript:` vectors are stripped while legitimate markdown survives.
- **Hook-event secret now required unconditionally (#127).** The `/api/hook-event` + `/api/status-telemetry` localhost bypass requires the per-instance hook secret whether or not a managed tunnel is running, closing the own-loopback-reverse-proxy gap. A self-heal refreshes pre-secret hook configs in existing cases on spawn so password-protected installs don't silently 401 their hooks. No-password loopback installs are unaffected.
- **`codeman doctor` dependency checker (#125).** New `doctor`/`check-deps` command probes Node, the agent CLIs, tmux, and document converters per environment (linux/darwin/win32/wsl), with grouped or `--json` output and a non-zero exit when a required tool is missing. Requires Node 22+, reports `pdftoppm` (used for PDF/Office thumbnails), and validates `--category`.
- **macOS Option / physical-key session shortcuts (#129).** Tab switching matches physical key codes (`e.code`) so Option+1–9 works on macOS layouts that remap Option, plus Option/Alt+`[`/`]` for previous/next session — without leaking escape sequences into the focused terminal.
- **Desktop session tabs auto-wrap to a second row on overflow (#128)** instead of horizontal scrolling (off when the manual two-row layout is pinned; mobile/tablet unchanged), re-evaluated on window resize.
- **CJK input textarea hidden on the welcome screen (#123)** so it no longer floats over the welcome overlay, and re-shown on session entry; vertical centering fixed.
## 1.1.0
### Minor Changes
+1 -1
View File
@@ -56,7 +56,7 @@ When user says "COM":
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
**Version**: 1.1.0 (must match `package.json`)
**Version**: 1.1.1 (must match `package.json`)
## Project Overview
+2 -1
View File
@@ -483,7 +483,8 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
|----------|--------|
| `Ctrl/Cmd+W` | Kill active session |
| `Ctrl/Cmd+Tab` | Next session |
| `Alt+1`–`Alt+9` | Switch to tab N |
| `Alt/Option+[` / `Alt/Option+]` | Previous / next session |
| `Alt/Option+1`-`Alt/Option+9` | Switch to tab N (physical keys, so macOS Option layouts work) |
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move active tab left / right |
| `Ctrl/Cmd+L` | Clear terminal |
| `Ctrl+Shift+R` | Restore terminal size |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.1.0",
"version": "1.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.1.0",
"version": "1.1.1",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.1.0",
"version": "1.1.1",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+2
View File
@@ -67,6 +67,7 @@ appendFileSync(
// 4. Minify frontend assets
run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite');
run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite');
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite');
run('minify terminal-ui.js', 'npx esbuild dist/web/public/terminal-ui.js --minify --outfile=dist/web/public/terminal-ui.js --allow-overwrite');
run('minify respawn-ui.js', 'npx esbuild dist/web/public/respawn-ui.js --minify --outfile=dist/web/public/respawn-ui.js --allow-overwrite');
@@ -90,6 +91,7 @@ console.log('\n[build] content-hash cache busting');
'notification-manager.js',
'keyboard-accessory.js',
'input-cjk.js',
'sanitize-html.js',
'app.js',
'terminal-ui.js',
'respawn-ui.js',
+30
View File
@@ -626,4 +626,34 @@ program
}
});
program
.command('doctor')
.alias('check-deps')
.description('Check Codeman tool dependencies (Node, Claude CLI, tmux, LibreOffice, MS Office)')
.option('--json', 'Output structured JSON instead of a table')
.option('--category <name>', 'Only check one category (core|office|other)')
.action(async (options) => {
const { createRealHost, checkAll } = await import('./utils/dependency-checker.js');
const { renderTable, renderJson, computeExitCode } = await import('./utils/dependency-report.js');
const { DEPENDENCY_REGISTRY, TOOL_CATEGORIES } = await import('./config/dependency-registry.js');
if (options.category && !(TOOL_CATEGORIES as readonly string[]).includes(options.category)) {
console.error(`Unknown category "${options.category}". Valid categories: ${TOOL_CATEGORIES.join(', ')}`);
process.exit(2);
}
const host = createRealHost();
const registry = options.category
? DEPENDENCY_REGISTRY.filter((t) => t.category === options.category)
: DEPENDENCY_REGISTRY;
const results = checkAll(registry, host);
if (options.json) {
console.log(JSON.stringify(renderJson(results, host.environment), null, 2));
} else {
console.log(renderTable(results, host.environment));
}
process.exit(computeExitCode(results));
});
export { program };
+140
View File
@@ -0,0 +1,140 @@
/**
* @fileoverview Static registry of downstream tool dependencies probed by
* `codeman doctor`. Each entry declares per-environment resolvers and the
* skills that use it. EXTENSION POINT: skill-manifest-driven discovery
* (COD follow-up) will merge dynamically-found entries into this list.
*
* @module config/dependency-registry
*/
export type ProbeEnvironment = 'linux' | 'darwin' | 'win32' | 'wsl';
/** The valid `--category` filter values; single source of truth for the type, the CLI
* help text, and CLI input validation. */
export const TOOL_CATEGORIES = ['core', 'office', 'other'] as const;
export type ToolCategory = (typeof TOOL_CATEGORIES)[number];
/** Resolve a binary on the PATH and read its version. */
export interface PathResolver {
kind: 'path';
bins: string[];
versionArg?: string; // default '--version'
versionRegex?: RegExp; // default matches first \d+.\d+(.\d+)?
}
/** Resolve a Windows-installed app reachable from win32 or WSL. */
export interface WindowsSideResolver {
kind: 'windows-side';
appDirs: string[]; // relative to a Program Files root
exes: string[]; // candidate executables; first found wins
}
export interface ResolverSpec {
match: ProbeEnvironment[];
resolver: PathResolver | WindowsSideResolver;
}
export interface ToolDependency {
id: string;
label: string;
category: ToolCategory;
required: boolean;
usedBy?: string[];
minVersion?: string;
resolvers: ResolverSpec[];
installHint?: Partial<Record<ProbeEnvironment, string>>;
}
const ALL: ProbeEnvironment[] = ['linux', 'darwin', 'wsl', 'win32'];
export const DEPENDENCY_REGISTRY: ToolDependency[] = [
{
id: 'node',
label: 'Node.js',
category: 'core',
required: true,
minVersion: '22.0.0',
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['node'], versionArg: '--version' } }],
installHint: { linux: 'https://nodejs.org', darwin: 'brew install node', wsl: 'https://nodejs.org' },
},
{
id: 'claude',
label: 'Claude CLI',
category: 'core',
required: false,
usedBy: ['Claude Code sessions (default backend)'],
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['claude'], versionArg: '--version' } }],
installHint: { linux: 'https://docs.claude.com/claude-code', darwin: 'https://docs.claude.com/claude-code' },
},
{
id: 'tmux',
label: 'tmux',
category: 'core',
required: true,
resolvers: [{ match: ['linux', 'darwin', 'wsl'], resolver: { kind: 'path', bins: ['tmux'], versionArg: '-V' } }],
installHint: { linux: 'sudo apt install tmux', darwin: 'brew install tmux', wsl: 'sudo apt install tmux' },
},
{
id: 'opencode',
label: 'OpenCode CLI',
category: 'core',
required: false,
usedBy: ['OpenCode sessions'],
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['opencode'], versionArg: '--version' } }],
},
{
id: 'codex',
label: 'Codex CLI',
category: 'core',
required: false,
usedBy: ['Codex sessions'],
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['codex'], versionArg: '--version' } }],
},
{
id: 'libreoffice',
label: 'LibreOffice',
category: 'office',
required: false,
usedBy: ['document preview', 'thumbnails'],
resolvers: [
{
match: ['linux', 'darwin', 'wsl'],
resolver: { kind: 'path', bins: ['libreoffice', 'soffice'], versionArg: '--version' },
},
],
installHint: { linux: 'sudo apt install libreoffice', darwin: 'brew install --cask libreoffice' },
},
{
id: 'pdftoppm',
label: 'pdftoppm',
category: 'office',
required: false,
usedBy: ['document preview', 'PDF/Office first-page thumbnails'],
// poppler's pdftoppm prints its version to stderr; presence is what matters here.
resolvers: [
{ match: ['linux', 'darwin', 'wsl'], resolver: { kind: 'path', bins: ['pdftoppm'], versionArg: '-v' } },
],
installHint: {
linux: 'sudo apt install poppler-utils',
darwin: 'brew install poppler',
wsl: 'sudo apt install poppler-utils',
},
},
{
id: 'msoffice',
label: 'MS Office',
category: 'office',
required: false,
usedBy: ['document preview', 'thumbnails'],
resolvers: [
{
match: ['wsl', 'win32'],
resolver: {
kind: 'windows-side',
appDirs: ['Microsoft Office/root/Office16'],
exes: ['WINWORD.EXE', 'POWERPNT.EXE', 'EXCEL.EXE'],
},
},
],
},
];
+37
View File
@@ -243,6 +243,43 @@ export async function writeHooksConfig(casePath: string): Promise<void> {
});
}
/**
* Self-heal a case's hooks block so the COD-91 unconditional hook-secret gate keeps
* accepting its hook events.
*
* `writeHooksConfig` only runs when a case is first CREATED. Cases created before the
* X-Codeman-Hook-Secret header was added (COD-54, 2026-06-10) keep hook curls in their
* settings.local.json that POST to /api/hook-event WITHOUT the secret — which, once the
* gate requires it unconditionally (COD-91), silently 401 on a password-protected
* install. This refreshes the hooks block so those stale curls regain the header.
*
* Deliberately surgical: regenerates ONLY when settings.local.json already contains
* Codeman's own hook curls (they target `/api/hook-event`) that lack the secret header.
* No-op when the file/hooks are absent (we never impose hooks on a user who removed
* them), when the hooks aren't ours, or when the secret is already present — so it never
* clobbers a user's customizations and is cheap enough to call on every Claude spawn.
*/
export async function refreshStaleHookSecret(casePath: string): Promise<void> {
const settingsPath = join(casePath, '.claude', 'settings.local.json');
if (!existsSync(settingsPath)) return;
await withSettingsLock(settingsPath, async () => {
let existing: Record<string, unknown>;
try {
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
} catch {
return; // malformed — leave it untouched (case-create owns the happy path)
}
const hooksJson = JSON.stringify(existing.hooks ?? null);
const isOurs = hooksJson.includes('/api/hook-event');
// The generated curl carries this header literal (see generateHooksConfig); its
// absence on our own hooks means they predate COD-54 and need regenerating.
const hasSecret = hooksJson.includes('X-Codeman-Hook-Secret');
if (!isOurs || hasSecret) return;
const merged = { ...existing, ...generateHooksConfig() };
await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n');
});
}
/** Unique marker identifying Codeman's own statusLine command (vs a user's). */
const STATUSLINE_MARKER = '/api/status-telemetry';
+211
View File
@@ -0,0 +1,211 @@
/**
* @fileoverview Probe engine for `codeman doctor`. Resolves each registry tool
* against an injectable ProbeHost (real impl uses child_process/fs; tests inject
* fakes) and returns structured results. Pure given the host — no global I/O.
*
* @module utils/dependency-checker
*/
import { execFileSync } from 'node:child_process';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import type { ProbeEnvironment, ToolCategory, ToolDependency } from '../config/dependency-registry.js';
export interface EnvDetectionInputs {
platform: NodeJS.Platform;
procVersion: string;
hasWindowsInterop: boolean;
}
export function detectEnvironment(inputs: EnvDetectionInputs): ProbeEnvironment {
if (inputs.platform === 'win32') return 'win32';
if (inputs.platform === 'darwin') return 'darwin';
const isWsl = /microsoft|wsl/i.test(inputs.procVersion) && inputs.hasWindowsInterop;
return isWsl ? 'wsl' : 'linux';
}
const DEFAULT_VERSION_RE = /(\d+\.\d+(?:\.\d+)?)/;
export function extractVersion(text: string, re?: RegExp): string | undefined {
const m = (re ?? DEFAULT_VERSION_RE).exec(text);
return m ? m[1] : undefined;
}
/** Returns -1 if a < b, 0 if equal, 1 if a > b (numeric, component-wise). */
export function compareVersions(a: string, b: string): number {
const pa = a.split('.').map((n) => parseInt(n, 10) || 0);
const pb = b.split('.').map((n) => parseInt(n, 10) || 0);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const d = (pa[i] || 0) - (pb[i] || 0);
if (d !== 0) return d < 0 ? -1 : 1;
}
return 0;
}
export type ToolStatus = 'ok' | 'missing' | 'outdated' | 'skipped' | 'error';
export interface ToolResult {
id: string;
label: string;
category: ToolCategory;
required: boolean;
usedBy: string[];
status: ToolStatus;
version?: string;
path?: string;
installHint?: string;
reason?: string;
}
export interface ProbeHost {
environment: ProbeEnvironment;
which(bin: string): string | null;
fileExists(path: string): boolean;
runVersion(bin: string, args: string[]): string | null;
windowsProgramRoots(): string[];
windowsFileVersion(winPath: string): string | null;
}
function finalize(
base: Pick<ToolResult, 'id' | 'label' | 'category' | 'required' | 'usedBy'>,
tool: ToolDependency,
path: string,
version: string | undefined
): ToolResult {
if (tool.minVersion) {
if (!version) return { ...base, status: 'error', path, reason: 'version required but could not be parsed' };
if (compareVersions(version, tool.minVersion) < 0) return { ...base, status: 'outdated', path, version };
}
return { ...base, status: 'ok', path, version };
}
export function checkTool(tool: ToolDependency, host: ProbeHost): ToolResult {
const base = {
id: tool.id,
label: tool.label,
category: tool.category,
required: tool.required,
usedBy: tool.usedBy ?? [],
};
const installHint = tool.installHint?.[host.environment];
const spec = tool.resolvers.find((r) => r.match.includes(host.environment));
if (!spec) return { ...base, status: 'skipped', reason: `not applicable on ${host.environment}` };
if (spec.resolver.kind === 'path') {
const { bins, versionArg, versionRegex } = spec.resolver;
for (const bin of bins) {
const resolved = host.which(bin);
if (resolved) {
const out = host.runVersion(bin, [versionArg ?? '--version']);
const version = out ? extractVersion(out, versionRegex) : undefined;
return finalize(base, tool, resolved, version);
}
}
return { ...base, status: 'missing', installHint };
}
// windows-side
const { appDirs, exes } = spec.resolver;
for (const root of host.windowsProgramRoots()) {
for (const dir of appDirs) {
for (const exe of exes) {
const winPath = `${root}/${dir}/${exe}`;
if (host.fileExists(winPath)) {
const raw = host.windowsFileVersion(winPath);
const version = raw ? extractVersion(raw) : undefined;
return finalize(base, tool, winPath, version);
}
}
}
}
return { ...base, status: 'missing', installHint };
}
export function checkAll(registry: ToolDependency[], host: ProbeHost): ToolResult[] {
return registry.map((tool) => checkTool(tool, host));
}
function safeWhich(bin: string): string | null {
try {
const out = execFileSync(process.platform === 'win32' ? 'where' : 'which', [bin], {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
const first = out.split(/\r?\n/)[0]?.trim();
return first && existsSync(first) ? first : null;
} catch {
return null;
}
}
function safeRunVersion(bin: string, args: string[]): string | null {
try {
return execFileSync(bin, args, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
});
} catch (err: unknown) {
// Some tools (e.g. ffmpeg) exit non-zero on -version but still print to stdout
const stdout = (err as { stdout?: Buffer | string })?.stdout;
return stdout ? stdout.toString() : null;
}
}
function readProcVersion(): string {
try {
return readFileSync('/proc/version', 'utf-8');
} catch {
return '';
}
}
function listWindowsProgramRoots(): string[] {
const roots: string[] = [];
try {
for (const entry of readdirSync('/mnt')) {
for (const pf of ['Program Files', 'Program Files (x86)']) {
const root = `/mnt/${entry}/${pf}`;
if (existsSync(root)) roots.push(root);
}
}
} catch {
// /mnt absent (not WSL) -> no roots
}
return roots;
}
function readWindowsFileVersion(winPath: string): string | null {
try {
const windowsPath = execFileSync('wslpath', ['-w', winPath], {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
const out = execFileSync(
'powershell.exe',
['-NoProfile', '-Command', `(Get-Item '${windowsPath.replace(/'/g, "''")}').VersionInfo.ProductVersion`],
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
).trim();
return out || null;
} catch {
return null;
}
}
export function createRealHost(): ProbeHost {
const environment = detectEnvironment({
platform: process.platform,
procVersion: readProcVersion(),
hasWindowsInterop: safeWhich('cmd.exe') !== null || safeWhich('powershell.exe') !== null,
});
return {
environment,
which: safeWhich,
fileExists: existsSync,
runVersion: safeRunVersion,
windowsProgramRoots: listWindowsProgramRoots,
windowsFileVersion: readWindowsFileVersion,
};
}
+75
View File
@@ -0,0 +1,75 @@
/**
* @fileoverview Renders ToolResult[] from the dependency checker into a
* human-readable grouped table or JSON, and computes the process exit code.
* Plain text only (no color) so output is stable and snapshot-friendly; the
* CLI layer may colorize.
*
* @module utils/dependency-report
*/
import type { ProbeEnvironment, ToolCategory } from '../config/dependency-registry.js';
import type { ToolResult, ToolStatus } from './dependency-checker.js';
const CATEGORY_ORDER: ToolCategory[] = ['core', 'office', 'other'];
function glyph(r: ToolResult): string {
if (r.status === 'ok') return '✓';
if (r.status === 'skipped') return '○';
return r.required ? '✗' : '○';
}
function statusText(r: ToolResult): string {
if (r.status === 'ok') return r.version ?? 'installed';
if (r.status === 'outdated') return `${r.version ?? '?'} (below minimum)`;
if (r.status === 'skipped') return 'n/a';
if (r.status === 'error') return 'version error';
return 'not found';
}
export function computeExitCode(results: ToolResult[]): number {
const failed = results.some(
(r) => r.required && (r.status === 'missing' || r.status === 'outdated' || r.status === 'error')
);
return failed ? 1 : 0;
}
export function renderTable(results: ToolResult[], environment: ProbeEnvironment): string {
const lines: string[] = [`Codeman dependency check — ${environment}`, ''];
for (const category of CATEGORY_ORDER) {
const rows = results.filter((r) => r.category === category);
if (rows.length === 0) continue;
lines.push(category.toUpperCase());
for (const r of rows) {
const detail = r.path ? ` ${r.path}` : '';
lines.push(` ${glyph(r)} ${r.label.padEnd(14)} ${statusText(r).padEnd(22)}${detail}`);
if (r.usedBy.length) lines.push(` used by: ${r.usedBy.join(', ')}`);
if (r.installHint) lines.push(` install: ${r.installHint}`);
}
lines.push('');
}
const ok = results.filter((r) => r.status === 'ok').length;
const requiredMissing = results.filter((r) => r.required && r.status !== 'ok' && r.status !== 'skipped').length;
const optionalMissing = results.filter((r) => !r.required && r.status === 'missing').length;
lines.push(`Summary: ${ok} ok · ${requiredMissing} required missing · ${optionalMissing} optional missing`);
return lines.join('\n');
}
export interface DependencyReportJson {
platform: { environment: ProbeEnvironment };
summary: { ok: number; requiredMissing: number; optionalMissing: number; exitCode: number };
tools: ToolResult[];
}
export function renderJson(results: ToolResult[], environment: ProbeEnvironment): DependencyReportJson {
const byStatus = (s: ToolStatus) => results.filter((r) => r.status === s).length;
return {
platform: { environment },
summary: {
ok: byStatus('ok'),
requiredMissing: results.filter((r) => r.required && r.status !== 'ok' && r.status !== 'skipped').length,
optionalMissing: results.filter((r) => !r.required && r.status === 'missing').length,
exitCode: computeExitCode(results),
},
tools: results,
};
}
+14 -26
View File
@@ -36,20 +36,12 @@ interface AuthState {
* Register HTTP Basic Auth middleware with session cookies and rate limiting.
* Only active when CODEMAN_PASSWORD is set.
*
* @param getTunnelRunning - returns true while a managed tunnel is active. Used
* to gate the `/api/hook-event` localhost bypass: when a tunnel is up, tunneled
* internet traffic reaches the loopback origin with `req.ip === 127.0.0.1`, so
* the bypass additionally requires the shared hook secret (COD-54). When no
* tunnel is running (loopback-only, the normal case) the plain localhost bypass
* is kept so already-deployed (pre-secret) hooks + the loop channel keep working.
* Optional; defaults to "no tunnel" (unchanged behavior) when omitted.
* The `/api/hook-event` + `/api/status-telemetry` localhost bypass requires the
* shared hook secret unconditionally (COD-91) — see the onRequest hook below.
*
* @returns AuthState for lifecycle management (dispose on server stop)
*/
export function registerAuthMiddleware(
app: FastifyInstance,
https: boolean,
getTunnelRunning: () => boolean = () => false
): AuthState {
export function registerAuthMiddleware(app: FastifyInstance, https: boolean): AuthState {
const state: AuthState = {
authSessions: null,
authFailures: null,
@@ -114,30 +106,26 @@ export function registerAuthMiddleware(
// COD-54: the bare localhost bypass is unsafe while a tunnel is running, because
// `cloudflared --url http://127.0.0.1:port` proxies internet traffic INTO the
// loopback origin, so a tunneled request arrives with req.ip === 127.0.0.1 and
// would pass. So:
// - tunnel running → bypass requires the shared hook secret (local hooks present
// it via the X-Codeman-Hook-Secret header; internet traffic can't know it),
// - tunnel not running (loopback-only, the normal case) → keep the plain
// localhost bypass so already-deployed (pre-secret) hooks + the loop's own
// credential-less hook channel keep working.
// would pass. COD-91: require the shared hook secret on the loopback bypass
// UNCONDITIONALLY (not just while the managed tunnel is up). Codeman can't detect
// a user's own loopback reverse proxy (their own `cloudflared --url`, `tailscale
// serve`, nginx → 127.0.0.1), so tunnel-gating left that path with the unsafe plain
// bypass. Managed-session hooks always present the secret (X-Codeman-Hook-Secret,
// from $CODEMAN_HOOK_SECRET_FILE — generated for every instance), so requiring it
// always closes the gap without breaking the legitimate hook channel.
if ((req.url === '/api/hook-event' || req.url === '/api/status-telemetry') && req.method === 'POST') {
const ip = req.ip;
const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
if (isLoopback) {
if (!getTunnelRunning()) {
// Loopback-only: unchanged behavior.
done();
return;
}
// Tunnel up: require the shared secret (constant-time compare).
// Always require the shared secret (constant-time compare).
const presented = Buffer.from(req.headers[HOOK_SECRET_HEADER.toLowerCase()]?.toString() ?? '');
const expected = Buffer.from(getHookSecret());
if (presented.length === expected.length && timingSafeEqual(presented, expected)) {
done();
return;
}
// Wrong/absent secret while tunneled — rate-limit per IP in the DEDICATED
// hook bucket (never authFailures, which would lock out the login path).
// Wrong/absent secret — rate-limit per IP in the DEDICATED hook bucket
// (never authFailures, which would lock out the login path).
const hookIp = req.ip;
const hookFailures = hookSecretFailures.get(hookIp) ?? 0;
if (hookFailures >= AUTH_FAILURE_MAX) {
+76 -28
View File
@@ -770,14 +770,31 @@ class CodemanApp {
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
}
// Alt+1-9: switch to Codeman session by index
if (e.altKey && !e.ctrlKey && !e.shiftKey && e.key >= '1' && e.key <= '9') {
const idx = parseInt(e.key) - 1;
if (idx < this.sessionOrder.length) {
// Option/Alt session navigation uses physical key CODES, not e.key, so macOS
// keyboard layouts that emit special characters under Option (Option+1 -> ¡,
// Option+[ -> "“") still switch sessions. e.code is the physical key regardless
// of layout. Option+1-9 = switch by index; Option+[ / Option+] = prev / next.
if (e.altKey && !e.ctrlKey && !e.shiftKey) {
const code = e.code || '';
const digitMatch = code.match(/^Digit([1-9])$/);
if (digitMatch) {
const idx = parseInt(digitMatch[1], 10) - 1;
if (idx < this.sessionOrder.length) {
e.preventDefault();
this.selectSession(this.sessionOrder[idx]);
}
return;
}
if (e.code === 'BracketLeft') {
e.preventDefault();
this.selectSession(this.sessionOrder[idx]);
this.prevSession();
return;
}
if (e.code === 'BracketRight') {
e.preventDefault();
this.nextSession();
return;
}
return;
}
// Match against shortcut table
@@ -1302,28 +1319,14 @@ class CodemanApp {
/** Strip dangerous elements and attributes from HTML (XSS prevention) */
_sanitizeHtml(html) {
const tpl = document.createElement('template');
tpl.innerHTML = html;
const frag = tpl.content;
for (const el of frag.querySelectorAll('script, iframe, object, embed, form, base, meta, link, style')) {
el.remove();
if (typeof window !== 'undefined' && typeof window.sanitizeMarkdownHtml === 'function') {
return window.sanitizeMarkdownHtml(html);
}
for (const el of frag.querySelectorAll('*')) {
for (const attr of [...el.attributes]) {
const name = attr.name.toLowerCase();
if (name.startsWith('on')) {
el.removeAttribute(attr.name);
} else if (['href', 'src', 'action', 'xlink:href', 'formaction'].includes(name)) {
const val = attr.value.replace(/\s/g, '').toLowerCase();
if (val.startsWith('javascript:') || val.startsWith('vbscript:') || val.startsWith('data:text/html')) {
el.removeAttribute(attr.name);
}
}
}
}
const div = document.createElement('div');
div.appendChild(frag);
return div.innerHTML;
// Fail closed: DOMPurify unavailable — never return un-sanitized HTML.
return String(html == null ? '' : html)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
/**
@@ -2166,7 +2169,13 @@ class CodemanApp {
// Mobile defaults ship cjkInputEnabled: false (native terminal input by
// default on touch), but an explicit user enable is honored everywhere —
// the App Settings toggle must not be a silent no-op on phones.
const showCjk = this._serverCjkOverride || (settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false);
// The welcome/home screen (no active session) has nothing to type into.
// Force-hide the CJK textarea there — otherwise the `position: fixed`
// `.cjk-input-visible` rule floats it over the welcome overlay and blocks
// content. Re-synced on session enter/leave via hideWelcome()/showWelcome().
const cjkUserEnabled =
this._serverCjkOverride || (settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false);
const showCjk = cjkUserEnabled && !!this.activeSessionId;
cjkEl.classList.toggle('cjk-input-visible', !!showCjk);
document.body.classList.toggle('cjk-input-visible', !!showCjk);
cjkEl.style.display = showCjk ? 'block' : 'none';
@@ -2656,6 +2665,39 @@ class CodemanApp {
this._fullRenderSessionTabs();
}
this.updateTabOverflowMode();
}
// Auto-wrap desktop session tabs to a second row when they overflow one row,
// unless the user has pinned the manual two-row layout (tabTwoRows). Mobile/
// tablet keep horizontal scroll. Policy lives in constants.js for unit testing.
updateTabOverflowMode() {
const container = this.$('sessionTabs');
if (!container) return;
const deviceType = MobileDetection.getDeviceType();
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
const manualTwoRows = deviceType === 'desktop' ? (settings.tabTwoRows ?? defaults.tabTwoRows ?? false) : false;
if (manualTwoRows || deviceType !== 'desktop') {
container.classList.remove('tabs-auto-wrap');
return;
}
// Measure the natural one-row overflow, then enable wrapping only if needed.
container.classList.remove('tabs-auto-wrap');
const shouldWrap = window.CodemanTabOverflow?.shouldAutoWrapTabs
? window.CodemanTabOverflow.shouldAutoWrapTabs({
deviceType,
manualTwoRows,
tabCount: this.sessions.size,
scrollWidth: container.scrollWidth,
clientWidth: container.clientWidth,
})
: container.scrollWidth > container.clientWidth + 1;
container.classList.toggle('tabs-auto-wrap', shouldWrap);
}
_fullRenderSessionTabs() {
@@ -2732,6 +2774,12 @@ class CodemanApp {
// Update connection lines after tabs change (positions may have shifted)
this.updateConnectionLines();
// Re-evaluate desktop auto-wrap for every full rebuild, including the incremental
// branch's early `_fullRenderSessionTabs(); return;` paths and the manual two-rows
// toggle (applyTabWrapSettings calls this) which would otherwise leave a stale
// tabs-auto-wrap class until the next content render.
this.updateTabOverflowMode();
}
// Set up arrow key navigation for session tabs (accessibility)
+15
View File
@@ -114,9 +114,24 @@ function evaluateWebGLLongTaskTrip(recent, entries, now, config = WEBGL_FALLBACK
// Expose for tests. `const` declarations at the top of a non-module script
// are global lexical bindings but not `window` properties, so explicit
// assignment is the test-visible API surface.
// Desktop tab-overflow policy: auto-wrap the session tabs to a second row when
// they overflow one row (and the user hasn't pinned the manual two-row layout).
function shouldAutoWrapTabs(input) {
if (!input || input.deviceType !== 'desktop') return false;
if (input.manualTwoRows) return false;
if ((input.tabCount || 0) < 2) return false;
const scrollWidth = Number(input.scrollWidth) || 0;
const clientWidth = Number(input.clientWidth) || 0;
return scrollWidth > clientWidth + 1;
}
if (typeof window !== 'undefined') {
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
window.CodemanTabOverflow = {
shouldAutoWrapTabs,
};
}
// Scheduler API — prioritize terminal writes over background UI updates.
+7 -1
View File
@@ -39,6 +39,9 @@
<script defer src="vendor/xterm-addon-unicode11.min.js"></script>
<script defer src="vendor/xterm-zerolag-input.js"></script>
<script defer src="vendor/marked.min.js"></script>
<!-- DOMPurify (allowlist HTML sanitizer for rendered markdown).
Must load before sanitize-html.js (which wires it) and app.js (which calls it). -->
<script defer src="vendor/dompurify.min.js"></script>
<!-- Synchronous mobile detection — runs before first paint to prevent panel flash -->
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
<!-- Synchronous skin selection — runs before first paint to prevent theme flash -->
@@ -505,7 +508,8 @@
<div class="shortcuts-grid">
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
<div><kbd>Alt</kbd>+<kbd>1-9</kbd></div><div>Switch to Tab N</div>
<div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div>
<div><kbd>Alt/Option</kbd>+<kbd>1-9</kbd></div><div>Switch to Tab N</div>
<div><kbd>Ctrl</kbd>+<kbd>L</kbd></div><div>Clear Terminal</div>
<div><kbd>Ctrl</kbd>+<kbd>+</kbd></div><div>Increase Font</div>
<div><kbd>Ctrl</kbd>+<kbd>-</kbd></div><div>Decrease Font</div>
@@ -1914,6 +1918,8 @@
<script defer src="notification-manager.js"></script>
<script defer src="keyboard-accessory.js"></script>
<script defer src="input-cjk.js"></script>
<!-- Hardened markdown HTML sanitizer (wires DOMPurify). Must precede app.js. -->
<script defer src="sanitize-html.js"></script>
<script defer src="app.js"></script>
<script defer src="terminal-ui.js"></script>
<script defer src="respawn-ui.js"></script>
+3
View File
@@ -139,6 +139,9 @@ const MobileDetection = {
resizeTimeout = setTimeout(() => {
this.updateBodyClass();
this.updateAppHeight();
// Tab auto-wrap is width-driven, so it must re-evaluate on resize — the only
// other trigger is a tab content render. No-op on mobile/tablet (method bails).
if (typeof app !== 'undefined') app.updateTabOverflowMode?.();
}, 100);
};
window.addEventListener('resize', this._resizeHandler);
+165
View File
@@ -0,0 +1,165 @@
/**
* @fileoverview Allowlist-based HTML sanitizer for markdown-rendered, agent/transcript-derived
* content that is subsequently assigned via innerHTML (response viewer, attachment markdown
* preview, message bodies).
*
* Security (COD-56): the previous sanitizer was a hand-rolled DENYLIST — it removed a fixed
* set of tags (script/iframe/object/embed/form/base/meta/link/style), stripped on* attrs and a
* few dangerous URL schemes, then re-serialized. Denylists are mXSS-prone: they did not strip
* `svg`/`math` (which carry their own foreign-namespace parsing rules and can smuggle script via
* namespace confusion), did not strip `style` attributes (CSS `expression()`/`url(javascript:)`
* on legacy engines), and had no positive allowlist, so any tag/attribute not explicitly named
* survived. `marked` runs with raw-HTML passthrough, so crafted HTML echoed by an agent flows
* straight into this function.
*
* This module replaces that with DOMPurify (Cure53), an allowlist sanitizer that is the
* industry standard for mXSS defense. It is configured to allow exactly the tag/attribute set
* that markdown rendering legitimately produces (headings, lists, code, blockquotes, links,
* tables, images with safe src) and to FORBID `style`/`svg`/`math` plus all event handlers and
* dangerous URL schemes.
*
* Cross-environment: in the browser this file runs as a classic <script> after
* vendor/dompurify.min.js and wires `window.sanitizeMarkdownHtml`. The factory is also exported
* (window/globalThis + CommonJS) so a jsdom unit test can build a sanitizer bound to a
* jsdom-window DOMPurify instance and exercise the exact same config.
*
* @globals {function} sanitizeMarkdownHtml - (html:string) => string, sanitized HTML
* @globals {function} createMarkdownSanitizer - (DOMPurify) => sanitizeMarkdownHtml (for tests)
* @dependency vendor/dompurify.min.js (provides the global DOMPurify)
* @loadorder 5.6 of 15 — after input-cjk.js(5.5), before app.js(6) (app.js calls it)
*/
(function (root) {
'use strict';
/**
* Tags markdown rendering (marked, gfm) legitimately emits. Anything outside this set is
* dropped by DOMPurify. Deliberately excludes svg/math (mXSS foreign-namespace vectors) and
* form/embed/object/iframe/script/style (no place in rendered markdown).
*/
var ALLOWED_TAGS = [
'a',
'b',
'blockquote',
'br',
'caption',
'code',
'del',
'div',
'em',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'hr',
'i',
'img',
'ins',
'kbd',
'li',
'mark',
'ol',
'p',
'pre',
'q',
's',
'samp',
'span',
'strong',
'sub',
'sup',
'table',
'tbody',
'td',
'tfoot',
'th',
'thead',
'tr',
'ul',
'var',
];
/**
* Attributes allowed on the tags above. `style` is intentionally absent (CSS-based vectors).
* `class`/`id` survive because the response viewer adds wrapper classes downstream and code
* blocks may carry `language-*` classes from marked.
*/
var ALLOWED_ATTR = [
'href',
'src',
'alt',
'title',
'class',
'id',
'name',
'colspan',
'rowspan',
'align',
'width',
'height',
'lang',
'dir',
'start',
'reversed',
'type',
];
/**
* Build a sanitizer bound to a specific DOMPurify instance. The browser passes the global
* DOMPurify; tests pass a jsdom-window-bound instance so the same config is exercised under
* vitest without a real browser.
*/
function createMarkdownSanitizer(DOMPurify) {
if (!DOMPurify || typeof DOMPurify.sanitize !== 'function') {
throw new Error('createMarkdownSanitizer: a DOMPurify instance is required');
}
var CONFIG = {
ALLOWED_TAGS: ALLOWED_TAGS,
ALLOWED_ATTR: ALLOWED_ATTR,
// Defense in depth even though style/svg/math are not in ALLOWED_TAGS: also forbid the
// foreign-namespace roots and style so config drift can't silently re-admit them.
FORBID_TAGS: ['style', 'svg', 'math', 'script', 'iframe', 'object', 'embed', 'form'],
FORBID_ATTR: ['style'],
// NOTE: do NOT set USE_PROFILES here. DOMPurify treats USE_PROFILES and
// ALLOWED_TAGS/ALLOWED_ATTR as mutually exclusive — when a profile is set it
// RESETS the allow-lists to the full profile and silently ignores the curated
// lists above, widening the tag set far beyond what markdown emits. Relying on
// the explicit ALLOWED_TAGS/ALLOWED_ATTR keeps the tight allowlist in force;
// FORBID_TAGS/FORBID_ATTR remain as defense-in-depth. DOMPurify still applies
// its default safe-URI handling (blocks javascript:/vbscript:, allows
// http/https/mailto/tel + data: only on image tags).
ALLOW_DATA_ATTR: false,
ADD_ATTR: [],
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false,
// Keep text content of any removed element (so stripping a stray tag doesn't eat prose),
// matching the previous serializer's behavior of dropping the element but not its text.
KEEP_CONTENT: true,
};
return function sanitizeMarkdownHtml(html) {
return DOMPurify.sanitize(html == null ? '' : String(html), CONFIG);
};
}
// Expose the factory for tests (and any non-browser consumer).
if (root) {
root.createMarkdownSanitizer = createMarkdownSanitizer;
// In the browser, vendor/dompurify.min.js has already defined the global DOMPurify.
if (root.DOMPurify && typeof root.DOMPurify.sanitize === 'function') {
root.sanitizeMarkdownHtml = createMarkdownSanitizer(root.DOMPurify);
}
}
// CommonJS export for the vitest/jsdom unit test.
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
createMarkdownSanitizer: createMarkdownSanitizer,
ALLOWED_TAGS: ALLOWED_TAGS,
ALLOWED_ATTR: ALLOWED_ATTR,
};
}
})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
+8
View File
@@ -312,6 +312,13 @@ body {
max-height: 120px;
}
.session-tabs.tabs-auto-wrap {
flex-wrap: wrap;
overflow-x: hidden;
overflow-y: auto;
max-height: 96px;
}
.session-tabs::-webkit-scrollbar {
width: 4px;
height: 0;
@@ -8968,6 +8975,7 @@ kbd {
display: block;
min-height: 44px;
max-height: 96px;
padding: 12px 10px;
border: 1px solid rgba(80, 120, 190, 0.55);
border-left: none;
border-right: none;
+14 -2
View File
@@ -114,8 +114,14 @@ Object.assign(CodemanApp.prototype, {
this.terminal.attachCustomKeyEventHandler((ev) => {
if (ev.isComposing || ev.keyCode === 229) return false;
// Let Alt+digit pass through to browser (tab switching)
if (ev.altKey && ev.key >= '0' && ev.key <= '9') return false;
// Let the app's Alt/Option session-nav shortcuts reach the document keydown handler
// (app.js switches tabs by PHYSICAL e.code) instead of xterm injecting ESC<char> into
// the PTY. Mirror app.js's gate exactly — same physical codes + modifier guard — so
// macOS Option layouts (Option+1 -> "¡", Option+[ -> "“") are suppressed here too and
// don't leak an escape sequence into the focused terminal on every tab switch.
if (ev.altKey && !ev.ctrlKey && !ev.shiftKey && /^(Digit[1-9]|BracketLeft|BracketRight)$/.test(ev.code || '')) {
return false;
}
// Ctrl+V / Cmd+V: intercept before xterm sends ^V to PTY.
// Route through our paste trap which handles both images and text.
@@ -937,6 +943,9 @@ Object.assign(CodemanApp.prototype, {
this.loadTunnelStatus();
this.loadHistorySessions();
}
// Home screen has no input target — hide the CJK textarea (activeSessionId
// is null by the time we get here). Guarded: defined on the app object.
this._updateCjkInputState?.();
},
hideWelcome() {
@@ -950,6 +959,9 @@ Object.assign(CodemanApp.prototype, {
clearTimeout(this._welcomeQrShrinkTimer);
qrWrap.classList.remove('expanded');
}
// Entering a session — restore CJK textarea if the user has it enabled
// (activeSessionId is already set by selectSession before this call).
this._updateCjkInputState?.();
},
/**
File diff suppressed because one or more lines are too long
+19 -1
View File
@@ -45,7 +45,13 @@ import {
validatePathWithinBase,
} from '../route-helpers.js';
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
import { writeHooksConfig, updateCaseModel, stripCaseEnvKeys, applyStatusLineConfig } from '../../hooks-config.js';
import {
writeHooksConfig,
updateCaseModel,
stripCaseEnvKeys,
applyStatusLineConfig,
refreshStaleHookSecret,
} from '../../hooks-config.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { imageWatcher } from '../../image-watcher.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js';
@@ -312,6 +318,13 @@ export function registerSessionRoutes(
await applyStatusLineConfig(workingDir, true);
}
// COD-91 self-heal: refresh a pre-secret hooks block in an existing case so the now
// unconditional hook-secret gate keeps accepting its hook events. No-op for fresh
// cases (writeHooksConfig already wrote the secret) and for non-Codeman/absent hooks.
if ((body.mode ?? 'claude') === 'claude') {
await refreshStaleHookSecret(workingDir).catch(() => {});
}
// Check OpenCode availability if requested
if (body.mode === 'opencode') {
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
@@ -1279,6 +1292,11 @@ export function registerSessionRoutes(
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
}
} else if (mode !== 'opencode') {
// COD-91 self-heal for an EXISTING case: refresh a pre-secret hooks block so the
// now-unconditional hook-secret gate keeps accepting its hook events. No-op when
// the hooks aren't ours or already carry the secret.
await refreshStaleHookSecret(casePath).catch(() => {});
}
// Strip stale disk entries for keys this request is actively setting (Claude only —
+1 -1
View File
@@ -630,7 +630,7 @@ export class WebServer extends EventEmitter {
registerHostGuard(this.app, () => this.getHostPolicy());
// Auth middleware (Basic Auth + session cookies + rate limiting)
const authState = registerAuthMiddleware(this.app, this.https, () => this.tunnelManager.isRunning());
const authState = registerAuthMiddleware(this.app, this.https);
if (authState) {
this.authSessions = authState.authSessions;
this.authFailures = authState.authFailures;
+7 -6
View File
@@ -14,6 +14,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi }
import { WebServer } from '../src/web/server.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
import { getHookSecret, HOOK_SECRET_HEADER } from '../src/config/hook-secret.js';
const AUTH_PORT = 3160;
const NOAUTH_PORT = 3161;
@@ -250,28 +251,28 @@ describe('Auth Security', () => {
});
describe('Hook Event Endpoint', () => {
it('should allow hook events from localhost without auth', async () => {
it('should allow hook events from localhost with the hook secret (no Basic auth)', async () => {
const res = await fetch(`${baseUrl}/api/hook-event`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', [HOOK_SECRET_HEADER]: getHookSecret() },
body: JSON.stringify({
event: 'stop',
sessionId: 'nonexistent-session',
data: {},
}),
});
// Should pass auth (localhost bypass) but may 404 on session — that's fine
// The key assertion is it does NOT return 401
// Should pass auth (localhost bypass + hook secret) but may 404 on session — that's fine.
// The key assertion is it does NOT return 401 (COD-91: secret required even with no tunnel).
expect(res.status).not.toBe(401);
});
it('should reject hook events with invalid schema', async () => {
const res = await fetch(`${baseUrl}/api/hook-event`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', [HOOK_SECRET_HEADER]: getHookSecret() },
body: JSON.stringify({ invalid: 'data' }),
});
// Schema validation should catch this
// Past the auth gate (valid secret) → schema validation should catch this (not a 401).
expect(res.status).not.toBe(401); // Not an auth error
});
});
+15 -6
View File
@@ -4,15 +4,19 @@
* The `/api/hook-event` localhost bypass let tunnel traffic (cloudflared
* --url http://127.0.0.1:port) reach the loopback origin with req.ip ===
* 127.0.0.1 and drive respawn/Ralph signals unauthenticated. The fix gates
* the bypass behind a shared hook secret WHEN A TUNNEL IS RUNNING, while
* keeping the plain localhost bypass for the normal loopback-only case so
* already-deployed (pre-secret) hooks and the loop's own channel keep working.
* the bypass behind a shared hook secret. COD-91 makes that requirement
* UNCONDITIONAL — the loopback bypass requires the secret whether or not a
* managed tunnel is running, because Codeman can't detect a user's own loopback
* reverse proxy (own cloudflared / `tailscale serve` / nginx → 127.0.0.1).
* Managed-session hooks always present the secret, so the legitimate channel
* keeps working.
*
* Tests:
* - tunnel running + no secret → 401 (closes the hole)
* - tunnel running + bad secret → 401
* - tunnel running + good secret → not 401 (allowed)
* - tunnel NOT running + no secret → not 401 (back-compat regression guard)
* - tunnel NOT running + no secret → 401 (COD-91: secret required unconditionally)
* - tunnel NOT running + good secret → not 401 (allowed)
* - rate limiting: rapid unauthorized hook POSTs eventually 429
*
* Port: 3230 (tunnel-running), 3231 (tunnel-down), 3232 (rate-limit)
@@ -83,7 +87,7 @@ describe('COD-54 hook-event auth — tunnel running requires secret', () => {
});
});
describe('COD-54 hook-event auth — tunnel down keeps localhost bypass (back-compat)', () => {
describe('COD-91 hook-event auth — tunnel down ALSO requires the secret', () => {
let server: WebServer;
let baseUrl: string;
let isRunningSpy: ReturnType<typeof vi.spyOn>;
@@ -105,8 +109,13 @@ describe('COD-54 hook-event auth — tunnel down keeps localhost bypass (back-co
delete process.env.CODEMAN_USERNAME;
});
it('still allows a localhost hook POST WITHOUT a secret (existing hooks + loop channel keep working)', async () => {
it('rejects a localhost hook POST WITHOUT a secret even with no tunnel (COD-91)', async () => {
const res = await postHook(baseUrl);
expect(res.status).toBe(401);
});
it('allows a localhost hook POST WITH the correct secret when no tunnel is running', async () => {
const res = await postHook(baseUrl, { [HOOK_SECRET_HEADER]: getHookSecret() });
expect(res.status).not.toBe(401);
});
});
+181
View File
@@ -0,0 +1,181 @@
import { describe, it, expect } from 'vitest';
import { DEPENDENCY_REGISTRY } from '../src/config/dependency-registry.js';
import {
detectEnvironment,
extractVersion,
compareVersions,
checkTool,
checkAll,
createRealHost,
} from '../src/utils/dependency-checker.js';
import type { ProbeHost } from '../src/utils/dependency-checker.js';
import type { ProbeEnvironment, ToolDependency } from '../src/config/dependency-registry.js';
describe('DEPENDENCY_REGISTRY', () => {
it('has unique ids', () => {
const ids = DEPENDENCY_REGISTRY.map((t) => t.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('hard-requires only node and tmux; agent CLIs and office are optional', () => {
const required = DEPENDENCY_REGISTRY.filter((t) => t.required)
.map((t) => t.id)
.sort();
expect(required).toEqual(['node', 'tmux']);
// all agent CLIs are optional (Codeman runs any of them)
const agentClis = ['claude', 'opencode', 'codex'];
expect(DEPENDENCY_REGISTRY.filter((t) => agentClis.includes(t.id)).every((t) => t.required === false)).toBe(true);
const office = DEPENDENCY_REGISTRY.filter((t) => t.category === 'office');
expect(office.every((t) => t.required === false)).toBe(true);
});
it('gives msoffice a windows-side resolver scoped to wsl + win32 only', () => {
const ms = DEPENDENCY_REGISTRY.find((t) => t.id === 'msoffice');
expect(ms).toBeDefined();
const spec = ms!.resolvers.find((r) => r.resolver.kind === 'windows-side');
expect(spec).toBeDefined();
expect([...spec!.match].sort()).toEqual(['win32', 'wsl']);
expect(ms!.resolvers.some((r) => r.match.includes('linux'))).toBe(false);
});
});
describe('detectEnvironment', () => {
it('returns win32/darwin straight from platform', () => {
expect(detectEnvironment({ platform: 'win32', procVersion: '', hasWindowsInterop: false })).toBe('win32');
expect(detectEnvironment({ platform: 'darwin', procVersion: '', hasWindowsInterop: false })).toBe('darwin');
});
it('detects wsl from /proc/version + interop, else linux', () => {
const wsl = detectEnvironment({
platform: 'linux',
procVersion: 'Linux version 6.6 (Microsoft@WSL2)',
hasWindowsInterop: true,
});
expect(wsl).toBe('wsl');
expect(detectEnvironment({ platform: 'linux', procVersion: 'Microsoft', hasWindowsInterop: false })).toBe('linux');
expect(detectEnvironment({ platform: 'linux', procVersion: 'generic', hasWindowsInterop: true })).toBe('linux');
});
});
describe('extractVersion', () => {
it('pulls a dotted version from typical --version output', () => {
expect(extractVersion('v22.22.1')).toBe('22.22.1');
expect(extractVersion('tmux 3.4')).toBe('3.4');
expect(extractVersion('no digits here')).toBeUndefined();
});
it('honors a custom regex', () => {
expect(extractVersion('ProductVersion 16.0.19929.20172', /(\d+\.\d+\.\d+)/)).toBe('16.0.19929');
});
});
describe('compareVersions', () => {
it('orders by numeric components', () => {
expect(compareVersions('18.0.0', '18.0.0')).toBe(0);
expect(compareVersions('16.5.0', '18.0.0')).toBe(-1);
expect(compareVersions('22.22.1', '18.0.0')).toBe(1);
expect(compareVersions('3.4', '3.4.0')).toBe(0);
});
});
function fakeHost(env: ProbeEnvironment, over: Partial<ProbeHost> = {}): ProbeHost {
return {
environment: env,
which: () => null,
fileExists: () => false,
runVersion: () => null,
windowsProgramRoots: () => [],
windowsFileVersion: () => null,
...over,
};
}
const tmuxTool: ToolDependency = {
id: 'tmux',
label: 'tmux',
category: 'core',
required: true,
resolvers: [{ match: ['linux', 'wsl'], resolver: { kind: 'path', bins: ['tmux'], versionArg: '-V' } }],
};
const nodeTool: ToolDependency = {
id: 'node',
label: 'Node.js',
category: 'core',
required: true,
minVersion: '18.0.0',
resolvers: [{ match: ['linux'], resolver: { kind: 'path', bins: ['node'] } }],
};
const msTool: ToolDependency = {
id: 'msoffice',
label: 'MS Office',
category: 'office',
required: false,
resolvers: [
{
match: ['wsl', 'win32'],
resolver: { kind: 'windows-side', appDirs: ['Microsoft Office/root/Office16'], exes: ['WINWORD.EXE'] },
},
],
};
describe('checkTool', () => {
it('reports ok with path + version when found on PATH', () => {
const host = fakeHost('linux', {
which: (b) => (b === 'tmux' ? '/usr/bin/tmux' : null),
runVersion: () => 'tmux 3.4',
});
expect(checkTool(tmuxTool, host)).toMatchObject({
id: 'tmux',
status: 'ok',
version: '3.4',
path: '/usr/bin/tmux',
});
});
it('reports missing when no bin resolves', () => {
expect(checkTool(tmuxTool, fakeHost('linux'))).toMatchObject({ id: 'tmux', status: 'missing' });
});
it('reports outdated when below minVersion', () => {
const host = fakeHost('linux', { which: () => '/n', runVersion: () => 'v16.5.0' });
expect(checkTool(nodeTool, host)).toMatchObject({ id: 'node', status: 'outdated', version: '16.5.0' });
});
it('reports error when minVersion set but version unparseable', () => {
const host = fakeHost('linux', { which: () => '/n', runVersion: () => 'unknown' });
expect(checkTool(nodeTool, host)).toMatchObject({ id: 'node', status: 'error' });
});
it('reports skipped when no resolver matches the environment', () => {
expect(checkTool(msTool, fakeHost('linux'))).toMatchObject({ id: 'msoffice', status: 'skipped' });
});
it('finds windows-side apps under WSL', () => {
const host = fakeHost('wsl', {
windowsProgramRoots: () => ['/mnt/c/Program Files'],
fileExists: (p) => p === '/mnt/c/Program Files/Microsoft Office/root/Office16/WINWORD.EXE',
windowsFileVersion: () => '16.0.19929.20172',
});
expect(checkTool(msTool, host)).toMatchObject({
id: 'msoffice',
status: 'ok',
version: '16.0.19929',
path: '/mnt/c/Program Files/Microsoft Office/root/Office16/WINWORD.EXE',
});
});
});
describe('checkAll', () => {
it('maps every tool to a result', () => {
const results = checkAll([tmuxTool, msTool], fakeHost('linux'));
expect(results.map((r) => r.id)).toEqual(['tmux', 'msoffice']);
});
});
describe('createRealHost', () => {
it('returns a host with a valid detected environment and callable methods', () => {
const host = createRealHost();
expect(['linux', 'darwin', 'win32', 'wsl']).toContain(host.environment);
expect(typeof host.which).toBe('function');
expect(Array.isArray(host.windowsProgramRoots())).toBe(true);
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest';
import { renderTable, renderJson, computeExitCode } from '../src/utils/dependency-report.js';
import type { ToolResult } from '../src/utils/dependency-checker.js';
const results: ToolResult[] = [
{
id: 'node',
label: 'Node.js',
category: 'core',
required: true,
usedBy: [],
status: 'ok',
version: '22.22.1',
path: '/n',
},
{
id: 'tmux',
label: 'tmux',
category: 'core',
required: true,
usedBy: [],
status: 'missing',
installHint: 'sudo apt install tmux',
},
{
id: 'libreoffice',
label: 'LibreOffice',
category: 'office',
required: false,
usedBy: ['document preview', 'thumbnails'],
status: 'missing',
},
{
id: 'msoffice',
label: 'MS Office',
category: 'office',
required: false,
usedBy: ['document preview', 'thumbnails'],
status: 'skipped',
reason: 'not applicable on linux',
},
];
describe('computeExitCode', () => {
it('non-zero when a required tool is missing/outdated/error', () => {
expect(computeExitCode(results)).toBe(1);
});
it('zero when only optional tools are missing', () => {
const ok = results.filter((r) => r.id !== 'tmux');
expect(computeExitCode(ok)).toBe(0);
});
});
describe('renderTable', () => {
it('groups by category and shows status, version, and install hints', () => {
const out = renderTable(results, 'linux');
expect(out).toContain('CORE');
expect(out).toContain('Node.js');
expect(out).toContain('22.22.1');
expect(out).toContain('OFFICE');
expect(out).toContain('document preview');
expect(out).toContain('sudo apt install tmux');
});
});
describe('renderJson', () => {
it('includes environment, summary, and per-tool data', () => {
const json = renderJson(results, 'linux');
expect(json.platform.environment).toBe('linux');
expect(json.summary.exitCode).toBe(1);
expect(json.summary.ok).toBe(1);
expect(json.tools).toHaveLength(4);
});
});
+106
View File
@@ -0,0 +1,106 @@
/**
* COD-91 — `refreshStaleHookSecret` self-heal.
*
* Making the hook-event secret unconditionally required (PR #127) would silently 401 the
* hook curls baked into cases created before the secret header existed (COD-54). Those
* curls live in `.claude/settings.local.json` and `writeHooksConfig` only runs at case
* CREATION, so existing cases never refresh. `refreshStaleHookSecret` regenerates the
* hooks block on session spawn — but ONLY when the case already holds Codeman's own
* pre-secret hook curls, never clobbering a user's customizations.
*
* Pure filesystem logic against a temp dir — no port / server / tmux.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { refreshStaleHookSecret } from '../src/hooks-config.js';
const SECRET_HEADER = 'X-Codeman-Hook-Secret';
// A faithful pre-secret Codeman hook curl (what cases created before COD-54 contain):
// targets /api/hook-event, but with NO X-Codeman-Hook-Secret header.
function staleCodemanHooks() {
return {
Stop: [
{
matcher: '',
hooks: [
{
type: 'command',
command:
"HOOK_DATA=$(cat 2>/dev/null || echo '{}'); " +
'printf \'{"event":"stop","sessionId":"%s","data":%s}\' "$CODEMAN_SESSION_ID" "$HOOK_DATA" | ' +
'curl -s -X POST "$CODEMAN_API_URL/api/hook-event" -H \'Content-Type: application/json\' --data @- 2>/dev/null || true',
timeout: 5,
},
],
},
],
};
}
describe('refreshStaleHookSecret', () => {
let dir: string;
let settingsPath: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'codeman-selfheal-'));
mkdirSync(join(dir, '.claude'), { recursive: true });
settingsPath = join(dir, '.claude', 'settings.local.json');
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('adds the secret header to a stale Codeman hooks block and preserves other keys', async () => {
writeFileSync(
settingsPath,
JSON.stringify({ env: { CLAUDE_CODE_FOO: '1' }, model: 'opus', hooks: staleCodemanHooks() }, null, 2)
);
await refreshStaleHookSecret(dir);
const after = JSON.parse(readFileSync(settingsPath, 'utf-8'));
expect(JSON.stringify(after.hooks)).toContain(SECRET_HEADER);
expect(JSON.stringify(after.hooks)).toContain('CODEMAN_HOOK_SECRET_FILE');
// sibling keys untouched
expect(after.env).toEqual({ CLAUDE_CODE_FOO: '1' });
expect(after.model).toBe('opus');
});
it('leaves a hooks block that already carries the secret unchanged', async () => {
// Seed with a current block by healing a stale one first, then re-heal: second pass must no-op.
writeFileSync(settingsPath, JSON.stringify({ hooks: staleCodemanHooks() }, null, 2));
await refreshStaleHookSecret(dir);
const healed = readFileSync(settingsPath, 'utf-8');
expect(healed).toContain(SECRET_HEADER);
await refreshStaleHookSecret(dir);
expect(readFileSync(settingsPath, 'utf-8')).toBe(healed); // byte-identical: no rewrite
});
it('does not touch hooks that are not Codeman’s (no /api/hook-event)', async () => {
const foreign = JSON.stringify(
{ hooks: { Stop: [{ matcher: '', hooks: [{ type: 'command', command: 'echo hi', timeout: 5 }] }] } },
null,
2
);
writeFileSync(settingsPath, foreign);
await refreshStaleHookSecret(dir);
expect(readFileSync(settingsPath, 'utf-8')).toBe(foreign);
});
it('is a no-op when settings.local.json is absent (does not create one)', async () => {
await refreshStaleHookSecret(dir);
expect(existsSync(settingsPath)).toBe(false);
});
it('leaves a malformed settings file untouched', async () => {
const garbage = '{ not valid json';
writeFileSync(settingsPath, garbage);
await refreshStaleHookSecret(dir);
expect(readFileSync(settingsPath, 'utf-8')).toBe(garbage);
});
});
+37
View File
@@ -0,0 +1,37 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const appSource = readFileSync('src/web/public/app.js', 'utf8');
const terminalUiSource = readFileSync('src/web/public/terminal-ui.js', 'utf8');
const helpHtml = readFileSync('src/web/public/index.html', 'utf8');
const readme = readFileSync('README.md', 'utf8');
describe('keyboard shortcuts', () => {
it('uses physical Option+number keys so macOS special characters do not break tab switching', () => {
expect(appSource).toContain('e.code ||');
expect(appSource).toContain('Digit([1-9])');
expect(appSource).toContain('parseInt(digitMatch[1], 10) - 1');
});
it('provides Option+bracket shortcuts for previous and next session', () => {
expect(appSource).toContain("e.code === 'BracketLeft'");
expect(appSource).toContain("e.code === 'BracketRight'");
expect(appSource).toContain('this.prevSession()');
expect(appSource).toContain('this.nextSession()');
});
it('suppresses xterm PTY injection for the same physical Alt nav codes (no ESC leak)', () => {
// terminal-ui.js must gate its xterm pass-through on the SAME physical e.code set the
// app.js handler consumes; otherwise Alt+[ / Alt+] (and Option+digit on remapped macOS
// layouts) switch tabs AND inject ESC<char> into the focused terminal. Keep in sync.
expect(terminalUiSource).toContain('/^(Digit[1-9]|BracketLeft|BracketRight)$/.test(ev.code');
});
it('documents the Alt/Option shortcuts in help and README', () => {
expect(helpHtml).toContain('<kbd>Alt/Option</kbd>+<kbd>[</kbd>');
expect(helpHtml).toContain('<kbd>Alt/Option</kbd>+<kbd>]</kbd>');
expect(helpHtml).toContain('<kbd>Alt/Option</kbd>+<kbd>1-9</kbd>');
expect(readme).toContain('`Alt/Option+[` / `Alt/Option+]`');
expect(readme).toContain('`Alt/Option+1`-`Alt/Option+9`');
});
});
+226
View File
@@ -0,0 +1,226 @@
/**
* COD-56 — markdown HTML sanitizer (mXSS hardening).
*
* The response viewer / attachment preview render agent- and transcript-derived markdown to
* HTML via `marked` (raw-HTML passthrough) and assign the result with innerHTML. The HTML must
* be sanitized first. The original sanitizer (`_sanitizeHtml` in app.js) was a hand-rolled
* DENYLIST and is mXSS-prone — it never stripped `svg`/`math`/`style`, so foreign-namespace and
* CSS vectors survived.
*
* This suite drives the EXACT shipping artifacts:
* - src/web/public/vendor/dompurify.min.js (the vendored sanitizer)
* - src/web/public/sanitize-html.js (our allowlist config wired to DOMPurify)
*
* It runs in the DEFAULT node environment (it deliberately does NOT declare a per-file jsdom
* environment) and constructs a jsdom window here, then binds the vendored DOMPurify to it. A
* per-file jsdom environment externalizes node:fs/node:path under vite, which made this suite fail
* to load when
* run in isolation (it only survived the full CI run because an earlier node-env test happened to
* pre-cache node:fs). Building the window in-test keeps fs/path native and the suite order-robust.
*
* It feeds a corpus of mXSS payloads (svg/math/style/namespace-confusion/event-handler) and
* asserts the output carries NO script-executing constructs, that the curated allowlist is
* actually enforced (non-markdown tags dropped), and that legitimate markdown-rendered HTML
* survives unchanged. A faithful re-implementation of the OLD denylist is included and asserted to
* LET payloads through — the gap this fix closes.
*
* No port / server needed.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { JSDOM } from 'jsdom';
const publicDir = join(process.cwd(), 'src/web/public');
// One jsdom window shared by the shipping sanitizer (bound to its DOMPurify) and the old-denylist
// reference impl (which needs a DOM `document`).
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
const jsdomWindow = dom.window as unknown as Window & typeof globalThis;
const jsdomDocument = jsdomWindow.document;
/** Build the SHIPPING sanitizer the way the browser does: vendored DOMPurify (bound to our jsdom
* window) + the EXACT CONFIG from sanitize-html.js — so the same allow/forbid lists are exercised
* under vitest without a real browser. */
function loadShippingSanitizer(): (html: string) => string {
const dompurifySrc = readFileSync(join(publicDir, 'vendor/dompurify.min.js'), 'utf8');
const sanitizeSrc = readFileSync(join(publicDir, 'sanitize-html.js'), 'utf8');
// dompurify.min.js is a UMD — evaluate it as CommonJS to obtain the factory (createDOMPurify),
// then bind it to our jsdom window so DOMPurify sanitizes against a real DOM.
const dpModule: { exports: unknown } = { exports: {} };
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
new Function('module', 'exports', dompurifySrc)(dpModule, dpModule.exports);
const factory = dpModule.exports as (win: unknown) => { sanitize: (h: string, c?: unknown) => string };
const DOMPurify = factory(jsdomWindow);
// sanitize-html.js exposes createMarkdownSanitizer via its CommonJS export.
const sanModule: { exports: { createMarkdownSanitizer?: (dp: unknown) => (html: string) => string } } = {
exports: {},
};
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
new Function('module', 'exports', sanitizeSrc)(sanModule, sanModule.exports);
const create = sanModule.exports.createMarkdownSanitizer;
if (typeof create !== 'function') throw new Error('createMarkdownSanitizer not exported');
const fn = create(DOMPurify);
if (typeof fn !== 'function') throw new Error('sanitizeMarkdownHtml not wired');
return fn;
}
/** Faithful copy of the OLD denylist _sanitizeHtml (app.js pre-COD-56) — used only to prove RED. */
function oldDenylistSanitize(html: string): string {
const tpl = jsdomDocument.createElement('template');
tpl.innerHTML = html;
const frag = tpl.content;
for (const el of frag.querySelectorAll('script, iframe, object, embed, form, base, meta, link, style')) {
el.remove();
}
for (const el of frag.querySelectorAll('*')) {
for (const attr of [...el.attributes]) {
const name = attr.name.toLowerCase();
if (name.startsWith('on')) {
el.removeAttribute(attr.name);
} else if (['href', 'src', 'action', 'xlink:href', 'formaction'].includes(name)) {
const val = attr.value.replace(/\s/g, '').toLowerCase();
if (val.startsWith('javascript:') || val.startsWith('vbscript:') || val.startsWith('data:text/html')) {
el.removeAttribute(attr.name);
}
}
}
}
const div = jsdomDocument.createElement('div');
div.appendChild(frag);
return div.innerHTML;
}
// mXSS / XSS payloads. Each must be neutralized by the shipping sanitizer.
const PAYLOADS: { name: string; html: string }[] = [
{ name: 'img onerror', html: '<img src=x onerror=alert(1)>' },
{ name: 'svg onload', html: '<svg onload=alert(1)></svg>' },
{ name: 'svg/script', html: '<svg><script>alert(1)</script></svg>' },
{ name: 'svg/style mXSS', html: '<svg><style><img src=x onerror=alert(1)></style></svg>' },
{
name: 'math/mtext/table namespace confusion',
html: '<math><mtext><table><mglyph><style><img src=x onerror=alert(1)></style></table></mtext></math>',
},
{ name: 'style attr expression', html: '<div style="width:expression(alert(1))">x</div>' },
{ name: 'style attr url(javascript:)', html: '<div style="background:url(javascript:alert(1))">x</div>' },
{ name: 'style element', html: '<style>body{background:url("javascript:alert(1)")}</style>' },
{ name: 'noscript wrap', html: '<noscript><p title="</noscript><img src=x onerror=alert(1)>">' },
{ name: 'a javascript: href', html: '<a href="javascript:alert(1)">x</a>' },
{ name: 'iframe srcdoc', html: '<iframe srcdoc="<img src=x onerror=alert(1)>"></iframe>' },
{
name: 'foreignObject mXSS',
html: '<svg><foreignObject><iframe src="javascript:alert(1)"></iframe></foreignObject></svg>',
},
{ name: 'details ontoggle', html: '<details open ontoggle=alert(1)>x</details>' },
{ name: 'object data', html: '<object data="javascript:alert(1)"></object>' },
];
function assertNeutralized(out: string, label: string) {
const lower = out.toLowerCase();
expect(lower, `${label}: no <script>`).not.toContain('<script');
expect(lower, `${label}: no <svg>`).not.toContain('<svg');
expect(lower, `${label}: no <math>`).not.toContain('<math');
expect(lower, `${label}: no <iframe>`).not.toContain('<iframe');
expect(lower, `${label}: no <object>`).not.toContain('<object');
expect(lower, `${label}: no <style>`).not.toContain('<style');
expect(lower, `${label}: no onerror`).not.toContain('onerror');
expect(lower, `${label}: no onload`).not.toContain('onload');
expect(lower, `${label}: no ontoggle`).not.toContain('ontoggle');
expect(lower, `${label}: no style= attr`).not.toMatch(/\sstyle\s*=/);
expect(lower, `${label}: no javascript: scheme`).not.toContain('javascript:');
expect(lower, `${label}: no expression(`).not.toContain('expression(');
}
describe('COD-56 markdown sanitizer (DOMPurify allowlist)', () => {
let sanitize: (html: string) => string;
beforeAll(() => {
sanitize = loadShippingSanitizer();
});
describe('mXSS / XSS payloads are neutralized', () => {
for (const { name, html } of PAYLOADS) {
it(`blocks: ${name}`, () => {
assertNeutralized(sanitize(html), name);
});
}
});
describe('curated allowlist is actually enforced (USE_PROFILES must not override it)', () => {
// These tags are in DOMPurify's default html profile but NOT in the curated ALLOWED_TAGS.
// If USE_PROFILES were set, the profile would override the allowlist and these would survive.
const NON_MARKDOWN_TAGS: { name: string; html: string; tag: string }[] = [
{ name: 'button', html: '<button>click</button>', tag: '<button' },
{ name: 'input', html: '<input value="x">', tag: '<input' },
{ name: 'details', html: '<details open>d</details>', tag: '<details' },
{ name: 'audio', html: '<audio controls></audio>', tag: '<audio' },
{ name: 'select/option', html: '<select><option>o</option></select>', tag: '<select' },
{ name: 'label', html: '<label>l</label>', tag: '<label' },
];
for (const { name, html, tag } of NON_MARKDOWN_TAGS) {
it(`drops non-markdown tag: ${name}`, () => {
expect(sanitize(html).toLowerCase()).not.toContain(tag);
});
}
});
describe('legitimate markdown-rendered HTML survives', () => {
it('keeps bold, links, lists, code, headings, tables, safe images', () => {
const md =
'<h2>Title</h2>' +
'<p><strong>bold</strong> and <em>em</em> and <a href="https://example.com">link</a></p>' +
'<ul><li>one</li><li>two</li></ul>' +
'<pre><code class="language-js">const x = 1;</code></pre>' +
'<blockquote><p>quote</p></blockquote>' +
'<table><thead><tr><th>h</th></tr></thead><tbody><tr><td>c</td></tr></tbody></table>' +
'<img src="https://example.com/a.png" alt="pic">';
const out = sanitize(md);
expect(out).toContain('<strong>bold</strong>');
expect(out).toContain('<em>em</em>');
expect(out).toContain('href="https://example.com"');
expect(out).toContain('<li>one</li>');
expect(out).toContain('<code class="language-js">const x = 1;</code>');
expect(out).toContain('<blockquote>');
expect(out).toContain('<th>h</th>');
expect(out).toContain('<td>c</td>');
expect(out).toContain('src="https://example.com/a.png"');
expect(out).toContain('alt="pic"');
});
it('preserves a relative/inline image src and code fences', () => {
const out = sanitize('<p>see <code>code</code></p><img src="/local/path.png" alt="x">');
expect(out).toContain('<code>code</code>');
expect(out).toContain('src="/local/path.png"');
});
});
// RED EVIDENCE: the OLD denylist let mXSS through. This documents the gap the fix closes;
// it asserts the OLD logic FAILS to neutralize at least the svg/math/style vectors.
describe('RED: the old denylist sanitizer was bypassable', () => {
it('old code leaves <svg> / <math> roots in the output', () => {
// svg/math were never in the denylist tag set -> they survive (mXSS foreign namespace).
const svgOut = oldDenylistSanitize('<svg><circle></circle></svg>').toLowerCase();
const mathOut = oldDenylistSanitize('<math><mtext>x</mtext></math>').toLowerCase();
expect(svgOut).toContain('<svg');
expect(mathOut).toContain('<math');
});
it('old code leaves a CSS-vector style attribute in the output', () => {
const out = oldDenylistSanitize('<div style="background:url(javascript:alert(1))">x</div>').toLowerCase();
// style attributes were never stripped by the denylist.
expect(out).toMatch(/\sstyle\s*=/);
expect(out).toContain('javascript:');
});
it('NEW code closes those same gaps', () => {
expect(sanitize('<svg><circle></circle></svg>').toLowerCase()).not.toContain('<svg');
expect(sanitize('<math><mtext>x</mtext></math>').toLowerCase()).not.toContain('<math');
const out = sanitize('<div style="background:url(javascript:alert(1))">x</div>').toLowerCase();
expect(out).not.toMatch(/\sstyle\s*=/);
expect(out).not.toContain('javascript:');
});
});
});
+14 -4
View File
@@ -528,15 +528,24 @@ describe('Virtual Keyboard', () => {
expect(afterEnter.sentInputs).toEqual(['hello', '\r']);
});
it('shows the CJK textarea on mobile only for server override', async () => {
it('shows the CJK textarea on mobile for server override only inside an active session', async () => {
const state = await page.evaluate(() => {
app._serverCjkOverride = true;
app._updateCjkInputState();
const input = document.getElementById('cjkInput');
if (!(input instanceof HTMLElement)) return null;
// Welcome screen (no active session): even with the server override on, the
// fixed-position textarea must stay hidden so it doesn't float over the overlay.
app.activeSessionId = null;
app._serverCjkOverride = true;
app._updateCjkInputState();
const onWelcomeDisplay = getComputedStyle(input).display;
// Entering a session reveals it.
app.activeSessionId = 'cjk-server-override-test';
app._updateCjkInputState();
const cs = getComputedStyle(input);
return {
onWelcomeDisplay,
display: cs.display,
position: cs.position,
bottom: cs.bottom,
@@ -546,6 +555,7 @@ describe('Virtual Keyboard', () => {
});
expect(state).not.toBeNull();
expect(state?.onWelcomeDisplay).toBe('none');
expect(state?.display).not.toBe('none');
expect(state?.position).toBe('fixed');
expect(Number(state?.zIndex)).toBeGreaterThan(50);
+65
View File
@@ -0,0 +1,65 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
function loadTabOverflowHelper() {
const context = vm.createContext({ window: {}, globalThis: {} });
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
vm.runInContext(source, context, { filename: 'constants.js' });
return (context.window as { CodemanTabOverflow: { shouldAutoWrapTabs: (input: unknown) => boolean } })
.CodemanTabOverflow;
}
describe('tab overflow layout policy', () => {
it('auto-wraps desktop tabs when their rendered width exceeds available tab space', () => {
const helper = loadTabOverflowHelper();
expect(
helper.shouldAutoWrapTabs({
deviceType: 'desktop',
manualTwoRows: false,
tabCount: 18,
scrollWidth: 1400,
clientWidth: 760,
})
).toBe(true);
});
it('does not auto-wrap when manual tall tabs are enabled or on mobile/tablet', () => {
const helper = loadTabOverflowHelper();
expect(
helper.shouldAutoWrapTabs({
deviceType: 'desktop',
manualTwoRows: true,
tabCount: 18,
scrollWidth: 1400,
clientWidth: 760,
})
).toBe(false);
expect(
helper.shouldAutoWrapTabs({
deviceType: 'mobile',
manualTwoRows: false,
tabCount: 18,
scrollWidth: 1400,
clientWidth: 320,
})
).toBe(false);
});
it('respects the boundary conditions (exact fit, +1 tolerance, and tabCount < 2)', () => {
const helper = loadTabOverflowHelper();
const base = { deviceType: 'desktop' as const, manualTwoRows: false, tabCount: 6 };
// Exact fit: no overflow, no wrap.
expect(helper.shouldAutoWrapTabs({ ...base, scrollWidth: 800, clientWidth: 800 })).toBe(false);
// Within the +1 sub-pixel tolerance: still no wrap.
expect(helper.shouldAutoWrapTabs({ ...base, scrollWidth: 801, clientWidth: 800 })).toBe(false);
// 2px over: wrap.
expect(helper.shouldAutoWrapTabs({ ...base, scrollWidth: 802, clientWidth: 800 })).toBe(true);
// A single overflowing tab must not wrap (need at least 2 to form a second row).
expect(helper.shouldAutoWrapTabs({ ...base, tabCount: 1, scrollWidth: 1400, clientWidth: 760 })).toBe(false);
});
});