From bd55d014d17872fcfde4dd1172fef9f82411dd5a Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 23 Oct 2026 05:16:35 -0400 Subject: [PATCH 1/7] Integrate NVIDIA Goose Cline and OpenHands providers --- README.md | 2 + docs/agents.md | 16 +- src/server/agents.ts | 3 +- src/server/config.ts | 2 +- src/server/providers/cline.ts | 29 ++ src/server/providers/goose.ts | 36 +++ src/server/providers/index.ts | 6 + src/server/providers/openhands-bridge.ts | 321 +++++++++++++++++++++++ src/server/providers/openhands.ts | 54 ++++ src/shared/providers.ts | 23 +- tests/agents.test.ts | 16 +- tests/goose.test.ts | 26 ++ tests/openhands-bridge.test.ts | 113 ++++++++ 13 files changed, 641 insertions(+), 6 deletions(-) create mode 100644 src/server/providers/cline.ts create mode 100644 src/server/providers/goose.ts create mode 100644 src/server/providers/openhands-bridge.ts create mode 100644 src/server/providers/openhands.ts create mode 100644 tests/goose.test.ts create mode 100644 tests/openhands-bridge.test.ts diff --git a/README.md b/README.md index fac9441a5..ef90678c9 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,8 @@ agent-office setup # the first-start walkthrough again Every option is in [docs/configuration.md](docs/configuration.md). Choosing models and providers per worker is in [docs/agents.md](docs/agents.md). +This local integration build also offers **Goose**, **Cline**, and **OpenHands** in the worker picker. The three agents use their existing NVIDIA credentials and settings; Agent Office does not store an NVIDIA key. Goose and Cline run in their interactive terminals. OpenHands connects to the running Agent Canvas service on `127.0.0.1:8000` through a local terminal bridge. See [the local agent setup and recovery notes](docs/agents.md#goose-cline-and-openhands-in-this-local-build). + To run it from a clone instead: ```bash diff --git a/docs/agents.md b/docs/agents.md index 02016b48b..aed9ec313 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -2,9 +2,9 @@ Back to the [README](../README.md). -Every new worker starts on the office's **Default worker**: a provider, model and effort an admin picks in ⚙️ Settings → **🤖 Workers** (the `--agent` with its own default model until someone does). The hire, ask, queue, issue and meeting windows show it on one line; click **✏️ Edit** there to pick **Claude Code**, **OpenCode**, **Codex**, **Grok**, **Muse**, **DeepSeek Harness**, **Pi** or **Cursor**, a model and an effort for just that worker or task, and **↺ Use the default** to go back. Board agents and tasks the Queue agent adds, which nobody picks for, start on the default too. Existing workers keep their provider when prompted or resumed, and queued tasks keep their choice when retried or restored after a restart. +Every new worker starts on the office's **Default worker**: a provider, model and effort an admin picks in ⚙️ Settings → **🤖 Workers** (the `--agent` with its own default model until someone does). The hire, ask, queue, issue and meeting windows show it on one line; click **✏️ Edit** there to pick **Claude Code**, **OpenCode**, **Codex**, **Grok**, **Muse**, **DeepSeek Harness**, **Pi**, **Cursor**, **Goose**, **Cline**, or **OpenHands**, with any model and effort fields supported by that provider for just that worker or task, and **↺ Use the default** to go back. Board agents and tasks the Queue agent adds, which nobody picks for, start on the default too. Existing workers keep their provider when prompted or resumed, and queued tasks keep their choice when retried or restored after a restart. -Every harness takes both a **Model** and an **Effort**, each optional: leave one on **Default** and the harness uses its own settings. +The providers in the table take a **Model** and sometimes an **Effort**: leave one on **Default** and the harness uses its own settings. Goose, Cline, and OpenHands use the NVIDIA configurations already verified on this computer and do not offer those fields in this local build. | Harness | Model | Effort | | --- | --- | --- | @@ -18,6 +18,18 @@ Every harness takes both a **Model** and an **Effort**, each optional: leave one The efforts are low, medium, high, extra high and max. Where a harness's own catalog says which of them a model supports (Codex and OpenCode do), the ones it doesn't are greyed out once that model is picked. A harness whose models can't be listed (its CLI isn't installed, or isn't signed in) still takes a model id typed in. A **Custom** `--agent` is run as it is, so it has neither field: set its model and effort in `--agent-args`. +## Goose, Cline, and OpenHands in this local build + +This local build adds three provider choices. Their API credentials stay in each application's existing store; Agent Office neither requests nor stores an NVIDIA API key. The Office listener remains on `127.0.0.1` unless an operator explicitly changes `--host`. + +| Worker | What must already be running or installed | How it operates in the office | Restart behavior | +| --- | --- | --- | --- | +| Goose | `goose` CLI with its saved NVIDIA provider and model | Interactive PTY, one named Goose session per desk. This build sets thinking effort to `low` only for Office workers so short requests complete promptly. | Resumes its named session when the Office restarts or a worker is woken. If that session was removed externally, the terminal reports the missing session. | +| Cline | `cline` CLI with the existing Cline NVIDIA sign-in | Interactive PTY with the verified NVIDIA provider and model; Office requests disabled automatic approval at launch. Cline's native setting can still change: keep **Auto-approve all disabled** for manual review and approve requested actions in the terminal. | A living PTY survives an ordinary Office restart through the host process. If Cline itself exited, waking the desk starts a fresh conversation because the CLI's `--id` path ignores a new initial prompt. | +| OpenHands | Agent Canvas running on `127.0.0.1:8000` with an active NVIDIA agent profile | A local Node terminal bridge creates or follows Agent Canvas conversations through its authenticated API. The bridge reads Agent Canvas's local session key in memory and sends it only to loopback. | Resumes the saved conversation ID after confirming its workspace is the same directory. If the prior conversation is gone, the next prompt starts a new one. | + +Pick one of these names when hiring a desk worker or set Goose as the default with `--agent goose`. The current local build uses each worker's verified NVIDIA model; its hire dialog has no separate model or effort field for these three. A failure to find the CLI or the Agent Canvas service appears on that worker's terminal. OpenHands actions requiring confirmation are approved in Agent Canvas. + What's picked shows next to the worker everywhere it's named (the Workers panel, its task card, its terminal's title, the queue), by the model's name rather than its family: **Opus 5.5**, not "Opus". For Claude Code that name comes from the session itself once the worker has spoken, so a worker left on Default says which model the default is, and one switched with `/model` in its terminal says the new one. For OpenCode, the **Model** field selects the initial model for a new worker or queue task. Suggestions come from `opencode models`; you can also enter a `provider/model` ID. Leave it empty to use your OpenCode settings (including configured CLI arguments). An explicit choice overrides configured model arguments for that launch and stays with a queued task when retried. **Effort** is what OpenCode calls the model's *variant*. Its TUI has no flag for one, and sends each message at the variant it remembers for the model from whichever session used it last, so the office's plugin puts the worker's first message on the effort picked for it; the TUI carries on from that message, and `Ctrl+T` in the terminal still changes it. An effort the model has no variant for changes nothing. diff --git a/src/server/agents.ts b/src/server/agents.ts index d5caeca0a..cfa7d12a2 100644 --- a/src/server/agents.ts +++ b/src/server/agents.ts @@ -9,7 +9,8 @@ import { AGENT_PROVIDERS, PROVIDER_META, isAgentEffort, providerMeta, providerNa */ export function configuredProvider(command: string): AgentProvider { const base = path.basename(command.replaceAll('\\', '/')).toLowerCase().replace(/\.(?:exe|cmd|bat|com)$/, ''); - return AGENT_PROVIDERS.find((p) => PROVIDER_META[p].bin === base) ?? 'custom'; + // OpenHands uses Node to run its Office bridge; Node itself is not an OpenHands agent. + return AGENT_PROVIDERS.find((p) => p !== 'openhands' && PROVIDER_META[p].bin === base) ?? 'custom'; } /** The providers an office started with `configured` can hire: the ones it knows, and a custom --agent only when that's what it was started with. */ diff --git a/src/server/config.ts b/src/server/config.ts index 177613751..2ab660b83 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -127,7 +127,7 @@ Options: --agent Default agent command (default "claude", env AGENT_OFFICE_AGENT) --agent-args Extra args for the configured agent, e.g. "--model opus" Workers can also select Claude Code, OpenCode, Codex, Grok, - Muse or DeepSeek Harness in the UI + Muse, DeepSeek Harness, Goose, Cline or OpenHands in the UI --dsh-profile DeepSeek Harness profile for its workers, over the ACP server (default "acp", env AGENT_OFFICE_DSH_PROFILE) --tls-cert Serve HTTPS with this certificate (PEM) diff --git a/src/server/providers/cline.ts b/src/server/providers/cline.ts new file mode 100644 index 000000000..fdc4ad451 --- /dev/null +++ b/src/server/providers/cline.ts @@ -0,0 +1,29 @@ +// Cline runs as an interactive PTY worker. Its CLI takes the first prompt on argv and keeps the TUI +// open for later turns. A TUI resumed with --id ignores a new argv prompt, so an Office restart +// begins a new Cline session rather than silently dropping the carry-on prompt. +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import type { ProviderAdapter } from './types.js'; + +const NVIDIA_MODEL = 'nvidia/nemotron-3.5-super-vl-120b-a12b'; + +export const cline: ProviderAdapter = { + id: 'cline', + launch({ prompt, cwd }) { + // Office owns the entire invocation. Saved --agent-args can change credentials, permission + // policy, or session selection, so this provider intentionally starts from a clean argv. + const args = ['-i', '--auto-approve', 'false', '-P', 'nvidia', '-m', NVIDIA_MODEL, '-c', cwd]; + if (prompt) args.push('--', prompt); + return { + args, + // The bundled Desktop CLI needs this file to load Cline plugins. An already configured path + // wins, and other platforms (or installs without Desktop) keep their own runtime behavior. + finishEnv(env) { + if (process.platform !== 'win32' || env.CLINE_PLUGIN_BOOTSTRAP_PATH || !env.LOCALAPPDATA) return; + const bootstrap = path.join(env.LOCALAPPDATA, 'Cline', 'bin', 'extensions', 'plugin-sandbox-bootstrap.js'); + if (existsSync(bootstrap)) env.CLINE_PLUGIN_BOOTSTRAP_PATH = bootstrap; + }, + }; + }, + titleNoise: /^Cline$/i, +}; diff --git a/src/server/providers/goose.ts b/src/server/providers/goose.ts new file mode 100644 index 000000000..fb482290f --- /dev/null +++ b/src/server/providers/goose.ts @@ -0,0 +1,36 @@ +// Goose keeps an interactive PTY open after its first --text prompt. A named run can be resumed +// with the same name, including a new first prompt, so each desk keeps its own conversation. +import type { ProviderAdapter } from './types.js'; + +const workerSession = (id: string): string => `agent-office-${id}`; + +export const goose: ProviderAdapter = { + id: 'goose', + launch({ h, prompt, resumeSessionId }) { + const name = workerSession(h.info.id); + const resume = resumeSessionId === name; + h.info.sessionId = name; + + // The office owns session and provider selection here. In particular, old --agent-args and + // inherited GOOSE_PROVIDER/GOOSE_MODEL values must not override Goose's saved NVIDIA setup. + // `goose run` requires a prompt even with --interactive. When Office wakes a desk with no + // carry-on prompt (for example after restart), enter its saved session directly instead. + const args = resume && !prompt + ? ['session', '--resume', '--name', name] + : ['run', '--interactive', '--name', name]; + if (resume && prompt) args.push('--resume'); + if (prompt) args.push('--text', prompt); + + return { + args, + finishEnv(env) { + delete env.GOOSE_PROVIDER; + delete env.GOOSE_MODEL; + // Goose's saved max effort made a short tool task spend minutes thinking. This affects only + // office workers; the person's Goose setting remains theirs for other sessions. + env.GOOSE_THINKING_EFFORT = 'low'; + }, + }; + }, + titleNoise: /^goose$/i, +}; diff --git a/src/server/providers/index.ts b/src/server/providers/index.ts index 1158d0a0a..717714e50 100644 --- a/src/server/providers/index.ts +++ b/src/server/providers/index.ts @@ -10,6 +10,9 @@ import { grok } from './grok.js'; import { muse } from './muse.js'; import { opencode } from './opencode.js'; import { pi } from './pi.js'; +import { goose } from './goose.js'; +import { cline } from './cline.js'; +import { openhands } from './openhands.js'; import type { SomeAdapter } from './types.js'; export type { LaunchPlan, ProviderAdapter, ProviderFloor, SomeAdapter } from './types.js'; @@ -23,6 +26,9 @@ export const PROVIDERS: Record = { dsh, pi, cursor, + goose, + cline, + openhands, custom, }; diff --git a/src/server/providers/openhands-bridge.ts b/src/server/providers/openhands-bridge.ts new file mode 100644 index 000000000..e92a50697 --- /dev/null +++ b/src/server/providers/openhands-bridge.ts @@ -0,0 +1,321 @@ +// A self-contained Node terminal for OpenHands Agent Canvas. Written into the Office data directory +// by the provider adapter, since the packaged server build does not copy source .mjs assets. +export const OPENHANDS_BRIDGE_SOURCE = String.raw`import { readFile, realpath, stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import path from 'node:path'; + +const BASE = 'http://127.0.0.1:8000'; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const ACTIVE = new Set(['running', 'idle', 'waiting_for_confirmation']); +const args = process.argv.slice(2); +const option = (name) => { + const at = args.indexOf(name); + return at >= 0 ? args[at + 1] : undefined; +}; +const cwdArg = option('--cwd'); +const initialPrompt = option('--prompt'); +let conversationId = option('--resume'); +let sessionKey; +let currentCwd; +let verifiedResume = false; +let busy = false; +let stopping = false; +let lastTimestamp; +const seen = new Set(); + +// Agent/tool text is untrusted terminal content. Strip control bytes (including ESC and C1) +// before it reaches the PTY; retain tabs and line feeds for readable tool results. +function safeText(value) { return String(value ?? '').replace(/[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g, ''); } +function output(message) { process.stdout.write(safeText(message) + '\r\n'); } +function short(value, limit = 10000) { + const text = safeText(value); + return text.length <= limit ? text : text.slice(0, limit) + '\n[Output truncated in Agent Office]'; +} +function textParts(parts) { + return Array.isArray(parts) ? parts.filter((x) => x && x.type === 'text' && typeof x.text === 'string').map((x) => x.text).join('\n') : ''; +} +function normalizeDir(dir) { + const resolved = path.resolve(dir); + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; +} +async function key() { + if (sessionKey) return sessionKey; + const location = path.join(homedir(), '.openhands', 'agent-canvas', 'api-key.txt'); + let value; + try { value = (await readFile(location, 'utf8')).trim(); } + catch { throw new Error('OpenHands Agent Canvas session key is unavailable for this account. Start Agent Canvas and check its local sign-in.'); } + if (!value) throw new Error('OpenHands Agent Canvas session key is empty.'); + sessionKey = value; + return value; +} +async function api(route, method = 'GET', body) { + const send = async (value) => { + try { + return await fetch(BASE + route, { + method, + headers: { 'X-Session-API-Key': value, ...(body === undefined ? {} : { 'content-type': 'application/json' }) }, + body: body === undefined ? undefined : JSON.stringify(body), + redirect: 'error', + signal: AbortSignal.timeout(30000), + }); + } catch { + throw new Error('Cannot reach OpenHands Agent Canvas at 127.0.0.1:8000. Start Agent Canvas and try again.'); + } + }; + const previous = await key(); + let response = await send(previous); + if (response.status === 401) { + sessionKey = undefined; + const refreshed = await key(); + if (refreshed !== previous) response = await send(refreshed); + } + if (!response.ok) { + const error = new Error('OpenHands Agent Canvas API returned HTTP ' + response.status + ' for ' + method + ' ' + route.split('?')[0]); + error.status = response.status; + throw error; + } + return response.status === 204 ? {} : response.json(); +} +async function hook(type, status, fields = {}) { + const base = process.env.AGENT_OFFICE_HOOK_URL; + const worker = process.env.AGENT_OFFICE_WORKER_ID; + const token = process.env.AGENT_OFFICE_HOOK_TOKEN; + if (!base || !worker || !token || !conversationId) return; + try { + const url = new URL('/hooks/openhands', base); + if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1') return; + url.searchParams.set('worker', worker); + await fetch(url, { + method: 'POST', + headers: { authorization: 'Bearer ' + token, 'content-type': 'application/json' }, + body: JSON.stringify({ type, status, sessionId: conversationId, + ...Object.fromEntries(Object.entries(fields).map(([name, value]) => [name, typeof value === 'string' ? short(value, 4000) : value])) }), + redirect: 'error', + signal: AbortSignal.timeout(2500), + }); + } catch { /* The terminal stays usable if Office restarts. */ } +} +async function checkCwd() { + if (!cwdArg || !path.isAbsolute(cwdArg)) throw new Error('Agent Office did not provide an absolute project directory.'); + const candidate = await realpath(cwdArg); + if (!(await stat(candidate)).isDirectory()) throw new Error('Agent Office project directory is not a directory.'); + currentCwd = candidate; +} +function isNvidiaModel(model) { + return typeof model === 'string' && model.startsWith('nvidia_nim/') && model.length > 'nvidia_nim/'.length; +} +async function verifyResume() { + if (!conversationId || verifiedResume) return; + if (!UUID.test(conversationId)) throw new Error('The saved OpenHands conversation ID is invalid.'); + let existing; + try { existing = await api('/api/conversations/' + conversationId); } + catch (error) { + if (error.status !== 404) throw error; + output('The prior OpenHands conversation is unavailable; a new conversation will start with the next prompt.'); + conversationId = undefined; + return; + } + const previous = existing.workspace && existing.workspace.working_dir; + if (!previous || normalizeDir(previous) !== normalizeDir(currentCwd)) { + throw new Error('The saved OpenHands conversation belongs to a different project directory; refusing to resume it.'); + } + if (!isNvidiaModel(existing.agent && existing.agent.llm && existing.agent.llm.model)) { + throw new Error('The saved OpenHands conversation does not use an NVIDIA model; refusing to resume it in this Office worker.'); + } + verifiedResume = true; + await events(false); + output('Resumed OpenHands conversation ' + conversationId + '.'); + await hook('session', 'starting'); + if (existing.execution_status === 'running' || existing.execution_status === 'waiting_for_confirmation') await watchRun(); + else await hook('session', 'done'); +} +async function createConversation(prompt) { + const profiles = await api('/api/agent-profiles'); + const active = profiles.active_agent_profile_id; + const agentProfile = Array.isArray(profiles.profiles) ? profiles.profiles.find((p) => p.id === active) : undefined; + if (typeof active !== 'string' || !UUID.test(active) || !agentProfile) { + throw new Error('OpenHands Agent Canvas has no active agent profile. Select the NVIDIA profile in Agent Canvas.'); + } + const llmRef = agentProfile.llm_profile_ref; + if (typeof llmRef !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(llmRef)) { + throw new Error('The active OpenHands agent profile has no valid model profile reference. Select the NVIDIA profile in Agent Canvas.'); + } + const llmProfile = await api('/api/profiles/' + encodeURIComponent(llmRef)); + if (!isNvidiaModel(llmProfile.config && llmProfile.config.model)) { + throw new Error('The active OpenHands agent profile does not use NVIDIA. Select the NVIDIA profile in Agent Canvas.'); + } + const created = await api('/api/conversations', 'POST', { + workspace: { working_dir: currentCwd }, + agent_profile_id: active, + initial_message: { role: 'user', content: [{ type: 'text', text: prompt }], run: true }, + autotitle: false, + }); + if (typeof created.id !== 'string' || !UUID.test(created.id)) throw new Error('OpenHands did not return a valid conversation ID.'); + conversationId = created.id; + verifiedResume = true; + seen.clear(); + lastTimestamp = undefined; + output('OpenHands conversation ' + conversationId + ' started in ' + currentCwd + '.'); + await hook('session', 'starting'); +} +async function events(display) { + let page; + let count = 0; + let newest = lastTimestamp; + do { + const query = new URLSearchParams({ limit: '100' }); + if (lastTimestamp) query.set('timestamp__gte', lastTimestamp); + if (page) query.set('page_id', page); + const batch = await api('/api/conversations/' + conversationId + '/events/search?' + query); + if (!Array.isArray(batch.items)) throw new Error('OpenHands returned an invalid event page.'); + for (const event of batch.items) { + if (typeof event.timestamp === 'string' && (!newest || event.timestamp > newest)) newest = event.timestamp; + if (typeof event.id !== 'string' || seen.has(event.id)) continue; + seen.add(event.id); + if (display) await showEvent(event); + } + page = typeof batch.next_page_id === 'string' && batch.next_page_id ? batch.next_page_id : undefined; + count++; + if (count > 100) throw new Error('OpenHands event history exceeded the bridge page limit.'); + } while (page); + lastTimestamp = newest; +} +async function showEvent(event) { + if (event.kind === 'ActionEvent') { + const action = event.action || {}; + if (action.kind === 'FinishAction') { + if (action.message) output('\n' + short(action.message)); + } else { + const tool = event.tool_name || action.kind || 'tool'; + const description = typeof action.command === 'string' ? action.command : (event.summary || action.kind || tool); + output('\n[' + tool + '] ' + short(description, 5000)); + await hook('tool', 'working', { tool: String(tool), detail: short(description, 80) }); + } + } else if (event.kind === 'ObservationEvent') { + const observation = event.observation || {}; + if (observation.kind === 'FinishObservation') return; + const result = textParts(observation.content); + const code = typeof observation.exit_code === 'number' ? 'exit ' + observation.exit_code + ': ' : ''; + if (result || code) output('[' + code + (observation.kind || 'result') + ']\n' + short(result)); + } else if (event.kind === 'MessageEvent' && event.source === 'agent') { + const reply = textParts(event.llm_message && event.llm_message.content); + if (reply) output('\n' + short(reply)); + } else if (event.kind === 'ErrorEvent') { + output('[OpenHands reported an error]'); + } +} +async function watchRun() { + let waiting = false; + const deadline = Date.now() + 30 * 60 * 1000; + let idleSince; + while (!stopping) { + if (Date.now() > deadline) throw new Error('OpenHands turn exceeded 30 minutes. Inspect Agent Canvas before submitting another prompt.'); + await events(true); + const state = await api('/api/conversations/' + conversationId); + const status = state.execution_status; + if (status === 'idle') { + idleSince ??= Date.now(); + if (Date.now() - idleSince > 30000) throw new Error('OpenHands remained idle for 30 seconds. Inspect Agent Canvas before submitting another prompt.'); + } else idleSince = undefined; + if (status === 'waiting_for_confirmation') { + if (!waiting) { + output('OpenHands needs confirmation in Agent Canvas.'); + await hook('permission', 'needs_input', { detail: 'Approve the pending OpenHands action in Agent Canvas.' }); + waiting = true; + } + } else if (waiting && status === 'running') { + waiting = false; + await hook('session', 'working'); + } + if (!ACTIVE.has(status)) { + await new Promise((resolve) => setTimeout(resolve, 300)); + await events(true); + if (status === 'error' || status === 'stuck') { + output('OpenHands ended with status ' + status + '. Inspect Agent Canvas for details.'); + await hook('error', 'needs_input', { detail: 'OpenHands status: ' + status }); + } else { + if (status === 'paused') output('OpenHands run paused.'); + await hook('session', 'done'); + } + return; + } + await new Promise((resolve) => setTimeout(resolve, 1200)); + } +} +async function handlePrompt(prompt) { + const clean = String(prompt || '').replace(/\r\n?/g, '\n').trim(); + if (!clean) return; + busy = true; + try { + await checkCwd(); + await verifyResume(); + output('\n> ' + clean); + if (!conversationId) await createConversation(clean); + else { + const body = { role: 'user', content: [{ type: 'text', text: clean }], run: true }; + try { await api('/api/conversations/' + conversationId + '/events', 'POST', body); } + catch (error) { + if (error.status !== 429) throw error; + throw new Error('OpenHands rate limited the prompt. Check Agent Canvas for whether it was saved before retrying; Office did not start another run.'); + } + } + await hook('prompt', 'working', { prompt: clean }); + await watchRun(); + } catch (error) { + output('[OpenHands bridge] ' + (error instanceof Error ? error.message : String(error))); + await hook('error', 'needs_input', { detail: error instanceof Error ? error.message : 'Bridge error' }); + } finally { + busy = false; + if (!stopping) output('\nOpenHands ready for another prompt.'); + } +} +let queue = Promise.resolve(); +function enqueue(prompt) { queue = queue.then(() => handlePrompt(prompt)); } +async function interrupt() { + if (!busy || !conversationId) return; + try { await api('/api/conversations/' + conversationId + '/interrupt', 'POST'); } + catch (error) { output('[OpenHands bridge] Could not interrupt: ' + error.message); } +} + +let pending = ''; +let line = ''; +let paste = false; +function onInput(chunk) { + pending += chunk; + while (pending) { + if (pending.startsWith('\x1b[200~')) { paste = true; pending = pending.slice(6); continue; } + if (pending.startsWith('\x1b[201~')) { paste = false; pending = pending.slice(6); continue; } + if (pending[0] === '\x1b') { + if ('\x1b[200~'.startsWith(pending) || '\x1b[201~'.startsWith(pending)) break; + const match = /^\x1b\[[0-9;]*[A-Za-z~]/.exec(pending); + if (match) { pending = pending.slice(match[0].length); continue; } + pending = pending.slice(1); + continue; + } + const ch = pending[0]; + pending = pending.slice(1); + if (ch === '\x03') { void interrupt(); continue; } + if (!paste && (ch === '\r' || ch === '\n')) { + const submitted = line; + line = ''; + if (submitted.trim()) enqueue(submitted); + } else if (ch === '\x7f' || ch === '\b') line = line.slice(0, -1); + else line += ch; + } +} +process.stdin.setEncoding('utf8'); +if (process.stdin.isTTY && process.stdin.setRawMode) process.stdin.setRawMode(true); +process.stdin.on('data', onInput); +process.stdin.resume(); +process.on('SIGINT', () => { void interrupt(); }); +process.on('SIGTERM', () => { stopping = true; void interrupt().finally(() => process.exit(0)); }); + +output('OpenHands Agent Canvas bridge (local API). Ctrl+C interrupts the current run.'); +if (initialPrompt) enqueue(initialPrompt); +else if (conversationId) { + queue = queue.then(async () => { + try { await checkCwd(); await verifyResume(); output('OpenHands ready for another prompt.'); } + catch (error) { output('[OpenHands bridge] ' + error.message); await hook('error', 'needs_input', { detail: error.message }); } + }); +} else output('OpenHands ready for a prompt.'); +`; diff --git a/src/server/providers/openhands.ts b/src/server/providers/openhands.ts new file mode 100644 index 000000000..b78d998ce --- /dev/null +++ b/src/server/providers/openhands.ts @@ -0,0 +1,54 @@ +// OpenHands Agent Canvas has a local authenticated API, but no installed interactive CLI. A small +// terminal bridge gives Agent Office a persistent PTY and reports the same lifecycle as OpenCode. +import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import type { OpenCodeStatusEvent } from '../opencode.js'; +import type { WorkerHandle } from '../workers/types.js'; +import { truncate } from '../workers/util.js'; +import { OPENHANDS_BRIDGE_SOURCE } from './openhands-bridge.js'; +import { reduceStatus, type StatusState } from './opencode.js'; +import type { ProviderAdapter } from './types.js'; + +interface OpenHandsSetup { bridge: string } +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function statusEvent(value: unknown): value is OpenCodeStatusEvent { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const v = value as Record; + return typeof v.sessionId === 'string' && UUID.test(v.sessionId) + && typeof v.type === 'string' && ['session', 'prompt', 'tool', 'permission', 'question', 'error'].includes(v.type) + && typeof v.status === 'string' && ['starting', 'working', 'needs_input', 'done'].includes(v.status) + && (v.prompt === undefined || typeof v.prompt === 'string') + && (v.tool === undefined || typeof v.tool === 'string') + && (v.detail === undefined || typeof v.detail === 'string'); +} + +function openHandsHook(h: WorkerHandle, _event: string, payload: unknown): boolean { + if (!statusEvent(payload)) return false; + const accepted = reduceStatus(h, payload, { idleOnStart: true }); + if (accepted && payload.type === 'tool' && payload.tool) h.noteTool(truncate(payload.tool, 80)); + return accepted; +} + +export const openhands: ProviderAdapter = { + id: 'openhands', + createState: () => ({}), + prepare({ dataDir }) { + const dir = path.join(dataDir, 'openhands'); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const bridge = path.join(dir, 'bridge.mjs'); + writeFileSync(bridge, OPENHANDS_BRIDGE_SOURCE, { mode: 0o600 }); + chmodSync(bridge, 0o600); + return { bridge }; + }, + launch({ cwd, prompt, resumeSessionId, setup }) { + // The provider's bin is Node. Do not pass user --agent-args through as Node flags; the bridge + // uses only its own fixed arguments and the worker's verified project directory. + const args = [setup.bridge, '--cwd', cwd]; + if (resumeSessionId) args.push('--resume', resumeSessionId); + if (prompt) args.push('--prompt', prompt); + return { args, rotateToken: true }; + }, + hook: { strictJson: true, handle: openHandsHook }, + titleNoise: /^OpenHands( Agent Canvas)?$/i, +}; diff --git a/src/shared/providers.ts b/src/shared/providers.ts index 720dd8725..f8b7b79d6 100644 --- a/src/shared/providers.ts +++ b/src/shared/providers.ts @@ -4,7 +4,7 @@ // // Browser-safe: no node imports, the hire dialog reads this too. -export const AGENT_PROVIDERS = ['claude', 'opencode', 'codex', 'grok', 'muse', 'dsh', 'pi', 'cursor', 'custom'] as const; +export const AGENT_PROVIDERS = ['claude', 'opencode', 'codex', 'grok', 'muse', 'dsh', 'pi', 'cursor', 'goose', 'cline', 'openhands', 'custom'] as const; export type AgentProvider = (typeof AGENT_PROVIDERS)[number]; @@ -320,6 +320,27 @@ export const PROVIDER_META: Record = { }, usage: { note: 'Cursor uses the Cursor CLI login on the office machine. Usage and cost stay in its terminal and your Cursor account; the office does not meter them.' }, }, + goose: { + label: 'Goose', + name: 'Goose', + bin: 'goose', + unpicked: 'Uses the NVIDIA provider and model already configured in Goose.', + usage: { note: 'Goose usage and cost stay in its terminal; the office does not meter them.' }, + }, + cline: { + label: 'Cline', + name: 'Cline', + bin: 'cline', + unpicked: 'Uses the NVIDIA provider and model verified for Cline on this computer.', + usage: { note: 'Cline usage and cost stay in its terminal; the office does not meter them.' }, + }, + openhands: { + label: 'OpenHands', + name: 'OpenHands', + bin: 'node', + unpicked: 'Uses the active OpenHands agent profile, configured for NVIDIA on this computer.', + usage: { note: 'OpenHands usage and cost stay in Agent Canvas; the office does not meter them.' }, + }, custom: { label: 'Custom', name: 'Custom', diff --git a/tests/agents.test.ts b/tests/agents.test.ts index 1223f797d..0f9b4b1de 100644 --- a/tests/agents.test.ts +++ b/tests/agents.test.ts @@ -26,6 +26,10 @@ test('detects the configured provider from Unix and Windows command paths', () = assert.equal(configuredProvider('cursor-agent.cmd'), 'cursor'); // `cursor` is the editor's launcher, not the agent. assert.equal(configuredProvider('cursor'), 'custom'); + // OpenHands launches its bridge with Node, but a configured Node program remains custom. + assert.equal(configuredProvider('node'), 'custom'); + assert.equal(configuredProvider('C:\\Program Files\\nodejs\\node.exe'), 'custom'); + assert.equal(providerCommand('openhands', 'claude'), 'node'); assert.equal(configuredProvider('my-agent'), 'custom'); }); @@ -107,7 +111,8 @@ test('reasoning effort joins Claude for DeepSeek Harness', () => { assert.match(validateWorkerEffort('agent', 'custom', 'high') ?? '', /Claude Code, OpenCode, Codex, Grok, Muse, DeepSeek Harness or Pi/); }); -test('every provider the office knows the CLI of takes a model and (but for Cursor) an effort, with the fields to pick them', () => { +test('configurable providers offer model and effort fields; fixed NVIDIA providers explain their configuration', () => { + const fixedNvidia = new Set(['goose', 'cline', 'openhands']); for (const provider of AGENT_PROVIDERS) { const meta = PROVIDER_META[provider]; if (provider === 'custom') { @@ -117,6 +122,15 @@ test('every provider the office knows the CLI of takes a model and (but for Curs assert.ok(meta.unpicked); continue; } + if (fixedNvidia.has(provider)) { + assert.ok(meta.bin, `${provider} has a command to launch`); + assert.ok(meta.unpicked?.includes('NVIDIA'), `${provider} explains its NVIDIA configuration`); + assert.equal(meta.validModel, undefined, `${provider} has no model picker`); + assert.equal(meta.invalidModel, undefined, `${provider} has no model validation message`); + assert.equal(meta.takesEffort, undefined, `${provider} has no effort picker`); + assert.equal(meta.models, undefined, `${provider} has no model field`); + continue; + } assert.ok(meta.validModel && meta.invalidModel, `${provider} takes a model`); // Cursor's effort is part of its model id (`model[effort=high]`), so it has no effort field of its own. if (provider === 'cursor') assert.equal(meta.takesEffort, undefined); diff --git a/tests/goose.test.ts b/tests/goose.test.ts new file mode 100644 index 000000000..ee540a7e0 --- /dev/null +++ b/tests/goose.test.ts @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { goose } from '../src/server/providers/goose.js'; + +test('Goose starts and resumes a named desk session with the right command', () => { + const info = { id: 'desk-123', sessionId: undefined as string | undefined }; + const h = { info } as never; + const base = { h, args: ['--old-agent-arg'], cwd: 'C:\\workspace', setup: undefined }; + + const first = goose.launch({ ...base, prompt: 'Start work' }); + assert.deepEqual(first.args, ['run', '--interactive', '--name', 'agent-office-desk-123', '--text', 'Start work']); + assert.equal(info.sessionId, 'agent-office-desk-123'); + + const resumeWithoutPrompt = goose.launch({ ...base, resumeSessionId: info.sessionId }); + assert.deepEqual(resumeWithoutPrompt.args, ['session', '--resume', '--name', 'agent-office-desk-123']); + + const resumeWithPrompt = goose.launch({ ...base, resumeSessionId: info.sessionId, prompt: 'Continue work' }); + assert.deepEqual(resumeWithPrompt.args, ['run', '--interactive', '--name', 'agent-office-desk-123', '--resume', '--text', 'Continue work']); + + const env = { GOOSE_PROVIDER: 'stale', GOOSE_MODEL: 'stale', OTHER: 'keep' }; + resumeWithPrompt.finishEnv?.(env); + assert.equal(env.GOOSE_PROVIDER, undefined); + assert.equal(env.GOOSE_MODEL, undefined); + assert.equal(env.OTHER, 'keep'); + assert.equal(env.GOOSE_THINKING_EFFORT, 'low'); +}); diff --git a/tests/openhands-bridge.test.ts b/tests/openhands-bridge.test.ts new file mode 100644 index 000000000..cd1621c6c --- /dev/null +++ b/tests/openhands-bridge.test.ts @@ -0,0 +1,113 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { createServer } from 'node:http'; +import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { OPENHANDS_BRIDGE_SOURCE } from '../src/server/providers/openhands-bridge.js'; + +const AGENT_ID = '4d92aeec-2478-484c-9663-8d3956bb3c01'; +const CONVERSATION_ID = 'ac40c16d-be36-4bfb-8740-a689121ddf89'; + +async function probe(model: string, resume = false, scenario: 'done' | 'idle' | 'rate-limit' = 'done') { + const home = await mkdtemp(path.join(tmpdir(), 'agent-office-bridge-test-')); + const project = path.join(home, 'project'); + await mkdir(project); + const canonicalProject = await realpath(project); + let created = 0; + let extraRun = 0; + const server = createServer((req, res) => { + if (req.headers['x-session-api-key'] !== 'local-test-key') { res.writeHead(401).end(); return; } + const route = new URL(req.url ?? '/', 'http://127.0.0.1').pathname; + let body: unknown; + if (route === '/api/agent-profiles') body = { active_agent_profile_id: AGENT_ID, profiles: [{ id: AGENT_ID, llm_profile_ref: 'active-profile' }] }; + else if (route === '/api/profiles/active-profile') body = { config: { model } }; + else if (route === '/api/conversations' && req.method === 'POST') { created++; body = { id: CONVERSATION_ID }; } + else if (route === `/api/conversations/${CONVERSATION_ID}/events/search`) body = { + items: [ + { id: 'event-1', timestamp: '2026-10-22T00:00:00Z', kind: 'ActionEvent', tool_name: 'terminal', action: { kind: 'TerminalAction', command: '\x1b[2Junsafe-command\x07' } }, + { id: 'event-2', timestamp: '2026-10-22T00:00:01Z', kind: 'ObservationEvent', observation: { kind: 'TerminalObservation', content: [{ type: 'text', text: '\x1b]8;;https://example.invalid\x07unsafe-output\x1b\\' }] } }, + ], + }; + else if (route === `/api/conversations/${CONVERSATION_ID}/events` && scenario === 'rate-limit') { res.writeHead(429).end(); return; } + else if (route === `/api/conversations/${CONVERSATION_ID}/run`) { extraRun++; body = {}; } + else if (route === `/api/conversations/${CONVERSATION_ID}`) body = { workspace: { working_dir: canonicalProject }, agent: { llm: { model } }, execution_status: scenario === 'idle' ? 'idle' : 'done' }; + else { res.writeHead(404).end(); return; } + res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(body)); + }); + try { + await mkdir(path.join(home, '.openhands', 'agent-canvas'), { recursive: true }); + await writeFile(path.join(home, '.openhands', 'agent-canvas', 'api-key.txt'), 'local-test-key'); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + assert.ok(address && typeof address !== 'string'); + const url = `http://127.0.0.1:${address.port}`; + const bridge = OPENHANDS_BRIDGE_SOURCE.replace("const BASE = 'http://127.0.0.1:8000';", `const BASE = '${url}';`) + .replace('Date.now() - idleSince > 30000', 'Date.now() - idleSince > 10') + .replace('setTimeout(resolve, 1200)', 'setTimeout(resolve, 20)'); + assert.notEqual(bridge, OPENHANDS_BRIDGE_SOURCE); + const script = path.join(home, 'bridge.mjs'); + await writeFile(script, bridge); + const child = spawn(process.execPath, [script, '--cwd', project, ...(resume ? ['--resume', CONVERSATION_ID] : []), ...(!resume || scenario === 'rate-limit' ? ['--prompt', 'Check the provider'] : [])], { + env: { ...process.env, USERPROFILE: home, HOME: home, AGENT_OFFICE_HOOK_URL: '' }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let output = ''; + let error = ''; + const marker = resume && !model.startsWith('nvidia_nim/') + ? 'refusing to resume it in this Office worker.' + : 'OpenHands ready for another prompt.'; + try { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Bridge probe timed out: ' + output.slice(-300) + error.slice(-200))), 12000); + child.stdout.on('data', (chunk) => { + output += chunk.toString(); + if (output.includes(marker)) { clearTimeout(timeout); resolve(); } + }); + child.stderr.on('data', (chunk) => { error += chunk.toString(); }); + child.once('error', (cause) => { clearTimeout(timeout); reject(cause); }); + child.once('exit', (code) => { if (!output.includes(marker)) { clearTimeout(timeout); reject(new Error(`Bridge exited ${code}: ${error}`)); } }); + }); + } finally { + if (child.exitCode === null) { child.kill(); await once(child, 'exit'); } + } + return { output, created, extraRun }; + } finally { + await new Promise((resolve) => server.close(() => resolve())); + assert.equal(path.dirname(home), tmpdir()); + await rm(home, { recursive: true, force: true }); + } +} + +test('OpenHands bridge rejects a non-NVIDIA active profile before creating a conversation', async () => { + const result = await probe('openai/other-model'); + assert.match(result.output, /does not use NVIDIA/); + assert.equal(result.created, 0); +}); + +test('OpenHands bridge rejects a resumed conversation that uses another provider', async () => { + const result = await probe('openai/other-model', true); + assert.match(result.output, /does not use an NVIDIA model/); + assert.equal(result.created, 0); +}); + +test('OpenHands bridge starts with NVIDIA and strips terminal controls from tool output', async () => { + const result = await probe('nvidia_nim/z-ai/glm-5.3'); + assert.equal(result.created, 1); + assert.match(result.output, /unsafe-command/); + assert.match(result.output, /unsafe-output/); + assert.doesNotMatch(result.output.replace(/\r\n/g, '\n'), /[\x00-\x08\x0B-\x1F\x7F-\x9F]/); +}); + +test('OpenHands bridge stops polling a conversation that stays idle', async () => { + const result = await probe('nvidia_nim/z-ai/glm-5.3', false, 'idle'); + assert.match(result.output, /remained idle for 30 seconds/); +}); + +test('OpenHands bridge reports ambiguous rate limits without starting an extra run', async () => { + const result = await probe('nvidia_nim/z-ai/glm-5.3', true, 'rate-limit'); + assert.match(result.output, /Check Agent Canvas for whether it was saved/); + assert.equal(result.extraRun, 0); +}); From 898ec89189edacdde18a652e342aa8aa15d0ed24 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 23 Oct 2026 12:02:51 -0400 Subject: [PATCH 2/7] Repair Windows agent lifecycle, meetings, queue and preview discovery --- docs/agents.md | 8 ++- src/client/ui/meeting.ts | 1 + src/server/cline-hooks.ts | 67 ++++++++++++++++++ src/server/goose-hooks.ts | 84 ++++++++++++++++++++++ src/server/limits.ts | 11 ++- src/server/meetings.ts | 20 ++++++ src/server/providers/cline.ts | 34 ++++++++- src/server/providers/goose.ts | 36 +++++++++- src/server/providers/openhands-bridge.ts | 22 +++--- src/server/providers/openhands.ts | 1 + src/server/providers/types.ts | 2 + src/server/services.ts | 31 ++++++++- src/server/tasks.ts | 9 ++- src/server/workers/manager.ts | 6 +- src/server/workers/tasks.ts | 4 ++ src/server/worktrees.ts | 3 +- src/server/ws/handlers/meetings.ts | 4 ++ src/shared/protocol/meetings.ts | 2 + tests/cline-hooks.test.ts | 88 ++++++++++++++++++++++++ tests/goose-hooks.test.ts | 87 +++++++++++++++++++++++ tests/goose.test.ts | 17 +++-- tests/limits.test.ts | 17 +++++ tests/meetings.test.ts | 30 ++++++++ tests/openhands-bridge.test.ts | 29 +++++--- tests/repos.test.ts | 2 +- tests/restart-prompt.test.ts | 11 +++ tests/services.test.ts | 38 ++++++++++ tests/task-namer.test.ts | 15 ++++ 28 files changed, 643 insertions(+), 36 deletions(-) create mode 100644 src/server/cline-hooks.ts create mode 100644 src/server/goose-hooks.ts create mode 100644 tests/cline-hooks.test.ts create mode 100644 tests/goose-hooks.test.ts create mode 100644 tests/limits.test.ts create mode 100644 tests/restart-prompt.test.ts create mode 100644 tests/services.test.ts create mode 100644 tests/task-namer.test.ts diff --git a/docs/agents.md b/docs/agents.md index aed9ec313..49daebc63 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -24,12 +24,16 @@ This local build adds three provider choices. Their API credentials stay in each | Worker | What must already be running or installed | How it operates in the office | Restart behavior | | --- | --- | --- | --- | -| Goose | `goose` CLI with its saved NVIDIA provider and model | Interactive PTY, one named Goose session per desk. This build sets thinking effort to `low` only for Office workers so short requests complete promptly. | Resumes its named session when the Office restarts or a worker is woken. If that session was removed externally, the terminal reports the missing session. | +| Goose | `goose` CLI with its existing NVIDIA sign-in | Interactive PTY, one named session per desk, using NVIDIA Nemotron 3.5 Super VL and low thinking effort. Native hooks report work and completion to meetings and the queue. Standalone Goose preferences are unchanged. | Resumes the same named session and Office model. If that session was removed externally, the terminal reports the missing session. | | Cline | `cline` CLI with the existing Cline NVIDIA sign-in | Interactive PTY with the verified NVIDIA provider and model; Office requests disabled automatic approval at launch. Cline's native setting can still change: keep **Auto-approve all disabled** for manual review and approve requested actions in the terminal. | A living PTY survives an ordinary Office restart through the host process. If Cline itself exited, waking the desk starts a fresh conversation because the CLI's `--id` path ignores a new initial prompt. | -| OpenHands | Agent Canvas running on `127.0.0.1:8000` with an active NVIDIA agent profile | A local Node terminal bridge creates or follows Agent Canvas conversations through its authenticated API. The bridge reads Agent Canvas's local session key in memory and sends it only to loopback. | Resumes the saved conversation ID after confirming its workspace is the same directory. If the prior conversation is gone, the next prompt starts a new one. | +| OpenHands | Agent Canvas running on `127.0.0.1:8000` with an active NVIDIA agent profile | A local terminal bridge follows the authenticated API. Long runs have no overall turn deadline; individual requests time out, status is reported each minute, and Ctrl+C interrupts the run. | Reconnects to an ongoing saved conversation after checking its workspace and NVIDIA model, without submitting a second prompt. If the conversation is gone, the next prompt starts a new one. | Pick one of these names when hiring a desk worker or set Goose as the default with `--agent goose`. The current local build uses each worker's verified NVIDIA model; its hire dialog has no separate model or effort field for these three. A failure to find the CLI or the Agent Canvas service appears on that worker's terminal. OpenHands actions requiring confirmation are approved in Agent Canvas. +Cline uses project lifecycle hooks and its native local state mode so a shared background hub cannot lose the Office worker identity. Its existing provider store is reused. This mode is not an OS sandbox. A tool check is shown as needing input until the native tool proceeds; inspect the terminal for approval requests. + +A stopped meeting can be continued with **Resume meeting** while its original workers and notes remain. Completed notes are reused; a missing or exited worker must be recovered first. Windows Services can discover web servers under a worker's process tree. A detached server whose parent has already exited cannot currently be assigned reliably, so keep the preview running under its worker terminal. + What's picked shows next to the worker everywhere it's named (the Workers panel, its task card, its terminal's title, the queue), by the model's name rather than its family: **Opus 5.5**, not "Opus". For Claude Code that name comes from the session itself once the worker has spoken, so a worker left on Default says which model the default is, and one switched with `/model` in its terminal says the new one. For OpenCode, the **Model** field selects the initial model for a new worker or queue task. Suggestions come from `opencode models`; you can also enter a `provider/model` ID. Leave it empty to use your OpenCode settings (including configured CLI arguments). An explicit choice overrides configured model arguments for that launch and stays with a queued task when retried. **Effort** is what OpenCode calls the model's *variant*. Its TUI has no flag for one, and sends each message at the variant it remembers for the model from whichever session used it last, so the office's plugin puts the worker's first message on the effort picked for it; the TUI carries on from that message, and `Ctrl+T` in the terminal still changes it. An effort the model has no variant for changes nothing. diff --git a/src/client/ui/meeting.ts b/src/client/ui/meeting.ts index b3596ffde..ad0c3112e 100644 --- a/src/client/ui/meeting.ts +++ b/src/client/ui/meeting.ts @@ -115,6 +115,7 @@ function renderStatus(m: Meeting, body: HTMLElement, foot: HTMLElement, net: Net ...present( h('span.grow', {}, running ? 'The workers stay at the table after it ends, so you can read their terminals.' : 'Clearing the room sends the workers home. A committed output stays on its branch.'), running ? h('button.btn', { type: 'button', onclick: () => confirmDialog('Stop the meeting?', `The workers stop where they are and stay at the table. ${m.output} is only there if it was written.`, 'Stop it', () => net.send({ t: 'meeting.stop' })) }, '⛔ Stop meeting') : null, + m.status === 'stopped' && !m.cleared ? h('button.btn.primary', { type: 'button', onclick: () => net.send({ t: 'meeting.resume' }) }, '▶ Resume meeting') : null, !running && m.commit && head?.worktree ? h('button.btn', { type: 'button', title: `Push ${m.worktree?.branch} and open a pull request`, onclick: () => actions.openPr(head.id) }, head.pr ? `🔀 PR #${head.pr.number}` : '🔀 Open PR') : null, !running ? h('button.btn', { type: 'button', onclick: () => net.send({ t: 'meeting.clear' }) }, '🧹 Clear the room') : null, !running ? h('button.btn.primary', { type: 'button', onclick: callAnother }, '🤝 Call a meeting…') : null, diff --git a/src/server/cline-hooks.ts b/src/server/cline-hooks.ts new file mode 100644 index 000000000..90a9fc605 --- /dev/null +++ b/src/server/cline-hooks.ts @@ -0,0 +1,67 @@ +import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { excludeFromGit } from './config.js'; + +export const CLINE_EVENTS = ['TaskStart', 'TaskResume', 'TaskCancel', 'TaskComplete', 'TaskError', 'PreToolUse', 'PostToolUse', 'UserPromptSubmit'] as const; + +// Observation only: returning {} leaves native permissions and tool inputs unchanged. +export const CLINE_HOOK_SOURCE = String.raw`let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', chunk => { if (input.length <= 1048576) input += chunk; }); +process.stdin.on('end', async () => { + try { + if (input.length > 1048576) return; + const report = JSON.parse(input); + if (report.parent_agent_id) return; + const states = { agent_start: ['prompt', 'working'], agent_resume: ['prompt', 'working'], + prompt_submit: ['prompt', 'working'], tool_call: ['permission', 'needs_input'], tool_result: ['tool', 'working'], + agent_end: ['session', 'done'], agent_abort: ['error', 'needs_input'], agent_error: ['error', 'needs_input'] }; + const state = states[report.hookName]; + const worker = process.env.AGENT_OFFICE_WORKER_ID; + const token = process.env.AGENT_OFFICE_HOOK_TOKEN; + const base = process.env.AGENT_OFFICE_HOOK_URL; + if (!state || !/^[a-f0-9]{12}$/.test(worker || '') || !/^[a-f0-9]{32}$/.test(token || '') || !base) return; + const url = new URL('/hooks/cline', base); + if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1') return; + url.searchParams.set('worker', worker); + const payload = { type: state[0], status: state[1], sessionId: 'agent-office-' + worker }; + const tool = report.preToolUse?.toolName || report.postToolUse?.toolName || report.tool_call?.name || report.tool_result?.name; + if (typeof tool === 'string') payload.tool = tool.replace(/[\x00-\x1f\x7f-\x9f]/g, '').slice(0,120); + await fetch(url, { method: 'POST', headers: { authorization: 'Bearer ' + token, 'content-type': 'application/json' }, + body: JSON.stringify(payload), redirect: 'error', signal: AbortSignal.timeout(2500) }); + } catch { /* No raw payload or credentials in errors. */ } + finally { process.stdout.write('{}\n'); } +}); +`; + +export function writeClineHooks(dataDir: string): string { + const dir = path.join(dataDir, 'cline-hooks'); + mkdirSync(dir, { recursive: true }); + for (const event of CLINE_EVENTS) writeFileSync(path.join(dir, event + '.cjs'), CLINE_HOOK_SOURCE, { mode: 0o600 }); + return dir; +} + +/** Shipped CLI versions ignore --hooks-dir. Use native project discovery without replacing hooks. */ +export function addClineHooks(cwd: string, source: string): void { + const dir = path.join(cwd, '.cline', 'hooks'); + const root = realpathSync(cwd); + for (const relative of ['.cline', '.cline/hooks', ...CLINE_EVENTS.map(event => '.cline/hooks/' + event + '.cjs')]) { + const file = path.join(cwd, relative); + if (!existsSync(file)) continue; + const within = path.relative(root, realpathSync(file)); + if (lstatSync(file).isSymbolicLink() || within.startsWith('..') || path.isAbsolute(within)) throw new Error('Office will not write Cline hooks through linked project paths.'); + } + const wrapper = (event: string) => '// Agent Office managed lifecycle hook\nrequire(' + JSON.stringify(path.join(source, event + '.cjs')) + ');\n'; + if (existsSync(dir)) { + for (const file of readdirSync(dir)) { + const event = CLINE_EVENTS.find(e => e.toLowerCase() === path.parse(file).name.toLowerCase()); + if (!event) continue; + if (file !== event + '.cjs' || readFileSync(path.join(dir, file), 'utf8') !== wrapper(event)) throw new Error('Office will not replace an existing project Cline lifecycle hook.'); + } + } + mkdirSync(dir, { recursive: true }); + for (const event of CLINE_EVENTS) { + writeFileSync(path.join(dir, event + '.cjs'), wrapper(event)); + excludeFromGit(cwd, '/.cline/hooks/' + event + '.cjs'); + } +} diff --git a/src/server/goose-hooks.ts b/src/server/goose-hooks.ts new file mode 100644 index 000000000..5426c049c --- /dev/null +++ b/src/server/goose-hooks.ts @@ -0,0 +1,84 @@ +import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { excludeFromGit } from './config.js'; + +export const GOOSE_OFFICE_PLUGIN = 'agent-office-status'; +export const GOOSE_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'Stop'] as const; + +// Native lifecycle events, not model-controlled terminal text, advance Office workflows. +export const GOOSE_HOOK_SOURCE = String.raw`const fs = require('node:fs'); +const path = require('node:path'); +const { createHash } = require('node:crypto'); +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', chunk => { if (input.length < 1048576) input += chunk; }); +process.stdin.on('end', async () => { + try { + if (input.length > 1048576) return; + const report = JSON.parse(input); + const worker = process.env.AGENT_OFFICE_WORKER_ID; + const token = process.env.AGENT_OFFICE_HOOK_TOKEN; + const base = process.env.AGENT_OFFICE_HOOK_URL; + if (!/^[a-f0-9]{12}$/.test(worker || '') || !/^[a-f0-9]{32}$/.test(token || '') || !base) return; + const url = new URL('/hooks/goose', base); + if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1') return; + const states = { SessionStart: ['session', 'starting'], UserPromptSubmit: ['prompt', 'working'], + PreToolUse: ['tool', 'working'], PostToolUse: ['tool', 'working'], PostToolUseFailure: ['tool', 'working'], Stop: ['session', 'done'] }; + const state = states[report.event]; + if (!state) return; + if (typeof report.session_id !== 'string' || !report.session_id || report.session_id.length > 512) return; + // Child Goose sessions can inherit Office env and project plugins. Only the first native + // SessionStart for this worker token owns its lifecycle; a child Stop cannot complete its parent. + const binding = path.join(__dirname, '.goose-root-' + worker + '-' + createHash('sha256').update(token).digest('hex').slice(0,32)); + if (report.event === 'SessionStart') { + try { fs.writeFileSync(binding, report.session_id, { flag: 'wx', mode: 0o600 }); } + catch (error) { if (error.code !== 'EEXIST') return; } + } + if (fs.readFileSync(binding, 'utf8') !== report.session_id) return; + if (report.event === 'SessionStart' && process.env.AGENT_OFFICE_INITIAL_PROMPT === '1') { + state[0] = 'prompt'; state[1] = 'working'; + } + const payload = { type: state[0], status: state[1], sessionId: 'agent-office-' + worker }; + if (state[0] === 'tool' && typeof report.tool_name === 'string') payload.tool = report.tool_name.replace(/[\x00-\x1f\x7f-\x9f]/g, '').slice(0, 120); + url.searchParams.set('worker', worker); + await fetch(url, { method: 'POST', headers: { authorization: 'Bearer ' + token, 'content-type': 'application/json' }, + body: JSON.stringify(payload), redirect: 'error', signal: AbortSignal.timeout(2500) }); + } catch { /* Reporting must never change native tool approval or expose a payload. */ } +}); +`; + +export function writeGooseHook(dataDir: string): string { + mkdirSync(dataDir, { recursive: true }); + const file = path.join(dataDir, 'agent-office-goose-hook.cjs'); + writeFileSync(file, GOOSE_HOOK_SOURCE, { mode: 0o600 }); + return file; +} + +/** Same project-scoped discovery Goose itself supports; never replace a user's plugin. */ +export function addGooseHooks(cwd: string, hook: string): void { + const dir = path.join(cwd, '.agents', 'plugins', GOOSE_OFFICE_PLUGIN); + const manifest = path.join(dir, 'plugin.json'); + const root = realpathSync(cwd); + for (const relative of ['.agents', '.agents/plugins', `.agents/plugins/${GOOSE_OFFICE_PLUGIN}`, + `.agents/plugins/${GOOSE_OFFICE_PLUGIN}/plugin.json`, `.agents/plugins/${GOOSE_OFFICE_PLUGIN}/hooks`, + `.agents/plugins/${GOOSE_OFFICE_PLUGIN}/hooks/hooks.json`]) { + const candidate = path.join(cwd, relative); + if (!existsSync(candidate)) continue; + const actual = realpathSync(candidate); + const within = path.relative(root, actual); + if (lstatSync(candidate).isSymbolicLink() || within.startsWith('..') || path.isAbsolute(within)) { + throw new Error('Office will not write a Goose plugin through linked project paths.'); + } + } + if (existsSync(dir)) { + let owned = false; + try { owned = JSON.parse(readFileSync(manifest, 'utf8')).agentOfficeManaged === true; } catch { /* fail closed below */ } + if (!owned) throw new Error('The project already has an agent-office-status plugin that Office does not own.'); + } + mkdirSync(path.join(dir, 'hooks'), { recursive: true }); + const quote = (value: string) => `'${value.replaceAll('\\', '/').replaceAll("'", "'\"'\"'")}'`; + const command = `${quote(process.execPath)} ${quote(hook)}`; + writeFileSync(manifest, JSON.stringify({ name: GOOSE_OFFICE_PLUGIN, version: '1.0.0', description: 'Agent Office lifecycle reporting', agentOfficeManaged: true }, null, 2)); + writeFileSync(path.join(dir, 'hooks', 'hooks.json'), JSON.stringify({ hooks: Object.fromEntries(GOOSE_EVENTS.map(event => [event, [{ hooks: [{ type: 'command', command, timeout: 5 }] }]])) }, null, 2)); + excludeFromGit(cwd, `/.agents/plugins/${GOOSE_OFFICE_PLUGIN}/`); +} diff --git a/src/server/limits.ts b/src/server/limits.ts index 0acd89947..5ba9d2a58 100644 --- a/src/server/limits.ts +++ b/src/server/limits.ts @@ -130,7 +130,16 @@ function ask(claude: string, env: Record): Promise { return new Promise((resolve) => { let settled = false; let buf = ''; - const child = spawn(claude, args, { cwd: os.tmpdir(), env, stdio: ['pipe', 'pipe', 'ignore'] }); + const launch = () => spawn(claude, args, { cwd: os.tmpdir(), env, stdio: ['pipe', 'pipe', 'ignore'] }); + let child: ReturnType; + try { + child = launch(); + } catch { + // Windows can reject a .cmd launcher synchronously (EINVAL), before an error listener exists. + // This optional meter must fail closed without invoking a shell or rejecting the office timer. + resolve(null); + return; + } const finish = (v: any) => { if (settled) return; settled = true; diff --git a/src/server/meetings.ts b/src/server/meetings.ts index 31c8919d4..191aa6849 100644 --- a/src/server/meetings.ts +++ b/src/server/meetings.ts @@ -227,6 +227,26 @@ export class MeetingRoom { return undefined; } + /** Continue the same stopped meeting, retaining notes written after its last status update. */ + resume(by: string): string | undefined { + const m = this.current; + if (!m || m.status !== 'stopped' || m.cleared) return 'No stopped meeting can be resumed'; + const workers = new Map(this.workers.list().map(w => [w.id, w])); + for (const seat of m.seats) { + const worker = seat.workerId && workers.get(seat.workerId); + if (!worker || worker.meeting !== m.id || worker.status === 'exited' || worker.status === 'offline') return 'Resume the original meeting workers before continuing this meeting'; + } + m.status = 'running'; + delete m.reason; + delete m.finishedAt; + this.readySince.clear(); + for (const turn of m.turns) if (turn.state !== 'done') delete turn.retried; + this.events.toast(`${by} resumed the meeting on “${m.title}”`, 'info'); + this.changed(); + this.pump(); + return undefined; + } + /** Sends the last meeting's workers home and clears the table. */ clear(by: string): string | undefined { const m = this.current; diff --git a/src/server/providers/cline.ts b/src/server/providers/cline.ts index fdc4ad451..f5d83d496 100644 --- a/src/server/providers/cline.ts +++ b/src/server/providers/cline.ts @@ -3,27 +3,57 @@ // begins a new Cline session rather than silently dropping the carry-on prompt. import { existsSync } from 'node:fs'; import path from 'node:path'; +import { homedir } from 'node:os'; import type { ProviderAdapter } from './types.js'; +import { addClineHooks, writeClineHooks } from '../cline-hooks.js'; +import { reduceStatus, type StatusState } from './opencode.js'; +import type { OpenCodeStatusEvent } from '../opencode.js'; const NVIDIA_MODEL = 'nvidia/nemotron-3.5-super-vl-120b-a12b'; -export const cline: ProviderAdapter = { +export const cline: ProviderAdapter = { id: 'cline', - launch({ prompt, cwd }) { + createState: () => ({}), + prepare: ({ dataDir }) => ({ hooks: writeClineHooks(dataDir) }), + launch({ h, prompt, cwd, setup }) { + addClineHooks(cwd, setup.hooks); + h.info.sessionId = 'agent-office-' + h.info.id; // Office owns the entire invocation. Saved --agent-args can change credentials, permission // policy, or session selection, so this provider intentionally starts from a clean argv. const args = ['-i', '--auto-approve', 'false', '-P', 'nvidia', '-m', NVIDIA_MODEL, '-c', cwd]; if (prompt) args.push('--', prompt); return { args, + rotateToken: true, // The bundled Desktop CLI needs this file to load Cline plugins. An already configured path // wins, and other platforms (or installs without Desktop) keep their own runtime behavior. finishEnv(env) { + // A shared hub was started outside Office and cannot inherit this worker's hook identity. + // Keep the session in this process so per-worker native hooks receive its environment. + env.CLINE_SESSION_BACKEND_MODE = 'local'; + // The shipped interactive UI explicitly chooses auto despite that env setting. Its native + // sandbox flag forces a local session. Reuse the existing provider store; no key is copied. + // This flag controls Cline state/routing and is not an OS security boundary. + env.CLINE_SANDBOX = '1'; + env.CLINE_SANDBOX_DATA_DIR = env.CLINE_DATA_DIR || env.CLINE_SANDBOX_DATA_DIR + || path.join(env.USERPROFILE || env.HOME || homedir(), '.cline', 'data'); if (process.platform !== 'win32' || env.CLINE_PLUGIN_BOOTSTRAP_PATH || !env.LOCALAPPDATA) return; const bootstrap = path.join(env.LOCALAPPDATA, 'Cline', 'bin', 'extensions', 'plugin-sandbox-bootstrap.js'); if (existsSync(bootstrap)) env.CLINE_PLUGIN_BOOTSTRAP_PATH = bootstrap; }, }; }, + hook: { + strictJson: true, + handle(h, _event, value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const p = value as Record; + if (p.sessionId !== 'agent-office-' + h.info.id + || !['session', 'prompt', 'tool', 'permission', 'error'].includes(String(p.type)) + || !['working', 'needs_input', 'done'].includes(String(p.status)) + || (p.tool !== undefined && (typeof p.tool !== 'string' || p.tool.length > 120))) return false; + return reduceStatus(h, p as unknown as OpenCodeStatusEvent); + }, + }, titleNoise: /^Cline$/i, }; diff --git a/src/server/providers/goose.ts b/src/server/providers/goose.ts index fb482290f..dc9e72323 100644 --- a/src/server/providers/goose.ts +++ b/src/server/providers/goose.ts @@ -1,12 +1,22 @@ // Goose keeps an interactive PTY open after its first --text prompt. A named run can be resumed // with the same name, including a new first prompt, so each desk keeps its own conversation. import type { ProviderAdapter } from './types.js'; +import { addGooseHooks, writeGooseHook } from '../goose-hooks.js'; +import { reduceStatus, type StatusState } from './opencode.js'; +import type { OpenCodeStatusEvent } from '../opencode.js'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { resolveCommand } from '../workers/process.js'; const workerSession = (id: string): string => `agent-office-${id}`; +export const GOOSE_OFFICE_MODEL = 'nvidia/nemotron-3.5-super-vl-120b-a12b'; -export const goose: ProviderAdapter = { +export const goose: ProviderAdapter = { id: 'goose', - launch({ h, prompt, resumeSessionId }) { + createState: () => ({}), + prepare: ({ dataDir }) => ({ hook: writeGooseHook(dataDir) }), + launch({ h, prompt, resumeSessionId, cwd, setup }) { + addGooseHooks(cwd, setup.hook); const name = workerSession(h.info.id); const resume = resumeSessionId === name; h.info.sessionId = name; @@ -19,11 +29,21 @@ export const goose: ProviderAdapter = { ? ['session', '--resume', '--name', name] : ['run', '--interactive', '--name', name]; if (resume && prompt) args.push('--resume'); + args.push('--provider', 'nvidia', '--model', GOOSE_OFFICE_MODEL); if (prompt) args.push('--text', prompt); return { args, + rotateToken: true, finishEnv(env) { + env.AGENT_OFFICE_INITIAL_PROMPT = prompt ? '1' : '0'; + if (process.platform === 'win32') { + const git = resolveCommand('git'); + const shDir = git && path.resolve(path.dirname(git), '..', 'usr', 'bin'); + if (!shDir || !existsSync(path.join(shDir, 'sh.exe'))) throw new Error('Goose Office lifecycle hooks require Git for Windows sh.exe.'); + const key = Object.keys(env).find(k => k.toUpperCase() === 'PATH') ?? 'PATH'; + env[key] = [shDir, env[key] ?? process.env.PATH ?? ''].join(path.delimiter); + } delete env.GOOSE_PROVIDER; delete env.GOOSE_MODEL; // Goose's saved max effort made a short tool task spend minutes thinking. This affects only @@ -32,5 +52,17 @@ export const goose: ProviderAdapter = { }, }; }, + hook: { + strictJson: true, + handle(h, _event, value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const p = value as Record; + if (p.sessionId !== workerSession(h.info.id) + || !['session', 'prompt', 'tool'].includes(String(p.type)) + || !['starting', 'working', 'done'].includes(String(p.status)) + || (p.tool !== undefined && (typeof p.tool !== 'string' || p.tool.length > 120))) return false; + return reduceStatus(h, p as unknown as OpenCodeStatusEvent, { idleOnStart: true }); + }, + }, titleNoise: /^goose$/i, }; diff --git a/src/server/providers/openhands-bridge.ts b/src/server/providers/openhands-bridge.ts index e92a50697..9e12e0dc2 100644 --- a/src/server/providers/openhands-bridge.ts +++ b/src/server/providers/openhands-bridge.ts @@ -126,7 +126,10 @@ async function verifyResume() { await events(false); output('Resumed OpenHands conversation ' + conversationId + '.'); await hook('session', 'starting'); - if (existing.execution_status === 'running' || existing.execution_status === 'waiting_for_confirmation') await watchRun(); + if (ACTIVE.has(existing.execution_status)) { + await hook('session', 'working'); + await watchRun(); + } else await hook('session', 'done'); } async function createConversation(prompt) { @@ -206,17 +209,18 @@ async function showEvent(event) { } async function watchRun() { let waiting = false; - const deadline = Date.now() + 30 * 60 * 1000; - let idleSince; + let lastNotice = Date.now(); while (!stopping) { - if (Date.now() > deadline) throw new Error('OpenHands turn exceeded 30 minutes. Inspect Agent Canvas before submitting another prompt.'); await events(true); const state = await api('/api/conversations/' + conversationId); const status = state.execution_status; - if (status === 'idle') { - idleSince ??= Date.now(); - if (Date.now() - idleSince > 30000) throw new Error('OpenHands remained idle for 30 seconds. Inspect Agent Canvas before submitting another prompt.'); - } else idleSince = undefined; + // Long inference/tool runs have no overall deadline. Individual HTTP requests remain bounded. + // Keep watching the existing conversation; never submit another prompt to recover a watcher. + if (ACTIVE.has(status) && Date.now() - lastNotice >= 60000) { + output('[OpenHands status: ' + status + '; still watching this conversation]'); + if (status !== 'waiting_for_confirmation') await hook('session', 'working'); + lastNotice = Date.now(); + } if (status === 'waiting_for_confirmation') { if (!waiting) { output('OpenHands needs confirmation in Agent Canvas.'); @@ -314,8 +318,10 @@ output('OpenHands Agent Canvas bridge (local API). Ctrl+C interrupts the current if (initialPrompt) enqueue(initialPrompt); else if (conversationId) { queue = queue.then(async () => { + busy = true; try { await checkCwd(); await verifyResume(); output('OpenHands ready for another prompt.'); } catch (error) { output('[OpenHands bridge] ' + error.message); await hook('error', 'needs_input', { detail: error.message }); } + finally { busy = false; } }); } else output('OpenHands ready for a prompt.'); `; diff --git a/src/server/providers/openhands.ts b/src/server/providers/openhands.ts index b78d998ce..a82753e18 100644 --- a/src/server/providers/openhands.ts +++ b/src/server/providers/openhands.ts @@ -32,6 +32,7 @@ function openHandsHook(h: WorkerHandle, _event: string, payload: un export const openhands: ProviderAdapter = { id: 'openhands', + resumesOwnRun: true, createState: () => ({}), prepare({ dataDir }) { const dir = path.join(dataDir, 'openhands'); diff --git a/src/server/providers/types.ts b/src/server/providers/types.ts index 996c6b76e..c7b64938f 100644 --- a/src/server/providers/types.ts +++ b/src/server/providers/types.ts @@ -95,6 +95,8 @@ export interface ProviderAdapter { titleNoise?: RegExp; /** Resuming a conversation it no longer has exits before it ever starts: a fresh one is started instead. */ freshIfResumeFails?: boolean; + /** Its backend retains the running turn; reconnect instead of submitting a carry-on prompt. */ + resumesOwnRun?: boolean; /** Its hook route, /hooks/. */ hook?: ProviderHook; /** The provider whose hook route its workers report on, when not their own (a custom --agent speaks Claude Code's). */ diff --git a/src/server/services.ts b/src/server/services.ts index 689ee07df..5547a4aca 100644 --- a/src/server/services.ts +++ b/src/server/services.ts @@ -49,7 +49,7 @@ interface Tracked { function run(cmd: string, args: string[]): Promise { return new Promise((resolve) => { - execFile(cmd, args, { encoding: 'utf8', timeout: 5000, maxBuffer: 8 * 1024 * 1024 }, (_err, out) => resolve(out ?? '')); + execFile(cmd, args, { encoding: 'utf8', timeout: 5000, maxBuffer: 8 * 1024 * 1024, windowsHide: true }, (_err, out) => resolve(out ?? '')); }); } @@ -63,6 +63,7 @@ function splitAddr(addr: string): { host: string; port: number } | null { } async function listeners(): Promise { + if (process.platform === 'win32') return windowsListeners(await run('netstat.exe', ['-ano', '-p', 'tcp'])); const out: Listener[] = []; if (process.platform === 'linux') { // `ss -p` shows the owning process for this user's sockets without root. @@ -89,8 +90,34 @@ async function listeners(): Promise { return out; } +export function windowsListeners(text: string): Listener[] { + const out: Listener[] = []; + for (const line of text.split('\n')) { + const cols = line.trim().split(/\s+/); + if (cols.length !== 5 || cols[0] !== 'TCP' || cols[3] !== 'LISTENING') continue; + const addr = splitAddr(cols[1]); + const pid = Number(cols[4]); + if (addr && addr.port > 0 && addr.port <= 65535 && Number.isSafeInteger(pid) && pid > 0) out.push({ pid, ...addr }); + } + return out; +} + async function processes(): Promise> { const procs = new Map(); + if (process.platform === 'win32') { + // Only process identity/ancestry is needed. CommandLine may contain credentials; do not collect it. + const text = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', + 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name | ConvertTo-Json -Compress']); + try { + const parsed = JSON.parse(text.replace(/^\uFEFF/, '')); + for (const item of Array.isArray(parsed) ? parsed : [parsed]) { + if (!Number.isSafeInteger(item?.ProcessId) || !Number.isSafeInteger(item?.ParentProcessId) || typeof item?.Name !== 'string') continue; + const browser = /^(?:chrome|msedge|firefox|crashpad).*\.exe$/i.test(item.Name); + procs.set(item.ProcessId, { ppid: item.ParentProcessId, args: browser ? '--remote-debugging-port' : item.Name }); + } + } catch { /* A failed or truncated scan publishes no unverified process ownership. */ } + return procs; + } const text = await run('ps', ['-A', '-ww', '-o', 'pid=', '-o', 'ppid=', '-o', 'args=']); for (const line of text.split('\n')) { const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line); @@ -103,6 +130,8 @@ async function processes(): Promise> { async function cwds(pids: number[]): Promise> { const out = new Map(); if (!pids.length) return out; + // Windows CIM does not expose cwd. Only verified process ancestry is attributed there. + if (process.platform === 'win32') return out; if (process.platform === 'linux') { await Promise.all(pids.map(async (pid) => out.set(pid, await readlink(`/proc/${pid}/cwd`).catch(() => '')))); return out; diff --git a/src/server/tasks.ts b/src/server/tasks.ts index 675fd232f..a8fe55594 100644 --- a/src/server/tasks.ts +++ b/src/server/tasks.ts @@ -149,12 +149,19 @@ function run(claude: string, env: Record, system: string, input: clearTimeout(timer); resolve(v); }; - const child = spawn(claude, args, { + const launch = () => spawn(claude, args, { // A neutral directory, so it doesn't pick up the project's CLAUDE.md. cwd: os.tmpdir(), env: { ...env, MAX_THINKING_TOKENS: '0' }, stdio: ['pipe', 'pipe', 'ignore'], }); + let child: ReturnType; + try { child = launch(); } + catch { + // Optional labels must not reject the scheduler when Windows cannot spawn an npm .cmd shim. + resolve(null); + return; + } const timer = setTimeout(() => { child.kill('SIGKILL'); finish(null); diff --git a/src/server/workers/manager.ts b/src/server/workers/manager.ts index b19b2b3cd..d6316bb67 100644 --- a/src/server/workers/manager.ts +++ b/src/server/workers/manager.ts @@ -23,7 +23,7 @@ import { midTurn } from './lifecycle.js'; import { restoreWorkers, saveWorkers } from './persist.js'; import { WorkerPrs } from './pr.js'; import { WIN, binScript, defaultShell, resolveCommand, shellRun, shq, writeOfficeCommands } from './process.js'; -import { CARRY_ON_PROMPT, WorkerTasks } from './tasks.js'; +import { restartPrompt, WorkerTasks } from './tasks.js'; import { flushScreens, fullScreens, newTerm, offlineBanner, screenText, type HeadlessTerminal } from './terminal.js'; import type { HookEnv, OpenedPr, RepoSource, RunAs, Worker, WorkerContext, WorkerEvents, WorkerHandle } from './types.js'; import { clamp, safeEq, truncate } from './util.js'; @@ -322,9 +322,9 @@ export class WorkerManager { this.tasks.notePrompt(w, prompt); } // Cut off mid-turn by a restart: it gets on with it, as whoever was watching would have told it to. - const carryOn = !prompt && w.interrupted && w.info.kind === 'agent' && !!w.info.sessionId; + const carryOn = !prompt && w.info.kind === 'agent' ? restartPrompt(w.info.provider, w.info.sessionId, !!w.interrupted) : undefined; w.interrupted = false; - this.launch(w, carryOn ? CARRY_ON_PROMPT : first, w.info.sessionId); + this.launch(w, carryOn ?? first, w.info.sessionId); return undefined; } diff --git a/src/server/workers/tasks.ts b/src/server/workers/tasks.ts index d2f6059d8..954bf5373 100644 --- a/src/server/workers/tasks.ts +++ b/src/server/workers/tasks.ts @@ -19,6 +19,10 @@ const TASK_REFRESH_MS = 90_000; */ export const CARRY_ON_PROMPT = 'continue — the office restarted and interrupted you. Pick up where you left off; if you were waiting on an answer or a permission, ask again.'; +export function restartPrompt(provider: unknown, sessionId: string | undefined, interrupted: boolean): string | undefined { + return interrupted && sessionId && !providerAdapter(provider)?.resumesOwnRun ? CARRY_ON_PROMPT : undefined; +} + export class WorkerTasks { private namer: TaskNamer; diff --git a/src/server/worktrees.ts b/src/server/worktrees.ts index 506596539..677a81c72 100644 --- a/src/server/worktrees.ts +++ b/src/server/worktrees.ts @@ -378,7 +378,8 @@ export function gitError(err: unknown): string { function real(p: string): string { try { - return realpathSync(p); + // Native canonicalization expands Windows 8.3 aliases consistently with git's long paths. + return realpathSync.native(p); } catch { return path.resolve(p); } diff --git a/src/server/ws/handlers/meetings.ts b/src/server/ws/handlers/meetings.ts index df1f88ca4..ff3249585 100644 --- a/src/server/ws/handlers/meetings.ts +++ b/src/server/ws/handlers/meetings.ts @@ -38,6 +38,10 @@ export const meetingHandlers = { const floor = here(ctx, c); if (floor) ctx.warn(c, floor.meetings.stop(who)); }, + 'meeting.resume'(ctx, c) { + const floor = here(ctx, c); + if (floor) ctx.warn(c, floor.meetings.resume(c.peer.name)); + }, 'meeting.clear'(ctx, c) { const who = c.peer.name; const floor = here(ctx, c); diff --git a/src/shared/protocol/meetings.ts b/src/shared/protocol/meetings.ts index ed54d60f3..431a24b36 100644 --- a/src/shared/protocol/meetings.ts +++ b/src/shared/protocol/meetings.ts @@ -139,6 +139,8 @@ export type MeetingClientMsg = | ({ t: 'meeting.start' } & MeetingRequest) /** Stop the meeting that's running; its workers stay at the table. */ | { t: 'meeting.stop' } + /** Continue a stopped meeting with its original workers and notes. */ + | { t: 'meeting.resume' } /** Send the last meeting's workers home and clear the table. */ | { t: 'meeting.clear' }; diff --git a/tests/cline-hooks.test.ts b/tests/cline-hooks.test.ts new file mode 100644 index 000000000..31776fc77 --- /dev/null +++ b/tests/cline-hooks.test.ts @@ -0,0 +1,88 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtemp, mkdir, writeFile, readFile, rm, symlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { addClineHooks, writeClineHooks } from '../src/server/cline-hooks.js'; +import { cline } from '../src/server/providers/cline.js'; + +test('Cline native hooks report parent lifecycle and errors without secrets or permission overrides', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'office-cline-hooks-')); + const reports: any[] = []; + const token = 'b'.repeat(32); + const id = '123456abcdef'; + const server = createServer((req, res) => { + assert.equal(req.headers.authorization, `Bearer ${token}`); + assert.equal(req.url, `/hooks/cline?worker=${id}`); + let body = ''; + req.on('data', chunk => { body += chunk; }); + req.on('end', () => { reports.push(JSON.parse(body)); res.writeHead(204).end(); }); + }); + try { + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const addr = server.address(); + assert.ok(addr && typeof addr !== 'string'); + const hooks = writeClineHooks(dir); + const cases = [ + ['TaskStart', 'agent_start', null], ['PreToolUse', 'tool_call', null], + ['TaskComplete', 'agent_end', 'child-parent'], ['TaskComplete', 'agent_end', null], + ['TaskError', 'agent_error', null], ['TaskCancel', 'agent_abort', null], + ]; + for (const [file, hookName, parent_agent_id] of cases) { + const child = spawn(process.execPath, [path.join(hooks, file + '.cjs')], { env: { ...process.env, + AGENT_OFFICE_WORKER_ID: id, AGENT_OFFICE_HOOK_TOKEN: token, + AGENT_OFFICE_HOOK_URL: `http://127.0.0.1:${addr.port}` }, stdio: ['pipe', 'pipe', 'pipe'] }); + let stdout = ''; + child.stdout.on('data', chunk => { stdout += chunk; }); + child.stdin.end(JSON.stringify({ hookName, parent_agent_id, tool_call: { name: 'read_file', input: 'private-key-value' }, userPromptSubmit: { prompt: 'private-prompt' } })); + const [code] = await once(child, 'close'); + assert.equal(code, 0); + assert.deepEqual(JSON.parse(stdout), {}); + } + assert.deepEqual(reports.map(r => r.status), ['working', 'needs_input', 'done', 'needs_input', 'needs_input']); + assert.doesNotMatch(JSON.stringify(reports), /private-key-value|private-prompt/); + assert.equal(reports[1].tool, 'read_file'); + } finally { + await new Promise(resolve => server.close(() => resolve())); + assert.equal(path.dirname(dir), tmpdir()); + await rm(dir, { recursive: true, force: true }); + } +}); + +test('Cline launch uses project hooks, manual approval and strict lifecycle reduction', async (t) => { + const cwd = await mkdtemp(path.join(tmpdir(), 'office-cline-launch-')); + t.after(async () => { assert.equal(path.dirname(cwd), tmpdir()); await rm(cwd, { recursive: true, force: true }); }); + const info = { id: '123456abcdef', sessionId: '', status: 'starting' }; + const h = { info, state: {}, running: true, setStatus(s: string) { info.status = s; }, persist() {}, emit() {}, notePrompt() {}, clearTask() {} } as never; + const plan = cline.launch({ h, cwd, prompt: 'hi', setup: { hooks: 'scoped-hooks' }, args: ['--unsafe'] }); + assert.match(await readFile(path.join(cwd, '.cline/hooks/TaskComplete.cjs'), 'utf8'), /Agent Office managed/); + assert.equal(plan.args[plan.args.indexOf('--auto-approve') + 1], 'false'); + assert.ok(!plan.args.includes('--unsafe')); + const env: Record = {}; + plan.finishEnv?.(env); + assert.equal(env.CLINE_SESSION_BACKEND_MODE, 'local'); + assert.equal(env.CLINE_SANDBOX, '1'); + assert.ok(env.CLINE_SANDBOX_DATA_DIR.endsWith(path.join('.cline', 'data'))); + for (const status of ['working', 'done']) { + assert.equal(cline.hook!.handle(h, '', { type: 'session', status, sessionId: info.sessionId }), true); + assert.equal(info.status, status); + } + assert.equal(cline.hook!.handle(h, '', { type: 'session', status: 'done', sessionId: 'other' }), false); +}); + +test('Cline project hook install refuses user hooks and linked paths', async () => { + const cwd = await mkdtemp(path.join(tmpdir(), 'office-cline-collision-')); + try { + await mkdir(path.join(cwd, '.cline/hooks'), { recursive: true }); + await writeFile(path.join(cwd, '.cline/hooks/TaskStart.sh'), 'user-owned'); + assert.throws(() => addClineHooks(cwd, 'source'), /existing project/); + assert.equal(await readFile(path.join(cwd, '.cline/hooks/TaskStart.sh'), 'utf8'), 'user-owned'); + const other = path.join(cwd, 'linked-project'); + await mkdir(other); + await symlink(path.join(cwd, '.cline'), path.join(other, '.cline'), process.platform === 'win32' ? 'junction' : 'dir'); + assert.throws(() => addClineHooks(other, 'source'), /linked project/); + } finally { assert.equal(path.dirname(cwd), tmpdir()); await rm(cwd, { recursive: true, force: true }); } +}); diff --git a/tests/goose-hooks.test.ts b/tests/goose-hooks.test.ts new file mode 100644 index 000000000..a4a4efee5 --- /dev/null +++ b/tests/goose-hooks.test.ts @@ -0,0 +1,87 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtemp, mkdir, readFile, rm, symlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { addGooseHooks, GOOSE_EVENTS, writeGooseHook } from '../src/server/goose-hooks.js'; +import { goose } from '../src/server/providers/goose.js'; + +test('native Goose hooks report authenticated start, tool and completion without raw payloads', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'office-goose-hooks-')); + const reports: any[] = []; + const token = 'a'.repeat(32); + const id = '123456abcdef'; + const server = createServer((req, res) => { + assert.equal(req.headers.authorization, `Bearer ${token}`); + assert.equal(req.url, `/hooks/goose?worker=${id}`); + let body = ''; + req.on('data', data => { body += data; }); + req.on('end', () => { reports.push(JSON.parse(body)); res.writeHead(204).end(); }); + }); + try { + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const addr = server.address(); + assert.ok(addr && typeof addr !== 'string'); + const hook = writeGooseHook(dir); + addGooseHooks(dir, hook); + const config = JSON.parse(await readFile(path.join(dir, '.agents/plugins/agent-office-status/hooks/hooks.json'), 'utf8')); + assert.deepEqual(Object.keys(config.hooks), [...GOOSE_EVENTS]); + for (const [event, session] of [['SessionStart', 'native-session'], ['UserPromptSubmit', 'native-session'], + ['SessionStart', 'child-session'], ['Stop', 'child-session'], ['PreToolUse', 'native-session'], ['Stop', 'native-session']]) { + const child = spawn(process.execPath, [hook], { env: { ...process.env, + AGENT_OFFICE_WORKER_ID: id, AGENT_OFFICE_HOOK_TOKEN: token, + AGENT_OFFICE_INITIAL_PROMPT: '1', + AGENT_OFFICE_HOOK_URL: `http://127.0.0.1:${addr.port}` }, stdio: ['pipe', 'ignore', 'pipe'] }); + child.stdin.end(JSON.stringify({ event, session_id: session, tool_name: 'shell', message: 'private-prompt', tool_input: { credential: 'private-token' } })); + const [code] = await once(child, 'close'); + assert.equal(code, 0); + } + assert.deepEqual(reports.map(r => r.status), ['working', 'working', 'working', 'done']); + assert.ok(reports.every(r => r.sessionId === `agent-office-${id}`)); + assert.equal(reports[2].tool, 'shell'); + assert.doesNotMatch(JSON.stringify(reports), /private-prompt|private-token/); + } finally { + await new Promise(resolve => server.close(() => resolve())); + assert.equal(path.dirname(dir), tmpdir()); + await rm(dir, { recursive: true, force: true }); + } +}); + +test('Goose refuses a plugin collision instead of overwriting project-owned files', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'office-goose-collision-')); + try { + await mkdir(path.join(dir, '.agents/plugins/agent-office-status'), { recursive: true }); + assert.throws(() => addGooseHooks(dir, 'hook.cjs'), /does not own/); + } finally { + assert.equal(path.dirname(dir), tmpdir()); + await rm(dir, { recursive: true, force: true }); + } +}); + +test('Goose lifecycle reducer transitions working to done and rejects another worker session', () => { + const info = { id: '123456abcdef', sessionId: 'agent-office-123456abcdef', status: 'idle' }; + const h = { info, state: {}, running: true, setStatus: (s: string) => { info.status = s; }, persist() {}, emit() {}, notePrompt() {}, clearTask() {} } as never; + assert.equal(goose.hook!.handle(h, '', { type: 'prompt', status: 'working', sessionId: info.sessionId }), true); + assert.equal(info.status, 'working'); + assert.equal(goose.hook!.handle(h, '', { type: 'session', status: 'done', sessionId: info.sessionId }), true); + assert.equal(info.status, 'done'); + assert.equal(goose.hook!.handle(h, '', { type: 'session', status: 'done', sessionId: 'other' }), false); +}); + +test('Goose generated hooks cannot follow a project junction outside the workspace', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'office-goose-link-')); + try { + const project = path.join(dir, 'project'); + const external = path.join(dir, 'external'); + await mkdir(project); + await mkdir(external); + await symlink(external, path.join(project, '.agents'), process.platform === 'win32' ? 'junction' : 'dir'); + assert.throws(() => addGooseHooks(project, 'hook.cjs'), /linked project paths/); + } finally { + assert.equal(path.dirname(dir), tmpdir()); + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/goose.test.ts b/tests/goose.test.ts index ee540a7e0..65f3e6255 100644 --- a/tests/goose.test.ts +++ b/tests/goose.test.ts @@ -1,21 +1,26 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { goose } from '../src/server/providers/goose.js'; +import { goose, GOOSE_OFFICE_MODEL } from '../src/server/providers/goose.js'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; -test('Goose starts and resumes a named desk session with the right command', () => { +test('Goose starts and resumes a named desk session with the right command', async (t) => { + const cwd = await mkdtemp(path.join(tmpdir(), 'office-goose-')); + t.after(async () => { assert.equal(path.dirname(cwd), tmpdir()); await rm(cwd, { recursive: true, force: true }); }); const info = { id: 'desk-123', sessionId: undefined as string | undefined }; const h = { info } as never; - const base = { h, args: ['--old-agent-arg'], cwd: 'C:\\workspace', setup: undefined }; + const base = { h, args: ['--old-agent-arg'], cwd, setup: { hook: path.join(cwd, 'hook.cjs') } }; const first = goose.launch({ ...base, prompt: 'Start work' }); - assert.deepEqual(first.args, ['run', '--interactive', '--name', 'agent-office-desk-123', '--text', 'Start work']); + assert.deepEqual(first.args, ['run', '--interactive', '--name', 'agent-office-desk-123', '--provider', 'nvidia', '--model', GOOSE_OFFICE_MODEL, '--text', 'Start work']); assert.equal(info.sessionId, 'agent-office-desk-123'); const resumeWithoutPrompt = goose.launch({ ...base, resumeSessionId: info.sessionId }); - assert.deepEqual(resumeWithoutPrompt.args, ['session', '--resume', '--name', 'agent-office-desk-123']); + assert.deepEqual(resumeWithoutPrompt.args, ['session', '--resume', '--name', 'agent-office-desk-123', '--provider', 'nvidia', '--model', GOOSE_OFFICE_MODEL]); const resumeWithPrompt = goose.launch({ ...base, resumeSessionId: info.sessionId, prompt: 'Continue work' }); - assert.deepEqual(resumeWithPrompt.args, ['run', '--interactive', '--name', 'agent-office-desk-123', '--resume', '--text', 'Continue work']); + assert.deepEqual(resumeWithPrompt.args, ['run', '--interactive', '--name', 'agent-office-desk-123', '--resume', '--provider', 'nvidia', '--model', GOOSE_OFFICE_MODEL, '--text', 'Continue work']); const env = { GOOSE_PROVIDER: 'stale', GOOSE_MODEL: 'stale', OTHER: 'keep' }; resumeWithPrompt.finishEnv?.(env); diff --git a/tests/limits.test.ts b/tests/limits.test.ts new file mode 100644 index 000000000..f473e1f43 --- /dev/null +++ b/tests/limits.test.ts @@ -0,0 +1,17 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { setTimeout } from 'node:timers/promises'; +import { PlanLimitsReader } from '../src/server/limits.js'; + +test('optional usage meter tolerates synchronous spawn rejection without unhandled promise or shell fallback', async () => { + let reads = 0; + let changes = 0; + const meter = new PlanLimitsReader('invalid\0executable', {}, () => { reads++; return true; }, () => { changes++; }); + try { + await setTimeout(80); + assert.equal(reads, 1); + assert.equal(changes, 0); + assert.deepEqual(meter.state, { windows: [], at: 0 }); + assert.equal((meter as any).running, false); + } finally { meter.close(); } +}); diff --git a/tests/meetings.test.ts b/tests/meetings.test.ts index 77270a909..7671d638a 100644 --- a/tests/meetings.test.ts +++ b/tests/meetings.test.ts @@ -101,6 +101,36 @@ function fixture(opts: { git?: boolean; rewritten?: Partial manager.kill(id), close() { room.shutdown(); rmSync(dir, { recursive: true, force: true }); } }; } +test('a stopped meeting reuses late notes and finishes with its original workers', (t) => { + const f = fixture(); t.after(() => f.close()); + assert.equal(f.start({ pattern: 'redblue', rounds: 1, roles: ['Blue', 'Red'] }), undefined); + f.settle(); + const before = f.room.state().current!; + const redId = before.seats[1].workerId; + const redPrompts = f.prompts.filter(p => p.id === redId).length; + assert.equal(f.room.stop('Ada'), undefined); + f.take(1, 'Observed findings written after the stop.'); + assert.equal(f.room.state().current!.status, 'stopped'); + assert.equal(f.room.resume('Ada'), undefined); + const resumed = f.room.state().current!; + assert.equal(resumed.id, before.id); + assert.equal(resumed.step, 2); + assert.equal(f.prompts.filter(p => p.id === redId).length, redPrompts); + assert.equal(readFileSync(path.join(f.cwd(), before.turns[0].file), 'utf8'), 'Observed findings written after the stop.'); + f.take(0, 'Final evidence report.'); + assert.equal(f.room.state().current!.status, 'done'); + assert.match(f.room.resume('Ada')!, /No stopped meeting/); +}); + +test('meeting resume refuses a missing original worker', (t) => { + const f = fixture(); t.after(() => f.close()); + assert.equal(f.start({ pattern: 'redblue', rounds: 1 }), undefined); + assert.equal(f.room.stop('Ada'), undefined); + f.workers.pop(); + assert.match(f.room.resume('Ada')!, /original meeting workers/); + assert.equal(f.room.state().current!.status, 'stopped'); +}); + test('a debate runs its rounds and ends when the chair writes the decision', (t) => { const f = fixture(); t.after(() => f.close()); assert.equal(f.start({ rounds: 3, output: 'docs/decision.md' }), undefined); diff --git a/tests/openhands-bridge.test.ts b/tests/openhands-bridge.test.ts index cd1621c6c..384a8cb56 100644 --- a/tests/openhands-bridge.test.ts +++ b/tests/openhands-bridge.test.ts @@ -11,13 +11,15 @@ import { OPENHANDS_BRIDGE_SOURCE } from '../src/server/providers/openhands-bridg const AGENT_ID = '4d92aeec-2478-484c-9663-8d3956bb3c01'; const CONVERSATION_ID = 'ac40c16d-be36-4bfb-8740-a689121ddf89'; -async function probe(model: string, resume = false, scenario: 'done' | 'idle' | 'rate-limit' = 'done') { +async function probe(model: string, resume = false, scenario: 'done' | 'idle' | 'running' | 'rate-limit' = 'done') { const home = await mkdtemp(path.join(tmpdir(), 'agent-office-bridge-test-')); const project = path.join(home, 'project'); await mkdir(project); const canonicalProject = await realpath(project); let created = 0; let extraRun = 0; + let stateReads = 0; + let eventPosts = 0; const server = createServer((req, res) => { if (req.headers['x-session-api-key'] !== 'local-test-key') { res.writeHead(401).end(); return; } const route = new URL(req.url ?? '/', 'http://127.0.0.1').pathname; @@ -31,9 +33,9 @@ async function probe(model: string, resume = false, scenario: 'done' | 'idle' | { id: 'event-2', timestamp: '2026-10-22T00:00:01Z', kind: 'ObservationEvent', observation: { kind: 'TerminalObservation', content: [{ type: 'text', text: '\x1b]8;;https://example.invalid\x07unsafe-output\x1b\\' }] } }, ], }; - else if (route === `/api/conversations/${CONVERSATION_ID}/events` && scenario === 'rate-limit') { res.writeHead(429).end(); return; } + else if (route === `/api/conversations/${CONVERSATION_ID}/events`) { eventPosts++; if (scenario === 'rate-limit') { res.writeHead(429).end(); return; } body = {}; } else if (route === `/api/conversations/${CONVERSATION_ID}/run`) { extraRun++; body = {}; } - else if (route === `/api/conversations/${CONVERSATION_ID}`) body = { workspace: { working_dir: canonicalProject }, agent: { llm: { model } }, execution_status: scenario === 'idle' ? 'idle' : 'done' }; + else if (route === `/api/conversations/${CONVERSATION_ID}`) body = { workspace: { working_dir: canonicalProject }, agent: { llm: { model } }, execution_status: ++stateReads < 5 && (scenario === 'idle' || scenario === 'running') ? scenario : 'done' }; else { res.writeHead(404).end(); return; } res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(body)); }); @@ -45,11 +47,11 @@ async function probe(model: string, resume = false, scenario: 'done' | 'idle' | assert.ok(address && typeof address !== 'string'); const url = `http://127.0.0.1:${address.port}`; const bridge = OPENHANDS_BRIDGE_SOURCE.replace("const BASE = 'http://127.0.0.1:8000';", `const BASE = '${url}';`) - .replace('Date.now() - idleSince > 30000', 'Date.now() - idleSince > 10') .replace('setTimeout(resolve, 1200)', 'setTimeout(resolve, 20)'); assert.notEqual(bridge, OPENHANDS_BRIDGE_SOURCE); const script = path.join(home, 'bridge.mjs'); - await writeFile(script, bridge); + // Advance wall time by 31 minutes per read while keeping the fixture fast. + await writeFile(script, 'const actualNow = Date.now; let clockTicks = 0; Date.now = () => actualNow() + (++clockTicks * 31 * 60 * 1000);\n' + bridge); const child = spawn(process.execPath, [script, '--cwd', project, ...(resume ? ['--resume', CONVERSATION_ID] : []), ...(!resume || scenario === 'rate-limit' ? ['--prompt', 'Check the provider'] : [])], { env: { ...process.env, USERPROFILE: home, HOME: home, AGENT_OFFICE_HOOK_URL: '' }, stdio: ['pipe', 'pipe', 'pipe'], @@ -73,7 +75,7 @@ async function probe(model: string, resume = false, scenario: 'done' | 'idle' | } finally { if (child.exitCode === null) { child.kill(); await once(child, 'exit'); } } - return { output, created, extraRun }; + return { output, created, extraRun, eventPosts, stateReads }; } finally { await new Promise((resolve) => server.close(() => resolve())); assert.equal(path.dirname(home), tmpdir()); @@ -101,9 +103,20 @@ test('OpenHands bridge starts with NVIDIA and strips terminal controls from tool assert.doesNotMatch(result.output.replace(/\r\n/g, '\n'), /[\x00-\x08\x0B-\x1F\x7F-\x9F]/); }); -test('OpenHands bridge stops polling a conversation that stays idle', async () => { +test('OpenHands bridge keeps watching idle transitions beyond 30 minutes', async () => { const result = await probe('nvidia_nim/z-ai/glm-5.3', false, 'idle'); - assert.match(result.output, /remained idle for 30 seconds/); + assert.equal(result.stateReads, 5); + assert.doesNotMatch(result.output, /exceeded 30 minutes|remained idle|\[OpenHands bridge\]/); +}); + +test('OpenHands bridge reconnects to an active run without submitting duplicate work', async () => { + const result = await probe('nvidia_nim/z-ai/glm-5.3', true, 'running'); + assert.equal(result.stateReads, 5); + assert.equal(result.created, 0); + assert.equal(result.eventPosts, 0); + assert.equal(result.extraRun, 0); + assert.match(result.output, /still watching this conversation/); + assert.doesNotMatch(result.output, /exceeded 30 minutes|\[OpenHands bridge\]/); }); test('OpenHands bridge reports ambiguous rate limits without starting an extra run', async () => { diff --git a/tests/repos.test.ts b/tests/repos.test.ts index b7fd83a61..ab02e9c23 100644 --- a/tests/repos.test.ts +++ b/tests/repos.test.ts @@ -85,7 +85,7 @@ interface Fixture { } function fixture(t: { after(fn: () => void): void }): Fixture { - const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'agent-office-repos-'))); + const root = realpathSync.native(mkdtempSync(path.join(tmpdir(), 'agent-office-repos-'))); const bin = path.join(root, 'bin'); mkdirSync(bin); const agent = path.join(bin, 'fake-agent'); diff --git a/tests/restart-prompt.test.ts b/tests/restart-prompt.test.ts new file mode 100644 index 000000000..4d0de0618 --- /dev/null +++ b/tests/restart-prompt.test.ts @@ -0,0 +1,11 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { restartPrompt, CARRY_ON_PROMPT } from '../src/server/workers/tasks.js'; + +test('OpenHands reconnects without a new prompt while terminal agents retain carry-on recovery', () => { + assert.equal(restartPrompt('openhands', 'existing-conversation', true), undefined); + assert.equal(restartPrompt('goose', 'named-session', true), CARRY_ON_PROMPT); + assert.equal(restartPrompt('cline', 'session', true), CARRY_ON_PROMPT); + assert.equal(restartPrompt('goose', 'session', false), undefined); + assert.equal(restartPrompt('goose', undefined, true), undefined); +}); diff --git a/tests/services.test.ts b/tests/services.test.ts new file mode 100644 index 000000000..83ee7ad8a --- /dev/null +++ b/tests/services.test.ts @@ -0,0 +1,38 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { setTimeout } from 'node:timers/promises'; +import { Services, windowsListeners } from '../src/server/services.js'; + +test('Windows listeners include listening TCP only, with valid IPv4/IPv6 ports and pids', () => { + assert.deepEqual(windowsListeners('TCP 0.0.0.0:4100 0.0.0.0:0 LISTENING 123\nTCP [::1]:4101 [::]:0 LISTENING 124\nTCP 127.0.0.1:4102 127.0.0.1:5000 ESTABLISHED 125\nTCP 127.0.0.1:99999 0.0.0.0:0 LISTENING 126\nUDP 0.0.0.0:4103 *:* 127'), [ + { host: '127.0.0.1', port: 4100, pid: 123 }, { host: '::1', port: 4101, pid: 124 }, + ]); +}); + +test('Windows Services discovers and HTTP probes a real server under its worker process', { skip: process.platform !== 'win32', timeout: 20000 }, async () => { + const serverCode = `const http=require('node:http');const server=http.createServer((q,s)=>{s.setHeader('content-type','text/html');s.end('Office preview verified');});server.listen(0,'127.0.0.1',()=>console.log(server.address().port));process.on('SIGTERM',()=>server.close());`; + const parentCode = `const {spawn}=require('node:child_process');const child=spawn(process.execPath,['-e',${JSON.stringify(serverCode)}],{stdio:['ignore','pipe','inherit']});child.stdout.pipe(process.stdout);process.stdin.resume();process.stdin.on('end',()=>child.kill());`; + const parent = spawn(process.execPath, ['-e', parentCode], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }); + let services: Services | undefined; + try { + const port = await new Promise((resolve, reject) => { + parent.stdout.once('data', chunk => resolve(Number(String(chunk).trim()))); + parent.once('error', reject); + }); + assert.ok(port > 0 && parent.pid); + services = new Services(() => [{ workerId: 'test-worker', pid: parent.pid, agent: true, cwd: process.cwd(), root: process.cwd() }], () => {}); + await services.scan(); + for (let i = 0; i < 30 && !services.lookup(port); i++) await setTimeout(100); + const found = services.lookup(port); + assert.ok(found && found !== 'gone'); + assert.equal(found.workerId, 'test-worker'); + assert.equal(found.title, 'Office preview verified'); + assert.equal(found.command, 'node.exe'); + } finally { + services?.stop(); + parent.stdin.end(); + if (parent.exitCode === null) await once(parent, 'exit'); + } +}); diff --git a/tests/task-namer.test.ts b/tests/task-namer.test.ts new file mode 100644 index 000000000..2228467ea --- /dev/null +++ b/tests/task-namer.test.ts @@ -0,0 +1,15 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { setTimeout } from 'node:timers/promises'; +import { TaskNamer } from '../src/server/tasks.js'; + +test('task naming handles synchronous spawn failure and releases the scheduler slot', async () => { + let named = 0; + const namer = new TaskNamer('invalid\0executable', {}, () => 'test', () => { named++; }); + namer.request('worker', { prompts: ['local test'], tools: [], epoch: 0 }); + await setTimeout(950); + assert.equal(named, 0); + assert.equal((namer as any).running.size, 0); + assert.equal((namer as any).fails, 1); + namer.forget('worker'); +}); From e53f403a8e98c5820b5d07fcac3c39cb6bbd40a1 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 23 Oct 2026 15:27:32 -0400 Subject: [PATCH 3/7] Use native PowerShell arguments for Windows shell workers --- src/server/workers/manager.ts | 4 ++-- src/server/workers/process.ts | 18 +++++++++++++++--- tests/windows-shell.test.ts | 18 ++++++++++++++++++ 3 files changed, 35 insertions(+), 5 deletions(-) create mode 100644 tests/windows-shell.test.ts diff --git a/src/server/workers/manager.ts b/src/server/workers/manager.ts index d6316bb67..a992be56e 100644 --- a/src/server/workers/manager.ts +++ b/src/server/workers/manager.ts @@ -22,7 +22,7 @@ import { childEnv } from './env.js'; import { midTurn } from './lifecycle.js'; import { restoreWorkers, saveWorkers } from './persist.js'; import { WorkerPrs } from './pr.js'; -import { WIN, binScript, defaultShell, resolveCommand, shellRun, shq, writeOfficeCommands } from './process.js'; +import { WIN, binScript, defaultShell, resolveCommand, shellInteractiveArgs, shellRun, shq, writeOfficeCommands } from './process.js'; import { restartPrompt, WorkerTasks } from './tasks.js'; import { flushScreens, fullScreens, newTerm, offlineBanner, screenText, type HeadlessTerminal } from './terminal.js'; import type { HookEnv, OpenedPr, RepoSource, RunAs, Worker, WorkerContext, WorkerEvents, WorkerHandle } from './types.js'; @@ -662,7 +662,7 @@ export class WorkerManager { const cwd = this.cwd(info); const command = this.command(info); const commandPath = isShell ? undefined : configured ? this.agentPath : resolveCommand(command); - const base = isShell ? (WIN && !process.env.SHELL ? [] : ['-l']) : configured ? [...this.agentArgs] : []; + const base = isShell ? shellInteractiveArgs(shell) : configured ? [...this.agentArgs] : []; // Its provider's command line, and anything it sets for this run (see ProviderAdapter.launch). const plan: LaunchPlan = adapter ? adapter.launch({ h: this.handleOf(w), args: base, prompt, resumeSessionId, station: DESK_BY_ID.get(info.deskId)?.station, cwd, setup: this.setups[adapter.id] }) : { args: base }; const { args } = plan; diff --git a/src/server/workers/process.ts b/src/server/workers/process.ts index 2e5446aed..95c1be9bc 100644 --- a/src/server/workers/process.ts +++ b/src/server/workers/process.ts @@ -23,9 +23,21 @@ export function defaultShell(): string { return process.env.SHELL || (WIN ? process.env.COMSPEC || 'cmd.exe' : '/bin/bash'); } +function shellFamily(shell: string): 'powershell' | 'cmd' | 'unix' { + const name = shell.split(/[\\/]/).pop()?.toLowerCase().replace(/\.exe$/, ''); + return name === 'powershell' || name === 'pwsh' ? 'powershell' : name === 'cmd' ? 'cmd' : 'unix'; +} + +export function shellInteractiveArgs(shell = defaultShell()): string[] { + const family = shellFamily(shell); + return family === 'powershell' ? ['-NoLogo', '-NoProfile'] : family === 'cmd' ? ['/d'] : ['-l']; +} + /** How to have the default shell run one command line. */ -export function shellRun(line: string): string[] { - return WIN && !process.env.SHELL ? ['/d', '/s', '/c', line] : ['-l', '-i', '-c', line]; +export function shellRun(line: string, shell = defaultShell()): string[] { + const family = shellFamily(shell); + return family === 'powershell' ? ['-NoLogo', '-NoProfile', '-Command', line] + : family === 'cmd' ? ['/d', '/s', '/c', line] : ['-l', '-i', '-c', line]; } export function resolveCommand(cmd: string): string | null { @@ -52,7 +64,7 @@ export function resolveCommand(cmd: string): string | null { const found = usable(path.join(dir, cmd)); if (found) return found; } - if (WIN && !process.env.SHELL) return null; + if (WIN && shellFamily(defaultShell()) !== 'unix') return null; try { const found = execFileSync(defaultShell(), ['-l', '-i', '-c', `command -v ${shq(cmd)}`], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] }) .trim() diff --git a/tests/windows-shell.test.ts b/tests/windows-shell.test.ts new file mode 100644 index 000000000..578774e44 --- /dev/null +++ b/tests/windows-shell.test.ts @@ -0,0 +1,18 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {execFileSync} from 'node:child_process'; +import {shellInteractiveArgs, shellRun} from '../src/server/workers/process.js'; + +test('configured shell receives its own argument family', () => { + assert.deepEqual(shellInteractiveArgs('C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'), ['-NoLogo', '-NoProfile']); + assert.deepEqual(shellInteractiveArgs('pwsh.exe'), ['-NoLogo', '-NoProfile']); + assert.deepEqual(shellInteractiveArgs('C:\\Windows\\System32\\cmd.exe'), ['/d']); + assert.deepEqual(shellInteractiveArgs('/bin/bash'), ['-l']); + assert.deepEqual(shellRun('echo ok', 'cmd.exe'), ['/d', '/s', '/c', 'echo ok']); + assert.deepEqual(shellRun('echo ok', '/bin/bash'), ['-l', '-i', '-c', 'echo ok']); +}); + +test('actual Windows PowerShell executes a command without Unix login flags', {skip:process.platform !== 'win32'}, () => { + const output=execFileSync('powershell.exe',shellRun("Write-Output 'office-shell-ok'",'powershell.exe'),{encoding:'utf8',timeout:10000,windowsHide:true}); + assert.equal(output.trim(),'office-shell-ok'); +}); From ad28d097499968b001f5fa19717cab328a9c8e6d Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 23 Oct 2026 15:34:27 -0400 Subject: [PATCH 4/7] Keep queued task ownership while saved workers resume --- src/server/floor.ts | 2 +- src/server/queue.ts | 25 ++++++++++++++++++++----- tests/queue.test.ts | 29 +++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/server/floor.ts b/src/server/floor.ts index ad3bd25fd..ff053a3e4 100644 --- a/src/server/floor.ts +++ b/src/server/floor.ts @@ -308,7 +308,7 @@ export class Floor { this.decor = new Decor(dataDir); this.jukebox = new Jukebox(dataDir); this.whiteboard = new Whiteboard(dataDir); - this.ready = this.workers.start(); + this.ready = this.workers.start().then(() => this.queue.restored()); void this.github.refresh(); // A floor with people on it, or work under way, keeps its boards fresh; the others check in now and then. diff --git a/src/server/queue.ts b/src/server/queue.ts index bc3736e06..a9b4b8400 100644 --- a/src/server/queue.ts +++ b/src/server/queue.ts @@ -63,6 +63,8 @@ export class TaskQueue { /** Set on shutdown: the workers' exit events must not seat anyone into a dying office. */ private stopped = false; private lastStatus = new Map(); + /** Assignments whose saved worker is still being reattached by the terminal host. */ + private recovering = new Set(); constructor( dataDir: string, @@ -194,6 +196,12 @@ export class TaskQueue { this.pump(); } + /** Called once all saved workers have been adopted or restarted. No task is resubmitted here. */ + restored() { + this.recovering.clear(); + this.pump(); + } + /** Fresh pull requests from GitHub: link each task to the PR that closes its issue (or came from its branch). */ onPulls(pulls: GhPull[]) { let changed = false; @@ -245,6 +253,8 @@ export class TaskQueue { for (const t of this.tasks) { if (t.status !== 'running' || !t.workerId) continue; const w = byId.get(t.workerId); + if (w?.status === 'offline' && this.recovering.has(w.id)) continue; + if (w) this.recovering.delete(w.id); if (!w) this.finish(t, 'killed'); else if (FINISHED.has(w.status)) done = this.finish(t, w.status === 'done' ? 'done' : 'exited') || done; else continue; @@ -404,12 +414,17 @@ export class TaskQueue { error: s.error, pr: s.pr, }; - // Whatever was running died with the old office process; its worker comes back asleep at best. + // Workers are adopted/restarted by Workers.start(), and API-backed runs may never have stopped. + // Keep their assignment until startup has resolved their real state, without seating a duplicate. if (t.status === 'running') { - t.status = 'done'; - t.outcome = 'exited'; - t.finishedAt = Date.now(); - t.error = 'The office restarted while it was running'; + const worker = this.workers.list().find((w) => w.id === t.workerId && w.kind === 'agent' && w.provider === provider); + if (worker) this.recovering.add(worker.id); + else { + t.status = 'done'; + t.outcome = 'exited'; + t.finishedAt = Date.now(); + t.error = 'The office restarted while it was running and its worker is missing'; + } } this.tasks.push(t); } diff --git a/tests/queue.test.ts b/tests/queue.test.ts index 7c42d921a..e27ad622f 100644 --- a/tests/queue.test.ts +++ b/tests/queue.test.ts @@ -58,6 +58,35 @@ test('queue seats the selected provider and preserves it through completion and assert.equal(f.workers[1].provider, 'opencode'); }); +test('restart retains an OpenHands assignment while its worker resumes, without duplicate seating', (t) => { + const f=fixture('openhands');t.after(()=>f.close()); + const q=f.open();q.setLimit(1);q.add('First audit','Tester');q.add('Second audit','Tester'); + const first=f.workers[0];q.shutdown();first.status='offline'; + const restored=f.open();restored.pump(); + assert.deepEqual(restored.state().tasks.map(x=>x.status),['running','queued']); + assert.equal(f.workers.length,1); + first.status='working';restored.onWorker(first);restored.restored(); + assert.equal(f.workers.length,1); + assert.equal(restored.state().tasks[0].workerId,first.id); + first.status='done';restored.onWorker(first); + assert.equal(restored.state().tasks[0].outcome,'done'); + assert.equal(restored.state().tasks[1].status,'running'); + assert.notEqual(restored.state().tasks[1].workerId,first.id); +}); + +test('a restored worker still offline after host startup is stopped accurately', (t) => { + const f=fixture('openhands');t.after(()=>f.close());const q=f.open();q.add('Audit','Tester');q.shutdown(); + f.workers[0].status='offline';const restored=f.open();restored.pump(); + assert.equal(restored.state().tasks[0].status,'running');restored.restored(); + assert.equal(restored.state().tasks[0].outcome,'exited'); +}); + +test('a saved running task with a missing worker is never silently requeued', (t) => { + const f=fixture('openhands');t.after(()=>f.close());const q=f.open();q.add('Audit','Tester');q.shutdown(); + f.workers.length=0;const restored=f.open();restored.restored(); + assert.equal(restored.state().tasks[0].outcome,'exited');assert.equal(f.workers.length,0); +}); + test('queued provider survives restart even when the configured default differs', (t) => { const f = fixture(); t.after(() => f.close()); const q = f.open(); q.setLimit(0); From 11aa3deb6a6b518dd423f39b8e2a63a95d5f9c2e Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 23 Oct 2026 16:25:11 -0400 Subject: [PATCH 5/7] Define Office-owned Cloudflare monitoring acceptance and recovery --- docs/cloudflare-monitor-template.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 docs/cloudflare-monitor-template.md diff --git a/docs/cloudflare-monitor-template.md b/docs/cloudflare-monitor-template.md new file mode 100644 index 000000000..bc5a3e0b9 --- /dev/null +++ b/docs/cloudflare-monitor-template.md @@ -0,0 +1,25 @@ +# Agent Office Cloudflare monitoring template v1.0 + +Owner correction, 2026-10-23: Services means the owner's Cloudflare-hosted domains and their errors. Monitoring must originate in Agent Office on this computer. Never import the inaccurate historical domain inventory. Preserve local preview support and all active agent tasks. Hermes remains desktop only and is a separate pending task. + +## M1 Requirements and architecture + +An Office-owned timer discovers current zones, web DNS names, Worker domain bindings, route mappings, Worker scripts and Pages using the designated removable-drive Cloudflare credential file. The path is local configuration; the token is read only in server memory, never copied into configuration, logs, CLI arguments, browser state or repository. Only GET metadata and a fixed read-only GraphQL analytics query are permitted. No production resource/configuration mutations, deployment, server monitor, cron, or Tail Worker installation. + +Every 60 seconds Office independently probes discovered public HTTPS website roots, with bounded concurrency, DNS/IP checks, certificate validation, response limits and an overall deadline. Local/private IPs, credentials in URLs, alternate protocols and arbitrary caller URLs are rejected. Redirects are reported without following unverified targets. Do not mistake Access/challenge/auth, redirects, zero traffic, stale observations or missing API permissions for complete application health. + +Refresh discovery and the last-15-minute Worker aggregate requests/errors/outcomes every five minutes. Preserve observation timestamps, capability failures and incomplete pagination. Pages/API permission errors remain visible. Only latest observations generated by this monitor may be persisted; they are explicitly stale on reload until refreshed. Never seed from earlier inventory files. Bound all arrays and persisted history. Do not collect raw HTTP bodies or console logs into persistent state. Initially show error classes/outcomes/counts; unavailable detailed application logs must be explicit. + +## M2 Interfaces and UX + +Local `.agent-office/cloudflare-monitor.json` contains only `credentialFile` and `enabled`. No unauthenticated monitoring results or configuration API. A signed-in GET `/api/domain-monitor` returns sanitized current state. Start/stop through Office timer lifecycle, not panel visibility or an agent's task. The Services window exposes Cloudflare monitoring, and the menu offers a direct Cloudflare status action. Show monitor location, time of last/next check, per-domain HTTPS status/latency/error/ray ID, Worker mapping and aggregate error outcomes, capability errors, and stale/closed-Office limitations. Close button and Esc retain Office focus behavior. 2D Services should expose the same monitoring panel. + +## M3 Verification and security + +Unit/regression tests: missing drive/profile; ambiguous credentials; pagination/failure; new/deleted live domains without saved inventory; SSRF/private IPv4/IPv6/rebinding/redirect protections; HTTP 2xx/3xx/401/403/5xx/network timeout; no API/token text echoed; read-only query shape; no-data/permission errors; sanitized serialization; repeated timers/concurrency/shutdown; size guard, typecheck, build, relevant Office tests and bounded full-suite attempt. Apply provisional47 (authoritative checklist missing), current primary Cloudflare API documentation, CWE-918/200/400/532, OWASP SSRF/secret handling. + +Live acceptance: use G-drive key against current Cloudflare API, compare discovered sites with live API outputs, probe actual domain URLs, observe aggregate analytics permission/data, display actual results in installed Office and save screenshot. Prove updates occur with the panel closed and no agent assigned. Mock security tests are not live-domain evidence. Check unsupported logs/capabilities explicitly. + +## M4 Release/recovery + +Branch work from freshly fetched origin/main with existing verified Office integration commits retained. Build isolated candidate, preserve installed dist/config/agent state, use graceful restart only after source tests pass. Retain original profile path, never copy keys. Validate live workers/queue/session IDs after rollout. Hash installed files and verify backup restoration. Rollback removes monitor registration/config and restores the prior build; no Cloudflare change requires rollback. Record review/audit/tests/limitations, update durable task tracker and the single checkpoint. Ship a PR as required by repository AGENTS.md; inability to push is reported accurately. From 84d47cab2fcbbfb72954201c3d00e07e6473c1b9 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 23 Oct 2026 16:33:35 -0400 Subject: [PATCH 6/7] Monitor live Cloudflare domains and Worker errors from Office --- src/server/domain-monitor/cloudflare.ts | 105 ++++++++++++++++++++ src/server/domain-monitor/index.ts | 51 ++++++++++ src/server/domain-monitor/probe.ts | 48 +++++++++ src/server/http/routes/domain-monitor.ts | 6 ++ src/server/http/routes/index.ts | 2 + src/server/meeting-format.ts | 14 +++ src/server/meetings.ts | 28 +----- src/server/office/timers.ts | 3 + src/shared/domain-monitor.ts | 13 +++ tests/domain-monitor.test.ts | 121 +++++++++++++++++++++++ tests/size.test.ts | 2 +- 11 files changed, 365 insertions(+), 28 deletions(-) create mode 100644 src/server/domain-monitor/cloudflare.ts create mode 100644 src/server/domain-monitor/index.ts create mode 100644 src/server/domain-monitor/probe.ts create mode 100644 src/server/http/routes/domain-monitor.ts create mode 100644 src/server/meeting-format.ts create mode 100644 src/shared/domain-monitor.ts create mode 100644 tests/domain-monitor.test.ts diff --git a/src/server/domain-monitor/cloudflare.ts b/src/server/domain-monitor/cloudflare.ts new file mode 100644 index 000000000..f8a145aee --- /dev/null +++ b/src/server/domain-monitor/cloudflare.ts @@ -0,0 +1,105 @@ +import { readFile, stat } from 'node:fs/promises'; +import type { DomainTarget, WorkerMetric } from '../../shared/domain-monitor.js'; +type Row = Record; +const row = (v: unknown): Row => v && typeof v === 'object' && !Array.isArray(v) ? v as Row : {}; +const rows = (v: unknown): Row[] => Array.isArray(v) ? v.map(row) : []; +export const hostnameValid = (v: unknown): v is string => typeof v === 'string' && v.length <= 253 && v.includes('.') && v.split('.').every(s => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(s)); +export function parseProfile(source: string): { token: string; account: string } { + const tokens = new Set([...source.matchAll(/Bearer\s+([A-Za-z0-9_-]{35,300})/gi)].map(m => m[1])); + const accounts = new Set([...source.matchAll(/\/accounts\/([0-9a-f]{32})(?:\/|\b)/gi)].map(m => m[1].toLowerCase())); + if (tokens.size !== 1 || accounts.size !== 1) throw Error('Cloudflare credential file must contain one bearer token and one account URL.'); + return { token: [...tokens][0], account: [...accounts][0] }; +} +export async function readProfile(file: string) { + try { const info = await stat(file); if (!info.isFile() || info.size > 16384) throw Error(); return parseProfile(await readFile(file, 'utf8')); } + catch { throw Error('Cloudflare credential file unavailable or ambiguous. Check removable drive and profile.'); } +} +export type ApiRequest = (route: string, body?: unknown) => Promise; +const WORKER_QUERY = 'query($account:String!,$start:Time!,$end:Time!){viewer{accounts(filter:{accountTag:$account}){workersInvocationsAdaptive(limit:1000,filter:{datetime_geq:$start,datetime_leq:$end}){sum{requests errors} dimensions{scriptName status}}}}}'; +/** Fixed origin, GET metadata and one read-only GraphQL POST. No redirects or raw error text. */ +export function cloudflareRequest(token: string, signal?: AbortSignal): ApiRequest { + return async (route, body) => { + if (!(route.startsWith('/accounts/') || route.startsWith('/zones') || route === '/graphql') || (body !== undefined && route !== '/graphql')) throw Error('API operation refused.'); + if (route === '/graphql' && row(body).query !== WORKER_QUERY) throw Error('Only the fixed read-only Worker analytics query is allowed.'); + const response = await fetch('https://api.cloudflare.com/client/v4' + route, { + method: body === undefined ? 'GET' : 'POST', redirect: 'error', signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(12000)]) : AbortSignal.timeout(12000), + headers: { authorization: 'Bearer ' + token, accept: 'application/json', ...(body === undefined ? {} : { 'content-type': 'application/json' }) }, + body: body === undefined ? undefined : JSON.stringify(body), + }).catch(() => { throw Error('Cloudflare API network/TLS/timeout failure.'); }); + if (!response.ok) { await response.body?.cancel(); throw Error('Cloudflare API HTTP ' + response.status + '.'); } + const reader = response.body?.getReader(); if (!reader) throw Error('Cloudflare API empty response.'); + const chunks: Uint8Array[] = []; let size = 0; + try { + for (;;) { const chunk = await reader.read(); if (chunk.done) break; size += chunk.value.length; if (size > 2 * 1024 * 1024) throw Error('Cloudflare API response limit.'); chunks.push(chunk.value); } + try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw Error('Cloudflare API invalid JSON.'); } + } finally { await reader.cancel().catch(() => {}); } + }; +} +export async function collection(api: ApiRequest, route: string): Promise { + const out: Row[] = []; const perPage = route.includes('/pages/projects') ? 10 : 50; + for (let page = 1; page <= 40; page++) { + const d = row(await api(route + (route.includes('?') ? '&' : '?') + `per_page=${perPage}&page=${page}`)); + if (d.success !== true || !Array.isArray(d.result)) throw Error('Cloudflare collection unavailable.'); + const batch = rows(d.result); out.push(...batch); const info = row(d.result_info); + if (info.page !== undefined && info.page !== page) throw Error('Cloudflare pagination mismatch.'); + if (typeof info.total_pages === 'number' ? page >= info.total_pages : batch.length < perPage) return out; + } + throw Error('Cloudflare collection exceeded pagination limit.'); +} +export interface Discovery { targets: DomainTarget[]; workers: string[]; issues: string[] } +export async function discover(api: ApiRequest, account: string): Promise { + if (!/^[0-9a-f]{32}$/.test(account)) throw Error('Invalid Cloudflare account.'); + const issues: string[] = [], prefix = '/accounts/' + account, targets = new Map(); + const add = (hostname: unknown, source: string, worker?: unknown) => { + if (!hostnameValid(hostname)) return; const key = hostname.toLowerCase(); + if (!targets.has(key)) targets.set(key, { hostname: key, workers: [], source }); + const item = targets.get(key)!; + if (typeof worker === 'string' && /^[a-z0-9_.-]{1,100}$/i.test(worker) && !item.workers.includes(worker)) item.workers.push(worker); + }; + const get = async (route: string, label: string) => { + try { return await collection(api, route); } + catch (e) { issues.push(label + ': ' + (e instanceof Error ? e.message : 'unavailable')); return []; } + }; + const [zones, domains, scripts, pages] = await Promise.all([ + get('/zones?account.id=' + account, 'Zones'), get(prefix + '/workers/domains', 'Worker domains'), + get(prefix + '/workers/scripts', 'Workers'), get(prefix + '/pages/projects', 'Pages'), + ]); + if (zones.length > 100) throw Error('Zone limit exceeded; coverage incomplete.'); + for (const zone of zones) { + if (!/^[0-9a-f]{32}$/.test(String(zone.id))) { issues.push('Zone ID invalid.'); continue; } + add(zone.name, 'Zone'); + const [dns, routes] = await Promise.all([get('/zones/' + zone.id + '/dns_records', 'DNS ' + zone.name), get('/zones/' + zone.id + '/workers/routes', 'Routes ' + zone.name)]); + for (const d of dns) if (['A', 'AAAA', 'CNAME'].includes(String(d.type))) add(d.name, 'Live DNS'); + for (const r of routes) { + const host = String(r.pattern ?? '').replace(/^https?:\/\//, '').split('/')[0]; + if (host.includes('*')) { const suffix = host.replace(/^\*\.?/, '').toLowerCase(); for (const name of targets.keys()) if (name === suffix || name.endsWith('.' + suffix)) add(name, 'Worker route', r.script); } + else add(host, 'Worker route', r.script); + } + } + for (const d of domains) add(d.hostname, 'Worker custom domain', d.service); + for (const p of pages) for (const d of Array.isArray(p.domains) ? p.domains : []) add(d, 'Pages', p.name); + const all = [...targets.values()].sort((a, b) => a.hostname.localeCompare(b.hostname)); + if (all.length > 200) issues.push('Domain coverage capped at 200; remaining domains are unmonitored.'); + return { targets: all.slice(0, 200), workers: scripts.map(s => s.id).filter((s): s is string => typeof s === 'string' && /^[a-z0-9_.-]{1,100}$/i.test(s)).slice(0, 2000), issues }; +} +export async function analytics(api: ApiRequest, account: string, now = new Date()) { + const from = new Date(now.getTime() - 15 * 60_000).toISOString(), to = now.toISOString(); + const query = WORKER_QUERY; + const d = row(await api('/graphql', { query, variables: { account, start: from, end: to } })); + if (Array.isArray(d.errors) && d.errors.length) throw Error('Worker analytics query rejected; error visibility unavailable.'); + const accounts = rows(row(row(d.data).viewer).accounts); + if (accounts.length !== 1 || !Array.isArray(accounts[0].workersInvocationsAdaptive)) throw Error('Worker analytics unavailable for this account.'); + const samples = rows(accounts[0].workersInvocationsAdaptive); + if (samples.length >= 1000) throw Error('Worker analytics result limit reached; incomplete data.'); + const metrics = new Map(); + for (const sample of samples) { + const dim = row(sample.dimensions), sum = row(sample.sum); + if (typeof dim.scriptName !== 'string' || !/^[a-z0-9_.-]{1,100}$/i.test(dim.scriptName)) continue; + const m = metrics.get(dim.scriptName) ?? { name: dim.scriptName, requests: 0, errors: 0, outcomes: Object.create(null) as Record }; + if (![sum.requests, sum.errors].every(v => typeof v === 'number' && Number.isFinite(v) && v >= 0)) throw Error('Worker analytics invalid counters.'); + m.requests += sum.requests as number; m.errors += sum.errors as number; + const outcome = typeof dim.status === 'string' && /^[a-z0-9_-]{1,60}$/i.test(dim.status) ? dim.status : 'unknown'; + m.outcomes[outcome] = (m.outcomes[outcome] ?? 0) + (sum.requests as number); metrics.set(m.name, m); + } + return { metrics: [...metrics.values()], from, to }; +} diff --git a/src/server/domain-monitor/index.ts b/src/server/domain-monitor/index.ts new file mode 100644 index 000000000..d9ce06f08 --- /dev/null +++ b/src/server/domain-monitor/index.ts @@ -0,0 +1,51 @@ +import { readFile, stat, writeFile, rename } from 'node:fs/promises'; +import path from 'node:path'; +import type { DomainMonitorState, DomainTarget } from '../../shared/domain-monitor.js'; +import { analytics, cloudflareRequest, discover, readProfile } from './cloudflare.js'; +import { probeDomain } from './probe.js'; +import type { Ctx } from '../office/context.js'; +const INTERVAL = 60_000, DISCOVERY = 5 * 60_000; +export class DomainMonitor { + private timer?: NodeJS.Timeout; private lastDiscovery = 0; private targets: DomainTarget[] = []; + private controller = new AbortController(); + private state: DomainMonitorState = { enabled: false, running: false, location: 'Agent Office on this computer', intervalSeconds: 60, domains: [], workers: [], metrics: [], issues: ['Cloudflare monitoring not configured.'], checking: false }; + constructor(private readonly dataDir: string) {} + snapshot(): DomainMonitorState { return structuredClone(this.state); } + start() { if (this.timer) return; this.controller = new AbortController(); this.state.running = true; void this.check(); this.timer = setInterval(() => void this.check(), INTERVAL); this.timer.unref(); } + stop() { clearInterval(this.timer); this.timer = undefined; this.controller.abort(); this.state.running = false; } + async check() { + if (this.state.checking) return; this.state.checking = true; + const signal = AbortSignal.any([this.controller.signal, AbortSignal.timeout(180_000)]); + try { + const file = path.join(this.dataDir, 'cloudflare-monitor.json'); + if ((await stat(file)).size > 8192) throw Error('Cloudflare monitoring configuration too large.'); + const cfg = JSON.parse(await readFile(file, 'utf8')) as { enabled?: unknown; credentialFile?: unknown }; + if (cfg.enabled !== true) { this.lastDiscovery = 0; this.state.enabled = false; this.state.domains = []; this.targets = []; this.state.issues = ['Cloudflare monitoring disabled.']; return; } + if (typeof cfg.credentialFile !== 'string' || !path.isAbsolute(cfg.credentialFile)) throw Error('Configure an absolute Cloudflare credential file path locally.'); + this.state.enabled = true; + const profile = await readProfile(cfg.credentialFile); + if (Date.now() - this.lastDiscovery >= DISCOVERY) { + this.lastDiscovery = Date.now(); const api = cloudflareRequest(profile.token, signal), discovery = await discover(api, profile.account); + signal.throwIfAborted(); this.targets = discovery.targets; this.state.workers = discovery.workers; + this.state.issues = discovery.issues; this.state.discoveredAt = new Date().toISOString(); + try { const a = await analytics(api, profile.account); this.state.metrics = a.metrics; this.state.analyticsFrom = a.from; this.state.analyticsTo = a.to; this.state.analyticsAt = new Date().toISOString(); } + catch (e) { this.state.metrics = []; this.state.analyticsAt = undefined; this.state.issues.push(e instanceof Error ? e.message : 'Worker analytics unavailable.'); } + } + const checked = new Map>>(); let next = 0; + await Promise.all(Array.from({ length: Math.min(4, this.targets.length) }, async () => { + while (next < this.targets.length) { signal.throwIfAborted(); const target = this.targets[next++]; checked.set(target.hostname, await probeDomain(target, signal)); } + })); + signal.throwIfAborted(); this.state.domains = this.targets.map(t => checked.get(t.hostname)!).filter(Boolean); + this.state.checkedAt = new Date().toISOString(); this.state.nextCheckAt = new Date(Date.now() + INTERVAL).toISOString(); + const output = path.join(this.dataDir, 'cloudflare-monitor-latest.json'); + await writeFile(output + '.tmp', JSON.stringify({ ...this.state, checking: false }), { mode: 0o600 }); await rename(output + '.tmp', output); + } catch (e) { + this.lastDiscovery = 0; this.targets = []; this.state.domains = []; this.state.metrics = []; this.state.analyticsAt = undefined; + this.state.issues = [(e as NodeJS.ErrnoException)?.code === 'ENOENT' ? 'Cloudflare monitor configuration or removable drive unavailable.' : e instanceof SyntaxError ? 'Cloudflare monitor configuration invalid.' : e instanceof Error ? e.message : 'Cloudflare monitoring unavailable.']; + this.state.nextCheckAt = new Date(Date.now() + INTERVAL).toISOString(); + } finally { this.state.checking = false; } + } +} +const monitors = new WeakMap(); +export function monitorOf(ctx: Ctx) { let m = monitors.get(ctx); if (!m) { m = new DomainMonitor(ctx.cfg.dataDir); monitors.set(ctx, m); } return m; } +export function startDomainMonitor(ctx: Ctx): () => void { const m = monitorOf(ctx); m.start(); return () => m.stop(); } diff --git a/src/server/domain-monitor/probe.ts b/src/server/domain-monitor/probe.ts new file mode 100644 index 000000000..02fc620c6 --- /dev/null +++ b/src/server/domain-monitor/probe.ts @@ -0,0 +1,48 @@ +import { lookup } from 'node:dns/promises'; +import https from 'node:https'; +import { isIP } from 'node:net'; +import type { DomainCheck, DomainTarget } from '../../shared/domain-monitor.js'; +import { hostnameValid } from './cloudflare.js'; +export function publicAddress(address: string): boolean { + if (isIP(address) === 4) { + const [a, b] = address.split('.').map(Number); + return !(a === 0 || a === 10 || a === 127 || a >= 224 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && (b === 0 || b === 168)) || (a === 198 && (b === 18 || b === 19 || b === 51)) || (a === 203 && b === 0)); + } + if (isIP(address) !== 6) return false; + // Conservative global-unicast allowlist excludes mapped/private/tunnel/documentation ranges. + const v = address.toLowerCase(); return /^[23][0-9a-f]{3}:/.test(v) && !v.startsWith('2001:') && !v.startsWith('2002:'); +} +export function classify(http: number): DomainCheck['status'] { + return http >= 200 && http < 300 ? 'reachable' : http >= 300 && http < 400 ? 'redirect' : http === 401 || http === 403 ? 'restricted' : http >= 400 ? 'error' : 'unknown'; +} +export async function probeDomain(target: DomainTarget, signal?: AbortSignal): Promise { + const started = Date.now(), checkedAt = new Date().toISOString(), base = { ...target, checkedAt }; + if (!hostnameValid(target.hostname)) return { ...base, status: 'unknown', latencyMs: 0, issue: 'Invalid hostname; probe refused.' }; + try { + signal?.throwIfAborted(); let timer: NodeJS.Timeout | undefined; + const addresses = await Promise.race([lookup(target.hostname, { all: true, verbatim: true }), new Promise((_, reject) => { timer = setTimeout(() => reject(Error('DNS_TIMEOUT')), 4000); })]).finally(() => clearTimeout(timer)); + if (!addresses.length || addresses.some(a => !publicAddress(a.address))) return { ...base, status: 'unknown', latencyMs: Date.now() - started, issue: 'Private/special DNS address; probe refused.' }; + const address = addresses.find(a => a.family === 4) ?? addresses[0]; + return await new Promise((resolve) => { + let settled = false; + const finish = (value: Partial & Pick) => { if (settled) return; settled = true; resolve({ ...base, latencyMs: Date.now() - started, ...value }); }; + const deadline = AbortSignal.timeout(Math.max(100, 8000 - (Date.now() - started))); + const request = https.get({ hostname: target.hostname, port: 443, path: '/', family: address.family, + lookup: (_host, _options, callback) => callback(null, address.address, address.family), + rejectUnauthorized: true, signal: signal ? AbortSignal.any([signal, deadline]) : deadline, + headers: { 'user-agent': 'Agent-Office-domain-monitor/1.0', accept: 'text/html,application/json;q=0.9,*/*;q=0.5' }, + }, response => { + const http = response.statusCode ?? 0, status = classify(http), rawRay = String(response.headers['cf-ray'] ?? ''); + const ray = /^[a-z0-9-]{10,50}$/i.test(rawRay) ? rawRay : undefined; + let body = '', bytes = 0; + const done = () => { + const cf = /(?:error\s*(?:code\s*[:=]?\s*)?|cf-error-code[^>]*>\s*)(1\d{3})\b/i.exec(body)?.[1]; + finish({ http, status: cf ? 'error' : status, ray, cloudflareError: cf, issue: cf ? 'Cloudflare error ' + cf : status === 'redirect' ? 'HTTP redirect; destination not probed.' : status === 'restricted' ? 'Authentication, Access or challenge; application health unknown.' : status === 'error' ? 'HTTP ' + http : undefined }); + }; + response.on('data', (chunk: Buffer) => { bytes += chunk.length; if (body.length < 16384) body += chunk.toString('utf8').slice(0, 16384 - body.length); if (bytes >= 16384) { done(); response.destroy(); } }); + response.on('end', done); response.on('close', done); response.on('error', () => finish({ status: 'error', issue: 'HTTP response interrupted.' })); + }); + request.on('error', (error: NodeJS.ErrnoException) => finish({ status: 'error', issue: 'DNS/TLS/network failure (' + String(error.code ?? 'NETWORK').replace(/[^A-Z0-9_]/g, '').slice(0, 50) + ').' })); + }); + } catch { return { ...base, status: 'error', latencyMs: Date.now() - started, issue: 'DNS failure or timeout.' }; } +} diff --git a/src/server/http/routes/domain-monitor.ts b/src/server/http/routes/domain-monitor.ts new file mode 100644 index 000000000..09952f20f --- /dev/null +++ b/src/server/http/routes/domain-monitor.ts @@ -0,0 +1,6 @@ +import { monitorOf } from '../../domain-monitor/index.js'; +import type { Route } from '../router.js'; +import { send } from '../util.js'; +export const domainMonitorRoutes = { + state: { method: 'GET', path: '/api/domain-monitor', auth: 'session', handle(ctx, { res }) { res.setHeader('cache-control', 'no-store'); send(res, 200, monitorOf(ctx).snapshot()); } }, +} satisfies Record; diff --git a/src/server/http/routes/index.ts b/src/server/http/routes/index.ts index 982acdd1b..328425875 100644 --- a/src/server/http/routes/index.ts +++ b/src/server/http/routes/index.ts @@ -9,6 +9,7 @@ import { githubRoutes } from './github.js'; import { pageRoutes } from './pages.js'; import { searchRoutes } from './search.js'; import { serviceRoutes } from './services.js'; +import { domainMonitorRoutes } from './domain-monitor.js'; export const routes: readonly Route[] = [ // Anyone. @@ -35,6 +36,7 @@ export const routes: readonly Route[] = [ fileRoutes.docs, searchRoutes.search, serviceRoutes.forwards, + domainMonitorRoutes.state, githubRoutes.github, pageRoutes.office, pageRoutes.lite, diff --git a/src/server/meeting-format.ts b/src/server/meeting-format.ts new file mode 100644 index 000000000..52e821eed --- /dev/null +++ b/src/server/meeting-format.ts @@ -0,0 +1,14 @@ +/** Pure meeting text helpers, shared without growing the meeting coordinator. */ +export function numbered(roles: string[]): string[] { + const seen = new Map(), count = new Map(); + for (const r of roles) count.set(r.toLowerCase(), (count.get(r.toLowerCase()) ?? 0) + 1); + return roles.map(r => { + const key = r.toLowerCase(); + if ((count.get(key) ?? 0) < 2) return r; + const n = (seen.get(key) ?? 0) + 1; seen.set(key, n); + return `${r} ${n}`; + }); +} +export function list(xs: string[]): string { return xs.length < 2 ? (xs[0] ?? '') : `${xs.slice(0, -1).join(', ')} and ${xs[xs.length - 1]}`; } +export function firstLine(s: string): string { return s.split('\n').map(l => l.trim()).find(Boolean) ?? ''; } +export function clamp(v: number, lo: number, hi: number) { return Math.max(lo, Math.min(hi, v)); } diff --git a/src/server/meetings.ts b/src/server/meetings.ts index 191aa6849..d20741853 100644 --- a/src/server/meetings.ts +++ b/src/server/meetings.ts @@ -10,6 +10,7 @@ import { validateWorkerEffort, validateWorkerModel } from './agents.js'; import { providerMeta, takesEffort, takesModel } from '../shared/providers.js'; import { gitError, type WorktreeRef, type WorktreeState } from './worktrees.js'; import { PROMPTS, fillPrompt, type PromptId, type PromptVars } from '../shared/prompts.js'; +import { numbered, list, firstLine, clamp } from './meeting-format.js'; const execFileP = promisify(execFile); @@ -755,30 +756,3 @@ function readStart(file: string, bytes: number): string { closeSync(fd); } } - -/** Roles that repeat get numbered, so each worker at the table has one of its own: Engineer 1, Engineer 2. */ -function numbered(roles: string[]): string[] { - const seen = new Map(); - const count = new Map(); - for (const r of roles) count.set(r.toLowerCase(), (count.get(r.toLowerCase()) ?? 0) + 1); - return roles.map((r) => { - const key = r.toLowerCase(); - if ((count.get(key) ?? 0) < 2) return r; - const n = (seen.get(key) ?? 0) + 1; - seen.set(key, n); - return `${r} ${n}`; - }); -} - -/** "a", "a and b", "a, b and c". */ -function list(xs: string[]): string { - return xs.length < 2 ? (xs[0] ?? '') : `${xs.slice(0, -1).join(', ')} and ${xs[xs.length - 1]}`; -} - -function firstLine(s: string): string { - return s.split('\n').map((l) => l.trim()).find(Boolean) ?? ''; -} - -function clamp(v: number, lo: number, hi: number) { - return Math.max(lo, Math.min(hi, v)); -} diff --git a/src/server/office/timers.ts b/src/server/office/timers.ts index 15eabb94a..677ace44a 100644 --- a/src/server/office/timers.ts +++ b/src/server/office/timers.ts @@ -1,8 +1,10 @@ import type { Ctx } from './context.js'; import { SLOW_CLIENT_BYTES } from './client.js'; +import { startDomainMonitor } from '../domain-monitor/index.js'; /** The office's own clocks: terminals re-sent to viewers who fell behind, and the heartbeat. Returns what stops them. */ export function startTimers(ctx: Ctx): () => void { + const stopDomainMonitor = startDomainMonitor(ctx); const { clients } = ctx; const resync = setInterval(() => { for (const c of clients.values()) { @@ -32,6 +34,7 @@ export function startTimers(ctx: Ctx): () => void { }, 20_000); return () => { + stopDomainMonitor(); clearInterval(heartbeat); clearInterval(resync); }; diff --git a/src/shared/domain-monitor.ts b/src/shared/domain-monitor.ts new file mode 100644 index 000000000..1247af110 --- /dev/null +++ b/src/shared/domain-monitor.ts @@ -0,0 +1,13 @@ +export interface DomainTarget { hostname: string; workers: string[]; source: string } +export interface DomainCheck extends DomainTarget { + checkedAt: string; + status: 'reachable' | 'redirect' | 'restricted' | 'error' | 'unknown'; + http?: number; latencyMs: number; issue?: string; ray?: string; cloudflareError?: string; +} +export interface WorkerMetric { name: string; requests: number; errors: number; outcomes: Record } +export interface DomainMonitorState { + enabled: boolean; running: boolean; location: string; intervalSeconds: number; + checkedAt?: string; nextCheckAt?: string; discoveredAt?: string; + analyticsAt?: string; analyticsFrom?: string; analyticsTo?: string; + domains: DomainCheck[]; workers: string[]; metrics: WorkerMetric[]; issues: string[]; checking: boolean; +} diff --git a/tests/domain-monitor.test.ts b/tests/domain-monitor.test.ts new file mode 100644 index 000000000..7f3d0c14e --- /dev/null +++ b/tests/domain-monitor.test.ts @@ -0,0 +1,121 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { parseProfile, hostnameValid, discover, collection, analytics, cloudflareRequest } from '../src/server/domain-monitor/cloudflare.js'; +import { publicAddress, classify, probeDomain } from '../src/server/domain-monitor/probe.js'; +import { DomainMonitor } from '../src/server/domain-monitor/index.js'; +import { domainMonitorRoutes } from '../src/server/http/routes/domain-monitor.js'; + +const account = '1'.repeat(32), token = 'synthetic_profile_'.repeat(3); +test('profile accepts one credential, rejects missing or conflicting values without echoing them', () => { + assert.deepEqual(parseProfile(`Authorization: Bearer ${token}\nhttps://api.cloudflare.com/client/v4/accounts/${account}/tokens/verify`), { token, account }); + for (const source of ['', `Bearer ${token}`, `Bearer ${token}\nBearer ${'different_'.repeat(5)}\n/accounts/${account}/`]) { + assert.throws(() => parseProfile(source), e => e instanceof Error && !e.message.includes(token)); + } +}); +test('hostname validation rejects URL/credential/control/wildcard injection', () => { + for (const s of ['https://example.com', 'user:pass@example.com', 'example.com:443', '../example.com', '*.example.com', 'example.com\n', '-a.example.com']) assert.equal(hostnameValid(s), false, s); + assert.ok(hostnameValid('www.example.com')); +}); +test('public IP allowlist refuses local, special and IPv6 mapping/tunnel addresses', () => { + for (const s of ['127.0.0.1', '10.2.3.4', '169.254.169.254', '100.100.100.200', '172.31.0.1', '192.168.0.2', '198.18.0.1', '224.0.0.1', '::1', '::ffff:127.0.0.1', '::ffff:7f00:1', 'fc00::1', 'fe80::1', '2001:db8::1', '2002:7f00:1::', 'not-ip']) assert.equal(publicAddress(s), false, s); + assert.equal(publicAddress('1.1.1.1'), true); assert.equal(publicAddress('2606:4700:4700::1111'), true); +}); +test('loopback HTTPS probe is refused before any HTTP request', async () => { + const d = await probeDomain({ hostname: '127.0.0.1', workers: [], source: 'test' }); + assert.equal(d.status, 'unknown'); assert.match(d.issue!, /refused/); assert.equal(d.http, undefined); +}); +test('HTTP categories do not represent auth or redirects as application health', () => { + assert.deepEqual([200, 204, 301, 401, 403, 404, 503, 0].map(classify), ['reachable', 'reachable', 'redirect', 'restricted', 'restricted', 'error', 'error', 'unknown']); +}); +test('live discovery replaces domain sets and maps route plus custom-domain Workers', async () => { + let current = 'first.example.com'; + const api = async (route: string) => { + let result: unknown[] = []; + if (route.startsWith('/zones?')) result = [{ name: 'example.com', id: '2'.repeat(32) }]; + if (route.includes('/dns_records')) result = [{ name: current, type: 'A' }, { name: 'mail-secret.example.com', type: 'TXT', content: token }]; + if (route.includes('/workers/routes')) result = [{ pattern: current + '/*', script: 'gateway' }]; + if (route.includes('/workers/domains')) result = [{ hostname: current, service: 'origin' }]; + if (route.includes('/workers/scripts')) result = [{ id: 'gateway' }, { id: 'origin' }]; + if (route.includes('/pages/projects')) { assert.ok(route.includes('per_page=10')); result = [{ name: 'site', domains: ['site.pages.dev'] }]; } + return { success: true, result, result_info: { total_pages: 1, page: 1 } }; + }; + const a = await discover(api, account); current = 'second.example.com'; const b = await discover(api, account); + assert.deepEqual(a.targets.find(x => x.hostname === 'first.example.com')?.workers, ['gateway', 'origin']); + assert.ok(b.targets.some(x => x.hostname === 'second.example.com')); assert.ok(!b.targets.some(x => x.hostname === 'first.example.com')); + assert.ok(!JSON.stringify(a).includes(token)); assert.ok(!a.targets.some(x => x.hostname === 'mail-secret.example.com')); +}); +test('collection follows actual pagination and fails on mismatch or API denial', async () => { + let count = 0; + const values = await collection(async () => ({ success: true, result: [{ n: ++count }], result_info: { page: count, total_pages: 2 } }), '/zones'); + assert.equal(values.length, 2); + await assert.rejects(collection(async () => ({ success: true, result: [], result_info: { page: 2 } }), '/zones'), /pagination/); + await assert.rejects(collection(async () => ({ success: false, result: null, errors: [{ message: token }] }), '/zones'), e => e instanceof Error && !e.message.includes(token)); +}); +test('capability failures remain visible rather than claiming complete coverage', async () => { + const d = await discover(async () => { throw Error('Cloudflare API HTTP 403.'); }, account); + assert.equal(d.targets.length, 0); assert.equal(d.issues.length, 4); assert.ok(d.issues.every(x => x.includes('403'))); +}); +test('analytics use fixed read-only query and retain request/error outcome counts', async () => { + const result = await analytics(async (route, body) => { + assert.equal(route, '/graphql'); assert.match(JSON.stringify(body), /workersInvocationsAdaptive/); assert.ok(!JSON.stringify(body).includes('mutation')); + return { data: { viewer: { accounts: [{ workersInvocationsAdaptive: [ + { dimensions: { scriptName: 'worker', status: 'success' }, sum: { requests: 4, errors: 0 } }, + { dimensions: { scriptName: 'worker', status: 'exception' }, sum: { requests: 2, errors: 2 } }, + ] }] } }, errors: null }; + }, account); + assert.deepEqual(JSON.parse(JSON.stringify(result.metrics)), [{ name: 'worker', requests: 6, errors: 2, outcomes: { success: 4, exception: 2 } }]); +}); +test('analytics missing access is unknown and does not echo API errors', async () => { + await assert.rejects(analytics(async () => ({ errors: [{ message: token }] }), account), e => e instanceof Error && !e.message.includes(token)); + await assert.rejects(analytics(async () => ({ data: { viewer: { accounts: [] } } }), account), /unavailable/); + const result = await analytics(async () => ({ data: { viewer: { accounts: [{ workersInvocationsAdaptive: [] }] } } }), account); + assert.deepEqual(result.metrics, []); +}); +test('transport refuses writes, rejects redirects, bounds bodies and hides raw API errors', async () => { + const fetchBefore = globalThis.fetch; + try { + globalThis.fetch = async (_url, init) => { assert.equal(init?.redirect, 'error'); return new Response(JSON.stringify({ error: token }), { status: 403 }); }; + const api = cloudflareRequest(token); + await assert.rejects(api('/accounts/' + account + '/workers/scripts', {}), /refused/); + await assert.rejects(api('https://evil.example/zones'), /refused/); + await assert.rejects(api('/graphql', { query: 'mutation { unsafe }' }), /fixed read-only/); + await assert.rejects(api('/zones'), e => e instanceof Error && e.message.includes('403') && !e.message.includes(token)); + globalThis.fetch = async () => new Response('x'.repeat(2 * 1024 * 1024 + 1)); + await assert.rejects(api('/zones'), /response limit/); + globalThis.fetch = async () => new Response(token); + await assert.rejects(api('/zones'), e => e instanceof Error && !e.message.includes(token)); + } finally { globalThis.fetch = fetchBefore; } +}); +test('missing/disabled local configuration and stopped lifecycle are explicit; snapshots are copies', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'office-monitor-')); + const monitor = new DomainMonitor(dir); + try { + await monitor.check(); assert.match(monitor.snapshot().issues.join(' '), /unavailable/); + await writeFile(path.join(dir, 'cloudflare-monitor.json'), JSON.stringify({ enabled: false })); + monitor.start(); monitor.start(); await new Promise(resolve => setTimeout(resolve, 50)); + assert.equal(monitor.snapshot().enabled, false); const s = monitor.snapshot(); s.issues.push(token); assert.ok(!monitor.snapshot().issues.includes(token)); + monitor.stop(); assert.equal(monitor.snapshot().running, false); assert.equal(domainMonitorRoutes.state.auth, 'session'); + } finally { monitor.stop(); await rm(dir, { recursive: true, force: true }); } +}); +test('restoring removable-drive credentials triggers fresh discovery after a failure', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'office-monitor-recovery-')), fetchBefore = globalThis.fetch; + const profile = path.join(dir, 'profile.txt'), monitor = new DomainMonitor(dir); let discoveries = 0; + try { + await writeFile(path.join(dir, 'cloudflare-monitor.json'), JSON.stringify({ enabled: true, credentialFile: profile })); + const content = `Bearer ${token}\n/accounts/${account}/`; + await writeFile(profile, content); + globalThis.fetch = async url => { + const route = String(url); + if (route.includes('/graphql')) return Response.json({ data: { viewer: { accounts: [{ workersInvocationsAdaptive: [] }] } } }); + if (route.includes('/workers/scripts')) discoveries++; + return Response.json({ success: true, result: [], result_info: { page: 1, total_pages: 1 } }); + }; + await monitor.check(); assert.equal(discoveries, 1); + await rm(profile); await monitor.check(); assert.match(monitor.snapshot().issues.join(' '), /credential file unavailable/); + await writeFile(profile, content); await monitor.check(); assert.equal(discoveries, 2); + assert.ok(!JSON.stringify(monitor.snapshot()).includes(token)); + } finally { globalThis.fetch = fetchBefore; monitor.stop(); await rm(dir, { recursive: true, force: true }); } +}); diff --git a/tests/size.test.ts b/tests/size.test.ts index be8ce888f..aa52a924a 100644 --- a/tests/size.test.ts +++ b/tests/size.test.ts @@ -24,7 +24,7 @@ const CEILINGS: Readonly> = { 'src/server/workers/manager.ts': 1029, 'src/client/features/rooftop/world.ts': 989, 'src/client/world/sky.ts': 966, - 'src/server/meetings.ts': 764, + 'src/server/meetings.ts': 758, 'src/client/world/holiday.ts': 702, 'src/client/features/dog/world.ts': 702, 'src/client/world/character/person.ts': 694, From 7e2929fb56eb6f0ce6f91109fc20f547a6b64faf Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 23 Oct 2026 20:18:22 -0400 Subject: [PATCH 7/7] Show live Cloudflare status in Office Services and lightweight view --- README.md | 2 ++ docs/cloudflare-monitor.md | 17 +++++++++++ src/client/features/hud/index.ts | 2 ++ src/client/lite.ts | 2 ++ src/client/ui/domain-monitor.css | 8 +++++ src/client/ui/domain-monitor.ts | 52 ++++++++++++++++++++++++++++++++ src/client/ui/services.ts | 2 ++ tests/client-registry.test.ts | 2 +- 8 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 docs/cloudflare-monitor.md create mode 100644 src/client/ui/domain-monitor.css create mode 100644 src/client/ui/domain-monitor.ts diff --git a/README.md b/README.md index ef90678c9..dfa36cf05 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ > [!WARNING] +> Local domain monitoring: [Cloudflare status setup and coverage](docs/cloudflare-monitor.md). The Office computer performs the checks with a locally referenced credential file; no monitor is deployed to the websites. +> > **Work in progress.** Agent Office is built for one person's workflow — mine — and it changes fast as I iterate on it. > Expect breaking changes between releases: keys that move, screens that get redrawn, features that come and go > without notice. If it's close to what you want, fork or clone it and bend it into what you need it to be. diff --git a/docs/cloudflare-monitor.md b/docs/cloudflare-monitor.md new file mode 100644 index 000000000..678f7c1c1 --- /dev/null +++ b/docs/cloudflare-monitor.md @@ -0,0 +1,17 @@ +# Cloudflare monitoring from Agent Office + +Open **☰ → Cloudflare status**, **Services → Cloudflare domains, status and errors**, or the **Cloudflare status** button in 2D view. Agent Office performs the checks on the computer running Office, independently of agents and whether the panel is open. Keep Office running; shutdown, sleep or network loss interrupts monitoring. No monitoring Worker or website-side agent is installed. + +Create `cloudflare-monitor.json` in the Office building's `.agent-office` directory: + +```json +{ "enabled": true, "credentialFile": "G:/your-folder/Cloudflare account API key.txt" } +``` + +The credential file must contain exactly one `Authorization: Bearer TOKEN` and one Cloudflare `/accounts/ACCOUNT_ID/` URL. Keep it private. Configuration stores its path only; Office reads credentials in memory. Drive removal/rotation is detected. Do not put a token in source, launch arguments, prompts or browser settings. + +Every five minutes Office reads the live Cloudflare API for zones, web DNS names, Worker custom domains/routes/scripts and Pages. It never imports saved domain inventories. API coverage/permission failures are shown. Every minute it independently requests HTTPS `/` on discovered domains, with public DNS addresses pinned to the connection, TLS verification, deadlines, response bounds and four-request concurrency. Redirect destinations are not followed. Wildcard-only names cannot be probed without a concrete domain. + +Statuses distinguish reachable, redirect, restricted, error and unknown. A reachable root does not validate login, APIs, checkout or every path. Cloudflare error codes/ray IDs are shown when observed. Last-15-minute aggregate Worker analytics shows request/error counts and outcome categories. No returned traffic does not establish zero errors. These are sampled analytics, not complete console/exception logs. Missing permissions remain visible and telemetry settings are never changed automatically. + +Only authenticated Office sessions can read `/api/domain-monitor`. Current observations are saved atomically in `cloudflare-monitor-latest.json`; startup always performs fresh discovery rather than loading it as inventory. No raw page bodies, cookies, credentials or console logs are saved. Close with ✕ or Esc. To disable, set `enabled` false locally; it takes effect at the next check. Restore the prior installed build/configuration backup to roll back. diff --git a/src/client/features/hud/index.ts b/src/client/features/hud/index.ts index a54730173..429b4e0fa 100644 --- a/src/client/features/hud/index.ts +++ b/src/client/features/hud/index.ts @@ -18,6 +18,7 @@ import { toggleFloorMenu } from '../../ui/floormenu'; import { openHelp } from '../../ui/hud'; import { mountHud } from '../../ui/menu'; import { openServices } from '../../ui/services'; +import { openDomainMonitor } from '../../ui/domain-monitor'; import { openSettings, type SettingsPane } from '../../ui/settings'; import { needsSigningIn, openSignIns } from '../../ui/signins'; import { openTeam } from '../../ui/team'; @@ -53,6 +54,7 @@ export function installHud(ctx: Ctx, core: CoreState, parts: HudParts) { { id: 'pulls', icon: '🔀', label: 'Pull requests', section: 'Open', count: () => store.pulls.items.filter((p) => p.state === 'OPEN').length, run: () => openBoard('pulls', net, actions.boardActions()) }, { id: 'queue', icon: '📋', label: 'Task queue', section: 'Open', count: () => store.queue.tasks.filter((t) => t.status !== 'done').length, title: () => 'Issues and tasks waiting for a worker', run: waiting.showQueue }, { id: 'services', icon: '🌐', label: 'Services', section: 'Open', count: () => store.services.items.length, title: () => 'Web servers the workers are running', run: () => openServices() }, + { id: 'cloudflare-status', icon: '☁️', label: 'Cloudflare status', section: 'Open', title: () => 'Domain availability and Worker errors monitored by this Office', run: () => openDomainMonitor() }, { id: 'whiteboard', icon: '📝', label: 'Whiteboard', section: 'Open', title: () => 'Draw together, live', run: () => openWhiteboard(net) }, // Up on the top bar while a meeting is on: what's being worked through in the meeting room. { diff --git a/src/client/lite.ts b/src/client/lite.ts index 49aaf6f0e..d7792df97 100644 --- a/src/client/lite.ts +++ b/src/client/lite.ts @@ -20,6 +20,7 @@ import { openBoard } from './ui/boards'; import type { BoardActions } from './ui/github/prompts'; import { openPull, routePullMessage } from './ui/pull'; import { openQueue } from './ui/queue'; +import { mountDomainMonitorLink } from './ui/domain-monitor'; import { openAsk } from './ui/ask'; import { openMeeting, type MeetingPreset } from './ui/meeting'; import { openSignIns } from './ui/signins'; @@ -320,6 +321,7 @@ function showMeeting(preset?: MeetingPreset) { $('btn-issues').addEventListener('click', () => openBoard('issues', net, boardActions())); $('btn-pulls').addEventListener('click', () => openBoard('pulls', net, boardActions())); $('btn-queue').addEventListener('click', () => openQueue(net, { openTerminal: openWorker })); +mountDomainMonitorLink($('btn-queue')); $('btn-new').addEventListener('click', () => sendToWorker('✨ New task')); function renderNav() { diff --git a/src/client/ui/domain-monitor.css b/src/client/ui/domain-monitor.css new file mode 100644 index 000000000..a38f7e3b6 --- /dev/null +++ b/src/client/ui/domain-monitor.css @@ -0,0 +1,8 @@ +.domain-monitor { overflow:auto; max-height:76vh; } +.monitor-table { width:100%; border-collapse:collapse; font-size:13px; } +.monitor-table th,.monitor-table td { padding:9px 7px; text-align:left; border-bottom:1px solid #8885; vertical-align:top; overflow-wrap:anywhere; } +.monitor-table th { position:sticky; top:0; background:var(--panel,#fff4df); } +.monitor-table small { display:block; } +.monitor-warning,.monitor-error { color:#9d2020; font-weight:600; } +.monitor-reachable { color:#226b3b; } +.monitor-restricted,.monitor-redirect,.monitor-unknown { color:#795413; } diff --git a/src/client/ui/domain-monitor.ts b/src/client/ui/domain-monitor.ts new file mode 100644 index 000000000..2883b9b41 --- /dev/null +++ b/src/client/ui/domain-monitor.ts @@ -0,0 +1,52 @@ +import type { DomainMonitorState } from '../../shared/domain-monitor'; +import { h, openModal } from './dom'; +import './domain-monitor.css'; + +export function openDomainMonitor() { + const content = h('div.body.domain-monitor'), controller = new AbortController(); + let busy = false; + const modal = openModal(h('div.modal', { role: 'dialog', 'aria-label': 'Cloudflare status', style: 'width:min(1050px,100%)' }, + h('header', {}, h('h2', {}, '☁️ Cloudflare status')), content), { doing: '☁️ checking Cloudflare domains', onClose: () => { clearInterval(timer); controller.abort(); } }); + const render = async () => { + if (busy) return; busy = true; + try { + const response = await fetch('/api/domain-monitor', { credentials: 'same-origin', cache: 'no-store', signal: controller.signal }); + if (!response.ok) throw Error('Monitoring unavailable (HTTP ' + response.status + ').'); + const s = await response.json() as DomainMonitorState; + const when = (value?: string) => value ? new Date(value).toLocaleString() : 'Not yet checked'; + const stale = !s.checkedAt || Date.now() - Date.parse(s.checkedAt) > 150_000; + content.replaceChildren( + h('p', {}, 'Monitoring from ', h('strong', {}, s.location), '. Checks continue while this panel is closed. Office must remain running on this computer.'), + h('p', { class: stale ? 'monitor-warning' : '' }, `${s.checking ? 'Checking… · ' : ''}${stale ? 'STALE / NOT YET VERIFIED · ' : ''}Last website check: ${when(s.checkedAt)} · Every ${s.intervalSeconds}s · Live API discovery: ${when(s.discoveredAt)}`), + h('p.note', {}, 'Reachable means an HTTPS response at /. Redirects, restricted pages and zero traffic do not establish application health. Worker counts are sampled aggregate analytics, not a complete exception log.'), + ...s.issues.map(issue => h('p.monitor-warning', {}, issue)), + ); + if (!s.enabled) content.append(h('p', {}, 'Cloudflare monitoring is not enabled in this Office installation.')); + const table = h('table.monitor-table', {}, h('thead', {}, h('tr', {}, ...['Domain', 'Website status', 'Workers / Pages', 'Last checked'].map(x => h('th', {}, x))))); + const tbody = h('tbody'); table.append(tbody); + for (const d of s.domains) tbody.append(h('tr', {}, + h('td', {}, h('a', { href: 'https://' + d.hostname + '/', target: '_blank', rel: 'noopener noreferrer' }, d.hostname)), + h('td', { class: 'monitor-' + d.status }, `${d.status.toUpperCase()}${d.http ? ' · HTTP ' + d.http : ''} · ${d.latencyMs} ms`, d.issue ? h('div', {}, d.issue) : null, d.ray ? h('small', {}, 'Ray ' + d.ray) : null), + h('td', {}, d.workers.join(', ') || 'DNS / origin; no Worker route found'), h('td', {}, when(d.checkedAt)), + )); + content.append(h('h3', {}, `Domains (${s.domains.length})`), table); + content.append(h('h3', {}, `Worker outcomes · ${when(s.analyticsFrom)} — ${when(s.analyticsTo)}`)); + if (!s.analyticsAt) content.append(h('p.monitor-warning', {}, 'Worker error analytics unavailable; no zero-error claim.')); + const workers = h('table.monitor-table', {}, h('thead', {}, h('tr', {}, ...['Worker', 'Requests', 'Errors', 'Outcomes'].map(x => h('th', {}, x))))); + const body = h('tbody'); workers.append(body); + for (const name of s.workers) { + const m = s.metrics.find(x => x.name === name); + body.append(h('tr', {}, h('td', {}, name), h('td', {}, m ? m.requests : 'No returned traffic'), h('td', { class: m?.errors ? 'monitor-error' : '' }, m ? m.errors : 'Not established'), h('td', {}, m ? Object.entries(m.outcomes).map(([k, v]) => `${k}: ${v}`).join(', ') : 'No sample / analytics unavailable'))); + } + content.append(workers); + } catch (e) { if (!controller.signal.aborted) content.replaceChildren(h('p.monitor-warning', {}, e instanceof Error ? e.message : 'Monitoring unavailable.')); } + finally { busy = false; } + }; + const timer = setInterval(() => void render(), 5000); void render(); + return modal; +} + +/** The lightweight Office view shares this module without importing the 3D engine. */ +export function mountDomainMonitorLink(anchor: HTMLElement) { + anchor.after(h('button.btn', { type: 'button', onclick: () => openDomainMonitor() }, '☁️ Cloudflare status')); +} diff --git a/src/client/ui/services.ts b/src/client/ui/services.ts index 11954ccd3..d52159765 100644 --- a/src/client/ui/services.ts +++ b/src/client/ui/services.ts @@ -2,6 +2,7 @@ import './services.css'; import type { ServiceInfo, ServicesState } from '../../shared/protocol'; import { store } from '../state'; import { h, openModal, timeAgo } from './dom'; +import { openDomainMonitor } from './domain-monitor'; import { copy, copyButton, guessOs, openCommand, OS_LABEL, type Os } from './team'; /** Whether this page came over the office's Tailscale network, where every server has its own link. */ @@ -78,6 +79,7 @@ export function openServices() { ? 'Every link goes through the office, so the office sign-in still guards every page.' : 'Tunnels go through the office, so the office password still guards every page. Keep the terminal open while you look.'; body.replaceChildren( + h('button.btn', { type: 'button', onclick: () => openDomainMonitor() }, '☁️ Cloudflare domains, status and errors'), h( 'p.note', { style: 'margin:0 0 12px' }, diff --git a/tests/client-registry.test.ts b/tests/client-registry.test.ts index 09487039e..e5ed96fd5 100644 --- a/tests/client-registry.test.ts +++ b/tests/client-registry.test.ts @@ -358,7 +358,7 @@ test('interactions: one definition per kind, with its reach, hint and use', () = function clientSources(): { file: string; src: string }[] { const root = path.join(import.meta.dirname, '../src/client'); const code = (src: string) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, ''); - return (readdirSync(root, { recursive: true }) as string[]).filter((rel) => rel.endsWith('.ts')).map((file) => ({ file, src: code(readFileSync(path.join(root, file), 'utf8')) })); + return (readdirSync(root, { recursive: true }) as string[]).filter((rel) => rel.endsWith('.ts')).map((file) => ({ file: file.split(path.sep).join('/'), src: code(readFileSync(path.join(root, file), 'utf8')) })); } /**