diff --git a/apps/server/src/modules/agent/authorize.ts b/apps/server/src/modules/agent/authorize.ts index e5fdd246..6f71473d 100644 --- a/apps/server/src/modules/agent/authorize.ts +++ b/apps/server/src/modules/agent/authorize.ts @@ -29,6 +29,9 @@ export const GRANT_ACTIONS: Record = { "browser.solve_challenge": ["browser.solve_challenge", "browser.interact"], }; +/** 视觉子流程内由 solve_challenge 授权覆盖的执行类坐标动作 */ +const CHALLENGE_STEP_CAPS = new Set(["browser.click_at", "browser.type_at", "browser.scroll"]); + /** 单次审批绑定的计划类型与输入里的计划 ID 字段 */ const APPROVAL_PLAN: Record = { "download.submit": { planType: "acquisition_plan", field: "planId" }, @@ -215,6 +218,13 @@ export function createAuthorizer(deps: { if (block) return { allowed: false, reason: block }; } + // 视觉子流程(spawn_reason = browser.solve_challenge)里的坐标动作:父会话那次 solve_challenge 已过授权, + // 步数 / 预算 / 白名单由子流程约束,这里随之放行(否则只授权 solve_challenge 时子流程一步也点不了) + if (runId && CHALLENGE_STEP_CAPS.has(d.id)) { + const r = await db.selectFrom("agent_run").select("spawn_reason").where("id", "=", runId).executeTakeFirst(); + if (r?.spawn_reason === "browser.solve_challenge") return { allowed: true }; + } + // 1) 单次审批 const ap = APPROVAL_PLAN[d.id]; const approvalId = typeof inp.approvalId === "string" ? inp.approvalId : null; diff --git a/apps/server/test/agent/approvals.test.ts b/apps/server/test/agent/approvals.test.ts index ceab4b5a..fe679a51 100644 --- a/apps/server/test/agent/approvals.test.ts +++ b/apps/server/test/agent/approvals.test.ts @@ -226,4 +226,31 @@ describe.skipIf(!hasPg)("单次审批(真实 PG)", () => { expect(denied.rows[0]!.n).toBeGreaterThanOrEqual(2); }); }); + + it("视觉子流程里的坐标点击随 solve_challenge 放行;普通会话仍要授权", async () => { + const spec = { ...ORGANIZE_SPEC, id: "browser.click_at", scope: "admin" } as CapabilitySpec; + const run = async (spawn_reason: string | null) => + ( + await h.db + .insertInto("agent_run") + .values({ trigger: "user", goal: "g", status: "running", mode: "execute", spawn_reason } as never) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + const check = async (agentRunId: string) => + await createAuthorizer({ db: h.db, settings: h.ctx.settings })({ + descriptor: spec, + input: { sessionId: "s", x: 1, y: 1 }, + ctx: { + actorId: "user:admin", + taskId: "t", + scopeIds: [], + signal: AbortSignal.timeout(10_000), + source: "agent", + agentRunId, + }, + }); + expect(await check(await run("browser.solve_challenge"))).toEqual({ allowed: true }); + expect(await check(await run(null))).toMatchObject({ allowed: false, reason: /browser.click_at/ }); + }); }); diff --git a/docs/runbook/browser.md b/docs/runbook/browser.md index 0a376d44..26193742 100644 --- a/docs/runbook/browser.md +++ b/docs/runbook/browser.md @@ -136,7 +136,7 @@ execute 类需要持续授权(`automation_grant`)或审批。元素引用只来 - **证据**:每次截图(含动作后的截图)写 480 像素宽的 JPEG 缩略图到 `DATA_DIR/tmp/evidence/.jpg`(0600,7 天后清理); 能力输出只给引用 `evidence/.jpg`,随工具结果摘要进 `agent_run_event`。图片数据不进 JSON 文本、不进操作账本。 - **授权**:`click_at / type_at / scroll / solve_challenge` 由 `browser.interact` 的持续授权覆盖(也可单独授权); - 父会话白名单里有 `browser.solve_challenge` 即覆盖子流程用到的截图与坐标能力。坐标动作按调用区分幂等键,崩溃恢复不重放。 + 父会话白名单里有 `browser.solve_challenge` 即覆盖子流程用到的截图与坐标能力;授权同理,solve_challenge 通过授权后,子流程(`spawn_reason = browser.solve_challenge`)内的坐标动作不再单独要持续授权。坐标动作按调用区分幂等键,崩溃恢复不重放。 - 交给模型的截图参数可在 `browser_provider.config` 调:`screenshotMaxWidth`(≤1280)、`screenshotFormat`(`jpeg` / `png`)、 `screenshotQuality`(20–95)。