Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-comparisons-stop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/brunch": patch
---

Block host landing from isolated execution comparison runs.
48 changes: 47 additions & 1 deletion src/.pi/extensions/executor/__tests__/execute-land.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import { describe, expect, it } from 'vitest';
import { createFakeGitHostLandPort } from '../../../../executor/__tests__/fake-ports.js';
import { promotionReportPath } from '../../../../executor/promotion.js';
import { runMetadataPath, runDirPath } from '../../../../executor/run.js';
import { createExecuteLandPreflightTool, runBrunchLandCommand } from '../execute-land/index.js';
import {
createExecuteLandPreflightTool,
registerBrunchExecuteLand,
runBrunchLandCommand,
} from '../execute-land/index.js';

const TIP = 'tip456';

Expand Down Expand Up @@ -80,6 +84,29 @@ async function runStatus(cwd: string, runId = 'run-1'): Promise<string> {
}

describe('runBrunchLandCommand', () => {
it('refuses landing under comparison policy before inspection or confirmation', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'brunch-land-cmd-comparison-'));
await createPromotionPreparedRun(cwd);
const ui = makeUi();
const inspectCalls: unknown[] = [];
const gitHostLand = createFakeGitHostLandPort({
async inspect(args) {
inspectCalls.push(args);
return { status: 'failed', message: 'must not inspect', sideEffects: [] };
},
});

await runBrunchLandCommand('run-1', stubCtx(cwd, ui), {
gitHostLand,
allowHostLanding: false,
});

expect(inspectCalls).toEqual([]);
expect(ui.confirms).toEqual([]);
expect(ui.notifications).toEqual(['Landing is disabled for isolated execution comparisons.']);
await expect(runStatus(cwd)).resolves.toBe('promotion_prepared');
});

it('lands an explicit run after the user confirms', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'brunch-land-cmd-confirm-'));
await createPromotionPreparedRun(cwd);
Expand Down Expand Up @@ -412,6 +439,25 @@ describe('runBrunchLandCommand', () => {
});
});

describe('registerBrunchExecuteLand', () => {
it('registers no landing surface when host landing is disabled', () => {
const commands: string[] = [];
const tools: string[] = [];

registerBrunchExecuteLand(
{
registerCommand: (name: string) => commands.push(name),
registerTool: (tool: { name: string }) => tools.push(tool.name),
} as never,
createFakeGitHostLandPort(),
{ allowHostLanding: false },
);

expect(commands).toEqual([]);
expect(tools).toEqual([]);
});
});

describe('createExecuteLandPreflightTool', () => {
it('renders landing readiness read-only and points at /brunch:land', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'brunch-land-preflight-tool-'));
Expand Down
23 changes: 20 additions & 3 deletions src/.pi/extensions/executor/execute-land/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,20 @@ export interface LandCommandContext {
};
}

export interface HostLandingPolicyOptions {
/** Defaults on; isolated execution comparisons disable every landing surface. */
readonly allowHostLanding?: boolean;
}

export async function runBrunchLandCommand(
args: string,
ctx: LandCommandContext,
deps: { readonly gitHostLand: GitHostLandPort },
deps: { readonly gitHostLand: GitHostLandPort } & HostLandingPolicyOptions,
): Promise<void> {
if (deps.allowHostLanding === false) {
ctx.ui.notify('Landing is disabled for isolated execution comparisons.', 'warning');
return;
}
if (!ctx.hasUI) {
ctx.ui.notify('/brunch:land requires an interactive session to confirm the landing.', 'error');
return;
Expand Down Expand Up @@ -160,12 +169,20 @@ function splitLandCommandArgs(args: string): readonly [string | undefined, strin
return [trimmed.slice(0, boundary), target || undefined];
}

export function registerBrunchExecuteLand(pi: ExtensionAPI, gitHostLand: GitHostLandPort): void {
export function registerBrunchExecuteLand(
pi: ExtensionAPI,
gitHostLand: GitHostLandPort,
options: HostLandingPolicyOptions = {},
): void {
if (options.allowHostLanding === false) return;
pi.registerTool(createExecuteLandPreflightTool() as never);
pi.registerCommand(BRUNCH_LAND_COMMAND, {
description: 'Review and land a promoted run into the host (user-confirmed)',
handler: async (args, ctx) => {
await runBrunchLandCommand(args ?? '', ctx as unknown as LandCommandContext, { gitHostLand });
await runBrunchLandCommand(args ?? '', ctx as unknown as LandCommandContext, {
gitHostLand,
...options,
});
},
});
}
Expand Down
27 changes: 27 additions & 0 deletions src/app/__tests__/pi-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,33 @@ import { createBrunchAgentSessionRuntimeFactory } from '../brunch-tui.js';
import { appendBrunchAgentRuntimeSwitch } from '../pi-extensions.js';

describe('Brunch Pi runtime', () => {
it('exposes no host-landing surface in an isolated execution comparison', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'brunch-comparison-runtime-'));
const agentDir = await mkdtemp(join(tmpdir(), 'brunch-agent-dir-'));
const coordinator = createWorkspaceSessionCoordinator({ cwd });
const workspace = await coordinator.createSetupSession({
specTitle: 'Comparison runtime',
createNewSpec: true,
});
const createRuntime = createBrunchAgentSessionRuntimeFactory({
workspace,
coordinator,
comparisonIsolation: { targetRoot: cwd },
});
const created = await createRuntime({
cwd,
agentDir,
sessionManager: workspace.session.manager,
});

try {
expect(created.session.extensionRunner.getCommand('brunch:land')).toBeUndefined();
expect(created.session.getToolDefinition('execute_land_preflight')).toBeUndefined();
} finally {
created.session.dispose();
}
});

it('registers graph and read-only tools without built-in write tools on the product runtime path', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'brunch-tui-graph-runtime-'));
const agentDir = await mkdtemp(join(tmpdir(), 'brunch-agent-dir-'));
Expand Down
1 change: 1 addition & 0 deletions src/app/brunch-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,7 @@ export function createBrunchAgentSessionRuntimeFactory(
...(comparisonIsolation
? {
allowWebTools: false,
allowHostLanding: false,
foregroundFilesystemRoot: comparisonIsolation.targetRoot,
}
: {}),
Expand Down
9 changes: 8 additions & 1 deletion src/app/pi-extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,8 @@ export interface BrunchPiExtensionsOptions extends BrunchCommandsOptions {
foregroundFilesystemRoot?: string;
/** Defaults on; strict comparison launches set false. */
allowWebTools?: boolean;
/** Defaults on; strict comparison launches must stop at promotion_prepared. */
allowHostLanding?: boolean;
/**
* Optional subagent registry (D44-L/D92-L). When provided with a non-empty
* code-owned delegatable set, the product `subagent` tool is registered and
Expand Down Expand Up @@ -378,7 +380,12 @@ export function createBrunchPiExtensions(
...(graph ? [(api: ExtensionAPI) => registerBrunchExecutePlanPreview(api, graph)] : []),
registerBrunchExecutePetriExport,
(api) => registerBrunchExecutePromotionPrepare(api, executionPorts.gitRunPromotion),
(api) => registerBrunchExecuteLand(api, executionPorts.gitHostLand),
(api) =>
registerBrunchExecuteLand(
api,
executionPorts.gitHostLand,
options.allowHostLanding === undefined ? {} : { allowHostLanding: options.allowHostLanding },
),
registerBrunchExecutePopulate,
registerBrunchExecuteReportInit,
registerBrunchExecuteRunComplete,
Expand Down
Loading