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
1 change: 1 addition & 0 deletions apps/host-daemon/src/command-handlers/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export async function provisionEnvironment(
environmentId: command.environmentId,
provision: toProvisionWorkspaceOptions(command, onProgress),
setupScriptTimeoutMs: command.setupScriptTimeoutMs,
setupContributedEnv: command.contributedEnv,
});

const [branchName, resolvedDefaultBranch] = await Promise.all([
Expand Down
3 changes: 3 additions & 0 deletions apps/host-daemon/src/runtime-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
import { threadScope, turnScope } from "@bb/domain";
import type {
HostDaemonActiveThread,
HostDaemonContributedEnvEntry,
HostDaemonEnvironmentChange,
HostDaemonLoadedEnvironment,
HostDaemonInjectedSkillSource,
Expand Down Expand Up @@ -142,6 +143,7 @@ export interface EnsureEnvironmentArgs {
environmentId: string;
injectedSkillSources?: readonly HostDaemonInjectedSkillSource[];
setupScriptTimeoutMs?: number | null;
setupContributedEnv?: readonly HostDaemonContributedEnvEntry[];
targetThreadId?: string;
workspacePath?: string;
provision?: ProvisionWorkspaceArgs;
Expand Down Expand Up @@ -1216,6 +1218,7 @@ export class RuntimeManager {
await runSetupScript({
workspacePath: provision.path,
timeoutMs: args.setupScriptTimeoutMs,
contributedEnv: args.setupContributedEnv,
shellPath: this.getShellEnv().PATH,
signal: args.provisionSignal,
onProgress: provision.onProgress,
Expand Down
1 change: 1 addition & 0 deletions apps/host-daemon/test/command/command-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ function textPromptInput(text: string): TextPromptInput {
function createEnvironmentProvisionCommand(): EnvironmentProvisionCommand {
return {
type: "environment.attach",
contributedEnv: [],
environmentId: "env-router",
initiator: null,
path: "/tmp/env-router",
Expand Down
68 changes: 67 additions & 1 deletion apps/host-daemon/test/command/environment-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ describe("environment command dispatch", () => {
const result = await dispatchCommand(
{
type: "environment.attach",
contributedEnv: [],
environmentId: "env-unmanaged",
initiator: null,
path: sourcePath,
Expand Down Expand Up @@ -60,13 +61,21 @@ describe("environment command dispatch", () => {
const markerPath = `${sourcePath}/setup-marker`;
await fs.writeFile(
`${sourcePath}/.bb-env-setup.sh`,
`printf '%s' ready > '${markerPath}'\n`,
`printf '%s' \"$SETUP_VALUE\" > '${markerPath}'\n`,
);
const emittedEvents: EventSinkInput[] = [];

await dispatchCommand(
{
type: "environment.attach",
contributedEnv: [
{
name: "SETUP_VALUE",
value: "ready",
source: { plugin: "fixture" },
reason: "test",
},
],
environmentId: "env-provider-owned",
initiator: {
threadId: "thr-provider-owned",
Expand Down Expand Up @@ -109,6 +118,7 @@ describe("environment command dispatch", () => {
dispatchCommand(
{
type: "environment.attach",
contributedEnv: [],
environmentId: "env-setup-failure",
initiator: null,
path: sourcePath,
Expand Down Expand Up @@ -163,6 +173,7 @@ describe("environment command dispatch", () => {
const provision = dispatchCommand(
{
type: "environment.attach",
contributedEnv: [],
environmentId: "env-cancel",
initiator: null,
path: "/tmp/cancelled",
Expand Down Expand Up @@ -216,6 +227,7 @@ describe("environment command dispatch", () => {
const provision = dispatchCommand(
{
type: "environment.attach",
contributedEnv: [],
environmentId: "env-cancel-no-settle",
initiator: null,
path: "/tmp/cancelled-no-settle",
Expand Down Expand Up @@ -251,6 +263,7 @@ describe("environment command dispatch", () => {
await dispatchCommand(
{
type: "environment.attach",
contributedEnv: [],
environmentId: "env-stream",
initiator: {
threadId: "thr-initiator",
Expand Down Expand Up @@ -331,6 +344,7 @@ describe("environment command dispatch", () => {
await dispatchCommand(
{
type: "environment.attach",
contributedEnv: [],
environmentId: "env-batched-progress",
initiator: {
threadId: "thr-batched-progress",
Expand Down Expand Up @@ -389,6 +403,7 @@ describe("environment command dispatch", () => {
dispatchCommand(
{
type: "environment.attach",
contributedEnv: [],
environmentId: "env-failure",
initiator: {
threadId: "thr-failure",
Expand Down Expand Up @@ -428,6 +443,7 @@ describe("environment command dispatch", () => {
await dispatchCommand(
{
type: "environment.attach",
contributedEnv: [],
environmentId: "env-idempotent",
initiator: null,
path: sourcePath,
Expand All @@ -439,6 +455,7 @@ describe("environment command dispatch", () => {
const result = await dispatchCommand(
{
type: "environment.attach",
contributedEnv: [],
environmentId: "env-idempotent",
initiator: {
threadId: "thr-second",
Expand Down Expand Up @@ -471,3 +488,52 @@ describe("environment command dispatch", () => {
]);
});
});

it("cancels setup with contributions even when another attach is waiting", async () => {
const sourcePath = await makeTempDir("bb-setup-env-cancel-");
const harness = createHarness({ workspacePath: sourcePath });
await fs.writeFile(
`${sourcePath}/.bb-env-setup.sh`,
'printf "%s" "$SETUP_VALUE" > started\nsleep 120\nprintf unsafe > after-cancel\n',
);
const command = {
type: "environment.attach" as const,
contributedEnv: [
{
name: "SETUP_VALUE",
value: "configured",
source: { plugin: "fixture" },
reason: "test",
},
],
environmentId: "env-setup-cancel",
initiator: null,
path: sourcePath,
setupScriptTimeoutMs: 5000,
};
const options = harness.dispatchOptions();
const first = dispatchCommand(command, options);
const second = dispatchCommand(command, options);
const settled = Promise.allSettled([first, second]);
try {
await expect
.poll(async () => fs.readFile(`${sourcePath}/started`, "utf8"))
.toBe("configured");
await dispatchCommand(
{
type: "environment.attach.cancel",
environmentId: command.environmentId,
},
options,
);
expect(await settled).toEqual([
expect.objectContaining({ status: "rejected" }),
expect.objectContaining({ status: "rejected" }),
]);
await expect(fs.stat(`${sourcePath}/after-cancel`)).rejects.toThrow();
expect(harness.provisions).toHaveLength(0);
} finally {
await harness.manager.shutdownAll();
await settled;
}
});
21 changes: 20 additions & 1 deletion apps/server/src/services/hosts/live-command.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { getEnvironment } from "@bb/db";
import { resolveHostEnvironment } from "./host-environment.js";
import { randomUUID } from "node:crypto";
import {
type HostDaemonCommand,
Expand Down Expand Up @@ -229,8 +231,25 @@ export async function runLiveHostCommand<
args.command.type === "thread.stop"
? callHostOnlineRpc
: callHostOnlineRpcForWork;
const sourceCommand: HostDaemonCommand = args.command;
const command = {
...args.command,
...(sourceCommand.type === "environment.attach"
? {
contributedEnv:
sourceCommand.setupScriptTimeoutMs === null
? []
: await resolveHostEnvironment(deps, {
hostId: args.hostId,
projectId:
getEnvironment(deps.db, sourceCommand.environmentId)
?.projectId ?? null,
}),
}
: {}),
};
const result = await call(deps, {
command: args.command,
command,
hostId: args.hostId,
timeoutMs: args.timeoutMs,
});
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/services/threads/thread-create-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export function buildEnvironmentProvisionCommand(
): EnvironmentProvisionCommand {
return {
type: "environment.attach" as const,
contributedEnv: [],
environmentId: args.environmentId,
initiator: args.initiator,
path: args.path,
Expand Down
147 changes: 146 additions & 1 deletion apps/server/test/hosts/live-command.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
import {
createEnvironment,
getEnvironment,
getAppSettings,
setAppSettings,
updateHost,
} from "@bb/db";
import { rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { updateMachineEnvironment } from "../../src/services/machines/environment-settings.js";
import { buildEnvironmentProvisionCommand } from "../../src/services/threads/thread-create-helpers.js";
import { describe, expect, it, vi } from "vitest";
import {
LIVE_DAEMON_COMMAND_TIMEOUT_MS,
startLiveHostCommand,
runLiveHostCommand,
} from "../../src/services/hosts/live-command.js";
import {
reportQueuedCommandError,
waitForQueuedCommand,
} from "../helpers/commands.js";
import { seedHostSession } from "../helpers/seed.js";
import { seedHostSession, seedProjectWithSource } from "../helpers/seed.js";
import { withTestHarness } from "../helpers/test-app.js";

describe("live host command logging", () => {
Expand Down Expand Up @@ -81,3 +93,136 @@ describe("live host command logging", () => {
});
});
});

it("resolves fresh setup values at dispatch without retaining them in the request", async () => {
await withTestHarness(async (harness) => {
setAppSettings(harness.db, {
...getAppSettings(harness.db),
machineGitCredentialsEnabled: false,
});
const { host } = seedHostSession(harness.deps);
updateHost(harness.db, harness.hub, host.id, {
machineProviderId: "manual",
});
const { project } = seedProjectWithSource(harness.deps, {
hostId: host.id,
});
const environment = createEnvironment(harness.db, harness.hub, {
projectId: project.id,
hostId: host.id,
providerOwnsPath: true,
});
const command = buildEnvironmentProvisionCommand({
environmentId: environment.id,
hostId: host.id,
initiator: null,
path: "/tmp/setup-values",
setupScriptTimeoutMs: 1000,
});
const original = JSON.stringify(command);
const request = vi
.spyOn(harness.hub, "requestHostOnlineRpc")
.mockImplementation(async ({ message }) => ({
type: "host-rpc.response",
requestId: message.requestId,
commandType: "environment.attach",
ok: true,
result: {
path: command.path,
isGitRepo: false,
isWorktree: false,
branchName: null,
defaultBranch: null,
},
}));
for (const value of ["first-secret", "refreshed-secret"]) {
await updateMachineEnvironment(
harness.db,
harness.config.dataDir,
"SETUP_VALUE",
{ name: "SETUP_VALUE", value, note: null },
);
await runLiveHostCommand(harness.deps, {
command,
hostId: host.id,
timeoutMs: 1000,
});
expect(request.mock.lastCall?.[0].message.command).toMatchObject({
contributedEnv: [
expect.objectContaining({ name: "SETUP_VALUE", value }),
],
});
expect(JSON.stringify(command)).toBe(original);
expect(
JSON.stringify(getEnvironment(harness.db, environment.id)),
).not.toContain(value);
expect(
JSON.stringify(
harness.db.$client.prepare("SELECT * FROM app_settings_values").all(),
),
).not.toContain(value);
}
await runLiveHostCommand(harness.deps, {
command: { ...command, setupScriptTimeoutMs: null },
hostId: host.id,
timeoutMs: 1000,
});
expect(request.mock.lastCall?.[0].message.command).toMatchObject({
contributedEnv: [],
});
await writeFile(join(harness.config.dataDir, "host-id"), host.id);
await runLiveHostCommand(harness.deps, {
command,
hostId: host.id,
timeoutMs: 1000,
});
expect(request.mock.lastCall?.[0].message.command).toMatchObject({
contributedEnv: [],
});
});
});

it("fails provisioning if saved setup variables cannot be decrypted", async () => {
await withTestHarness(async (harness) => {
setAppSettings(harness.db, {
...getAppSettings(harness.db),
machineGitCredentialsEnabled: false,
});
const { host } = seedHostSession(harness.deps);
updateHost(harness.db, harness.hub, host.id, {
machineProviderId: "manual",
});
const { project } = seedProjectWithSource(harness.deps, {
hostId: host.id,
});
const environment = createEnvironment(harness.db, harness.hub, {
projectId: project.id,
hostId: host.id,
providerOwnsPath: true,
});
await updateMachineEnvironment(
harness.db,
harness.config.dataDir,
"SETUP_VALUE",
{ name: "SETUP_VALUE", value: "private-value", note: null },
);
await rm(join(harness.config.dataDir, "machine-environment-key"));
const request = vi.spyOn(harness.hub, "requestHostOnlineRpc");
const command = buildEnvironmentProvisionCommand({
environmentId: environment.id,
hostId: host.id,
initiator: null,
path: "/tmp/setup-values",
setupScriptTimeoutMs: 1000,
});
await expect(
runLiveHostCommand(harness.deps, {
command,
hostId: host.id,
timeoutMs: 1000,
}),
).rejects.toThrow("Machine environment encryption key is unavailable");
expect(request).not.toHaveBeenCalled();
expect(getEnvironment(harness.db, environment.id)?.status).toBe("error");
});
});
Loading
Loading