- 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
+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, {
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 {
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 }
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[];