baa-conductor

git clone 

commit
f08e7dd
parent
7721466
author
im_wower
date
2026-03-28 02:07:42 +0800 CST
fix: keep baa batches running after denied preflight
4 files changed,  +183, -48
M apps/conductor-daemon/src/index.test.js
+120, -32
  1@@ -12,7 +12,6 @@ import { ConductorLocalControlPlane } from "../dist/local-control-plane.js";
  2 import { FirefoxCommandBroker } from "../dist/firefox-bridge.js";
  3 import {
  4   BaaInstructionCenter,
  5-  BaaInstructionCenterError,
  6   BaaLiveInstructionIngest,
  7   BrowserRequestPolicyController,
  8   ConductorDaemon,
  9@@ -697,10 +696,10 @@ test("BaaInstructionCenter runs the Phase 1 execution loop and dedupes replayed
 10   }
 11 });
 12 
 13-test("BaaInstructionCenter fails closed before execution when a batch contains an unsupported instruction", async () => {
 14+test("BaaInstructionCenter keeps supported instructions running when one instruction is denied", async () => {
 15   const { controlPlane, repository, sharedToken, snapshot } = await createLocalApiFixture();
 16-  const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-instruction-center-fail-closed-"));
 17-  const blockedFilePath = join(hostOpsDir, "should-not-exist.txt");
 18+  const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-instruction-center-partial-deny-"));
 19+  const allowedFilePath = join(hostOpsDir, "still-runs.txt");
 20   const center = new BaaInstructionCenter({
 21     localApiContext: {
 22       fetchImpl: globalThis.fetch,
 23@@ -711,31 +710,48 @@ test("BaaInstructionCenter fails closed before execution when a batch contains a
 24   });
 25   const message = [
 26     "```baa",
 27-    `@conductor::files/write::{"path":"should-not-exist.txt","cwd":${JSON.stringify(hostOpsDir)},"content":"must not run","overwrite":true}`,
 28+    `@conductor::files/write::{"path":"still-runs.txt","cwd":${JSON.stringify(hostOpsDir)},"content":"partial deny is open","overwrite":true}`,
 29     "```",
 30     "",
 31     "```baa",
 32     "@browser.chatgpt::send::draw a cat",
 33+    "```",
 34+    "",
 35+    "```baa",
 36+    `@conductor::exec::{"command":"printf 'instruction-still-runs'","cwd":${JSON.stringify(hostOpsDir)}}`,
 37     "```"
 38   ].join("\n");
 39 
 40   try {
 41-    await assert.rejects(
 42-      () =>
 43-        center.processAssistantMessage({
 44-          assistantMessageId: "msg-fail-closed-1",
 45-          conversationId: "conv-fail-closed-1",
 46-          platform: "claude",
 47-          text: message
 48-        }),
 49-      (error) => {
 50-        assert.ok(error instanceof BaaInstructionCenterError);
 51-        assert.equal(error.stage, "policy");
 52-        assert.equal(error.blockIndex, 1);
 53-        return true;
 54-      }
 55-    );
 56-    assert.equal(existsSync(blockedFilePath), false);
 57+    const result = await center.processAssistantMessage({
 58+      assistantMessageId: "msg-partial-deny-1",
 59+      conversationId: "conv-partial-deny-1",
 60+      platform: "claude",
 61+      text: message
 62+    });
 63+
 64+    assert.equal(result.status, "executed");
 65+    assert.equal(result.instructions.length, 3);
 66+    assert.equal(result.duplicates.length, 0);
 67+    assert.equal(result.executions.length, 2);
 68+    assert.equal(result.denied.length, 1);
 69+
 70+    const writeExecution = result.executions.find((execution) => execution.tool === "files/write");
 71+    assert.ok(writeExecution);
 72+    assert.equal(writeExecution.ok, true);
 73+    assert.equal(readFileSync(allowedFilePath, "utf8"), "partial deny is open");
 74+
 75+    const execExecution = result.executions.find((execution) => execution.tool === "exec");
 76+    assert.ok(execExecution);
 77+    assert.equal(execExecution.ok, true);
 78+    assert.equal(execExecution.data.result.stdout, "instruction-still-runs");
 79+
 80+    assert.equal(result.denied[0].blockIndex, 1);
 81+    assert.equal(result.denied[0].stage, "policy");
 82+    assert.equal(result.denied[0].code, "unsupported_target");
 83+    assert.equal(result.denied[0].instruction.target, "browser.chatgpt");
 84+    assert.equal(result.denied[0].instruction.tool, "send");
 85+    assert.match(result.denied[0].reason, /not supported in Phase 1/i);
 86   } finally {
 87     controlPlane.close();
 88     rmSync(hostOpsDir, {
 89@@ -745,7 +761,47 @@ test("BaaInstructionCenter fails closed before execution when a batch contains a
 90   }
 91 });
 92 
 93-test("BaaLiveInstructionIngest ignores plain messages, dedupes replayed browser final messages, and tolerates missing conversation ids", async () => {
 94+test("BaaInstructionCenter returns denied_only when every pending instruction is denied", async () => {
 95+  const { controlPlane, repository, sharedToken, snapshot } = await createLocalApiFixture();
 96+  const center = new BaaInstructionCenter({
 97+    localApiContext: {
 98+      fetchImpl: globalThis.fetch,
 99+      repository,
100+      sharedToken,
101+      snapshotLoader: () => snapshot
102+    }
103+  });
104+  const message = [
105+    "```baa",
106+    "@browser.chatgpt::send::draw a cat",
107+    "```",
108+    "",
109+    "```baa",
110+    "@browser.claude::current",
111+    "```"
112+  ].join("\n");
113+
114+  try {
115+    const result = await center.processAssistantMessage({
116+      assistantMessageId: "msg-denied-only-1",
117+      conversationId: "conv-denied-only-1",
118+      platform: "claude",
119+      text: message
120+    });
121+
122+    assert.equal(result.status, "denied_only");
123+    assert.equal(result.executions.length, 0);
124+    assert.equal(result.denied.length, 2);
125+    assert.deepEqual(
126+      result.denied.map((entry) => entry.instruction.target),
127+      ["browser.chatgpt", "browser.claude"]
128+    );
129+  } finally {
130+    controlPlane.close();
131+  }
132+});
133+
134+test("BaaLiveInstructionIngest ignores plain messages, dedupes replayed browser final messages, tolerates missing conversation ids, and surfaces partial denies", async () => {
135   const { controlPlane, repository, sharedToken, snapshot } = await createLocalApiFixture();
136   const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-live-instruction-ingest-"));
137   const dedupeFilePath = join(hostOpsDir, "dedupe-count.txt");
138@@ -812,30 +868,62 @@ test("BaaLiveInstructionIngest ignores plain messages, dedupes replayed browser
139     assert.equal(replayPass.summary.execution_count, 0);
140     assert.equal(readFileSync(dedupeFilePath, "utf8"), "live-hit\n");
141 
142-    const failed = await ingest.ingestAssistantFinalMessage({
143-      assistantMessageId: "msg-live-fail-closed",
144+    const partialDeny = await ingest.ingestAssistantFinalMessage({
145+      assistantMessageId: "msg-live-partial-deny",
146       conversationId: null,
147       observedAt: 1_710_000_004_000,
148       platform: "chatgpt",
149       source: "browser.final_message",
150       text: [
151         "```baa",
152-        `@conductor::files/write::{"path":"should-not-exist.txt","cwd":${JSON.stringify(hostOpsDir)},"content":"must not run","overwrite":true}`,
153+        `@conductor::exec::{"command":"printf 'partial-live\\n' >> dedupe-count.txt","cwd":${JSON.stringify(hostOpsDir)}}`,
154         "```",
155         "",
156         "```baa",
157-        "@browser.chatgpt::send::should fail closed",
158+        "@conductor::files/write::should route fail",
159         "```"
160       ].join("\n")
161     });
162 
163-    assert.equal(failed.summary.status, "failed");
164-    assert.equal(failed.summary.error_stage, "policy");
165-    assert.equal(failed.summary.error_block_index, 1);
166-    assert.equal(existsSync(join(hostOpsDir, "should-not-exist.txt")), false);
167+    assert.equal(partialDeny.summary.status, "executed");
168+    assert.equal(partialDeny.summary.execution_count, 1);
169+    assert.equal(partialDeny.summary.execution_ok_count, 1);
170+    assert.equal(partialDeny.summary.error_stage, null);
171+    assert.ok(partialDeny.processResult);
172+    assert.equal(partialDeny.processResult.executions.length, 1);
173+    assert.equal(partialDeny.processResult.denied.length, 1);
174+    assert.equal(partialDeny.processResult.denied[0].blockIndex, 1);
175+    assert.equal(partialDeny.processResult.denied[0].stage, "route");
176+    assert.equal(partialDeny.processResult.denied[0].code, null);
177+    assert.equal(partialDeny.processResult.denied[0].instruction.tool, "files/write");
178+    assert.match(partialDeny.processResult.denied[0].reason, /requires JSON object params/i);
179+    assert.equal(readFileSync(dedupeFilePath, "utf8"), "live-hit\npartial-live\n");
180+
181+    const deniedOnly = await ingest.ingestAssistantFinalMessage({
182+      assistantMessageId: "msg-live-denied-only",
183+      conversationId: null,
184+      observedAt: 1_710_000_005_000,
185+      platform: "chatgpt",
186+      source: "browser.final_message",
187+      text: [
188+        "```baa",
189+        "@browser.chatgpt::send::still denied",
190+        "```"
191+      ].join("\n")
192+    });
193 
194-    assert.equal(ingest.getSnapshot().last_ingest?.assistant_message_id, "msg-live-fail-closed");
195-    assert.equal(ingest.getSnapshot().last_execute?.status, "failed");
196+    assert.equal(deniedOnly.summary.status, "denied_only");
197+    assert.equal(deniedOnly.summary.execution_count, 0);
198+    assert.equal(deniedOnly.summary.execution_ok_count, 0);
199+    assert.equal(deniedOnly.summary.error_stage, null);
200+    assert.ok(deniedOnly.processResult);
201+    assert.equal(deniedOnly.processResult.executions.length, 0);
202+    assert.equal(deniedOnly.processResult.denied.length, 1);
203+    assert.equal(deniedOnly.processResult.denied[0].stage, "policy");
204+    assert.equal(deniedOnly.processResult.denied[0].code, "unsupported_target");
205+
206+    assert.equal(ingest.getSnapshot().last_ingest?.assistant_message_id, "msg-live-denied-only");
207+    assert.equal(ingest.getSnapshot().last_execute?.status, "denied_only");
208   } finally {
209     controlPlane.close();
210     rmSync(hostOpsDir, {
M apps/conductor-daemon/src/instructions/ingest.ts
+9, -1
 1@@ -12,6 +12,7 @@ import type { BaaInstructionProcessResult, BaaInstructionProcessStatus } from ".
 2 import { stableStringifyBaaJson } from "./types.js";
 3 
 4 export type BaaLiveInstructionIngestStatus =
 5+  | "denied_only"
 6   | "duplicate_message"
 7   | "duplicate_only"
 8   | "executed"
 9@@ -108,6 +109,8 @@ function buildInstructionDescriptor(target: string, tool: string): string {
10 
11 function classifyProcessStatus(status: BaaInstructionProcessStatus): BaaLiveInstructionIngestStatus {
12   switch (status) {
13+    case "denied_only":
14+      return "denied_only";
15     case "duplicate_only":
16       return "duplicate_only";
17     case "executed":
18@@ -118,7 +121,12 @@ function classifyProcessStatus(status: BaaInstructionProcessStatus): BaaLiveInst
19 }
20 
21 function shouldUpdateExecutionSummary(status: BaaLiveInstructionIngestStatus): boolean {
22-  return status === "duplicate_only" || status === "executed" || status === "failed";
23+  return (
24+    status === "denied_only"
25+    || status === "duplicate_only"
26+    || status === "executed"
27+    || status === "failed"
28+  );
29 }
30 
31 function normalizeOptionalString(value: string | null | undefined): string | null {
M apps/conductor-daemon/src/instructions/loop.ts
+38, -14
  1@@ -9,6 +9,7 @@ import { evaluateBaaInstructionPolicy } from "./policy.js";
  2 import { routeBaaInstruction } from "./router.js";
  3 import type {
  4   BaaAssistantMessageInput,
  5+  BaaInstructionDeniedResult,
  6   BaaInstructionEnvelope,
  7   BaaInstructionProcessResult,
  8   BaaInstructionRoute
  9@@ -49,6 +50,7 @@ export class BaaInstructionCenter {
 10     if (blocks.length === 0) {
 11       return {
 12         blocks,
 13+        denied: [],
 14         duplicates: [],
 15         executions: [],
 16         instructions: [],
 17@@ -72,6 +74,7 @@ export class BaaInstructionCenter {
 18     if (pending.length === 0) {
 19       return {
 20         blocks,
 21+        denied: [],
 22         duplicates,
 23         executions: [],
 24         instructions,
 25@@ -79,24 +82,25 @@ export class BaaInstructionCenter {
 26       };
 27     }
 28 
 29-    const routedInstructions = this.preflight(pending);
 30+    const { denied, routed } = this.preflight(pending);
 31 
 32     for (const instruction of pending) {
 33       await this.deduper.add(instruction);
 34     }
 35 
 36     const executions = await Promise.all(
 37-      routedInstructions.map(({ instruction, route }) =>
 38+      routed.map(({ instruction, route }) =>
 39         executeBaaInstruction(instruction, route, this.localApiContext)
 40       )
 41     );
 42 
 43     return {
 44       blocks,
 45+      denied,
 46       duplicates,
 47       executions,
 48       instructions,
 49-      status: "executed"
 50+      status: executions.length === 0 && denied.length > 0 ? "denied_only" : "executed"
 51     };
 52   }
 53 
 54@@ -148,27 +152,47 @@ export class BaaInstructionCenter {
 55 
 56   private preflight(
 57     instructions: BaaInstructionEnvelope[]
 58-  ): Array<{ instruction: BaaInstructionEnvelope; route: BaaInstructionRoute }> {
 59-    return instructions.map((instruction) => {
 60+  ): {
 61+    denied: BaaInstructionDeniedResult[];
 62+    routed: Array<{ instruction: BaaInstructionEnvelope; route: BaaInstructionRoute }>;
 63+  } {
 64+    const denied: BaaInstructionDeniedResult[] = [];
 65+    const routed: Array<{ instruction: BaaInstructionEnvelope; route: BaaInstructionRoute }> = [];
 66+
 67+    for (const instruction of instructions) {
 68       const decision = evaluateBaaInstructionPolicy(instruction);
 69 
 70       if (!decision.ok) {
 71-        throw new BaaInstructionCenterError(
 72-          "policy",
 73-          decision.message ?? "BAA instruction was denied by policy.",
 74-          instruction.blockIndex
 75-        );
 76+        denied.push({
 77+          blockIndex: instruction.blockIndex,
 78+          code: decision.code,
 79+          instruction,
 80+          reason: decision.message ?? "BAA instruction was denied by policy.",
 81+          stage: "policy"
 82+        });
 83+        continue;
 84       }
 85 
 86       try {
 87-        return {
 88+        routed.push({
 89           instruction,
 90           route: routeBaaInstruction(instruction)
 91-        };
 92+        });
 93       } catch (error) {
 94         const message = error instanceof Error ? error.message : String(error);
 95-        throw new BaaInstructionCenterError("route", message, instruction.blockIndex);
 96+        denied.push({
 97+          blockIndex: instruction.blockIndex,
 98+          code: null,
 99+          instruction,
100+          reason: message,
101+          stage: "route"
102+        });
103       }
104-    });
105+    }
106+
107+    return {
108+      denied,
109+      routed
110+    };
111   }
112 }
M apps/conductor-daemon/src/instructions/types.ts
+16, -1
 1@@ -6,7 +6,11 @@ export interface BaaJsonObject {
 2 
 3 export type BaaInstructionParams = BaaJsonValue;
 4 export type BaaInstructionParamsKind = "body" | "inline_json" | "inline_string" | "none";
 5-export type BaaInstructionProcessStatus = "duplicate_only" | "executed" | "no_instructions";
 6+export type BaaInstructionProcessStatus =
 7+  | "denied_only"
 8+  | "duplicate_only"
 9+  | "executed"
10+  | "no_instructions";
11 
12 export interface BaaExtractedBlock {
13   blockIndex: number;
14@@ -63,6 +67,16 @@ export interface BaaInstructionRoute {
15   requiresSharedToken: boolean;
16 }
17 
18+export type BaaInstructionDeniedStage = "policy" | "route";
19+
20+export interface BaaInstructionDeniedResult {
21+  blockIndex: number;
22+  code: string | null;
23+  instruction: BaaInstructionEnvelope;
24+  reason: string;
25+  stage: BaaInstructionDeniedStage;
26+}
27+
28 export interface BaaInstructionExecutionResult {
29   data: BaaJsonValue | null;
30   dedupeKey: string;
31@@ -88,6 +102,7 @@ export interface BaaAssistantMessageInput extends BaaInstructionSourceMessage {
32 
33 export interface BaaInstructionProcessResult {
34   blocks: BaaExtractedBlock[];
35+  denied: BaaInstructionDeniedResult[];
36   duplicates: BaaInstructionEnvelope[];
37   executions: BaaInstructionExecutionResult[];
38   instructions: BaaInstructionEnvelope[];