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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions bin/iak-mcp-daemon.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'})`);

Expand Down
67 changes: 67 additions & 0 deletions src/confirmations.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---------------------------------------------------------------

Expand Down Expand Up @@ -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'}`);
Expand Down Expand Up @@ -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/<handle> body { role, text, ts, session_id, tool_calls? }
// GET /ide-chat/<handle>?since=<iso> → { events: [...] }
//
Expand Down
1 change: 1 addition & 0 deletions src/mcp-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
181 changes: 181 additions & 0 deletions src/session-send.mjs
Original file line number Diff line number Diff line change
@@ -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' });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle spawn errors before returning 202

When a wake agent (or gui without idle_guard) has a stale or non-executable script path, spawn() emits its failure asynchronously as a child error event; this catch does not run and no error listener is attached, so Node treats it as an unhandled error and terminates the daemon after the endpoint has already returned 202. Attach an error handler or validate the script before accepting the delivery.

Useful? React with 👍 / 👎.

}
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear the forward timeout on failures

When a forward peer is down or the request aborts, fetch() throws before the later clearTimeout(timer) runs, leaving this referenced timer alive for the full 8 seconds; short-lived callers and tests that hit an unreachable peer hang until that timeout per failed attempt, and repeated failures accumulate pending timers. Clear it in a finally block or unref the timer.

Useful? React with 👍 / 👎.

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}`));
});
});
}
Loading
Loading