diff --git a/README.md b/README.md index 80faadf..6da1866 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,8 @@ NODE_ENV=production node server/index.js 新規セッションの既定アプリ・サンドボックス設定は `sandbox.config.json` (下記「設定ファイル」参照) でサーバー全体の初期値を決められますが、上記モーダルで一度でも明示的に選んだ後はブラウザ側の記憶が優先されます。 +**サーバーにインストールされていない CLI は選択できません**: ccserver は起動モーダル表示時にサーバー側の実行ファイル解決 (PATH・サーバーの node バイナリディレクトリ・`~/.local/bin`・アプリ別ディレクトリ) を確認し、見つからないアプリはグレーアウトされます (ツールチップ「サーバーに未インストール」)。既定アプリが未インストールの場合も、利用可能なアプリへ自動で切り替えます。何らかの経路で未インストールのアプリが指定された場合 (例: 予約プロンプトの自動再開)、サーバーは `Cannot launch: is not installed on this server (searched ...)` という明示エラーを返します。インストール/アンインストールした場合はブラウザを再読込すれば反映されます。 + opencode を選んだ場合の挙動の違い: - **クリップボード同期 (OSC 52)**: opencode がターミナルに書き込む OSC 52 シーケンスをブラウザが解釈し、システムクリップボードへ反映します (xterm.js は OSC 52 を無視するため、ccserver 側で処理)。 @@ -156,6 +158,7 @@ GitHub Copilot を選んだ場合: - 裏側では `claude --ax-screen-reader` を短時間起動して `/usage` の描画をパースし、結果を約 2 分キャッシュします (`/usage` の閲覧自体は API を消費しません)。「更新」ボタンで即時に再取得できます。 - bwrap がある環境では、**Claude の設定だけを見せる最小サンドボックス** (docker/gpg/ssh なし) で起動します。無ければ claude を直接起動します。 - API: `GET /api/usage` (`?force=1` で強制再取得)。サーバー起動時にキャッシュを 1 度ウォームします。 +- ボタンは設定ファイルの `showUsage: false` で非表示にできます。さらに **claude がサーバーにインストールされていない環境では、設定に関わらず自動的に非表示**になります (この場合 `GET /api/usage` は `claude is not installed on this server` を返します)。 ### コンボ起動: オーケストレーターが `send_input` でワーカーに指示するときの注意 @@ -259,6 +262,7 @@ cp server/sandbox.config.example.json server/sandbox.config.json "gitBroker": true, "forceSandbox": false, "defaultApp": "claude", + "showUsage": true, "notify": { "discordWebhook": "", "subscriptions": [] @@ -276,6 +280,7 @@ cp server/sandbox.config.example.json server/sandbox.config.json | `gitBroker` | `true` | git/gh の認証情報スコープ制限 (上記参照)。 | | `forceSandbox` | `false` | `true` でサンドボックス外の起動を全面禁止。エージェント・シェルを問わず全セッションがサンドボックス強制になり、UI のサンドボックス切替は無効化されます。bwrap が無い環境 (または Windows) では起動をエラーで拒否します (`/usage` 取得の直接起動フォールバックも同様に禁止)。ホストに bwrap (bubblewrap) のインストールが必須です。 | | `defaultApp` | `"claude"` | 新規セッションの既定エージェント (`"claude"`、`"opencode"`、`"copilot"`)。UI で一度明示的に選んだ後はブラウザの記憶が優先され、この値は初回表示時の見た目とサーバー側フォールバック (予約プロンプトの自動再開など、クライアントが `app` を指定しない経路) にのみ使われます。**コンボ起動のメンバーには適用されません** (グループ内では claude が既定で、copilot はそもそも選択不可)。 | +| `showUsage` | `true` | タブバー右端の Usage ボタン (Claude Code の `/usage`) を表示するか。`false` で非表示。**claude がサーバーに無い場合は設定に関わらず自動的に非表示**になります。 | | `binds` | `[]` | 追加で見せるホストパス。各要素 `{ src, mode?, dest? }`。`mode` は `ro` (既定) か `rw`。存在しないパスはスキップ。`~` はホームに展開。`~/.ssh` と `~/.config/gh` は `gitBroker` の設定に関わらず常にブロックされます。 | | `env` | `{}` | サンドボックス内の追加環境変数 (適用順は最後 = 既定値を上書き)。例: `sshAgent: true` のときに `SSH_AUTH_SOCK` を明示指定して自動検出を上書き。 | | `claudeBin` | 自動検出 | claude/opencode/copilot の起動方法。`claude` を PATH から解決し、ラッパー (例: `/usr/bin/claude` → `/opt/claude-code/bin/claude`) の場合は実体のインストール先を辿ってサンドボックスへ自動的に公開します。opencode は PATH に加えて `~/.opencode/bin` も自動探索。copilot は PATH (SANDBOX_PATH) で自動解決されます (通常 `~/.local/bin/copilot`)。自動検出で外れる場所にある場合や特定ビルドに固定したい場合のみ絶対パスで指定 (環境変数 `CCSERVER_CLAUDE_BIN` が優先。copilot に個別の bin 設定はありません)。 | diff --git a/client/src/App.jsx b/client/src/App.jsx index 7e4da41..a76e0d1 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -32,6 +32,11 @@ export default function App() { ); const pendingOpenRef = useRef(null); const { enabled: notifyEnabled, permission: notifyPermission, toggle: toggleNotify, notify } = useNotifications(); + // Server-side facts from /api/dirs/home: whether the Usage button is + // enabled (sandbox.config.json's "showUsage") and which agent CLIs are + // installed here (availableApps). Usage is only meaningful when claude + // exists, so a missing claude hides the button regardless of showUsage. + const [usagePrefs, setUsagePrefs] = useState({ showUsage: true, availableApps: null }); useEffect(() => { applyThemeCss(themeId); @@ -47,7 +52,14 @@ export default function App() { authFetch('/api/dirs/home') .then((res) => (res.ok ? res.json() : null)) .then((data) => { - if (data?.hostname) document.title = `${data.hostname} ccserver`; + if (!data) return; + if (data.hostname) document.title = `${data.hostname} ccserver`; + // Absent keys (older server / default config) keep the button shown + // and the app picker unrestricted. + setUsagePrefs({ + showUsage: data.showUsage !== false, + availableApps: data.availableApps || null, + }); }) .catch(() => {}); }, []); @@ -315,9 +327,16 @@ export default function App() { const activeTab = tabs.find((t) => t.id === activeTabId); // Usage (Claude spend) is only meaningful for claude sessions; hide it for // opencode/copilot terminals and for a group tab whose active sub-tab is - // opencode (copilot never appears in groups). + // opencode (copilot never appears in groups). The server can also hide it: + // sandbox.config.json's "showUsage": false, or claude not being installed + // at all (the /usage capture would never succeed). + // `availableApps` null/absent (fetch pending or failed, older server) means + // "unknown", not "claude missing" -- the button stays shown in that case and + // is only auto-hidden when the server actually reports claude absent. const usageHidden = (activeTab?.type === 'terminal' && (activeTab.app === 'opencode' || activeTab.app === 'copilot')) - || (activeTab?.type === 'group' && groupActiveApp === 'opencode'); + || (activeTab?.type === 'group' && groupActiveApp === 'opencode') + || !usagePrefs.showUsage + || (usagePrefs.availableApps && !usagePrefs.availableApps.claude); return (
diff --git a/client/src/components/DirectoryBrowser.jsx b/client/src/components/DirectoryBrowser.jsx index 1a6ced3..7579cad 100644 --- a/client/src/components/DirectoryBrowser.jsx +++ b/client/src/components/DirectoryBrowser.jsx @@ -57,6 +57,10 @@ export default function DirectoryBrowser({ onOpen, onOpenShell, onOpenCombo, onO // the sandbox toggle is overridden -- every launch is sandboxed and the // "通常起動" choice is disabled. Set from /api/dirs/home. const [forceSandbox, setForceSandbox] = useState(false); + // Which agent CLIs the server can actually launch ({ claude, opencode, + // copilot } booleans), from /api/dirs/home. null until the fetch resolves; + // while null every picker entry stays enabled (old-server fallback). + const [availableApps, setAvailableApps] = useState(null); // 'claude' until the server's configured default (sandbox.config.json's // "defaultApp") arrives via /api/dirs/home, or the user picks explicitly. const [appDefault, setAppDefault] = useState(() => { @@ -96,9 +100,10 @@ export default function DirectoryBrowser({ onOpen, onOpenShell, onOpenCombo, onO }, [forceSandbox]); const chooseApp = useCallback((val) => { + if (availableApps && !availableApps[val]) return; // server lacks this CLI setAppDefault(val); localStorage.setItem(APP_KEY, val); - }, []); + }, [availableApps]); // gpg/sshAgent are remembered per directory, not globally -- reload whenever // the browser navigates to a different one. @@ -150,6 +155,38 @@ export default function DirectoryBrowser({ onOpen, onOpenShell, onOpenCombo, onO setForceSandbox(true); setSandboxDefault(true); } + // Server-side install detection: grey out picker entries for CLIs that + // don't exist here, and correct a stale default (localStorage + // ccserver-app-default, or the server's defaultApp) that points at an + // uninstalled app -- the launch button label and modal checkmark must + // never advertise an app that cannot start. + if (data.availableApps) { + setAvailableApps(data.availableApps); + const avail = ['claude', 'opencode', 'copilot'].filter((a) => data.availableApps[a]); + // The server's defaultApp seeding above runs in the same effect tick, + // so appDefault is still the stale pre-seeding value here -- evaluate + // the effective default (server's when the browser hasn't chosen yet, + // else the remembered one) before testing availability. + const effectiveDefault = ['claude', 'opencode', 'copilot'].includes(data.defaultApp) && !localStorage.getItem(APP_KEY) + ? data.defaultApp + : appDefault; + if (avail.length > 0 && !data.availableApps[effectiveDefault]) { + setAppDefault(avail[0]); + } + // Same rule for the combo modal's role selections: workerA and the + // orchestrator start as claude, workerB as opencode -- a role whose + // default points at a missing CLI must not stay selected-active (the + // launch would be refused server-side). Combo only offers + // claude/opencode, so the fallback is restricted to those. + const comboAvail = ['claude', 'opencode'].filter((a) => data.availableApps[a]); + if (comboAvail.length > 0) { + setComboApps((c) => ({ + workerA: data.availableApps[c.workerA] ? c.workerA : comboAvail[0], + workerB: data.availableApps[c.workerB] ? c.workerB : comboAvail[0], + orchestrator: data.availableApps[c.orchestrator] ? c.orchestrator : comboAvail[0], + })); + } + } }).catch(() => {}); }, []); @@ -432,22 +469,25 @@ export default function DirectoryBrowser({ onOpen, onOpenShell, onOpenCombo, onO <>
アプリ
chooseApp('claude')} + title={availableApps && !availableApps.claude ? 'サーバーに未インストール' : ''} > {appDefault === 'claude' ? '✓' : ''} Claude Code
chooseApp('opencode')} + title={availableApps && !availableApps.opencode ? 'サーバーに未インストール' : ''} > {appDefault === 'opencode' ? '✓' : ''} opencode
chooseApp('copilot')} + title={availableApps && !availableApps.copilot ? 'サーバーに未インストール' : ''} > {appDefault === 'copilot' ? '✓' : ''} GitHub Copilot @@ -505,8 +545,9 @@ export default function DirectoryBrowser({ onOpen, onOpenShell, onOpenCombo, onO {['claude', 'opencode'].map((app) => ( @@ -529,8 +570,9 @@ export default function DirectoryBrowser({ onOpen, onOpenShell, onOpenCombo, onO {['claude', 'opencode'].map((app) => ( @@ -612,8 +654,9 @@ export default function DirectoryBrowser({ onOpen, onOpenShell, onOpenCombo, onO {['claude', 'opencode'].map((app) => ( diff --git a/server/routes/dirs.js b/server/routes/dirs.js index 6b3f1e9..764332e 100644 --- a/server/routes/dirs.js +++ b/server/routes/dirs.js @@ -1,16 +1,20 @@ import { readdir, mkdir, stat } from 'node:fs/promises'; import { join, resolve, basename } from 'node:path'; import { homedir } from 'node:os'; -import { loadSandboxConfig } from '../ws/sandbox.js'; +import { loadSandboxConfig, installedApps } from '../ws/sandbox.js'; import { resolvedHostname } from '../ws/notify.js'; export async function dirsRoute(fastify, opts) { fastify.get('/dirs/home', async () => { - const { defaultApp, forceSandbox } = loadSandboxConfig(); + const { defaultApp, forceSandbox, showUsage } = loadSandboxConfig(); // hostname for the browser tab title (" ccserver"): the same // resolution the notify footer uses, so the tab matches _from: . // Extra field, so existing clients are unaffected. - return { home: homedir(), defaultApp, forceSandbox, hostname: resolvedHostname() }; + // showUsage / availableApps: the client's Usage button visibility and the + // launch modal's app picker both need server-side facts -- whether the + // Usage button is enabled in config, and which agent CLIs are installed + // here. Both are extra fields, so existing clients are unaffected. + return { home: homedir(), defaultApp, forceSandbox, hostname: resolvedHostname(), showUsage, availableApps: installedApps() }; }); fastify.get('/dirs', async (request, reply) => { diff --git a/server/sandbox.config.example.json b/server/sandbox.config.example.json index 0284998..008664a 100644 --- a/server/sandbox.config.example.json +++ b/server/sandbox.config.example.json @@ -27,6 +27,9 @@ "//defaultApp": "Which agent CLI a new session launches when the client doesn't request one (legacy scheduled prompts, or a browser tab that hasn't picked one yet). 'claude' (default), 'opencode' or 'copilot'. The launch UI's own choice (remembered per browser) always wins once made; this only seeds that choice on first load and covers server-side fallback paths. Combo (group) members never use it — groups default to claude and refuse copilot.", "defaultApp": "claude", + "//showUsage": "true (default): the client shows the top-bar Usage button (Claude Code /usage spend). false: hide it entirely. The button is also hidden automatically when claude is not installed on this server (the /usage capture could never succeed).", + "showUsage": true, + "//notify": "ccserver-notify MCP (通知用, 下記 README)。discordWebhook: https:// の Discord webhook URL (空文字で無効)。設定するとエージェントの notify ツールが Discord へ通知を送れる。CCSERVER_DISCORD_WEBHOOK 環境変数で上書き可。subscriptions: 初期購読 (bootstrap)。https:// の webhook URL の配列。購読ゼロかつ discordWebhook 未設定なら MCP は注入されない。hostname: 通知フッターのホスト名を固定したい場合のみ (未設定なら OS の hostname、CCSERVER_HOSTNAME 環境変数が最優先)。attribution: false にすると通知ペイロード末尾の \"_from: host · project · group · session\" フッターを丸ごと無効化 (既定 true)。", "notify": { "discordWebhook": "", diff --git a/server/usage.js b/server/usage.js index 1c6f339..6640cdd 100644 --- a/server/usage.js +++ b/server/usage.js @@ -157,7 +157,16 @@ function looksReady(parsed) { function capture() { return new Promise((resolve) => { - let command = resolveClaude().command; + // claude not installed on this host (or claudeBin pointing at a missing + // path): a pty.spawn would just fail with execvp/ENOENT. Report the real + // cause up front -- this also backs the client's automatic Usage-button + // hiding (availableApps.claude === false via /dirs/home). + const resolvedClaude = resolveClaude(); + if (resolvedClaude.found === false) { + resolve({ error: 'claude is not installed on this server' }); + return; + } + let command = resolvedClaude.command; let args = ['--ax-screen-reader']; let spawnCwd = homedir(); let sandboxed = false; diff --git a/server/ws/sandbox-config.test.js b/server/ws/sandbox-config.test.js index fd09f76..ade39b1 100644 --- a/server/ws/sandbox-config.test.js +++ b/server/ws/sandbox-config.test.js @@ -68,6 +68,27 @@ test('defaultApp accepts copilot and falls back to claude for anything else', () }); }); +test('showUsage defaults to true when the key is absent', () => { + withConfig({ docker: true }, () => { + assert.equal(loadSandboxConfig().showUsage, true); + }); +}); + +test('showUsage is false only for an explicit false value', () => { + withConfig({ showUsage: false }, () => { + assert.equal(loadSandboxConfig().showUsage, false); + }); + withConfig({ showUsage: true }, () => { + assert.equal(loadSandboxConfig().showUsage, true); + }); + withConfig({ showUsage: 'no' }, () => { + assert.equal(loadSandboxConfig().showUsage, true); + }); + withConfig({}, () => { + assert.equal(loadSandboxConfig().showUsage, true); + }); +}); + // ccserver-notify config (see notify.js): the Discord webhook is parsed only // when it is an https:// URL; anything else is dropped. The env override // CCSERVER_DISCORD_WEBHOOK wins over the config file. diff --git a/server/ws/sandbox-resolve.test.js b/server/ws/sandbox-resolve.test.js index a0ae4a3..0069775 100644 --- a/server/ws/sandbox-resolve.test.js +++ b/server/ws/sandbox-resolve.test.js @@ -2,7 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; -import { resolveApp, SANDBOX_PATH } from './sandbox.js'; +import { resolveApp, installedApps, SANDBOX_PATH } from './sandbox.js'; // which() (used by resolveApp/resolveAgentCommand) resolves against // SANDBOX_PATH -- a fixed constant, not the calling process's own PATH -- so @@ -52,3 +52,27 @@ test('resolveApp keeps the bare command name when SANDBOX_PATH resolves it', { s assert.ok(['claude', 'claude.exe'].includes(r.command), `expected bare name, got: ${r.command}`); } }); + +// found: an installed app must report found: true (the launch is a real +// binary), while a genuinely missing one reports found: false with only the +// fallback bare command name -- the signal the server uses to refuse the +// launch and the client to grey out the picker entry. +test('resolveApp reports found: true for an installed app', { skip: !isInstalled('claude') }, () => { + assert.equal(resolveApp('claude').found, true); +}); + +test('resolveApp reports found: false when the app is genuinely missing', { skip: isInstalled('claude') }, () => { + const r = resolveApp('claude'); + assert.equal(r.found, false); + assert.equal(typeof r.command, 'string', 'the fallback bare name is still returned'); +}); + +// installedApps() must agree with resolveApp on every app id -- host- and +// install-state-independent (it is a pure mirror of the per-app resolution). +test('installedApps mirrors resolveApp found flags for all three apps', () => { + const installed = installedApps(); + assert.deepEqual(Object.keys(installed).sort(), ['claude', 'copilot', 'opencode']); + for (const app of ['claude', 'opencode', 'copilot']) { + assert.equal(installed[app], resolveApp(app).found, `${app} flag must match resolveApp`); + } +}); diff --git a/server/ws/sandbox.js b/server/ws/sandbox.js index 547de9a..872da0f 100644 --- a/server/ws/sandbox.js +++ b/server/ws/sandbox.js @@ -166,7 +166,11 @@ export function loadSandboxConfig() { // See appLaunch.js's APPS; anything else (including unset) falls back to // claude -- see sessionManager.js's defaultApp(). const defaultApp = raw.defaultApp === 'opencode' || raw.defaultApp === 'copilot' ? raw.defaultApp : 'claude'; - return { docker, gpg, sshAgent, gitBroker, forceSandbox, binds, env, claudeBin, defaultApp, notify: { discordWebhook, subscriptions, hostname: notifyHostname, attribution: notifyAttribution }, configPath }; + // Show the client's top-bar Usage button (Claude Code /usage spend). Off + // for setups that don't want it; the client also hides the button on its + // own when claude is not installed (the capture would never succeed). + const showUsage = raw.showUsage !== false; + return { docker, gpg, sshAgent, gitBroker, forceSandbox, binds, env, claudeBin, defaultApp, showUsage, notify: { discordWebhook, subscriptions, hostname: notifyHostname, attribution: notifyAttribution }, configPath }; } // Locate an executable named `cmd` on the given PATH (or return it as-is if @@ -255,6 +259,11 @@ function resolveAgentCommand(cmd, extraDirs = []) { // that must be exposed read-only in the sandbox for that invocation to work. // command - argv[0] to run // installDir - extra ro-bind so the resolved binary is present, or null +// found - whether the CLI actually resolved somewhere searchable. When +// false, `command` is only a fallback bare name that will fail +// at spawn time (execvp ENOENT) -- callers should refuse the +// launch up front instead (see sessionManager's not-installed +// error and installedApps()). // // claude: "claude" (so the sandbox's PATH resolves it) unless overridden via // CCSERVER_CLAUDE_BIN / "claudeBin" in the sandbox config. @@ -269,21 +278,34 @@ export function resolveApp(app, configuredBin = loadSandboxConfig().claudeBin) { if (r) { let real = r.path; try { real = realpathSync(r.path); } catch { /* keep as given */ } - return { command: real, installDir: appInstallDir(real) }; + return { command: real, installDir: appInstallDir(real), found: true }; } - return { command: process.platform === 'win32' ? 'opencode.exe' : 'opencode', installDir: null }; + return { command: process.platform === 'win32' ? 'opencode.exe' : 'opencode', installDir: null, found: false }; } if (app === 'copilot') { const r = resolveAgentCommand('copilot', [join(HOME, '.local', 'bin')]); - if (r) return { command: r.command, installDir: appInstallDir(r.path) }; - return { command: process.platform === 'win32' ? 'copilot.exe' : 'copilot', installDir: null }; + if (r) return { command: r.command, installDir: appInstallDir(r.path), found: true }; + return { command: process.platform === 'win32' ? 'copilot.exe' : 'copilot', installDir: null, found: false }; } const command = configuredBin || (process.platform === 'win32' ? 'claude.exe' : 'claude'); const r = resolveAgentCommand(command); // Keep the bare name when PATH resolves it (the sandbox PATH can too); use // an absolute path for installs PATH can't see (e.g. systemd). - if (r) return { command: r.command, installDir: appInstallDir(r.path) }; - return { command, installDir: null }; + if (r) return { command: r.command, installDir: appInstallDir(r.path), found: true }; + return { command, installDir: null, found: false }; +} + +// Which agent CLIs are actually launchable on this host, keyed by app id. +// Exposed via GET /dirs/home so the client can grey out (and the server can +// refuse) launches of uninstalled apps. A few statSync calls per request -- +// recomputed every call, no caching needed. claude respects the claudeBin +// override (resolveApp does), so a configured but missing path reads false. +export function installedApps() { + return { + claude: resolveApp('claude').found, + opencode: resolveApp('opencode').found, + copilot: resolveApp('copilot').found, + }; } // Backwards-compatible alias used by the claude-only /usage capture. diff --git a/server/ws/sessionManager.js b/server/ws/sessionManager.js index 3cfc3e0..9dbf97b 100644 --- a/server/ws/sessionManager.js +++ b/server/ws/sessionManager.js @@ -121,6 +121,28 @@ export function createSession({ cwd, cols, rows, claudeSessionId, shell, sandbox // Which agent CLI this session runs. Shell sessions have no app. const sessionApp = shell ? null : (isValidApp(app) ? app : defaultApp()); + const resolved = sessionApp ? resolveApp(sessionApp) : null; + + // Refuse launches of an agent that doesn't exist on this host, instead of + // letting node-pty fail with an opaque execvp/ENOENT error (exit 127) right + // after the "起動しました" message. resolveApp's `found` covers every search + // path (PATH, the server's node bin dir, ~/.local/bin, and the app-specific + // extras) and honors the claudeBin override; the searched-dirs text mirrors + // resolveAgentCommand's candidates. A defaultApp pointing at a missing + // install is refused the same way: silently switching to another app would + // start scheduled prompts / orchestrator restarts in an unintended agent. + if (sessionApp && !resolved.found) { + const searched = { + claude: "PATH, the server's node bin directory, ~/.local/bin", + opencode: "PATH, the server's node bin directory, ~/.local/bin, ~/.opencode/bin", + copilot: "PATH, the server's node bin directory, ~/.local/bin", + }[sessionApp]; + return { + sessionId: id, + session: null, + error: `Cannot launch: ${sessionApp} is not installed on this server (searched ${searched}).`, + }; + } // Which model this session launches with. Explicit null / absent means "use // the app's persisted-or-default model" (no --model flag is emitted); only a // non-empty string becomes a CLI model selection. Shells never carry one. @@ -167,7 +189,7 @@ export function createSession({ cwd, cols, rows, claudeSessionId, shell, sandbox command = process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : '/bin/bash'); args = []; } else { - command = resolveApp(sessionApp).command; + command = resolved.command; args = appResumeArgs(sessionApp, claudeSessionId, { resumeLast }); // Model selection must accompany fresh launches and resume alike; the // helper only emits the flag for apps whose CLI is verified to accept it. diff --git a/server/ws/sessionManager.test.js b/server/ws/sessionManager.test.js index 0bcf74e..11030ba 100644 --- a/server/ws/sessionManager.test.js +++ b/server/ws/sessionManager.test.js @@ -120,6 +120,35 @@ test('createSession stores the effective model (normalized); shells never carry } }); +// A configured claudeBin that resolves nowhere (a bare name on no searched +// dir) must be refused with the clear not-installed error instead of reaching +// pty.spawn (opaque execvp ENOENT / exit 127 right after "起動しました"). +// Deterministic: CCSERVER_CLAUDE_BIN overrides the config file, and no real +// CLI install is needed. +test('createSession refuses an uninstalled agent with a clear error', () => { + const cfgDir = mkdtempSync(join(tmpdir(), 'ccserver-sess-cfg-')); + const cfgPath = join(cfgDir, 'sandbox.config.json'); + writeFileSync(cfgPath, JSON.stringify({ docker: false, gitBroker: false })); + const prevBin = process.env.CCSERVER_CLAUDE_BIN; + const prevCfg = process.env.CCSERVER_SANDBOX_CONFIG; + process.env.CCSERVER_CLAUDE_BIN = 'no-such-claude-xyz'; + process.env.CCSERVER_SANDBOX_CONFIG = cfgPath; + try { + const res = sessionManager.createSession({ + cwd: '/tmp', cols: 80, rows: 24, shell: false, sandbox: false, app: 'claude', + }); + assert.equal(res.session, null, 'no session may be created for a missing CLI'); + assert.match(res.error, /claude is not installed on this server/); + assert.match(res.error, /searched PATH/, 'the error names the search targets'); + } finally { + if (prevBin === undefined) delete process.env.CCSERVER_CLAUDE_BIN; + else process.env.CCSERVER_CLAUDE_BIN = prevBin; + if (prevCfg === undefined) delete process.env.CCSERVER_SANDBOX_CONFIG; + else process.env.CCSERVER_SANDBOX_CONFIG = prevCfg; + try { rmSync(cfgDir, { recursive: true, force: true }); } catch { /* ignore */ } + } +}); + // setScheduledPrompt captures the session's launch model into the persisted // schedule entry so the auto-resume path replays it (persistSchedules). test('setScheduledPrompt persists the session model into the schedule file', async () => {