diff --git a/bin/obsidian-idea-intake b/bin/obsidian-idea-intake new file mode 100755 index 000000000..58d497720 --- /dev/null +++ b/bin/obsidian-idea-intake @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { runObsidianIdeaIntakeCli } from '../scripts/obsidian-idea-intake.mjs'; + +runObsidianIdeaIntakeCli().catch((error) => { + process.stdout.write(`${JSON.stringify({ ok: false, error: error?.message || String(error), sideEffects: { obsidianWrites: false, kanbanWrites: false, githubCalls: false, githubWrites: false, telegramSends: false, serviceTimerChanges: false, dispatcherSwarm: false, liveCapturedIdeasAppend: false, tempCapturedIdeasAppend: false } }, null, 2)}\n`); + process.exitCode = 1; +}); diff --git a/bin/public-github-discovery-timer-install b/bin/public-github-discovery-timer-install new file mode 100755 index 000000000..aff03ef4a --- /dev/null +++ b/bin/public-github-discovery-timer-install @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { runPublicGithubDiscoveryTimerCli } from '../scripts/public-github-discovery-timer.mjs'; + +runPublicGithubDiscoveryTimerCli().catch((error) => { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; +}); diff --git a/bin/public-github-discovery-timer-plan b/bin/public-github-discovery-timer-plan new file mode 100755 index 000000000..aff03ef4a --- /dev/null +++ b/bin/public-github-discovery-timer-plan @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { runPublicGithubDiscoveryTimerCli } from '../scripts/public-github-discovery-timer.mjs'; + +runPublicGithubDiscoveryTimerCli().catch((error) => { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; +}); diff --git a/bin/telegram-approval-poll b/bin/telegram-approval-poll new file mode 100755 index 000000000..b39d12b2f --- /dev/null +++ b/bin/telegram-approval-poll @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { runTelegramApprovalPollCli } from '../scripts/telegram-approval-poll.mjs'; + +runTelegramApprovalPollCli().catch((error) => { + process.stdout.write(`${JSON.stringify({ ok: false, error: String(error?.message || error).replace(/bot\d+:[A-Za-z0-9_-]+/g, 'bot[REDACTED]'), offsetUpdated: false, decisionsAppended: 0, decision_rows: [], executor: false, clone: false, fork: false, runCode: false, installDependencies: false, createRepo: false, push: false, prOpen: false, merge: false, delete: false, githubWrites: false, auditAppend: false, durableMutation: false, obsidianKanbanWrites: false, note: 'No executor ran. No approval decision recorded after this error.' }, null, 2)}\n`); + process.exitCode = 1; +}); diff --git a/bin/telegram-approved-discovery-executor b/bin/telegram-approved-discovery-executor new file mode 100755 index 000000000..749520f49 --- /dev/null +++ b/bin/telegram-approved-discovery-executor @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { runTelegramApprovedDiscoveryExecutorCli } from '../scripts/telegram-approved-discovery-executor.mjs'; + +runTelegramApprovedDiscoveryExecutorCli(); diff --git a/bin/telegram-approved-discovery-research b/bin/telegram-approved-discovery-research new file mode 100755 index 000000000..adc68b469 --- /dev/null +++ b/bin/telegram-approved-discovery-research @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +exec node "$(dirname "$0")/../scripts/telegram-approved-discovery-research.mjs" "$@" diff --git a/bin/telegram-approved-implementation-build-plan b/bin/telegram-approved-implementation-build-plan new file mode 100755 index 000000000..7759ce6fc --- /dev/null +++ b/bin/telegram-approved-implementation-build-plan @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { runApprovedImplementationBuildPlanCli } from '../scripts/telegram-approved-implementation-build-plan.mjs' + +runApprovedImplementationBuildPlanCli().catch((error) => { + process.stderr.write(`${error.message}\n`) + process.exitCode = 1 +}) diff --git a/bin/telegram-code-edit-approval-packet b/bin/telegram-code-edit-approval-packet new file mode 100755 index 000000000..2ab70b603 --- /dev/null +++ b/bin/telegram-code-edit-approval-packet @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { runTelegramCodeEditApprovalPacketCli } from '../scripts/telegram-code-edit-approval-packet.mjs' + +runTelegramCodeEditApprovalPacketCli().catch((error) => { + process.stderr.write(`${error.message}\n`) + process.exitCode = 1 +}) diff --git a/bin/telegram-discovery-approval-preview b/bin/telegram-discovery-approval-preview new file mode 100755 index 000000000..1fc6441a9 --- /dev/null +++ b/bin/telegram-discovery-approval-preview @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { runTelegramDiscoveryNotificationCli } from '../scripts/telegram-discovery-notification.mjs'; + +runTelegramDiscoveryNotificationCli().catch((error) => { + process.stdout.write(`${JSON.stringify({ ok: false, sent: false, executed: false, approved: false, rejected: false, error: error.message.replace(/bot\d+:[A-Za-z0-9_-]+/g, 'bot[REDACTED]'), githubCalls: false, githubWrites: false, cloneForkRunInstallCreateRepoPushPrMergeDelete: false, auditAppend: false, durableMutation: false, obsidianKanbanWrites: false, note: 'No approve/reject commands. No action taken.' }, null, 2)}\n`); + process.exitCode = 1; +}); diff --git a/bin/telegram-discovery-approval-send b/bin/telegram-discovery-approval-send new file mode 100755 index 000000000..6924fc592 --- /dev/null +++ b/bin/telegram-discovery-approval-send @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { runTelegramDiscoveryNotificationCli } from '../scripts/telegram-discovery-notification.mjs'; + +runTelegramDiscoveryNotificationCli().catch((error) => { + process.stdout.write(`${JSON.stringify({ ok: false, sent: false, stateAppended: false, executed: false, approved: false, rejected: false, error: error.message.replace(/bot\d+:[A-Za-z0-9_-]+/g, 'bot[REDACTED]'), githubCalls: false, githubWrites: false, cloneForkRunInstallCreateRepoPushPrMergeDelete: false, auditAppend: false, durableMutation: false, obsidianKanbanWrites: false, getUpdatesPolling: false, approveRejectHandling: false, executor: false, note: 'No action taken.' }, null, 2)}\n`); + process.exitCode = 1; +}); diff --git a/bin/telegram-discovery-notification-preview b/bin/telegram-discovery-notification-preview new file mode 100755 index 000000000..a30beed4d --- /dev/null +++ b/bin/telegram-discovery-notification-preview @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { runTelegramDiscoveryNotificationCli } from '../scripts/telegram-discovery-notification.mjs'; + +runTelegramDiscoveryNotificationCli().catch((error) => { + process.stdout.write(`${JSON.stringify({ ok: false, sent: false, error: error.message.replace(/bot\d+:[A-Za-z0-9_-]+/g, 'bot[REDACTED]'), githubCalls: false, githubWrites: false, cloneForkRunInstallCreateRepoPushPrMergeDelete: false, auditAppend: false, durableMutation: false, obsidianKanbanWrites: false, note: 'No action taken.' }, null, 2)}\n`); + process.exitCode = 1; +}); diff --git a/bin/telegram-discovery-notification-send b/bin/telegram-discovery-notification-send new file mode 100755 index 000000000..a30beed4d --- /dev/null +++ b/bin/telegram-discovery-notification-send @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { runTelegramDiscoveryNotificationCli } from '../scripts/telegram-discovery-notification.mjs'; + +runTelegramDiscoveryNotificationCli().catch((error) => { + process.stdout.write(`${JSON.stringify({ ok: false, sent: false, error: error.message.replace(/bot\d+:[A-Za-z0-9_-]+/g, 'bot[REDACTED]'), githubCalls: false, githubWrites: false, cloneForkRunInstallCreateRepoPushPrMergeDelete: false, auditAppend: false, durableMutation: false, obsidianKanbanWrites: false, note: 'No action taken.' }, null, 2)}\n`); + process.exitCode = 1; +}); diff --git a/bin/telegram-message-preview b/bin/telegram-message-preview new file mode 100755 index 000000000..4730eccd8 --- /dev/null +++ b/bin/telegram-message-preview @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { runTelegramMessagePreviewCli } from '../scripts/telegram-message-format.mjs' + +runTelegramMessagePreviewCli().catch((error) => { + process.stdout.write(`${JSON.stringify({ ok: false, blocked: true, error: String(error?.message || error), sent: false }, null, 2)}\n`) + process.exitCode = 1 +}) diff --git a/bin/telegram-notification-credentials-status b/bin/telegram-notification-credentials-status new file mode 100755 index 000000000..8f4a1a4b2 --- /dev/null +++ b/bin/telegram-notification-credentials-status @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { runTelegramChatRegistrationCli } from '../scripts/telegram-chat-registration.mjs'; + +runTelegramChatRegistrationCli().catch((error) => { + process.stdout.write(`${JSON.stringify({ ok: false, error: String(error?.message || error).replace(/bot\d+:[A-Za-z0-9_-]+|\b\d{5,}:[A-Za-z0-9_-]+\b/g, '[REDACTED]'), sentTelegramMessages: false, githubCalls: false, githubWrites: false, auditAppend: false, durableMutation: false, obsidianKanbanWrites: false, approvalActions: false, note: 'No action taken.' }, null, 2)}\n`); + process.exitCode = 1; +}); diff --git a/bin/telegram-register-chat b/bin/telegram-register-chat new file mode 100755 index 000000000..8f4a1a4b2 --- /dev/null +++ b/bin/telegram-register-chat @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { runTelegramChatRegistrationCli } from '../scripts/telegram-chat-registration.mjs'; + +runTelegramChatRegistrationCli().catch((error) => { + process.stdout.write(`${JSON.stringify({ ok: false, error: String(error?.message || error).replace(/bot\d+:[A-Za-z0-9_-]+|\b\d{5,}:[A-Za-z0-9_-]+\b/g, '[REDACTED]'), sentTelegramMessages: false, githubCalls: false, githubWrites: false, auditAppend: false, durableMutation: false, obsidianKanbanWrites: false, approvalActions: false, note: 'No action taken.' }, null, 2)}\n`); + process.exitCode = 1; +}); diff --git a/bin/telegram-research-implementation-proposal b/bin/telegram-research-implementation-proposal new file mode 100755 index 000000000..a344139e8 --- /dev/null +++ b/bin/telegram-research-implementation-proposal @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +exec node "$(dirname "$0")/../scripts/telegram-research-implementation-proposal.mjs" "$@" diff --git a/bin/telegram-source-code-edit-lane b/bin/telegram-source-code-edit-lane new file mode 100755 index 000000000..61a51fff0 --- /dev/null +++ b/bin/telegram-source-code-edit-lane @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { runTelegramSourceCodeEditLaneCli } from '../scripts/telegram-source-code-edit-lane.mjs' + +runTelegramSourceCodeEditLaneCli().catch((error) => { + process.stderr.write(`${error.stack || error.message}\n`) + process.exitCode = 1 +}) diff --git a/package.json b/package.json index f1f23fa71..a5f4029f9 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,25 @@ "license": "MIT", "private": true, "type": "module", + "bin": { + "telegram-message-preview": "./bin/telegram-message-preview", + "public-github-discovery-timer-plan": "./bin/public-github-discovery-timer-plan", + "public-github-discovery-timer-install": "./bin/public-github-discovery-timer-install", + "obsidian-idea-intake": "./bin/obsidian-idea-intake", + "telegram-discovery-notification-preview": "./bin/telegram-discovery-notification-preview", + "telegram-discovery-notification-send": "./bin/telegram-discovery-notification-send", + "telegram-discovery-approval-preview": "./bin/telegram-discovery-approval-preview", + "telegram-discovery-approval-send": "./bin/telegram-discovery-approval-send", + "telegram-register-chat": "./bin/telegram-register-chat", + "telegram-notification-credentials-status": "./bin/telegram-notification-credentials-status", + "telegram-approval-poll": "./bin/telegram-approval-poll", + "telegram-approved-discovery-executor": "./bin/telegram-approved-discovery-executor", + "telegram-approved-discovery-research": "./bin/telegram-approved-discovery-research", + "telegram-research-implementation-proposal": "./bin/telegram-research-implementation-proposal", + "telegram-approved-implementation-build-plan": "./bin/telegram-approved-implementation-build-plan", + "telegram-code-edit-approval-packet": "./bin/telegram-code-edit-approval-packet", + "telegram-source-code-edit-lane": "./bin/telegram-source-code-edit-lane" + }, "main": "electron/main.cjs", "scripts": { "dev": "vite dev", diff --git a/scripts/__snapshots__/telegram-message-format.test.mjs.snap b/scripts/__snapshots__/telegram-message-format.test.mjs.snap new file mode 100644 index 000000000..51bf80427 --- /dev/null +++ b/scripts/__snapshots__/telegram-message-format.test.mjs.snap @@ -0,0 +1,127 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`human-readable Telegram message formatting > formats approval as a short six-part phone message 1`] = ` +"Approval needed + +What happened: Request 90abcdef is ready for learn_from. +Recommendation: Approve only if the scope looks right. +If you approve: I will do only the approved next step. +What will NOT happen: No GitHub writes, clone/fork/install, code edits, or durable mutation. +Details saved in report. +Reply: Approve: /approve 90abcdef +Reject: /reject 90abcdef" +`; + +exports[`human-readable Telegram message formatting > formats approval-recorded as a short six-part phone message 1`] = ` +"Approval recorded + +What happened: approved recorded for 90abcdef. +Recommendation: Next worker may proceed only within the approved scope. +If you approve: Already recorded. No extra reply needed. +What will NOT happen: Recording the reply did not run executor, edit code, call GitHub, or mutate durable store. +Reply: No reply needed." +`; + +exports[`human-readable Telegram message formatting > formats blocked-error as a short six-part phone message 1`] = ` +"Blocked + +What happened: Exact edit scope is missing. +Recommendation: Fix the blocker or send a narrower approval. +If you approve: Approval alone may not continue until the blocker is fixed. +What will NOT happen: No retry, code edit, GitHub call/write, audit append, durable mutation, or Obsidian/Kanban write. +Reply: Reply with corrected scope or say stop." +`; + +exports[`human-readable Telegram message formatting > formats code-edit-approval as a short six-part phone message 1`] = ` +"Code edit approval needed + +What happened: Edit packet edit1 is ready. Files: scripts/telegram-message-format.mjs, bin/telegram-message-preview. +Recommendation: Approve only if these exact files are right. +If you approve: Reply /approve edit1 to allow edits to the exact listed files. +What will NOT happen: No extra files, staging, commit, GitHub call/write, audit append, or durable mutation. +Details saved in report. +Reply: Approve: /approve edit1 +Reject: /reject edit1" +`; + +exports[`human-readable Telegram message formatting > formats completion as a short six-part phone message 1`] = ` +"Execution completed + +What happened: Completed for done1. +Recommendation: Review the result before approving any stage/commit/send step. +If you approve: Reply /approve done1 only if a follow-up approval request asks for it. +What will NOT happen: No staging, commit, push, PR, GitHub write, audit append, or durable mutation happened here. +Details saved in report. +Reply: Reply with the next exact instruction if you want follow-up work." +`; + +exports[`human-readable Telegram message formatting > formats discovery as a short six-part phone message 1`] = ` +"Discovery ready + +What happened: Found owner/repo for Cleaner Telegram approval UX. +Recommendation: learn_from +If you approve: Reply /approve 90abcdef to allow the next read-only step. +What will NOT happen: No clone, fork, GitHub write, code edit, audit append, or Obsidian/Kanban write. +Details saved in report. +Reply: Approve: /approve 90abcdef +Reject: /reject 90abcdef" +`; + +exports[`human-readable Telegram message formatting > formats implementation-proposal-approval as a short six-part phone message 1`] = ` +"Implementation proposal ready + +What happened: Proposal aaaabbbb is ready for review. +Recommendation: Approve only if you want a later build plan; this is not code approval. +If you approve: Reply /approve aaaabbbb to allow a build plan. +What will NOT happen: No code edits, commands, installs, GitHub writes, audit append, or durable mutation. +Details saved in report. +Reply: Approve: /approve aaaabbbb +Reject: /reject aaaabbbb" +`; + +exports[`human-readable Telegram message formatting > telegram-message-preview --type approval --json works without side effects 1`] = ` +"Approval needed + +What happened: Request 90abcdef is ready for learn_from. +Recommendation: Approve only if the scope looks right. +If you approve: I will do only the approved next step. +What will NOT happen: No GitHub writes, clone/fork/install, code edits, or durable mutation. +Details saved in report. +Reply: Approve: /approve 90abcdef +Reject: /reject 90abcdef" +`; + +exports[`human-readable Telegram message formatting > telegram-message-preview --type code-edit-approval --json works without side effects 1`] = ` +"Code edit approval needed + +What happened: Edit packet edit1 is ready. Files: scripts/telegram-message-format.mjs, bin/telegram-message-preview. +Recommendation: Approve only if these exact files are right. +If you approve: Reply /approve edit1 to allow edits to the exact listed files. +What will NOT happen: No extra files, staging, commit, GitHub call/write, audit append, or durable mutation. +Details saved in report. +Reply: Approve: /approve edit1 +Reject: /reject edit1" +`; + +exports[`human-readable Telegram message formatting > telegram-message-preview --type completion --json works without side effects 1`] = ` +"Execution completed + +What happened: Completed for done1. +Recommendation: Review the result before approving any stage/commit/send step. +If you approve: Reply /approve done1 only if a follow-up approval request asks for it. +What will NOT happen: No staging, commit, push, PR, GitHub write, audit append, or durable mutation happened here. +Details saved in report. +Reply: Reply with the next exact instruction if you want follow-up work." +`; + +exports[`human-readable Telegram message formatting > telegram-message-preview --type discovery --json works without side effects 1`] = ` +"Discovery ready + +What happened: Found owner/repo for Cleaner Telegram approval UX. +Recommendation: learn_from +If you approve: Reply /approve 90abcdef to allow the next read-only step. +What will NOT happen: No clone, fork, GitHub write, code edit, audit append, or Obsidian/Kanban write. +Details saved in report. +Reply: Approve: /approve 90abcdef +Reject: /reject 90abcdef" +`; diff --git a/scripts/obsidian-idea-intake.mjs b/scripts/obsidian-idea-intake.mjs new file mode 100644 index 000000000..1d29ce0fd --- /dev/null +++ b/scripts/obsidian-idea-intake.mjs @@ -0,0 +1,320 @@ +#!/usr/bin/env node +import crypto from 'node:crypto'; +import { constants as fsConstants } from 'node:fs'; +import { access, appendFile, mkdir, readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const DEFAULT_OBSIDIAN_IDEAS_DIR = '/mnt/lachlan-pc-obsidian/LifeOS Vault/01 Ideas'; +export const DEFAULT_CAPTURED_IDEAS_JSONL = '/root/.hermes/runtime/captured-ideas.jsonl'; +export const SOURCE_KIND = 'obsidian_01_ideas'; + +const CONTROL_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g; + +function sha256(value) { + return crypto.createHash('sha256').update(value, 'utf8').digest('hex'); +} + +function normalizeWhitespace(value) { + return String(value || '') + .replace(CONTROL_CHARS, '') + .replace(/\r\n?/g, '\n') + .split('\n') + .map((line) => line.replace(/[ \t]+$/g, '')) + .join('\n') + .replace(/\n{4,}/g, '\n\n\n') + .trim(); +} + +export function sanitizeTitle(value) { + const cleaned = normalizeWhitespace(value) + .replace(/^#+\s+/u, '') + .replace(/[\[\]{}<>]/g, '') + .replace(/\s+/g, ' ') + .trim(); + return cleaned.slice(0, 180) || 'Untitled idea'; +} + +export function sanitizeBody(value) { + return normalizeWhitespace(value).slice(0, 20_000); +} + +function stripFrontmatter(markdown) { + if (!markdown.startsWith('---\n')) return markdown; + const end = markdown.indexOf('\n---', 4); + if (end === -1) return markdown; + const after = markdown.slice(end + 4); + return after.startsWith('\n') ? after.slice(1) : after; +} + +function splitTitleAndBody(markdown, fallbackName) { + const withoutFrontmatter = stripFrontmatter(markdown); + const lines = withoutFrontmatter.split(/\n/); + const h1Index = lines.findIndex((line) => /^#\s+\S/.test(line)); + if (h1Index >= 0) { + const title = lines[h1Index].replace(/^#\s+/, ''); + const body = [...lines.slice(0, h1Index), ...lines.slice(h1Index + 1)].join('\n'); + return { title, body }; + } + return { title: fallbackName.replace(/\.md$/i, ''), body: withoutFrontmatter }; +} + +function assertInsideSource(sourceDir, notePath) { + const source = path.resolve(sourceDir); + const note = path.resolve(notePath); + if (note !== source && !note.startsWith(`${source}${path.sep}`)) { + throw new Error(`Refusing note outside source directory: ${note}`); + } +} + +export async function parseIdeaNote({ sourceDir, notePath, capturedAt }) { + assertInsideSource(sourceDir, notePath); + const markdown = await readFile(notePath, 'utf8'); + const { title, body } = splitTitleAndBody(markdown, path.basename(notePath)); + const ideaTitle = sanitizeTitle(title); + const ideaBody = sanitizeBody(body); + const relativePath = path.relative(path.resolve(sourceDir), path.resolve(notePath)); + const contentHash = sha256(JSON.stringify({ idea_title: ideaTitle, idea_body: ideaBody, source_kind: SOURCE_KIND })); + return { + idea_id: `obsidea_${contentHash.slice(0, 20)}`, + idea_title: ideaTitle, + idea_body: ideaBody, + source: { + kind: SOURCE_KIND, + path: path.resolve(notePath), + relative_path: relativePath, + }, + captured_at: capturedAt, + content_hash: contentHash, + sanitized: true, + }; +} + +async function walkMarkdownFiles(dir) { + const entries = await readdir(dir, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + if (entry.name.startsWith('.')) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) files.push(...await walkMarkdownFiles(full)); + else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) files.push(full); + } + return files.sort((a, b) => a.localeCompare(b)); +} + +export async function readExistingCapturedIdeas(jsonlPath) { + const hashes = new Set(); + const ids = new Set(); + try { + const text = await readFile(jsonlPath, 'utf8'); + const lines = text.split(/\n/).filter((line) => line.trim().length > 0); + lines.forEach((line, index) => { + let parsed; + try { + parsed = JSON.parse(line); + } catch (error) { + throw new Error(`Malformed captured ideas JSONL at line ${index + 1}: ${error.message}`); + } + if (!parsed || typeof parsed !== 'object') throw new Error(`Malformed captured ideas JSONL at line ${index + 1}: record is not an object`); + if (typeof parsed.content_hash === 'string') hashes.add(parsed.content_hash); + if (typeof parsed.idea_id === 'string') ids.add(parsed.idea_id); + }); + } catch (error) { + if (error && error.code === 'ENOENT') return { hashes, ids, exists: false }; + throw error; + } + return { hashes, ids, exists: true }; +} + +function validateRecord(record) { + const requiredStrings = ['idea_id', 'idea_title', 'idea_body', 'captured_at', 'content_hash']; + for (const key of requiredStrings) { + if (typeof record[key] !== 'string') throw new Error(`Invalid captured idea record: ${key} must be a string`); + } + if (!record.source || typeof record.source !== 'object') throw new Error('Invalid captured idea record: source must be an object'); + if (record.sanitized !== true) throw new Error('Invalid captured idea record: sanitized must be true'); +} + +function isDefaultLiveTarget(targetPath) { + return path.resolve(targetPath) === path.resolve(DEFAULT_CAPTURED_IDEAS_JSONL); +} + +function isDefaultLiveSource(sourceDir) { + return path.resolve(sourceDir) === path.resolve(DEFAULT_OBSIDIAN_IDEAS_DIR); +} + +export async function collectObsidianIdeaRecords({ + sourceDir = DEFAULT_OBSIDIAN_IDEAS_DIR, + jsonlPath = DEFAULT_CAPTURED_IDEAS_JSONL, + capturedAt = new Date().toISOString(), +} = {}) { + await access(sourceDir, fsConstants.R_OK); + const existing = await readExistingCapturedIdeas(jsonlPath); + const files = await walkMarkdownFiles(sourceDir); + const records = []; + const duplicates = []; + for (const notePath of files) { + const record = await parseIdeaNote({ sourceDir, notePath, capturedAt }); + validateRecord(record); + if (existing.hashes.has(record.content_hash) || existing.ids.has(record.idea_id)) { + duplicates.push({ path: notePath, idea_id: record.idea_id, content_hash: record.content_hash }); + } else { + records.push(record); + } + } + return { + ok: true, + mode: 'dry-run', + sourceDir: path.resolve(sourceDir), + jsonlPath: path.resolve(jsonlPath), + scanned: files.length, + duplicateCount: duplicates.length, + appendCount: records.length, + records, + duplicates, + sideEffects: sideEffects(false), + }; +} + +function sideEffects(appended) { + return { + obsidianWrites: false, + kanbanWrites: false, + githubCalls: false, + githubWrites: false, + telegramSends: false, + serviceTimerChanges: false, + dispatcherSwarm: false, + liveCapturedIdeasAppend: false, + tempCapturedIdeasAppend: appended, + }; +} + +function isTempPath(targetPath) { + const resolved = path.resolve(targetPath); + const tmp = path.resolve(process.env.TMPDIR || '/tmp'); + return resolved === tmp || resolved.startsWith(`${tmp}${path.sep}`); +} + +export async function appendObsidianIdeaRecords({ + sourceDir = DEFAULT_OBSIDIAN_IDEAS_DIR, + jsonlPath, + capturedAt = new Date().toISOString(), + allowLiveAppend = false, +} = {}) { + if (!jsonlPath) throw new Error('--jsonl is required for append mode'); + if (isDefaultLiveTarget(jsonlPath) && !allowLiveAppend) { + throw new Error('Refusing live append to /root/.hermes/runtime/captured-ideas.jsonl without --allow-live-append'); + } + if (!isTempPath(jsonlPath) && !allowLiveAppend) { + throw new Error('Refusing append outside temp JSONL without --allow-live-append'); + } + if (isDefaultLiveSource(sourceDir) && allowLiveAppend !== true && !isTempPath(jsonlPath)) { + throw new Error('Refusing live Obsidian source append outside temp JSONL'); + } + + const result = await collectObsidianIdeaRecords({ sourceDir, jsonlPath, capturedAt }); + const targetDir = path.dirname(path.resolve(jsonlPath)); + await mkdir(targetDir, { recursive: true }); + if (result.records.length > 0) { + const payload = result.records.map((record) => `${JSON.stringify(record)}\n`).join(''); + await appendFile(jsonlPath, payload, 'utf8'); + } + return { + ...result, + mode: 'append', + wrote: result.records.length > 0 ? [path.resolve(jsonlPath)] : [], + sideEffects: sideEffects(true), + }; +} + +function parseArgs(argv) { + const args = { + json: false, + dryRun: false, + append: false, + sourceDir: DEFAULT_OBSIDIAN_IDEAS_DIR, + jsonlPath: DEFAULT_CAPTURED_IDEAS_JSONL, + capturedAt: undefined, + allowLiveAppend: false, + requireReadonlySource: false, + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--json') args.json = true; + else if (arg === '--dry-run') args.dryRun = true; + else if (arg === '--append') args.append = true; + else if (arg === '--allow-live-append') args.allowLiveAppend = true; + else if (arg === '--require-readonly-source') args.requireReadonlySource = true; + else if (arg === '--source-dir') { + i += 1; + if (!argv[i]) throw new Error('--source-dir requires a value'); + args.sourceDir = argv[i]; + } else if (arg === '--jsonl') { + i += 1; + if (!argv[i]) throw new Error('--jsonl requires a value'); + args.jsonlPath = argv[i]; + } else if (arg === '--captured-at') { + i += 1; + if (!argv[i]) throw new Error('--captured-at requires a value'); + args.capturedAt = argv[i]; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + return args; +} + +async function sourceIsWritable(sourceDir) { + try { + await access(sourceDir, fsConstants.W_OK); + return true; + } catch { + return false; + } +} + +async function assertReadonlySourceIfRequested(args) { + if (!args.requireReadonlySource) return; + if (!isDefaultLiveSource(args.sourceDir)) return; + const writable = await sourceIsWritable(args.sourceDir); + if (writable) throw new Error('Refusing live Obsidian dry-run because source mount is writable; expected read-only mount.'); +} + +function assertJson(args) { + if (!args.json) throw new Error('This CLI is intentionally JSON-only. Pass --json.'); +} + +function render(value) { + return `${JSON.stringify(value, null, 2)}\n`; +} + +export async function runObsidianIdeaIntakeCli(argv = process.argv.slice(2)) { + const args = parseArgs(argv); + assertJson(args); + if (args.append && args.dryRun) throw new Error('Choose either --dry-run or --append, not both'); + if (!args.append) args.dryRun = true; + await assertReadonlySourceIfRequested(args); + const capturedAt = args.capturedAt || new Date().toISOString(); + const result = args.append + ? await appendObsidianIdeaRecords({ + sourceDir: args.sourceDir, + jsonlPath: args.jsonlPath, + capturedAt, + allowLiveAppend: args.allowLiveAppend, + }) + : await collectObsidianIdeaRecords({ sourceDir: args.sourceDir, jsonlPath: args.jsonlPath, capturedAt }); + process.stdout.write(render(result)); +} + +const thisFile = fileURLToPath(import.meta.url); +if (process.argv[1] && path.resolve(process.argv[1]) === thisFile) { + runObsidianIdeaIntakeCli().catch((error) => { + process.stdout.write(render({ + ok: false, + error: error?.message || String(error), + sideEffects: sideEffects(false), + })); + process.exitCode = 1; + }); +} diff --git a/scripts/obsidian-idea-intake.test.mjs b/scripts/obsidian-idea-intake.test.mjs new file mode 100644 index 000000000..5941841a4 --- /dev/null +++ b/scripts/obsidian-idea-intake.test.mjs @@ -0,0 +1,134 @@ +import { execFile } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; + +import { + appendObsidianIdeaRecords, + collectObsidianIdeaRecords, +} from './obsidian-idea-intake.mjs'; + +const execFileAsync = promisify(execFile); +const repoRoot = path.resolve(new URL('..', import.meta.url).pathname); +const capturedAt = '2026-07-05T00:00:00.000Z'; + +async function makeFixture() { + const root = await mkdtemp(path.join(os.tmpdir(), 'obs-idea-fixture-')); + const ideas = path.join(root, '01 Ideas'); + await mkdir(path.join(ideas, 'Nested'), { recursive: true }); + await writeFile(path.join(ideas, 'Alpha.md'), '---\nprivate: yes\n---\n# Alpha \n\nBody with\u0000 control.\n\n[[Wiki Link]]\n', 'utf8'); + await writeFile(path.join(ideas, 'Nested', 'Beta.md'), 'No heading body\nsecond line\n', 'utf8'); + await writeFile(path.join(ideas, '.ignored.md'), '# Ignored\n', 'utf8'); + return { root, ideas }; +} + +async function runCli(args) { + const { stdout, stderr } = await execFileAsync(path.join(repoRoot, 'bin/obsidian-idea-intake'), args, { + cwd: repoRoot, + env: { ...process.env, GITHUB_TOKEN: 'must-not-be-read', TELEGRAM_BOT_TOKEN: 'must-not-be-read' }, + }); + expect(stderr).toBe(''); + return JSON.parse(stdout); +} + +describe('Obsidian idea intake bridge', () => { + it('temp fixture dry-run emits sanitized captured idea records without writes', async () => { + const { root, ideas } = await makeFixture(); + const jsonl = path.join(root, 'captured-ideas.jsonl'); + try { + const result = await runCli(['--dry-run', '--json', '--source-dir', ideas, '--jsonl', jsonl, '--captured-at', capturedAt]); + + expect(result.ok).toBe(true); + expect(result.mode).toBe('dry-run'); + expect(result.scanned).toBe(2); + expect(result.appendCount).toBe(2); + expect(result.duplicateCount).toBe(0); + expect(result.records).toHaveLength(2); + expect(result.records[0]).toMatchObject({ + idea_title: 'Alpha Idea', + captured_at: capturedAt, + sanitized: true, + }); + expect(result.records[0].idea_body).not.toContain('\u0000'); + expect(result.records[0].content_hash).toMatch(/^[a-f0-9]{64}$/); + expect(result.records[0].idea_id).toBe(`obsidea_${result.records[0].content_hash.slice(0, 20)}`); + await expect(readFile(jsonl, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + expect(result.sideEffects).toMatchObject({ + obsidianWrites: false, + kanbanWrites: false, + githubCalls: false, + githubWrites: false, + telegramSends: false, + serviceTimerChanges: false, + dispatcherSwarm: false, + liveCapturedIdeasAppend: false, + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('temp fixture append writes only to temp JSONL', async () => { + const { root, ideas } = await makeFixture(); + const jsonl = path.join(root, 'runtime', 'captured-ideas.jsonl'); + try { + const result = await appendObsidianIdeaRecords({ sourceDir: ideas, jsonlPath: jsonl, capturedAt }); + expect(result.mode).toBe('append'); + expect(result.wrote).toEqual([jsonl]); + expect(result.sideEffects.tempCapturedIdeasAppend).toBe(true); + expect(result.sideEffects.liveCapturedIdeasAppend).toBe(false); + const lines = (await readFile(jsonl, 'utf8')).trim().split('\n'); + expect(lines).toHaveLength(2); + for (const line of lines) { + const parsed = JSON.parse(line); + expect(Object.keys(parsed).sort()).toEqual(['captured_at', 'content_hash', 'idea_body', 'idea_id', 'idea_title', 'sanitized', 'source'].sort()); + expect(parsed.sanitized).toBe(true); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('duplicate prevention skips records already present in captured ideas JSONL', async () => { + const { root, ideas } = await makeFixture(); + const jsonl = path.join(root, 'captured-ideas.jsonl'); + try { + const first = await appendObsidianIdeaRecords({ sourceDir: ideas, jsonlPath: jsonl, capturedAt }); + expect(first.appendCount).toBe(2); + const second = await collectObsidianIdeaRecords({ sourceDir: ideas, jsonlPath: jsonl, capturedAt: '2026-07-06T00:00:00.000Z' }); + expect(second.records).toEqual([]); + expect(second.appendCount).toBe(0); + expect(second.duplicateCount).toBe(2); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('malformed JSONL fails closed before append', async () => { + const { root, ideas } = await makeFixture(); + const jsonl = path.join(root, 'bad.jsonl'); + try { + await writeFile(jsonl, '{"content_hash":"ok"}\nnot-json\n', 'utf8'); + await expect(appendObsidianIdeaRecords({ sourceDir: ideas, jsonlPath: jsonl, capturedAt })).rejects.toThrow('Malformed captured ideas JSONL at line 2'); + const text = await readFile(jsonl, 'utf8'); + expect(text).toBe('{"content_hash":"ok"}\nnot-json\n'); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('refuses live captured ideas append without explicit approval flag', async () => { + const { root, ideas } = await makeFixture(); + try { + await expect(appendObsidianIdeaRecords({ + sourceDir: ideas, + jsonlPath: '/root/.hermes/runtime/captured-ideas.jsonl', + capturedAt, + })).rejects.toThrow('Refusing live append'); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/public-github-discovery-timer.mjs b/scripts/public-github-discovery-timer.mjs new file mode 100644 index 000000000..5f8586b2d --- /dev/null +++ b/scripts/public-github-discovery-timer.mjs @@ -0,0 +1,190 @@ +#!/usr/bin/env node +import { execFile } from 'node:child_process'; +import { constants as fsConstants } from 'node:fs'; +import { access, mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +export const SERVICE_UNIT_NAME = 'hermes-public-github-discovery-worker.service'; +export const TIMER_UNIT_NAME = 'hermes-public-github-discovery-worker.timer'; +export const PYTHON_COMMAND = '/usr/bin/python3'; +export const GITHUB_CONNECTOR_SCRIPT = '/root/.hermes/scripts/github_connector.py'; +export const WORKER_SUBCOMMAND = 'public-github-discovery-worker'; +export const WORKER_ARGS = ['--limit', '3', '--json']; +export const PREFLIGHT_ARGS = ['--dry-run', '--json']; + +const execFileAsync = promisify(execFile); +const WORKER_COMMAND = [PYTHON_COMMAND, GITHUB_CONNECTOR_SCRIPT, WORKER_SUBCOMMAND, ...WORKER_ARGS]; +const PREFLIGHT_COMMAND = [PYTHON_COMMAND, GITHUB_CONNECTOR_SCRIPT, WORKER_SUBCOMMAND, ...PREFLIGHT_ARGS]; + +const SERVICE_UNIT = `[Unit] +Description=Hermes public GitHub discovery worker +Documentation=https://hermes-agent.nousresearch.com/docs + +[Service] +Type=oneshot +RuntimeDirectory=hermes-public-github-discovery-worker +RuntimeDirectoryMode=0700 +ExecStartPre=/bin/sleep 120 +ExecStart=/usr/bin/flock -n /run/hermes-public-github-discovery-worker/worker.lock ${WORKER_COMMAND.join(' ')} +`; + +const TIMER_UNIT = `[Unit] +Description=Run Hermes public GitHub discovery worker every 30 minutes (disabled by default) +Documentation=https://hermes-agent.nousresearch.com/docs + +[Timer] +OnCalendar=*:0/30 +AccuracySec=1s +RandomizedDelaySec=3min +Persistent=false +Unit=${SERVICE_UNIT_NAME} + +[Install] +WantedBy=timers.target +`; + +export function getPublicGithubDiscoveryTimerPlan({ unitDir = '/etc/systemd/system' } = {}) { + const resolvedUnitDir = path.resolve(unitDir); + return { + defaultState: 'disabled', + enableTimer: false, + startTimer: false, + runWorker: false, + githubCalls: false, + reportWrites: false, + indexWrites: false, + auditWrites: false, + durableStoreWrites: false, + workerCommand: WORKER_COMMAND, + preflightCommand: PREFLIGHT_COMMAND, + schedule: { + onCalendar: '*:0/30', + everyMinutes: 30, + randomizedDelay: '2-5 minutes', + randomizedDelayImplementation: 'ExecStartPre=/bin/sleep 120 plus RandomizedDelaySec=3min', + nonOverlap: 'same systemd service instance plus flock -n /run/hermes-public-github-discovery-worker/worker.lock', + }, + units: [ + { + name: SERVICE_UNIT_NAME, + path: path.join(resolvedUnitDir, SERVICE_UNIT_NAME), + content: SERVICE_UNIT, + }, + { + name: TIMER_UNIT_NAME, + path: path.join(resolvedUnitDir, TIMER_UNIT_NAME), + content: TIMER_UNIT, + }, + ], + }; +} + +export async function preflightPublicGithubDiscoveryWorker({ + pythonPath = PYTHON_COMMAND, + connectorPath = GITHUB_CONNECTOR_SCRIPT, +} = {}) { + await access(pythonPath, fsConstants.X_OK); + await access(connectorPath, fsConstants.R_OK); + const { stdout, stderr } = await execFileAsync(pythonPath, [connectorPath, WORKER_SUBCOMMAND, ...PREFLIGHT_ARGS], { + timeout: 120_000, + maxBuffer: 1024 * 1024, + }); + const parsed = JSON.parse(stdout); + return { + ok: true, + rc: 0, + stderr, + command: [pythonPath, connectorPath, WORKER_SUBCOMMAND, ...PREFLIGHT_ARGS], + verdict: parsed.verdict, + githubWrites: parsed?.result?.github_write === true, + auditWrites: parsed?.result?.audit_append === true, + durableStoreWrites: parsed?.result?.durable_mutation === true, + reportWrites: Number(parsed?.result?.reports_saved || 0) > 0, + indexWrites: Number(parsed?.result?.index_rows_appended_count || 0) > 0, + raw: parsed, + }; +} + +function parseArgs(argv) { + const args = { json: false, dryRun: false, unitDir: '/etc/systemd/system', allowSystemDir: false }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--json') args.json = true; + else if (arg === '--dry-run') args.dryRun = true; + else if (arg === '--allow-system-dir') args.allowSystemDir = true; + else if (arg === '--unit-dir') { + i += 1; + if (!argv[i]) throw new Error('--unit-dir requires a value'); + args.unitDir = argv[i]; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + return args; +} + +function assertJson(args) { + if (!args.json) throw new Error('This CLI is intentionally JSON-only. Pass --json.'); +} + +function isTempPath(targetPath) { + const resolved = path.resolve(targetPath); + const tmp = path.resolve(process.env.TMPDIR || '/tmp'); + return resolved === tmp || resolved.startsWith(`${tmp}${path.sep}`); +} + +export async function installPublicGithubDiscoveryTimer({ unitDir = '/etc/systemd/system', dryRun, allowSystemDir = false } = {}) { + const plan = getPublicGithubDiscoveryTimerPlan({ unitDir }); + if (dryRun) { + return { action: 'dry-run', wrote: [], enabled: false, started: false, workerExecuted: false, ...plan }; + } + + if (!allowSystemDir && !isTempPath(unitDir)) { + throw new Error('Refusing to write outside a temp unit directory without --allow-system-dir. This installer never enables or starts the timer.'); + } + + await mkdir(unitDir, { recursive: true }); + const wrote = []; + for (const unit of plan.units) { + await writeFile(unit.path, unit.content, { encoding: 'utf8', mode: 0o644 }); + wrote.push(unit.path); + } + return { action: 'install', wrote, enabled: false, started: false, workerExecuted: false, ...plan }; +} + +function render(result) { + return `${JSON.stringify(result, null, 2)}\n`; +} + +export async function runPublicGithubDiscoveryTimerCli(argv = process.argv.slice(2), invokedPath = process.argv[1] || '') { + const invokedAs = path.basename(invokedPath); + const args = parseArgs(argv); + assertJson(args); + + if (invokedAs.endsWith('public-github-discovery-timer-plan')) { + process.stdout.write(render(getPublicGithubDiscoveryTimerPlan({ unitDir: args.unitDir }))); + return; + } + + if (invokedAs.endsWith('public-github-discovery-timer-install')) { + const result = await installPublicGithubDiscoveryTimer({ + unitDir: args.unitDir, + dryRun: args.dryRun, + allowSystemDir: args.allowSystemDir, + }); + process.stdout.write(render(result)); + return; + } + + throw new Error('Invoke as public-github-discovery-timer-plan or public-github-discovery-timer-install'); +} + +const thisFile = fileURLToPath(import.meta.url); +if (process.argv[1] && path.resolve(process.argv[1]) === thisFile) { + runPublicGithubDiscoveryTimerCli().catch((error) => { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/scripts/public-github-discovery-timer.test.mjs b/scripts/public-github-discovery-timer.test.mjs new file mode 100644 index 000000000..5cbda57ee --- /dev/null +++ b/scripts/public-github-discovery-timer.test.mjs @@ -0,0 +1,147 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; + +import { + GITHUB_CONNECTOR_SCRIPT, + PYTHON_COMMAND, + SERVICE_UNIT_NAME, + TIMER_UNIT_NAME, + getPublicGithubDiscoveryTimerPlan, + installPublicGithubDiscoveryTimer, + preflightPublicGithubDiscoveryWorker, +} from './public-github-discovery-timer.mjs'; + +const execFileAsync = promisify(execFile); +const repoRoot = path.resolve(new URL('..', import.meta.url).pathname); + +async function runCli(relativeBin, args) { + const { stdout, stderr } = await execFileAsync(path.join(repoRoot, relativeBin), args, { + cwd: repoRoot, + env: { ...process.env, PATH: `/tmp/nonexistent-worker-bin:${process.env.PATH || ''}` }, + }); + expect(stderr).toBe(''); + return JSON.parse(stdout); +} + +describe('public GitHub discovery systemd timer packaging', () => { + it('plan CLI emits exact disabled-by-default unit content without side-effect flags', async () => { + const plan = await runCli('bin/public-github-discovery-timer-plan', ['--json']); + + expect(plan.defaultState).toBe('disabled'); + expect(plan.enableTimer).toBe(false); + expect(plan.startTimer).toBe(false); + expect(plan.runWorker).toBe(false); + expect(plan.githubCalls).toBe(false); + expect(plan.auditWrites).toBe(false); + expect(plan.reportWrites).toBe(false); + expect(plan.indexWrites).toBe(false); + expect(plan.durableStoreWrites).toBe(false); + expect(plan.workerCommand).toEqual([ + PYTHON_COMMAND, + GITHUB_CONNECTOR_SCRIPT, + 'public-github-discovery-worker', + '--limit', + '3', + '--json', + ]); + expect(plan.preflightCommand).toEqual([ + PYTHON_COMMAND, + GITHUB_CONNECTOR_SCRIPT, + 'public-github-discovery-worker', + '--dry-run', + '--json', + ]); + expect(plan.schedule).toMatchObject({ + everyMinutes: 30, + randomizedDelay: '2-5 minutes', + }); + + const service = plan.units.find((unit) => unit.name === SERVICE_UNIT_NAME); + const timer = plan.units.find((unit) => unit.name === TIMER_UNIT_NAME); + expect(service.content).toBe(`[Unit]\nDescription=Hermes public GitHub discovery worker\nDocumentation=https://hermes-agent.nousresearch.com/docs\n\n[Service]\nType=oneshot\nRuntimeDirectory=hermes-public-github-discovery-worker\nRuntimeDirectoryMode=0700\nExecStartPre=/bin/sleep 120\nExecStart=/usr/bin/flock -n /run/hermes-public-github-discovery-worker/worker.lock ${PYTHON_COMMAND} ${GITHUB_CONNECTOR_SCRIPT} public-github-discovery-worker --limit 3 --json\n`); + expect(timer.content).toBe(`[Unit]\nDescription=Run Hermes public GitHub discovery worker every 30 minutes (disabled by default)\nDocumentation=https://hermes-agent.nousresearch.com/docs\n\n[Timer]\nOnCalendar=*:0/30\nAccuracySec=1s\nRandomizedDelaySec=3min\nPersistent=false\nUnit=${SERVICE_UNIT_NAME}\n\n[Install]\nWantedBy=timers.target\n`); + expect(`${service.content}\n${timer.content}`).not.toMatch(/systemctl\s+(enable|start)|gh\s+|git\s+|github\.com|audit|report|index/i); + }); + + it('install dry-run CLI emits exact units and writes nothing', async () => { + const unitDir = await mkdtemp(path.join(os.tmpdir(), 'gh-disc-timer-dry-')); + try { + const before = await readdir(unitDir); + const result = await runCli('bin/public-github-discovery-timer-install', ['--dry-run', '--json', '--unit-dir', unitDir]); + const after = await readdir(unitDir); + + expect(before).toEqual([]); + expect(after).toEqual([]); + expect(result.action).toBe('dry-run'); + expect(result.wrote).toEqual([]); + expect(result.enabled).toBe(false); + expect(result.started).toBe(false); + expect(result.workerExecuted).toBe(false); + expect(result.units.map((unit) => unit.path).sort()).toEqual([ + path.join(unitDir, SERVICE_UNIT_NAME), + path.join(unitDir, TIMER_UNIT_NAME), + ].sort()); + } finally { + await rm(unitDir, { recursive: true, force: true }); + } + }); + + it('install writes only approved temp unit paths and never enables, starts, or runs the worker', async () => { + const unitDir = await mkdtemp(path.join(os.tmpdir(), 'gh-disc-timer-install-')); + try { + const auditBefore = getPublicGithubDiscoveryTimerPlan().auditWrites; + const durableBefore = getPublicGithubDiscoveryTimerPlan().durableStoreWrites; + const result = await installPublicGithubDiscoveryTimer({ unitDir, dryRun: false }); + + expect(result.action).toBe('install'); + expect(result.enabled).toBe(false); + expect(result.started).toBe(false); + expect(result.workerExecuted).toBe(false); + expect(result.githubCalls).toBe(false); + expect(result.auditWrites).toBe(auditBefore); + expect(result.durableStoreWrites).toBe(durableBefore); + expect(result.wrote.sort()).toEqual([ + path.join(unitDir, SERVICE_UNIT_NAME), + path.join(unitDir, TIMER_UNIT_NAME), + ].sort()); + expect((await readdir(unitDir)).sort()).toEqual([SERVICE_UNIT_NAME, TIMER_UNIT_NAME].sort()); + for (const unit of result.units) { + expect(await readFile(unit.path, 'utf8')).toBe(unit.content); + expect((await stat(unit.path)).isFile()).toBe(true); + } + } finally { + await rm(unitDir, { recursive: true, force: true }); + } + }); + + it('preflight runs the absolute dry-run worker command without writes', async () => { + const result = await preflightPublicGithubDiscoveryWorker(); + + expect(result.ok).toBe(true); + expect(result.rc).toBe(0); + expect(result.stderr).toBe(''); + expect(result.command).toEqual([ + PYTHON_COMMAND, + GITHUB_CONNECTOR_SCRIPT, + 'public-github-discovery-worker', + '--dry-run', + '--json', + ]); + expect(result.verdict).toBe('PASS_PUBLIC_GITHUB_DISCOVERY_WORKER_DRY_RUN'); + expect(result.githubWrites).toBe(false); + expect(result.auditWrites).toBe(false); + expect(result.durableStoreWrites).toBe(false); + expect(result.reportWrites).toBe(false); + expect(result.indexWrites).toBe(false); + }); + + it('refuses non-temp writes unless explicitly allowed', async () => { + await expect(installPublicGithubDiscoveryTimer({ unitDir: '/etc/systemd/system', dryRun: false })).rejects.toThrow( + 'Refusing to write outside a temp unit directory', + ); + }); +}); diff --git a/scripts/telegram-approval-poll.mjs b/scripts/telegram-approval-poll.mjs new file mode 100644 index 000000000..70b1f6b40 --- /dev/null +++ b/scripts/telegram-approval-poll.mjs @@ -0,0 +1,405 @@ +#!/usr/bin/env node +import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { formatHumanTelegramMessage } from './telegram-message-format.mjs'; + +export const DEFAULT_TELEGRAM_APPROVAL_REQUESTS_PATH = '/root/.hermes/reports/github-discovery/telegram-approval-requests.jsonl'; +export const DEFAULT_TELEGRAM_CODE_EDIT_APPROVAL_PACKETS_PATH = '/root/.hermes/reports/github-discovery/telegram-code-edit-approval-packets.jsonl'; +export const DEFAULT_TELEGRAM_APPROVAL_DECISIONS_PATH = '/root/.hermes/reports/github-discovery/telegram-approval-decisions.jsonl'; +export const DEFAULT_TELEGRAM_APPROVAL_OFFSET_PATH = '/root/.hermes/reports/github-discovery/telegram-approval-offset.json'; +export const LEGACY_CODE_EDIT_APPROVAL_ID = 'code_edit_build_impl_tg4_d3fd7da71ae557f3_8f61483465_d7420c166e_a033e3539084'; + +const SIDE_EFFECT_FLAGS = Object.freeze({ + executor: false, + clone: false, + fork: false, + runCode: false, + installDependencies: false, + createRepo: false, + push: false, + prOpen: false, + merge: false, + delete: false, + githubWrites: false, + auditAppend: false, + durableMutation: false, + obsidianKanbanWrites: false, +}); + +function parseArgs(argv) { + const args = { _: [] }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--json') args.json = true; + else if (arg === '--dry-run') args.dryRun = true; + else if (arg === '--requests-path') args.requestsPath = argv[++i]; + else if (arg === '--code-edit-approval-packets-path') args.codeEditApprovalPacketsPath = argv[++i]; + else if (arg === '--decisions-path') args.decisionsPath = argv[++i]; + else if (arg === '--offset-path') args.offsetPath = argv[++i]; + else if (arg === '--env-file') args.envFile = argv[++i]; + else if (arg === '--bot-token') args.botToken = argv[++i]; + else if (arg === '--chat-id') args.chatId = argv[++i]; + else if (arg === '--now') args.now = argv[++i]; + else args._.push(arg); + } + return args; +} + +function requireJson(args) { + if (!args.json) throw new Error('telegram-approval-poll is intentionally JSON-only. Pass --json.'); +} + +function sanitizeError(error) { + return String(error?.message || error || 'unknown error') + .replace(/bot\d+:[A-Za-z0-9_-]+/g, 'bot[REDACTED]') + .replace(/(botToken|chatId|chat_id|token)=[^\s&]+/gi, '$1=[REDACTED]'); +} + +function parseEnvContent(content) { + const parsed = {}; + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const index = trimmed.indexOf('='); + if (index === -1) continue; + const key = trimmed.slice(0, index).trim(); + let value = trimmed.slice(index + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1); + parsed[key] = value; + } + return parsed; +} + +async function readOptionalEnvFile(envFile) { + try { + return parseEnvContent(await readFile(envFile, 'utf8')); + } catch (error) { + if (error?.code === 'ENOENT') return {}; + throw error; + } +} + +export function defaultTelegramEnvFile() { + return path.join(os.homedir(), '.hermes', '.env'); +} + +export async function resolveTelegramApprovalCredentials(env = process.env, overrides = {}) { + const fileEnv = await readOptionalEnvFile(overrides.envFile || defaultTelegramEnvFile()); + const merged = { ...fileEnv, ...env }; + const botToken = overrides.botToken || merged.TELEGRAM_BOT_TOKEN || merged.HERMES_TELEGRAM_BOT_TOKEN || merged.TG_BOT_TOKEN || null; + const chatId = overrides.chatId || merged.TELEGRAM_CHAT_ID || merged.HERMES_TELEGRAM_CHAT_ID || merged.TG_CHAT_ID || null; + return { + botToken, + chatId: chatId == null ? null : String(chatId), + redacted: { + botToken: botToken ? '[REDACTED]' : null, + chatId: chatId ? '[REDACTED]' : null, + }, + }; +} + +async function readJsonl(pathname) { + try { + const content = await readFile(pathname, 'utf8'); + return content.split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)); + } catch (error) { + if (error?.code === 'ENOENT') return []; + throw error; + } +} + +async function appendJsonl(pathname, row) { + await mkdir(path.dirname(pathname), { recursive: true }); + await appendFile(pathname, `${JSON.stringify(row)}\n`, 'utf8'); +} + +async function readOffset(offsetPath) { + try { + const parsed = JSON.parse(await readFile(offsetPath, 'utf8')); + return Number.isSafeInteger(parsed.offset) ? parsed.offset : 0; + } catch (error) { + if (error?.code === 'ENOENT') return 0; + throw error; + } +} + +async function writeOffset(offsetPath, offset, now) { + await mkdir(path.dirname(offsetPath), { recursive: true }); + await writeFile(offsetPath, `${JSON.stringify({ offset, updated_at: new Date(now).toISOString(), sanitized: true }, null, 2)}\n`, 'utf8'); +} + +function parseDecisionCommand(text) { + const match = String(text || '').trim().match(/^\/(approve|reject)\s+(tg4_[a-f0-9]{16}|[A-Za-z0-9_-]+)$/); + if (!match) return null; + return { decision: match[1] === 'approve' ? 'approved' : 'rejected', approval_token: match[2] }; +} + +export function shortAliasForApprovalId(approvalId) { + const id = String(approvalId || ''); + if (id === LEGACY_CODE_EDIT_APPROVAL_ID) return 'edit1'; + const trailingHex = id.match(/([a-f0-9]{8})[a-f0-9]*$/i); + if (trailingHex) return trailingHex[1].toLowerCase(); + const safe = id.toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 12); + return safe || null; +} + +function normalizeDiscoveryApprovalRequest(row) { + if (!row?.approval_id || row?.status !== 'requested') return null; + const shortAlias = row.short_alias || shortAliasForApprovalId(row.approval_id); + return { + ...row, + approval_id: row.approval_id, + short_alias: shortAlias, + approval_command: `/approve ${shortAlias}`, + reject_command: `/reject ${shortAlias}`, + }; +} + +function normalizeCodeEditApprovalPacket(row) { + const approvalId = row?.approval_id || row?.code_edit_approval_id; + if (!approvalId) return null; + if (row.status && row.status !== 'requested') return null; + const shortAlias = row.short_alias || shortAliasForApprovalId(approvalId); + return { + approval_id: approvalId, + short_alias: shortAlias, + approval_command: `/approve ${shortAlias}`, + reject_command: `/reject ${shortAlias}`, + report_path: row.report_path || row.approval_packet_report_path || null, + report_hash: row.report_hash || row.approval_packet_report_hash || null, + selected_repo: row.selected_repo || row.target_workspace || null, + recommended_next_action: row.recommended_next_action || 'code_edit_approval', + expires_at: row.expires_at || row.expiry_time || null, + status: 'requested', + }; +} + +function pendingApprovalRequests(requestRows, codeEditPacketRows) { + return [ + ...requestRows.map(normalizeDiscoveryApprovalRequest), + ...codeEditPacketRows.map(normalizeCodeEditApprovalPacket), + ].filter(Boolean); +} + +function requestByApprovalToken(requestRows, approvalToken) { + const exactId = requestRows.find((row) => row?.approval_id === approvalToken) || null; + if (exactId) return { request: exactId, reason: null, aliasUsed: null }; + const aliasMatches = requestRows.filter((row) => row?.short_alias === approvalToken); + if (aliasMatches.length === 1) return { request: aliasMatches[0], reason: null, aliasUsed: approvalToken }; + if (aliasMatches.length > 1) return { request: null, reason: 'AMBIGUOUS_APPROVAL_ALIAS', aliasUsed: approvalToken }; + return { request: null, reason: 'UNKNOWN_APPROVAL_ID', aliasUsed: approvalToken }; +} + +function existingDecision(decisionRows, approvalId) { + return decisionRows.find((row) => row?.approval_id === approvalId && (row?.status === 'approved' || row?.status === 'rejected')) || null; +} + +function isExpired(request, now) { + const expiresAt = request?.expires_at || request?.expiry_time; + if (!expiresAt) return true; + const expiryMs = new Date(expiresAt).getTime(); + if (!Number.isFinite(expiryMs)) return true; + return expiryMs <= new Date(now).getTime(); +} + +function safeMessageSummary(update, reason, extra = {}) { + return { + telegram_update_id: update?.update_id ?? null, + telegram_message_id: update?.message?.message_id ?? null, + accepted: false, + reason, + ...extra, + }; +} + +async function fetchTelegramUpdates({ credentials, offset, fetchImpl = globalThis.fetch } = {}) { + if (!credentials.botToken || !credentials.chatId) { + return { ok: false, blocked: true, reason: 'MISSING_TELEGRAM_CREDENTIALS', updates: [] }; + } + if (typeof fetchImpl !== 'function') throw new Error('fetch is unavailable for Telegram getUpdates.'); + const url = `https://api.telegram.org/bot${credentials.botToken}/getUpdates?timeout=0&allowed_updates=%5B%22message%22%5D${offset ? `&offset=${offset}` : ''}`; + const response = await fetchImpl(url, { method: 'GET' }); + let body = null; + try { + body = await response.json(); + } catch { + body = null; + } + if (!response.ok || body?.ok === false) throw new Error(`Telegram getUpdates failed with status ${response.status}`); + return { ok: true, updates: Array.isArray(body?.result) ? body.result : [] }; +} + +export async function pollTelegramApprovals({ + requestsPath = DEFAULT_TELEGRAM_APPROVAL_REQUESTS_PATH, + codeEditApprovalPacketsPath = DEFAULT_TELEGRAM_CODE_EDIT_APPROVAL_PACKETS_PATH, + decisionsPath = DEFAULT_TELEGRAM_APPROVAL_DECISIONS_PATH, + offsetPath = DEFAULT_TELEGRAM_APPROVAL_OFFSET_PATH, + dryRun = false, + env = process.env, + envFile = defaultTelegramEnvFile(), + botToken, + chatId, + fetchImpl = globalThis.fetch, + now = new Date(), +} = {}) { + const credentials = await resolveTelegramApprovalCredentials(env, { envFile, botToken, chatId }); + const offsetBefore = await readOffset(offsetPath); + const [requestStateRows, codeEditPacketRows] = await Promise.all([ + readJsonl(requestsPath), + readJsonl(codeEditApprovalPacketsPath), + ]); + const requestRows = pendingApprovalRequests(requestStateRows, codeEditPacketRows); + const initialDecisionRows = await readJsonl(decisionsPath); + const fetchResult = await fetchTelegramUpdates({ credentials, offset: offsetBefore, fetchImpl }); + + if (fetchResult.blocked) { + return { + ok: false, + blocked: true, + reason: fetchResult.reason, + mode: dryRun ? 'telegram_approval_poll_dry_run' : 'telegram_approval_poll', + dryRun, + telegram: credentials.redacted, + offset_before: offsetBefore, + offset_after: offsetBefore, + offsetUpdated: false, + decisionsAppended: 0, + decision_rows: [], + handled_updates: [], + ...SIDE_EFFECT_FLAGS, + note: 'No state changed.', + }; + } + + const handled = []; + const decisionRows = [...initialDecisionRows]; + const appendedRows = []; + let maxSeenUpdateId = null; + + for (const update of fetchResult.updates) { + if (Number.isSafeInteger(update?.update_id)) maxSeenUpdateId = Math.max(maxSeenUpdateId ?? update.update_id, update.update_id); + const message = update?.message || null; + const updateChatId = message?.chat?.id == null ? null : String(message.chat.id); + if (!message || updateChatId !== credentials.chatId) { + handled.push(safeMessageSummary(update, 'WRONG_CHAT_OR_NO_MESSAGE')); + continue; + } + + const parsed = parseDecisionCommand(message.text || ''); + if (!parsed) { + handled.push(safeMessageSummary(update, 'MALFORMED_COMMAND')); + continue; + } + + const resolved = requestByApprovalToken(requestRows, parsed.approval_token); + const request = resolved.request; + if (!request) { + handled.push(safeMessageSummary(update, resolved.reason, { approval_alias_used: resolved.aliasUsed })); + continue; + } + if (isExpired(request, now)) { + handled.push(safeMessageSummary(update, 'EXPIRED_APPROVAL_ID', { approval_id: request.approval_id, approval_alias_used: resolved.aliasUsed })); + continue; + } + if (existingDecision(decisionRows, request.approval_id)) { + handled.push(safeMessageSummary(update, 'ALREADY_DECIDED', { approval_id: request.approval_id, approval_alias_used: resolved.aliasUsed })); + continue; + } + + const row = { + approval_id: request.approval_id, + approval_alias_used: resolved.aliasUsed, + decision: parsed.decision, + decided_at: new Date(now).toISOString(), + report_path: request.report_path, + report_hash: request.report_hash, + selected_repo: request.selected_repo, + recommended_next_action: request.recommended_next_action, + telegram_update_id: update.update_id, + telegram_message_id: message.message_id, + telegram_chat_verified: true, + status: parsed.decision, + sanitized: true, + }; + if (!dryRun) await appendJsonl(decisionsPath, row); + decisionRows.push(row); + appendedRows.push(row); + handled.push({ + telegram_update_id: update.update_id, + telegram_message_id: message.message_id, + accepted: true, + approval_id: request.approval_id, + approval_alias_used: resolved.aliasUsed, + decision: parsed.decision, + telegram_message_text: formatHumanTelegramMessage({ + type: 'approval-recorded', + approval_id: request.approval_id, + alias: request.short_alias, + decision: parsed.decision, + report_path: request.report_path, + }), + }); + } + + const offsetAfter = maxSeenUpdateId == null ? offsetBefore : maxSeenUpdateId + 1; + if (!dryRun && offsetAfter !== offsetBefore) await writeOffset(offsetPath, offsetAfter, now); + + return { + ok: true, + mode: dryRun ? 'telegram_approval_poll_dry_run' : 'telegram_approval_poll', + dryRun, + telegram: credentials.redacted, + getUpdatesCalled: true, + offset_before: offsetBefore, + offset_after: dryRun ? offsetBefore : offsetAfter, + computed_offset_after: offsetAfter, + offsetUpdated: !dryRun && offsetAfter !== offsetBefore, + requestsLoaded: requestRows.length, + codeEditApprovalPacketsLoaded: codeEditPacketRows.length, + existingDecisionsLoaded: initialDecisionRows.length, + updatesSeen: fetchResult.updates.length, + decisionsAppended: dryRun ? 0 : appendedRows.length, + decision_rows: dryRun ? [] : appendedRows, + dry_run_decision_rows: dryRun ? appendedRows : undefined, + handled_updates: handled, + approvalOneShotReplayBlocked: true, + ...SIDE_EFFECT_FLAGS, + note: dryRun ? 'Dry-run only. No decision row appended and no offset advanced.' : 'Approval/rejection decisions recorded only after validation. No executor ran.', + }; +} + +export async function runTelegramApprovalPollCli(argv = process.argv.slice(2)) { + const args = parseArgs(argv); + requireJson(args); + const result = await pollTelegramApprovals({ + requestsPath: args.requestsPath || DEFAULT_TELEGRAM_APPROVAL_REQUESTS_PATH, + codeEditApprovalPacketsPath: args.codeEditApprovalPacketsPath || DEFAULT_TELEGRAM_CODE_EDIT_APPROVAL_PACKETS_PATH, + decisionsPath: args.decisionsPath || DEFAULT_TELEGRAM_APPROVAL_DECISIONS_PATH, + offsetPath: args.offsetPath || DEFAULT_TELEGRAM_APPROVAL_OFFSET_PATH, + dryRun: args.dryRun === true, + envFile: args.envFile || defaultTelegramEnvFile(), + botToken: args.botToken, + chatId: args.chatId, + now: args.now ? new Date(args.now) : new Date(), + }); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { + runTelegramApprovalPollCli().catch((error) => { + const safe = { + ok: false, + error: sanitizeError(error), + telegram: { botToken: null, chatId: null }, + offsetUpdated: false, + decisionsAppended: 0, + decision_rows: [], + ...SIDE_EFFECT_FLAGS, + note: 'No executor ran. No approval decision recorded after this error.', + }; + process.stdout.write(`${JSON.stringify(safe, null, 2)}\n`); + process.exitCode = 1; + }); +} diff --git a/scripts/telegram-approval-poll.test.mjs b/scripts/telegram-approval-poll.test.mjs new file mode 100644 index 000000000..587584fdc --- /dev/null +++ b/scripts/telegram-approval-poll.test.mjs @@ -0,0 +1,274 @@ +import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { pollTelegramApprovals, LEGACY_CODE_EDIT_APPROVAL_ID } from './telegram-approval-poll.mjs'; + +const SECRET_TOKEN = '123456:SUPER_SECRET_TOKEN'; +const CHAT_ID = '987654321'; +const OTHER_CHAT_ID = '111111111'; +const APPROVAL_ID = 'tg4_d3fd7da71ae557f3'; + +async function exists(pathname) { + try { + await stat(pathname); + return true; + } catch (error) { + if (error?.code === 'ENOENT') return false; + throw error; + } +} + +async function fixture({ expiresAt = '2026-07-05T10:00:00.000Z', withDecision = null } = {}) { + const dir = await mkdtemp(path.join(tmpdir(), 'tg-approval-poll-')); + const requestsPath = path.join(dir, 'telegram-approval-requests.jsonl'); + const decisionsPath = path.join(dir, 'telegram-approval-decisions.jsonl'); + const offsetPath = path.join(dir, 'telegram-approval-offset.json'); + const codeEditApprovalPacketsPath = path.join(dir, 'telegram-code-edit-approval-packets.jsonl'); + await writeFile(requestsPath, `${JSON.stringify({ + approval_id: APPROVAL_ID, + report_path: '/tmp/report.md', + report_hash: 'abc123', + sent_at: '2026-07-05T08:00:00.000Z', + expires_at: expiresAt, + selected_repo: 'owner/repo', + recommended_next_action: 'learn_from', + message_id: 77, + status: 'requested', + })}\n`, 'utf8'); + if (withDecision) { + await writeFile(decisionsPath, `${JSON.stringify({ + approval_id: APPROVAL_ID, + decision: withDecision, + decided_at: '2026-07-05T08:10:00.000Z', + report_path: '/tmp/report.md', + report_hash: 'abc123', + selected_repo: 'owner/repo', + recommended_next_action: 'learn_from', + telegram_update_id: 1, + telegram_message_id: 2, + telegram_chat_verified: true, + status: withDecision, + sanitized: true, + })}\n`, 'utf8'); + } + return { dir, requestsPath, decisionsPath, offsetPath, codeEditApprovalPacketsPath }; +} + +function updatesFetch(updates, calls = []) { + return async (url, init) => { + calls.push({ url, init }); + return { ok: true, status: 200, json: async () => ({ ok: true, result: updates }) }; + }; +} + +function message(updateId, text, chatId = CHAT_ID, messageId = updateId + 100) { + return { update_id: updateId, message: { message_id: messageId, chat: { id: chatId }, text } }; +} + +async function readJsonl(pathname) { + return (await readFile(pathname, 'utf8')).split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)); +} + +describe('telegram approval reply intake', () => { + it('approve command writes one sanitized decision row and advances offset', async () => { + const f = await fixture(); + const calls = []; + const result = await pollTelegramApprovals({ + ...f, + env: { TELEGRAM_BOT_TOKEN: SECRET_TOKEN, TELEGRAM_CHAT_ID: CHAT_ID }, + fetchImpl: updatesFetch([message(10, `/approve ${APPROVAL_ID}`, CHAT_ID, 501)], calls), + now: new Date('2026-07-05T09:00:00.000Z'), + }); + expect(result.ok).toBe(true); + expect(result.decisionsAppended).toBe(1); + expect(result.decision_rows[0]).toMatchObject({ approval_id: APPROVAL_ID, decision: 'approved', status: 'approved', telegram_chat_verified: true, sanitized: true }); + expect(result.decision_rows[0].telegram_message_id).toBe(501); + expect(result.offset_after).toBe(11); + expect(JSON.parse(await readFile(f.offsetPath, 'utf8')).offset).toBe(11); + expect(await readJsonl(f.decisionsPath)).toHaveLength(1); + expect(JSON.stringify(result)).not.toContain(SECRET_TOKEN); + expect(JSON.stringify(result)).not.toContain(CHAT_ID); + expect(calls[0].url).toContain('/getUpdates'); + }); + + it('reject command writes one sanitized decision row', async () => { + const f = await fixture(); + const result = await pollTelegramApprovals({ + ...f, + env: { TELEGRAM_BOT_TOKEN: SECRET_TOKEN, TELEGRAM_CHAT_ID: CHAT_ID }, + fetchImpl: updatesFetch([message(11, `/reject ${APPROVAL_ID}`)]), + now: new Date('2026-07-05T09:00:00.000Z'), + }); + expect(result.decisionsAppended).toBe(1); + expect(result.decision_rows[0].decision).toBe('rejected'); + expect(result.decision_rows[0].status).toBe('rejected'); + }); + + it('duplicate approve and approve-after-reject are blocked one-shot', async () => { + for (const prior of ['approved', 'rejected']) { + const f = await fixture({ withDecision: prior }); + const result = await pollTelegramApprovals({ + ...f, + env: { TELEGRAM_BOT_TOKEN: SECRET_TOKEN, TELEGRAM_CHAT_ID: CHAT_ID }, + fetchImpl: updatesFetch([message(12, `/approve ${APPROVAL_ID}`)]), + now: new Date('2026-07-05T09:00:00.000Z'), + }); + expect(result.decisionsAppended).toBe(0); + expect(result.handled_updates[0].reason).toBe('ALREADY_DECIDED'); + expect(await readJsonl(f.decisionsPath)).toHaveLength(1); + } + }); + + it('reject after approve is blocked one-shot', async () => { + const f = await fixture({ withDecision: 'approved' }); + const result = await pollTelegramApprovals({ + ...f, + env: { TELEGRAM_BOT_TOKEN: SECRET_TOKEN, TELEGRAM_CHAT_ID: CHAT_ID }, + fetchImpl: updatesFetch([message(13, `/reject ${APPROVAL_ID}`)]), + now: new Date('2026-07-05T09:00:00.000Z'), + }); + expect(result.decisionsAppended).toBe(0); + expect(result.handled_updates[0].reason).toBe('ALREADY_DECIDED'); + }); + + it('unknown and expired approval_id are blocked', async () => { + const unknown = await fixture(); + const unknownResult = await pollTelegramApprovals({ + ...unknown, + env: { TELEGRAM_BOT_TOKEN: SECRET_TOKEN, TELEGRAM_CHAT_ID: CHAT_ID }, + fetchImpl: updatesFetch([message(14, '/approve tg4_unknownapproval')]), + now: new Date('2026-07-05T09:00:00.000Z'), + }); + expect(unknownResult.decisionsAppended).toBe(0); + expect(unknownResult.handled_updates[0].reason).toBe('UNKNOWN_APPROVAL_ID'); + + const expired = await fixture({ expiresAt: '2026-07-05T08:59:59.000Z' }); + const expiredResult = await pollTelegramApprovals({ + ...expired, + env: { TELEGRAM_BOT_TOKEN: SECRET_TOKEN, TELEGRAM_CHAT_ID: CHAT_ID }, + fetchImpl: updatesFetch([message(15, `/approve ${APPROVAL_ID}`)]), + now: new Date('2026-07-05T09:00:00.000Z'), + }); + expect(expiredResult.decisionsAppended).toBe(0); + expect(expiredResult.handled_updates[0].reason).toBe('EXPIRED_APPROVAL_ID'); + }); + + it('wrong chat and malformed commands are ignored without decision rows', async () => { + const f = await fixture(); + const result = await pollTelegramApprovals({ + ...f, + env: { TELEGRAM_BOT_TOKEN: SECRET_TOKEN, TELEGRAM_CHAT_ID: CHAT_ID }, + fetchImpl: updatesFetch([ + message(16, `/approve ${APPROVAL_ID}`, OTHER_CHAT_ID), + message(17, `approve ${APPROVAL_ID}`, CHAT_ID), + ]), + now: new Date('2026-07-05T09:00:00.000Z'), + }); + expect(result.decisionsAppended).toBe(0); + expect(result.handled_updates.map((item) => item.reason)).toEqual(['WRONG_CHAT_OR_NO_MESSAGE', 'MALFORMED_COMMAND']); + expect(await exists(f.decisionsPath)).toBe(false); + expect(JSON.parse(await readFile(f.offsetPath, 'utf8')).offset).toBe(18); + }); + + it('dry-run writes nothing and advances no offset', async () => { + const f = await fixture(); + const result = await pollTelegramApprovals({ + ...f, + dryRun: true, + env: { TELEGRAM_BOT_TOKEN: SECRET_TOKEN, TELEGRAM_CHAT_ID: CHAT_ID }, + fetchImpl: updatesFetch([message(18, `/approve ${APPROVAL_ID}`)]), + now: new Date('2026-07-05T09:00:00.000Z'), + }); + expect(result.decisionsAppended).toBe(0); + expect(result.dry_run_decision_rows).toHaveLength(1); + expect(result.offset_after).toBe(0); + expect(await exists(f.decisionsPath)).toBe(false); + expect(await exists(f.offsetPath)).toBe(false); + }); + + it('accepts exact alias for the legacy code edit approval packet and stores approval_alias_used', async () => { + const f = await fixture(); + await writeFile(f.codeEditApprovalPacketsPath, `${JSON.stringify({ + code_edit_approval_id: LEGACY_CODE_EDIT_APPROVAL_ID, + approval_packet_report_path: '/tmp/code-edit-packet.md', + approval_packet_report_hash: 'packet-hash', + target_workspace: '/root/hermes-workspace', + expiry_time: '2026-07-05T10:00:00.000Z', + })}\n`, 'utf8'); + const result = await pollTelegramApprovals({ + ...f, + env: { TELEGRAM_BOT_TOKEN: SECRET_TOKEN, TELEGRAM_CHAT_ID: CHAT_ID }, + fetchImpl: updatesFetch([message(19, '/approve edit1')]), + now: new Date('2026-07-05T09:00:00.000Z'), + }); + expect(result.decisionsAppended).toBe(1); + expect(result.decision_rows[0]).toMatchObject({ + approval_id: LEGACY_CODE_EDIT_APPROVAL_ID, + approval_alias_used: 'edit1', + decision: 'approved', + recommended_next_action: 'code_edit_approval', + status: 'approved', + }); + }); + + it('accepts exact full approval_id while storing null approval_alias_used', async () => { + const f = await fixture(); + const result = await pollTelegramApprovals({ + ...f, + env: { TELEGRAM_BOT_TOKEN: SECRET_TOKEN, TELEGRAM_CHAT_ID: CHAT_ID }, + fetchImpl: updatesFetch([message(20, `/approve ${APPROVAL_ID}`)]), + now: new Date('2026-07-05T09:00:00.000Z'), + }); + expect(result.decisionsAppended).toBe(1); + expect(result.decision_rows[0].approval_id).toBe(APPROVAL_ID); + expect(result.decision_rows[0].approval_alias_used).toBeNull(); + }); + + it('rejects ambiguous and unknown aliases without decision rows', async () => { + const f = await fixture(); + await writeFile(f.requestsPath, `${JSON.stringify({ + approval_id: 'tg4_aaaaaaaaaaaaaaaa', + short_alias: 'same', + expires_at: '2026-07-05T10:00:00.000Z', + status: 'requested', + })}\n${JSON.stringify({ + approval_id: 'tg4_bbbbbbbbbbbbbbbb', + short_alias: 'same', + expires_at: '2026-07-05T10:00:00.000Z', + status: 'requested', + })}\n`, 'utf8'); + const result = await pollTelegramApprovals({ + ...f, + env: { TELEGRAM_BOT_TOKEN: SECRET_TOKEN, TELEGRAM_CHAT_ID: CHAT_ID }, + fetchImpl: updatesFetch([message(21, '/approve same'), message(22, '/approve missing')]), + now: new Date('2026-07-05T09:00:00.000Z'), + }); + expect(result.decisionsAppended).toBe(0); + expect(result.handled_updates.map((item) => item.reason)).toEqual(['AMBIGUOUS_APPROVAL_ALIAS', 'UNKNOWN_APPROVAL_ID']); + expect(await exists(f.decisionsPath)).toBe(false); + }); + + it('offset prevents replay by using saved offset in getUpdates URL and token/chat are never printed', async () => { + const f = await fixture(); + await writeFile(f.offsetPath, JSON.stringify({ offset: 44 }), 'utf8'); + const calls = []; + const result = await pollTelegramApprovals({ + ...f, + env: { TELEGRAM_BOT_TOKEN: SECRET_TOKEN, TELEGRAM_CHAT_ID: CHAT_ID }, + fetchImpl: updatesFetch([], calls), + now: new Date('2026-07-05T09:00:00.000Z'), + }); + expect(calls[0].url).toContain('offset=44'); + expect(result.offset_before).toBe(44); + expect(result.offset_after).toBe(44); + const raw = JSON.stringify(result); + expect(raw).not.toContain(SECRET_TOKEN); + expect(raw).not.toContain(CHAT_ID); + expect(result.githubWrites).toBe(false); + expect(result.auditAppend).toBe(false); + expect(result.durableMutation).toBe(false); + expect(result.obsidianKanbanWrites).toBe(false); + expect(result.executor).toBe(false); + }); +}); diff --git a/scripts/telegram-approved-discovery-executor.mjs b/scripts/telegram-approved-discovery-executor.mjs new file mode 100644 index 000000000..8c42ba3c9 --- /dev/null +++ b/scripts/telegram-approved-discovery-executor.mjs @@ -0,0 +1,266 @@ +#!/usr/bin/env node +import { appendFile, mkdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { buildDiscoveryNotificationPreview, hashReportContent } from './telegram-discovery-notification.mjs'; + +export const DEFAULT_TELEGRAM_APPROVAL_REQUESTS_PATH = '/root/.hermes/reports/github-discovery/telegram-approval-requests.jsonl'; +export const DEFAULT_TELEGRAM_APPROVAL_DECISIONS_PATH = '/root/.hermes/reports/github-discovery/telegram-approval-decisions.jsonl'; +export const DEFAULT_TELEGRAM_EXECUTION_PLANS_PATH = '/root/.hermes/reports/github-discovery/telegram-execution-plans.jsonl'; +export const DEFAULT_DISCOVERY_INDEX_PATH = '/root/.hermes/reports/github-discovery/index.jsonl'; + +export const SIDE_EFFECT_FLAGS = Object.freeze({ + telegramSend: false, + githubWrites: false, + githubCalls: false, + clone: false, + fork: false, + dependencyInstall: false, + codeExecution: false, + repoCreation: false, + branchPush: false, + prOpen: false, + merge: false, + delete: false, + auditAppend: false, + durableMutation: false, + obsidianKanbanWrites: false, +}); + +function parseArgs(argv) { + const args = { _: [] }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--json') args.json = true; + else if (arg === '--dry-run') args.dryRun = true; + else if (arg === '--write-plan') args.writePlan = true; + else if (arg === '--approval-id') args.approvalId = argv[++i]; + else if (arg === '--requests-path') args.requestsPath = argv[++i]; + else if (arg === '--decisions-path') args.decisionsPath = argv[++i]; + else if (arg === '--plans-path') args.plansPath = argv[++i]; + else if (arg === '--index-path') args.indexPath = argv[++i]; + else if (arg === '--now') args.now = argv[++i]; + else args._.push(arg); + } + return args; +} + +function requireJson(args) { + if (!args.json) throw new Error('telegram-approved-discovery-executor is intentionally JSON-only. Pass --json.'); +} + +async function readJsonl(pathname) { + try { + const content = await readFile(pathname, 'utf8'); + return content.split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)); + } catch (error) { + if (error?.code === 'ENOENT') return []; + throw error; + } +} + +async function appendJsonl(pathname, row) { + await mkdir(path.dirname(pathname), { recursive: true }); + await appendFile(pathname, `${JSON.stringify(row)}\n`, 'utf8'); +} + +function block(reason, extra = {}) { + return { + ok: false, + blocked: true, + reason, + executed: false, + planCreated: false, + planWritten: false, + ...SIDE_EFFECT_FLAGS, + ...extra, + note: 'No execution plan was written and no action executed.', + }; +} + +function expiryValue(request) { + return request?.expires_at || request?.expiry_time || null; +} + +function isExpired(request, now) { + const expiresAt = expiryValue(request); + if (!expiresAt) return true; + const expiryMs = new Date(expiresAt).getTime(); + if (!Number.isFinite(expiryMs)) return true; + return expiryMs <= new Date(now).getTime(); +} + +function selectedRepoName(value) { + if (typeof value === 'string') return value; + if (value && typeof value === 'object') return value.full_name || value.name || null; + return null; +} + +function normalizeAction(value) { + return String(value || '').trim().toLowerCase().replace(/\s+/g, '_'); +} + +function findRequest(rows, approvalId) { + return rows.find((row) => row?.approval_id === approvalId && row?.status === 'requested') || null; +} + +function findDecision(rows, approvalId) { + return rows.find((row) => row?.approval_id === approvalId && (row?.status === 'approved' || row?.status === 'rejected' || row?.decision === 'approved' || row?.decision === 'rejected')) || null; +} + +function existingExecution(rows, approvalId) { + return rows.find((row) => row?.approval_id === approvalId && row?.status === 'planned') || null; +} + +function reportCandidateMatches(reportPreview, expectedRepo, expectedAction) { + const candidates = Array.isArray(reportPreview?.report?.topCandidates) ? reportPreview.report.topCandidates : []; + return candidates.some((candidate) => selectedRepoName(candidate) === expectedRepo && normalizeAction(candidate.recommendation || expectedAction) === expectedAction); +} + +function safeReadOnlyStepsForLearnFrom(repo) { + return [ + { id: 'fetch_public_repo_metadata', action: 'fetch public repo metadata', method: 'read_public_github_metadata_only', target: repo, writes: false }, + { id: 'fetch_readme_metadata_or_summary', action: 'fetch README metadata or summary', method: 'read_public_readme_only', target: repo, writes: false }, + { id: 'inspect_license_metadata', action: 'inspect license metadata', method: 'read_public_license_metadata_only', target: repo, writes: false }, + { id: 'inspect_package_manifest_metadata', action: 'inspect package/manifest metadata', method: 'read_public_manifest_metadata_only', target: repo, writes: false }, + { id: 'produce_implementation_notes', action: 'produce implementation notes', method: 'derive_notes_from_read_only_metadata', target: repo, writes: false }, + { id: 'produce_next_action_recommendation', action: 'produce next-action recommendation', method: 'recommend_follow_up_without_side_effects', target: repo, writes: false }, + ]; +} + +export function buildExecutionPlan({ approvalId, request, decision, reportHash, dryRun = true, now = new Date() }) { + const selectedRepo = selectedRepoName(decision.selected_repo) || selectedRepoName(request.selected_repo); + const recommendedNextAction = normalizeAction(decision.recommended_next_action || request.recommended_next_action); + if (recommendedNextAction !== 'learn_from') throw new Error(`Unsupported approved discovery action: ${recommendedNextAction}`); + return { + approval_id: approvalId, + status: 'planned', + dry_run: dryRun, + executed: false, + plan_only: true, + created_at: new Date(now).toISOString(), + report_path: path.resolve(request.report_path), + report_hash: reportHash, + selected_repo: selectedRepo, + recommended_next_action: recommendedNextAction, + plan_type: 'learn_from_read_only_research', + steps: safeReadOnlyStepsForLearnFrom(selectedRepo), + forbidden_side_effects: [ + 'Telegram send', + 'GitHub write', + 'clone', + 'fork', + 'dependency install', + 'code execution', + 'repo creation', + 'branch push', + 'PR open', + 'merge', + 'delete', + 'audit append', + 'durable mutation', + 'Obsidian/Kanban write', + ], + sanitized: true, + }; +} + +export async function createApprovedDiscoveryExecutionPlan({ + approvalId, + requestsPath = DEFAULT_TELEGRAM_APPROVAL_REQUESTS_PATH, + decisionsPath = DEFAULT_TELEGRAM_APPROVAL_DECISIONS_PATH, + plansPath = DEFAULT_TELEGRAM_EXECUTION_PLANS_PATH, + indexPath = DEFAULT_DISCOVERY_INDEX_PATH, + dryRun = false, + writePlan = false, + now = new Date(), +} = {}) { + if (!approvalId) throw new Error('telegram-approved-discovery-executor requires --approval-id.'); + if (!dryRun && !writePlan) throw new Error('telegram-approved-discovery-executor is plan/dry-run only. Pass --dry-run or --write-plan.'); + + const [requestRows, decisionRows, existingPlanRows] = await Promise.all([ + readJsonl(requestsPath), + readJsonl(decisionsPath), + readJsonl(plansPath), + ]); + const request = findRequest(requestRows, approvalId); + if (!request) return block('UNKNOWN_APPROVAL_ID', { approval_id: approvalId }); + const decision = findDecision(decisionRows, approvalId); + if (!decision) return block('MISSING_APPROVED_DECISION', { approval_id: approvalId }); + if ((decision.decision || decision.status) !== 'approved') return block('DECISION_NOT_APPROVED', { approval_id: approvalId, decision: decision.decision || decision.status }); + if (isExpired(request, now)) return block('EXPIRED_APPROVAL_ID', { approval_id: approvalId, expires_at: expiryValue(request) }); + if (existingExecution(existingPlanRows, approvalId)) return block('EXECUTION_RECORD_EXISTS', { approval_id: approvalId }); + + const requestRepo = selectedRepoName(request.selected_repo); + const decisionRepo = selectedRepoName(decision.selected_repo); + const requestAction = normalizeAction(request.recommended_next_action); + const decisionAction = normalizeAction(decision.recommended_next_action); + if (!requestRepo || !decisionRepo || requestRepo !== decisionRepo || requestAction !== decisionAction) { + return block('DECISION_REQUEST_MISMATCH', { approval_id: approvalId, request_selected_repo: requestRepo, decision_selected_repo: decisionRepo, request_action: requestAction, decision_action: decisionAction }); + } + + const reportPath = request.report_path || decision.report_path; + if (!reportPath) return block('MISSING_DISCOVERY_REPORT_PATH', { approval_id: approvalId }); + const resolvedReportPath = path.resolve(reportPath); + const reportContent = await readFile(resolvedReportPath, 'utf8'); + const reportHash = hashReportContent(reportContent); + if ((request.report_hash && request.report_hash !== reportHash) || (decision.report_hash && decision.report_hash !== reportHash)) { + return block('REPORT_HASH_MISMATCH', { approval_id: approvalId, report_path: resolvedReportPath }); + } + + const reportPreview = await buildDiscoveryNotificationPreview({ reportPath: resolvedReportPath, indexPath }); + if (!reportCandidateMatches(reportPreview, requestRepo, requestAction)) { + return block('REQUEST_REPORT_MISMATCH', { approval_id: approvalId, selected_repo: requestRepo, recommended_next_action: requestAction }); + } + + const plan = buildExecutionPlan({ approvalId, request: { ...request, report_path: resolvedReportPath }, decision, reportHash, dryRun, now }); + if (writePlan) await appendJsonl(plansPath, plan); + return { + ok: true, + blocked: false, + mode: writePlan ? 'telegram_approved_discovery_executor_write_plan' : 'telegram_approved_discovery_executor_dry_run', + approval_id: approvalId, + dryRun, + writePlan, + executed: false, + planCreated: true, + planWritten: writePlan, + plans_path: plansPath, + plan, + ...SIDE_EFFECT_FLAGS, + note: writePlan ? 'Wrote one sanitized execution plan row only. Plan was not executed.' : 'Dry-run only. No writes and no execution.', + }; +} + +export async function runTelegramApprovedDiscoveryExecutorCli(argv = process.argv.slice(2)) { + const args = parseArgs(argv); + requireJson(args); + const result = await createApprovedDiscoveryExecutionPlan({ + approvalId: args.approvalId, + requestsPath: args.requestsPath || DEFAULT_TELEGRAM_APPROVAL_REQUESTS_PATH, + decisionsPath: args.decisionsPath || DEFAULT_TELEGRAM_APPROVAL_DECISIONS_PATH, + plansPath: args.plansPath || DEFAULT_TELEGRAM_EXECUTION_PLANS_PATH, + indexPath: args.indexPath || DEFAULT_DISCOVERY_INDEX_PATH, + dryRun: args.dryRun === true, + writePlan: args.writePlan === true, + now: args.now ? new Date(args.now) : new Date(), + }); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + if (!result.ok) process.exitCode = 1; +} + +if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { + runTelegramApprovedDiscoveryExecutorCli().catch((error) => { + const safe = { + ok: false, + blocked: true, + error: String(error?.message || error).replace(/bot\d+:[A-Za-z0-9_-]+/g, 'bot[REDACTED]'), + executed: false, + planWritten: false, + ...SIDE_EFFECT_FLAGS, + note: 'No action taken.', + }; + process.stdout.write(`${JSON.stringify(safe, null, 2)}\n`); + process.exitCode = 1; + }); +} diff --git a/scripts/telegram-approved-discovery-executor.test.mjs b/scripts/telegram-approved-discovery-executor.test.mjs new file mode 100644 index 000000000..1f3029810 --- /dev/null +++ b/scripts/telegram-approved-discovery-executor.test.mjs @@ -0,0 +1,210 @@ +import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { createApprovedDiscoveryExecutionPlan } from './telegram-approved-discovery-executor.mjs'; + +const APPROVAL_ID = 'tg4_d3fd7da71ae557f3'; +const SECRET_TOKEN = '123456:SUPER_SECRET_TOKEN'; +const CHAT_ID = '987654321'; + +async function exists(pathname) { + try { + await stat(pathname); + return true; + } catch (error) { + if (error?.code === 'ENOENT') return false; + throw error; + } +} + +function discoveryReport(repo = 'CoWork-OS/CoWork-OS', action = 'learn_from') { + return `# GitHub discovery report: File/document assistant summary starter\n\nRecommendation: ${action}\n\n### 1. ${repo}\n- URL: https://github.com/${repo}\n- Recommendation: ${action}\n- Score: 91\n- Stars: 123\n`; +} + +async function fixture({ + decision = 'approved', + requestRepo = 'CoWork-OS/CoWork-OS', + decisionRepo = 'CoWork-OS/CoWork-OS', + requestAction = 'learn_from', + decisionAction = 'learn_from', + reportRepo = 'CoWork-OS/CoWork-OS', + reportAction = 'learn_from', + expiresAt = '2026-07-05T10:00:00.000Z', + existingExecution = false, + includeRequest = true, +} = {}) { + const dir = await mkdtemp(path.join(tmpdir(), 'tg-approved-executor-')); + const reportPath = path.join(dir, 'report.md'); + const indexPath = path.join(dir, 'index.jsonl'); + const requestsPath = path.join(dir, 'telegram-approval-requests.jsonl'); + const decisionsPath = path.join(dir, 'telegram-approval-decisions.jsonl'); + const plansPath = path.join(dir, 'telegram-execution-plans.jsonl'); + await writeFile(reportPath, discoveryReport(reportRepo, reportAction), 'utf8'); + const { createHash } = await import('node:crypto'); + const reportHash = createHash('sha256').update(await readFile(reportPath, 'utf8')).digest('hex'); + await writeFile(indexPath, `${JSON.stringify({ + report_path: reportPath, + report_hash: reportHash, + idea_title: 'File/document assistant summary starter', + recommendation: reportAction, + top_candidate_summaries: [{ full_name: reportRepo, html_url: `https://github.com/${reportRepo}`, recommendation: reportAction, score: 91, stars: 123 }], + indexed_at: '2026-07-05T08:00:00.000Z', + sanitized: true, + })}\n`, 'utf8'); + if (includeRequest) { + await writeFile(requestsPath, `${JSON.stringify({ + approval_id: APPROVAL_ID, + report_path: reportPath, + report_hash: reportHash, + sent_at: '2026-07-05T08:00:00.000Z', + expires_at: expiresAt, + selected_repo: requestRepo, + recommended_next_action: requestAction, + message_id: 77, + status: 'requested', + })}\n`, 'utf8'); + } + await writeFile(decisionsPath, `${JSON.stringify({ + approval_id: APPROVAL_ID, + decision, + decided_at: '2026-07-05T08:10:00.000Z', + report_path: reportPath, + report_hash: reportHash, + selected_repo: decisionRepo, + recommended_next_action: decisionAction, + telegram_update_id: 1, + telegram_message_id: 2, + telegram_chat_verified: true, + status: decision, + sanitized: true, + })}\n`, 'utf8'); + if (existingExecution) { + await writeFile(plansPath, `${JSON.stringify({ approval_id: APPROVAL_ID, status: 'planned', sanitized: true })}\n`, 'utf8'); + } + return { dir, reportPath, indexPath, requestsPath, decisionsPath, plansPath }; +} + +async function run(f, extra = {}) { + return createApprovedDiscoveryExecutionPlan({ + approvalId: extra.approvalId || APPROVAL_ID, + requestsPath: f.requestsPath, + decisionsPath: f.decisionsPath, + plansPath: f.plansPath, + indexPath: f.indexPath, + dryRun: true, + now: new Date('2026-07-05T09:00:00.000Z'), + ...extra, + }); +} + +describe('telegram approved discovery executor dry-run planner', () => { + it('approved learn_from decision produces a safe read-only plan', async () => { + const f = await fixture(); + const result = await run(f); + expect(result.ok).toBe(true); + expect(result.executed).toBe(false); + expect(result.planWritten).toBe(false); + expect(result.plan.selected_repo).toBe('CoWork-OS/CoWork-OS'); + expect(result.plan.recommended_next_action).toBe('learn_from'); + expect(result.plan.steps.map((step) => step.id)).toEqual([ + 'fetch_public_repo_metadata', + 'fetch_readme_metadata_or_summary', + 'inspect_license_metadata', + 'inspect_package_manifest_metadata', + 'produce_implementation_notes', + 'produce_next_action_recommendation', + ]); + expect(result.plan.steps.every((step) => step.writes === false)).toBe(true); + expect(result.telegramSend).toBe(false); + expect(result.githubWrites).toBe(false); + expect(result.clone).toBe(false); + expect(result.fork).toBe(false); + expect(result.auditAppend).toBe(false); + expect(result.durableMutation).toBe(false); + expect(result.obsidianKanbanWrites).toBe(false); + }); + + it('rejected decision blocks', async () => { + const f = await fixture({ decision: 'rejected' }); + const result = await run(f); + expect(result.ok).toBe(false); + expect(result.reason).toBe('DECISION_NOT_APPROVED'); + }); + + it('unknown approval_id blocks', async () => { + const f = await fixture({ includeRequest: false }); + const result = await run(f, { approvalId: 'tg4_unknownapproval' }); + expect(result.ok).toBe(false); + expect(result.reason).toBe('UNKNOWN_APPROVAL_ID'); + }); + + it('expired request blocks', async () => { + const f = await fixture({ expiresAt: '2026-07-05T08:59:59.000Z' }); + const result = await run(f); + expect(result.ok).toBe(false); + expect(result.reason).toBe('EXPIRED_APPROVAL_ID'); + }); + + it('mismatched repo/action blocks', async () => { + const repoMismatch = await fixture({ decisionRepo: 'other/repo' }); + expect((await run(repoMismatch)).reason).toBe('DECISION_REQUEST_MISMATCH'); + + const actionMismatch = await fixture({ decisionAction: 'fork' }); + expect((await run(actionMismatch)).reason).toBe('DECISION_REQUEST_MISMATCH'); + + const reportMismatch = await fixture({ reportRepo: 'other/repo' }); + expect((await run(reportMismatch)).reason).toBe('REQUEST_REPORT_MISMATCH'); + }); + + it('existing execution record blocks replay', async () => { + const f = await fixture({ existingExecution: true }); + const result = await run(f); + expect(result.ok).toBe(false); + expect(result.reason).toBe('EXECUTION_RECORD_EXISTS'); + }); + + it('dry-run writes nothing', async () => { + const f = await fixture(); + const result = await run(f); + expect(result.ok).toBe(true); + expect(result.planWritten).toBe(false); + expect(await exists(f.plansPath)).toBe(false); + }); + + it('--write-plan writes one sanitized plan row only', async () => { + const f = await fixture(); + const result = await run(f, { writePlan: true }); + expect(result.ok).toBe(true); + expect(result.planWritten).toBe(true); + const rows = (await readFile(f.plansPath, 'utf8')).split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ approval_id: APPROVAL_ID, status: 'planned', sanitized: true, executed: false, selected_repo: 'CoWork-OS/CoWork-OS' }); + expect(rows[0].steps.every((step) => step.writes === false)).toBe(true); + expect(JSON.stringify(rows[0])).not.toContain(SECRET_TOKEN); + expect(JSON.stringify(rows[0])).not.toContain(CHAT_ID); + }); + + it('--write-plan is the explicit one-shot write mode and does not require --dry-run', async () => { + const f = await fixture(); + const result = await run(f, { dryRun: false, writePlan: true }); + expect(result.ok).toBe(true); + expect(result.mode).toBe('telegram_approved_discovery_executor_write_plan'); + expect(result.dryRun).toBe(false); + expect(result.executed).toBe(false); + expect(result.planWritten).toBe(true); + const rows = (await readFile(f.plansPath, 'utf8')).split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ approval_id: APPROVAL_ID, status: 'planned', dry_run: false, plan_only: true, executed: false }); + expect(rows[0].steps.every((step) => step.writes === false)).toBe(true); + }); + + it('token/chat/secrets are never printed', async () => { + const f = await fixture(); + const result = await run(f); + const raw = JSON.stringify(result); + expect(raw).not.toContain(SECRET_TOKEN); + expect(raw).not.toContain(CHAT_ID); + expect(raw).not.toMatch(/bot\d+:/); + }); +}); diff --git a/scripts/telegram-approved-discovery-research.mjs b/scripts/telegram-approved-discovery-research.mjs new file mode 100644 index 000000000..df37590a0 --- /dev/null +++ b/scripts/telegram-approved-discovery-research.mjs @@ -0,0 +1,442 @@ +#!/usr/bin/env node +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export const DEFAULT_TELEGRAM_EXECUTION_PLANS_PATH = '/root/.hermes/reports/github-discovery/telegram-execution-plans.jsonl'; +export const DEFAULT_TELEGRAM_EXECUTION_RESULTS_PATH = '/root/.hermes/reports/github-discovery/telegram-execution-results.jsonl'; +export const DEFAULT_RESEARCH_REPORT_DIR = '/root/.hermes/reports/github-discovery/research'; +export const APPROVED_REPO = 'CoWork-OS/CoWork-OS'; +export const APPROVED_ACTION = 'learn_from'; +export const README_CAP = 12000; +export const MANIFEST_CAP = 4000; + +export const SIDE_EFFECT_FLAGS = Object.freeze({ + githubWrites: false, + clone: false, + fork: false, + dependencyInstall: false, + codeExecution: false, + repoCreation: false, + branchPush: false, + prOpen: false, + merge: false, + delete: false, + auditAppend: false, + durableMutation: false, + obsidianKanbanWrites: false, +}); + +const SECRET_PATTERNS = [ + /gh[pousr]_[A-Za-z0-9_]{20,}/g, + /github_pat_[A-Za-z0-9_]{20,}/g, + /bot\d+:[A-Za-z0-9_-]+/g, + /xox[baprs]-[A-Za-z0-9-]+/g, + /["']?(?token|secret|password|api[_-]?key|client[_-]?secret)["']?\s*[:=]\s*["']?[^\s,'\"}]+["']?/gi, +]; + +const MANIFEST_NAMES = new Set([ + 'package.json', + 'pnpm-lock.yaml', + 'yarn.lock', + 'package-lock.json', + 'pyproject.toml', + 'requirements.txt', + 'Pipfile', + 'Cargo.toml', + 'go.mod', + 'pom.xml', + 'build.gradle', + 'build.gradle.kts', + 'Dockerfile', + 'docker-compose.yml', + 'compose.yaml', + 'Gemfile', +]); + +function parseArgs(argv) { + const args = { _: [] }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--json') args.json = true; + else if (arg === '--dry-run') args.dryRun = true; + else if (arg === '--approval-id') args.approvalId = argv[++i]; + else if (arg === '--plans-path') args.plansPath = argv[++i]; + else if (arg === '--results-path') args.resultsPath = argv[++i]; + else if (arg === '--report-dir') args.reportDir = argv[++i]; + else if (arg === '--now') args.now = argv[++i]; + else args._.push(arg); + } + return args; +} + +function requireJson(args) { + if (!args.json) throw new Error('telegram-approved-discovery-research is intentionally JSON-only. Pass --json.'); +} + +function sanitizeText(value, cap = README_CAP) { + let text = String(value || '').replace(/\u0000/g, ''); + for (const pattern of SECRET_PATTERNS) text = text.replace(pattern, (...match) => (match.groups?.key ? `${match.groups.key}=[REDACTED]` : '[REDACTED]')); + text = text.replace(/[\t ]+$/gm, '').slice(0, cap); + return text; +} + +function normalizeAction(value) { + return String(value || '').trim().toLowerCase().replace(/\s+/g, '_'); +} + +async function readJsonl(pathname) { + try { + const content = await readFile(pathname, 'utf8'); + return content.split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)); + } catch (error) { + if (error?.code === 'ENOENT') return []; + throw error; + } +} + +async function appendJsonl(pathname, row) { + await mkdir(path.dirname(pathname), { recursive: true }); + await appendFile(pathname, `${JSON.stringify(row)}\n`, 'utf8'); +} + +function block(reason, extra = {}) { + return { + ok: false, + blocked: true, + reason, + executed: false, + reportWritten: false, + resultWritten: false, + githubCalls: false, + ...SIDE_EFFECT_FLAGS, + ...extra, + note: 'Fail-closed. No research report or execution result row was written.', + }; +} + +function findPlan(rows, approvalId) { + return rows.find((row) => row?.approval_id === approvalId && row?.status === 'planned') || null; +} + +function findResult(rows, approvalId) { + return rows.find((row) => row?.approval_id === approvalId) || null; +} + +function assertApprovedPlan(plan, approvalId) { + if (!plan) return block('PLAN_NOT_FOUND_OR_NOT_PLANNED', { approval_id: approvalId }); + if (plan.status !== 'planned') return block('PLAN_STATUS_NOT_PLANNED', { approval_id: approvalId, status: plan.status || null }); + if (plan.selected_repo !== APPROVED_REPO) return block('SELECTED_REPO_MISMATCH', { approval_id: approvalId, selected_repo: plan.selected_repo || null, expected_repo: APPROVED_REPO }); + if (normalizeAction(plan.recommended_next_action) !== APPROVED_ACTION) return block('ACTION_MISMATCH', { approval_id: approvalId, recommended_next_action: plan.recommended_next_action || null, expected_action: APPROVED_ACTION }); + return null; +} + +function githubApiUrl(route) { + return `https://api.github.com${route}`; +} + +function ghApiJson(route) { + return new Promise((resolve, reject) => { + execFile('gh', ['api', route.replace(/^\//, '')], { timeout: 30000, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => { + if (error) { + reject(new Error(`GitHub read failed via gh for ${route}: ${sanitizeText(stderr || error.message, 500)}`)); + return; + } + try { + resolve(JSON.parse(stdout)); + } catch (parseError) { + reject(new Error(`GitHub read returned invalid JSON for ${route}: ${sanitizeText(parseError.message, 300)}`)); + } + }); + }); +} + +async function githubJson(route, fetchImpl, { allowGhFallback = false } = {}) { + const response = await fetchImpl(githubApiUrl(route), { + method: 'GET', + headers: { + accept: 'application/vnd.github+json', + 'user-agent': 'hermes-approved-discovery-research-readonly', + }, + }); + if (!response.ok) { + if (allowGhFallback && response.status === 403) return ghApiJson(route); + throw new Error(`GitHub read failed ${response.status} for ${route}`); + } + return response.json(); +} + +function decodeBase64Content(item, cap) { + if (!item || item.encoding !== 'base64' || typeof item.content !== 'string') return ''; + return sanitizeText(Buffer.from(item.content.replace(/\s+/g, ''), 'base64').toString('utf8'), cap); +} + +function summarizeReadme(readme) { + const lines = sanitizeText(readme, README_CAP).split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + const headings = lines.filter((line) => /^#{1,3}\s+/.test(line)).slice(0, 12).map((line) => line.replace(/^#+\s*/, '')); + const firstParas = lines.filter((line) => !line.startsWith('#') && !line.startsWith('![') && !line.startsWith('[!')).slice(0, 8); + return sanitizeText([headings.length ? `Headings: ${headings.join(' | ')}` : '', firstParas.join(' ')].filter(Boolean).join('\n'), 2000); +} + +function usefulLessons({ repo, manifests, readmeSummary, languages }) { + const lessons = []; + if (Object.keys(languages || {}).length) lessons.push(`Language mix suggests implementation split: ${Object.keys(languages).slice(0, 5).join(', ')}.`); + if (manifests.some((m) => m.name === 'package.json')) lessons.push('Has Node/package manifest; inspect scripts/dependency shape later before any adoption.'); + if (manifests.some((m) => /Dockerfile|compose/i.test(m.name))) lessons.push('Container/deployment files may provide useful setup/deployment patterns.'); + if (/agent|workflow|workspace|collaboration|task|project/i.test(readmeSummary)) lessons.push('README appears relevant to agent/workspace/collaboration workflow ideas.'); + if (!lessons.length) lessons.push(`Use ${repo.full_name} mainly as a reference for product shape and documentation conventions, not as ready-to-run source.`); + return lessons; +} + +function estimateSetupComplexity(manifests, readmeText) { + const names = manifests.map((m) => m.name); + let score = 1; + if (names.includes('package.json')) score += 1; + if (names.includes('Dockerfile') || names.includes('docker-compose.yml') || names.includes('compose.yaml')) score += 1; + if (/database|postgres|redis|docker|env|\.env|oauth|supabase|firebase/i.test(readmeText)) score += 1; + if (score <= 2) return 'low-to-medium'; + if (score === 3) return 'medium'; + return 'medium-to-high'; +} + +function risks({ repo, license, manifests, readmeText }) { + const out = []; + if (!license?.spdx_id || license.spdx_id === 'NOASSERTION') out.push('License is unclear; reuse should stay learning-only until clarified.'); + if (repo.archived) out.push('Repository is archived.'); + if (!manifests.length) out.push('No common package/manifest files detected at repository root.'); + if (/\.env|secret|token|api key|credential/i.test(readmeText)) out.push('Setup likely depends on external credentials or environment variables; do not copy config blindly.'); + if (!out.length) out.push('No immediate read-only metadata blocker found; still needs later code review before any reuse.'); + return out; +} + +export function buildResearchMarkdown({ approvalId, repo, readmeSummary, license, languages, manifests, lessons, complexity, concerns, now }) { + const manifestLines = manifests.length ? manifests.map((m) => `- ${m.name}${m.summary ? `: ${m.summary}` : ''}`).join('\n') : '- none detected'; + const languageLines = Object.entries(languages || {}).slice(0, 10).map(([name, bytes]) => `- ${name}: ${bytes}`).join('\n') || '- unavailable'; + return sanitizeText(`# Approved GitHub discovery research: ${repo.full_name} + +Approval ID: ${approvalId} +Action: learn_from +Generated: ${new Date(now).toISOString()} +Read-only: true + +## Repo +- Name: ${repo.full_name} +- URL: ${repo.html_url} +- Stars: ${repo.stargazers_count ?? 'unknown'} +- Forks: ${repo.forks_count ?? 'unknown'} +- Open issues: ${repo.open_issues_count ?? 'unknown'} +- Latest activity: ${repo.pushed_at || repo.updated_at || 'unknown'} +- License: ${license?.spdx_id || license?.name || 'unknown'} + +## Languages +${languageLines} + +## Detected manifests +${manifestLines} + +## README summary +${readmeSummary || 'No README summary available.'} + +## Useful architecture/features +${lessons.map((item) => `- ${item}`).join('\n')} + +## Possible reuse lessons +- Treat this as reference material only under the approved learn_from action. +- Compare its manifest and README structure against Hermes discovery/workspace workflows before writing any code. +- Do not clone, install, or execute anything from the repo without a separate approval. + +## Setup complexity estimate +${complexity} + +## Risks/concerns +${concerns.map((item) => `- ${item}`).join('\n')} + +## Recommended next step for Hermes +Create a narrow follow-up design note extracting only the useful workflow/UI/architecture patterns from this repo. Keep implementation local and do not import code until a separate license/code review package is approved. +`, 20000); +} + +async function fetchPublicRepoResearch(repoFullName, fetchImpl, { allowGhFallback = false } = {}) { + if (repoFullName !== APPROVED_REPO) throw new Error('Refusing to fetch any repo outside approved selected_repo.'); + const [owner, repoName] = repoFullName.split('/'); + const read = (route) => githubJson(route, fetchImpl, { allowGhFallback }); + const repo = await read(`/repos/${owner}/${repoName}`); + if (repo.full_name !== APPROVED_REPO || repo.html_url !== `https://github.com/${APPROVED_REPO}`) { + throw new Error('GitHub repo lookup did not resolve to the approved selected_repo.'); + } + const [languages, rootContents, readmeResult, licenseResult] = await Promise.all([ + read(`/repos/${owner}/${repoName}/languages`).catch(() => ({})), + read(`/repos/${owner}/${repoName}/contents`).catch(() => []), + read(`/repos/${owner}/${repoName}/readme`).catch(() => null), + read(`/repos/${owner}/${repoName}/license`).catch(() => null), + ]); + const rootFiles = Array.isArray(rootContents) ? rootContents.filter((item) => item?.type === 'file') : []; + const manifestFiles = rootFiles.filter((item) => MANIFEST_NAMES.has(item.name)).slice(0, 8); + const manifestPayloads = await Promise.all(manifestFiles.map(async (item) => { + try { + const contentItem = await read(`/repos/${owner}/${repoName}/contents/${encodeURIComponent(item.name)}`); + const content = decodeBase64Content(contentItem, MANIFEST_CAP); + return { name: item.name, size: item.size || content.length, summary: content.split(/\r?\n/).slice(0, 12).join(' ').slice(0, 500), content_capped: true }; + } catch { + return { name: item.name, size: item.size || null, summary: '', content_capped: true }; + } + })); + const readmeText = decodeBase64Content(readmeResult, README_CAP); + const readmeSummary = summarizeReadme(readmeText); + const license = licenseResult?.license || repo.license || null; + const lessons = usefulLessons({ repo, manifests: manifestPayloads, readmeSummary, languages }); + const complexity = estimateSetupComplexity(manifestPayloads, readmeText); + const concerns = risks({ repo, license, manifests: manifestPayloads, readmeText }); + return { repo, languages, manifests: manifestPayloads, readmeSummary, license, lessons, complexity, concerns }; +} + +function safeReportName(approvalId) { + return `${approvalId.replace(/[^a-zA-Z0-9_-]/g, '_')}-CoWork-OS_CoWork-OS-research.md`; +} + +function resultRow({ approvalId, plan, research, reportPath, reportHash, now }) { + return { + approval_id: approvalId, + status: 'completed', + executed_at: new Date(now).toISOString(), + action: APPROVED_ACTION, + selected_repo: APPROVED_REPO, + report_path: reportPath, + report_hash: reportHash, + github_read_only_calls: true, + github_writes: false, + clone: false, + fork: false, + dependency_install: false, + code_execution: false, + repo_creation: false, + branch_push: false, + pr_open: false, + merge: false, + delete: false, + audit_append: false, + durable_mutation: false, + obsidian_kanban_writes: false, + telegram_completion_sent: false, + telegram_message_id: null, + sanitized: true, + repo: { + full_name: research.repo.full_name, + html_url: research.repo.html_url, + stars: research.repo.stargazers_count ?? null, + forks: research.repo.forks_count ?? null, + open_issues: research.repo.open_issues_count ?? null, + latest_activity: research.repo.pushed_at || research.repo.updated_at || null, + license: research.license?.spdx_id || research.license?.name || null, + languages: Object.keys(research.languages || {}).slice(0, 10), + manifests: research.manifests.map((m) => ({ name: m.name, size: m.size ?? null })), + setup_complexity_estimate: research.complexity, + }, + source_plan: { + plan_type: plan.plan_type || null, + plan_only: plan.plan_only === true, + status: plan.status, + }, + }; +} + +export async function runApprovedDiscoveryResearch({ + approvalId, + plansPath = DEFAULT_TELEGRAM_EXECUTION_PLANS_PATH, + resultsPath = DEFAULT_TELEGRAM_EXECUTION_RESULTS_PATH, + reportDir = DEFAULT_RESEARCH_REPORT_DIR, + dryRun = false, + now = new Date(), + fetchImpl = globalThis.fetch, + allowGhFallback = true, +} = {}) { + if (!approvalId) throw new Error('telegram-approved-discovery-research requires --approval-id.'); + const [plans, results] = await Promise.all([readJsonl(plansPath), readJsonl(resultsPath)]); + const plan = findPlan(plans, approvalId); + const planBlock = assertApprovedPlan(plan, approvalId); + if (planBlock) return planBlock; + if (findResult(results, approvalId)) return block('EXECUTION_RESULT_EXISTS_REPLAY_BLOCKED', { approval_id: approvalId }); + + const dryRunBase = { + ok: true, + blocked: false, + mode: dryRun ? 'approved_discovery_research_dry_run' : 'approved_discovery_research_execute', + approval_id: approvalId, + dryRun, + selected_repo: APPROVED_REPO, + recommended_next_action: APPROVED_ACTION, + report_dir: reportDir, + results_path: resultsPath, + executed: false, + reportWritten: false, + resultWritten: false, + githubCalls: false, + ...SIDE_EFFECT_FLAGS, + }; + if (dryRun) return { ...dryRunBase, note: 'Dry-run only. Validated plan/replay gates; no GitHub call and no writes.' }; + + if (typeof fetchImpl !== 'function') throw new Error('fetch is unavailable for public GitHub read-only research.'); + let research; + try { + research = await fetchPublicRepoResearch(APPROVED_REPO, fetchImpl, { allowGhFallback }); + } catch (error) { + return block('GITHUB_READ_FAILED_NO_RESULT_WRITTEN', { approval_id: approvalId, githubCalls: true, error: sanitizeText(error?.message || error, 500) }); + } + + const markdown = buildResearchMarkdown({ approvalId, ...research, now }); + const reportPath = path.join(reportDir, safeReportName(approvalId)); + const reportHash = createHash('sha256').update(markdown).digest('hex'); + const row = resultRow({ approvalId, plan, research, reportPath, reportHash, now }); + await mkdir(reportDir, { recursive: true }); + await writeFile(reportPath, markdown, 'utf8'); + await appendJsonl(resultsPath, row); + return { + ...dryRunBase, + ok: true, + mode: 'approved_discovery_research_execute', + executed: true, + reportWritten: true, + resultWritten: true, + githubCalls: true, + report_path: reportPath, + report_hash: reportHash, + result_row: row, + research_summary: row.repo, + note: 'Completed read-only public GitHub research and appended one sanitized execution result row.', + }; +} + +export async function runApprovedDiscoveryResearchCli(argv = process.argv.slice(2)) { + const args = parseArgs(argv); + requireJson(args); + const result = await runApprovedDiscoveryResearch({ + approvalId: args.approvalId, + plansPath: args.plansPath || DEFAULT_TELEGRAM_EXECUTION_PLANS_PATH, + resultsPath: args.resultsPath || DEFAULT_TELEGRAM_EXECUTION_RESULTS_PATH, + reportDir: args.reportDir || DEFAULT_RESEARCH_REPORT_DIR, + dryRun: args.dryRun === true, + now: args.now ? new Date(args.now) : new Date(), + }); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + if (!result.ok) process.exitCode = 1; +} + +if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { + runApprovedDiscoveryResearchCli().catch((error) => { + const safe = { + ok: false, + blocked: true, + error: sanitizeText(error?.message || error, 500), + executed: false, + reportWritten: false, + resultWritten: false, + githubCalls: false, + ...SIDE_EFFECT_FLAGS, + note: 'No action taken.', + }; + process.stdout.write(`${JSON.stringify(safe, null, 2)}\n`); + process.exitCode = 1; + }); +} diff --git a/scripts/telegram-approved-discovery-research.test.mjs b/scripts/telegram-approved-discovery-research.test.mjs new file mode 100644 index 000000000..a718181dd --- /dev/null +++ b/scripts/telegram-approved-discovery-research.test.mjs @@ -0,0 +1,152 @@ +import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { runApprovedDiscoveryResearch } from './telegram-approved-discovery-research.mjs'; + +const APPROVAL_ID = 'tg4_d3fd7da71ae557f3'; + +async function exists(pathname) { + try { + await stat(pathname); + return true; + } catch (error) { + if (error?.code === 'ENOENT') return false; + throw error; + } +} + +async function fixture({ repo = 'CoWork-OS/CoWork-OS', action = 'learn_from', status = 'planned', existingResult = false } = {}) { + const dir = await mkdtemp(path.join(tmpdir(), 'tg-research-')); + const plansPath = path.join(dir, 'telegram-execution-plans.jsonl'); + const resultsPath = path.join(dir, 'telegram-execution-results.jsonl'); + const reportDir = path.join(dir, 'research'); + await writeFile(plansPath, `${JSON.stringify({ + approval_id: APPROVAL_ID, + status, + selected_repo: repo, + recommended_next_action: action, + plan_type: 'learn_from_read_only_research', + plan_only: true, + executed: false, + sanitized: true, + })}\n`, 'utf8'); + if (existingResult) await writeFile(resultsPath, `${JSON.stringify({ approval_id: APPROVAL_ID, status: 'completed', sanitized: true })}\n`, 'utf8'); + return { dir, plansPath, resultsPath, reportDir }; +} + +function response(body, ok = true, status = 200) { + return { ok, status, json: async () => body }; +} + +function fakeGithubFetch({ fail = false } = {}) { + const calls = []; + const fetchImpl = async (url, options) => { + calls.push({ url, method: options?.method || 'GET' }); + if (fail) return response({ message: 'boom' }, false, 503); + if (url.endsWith('/repos/CoWork-OS/CoWork-OS')) { + return response({ + full_name: 'CoWork-OS/CoWork-OS', + html_url: 'https://github.com/CoWork-OS/CoWork-OS', + stargazers_count: 42, + forks_count: 7, + open_issues_count: 3, + pushed_at: '2026-07-01T00:00:00Z', + updated_at: '2026-07-02T00:00:00Z', + archived: false, + license: { spdx_id: 'MIT', name: 'MIT License' }, + }); + } + if (url.endsWith('/languages')) return response({ TypeScript: 1000, JavaScript: 500 }); + if (url.endsWith('/contents')) return response([{ type: 'file', name: 'package.json', size: 120 }, { type: 'file', name: 'Dockerfile', size: 80 }]); + if (url.endsWith('/readme')) return response({ encoding: 'base64', content: Buffer.from('# CoWork OS\nAgent workspace and project workflow. token=SHOULD_NOT_SURVIVE\n').toString('base64') }); + if (url.endsWith('/license')) return response({ license: { spdx_id: 'MIT', name: 'MIT License' } }); + if (url.includes('/contents/package.json')) return response({ encoding: 'base64', content: Buffer.from('{"scripts":{"dev":"vite"},"secret":"SHOULD_NOT_SURVIVE"}').toString('base64') }); + if (url.includes('/contents/Dockerfile')) return response({ encoding: 'base64', content: Buffer.from('FROM node:22-alpine').toString('base64') }); + throw new Error(`unexpected URL ${url}`); + }; + return { fetchImpl, calls }; +} + +async function readRows(pathname) { + return (await readFile(pathname, 'utf8')).split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)); +} + +describe('telegram approved discovery research executor', () => { + it('dry-run performs no GitHub call and writes nothing', async () => { + const f = await fixture(); + const fake = fakeGithubFetch(); + const result = await runApprovedDiscoveryResearch({ approvalId: APPROVAL_ID, plansPath: f.plansPath, resultsPath: f.resultsPath, reportDir: f.reportDir, dryRun: true, fetchImpl: fake.fetchImpl }); + expect(result.ok).toBe(true); + expect(result.githubCalls).toBe(false); + expect(result.reportWritten).toBe(false); + expect(result.resultWritten).toBe(false); + expect(fake.calls).toHaveLength(0); + expect(await exists(f.resultsPath)).toBe(false); + expect(await exists(f.reportDir)).toBe(false); + }); + + it('mocked read-only GitHub research writes one report and one result row', async () => { + const f = await fixture(); + const fake = fakeGithubFetch(); + const result = await runApprovedDiscoveryResearch({ approvalId: APPROVAL_ID, plansPath: f.plansPath, resultsPath: f.resultsPath, reportDir: f.reportDir, now: new Date('2026-07-05T10:00:00Z'), fetchImpl: fake.fetchImpl }); + expect(result.ok).toBe(true); + expect(result.executed).toBe(true); + expect(result.githubCalls).toBe(true); + expect(result.githubWrites).toBe(false); + expect(result.clone).toBe(false); + expect(result.fork).toBe(false); + expect(result.dependencyInstall).toBe(false); + expect(result.codeExecution).toBe(false); + const report = await readFile(result.report_path, 'utf8'); + expect(report).toContain('CoWork-OS/CoWork-OS'); + expect(report).toContain('README summary'); + expect(report).not.toContain('SHOULD_NOT_SURVIVE'); + const rows = await readRows(f.resultsPath); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ approval_id: APPROVAL_ID, status: 'completed', selected_repo: 'CoWork-OS/CoWork-OS', github_writes: false, clone: false, fork: false, dependency_install: false, code_execution: false, sanitized: true }); + }); + + it('replay blocks after one result row exists', async () => { + const f = await fixture({ existingResult: true }); + const fake = fakeGithubFetch(); + const result = await runApprovedDiscoveryResearch({ approvalId: APPROVAL_ID, plansPath: f.plansPath, resultsPath: f.resultsPath, reportDir: f.reportDir, fetchImpl: fake.fetchImpl }); + expect(result.ok).toBe(false); + expect(result.reason).toBe('EXECUTION_RESULT_EXISTS_REPLAY_BLOCKED'); + expect(fake.calls).toHaveLength(0); + }); + + it('wrong repo/action blocks before GitHub calls', async () => { + const wrongRepo = await fixture({ repo: 'other/repo' }); + const fakeRepo = fakeGithubFetch(); + expect((await runApprovedDiscoveryResearch({ approvalId: APPROVAL_ID, plansPath: wrongRepo.plansPath, resultsPath: wrongRepo.resultsPath, reportDir: wrongRepo.reportDir, fetchImpl: fakeRepo.fetchImpl })).reason).toBe('SELECTED_REPO_MISMATCH'); + expect(fakeRepo.calls).toHaveLength(0); + + const wrongAction = await fixture({ action: 'fork' }); + const fakeAction = fakeGithubFetch(); + expect((await runApprovedDiscoveryResearch({ approvalId: APPROVAL_ID, plansPath: wrongAction.plansPath, resultsPath: wrongAction.resultsPath, reportDir: wrongAction.reportDir, fetchImpl: fakeAction.fetchImpl })).reason).toBe('ACTION_MISMATCH'); + expect(fakeAction.calls).toHaveLength(0); + }); + + it('GitHub failure writes no result unless failed-result mode exists', async () => { + const f = await fixture(); + const fake = fakeGithubFetch({ fail: true }); + const result = await runApprovedDiscoveryResearch({ approvalId: APPROVAL_ID, plansPath: f.plansPath, resultsPath: f.resultsPath, reportDir: f.reportDir, fetchImpl: fake.fetchImpl }); + expect(result.ok).toBe(false); + expect(result.reason).toBe('GITHUB_READ_FAILED_NO_RESULT_WRITTEN'); + expect(result.githubCalls).toBe(true); + expect(await exists(f.resultsPath)).toBe(false); + expect(await exists(f.reportDir)).toBe(false); + }); + + it('secrets are redacted from result/report output', async () => { + const f = await fixture(); + const fake = fakeGithubFetch(); + const result = await runApprovedDiscoveryResearch({ approvalId: APPROVAL_ID, plansPath: f.plansPath, resultsPath: f.resultsPath, reportDir: f.reportDir, fetchImpl: fake.fetchImpl }); + const raw = JSON.stringify(result) + await readFile(result.report_path, 'utf8') + await readFile(f.resultsPath, 'utf8'); + expect(raw).not.toContain('SHOULD_NOT_SURVIVE'); + expect(raw).not.toMatch(/gh[pousr]_/); + expect(raw).not.toMatch(/github_pat_/); + expect(raw).not.toMatch(/bot\d+:/); + }); +}); diff --git a/scripts/telegram-approved-implementation-build-plan.mjs b/scripts/telegram-approved-implementation-build-plan.mjs new file mode 100644 index 000000000..8ce4e3bb6 --- /dev/null +++ b/scripts/telegram-approved-implementation-build-plan.mjs @@ -0,0 +1,528 @@ +#!/usr/bin/env node +import { createHash } from 'node:crypto' +import { appendFile, mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +export const DEFAULT_PROPOSALS_PATH = + '/root/.hermes/reports/github-discovery/telegram-implementation-proposals.jsonl' +export const DEFAULT_APPROVAL_DECISIONS_PATH = + '/root/.hermes/reports/github-discovery/telegram-approval-decisions.jsonl' +export const DEFAULT_BUILD_PLANS_PATH = + '/root/.hermes/reports/github-discovery/telegram-build-plans.jsonl' +export const DEFAULT_BUILD_PLAN_REPORT_DIR = + '/root/.hermes/reports/github-discovery/build-plans' +export const APPROVED_PROPOSAL_ID = 'impl_tg4_d3fd7da71ae557f3_8f61483465' +export const PARENT_APPROVAL_ID = 'tg4_d3fd7da71ae557f3' + +export const SIDE_EFFECT_FLAGS = Object.freeze({ + planOnly: true, + codeEdits: false, + fileWritesOutsideBuildPlanState: false, + githubWrites: false, + githubCalls: false, + clone: false, + fork: false, + install: false, + commandExecution: false, + branch: false, + pr: false, + merge: false, + delete: false, + auditAppend: false, + durableMutation: false, + obsidianKanbanWrites: false, + telegramSent: false, +}) + +const SECRET_PATTERNS = [ + /gh[pousr]_[A-Za-z0-9_]{20,}/g, + /github_pat_[A-Za-z0-9_]{20,}/g, + /bot\d+:[A-Za-z0-9_-]+/g, + /\b-?\d{8,15}\b/g, + /["']?(?token|secret|password|api[_-]?key|client[_-]?secret|chat[_-]?id)["']?\s*[:=]\s*["']?[^\s,'\"}]+["']?/gi, +] + +function parseArgs(argv) { + const args = { _: [] } + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i] + if (arg === '--json') args.json = true + else if (arg === '--dry-run') args.dryRun = true + else if (arg === '--send-telegram') args.sendTelegram = true + else if (arg === '--proposal-id') args.proposalId = argv[++i] + else if (arg === '--target-workspace') args.targetWorkspace = argv[++i] + else if (arg === '--proposals-path') args.proposalsPath = argv[++i] + else if (arg === '--approval-decisions-path') args.approvalDecisionsPath = argv[++i] + else if (arg === '--build-plans-path') args.buildPlansPath = argv[++i] + else if (arg === '--build-plan-report-dir') args.buildPlanReportDir = argv[++i] + else if (arg === '--now') args.now = argv[++i] + else args._.push(arg) + } + return args +} + +function requireJson(args) { + if (!args.json) + throw new Error( + 'telegram-approved-implementation-build-plan is intentionally JSON-only. Pass --json.', + ) +} + +export function sanitizeText(value, cap = 12000) { + let text = String(value || '').replace(/\u0000/g, '') + for (const pattern of SECRET_PATTERNS) { + text = text.replace(pattern, (...match) => + match.groups?.key ? `${match.groups.key}=[REDACTED]` : '[REDACTED]', + ) + } + return text.replace(/[\t ]+$/gm, '').slice(0, cap) +} + +async function readJsonl(pathname) { + try { + const content = await readFile(pathname, 'utf8') + return content + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line)) + } catch (error) { + if (error?.code === 'ENOENT') return [] + throw error + } +} + +async function appendJsonl(pathname, row) { + await mkdir(path.dirname(pathname), { recursive: true }) + await appendFile(pathname, `${JSON.stringify(row)}\n`, 'utf8') +} + +async function pathExists(pathname) { + try { + await stat(pathname) + return true + } catch (error) { + if (error?.code === 'ENOENT') return false + throw error + } +} + +function safeIdPart(value) { + return String(value || '').replace(/[^a-zA-Z0-9_-]/g, '_') +} + +function buildPlanId(proposalId, targetWorkspace) { + const hash = createHash('sha256') + .update(`${proposalId}\n${targetWorkspace}`) + .digest('hex') + .slice(0, 10) + return `build_${safeIdPart(proposalId)}_${hash}` +} + +function safeReportName(buildPlanIdValue) { + return `${safeIdPart(buildPlanIdValue)}.md` +} + +function block(reason, extra = {}) { + return { + ok: false, + blocked: true, + reason, + executed: false, + buildPlanWritten: false, + reportWritten: false, + ...SIDE_EFFECT_FLAGS, + ...extra, + note: 'Fail-closed. No build plan row or report was written.', + } +} + +function findProposal(rows, proposalId) { + return rows.find((row) => row?.proposal_id === proposalId) || null +} + +function findApprovedDecision(rows, proposalId) { + return ( + rows.find( + (row) => + row?.approval_id === proposalId && + row?.decision === 'approved' && + row?.status === 'approved', + ) || null + ) +} + +function findParentDecision(rows, parentApprovalId) { + return ( + rows.find( + (row) => + row?.approval_id === parentApprovalId && + row?.decision === 'approved' && + row?.status === 'approved', + ) || null + ) +} + +function findExistingPlan(rows, proposalId) { + return rows.find((row) => row?.proposal_id === proposalId) || null +} + +async function directoryExists(pathname) { + try { + const info = await stat(pathname) + return info.isDirectory() + } catch (error) { + if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return false + throw error + } +} + +async function findRepoRootCandidate(start = process.cwd()) { + let current = path.resolve(start) + while (true) { + if (await directoryExists(path.join(current, '.git'))) return current + const parent = path.dirname(current) + if (parent === current) return null + current = parent + } +} + +async function likelyWorkspaceCandidates() { + const candidates = [] + const addCandidate = async (candidate) => { + if (!candidate) return + const resolved = path.resolve(candidate) + if (!path.isAbsolute(resolved)) return + if (!(await directoryExists(resolved))) return + if (!candidates.includes(resolved)) candidates.push(resolved) + } + + const repoRoot = await findRepoRootCandidate() + await addCandidate(repoRoot) + await addCandidate(process.cwd()) + + const workspaceParent = process.env.HERMES_WORKSPACE_PARENT + if (workspaceParent && (await directoryExists(workspaceParent))) { + const entries = await readdir(workspaceParent, { withFileTypes: true }) + for (const entry of entries) { + if (entry.isDirectory()) await addCandidate(path.join(workspaceParent, entry.name)) + } + } + + return candidates +} + +function validateProposalSafety(proposal) { + const unsafeScope = /\b(clone|fork|install|npm install|pnpm install|execute repo|run repo|github write|open pr|merge|delete branch|push)\b/i + const scope = proposal?.proposed_scope || '' + if (unsafeScope.test(scope) && !/without importing code|No execution|do not import code/i.test(scope)) { + return block('UNSAFE_FILE_SCOPE_BLOCKED', { proposal_id: proposal?.proposal_id || null }) + } + const expectedFiles = proposal?.files_or_modules_expected_to_touch || [] + if (!Array.isArray(expectedFiles)) + return block('UNSAFE_FILE_SCOPE_BLOCKED', { proposal_id: proposal?.proposal_id || null }) + if ( + expectedFiles.some((item) => + /(^|\/)(\.env|\.git|node_modules|\.ssh)(\/|$)|token|secret|password|credential/i.test( + String(item), + ), + ) + ) { + return block('UNSAFE_FILE_SCOPE_BLOCKED', { proposal_id: proposal?.proposal_id || null }) + } + return null +} + +function buildPlan({ proposal, targetWorkspace, now }) { + const id = buildPlanId(proposal.proposal_id, targetWorkspace) + const expectedFiles = [ + 'docs/github-discovery/cowork-os-pattern-adaptation.md', + 'docs/github-discovery/cowork-os-pattern-adaptation.test-plan.md', + ] + const plan = { + build_plan_id: id, + proposal_id: proposal.proposal_id, + parent_approval_id: proposal.parent_approval_id, + recommended_build_type: proposal.recommended_build_type, + target_repo_or_workspace: targetWorkspace, + expected_files_to_touch: expectedFiles, + forbidden_files_or_paths: [ + '.env', + '.env.*', + '.git/**', + 'node_modules/**', + 'package.json', + 'pnpm-lock.yaml', + 'package-lock.json', + 'yarn.lock', + 'src/**', + 'electron/**', + 'server-entry.js', + 'swarm.yaml', + '/root/.hermes/audit/**', + '/root/.hermes/github-connector-durable-store/**', + '/mnt/lachlan-pc-obsidian/**', + ], + implementation_steps: [ + 'Preflight: confirm clean staged index for the two approved docs paths only and inspect current docs folder shape.', + 'Create a concise design note summarising CoWork-OS patterns relevant to Hermes/LifeOS without copying source code.', + 'Create a companion test-plan note describing future validation gates for any later UX/prototype implementation.', + 'Run formatting/spell-safe checks that do not install dependencies or touch runtime state.', + 'Run repository status checks scoped to the two expected docs paths and confirm forbidden paths are untouched.', + 'Stop for final code edit approval before any implementation, prototype, dashboard, route, service, or dependency edits.', + ], + tests_to_run: [ + 'git diff --check -- docs/github-discovery/cowork-os-pattern-adaptation.md docs/github-discovery/cowork-os-pattern-adaptation.test-plan.md', + 'git status --short -- docs/github-discovery/cowork-os-pattern-adaptation.md docs/github-discovery/cowork-os-pattern-adaptation.test-plan.md', + 'static scan: no token/chat_id/API key/password strings in the two generated docs', + 'static scan: no source-code import/copy instructions from CoWork-OS in the design note', + ], + acceptance_criteria: [ + 'Only the two expected docs paths are created or modified.', + 'No CoWork-OS source code is copied, cloned, forked, installed, executed, or vendored.', + 'The design note is actionable for Hermes/LifeOS and explicitly marks implementation as a later approval-gated stage.', + 'The test-plan note lists concrete future checks without running them in this package.', + 'GitHub writes, audit append, durable mutation, Obsidian/Kanban writes, Telegram sends, branch/PR/merge/delete, clone/fork/install/run all remain false.', + ], + rollback_plan: [ + 'Delete docs/github-discovery/cowork-os-pattern-adaptation.md if created by the approved edit package.', + 'Delete docs/github-discovery/cowork-os-pattern-adaptation.test-plan.md if created by the approved edit package.', + 'Remove the matching build plan row and report only if explicitly rolling back this plan metadata.', + 'No external service rollback should be needed because the build is local docs-only and side-effect-gated.', + ], + risk_level: 'low', + requires_final_code_edit_approval: true, + approval_command: `/approve ${id}`, + reject_command: `/reject ${id}`, + created_at: new Date(now).toISOString(), + source_repo: sanitizeText(proposal.source_repo || '', 500) || null, + source_proposal_report_path: sanitizeText( + proposal.proposal_report_path || '', + 1000, + ) || null, + sanitized: true, + ...SIDE_EFFECT_FLAGS, + } + return plan +} + +function validateBuildPlan(plan) { + const serialized = JSON.stringify(plan) + if (/gh[pousr]_|github_pat_|bot\d+:/i.test(serialized)) + return block('UNREDACTED_SECRET_DETECTED', { build_plan_id: plan.build_plan_id }) + if (/chat[_-]?id\s*[:=]/i.test(serialized)) + return block('UNREDACTED_CHAT_IDENTIFIER_DETECTED', { + build_plan_id: plan.build_plan_id, + }) + if (plan.requires_final_code_edit_approval !== true) + return block('FINAL_CODE_EDIT_APPROVAL_GATE_MISSING', { + build_plan_id: plan.build_plan_id, + }) + if ( + !Array.isArray(plan.expected_files_to_touch) || + plan.expected_files_to_touch.length === 0 || + plan.expected_files_to_touch.some((item) => + /(^|\/)(\.env|\.git|node_modules|\.ssh)(\/|$)|token|secret|password|credential/i.test( + String(item), + ), + ) + ) { + return block('UNSAFE_FILE_SCOPE_BLOCKED', { build_plan_id: plan.build_plan_id }) + } + return null +} + +function buildPlanMarkdown(plan) { + return sanitizeText( + `# Telegram approved implementation build plan + +Build plan ID: ${plan.build_plan_id} +Proposal ID: ${plan.proposal_id} +Parent approval ID: ${plan.parent_approval_id} +Recommended build type: ${plan.recommended_build_type} +Target repo/workspace: ${plan.target_repo_or_workspace} +Risk level: ${plan.risk_level} +Requires final code edit approval: true + +## Expected files to touch +${plan.expected_files_to_touch.map((item) => `- ${item}`).join('\n')} + +## Forbidden files or paths +${plan.forbidden_files_or_paths.map((item) => `- ${item}`).join('\n')} + +## Implementation steps +${plan.implementation_steps.map((item) => `- ${item}`).join('\n')} + +## Tests to run +${plan.tests_to_run.map((item) => `- ${item}`).join('\n')} + +## Acceptance criteria +${plan.acceptance_criteria.map((item) => `- ${item}`).join('\n')} + +## Rollback plan +${plan.rollback_plan.map((item) => `- ${item}`).join('\n')} + +## Approval commands +- ${plan.approval_command} +- ${plan.reject_command} +`, + 20000, + ) +} + +export async function runApprovedImplementationBuildPlan({ + proposalId, + targetWorkspace, + proposalsPath = DEFAULT_PROPOSALS_PATH, + approvalDecisionsPath = DEFAULT_APPROVAL_DECISIONS_PATH, + buildPlansPath = DEFAULT_BUILD_PLANS_PATH, + buildPlanReportDir = DEFAULT_BUILD_PLAN_REPORT_DIR, + dryRun = false, + sendTelegram = false, + now = new Date(), +} = {}) { + if (!proposalId) + throw new Error( + 'telegram-approved-implementation-build-plan requires --proposal-id.', + ) + if (sendTelegram) + return block('TELEGRAM_SEND_NOT_IMPLEMENTED_FOR_THIS_PACKAGE', { + proposal_id: proposalId, + telegramSent: false, + }) + + const [proposals, decisions, existingPlans] = await Promise.all([ + readJsonl(proposalsPath), + readJsonl(approvalDecisionsPath), + readJsonl(buildPlansPath), + ]) + const proposal = findProposal(proposals, proposalId) + if (!proposal) return block('PROPOSAL_NOT_FOUND', { proposal_id: proposalId }) + if (proposalId !== APPROVED_PROPOSAL_ID) + return block('PROPOSAL_NOT_ALLOWED_FOR_THIS_PACKAGE', { + proposal_id: proposalId, + expected_proposal_id: APPROVED_PROPOSAL_ID, + }) + const approvalDecision = findApprovedDecision(decisions, proposalId) + if (!approvalDecision) + return block('PROPOSAL_NOT_APPROVED', { proposal_id: proposalId }) + const parentDecision = findParentDecision(decisions, proposal.parent_approval_id) + if (!parentDecision) + return block('PARENT_APPROVAL_NOT_APPROVED', { + proposal_id: proposalId, + parent_approval_id: proposal.parent_approval_id || null, + }) + if (proposal.parent_approval_id !== PARENT_APPROVAL_ID) + return block('PARENT_APPROVAL_MISMATCH', { + proposal_id: proposalId, + parent_approval_id: proposal.parent_approval_id || null, + expected_parent_approval_id: PARENT_APPROVAL_ID, + }) + const existing = findExistingPlan(existingPlans, proposalId) + if (existing) + return block('BUILD_PLAN_EXISTS_DUPLICATE_BLOCKED', { + proposal_id: proposalId, + build_plan_id: existing.build_plan_id || null, + }) + const proposalUnsafe = validateProposalSafety(proposal) + if (proposalUnsafe) return proposalUnsafe + + const candidates = await likelyWorkspaceCandidates() + if (!targetWorkspace) { + return block('BLOCKED_TARGET_WORKSPACE_REQUIRED', { + proposal_id: proposalId, + parent_approval_id: proposal.parent_approval_id, + required_field: '--target-workspace ', + likely_candidates: candidates, + message: + 'Target repo/workspace is unknown. Provide the exact workspace/repo path; candidates are reported but not selected automatically.', + }) + } + if (!path.isAbsolute(targetWorkspace)) + return block('TARGET_WORKSPACE_MUST_BE_ABSOLUTE', { + proposal_id: proposalId, + target_repo_or_workspace: targetWorkspace, + }) + if (!(await pathExists(targetWorkspace))) + return block('TARGET_WORKSPACE_NOT_FOUND', { + proposal_id: proposalId, + target_repo_or_workspace: targetWorkspace, + }) + + const plan = buildPlan({ proposal, targetWorkspace, now }) + const unsafe = validateBuildPlan(plan) + if (unsafe) return unsafe + const markdown = buildPlanMarkdown(plan) + const buildPlanReportPath = path.join( + buildPlanReportDir, + safeReportName(plan.build_plan_id), + ) + const row = { + ...plan, + build_plan_report_path: buildPlanReportPath, + build_plan_report_hash: createHash('sha256').update(markdown).digest('hex'), + } + const base = { + ok: true, + blocked: false, + mode: dryRun ? 'build_plan_dry_run' : 'build_plan_write', + proposal_id: proposalId, + parent_approval_id: proposal.parent_approval_id, + build_plan_id: plan.build_plan_id, + dryRun, + build_plans_path: buildPlansPath, + build_plan_report_path: buildPlanReportPath, + build_plan: row, + executed: false, + buildPlanWritten: false, + reportWritten: false, + ...SIDE_EFFECT_FLAGS, + note: dryRun + ? 'Dry-run only. Build plan generated in JSON; no build plan row or report written.' + : 'Build plan written only. Final code edit approval is still required before implementation.', + } + if (dryRun) return base + + await mkdir(buildPlanReportDir, { recursive: true }) + await writeFile(buildPlanReportPath, markdown, 'utf8') + await appendJsonl(buildPlansPath, row) + return { ...base, buildPlanWritten: true, reportWritten: true } +} + +export async function runApprovedImplementationBuildPlanCli( + argv = process.argv.slice(2), +) { + const args = parseArgs(argv) + requireJson(args) + const result = await runApprovedImplementationBuildPlan({ + proposalId: args.proposalId, + targetWorkspace: args.targetWorkspace, + proposalsPath: args.proposalsPath || DEFAULT_PROPOSALS_PATH, + approvalDecisionsPath: + args.approvalDecisionsPath || DEFAULT_APPROVAL_DECISIONS_PATH, + buildPlansPath: args.buildPlansPath || DEFAULT_BUILD_PLANS_PATH, + buildPlanReportDir: args.buildPlanReportDir || DEFAULT_BUILD_PLAN_REPORT_DIR, + dryRun: args.dryRun === true, + sendTelegram: args.sendTelegram === true, + now: args.now ? new Date(args.now) : new Date(), + }) + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`) + if (!result.ok) process.exitCode = 1 +} + +if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { + runApprovedImplementationBuildPlanCli().catch((error) => { + const safe = { + ok: false, + blocked: true, + error: sanitizeText(error?.message || error, 500), + executed: false, + buildPlanWritten: false, + reportWritten: false, + ...SIDE_EFFECT_FLAGS, + note: 'No action taken.', + } + process.stdout.write(`${JSON.stringify(safe, null, 2)}\n`) + process.exitCode = 1 + }) +} diff --git a/scripts/telegram-approved-implementation-build-plan.test.mjs b/scripts/telegram-approved-implementation-build-plan.test.mjs new file mode 100644 index 000000000..7f9a12d81 --- /dev/null +++ b/scripts/telegram-approved-implementation-build-plan.test.mjs @@ -0,0 +1,225 @@ +import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { runApprovedImplementationBuildPlan } from './telegram-approved-implementation-build-plan.mjs' + +const PROPOSAL_ID = 'impl_tg4_d3fd7da71ae557f3_8f61483465' +const PARENT_APPROVAL_ID = 'tg4_d3fd7da71ae557f3' + +async function exists(pathname) { + try { + await stat(pathname) + return true + } catch (error) { + if (error?.code === 'ENOENT') return false + throw error + } +} + +const proposalRow = (overrides = {}) => ({ + proposal_id: PROPOSAL_ID, + parent_approval_id: PARENT_APPROVAL_ID, + idea_title: 'Adapt CoWork-OS workflow/product patterns into a local Hermes implementation design', + source_repo: 'CoWork-OS/CoWork-OS', + recommended_build_type: 'adapt_pattern', + proposed_scope: 'Draft a local Hermes/LifeOS design note and thin prototype plan that adapts CoWork-OS product/workflow patterns without importing code.', + files_or_modules_expected_to_touch: ['docs or reports/proposals design note only at approval stage', 'exact files TBD after approval'], + risks: ['token=SHOULD_NOT_SURVIVE chat_id=123456789'], + sanitized: true, + ...overrides, +}) + +const decisionRow = (approvalId, overrides = {}) => ({ + approval_id: approvalId, + decision: 'approved', + status: 'approved', + selected_repo: 'CoWork-OS/CoWork-OS', + sanitized: true, + ...overrides, +}) + +async function fixture({ proposal, decisions, existingPlan } = {}) { + const dir = await mkdtemp(path.join(tmpdir(), 'tg-build-plan-')) + const targetWorkspace = path.join(dir, 'workspace') + await writeFile(path.join(dir, 'workspace-marker'), 'ok', 'utf8') + await import('node:fs/promises').then(({ mkdir }) => mkdir(targetWorkspace)) + const proposalsPath = path.join(dir, 'telegram-implementation-proposals.jsonl') + const decisionsPath = path.join(dir, 'telegram-approval-decisions.jsonl') + const buildPlansPath = path.join(dir, 'telegram-build-plans.jsonl') + const reportDir = path.join(dir, 'build-plans') + if (proposal !== null) { + await writeFile(proposalsPath, `${JSON.stringify(proposal || proposalRow())}\n`, 'utf8') + } + await writeFile( + decisionsPath, + `${(decisions || [decisionRow(PARENT_APPROVAL_ID), decisionRow(PROPOSAL_ID)]).map((row) => JSON.stringify(row)).join('\n')}\n`, + 'utf8', + ) + if (existingPlan) { + await writeFile( + buildPlansPath, + `${JSON.stringify({ proposal_id: PROPOSAL_ID, build_plan_id: 'existing' })}\n`, + 'utf8', + ) + } + return { dir, targetWorkspace, proposalsPath, decisionsPath, buildPlansPath, reportDir } +} + +async function readRows(pathname) { + return (await readFile(pathname, 'utf8')) + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line)) +} + +describe('telegram approved implementation build plan', () => { + it('approved proposal creates a build plan in dry-run without writes', async () => { + const f = await fixture() + const result = await runApprovedImplementationBuildPlan({ + proposalId: PROPOSAL_ID, + targetWorkspace: f.targetWorkspace, + proposalsPath: f.proposalsPath, + approvalDecisionsPath: f.decisionsPath, + buildPlansPath: f.buildPlansPath, + buildPlanReportDir: f.reportDir, + dryRun: true, + now: new Date('2026-07-05T12:00:00Z'), + }) + expect(result.ok).toBe(true) + expect(result.build_plan.proposal_id).toBe(PROPOSAL_ID) + expect(result.build_plan.parent_approval_id).toBe(PARENT_APPROVAL_ID) + expect(result.build_plan.requires_final_code_edit_approval).toBe(true) + expect(result.build_plan.approval_command).toBe(`/approve ${result.build_plan_id}`) + expect(result.build_plan.reject_command).toBe(`/reject ${result.build_plan_id}`) + expect(result.githubWrites).toBe(false) + expect(result.auditAppend).toBe(false) + expect(result.durableMutation).toBe(false) + expect(result.buildPlanWritten).toBe(false) + expect(result.reportWritten).toBe(false) + expect(await exists(f.buildPlansPath)).toBe(false) + expect(await exists(f.reportDir)).toBe(false) + }) + + it('unapproved proposal blocks', async () => { + const f = await fixture({ decisions: [decisionRow(PARENT_APPROVAL_ID)] }) + const result = await runApprovedImplementationBuildPlan({ + proposalId: PROPOSAL_ID, + targetWorkspace: f.targetWorkspace, + proposalsPath: f.proposalsPath, + approvalDecisionsPath: f.decisionsPath, + buildPlansPath: f.buildPlansPath, + buildPlanReportDir: f.reportDir, + }) + expect(result.ok).toBe(false) + expect(result.reason).toBe('PROPOSAL_NOT_APPROVED') + }) + + it('missing proposal blocks', async () => { + const f = await fixture({ proposal: null }) + const result = await runApprovedImplementationBuildPlan({ + proposalId: PROPOSAL_ID, + targetWorkspace: f.targetWorkspace, + proposalsPath: f.proposalsPath, + approvalDecisionsPath: f.decisionsPath, + buildPlansPath: f.buildPlansPath, + buildPlanReportDir: f.reportDir, + }) + expect(result.ok).toBe(false) + expect(result.reason).toBe('PROPOSAL_NOT_FOUND') + }) + + it('duplicate plan blocks', async () => { + const f = await fixture({ existingPlan: true }) + const result = await runApprovedImplementationBuildPlan({ + proposalId: PROPOSAL_ID, + targetWorkspace: f.targetWorkspace, + proposalsPath: f.proposalsPath, + approvalDecisionsPath: f.decisionsPath, + buildPlansPath: f.buildPlansPath, + buildPlanReportDir: f.reportDir, + }) + expect(result.ok).toBe(false) + expect(result.reason).toBe('BUILD_PLAN_EXISTS_DUPLICATE_BLOCKED') + }) + + it('unknown target workspace blocks with required field and likely candidates', async () => { + const f = await fixture() + const previousWorkspaceParent = process.env.HERMES_WORKSPACE_PARENT + process.env.HERMES_WORKSPACE_PARENT = f.dir + const result = await runApprovedImplementationBuildPlan({ + proposalId: PROPOSAL_ID, + proposalsPath: f.proposalsPath, + approvalDecisionsPath: f.decisionsPath, + buildPlansPath: f.buildPlansPath, + buildPlanReportDir: f.reportDir, + dryRun: true, + }) + if (previousWorkspaceParent === undefined) delete process.env.HERMES_WORKSPACE_PARENT + else process.env.HERMES_WORKSPACE_PARENT = previousWorkspaceParent + expect(result.ok).toBe(false) + expect(result.reason).toBe('BLOCKED_TARGET_WORKSPACE_REQUIRED') + expect(result.required_field).toBe('--target-workspace ') + expect(result.likely_candidates).toContain(f.targetWorkspace) + expect(await exists(f.buildPlansPath)).toBe(false) + }) + + it('write mode writes exactly one plan row and report', async () => { + const f = await fixture() + const result = await runApprovedImplementationBuildPlan({ + proposalId: PROPOSAL_ID, + targetWorkspace: f.targetWorkspace, + proposalsPath: f.proposalsPath, + approvalDecisionsPath: f.decisionsPath, + buildPlansPath: f.buildPlansPath, + buildPlanReportDir: f.reportDir, + }) + expect(result.ok).toBe(true) + expect(result.buildPlanWritten).toBe(true) + expect(result.reportWritten).toBe(true) + const rows = await readRows(f.buildPlansPath) + expect(rows).toHaveLength(1) + expect(rows[0].proposal_id).toBe(PROPOSAL_ID) + const report = await readFile(result.build_plan_report_path, 'utf8') + expect(report).toContain(result.build_plan_id) + expect(report).toContain('/approve') + expect(report).not.toContain('SHOULD_NOT_SURVIVE') + expect(report).not.toContain('123456789') + }) + + it('unsafe file scope blocks', async () => { + const f = await fixture({ + proposal: proposalRow({ files_or_modules_expected_to_touch: ['.env', 'src/secrets.ts'] }), + }) + const result = await runApprovedImplementationBuildPlan({ + proposalId: PROPOSAL_ID, + targetWorkspace: f.targetWorkspace, + proposalsPath: f.proposalsPath, + approvalDecisionsPath: f.decisionsPath, + buildPlansPath: f.buildPlansPath, + buildPlanReportDir: f.reportDir, + }) + expect(result.ok).toBe(false) + expect(result.reason).toBe('UNSAFE_FILE_SCOPE_BLOCKED') + expect(await exists(f.buildPlansPath)).toBe(false) + }) + + it('secrets are redacted from JSON and markdown outputs', async () => { + const f = await fixture({ proposal: proposalRow({ source_repo: 'token=SHOULD_NOT_SURVIVE chat_id=123456789' }) }) + const result = await runApprovedImplementationBuildPlan({ + proposalId: PROPOSAL_ID, + targetWorkspace: f.targetWorkspace, + proposalsPath: f.proposalsPath, + approvalDecisionsPath: f.decisionsPath, + buildPlansPath: f.buildPlansPath, + buildPlanReportDir: f.reportDir, + }) + expect(result.ok).toBe(true) + const serialized = JSON.stringify(result) + expect(serialized).not.toContain('SHOULD_NOT_SURVIVE') + expect(serialized).not.toContain('123456789') + const report = await readFile(result.build_plan_report_path, 'utf8') + expect(report).not.toContain('SHOULD_NOT_SURVIVE') + expect(report).not.toContain('123456789') + }) +}) diff --git a/scripts/telegram-chat-registration.mjs b/scripts/telegram-chat-registration.mjs new file mode 100644 index 000000000..3854aaab7 --- /dev/null +++ b/scripts/telegram-chat-registration.mjs @@ -0,0 +1,245 @@ +#!/usr/bin/env node +import { readFile, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import crypto from 'node:crypto'; + +export const TELEGRAM_CHAT_ENV_KEY = 'HERMES_TELEGRAM_CHAT_ID'; +export const SIDE_EFFECT_FLAGS = Object.freeze({ + sentTelegramMessages: false, + githubCalls: false, + githubWrites: false, + auditAppend: false, + durableMutation: false, + obsidianKanbanWrites: false, + approvalActions: false, +}); + +const SECRET_RE = /bot\d+:[A-Za-z0-9_-]+|\b\d{5,}:[A-Za-z0-9_-]+\b/g; + +export function redact(value) { + return value ? '[REDACTED]' : null; +} + +function safeErrorMessage(error) { + return String(error?.message || error).replace(SECRET_RE, '[REDACTED]'); +} + +function parseArgs(argv) { + const args = { _: [] }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--json') args.json = true; + else if (arg === '--expected-nonce') args.expectedNonce = argv[++i]; + else if (arg === '--chat-id') args.chatId = argv[++i]; + else if (arg === '--env-file') args.envFile = argv[++i]; + else if (arg === '--bot-token') args.botToken = argv[++i]; + else if (arg === '--mock-updates-file') args.mockUpdatesFile = argv[++i]; + else args._.push(arg); + } + return args; +} + +function requireJson(args) { + if (!args.json) throw new Error('This CLI is intentionally JSON-only. Pass --json.'); +} + +export function defaultEnvFile() { + return path.join(os.homedir(), '.hermes', '.env'); +} + +function parseEnvContent(content) { + const env = {}; + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const index = trimmed.indexOf('='); + if (index === -1) continue; + const key = trimmed.slice(0, index).trim(); + let value = trimmed.slice(index + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + env[key] = value; + } + return env; +} + +async function readEnvFile(envFile) { + try { + return await readFile(envFile, 'utf8'); + } catch (error) { + if (error.code === 'ENOENT') return ''; + throw error; + } +} + +async function resolveCredentialState({ env = process.env, envFile = defaultEnvFile(), botToken } = {}) { + const fileContent = await readEnvFile(envFile); + const fileEnv = parseEnvContent(fileContent); + const token = botToken || env.TELEGRAM_BOT_TOKEN || env.HERMES_TELEGRAM_BOT_TOKEN || env.TG_BOT_TOKEN || fileEnv.TELEGRAM_BOT_TOKEN || fileEnv.HERMES_TELEGRAM_BOT_TOKEN || fileEnv.TG_BOT_TOKEN || null; + const chatId = env.HERMES_TELEGRAM_CHAT_ID || env.TELEGRAM_CHAT_ID || env.TG_CHAT_ID || fileEnv.HERMES_TELEGRAM_CHAT_ID || fileEnv.TELEGRAM_CHAT_ID || fileEnv.TG_CHAT_ID || null; + return { token, chatId, envFile, fileContent }; +} + +export async function telegramNotificationCredentialsStatus({ env = process.env, envFile = defaultEnvFile(), botToken } = {}) { + const state = await resolveCredentialState({ env, envFile, botToken }); + return { + ok: true, + mode: 'status', + token_present: Boolean(state.token), + chat_id_present: Boolean(state.chatId), + telegram: { + botToken: redact(state.token), + chatId: redact(state.chatId), + }, + next_nonce: crypto.randomBytes(9).toString('base64url'), + note: state.chatId ? 'Telegram chat is registered. Values redacted.' : 'Send next_nonce to the Hermes Telegram bot, then run telegram-register-chat --expected-nonce --json.', + ...SIDE_EFFECT_FLAGS, + }; +} + +function extractMessageText(update) { + return update?.message?.text ?? update?.edited_message?.text ?? update?.channel_post?.text ?? null; +} + +function extractChatId(update) { + return update?.message?.chat?.id ?? update?.edited_message?.chat?.id ?? update?.channel_post?.chat?.id ?? null; +} + +export function findChatIdByNonce(updates, expectedNonce) { + const matches = updates + .map((update) => ({ text: extractMessageText(update), chatId: extractChatId(update) })) + .filter((entry) => entry.chatId !== null && entry.chatId !== undefined && typeof entry.text === 'string' && entry.text.trim() === expectedNonce); + + const unique = [...new Set(matches.map((entry) => String(entry.chatId)))]; + if (unique.length === 0) return { ok: false, reason: 'NONCE_NOT_FOUND' }; + if (unique.length > 1) return { ok: false, reason: 'MULTIPLE_MATCHING_CHATS' }; + return { ok: true, chatId: unique[0] }; +} + +function renderEnvLine(key, value) { + const escaped = String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return `${key}="${escaped}"`; +} + +function upsertEnvValue(content, key, value) { + const line = renderEnvLine(key, value); + const lines = content.split(/\r?\n/); + let replaced = false; + const next = lines.map((existing) => { + if (existing.match(new RegExp(`^\\s*${key}\\s*=`))) { + replaced = true; + return line; + } + return existing; + }); + if (!replaced) { + if (next.length && next.at(-1) !== '') next.push(''); + next.push(line); + } + return `${next.join('\n').replace(/\n+$/u, '')}\n`; +} + +async function storeChatId({ envFile = defaultEnvFile(), chatId }) { + const content = await readEnvFile(envFile); + const next = upsertEnvValue(content, TELEGRAM_CHAT_ENV_KEY, chatId); + await writeFile(envFile, next, { encoding: 'utf8', mode: 0o600 }); +} + +async function getUpdatesOnce({ token, fetchImpl = globalThis.fetch }) { + if (typeof fetchImpl !== 'function') throw new Error('fetch is unavailable for Telegram getUpdates.'); + const response = await fetchImpl(`https://api.telegram.org/bot${token}/getUpdates`, { method: 'GET' }); + if (!response.ok) throw new Error(`Telegram getUpdates failed with status ${response.status}`); + const body = await response.json(); + if (body?.ok === false) throw new Error('Telegram getUpdates returned ok=false.'); + return Array.isArray(body?.result) ? body.result : []; +} + +export async function registerTelegramChat({ expectedNonce, chatId, env = process.env, envFile = defaultEnvFile(), botToken, fetchImpl = globalThis.fetch, updates } = {}) { + if (!expectedNonce && !chatId) throw new Error('telegram-register-chat requires --expected-nonce or explicit --chat-id manual mode.'); + const state = await resolveCredentialState({ env, envFile, botToken }); + if (!state.token && expectedNonce) { + return { + ok: false, + mode: 'register', + registered: false, + blocked: true, + reason: 'MISSING_TELEGRAM_BOT_TOKEN', + token_present: false, + chat_id_present: Boolean(state.chatId), + telegram: { botToken: null, chatId: redact(state.chatId) }, + ...SIDE_EFFECT_FLAGS, + note: 'No action taken.', + }; + } + + let selectedChatId = chatId ? String(chatId) : null; + let telegramGetUpdatesCalls = 0; + if (!selectedChatId) { + const telegramUpdates = updates || await getUpdatesOnce({ token: state.token, fetchImpl }); + telegramGetUpdatesCalls = updates ? 0 : 1; + const match = findChatIdByNonce(telegramUpdates, expectedNonce); + if (!match.ok) { + return { + ok: false, + mode: 'register', + registered: false, + blocked: true, + reason: match.reason, + token_present: true, + chat_id_present: Boolean(state.chatId), + telegramGetUpdatesCalls, + telegram: { botToken: redact(state.token), chatId: redact(state.chatId) }, + ...SIDE_EFFECT_FLAGS, + note: 'No chat ID stored.', + }; + } + selectedChatId = match.chatId; + } + + await storeChatId({ envFile, chatId: selectedChatId }); + return { + ok: true, + mode: 'register', + registered: true, + token_present: Boolean(state.token), + chat_id_present: true, + telegramGetUpdatesCalls, + telegram: { botToken: redact(state.token), chatId: redact(selectedChatId) }, + ...SIDE_EFFECT_FLAGS, + note: 'Stored matching Telegram chat ID. Values redacted. No Telegram message sent.', + }; +} + +export async function runTelegramChatRegistrationCli(argv = process.argv.slice(2), invokedPath = process.argv[1] || '') { + const args = parseArgs(argv); + requireJson(args); + const command = path.basename(invokedPath).includes('register') ? 'register' : 'status'; + let updates; + if (args.mockUpdatesFile) { + const mock = JSON.parse(await readFile(args.mockUpdatesFile, 'utf8')); + updates = Array.isArray(mock) ? mock : mock.result; + } + const result = command === 'register' + ? await registerTelegramChat({ + expectedNonce: args.expectedNonce, + chatId: args.chatId, + envFile: args.envFile || defaultEnvFile(), + botToken: args.botToken, + updates, + }) + : await telegramNotificationCredentialsStatus({ + envFile: args.envFile || defaultEnvFile(), + botToken: args.botToken, + }); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { + runTelegramChatRegistrationCli().catch((error) => { + process.stdout.write(`${JSON.stringify({ ok: false, error: safeErrorMessage(error), ...SIDE_EFFECT_FLAGS, note: 'No action taken.' }, null, 2)}\n`); + process.exitCode = 1; + }); +} diff --git a/scripts/telegram-chat-registration.test.mjs b/scripts/telegram-chat-registration.test.mjs new file mode 100644 index 000000000..3f28fa9ea --- /dev/null +++ b/scripts/telegram-chat-registration.test.mjs @@ -0,0 +1,149 @@ +import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; +import { + findChatIdByNonce, + registerTelegramChat, + telegramNotificationCredentialsStatus, +} from './telegram-chat-registration.mjs'; + +const execFileAsync = promisify(execFile); + +async function fixtureEnv(content = 'HERMES_TELEGRAM_BOT_TOKEN="123456:SUPER_SECRET_TOKEN"\n') { + const dir = await mkdtemp(path.join(tmpdir(), 'tg-chat-reg-')); + const envFile = path.join(dir, '.env'); + await writeFile(envFile, content, 'utf8'); + return { dir, envFile }; +} + +function updatesFor(chatId, text) { + return [{ update_id: 1, message: { message_id: 2, text, chat: { id: chatId, type: 'private' } } }]; +} + +describe('telegram chat registration', () => { + it('status shows token present and chat id missing without printing secrets', async () => { + const { envFile } = await fixtureEnv(); + const result = await telegramNotificationCredentialsStatus({ env: {}, envFile }); + expect(result.ok).toBe(true); + expect(result.token_present).toBe(true); + expect(result.chat_id_present).toBe(false); + expect(result.telegram.botToken).toBe('[REDACTED]'); + expect(result.telegram.chatId).toBeNull(); + expect(result.next_nonce).toMatch(/^[A-Za-z0-9_-]+$/); + expect(JSON.stringify(result)).not.toContain('SUPER_SECRET_TOKEN'); + expect(result.sentTelegramMessages).toBe(false); + expect(result.githubCalls).toBe(false); + expect(result.auditAppend).toBe(false); + expect(result.durableMutation).toBe(false); + expect(result.obsidianKanbanWrites).toBe(false); + expect(result.approvalActions).toBe(false); + }); + + it('registers exactly one chat by matching nonce from mocked getUpdates data', async () => { + const { envFile } = await fixtureEnv(); + const result = await registerTelegramChat({ + expectedNonce: 'nonce-abc', + env: {}, + envFile, + updates: updatesFor('987654321', 'nonce-abc'), + }); + expect(result.ok).toBe(true); + expect(result.registered).toBe(true); + expect(result.chat_id_present).toBe(true); + expect(result.telegram.chatId).toBe('[REDACTED]'); + expect(JSON.stringify(result)).not.toContain('987654321'); + expect(JSON.stringify(result)).not.toContain('SUPER_SECRET_TOKEN'); + const stored = await readFile(envFile, 'utf8'); + expect(stored).toContain('HERMES_TELEGRAM_CHAT_ID="987654321"'); + }); + + it('calls Telegram getUpdates exactly once with mocked fetch and never sends a message', async () => { + const { envFile } = await fixtureEnv(); + const calls = []; + const result = await registerTelegramChat({ + expectedNonce: 'nonce-fetch', + env: {}, + envFile, + fetchImpl: async (url, init) => { + calls.push({ url, init }); + return { ok: true, status: 200, json: async () => ({ ok: true, result: updatesFor('987654321', 'nonce-fetch') }) }; + }, + }); + expect(result.ok).toBe(true); + expect(result.telegramGetUpdatesCalls).toBe(1); + expect(calls).toHaveLength(1); + expect(calls[0].url).toContain('/getUpdates'); + expect(calls[0].url).not.toContain('/sendMessage'); + expect(result.sentTelegramMessages).toBe(false); + }); + + it('mismatched nonce blocks and does not store chat id', async () => { + const { envFile } = await fixtureEnv(); + const result = await registerTelegramChat({ + expectedNonce: 'wanted', + env: {}, + envFile, + updates: updatesFor('987654321', 'wrong'), + }); + expect(result.ok).toBe(false); + expect(result.reason).toBe('NONCE_NOT_FOUND'); + const stored = await readFile(envFile, 'utf8'); + expect(stored).not.toContain('HERMES_TELEGRAM_CHAT_ID'); + }); + + it('multiple matching chats block unless exactly one chat matches', () => { + const blocked = findChatIdByNonce([ + ...updatesFor('111', 'same'), + ...updatesFor('222', 'same'), + ], 'same'); + expect(blocked.ok).toBe(false); + expect(blocked.reason).toBe('MULTIPLE_MATCHING_CHATS'); + + const allowed = findChatIdByNonce([ + ...updatesFor('111', 'same'), + ...updatesFor('111', 'same'), + ...updatesFor('222', 'different'), + ], 'same'); + expect(allowed.ok).toBe(true); + expect(allowed.chatId).toBe('111'); + }); + + it('after registration, status shows chat id present with redacted output', async () => { + const { envFile } = await fixtureEnv(); + await registerTelegramChat({ expectedNonce: 'nonce', env: {}, envFile, updates: updatesFor('987654321', 'nonce') }); + const result = await telegramNotificationCredentialsStatus({ env: {}, envFile }); + expect(result.chat_id_present).toBe(true); + expect(result.telegram.chatId).toBe('[REDACTED]'); + expect(JSON.stringify(result)).not.toContain('987654321'); + }); + + it('CLI status and mocked registration are JSON-only and secret-redacted', async () => { + const { dir, envFile } = await fixtureEnv(); + const mockUpdatesFile = path.join(dir, 'updates.json'); + await writeFile(mockUpdatesFile, JSON.stringify({ ok: true, result: updatesFor('987654321', 'nonce-cli') }), 'utf8'); + const status = await execFileAsync('node', ['bin/telegram-notification-credentials-status', '--json', '--env-file', envFile], { + cwd: process.cwd(), env: { PATH: process.env.PATH, NODE_NO_WARNINGS: '1' }, + }); + const before = JSON.parse(status.stdout); + expect(before.token_present).toBe(true); + expect(before.chat_id_present).toBe(false); + + const registration = await execFileAsync('node', ['bin/telegram-register-chat', '--json', '--env-file', envFile, '--expected-nonce', 'nonce-cli', '--mock-updates-file', mockUpdatesFile], { + cwd: process.cwd(), env: { PATH: process.env.PATH, NODE_NO_WARNINGS: '1' }, + }); + const registered = JSON.parse(registration.stdout); + expect(registered.ok).toBe(true); + expect(registered.sentTelegramMessages).toBe(false); + expect(registered.githubCalls).toBe(false); + expect(registered.githubWrites).toBe(false); + expect(registered.auditAppend).toBe(false); + expect(registered.durableMutation).toBe(false); + expect(registered.obsidianKanbanWrites).toBe(false); + expect(registered.approvalActions).toBe(false); + expect(registration.stdout).not.toContain('987654321'); + expect(registration.stdout).not.toContain('SUPER_SECRET_TOKEN'); + }); +}); diff --git a/scripts/telegram-code-edit-approval-packet.mjs b/scripts/telegram-code-edit-approval-packet.mjs new file mode 100644 index 000000000..ce4309fcc --- /dev/null +++ b/scripts/telegram-code-edit-approval-packet.mjs @@ -0,0 +1,448 @@ +#!/usr/bin/env node +import { createHash } from 'node:crypto' +import { appendFile, mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { formatHumanTelegramMessage } from './telegram-message-format.mjs' + +export const DEFAULT_BUILD_PLANS_PATH = + '/root/.hermes/reports/github-discovery/telegram-build-plans.jsonl' +export const DEFAULT_APPROVAL_PACKETS_PATH = + '/root/.hermes/reports/github-discovery/telegram-code-edit-approval-packets.jsonl' +export const DEFAULT_APPROVAL_PACKET_REPORT_DIR = + '/root/.hermes/reports/github-discovery/code-edit-approval-packets' + +export const SIDE_EFFECT_FLAGS = Object.freeze({ + approvalPacketOnly: true, + codeEdits: false, + commandsRun: false, + dependencyInstall: false, + githubCalls: false, + githubWrites: false, + branch: false, + pr: false, + merge: false, + delete: false, + auditAppend: false, + durableMutation: false, + obsidianKanbanWrites: false, + telegramSent: false, +}) + +const SECRET_PATTERNS = [ + /gh[pousr]_[A-Za-z0-9_]{20,}/g, + /github_pat_[A-Za-z0-9_]{20,}/g, + /bot\d+:[A-Za-z0-9_-]+/g, + /\b-?\d{8,15}\b/g, + /["']?(?token|secret|password|api[_-]?key|client[_-]?secret|chat[_-]?id)["']?\s*[:=]\s*["']?[^\s,'\"}]+["']?/gi, +] + +function parseArgs(argv) { + const args = { _: [] } + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i] + if (arg === '--json') args.json = true + else if (arg === '--dry-run') args.dryRun = true + else if (arg === '--send-telegram') args.sendTelegram = true + else if (arg === '--build-plan-id') args.buildPlanId = argv[++i] + else if (arg === '--build-plans-path') args.buildPlansPath = argv[++i] + else if (arg === '--approval-packets-path') args.approvalPacketsPath = argv[++i] + else if (arg === '--approval-packet-report-dir') args.approvalPacketReportDir = argv[++i] + else if (arg === '--target-workspace') args.targetWorkspace = argv[++i] + else if (arg === '--now') args.now = argv[++i] + else args._.push(arg) + } + return args +} + +function requireJson(args) { + if (!args.json) + throw new Error( + 'telegram-code-edit-approval-packet is intentionally JSON-only. Pass --json.', + ) +} + +export function sanitizeText(value, cap = 12000) { + let text = String(value || '').replace(/\u0000/g, '') + for (const pattern of SECRET_PATTERNS) { + text = text.replace(pattern, (...match) => + match.groups?.key ? `${match.groups.key}=[REDACTED]` : '[REDACTED]', + ) + } + return text.replace(/[\t ]+$/gm, '').slice(0, cap) +} + +async function readJsonl(pathname) { + try { + const content = await readFile(pathname, 'utf8') + return content + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line)) + } catch (error) { + if (error?.code === 'ENOENT') return [] + throw error + } +} + +async function appendJsonl(pathname, row) { + await mkdir(path.dirname(pathname), { recursive: true }) + await appendFile(pathname, `${JSON.stringify(row)}\n`, 'utf8') +} + +async function pathExists(pathname) { + try { + await stat(pathname) + return true + } catch (error) { + if (error?.code === 'ENOENT') return false + throw error + } +} + +function safeIdPart(value) { + return String(value || '').replace(/[^a-zA-Z0-9_-]/g, '_') +} + +function approvalPacketId(buildPlanId, targetWorkspace) { + const hash = createHash('sha256') + .update(`${buildPlanId}\n${targetWorkspace}\ncode-edit-approval`) + .digest('hex') + .slice(0, 12) + return `code_edit_${safeIdPart(buildPlanId)}_${hash}` +} + +function shortAliasForApprovalId(approvalId) { + const id = String(approvalId || '') + if ( + id === + 'code_edit_build_impl_tg4_d3fd7da71ae557f3_8f61483465_d7420c166e_a033e3539084' + ) + return 'edit1' + const match = id.match(/([a-f0-9]{8})[a-f0-9]*$/i) + return match + ? match[1].toLowerCase() + : id.toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 12) +} + +function safeReportName(codeEditApprovalId) { + return `${safeIdPart(codeEditApprovalId)}.md` +} + +function block(reason, extra = {}) { + return { + ok: false, + blocked: true, + reason, + approvalPacketWritten: false, + reportWritten: false, + ...SIDE_EFFECT_FLAGS, + ...extra, + note: 'Fail-closed. No code edit approval packet row or report was written.', + } +} + +function findBuildPlan(rows, buildPlanId) { + return rows.find((row) => row?.build_plan_id === buildPlanId) || null +} + +function findExistingPacket(rows, buildPlanId) { + return rows.find((row) => row?.build_plan_id === buildPlanId) || null +} + +function exactFilesFromBuildPlan(buildPlan) { + const files = buildPlan?.expected_files_to_touch + if (!Array.isArray(files) || files.length === 0) return null + const normalized = files.map((item) => String(item || '').trim()).filter(Boolean) + if (normalized.length !== files.length || normalized.length === 0) return null + const unsafe = normalized.some( + (item) => + item.includes('*') || + item.endsWith('/') || + path.isAbsolute(item) || + item.split('/').includes('..') || + /(^|\/)(\.env|\.git|node_modules|\.ssh)(\/|$)|token|secret|password|credential/i.test( + item, + ), + ) + return unsafe ? null : normalized +} + +function forbiddenFilesFromBuildPlan(buildPlan) { + const forbidden = buildPlan?.forbidden_files_or_paths + if (!Array.isArray(forbidden)) return [] + return forbidden.map((item) => sanitizeText(item, 500)).filter(Boolean) +} + +function addMinutes(date, minutes) { + return new Date(date.getTime() + minutes * 60 * 1000).toISOString() +} + +function buildPacket({ buildPlan, now }) { + const targetWorkspace = buildPlan.target_repo_or_workspace || buildPlan.target_workspace + const exactFilesAllowed = exactFilesFromBuildPlan(buildPlan) + if (!exactFilesAllowed) { + return block('BLOCKED_EXACT_EDIT_SCOPE_REQUIRED', { + build_plan_id: buildPlan?.build_plan_id || null, + target_workspace: targetWorkspace || null, + }) + } + + const id = approvalPacketId(buildPlan.build_plan_id, targetWorkspace) + const shortAlias = shortAliasForApprovalId(id) + return { + code_edit_approval_id: id, + approval_id: id, + short_alias: shortAlias, + build_plan_id: buildPlan.build_plan_id, + target_workspace: targetWorkspace, + exact_files_allowed_to_edit: exactFilesAllowed, + exact_files_forbidden_to_edit: forbiddenFilesFromBuildPlan(buildPlan), + proposed_change_summary: [ + 'Create the approved local docs-only CoWork-OS pattern adaptation design note.', + 'Create the approved companion test-plan note for later implementation validation gates.', + 'Keep this package limited to the exact docs paths; no implementation, prototype, dashboard, route, service, dependency, or external-service edits.', + ], + planned_commands: [ + 'git diff --cached --name-status', + 'mkdir -p docs/github-discovery', + 'write docs/github-discovery/cowork-os-pattern-adaptation.md', + 'write docs/github-discovery/cowork-os-pattern-adaptation.test-plan.md', + 'git diff --check -- docs/github-discovery/cowork-os-pattern-adaptation.md docs/github-discovery/cowork-os-pattern-adaptation.test-plan.md', + 'git status --short -- docs/github-discovery/cowork-os-pattern-adaptation.md docs/github-discovery/cowork-os-pattern-adaptation.test-plan.md', + 'static scan: no token/chat_id/API key/password strings in the two generated docs', + 'static scan: no source-code import/copy instructions from CoWork-OS in the design note', + ], + tests_to_run: (buildPlan.tests_to_run || []).map((item) => sanitizeText(item, 1000)), + rollback_plan: (buildPlan.rollback_plan || []).map((item) => sanitizeText(item, 1000)), + allowed_side_effects: [ + 'Create or modify only docs/github-discovery/cowork-os-pattern-adaptation.md', + 'Create or modify only docs/github-discovery/cowork-os-pattern-adaptation.test-plan.md', + 'Run local git diff/status/static-scan verification commands only after approval.', + ], + forbidden_side_effects: [ + 'No edits outside exact_files_allowed_to_edit.', + 'No command execution before approval.', + 'No dependency install.', + 'No GitHub calls or writes.', + 'No branch, PR, merge, push, clone, fork, or delete.', + 'No audit append.', + 'No durable-store mutation.', + 'No Obsidian or Kanban writes.', + 'No Telegram send unless explicitly passed --send-telegram in a future sender package.', + 'No token/chat/API key/password exposure; redact sensitive identifiers.', + ], + expiry_time: addMinutes(now, 24 * 60), + approval_command: `/approve ${shortAlias}`, + reject_command: `/reject ${shortAlias}`, + created_at: now.toISOString(), + sanitized: true, + ...SIDE_EFFECT_FLAGS, + } +} + +function validatePacket(packet) { + if (packet?.blocked) return packet + const serialized = JSON.stringify(packet) + if (/gh[pousr]_|github_pat_|bot\d+:/i.test(serialized)) + return block('UNREDACTED_SECRET_DETECTED', { + code_edit_approval_id: packet.code_edit_approval_id, + }) + if (/chat[_-]?id\s*[:=]/i.test(serialized)) + return block('UNREDACTED_CHAT_IDENTIFIER_DETECTED', { + code_edit_approval_id: packet.code_edit_approval_id, + }) + if ( + !Array.isArray(packet.exact_files_allowed_to_edit) || + packet.exact_files_allowed_to_edit.length === 0 + ) { + return block('BLOCKED_EXACT_EDIT_SCOPE_REQUIRED', { + build_plan_id: packet.build_plan_id, + }) + } + return null +} + +function packetMarkdown(packet) { + return sanitizeText( + `# Telegram code edit approval packet + +Code edit approval ID: ${packet.code_edit_approval_id} +Build plan ID: ${packet.build_plan_id} +Target workspace: ${packet.target_workspace} +Expiry time: ${packet.expiry_time} + +## Exact files allowed to edit +${packet.exact_files_allowed_to_edit.map((item) => `- ${item}`).join('\n')} + +## Exact files forbidden to edit +${packet.exact_files_forbidden_to_edit.map((item) => `- ${item}`).join('\n')} + +## Proposed change summary +${packet.proposed_change_summary.map((item) => `- ${item}`).join('\n')} + +## Planned commands after approval +${packet.planned_commands.map((item) => `- ${item}`).join('\n')} + +## Tests to run after approval +${packet.tests_to_run.map((item) => `- ${item}`).join('\n')} + +## Rollback plan +${packet.rollback_plan.map((item) => `- ${item}`).join('\n')} + +## Allowed side effects +${packet.allowed_side_effects.map((item) => `- ${item}`).join('\n')} + +## Forbidden side effects +${packet.forbidden_side_effects.map((item) => `- ${item}`).join('\n')} + +## Approval commands +- ${packet.approval_command} +- ${packet.reject_command} +`, + 24000, + ) +} + +export async function runTelegramCodeEditApprovalPacket({ + buildPlanId, + buildPlansPath = DEFAULT_BUILD_PLANS_PATH, + approvalPacketsPath = DEFAULT_APPROVAL_PACKETS_PATH, + approvalPacketReportDir = DEFAULT_APPROVAL_PACKET_REPORT_DIR, + targetWorkspace, + dryRun = false, + sendTelegram = false, + now = new Date(), +} = {}) { + if (!buildPlanId) + throw new Error('telegram-code-edit-approval-packet requires --build-plan-id.') + if (sendTelegram) + return block('TELEGRAM_SEND_NOT_IMPLEMENTED_FOR_THIS_PACKAGE', { + build_plan_id: buildPlanId, + telegramSent: false, + }) + + const [buildPlans, existingPackets] = await Promise.all([ + readJsonl(buildPlansPath), + readJsonl(approvalPacketsPath), + ]) + const buildPlan = findBuildPlan(buildPlans, buildPlanId) + if (!buildPlan) return block('BUILD_PLAN_NOT_FOUND', { build_plan_id: buildPlanId }) + + if (!targetWorkspace) { + return block('BLOCKED_TARGET_WORKSPACE_REQUIRED', { + build_plan_id: buildPlanId, + required_field: '--target-workspace ', + }) + } + + const planWorkspace = buildPlan.target_repo_or_workspace || buildPlan.target_workspace || null + if (planWorkspace !== targetWorkspace) { + return block('TARGET_WORKSPACE_MISMATCH', { + build_plan_id: buildPlanId, + target_workspace: targetWorkspace, + build_plan_target_workspace: planWorkspace, + }) + } + if (!path.isAbsolute(targetWorkspace)) { + return block('TARGET_WORKSPACE_MUST_BE_ABSOLUTE', { + build_plan_id: buildPlanId, + target_workspace: targetWorkspace, + }) + } + if (!(await pathExists(targetWorkspace))) { + return block('TARGET_WORKSPACE_NOT_FOUND', { + build_plan_id: buildPlanId, + target_workspace: targetWorkspace, + }) + } + + const existing = findExistingPacket(existingPackets, buildPlanId) + if (existing) { + return block('CODE_EDIT_APPROVAL_PACKET_DUPLICATE_BLOCKED', { + build_plan_id: buildPlanId, + code_edit_approval_id: existing.code_edit_approval_id || null, + }) + } + + const packet = buildPacket({ buildPlan, now }) + const invalid = validatePacket(packet) + if (invalid) return invalid + + const markdown = packetMarkdown(packet) + const reportPath = path.join( + approvalPacketReportDir, + safeReportName(packet.code_edit_approval_id), + ) + const row = { + ...packet, + approval_packet_report_path: reportPath, + approval_packet_report_hash: createHash('sha256').update(markdown).digest('hex'), + telegram_message_text: formatHumanTelegramMessage({ + type: 'code-edit-approval', + code_edit_approval_id: packet.code_edit_approval_id, + alias: packet.short_alias, + exact_files_allowed_to_edit: packet.exact_files_allowed_to_edit, + report_path: reportPath, + }), + } + const base = { + ok: true, + blocked: false, + mode: dryRun ? 'code_edit_approval_packet_dry_run' : 'code_edit_approval_packet_write', + build_plan_id: buildPlanId, + code_edit_approval_id: packet.code_edit_approval_id, + dryRun, + approval_packets_path: approvalPacketsPath, + approval_packet_report_path: reportPath, + approval_packet: row, + approvalPacketWritten: false, + reportWritten: false, + ...SIDE_EFFECT_FLAGS, + note: dryRun + ? 'Dry-run only. Approval packet generated in JSON; no packet row or report written.' + : 'Approval packet written only. No code edits or commands were run.', + } + if (dryRun) return base + + await mkdir(approvalPacketReportDir, { recursive: true }) + await writeFile(reportPath, markdown, 'utf8') + await appendJsonl(approvalPacketsPath, row) + return { ...base, approvalPacketWritten: true, reportWritten: true } +} + +export async function runTelegramCodeEditApprovalPacketCli( + argv = process.argv.slice(2), +) { + const args = parseArgs(argv) + requireJson(args) + const result = await runTelegramCodeEditApprovalPacket({ + buildPlanId: args.buildPlanId, + buildPlansPath: args.buildPlansPath || DEFAULT_BUILD_PLANS_PATH, + approvalPacketsPath: args.approvalPacketsPath || DEFAULT_APPROVAL_PACKETS_PATH, + approvalPacketReportDir: + args.approvalPacketReportDir || DEFAULT_APPROVAL_PACKET_REPORT_DIR, + targetWorkspace: args.targetWorkspace, + dryRun: args.dryRun === true, + sendTelegram: args.sendTelegram === true, + now: args.now ? new Date(args.now) : new Date(), + }) + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`) + if (!result.ok) process.exitCode = 1 +} + +if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { + runTelegramCodeEditApprovalPacketCli().catch((error) => { + const safe = { + ok: false, + blocked: true, + error: sanitizeText(error?.message || error, 500), + approvalPacketWritten: false, + reportWritten: false, + ...SIDE_EFFECT_FLAGS, + note: 'No action taken.', + } + process.stdout.write(`${JSON.stringify(safe, null, 2)}\n`) + process.exitCode = 1 + }) +} diff --git a/scripts/telegram-code-edit-approval-packet.test.mjs b/scripts/telegram-code-edit-approval-packet.test.mjs new file mode 100644 index 000000000..d02bb8455 --- /dev/null +++ b/scripts/telegram-code-edit-approval-packet.test.mjs @@ -0,0 +1,245 @@ +import { mkdir, mkdtemp, readFile, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { runTelegramCodeEditApprovalPacket } from './telegram-code-edit-approval-packet.mjs' + +const BUILD_PLAN_ID = 'build_impl_tg4_d3fd7da71ae557f3_8f61483465_d7420c166e' + +async function exists(pathname) { + try { + await stat(pathname) + return true + } catch (error) { + if (error?.code === 'ENOENT') return false + throw error + } +} + +const buildPlanRow = (targetWorkspace, overrides = {}) => ({ + build_plan_id: BUILD_PLAN_ID, + proposal_id: 'impl_tg4_d3fd7da71ae557f3_8f61483465', + parent_approval_id: 'tg4_d3fd7da71ae557f3', + recommended_build_type: 'adapt_pattern', + target_repo_or_workspace: targetWorkspace, + expected_files_to_touch: [ + 'docs/github-discovery/cowork-os-pattern-adaptation.md', + 'docs/github-discovery/cowork-os-pattern-adaptation.test-plan.md', + ], + forbidden_files_or_paths: [ + '.env', + '.env.*', + '.git/**', + 'node_modules/**', + 'package.json', + 'pnpm-lock.yaml', + 'package-lock.json', + 'yarn.lock', + 'src/**', + 'electron/**', + 'server-entry.js', + 'swarm.yaml', + '/root/.hermes/audit/**', + '/root/.hermes/github-connector-durable-store/**', + '/mnt/lachlan-pc-obsidian/**', + ], + implementation_steps: ['Create docs only after final approval.'], + tests_to_run: [ + 'git diff --check -- docs/github-discovery/cowork-os-pattern-adaptation.md docs/github-discovery/cowork-os-pattern-adaptation.test-plan.md', + 'static scan: no token/chat_id/API key/password strings in the two generated docs', + ], + rollback_plan: [ + 'Delete docs/github-discovery/cowork-os-pattern-adaptation.md if created by the approved edit package.', + 'Delete docs/github-discovery/cowork-os-pattern-adaptation.test-plan.md if created by the approved edit package.', + ], + requires_final_code_edit_approval: true, + sanitized: true, + ...overrides, +}) + +async function fixture({ buildPlan, existingPacket } = {}) { + const dir = await mkdtemp(path.join(tmpdir(), 'tg-code-edit-approval-')) + const targetWorkspace = path.join(dir, 'workspace') + await mkdir(targetWorkspace) + const buildPlansPath = path.join(dir, 'telegram-build-plans.jsonl') + const approvalPacketsPath = path.join(dir, 'telegram-code-edit-approval-packets.jsonl') + const reportDir = path.join(dir, 'code-edit-approval-packets') + if (buildPlan !== null) { + await writeFile( + buildPlansPath, + `${JSON.stringify(buildPlan || buildPlanRow(targetWorkspace))}\n`, + 'utf8', + ) + } + if (existingPacket) { + await writeFile( + approvalPacketsPath, + `${JSON.stringify({ build_plan_id: BUILD_PLAN_ID, code_edit_approval_id: 'existing' })}\n`, + 'utf8', + ) + } + return { dir, targetWorkspace, buildPlansPath, approvalPacketsPath, reportDir } +} + +async function readRows(pathname) { + return (await readFile(pathname, 'utf8')) + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line)) +} + +describe('telegram code edit approval packet', () => { + it('creates packet from build plan in dry-run without writes', async () => { + const f = await fixture() + const result = await runTelegramCodeEditApprovalPacket({ + buildPlanId: BUILD_PLAN_ID, + buildPlansPath: f.buildPlansPath, + approvalPacketsPath: f.approvalPacketsPath, + approvalPacketReportDir: f.reportDir, + targetWorkspace: f.targetWorkspace, + dryRun: true, + now: new Date('2026-07-05T12:00:00Z'), + }) + expect(result.ok).toBe(true) + expect(result.approval_packet.build_plan_id).toBe(BUILD_PLAN_ID) + expect(result.approval_packet.target_workspace).toBe(f.targetWorkspace) + expect(result.approval_packet.exact_files_allowed_to_edit).toEqual([ + 'docs/github-discovery/cowork-os-pattern-adaptation.md', + 'docs/github-discovery/cowork-os-pattern-adaptation.test-plan.md', + ]) + expect(result.approval_packet.short_alias).toMatch(/^[a-f0-9]{8}$/) + expect(result.approval_packet.approval_id).toBe(result.code_edit_approval_id) + expect(result.approval_packet.approval_command).toBe( + `/approve ${result.approval_packet.short_alias}`, + ) + expect(result.approval_packet.reject_command).toBe( + `/reject ${result.approval_packet.short_alias}`, + ) + expect(result.commandsRun).toBe(false) + expect(result.githubWrites).toBe(false) + expect(result.githubCalls).toBe(false) + expect(result.auditAppend).toBe(false) + expect(result.durableMutation).toBe(false) + expect(result.approvalPacketWritten).toBe(false) + expect(result.reportWritten).toBe(false) + expect(await exists(f.approvalPacketsPath)).toBe(false) + expect(await exists(f.reportDir)).toBe(false) + }) + + it('blocks missing build plan', async () => { + const f = await fixture({ buildPlan: null }) + const result = await runTelegramCodeEditApprovalPacket({ + buildPlanId: BUILD_PLAN_ID, + buildPlansPath: f.buildPlansPath, + approvalPacketsPath: f.approvalPacketsPath, + approvalPacketReportDir: f.reportDir, + }) + expect(result.ok).toBe(false) + expect(result.reason).toBe('BUILD_PLAN_NOT_FOUND') + expect(await exists(f.approvalPacketsPath)).toBe(false) + }) + + it('blocks when target workspace is not explicit', async () => { + const f = await fixture() + const result = await runTelegramCodeEditApprovalPacket({ + buildPlanId: BUILD_PLAN_ID, + buildPlansPath: f.buildPlansPath, + approvalPacketsPath: f.approvalPacketsPath, + approvalPacketReportDir: f.reportDir, + }) + expect(result.ok).toBe(false) + expect(result.reason).toBe('BLOCKED_TARGET_WORKSPACE_REQUIRED') + expect(result.required_field).toBe('--target-workspace ') + expect(await exists(f.approvalPacketsPath)).toBe(false) + }) + + it('blocks if target workspace mismatches', async () => { + const otherWorkspace = path.join(tmpdir(), 'tg-code-edit-other-workspace') + const f = await fixture({ buildPlan: buildPlanRow(otherWorkspace) }) + const result = await runTelegramCodeEditApprovalPacket({ + buildPlanId: BUILD_PLAN_ID, + buildPlansPath: f.buildPlansPath, + approvalPacketsPath: f.approvalPacketsPath, + approvalPacketReportDir: f.reportDir, + targetWorkspace: f.targetWorkspace, + }) + expect(result.ok).toBe(false) + expect(result.reason).toBe('TARGET_WORKSPACE_MISMATCH') + expect(await exists(f.approvalPacketsPath)).toBe(false) + }) + + it('blocks if exact edit files cannot be determined', async () => { + const f = await fixture() + const exactScopeBuildPlan = buildPlanRow(f.targetWorkspace, { + expected_files_to_touch: ['docs/github-discovery/**'], + }) + await writeFile(f.buildPlansPath, `${JSON.stringify(exactScopeBuildPlan)}\n`, 'utf8') + const result = await runTelegramCodeEditApprovalPacket({ + buildPlanId: BUILD_PLAN_ID, + buildPlansPath: f.buildPlansPath, + approvalPacketsPath: f.approvalPacketsPath, + approvalPacketReportDir: f.reportDir, + targetWorkspace: f.targetWorkspace, + }) + expect(result.ok).toBe(false) + expect(result.reason).toBe('BLOCKED_EXACT_EDIT_SCOPE_REQUIRED') + expect(await exists(f.approvalPacketsPath)).toBe(false) + }) + + it('write mode writes one approval packet row and report only', async () => { + const f = await fixture() + const result = await runTelegramCodeEditApprovalPacket({ + buildPlanId: BUILD_PLAN_ID, + buildPlansPath: f.buildPlansPath, + approvalPacketsPath: f.approvalPacketsPath, + approvalPacketReportDir: f.reportDir, + targetWorkspace: f.targetWorkspace, + now: new Date('2026-07-05T12:00:00Z'), + }) + expect(result.ok).toBe(true) + expect(result.approvalPacketWritten).toBe(true) + expect(result.reportWritten).toBe(true) + expect(result.commandsRun).toBe(false) + const rows = await readRows(f.approvalPacketsPath) + expect(rows).toHaveLength(1) + expect(rows[0].build_plan_id).toBe(BUILD_PLAN_ID) + const report = await readFile(result.approval_packet_report_path, 'utf8') + expect(report).toContain(result.code_edit_approval_id) + expect(report).toContain('/approve') + }) + + it('duplicate packet blocks', async () => { + const f = await fixture({ existingPacket: true }) + const result = await runTelegramCodeEditApprovalPacket({ + buildPlanId: BUILD_PLAN_ID, + buildPlansPath: f.buildPlansPath, + approvalPacketsPath: f.approvalPacketsPath, + approvalPacketReportDir: f.reportDir, + targetWorkspace: f.targetWorkspace, + }) + expect(result.ok).toBe(false) + expect(result.reason).toBe('CODE_EDIT_APPROVAL_PACKET_DUPLICATE_BLOCKED') + }) + + it('secrets are redacted', async () => { + const f = await fixture() + const secretBuildPlan = buildPlanRow(f.targetWorkspace, { + tests_to_run: ['static scan token=SHOULD_NOT_SURVIVE chat_id=123456789'], + }) + await writeFile(f.buildPlansPath, `${JSON.stringify(secretBuildPlan)}\n`, 'utf8') + const result = await runTelegramCodeEditApprovalPacket({ + buildPlanId: BUILD_PLAN_ID, + buildPlansPath: f.buildPlansPath, + approvalPacketsPath: f.approvalPacketsPath, + approvalPacketReportDir: f.reportDir, + targetWorkspace: f.targetWorkspace, + }) + expect(result.ok).toBe(true) + const serialized = JSON.stringify(result) + expect(serialized).not.toContain('SHOULD_NOT_SURVIVE') + expect(serialized).not.toContain('123456789') + const report = await readFile(result.approval_packet_report_path, 'utf8') + expect(report).not.toContain('SHOULD_NOT_SURVIVE') + expect(report).not.toContain('123456789') + }) +}) diff --git a/scripts/telegram-discovery-notification.mjs b/scripts/telegram-discovery-notification.mjs new file mode 100644 index 000000000..3101f3e7e --- /dev/null +++ b/scripts/telegram-discovery-notification.mjs @@ -0,0 +1,676 @@ +#!/usr/bin/env node +import { createHash } from 'node:crypto'; +import { appendFile, mkdir, readFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { formatHumanTelegramMessage } from './telegram-message-format.mjs'; + +export const DEFAULT_DISCOVERY_INDEX_PATH = '/root/.hermes/reports/github-discovery/index.jsonl'; +export const DEFAULT_TELEGRAM_APPROVAL_REQUESTS_PATH = '/root/.hermes/reports/github-discovery/telegram-approval-requests.jsonl'; +export const ALLOWED_RECOMMENDATIONS = new Set(['use', 'fork', 'learn_from', 'avoid', 'build_from_scratch']); +export const APPROVAL_REQUEST_RECOMMENDATIONS = new Set(['use', 'fork', 'learn_from']); +export const SIDE_EFFECT_FLAGS = Object.freeze({ + githubCalls: false, + githubWrites: false, + cloneForkRunInstallCreateRepoPushPrMergeDelete: false, + auditAppend: false, + durableMutation: false, + obsidianKanbanWrites: false, +}); + +export function redactSecret(value) { + if (!value) return null; + return '[REDACTED]'; +} + +function parseArgs(argv) { + const args = { _: [] }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--json') args.json = true; + else if (arg === '--report-path') args.reportPath = argv[++i]; + else if (arg === '--index-path') args.indexPath = argv[++i]; + else if (arg === '--dry-run') args.dryRun = true; + else if (arg === '--force') args.force = true; + else if (arg === '--mock-send') args.mockSend = true; + else if (arg === '--state-path') args.statePath = argv[++i]; + else if (arg === '--env-file') args.envFile = argv[++i]; + else if (arg === '--bot-token') args.botToken = argv[++i]; + else if (arg === '--chat-id') args.chatId = argv[++i]; + else if (arg === '--now') args.now = argv[++i]; + else if (arg === '--ttl-minutes') args.ttlMinutes = Number(argv[++i]); + else args._.push(arg); + } + return args; +} + +function requireJson(args) { + if (!args.json) throw new Error('This CLI is intentionally JSON-only. Pass --json.'); +} + +function firstMatch(text, regex, fallback = null) { + const match = text.match(regex); + return match ? match[1].trim() : fallback; +} + +function normalizeRecommendation(value) { + const normalized = String(value || '').trim().toLowerCase(); + return ALLOWED_RECOMMENDATIONS.has(normalized) ? normalized : 'avoid'; +} + +function stripSensitiveIdeaBody(report) { + const sensitive = /idea_body_sensitive\s*[:=]\s*true|sensitive\s*[:=]\s*true|marked sensitive/i.test(report); + if (!sensitive) return { sensitiveIdeaBodyOmitted: false }; + return { sensitiveIdeaBodyOmitted: true }; +} + +function parseCandidatesFromMarkdown(report) { + const sections = report.split(/\n###\s+\d+\.\s+/).slice(1); + return sections.slice(0, 3).map((section) => { + const lines = section.split('\n'); + const fullName = lines[0]?.trim() || 'UNKNOWN'; + return { + full_name: fullName, + html_url: firstMatch(section, /^- URL:\s*(.+)$/m, null), + recommendation: normalizeRecommendation(firstMatch(section, /^- Recommendation:\s*(.+)$/m, 'avoid')), + score: Number(firstMatch(section, /^- Score:\s*(\d+)$/m, '0')), + stars: Number(firstMatch(section, /^- Stars:\s*(\d+)$/m, '0')), + }; + }); +} + +export function parseDiscoveryReportMarkdown(report, reportPath) { + const titleLine = firstMatch(report, /^# GitHub discovery report:\s*(.+)$/m, 'UNKNOWN'); + const ideaTitle = titleLine.replace(/\s+/g, ' ').trim(); + const recommendation = normalizeRecommendation(firstMatch(report, /^Recommendation:\s*(.+)$/m, 'avoid')); + const candidates = parseCandidatesFromMarkdown(report); + return { + ideaTitle, + recommendation, + topCandidates: candidates, + reportPath, + ...stripSensitiveIdeaBody(report), + }; +} + +function normalizeIndexCandidate(candidate) { + return { + full_name: candidate.full_name || candidate.name || 'UNKNOWN', + html_url: candidate.html_url || candidate.url || null, + recommendation: normalizeRecommendation(candidate.recommendation), + score: Number(candidate.score || 0), + stars: Number(candidate.stars || 0), + }; +} + +function mergeIndexRecord(reportData, indexRecord) { + if (!indexRecord) return reportData; + return { + ...reportData, + ideaTitle: indexRecord.idea_title || reportData.ideaTitle, + recommendation: normalizeRecommendation(indexRecord.recommendation || reportData.recommendation), + topCandidates: Array.isArray(indexRecord.top_candidate_summaries) + ? indexRecord.top_candidate_summaries.slice(0, 3).map(normalizeIndexCandidate) + : reportData.topCandidates, + source: indexRecord.source || null, + sanitized: indexRecord.sanitized === true, + }; +} + +export function formatTelegramDiscoveryNotification(reportData) { + const candidates = Array.isArray(reportData.topCandidates) ? reportData.topCandidates.slice(0, 3) : []; + const top = candidates[0] || {}; + const alias = shortAliasForApprovalId(`${reportData.ideaTitle || 'discovery'}-${top.full_name || 'repo'}`); + return formatHumanTelegramMessage({ + type: 'discovery', + idea_title: reportData.ideaTitle, + repo: top.full_name || 'UNKNOWN', + recommendation: reportData.recommendation, + alias, + report_path: reportData.reportPath, + }); +} + +function normalizeActionLabel(value) { + return String(value || '').trim().toLowerCase().replace(/\s+/g, '_'); +} + +function selectApprovalCandidate(reportData) { + const reportRecommendation = normalizeActionLabel(reportData.recommendation); + const candidates = Array.isArray(reportData.topCandidates) ? reportData.topCandidates : []; + const selected = candidates[0] || null; + if (!selected) throw new Error('No selected repository candidate found in discovery report.'); + const candidateRecommendation = normalizeActionLabel(selected.recommendation); + const recommendedNextAction = APPROVAL_REQUEST_RECOMMENDATIONS.has(reportRecommendation) ? reportRecommendation : candidateRecommendation; + if (!APPROVAL_REQUEST_RECOMMENDATIONS.has(recommendedNextAction)) { + throw new Error(`Approval request preview is only available for use/fork/learn_from recommendations. Got: ${recommendedNextAction}`); + } + return { selected, recommendedNextAction }; +} + +function buildApprovalId({ ideaTitle, selectedRepo, recommendedNextAction, reportPath }) { + const seed = [ideaTitle, selectedRepo, recommendedNextAction, reportPath].join('\n'); + return `tg4_${createHash('sha256').update(seed).digest('hex').slice(0, 16)}`; +} + +function shortAliasForApprovalId(approvalId) { + const match = String(approvalId || '').match(/([a-f0-9]{8})[a-f0-9]*$/i); + return match ? match[1].toLowerCase() : String(approvalId || '').toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 12); +} + +function proposedScopeForAction(action, selectedRepo) { + if (action === 'use') { + return `Review ${selectedRepo} as a candidate implementation source in a later explicitly approved step: read public repo metadata/docs only, then produce a narrow adoption plan. No clone, install, run, fork, or GitHub write is approved by this preview.`; + } + if (action === 'fork') { + return `Request explicit human approval for a later fork of ${selectedRepo}. This preview does not fork; the later approved scope must name the target account and audit path before any GitHub write.`; + } + return `Review ${selectedRepo} as a learning/reference source in a later explicitly approved step: read public metadata/docs only and extract lessons. No clone, install, run, fork, or GitHub write is approved by this preview.`; +} + +export function formatTelegramApprovalRequestMessage(approval, { includeCommands = false, verbose = false } = {}) { + return formatHumanTelegramMessage({ + type: 'approval', + approval_id: approval.approval_id, + alias: approval.short_alias, + action: approval.recommended_next_action, + recommendation: `Review ${approval.selected_repo.full_name}; approve only if this scope is right.`, + if_approve: includeCommands + ? `Reply /approve ${approval.short_alias} to allow this next step.` + : 'This preview records no approval yet.', + will_not: includeCommands + ? 'No clone, fork, GitHub write, code edit, install, or executor run from this message alone.' + : 'No approve/reject command is active, no Telegram send, no executor, and no GitHub write.', + report_path: approval.report_path, + verbose, + }); +} + +export async function buildDiscoveryApprovalRequestPreview({ reportPath, indexPath = DEFAULT_DISCOVERY_INDEX_PATH, now = new Date(), ttlMinutes = 60 } = {}) { + const notificationPreview = await buildDiscoveryNotificationPreview({ reportPath, indexPath }); + const reportData = notificationPreview.report; + const { selected, recommendedNextAction } = selectApprovalCandidate(reportData); + const selectedRepoName = selected.full_name || 'UNKNOWN'; + const expiry = new Date(new Date(now).getTime() + Number(ttlMinutes || 60) * 60 * 1000).toISOString(); + const approval = { + approval_id: buildApprovalId({ + ideaTitle: reportData.ideaTitle, + selectedRepo: selectedRepoName, + recommendedNextAction, + reportPath: reportData.reportPath, + }), + idea_title: reportData.ideaTitle, + selected_repo: { + full_name: selectedRepoName, + html_url: selected.html_url || null, + recommendation: normalizeActionLabel(selected.recommendation || recommendedNextAction), + score: Number(selected.score || 0), + stars: Number(selected.stars || 0), + }, + recommended_next_action: recommendedNextAction, + exact_proposed_scope: proposedScopeForAction(recommendedNextAction, selectedRepoName), + allowed_side_effects: [ + 'read existing discovery report/index from local disk', + 'generate JSON approval request preview on stdout', + 'generate Telegram-safe plain-text message preview', + ], + forbidden_side_effects: [ + 'approve command', + 'reject command', + 'Telegram sendMessage call', + 'action execution', + 'clone', + 'fork', + 'GitHub API call or write', + 'repo creation/push/PR/merge/delete', + 'report/index/audit/durable-store write', + ], + expiry_time: expiry, + report_path: reportData.reportPath, + }; + approval.short_alias = shortAliasForApprovalId(approval.approval_id); + approval.approve_command = `/approve ${approval.short_alias}`; + approval.reject_command = `/reject ${approval.short_alias}`; + return { + ok: true, + mode: 'approval_request_preview', + sent: false, + executed: false, + approved: false, + rejected: false, + approval, + approval_id: approval.approval_id, + short_alias: approval.short_alias, + approve_command: approval.approve_command, + reject_command: approval.reject_command, + idea_title: approval.idea_title, + selected_repo: approval.selected_repo, + recommended_next_action: approval.recommended_next_action, + exact_proposed_scope: approval.exact_proposed_scope, + allowed_side_effects: approval.allowed_side_effects, + forbidden_side_effects: approval.forbidden_side_effects, + expiry_time: approval.expiry_time, + telegram_message_text: formatTelegramApprovalRequestMessage(approval), + ...SIDE_EFFECT_FLAGS, + note: 'No approve/reject commands. No action taken.', + }; +} + +export function hashReportContent(content) { + return createHash('sha256').update(content).digest('hex'); +} + +async function readApprovalRequestRows(statePath = DEFAULT_TELEGRAM_APPROVAL_REQUESTS_PATH) { + try { + const content = await readFile(statePath, 'utf8'); + return content + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line)); + } catch (error) { + if (error && error.code === 'ENOENT') return []; + throw error; + } +} + +async function appendApprovalRequestRow(statePath, row) { + await mkdir(path.dirname(statePath), { recursive: true }); + await appendFile(statePath, `${JSON.stringify(row)}\n`, 'utf8'); +} + +function approvalSendSideEffects({ sent = false, stateAppended = false } = {}) { + return { + sent, + stateAppended, + githubCalls: false, + githubWrites: false, + cloneForkRunInstallCreateRepoPushPrMergeDelete: false, + auditAppend: false, + durableMutation: false, + obsidianKanbanWrites: false, + getUpdatesPolling: false, + approveRejectHandling: false, + executor: false, + }; +} + +async function sendTelegramApprovalMessage({ credentials, message, fetchImpl = globalThis.fetch, mockSend = false } = {}) { + if (!credentials.botToken || !credentials.chatId) { + return { ok: false, blocked: true, reason: 'MISSING_TELEGRAM_CREDENTIALS', messageId: null }; + } + if (mockSend) return { ok: true, mocked: true, messageId: 'mock-message-id' }; + if (typeof fetchImpl !== 'function') throw new Error('fetch is unavailable for Telegram send.'); + const response = await fetchImpl(`https://api.telegram.org/bot${credentials.botToken}/sendMessage`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + chat_id: credentials.chatId, + text: message, + disable_web_page_preview: true, + }), + }); + let telegramOk = response.ok; + let messageId = null; + try { + const body = await response.json(); + telegramOk = telegramOk && body.ok !== false; + messageId = body?.result?.message_id ?? null; + } catch { + // Keep response metadata minimal and secret-free. + } + if (!telegramOk) throw new Error(`Telegram send failed with status ${response.status}`); + return { ok: true, messageId }; +} + +export async function sendTelegramDiscoveryApprovalRequest({ + reportPath, + indexPath = DEFAULT_DISCOVERY_INDEX_PATH, + statePath = DEFAULT_TELEGRAM_APPROVAL_REQUESTS_PATH, + dryRun = false, + force = false, + env = process.env, + envFile = defaultTelegramEnvFile(), + fetchImpl = globalThis.fetch, + mockSend = false, + botToken, + chatId, + now = new Date(), + ttlMinutes = 60, +} = {}) { + if (!reportPath) throw new Error('telegram-discovery-approval-send requires --report-path.'); + const resolvedReportPath = path.resolve(reportPath); + const reportContent = await readFile(resolvedReportPath, 'utf8'); + const reportHash = hashReportContent(reportContent); + const preview = await buildDiscoveryApprovalRequestPreview({ reportPath: resolvedReportPath, indexPath, now, ttlMinutes }); + const approval = { + ...preview.approval, + allowed_side_effects: dryRun + ? preview.approval.allowed_side_effects + : [ + ...preview.approval.allowed_side_effects, + 'send exactly one Telegram sendMessage approval request', + 'append one JSONL approval request state row after Telegram send succeeds', + ], + forbidden_side_effects: (dryRun + ? preview.approval.forbidden_side_effects + : preview.approval.forbidden_side_effects + .filter((item) => item !== 'Telegram sendMessage call') + .map((item) => item === 'report/index/audit/durable-store write' + ? 'discovery report/index/audit/durable-store write except the approval request JSONL state row after successful send' + : item)), + }; + const message = formatTelegramApprovalRequestMessage(approval, { includeCommands: true }); + const existingRows = await readApprovalRequestRows(statePath); + const duplicate = existingRows.find((row) => row.report_hash === reportHash && row.status === 'requested'); + const credentials = await resolveTelegramCredentialsWithEnvFile(env, { botToken, chatId, envFile }); + + const base = { + ok: true, + mode: dryRun ? 'approval_send_dry_run' : 'approval_send', + dryRun, + force, + approval_id: approval.approval_id, + idea_title: approval.idea_title, + selected_repo: approval.selected_repo, + recommended_next_action: approval.recommended_next_action, + exact_proposed_scope: approval.exact_proposed_scope, + allowed_side_effects: approval.allowed_side_effects, + forbidden_side_effects: approval.forbidden_side_effects, + expiry_time: approval.expiry_time, + short_alias: approval.short_alias, + approve_command: approval.approve_command, + reject_command: approval.reject_command, + telegram_message_text: message, + report_path: resolvedReportPath, + report_hash: reportHash, + state_path: statePath, + telegram: credentials.redacted, + ...approvalSendSideEffects({ sent: false, stateAppended: false }), + }; + + if (dryRun) { + return { ...base, ok: true, note: 'Dry-run only. No Telegram send and no state write.' }; + } + + if (duplicate && !force) { + return { + ...base, + ok: true, + skipped: true, + duplicate: true, + existing_approval_id: duplicate.approval_id, + existing_message_id: duplicate.message_id ?? null, + note: 'Duplicate approval request skipped by report_hash. Use --force to send another request.', + }; + } + + const sendResult = await sendTelegramApprovalMessage({ credentials, message, fetchImpl, mockSend }); + if (!sendResult.ok) { + return { + ...base, + ok: false, + blocked: true, + reason: sendResult.reason, + note: 'No state row appended because Telegram send did not succeed.', + }; + } + + const sentAt = new Date(now).toISOString(); + const row = { + approval_id: approval.approval_id, + short_alias: approval.short_alias, + approve_command: approval.approve_command, + reject_command: approval.reject_command, + report_path: resolvedReportPath, + report_hash: reportHash, + sent_at: sentAt, + expires_at: approval.expiry_time, + selected_repo: approval.selected_repo.full_name, + recommended_next_action: approval.recommended_next_action, + message_id: sendResult.messageId, + status: 'requested', + }; + await appendApprovalRequestRow(statePath, row); + return { + ...base, + ok: true, + sent: true, + stateAppended: true, + mocked: sendResult.mocked === true, + message_id: sendResult.messageId, + state_row: row, + ...approvalSendSideEffects({ sent: true, stateAppended: true }), + note: 'Telegram approval request sent. No approve/reject handling or executor ran.', + }; +} + +export async function readLatestDiscoveryIndexRecord(indexPath = DEFAULT_DISCOVERY_INDEX_PATH) { + const content = await readFile(indexPath, 'utf8'); + const records = content + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line)) + .filter((record) => record && record.report_path); + if (!records.length) throw new Error(`No discovery reports found in index: ${indexPath}`); + records.sort((a, b) => String(a.indexed_at || '').localeCompare(String(b.indexed_at || ''))); + return records.at(-1); +} + +export async function findIndexRecordForReport(reportPath, indexPath = DEFAULT_DISCOVERY_INDEX_PATH) { + try { + const content = await readFile(indexPath, 'utf8'); + const resolved = path.resolve(reportPath); + const records = content.split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)); + return records.find((record) => path.resolve(record.report_path || '') === resolved) || null; + } catch { + return null; + } +} + +export async function buildDiscoveryNotificationPreview({ reportPath, indexPath = DEFAULT_DISCOVERY_INDEX_PATH } = {}) { + let selectedReportPath = reportPath; + let indexRecord = null; + if (!selectedReportPath) { + indexRecord = await readLatestDiscoveryIndexRecord(indexPath); + selectedReportPath = indexRecord.report_path; + } else { + indexRecord = await findIndexRecordForReport(selectedReportPath, indexPath); + } + + if (!selectedReportPath) throw new Error('Missing report path.'); + const resolvedReportPath = path.resolve(selectedReportPath); + const report = await readFile(resolvedReportPath, 'utf8'); + const reportData = mergeIndexRecord(parseDiscoveryReportMarkdown(report, resolvedReportPath), indexRecord); + const message = formatTelegramDiscoveryNotification(reportData); + + return { + ok: true, + mode: 'preview', + sent: false, + message, + report: { + ideaTitle: reportData.ideaTitle, + recommendation: reportData.recommendation, + topCandidates: reportData.topCandidates.slice(0, 3), + reportPath: resolvedReportPath, + sensitiveIdeaBodyOmitted: reportData.sensitiveIdeaBodyOmitted === true, + }, + telegram: { + botToken: null, + chatId: null, + }, + ...SIDE_EFFECT_FLAGS, + note: 'No action taken.', + }; +} + +export function defaultTelegramEnvFile() { + return path.join(os.homedir(), '.hermes', '.env'); +} + +function parseEnvContent(content) { + const parsed = {}; + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const index = trimmed.indexOf('='); + if (index === -1) continue; + const key = trimmed.slice(0, index).trim(); + let value = trimmed.slice(index + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + parsed[key] = value; + } + return parsed; +} + +async function readOptionalEnvFile(envFile) { + try { + return parseEnvContent(await readFile(envFile, 'utf8')); + } catch (error) { + if (error && error.code === 'ENOENT') return {}; + throw error; + } +} + +export function resolveTelegramCredentials(env = process.env, overrides = {}) { + const botToken = overrides.botToken || env.TELEGRAM_BOT_TOKEN || env.HERMES_TELEGRAM_BOT_TOKEN || env.TG_BOT_TOKEN || null; + const chatId = overrides.chatId || env.TELEGRAM_CHAT_ID || env.HERMES_TELEGRAM_CHAT_ID || env.TG_CHAT_ID || null; + return { + botToken, + chatId, + redacted: { + botToken: redactSecret(botToken), + chatId: redactSecret(chatId), + }, + }; +} + +async function resolveTelegramCredentialsWithEnvFile(env = process.env, overrides = {}) { + const envFile = overrides.envFile || defaultTelegramEnvFile(); + const fileEnv = await readOptionalEnvFile(envFile); + return resolveTelegramCredentials({ ...fileEnv, ...env }, overrides); +} + +export async function sendTelegramDiscoveryNotification({ reportPath, indexPath = DEFAULT_DISCOVERY_INDEX_PATH, env = process.env, fetchImpl = globalThis.fetch, mockSend = false, botToken, chatId } = {}) { + if (!reportPath) throw new Error('telegram-discovery-notification-send requires --report-path.'); + const preview = await buildDiscoveryNotificationPreview({ reportPath, indexPath }); + const credentials = resolveTelegramCredentials(env, { botToken, chatId }); + + if (!credentials.botToken || !credentials.chatId) { + return { + ok: false, + mode: 'send', + sent: false, + blocked: true, + reason: 'MISSING_TELEGRAM_CREDENTIALS', + message: preview.message, + telegram: credentials.redacted, + ...SIDE_EFFECT_FLAGS, + note: 'No action taken.', + }; + } + + if (mockSend) { + return { + ok: true, + mode: 'send', + sent: true, + mocked: true, + message: preview.message, + telegram: credentials.redacted, + ...SIDE_EFFECT_FLAGS, + note: 'Mock send only. No action taken outside mock.', + }; + } + + if (typeof fetchImpl !== 'function') throw new Error('fetch is unavailable for Telegram send.'); + const response = await fetchImpl(`https://api.telegram.org/bot${credentials.botToken}/sendMessage`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + chat_id: credentials.chatId, + text: preview.message, + disable_web_page_preview: true, + }), + }); + + let telegramOk = response.ok; + let messageId = null; + try { + const body = await response.json(); + telegramOk = telegramOk && body.ok !== false; + messageId = body?.result?.message_id ?? null; + } catch { + // Keep response metadata minimal and secret-free. + } + + if (!telegramOk) throw new Error(`Telegram send failed with status ${response.status}`); + return { + ok: true, + mode: 'send', + sent: true, + messageId, + message: preview.message, + telegram: credentials.redacted, + ...SIDE_EFFECT_FLAGS, + note: 'No action taken beyond Telegram notification.', + }; +} + +export async function runTelegramDiscoveryNotificationCli(argv = process.argv.slice(2), invokedPath = process.argv[1] || '') { + const args = parseArgs(argv); + requireJson(args); + const basename = path.basename(invokedPath); + const command = basename.includes('approval-send') ? 'approval-send' : basename.includes('approval') ? 'approval-preview' : basename.includes('send') ? 'send' : 'preview'; + const result = command === 'approval-send' + ? await sendTelegramDiscoveryApprovalRequest({ + reportPath: args.reportPath, + indexPath: args.indexPath || DEFAULT_DISCOVERY_INDEX_PATH, + statePath: args.statePath || DEFAULT_TELEGRAM_APPROVAL_REQUESTS_PATH, + dryRun: args.dryRun === true, + force: args.force === true, + mockSend: args.mockSend, + botToken: args.botToken, + chatId: args.chatId, + envFile: args.envFile || defaultTelegramEnvFile(), + now: args.now ? new Date(args.now) : new Date(), + ttlMinutes: args.ttlMinutes || 60, + }) + : command === 'send' + ? await sendTelegramDiscoveryNotification({ + reportPath: args.reportPath, + indexPath: args.indexPath || DEFAULT_DISCOVERY_INDEX_PATH, + mockSend: args.mockSend, + botToken: args.botToken, + chatId: args.chatId, + }) + : command === 'approval-preview' + ? await buildDiscoveryApprovalRequestPreview({ + reportPath: args.reportPath, + indexPath: args.indexPath || DEFAULT_DISCOVERY_INDEX_PATH, + now: args.now ? new Date(args.now) : new Date(), + ttlMinutes: args.ttlMinutes || 60, + }) + : await buildDiscoveryNotificationPreview({ + reportPath: args.reportPath, + indexPath: args.indexPath || DEFAULT_DISCOVERY_INDEX_PATH, + }); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { + runTelegramDiscoveryNotificationCli().catch((error) => { + const safe = { + ok: false, + sent: false, + error: error.message.replace(/bot\d+:[A-Za-z0-9_-]+/g, 'bot[REDACTED]'), + ...SIDE_EFFECT_FLAGS, + note: 'No action taken.', + }; + process.stdout.write(`${JSON.stringify(safe, null, 2)}\n`); + process.exitCode = 1; + }); +} diff --git a/scripts/telegram-discovery-notification.test.mjs b/scripts/telegram-discovery-notification.test.mjs new file mode 100644 index 000000000..ed738a752 --- /dev/null +++ b/scripts/telegram-discovery-notification.test.mjs @@ -0,0 +1,330 @@ +import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; +import { + buildDiscoveryApprovalRequestPreview, + buildDiscoveryNotificationPreview, + sendTelegramDiscoveryApprovalRequest, + sendTelegramDiscoveryNotification, +} from './telegram-discovery-notification.mjs'; + +const execFileAsync = promisify(execFile); + +const reportMarkdown = `# GitHub discovery report: Sensitive workflow helper + +Status: PASS_PUBLIC_GITHUB_REPO_DISCOVERY_REPORT +Recommendation: learn_from +sensitive: true +idea_body: SHOULD_NOT_BE_SENT + +## Safety + +Read-only public GitHub discovery only. + +## Candidates + +### 1. owner/alpha +- URL: https://github.com/owner/alpha +- Recommendation: use +- Score: 99 +- Stars: 123 + +### 2. owner/beta +- URL: https://github.com/owner/beta +- Recommendation: fork +- Score: 88 +- Stars: 45 + +### 3. owner/gamma +- URL: https://github.com/owner/gamma +- Recommendation: avoid +- Score: 77 +- Stars: 6 + +### 4. owner/delta +- URL: https://github.com/owner/delta +- Recommendation: use +- Score: 66 +- Stars: 5 +`; + +async function fixture() { + const dir = await mkdtemp(path.join(tmpdir(), 'tg-disc-test-')); + const reportPath = path.join(dir, 'report.md'); + const indexPath = path.join(dir, 'index.jsonl'); + await writeFile(reportPath, reportMarkdown, 'utf8'); + await writeFile(indexPath, `${JSON.stringify({ + schema: 'github_discovery_captured_idea_index_v1', + indexed_at: '2026-07-04T00:00:00Z', + idea_title: 'Sensitive workflow helper', + recommendation: 'learn_from', + report_path: reportPath, + github_write: false, + audit_append: false, + durable_mutation: false, + obsidian_kanban_discord_write: false, + sanitized: true, + top_candidate_summaries: [ + { full_name: 'owner/alpha', html_url: 'https://github.com/owner/alpha', recommendation: 'use', score: 99, stars: 123 }, + { full_name: 'owner/beta', html_url: 'https://github.com/owner/beta', recommendation: 'fork', score: 88, stars: 45 }, + { full_name: 'owner/gamma', html_url: 'https://github.com/owner/gamma', recommendation: 'avoid', score: 77, stars: 6 }, + { full_name: 'owner/delta', html_url: 'https://github.com/owner/delta', recommendation: 'use', score: 66, stars: 5 }, + ], + })}\n`, 'utf8'); + return { dir, reportPath, indexPath }; +} + +describe('telegram discovery notification bridge', () => { + it('preview CLI emits a read-only JSON notification from an existing discovery report', async () => { + const { indexPath } = await fixture(); + const { stdout } = await execFileAsync('node', ['bin/telegram-discovery-notification-preview', '--json', '--index-path', indexPath], { + cwd: process.cwd(), + env: { PATH: process.env.PATH, NODE_NO_WARNINGS: '1' }, + }); + const result = JSON.parse(stdout); + expect(result.ok).toBe(true); + expect(result.sent).toBe(false); + expect(result.githubCalls).toBe(false); + expect(result.githubWrites).toBe(false); + expect(result.auditAppend).toBe(false); + expect(result.durableMutation).toBe(false); + expect(result.obsidianKanbanWrites).toBe(false); + expect(result.message).toContain('What happened: Found owner/alpha for Sensitive workflow helper.'); + expect(result.message).toContain('Recommendation: learn_from'); + expect(result.message).toContain('If you approve:'); + expect(result.message).toContain('What will NOT happen:'); + expect(result.message).toContain('Reply:'); + expect(result.message).not.toContain('owner/delta'); + expect(result.message).toContain('Details saved in report'); + expect(result.message).not.toContain('SHOULD_NOT_BE_SENT'); + }); + + it('send blocks safely when Telegram credentials are missing', async () => { + const { reportPath, indexPath } = await fixture(); + const result = await sendTelegramDiscoveryNotification({ reportPath, indexPath, env: {} }); + expect(result.ok).toBe(false); + expect(result.sent).toBe(false); + expect(result.blocked).toBe(true); + expect(result.reason).toBe('MISSING_TELEGRAM_CREDENTIALS'); + expect(result.telegram.botToken).toBeNull(); + expect(result.telegram.chatId).toBeNull(); + expect(result.githubCalls).toBe(false); + expect(result.auditAppend).toBe(false); + expect(result.durableMutation).toBe(false); + }); + + it('send requires explicit report_path', async () => { + const result = await sendTelegramDiscoveryNotification({ env: {}, fetchImpl: async () => ({ ok: true }) }).catch((error) => error); + expect(result).toBeInstanceOf(Error); + expect(result.message).toContain('--report-path'); + }); + + it('send path is mocked in tests and redacts token/chat values', async () => { + const { reportPath, indexPath } = await fixture(); + const result = await sendTelegramDiscoveryNotification({ + reportPath, + indexPath, + env: { TELEGRAM_BOT_TOKEN: '123456:SUPER_SECRET_TOKEN', TELEGRAM_CHAT_ID: '987654321' }, + mockSend: true, + }); + expect(result.ok).toBe(true); + expect(result.sent).toBe(true); + expect(result.mocked).toBe(true); + expect(result.telegram.botToken).toBe('[REDACTED]'); + expect(result.telegram.chatId).toBe('[REDACTED]'); + const raw = JSON.stringify(result); + expect(raw).not.toContain('SUPER_SECRET_TOKEN'); + expect(raw).not.toContain('987654321'); + expect(result.githubCalls).toBe(false); + expect(result.githubWrites).toBe(false); + expect(result.auditAppend).toBe(false); + expect(result.durableMutation).toBe(false); + expect(result.obsidianKanbanWrites).toBe(false); + }); + + it('live send uses exactly one Telegram API request when a fetch implementation is supplied', async () => { + const { reportPath, indexPath } = await fixture(); + const calls = []; + const result = await sendTelegramDiscoveryNotification({ + reportPath, + indexPath, + env: { TELEGRAM_BOT_TOKEN: '123456:SUPER_SECRET_TOKEN', TELEGRAM_CHAT_ID: '987654321' }, + fetchImpl: async (url, init) => { + calls.push({ url, init }); + return { ok: true, status: 200, json: async () => ({ ok: true, result: { message_id: 42 } }) }; + }, + }); + expect(calls).toHaveLength(1); + expect(result.sent).toBe(true); + expect(result.messageId).toBe(42); + expect(calls[0].url).toContain('/sendMessage'); + expect(JSON.parse(calls[0].init.body).text).toContain('What will NOT happen'); + }); + + it('approval preview emits read-only Telegram-safe approval request fields without commands or execution', async () => { + const { reportPath, indexPath } = await fixture(); + const result = await buildDiscoveryApprovalRequestPreview({ + reportPath, + indexPath, + now: new Date('2026-07-04T10:00:00.000Z'), + ttlMinutes: 30, + }); + expect(result.ok).toBe(true); + expect(result.mode).toBe('approval_request_preview'); + expect(result.sent).toBe(false); + expect(result.executed).toBe(false); + expect(result.approved).toBe(false); + expect(result.rejected).toBe(false); + expect(result.approval_id).toMatch(/^tg4_[a-f0-9]{16}$/); + expect(result.idea_title).toBe('Sensitive workflow helper'); + expect(result.selected_repo.full_name).toBe('owner/alpha'); + expect(result.recommended_next_action).toBe('learn_from'); + expect(result.exact_proposed_scope).toContain('owner/alpha'); + expect(result.allowed_side_effects).toEqual([ + 'read existing discovery report/index from local disk', + 'generate JSON approval request preview on stdout', + 'generate Telegram-safe plain-text message preview', + ]); + expect(result.forbidden_side_effects).toContain('approve command'); + expect(result.forbidden_side_effects).toContain('reject command'); + expect(result.forbidden_side_effects).toContain('fork'); + expect(result.forbidden_side_effects).toContain('GitHub API call or write'); + expect(result.expiry_time).toBe('2026-07-04T10:30:00.000Z'); + expect(result.telegram_message_text).toContain('Approval needed'); + expect(result.telegram_message_text).not.toContain(result.approval_id); + expect(result.telegram_message_text).toContain('This preview records no approval yet.'); + expect(result.telegram_message_text).not.toContain('SHOULD_NOT_BE_SENT'); + expect(result.githubCalls).toBe(false); + expect(result.githubWrites).toBe(false); + expect(result.auditAppend).toBe(false); + expect(result.durableMutation).toBe(false); + }); + + it('approval preview CLI emits JSON and performs no Telegram/GitHub/write side effects', async () => { + const { indexPath } = await fixture(); + const { stdout } = await execFileAsync('node', ['bin/telegram-discovery-approval-preview', '--json', '--index-path', indexPath, '--now', '2026-07-04T10:00:00.000Z', '--ttl-minutes', '30'], { + cwd: process.cwd(), + env: { PATH: process.env.PATH, NODE_NO_WARNINGS: '1' }, + }); + const result = JSON.parse(stdout); + expect(result.sent).toBe(false); + expect(result.executed).toBe(false); + expect(result.selected_repo.full_name).toBe('owner/alpha'); + expect(result.recommended_next_action).toBe('learn_from'); + expect(result.expiry_time).toBe('2026-07-04T10:30:00.000Z'); + expect(result.telegram_message_text).toContain('Review owner/alpha'); + expect(result.githubWrites).toBe(false); + expect(result.cloneForkRunInstallCreateRepoPushPrMergeDelete).toBe(false); + }); + + it('approval send dry-run generates approval text without sending or writing state', async () => { + const { reportPath, indexPath, dir } = await fixture(); + const statePath = path.join(dir, 'telegram-approval-requests.jsonl'); + const { stdout } = await execFileAsync('node', ['bin/telegram-discovery-approval-send', '--report-path', reportPath, '--dry-run', '--json', '--index-path', indexPath, '--state-path', statePath, '--now', '2026-07-04T10:00:00.000Z'], { + cwd: process.cwd(), + env: { PATH: process.env.PATH, NODE_NO_WARNINGS: '1', TELEGRAM_BOT_TOKEN: '123456:SUPER_SECRET_TOKEN', TELEGRAM_CHAT_ID: '987654321' }, + }); + const result = JSON.parse(stdout); + expect(result.ok).toBe(true); + expect(result.dryRun).toBe(true); + expect(result.sent).toBe(false); + expect(result.stateAppended).toBe(false); + expect(result.short_alias).toBeTruthy(); + expect(result.telegram_message_text).toContain(`/approve ${result.short_alias}`); + expect(result.telegram_message_text).toContain(`/reject ${result.short_alias}`); + expect(result.telegram_message_text).not.toContain(result.approval_id); + await expect(stat(statePath)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(stdout).not.toContain('SUPER_SECRET_TOKEN'); + expect(stdout).not.toContain('987654321'); + }); + + it('mocked approval send appends one state row and skips duplicate reports', async () => { + const { reportPath, indexPath, dir } = await fixture(); + const statePath = path.join(dir, 'telegram-approval-requests.jsonl'); + const first = await sendTelegramDiscoveryApprovalRequest({ + reportPath, + indexPath, + statePath, + mockSend: true, + env: { TELEGRAM_BOT_TOKEN: '123456:SUPER_SECRET_TOKEN', TELEGRAM_CHAT_ID: '987654321' }, + now: new Date('2026-07-04T10:00:00.000Z'), + }); + expect(first.ok).toBe(true); + expect(first.sent).toBe(true); + expect(first.stateAppended).toBe(true); + expect(first.message_id).toBe('mock-message-id'); + let rows = (await readFile(statePath, 'utf8')).trim().split('\n').map((line) => JSON.parse(line)); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + approval_id: first.approval_id, + report_path: reportPath, + report_hash: first.report_hash, + sent_at: '2026-07-04T10:00:00.000Z', + expires_at: first.expiry_time, + selected_repo: 'owner/alpha', + recommended_next_action: 'learn_from', + message_id: 'mock-message-id', + status: 'requested', + }); + + const second = await sendTelegramDiscoveryApprovalRequest({ + reportPath, + indexPath, + statePath, + mockSend: true, + env: { TELEGRAM_BOT_TOKEN: '123456:SUPER_SECRET_TOKEN', TELEGRAM_CHAT_ID: '987654321' }, + now: new Date('2026-07-04T10:01:00.000Z'), + }); + expect(second.skipped).toBe(true); + expect(second.sent).toBe(false); + expect(second.stateAppended).toBe(false); + rows = (await readFile(statePath, 'utf8')).trim().split('\n').map((line) => JSON.parse(line)); + expect(rows).toHaveLength(1); + }); + + it('--force creates a second approval request only when explicit', async () => { + const { reportPath, indexPath, dir } = await fixture(); + const statePath = path.join(dir, 'telegram-approval-requests.jsonl'); + await sendTelegramDiscoveryApprovalRequest({ reportPath, indexPath, statePath, mockSend: true, env: { TELEGRAM_BOT_TOKEN: '123456:SUPER_SECRET_TOKEN', TELEGRAM_CHAT_ID: '987654321' } }); + const forced = await sendTelegramDiscoveryApprovalRequest({ reportPath, indexPath, statePath, force: true, mockSend: true, env: { TELEGRAM_BOT_TOKEN: '123456:SUPER_SECRET_TOKEN', TELEGRAM_CHAT_ID: '987654321' } }); + expect(forced.sent).toBe(true); + const rows = (await readFile(statePath, 'utf8')).trim().split('\n').map((line) => JSON.parse(line)); + expect(rows).toHaveLength(2); + }); + + it('approval send blocks invalid reports before sending or writing state', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'tg-disc-invalid-')); + const reportPath = path.join(dir, 'invalid.md'); + const statePath = path.join(dir, 'telegram-approval-requests.jsonl'); + await writeFile(reportPath, '# Not a discovery report\nRecommendation: avoid\n', 'utf8'); + const result = await sendTelegramDiscoveryApprovalRequest({ + reportPath, + statePath, + env: { TELEGRAM_BOT_TOKEN: '123456:SUPER_SECRET_TOKEN', TELEGRAM_CHAT_ID: '987654321' }, + fetchImpl: async () => { throw new Error('should not send'); }, + }).catch((error) => error); + expect(result).toBeInstanceOf(Error); + expect(result.message).toContain('No selected repository candidate'); + await expect(stat(statePath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('approval send output redacts token and chat values', async () => { + const { reportPath, indexPath, dir } = await fixture(); + const statePath = path.join(dir, 'telegram-approval-requests.jsonl'); + const result = await sendTelegramDiscoveryApprovalRequest({ + reportPath, + indexPath, + statePath, + dryRun: true, + env: { TELEGRAM_BOT_TOKEN: '123456:SUPER_SECRET_TOKEN', TELEGRAM_CHAT_ID: '987654321' }, + }); + const raw = JSON.stringify(result); + expect(raw).not.toContain('SUPER_SECRET_TOKEN'); + expect(raw).not.toContain('987654321'); + expect(result.telegram.botToken).toBe('[REDACTED]'); + expect(result.telegram.chatId).toBe('[REDACTED]'); + }); +}); diff --git a/scripts/telegram-message-format.mjs b/scripts/telegram-message-format.mjs new file mode 100644 index 000000000..2ecb951ee --- /dev/null +++ b/scripts/telegram-message-format.mjs @@ -0,0 +1,310 @@ +#!/usr/bin/env node +import { pathToFileURL } from 'node:url' + +export const TELEGRAM_MESSAGE_TYPES = Object.freeze([ + 'discovery', + 'approval', + 'implementation-proposal-approval', + 'code-edit-approval', + 'approval-recorded', + 'completion', + 'blocked-error', +]) + +const SECRET_PATTERNS = [ + /gh[pousr]_[A-Za-z0-9_]{20,}/gi, + /github_pat_[A-Za-z0-9_]{20,}/gi, + /bot\d+:[A-Za-z0-9_-]+/gi, + /(["']?(?:token|secret|password|api[_-]?key|client[_-]?secret|chat[_-]?id)["']?\s*[:=]\s*)["']?[^\s,'"}]+["']?/gi, +] + +const TYPE_ALIASES = Object.freeze({ + approval: 'approval', + 'approval-request': 'approval', + 'implementation-proposal': 'implementation-proposal-approval', + 'implementation-proposal-approval': 'implementation-proposal-approval', + 'code-edit': 'code-edit-approval', + 'code-edit-approval': 'code-edit-approval', + 'approval-recorded': 'approval-recorded', + recorded: 'approval-recorded', + completion: 'completion', + completed: 'completion', + blocked: 'blocked-error', + error: 'blocked-error', + 'blocked-error': 'blocked-error', + discovery: 'discovery', +}) + +export const MESSAGE_SIDE_EFFECT_FLAGS = Object.freeze({ + sent: false, + sendTest: false, + githubCalls: false, + githubWrites: false, + codeEdits: false, + auditAppend: false, + durableMutation: false, + obsidianKanbanWrites: false, +}) + +export function sanitizeTelegramText(value, cap = 500) { + let text = String(value ?? '').replace(/\u0000/g, '').replace(/idea_body\s*[:=]\s*[^\s\n\r]*/gi, 'idea_body=[OMITTED]') + for (const pattern of SECRET_PATTERNS) { + text = text.replace(pattern, (...match) => (match[1] ? `${match[1]}[REDACTED]` : '[REDACTED]')) + } + text = text.replace(/\/root\/[^\s,;)]{40,}/g, '[long path hidden]') + text = text.replace(/[\t ]+/g, ' ').replace(/\s+\n/g, '\n').trim() + return text.length > cap ? `${text.slice(0, Math.max(0, cap - 1)).trim()}…` : text +} + +export function normalizeTelegramMessageType(type) { + const normalized = String(type || '').trim().toLowerCase() + const mapped = TYPE_ALIASES[normalized] + if (!mapped) throw new Error(`Unsupported Telegram message preview type: ${type}`) + return mapped +} + +export function shortAliasForTelegram(value, fallback = 'item1') { + const id = String(value || '') + if (!id) return fallback + if (id === 'code_edit_build_impl_tg4_d3fd7da71ae557f3_8f61483465_d7420c166e_a033e3539084') return 'edit1' + const explicit = id.match(/\b(edit\d+|app\d+|disc\d+|done\d+|blk\d+)\b/i) + if (explicit) return explicit[1].toLowerCase() + const hex = id.match(/([a-f0-9]{8})[a-f0-9]*$/i) + if (hex) return hex[1].toLowerCase() + const safe = id.toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 8) + return safe || fallback +} + +function reportLine(reportPath) { + return reportPath ? 'Details saved in report.' : null +} + +function displayId(id, alias, verbose) { + if (verbose && id) return `${alias} (${sanitizeTelegramText(id, 120)})` + return alias +} + +function truncateList(items, cap = 3) { + return (Array.isArray(items) ? items : []).map((item) => sanitizeTelegramText(item, 80)).filter(Boolean).slice(0, cap) +} + +function defaultsForType(type, input) { + const alias = input.alias || input.short_alias || shortAliasForTelegram(input.approval_id || input.id, 'req1') + const reportPath = input.report_path || input.reportPath || null + const common = { alias, reportPath } + switch (type) { + case 'discovery': + return { + title: 'Discovery ready', + what: `Found ${input.repo || input.selected_repo || 'a repo candidate'} for ${input.idea_title || input.ideaTitle || 'the idea'}.`, + recommendation: input.recommendation || input.recommended_next_action || 'Review the report, then approve only if useful.', + ifApprove: `Reply /approve ${alias} to allow the next read-only step.`, + willNot: 'No clone, fork, GitHub write, code edit, audit append, or Obsidian/Kanban write.', + reply: `Approve: /approve ${alias}\nReject: /reject ${alias}`, + ...common, + } + case 'approval': + return { + title: 'Approval needed', + what: `Request ${displayId(input.approval_id, alias, input.verbose)} is ready for ${input.action || input.recommended_next_action || 'the next step'}.`, + recommendation: input.recommendation || 'Approve only if the scope looks right.', + ifApprove: input.if_approve || 'I will do only the approved next step.', + willNot: input.will_not || 'No GitHub writes, clone/fork/install, code edits, or durable mutation.', + reply: `Approve: /approve ${alias}\nReject: /reject ${alias}`, + ...common, + } + case 'implementation-proposal-approval': + return { + title: 'Implementation proposal ready', + what: `Proposal ${displayId(input.proposal_id || input.approval_id, alias, input.verbose)} is ready for review.`, + recommendation: input.recommendation || 'Approve only if you want a later build plan; this is not code approval.', + ifApprove: `Reply /approve ${alias} to allow a build plan.`, + willNot: 'No code edits, commands, installs, GitHub writes, audit append, or durable mutation.', + reply: `Approve: /approve ${alias}\nReject: /reject ${alias}`, + ...common, + } + case 'code-edit-approval': + return { + title: 'Code edit approval needed', + what: `Edit packet ${displayId(input.code_edit_approval_id || input.approval_id, alias, input.verbose)} is ready. Files: ${truncateList(input.files || input.exact_files_allowed_to_edit, 2).join(', ') || 'exact approved files only'}.`, + recommendation: input.recommendation || 'Approve only if these exact files are right.', + ifApprove: `Reply /approve ${alias} to allow edits to the exact listed files.`, + willNot: 'No extra files, staging, commit, GitHub call/write, audit append, or durable mutation.', + reply: `Approve: /approve ${alias}\nReject: /reject ${alias}`, + ...common, + } + case 'approval-recorded': + return { + title: 'Approval recorded', + what: `${input.decision || 'Decision'} recorded for ${displayId(input.approval_id, alias, input.verbose)}.`, + recommendation: input.decision === 'rejected' ? 'I will stop this path.' : 'Next worker may proceed only within the approved scope.', + ifApprove: 'Already recorded. No extra reply needed.', + willNot: 'Recording the reply did not run executor, edit code, call GitHub, or mutate durable store.', + reply: 'No reply needed.', + ...common, + } + case 'completion': + return { + title: 'Execution completed', + what: `${input.status || 'Completed'} for ${displayId(input.execution_result_id || input.approval_id, alias, input.verbose)}.`, + recommendation: input.recommendation || 'Review the result before approving any stage/commit/send step.', + ifApprove: `Reply /approve ${alias} only if a follow-up approval request asks for it.`, + willNot: 'No staging, commit, push, PR, GitHub write, audit append, or durable mutation happened here.', + reply: input.reply || 'Reply with the next exact instruction if you want follow-up work.', + ...common, + } + case 'blocked-error': + return { + title: 'Blocked', + what: sanitizeTelegramText(input.reason || input.error || 'The task stopped safely.', 180), + recommendation: input.recommendation || 'Fix the blocker or send a narrower approval.', + ifApprove: 'Approval alone may not continue until the blocker is fixed.', + willNot: 'No retry, code edit, GitHub call/write, audit append, durable mutation, or Obsidian/Kanban write.', + reply: input.reply || 'Reply with corrected scope or say stop.', + ...common, + } + default: + throw new Error(`Unsupported Telegram message type: ${type}`) + } +} + +export function formatHumanTelegramMessage(input = {}) { + const type = normalizeTelegramMessageType(input.type || 'discovery') + const spec = defaultsForType(type, input) + const lines = [ + sanitizeTelegramText(spec.title, 80), + '', + `What happened: ${sanitizeTelegramText(spec.what, 220)}`, + `Recommendation: ${sanitizeTelegramText(spec.recommendation, 180)}`, + `If you approve: ${sanitizeTelegramText(spec.ifApprove, 180)}`, + `What will NOT happen: ${sanitizeTelegramText(spec.willNot, 220)}`, + ] + const report = reportLine(spec.reportPath) + if (report) lines.push(report) + lines.push(`Reply: ${sanitizeTelegramText(spec.reply, 180)}`) + return lines.join('\n') +} + +function sampleForType(type, verbose = false) { + const samples = { + discovery: { + type, + idea_title: 'Cleaner Telegram approval UX', + repo: 'owner/repo', + recommendation: 'learn_from', + approval_id: 'tg4_1234567890abcdef', + alias: '90abcdef', + report_path: '/root/.hermes/reports/github-discovery/example-report.md', + verbose, + }, + approval: { + type, + approval_id: 'tg4_1234567890abcdef', + alias: '90abcdef', + action: 'learn_from', + report_path: '/root/.hermes/reports/github-discovery/example-report.md', + verbose, + }, + 'implementation-proposal-approval': { + type, + proposal_id: 'impl_tg4_1234567890abcdef_aaaabbbbcc', + alias: 'aaaabbbb', + report_path: '/root/.hermes/reports/github-discovery/proposals/example.md', + verbose, + }, + 'code-edit-approval': { + type, + code_edit_approval_id: 'code_edit_build_impl_tg4_1234567890abcdef_longlonglong_aaaabbbbcccc', + alias: 'edit1', + exact_files_allowed_to_edit: ['scripts/telegram-message-format.mjs', 'bin/telegram-message-preview'], + report_path: '/root/.hermes/reports/github-discovery/code-edit-approval-packets/example.md', + verbose, + }, + 'approval-recorded': { + type, + approval_id: 'tg4_1234567890abcdef', + alias: '90abcdef', + decision: 'approved', + verbose, + }, + completion: { + type, + execution_result_id: 'exec_tg4_1234567890abcdef_aaaabbbbcccc', + alias: 'done1', + status: 'Completed', + report_path: '/root/.hermes/reports/github-discovery/execution/example.md', + verbose, + }, + 'blocked-error': { + type, + approval_id: 'tg4_1234567890abcdef', + alias: 'blk1', + reason: 'Exact edit scope is missing.', + verbose, + }, + } + return samples[type] +} + +function parseArgs(argv) { + const args = { _: [] } + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i] + if (arg === '--json') args.json = true + else if (arg === '--verbose') args.verbose = true + else if (arg === '--send-test') args.sendTest = true + else if (arg === '--type') args.type = argv[++i] + else args._.push(arg) + } + return args +} + +function requireJson(args) { + if (!args.json) throw new Error('telegram-message-preview is intentionally JSON-only. Pass --json.') +} + +export async function buildTelegramMessagePreview({ type, verbose = false, sendTest = false } = {}) { + const normalizedType = normalizeTelegramMessageType(type) + const sample = sampleForType(normalizedType, verbose) + const message = formatHumanTelegramMessage(sample) + return { + ok: true, + mode: 'telegram_message_preview', + type: normalizedType, + verbose, + message, + message_length: message.length, + alias: sample.alias, + approve_command: message.includes('/approve') ? `/approve ${sample.alias}` : null, + reject_command: message.includes('/reject') ? `/reject ${sample.alias}` : null, + full_ids_hidden: !verbose, + raw_idea_body_included: false, + secrets_included: false, + report_path: sample.report_path || null, + details_saved_in_report: Boolean(sample.report_path), + ...MESSAGE_SIDE_EFFECT_FLAGS, + sendTest: sendTest === true, + note: sendTest ? 'Preview only in this CLI. Live test send is intentionally not implemented here.' : 'Preview only. No Telegram send.', + } +} + +export async function runTelegramMessagePreviewCli(argv = process.argv.slice(2)) { + const args = parseArgs(argv) + requireJson(args) + const result = await buildTelegramMessagePreview({ type: args.type, verbose: args.verbose === true, sendTest: args.sendTest === true }) + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`) +} + +if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { + runTelegramMessagePreviewCli().catch((error) => { + const safe = { + ok: false, + blocked: true, + error: sanitizeTelegramText(error?.message || error, 500), + ...MESSAGE_SIDE_EFFECT_FLAGS, + note: 'No action taken.', + } + process.stdout.write(`${JSON.stringify(safe, null, 2)}\n`) + process.exitCode = 1 + }) +} diff --git a/scripts/telegram-message-format.test.mjs b/scripts/telegram-message-format.test.mjs new file mode 100644 index 000000000..e70105c10 --- /dev/null +++ b/scripts/telegram-message-format.test.mjs @@ -0,0 +1,89 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { describe, expect, it } from 'vitest' +import { buildTelegramMessagePreview, formatHumanTelegramMessage } from './telegram-message-format.mjs' + +const execFileAsync = promisify(execFile) +const previewTypes = ['discovery', 'approval', 'code-edit-approval', 'completion'] +const allTypes = [ + 'discovery', + 'approval', + 'implementation-proposal-approval', + 'code-edit-approval', + 'approval-recorded', + 'completion', + 'blocked-error', +] + +function expectSixPartMessage(message) { + expect(message).toMatchSnapshot() + expect(message).toContain('What happened:') + expect(message).toContain('Recommendation:') + expect(message).toContain('If you approve:') + expect(message).toContain('What will NOT happen:') + expect(message).toContain('Reply:') + expect(message.length).toBeLessThanOrEqual(900) +} + +describe('human-readable Telegram message formatting', () => { + it.each(allTypes)('formats %s as a short six-part phone message', async (type) => { + const result = await buildTelegramMessagePreview({ type }) + expect(result.ok).toBe(true) + expectSixPartMessage(result.message) + expect(result.message).not.toContain('idea_body') + expect(result.message).not.toContain('SHOULD_NOT_BE_SENT') + expect(result.message).not.toMatch(/bot\d+:/) + expect(result.message).not.toMatch(/chat[_-]?id\s*[:=]/i) + expect(result.secrets_included).toBe(false) + expect(result.raw_idea_body_included).toBe(false) + expect(result.githubCalls).toBe(false) + expect(result.githubWrites).toBe(false) + expect(result.codeEdits).toBe(false) + expect(result.auditAppend).toBe(false) + expect(result.durableMutation).toBe(false) + expect(result.obsidianKanbanWrites).toBe(false) + }) + + it.each(previewTypes)('telegram-message-preview --type %s --json works without side effects', async (type) => { + const { stdout } = await execFileAsync('node', ['bin/telegram-message-preview', '--type', type, '--json'], { + cwd: process.cwd(), + env: { PATH: process.env.PATH, NODE_NO_WARNINGS: '1' }, + }) + const result = JSON.parse(stdout) + expect(result.ok).toBe(true) + expect(result.type).toBe(type) + expect(result.sent).toBe(false) + expect(result.sendTest).toBe(false) + expect(result.githubCalls).toBe(false) + expect(result.githubWrites).toBe(false) + expect(result.auditAppend).toBe(false) + expectSixPartMessage(result.message) + }) + + it('includes approve/reject aliases and hides long IDs by default', async () => { + const result = await buildTelegramMessagePreview({ type: 'code-edit-approval' }) + expect(result.message).toContain('/approve edit1') + expect(result.message).toContain('/reject edit1') + expect(result.message).not.toContain('code_edit_build_impl_tg4_1234567890abcdef_longlonglong') + expect(result.full_ids_hidden).toBe(true) + }) + + it('shows full IDs only in verbose mode', async () => { + const quiet = await buildTelegramMessagePreview({ type: 'approval' }) + const verbose = await buildTelegramMessagePreview({ type: 'approval', verbose: true }) + expect(quiet.message).not.toContain('tg4_1234567890abcdef') + expect(verbose.message).toContain('tg4_1234567890abcdef') + }) + + it('redacts secrets, chat IDs, raw idea_body, and long paths from message text', () => { + const message = formatHumanTelegramMessage({ + type: 'blocked-error', + reason: 'token=SHOULD_NOT_SURVIVE chat_id=123456789 idea_body: RAW_BODY /root/some/really/long/path/that/should/not/be/printed/in/a/tiny/telegram/message/file.txt', + alias: 'blk1', + }) + expect(message).not.toContain('SHOULD_NOT_SURVIVE') + expect(message).not.toContain('123456789') + expect(message).not.toContain('RAW_BODY') + expect(message).toContain('[long path hidden]') + }) +}) diff --git a/scripts/telegram-research-implementation-proposal.mjs b/scripts/telegram-research-implementation-proposal.mjs new file mode 100644 index 000000000..108d19d54 --- /dev/null +++ b/scripts/telegram-research-implementation-proposal.mjs @@ -0,0 +1,518 @@ +#!/usr/bin/env node +import { createHash } from 'node:crypto' +import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { formatHumanTelegramMessage, shortAliasForTelegram } from './telegram-message-format.mjs' + +export const DEFAULT_EXECUTION_RESULTS_PATH = + '/root/.hermes/reports/github-discovery/telegram-execution-results.jsonl' +export const DEFAULT_PROPOSAL_STATE_PATH = + '/root/.hermes/reports/github-discovery/telegram-implementation-proposals.jsonl' +export const DEFAULT_PROPOSAL_REPORT_DIR = + '/root/.hermes/reports/github-discovery/proposals' +export const APPROVAL_ID = 'tg4_d3fd7da71ae557f3' +export const SELECTED_REPO = 'CoWork-OS/CoWork-OS' +export const PRIOR_ACTION = 'learn_from' + +export const SIDE_EFFECT_FLAGS = Object.freeze({ + executor: false, + repoClone: false, + fork: false, + install: false, + codeExecution: false, + githubCalls: false, + githubWrites: false, + branch: false, + pr: false, + merge: false, + delete: false, + auditAppend: false, + durableMutation: false, + obsidianKanbanWrites: false, + telegramSent: false, +}) + +const SECRET_PATTERNS = [ + /gh[pousr]_[A-Za-z0-9_]{20,}/g, + /github_pat_[A-Za-z0-9_]{20,}/g, + /bot\d+:[A-Za-z0-9_-]+/g, + /\b-?\d{8,15}\b/g, + /["']?(?token|secret|password|api[_-]?key|client[_-]?secret|chat[_-]?id)["']?\s*[:=]\s*["']?[^\s,'\"}]+["']?/gi, +] + +function parseArgs(argv) { + const args = { _: [] } + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i] + if (arg === '--json') args.json = true + else if (arg === '--dry-run') args.dryRun = true + else if (arg === '--force') args.force = true + else if (arg === '--send-telegram') args.sendTelegram = true + else if (arg === '--approval-id') args.approvalId = argv[++i] + else if (arg === '--results-path') args.resultsPath = argv[++i] + else if (arg === '--proposals-path') args.proposalsPath = argv[++i] + else if (arg === '--proposal-report-dir') args.proposalReportDir = argv[++i] + else if (arg === '--research-report-path') + args.researchReportPath = argv[++i] + else if (arg === '--now') args.now = argv[++i] + else args._.push(arg) + } + return args +} + +function requireJson(args) { + if (!args.json) + throw new Error( + 'telegram-research-implementation-proposal is intentionally JSON-only. Pass --json.', + ) +} + +export function sanitizeText(value, cap = 12000) { + let text = String(value || '').replace(/\u0000/g, '') + for (const pattern of SECRET_PATTERNS) { + text = text.replace(pattern, (...match) => + match.groups?.key ? `${match.groups.key}=[REDACTED]` : '[REDACTED]', + ) + } + return text.replace(/[\t ]+$/gm, '').slice(0, cap) +} + +async function readJsonl(pathname) { + try { + const content = await readFile(pathname, 'utf8') + return content + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line)) + } catch (error) { + if (error?.code === 'ENOENT') return [] + throw error + } +} + +async function appendJsonl(pathname, row) { + await mkdir(path.dirname(pathname), { recursive: true }) + await appendFile(pathname, `${JSON.stringify(row)}\n`, 'utf8') +} + +function block(reason, extra = {}) { + return { + ok: false, + blocked: true, + reason, + executed: false, + proposalWritten: false, + reportWritten: false, + ...SIDE_EFFECT_FLAGS, + ...extra, + note: 'Fail-closed. No proposal row or proposal report was written.', + } +} + +function safeIdPart(value) { + return String(value || '').replace(/[^a-zA-Z0-9_-]/g, '_') +} + +function proposalId(approvalId, sourceRepo) { + return `impl_${safeIdPart(approvalId)}_${createHash('sha256').update(sourceRepo).digest('hex').slice(0, 10)}` +} + +function safeReportName(proposalIdValue) { + return `${safeIdPart(proposalIdValue)}.md` +} + +function findResult(rows, approvalId) { + return ( + rows.find( + (row) => row?.approval_id === approvalId && row?.status === 'completed', + ) || null + ) +} + +function findProposal(rows, approvalId) { + return rows.find((row) => row?.parent_approval_id === approvalId) || null +} + +function expectedResearchPath(result) { + return ( + result?.report_path || + `/root/.hermes/reports/github-discovery/research/${APPROVAL_ID}-CoWork-OS_CoWork-OS-research.md` + ) +} + +function extractSection(markdown, heading) { + const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const match = markdown.match( + new RegExp(`^## ${escaped}\\s*\\n([\\s\\S]*?)(?=^## |$)`, 'm'), + ) + return sanitizeText(match?.[1]?.trim() || '', 3000) +} + +function assertApprovedResult(result, approvalId) { + if (!result) + return block('EXECUTION_RESULT_NOT_FOUND', { approval_id: approvalId }) + if (result.selected_repo !== SELECTED_REPO) + return block('SELECTED_REPO_MISMATCH', { + approval_id: approvalId, + selected_repo: result.selected_repo || null, + expected_repo: SELECTED_REPO, + }) + if (result.action !== PRIOR_ACTION) + return block('ACTION_MISMATCH', { + approval_id: approvalId, + action: result.action || null, + expected_action: PRIOR_ACTION, + }) + if ( + result.github_writes !== false || + result.clone !== false || + result.fork !== false || + result.dependency_install !== false || + result.code_execution !== false + ) { + return block('UNSAFE_EXECUTION_RESULT_FLAGS', { approval_id: approvalId }) + } + return null +} + +function buildProposal({ approvalId, result, researchMarkdown, now }) { + const id = proposalId(approvalId, SELECTED_REPO) + const useful = extractSection( + researchMarkdown, + 'Useful architecture/features', + ) + const recommended = extractSection( + researchMarkdown, + 'Recommended next step for Hermes', + ) + const risks = extractSection(researchMarkdown, 'Risks/concerns') + const manifests = + result.repo?.manifests?.map((item) => item.name).filter(Boolean) || [] + const languages = result.repo?.languages || [] + const proposedScope = sanitizeText( + [ + 'Draft a local Hermes/LifeOS design note and thin prototype plan that adapts CoWork-OS product/workflow patterns without importing code.', + 'Focus on agent workspace/product-shape lessons: GUI-first workflow surfaces, CLI-capable local app shape, container/deployment notes, and TypeScript workspace structure.', + recommended + ? `Research recommendation: ${recommended.replace(/\s+/g, ' ')}` + : '', + ] + .filter(Boolean) + .join(' '), + 1800, + ) + const proposal = { + proposal_id: id, + parent_approval_id: approvalId, + idea_title: + 'Adapt CoWork-OS workflow/product patterns into a local Hermes implementation design', + source_repo: SELECTED_REPO, + recommended_build_type: 'adapt_pattern', + proposed_scope: proposedScope, + files_or_modules_expected_to_touch: [ + 'docs or reports/proposals design note only at approval stage', + 'future implementation may touch workspace/dashboard UX modules after separate approval', + manifests.includes('package.json') || languages.includes('TypeScript') + ? 'TypeScript workspace/dashboard patterns are likely relevant, exact files TBD after approval' + : 'exact files TBD after approval', + ], + allowed_side_effects: [ + 'Create this proposal row and proposal report only.', + 'No execution until a separate /approve command is received and a later implementation package is explicitly scoped.', + ], + forbidden_side_effects: [ + 'executor run', + 'repo clone', + 'fork', + 'install/dependency install', + 'code execution from source repo', + 'GitHub writes or API mutations', + 'branch/PR/merge/delete', + 'audit append', + 'durable store mutation outside the proposal state file', + 'Obsidian or Kanban writes', + 'Telegram send unless --send-telegram is explicitly implemented/tested later', + ], + estimated_complexity: result.repo?.setup_complexity_estimate || 'medium', + risks: [ + ...(risks + ? risks + .split(/\r?\n/) + .map((line) => line.replace(/^-\s*/, '').trim()) + .filter(Boolean) + : []), + 'CoWork-OS is a broad local AI app; scope can sprawl unless the next package stays design/prototype-only.', + 'License is MIT in research, but no source copy/import should occur without a separate code/license review.', + 'External credentials and environment-dependent features must stay excluded from the first implementation task.', + ].map((item) => sanitizeText(item, 500)), + acceptance_tests: [ + 'Proposal/preview emits JSON with side-effect flags false.', + 'Next approved task produces a design/prototype artifact only, with no clone/fork/install/GitHub write path.', + 'Any future code package has explicit file scope, targeted tests, and rollback instructions before edits.', + 'Secrets, Telegram token, and chat identifiers are redacted from all output.', + ], + rollback_plan: [ + 'Remove the single proposal JSONL row matching this proposal_id.', + 'Delete the single proposal report file matching this proposal_id.', + 'No repo, GitHub, audit, durable, Obsidian, Kanban, or Telegram rollback should be needed because those side effects are forbidden.', + ], + approval_command: `/approve ${id}`, + reject_command: `/reject ${id}`, + created_at: new Date(now).toISOString(), + source_research_report_path: expectedResearchPath(result), + source_research_report_hash: createHash('sha256') + .update(researchMarkdown) + .digest('hex'), + source_execution_result_status: result.status, + sanitized: true, + preview_only: true, + ...SIDE_EFFECT_FLAGS, + } + if (proposal.recommended_build_type !== 'adapt_pattern') + throw new Error('Unsafe proposal build type.') + if ( + /clone|fork|install|execute|run worker|github write/i.test( + proposal.proposed_scope, + ) && + !/without importing code|No execution/i.test(proposal.proposed_scope) + ) { + throw new Error('Unsafe proposal scope detected.') + } + return proposal +} + +function buildProposalMarkdown(proposal, researchMarkdown) { + const useful = + extractSection(researchMarkdown, 'Useful architecture/features') || + '- No useful features extracted.' + return sanitizeText( + `# Telegram research implementation proposal + +Proposal ID: ${proposal.proposal_id} +Parent approval ID: ${proposal.parent_approval_id} +Source repo: ${proposal.source_repo} +Recommended build type: ${proposal.recommended_build_type} +Created: ${proposal.created_at} +Preview only: true + +## Idea title +${proposal.idea_title} + +## Proposed scope +${proposal.proposed_scope} + +## Research signals +${useful} + +## Files/modules expected to touch +${proposal.files_or_modules_expected_to_touch.map((item) => `- ${item}`).join('\n')} + +## Allowed side effects +${proposal.allowed_side_effects.map((item) => `- ${item}`).join('\n')} + +## Forbidden side effects +${proposal.forbidden_side_effects.map((item) => `- ${item}`).join('\n')} + +## Estimated complexity +${proposal.estimated_complexity} + +## Risks +${proposal.risks.map((item) => `- ${item}`).join('\n')} + +## Acceptance tests +${proposal.acceptance_tests.map((item) => `- ${item}`).join('\n')} + +## Rollback plan +${proposal.rollback_plan.map((item) => `- ${item}`).join('\n')} + +## Approval commands +- ${proposal.approval_command} +- ${proposal.reject_command} +`, + 20000, + ) +} + +function validateSafeProposal(proposal) { + const serialized = JSON.stringify(proposal) + if (/gh[pousr]_|github_pat_|bot\d+:/i.test(serialized)) + return block('UNREDACTED_SECRET_DETECTED', { + proposal_id: proposal.proposal_id, + }) + if (/chat[_-]?id\s*[:=]/i.test(serialized)) + return block('UNREDACTED_CHAT_IDENTIFIER_DETECTED', { + proposal_id: proposal.proposal_id, + }) + const forbiddenTrue = Object.entries(SIDE_EFFECT_FLAGS) + .filter(([key]) => proposal[key] !== false) + .map(([key]) => key) + if (forbiddenTrue.length) + return block('UNSAFE_SIDE_EFFECT_FLAG', { + proposal_id: proposal.proposal_id, + flags: forbiddenTrue, + }) + if ( + !['build_from_scratch', 'adapt_pattern', 'fork_later', 'no_build'].includes( + proposal.recommended_build_type, + ) + ) + return block('INVALID_BUILD_TYPE', { proposal_id: proposal.proposal_id }) + if ( + /\b(clone|fork|install|npm install|pnpm install|execute repo|run repo|github write|open pr|merge|delete branch)\b/i.test( + proposal.proposed_scope, + ) + ) + return block('UNSAFE_PROPOSAL_SCOPE', { proposal_id: proposal.proposal_id }) + return null +} + +export async function runResearchImplementationProposal({ + approvalId, + resultsPath = DEFAULT_EXECUTION_RESULTS_PATH, + proposalsPath = DEFAULT_PROPOSAL_STATE_PATH, + proposalReportDir = DEFAULT_PROPOSAL_REPORT_DIR, + researchReportPath, + dryRun = false, + force = false, + sendTelegram = false, + now = new Date(), +} = {}) { + if (!approvalId) + throw new Error( + 'telegram-research-implementation-proposal requires --approval-id.', + ) + if (approvalId !== APPROVAL_ID) + return block('APPROVAL_ID_NOT_ALLOWED', { + approval_id: approvalId, + expected_approval_id: APPROVAL_ID, + }) + if (sendTelegram) + return block('TELEGRAM_SEND_NOT_IMPLEMENTED_FOR_THIS_PACKAGE', { + approval_id: approvalId, + telegramSent: false, + }) + + const [results, existingProposals] = await Promise.all([ + readJsonl(resultsPath), + readJsonl(proposalsPath), + ]) + const result = findResult(results, approvalId) + const resultBlock = assertApprovedResult(result, approvalId) + if (resultBlock) return resultBlock + const existing = findProposal(existingProposals, approvalId) + if (existing && !force) + return block('PROPOSAL_EXISTS_DUPLICATE_BLOCKED', { + approval_id: approvalId, + proposal_id: existing.proposal_id || null, + }) + + const reportPath = researchReportPath || expectedResearchPath(result) + let researchMarkdown + try { + researchMarkdown = sanitizeText(await readFile(reportPath, 'utf8'), 20000) + } catch (error) { + if (error?.code === 'ENOENT') + return block('RESEARCH_REPORT_MISSING', { + approval_id: approvalId, + research_report_path: reportPath, + }) + throw error + } + if ( + !researchMarkdown.includes(`Approval ID: ${approvalId}`) || + !researchMarkdown.includes(`Name: ${SELECTED_REPO}`) + ) { + return block('RESEARCH_REPORT_MISMATCH', { + approval_id: approvalId, + research_report_path: reportPath, + }) + } + + const proposal = buildProposal({ approvalId, result, researchMarkdown, now }) + const unsafe = validateSafeProposal(proposal) + if (unsafe) return unsafe + const markdown = buildProposalMarkdown(proposal, researchMarkdown) + const proposalReportPath = path.join( + proposalReportDir, + safeReportName(proposal.proposal_id), + ) + const proposalRow = { + ...proposal, + proposal_report_path: proposalReportPath, + proposal_report_hash: createHash('sha256').update(markdown).digest('hex'), + } + proposalRow.short_alias = shortAliasForTelegram(proposal.proposal_id, 'impl1') + proposalRow.telegram_message_text = formatHumanTelegramMessage({ + type: 'implementation-proposal-approval', + proposal_id: proposal.proposal_id, + alias: proposalRow.short_alias, + report_path: proposalReportPath, + }) + + const base = { + ok: true, + blocked: false, + mode: dryRun + ? 'implementation_proposal_dry_run' + : 'implementation_proposal_write', + approval_id: approvalId, + proposal_id: proposal.proposal_id, + dryRun, + force, + selected_repo: SELECTED_REPO, + proposal_path: proposalsPath, + proposal_report_path: proposalReportPath, + proposal: proposalRow, + executed: false, + proposalWritten: false, + reportWritten: false, + ...SIDE_EFFECT_FLAGS, + note: dryRun + ? 'Dry-run only. Proposal generated in JSON; no proposal row or report written.' + : 'Proposal generated and ready for explicit approval. No executor or external side effects were run.', + } + if (dryRun) return base + + await mkdir(proposalReportDir, { recursive: true }) + await writeFile(proposalReportPath, markdown, 'utf8') + await appendJsonl(proposalsPath, proposalRow) + return { ...base, proposalWritten: true, reportWritten: true } +} + +export async function runResearchImplementationProposalCli( + argv = process.argv.slice(2), +) { + const args = parseArgs(argv) + requireJson(args) + const result = await runResearchImplementationProposal({ + approvalId: args.approvalId, + resultsPath: args.resultsPath || DEFAULT_EXECUTION_RESULTS_PATH, + proposalsPath: args.proposalsPath || DEFAULT_PROPOSAL_STATE_PATH, + proposalReportDir: args.proposalReportDir || DEFAULT_PROPOSAL_REPORT_DIR, + researchReportPath: args.researchReportPath, + dryRun: args.dryRun === true, + force: args.force === true, + sendTelegram: args.sendTelegram === true, + now: args.now ? new Date(args.now) : new Date(), + }) + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`) + if (!result.ok) process.exitCode = 1 +} + +if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { + runResearchImplementationProposalCli().catch((error) => { + const safe = { + ok: false, + blocked: true, + error: sanitizeText(error?.message || error, 500), + executed: false, + proposalWritten: false, + reportWritten: false, + ...SIDE_EFFECT_FLAGS, + note: 'No action taken.', + } + process.stdout.write(`${JSON.stringify(safe, null, 2)}\n`) + process.exitCode = 1 + }) +} diff --git a/scripts/telegram-research-implementation-proposal.test.mjs b/scripts/telegram-research-implementation-proposal.test.mjs new file mode 100644 index 000000000..4c4858377 --- /dev/null +++ b/scripts/telegram-research-implementation-proposal.test.mjs @@ -0,0 +1,249 @@ +import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { runResearchImplementationProposal } from './telegram-research-implementation-proposal.mjs' + +const APPROVAL_ID = 'tg4_d3fd7da71ae557f3' + +async function exists(pathname) { + try { + await stat(pathname) + return true + } catch (error) { + if (error?.code === 'ENOENT') return false + throw error + } +} + +const resultRow = (overrides = {}) => ({ + approval_id: APPROVAL_ID, + status: 'completed', + executed_at: '2026-07-05T07:58:50.925Z', + action: 'learn_from', + selected_repo: 'CoWork-OS/CoWork-OS', + report_path: '', + report_hash: 'abc', + github_read_only_calls: true, + github_writes: false, + clone: false, + fork: false, + dependency_install: false, + code_execution: false, + durable_mutation: false, + audit_append: false, + obsidian_kanban_writes: false, + sanitized: true, + repo: { + full_name: 'CoWork-OS/CoWork-OS', + html_url: 'https://github.com/CoWork-OS/CoWork-OS', + license: 'MIT', + languages: ['TypeScript', 'CSS', 'JavaScript'], + manifests: [ + { name: 'package.json', size: 123 }, + { name: 'Dockerfile', size: 45 }, + ], + setup_complexity_estimate: 'medium', + }, + ...overrides, +}) + +const researchMarkdown = `# Approved GitHub discovery research: CoWork-OS/CoWork-OS + +Approval ID: ${APPROVAL_ID} +Action: learn_from +Read-only: true + +## Repo +- Name: CoWork-OS/CoWork-OS +- License: MIT + +## Useful architecture/features +- Language mix suggests implementation split: TypeScript, CSS, JavaScript. +- Has Node/package manifest; inspect scripts/dependency shape later before any adoption. +- Container/deployment files may provide useful setup/deployment patterns. + +## Risks/concerns +- Setup likely depends on external credentials or environment variables; token=SHOULD_NOT_SURVIVE chat_id=123456789 + +## Recommended next step for Hermes +Create a narrow follow-up design note extracting only the useful workflow/UI/architecture patterns from this repo. Keep implementation local and do not import code until a separate license/code review package is approved. +` + +async function fixture({ + existingProposal = false, + missingResearch = false, + unsafeResult = {}, + markdown = researchMarkdown, +} = {}) { + const dir = await mkdtemp(path.join(tmpdir(), 'tg-impl-proposal-')) + const resultsPath = path.join(dir, 'telegram-execution-results.jsonl') + const proposalsPath = path.join( + dir, + 'telegram-implementation-proposals.jsonl', + ) + const proposalReportDir = path.join(dir, 'proposals') + const researchReportPath = path.join(dir, 'research.md') + await writeFile( + resultsPath, + `${JSON.stringify(resultRow({ report_path: researchReportPath, ...unsafeResult }))}\n`, + 'utf8', + ) + if (!missingResearch) await writeFile(researchReportPath, markdown, 'utf8') + if (existingProposal) + await writeFile( + proposalsPath, + `${JSON.stringify({ parent_approval_id: APPROVAL_ID, proposal_id: 'existing' })}\n`, + 'utf8', + ) + return { + dir, + resultsPath, + proposalsPath, + proposalReportDir, + researchReportPath, + } +} + +async function readRows(pathname) { + return (await readFile(pathname, 'utf8')) + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line)) +} + +describe('telegram research implementation proposal', () => { + it('creates a proposal from research in dry-run without writes', async () => { + const f = await fixture() + const result = await runResearchImplementationProposal({ + approvalId: APPROVAL_ID, + resultsPath: f.resultsPath, + proposalsPath: f.proposalsPath, + proposalReportDir: f.proposalReportDir, + dryRun: true, + }) + expect(result.ok).toBe(true) + expect(result.proposal.recommended_build_type).toBe('adapt_pattern') + expect(result.proposal.approval_command).toBe( + `/approve ${result.proposal_id}`, + ) + expect(result.proposal.reject_command).toBe(`/reject ${result.proposal_id}`) + expect(result.githubCalls).toBe(false) + expect(result.githubWrites).toBe(false) + expect(result.executor).toBe(false) + expect(result.proposalWritten).toBe(false) + expect(result.reportWritten).toBe(false) + expect(await exists(f.proposalsPath)).toBe(false) + expect(await exists(f.proposalReportDir)).toBe(false) + }) + + it('write mode writes exactly one proposal row and one proposal report', async () => { + const f = await fixture() + const result = await runResearchImplementationProposal({ + approvalId: APPROVAL_ID, + resultsPath: f.resultsPath, + proposalsPath: f.proposalsPath, + proposalReportDir: f.proposalReportDir, + now: new Date('2026-07-05T11:00:00Z'), + }) + expect(result.ok).toBe(true) + expect(result.proposalWritten).toBe(true) + expect(result.reportWritten).toBe(true) + const rows = await readRows(f.proposalsPath) + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ + parent_approval_id: APPROVAL_ID, + source_repo: 'CoWork-OS/CoWork-OS', + recommended_build_type: 'adapt_pattern', + }) + expect(rows[0].githubWrites).toBe(false) + const report = await readFile(result.proposal_report_path, 'utf8') + expect(report).toContain(result.proposal_id) + expect(report).toContain('/approve') + expect(report).not.toContain('SHOULD_NOT_SURVIVE') + expect(report).not.toContain('123456789') + }) + + it('duplicate proposal blocks unless forced', async () => { + const f = await fixture({ existingProposal: true }) + const blocked = await runResearchImplementationProposal({ + approvalId: APPROVAL_ID, + resultsPath: f.resultsPath, + proposalsPath: f.proposalsPath, + proposalReportDir: f.proposalReportDir, + }) + expect(blocked.ok).toBe(false) + expect(blocked.reason).toBe('PROPOSAL_EXISTS_DUPLICATE_BLOCKED') + const forced = await runResearchImplementationProposal({ + approvalId: APPROVAL_ID, + resultsPath: f.resultsPath, + proposalsPath: f.proposalsPath, + proposalReportDir: f.proposalReportDir, + force: true, + }) + expect(forced.ok).toBe(true) + expect(await readRows(f.proposalsPath)).toHaveLength(2) + }) + + it('missing research blocks', async () => { + const f = await fixture({ missingResearch: true }) + const result = await runResearchImplementationProposal({ + approvalId: APPROVAL_ID, + resultsPath: f.resultsPath, + proposalsPath: f.proposalsPath, + proposalReportDir: f.proposalReportDir, + }) + expect(result.ok).toBe(false) + expect(result.reason).toBe('RESEARCH_REPORT_MISSING') + expect(await exists(f.proposalsPath)).toBe(false) + }) + + it('unsafe execution result blocks before proposal writes', async () => { + const f = await fixture({ unsafeResult: { code_execution: true } }) + const result = await runResearchImplementationProposal({ + approvalId: APPROVAL_ID, + resultsPath: f.resultsPath, + proposalsPath: f.proposalsPath, + proposalReportDir: f.proposalReportDir, + }) + expect(result.ok).toBe(false) + expect(result.reason).toBe('UNSAFE_EXECUTION_RESULT_FLAGS') + expect(await exists(f.proposalsPath)).toBe(false) + }) + + it('unsafe proposal scope blocks before writes', async () => { + const unsafeMarkdown = researchMarkdown.replace( + 'Create a narrow follow-up design note extracting only the useful workflow/UI/architecture patterns from this repo. Keep implementation local and do not import code until a separate license/code review package is approved.', + 'Clone the repository, install dependencies, execute it locally, and open a PR.', + ) + const f = await fixture({ markdown: unsafeMarkdown }) + const result = await runResearchImplementationProposal({ + approvalId: APPROVAL_ID, + resultsPath: f.resultsPath, + proposalsPath: f.proposalsPath, + proposalReportDir: f.proposalReportDir, + }) + expect(result.ok).toBe(false) + expect(result.reason).toBe('UNSAFE_PROPOSAL_SCOPE') + expect(await exists(f.proposalsPath)).toBe(false) + }) + + it('token chat and secrets are redacted', async () => { + const f = await fixture() + const result = await runResearchImplementationProposal({ + approvalId: APPROVAL_ID, + resultsPath: f.resultsPath, + proposalsPath: f.proposalsPath, + proposalReportDir: f.proposalReportDir, + }) + const raw = + JSON.stringify(result) + + (await readFile(result.proposal_report_path, 'utf8')) + + (await readFile(f.proposalsPath, 'utf8')) + expect(raw).not.toContain('SHOULD_NOT_SURVIVE') + expect(raw).not.toContain('123456789') + expect(raw).not.toMatch(/gh[pousr]_/) + expect(raw).not.toMatch(/github_pat_/) + expect(raw).not.toMatch(/bot\d+:/) + }) +}) diff --git a/scripts/telegram-source-code-edit-lane.mjs b/scripts/telegram-source-code-edit-lane.mjs new file mode 100644 index 000000000..44b5fae1e --- /dev/null +++ b/scripts/telegram-source-code-edit-lane.mjs @@ -0,0 +1,423 @@ +#!/usr/bin/env node +import { createHash } from 'node:crypto' +import { exec } from 'node:child_process' +import { appendFile, mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { promisify } from 'node:util' +import { formatHumanTelegramMessage } from './telegram-message-format.mjs' +import { defaultTelegramEnvFile, resolveTelegramCredentials } from './telegram-discovery-notification.mjs' + +const execAsync = promisify(exec) + +export const DEFAULT_APPROVAL_PACKETS_PATH = '/root/.hermes/reports/github-discovery/telegram-code-edit-approval-packets.jsonl' +export const DEFAULT_APPROVAL_DECISIONS_PATH = '/root/.hermes/reports/github-discovery/telegram-approval-decisions.jsonl' +export const DEFAULT_EXECUTION_RESULTS_PATH = '/root/.hermes/reports/github-discovery/telegram-source-code-edit-execution-results.jsonl' +export const DEFAULT_COMPLETION_NOTIFICATIONS_PATH = '/root/.hermes/reports/github-discovery/telegram-source-code-edit-completion-notifications.jsonl' +export const DEFAULT_TARGET_WORKSPACE = '/root/hermes-workspace' + +export const SOURCE_EDIT_SIDE_EFFECT_FLAGS = Object.freeze({ + githubCalls: false, + githubWrites: false, + branch: false, + pr: false, + merge: false, + delete: false, + dependencyInstall: false, + obsidianKanbanWrites: false, + staged: false, + committed: false, + auditAppend: false, + durableMutation: false, +}) + +const PACKAGE_FILES = new Set(['package.json', 'package-lock.json', 'pnpm-lock.yaml']) +const SECRET_PATTERNS = [ + /gh[pousr]_[A-Za-z0-9_]{20,}/gi, + /github_pat_[A-Za-z0-9_]{20,}/gi, + /bot\d+:[A-Za-z0-9_-]+/gi, + /(["']?(?:token|secret|password|api[_-]?key|client[_-]?secret|chat[_-]?id)["']?\s*[:=]\s*)["']?[^\s,'"}]+["']?/gi, +] + +function parseArgs(argv) { + const args = { allowedFile: [], approvedTestCommand: [] } + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i] + if (arg === '--json') args.json = true + else if (arg === '--dry-run') args.dryRun = true + else if (arg === '--approval-id') args.approvalId = argv[++i] + else if (arg === '--target-workspace') args.targetWorkspace = argv[++i] + else if (arg === '--allowed-file') args.allowedFile.push(argv[++i]) + else if (arg === '--approved-test-command') args.approvedTestCommand.push(argv[++i]) + else if (arg === '--edit-manifest') args.editManifestPath = argv[++i] + else if (arg === '--approval-packets-path') args.approvalPacketsPath = argv[++i] + else if (arg === '--approval-decisions-path') args.approvalDecisionsPath = argv[++i] + else if (arg === '--execution-results-path') args.executionResultsPath = argv[++i] + else if (arg === '--completion-notifications-path') args.completionNotificationsPath = argv[++i] + else if (arg === '--env-file') args.envFile = argv[++i] + else if (arg === '--bot-token') args.botToken = argv[++i] + else if (arg === '--chat-id') args.chatId = argv[++i] + else if (arg === '--mock-send') args.mockSend = true + else if (arg === '--now') args.now = argv[++i] + } + return args +} + +function requireJson(json) { + if (!json) throw new Error('telegram-source-code-edit-lane is intentionally JSON-only. Pass --json.') +} + +export function sanitizeText(value, cap = 12000) { + let text = String(value ?? '').replace(/\u0000/g, '') + for (const pattern of SECRET_PATTERNS) { + text = text.replace(pattern, (...match) => (match[1] ? `${match[1]}[REDACTED]` : '[REDACTED]')) + } + return text.slice(0, cap) +} + +async function readJsonl(pathname) { + try { + return (await readFile(pathname, 'utf8')) + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line)) + } catch (error) { + if (error?.code === 'ENOENT') return [] + throw error + } +} + +async function readJson(pathname) { + return JSON.parse(await readFile(pathname, 'utf8')) +} + +async function appendJsonl(pathname, row) { + await mkdir(path.dirname(pathname), { recursive: true }) + await appendFile(pathname, `${JSON.stringify(row)}\n`, 'utf8') +} + +async function exists(pathname) { + try { + await stat(pathname) + return true + } catch (error) { + if (error?.code === 'ENOENT') return false + throw error + } +} + +function normalizeWorkspace(workspace) { + return path.resolve(workspace || DEFAULT_TARGET_WORKSPACE) +} + +export function normalizeExactFileList(files) { + if (!Array.isArray(files) || files.length === 0) return null + const normalized = files.map((file) => String(file || '').trim()).filter(Boolean) + if (normalized.length !== files.length || normalized.length === 0) return null + const seen = new Set() + for (const file of normalized) { + const parts = file.split('/').filter(Boolean) + const unsafe = + path.isAbsolute(file) || + file.includes('*') || + file.endsWith('/') || + file === '.' || + file === '..' || + parts.includes('..') || + parts.includes('.git') || + parts.includes('node_modules') || + /(^|\/)(\.env|\.ssh)(\/|$)|token|secret|password|credential/i.test(file) + if (unsafe) return null + if (seen.has(file)) return null + seen.add(file) + } + return normalized +} + +function sameOrderedList(left, right) { + return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((item, index) => item === right[index]) +} + +function commandAllowed(command, approvedCommands) { + if (!approvedCommands.includes(command)) return false + const forbidden = /(^|\s)(gh|git\s+(add|commit|push|branch|checkout|switch|merge|reset|rm)|npm\s+install|pnpm\s+(install|add|remove)|yarn\s+(install|add|remove)|bun\s+(install|add|remove)|rm\s+-rf)\b/i + return !forbidden.test(command) +} + +function hashObject(value) { + return createHash('sha256').update(JSON.stringify(value, Object.keys(value).sort())).digest('hex') +} + +function findPacket(rows, approvalId) { + return rows.find((row) => row?.approval_id === approvalId || row?.code_edit_approval_id === approvalId || row?.short_alias === approvalId) || null +} + +function findDecision(rows, approvalId, packet) { + const aliases = new Set([approvalId, packet?.approval_id, packet?.code_edit_approval_id, packet?.short_alias].filter(Boolean)) + return rows.find((row) => aliases.has(row?.approval_id) && String(row?.decision || row?.status || '').toLowerCase() === 'approved') || null +} + +function block(reason, extra = {}) { + return { + ok: false, + blocked: true, + reason, + dryRun: extra.dryRun === true, + filesEdited: [], + testsRun: [], + executionResultWritten: false, + telegramSent: false, + completionNotificationWritten: false, + workerExecuted: false, + ...SOURCE_EDIT_SIDE_EFFECT_FLAGS, + ...extra, + } +} + +function validatePacketGates(packet) { + if (packet?.implementation_proposal_approved !== true) return 'IMPLEMENTATION_PROPOSAL_NOT_APPROVED' + if (packet?.build_plan_approved !== true) return 'BUILD_PLAN_NOT_APPROVED' + if (packet?.exact_source_file_scope_approved !== true) return 'EXACT_SOURCE_FILE_SCOPE_NOT_APPROVED' + return null +} + +function manifestFiles(manifest) { + return Array.isArray(manifest?.files) ? manifest.files : [] +} + +function validateManifest(manifest, allowedFiles) { + const files = manifestFiles(manifest) + if (!files.length) return 'EDIT_MANIFEST_EMPTY' + const editPaths = normalizeExactFileList(files.map((file) => file.path)) + if (!editPaths) return 'EDIT_MANIFEST_HAS_UNSAFE_PATHS' + if (editPaths.some((file) => PACKAGE_FILES.has(file))) return 'PACKAGE_FILE_EDIT_REQUIRES_SEPARATE_APPROVAL_LANE' + if (!sameOrderedList(editPaths, allowedFiles)) return 'EDIT_MANIFEST_DOES_NOT_MATCH_ALLOWED_FILES' + for (const file of files) { + if (typeof file.content !== 'string') return 'EDIT_MANIFEST_FILE_CONTENT_REQUIRED' + } + return null +} + +async function readEnvFile(envFile) { + try { + const parsed = {} + for (const line of (await readFile(envFile, 'utf8')).split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const index = trimmed.indexOf('=') + if (index === -1) continue + parsed[trimmed.slice(0, index).trim()] = trimmed.slice(index + 1).trim().replace(/^['"]|['"]$/g, '') + } + return parsed + } catch (error) { + if (error?.code === 'ENOENT') return {} + throw error + } +} + +async function sendTelegramCompletion({ credentials, message, mockSend, fetchImpl = globalThis.fetch }) { + if (!credentials.botToken || !credentials.chatId) return { ok: false, reason: 'MISSING_TELEGRAM_CREDENTIALS' } + if (mockSend) return { ok: true, mocked: true, messageId: 'mock-completion-message-id' } + if (typeof fetchImpl !== 'function') return { ok: false, reason: 'FETCH_UNAVAILABLE' } + const response = await fetchImpl(`https://api.telegram.org/bot${credentials.botToken}/sendMessage`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ chat_id: credentials.chatId, text: message, disable_web_page_preview: true }), + }) + let body = null + try { body = await response.json() } catch {} + if (!response.ok || body?.ok === false) return { ok: false, reason: `TELEGRAM_SEND_FAILED_${response.status}` } + return { ok: true, messageId: body?.result?.message_id ?? null } +} + +async function runApprovedTests(commands, workspace) { + const results = [] + for (const command of commands) { + const { stdout, stderr } = await execAsync(command, { cwd: workspace, timeout: 120000, maxBuffer: 1024 * 1024 }) + results.push({ command, exit_code: 0, stdout: sanitizeText(stdout, 4000), stderr: sanitizeText(stderr, 4000) }) + } + return results +} + +export async function runTelegramSourceCodeEditLane({ + approvalId, + targetWorkspace = DEFAULT_TARGET_WORKSPACE, + allowedFile = [], + approvedTestCommand = [], + editManifestPath, + approvalPacketsPath = DEFAULT_APPROVAL_PACKETS_PATH, + approvalDecisionsPath = DEFAULT_APPROVAL_DECISIONS_PATH, + executionResultsPath = DEFAULT_EXECUTION_RESULTS_PATH, + completionNotificationsPath = DEFAULT_COMPLETION_NOTIFICATIONS_PATH, + dryRun = false, + env = process.env, + envFile = defaultTelegramEnvFile(), + botToken, + chatId, + mockSend = false, + fetchImpl = globalThis.fetch, + now = new Date(), +} = {}) { + if (!approvalId) return block('APPROVAL_ID_REQUIRED', { dryRun }) + if (!editManifestPath) return block('EDIT_MANIFEST_REQUIRED', { dryRun }) + const workspace = normalizeWorkspace(targetWorkspace) + const exactAllowedFiles = normalizeExactFileList(allowedFile) + if (!exactAllowedFiles) return block('EXACT_ALLOWED_FILE_LIST_REQUIRED', { dryRun }) + if (exactAllowedFiles.some((file) => PACKAGE_FILES.has(file))) { + return block('PACKAGE_FILE_EDIT_REQUIRES_SEPARATE_APPROVAL_LANE', { dryRun, approval_id: approvalId, package_files_blocked: exactAllowedFiles.filter((file) => PACKAGE_FILES.has(file)) }) + } + + const packets = await readJsonl(approvalPacketsPath) + const packet = findPacket(packets, approvalId) + if (!packet) return block('CODE_EDIT_APPROVAL_PACKET_NOT_FOUND', { dryRun, approval_id: approvalId }) + const gateReason = validatePacketGates(packet) + if (gateReason) return block(gateReason, { dryRun, approval_id: approvalId }) + const packetFiles = normalizeExactFileList(packet.exact_files_allowed_to_edit) + if (!sameOrderedList(packetFiles, exactAllowedFiles)) return block('ALLOWED_FILE_LIST_MISMATCH', { dryRun, approval_id: approvalId, packet_files: packetFiles, allowed_files: exactAllowedFiles }) + + const decisions = await readJsonl(approvalDecisionsPath) + const decision = findDecision(decisions, approvalId, packet) + if (!decision) return block('TELEGRAM_APPROVAL_REQUIRED', { dryRun, approval_id: approvalId }) + + const previousResults = await readJsonl(executionResultsPath) + if (previousResults.some((row) => row.approval_id === (packet.approval_id || packet.code_edit_approval_id))) { + return block('REPLAY_BLOCKED_PREVIOUS_EXECUTION_RESULT', { dryRun, approval_id: approvalId, replayBlocked: true }) + } + + const manifest = await readJson(editManifestPath) + const manifestReason = validateManifest(manifest, exactAllowedFiles) + if (manifestReason) return block(manifestReason, { dryRun, approval_id: approvalId }) + + const packetTests = Array.isArray(packet.tests_to_run) ? packet.tests_to_run.map(String) : [] + const manifestTests = Array.isArray(manifest.test_commands) ? manifest.test_commands.map(String) : [] + const approvedTests = approvedTestCommand.map(String) + if (!sameOrderedList(manifestTests, approvedTests) || !sameOrderedList(packetTests, approvedTests)) { + return block('APPROVED_TEST_COMMANDS_REQUIRED', { dryRun, approval_id: approvalId, packet_tests: packetTests, approved_test_commands: approvedTests }) + } + if (!approvedTests.every((command) => commandAllowed(command, approvedTests))) { + return block('UNAPPROVED_OR_FORBIDDEN_TEST_COMMAND', { dryRun, approval_id: approvalId }) + } + + const plan = { + ok: true, + dryRun, + approval_id: packet.approval_id || packet.code_edit_approval_id, + short_alias: packet.short_alias || null, + target_workspace: workspace, + exact_allowed_files: exactAllowedFiles, + approved_test_commands: approvedTests, + filesEdited: [], + testsRun: [], + executionResultWritten: false, + telegramSent: false, + completionNotificationWritten: false, + replayBlocked: false, + workerExecuted: false, + ...SOURCE_EDIT_SIDE_EFFECT_FLAGS, + } + if (dryRun) return { ...plan, mode: 'dry-run', note: 'Dry-run only. No source files, execution results, Telegram, GitHub, audit, durable store, Obsidian, Kanban, staging, or commits touched.' } + + for (const file of manifestFiles(manifest)) { + const target = path.resolve(workspace, file.path) + if (!target.startsWith(`${workspace}${path.sep}`)) return block('TARGET_FILE_ESCAPES_WORKSPACE', { approval_id: approvalId }) + await mkdir(path.dirname(target), { recursive: true }) + await writeFile(target, file.content, 'utf8') + } + const testsRun = await runApprovedTests(approvedTests, workspace) + const resultId = `source_edit_${createHash('sha256').update(`${packet.approval_id || approvalId}\n${now.toISOString()}`).digest('hex').slice(0, 16)}` + const executionResult = { + execution_result_id: resultId, + approval_id: packet.approval_id || packet.code_edit_approval_id, + short_alias: packet.short_alias || null, + status: 'completed', + completed_at: new Date(now).toISOString(), + target_workspace: workspace, + files_edited: exactAllowedFiles, + tests_run: testsRun.map((test) => ({ command: test.command, exit_code: test.exit_code })), + replay_blocked: true, + telegram_approval_received: true, + implementation_proposal_approved: true, + build_plan_approved: true, + exact_source_file_scope_approved: true, + ...SOURCE_EDIT_SIDE_EFFECT_FLAGS, + } + await appendJsonl(executionResultsPath, executionResult) + + const message = formatHumanTelegramMessage({ + type: 'completion', + execution_result_id: resultId, + approval_id: executionResult.approval_id, + alias: packet.short_alias || null, + status: 'Source code edit completed', + recommendation: 'Review edited files. Staging/commit still needs separate explicit approval.', + reply: 'Reply with exact next instruction if you want review, staging, or commit.', + }) + const fileEnv = await readEnvFile(envFile) + const credentials = resolveTelegramCredentials({ ...fileEnv, ...env }, { botToken, chatId }) + const sent = await sendTelegramCompletion({ credentials, message, mockSend, fetchImpl }) + if (!sent.ok) return block(sent.reason, { approval_id: approvalId, filesEdited: exactAllowedFiles, testsRun, executionResultWritten: true }) + const notificationRow = { + sent: true, + sent_at: new Date(now).toISOString(), + execution_result_id: resultId, + execution_result_hash: hashObject(executionResult), + approval_id: executionResult.approval_id, + approval_alias: packet.short_alias || null, + files_edited: exactAllowedFiles, + result_status: 'completed', + replay_blocked: true, + telegram_message_id: sent.messageId, + telegram_chat: credentials.redacted.chatId, + telegram_token: credentials.redacted.botToken, + ...SOURCE_EDIT_SIDE_EFFECT_FLAGS, + } + await appendJsonl(completionNotificationsPath, notificationRow) + + return { + ...plan, + mode: 'execute', + filesEdited: exactAllowedFiles, + testsRun, + executionResultWritten: true, + execution_result: executionResult, + execution_results_path: executionResultsPath, + telegramSent: true, + telegram_message_text: message, + telegram_message_id: sent.messageId, + completionNotificationWritten: true, + completion_notifications_path: completionNotificationsPath, + mocked: sent.mocked === true, + note: 'Approved exact-scope source edit completed. No staging, commit, GitHub write, dependency install, Obsidian, Kanban, audit, or durable-store mutation was performed.', + } +} + +export async function runTelegramSourceCodeEditLaneCli(argv = process.argv.slice(2)) { + const args = parseArgs(argv) + requireJson(args.json) + const result = await runTelegramSourceCodeEditLane({ + approvalId: args.approvalId, + targetWorkspace: args.targetWorkspace, + allowedFile: args.allowedFile, + approvedTestCommand: args.approvedTestCommand, + editManifestPath: args.editManifestPath, + approvalPacketsPath: args.approvalPacketsPath, + approvalDecisionsPath: args.approvalDecisionsPath, + executionResultsPath: args.executionResultsPath, + completionNotificationsPath: args.completionNotificationsPath, + dryRun: args.dryRun === true, + envFile: args.envFile, + botToken: args.botToken, + chatId: args.chatId, + mockSend: args.mockSend === true, + now: args.now ? new Date(args.now) : new Date(), + }) + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`) + process.exitCode = result.ok ? 0 : 1 +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + runTelegramSourceCodeEditLaneCli().catch((error) => { + process.stderr.write(`${error.stack || error.message}\n`) + process.exitCode = 1 + }) +} diff --git a/scripts/telegram-source-code-edit-lane.test.mjs b/scripts/telegram-source-code-edit-lane.test.mjs new file mode 100644 index 000000000..ce29a8ff4 --- /dev/null +++ b/scripts/telegram-source-code-edit-lane.test.mjs @@ -0,0 +1,177 @@ +import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { runTelegramSourceCodeEditLane } from './telegram-source-code-edit-lane.mjs' + +const APPROVAL_ID = 'source_edit_tg15_approved' +const ALLOWED = ['src/example.ts'] +const TESTS = ['node -e "process.exit(0)"'] + +async function exists(pathname) { + try { + await stat(pathname) + return true + } catch (error) { + if (error?.code === 'ENOENT') return false + throw error + } +} + +async function fixture({ packet = {}, decision = {}, manifest = {}, previousResult = null } = {}) { + const dir = await mkdtemp(path.join(tmpdir(), 'tg-source-edit-')) + const workspace = path.join(dir, 'workspace') + const packetsPath = path.join(dir, 'telegram-code-edit-approval-packets.jsonl') + const decisionsPath = path.join(dir, 'telegram-approval-decisions.jsonl') + const resultsPath = path.join(dir, 'telegram-source-code-edit-execution-results.jsonl') + const notificationsPath = path.join(dir, 'telegram-source-code-edit-completion-notifications.jsonl') + const manifestPath = path.join(dir, 'edit-manifest.json') + const basePacket = { + approval_id: APPROVAL_ID, + code_edit_approval_id: APPROVAL_ID, + short_alias: 'tg15', + target_workspace: workspace, + implementation_proposal_approved: true, + build_plan_approved: true, + exact_source_file_scope_approved: true, + exact_files_allowed_to_edit: ALLOWED, + tests_to_run: TESTS, + ...packet, + } + const baseDecision = { approval_id: APPROVAL_ID, decision: 'approved', ...decision } + const baseManifest = { + files: [{ path: 'src/example.ts', content: 'export const tg15 = true\n' }], + test_commands: TESTS, + ...manifest, + } + await writeFile(packetsPath, `${JSON.stringify(basePacket)}\n`, 'utf8') + await writeFile(decisionsPath, `${JSON.stringify(baseDecision)}\n`, 'utf8') + await writeFile(manifestPath, JSON.stringify(baseManifest), 'utf8') + if (previousResult) await writeFile(resultsPath, `${JSON.stringify(previousResult)}\n`, 'utf8') + return { dir, workspace, packetsPath, decisionsPath, resultsPath, notificationsPath, manifestPath } +} + +function runArgs(f, overrides = {}) { + return { + approvalId: APPROVAL_ID, + targetWorkspace: f.workspace, + allowedFile: ALLOWED, + approvedTestCommand: TESTS, + editManifestPath: f.manifestPath, + approvalPacketsPath: f.packetsPath, + approvalDecisionsPath: f.decisionsPath, + executionResultsPath: f.resultsPath, + completionNotificationsPath: f.notificationsPath, + env: { TELEGRAM_BOT_TOKEN: 'bot123:ABC', TELEGRAM_CHAT_ID: '12345' }, + mockSend: true, + now: new Date('2026-07-05T12:00:00Z'), + ...overrides, + } +} + +describe('telegram source-code edit approval lane', () => { + it('dry-run validates exact approval chain and writes nothing', async () => { + const f = await fixture() + const result = await runTelegramSourceCodeEditLane(runArgs(f, { dryRun: true })) + expect(result.ok).toBe(true) + expect(result.mode).toBe('dry-run') + expect(result.exact_allowed_files).toEqual(ALLOWED) + expect(result.executionResultWritten).toBe(false) + expect(result.telegramSent).toBe(false) + expect(result.githubWrites).toBe(false) + expect(result.staged).toBe(false) + expect(await exists(path.join(f.workspace, 'src/example.ts'))).toBe(false) + expect(await exists(f.resultsPath)).toBe(false) + expect(await exists(f.notificationsPath)).toBe(false) + }) + + it('requires exact allowed file list and rejects broad directories', async () => { + const f = await fixture() + const result = await runTelegramSourceCodeEditLane(runArgs(f, { allowedFile: ['src/'] })) + expect(result.ok).toBe(false) + expect(result.reason).toBe('EXACT_ALLOWED_FILE_LIST_REQUIRED') + expect(await exists(f.resultsPath)).toBe(false) + }) + + it('hard-blocks package files even when explicitly listed by packet and invocation', async () => { + for (const packageFile of ['package.json', 'package-lock.json', 'pnpm-lock.yaml']) { + const f = await fixture({ + packet: { exact_files_allowed_to_edit: [packageFile] }, + manifest: { files: [{ path: packageFile, content: '{}' }], test_commands: TESTS }, + }) + const result = await runTelegramSourceCodeEditLane(runArgs(f, { allowedFile: [packageFile] })) + expect(result.ok).toBe(false) + expect(result.reason).toBe('PACKAGE_FILE_EDIT_REQUIRES_SEPARATE_APPROVAL_LANE') + expect(result.package_files_blocked).toEqual([packageFile]) + expect(await exists(path.join(f.workspace, packageFile))).toBe(false) + expect(await exists(f.resultsPath)).toBe(false) + expect(result.githubCalls).toBe(false) + expect(result.dependencyInstall).toBe(false) + expect(result.auditAppend).toBe(false) + expect(result.durableMutation).toBe(false) + } + }) + + it('hard-blocks package files from edit manifest even when allowed scope is different', async () => { + const f = await fixture({ manifest: { files: [{ path: 'package.json', content: '{}' }], test_commands: TESTS } }) + const result = await runTelegramSourceCodeEditLane(runArgs(f)) + expect(result.ok).toBe(false) + expect(result.reason).toBe('PACKAGE_FILE_EDIT_REQUIRES_SEPARATE_APPROVAL_LANE') + expect(await exists(path.join(f.workspace, 'package.json'))).toBe(false) + expect(await exists(f.resultsPath)).toBe(false) + }) + + it('rejects unapproved test commands and dependency installs', async () => { + const forbidden = ['pnpm install'] + const f = await fixture({ packet: { tests_to_run: forbidden }, manifest: { test_commands: forbidden } }) + const result = await runTelegramSourceCodeEditLane(runArgs(f, { approvedTestCommand: forbidden })) + expect(result.ok).toBe(false) + expect(result.reason).toBe('UNAPPROVED_OR_FORBIDDEN_TEST_COMMAND') + expect(await exists(f.resultsPath)).toBe(false) + }) + + it('executes approved exact-scope edit, approved tests, result write, and Telegram completion only', async () => { + const f = await fixture() + const result = await runTelegramSourceCodeEditLane(runArgs(f)) + expect(result.ok).toBe(true) + expect(result.filesEdited).toEqual(ALLOWED) + expect(result.testsRun).toHaveLength(1) + expect(result.executionResultWritten).toBe(true) + expect(result.telegramSent).toBe(true) + expect(result.completionNotificationWritten).toBe(true) + expect(result.githubCalls).toBe(false) + expect(result.githubWrites).toBe(false) + expect(result.dependencyInstall).toBe(false) + expect(result.obsidianKanbanWrites).toBe(false) + expect(result.staged).toBe(false) + expect(result.committed).toBe(false) + expect(await readFile(path.join(f.workspace, 'src/example.ts'), 'utf8')).toBe('export const tg15 = true\n') + const rows = (await readFile(f.resultsPath, 'utf8')).trim().split(/\r?\n/).map((line) => JSON.parse(line)) + expect(rows).toHaveLength(1) + expect(rows[0].files_edited).toEqual(ALLOWED) + const notifications = (await readFile(f.notificationsPath, 'utf8')).trim().split(/\r?\n/).map((line) => JSON.parse(line)) + expect(notifications).toHaveLength(1) + expect(JSON.stringify(notifications[0])).not.toContain('bot123:ABC') + expect(JSON.stringify(notifications[0])).not.toContain('12345') + }) + + it('blocks replay before edits, tests, result writes, or Telegram sends', async () => { + const f = await fixture({ previousResult: { approval_id: APPROVAL_ID, execution_result_id: 'old' } }) + const result = await runTelegramSourceCodeEditLane(runArgs(f)) + expect(result.ok).toBe(false) + expect(result.reason).toBe('REPLAY_BLOCKED_PREVIOUS_EXECUTION_RESULT') + expect(result.telegramSent).toBe(false) + expect(await exists(path.join(f.workspace, 'src/example.ts'))).toBe(false) + }) + + it('requires implementation proposal, build plan, exact scope, and Telegram approval', async () => { + const missingProposal = await fixture({ packet: { implementation_proposal_approved: false } }) + expect((await runTelegramSourceCodeEditLane(runArgs(missingProposal))).reason).toBe('IMPLEMENTATION_PROPOSAL_NOT_APPROVED') + const missingBuild = await fixture({ packet: { build_plan_approved: false } }) + expect((await runTelegramSourceCodeEditLane(runArgs(missingBuild))).reason).toBe('BUILD_PLAN_NOT_APPROVED') + const missingScope = await fixture({ packet: { exact_source_file_scope_approved: false } }) + expect((await runTelegramSourceCodeEditLane(runArgs(missingScope))).reason).toBe('EXACT_SOURCE_FILE_SCOPE_NOT_APPROVED') + const missingTelegram = await fixture({ decision: { decision: 'rejected' } }) + expect((await runTelegramSourceCodeEditLane(runArgs(missingTelegram))).reason).toBe('TELEGRAM_APPROVAL_REQUIRED') + }) +}) diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 63135809c..fb10c9ffc 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -74,6 +74,7 @@ import { Route as ApiSessionSendRouteImport } from './routes/api/session-send' import { Route as ApiSessionHistoryRouteImport } from './routes/api/session-history' import { Route as ApiSendStreamRouteImport } from './routes/api/send-stream' import { Route as ApiSendRouteImport } from './routes/api/send' +import { Route as ApiReadonlyWorkerStatusRouteImport } from './routes/api/readonly-worker-status' import { Route as ApiProviderUsageRouteImport } from './routes/api/provider-usage' import { Route as ApiPreviewFileRouteImport } from './routes/api/preview-file' import { Route as ApiPluginsRouteImport } from './routes/api/plugins' @@ -496,6 +497,11 @@ const ApiSendRoute = ApiSendRouteImport.update({ path: '/api/send', getParentRoute: () => rootRouteImport, } as any) +const ApiReadonlyWorkerStatusRoute = ApiReadonlyWorkerStatusRouteImport.update({ + id: '/api/readonly-worker-status', + path: '/api/readonly-worker-status', + getParentRoute: () => rootRouteImport, +} as any) const ApiProviderUsageRoute = ApiProviderUsageRouteImport.update({ id: '/api/provider-usage', path: '/api/provider-usage', @@ -1042,6 +1048,7 @@ export interface FileRoutesByFullPath { '/api/plugins': typeof ApiPluginsRoute '/api/preview-file': typeof ApiPreviewFileRoute '/api/provider-usage': typeof ApiProviderUsageRoute + '/api/readonly-worker-status': typeof ApiReadonlyWorkerStatusRoute '/api/send': typeof ApiSendRoute '/api/send-stream': typeof ApiSendStreamRoute '/api/session-history': typeof ApiSessionHistoryRoute @@ -1203,6 +1210,7 @@ export interface FileRoutesByTo { '/api/plugins': typeof ApiPluginsRoute '/api/preview-file': typeof ApiPreviewFileRoute '/api/provider-usage': typeof ApiProviderUsageRoute + '/api/readonly-worker-status': typeof ApiReadonlyWorkerStatusRoute '/api/send': typeof ApiSendRoute '/api/send-stream': typeof ApiSendStreamRoute '/api/session-history': typeof ApiSessionHistoryRoute @@ -1366,6 +1374,7 @@ export interface FileRoutesById { '/api/plugins': typeof ApiPluginsRoute '/api/preview-file': typeof ApiPreviewFileRoute '/api/provider-usage': typeof ApiProviderUsageRoute + '/api/readonly-worker-status': typeof ApiReadonlyWorkerStatusRoute '/api/send': typeof ApiSendRoute '/api/send-stream': typeof ApiSendStreamRoute '/api/session-history': typeof ApiSessionHistoryRoute @@ -1530,6 +1539,7 @@ export interface FileRouteTypes { | '/api/plugins' | '/api/preview-file' | '/api/provider-usage' + | '/api/readonly-worker-status' | '/api/send' | '/api/send-stream' | '/api/session-history' @@ -1691,6 +1701,7 @@ export interface FileRouteTypes { | '/api/plugins' | '/api/preview-file' | '/api/provider-usage' + | '/api/readonly-worker-status' | '/api/send' | '/api/send-stream' | '/api/session-history' @@ -1853,6 +1864,7 @@ export interface FileRouteTypes { | '/api/plugins' | '/api/preview-file' | '/api/provider-usage' + | '/api/readonly-worker-status' | '/api/send' | '/api/send-stream' | '/api/session-history' @@ -2016,6 +2028,7 @@ export interface RootRouteChildren { ApiPluginsRoute: typeof ApiPluginsRoute ApiPreviewFileRoute: typeof ApiPreviewFileRoute ApiProviderUsageRoute: typeof ApiProviderUsageRoute + ApiReadonlyWorkerStatusRoute: typeof ApiReadonlyWorkerStatusRoute ApiSendRoute: typeof ApiSendRoute ApiSendStreamRoute: typeof ApiSendStreamRoute ApiSessionHistoryRoute: typeof ApiSessionHistoryRoute @@ -2542,6 +2555,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiSendRouteImport parentRoute: typeof rootRouteImport } + '/api/readonly-worker-status': { + id: '/api/readonly-worker-status' + path: '/api/readonly-worker-status' + fullPath: '/api/readonly-worker-status' + preLoaderRoute: typeof ApiReadonlyWorkerStatusRouteImport + parentRoute: typeof rootRouteImport + } '/api/provider-usage': { id: '/api/provider-usage' path: '/api/provider-usage' @@ -3485,6 +3505,7 @@ const rootRouteChildren: RootRouteChildren = { ApiPluginsRoute: ApiPluginsRoute, ApiPreviewFileRoute: ApiPreviewFileRoute, ApiProviderUsageRoute: ApiProviderUsageRoute, + ApiReadonlyWorkerStatusRoute: ApiReadonlyWorkerStatusRoute, ApiSendRoute: ApiSendRoute, ApiSendStreamRoute: ApiSendStreamRoute, ApiSessionHistoryRoute: ApiSessionHistoryRoute, diff --git a/src/routes/api/-readonly-worker-status.test.ts b/src/routes/api/-readonly-worker-status.test.ts new file mode 100644 index 000000000..106122adc --- /dev/null +++ b/src/routes/api/-readonly-worker-status.test.ts @@ -0,0 +1,245 @@ +import { readFile } from 'node:fs/promises' +import { afterEach, describe, expect, it, vi } from 'vitest' + +type FakeStats = { + isSymbolicLink: () => boolean + isFile: () => boolean +} + +type FakeFs = { + lstat: ReturnType + readFile: ReturnType +} + +function stats({ symlink = false, file = true } = {}): FakeStats { + return { + isSymbolicLink: () => symlink, + isFile: () => file, + } +} + +function fakeFs(raw: string, fakeStats: FakeStats = stats()): FakeFs { + return { + lstat: vi.fn().mockResolvedValue(fakeStats), + readFile: vi.fn().mockResolvedValue(raw), + } +} + +async function responseJson(response: Response): Promise> { + return (await response.json()) as Record +} + +afterEach(() => { + delete process.env.HERMES_PASSWORD + delete process.env.CLAUDE_PASSWORD + vi.restoreAllMocks() +}) + +describe('/api/readonly-worker-status hardening', () => { + it('returns 401 for unauthenticated requests', async () => { + process.env.HERMES_PASSWORD = 'required' + const { handleReadonlyWorkerStatusGet } = await import('./readonly-worker-status') + const fs = fakeFs('{"status":"ok"}') + + const response = await handleReadonlyWorkerStatusGet( + new Request('http://localhost/api/readonly-worker-status'), + fs, + ) + + expect(response.status).toBe(401) + await expect(responseJson(response)).resolves.toEqual({ + ok: false, + error: 'Unauthorized', + }) + expect(fs.lstat).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + }) + + it('missing status file returns safe unavailable DTO', async () => { + const { handleReadonlyWorkerStatusGet } = await import('./readonly-worker-status') + const missing = Object.assign(new Error('missing'), { code: 'ENOENT' }) + const fs: FakeFs = { + lstat: vi.fn().mockRejectedValue(missing), + readFile: vi.fn(), + } + + const response = await handleReadonlyWorkerStatusGet( + new Request('http://localhost/api/readonly-worker-status'), + fs, + ) + const body = await responseJson(response) + + expect(response.status).toBe(404) + expect(body).toMatchObject({ + ok: false, + status: 'unavailable', + message: 'readonly worker status unavailable', + }) + expect(JSON.stringify(body)).not.toMatch(/readonly-worker-status\.json|\/root|\.json/i) + }) + + it('rejects symlink before read', async () => { + const { handleReadonlyWorkerStatusGet } = await import('./readonly-worker-status') + const fs = fakeFs('{"status":"ok"}', stats({ symlink: true, file: true })) + + const response = await handleReadonlyWorkerStatusGet( + new Request('http://localhost/api/readonly-worker-status'), + fs, + ) + const body = await responseJson(response) + + expect(response.status).toBe(409) + expect(fs.readFile).not.toHaveBeenCalled() + expect(body).toMatchObject({ + ok: false, + status: 'error', + message: 'readonly worker status error', + }) + }) + + it('rejects non-regular file as unavailable before read', async () => { + const { handleReadonlyWorkerStatusGet } = await import('./readonly-worker-status') + const fs = fakeFs('{"status":"ok"}', stats({ symlink: false, file: false })) + + const response = await handleReadonlyWorkerStatusGet( + new Request('http://localhost/api/readonly-worker-status'), + fs, + ) + const body = await responseJson(response) + + expect(response.status).toBe(404) + expect(fs.readFile).not.toHaveBeenCalled() + expect(body).toMatchObject({ + ok: false, + status: 'unavailable', + message: 'readonly worker status unavailable', + }) + }) + + it('handles invalid JSON safely', async () => { + const { handleReadonlyWorkerStatusGet } = await import('./readonly-worker-status') + const fs = fakeFs('{ invalid json') + + const response = await handleReadonlyWorkerStatusGet( + new Request('http://localhost/api/readonly-worker-status'), + fs, + ) + const body = await responseJson(response) + + expect(response.status).toBe(422) + expect(body).toMatchObject({ + ok: false, + status: 'error', + message: 'readonly worker status error', + }) + expect(JSON.stringify(body)).not.toMatch(/invalid json|SyntaxError|readonly-worker-status\.json|\/root/i) + }) + + it('valid JSON returns only whitelisted DTO fields', async () => { + const { handleReadonlyWorkerStatusGet } = await import('./readonly-worker-status') + const fs = fakeFs( + JSON.stringify({ + status: 'ok', + generatedAt: new Date().toISOString(), + workerDecision: 'refused', + reportComplete: true, + mountReadOnly: true, + targetInspected: false, + contentsRead: false, + filesWritten: 0, + queueMetadataUpdated: false, + externalMessagesSent: false, + dispatcherStarted: false, + swarmStarted: false, + lockClean: true, + redactionApplied: true, + journal: ['raw journal'], + path: '/root/private/path', + filename: 'readonly-worker-status.json', + }), + ) + + const response = await handleReadonlyWorkerStatusGet( + new Request('http://localhost/api/readonly-worker-status'), + fs, + ) + const body = await responseJson(response) + + expect(response.status).toBe(200) + expect(Object.keys(body).sort()).toEqual( + [ + 'contentsRead', + 'dispatcherStarted', + 'externalMessagesSent', + 'filesWritten', + 'generatedAt', + 'isStale', + 'lockClean', + 'message', + 'mountReadOnly', + 'ok', + 'queueMetadataUpdated', + 'redactionApplied', + 'reportComplete', + 'stalenessSeconds', + 'status', + 'swarmStarted', + 'targetInspected', + 'workerDecision', + ].sort(), + ) + expect(body).not.toHaveProperty('journal') + expect(body).not.toHaveProperty('path') + expect(body).not.toHaveProperty('filename') + }) + + it('does not return raw source.message or source.summary', async () => { + const { handleReadonlyWorkerStatusGet } = await import('./readonly-worker-status') + const fs = fakeFs( + JSON.stringify({ + status: 'ok', + generatedAt: new Date().toISOString(), + message: 'raw /root/.hermes/runtime/readonly-worker-status.json stack Trace: boom', + summary: 'raw journal child.md queue authority contents', + }), + ) + + const response = await handleReadonlyWorkerStatusGet( + new Request('http://localhost/api/readonly-worker-status'), + fs, + ) + const body = await responseJson(response) + const serialized = JSON.stringify(body) + + expect(body.message).toBe('readonly worker status ok') + expect(serialized).not.toMatch(/\/root|\.json|\.md|journal|Trace|stack|queue authority|readonly-worker-status/i) + }) + + it('does not accept query or path override for the fixed status path', async () => { + const { READONLY_WORKER_STATUS_FILE, handleReadonlyWorkerStatusGet } = await import( + './readonly-worker-status' + ) + const fs = fakeFs('{"status":"ok"}') + + await handleReadonlyWorkerStatusGet( + new Request( + 'http://localhost/api/readonly-worker-status?path=/tmp/evil.json&file=/tmp/other.json', + ), + fs, + ) + + expect(fs.lstat).toHaveBeenCalledWith(READONLY_WORKER_STATUS_FILE) + expect(fs.readFile).toHaveBeenCalledWith(READONLY_WORKER_STATUS_FILE, 'utf8') + expect(READONLY_WORKER_STATUS_FILE).toBe( + '/root/.hermes/runtime/readonly-worker-status.json', + ) + }) + + it('handler source does not trigger worker, extractor, Kanban, Obsidian, queue, or authority mutation modules', async () => { + const source = await readFile('src/routes/api/readonly-worker-status.ts', 'utf8') + + expect(source).not.toMatch(/worker\.(run|start)|extractor\.(run|start)|dispatcher\.(run|start)/i) + expect(source).not.toMatch(/from ['"].*(kanban|obsidian|queue|authority|worker|extractor)/i) + expect(source).not.toMatch(/writeFile|appendFile|unlink|rm\(|rename\(|mkdir/i) + }) +}) diff --git a/src/routes/api/readonly-worker-status.ts b/src/routes/api/readonly-worker-status.ts new file mode 100644 index 000000000..766ab996a --- /dev/null +++ b/src/routes/api/readonly-worker-status.ts @@ -0,0 +1,230 @@ +import fs from 'node:fs/promises' +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { isAuthenticated } from '../../server/auth-middleware' + +export const READONLY_WORKER_STATUS_FILE = '/root/.hermes/runtime/readonly-worker-status.json' +const STALE_AFTER_SECONDS = 10 * 60 + +type ReadonlyWorkerStatusFs = { + lstat: (path: string) => Promise<{ + isSymbolicLink: () => boolean + isFile: () => boolean + }> + readFile: (path: string, encoding: 'utf8') => Promise +} + +type ReadonlyWorkerStatusDto = { + ok: boolean + status: string + generatedAt: string | null + workerDecision: string | null + reportComplete: boolean | null + mountReadOnly: boolean | null + targetInspected: boolean | null + contentsRead: boolean | null + filesWritten: number | null + queueMetadataUpdated: boolean | null + externalMessagesSent: boolean | null + dispatcherStarted: boolean | null + swarmStarted: boolean | null + lockClean: boolean | null + redactionApplied: boolean | null + stalenessSeconds: number | null + isStale: boolean + message: string +} + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {} +} + +function readString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null +} + +function readBool(value: unknown): boolean | null { + return typeof value === 'boolean' ? value : null +} + +function readNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function firstString(source: Record, keys: Array): string | null { + for (const key of keys) { + const value = readString(source[key]) + if (value !== null) return value + } + return null +} + +function firstBool(source: Record, keys: Array): boolean | null { + for (const key of keys) { + const value = readBool(source[key]) + if (value !== null) return value + } + return null +} + +function firstNumber(source: Record, keys: Array): number | null { + for (const key of keys) { + const value = readNumber(source[key]) + if (value !== null) return value + } + return null +} + +function ageSeconds(generatedAt: string | null): number | null { + if (!generatedAt) return null + const generatedMs = Date.parse(generatedAt) + if (!Number.isFinite(generatedMs)) return null + return Math.max(0, Math.floor((Date.now() - generatedMs) / 1000)) +} + +function lockCleanFrom(source: Record): boolean | null { + const direct = firstBool(source, ['lockClean', 'lock_clean']) + if (direct !== null) return direct + + const acquired = firstBool(source, ['lock_acquired', 'lockAcquired']) + const released = firstBool(source, ['lock_released', 'lockReleased']) + const releaseStatus = firstString(source, ['lock_release_status', 'lockReleaseStatus']) + if (acquired === null && released === null && releaseStatus === null) return null + return acquired === true && released === true && releaseStatus === 'released_own_lock' +} + +function statusFrom(unsafe: boolean, isStale: boolean): string { + if (unsafe) return 'error' + if (isStale) return 'warning' + return 'ok' +} + +function messageFrom(status: string): string { + if (status === 'ok') return 'readonly worker status ok' + if (status === 'warning') return 'readonly worker status warning' + if (status === 'error') return 'readonly worker status error' + return 'readonly worker status unavailable' +} + +function buildDto(source: Record): ReadonlyWorkerStatusDto { + const generatedAt = firstString(source, [ + 'generatedAt', + 'generated_at', + 'updatedAt', + 'updated_at', + 'run_finished_at', + 'runFinishedAt', + ]) + const stalenessSeconds = ageSeconds(generatedAt) + const isStale = stalenessSeconds === null || stalenessSeconds > STALE_AFTER_SECONDS + const workerDecision = firstString(source, ['workerDecision', 'worker_decision']) + const reportComplete = firstBool(source, ['reportComplete', 'report_complete']) + const contentsRead = firstBool(source, ['contentsRead', 'contents_read']) + const filesWritten = firstNumber(source, ['filesWritten', 'files_written']) + const queueMetadataUpdated = firstBool(source, [ + 'queueMetadataUpdated', + 'queue_metadata_updated', + ]) + const externalMessagesSent = firstBool(source, [ + 'externalMessagesSent', + 'external_messages_sent', + ]) + const dispatcherStarted = firstBool(source, ['dispatcherStarted', 'dispatcher_started']) + const swarmStarted = firstBool(source, ['swarmStarted', 'swarm_started']) + + const unsafe = + contentsRead === true || + (filesWritten !== null && filesWritten > 0) || + queueMetadataUpdated === true || + externalMessagesSent === true || + dispatcherStarted === true || + swarmStarted === true + + const status = statusFrom(unsafe, isStale) + + return { + ok: true, + status, + generatedAt, + workerDecision, + reportComplete, + mountReadOnly: firstBool(source, ['mountReadOnly', 'mount_read_only']), + targetInspected: firstBool(source, ['targetInspected', 'target_inspected']), + contentsRead, + filesWritten, + queueMetadataUpdated, + externalMessagesSent, + dispatcherStarted, + swarmStarted, + lockClean: lockCleanFrom(source), + redactionApplied: firstBool(source, ['redactionApplied', 'redaction_applied']), + stalenessSeconds, + isStale, + message: messageFrom(status), + } +} + +function errorDto(status = 'unavailable'): ReadonlyWorkerStatusDto { + const safeStatus = status === 'error' ? 'error' : 'unavailable' + return { + ok: false, + status: safeStatus, + generatedAt: null, + workerDecision: null, + reportComplete: null, + mountReadOnly: null, + targetInspected: null, + contentsRead: null, + filesWritten: null, + queueMetadataUpdated: null, + externalMessagesSent: null, + dispatcherStarted: null, + swarmStarted: null, + lockClean: null, + redactionApplied: null, + stalenessSeconds: null, + isStale: true, + message: messageFrom(safeStatus), + } +} + +export async function handleReadonlyWorkerStatusGet( + request: Request, + statusFs: ReadonlyWorkerStatusFs = fs, +): Promise { + if (!isAuthenticated(request)) { + return json({ ok: false, error: 'Unauthorized' }, { status: 401 }) + } + + try { + const linkStats = await statusFs.lstat(READONLY_WORKER_STATUS_FILE) + if (linkStats.isSymbolicLink()) { + return json(errorDto('error'), { status: 409 }) + } + if (!linkStats.isFile()) { + return json(errorDto(), { status: 404 }) + } + + const raw = await statusFs.readFile(READONLY_WORKER_STATUS_FILE, 'utf8') + const parsed = JSON.parse(raw) as unknown + return json(buildDto(asRecord(parsed))) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return json(errorDto(), { status: 404 }) + } + if (error instanceof SyntaxError) { + return json(errorDto('error'), { status: 422 }) + } + return json(errorDto('error'), { status: 500 }) + } +} + +export const Route = createFileRoute('/api/readonly-worker-status')({ + server: { + handlers: { + GET: async ({ request }) => handleReadonlyWorkerStatusGet(request), + }, + }, +}) diff --git a/src/screens/dashboard/components/readonly-worker-status-card.tsx b/src/screens/dashboard/components/readonly-worker-status-card.tsx new file mode 100644 index 000000000..841dee77f --- /dev/null +++ b/src/screens/dashboard/components/readonly-worker-status-card.tsx @@ -0,0 +1,318 @@ +import { useEffect, useState } from 'react' + +type ReadonlyWorkerStatus = { + ok: boolean + status: string + generatedAt: string | null + workerDecision: string | null + reportComplete: boolean | null + mountReadOnly: boolean | null + targetInspected: boolean | null + contentsRead: boolean | null + filesWritten: number | null + queueMetadataUpdated: boolean | null + externalMessagesSent: boolean | null + dispatcherStarted: boolean | null + swarmStarted: boolean | null + lockClean: boolean | null + redactionApplied: boolean | null + stalenessSeconds: number | null + isStale: boolean + message: string +} + +type LoadState = { + loading: boolean + error: string | null + data: ReadonlyWorkerStatus | null +} + +function formatAge(seconds: number | null): string { + if (seconds === null) return 'unknown age' + if (seconds < 60) return `${seconds}s ago` + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago` + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago` + return `${Math.floor(seconds / 86400)}d ago` +} + +function formatDate(value: string | null): string { + if (!value) return 'not reported' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return 'invalid timestamp' + return date.toLocaleString() +} + +function yesNo(value: boolean | null): string { + if (value === null) return 'unknown' + return value ? 'yes' : 'no' +} + +function countValue(value: number | null): string { + return value === null ? 'unknown' : String(value) +} + +function statusTone(data: ReadonlyWorkerStatus | null, error: string | null) { + if (error || !data?.ok) { + return { + label: data?.status ?? 'unavailable', + color: 'var(--theme-danger)', + background: 'color-mix(in srgb, var(--theme-danger) 14%, transparent)', + } + } + if (data.status === 'unsafe') { + return { + label: 'unsafe', + color: 'var(--theme-danger)', + background: 'color-mix(in srgb, var(--theme-danger) 14%, transparent)', + } + } + if (data.isStale || data.status === 'stale') { + return { + label: 'stale', + color: 'var(--theme-warning)', + background: 'color-mix(in srgb, var(--theme-warning) 16%, transparent)', + } + } + return { + label: data.status || 'ok', + color: 'var(--theme-success)', + background: 'color-mix(in srgb, var(--theme-success) 16%, transparent)', + } +} + +function SafetyRow({ + label, + value, + safeWhen, +}: { + label: string + value: string + safeWhen: boolean | null +}) { + const color = + safeWhen === null + ? 'var(--theme-muted)' + : safeWhen + ? 'var(--theme-success)' + : 'var(--theme-danger)' + + return ( +
+ {label} + + {value} + +
+ ) +} + +export function ReadonlyWorkerStatusCard() { + const [state, setState] = useState({ + loading: true, + error: null, + data: null, + }) + + useEffect(() => { + let mounted = true + let activeController: AbortController | null = null + + const load = async () => { + activeController?.abort() + const controller = new AbortController() + activeController = controller + setState((prev) => ({ ...prev, loading: true, error: null })) + + try { + const response = await fetch('/api/readonly-worker-status', { + signal: controller.signal, + }) + const data = (await response.json()) as ReadonlyWorkerStatus + if (!mounted || controller.signal.aborted) return + setState({ + loading: false, + error: response.ok ? null : data.message || `HTTP ${response.status}`, + data, + }) + } catch (error) { + if (!mounted || controller.signal.aborted) return + setState({ + loading: false, + error: error instanceof Error ? error.message : 'Unable to load status', + data: null, + }) + } + } + + void load() + const interval = window.setInterval(() => void load(), 30_000) + + return () => { + mounted = false + window.clearInterval(interval) + activeController?.abort() + } + }, []) + + const { data, error, loading } = state + const tone = statusTone(data, error) + const message = error ?? data?.message ?? (loading ? 'Loading readonly worker status.' : '') + + return ( +
+
+ +
+

+ Readonly Worker +

+ + {loading ? 'loading' : tone.label} + +
+ +
+
+ Lane + + Obsidian metadata + +
+
+ Generated + + {formatAge(data?.stalenessSeconds ?? null)} + +
+
+ Decision + + {data?.workerDecision ?? 'not reported'} + +
+
+ Mount read-only + + {yesNo(data?.mountReadOnly ?? null)} + +
+
+ +
+
+ Safety summary +
+
+ + + + + + +
+
+ +
+
+ Lock clean +
+ {yesNo(data?.lockClean ?? null)} +
+
+
+ Redaction +
+ {yesNo(data?.redactionApplied ?? null)} +
+
+
+ + {message ? ( +

+ {message} +

+ ) : null} +
+ ) +} diff --git a/src/screens/dashboard/dashboard-screen.tsx b/src/screens/dashboard/dashboard-screen.tsx index e103b5e04..262f32515 100644 --- a/src/screens/dashboard/dashboard-screen.tsx +++ b/src/screens/dashboard/dashboard-screen.tsx @@ -33,6 +33,7 @@ import { LogsTailCard } from './components/logs-tail-card' import { OperatorTipCard } from './components/operator-tip-card' import { OpsStrip } from './components/ops-strip' import { ProviderMixCard } from './components/provider-mix-card' +import { ReadonlyWorkerStatusCard } from './components/readonly-worker-status-card' import { SessionsIntelligenceCard } from './components/sessions-intelligence-card' import { SkillsUsageCard } from './components/skills-usage-card' import { TokenMixHourCard } from './components/token-mix-hour-card' @@ -1183,6 +1184,9 @@ export function DashboardScreen() { onOpen={() => navigate({ to: '/skills' })} /> + + + {/* `flex-1` here pushes the rhythm card to consume any remaining vertical space so the rail's bottom aligns with Sessions Intelligence. The card itself uses diff --git a/src/screens/dashboard/lib/use-dashboard-layout.ts b/src/screens/dashboard/lib/use-dashboard-layout.ts index cfaa50424..0edc7f708 100644 --- a/src/screens/dashboard/lib/use-dashboard-layout.ts +++ b/src/screens/dashboard/lib/use-dashboard-layout.ts @@ -21,6 +21,7 @@ export type WidgetId = | 'logs_tail' | 'operator_tip' | 'skills_usage' + | 'readonly_worker_status' | 'achievements' | 'mix_rhythm' @@ -111,6 +112,13 @@ export const WIDGET_CATALOG: ReadonlyArray = [ column: 'rail', hideable: true, }, + { + id: 'readonly_worker_status', + label: 'Readonly Worker', + description: 'Safe automatic Obsidian metadata worker health', + column: 'rail', + hideable: true, + }, { id: 'achievements', label: 'Achievements', @@ -172,7 +180,7 @@ function readLayout(): StoredLayout { const valid = new Set(WIDGET_CATALOG.map((w) => w.id)) const incoming = Array.isArray(parsed.hidden) ? parsed.hidden : [] const filtered = incoming.filter((id): id is WidgetId => - valid.has(id as WidgetId), + valid.has(id), ) // Schema migration: when we introduce new widgets that should be // off-by-default, bump STORAGE_VERSION and union the prior user