From e98d4f6e3d5531a00c11b82a5a2dfed83c5671f6 Mon Sep 17 00:00:00 2001 From: Andrew Thal <467872+athal7@users.noreply.github.com> Date: Thu, 1 Jan 2026 17:22:05 -0600 Subject: [PATCH 1/9] feat(#13): add standalone callback service with IPC - HTTP callback server for ntfy action buttons - Unix socket IPC for plugin communication - Nonce management for permission requests - Session registration and response forwarding --- service/server.js | 379 +++++++++++++++++++++++++++++++++++++++++ test/test_service.bash | 274 +++++++++++++++++++++++++++++ 2 files changed, 653 insertions(+) create mode 100644 service/server.js create mode 100644 test/test_service.bash diff --git a/service/server.js b/service/server.js new file mode 100644 index 0000000..c12656f --- /dev/null +++ b/service/server.js @@ -0,0 +1,379 @@ +// Standalone callback server for opencode-ntfy +// Implements Issue #13: Separate callback server as brew service +// +// This service runs persistently via brew services and handles: +// - HTTP callbacks from ntfy action buttons +// - Unix socket IPC for plugin communication +// - Nonce management for permission requests +// - Session registration and response forwarding + +import { createServer as createHttpServer } from 'http' +import { createServer as createNetServer } from 'net' +import { randomUUID } from 'crypto' +import { existsSync, unlinkSync } from 'fs' + +// Default configuration +const DEFAULT_HTTP_PORT = 4097 +const DEFAULT_SOCKET_PATH = '/tmp/opencode-ntfy.sock' + +// Nonce storage: nonce -> { sessionId, permissionId, createdAt } +const nonces = new Map() +const NONCE_TTL_MS = 60 * 60 * 1000 // 1 hour + +// Session storage: sessionId -> socket connection +const sessions = new Map() + +// Valid response types +const VALID_RESPONSES = ['once', 'always', 'reject'] + +/** + * Create a nonce for a permission request + * @param {string} sessionId - OpenCode session ID + * @param {string} permissionId - Permission request ID + * @returns {string} The generated nonce + */ +export function createNonce(sessionId, permissionId) { + const nonce = randomUUID() + nonces.set(nonce, { + sessionId, + permissionId, + createdAt: Date.now(), + }) + return nonce +} + +/** + * Consume a nonce, returning its data if valid + * @param {string} nonce - The nonce to consume + * @returns {Object|null} { sessionId, permissionId } or null if invalid/expired + */ +function consumeNonce(nonce) { + const data = nonces.get(nonce) + if (!data) return null + + nonces.delete(nonce) + + if (Date.now() - data.createdAt > NONCE_TTL_MS) { + return null + } + + return { + sessionId: data.sessionId, + permissionId: data.permissionId, + } +} + +/** + * Clean up expired nonces + * @returns {number} Number of expired nonces removed + */ +function cleanupNonces() { + const now = Date.now() + let removed = 0 + + for (const [nonce, data] of nonces) { + if (now - data.createdAt > NONCE_TTL_MS) { + nonces.delete(nonce) + removed++ + } + } + + return removed +} + +/** + * Register a session connection + * @param {string} sessionId - OpenCode session ID + * @param {net.Socket} socket - Socket connection to the plugin + */ +function registerSession(sessionId, socket) { + console.log(`[opencode-ntfy] Session registered: ${sessionId}`) + sessions.set(sessionId, socket) + + socket.on('close', () => { + console.log(`[opencode-ntfy] Session disconnected: ${sessionId}`) + sessions.delete(sessionId) + }) +} + +/** + * Send a permission response to a session + * @param {string} sessionId - OpenCode session ID + * @param {string} permissionId - Permission request ID + * @param {string} response - Response type: 'once' | 'always' | 'reject' + * @returns {boolean} True if sent successfully + */ +function sendToSession(sessionId, permissionId, response) { + const socket = sessions.get(sessionId) + if (!socket) { + console.warn(`[opencode-ntfy] Session not found: ${sessionId}`) + return false + } + + try { + const message = JSON.stringify({ + type: 'permission_response', + permissionId, + response, + }) + socket.write(message + '\n') + return true + } catch (error) { + console.error(`[opencode-ntfy] Failed to send to session ${sessionId}: ${error.message}`) + return false + } +} + +/** + * Create the HTTP callback server + * @param {number} port - Port to listen on + * @returns {http.Server} The HTTP server + */ +function createCallbackServer(port) { + const server = createHttpServer((req, res) => { + const url = new URL(req.url, `http://localhost:${port}`) + + // GET /health - Health check + if (req.method === 'GET' && url.pathname === '/health') { + res.writeHead(200, { 'Content-Type': 'text/plain' }) + res.end('OK') + return + } + + // POST /callback - Permission response from ntfy + if (req.method === 'POST' && url.pathname === '/callback') { + const nonce = url.searchParams.get('nonce') + const response = url.searchParams.get('response') + + // Validate required params + if (!nonce || !response) { + res.writeHead(400, { 'Content-Type': 'text/plain' }) + res.end('Missing required parameters') + return + } + + // Validate response value + if (!VALID_RESPONSES.includes(response)) { + res.writeHead(400, { 'Content-Type': 'text/plain' }) + res.end('Invalid response value') + return + } + + // Validate and consume nonce + const payload = consumeNonce(nonce) + if (!payload) { + res.writeHead(401, { 'Content-Type': 'text/plain' }) + res.end('Invalid or expired nonce') + return + } + + // Forward to session + const sent = sendToSession(payload.sessionId, payload.permissionId, response) + if (sent) { + res.writeHead(200, { 'Content-Type': 'text/plain' }) + res.end('OK') + } else { + res.writeHead(503, { 'Content-Type': 'text/plain' }) + res.end('Session not connected') + } + return + } + + // Unknown route + res.writeHead(404, { 'Content-Type': 'text/plain' }) + res.end('Not found') + }) + + server.on('error', (err) => { + console.error(`[opencode-ntfy] HTTP server error: ${err.message}`) + }) + + return server +} + +/** + * Create the Unix socket server for IPC + * @param {string} socketPath - Path to the socket file + * @returns {net.Server} The socket server + */ +function createSocketServer(socketPath) { + const server = createNetServer((socket) => { + console.log('[opencode-ntfy] Plugin connected') + + let buffer = '' + + socket.on('data', (data) => { + buffer += data.toString() + + // Process complete messages (newline-delimited JSON) + let newlineIndex + while ((newlineIndex = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newlineIndex) + buffer = buffer.slice(newlineIndex + 1) + + if (!line.trim()) continue + + try { + const message = JSON.parse(line) + handleSocketMessage(socket, message) + } catch (error) { + console.warn(`[opencode-ntfy] Invalid message: ${error.message}`) + } + } + }) + + socket.on('error', (err) => { + console.warn(`[opencode-ntfy] Socket error: ${err.message}`) + }) + }) + + server.on('error', (err) => { + console.error(`[opencode-ntfy] Socket server error: ${err.message}`) + }) + + return server +} + +/** + * Handle a message from a plugin + * @param {net.Socket} socket - The socket connection + * @param {Object} message - The parsed message + */ +function handleSocketMessage(socket, message) { + switch (message.type) { + case 'register': + if (message.sessionId) { + registerSession(message.sessionId, socket) + socket.write(JSON.stringify({ type: 'registered', sessionId: message.sessionId }) + '\n') + } + break + + case 'create_nonce': + if (message.sessionId && message.permissionId) { + const nonce = createNonce(message.sessionId, message.permissionId) + socket.write(JSON.stringify({ type: 'nonce_created', nonce, permissionId: message.permissionId }) + '\n') + } + break + + default: + console.warn(`[opencode-ntfy] Unknown message type: ${message.type}`) + } +} + +/** + * Start the callback service + * @param {Object} config - Configuration options + * @param {number} [config.httpPort] - HTTP server port (default: 4097) + * @param {string} [config.socketPath] - Unix socket path (default: /tmp/opencode-ntfy.sock) + * @returns {Promise} Service instance with httpServer, socketServer, and cleanup interval + */ +export async function startService(config = {}) { + const httpPort = config.httpPort ?? DEFAULT_HTTP_PORT + const socketPath = config.socketPath ?? DEFAULT_SOCKET_PATH + + // Clean up stale socket file + if (existsSync(socketPath)) { + try { + unlinkSync(socketPath) + } catch (err) { + console.warn(`[opencode-ntfy] Could not remove stale socket: ${err.message}`) + } + } + + // Create servers + const httpServer = createCallbackServer(httpPort) + const socketServer = createSocketServer(socketPath) + + // Start HTTP server + await new Promise((resolve, reject) => { + httpServer.listen(httpPort, () => { + const actualPort = httpServer.address().port + console.log(`[opencode-ntfy] HTTP server listening on port ${actualPort}`) + resolve() + }) + httpServer.once('error', reject) + }) + + // Start socket server + await new Promise((resolve, reject) => { + socketServer.listen(socketPath, () => { + console.log(`[opencode-ntfy] Socket server listening at ${socketPath}`) + resolve() + }) + socketServer.once('error', reject) + }) + + // Start periodic nonce cleanup + const cleanupInterval = setInterval(() => { + const removed = cleanupNonces() + if (removed > 0) { + console.log(`[opencode-ntfy] Cleaned up ${removed} expired nonces`) + } + }, 60 * 1000) // Every minute + + return { + httpServer, + socketServer, + cleanupInterval, + socketPath, + } +} + +/** + * Stop the callback service + * @param {Object} service - Service instance from startService + */ +export async function stopService(service) { + if (service.cleanupInterval) { + clearInterval(service.cleanupInterval) + } + + if (service.httpServer) { + await new Promise((resolve) => { + service.httpServer.close(resolve) + }) + } + + if (service.socketServer) { + await new Promise((resolve) => { + service.socketServer.close(resolve) + }) + } + + // Clean up socket file + if (service.socketPath && existsSync(service.socketPath)) { + try { + unlinkSync(service.socketPath) + } catch (err) { + // Ignore errors + } + } + + console.log('[opencode-ntfy] Service stopped') +} + +// If run directly, start the service +const isMainModule = import.meta.url === `file://${process.argv[1]}` +if (isMainModule) { + const config = { + httpPort: parseInt(process.env.NTFY_CALLBACK_PORT || '4097', 10), + socketPath: process.env.NTFY_SOCKET_PATH || DEFAULT_SOCKET_PATH, + } + + console.log('[opencode-ntfy] Starting callback service...') + + const service = await startService(config) + + // Handle graceful shutdown + process.on('SIGTERM', async () => { + console.log('[opencode-ntfy] Received SIGTERM, shutting down...') + await stopService(service) + process.exit(0) + }) + + process.on('SIGINT', async () => { + console.log('[opencode-ntfy] Received SIGINT, shutting down...') + await stopService(service) + process.exit(0) + }) +} diff --git a/test/test_service.bash b/test/test_service.bash new file mode 100644 index 0000000..e373e2f --- /dev/null +++ b/test/test_service.bash @@ -0,0 +1,274 @@ +#!/usr/bin/env bash +# +# Tests for service/server.js - Standalone callback server as brew service +# Issue #13: Separate callback server as brew service +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/test_helper.bash" + +SERVICE_DIR="$(dirname "$SCRIPT_DIR")/service" + +echo "Testing service/server.js module..." +echo "" + +# ============================================================================= +# File Structure Tests +# ============================================================================= + +test_service_file_exists() { + assert_file_exists "$SERVICE_DIR/server.js" +} + +test_service_js_syntax() { + if ! command -v node &>/dev/null; then + echo "SKIP: node not available" + return 0 + fi + node --check "$SERVICE_DIR/server.js" 2>&1 || { + echo "server.js has syntax errors" + return 1 + } +} + +# ============================================================================= +# Export Tests +# ============================================================================= + +test_service_exports_start_service() { + grep -q "export.*function startService\|export.*startService" "$SERVICE_DIR/server.js" || { + echo "startService export not found in server.js" + return 1 + } +} + +test_service_exports_stop_service() { + grep -q "export.*function stopService\|export.*stopService" "$SERVICE_DIR/server.js" || { + echo "stopService export not found in server.js" + return 1 + } +} + +# ============================================================================= +# Implementation Tests +# ============================================================================= + +test_service_has_http_server() { + grep -q "createServer\|http" "$SERVICE_DIR/server.js" || { + echo "HTTP server not found in server.js" + return 1 + } +} + +test_service_has_unix_socket() { + grep -q "createServer\|net\|\.sock\|socket" "$SERVICE_DIR/server.js" || { + echo "Unix socket handling not found in server.js" + return 1 + } +} + +test_service_has_health_endpoint() { + grep -q "/health" "$SERVICE_DIR/server.js" || { + echo "/health endpoint not found in server.js" + return 1 + } +} + +test_service_has_callback_endpoint() { + grep -q "/callback" "$SERVICE_DIR/server.js" || { + echo "/callback endpoint not found in server.js" + return 1 + } +} + +test_service_handles_session_registration() { + grep -q "register\|session" "$SERVICE_DIR/server.js" || { + echo "Session registration not found in server.js" + return 1 + } +} + +test_service_handles_nonce_creation() { + grep -q "createNonce\|nonce" "$SERVICE_DIR/server.js" || { + echo "Nonce creation not found in server.js" + return 1 + } +} + +test_service_logs_with_prefix() { + grep -q "\[opencode-ntfy\]" "$SERVICE_DIR/server.js" || { + echo "Logging prefix [opencode-ntfy] not found in server.js" + return 1 + } +} + +# ============================================================================= +# Functional Tests (requires Node.js) +# ============================================================================= + +test_service_starts_and_stops() { + if ! command -v node &>/dev/null; then + echo "SKIP: node not available" + return 0 + fi + + local result + result=$(node --experimental-vm-modules -e " + import { startService, stopService } from './service/server.js'; + + // Use random ports/sockets to avoid conflicts + const config = { + httpPort: 0, + socketPath: '/tmp/opencode-ntfy-test-' + process.pid + '.sock' + }; + + const service = await startService(config); + + if (!service.httpServer) { + console.log('FAIL: HTTP server not started'); + process.exit(1); + } + + await stopService(service); + console.log('PASS'); + " 2>&1) || { + echo "Functional test failed: $result" + return 1 + } + + if ! echo "$result" | grep -q "PASS"; then + echo "$result" + return 1 + fi +} + +test_service_health_endpoint_returns_200() { + if ! command -v node &>/dev/null; then + echo "SKIP: node not available" + return 0 + fi + + local result + result=$(node --experimental-vm-modules -e " + import { startService, stopService } from './service/server.js'; + + const config = { + httpPort: 0, + socketPath: '/tmp/opencode-ntfy-test-' + process.pid + '.sock' + }; + + const service = await startService(config); + const port = service.httpServer.address().port; + + const res = await fetch('http://localhost:' + port + '/health'); + + if (res.status !== 200) { + console.log('FAIL: Health check returned ' + res.status); + process.exit(1); + } + + await stopService(service); + console.log('PASS'); + " 2>&1) || { + echo "Functional test failed: $result" + return 1 + } + + if ! echo "$result" | grep -q "PASS"; then + echo "$result" + return 1 + fi +} + +test_service_returns_401_for_invalid_nonce() { + if ! command -v node &>/dev/null; then + echo "SKIP: node not available" + return 0 + fi + + local result + result=$(node --experimental-vm-modules -e " + import { startService, stopService } from './service/server.js'; + + const config = { + httpPort: 0, + socketPath: '/tmp/opencode-ntfy-test-' + process.pid + '.sock' + }; + + const service = await startService(config); + const port = service.httpServer.address().port; + + const res = await fetch('http://localhost:' + port + '/callback?nonce=invalid&response=once', { + method: 'POST' + }); + + if (res.status !== 401) { + console.log('FAIL: Expected 401, got ' + res.status); + process.exit(1); + } + + await stopService(service); + console.log('PASS'); + " 2>&1) || { + echo "Functional test failed: $result" + return 1 + } + + if ! echo "$result" | grep -q "PASS"; then + echo "$result" + return 1 + fi +} + +# ============================================================================= +# Run Tests +# ============================================================================= + +echo "File Structure Tests:" + +for test_func in \ + test_service_file_exists \ + test_service_js_syntax +do + run_test "${test_func#test_}" "$test_func" +done + +echo "" +echo "Export Tests:" + +for test_func in \ + test_service_exports_start_service \ + test_service_exports_stop_service +do + run_test "${test_func#test_}" "$test_func" +done + +echo "" +echo "Implementation Tests:" + +for test_func in \ + test_service_has_http_server \ + test_service_has_unix_socket \ + test_service_has_health_endpoint \ + test_service_has_callback_endpoint \ + test_service_handles_session_registration \ + test_service_handles_nonce_creation \ + test_service_logs_with_prefix +do + run_test "${test_func#test_}" "$test_func" +done + +echo "" +echo "Functional Tests:" + +for test_func in \ + test_service_starts_and_stops \ + test_service_health_endpoint_returns_200 \ + test_service_returns_401_for_invalid_nonce +do + run_test "${test_func#test_}" "$test_func" +done + +print_summary From a3ef6af16cbb848d9b692de7a9bfb2b5d86d5dea Mon Sep 17 00:00:00 2001 From: Andrew Thal <467872+athal7@users.noreply.github.com> Date: Thu, 1 Jan 2026 17:22:48 -0600 Subject: [PATCH 2/9] feat(#13): add LaunchAgent plist for brew services - Label: io.opencode.ntfy - Runs server.js via node - KeepAlive and RunAtLoad enabled - Logs to /usr/local/var/log/opencode-ntfy.log --- service/io.opencode.ntfy.plist | 29 ++++++++ test/test_plist.bash | 125 +++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 service/io.opencode.ntfy.plist create mode 100644 test/test_plist.bash diff --git a/service/io.opencode.ntfy.plist b/service/io.opencode.ntfy.plist new file mode 100644 index 0000000..4ec7a15 --- /dev/null +++ b/service/io.opencode.ntfy.plist @@ -0,0 +1,29 @@ + + + + + Label + io.opencode.ntfy + + ProgramArguments + + /usr/local/bin/node + /usr/local/opt/opencode-ntfy/libexec/server.js + + + RunAtLoad + + + KeepAlive + + + StandardOutPath + /usr/local/var/log/opencode-ntfy.log + + StandardErrorPath + /usr/local/var/log/opencode-ntfy.log + + WorkingDirectory + /usr/local/opt/opencode-ntfy/libexec + + diff --git a/test/test_plist.bash b/test/test_plist.bash new file mode 100644 index 0000000..fa35556 --- /dev/null +++ b/test/test_plist.bash @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# +# Tests for service/io.opencode.ntfy.plist - LaunchAgent plist for brew services +# Issue #13: Separate callback server as brew service +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/test_helper.bash" + +SERVICE_DIR="$(dirname "$SCRIPT_DIR")/service" + +echo "Testing LaunchAgent plist..." +echo "" + +# ============================================================================= +# File Structure Tests +# ============================================================================= + +test_plist_file_exists() { + assert_file_exists "$SERVICE_DIR/io.opencode.ntfy.plist" +} + +test_plist_is_valid_xml() { + if ! command -v plutil &>/dev/null; then + echo "SKIP: plutil not available (macOS only)" + return 0 + fi + plutil -lint "$SERVICE_DIR/io.opencode.ntfy.plist" 2>&1 || { + echo "plist is not valid XML" + return 1 + } +} + +# ============================================================================= +# Content Tests +# ============================================================================= + +test_plist_has_label() { + grep -q "io.opencode.ntfy" "$SERVICE_DIR/io.opencode.ntfy.plist" || { + echo "Label not found in plist" + return 1 + } +} + +test_plist_has_program_arguments() { + grep -q "ProgramArguments" "$SERVICE_DIR/io.opencode.ntfy.plist" || { + echo "ProgramArguments not found in plist" + return 1 + } +} + +test_plist_runs_node() { + grep -q "node" "$SERVICE_DIR/io.opencode.ntfy.plist" || { + echo "node command not found in plist" + return 1 + } +} + +test_plist_runs_server_js() { + grep -q "server.js" "$SERVICE_DIR/io.opencode.ntfy.plist" || { + echo "server.js not found in plist" + return 1 + } +} + +test_plist_has_keep_alive() { + grep -q "KeepAlive" "$SERVICE_DIR/io.opencode.ntfy.plist" || { + echo "KeepAlive not found in plist" + return 1 + } +} + +test_plist_has_run_at_load() { + grep -q "RunAtLoad" "$SERVICE_DIR/io.opencode.ntfy.plist" || { + echo "RunAtLoad not found in plist" + return 1 + } +} + +test_plist_has_stdout_log() { + grep -q "stdout\|StandardOutPath" "$SERVICE_DIR/io.opencode.ntfy.plist" || { + echo "Stdout logging not found in plist" + return 1 + } +} + +test_plist_has_stderr_log() { + grep -q "stderr\|StandardErrorPath" "$SERVICE_DIR/io.opencode.ntfy.plist" || { + echo "Stderr logging not found in plist" + return 1 + } +} + +# ============================================================================= +# Run Tests +# ============================================================================= + +echo "File Structure Tests:" + +for test_func in \ + test_plist_file_exists \ + test_plist_is_valid_xml +do + run_test "${test_func#test_}" "$test_func" +done + +echo "" +echo "Content Tests:" + +for test_func in \ + test_plist_has_label \ + test_plist_has_program_arguments \ + test_plist_runs_node \ + test_plist_runs_server_js \ + test_plist_has_keep_alive \ + test_plist_has_run_at_load \ + test_plist_has_stdout_log \ + test_plist_has_stderr_log +do + run_test "${test_func#test_}" "$test_func" +done + +print_summary From f9f76a0c43f9298746bfdebe01d175bd8de547ea Mon Sep 17 00:00:00 2001 From: Andrew Thal <467872+athal7@users.noreply.github.com> Date: Thu, 1 Jan 2026 17:24:15 -0600 Subject: [PATCH 3/9] feat(#13): add service client for plugin-to-service IPC - Connect/disconnect to callback service via Unix socket - Session registration with service - Nonce request/response handling - Permission response callback forwarding - Graceful degradation when service not running --- plugin/service-client.js | 194 +++++++++++++++++ test/test_service_client.bash | 384 ++++++++++++++++++++++++++++++++++ 2 files changed, 578 insertions(+) create mode 100644 plugin/service-client.js create mode 100644 test/test_service_client.bash diff --git a/plugin/service-client.js b/plugin/service-client.js new file mode 100644 index 0000000..adc0329 --- /dev/null +++ b/plugin/service-client.js @@ -0,0 +1,194 @@ +// Service client for plugin-to-service IPC +// Implements Issue #13: Separate callback server as brew service +// +// This module connects to the standalone callback service via Unix socket +// and handles: +// - Session registration +// - Nonce requests for permission notifications +// - Permission response callbacks + +import { createConnection } from 'net' + +// Default socket path (same as service) +const DEFAULT_SOCKET_PATH = '/tmp/opencode-ntfy.sock' + +// Connection state +let socket = null +let sessionId = null +let permissionHandler = null +let pendingNonceRequests = new Map() // permissionId -> resolve/reject + +/** + * Check if connected to the service + * @returns {boolean} True if connected + */ +export function isConnected() { + return socket !== null && !socket.destroyed +} + +/** + * Set the handler for permission responses + * @param {Function} handler - Called with (permissionId, response) when permission response received + */ +export function setPermissionHandler(handler) { + permissionHandler = handler +} + +/** + * Connect to the callback service + * @param {Object} options + * @param {string} options.sessionId - OpenCode session ID + * @param {string} [options.socketPath] - Unix socket path (default: /tmp/opencode-ntfy.sock) + * @returns {Promise} True if connected successfully + */ +export async function connectToService(options) { + const socketPath = options.socketPath || DEFAULT_SOCKET_PATH + sessionId = options.sessionId + + return new Promise((resolve) => { + socket = createConnection(socketPath) + + let buffer = '' + + socket.on('connect', () => { + console.log(`[opencode-ntfy] Connected to service at ${socketPath}`) + + // Register session + sendMessage({ + type: 'register', + sessionId, + }) + }) + + socket.on('data', (data) => { + buffer += data.toString() + + // Process complete messages (newline-delimited JSON) + let newlineIndex + while ((newlineIndex = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newlineIndex) + buffer = buffer.slice(newlineIndex + 1) + + if (!line.trim()) continue + + try { + const message = JSON.parse(line) + handleMessage(message, resolve) + } catch (error) { + console.warn(`[opencode-ntfy] Invalid message from service: ${error.message}`) + } + } + }) + + socket.on('error', (err) => { + if (err.code === 'ECONNREFUSED' || err.code === 'ENOENT') { + console.warn(`[opencode-ntfy] Service not running (${err.code})`) + } else { + console.warn(`[opencode-ntfy] Socket error: ${err.message}`) + } + socket = null + resolve(false) + }) + + socket.on('close', () => { + console.log('[opencode-ntfy] Disconnected from service') + socket = null + + // Reject any pending nonce requests + for (const [permissionId, { reject }] of pendingNonceRequests) { + reject(new Error('Service connection closed')) + } + pendingNonceRequests.clear() + }) + }) +} + +/** + * Disconnect from the callback service + */ +export async function disconnectFromService() { + if (socket) { + socket.destroy() + socket = null + } + sessionId = null +} + +/** + * Request a nonce from the service for a permission request + * @param {string} permissionId - Permission request ID + * @returns {Promise} The generated nonce + */ +export function requestNonce(permissionId) { + return new Promise((resolve, reject) => { + if (!isConnected()) { + reject(new Error('Not connected to service')) + return + } + + // Store pending request + pendingNonceRequests.set(permissionId, { resolve, reject }) + + // Send request + sendMessage({ + type: 'create_nonce', + sessionId, + permissionId, + }) + + // Timeout after 5 seconds + setTimeout(() => { + if (pendingNonceRequests.has(permissionId)) { + pendingNonceRequests.delete(permissionId) + reject(new Error('Nonce request timed out')) + } + }, 5000) + }) +} + +/** + * Send a message to the service + * @param {Object} message - Message to send + */ +function sendMessage(message) { + if (socket && !socket.destroyed) { + socket.write(JSON.stringify(message) + '\n') + } +} + +/** + * Handle a message from the service + * @param {Object} message - Parsed message + * @param {Function} [connectResolve] - Resolve function for connection promise + */ +function handleMessage(message, connectResolve) { + switch (message.type) { + case 'registered': + console.log(`[opencode-ntfy] Session registered: ${message.sessionId}`) + if (connectResolve) { + connectResolve(true) + } + break + + case 'nonce_created': + // Resolve pending nonce request + const pending = pendingNonceRequests.get(message.permissionId) + if (pending) { + pendingNonceRequests.delete(message.permissionId) + pending.resolve(message.nonce) + } + break + + case 'permission_response': + // Forward to handler + if (permissionHandler) { + permissionHandler(message.permissionId, message.response) + } else { + console.warn(`[opencode-ntfy] Received permission response but no handler set`) + } + break + + default: + console.warn(`[opencode-ntfy] Unknown message type from service: ${message.type}`) + } +} diff --git a/test/test_service_client.bash b/test/test_service_client.bash new file mode 100644 index 0000000..52b1888 --- /dev/null +++ b/test/test_service_client.bash @@ -0,0 +1,384 @@ +#!/usr/bin/env bash +# +# Tests for plugin/service-client.js - IPC client for plugin-to-service communication +# Issue #13: Separate callback server as brew service +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/test_helper.bash" + +PLUGIN_DIR="$(dirname "$SCRIPT_DIR")/plugin" + +echo "Testing service-client.js module..." +echo "" + +# ============================================================================= +# File Structure Tests +# ============================================================================= + +test_service_client_file_exists() { + assert_file_exists "$PLUGIN_DIR/service-client.js" +} + +test_service_client_js_syntax() { + if ! command -v node &>/dev/null; then + echo "SKIP: node not available" + return 0 + fi + node --check "$PLUGIN_DIR/service-client.js" 2>&1 || { + echo "service-client.js has syntax errors" + return 1 + } +} + +# ============================================================================= +# Export Tests +# ============================================================================= + +test_service_client_exports_connect() { + grep -q "export.*function connectToService\|export.*connectToService" "$PLUGIN_DIR/service-client.js" || { + echo "connectToService export not found in service-client.js" + return 1 + } +} + +test_service_client_exports_disconnect() { + grep -q "export.*function disconnectFromService\|export.*disconnectFromService" "$PLUGIN_DIR/service-client.js" || { + echo "disconnectFromService export not found in service-client.js" + return 1 + } +} + +test_service_client_exports_request_nonce() { + grep -q "export.*function requestNonce\|export.*requestNonce" "$PLUGIN_DIR/service-client.js" || { + echo "requestNonce export not found in service-client.js" + return 1 + } +} + +test_service_client_exports_is_connected() { + grep -q "export.*function isConnected\|export.*isConnected" "$PLUGIN_DIR/service-client.js" || { + echo "isConnected export not found in service-client.js" + return 1 + } +} + +# ============================================================================= +# Implementation Tests +# ============================================================================= + +test_service_client_uses_net_module() { + grep -q "net\|createConnection\|connect" "$PLUGIN_DIR/service-client.js" || { + echo "net module usage not found in service-client.js" + return 1 + } +} + +test_service_client_has_socket_path() { + grep -q "socket\|\.sock" "$PLUGIN_DIR/service-client.js" || { + echo "Socket path handling not found in service-client.js" + return 1 + } +} + +test_service_client_handles_registration() { + grep -q "register\|session" "$PLUGIN_DIR/service-client.js" || { + echo "Session registration not found in service-client.js" + return 1 + } +} + +test_service_client_handles_nonce_request() { + grep -q "create_nonce\|nonce" "$PLUGIN_DIR/service-client.js" || { + echo "Nonce request handling not found in service-client.js" + return 1 + } +} + +test_service_client_handles_permission_response() { + grep -q "permission_response\|onPermissionResponse" "$PLUGIN_DIR/service-client.js" || { + echo "Permission response handling not found in service-client.js" + return 1 + } +} + +test_service_client_logs_with_prefix() { + grep -q "\[opencode-ntfy\]" "$PLUGIN_DIR/service-client.js" || { + echo "Logging prefix [opencode-ntfy] not found in service-client.js" + return 1 + } +} + +test_service_client_handles_connection_errors() { + grep -q "error\|ECONNREFUSED\|ENOENT" "$PLUGIN_DIR/service-client.js" || { + echo "Connection error handling not found in service-client.js" + return 1 + } +} + +# ============================================================================= +# Functional Tests (requires Node.js and running service) +# ============================================================================= + +test_service_client_connects_to_service() { + if ! command -v node &>/dev/null; then + echo "SKIP: node not available" + return 0 + fi + + local result + result=$(node --experimental-vm-modules -e " + import { startService, stopService } from './service/server.js'; + import { connectToService, disconnectFromService, isConnected } from './plugin/service-client.js'; + + // Start service with test socket + const socketPath = '/tmp/opencode-ntfy-test-' + process.pid + '.sock'; + const service = await startService({ httpPort: 0, socketPath }); + + // Connect client + const connected = await connectToService({ + sessionId: 'test-session', + socketPath, + }); + + if (!connected) { + console.log('FAIL: Failed to connect to service'); + await stopService(service); + process.exit(1); + } + + if (!isConnected()) { + console.log('FAIL: isConnected() returned false after connection'); + await stopService(service); + process.exit(1); + } + + await disconnectFromService(); + await stopService(service); + console.log('PASS'); + " 2>&1) || { + echo "Functional test failed: $result" + return 1 + } + + if ! echo "$result" | grep -q "PASS"; then + echo "$result" + return 1 + fi +} + +test_service_client_requests_nonce() { + if ! command -v node &>/dev/null; then + echo "SKIP: node not available" + return 0 + fi + + local result + result=$(node --experimental-vm-modules -e " + import { startService, stopService } from './service/server.js'; + import { connectToService, disconnectFromService, requestNonce } from './plugin/service-client.js'; + + // Start service with test socket + const socketPath = '/tmp/opencode-ntfy-test-' + process.pid + '.sock'; + const service = await startService({ httpPort: 0, socketPath }); + + // Connect client + await connectToService({ + sessionId: 'test-session', + socketPath, + }); + + // Request nonce + const nonce = await requestNonce('perm-123'); + + if (!nonce || typeof nonce !== 'string') { + console.log('FAIL: Did not receive valid nonce: ' + nonce); + await disconnectFromService(); + await stopService(service); + process.exit(1); + } + + await disconnectFromService(); + await stopService(service); + console.log('PASS'); + " 2>&1) || { + echo "Functional test failed: $result" + return 1 + } + + if ! echo "$result" | grep -q "PASS"; then + echo "$result" + return 1 + fi +} + +test_service_client_receives_permission_response() { + if ! command -v node &>/dev/null; then + echo "SKIP: node not available" + return 0 + fi + + local result + result=$(node --experimental-vm-modules -e " + import { startService, stopService } from './service/server.js'; + import { connectToService, disconnectFromService, requestNonce, setPermissionHandler } from './plugin/service-client.js'; + + // Start service with test socket + const socketPath = '/tmp/opencode-ntfy-test-' + process.pid + '.sock'; + const service = await startService({ httpPort: 0, socketPath }); + const port = service.httpServer.address().port; + + // Set up handler to receive response + let receivedPermissionId, receivedResponse; + setPermissionHandler((permissionId, response) => { + receivedPermissionId = permissionId; + receivedResponse = response; + }); + + // Connect client + await connectToService({ + sessionId: 'test-session', + socketPath, + }); + + // Request nonce + const nonce = await requestNonce('perm-456'); + + // Simulate callback from ntfy + const res = await fetch('http://localhost:' + port + '/callback?nonce=' + nonce + '&response=once', { + method: 'POST' + }); + + if (res.status !== 200) { + console.log('FAIL: Callback returned ' + res.status); + await disconnectFromService(); + await stopService(service); + process.exit(1); + } + + // Wait for handler to be called + await new Promise(resolve => setTimeout(resolve, 100)); + + if (receivedPermissionId !== 'perm-456') { + console.log('FAIL: Wrong permissionId: ' + receivedPermissionId); + await disconnectFromService(); + await stopService(service); + process.exit(1); + } + + if (receivedResponse !== 'once') { + console.log('FAIL: Wrong response: ' + receivedResponse); + await disconnectFromService(); + await stopService(service); + process.exit(1); + } + + await disconnectFromService(); + await stopService(service); + console.log('PASS'); + " 2>&1) || { + echo "Functional test failed: $result" + return 1 + } + + if ! echo "$result" | grep -q "PASS"; then + echo "$result" + return 1 + fi +} + +test_service_client_handles_service_not_running() { + if ! command -v node &>/dev/null; then + echo "SKIP: node not available" + return 0 + fi + + local result + result=$(node --experimental-vm-modules -e " + import { connectToService, isConnected } from './plugin/service-client.js'; + + // Try to connect to non-existent service + const socketPath = '/tmp/opencode-ntfy-nonexistent-' + process.pid + '.sock'; + + const connected = await connectToService({ + sessionId: 'test-session', + socketPath, + }); + + if (connected) { + console.log('FAIL: Should not have connected to non-existent service'); + process.exit(1); + } + + if (isConnected()) { + console.log('FAIL: isConnected() should return false'); + process.exit(1); + } + + console.log('PASS'); + " 2>&1) || { + echo "Functional test failed: $result" + return 1 + } + + if ! echo "$result" | grep -q "PASS"; then + echo "$result" + return 1 + fi +} + +# ============================================================================= +# Run Tests +# ============================================================================= + +echo "File Structure Tests:" + +for test_func in \ + test_service_client_file_exists \ + test_service_client_js_syntax +do + run_test "${test_func#test_}" "$test_func" +done + +echo "" +echo "Export Tests:" + +for test_func in \ + test_service_client_exports_connect \ + test_service_client_exports_disconnect \ + test_service_client_exports_request_nonce \ + test_service_client_exports_is_connected +do + run_test "${test_func#test_}" "$test_func" +done + +echo "" +echo "Implementation Tests:" + +for test_func in \ + test_service_client_uses_net_module \ + test_service_client_has_socket_path \ + test_service_client_handles_registration \ + test_service_client_handles_nonce_request \ + test_service_client_handles_permission_response \ + test_service_client_logs_with_prefix \ + test_service_client_handles_connection_errors +do + run_test "${test_func#test_}" "$test_func" +done + +echo "" +echo "Functional Tests:" + +for test_func in \ + test_service_client_connects_to_service \ + test_service_client_requests_nonce \ + test_service_client_receives_permission_response \ + test_service_client_handles_service_not_running +do + run_test "${test_func#test_}" "$test_func" +done + +print_summary From e797b7da49c9448cef426837ec9ce14e2042143c Mon Sep 17 00:00:00 2001 From: Andrew Thal <467872+athal7@users.noreply.github.com> Date: Thu, 1 Jan 2026 17:26:29 -0600 Subject: [PATCH 4/9] feat(#13): update plugin to use service client for interactive permissions - Connect to callback service on startup when callbackHost configured - Handle permission.updated events by sending notifications - Request nonces from service for each permission notification - Forward permission responses to OpenCode client - Add shutdown handler to disconnect from service - Add service client tests to plugin test suite --- plugin/index.js | 116 ++++++++++++++++++++++++++++++++++++++++-- test/test_plugin.bash | 57 ++++++++++++++++++++- 2 files changed, 167 insertions(+), 6 deletions(-) diff --git a/plugin/index.js b/plugin/index.js index db8720a..717469e 100644 --- a/plugin/index.js +++ b/plugin/index.js @@ -9,8 +9,16 @@ // See README.md for full configuration options. import { basename } from 'path' -import { sendNotification } from './notifier.js' +import { randomUUID } from 'crypto' +import { sendNotification, sendPermissionNotification } from './notifier.js' import { loadConfig } from './config.js' +import { + connectToService, + disconnectFromService, + isConnected, + requestNonce, + setPermissionHandler, +} from './service-client.js' // Load configuration from opencode.json and environment const config = loadConfig() @@ -22,19 +30,67 @@ export const Notify = async ({ $, client, directory }) => { } console.log(`[opencode-ntfy] Initialized for topic: ${config.topic}`) + + // Session ID for this plugin instance + const sessionId = randomUUID() + + // Interactive mode state + let serviceConnected = false + if (config.callbackHost) { console.log(`[opencode-ntfy] Interactive mode enabled (callback: ${config.callbackHost}:${config.callbackPort})`) + + // Set up permission response handler + setPermissionHandler(async (permissionId, response) => { + console.log(`[opencode-ntfy] Permission response received: ${permissionId} -> ${response}`) + + // Map response to OpenCode permission action + let action + switch (response) { + case 'once': + action = 'allow' + break + case 'always': + action = 'allowAlways' + break + case 'reject': + action = 'deny' + break + default: + console.warn(`[opencode-ntfy] Unknown response type: ${response}`) + return + } + + // Submit permission response to OpenCode + try { + await client.permission.respond({ + id: permissionId, + action, + }) + console.log(`[opencode-ntfy] Permission ${permissionId} resolved with action: ${action}`) + } catch (error) { + console.warn(`[opencode-ntfy] Failed to respond to permission ${permissionId}: ${error.message}`) + } + }) + + // Connect to service + serviceConnected = await connectToService({ sessionId }) + if (serviceConnected) { + console.log('[opencode-ntfy] Connected to callback service') + } else { + console.log('[opencode-ntfy] Callback service not running, interactive permissions disabled') + console.log('[opencode-ntfy] Start service with: brew services start opencode-ntfy') + } } else { console.log('[opencode-ntfy] Read-only mode (set callbackHost for interactive permissions)') } - // TODO: Issue #4 - Start callback server if callbackHost is configured - const dir = basename(process.cwd()) let idleTimer = null return { event: async ({ event }) => { + // Handle session status events (idle notifications) if (event.type === 'session.status') { const status = event.properties?.status?.type if (status === 'idle' && !idleTimer) { @@ -54,9 +110,61 @@ export const Notify = async ({ $, client, directory }) => { idleTimer = null } } - // TODO: Issue #3 - Handle permission.updated events + + // Handle permission.updated events (interactive permissions) + if (event.type === 'permission.updated') { + const permission = event.properties?.permission + if (!permission || permission.status !== 'pending') { + return + } + + // Only send interactive notifications if service is connected + if (!config.callbackHost || !isConnected()) { + return + } + + const permissionId = permission.id + const tool = permission.tool || 'Unknown tool' + const description = permission.description || 'Permission requested' + + try { + // Request nonce from service + const nonce = await requestNonce(permissionId) + + // Build callback URL + const callbackUrl = `http://${config.callbackHost}:${config.callbackPort}/callback` + + // Send permission notification with action buttons + await sendPermissionNotification({ + server: config.server, + topic: config.topic, + callbackUrl, + nonce, + tool, + description, + authToken: config.authToken, + }) + + console.log(`[opencode-ntfy] Permission notification sent for: ${tool}`) + } catch (error) { + console.warn(`[opencode-ntfy] Failed to send permission notification: ${error.message}`) + } + } + // TODO: Issue #7 - Handle error and retry events }, + + // Cleanup on shutdown + shutdown: async () => { + if (idleTimer) { + clearTimeout(idleTimer) + idleTimer = null + } + + if (serviceConnected) { + await disconnectFromService() + } + }, } } diff --git a/test/test_plugin.bash b/test/test_plugin.bash index 3d1b8dd..78c88c5 100755 --- a/test/test_plugin.bash +++ b/test/test_plugin.bash @@ -40,6 +40,10 @@ test_plugin_nonces_exists() { assert_file_exists "$PLUGIN_DIR/nonces.js" } +test_plugin_service_client_exists() { + assert_file_exists "$PLUGIN_DIR/service-client.js" +} + # ============================================================================= # JavaScript Syntax Validation Tests # ============================================================================= @@ -99,6 +103,17 @@ test_nonces_js_syntax() { } } +test_service_client_js_syntax() { + if ! command -v node &>/dev/null; then + echo "SKIP: node not available" + return 0 + fi + node --check "$PLUGIN_DIR/service-client.js" 2>&1 || { + echo "service-client.js has syntax errors" + return 1 + } +} + # ============================================================================= # Configuration Tests # ============================================================================= @@ -279,6 +294,31 @@ test_nonces_exports_consume_nonce() { } } +# ============================================================================= +# Service Integration Tests +# ============================================================================= + +test_index_imports_service_client() { + grep -q "import.*service-client\|from.*service-client" "$PLUGIN_DIR/index.js" || { + echo "service-client import not found in index.js" + return 1 + } +} + +test_index_connects_to_service() { + grep -q "connectToService" "$PLUGIN_DIR/index.js" || { + echo "connectToService call not found in index.js" + return 1 + } +} + +test_index_handles_permission_updated() { + grep -q "permission.updated\|permission\.updated" "$PLUGIN_DIR/index.js" || { + echo "permission.updated event handling not found in index.js" + return 1 + } +} + # ============================================================================= # OpenCode Runtime Integration Tests # ============================================================================= @@ -421,7 +461,8 @@ for test_func in \ test_plugin_notifier_exists \ test_plugin_callback_exists \ test_plugin_hostname_exists \ - test_plugin_nonces_exists + test_plugin_nonces_exists \ + test_plugin_service_client_exists do run_test "${test_func#test_}" "$test_func" done @@ -434,7 +475,8 @@ for test_func in \ test_notifier_js_syntax \ test_callback_js_syntax \ test_hostname_js_syntax \ - test_nonces_js_syntax + test_nonces_js_syntax \ + test_service_client_js_syntax do run_test "${test_func#test_}" "$test_func" done @@ -492,6 +534,17 @@ do run_test "${test_func#test_}" "$test_func" done +echo "" +echo "Service Integration Tests:" + +for test_func in \ + test_index_imports_service_client \ + test_index_connects_to_service \ + test_index_handles_permission_updated +do + run_test "${test_func#test_}" "$test_func" +done + echo "" echo "OpenCode Runtime Integration Tests (CI=${CI:-false}):" From b0f481dbe94689cfb534ce3d0e7f25370802e559 Mon Sep 17 00:00:00 2001 From: Andrew Thal <467872+athal7@users.noreply.github.com> Date: Thu, 1 Jan 2026 17:27:17 -0600 Subject: [PATCH 5/9] feat(#13): update install.sh to install service and LaunchAgent - Install service files to ~/.local/share/opencode-ntfy - Generate LaunchAgent plist with correct paths - Include service-client.js and config.js in plugin files - Add launchctl commands for service management --- install.sh | 99 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 92 insertions(+), 7 deletions(-) diff --git a/install.sh b/install.sh index 3eecec7..35280f3 100755 --- a/install.sh +++ b/install.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# Install opencode-ntfy plugin +# Install opencode-ntfy plugin and callback service # # Usage: # curl -fsSL https://raw.githubusercontent.com/athal7/opencode-ntfy/main/install.sh | bash @@ -14,14 +14,19 @@ set -euo pipefail REPO="athal7/opencode-ntfy" PLUGIN_NAME="opencode-ntfy" PLUGIN_DIR="$HOME/.config/opencode/plugins/$PLUGIN_NAME" +SERVICE_DIR="$HOME/.local/share/opencode-ntfy" CONFIG_FILE="$HOME/.config/opencode/opencode.json" -PLUGIN_FILES="index.js notifier.js callback.js hostname.js nonces.js" +PLIST_DIR="$HOME/Library/LaunchAgents" +PLIST_NAME="io.opencode.ntfy.plist" +PLUGIN_FILES="index.js notifier.js callback.js hostname.js nonces.js config.js service-client.js" +SERVICE_FILES="server.js" echo "Installing $PLUGIN_NAME..." echo "" -# Create plugin directory +# Create directories mkdir -p "$PLUGIN_DIR" +mkdir -p "$SERVICE_DIR" # Check if we're running from a local clone or need to download SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}" 2>/dev/null)" && pwd 2>/dev/null)" || SCRIPT_DIR="" @@ -30,10 +35,21 @@ if [[ -n "$SCRIPT_DIR" ]] && [[ -f "$SCRIPT_DIR/plugin/index.js" ]]; then # Local install from clone echo "Installing from local directory..." + echo "" + echo "Plugin files:" for file in $PLUGIN_FILES; do if [[ -f "$SCRIPT_DIR/plugin/$file" ]]; then cp "$SCRIPT_DIR/plugin/$file" "$PLUGIN_DIR/$file" - echo " Installed: $file" + echo " Installed: plugin/$file -> $PLUGIN_DIR/$file" + fi + done + + echo "" + echo "Service files:" + for file in $SERVICE_FILES; do + if [[ -f "$SCRIPT_DIR/service/$file" ]]; then + cp "$SCRIPT_DIR/service/$file" "$SERVICE_DIR/$file" + echo " Installed: service/$file -> $SERVICE_DIR/$file" fi done else @@ -41,7 +57,7 @@ else echo "Downloading plugin files from GitHub..." for file in $PLUGIN_FILES; do - echo " Downloading: $file" + echo " Downloading: plugin/$file" if curl -fsSL "https://raw.githubusercontent.com/$REPO/main/plugin/$file" -o "$PLUGIN_DIR/$file"; then echo " Installed: $file" else @@ -49,10 +65,76 @@ else exit 1 fi done + + echo "" + echo "Downloading service files from GitHub..." + + for file in $SERVICE_FILES; do + echo " Downloading: service/$file" + if curl -fsSL "https://raw.githubusercontent.com/$REPO/main/service/$file" -o "$SERVICE_DIR/$file"; then + echo " Installed: $file" + else + echo " ERROR: Failed to download $file" + exit 1 + fi + done fi echo "" echo "Plugin files installed to: $PLUGIN_DIR" +echo "Service files installed to: $SERVICE_DIR" + +# Install LaunchAgent plist (macOS only) +if [[ "$(uname)" == "Darwin" ]]; then + echo "" + echo "Installing LaunchAgent for callback service..." + + mkdir -p "$PLIST_DIR" + + # Find node path + NODE_PATH=$(command -v node 2>/dev/null || echo "/usr/local/bin/node") + + # Generate plist with correct paths + cat > "$PLIST_DIR/$PLIST_NAME" << EOF + + + + + Label + io.opencode.ntfy + + ProgramArguments + + $NODE_PATH + $SERVICE_DIR/server.js + + + RunAtLoad + + + KeepAlive + + + StandardOutPath + $HOME/.local/share/opencode-ntfy/opencode-ntfy.log + + StandardErrorPath + $HOME/.local/share/opencode-ntfy/opencode-ntfy.log + + WorkingDirectory + $SERVICE_DIR + + +EOF + + echo " LaunchAgent installed to: $PLIST_DIR/$PLIST_NAME" + echo "" + echo " To start the callback service:" + echo " launchctl load $PLIST_DIR/$PLIST_NAME" + echo "" + echo " To stop the callback service:" + echo " launchctl unload $PLIST_DIR/$PLIST_NAME" +fi # Configure opencode.json echo "" @@ -144,7 +226,10 @@ echo " NTFY_TOKEN=tk_xxx # ntfy access token for protected top echo " NTFY_CALLBACK_HOST=host.ts.net # Callback host for interactive notifications" echo " NTFY_CALLBACK_PORT=4097 # Callback server port" echo " NTFY_IDLE_DELAY_MS=300000 # Idle notification delay (5 min)" + echo "" -echo "For interactive permissions, ensure your phone can reach" -echo "the callback URL (e.g., via Tailscale)." +echo "For interactive permissions:" +echo " 1. Set NTFY_CALLBACK_HOST to your machine's hostname (e.g., via Tailscale)" +echo " 2. Start the callback service: launchctl load ~/Library/LaunchAgents/$PLIST_NAME" +echo " 3. Ensure your phone can reach the callback URL" echo "" From e4957879808768750f2bb9339a3062ce22e82464 Mon Sep 17 00:00:00 2001 From: Andrew Thal <467872+athal7@users.noreply.github.com> Date: Thu, 1 Jan 2026 17:27:42 -0600 Subject: [PATCH 6/9] docs(#13): update README with callback service instructions - Add section for starting/stopping the callback service - Include launchctl commands and log location - Clarify that service must be running for interactive permissions --- README.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 42a90fa..1646cb7 100644 --- a/README.md +++ b/README.md @@ -60,13 +60,34 @@ export NTFY_CALLBACK_HOST=your-machine.tailnet.ts.net ### Interactive Permissions -Interactive permission notifications require `callbackHost` to be configured. Without it, only read-only notifications (idle, error, retry) are sent. +Interactive permission notifications require `callbackHost` to be configured AND the callback service to be running. Without these, only read-only notifications (idle, error, retry) are sent. + +#### Starting the Callback Service + +The callback service runs persistently to handle permission responses from ntfy. Start it with: + +```bash +# Start the service +launchctl load ~/Library/LaunchAgents/io.opencode.ntfy.plist + +# Check service status +launchctl list | grep opencode + +# View logs +tail -f ~/.local/share/opencode-ntfy/opencode-ntfy.log + +# Stop the service +launchctl unload ~/Library/LaunchAgents/io.opencode.ntfy.plist +``` + +#### Configuring Callback Access For interactive notifications to work, your phone must be able to reach the callback server: 1. Set `callbackHost` to your machine's hostname accessible from your phone 2. For Tailscale users: use your Tailscale hostname (e.g., `macbook.tail1234.ts.net`) 3. Ensure port 4097 (or your configured `callbackPort`) is accessible +4. Start the callback service (see above) ## Notifications From b7c5831ecf869d1c4540360c4e0cacda092313d1 Mon Sep 17 00:00:00 2001 From: Andrew Thal <467872+athal7@users.noreply.github.com> Date: Thu, 1 Jan 2026 17:31:20 -0600 Subject: [PATCH 7/9] fix(#13): address review feedback - Fix launchctl command in plugin log message - Improve node path detection for Apple Silicon Macs - Make createNonce internal in server.js (was exported but unused) - Use isConnected() instead of potentially stale serviceConnected flag - Add defensive validation for permission.id --- install.sh | 15 +++++++++++++-- plugin/index.js | 7 ++++--- service/server.js | 2 +- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/install.sh b/install.sh index 35280f3..12440e8 100755 --- a/install.sh +++ b/install.sh @@ -91,8 +91,19 @@ if [[ "$(uname)" == "Darwin" ]]; then mkdir -p "$PLIST_DIR" - # Find node path - NODE_PATH=$(command -v node 2>/dev/null || echo "/usr/local/bin/node") + # Find node path (handle both Intel and Apple Silicon Macs) + NODE_PATH=$(command -v node 2>/dev/null) + if [[ -z "$NODE_PATH" ]]; then + # Try Homebrew paths + if [[ -x "/opt/homebrew/bin/node" ]]; then + NODE_PATH="/opt/homebrew/bin/node" + elif [[ -x "/usr/local/bin/node" ]]; then + NODE_PATH="/usr/local/bin/node" + else + echo " WARNING: node not found, please install Node.js" + NODE_PATH="/usr/local/bin/node" + fi + fi # Generate plist with correct paths cat > "$PLIST_DIR/$PLIST_NAME" << EOF diff --git a/plugin/index.js b/plugin/index.js index 717469e..7a38df5 100644 --- a/plugin/index.js +++ b/plugin/index.js @@ -79,7 +79,7 @@ export const Notify = async ({ $, client, directory }) => { console.log('[opencode-ntfy] Connected to callback service') } else { console.log('[opencode-ntfy] Callback service not running, interactive permissions disabled') - console.log('[opencode-ntfy] Start service with: brew services start opencode-ntfy') + console.log('[opencode-ntfy] Start service with: launchctl load ~/Library/LaunchAgents/io.opencode.ntfy.plist') } } else { console.log('[opencode-ntfy] Read-only mode (set callbackHost for interactive permissions)') @@ -114,7 +114,7 @@ export const Notify = async ({ $, client, directory }) => { // Handle permission.updated events (interactive permissions) if (event.type === 'permission.updated') { const permission = event.properties?.permission - if (!permission || permission.status !== 'pending') { + if (!permission || permission.status !== 'pending' || !permission.id) { return } @@ -161,7 +161,8 @@ export const Notify = async ({ $, client, directory }) => { idleTimer = null } - if (serviceConnected) { + // Use isConnected() as the source of truth (socket may have closed unexpectedly) + if (isConnected()) { await disconnectFromService() } }, diff --git a/service/server.js b/service/server.js index c12656f..1f4405f 100644 --- a/service/server.js +++ b/service/server.js @@ -32,7 +32,7 @@ const VALID_RESPONSES = ['once', 'always', 'reject'] * @param {string} permissionId - Permission request ID * @returns {string} The generated nonce */ -export function createNonce(sessionId, permissionId) { +function createNonce(sessionId, permissionId) { const nonce = randomUUID() nonces.set(nonce, { sessionId, From 4099a168accef120618d52b58b7ebbf5b6d2ff5b Mon Sep 17 00:00:00 2001 From: Andrew Thal <467872+athal7@users.noreply.github.com> Date: Thu, 1 Jan 2026 18:47:46 -0600 Subject: [PATCH 8/9] docs(#13): add brew services instructions to README Recommend brew services for Homebrew installs, keep launchctl instructions for manual installs. --- README.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1646cb7..53bd462 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,25 @@ Interactive permission notifications require `callbackHost` to be configured AND #### Starting the Callback Service -The callback service runs persistently to handle permission responses from ntfy. Start it with: +The callback service runs persistently to handle permission responses from ntfy. + +**If installed via Homebrew (recommended):** + +```bash +# Start the service (runs at login) +brew services start opencode-ntfy + +# Check service status +brew services info opencode-ntfy + +# View logs +tail -f ~/Library/Logs/Homebrew/opencode-ntfy.log + +# Stop the service +brew services stop opencode-ntfy +``` + +**If installed manually:** ```bash # Start the service From d984c3e8759e1c5dfc9134be7410f3cc16082f77 Mon Sep 17 00:00:00 2001 From: Andrew Thal <467872+athal7@users.noreply.github.com> Date: Thu, 1 Jan 2026 21:15:13 -0600 Subject: [PATCH 9/9] fix(#13): use realpath for main module detection The simple string comparison of import.meta.url vs process.argv[1] fails on macOS due to symlink resolution differences: - /tmp vs /private/tmp - /opt/homebrew/opt vs /opt/homebrew/Cellar This caused brew services to fail because launchd runs the script via the symlinked path but import.meta.url resolves to the real path. --- service/server.js | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/service/server.js b/service/server.js index 1f4405f..91e4e84 100644 --- a/service/server.js +++ b/service/server.js @@ -10,7 +10,8 @@ import { createServer as createHttpServer } from 'http' import { createServer as createNetServer } from 'net' import { randomUUID } from 'crypto' -import { existsSync, unlinkSync } from 'fs' +import { existsSync, unlinkSync, realpathSync } from 'fs' +import { fileURLToPath } from 'url' // Default configuration const DEFAULT_HTTP_PORT = 4097 @@ -353,8 +354,19 @@ export async function stopService(service) { } // If run directly, start the service -const isMainModule = import.meta.url === `file://${process.argv[1]}` -if (isMainModule) { +// Use realpath comparison to handle symlinks (e.g., /tmp vs /private/tmp on macOS, +// or /opt/homebrew/opt vs /opt/homebrew/Cellar) +function isMainModule() { + try { + const currentFile = realpathSync(fileURLToPath(import.meta.url)) + const argvFile = realpathSync(process.argv[1]) + return currentFile === argvFile + } catch { + return false + } +} + +if (isMainModule()) { const config = { httpPort: parseInt(process.env.NTFY_CALLBACK_PORT || '4097', 10), socketPath: process.env.NTFY_SOCKET_PATH || DEFAULT_SOCKET_PATH,