mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-01 21:19:41 +02:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00fb3b0908 | ||
|
|
ffccde4f7d | ||
|
|
bec3da3d31 | ||
|
|
6e89eb9ec1 |
@@ -1,5 +1,49 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.14.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Four reported bugs fixed, and the Codeman agent skill from 1.14.1 gets its first published build with the fixes below alongside it.
|
||||
|
||||
## The Codeman agent skill
|
||||
|
||||
Introduced in 1.14.1 and the headline of this line. `skills/codeman` is a Claude Code skill that lets an agent running **inside** a Codeman session drive the HTTP API: start worker sessions, send them prompts, block until they finish, read their answers and clean up. It ships in the npm package and self-gates, so outside a Codeman session (`CODEMAN_MUX` unset) it refuses to act and costs unrelated sessions nothing.
|
||||
|
||||
### Installing it
|
||||
|
||||
```bash
|
||||
codeman skill install # ~/.claude/skills/codeman, every new Claude Code session sees it
|
||||
codeman skill install --case myproject # just that case; linked cases resolve by name too
|
||||
codeman skill uninstall # reverses either one
|
||||
```
|
||||
|
||||
Or turn on **App Settings > Agent Skill** (`agentSkillEnabled`, synced, default off) and Codeman injects the skill into each case when a Claude session is created there.
|
||||
|
||||
Installs are marker-owned: a `skills/codeman` that Codeman did not write is never touched, a stale managed copy is refreshed in place, and a symlinked skill directory is refused rather than written through. Re-run `codeman skill install` after upgrading to refresh the copy. Turning `agentSkillEnabled` back off does **not** remove already-injected copies, because a create-time sweep would yank the skill out from under other live sessions sharing that `.claude/` directory; remove them per case with `codeman skill uninstall --case <name>`.
|
||||
|
||||
### Using it
|
||||
|
||||
Ask for orchestration in plain language ("spin up three workers, have them lint, typecheck and test in parallel, then report back") and the skill supplies the guard, the safety rules and the recipes. The flow it runs:
|
||||
1. **Guard.** Re-runs a preamble on every shell call that refuses outside `CODEMAN_MUX=1`, reads `CODEMAN_API_URL` and `CODEMAN_SESSION_ID`, recovers a password from the data dir `.env` or the install's service definition if one is set, and defines a fail-closed `delete_session`. It re-runs it every call because shell state does not survive between an agent's tool calls.
|
||||
2. **Start a worker** with `POST /api/v1/quick-start` (`mode` is any of `claude`, `shell`, `opencode`, `codex`, `gemini`, `antigravity`), checking `.success` before reading `.data.sessionId`.
|
||||
3. **Wait until it is really ready.** A new session reports `idle` before its CLI has spawned, and a brand-new case shows a trust dialog first, so the skill waits for the composer's own status bar and treats the dialog as a bounded fallback.
|
||||
4. **Send and wait in one call**: `wait`/`waitTimeout` on `POST /api/v1/sessions/:id/input`. It registers the waiter before typing, closing the race where a separate wait reports the previous turn's idle state as this turn's answer. For `claude` workers it resolves on the `stop` hook, usually within seconds.
|
||||
5. **Read the answer** from `GET /api/v1/sessions/:id/last-response`, which returns clean transcript text rather than a screen scrape.
|
||||
6. **Clean up** with `delete_session`, for ids it created and nothing else.
|
||||
|
||||
Hook-less modes (`shell` and the external CLIs) have no `stop` signal and coarse lifecycle transitions, so the skill synchronizes those with a unique split marker and `wait-output ... from=buffer`. Worked fan-out flows, the per-mode signal table, error codes and the Docker/remote caveats live in the skill's `reference/` files, loaded on demand.
|
||||
|
||||
### The rules it encodes
|
||||
|
||||
Each of these silently wastes a run, which is why they are written down: every input must end with `\r` or Enter is never sent; input is single-line; a wait timeout is HTTP 200 with `wait.timedOut`, not an error; `stop` and `blocked` are `claude`-only; signals are edge-triggered with no history, so never fire-and-forget N prompts and then gather signal-waits one by one; a typed command echoes into the output stream, so markers must be split; a full-screen TUI stream is space-less, so match single tokens; and `pid != null` proves startup, not life, so `wait?until=exit` is the death check.
|
||||
|
||||
## Bug fixes
|
||||
- **Web tabs: long-running proxied requests were aborted after 30 seconds with no server log (#237).** The proxy wrapped each upstream fetch in a 30s `AbortSignal.timeout`, which bounds the entire exchange rather than the wait for response headers, so a dashboard endpoint doing model inference and any actively streaming response both died at 30s as a generic unlogged 502 that read as an intermittent network error. The timeout now bounds time-to-headers only and is cleared the moment headers arrive, with the default raised to 300s (`CODEMAN_WEBVIEW_TIMEOUT_MS`). Header timeouts are logged with a sanitized identity (method plus origin plus path, never the query string, which can carry the dashboard's tokens). A browser that navigates away mid-request now aborts the upstream fetch, guarded by `writableFinished` so a completed response never triggers it. The WebSocket handshake keeps its own 30s budget via the new `CODEMAN_WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS`, since a handshake is connection establishment and waiting minutes on one only delays the browser's reconnect logic.
|
||||
- **Web tabs: sandbox incompatibility with cookie-authenticated reverse proxies documented (#238).** `docs/web-tabs.md` now covers cookie auth in front of Codeman itself (Cloudflare Access and similar), where a sandboxed frame's asset and API requests carry no auth cookie, bounce to the login provider, and leave the embedded app apparently unstyled while trusted mode works. The Test button's result now states its own scope: it verifies server-to-upstream reachability, not how the page behaves in a sandboxed frame.
|
||||
- **A described session tab now shows just the description (#232).** A session named `w2-foo-bar: some description` rendered both halves, so the generated id ate the width the chosen part needed. The tab shows the description alone, the `w<n>-<case>` id moves to the tooltip and stays in the session settings modal, and `aria-label` deliberately keeps the full name so screen readers still get the id. Undescribed tabs are unchanged. Right-click a tab to rename it inline. This also fixed a re-render loop: the incremental update compared against the full name, which a described tab never matched, so those tabs re-rendered on every pass.
|
||||
- **`codeman status` now probes the running server (#230).** The command runs in its own fresh process and reported that process's always-stopped Ralph loop under a bare "Status:", which reads as "the server is down" while the service is running fine and agents are reachable. It now probes the real server (`CODEMAN_API_URL`, else https then http on the local port, overridable with `--url`) and reports reachability, version and live session state; any HTTP answer proves the server is up, including a 401 from a password-protected install. The Ralph loop keeps its own `codeman ralph status`. This complements `codeman web --status` from the daemon work: that answers "did I start a daemon", this answers "is a server running at all".
|
||||
|
||||
## 1.14.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -74,7 +74,7 @@ When user says "COM":
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 1.14.1 (must match `package.json`)
|
||||
**Version**: 1.14.2 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
|
||||
+25
-1
@@ -46,7 +46,11 @@ Codeman, including a phone that is not on the tailnet.
|
||||
|
||||
`direct` mode (a plain cross-origin iframe) still exists and is cheaper, but it only
|
||||
works for an HTTPS dashboard that permits framing. The **Test** button probes from
|
||||
the server and tells you which mode applies.
|
||||
the server and tells you which mode applies. Note what Test actually verifies:
|
||||
**server-to-upstream reachability, nothing else**. It does not exercise the browser
|
||||
sandbox, cookies, CORS, CSP, or any reverse proxy sitting in front of Codeman, so a
|
||||
passing Test does not guarantee the embedded page will render (see the
|
||||
cookie-authenticated reverse proxy caveat below).
|
||||
|
||||
## The sandbox, and when to turn it off
|
||||
|
||||
@@ -66,6 +70,17 @@ Even in trusted mode, Codeman never forwards its own credentials upstream: the
|
||||
`Authorization` header and the `codeman_session` cookie are stripped on the way out,
|
||||
so `CODEMAN_PASSWORD` cannot leak into a dashboard.
|
||||
|
||||
⚠️ **Sandboxed tabs may not work when Codeman itself is behind a
|
||||
cookie-authenticated reverse proxy** (Cloudflare Access, Authelia, oauth2-proxy and
|
||||
similar). The sandboxed frame is opaque-origin, so its stylesheet, script, and API
|
||||
requests do not carry the proxy's authentication cookie; the proxy redirects them to
|
||||
the login provider, where CORS/CSP kills them, and the embedded app renders
|
||||
unstyled or broken while the Codeman page around it works fine. Trusted mode
|
||||
(**Open sandboxed** off) keeps a real origin and the cookie, so it works. The
|
||||
**Test** button cannot catch this: it checks that the Codeman *server* can reach the
|
||||
upstream, not that a sandboxed *browser* frame can load assets through the public
|
||||
authentication layer.
|
||||
|
||||
## How the proxy authenticates
|
||||
|
||||
A sandboxed iframe is opaque-origin, so every request it makes is cross-site: the
|
||||
@@ -137,6 +152,15 @@ then every API call fails, which looks like the dashboard being broken.
|
||||
- **Login-protected dashboards need trusted mode**, since a sandboxed frame has no
|
||||
cookie jar. A server-side per-dashboard cookie jar would lift this and is the
|
||||
natural next step if it becomes annoying.
|
||||
- **Cookie-authenticated reverse proxies in front of Codeman break sandboxed tabs**
|
||||
(#238). The sandboxed frame's requests carry no auth cookie, so the proxy bounces
|
||||
them to its login provider and the app loads broken while Test reports reachable.
|
||||
Use trusted mode behind Cloudflare Access and friends; see the warning above.
|
||||
- **Slow endpoints and the upstream timeout** (#237). The proxy waits
|
||||
`CODEMAN_WEBVIEW_TIMEOUT_MS` (default 300s) for the upstream's response *headers*,
|
||||
then streams the body without any time bound; a header timeout is logged
|
||||
server-side and answered as a 502 that names the limit. WebSocket handshakes use
|
||||
the separate `CODEMAN_WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS` (default 30s).
|
||||
- **Not a security boundary.** The proxy reaches whatever the Codeman server can
|
||||
reach. That is not an escalation for someone who already commands
|
||||
`--dangerously-skip-permissions` agents, but in multi-user mode it does mean a
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.14.1",
|
||||
"version": "1.14.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.14.1",
|
||||
"version": "1.14.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.14.1",
|
||||
"version": "1.14.2",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
+133
-28
@@ -577,47 +577,152 @@ function printStats(stats: ReturnType<ReturnType<typeof getRalphLoop>['getStats'
|
||||
|
||||
// ============ Utility Commands ============
|
||||
|
||||
/** What probing the web server found. */
|
||||
interface WebServerProbe {
|
||||
reachable: boolean;
|
||||
/** The URL that answered, or the first candidate when nothing did. */
|
||||
url: string;
|
||||
statusCode?: number;
|
||||
version?: string;
|
||||
authRequired?: boolean;
|
||||
/** Live session states from `/api/status`, when the probe could read them. */
|
||||
sessions?: Array<{ status?: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET `<base>/api/status` with a short timeout, tolerating the self-signed cert an
|
||||
* `--https` install uses. ANY HTTP answer proves the server is up: a 401 just
|
||||
* means it wants credentials (sent when available, same env → data-dir `.env`
|
||||
* fallback as `codeman attach`).
|
||||
*/
|
||||
function probeWebServerAt(base: string): Promise<WebServerProbe | null> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL('/api/status', base);
|
||||
} catch {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
const envFile = readCodemanEnv();
|
||||
const username = process.env.CODEMAN_USERNAME || envFile.CODEMAN_USERNAME || 'admin';
|
||||
const password = process.env.CODEMAN_PASSWORD || envFile.CODEMAN_PASSWORD;
|
||||
const transport = url.protocol === 'https:' ? https : http;
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (password) {
|
||||
headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const req = transport.request(
|
||||
{
|
||||
protocol: url.protocol,
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
method: 'GET',
|
||||
path: url.pathname,
|
||||
rejectUnauthorized: false,
|
||||
headers,
|
||||
timeout: 3000,
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let received = 0;
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
received += chunk.length;
|
||||
if (received <= 1024 * 1024) chunks.push(chunk);
|
||||
});
|
||||
res.on('end', () => {
|
||||
const statusCode = res.statusCode ?? 0;
|
||||
if (statusCode === 401) {
|
||||
resolve({ reachable: true, url: base, statusCode, authRequired: true });
|
||||
return;
|
||||
}
|
||||
let version: string | undefined;
|
||||
let sessions: Array<{ status?: string }> | undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf-8')) as {
|
||||
data?: { version?: unknown; sessions?: unknown };
|
||||
};
|
||||
const data = parsed?.data ?? (parsed as { version?: unknown; sessions?: unknown });
|
||||
if (typeof data?.version === 'string') version = data.version;
|
||||
if (Array.isArray(data?.sessions)) sessions = data.sessions as Array<{ status?: string }>;
|
||||
} catch {
|
||||
// Not JSON, but still an answer, so still running.
|
||||
}
|
||||
resolve({ reachable: true, url: base, statusCode, version, sessions });
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on('timeout', () => req.destroy(new Error('timeout')));
|
||||
req.on('error', () => resolve(null));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
program
|
||||
.command('status')
|
||||
.description('Show overall status')
|
||||
.action(() => {
|
||||
const manager = getSessionManager();
|
||||
const queue = getTaskQueue();
|
||||
const loop = getRalphLoop();
|
||||
|
||||
const sessions = manager.getAllSessions();
|
||||
const stored = manager.getStoredSessions();
|
||||
const storedValues = Object.values(stored);
|
||||
const taskCounts = queue.getCount();
|
||||
const loopStatus = loop.status;
|
||||
|
||||
// Use live sessions if available, otherwise fall back to stored state
|
||||
const activeCount = sessions.length || storedValues.filter((s) => s.status !== 'stopped').length;
|
||||
const idleCount = sessions.length
|
||||
? sessions.filter((s) => s.isIdle()).length
|
||||
: storedValues.filter((s) => s.status === 'idle').length;
|
||||
const busyCount = sessions.length
|
||||
? sessions.filter((s) => s.isBusy()).length
|
||||
: storedValues.filter((s) => s.status === 'busy').length;
|
||||
.description('Show whether the Codeman web server is running, plus session/task state')
|
||||
.option('--url <url>', 'Server URL to probe (defaults to CODEMAN_API_URL, then local port)')
|
||||
.action(async (options: { url?: string }) => {
|
||||
// Issue #230: this command runs in its own fresh process, and the old output
|
||||
// reported THAT process's (always-stopped) Ralph loop under a bare "Status:",
|
||||
// reading as "the server is down" while the web service ran fine. Probe the
|
||||
// real server first; the Ralph loop has its own `codeman ralph status`.
|
||||
const port = process.env.CODEMAN_PORT || '3000';
|
||||
const candidates = options.url
|
||||
? [options.url]
|
||||
: process.env.CODEMAN_API_URL
|
||||
? [process.env.CODEMAN_API_URL]
|
||||
: [`https://127.0.0.1:${port}`, `http://127.0.0.1:${port}`];
|
||||
let probe: WebServerProbe = { reachable: false, url: candidates[0] };
|
||||
for (const candidate of candidates) {
|
||||
const answer = await probeWebServerAt(candidate);
|
||||
if (answer) {
|
||||
probe = answer;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.bold('\nCodeman Status'));
|
||||
console.log('─'.repeat(40));
|
||||
|
||||
console.log(chalk.bold('\nSessions:'));
|
||||
console.log(` Active: ${activeCount}`);
|
||||
console.log(` Idle: ${idleCount}`);
|
||||
console.log(` Busy: ${busyCount}`);
|
||||
console.log(chalk.bold('\nWeb Server:'));
|
||||
if (probe.reachable) {
|
||||
const version = probe.version ? ` (v${probe.version})` : '';
|
||||
console.log(` Status: ${chalk.green('running')}${version} at ${probe.url}`);
|
||||
if (probe.authRequired) {
|
||||
console.log(chalk.gray(' (answers 401: set CODEMAN_PASSWORD/CODEMAN_USERNAME to see session details)'));
|
||||
}
|
||||
} else {
|
||||
console.log(` Status: ${chalk.red('not reachable')} at ${candidates.join(' or ')}`);
|
||||
console.log(
|
||||
chalk.gray(' (start it with `codeman web`, or check your service: systemctl --user status codeman-web)')
|
||||
);
|
||||
}
|
||||
|
||||
// Prefer the server's live view; fall back to the shared saved state, labeled
|
||||
// as such, so the numbers are never silently a different thing.
|
||||
if (probe.sessions) {
|
||||
const live = probe.sessions;
|
||||
console.log(chalk.bold('\nSessions (live, from the server):'));
|
||||
console.log(` Total: ${live.length}`);
|
||||
console.log(` Idle: ${live.filter((s) => s.status === 'idle').length}`);
|
||||
console.log(` Busy: ${live.filter((s) => s.status === 'busy').length}`);
|
||||
} else {
|
||||
const manager = getSessionManager();
|
||||
const storedValues = Object.values(manager.getStoredSessions());
|
||||
console.log(chalk.bold('\nSessions (from saved state):'));
|
||||
console.log(` Active: ${storedValues.filter((s) => s.status !== 'stopped').length}`);
|
||||
console.log(` Idle: ${storedValues.filter((s) => s.status === 'idle').length}`);
|
||||
console.log(` Busy: ${storedValues.filter((s) => s.status === 'busy').length}`);
|
||||
}
|
||||
|
||||
const taskCounts = getTaskQueue().getCount();
|
||||
console.log(chalk.bold('\nTasks:'));
|
||||
console.log(` Total: ${taskCounts.total}`);
|
||||
console.log(` Pending: ${taskCounts.pending}`);
|
||||
console.log(` Running: ${taskCounts.running}`);
|
||||
console.log(` Completed: ${taskCounts.completed}`);
|
||||
console.log(` Failed: ${taskCounts.failed}`);
|
||||
|
||||
const statusColor = loopStatus === 'running' ? chalk.green : loopStatus === 'paused' ? chalk.yellow : chalk.gray;
|
||||
console.log(chalk.bold('\nRalph Loop:'));
|
||||
console.log(` Status: ${statusColor(loopStatus)}`);
|
||||
console.log('');
|
||||
});
|
||||
|
||||
|
||||
@@ -29,12 +29,29 @@ export const WEBVIEW_CAPABILITY_TTL_MS = envInt('CODEMAN_WEBVIEW_CAPABILITY_TTL_
|
||||
/** Max concurrent capabilities held in memory before the oldest are dropped. */
|
||||
export const MAX_WEBVIEW_CAPABILITIES = 200;
|
||||
|
||||
/** Upstream request timeout for a proxied HTTP request. */
|
||||
export const WEBVIEW_UPSTREAM_TIMEOUT_MS = envInt('CODEMAN_WEBVIEW_TIMEOUT_MS', 30_000);
|
||||
/**
|
||||
* How long a proxied HTTP request waits for the upstream's RESPONSE HEADERS.
|
||||
*
|
||||
* This bounds time-to-headers only, never an actively streaming body: the proxy
|
||||
* clears the timer the moment headers arrive (issue #237: the old 30s
|
||||
* `AbortSignal.timeout` bounded the whole fetch and killed slow AI/model endpoints
|
||||
* and long streams alike, as a silent 502). 300s because "the app is thinking" is
|
||||
* normal for the dashboards people proxy; abandoned upstreams are reclaimed by the
|
||||
* client-hangup abort, not by this value, so a generous default costs nothing.
|
||||
*/
|
||||
export const WEBVIEW_UPSTREAM_TIMEOUT_MS = envInt('CODEMAN_WEBVIEW_TIMEOUT_MS', 300_000);
|
||||
|
||||
/** Shorter timeout for the editor's "Test" probe, which a human is waiting on. */
|
||||
export const WEBVIEW_PROBE_TIMEOUT_MS = envInt('CODEMAN_WEBVIEW_PROBE_TIMEOUT_MS', 8_000);
|
||||
|
||||
/**
|
||||
* WebSocket upgrade handshake timeout. Deliberately decoupled from
|
||||
* WEBVIEW_UPSTREAM_TIMEOUT_MS: a handshake is connection establishment, and waiting
|
||||
* minutes on one only delays the browser's reconnect logic. Matches the pre-#237
|
||||
* behavior (the handshake used to ride the 30s upstream timeout).
|
||||
*/
|
||||
export const WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS = envInt('CODEMAN_WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS', 30_000);
|
||||
|
||||
/**
|
||||
* Max bytes of an HTML response buffered for `<base>` injection and link
|
||||
* rewriting. Larger HTML documents stream through untouched: the rewrite is a
|
||||
|
||||
+22
-8
@@ -3438,14 +3438,19 @@ class CodemanApp {
|
||||
statusEl.className = `tab-status ${status}`;
|
||||
}
|
||||
|
||||
// Update name if changed
|
||||
// Update name if changed. #232: a description (the `: suffix` part of the
|
||||
// name) is the whole tab label; the generated id lives in the tooltip. The
|
||||
// compare targets the DISPLAY text, or a described tab would re-render on
|
||||
// every pass (textContent never equals the full name there).
|
||||
const nameEl = tab.querySelector('.tab-name');
|
||||
if (nameEl && nameEl.textContent !== name) {
|
||||
if (nameEl) {
|
||||
const _p = parseSessionPrefix(name);
|
||||
if (_p && _p.suffix) {
|
||||
nameEl.innerHTML = '<span class="tab-prefix">' + escapeHtml(_p.prefix) + '</span><span class="tab-suffix">: ' + escapeHtml(_p.suffix) + '</span>';
|
||||
} else {
|
||||
nameEl.textContent = name;
|
||||
const _label = _p && _p.suffix ? _p.suffix : name;
|
||||
if (nameEl.textContent !== _label) {
|
||||
nameEl.textContent = _label;
|
||||
tab.title = _p && _p.suffix
|
||||
? (session.workingDir ? `${_p.prefix} (${session.workingDir})` : _p.prefix)
|
||||
: (session.workingDir || '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3617,14 +3622,23 @@ class CodemanApp {
|
||||
const tallTabsEnabled = this._tallTabsEnabled ?? false;
|
||||
const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name;
|
||||
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${loadState ? ' tab-loading' : ''}" data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
|
||||
// #232: a session with a description (the `: suffix` part of its name) shows
|
||||
// JUST the description on the tab; the generated w<n>-<case> id moves to the
|
||||
// tooltip and stays visible in the session settings modal.
|
||||
const parsedName = parseSessionPrefix(name);
|
||||
const tabLabel = parsedName && parsedName.suffix ? parsedName.suffix : name;
|
||||
const tabTooltip = parsedName && parsedName.suffix
|
||||
? (session.workingDir ? `${parsedName.prefix} (${session.workingDir})` : parsedName.prefix)
|
||||
: (session.workingDir || '');
|
||||
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${loadState ? ' tab-loading' : ''}" data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
|
||||
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
||||
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
|
||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||
<span class="tab-info">
|
||||
<span class="tab-name-row">
|
||||
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : ''}
|
||||
<span class="tab-name" data-session-id="${id}">${(() => { const p = parseSessionPrefix(name); return p && p.suffix ? '<span class="tab-prefix">' + escapeHtml(p.prefix) + '</span><span class="tab-suffix">: ' + escapeHtml(p.suffix) + '</span>' : escapeHtml(name); })()}</span>
|
||||
<span class="tab-name" data-session-id="${id}">${escapeHtml(tabLabel)}</span>
|
||||
<span class="tab-detached-badge" aria-hidden="true">detached</span>
|
||||
</span>
|
||||
${showFolder ? `<span class="tab-folder">\u{1F4C1} ${escapeHtml(folderName)}</span>` : ''}
|
||||
|
||||
@@ -708,7 +708,10 @@
|
||||
<span class="form-hint">
|
||||
Recommended. A proxied dashboard is served from Codeman's own address, so unchecking
|
||||
this lets its JavaScript read this page and call the API that starts agents. Uncheck
|
||||
only for a dashboard you fully trust, or one whose own login needs cookies.
|
||||
only for a dashboard you fully trust, or one whose own login needs cookies. Also
|
||||
uncheck it if Codeman itself sits behind a cookie-authenticated reverse proxy
|
||||
(e.g. Cloudflare Access): a sandboxed frame carries no auth cookie, so its asset
|
||||
and API requests bounce to the login provider and the page loads broken.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
|
||||
@@ -1383,15 +1383,6 @@ html[data-line-anim="packet"] .connection-line.line-enter {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.session-tab .tab-prefix {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.session-tab .tab-suffix {
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Tab folder path — hidden by default, shown via .tabs-show-folder on container */
|
||||
.session-tab .tab-folder {
|
||||
font-size: 0.6rem;
|
||||
|
||||
@@ -396,9 +396,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
out.textContent = 'Test failed (invalid URL?).';
|
||||
return;
|
||||
}
|
||||
// #238: the probe runs server-to-upstream; say so, or a passing Test reads as
|
||||
// "the embedded page will work" when the browser sandbox / a cookie-auth
|
||||
// reverse proxy in front of Codeman can still break it.
|
||||
out.textContent = probe.reachable
|
||||
? `Reachable (HTTP ${probe.status}). ${probe.reason}`
|
||||
: `Not reachable. ${probe.reason}`;
|
||||
? `Reachable (HTTP ${probe.status}) from the Codeman server. ${probe.reason} ` +
|
||||
`(Tests server-to-upstream reachability only, not how the page behaves in a sandboxed frame.)`
|
||||
: `Not reachable from the Codeman server. ${probe.reason}`;
|
||||
out.className = 'form-hint webview-probe-result ' + (probe.reachable ? 'ok' : 'bad');
|
||||
},
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
WEBVIEW_PROBE_TIMEOUT_MS,
|
||||
WEBVIEW_PROXY_PREFIX,
|
||||
WEBVIEW_UPSTREAM_TIMEOUT_MS,
|
||||
WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS,
|
||||
} from '../../config/webview-limits.js';
|
||||
import { readWebviews, writeWebviews } from '../../webview-store.js';
|
||||
import { webviewCapabilities } from '../../webview-capabilities.js';
|
||||
@@ -420,6 +421,34 @@ async function proxyRequest(
|
||||
refererPath: typeof req.headers.referer === 'string' ? stripProxyPrefix(req.headers.referer, cap) : undefined,
|
||||
});
|
||||
|
||||
// #237: the timeout bounds TIME-TO-HEADERS only. A plain AbortSignal.timeout on
|
||||
// the fetch bounded the entire exchange, so a legitimately slow endpoint (AI
|
||||
// inference behind the dashboard) and an actively streaming response both died at
|
||||
// 30s as an unlogged generic 502. The timer is cleared the moment headers arrive;
|
||||
// what reclaims an abandoned upstream afterwards is the client hangup below.
|
||||
const startedAt = Date.now();
|
||||
const abort = new AbortController();
|
||||
let headerTimedOut = false;
|
||||
let clientGone = false;
|
||||
const headerTimer = setTimeout(() => {
|
||||
headerTimedOut = true;
|
||||
abort.abort();
|
||||
}, WEBVIEW_UPSTREAM_TIMEOUT_MS);
|
||||
// A browser that navigates away mid-request (or mid-stream) must abort the
|
||||
// upstream fetch, or slow endpoints accumulate as orphaned upstream sockets.
|
||||
// Guarded by writableFinished, same as abortOnClientHangUp in session-routes:
|
||||
// `close` also fires after a completed response, which must not abort anything.
|
||||
reply.raw.on('close', () => {
|
||||
if (!reply.raw.writableFinished) {
|
||||
clientGone = true;
|
||||
abort.abort();
|
||||
}
|
||||
});
|
||||
|
||||
// Sanitized request identity for logs: method + origin + path, never the query
|
||||
// string (it can carry the dashboard's tokens).
|
||||
const logTarget = `${req.method} ${upstream.origin}${upstream.pathname}`;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(upstream.href, {
|
||||
@@ -431,11 +460,37 @@ async function proxyRequest(
|
||||
// Redirects are rewritten into the proxy prefix instead of followed, so the
|
||||
// browser's URL stays inside the frame and relative assets keep resolving.
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(WEBVIEW_UPSTREAM_TIMEOUT_MS),
|
||||
signal: abort.signal,
|
||||
} as RequestInit);
|
||||
} catch (err) {
|
||||
const elapsed = Date.now() - startedAt;
|
||||
if (clientGone) {
|
||||
// Nobody is listening; the abort was ours and intentional. Not an upstream
|
||||
// failure, so no warn (it would read as the dashboard being broken).
|
||||
return reply;
|
||||
}
|
||||
if (headerTimedOut) {
|
||||
console.warn(
|
||||
`[Webview] upstream sent no response headers within ${WEBVIEW_UPSTREAM_TIMEOUT_MS}ms: ` +
|
||||
`${logTarget} (webview "${webview.name}")`
|
||||
);
|
||||
return reply
|
||||
.code(502)
|
||||
.type('text/plain')
|
||||
.send(
|
||||
`Dashboard unreachable: upstream sent no response headers within ${WEBVIEW_UPSTREAM_TIMEOUT_MS}ms ` +
|
||||
`(CODEMAN_WEBVIEW_TIMEOUT_MS raises this limit)`
|
||||
);
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.warn(
|
||||
`[Webview] upstream fetch failed after ${elapsed}ms: ${logTarget} (webview "${webview.name}"): ${message}`
|
||||
);
|
||||
return reply.code(502).type('text/plain').send(`Dashboard unreachable: ${message}`);
|
||||
} finally {
|
||||
// Headers arrived (or the fetch failed): from here on the timeout must never
|
||||
// fire, a streaming body is allowed to take as long as it takes.
|
||||
clearTimeout(headerTimer);
|
||||
}
|
||||
|
||||
const secureContext = req.protocol === 'https';
|
||||
@@ -581,7 +636,7 @@ function proxyWebSocket(socket: WebSocket, req: FastifyRequest<{ Params: ProxyPa
|
||||
origin: upstream.origin,
|
||||
...(webview.trusted && req.headers.cookie ? { cookie: String(req.headers.cookie) } : {}),
|
||||
},
|
||||
handshakeTimeout: WEBVIEW_UPSTREAM_TIMEOUT_MS,
|
||||
handshakeTimeout: WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* @fileoverview Issue #237: the webview proxy's upstream timeout bounds
|
||||
* TIME-TO-HEADERS only, is logged when it fires, and never kills a response that
|
||||
* is actively streaming.
|
||||
*
|
||||
* Uses app.inject() against the real registerWebviewRoutes with a real local
|
||||
* upstream http server on an ephemeral port (inject fakes only the inbound
|
||||
* request; the proxy's outbound fetch is real). Port: ephemeral (server.listen(0)).
|
||||
*
|
||||
* The timeout is shrunk via CODEMAN_WEBVIEW_TIMEOUT_MS inside vi.hoisted(), which
|
||||
* runs before the module graph loads (webview-limits reads the env at import).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
|
||||
const TIMEOUT_MS = vi.hoisted(() => {
|
||||
process.env.CODEMAN_WEBVIEW_TIMEOUT_MS = '400';
|
||||
return 400;
|
||||
});
|
||||
|
||||
import { registerWebviewRoutes } from '../src/web/routes/webview-routes.js';
|
||||
import { webviewCapabilities } from '../src/webview-capabilities.js';
|
||||
import { writeWebviews } from '../src/webview-store.js';
|
||||
import { getDataDir } from '../src/config/instance.js';
|
||||
import type { EventPort } from '../src/web/ports/index.js';
|
||||
|
||||
const WEBVIEW_ID = 'wv-timeout-test';
|
||||
|
||||
let upstream: Server;
|
||||
let upstreamPort: number;
|
||||
let app: FastifyInstance;
|
||||
let capability: string;
|
||||
|
||||
const eventPortStub: EventPort = {
|
||||
broadcast: vi.fn(),
|
||||
sendPushNotifications: vi.fn(),
|
||||
batchTerminalData: vi.fn(),
|
||||
broadcastSessionStateDebounced: vi.fn(),
|
||||
batchTaskUpdate: vi.fn(),
|
||||
getSseClientCount: vi.fn(() => 0),
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
upstream = createServer((req, res) => {
|
||||
if (req.url === '/fast') {
|
||||
res.writeHead(200, { 'content-type': 'text/plain' });
|
||||
res.end('quick');
|
||||
return;
|
||||
}
|
||||
if (req.url === '/slow-headers') {
|
||||
// Headers arrive AFTER the proxy's limit: this is the #237 repro shape
|
||||
// (an API endpoint thinking for longer than the timeout).
|
||||
setTimeout(() => {
|
||||
res.writeHead(200, { 'content-type': 'text/plain' });
|
||||
res.end('finally');
|
||||
}, TIMEOUT_MS + 700);
|
||||
return;
|
||||
}
|
||||
if (req.url === '/stream') {
|
||||
// Headers immediately, then a body that takes ~3x the limit to finish.
|
||||
// Under the old whole-fetch AbortSignal.timeout this died mid-stream.
|
||||
res.writeHead(200, { 'content-type': 'text/plain' });
|
||||
res.write('start;');
|
||||
let chunks = 0;
|
||||
const timer = setInterval(() => {
|
||||
chunks++;
|
||||
res.write(`chunk${chunks};`);
|
||||
if (chunks >= 4) {
|
||||
clearInterval(timer);
|
||||
res.end('done');
|
||||
}
|
||||
}, TIMEOUT_MS * 0.75);
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
await new Promise<void>((resolve) => upstream.listen(0, '127.0.0.1', resolve));
|
||||
const address = upstream.address();
|
||||
if (typeof address === 'object' && address) upstreamPort = address.port;
|
||||
|
||||
await writeWebviews(getDataDir(), [
|
||||
{
|
||||
id: WEBVIEW_ID,
|
||||
name: 'timeout-under-test',
|
||||
url: `http://127.0.0.1:${upstreamPort}/`,
|
||||
embedMode: 'proxy',
|
||||
trusted: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
capability = webviewCapabilities.mint(WEBVIEW_ID, undefined);
|
||||
|
||||
app = Fastify({ logger: false });
|
||||
registerWebviewRoutes(app, eventPortStub);
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
webviewCapabilities.revokeWebview(WEBVIEW_ID);
|
||||
await new Promise<void>((resolve) => upstream.close(() => resolve()));
|
||||
});
|
||||
|
||||
describe('webview proxy upstream timeout (#237)', () => {
|
||||
it('proxies a fast request untouched', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: `/webview/${capability}/fast` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe('quick');
|
||||
});
|
||||
|
||||
it('502s with an actionable, logged message when headers never arrive in time', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
try {
|
||||
const res = await app.inject({ method: 'GET', url: `/webview/${capability}/slow-headers` });
|
||||
expect(res.statusCode).toBe(502);
|
||||
// The body names the limit and the env var that raises it; the old message
|
||||
// was an opaque "The operation was aborted due to timeout".
|
||||
expect(res.body).toContain(`no response headers within ${TIMEOUT_MS}ms`);
|
||||
expect(res.body).toContain('CODEMAN_WEBVIEW_TIMEOUT_MS');
|
||||
// And it is logged server-side (the old path was completely silent), with
|
||||
// the sanitized target and the webview's name.
|
||||
const logged = warn.mock.calls.map((args) => args.join(' ')).join('\n');
|
||||
expect(logged).toContain('no response headers');
|
||||
expect(logged).toContain(`/slow-headers`);
|
||||
expect(logged).toContain('timeout-under-test');
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('never aborts a response that is actively streaming past the timeout', async () => {
|
||||
const started = Date.now();
|
||||
const res = await app.inject({ method: 'GET', url: `/webview/${capability}/stream` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe('start;chunk1;chunk2;chunk3;chunk4;done');
|
||||
// Sanity: the exchange really did outlive the header timeout.
|
||||
expect(Date.now() - started).toBeGreaterThan(TIMEOUT_MS);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user