mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 08:29:42 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce22c2a608 |
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
'aicodeman': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
The filesystem path picker can show hidden files and folders, and the shared secret blocklist grew to make that safe.
|
||||||
|
|
||||||
|
The picker behind Link Existing's "Browse" and the mobile keyboard's `Path` key
|
||||||
|
refused every path with a dot-prefixed segment, so `.github/workflows/ci.yml`
|
||||||
|
could not be selected and a hidden folder could not even be opened. It now has
|
||||||
|
the same `.*` toggle as the File Viewer, default OFF, per-device, and it applies
|
||||||
|
to both the listing and the preview endpoint (which re-resolves the path
|
||||||
|
independently).
|
||||||
|
|
||||||
|
That filter was quietly doing security work. With every hidden path unreachable,
|
||||||
|
`isSensitivePath` never had to name the credentials that live in dot-directories,
|
||||||
|
because the picker's roots include Home. Lifting the filter removes that
|
||||||
|
accident, so the blocklist now covers them explicitly: SSH keys at any depth (not
|
||||||
|
only under `$HOME`), GPG keyrings, AWS/GCloud/Azure/Docker/Kubernetes
|
||||||
|
credentials, npm, Yarn, git, `gh`, netrc, PyPI, RubyGems, Cargo and Terraform
|
||||||
|
tokens, `.pgpass` and `.my.cnf`, and the Claude and Codeman agent credentials.
|
||||||
|
`~/.codeman/` and `~/.claude/` stay attachable as trees, since the publish skill
|
||||||
|
and the review-card loop read from them; only their secret-bearing members are
|
||||||
|
named.
|
||||||
|
|
||||||
|
Blocked trees, sensitive files, root confinement and symlink-escape checks are
|
||||||
|
all unchanged and still apply with the toggle on: a hidden entry that resolves
|
||||||
|
to a secret is dropped from the listing, and opening it is refused.
|
||||||
|
|
||||||
|
Follows #221.
|
||||||
@@ -186,8 +186,6 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`.
|
**Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`.
|
||||||
|
|
||||||
⚠️ **A `❯` sighting is NOT the end of a turn, and neither is silence.** Claude redraws the composer (`❯`) about once a second all through a turn, so the old "saw a ❯, wait 2s → idle" rule flipped every working session to idle two seconds in (measured: a session mid-tool-call at 17 minutes reporting `status:"idle"`). Its working indicator is `✻ Actualizing… (13m 23s · ↓ 47.5k tokens)`: the glyph animates through `· ✢ ✳ ∗ ✻ ✽`, the gerund is randomized, and the finished line (`✻ Cooked for 2m 49s`) carries the same glyph, so neither `SPINNER_PATTERN` (braille, not what current versions draw) nor a keyword list can see it. Matching the new line in the STREAM does not work either: tmux ships partial repaints, so the whole line reaches the PTY only every few tens of seconds. So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks the SCREEN via `capturePaneText()` + `CLAUDE_WORKING_LINE_PATTERN` before believing it; a sustained run of repaints (`session-activity.ts`, pure + unit tested) is what marks a turn as started, with the same screen probe vetoing keystroke echo. Idle now lands ~3-5s after a turn ends instead of 2s into one. Claude-mode only, since an external CLI has no `❯`, so nothing would ever arm the confirmation and the session would latch busy.
|
|
||||||
|
|
||||||
**Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit)
|
**Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit)
|
||||||
|
|
||||||
**Plan-usage chip** (statusLine telemetry, `showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF via the mobile block in `getDefaultSettings()`): resolve it ONLY through `planUsageChipEnabled()` in settings-ui.js, which backs all three call sites (the App Settings checkbox, the chip's visibility, and the `statusLineTelemetry` flag on session create). A chip shown without telemetry renders `—` forever. Codeman injects its own `statusLine.command` exporter which POSTs Claude's `rate_limits` blob to `POST /api/status-telemetry`. The exporter is identified by a marker, so it only ever adds/updates/removes a statusLine that is **ours**, never a user's hand-authored one, and it prints the footer through so the in-terminal statusline is not blanked. Claude-mode only; distinct from auto-resume, which reacts to the limit *message* rather than showing live %. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
|
**Plan-usage chip** (statusLine telemetry, `showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF via the mobile block in `getDefaultSettings()`): resolve it ONLY through `planUsageChipEnabled()` in settings-ui.js, which backs all three call sites (the App Settings checkbox, the chip's visibility, and the `statusLineTelemetry` flag on session create). A chip shown without telemetry renders `—` forever. Codeman injects its own `statusLine.command` exporter which POSTs Claude's `rate_limits` blob to `POST /api/status-telemetry`. The exporter is identified by a marker, so it only ever adds/updates/removes a statusLine that is **ours**, never a user's hand-authored one, and it prints the footer through so the in-terminal statusline is not blanked. Claude-mode only; distinct from auto-resume, which reacts to the limit *message* rather than showing live %. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
|
||||||
|
|||||||
@@ -274,13 +274,4 @@ export interface TerminalMultiplexer extends EventEmitter {
|
|||||||
* Pass `{ fullHistory: true }` to capture the entire scrollback (COD-47).
|
* Pass `{ fullHistory: true }` to capture the entire scrollback (COD-47).
|
||||||
*/
|
*/
|
||||||
captureActivePaneBuffer?(muxName: string, opts?: PaneCaptureOptions): string | null;
|
captureActivePaneBuffer?(muxName: string, opts?: PaneCaptureOptions): string | null;
|
||||||
|
|
||||||
/**
|
|
||||||
* Plain text of the visible frame: no styles, no cursor query, no repaint
|
|
||||||
* reconstruction. Deliberately cheaper than `capturePaneBuffer` because idle
|
|
||||||
* detection calls it on a timer: it only needs to read what the CLI is
|
|
||||||
* currently rendering, never to replay it into an xterm. Returns null when the
|
|
||||||
* pane cannot be read.
|
|
||||||
*/
|
|
||||||
capturePaneText?(muxName: string, paneTarget?: string): string | null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* @module respawn-patterns
|
* @module respawn-patterns
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { TOKEN_PATTERN, CLAUDE_WORKING_LINE_PATTERN } from './utils/index.js';
|
import { TOKEN_PATTERN } from './utils/index.js';
|
||||||
|
|
||||||
// ========== Constants ==========
|
// ========== Constants ==========
|
||||||
|
|
||||||
@@ -108,12 +108,7 @@ export function isCompletionMessage(data: string): boolean {
|
|||||||
* @returns True if any working pattern is found in the window
|
* @returns True if any working pattern is found in the window
|
||||||
*/
|
*/
|
||||||
export function hasWorkingPattern(window: string): boolean {
|
export function hasWorkingPattern(window: string): boolean {
|
||||||
// Current Claude randomizes the gerund ("Actualizing…", "Finagling…"), so the
|
return WORKING_PATTERNS.some((pattern) => window.includes(pattern));
|
||||||
// list above catches only a fraction of turns. The live status line's own shape
|
|
||||||
// (`… (13m 23s · ↓ 47.5k tokens)`) is what identifies the rest. Kept as an
|
|
||||||
// extra signal rather than a replacement: this window is RAW terminal data, and
|
|
||||||
// a partial repaint can split the line across chunks.
|
|
||||||
return CLAUDE_WORKING_LINE_PATTERN.test(window) || WORKING_PATTERNS.some((pattern) => window.includes(pattern));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Pure working/idle heuristics for a Claude interactive pane.
|
|
||||||
*
|
|
||||||
* Split out of `session.ts` so the thresholds and the state math are unit
|
|
||||||
* testable without a PTY (same reasoning as `session-order.ts` /
|
|
||||||
* `usage-limit-patterns.ts`).
|
|
||||||
*
|
|
||||||
* **Why activity and not the status line.** Claude Code's working indicator is
|
|
||||||
* `✻ Actualizing… (13m 23s · ↓ 47.5k tokens)`, where the glyph animates through
|
|
||||||
* `· ✢ ✳ ∗ ✻ ✽` and the gerund is randomized per turn. Neither the braille
|
|
||||||
* spinner (`SPINNER_PATTERN`) nor the old keyword list (`Thinking|Writing|
|
|
||||||
* Reading|Running`) matches any of that, so the pane looked idle for a whole
|
|
||||||
* turn. Matching the new line does not rescue the stream either: tmux ships
|
|
||||||
* PARTIAL repaints, so measured on a live worker the complete line reached the
|
|
||||||
* PTY roughly once every 20 seconds, while the composer's `❯` (which is what
|
|
||||||
* ARMS idle detection) arrived every single second.
|
|
||||||
*
|
|
||||||
* What is left is the one thing measured to separate the two states cleanly: a
|
|
||||||
* working pane repaints, an idle pane emits nothing at all. Sampled once per
|
|
||||||
* second for 12s across six live sessions, the two working ones produced output
|
|
||||||
* in 12/12 windows and the four idle ones in 0/12.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A gap longer than this ends a run of continuous output. Claude repaints at
|
|
||||||
* least once a second while working, so this leaves generous headroom.
|
|
||||||
*/
|
|
||||||
export const ACTIVITY_GAP_MS = 2000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Continuous output for this long means the pane is working. Long enough that a
|
|
||||||
* one-off repaint (an update-check line, a rotating tip) cannot reach it.
|
|
||||||
*/
|
|
||||||
export const WORKING_STREAK_MS = 2000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Silence for this long is what confirms the pane really went idle. Must stay
|
|
||||||
* above ACTIVITY_GAP_MS, or a pause between two repaints of one turn would
|
|
||||||
* read as the end of the turn.
|
|
||||||
*/
|
|
||||||
export const IDLE_SILENCE_MS = 2500;
|
|
||||||
|
|
||||||
/** How often a pending idle confirmation re-checks a pane that is still noisy. */
|
|
||||||
export const IDLE_RECHECK_MS = 500;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Floor between two pane probes for one session. The probe shells out to tmux,
|
|
||||||
* so this is what keeps a screenful of busy sessions from turning idle detection
|
|
||||||
* into a subprocess storm.
|
|
||||||
*/
|
|
||||||
export const PANE_PROBE_MIN_INTERVAL_MS = 1500;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How long to wait before looking again at a pane the probe just called working.
|
|
||||||
* Claude can sit silent for tens of seconds inside one tool call, so this is the
|
|
||||||
* cadence that carries a long quiet turn, so it is deliberately slow.
|
|
||||||
*/
|
|
||||||
export const PANE_PROBE_RECHECK_MS = 5000;
|
|
||||||
|
|
||||||
/** An unbroken run of PTY output. */
|
|
||||||
export interface ActivityStreak {
|
|
||||||
/** When this run began. */
|
|
||||||
startedAt: number;
|
|
||||||
/** The most recent chunk in it. */
|
|
||||||
lastAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fold one output chunk into the current streak, starting a new one when the
|
|
||||||
* pane has been quiet longer than `gapMs`.
|
|
||||||
*/
|
|
||||||
export function trackActivityStreak(
|
|
||||||
streak: ActivityStreak | null,
|
|
||||||
now: number,
|
|
||||||
gapMs: number = ACTIVITY_GAP_MS
|
|
||||||
): ActivityStreak {
|
|
||||||
if (!streak || now - streak.lastAt > gapMs) return { startedAt: now, lastAt: now };
|
|
||||||
return { startedAt: streak.startedAt, lastAt: now };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True once a streak has been running long enough to mean work rather than a
|
|
||||||
* single repaint. Measured on the streak's own span (`lastAt - startedAt`), not
|
|
||||||
* against the caller's clock, so a stale streak cannot age into a true.
|
|
||||||
*/
|
|
||||||
export function isSustainedActivity(streak: ActivityStreak | null, streakMs: number = WORKING_STREAK_MS): boolean {
|
|
||||||
return !!streak && streak.lastAt - streak.startedAt >= streakMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when the pane has produced nothing for long enough to call it idle. */
|
|
||||||
export function isPaneQuiet(lastActivityAt: number, now: number, silenceMs: number = IDLE_SILENCE_MS): boolean {
|
|
||||||
return now - lastActivityAt >= silenceMs;
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Recognizing Claude Code's workspace-trust dialog on screen.
|
|
||||||
*
|
|
||||||
* Claude asks once per directory before it will read or edit anything:
|
|
||||||
*
|
|
||||||
* Quick safety check: Is this a project you created or one you trust? ...
|
|
||||||
* ❯ 1. Yes, I trust this folder
|
|
||||||
* 2. No, exit
|
|
||||||
* Enter to confirm · Esc to cancel
|
|
||||||
*
|
|
||||||
* Codeman sessions run permission-skipping or classifier-guarded modes, so the
|
|
||||||
* answer is always yes, and a session parked on this dialog is simply stuck.
|
|
||||||
*
|
|
||||||
* **Why the text has to be compacted.** tmux repaints a row by writing each word
|
|
||||||
* and then a cursor-forward (`\x1b[C`) instead of a space, and Ink colours each
|
|
||||||
* word separately, so the wire carries `I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder`.
|
|
||||||
* Stripping the escapes leaves `Itrustthisfolder`: the spaces are not there to
|
|
||||||
* strip, they were never sent. A plain `includes('trust this folder')` therefore
|
|
||||||
* never matched a single chunk, which is why the auto-accept had been silently
|
|
||||||
* dead. Removing ALL whitespace instead is what survives both that repaint style
|
|
||||||
* and the spaced full-screen redraw.
|
|
||||||
*
|
|
||||||
* **Why two markers are required.** Answering means pressing Enter, so a false
|
|
||||||
* positive types into a live session. One phrase is not enough: an agent's own
|
|
||||||
* transcript can quote it (this file does). Matching a trust phrase AND the
|
|
||||||
* dialog's confirm affordance is the cheap way to require the actual widget, and
|
|
||||||
* the caller adds the real guard by only looking during session startup.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { stripAnsi } from './utils/index.js';
|
|
||||||
|
|
||||||
/** Phrases from the question or the "yes" option, whitespace removed, lowercased. */
|
|
||||||
const TRUST_PHRASES = [
|
|
||||||
'trustthisfolder', // 2.x: "1. Yes, I trust this folder"
|
|
||||||
'trustthefiles', // older: "Do you trust the files in this folder?"
|
|
||||||
'oneyoutrust', // 2.x question: "a project you created or one you trust?"
|
|
||||||
];
|
|
||||||
|
|
||||||
/** The dialog's own affordances. Prose that quotes the question will not have these. */
|
|
||||||
const CONFIRM_PHRASES = ['entertoconfirm', 'esctocancel', '2.no,exit'];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Charset-select sequences (`ESC ( B`), which tmux emits around styled runs and
|
|
||||||
* `stripAnsi` does not cover. Left in, they would land inside a phrase as a
|
|
||||||
* literal `(B` and break the match.
|
|
||||||
*/
|
|
||||||
// eslint-disable-next-line no-control-regex
|
|
||||||
const CHARSET_SELECT = /\x1b[()][AB0]/g;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize a screen or PTY chunk for phrase matching: escapes dropped, every
|
|
||||||
* whitespace run removed, lowercased.
|
|
||||||
*/
|
|
||||||
export function compactScreenText(text: string): string {
|
|
||||||
return stripAnsi(text).replace(CHARSET_SELECT, '').replace(/\s+/g, '').toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True when this text is the trust dialog rather than something merely talking
|
|
||||||
* about it. Feed the RENDERED SCREEN where possible: the session's terminal
|
|
||||||
* buffer is append-only, so the dialog stays in its tail long after it is gone.
|
|
||||||
*/
|
|
||||||
export function isTrustDialogScreen(text: string): boolean {
|
|
||||||
const compact = compactScreenText(text);
|
|
||||||
return TRUST_PHRASES.some((p) => compact.includes(p)) && CONFIRM_PHRASES.some((p) => compact.includes(p));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How long after the pane starts the dialog is still plausible. It renders
|
|
||||||
* before the main UI, so this only has to cover a slow first launch; leaving it
|
|
||||||
* open forever would let a transcript that quotes the dialog trigger an Enter.
|
|
||||||
*/
|
|
||||||
export const TRUST_DIALOG_WINDOW_MS = 90_000;
|
|
||||||
|
|
||||||
/** Minimum gap between two Enter presses, and between two screen reads. */
|
|
||||||
export const TRUST_DIALOG_RETRY_MS = 1500;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attempts before giving up and leaving the dialog to the user. A keystroke can
|
|
||||||
* land while Ink is still mounting the widget and be dropped, which is the other
|
|
||||||
* half of why sessions got stuck here; retrying costs nothing, but retrying
|
|
||||||
* forever would hammer Enter into whatever came next.
|
|
||||||
*/
|
|
||||||
export const TRUST_DIALOG_MAX_ATTEMPTS = 3;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How much of the append-only terminal buffer to read on a direct-PTY session,
|
|
||||||
* which has no pane to capture. Small on purpose: the dialog scrolls out of a
|
|
||||||
* short tail as soon as Claude repaints its main UI, which is what keeps a
|
|
||||||
* fallback retry from firing at an already-answered dialog.
|
|
||||||
*/
|
|
||||||
export const TRUST_DIALOG_SCAN_BYTES = 4000;
|
|
||||||
+55
-215
@@ -59,28 +59,11 @@ import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
|
|||||||
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
|
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
|
||||||
import { RalphTracker } from './ralph-tracker.js';
|
import { RalphTracker } from './ralph-tracker.js';
|
||||||
import { BashToolParser } from './bash-tool-parser.js';
|
import { BashToolParser } from './bash-tool-parser.js';
|
||||||
import {
|
|
||||||
isTrustDialogScreen,
|
|
||||||
TRUST_DIALOG_WINDOW_MS,
|
|
||||||
TRUST_DIALOG_RETRY_MS,
|
|
||||||
TRUST_DIALOG_MAX_ATTEMPTS,
|
|
||||||
TRUST_DIALOG_SCAN_BYTES,
|
|
||||||
} from './session-trust-dialog.js';
|
|
||||||
import {
|
|
||||||
trackActivityStreak,
|
|
||||||
isSustainedActivity,
|
|
||||||
isPaneQuiet,
|
|
||||||
IDLE_RECHECK_MS,
|
|
||||||
PANE_PROBE_MIN_INTERVAL_MS,
|
|
||||||
PANE_PROBE_RECHECK_MS,
|
|
||||||
type ActivityStreak,
|
|
||||||
} from './session-activity.js';
|
|
||||||
import {
|
import {
|
||||||
BufferAccumulator,
|
BufferAccumulator,
|
||||||
ANSI_ESCAPE_PATTERN_FULL,
|
ANSI_ESCAPE_PATTERN_FULL,
|
||||||
TOKEN_PATTERN,
|
TOKEN_PATTERN,
|
||||||
SPINNER_PATTERN,
|
SPINNER_PATTERN,
|
||||||
CLAUDE_WORKING_LINE_PATTERN,
|
|
||||||
MAX_SESSION_TOKENS,
|
MAX_SESSION_TOKENS,
|
||||||
execPattern,
|
execPattern,
|
||||||
getClaudeCliVersion,
|
getClaudeCliVersion,
|
||||||
@@ -393,13 +376,7 @@ export class Session extends EventEmitter {
|
|||||||
private _lastPromptTime: number = 0;
|
private _lastPromptTime: number = 0;
|
||||||
private activityTimeout: NodeJS.Timeout | null = null;
|
private activityTimeout: NodeJS.Timeout | null = null;
|
||||||
private _awaitingIdleConfirmation: boolean = false; // Prevents timeout reset during idle detection
|
private _awaitingIdleConfirmation: boolean = false; // Prevents timeout reset during idle detection
|
||||||
private _activityStreak: ActivityStreak | null = null; // Unbroken run of PTY repaints (working detection)
|
private _trustDialogAccepted: boolean = false; // Prevents repeated trust dialog auto-accept
|
||||||
private _lastPaneProbeAt = 0; // Throttle for the tmux screen probe
|
|
||||||
private _lastPaneProbeWorking: boolean | null = null; // Its last verdict (null = could not read)
|
|
||||||
private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up)
|
|
||||||
private _trustDialogAttempts = 0; // Enter presses sent at the trust dialog
|
|
||||||
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
|
|
||||||
private _interactiveStartedAt = 0; // When the interactive pane launched (bounds that scan)
|
|
||||||
private _taskTracker: TaskTracker;
|
private _taskTracker: TaskTracker;
|
||||||
|
|
||||||
// Token tracking for auto-clear
|
// Token tracking for auto-clear
|
||||||
@@ -1537,12 +1514,6 @@ export class Session extends EventEmitter {
|
|||||||
throw new Error('Session already has a running process');
|
throw new Error('Session already has a running process');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bounds the workspace-trust scan (see _maybeAcceptTrustDialog). Stamped here
|
|
||||||
// rather than at PTY spawn so a slow mux attach still counts as startup.
|
|
||||||
this._interactiveStartedAt = Date.now();
|
|
||||||
this._trustDialogAttempts = 0;
|
|
||||||
this._lastTrustDialogScanAt = 0;
|
|
||||||
|
|
||||||
// COD-118: if the PTY exit breaker has tripped (repeated non-zero exits in a
|
// COD-118: if the PTY exit breaker has tripped (repeated non-zero exits in a
|
||||||
// short window), refuse to respawn. This is the uniform choke point that stops
|
// short window), refuse to respawn. This is the uniform choke point that stops
|
||||||
// automatic recovery/reconnect callers from re-creating a crash-looping PTY.
|
// automatic recovery/reconnect callers from re-creating a crash-looping PTY.
|
||||||
@@ -1772,10 +1743,54 @@ export class Session extends EventEmitter {
|
|||||||
this._handleTerminalOutput(data);
|
this._handleTerminalOutput(data);
|
||||||
|
|
||||||
// === Auto-accept workspace trust dialog ===
|
// === Auto-accept workspace trust dialog ===
|
||||||
this._maybeAcceptTrustDialog();
|
// Claude CLI 2.x shows "Yes, I trust this folder" prompt on first launch per directory.
|
||||||
|
// Codeman sessions run permission-skipping or classifier-guarded (auto) modes, so auto-accept.
|
||||||
|
if (!this._trustDialogAccepted && data.includes('trust this folder')) {
|
||||||
|
this._trustDialogAccepted = true;
|
||||||
|
console.log(`[Session] Auto-accepting workspace trust dialog for: ${this.id}`);
|
||||||
|
// Send Enter to accept the default selection ("Yes, I trust this folder")
|
||||||
|
this.writeViaMux('\r');
|
||||||
|
}
|
||||||
|
|
||||||
// === Idle/working detection runs on every chunk (latency-sensitive) ===
|
// === Idle/working detection runs on every chunk (latency-sensitive) ===
|
||||||
this._detectInteractiveActivity(data);
|
// Detect if Claude is working or at prompt
|
||||||
|
// The prompt line contains "❯" when waiting for input
|
||||||
|
if (data.includes('❯') || data.includes('\u276f')) {
|
||||||
|
// Only start a new timeout if we're not already awaiting idle confirmation
|
||||||
|
// This prevents status bar redraws (which include ❯) from resetting the timer
|
||||||
|
if (!this._awaitingIdleConfirmation) {
|
||||||
|
if (this.activityTimeout) clearTimeout(this.activityTimeout);
|
||||||
|
this._awaitingIdleConfirmation = true;
|
||||||
|
this.activityTimeout = setTimeout(() => {
|
||||||
|
this._awaitingIdleConfirmation = false;
|
||||||
|
// Emit idle if either:
|
||||||
|
// 1. Claude was working and is now at prompt (normal case)
|
||||||
|
// 2. Session just started and is ready (status is 'busy' but _isWorking is false)
|
||||||
|
const wasWorking = this._isWorking;
|
||||||
|
const isInitialReady = this._status === 'busy' && !this._isWorking;
|
||||||
|
if (wasWorking || isInitialReady) {
|
||||||
|
this._isWorking = false;
|
||||||
|
this._status = 'idle';
|
||||||
|
this._lastPromptTime = Date.now();
|
||||||
|
this.emit('idle');
|
||||||
|
}
|
||||||
|
}, IDLE_DETECTION_DELAY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect when Claude starts working (thinking, writing, etc)
|
||||||
|
// Fast path: check spinner characters on raw data (Unicode, never in ANSI sequences)
|
||||||
|
const hasSpinner = SPINNER_PATTERN.test(data);
|
||||||
|
if (hasSpinner) {
|
||||||
|
if (!this._isWorking) {
|
||||||
|
this._isWorking = true;
|
||||||
|
this._status = 'busy';
|
||||||
|
this.emit('working');
|
||||||
|
this._autoOps.notifyWorking();
|
||||||
|
}
|
||||||
|
this._awaitingIdleConfirmation = false;
|
||||||
|
if (this.activityTimeout) clearTimeout(this.activityTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
// === Expensive processing (ANSI strip, Ralph, bash parser) is throttled ===
|
// === Expensive processing (ANSI strip, Ralph, bash parser) is throttled ===
|
||||||
// Instead of running regex-heavy parsers on every PTY chunk, we accumulate
|
// Instead of running regex-heavy parsers on every PTY chunk, we accumulate
|
||||||
@@ -1824,7 +1839,6 @@ export class Session extends EventEmitter {
|
|||||||
this._pid = null;
|
this._pid = null;
|
||||||
this._status = 'idle';
|
this._status = 'idle';
|
||||||
this._awaitingIdleConfirmation = false;
|
this._awaitingIdleConfirmation = false;
|
||||||
this._activityStreak = null;
|
|
||||||
// Clear all timers to prevent memory leaks
|
// Clear all timers to prevent memory leaks
|
||||||
if (this.activityTimeout) {
|
if (this.activityTimeout) {
|
||||||
clearTimeout(this.activityTimeout);
|
clearTimeout(this.activityTimeout);
|
||||||
@@ -1880,180 +1894,6 @@ export class Session extends EventEmitter {
|
|||||||
return this._respawnBlocked;
|
return this._respawnBlocked;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Answer Claude's workspace-trust dialog, which blocks a fresh case until
|
|
||||||
* someone presses Enter. Codeman sessions run permission-skipping or
|
|
||||||
* classifier-guarded modes, so the answer is always "yes, I trust this folder".
|
|
||||||
*
|
|
||||||
* Reads the RENDERED SCREEN rather than the chunk that just arrived. tmux
|
|
||||||
* repaints a row with cursor-forward escapes in place of spaces, so the wire
|
|
||||||
* carries `I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder` and the old
|
|
||||||
* `data.includes('trust this folder')` could never match: the auto-accept had
|
|
||||||
* been dead for every session that hit the dialog. The screen is also what
|
|
||||||
* makes a retry safe, since the terminal buffer is append-only and keeps the
|
|
||||||
* dialog in its tail long after it has been answered.
|
|
||||||
*
|
|
||||||
* Three guards keep an Enter press off a live session: a startup-only window,
|
|
||||||
* a two-marker match (isTrustDialogScreen), and an attempt cap.
|
|
||||||
*/
|
|
||||||
private _maybeAcceptTrustDialog(): void {
|
|
||||||
if (this._trustDialogAccepted) return;
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - this._interactiveStartedAt > TRUST_DIALOG_WINDOW_MS) {
|
|
||||||
this._trustDialogAccepted = true; // window closed; anything matching now is not the dialog
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (now - this._lastTrustDialogScanAt < TRUST_DIALOG_RETRY_MS) return;
|
|
||||||
this._lastTrustDialogScanAt = now;
|
|
||||||
|
|
||||||
// Prefer the pane; fall back to the buffer tail on a direct-PTY session,
|
|
||||||
// where there is no screen to read.
|
|
||||||
const screen =
|
|
||||||
(this._mux && this._muxSession ? this._mux.capturePaneText?.(this._muxSession.muxName) : null) ??
|
|
||||||
this._terminalBuffer.value.slice(-TRUST_DIALOG_SCAN_BYTES);
|
|
||||||
if (!isTrustDialogScreen(screen)) return;
|
|
||||||
|
|
||||||
this._trustDialogAttempts++;
|
|
||||||
if (this._trustDialogAttempts > TRUST_DIALOG_MAX_ATTEMPTS) {
|
|
||||||
this._trustDialogAccepted = true; // leave it to the user rather than keep typing
|
|
||||||
console.warn(`[Session] Workspace trust dialog did not clear after retries: ${this.id}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log(
|
|
||||||
`[Session] Auto-accepting workspace trust dialog for: ${this.id} (attempt ${this._trustDialogAttempts})`
|
|
||||||
);
|
|
||||||
// Enter confirms the highlighted default, "1. Yes, I trust this folder".
|
|
||||||
this.writeViaMux('\r');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-chunk working/idle detection for an interactive pane. Split out of the
|
|
||||||
* PTY `onData` handler so it can be unit tested without spawning one.
|
|
||||||
*
|
|
||||||
* @param data raw PTY chunk, ANSI included
|
|
||||||
*/
|
|
||||||
private _detectInteractiveActivity(data: string): void {
|
|
||||||
// The prompt line contains "❯" when Claude is waiting for input. It only ARMS
|
|
||||||
// the check and is NOT evidence the turn ended: Claude redraws the composer
|
|
||||||
// about once a second all the way through a turn, which is exactly how a
|
|
||||||
// working session used to flip to idle two seconds in. _confirmIdle() waits
|
|
||||||
// for the pane to actually go quiet before believing it.
|
|
||||||
if (data.includes('❯')) {
|
|
||||||
// Only start a new timeout if we're not already awaiting idle confirmation.
|
|
||||||
// This prevents status bar redraws (which include the prompt) from resetting it.
|
|
||||||
if (!this._awaitingIdleConfirmation) {
|
|
||||||
if (this.activityTimeout) clearTimeout(this.activityTimeout);
|
|
||||||
this._awaitingIdleConfirmation = true;
|
|
||||||
this.activityTimeout = setTimeout(() => this._confirmIdle(), IDLE_DETECTION_DELAY_MS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect when Claude starts working (thinking, writing, etc).
|
|
||||||
// Fast path: spinner characters on raw data (Unicode, never inside ANSI sequences).
|
|
||||||
if (SPINNER_PATTERN.test(data)) this._markWorking();
|
|
||||||
|
|
||||||
// Activity fallback: current Claude Code animates `✻ Actualizing…` instead of a
|
|
||||||
// braille spinner, so the fast path above misses entire turns, and matching the
|
|
||||||
// new status line does not rescue it either (tmux repaints partially, so the
|
|
||||||
// complete line reaches the PTY only every few tens of seconds). An unbroken run
|
|
||||||
// of repaints is the signal that survives. See session-activity.ts for the
|
|
||||||
// measurement. Claude only: an external CLI's TUI has no ❯, so nothing would
|
|
||||||
// ever arm the idle confirmation and such a session would latch busy forever.
|
|
||||||
if (!isExternalCliMode(this.mode)) {
|
|
||||||
this._activityStreak = trackActivityStreak(this._activityStreak, Date.now());
|
|
||||||
// A streak is the TRIGGER to look, not the verdict: typing into the composer
|
|
||||||
// also produces a steady stream of repaints. The screen settles it, and only
|
|
||||||
// an explicit "no working line" vetoes; a probe that cannot read the pane
|
|
||||||
// (null) leaves the streak in charge.
|
|
||||||
if (!this._isWorking && isSustainedActivity(this._activityStreak) && this._probePaneWorking() !== false) {
|
|
||||||
this._markWorking();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ask the pane what it is rendering right now.
|
|
||||||
*
|
|
||||||
* The PTY stream cannot answer this on its own: measured on a live worker,
|
|
||||||
* Claude repaints roughly once a second for most of a turn but can then sit
|
|
||||||
* completely silent for tens of seconds inside a single tool call, while the
|
|
||||||
* `✻ Elucidating… (39s · ↓ 2.0k tokens)` line stays on screen the whole time.
|
|
||||||
* Silence therefore proves nothing, and the rendered frame is the only cheap
|
|
||||||
* source that is right in both directions.
|
|
||||||
*
|
|
||||||
* Costs one `capture-pane`, floored at PANE_PROBE_MIN_INTERVAL_MS per session
|
|
||||||
* and only ever called at a transition, never on the output hot path.
|
|
||||||
*
|
|
||||||
* @returns true/false when the screen could be read, null when it could not
|
|
||||||
* (no mux, capture failed, tests). Callers must treat null as "no evidence"
|
|
||||||
* and fall back to their stream heuristics.
|
|
||||||
*/
|
|
||||||
private _probePaneWorking(): boolean | null {
|
|
||||||
if (!this._mux || !this._muxSession) return null;
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - this._lastPaneProbeAt < PANE_PROBE_MIN_INTERVAL_MS) return this._lastPaneProbeWorking;
|
|
||||||
this._lastPaneProbeAt = now;
|
|
||||||
const text = this._mux.capturePaneText?.(this._muxSession.muxName) ?? null;
|
|
||||||
this._lastPaneProbeWorking = text === null ? null : CLAUDE_WORKING_LINE_PATTERN.test(text);
|
|
||||||
return this._lastPaneProbeWorking;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark the pane as working. Idempotent: `working` is emitted on the transition
|
|
||||||
* only, so the per-chunk detectors can all call it freely.
|
|
||||||
*
|
|
||||||
* Deliberately does NOT cancel a pending idle confirmation. That confirmation
|
|
||||||
* is what eventually notices the turn ended, and it already refuses to fire
|
|
||||||
* while the pane is noisy, and cancelling it here would leave a session that
|
|
||||||
* finished during a lull with nothing armed to ever call it idle.
|
|
||||||
*/
|
|
||||||
private _markWorking(): void {
|
|
||||||
if (this._isWorking) return;
|
|
||||||
this._isWorking = true;
|
|
||||||
this._status = 'busy';
|
|
||||||
this.emit('working');
|
|
||||||
this._autoOps.notifyWorking();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decide whether the armed idle confirmation is real.
|
|
||||||
*
|
|
||||||
* A ❯ sighting alone means nothing (Claude redraws the composer through the
|
|
||||||
* whole turn), so the pane must ALSO have gone quiet. While output is still
|
|
||||||
* flowing the check re-arms instead of concluding. That loop is a timestamp
|
|
||||||
* compare every IDLE_RECHECK_MS and ends the moment the pane falls silent.
|
|
||||||
*/
|
|
||||||
private _confirmIdle(): void {
|
|
||||||
if (this._isStopped) {
|
|
||||||
this._awaitingIdleConfirmation = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isPaneQuiet(this._lastActivityAt, Date.now())) {
|
|
||||||
this.activityTimeout = setTimeout(() => this._confirmIdle(), IDLE_RECHECK_MS);
|
|
||||||
return; // stays _awaitingIdleConfirmation, so ❯ redraws do not pile up timers
|
|
||||||
}
|
|
||||||
// Quiet is necessary but NOT sufficient: a turn can go silent mid-tool-call.
|
|
||||||
// Ask the screen before concluding, and keep asking on a slow cadence.
|
|
||||||
if (this._probePaneWorking() === true) {
|
|
||||||
this._markWorking();
|
|
||||||
this.activityTimeout = setTimeout(() => this._confirmIdle(), PANE_PROBE_RECHECK_MS);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._awaitingIdleConfirmation = false;
|
|
||||||
this.activityTimeout = null;
|
|
||||||
// Emit idle if either:
|
|
||||||
// 1. Claude was working and is now at prompt (normal case)
|
|
||||||
// 2. Session just started and is ready (status is 'busy' but _isWorking is false)
|
|
||||||
const wasWorking = this._isWorking;
|
|
||||||
const isInitialReady = this._status === 'busy' && !this._isWorking;
|
|
||||||
if (wasWorking || isInitialReady) {
|
|
||||||
this._isWorking = false;
|
|
||||||
this._status = 'idle';
|
|
||||||
this._lastPromptTime = Date.now();
|
|
||||||
this.emit('idle');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process expensive parsers (ANSI strip, Ralph, bash tool, token, CLI info, task descriptions).
|
* Process expensive parsers (ANSI strip, Ralph, bash tool, token, CLI info, task descriptions).
|
||||||
* Called on a throttled schedule (every EXPENSIVE_PROCESS_INTERVAL_MS) instead of on every
|
* Called on a throttled schedule (every EXPENSIVE_PROCESS_INTERVAL_MS) instead of on every
|
||||||
@@ -2104,22 +1944,22 @@ export class Session extends EventEmitter {
|
|||||||
this.parseTaskDescriptionsFromTerminalData(getCleanData());
|
this.parseTaskDescriptionsFromTerminalData(getCleanData());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Work detection (text-based, needs clean data: the status line is coloured,
|
// Work keyword detection (text-based, needs clean data)
|
||||||
// so raw data has escape sequences between the `…` and the elapsed timer).
|
// Only check if spinner didn't already trigger working state
|
||||||
// Only check if a faster path didn't already trigger working state.
|
|
||||||
if (!this._isWorking) {
|
if (!this._isWorking) {
|
||||||
const cleanData = getCleanData();
|
const cleanData = getCleanData();
|
||||||
if (
|
if (
|
||||||
CLAUDE_WORKING_LINE_PATTERN.test(cleanData) ||
|
|
||||||
// Legacy gerunds. Current Claude randomizes the word ("Actualizing…",
|
|
||||||
// "Finagling…"), so these catch only a fraction of turns; the pattern
|
|
||||||
// above and the activity streak carry the rest.
|
|
||||||
cleanData.includes('Thinking') ||
|
cleanData.includes('Thinking') ||
|
||||||
cleanData.includes('Writing') ||
|
cleanData.includes('Writing') ||
|
||||||
cleanData.includes('Reading') ||
|
cleanData.includes('Reading') ||
|
||||||
cleanData.includes('Running')
|
cleanData.includes('Running')
|
||||||
) {
|
) {
|
||||||
this._markWorking();
|
this._isWorking = true;
|
||||||
|
this._status = 'busy';
|
||||||
|
this.emit('working');
|
||||||
|
this._autoOps.notifyWorking();
|
||||||
|
this._awaitingIdleConfirmation = false;
|
||||||
|
if (this.activityTimeout) clearTimeout(this.activityTimeout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3144,30 +3144,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
* Used for full page reloads so the user gets back their scroll history.
|
* Used for full page reloads so the user gets back their scroll history.
|
||||||
* Caveat: lines tmux has already evicted past its history-limit are gone.
|
* Caveat: lines tmux has already evicted past its history-limit are gone.
|
||||||
*/
|
*/
|
||||||
/**
|
|
||||||
* Plain visible-frame text for the working/idle probe (see `session.ts`).
|
|
||||||
*
|
|
||||||
* One `capture-pane` and nothing else: no `-e` styles, no `display-message`
|
|
||||||
* cursor query, no repaint reconstruction: this feeds a regex, not a
|
|
||||||
* terminal. Returns null in tests (no tmux) so callers fall back to their
|
|
||||||
* stream heuristics rather than reading an empty screen as "not working".
|
|
||||||
*/
|
|
||||||
capturePaneText(muxName: string, paneTarget?: string): string | null {
|
|
||||||
if (IS_TEST_MODE) return null;
|
|
||||||
const target = resolveTmuxPaneTarget(muxName, paneTarget);
|
|
||||||
if (!target) return null;
|
|
||||||
try {
|
|
||||||
return execSync(`${this.tmux()} capture-pane -p -t ${shellescape(target)}`, {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// A dead/renamed pane is an ordinary outcome here, not an error worth logging
|
|
||||||
// on a timer; the caller treats null as "no evidence either way".
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
capturePaneBuffer(muxName: string, paneTarget?: string, opts?: PaneCaptureOptions): string | null {
|
capturePaneBuffer(muxName: string, paneTarget?: string, opts?: PaneCaptureOptions): string | null {
|
||||||
if (IS_TEST_MODE) return '';
|
if (IS_TEST_MODE) return '';
|
||||||
const target = resolveTmuxPaneTarget(muxName, paneTarget);
|
const target = resolveTmuxPaneTarget(muxName, paneTarget);
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export {
|
|||||||
ANSI_ESCAPE_PATTERN_SIMPLE,
|
ANSI_ESCAPE_PATTERN_SIMPLE,
|
||||||
TOKEN_PATTERN,
|
TOKEN_PATTERN,
|
||||||
SPINNER_PATTERN,
|
SPINNER_PATTERN,
|
||||||
CLAUDE_WORKING_LINE_PATTERN,
|
|
||||||
stripAnsi,
|
stripAnsi,
|
||||||
SAFE_PATH_PATTERN,
|
SAFE_PATH_PATTERN,
|
||||||
execPattern,
|
execPattern,
|
||||||
|
|||||||
@@ -60,24 +60,6 @@ export function stripAnsi(text: string): string {
|
|||||||
*/
|
*/
|
||||||
export const SPINNER_PATTERN = /[⠋⠙⠹⠸⠼⠴⠦⠧]/;
|
export const SPINNER_PATTERN = /[⠋⠙⠹⠸⠼⠴⠦⠧]/;
|
||||||
|
|
||||||
/**
|
|
||||||
* Claude Code's live working status line, e.g.
|
|
||||||
* `✻ Actualizing… (13m 23s · ↓ 47.5k tokens)`
|
|
||||||
* `✽ Herding… (3s · esc to interrupt)`
|
|
||||||
*
|
|
||||||
* Matched on the ELLIPSIS + elapsed timer, never on the leading glyph: the
|
|
||||||
* animation cycles through `· ✢ ✳ ∗ ✻ ✽` (two of those are ordinary punctuation)
|
|
||||||
* and the gerund is randomized per turn, while the finished line (`✻ Cooked for
|
|
||||||
* 2m 49s`) carries the same glyph with no `…` and no parenthesis. Feed this
|
|
||||||
* ANSI-STRIPPED data: tmux colours the timer separately, so the raw stream has
|
|
||||||
* escape sequences sitting between the `…` and the `(`.
|
|
||||||
*
|
|
||||||
* A sighting is proof the pane is working; its ABSENCE proves nothing, because
|
|
||||||
* tmux repaints partially and the whole line reaches the PTY only occasionally
|
|
||||||
* (see `session-activity.ts` for what carries the idle decision instead).
|
|
||||||
*/
|
|
||||||
export const CLAUDE_WORKING_LINE_PATTERN = /…\s*\((?:\d+h\s+)?(?:\d+m\s+)?\d+s\b|esc to interrupt/;
|
|
||||||
|
|
||||||
export const SAFE_PATH_PATTERN = /^[\p{L}\p{N}_/\-. ~]+$/u;
|
export const SAFE_PATH_PATTERN = /^[\p{L}\p{N}_/\-. ~]+$/u;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -33,6 +33,12 @@
|
|||||||
// Shared Filesystem Path Picker
|
// Shared Filesystem Path Picker
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// Per-device, and deliberately its own key rather than a shared "show hidden"
|
||||||
|
// preference with the File Viewer: that tree is confined to one workspace, while
|
||||||
|
// the picker browses Home and every configured root, so wanting dotfiles in a
|
||||||
|
// project does not imply wanting them in ~.
|
||||||
|
const PATH_PICKER_SHOW_HIDDEN_KEY = 'codeman:pathPickerShowHidden';
|
||||||
|
|
||||||
const PathPicker = {
|
const PathPicker = {
|
||||||
overlay: null,
|
overlay: null,
|
||||||
_options: null,
|
_options: null,
|
||||||
@@ -43,6 +49,7 @@ const PathPicker = {
|
|||||||
_previewOverlay: null,
|
_previewOverlay: null,
|
||||||
_previewRequestSequence: 0,
|
_previewRequestSequence: 0,
|
||||||
_previewPreviousFocus: null,
|
_previewPreviousFocus: null,
|
||||||
|
_showHidden: false,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the lazy filesystem browser.
|
* Open the lazy filesystem browser.
|
||||||
@@ -53,6 +60,7 @@ const PathPicker = {
|
|||||||
this.close(false);
|
this.close(false);
|
||||||
this._options = options;
|
this._options = options;
|
||||||
this._selectedPath = '';
|
this._selectedPath = '';
|
||||||
|
this._showHidden = this._loadShowHidden();
|
||||||
this._previousFocus = document.activeElement;
|
this._previousFocus = document.activeElement;
|
||||||
this._previousFocus?.blur?.();
|
this._previousFocus?.blur?.();
|
||||||
|
|
||||||
@@ -74,6 +82,7 @@ const PathPicker = {
|
|||||||
<div class="path-picker-nav">
|
<div class="path-picker-nav">
|
||||||
<button type="button" class="path-picker-up" title="Parent folder" aria-label="Parent folder">↑</button>
|
<button type="button" class="path-picker-up" title="Parent folder" aria-label="Parent folder">↑</button>
|
||||||
<div class="path-picker-current" title="Current folder"></div>
|
<div class="path-picker-current" title="Current folder"></div>
|
||||||
|
<button type="button" class="path-picker-hidden" title="Show hidden files and folders" aria-label="Show hidden files and folders" aria-pressed="false">.*</button>
|
||||||
<button type="button" class="path-picker-refresh" title="Refresh" aria-label="Refresh">↻</button>
|
<button type="button" class="path-picker-refresh" title="Refresh" aria-label="Refresh">↻</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="path-picker-status" aria-live="polite">Loading...</div>
|
<div class="path-picker-status" aria-live="polite">Loading...</div>
|
||||||
@@ -100,6 +109,8 @@ const PathPicker = {
|
|||||||
if (current) this.select(current);
|
if (current) this.select(current);
|
||||||
});
|
});
|
||||||
overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load());
|
overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load());
|
||||||
|
overlay.querySelector('.path-picker-hidden').addEventListener('click', () => this.toggleHidden());
|
||||||
|
this._syncHiddenButton();
|
||||||
overlay.querySelector('.path-picker-up').addEventListener('click', () => {
|
overlay.querySelector('.path-picker-up').addEventListener('click', () => {
|
||||||
const parent = overlay.querySelector('.path-picker-up').dataset.parent;
|
const parent = overlay.querySelector('.path-picker-up').dataset.parent;
|
||||||
if (parent) this.load(parent);
|
if (parent) this.load(parent);
|
||||||
@@ -120,6 +131,38 @@ const PathPicker = {
|
|||||||
this.load(options.initialPath || '');
|
this.load(options.initialPath || '');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_loadShowHidden() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(PATH_PICKER_SHOW_HIDDEN_KEY) === '1';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_syncHiddenButton() {
|
||||||
|
const btn = this.overlay?.querySelector('.path-picker-hidden');
|
||||||
|
if (!btn) return;
|
||||||
|
const label = this._showHidden ? 'Hide hidden files and folders' : 'Show hidden files and folders';
|
||||||
|
btn.classList.toggle('active', this._showHidden);
|
||||||
|
btn.setAttribute('aria-pressed', this._showHidden ? 'true' : 'false');
|
||||||
|
btn.setAttribute('title', label);
|
||||||
|
btn.setAttribute('aria-label', label);
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleHidden() {
|
||||||
|
if (!this.overlay) return;
|
||||||
|
this._showHidden = !this._showHidden;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PATH_PICKER_SHOW_HIDDEN_KEY, this._showHidden ? '1' : '0');
|
||||||
|
} catch {}
|
||||||
|
this._syncHiddenButton();
|
||||||
|
// Reload where we are rather than resetting to the root. Turning the toggle
|
||||||
|
// OFF inside a hidden folder makes the current path unbrowsable again; the
|
||||||
|
// server answers 403 and load()'s catch falls back to the default root,
|
||||||
|
// which is the only place left to stand.
|
||||||
|
this.load(this.overlay.querySelector('.path-picker-current').textContent || '');
|
||||||
|
},
|
||||||
|
|
||||||
async load(path) {
|
async load(path) {
|
||||||
if (!this.overlay || !this._options) return;
|
if (!this.overlay || !this._options) return;
|
||||||
const loadSequence = ++this._loadSequence;
|
const loadSequence = ++this._loadSequence;
|
||||||
@@ -131,6 +174,7 @@ const PathPicker = {
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (path) params.set('path', path);
|
if (path) params.set('path', path);
|
||||||
if (this._options.sessionId) params.set('sessionId', this._options.sessionId);
|
if (this._options.sessionId) params.set('sessionId', this._options.sessionId);
|
||||||
|
if (this._showHidden) params.set('showHidden', 'true');
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/filesystem/browse?${params.toString()}`);
|
const response = await fetch(`/api/filesystem/browse?${params.toString()}`);
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -248,6 +292,9 @@ const PathPicker = {
|
|||||||
const requestSequence = ++this._previewRequestSequence;
|
const requestSequence = ++this._previewRequestSequence;
|
||||||
const params = new URLSearchParams({ path: entry.path });
|
const params = new URLSearchParams({ path: entry.path });
|
||||||
if (this._options?.sessionId) params.set('sessionId', this._options.sessionId);
|
if (this._options?.sessionId) params.set('sessionId', this._options.sessionId);
|
||||||
|
// A hidden file is only reachable while the toggle is on, and the preview
|
||||||
|
// endpoint re-resolves the path independently, so it needs the flag too.
|
||||||
|
if (this._showHidden) params.set('showHidden', 'true');
|
||||||
const previewUrl = `/api/filesystem/preview?${params.toString()}`;
|
const previewUrl = `/api/filesystem/preview?${params.toString()}`;
|
||||||
|
|
||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('div');
|
||||||
|
|||||||
@@ -2522,51 +2522,6 @@ html.mobile-init .file-browser-panel {
|
|||||||
border-color: var(--red);
|
border-color: var(--red);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Working is not an alert, so it gets a calm green breathing edge rather than a
|
|
||||||
blink: at a glance the row reads "this one is moving", without competing with
|
|
||||||
the two states that actually want you. Slower than both of them on purpose. */
|
|
||||||
.mobile-overview-row--working {
|
|
||||||
border-color: var(--green);
|
|
||||||
animation: mobile-overview-breathe-green 2.2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes mobile-overview-breathe-green {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border-color: var(--border);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
background: rgba(34, 197, 94, 0.1);
|
|
||||||
border-color: var(--green);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The pill picks up a three-dot ellipsis that fills in and empties, so the row
|
|
||||||
still reads as active on a skin where the border tint is subtle. */
|
|
||||||
.mobile-overview-pill--working::after {
|
|
||||||
content: '';
|
|
||||||
display: inline-block;
|
|
||||||
width: 0.75em;
|
|
||||||
text-align: left;
|
|
||||||
animation: mobile-overview-pill-dots 1.5s steps(1, end) infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes mobile-overview-pill-dots {
|
|
||||||
0% {
|
|
||||||
content: '';
|
|
||||||
}
|
|
||||||
25% {
|
|
||||||
content: '.';
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
content: '..';
|
|
||||||
}
|
|
||||||
75% {
|
|
||||||
content: '...';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes mobile-overview-blink-red {
|
@keyframes mobile-overview-blink-red {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
@@ -2648,25 +2603,6 @@ html.mobile-init .file-browser-panel {
|
|||||||
will-change: opacity;
|
will-change: opacity;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ring the pulsing dot with the SAME spinner a tab shows while it loads: same
|
|
||||||
2px ring, same bright leading edge, same `tab-load-spin` keyframes from
|
|
||||||
styles.css (reused, not re-declared, so the two can never drift). Green
|
|
||||||
rather than the tab's blue because here it means "running", not "loading":
|
|
||||||
the motion is the shared part, the color still belongs to the state. */
|
|
||||||
.mobile-overview-dot {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-overview-dot--working::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: -4px;
|
|
||||||
border: 2px solid rgba(34, 197, 94, 0.25);
|
|
||||||
border-top-color: var(--green);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: tab-load-spin 0.7s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-overview-dot--idle {
|
.mobile-overview-dot--idle {
|
||||||
background: var(--green);
|
background: var(--green);
|
||||||
}
|
}
|
||||||
@@ -2777,24 +2713,6 @@ html.mobile-init .file-browser-panel {
|
|||||||
.mobile-overview-dot--working {
|
.mobile-overview-dot--working {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The ring stays as a static full circle: it still marks the row, it just
|
|
||||||
stops turning. */
|
|
||||||
.mobile-overview-dot--working::after {
|
|
||||||
border-color: var(--green);
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Working is only informational, so it drops to a static green edge and a
|
|
||||||
static ellipsis rather than holding a tint the way the alerts do. */
|
|
||||||
.mobile-overview-row--working {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-overview-pill--working::after {
|
|
||||||
content: '...';
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Light-skin compatibility for mobile-only chrome. These components predate
|
/* Light-skin compatibility for mobile-only chrome. These components predate
|
||||||
|
|||||||
@@ -11980,7 +11980,8 @@ body.touch-device.cjk-input-visible .main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.path-picker-up,
|
.path-picker-up,
|
||||||
.path-picker-refresh {
|
.path-picker-refresh,
|
||||||
|
.path-picker-hidden {
|
||||||
flex: 0 0 38px;
|
flex: 0 0 38px;
|
||||||
height: 38px;
|
height: 38px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -11990,6 +11991,20 @@ body.touch-device.cjk-input-visible .main {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Show-hidden toggle: a literal `.*` glyph rather than an icon, so its meaning
|
||||||
|
* (dot-prefixed files and folders) survives every skin and font stack. */
|
||||||
|
.path-picker-hidden {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-hidden.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.path-picker-up:disabled {
|
.path-picker-up:disabled {
|
||||||
opacity: 0.35;
|
opacity: 0.35;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
|||||||
@@ -315,11 +315,25 @@ function findMatchingPickerRoot(roots: FilesystemBrowseRoot[], candidate: string
|
|||||||
.sort((a, b) => b.path.length - a.path.length)[0];
|
.sort((a, b) => b.path.length - a.path.length)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a path has a dot-prefixed segment anywhere below its browse root.
|
||||||
|
*
|
||||||
|
* Checked against the REALPATH, so a plainly-named symlink pointing into a
|
||||||
|
* hidden tree is caught too. Callers skip it when the request opts into hidden
|
||||||
|
* entries (`showHidden`), which is why the sensitive-path blocklist and the
|
||||||
|
* blocked-tree checks must stand on their own: with the toggle on, this is no
|
||||||
|
* longer the thing keeping `~/.config/gh/hosts.yml` out of reach.
|
||||||
|
*/
|
||||||
function containsHiddenPickerSegment(root: string, candidate: string): boolean {
|
function containsHiddenPickerSegment(root: string, candidate: string): boolean {
|
||||||
const rel = relative(root, candidate);
|
const rel = relative(root, candidate);
|
||||||
return rel !== '' && rel.split(sep).some((segment) => segment.startsWith('.'));
|
return rel !== '' && rel.split(sep).some((segment) => segment.startsWith('.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parses the picker's opt-in `showHidden` query flag (absent means off). */
|
||||||
|
function wantsHiddenPickerEntries(showHidden?: string): boolean {
|
||||||
|
return showHidden === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
function getFilesystemPreviewKind(fileName: string): FilesystemPreviewKind | undefined {
|
function getFilesystemPreviewKind(fileName: string): FilesystemPreviewKind | undefined {
|
||||||
const extension = extname(fileName).slice(1).toLowerCase();
|
const extension = extname(fileName).slice(1).toLowerCase();
|
||||||
if (FILESYSTEM_IMAGE_PREVIEW_EXTENSIONS.has(extension)) return 'image';
|
if (FILESYSTEM_IMAGE_PREVIEW_EXTENSIONS.has(extension)) return 'image';
|
||||||
@@ -431,7 +445,8 @@ async function resolveFilesystemPickerPath(
|
|||||||
ctx: SessionPort & ConfigPort,
|
ctx: SessionPort & ConfigPort,
|
||||||
req: FastifyRequest,
|
req: FastifyRequest,
|
||||||
requestedPath: string | undefined,
|
requestedPath: string | undefined,
|
||||||
sessionId?: string
|
sessionId?: string,
|
||||||
|
showHidden = false
|
||||||
): Promise<ResolvedFilesystemPickerPath> {
|
): Promise<ResolvedFilesystemPickerPath> {
|
||||||
const roots = await resolveFilesystemPickerRoots(ctx, req, sessionId);
|
const roots = await resolveFilesystemPickerRoots(ctx, req, sessionId);
|
||||||
if (roots.length === 0) {
|
if (roots.length === 0) {
|
||||||
@@ -453,7 +468,7 @@ async function resolveFilesystemPickerPath(
|
|||||||
if (!matchingRoot) {
|
if (!matchingRoot) {
|
||||||
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Path is outside the allowed browse roots');
|
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Path is outside the allowed browse roots');
|
||||||
}
|
}
|
||||||
if (containsHiddenPickerSegment(matchingRoot.path, resolvedPath)) {
|
if (!showHidden && containsHiddenPickerSegment(matchingRoot.path, resolvedPath)) {
|
||||||
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Hidden paths are not available in the file picker');
|
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Hidden paths are not available in the file picker');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -662,12 +677,14 @@ function inheritedHeaders(reply: {
|
|||||||
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort): void {
|
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort): void {
|
||||||
// Lazy filesystem listing for the Link Existing and mobile input path pickers.
|
// Lazy filesystem listing for the Link Existing and mobile input path pickers.
|
||||||
app.get('/api/filesystem/browse', async (req, reply): Promise<ApiResponse<FilesystemBrowseData>> => {
|
app.get('/api/filesystem/browse', async (req, reply): Promise<ApiResponse<FilesystemBrowseData>> => {
|
||||||
const { path: requestedPath, sessionId } = parseBody(FilesystemBrowseQuerySchema, req.query);
|
const { path: requestedPath, sessionId, showHidden } = parseBody(FilesystemBrowseQuerySchema, req.query);
|
||||||
|
const includeHidden = wantsHiddenPickerEntries(showHidden);
|
||||||
const { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees } = await resolveFilesystemPickerPath(
|
const { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees } = await resolveFilesystemPickerPath(
|
||||||
ctx,
|
ctx,
|
||||||
req,
|
req,
|
||||||
requestedPath,
|
requestedPath,
|
||||||
sessionId
|
sessionId,
|
||||||
|
includeHidden
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isBlockedPickerPath(resolvedPath, blockedTrees, true)) {
|
if (isBlockedPickerPath(resolvedPath, blockedTrees, true)) {
|
||||||
@@ -703,7 +720,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
|||||||
const entries: FilesystemBrowseEntry[] = [];
|
const entries: FilesystemBrowseEntry[] = [];
|
||||||
let truncated = false;
|
let truncated = false;
|
||||||
for (const entry of dirEntries) {
|
for (const entry of dirEntries) {
|
||||||
if (entry.name.startsWith('.')) continue;
|
if (!includeHidden && entry.name.startsWith('.')) continue;
|
||||||
if (entries.length >= FILESYSTEM_PICKER_ENTRY_LIMIT) {
|
if (entries.length >= FILESYSTEM_PICKER_ENTRY_LIMIT) {
|
||||||
truncated = true;
|
truncated = true;
|
||||||
break;
|
break;
|
||||||
@@ -718,7 +735,8 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
|||||||
}
|
}
|
||||||
|
|
||||||
const targetRoot = findMatchingPickerRoot(roots, targetPath);
|
const targetRoot = findMatchingPickerRoot(roots, targetPath);
|
||||||
if (!targetRoot || containsHiddenPickerSegment(targetRoot.path, targetPath)) continue;
|
if (!targetRoot) continue;
|
||||||
|
if (!includeHidden && containsHiddenPickerSegment(targetRoot.path, targetPath)) continue;
|
||||||
|
|
||||||
let type: FilesystemBrowseEntry['type'];
|
let type: FilesystemBrowseEntry['type'];
|
||||||
let size: number | undefined;
|
let size: number | undefined;
|
||||||
@@ -783,12 +801,13 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
|||||||
|
|
||||||
// Inline preview for files selected through the root-confined filesystem picker.
|
// Inline preview for files selected through the root-confined filesystem picker.
|
||||||
app.get('/api/filesystem/preview', { compress: false }, async (req, reply): Promise<void> => {
|
app.get('/api/filesystem/preview', { compress: false }, async (req, reply): Promise<void> => {
|
||||||
const { path: requestedPath, sessionId } = parseBody(FilesystemPreviewQuerySchema, req.query);
|
const { path: requestedPath, sessionId, showHidden } = parseBody(FilesystemPreviewQuerySchema, req.query);
|
||||||
const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath(
|
const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath(
|
||||||
ctx,
|
ctx,
|
||||||
req,
|
req,
|
||||||
requestedPath,
|
requestedPath,
|
||||||
sessionId
|
sessionId,
|
||||||
|
wantsHiddenPickerEntries(showHidden)
|
||||||
);
|
);
|
||||||
if (isBlockedPickerPath(resolvedPath, blockedTrees)) {
|
if (isBlockedPickerPath(resolvedPath, blockedTrees)) {
|
||||||
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked');
|
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked');
|
||||||
|
|||||||
@@ -65,6 +65,14 @@ const filesystemPickerPathSchema = z
|
|||||||
})
|
})
|
||||||
.refine((p) => !p.split('/').includes('..'), { message: 'Path traversal is not allowed' });
|
.refine((p) => !p.split('/').includes('..'), { message: 'Path traversal is not allowed' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opt-in flag for listing dot-prefixed entries in the path picker. Absent means
|
||||||
|
* off, so an old client keeps the previous behavior. It is a string rather than
|
||||||
|
* a boolean because it arrives as a query parameter; `'false'` is accepted (and
|
||||||
|
* means off) so a client can send the flag unconditionally.
|
||||||
|
*/
|
||||||
|
const showHiddenQuerySchema = z.enum(['true', 'false']).optional();
|
||||||
|
|
||||||
/** Query validation for the lazy, allowlisted filesystem path picker. */
|
/** Query validation for the lazy, allowlisted filesystem path picker. */
|
||||||
export const FilesystemBrowseQuerySchema = z.object({
|
export const FilesystemBrowseQuerySchema = z.object({
|
||||||
path: filesystemPickerPathSchema.optional(),
|
path: filesystemPickerPathSchema.optional(),
|
||||||
@@ -73,6 +81,7 @@ export const FilesystemBrowseQuerySchema = z.object({
|
|||||||
.max(100)
|
.max(100)
|
||||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id')
|
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id')
|
||||||
.optional(),
|
.optional(),
|
||||||
|
showHidden: showHiddenQuerySchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Query validation for a single allowlisted path-picker file preview. */
|
/** Query validation for a single allowlisted path-picker file preview. */
|
||||||
@@ -83,6 +92,7 @@ export const FilesystemPreviewQuerySchema = z.object({
|
|||||||
.max(100)
|
.max(100)
|
||||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id')
|
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id')
|
||||||
.optional(),
|
.optional(),
|
||||||
|
showHidden: showHiddenQuerySchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,23 +13,73 @@
|
|||||||
* credentials, dotenv files) while leaving ordinary cross-workspace files
|
* credentials, dotenv files) while leaving ordinary cross-workspace files
|
||||||
* attachable.
|
* attachable.
|
||||||
*
|
*
|
||||||
|
* ⚠️ The path picker's `showHidden` option is what makes the dot-prefixed half
|
||||||
|
* of this list load-bearing. Before it existed, the picker refused every path
|
||||||
|
* with a hidden segment, so `~/.config/gh/hosts.yml` and friends were
|
||||||
|
* unreachable by construction and the list only had to cover the few secrets
|
||||||
|
* that live in plain sight. Opting into hidden entries removes that accident,
|
||||||
|
* so every credential location below has to be named. Adding a new browse
|
||||||
|
* surface means re-reading this file, not assuming it already covers you.
|
||||||
|
*
|
||||||
|
* ⚠️ Deliberately NOT whole-tree blocks: `~/.codeman/` (the publish skill
|
||||||
|
* attaches from it) and `~/.claude/` (transcripts and team state are ordinary
|
||||||
|
* files worth attaching). Only their secret-bearing members are named.
|
||||||
|
*
|
||||||
* Callers MUST resolve symlinks (realpath) BEFORE calling isSensitivePath so a
|
* Callers MUST resolve symlinks (realpath) BEFORE calling isSensitivePath so a
|
||||||
* symlink pointing at a sensitive target is also caught.
|
* symlink pointing at a sensitive target is also caught.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { homedir } from 'node:os';
|
|
||||||
|
|
||||||
const SENSITIVE_PATTERNS: RegExp[] = [
|
const SENSITIVE_PATTERNS: RegExp[] = [
|
||||||
|
// System account databases.
|
||||||
/^\/etc\/shadow$/,
|
/^\/etc\/shadow$/,
|
||||||
/^\/etc\/gshadow$/,
|
/^\/etc\/gshadow$/,
|
||||||
/^\/etc\/master\.passwd$/,
|
/^\/etc\/master\.passwd$/,
|
||||||
new RegExp(`^${homedir().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/\\.ssh\\/`),
|
|
||||||
|
// SSH and GPG private key material. `.ssh/` is matched at any depth rather
|
||||||
|
// than only under homedir(): a per-project or per-deploy key directory holds
|
||||||
|
// exactly the same secret, and it drops a homedir() read that is captured at
|
||||||
|
// module load and therefore wrong for anything that changes HOME later.
|
||||||
|
/\/\.ssh\//,
|
||||||
|
/\/\.gnupg\//,
|
||||||
|
|
||||||
|
// Dotenv, in every conventional spelling (.env, .env.local, .env.production).
|
||||||
/\/\.env$/,
|
/\/\.env$/,
|
||||||
/\/\.env\./,
|
/\/\.env\./,
|
||||||
/\/credentials(\.json|\.yml|\.yaml|\.xml)?$/i,
|
|
||||||
/\/\.aws\/credentials$/,
|
// Generic credential files, plus the per-vendor spellings that do not match it.
|
||||||
|
/\/credentials(\.json|\.yml|\.yaml|\.xml|\.toml|\.db)?$/i,
|
||||||
|
/\/\.aws\/(credentials|config)$/,
|
||||||
|
/\/\.aws\/sso\/cache\//,
|
||||||
/\/\.gcloud\/credentials\.db$/,
|
/\/\.gcloud\/credentials\.db$/,
|
||||||
|
/\/\.config\/gcloud\//,
|
||||||
|
/\/\.azure\//,
|
||||||
/\/\.docker\/config\.json$/,
|
/\/\.docker\/config\.json$/,
|
||||||
|
/\/\.kube\/config$/,
|
||||||
|
|
||||||
|
// Package-registry and forge tokens. Each of these is a bearer credential in
|
||||||
|
// a plain-text dotfile, which is exactly what a path picker will surface.
|
||||||
|
/\/\.npmrc$/,
|
||||||
|
/\/\.yarnrc\.yml$/,
|
||||||
|
/\/\.git-credentials$/,
|
||||||
|
/\/\.config\/gh\//,
|
||||||
|
/\/\.config\/hub$/,
|
||||||
|
/\/\.netrc$/,
|
||||||
|
/\/_netrc$/,
|
||||||
|
/\/\.pypirc$/,
|
||||||
|
/\/\.gem\/credentials$/,
|
||||||
|
/\/\.cargo\/credentials(\.toml)?$/,
|
||||||
|
/\/\.terraformrc$/,
|
||||||
|
/\/\.terraform\.d\//,
|
||||||
|
|
||||||
|
// Database client credentials.
|
||||||
|
/\/\.pgpass$/,
|
||||||
|
/\/\.my\.cnf$/,
|
||||||
|
|
||||||
|
// Agent CLI credentials, including Codeman's own hook secret and user table.
|
||||||
|
// Named individually so the surrounding trees stay attachable (see above).
|
||||||
|
/\/\.claude\/\.credentials\.json$/,
|
||||||
|
/\/\.codeman[^/]*\/hook-secret$/,
|
||||||
|
/\/\.codeman[^/]*\/users\.json$/,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview PathPicker "show hidden" toggle (issue #221).
|
||||||
|
*
|
||||||
|
* `PathPicker` (keyboard-accessory.js) is the shared browser behind Link
|
||||||
|
* Existing's "Browse" and the mobile keyboard's `📁 Path` key, so one toggle
|
||||||
|
* serves both. What can silently go wrong here:
|
||||||
|
*
|
||||||
|
* 1. `showHidden` missing from the browse request (toggle looks dead),
|
||||||
|
* 2. `showHidden` missing from the PREVIEW request, which re-resolves the
|
||||||
|
* path independently, so the listing would show a hidden file that then
|
||||||
|
* 403s the moment you tap it,
|
||||||
|
* 3. the toggle resetting you to the root instead of reloading where you are,
|
||||||
|
* 4. the flag not surviving a reopen, or a `localStorage` throw taking the
|
||||||
|
* picker down with it.
|
||||||
|
*
|
||||||
|
* The picker builds its dialog with innerHTML and drives it through real
|
||||||
|
* listeners, so this needs a DOM rather than a `vm` stub. It runs in the DEFAULT
|
||||||
|
* node environment and constructs a jsdom window here, matching
|
||||||
|
* markdown-sanitizer.test.ts: a per-file jsdom environment directive
|
||||||
|
* externalizes node:fs under vite and the suite then fails to load. ⚠️ Do not
|
||||||
|
* write that directive's literal name anywhere in this file, not even in prose
|
||||||
|
* like this: vitest scans the whole source for it, so merely explaining the trap
|
||||||
|
* re-arms it.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||||
|
const accessoryJs = readFileSync(resolve(PUBLIC, 'keyboard-accessory.js'), 'utf8');
|
||||||
|
const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'codeman:pathPickerShowHidden';
|
||||||
|
|
||||||
|
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://localhost/' });
|
||||||
|
const jsdomWindow = dom.window as unknown as Window & typeof globalThis;
|
||||||
|
const jsdomDocument = jsdomWindow.document;
|
||||||
|
|
||||||
|
/** Evaluate keyboard-accessory.js against the jsdom window and return PathPicker. */
|
||||||
|
function loadPathPicker(fetchImpl: (url: string) => Promise<unknown>): any {
|
||||||
|
const MobileDetection = { isTouchDevice: () => false };
|
||||||
|
const factory = new Function(
|
||||||
|
'window',
|
||||||
|
'document',
|
||||||
|
'localStorage',
|
||||||
|
'fetch',
|
||||||
|
'MobileDetection',
|
||||||
|
`${accessoryJs}\nreturn PathPicker;`
|
||||||
|
);
|
||||||
|
return factory(jsdomWindow, jsdomDocument, jsdomWindow.localStorage, fetchImpl, MobileDetection);
|
||||||
|
}
|
||||||
|
|
||||||
|
function browseResponse(entries: Array<{ name: string; type: string }>, path = '/home/dev/project') {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
path,
|
||||||
|
parent: null,
|
||||||
|
root: '/home/dev',
|
||||||
|
roots: [{ label: 'Home', path: '/home/dev' }],
|
||||||
|
entries: entries.map((e) => ({ ...e, path: `${path}/${e.name}` })),
|
||||||
|
truncated: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PathPicker show-hidden toggle', () => {
|
||||||
|
let PathPicker: any;
|
||||||
|
let urls: string[];
|
||||||
|
let respond: (url: string) => unknown;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jsdomWindow.localStorage.clear();
|
||||||
|
jsdomDocument.body.replaceChildren();
|
||||||
|
urls = [];
|
||||||
|
respond = () =>
|
||||||
|
browseResponse([
|
||||||
|
{ name: '.github', type: 'directory' },
|
||||||
|
{ name: 'src', type: 'directory' },
|
||||||
|
]);
|
||||||
|
PathPicker = loadPathPicker(async (url: string) => {
|
||||||
|
urls.push(url);
|
||||||
|
return respond(url);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
PathPicker?.close?.(false);
|
||||||
|
jsdomDocument.body.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
const open = async (options: Record<string, unknown> = {}) => {
|
||||||
|
PathPicker.open({ onSelect: () => {}, ...options });
|
||||||
|
await vi.waitFor(() => expect(urls.length).toBeGreaterThan(0));
|
||||||
|
};
|
||||||
|
const toggle = () => jsdomDocument.querySelector('.path-picker-hidden') as HTMLButtonElement;
|
||||||
|
const previewHref = () =>
|
||||||
|
(jsdomDocument.querySelector('.path-preview-open') as HTMLAnchorElement).getAttribute('href') ?? '';
|
||||||
|
|
||||||
|
it('omits showHidden by default', async () => {
|
||||||
|
await open();
|
||||||
|
|
||||||
|
expect(urls[0]).not.toContain('showHidden');
|
||||||
|
expect(toggle().getAttribute('aria-pressed')).toBe('false');
|
||||||
|
expect(toggle().classList.contains('active')).toBe(false);
|
||||||
|
expect(toggle().getAttribute('title')).toBe('Show hidden files and folders');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends showHidden=true after the toggle is pressed, and persists it', async () => {
|
||||||
|
await open();
|
||||||
|
toggle().click();
|
||||||
|
await vi.waitFor(() => expect(urls.length).toBe(2));
|
||||||
|
|
||||||
|
expect(urls[1]).toContain('showHidden=true');
|
||||||
|
expect(jsdomWindow.localStorage.getItem(STORAGE_KEY)).toBe('1');
|
||||||
|
expect(toggle().getAttribute('aria-pressed')).toBe('true');
|
||||||
|
expect(toggle().classList.contains('active')).toBe(true);
|
||||||
|
expect(toggle().getAttribute('title')).toBe('Hide hidden files and folders');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores the preference when the picker is reopened', async () => {
|
||||||
|
jsdomWindow.localStorage.setItem(STORAGE_KEY, '1');
|
||||||
|
await open();
|
||||||
|
|
||||||
|
expect(urls[0]).toContain('showHidden=true');
|
||||||
|
expect(toggle().getAttribute('aria-pressed')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reloads the current folder rather than resetting to the root', async () => {
|
||||||
|
jsdomWindow.localStorage.setItem(STORAGE_KEY, '1');
|
||||||
|
// Sitting inside a hidden folder, reachable only because the toggle is on.
|
||||||
|
respond = () => browseResponse([{ name: 'workflows', type: 'directory' }], '/home/dev/project/.github');
|
||||||
|
await open({ initialPath: '/home/dev/project/.github' });
|
||||||
|
|
||||||
|
toggle().click();
|
||||||
|
await vi.waitFor(() => expect(urls.length).toBe(2));
|
||||||
|
|
||||||
|
expect(decodeURIComponent(urls[1])).toContain('path=/home/dev/project/.github');
|
||||||
|
expect(urls[1]).not.toContain('showHidden=true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries the flag into the preview request', async () => {
|
||||||
|
jsdomWindow.localStorage.setItem(STORAGE_KEY, '1');
|
||||||
|
await open();
|
||||||
|
|
||||||
|
PathPicker.openPreview({ name: '.gitignore', path: '/home/dev/project/.gitignore', previewKind: 'text' });
|
||||||
|
|
||||||
|
expect(previewHref()).toContain('showHidden=true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the preview flag off when the toggle is off', async () => {
|
||||||
|
await open();
|
||||||
|
|
||||||
|
PathPicker.openPreview({ name: 'notes.txt', path: '/home/dev/project/notes.txt', previewKind: 'text' });
|
||||||
|
|
||||||
|
expect(previewHref()).not.toContain('showHidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives a localStorage that throws (private browsing)', async () => {
|
||||||
|
const storage = Object.getPrototypeOf(jsdomWindow.localStorage);
|
||||||
|
const getItem = vi.spyOn(storage, 'getItem').mockImplementation(() => {
|
||||||
|
throw new Error('denied');
|
||||||
|
});
|
||||||
|
const setItem = vi.spyOn(storage, 'setItem').mockImplementation(() => {
|
||||||
|
throw new Error('denied');
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await open();
|
||||||
|
expect(urls[0]).not.toContain('showHidden');
|
||||||
|
|
||||||
|
toggle().click();
|
||||||
|
await vi.waitFor(() => expect(urls.length).toBe(2));
|
||||||
|
expect(urls[1]).toContain('showHidden=true');
|
||||||
|
} finally {
|
||||||
|
getItem.mockRestore();
|
||||||
|
setItem.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('styles the active toggle so it reads as on', () => {
|
||||||
|
expect(stylesCss).toContain('.path-picker-hidden.active');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -115,21 +115,6 @@ describe('hasWorkingPattern', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('current Claude status line', () => {
|
|
||||||
it('should detect the randomized gerund by the elapsed timer', () => {
|
|
||||||
// Live captures on Claude Code 2.1.220. The word changes every turn, so the
|
|
||||||
// WORKING_PATTERNS list above cannot see any of these.
|
|
||||||
expect(hasWorkingPattern('✻ Actualizing… (15m 17s · ↓ 47.5k tokens)')).toBe(true);
|
|
||||||
expect(hasWorkingPattern('· Finagling… (4m 45s · ↓ 13.3k tokens)')).toBe(true);
|
|
||||||
expect(hasWorkingPattern('✽ Herding… (3s · esc to interrupt)')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should NOT treat the completion line as working', () => {
|
|
||||||
expect(hasWorkingPattern('✻ Cooked for 2m 49s')).toBe(false);
|
|
||||||
expect(hasWorkingPattern('✻ Brewed for 18m 41s')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('spinner characters', () => {
|
describe('spinner characters', () => {
|
||||||
it('should detect braille spinner characters', () => {
|
it('should detect braille spinner characters', () => {
|
||||||
expect(hasWorkingPattern('Loading... \u280B')).toBe(true);
|
expect(hasWorkingPattern('Loading... \u280B')).toBe(true);
|
||||||
|
|||||||
@@ -167,6 +167,118 @@ describe('file-routes', () => {
|
|||||||
expect(res.statusCode).toBe(403);
|
expect(res.statusCode).toBe(403);
|
||||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== showHidden=true (issue #221) =====
|
||||||
|
//
|
||||||
|
// The dotfile filter used to be doing security work by accident: with every
|
||||||
|
// hidden path unreachable, the sensitive-path blocklist never had to cover
|
||||||
|
// `~/.config/gh/hosts.yml` and friends. These pin that opting in lifts the
|
||||||
|
// hidden filter and NOTHING else — blocked trees, sensitive files and root
|
||||||
|
// confinement all still apply.
|
||||||
|
describe('showHidden=true', () => {
|
||||||
|
it('lists dot-prefixed entries', async () => {
|
||||||
|
mockedReaddir.mockResolvedValueOnce([
|
||||||
|
{ name: '.github', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
|
||||||
|
{ name: '.gitignore', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
|
||||||
|
{ name: 'src', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
|
||||||
|
] as never);
|
||||||
|
|
||||||
|
const root = harness.ctx._session.workingDir;
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(root)}&showHidden=true`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(JSON.parse(res.body).data.entries.map((e: { name: string }) => e.name)).toEqual([
|
||||||
|
'.github',
|
||||||
|
'src',
|
||||||
|
'.gitignore',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows navigating into a hidden descendant', async () => {
|
||||||
|
mockedReaddir.mockResolvedValueOnce([
|
||||||
|
{ name: 'workflows', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
|
||||||
|
] as never);
|
||||||
|
|
||||||
|
const hidden = `${harness.ctx._session.workingDir}/.github`;
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(hidden)}&showHidden=true`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(JSON.parse(res.body).data.path).toBe(hidden);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still hides dot-prefixed entries when the flag is absent or false', async () => {
|
||||||
|
const entries = [
|
||||||
|
{ name: '.gitignore', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
|
||||||
|
{ name: 'src', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
|
||||||
|
];
|
||||||
|
const root = harness.ctx._session.workingDir;
|
||||||
|
|
||||||
|
for (const query of ['', '&showHidden=false']) {
|
||||||
|
mockedReaddir.mockResolvedValueOnce(entries as never);
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(root)}${query}`,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(JSON.parse(res.body).data.entries.map((e: { name: string }) => e.name)).toEqual(['src']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a showHidden value that is not a boolean string', async () => {
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&showHidden=yes`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still omits blocked and sensitive entries', async () => {
|
||||||
|
const root = harness.ctx._session.workingDir;
|
||||||
|
mockedReaddir.mockResolvedValueOnce([
|
||||||
|
{ name: '.ssh', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
|
||||||
|
{ name: '.npmrc', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
|
||||||
|
{ name: '.env', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
|
||||||
|
{ name: '.gitignore', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
|
||||||
|
// A plainly-named symlink whose target is a secret: caught on the
|
||||||
|
// resolved path, not the visible name.
|
||||||
|
{ name: 'notes', isDirectory: () => false, isFile: () => false, isSymbolicLink: () => true },
|
||||||
|
] as never);
|
||||||
|
mockedRealpathSync.mockImplementation((p: string) =>
|
||||||
|
p === `${root}/notes` ? (`${root}/.aws/credentials` as never) : (p as never)
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(root)}&showHidden=true`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(JSON.parse(res.body).data.entries.map((e: { name: string }) => e.name)).toEqual(['.gitignore']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a hidden path that resolves outside every root', async () => {
|
||||||
|
const outside = `${harness.ctx._session.workingDir}/.cache`;
|
||||||
|
mockedRealpathSync.mockImplementation((p: string) =>
|
||||||
|
p === outside ? ('/tmp/somewhere-else' as never) : (p as never)
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(outside)}&showHidden=true`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ========== Multi-user scoping for the filesystem picker ==========
|
// ========== Multi-user scoping for the filesystem picker ==========
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The shared sensitive-path blocklist (`src/web/sensitive-path.ts`).
|
||||||
|
*
|
||||||
|
* This list guards every browser-facing file surface: workspace download,
|
||||||
|
* cross-workspace attachment registration, raw/preview serving, and the
|
||||||
|
* filesystem path picker.
|
||||||
|
*
|
||||||
|
* It became load-bearing when the picker gained `showHidden` (issue #221).
|
||||||
|
* Before that, the picker refused any path with a dot-prefixed segment, so most
|
||||||
|
* of the credential locations below were unreachable by construction and the
|
||||||
|
* list only had to cover secrets that sit in plain sight. Opting into hidden
|
||||||
|
* entries removes that accident, which is why each entry is pinned here: a
|
||||||
|
* pattern silently dropped in a refactor would re-expose a real token.
|
||||||
|
*
|
||||||
|
* The list is a BLOCKLIST by design (cross-workspace attachment is a supported
|
||||||
|
* feature), so the "stays attachable" cases matter just as much: over-blocking
|
||||||
|
* breaks the publish skill and the review-card loop.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { isSensitivePath } from '../src/web/sensitive-path.js';
|
||||||
|
|
||||||
|
const HOME = '/home/dev';
|
||||||
|
|
||||||
|
describe('isSensitivePath', () => {
|
||||||
|
describe('blocks', () => {
|
||||||
|
const blocked: Array<[string, string]> = [
|
||||||
|
['system shadow file', '/etc/shadow'],
|
||||||
|
['system gshadow file', '/etc/gshadow'],
|
||||||
|
['BSD master password db', '/etc/master.passwd'],
|
||||||
|
|
||||||
|
['ssh keys in home', `${HOME}/.ssh/id_ed25519`],
|
||||||
|
// Not only under homedir(): a deploy key in a project is the same secret,
|
||||||
|
// and the old homedir()-anchored pattern was captured at module load.
|
||||||
|
['ssh keys anywhere', '/srv/deploy/.ssh/id_rsa'],
|
||||||
|
['gpg keyring', `${HOME}/.gnupg/private-keys-v1.d/key.key`],
|
||||||
|
|
||||||
|
['dotenv', '/srv/app/.env'],
|
||||||
|
['suffixed dotenv', '/srv/app/.env.production'],
|
||||||
|
// Pre-existing and deliberate: `.env.*` is blocked wholesale, so even a
|
||||||
|
// committed `.env.example` is refused rather than risking the one repo
|
||||||
|
// whose "example" holds a live key.
|
||||||
|
['a dotenv example', '/srv/app/.env.example'],
|
||||||
|
|
||||||
|
['generic credentials file', '/srv/app/credentials'],
|
||||||
|
['json credentials', '/srv/app/credentials.json'],
|
||||||
|
['toml credentials', '/srv/app/credentials.toml'],
|
||||||
|
['aws credentials', `${HOME}/.aws/credentials`],
|
||||||
|
['aws config', `${HOME}/.aws/config`],
|
||||||
|
['aws sso cache', `${HOME}/.aws/sso/cache/abc.json`],
|
||||||
|
['legacy gcloud credential db', `${HOME}/.gcloud/credentials.db`],
|
||||||
|
['modern gcloud config tree', `${HOME}/.config/gcloud/application_default_credentials.json`],
|
||||||
|
['azure profile', `${HOME}/.azure/accessTokens.json`],
|
||||||
|
['docker registry auth', `${HOME}/.docker/config.json`],
|
||||||
|
['kubernetes context', `${HOME}/.kube/config`],
|
||||||
|
|
||||||
|
['npm token', `${HOME}/.npmrc`],
|
||||||
|
['yarn token', `${HOME}/.yarnrc.yml`],
|
||||||
|
['git credential store', `${HOME}/.git-credentials`],
|
||||||
|
['gh cli token', `${HOME}/.config/gh/hosts.yml`],
|
||||||
|
['hub token', `${HOME}/.config/hub`],
|
||||||
|
['netrc', `${HOME}/.netrc`],
|
||||||
|
['windows netrc', `${HOME}/_netrc`],
|
||||||
|
['pypi token', `${HOME}/.pypirc`],
|
||||||
|
['rubygems token', `${HOME}/.gem/credentials`],
|
||||||
|
['cargo token', `${HOME}/.cargo/credentials.toml`],
|
||||||
|
['terraform cli config', `${HOME}/.terraformrc`],
|
||||||
|
['terraform credentials dir', `${HOME}/.terraform.d/credentials.tfrc.json`],
|
||||||
|
|
||||||
|
['postgres password file', `${HOME}/.pgpass`],
|
||||||
|
['mysql client config', `${HOME}/.my.cnf`],
|
||||||
|
|
||||||
|
['claude oauth token', `${HOME}/.claude/.credentials.json`],
|
||||||
|
['codeman hook secret', `${HOME}/.codeman/hook-secret`],
|
||||||
|
['codeman user table', `${HOME}/.codeman/users.json`],
|
||||||
|
['codeman hook secret on a named instance', `${HOME}/.codeman-beta/hook-secret`],
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(blocked)('blocks the %s', (_label, path) => {
|
||||||
|
expect(isSensitivePath(path)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('leaves ordinary files attachable', () => {
|
||||||
|
const allowed: Array<[string, string]> = [
|
||||||
|
['a source file', '/srv/app/src/index.ts'],
|
||||||
|
['a dotfile that carries no secret', '/srv/app/.gitignore'],
|
||||||
|
['a hidden CI directory', '/srv/app/.github/workflows/ci.yml'],
|
||||||
|
// The publish skill and the review-card loop attach from these trees, so
|
||||||
|
// only their named secret members are blocked, never the whole tree.
|
||||||
|
['a codeman screenshot', `${HOME}/.codeman/screenshots/shot.png`],
|
||||||
|
['a claude transcript', `${HOME}/.claude/projects/proj/session.jsonl`],
|
||||||
|
['a claude team inbox', `${HOME}/.claude/teams/alpha/inboxes/bob.json`],
|
||||||
|
// isUnderTree-style separator awareness: a sibling name that merely starts
|
||||||
|
// with a blocked segment must not be caught.
|
||||||
|
['an unrelated sshd notes file', '/srv/notes/.sshd-setup.md'],
|
||||||
|
['a file named credentials-policy.md', '/srv/app/credentials-policy.md'],
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(allowed)('allows %s', (_label, path) => {
|
||||||
|
expect(isSensitivePath(path)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches on the resolved path, so callers must realpath first', () => {
|
||||||
|
// The function itself is pure string matching; this pins the contract its
|
||||||
|
// docblock states, which every caller depends on.
|
||||||
|
expect(isSensitivePath('/srv/app/looks-innocent')).toBe(false);
|
||||||
|
expect(isSensitivePath(`${HOME}/.ssh/looks-innocent`)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
/**
|
|
||||||
* Working/idle detection for an interactive Claude pane.
|
|
||||||
*
|
|
||||||
* The bug this pins: Claude redraws the composer (`❯`) about once a second all
|
|
||||||
* the way through a turn, so the old "saw a ❯, wait 2s, call it idle" rule
|
|
||||||
* flipped a busy session to idle two seconds into every turn. Measured on a live
|
|
||||||
* worker: `GET /api/sessions` reported `idle` for a session that had been
|
|
||||||
* running for 17 minutes and was mid-tool-call.
|
|
||||||
*
|
|
||||||
* The status-line fixtures below are verbatim captures from live panes
|
|
||||||
* (`tmux -L codeman capture-pane -p`) on Claude Code 2.1.220.
|
|
||||||
*/
|
|
||||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
|
||||||
import { Session } from '../src/session.js';
|
|
||||||
import { CLAUDE_WORKING_LINE_PATTERN } from '../src/utils/regex-patterns.js';
|
|
||||||
import {
|
|
||||||
trackActivityStreak,
|
|
||||||
isSustainedActivity,
|
|
||||||
isPaneQuiet,
|
|
||||||
ACTIVITY_GAP_MS,
|
|
||||||
WORKING_STREAK_MS,
|
|
||||||
IDLE_SILENCE_MS,
|
|
||||||
} from '../src/session-activity.js';
|
|
||||||
|
|
||||||
type SessionInternals = {
|
|
||||||
_handleTerminalOutput(data: string): void;
|
|
||||||
_detectInteractiveActivity(data: string): void;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** One PTY chunk: what the pane emitted, exactly as the interactive handler sees it. */
|
|
||||||
function feed(session: Session, data: string): void {
|
|
||||||
const internals = session as unknown as SessionInternals;
|
|
||||||
internals._handleTerminalOutput(data);
|
|
||||||
internals._detectInteractiveActivity(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A session whose mux reports a fixed (or scripted) screen, so the pane probe has
|
|
||||||
* something to read. Only `capturePaneText` is exercised by these paths.
|
|
||||||
*/
|
|
||||||
function withFakePane(screen: string | (() => string)): Session {
|
|
||||||
const read = typeof screen === 'function' ? screen : () => screen;
|
|
||||||
const mux = {
|
|
||||||
isAvailable: () => true,
|
|
||||||
capturePaneText: () => read(),
|
|
||||||
} as unknown as NonNullable<Parameters<typeof Session.prototype.constructor>[0]>['mux'];
|
|
||||||
return new Session({
|
|
||||||
workingDir: '/tmp',
|
|
||||||
mode: 'claude',
|
|
||||||
mux,
|
|
||||||
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
|
|
||||||
} as ConstructorParameters<typeof Session>[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A composer repaint: the frame Claude ships roughly once a second while working. */
|
|
||||||
const COMPOSER_REPAINT =
|
|
||||||
'\x1b[31;1H\x1b[38;5;246m❯\xa0\x1b[39m\x1b[0m\x1b[33;1H \x1b[38;5;246mOpus 5 in:143,699 out:669 ctx:14%\x1b[39m';
|
|
||||||
|
|
||||||
describe('CLAUDE_WORKING_LINE_PATTERN', () => {
|
|
||||||
it('matches the live status line, whatever the glyph and gerund are', () => {
|
|
||||||
// Captured from three different live panes: the glyph animates through
|
|
||||||
// `· ✢ ✳ ∗ ✻ ✽` and the gerund is randomized per turn, so neither is matchable.
|
|
||||||
expect(CLAUDE_WORKING_LINE_PATTERN.test('✻ Actualizing… (15m 17s · ↓ 47.5k tokens)')).toBe(true);
|
|
||||||
expect(CLAUDE_WORKING_LINE_PATTERN.test('* Implementing the backend… (18m 59s · ↓ 69.9k tokens)')).toBe(true);
|
|
||||||
expect(CLAUDE_WORKING_LINE_PATTERN.test('· Finagling… (4m 45s · ↓ 13.3k tokens)')).toBe(true);
|
|
||||||
expect(CLAUDE_WORKING_LINE_PATTERN.test('✽ Herding… (3s · esc to interrupt)')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not match the FINISHED line, which carries the same glyph', () => {
|
|
||||||
// `✻ Cooked for 2m 49s` sits on screen for the whole idle period afterwards.
|
|
||||||
// Matching the glyph alone would pin such a session at "working" forever.
|
|
||||||
expect(CLAUDE_WORKING_LINE_PATTERN.test('✻ Cooked for 2m 49s')).toBe(false);
|
|
||||||
expect(CLAUDE_WORKING_LINE_PATTERN.test('✻ Brewed for 18m 41s')).toBe(false);
|
|
||||||
expect(CLAUDE_WORKING_LINE_PATTERN.test('✻ Worked for 2m 46s')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores ordinary prose and the idle footer', () => {
|
|
||||||
expect(CLAUDE_WORKING_LINE_PATTERN.test(COMPOSER_REPAINT)).toBe(false);
|
|
||||||
expect(CLAUDE_WORKING_LINE_PATTERN.test(' ⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents')).toBe(
|
|
||||||
false
|
|
||||||
);
|
|
||||||
expect(CLAUDE_WORKING_LINE_PATTERN.test('the build took 45s to finish')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('activity streak helpers', () => {
|
|
||||||
it('extends a streak while chunks keep arriving', () => {
|
|
||||||
let streak = trackActivityStreak(null, 1000);
|
|
||||||
streak = trackActivityStreak(streak, 2000);
|
|
||||||
streak = trackActivityStreak(streak, 3000);
|
|
||||||
expect(streak).toEqual({ startedAt: 1000, lastAt: 3000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('restarts the streak after a gap', () => {
|
|
||||||
const first = trackActivityStreak(null, 1000);
|
|
||||||
const after = trackActivityStreak(first, 1000 + ACTIVITY_GAP_MS + 1);
|
|
||||||
expect(after.startedAt).toBe(1000 + ACTIVITY_GAP_MS + 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls it working only once the streak spans the threshold', () => {
|
|
||||||
expect(isSustainedActivity(null)).toBe(false);
|
|
||||||
expect(isSustainedActivity({ startedAt: 0, lastAt: WORKING_STREAK_MS - 1 })).toBe(false);
|
|
||||||
expect(isSustainedActivity({ startedAt: 0, lastAt: WORKING_STREAK_MS })).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('measures the streak on its own span, so a stale streak cannot age into working', () => {
|
|
||||||
// A single old chunk stays a single chunk no matter how much later we ask.
|
|
||||||
const oneChunk = { startedAt: 0, lastAt: 0 };
|
|
||||||
expect(isSustainedActivity(oneChunk)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls the pane quiet only after the silence window', () => {
|
|
||||||
expect(isPaneQuiet(1000, 1000 + IDLE_SILENCE_MS - 1)).toBe(false);
|
|
||||||
expect(isPaneQuiet(1000, 1000 + IDLE_SILENCE_MS)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Session interactive idle detection', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stays busy through a long turn of composer repaints', () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
|
||||||
const events: string[] = [];
|
|
||||||
session.on('idle', () => events.push('idle'));
|
|
||||||
session.on('working', () => events.push('working'));
|
|
||||||
|
|
||||||
// 30 seconds of the once-a-second repaint a working pane emits. Every one of
|
|
||||||
// these carries a ❯; the old rule went idle after the first two seconds.
|
|
||||||
for (let i = 0; i < 30; i++) {
|
|
||||||
feed(session, COMPOSER_REPAINT);
|
|
||||||
vi.advanceTimersByTime(1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(events).toEqual(['working']);
|
|
||||||
expect(session.status).toBe('busy');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('goes idle once the pane falls silent', () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
|
||||||
const events: string[] = [];
|
|
||||||
session.on('idle', () => events.push('idle'));
|
|
||||||
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
feed(session, COMPOSER_REPAINT);
|
|
||||||
vi.advanceTimersByTime(1000);
|
|
||||||
}
|
|
||||||
expect(events).toEqual([]);
|
|
||||||
|
|
||||||
// Turn over: nothing more is emitted.
|
|
||||||
vi.advanceTimersByTime(IDLE_SILENCE_MS + 1000);
|
|
||||||
|
|
||||||
expect(events).toEqual(['idle']);
|
|
||||||
expect(session.status).toBe('idle');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('emits idle once, not once per re-check', () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
|
||||||
const events: string[] = [];
|
|
||||||
session.on('idle', () => events.push('idle'));
|
|
||||||
|
|
||||||
for (let i = 0; i < 4; i++) {
|
|
||||||
feed(session, COMPOSER_REPAINT);
|
|
||||||
vi.advanceTimersByTime(1000);
|
|
||||||
}
|
|
||||||
vi.advanceTimersByTime(60_000);
|
|
||||||
|
|
||||||
expect(events).toEqual(['idle']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses to go idle while the screen still shows the working line', () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
// A turn can go completely silent inside one tool call (measured at 20+
|
|
||||||
// seconds on a live worker) while `✻ Elucidating… (39s · ↓ 2.0k tokens)`
|
|
||||||
// sits on screen the whole time. Silence alone must not end the turn.
|
|
||||||
const session = withFakePane('✻ Elucidating… (39s · ↓ 2.0k tokens)\n❯ \n');
|
|
||||||
const events: string[] = [];
|
|
||||||
session.on('idle', () => events.push('idle'));
|
|
||||||
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
feed(session, COMPOSER_REPAINT);
|
|
||||||
vi.advanceTimersByTime(1000);
|
|
||||||
}
|
|
||||||
vi.advanceTimersByTime(60_000); // silent for a minute
|
|
||||||
|
|
||||||
expect(events).toEqual([]);
|
|
||||||
expect(session.status).toBe('busy');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('goes idle once the working line leaves the screen', () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const pane = { text: '✻ Elucidating… (39s · ↓ 2.0k tokens)\n❯ \n' };
|
|
||||||
const session = withFakePane(() => pane.text);
|
|
||||||
const events: string[] = [];
|
|
||||||
session.on('idle', () => events.push('idle'));
|
|
||||||
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
feed(session, COMPOSER_REPAINT);
|
|
||||||
vi.advanceTimersByTime(1000);
|
|
||||||
}
|
|
||||||
vi.advanceTimersByTime(20_000);
|
|
||||||
expect(events).toEqual([]);
|
|
||||||
|
|
||||||
// Turn over: the same glyph remains, on the FINISHED line this time.
|
|
||||||
pane.text = '✻ Cooked for 2m 49s\n❯ \n';
|
|
||||||
vi.advanceTimersByTime(20_000);
|
|
||||||
|
|
||||||
expect(events).toEqual(['idle']);
|
|
||||||
expect(session.status).toBe('idle');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not call typing into the composer "working"', () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
// Keystroke echo is a steady stream of repaints too, so the streak alone
|
|
||||||
// would call it work. The screen has no working line, which vetoes it.
|
|
||||||
const session = withFakePane('❯ some prompt being typed\n');
|
|
||||||
const events: string[] = [];
|
|
||||||
session.on('working', () => events.push('working'));
|
|
||||||
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
feed(session, '\x1b[31;3Hx');
|
|
||||||
vi.advanceTimersByTime(300);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(events).toEqual([]);
|
|
||||||
expect(session.status).toBe('idle');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not mark an external CLI pane working off raw activity', () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
// Codex/Gemini/OpenCode render their own TUIs and have no ❯, so nothing would
|
|
||||||
// arm the idle confirmation, so a session marked working here would never recover.
|
|
||||||
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
|
|
||||||
const events: string[] = [];
|
|
||||||
session.on('working', () => events.push('working'));
|
|
||||||
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
feed(session, '\x1b[2K▌ Working (12s)');
|
|
||||||
vi.advanceTimersByTime(1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(events).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
/**
|
|
||||||
* Workspace-trust dialog auto-accept.
|
|
||||||
*
|
|
||||||
* The bug this pins: `data.includes('trust this folder')` could never match,
|
|
||||||
* because tmux repaints a row with cursor-forward escapes instead of spaces, so
|
|
||||||
* the wire carries `I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder`. Every session on a fresh
|
|
||||||
* directory sat on the dialog until a human pressed Enter.
|
|
||||||
*
|
|
||||||
* RAW_DIALOG_CHUNK below is a verbatim slice of the PTY stream from a live
|
|
||||||
* session parked on that dialog (Claude Code 2.1.220).
|
|
||||||
*/
|
|
||||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
|
||||||
import { Session } from '../src/session.js';
|
|
||||||
import { isTrustDialogScreen, compactScreenText, TRUST_DIALOG_MAX_ATTEMPTS } from '../src/session-trust-dialog.js';
|
|
||||||
|
|
||||||
/** Verbatim from the wire: note the `\x1b[C` where every space should be. */
|
|
||||||
const RAW_DIALOG_CHUNK =
|
|
||||||
'\x1b[C\x1b[38;5;246m1.\x1b[C\x1b[38;5;153mYes,\x1b[CI\x1b[Ctrust\x1b[Cthis\x1b[Cfolder\x1b[15;4H' +
|
|
||||||
'\x1b[38;5;246m2.\x1b[C\x1b[39mNo,\x1b[Cexit\x1b[17;2H\x1b[38;5;246mEnter\x1b[Cto\x1b[Cconfirm\x1b[C·\x1b[CEsc\x1b[Cto\x1b[Ccancel';
|
|
||||||
|
|
||||||
/** What `tmux capture-pane -p` shows for the same moment. */
|
|
||||||
const RENDERED_DIALOG = [
|
|
||||||
' Quick safety check: Is this a project you created or one you trust? (Like your own code, a well-known open source',
|
|
||||||
' project, or work from your team). If not, take a moment to review what is in this folder first.',
|
|
||||||
'',
|
|
||||||
' ❯ 1. Yes, I trust this folder',
|
|
||||||
' 2. No, exit',
|
|
||||||
'',
|
|
||||||
' Enter to confirm · Esc to cancel',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
/** An ordinary working session: no dialog anywhere. */
|
|
||||||
const RENDERED_MAIN_UI = [
|
|
||||||
'✻ Actualizing… (13m 23s · ↓ 47.5k tokens)',
|
|
||||||
'────────────────────────────────',
|
|
||||||
'❯ ',
|
|
||||||
' ⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
describe('isTrustDialogScreen', () => {
|
|
||||||
it('sees the dialog in the raw space-less repaint', () => {
|
|
||||||
// The whole point: the literal phrase is NOT in this chunk.
|
|
||||||
expect(RAW_DIALOG_CHUNK.includes('trust this folder')).toBe(false);
|
|
||||||
expect(isTrustDialogScreen(RAW_DIALOG_CHUNK)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sees the dialog in the rendered screen', () => {
|
|
||||||
expect(isTrustDialogScreen(RENDERED_DIALOG)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not fire on a normal session screen', () => {
|
|
||||||
expect(isTrustDialogScreen(RENDERED_MAIN_UI)).toBe(false);
|
|
||||||
expect(isTrustDialogScreen('')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not fire on text that merely quotes the dialog', () => {
|
|
||||||
// An agent reading or writing about this feature (this file, for one) must
|
|
||||||
// not cause an Enter press. The confirm affordance is what separates the
|
|
||||||
// widget from prose about it.
|
|
||||||
expect(isTrustDialogScreen('the installer asks you to trust this folder before it runs')).toBe(false);
|
|
||||||
expect(isTrustDialogScreen('press Enter to confirm the release')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('compacts away both real spaces and the escapes tmux sends instead', () => {
|
|
||||||
expect(compactScreenText('I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder')).toBe('itrustthisfolder');
|
|
||||||
expect(compactScreenText('I trust this folder')).toBe('itrustthisfolder');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Session trust-dialog auto-accept', () => {
|
|
||||||
afterEach(() => vi.useRealTimers());
|
|
||||||
|
|
||||||
/** A session whose pane renders `screen`, recording everything written to it. */
|
|
||||||
function sessionShowing(screen: () => string) {
|
|
||||||
const writes: string[] = [];
|
|
||||||
const mux = {
|
|
||||||
isAvailable: () => true,
|
|
||||||
capturePaneText: () => screen(),
|
|
||||||
sendInput: (_id: string, data: string) => {
|
|
||||||
writes.push(data);
|
|
||||||
return Promise.resolve(true);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const session = new Session({
|
|
||||||
workingDir: '/tmp',
|
|
||||||
mode: 'claude',
|
|
||||||
mux,
|
|
||||||
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
|
|
||||||
} as ConstructorParameters<typeof Session>[0]);
|
|
||||||
const internals = session as unknown as {
|
|
||||||
_maybeAcceptTrustDialog(): void;
|
|
||||||
_interactiveStartedAt: number;
|
|
||||||
};
|
|
||||||
internals._interactiveStartedAt = Date.now();
|
|
||||||
return { session, writes, tick: () => internals._maybeAcceptTrustDialog() };
|
|
||||||
}
|
|
||||||
|
|
||||||
it('presses Enter when the dialog is on screen', () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const { writes, tick } = sessionShowing(() => RENDERED_DIALOG);
|
|
||||||
tick();
|
|
||||||
expect(writes).toEqual(['\r']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('retries a dropped keystroke, then gives up rather than typing forever', () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
// Ink can drop a keystroke while it is still mounting the widget, so one
|
|
||||||
// press is not always enough; a stuck dialog must not become an Enter loop.
|
|
||||||
const { writes, tick } = sessionShowing(() => RENDERED_DIALOG);
|
|
||||||
for (let i = 0; i < 20; i++) {
|
|
||||||
tick();
|
|
||||||
vi.advanceTimersByTime(2000);
|
|
||||||
}
|
|
||||||
expect(writes.length).toBe(TRUST_DIALOG_MAX_ATTEMPTS);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stops once the dialog is answered', () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
let screen = RENDERED_DIALOG;
|
|
||||||
const { writes, tick } = sessionShowing(() => screen);
|
|
||||||
tick();
|
|
||||||
expect(writes).toEqual(['\r']);
|
|
||||||
|
|
||||||
screen = RENDERED_MAIN_UI;
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
vi.advanceTimersByTime(2000);
|
|
||||||
tick();
|
|
||||||
}
|
|
||||||
expect(writes).toEqual(['\r']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('never answers a dialog-looking screen outside the startup window', () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
// A live agent can print this text hours in; only a launching pane can be
|
|
||||||
// showing the real widget.
|
|
||||||
const { writes, tick } = sessionShowing(() => RENDERED_DIALOG);
|
|
||||||
vi.advanceTimersByTime(10 * 60_000);
|
|
||||||
tick();
|
|
||||||
expect(writes).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not press Enter on a normal screen', () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const { writes, tick } = sessionShowing(() => RENDERED_MAIN_UI);
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
tick();
|
|
||||||
vi.advanceTimersByTime(2000);
|
|
||||||
}
|
|
||||||
expect(writes).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user