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
66 changes: 66 additions & 0 deletions packages/control-plane/src/router.spawn-child.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ describe("handleSpawnChild prompt enqueue handling", () => {
reasoningEffort: string | null;
owner: {
userId: string;
scmUserId: string | null;
scmLogin: string | null;
scmName: string | null;
scmEmail: string | null;
Expand All @@ -42,8 +43,10 @@ describe("handleSpawnChild prompt enqueue handling", () => {
repoId: 12345,
model: "anthropic/claude-sonnet-4-6",
reasoningEffort: null,
baseBranch: "main",
owner: {
userId: "user-1",
scmUserId: "12345",
scmLogin: "acmedev",
scmName: "Acme Dev",
scmEmail: "dev@acme.test",
Expand Down Expand Up @@ -226,6 +229,69 @@ describe("handleSpawnChild prompt enqueue handling", () => {
expect(payload.error).toContain("Valid models:");
});

it("returns 400 for a malformed child spawn request", async () => {
const store = makeStore();
vi.mocked(SessionIndexStore).mockImplementation(function () {
return store as never;
});

const env = {
INTERNAL_CALLBACK_SECRET: "test-internal-secret",
SCM_PROVIDER: "github",
DB: {},
SESSION: {
idFromName: (name: string) => name,
get: vi.fn(),
},
};

const token = await generateInternalToken(env.INTERNAL_CALLBACK_SECRET);

const response = await handleRequest(
new Request(`https://test.local/sessions/${parentId}/children`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ title: "Child task" }),
}),
env as never
);

expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({ error: "title and prompt are required" });
expect(SessionIndexStore).not.toHaveBeenCalled();
});

it("returns 500 for a malformed parent spawn context", async () => {
const store = makeStore();
vi.mocked(SessionIndexStore).mockImplementation(function () {
return store as never;
});

const parentStub: DurableObjectStub = {
fetch: vi.fn(async () => Response.json({ repoOwner: "acme", repoName: "web-app" })),
} as never;

const env = {
INTERNAL_CALLBACK_SECRET: "test-internal-secret",
SCM_PROVIDER: "github",
DB: {},
SESSION: {
idFromName: (name: string) => name,
get: () => parentStub,
},
};

const response = await makeRequest(env);

expect(response.status).toBe(500);
await expect(response.json()).resolves.toEqual({
error: "Failed to get parent session context",
});
});

it("uses configured concurrent child session limit", async () => {
const store = makeStore();
store.countActiveChildren.mockResolvedValue(2);
Expand Down
16 changes: 12 additions & 4 deletions packages/control-plane/src/routes/session-child-spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import {
getValidModelOrDefault,
isValidModel,
isValidReasoningEffort,
spawnChildSessionRequestSchema,
spawnContextSchema,
VALID_MODELS,
type SpawnChildSessionRequest,
type SpawnContext,
} from "@open-inspect/shared";
import { generateId } from "../auth/crypto";
import { SessionIndexStore } from "../db/session-index";
Expand All @@ -33,7 +33,11 @@ async function handleSpawnChild(
const parentId = match.groups?.id;
if (!parentId) return error("Parent session ID required");

const body = (await request.json()) as SpawnChildSessionRequest;
const parsedBody = spawnChildSessionRequestSchema.safeParse(await request.json());
if (!parsedBody.success) {
return error("title and prompt are required");
}
const body = parsedBody.data;

if (!body.title || !body.prompt) {
return error("title and prompt are required");
Expand Down Expand Up @@ -84,7 +88,11 @@ async function handleSpawnChild(
return error(message, spawnContextRes.status);
}

const spawnContext = (await spawnContextRes.json()) as SpawnContext;
const parsedSpawnContext = spawnContextSchema.safeParse(await spawnContextRes.json());
if (!parsedSpawnContext.success) {
return error("Failed to get parent session context", 500);
}
const spawnContext = parsedSpawnContext.data;

const requestedRepoOwner = body.repoOwner?.trim().toLowerCase() || null;
const requestedRepoName = body.repoName?.trim().toLowerCase() || null;
Expand Down
9 changes: 8 additions & 1 deletion packages/github-bot/src/github-auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { DEFAULT_APP_NAME } from "@open-inspect/shared";
import { z } from "zod";

const collaboratorPermissionResponseSchema = z.object({
permission: z.string(),
});

export interface GitHubAppConfig {
appId: string;
Expand Down Expand Up @@ -124,7 +129,9 @@ export async function checkSenderPermission(
}
);
if (!response.ok) return { hasPermission: false, error: true };
const data = (await response.json()) as { permission: string };
const parsed = collaboratorPermissionResponseSchema.safeParse(await response.json());
if (!parsed.success) return { hasPermission: false, error: true };
const data = parsed.data;
return { hasPermission: WRITE_PERMISSIONS.has(data.permission) };
} catch {
return { hasPermission: false, error: true };
Expand Down
8 changes: 8 additions & 0 deletions packages/github-bot/test/github-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,14 @@ describe("checkSenderPermission", () => {
expect(result).toEqual({ hasPermission: false });
});

it("returns error flag on malformed permission response", async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
new Response(JSON.stringify({ user: { login: "alice" } }), { status: 200 })
);
const result = await checkSenderPermission("tok", "acme", "widgets", "alice");
expect(result).toEqual({ hasPermission: false, error: true });
});

it("returns error flag on API error (404)", async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(new Response("Not Found", { status: 404 }));
const result = await checkSenderPermission("tok", "acme", "widgets", "alice");
Expand Down
82 changes: 82 additions & 0 deletions packages/shared/src/types/boundary-schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
clientMessageSchema,
createSessionRequestSchema,
sandboxEventSchema,
spawnChildSessionRequestSchema,
spawnContextSchema,
userPreferencesRequestSchema,
} from ".";

Expand Down Expand Up @@ -192,4 +194,84 @@ describe("boundary schemas", () => {
expect(result.success).toBe(false);
});
});

describe("spawnChildSessionRequestSchema", () => {
it("parses a valid child session request", () => {
const result = spawnChildSessionRequestSchema.safeParse({
title: "Investigate failure",
prompt: "Find and fix the failing test",
repoOwner: "open-inspect",
repoName: "background-agents",
model: "anthropic/claude-sonnet-4-6",
reasoningEffort: "high",
});

expect(result.success).toBe(true);
});

it("rejects a malformed partial child session request", () => {
const result = spawnChildSessionRequestSchema.safeParse({
title: "Missing prompt",
});

expect(result.success).toBe(false);
});
});

describe("spawnContextSchema", () => {
it("parses a valid spawn context with nullable fields", () => {
const result = spawnContextSchema.safeParse({
repoOwner: "open-inspect",
repoName: "background-agents",
repoId: null,
model: "anthropic/claude-sonnet-4-6",
reasoningEffort: null,
baseBranch: null,
owner: {
userId: "user-1",
scmUserId: null,
scmLogin: null,
scmName: null,
scmEmail: null,
scmAccessTokenEncrypted: null,
scmRefreshTokenEncrypted: null,
scmTokenExpiresAt: null,
},
});

expect(result.success).toBe(true);
});

it("parses a repo-less spawn context", () => {
const result = spawnContextSchema.safeParse({
repoOwner: null,
repoName: null,
repoId: null,
model: "anthropic/claude-sonnet-4-6",
reasoningEffort: null,
baseBranch: null,
owner: {
userId: "user-1",
scmUserId: null,
scmLogin: null,
scmName: null,
scmEmail: null,
scmAccessTokenEncrypted: null,
scmRefreshTokenEncrypted: null,
scmTokenExpiresAt: null,
},
});

expect(result.success).toBe(true);
});

it("rejects a malformed partial spawn context", () => {
const result = spawnContextSchema.safeParse({
repoOwner: "open-inspect",
repoName: "background-agents",
});

expect(result.success).toBe(false);
});
});
});
56 changes: 30 additions & 26 deletions packages/shared/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -656,34 +656,38 @@ export interface ListSessionsResponse {
// --- Agent-spawned sub-sessions ---

/** Request body for POST /sessions/:parentId/children */
export interface SpawnChildSessionRequest {
title: string;
prompt: string;
repoOwner?: string;
repoName?: string;
model?: string;
reasoningEffort?: string;
}
export const spawnChildSessionRequestSchema = z.object({
title: z.string(),
prompt: z.string(),
repoOwner: z.string().optional(),
repoName: z.string().optional(),
model: z.string().optional(),
reasoningEffort: z.string().optional(),
});

export type SpawnChildSessionRequest = z.infer<typeof spawnChildSessionRequestSchema>;

/** Returned by parent DO's GET /internal/spawn-context */
export interface SpawnContext {
repoOwner: string | null;
repoName: string | null;
repoId: number | null;
model: string;
reasoningEffort: string | null;
baseBranch: string | null;
owner: {
userId: string;
scmUserId: string | null;
scmLogin: string | null;
scmName: string | null;
scmEmail: string | null;
scmAccessTokenEncrypted: string | null;
scmRefreshTokenEncrypted: string | null;
scmTokenExpiresAt: number | null;
};
}
export const spawnContextSchema = z.object({
repoOwner: z.string().nullable(),
repoName: z.string().nullable(),
repoId: z.number().nullable(),
model: z.string(),
reasoningEffort: z.string().nullable(),
baseBranch: z.string().nullable(),
owner: z.object({
userId: z.string(),
scmUserId: z.string().nullable(),
scmLogin: z.string().nullable(),
scmName: z.string().nullable(),
scmEmail: z.string().nullable(),
scmAccessTokenEncrypted: z.string().nullable(),
scmRefreshTokenEncrypted: z.string().nullable(),
scmTokenExpiresAt: z.number().nullable(),
}),
});

export type SpawnContext = z.infer<typeof spawnContextSchema>;

/** Returned by child DO's GET /internal/child-summary */
export interface ChildSessionFinalResponse extends AgentResponse {
Expand Down
Loading