diff --git a/README.md b/README.md index 3af046fa..e0cc9887 100644 --- a/README.md +++ b/README.md @@ -80,18 +80,20 @@ For link discovery, browser-only use, Android/iOS installation, notifications, a All listed agents are discovered automatically, accept direct input, and can be launched from ChatMux. Indexed histories render as conversations; live CLI output is always available. -| Agent | Chat view | -|---|---| -| **Claude Code** | After history is indexed | -| **Codex CLI** | After history is indexed | -| **Cursor CLI** | After history is indexed | -| **OpenCode** | After history is indexed | -| **Oh My OpenAgent** (`omo`) | After history is indexed | -| **Oh My Pi** | After history is indexed | -| **Gajae Code (GJC)** | Native | +| Agent | Chat view | Full access at launch | +|---|---|---| +| **Claude Code** | After history is indexed | Yes | +| **Codex CLI** | After history is indexed | Yes | +| **Cursor CLI** | After history is indexed | No | +| **OpenCode** | After history is indexed | Yes | +| **Oh My OpenAgent** (`omo`) | After history is indexed | Yes | +| **Oh My Pi** | After history is indexed | Yes | +| **Gajae Code (GJC)** | Native | Not applicable | SSH tmux and local shells are also supported as terminal-only connections. +**Full access** is an explicit, per-session option for newly launched local sessions. It is not saved and does not affect existing or remote sessions. Depending on the CLI, it selects that provider's verified auto-approval or full-access mode, so tools, shell commands, and file changes may proceed without another confirmation. GJC keeps its native default-allow policy and has no separate launch switch; Cursor has no verified interactive full-access flag. + Cursor sessions use the documented agent command; the legacy cursor-agent alias remains supported for older installations. ## Remote access diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index aa8968c9..91d3ad96 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -78,18 +78,20 @@ Linux x86_64 (glibc 2.35+)、tmux、ユーザーレベルの systemd、`curl`/`t 以下のエージェントはすべて自動検出され、直接入力と ChatMux からの新規セッション開始に対応します。インデックス済みの履歴は会話として表示され、ライブ CLI 出力はいつでも確認できます。 -| エージェント | チャットビュー | -|---|---| -| **Claude Code** | 履歴のインデックス後 | -| **Codex CLI** | 履歴のインデックス後 | -| **Cursor CLI** | 履歴のインデックス後 | -| **OpenCode** | 履歴のインデックス後 | -| **Oh My OpenAgent** (`omo`) | 履歴のインデックス後 | -| **Oh My Pi** | 履歴のインデックス後 | -| **Gajae Code (GJC)** | ネイティブ対応 | +| エージェント | チャットビュー | 起動時のフルアクセス | +|---|---|---| +| **Claude Code** | 履歴のインデックス後 | 対応 | +| **Codex CLI** | 履歴のインデックス後 | 対応 | +| **Cursor CLI** | 履歴のインデックス後 | 非対応 | +| **OpenCode** | 履歴のインデックス後 | 対応 | +| **Oh My OpenAgent** (`omo`) | 履歴のインデックス後 | 対応 | +| **Oh My Pi** | 履歴のインデックス後 | 対応 | +| **Gajae Code (GJC)** | ネイティブ対応 | 該当なし | SSH tmux とローカルシェルも、ターミナル専用接続として利用できます。 +**フルアクセス**は、新しく起動するローカルセッションごとに明示的に選択するオプションです。設定は保存されず、既存またはリモートのセッションには影響しません。CLI に応じて、そのプロバイダーで検証済みの自動承認またはフルアクセスモードを選択するため、ツール、シェルコマンド、ファイル変更が追加確認なしで実行される場合があります。GJC は独自のデフォルト許可ポリシーを使用し、個別の起動スイッチはありません。Cursor には検証済みの対話型フルアクセスフラグがありません。 + Cursor セッションはドキュメントに記載された agent コマンドを使用します。古いインストール向けにレガシーの cursor-agent エイリアスも引き続きサポートされます。 ## リモートアクセス diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 671e266d..bc087248 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -80,18 +80,20 @@ Linux x86_64(glibc 2.35+)와 tmux, 사용자 레벨 systemd, `curl`/`tar`/`sha25 아래 에이전트는 모두 자동으로 발견되며 직접 입력과 ChatMux에서의 새 세션 시작을 지원합니다. 인덱싱된 히스토리는 대화로 표시되고, 실시간 CLI 출력은 언제든 볼 수 있습니다. -| 에이전트 | 채팅 뷰 | -|---|---| -| **Claude Code** | 히스토리 인덱싱 후 | -| **Codex CLI** | 히스토리 인덱싱 후 | -| **Cursor CLI** | 히스토리 인덱싱 후 | -| **OpenCode** | 히스토리 인덱싱 후 | -| **Oh My OpenAgent** (`omo`) | 히스토리 인덱싱 후 | -| **Oh My Pi** | 히스토리 인덱싱 후 | -| **Gajae Code (GJC)** | 기본 지원 | +| 에이전트 | 채팅 뷰 | 시작 시 모두 허용 | +|---|---|---| +| **Claude Code** | 히스토리 인덱싱 후 | 지원 | +| **Codex CLI** | 히스토리 인덱싱 후 | 지원 | +| **Cursor CLI** | 히스토리 인덱싱 후 | 미지원 | +| **OpenCode** | 히스토리 인덱싱 후 | 지원 | +| **Oh My OpenAgent** (`omo`) | 히스토리 인덱싱 후 | 지원 | +| **Oh My Pi** | 히스토리 인덱싱 후 | 지원 | +| **Gajae Code (GJC)** | 기본 지원 | 해당 없음 | SSH tmux와 로컬 셸도 터미널 전용 연결로 지원합니다. +**모두 허용**은 새로 시작하는 로컬 세션마다 명시적으로 선택하는 옵션입니다. 선택은 저장되지 않으며 기존 세션이나 원격 세션에는 영향을 주지 않습니다. CLI에 따라 해당 프로바이더에서 검증된 자동 승인 또는 전체 권한 모드를 사용하므로, 도구 실행과 셸 명령, 파일 변경이 추가 확인 없이 진행될 수 있습니다. GJC는 자체 기본 허용 정책을 사용하고 별도 시작 옵션이 없으며, Cursor는 검증된 대화형 전체 권한 옵션이 없습니다. + Cursor 세션은 문서화된 agent 명령을 사용합니다. 구형 설치를 위한 레거시 cursor-agent 별칭도 계속 지원됩니다. ## 원격 접속 diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index f1140346..92dc1413 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -78,18 +78,20 @@ curl -fsSL https://github.com/devswha/chatmux/releases/latest/download/install.s 以下代理均会被自动发现,支持直接输入,也可从 ChatMux 启动新会话。已索引的历史记录会显示为对话,实时 CLI 输出则始终可用。 -| 代理 | 聊天视图 | -|---|---| -| **Claude Code** | 历史索引后 | -| **Codex CLI** | 历史索引后 | -| **Cursor CLI** | 历史索引后 | -| **OpenCode** | 历史索引后 | -| **Oh My OpenAgent** (`omo`) | 历史索引后 | -| **Oh My Pi** | 历史索引后 | -| **Gajae Code (GJC)** | 原生支持 | +| 代理 | 聊天视图 | 启动时完全访问 | +|---|---|---| +| **Claude Code** | 历史索引后 | 支持 | +| **Codex CLI** | 历史索引后 | 支持 | +| **Cursor CLI** | 历史索引后 | 不支持 | +| **OpenCode** | 历史索引后 | 支持 | +| **Oh My OpenAgent** (`omo`) | 历史索引后 | 支持 | +| **Oh My Pi** | 历史索引后 | 支持 | +| **Gajae Code (GJC)** | 原生支持 | 不适用 | SSH tmux 和本地 shell 也可作为纯终端连接使用。 +**完全访问**是为每个新启动的本地会话明确选择的选项。该选项不会保存,也不会影响现有会话或远程会话。根据 CLI,它会选择该提供商已验证的自动批准或完全访问模式,因此工具、Shell 命令和文件更改可能无需再次确认即可执行。GJC 使用其原生的默认允许策略,没有单独的启动开关;Cursor 没有经过验证的交互式完全访问参数。 + Cursor 会话使用文档中的 agent 命令;为旧安装保留了传统的 cursor-agent 别名。 ## 远程访问 diff --git a/server/modules/providers/README.md b/server/modules/providers/README.md index 5fe7ce81..65726648 100644 --- a/server/modules/providers/README.md +++ b/server/modules/providers/README.md @@ -45,6 +45,35 @@ Here, `omo` is the CLI/provider id for **Oh My OpenAgent**. Those ids are mirrored in backend unions and frontend provider constants. If adding a new provider, update every place that hardcodes this list. +## External Session Startup Capabilities + +The new-session form can opt a newly launched local native CLI into that +provider's verified interactive auto-approval or full-access mode. This is a +per-request boolean: it is off by default, is not persisted, and is not sent to +remote fleet spawns or applied to existing sessions. + +`shared/external-cli-spawn.ts` is the shared capability list. The client uses it +to enable the checkbox, but `provider.routes.ts` remains authoritative: it +validates the boolean, rejects unsupported providers, and passes only fixed +server-owned arguments to the tmux launcher. Never accept arbitrary launch +arguments from the browser. + +| Provider | Full-access startup | Native interactive arguments | +| --- | --- | --- | +| Claude | Supported | `--dangerously-skip-permissions` | +| Codex | Supported | `--dangerously-bypass-approvals-and-sandbox` | +| Cursor | Unsupported | No verified interactive full-access flag | +| OpenCode | Supported | `--auto` | +| GJC | Not applicable | Guarded tools use the native default-allow policy; there is no per-launch switch | +| Oh My Pi (`omp`) | Supported | `--approval-mode=yolo` | +| Oh My OpenAgent (`omo`) | Supported | `--approve --permission-preset full-access` | + +These provider modes are not semantically identical. Document them as +auto-approval or full-access modes rather than claiming that every provider +disables the same sandbox boundary. Keep the UI warning visible when the option +is selected: tools, shell commands, and file changes may proceed without another +approval in that session. + ## Current File Layout Each provider lives under its own folder in `server/modules/providers/list/`: diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts index 5c069c2f..f6a6cb93 100644 --- a/server/modules/providers/provider.routes.ts +++ b/server/modules/providers/provider.routes.ts @@ -79,6 +79,8 @@ import type { import { resolveWorkspaceDirectoryWithinRoot } from '@/shared/workspace-paths.js'; import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js'; +import { EXTERNAL_SPAWN_CLIS, supportsExternalCliFullAccess } from '../../../shared/external-cli-spawn.js'; + import { attachCapabilityService } from './services/attach-capability.service.js'; @@ -979,14 +981,14 @@ router.post( router.post( '/sessions/external/spawn', asyncHandler(async (req: Request, res: Response) => { - const body = (req.body ?? {}) as { name?: unknown; cwd?: unknown; cli?: unknown }; + const body = (req.body ?? {}) as { name?: unknown; cwd?: unknown; cli?: unknown; fullAccess?: unknown }; if (!isValidSpawnName(body.name)) { throw new AppError('A valid session name is required (alphanumeric, not "company").', { code: 'INVALID_SPAWN_NAME', statusCode: 400, }); } - const supportedClis: ExternalSpawnCli[] = ['claude', 'codex', 'cursor', 'opencode', 'omp', 'omo']; + const supportedClis: readonly ExternalSpawnCli[] = EXTERNAL_SPAWN_CLIS; if (body.cli !== undefined && !supportedClis.includes(body.cli as ExternalSpawnCli)) { throw new AppError(`cli must be one of: ${supportedClis.join(', ')}.`, { code: 'INVALID_CLI', @@ -996,6 +998,19 @@ router.post( const cli: ExternalSpawnCli = body.cli === undefined ? 'codex' : body.cli as ExternalSpawnCli; + if (body.fullAccess !== undefined && typeof body.fullAccess !== 'boolean') { + throw new AppError('fullAccess must be a boolean.', { + code: 'INVALID_FULL_ACCESS', + statusCode: 400, + }); + } + const fullAccess = body.fullAccess === true; + if (fullAccess && !supportsExternalCliFullAccess(cli)) { + throw new AppError(`${cli} does not support full-access startup.`, { + code: 'UNSUPPORTED_FULL_ACCESS', + statusCode: 400, + }); + } const cwdInput = typeof body.cwd === 'string' ? body.cwd.trim() : ''; if (!cwdInput) { throw new AppError('cwd is required.', { code: 'EMPTY_CWD', statusCode: 400 }); @@ -1008,7 +1023,7 @@ router.post( }); } try { - await spawnExternalCliSession(cli, body.name, cwd); + await spawnExternalCliSession(cli, body.name, cwd, { fullAccess }); } catch { throw new AppError('The external CLI session could not be created; the tmux name may already exist.', { code: 'EXTERNAL_CLI_SPAWN_FAILED', diff --git a/server/modules/providers/services/external-cli-sessions.service.ts b/server/modules/providers/services/external-cli-sessions.service.ts index d9b1a01c..850682cd 100644 --- a/server/modules/providers/services/external-cli-sessions.service.ts +++ b/server/modules/providers/services/external-cli-sessions.service.ts @@ -4,7 +4,7 @@ export { selectMostRecentCodexThreadId, selectObservedCodexThread, isCodexMainTh export { parseClaudeRuntimeSession, isCodexRuntimeProcess, isClaudeRuntimeProcess, classifyExternalSessions, selectPrimaryCodexProcessPid } from './external-cli-sessions/process-classification.js'; export type { ExternalCliSessionCommandRunner } from './external-cli-sessions/process-classification.js'; export { extractContainedTranscriptSessionId, applyInferredProviderSessionIds, claudeReceiptPaneTag, isRunningProcess, selectClaudePaneReceipt, selectParkedClaudeReceipt } from './external-cli-sessions/provider-runtime-inference.js'; -export { normalizeExternalPaneOutput, resolveExternalCliCwd, withoutNodeModulesBins, buildExternalCliRuntimePath, resolveExternalCliExecutable, buildExternalCliTmuxSpawnArgs, spawnExternalCliSession } from './external-cli-sessions/inference-and-spawn.js'; +export { normalizeExternalPaneOutput, resolveExternalCliCwd, withoutNodeModulesBins, buildExternalCliRuntimePath, resolveExternalCliExecutable, buildExternalCliTmuxSpawnArgs, externalCliFullAccessArgs, spawnExternalCliSession } from './external-cli-sessions/inference-and-spawn.js'; export type { ExternalSpawnCli, CurrentTmuxPaneIdentity } from './external-cli-sessions/inference-and-spawn.js'; export { getCurrentTmuxPaneIdentityState, getCurrentTmuxPaneIdentity, resolveCodexRolloutPath, createExternalCliSessionDiscovery, getExternalCliSessionsDetailed } from './external-cli-sessions/discovery.js'; export type { ExternalCliSessionDiscovery, ExternalCliSessionDiscoveryOptions } from './external-cli-sessions/discovery.js'; diff --git a/server/modules/providers/services/external-cli-sessions/codex-fork-inference.ts b/server/modules/providers/services/external-cli-sessions/codex-fork-inference.ts index 425fd8ac..adf965a7 100644 --- a/server/modules/providers/services/external-cli-sessions/codex-fork-inference.ts +++ b/server/modules/providers/services/external-cli-sessions/codex-fork-inference.ts @@ -18,6 +18,7 @@ const MAX_TAIL_BYTES = 1024 * 1024; const MAX_LOG_ROWS = 256; const ANCHOR_LENGTH = 96; const MIN_ANCHOR_LENGTH = 64; +export const CODEX_ANCHORLESS_BINDING_RECHECK_MS = 60_000; /** Text is evidence for display only, never a native session-binding receipt. */ export function normalizeCodexDisplayText(text: string): string { @@ -55,8 +56,7 @@ export function selectCodexForkByDisplay(args: { }): string | null { if (args.anchor.length < MIN_ANCHOR_LENGTH || args.anchor.length > ANCHOR_LENGTH) return null; // /new or /resume can leave old cells in scrollback above a new welcome card. - const headers = [...args.paneOutput.matchAll(/OpenAI Codex \(v[^\n]*\)/g)]; - const output = args.paneOutput.slice(headers.at(-1)?.index ?? 0); + const output = currentCodexPaneOutput(args.paneOutput); if (!normalizeCodexDisplayText(output).includes(args.anchor)) return null; const matches = new Set(args.candidates.filter((candidate) => ( candidate.messages.some((message) => normalizeCodexDisplayText(message).includes(args.anchor)) @@ -65,12 +65,71 @@ export function selectCodexForkByDisplay(args: { } type ForkTranscript = { id: string; parentId?: string; messages: string[] }; +type InitialDisplayCandidate = ForkTranscript & { cwd: string }; + +function currentCodexPaneOutput(paneOutput: string): string { + const headers = [...paneOutput.matchAll(/OpenAI Codex \(v[^\n]*\)/g)]; + return paneOutput.slice(headers.at(-1)?.index ?? 0); +} + +function transcriptMessageAnchor(message: string): string | null { + const normalized = normalizeCodexDisplayText(message); + const anchor = normalized.slice(-ANCHOR_LENGTH); + return anchor.length >= MIN_ANCHOR_LENGTH && new Set(anchor).size >= 12 ? anchor : null; +} + +/** + * A shared app-server owns the rollout file, so a new TUI may have no + * process-local file descriptor or `source = 'cli'` state row. The exact cwd + * narrows the app-server candidates; render and pane text must still select a + * single transcript before ChatMux exposes a display-only binding. + */ +export function selectInitialCodexThreadByDisplay(args: { + cwd?: string; + anchor?: string; + paneOutput: string; + candidates: readonly InitialDisplayCandidate[]; +}): string | null { + if (!args.cwd) return null; + const candidates = args.candidates.filter((candidate) => candidate.cwd === args.cwd); + if (args.anchor) { + return selectCodexForkByDisplay({ + anchor: args.anchor, + paneOutput: args.paneOutput, + candidates, + }); + } + + // A newly attached TUI restores transcript cells without replaying their + // markdown-stream deltas. In that startup window, use only a long, diverse + // assistant-message suffix visible below the newest Codex welcome card. The + // cwd and uniqueness checks keep this a conservative display-only fallback. + const output = normalizeCodexDisplayText(currentCodexPaneOutput(args.paneOutput)); + const matches = new Set(candidates.filter((candidate) => candidate.messages.some((message) => { + const messageAnchor = transcriptMessageAnchor(message); + return messageAnchor !== null && output.includes(messageAnchor); + })).map((candidate) => candidate.id)); + return matches.size === 1 ? [...matches][0] : null; +} + type CachedTranscript = { signature: string; transcript: ForkTranscript }; -type CachedForkBinding = { processKey: string; anchor: string; selectedId: string }; +type CachedForkBinding = { processKey: string; anchor?: string; selectedId: string; validatedAtMs: number }; // Bounded, server-private text cache. No prompt bodies enter logs or descriptors. const transcriptCache = new Map(); const forkBindingsByTarget = new Map(); +export function canReuseCodexDisplayBinding(args: { + previous?: Readonly; + processKey: string; + anchor?: string; + nowMs?: number; +}): boolean { + if (args.previous?.processKey !== args.processKey || args.previous.anchor !== args.anchor) return false; + if (args.anchor !== undefined) return true; + const ageMs = (args.nowMs ?? Date.now()) - args.previous.validatedAtMs; + return ageMs >= 0 && ageMs < CODEX_ANCHORLESS_BINDING_RECHECK_MS; +} + export async function readCodexForkTranscript(path: string, expectedId: string, root: string): Promise { try { const canonical = await realpath(path); @@ -148,21 +207,37 @@ export function readCodexRenderAnchor(db: Database.Database, pid: number, starte } catch { return null; } } +export function shouldInferSharedCodexDisplay( + session: ExternalCliSession, + observed: ReadonlyMap, + attemptableTargetKeys: ReadonlySet, +): boolean { + const targetKey = tmuxPaneIdentityKey(session.tmux); + return session.kind === 'codex' + && !session.connectionIssue + && session.startedAtMs !== undefined + && !observed.has(targetKey) + && Boolean(session.providerSessionId || attemptableTargetKeys.has(targetKey)); +} + /** * Shared app-server Codex releases no longer keep rollouts open in the TUI. - * Recover a *display-only* fork link from native ancestry + exact TUI render - * content + that pane's current output. Do not promote this to `observed`: - * neither timestamps, text equality nor ancestry is a provider identity receipt. + * Recover a *display-only* link from exact TUI render content + that pane's + * current output. Existing sessions additionally require native fork ancestry; + * initial sessions require an exact cwd match against app-server-owned open + * rollouts. Do not promote either path to `observed`: text and ancestry are not + * provider identity receipts. */ export async function inferSharedCodexForkIds(args: { sessions: ExternalCliSession[]; panes: ExternalPane[]; procs: ProcessTreeEntry[]; observed: ReadonlyMap; + attemptableTargetKeys: ReadonlySet; }): Promise> { - const targets = args.sessions.filter((session) => session.kind === 'codex' - && !session.connectionIssue && session.providerSessionId && session.startedAtMs !== undefined - && !args.observed.has(tmuxPaneIdentityKey(session.tmux))); + const targets = args.sessions.filter((session) => ( + shouldInferSharedCodexDisplay(session, args.observed, args.attemptableTargetKeys) + )); const resolved = new Map(); if (!targets.length) { forkBindingsByTarget.clear(); @@ -182,9 +257,10 @@ export async function inferSharedCodexForkIds(args: { pane: ExternalPane; key: string; processKey: string; - anchor: string; + anchor?: string; previous?: CachedForkBinding; }> = []; + const nowMs = Date.now(); for (const session of targets) { const key = tmuxPaneIdentityKey(session.tmux); const pane = args.panes.find((candidate) => tmuxPaneIdentityKey(candidate.tmux) === key); @@ -198,15 +274,22 @@ export async function inferSharedCodexForkIds(args: { const anchor = readCodexRenderAnchor(logs, pid, session.startedAtMs! - 1000); if (anchor) anchors.add(anchor); } - if (anchors.size !== 1) continue; - const [anchor] = [...anchors]; + if (anchors.size > 1 || (session.providerSessionId && anchors.size !== 1)) continue; + const anchor = anchors.size === 1 ? [...anchors][0] : undefined; const processKey = [externalSessionInferenceKey(session), ...codexPids.sort((a, b) => a - b)].join('\0'); - const previous = forkBindingsByTarget.get(key); - if (previous?.processKey === processKey && previous.anchor === anchor) { - resolved.set(key, previous.selectedId); + const cached = forkBindingsByTarget.get(key); + if (cached && canReuseCodexDisplayBinding({ previous: cached, processKey, anchor, nowMs })) { + resolved.set(key, cached.selectedId); continue; } - pending.push({ session, pane, key, processKey, anchor, ...(previous?.processKey === processKey ? { previous } : {}) }); + // An expired anchorless match must fail closed on revalidation instead of + // keeping an old thread visible after /new in the same TUI process. Drop + // it before I/O so a failed attempt does not retry on every poll. + if (cached?.processKey === processKey && cached.anchor === undefined && anchor === undefined) { + forkBindingsByTarget.delete(key); + } + const previous = cached?.processKey === processKey && cached.anchor !== undefined ? cached : undefined; + pending.push({ session, pane, key, processKey, ...(anchor ? { anchor } : {}), ...(previous ? { previous } : {}) }); } for (const key of forkBindingsByTarget.keys()) { if (!targets.some((session) => tmuxPaneIdentityKey(session.tmux) === key)) forkBindingsByTarget.delete(key); @@ -218,34 +301,51 @@ export async function inferSharedCodexForkIds(args: { if (!servers.length || servers.length > 8) return resolved; const openIds = new Set((await Promise.all(servers.map((proc) => readOpenCodexThreads(proc.pid, root)))).flat().map((thread) => thread.id)); if (!openIds.size || openIds.size > MAX_THREADS) return resolved; - const readRow = state.prepare('SELECT rollout_path, source, thread_source, agent_role FROM threads WHERE id = ?'); + type ThreadRow = { + rollout_path: string; + cwd: string; + source?: string; + thread_source?: string; + agent_role?: string; + }; + const readRow = state.prepare('SELECT rollout_path, cwd, source, thread_source, agent_role FROM threads WHERE id = ?'); + const threadRows = new Map(); const transcripts = new Map(); const read = async (id: string): Promise => { if (transcripts.has(id)) return transcripts.get(id)!; if (transcripts.size >= MAX_THREADS * 2) return null; - const row = readRow.get(id) as { rollout_path: string; source?: string; thread_source?: string; agent_role?: string } | undefined; + const row = readRow.get(id) as ThreadRow | undefined; + threadRows.set(id, row); const transcript = row && isCodexMainThreadMetadata(row) ? await readCodexForkTranscript(row.rollout_path, id, root) : null; transcripts.set(id, transcript); return transcript; }; + const initial: InitialDisplayCandidate[] = []; const forks: ForkTranscript[] = []; // Large rollouts can have large individual records; keep scratch buffers // sequential rather than multiplying them by the number of loaded threads. for (const id of openIds) { const transcript = await read(id); + const row = threadRows.get(id); + if (transcript && row?.cwd) initial.push({ ...transcript, cwd: row.cwd }); if (transcript?.parentId) forks.push(transcript); } - if (!forks.length) return resolved; + if (!initial.length) return resolved; for (const target of pending) { - const candidates: ForkTranscript[] = []; - for (const fork of forks) { - let parent = fork.parentId; - const visited = new Set([fork.id]); - for (let depth = 0; parent && depth < MAX_ANCESTORS && !visited.has(parent); depth += 1) { - if (parent === target.session.providerSessionId) { candidates.push(fork); break; } - visited.add(parent); - parent = (await read(parent))?.parentId; + let candidates: ForkTranscript[]; + if (target.session.providerSessionId) { + candidates = []; + for (const fork of forks) { + let parent = fork.parentId; + const visited = new Set([fork.id]); + for (let depth = 0; parent && depth < MAX_ANCESTORS && !visited.has(parent); depth += 1) { + if (parent === target.session.providerSessionId) { candidates.push(fork); break; } + visited.add(parent); + parent = (await read(parent))?.parentId; + } } + } else { + candidates = initial.filter((candidate) => candidate.cwd === target.session.cwd); } if (!candidates.length) { if (target.previous) resolved.set(target.key, target.previous.selectedId); @@ -253,7 +353,7 @@ export async function inferSharedCodexForkIds(args: { } // Most turns need no extra tmux command: first check whether any child // transcript even contains this process's current rendered content. - if (!candidates.some((candidate) => candidate.messages.some((message) => normalizeCodexDisplayText(message).includes(target.anchor)))) { + if (target.anchor && !candidates.some((candidate) => candidate.messages.some((message) => normalizeCodexDisplayText(message).includes(target.anchor!)))) { if (target.previous) resolved.set(target.key, target.previous.selectedId); continue; } @@ -264,9 +364,22 @@ export async function inferSharedCodexForkIds(args: { if (target.previous) resolved.set(target.key, target.previous.selectedId); continue; } - const selected = selectCodexForkByDisplay({ anchor: target.anchor, paneOutput: output.slice(-131072), candidates }); + const paneOutput = output.slice(-131072); + const selected = target.session.providerSessionId + ? selectCodexForkByDisplay({ anchor: target.anchor!, paneOutput, candidates }) + : selectInitialCodexThreadByDisplay({ + cwd: target.session.cwd, + anchor: target.anchor, + paneOutput, + candidates: initial, + }); if (selected) { - forkBindingsByTarget.set(target.key, { processKey: target.processKey, anchor: target.anchor, selectedId: selected }); + forkBindingsByTarget.set(target.key, { + processKey: target.processKey, + ...(target.anchor ? { anchor: target.anchor } : {}), + selectedId: selected, + validatedAtMs: nowMs, + }); resolved.set(target.key, selected); } else if (target.previous) { resolved.set(target.key, target.previous.selectedId); diff --git a/server/modules/providers/services/external-cli-sessions/inference-and-spawn.ts b/server/modules/providers/services/external-cli-sessions/inference-and-spawn.ts index 5d0946cd..ad08595e 100644 --- a/server/modules/providers/services/external-cli-sessions/inference-and-spawn.ts +++ b/server/modules/providers/services/external-cli-sessions/inference-and-spawn.ts @@ -7,6 +7,10 @@ import { fileURLToPath } from 'node:url'; import { CURSOR_CLI_COMMAND_CANDIDATES } from '@/modules/providers/list/cursor/cursor-cli-command.js'; +import { + supportsExternalCliFullAccess, + type FullAccessExternalSpawnCli, +} from '../../../../../shared/external-cli-spawn.js'; import type { TmuxPaneIdentity } from '../../../../../shared/tmux.js'; import { tmuxPaneIdentityKey } from '../../../../../shared/tmux.js'; import { resolveTmuxSpawnLaunch } from '../tmux-spawn-scope.service.js'; @@ -54,7 +58,11 @@ export async function inferExternalProviderSessionIds(args: { [...freshCodex].filter(([targetKey]) => attemptableTargetKeys.has(targetKey)), ); const inferredForks = await inferSharedCodexForkIds({ - sessions: safeSessions, panes: args.panes, procs: args.procs, observed: observedCodex, + sessions: safeSessions, + panes: args.panes, + procs: args.procs, + observed: observedCodex, + attemptableTargetKeys, }); const authoritativeTargetKeys = new Set([ ...observedCodex.keys(), @@ -344,6 +352,7 @@ export function buildExternalCliTmuxSpawnArgs( tmuxName: string, cwd: string, runtimePath = buildExternalCliRuntimePath(process.env.PATH ?? '', homedir(), process.execPath, executable), + cliArgs: readonly string[] = [], ): string[] { // Codex and OMP terminate during detached startup without an initial grid. // The explicit PATH also preserves user-installed Node/Bun launchers when @@ -354,10 +363,30 @@ export function buildExternalCliTmuxSpawnArgs( '-e', `PATH=${runtimePath}`, '-s', tmuxName, '-c', cwd, - '/usr/bin/env', `PATH=${runtimePath}`, executable, + '/usr/bin/env', `PATH=${runtimePath}`, executable, ...cliArgs, ]; } +export function externalCliFullAccessArgs( + cli: ExternalSpawnCli, + fullAccess: boolean, +): readonly string[] { + if (!fullAccess) return []; + if (!supportsExternalCliFullAccess(cli)) { + throw new Error(`${cli} does not support full-access interactive startup.`); + } + const args: Record = { + claude: ['--dangerously-skip-permissions'], + codex: ['--dangerously-bypass-approvals-and-sandbox'], + opencode: ['--auto'], + omp: ['--approval-mode=yolo'], + // OmO separates project-local resource trust from tool-call permission + // policy, so both audited interactive flags are required. + omo: ['--approve', '--permission-preset', 'full-access'], + }; + return args[cli]; +} + /** * Boots and tags a native CLI in a fresh detached tmux session. Session * creation may fork the tmux server, so under systemd it runs in a transient @@ -367,12 +396,20 @@ export async function spawnExternalCliSession( cli: ExternalSpawnCli, tmuxName: string, cwd: string, - deps: { launch?: typeof resolveTmuxSpawnLaunch; run?: typeof runCommand } = {}, + options: { + fullAccess?: boolean; + launch?: typeof resolveTmuxSpawnLaunch; + run?: typeof runCommand; + } = {}, ): Promise { - const run = deps.run ?? runCommand; + const run = options.run ?? runCommand; const executable = await resolveExternalCliExecutable(cli); - const launch = await (deps.launch ?? resolveTmuxSpawnLaunch)(); - await run(launch.command, [...launch.prefixArgs, ...buildExternalCliTmuxSpawnArgs(executable, tmuxName, cwd)]); + const launch = await (options.launch ?? resolveTmuxSpawnLaunch)(); + const cliArgs = externalCliFullAccessArgs(cli, options.fullAccess ?? false); + await run(launch.command, [ + ...launch.prefixArgs, + ...buildExternalCliTmuxSpawnArgs(executable, tmuxName, cwd, undefined, cliArgs), + ]); try { await run('tmux', ['set-option', '-t', tmuxName, '@chatmux_cli_kind', cli]); } catch (error) { diff --git a/server/modules/providers/services/provider-capabilities.service.ts b/server/modules/providers/services/provider-capabilities.service.ts index 17a6d3e1..0ceee008 100644 --- a/server/modules/providers/services/provider-capabilities.service.ts +++ b/server/modules/providers/services/provider-capabilities.service.ts @@ -1,5 +1,7 @@ import type { LLMProvider } from '@/shared/types.js'; +import { supportsExternalCliFullAccess } from '../../../../shared/external-cli-spawn.js'; + /** * Static, backend-owned description of what one provider integration supports. * @@ -23,6 +25,8 @@ type ProviderCapabilities = { supportsTokenUsage: boolean; /** Whether the provider runtime can accept model-level reasoning effort. */ supportsEffort: boolean; + /** Whether a native tmux session has a verified auto-approval/full-access startup mode. */ + supportsFullAccessSpawn: boolean; }; /** @@ -41,6 +45,7 @@ const PROVIDER_CAPABILITIES: Record = { supportsPermissionRequests: true, supportsTokenUsage: true, supportsEffort: true, + supportsFullAccessSpawn: supportsExternalCliFullAccess('claude'), }, cursor: { provider: 'cursor', @@ -51,6 +56,7 @@ const PROVIDER_CAPABILITIES: Record = { supportsPermissionRequests: false, supportsTokenUsage: false, supportsEffort: false, + supportsFullAccessSpawn: supportsExternalCliFullAccess('cursor'), }, codex: { provider: 'codex', @@ -61,6 +67,7 @@ const PROVIDER_CAPABILITIES: Record = { supportsPermissionRequests: false, supportsTokenUsage: true, supportsEffort: true, + supportsFullAccessSpawn: supportsExternalCliFullAccess('codex'), }, opencode: { provider: 'opencode', @@ -74,6 +81,7 @@ const PROVIDER_CAPABILITIES: Record = { supportsPermissionRequests: false, supportsTokenUsage: true, supportsEffort: true, + supportsFullAccessSpawn: supportsExternalCliFullAccess('opencode'), }, gjc: { provider: 'gjc', @@ -88,6 +96,9 @@ const PROVIDER_CAPABILITIES: Record = { // Runtime unsupported: spawnGjcWithRuntime does not read an effort option // (`server/gjc-cli.js:255`), so no reasoning-effort control reaches GJC. supportsEffort: false, + // GJC's guarded tools default to allow and it exposes no per-launch + // full-access flag, so enabling this checkbox would only create a no-op. + supportsFullAccessSpawn: false, }, omp: { provider: 'omp', @@ -108,6 +119,7 @@ const PROVIDER_CAPABILITIES: Record = { // B11x-omp-effort: buildOmpArgs passes effort through --thinking // (`server/omp-cli.ts:48-49`), but this matrix still hides the effort control. supportsEffort: false, + supportsFullAccessSpawn: supportsExternalCliFullAccess('omp'), }, omo: { provider: 'omo', @@ -128,6 +140,7 @@ const PROVIDER_CAPABILITIES: Record = { // `omo --list-models` reports thinking as a yes/no column and never // enumerates the levels, so no effort values can be offered. supportsEffort: false, + supportsFullAccessSpawn: supportsExternalCliFullAccess('omo'), }, }; diff --git a/server/modules/providers/tests/codex-fork-inference.test.ts b/server/modules/providers/tests/codex-fork-inference.test.ts index e27dd069..dc11ee57 100644 --- a/server/modules/providers/tests/codex-fork-inference.test.ts +++ b/server/modules/providers/tests/codex-fork-inference.test.ts @@ -6,7 +6,7 @@ import test from 'node:test'; import Database from 'better-sqlite3'; -import { codexRenderAnchor, normalizeCodexDisplayText, readCodexForkTranscript, readCodexRenderAnchor, selectCodexForkByDisplay } from '../services/external-cli-sessions/codex-fork-inference.js'; +import { canReuseCodexDisplayBinding, CODEX_ANCHORLESS_BINDING_RECHECK_MS, codexRenderAnchor, normalizeCodexDisplayText, readCodexForkTranscript, readCodexRenderAnchor, selectCodexForkByDisplay, selectInitialCodexThreadByDisplay, shouldInferSharedCodexDisplay } from '../services/external-cli-sessions/codex-fork-inference.js'; import { applyInferredProviderSessionIds } from '../services/external-cli-sessions/provider-runtime-inference.js'; import { assertProvenSessionBinding } from '../services/tmux-session-binding.service.js'; import { tmuxPaneIdentityKey } from '../../../../shared/tmux.js'; @@ -38,6 +38,112 @@ test('display matching requires both the exact pane output and a unique candidat assert.equal(selectCodexForkByDisplay({ anchor: 'too short', paneOutput: message, candidates }), null); }); +test('initial shared app-server display matching requires the exact cwd and unique transcript', () => { + const initial = '01a0be12-0298-7431-a9c8-9d15289c7f12'; + const candidates = [ + { id: initial, cwd: '/workspace/edgepose', messages: [message] }, + { id: sibling, cwd: '/workspace/other', messages: [message] }, + ]; + assert.equal(selectInitialCodexThreadByDisplay({ + cwd: '/workspace/edgepose', anchor, paneOutput: message, candidates, + }), initial); + assert.equal(selectInitialCodexThreadByDisplay({ + cwd: '/workspace/missing', anchor, paneOutput: message, candidates, + }), null); + assert.equal(selectInitialCodexThreadByDisplay({ + anchor, paneOutput: message, candidates, + }), null); + assert.equal(selectInitialCodexThreadByDisplay({ + cwd: '/workspace/edgepose', anchor, paneOutput: message, + candidates: [...candidates, { id: parent, cwd: '/workspace/edgepose', messages: [message] }], + }), null); +}); + +test('initial shared app-server matching can use restored pane history before new render deltas', () => { + const restored = '복원된 Codex 대화의 긴 응답은 새 스트리밍 로그가 없어도 현재 welcome card 아래 화면과 열린 rollout에서 유일하게 일치해야 합니다. 짧거나 중복된 내용은 연결 근거가 되지 않습니다.'; + const initial = '01a0be12-0298-7431-a9c8-9d15289c7f12'; + const candidates = [ + { id: initial, cwd: '/workspace/edgepose', messages: [restored] }, + { id: sibling, cwd: '/workspace/edgepose', messages: ['완전히 다른 대화의 충분히 긴 응답입니다. 화면에 나타나지 않으므로 후보가 되어서는 안 됩니다. 고유한 문자를 더해 최소 길이도 충족합니다.'] }, + ]; + const paneOutput = `old shell output\nOpenAI Codex (v0.155.1)\n${restored}\n› Ask Codex to do anything`; + assert.equal(selectInitialCodexThreadByDisplay({ cwd: '/workspace/edgepose', paneOutput, candidates }), initial); + assert.equal(selectInitialCodexThreadByDisplay({ + cwd: '/workspace/edgepose', paneOutput, + candidates: [...candidates, { id: parent, cwd: '/workspace/edgepose', messages: [restored] }], + }), null); + assert.equal(selectInitialCodexThreadByDisplay({ + cwd: '/workspace/edgepose', paneOutput: `old ${restored}\nOpenAI Codex (v0.155.1)\nnew session`, candidates, + }), null); + assert.equal(selectInitialCodexThreadByDisplay({ + cwd: '/workspace/edgepose', paneOutput: 'OpenAI Codex (v0.155.1)\n짧은 답변', + candidates: [{ id: initial, cwd: '/workspace/edgepose', messages: ['짧은 답변'] }], + }), null); +}); + +test('shared app-server display inference includes an unbound Codex TUI but excludes unsafe targets', () => { + const tmux = { socketPath: '/tmp/test-socket', sessionId: '$20', windowId: '@20', paneId: '%20' }; + const unbound = { + tmuxName: 'initial-codex', tmux, kind: 'codex' as const, + cwd: '/workspace/edgepose', agentPid: 20, startedAtMs: 1_000, + }; + const targetKey = tmuxPaneIdentityKey(tmux); + assert.equal(shouldInferSharedCodexDisplay(unbound, new Map(), new Set([targetKey])), true); + assert.equal(shouldInferSharedCodexDisplay(unbound, new Map(), new Set()), false); + assert.equal(shouldInferSharedCodexDisplay( + { ...unbound, providerSessionId: parent, binding: 'observed' }, + new Map(), + new Set(), + ), true); + assert.equal(shouldInferSharedCodexDisplay(unbound, new Map([[targetKey, parent]]), new Set([targetKey])), false); + assert.equal(shouldInferSharedCodexDisplay({ ...unbound, startedAtMs: undefined }, new Map(), new Set([targetKey])), false); + assert.equal(shouldInferSharedCodexDisplay( + { ...unbound, connectionIssue: 'socket_unreachable' as never }, + new Map(), + new Set([targetKey]), + ), false); +}); + +test('display inference periodically revalidates anchorless bindings for the same process generation', () => { + const previous = { + processKey: ['pane', 'codex', '20', '1000', '20'].join('\0'), + selectedId: child, + validatedAtMs: 1_000, + }; + assert.equal(canReuseCodexDisplayBinding({ previous, processKey: previous.processKey, nowMs: 1_001 }), true); + assert.equal(canReuseCodexDisplayBinding({ + previous, + processKey: previous.processKey, + nowMs: previous.validatedAtMs + CODEX_ANCHORLESS_BINDING_RECHECK_MS, + }), false); + assert.equal(canReuseCodexDisplayBinding({ + previous, + processKey: previous.processKey, + anchor, + nowMs: 1_001, + }), false); + assert.equal(canReuseCodexDisplayBinding({ + previous, + processKey: `${previous.processKey}-restarted`, + nowMs: 1_001, + }), false); +}); + +test('display inference reuses matching render anchors and invalidates changed anchors', () => { + const previous = { processKey: 'process-generation', anchor, selectedId: child, validatedAtMs: 1_000 }; + assert.equal(canReuseCodexDisplayBinding({ + previous, + processKey: previous.processKey, + anchor, + nowMs: Number.MAX_SAFE_INTEGER, + }), true); + assert.equal(canReuseCodexDisplayBinding({ + previous, + processKey: previous.processKey, + anchor: `${anchor.slice(0, -1)}x`, + }), false); +}); + test('a new Codex welcome card invalidates matching old scrollback', () => { const candidates = [{ id: child, messages: [message] }]; assert.equal(selectCodexForkByDisplay({ anchor, paneOutput: `${message}\n│ >_ OpenAI Codex (v0.154.0) │\n› Ask anything`, candidates }), null); diff --git a/server/modules/providers/tests/external-cli-sessions.service.test.ts b/server/modules/providers/tests/external-cli-sessions.service.test.ts index 30ed2684..8db3f72c 100644 --- a/server/modules/providers/tests/external-cli-sessions.service.test.ts +++ b/server/modules/providers/tests/external-cli-sessions.service.test.ts @@ -10,6 +10,7 @@ import { classifyExternalSessions, claudeReceiptPaneTag, buildExternalCliTmuxSpawnArgs, + externalCliFullAccessArgs, spawnExternalCliSession, buildExternalCliRuntimePath, createExternalCliSessionDiscovery, @@ -263,6 +264,38 @@ test('detached external CLI spawns receive a stable initial terminal grid', () = ); }); +test('full-access startup uses only the audited interactive CLI flags', () => { + const fullAccessClis = ['claude', 'codex', 'opencode', 'omp', 'omo'] as const; + assert.deepEqual( + Object.fromEntries(fullAccessClis.map((cli) => [ + cli, + externalCliFullAccessArgs(cli, true), + ])), + { + claude: ['--dangerously-skip-permissions'], + codex: ['--dangerously-bypass-approvals-and-sandbox'], + opencode: ['--auto'], + omp: ['--approval-mode=yolo'], + omo: ['--approve', '--permission-preset', 'full-access'], + }, + ); + assert.deepEqual(externalCliFullAccessArgs('codex', false), []); + assert.throws( + () => externalCliFullAccessArgs('cursor', true), + /does not support full-access interactive startup/, + ); + assert.deepEqual( + buildExternalCliTmuxSpawnArgs( + '/home/user/.local/bin/codex', + 'probe', + '/workspace', + '/runtime/bin:/usr/bin', + externalCliFullAccessArgs('codex', true), + ).slice(-2), + ['/home/user/.local/bin/codex', '--dangerously-bypass-approvals-and-sandbox'], + ); +}); + test('Cursor external CLI resolution uses the documented agent executable', async () => { const checked: string[] = []; const resolved = await resolveExternalCliExecutable('cursor', { @@ -1329,3 +1362,25 @@ test('external CLI spawns run tmux new-session through the resolved launch comma assert.deepEqual(calls[0].args.slice(0, 8), ['--user', '--scope', '--collect', '--quiet', '--', 'tmux', 'new-session', '-d'], 'session creation is wrapped in a transient scope'); assert.deepEqual([calls[1].command, calls[1].args.slice(0, 3)], ['tmux', ['set-option', '-t', 'scoped']], 'tagging talks to the now-running server directly'); }); + +test('external CLI spawn appends full-access mode to the native command only when requested', async () => { + const expectations = { + claude: ['--dangerously-skip-permissions'], + codex: ['--dangerously-bypass-approvals-and-sandbox'], + opencode: ['--auto'], + omp: ['--approval-mode=yolo'], + omo: ['--approve', '--permission-preset', 'full-access'], + } as const; + + for (const [cli, suffix] of Object.entries(expectations)) { + const calls: Array<{ command: string; args: string[] }> = []; + await spawnExternalCliSession(cli as keyof typeof expectations, `full-access-${cli}`, '/workspace', { + fullAccess: true, + launch: async () => ({ command: 'tmux', prefixArgs: [] }), + run: async (command, args) => { calls.push({ command, args }); return ''; }, + }); + + assert.deepEqual(calls[0]?.args.slice(-suffix.length), suffix); + assert.equal(calls[1]?.args.at(-1), cli); + } +}); diff --git a/server/modules/providers/tests/provider-contract-documentation.test.ts b/server/modules/providers/tests/provider-contract-documentation.test.ts index ecb6721c..9e019df4 100644 --- a/server/modules/providers/tests/provider-contract-documentation.test.ts +++ b/server/modules/providers/tests/provider-contract-documentation.test.ts @@ -52,3 +52,19 @@ test('GJC and OMP capability flags remain behavior-neutral', () => { }, ); }); + +test('only verified native CLIs advertise full-access startup', () => { + const capabilities = Object.fromEntries( + providerCapabilitiesService.listAllProviderCapabilities() + .map((entry) => [entry.provider, entry.supportsFullAccessSpawn]), + ); + assert.deepEqual(capabilities, { + claude: true, + cursor: false, + codex: true, + opencode: true, + gjc: false, + omp: true, + omo: true, + }); +}); diff --git a/server/modules/providers/tests/provider-routes-contract-cases-3.ts b/server/modules/providers/tests/provider-routes-contract-cases-3.ts index 81eed16d..db1299aa 100644 --- a/server/modules/providers/tests/provider-routes-contract-cases-3.ts +++ b/server/modules/providers/tests/provider-routes-contract-cases-3.ts @@ -110,6 +110,8 @@ test('external kill protects company-prefixed sessions', async () => { test('external and live spawn create missing HOME cwd paths while retaining safety rejections', async () => { assertError(await request('/sessions/external/spawn', { name: 'company', cwd: '~' }), 400, 'INVALID_SPAWN_NAME'); assertError(await request('/sessions/external/spawn', { name: 'contract', cwd: ' ' }), 400, 'EMPTY_CWD'); + assertError(await request('/sessions/external/spawn', { name: 'contract', cwd: '~', cli: 'codex', fullAccess: 'yes' }), 400, 'INVALID_FULL_ACCESS'); + assertError(await request('/sessions/external/spawn', { name: 'contract', cwd: '~', cli: 'cursor', fullAccess: true }), 400, 'UNSUPPORTED_FULL_ACCESS'); assertError(await request('/sessions/live/spawn', { name: 'contract', cwd: ' ' }), 400, 'EMPTY_CWD'); // Given: an isolated HOME and paths which must remain outside it. diff --git a/shared/external-cli-spawn.ts b/shared/external-cli-spawn.ts new file mode 100644 index 00000000..4b360c61 --- /dev/null +++ b/shared/external-cli-spawn.ts @@ -0,0 +1,30 @@ +/** Native CLIs that ChatMux can start in a local tmux session. */ +export const EXTERNAL_SPAWN_CLIS = [ + 'claude', + 'codex', + 'cursor', + 'opencode', + 'omp', + 'omo', +] as const; + +export type ExternalSpawnCli = typeof EXTERNAL_SPAWN_CLIS[number]; + +/** + * CLIs whose interactive startup contract has a verified auto-approval or + * full-access mode. + * Keep this list narrow: a non-interactive `run` flag is not sufficient. + */ +export const FULL_ACCESS_EXTERNAL_SPAWN_CLIS = [ + 'claude', + 'codex', + 'opencode', + 'omp', + 'omo', +] as const; + +export type FullAccessExternalSpawnCli = typeof FULL_ACCESS_EXTERNAL_SPAWN_CLIS[number]; + +export function supportsExternalCliFullAccess(cli: string): cli is FullAccessExternalSpawnCli { + return (FULL_ACCESS_EXTERNAL_SPAWN_CLIS as readonly string[]).includes(cli); +} diff --git a/src/components/chat/hooks/useChatProviderState.ts b/src/components/chat/hooks/useChatProviderState.ts index 2ee14ccd..25b7c9b5 100644 --- a/src/components/chat/hooks/useChatProviderState.ts +++ b/src/components/chat/hooks/useChatProviderState.ts @@ -60,6 +60,7 @@ type ProviderCapabilities = { supportsPermissionRequests: boolean; supportsTokenUsage: boolean; supportsEffort?: boolean; + supportsFullAccessSpawn?: boolean; }; type ProviderCapabilitiesApiResponse = { diff --git a/src/components/chat/utils/chatFormatting.test.ts b/src/components/chat/utils/chatFormatting.test.ts new file mode 100644 index 00000000..b8976874 --- /dev/null +++ b/src/components/chat/utils/chatFormatting.test.ts @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { performance } from 'node:perf_hooks'; + +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import ReactMarkdown from 'react-markdown'; +import rehypeKatex from 'rehype-katex'; +import remarkMath from 'remark-math'; + +import { normalizeLatexMathDelimiters } from './chatFormatting'; + +test('normalizes paired LaTeX display and inline delimiters for remark-math', () => { + assert.equal( + normalizeLatexMathDelimiters('\\[\n\\text{보정값} \\approx E[\\text{weight 오차} \\times \\text{입력 특징}]\n\\]'), + '$$\n\\text{보정값} \\approx E[\\text{weight 오차} \\times \\text{입력 특징}]\n$$', + ); + assert.equal(normalizeLatexMathDelimiters('값은 \\(x \\times y\\) 입니다.'), '값은 $x \\times y$ 입니다.'); +}); + +test('leaves LaTeX delimiters in Markdown code and unmatched input unchanged', () => { + const markdown = [ + '`\\(inline code\\)`', + '```latex', + '\\[fenced code\\]', + '```', + ' \\(indented code\\)', + '\\[unmatched', + String.raw`escaped \\[literal\\]`, + ].join('\n'); + assert.equal(normalizeLatexMathDelimiters(markdown), markdown); +}); + +test('does not let an unmatched inline delimiter consume a later fenced code block', () => { + const markdown = [ + 'Group with \\( in BRE.', + '', + '```sh', + String.raw`sed 's/\(foo\)/bar/' file`, + '```', + ].join('\n'); + assert.equal(normalizeLatexMathDelimiters(markdown), markdown); +}); + +test('normalizes display math with space- or tab-indented bodies', () => { + const spaced = [ + '\\[', + ' \\begin{aligned}', + ' a &= b \\\\', + ' c &= d', + ' \\end{aligned}', + '\\]', + ].join('\n'); + assert.equal(normalizeLatexMathDelimiters(spaced), [ + '$$', + ' \\begin{aligned}', + ' a &= b \\\\', + ' c &= d', + ' \\end{aligned}', + '$$', + ].join('\n')); + + const tabbed = ['\\[', '\t\\begin{bmatrix}', '\t\ta & b', '\t\\end{bmatrix}', '\\]'].join('\n'); + assert.equal( + normalizeLatexMathDelimiters(tabbed), + ['$$', '\t\\begin{bmatrix}', '\t\ta & b', '\t\\end{bmatrix}', '$$'].join('\n'), + ); +}); + +test('keeps escaped Markdown brackets while accepting unambiguous display math', () => { + const references = 'See \\[1\\] and \\[2\\].'; + assert.equal(normalizeLatexMathDelimiters(references), references); + const option = 'Usage: cmd \\[--flag=value\\]'; + assert.equal(normalizeLatexMathDelimiters(option), option); + assert.equal(normalizeLatexMathDelimiters('Equation: \\[E = mc^2\\].'), 'Equation: $$E = mc^2$$.'); + assert.equal(normalizeLatexMathDelimiters('\\[x\\]'), '$$x$$'); +}); + +test('normalizes ordinary inline math while preserving slash-delimited BRE groups', () => { + const sed = String.raw`Run sed 's/\(foo\)/bar/' to rename.`; + assert.equal(normalizeLatexMathDelimiters(sed), sed); + const slashAfter = String.raw`Pattern \(foo\)/bar`; + assert.equal(normalizeLatexMathDelimiters(slashAfter), slashAfter); + assert.equal(normalizeLatexMathDelimiters('원소가 \\(n\\)개 있습니다.'), '원소가 $n$개 있습니다.'); + assert.equal(normalizeLatexMathDelimiters('value \\(x+1\\) done'), 'value $x+1$ done'); + assert.equal(normalizeLatexMathDelimiters('when \\(x = y\\) holds'), 'when $x = y$ holds'); + assert.equal(normalizeLatexMathDelimiters('points \\(a, b\\) given'), 'points $a, b$ given'); + assert.equal(normalizeLatexMathDelimiters('값은 \\(x \\times y\\) 입니다.'), '값은 $x \\times y$ 입니다.'); + assert.equal(normalizeLatexMathDelimiters('\\(x\\)'), '$x$'); +}); + +test('normalizes long backslash runs without superlinear slowdown', () => { + const markdown = '\\'.repeat(100_000); + const plain = 'a'.repeat(markdown.length); + const plainStartedAt = performance.now(); + assert.equal(normalizeLatexMathDelimiters(plain), plain); + const plainElapsedMs = performance.now() - plainStartedAt; + const slashStartedAt = performance.now(); + assert.equal(normalizeLatexMathDelimiters(markdown), markdown); + const slashElapsedMs = performance.now() - slashStartedAt; + const limitMs = Math.max(250, plainElapsedMs * 50); + assert.ok( + slashElapsedMs < limitMs, + `backslashes took ${slashElapsedMs.toFixed(1)} ms vs ${plainElapsedMs.toFixed(1)} ms for plain text`, + ); +}); + +test('renders Codex backslash display math through KaTeX without dropping command prefixes', () => { + const content = normalizeLatexMathDelimiters( + '\\[\n\\text{보정값} \\approx E[\\text{weight 오차} \\times \\text{입력 특징}]\n\\]', + ); + const html = renderToStaticMarkup( + React.createElement(ReactMarkdown, { + remarkPlugins: [remarkMath], + rehypePlugins: [rehypeKatex], + }, content), + ); + assert.ok(html.includes('class="katex-display"')); + assert.ok(html.includes('보정값')); + assert.ok(html.includes('weight 오차')); + assert.ok(html.includes('입력 특징')); + assert.ok(html.includes('annotation encoding="application/x-tex"')); +}); diff --git a/src/components/chat/utils/chatFormatting.ts b/src/components/chat/utils/chatFormatting.ts index 418fb56e..d3cd3873 100644 --- a/src/components/chat/utils/chatFormatting.ts +++ b/src/components/chat/utils/chatFormatting.ts @@ -17,6 +17,222 @@ export function normalizeInlineCodeFences(text: string) { } } +type MarkdownFence = { + marker: '`' | '~'; + length: number; +}; + +function backtickRunLength(text: string, index: number): number { + let cursor = index; + while (text[cursor] === '`') cursor += 1; + return cursor - index; +} + +function isMathBoundaryLine(line: string): boolean { + return /^[ \t]*$/.test(line) + || /^ {0,3}(?:`{3,}|~{3,})/.test(line); +} + +function displayMathShouldNormalize(args: { + text: string; + closeIndex: number; + prefixOnlyWhitespace: boolean; + strongMathSyntax: boolean; +}): boolean { + if (args.strongMathSyntax) return true; + if (!args.prefixOnlyWhitespace) return false; + const lineEnd = args.text.indexOf('\n', args.closeIndex + 2); + const suffix = args.text.slice(args.closeIndex + 2, lineEnd < 0 ? args.text.length : lineEnd); + return /^[ \t\r]*$/.test(suffix); +} + +function findClosingBacktickRun(text: string, start: number, length: number): number { + let cursor = start; + while (cursor < text.length) { + const next = text.indexOf('`', cursor); + if (next < 0) return -1; + const runLength = backtickRunLength(text, next); + if (runLength === length) return next; + cursor = next + runLength; + } + return -1; +} + +/** + * remark-math recognizes dollar delimiters, while Codex and Claude commonly + * emit LaTeX's `\\(...\\)` and `\\[...\\]` forms. Normalize only paired + * delimiters outside Markdown code so the Markdown parser cannot consume the + * backslashes before remark-math sees them. + */ +export function normalizeLatexMathDelimiters(text: string) { + if (!text || typeof text !== 'string') return text; + + const output: string[] = []; + let cursor = 0; + let atLineStart = true; + let lineHasContent = false; + let precedingBackslashes = 0; + let fence: MarkdownFence | null = null; + let math: { + close: '\\)' | '\\]'; + replacement: '$' | '$$'; + outputIndex: number; + prefixOnlyWhitespace: boolean; + strongMathSyntax: boolean; + } | null = null; + + while (cursor < text.length) { + if (math) { + if (atLineStart) { + const lineEnd = text.indexOf('\n', cursor); + const end = lineEnd < 0 ? text.length : lineEnd; + if (isMathBoundaryLine(text.slice(cursor, end))) { + math = null; + continue; + } + } + + if (text[cursor] === '`') { + math = null; + continue; + } + + const backslashEscaped = precedingBackslashes % 2 === 1; + if (text[cursor] === '\\' && !backslashEscaped && text.startsWith(math.close, cursor)) { + const shouldNormalize = math.replacement === '$' + ? text[cursor + math.close.length] !== '/' + : displayMathShouldNormalize({ + text, + closeIndex: cursor, + prefixOnlyWhitespace: math.prefixOnlyWhitespace, + strongMathSyntax: math.strongMathSyntax, + }); + if (shouldNormalize) { + output[math.outputIndex] = math.replacement; + output.push(math.replacement); + } else { + output.push(math.close); + } + cursor += math.close.length; + math = null; + atLineStart = false; + lineHasContent = true; + precedingBackslashes = 0; + continue; + } + const char = text[cursor]; + if ((char === '\\' && /[A-Za-z]/.test(text[cursor + 1] ?? '')) || char === '^' || char === '_') { + math.strongMathSyntax = true; + } + output.push(char); + cursor += 1; + atLineStart = char === '\n'; + if (atLineStart) { + lineHasContent = false; + precedingBackslashes = 0; + } else { + if (!/[ \t\r]/.test(char)) lineHasContent = true; + precedingBackslashes = char === '\\' ? precedingBackslashes + 1 : 0; + } + continue; + } + + if (atLineStart) { + const lineEnd = text.indexOf('\n', cursor); + const end = lineEnd < 0 ? text.length : lineEnd; + const line = text.slice(cursor, end); + if (fence) { + const close = line.match(/^ {0,3}(`+|~+)[ \t]*$/); + if (close && close[1][0] === fence.marker && close[1].length >= fence.length) { + fence = null; + } + output.push(text.slice(cursor, lineEnd < 0 ? end : end + 1)); + cursor = lineEnd < 0 ? end : end + 1; + atLineStart = lineEnd >= 0; + lineHasContent = !atLineStart; + precedingBackslashes = 0; + continue; + } + + const open = line.match(/^ {0,3}(`{3,}|~{3,})/); + if (open) { + fence = { marker: open[1][0] as '`' | '~', length: open[1].length }; + output.push(text.slice(cursor, lineEnd < 0 ? end : end + 1)); + cursor = lineEnd < 0 ? end : end + 1; + atLineStart = lineEnd >= 0; + lineHasContent = !atLineStart; + precedingBackslashes = 0; + continue; + } + + // Four-space and tab-indented code lines are code even without fences. + if (line.startsWith(' ') || line.startsWith('\t')) { + output.push(text.slice(cursor, lineEnd < 0 ? end : end + 1)); + cursor = lineEnd < 0 ? end : end + 1; + atLineStart = lineEnd >= 0; + lineHasContent = !atLineStart; + precedingBackslashes = 0; + continue; + } + } + + if (text[cursor] === '`') { + const runLength = backtickRunLength(text, cursor); + const close = findClosingBacktickRun(text, cursor + runLength, runLength); + if (close >= 0) { + const end = close + runLength; + const code = text.slice(cursor, end); + output.push(code); + atLineStart = code.endsWith('\n'); + lineHasContent = !atLineStart; + precedingBackslashes = 0; + cursor = end; + continue; + } + } + + if (text[cursor] === '\\' && precedingBackslashes % 2 === 0) { + const opening = text.startsWith('\\[', cursor) + ? { token: '\\[', close: '\\]' as const, replacement: '$$' as const } + : text.startsWith('\\(', cursor) + ? { token: '\\(', close: '\\)' as const, replacement: '$' as const } + : null; + // BRE groups commonly appear as /\(...\)/. Preserve that syntax while + // accepting ordinary inline math without requiring a LaTeX command. + if (opening && (opening.replacement !== '$' || text[cursor - 1] !== '/')) { + const outputIndex = output.length; + output.push(opening.token); + cursor += opening.token.length; + math = { + close: opening.close, + replacement: opening.replacement, + outputIndex, + prefixOnlyWhitespace: !lineHasContent, + strongMathSyntax: false, + }; + atLineStart = false; + lineHasContent = true; + precedingBackslashes = 0; + continue; + } + } + + const char = text[cursor]; + output.push(char); + cursor += 1; + atLineStart = char === '\n'; + if (atLineStart) { + lineHasContent = false; + precedingBackslashes = 0; + } else { + if (!/[ \t\r]/.test(char)) lineHasContent = true; + precedingBackslashes = char === '\\' ? precedingBackslashes + 1 : 0; + } + } + + return output.join(''); +} + export function unescapeWithMathProtection(text: string) { if (!text || typeof text !== 'string') return text; diff --git a/src/components/chat/view/subcomponents/Markdown.tsx b/src/components/chat/view/subcomponents/Markdown.tsx index 01c50341..f46bcb87 100644 --- a/src/components/chat/view/subcomponents/Markdown.tsx +++ b/src/components/chat/view/subcomponents/Markdown.tsx @@ -10,7 +10,7 @@ import oneDark from 'react-syntax-highlighter/dist/esm/styles/prism/one-dark'; import oneLight from 'react-syntax-highlighter/dist/esm/styles/prism/one-light'; import { useTranslation } from 'react-i18next'; -import { normalizeInlineCodeFences } from '../../utils/chatFormatting'; +import { normalizeInlineCodeFences, normalizeLatexMathDelimiters } from '../../utils/chatFormatting'; import { copyTextToClipboard } from '../../../../utils/clipboard'; import { usePaletteOps } from '../../../../contexts/PaletteOpsContext'; import { useTheme } from '../../../../contexts/ThemeContext'; @@ -185,7 +185,7 @@ const markdownComponents = { }; export function Markdown({ children, className }: MarkdownProps) { - const content = normalizeInlineCodeFences(String(children ?? '')); + const content = normalizeLatexMathDelimiters(normalizeInlineCodeFences(String(children ?? ''))); const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []); const rehypePlugins = useMemo(() => [rehypeKatex], []); const { openFileInEditor } = usePaletteOps(); diff --git a/src/components/sidebar/view/subcomponents/SidebarNewSession.hosts.mounted.test.tsx b/src/components/sidebar/view/subcomponents/SidebarNewSession.hosts.mounted.test.tsx index 69e25b02..4a823594 100644 --- a/src/components/sidebar/view/subcomponents/SidebarNewSession.hosts.mounted.test.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarNewSession.hosts.mounted.test.tsx @@ -49,6 +49,75 @@ test('Given a local spawn for GJC and for a native CLI, when each succeeds, then assert.equal(harness.created(), 2); }); +test('Given a verified native CLI, full access is opt-in and reaches spawn as a boolean', async (t) => { + // Given + const fetches = stubFetch(); + t.after(fetches.restore); + const harness = mount({ localHostId: LOCAL, hosts: new Map() }); + t.after(harness.dispose); + + // When + for (const cli of ['claude', 'codex', 'opencode', 'omp', 'omo']) { + press(harness, 'data-spawn-provider', cli); + assert.equal( + byAttribute(harness, 'data-spawn-full-access')[0]?.props.disabled, + false, + `${cli} exposes its audited startup mode`, + ); + } + press(harness, 'data-spawn-provider', 'codex'); + const checkbox = byAttribute(harness, 'data-spawn-full-access')[0]; + assert.equal(checkbox?.props.disabled, false); + assert.equal(byAttribute(harness, 'data-spawn-full-access-warning').length, 0); + act(() => { checkbox?.props.onChange({ target: { checked: true } }); }); + const warning = byAttribute(harness, 'data-spawn-full-access-warning')[0]; + assert.ok(warning, 'the active dangerous mode has a visible warning'); + assert.equal(checkbox?.props['aria-describedby'], warning.props.id); + assert.match(warning.children.join(''), /without another approval/); + typeInto(harness, 'data-spawn-name', 'trusted-codex'); + typeInto(harness, 'placeholder', '/home/me/app', 'Working folder (e.g. ~/workspace/my-proj or an absolute path)'); + press(harness, 'data-spawn-submit'); + await act(async () => { await fetches.awaitRequest((url) => url.includes('spawn')); }); + await act(async () => { await Promise.resolve(); }); + + // Then + assert.deepEqual(spawnCalls(fetches)[0]?.body, { + name: 'trusted-codex', + cwd: '/home/me/app', + cli: 'codex', + fullAccess: true, + }); +}); + +test('Given an unsupported provider or remote host, full access stays disabled and stale opt-in is cleared', (t) => { + // Given + const harness = mount(catalogOf(entry(LOCAL, 'online'), entry(PEER_A, 'online'))); + t.after(harness.dispose); + press(harness, 'data-spawn-provider', 'claude'); + const supported = byAttribute(harness, 'data-spawn-full-access')[0]; + act(() => { supported?.props.onChange({ target: { checked: true } }); }); + + // When / Then: changing providers clears the dangerous opt-in. + press(harness, 'data-spawn-provider', 'cursor'); + const unsupported = byAttribute(harness, 'data-spawn-full-access')[0]; + assert.equal(unsupported?.props.disabled, true); + assert.equal(unsupported?.props.checked, false); + assert.equal(byAttribute(harness, 'data-spawn-full-access-warning').length, 0); + + // GJC already runs its guarded tools with the native default allow policy; + // it has no per-launch full-access switch for this control to apply. + press(harness, 'data-spawn-provider', 'gjc'); + const gjc = byAttribute(harness, 'data-spawn-full-access')[0]; + assert.equal(gjc?.props.disabled, true); + assert.equal(gjc?.props.checked, false); + + // When / Then: remote spawning cannot inherit the local option. + press(harness, 'data-spawn-host', PEER_A); + const remote = byAttribute(harness, 'data-spawn-full-access')[0]; + assert.equal(remote?.props.disabled, true); + assert.equal(remote?.props.checked, false); +}); + test('Given a local spawn with no working directory, when submit is pressed, then nothing is dispatched', async (t) => { // Given const fetches = stubFetch(); diff --git a/src/components/sidebar/view/subcomponents/SidebarNewSession.tsx b/src/components/sidebar/view/subcomponents/SidebarNewSession.tsx index 3f4dca07..98888e5c 100644 --- a/src/components/sidebar/view/subcomponents/SidebarNewSession.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarNewSession.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useId, useMemo, useState } from 'react'; import { Plus } from 'lucide-react'; import { useTranslation } from 'react-i18next'; @@ -7,6 +7,7 @@ import { EMPTY_HOST_ROW_SET } from '../../../../fleet/discovery/hostRows'; import { spawnableHosts, type SpawnHostChoice } from '../../../../fleet/hostAvailability'; import HomeDirInput from '../../../../shared/view/HomeDirInput'; import { cn } from '../../../../lib/utils'; +import { supportsExternalCliFullAccess } from '../../../../../shared/external-cli-spawn'; import PeerDirInput from './newSession/PeerDirInput'; import SpawnHostFields from './newSession/SpawnHostFields'; @@ -72,6 +73,8 @@ export default function SidebarNewSession({ const [cwd, setCwd] = useState(() => readRecentCwds()[0] ?? ''); const [hostId, setHostId] = useState(null); const [projectLocalId, setProjectLocalId] = useState(null); + const [fullAccess, setFullAccess] = useState(false); + const fullAccessWarningId = useId(); const hosts = useMemo( () => spawnableHosts(catalog, t('newSessionForm.thisMachine')), @@ -96,6 +99,7 @@ export default function SidebarNewSession({ rememberCwd(path); setOpen(false); setName(''); + setFullAccess(false); // Keep the path of least resistance: the next spawn most likely targets // the same repo, so the field reopens prefilled with the latest cwd. setCwd(readRecentCwds()[0] ?? ''); @@ -105,6 +109,7 @@ export default function SidebarNewSession({ const selectHost = (nextHostId: string | null) => { setHostId(nextHostId); setProjectLocalId(null); + setFullAccess(false); // A peer's path space is its own: keeping this machine's last path would // pre-fill a directory that does not exist there. setCwd(nextHostId === null || nextHostId === catalog.localHostId ? readRecentCwds()[0] ?? '' : ''); @@ -122,6 +127,7 @@ export default function SidebarNewSession({ ? PROVIDERS.filter((item) => (PEER_SPAWN_PROVIDERS as readonly string[]).includes(item.id)) : PROVIDERS; const activeProvider = isRemote ? 'gjc' : provider; + const supportsFullAccess = !isRemote && supportsExternalCliFullAccess(activeProvider); const ready = canDispatchSpawn({ host: selectedHost, name, cwd, projectLocalId }); const submit = () => { if (!ready || status.kind === 'spawning' || status.kind === 'unknown') return; @@ -132,6 +138,7 @@ export default function SidebarNewSession({ provider: activeProvider, name: name.trim(), cwd, + fullAccess: supportsFullAccess && fullAccess, }); }; @@ -165,7 +172,7 @@ export default function SidebarNewSession({ - + setFullAccess(event.target.checked)} + aria-describedby={supportsFullAccess && fullAccess ? fullAccessWarningId : undefined} + data-spawn-full-access + className="h-3.5 w-3.5 accent-amber-600" + /> + {t('newSessionForm.fullAccess')} + +
+ + +
); diff --git a/src/components/sidebar/view/subcomponents/newSession/useSessionSpawn.ts b/src/components/sidebar/view/subcomponents/newSession/useSessionSpawn.ts index 09e675c4..49e67c9f 100644 --- a/src/components/sidebar/view/subcomponents/newSession/useSessionSpawn.ts +++ b/src/components/sidebar/view/subcomponents/newSession/useSessionSpawn.ts @@ -37,6 +37,7 @@ export type SpawnRequest = { readonly provider: string; readonly name: string; readonly cwd: string; + readonly fullAccess: boolean; }; const IDLE: SpawnStatus = { kind: 'idle' }; @@ -54,7 +55,7 @@ function detailOf(payload: Readonly> | null): string | n async function localSpawn(request: SpawnRequest): Promise { const response = request.provider === 'gjc' ? await api.liveSessionSpawn(request.name, request.cwd) - : await api.externalCliSessionSpawn(request.provider, request.name, request.cwd); + : await api.externalCliSessionSpawn(request.provider, request.name, request.cwd, request.fullAccess); const payload = body(await response.json().catch(() => null)); const data = body(payload?.data) ?? payload; if (response.ok && data?.ok === true) { diff --git a/src/i18n/locales/de/sidebar.json b/src/i18n/locales/de/sidebar.json index 8cbdb23f..0bf5354a 100644 --- a/src/i18n/locales/de/sidebar.json +++ b/src/i18n/locales/de/sidebar.json @@ -19,6 +19,10 @@ "sessionNamePlaceholder": "Sitzungsname (Buchstaben und Zahlen, z. B. mein-feature)", "workingDirectoryPlaceholder": "Arbeitsordner (z. B. ~/workspace/mein-projekt oder absoluter Pfad)", "recentPaths": "Zuletzt verwendete Pfade", + "fullAccess": "Vollzugriff", + "fullAccessWarning": "Diese Sitzung im verifizierten Auto-Genehmigungs- oder Vollzugriffsmodus des Anbieters starten", + "fullAccessActiveWarning": "Tools, Shell-Befehle und Dateiänderungen können in dieser Sitzung ohne weitere Bestätigung ausgeführt werden.", + "fullAccessUnsupported": "Dieser Anbieter unterstützt keinen Start mit Vollzugriff", "cancel": "Abbrechen", "creating": "Wird erstellt…", "create": "Erstellen", diff --git a/src/i18n/locales/en/sidebar.json b/src/i18n/locales/en/sidebar.json index 5ed15e52..00f0e7ae 100644 --- a/src/i18n/locales/en/sidebar.json +++ b/src/i18n/locales/en/sidebar.json @@ -28,6 +28,10 @@ "sessionNamePlaceholder": "Session name (letters and numbers, e.g. my-feature)", "workingDirectoryPlaceholder": "Working folder (e.g. ~/workspace/my-proj or an absolute path)", "recentPaths": "Recent paths", + "fullAccess": "Full access", + "fullAccessWarning": "Use this provider's verified auto-approval or full-access mode for this session", + "fullAccessActiveWarning": "Tools, shell commands, and file changes may run without another approval in this session.", + "fullAccessUnsupported": "This provider does not support full-access startup", "cancel": "Cancel", "creating": "Creating…", "create": "Create", diff --git a/src/i18n/locales/fr/sidebar.json b/src/i18n/locales/fr/sidebar.json index be3e437f..9d578a7c 100644 --- a/src/i18n/locales/fr/sidebar.json +++ b/src/i18n/locales/fr/sidebar.json @@ -19,6 +19,10 @@ "sessionNamePlaceholder": "Nom de session (lettres et chiffres, ex. ma-fonctionnalite)", "workingDirectoryPlaceholder": "Dossier de travail (ex. ~/workspace/mon-projet ou chemin absolu)", "recentPaths": "Chemins récents", + "fullAccess": "Accès total", + "fullAccessWarning": "Démarrer cette session avec le mode vérifié d’approbation automatique ou d’accès complet du fournisseur", + "fullAccessActiveWarning": "Dans cette session, les outils, commandes shell et modifications de fichiers peuvent s’exécuter sans nouvelle approbation.", + "fullAccessUnsupported": "Ce fournisseur ne prend pas en charge le démarrage en accès total", "cancel": "Annuler", "creating": "Création…", "create": "Créer", diff --git a/src/i18n/locales/it/sidebar.json b/src/i18n/locales/it/sidebar.json index e438c8f4..2c3c7942 100644 --- a/src/i18n/locales/it/sidebar.json +++ b/src/i18n/locales/it/sidebar.json @@ -19,6 +19,10 @@ "sessionNamePlaceholder": "Nome sessione (lettere e numeri, es. mia-funzione)", "workingDirectoryPlaceholder": "Cartella di lavoro (es. ~/workspace/mio-progetto o percorso assoluto)", "recentPaths": "Percorsi recenti", + "fullAccess": "Accesso completo", + "fullAccessWarning": "Avvia questa sessione con la modalità verificata di approvazione automatica o accesso completo del provider", + "fullAccessActiveWarning": "In questa sessione, strumenti, comandi shell e modifiche ai file possono essere eseguiti senza ulteriori approvazioni.", + "fullAccessUnsupported": "Questo provider non supporta l’avvio con accesso completo", "cancel": "Annulla", "creating": "Creazione…", "create": "Crea", diff --git a/src/i18n/locales/ja/sidebar.json b/src/i18n/locales/ja/sidebar.json index 25e36567..7573ce3d 100644 --- a/src/i18n/locales/ja/sidebar.json +++ b/src/i18n/locales/ja/sidebar.json @@ -19,6 +19,10 @@ "sessionNamePlaceholder": "セッション名(英数字、例: my-feature)", "workingDirectoryPlaceholder": "作業フォルダー(例: ~/workspace/my-proj、絶対パスも可)", "recentPaths": "最近使用したパス", + "fullAccess": "すべて許可", + "fullAccessWarning": "このセッションをプロバイダーで検証済みの自動承認またはフルアクセスモードで開始します", + "fullAccessActiveWarning": "このセッションでは、ツール、シェルコマンド、ファイル変更が追加の承認なしで実行される場合があります。", + "fullAccessUnsupported": "このプロバイダーはフルアクセスでの起動に対応していません", "cancel": "キャンセル", "creating": "作成中…", "create": "作成", diff --git a/src/i18n/locales/ko/sidebar.json b/src/i18n/locales/ko/sidebar.json index f1b57acb..cd3c016f 100644 --- a/src/i18n/locales/ko/sidebar.json +++ b/src/i18n/locales/ko/sidebar.json @@ -43,6 +43,10 @@ "sessionNamePlaceholder": "세션 이름 (영숫자, 예: my-feature)", "workingDirectoryPlaceholder": "작업 폴더 (예: ~/workspace/my-proj, 절대 경로 가능)", "recentPaths": "최근 사용 경로", + "fullAccess": "모두 허용", + "fullAccessWarning": "이 세션을 프로바이더가 지원하는 자동 승인 또는 전체 권한 모드로 시작합니다", + "fullAccessActiveWarning": "이 세션에서는 도구 실행, 셸 명령 및 파일 변경이 추가 승인 없이 진행될 수 있습니다.", + "fullAccessUnsupported": "이 프로바이더는 모두 허용 시작을 지원하지 않습니다", "cancel": "취소", "creating": "생성 중…", "create": "만들기", diff --git a/src/i18n/locales/ru/sidebar.json b/src/i18n/locales/ru/sidebar.json index 54a75b6e..28cde451 100644 --- a/src/i18n/locales/ru/sidebar.json +++ b/src/i18n/locales/ru/sidebar.json @@ -34,6 +34,10 @@ "sessionNamePlaceholder": "Имя сеанса (буквы и цифры, например my-feature)", "workingDirectoryPlaceholder": "Рабочая папка (например ~/workspace/my-proj или абсолютный путь)", "recentPaths": "Недавние пути", + "fullAccess": "Полный доступ", + "fullAccessWarning": "Запустить этот сеанс в проверенном режиме автоодобрения или полного доступа провайдера", + "fullAccessActiveWarning": "В этом сеансе инструменты, команды оболочки и изменения файлов могут выполняться без дополнительного подтверждения.", + "fullAccessUnsupported": "Этот провайдер не поддерживает запуск с полным доступом", "cancel": "Отмена", "creating": "Создание…", "create": "Создать", diff --git a/src/i18n/locales/tr/sidebar.json b/src/i18n/locales/tr/sidebar.json index 3c5b6acb..83a249a4 100644 --- a/src/i18n/locales/tr/sidebar.json +++ b/src/i18n/locales/tr/sidebar.json @@ -34,6 +34,10 @@ "sessionNamePlaceholder": "Oturum adı (harf ve rakamlar, ör. benim-ozelligim)", "workingDirectoryPlaceholder": "Çalışma klasörü (ör. ~/workspace/projem veya mutlak yol)", "recentPaths": "Son kullanılan yollar", + "fullAccess": "Tam erişim", + "fullAccessWarning": "Bu oturumu sağlayıcının doğrulanmış otomatik onay veya tam erişim modunda başlat", + "fullAccessActiveWarning": "Bu oturumda araçlar, kabuk komutları ve dosya değişiklikleri ek onay olmadan çalıştırılabilir.", + "fullAccessUnsupported": "Bu sağlayıcı tam erişimli başlatmayı desteklemiyor", "cancel": "İptal", "creating": "Oluşturuluyor…", "create": "Oluştur", diff --git a/src/i18n/locales/zh-CN/sidebar.json b/src/i18n/locales/zh-CN/sidebar.json index 4387d2a6..5508da08 100644 --- a/src/i18n/locales/zh-CN/sidebar.json +++ b/src/i18n/locales/zh-CN/sidebar.json @@ -34,6 +34,10 @@ "sessionNamePlaceholder": "会话名称(字母和数字,例如 my-feature)", "workingDirectoryPlaceholder": "工作文件夹(例如 ~/workspace/my-proj,也可使用绝对路径)", "recentPaths": "最近使用的路径", + "fullAccess": "全部允许", + "fullAccessWarning": "使用该提供商已验证的自动批准或完全访问模式启动此会话", + "fullAccessActiveWarning": "在此会话中,工具、Shell 命令和文件更改可能无需再次批准即可执行。", + "fullAccessUnsupported": "此提供商不支持以完全访问模式启动", "cancel": "取消", "creating": "正在创建…", "create": "创建", diff --git a/src/i18n/locales/zh-TW/sidebar.json b/src/i18n/locales/zh-TW/sidebar.json index cb90f2e3..36e340ee 100644 --- a/src/i18n/locales/zh-TW/sidebar.json +++ b/src/i18n/locales/zh-TW/sidebar.json @@ -34,6 +34,10 @@ "sessionNamePlaceholder": "工作階段名稱(英文字母和數字,例如 my-feature)", "workingDirectoryPlaceholder": "工作資料夾(例如 ~/workspace/my-proj,也可使用絕對路徑)", "recentPaths": "最近使用的路徑", + "fullAccess": "全部允許", + "fullAccessWarning": "使用此供應商已驗證的自動核准或完整存取模式啟動此工作階段", + "fullAccessActiveWarning": "在此工作階段中,工具、Shell 指令和檔案變更可能無需再次核准即可執行。", + "fullAccessUnsupported": "此供應商不支援以完整存取模式啟動", "cancel": "取消", "creating": "建立中…", "create": "建立", diff --git a/src/utils/api.js b/src/utils/api.js index 8eb99778..68a22295 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -189,10 +189,10 @@ export const api = { body: JSON.stringify({ tmux, process }), }), // Create a supported local coding-agent tmux session from the unified sessions tab. - externalCliSessionSpawn: (cli, name, cwd) => + externalCliSessionSpawn: (cli, name, cwd, fullAccess = false) => authenticatedFetch('/api/providers/sessions/external/spawn', { method: 'POST', - body: JSON.stringify({ name, cwd, cli }), + body: JSON.stringify({ name, cwd, cli, fullAccess }), }), externalCliSessionKill: (tmux, process, mode = 'process') => authenticatedFetch('/api/providers/sessions/external/kill', {