mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-02 14:19:33 +01:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c5920d3b9 | ||
|
|
e1e670594b | ||
|
|
2e28e17834 | ||
|
|
90f18438ff | ||
|
|
5b62f397ec | ||
|
|
1e54ebcdf4 | ||
|
|
0364bea166 | ||
|
|
c7e8ff616f | ||
|
|
21fbff4d8a | ||
|
|
8ffb2b0644 | ||
|
|
80ebf8b549 | ||
|
|
c101cc8716 | ||
|
|
cceb24ed8f | ||
|
|
60dab7ce3f | ||
|
|
a5263b3252 | ||
|
|
f0f43ddbad | ||
|
|
ea53916adc | ||
|
|
585127deb2 | ||
|
|
41a209e96d |
No files matched your search
@@ -1,5 +1,17 @@
|
|||||||
# aicodeman
|
# 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
|
## 1.1.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -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.
|
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
|
## Project Overview
|
||||||
|
|
||||||
|
|||||||
@@ -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+W` | Kill active session |
|
||||||
| `Ctrl/Cmd+Tab` | Next 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+Shift+{` / `Ctrl+Shift+}` | Move active tab left / right |
|
||||||
| `Ctrl/Cmd+L` | Clear terminal |
|
| `Ctrl/Cmd+L` | Clear terminal |
|
||||||
| `Ctrl+Shift+R` | Restore terminal size |
|
| `Ctrl+Shift+R` | Restore terminal size |
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.1.0",
|
"version": "1.1.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.1.0",
|
"version": "1.1.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"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",
|
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ appendFileSync(
|
|||||||
|
|
||||||
// 4. Minify frontend assets
|
// 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 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 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 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');
|
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',
|
'notification-manager.js',
|
||||||
'keyboard-accessory.js',
|
'keyboard-accessory.js',
|
||||||
'input-cjk.js',
|
'input-cjk.js',
|
||||||
|
'sanitize-html.js',
|
||||||
'app.js',
|
'app.js',
|
||||||
'terminal-ui.js',
|
'terminal-ui.js',
|
||||||
'respawn-ui.js',
|
'respawn-ui.js',
|
||||||
|
|||||||
+30
@@ -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 };
|
export { program };
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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). */
|
/** Unique marker identifying Codeman's own statusLine command (vs a user's). */
|
||||||
const STATUSLINE_MARKER = '/api/status-telemetry';
|
const STATUSLINE_MARKER = '/api/status-telemetry';
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
@@ -36,20 +36,12 @@ interface AuthState {
|
|||||||
* Register HTTP Basic Auth middleware with session cookies and rate limiting.
|
* Register HTTP Basic Auth middleware with session cookies and rate limiting.
|
||||||
* Only active when CODEMAN_PASSWORD is set.
|
* Only active when CODEMAN_PASSWORD is set.
|
||||||
*
|
*
|
||||||
* @param getTunnelRunning - returns true while a managed tunnel is active. Used
|
* The `/api/hook-event` + `/api/status-telemetry` localhost bypass requires the
|
||||||
* to gate the `/api/hook-event` localhost bypass: when a tunnel is up, tunneled
|
* shared hook secret unconditionally (COD-91) — see the onRequest hook below.
|
||||||
* 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.
|
|
||||||
* @returns AuthState for lifecycle management (dispose on server stop)
|
* @returns AuthState for lifecycle management (dispose on server stop)
|
||||||
*/
|
*/
|
||||||
export function registerAuthMiddleware(
|
export function registerAuthMiddleware(app: FastifyInstance, https: boolean): AuthState {
|
||||||
app: FastifyInstance,
|
|
||||||
https: boolean,
|
|
||||||
getTunnelRunning: () => boolean = () => false
|
|
||||||
): AuthState {
|
|
||||||
const state: AuthState = {
|
const state: AuthState = {
|
||||||
authSessions: null,
|
authSessions: null,
|
||||||
authFailures: null,
|
authFailures: null,
|
||||||
@@ -114,30 +106,26 @@ export function registerAuthMiddleware(
|
|||||||
// COD-54: the bare localhost bypass is unsafe while a tunnel is running, because
|
// 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
|
// `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
|
// loopback origin, so a tunneled request arrives with req.ip === 127.0.0.1 and
|
||||||
// would pass. So:
|
// would pass. COD-91: require the shared hook secret on the loopback bypass
|
||||||
// - tunnel running → bypass requires the shared hook secret (local hooks present
|
// UNCONDITIONALLY (not just while the managed tunnel is up). Codeman can't detect
|
||||||
// it via the X-Codeman-Hook-Secret header; internet traffic can't know it),
|
// a user's own loopback reverse proxy (their own `cloudflared --url`, `tailscale
|
||||||
// - tunnel not running (loopback-only, the normal case) → keep the plain
|
// serve`, nginx → 127.0.0.1), so tunnel-gating left that path with the unsafe plain
|
||||||
// localhost bypass so already-deployed (pre-secret) hooks + the loop's own
|
// bypass. Managed-session hooks always present the secret (X-Codeman-Hook-Secret,
|
||||||
// credential-less hook channel keep working.
|
// 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') {
|
if ((req.url === '/api/hook-event' || req.url === '/api/status-telemetry') && req.method === 'POST') {
|
||||||
const ip = req.ip;
|
const ip = req.ip;
|
||||||
const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
|
const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
|
||||||
if (isLoopback) {
|
if (isLoopback) {
|
||||||
if (!getTunnelRunning()) {
|
// Always require the shared secret (constant-time compare).
|
||||||
// Loopback-only: unchanged behavior.
|
|
||||||
done();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Tunnel up: require the shared secret (constant-time compare).
|
|
||||||
const presented = Buffer.from(req.headers[HOOK_SECRET_HEADER.toLowerCase()]?.toString() ?? '');
|
const presented = Buffer.from(req.headers[HOOK_SECRET_HEADER.toLowerCase()]?.toString() ?? '');
|
||||||
const expected = Buffer.from(getHookSecret());
|
const expected = Buffer.from(getHookSecret());
|
||||||
if (presented.length === expected.length && timingSafeEqual(presented, expected)) {
|
if (presented.length === expected.length && timingSafeEqual(presented, expected)) {
|
||||||
done();
|
done();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Wrong/absent secret while tunneled — rate-limit per IP in the DEDICATED
|
// Wrong/absent secret — rate-limit per IP in the DEDICATED hook bucket
|
||||||
// hook bucket (never authFailures, which would lock out the login path).
|
// (never authFailures, which would lock out the login path).
|
||||||
const hookIp = req.ip;
|
const hookIp = req.ip;
|
||||||
const hookFailures = hookSecretFailures.get(hookIp) ?? 0;
|
const hookFailures = hookSecretFailures.get(hookIp) ?? 0;
|
||||||
if (hookFailures >= AUTH_FAILURE_MAX) {
|
if (hookFailures >= AUTH_FAILURE_MAX) {
|
||||||
|
|||||||
+76
-28
@@ -770,14 +770,31 @@ class CodemanApp {
|
|||||||
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
|
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alt+1-9: switch to Codeman session by index
|
// Option/Alt session navigation uses physical key CODES, not e.key, so macOS
|
||||||
if (e.altKey && !e.ctrlKey && !e.shiftKey && e.key >= '1' && e.key <= '9') {
|
// keyboard layouts that emit special characters under Option (Option+1 -> ¡,
|
||||||
const idx = parseInt(e.key) - 1;
|
// Option+[ -> "“") still switch sessions. e.code is the physical key regardless
|
||||||
if (idx < this.sessionOrder.length) {
|
// 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();
|
e.preventDefault();
|
||||||
this.selectSession(this.sessionOrder[idx]);
|
this.prevSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.code === 'BracketRight') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.nextSession();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match against shortcut table
|
// Match against shortcut table
|
||||||
@@ -1302,28 +1319,14 @@ class CodemanApp {
|
|||||||
|
|
||||||
/** Strip dangerous elements and attributes from HTML (XSS prevention) */
|
/** Strip dangerous elements and attributes from HTML (XSS prevention) */
|
||||||
_sanitizeHtml(html) {
|
_sanitizeHtml(html) {
|
||||||
const tpl = document.createElement('template');
|
if (typeof window !== 'undefined' && typeof window.sanitizeMarkdownHtml === 'function') {
|
||||||
tpl.innerHTML = html;
|
return window.sanitizeMarkdownHtml(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('*')) {
|
// Fail closed: DOMPurify unavailable — never return un-sanitized HTML.
|
||||||
for (const attr of [...el.attributes]) {
|
return String(html == null ? '' : html)
|
||||||
const name = attr.name.toLowerCase();
|
.replace(/&/g, '&')
|
||||||
if (name.startsWith('on')) {
|
.replace(/</g, '<')
|
||||||
el.removeAttribute(attr.name);
|
.replace(/>/g, '>');
|
||||||
} 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2166,7 +2169,13 @@ class CodemanApp {
|
|||||||
// Mobile defaults ship cjkInputEnabled: false (native terminal input by
|
// Mobile defaults ship cjkInputEnabled: false (native terminal input by
|
||||||
// default on touch), but an explicit user enable is honored everywhere —
|
// default on touch), but an explicit user enable is honored everywhere —
|
||||||
// the App Settings toggle must not be a silent no-op on phones.
|
// 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);
|
cjkEl.classList.toggle('cjk-input-visible', !!showCjk);
|
||||||
document.body.classList.toggle('cjk-input-visible', !!showCjk);
|
document.body.classList.toggle('cjk-input-visible', !!showCjk);
|
||||||
cjkEl.style.display = showCjk ? 'block' : 'none';
|
cjkEl.style.display = showCjk ? 'block' : 'none';
|
||||||
@@ -2656,6 +2665,39 @@ class CodemanApp {
|
|||||||
this._fullRenderSessionTabs();
|
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() {
|
_fullRenderSessionTabs() {
|
||||||
@@ -2732,6 +2774,12 @@ class CodemanApp {
|
|||||||
|
|
||||||
// Update connection lines after tabs change (positions may have shifted)
|
// Update connection lines after tabs change (positions may have shifted)
|
||||||
this.updateConnectionLines();
|
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)
|
// Set up arrow key navigation for session tabs (accessibility)
|
||||||
|
|||||||
@@ -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
|
// Expose for tests. `const` declarations at the top of a non-module script
|
||||||
// are global lexical bindings but not `window` properties, so explicit
|
// are global lexical bindings but not `window` properties, so explicit
|
||||||
// assignment is the test-visible API surface.
|
// 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') {
|
if (typeof window !== 'undefined') {
|
||||||
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
|
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
|
||||||
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
|
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
|
||||||
|
window.CodemanTabOverflow = {
|
||||||
|
shouldAutoWrapTabs,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scheduler API — prioritize terminal writes over background UI updates.
|
// Scheduler API — prioritize terminal writes over background UI updates.
|
||||||
|
|||||||
@@ -39,6 +39,9 @@
|
|||||||
<script defer src="vendor/xterm-addon-unicode11.min.js"></script>
|
<script defer src="vendor/xterm-addon-unicode11.min.js"></script>
|
||||||
<script defer src="vendor/xterm-zerolag-input.js"></script>
|
<script defer src="vendor/xterm-zerolag-input.js"></script>
|
||||||
<script defer src="vendor/marked.min.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 -->
|
<!-- 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>
|
<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 -->
|
<!-- Synchronous skin selection — runs before first paint to prevent theme flash -->
|
||||||
@@ -505,7 +508,8 @@
|
|||||||
<div class="shortcuts-grid">
|
<div class="shortcuts-grid">
|
||||||
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
|
<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>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>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>Increase Font</div>
|
||||||
<div><kbd>Ctrl</kbd>+<kbd>-</kbd></div><div>Decrease 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="notification-manager.js"></script>
|
||||||
<script defer src="keyboard-accessory.js"></script>
|
<script defer src="keyboard-accessory.js"></script>
|
||||||
<script defer src="input-cjk.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="app.js"></script>
|
||||||
<script defer src="terminal-ui.js"></script>
|
<script defer src="terminal-ui.js"></script>
|
||||||
<script defer src="respawn-ui.js"></script>
|
<script defer src="respawn-ui.js"></script>
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ const MobileDetection = {
|
|||||||
resizeTimeout = setTimeout(() => {
|
resizeTimeout = setTimeout(() => {
|
||||||
this.updateBodyClass();
|
this.updateBodyClass();
|
||||||
this.updateAppHeight();
|
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);
|
}, 100);
|
||||||
};
|
};
|
||||||
window.addEventListener('resize', this._resizeHandler);
|
window.addEventListener('resize', this._resizeHandler);
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -312,6 +312,13 @@ body {
|
|||||||
max-height: 120px;
|
max-height: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-tabs.tabs-auto-wrap {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
.session-tabs::-webkit-scrollbar {
|
.session-tabs::-webkit-scrollbar {
|
||||||
width: 4px;
|
width: 4px;
|
||||||
height: 0;
|
height: 0;
|
||||||
@@ -8968,6 +8975,7 @@ kbd {
|
|||||||
display: block;
|
display: block;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
max-height: 96px;
|
max-height: 96px;
|
||||||
|
padding: 12px 10px;
|
||||||
border: 1px solid rgba(80, 120, 190, 0.55);
|
border: 1px solid rgba(80, 120, 190, 0.55);
|
||||||
border-left: none;
|
border-left: none;
|
||||||
border-right: none;
|
border-right: none;
|
||||||
|
|||||||
@@ -114,8 +114,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.terminal.attachCustomKeyEventHandler((ev) => {
|
this.terminal.attachCustomKeyEventHandler((ev) => {
|
||||||
if (ev.isComposing || ev.keyCode === 229) return false;
|
if (ev.isComposing || ev.keyCode === 229) return false;
|
||||||
|
|
||||||
// Let Alt+digit pass through to browser (tab switching)
|
// Let the app's Alt/Option session-nav shortcuts reach the document keydown handler
|
||||||
if (ev.altKey && ev.key >= '0' && ev.key <= '9') return false;
|
// (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.
|
// Ctrl+V / Cmd+V: intercept before xterm sends ^V to PTY.
|
||||||
// Route through our paste trap which handles both images and text.
|
// Route through our paste trap which handles both images and text.
|
||||||
@@ -937,6 +943,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.loadTunnelStatus();
|
this.loadTunnelStatus();
|
||||||
this.loadHistorySessions();
|
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() {
|
hideWelcome() {
|
||||||
@@ -950,6 +959,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
clearTimeout(this._welcomeQrShrinkTimer);
|
clearTimeout(this._welcomeQrShrinkTimer);
|
||||||
qrWrap.classList.remove('expanded');
|
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?.();
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
/*! @license DOMPurify 3.4.8 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.8/LICENSE */
|
||||||
|
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).DOMPurify=t()}(this,function(){"use strict";function e(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,o=Array(t);n<t;n++)o[n]=e[n];return o}function t(t,n){return function(e){if(Array.isArray(e))return e}(t)||function(e,t){var n=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null!=n){var o,r,i,a,l=[],c=!0,s=!1;try{if(i=(n=n.call(e)).next,0===t);else for(;!(c=(o=i.call(n)).done)&&(l.push(o.value),l.length!==t);c=!0);}catch(e){s=!0,r=e}finally{try{if(!c&&null!=n.return&&(a=n.return(),Object(a)!==a))return}finally{if(s)throw r}}return l}}(t,n)||function(t,n){if(t){if("string"==typeof t)return e(t,n);var o={}.toString.call(t).slice(8,-1);return"Object"===o&&t.constructor&&(o=t.constructor.name),"Map"===o||"Set"===o?Array.from(t):"Arguments"===o||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(o)?e(t,n):void 0}}(t,n)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}const n=Object.entries,o=Object.setPrototypeOf,r=Object.isFrozen,i=Object.getPrototypeOf,a=Object.getOwnPropertyDescriptor;let l=Object.freeze,c=Object.seal,s=Object.create,u="undefined"!=typeof Reflect&&Reflect,f=u.apply,m=u.construct;l||(l=function(e){return e}),c||(c=function(e){return e}),f||(f=function(e,t){for(var n=arguments.length,o=new Array(n>2?n-2:0),r=2;r<n;r++)o[r-2]=arguments[r];return e.apply(t,o)}),m||(m=function(e){for(var t=arguments.length,n=new Array(t>1?t-1:0),o=1;o<t;o++)n[o-1]=arguments[o];return new e(...n)});const p=L(Array.prototype.forEach),d=L(Array.prototype.lastIndexOf),h=L(Array.prototype.pop),g=L(Array.prototype.push),y=L(Array.prototype.splice),T=Array.isArray,b=L(String.prototype.toLowerCase),A=L(String.prototype.toString),S=L(String.prototype.match),E=L(String.prototype.replace),_=L(String.prototype.indexOf),N=L(String.prototype.trim),O=L(Number.prototype.toString),D=L(Boolean.prototype.toString),R="undefined"==typeof BigInt?null:L(BigInt.prototype.toString),w="undefined"==typeof Symbol?null:L(Symbol.prototype.toString),I=L(Object.prototype.hasOwnProperty),v=L(Object.prototype.toString),C=L(RegExp.prototype.test),x=(k=TypeError,function(){for(var e=arguments.length,t=new Array(e),n=0;n<e;n++)t[n]=arguments[n];return m(k,t)});var k;function L(e){return function(t){t instanceof RegExp&&(t.lastIndex=0);for(var n=arguments.length,o=new Array(n>1?n-1:0),r=1;r<n;r++)o[r-1]=arguments[r];return f(e,t,o)}}function M(e,t){let n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:b;if(o&&o(e,null),!T(t))return e;let i=t.length;for(;i--;){let o=t[i];if("string"==typeof o){const e=n(o);e!==o&&(r(t)||(t[i]=e),o=e)}e[o]=!0}return e}function P(e){for(let t=0;t<e.length;t++){I(e,t)||(e[t]=null)}return e}function z(e){const o=s(null);for(const i of n(e)){var r=t(i,2);const n=r[0],a=r[1];I(e,n)&&(T(a)?o[n]=P(a):a&&"object"==typeof a&&a.constructor===Object?o[n]=z(a):o[n]=a)}return o}function U(e,t){for(;null!==e;){const n=a(e,t);if(n){if(n.get)return L(n.get);if("function"==typeof n.value)return L(n.value)}e=i(e)}return function(){return null}}const F=l(["a","abbr","acronym","address","area","article","aside","audio","b","bdi","bdo","big","blink","blockquote","body","br","button","canvas","caption","center","cite","code","col","colgroup","content","data","datalist","dd","decorator","del","details","dfn","dialog","dir","div","dl","dt","element","em","fieldset","figcaption","figure","font","footer","form","h1","h2","h3","h4","h5","h6","head","header","hgroup","hr","html","i","img","input","ins","kbd","label","legend","li","main","map","mark","marquee","menu","menuitem","meter","nav","nobr","ol","optgroup","option","output","p","picture","pre","progress","q","rp","rt","ruby","s","samp","search","section","select","shadow","slot","small","source","spacer","span","strike","strong","style","sub","summary","sup","table","tbody","td","template","textarea","tfoot","th","thead","time","tr","track","tt","u","ul","var","video","wbr"]),H=l(["svg","a","altglyph","altglyphdef","altglyphitem","animatecolor","animatemotion","animatetransform","circle","clippath","defs","desc","ellipse","enterkeyhint","exportparts","filter","font","g","glyph","glyphref","hkern","image","inputmode","line","lineargradient","marker","mask","metadata","mpath","part","path","pattern","polygon","polyline","radialgradient","rect","stop","style","switch","symbol","text","textpath","title","tref","tspan","view","vkern"]),B=l(["feBlend","feColorMatrix","feComponentTransfer","feComposite","feConvolveMatrix","feDiffuseLighting","feDisplacementMap","feDistantLight","feDropShadow","feFlood","feFuncA","feFuncB","feFuncG","feFuncR","feGaussianBlur","feImage","feMerge","feMergeNode","feMorphology","feOffset","fePointLight","feSpecularLine truncated
|
||||||
|
//# sourceMappingURL=purify.min.js.map
|
||||||
@@ -45,7 +45,13 @@ import {
|
|||||||
validatePathWithinBase,
|
validatePathWithinBase,
|
||||||
} from '../route-helpers.js';
|
} from '../route-helpers.js';
|
||||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.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 { generateClaudeMd } from '../../templates/claude-md.js';
|
||||||
import { imageWatcher } from '../../image-watcher.js';
|
import { imageWatcher } from '../../image-watcher.js';
|
||||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||||
@@ -312,6 +318,13 @@ export function registerSessionRoutes(
|
|||||||
await applyStatusLineConfig(workingDir, true);
|
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
|
// Check OpenCode availability if requested
|
||||||
if (body.mode === 'opencode') {
|
if (body.mode === 'opencode') {
|
||||||
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
|
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
|
||||||
@@ -1279,6 +1292,11 @@ export function registerSessionRoutes(
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(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 —
|
// Strip stale disk entries for keys this request is actively setting (Claude only —
|
||||||
|
|||||||
+1
-1
@@ -630,7 +630,7 @@ export class WebServer extends EventEmitter {
|
|||||||
registerHostGuard(this.app, () => this.getHostPolicy());
|
registerHostGuard(this.app, () => this.getHostPolicy());
|
||||||
|
|
||||||
// Auth middleware (Basic Auth + session cookies + rate limiting)
|
// 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) {
|
if (authState) {
|
||||||
this.authSessions = authState.authSessions;
|
this.authSessions = authState.authSessions;
|
||||||
this.authFailures = authState.authFailures;
|
this.authFailures = authState.authFailures;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi }
|
|||||||
import { WebServer } from '../src/web/server.js';
|
import { WebServer } from '../src/web/server.js';
|
||||||
import { TmuxManager } from '../src/tmux-manager.js';
|
import { TmuxManager } from '../src/tmux-manager.js';
|
||||||
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
||||||
|
import { getHookSecret, HOOK_SECRET_HEADER } from '../src/config/hook-secret.js';
|
||||||
|
|
||||||
const AUTH_PORT = 3160;
|
const AUTH_PORT = 3160;
|
||||||
const NOAUTH_PORT = 3161;
|
const NOAUTH_PORT = 3161;
|
||||||
@@ -250,28 +251,28 @@ describe('Auth Security', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Hook Event Endpoint', () => {
|
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`, {
|
const res = await fetch(`${baseUrl}/api/hook-event`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', [HOOK_SECRET_HEADER]: getHookSecret() },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
event: 'stop',
|
event: 'stop',
|
||||||
sessionId: 'nonexistent-session',
|
sessionId: 'nonexistent-session',
|
||||||
data: {},
|
data: {},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
// Should pass auth (localhost bypass) but may 404 on session — that's fine
|
// Should pass auth (localhost bypass + hook secret) but may 404 on session — that's fine.
|
||||||
// The key assertion is it does NOT return 401
|
// The key assertion is it does NOT return 401 (COD-91: secret required even with no tunnel).
|
||||||
expect(res.status).not.toBe(401);
|
expect(res.status).not.toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject hook events with invalid schema', async () => {
|
it('should reject hook events with invalid schema', async () => {
|
||||||
const res = await fetch(`${baseUrl}/api/hook-event`, {
|
const res = await fetch(`${baseUrl}/api/hook-event`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', [HOOK_SECRET_HEADER]: getHookSecret() },
|
||||||
body: JSON.stringify({ invalid: 'data' }),
|
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
|
expect(res.status).not.toBe(401); // Not an auth error
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,15 +4,19 @@
|
|||||||
* The `/api/hook-event` localhost bypass let tunnel traffic (cloudflared
|
* The `/api/hook-event` localhost bypass let tunnel traffic (cloudflared
|
||||||
* --url http://127.0.0.1:port) reach the loopback origin with req.ip ===
|
* --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
|
* 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
|
* the bypass behind a shared hook secret. COD-91 makes that requirement
|
||||||
* keeping the plain localhost bypass for the normal loopback-only case so
|
* UNCONDITIONAL — the loopback bypass requires the secret whether or not a
|
||||||
* already-deployed (pre-secret) hooks and the loop's own channel keep working.
|
* 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:
|
* Tests:
|
||||||
* - tunnel running + no secret → 401 (closes the hole)
|
* - tunnel running + no secret → 401 (closes the hole)
|
||||||
* - tunnel running + bad secret → 401
|
* - tunnel running + bad secret → 401
|
||||||
* - tunnel running + good secret → not 401 (allowed)
|
* - 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
|
* - rate limiting: rapid unauthorized hook POSTs eventually 429
|
||||||
*
|
*
|
||||||
* Port: 3230 (tunnel-running), 3231 (tunnel-down), 3232 (rate-limit)
|
* 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 server: WebServer;
|
||||||
let baseUrl: string;
|
let baseUrl: string;
|
||||||
let isRunningSpy: ReturnType<typeof vi.spyOn>;
|
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;
|
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);
|
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);
|
expect(res.status).not.toBe(401);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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`');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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:');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -528,15 +528,24 @@ describe('Virtual Keyboard', () => {
|
|||||||
expect(afterEnter.sentInputs).toEqual(['hello', '\r']);
|
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(() => {
|
const state = await page.evaluate(() => {
|
||||||
app._serverCjkOverride = true;
|
|
||||||
app._updateCjkInputState();
|
|
||||||
|
|
||||||
const input = document.getElementById('cjkInput');
|
const input = document.getElementById('cjkInput');
|
||||||
if (!(input instanceof HTMLElement)) return null;
|
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);
|
const cs = getComputedStyle(input);
|
||||||
return {
|
return {
|
||||||
|
onWelcomeDisplay,
|
||||||
display: cs.display,
|
display: cs.display,
|
||||||
position: cs.position,
|
position: cs.position,
|
||||||
bottom: cs.bottom,
|
bottom: cs.bottom,
|
||||||
@@ -546,6 +555,7 @@ describe('Virtual Keyboard', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(state).not.toBeNull();
|
expect(state).not.toBeNull();
|
||||||
|
expect(state?.onWelcomeDisplay).toBe('none');
|
||||||
expect(state?.display).not.toBe('none');
|
expect(state?.display).not.toBe('none');
|
||||||
expect(state?.position).toBe('fixed');
|
expect(state?.position).toBe('fixed');
|
||||||
expect(Number(state?.zIndex)).toBeGreaterThan(50);
|
expect(Number(state?.zIndex)).toBeGreaterThan(50);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user