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
+1 -1
View File
@@ -128,7 +128,7 @@ Codex also ships first-class local-runner flags — `codex --oss --local-provide
### 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.
+1 -1
View File
@@ -208,7 +208,7 @@ onecli secrets create --name "OpenCode Zen" --type generic \
### 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.
+6 -1
View File
@@ -1,7 +1,12 @@
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:
pull_request:
pull_request_target:
types: [opened, edited]
jobs:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.0.10",
"version": "2.0.11",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+30
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');
});
});
+20 -4
View File
@@ -13,7 +13,21 @@
*/
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> {
return new Promise((resolve) => {
@@ -21,6 +35,7 @@ export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
@@ -32,13 +47,14 @@ export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf-8');
});
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf-8');
});
child.on('close', (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (code === 2) resolve('socket_error');
else if (code === 0 && stdout.trim().length > 0) resolve('ok');
else resolve('no_reply');
resolve(classifyPingResult(code, stdout, stderr));
});
child.on('error', () => {
if (settled) return;
+6 -8
View File
@@ -167,18 +167,16 @@ export async function run(args: string[]): Promise<void> {
if (!existing) {
newlyWired = true;
const mgaId = generateId('mga');
const triggerRules = parsed.trigger
? JSON.stringify({
pattern: parsed.trigger,
requiresTrigger: parsed.requiresTrigger,
})
: null;
const engageMode = parsed.trigger || !parsed.requiresTrigger ? 'pattern' : 'mention';
const engagePattern = parsed.trigger ? parsed.trigger : (!parsed.requiresTrigger ? '.' : null);
createMessagingGroupAgent({
id: mgaId,
messaging_group_id: messagingGroup.id,
agent_group_id: agentGroup.id,
trigger_rules: triggerRules,
response_scope: 'all',
engage_mode: engageMode,
engage_pattern: engagePattern,
sender_scope: 'all',
ignored_message_policy: 'drop',
session_mode: parsed.sessionMode,
priority: 0,
created_at: new Date().toISOString(),
+55
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');
});
});
+30 -11
View File
@@ -14,7 +14,7 @@ import Database from 'better-sqlite3';
import { DATA_DIR } from '../src/config.js';
import { readEnvFile } from '../src/env.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 {
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
// 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) {
log.info('Pinging CLI agent');
agentPing = await pingCliAgent();
log.info('Agent ping result', { agentPing });
}
// Determine overall status
const status =
service === 'running' &&
credentials !== 'missing' &&
anyChannelConfigured &&
registeredGroups > 0 &&
(agentPing === 'ok' || agentPing === 'skipped')
? 'success'
: 'failed';
// Determine overall status. A CLI-only install is valid when the local
// agent round-trip succeeds; messaging app credentials are optional.
const status = determineVerifyStatus({
service,
credentials,
anyChannelConfigured,
registeredGroups,
agentPing,
});
log.info('Verification complete', { status, channelAuth });
@@ -255,6 +255,25 @@ export async function run(_args: string[]): Promise<void> {
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
* first `.js` / `.ts` / `.mjs` arg after `node`). Returns null on any
+10 -2
View File
@@ -125,7 +125,11 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
let setupConfig: ChannelSetup;
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
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.
chat.onSubscribedMessage(async (thread, message) => {
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.