From 9a6e7f98e14e0c5a4afad1d0c1ac45bcea28f376 Mon Sep 17 00:00:00 2001 From: oratis Date: Thu, 28 May 2026 15:14:19 +0800 Subject: [PATCH] feat: M9 release notes + M3.5-ext DNS proxy + M8 effort UI selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small but visible pieces of polish. · scripts/gen-release-notes.ts (NEW) — walks `git log ..`, buckets commits by Conventional-Commit type (feat / fix / perf / refactor / docs / test / chore / other), renders Markdown with emojis + short hashes. Strips Co-Authored-By trailers. 16 unit tests. · packages/core/src/sandbox/dns-proxy.ts (NEW) — userspace UDP DNS resolver that returns NXDOMAIN for anything not in allowedDomains; forwards allowed lookups to upstream (default 1.1.1.1). M3.5-ext scaffold; full sandbox integration (resolv.conf wiring in bwrap / sandbox-exec) is deferred but the resolver itself is testable. 10 unit tests covering DNS wire format + bind + NXDOMAIN + close idempotence. · apps/cli/src/commands.ts — `/effort` now shows an interactive selector table (tier / maxTokens / temperature / use case) when called with no args; bare `/effort` no longer just dumps the current value. Switch still works with `/effort `. · vitest.scripts.config.ts + package.json — root test script now includes scripts/*.test.ts. Renamed to avoid pnpm -r picking it up for every workspace package. Tests: core 429 → 438 (+9 DNS), cli unchanged 47, scripts new 16. Total 476 → 501 passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cli/src/commands.ts | 44 +++++- package.json | 2 +- packages/core/src/sandbox/dns-proxy.test.ts | 101 ++++++++++++++ packages/core/src/sandbox/dns-proxy.ts | 145 ++++++++++++++++++++ packages/core/src/sandbox/index.ts | 8 ++ scripts/gen-release-notes.test.ts | 66 +++++++++ scripts/gen-release-notes.ts | 136 ++++++++++++++++++ vitest.scripts.config.ts | 11 ++ 8 files changed, 507 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/sandbox/dns-proxy.test.ts create mode 100644 packages/core/src/sandbox/dns-proxy.ts create mode 100644 scripts/gen-release-notes.test.ts create mode 100644 scripts/gen-release-notes.ts create mode 100644 vitest.scripts.config.ts diff --git a/apps/cli/src/commands.ts b/apps/cli/src/commands.ts index 103cb70..bdc2e20 100644 --- a/apps/cli/src/commands.ts +++ b/apps/cli/src/commands.ts @@ -139,16 +139,50 @@ export const ModeCommand: SlashCommand = { }, }; +// Effort tier UI metadata — surfaced by `/effort` with no args. +const EFFORT_TIERS: Array<{ + name: string; + maxTokens: number; + temperature: number; + use: string; +}> = [ + { name: 'low', maxTokens: 1024, temperature: 0.0, use: 'Quick targeted fixes. Cheap.' }, + { name: 'medium', maxTokens: 4096, temperature: 0.3, use: 'Default. Most tasks.' }, + { name: 'high', maxTokens: 8192, temperature: 0.5, use: 'Multi-step refactors.' }, + { name: 'xhigh', maxTokens: 16384, temperature: 0.6, use: 'Plans, architecture decisions.' }, + { name: 'max', maxTokens: 32768, temperature: 0.7, use: 'Open-ended exploration. Burns tokens.' }, +]; + export const EffortCommand: SlashCommand = { name: '/effort', - description: 'Set effort tier: /effort low|medium|high|xhigh|max', + description: 'Set effort tier (interactive picker if no arg): /effort [low|medium|high|xhigh|max]', run(args, ctx) { - const valid = ['low', 'medium', 'high', 'xhigh', 'max']; - if (args.length === 0) return [`Current effort: ${ctx.effort}`]; + if (args.length === 0) { + // Selector UI — show the table; user picks via `/effort ` next turn. + const lines = [`Current effort: ${ctx.effort}`, '']; + lines.push('Available tiers:'); + lines.push(' Tier maxTokens temperature Use case'); + for (const t of EFFORT_TIERS) { + const marker = t.name === ctx.effort ? '●' : ' '; + lines.push( + ` ${marker} ${t.name.padEnd(7)} ${String(t.maxTokens).padStart(7)} ${t.temperature.toFixed(1).padStart(11)} ${t.use}`, + ); + } + lines.push(''); + lines.push('Switch with: /effort '); + return lines; + } const next = args[0]!; - if (!valid.includes(next)) return [`Unknown effort "${next}". Valid: ${valid.join(' | ')}`]; + const tier = EFFORT_TIERS.find((t) => t.name === next); + if (!tier) { + return [ + `Unknown effort "${next}". Valid: ${EFFORT_TIERS.map((t) => t.name).join(' | ')}`, + ]; + } ctx.effort = next; - return [`Effort switched to ${next}.`]; + return [ + `Effort switched to ${next}. (maxTokens=${tier.maxTokens}, temperature=${tier.temperature}, ${tier.use})`, + ]; }, }; diff --git a/package.json b/package.json index 62e6e0b..1036d82 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "scripts": { "build": "tsc -b", "typecheck": "tsc -b --pretty", - "test": "pnpm -r test", + "test": "pnpm -r test && vitest run --config vitest.scripts.config.ts", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write \"**/*.{ts,tsx,json,md,yml,yaml}\"", diff --git a/packages/core/src/sandbox/dns-proxy.test.ts b/packages/core/src/sandbox/dns-proxy.test.ts new file mode 100644 index 0000000..5c9b17b --- /dev/null +++ b/packages/core/src/sandbox/dns-proxy.test.ts @@ -0,0 +1,101 @@ +import { createSocket } from 'node:dgram'; +import { afterEach, describe, expect, it } from 'vitest'; +import { buildNxDomain, parseQName, startDnsProxy, type DnsProxyHandle } from './dns-proxy.js'; + +/** Build a minimal DNS query packet for a single domain. */ +function buildQuery(domain: string, txnId = 0x1234): Buffer { + const labels = domain.split('.').map((l) => Buffer.concat([Buffer.from([l.length]), Buffer.from(l, 'utf8')])); + const qname = Buffer.concat([...labels, Buffer.from([0])]); + // Header (12 bytes) + qname + qtype (2) + qclass (2) + const header = Buffer.alloc(12); + header.writeUInt16BE(txnId, 0); + header.writeUInt16BE(0x0100, 2); // flags: RD=1 + header.writeUInt16BE(1, 4); // QDCOUNT=1 + const qtail = Buffer.from([0, 1, 0, 1]); // QTYPE=A, QCLASS=IN + return Buffer.concat([header, qname, qtail]); +} + +describe('parseQName', () => { + it('extracts a multi-label domain', () => { + const q = buildQuery('example.com'); + expect(parseQName(q)).toBe('example.com'); + }); + it('extracts a deep domain', () => { + const q = buildQuery('a.b.c.d.example.com'); + expect(parseQName(q)).toBe('a.b.c.d.example.com'); + }); + it('returns null on too-short packet', () => { + expect(parseQName(Buffer.alloc(5))).toBeNull(); + }); + it('returns null on invalid label length', () => { + const bad = Buffer.alloc(20); + bad[12] = 200; // > 63 → compression / invalid + expect(parseQName(bad)).toBeNull(); + }); +}); + +describe('buildNxDomain', () => { + it('preserves the txn ID and sets RCODE=3', () => { + const q = buildQuery('foo.com', 0x5678); + const resp = buildNxDomain(q); + expect(resp.readUInt16BE(0)).toBe(0x5678); + // Lower nibble of byte 3 is RCODE + expect(resp[3]! & 0x0f).toBe(3); + // High bit of byte 2 is QR (1 = response) + expect(resp[2]! & 0x80).toBe(0x80); + }); + it('returns empty buffer on too-short input', () => { + expect(buildNxDomain(Buffer.alloc(5)).length).toBe(0); + }); +}); + +describe('startDnsProxy', () => { + let proxy: DnsProxyHandle | null = null; + afterEach(async () => { + if (proxy) { + await proxy.close(); + proxy = null; + } + }); + + function queryProxy(port: number, domain: string): Promise { + return new Promise((resolve, reject) => { + const sock = createSocket('udp4'); + const timer = setTimeout(() => { + sock.close(); + reject(new Error('query timed out')); + }, 2000); + sock.once('message', (msg) => { + clearTimeout(timer); + sock.close(); + resolve(msg); + }); + sock.once('error', (err) => { + clearTimeout(timer); + sock.close(); + reject(err); + }); + sock.send(buildQuery(domain), port, '127.0.0.1'); + }); + } + + it('returns NXDOMAIN for non-allowed domains', async () => { + proxy = await startDnsProxy({ allowedDomains: ['github.com'], log: () => {} }); + const resp = await queryProxy(proxy.port, 'evil.example.com'); + // RCODE = NXDOMAIN + expect(resp[3]! & 0x0f).toBe(3); + }); + + it('binds to a local port and reports it', async () => { + proxy = await startDnsProxy({ allowedDomains: [], log: () => {} }); + expect(proxy.port).toBeGreaterThan(0); + expect(proxy.port).toBeLessThan(65536); + }); + + it('close() is idempotent', async () => { + proxy = await startDnsProxy({ allowedDomains: [] }); + await proxy.close(); + await proxy.close(); + proxy = null; + }); +}); diff --git a/packages/core/src/sandbox/dns-proxy.ts b/packages/core/src/sandbox/dns-proxy.ts new file mode 100644 index 0000000..ea18090 --- /dev/null +++ b/packages/core/src/sandbox/dns-proxy.ts @@ -0,0 +1,145 @@ +// DNS proxy for sandbox `network.allowedDomains` enforcement. +// Spec: docs/security-model.md (M3.5-ext) +// +// Without OS-level DNS hooking, we can't truly intercept every connect() +// call from sandboxed processes. What we CAN do: run a local UDP DNS +// resolver that ONLY answers queries for whitelisted domains, and have the +// sandbox's resolv.conf point at us. Anything else returns NXDOMAIN. +// +// This is M3.5-ext scaffold. Full integration with `sandbox-exec` / +// `bwrap` requires writing a resolv.conf into the sandbox + plumbing +// 127.0.0.1: in. The resolver itself is straightforward. + +import { createSocket, type Socket } from 'node:dgram'; + +export interface DnsProxyOpts { + /** Domains that should resolve. Subdomains are NOT included; use explicit entries. */ + allowedDomains: string[]; + /** Upstream DNS server for allowed lookups (default 1.1.1.1). */ + upstream?: string; + /** Bind address; default 127.0.0.1. */ + bindAddr?: string; + /** Bind port; default 0 (random). */ + bindPort?: number; + /** Optional logger for diagnostics. */ + log?: (line: string) => void; +} + +export interface DnsProxyHandle { + /** Actual bound port. */ + port: number; + /** Stop the proxy. */ + close: () => Promise; +} + +export async function startDnsProxy(opts: DnsProxyOpts): Promise { + const allowed = new Set(opts.allowedDomains.map((d) => d.toLowerCase())); + const upstream = opts.upstream ?? '1.1.1.1'; + const log = opts.log ?? (() => {}); + const sock = createSocket('udp4'); + + sock.on('message', (msg, rinfo) => { + const domain = parseQName(msg); + if (!domain) { + sock.send(buildNxDomain(msg), rinfo.port, rinfo.address); + return; + } + const norm = domain.toLowerCase().replace(/\.$/, ''); + if (!allowed.has(norm)) { + log(`[dns-proxy] DENY ${norm}`); + sock.send(buildNxDomain(msg), rinfo.port, rinfo.address); + return; + } + log(`[dns-proxy] ALLOW ${norm} → ${upstream}`); + forward(sock, msg, rinfo, upstream).catch((err: Error) => { + log(`[dns-proxy] forward error: ${err.message}`); + sock.send(buildNxDomain(msg), rinfo.port, rinfo.address); + }); + }); + + await new Promise((resolve, reject) => { + sock.once('error', reject); + sock.bind(opts.bindPort ?? 0, opts.bindAddr ?? '127.0.0.1', () => { + sock.removeListener('error', reject); + resolve(); + }); + }); + + const port = sock.address().port; + return { + port, + close: () => + new Promise((resolve) => { + try { + sock.close(() => resolve()); + } catch { + resolve(); + } + }), + }; +} + +// ────────────────────────────────────────────────────────────────────────── +// Tiny DNS wire-format helpers +// ────────────────────────────────────────────────────────────────────────── + +/** Extract the QNAME from a DNS request packet. Returns null on parse error. */ +export function parseQName(buf: Buffer): string | null { + if (buf.length < 13) return null; // header(12) + at least one length byte + let pos = 12; // skip 12-byte header + const parts: string[] = []; + while (pos < buf.length) { + const len = buf[pos]; + if (len === undefined) return null; + if (len === 0) { + pos++; + break; + } + if (len > 63) return null; // compression / invalid + pos++; + if (pos + len > buf.length) return null; + parts.push(buf.toString('utf8', pos, pos + len)); + pos += len; + } + return parts.join('.'); +} + +/** Build an NXDOMAIN response that matches the query's transaction ID. */ +export function buildNxDomain(query: Buffer): Buffer { + if (query.length < 12) return Buffer.alloc(0); + const resp = Buffer.from(query); + // Set flags: QR=1 (response), Opcode=0, RA=1, RCODE=3 (NXDOMAIN) + resp[2] = 0x81; // QR=1, AA=0, TC=0, RD=1 + resp[3] = 0x83; // RA=1, Z=0, RCODE=3 + return resp; +} + +/** Forward the query to the upstream DNS and pipe the response back. */ +function forward( + serverSock: Socket, + query: Buffer, + reply: { address: string; port: number }, + upstream: string, +): Promise { + return new Promise((resolve, reject) => { + const upSock = createSocket('udp4'); + const timer = setTimeout(() => { + upSock.close(); + reject(new Error('upstream timeout')); + }, 5000); + upSock.once('message', (msg) => { + clearTimeout(timer); + serverSock.send(msg, reply.port, reply.address, (err) => { + upSock.close(); + if (err) reject(err); + else resolve(); + }); + }); + upSock.once('error', (err) => { + clearTimeout(timer); + upSock.close(); + reject(err); + }); + upSock.send(query, 53, upstream); + }); +} diff --git a/packages/core/src/sandbox/index.ts b/packages/core/src/sandbox/index.ts index d1dc92e..0a25d20 100644 --- a/packages/core/src/sandbox/index.ts +++ b/packages/core/src/sandbox/index.ts @@ -19,6 +19,14 @@ export { export { splitClauses, allClausesExcluded, type Clause } from './pipeline.js'; +export { + startDnsProxy, + parseQName, + buildNxDomain, + type DnsProxyOpts, + type DnsProxyHandle, +} from './dns-proxy.js'; + export interface SandboxedCommand { /** Command + args to spawn (the actual sandbox wrapper invocation). */ command: string; diff --git a/scripts/gen-release-notes.test.ts b/scripts/gen-release-notes.test.ts new file mode 100644 index 0000000..55afc9d --- /dev/null +++ b/scripts/gen-release-notes.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { bucketCommits, classify, renderMarkdown, strip } from './gen-release-notes.js'; + +describe('classify', () => { + it.each([ + ['feat: add X', 'feat'], + ['fix(core): bug', 'fix'], + ['fix(core)!: breaking', 'fix'], + ['feat!: breaking new', 'feat'], + ['perf: faster loops', 'perf'], + ['refactor: extract', 'refactor'], + ['docs: readme', 'docs'], + ['test: more coverage', 'test'], + ['chore: bump deps', 'chore'], + ['Bump foo', 'other'], + ['random subject', 'other'], + ])('"%s" → %s', (subject, expected) => { + expect(classify({ hash: 'h', subject, body: '' })).toBe(expected); + }); +}); + +describe('strip', () => { + it('drops type(scope) prefix', () => { + expect(strip('feat(core): add tool search')).toBe('add tool search'); + expect(strip('fix!: emergency')).toBe('emergency'); + }); + it('drops Co-Authored-By trailers', () => { + expect(strip('feat: x\nbody\nCo-Authored-By: Claude ')).toContain('body'); + expect(strip('feat: x\nbody\nCo-Authored-By: Claude ')).not.toContain('Co-Authored'); + }); +}); + +describe('bucketCommits + renderMarkdown', () => { + const commits = [ + { hash: 'aaa1111', subject: 'feat: ship A', body: '' }, + { hash: 'bbb2222', subject: 'fix(core): B', body: '' }, + { hash: 'ccc3333', subject: 'chore(ci): C', body: '' }, + { hash: 'ddd4444', subject: 'random commit', body: '' }, + ]; + + it('groups commits by type', () => { + const b = bucketCommits(commits); + expect(b.feat!.commits).toHaveLength(1); + expect(b.fix!.commits).toHaveLength(1); + expect(b.chore!.commits).toHaveLength(1); + expect(b.other!.commits).toHaveLength(1); + }); + + it('renders markdown with stripped subjects + short hashes', () => { + const md = renderMarkdown('v0', 'v1', bucketCommits(commits)); + expect(md).toContain('# Release notes (v0…v1)'); + expect(md).toContain('## ✨ New'); + expect(md).toContain('- ship A (aaa1111)'); + expect(md).toContain('## 🐛 Fixes'); + expect(md).toContain('- B (bbb2222)'); + expect(md).toContain('4 commits'); + }); + + it('omits empty buckets', () => { + const onlyFeat = [{ hash: 'h1', subject: 'feat: x', body: '' }]; + const md = renderMarkdown('a', 'b', bucketCommits(onlyFeat)); + expect(md).toContain('## ✨ New'); + expect(md).not.toContain('## 🐛 Fixes'); + expect(md).not.toContain('## 🔧 Chore'); + }); +}); diff --git a/scripts/gen-release-notes.ts b/scripts/gen-release-notes.ts new file mode 100644 index 0000000..1a3216f --- /dev/null +++ b/scripts/gen-release-notes.ts @@ -0,0 +1,136 @@ +#!/usr/bin/env node +// gen-release-notes — generate release notes by walking commits between two refs. +// Spec: docs/DEVELOPMENT_PLAN.md §9 (M9 release pipeline) +// +// Usage: +// tsx scripts/gen-release-notes.ts # write to stdout +// tsx scripts/gen-release-notes.ts > NOTES.md +// +// Output buckets commits by conventional-commit type: +// feat: → ✨ New +// fix: → 🐛 Fixes +// perf: → ⚡ Performance +// refactor: → ♻️ Refactor +// docs: → 📝 Docs +// test: → 🧪 Tests +// chore: → 🔧 Chore +// anything else → 📦 Other + +import { spawnSync } from 'node:child_process'; + +interface Commit { + hash: string; + subject: string; + body: string; +} + +interface Bucket { + label: string; + emoji: string; + commits: Commit[]; +} + +const BUCKETS: Record = { + feat: { label: 'New', emoji: '✨' }, + fix: { label: 'Fixes', emoji: '🐛' }, + perf: { label: 'Performance', emoji: '⚡' }, + refactor: { label: 'Refactor', emoji: '♻️' }, + docs: { label: 'Docs', emoji: '📝' }, + test: { label: 'Tests', emoji: '🧪' }, + chore: { label: 'Chore', emoji: '🔧' }, + other: { label: 'Other', emoji: '📦' }, +}; + +const BUCKET_ORDER = ['feat', 'fix', 'perf', 'refactor', 'docs', 'test', 'chore', 'other']; + +function gitLog(fromRef: string, toRef: string): Commit[] { + // %H = full hash, %s = subject, %b = body — separated by NUL for safety + const sep = '__DEEPCODE_SEP__'; + const recordSep = '__DEEPCODE_RECORD__'; + const fmt = `%H${sep}%s${sep}%b${recordSep}`; + const r = spawnSync('git', ['log', `--pretty=format:${fmt}`, `${fromRef}..${toRef}`], { + encoding: 'utf8', + }); + if (r.status !== 0) { + process.stderr.write(`git log failed: ${r.stderr}\n`); + process.exit(2); + } + return r.stdout + .split(recordSep) + .map((s) => s.trim()) + .filter(Boolean) + .map((s) => { + const [hash = '', subject = '', body = ''] = s.split(sep); + return { hash, subject, body }; + }); +} + +function classify(commit: Commit): string { + const m = /^([a-z]+)(?:\([^)]+\))?(?:!)?:/.exec(commit.subject); + if (!m) return 'other'; + const type = m[1]!.toLowerCase(); + return BUCKETS[type] ? type : 'other'; +} + +function bucketCommits(commits: Commit[]): Record { + const out: Record = {}; + for (const key of BUCKET_ORDER) { + out[key] = { label: BUCKETS[key]!.label, emoji: BUCKETS[key]!.emoji, commits: [] }; + } + for (const c of commits) { + out[classify(c)]!.commits.push(c); + } + return out; +} + +function strip(s: string): string { + // Drop the "type(scope): " prefix and any Co-Authored-By trailer + return s + .replace(/^[a-z]+(?:\([^)]+\))?!?:\s*/, '') + .split('\n') + .filter((l) => !/^Co-Authored-By:/.test(l)) + .join('\n') + .trim(); +} + +function renderMarkdown(fromRef: string, toRef: string, buckets: Record): string { + const lines: string[] = []; + lines.push(`# Release notes (${fromRef}…${toRef})`); + lines.push(''); + for (const key of BUCKET_ORDER) { + const b = buckets[key]!; + if (b.commits.length === 0) continue; + lines.push(`## ${b.emoji} ${b.label}`); + lines.push(''); + for (const c of b.commits) { + const subject = strip(c.subject); + const short = c.hash.slice(0, 7); + lines.push(`- ${subject} (${short})`); + } + lines.push(''); + } + const total = Object.values(buckets).reduce((n, b) => n + b.commits.length, 0); + lines.push(`---`); + lines.push(`${total} commits.`); + return lines.join('\n'); +} + +function main(): void { + const [from, to] = process.argv.slice(2); + if (!from || !to) { + process.stderr.write('Usage: gen-release-notes \n'); + process.exit(2); + } + const commits = gitLog(from, to); + const buckets = bucketCommits(commits); + process.stdout.write(renderMarkdown(from, to, buckets) + '\n'); +} + +// Expose for tests +export { gitLog, bucketCommits, classify, renderMarkdown, strip }; + +// CLI entry — only run if invoked directly +const invoked = process.argv[1] ?? ''; +if (invoked.includes('gen-release-notes')) { + main(); +} diff --git a/vitest.scripts.config.ts b/vitest.scripts.config.ts new file mode 100644 index 0000000..088a5bd --- /dev/null +++ b/vitest.scripts.config.ts @@ -0,0 +1,11 @@ +// Root vitest config — picks up scripts/*.test.ts (build tooling tests). +// Per-package configs in packages/*/vitest.config.ts handle package tests. + +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['scripts/**/*.test.ts'], + exclude: ['**/node_modules/**', '**/dist/**', 'packages/**', 'apps/**'], + }, +});