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
16 changes: 13 additions & 3 deletions apps/supercode-cli/server/src/lib/composio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,11 @@ export class ServerComposioService {
const accounts = await this.activeAccounts(userId)
const toolkits = [...new Set(accounts.map((account) => account.toolkit.slug))]
if (toolkits.length === 0) return []
const raw = await this.composio.tools.getRawComposioTools({ toolkits })
// Toolkit-only queries default to important tools and can omit entire apps.
const catalogs = await Promise.all(toolkits.map((toolkit) =>
this.composio.tools.getRawComposioTools({ toolkits: [toolkit], important: false, limit: 1000 }),
))
const raw = [...new Map(catalogs.flat().map((tool) => [tool.slug, tool])).values()]
return raw.filter((tool) => !tool.isDeprecated && tool.toolkit && toolkits.includes(tool.toolkit.slug)).map((tool) => ({
name: tool.slug,
displayName: tool.name,
Expand All @@ -148,9 +152,15 @@ export class ServerComposioService {
if (!account) {
throw Object.assign(new Error(`Connect ${toolkit} before using this tool`), { statusCode: 403 })
}
const version = process.env[`COMPOSIO_TOOLKIT_VERSION_${toolkit.toUpperCase()}`]?.trim()
const configuredVersion = process.env[`COMPOSIO_TOOLKIT_VERSION_${toolkit.toUpperCase()}`]?.trim()
|| process.env.COMPOSIO_TOOLKIT_VERSION?.trim()
|| "latest"
// The SDK rejects "latest" for manual execution, even with a valid account.
const version = configuredVersion && configuredVersion !== "latest"
? configuredVersion
: tool.version?.trim() || (await this.composio.tools.getRawComposioToolBySlug(name)).version?.trim()
if (!version || version === "latest") {
throw Object.assign(new Error(`No concrete tool version available for ${toolkit}; configure COMPOSIO_TOOLKIT_VERSION_${toolkit.toUpperCase()}`), { statusCode: 503 })
}
return this.composio.tools.execute(name, {
userId,
connectedAccountId: account.id,
Expand Down
4 changes: 2 additions & 2 deletions apps/supercode-cli/server/src/routes/composio-execute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const execute = mock(async (_name: string, _args: unknown) => ({ successful: tru
const client = {
connectedAccounts: { list: async () => ({ items: [{ id: "ca_linear", status: "ACTIVE", isDisabled: false, toolkit: { slug: "linear" } }], nextCursor: null }) },
tools: {
getRawComposioTools: async () => [{ slug: "LINEAR_GET_WORKSPACE", toolkit: { slug: "linear" } }],
getRawComposioTools: async () => [{ slug: "LINEAR_GET_WORKSPACE", toolkit: { slug: "linear" }, version: "20261020_01" }],
execute,
},
} as unknown as Composio
Expand All @@ -32,7 +32,7 @@ test("accepts desktop’s toolName execute contract", async () => {
expect(response.status).toBe(200)
expect((await response.json()).successful).toBe(true)
expect(execute).toHaveBeenCalledWith("LINEAR_GET_WORKSPACE", {
userId: "cli_user", connectedAccountId: "ca_linear", arguments: {}, version: "latest",
userId: "cli_user", connectedAccountId: "ca_linear", arguments: {}, version: "20261020_01",
})
})

Expand Down
21 changes: 19 additions & 2 deletions apps/supercode-cli/server/src/routes/composio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ describe("CLI Composio connections", () => {
create: mock(async (_userId: string, _options: unknown) => ({ sessionId: "session_1", mcp: { url: "https://mcp.composio.dev/session_1", headers: { "x-mcp-secret": "test-only" } } })),
},
tools: {
getRawComposioTools: mock(async () => [
getRawComposioToolBySlug: mock(async (_name: string) => ({ version: "20261020_01" })),
getRawComposioTools: mock(async (_query?: unknown) => [
{ slug: "GOOGLECALENDAR_LIST_EVENTS", name: "List events", toolkit: { slug: "googlecalendar", name: "Calendar" } },
{ slug: "GOOGLECALENDAR_CREATE_EVENT", name: "Create event", toolkit: { slug: "googlecalendar", name: "Calendar" } },
{ slug: "GITHUB_CREATE_ISSUE", name: "Create issue", toolkit: { slug: "github", name: "GitHub" } },
Expand Down Expand Up @@ -177,10 +178,26 @@ describe("CLI Composio connections", () => {
expect((await post("execute", { name: "GITHUB_CREATE_ISSUE", arguments: {} })).status).toBe(403)
expect((await post("execute", { name: "GOOGLECALENDAR_LIST_EVENTS", arguments: {} })).status).toBe(200)
expect(client.tools.execute).toHaveBeenCalledWith("GOOGLECALENDAR_LIST_EVENTS", {
userId: "user_1", connectedAccountId: "ca_calendar", arguments: {}, version: "latest",
userId: "user_1", connectedAccountId: "ca_calendar", arguments: {}, version: "20261020_01",
})
})

test("resolves a concrete version and refuses execution when none is available", async () => {
await service.executeTool("user_1", "GOOGLECALENDAR_LIST_EVENTS", {})
expect(client.tools.getRawComposioToolBySlug).toHaveBeenCalledWith("GOOGLECALENDAR_LIST_EVENTS")
client.tools.execute.mockClear()
client.tools.getRawComposioToolBySlug.mockResolvedValue({ version: "latest" })
await expect(service.executeTool("user_1", "GOOGLECALENDAR_LIST_EVENTS", {})).rejects.toThrow("concrete tool version")
expect(client.tools.execute).not.toHaveBeenCalled()
})

test("queries each connected toolkit without the important-only default", async () => {
accounts.push({ id: "ca_linear", owner: "user_1", status: "ACTIVE", toolkit: { slug: "linear" }, isDisabled: false })
await service.listTools("user_1")
expect(client.tools.getRawComposioTools).toHaveBeenCalledWith({ toolkits: ["googlecalendar"], important: false, limit: 1000 })
expect(client.tools.getRawComposioTools).toHaveBeenCalledWith({ toolkits: ["linear"], important: false, limit: 1000 })
})

test("requires authentication and validates input", async () => {
expect((await post("apps", {}, "invalid")).status).toBe(401)
expect((await post("connect", { slug: "../dashboard" })).status).toBe(400)
Expand Down
2 changes: 2 additions & 0 deletions apps/web/app/api/inngest/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { serve } from "inngest/next"
import { reconcileNovaSandboxes } from "@/inngest/functions/sandbox"
import { processNovaAutomation, scheduleNovaAutomations } from "@/inngest/functions/automations"
import { inngest } from "../../../inngest/client"
import { indexRepo } from "@/inngest/functions"
Expand All @@ -22,6 +23,7 @@ export const { GET, POST, PUT } = serve({
functions: [
indexRepo,
generateReview,
reconcileNovaSandboxes,
processNovaAutomation,
scheduleNovaAutomations,
processNovaInboundEvent,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { beforeEach, expect, mock, test } from "bun:test"

let userId: string | null = "u"
let membership = "active"
let owner = "u"
let organizationId = "o"
let expiresAt = new Date(Date.now() + 60_000).toISOString()
let patch = "diff --git a/a b/a\n"
mock.module("@super/auth/server", () => ({ auth: { api: { getSession: async () => userId ? { user: { id: userId } } : null } } }))
mock.module("next/headers", () => ({ headers: async () => new Headers() }))
mock.module("@super/db", () => ({ default: {
novaArtifact: { findFirst: async () => ({ metadata: { userId: owner, organizationId, expiresAt, patch }, agentSession: { organizationId: "o" } }) },
organizationMembership: { findUnique: async () => ({ status: membership }) },
} }))
const { GET } = await import("./route")
const download = () => GET(new Request("https://example.test"), { params: Promise.resolve({ sessionId: "s", artifactId: "a" }) })
beforeEach(() => {
userId = "u"
membership = "active"
owner = "u"
organizationId = "o"
expiresAt = new Date(Date.now() + 60_000).toISOString()
patch = "diff --git a/a b/a\n"
})
test("download requires authentication and owner membership", async () => {
userId = null
expect((await download()).status).toBe(401)
userId = "another-user"
expect((await download()).status).toBe(404)
userId = "u"
membership = "inactive"
expect((await download()).status).toBe(404)
})
test("cross-organization, expired and oversized artifacts are rejected", async () => {
organizationId = "another-org"
expect((await download()).status).toBe(404)
organizationId = "o"
expiresAt = new Date(0).toISOString()
expect((await download()).status).toBe(410)
expiresAt = new Date(Date.now() + 60_000).toISOString()
patch = "x".repeat(1_000_001)
expect((await download()).status).toBe(422)
})
test("valid patch is a private non-cacheable attachment", async () => {
const response = await download()
expect(response.status).toBe(200)
expect(response.headers.get("cache-control")).toBe("private, no-store")
expect(response.headers.get("content-disposition")).toContain("attachment")
expect(response.headers.get("x-content-type-options")).toBe("nosniff")
expect(await response.text()).toBe(patch)
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { auth } from "@super/auth/server"
import prisma from "@super/db"
import { headers } from "next/headers"
import { z } from "zod"

import { MAX_PATCH_BYTES } from "@/modules/nova/sandbox/policy"

const metadataSchema = z.object({ userId: z.string(), organizationId: z.string(), patch: z.string(), expiresAt: z.iso.datetime() })
export const runtime = "nodejs"
export const dynamic = "force-dynamic"

export async function GET(_request: Request, context: { params: Promise<{ sessionId: string; artifactId: string }> }) {
const session = await auth.api.getSession({ headers: await headers() })
if (!session?.user.id) return new Response("Unauthorized", { status: 401 })
const { sessionId, artifactId } = await context.params
const artifact = await prisma.novaArtifact.findFirst({ where: { id: artifactId, agentSessionId: sessionId, type: "sandbox_patch" }, include: { agentSession: { select: { organizationId: true } } } })
const metadata = metadataSchema.safeParse(artifact?.metadata)
if (!artifact || !metadata.success || metadata.data.userId !== session.user.id || metadata.data.organizationId !== artifact.agentSession.organizationId) return new Response("Not found", { status: 404 })
const membership = await prisma.organizationMembership.findUnique({ where: { organizationId_userId: { organizationId: metadata.data.organizationId, userId: session.user.id } }, select: { status: true } })
if (membership?.status !== "active") return new Response("Not found", { status: 404 })
if (new Date(metadata.data.expiresAt) <= new Date()) return new Response("Patch expired", { status: 410 })
if (Buffer.byteLength(metadata.data.patch) > MAX_PATCH_BYTES) return new Response("Patch unavailable", { status: 422 })
return new Response(metadata.data.patch, { headers: {
"Content-Type": "text/x-diff; charset=utf-8",
"Content-Disposition": "attachment; filename=\"nova-changes.patch\"",
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'; sandbox",
} })
}
15 changes: 11 additions & 4 deletions apps/web/app/api/nova/sessions/[sessionId]/turn/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const turnSchema = z.object({

export const runtime = "nodejs"
export const dynamic = "force-dynamic"
export const maxDuration = 120
export const maxDuration = 600

export async function POST(
request: NextRequest,
Expand Down Expand Up @@ -71,10 +71,17 @@ export async function POST(
responseHeaders.append("Set-Cookie", harnessAuth.setCookie)
}

const cancellation = new AbortController()
const signal = AbortSignal.any([request.signal, cancellation.signal])
let cancelled = false
const stream = new ReadableStream({
cancel() {
cancelled = true
cancellation.abort()
},
async start(controller) {
const write = (payload: unknown) => {
controller.enqueue(encoder.encode(`${JSON.stringify(payload)}\n`))
if (!cancelled) controller.enqueue(encoder.encode(`${JSON.stringify(payload)}\n`))
}
try {
for await (const event of runWebTurn({
Expand All @@ -87,7 +94,7 @@ export async function POST(
effort: parsed.data.effort,
mode: parsed.data.mode ?? "agent",
harnessToken: harnessAuth.token,
signal: request.signal,
signal,
references: parsed.data.references,
localAttachments: parsed.data.localAttachments,
})) {
Expand All @@ -100,7 +107,7 @@ export async function POST(
})
write({ type: "finish", reason: "error" })
} finally {
controller.close()
if (!cancelled) controller.close()
}
},
})
Expand Down
8 changes: 8 additions & 0 deletions apps/web/inngest/functions/sandbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { reconcileSandboxWorkspaces } from "@/modules/nova/sandbox/service"
import { inngest } from "../client"

export const reconcileNovaSandboxes = inngest.createFunction(
{ id: "nova-reconcile-sandboxes", retries: 2, concurrency: 1 },
{ cron: "*/5 * * * *" },
async ({ step }) => step.run("recover-cloud-workspaces", reconcileSandboxWorkspaces),
)
21 changes: 21 additions & 0 deletions apps/web/modules/nova-web/working-steps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,27 @@ test("status and reasoning build a live working log", () => {
expect(steps.at(-1)?.label.startsWith("Thought for")).toBe(true)
})

test("cloud patch activity identifies the actual edited path", () => {
const steps = reduceWorkingSteps([], {
type: "activity",
activity: { type: "tool_result", title: "nova_sandbox_patch", body: "nova_sandbox_patch · src/index.ts · completed", status: "completed" },
})
expect(steps[0]?.kind).toBe("write")
expect(steps[0]?.meta?.path).toBe("src/index.ts")
})

test("failed cloud writes and denied commands never appear as successful edits", () => {
for (const status of ["failed", "denied"]) {
const steps = reduceWorkingSteps([], {
type: "activity",
activity: { type: "tool_result", title: "nova_sandbox_write", body: "src/index.ts · failed (exit 1)", status },
})
expect(steps[0]?.kind).toBe("tool")
expect(steps[0]?.label).toStartWith(status === "failed" ? "Failed" : "Denied")
expect(steps[0]?.label).toContain("src/index.ts")
}
})

test("consecutive file reads collapse into an explore group", () => {
let steps = reduceWorkingSteps([], {
type: "activity",
Expand Down
8 changes: 7 additions & 1 deletion apps/web/modules/nova-web/working-steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ function parseToolActivity(title: string | null | undefined, body: string | null
detail.match(/(?:^|[\s`"'])([A-Za-z0-9_./-]+\.[A-Za-z0-9]{1,8})/)?.[1]
|| name.match(/([A-Za-z0-9_./-]+\.[A-Za-z0-9]{1,8})/)?.[1]

if (writeMatch || /write|edit|create/i.test(name)) {
if (writeMatch || /write|edit|create|nova_sandbox_patch/i.test(name)) {
const filePath = path || "file"
const add = detail.match(/\+(\d+)/)?.[1]
const del = detail.match(/-(\d+)/)?.[1]
Expand Down Expand Up @@ -269,6 +269,12 @@ export function reduceWorkingSteps(
if (event.activity.type === "acknowledgement") {
return closed
}
if (event.activity.status === "failed" || event.activity.status === "denied") {
return [...closed, {
id: newId(), kind: "tool", status: "done", startedAt: now, endedAt: now,
label: `${event.activity.status === "failed" ? "Failed" : "Denied"} · ${event.activity.body || event.activity.title || "Tool"}`,
}]
}
const step = parseToolActivity(event.activity.title, event.activity.body)
// Merge consecutive reads into an explore group
if (step.kind === "read" && step.meta?.path) {
Expand Down
26 changes: 26 additions & 0 deletions apps/web/modules/nova/harness/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,32 @@ test("catalog failures stop the turn instead of claiming no integration exists",
expect(executions).toHaveLength(0)
})

test("large GitHub catalogs do not crowd other connected apps out of the model tools", async () => {
catalog = [
...Array.from({ length: 100 }, (_, index) => ({ ...tools[0]!, name: `GITHUB_GET_ITEM_${index}`, toolkit: "github" })),
...tools,
{ ...tools[0]!, name: "GOOGLECALENDAR_LIST_EVENTS", toolkit: "googlecalendar" },
]
responses = [[{ type: "text", content: "Connected apps are available." }]]
await collect()
const names = chats[0]?.tools?.map((tool) => tool.function.name) ?? []
expect(names).toContain("LINEAR_GET_WORKSPACE")
expect(names).toContain("GOOGLECALENDAR_LIST_EVENTS")
expect(names.length).toBeLessThanOrEqual(64)
})

test("isolated workspaces still do not expose or execute connected-app tools", async () => {
responses = [[call("LINEAR_GET_WORKSPACE")], [{ type: "text", content: "That tool is not available in this isolated run." }]]
const events = []
for await (const event of streamHarnessAgent({
token: "authenticated-cli-account", provider: "supercode", model: "test-model", mode: "agent",
isolatedTools: true, messages: [{ role: "user", content: "Read Linear" }],
})) events.push(event)
expect(executions).toHaveLength(0)
expect(chats[0]?.tools).toBeUndefined()
expect(events.some((event) => event.type === "tool-result" && event.status === "denied")).toBe(true)
})

test("tool-free accounts still support normal chat", async () => {
catalog = []
responses = [[{ type: "text", content: "Hello." }, { type: "finish", reason: "stop" }]]
Expand Down
Loading
Loading