Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
26 changes: 26 additions & 0 deletions packages/control-plane/src/routes/session-runtime-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,32 @@ function getHandler(method: string, path: string) {
}

describe("session runtime proxy routes", () => {
it("forwards budget updates with verified user identity", async () => {
const requests: Request[] = [];
const fetch = vi.fn(async (request: Request) => {
requests.push(request);
return Response.json({ maxSessionCostUsd: 20 });
});
const path = "/sessions/session-1/budget";
const { handler, match, route } = getHandler("PATCH", path);

const response = await handler(
new Request(`https://test.local${path}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ maxCostUsd: 20 }),
}),
createEnv(fetch),
match,
createCtx()
);

expect(response.status).toBe(200);
expect(route.authentication.kind).toBe("user");
expect(new URL(requests[0].url).pathname).toBe(SessionInternalPaths.budget);
await expect(requests[0].json()).resolves.toEqual({ maxCostUsd: 20, userId: "user-1" });
});

it.each([
["snapshot", "/sessions/session-1", SessionInternalPaths.snapshot],
["sandbox access", "/sessions/session-1/sandbox-access", SessionInternalPaths.sandboxAccess],
Expand Down
32 changes: 32 additions & 0 deletions packages/control-plane/src/routes/session-runtime-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
SessionParticipantProfilesResponse,
SessionParticipantProfile,
} from "@open-inspect/shared/types/sessions";
import { sessionBudgetUpdateSchema } from "@open-inspect/shared/types/session-api";
import { z } from "zod";
import { UserStore } from "../db/user-store";
import { SessionIndexStore } from "../db/session-index";
Expand Down Expand Up @@ -305,7 +306,38 @@ function lifecycleProxyRoute(
);
}

const budgetProxyRoute = defineRoute(
SCM_AGNOSTIC_HUMAN_USER_ROUTE,
sessionRoute({
method: "PATCH",
pattern: parsePattern("/sessions/:id/budget"),
handler: async (request, _env, match, ctx) => {
const sessionId = getSessionId(match);
if (sessionId instanceof Response) return sessionId;

const rawBody = await parseJsonBody<unknown>(request);
if (rawBody instanceof Response) return rawBody;
if (!isObjectBody(rawBody)) return error("Invalid budget request", 400);

const enforcement = applyIdentityEnforcement(ctx, "session-lifecycle", rawBody);
if (enforcement.rejection) return enforcement.rejection;
const parsed = sessionBudgetUpdateSchema.safeParse(rawBody);
if (!parsed.success) return error("Invalid budget request", 400);

return ctx.sessionRuntime.fetch(sessionId, SessionInternalPaths.budget, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...parsed.data,
userId: enforcement.enforced.participantUserId,
}),
});
},
})
);

export const sessionRuntimeProxyRoutes: Route[] = [
budgetProxyRoute,
simpleProxyRoute({
policy: SCM_AGNOSTIC_HUMAN_USER_ROUTE,
method: "GET",
Expand Down
4 changes: 4 additions & 0 deletions packages/control-plane/src/sandbox/lifecycle/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ function createMockSession(overrides: Partial<SessionRow> = {}): SessionRow {
code_server_enabled: 0,
vnc_enabled: 0,
total_cost: 0,
max_cost_usd: null,
cost_warning_sent: 0,
budget_exhausted: 0,
cost_tracking_unavailable: 0,
sandbox_settings: null,
environment_id: null,
created_at: Date.now() - 60000,
Expand Down
30 changes: 30 additions & 0 deletions packages/control-plane/src/sandbox/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,36 @@ describe("normalizeSandboxSettings", () => {
).toEqual({ terminalEnabled: true });
});

it("accepts valid session cost settings", () => {
expect(
normalizeSandboxSettings({ maxSessionCostUsd: 12.5, costWarningThresholdPct: 99 })
).toEqual({ maxSessionCostUsd: 12.5, costWarningThresholdPct: 99 });
});

it.each([
{ maxSessionCostUsd: 0 },
{ maxSessionCostUsd: -1 },
{ maxSessionCostUsd: Number.NaN },
{ costWarningThresholdPct: 0 },
{ costWarningThresholdPct: 99.5 },
{ costWarningThresholdPct: 100 },
])("rejects invalid session cost settings %#", (settings) => {
expect(() => normalizeSandboxSettings(settings)).toThrow(SandboxSettingsValidationError);
});

it("omits invalid session cost settings while preserving valid siblings", () => {
expect(
normalizeSandboxSettings(
{
maxSessionCostUsd: -1,
costWarningThresholdPct: 100,
terminalEnabled: true,
},
{ invalid: "omit" }
)
).toEqual({ terminalEnabled: true });
});

it("accepts valid service ports", () => {
expect(
normalizeSandboxSettings({ codeServerPort: 8081, vncPort: 6081, terminalPort: 7000 })
Expand Down
35 changes: 35 additions & 0 deletions packages/control-plane/src/sandbox/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,28 @@ export function normalizeSandboxSettings(
result.buildTimeoutSeconds = buildTimeoutSeconds;
}

const maxSessionCostUsd = normalizePositiveNumberSetting(
settings.maxSessionCostUsd,
"maxSessionCostUsd",
reject
);
if (maxSessionCostUsd !== undefined) {
result.maxSessionCostUsd = maxSessionCostUsd;
}

const costWarningThresholdPct = normalizePositiveIntegerSetting(
settings.costWarningThresholdPct,
"costWarningThresholdPct",
reject
);
if (costWarningThresholdPct !== undefined) {
if (costWarningThresholdPct > 99) {
reject("costWarningThresholdPct must be an integer between 1 and 99");
} else {
result.costWarningThresholdPct = costWarningThresholdPct;
}
}

checkPortCollisions(result, reject);

return result;
Expand Down Expand Up @@ -271,3 +293,16 @@ function normalizePositiveIntegerSetting(
}
return value;
}

function normalizePositiveNumberSetting(
value: unknown,
name: string,
reject: (message: string) => false
): number | undefined {
if (value === undefined) return undefined;
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
reject(`${name} must be a positive finite number`);
return undefined;
}
return value;
}
1 change: 1 addition & 0 deletions packages/control-plane/src/session/alarm/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ describe("createAlarmHandler", () => {
earliest: vi.fn(() => null),
cancelled: vi.fn(() => false),
setPending: vi.fn(),
setPendingEarliest: vi.fn(),
activate: vi.fn(),
clear: vi.fn(),
beginDelivery: vi.fn(() => null),
Expand Down
8 changes: 8 additions & 0 deletions packages/control-plane/src/session/alarm/scheduler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ function createDeadlineStore(
setPending: vi.fn((value: number) => {
pending = value;
}),
setPendingEarliest: vi.fn((value: number) => {
pending = pending === null ? value : Math.min(pending, value);
}),
activate: vi.fn(() => {
cancelled = false;
}),
Expand Down Expand Up @@ -357,6 +360,11 @@ describe("PersistedAlarmDeadlineStore", () => {
initSchema(sql);
const deadlines = new PersistedAlarmDeadlineStore(sql);

deadlines.setPending(2_000);
deadlines.setPendingEarliest(3_000);
expect(deadlines.pending()).toBe(2_000);
deadlines.setPendingEarliest(1_000);
expect(deadlines.pending()).toBe(1_000);
deadlines.setPending(2_000);
expect(deadlines.beginDelivery()).toBe(2_000);
deadlines.setPending(3_000);
Expand Down
13 changes: 13 additions & 0 deletions packages/control-plane/src/session/alarm/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface AlarmDeadlineStore {
earliest(): number | null;
cancelled(): boolean;
setPending(deadline: number): void;
setPendingEarliest(deadline: number): void;
activate(): void;
clear(): void;
beginDelivery(): number | "cancelled" | null;
Expand Down Expand Up @@ -53,6 +54,18 @@ export class PersistedAlarmDeadlineStore implements AlarmDeadlineStore {
);
}

setPendingEarliest(deadline: number): void {
this.sql.exec(
`INSERT INTO session_alarm_state (singleton, pending_deadline) VALUES (1, ?)
ON CONFLICT(singleton) DO UPDATE SET pending_deadline =
CASE
WHEN session_alarm_state.pending_deadline IS NULL THEN excluded.pending_deadline
ELSE MIN(session_alarm_state.pending_deadline, excluded.pending_deadline)
END`,
deadline
);
}

activate(): void {
this.sql.exec("UPDATE session_alarm_state SET cancelled = 0 WHERE singleton = 1");
}
Expand Down
Loading
Loading