mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
SessionMode gains 'pi', a first-class backend alongside Claude Code, OpenCode, Codex, Gemini and Antigravity: its own PTY, tmux session, rose tab identity, welcome button, run-mode entry, cron agentType, Docker and remote-SSH command defaults, and clone-repo Brain option. Pi is a different shape of CLI from the other four, and three decisions follow from that: - It has NO permission prompts and no sandbox, so there is no --dangerously-skip-permissions analog and none was invented. The privilege-shaped knob is the tri-state approveProjectTrust, which makes pi load and EXECUTE repo-local .pi/extensions TypeScript and install missing project packages. clampExternalCliBypassForOwner() therefore puts pi in the MATERIALIZE branch: a non-granted multi-user owner gets --no-approve even when no config was sent, because pi's own default is a prompt the session user could answer themselves. That helper had zero test coverage; it now has coverage for all four CLIs. - Only the PI_ prefix joins the env allowlist. Pi's ~34 provider key vars share no prefix and ALLOWED_ENV_PREFIXES is one global list with no mode context, so admitting them would widen the allowlist for every mode at once. Auth goes through pi's /login or the server's own environment. --api-key is deliberately never wired: it would put a provider secret on the spawn command line. - pi stays OUT of isAltScreenStripMode(). Its default TUI renders into the main screen with terminal-owned scrollback, and its 0.84.0 fullscreen mode is runtime-switchable via /settings; that flip was measured to put the pane into the alt screen, which the strip would have corrupted. pi-cli-resolver.ts additionally sanity-probes `pi --version` and requires semver-shaped output, because `pi` is a short generic name a stray binary can shadow; GET /api/pi/status surfaces path and version so a misresolution is diagnosable rather than presenting as a broken mode. Docker installs pi in its own --ignore-scripts step so that flag cannot affect the other four CLIs, and seeds its credentials per-file rather than whole-dir (~/.pi/agent also holds sessions, extensions and package trees). Verified end to end against pi 0.84.1 on an isolated instance: resolver search-dir fallback, flag construction, piConfig persistence across a full server restart, the trust prompt and its --no-approve suppression, the rose Run button on the default daylight-blue skin (the nested skin block eats per-mode gradients unless the rule lives inside it), and the buffer local-echo policy, which pi tolerates where codex did not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
197 lines
8.3 KiB
TypeScript
197 lines
8.3 KiB
TypeScript
/**
|
|
* WebServer.renderIndexHtml — server-side gating of the index shell:
|
|
* - multi-monitor button reveal (stable class-marker, not brittle copy match)
|
|
* - solo (/session/:id) global injection + escaping, and settings skipped
|
|
* - gesture overlay availability vs. enablement (CODEMAN_GESTURE + setting)
|
|
* - settings read FRESH so a post-save reload doesn't render stale state
|
|
*
|
|
* WebServer's constructor only assigns fields (no port bind), so we construct it
|
|
* directly, swap in a tiny indexHtmlTemplate, and stub readSettings to avoid disk.
|
|
*
|
|
* Port: N/A (no server start).
|
|
*/
|
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
import { WebServer } from '../src/web/server.js';
|
|
import { isClaudeAvailable } from '../src/utils/claude-cli-resolver.js';
|
|
import { isOpenCodeAvailable } from '../src/utils/opencode-cli-resolver.js';
|
|
import { isCodexAvailable } from '../src/utils/codex-cli-resolver.js';
|
|
import { isGeminiAvailable } from '../src/utils/gemini-cli-resolver.js';
|
|
import { isAntigravityAvailable } from '../src/utils/antigravity-cli-resolver.js';
|
|
import { isPiAvailable } from '../src/utils/pi-cli-resolver.js';
|
|
import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js';
|
|
import { isGitAvailable } from '../src/git-clone.js';
|
|
|
|
// renderIndexHtml probes the real PATH for every CLI, which would make the
|
|
// assertions below depend on whatever happens to be installed on the machine
|
|
// running the suite. Default them all to "not installed" and opt in per test.
|
|
vi.mock('../src/utils/claude-cli-resolver.js', () => ({
|
|
isClaudeAvailable: vi.fn(() => false),
|
|
findClaudeDir: vi.fn(() => null),
|
|
}));
|
|
vi.mock('../src/utils/opencode-cli-resolver.js', () => ({
|
|
isOpenCodeAvailable: vi.fn(() => false),
|
|
resolveOpenCodeDir: vi.fn(() => null),
|
|
}));
|
|
vi.mock('../src/utils/codex-cli-resolver.js', () => ({
|
|
isCodexAvailable: vi.fn(() => false),
|
|
resolveCodexDir: vi.fn(() => null),
|
|
}));
|
|
vi.mock('../src/utils/gemini-cli-resolver.js', () => ({
|
|
isGeminiAvailable: vi.fn(() => false),
|
|
resolveGeminiDir: vi.fn(() => null),
|
|
}));
|
|
vi.mock('../src/utils/antigravity-cli-resolver.js', () => ({
|
|
isAntigravityAvailable: vi.fn(() => false),
|
|
resolveAntigravityDir: vi.fn(() => null),
|
|
}));
|
|
vi.mock('../src/utils/pi-cli-resolver.js', () => ({
|
|
isPiAvailable: vi.fn(() => false),
|
|
resolvePiDir: vi.fn(() => null),
|
|
getPiCliVersion: vi.fn(() => null),
|
|
}));
|
|
vi.mock('../src/utils/cloudflared-resolver.js', () => ({
|
|
isCloudflaredAvailable: vi.fn(() => false),
|
|
resolveCloudflaredPath: vi.fn(() => null),
|
|
}));
|
|
// git gates the Add Case -> Clone Repo tab (#236), so it rides in the same object.
|
|
vi.mock('../src/git-clone.js', () => ({
|
|
isGitAvailable: vi.fn(() => false),
|
|
}));
|
|
|
|
const TEMPLATE = [
|
|
'<head>',
|
|
'<title>Codeman</title>',
|
|
'</head>',
|
|
'<body>',
|
|
'<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" aria-label="Open Codeman across all displays"></button>',
|
|
'</body>',
|
|
].join('\n');
|
|
|
|
function makeServer(settings: Record<string, unknown> = {}) {
|
|
const server = new WebServer(0, false, true);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(server as any).indexHtmlTemplate = TEMPLATE;
|
|
const readSettings = vi.fn(async () => settings);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(server as any).readSettings = readSettings;
|
|
return { server, readSettings };
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const render = (server: WebServer, solo?: string): Promise<string> => (server as any).renderIndexHtml(solo);
|
|
|
|
const ORIG_GESTURE = process.env.CODEMAN_GESTURE;
|
|
afterEach(() => {
|
|
if (ORIG_GESTURE === undefined) delete process.env.CODEMAN_GESTURE;
|
|
else process.env.CODEMAN_GESTURE = ORIG_GESTURE;
|
|
});
|
|
|
|
describe('WebServer.renderIndexHtml', () => {
|
|
it('keeps the multi-monitor button hidden by default and reads settings FRESH', async () => {
|
|
const { server, readSettings } = makeServer({});
|
|
const html = await render(server);
|
|
expect(html).toContain('btn-multimonitor--hidden');
|
|
// forceFresh=true — fixes the post-save reload race against the 2s cache.
|
|
expect(readSettings).toHaveBeenCalledWith(true);
|
|
});
|
|
|
|
it('reveals the multi-monitor button when showMultiMonitorButton is set', async () => {
|
|
const { server } = makeServer({ showMultiMonitorButton: true });
|
|
const html = await render(server);
|
|
expect(html).not.toContain('btn-multimonitor--hidden');
|
|
expect(html).toContain('btn-multimonitor"'); // class list still present, only the marker stripped
|
|
});
|
|
|
|
it('injects the solo global and skips settings for a /session/:id window', async () => {
|
|
const { server, readSettings } = makeServer({ showMultiMonitorButton: true });
|
|
const html = await render(server, 'sess-123');
|
|
expect(html).toContain('window.__CODEMAN_SOLO__="sess-123"');
|
|
expect(readSettings).not.toHaveBeenCalled();
|
|
// Solo skips settings, so the button is NOT revealed even though the setting is on.
|
|
expect(html).toContain('btn-multimonitor--hidden');
|
|
});
|
|
|
|
it('escapes the solo id so it cannot break out of the inline <script>', async () => {
|
|
const { server } = makeServer({});
|
|
const html = await render(server, 'a</script><b>');
|
|
expect(html).not.toContain('</script><b>');
|
|
expect(html).toContain('\\u003c');
|
|
});
|
|
|
|
it('exposes gesture availability but injects the bundle only when enabled', async () => {
|
|
process.env.CODEMAN_GESTURE = '1';
|
|
let { server } = makeServer({ gestureControlEnabled: false });
|
|
let html = await render(server);
|
|
expect(html).toContain('window.__codemanGestureAvailable=true');
|
|
expect(html).not.toContain('gesture-codeman.js');
|
|
|
|
({ server } = makeServer({ gestureControlEnabled: true }));
|
|
html = await render(server);
|
|
expect(html).toContain('window.__codemanGestureAvailable=true');
|
|
expect(html).toContain('gesture-codeman.js');
|
|
});
|
|
|
|
it('reports every tool the welcome buttons, run menu and Codex tab gate on', async () => {
|
|
vi.mocked(isClaudeAvailable).mockReturnValue(true);
|
|
vi.mocked(isOpenCodeAvailable).mockReturnValue(false);
|
|
vi.mocked(isCodexAvailable).mockReturnValue(true);
|
|
vi.mocked(isGeminiAvailable).mockReturnValue(false);
|
|
vi.mocked(isAntigravityAvailable).mockReturnValue(false);
|
|
vi.mocked(isPiAvailable).mockReturnValue(true);
|
|
vi.mocked(isCloudflaredAvailable).mockReturnValue(true);
|
|
vi.mocked(isGitAvailable).mockReturnValue(true);
|
|
const { server } = makeServer({});
|
|
const html = await render(server);
|
|
const flags = JSON.parse(html.match(/window\.__codemanCliAvailable=(\{.*?\});/)![1]);
|
|
// Every key must be PRESENT, not merely truthy where installed: the client
|
|
// treats a missing key as available, so a dropped key silently un-gates.
|
|
expect(flags).toEqual({
|
|
claude: true,
|
|
opencode: false,
|
|
codex: true,
|
|
gemini: false,
|
|
antigravity: false,
|
|
pi: true,
|
|
cloudflared: true,
|
|
git: true,
|
|
});
|
|
});
|
|
|
|
it('still emits the object when nothing at all is installed', async () => {
|
|
// The all-false case is the one that matters most and the easiest to get
|
|
// wrong by only injecting when something resolves.
|
|
for (const probe of [
|
|
isClaudeAvailable,
|
|
isOpenCodeAvailable,
|
|
isCodexAvailable,
|
|
isGeminiAvailable,
|
|
isAntigravityAvailable,
|
|
isPiAvailable,
|
|
isCloudflaredAvailable,
|
|
isGitAvailable,
|
|
]) {
|
|
vi.mocked(probe).mockReturnValue(false);
|
|
}
|
|
const { server } = makeServer({});
|
|
const html = await render(server);
|
|
expect(html).toContain('window.__codemanCliAvailable=');
|
|
const flags = JSON.parse(html.match(/window\.__codemanCliAvailable=(\{.*?\});/)![1]);
|
|
expect(Object.values(flags).every((v) => v === false)).toBe(true);
|
|
});
|
|
|
|
it('skips the probe for a solo window, which has no welcome screen or run menu', async () => {
|
|
vi.mocked(isCodexAvailable).mockReturnValue(true);
|
|
const { server } = makeServer({});
|
|
const html = await render(server, 'sess-123');
|
|
expect(html).not.toContain('__codemanCliAvailable');
|
|
});
|
|
|
|
it('does not expose gesture at all when CODEMAN_GESTURE is unset', async () => {
|
|
delete process.env.CODEMAN_GESTURE;
|
|
const { server } = makeServer({ gestureControlEnabled: true });
|
|
const html = await render(server);
|
|
expect(html).not.toContain('__codemanGestureAvailable');
|
|
expect(html).not.toContain('gesture-codeman.js');
|
|
});
|
|
});
|