From b97d20de0978b91b6074b674c6aee0bae9389e8f Mon Sep 17 00:00:00 2001 From: devcxl <64475363+devcxl@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:52:36 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(kernel):=20caller=20=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E5=8E=9F=E8=AF=AD=20=E2=80=94=20resolveCaller/requireCaller=20?= =?UTF-8?q?+=20CALLER=5FNOT=5FAUTHORIZED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spec §2.2:基于 ctx.agent + session 父子链判定调用者; 无 parentID → primary,子会话按 agent 名映射,未知 agent 保守视为 reviewer。 为批 7 setup_control 及后续工具的 caller 门禁提供基础。 --- src/kernel/caller.ts | 54 ++++++++++++++++++++++++++++++++ test/kernel/caller.test.ts | 64 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 src/kernel/caller.ts create mode 100644 test/kernel/caller.test.ts diff --git a/src/kernel/caller.ts b/src/kernel/caller.ts new file mode 100644 index 0000000..2aee80d --- /dev/null +++ b/src/kernel/caller.ts @@ -0,0 +1,54 @@ +/** + * 工具调用者校验原语(spec §2.2)。 + * + * 判定:`ctx.agent` 为 agent 名;session 无 parentID(经 session client 检查)→ primary; + * 子会话按 agent 名映射角色,未知 agent 保守视为 reviewer(最低权限)。 + */ + +export type CallerRole = "primary" | "developer" | "architect" | "reviewer" | "goal-verify" + +/** requireCaller 拒绝时的错误码 */ +export const CALLER_NOT_AUTHORIZED = "CALLER_NOT_AUTHORIZED" + +/** 工具 execute 上下文的最小可见面(ToolContext 的子集) */ +export interface CallerContext { + agent: string + sessionID: string +} + +/** session client 的最小可见面(goalClient 的子集) */ +export interface CallerSessionClient { + session: { + get(input: { sessionID: string }): Promise<{ data?: { parentID?: string | null } }> + } +} + +const ROLE_BY_AGENT: Record = { + "dev-lifecycle": "primary", + developer: "developer", + architect: "architect", + reviewer: "reviewer", + "goal-verify": "goal-verify", +} + +/** 解析调用者角色:无 parentID → primary;子会话按 agent 名映射;未知 agent → reviewer */ +export async function resolveCaller(ctx: CallerContext, client: CallerSessionClient): Promise { + const session = await client.session.get({ sessionID: ctx.sessionID }) + if (!session?.data?.parentID) return "primary" + return ROLE_BY_AGENT[ctx.agent] ?? "reviewer" +} + +/** + * 调用者门禁原语:角色在 allowedRoles 内返回 null(通过), + * 否则返回含 CALLER_NOT_AUTHORIZED 码的错误消息(调用方组装 `{ code, message }` 响应)。 + */ +export async function requireCaller( + ctx: CallerContext, + allowedRoles: CallerRole[], + op: string, + client: CallerSessionClient, +): Promise { + const role = await resolveCaller(ctx, client) + if (allowedRoles.includes(role)) return null + return `${CALLER_NOT_AUTHORIZED}: caller "${ctx.agent}" resolved as "${role}" is not allowed to call "${op}" (allowed: ${allowedRoles.join(", ")})` +} diff --git a/test/kernel/caller.test.ts b/test/kernel/caller.test.ts new file mode 100644 index 0000000..df2d01f --- /dev/null +++ b/test/kernel/caller.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from "vitest" +import { + resolveCaller, + requireCaller, + CALLER_NOT_AUTHORIZED, + type CallerContext, + type CallerSessionClient, +} from "../../src/kernel/caller.js" + +/** 构造最小 session client:按 sessionID 返回预设 parentID */ +function sessionClient(parentBySession: Record): CallerSessionClient { + return { + session: { + async get({ sessionID }) { + return { data: { parentID: parentBySession[sessionID] ?? null } } + }, + }, + } +} + +const ctx = (agent: string, sessionID = "sess_x"): CallerContext => ({ agent, sessionID }) + +describe("resolveCaller", () => { + it("resolves a session without parentID as primary regardless of agent name", async () => { + const client = sessionClient({ sess_x: null }) + await expect(resolveCaller(ctx("dev-lifecycle"), client)).resolves.toBe("primary") + await expect(resolveCaller(ctx("developer"), client)).resolves.toBe("primary") + }) + + it("resolves child sessions by agent name", async () => { + const client = sessionClient({ sess_x: "parent" }) + await expect(resolveCaller(ctx("developer"), client)).resolves.toBe("developer") + await expect(resolveCaller(ctx("architect"), client)).resolves.toBe("architect") + await expect(resolveCaller(ctx("reviewer"), client)).resolves.toBe("reviewer") + await expect(resolveCaller(ctx("goal-verify"), client)).resolves.toBe("goal-verify") + }) + + it("treats an unknown child agent conservatively as reviewer", async () => { + const client = sessionClient({ sess_x: "parent" }) + await expect(resolveCaller(ctx("some-unknown-agent"), client)).resolves.toBe("reviewer") + }) +}) + +describe("requireCaller", () => { + it("returns null when the resolved role is allowed", async () => { + const client = sessionClient({ sess_x: null }) + await expect(requireCaller(ctx("dev-lifecycle"), ["primary"], "probe", client)).resolves.toBeNull() + }) + + it("returns a CALLER_NOT_AUTHORIZED message when the role is not allowed", async () => { + const client = sessionClient({ sess_x: "parent" }) + const denied = await requireCaller(ctx("developer"), ["primary"], "probe", client) + expect(denied).not.toBeNull() + expect(denied).toContain(CALLER_NOT_AUTHORIZED) + expect(denied).toContain("probe") + expect(denied).toContain("primary") + }) + + it("denies when the agent name is unknown even in a primary session scope check", async () => { + const client = sessionClient({ sess_x: "parent" }) + const denied = await requireCaller(ctx("ghost"), ["primary"], "generate-workflows", client) + expect(denied).toContain(CALLER_NOT_AUTHORIZED) + }) +}) From ca7a73865046539e9227b4d8a0d83815252c66dd Mon Sep 17 00:00:00 2001 From: devcxl <64475363+devcxl@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:52:41 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(kernel):=20setup=20=E6=8E=A2=E6=B5=8B/?= =?UTF-8?q?=E7=94=9F=E6=88=90=20workflows/Profile=20=E5=86=99=E5=9B=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - probe:readiness 报告(development-ready / release-ready,PRD R10) - generateWorkflows:缺失时生成 CI/release 草案(技术栈无关, 不硬编码包管理器)+ chore/setup-workflows 分支/commit/push/Setup PR - confirmProjectProfile:mutex 串行读改写回 AGENTS.md Profile 区块(§9.3) - 沿用 records.ts 的 executor 注入模式便于测试 --- src/kernel/setup.ts | 410 ++++++++++++++++++++++++++++++ test/kernel/setup.test.ts | 516 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 926 insertions(+) create mode 100644 src/kernel/setup.ts create mode 100644 test/kernel/setup.test.ts diff --git a/src/kernel/setup.ts b/src/kernel/setup.ts new file mode 100644 index 0000000..e27488a --- /dev/null +++ b/src/kernel/setup.ts @@ -0,0 +1,410 @@ +import { exec } from "node:child_process" +import { promisify } from "node:util" +import { readdir, access, constants, readFile, writeFile, mkdir } from "node:fs/promises" +import { join, basename } from "node:path" +import { readProjectProfile, upsertProjectProfile } from "./profile.js" +import { KeyedMutex } from "./mutex.js" +import { escapeShellArg } from "../util/shell.js" + +const execAsync = promisify(exec) + +// ─── 可替换的 git/gh executor(用于测试,仿 records.ts) ─── + +export type CmdResult = { stdout: string; stderr: string } +export type CmdFn = (args: string, cwd?: string) => Promise + +let setupGitExecutor: CmdFn | null = null +let setupGhExecutor: CmdFn | null = null + +export function setSetupGitExecutor(fn: CmdFn | null): void { + setupGitExecutor = fn +} + +export function setSetupGhExecutor(fn: CmdFn | null): void { + setupGhExecutor = fn +} + +async function runGit(args: string, cwd?: string): Promise { + if (setupGitExecutor) return setupGitExecutor(args, cwd) + return execAsync(`git ${args}`, { cwd }) +} + +async function runGh(args: string): Promise { + if (setupGhExecutor) return setupGhExecutor(args) + return execAsync(`gh ${args}`, {}) +} + +// ─── probe:readiness 探测 ─── + +export interface SetupProbeReport { + gitAvailable: boolean + ghAuthenticated: boolean + hasRemote: boolean + defaultBranch: string | null + profileConfirmed: boolean + tddCommandExecutable: boolean + ciWorkflow: string | null + releaseWorkflow: string | null + contextMdPresent: boolean + branchProtection: boolean + versionRuleConfirmed: boolean + developmentReady: boolean + releaseReady: boolean +} + +/** + * 探测项目 setup 就绪度(PRD R10)。 + * development-ready:git/gh + Profile 确认 + TDD 命令可执行 + CI workflow + 分支保护; + * release-ready:额外版本规则 + release workflow。 + */ +export async function probe(projectDir: string): Promise { + const gitAvailable = await tryRun(() => runGit("--version", projectDir)) + const ghAuthenticated = await tryRun(() => runGh("auth status")) + + let remoteUrl: string | null = null + try { + const { stdout } = await runGit("remote get-url origin", projectDir) + remoteUrl = stdout.trim() || null + } catch { + remoteUrl = null + } + const hasRemote = remoteUrl !== null + + let defaultBranch: string | null = null + try { + const { stdout } = await runGit("symbolic-ref --short refs/remotes/origin/HEAD", projectDir) + defaultBranch = stdout.trim() || null + } catch { + defaultBranch = null + } + + const profile = await readProjectProfile(projectDir) + const profileConfirmed = profile.testCommand !== null + const versionRuleConfirmed = + profile.versionBumpRule !== null && profile.versionFile !== null && profile.tagFormat !== null + const tddCommandExecutable = await isCommandExecutable(profile.testCommand) + + const { ci, release } = await scanWorkflows(projectDir, profile.releaseWorkflowPath) + const contextMdPresent = await fileExists(join(projectDir, "CONTEXT.md")) + + let branchProtection = false + const ownerRepo = remoteUrl ? parseGithubRemote(remoteUrl) : null + if (ownerRepo && defaultBranch) { + branchProtection = await tryRun(() => + runGh(`api repos/${ownerRepo.owner}/${ownerRepo.repo}/branches/${defaultBranch}/protection`), + ) + } + + const developmentReady = + gitAvailable && ghAuthenticated && profileConfirmed && tddCommandExecutable && ci !== null && branchProtection + const releaseReady = developmentReady && versionRuleConfirmed && release !== null + + return { + gitAvailable, + ghAuthenticated, + hasRemote, + defaultBranch, + profileConfirmed, + tddCommandExecutable, + ciWorkflow: ci, + releaseWorkflow: release, + contextMdPresent, + branchProtection, + versionRuleConfirmed, + developmentReady, + releaseReady, + } +} + +/** 从 git remote URL 解析 GitHub owner/repo;非 GitHub 仓库返回 null */ +export function parseGithubRemote(url: string): { owner: string; repo: string } | null { + const patterns = [ + /^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/, + /^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/, + /^ssh:\/\/git@github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/, + ] + for (const pattern of patterns) { + const match = pattern.exec(url) + if (match) return { owner: match[1], repo: match[2] } + } + return null +} + +/** 扫描 .github/workflows/:文件名含 ci → CI workflow;含 release 或 Profile 指定路径 → release workflow */ +async function scanWorkflows( + projectDir: string, + profileReleasePath: string | null, +): Promise<{ ci: string | null; release: string | null }> { + const workflowsDir = join(projectDir, ".github", "workflows") + let files: string[] = [] + try { + files = (await readdir(workflowsDir)).filter(f => f.endsWith(".yml") || f.endsWith(".yaml")) + } catch { + files = [] + } + + let ci: string | null = null + let release: string | null = null + for (const file of files) { + const lower = file.toLowerCase() + if (ci === null && lower.includes("ci")) ci = `.github/workflows/${file}` + if (release === null && lower.includes("release")) release = `.github/workflows/${file}` + } + + if (profileReleasePath && (await fileExists(join(projectDir, profileReleasePath)))) { + release = profileReleasePath + } + return { ci, release } +} + +/** TDD 命令可执行:首个 token 存在于 PATH(相对路径则直接检查可执行) */ +async function isCommandExecutable(testCommand: string | null): Promise { + if (!testCommand) return false + const firstToken = testCommand.trim().split(/\s+/)[0] + if (!firstToken) return false + const candidates = firstToken.includes("/") + ? [firstToken] + : (process.env.PATH ?? "").split(":").filter(Boolean).map(dir => join(dir, firstToken)) + for (const candidate of candidates) { + if (await isExecutable(candidate)) return true + } + return false +} + +async function isExecutable(path: string): Promise { + try { + await access(path, constants.X_OK) + return true + } catch { + return false + } +} + +async function fileExists(path: string): Promise { + try { + await access(path) + return true + } catch { + return false + } +} + +async function tryRun(fn: () => Promise): Promise { + try { + await fn() + return true + } catch { + return false + } +} + +// ─── generate-workflows:缺失时生成 CI/release 草案 + Setup PR ─── + +const SETUP_WORKFLOWS_BRANCH = "chore/setup-workflows" + +export interface GenerateWorkflowsInput { + prTitle: string + defaultBranch?: string | null + testCommand?: string | null + tagFormat?: string | null + releaseWorkflowPath?: string | null +} + +export interface GenerateWorkflowsResult { + ok: boolean + created: string[] + existing: string[] + branch: string | null + prNumber: number | null + error?: string +} + +/** + * 缺失时生成 CI/release workflow 草案(技术栈无关,不硬编码包管理器), + * 创建 chore/setup-workflows 分支 + commit + push + Setup PR(人工合入)。 + */ +export async function generateWorkflows( + projectDir: string, + input: GenerateWorkflowsInput, +): Promise { + const profile = await readProjectProfile(projectDir) + const testCommand = input.testCommand ?? profile.testCommand + const tagFormat = input.tagFormat ?? profile.tagFormat + const releaseWorkflowPath = input.releaseWorkflowPath ?? profile.releaseWorkflowPath + + let defaultBranch = input.defaultBranch ?? null + if (!defaultBranch) { + try { + const { stdout } = await runGit("symbolic-ref --short refs/remotes/origin/HEAD", projectDir) + defaultBranch = stdout.trim() || null + } catch { + defaultBranch = null + } + } + + const workflowsDir = join(projectDir, ".github", "workflows") + await mkdir(workflowsDir, { recursive: true }) + + const { ci, release } = await scanWorkflows(projectDir, releaseWorkflowPath) + const created: string[] = [] + const existing: string[] = [] + + if (ci === null) { + await writeFile(join(workflowsDir, "ci.yml"), buildCiWorkflow(testCommand, defaultBranch)) + created.push(".github/workflows/ci.yml") + } else { + existing.push(ci) + } + + if (release === null) { + const fileName = releaseWorkflowPath ? basename(releaseWorkflowPath) : "release.yml" + await writeFile(join(workflowsDir, fileName), buildReleaseWorkflow(tagFormat)) + created.push(`.github/workflows/${fileName}`) + } else { + existing.push(release) + } + + if (created.length === 0) { + return { ok: true, created, existing, branch: null, prNumber: null } + } + + try { + const prTitle = input.prTitle || "chore: add CI/release workflow drafts" + const prBody = `Setup generated workflow drafts for manual review:\n\n${created.map(f => `- ${f}`).join("\n")}` + + await runGit(`checkout -b ${SETUP_WORKFLOWS_BRANCH}`, projectDir) + await runGit("add .github/workflows", projectDir) + await runGit(`commit -m ${shellQuote(prTitle)}`, projectDir) + await runGit(`push -u origin ${SETUP_WORKFLOWS_BRANCH}`, projectDir) + + const { stdout } = await runGh( + `pr create --title ${shellQuote(prTitle)} --body ${shellQuote(prBody)} --base ${defaultBranch ?? "main"} --head ${SETUP_WORKFLOWS_BRANCH} --json number --jq .number`, + ) + return { + ok: true, + created, + existing, + branch: SETUP_WORKFLOWS_BRANCH, + prNumber: Number(stdout.trim()) || null, + } + } catch (err) { + return { ok: false, created, existing, branch: null, prNumber: null, error: String(err) } + } +} + +/** CI workflow 草案:PR check 触发,运行 Profile 的测试命令(无则占位) */ +export function buildCiWorkflow(testCommand: string | null, defaultBranch: string | null): string { + const command = testCommand ?? "" + const branch = defaultBranch ?? "**" + return [ + "name: CI", + "", + "on:", + " pull_request:", + " branches:", + ` - '${branch}'`, + "", + "jobs:", + " check:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@v4", + ` - run: ${command}`, + "", + ].join("\n") +} + +/** release workflow 草案:tag 触发,技术栈无关占位步骤 */ +export function buildReleaseWorkflow(tagFormat: string | null): string { + const tag = tagFormat ?? "v*" + return [ + "name: Release", + "", + "on:", + " push:", + " tags:", + ` - '${tag}'`, + "", + "jobs:", + " release:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@v4", + " - run: echo \"Define project-specific release steps here (version bump + artifact publish)\"", + "", + ].join("\n") +} + +function shellQuote(value: string): string { + return `'${escapeShellArg(value)}'` +} + +// ─── confirm-profile:Profile 写回 AGENTS.md ─── + +export interface ConfirmProfileResult { + ok: boolean + block?: string + code?: string + message?: string +} + +const profileMutex = new KeyedMutex() + +/** overrides JSON 键 → Profile 区块行键(§9.1 顺序) */ +const PROFILE_KEY_ORDER: Array<{ jsonKey: string; sectionKey: string }> = [ + { jsonKey: "testCommand", sectionKey: "test command" }, + { jsonKey: "regressionCommand", sectionKey: "regression command" }, + { jsonKey: "testFilePatterns", sectionKey: "test file patterns" }, + { jsonKey: "implementationFilePatterns", sectionKey: "implementation file patterns" }, + { jsonKey: "tddDefaultMode", sectionKey: "tdd default mode" }, + { jsonKey: "versionBumpRule", sectionKey: "version bump rule" }, + { jsonKey: "versionFile", sectionKey: "version file" }, + { jsonKey: "tagFormat", sectionKey: "tag format" }, + { jsonKey: "releaseWorkflowPath", sectionKey: "release workflow" }, + { jsonKey: "riskPatterns", sectionKey: "risk patterns" }, +] + +/** 由用户确认的 overrides 生成完整 `## Project Profile` 区块(未提供的键省略) */ +export function buildProfileBlock(overrides: Record): string { + const lines: string[] = ["## Project Profile", ""] + for (const { jsonKey, sectionKey } of PROFILE_KEY_ORDER) { + const value = overrides[jsonKey] + if (value === undefined || value === null) continue + const formatted = Array.isArray(value) ? value.join(", ") : String(value) + if (formatted === "") continue + lines.push(`- ${sectionKey}: \`${formatted}\``) + } + return lines.join("\n") +} + +/** + * 用户确认后把 Profile 区块写回根 AGENTS.md(§9.3)。 + * 先读后写,按 key 串行(mutex);testCommand 为必需键。 + */ +export async function confirmProjectProfile( + projectDir: string, + overridesJson: string, +): Promise { + let overrides: Record + try { + overrides = JSON.parse(overridesJson) as Record + } catch { + return { ok: false, code: "POLICY_INVALID", message: "profile_overrides is not valid JSON" } + } + if (typeof overrides !== "object" || overrides === null || Array.isArray(overrides)) { + return { ok: false, code: "POLICY_INVALID", message: "profile_overrides must be a JSON object" } + } + if (typeof overrides.testCommand !== "string" || overrides.testCommand.trim() === "") { + return { ok: false, code: "POLICY_INVALID", message: "testCommand is required to confirm the profile" } + } + + const block = buildProfileBlock(overrides) + const agentsPath = join(projectDir, "AGENTS.md") + + await profileMutex.runExclusive("agents-md", async () => { + const markdown = await readFile(agentsPath, "utf8").catch(() => "") + await writeFile(agentsPath, upsertProjectProfile(markdown, block)) + }) + + return { ok: true, block } +} diff --git a/test/kernel/setup.test.ts b/test/kernel/setup.test.ts new file mode 100644 index 0000000..2158587 --- /dev/null +++ b/test/kernel/setup.test.ts @@ -0,0 +1,516 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import { mkdtemp, writeFile, mkdir, rm, chmod } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { + probe, + generateWorkflows, + buildCiWorkflow, + buildReleaseWorkflow, + buildProfileBlock, + confirmProjectProfile, + setSetupGitExecutor, + setSetupGhExecutor, +} from "../../src/kernel/setup.js" +import { readFile } from "node:fs/promises" + +type CmdFn = (args: string, cwd?: string) => Promise<{ stdout: string; stderr: string }> + +const FULL_PROFILE = `## Project Profile + +- test command: \`cabbage-test-tool run\` +- test file patterns: \`test/**/*.test.ts\` +- implementation file patterns: \`src/**/*.ts\` +- tdd default mode: \`strict\` +- version bump rule: \`breaking→major, feature→minor, fix→patch\` +- version file: \`package.json\` +- tag format: \`v{version}\` +- release workflow: \`.github/workflows/release.yml\` +` + +const READY_GIT: CmdFn = async (args) => { + if (args === "--version") return { stdout: "git version 2.43.0", stderr: "" } + if (args.startsWith("remote get-url")) return { stdout: "https://github.com/devcxl/opencode-cabbage.git", stderr: "" } + if (args.includes("symbolic-ref")) return { stdout: "main", stderr: "" } + throw new Error(`unexpected git call: ${args}`) +} + +const READY_GH: CmdFn = async (args) => { + if (args.startsWith("auth status")) return { stdout: "logged in", stderr: "" } + if (args.includes("/protection")) return { stdout: "{}", stderr: "" } + throw new Error(`unexpected gh call: ${args}`) +} + +async function withProjectDir(fn: (dir: string) => Promise) { + const dir = await mkdtemp(join(tmpdir(), "cabbage-setup-")) + try { + await fn(dir) + } finally { + await rm(dir, { recursive: true, force: true }) + } +} + +async function writeFixture(dir: string, files: Record) { + for (const [rel, content] of Object.entries(files)) { + const abs = join(dir, rel) + await mkdir(join(abs, ".."), { recursive: true }) + await writeFile(abs, content) + } +} + +async function addFakeBin(fn: (binDir: string) => Promise) { + const binDir = await mkdtemp(join(tmpdir(), "cabbage-bin-")) + const oldPath = process.env.PATH + process.env.PATH = `${binDir}:${oldPath ?? ""}` + try { + await fn(binDir) + } finally { + process.env.PATH = oldPath + await rm(binDir, { recursive: true, force: true }) + } +} + +describe("probe", () => { + beforeEach(() => { + setSetupGitExecutor(() => { + throw new Error("unexpected git call") + }) + setSetupGhExecutor(() => { + throw new Error("unexpected gh call") + }) + }) + + afterEach(() => { + setSetupGitExecutor(null) + setSetupGhExecutor(null) + }) + + it("reports development-ready and release-ready when everything is in place", async () => { + await addFakeBin(async binDir => { + await writeFile(join(binDir, "cabbage-test-tool"), "#!/bin/sh\nexit 0\n") + await chmod(join(binDir, "cabbage-test-tool"), 0o755) + + await withProjectDir(async dir => { + await writeFixture(dir, { + "AGENTS.md": FULL_PROFILE, + "CONTEXT.md": "# Domain Terms\n", + ".github/workflows/ci.yml": "name: CI\non:\n pull_request:\n", + ".github/workflows/release.yml": "name: Release\n", + }) + setSetupGitExecutor(READY_GIT) + setSetupGhExecutor(READY_GH) + + const report = await probe(dir) + expect(report.gitAvailable).toBe(true) + expect(report.ghAuthenticated).toBe(true) + expect(report.hasRemote).toBe(true) + expect(report.defaultBranch).toBe("main") + expect(report.profileConfirmed).toBe(true) + expect(report.tddCommandExecutable).toBe(true) + expect(report.ciWorkflow).toBe(".github/workflows/ci.yml") + expect(report.releaseWorkflow).toBe(".github/workflows/release.yml") + expect(report.contextMdPresent).toBe(true) + expect(report.branchProtection).toBe(true) + expect(report.versionRuleConfirmed).toBe(true) + expect(report.developmentReady).toBe(true) + expect(report.releaseReady).toBe(true) + }) + }) + }) + + it("reports development-ready=false when the profile is not confirmed", async () => { + await withProjectDir(async dir => { + await writeFixture(dir, { "AGENTS.md": "# Rules only\n" }) + setSetupGitExecutor(READY_GIT) + setSetupGhExecutor(READY_GH) + + const report = await probe(dir) + expect(report.profileConfirmed).toBe(false) + expect(report.developmentReady).toBe(false) + expect(report.releaseReady).toBe(false) + }) + }) + + it("reports development-ready=false when the CI workflow is missing", async () => { + await addFakeBin(async binDir => { + await writeFile(join(binDir, "cabbage-test-tool"), "#!/bin/sh\nexit 0\n") + await chmod(join(binDir, "cabbage-test-tool"), 0o755) + + await withProjectDir(async dir => { + await writeFixture(dir, { + "AGENTS.md": FULL_PROFILE, + "CONTEXT.md": "x", + ".github/workflows/release.yml": "name: Release\n", + }) + setSetupGitExecutor(READY_GIT) + setSetupGhExecutor(READY_GH) + + const report = await probe(dir) + expect(report.ciWorkflow).toBeNull() + expect(report.developmentReady).toBe(false) + expect(report.releaseReady).toBe(false) + }) + }) + }) + + it("reports development-ready=false when branch protection is missing", async () => { + await addFakeBin(async binDir => { + await writeFile(join(binDir, "cabbage-test-tool"), "#!/bin/sh\nexit 0\n") + await chmod(join(binDir, "cabbage-test-tool"), 0o755) + + await withProjectDir(async dir => { + await writeFixture(dir, { + "AGENTS.md": FULL_PROFILE, + "CONTEXT.md": "x", + ".github/workflows/ci.yml": "name: CI\n", + ".github/workflows/release.yml": "name: Release\n", + }) + setSetupGitExecutor(READY_GIT) + setSetupGhExecutor(async (args) => { + if (args.startsWith("auth status")) return { stdout: "logged in", stderr: "" } + if (args.includes("/protection")) throw new Error("HTTP 404: branch not protected") + throw new Error(`unexpected gh call: ${args}`) + }) + + const report = await probe(dir) + expect(report.branchProtection).toBe(false) + expect(report.developmentReady).toBe(false) + expect(report.releaseReady).toBe(false) + }) + }) + }) + + it("reports release-ready=false when version rules are incomplete", async () => { + await addFakeBin(async binDir => { + await writeFile(join(binDir, "cabbage-test-tool"), "#!/bin/sh\nexit 0\n") + await chmod(join(binDir, "cabbage-test-tool"), 0o755) + + await withProjectDir(async dir => { + const profileNoVersion = FULL_PROFILE.replace("- version bump rule: `breaking→major, feature→minor, fix→patch`\n", "") + await writeFixture(dir, { + "AGENTS.md": profileNoVersion, + "CONTEXT.md": "x", + ".github/workflows/ci.yml": "name: CI\n", + ".github/workflows/release.yml": "name: Release\n", + }) + setSetupGitExecutor(READY_GIT) + setSetupGhExecutor(READY_GH) + + const report = await probe(dir) + expect(report.versionRuleConfirmed).toBe(false) + expect(report.releaseReady).toBe(false) + }) + }) + }) + + it("reports release-ready=false when the release workflow is missing", async () => { + await addFakeBin(async binDir => { + await writeFile(join(binDir, "cabbage-test-tool"), "#!/bin/sh\nexit 0\n") + await chmod(join(binDir, "cabbage-test-tool"), 0o755) + + await withProjectDir(async dir => { + await writeFixture(dir, { + "AGENTS.md": FULL_PROFILE, + "CONTEXT.md": "x", + ".github/workflows/ci.yml": "name: CI\n", + }) + setSetupGitExecutor(READY_GIT) + setSetupGhExecutor(READY_GH) + + const report = await probe(dir) + expect(report.releaseWorkflow).toBeNull() + expect(report.releaseReady).toBe(false) + }) + }) + }) + + it("reports tddCommandExecutable=false when the test command is not on PATH", async () => { + await withProjectDir(async dir => { + await writeFixture(dir, { "AGENTS.md": FULL_PROFILE }) + setSetupGitExecutor(READY_GIT) + setSetupGhExecutor(READY_GH) + + const report = await probe(dir) + expect(report.tddCommandExecutable).toBe(false) + }) + }) + + it("reports gitAvailable=false when git fails", async () => { + await withProjectDir(async dir => { + setSetupGitExecutor(async () => { + throw new Error("git: command not found") + }) + setSetupGhExecutor(READY_GH) + + const report = await probe(dir) + expect(report.gitAvailable).toBe(false) + expect(report.developmentReady).toBe(false) + }) + }) + + it("does not crash when the repo has no github remote", async () => { + await addFakeBin(async binDir => { + await writeFile(join(binDir, "cabbage-test-tool"), "#!/bin/sh\nexit 0\n") + await chmod(join(binDir, "cabbage-test-tool"), 0o755) + + await withProjectDir(async dir => { + await writeFixture(dir, { + "AGENTS.md": FULL_PROFILE, + "CONTEXT.md": "x", + ".github/workflows/ci.yml": "name: CI\n", + ".github/workflows/release.yml": "name: Release\n", + }) + setSetupGitExecutor(async (args) => { + if (args === "--version") return { stdout: "git version 2.43.0", stderr: "" } + if (args.startsWith("remote get-url")) throw new Error("remote not found") + if (args.includes("symbolic-ref")) return { stdout: "main", stderr: "" } + throw new Error(`unexpected git call: ${args}`) + }) + setSetupGhExecutor(READY_GH) + + const report = await probe(dir) + expect(report.hasRemote).toBe(false) + expect(report.branchProtection).toBe(false) + expect(report.developmentReady).toBe(false) + }) + }) + }) +}) + +describe("buildCiWorkflow / buildReleaseWorkflow", () => { + it("builds a PR-triggered CI workflow using the profile test command", () => { + const content = buildCiWorkflow("cabbage-test-tool run", "main") + expect(content).toContain("name: CI") + expect(content).toContain("pull_request:") + expect(content).toContain("'main'") + expect(content).toContain("- run: cabbage-test-tool run") + }) + + it("does not hardcode any package manager (tech-stack agnostic)", () => { + const ci = buildCiWorkflow("cabbage-test-tool run", "main") + const release = buildReleaseWorkflow("v{version}") + expect(ci.toLowerCase()).not.toContain("npm") + expect(release.toLowerCase()).not.toContain("npm") + }) + + it("uses a placeholder test command when the profile has none", () => { + const content = buildCiWorkflow(null, null) + expect(content).toContain("") + expect(content).toContain("'**'") + }) + + it("builds a tag-triggered release workflow placeholder with the profile tag format", () => { + const content = buildReleaseWorkflow("v{version}") + expect(content).toContain("name: Release") + expect(content).toContain("tags:") + expect(content).toContain("'v{version}'") + }) +}) + +describe("generateWorkflows", () => { + beforeEach(() => { + setSetupGitExecutor(() => { + throw new Error("unexpected git call") + }) + setSetupGhExecutor(() => { + throw new Error("unexpected gh call") + }) + }) + + afterEach(() => { + setSetupGitExecutor(null) + setSetupGhExecutor(null) + }) + + it("generates missing CI and release workflows and opens a setup PR", async () => { + await withProjectDir(async dir => { + await writeFixture(dir, { "AGENTS.md": FULL_PROFILE }) + const gitCalls: string[] = [] + const ghCalls: string[] = [] + setSetupGitExecutor(async (args) => { + gitCalls.push(args) + if (args.includes("symbolic-ref")) return { stdout: "main", stderr: "" } + return { stdout: "", stderr: "" } + }) + setSetupGhExecutor(async (args) => { + ghCalls.push(args) + return { stdout: "99", stderr: "" } + }) + + const result = await generateWorkflows(dir, { prTitle: "chore: add workflow drafts", defaultBranch: "main" }) + + expect(result.ok).toBe(true) + expect(result.created).toEqual([ + ".github/workflows/ci.yml", + ".github/workflows/release.yml", + ]) + + const ci = await readFile(join(dir, ".github/workflows/ci.yml"), "utf8") + expect(ci).toContain("- run: cabbage-test-tool run") + const release = await readFile(join(dir, ".github/workflows/release.yml"), "utf8") + expect(release).toContain("'v{version}'") + + expect(gitCalls).toEqual([ + "checkout -b chore/setup-workflows", + "add .github/workflows", + "commit -m 'chore: add workflow drafts'", + "push -u origin chore/setup-workflows", + ]) + expect(ghCalls.some(c => c.startsWith("pr create"))).toBe(true) + expect(ghCalls[ghCalls.length - 1]).toContain("--head chore/setup-workflows") + expect(result.prNumber).toBe(99) + expect(result.branch).toBe("chore/setup-workflows") + }) + }) + + it("does not regenerate existing workflows and skips git/gh when nothing is missing", async () => { + await withProjectDir(async dir => { + await writeFixture(dir, { + "AGENTS.md": FULL_PROFILE, + ".github/workflows/ci.yml": "name: CI\n", + ".github/workflows/release.yml": "name: Release\n", + }) + const gitCalls: string[] = [] + const ghCalls: string[] = [] + setSetupGitExecutor(async (args) => { + gitCalls.push(args) + return { stdout: "", stderr: "" } + }) + setSetupGhExecutor(async (args) => { + ghCalls.push(args) + return { stdout: "", stderr: "" } + }) + + const result = await generateWorkflows(dir, { prTitle: "chore: noop", defaultBranch: "main" }) + + expect(result.ok).toBe(true) + expect(result.created).toEqual([]) + expect(result.existing.sort()).toEqual([ + ".github/workflows/ci.yml", + ".github/workflows/release.yml", + ]) + expect(result.branch).toBeNull() + expect(gitCalls).toEqual([]) + expect(ghCalls).toEqual([]) + }) + }) + + it("generates only the missing release workflow when CI already exists", async () => { + await withProjectDir(async dir => { + await writeFixture(dir, { + "AGENTS.md": FULL_PROFILE, + ".github/workflows/ci.yml": "name: CI\n", + }) + const gitCalls: string[] = [] + setSetupGitExecutor(async (args) => { + gitCalls.push(args) + if (args.includes("symbolic-ref")) return { stdout: "main", stderr: "" } + return { stdout: "", stderr: "" } + }) + setSetupGhExecutor(async () => ({ stdout: "7", stderr: "" })) + + const result = await generateWorkflows(dir, { prTitle: "chore: add release workflow", defaultBranch: "main" }) + + expect(result.created).toEqual([".github/workflows/release.yml"]) + expect(result.existing).toEqual([".github/workflows/ci.yml"]) + expect(gitCalls[0]).toBe("checkout -b chore/setup-workflows") + }) + }) + + it("uses a placeholder test command in CI when no profile test command exists", async () => { + await withProjectDir(async dir => { + const gitCalls: string[] = [] + setSetupGitExecutor(async (args) => { + gitCalls.push(args) + if (args.includes("symbolic-ref")) return { stdout: "main", stderr: "" } + return { stdout: "", stderr: "" } + }) + setSetupGhExecutor(async () => ({ stdout: "1", stderr: "" })) + + await generateWorkflows(dir, { prTitle: "chore: add workflows" }) + + const ci = await readFile(join(dir, ".github/workflows/ci.yml"), "utf8") + expect(ci).toContain("") + }) + }) +}) + +describe("buildProfileBlock", () => { + it("builds a full Project Profile block from confirmed overrides", () => { + const block = buildProfileBlock({ + testCommand: "npm test -- run", + testFilePatterns: ["test/**/*.test.ts"], + implementationFilePatterns: ["src/**/*.ts", "src/kernel/**/*.ts"], + tddDefaultMode: "strict", + versionBumpRule: "breaking→major, feature→minor, fix→patch", + versionFile: "package.json", + tagFormat: "v{version}", + releaseWorkflowPath: ".github/workflows/release.yml", + }) + expect(block).toContain("## Project Profile") + expect(block).toContain("- test command: `npm test -- run`") + expect(block).toContain("- test file patterns: `test/**/*.test.ts`") + expect(block).toContain("- implementation file patterns: `src/**/*.ts, src/kernel/**/*.ts`") + expect(block).toContain("- tdd default mode: `strict`") + expect(block).toContain("- version file: `package.json`") + expect(block).toContain("- tag format: `v{version}`") + expect(block).toContain("- release workflow: `.github/workflows/release.yml`") + }) + + it("omits keys that are not provided", () => { + const block = buildProfileBlock({ testCommand: "cabbage-test-tool run" }) + expect(block).toContain("- test command: `cabbage-test-tool run`") + expect(block).not.toContain("version") + }) +}) + +describe("confirmProjectProfile", () => { + it("appends the profile block to AGENTS.md when none exists", async () => { + await withProjectDir(async dir => { + await writeFixture(dir, { "AGENTS.md": "# Project Rules\n\n- be concise\n" }) + + const result = await confirmProjectProfile(dir, JSON.stringify({ testCommand: "cabbage-test-tool run" })) + + expect(result.ok).toBe(true) + if (result.ok) expect(result.block).toContain("cabbage-test-tool run") + + const agentsMd = await readFile(join(dir, "AGENTS.md"), "utf8") + expect(agentsMd).toContain("## Project Profile") + expect(agentsMd).toContain("- test command: `cabbage-test-tool run`") + }) + }) + + it("replaces an existing profile block and keeps surrounding content", async () => { + await withProjectDir(async dir => { + await writeFixture(dir, { + "AGENTS.md": "# Rules\n\n## Project Profile\n\n- test command: `old`\n\n## Other\n\nkeep me\n", + }) + + const result = await confirmProjectProfile(dir, JSON.stringify({ testCommand: "new-tool run" })) + + expect(result.ok).toBe(true) + const agentsMd = await readFile(join(dir, "AGENTS.md"), "utf8") + expect(agentsMd).toContain("# Rules") + expect(agentsMd).toContain("## Other\n\nkeep me") + expect(agentsMd).toContain("- test command: `new-tool run`") + expect(agentsMd).not.toContain("old") + }) + }) + + it("rejects when testCommand is missing", async () => { + await withProjectDir(async dir => { + await writeFixture(dir, { "AGENTS.md": "# Rules\n" }) + const result = await confirmProjectProfile(dir, JSON.stringify({ versionBumpRule: "breaking→major" })) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.code).toBe("POLICY_INVALID") + }) + }) + + it("rejects invalid JSON overrides", async () => { + await withProjectDir(async dir => { + const result = await confirmProjectProfile(dir, "not-json{") + expect(result.ok).toBe(false) + if (!result.ok) expect(result.code).toBe("POLICY_INVALID") + }) + }) +}) From 265f7aed0ff4566e551bb2399c9ac9010913f417 Mon Sep 17 00:00:00 2001 From: devcxl <64475363+devcxl@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:52:46 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(plugin):=20setup=5Fcontrol=20=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E6=B3=A8=E5=86=8C=EF=BC=88=E5=B7=A5=E5=8E=82=20+=20re?= =?UTF-8?q?gisterSetupControl=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三 op:probe / generate-workflows / confirm-profile,caller=primary。 按任务约定不在 server.ts 注册(避免与批 8-11 接线冲突), 提供 registerSetupControl 供后续批次统一接线(§2.3)。 --- src/plugin/setup-control.ts | 104 +++++++++++++++++++++ test/plugin/setup-control.test.ts | 146 ++++++++++++++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 src/plugin/setup-control.ts create mode 100644 test/plugin/setup-control.test.ts diff --git a/src/plugin/setup-control.ts b/src/plugin/setup-control.ts new file mode 100644 index 0000000..23b5f2a --- /dev/null +++ b/src/plugin/setup-control.ts @@ -0,0 +1,104 @@ +import { tool } from "@opencode-ai/plugin/tool" +import { requireCaller, CALLER_NOT_AUTHORIZED, type CallerSessionClient } from "../kernel/caller.js" +import { + probe, + generateWorkflows, + confirmProjectProfile, + type SetupProbeReport, +} from "../kernel/setup.js" + +/** setup_control 工具依赖:项目目录 + caller 判定所需的 session client */ +export interface SetupControlDeps { + projectDir: string + sessionClient: CallerSessionClient +} + +export type SetupControlResponse = { + ok: boolean + readiness?: SetupProbeReport + profileConfirmed?: boolean + created?: string[] + prNumber?: number | null + error?: { code: string; message: string } +} + +const DEFAULT_PR_TITLE = "chore: add CI/release workflow drafts" + +/** + * setup_control 工具工厂(spec §2.3)。 + * 三 op:probe(readiness 报告) / generate-workflows(缺失时生成草案 + Setup PR) / confirm-profile(Profile 写回 AGENTS.md)。 + * caller:primary。server.ts 接线由后续批次统一处理(本批不注册,避免文件冲突)。 + */ +export function createSetupControlTool(deps: SetupControlDeps) { + return tool({ + description: `Detect project setup readiness, generate missing CI/release workflow drafts, and confirm the project profile. + +Operations: +- probe: Detect git/gh availability, AGENTS.md Profile, CI/release workflows, and branch protection. Returns a readiness report (development-ready / release-ready). +- generate-workflows: Generate missing CI/release workflow drafts under .github/workflows/ (tech-stack agnostic), then create a chore/setup-workflows branch, commit, push, and open a Setup PR for manual merge. +- confirm-profile: After user confirmation, write the Project Profile block (from profile_overrides) back to the root AGENTS.md. + +Caller: primary.`, + args: { + op: tool.schema.enum(["probe", "generate-workflows", "confirm-profile"]).describe("Setup operation"), + pr_title: tool.schema.string().optional().describe("PR title for generate-workflows"), + profile_overrides: tool.schema.string().optional().describe("JSON of user-confirmed profile fields for confirm-profile"), + }, + async execute(args, ctx) { + const op = args.op as string + + const denied = await requireCaller(ctx, ["primary"], op, deps.sessionClient) + if (denied) return errorResponse(CALLER_NOT_AUTHORIZED, denied) + + try { + switch (op) { + case "probe": { + const readiness = await probe(deps.projectDir) + return okResponse({ readiness }) + } + case "generate-workflows": { + const prTitle = (args.pr_title as string | undefined) ?? DEFAULT_PR_TITLE + const result = await generateWorkflows(deps.projectDir, { prTitle }) + if (!result.ok) { + return errorResponse("SETUP_FAILED", result.error ?? "failed to generate workflows") + } + return okResponse({ created: result.created, prNumber: result.prNumber }) + } + case "confirm-profile": { + const overrides = args.profile_overrides as string | undefined + if (!overrides) { + return errorResponse("POLICY_INVALID", "profile_overrides is required for confirm-profile") + } + const result = await confirmProjectProfile(deps.projectDir, overrides) + if (!result.ok) { + return errorResponse(result.code ?? "POLICY_INVALID", result.message ?? "failed to confirm profile") + } + return okResponse({ profileConfirmed: true }) + } + default: + return errorResponse("UNKNOWN_OP", `Unknown op: "${op}"`) + } + } catch (err) { + return errorResponse("INTERNAL_ERROR", String(err)) + } + }, + }) +} + +/** + * 独立注册辅助:把 setup_control 挂到工具注册表。 + * 供后续批次(批 8-11 或最终接线)在 server.ts 统一接线时调用。 + */ +export function registerSetupControl(registry: Record, deps: SetupControlDeps): void { + registry.setup_control = createSetupControlTool(deps) +} + +function okResponse(overrides: Partial = {}): string { + const resp: SetupControlResponse = { ok: true, ...overrides } + return JSON.stringify(resp, null, 2) +} + +function errorResponse(code: string, message: string): string { + const resp: SetupControlResponse = { ok: false, error: { code, message } } + return JSON.stringify(resp, null, 2) +} diff --git a/test/plugin/setup-control.test.ts b/test/plugin/setup-control.test.ts new file mode 100644 index 0000000..ed78d43 --- /dev/null +++ b/test/plugin/setup-control.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import { mkdtemp, writeFile, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { createSetupControlTool, registerSetupControl } from "../../src/plugin/setup-control.js" +import { setSetupGitExecutor, setSetupGhExecutor } from "../../src/kernel/setup.js" +import type { CallerSessionClient } from "../../src/kernel/caller.js" + +const primaryClient: CallerSessionClient = { + session: { get: async () => ({ data: { parentID: null } }) }, +} +const childClient: CallerSessionClient = { + session: { get: async () => ({ data: { parentID: "parent" } }) }, +} + +function makeCtx(agent: string, sessionID: string) { + return { + agent, + sessionID, + messageID: "m1", + directory: ".", + worktree: ".", + abort: new AbortController().signal, + metadata: () => {}, + ask: async () => {}, + } +} + +async function executeOp( + dir: string, + op: string, + args: Record = {}, + client: CallerSessionClient = primaryClient, + agent = "dev-lifecycle", +): Promise> { + const t = createSetupControlTool({ projectDir: dir, sessionClient: client }) + const out = await t.execute({ op, ...args }, makeCtx(agent, "sess-1") as never) + return JSON.parse(String(out)) as Record +} + +async function withProjectDir(fn: (dir: string) => Promise) { + const dir = await mkdtemp(join(tmpdir(), "cabbage-setup-tool-")) + try { + await fn(dir) + } finally { + await rm(dir, { recursive: true, force: true }) + } +} + +describe("createSetupControlTool", () => { + beforeEach(() => { + setSetupGitExecutor(() => { + throw new Error("unexpected git call") + }) + setSetupGhExecutor(() => { + throw new Error("unexpected gh call") + }) + }) + + afterEach(() => { + setSetupGitExecutor(null) + setSetupGhExecutor(null) + }) + + it("defines the three ops in the args schema", () => { + const t = createSetupControlTool({ projectDir: ".", sessionClient: primaryClient }) + const opSchema = t.args.op as { safeParse(value: unknown): { success: boolean } } + for (const op of ["probe", "generate-workflows", "confirm-profile"]) { + expect(opSchema.safeParse(op).success).toBe(true) + } + expect(opSchema.safeParse("bogus").success).toBe(false) + }) + + it("runs probe and returns the readiness report", async () => { + await withProjectDir(async dir => { + setSetupGitExecutor(async () => ({ stdout: "", stderr: "" })) + setSetupGhExecutor(async () => ({ stdout: "", stderr: "" })) + + const resp = await executeOp(dir, "probe") + expect(resp.ok).toBe(true) + expect(resp.readiness).toBeDefined() + expect(typeof resp.readiness.developmentReady).toBe("boolean") + expect(typeof resp.readiness.releaseReady).toBe("boolean") + }) + }) + + it("confirm-profile writes the profile back to AGENTS.md", async () => { + await withProjectDir(async dir => { + await writeFile(join(dir, "AGENTS.md"), "# Rules\n") + + const resp = await executeOp(dir, "confirm-profile", { + profile_overrides: JSON.stringify({ testCommand: "cabbage-test-tool run" }), + }) + expect(resp.ok).toBe(true) + expect(resp.profileConfirmed).toBe(true) + }) + }) + + it("confirm-profile requires profile_overrides", async () => { + await withProjectDir(async dir => { + const resp = await executeOp(dir, "confirm-profile") + expect(resp.ok).toBe(false) + expect(resp.error.code).toBe("POLICY_INVALID") + }) + }) + + it("generate-workflows creates the drafts and returns the PR number", async () => { + await withProjectDir(async dir => { + await writeFile(join(dir, "AGENTS.md"), "# Rules\n") + setSetupGitExecutor(async (args) => { + if (args.includes("symbolic-ref")) return { stdout: "main", stderr: "" } + return { stdout: "", stderr: "" } + }) + setSetupGhExecutor(async () => ({ stdout: "5", stderr: "" })) + + const resp = await executeOp(dir, "generate-workflows", { pr_title: "chore: add workflows" }) + expect(resp.ok).toBe(true) + expect(resp.created).toContain(".github/workflows/ci.yml") + expect(resp.prNumber).toBe(5) + }) + }) + + it("rejects a non-primary caller with CALLER_NOT_AUTHORIZED", async () => { + await withProjectDir(async dir => { + const resp = await executeOp(dir, "probe", {}, childClient, "developer") + expect(resp.ok).toBe(false) + expect(resp.error.code).toBe("CALLER_NOT_AUTHORIZED") + }) + }) + + it("returns UNKNOWN_OP for an unknown op", async () => { + await withProjectDir(async dir => { + const resp = await executeOp(dir, "bogus" as string) + expect(resp.ok).toBe(false) + expect(resp.error.code).toBe("UNKNOWN_OP") + }) + }) +}) + +describe("registerSetupControl", () => { + it("mounts setup_control on the tool registry", () => { + const registry: Record = {} + registerSetupControl(registry, { projectDir: ".", sessionClient: primaryClient }) + expect(registry.setup_control).toBeDefined() + }) +}) From 580cb5f0902647098dfcab69de7dba893719bbcc Mon Sep 17 00:00:00 2001 From: devcxl <64475363+devcxl@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:02:39 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(kernel):=20caller=20fail-closed=20?= =?UTF-8?q?=E2=80=94=20session=20=E6=9F=A5=E8=AF=A2=E5=BC=82=E5=B8=B8/?= =?UTF-8?q?=E6=97=A0=20data=20=E4=BF=9D=E5=AE=88=E8=A7=86=E4=B8=BA=20revie?= =?UTF-8?q?wer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/kernel/caller.ts | 18 ++++++++++++++---- test/kernel/caller.test.ts | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/kernel/caller.ts b/src/kernel/caller.ts index 2aee80d..fff7f7e 100644 --- a/src/kernel/caller.ts +++ b/src/kernel/caller.ts @@ -31,11 +31,21 @@ const ROLE_BY_AGENT: Record = { "goal-verify": "goal-verify", } -/** 解析调用者角色:无 parentID → primary;子会话按 agent 名映射;未知 agent → reviewer */ +/** + * 解析调用者角色:无 parentID(session 查询成功且 data 存在)→ primary; + * 查询失败/无 data → 保守视为 reviewer(最低权限,fail-closed); + * 子会话按 agent 名映射;未知 agent → reviewer。 + */ export async function resolveCaller(ctx: CallerContext, client: CallerSessionClient): Promise { - const session = await client.session.get({ sessionID: ctx.sessionID }) - if (!session?.data?.parentID) return "primary" - return ROLE_BY_AGENT[ctx.agent] ?? "reviewer" + let session + try { + session = await client.session.get({ sessionID: ctx.sessionID }) + } catch { + return "reviewer" + } + if (!session?.data) return "reviewer" + if (session.data.parentID) return ROLE_BY_AGENT[ctx.agent] ?? "reviewer" + return "primary" } /** diff --git a/test/kernel/caller.test.ts b/test/kernel/caller.test.ts index df2d01f..377af5f 100644 --- a/test/kernel/caller.test.ts +++ b/test/kernel/caller.test.ts @@ -39,6 +39,39 @@ describe("resolveCaller", () => { const client = sessionClient({ sess_x: "parent" }) await expect(resolveCaller(ctx("some-unknown-agent"), client)).resolves.toBe("reviewer") }) + + it("fails closed to reviewer when session query throws", async () => { + const client: CallerSessionClient = { + session: { + async get() { + throw new Error("session.get failed") + }, + }, + } + await expect(resolveCaller(ctx("dev-lifecycle"), client)).resolves.toBe("reviewer") + }) + + it("fails closed to reviewer when session query returns no data", async () => { + const client: CallerSessionClient = { + session: { + async get() { + return {} as { data?: { parentID?: string | null } } + }, + }, + } + await expect(resolveCaller(ctx("dev-lifecycle"), client)).resolves.toBe("reviewer") + }) + + it("fails closed to reviewer when session query returns an error payload", async () => { + const client: CallerSessionClient = { + session: { + async get() { + return { error: "not found" } as unknown as { data?: { parentID?: string | null } } + }, + }, + } + await expect(resolveCaller(ctx("dev-lifecycle"), client)).resolves.toBe("reviewer") + }) }) describe("requireCaller", () => {