Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ NODE_ENV=production node server/index.js

サンドボックス・GPG・ssh-agent の詳細は [サンドボックス (bwrap + rootless docker)](#サンドボックス-bwrap--rootless-docker) を参照。「アプリ」と「起動モード」のどちらの項目をクリックしても、選んだ内容で即座に起動します。

コンボ起動のロール別アプリ選択 (ワーカーA / ワーカーB / オーケストレーター) もブラウザの `localStorage` に記憶され、次回のコンボ起動の既定になります (初期値: ワーカーA・オーケストレーターが Claude Code、ワーカーB が opencode)。単発起動の「アプリ」記憶とは独立しており、コンボ起動には `defaultApp` は適用されません。

新規セッションの既定アプリ・サンドボックス設定は `sandbox.config.json` (下記「設定ファイル」参照) でサーバー全体の初期値を決められますが、上記モーダルで一度でも明示的に選んだ後はブラウザ側の記憶が優先されます。

**サーバーにインストールされていない CLI は選択できません**: ccserver は起動モーダル表示時にサーバー側の実行ファイル解決 (PATH・サーバーの node バイナリディレクトリ・`~/.local/bin`・アプリ別ディレクトリ) を確認し、見つからないアプリはグレーアウトされます (ツールチップ「サーバーに未インストール」)。既定アプリが未インストールの場合も、利用可能なアプリへ自動で切り替えます。何らかの経路で未インストールのアプリが指定された場合 (例: 予約プロンプトの自動再開)、サーバーは `Cannot launch: <app> is not installed on this server (searched ...)` という明示エラーを返します。インストール/アンインストールした場合はブラウザを再読込すれば反映されます。
Expand Down Expand Up @@ -279,7 +281,7 @@ cp server/sandbox.config.example.json server/sandbox.config.json
| `sshAgent` | `false` | ssh-agent を転送 (上記参照)。UI で上書き可。 |
| `gitBroker` | `true` | git/gh の認証情報スコープ制限 (上記参照)。 |
| `forceSandbox` | `false` | `true` でサンドボックス外の起動を全面禁止。エージェント・シェルを問わず全セッションがサンドボックス強制になり、UI のサンドボックス切替は無効化されます。bwrap が無い環境 (または Windows) では起動をエラーで拒否します (`/usage` 取得の直接起動フォールバックも同様に禁止)。ホストに bwrap (bubblewrap) のインストールが必須です。 |
| `defaultApp` | `"claude"` | 新規セッションの既定エージェント (`"claude"`、`"opencode"`、`"copilot"`)。UI で一度明示的に選んだ後はブラウザの記憶が優先され、この値は初回表示時の見た目とサーバー側フォールバック (予約プロンプトの自動再開など、クライアントが `app` を指定しない経路) にのみ使われます。**コンボ起動のメンバーには適用されません** (グループ内では claude が既定で、copilot はそもそも選択不可)。 |
| `defaultApp` | `"claude"` | 新規セッションの既定エージェント (`"claude"`、`"opencode"`、`"copilot"`)。UI で一度明示的に選んだ後はブラウザの記憶が優先され、この値は初回表示時の見た目とサーバー側フォールバック (予約プロンプトの自動再開など、クライアントが `app` を指定しない経路) にのみ使われます。**コンボ起動のメンバーには適用されません** (コンボのロール別選択は別途ブラウザの `localStorage` に記憶され、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` を明示指定して自動検出を上書き。 |
Expand Down
60 changes: 51 additions & 9 deletions client/src/components/DirectoryBrowser.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,31 @@ const LAST_DIR_KEY = 'ccserver-last-dir';
const SANDBOX_KEY = 'ccserver-sandbox-default';
const SANDBOX_OPTS_PREFIX = 'ccserver-sandbox-opts:';
const APP_KEY = 'ccserver-app-default';
const COMBO_APPS_KEY = 'ccserver-combo-apps';
const COMBO_ROLES = ['workerA', 'workerB', 'orchestrator'];
const COMBO_DEFAULT_APPS = { workerA: 'claude', workerB: 'opencode', orchestrator: 'claude' };

// Combo-mode role app picks, remembered per browser like the single-launch
// APP_KEY so the next combo launch reuses them instead of the claude/
// opencode defaults. Only claude/opencode are valid choices; anything else
// falls back to the per-role default.
function loadComboApps() {
try {
const raw = localStorage.getItem(COMBO_APPS_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
const out = {};
for (const role of COMBO_ROLES) {
const v = parsed[role];
out[role] = v === 'claude' || v === 'opencode' ? v : COMBO_DEFAULT_APPS[role];
}
return out;
}
}
} catch { /* ignore */ }
return { ...COMBO_DEFAULT_APPS };
}

// Per-directory opt-in sandbox flags (gpg / sshAgent), remembered separately
// per cwd rather than as one server-wide default -- see server/sandbox.config.json's
Expand Down Expand Up @@ -69,7 +94,7 @@ export default function DirectoryBrowser({ onOpen, onOpenShell, onOpenCombo, onO
});
const [openMenuOpen, setOpenMenuOpen] = useState(false);
const [launchMode, setLaunchMode] = useState('single'); // 'single' | 'combo'
const [comboApps, setComboApps] = useState({ workerA: 'claude', workerB: 'opencode', orchestrator: 'claude' });
const [comboApps, setComboApps] = useState(() => loadComboApps());
// Free-form per-role model identifiers; empty string = omitted (server uses
// the persisted role preference, then the app default). null would mean
// "explicitly the app default", which the text input doesn't produce -- an
Expand All @@ -85,6 +110,9 @@ export default function DirectoryBrowser({ onOpen, onOpenShell, onOpenCombo, onO
// otherwise the user's next "起動" -- possibly for a different project --
// would silently fire a full combo spawn with the previous instructions.
// One close path for every exit route so future routes can't forget.
// (The role app picks in comboApps are the exception: they are remembered
// in localStorage, see loadComboApps/chooseComboApp, and intentionally
// survive both modal closes and reloads.)
const closeOpenMenu = useCallback(() => {
setLaunchMode('single');
setOrchestratorInstructions('');
Expand All @@ -105,6 +133,15 @@ export default function DirectoryBrowser({ onOpen, onOpenShell, onOpenCombo, onO
localStorage.setItem(APP_KEY, val);
}, [availableApps]);

const chooseComboApp = useCallback((role, app) => {
if (availableApps && !availableApps[app]) return; // server lacks this CLI
setComboApps((c) => {
const next = { ...c, [role]: app };
localStorage.setItem(COMBO_APPS_KEY, JSON.stringify(next));
return next;
});
}, [availableApps]);

// gpg/sshAgent are remembered per directory, not globally -- reload whenever
// the browser navigates to a different one.
useEffect(() => {
Expand Down Expand Up @@ -180,11 +217,16 @@ export default function DirectoryBrowser({ onOpen, onOpenShell, onOpenCombo, onO
// 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],
}));
setComboApps((c) => {
const next = {
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],
};
// Write the corrected picks through so a reload keeps them.
localStorage.setItem(COMBO_APPS_KEY, JSON.stringify(next));
return next;
});
}
}
}).catch(() => {});
Expand Down Expand Up @@ -546,7 +588,7 @@ export default function DirectoryBrowser({ onOpen, onOpenShell, onOpenCombo, onO
<button
key={app}
className={`open-menu-app-btn${comboApps.workerA === app ? ' active' : ''}${availableApps && !availableApps[app] ? ' open-menu-item-disabled' : ''}`}
onClick={() => { if (!availableApps || availableApps[app]) setComboApps((c) => ({ ...c, workerA: app })); }}
onClick={() => chooseComboApp('workerA', app)}
title={availableApps && !availableApps[app] ? 'サーバーに未インストール' : ''}
>
{app === 'claude' ? 'Claude Code' : 'opencode'}
Expand All @@ -571,7 +613,7 @@ export default function DirectoryBrowser({ onOpen, onOpenShell, onOpenCombo, onO
<button
key={app}
className={`open-menu-app-btn${comboApps.workerB === app ? ' active' : ''}${availableApps && !availableApps[app] ? ' open-menu-item-disabled' : ''}`}
onClick={() => { if (!availableApps || availableApps[app]) setComboApps((c) => ({ ...c, workerB: app })); }}
onClick={() => chooseComboApp('workerB', app)}
title={availableApps && !availableApps[app] ? 'サーバーに未インストール' : ''}
>
{app === 'claude' ? 'Claude Code' : 'opencode'}
Expand Down Expand Up @@ -655,7 +697,7 @@ export default function DirectoryBrowser({ onOpen, onOpenShell, onOpenCombo, onO
<button
key={app}
className={`open-menu-app-btn${comboApps.orchestrator === app ? ' active' : ''}${availableApps && !availableApps[app] ? ' open-menu-item-disabled' : ''}`}
onClick={() => { if (!availableApps || availableApps[app]) setComboApps((c) => ({ ...c, orchestrator: app })); }}
onClick={() => chooseComboApp('orchestrator', app)}
title={availableApps && !availableApps[app] ? 'サーバーに未インストール' : ''}
>
{app === 'claude' ? 'Claude Code' : 'opencode'}
Expand Down
18 changes: 18 additions & 0 deletions server/ws/sandbox-resolve.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,24 @@ test('resolveApp reports found: false when the app is genuinely missing', { skip
assert.equal(typeof r.command, 'string', 'the fallback bare name is still returned');
});

// The configured-claudeBin variant of the above: an absolute path pointing at
// a removed CLI (stale "claudeBin" in sandbox.config.json, or
// CCSERVER_CLAUDE_BIN) must read found: false just like a bare name no PATH
// dir resolves -- the availability detection depends on it. Deterministic:
// the env override runs on every host, no claude install needed.
test('resolveApp reports found: false for a configured claudeBin that does not exist', () => {
const prevBin = process.env.CCSERVER_CLAUDE_BIN;
process.env.CCSERVER_CLAUDE_BIN = '/no/such/claude-xyz';
try {
const r = resolveApp('claude');
assert.equal(r.found, false, 'a path-form claudeBin that does not exist must read as not found');
assert.equal(r.command, '/no/such/claude-xyz', 'the configured bin is kept as the fallback command');
} finally {
if (prevBin === undefined) delete process.env.CCSERVER_CLAUDE_BIN;
else process.env.CCSERVER_CLAUDE_BIN = prevBin;
}
});

// 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', () => {
Expand Down
12 changes: 11 additions & 1 deletion server/ws/sandbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,17 @@ export function loadSandboxConfig() {
// the command once launched.
function which(cmd, pathEnv = SANDBOX_PATH) {
if (!cmd) return null;
if (cmd.includes('/')) return cmd;
// A path-form command (e.g. a configured claudeBin like "/usr/bin/claude")
// must still exist and be executable on the host -- a stale config pointing
// at a removed CLI would otherwise read as "found" and defeat the
// availability detection that greys it out client-side.
if (cmd.includes('/')) {
try {
const st = statSync(cmd);
if (st.isFile() && (st.mode & 0o111)) return cmd;
} catch { /* not here */ }
return null;
}
for (const dir of (pathEnv || '').split(':')) {
if (!dir) continue;
const p = join(dir, cmd);
Expand Down
55 changes: 55 additions & 0 deletions tests/launch-modal.spec.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { test, expect } from '@playwright/test';
import { resolveApp, SANDBOX_PATH } from '../server/ws/sandbox.js';

// The launch modal's combo-mode state must not persist across closes: a
// user who glances at combo mode, cancels, and later opens the modal for a
Expand Down Expand Up @@ -50,3 +53,55 @@ test('launch modal resets to single mode after an overlay-click close', async ({
await page.getByRole('button', { name: '起動方法を選択' }).click();
await expect.poll(() => page.locator('.launch-mode-btn.active').textContent()).toBe('通常起動');
});

// Combo mode's per-role app picks are remembered in localStorage
// (ccserver-combo-apps): they survive modal closes and full reloads, so the
// next combo launch starts with the previous selections instead of the
// claude/opencode defaults.
function appResolves(app) {
try {
const r = resolveApp(app).command;
if (r.startsWith('/')) return existsSync(r);
return SANDBOX_PATH.split(':').some((dir) => dir && existsSync(join(dir, r)));
} catch {
return false;
}
}

// The test switches every role to opencode, so it only makes sense where
// opencode is actually installable (plain CI runners have neither CLI).
test.skip(!appResolves('opencode'), 'opencode not installed — nothing to persist');

test('combo role app picks persist across a reload', async ({ page }) => {
await page.goto('/');
const openLaunchModal = async () => {
await page.getByRole('button', { name: '起動方法を選択' }).click();
};
const roleAppRow = (label) =>
page.locator('.open-menu-label', { hasText: label }).locator('xpath=following-sibling::div[1]');
const activeApp = (label) => roleAppRow(label).locator('.open-menu-app-btn.active');

await openLaunchModal();
await page.locator('.resume-dialog .launch-mode-btn', { hasText: 'コンボ起動' }).click();
await expect.poll(() => page.locator('.launch-mode-btn.active').textContent()).toBe('コンボ起動');

// Switch every role to opencode.
for (const label of ['ワーカーA', 'ワーカーB', 'オーケストレーター']) {
await roleAppRow(label).getByRole('button', { name: 'opencode', exact: true }).click();
await expect(activeApp(label)).toHaveText('opencode');
}

// A plain modal close must not forget the picks...
await page.getByRole('button', { name: 'キャンセル' }).click();
await expect(page.locator('.resume-overlay')).toHaveCount(0);

// ...and neither must a full reload: the next combo launch reopens with
// all three roles still on opencode.
await page.reload();
await openLaunchModal();
await page.locator('.resume-dialog .launch-mode-btn', { hasText: 'コンボ起動' }).click();
await expect.poll(() => page.locator('.launch-mode-btn.active').textContent()).toBe('コンボ起動');
for (const label of ['ワーカーA', 'ワーカーB', 'オーケストレーター']) {
await expect(activeApp(label)).toHaveText('opencode');
}
});
Loading