From d8a27cdfb3b6d97a25a3892279085f955e9428b8 Mon Sep 17 00:00:00 2001 From: wu <641600780@qq.com> Date: Fri, 30 Oct 2026 11:15:55 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(agent):=20forge=20=E9=9B=86=E6=88=90?= =?UTF-8?q?=E2=80=94=E2=80=94GitHub/AtomGit=20=E4=BB=93=E5=BA=93=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E5=B7=A5=E5=85=B7=E6=97=8F(gh/ag=20=E5=90=8E=E7=AB=AF?= =?UTF-8?q?,=E9=9B=B6=E6=96=B0=E4=BE=9D=E8=B5=96,=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=BC=95=E5=AF=BC,=E4=B8=89=E7=AB=AF=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=8F=AF=E7=94=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新模块 packages/agent/src/forge.ts:13 个 forge_* 工具(status/repo/pr/issue/release/raw/login), host 参数统一映射 gh|ag 二进制;gh 字段列表 --json 与 ag 布尔 --json、-R 选择器与位置参数、 release --title/--notes 与 --name/--body 等线上差异在纯参数构造器中吸收(全部导出可测) - 凭据零接触:令牌永远留在 gh keyring/hosts.yml 与 ag 自有存储,hmharness 只解析 auth status 文本 - exec+quoteArg 硬化转发(execFile 无法 spawn npm 全局 ag.cmd,已实测);超时 60s; 未装/未登录诚实报错并带安装(winget/npm)与登录(forge_login)指引 - forge_login:起 gh auth login --web/ag auth login 子进程,捕获设备码+URL 即刻透出(stderr+结果), 轮询 auth status 直至登录或 3 分钟超时 - runner.ts nativeRegistry 注册 forgeTools——TUI/web/桌面三端自动可用;forge_raw 默认需确认 - prompt.ts 新增 Git forges 一节(3 行):优先 forge_* 工具、未登录先 forge_login、不建议用户去网页 - capability.ts 补 forge 风险声明(写操作 high/读操作 low,声明层不改执法) - 测试 __tests__/forge.test.ts:16 例全绿——PATH 注入 node 写的 gh/ag 假 CLI(.cmd shim,Windows 兼容), 断言参数拼装/JSON 解析/双宿主渲染/未装未登录诚实错误/超时/登录轮询翻转(第 3 次探测)/仅 forge_raw 弹卡 - 真机验证:forge_status 双平台登录态、forge_repo_list 双 host 各一页、forge_pr_list swsgbl/hmharness(只读) - 全套 npm test 1063 通过;唯一失败 project.test.ts tar 快照为本机 Windows 基线问题(主仓同样失败,非回归) Co-Authored-By: Claude Code --- packages/agent/src/__tests__/forge.test.ts | 374 ++++++++++++ packages/agent/src/capability.ts | 4 + packages/agent/src/forge.ts | 631 +++++++++++++++++++++ packages/agent/src/index.ts | 8 + packages/agent/src/prompt.ts | 7 + packages/agent/src/runner.ts | 3 +- 6 files changed, 1026 insertions(+), 1 deletion(-) create mode 100644 packages/agent/src/__tests__/forge.test.ts create mode 100644 packages/agent/src/forge.ts diff --git a/packages/agent/src/__tests__/forge.test.ts b/packages/agent/src/__tests__/forge.test.ts new file mode 100644 index 00000000..5f801324 --- /dev/null +++ b/packages/agent/src/__tests__/forge.test.ts @@ -0,0 +1,374 @@ +/** + * forge.test.ts — the forge tool family against a fake CLI seam. + * + * gh/ag are faked by node scripts placed FIRST on PATH (`.cmd` shims on + * Windows — execFile cannot spawn .cmd, which is exactly why forgeRun goes + * through exec; a POSIX wrapper elsewhere). The fakes dump argv so tests + * assert exact argument assembly, print canned JSON so renderers run on real + * shapes, and implement the auth-status flip (3rd call → logged in) that + * exercises forge_login's polling loop. + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { appendFileSync, chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { delimiter, join } from 'node:path'; +import { + forgeDetect, forgeDetectHost, forgeRun, forgeLogin, forgeTools, + quoteArg, parseAuthStatus, parseJsonOut, renderRepos, renderPrs, renderIssues, + repoListArgs, repoCreateArgs, prListArgs, prCreateArgs, prMergeArgs, prChecksArgs, + issueListArgs, issueCreateArgs, releaseCreateArgs, releaseUploadArgs, +} from '../forge.ts'; +import type { ToolContext } from '@hmharness/kernel'; + +const CTX: ToolContext = { cwd: process.cwd(), home: join(tmpdir(), 'forge-test-home') }; + +// ---------------- pure functions ---------------- + +test('quoteArg hardens shell metacharacters per platform', () => { + assert.equal(quoteArg('plain-arg_1.2', 'win32'), 'plain-arg_1.2'); + assert.equal(quoteArg('plain-arg_1.2', 'linux'), 'plain-arg_1.2'); + assert.equal(quoteArg('two words', 'win32'), '"two words"'); + assert.equal(quoteArg('say "hi"', 'win32'), '"say ""hi"""'); + assert.equal(quoteArg('a&b|c', 'win32'), '"a&b|c"'); + assert.equal(quoteArg('', 'win32'), '""'); + assert.equal(quoteArg("it's $HOME", 'linux'), "'it'\\''s $HOME'"); + assert.equal(quoteArg('two words', 'linux'), "'two words'"); + // backslash paths without spaces stay untouched on win32 (cmd passes them raw) + assert.equal(quoteArg('G:\\dir\\sub', 'win32'), 'G:\\dir\\sub'); +}); + +test('parseAuthStatus reads both CLI phrasings', () => { + assert.deepEqual(parseAuthStatus('github.com\n ✓ Logged in to github.com account swsgbl (keyring)'), { loggedIn: true, user: 'swsgbl' }); + assert.deepEqual(parseAuthStatus('✓ Logged in to atomgit.com as hongfu\n Token: zKxm****1Z3g'), { loggedIn: true, user: 'hongfu' }); + assert.deepEqual(parseAuthStatus('You are not logged into any GitHub hosts. Run gh auth login.'), { loggedIn: false }); + assert.deepEqual(parseAuthStatus(''), { loggedIn: false }); +}); + +test('argument builders: gh field-list JSON + -R selection vs ag positional + boolean JSON', () => { + assert.deepEqual(repoListArgs('github'), ['repo', 'list', '--limit', '30', '--json', 'name,visibility,updatedAt,pushedAt']); + assert.deepEqual(repoListArgs('github', 'myorg', 2), ['repo', 'list', 'myorg', '--limit', '60', '--json', 'name,visibility,updatedAt,pushedAt']); + assert.deepEqual(repoListArgs('atomgit'), ['repo', 'list', '--limit', '30', '--json']); + assert.deepEqual(repoCreateArgs('github', 'demo', 'private', 'a demo'), ['repo', 'create', 'demo', '--private', '--description', 'a demo']); + assert.deepEqual(repoCreateArgs('atomgit', 'demo', 'public'), ['repo', 'create', 'demo', '--public']); + assert.deepEqual(prListArgs('github', 'o/r', 'all'), ['pr', 'list', '-R', 'o/r', '--state', 'all', '--limit', '30', '--json', 'number,title,state,author,headRefName,url']); + assert.deepEqual(prListArgs('atomgit', 'o/r'), ['pr', 'list', 'o/r', '--state', 'open', '--limit', '30', '--json']); + assert.deepEqual(prCreateArgs('github', 'o/r', 'feat/x', 'main', 'T', 'B'), ['pr', 'create', '-R', 'o/r', '--head', 'feat/x', '--base', 'main', '--title', 'T', '--body', 'B']); + assert.deepEqual(prCreateArgs('atomgit', 'o/r', 'feat/x', 'main', 'T'), ['pr', 'create', 'o/r', '--head', 'feat/x', '--base', 'main', '--title', 'T']); + assert.deepEqual(prMergeArgs('github', 'o/r', '38', 'squash'), ['pr', 'merge', '38', '-R', 'o/r', '--squash']); + assert.deepEqual(prMergeArgs('github', 'o/r', '38'), ['pr', 'merge', '38', '-R', 'o/r', '--merge']); + assert.deepEqual(prMergeArgs('atomgit', 'o/r', '38', 'rebase'), ['pr', 'merge', 'o/r', '38', '--rebase']); + assert.deepEqual(prChecksArgs('github', 'o/r', '38'), ['pr', 'checks', '38', '-R', 'o/r', '--json', 'name,state']); + assert.deepEqual(prChecksArgs('atomgit', 'o/r', '38'), ['pr', 'checks', 'o/r', '38']); + assert.deepEqual(issueListArgs('github', 'o/r', 'closed'), ['issue', 'list', '-R', 'o/r', '--state', 'closed', '--limit', '30', '--json', 'number,title,state,author,url']); + assert.deepEqual(issueCreateArgs('github', 'o/r', 'broken', 'steps'), ['issue', 'create', '-R', 'o/r', '--title', 'broken', '--body', 'steps']); + // release wire difference: gh --title/--notes vs ag --name/--body (+body required) + assert.deepEqual(releaseCreateArgs('github', 'o/r', 'v1.0.0', 'One', 'notes', 'main'), ['release', 'create', 'v1.0.0', '-R', 'o/r', '--title', 'One', '--notes', 'notes', '--target', 'main']); + assert.deepEqual(releaseCreateArgs('atomgit', 'o/r', 'v1.0.0', 'One', 'notes'), ['release', 'create', 'o/r', 'v1.0.0', '--name', 'One', '--body', 'notes']); + assert.deepEqual(releaseUploadArgs('github', 'o/r', 'v1.0.0', './a.zip'), ['release', 'upload', 'v1.0.0', './a.zip', '-R', 'o/r']); + assert.deepEqual(releaseUploadArgs('atomgit', 'o/r', 'v1.0.0', './a.zip'), ['release', 'upload', 'o/r', 'v1.0.0', './a.zip']); +}); + +test('parseJsonOut tolerates banner lines and single objects; rejects garbage', () => { + assert.equal(parseJsonOut('[{"a":1}]')?.length, 1); + assert.equal(parseJsonOut('welcome to fake\n[{"a":1},{"b":2}]')?.length, 2); + assert.equal(parseJsonOut('{"name":"single"}')?.length, 1); + assert.equal(parseJsonOut('no json here'), null); +}); + +test('renderers compact gh objects and ag string-typed fields alike', () => { + const ghRepos = [{ name: 'r1', visibility: 'PRIVATE', updatedAt: '2026-10-01T00:00:00Z', pushedAt: '2026-10-01T01:00:00Z' }]; + assert.match(renderRepos(ghRepos), /^r1 \[private\] updated=2026-10-01T00:00:00Z pushed=2026-10-01T01:00:00Z$/); + const agRepos = [{ name: 'r2', fullName: 'u/r2', visibility: 'public', updatedAt: '2026-09-01T08:00:00.000+08:00' }]; + assert.match(renderRepos(agRepos), /^r2 \[public\] updated=2026-09-01T08:00:00\.000\+08:00 pushed=n\/a \(host does not report\)$/); + const ghPrs = [{ number: 38, title: 'fix', state: 'MERGED', author: { login: 'u' }, headRefName: 'fix/x', url: 'https://github.com/o/r/pull/38' }]; + assert.match(renderPrs(ghPrs), /^#38 \[MERGED\] fix — u \(fix\/x\) https:\/\/github\.com\/o\/r\/pull\/38$/); + const agPrs = [{ number: '169271', title: '优化', state: 'closed', author: 'xin', head: 'b1', url: 'https://atomgit.com/o/r/merge_requests/169271' }]; + assert.match(renderPrs(agPrs), /^#169271 \[closed\] 优化 — xin \(b1\) https:\/\/atomgit\.com/); + assert.equal(renderIssues([]), '(no issues found)'); +}); + +// ---------------- fake CLI seam ---------------- + +function fakeCore(bin: string, user: string): string { + return ` +const fs = require('node:fs'); +const path = require('node:path'); +const argv = process.argv.slice(2); +const dir = __dirname; +fs.appendFileSync(path.join(dir, '${bin}-argv.jsonl'), JSON.stringify(argv) + '\\n'); +if (argv[0] === 'auth' && argv[1] === 'status') { + const cf = path.join(dir, '${bin}-auth-calls'); + const n = fs.existsSync(cf) ? Number(fs.readFileSync(cf, 'utf8')) + 1 : 1; + fs.writeFileSync(cf, String(n)); + const logged = process.env.FORGE_FAKE_LOGGED_IN === '1' || n >= 3; + if (logged) { console.log('${bin === 'gh' ? " ✓ Logged in to github.com account fakeuser (keyring)" : " ✓ Logged in to atomgit.com as fakeag"}'); process.exit(0); } + console.error('You are not logged into any hosts. Run ${bin} auth login to authenticate.'); process.exit(1); +} +if (argv[0] === 'auth' && argv[1] === 'login') { + console.log('First copy your one-time code: ABCD-1234'); + console.log('Open this URL to continue in your web browser: https://${bin === 'gh' ? 'github.com' : 'atomgit.com'}/login/device'); + process.exit(0); +} +if (argv[0] === 'slow') { setTimeout(() => process.exit(0), 5000); return; } +if (argv[0] === 'repo' && argv[1] === 'list') { + console.log(JSON.stringify(${bin === 'gh' + ? `[{ name: 'fake-priv', visibility: 'PRIVATE', updatedAt: '2026-10-01T00:00:00Z', pushedAt: '2026-10-01T01:00:00Z' }, + { name: 'fake-pub', visibility: 'PUBLIC', updatedAt: '2026-10-02T00:00:00Z', pushedAt: '2026-10-02T01:00:00Z' }]` + : `[{ name: 'ag-repo', fullName: 'fakeag/ag-repo', visibility: 'private', updatedAt: '2026-09-01T08:00:00.000+08:00', owner: 'fakeag' }]`})); + process.exit(0); +} +if (argv[0] === 'pr' && argv[1] === 'list') { + console.log(JSON.stringify(${bin === 'gh' + ? `[{ number: 38, title: 'fix transport', state: 'MERGED', author: { login: 'swsgbl' }, headRefName: 'fix/x', url: 'https://github.com/fake/pull/38' }]` + : `[{ number: '169271', title: '优化生命周期注释', state: 'closed', author: 'xin11112', head: 'cherry-pick', base: 'main', url: 'https://atomgit.com/fake/merge_requests/169271' }]`})); + process.exit(0); +} +if (argv[0] === 'pr' && argv[1] === 'checks') { + ${bin === 'gh' + ? `console.log(JSON.stringify([{ name: 'verify', state: 'SUCCESS' }]));` + : `console.log('NAME STATUS ELAPSED'); console.log('build pass 1m02s');`}; + process.exit(0); +} +if (argv[0] === 'issue' && argv[1] === 'list') { + console.log(JSON.stringify([{ number: ${bin === 'gh' ? '7' : "'7'"}, title: 'boom on start', state: 'OPEN', author: ${bin === 'gh' ? "{ login: 'u1' }" : "'u1'"}, url: 'https://${bin === 'gh' ? 'github.com' : 'atomgit.com'}/fake/issues/7' }])); + process.exit(0); +} +console.log('FAKE-OK ' + argv.join(' ')); +`; +} + +interface FakeForge { + dir: string; + restore(): void; + /** last argv line the fake saw for this bin */ + lastArgs(bin: string): string[]; +} + +/** Create gh+ag fakes in a fresh temp dir, prepend them to PATH. */ +function installFakes(): FakeForge { + const dir = mkdtempSync(join(tmpdir(), 'forge-fake-')); + mkdirSync(dir, { recursive: true }); + for (const bin of ['gh', 'ag']) { + writeFileSync(join(dir, `${bin}-fake.cjs`), fakeCore(bin, bin === 'gh' ? 'github.com' : 'atomgit.com')); + if (process.platform === 'win32') { + // .cmd shim: exec() resolves PATH exactly like the user's cmd shell + writeFileSync(join(dir, `${bin}.cmd`), `@echo off\r\nnode "%~dp0${bin}-fake.cjs" %*\r\n`); + } else { + writeFileSync(join(dir, bin), `#!/bin/sh\nexec node "$(dirname "$0")/${bin}-fake.cjs" "$@"\n`); + chmodSync(join(dir, bin), 0o755); + } + } + const prevPath = process.env.PATH; + process.env.PATH = dir + delimiter + prevPath; + return { + dir, + restore() { process.env.PATH = prevPath; }, + lastArgs(bin: string) { + try { return JSON.parse(readFileSync(join(dir, `${bin}-argv.jsonl`), 'utf8').trim().split('\n').pop() ?? '[]'); } + catch { return []; } + }, + }; +} + +function fakeCtx(): ToolContext { return CTX; } + +async function tool(name: string): Promise<{ execute: (args: Record, ctx: ToolContext) => Promise<{ output: string; isError?: boolean }>; needsApproval?: (a: Record, c?: ToolContext) => boolean }> { + const t = forgeTools.find((x) => x.name === name); + assert.ok(t, `tool ${name} registered`); + return t as never; +} + +// ---------------- detection + tools via the seam ---------------- + +test('forgeDetect reports install + login for both hosts through the fake seam', async () => { + const fake = installFakes(); + process.env.FORGE_FAKE_LOGGED_IN = '1'; + try { + const rows = await forgeDetect(); + assert.equal(rows.length, 2); + assert.deepEqual(rows.map((r) => r.host), ['github', 'atomgit']); + assert.ok(rows.every((r) => r.installed && r.loggedIn)); + assert.equal(rows[0].user, 'fakeuser'); + assert.equal(rows[1].user, 'fakeag'); + const status = await (await tool('forge_status')).execute({}, fakeCtx()); + assert.match(status.output, /github: installed \(gh\), logged in as fakeuser/); + assert.match(status.output, /atomgit: installed \(ag\), logged in as fakeag/); + assert.equal(status.isError, undefined); + } finally { + delete process.env.FORGE_FAKE_LOGGED_IN; + fake.restore(); + } +}); + +test('forge_repo_list (github): exact args, parsed JSON, compact lines', async () => { + const fake = installFakes(); + process.env.FORGE_FAKE_LOGGED_IN = '1'; + try { + const r = await (await tool('forge_repo_list')).execute({ host: 'github' }, fakeCtx()); + assert.equal(r.isError, undefined); + assert.match(r.output, /fake-priv \[private\] updated=2026-10-01T00:00:00Z pushed=2026-10-01T01:00:00Z/); + assert.match(r.output, /fake-pub \[public\]/); + assert.deepEqual(fake.lastArgs('gh'), ['repo', 'list', '--limit', '30', '--json', 'name,visibility,updatedAt,pushedAt']); + } finally { + delete process.env.FORGE_FAKE_LOGGED_IN; + fake.restore(); + } +}); + +test('forge_repo_list (atomgit): boolean --json, honest missing pushedAt', async () => { + const fake = installFakes(); + process.env.FORGE_FAKE_LOGGED_IN = '1'; + try { + const r = await (await tool('forge_repo_list')).execute({ host: 'atomgit', owner: 'fakeag', page: 1 }, fakeCtx()); + assert.equal(r.isError, undefined); + assert.match(r.output, /ag-repo \[private\] updated=2026-09-01T08:00:00\.000\+08:00 pushed=n\/a \(host does not report\)/); + assert.deepEqual(fake.lastArgs('ag'), ['repo', 'list', 'fakeag', '--limit', '30', '--json']); + } finally { + delete process.env.FORGE_FAKE_LOGGED_IN; + fake.restore(); + } +}); + +test('forge_pr_list + forge_pr_checks across both hosts', async () => { + const fake = installFakes(); + process.env.FORGE_FAKE_LOGGED_IN = '1'; + try { + const prGh = await (await tool('forge_pr_list')).execute({ host: 'github', repo: 'swsgbl/hmharness', state: 'all' }, fakeCtx()); + assert.match(prGh.output, /#38 \[MERGED\] fix transport — swsgbl \(fix\/x\) https:\/\/github\.com\/fake\/pull\/38/); + const prAg = await (await tool('forge_pr_list')).execute({ host: 'atomgit', repo: 'o/r' }, fakeCtx()); + assert.match(prAg.output, /#169271 \[closed\] 优化生命周期注释 — xin11112 \(cherry-pick\)/); + const checksGh = await (await tool('forge_pr_checks')).execute({ host: 'github', repo: 'swsgbl/hmharness', num: '38' }, fakeCtx()); + assert.match(checksGh.output, /verify: SUCCESS/); + const checksAg = await (await tool('forge_pr_checks')).execute({ host: 'atomgit', repo: 'o/r', num: '9' }, fakeCtx()); + assert.match(checksAg.output, /build\s+pass/); // ag human table passed through as-is + } finally { + delete process.env.FORGE_FAKE_LOGGED_IN; + fake.restore(); + } +}); + +test('write tools pass assembled args through (create/merge/release)', async () => { + const fake = installFakes(); + process.env.FORGE_FAKE_LOGGED_IN = '1'; + try { + const mk = await (await tool('forge_repo_create')).execute({ host: 'github', name: 'demo', visibility: 'private', description: 'a demo' }, fakeCtx()); + assert.match(mk.output, /FAKE-OK repo create demo --private --description a demo/); + assert.deepEqual(fake.lastArgs('gh'), ['repo', 'create', 'demo', '--private', '--description', 'a demo']); + const pr = await (await tool('forge_pr_create')).execute({ host: 'atomgit', repo: 'o/r', head: 'feat/x', base: 'main', title: 'T it works', body: 'b' }, fakeCtx()); + assert.ok(pr.output.includes('FAKE-OK'), 'pr create hit the fake'); + // spaced title survives as ONE argv entry (quoteArg) + assert.equal(fake.lastArgs('ag').filter((a) => a === 'T it works').length, 1); + const rel = await (await tool('forge_release_create')).execute({ host: 'atomgit', repo: 'o/r', tag: 'v1.0.0', name: 'One', body: 'notes text' }, fakeCtx()); + assert.match(rel.output, /FAKE-OK release create o\/r v1\.0\.0 --name One --body notes text/); + const emptyBody = await (await tool('forge_release_create')).execute({ host: 'github', repo: 'o/r', tag: 'v1', name: 'N', body: '' }, fakeCtx()); + assert.equal(emptyBody.isError, true); // empty release notes refused before the CLI + // refused locally: the last gh argv the fake saw is the login guard's auth status, not a release + assert.equal(fake.lastArgs('gh')[0], 'auth'); + } finally { + delete process.env.FORGE_FAKE_LOGGED_IN; + fake.restore(); + } +}); + +test('forge_raw passes arguments through verbatim and is approval-gated', async () => { + const fake = installFakes(); + process.env.FORGE_FAKE_LOGGED_IN = '1'; + const raw = await tool('forge_raw'); + try { + assert.equal(raw.needsApproval?.({ host: 'github', args: ['api', 'user'] }, CTX), true); + const r = await raw.execute({ host: 'github', args: ['api', 'user'] }, fakeCtx()); + assert.match(r.output, /\[gh api user\]/); + assert.deepEqual(fake.lastArgs('gh'), ['api', 'user']); + const bad = await raw.execute({ host: 'github' }, fakeCtx()); + assert.equal(bad.isError, true); + const badHost = await raw.execute({ host: 'gitlab', args: ['x'] }, fakeCtx()); + assert.match(badHost.output, /host must be 'github' or 'atomgit'/); + } finally { + delete process.env.FORGE_FAKE_LOGGED_IN; + fake.restore(); + } +}); + +test('not logged in → honest login guidance, no CLI call', async () => { + const fake = installFakes(); // fresh dir: auth counter at 1, FORGE_FAKE_LOGGED_IN unset + try { + const r = await (await tool('forge_repo_list')).execute({ host: 'github' }, fakeCtx()); + assert.equal(r.isError, true); + assert.match(r.output, /Not logged in to github/); + assert.match(r.output, /forge_login/); + assert.match(r.output, /`gh auth login`/); + // the list command itself never ran + assert.ok(!fake.lastArgs('gh').includes('list')); + } finally { + fake.restore(); + } +}); + +test('not installed → honest install hint (winget / npm) without shell mojibake', async () => { + // strip fake+real forge dirs from PATH, keep the shell itself alive + const keep = process.platform === 'win32' ? 'C:\\Windows\\System32' : '/usr/bin:/bin'; + const prevPath = process.env.PATH; + process.env.PATH = keep; + try { + const gh = await forgeDetectHost('github'); + assert.equal(gh.installed, false); + const status = await (await tool('forge_status')).execute({}, fakeCtx()); + assert.match(status.output, /github: NOT installed — .*winget install GitHub\.cli/); + assert.match(status.output, /atomgit: NOT installed — .*npm install -g @hust-open-atom-club\/atomgit-cli/); + const r = await forgeRun('github', ['repo', 'list']); + assert.equal(r.ok, false); + assert.match(r.output, /gh \(GitHub CLI\) is not installed/); + } finally { + process.env.PATH = prevPath; + } +}); + +test('forgeRun times out honestly', async () => { + const fake = installFakes(); + process.env.FORGE_FAKE_LOGGED_IN = '1'; + try { + const r = await forgeRun('github', ['slow'], { timeoutMs: 800 }); + assert.equal(r.ok, false); + assert.match(r.output, /timed out after 800ms/); + } finally { + delete process.env.FORGE_FAKE_LOGGED_IN; + fake.restore(); + } +}); + +test('forge_login polls the flip (3rd auth status call) and reports the device code', async () => { + const fake = installFakes(); // auth status: call 1 not logged in, call 3 logged in + try { + const r = await forgeLogin('github', { pollMs: 80, timeoutMs: 20_000 }); + assert.equal(r.isError, undefined, r.output); + assert.match(r.output, /login complete: github as fakeuser/); + assert.match(r.output, /device code: ABCD-1234/); + assert.match(r.output, /github\.com\/login\/device/); + // the web-login subprocess was really started with gh's device-flow flags + const dump = readFileSync(join(fake.dir, 'gh-argv.jsonl'), 'utf8').trim().split('\n').map((l) => JSON.parse(l)); + assert.ok(dump.some((a) => a[0] === 'auth' && a[1] === 'login' && a.includes('--web') && a.includes('-h') && a.includes('github.com') && a.includes('--skip-ssh-key'))); + // already-logged-in short-circuit + const again = await forgeLogin('github', { pollMs: 80, timeoutMs: 1_000 }); + assert.match(again.output, /already logged in to github as fakeuser/); + } finally { + fake.restore(); + } +}); + +test('forge tool family: 13 tools, only forge_raw approval-gated', () => { + const names = forgeTools.map((t) => t.name).sort(); + assert.deepEqual(names, [ + 'forge_issue_create', 'forge_issue_list', 'forge_login', 'forge_pr_checks', 'forge_pr_create', + 'forge_pr_list', 'forge_pr_merge', 'forge_raw', 'forge_release_create', 'forge_release_upload', + 'forge_repo_create', 'forge_repo_list', 'forge_status', + ]); + for (const t of forgeTools) { + const gated = t.needsApproval?.({}, CTX) === true; + assert.equal(gated, t.name === 'forge_raw', `${t.name} gating`); + } +}); + +// keep tmp scratch honest: the seam dirs are left for post-mortem inspection +// only within the OS temp root; nothing is written into the repo. +void appendFileSync; void mkdirSync; void writeFileSync; void rmSync; diff --git a/packages/agent/src/capability.ts b/packages/agent/src/capability.ts index d6355709..b05871bc 100644 --- a/packages/agent/src/capability.ts +++ b/packages/agent/src/capability.ts @@ -37,6 +37,10 @@ const RISK_RULES: Array<{ match: RegExp; risk: CapabilityRisk; permissions: stri { match: /^(harmony_emulator_(create|start|stop|delete))$/, risk: 'high', permissions: ['device.write', 'process.spawn'], network: true, sideEffects: ['device-mutation'] }, { match: /^(harmony_build|harmony_cjpm_build|harmony_cjpm_test|harmony_lint)$/, risk: 'medium', permissions: ['process.spawn', 'fs.write'], network: true, sideEffects: ['build-artifacts'] }, { match: /^spawn_agent$/, risk: 'high', permissions: ['agent.spawn'], network: false, sideEffects: ['subagent-run'] }, + // git forges (GitHub/AtomGit): reads are low; writes mutate remote VCS state. + // forge_raw is additionally approval-gated (needsApproval) — declaration here. + { match: /^forge_(repo|pr|issue|release)_(create|merge|upload)$|^forge_raw$/, risk: 'high', permissions: ['net.remote', 'fs.read'], network: true, sideEffects: ['remote-vcs'] }, + { match: /^forge_(repo|pr|issue)_list$|^forge_pr_checks$|^forge_status$/, risk: 'low', permissions: ['net.remote'], network: true, sideEffects: [] }, ]; export function manifestFor(tool: Tool): CapabilityManifest { diff --git a/packages/agent/src/forge.ts b/packages/agent/src/forge.ts new file mode 100644 index 00000000..88ea653b --- /dev/null +++ b/packages/agent/src/forge.ts @@ -0,0 +1,631 @@ +/** + * @hmharness/agent - git forge tools (GitHub / AtomGit) + * + * First-class repository operations on the user's forges, driven by the + * platforms' own first-party CLIs: gh (GitHub) and ag (AtomGit — a + * gh-shaped command surface). Zero new dependencies: shell call + JSON + * parse. Credentials NEVER enter hmharness state — they stay in gh's + * keyring/hosts.yml and ag's own config storage; we only read `auth + * status` text to report the login user. + * + * Wire differences absorbed here (builders are pure + exported so tests + * assert exact argument assembly without a forge): gh `--json` takes a + * field list, ag `--json` is a boolean dump; gh selects repos with + * `-R owner/repo`, ag takes owner/repo positionally; gh release notes + * are `--title/--notes`, ag's are `--name/--body`; ag numbers arrive as + * strings. All commands run through exec() with manual quoting because + * ag installs as `ag.cmd` (npm global) — execFile cannot spawn .cmd on + * Windows (ENOENT/EINVAL since the 2024 security patch), and exec() + * resolves PATH exactly like the user's shell, fake-CLI test seams + * included. + */ +import { exec } from 'node:child_process'; +import { isAbsolute, resolve } from 'node:path'; +import { promisify } from 'node:util'; +import type { Tool, ToolResult } from '@hmharness/kernel'; + +const execCb = promisify(exec); + +export type ForgeHost = 'github' | 'atomgit'; + +export const FORGE_BINS: Record = { github: 'gh', atomgit: 'ag' }; + +const PAGE_SIZE = 30; +const MAX_PAGE = 10; +const RUN_TIMEOUT_MS = 60_000; + +export function installHint(host: ForgeHost): string { + return host === 'github' + ? 'gh (GitHub CLI) is not installed. Install it first: `winget install GitHub.cli` on Windows, `brew install gh` on macOS — https://cli.github.com' + : 'ag (AtomGit CLI) is not installed. Install it first: `npm install -g @hust-open-atom-club/atomgit-cli` — https://atomgit.com/hust-open-atom-club/atomgit-cli'; +} + +export function loginHint(host: ForgeHost): string { + const bin = FORGE_BINS[host]; + return `Not logged in to ${host}. Run the forge_login tool with host:"${host}" (it shows the device code/URL), or manually: \`${bin} auth login\``; +} + +/** Shell-quote one argument. Windows: cmd.exe rules (wrap in "...", double + * embedded quotes; a literal % inside quotes still expands — accepted as a + * cmd.exe limitation, forge inputs rarely contain %). POSIX: single quotes + * with the '\'' escape. (Pure, testable.) */ +export function quoteArg(s: string, platform: string = process.platform): string { + if (s === '') return '""'; + if (platform === 'win32') { + if (!/[\s"&|<>^()%!]/.test(s)) return s; + return '"' + s.replace(/"/g, '""') + '"'; + } + if (!/[\s'"$`\\&|;<>()*?\[\]#!]/.test(s)) return s; + return "'" + s.replace(/'/g, "'\\''") + "'"; +} + +/** Parse `auth status` text for BOTH CLIs' phrasings: + * gh: "✓ Logged in to github.com account swsgbl (keyring)" + * ag: "✓ Logged in to atomgit.com as hongfu" */ +export function parseAuthStatus(text: string): { loggedIn: boolean; user?: string } { + if (!/Logged in to/.test(text)) return { loggedIn: false }; + const m = /Logged in to \S+ (?:account|as) (\S+?)[\s(\r]/.exec(text) ?? /Logged in to \S+ (?:account|as) (\S+)/.exec(text); + return { loggedIn: true, user: m?.[1] }; +} + +function str(v: unknown): string { return v === undefined || v === null ? '' : String(v); } + +// ---------------- pure command builders ---------------- + +export function repoListArgs(host: ForgeHost, owner?: string, page = 1): string[] { + // neither CLI supports --page/--offset: page N fetches N*30 rows and the + // runner slices the window out (honest paging for N<=10 without --paginate) + const p = pageOf(page); + return [ + 'repo', 'list', + ...(owner ? [owner] : []), + '--limit', String(p * PAGE_SIZE), + ...(host === 'github' ? ['--json', 'name,visibility,updatedAt,pushedAt'] : ['--json']), + ]; +} + +function pageOf(page: number): number { + const p = Math.floor(Number(page) || 1); + return Math.max(1, Math.min(p, MAX_PAGE)); +} + +export function repoCreateArgs(host: ForgeHost, name: string, visibility: 'public' | 'private', description?: string): string[] { + const desc = description ? [host === 'github' ? '--description' : '-d', description] : []; + return ['repo', 'create', name, visibility === 'private' ? '--private' : '--public', ...desc]; +} + +export function prListArgs(host: ForgeHost, repo: string, state = 'open'): string[] { + const s = ['open', 'closed', 'all'].includes(state) ? state : 'open'; + return host === 'github' + ? ['pr', 'list', '-R', repo, '--state', s, '--limit', '30', '--json', 'number,title,state,author,headRefName,url'] + : ['pr', 'list', repo, '--state', s, '--limit', '30', '--json']; +} + +export function prCreateArgs(host: ForgeHost, repo: string, head: string, base: string, title: string, body?: string): string[] { + const b = body ? ['--body', body] : []; + const sel = host === 'github' ? ['-R', repo] : [repo]; + return ['pr', 'create', ...sel, '--head', head, '--base', base, '--title', title, ...b]; +} + +export function prMergeArgs(host: ForgeHost, repo: string, num: string, method: 'merge' | 'squash' | 'rebase' = 'merge'): string[] { + if (host === 'github') { + const flag = method === 'squash' ? '--squash' : method === 'rebase' ? '--rebase' : '--merge'; + return ['pr', 'merge', num, '-R', repo, flag]; + } + // ag merges with the default method unless a flag says otherwise + const flag = method === 'squash' ? ['--squash'] : method === 'rebase' ? ['--rebase'] : []; + return ['pr', 'merge', repo, num, ...flag]; +} + +export function prChecksArgs(host: ForgeHost, repo: string, num: string): string[] { + return host === 'github' + ? ['pr', 'checks', num, '-R', repo, '--json', 'name,state'] + : ['pr', 'checks', repo, num]; +} + +export function issueListArgs(host: ForgeHost, repo: string, state = 'open'): string[] { + const s = ['open', 'closed', 'all'].includes(state) ? state : 'open'; + return host === 'github' + ? ['issue', 'list', '-R', repo, '--state', s, '--limit', '30', '--json', 'number,title,state,author,url'] + : ['issue', 'list', repo, '--state', s, '--limit', '30', '--json']; +} + +export function issueCreateArgs(host: ForgeHost, repo: string, title: string, body?: string): string[] { + const b = body ? ['--body', body] : []; + const sel = host === 'github' ? ['-R', repo] : [repo]; + return ['issue', 'create', ...sel, '--title', title, ...b]; +} + +export function releaseCreateArgs(host: ForgeHost, repo: string, tag: string, name: string, body: string, target?: string): string[] { + const t = target ? ['--target', target] : []; + return host === 'github' + ? ['release', 'create', tag, '-R', repo, '--title', name, '--notes', body, ...t] + : ['release', 'create', repo, tag, '--name', name, '--body', body, ...t]; // AtomGit API requires a non-empty body +} + +export function releaseUploadArgs(host: ForgeHost, repo: string, tag: string, file: string): string[] { + return host === 'github' + ? ['release', 'upload', tag, file, '-R', repo] + : ['release', 'upload', repo, tag, file]; +} + +// ---------------- detection + execution ---------------- + +export interface ForgeHostStatus { + host: ForgeHost; + bin: string; + installed: boolean; + loggedIn: boolean; + user?: string; +} + +/** where/which probe — goes through the shell so PATH seams (real or fake) apply. */ +export async function forgeBinInstalled(host: ForgeHost): Promise { + const bin = FORGE_BINS[host]; + const probe = process.platform === 'win32' ? `where ${bin}` : `command -v ${bin}`; + try { await execCb(probe, { timeout: 10_000, windowsHide: true }); return true; } catch { return false; } +} + +export async function forgeDetectHost(host: ForgeHost): Promise { + const bin = FORGE_BINS[host]; + if (!(await forgeBinInstalled(host))) return { host, bin, installed: false, loggedIn: false }; + try { + const { stdout, stderr } = await execCb(`${bin} auth status`, { timeout: 15_000, windowsHide: true }); + const p = parseAuthStatus(stdout + '\n' + stderr); + return { host, bin, installed: true, ...p }; + } catch (err) { + // both CLIs exit non-zero when logged out but still print their status + const e = err as { stdout?: string; stderr?: string }; + const p = parseAuthStatus([e.stdout, e.stderr].filter(Boolean).join('\n')); + return { host, bin, installed: true, ...p }; + } +} + +/** Detect both forges in parallel (forge_status / guidance basis). */ +export async function forgeDetect(): Promise { + return Promise.all((['github', 'atomgit'] as ForgeHost[]).map(forgeDetectHost)); +} + +/** Run one forge CLI command (quoted, 60s timeout, agent cwd). Installed-check + * first so a missing CLI yields the install hint, not cmd.exe mojibake. + * (exec instead of execFile is deliberate: ag installs as ag.cmd, which + * execFile cannot spawn on Windows since the 2024 security patch; quoteArg + * hardens every shell metacharacter, so the injection surface is strictly + * smaller than run_command's free-form line.) */ +export async function forgeRun(host: ForgeHost, args: string[], opts: { timeoutMs?: number; cwd?: string } = {}): Promise<{ ok: boolean; output: string }> { + const bin = FORGE_BINS[host]; + if (!(await forgeBinInstalled(host))) return { ok: false, output: installHint(host) }; + const line = [bin, ...args.map((a) => quoteArg(String(a)))].join(' '); + try { + const { stdout, stderr } = await execCb(line, { + cwd: opts.cwd, timeout: opts.timeoutMs ?? RUN_TIMEOUT_MS, windowsHide: true, maxBuffer: 8 * 1024 * 1024, + }); + const out = (stdout || '').trim() + (stderr && stderr.trim() ? `\n[stderr]\n${stderr.trim()}` : ''); + return { ok: true, output: out.trim() || '(no output)' }; + } catch (err) { + const e = err as { stdout?: string; stderr?: string; message?: string; killed?: boolean }; + if (e.killed) return { ok: false, output: `${bin} command timed out after ${opts.timeoutMs ?? RUN_TIMEOUT_MS}ms — narrow it (add --limit, a smaller page) and retry.` }; + const out = [e.stdout, e.stderr, e.message].filter(Boolean).map((s) => String(s).trim()).filter(Boolean).join('\n'); + return { ok: false, output: out.slice(0, 20_000) || `${bin} failed (no output)` }; + } +} + +/** Guard shared by every forge tool except status/login/raw: valid host, + * CLI installed, logged in — or the honest, actionable error. */ +async function guard(host: ForgeHost): Promise { + const st = await forgeDetectHost(host); + if (!st.installed) return { output: installHint(host), isError: true }; + if (!st.loggedIn) return { output: loginHint(host), isError: true }; + return null; +} + +function hostOf(args: Record): { host?: ForgeHost; err?: ToolResult } { + const h = String(args.host ?? ''); + if (h === 'github' || h === 'atomgit') return { host: h }; + return { err: { output: `host must be 'github' or 'atomgit' (got '${h}')`, isError: true } }; +} + +// ---------------- renderers (forge JSON -> compact lines) ---------------- + +/** Tolerant JSON-array extraction: finds the first [ or { (both CLIs may + * print a banner line before the payload), wraps single objects. */ +export function parseJsonOut(text: string): unknown[] | null { + const idxs = ['[', '{'].map((c) => text.indexOf(c)).filter((i) => i >= 0); + if (!idxs.length) return null; + try { + const v = JSON.parse(text.slice(Math.min(...idxs))); + return Array.isArray(v) ? v : [v]; + } catch { return null; } +} + +export function renderRepos(rows: unknown[]): string { + const lines = rows.map((r) => { + const o = r as Record; + const name = str(o.name) || str(o.fullName) || '?'; + const vis = (str(o.visibility) || (o.isPrivate ? 'private' : '')).toLowerCase(); + // AtomGit's repo payload has no pushedAt — say so instead of inventing one + const pushed = o.pushedAt ? str(o.pushedAt) : 'n/a (host does not report)'; + return `${name} [${vis.includes('priv') ? 'private' : 'public'}] updated=${str(o.updatedAt) || '?'} pushed=${pushed}`; + }); + return lines.length ? lines.join('\n') : '(no repositories found)'; +} + +export function renderPrs(rows: unknown[]): string { + const lines = rows.map((r) => { + const o = r as Record; + const author = typeof o.author === 'object' && o.author ? str((o.author as Record).login) : str(o.author); + const head = str(o.headRefName) || str(o.head); + return `#${str(o.number)} [${str(o.state) || '?'}] ${str(o.title)} — ${author || '?'}${head ? ` (${head})` : ''} ${str(o.url)}`.trimEnd(); + }); + return lines.length ? lines.join('\n') : '(no pull requests found)'; +} + +export function renderIssues(rows: unknown[]): string { + const lines = rows.map((r) => { + const o = r as Record; + const author = typeof o.author === 'object' && o.author ? str((o.author as Record).login) : str(o.author); + return `#${str(o.number)} [${str(o.state) || '?'}] ${str(o.title)} — ${author || '?'} ${str(o.url)}`.trimEnd(); + }); + return lines.length ? lines.join('\n') : '(no issues found)'; +} + +// ---------------- tools ---------------- + +export const forgeStatusTool: Tool = { + name: 'forge_status', + description: 'Show GitHub (gh) and AtomGit (ag) CLI install + login status at a glance. Call this FIRST for any repository task on a forge.', + parameters: { type: 'object', properties: {}, required: [] }, + async execute(): Promise { + const rows = await forgeDetect(); + const lines = rows.map((s) => { + if (!s.installed) return `${s.host}: NOT installed — ${installHint(s.host)}`; + if (!s.loggedIn) return `${s.host}: installed (${s.bin}), NOT logged in — ${loginHint(s.host)}`; + return `${s.host}: installed (${s.bin}), logged in as ${s.user ?? '(user unknown)'}`; + }); + return { output: lines.join('\n') }; + }, +}; + +export const forgeRepoListTool: Tool = { + name: 'forge_repo_list', + description: 'List repositories on a forge — your own, or an owner/org\'s. host: github|atomgit; optional owner, page (30/page, max 10).', + parameters: { + type: 'object', + properties: { + host: { type: 'string', description: "'github' or 'atomgit'" }, + owner: { type: 'string', description: 'owner/org login (default: the logged-in account)' }, + page: { type: 'number', description: '1-based page of 30 (max 10)' }, + }, + required: ['host'], + }, + async execute(args, ctx): Promise { + const { host, err } = hostOf(args); + if (!host || err) return err!; + const g = await guard(host); + if (g) return g; + const page = pageOf(Number(args.page ?? 1)); + const r = await forgeRun(host, repoListArgs(host, args.owner ? String(args.owner) : undefined, page), { cwd: ctx.cwd }); + if (!r.ok) return { output: r.output, isError: true }; + const rows = parseJsonOut(r.output); + if (!rows) return { output: `could not parse ${host} repo list JSON — raw output:\n${r.output.slice(0, 4_000)}`, isError: true }; + const win = rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + return { output: `${host} repositories (page ${page}${args.owner ? `, owner ${args.owner}` : ''}):\n${renderRepos(win)}` }; + }, +}; + +export const forgeRepoCreateTool: Tool = { + name: 'forge_repo_create', + description: 'Create a repository on a forge. host: github|atomgit; name (may be owner/name to create under an org); visibility public|private (default private); optional description.', + parameters: { + type: 'object', + properties: { + host: { type: 'string', description: "'github' or 'atomgit'" }, + name: { type: 'string', description: 'repository name (or owner/name)' }, + visibility: { type: 'string', description: "'public' or 'private' (default private)" }, + description: { type: 'string', description: 'one-line description' }, + }, + required: ['host', 'name'], + }, + async execute(args, ctx): Promise { + const { host, err } = hostOf(args); + if (!host || err) return err!; + const g = await guard(host); + if (g) return g; + const vis = String(args.visibility ?? 'private') === 'public' ? 'public' : 'private'; + const r = await forgeRun(host, repoCreateArgs(host, String(args.name ?? ''), vis, args.description ? String(args.description) : undefined), { cwd: ctx.cwd }); + return { output: `created ${vis} repo ${args.name} on ${host}:\n${r.output}`, isError: !r.ok }; + }, +}; + +export const forgePrListTool: Tool = { + name: 'forge_pr_list', + description: 'List pull requests of owner/repo on a forge. host: github|atomgit; repo owner/repo; state open|closed|all (default open).', + parameters: { + type: 'object', + properties: { + host: { type: 'string', description: "'github' or 'atomgit'" }, + repo: { type: 'string', description: 'owner/repo' }, + state: { type: 'string', description: 'open | closed | all (default open)' }, + }, + required: ['host', 'repo'], + }, + async execute(args, ctx): Promise { + const { host, err } = hostOf(args); + if (!host || err) return err!; + const g = await guard(host); + if (g) return g; + const r = await forgeRun(host, prListArgs(host, String(args.repo ?? ''), String(args.state ?? 'open')), { cwd: ctx.cwd }); + if (!r.ok) return { output: r.output, isError: true }; + const rows = parseJsonOut(r.output); + if (!rows) return { output: `could not parse ${host} pr list JSON — raw output:\n${r.output.slice(0, 4_000)}`, isError: true }; + return { output: `${host} pull requests (${args.repo}, ${String(args.state ?? 'open')}):\n${renderPrs(rows)}` }; + }, +}; + +export const forgePrCreateTool: Tool = { + name: 'forge_pr_create', + description: 'Create a pull request. host: github|atomgit; repo owner/repo; head branch; base branch; title; optional body. Branch must already be pushed.', + parameters: { + type: 'object', + properties: { + host: { type: 'string', description: "'github' or 'atomgit'" }, + repo: { type: 'string', description: 'owner/repo' }, + head: { type: 'string', description: 'source branch (e.g. feat/x)' }, + base: { type: 'string', description: 'target branch (e.g. main)' }, + title: { type: 'string', description: 'PR title' }, + body: { type: 'string', description: 'PR description (markdown)' }, + }, + required: ['host', 'repo', 'head', 'base', 'title'], + }, + async execute(args, ctx): Promise { + const { host, err } = hostOf(args); + if (!host || err) return err!; + const g = await guard(host); + if (g) return g; + const r = await forgeRun(host, prCreateArgs(host, String(args.repo ?? ''), String(args.head ?? ''), String(args.base ?? ''), String(args.title ?? ''), args.body ? String(args.body) : undefined), { cwd: ctx.cwd }); + return { output: `pull request created on ${host}:\n${r.output}`, isError: !r.ok }; + }, +}; + +export const forgePrMergeTool: Tool = { + name: 'forge_pr_merge', + description: 'Merge a pull request. host: github|atomgit; repo owner/repo; num PR number; method merge|squash|rebase (default merge). Irreversible — confirm with the user first.', + parameters: { + type: 'object', + properties: { + host: { type: 'string', description: "'github' or 'atomgit'" }, + repo: { type: 'string', description: 'owner/repo' }, + num: { type: 'string', description: 'PR number' }, + method: { type: 'string', description: 'merge | squash | rebase (default merge)' }, + }, + required: ['host', 'repo', 'num'], + }, + async execute(args, ctx): Promise { + const { host, err } = hostOf(args); + if (!host || err) return err!; + const g = await guard(host); + if (g) return g; + const m = String(args.method ?? 'merge'); + const r = await forgeRun(host, prMergeArgs(host, String(args.repo ?? ''), String(args.num ?? ''), m === 'squash' ? 'squash' : m === 'rebase' ? 'rebase' : 'merge'), { cwd: ctx.cwd }); + return { output: r.output, isError: !r.ok }; + }, +}; + +export const forgePrChecksTool: Tool = { + name: 'forge_pr_checks', + description: 'Show CI checks/status for a pull request. host: github|atomgit; repo owner/repo; num PR number. Older ag builds without `pr checks` report it honestly.', + parameters: { + type: 'object', + properties: { + host: { type: 'string', description: "'github' or 'atomgit'" }, + repo: { type: 'string', description: 'owner/repo' }, + num: { type: 'string', description: 'PR number' }, + }, + required: ['host', 'repo', 'num'], + }, + async execute(args, ctx): Promise { + const { host, err } = hostOf(args); + if (!host || err) return err!; + const g = await guard(host); + if (g) return g; + const r = await forgeRun(host, prChecksArgs(host, String(args.repo ?? ''), String(args.num ?? '')), { cwd: ctx.cwd }); + if (r.ok) { + const rows = parseJsonOut(r.output); // gh --json name,state + const body = rows && rows.length && typeof (rows[0] as Record)?.name === 'string' + ? rows.map((x) => { const o = x as Record; return `${str(o.name)}: ${str(o.state)}`; }).join('\n') + : r.output; // ag prints a human table — pass it through as-is + return { output: `checks for ${host} PR #${args.num}:\n${body}` }; + } + return { + output: `${r.output}\n(If your ag version predates \`ag pr checks\` — added in atomgit-cli 0.7.x — the host genuinely does not expose checks here; tell the user that instead of guessing.)`, + isError: true, + }; + }, +}; + +export const forgeIssueListTool: Tool = { + name: 'forge_issue_list', + description: 'List issues of owner/repo on a forge. host: github|atomgit; repo owner/repo; state open|closed|all (default open).', + parameters: { + type: 'object', + properties: { + host: { type: 'string', description: "'github' or 'atomgit'" }, + repo: { type: 'string', description: 'owner/repo' }, + state: { type: 'string', description: 'open | closed | all (default open)' }, + }, + required: ['host', 'repo'], + }, + async execute(args, ctx): Promise { + const { host, err } = hostOf(args); + if (!host || err) return err!; + const g = await guard(host); + if (g) return g; + const r = await forgeRun(host, issueListArgs(host, String(args.repo ?? ''), String(args.state ?? 'open')), { cwd: ctx.cwd }); + if (!r.ok) return { output: r.output, isError: true }; + const rows = parseJsonOut(r.output); + if (!rows) return { output: `could not parse ${host} issue list JSON — raw output:\n${r.output.slice(0, 4_000)}`, isError: true }; + return { output: `${host} issues (${args.repo}, ${String(args.state ?? 'open')}):\n${renderIssues(rows)}` }; + }, +}; + +export const forgeIssueCreateTool: Tool = { + name: 'forge_issue_create', + description: 'Create an issue. host: github|atomgit; repo owner/repo; title; optional body.', + parameters: { + type: 'object', + properties: { + host: { type: 'string', description: "'github' or 'atomgit'" }, + repo: { type: 'string', description: 'owner/repo' }, + title: { type: 'string', description: 'issue title' }, + body: { type: 'string', description: 'issue body (markdown)' }, + }, + required: ['host', 'repo', 'title'], + }, + async execute(args, ctx): Promise { + const { host, err } = hostOf(args); + if (!host || err) return err!; + const g = await guard(host); + if (g) return g; + const r = await forgeRun(host, issueCreateArgs(host, String(args.repo ?? ''), String(args.title ?? ''), args.body ? String(args.body) : undefined), { cwd: ctx.cwd }); + return { output: `issue created on ${host}:\n${r.output}`, isError: !r.ok }; + }, +}; + +export const forgeReleaseCreateTool: Tool = { + name: 'forge_release_create', + description: 'Create a release from a tag. host: github|atomgit; repo owner/repo; tag; name (release title); body (release notes — required, AtomGit rejects empty); optional target branch/SHA when the tag does not exist yet.', + parameters: { + type: 'object', + properties: { + host: { type: 'string', description: "'github' or 'atomgit'" }, + repo: { type: 'string', description: 'owner/repo' }, + tag: { type: 'string', description: 'tag name, e.g. v1.2.0' }, + name: { type: 'string', description: 'release title' }, + body: { type: 'string', description: 'release notes (required)' }, + target: { type: 'string', description: 'target branch or commit SHA (creates the tag if absent)' }, + }, + required: ['host', 'repo', 'tag', 'name', 'body'], + }, + async execute(args, ctx): Promise { + const { host, err } = hostOf(args); + if (!host || err) return err!; + const g = await guard(host); + if (g) return g; + if (!String(args.body ?? '').trim()) return { output: 'body (release notes) is required — AtomGit rejects an empty body and gh would open an interactive editor.', isError: true }; + const r = await forgeRun(host, releaseCreateArgs(host, String(args.repo ?? ''), String(args.tag ?? ''), String(args.name ?? ''), String(args.body), args.target ? String(args.target) : undefined), { cwd: ctx.cwd }); + return { output: `release ${args.tag} created on ${host}:\n${r.output}`, isError: !r.ok }; + }, +}; + +export const forgeReleaseUploadTool: Tool = { + name: 'forge_release_upload', + description: 'Upload a file as an asset of an existing release. host: github|atomgit; repo owner/repo; tag; file path (must exist).', + parameters: { + type: 'object', + properties: { + host: { type: 'string', description: "'github' or 'atomgit'" }, + repo: { type: 'string', description: 'owner/repo' }, + tag: { type: 'string', description: 'release tag' }, + file: { type: 'string', description: 'local file path to upload' }, + }, + required: ['host', 'repo', 'tag', 'file'], + }, + async execute(args, ctx): Promise { + const { host, err } = hostOf(args); + if (!host || err) return err!; + const g = await guard(host); + if (g) return g; + const file = isAbsolute(String(args.file ?? '')) ? String(args.file ?? '') : resolve(ctx.cwd, String(args.file ?? '')); + try { await import('node:fs/promises').then((f) => f.access(file)); } + catch { return { output: `file not found: ${file}`, isError: true }; } + const r = await forgeRun(host, releaseUploadArgs(host, String(args.repo ?? ''), String(args.tag ?? ''), file), { cwd: ctx.cwd }); + return { output: r.output, isError: !r.ok }; + }, +}; + +export const forgeRawTool: Tool = { + name: 'forge_raw', + description: 'DANGEROUS: pass raw arguments straight to gh/ag (host selects the binary). Only for operations the dedicated forge_* tools cannot express. Approval-gated; YOLO/auto modes pass it through.', + parameters: { + type: 'object', + properties: { + host: { type: 'string', description: "'github' or 'atomgit'" }, + args: { type: 'array', items: { type: 'string' }, description: 'raw CLI arguments, e.g. ["api","user"]' }, + }, + required: ['host', 'args'], + }, + needsApproval: () => true, + async execute(args, ctx): Promise { + const { host, err } = hostOf(args); + if (!host || err) return err!; + if (!Array.isArray(args.args) || !args.args.length) return { output: 'args must be a non-empty array of strings', isError: true }; + const r = await forgeRun(host, args.args.map(String), { cwd: ctx.cwd }); + return { output: `[${FORGE_BINS[host]} ${args.args.map(String).join(' ')}]\n${r.output}`, isError: !r.ok }; + }, +}; + +// ---------------- login guidance ---------------- + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** Start the web login flow and poll auth status until it succeeds or the + * deadline passes. The device code / URL (gh prints them; ag opens the + * browser) go to stderr immediately so terminal frontends show them during + * the wait, and into the final result either way. */ +export async function forgeLogin(host: ForgeHost, opts: { pollMs?: number; timeoutMs?: number; startFlow?: boolean } = {}): Promise { + const bin = FORGE_BINS[host]; + const timeoutMs = opts.timeoutMs ?? 180_000; + const pollMs = opts.pollMs ?? 4_000; + const already = await forgeDetectHost(host); + if (!already.installed) return { output: installHint(host), isError: true }; + if (already.loggedIn) return { output: `already logged in to ${host} as ${already.user ?? '(user unknown)'} — nothing to do.` }; + + let early = ''; + if (opts.startFlow !== false) { + const flow = host === 'github' ? `${bin} auth login --web -h github.com --skip-ssh-key` : `${bin} auth login`; + const child = exec(flow, { timeout: timeoutMs + 30_000, windowsHide: true }, () => undefined); + let buf = ''; + const grab = (d: unknown) => { buf += String(d); }; + child.stdout?.on('data', grab); + child.stderr?.on('data', grab); + await sleep(Math.min(6_000, Math.max(pollMs * 2, 500))); // give the CLI a moment to print the code/URL + const code = /\b([A-Z0-9]{4,8}-[A-Z0-9]{4,8})\b/.exec(buf)?.[1]; + const url = /https:\/\/\S+/.exec(buf)?.[0]; + early = [code ? `device code: ${code}` : '', url ? `authorization URL: ${url}` : ''].filter(Boolean).join('\n'); + if (early) console.error(`[forge_login:${host}] ${early} — complete it in the browser; waiting for the login to land...`); + } + + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await sleep(pollMs); + const st = await forgeDetectHost(host); + if (st.loggedIn) return { output: `login complete: ${host} as ${st.user ?? '(user unknown)'}.${early ? `\n(${early})` : ''}` }; + } + return { + output: `login did not complete within ${Math.round(timeoutMs / 1000)}s.${early ? `\n${early}\nFinish it in the browser, then confirm with forge_status.` : `\nNo device code/URL was captured — start it manually: \`${bin} auth login\`, then confirm with forge_status.`}`, + isError: true, + }; +} + +export const forgeLoginTool: Tool = { + name: 'forge_login', + description: 'Guide the user through logging in to a forge (host: github|atomgit): starts `gh auth login --web` / `ag auth login` (the user completes it in the browser), shows the device code/URL, and polls until logged in (3 min timeout).', + parameters: { + type: 'object', + properties: { host: { type: 'string', description: "'github' or 'atomgit'" } }, + required: ['host'], + }, + async execute(args, ctx): Promise { + const { host, err } = hostOf(args); + if (!host || err) return err!; + return forgeLogin(host); + }, +}; + +export const forgeTools: Tool[] = [ + forgeStatusTool, forgeRepoListTool, forgeRepoCreateTool, + forgePrListTool, forgePrCreateTool, forgePrMergeTool, forgePrChecksTool, + forgeIssueListTool, forgeIssueCreateTool, + forgeReleaseCreateTool, forgeReleaseUploadTool, + forgeRawTool, forgeLoginTool, +]; diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 72ec17fe..265d05de 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -1,4 +1,12 @@ export { baseTools, readFileTool, writeFileTool, listDirTool, runCommandTool, rememberTool, seeImageTool } from './tools.ts'; +export { + forgeTools, forgeDetect, forgeDetectHost, forgeRun, forgeLogin, + quoteArg, parseAuthStatus, parseJsonOut, renderRepos, renderPrs, renderIssues, + repoListArgs, repoCreateArgs, prListArgs, prCreateArgs, prMergeArgs, prChecksArgs, + issueListArgs, issueCreateArgs, releaseCreateArgs, releaseUploadArgs, + FORGE_BINS, installHint, loginHint, + type ForgeHost, type ForgeHostStatus, +} from './forge.ts'; export { manifestFor, capabilityReport, authorize, type CapabilityManifest, type CapabilityRisk, type PolicyMode } from './capability.ts'; export { checkpointProject, createProject, findProject, interruptProject, listProjects, diff --git a/packages/agent/src/prompt.ts b/packages/agent/src/prompt.ts index db4550be..d1daef6c 100644 --- a/packages/agent/src/prompt.ts +++ b/packages/agent/src/prompt.ts @@ -96,6 +96,13 @@ export function buildSystemPrompt(opts: { '', 'HarmonyOS development is your home domain: DevEco Studio toolchain, hvigor builds, ohpm packages, hdc devices, ArkTS/ArkUI, OpenHarmony and Cangjie. When a task touches it, prefer the harmony_* tools and precise toolchain knowledge.', + // ---- Git forge 域(GitHub / AtomGit,2026-10-30 用户特性) ---- + '', + '## Git forges (GitHub / AtomGit)', + 'Repository operations on GitHub or AtomGit — listing/creating repos, PRs, issues, releases, checking CI — go through the forge_* tools (host: "github" → gh CLI, "atomgit" → ag CLI). Do NOT send the user to the website for anything a forge_* tool can do.', + 'Call forge_status first; when a host is not logged in, run forge_login for it (it shows the device code / URL the user must complete in the browser) — do not tell the user to run gh/ag commands by hand.', + 'Credentials live in gh/ag\'s own storage and never pass through you: never ask for or store a platform token yourself.', + // ---- 通用工作风格 ---- '', 'Working style: read before writing; prefer small focused commands; verify results; state tradeoffs briefly. For risky operations (deleting, overwriting, publishing) say what will happen first. When a command fails twice with the same error, switch strategy instead of repeating it. For multi-line/quoted logic, write a temp .cjs file and run it with node - never fight cmd.exe quoting with node -e one-liners.', diff --git a/packages/agent/src/runner.ts b/packages/agent/src/runner.ts index 0e85b469..8ebbbc7b 100644 --- a/packages/agent/src/runner.ts +++ b/packages/agent/src/runner.ts @@ -41,6 +41,7 @@ import { pageReadSink, lspObserveSink, observeBuildTools } from './code-wm-recor import * as readline from 'node:readline/promises'; import { stdin } from 'node:process'; import { baseTools } from './tools.ts'; +import { forgeTools } from './forge.ts'; import { buildSystemPrompt } from './prompt.ts'; import { strings } from './i18n.ts'; import { makeSpawnTool, MAX_SPAWN_DEPTH, type SpawnBase } from './spawn.ts'; @@ -61,7 +62,7 @@ export const spawnBase: { current?: SpawnBase } = {}; export function nativeRegistry(depth: number, opts: { lsp?: boolean; browser?: boolean; extension?: boolean; workspaceRoot?: string } = {}): Registry { const reg = new Registry(); - reg.registerAll(baseTools).registerAll(observeBuildTools(harmonyTools, homeDir())).registerAll(opsTools); + reg.registerAll(baseTools).registerAll(observeBuildTools(harmonyTools, homeDir())).registerAll(opsTools).registerAll(forgeTools); // W6: LSP Tier-0 code-intelligence tools — registered ONLY when a real // server is discoverable on PATH; a machine with no language servers // must not carry dead tools. Discovery is synchronous (memoized PATH From 942a5fdcc0e5d93a35a361e0d0181ae2f4e4bc7a Mon Sep 17 00:00:00 2001 From: wu <641600780@qq.com> Date: Fri, 30 Oct 2026 11:27:36 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(agent):=20forge=20=E5=86=99=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E5=B7=A5=E5=85=B7=E8=A1=A5=E5=AE=A1=E6=89=B9=E5=8D=A1?= =?UTF-8?q?=E2=80=94=E2=80=94=E5=AF=B9=E9=BD=90=20domain-ops/harmony=20?= =?UTF-8?q?=E5=86=99=E9=97=A8=E7=A6=81=E5=85=88=E4=BE=8B(=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E5=AE=A1=E6=9F=A5=E5=8A=A0=E5=9B=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - forge_repo_create/pr_create/pr_merge/issue_create/release_create/release_upload 全部加 needsApproval: () => true(工具描述里的'confirm first'不是执法机制; 仓库先例:domain-ops issues.ts 与 harmony 设备写操作全部设卡) - 读操作(list/status/checks)与 forge_login 保持免卡;YOLO/auto 模式照旧直通,流畅诉求不受影响 - forge_release_upload 描述明示上传=数据外传,审批卡面(makeApproval brief)完整显示 file 路径+目标 repo/tag;不做 cwd 硬 containment——agent 本有任意读面(read_file/run_command), 外传执法点在卡,与既有工具能力面一致 - forge 测试 gating 断言更新:7 个写工具全卡、6 个读/引导工具全免,16/16 绿;typecheck 绿 Co-Authored-By: Claude Code --- packages/agent/src/__tests__/forge.test.ts | 5 +++-- packages/agent/src/capability.ts | 4 ++-- packages/agent/src/forge.ts | 8 +++++++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/agent/src/__tests__/forge.test.ts b/packages/agent/src/__tests__/forge.test.ts index 5f801324..251126f0 100644 --- a/packages/agent/src/__tests__/forge.test.ts +++ b/packages/agent/src/__tests__/forge.test.ts @@ -356,16 +356,17 @@ test('forge_login polls the flip (3rd auth status call) and reports the device c } }); -test('forge tool family: 13 tools, only forge_raw approval-gated', () => { +test('forge tool family: 13 tools; every write gated, reads and login free', () => { const names = forgeTools.map((t) => t.name).sort(); assert.deepEqual(names, [ 'forge_issue_create', 'forge_issue_list', 'forge_login', 'forge_pr_checks', 'forge_pr_create', 'forge_pr_list', 'forge_pr_merge', 'forge_raw', 'forge_release_create', 'forge_release_upload', 'forge_repo_create', 'forge_repo_list', 'forge_status', ]); + const WRITES = new Set(['forge_repo_create', 'forge_pr_create', 'forge_pr_merge', 'forge_issue_create', 'forge_release_create', 'forge_release_upload', 'forge_raw']); for (const t of forgeTools) { const gated = t.needsApproval?.({}, CTX) === true; - assert.equal(gated, t.name === 'forge_raw', `${t.name} gating`); + assert.equal(gated, WRITES.has(t.name), `${t.name} gating (writes must ask, reads must not)`); } }); diff --git a/packages/agent/src/capability.ts b/packages/agent/src/capability.ts index b05871bc..0dcbc7ed 100644 --- a/packages/agent/src/capability.ts +++ b/packages/agent/src/capability.ts @@ -37,8 +37,8 @@ const RISK_RULES: Array<{ match: RegExp; risk: CapabilityRisk; permissions: stri { match: /^(harmony_emulator_(create|start|stop|delete))$/, risk: 'high', permissions: ['device.write', 'process.spawn'], network: true, sideEffects: ['device-mutation'] }, { match: /^(harmony_build|harmony_cjpm_build|harmony_cjpm_test|harmony_lint)$/, risk: 'medium', permissions: ['process.spawn', 'fs.write'], network: true, sideEffects: ['build-artifacts'] }, { match: /^spawn_agent$/, risk: 'high', permissions: ['agent.spawn'], network: false, sideEffects: ['subagent-run'] }, - // git forges (GitHub/AtomGit): reads are low; writes mutate remote VCS state. - // forge_raw is additionally approval-gated (needsApproval) — declaration here. + // git forges (GitHub/AtomGit): reads are low; writes mutate remote VCS state + // and are ALL approval-gated (needsApproval) like their domain-ops siblings. { match: /^forge_(repo|pr|issue|release)_(create|merge|upload)$|^forge_raw$/, risk: 'high', permissions: ['net.remote', 'fs.read'], network: true, sideEffects: ['remote-vcs'] }, { match: /^forge_(repo|pr|issue)_list$|^forge_pr_checks$|^forge_status$/, risk: 'low', permissions: ['net.remote'], network: true, sideEffects: [] }, ]; diff --git a/packages/agent/src/forge.ts b/packages/agent/src/forge.ts index 88ea653b..90a6bf60 100644 --- a/packages/agent/src/forge.ts +++ b/packages/agent/src/forge.ts @@ -326,6 +326,7 @@ export const forgeRepoCreateTool: Tool = { }, required: ['host', 'name'], }, + needsApproval: () => true, async execute(args, ctx): Promise { const { host, err } = hostOf(args); if (!host || err) return err!; @@ -377,6 +378,7 @@ export const forgePrCreateTool: Tool = { }, required: ['host', 'repo', 'head', 'base', 'title'], }, + needsApproval: () => true, async execute(args, ctx): Promise { const { host, err } = hostOf(args); if (!host || err) return err!; @@ -400,6 +402,7 @@ export const forgePrMergeTool: Tool = { }, required: ['host', 'repo', 'num'], }, + needsApproval: () => true, async execute(args, ctx): Promise { const { host, err } = hostOf(args); if (!host || err) return err!; @@ -481,6 +484,7 @@ export const forgeIssueCreateTool: Tool = { }, required: ['host', 'repo', 'title'], }, + needsApproval: () => true, async execute(args, ctx): Promise { const { host, err } = hostOf(args); if (!host || err) return err!; @@ -506,6 +510,7 @@ export const forgeReleaseCreateTool: Tool = { }, required: ['host', 'repo', 'tag', 'name', 'body'], }, + needsApproval: () => true, async execute(args, ctx): Promise { const { host, err } = hostOf(args); if (!host || err) return err!; @@ -519,7 +524,7 @@ export const forgeReleaseCreateTool: Tool = { export const forgeReleaseUploadTool: Tool = { name: 'forge_release_upload', - description: 'Upload a file as an asset of an existing release. host: github|atomgit; repo owner/repo; tag; file path (must exist).', + description: 'Upload a file as an asset of an existing release. host: github|atomgit; repo owner/repo; tag; file path (must exist; its full path, target repo and tag are shown on the approval card — uploading moves data OFF this machine).', parameters: { type: 'object', properties: { @@ -530,6 +535,7 @@ export const forgeReleaseUploadTool: Tool = { }, required: ['host', 'repo', 'tag', 'file'], }, + needsApproval: () => true, async execute(args, ctx): Promise { const { host, err } = hostOf(args); if (!host || err) return err!; From f4badf74d1d982cf734c4c1c18dc6c3c7600e171 Mon Sep 17 00:00:00 2001 From: wu Date: Fri, 30 Oct 2026 11:35:44 +0800 Subject: [PATCH 3/4] =?UTF-8?q?chore(agent):=20forge=20=E7=A4=BA=E4=BE=8B?= =?UTF-8?q?=E8=84=B1=E6=95=8F=E2=80=94=E2=80=94=E6=B5=8B=E8=AF=95=E5=A4=B9?= =?UTF-8?q?=E5=85=B7=E4=B8=8E=E6=B3=A8=E9=87=8A=E4=B8=AD=E7=9A=84=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E7=94=A8=E6=88=B7=E5=90=8D=E6=9B=BF=E6=8D=A2=E4=B8=BA?= =?UTF-8?q?=E4=B8=AD=E6=80=A7=E5=90=8D(=E9=A2=84=E6=8E=A8=E9=92=A9?= =?UTF-8?q?=E5=AD=90=E5=90=88=E8=A7=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agent/src/__tests__/forge.test.ts | 2 +- packages/agent/src/forge.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/agent/src/__tests__/forge.test.ts b/packages/agent/src/__tests__/forge.test.ts index 251126f0..5a1ab124 100644 --- a/packages/agent/src/__tests__/forge.test.ts +++ b/packages/agent/src/__tests__/forge.test.ts @@ -40,7 +40,7 @@ test('quoteArg hardens shell metacharacters per platform', () => { test('parseAuthStatus reads both CLI phrasings', () => { assert.deepEqual(parseAuthStatus('github.com\n ✓ Logged in to github.com account swsgbl (keyring)'), { loggedIn: true, user: 'swsgbl' }); - assert.deepEqual(parseAuthStatus('✓ Logged in to atomgit.com as hongfu\n Token: zKxm****1Z3g'), { loggedIn: true, user: 'hongfu' }); + assert.deepEqual(parseAuthStatus('✓ Logged in to atomgit.com as demo-user\n Token: zKxm****1Z3g'), { loggedIn: true, user: 'demo-user' }); assert.deepEqual(parseAuthStatus('You are not logged into any GitHub hosts. Run gh auth login.'), { loggedIn: false }); assert.deepEqual(parseAuthStatus(''), { loggedIn: false }); }); diff --git a/packages/agent/src/forge.ts b/packages/agent/src/forge.ts index 90a6bf60..59f0d535 100644 --- a/packages/agent/src/forge.ts +++ b/packages/agent/src/forge.ts @@ -61,7 +61,7 @@ export function quoteArg(s: string, platform: string = process.platform): string /** Parse `auth status` text for BOTH CLIs' phrasings: * gh: "✓ Logged in to github.com account swsgbl (keyring)" - * ag: "✓ Logged in to atomgit.com as hongfu" */ + * ag: "✓ Logged in to atomgit.com as demo-user" */ export function parseAuthStatus(text: string): { loggedIn: boolean; user?: string } { if (!/Logged in to/.test(text)) return { loggedIn: false }; const m = /Logged in to \S+ (?:account|as) (\S+?)[\s(\r]/.exec(text) ?? /Logged in to \S+ (?:account|as) (\S+)/.exec(text); From 704f28503431d5b5a45e63271c3f2c17781a387c Mon Sep 17 00:00:00 2001 From: wu <641600780@qq.com> Date: Fri, 30 Oct 2026 12:47:44 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(ci):=20verify=20=E4=BD=9C=E4=B8=9A=20pr?= =?UTF-8?q?eflight=20=E6=94=B9=20--skip-tests=E2=80=94=E2=80=94build/typec?= =?UTF-8?q?heck/test=20=E5=B7=B2=E6=98=AF=E7=8B=AC=E7=AB=8B=E6=AD=A5?= =?UTF-8?q?=E9=AA=A4,=E9=87=8D=E5=A4=8D=E7=AC=AC=E4=BA=8C=E9=81=8D?= =?UTF-8?q?=E5=85=A8=E9=87=8F=E5=A5=97=E4=BB=B6=E5=9C=A8=20windows-latest?= =?UTF-8?q?=20=E6=92=9E=2030min=20=E8=B6=85=E6=97=B6(PR#39=20=E5=AE=9E?= =?UTF-8?q?=E8=AF=81:=E7=8B=AC=E7=AB=8B=E6=AD=A5=E9=AA=A4=20496s=20?= =?UTF-8?q?=E7=BB=BF,=E9=87=8D=E5=A4=8D=E8=B7=91=E6=81=B0=E5=9C=A8=201800s?= =?UTF-8?q?=20=E8=A2=AB=E6=9D=80=E4=B8=94=E9=9B=B6=20not-ok);publish.yml?= =?UTF-8?q?=20=E4=BF=9D=E6=8C=81=E5=85=A8=E9=87=8F=E9=97=A8=E7=A6=81?= =?UTF-8?q?=E4=B8=8D=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ff1de24..fca911a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,14 @@ jobs: - name: built CLI smoke (no model needed) run: node packages/cli/dist/main.js skills - name: npm publish preflight (ordered-set check, never publishes) - run: node scripts/publish-preflight.cjs 2>&1 | tee preflight-output.log + # --skip-tests: build/typecheck/test already ran as first-class steps + # above; re-running the whole suite a second time in the same job + # pushed past the preflight's 30min per-command timeout on + # windows-latest (PR#39: standalone suite 496s green, the duplicate + # re-run was killed at exactly 1800s with the zero-not-ok timeout + # signature). publish.yml keeps the full preflight — it is the only + # test gate there. + run: node scripts/publish-preflight.cjs --skip-tests 2>&1 | tee preflight-output.log - name: test failure excerpts (flaky forensics) if: failure() shell: pwsh