mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0ac10a07c | ||
|
|
f7814ad364 | ||
|
|
3172befd5d |
@@ -1,5 +1,31 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.1.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Two welcome-screen tunnel changes:
|
||||
- **UI (Daylight Blue skin):** the **Cloudflare Tunnel** button is now purple (was orange/yellow), keeping the three welcome buttons visually distinct — Claude blue, Tunnel purple, OpenCode green.
|
||||
- **Enable a tunnel without `CODEMAN_PASSWORD`, with a warning.** Previously enabling the Cloudflare tunnel with no password set was hard-refused unless you set `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1`. Now you can opt in straight from the browser: clicking the tunnel toggle without a password pops a **security confirm dialog** ("publishes this machine to a public URL with no login — effectively remote code execution; set CODEMAN_PASSWORD instead"), and only on confirm does it enable, sending an explicit per-request `acknowledgeUnauthTunnel:true`. The server logs a loud warning whenever a passwordless public tunnel starts. curl/API/CLI callers are unchanged — still refused unless they set a password, set the env var, or pass `acknowledgeUnauthTunnel:true` — so nothing gets exposed accidentally. The acknowledgment is an action field and is never persisted to settings.json.
|
||||
|
||||
## 1.1.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- UI (Daylight Blue skin): give the welcome-screen action buttons distinct colors instead of all reading blue. **Run Claude Code** keeps the blue accent, **Cloudflare Tunnel** now uses Cloudflare's brand orange, and **Run OpenCode** uses an emerald green — so the three are visually distinguishable at a glance. Scoped to the default `daylight-blue` skin only (daylight-green and OG are unchanged), with matching hover/active states and dark ink for contrast. Verified in a real browser: the three buttons compute to blue / orange / green gradients on the welcome overlay.
|
||||
|
||||
## 1.1.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix: terminal scroll-up (scrollback) intermittently breaking for **Claude** sessions — most visible on iPhone, where you suddenly "can't scroll up the Claude console."
|
||||
|
||||
Root cause: Claude Code periodically emits alternate-screen switches (`\x1b[?1049h`/`\x1b[?47h`/`\x1b[?1047h`), scrollback-erase (`\x1b[3J`), and mouse-tracking enables — typically when it draws a full-screen UI (pickers/dialogs, the boot welcome). xterm.js obeys these by moving to the scrollback-less alternate buffer (or wiping saved lines / hijacking the wheel), so the conversation history becomes unreachable until Claude returns to its normal view. Codeman already stripped these sequences so history stays scrollable, but the strip was gated to **Codex mode only** — Claude (and the equivalent buffer-replay path) let them through.
|
||||
|
||||
The strip is now shared via a single `isAltScreenStripMode(mode)` predicate (`codex || claude`) applied at BOTH sites that were Codex-only: the live PTY stream (`Session._handleTerminalOutput`, including the split-across-chunks carry reassembly) and the `/terminal` buffer replay used on tab-switch/reconnect. `shell` is deliberately excluded so full-screen TUIs run from a shell (vim/less/htop) keep their alternate screen; `opencode` is also unchanged.
|
||||
|
||||
Verified end-to-end on an isolated instance against a real Claude session: the replayed buffer and live stream now carry zero alt-screen/scrollback-erase/mouse sequences, the terminal stays in the normal buffer with scrollback intact, and touch swipe-up scrolls correctly. Covered by new unit tests (`test/claude-scrollback-strip.test.ts`); the existing Codex strip tests are unchanged.
|
||||
|
||||
## 1.1.6
|
||||
|
||||
### Patch 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.
|
||||
|
||||
**Version**: 1.1.6 (must match `package.json`)
|
||||
**Version**: 1.1.9 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.9",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.9",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
+34
-13
@@ -136,6 +136,21 @@ export function isExternalCliMode(mode: SessionMode): boolean {
|
||||
return mode === 'opencode' || mode === 'codex';
|
||||
}
|
||||
|
||||
/**
|
||||
* Modes whose TUI emits alt-screen / scrollback-erase / mouse-tracking sequences
|
||||
* that we strip so the browser keeps everything in the main buffer with scrollback
|
||||
* reachable (the strip runs on both the live stream and the buffer replay).
|
||||
*
|
||||
* Codex and Claude Code are known, controlled TUIs that repaint via cursor
|
||||
* positioning, so dropping the alt-screen switch is safe — content stays in the
|
||||
* normal buffer. Excluded: `shell` (arbitrary programs like vim/less/htop
|
||||
* legitimately need the alt screen) and `opencode` (renders its own TUI that
|
||||
* may rely on it). Keep parity with the replay-side strip in session-routes.ts.
|
||||
*/
|
||||
export function isAltScreenStripMode(mode: SessionMode): boolean {
|
||||
return mode === 'codex' || mode === 'claude';
|
||||
}
|
||||
|
||||
// Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv)
|
||||
|
||||
/** PTY fallback geometry when tmux can't be queried (matches pre-#80 hardcoded values). */
|
||||
@@ -265,9 +280,10 @@ export class Session extends EventEmitter {
|
||||
private _messages: ClaudeMessage[] = [];
|
||||
private _lineBuffer: string = '';
|
||||
private _lineBufferFlushTimer: NodeJS.Timeout | null = null;
|
||||
// Codex only: trailing partial CSI held back so sequences split across PTY
|
||||
// chunks can't slip past the alt-screen/scrollback strip (see _handleTerminalOutput)
|
||||
private _codexSeqCarry: string = '';
|
||||
// Alt-screen-strip modes (Codex/Claude): trailing partial CSI held back so
|
||||
// sequences split across PTY chunks can't slip past the alt-screen/scrollback
|
||||
// strip (see _handleTerminalOutput / isAltScreenStripMode)
|
||||
private _altScreenSeqCarry: string = '';
|
||||
private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null;
|
||||
private rejectPromise: ((reason: Error) => void) | null = null;
|
||||
private _promptResolved: boolean = false; // Guard against race conditions in runPrompt
|
||||
@@ -1134,35 +1150,40 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
|
||||
private _handleTerminalOutput(data: string): void {
|
||||
// Codex emits sequences that wipe xterm.js scrollback, plus mouse-tracking
|
||||
// enables that hijack the scroll wheel so the user can't reach scrollback:
|
||||
// Codex AND Claude Code emit sequences that wipe xterm.js scrollback, plus
|
||||
// mouse-tracking enables that hijack the scroll wheel so the user can't reach
|
||||
// scrollback. Claude Code does this intermittently (e.g. full-screen pickers /
|
||||
// dialogs), which is why terminal scroll-up "randomly" breaks for Claude
|
||||
// sessions on mobile and desktop until the dialog closes:
|
||||
// - \x1b[?1049h / \x1b[?47h / \x1b[?1047h: switch to the alt buffer (no
|
||||
// scrollback) — \x1b[?...l switches back.
|
||||
// - \x1b[3J: erase saved lines (scrollback). (\x1b[2J / \x1b[J — erase
|
||||
// the visible viewport — are left intact; the TUI repaints those rows.)
|
||||
// - \x1b[?1000h / 1002h / 1003h / 1005h / 1006h / 1007h: mouse-tracking
|
||||
// modes (X10, button-event, any-event, UTF-8, SGR, alt-scroll). Once on,
|
||||
// xterm.js forwards wheel events to codex instead of scrolling the
|
||||
// xterm.js forwards wheel events to the CLI instead of scrolling the
|
||||
// viewport, so the conversation is in scrollback but unreachable.
|
||||
// (Focus events at ?1004 are left alone — codeman uses them for
|
||||
// active-tab detection.)
|
||||
// Strip them at the source so neither the persisted buffer nor the live
|
||||
// SSE/WS stream carries them, keeping everything in the main buffer with
|
||||
// scrollback intact. Codex's cursor-positioned redraws overwrite only the
|
||||
// cells they actually target, so the non-erased rows keep their content.
|
||||
if (this.mode === 'codex') {
|
||||
// scrollback intact. These are controlled TUIs whose cursor-positioned
|
||||
// redraws overwrite only the cells they target, so non-erased rows keep
|
||||
// their content. Gated to Codex/Claude (isAltScreenStripMode) — shell must
|
||||
// keep the alt screen for vim/less/htop.
|
||||
if (isAltScreenStripMode(this.mode)) {
|
||||
// Reassemble sequences split across PTY chunk boundaries first: a chunk
|
||||
// ending mid-sequence ('\x1b[?104' now, '9h' next) would slip past the
|
||||
// strip below and leave xterm stuck in the scrollback-less alt buffer
|
||||
// until the next buffer replay. Hold back an incomplete digit-only CSI
|
||||
// tail (≤7 chars — the longest strippable intro is '\x1b[?1049') and
|
||||
// prepend it to the next chunk; complete sequences are never held.
|
||||
data = this._codexSeqCarry + data;
|
||||
this._codexSeqCarry = '';
|
||||
data = this._altScreenSeqCarry + data;
|
||||
this._altScreenSeqCarry = '';
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const splitTail = data.match(/\x1b(?:\[\??[0-9]{0,4})?$/);
|
||||
if (splitTail) {
|
||||
this._codexSeqCarry = splitTail[0];
|
||||
this._altScreenSeqCarry = splitTail[0];
|
||||
data = data.slice(0, -splitTail[0].length);
|
||||
if (!data) return;
|
||||
}
|
||||
@@ -1809,7 +1830,7 @@ export class Session extends EventEmitter {
|
||||
this._errorBuffer = '';
|
||||
this._messages = [];
|
||||
this._lineBuffer = '';
|
||||
this._codexSeqCarry = '';
|
||||
this._altScreenSeqCarry = '';
|
||||
this._lastActivityAt = Date.now();
|
||||
}
|
||||
|
||||
|
||||
@@ -1176,6 +1176,48 @@ Object.assign(CodemanApp.prototype, {
|
||||
} catch {
|
||||
/* non-JSON body — use the default message */
|
||||
}
|
||||
// 403 = the no-password safety refusal (COD-55). Warn loudly and let the
|
||||
// operator acknowledge the risk; on confirm, retry with explicit acknowledgment.
|
||||
if (res.status === 403) {
|
||||
const confirmed = confirm(
|
||||
'⚠️ SECURITY WARNING — no password set\n\n' +
|
||||
'Enabling the Cloudflare tunnel will publish THIS machine to a public URL with ' +
|
||||
'NO login. Anyone who gets the URL has full terminal control — effectively remote ' +
|
||||
'code execution on your computer.\n\n' +
|
||||
'Strongly recommended: set CODEMAN_PASSWORD instead.\n\n' +
|
||||
'Enable the unauthenticated public tunnel anyway?'
|
||||
);
|
||||
if (!confirmed) {
|
||||
this._dismissTunnelConnecting?.();
|
||||
this.showToast('Tunnel not enabled', 'info');
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const retry = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tunnelEnabled: true, acknowledgeUnauthTunnel: true }),
|
||||
});
|
||||
if (retry.ok) {
|
||||
this.showToast('Public tunnel enabling — no password set ⚠️', 'warning');
|
||||
return false; // proceed with the caller's success/connecting path
|
||||
}
|
||||
let m = 'Failed to enable tunnel.';
|
||||
try {
|
||||
const b = await retry.json();
|
||||
if (b && b.error) m = b.error;
|
||||
} catch {
|
||||
/* non-JSON */
|
||||
}
|
||||
this._dismissTunnelConnecting?.();
|
||||
this.showToast(m, 'error');
|
||||
return true;
|
||||
} catch {
|
||||
this._dismissTunnelConnecting?.();
|
||||
this.showToast('Failed to enable tunnel', 'error');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
this._dismissTunnelConnecting?.();
|
||||
this.showToast(message, 'error');
|
||||
return true;
|
||||
|
||||
@@ -10199,3 +10199,38 @@ html:not([data-skin="og"]) {
|
||||
/* ---- Focus rings (driven by --accent, now emerald) stay accessible ---- */
|
||||
:focus-visible { outline-color: var(--accent); }
|
||||
}
|
||||
|
||||
/* ---- Daylight Blue: distinct identity per welcome action button ----
|
||||
Run Claude Code keeps the blue accent; Cloudflare Tunnel is purple; Run
|
||||
OpenCode is emerald green — so the three are clearly different colors instead
|
||||
of all reading blue. Scoped to daylight-blue only; placed after the shared
|
||||
daylight block to win on equal specificity. ---- */
|
||||
html[data-skin="daylight-blue"] .welcome-btn-opencode {
|
||||
background: linear-gradient(135deg, #0d9f6e, #2fbf85);
|
||||
border-color: rgba(16, 185, 129, 0.5);
|
||||
color: #04291b;
|
||||
}
|
||||
html[data-skin="daylight-blue"] .welcome-btn-opencode:hover {
|
||||
background: linear-gradient(135deg, #10b981, #34d399);
|
||||
border-color: rgba(52, 211, 153, 0.6);
|
||||
box-shadow: 0 0 28px -4px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
html[data-skin="daylight-blue"] .welcome-btn-tunnel {
|
||||
background: linear-gradient(135deg, #7c3aed, #a855f7);
|
||||
border-color: rgba(168, 85, 247, 0.5);
|
||||
color: #f5f3ff;
|
||||
}
|
||||
html[data-skin="daylight-blue"] .welcome-btn-tunnel:hover {
|
||||
background: linear-gradient(135deg, #8b5cf6, #c084fc);
|
||||
border-color: rgba(192, 132, 252, 0.6);
|
||||
box-shadow: 0 0 28px -4px rgba(124, 58, 237, 0.45);
|
||||
}
|
||||
html[data-skin="daylight-blue"] .welcome-btn-tunnel.active {
|
||||
background: linear-gradient(135deg, #6d28d9, #7c3aed);
|
||||
border-color: rgba(124, 58, 237, 0.6);
|
||||
color: #f5f3ff;
|
||||
}
|
||||
html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
||||
background: linear-gradient(135deg, #7c3aed, #8b5cf6);
|
||||
box-shadow: 0 0 28px -4px rgba(124, 58, 237, 0.5);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
type ApiResponse,
|
||||
type SessionColor,
|
||||
} from '../../types.js';
|
||||
import { Session } from '../../session.js';
|
||||
import { Session, isAltScreenStripMode } from '../../session.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import {
|
||||
CreateSessionSchema,
|
||||
@@ -79,12 +79,13 @@ const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/;
|
||||
* (1049 also saves cursor and clears the alt buffer).
|
||||
* - CSI 3 J = erase saved lines (scrollback).
|
||||
*
|
||||
* Codex emits `\x1b[?1049h` and clear-scrollback sequences during startup and
|
||||
* on repaint. xterm.js obeys them by switching to the alt buffer (no native
|
||||
* scrollback) and wiping saved lines, so the user's conversation history
|
||||
* disappears on every tab switch / pane refresh. Stripping these from the
|
||||
* replayed byte stream keeps everything in the main buffer with scrollback
|
||||
* intact. Mirrors the live-stream strip in Session._handleTerminalOutput.
|
||||
* Codex AND Claude Code emit `\x1b[?1049h` and clear-scrollback sequences (the
|
||||
* latter intermittently, e.g. full-screen pickers/dialogs). xterm.js obeys them
|
||||
* by switching to the alt buffer (no native scrollback) and wiping saved lines,
|
||||
* so the user's conversation history disappears on every tab switch / pane
|
||||
* refresh (and scroll-up breaks live). Stripping these from the replayed byte
|
||||
* stream keeps everything in the main buffer with scrollback intact. Mirrors the
|
||||
* live-stream strip in Session._handleTerminalOutput (isAltScreenStripMode).
|
||||
*/
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const ALT_SCREEN_TOGGLE_PATTERN = /\x1b\[\?(?:47|1047|1049)[hl]/g;
|
||||
@@ -989,10 +990,11 @@ export function registerSessionRoutes(
|
||||
// the terminal appears empty when switching tabs.
|
||||
let strippedBuffer = stripInkRedrawBloat(rawBuffer);
|
||||
|
||||
// Strip alt-screen toggles and scrollback-erase from codex byte streams.
|
||||
// xterm.js obeys them by switching to its scrollback-less alt buffer and
|
||||
// wiping saved lines, so conversation history disappears on tab switch.
|
||||
if (session.mode === 'codex') {
|
||||
// Strip alt-screen toggles and scrollback-erase from Codex/Claude byte
|
||||
// streams. xterm.js obeys them by switching to its scrollback-less alt
|
||||
// buffer and wiping saved lines, so conversation history disappears on tab
|
||||
// switch. Same gate as the live-stream strip in session.ts.
|
||||
if (isAltScreenStripMode(session.mode)) {
|
||||
strippedBuffer = strippedBuffer
|
||||
.replace(ALT_SCREEN_TOGGLE_PATTERN, '')
|
||||
.replace(ERASE_SCROLLBACK_PATTERN, '')
|
||||
|
||||
@@ -505,20 +505,34 @@ export function registerSystemRoutes(
|
||||
// control = effectively RCE) to a public *.trycloudflare.com URL. Because the
|
||||
// tunnel binds to loopback, server.ts's non-loopback bind guard never trips, and
|
||||
// with no CODEMAN_PASSWORD the auth middleware is inactive — so the tunnel URL is
|
||||
// unauthenticated. Refuse to start a tunnel unless auth is configured OR the
|
||||
// operator has acknowledged unauthenticated-network exposure. A public tunnel is
|
||||
// higher-stakes than a LAN bind, so this is REFUSE (vs the bind guard's warn).
|
||||
// unauthenticated. Refuse to start a tunnel unless auth is configured OR exposure
|
||||
// is acknowledged: either the CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var, or an
|
||||
// explicit per-request `acknowledgeUnauthTunnel:true` (the UI sends this after a
|
||||
// confirm dialog). This keeps curl/API/CLI callers protected by default while
|
||||
// letting an operator opt in from the browser without setting the env var.
|
||||
// Guard runs BEFORE persisting so a refused tunnelEnabled:true is not saved.
|
||||
if (settings.tunnelEnabled === true && !ctx.tunnelManager.isRunning() && !isUnauthenticatedNetworkAcknowledged()) {
|
||||
const msg =
|
||||
'Refusing to start the Cloudflare tunnel without authentication: it would publish ' +
|
||||
'full terminal control to a public URL with no password. Set CODEMAN_PASSWORD to ' +
|
||||
'require login, or set CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1 to acknowledge an ' +
|
||||
'unauthenticated public tunnel.';
|
||||
throw Object.assign(new Error(msg), {
|
||||
statusCode: 403,
|
||||
body: createErrorResponse(ApiErrorCode.OPERATION_FAILED, msg),
|
||||
});
|
||||
if (settings.tunnelEnabled === true && !ctx.tunnelManager.isRunning()) {
|
||||
const acknowledged = isUnauthenticatedNetworkAcknowledged() || settings.acknowledgeUnauthTunnel === true;
|
||||
if (!acknowledged) {
|
||||
const msg =
|
||||
'Refusing to start the Cloudflare tunnel without authentication: it would publish ' +
|
||||
'full terminal control to a public URL with no password. Set CODEMAN_PASSWORD to ' +
|
||||
'require login, set CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1, or resend with ' +
|
||||
'acknowledgeUnauthTunnel:true to acknowledge an unauthenticated public tunnel.';
|
||||
throw Object.assign(new Error(msg), {
|
||||
statusCode: 403,
|
||||
body: createErrorResponse(ApiErrorCode.OPERATION_FAILED, msg),
|
||||
});
|
||||
}
|
||||
// Loud warning whenever a public tunnel is started with no password — whether
|
||||
// acknowledged via env var or the per-request UI confirmation.
|
||||
if (!process.env.CODEMAN_PASSWORD) {
|
||||
console.warn(
|
||||
'⚠️ [tunnel] Starting an UNAUTHENTICATED public Cloudflare tunnel — no CODEMAN_PASSWORD set. ' +
|
||||
'Anyone with the tunnel URL gets full terminal control (effectively RCE). ' +
|
||||
'Set CODEMAN_PASSWORD to require login.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -532,9 +546,9 @@ export function registerSystemRoutes(
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// statusLineTelemetry is an ACTION field (reconcile the plan-usage exporter),
|
||||
// not a stored setting — strip it before persisting so settings.json stays clean.
|
||||
const { statusLineTelemetry, ...settingsToStore } = settings;
|
||||
// statusLineTelemetry and acknowledgeUnauthTunnel are ACTION fields (not stored
|
||||
// settings) — strip them before persisting so settings.json stays clean.
|
||||
const { statusLineTelemetry, acknowledgeUnauthTunnel, ...settingsToStore } = settings;
|
||||
const merged = { ...existing, ...settingsToStore };
|
||||
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
|
||||
|
||||
|
||||
@@ -354,6 +354,11 @@ export const SettingsUpdateSchema = z
|
||||
ultracodeFloatingWindows: z.boolean().optional(),
|
||||
imageWatcherEnabled: z.boolean().optional(),
|
||||
tunnelEnabled: z.boolean().optional(),
|
||||
// Action field (NOT persisted): explicit per-request acknowledgment that the
|
||||
// operator accepts exposing an UNAUTHENTICATED public tunnel (no CODEMAN_PASSWORD).
|
||||
// Lets the UI enable a tunnel after a confirm dialog without the
|
||||
// CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var. Stripped before persisting.
|
||||
acknowledgeUnauthTunnel: z.boolean().optional(),
|
||||
tabTwoRows: z.boolean().optional(),
|
||||
agentTeamsEnabled: z.boolean().optional(),
|
||||
/** Model for new Claude sessions (e.g. "claude-fable-5[1m]", "opus[1m]"); takes precedence over opusContext1mEnabled */
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Session, isAltScreenStripMode } from '../src/session.js';
|
||||
|
||||
type SessionInternals = {
|
||||
_handleTerminalOutput(data: string): void;
|
||||
};
|
||||
|
||||
function handleOutput(session: Session, data: string): void {
|
||||
(session as unknown as SessionInternals)._handleTerminalOutput(data);
|
||||
}
|
||||
|
||||
describe('isAltScreenStripMode', () => {
|
||||
it('strips for the controlled TUIs (codex + claude), not shell/opencode', () => {
|
||||
expect(isAltScreenStripMode('codex')).toBe(true);
|
||||
expect(isAltScreenStripMode('claude')).toBe(true);
|
||||
expect(isAltScreenStripMode('shell')).toBe(false);
|
||||
expect(isAltScreenStripMode('opencode')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Claude terminal scrollback strip', () => {
|
||||
it('strips alt-screen toggles, scrollback-erase, and mouse-tracking', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
const emitted: string[] = [];
|
||||
session.on('terminal', (data) => emitted.push(data));
|
||||
|
||||
handleOutput(session, '\x1b[?1049h\x1b[55;1Hdialog\x1b[3J\x1b[?1006h\x1b[?1049l');
|
||||
|
||||
expect(emitted[0]).toBe('\x1b[55;1Hdialog');
|
||||
expect(session.terminalBuffer).toBe('\x1b[55;1Hdialog');
|
||||
});
|
||||
|
||||
it('keeps the visible-screen erase (2J / [J) — only scrollback-erase (3J) is dropped', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
|
||||
handleOutput(session, '\x1b[?1049h\x1b[2Jvisible\x1b[3Jscrollback\x1b[?1049l');
|
||||
|
||||
expect(session.terminalBuffer).toBe('\x1b[2Jvisiblescrollback');
|
||||
});
|
||||
|
||||
it('preserves an ordinary erase-display redraw (no scrollback sequences)', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
|
||||
handleOutput(session, '\x1b[H\x1b[Jclaude redraw');
|
||||
|
||||
expect(session.terminalBuffer).toBe('\x1b[H\x1b[Jclaude redraw');
|
||||
});
|
||||
|
||||
it('strips sequences split across PTY chunk boundaries (carry reassembly)', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
const emitted: string[] = [];
|
||||
session.on('terminal', (data) => emitted.push(data));
|
||||
|
||||
handleOutput(session, 'before\x1b[?104');
|
||||
handleOutput(session, '9h\x1b[2Jafter\x1b[3');
|
||||
handleOutput(session, 'Jtail');
|
||||
|
||||
expect(session.terminalBuffer).toBe('before\x1b[2Jaftertail');
|
||||
expect(emitted).toEqual(['before', '\x1b[2Jafter', 'tail']);
|
||||
});
|
||||
|
||||
it('emits nothing for a chunk that is only a partial CSI, then completes it', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
const emitted: string[] = [];
|
||||
session.on('terminal', (data) => emitted.push(data));
|
||||
|
||||
handleOutput(session, '\x1b[?100'); // pure partial — held, nothing emitted
|
||||
handleOutput(session, '6h done'); // completes ?1006h (stripped); rest passes
|
||||
|
||||
expect(emitted).toEqual([' done']);
|
||||
expect(session.terminalBuffer).toBe(' done');
|
||||
});
|
||||
|
||||
it('does not touch ordinary Claude conversation output', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
|
||||
const text = 'Here is line one\r\nHere is line two\r\n\x1b[2mdim status\x1b[0m';
|
||||
handleOutput(session, text);
|
||||
|
||||
expect(session.terminalBuffer).toBe(text);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shell terminal output is NOT stripped (vim/less/htop need the alt screen)', () => {
|
||||
it('leaves alt-screen toggles, scrollback-erase, and mouse-tracking intact for shell', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
||||
|
||||
const vimLike = '\x1b[?1049h\x1b[?1002h\x1b[2J~ editing\x1b[3J\x1b[?1002l\x1b[?1049l';
|
||||
handleOutput(session, vimLike);
|
||||
|
||||
expect(session.terminalBuffer).toBe(vimLike);
|
||||
});
|
||||
});
|
||||
@@ -117,6 +117,35 @@ describe('COD-55 tunnel password guard (PUT /api/settings tunnelEnabled)', () =>
|
||||
expect(tunnel.start).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ALLOWS tunnel-enable with per-request acknowledgeUnauthTunnel:true (start called, 200, flag not persisted)', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
payload: { tunnelEnabled: true, acknowledgeUnauthTunnel: true },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(tunnel.start).toHaveBeenCalledTimes(1);
|
||||
// The action flag must NOT be persisted to settings.json.
|
||||
expect(mockedWriteFile).toHaveBeenCalled();
|
||||
const persisted = JSON.parse(mockedWriteFile.mock.calls[0][1] as string);
|
||||
expect(persisted.acknowledgeUnauthTunnel).toBeUndefined();
|
||||
expect(persisted.tunnelEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('still REFUSES when acknowledgeUnauthTunnel is false (4xx, start not called)', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
payload: { tunnelEnabled: true, acknowledgeUnauthTunnel: false },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect(res.statusCode).toBeLessThan(500);
|
||||
expect(tunnel.start).not.toHaveBeenCalled();
|
||||
expect(mockedWriteFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not guard tunnel-disable (tunnelEnabled:false always allowed)', async () => {
|
||||
tunnel = makeTunnelManager(true);
|
||||
(harness.ctx as unknown as { tunnelManager: unknown }).tunnelManager = tunnel;
|
||||
|
||||
Reference in New Issue
Block a user