diff --git a/.github/workflows/label-pr.yml b/.github/workflows/label-pr.yml index bec9d3e48..ebfe3f3ac 100644 --- a/.github/workflows/label-pr.yml +++ b/.github/workflows/label-pr.yml @@ -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: diff --git a/package.json b/package.json index 20afddb92..5454aa45c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/setup/verify.test.ts b/setup/verify.test.ts new file mode 100644 index 000000000..1e09acd51 --- /dev/null +++ b/setup/verify.test.ts @@ -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'); + }); +}); diff --git a/setup/verify.ts b/setup/verify.ts index 873af66ff..30a54084d 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -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, @@ -227,15 +227,15 @@ export async function run(_args: string[]): Promise { 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 { 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 diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index c8cf3cc41..18ab2cbf8 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -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 { + async function messageToInbound( + message: ChatMessage, + isMention: boolean, + isGroup?: boolean, + ): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any const serialized = message.toJSON() as Record; @@ -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.