Skip to content
Open
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
36 changes: 36 additions & 0 deletions apps/cli/src/__tests__/command-output/thread-spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,41 @@ describe("bb thread spawn command output", () => {
});
});

it("bb thread spawn forwards repeated environment variables", async () => {
const thread: domain.Thread = fixtures.makeThread({
id: "thread-env",
projectId: "proj-1",
providerId: "codex",
});
const post = vi.fn(async () => thread);
stubServerApi({ "v1.threads.$post": post });

await runCommand(
[
"thread",
"spawn",
"--project",
"proj-1",
"--prompt",
"hello",
"--env",
"MULTICA_TASK_ID=task-123",
"--env",
"MULTICA_TOKEN=prefix=value",
],
register,
);

expect(post).toHaveBeenCalledWith({
json: expect.objectContaining({
envVars: {
MULTICA_TASK_ID: "task-123",
MULTICA_TOKEN: "prefix=value",
},
}),
});
});

it("bb thread spawn forwards hidden visibility", async () => {
const thread: domain.Thread = fixtures.makeThread({
id: "thread-hidden",
Expand Down Expand Up @@ -333,6 +368,7 @@ describe("bb thread spawn command output", () => {
const helpOutput = await getHelpOutput(["thread", "spawn"], register);
expect(helpOutput).toContain("--permission-mode <mode>");
expect(helpOutput).toContain("--visibility <visibility>");
expect(helpOutput).toContain("--env <KEY=VALUE>");
expect(helpOutput).toContain("Exact Git ref");
expect(helpOutput).toContain("origin/<branch> for a remote ref");
expect(helpOutput).toContain("bb environment providers");
Expand Down
31 changes: 31 additions & 0 deletions apps/cli/src/__tests__/spawn-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { DEFAULT_THREAD_WAIT_POLL_INTERVAL_MS } from "@bb/sdk";
import {
buildSpawnEnvironment,
looksLikePath,
parseThreadEnvVars,
requireHostId,
} from "../commands/thread/spawn.js";
import {
Expand Down Expand Up @@ -84,6 +85,36 @@ describe("looksLikePath", () => {
});
});

describe("parseThreadEnvVars", () => {
it("parses repeated entries using the first equals sign", () => {
expect(parseThreadEnvVars(["TOKEN=prefix=value", "EMPTY="])).toEqual({
TOKEN: "prefix=value",
EMPTY: "",
});
expect(parseThreadEnvVars(undefined)).toBeUndefined();
});

it.each([
[["TOKEN"], "Expected KEY=VALUE"],
[["1TOKEN=value"], "Invalid --env variable name '1TOKEN'"],
[["BB_THREAD_ID=value"], "reserved BB_ prefix"],
[["TOKEN=one", "TOKEN=two"], "Duplicate --env variable 'TOKEN'"],
[[`TOKEN=ok\0bad`], "must not contain a null byte"],
])("rejects invalid entries %#", (values, message) => {
expect(() => parseThreadEnvVars(values)).toThrow(message);
});

it("reports map limits before sending a request", () => {
const values = Array.from(
{ length: 33 },
(_, index) => `VALUE_${index}=value`,
);
expect(() => parseThreadEnvVars(values)).toThrow(
"must contain at most 32 entries",
);
});
});

describe("requireHostId", () => {
it("throws when host ID is null", () => {
expect(() => requireHostId(null)).toThrow("Cannot reach local host daemon");
Expand Down
53 changes: 53 additions & 0 deletions apps/cli/src/commands/thread/spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ import { Command } from "commander";
import {
jsonValueSchema,
PERSONAL_PROJECT_ID,
threadEnvVarNameSchema,
threadEnvVarValueSchema,
threadEnvVarsSchema,
threadVisibilitySchema,
type GitBranchSelection,
type Thread,
type JsonValue,
type ThreadEnvVars,
} from "@bb/domain";
import type { CreateThreadEnvironmentArgs } from "@bb/server-contract";
import { action } from "../../action.js";
Expand Down Expand Up @@ -42,6 +46,7 @@ interface ThreadSpawnCommandOptions {
json?: boolean;
project?: string;
environment?: string;
env?: string[];
newEnvironment?: string;
environmentProvider?: string;
environmentInputs?: string;
Expand Down Expand Up @@ -71,6 +76,46 @@ export function looksLikePath(value: string): boolean {
return value.includes("/") || value.startsWith(".") || value.startsWith("~");
}

export function parseThreadEnvVars(
values: readonly string[] | undefined,
): ThreadEnvVars | undefined {
if (values === undefined || values.length === 0) return undefined;
const entries: [string, string][] = [];
const names = new Set<string>();
for (const entry of values) {
const separatorIndex = entry.indexOf("=");
if (separatorIndex === -1) {
throw new Error("Invalid --env entry. Expected KEY=VALUE.");
}
const name = entry.slice(0, separatorIndex);
const value = entry.slice(separatorIndex + 1);
const parsedName = threadEnvVarNameSchema.safeParse(name);
if (!parsedName.success) {
throw new Error(
`Invalid --env variable name '${name}': ${parsedName.error.issues[0]?.message ?? "is invalid"}.`,
);
}
if (names.has(parsedName.data)) {
throw new Error(`Duplicate --env variable '${parsedName.data}'.`);
}
const parsedValue = threadEnvVarValueSchema.safeParse(value);
if (!parsedValue.success) {
throw new Error(
`Invalid --env value for '${parsedName.data}': ${parsedValue.error.issues[0]?.message ?? "is invalid"}.`,
);
}
names.add(parsedName.data);
entries.push([parsedName.data, parsedValue.data]);
}
const parsed = threadEnvVarsSchema.safeParse(Object.fromEntries(entries));
if (!parsed.success) {
throw new Error(
`Invalid --env values: ${parsed.error.issues[0]?.message ?? "the environment is invalid"}.`,
);
}
return parsed.data;
}

export function requireHostId(hostId: string | null): string {
if (!hostId) {
throw new Error("Cannot reach local host daemon. Is it running?");
Expand Down Expand Up @@ -265,6 +310,12 @@ export function registerSpawnCommand(
"--environment <id-or-path>",
"Existing environment ID or unmanaged workspace path",
)
.option(
"--env <KEY=VALUE>",
"Set a per-thread environment variable (repeatable)",
collectOption,
[],
)
.option(
"--new-environment <kind>",
"Create a fresh environment of the given kind (personal or worktree)",
Expand Down Expand Up @@ -410,6 +461,7 @@ export function registerSpawnCommand(
const sendAt =
opts.sendAt === undefined ? undefined : parseSendAt(opts.sendAt);
const providerId = opts.provider?.trim();
const envVars = parseThreadEnvVars(opts.env);

let thread: Thread;
try {
Expand All @@ -418,6 +470,7 @@ export function registerSpawnCommand(
origin: "cli",
projectId,
...(providerId ? { providerId } : {}),
...(envVars ? { envVars } : {}),
...(opts.model ? { model: opts.model } : {}),
input: buildPromptInputs({
message: opts.prompt,
Expand Down
11 changes: 11 additions & 0 deletions apps/host-daemon/src/command-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ function createTurnSubmitCommand(
instructions: "Be concise.",
dynamicTools: [],
contributedEnv: [],
envVars: {},
injectedSkillSources: [],
instructionMode: "append",
},
Expand Down Expand Up @@ -345,6 +346,7 @@ function createInstallationGatedThreadStart(
instructions: "Be concise.",
dynamicTools: [],
contributedEnv: [],
envVars: {},
injectedSkillSources: [],
instructionMode: "append",
};
Expand Down Expand Up @@ -782,6 +784,7 @@ describe("dispatchCommand", () => {
instructions: "Be concise.",
dynamicTools: [],
contributedEnv: [],
envVars: {},
injectedSkillSources: [],
instructionMode: "append",
},
Expand Down Expand Up @@ -859,6 +862,7 @@ describe("dispatchCommand", () => {
instructions: "Be concise.",
dynamicTools: [],
contributedEnv: [],
envVars: {},
injectedSkillSources: [],
instructionMode: "append",
},
Expand Down Expand Up @@ -1232,6 +1236,7 @@ describe("dispatchCommand", () => {
instructions: "Be concise.",
dynamicTools: [],
contributedEnv: [],
envVars: {},
injectedSkillSources: [],
instructionMode: "append",
},
Expand Down Expand Up @@ -1325,6 +1330,7 @@ describe("dispatchCommand", () => {
instructions: "Be concise.",
dynamicTools: [],
contributedEnv: [],
envVars: {},
injectedSkillSources: [],
instructionMode: "append",
};
Expand Down Expand Up @@ -1404,6 +1410,7 @@ describe("dispatchCommand", () => {
instructions: "Be concise.",
dynamicTools: [],
contributedEnv: [],
envVars: {},
injectedSkillSources: [],
instructionMode: "append",
};
Expand Down Expand Up @@ -1471,6 +1478,7 @@ describe("dispatchCommand", () => {
instructions: "Be concise.",
dynamicTools: [],
contributedEnv: [],
envVars: {},
injectedSkillSources: [],
instructionMode: "append",
};
Expand Down Expand Up @@ -1731,6 +1739,7 @@ describe("dispatchCommand", () => {
instructions: start.instructions,
dynamicTools: start.dynamicTools,
contributedEnv: [],
envVars: {},
injectedSkillSources: start.injectedSkillSources,
instructionMode: start.instructionMode,
};
Expand Down Expand Up @@ -2297,6 +2306,7 @@ describe("dispatchCommand", () => {
instructions: "Be concise.",
dynamicTools: [],
contributedEnv: [],
envVars: {},
injectedSkillSources: [fixture.source],
instructionMode: "append",
};
Expand Down Expand Up @@ -2360,6 +2370,7 @@ describe("dispatchCommand", () => {
instructions: "Be concise.",
dynamicTools: [],
contributedEnv: [],
envVars: {},
injectedSkillSources: [fixture.source],
instructionMode: "append",
},
Expand Down
5 changes: 5 additions & 0 deletions apps/host-daemon/src/command-handlers/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ async function resumeThreadRuntimeIfMissing(
providerThreadId: resumeContext.providerThreadId,
providerId: resumeContext.providerId,
contributedEnv: resumeContext.contributedEnv,
envVars: resumeContext.envVars,
options: command.options,
instructions: resumeContext.instructions,
dynamicTools: resumeContext.dynamicTools,
Expand Down Expand Up @@ -243,6 +244,7 @@ export async function startThread(
projectId: command.projectId,
providerId: command.providerId,
contributedEnv: command.contributedEnv,
envVars: command.envVars,
clientRequestId: command.requestId,
input: staged.input,
...(staged.inputGroups !== undefined
Expand Down Expand Up @@ -287,6 +289,7 @@ export async function prepareThreadRewind(
projectId: command.projectId,
providerId: command.providerId,
contributedEnv: command.contributedEnv,
envVars: command.envVars,
sourceProviderThreadId: command.sourceProviderThreadId,
retainThroughProviderCheckpoint: command.retainThroughProviderCheckpoint,
options: command.options,
Expand Down Expand Up @@ -353,6 +356,7 @@ async function runSubmittedTurn(
clientRequestId: command.requestId,
options: command.options,
contributedEnv: command.resumeContext.contributedEnv,
envVars: command.resumeContext.envVars,
instructions: command.resumeContext.instructions,
});
return { appliedAs: "new-turn" };
Expand All @@ -376,6 +380,7 @@ async function steerSubmittedTurn(
clientRequestId: command.requestId,
options: command.options,
contributedEnv: command.resumeContext.contributedEnv,
envVars: command.resumeContext.envVars,
instructions: command.resumeContext.instructions,
});

Expand Down
2 changes: 2 additions & 0 deletions apps/host-daemon/test/command/command-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ function createTurnSubmitCommand(
instructions: "Be a helpful coding agent.",
dynamicTools: [],
contributedEnv: [],
envVars: {},
injectedSkillSources: [],
instructionMode: "append",
},
Expand Down Expand Up @@ -157,6 +158,7 @@ function createThreadStartCommand(): ThreadStartCommand {
instructions: "Be a helpful coding agent.",
dynamicTools: [],
contributedEnv: [],
envVars: {},
injectedSkillSources: [],
instructionMode: "append",
};
Expand Down
Loading