Merge branch 'main' into skill/add-gmail-tool

This commit is contained in:
gavrielc
2026-04-24 16:41:59 +03:00
committed by GitHub
10 changed files with 160 additions and 29 deletions

View File

@@ -128,7 +128,7 @@ Codex also ships first-class local-runner flags — `codex --oss --local-provide
### Per group / per session ### Per group / per session
Schema: **`agent_groups.agent_provider`** and **`sessions.agent_provider`**. Set to `codex` for groups or sessions that should use Codex. The container receives `AGENT_PROVIDER` from the resolved value (session overrides group). Set `"provider": "codex"` in the group's **`container.json`** (`groups/<folder>/container.json`) the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution per-session `~/.codex` mount, `OPENAI_*` / `CODEX_MODEL` env passthrough and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session group `container.json` `'claude'`.
`CODEX_MODEL` applies process-wide via `.env`; if you need different models for different groups, set them via `container_config.env` on the group. `CODEX_MODEL` applies process-wide via `.env`; if you need different models for different groups, set them via `container_config.env` on the group.

View File

@@ -208,7 +208,7 @@ onecli secrets create --name "OpenCode Zen" --type generic \
### Per group / per session ### Per group / per session
Schema: **`agent_groups.agent_provider`** and **`sessions.agent_provider`**. Set to `opencode` for groups or sessions that should use OpenCode. The container receives `AGENT_PROVIDER` from the resolved value (session overrides group). Set `"provider": "opencode"` in the group's **`container.json`** (`groups/<folder>/container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session XDG mount, `OPENCODE_*` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json``'claude'`.
Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host; the runner merges them into the same `mcpServers` object passed to **both** Claude and OpenCode providers. Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host; the runner merges them into the same `mcpServers` object passed to **both** Claude and OpenCode providers.

View File

@@ -1,7 +1,12 @@
name: Label PR name: Label PR
# SECURITY: this workflow runs with write access to the base repo on fork PRs,
# because `pull_request_target` executes in the context of the base branch.
# Keep it metadata-only — do NOT add actions/checkout or any step that
# executes PR-supplied content (install scripts, build commands, etc.).
# See https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/
on: on:
pull_request: pull_request_target:
types: [opened, edited] types: [opened, edited]
jobs: jobs:

View File

@@ -1,6 +1,6 @@
{ {
"name": "nanoclaw", "name": "nanoclaw",
"version": "2.0.10", "version": "2.0.11",
"description": "Personal Claude assistant. Lightweight, secure, customizable.", "description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module", "type": "module",
"packageManager": "pnpm@10.33.0", "packageManager": "pnpm@10.33.0",

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { classifyPingResult } from './agent-ping.js';
describe('classifyPingResult', () => {
it('treats a normal text reply as ok', () => {
expect(classifyPingResult(0, 'pong\n')).toBe('ok');
});
it('detects Anthropic auth errors printed as a chat reply', () => {
expect(
classifyPingResult(
0,
'Failed to authenticate. API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid bearer token"}}',
),
).toBe('auth_error');
});
it('detects auth errors on stderr too', () => {
expect(classifyPingResult(1, '', 'Authentication error')).toBe('auth_error');
});
it('preserves socket errors', () => {
expect(classifyPingResult(2, '')).toBe('socket_error');
});
it('treats empty output as no reply', () => {
expect(classifyPingResult(0, '')).toBe('no_reply');
});
});

View File

@@ -13,7 +13,21 @@
*/ */
import { spawn } from 'child_process'; import { spawn } from 'child_process';
export type PingResult = 'ok' | 'no_reply' | 'socket_error'; export type PingResult = 'ok' | 'no_reply' | 'socket_error' | 'auth_error';
export function classifyPingResult(exitCode: number | null, stdout: string, stderr = ''): PingResult {
const output = `${stdout}\n${stderr}`;
if (
/Invalid bearer token/i.test(output) ||
/authentication[_ ]error/i.test(output) ||
/Failed to authenticate/i.test(output)
) {
return 'auth_error';
}
if (exitCode === 2) return 'socket_error';
if (exitCode === 0 && stdout.trim().length > 0) return 'ok';
return 'no_reply';
}
export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> { export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -21,6 +35,7 @@ export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
}); });
let stdout = ''; let stdout = '';
let stderr = '';
let settled = false; let settled = false;
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (settled) return; if (settled) return;
@@ -32,13 +47,14 @@ export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
child.stdout.on('data', (chunk: Buffer) => { child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf-8'); stdout += chunk.toString('utf-8');
}); });
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf-8');
});
child.on('close', (code) => { child.on('close', (code) => {
if (settled) return; if (settled) return;
settled = true; settled = true;
clearTimeout(timer); clearTimeout(timer);
if (code === 2) resolve('socket_error'); resolve(classifyPingResult(code, stdout, stderr));
else if (code === 0 && stdout.trim().length > 0) resolve('ok');
else resolve('no_reply');
}); });
child.on('error', () => { child.on('error', () => {
if (settled) return; if (settled) return;

View File

@@ -167,18 +167,16 @@ export async function run(args: string[]): Promise<void> {
if (!existing) { if (!existing) {
newlyWired = true; newlyWired = true;
const mgaId = generateId('mga'); const mgaId = generateId('mga');
const triggerRules = parsed.trigger const engageMode = parsed.trigger || !parsed.requiresTrigger ? 'pattern' : 'mention';
? JSON.stringify({ const engagePattern = parsed.trigger ? parsed.trigger : (!parsed.requiresTrigger ? '.' : null);
pattern: parsed.trigger,
requiresTrigger: parsed.requiresTrigger,
})
: null;
createMessagingGroupAgent({ createMessagingGroupAgent({
id: mgaId, id: mgaId,
messaging_group_id: messagingGroup.id, messaging_group_id: messagingGroup.id,
agent_group_id: agentGroup.id, agent_group_id: agentGroup.id,
trigger_rules: triggerRules, engage_mode: engageMode,
response_scope: 'all', engage_pattern: engagePattern,
sender_scope: 'all',
ignored_message_policy: 'drop',
session_mode: parsed.sessionMode, session_mode: parsed.sessionMode,
priority: 0, priority: 0,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),

55
setup/verify.test.ts Normal file
View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { determineVerifyStatus } from './verify.js';
const healthyBase = {
service: 'running' as const,
credentials: 'configured',
anyChannelConfigured: false,
registeredGroups: 1,
agentPing: 'ok' as const,
};
describe('determineVerifyStatus', () => {
it('accepts a working CLI-only install', () => {
expect(determineVerifyStatus(healthyBase)).toBe('success');
});
it('accepts a messaging-channel install when CLI ping is skipped', () => {
expect(
determineVerifyStatus({
...healthyBase,
anyChannelConfigured: true,
agentPing: 'skipped',
}),
).toBe('success');
});
it('fails when neither CLI nor messaging channels are usable', () => {
expect(
determineVerifyStatus({
...healthyBase,
agentPing: 'skipped',
}),
).toBe('failed');
});
it('fails when the CLI agent does not respond', () => {
expect(
determineVerifyStatus({
...healthyBase,
anyChannelConfigured: true,
agentPing: 'no_reply',
}),
).toBe('failed');
});
it('fails when no agent groups are registered', () => {
expect(
determineVerifyStatus({
...healthyBase,
registeredGroups: 0,
}),
).toBe('failed');
});
});

View File

@@ -14,7 +14,7 @@ import Database from 'better-sqlite3';
import { DATA_DIR } from '../src/config.js'; import { DATA_DIR } from '../src/config.js';
import { readEnvFile } from '../src/env.js'; import { readEnvFile } from '../src/env.js';
import { log } from '../src/log.js'; import { log } from '../src/log.js';
import { pingCliAgent } from './lib/agent-ping.js'; import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
import { import {
getPlatform, getPlatform,
@@ -220,22 +220,22 @@ export async function run(_args: string[]): Promise<void> {
// 7. End-to-end: ping the CLI agent and confirm it replies. Only run if // 7. End-to-end: ping the CLI agent and confirm it replies. Only run if
// everything upstream looks healthy, since a broken socket would just hang. // everything upstream looks healthy, since a broken socket would just hang.
let agentPing: 'ok' | 'no_reply' | 'socket_error' | 'skipped' = 'skipped'; let agentPing: 'ok' | 'no_reply' | 'socket_error' | 'auth_error' | 'skipped' = 'skipped';
if (service === 'running' && registeredGroups > 0) { if (service === 'running' && registeredGroups > 0) {
log.info('Pinging CLI agent'); log.info('Pinging CLI agent');
agentPing = await pingCliAgent(); agentPing = await pingCliAgent();
log.info('Agent ping result', { agentPing }); log.info('Agent ping result', { agentPing });
} }
// Determine overall status // Determine overall status. A CLI-only install is valid when the local
const status = // agent round-trip succeeds; messaging app credentials are optional.
service === 'running' && const status = determineVerifyStatus({
credentials !== 'missing' && service,
anyChannelConfigured && credentials,
registeredGroups > 0 && anyChannelConfigured,
(agentPing === 'ok' || agentPing === 'skipped') registeredGroups,
? 'success' agentPing,
: 'failed'; });
log.info('Verification complete', { status, channelAuth }); log.info('Verification complete', { status, channelAuth });
@@ -255,6 +255,25 @@ export async function run(_args: string[]): Promise<void> {
if (status === 'failed') process.exit(1); if (status === 'failed') process.exit(1);
} }
export function determineVerifyStatus(input: {
service: 'not_found' | 'stopped' | 'running' | 'running_other_checkout';
credentials: string;
anyChannelConfigured: boolean;
registeredGroups: number;
agentPing: PingResult | 'skipped';
}): 'success' | 'failed' {
const cliAgentResponds = input.agentPing === 'ok';
const hasUsableChannel = input.anyChannelConfigured || cliAgentResponds;
return input.service === 'running' &&
input.credentials !== 'missing' &&
hasUsableChannel &&
input.registeredGroups > 0 &&
(cliAgentResponds || input.agentPing === 'skipped')
? 'success'
: 'failed';
}
/** /**
* Given a PID, resolve the script path the process is executing (i.e. the * Given a PID, resolve the script path the process is executing (i.e. the
* first `.js` / `.ts` / `.mjs` arg after `node`). Returns null on any * first `.js` / `.ts` / `.mjs` arg after `node`). Returns null on any

View File

@@ -125,7 +125,11 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
let setupConfig: ChannelSetup; let setupConfig: ChannelSetup;
let gatewayAbort: AbortController | null = null; let gatewayAbort: AbortController | null = null;
async function messageToInbound(message: ChatMessage, isMention: boolean, isGroup?: boolean): Promise<InboundMessage> { async function messageToInbound(
message: ChatMessage,
isMention: boolean,
isGroup?: boolean,
): Promise<InboundMessage> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const serialized = message.toJSON() as Record<string, any>; const serialized = message.toJSON() as Record<string, any>;
@@ -216,7 +220,11 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
// wirings still fire on in-thread mentions. // wirings still fire on in-thread mentions.
chat.onSubscribedMessage(async (thread, message) => { chat.onSubscribedMessage(async (thread, message) => {
const channelId = adapter.channelIdFromThreadId(thread.id); const channelId = adapter.channelIdFromThreadId(thread.id);
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, message.isMention === true, true)); await setupConfig.onInbound(
channelId,
thread.id,
await messageToInbound(message, message.isMention === true, true),
);
}); });
// @mention in an unsubscribed thread — SDK-confirmed bot mention. // @mention in an unsubscribed thread — SDK-confirmed bot mention.