mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-12 18:11:51 +08:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6627d32e2 | |||
| 6227bd1a5b | |||
| 28032bc0ec | |||
| 3e3a2945a5 | |||
| f3fc18e56e | |||
| d85efea229 | |||
| c5b22cb308 | |||
| 1592369201 | |||
| aef8d38b36 | |||
| 6d6f813deb | |||
| f9c86d0af2 | |||
| 728c6a641b | |||
| 8385236c30 |
@@ -19,7 +19,7 @@ ARG INSTALL_CJK_FONTS=false
|
||||
# Pin CLI versions for reproducibility. Bump deliberately — unpinned installs
|
||||
# mean every rebuild silently picks up the latest and can break in lockstep
|
||||
# across all users.
|
||||
ARG CLAUDE_CODE_VERSION=2.1.154
|
||||
ARG CLAUDE_CODE_VERSION=2.1.170
|
||||
ARG AGENT_BROWSER_VERSION=latest
|
||||
ARG VERCEL_VERSION=52.2.1
|
||||
ARG BUN_VERSION=1.3.12
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"": {
|
||||
"name": "nanoclaw-agent-runner",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.154",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.170",
|
||||
"@anthropic-ai/sdk": "^0.100.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"cron-parser": "^5.0.0",
|
||||
@@ -19,23 +19,23 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.154", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.154", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.154" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-iEn25urI2QrMPFIhId3h7v/7EG5gsmF7ooe+6EvsAosePeLmpVVerp5nXtHnlmBkMinLecurcPA+OddKw76jYw=="],
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.170", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.170", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.170" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-pAvhfk+iTodXZ6RF18Kz7BEUWFjL7EcR3tKuhUNdPpE1NAYCR3mSHGbafi72JsrNwKEDIs7FU31z3fqhwy8QzA=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.154", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oFW3LD5lYrKAU+AKu27Z8hrzqkrh362qQrwi/i3DxGcud9BXUycsXYjShpDj3D3JZu169UzZuSPhx1Wajmbiwg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.170", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rwfgArIa5WI0QPNqFsRBgvtSI0mrtpynUm0oK6+l6/KX4hcgnYGEzciZR1bOeD9/7sSZlTdIgt+T9alKeZmXcg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.154", "", { "os": "darwin", "cpu": "x64" }, "sha512-5BgWEueP+cqoctWjZYhCbyltuaV/N2DmKDXD3/69cKaVmJp8XL9OCzlq/HEirA/+Ssjskx6hDUBaOcpuZ3iwQA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.170", "", { "os": "darwin", "cpu": "x64" }, "sha512-0e58h8UQMtsQxLGIv9r4foxfBFWKZ7NeDtoplLhuD7EwQonehomw1sBXCch77t/IfUS+q5vQ5zv+fOGmap5nLQ=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.154", "", { "os": "linux", "cpu": "arm64" }, "sha512-rRkW4SBL3W7zQvKscCIfIGlmoeuTbMV6dXFbPdmpRGvmYZIs79RpzO6xrGBnnhmm+B7znQ9oHAnffi/2FBgJbA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.170", "", { "os": "linux", "cpu": "arm64" }, "sha512-gLbaFqcGppFJQd4DLNV4IXoeahejT/p2/M8bSSvRDbla9GOsBr1AxV5XLRyBn1e7xFGozZIAIQr3+1chp7NJgQ=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.154", "", { "os": "linux", "cpu": "arm64" }, "sha512-o2bCQN4Xn3UqCLErC5m4T7u0yYArJYmgFCUFnA6K96DdW2RERvx+gTKXxWuHEBkDO+eMoHLHLxk0u2jGES00Ng=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.170", "", { "os": "linux", "cpu": "arm64" }, "sha512-SRYfQcsXlOq+CD/FqkQBTSHbaD++w73GnnO+NUV9adLYrca3kfetRwWT1iguY1cNS0l34dCR3rlzCPq78vg1Jg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.154", "", { "os": "linux", "cpu": "x64" }, "sha512-GpiFF8Ez6PbM3m0gqtCo/FKM346qyRdP7VhbmJzdnbNKTiiUZ66vDQyEUPZPCG24ZkrG4m96KpRIUwY08rHiNg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.170", "", { "os": "linux", "cpu": "x64" }, "sha512-Xl/m7TaSC3T5IDBdHrZQ9fCQYyDmPELN34CL+MoyPIf7uSmuZnjE9fUOqDh2Rv26JxWssi1M6X+BBvVuKd6Cpg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.154", "", { "os": "linux", "cpu": "x64" }, "sha512-zA7S8Lm6O4QBsUpbhiOht8BgiXHOBBFUIo8ZLK6r5wAatK3Q44syWVxICeyCnR6wqfnkf3cugCw27ycS6vVgaA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.170", "", { "os": "linux", "cpu": "x64" }, "sha512-m4+I0qBEk7cxRKS+pL+eoWXbXTFOAo83fQ0tQvap4z/mDMm06IWJtEPoYTaMBwsp32GJWLkHWKbZSBCHZnp2DQ=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.154", "", { "os": "win32", "cpu": "arm64" }, "sha512-cDW1YFbU/PJFlrGXhlAGcbkXt80sEO6WtnH8nN8YHXLn5NWduy2q7o/qC6i8XozgvRGf6t/eMoH7IasGIEDhDw=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.170", "", { "os": "win32", "cpu": "arm64" }, "sha512-IG+8isJNNJKbnnhO7m+PGhfVCg+XoQ/MDxGde5eigFI0WsEfitjuWSWwx82bT9ghxI1aa6qNvI+UPgPcZuo5Fg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.154", "", { "os": "win32", "cpu": "x64" }, "sha512-tSKaIIpL72OPg3WfzZTCIl8OJgcbq4qieu8/fDWjsdeQuari9gQMIuEflFphk9HqNsxpSmDqKi8Sm5mW2V566Q=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.170", "", { "os": "win32", "cpu": "x64" }, "sha512-7cuqSKbHVItPGVwRbd3A0BEJwcNtc7Fhoh6qHN4C6yrmjSrvdYYx3MLvq/VI768/RoG7mAMDxb+j7WfEfoP9BA=="],
|
||||
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.100.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-cAm3aXm6qAiHIvHxyIIGd6tVmsD2gDqlc2h0R20ijNUzGgVnIN822bit4mKbF6CkuV7qIrLQIPoAepHEpanrQQ=="],
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.154",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.170",
|
||||
"@anthropic-ai/sdk": "^0.100.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"cron-parser": "^5.0.0",
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
* send_message(to="agent-name") since agents and channels share the
|
||||
* unified destinations namespace.
|
||||
*
|
||||
* create_agent is admin-only. Non-admin containers never see this tool
|
||||
* (see mcp-tools/index.ts). The host re-checks permission on receive.
|
||||
* create_agent writes central-DB state. The host authorizes it by CLI scope:
|
||||
* trusted owner agent groups (scope 'global') create directly; confined groups
|
||||
* require admin approval (see src/modules/agent-to-agent/create-agent.ts). This
|
||||
* tool just writes the outbound request; authorization is enforced host-side,
|
||||
* not here — the container is untrusted and cannot be relied on to gate itself.
|
||||
*/
|
||||
import { writeMessageOut } from '../db/messages-out.js';
|
||||
import { registerTools } from './server.js';
|
||||
@@ -32,7 +35,7 @@ export const createAgent: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'create_agent',
|
||||
description:
|
||||
'Create a long-lived companion sub-agent (research assistant, task manager, specialist) — the name becomes your destination for it. Admin-only. Fire-and-forget.',
|
||||
'Create a long-lived companion sub-agent (research assistant, task manager, specialist) — the name becomes your destination for it. May require admin approval before the agent is created. Fire-and-forget.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
|
||||
@@ -53,6 +53,80 @@ Model selection considerations for Apple Silicon:
|
||||
|
||||
The agent uses tool calls extensively (read/write files, shell commands). Models that support tool use reliably work best. Gemma 4 and Qwen 3 Coder both handle structured tool calls well.
|
||||
|
||||
## Allowing Prompt Caching (filter the cache-busting hash)
|
||||
|
||||
Out of the box this path is slow — every reply re-reads the whole multi-thousand-token system prompt from scratch, even for a one-word answer. Ollama has a prompt cache that should skip that repeated work, but on this path it never kicks in.
|
||||
|
||||
**Cause.** The Claude Agent SDK adds a per-request hash to the front of every prompt — `x-anthropic-billing-header: ...; cch=<hash>;`. It changes on every request, and Ollama's cache only reuses a prompt whose start is unchanged. So that one shifting value at the front makes Ollama treat every prompt as new and re-read all of it. (Ollama ignores the hash itself, so filtering it has no effect on output.)
|
||||
|
||||
**Fix.** Run a tiny proxy between the container and Ollama that filters the hash out (pins `cch=<hash>` to a constant). The start of the prompt is now stable, so the cache kicks in and only the new message gets processed. In our setup — a 31B model on Apple Silicon — follow-up replies dropped from ~80s to ~4s; your numbers will vary with model size and hardware. Output is unchanged, since Ollama ignores the value anyway.
|
||||
|
||||
Point the agent group's `ANTHROPIC_BASE_URL` at the proxy instead of Ollama directly (everything else from the sections above is unchanged):
|
||||
|
||||
```
|
||||
ANTHROPIC_BASE_URL=http://host.docker.internal:11999 # the proxy
|
||||
# proxy forwards to http://127.0.0.1:11434 (Ollama)
|
||||
```
|
||||
|
||||
The proxy is ~40 lines of dependency-free Node:
|
||||
|
||||
```js
|
||||
// ollama-cch-proxy.mjs — normalize the SDK's per-request cch nonce so Ollama's
|
||||
// prefix cache survives across turns. Listens on :11999, forwards to Ollama.
|
||||
import http from 'node:http';
|
||||
|
||||
const TARGET_HOST = process.env.OLLAMA_HOST || '127.0.0.1';
|
||||
const TARGET_PORT = Number(process.env.OLLAMA_PORT || 11434);
|
||||
const LISTEN_PORT = Number(process.env.PROXY_PORT || 11999);
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const chunks = [];
|
||||
req.on('data', (c) => chunks.push(c));
|
||||
req.on('end', () => {
|
||||
let body = Buffer.concat(chunks);
|
||||
if (req.method === 'POST' && body.length) {
|
||||
body = Buffer.from(body.toString('utf8').replace(/cch=[0-9a-f]+;/g, 'cch=00000;'), 'utf8');
|
||||
}
|
||||
const headers = { ...req.headers, host: `${TARGET_HOST}:${TARGET_PORT}`, 'content-length': String(body.length) };
|
||||
const proxyReq = http.request(
|
||||
{ host: TARGET_HOST, port: TARGET_PORT, method: req.method, path: req.url, headers },
|
||||
(proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
|
||||
proxyRes.pipe(res);
|
||||
},
|
||||
);
|
||||
proxyReq.on('error', (e) => { res.writeHead(502); res.end(String(e)); });
|
||||
proxyReq.end(body);
|
||||
});
|
||||
});
|
||||
server.listen(LISTEN_PORT, '0.0.0.0', () => console.log(`cch-proxy :${LISTEN_PORT} -> ${TARGET_HOST}:${TARGET_PORT}`));
|
||||
```
|
||||
|
||||
Run it durably so it survives reboots. On Linux, a systemd user service:
|
||||
|
||||
```ini
|
||||
# ~/.config/systemd/user/ollama-cch-proxy.service
|
||||
[Unit]
|
||||
Description=Ollama cch-normalizing proxy for NanoClaw
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/node %h/.config/nanoclaw/ollama-cch-proxy.mjs
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
```bash
|
||||
systemctl --user enable --now ollama-cch-proxy
|
||||
loginctl enable-linger "$USER" # so it runs without an active login session
|
||||
```
|
||||
|
||||
On macOS use a `launchd` user agent (`~/Library/LaunchAgents/`) running the same script.
|
||||
|
||||
**Scope.** This only affects the Claude-Code-CLI → Ollama path described here. Codex and OpenCode don't use the Claude Agent SDK, so they never emit the `cch` hash and get prompt caching for free.
|
||||
|
||||
## What Changes at the Code Level
|
||||
|
||||
Three files need to support this feature. See `/add-ollama-provider` for the exact changes.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.1.0",
|
||||
"version": "2.1.2",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="182k tokens, 91% of context window">
|
||||
<title>182k tokens, 91% of context window</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="183k tokens, 92% of context window">
|
||||
<title>183k tokens, 92% of context window</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,8 +15,8 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||
<text x="26" y="14">tokens</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">182k</text>
|
||||
<text x="71" y="14">182k</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">183k</text>
|
||||
<text x="71" y="14">183k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -443,4 +443,28 @@ describe('routeAgentMessage return-path', () => {
|
||||
expect(fs.existsSync(targetPath)).toBe(true);
|
||||
expect(fs.readFileSync(targetPath, 'utf-8')).toBe('fake-pdf-bytes');
|
||||
});
|
||||
|
||||
it('file forwarding: skips symlinked source files', async () => {
|
||||
const secretPath = path.join(TEST_DIR, 'host-secret.txt');
|
||||
fs.writeFileSync(secretPath, 'host-secret-bytes');
|
||||
|
||||
const outboxDir = path.join(sessionDir(A, S1.id), 'outbox', 'msg-with-symlink');
|
||||
fs.mkdirSync(outboxDir, { recursive: true });
|
||||
fs.symlinkSync(secretPath, path.join(outboxDir, 'safe-name.txt'));
|
||||
|
||||
await routeAgentMessage(
|
||||
{
|
||||
id: 'msg-with-symlink',
|
||||
platform_id: B,
|
||||
content: JSON.stringify({ text: 'see attached', files: ['safe-name.txt'] }),
|
||||
in_reply_to: null,
|
||||
},
|
||||
S1,
|
||||
);
|
||||
|
||||
const bRows = readInbound(B, SB.id);
|
||||
expect(bRows).toHaveLength(1);
|
||||
const parsed = JSON.parse(bRows[0].content);
|
||||
expect(parsed.attachments).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,11 @@ export interface ForwardedAttachment {
|
||||
localPath: string;
|
||||
}
|
||||
|
||||
function isPathInside(parent: string, child: string): boolean {
|
||||
const relative = path.relative(parent, child);
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy file attachments from the source agent's outbox into the target
|
||||
* agent's inbox. Returns attachments using the formatter's existing
|
||||
@@ -57,6 +62,11 @@ export function forwardAttachedFiles(
|
||||
): ForwardedAttachment[] {
|
||||
if (source.filenames.length === 0) return [];
|
||||
|
||||
if (!isSafeAttachmentName(source.messageId)) {
|
||||
log.warn('agent-route: rejecting unsafe source outbox message id', { sourceMsgId: source.messageId });
|
||||
return [];
|
||||
}
|
||||
|
||||
const sourceDir = path.join(sessionDir(source.agentGroupId, source.sessionId), 'outbox', source.messageId);
|
||||
if (!fs.existsSync(sourceDir)) {
|
||||
log.warn('agent-route: source outbox dir missing, no files forwarded', {
|
||||
@@ -66,6 +76,26 @@ export function forwardAttachedFiles(
|
||||
return [];
|
||||
}
|
||||
|
||||
let realSourceDir: string;
|
||||
try {
|
||||
const sourceDirStat = fs.lstatSync(sourceDir);
|
||||
if (!sourceDirStat.isDirectory() || sourceDirStat.isSymbolicLink()) {
|
||||
log.warn('agent-route: rejecting unsafe source outbox dir', {
|
||||
sourceMsgId: source.messageId,
|
||||
sourceDir,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
realSourceDir = fs.realpathSync(sourceDir);
|
||||
} catch (err) {
|
||||
log.warn('agent-route: failed to inspect source outbox dir', {
|
||||
sourceMsgId: source.messageId,
|
||||
sourceDir,
|
||||
err,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
const targetInboxDir = path.join(sessionDir(target.agentGroupId, target.sessionId), 'inbox', target.messageId);
|
||||
fs.mkdirSync(targetInboxDir, { recursive: true });
|
||||
|
||||
@@ -79,15 +109,33 @@ export function forwardAttachedFiles(
|
||||
continue;
|
||||
}
|
||||
const src = path.join(sourceDir, filename);
|
||||
if (!fs.existsSync(src)) {
|
||||
let realSrc: string;
|
||||
try {
|
||||
const srcStat = fs.lstatSync(src);
|
||||
if (!srcStat.isFile() || srcStat.isSymbolicLink()) {
|
||||
log.warn('agent-route: rejecting unsafe source outbox file', {
|
||||
sourceMsgId: source.messageId,
|
||||
filename,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
realSrc = fs.realpathSync(src);
|
||||
} catch {
|
||||
log.warn('agent-route: referenced file missing in source outbox, skipped', {
|
||||
sourceMsgId: source.messageId,
|
||||
filename,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!isPathInside(realSourceDir, realSrc)) {
|
||||
log.warn('agent-route: rejecting source file outside source outbox dir', {
|
||||
sourceMsgId: source.messageId,
|
||||
filename,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const dst = path.join(targetInboxDir, filename);
|
||||
fs.copyFileSync(src, dst);
|
||||
fs.copyFileSync(realSrc, dst);
|
||||
attachments.push({
|
||||
name: filename,
|
||||
filename,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Tests for create_agent host-side authorization.
|
||||
*
|
||||
* Regression guard for the audit finding: `create_agent` is a privileged
|
||||
* central-DB write with no host-side authz. The fix authorizes by CLI scope —
|
||||
* trusted owner agent groups ('global') create directly; confined groups
|
||||
* ('group', the default and the prompt-injection victim) must get admin
|
||||
* approval. These tests pin that branch decision.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Session } from '../../types.js';
|
||||
|
||||
// Mocks for the collaborators the branch decides between / depends on.
|
||||
const mockRequestApproval = vi.fn().mockResolvedValue(undefined);
|
||||
const mockGetContainerConfig = vi.fn();
|
||||
const mockCreateAgentGroup = vi.fn();
|
||||
const mockInitGroupFilesystem = vi.fn();
|
||||
const mockWriteDestinations = vi.fn();
|
||||
const mockNotifyWrite = vi.fn();
|
||||
|
||||
vi.mock('../approvals/index.js', () => ({
|
||||
requestApproval: (...a: unknown[]) => mockRequestApproval(...a),
|
||||
}));
|
||||
vi.mock('../../db/container-configs.js', () => ({
|
||||
getContainerConfig: (...a: unknown[]) => mockGetContainerConfig(...a),
|
||||
}));
|
||||
vi.mock('../../db/agent-groups.js', () => ({
|
||||
getAgentGroup: (id: string) => ({ id, name: id.toUpperCase(), folder: id, agent_provider: null, created_at: '' }),
|
||||
getAgentGroupByFolder: () => undefined,
|
||||
createAgentGroup: (...a: unknown[]) => mockCreateAgentGroup(...a),
|
||||
}));
|
||||
vi.mock('../../group-init.js', () => ({
|
||||
initGroupFilesystem: (...a: unknown[]) => mockInitGroupFilesystem(...a),
|
||||
}));
|
||||
vi.mock('./write-destinations.js', () => ({
|
||||
writeDestinations: (...a: unknown[]) => mockWriteDestinations(...a),
|
||||
}));
|
||||
vi.mock('./db/agent-destinations.js', () => ({
|
||||
getDestinationByName: () => undefined,
|
||||
createDestination: vi.fn(),
|
||||
normalizeName: (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
||||
}));
|
||||
// notifyAgent writes to the session inbound.db + wakes the container; stub both.
|
||||
vi.mock('../../session-manager.js', () => ({
|
||||
writeSessionMessage: (...a: unknown[]) => mockNotifyWrite(...a),
|
||||
}));
|
||||
vi.mock('../../container-runner.js', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock('../../db/sessions.js', () => ({
|
||||
getSession: (id: string) => ({ id, agent_group_id: 'ag-1' }),
|
||||
}));
|
||||
|
||||
import { handleCreateAgent } from './create-agent.js';
|
||||
|
||||
const SESSION = { id: 'sess-1', agent_group_id: 'ag-1' } as Session;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('handleCreateAgent — scope-based authorization', () => {
|
||||
it('global scope: creates directly, no approval requested', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
|
||||
|
||||
await handleCreateAgent({ name: 'Scout', instructions: 'help' }, SESSION);
|
||||
|
||||
expect(mockRequestApproval).not.toHaveBeenCalled();
|
||||
expect(mockCreateAgentGroup).toHaveBeenCalledTimes(1);
|
||||
expect(mockInitGroupFilesystem).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('group scope (default): requires approval, does NOT create directly', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
await handleCreateAgent({ name: 'Scout', instructions: 'help' }, SESSION);
|
||||
|
||||
expect(mockRequestApproval).toHaveBeenCalledTimes(1);
|
||||
expect(mockRequestApproval.mock.calls[0][0]).toMatchObject({ action: 'create_agent' });
|
||||
expect(mockCreateAgentGroup).not.toHaveBeenCalled();
|
||||
expect(mockInitGroupFilesystem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('missing config: fails closed to approval (no direct create)', async () => {
|
||||
mockGetContainerConfig.mockReturnValue(undefined);
|
||||
|
||||
await handleCreateAgent({ name: 'Scout' }, SESSION);
|
||||
|
||||
expect(mockRequestApproval).toHaveBeenCalledTimes(1);
|
||||
expect(mockCreateAgentGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disabled/other scope: requires approval', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'disabled' });
|
||||
|
||||
await handleCreateAgent({ name: 'Scout' }, SESSION);
|
||||
|
||||
expect(mockRequestApproval).toHaveBeenCalledTimes(1);
|
||||
expect(mockCreateAgentGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('empty name: neither creates nor requests approval', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
|
||||
|
||||
await handleCreateAgent({ name: '' }, SESSION);
|
||||
|
||||
expect(mockRequestApproval).not.toHaveBeenCalled();
|
||||
expect(mockCreateAgentGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,29 @@
|
||||
/**
|
||||
* `create_agent` delivery-action handler.
|
||||
*
|
||||
* Spawns a new agent group on demand from the parent agent, wires bidirectional
|
||||
* agent_destinations rows, projects the new destination into the parent's
|
||||
* running container, and notifies the parent.
|
||||
* SECURITY: `create_agent` writes to the CENTRAL DB (agent_groups,
|
||||
* container_configs, agent_destinations) and scaffolds host filesystem state —
|
||||
* a privileged operation a confined container is otherwise architecturally
|
||||
* barred from. The container's MCP tool gate is inside the (untrusted)
|
||||
* container and is trivially bypassed by writing the outbound system row
|
||||
* directly, so authorization MUST be enforced host-side. Trusted owner agent
|
||||
* groups (CLI scope 'global') create directly; every other (confined) group
|
||||
* requires admin approval via `requestApproval` — matching `ncl groups create`
|
||||
* (access: 'approval') and the self-mod actions. `applyCreateAgent` runs the
|
||||
* creation on approve; `performCreateAgent` is the shared body.
|
||||
*/
|
||||
import path from 'path';
|
||||
|
||||
import { GROUPS_DIR } from '../../config.js';
|
||||
import { createAgentGroup, getAgentGroup, getAgentGroupByFolder } from '../../db/agent-groups.js';
|
||||
import { getContainerConfig } from '../../db/container-configs.js';
|
||||
import { getSession } from '../../db/sessions.js';
|
||||
import { wakeContainer } from '../../container-runner.js';
|
||||
import { initGroupFilesystem } from '../../group-init.js';
|
||||
import { log } from '../../log.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
import type { AgentGroup, Session } from '../../types.js';
|
||||
import { requestApproval, type ApprovalHandler } from '../approvals/index.js';
|
||||
import { createDestination, getDestinationByName, normalizeName } from './db/agent-destinations.js';
|
||||
import { writeDestinations } from './write-destinations.js';
|
||||
|
||||
@@ -34,23 +43,95 @@ function notifyAgent(session: Session, text: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivery-action entry.
|
||||
*
|
||||
* Authorization depends on the calling group's CLI scope:
|
||||
* - `global` (set by init-first-agent for trusted owner agent groups):
|
||||
* create immediately. create_agent is the intended primitive for these
|
||||
* privileged agents, and an approval tap on every sub-agent spawn would be
|
||||
* needless friction.
|
||||
* - anything else (the default `group` scope — the realistic
|
||||
* prompt-injection victim): require an admin to approve before any
|
||||
* central-DB write. `applyCreateAgent` runs on approve.
|
||||
* Unknown/missing config fails closed to the approval path.
|
||||
*/
|
||||
export async function handleCreateAgent(content: Record<string, unknown>, session: Session): Promise<void> {
|
||||
const requestId = content.requestId as string;
|
||||
const name = content.name as string;
|
||||
const instructions = content.instructions as string | null;
|
||||
const name = typeof content.name === 'string' ? content.name : '';
|
||||
const instructions = typeof content.instructions === 'string' ? content.instructions : null;
|
||||
|
||||
if (!name) {
|
||||
notifyAgent(session, 'create_agent failed: name is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!sourceGroup) {
|
||||
notifyAgent(session, `create_agent failed: source agent group not found.`);
|
||||
notifyAgent(session, 'create_agent failed: source agent group not found.');
|
||||
log.warn('create_agent failed: missing source group', { sessionAgentGroup: session.agent_group_id, name });
|
||||
return;
|
||||
}
|
||||
|
||||
const cliScope = getContainerConfig(session.agent_group_id)?.cli_scope ?? 'group';
|
||||
if (cliScope === 'global') {
|
||||
// Trusted owner agent group — create directly, then notify (+wake) it.
|
||||
await performCreateAgent(name, instructions, session, sourceGroup, (text) => notifyAgent(session, text));
|
||||
return;
|
||||
}
|
||||
|
||||
await requestApproval({
|
||||
session,
|
||||
agentName: sourceGroup.name,
|
||||
action: 'create_agent',
|
||||
payload: { name, instructions },
|
||||
title: `Create agent: ${name}`,
|
||||
question: `Agent "${sourceGroup.name}" wants to create a new sub-agent "${name}" (a new agent group with its own workspace and container). Approve?`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Approval handler: performs the creation once an admin approves a request from
|
||||
* a confined (non-global) agent group. `session` is the requesting parent.
|
||||
*/
|
||||
export const applyCreateAgent: ApprovalHandler = async ({ session, payload, notify }) => {
|
||||
const name = typeof payload.name === 'string' ? payload.name : '';
|
||||
const instructions = typeof payload.instructions === 'string' ? payload.instructions : null;
|
||||
|
||||
if (!name) {
|
||||
notify('create_agent approved but the request had no name.');
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!sourceGroup) {
|
||||
notify('create_agent approved but the source agent group no longer exists.');
|
||||
log.warn('create_agent apply failed: missing source group', { sessionAgentGroup: session.agent_group_id, name });
|
||||
return;
|
||||
}
|
||||
|
||||
await performCreateAgent(name, instructions, session, sourceGroup, notify);
|
||||
};
|
||||
|
||||
/**
|
||||
* Core creation: writes the new agent group + bidirectional destinations and
|
||||
* scaffolds its filesystem, then reports via `notify`. Authorization is the
|
||||
* CALLER's responsibility (the global-scope shortcut in handleCreateAgent or
|
||||
* admin approval via applyCreateAgent) — never call this from an unauthorized
|
||||
* path, as it performs privileged central-DB writes a confined container is
|
||||
* otherwise barred from.
|
||||
*/
|
||||
async function performCreateAgent(
|
||||
name: string,
|
||||
instructions: string | null,
|
||||
session: Session,
|
||||
sourceGroup: AgentGroup,
|
||||
notify: (text: string) => void,
|
||||
): Promise<void> {
|
||||
const localName = normalizeName(name);
|
||||
|
||||
// Collision in the creator's destination namespace
|
||||
if (getDestinationByName(sourceGroup.id, localName)) {
|
||||
notifyAgent(session, `Cannot create agent "${name}": you already have a destination named "${localName}".`);
|
||||
notify(`Cannot create agent "${name}": you already have a destination named "${localName}".`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -66,7 +147,7 @@ export async function handleCreateAgent(content: Record<string, unknown>, sessio
|
||||
const resolvedPath = path.resolve(groupPath);
|
||||
const resolvedGroupsDir = path.resolve(GROUPS_DIR);
|
||||
if (!resolvedPath.startsWith(resolvedGroupsDir + path.sep)) {
|
||||
notifyAgent(session, `Cannot create agent "${name}": invalid folder path.`);
|
||||
notify(`Cannot create agent "${name}": invalid folder path.`);
|
||||
log.error('create_agent path traversal attempt', { folder, resolvedPath });
|
||||
return;
|
||||
}
|
||||
@@ -115,12 +196,6 @@ export async function handleCreateAgent(content: Record<string, unknown>, sessio
|
||||
// tries to send to the newly-created child.
|
||||
writeDestinations(session.agent_group_id, session.id);
|
||||
|
||||
// Fire-and-forget notification back to the creator
|
||||
notifyAgent(
|
||||
session,
|
||||
`Agent "${localName}" created. You can now message it with <message to="${localName}">...</message>.`,
|
||||
);
|
||||
notify(`Agent "${localName}" created. You can now message it with <message to="${localName}">...</message>.`);
|
||||
log.info('Agent group created', { agentGroupId, name, localName, folder, parent: sourceGroup.id });
|
||||
// Note: requestId is unused — this is fire-and-forget, not request/response.
|
||||
void requestId;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
/**
|
||||
* Agent-to-agent module — inter-agent messaging and on-demand agent creation.
|
||||
*
|
||||
* Registers one delivery action (`create_agent`). The sibling `channel_type === 'agent'`
|
||||
* routing path is NOT a system action — core `delivery.ts` dispatches into
|
||||
* `./agent-route.js` via a dynamic import when it sees `msg.channel_type === 'agent'`.
|
||||
* Registers one delivery action (`create_agent`) plus its matching approval
|
||||
* handler — `create_agent` writes central-DB state, so confined (non-global)
|
||||
* groups require admin approval (the delivery action queues the request;
|
||||
* `applyCreateAgent` runs on approve); trusted global-scope groups create
|
||||
* directly. The sibling `channel_type === 'agent'` routing path is NOT a system
|
||||
* action — core `delivery.ts` dispatches into `./agent-route.js` via a dynamic
|
||||
* import when it sees `msg.channel_type === 'agent'`.
|
||||
*
|
||||
* Host integration points:
|
||||
* - `src/container-runner.ts::spawnContainer` dynamically imports
|
||||
@@ -17,6 +21,8 @@
|
||||
* throw because the module isn't installed.
|
||||
*/
|
||||
import { registerDeliveryAction } from '../../delivery.js';
|
||||
import { handleCreateAgent } from './create-agent.js';
|
||||
import { registerApprovalHandler } from '../approvals/index.js';
|
||||
import { applyCreateAgent, handleCreateAgent } from './create-agent.js';
|
||||
|
||||
registerDeliveryAction('create_agent', handleCreateAgent);
|
||||
registerApprovalHandler('create_agent', applyCreateAgent);
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Regression coverage for approval response authorization.
|
||||
*
|
||||
* Approval cards may be delivered to an admin DM, but the callback payload is
|
||||
* still untrusted input. The response handler must not dispatch sensitive
|
||||
* approval handlers merely because a response carries a valid questionId.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { initTestDb, closeDb, runMigrations } from '../../db/index.js';
|
||||
import { createAgentGroup } from '../../db/agent-groups.js';
|
||||
import { createSession, createPendingApproval, getPendingApproval } from '../../db/sessions.js';
|
||||
import { upsertUser } from '../permissions/db/users.js';
|
||||
import { grantRole } from '../permissions/db/user-roles.js';
|
||||
|
||||
vi.mock('../../container-runner.js', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual('../../config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-approval-response-authz' };
|
||||
});
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-approval-response-authz';
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
|
||||
createAgentGroup({ id: 'ag-1', name: 'Agent', folder: 'agent', agent_provider: null, created_at: now() });
|
||||
createSession({
|
||||
id: 'sess-1',
|
||||
agent_group_id: 'ag-1',
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: now(),
|
||||
created_at: now(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('approval response authorization', () => {
|
||||
it('ignores a valid approval id clicked by a non-admin user', async () => {
|
||||
const { registerApprovalHandler } = await import('./primitive.js');
|
||||
const { handleApprovalsResponse } = await import('./response-handler.js');
|
||||
const handler = vi.fn().mockResolvedValue(undefined);
|
||||
registerApprovalHandler('install_packages', handler);
|
||||
|
||||
createPendingApproval({
|
||||
approval_id: 'appr-1',
|
||||
session_id: 'sess-1',
|
||||
request_id: 'appr-1',
|
||||
action: 'install_packages',
|
||||
payload: JSON.stringify({ packages: ['left-pad'] }),
|
||||
created_at: now(),
|
||||
title: 'Install packages',
|
||||
options_json: JSON.stringify([]),
|
||||
});
|
||||
|
||||
const claimed = await handleApprovalsResponse({
|
||||
questionId: 'appr-1',
|
||||
value: 'approve',
|
||||
userId: 'stranger',
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-stranger',
|
||||
threadId: null,
|
||||
});
|
||||
|
||||
expect(claimed).toBe(true);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(getPendingApproval('appr-1')).toBeDefined();
|
||||
});
|
||||
|
||||
it('allows an owner/admin click to dispatch the registered approval handler', async () => {
|
||||
upsertUser({ id: 'telegram:owner', kind: 'telegram', display_name: 'Owner', created_at: now() });
|
||||
grantRole({ user_id: 'telegram:owner', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() });
|
||||
|
||||
const { registerApprovalHandler } = await import('./primitive.js');
|
||||
const { handleApprovalsResponse } = await import('./response-handler.js');
|
||||
const handler = vi.fn().mockResolvedValue(undefined);
|
||||
registerApprovalHandler('install_packages_allowed', handler);
|
||||
|
||||
createPendingApproval({
|
||||
approval_id: 'appr-2',
|
||||
session_id: 'sess-1',
|
||||
request_id: 'appr-2',
|
||||
action: 'install_packages_allowed',
|
||||
payload: JSON.stringify({ packages: ['left-pad'] }),
|
||||
created_at: now(),
|
||||
title: 'Install packages',
|
||||
options_json: JSON.stringify([]),
|
||||
});
|
||||
|
||||
const claimed = await handleApprovalsResponse({
|
||||
questionId: 'appr-2',
|
||||
value: 'approve',
|
||||
userId: 'owner',
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-owner',
|
||||
threadId: null,
|
||||
});
|
||||
|
||||
expect(claimed).toBe(true);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ userId: 'telegram:owner' }));
|
||||
expect(getPendingApproval('appr-2')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows global admins to resolve approvals without a session-scoped agent group', async () => {
|
||||
upsertUser({ id: 'telegram:global-admin', kind: 'telegram', display_name: 'Global Admin', created_at: now() });
|
||||
grantRole({
|
||||
user_id: 'telegram:global-admin',
|
||||
role: 'admin',
|
||||
agent_group_id: null,
|
||||
granted_by: null,
|
||||
granted_at: now(),
|
||||
});
|
||||
|
||||
const { registerApprovalHandler } = await import('./primitive.js');
|
||||
const { handleApprovalsResponse } = await import('./response-handler.js');
|
||||
const handler = vi.fn().mockResolvedValue(undefined);
|
||||
registerApprovalHandler('global_admin_allowed', handler);
|
||||
|
||||
createPendingApproval({
|
||||
approval_id: 'appr-3',
|
||||
session_id: 'sess-1',
|
||||
agent_group_id: null,
|
||||
request_id: 'appr-3',
|
||||
action: 'global_admin_allowed',
|
||||
payload: JSON.stringify({ packages: ['left-pad'] }),
|
||||
created_at: now(),
|
||||
title: 'Install packages',
|
||||
options_json: JSON.stringify([]),
|
||||
});
|
||||
|
||||
const claimed = await handleApprovalsResponse({
|
||||
questionId: 'appr-3',
|
||||
value: 'approve',
|
||||
userId: 'global-admin',
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-global-admin',
|
||||
threadId: null,
|
||||
});
|
||||
|
||||
expect(claimed).toBe(true);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(getPendingApproval('appr-3')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -18,27 +18,35 @@ import type { ResponsePayload } from '../../response-registry.js';
|
||||
import { log } from '../../log.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
import type { PendingApproval } from '../../types.js';
|
||||
import { hasAdminPrivilege, isGlobalAdmin, isOwner } from '../permissions/db/user-roles.js';
|
||||
import { ONECLI_ACTION, resolveOneCLIApproval } from './onecli-approvals.js';
|
||||
import { getApprovalHandler } from './primitive.js';
|
||||
|
||||
export async function handleApprovalsResponse(payload: ResponsePayload): Promise<boolean> {
|
||||
// OneCLI credential approvals — resolved via in-memory Promise first.
|
||||
if (resolveOneCLIApproval(payload.questionId, payload.value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// DB-backed pending_approvals.
|
||||
const approval = getPendingApproval(payload.questionId);
|
||||
if (!approval) return false;
|
||||
|
||||
if (!isAuthorizedApprovalClick(approval, payload)) {
|
||||
log.warn('Ignoring unauthorized approval response', {
|
||||
approvalId: approval.approval_id,
|
||||
action: approval.action,
|
||||
userId: payload.userId,
|
||||
channelType: payload.channelType,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (approval.action === ONECLI_ACTION) {
|
||||
if (resolveOneCLIApproval(payload.questionId, payload.value)) {
|
||||
return true;
|
||||
}
|
||||
// Row exists but the in-memory resolver is gone (timer fired or the process
|
||||
// was in a weird state). Nothing to do — just drop the row.
|
||||
deletePendingApproval(payload.questionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
await handleRegisteredApproval(approval, payload.value, payload.userId ?? '');
|
||||
await handleRegisteredApproval(approval, payload.value, namespacedUserId(payload) ?? '');
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -104,3 +112,22 @@ async function handleRegisteredApproval(
|
||||
deletePendingApproval(approval.approval_id);
|
||||
await wakeContainer(session);
|
||||
}
|
||||
|
||||
function namespacedUserId(payload: ResponsePayload): string | null {
|
||||
if (!payload.userId) return null;
|
||||
return payload.userId.includes(':') ? payload.userId : `${payload.channelType}:${payload.userId}`;
|
||||
}
|
||||
|
||||
function isAuthorizedApprovalClick(approval: PendingApproval, payload: ResponsePayload): boolean {
|
||||
const userId = namespacedUserId(payload);
|
||||
if (!userId) return false;
|
||||
|
||||
const agentGroupId =
|
||||
approval.agent_group_id ?? (approval.session_id ? getSession(approval.session_id)?.agent_group_id : null);
|
||||
|
||||
if (!agentGroupId) {
|
||||
return isOwner(userId) || isGlobalAdmin(userId);
|
||||
}
|
||||
|
||||
return hasAdminPrivilege(userId, agentGroupId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user