kiuribots 反映処理のプログラム検査 実施日時(JST): 2026-10-13T16:07:54.012824+09:00 ソースGit版: 4e75d6455f9340ae242c2278ceaedd04f6f0da83 Node: v22.23.3 実行したもの: dist/test/publish.test.js(既存の配布JavaScript。今回再ビルドしていません) 条件: 架空の利用者OWNER、モデル応答の代用品、一時的なSQLite、ローカルのbare Git。外部通信は検査プロセスだけ無効化。外部モデル呼び出し・実際の外部Git送信はなし。 対象例: READMEのheloをhelloに直す、承認前のpush、承認後の正常push、承認要求を作った後、人が許可する前の追加コミット、cloneに置いたフック、却下。期待条件はテストコードのassertで判定。 試験の順序に関する注:changed-after-approvalというテスト名ですが、コードの操作順序は、承認要求の作成→作業クローンへの追加コミット→人のapprove操作です。承認操作完了後の変更を試したものではありません。Git送信先はローカルのbare Gitなので、実際のGitHub通信やSSHでの認証・送信設定の安全性も測っていません。 旧稿のモデル比較や模擬検証の再実行ではありません。未知のプロンプトインジェクション、実環境のOS隔離やDBへの直接書き込みへの耐性は、この検査の対象外です。 ソースSHA256と実行したJSのSHA256 { "source": { "roles/publisher.ts": "fa02ba6a47cd2c814bfbcf64c28b71b1359ee07b62d3e62697d8c185108f0d79", "core/store.ts": "a54a9588845afeaba324ac2410a61bdb2ef3f4c89a1683ed7fc7a53d90a62a75", "test/publish.test.ts": "2436258d80136e44d67e86b128e67f70d4741450e06d2ee980f0eb125a9ee06a", "test/helpers.ts": "3dc39c19385758650f5160c8f8b27afb9c6b46e0fc76ae033d41743a33ac0634" }, "artifact": { "dist/roles/publisher.js": "ec6bb7d36d3eca111556be1f77a91b013438f521e81c1aaa1ce23b3489f2e8ea", "dist/core/store.js": "3c6568da7a2960000d4aeb64ca716e43f2216f782fdf9e63e4d19b393009c63d", "dist/test/publish.test.js": "e1b66cf661dba4e7fc04bfd8272759b1d1a5c01fae79f6ea5837755d92742443", "dist/test/helpers.js": "203e59d51f628019a30e09ed91ac8c7533e41b414ae405f627a913343a1ffb1c" } } TAP version 13 # Subtest: the worker stops before pushing, and nothing leaves until approval ok 1 - the worker stops before pushing, and nothing leaves until approval --- duration_ms: 766.900908 type: 'test' ... # Subtest: a branch changed after approval is not pushed ok 2 - a branch changed after approval is not pushed --- duration_ms: 486.196566 type: 'test' ... # Subtest: hooks and ssh settings the model planted in its clone never run ok 3 - hooks and ssh settings the model planted in its clone never run --- duration_ms: 474.184344 type: 'test' ... # Subtest: rejecting a push leaves the remote untouched ok 4 - rejecting a push leaves the remote untouched --- duration_ms: 409.004458 type: 'test' ... # Subtest: without push_url the worker finishes locally, as before ok 5 - without push_url the worker finishes locally, as before --- duration_ms: 313.281076 type: 'test' ... # Subtest: the approval request is reported with what will be pushed ok 6 - the approval request is reported with what will be pushed --- duration_ms: 478.980163 type: 'test' ... 1..6 # tests 6 # suites 0 # pass 6 # fail 0 # cancelled 0 # skipped 0 # todo 0 # duration_ms 3645.124469 テストコード(対象と期待結果) import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { test } from 'node:test'; import type { ProviderRunner } from '../core/llm/index.js'; import { initStore, openStore } from '../core/store.js'; import { Gateway } from '../gateway/gateway.js'; import type { ChatAdapter } from '../gateway/types.js'; import { runOnce } from '../roles/runner.js'; import { reportBlock, tempDir, testConfig, testPaths } from './helpers.js'; const git = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, encoding: 'utf8' }).trim(); class Adapter implements ChatAdapter { readonly name = 'fake'; replies: string[] = []; async start(): Promise {} async stop(): Promise {} async reply(_t: string, text: string): Promise { this.replies.push(text); } async post(): Promise { return undefined; } } function setup() { const root = tempDir(); const origin = join(root, 'origin'); const remote = join(root, 'remote.git'); // stands in for GitHub mkdirSync(origin); git(origin, 'init', '-q', '-b', 'main'); writeFileSync(join(origin, 'README.md'), 'helo\n'); git(origin, 'add', '.'); git(origin, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'init'); git(root, 'init', '-q', '--bare', remote); const paths = testPaths(); initStore(paths.db); mkdirSync(paths.worktrees, { recursive: true }); const config = testConfig((c) => { c.policy.writes_enabled = true; c.repos = [{ name: 'demo', path: origin, base_branch: 'main', push_url: remote }]; c.intake.mode = 'keyword'; }); const gwStore = openStore(paths.db, { actor: 'gateway' }); const adapter = new Adapter(); const gateway = new Gateway({ adapter, store: gwStore, config, ownerId: 'OWNER', paths }); const deps = { role: 'worker' as const, config, paths, store: openStore(paths.db, { actor: 'worker' }), llmStore: openStore(paths.db, { actor: 'llm' }), }; const say = (text: string) => gateway.handle({ userId: 'OWNER', text, thread: 'D|1' }); return { root, remote, paths, gwStore, adapter, deps, say }; } const editor = (extra: (cwd: string) => void = () => {}): Record => ({ claude: async (req) => { writeFileSync(join(req.cwd, 'README.md'), 'hello\n'); extra(req.cwd); return { outcome: 'ok', text: `fixed the typo\n\n${reportBlock()}`, model: 'opus' }; }, }); const remoteBranches = (remote: string) => git(remote, 'branch', '--list'); test('the worker stops before pushing, and nothing leaves until approval', async () => { const s = setup(); await s.say('demo: READMEの誤字を直して'); assert.equal(await runOnce({ ...s.deps, runners: editor() }), 'awaiting_approval'); const task = s.gwStore.getTask(1)!; assert.equal(task.status, 'awaiting_approval'); const approval = s.gwStore.pendingApproval(1)!; assert.equal(approval.action, 'push'); assert.match(approval.detail, /push bot\/task-1 →[\s\S]*README\.md/); // No test_command is set for this repository: the request says so, ahead of the push line. assert.match(approval.detail, /^⚠ テスト未設定\n/); assert.ok(approval.detail.indexOf('⚠ テスト未設定') < approval.detail.indexOf('push bot/task-1')); assert.deepEqual(s.gwStore.checksFor(1).map((c) => [c.kind, c.outcome]), [['test', 'skipped'], ['review', 'error']], 'the editor stand-in answers the review with prose, which is not a review'); const payload = JSON.parse(approval.payload!) as { sha: string; branch: string }; assert.equal(payload.branch, 'bot/task-1'); assert.equal(remoteBranches(s.remote), '', 'nothing pushed before approval'); await s.say('approve 1'); assert.match(s.adapter.replies.at(-1)!, /push しました/); assert.equal(s.gwStore.getTask(1)!.status, 'done'); assert.equal(git(s.remote, 'rev-parse', 'refs/heads/bot/task-1'), payload.sha, 'exactly the approved commit'); assert.equal(git(s.remote, 'show', 'bot/task-1:README.md'), 'hello'); assert.equal(remoteBranches(s.remote), 'bot/task-1', 'only the bot branch, never main'); }); test('a branch changed after approval is not pushed', async () => { const s = setup(); await s.say('demo: 直して'); await runOnce({ ...s.deps, runners: editor() }); const clone = join(s.paths.worktrees, 'task-1'); writeFileSync(join(clone, 'evil.txt'), 'sneaked in\n'); git(clone, 'add', '.'); git(clone, '-c', 'user.name=x', '-c', 'user.email=x@x', 'commit', '-qm', 'after approval'); await s.say('approve 1'); assert.match(s.adapter.replies.at(-1)!, /push できませんでした: the branch changed after approval/); assert.equal(s.gwStore.getTask(1)!.status, 'failed'); assert.equal(remoteBranches(s.remote), ''); }); test('hooks and ssh settings the model planted in its clone never run', async () => { const s = setup(); const marker = join(s.root, 'PWNED'); const plant = (cwd: string) => { for (const hook of ['pre-push', 'pre-commit', 'post-commit', 'reference-transaction']) { writeFileSync(join(cwd, '.git', 'hooks', hook), `#!/bin/sh\ntouch ${marker}\n`, { mode: 0o755 }); } git(cwd, 'config', 'core.sshCommand', `touch ${marker}; ssh`); }; await s.say('demo: 直して'); assert.equal(await runOnce({ ...s.deps, runners: editor(plant) }), 'awaiting_approval'); await s.say('approve 1'); assert.equal(s.gwStore.getTask(1)!.status, 'done'); assert.ok(!existsSync(marker), 'no planted hook or command ran'); }); test('rejecting a push leaves the remote untouched', async () => { const s = setup(); await s.say('demo: 直して'); await runOnce({ ...s.deps, runners: editor() }); await s.say('reject 1 今は不要'); assert.equal(s.gwStore.getTask(1)!.status, 'rejected'); assert.equal(remoteBranches(s.remote), ''); }); test('without push_url the worker finishes locally, as before', async () => { const s = setup(); s.deps.config.repos[0]!.push_url = undefined; await s.say('demo: 直して'); assert.equal(await runOnce({ ...s.deps, runners: editor() }), 'done'); assert.match(s.gwStore.getTask(1)!.result_path!, /not pushed/); assert.match(s.gwStore.getTask(1)!.result_path!, /テスト未設定/, 'the result says what happened to the tests'); assert.deepEqual(s.gwStore.checksFor(1).map((c) => c.kind), ['test'], 'no push, no review'); }); test('the approval request is reported with what will be pushed', async () => { const s = setup(); const gw = new Gateway({ adapter: s.adapter, store: s.gwStore, config: s.deps.config, ownerId: 'OWNER', paths: s.paths }); await gw.pump(); await s.say('demo: 直して'); await runOnce({ ...s.deps, runners: editor() }); await gw.pump(); const text = s.adapter.replies.join('\n'); assert.match(text, /承認待ち #1[\s\S]*⚠ テスト未設定[\s\S]*push bot\/task-1[\s\S]*approve 1/); });