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 scripts/bundle-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const ENTRY_POINTS = [
"index.ts",
"computer-proxy.ts",
"container-mcp.ts",
"vps-container-mcp.ts",
"permission-proxy.ts",
"connector-proxy.ts",
"drivers/agents-proxy.ts",
Expand Down
6 changes: 6 additions & 0 deletions server/branching.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,12 @@ posixOnly("conversation branching e2e (fake ACP fleet)", () => {
expect((await api("POST", `/api/bots/${created.id}/messages`, { text: "first try" })).status).toBe(202);
await waitFor(async () => (await getBot(created.id)).busy === true, "the hung turn to start");

const backendBefore = (await getBot(created.id)).cloudBackend;
const backendChange = await api("PATCH", `/api/bots/${created.id}`, { cloudBackend: "vps" });
expect(backendChange.status).toBe(409);
expect(backendChange.body.error).toContain("stop the active turn");
expect((await getBot(created.id)).cloudBackend).toBe(backendBefore);

// a second send while busy queues (steer-queue) — never a parallel
// turn: the words land in the transcript, the live turn keeps running
const parallel = await api("POST", `/api/bots/${created.id}/messages`, { text: "sneaky second" });
Expand Down
29 changes: 29 additions & 0 deletions server/cloud-backend.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";

import {
CLOUD_BACKEND_CHANGE_ERROR,
VPS_ALIAS_CHANGE_ERROR,
cloudBackendChangeError,
vpsAliasChangeError,
} from "./cloud-backend.ts";

describe("cloud backend switching", () => {
const activeTurnCases: Array<[string, boolean, boolean]> = [
["a busy bot", true, false],
["an active VPS thread", false, true],
];

it.each(activeTurnCases)("rejects changes during %s", (_reason, busy, activeVpsThread) => {
expect(cloudBackendChangeError(busy, activeVpsThread)).toBe(CLOUD_BACKEND_CHANGE_ERROR);
});

it("allows changes while idle", () => {
expect(cloudBackendChangeError(false, false)).toBeNull();
});

it("keeps an active VPS turn on its original SSH host", () => {
expect(vpsAliasChangeError("old-vps", "new-vps", true)).toBe(VPS_ALIAS_CHANGE_ERROR);
expect(vpsAliasChangeError("old-vps", "old-vps", true)).toBeNull();
expect(vpsAliasChangeError("old-vps", "new-vps", false)).toBeNull();
});
});
10 changes: 10 additions & 0 deletions server/cloud-backend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export const CLOUD_BACKEND_CHANGE_ERROR = "stop the active turn before changing the cloud backend";
export const VPS_ALIAS_CHANGE_ERROR = "stop the active VPS turn before changing the SSH config alias";

export function cloudBackendChangeError(botBusy: boolean, activeVpsThread: boolean): string | null {
return botBusy || activeVpsThread ? CLOUD_BACKEND_CHANGE_ERROR : null;
}

export function vpsAliasChangeError(currentAlias: string | null, nextAlias: string | null, activeVpsThread: boolean): string | null {
return activeVpsThread && currentAlias !== nextAlias ? VPS_ALIAS_CHANGE_ERROR : null;
}
13 changes: 13 additions & 0 deletions server/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest";

import {
instanceConfigs,
isValidSshAlias,
parseConfigPatch,
parseStoredConfig,
vpsSshAlias,
withInstanceCli,
type AppConfig,
} from "./config.ts";
Expand All @@ -27,6 +29,17 @@ describe("configuration boundaries", () => {
expect(() => parseConfigPatch({ opencodeGo: { apiKey: 42 } })).toThrow("opencodeGo.apiKey");
expect(() => parseConfigPatch({ profile: [] })).toThrow("profile");
});

it("accepts only a simple VPS SSH config alias and exposes no credentials", () => {
expect(isValidSshAlias("production-vps")).toBe(true);
expect(isValidSshAlias("prod; reboot")).toBe(false);
expect(() => parseConfigPatch({ vps: { sshAlias: "prod; reboot" } })).toThrow("vps.sshAlias");
expect(parseConfigPatch({ vps: { sshAlias: "production-vps" } })).toEqual({
vps: { sshAlias: "production-vps" },
});
expect(vpsSshAlias({ vps: { sshAlias: "production-vps" } })).toBe("production-vps");
expect(vpsSshAlias({ vps: { sshAlias: "-bad" } })).toBeNull();
});
});

describe("default fleet", () => {
Expand Down
33 changes: 33 additions & 0 deletions server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,31 @@ import type { InstanceConfigMap } from "./contracts.ts";
import { parseJson, schemaIssue, type JsonObject, type JsonValue } from "./schema.ts";

const optionalText = z.string().optional();
const SSH_ALIAS = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;

export function isValidSshAlias(value: unknown): value is string {
return typeof value === "string" && SSH_ALIAS.test(value);
}

/** Keep the persisted VPS shape deliberately smaller than an SSH connection. */
export function normalizeVpsConfig(raw: unknown): { sshAlias?: string } {
if (raw === undefined || raw === null) return {};
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
throw new Error("vps must be an object containing an SSH config alias");
}
const alias = (raw as Record<string, unknown>).sshAlias;
if (alias === undefined || alias === "") return {};
if (!isValidSshAlias(alias)) {
throw new Error("vps.sshAlias must be a simple SSH config alias (letters, numbers, dot, dash, or underscore)");
}
return { sshAlias: alias };
}

const vpsConfigSchema = z.object({
sshAlias: z.string().refine((value) => value === "" || isValidSshAlias(value), {
message: "must be a simple SSH config alias",
}).optional(),
});
const instanceConfigSchema = z.object({
driver: z.string().min(1),
displayName: optionalText,
Expand All @@ -26,6 +51,7 @@ const appConfigSchema = z.object({
* are non-secret local identifiers used to reuse one Composio Session. */
composio: z.object({ apiKey: optionalText, userId: optionalText, sessionId: optionalText }).optional(),
box: z.object({ token: optionalText }).optional(),
vps: vpsConfigSchema.optional(),
/** OpenCode Go key; persisted write-only and passed only to its child. */
opencodeGo: z.object({ apiKey: optionalText }).optional(),
/** Voice credentials and the selected voice id. */
Expand All @@ -41,6 +67,8 @@ export interface AppConfig {
xai?: { key?: string; url?: string };
composio?: { apiKey?: string; userId?: string; sessionId?: string };
box?: { token?: string };
/** A named host from the user's SSH config. Authentication stays with SSH. */
vps?: { sshAlias?: string };
opencodeGo?: { apiKey?: string };
tts?: { key?: string; voice?: string };
profile?: { name?: string; email?: string };
Expand All @@ -62,6 +90,10 @@ export function parseConfigPatch(value: JsonValue): ConfigPatch {
return parsed.data;
}

export function vpsSshAlias(cfg: AppConfig): string | null {
return isValidSshAlias(cfg.vps?.sshAlias) ? cfg.vps.sshAlias : null;
}

// OMB_DATA_DIR isolates test/soak rigs from the user's real fleet.
export const DATA_DIR = process.env.OMB_DATA_DIR ?? join(homedir(), ".openmausbot");
const LEGACY_DATA_DIR = join(homedir(), ".opengrokbot");
Expand Down Expand Up @@ -117,6 +149,7 @@ export function saveConfig(patch: Partial<AppConfig>): void {
Object.assign(merged, section);
disk[key] = merged;
}
if (checkedPatch.vps !== undefined) disk.vps = normalizeVpsConfig(checkedPatch.vps);
if (checkedPatch.instances) {
const currentInstances = jsonObjectSchema.safeParse(disk.instances);
const diskInstances: JsonObject = currentInstances.success ? currentInstances.data : {};
Expand Down
3 changes: 2 additions & 1 deletion server/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type DriverKind = string;
export type InstanceId = string;
export type ThreadId = string;
export type TurnId = string;
export type CloudBackend = "box" | "vps";

export type ProviderErrorCode =
| "missing_cli"
Expand Down Expand Up @@ -154,7 +155,7 @@ export interface SendTurnInput {
composio?: { command: string; args: string[]; env: Record<string, string> };
/** Cloud computer, reached through OpenMausBot's REST-to-MCP adapter. */
computer?: { kind?: "box"; boxId: string; token: string };
/** Direct stdio connection to a Cua Driver MCP server (host or sandbox). */
/** Direct stdio connection to a Cua Driver MCP server (host, sandbox, or VPS). */
localComputer?: { command: string; args: string[]; env: Record<string, string> };
/** Peer-agent comms: an MCP proxy (list_bots / ask_bot) that routes back
* through the harness so this bot can message other bots. The harness
Expand Down
20 changes: 20 additions & 0 deletions server/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,26 @@ describe("harness HTTP API", () => {
expect(nothing.status).toBe(400);
});

it("validates the non-secret VPS alias and keeps old bots on Box by default", async () => {
const before = await api("GET", "/api/bots");
const bot = before.body.bots[0];
expect(bot.cloudBackend).toBeUndefined();

const bad = await api("PUT", "/api/config", { vps: { sshAlias: "prod; reboot" } });
expect(bad.status).toBe(400);

const saved = await api("PUT", "/api/config", { vps: { sshAlias: "production-vps" } });
expect(saved.status).toBe(200);
expect(saved.body.vps).toEqual({ configured: true, sshAlias: "production-vps" });
expect(JSON.stringify(saved.body)).not.toContain("privateKey");

const patched = await api("PATCH", `/api/bots/${bot.id}`, { cloudBackend: "vps" });
expect(patched.status).toBe(200);
expect(patched.body.bot.cloudBackend).toBe("vps");
const invalid = await api("PATCH", `/api/bots/${bot.id}`, { cloudBackend: "daytona" });
expect(invalid.status).toBe(400);
});

it("validates a Composio project key, creates a Session, and keeps externally stored secrets off disk", async () => {
const oldKey = await api("PUT", "/api/config", { composio: { apiKey: "old_key" } });
expect(oldKey.status).toBe(400);
Expand Down
Loading
Loading