baa-conductor

git clone 

commit
54f1c61
parent
4733478
author
codex@macbookpro
date
2026-04-01 18:03:21 +0800 CST
feat: make baa instruction policy configurable
6 files changed,  +209, -15
M apps/conductor-daemon/src/index.test.js
+86, -0
  1@@ -30,10 +30,12 @@ import {
  2   PersistentBaaInstructionDeduper,
  3   PersistentBaaLiveInstructionMessageDeduper,
  4   PersistentBaaLiveInstructionSnapshotStore,
  5+  createDefaultBaaInstructionPolicyTargetTools,
  6   createArtifactStoreBrowserRequestPolicyPersistence,
  7   createFetchControlApiClient,
  8   createRenewalDispatcherRunner,
  9   createRenewalProjectorRunner,
 10+  evaluateBaaInstructionPolicy,
 11   executeBaaInstruction,
 12   extractBaaInstructionBlocks,
 13   handleConductorHttpRequest,
 14@@ -838,6 +840,26 @@ test("routeBaaInstruction applies a 60s default timeout to conductor exec", () =
 15   });
 16 });
 17 
 18+test("evaluateBaaInstructionPolicy keeps the default Phase 1 policy behavior", () => {
 19+  const allowed = evaluateBaaInstructionPolicy(createInstructionEnvelope({
 20+    target: "browser.chatgpt",
 21+    tool: "send"
 22+  }));
 23+  const denied = evaluateBaaInstructionPolicy(createInstructionEnvelope({
 24+    target: "browser.chatgpt",
 25+    tool: "reload"
 26+  }));
 27+
 28+  assert.deepEqual(allowed, {
 29+    code: null,
 30+    message: null,
 31+    ok: true
 32+  });
 33+  assert.equal(denied.ok, false);
 34+  assert.equal(denied.code, "unsupported_tool");
 35+  assert.match(denied.message ?? "", /not supported in Phase 1/i);
 36+});
 37+
 38 test("executeBaaInstruction returns a structured timeout failure when the local handler stalls", async () => {
 39   const { controlPlane, snapshot } = await createLocalApiFixture();
 40   const instruction = createInstructionEnvelope({
 41@@ -893,6 +915,70 @@ test("executeBaaInstruction returns a structured timeout failure when the local
 42   }
 43 });
 44 
 45+test("BaaInstructionCenter applies custom policy overrides for target and tool allowlists", async () => {
 46+  const { controlPlane, repository, sharedToken, snapshot } = await createLocalApiFixture();
 47+  const targets = createDefaultBaaInstructionPolicyTargetTools();
 48+  const chatgptTools = targets.get("browser.chatgpt");
 49+  assert.ok(chatgptTools);
 50+  targets.delete("browser.gemini");
 51+  chatgptTools.add("reload");
 52+  const center = new BaaInstructionCenter({
 53+    localApiContext: {
 54+      fetchImpl: globalThis.fetch,
 55+      repository,
 56+      sharedToken,
 57+      snapshotLoader: () => snapshot
 58+    },
 59+    policy: {
 60+      targets
 61+    }
 62+  });
 63+  const message = [
 64+    "```baa",
 65+    "@conductor::describe",
 66+    "```",
 67+    "",
 68+    "```baa",
 69+    "@browser.chatgpt::reload",
 70+    "```",
 71+    "",
 72+    "```baa",
 73+    "@browser.gemini::send::hello gemini",
 74+    "```"
 75+  ].join("\n");
 76+
 77+  try {
 78+    const result = await center.processAssistantMessage({
 79+      assistantMessageId: "msg-custom-policy-1",
 80+      conversationId: "conv-custom-policy-1",
 81+      platform: "claude",
 82+      text: message
 83+    });
 84+
 85+    assert.equal(result.status, "executed");
 86+    assert.equal(result.executions.length, 1);
 87+    assert.equal(result.executions[0]?.tool, "describe");
 88+    assert.equal(result.denied.length, 2);
 89+
 90+    const expandedToolDeny = result.denied.find(
 91+      (entry) => entry.instruction.target === "browser.chatgpt" && entry.instruction.tool === "reload"
 92+    );
 93+    assert.ok(expandedToolDeny);
 94+    assert.equal(expandedToolDeny.stage, "route");
 95+    assert.equal(expandedToolDeny.code, null);
 96+    assert.match(expandedToolDeny.reason, /No Phase 1 route exists/i);
 97+
 98+    const removedTargetDeny = result.denied.find(
 99+      (entry) => entry.instruction.target === "browser.gemini" && entry.instruction.tool === "send"
100+    );
101+    assert.ok(removedTargetDeny);
102+    assert.equal(removedTargetDeny.stage, "policy");
103+    assert.equal(removedTargetDeny.code, "unsupported_target");
104+  } finally {
105+    controlPlane.close();
106+  }
107+});
108+
109 test("BaaInstructionCenter runs the Phase 1 execution loop and dedupes replayed messages", async () => {
110   const { controlPlane, repository, sharedToken, snapshot } = await createLocalApiFixture();
111   const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-instruction-center-"));
M apps/conductor-daemon/src/instructions/ingest.ts
+4, -1
 1@@ -11,6 +11,7 @@ import {
 2   BaaInstructionCenterError,
 3   type BaaInstructionCenterOptions
 4 } from "./loop.js";
 5+import type { BaaInstructionPolicyConfig } from "./policy.js";
 6 import type { BaaLiveInstructionSnapshotStore } from "./store.js";
 7 import type {
 8   BaaInstructionParseErrorStage,
 9@@ -110,6 +111,7 @@ export interface BaaLiveInstructionIngestOptions {
10   localApiContext?: ConductorLocalApiContext;
11   messageDeduper?: BaaLiveInstructionMessageDeduper;
12   now?: () => number;
13+  policy?: BaaInstructionPolicyConfig | null;
14   snapshotStore?: BaaLiveInstructionSnapshotStore | null;
15 }
16 
17@@ -283,7 +285,8 @@ export class BaaLiveInstructionIngest {
18     this.center =
19       options.center
20       ?? new BaaInstructionCenter({
21-        localApiContext: options.localApiContext as BaaInstructionCenterOptions["localApiContext"]
22+        localApiContext: options.localApiContext as BaaInstructionCenterOptions["localApiContext"],
23+        policy: options.policy ?? options.localApiContext?.instructionPolicy
24       });
25     this.artifactStore = options.localApiContext?.artifactStore ?? null;
26     this.historyLimit = normalizeHistoryLimit(options.historyLimit);
M apps/conductor-daemon/src/instructions/loop.ts
+10, -2
 1@@ -15,7 +15,12 @@ import {
 2   normalizeBaaInstructionSourceMessage
 3 } from "./normalize.js";
 4 import { parseBaaInstructionBlock } from "./parse.js";
 5-import { evaluateBaaInstructionPolicy } from "./policy.js";
 6+import {
 7+  evaluateBaaInstructionPolicy,
 8+  resolveBaaInstructionPolicy,
 9+  type BaaInstructionPolicy,
10+  type BaaInstructionPolicyConfig
11+} from "./policy.js";
12 import { isAutomationControlInstruction, routeBaaInstruction } from "./router.js";
13 import type {
14   BaaAssistantMessageInput,
15@@ -42,6 +47,7 @@ export class BaaInstructionCenterError extends Error {
16 export interface BaaInstructionCenterOptions {
17   deduper?: BaaInstructionDeduper;
18   localApiContext: ConductorLocalApiContext;
19+  policy?: BaaInstructionPolicyConfig | null;
20 }
21 
22 export interface BaaInstructionProcessOptions {
23@@ -53,10 +59,12 @@ export interface BaaInstructionProcessOptions {
24 export class BaaInstructionCenter {
25   private readonly deduper: BaaInstructionDeduper;
26   private readonly localApiContext: ConductorLocalApiContext;
27+  private readonly policy: BaaInstructionPolicy;
28 
29   constructor(options: BaaInstructionCenterOptions) {
30     this.deduper = options.deduper ?? new InMemoryBaaInstructionDeduper();
31     this.localApiContext = options.localApiContext;
32+    this.policy = resolveBaaInstructionPolicy(options.policy ?? options.localApiContext.instructionPolicy);
33   }
34 
35   async processAssistantMessage(
36@@ -320,7 +328,7 @@ export class BaaInstructionCenter {
37     const routed: Array<{ instruction: BaaInstructionEnvelope; route: BaaInstructionRoute }> = [];
38 
39     for (const instruction of instructions) {
40-      const decision = evaluateBaaInstructionPolicy(instruction);
41+      const decision = evaluateBaaInstructionPolicy(instruction, this.policy);
42 
43       if (!decision.ok) {
44         denied.push({
M apps/conductor-daemon/src/instructions/policy.ts
+91, -7
  1@@ -1,6 +1,6 @@
  2 import type { BaaInstructionEnvelope } from "./types.js";
  3 
  4-const CONDUCTOR_TOOLS = new Set([
  5+const CONDUCTOR_TOOLS = [
  6   "conversation/mode",
  7   "conversation/pause",
  8   "conversation/resume",
  9@@ -13,12 +13,12 @@ const CONDUCTOR_TOOLS = new Set([
 10   "status",
 11   "system/pause",
 12   "system/resume"
 13-]);
 14-const BROWSER_LEGACY_TARGET_TOOLS = new Set([
 15+];
 16+const BROWSER_LEGACY_TARGET_TOOLS = [
 17   "current",
 18   "send"
 19-]);
 20-const SUPPORTED_TARGET_TOOLS = new Map([
 21+];
 22+const DEFAULT_TARGET_TOOLS = new Map([
 23   ["conductor", CONDUCTOR_TOOLS],
 24   ["system", CONDUCTOR_TOOLS],
 25   ["browser.claude", BROWSER_LEGACY_TARGET_TOOLS],
 26@@ -26,16 +26,100 @@ const SUPPORTED_TARGET_TOOLS = new Map([
 27   ["browser.gemini", BROWSER_LEGACY_TARGET_TOOLS]
 28 ]);
 29 
 30+export type BaaInstructionPolicyTargetToolsInput =
 31+  | ReadonlyMap<string, Iterable<string> | null | undefined>
 32+  | Record<string, Iterable<string> | null | undefined>;
 33+
 34+export interface BaaInstructionPolicyConfig {
 35+  targets?: BaaInstructionPolicyTargetToolsInput | null;
 36+}
 37+
 38+export interface BaaInstructionPolicy {
 39+  targets: ReadonlyMap<string, ReadonlySet<string>>;
 40+}
 41+
 42 export interface BaaInstructionPolicyDecision {
 43   code: string | null;
 44   message: string | null;
 45   ok: boolean;
 46 }
 47 
 48+const DEFAULT_BAA_INSTRUCTION_POLICY = resolveBaaInstructionPolicy({
 49+  targets: createDefaultBaaInstructionPolicyTargetTools()
 50+});
 51+
 52+function normalizePolicyIdentifier(kind: "target" | "tool", value: string): string {
 53+  const normalized = value.trim();
 54+
 55+  if (normalized === "") {
 56+    throw new TypeError(`BAA instruction policy ${kind} names must be non-empty strings.`);
 57+  }
 58+
 59+  return normalized;
 60+}
 61+
 62+function normalizePolicyToolSet(
 63+  target: string,
 64+  tools: Iterable<string>
 65+): ReadonlySet<string> {
 66+  const normalizedTools = new Set<string>();
 67+
 68+  for (const tool of tools) {
 69+    if (typeof tool !== "string") {
 70+      throw new TypeError(`BAA instruction policy tools for "${target}" must be strings.`);
 71+    }
 72+
 73+    normalizedTools.add(normalizePolicyIdentifier("tool", tool));
 74+  }
 75+
 76+  return normalizedTools;
 77+}
 78+
 79+function iteratePolicyTargetTools(
 80+  input: BaaInstructionPolicyTargetToolsInput
 81+): Iterable<[string, Iterable<string> | null | undefined]> {
 82+  if (input instanceof Map) {
 83+    return input.entries();
 84+  }
 85+
 86+  return Object.entries(input);
 87+}
 88+
 89+export function createDefaultBaaInstructionPolicyTargetTools(): Map<string, Set<string>> {
 90+  return new Map(
 91+    [...DEFAULT_TARGET_TOOLS.entries()].map(([target, tools]) => [target, new Set(tools)])
 92+  );
 93+}
 94+
 95+export function resolveBaaInstructionPolicy(
 96+  config: BaaInstructionPolicyConfig | null | undefined = null
 97+): BaaInstructionPolicy {
 98+  const targetsInput = config?.targets ?? createDefaultBaaInstructionPolicyTargetTools();
 99+  const targets = new Map<string, ReadonlySet<string>>();
100+
101+  for (const [target, tools] of iteratePolicyTargetTools(targetsInput)) {
102+    if (typeof target !== "string") {
103+      throw new TypeError("BAA instruction policy target names must be strings.");
104+    }
105+
106+    if (tools == null) {
107+      continue;
108+    }
109+
110+    const normalizedTarget = normalizePolicyIdentifier("target", target);
111+    targets.set(normalizedTarget, normalizePolicyToolSet(normalizedTarget, tools));
112+  }
113+
114+  return {
115+    targets
116+  };
117+}
118+
119 export function evaluateBaaInstructionPolicy(
120-  instruction: BaaInstructionEnvelope
121+  instruction: BaaInstructionEnvelope,
122+  policy: BaaInstructionPolicy = DEFAULT_BAA_INSTRUCTION_POLICY
123 ): BaaInstructionPolicyDecision {
124-  const supportedTools = SUPPORTED_TARGET_TOOLS.get(instruction.target);
125+  const supportedTools = policy.targets.get(instruction.target);
126 
127   if (!supportedTools) {
128     return {
M apps/conductor-daemon/src/local-api.ts
+2, -0
 1@@ -82,6 +82,7 @@ import {
 2   listRenewalConversationDetails,
 3   setRenewalConversationAutomationStatus
 4 } from "./renewal/conversations.js";
 5+import type { BaaInstructionPolicyConfig } from "./instructions/policy.js";
 6 
 7 interface FileStatsLike {
 8   isDirectory(): boolean;
 9@@ -354,6 +355,7 @@ export interface ConductorLocalApiContext {
10   browserStateLoader?: (() => BrowserBridgeStateSnapshot | null) | null;
11   codexdLocalApiBase?: string | null;
12   fetchImpl?: typeof fetch;
13+  instructionPolicy?: BaaInstructionPolicyConfig | null;
14   now?: () => number;
15   repository: ControlPlaneRepository | null;
16   sharedToken?: string | null;
M tasks/T-S065.md
+16, -5
 1@@ -2,7 +2,7 @@
 2 
 3 ## 状态
 4 
 5-- 当前状态:`待开始`
 6+- 当前状态:`已完成`
 7 - 规模预估:`M`
 8 - 依赖任务:无
 9 - 建议执行者:`Codex`
10@@ -117,21 +117,32 @@
11 
12 ### 开始执行
13 
14-- 执行者:
15-- 开始时间:
16+- 执行者:`Codex`
17+- 开始时间:`2026-04-01 17:40:00 +0800`
18 - 状态变更:`待开始` → `进行中`
19 
20 ### 完成摘要
21 
22-- 完成时间:
23+- 完成时间:`2026-04-01 18:02:34 +0800`
24 - 状态变更:`进行中` → `已完成`
25 - 修改了哪些文件:
26+  - `apps/conductor-daemon/src/instructions/policy.ts`
27+  - `apps/conductor-daemon/src/instructions/loop.ts`
28+  - `apps/conductor-daemon/src/instructions/ingest.ts`
29+  - `apps/conductor-daemon/src/local-api.ts`
30+  - `apps/conductor-daemon/src/index.test.js`
31+  - `tasks/T-S065.md`
32 - 核心实现思路:
33+  - 把硬编码 target/tool 白名单抽成 `policy` 配置结构,提供默认 policy 克隆入口和解析函数。
34+  - 在 `BaaInstructionCenter` 构造阶段支持注入 policy,并在 `BaaLiveInstructionIngest` 初始化阶段支持把 policy 继续透传到内部 center。
35+  - 为 `ConductorLocalApiContext` 增加可选 `instructionPolicy` 扩展点,保持默认行为不变,同时给后续 automation control / 新 target/tool 扩面留入口。
36+  - 补测试覆盖默认 policy 主线行为,以及自定义 policy 对 target/tool allowlist 的收缩与扩展。
37 - 跑了哪些测试:
38+  - `pnpm -C /Users/george/code/baa-conductor-policy-configurable/apps/conductor-daemon test`
39 
40 ### 执行过程中遇到的问题
41 
42-- 暂无
43+- 新 worktree 初始没有 `node_modules`,先执行 `pnpm -C /Users/george/code/baa-conductor-policy-configurable install --frozen-lockfile` 后再跑测试。
44 
45 ### 剩余风险
46