diff --git a/CHANGELOG.md b/CHANGELOG.md index 32fe13e..0de471c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Added +- **Send-to-session**: the :8788 daemon gains `POST /sessions/send {agent, text, from?}` — deliver text as user input into any named agent's live session — plus `GET /sessions/agents` for the target picker. Four delivery adapters, configured per agent under `mcp.confirmations.sessions.agents`: `wake` (spawn a wake/injection script), `gui` (same, chained behind a human-idle guard with `--wait` so it never types over the user), `tmux` (literal `send-keys` + Enter), and `forward` (relay to the owning machine's daemon, provenance passed through so the `[from …]` prefix is applied exactly once). Deliveries are accepted-async (202) and every attempt is receipted. This is the backend half of the CodeWatch send box; it drives any agent uniformly, including headless sessions the provider apps cannot reach. - **Responder-lock enforcement in the MCP room tools (#42)**: `room_post` and `alert_recipient` now refuse when another live session holds the machine's room-responder lock — a PASSIVE session that ignores its injected instructions still cannot post as the agent through IAK tooling. Lock semantics mirror the SessionStart hook exactly (pid + process-start-time identity, stale locks ignored, fail-open on mechanics); the owning session is recognized by finding the lock's pid in the caller's process-ancestor chain. Read-only room tools (`room_recent`) are unaffected. Raw HTTP posting against GroupMind is out of scope client-side and tracked in issue #42's server-side follow-up. ### Fixed diff --git a/bin/iak-mcp-daemon.mjs b/bin/iak-mcp-daemon.mjs index 4c0de40..2771064 100755 --- a/bin/iak-mcp-daemon.mjs +++ b/bin/iak-mcp-daemon.mjs @@ -73,6 +73,7 @@ startConfirmationsServer({ receiptsPath: config?.receipts?.path, announce: serverAnnounce, wakeScript, + sessions: cc.sessions, }); console.log(`[iak-mcp-daemon] HTTP listener on http://${cc.host || '127.0.0.1'}:${cc.port || 8788} (POST /intent enabled: ${Object.keys(serverAnnouncerMap).join(',') || 'no announcers'})`); diff --git a/src/confirmations.mjs b/src/confirmations.mjs index 8160de9..5658841 100644 --- a/src/confirmations.mjs +++ b/src/confirmations.mjs @@ -21,6 +21,7 @@ import { createServer } from 'node:http'; import { randomUUID, createHmac, timingSafeEqual } from 'node:crypto'; import { appendFileSync } from 'node:fs'; import { spawn } from 'node:child_process'; +import { deliverToSession, listSessionAgents, HOP_HEADER } from './session-send.mjs'; // --- registry --------------------------------------------------------------- @@ -574,6 +575,7 @@ export function startConfirmationsServer({ receiptsPath, announce, // optional: enables POST /intent to create new intents externally wakeScript, // optional: shell script path; enables POST /wake to nudge the local IDE + sessions, // optional: {agents: {...}} enables POST /sessions/send + GET /sessions/agents } = {}) { const server = createServer((req, res) => { const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); @@ -729,6 +731,71 @@ export function startConfirmationsServer({ }); return; } + // POST /sessions/send — deliver text into a named agent's live session + // (the CodeWatch send-box primitive). Body: {agent, text, from?}. + // 202 = accepted for delivery (GUI adapters may wait on the human-idle + // guard before typing). See src/session-send.mjs for adapters/config. + if (req.method === 'POST' && url.pathname === '/sessions/send') { + if (!sessions || !sessions.agents) { + res.writeHead(503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: 'sessions not configured' })); + return; + } + let body = ''; + let overflow = false; + req.on('data', (c) => { + body += c; + // 64 KiB is far beyond any legal payload (text caps at 4000 chars); + // stop buffering hostile bodies instead of holding them in memory. + if (body.length > 64 * 1024 && !overflow) { + overflow = true; + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: 'body too large' })); + req.destroy(); + } + }); + req.on('end', async () => { + if (overflow) return; + let payload; + try { payload = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: 'invalid json' })); + return; + } + const hops = Math.max(0, Math.min(8, parseInt(req.headers[HOP_HEADER] || '0', 10) || 0)); + const result = await deliverToSession(sessions, payload.agent, { + text: payload.text, + from: payload.from, + hops, + onAsyncError: (e) => postReceipt(receiptsPath, { + kind: 'sessions.send.async_error', + agent: payload.agent, + error: e.message || String(e), + at: new Date().toISOString(), + }), + }); + postReceipt(receiptsPath, { + kind: 'sessions.send', + agent: payload.agent, + from: payload.from || null, + ok: result.ok, + delivered_via: result.deliveredVia || null, + error: result.error || null, + at: new Date().toISOString(), + }); + res.writeHead(result.status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result.ok + ? { ok: true, agent: payload.agent, deliveredVia: result.deliveredVia } + : { ok: false, error: result.error })); + }); + return; + } + // GET /sessions/agents — send-box target picker. + if (req.method === 'GET' && url.pathname === '/sessions/agents') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ agents: listSessionAgents(sessions) })); + return; + } // POST /ide-chat/ body { role, text, ts, session_id, tool_calls? } // GET /ide-chat/?since= → { events: [...] } // diff --git a/src/mcp-server.mjs b/src/mcp-server.mjs index 7e8fef6..d555470 100644 --- a/src/mcp-server.mjs +++ b/src/mcp-server.mjs @@ -386,6 +386,7 @@ export async function runMcpServer({ configPath } = {}) { receiptsPath: config?.receipts?.path, announce, wakeScript, + sessions: confirmCfg.sessions, }); process.stderr.write( `[iak-mcp] confirmations: enabled on ${daemonBase} (in-process) — channels: ${Object.keys(announcerMap).join(', ')}\n` diff --git a/src/session-send.mjs b/src/session-send.mjs new file mode 100644 index 0000000..50570f9 --- /dev/null +++ b/src/session-send.mjs @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// +// send-to-session: deliver a text message as user input into a named +// agent's live session. This is the backend primitive behind CodeWatch's +// send box — the uniform way to drive ANY agent (desktop-app, tmux, +// or an agent owned by another machine's daemon) from the phone. +// +// Config block (ide-agent-kit.json / dogfood.json): +// +// "sessions": { +// "agents": { +// "claudemm": { "adapter": "wake", "script": "/path/gui-wake.sh" }, +// "sally": { "adapter": "tmux", "tmux_session": "sally" }, +// "claudemb": { "adapter": "forward", "peer": "http://192.168.50.x:8788", +// "token_file": "~/.config/iak-gate.token" }, +// // and on the OWNING machine's daemon: +// // "claudemb": { "adapter": "gui", "script": "/path/gui-nudge.sh", +// // "idle_guard": "/path/human-idle-guard.sh" } +// } +// } +// +// Adapters: +// wake — spawn script with the text as its only argument (the /wake +// pattern; the script owns focusing/typing/logging). +// gui — like wake, but when idle_guard is set the guard runs first +// with --wait so injection never types over a human at the +// keyboard. +// tmux — literal send-keys into the tmux session, then Enter. +// forward — POST the same body to the owning machine's daemon. +// +// Delivery is accepted-async (202): spawn-based adapters detach, so a +// guard that waits minutes for the human to go idle never blocks the +// HTTP response. + +import { spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; + +const MAX_TEXT_LENGTH = 4000; +const MAX_FROM_LENGTH = 200; +const FORWARD_TIMEOUT_MS = 8000; +// One forward, ever: app → reachable daemon → owning daemon. The owning +// daemon must deliver locally; a second hop means a misconfigured peer +// chain (worst case a peer pointing back at itself), so it is refused +// instead of retried. +const MAX_FORWARD_HOPS = 1; +export const HOP_HEADER = 'x-iak-send-hops'; + +function expandHome(p) { + if (typeof p !== 'string') return p; + return p.startsWith('~/') ? `${homedir()}/${p.slice(2)}` : p; +} + +export function listSessionAgents(sessions) { + const agents = (sessions && sessions.agents) || {}; + return Object.entries(agents).map(([name, cfg]) => ({ + name, + adapter: (cfg && cfg.adapter) || 'unconfigured', + })); +} + +/** + * Deliver `text` into the session of `agentName`. + * Returns {ok, status, deliveredVia?|error?} — status is the HTTP code the + * caller should use. Never throws. + */ +export async function deliverToSession(sessions, agentName, { text, from, hops = 0, onAsyncError } = {}) { + if (typeof agentName !== 'string' || !agentName.trim()) { + return { ok: false, status: 400, error: 'missing agent' }; + } + if (typeof text !== 'string' || !text.trim()) { + return { ok: false, status: 400, error: 'missing text' }; + } + if (text.length > MAX_TEXT_LENGTH) { + return { ok: false, status: 400, error: `text too long (max ${MAX_TEXT_LENGTH})` }; + } + if (from !== undefined && (typeof from !== 'string' || from.length > MAX_FROM_LENGTH)) { + return { ok: false, status: 400, error: `from must be a string of at most ${MAX_FROM_LENGTH} characters` }; + } + const cfg = sessions && sessions.agents && sessions.agents[agentName]; + if (!cfg) { + return { ok: false, status: 404, error: 'unknown agent' }; + } + + const message = from ? `[from ${from}] ${text}` : text; + // Delivery is accepted-async: a failure after the detached spawn cannot + // change the already-sent 202, but it must not vanish either. + const reportAsync = (e) => { try { onAsyncError?.(e); } catch { /* never throw */ } }; + + switch (cfg.adapter) { + case 'wake': + case 'gui': { + const script = expandHome(cfg.script); + if (!script) return { ok: false, status: 503, error: 'no adapter configured' }; + try { + let child; + const guard = expandHome(cfg.idle_guard); + if (guard) { + // Chain through the human-idle guard so we never type over a + // human. Everything passed as argv — no shell interpolation of + // the message. + child = spawn('bash', [ + '-c', '"$1" --wait && exec "$2" "$3"', '--', guard, script, message, + ], { detached: true, stdio: 'ignore' }); + } else { + child = spawn(script, [message], { detached: true, stdio: 'ignore' }); + } + child.on('error', reportAsync); + child.on('close', (code) => { + if (code !== 0 && code !== null) reportAsync(new Error(`${cfg.adapter} adapter exited ${code}`)); + }); + child.unref(); + return { ok: true, status: 202, deliveredVia: cfg.adapter }; + } catch (e) { + return { ok: false, status: 500, error: e.message || String(e) }; + } + } + case 'tmux': { + const session = cfg.tmux_session; + if (!session) return { ok: false, status: 503, error: 'no adapter configured' }; + try { + // -l sends the message literally (no key-name interpretation), + // then a separate Enter submits it as user input. + await runOnce('tmux', ['send-keys', '-t', session, '-l', message]); + await runOnce('tmux', ['send-keys', '-t', session, 'C-m']); + return { ok: true, status: 202, deliveredVia: 'tmux' }; + } catch (e) { + return { ok: false, status: 500, error: e.message || String(e) }; + } + } + case 'forward': { + if (!cfg.peer) return { ok: false, status: 503, error: 'no adapter configured' }; + if (hops >= MAX_FORWARD_HOPS) { + return { ok: false, status: 508, error: 'forward hop limit reached — peer chain is misconfigured (possible loop)' }; + } + let token = cfg.token || ''; + if (!token && cfg.token_file) { + try { token = readFileSync(expandHome(cfg.token_file), 'utf8').trim(); } catch { /* peer may be open */ } + } + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FORWARD_TIMEOUT_MS); + const resp = await fetch(`${cfg.peer.replace(/\/$/, '')}/sessions/send`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + [HOP_HEADER]: String(hops + 1), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + // The peer daemon owns the final adapter; pass provenance through + // untouched rather than double-prefixing. + body: JSON.stringify({ agent: agentName, text, from }), + signal: controller.signal, + }); + clearTimeout(timer); + const data = await resp.json().catch(() => null); + // Peer confirmation must be explicit: exactly 202 + ok:true. A bare + // 200/204 or an unparseable body is NOT a delivery. + if (resp.status !== 202 || !data || data.ok !== true) { + return { ok: false, status: 502, error: `peer: ${(data && data.error) || `unexpected response ${resp.status}`}` }; + } + return { ok: true, status: 202, deliveredVia: `forward:${data.deliveredVia || cfg.peer}` }; + } catch (e) { + return { ok: false, status: 502, error: `peer unreachable: ${e.message || String(e)}` }; + } + } + default: + return { ok: false, status: 503, error: 'no adapter configured' }; + } +} + +function runOnce(cmd, args) { + return new Promise((resolvePromise, reject) => { + const child = spawn(cmd, args, { stdio: 'ignore' }); + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) resolvePromise(); + else reject(new Error(`${cmd} exited ${code}`)); + }); + }); +} diff --git a/test/session-send.test.mjs b/test/session-send.test.mjs new file mode 100644 index 0000000..6db26f0 --- /dev/null +++ b/test/session-send.test.mjs @@ -0,0 +1,266 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +import { describe, it, afterEach } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, chmodSync, realpathSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createServer } from 'node:http'; +import { deliverToSession, listSessionAgents } from '../src/session-send.mjs'; +import { startConfirmationsServer } from '../src/confirmations.mjs'; + +const tempPaths = []; +const servers = []; +let savedPath = null; + +function tempDir() { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'iak-send-'))); + tempPaths.push(dir); + return dir; +} + +afterEach(() => { + while (tempPaths.length > 0) rmSync(tempPaths.pop(), { recursive: true, force: true }); + while (servers.length > 0) servers.pop().close(); + if (savedPath !== null) { process.env.PATH = savedPath; savedPath = null; } +}); + +/** Stub executable that appends its argv to a log file. */ +function stubScript(dir, name, logName) { + const log = join(dir, logName); + const path = join(dir, name); + writeFileSync(path, `#!/bin/bash\nprintf '%s\\n' "$*" >> ${JSON.stringify(log)}\n`); + chmodSync(path, 0o755); + return { path, log }; +} + +async function waitFor(predicate, ms = 2000) { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + if (predicate()) return true; + await new Promise((r) => setTimeout(r, 25)); + } + return false; +} + +function listenEphemeral(opts) { + const server = startConfirmationsServer({ port: 0, host: '127.0.0.1', ...opts }); + servers.push(server); + return new Promise((resolvePromise) => { + server.on('listening', () => resolvePromise({ server, port: server.address().port })); + }); +} + +describe('deliverToSession', () => { + it('validates input and config', async () => { + const sessions = { agents: { known: { adapter: 'wake' } } }; + assert.equal((await deliverToSession(sessions, '', { text: 'x' })).status, 400); + assert.equal((await deliverToSession(sessions, 'known', { text: '' })).status, 400); + assert.equal((await deliverToSession(sessions, 'known', { text: 'y'.repeat(4001) })).status, 400); + assert.equal((await deliverToSession(sessions, 'nope', { text: 'x' })).status, 404); + // known agent, but adapter lacks its required field: + assert.equal((await deliverToSession(sessions, 'known', { text: 'x' })).status, 503); + assert.equal((await deliverToSession({ agents: { a: {} } }, 'a', { text: 'x' })).status, 503); + }); + + it('wake adapter spawns the script with the message, prefixing provenance', async () => { + const dir = tempDir(); + const { path, log } = stubScript(dir, 'wake.sh', 'wake.log'); + const sessions = { agents: { mm: { adapter: 'wake', script: path } } }; + const res = await deliverToSession(sessions, 'mm', { text: 'hello there', from: 'petrus' }); + assert.equal(res.status, 202); + assert.equal(res.deliveredVia, 'wake'); + assert.ok(await waitFor(() => existsSync(log)), 'script ran'); + assert.equal(readFileSync(log, 'utf8').trim(), '[from petrus] hello there'); + }); + + it('gui adapter runs the idle guard before the script', async () => { + const dir = tempDir(); + const guardLog = join(dir, 'guard.log'); + const guard = join(dir, 'guard.sh'); + // Guard records its invocation; script records guard-log existence so + // ordering is provable from the script side. + writeFileSync(guard, `#!/bin/bash\necho "guard $*" > ${JSON.stringify(guardLog)}\n`); + chmodSync(guard, 0o755); + const scriptLog = join(dir, 'script.log'); + const script = join(dir, 'inject.sh'); + writeFileSync(script, `#!/bin/bash\nprintf 'guard_ran=%s msg=%s\\n' "$([ -f ${JSON.stringify(guardLog)} ] && echo yes || echo no)" "$1" > ${JSON.stringify(scriptLog)}\n`); + chmodSync(script, 0o755); + const sessions = { agents: { mb: { adapter: 'gui', script, idle_guard: guard } } }; + const res = await deliverToSession(sessions, 'mb', { text: 'drive this' }); + assert.equal(res.status, 202); + assert.ok(await waitFor(() => existsSync(scriptLog)), 'script ran'); + assert.match(readFileSync(scriptLog, 'utf8'), /guard_ran=yes msg=drive this/); + assert.match(readFileSync(guardLog, 'utf8'), /--wait/); + }); + + it('tmux adapter sends the literal text then Enter', async () => { + const dir = tempDir(); + const { log } = stubScript(dir, 'tmux', 'tmux.log'); + savedPath = process.env.PATH; + process.env.PATH = `${dir}:${process.env.PATH}`; + const sessions = { agents: { sally: { adapter: 'tmux', tmux_session: 'sally-sess' } } }; + const res = await deliverToSession(sessions, 'sally', { text: 'status?' }); + assert.equal(res.status, 202); + assert.equal(res.deliveredVia, 'tmux'); + const calls = readFileSync(log, 'utf8').trim().split('\n'); + assert.equal(calls.length, 2); + assert.match(calls[0], /send-keys -t sally-sess -l status\?/); + assert.match(calls[1], /send-keys -t sally-sess C-m/); + }); + + it('forward adapter relays to a peer daemon and reports the peer verdict', async () => { + const dir = tempDir(); + const { path, log } = stubScript(dir, 'peer-wake.sh', 'peer.log'); + const { port } = await listenEphemeral({ + authToken: 'peer-secret', + sessions: { agents: { mb: { adapter: 'wake', script: path } } }, + }); + const tokenFile = join(dir, 'token'); + writeFileSync(tokenFile, 'peer-secret\n'); + const sessions = { + agents: { mb: { adapter: 'forward', peer: `http://127.0.0.1:${port}`, token_file: tokenFile } }, + }; + const res = await deliverToSession(sessions, 'mb', { text: 'relay me', from: 'petrus' }); + assert.equal(res.status, 202, JSON.stringify(res)); + assert.match(res.deliveredVia, /^forward:/); + assert.ok(await waitFor(() => existsSync(log)), 'peer script ran'); + // provenance prefixed exactly once, by the peer that owns the adapter: + assert.equal(readFileSync(log, 'utf8').trim(), '[from petrus] relay me'); + }); + + it('forward adapter surfaces an unreachable peer as 502', async () => { + const sessions = { agents: { mb: { adapter: 'forward', peer: 'http://127.0.0.1:1' } } }; + const res = await deliverToSession(sessions, 'mb', { text: 'x' }); + assert.equal(res.status, 502); + assert.match(res.error, /peer/); + }); + + it('refuses to forward past the hop limit (loop guard)', async () => { + const sessions = { agents: { mb: { adapter: 'forward', peer: 'http://127.0.0.1:1' } } }; + const res = await deliverToSession(sessions, 'mb', { text: 'x', hops: 1 }); + assert.equal(res.status, 508); + assert.match(res.error, /hop limit/); + }); + + it('a self-pointing peer terminates after one hop instead of looping', async () => { + // Daemon whose only route for 'mb' forwards to ITSELF. The first hop + // re-enters with hops=1, the inner forward refuses (508), the outer + // reports 502 — two requests total, no recursion. + const dir = tempDir(); + const sessions = { agents: { mb: { adapter: 'forward', peer: 'SELF' } } }; + const { port } = await listenEphemeral({ + authToken: '', // forward carries no token here; keep the peer open + receiptsPath: join(dir, 'receipts.jsonl'), + sessions, + }); + sessions.agents.mb.peer = `http://127.0.0.1:${port}`; + const resp = await fetch(`http://127.0.0.1:${port}/sessions/send`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agent: 'mb', text: 'loop me' }), + }); + assert.equal(resp.status, 502); + assert.match((await resp.json()).error, /hop limit/); + }); + + it('a peer answering bare 200 without ok:true is NOT a delivery', async () => { + const bare = createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{}'); + }); + servers.push(bare); + await new Promise((r) => bare.listen(0, '127.0.0.1', r)); + const sessions = { + agents: { mb: { adapter: 'forward', peer: `http://127.0.0.1:${bare.address().port}` } }, + }; + const res = await deliverToSession(sessions, 'mb', { text: 'x' }); + assert.equal(res.status, 502); + assert.match(res.error, /unexpected response 200/); + }); + + it('rejects an oversized or non-string from', async () => { + const sessions = { agents: { mm: { adapter: 'wake', script: '/bin/true' } } }; + assert.equal((await deliverToSession(sessions, 'mm', { text: 'x', from: 'f'.repeat(201) })).status, 400); + assert.equal((await deliverToSession(sessions, 'mm', { text: 'x', from: 42 })).status, 400); + }); + + it('reports async adapter failures through onAsyncError', async () => { + const failures = []; + const dir = tempDir(); + const bad = join(dir, 'fail.sh'); + writeFileSync(bad, '#!/bin/bash\nexit 1\n'); + chmodSync(bad, 0o755); + const sessions = { agents: { mm: { adapter: 'wake', script: bad } } }; + const res = await deliverToSession(sessions, 'mm', { + text: 'x', onAsyncError: (e) => failures.push(e.message), + }); + assert.equal(res.status, 202); // accepted-async by design + assert.ok(await waitFor(() => failures.length > 0), 'failure reported'); + assert.match(failures[0], /exited 1/); + }); +}); + +describe('sessions HTTP routes', () => { + it('POST /sessions/send delivers and GET /sessions/agents lists the picker', async () => { + const dir = tempDir(); + const { path, log } = stubScript(dir, 'wake.sh', 'wake.log'); + const { port } = await listenEphemeral({ + authToken: 'tok', + receiptsPath: join(dir, 'receipts.jsonl'), + sessions: { agents: { mm: { adapter: 'wake', script: path } } }, + }); + const headers = { Authorization: 'Bearer tok', 'Content-Type': 'application/json' }; + + const list = await fetch(`http://127.0.0.1:${port}/sessions/agents`, { headers }); + assert.deepEqual(await list.json(), { agents: [{ name: 'mm', adapter: 'wake' }] }); + + const send = await fetch(`http://127.0.0.1:${port}/sessions/send`, { + method: 'POST', headers, body: JSON.stringify({ agent: 'mm', text: 'via http' }), + }); + assert.equal(send.status, 202); + assert.deepEqual(await send.json(), { ok: true, agent: 'mm', deliveredVia: 'wake' }); + assert.ok(await waitFor(() => existsSync(log))); + // receipt written: + assert.ok(await waitFor(() => existsSync(join(dir, 'receipts.jsonl')))); + assert.match(readFileSync(join(dir, 'receipts.jsonl'), 'utf8'), /"kind":"sessions.send"/); + }); + + it('is 503 when sessions are not configured and 401 without the token', async () => { + const { port } = await listenEphemeral({ authToken: 'tok' }); + const noAuth = await fetch(`http://127.0.0.1:${port}/sessions/send`, { + method: 'POST', body: JSON.stringify({ agent: 'x', text: 'y' }), + }); + assert.equal(noAuth.status, 401); + const noCfg = await fetch(`http://127.0.0.1:${port}/sessions/send`, { + method: 'POST', + headers: { Authorization: 'Bearer tok', 'Content-Type': 'application/json' }, + body: JSON.stringify({ agent: 'x', text: 'y' }), + }); + assert.equal(noCfg.status, 503); + }); + + it('rejects oversized request bodies with 413', async () => { + const { port } = await listenEphemeral({ + sessions: { agents: { mm: { adapter: 'wake', script: '/bin/true' } } }, + }); + const resp = await fetch(`http://127.0.0.1:${port}/sessions/send`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agent: 'mm', text: 'x', padding: 'p'.repeat(100 * 1024) }), + }).catch(() => null); + // Depending on timing the server may destroy the socket mid-upload + // (fetch rejects) or answer 413 — both prove the cap. + if (resp) assert.equal(resp.status, 413); + }); +}); + +describe('listSessionAgents', () => { + it('reflects config and tolerates absence', () => { + assert.deepEqual(listSessionAgents(undefined), []); + assert.deepEqual(listSessionAgents({ agents: { a: { adapter: 'tmux' }, b: {} } }), [ + { name: 'a', adapter: 'tmux' }, + { name: 'b', adapter: 'unconfigured' }, + ]); + }); +});