diff --git a/.env.local.example b/.env.local.example index ee2a59fe6..671adaefc 100644 --- a/.env.local.example +++ b/.env.local.example @@ -68,8 +68,15 @@ OPENAI_API_KEY= # ============================================================================= # CODE EXECUTION - CLOUD SANDBOX (Required for cloud Agent Mode) # ============================================================================= -# E2B is the cloud sandbox provider. -# CLOUD_SANDBOX_PROVIDER=e2b +# Production selects MIOSA gradually with PostHog flag +# miosa_cloud_sandbox_rollout_v1 and falls back to E2B on acquisition errors. +# MIOSA requires the promoted HackerAI template ID supplied by MIOSA. Leave +# both values blank to keep E2B-only cloud execution. +# Set an explicit provider only for local/staging smoke tests. +# CLOUD_SANDBOX_PROVIDER=miosa +MIOSA_API_KEY= +MIOSA_TEMPLATE_ID= +# MIOSA_BASE_URL=https://api.miosa.ai/api/v1 E2B_API_KEY= E2B_TEMPLATE=terminal-agent-sandbox # Optional EU cluster. When configured, fresh sandboxes created by Trigger diff --git a/README.md b/README.md index f021b0754..dc30dab4c 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,10 @@ To use the agent locally: 2. In the Trigger.dev dashboard → your project → **Environment Variables**, add the env vars the task needs to run (these live on the worker, not on Vercel): `NEXT_PUBLIC_CONVEX_URL`, `CONVEX_SERVICE_ROLE_KEY`, - `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, one cloud sandbox provider, plus any keys you use + `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, and `E2B_API_KEY`. Add + both `MIOSA_API_KEY` and the promoted HackerAI `MIOSA_TEMPLATE_ID` only for + the MIOSA rollout or explicit MIOSA testing; E2B remains the cloud fallback. + Add any other keys you use (`PERPLEXITY_API_KEY`, `JINA_API_KEY`, S3, etc.). 3. Start the worker in a third terminal: diff --git a/jest.config.js b/jest.config.js index b3fd87406..351058c8c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -10,6 +10,7 @@ const customJestConfig = { setupFilesAfterEnv: ["/jest.setup.js"], testEnvironment: "jest-environment-jsdom", moduleNameMapper: { + "^@miosa/sdk$": "/node_modules/@miosa/sdk/dist/index.js", "^jose$": "/__mocks__/jose.ts", "^@workos-inc/node$": "/__mocks__/workos-node.ts", "^@workos-inc/authkit-nextjs$": "/__mocks__/workos-authkit.ts", @@ -34,7 +35,7 @@ const customJestConfig = { "^convex/browser$": "/__mocks__/convex/browser.ts", }, transformIgnorePatterns: [ - "node_modules/(?!(uuid|@ai-sdk|ai|convex|react-hotkeys-hook|react-markdown|streamdown|remark-.*|unified|bail|is-plain-obj|trough|vfile|unist-.*|mdast-.*|micromark.*|decode-named-character-reference|character-entities|escape-string-regexp|markdown-table|property-information|hast-.*|space-separated-tokens|comma-separated-tokens|zwitch|html-void-elements|ccount|devlop|superjson)/)", + "node_modules/(?!(uuid|@miosa/sdk|@ai-sdk|ai|convex|react-hotkeys-hook|react-markdown|streamdown|remark-.*|unified|bail|is-plain-obj|trough|vfile|unist-.*|mdast-.*|micromark.*|decode-named-character-reference|character-entities|escape-string-regexp|markdown-table|property-information|hast-.*|space-separated-tokens|comma-separated-tokens|zwitch|html-void-elements|ccount|devlop|superjson)/)", ], testMatch: ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)"], testPathIgnorePatterns: ["/node_modules/", "/.next/", "/e2e/", "/dist/"], diff --git a/lib/__tests__/system-prompt.test.ts b/lib/__tests__/system-prompt.test.ts index 80b68ead6..44006bb6c 100644 --- a/lib/__tests__/system-prompt.test.ts +++ b/lib/__tests__/system-prompt.test.ts @@ -217,6 +217,32 @@ Commands run directly on the host OS "workstation" without Docker isolation. Be expect(prompt).not.toContain( "agent-browser is installed in the cloud sandbox", ); + expect(prompt).not.toContain("Python 3.12.11"); + expect(prompt).not.toContain("Node.js 20.19.4"); + expect(prompt).not.toContain("Golang 1.24.2"); + }); + + it("does not claim E2B-only tools for MIOSA rollout sandboxes", async () => { + const prompt = await systemPrompt( + "user_123", + "agent", + "pro", + "agent-model", + null, + null, + "full_access", + false, + "miosa", + ); + + expect(prompt).toContain( + "The MIOSA template provides general Python and Node command execution.", + ); + expect(prompt).toContain("probe with `command -v `"); + expect(prompt).not.toContain("Pre-installed Pentesting Tools:"); + expect(prompt).not.toContain( + "agent-browser is installed in the cloud sandbox", + ); }); it("keeps all three Agent approval mode contracts distinct", async () => { diff --git a/lib/ai/subagents/__tests__/sandbox-identity.test.ts b/lib/ai/subagents/__tests__/sandbox-identity.test.ts index 12e7e05eb..f61777cd2 100644 --- a/lib/ai/subagents/__tests__/sandbox-identity.test.ts +++ b/lib/ai/subagents/__tests__/sandbox-identity.test.ts @@ -6,12 +6,17 @@ import { describe("subagent sandbox identity", () => { it("distinguishes E2B from user-owned Centrifugo connections", () => { const cloud = { sandboxId: "sandbox-1" } as never; + const miosa = { + sandboxKind: "miosa" as const, + sandboxId: "sandbox-2", + } as never; const local = { sandboxKind: "centrifugo", getConnectionId: () => "desktop-1", } as never; expect(getSubagentSandboxIdentity(cloud)).toBe("e2b:sandbox-1"); + expect(getSubagentSandboxIdentity(miosa)).toBe("miosa:sandbox-2"); expect(getSubagentSandboxIdentity(local)).toBe("connection:desktop-1"); expect(() => assertSubagentSandboxIdentity(cloud, "e2b:sandbox-1"), diff --git a/lib/ai/subagents/sandbox-identity.ts b/lib/ai/subagents/sandbox-identity.ts index ab9f49959..8d5c07954 100644 --- a/lib/ai/subagents/sandbox-identity.ts +++ b/lib/ai/subagents/sandbox-identity.ts @@ -1,10 +1,14 @@ import type { AnySandbox } from "@/types"; -import { isCentrifugoSandbox } from "@/lib/ai/tools/utils/sandbox-types"; +import { + isCentrifugoSandbox, + isMiosaSandbox, +} from "@/lib/ai/tools/utils/sandbox-types"; export const getSubagentSandboxIdentity = (sandbox: AnySandbox): string => { if (isCentrifugoSandbox(sandbox)) { return `connection:${sandbox.getConnectionId()}`; } + if (isMiosaSandbox(sandbox)) return `miosa:${sandbox.sandboxId}`; return `e2b:${sandbox.sandboxId}`; }; diff --git a/lib/ai/tools/__tests__/sandbox-acquisition-serialization.test.ts b/lib/ai/tools/__tests__/sandbox-acquisition-serialization.test.ts index 33e3127c4..313e2fb20 100644 --- a/lib/ai/tools/__tests__/sandbox-acquisition-serialization.test.ts +++ b/lib/ai/tools/__tests__/sandbox-acquisition-serialization.test.ts @@ -2,6 +2,8 @@ const mockGetSandboxWithFallbackGuard = jest.fn(); const mockResetSandbox = jest.fn(); const mockQuarantineLocalConnection = jest.fn(); const mockIsE2BSandbox = jest.fn(); +const mockIsMiosaSandbox = jest.fn(); +const mockLoggerWarn = jest.fn(); let mockTrackSandboxUsage: ((sandbox: unknown) => void) | undefined; jest.mock("../run-terminal-cmd", () => ({ createRunTerminalCmd: jest.fn() })); @@ -40,6 +42,13 @@ jest.mock("../utils/sandbox-manager", () => ({ jest.mock("../utils/sandbox-types", () => ({ isE2BSandbox: (...args: unknown[]) => mockIsE2BSandbox(...args), + isMiosaSandbox: (...args: unknown[]) => mockIsMiosaSandbox(...args), + getCloudSandboxProviderForInstance: ( + sandbox: { provider?: string } | null, + ) => + sandbox?.provider === "e2b" || sandbox?.provider === "miosa" + ? sandbox.provider + : null, })); jest.mock("../utils/sandbox-fallback", () => ({ @@ -50,6 +59,9 @@ jest.mock("../utils/sandbox-fallback", () => ({ jest.mock("@/lib/posthog/server", () => ({ phLogger: { event: jest.fn() }, })); +jest.mock("@/lib/logger", () => ({ + logger: { warn: (...args: unknown[]) => mockLoggerWarn(...args) }, +})); import { createTools } from ".."; import { E2B_COST_PER_MS } from "../utils/e2b-cost"; @@ -60,6 +72,7 @@ describe("sandbox acquisition serialization", () => { mockResetSandbox.mockResolvedValue(undefined); mockQuarantineLocalConnection.mockResolvedValue(undefined); mockIsE2BSandbox.mockReturnValue(false); + mockIsMiosaSandbox.mockReturnValue(false); mockTrackSandboxUsage = undefined; }); @@ -106,7 +119,7 @@ describe("sandbox acquisition serialization", () => { ); }); - it("accounts for E2B runtime", () => { + it("accounts for E2B runtime", async () => { jest.useFakeTimers(); mockIsE2BSandbox.mockImplementation( (sandbox: { provider?: string } | null) => sandbox?.provider === "e2b", @@ -127,15 +140,20 @@ describe("sandbox acquisition serialization", () => { mockTrackSandboxUsage?.({ provider: "e2b" }); jest.advanceTimersByTime(1_000); - expect(getSandboxSessionCost()).toBeCloseTo(E2B_COST_PER_MS * 1_000, 12); - expect(getSandboxSessionUsage()).toEqual({ + await expect(getSandboxSessionCost()).resolves.toBeCloseTo( + E2B_COST_PER_MS * 1_000, + 12, + ); + await expect(getSandboxSessionUsage()).resolves.toEqual({ totalCostDollars: E2B_COST_PER_MS * 1_000, + miosaRuntimeMs: 0, + miosaCostDollars: 0, e2bRuntimeMs: 1_000, e2bCostDollars: E2B_COST_PER_MS * 1_000, }); }); - it("does not charge shared sandbox runtime to a child agent", () => { + it("does not charge shared sandbox runtime to a child agent", async () => { jest.useFakeTimers(); mockIsE2BSandbox.mockImplementation( (sandbox: { provider?: string } | null) => sandbox?.provider === "e2b", @@ -169,15 +187,248 @@ describe("sandbox acquisition serialization", () => { mockTrackSandboxUsage?.({ provider: "e2b" }); jest.advanceTimersByTime(5_000); - expect(getSandboxSessionCost()).toBe(0); - expect(getSandboxSessionUsage()).toEqual({ + await expect(getSandboxSessionCost()).resolves.toBe(0); + await expect(getSandboxSessionUsage()).resolves.toEqual({ totalCostDollars: 0, + miosaRuntimeMs: 0, + miosaCostDollars: 0, e2bRuntimeMs: 0, e2bCostDollars: 0, }); }); - it("stops cloud runtime billing while a non-cloud sandbox is active", () => { + it("uses MIOSA provider-reported cost deltas", async () => { + jest.useFakeTimers(); + mockIsMiosaSandbox.mockImplementation( + (sandbox: { provider?: string } | null) => sandbox?.provider === "miosa", + ); + const usage = jest + .fn() + .mockResolvedValueOnce({ estimated_cost_cents: 100 }) + .mockResolvedValue({ estimated_cost_cents: 125 }); + const { getSandboxSessionCost, getSandboxSessionUsage } = createTools( + "user-1", + "chat-1", + {} as never, + "agent", + {} as never, + undefined, + true, + undefined, + "e2b", + "service-key", + ); + + mockTrackSandboxUsage?.({ + provider: "miosa", + sandboxId: "miosa-1", + sdkSandbox: { usage }, + }); + await jest.advanceTimersByTimeAsync(0); + jest.advanceTimersByTime(2_000); + + await expect(getSandboxSessionCost()).resolves.toBe(0.25); + await expect(getSandboxSessionUsage()).resolves.toEqual({ + totalCostDollars: 0.25, + miosaRuntimeMs: 2_000, + miosaCostDollars: 0.25, + e2bRuntimeMs: 0, + e2bCostDollars: 0, + }); + expect(usage).toHaveBeenCalledTimes(3); + }); + + it("caches step cost briefly while keeping final MIOSA settlement fresh", async () => { + jest.useFakeTimers(); + mockIsMiosaSandbox.mockImplementation( + (sandbox: { provider?: string } | null) => sandbox?.provider === "miosa", + ); + const usage = jest + .fn() + .mockResolvedValueOnce({ estimated_cost_cents: 100 }) + .mockResolvedValueOnce({ estimated_cost_cents: 125 }) + .mockResolvedValueOnce({ estimated_cost_cents: 150 }) + .mockResolvedValueOnce({ estimated_cost_cents: 175 }); + const { getSandboxSessionCost, getSandboxSessionUsage } = createTools( + "user-1", + "chat-1", + {} as never, + "agent", + {} as never, + undefined, + true, + undefined, + "e2b", + "service-key", + ); + + mockTrackSandboxUsage?.({ + provider: "miosa", + sandboxId: "miosa-1", + sdkSandbox: { usage }, + }); + await jest.advanceTimersByTimeAsync(0); + + await expect(getSandboxSessionCost()).resolves.toBe(0.25); + await expect(getSandboxSessionCost()).resolves.toBe(0.25); + expect(usage).toHaveBeenCalledTimes(2); + + await expect(getSandboxSessionUsage()).resolves.toMatchObject({ + miosaCostDollars: 0.5, + }); + expect(usage).toHaveBeenCalledTimes(3); + + await jest.advanceTimersByTimeAsync(1_000); + await expect(getSandboxSessionCost()).resolves.toBe(0.75); + expect(usage).toHaveBeenCalledTimes(4); + }); + + it("does not treat a failed MIOSA baseline read as zero cost", async () => { + mockIsMiosaSandbox.mockImplementation( + (sandbox: { provider?: string } | null) => sandbox?.provider === "miosa", + ); + const usage = jest + .fn() + .mockRejectedValueOnce(new Error("usage unavailable")) + .mockResolvedValueOnce({ estimated_cost_cents: 125 }) + .mockResolvedValueOnce({ estimated_cost_cents: 130 }); + const { getSandboxSessionCost } = createTools( + "user-1", + "chat-1", + {} as never, + "agent", + {} as never, + undefined, + true, + undefined, + "e2b", + "service-key", + ); + + mockTrackSandboxUsage?.({ + provider: "miosa", + sandboxId: "miosa-1", + sdkSandbox: { usage }, + }); + + await expect(getSandboxSessionCost()).resolves.toBe(0); + await expect(getSandboxSessionCost()).resolves.toBeCloseTo(0.05, 12); + expect(mockLoggerWarn).toHaveBeenCalledWith( + "MIOSA usage read failed", + expect.objectContaining({ + event: "miosa_usage_read_failed", + request_id: "chat-1", + sandbox_id: "miosa-1", + }), + ); + }); + + it("bounds a stalled MIOSA usage read during step settlement", async () => { + jest.useFakeTimers(); + mockIsMiosaSandbox.mockImplementation( + (sandbox: { provider?: string } | null) => sandbox?.provider === "miosa", + ); + const usage = jest.fn(() => new Promise(() => undefined)); + const { getSandboxSessionCost } = createTools( + "user-1", + "chat-1", + {} as never, + "agent", + {} as never, + undefined, + true, + undefined, + "e2b", + "service-key", + ); + + mockTrackSandboxUsage?.({ + provider: "miosa", + sandboxId: "miosa-1", + sdkSandbox: { usage }, + }); + const cost = getSandboxSessionCost(); + await jest.advanceTimersByTimeAsync(2_000); + + await expect(cost).resolves.toBe(0); + expect(usage).toHaveBeenCalledTimes(1); + }); + + it("reuses an in-flight MIOSA usage read across settlements", async () => { + jest.useFakeTimers(); + mockIsMiosaSandbox.mockImplementation( + (sandbox: { provider?: string } | null) => sandbox?.provider === "miosa", + ); + const usage = jest + .fn() + .mockResolvedValueOnce({ estimated_cost_cents: 100 }) + .mockImplementation(() => new Promise(() => undefined)); + const { getSandboxSessionCost } = createTools( + "user-1", + "chat-1", + {} as never, + "agent", + {} as never, + undefined, + true, + undefined, + "e2b", + "service-key", + ); + + mockTrackSandboxUsage?.({ + provider: "miosa", + sandboxId: "miosa-1", + sdkSandbox: { usage }, + }); + await jest.advanceTimersByTimeAsync(0); + const firstCost = getSandboxSessionCost(); + const secondCost = getSandboxSessionCost(); + await jest.advanceTimersByTimeAsync(2_000); + + await expect(firstCost).resolves.toBe(0); + await expect(secondCost).resolves.toBe(0); + expect(usage).toHaveBeenCalledTimes(2); + }); + + it("retains MIOSA cost settlement after falling back to E2B", async () => { + jest.useFakeTimers(); + mockIsMiosaSandbox.mockImplementation( + (sandbox: { provider?: string } | null) => sandbox?.provider === "miosa", + ); + mockIsE2BSandbox.mockImplementation( + (sandbox: { provider?: string } | null) => sandbox?.provider === "e2b", + ); + const usage = jest + .fn() + .mockResolvedValueOnce({ estimated_cost_cents: 100 }) + .mockResolvedValueOnce({ estimated_cost_cents: 125 }); + const { getSandboxSessionCost } = createTools( + "user-1", + "chat-1", + {} as never, + "agent", + {} as never, + undefined, + true, + undefined, + "e2b", + "service-key", + ); + + mockTrackSandboxUsage?.({ + provider: "miosa", + sandboxId: "miosa-1", + sdkSandbox: { usage }, + }); + await Promise.resolve(); + mockTrackSandboxUsage?.({ provider: "e2b", sandboxId: "e2b-1" }); + + await expect(getSandboxSessionCost()).resolves.toBe(0.25); + expect(usage).toHaveBeenCalledTimes(2); + }); + + it("stops cloud runtime billing while a non-cloud sandbox is active", async () => { jest.useFakeTimers(); mockIsE2BSandbox.mockImplementation( (sandbox: { provider?: string } | null) => sandbox?.provider === "e2b", @@ -202,8 +453,10 @@ describe("sandbox acquisition serialization", () => { mockTrackSandboxUsage?.({ provider: "e2b" }); jest.advanceTimersByTime(3_000); - expect(getSandboxSessionUsage()).toEqual({ + await expect(getSandboxSessionUsage()).resolves.toEqual({ totalCostDollars: E2B_COST_PER_MS * 4_000, + miosaRuntimeMs: 0, + miosaCostDollars: 0, e2bRuntimeMs: 4_000, e2bCostDollars: E2B_COST_PER_MS * 4_000, }); diff --git a/lib/ai/tools/__tests__/sandbox-capabilities.test.ts b/lib/ai/tools/__tests__/sandbox-capabilities.test.ts index ad7622199..681736353 100644 --- a/lib/ai/tools/__tests__/sandbox-capabilities.test.ts +++ b/lib/ai/tools/__tests__/sandbox-capabilities.test.ts @@ -16,7 +16,11 @@ import { buildSandboxCommandOptions, MAX_COMMAND_EXECUTION_TIME, } from "../utils/sandbox-command-options"; -import { isE2BSandbox } from "../utils/sandbox-types"; +import { + isCloudSandbox, + isE2BSandbox, + isMiosaSandbox, +} from "../utils/sandbox-types"; // Mock E2B sandbox (has jupyterUrl property - this is how isE2BSandbox detects it) const createMockE2BSandbox = () => ({ @@ -30,6 +34,11 @@ const createMockCentrifugoSandbox = () => ({ commands: { run: jest.fn() }, }); +const createMockMiosaSandbox = () => ({ + sandboxKind: "miosa" as const, + commands: { run: jest.fn() }, +}); + describe("Sandbox Capabilities for Network Tools", () => { describe("buildSandboxCommandOptions", () => { it("should include user:root and cwd:/home/user for E2B sandbox", () => { @@ -68,12 +77,18 @@ describe("Sandbox Capabilities for Network Tools", () => { }); describe("Sandbox Type Detection", () => { - it("should correctly identify E2B vs Centrifugo sandbox", () => { + it("should distinguish E2B, MIOSA, and Centrifugo sandboxes", () => { const e2bSandbox = createMockE2BSandbox(); + const miosaSandbox = createMockMiosaSandbox(); const centrifugoSandbox = createMockCentrifugoSandbox(); expect(isE2BSandbox(e2bSandbox as any)).toBe(true); + expect(isE2BSandbox(miosaSandbox as any)).toBe(false); expect(isE2BSandbox(centrifugoSandbox as any)).toBe(false); + expect(isMiosaSandbox(miosaSandbox as any)).toBe(true); + expect(isCloudSandbox(e2bSandbox as any)).toBe(true); + expect(isCloudSandbox(miosaSandbox as any)).toBe(true); + expect(isCloudSandbox(centrifugoSandbox as any)).toBe(false); expect(isE2BSandbox(null)).toBe(false); }); }); diff --git a/lib/ai/tools/index.ts b/lib/ai/tools/index.ts index 8c3055314..965789d29 100644 --- a/lib/ai/tools/index.ts +++ b/lib/ai/tools/index.ts @@ -21,6 +21,7 @@ import { // the added complexity. The agent should use run_terminal_cmd with rg instead. // import { createMatch } from "./match"; import type { ToolSet, UIMessageStreamWriter } from "ai"; +import type { Sandbox } from "@e2b/code-interpreter"; import type { ChatMode, ToolContext, @@ -40,14 +41,23 @@ import { FileAccumulator } from "./utils/file-accumulator"; import { BackgroundProcessTracker } from "./utils/background-process-tracker"; import { ptySessionManager } from "./utils/pty-session-manager"; import { createPtyParserLogBudget } from "./utils/pty-output-formatter"; -import { isE2BSandbox } from "./utils/sandbox-types"; +import { + getCloudSandboxProviderForInstance, + isE2BSandbox, + isMiosaSandbox, +} from "./utils/sandbox-types"; import { getSandboxWithFallbackGuard } from "./utils/sandbox-fallback"; import { createE2BResourcePressureObserver } from "@/lib/analytics/sandbox-resource-pressure"; import { E2B_COST_PER_MS } from "./utils/e2b-cost"; import { phLogger } from "@/lib/posthog/server"; +import { logger } from "@/lib/logger"; +import { redactSensitiveErrorMessage } from "@/lib/utils/error-redaction"; import type { TriggerRunRegion } from "@/lib/api/trigger-region"; import type { CloudSandboxAcquisitionContext } from "./utils/cloud-sandbox"; -import type { CloudSandboxProvider } from "./utils/cloud-sandbox-provider"; +import type { + CloudSandboxProvider, + CloudSandboxSelectionReason, +} from "./utils/cloud-sandbox-provider"; import { releaseE2BSandboxIdleLeaseBestEffort, startE2BSandboxLeaseHeartbeat, @@ -62,19 +72,25 @@ export type CreateToolsRuntimePolicy = { ptyScopeId?: string; chargeSandboxRuntime?: boolean; cloudSandboxProvider?: CloudSandboxProvider; + cloudSandboxSelectionReason?: CloudSandboxSelectionReason; triggerRegion?: TriggerRunRegion; keepE2BLeaseAliveForRun?: boolean; }; export type SandboxSessionUsage = { totalCostDollars: number; + miosaRuntimeMs: number; + miosaCostDollars: number; e2bRuntimeMs: number; e2bCostDollars: number; }; const emptySandboxRuntimeMs = (): Record => ({ + miosa: 0, e2b: 0, }); +const MIOSA_USAGE_READ_TIMEOUT_MS = 2_000; +const MIOSA_USAGE_CACHE_TTL_MS = 1_000; // Factory function to create tools with context export const createTools = ( @@ -108,11 +124,74 @@ export const createTools = ( const sandboxAccumulatedRuntimeMs = emptySandboxRuntimeMs(); let providerSelectionRecorded = false; let sandboxBootInfo: SandboxBootInfo | null = null; - let lastE2BSandbox: Extract | null = null; + let lastE2BSandbox: Sandbox | null = null; let runLeaseHeartbeat: E2BSandboxLeaseHeartbeat | null = null; let currentModelName = modelName; let sandboxOperationQueue: Promise = Promise.resolve(); let pendingSandbox: Promise | null = null; + type MiosaSandboxInstance = Extract; + type MiosaCostSource = { + sandbox: MiosaSandboxInstance; + baselinePromise: Promise; + latestCostDollars: number; + }; + const miosaCostSources = new Map(); + const miosaUsageReads = new Map>(); + let cachedMiosaCostSettlement: { + settledAt: number; + totalCostDollars: number; + } | null = null; + + const readMiosaCostDollars = async ( + miosaSandbox: MiosaSandboxInstance, + ): Promise => { + const sandboxId = miosaSandbox.sandboxId; + let providerRead = miosaUsageReads.get(sandboxId); + if (!providerRead) { + providerRead = miosaSandbox.sdkSandbox + .usage() + .then((usage) => usage.estimated_cost_cents / 100) + .catch((error) => { + logger.warn("MIOSA usage read failed", { + event: "miosa_usage_read_failed", + service: "agent-tools", + environment: + process.env.VERCEL_ENV ?? process.env.NODE_ENV ?? "unknown", + request_id: triggerRunId ?? chatId, + chat_id: chatId, + sandbox_id: sandboxId, + provider: "miosa", + error: + error instanceof Error + ? redactSensitiveErrorMessage(error.message) + : "non_error_rejection", + }); + return null; + }); + miosaUsageReads.set(sandboxId, providerRead); + void providerRead.then(() => { + if (miosaUsageReads.get(sandboxId) === providerRead) { + miosaUsageReads.delete(sandboxId); + } + }); + } + + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + providerRead, + new Promise((resolve) => { + timeout = setTimeout( + () => resolve(null), + MIOSA_USAGE_READ_TIMEOUT_MS, + ); + timeout.unref?.(); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } + }; const recordSandboxBoot = (info: SandboxBootInfo) => { sandboxBootInfo = info; @@ -121,6 +200,7 @@ export const createTools = ( const cloudSandboxContext: CloudSandboxAcquisitionContext = { provider: runtimePolicy.cloudSandboxProvider, + selectionReason: runtimePolicy.cloudSandboxSelectionReason, subscription, chatId, triggerRunId, @@ -131,7 +211,20 @@ export const createTools = ( const trackSandboxUsage = (newSandbox: AnySandbox) => { sandbox = newSandbox; - const provider = isE2BSandbox(newSandbox) ? "e2b" : null; + const provider = getCloudSandboxProviderForInstance(newSandbox); + if (isMiosaSandbox(newSandbox)) { + const existingSource = miosaCostSources.get(newSandbox.sandboxId); + if (existingSource) { + existingSource.sandbox = newSandbox; + } else { + miosaCostSources.set(newSandbox.sandboxId, { + sandbox: newSandbox, + baselinePromise: readMiosaCostDollars(newSandbox), + latestCostDollars: 0, + }); + cachedMiosaCostSettlement = null; + } + } if (isE2BSandbox(newSandbox)) { lastE2BSandbox = newSandbox; if (runtimePolicy.keepE2BLeaseAliveForRun && !runLeaseHeartbeat) { @@ -162,16 +255,22 @@ export const createTools = ( chat_id: chatId, trigger_run_id: triggerRunId, provider, - provider_selection_reason: "configured", - cloud_sandbox_transport: "e2b_sdk", + provider_selection_reason: + provider === runtimePolicy.cloudSandboxProvider + ? (runtimePolicy.cloudSandboxSelectionReason ?? "configured") + : "provider_fallback", + cloud_sandbox_transport: provider === "miosa" ? "miosa_sdk" : "e2b_sdk", subscription, subscription_tier: subscription, agent_run_kind: cloudSandboxContext.runKind, sandbox_boot_path: sandboxBootInfo?.path, sandbox_acquisition_duration_ms: sandboxBootInfo?.duration_ms, sandbox_create_attempts: sandboxBootInfo?.create_attempts, - image_version: process.env.E2B_TEMPLATE ?? "terminal-agent-sandbox", - cloud_sandbox_provider_event_version: 6, + image_version: + provider === "miosa" + ? process.env.MIOSA_TEMPLATE_ID + : (process.env.E2B_TEMPLATE ?? "terminal-agent-sandbox"), + cloud_sandbox_provider_event_version: 7, }); } }; @@ -357,10 +456,57 @@ export const createTools = ( return buildTools(); }; - const getSandboxSessionUsage = (): SandboxSessionUsage => { + const settleMiosaCostDollars = async ( + forceFresh = false, + ): Promise => { + const now = Date.now(); + if ( + !forceFresh && + cachedMiosaCostSettlement && + now - cachedMiosaCostSettlement.settledAt < MIOSA_USAGE_CACHE_TTL_MS + ) { + return cachedMiosaCostSettlement.totalCostDollars; + } + + let cacheable = true; + await Promise.all( + [...miosaCostSources.values()].map(async (source) => { + const baseline = await source.baselinePromise; + if (baseline === null) { + cacheable = false; + source.baselinePromise = readMiosaCostDollars(source.sandbox); + return; + } + const current = await readMiosaCostDollars(source.sandbox); + if (current !== null) { + source.latestCostDollars = Math.max( + source.latestCostDollars, + current - baseline, + ); + } else { + cacheable = false; + } + }), + ); + const totalCostDollars = [...miosaCostSources.values()].reduce( + (total, source) => total + source.latestCostDollars, + 0, + ); + if (cacheable) { + cachedMiosaCostSettlement = { + settledAt: Date.now(), + totalCostDollars, + }; + } + return totalCostDollars; + }; + + const getSandboxSessionUsage = async (): Promise => { if (runtimePolicy.chargeSandboxRuntime === false) { return { totalCostDollars: 0, + miosaRuntimeMs: 0, + miosaCostDollars: 0, e2bRuntimeMs: 0, e2bCostDollars: 0, }; @@ -372,15 +518,25 @@ export const createTools = ( Date.now() - sandboxCostSegmentStartedAt; } const e2bCostDollars = runtimeMs.e2b * E2B_COST_PER_MS; + const miosaCostDollars = await settleMiosaCostDollars(true); return { - totalCostDollars: e2bCostDollars, + totalCostDollars: e2bCostDollars + miosaCostDollars, + miosaRuntimeMs: runtimeMs.miosa, + miosaCostDollars, e2bRuntimeMs: runtimeMs.e2b, e2bCostDollars, }; }; - const getSandboxSessionCost = (): number => - getSandboxSessionUsage().totalCostDollars; + const getSandboxSessionCost = async (): Promise => { + if (runtimePolicy.chargeSandboxRuntime === false) return 0; + let e2bRuntimeMs = sandboxAccumulatedRuntimeMs.e2b; + if (sandboxCostSegmentStartedAt !== null && sandboxCostProvider === "e2b") { + e2bRuntimeMs += Date.now() - sandboxCostSegmentStartedAt; + } + const miosaCostDollars = await settleMiosaCostDollars(); + return e2bRuntimeMs * E2B_COST_PER_MS + miosaCostDollars; + }; const releaseE2BSandboxIdleLease = async (): Promise => { if (!lastE2BSandbox) return false; diff --git a/lib/ai/tools/run-terminal-cmd.ts b/lib/ai/tools/run-terminal-cmd.ts index 8cf104ca9..d2c27a8de 100644 --- a/lib/ai/tools/run-terminal-cmd.ts +++ b/lib/ai/tools/run-terminal-cmd.ts @@ -19,7 +19,12 @@ import { observeSandboxRecovery, observeTerminalTimeoutRecovery, } from "./utils/sandbox-health"; -import { isE2BSandbox, isCentrifugoSandbox } from "./utils/sandbox-types"; +import { + isCloudSandbox, + isE2BSandbox, + isMiosaSandbox, + isCentrifugoSandbox, +} from "./utils/sandbox-types"; import { buildSandboxCommandOptions, augmentCommandPath, @@ -377,13 +382,13 @@ export const createRunTerminalCmd = (context: ToolContext) => { const isCentrifugo = isCentrifugoSandbox(sandbox); const isE2B = isE2BSandbox(sandbox); - if (!isE2B && !isCentrifugo) { + if (!isE2B && !isCentrifugo && !isMiosaSandbox(sandbox)) { return { result: { output: "", exitCode: 1, error: - "Interactive PTY requires E2B or local (Centrifugo) sandbox.", + "Interactive PTY requires E2B, MIOSA, or local (Centrifugo) sandbox.", }, }; } @@ -439,6 +444,17 @@ export const createRunTerminalCmd = (context: ToolContext) => { cwd: sandbox.getWorkingDirectory(), }); } + if (isMiosaSandbox(sandbox)) { + const { createMiosaPtyHandle } = await import( + "./utils/miosa-pty-adapter" + ); + return createMiosaPtyHandle(sandbox, { + cols, + rows, + cwd: buildSandboxCommandOptions(sandbox).cwd, + envs: agentBrowserEnv, + }); + } return createE2BPtyHandle(sandbox, { cols, rows, @@ -586,9 +602,9 @@ export const createRunTerminalCmd = (context: ToolContext) => { }; } - // Only health-check E2B sandboxes — local sandboxes don't need it + // Health-check cloud sandboxes; local sandboxes have relay-specific checks. // (they relay commands through Convex and have their own connectivity) - if (isE2BSandbox(sandbox)) { + if (isCloudSandbox(sandbox)) { try { await waitForSandboxReady( sandbox, @@ -1126,9 +1142,13 @@ export const createRunTerminalCmd = (context: ToolContext) => { onStderr: forwardCommandOutput, }, ); - const agentBrowserEnv = isE2BSandbox(sandboxInstance) - ? getAgentBrowserRuntimeEnv(command) - : undefined; + // agent-browser is installed in MIOSA sandboxes too, and needs the + // same runtime env there. Gating this on E2B alone left Chromium + // running without its configured flags on MIOSA. + const agentBrowserEnv = + isE2BSandbox(sandboxInstance) || isMiosaSandbox(sandboxInstance) + ? getAgentBrowserRuntimeEnv(command) + : undefined; const runOptions = isCentrifugoSandbox(sandboxInstance) ? { ...commonOptions, diff --git a/lib/ai/tools/utils/__tests__/cloud-sandbox-cleanup.test.ts b/lib/ai/tools/utils/__tests__/cloud-sandbox-cleanup.test.ts index 28618cc66..b4b76ffca 100644 --- a/lib/ai/tools/utils/__tests__/cloud-sandbox-cleanup.test.ts +++ b/lib/ai/tools/utils/__tests__/cloud-sandbox-cleanup.test.ts @@ -1,6 +1,8 @@ import { Sandbox } from "@e2b/code-interpreter"; import { terminateCloudSandboxesForUser } from "../cloud-sandbox"; +const mockTerminateMiosaSandboxesForUser = jest.fn(); + jest.mock("@e2b/code-interpreter", () => ({ Sandbox: { list: jest.fn(), @@ -8,6 +10,12 @@ jest.mock("@e2b/code-interpreter", () => ({ }, })); +jest.mock("../miosa-sandbox", () => ({ + ensureMiosaSandboxConnection: jest.fn(), + terminateMiosaSandboxesForUser: (...args: unknown[]) => + mockTerminateMiosaSandboxesForUser(...args), +})); + const mockListE2BSandboxes = Sandbox.list as jest.MockedFunction< typeof Sandbox.list >; @@ -22,6 +30,7 @@ describe("cloud sandbox cleanup", () => { jest.clearAllMocks(); process.env = { ...originalEnv }; delete process.env.E2B_API_KEY; + delete process.env.MIOSA_API_KEY; delete process.env.E2B_EU_API_KEY; delete process.env.E2B_EU_DOMAIN; delete process.env.E2B_EU_TEMPLATE; @@ -72,6 +81,50 @@ describe("cloud sandbox cleanup", () => { errorSpy.mockRestore(); }); + it("combines MIOSA and E2B cleanup totals", async () => { + process.env.MIOSA_API_KEY = "msk_test"; + process.env.E2B_API_KEY = "e2b-test-key"; + mockTerminateMiosaSandboxesForUser.mockResolvedValue({ + total: 2, + killed: 1, + alreadyGone: 1, + }); + mockListE2BSandboxes.mockReturnValue({ + nextItems: jest.fn(async () => [{ sandboxId: "sandbox-e2b" }]), + hasNext: false, + } as ReturnType); + mockKillE2BSandbox.mockResolvedValue(undefined); + + await expect(terminateCloudSandboxesForUser("user_123")).resolves.toEqual({ + total: 3, + killed: 2, + alreadyGone: 1, + }); + expect(mockTerminateMiosaSandboxesForUser).toHaveBeenCalledWith("user_123"); + expect(mockKillE2BSandbox).toHaveBeenCalledWith("sandbox-e2b"); + }); + + it("still cleans up E2B when MIOSA cleanup fails", async () => { + process.env.MIOSA_API_KEY = "msk_test"; + process.env.E2B_API_KEY = "e2b-test-key"; + mockTerminateMiosaSandboxesForUser.mockRejectedValue( + new Error("MIOSA unavailable"), + ); + mockListE2BSandboxes.mockReturnValue({ + nextItems: jest.fn(async () => [{ sandboxId: "sandbox-e2b" }]), + hasNext: false, + } as ReturnType); + mockKillE2BSandbox.mockResolvedValue(undefined); + const errorSpy = jest.spyOn(console, "error").mockImplementation(); + + await expect(terminateCloudSandboxesForUser("user_123")).rejects.toThrow( + "MIOSA unavailable", + ); + expect(mockKillE2BSandbox).toHaveBeenCalledWith("sandbox-e2b"); + + errorSpy.mockRestore(); + }); + it("terminates sandboxes in both configured clusters", async () => { process.env.E2B_API_KEY = "e2b-us-test-key"; process.env.E2B_EU_API_KEY = "e2b-eu-test-key"; diff --git a/lib/ai/tools/utils/__tests__/cloud-sandbox-provider.test.ts b/lib/ai/tools/utils/__tests__/cloud-sandbox-provider.test.ts index b2bb4dcaa..e061b991a 100644 --- a/lib/ai/tools/utils/__tests__/cloud-sandbox-provider.test.ts +++ b/lib/ai/tools/utils/__tests__/cloud-sandbox-provider.test.ts @@ -1,7 +1,15 @@ -import { getCloudSandboxProvider } from "../cloud-sandbox-provider"; +import { + getCloudSandboxProvider, + MIOSA_CLOUD_SANDBOX_ENVIRONMENT_PROPERTY, + MIOSA_CLOUD_SANDBOX_ROLLOUT_FLAG, + normalizeCloudSandboxFlagEnvironment, + selectCloudSandboxProvider, +} from "../cloud-sandbox-provider"; describe("cloud sandbox provider selection", () => { const originalProvider = process.env.CLOUD_SANDBOX_PROVIDER; + const originalMiosaKey = process.env.MIOSA_API_KEY; + const originalMiosaTemplate = process.env.MIOSA_TEMPLATE_ID; afterEach(() => { if (originalProvider === undefined) { @@ -9,6 +17,22 @@ describe("cloud sandbox provider selection", () => { } else { process.env.CLOUD_SANDBOX_PROVIDER = originalProvider; } + if (originalMiosaKey === undefined) delete process.env.MIOSA_API_KEY; + else process.env.MIOSA_API_KEY = originalMiosaKey; + if (originalMiosaTemplate === undefined) { + delete process.env.MIOSA_TEMPLATE_ID; + } else { + process.env.MIOSA_TEMPLATE_ID = originalMiosaTemplate; + } + }); + + it.each([ + ["PREVIEW", "preview"], + ["PRODUCTION", "production"], + ["STAGING", "staging"], + ["DEVELOPMENT", "development"], + ])("normalizes the %s execution environment", (environment, expected) => { + expect(normalizeCloudSandboxFlagEnvironment(environment)).toBe(expected); }); it("defaults to E2B", () => { @@ -21,6 +45,63 @@ describe("cloud sandbox provider selection", () => { expect(getCloudSandboxProvider()).toBe("e2b"); }); + it("honors an explicit MIOSA provider", () => { + process.env.CLOUD_SANDBOX_PROVIDER = "miosa"; + expect(getCloudSandboxProvider()).toBe("miosa"); + }); + + it("selects MIOSA only for an enabled rollout assignment with complete configuration", async () => { + delete process.env.CLOUD_SANDBOX_PROVIDER; + process.env.MIOSA_API_KEY = "msk_test"; + process.env.MIOSA_TEMPLATE_ID = "hackerai-kali-promoted"; + const getFlag = jest.fn(() => true); + const evaluateFlags = jest.fn(async () => ({ getFlag })); + + await expect( + selectCloudSandboxProvider({ + userId: "user-1", + environment: "PREVIEW", + featureFlagClient: { evaluateFlags }, + }), + ).resolves.toEqual({ provider: "miosa", reason: "miosa_rollout" }); + expect(evaluateFlags).toHaveBeenCalledWith("user-1", { + flagKeys: [MIOSA_CLOUD_SANDBOX_ROLLOUT_FLAG], + personProperties: { + [MIOSA_CLOUD_SANDBOX_ENVIRONMENT_PROPERTY]: "preview", + }, + }); + expect(getFlag).toHaveBeenCalledWith(MIOSA_CLOUD_SANDBOX_ROLLOUT_FLAG); + }); + + it.each([ + ["API key", undefined, "hackerai-kali-promoted"], + ["template ID", "msk_test", undefined], + ])( + "keeps E2B when the MIOSA %s is unavailable", + async (_missingField, apiKey, templateId) => { + delete process.env.CLOUD_SANDBOX_PROVIDER; + if (apiKey === undefined) delete process.env.MIOSA_API_KEY; + else process.env.MIOSA_API_KEY = apiKey; + if (templateId === undefined) delete process.env.MIOSA_TEMPLATE_ID; + else process.env.MIOSA_TEMPLATE_ID = templateId; + const evaluateFlags = jest.fn(async () => ({ + getFlag: () => true, + })); + + await expect( + selectCloudSandboxProvider({ + userId: "user-1", + environment: "PREVIEW", + featureFlagClient: { evaluateFlags }, + }), + ).resolves.toEqual({ + provider: "e2b", + reason: "miosa_configuration_unavailable", + }); + expect(evaluateFlags).not.toHaveBeenCalled(); + }, + ); + it("fails closed for an unsupported provider", () => { process.env.CLOUD_SANDBOX_PROVIDER = "unknown-provider"; expect(() => getCloudSandboxProvider()).toThrow( diff --git a/lib/ai/tools/utils/__tests__/cloud-sandbox-routing.test.ts b/lib/ai/tools/utils/__tests__/cloud-sandbox-routing.test.ts new file mode 100644 index 000000000..7ea5b1d11 --- /dev/null +++ b/lib/ai/tools/utils/__tests__/cloud-sandbox-routing.test.ts @@ -0,0 +1,108 @@ +const mockEnsureE2B = jest.fn(); +const mockEnsureMiosa = jest.fn(); +const mockTerminateMiosa = jest.fn(); +const mockPostHogEvent = jest.fn(); + +jest.mock("../sandbox", () => ({ + ensureSandboxConnection: (...args: unknown[]) => mockEnsureE2B(...args), +})); + +jest.mock("../miosa-sandbox", () => ({ + ensureMiosaSandboxConnection: (...args: unknown[]) => + mockEnsureMiosa(...args), + terminateMiosaSandboxesForUser: (...args: unknown[]) => + mockTerminateMiosa(...args), +})); + +jest.mock("@/lib/posthog/server", () => ({ + phLogger: { event: (...args: unknown[]) => mockPostHogEvent(...args) }, +})); + +import { ensureCloudSandboxConnection } from "../cloud-sandbox"; + +describe("cloud sandbox provider routing", () => { + const setSandbox = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("uses MIOSA for treatment assignments", async () => { + const sandbox = { sandboxKind: "miosa", sandboxId: "miosa-1" }; + mockEnsureMiosa.mockResolvedValue({ sandbox }); + + await expect( + ensureCloudSandboxConnection({ + userId: "user-1", + setSandbox, + context: { + provider: "miosa", + selectionReason: "miosa_rollout", + triggerRunId: "run-1", + }, + }), + ).resolves.toEqual({ sandbox, provider: "miosa" }); + + expect(mockEnsureMiosa).toHaveBeenCalledTimes(1); + expect(mockEnsureE2B).not.toHaveBeenCalled(); + expect(mockPostHogEvent).toHaveBeenCalledWith( + "miosa_cloud_sandbox_rollout_exposed", + expect.objectContaining({ + userId: "user-1", + variant: "miosa", + eventUuid: "run-1:miosa-cloud-sandbox-rollout-v1", + }), + ); + }); + + it("falls back to E2B when MIOSA acquisition fails", async () => { + const sandbox = { sandboxId: "e2b-1" }; + mockEnsureMiosa.mockRejectedValue(new Error("MIOSA unavailable")); + mockEnsureE2B.mockResolvedValue({ sandbox }); + + await expect( + ensureCloudSandboxConnection({ + userId: "user-1", + setSandbox, + context: { + provider: "miosa", + selectionReason: "miosa_rollout", + triggerRunId: "run-1", + }, + }), + ).resolves.toEqual({ sandbox, provider: "e2b" }); + + expect(mockEnsureMiosa).toHaveBeenCalledTimes(1); + expect(mockEnsureE2B).toHaveBeenCalledTimes(1); + expect(mockPostHogEvent).toHaveBeenCalledWith( + "cloud_sandbox_provider_fallback", + expect.objectContaining({ + from_provider: "miosa", + to_provider: "e2b", + error_name: "Error", + }), + ); + }); + + it("does not call MIOSA for the E2B control", async () => { + const sandbox = { sandboxId: "e2b-1" }; + mockEnsureE2B.mockResolvedValue({ sandbox }); + + await expect( + ensureCloudSandboxConnection({ + userId: "user-1", + setSandbox, + context: { + provider: "e2b", + selectionReason: "miosa_rollout_control", + }, + }), + ).resolves.toEqual({ sandbox, provider: "e2b" }); + + expect(mockEnsureMiosa).not.toHaveBeenCalled(); + expect(mockPostHogEvent).toHaveBeenCalledWith( + "miosa_cloud_sandbox_rollout_exposed", + expect.objectContaining({ variant: "e2b" }), + ); + }); +}); diff --git a/lib/ai/tools/utils/__tests__/hybrid-sandbox-manager.test.ts b/lib/ai/tools/utils/__tests__/hybrid-sandbox-manager.test.ts index 911d9d976..b238fe715 100644 --- a/lib/ai/tools/utils/__tests__/hybrid-sandbox-manager.test.ts +++ b/lib/ai/tools/utils/__tests__/hybrid-sandbox-manager.test.ts @@ -30,6 +30,7 @@ import { import { assertAgentApprovalSandboxIdentity, assertLocalSandboxFallbackAllowed, + getAgentApprovalSandboxIdentity, getSandboxFallbackErrorMessage, getSandboxFallbackPromptReminder, getSandboxWithFallbackGuard, @@ -438,6 +439,38 @@ describe("HybridSandboxManager prompt-time fallback", () => { ).toThrow("selected sandbox changed after approval"); }); + it("keeps MIOSA approvals isolated from E2B", () => { + const miosa = { sandboxKind: "miosa" as const } as never; + const e2b = { commands: {} } as never; + + expect(getAgentApprovalSandboxIdentity(miosa)).toBe("miosa"); + expect(getAgentApprovalSandboxIdentity(e2b)).toBe("e2b"); + expect(() => + assertAgentApprovalSandboxIdentity({ + sandbox: miosa, + expectedSandboxIdentity: "e2b", + }), + ).toThrow("selected sandbox changed after approval"); + }); + + it("does not advertise interactive PTY after a local preference falls back to MIOSA", async () => { + const manager = new HybridSandboxManager( + "user-1", + jest.fn(), + "desktop", + "service-key", + null, + "pro", + undefined, + undefined, + undefined, + undefined, + { provider: "miosa" }, + ); + + await expect(manager.supportsInteractivePty()).resolves.toBe(false); + }); + it("blocks Desktop-local attachment preparation when Desktop falls back", () => { let error: unknown; try { diff --git a/lib/ai/tools/utils/__tests__/miosa-metrics.test.ts b/lib/ai/tools/utils/__tests__/miosa-metrics.test.ts new file mode 100644 index 000000000..610e2b3d2 --- /dev/null +++ b/lib/ai/tools/utils/__tests__/miosa-metrics.test.ts @@ -0,0 +1,136 @@ +/** + * Tests for sampleMiosaMetrics — live CPU/memory/disk sampling inside a MIOSA + * sandbox, used by the pre-command health check. + */ + +import { sampleMiosaMetrics } from "../miosa-metrics"; +import type { MiosaSandbox } from "../miosa-sandbox"; + +type RunResult = { stdout: string; stderr: string; exitCode: number }; + +function sandboxReturning(result: RunResult | Error): MiosaSandbox { + return { + commands: { + run: async () => { + if (result instanceof Error) throw result; + return result; + }, + }, + } as unknown as MiosaSandbox; +} + +/** Two /proc/stat lines whose delta is 100 busy jiffies out of 200 total. */ +const CPU_50_PCT = [ + "CPU_A:cpu 100 0 100 800 0 0 0 0 0 0", + "CPU_B:cpu 150 0 150 900 0 0 0 0 0 0", +].join("\n"); + +function probeOutput(overrides: Partial> = {}): string { + const base: Record = { + cpu: CPU_50_PCT, + memTotal: "MEM_TOTAL:1000000", + memAvail: "MEM_AVAIL:250000", + diskUsed: "DISK_USED:2000", + diskTotal: "DISK_TOTAL:10000", + }; + const merged = { ...base, ...overrides }; + return [ + merged.cpu, + merged.memTotal, + merged.memAvail, + merged.diskUsed, + merged.diskTotal, + ].join("\n"); +} + +describe("sampleMiosaMetrics", () => { + it("derives cpu, memory and disk percentages from the guest probe", async () => { + const sandbox = sandboxReturning({ + stdout: probeOutput(), + stderr: "", + exitCode: 0, + }); + + const metrics = await sampleMiosaMetrics(sandbox); + + // busy delta 100 of 200 total jiffies + expect(metrics?.cpuPct).toBeCloseTo(50, 5); + // 750000 of 1000000 kB in use + expect(metrics?.memPct).toBeCloseTo(75, 5); + // 2000 of 10000 kB used + expect(metrics?.diskPct).toBeCloseTo(20, 5); + }); + + it("reports 0% CPU for an idle guest rather than NaN", async () => { + const idle = [ + "CPU_A:cpu 100 0 100 800 0 0 0 0 0 0", + "CPU_B:cpu 100 0 100 800 0 0 0 0 0 0", + ].join("\n"); + + const metrics = await sampleMiosaMetrics( + sandboxReturning({ + stdout: probeOutput({ cpu: idle }), + stderr: "", + exitCode: 0, + }), + ); + + expect(metrics?.cpuPct).toBe(0); + }); + + it("returns null when the probe exits non-zero", async () => { + const metrics = await sampleMiosaMetrics( + sandboxReturning({ stdout: "", stderr: "boom", exitCode: 1 }), + ); + expect(metrics).toBeNull(); + }); + + it("returns null instead of throwing when the command itself fails", async () => { + const metrics = await sampleMiosaMetrics( + sandboxReturning(new Error("sandbox unreachable")), + ); + expect(metrics).toBeNull(); + }); + + it("returns null on unparseable output rather than reporting zeroes", async () => { + // A sandbox that answers but without the expected fields must not be + // reported as a perfectly idle machine — that would mask real pressure. + const metrics = await sampleMiosaMetrics( + sandboxReturning({ stdout: "unexpected", stderr: "", exitCode: 0 }), + ); + expect(metrics).toBeNull(); + }); + + it("tolerates a guest that reports no disk figures", async () => { + const metrics = await sampleMiosaMetrics( + sandboxReturning({ + stdout: [CPU_50_PCT, "MEM_TOTAL:1000000", "MEM_AVAIL:250000"].join( + "\n", + ), + stderr: "", + exitCode: 0, + }), + ); + + expect(metrics).not.toBeNull(); + expect(metrics?.diskPct).toBe(0); + expect(metrics?.memPct).toBeCloseTo(75, 5); + }); + + it("clamps percentages into 0-100", async () => { + // MemAvailable can briefly exceed MemTotal on some kernels; a negative + // percentage would render as "-3%" in a warning string. + const metrics = await sampleMiosaMetrics( + sandboxReturning({ + stdout: probeOutput({ + memTotal: "MEM_TOTAL:1000", + memAvail: "MEM_AVAIL:1200", + }), + stderr: "", + exitCode: 0, + }), + ); + + expect(metrics?.memPct).toBe(0); + }); +}); diff --git a/lib/ai/tools/utils/__tests__/miosa-sandbox.test.ts b/lib/ai/tools/utils/__tests__/miosa-sandbox.test.ts new file mode 100644 index 000000000..3a7ee7376 --- /dev/null +++ b/lib/ai/tools/utils/__tests__/miosa-sandbox.test.ts @@ -0,0 +1,174 @@ +const mockGetOrCreate = jest.fn(); + +jest.mock("@miosa/sdk", () => ({ + Miosa: jest.fn(() => ({ + sandboxes: { + getOrCreate: (...args: unknown[]) => mockGetOrCreate(...args), + }, + })), +})); + +import { ensureMiosaSandboxConnection, MiosaSandbox } from "../miosa-sandbox"; + +const createSdkSandbox = () => ({ + id: "miosa-1", + state: "running", + templateId: "hackerai-kali-promoted", + data: { id: "miosa-1", state: "running", boot_path: "created" }, + exec: { + run: jest.fn(async () => ({ stdout: "", stderr: "", exitCode: 0 })), + stream: jest.fn(), + }, + files: { + write: jest.fn(), + readText: jest.fn(), + list: jest.fn(), + stat: jest.fn(), + }, + extend: jest.fn(), + refresh: jest.fn(), + getHost: jest.fn(), + usage: jest.fn(async () => ({ estimated_cost_cents: 0 })), +}); + +describe("MIOSA sandbox adapter", () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { + ...originalEnv, + MIOSA_API_KEY: "msk_test", + MIOSA_TEMPLATE_ID: "hackerai-kali-promoted", + }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it("creates or resumes a stable persistent per-user workspace", async () => { + const sdkSandbox = createSdkSandbox(); + mockGetOrCreate.mockResolvedValue(sdkSandbox); + const setSandbox = jest.fn(); + const onBoot = jest.fn(); + + const result = await ensureMiosaSandboxConnection({ + userID: "user-1", + setSandbox, + onBoot, + }); + + expect(result.sandbox).toBeInstanceOf(MiosaSandbox); + expect(mockGetOrCreate).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.stringMatching(/^hackerai-[a-f0-9]{24}$/), + templateId: "hackerai-kali-promoted", + persistent: true, + idleTimeoutSec: 420, + waitUntilReady: true, + externalUserId: expect.stringMatching(/^hackerai-[a-f0-9]{24}$/), + }), + ); + expect(sdkSandbox.exec.run).toHaveBeenCalledWith( + expect.stringContaining("mkdir -p /home/user/upload"), + { timeoutSec: 10 }, + ); + expect(setSandbox).toHaveBeenCalledWith(result.sandbox); + expect(onBoot).toHaveBeenCalledWith( + expect.objectContaining({ path: "create_fresh", create_attempts: 1 }), + ); + }); + + it("rejects acquisition when no promoted template is configured", async () => { + delete process.env.MIOSA_TEMPLATE_ID; + + await expect( + ensureMiosaSandboxConnection({ + userID: "user-1", + setSandbox: jest.fn(), + }), + ).rejects.toThrow( + "MIOSA_TEMPLATE_ID must identify the promoted HackerAI sandbox template", + ); + expect(mockGetOrCreate).not.toHaveBeenCalled(); + }); + + it("maps streaming stdout, stderr, and exit status", async () => { + const sdkSandbox = createSdkSandbox(); + async function* stream() { + yield { type: "stdout", line: "hello" }; + yield { type: "stderr", line: "warning" }; + yield { type: "exit", exit_code: 7 }; + } + sdkSandbox.exec.stream.mockImplementation(stream); + const sandbox = new MiosaSandbox(sdkSandbox as never); + const onStdout = jest.fn(); + const onStderr = jest.fn(); + + await expect( + sandbox.commands.run("example", { onStdout, onStderr, timeoutMs: 1500 }), + ).resolves.toEqual({ + stdout: "hello", + stderr: "warning", + exitCode: 7, + }); + expect(sdkSandbox.exec.stream).toHaveBeenCalledWith("example", { + cwd: "/home/user", + timeoutSec: 2, + }); + expect(onStdout).toHaveBeenCalledWith("hello\n"); + expect(onStderr).toHaveBeenCalledWith("warning\n"); + }); + + it("starts background commands without waiting for their completion", async () => { + const sdkSandbox = createSdkSandbox(); + sdkSandbox.exec.run.mockResolvedValue({ + stdout: "4321", + stderr: "", + exitCode: 0, + }); + const sandbox = new MiosaSandbox(sdkSandbox as never); + + await expect( + sandbox.commands.run("npm run dev", { background: true }), + ).resolves.toEqual({ stdout: "", stderr: "", exitCode: 0, pid: 4321 }); + expect(sdkSandbox.exec.run).toHaveBeenCalledWith( + expect.stringContaining("nohup bash -lc 'npm run dev'"), + { cwd: "/home/user" }, + ); + }); + + it("terminates the remote process group when a foreground command is aborted", async () => { + const sdkSandbox = createSdkSandbox(); + let finishStream: (() => void) | undefined; + const streamFinished = new Promise((resolve) => { + finishStream = resolve; + }); + async function* stream() { + await streamFinished; + } + sdkSandbox.exec.stream.mockImplementation(stream); + sdkSandbox.exec.run.mockImplementation(async () => { + finishStream?.(); + return { stdout: "", stderr: "", exitCode: 0 }; + }); + const sandbox = new MiosaSandbox(sdkSandbox as never); + const controller = new AbortController(); + const command = sandbox.commands.run("sleep 60", { + signal: controller.signal, + }); + + controller.abort(); + + await expect(command).rejects.toMatchObject({ name: "AbortError" }); + expect(sdkSandbox.exec.stream).toHaveBeenCalledWith( + expect.stringContaining("setsid bash -lc"), + { cwd: "/home/user" }, + ); + expect(sdkSandbox.exec.run).toHaveBeenCalledWith( + expect.stringContaining("kill -TERM --"), + { timeoutSec: 5 }, + ); + }); +}); diff --git a/lib/ai/tools/utils/cloud-sandbox-provider.ts b/lib/ai/tools/utils/cloud-sandbox-provider.ts index e2742b69a..196f65754 100644 --- a/lib/ai/tools/utils/cloud-sandbox-provider.ts +++ b/lib/ai/tools/utils/cloud-sandbox-provider.ts @@ -1,19 +1,91 @@ -export type CloudSandboxProvider = "e2b"; +export type CloudSandboxProvider = "miosa" | "e2b"; +export type CloudSandboxSelectionReason = + | "configured" + | "miosa_rollout" + | "miosa_rollout_control" + | "miosa_configuration_unavailable"; + +export const MIOSA_CLOUD_SANDBOX_ROLLOUT_FLAG = + "miosa_cloud_sandbox_rollout_v1"; +export const MIOSA_CLOUD_SANDBOX_ENVIRONMENT_PROPERTY = "hackerai_environment"; /** - * Resolve the configured cloud sandbox provider, defaulting to E2B and - * rejecting unsupported values instead of silently selecting a provider. + * Resolve an explicit provider override. Without one, production request + * routing is decided by {@link selectCloudSandboxProvider}. */ export function getCloudSandboxProvider(): CloudSandboxProvider { const configured = process.env.CLOUD_SANDBOX_PROVIDER?.trim(); - if (configured === "e2b") { + if (configured === "e2b" || configured === "miosa") { return configured; } if (configured) { throw new Error( - `Unsupported CLOUD_SANDBOX_PROVIDER: ${configured}. Expected e2b.`, + `Unsupported CLOUD_SANDBOX_PROVIDER: ${configured}. Expected miosa or e2b.`, ); } return "e2b"; } + +type FeatureFlagClient = { + evaluateFlags: ( + distinctId: string, + options?: { + flagKeys?: string[]; + personProperties?: Record; + }, + ) => Promise<{ getFlag: (flagKey: string) => unknown }>; +}; + +export function normalizeCloudSandboxFlagEnvironment( + environment: string, +): string { + return environment.trim().toLowerCase(); +} + +/** + * Selects the request's preferred cloud provider. Callers supply their + * execution environment explicitly so durable workers do not infer deployment + * context from NODE_ENV. + */ +export async function selectCloudSandboxProvider(options: { + userId: string; + environment: string; + featureFlagClient?: FeatureFlagClient | null; +}): Promise<{ + provider: CloudSandboxProvider; + reason: CloudSandboxSelectionReason; +}> { + if (process.env.CLOUD_SANDBOX_PROVIDER) { + return { provider: getCloudSandboxProvider(), reason: "configured" }; + } + + if ( + !process.env.MIOSA_API_KEY?.trim() || + !process.env.MIOSA_TEMPLATE_ID?.trim() + ) { + return { + provider: "e2b", + reason: "miosa_configuration_unavailable", + }; + } + + try { + const flags = await options.featureFlagClient?.evaluateFlags( + options.userId, + { + flagKeys: [MIOSA_CLOUD_SANDBOX_ROLLOUT_FLAG], + personProperties: { + [MIOSA_CLOUD_SANDBOX_ENVIRONMENT_PROPERTY]: + normalizeCloudSandboxFlagEnvironment(options.environment), + }, + }, + ); + const enabled = flags?.getFlag(MIOSA_CLOUD_SANDBOX_ROLLOUT_FLAG) === true; + return enabled + ? { provider: "miosa", reason: "miosa_rollout" } + : { provider: "e2b", reason: "miosa_rollout_control" }; + } catch { + return { provider: "e2b", reason: "miosa_rollout_control" }; + } +} diff --git a/lib/ai/tools/utils/cloud-sandbox.ts b/lib/ai/tools/utils/cloud-sandbox.ts index a4ad0e353..854a983fc 100644 --- a/lib/ai/tools/utils/cloud-sandbox.ts +++ b/lib/ai/tools/utils/cloud-sandbox.ts @@ -1,14 +1,20 @@ import type { AnySandbox, SandboxBootInfo } from "@/types"; import type { SubscriptionTier } from "@/types"; import type { CloudSandboxProvider } from "./cloud-sandbox-provider"; +import type { CloudSandboxSelectionReason } from "./cloud-sandbox-provider"; import { ensureSandboxConnection } from "./sandbox"; -import { isE2BSandbox } from "./sandbox-types"; +import { isE2BSandbox, isMiosaSandbox } from "./sandbox-types"; +import { + ensureMiosaSandboxConnection, + terminateMiosaSandboxesForUser, +} from "./miosa-sandbox"; import { phLogger } from "@/lib/posthog/server"; import type { TriggerRunRegion } from "@/lib/api/trigger-region"; import { getConfiguredE2BClustersForCleanup } from "./e2b-cluster"; export type CloudSandboxAcquisitionContext = { provider?: CloudSandboxProvider; + selectionReason?: CloudSandboxSelectionReason; subscription?: SubscriptionTier; chatId?: string; triggerRunId?: string; @@ -38,33 +44,122 @@ const ensureE2BCloudSandboxConnection = (options: { }, ); +const ensureMiosaCloudSandboxConnection = (options: { + userId: string; + initialSandbox?: AnySandbox | null; + setSandbox: (sandbox: AnySandbox) => void; + onBoot?: (info: SandboxBootInfo) => void; +}) => + ensureMiosaSandboxConnection( + { + userID: options.userId, + setSandbox: options.setSandbox, + onBoot: options.onBoot, + }, + { + initialSandbox: + options.initialSandbox && isMiosaSandbox(options.initialSandbox) + ? options.initialSandbox + : null, + }, + ); + +const recordAcquisitionFailure = (options: { + userId: string; + provider: CloudSandboxProvider; + startedAt: number; + error: unknown; + context?: CloudSandboxAcquisitionContext; +}): void => { + phLogger.event("cloud_sandbox_acquisition_failed", { + userId: options.userId, + chat_id: options.context?.chatId, + trigger_run_id: options.context?.triggerRunId, + provider: options.provider, + cloud_sandbox_transport: + options.provider === "miosa" ? "miosa_sdk" : "e2b_sdk", + subscription: options.context?.subscription, + subscription_tier: options.context?.subscription, + agent_run_kind: options.context?.runKind ?? "parent", + trigger_region: options.context?.triggerRegion, + failure_stage: "ensure_cloud_sandbox", + duration_ms: Date.now() - options.startedAt, + error_name: + options.error instanceof Error ? options.error.name : "UnknownError", + cloud_sandbox_acquisition_failed_event_version: 3, + }); +}; + +const recordRolloutExposure = (options: { + userId: string; + context?: CloudSandboxAcquisitionContext; +}): void => { + const reason = options.context?.selectionReason; + if (reason !== "miosa_rollout" && reason !== "miosa_rollout_control") { + return; + } + const variant = reason === "miosa_rollout" ? "miosa" : "e2b"; + phLogger.event("miosa_cloud_sandbox_rollout_exposed", { + userId: options.userId, + ...(options.context?.triggerRunId && { + eventUuid: `${options.context.triggerRunId}:miosa-cloud-sandbox-rollout-v1`, + }), + chat_id: options.context?.chatId, + trigger_run_id: options.context?.triggerRunId, + variant, + subscription_tier: options.context?.subscription, + agent_run_kind: options.context?.runKind ?? "parent", + miosa_cloud_sandbox_rollout_exposed_event_version: 1, + }); +}; + export async function ensureCloudSandboxConnection(options: { userId: string; initialSandbox?: AnySandbox | null; setSandbox: (sandbox: AnySandbox) => void; onBoot?: (info: SandboxBootInfo) => void; context?: CloudSandboxAcquisitionContext; -}): Promise<{ sandbox: AnySandbox }> { +}): Promise<{ sandbox: AnySandbox; provider: CloudSandboxProvider }> { const startedAt = Date.now(); + const preferredProvider = options.context?.provider ?? "e2b"; + recordRolloutExposure(options); + + if (preferredProvider === "miosa") { + try { + const result = await ensureMiosaCloudSandboxConnection(options); + return { ...result, provider: "miosa" }; + } catch (error) { + recordAcquisitionFailure({ + userId: options.userId, + provider: "miosa", + startedAt, + error, + context: options.context, + }); + phLogger.event("cloud_sandbox_provider_fallback", { + userId: options.userId, + chat_id: options.context?.chatId, + trigger_run_id: options.context?.triggerRunId, + from_provider: "miosa", + to_provider: "e2b", + fallback_stage: "acquisition", + error_name: error instanceof Error ? error.name : "UnknownError", + cloud_sandbox_provider_fallback_event_version: 1, + }); + } + } + try { - return await ensureE2BCloudSandboxConnection(options); + const result = await ensureE2BCloudSandboxConnection(options); + return { ...result, provider: "e2b" }; } catch (error) { - phLogger.event("cloud_sandbox_acquisition_failed", { + recordAcquisitionFailure({ userId: options.userId, - chat_id: options.context?.chatId, - trigger_run_id: options.context?.triggerRunId, provider: "e2b", - cloud_sandbox_transport: "e2b_sdk", - subscription: options.context?.subscription, - subscription_tier: options.context?.subscription, - agent_run_kind: options.context?.runKind ?? "parent", - trigger_region: options.context?.triggerRegion, - failure_stage: "ensure_cloud_sandbox", - duration_ms: Date.now() - startedAt, - error_name: error instanceof Error ? error.name : "UnknownError", - cloud_sandbox_acquisition_failed_event_version: 2, + startedAt, + error, + context: options.context, }); - throw error; } } @@ -75,46 +170,67 @@ export async function terminateCloudSandboxesForUser(userId: string): Promise<{ alreadyGone: number; }> { const totals = { total: 0, killed: 0, alreadyGone: 0 }; + const failures: unknown[] = []; + + if (process.env.MIOSA_API_KEY) { + try { + const result = await terminateMiosaSandboxesForUser(userId); + totals.total += result.total; + totals.killed += result.killed; + totals.alreadyGone += result.alreadyGone; + } catch (error) { + failures.push(error); + console.error("Failed to clean up MIOSA sandboxes:", error); + } + } for (const cluster of getConfiguredE2BClustersForCleanup()) { - const { Sandbox } = await import("@e2b/code-interpreter"); - const paginator = Sandbox.list({ - ...cluster.connectionOptions, - query: { metadata: { userID: userId } }, - }); - const sandboxes = []; - do { - sandboxes.push(...(await paginator.nextItems())); - } while (paginator.hasNext); - let killed = 0; - let alreadyGone = 0; - const { isExpectedMissingResourceCleanupError } = - await import("@/lib/utils/cleanup-errors"); - for (const sandbox of sandboxes) { - try { - if (cluster.connectionOptions) { - await Sandbox.kill(sandbox.sandboxId, cluster.connectionOptions); - } else { - await Sandbox.kill(sandbox.sandboxId); + try { + const { Sandbox } = await import("@e2b/code-interpreter"); + const paginator = Sandbox.list({ + ...cluster.connectionOptions, + query: { metadata: { userID: userId } }, + }); + const sandboxes = []; + do { + sandboxes.push(...(await paginator.nextItems())); + } while (paginator.hasNext); + let killed = 0; + let alreadyGone = 0; + const { isExpectedMissingResourceCleanupError } = + await import("@/lib/utils/cleanup-errors"); + for (const sandbox of sandboxes) { + try { + if (cluster.connectionOptions) { + await Sandbox.kill(sandbox.sandboxId, cluster.connectionOptions); + } else { + await Sandbox.kill(sandbox.sandboxId); + } + killed++; + } catch (error) { + if (isExpectedMissingResourceCleanupError(error)) { + alreadyGone++; + console.debug( + `Sandbox ${sandbox.sandboxId} was already gone during delete`, + error, + ); + continue; + } + console.error(`Failed to kill sandbox ${sandbox.sandboxId}:`, error); + throw error; } - killed++; - } catch (error) { - if (isExpectedMissingResourceCleanupError(error)) { - alreadyGone++; - console.debug( - `Sandbox ${sandbox.sandboxId} was already gone during delete`, - error, - ); - continue; - } - console.error(`Failed to kill sandbox ${sandbox.sandboxId}:`, error); - throw error; } + totals.total += sandboxes.length; + totals.killed += killed; + totals.alreadyGone += alreadyGone; + } catch (error) { + failures.push(error); } - totals.total += sandboxes.length; - totals.killed += killed; - totals.alreadyGone += alreadyGone; } + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) { + throw new AggregateError(failures, "Cloud sandbox cleanup failed"); + } return totals; } diff --git a/lib/ai/tools/utils/hybrid-sandbox-manager.ts b/lib/ai/tools/utils/hybrid-sandbox-manager.ts index d59970d2c..8ca9a319e 100644 --- a/lib/ai/tools/utils/hybrid-sandbox-manager.ts +++ b/lib/ai/tools/utils/hybrid-sandbox-manager.ts @@ -34,6 +34,7 @@ import { type CloudSandboxAcquisitionContext, } from "./cloud-sandbox"; import { getCloudSandboxProvider } from "./cloud-sandbox-provider"; +import type { CloudSandboxProvider } from "./cloud-sandbox-provider"; type SandboxInstance = AnySandbox; @@ -263,6 +264,7 @@ export class HybridSandboxManager implements SandboxManager { private requiredConnectionIdAfterQuarantine: string | null = null; private healthFailureCount = 0; private sandboxUnavailable = false; + private activeCloudProvider: CloudSandboxProvider; constructor( private userID: string, @@ -278,6 +280,8 @@ export class HybridSandboxManager implements SandboxManager { private cloudSandboxContext?: CloudSandboxAcquisitionContext, ) { this.sandbox = initialSandbox || null; + this.activeCloudProvider = + cloudSandboxContext?.provider ?? getCloudSandboxProvider(); } recordHealthFailure(): boolean { @@ -533,8 +537,7 @@ export class HybridSandboxManager implements SandboxManager { if (!this.isLocal) { return { type: "e2b", - provider: - this.cloudSandboxContext?.provider ?? getCloudSandboxProvider(), + provider: this.activeCloudProvider, }; } const type: SandboxType = @@ -555,8 +558,8 @@ export class HybridSandboxManager implements SandboxManager { } async supportsInteractivePty(): Promise { - if (this.sandboxPreference === "e2b") { - return true; + if (!this.isLocal) { + return this.activeCloudProvider === "e2b"; } const connection = await this.getPreferredOrFallbackConnection(); @@ -803,6 +806,7 @@ export class HybridSandboxManager implements SandboxManager { }); this.sandbox = result.sandbox; + this.activeCloudProvider = result.provider; this.isLocal = false; this.currentConnectionId = null; this.currentConnectionName = null; diff --git a/lib/ai/tools/utils/miosa-metrics.ts b/lib/ai/tools/utils/miosa-metrics.ts new file mode 100644 index 000000000..59dfbd826 --- /dev/null +++ b/lib/ai/tools/utils/miosa-metrics.ts @@ -0,0 +1,114 @@ +/** + * MIOSA sandbox resource metrics. + * + * MIOSA's `GET /api/v1/sandboxes/:id/metrics` currently reports the sandbox's + * *configured* shape (cpu_count / memory_mb / disk_size_mb) and an empty time + * series — it does not yet publish live utilisation. Wiring the health check to + * it would return numbers that never move, which is worse than none: the agent + * would read "0% CPU" while the box is pegged. + * + * So sample the guest directly instead. One short exec reads /proc and df, and + * the CPU figure comes from two /proc/stat snapshots 200ms apart, because a + * single snapshot only gives cumulative jiffies since boot, not current load. + * + * Swap this for the API once MIOSA publishes a real series. + */ + +import type { SandboxResourceMetrics } from "@/types"; +import type { MiosaSandbox } from "./miosa-sandbox"; + +/** Gap between the two /proc/stat reads used to derive CPU utilisation. */ +const CPU_SAMPLE_INTERVAL_MS = 200; + +/** Keep the probe well under any caller timeout — metrics must never block a command. */ +const PROBE_TIMEOUT_MS = 5_000; + +const PROBE = ` +a=$(head -n1 /proc/stat) +sleep ${CPU_SAMPLE_INTERVAL_MS / 1000} +b=$(head -n1 /proc/stat) +echo "CPU_A:$a" +echo "CPU_B:$b" +echo "MEM_TOTAL:$(awk '/^MemTotal:/{print $2}' /proc/meminfo)" +echo "MEM_AVAIL:$(awk '/^MemAvailable:/{print $2}' /proc/meminfo)" +df -k / | awk 'NR==2{print "DISK_USED:"$3; print "DISK_TOTAL:"($3+$4)}' +`.trim(); + +function field(lines: string[], prefix: string): string | null { + const hit = lines.find((l) => l.startsWith(prefix)); + return hit ? hit.slice(prefix.length) : null; +} + +/** + * CPU utilisation between two `/proc/stat` cpu lines. + * + * Field 4 is idle and field 5 is iowait; everything else counts as busy. + * Returns 0 when the two samples are identical (an idle box), never NaN. + */ +function cpuPctFromStat(a: string, b: string): number { + const parse = (line: string): number[] => + line + .replace(/^cpu\s+/, "") + .trim() + .split(/\s+/) + .map((n) => Number.parseInt(n, 10) || 0); + + const first = parse(a); + const second = parse(b); + if (first.length < 5 || second.length < 5) return 0; + + const totalOf = (v: number[]) => v.reduce((sum, n) => sum + n, 0); + const idleOf = (v: number[]) => (v[3] ?? 0) + (v[4] ?? 0); + + const totalDelta = totalOf(second) - totalOf(first); + const idleDelta = idleOf(second) - idleOf(first); + if (totalDelta <= 0) return 0; + + const pct = ((totalDelta - idleDelta) / totalDelta) * 100; + return Math.min(100, Math.max(0, pct)); +} + +/** + * Sample live CPU, memory and disk usage from inside a MIOSA sandbox. + * + * Returns `null` — never throws — when the probe fails or the guest returns + * something unparseable. A metrics failure must not fail the health check that + * called it, let alone the command behind it. + */ +export async function sampleMiosaMetrics( + sandbox: MiosaSandbox, +): Promise { + try { + const result = await sandbox.commands.run(PROBE, { + timeoutMs: PROBE_TIMEOUT_MS, + }); + if (result.exitCode !== 0) return null; + + const lines = result.stdout.split("\n").map((l) => l.trim()); + + const cpuA = field(lines, "CPU_A:"); + const cpuB = field(lines, "CPU_B:"); + const memTotalKb = Number(field(lines, "MEM_TOTAL:")); + const memAvailKb = Number(field(lines, "MEM_AVAIL:")); + const diskUsedKb = Number(field(lines, "DISK_USED:")); + const diskTotalKb = Number(field(lines, "DISK_TOTAL:")); + + if (!cpuA || !cpuB) return null; + if (!Number.isFinite(memTotalKb) || memTotalKb <= 0) return null; + + const cpuPct = cpuPctFromStat(cpuA, cpuB); + const memPct = ((memTotalKb - memAvailKb) / memTotalKb) * 100; + const diskPct = + Number.isFinite(diskTotalKb) && diskTotalKb > 0 + ? (diskUsedKb / diskTotalKb) * 100 + : 0; + + return { + cpuPct, + memPct: Math.min(100, Math.max(0, memPct)), + diskPct: Math.min(100, Math.max(0, diskPct)), + }; + } catch { + return null; + } +} diff --git a/lib/ai/tools/utils/miosa-pty-adapter.ts b/lib/ai/tools/utils/miosa-pty-adapter.ts new file mode 100644 index 000000000..da89fe809 --- /dev/null +++ b/lib/ai/tools/utils/miosa-pty-adapter.ts @@ -0,0 +1,194 @@ +/** + * MIOSA PTY adapter. + * + * Creates a PtyHandle backed by MIOSA's sandbox terminal WebSocket, so the + * interactive exec branch in run-terminal-cmd.ts can treat MIOSA sandboxes the + * same way it treats E2B and Centrifugo ones. + * + * Message flow: + * POST /api/v1/sandboxes/:id/terminal → { session_id, ws_url, ... } + * Client ──── binary frames ────▶ PTY stdin + * Client ──── {"type":"resize"} ─▶ PTY resize + * Server ◀─── binary frames ───── PTY stdout/stderr + * DELETE /api/v1/sandboxes/:id/terminal/:session_id → closes the PTY + * + * The upgrade authenticates with `Authorization: Bearer `, the + * same credential the SDK client uses. MIOSA also mints a short-lived + * `stream_auth` for browser clients that cannot set headers; this adapter runs + * server-side and does not need it. + */ + +import WebSocket from "ws"; +import type { CreatePtyOptions, PtyHandle } from "./e2b-pty-adapter"; +import type { MiosaSandbox } from "./miosa-sandbox"; + +const LOG_PREFIX = "[miosa-pty-adapter]"; + +/** How long to wait for the WebSocket upgrade before giving up. */ +const CONNECT_TIMEOUT_MS = 15_000; + +type TerminalSession = { + sessionId: string; + wsUrl: string; +}; + +/** + * MIOSA returns snake_case JSON. The SDK passes it through untyped, so read + * both spellings rather than assuming one — a silent `undefined` here would + * surface much later as an unexplained WebSocket failure. + */ +function readTerminalSession(raw: Record): TerminalSession { + const sessionId = (raw.session_id ?? raw.sessionId) as string | undefined; + const wsUrl = (raw.ws_url ?? raw.wsUrl) as string | undefined; + + if (typeof sessionId !== "string" || sessionId === "") { + throw new Error(`${LOG_PREFIX} terminal create returned no session_id`); + } + if (typeof wsUrl !== "string" || wsUrl === "") { + throw new Error(`${LOG_PREFIX} terminal create returned no ws_url`); + } + + return { sessionId, wsUrl }; +} + +function toUint8Array(data: unknown): Uint8Array { + if (data instanceof Uint8Array) return data; + if (Buffer.isBuffer(data)) return new Uint8Array(data); + if (data instanceof ArrayBuffer) return new Uint8Array(data); + if (Array.isArray(data)) return new Uint8Array(Buffer.concat(data)); + return new TextEncoder().encode(String(data ?? "")); +} + +/** + * Create a PtyHandle for a MIOSA sandbox. + * + * Resolves once the WebSocket is open, so callers can write to the PTY + * immediately without racing the upgrade. + */ +export async function createMiosaPtyHandle( + sandbox: MiosaSandbox, + opts: CreatePtyOptions, +): Promise { + const apiKey = process.env.MIOSA_API_KEY; + if (!apiKey) { + throw new Error(`${LOG_PREFIX} MIOSA_API_KEY is not set`); + } + + const created = (await sandbox.sdkSandbox.terminal.create({ + cols: opts.cols, + rows: opts.rows, + ...(opts.cwd ? { cwd: opts.cwd } : {}), + ...(opts.envs ? { env: opts.envs } : {}), + })) as Record; + + const { sessionId, wsUrl } = readTerminalSession(created); + + const ws = new WebSocket(wsUrl, { + headers: { Authorization: `Bearer ${apiKey}` }, + }); + + const listeners = new Set<(bytes: Uint8Array) => void>(); + let exitResolved = false; + let resolveExited: (value: { exitCode: number | null }) => void = () => {}; + const exited = new Promise<{ exitCode: number | null }>((resolve) => { + resolveExited = resolve; + }); + + // The PTY is gone once the socket closes, however that happens. Settling + // once here means an abnormal close still releases anyone awaiting `exited` + // instead of hanging until the session manager's max-lifetime timer fires. + const settleExited = (exitCode: number | null) => { + if (exitResolved) return; + exitResolved = true; + resolveExited({ exitCode }); + }; + + ws.on("message", (data) => { + const bytes = toUint8Array(data); + for (const cb of listeners) { + try { + cb(bytes); + } catch { + // A throwing consumer must not tear down the stream for the others. + } + } + }); + + ws.on("close", (code) => settleExited(code === 1000 ? 0 : null)); + ws.on("error", () => settleExited(null)); + + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + try { + ws.terminate(); + } catch { + // already gone + } + reject( + new Error( + `${LOG_PREFIX} timed out after ${CONNECT_TIMEOUT_MS}ms connecting to the terminal stream`, + ), + ); + }, CONNECT_TIMEOUT_MS); + + ws.once("open", () => { + clearTimeout(timer); + resolve(); + }); + ws.once("error", (err) => { + clearTimeout(timer); + reject( + err instanceof Error + ? err + : new Error(`${LOG_PREFIX} terminal stream failed to open`), + ); + }); + }); + + const send = (payload: Uint8Array | string): void => { + if (ws.readyState !== WebSocket.OPEN) return; + ws.send(payload); + }; + + return { + // MIOSA addresses the PTY by session id, not by host pid. The session + // manager only uses `pid` as an opaque handle, so a stable non-zero + // placeholder keeps its bookkeeping intact. + get pid() { + return 1; + }, + + async sendInput(bytes: Uint8Array): Promise { + send(bytes); + }, + + async resize(cols: number, rows: number): Promise { + send(JSON.stringify({ type: "resize", cols, rows })); + }, + + async kill(): Promise { + // Delete the server-side session first: closing only the socket would + // leave the PTY running in the sandbox until it idled out. + try { + await sandbox.sdkSandbox.terminal.delete(sessionId); + } catch { + // Best effort — the close below still frees the local side. + } + try { + ws.close(); + } catch { + // already closed + } + settleExited(null); + }, + + onData(cb: (bytes: Uint8Array) => void): () => void { + listeners.add(cb); + return () => { + listeners.delete(cb); + }; + }, + + exited, + }; +} diff --git a/lib/ai/tools/utils/miosa-sandbox.ts b/lib/ai/tools/utils/miosa-sandbox.ts new file mode 100644 index 000000000..3604051fa --- /dev/null +++ b/lib/ai/tools/utils/miosa-sandbox.ts @@ -0,0 +1,346 @@ +import { createHash, randomUUID } from "node:crypto"; +import type { + Miosa as MiosaClient, + Sandbox as MiosaSdkSandbox, +} from "@miosa/sdk"; +import type { SandboxBootInfo, SandboxContext } from "@/types"; + +const MIOSA_SANDBOX_VERSION = "v1"; +const MIOSA_ACTIVITY_TIMEOUT_SECONDS = 24 * 60 * 60; +const MIOSA_IDLE_TIMEOUT_SECONDS = 7 * 60; +const MIOSA_SNAPSHOT_EXPIRATION_DAYS = 30; + +type MiosaCommandOptions = { + cwd?: string; + timeoutMs?: number; + envVars?: Record; + envs?: Record; + background?: boolean; + onStdout?: (data: string) => void; + onStderr?: (data: string) => void; + signal?: AbortSignal; +}; + +type MiosaCommandResult = { + stdout: string; + stderr: string; + exitCode: number; + pid?: number; +}; + +const shellQuote = (value: string): string => + `'${value.replaceAll("'", `'"'"'`)}'`; + +const sandboxNameForUser = (userId: string): string => + `hackerai-${createHash("sha256").update(userId).digest("hex").slice(0, 24)}`; + +const normalizeStreamLine = (line: unknown): string => { + const value = typeof line === "string" ? line : String(line ?? ""); + return value.endsWith("\n") ? value : `${value}\n`; +}; + +const createMiosaClient = async (): Promise => { + const { Miosa } = await import("@miosa/sdk"); + return new Miosa({ + apiKey: process.env.MIOSA_API_KEY, + ...(process.env.MIOSA_BASE_URL && { + baseUrl: process.env.MIOSA_BASE_URL, + }), + }); +}; + +const bootPathFromMiosa = ( + sandbox: MiosaSdkSandbox, +): SandboxBootInfo["path"] => { + const bootPath = sandbox.data.boot_path?.toLowerCase() ?? ""; + if (bootPath.includes("create") || bootPath.includes("provision")) { + return "create_fresh"; + } + return "reuse_existing"; +}; + +/** + * Adapts the MIOSA SDK to the command/file surface used by HackerAI tools. + * Interactive PTY sessions are intentionally not advertised until the public + * SDK exposes an input/resize stream in addition to terminal creation. + */ +export class MiosaSandbox { + readonly sandboxKind = "miosa" as const; + + constructor(readonly sdkSandbox: MiosaSdkSandbox) {} + + get sandboxId(): string { + return this.sdkSandbox.id; + } + + readonly commands = { + run: async ( + command: string, + options: MiosaCommandOptions = {}, + ): Promise => { + if (options.signal?.aborted) { + throw new DOMException("The operation was aborted", "AbortError"); + } + + const sdkOptions = { + cwd: options.cwd ?? "/home/user", + ...((options.envVars || options.envs) && { + env: { ...options.envVars, ...options.envs }, + }), + ...(options.timeoutMs && { + timeoutSec: Math.max(1, Math.ceil(options.timeoutMs / 1000)), + }), + }; + + if (options.background) { + const outputPath = `/tmp/hackerai-background-${randomUUID()}.log`; + const detachedCommand = [ + "nohup bash -lc", + shellQuote(command), + `>${shellQuote(outputPath)} 2>&1 < /dev/null & printf '%s' \"$!\"`, + ].join(" "); + const result = await this.sdkSandbox.exec.run( + detachedCommand, + sdkOptions, + ); + const pid = Number.parseInt(result.stdout.trim(), 10); + return { + stdout: "", + stderr: result.stderr, + exitCode: result.exitCode, + ...(Number.isFinite(pid) ? { pid } : {}), + }; + } + + const stdout: string[] = []; + const stderr: string[] = []; + let exitCode = 0; + const processIdPath = `/tmp/hackerai-foreground-${randomUUID()}.pid`; + const streamedCommand = options.signal + ? `setsid bash -lc ${shellQuote( + `echo $$ > ${shellQuote(processIdPath)}; bash -lc ${shellQuote(command)}; status=$?; rm -f -- ${shellQuote(processIdPath)}; exit $status`, + )}` + : command; + const stream = this.sdkSandbox.exec.stream(streamedCommand, sdkOptions); + + const consumeStream = async (): Promise => { + for await (const event of stream) { + if (event.type === "exit") { + exitCode = Number(event.exitCode ?? event.exit_code ?? 0); + continue; + } + if (event.type === "stderr") { + const chunk = normalizeStreamLine(event.line); + stderr.push(chunk); + options.onStderr?.(chunk); + continue; + } + if (event.type === "stdout" || "line" in event) { + const chunk = normalizeStreamLine(event.line); + stdout.push(chunk); + options.onStdout?.(chunk); + } + } + }; + + const abortError = new DOMException( + "The operation was aborted", + "AbortError", + ); + let abortHandler: (() => void) | undefined; + const abortPromise = options.signal + ? new Promise((_, reject) => { + let abortStarted = false; + abortHandler = () => { + if (abortStarted) return; + abortStarted = true; + void this.sdkSandbox.exec + .run( + `for i in $(seq 1 40); do if [ -f ${shellQuote(processIdPath)} ]; then pid=$(cat ${shellQuote(processIdPath)}); kill -TERM -- -"$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true; sleep 0.2; kill -KILL -- -"$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true; rm -f -- ${shellQuote(processIdPath)}; break; fi; sleep 0.05; done`, + { timeoutSec: 5 }, + ) + .finally(() => { + void stream.return?.().catch(() => undefined); + reject(abortError); + }); + }; + options.signal!.addEventListener("abort", abortHandler, { + once: true, + }); + if (options.signal!.aborted) abortHandler(); + }) + : null; + + try { + if (abortPromise) { + await Promise.race([consumeStream(), abortPromise]); + } else { + await consumeStream(); + } + } finally { + if (abortHandler) { + options.signal?.removeEventListener("abort", abortHandler); + } + } + + return { + stdout: stdout.join("").replace(/\n$/, ""), + stderr: stderr.join("").replace(/\n$/, ""), + exitCode, + }; + }, + kill: async (pid: number): Promise => { + const result = await this.sdkSandbox.exec.run(`kill -9 ${pid}`); + return result.exitCode === 0; + }, + }; + + readonly files = { + write: async ( + path: string, + content: string | Buffer | ArrayBuffer, + ): Promise => { + const normalized = + content instanceof ArrayBuffer ? new Uint8Array(content) : content; + await this.sdkSandbox.files.write(path, normalized); + }, + read: (path: string): Promise => + this.sdkSandbox.files.readText(path), + readText: async (path: string): Promise => + this.sdkSandbox.files.readText(path), + list: async ( + path: string, + ): Promise> => + (await this.sdkSandbox.files.list(path)).entries, + stat: (path: string) => this.sdkSandbox.files.stat(path), + exists: async (path: string): Promise => { + try { + await this.sdkSandbox.files.stat(path); + return true; + } catch { + return false; + } + }, + getInfo: async (path: string) => { + const info = await this.sdkSandbox.files.stat(path); + return { + type: (info.isDir ?? info.is_dir) ? "dir" : "file", + size: info.size, + symlinkTarget: undefined, + modifiedTime: info.modifiedAt + ? new Date(info.modifiedAt) + : info.modified_at + ? new Date(info.modified_at) + : undefined, + }; + }, + remove: async (path: string): Promise => { + const result = await this.sdkSandbox.exec.run( + `rm -rf -- ${shellQuote(path)}`, + ); + if (result.exitCode !== 0) { + throw new Error(result.stderr || `Failed to remove ${path}`); + } + }, + }; + + async setTimeout(timeoutMs: number): Promise { + await this.sdkSandbox.extend(Math.max(1, Math.ceil(timeoutMs / 1000))); + } + + async isRunning(): Promise { + await this.sdkSandbox.refresh(); + return this.sdkSandbox.state === "running"; + } + + async getHost(port: number): Promise { + return this.sdkSandbox.getHost(port); + } + + async close(): Promise { + // Dropping the SDK object must not destroy the persistent MIOSA workspace. + } +} + +export async function ensureMiosaSandboxConnection( + context: SandboxContext, + options: { initialSandbox?: MiosaSandbox | null } = {}, +): Promise<{ sandbox: MiosaSandbox }> { + if (options.initialSandbox) { + return { sandbox: options.initialSandbox }; + } + + const templateId = process.env.MIOSA_TEMPLATE_ID?.trim(); + if (!templateId) { + throw new Error( + "MIOSA_TEMPLATE_ID must identify the promoted HackerAI sandbox template", + ); + } + + const startedAt = performance.now(); + const client = await createMiosaClient(); + const externalUserId = sandboxNameForUser(context.userID); + const sdkSandbox = await client.sandboxes.getOrCreate({ + name: externalUserId, + templateId, + persistent: true, + timeoutSec: MIOSA_ACTIVITY_TIMEOUT_SECONDS, + idleTimeoutSec: MIOSA_IDLE_TIMEOUT_SECONDS, + snapshotExpirationDays: MIOSA_SNAPSHOT_EXPIRATION_DAYS, + keepLastSnapshots: 1, + externalWorkspaceId: externalUserId, + externalUserId, + waitUntilReady: true, + waitTimeoutSec: 60, + metadata: { + provider: "hackerai", + sandboxVersion: MIOSA_SANDBOX_VERSION, + }, + }); + const initialization = await sdkSandbox.exec.run( + "mkdir -p /home/user/upload /home/user/agent-transcripts /home/user/terminal_full_output /home/user/agent-browser-screenshots", + { timeoutSec: 10 }, + ); + if (initialization.exitCode !== 0) { + throw new Error( + initialization.stderr || "Failed to initialize MIOSA sandbox paths", + ); + } + const sandbox = new MiosaSandbox(sdkSandbox); + context.setSandbox(sandbox); + context.onBoot?.({ + path: bootPathFromMiosa(sdkSandbox), + duration_ms: Math.round(performance.now() - startedAt), + create_attempts: bootPathFromMiosa(sdkSandbox) === "create_fresh" ? 1 : 0, + image_version: templateId, + }); + return { sandbox }; +} + +export async function terminateMiosaSandboxesForUser( + userId: string, +): Promise<{ total: number; killed: number; alreadyGone: number }> { + const client = await createMiosaClient(); + const sandboxes = await client.sandboxes.list({ + externalUserId: sandboxNameForUser(userId), + }); + let killed = 0; + let alreadyGone = 0; + for (const sandbox of sandboxes) { + try { + await sandbox.destroy(); + killed += 1; + } catch (error) { + const status = + error && typeof error === "object" && "status" in error + ? (error as { status?: unknown }).status + : undefined; + if (status === 404 || sandbox.state === "destroyed") { + alreadyGone += 1; + continue; + } + throw error; + } + } + return { total: sandboxes.length, killed, alreadyGone }; +} diff --git a/lib/ai/tools/utils/sandbox-fallback.ts b/lib/ai/tools/utils/sandbox-fallback.ts index 2ba54c7ed..602f8a78a 100644 --- a/lib/ai/tools/utils/sandbox-fallback.ts +++ b/lib/ai/tools/utils/sandbox-fallback.ts @@ -6,7 +6,7 @@ import { } from "@/types"; import { ChatSDKError } from "@/lib/errors"; import type { SandboxFallbackInfo } from "./hybrid-sandbox-manager"; -import { isCentrifugoSandbox } from "./sandbox-types"; +import { isCentrifugoSandbox, isMiosaSandbox } from "./sandbox-types"; type SandboxContextForPromptManager = { getSandboxInfo?: () => unknown; @@ -46,6 +46,7 @@ const APPROVED_SANDBOX_CHANGED_MESSAGE = export function getAgentApprovalSandboxIdentity( sandbox: AnySandbox, ): AgentApprovalSandboxIdentity { + if (isMiosaSandbox(sandbox)) return "miosa"; return isCentrifugoSandbox(sandbox) ? getAgentApprovalConnectionSandboxIdentity(sandbox.getConnectionId()) : "e2b"; diff --git a/lib/ai/tools/utils/sandbox-file-uploader.ts b/lib/ai/tools/utils/sandbox-file-uploader.ts index e8a744ade..7a72028b8 100644 --- a/lib/ai/tools/utils/sandbox-file-uploader.ts +++ b/lib/ai/tools/utils/sandbox-file-uploader.ts @@ -4,7 +4,11 @@ import { ConvexError } from "convex/values"; import { api } from "@/convex/_generated/api"; import { Id } from "@/convex/_generated/dataModel"; import type { AnySandbox } from "@/types"; -import { isCentrifugoSandbox, isE2BSandbox } from "./sandbox-types"; +import { + getCloudSandboxProviderForInstance, + isCentrifugoSandbox, + isE2BSandbox, +} from "./sandbox-types"; import { buildSandboxCommandOptions } from "./sandbox-command-options"; import { generateS3UploadUrl } from "@/convex/s3Utils"; import { getConvexClient } from "@/lib/db/convex-client"; @@ -220,8 +224,10 @@ function assertSandboxFileSizeAllowed(fileName: string, size: number): void { ); } -function getSandboxLogType(sandbox: AnySandbox): "e2b" | "centrifugo" { - return isE2BSandbox(sandbox) ? "e2b" : "centrifugo"; +function getSandboxLogType( + sandbox: AnySandbox, +): "miosa" | "e2b" | "centrifugo" { + return getCloudSandboxProviderForInstance(sandbox) ?? "centrifugo"; } function errorToLog(error: unknown) { @@ -320,7 +326,11 @@ async function uploadGeneratedFileFromSandboxToUrl(args: { const { sandbox, fullPath, uploadUrl, mediaType } = args; const fileName = getFileNameFromPath(fullPath); - if (!isE2BSandbox(sandbox) && sandbox.files?.uploadToUrl) { + if ( + !isE2BSandbox(sandbox) && + "uploadToUrl" in sandbox.files && + typeof sandbox.files.uploadToUrl === "function" + ) { try { await sandbox.files.uploadToUrl(fullPath, uploadUrl, mediaType); return; diff --git a/lib/ai/tools/utils/sandbox-health.ts b/lib/ai/tools/utils/sandbox-health.ts index 118961932..c10429d36 100644 --- a/lib/ai/tools/utils/sandbox-health.ts +++ b/lib/ai/tools/utils/sandbox-health.ts @@ -11,7 +11,8 @@ import type { TerminalTimeoutRecoveryOutcome, } from "@/types"; import { createRetryLogger } from "@/lib/posthog/worker"; -import { isE2BSandbox } from "./sandbox-types"; +import { isE2BSandbox, isMiosaSandbox } from "./sandbox-types"; +import { sampleMiosaMetrics } from "./miosa-metrics"; import { retryWithBackoff } from "./retry-with-backoff"; import { AuthenticationError, @@ -31,6 +32,21 @@ const CPU_WARNING_THRESHOLD = 95; // percentage const MEM_WARNING_THRESHOLD = 90; // percentage const FAILURE_METRICS_TIMEOUT_MS = 1000; +/** + * Human-readable warning for a resource sample, or null when nothing is hot. + * Shared so every provider reports pressure with identical wording. + */ +function buildResourceWarning(metrics: SandboxResourceMetrics): string | null { + const warnings: string[] = []; + if (metrics.cpuPct > CPU_WARNING_THRESHOLD) { + warnings.push(`CPU at ${metrics.cpuPct.toFixed(0)}%`); + } + if (metrics.memPct > MEM_WARNING_THRESHOLD) { + warnings.push(`Memory at ${metrics.memPct.toFixed(0)}%`); + } + return warnings.length > 0 ? warnings.join(", ") : null; +} + /** * Check sandbox resource metrics and return a diagnostic summary. * Returns null if metrics are unavailable (non-E2B sandbox or API error). @@ -38,6 +54,15 @@ const FAILURE_METRICS_TIMEOUT_MS = 1000; async function checkSandboxMetrics( sandbox: AnySandbox, ): Promise<(SandboxResourceMetrics & { warning: string | null }) | null> { + // MIOSA has no live-utilisation series yet, so sample the guest directly. + // Without this branch the agent runs blind on MIOSA: no CPU or memory + // warning ever fires, and a wedged sandbox looks identical to a healthy one. + if (isMiosaSandbox(sandbox)) { + const sample = await sampleMiosaMetrics(sandbox); + if (!sample) return null; + return { ...sample, warning: buildResourceWarning(sample) }; + } + if (!isE2BSandbox(sandbox)) return null; try { diff --git a/lib/ai/tools/utils/sandbox-manager.ts b/lib/ai/tools/utils/sandbox-manager.ts index 7e62e053b..b741eb65f 100644 --- a/lib/ai/tools/utils/sandbox-manager.ts +++ b/lib/ai/tools/utils/sandbox-manager.ts @@ -5,6 +5,7 @@ import type { SandboxManager, SandboxType, } from "@/types"; +import type { CloudSandboxProvider } from "./cloud-sandbox-provider"; import { refreshE2BSandboxLeaseBestEffort } from "./sandbox"; import { SANDBOX_ENVIRONMENT_TOOLS } from "./sandbox-tools"; import { @@ -12,7 +13,7 @@ import { type CloudSandboxAcquisitionContext, } from "./cloud-sandbox"; import { getCloudSandboxProvider } from "./cloud-sandbox-provider"; -import { isE2BSandbox } from "./sandbox-types"; +import { isCentrifugoSandbox, isE2BSandbox } from "./sandbox-types"; import { isExpectedAlreadyGoneCleanupError } from "@/lib/utils/cleanup-errors"; // One failed initial readiness check plus one failed reconnect is enough to @@ -24,6 +25,7 @@ export class DefaultSandboxManager implements SandboxManager { private sandbox: AnySandbox | null = null; private healthFailureCount = 0; private sandboxUnavailable = false; + private activeCloudProvider: CloudSandboxProvider; constructor( private userID: string, @@ -33,6 +35,8 @@ export class DefaultSandboxManager implements SandboxManager { private cloudSandboxContext?: CloudSandboxAcquisitionContext, ) { this.sandbox = initialSandbox || null; + this.activeCloudProvider = + cloudSandboxContext?.provider ?? getCloudSandboxProvider(); } recordHealthFailure(): boolean { @@ -55,7 +59,7 @@ export class DefaultSandboxManager implements SandboxManager { getSandboxInfo(): SandboxInfo | null { return { type: "e2b", - provider: this.cloudSandboxContext?.provider ?? getCloudSandboxProvider(), + provider: this.activeCloudProvider, }; } @@ -90,6 +94,7 @@ export class DefaultSandboxManager implements SandboxManager { context: this.cloudSandboxContext, }); this.sandbox = result.sandbox; + this.activeCloudProvider = result.provider; if (!this.sandbox) { throw new Error("Failed to initialize sandbox"); @@ -109,7 +114,7 @@ export class DefaultSandboxManager implements SandboxManager { // underlying sandbox continues running. const sandbox = this.sandbox; this.sandbox = null; - if (sandbox && !isE2BSandbox(sandbox)) { + if (sandbox && isCentrifugoSandbox(sandbox)) { await sandbox.close().catch((error) => { if (isExpectedAlreadyGoneCleanupError(error)) { console.debug(`[${this.userID}] Sandbox relay was already closed`); @@ -122,4 +127,8 @@ export class DefaultSandboxManager implements SandboxManager { }); } } + + async supportsInteractivePty(): Promise { + return this.activeCloudProvider === "e2b"; + } } diff --git a/lib/ai/tools/utils/sandbox-types.ts b/lib/ai/tools/utils/sandbox-types.ts index 01d0b8bb4..71a68b7e2 100644 --- a/lib/ai/tools/utils/sandbox-types.ts +++ b/lib/ai/tools/utils/sandbox-types.ts @@ -1,6 +1,8 @@ import type { Sandbox } from "@e2b/code-interpreter"; import type { CentrifugoSandbox } from "./centrifugo-sandbox"; +import type { MiosaSandbox } from "./miosa-sandbox"; import type { AnySandbox } from "@/types"; +import type { CloudSandboxProvider } from "./cloud-sandbox-provider"; export interface OsInfo { platform: string; @@ -45,7 +47,34 @@ export function isCentrifugoSandbox( export function isE2BSandbox(sandbox: AnySandbox | null): sandbox is Sandbox { if (sandbox === null) return false; if (isCentrifugoSandbox(sandbox)) return false; - return true; // any non-Centrifugo sandbox is E2B + if (isMiosaSandbox(sandbox)) return false; + return true; +} + +/** Type guard for the HackerAI MIOSA SDK adapter. */ +export function isMiosaSandbox( + sandbox: AnySandbox | null, +): sandbox is MiosaSandbox { + return ( + sandbox !== null && + "sandboxKind" in sandbox && + (sandbox as { sandboxKind?: unknown }).sandboxKind === "miosa" + ); +} + +/** Any remotely hosted cloud sandbox, regardless of provider SDK. */ +export function isCloudSandbox( + sandbox: AnySandbox | null, +): sandbox is Sandbox | MiosaSandbox { + return isE2BSandbox(sandbox) || isMiosaSandbox(sandbox); +} + +export function getCloudSandboxProviderForInstance( + sandbox: AnySandbox | null, +): CloudSandboxProvider | null { + if (isMiosaSandbox(sandbox)) return "miosa"; + if (isE2BSandbox(sandbox)) return "e2b"; + return null; } /** @@ -72,7 +101,7 @@ export interface CommonSandboxInterface { remove: (path: string) => Promise; list: (path: string) => Promise<{ name: string }[]>; }; - getHost: (port: number) => string; + getHost: (port: number) => string | Promise; close: () => Promise; } diff --git a/lib/ai/tools/utils/terminal-output-saver.ts b/lib/ai/tools/utils/terminal-output-saver.ts index e436541cc..2b0055853 100644 --- a/lib/ai/tools/utils/terminal-output-saver.ts +++ b/lib/ai/tools/utils/terminal-output-saver.ts @@ -5,14 +5,16 @@ import { phLogger } from "@/lib/posthog/server"; import { FULL_OUTPUT_SAVED_MESSAGE } from "@/lib/token-utils"; import { asCommonSandbox, + isCloudSandbox, isCentrifugoSandbox, - isE2BSandbox, + isMiosaSandbox, } from "./sandbox-types"; export const MAX_SAVED_TERMINAL_OUTPUT_FILES = 10; const DESKTOP_RELAY_RETRY_DELAY_MS = 250; -type TerminalOutputPersistenceProvider = "e2b" | "desktop" | "centrifugo"; +type TerminalOutputPersistenceProvider = + "miosa" | "e2b" | "desktop" | "centrifugo"; type TerminalOutputPersistenceFailureCategory = | "timeout" | "transport" @@ -42,6 +44,7 @@ const getPersistenceProvider = ( } return "centrifugo"; } + if (isMiosaSandbox(sandbox)) return "miosa"; return "e2b"; }; @@ -129,7 +132,7 @@ const emitPersistenceFailure = (args: { /** Builds a stable, non-identifying output directory for one chat scope. */ const getOutputDirectory = (sandbox: AnySandbox, scopeId?: string): string => { - const baseDirectory = isE2BSandbox(sandbox) + const baseDirectory = isCloudSandbox(sandbox) ? "/home/user/terminal_full_output" : "/tmp/terminal_full_output"; const scopeKey = scopeId diff --git a/lib/api/__tests__/agent-long-contracts.test.ts b/lib/api/__tests__/agent-long-contracts.test.ts index 9823eaddc..3daa1637c 100644 --- a/lib/api/__tests__/agent-long-contracts.test.ts +++ b/lib/api/__tests__/agent-long-contracts.test.ts @@ -1803,7 +1803,7 @@ describe("agent-long task — Trigger.dev dashboard error visibility", () => { stepIndexArgIdx, ); const sandboxCostIdx = agentStreamRunnerSrc.indexOf( - "ctx.getSandboxCostDollars?.() ?? 0", + "await ctx.getSandboxCostDollars?.()", upstreamCostArgIdx, ); const triggerRunCostIdx = agentStreamRunnerSrc.indexOf( @@ -2210,19 +2210,26 @@ describe("agent-long task — Trigger.dev dashboard error visibility", () => { expect(taskSrc).toMatch(/recordFreeMonthlyCost\(\s*freeUsageSubject/); }); - test("agent-long uses E2B for tools and prompt", () => { - const providerIdx = taskSrc.indexOf( - 'const cloudSandboxProvider = "e2b" as const;', - ); + test("agent-long uses the selected cloud provider for tools and prompt", () => { + const providerIdx = taskSrc.indexOf("const cloudSandboxSelection ="); const toolsIdx = taskSrc.indexOf("createTools(", providerIdx); const promptIdx = taskSrc.indexOf("systemPrompt(", toolsIdx); expect(providerIdx).toBeGreaterThan(-1); + expect(taskSrc.slice(providerIdx, toolsIdx)).toContain( + "selectCloudSandboxProvider({", + ); + expect(taskSrc.slice(providerIdx, toolsIdx)).toContain( + "environment: ctx.environment.type", + ); expect(toolsIdx).toBeGreaterThan(providerIdx); expect(promptIdx).toBeGreaterThan(toolsIdx); expect(taskSrc.slice(toolsIdx, promptIdx)).toContain( "cloudSandboxProvider", ); + expect(taskSrc.slice(toolsIdx, promptIdx)).toContain( + "cloudSandboxSelectionReason: cloudSandboxSelection.reason", + ); expect(taskSrc.slice(promptIdx, promptIdx + 700)).toContain( "cloudSandboxProvider", ); diff --git a/lib/api/__tests__/chat-logger.test.ts b/lib/api/__tests__/chat-logger.test.ts index 50d97d375..1046e97ac 100644 --- a/lib/api/__tests__/chat-logger.test.ts +++ b/lib/api/__tests__/chat-logger.test.ts @@ -677,6 +677,8 @@ describe("captureUsageCost", () => { }, sandboxUsage: { totalCostDollars: 0.12, + miosaRuntimeMs: 2_000, + miosaCostDollars: 0.1, e2bRuntimeMs: 1_000, e2bCostDollars: 0.02, }, @@ -728,8 +730,10 @@ describe("captureUsageCost", () => { model_cost_dollars: 0.3, non_model_cost_dollars: 0.12, sandbox_cost_accounting_version: 1, - sandbox_cost_source: "configured_baseline_estimate", + sandbox_cost_source: "provider_specific", sandbox_cost_dollars: 0.12, + sandbox_miosa_runtime_ms: 2_000, + sandbox_miosa_cost_dollars: 0.1, sandbox_e2b_runtime_ms: 1_000, sandbox_e2b_cost_dollars: 0.02, trigger_run_cost_accounting_version: 1, diff --git a/lib/api/agent-stream-runner.ts b/lib/api/agent-stream-runner.ts index 6f5a42f25..821c1877b 100644 --- a/lib/api/agent-stream-runner.ts +++ b/lib/api/agent-stream-runner.ts @@ -667,7 +667,7 @@ export type AgentStreamContext = { retention: ProviderRequestRetentionDiagnostics, ) => void; /** Current cumulative runtime cost outside UsageTracker, such as a sandbox. */ - getSandboxCostDollars?: () => number; + getSandboxCostDollars?: () => number | Promise; /** Current cumulative Trigger.dev run cost, including compute and invocation. */ getTriggerRunCostDollars?: () => number; settleUsageAfterStep?: (args: { @@ -1743,7 +1743,7 @@ export async function createAgentStream( stepOpenRouterMetadata.openrouter_upstream_inference_cost, ); - const sandboxCostDollars = ctx.getSandboxCostDollars?.() ?? 0; + const sandboxCostDollars = (await ctx.getSandboxCostDollars?.()) ?? 0; const triggerRunCostDollars = ctx.getTriggerRunCostDollars?.() ?? 0; const currentCostDollars = ctx.usageTracker.computeCostDollars(modelName) + diff --git a/lib/api/chat-handler.ts b/lib/api/chat-handler.ts index 42872e2bc..6e3fd31df 100644 --- a/lib/api/chat-handler.ts +++ b/lib/api/chat-handler.ts @@ -62,6 +62,7 @@ import { } from "@/lib/token-utils"; import { ChatSDKError } from "@/lib/errors"; import PostHogClient from "@/app/posthog"; +import { selectCloudSandboxProvider } from "@/lib/ai/tools/utils/cloud-sandbox-provider"; import { captureAgentBudgetAbort, captureAgentCompletionAnalytics, @@ -483,6 +484,17 @@ export const createChatHandler = () => { // PostHog client for analytics. posthog ??= PostHogClient(); + const cloudSandboxSelection = + isAgentMode(mode) && (!sandboxPreference || sandboxPreference === "e2b") + ? await selectCloudSandboxProvider({ + userId, + environment: process.env.VERCEL_ENV ?? "development", + featureFlagClient: posthog, + }) + : ({ + provider: "e2b", + reason: "miosa_rollout_control", + } as const); const fileCounts = countFileAttachments(truncatedMessages); const chatLogContext = { @@ -750,6 +762,10 @@ export const createChatHandler = () => { projectContext.workingDirectory, undefined, auxiliaryVision, + { + cloudSandboxProvider: cloudSandboxSelection.provider, + cloudSandboxSelectionReason: cloudSandboxSelection.reason, + }, ); // Helper to send file metadata via stream for resumable stream clients @@ -905,6 +921,9 @@ export const createChatHandler = () => { selectedModel, userCustomization, sandboxContext, + "full_access", + false, + cloudSandboxSelection.provider, ); const systemPromptTokens = safeCountTokens(currentSystemPrompt); @@ -1031,7 +1050,7 @@ export const createChatHandler = () => { // Wait for it so its provider cost is included exactly once. await titlePromise; // Add cloud sandbox session cost (duration-based). - const sandboxUsage = getSandboxSessionUsage(); + const sandboxUsage = await getSandboxSessionUsage(); const sandboxCost = sandboxUsage.totalCostDollars; if (sandboxCost > 0) { usageTracker.providerCost += sandboxCost; diff --git a/lib/api/chat-logger.ts b/lib/api/chat-logger.ts index 7fb2a8e27..1ebe9f767 100644 --- a/lib/api/chat-logger.ts +++ b/lib/api/chat-logger.ts @@ -1740,8 +1740,10 @@ export function captureUsageCost({ non_model_cost_dollars: usage.nonModelCostDollars, ...(sandboxUsage && { sandbox_cost_accounting_version: 1, - sandbox_cost_source: "configured_baseline_estimate", + sandbox_cost_source: "provider_specific", sandbox_cost_dollars: sandboxUsage.totalCostDollars, + sandbox_miosa_runtime_ms: sandboxUsage.miosaRuntimeMs, + sandbox_miosa_cost_dollars: sandboxUsage.miosaCostDollars, sandbox_e2b_runtime_ms: sandboxUsage.e2bRuntimeMs, sandbox_e2b_cost_dollars: sandboxUsage.e2bCostDollars, }), diff --git a/lib/chat/agent-auto-review-evidence.ts b/lib/chat/agent-auto-review-evidence.ts index 0c3aedfa1..a816b7f71 100644 --- a/lib/chat/agent-auto-review-evidence.ts +++ b/lib/chat/agent-auto-review-evidence.ts @@ -8,7 +8,7 @@ import type { } from "@/types"; import { isCentrifugoSandbox, - isE2BSandbox, + isCloudSandbox, } from "@/lib/ai/tools/utils/sandbox-types"; const MAX_DELETE_TARGETS = 8; @@ -344,7 +344,7 @@ const inspectPath = async ({ let modifiedTime: string | undefined; let symlinkTarget: string | undefined; - if (isE2BSandbox(sandbox)) { + if (isCloudSandbox(sandbox)) { const files = sandbox.files as typeof sandbox.files & { exists?: (candidate: string) => Promise; getInfo?: (candidate: string) => Promise<{ @@ -576,7 +576,7 @@ const readBoundedText = async ({ } return result.content ?? ""; } - if (isE2BSandbox(sandbox)) { + if (isCloudSandbox(sandbox)) { const files = sandbox.files as typeof sandbox.files & { exists?: (candidate: string) => Promise; getInfo?: (candidate: string) => Promise<{ diff --git a/lib/chat/summarization/index.ts b/lib/chat/summarization/index.ts index 7e738ce84..d7d23e0f5 100644 --- a/lib/chat/summarization/index.ts +++ b/lib/chat/summarization/index.ts @@ -15,7 +15,7 @@ import { writeSummarizationStarted, writeSummarizationCompleted, } from "@/lib/utils/stream-writer-utils"; -import { isE2BSandbox } from "@/lib/ai/tools/utils/sandbox-types"; +import { isCloudSandbox } from "@/lib/ai/tools/utils/sandbox-types"; import type { Id } from "@/convex/_generated/dataModel"; import { KIMI_K3_SLUG, myProvider } from "@/lib/ai/providers"; import type { ProviderPromptPressure } from "./provider-pressure"; @@ -707,7 +707,7 @@ const saveTranscriptToSandbox = async ( for (let attempt = 0; attempt <= maxRetries; attempt++) { try { const transcriptId = uuidv4(); - const dir = isE2BSandbox(sandbox) + const dir = isCloudSandbox(sandbox) ? "/home/user/agent-transcripts" : "/tmp/agent-transcripts"; const path = `${dir}/${transcriptId}.json`; @@ -715,14 +715,14 @@ const saveTranscriptToSandbox = async ( // E2B needs an explicit mkdir since its files.write doesn't create parents. // CentrifugoSandbox's files.write already calls ensureDirectory internally // with proper Windows path/shell handling, so skip the raw mkdir for it. - if (isE2BSandbox(sandbox)) { + if (isCloudSandbox(sandbox)) { await sandbox.commands.run(`mkdir -p ${dir}`, { timeoutMs: 5000 }); } // Save as structured JSON — model messages (mid-stream, with separate // tool-call/tool-result parts) when available, otherwise UI messages const content = JSON.stringify(modelMessages ?? messages, null, 2); - if (isE2BSandbox(sandbox)) { + if (isCloudSandbox(sandbox)) { // E2B uploads via HTTP — no shell argument limits, string is fine await sandbox.files.write(path, content); } else { diff --git a/lib/logger.ts b/lib/logger.ts index 5402abc84..d4b71a840 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -159,7 +159,7 @@ export interface ChatWideEvent { sandbox?: { type: "e2b" | "desktop" | "remote-connection"; name?: string; - provider?: "e2b"; + provider?: "miosa" | "e2b"; }; // Sandbox boot timing — fires once per request, only when actual work is done diff --git a/lib/posthog/__tests__/server.test.ts b/lib/posthog/__tests__/server.test.ts index 313fde001..0de23d4f8 100644 --- a/lib/posthog/__tests__/server.test.ts +++ b/lib/posthog/__tests__/server.test.ts @@ -2,11 +2,14 @@ import { beforeEach, describe, expect, it, jest } from "@jest/globals"; const mockCapture = jest.fn(); const mockCaptureException = jest.fn(); +const mockGetFlag = jest.fn(); const mockGetFeatureFlag = jest.fn(); +const mockEvaluateFlags = jest.fn(); const mockPostHogClient = jest.fn(() => ({ capture: mockCapture, captureException: mockCaptureException, getFeatureFlag: mockGetFeatureFlag, + evaluateFlags: mockEvaluateFlags, })); const mockEmitPostHogLog = jest.fn(() => true); @@ -31,22 +34,25 @@ describe("phLogger", () => { beforeEach(() => { mockCapture.mockClear(); mockCaptureException.mockClear(); + mockGetFlag.mockReset(); mockGetFeatureFlag.mockReset(); + mockEvaluateFlags.mockReset(); mockPostHogClient.mockClear(); mockEmitPostHogLog.mockClear(); }); it("evaluates boolean flags for the authenticated distinct id and fails closed", async () => { - mockGetFeatureFlag.mockResolvedValueOnce(true); + mockGetFlag.mockReturnValueOnce(true); + mockEvaluateFlags.mockResolvedValueOnce({ getFlag: mockGetFlag }); await expect( getPostHogFeatureFlagForUser("agent-subagents", "user_123"), ).resolves.toBe(true); - expect(mockGetFeatureFlag).toHaveBeenCalledWith( - "agent-subagents", - "user_123", - ); + expect(mockEvaluateFlags).toHaveBeenCalledWith("user_123", { + flagKeys: ["agent-subagents"], + }); + expect(mockGetFlag).toHaveBeenCalledWith("agent-subagents"); - mockGetFeatureFlag.mockRejectedValueOnce(new Error("unavailable")); + mockEvaluateFlags.mockRejectedValueOnce(new Error("unavailable")); await expect( getPostHogFeatureFlagForUser("agent-subagents", "user_123"), ).resolves.toBe(false); diff --git a/lib/posthog/server.ts b/lib/posthog/server.ts index 3c57a6dbf..6ee221037 100644 --- a/lib/posthog/server.ts +++ b/lib/posthog/server.ts @@ -16,7 +16,16 @@ export async function getPostHogFeatureFlagForUser( flagKey: string, userId: string, ): Promise { - return (await getPostHogFeatureFlagValueForUser(flagKey, userId)) === true; + const client = getClient(); + if (!client) return false; + try { + const flags = await client.evaluateFlags(userId, { + flagKeys: [flagKey], + }); + return flags.getFlag(flagKey) === true; + } catch { + return false; + } } export async function getPostHogFeatureFlagValueForUser( diff --git a/lib/system-prompt.ts b/lib/system-prompt.ts index 8f13ba4db..1c55dfb90 100644 --- a/lib/system-prompt.ts +++ b/lib/system-prompt.ts @@ -216,15 +216,38 @@ Setup instructions: https://help.hackerai.co/en/articles/12961920-connecting-a-h `; const getDefaultSandboxEnvironmentSection = ( - _provider: CloudSandboxProvider = getCloudSandboxProvider(), + provider: CloudSandboxProvider = getCloudSandboxProvider(), ): string => { const portScanningSection = `Port-scanning limitation: - Cloud Agent networking can produce false-positive TCP port results where many or all ports appear open. This can affect naabu, nmap TCP connect scans, nc, and other tools that rely on successful outbound connections; changing scanner flags may not fix the underlying network behavior. - Treat implausible Cloud Agent port-scan output as invalid or unverified. Do not keep retrying broad scans, claim the ports are confirmed open, or blame the scanning tool when the environment is the likely cause. - When the user needs reliable port scanning or normal TCP, UDP, or raw-socket behavior, explain this Cloud Agent limitation and recommend selecting the HackerAI Desktop App or a Remote Control connection as the execution environment so the tools use that machine's native network stack.`; - const systemEnvironment = `- OS: Debian GNU/Linux 12 linux/amd64 (with internet access) + const systemEnvironment = + provider === "miosa" + ? `- OS: isolated Linux sandbox (with internet access) +- Compute: provider-assigned. Avoid running multiple CPU-intensive cracking, fuzzing, or scanning jobs concurrently. +- User: privileged sandbox user` + : `- OS: Debian GNU/Linux 12 linux/amd64 (with internet access) - Compute: 4 vCPU, 4 GiB RAM. Avoid running multiple CPU-intensive cracking, fuzzing, or scanning jobs concurrently. - User: \`root\` (with sudo privileges)`; + const installedTools = + provider === "miosa" + ? `Tool availability: +- The MIOSA template provides general Python and Node command execution. Do not assume a security CLI or browser package is installed; probe with \`command -v \` before relying on it. +- Install a missing package only when it is necessary for the user's task and safe to install in the isolated sandbox.` + : `${PREINSTALLED_PENTESTING_TOOLS} + +${SANDBOX_TOOL_RECIPES_SECTION} + +${AGENT_BROWSER_SECTION}`; + const developmentEnvironment = + provider === "miosa" + ? `Development Environment: +- Probe runtime and package versions before relying on them; the configured MIOSA template can vary.` + : `Development Environment: +- Python 3.12.11 (commands: python3, pip3) +- Node.js 20.19.4 (commands: node, npm) +- Golang 1.24.2 (commands: go)`; return ` IMPORTANT: All tools operate in an isolated sandbox environment that is individual to each user. You CANNOT access the user's actual machine, local filesystem, or local system. Tools can ONLY interact with the sandbox environment described below. @@ -244,16 +267,9 @@ ${systemEnvironment} - Inline image attachments are already visible in the conversation. If an \`inline_image_attachment\` also lists a sandbox path, use that path only for file-system operations such as metadata extraction, conversion, or scripting; do not call the file view action just to describe the image. - VPN connectivity is not available due to missing TUN/TAP device support in the sandbox environment -Development Environment: -- Python 3.12.11 (commands: python3, pip3) -- Node.js 20.19.4 (commands: node, npm) -- Golang 1.24.2 (commands: go) - -${PREINSTALLED_PENTESTING_TOOLS} - -${SANDBOX_TOOL_RECIPES_SECTION} +${developmentEnvironment} -${AGENT_BROWSER_SECTION} +${installedTools} `; }; diff --git a/package.json b/package.json index addfa6666..0fdfd232c 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "@e2b/code-interpreter": "2.7.1", "@langchain/community": "^1.1.29", "@legendapp/list": "3.3.8", + "@miosa/sdk": "3.0.1", "@monaco-editor/react": "^4.7.0", "@openrouter/ai-sdk-provider": "^2.10.0", "@posthog/ai": "8.8.0", @@ -157,6 +158,7 @@ "devDependencies": { "@eslint/eslintrc": "^3.3.6", "@jest/globals": "^30.4.1", + "@miosa/cli": "1.1.19", "@playwright/test": "^1.62.1", "@posthog/cli": "0.15.0", "@tailwindcss/postcss": "^4.3.3", @@ -185,8 +187,8 @@ "prettier": "3.9.6", "tailwindcss": "^4.3.3", "trigger.dev": "4.5.12", - "tsx": "^4.23.12", "ts-node": "^10.9.2", + "tsx": "^4.23.12", "tw-animate-css": "^1.4.0", "typescript": "^6.0.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59d8e3035..7affbadce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,6 +111,9 @@ importers: '@legendapp/list': specifier: 3.3.8 version: 3.3.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@miosa/sdk': + specifier: 3.0.1 + version: 3.0.1 '@monaco-editor/react': specifier: ^4.7.0 version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -355,6 +358,9 @@ importers: '@jest/globals': specifier: ^30.4.1 version: 30.4.1 + '@miosa/cli': + specifier: 1.1.19 + version: 1.1.19(@types/node@25.9.3)(@types/react@19.2.18) '@playwright/test': specifier: ^1.62.1 version: 1.62.1 @@ -560,6 +566,10 @@ packages: peerDependencies: react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1 + '@alcalzone/ansi-tokenize@0.3.0': + resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==} + engines: {node: '>=18'} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -1749,6 +1759,19 @@ packages: cpu: [x64] os: [win32] + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -2349,6 +2372,15 @@ packages: '@microsoft/fetch-event-source@2.0.1': resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==} + '@miosa/cli@1.1.19': + resolution: {integrity: sha512-5lbOvwNnVXWaIavC2Wpvy0zFTZ+fssx46PY1EAaaOm9vJRJPIIlQxx++hF3mw/vwkv55DtIxjMSTjyKR7eIp0w==} + engines: {node: '>=20'} + hasBin: true + + '@miosa/sdk@3.0.1': + resolution: {integrity: sha512-f949pMBMpXht2BWafpi6fqPFlH6xU4XGkAbW7yJ84PbLaW4Wu1Txec7I0/9JvBIcjou8RKLLzfGP9e3/85V3Vw==} + engines: {node: '>=18.0.0'} + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -4384,6 +4416,10 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + auto-bind@5.0.1: + resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -4453,6 +4489,9 @@ packages: bintrees@1.0.2: resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bluebird@3.4.7: resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} @@ -4488,6 +4527,9 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -4572,6 +4614,9 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -4606,10 +4651,38 @@ packages: classnames@2.5.1: resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + cli-boxes@4.0.1: + resolution: {integrity: sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==} + engines: {node: '>=18.20 <19 || >=20.10'} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-cursor@4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + cli-table3@0.6.5: resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} engines: {node: 10.* || >= 12.*} + cli-truncate@6.1.1: + resolution: {integrity: sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==} + engines: {node: '>=22'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -4621,6 +4694,10 @@ packages: resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} engines: {node: '>=20'} + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -4633,6 +4710,10 @@ packages: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + code-excerpt@4.0.0: + resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + collect-v8-coverage@1.0.3: resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} @@ -4650,6 +4731,10 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + commander@7.2.0: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} @@ -4691,6 +4776,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + convert-to-spaces@2.0.1: + resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + convex@1.42.0: resolution: {integrity: sha512-KS1U+YkE7N7jDfucPW28iNehnb9/N5n9RJmdAFNdPIHTeNpbLKNqk1pJHp4ajHyMBAhGmri8Z4S1lc1LjliZWQ==} engines: {node: '>=18.0.0', npm: '>=7.0.0'} @@ -4858,6 +4947,9 @@ packages: resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} engines: {node: '>=18'} + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -5034,6 +5126,9 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + es-toolkit@1.51.0: + resolution: {integrity: sha512-zC2lQGkM7QX+Gm6iM3+WIdZJzthsEd14LvRNJneSO2hzyz/zNBENR8+YXWo1cKxgPBtV6ksPYHELbcwBRzmdCw==} + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -5711,6 +5806,10 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -5722,9 +5821,26 @@ packages: resolution: {integrity: sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==} engines: {node: ^18.17.0 || >=20.5.0} + ink@7.1.1: + resolution: {integrity: sha512-Y43xxa1ZSPvpmfLHcN5o+OdP8Rf8ykkNJEuKYOUNZKT8wXVNLFTtEm1nSDMQkfBH+YANF4Xuu0hhZ4ejqAtN2w==} + engines: {node: '>=22'} + peerDependencies: + '@types/react': '>=19.2.0' + react: '>=19.2.0' + react-devtools-core: '>=6.1.2' + peerDependenciesMeta: + '@types/react': + optional: true + react-devtools-core: + optional: true + inline-style-parser@0.2.4: resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==} + inquirer@9.3.8: + resolution: {integrity: sha512-pFGGdaHrmRKMh4WoDDSowddgjT1Vkl90atobmTeSmcPGdYiwikch/m/Ef5wRaiamHejtw0cUUMMerzDUXCci2w==} + engines: {node: '>=18'} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -5811,6 +5927,10 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + is-generator-fn@2.1.0: resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} engines: {node: '>=6'} @@ -5826,11 +5946,24 @@ packages: is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-in-ci@2.0.0: + resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==} + engines: {node: '>=20'} + hasBin: true + is-inside-container@1.0.0: resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} engines: {node: '>=14.16'} hasBin: true + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + is-map@2.0.3: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} @@ -5889,6 +6022,18 @@ packages: resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} engines: {node: '>= 0.4'} + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + is-weakmap@2.0.2: resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} engines: {node: '>= 0.4'} @@ -6372,6 +6517,14 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + log-symbols@6.0.0: + resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + engines: {node: '>=18'} + long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} @@ -6618,6 +6771,10 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -6683,6 +6840,10 @@ packages: resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} hasBin: true + mute-stream@1.0.0: + resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + nanoid@3.3.18: resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -6833,6 +6994,10 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + oniguruma-parser@0.12.2: resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} @@ -6879,6 +7044,14 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + + ora@8.2.0: + resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} + engines: {node: '>=18'} + os-paths@4.4.0: resolution: {integrity: sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==} engines: {node: '>= 6.0'} @@ -6967,6 +7140,10 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + patch-console@2.0.0: + resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -7231,6 +7408,12 @@ packages: react-is@19.2.8: resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==} + react-reconciler@0.33.0: + resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.2.0 + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -7287,6 +7470,10 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -7383,6 +7570,18 @@ packages: resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} hasBin: true + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + restore-cursor@4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + resumable-stream@2.2.12: resolution: {integrity: sha512-e6boHekmb72MdWDn7vSV9kiuRb9K3CepcDj+3Z0NNhdAfld7uHbVU8+NKlHRf3LO2Chtj4cXuWL88StLg64eIw==} @@ -7415,6 +7614,10 @@ packages: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} + run-async@3.0.0: + resolution: {integrity: sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==} + engines: {node: '>=0.12.0'} + run-exclusive@2.2.19: resolution: {integrity: sha512-K3mdoAi7tjJ/qT7Flj90L7QyPozwUaAG+CVhkdDje4HLKXUYC3N/Jzkau3flHVDLQVhiHBtcimVodMjN9egYbA==} @@ -7549,6 +7752,10 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + slice-ansi@9.0.0: + resolution: {integrity: sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==} + engines: {node: '>=22'} + socket.io-client@4.7.5: resolution: {integrity: sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ==} engines: {node: '>=10.0.0'} @@ -7604,6 +7811,10 @@ packages: std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -7634,6 +7845,10 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} + string-width@8.2.2: + resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} + engines: {node: '>=20'} + string.prototype.includes@2.0.1: resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} engines: {node: '>= 0.4'} @@ -7760,6 +7975,10 @@ packages: os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android] hasBin: true + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tailwind-merge@3.6.0: resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} @@ -7777,6 +7996,10 @@ packages: tdigest@0.1.2: resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} + terminal-size@4.0.1: + resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} + engines: {node: '>=18'} + test-exclude@6.0.0: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} @@ -7908,6 +8131,10 @@ packages: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} + type-fest@5.8.0: + resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} + engines: {node: '>=20'} + type-is@2.1.0: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} @@ -7968,6 +8195,14 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici@6.28.0: + resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==} + engines: {node: '>=18.17'} + + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + undici@7.29.0: resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} engines: {node: '>=20.18.1'} @@ -8132,6 +8367,9 @@ packages: walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} @@ -8193,6 +8431,10 @@ packages: engines: {node: '>= 8'} hasBin: true + widest-line@6.0.0: + resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} + engines: {node: '>=20'} + word-extractor@1.0.4: resolution: {integrity: sha512-PyAGZQ2gjnVA5kcZAOAxoYciCMaAvu0dbVlw/zxHphhy+3be8cDeYKHJPO8iedIM3Sx0arA/ugKTJyXhZNgo6g==} @@ -8203,6 +8445,14 @@ packages: wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + wrap-ansi@10.0.1: + resolution: {integrity: sha512-M0N4xzyzosiIok3svYlEo1sdLZts/8FPgYH/GPC3wvlmPoRvnoManGMrE54waYj3tISA8w6lsdesfVv67qSr8Q==} + engines: {node: '>=20'} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -8336,6 +8586,13 @@ packages: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -8417,6 +8674,11 @@ snapshots: transitivePeerDependencies: - zod + '@alcalzone/ansi-tokenize@0.3.0': + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + '@alloc/quick-lru@5.2.0': {} '@anthropic-ai/sdk@0.27.3': @@ -9462,6 +9724,15 @@ snapshots: '@img/sharp-win32-x64@0.35.3': optional: true + '@inquirer/external-editor@1.0.3(@types/node@25.9.3)': + dependencies: + chardet: 2.2.0 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 25.9.3 + + '@inquirer/figures@1.0.15': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -9870,6 +10141,32 @@ snapshots: '@microsoft/fetch-event-source@2.0.1': {} + '@miosa/cli@1.1.19(@types/node@25.9.3)(@types/react@19.2.18)': + dependencies: + chalk: 5.6.2 + commander: 12.1.0 + ink: 7.1.1(@types/react@19.2.18)(react@19.2.8) + inquirer: 9.3.8(@types/node@25.9.3) + ora: 8.2.0 + react: 19.2.8 + undici: 6.28.0 + ws: 8.21.0 + yaml: 2.9.0 + transitivePeerDependencies: + - '@types/node' + - '@types/react' + - bufferutil + - react-devtools-core + - utf-8-validate + + '@miosa/sdk@3.0.1': + optionalDependencies: + undici: 7.28.0 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(zod@3.25.76)': dependencies: '@hono/node-server': 2.0.10(hono@4.12.34) @@ -11975,6 +12272,8 @@ snapshots: asynckit@0.4.0: {} + auto-bind@5.0.1: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -12067,6 +12366,12 @@ snapshots: bintrees@1.0.2: {} + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + bluebird@3.4.7: {} body-parser@2.3.0(supports-color@10.2.2): @@ -12111,6 +12416,11 @@ snapshots: buffer-from@1.1.2: {} + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -12205,6 +12515,8 @@ snapshots: character-reference-invalid@2.0.1: {} + chardet@2.2.0: {} + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -12241,12 +12553,35 @@ snapshots: classnames@2.5.1: {} + cli-boxes@4.0.1: {} + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-cursor@4.0.0: + dependencies: + restore-cursor: 4.0.0 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + cli-table3@0.6.5: dependencies: string-width: 4.2.3 optionalDependencies: '@colors/colors': 1.5.0 + cli-truncate@6.1.1: + dependencies: + slice-ansi: 9.0.0 + string-width: 8.2.2 + + cli-width@4.1.0: {} + client-only@0.0.1: {} cliui@8.0.1: @@ -12261,12 +12596,18 @@ snapshots: strip-ansi: 7.1.2 wrap-ansi: 9.0.2 + clone@1.0.4: {} + clsx@2.1.1: {} cluster-key-slot@1.1.2: {} co@4.6.0: {} + code-excerpt@4.0.0: + dependencies: + convert-to-spaces: 2.0.1 + collect-v8-coverage@1.0.3: {} color-convert@2.0.1: @@ -12281,6 +12622,8 @@ snapshots: comma-separated-tokens@2.0.3: {} + commander@12.1.0: {} + commander@7.2.0: optional: true @@ -12311,6 +12654,8 @@ snapshots: convert-source-map@2.0.0: {} + convert-to-spaces@2.0.1: {} + convex@1.42.0(react@19.2.8): dependencies: esbuild: 0.27.0 @@ -12448,6 +12793,10 @@ snapshots: bundle-name: 4.1.0 default-browser-id: 5.0.1 + defaults@1.0.4: + dependencies: + clone: 1.0.4 + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 @@ -12688,6 +13037,8 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es-toolkit@1.51.0: {} + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -13630,7 +13981,7 @@ snapshots: isstream: 0.1.2 jsonwebtoken: 9.0.3 mime-types: 2.1.35 - retry-axios: 2.6.0(axios@1.18.0) + retry-axios: 2.6.0(axios@1.18.0(debug@4.4.3)) tough-cookie: 4.1.4 transitivePeerDependencies: - supports-color @@ -13672,6 +14023,8 @@ snapshots: indent-string@4.0.0: {} + indent-string@5.0.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -13681,8 +14034,59 @@ snapshots: ini@5.0.0: {} + ink@7.1.1(@types/react@19.2.18)(react@19.2.8): + dependencies: + '@alcalzone/ansi-tokenize': 0.3.0 + ansi-escapes: 7.3.0 + ansi-styles: 6.2.3 + auto-bind: 5.0.1 + chalk: 5.6.2 + cli-boxes: 4.0.1 + cli-cursor: 4.0.0 + cli-truncate: 6.1.1 + code-excerpt: 4.0.0 + es-toolkit: 1.51.0 + indent-string: 5.0.0 + is-in-ci: 2.0.0 + patch-console: 2.0.0 + react: 19.2.8 + react-reconciler: 0.33.0(react@19.2.8) + scheduler: 0.27.0 + signal-exit: 3.0.7 + slice-ansi: 9.0.0 + stack-utils: 2.0.6 + string-width: 8.2.2 + terminal-size: 4.0.1 + type-fest: 5.8.0 + widest-line: 6.0.0 + wrap-ansi: 10.0.1 + ws: 8.21.0 + yoga-layout: 3.2.1 + optionalDependencies: + '@types/react': 19.2.18 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + inline-style-parser@0.2.4: {} + inquirer@9.3.8(@types/node@25.9.3): + dependencies: + '@inquirer/external-editor': 1.0.3(@types/node@25.9.3) + '@inquirer/figures': 1.0.15 + ansi-escapes: 4.3.2 + cli-width: 4.1.0 + mute-stream: 1.0.0 + ora: 5.4.1 + run-async: 3.0.0 + rxjs: 7.8.2 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + transitivePeerDependencies: + - '@types/node' + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -13770,6 +14174,10 @@ snapshots: is-fullwidth-code-point@3.0.0: {} + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.6.0 + is-generator-fn@2.1.0: {} is-generator-function@1.1.0: @@ -13785,10 +14193,16 @@ snapshots: is-hexadecimal@2.0.1: {} + is-in-ci@2.0.0: {} + is-inside-container@1.0.0: dependencies: is-docker: 3.0.0 + is-interactive@1.0.0: {} + + is-interactive@2.0.0: {} + is-map@2.0.3: {} is-negative-zero@2.0.3: {} @@ -13838,6 +14252,12 @@ snapshots: dependencies: which-typed-array: 1.1.19 + is-unicode-supported@0.1.0: {} + + is-unicode-supported@1.3.0: {} + + is-unicode-supported@2.1.0: {} + is-weakmap@2.0.2: {} is-weakref@1.1.1: @@ -14529,6 +14949,16 @@ snapshots: lodash.once@4.1.1: {} + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + log-symbols@6.0.0: + dependencies: + chalk: 5.6.2 + is-unicode-supported: 1.3.0 + long@5.3.2: {} longest-streak@3.1.0: {} @@ -14973,6 +15403,8 @@ snapshots: mimic-fn@2.1.0: {} + mimic-function@5.0.1: {} + min-indent@1.0.1: {} minimal-polyfills@2.2.3: {} @@ -15033,6 +15465,8 @@ snapshots: mustache@4.2.0: {} + mute-stream@1.0.0: {} + nanoid@3.3.18: {} napi-postinstall@0.3.3: {} @@ -15183,6 +15617,10 @@ snapshots: dependencies: mimic-fn: 2.1.0 + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + oniguruma-parser@0.12.2: {} oniguruma-to-es@4.3.6: @@ -15224,6 +15662,30 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + ora@5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + + ora@8.2.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 7.2.0 + strip-ansi: 7.1.2 + os-paths@4.4.0: {} os-paths@7.4.0: @@ -15317,6 +15779,8 @@ snapshots: parseurl@1.3.3: {} + patch-console@2.0.0: {} + path-exists@4.0.0: {} path-expression-matcher@1.6.2: @@ -15612,6 +16076,11 @@ snapshots: react-is@19.2.8: {} + react-reconciler@0.33.0(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + react-remove-scroll-bar@2.3.8(@types/react@19.2.18)(react@19.2.8): dependencies: react: 19.2.8 @@ -15675,6 +16144,12 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readdirp@3.6.0: dependencies: picomatch: 2.3.2 @@ -15814,9 +16289,24 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + restore-cursor@4.0.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + resumable-stream@2.2.12: {} - retry-axios@2.6.0(axios@1.18.0): + retry-axios@2.6.0(axios@1.18.0(debug@4.4.3)): dependencies: axios: 1.18.0(debug@4.4.3) @@ -15843,6 +16333,8 @@ snapshots: run-applescript@7.1.0: {} + run-async@3.0.0: {} + run-exclusive@2.2.19: dependencies: minimal-polyfills: 2.2.3 @@ -16036,6 +16528,11 @@ snapshots: slash@3.0.0: {} + slice-ansi@9.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + socket.io-client@4.7.5(supports-color@10.2.2): dependencies: '@socket.io/component-emitter': 3.1.2 @@ -16091,6 +16588,8 @@ snapshots: std-env@3.9.0: {} + stdin-discarder@0.2.2: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -16143,6 +16642,11 @@ snapshots: get-east-asian-width: 1.6.0 strip-ansi: 7.1.2 + string-width@8.2.2: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.1.2 + string.prototype.includes@2.0.1: dependencies: call-bind: 1.0.8 @@ -16277,6 +16781,8 @@ snapshots: systeminformation@5.31.7: {} + tagged-tag@1.0.0: {} + tailwind-merge@3.6.0: {} tailwindcss@4.3.3: {} @@ -16295,6 +16801,8 @@ snapshots: dependencies: bintrees: 1.0.2 + terminal-size@4.0.1: {} + test-exclude@6.0.0: dependencies: '@istanbuljs/schema': 0.1.3 @@ -16475,6 +16983,10 @@ snapshots: type-fest@0.21.3: {} + type-fest@5.8.0: + dependencies: + tagged-tag: 1.0.0 + type-is@2.1.0: dependencies: content-type: 2.0.0 @@ -16549,6 +17061,11 @@ snapshots: undici-types@7.24.6: {} + undici@6.28.0: {} + + undici@7.28.0: + optional: true + undici@7.29.0: {} undici@8.9.0: @@ -16730,6 +17247,10 @@ snapshots: dependencies: makeerror: 1.0.12 + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + web-namespaces@2.0.1: {} web-streams-polyfill@3.3.3: @@ -16806,6 +17327,10 @@ snapshots: dependencies: isexe: 2.0.0 + widest-line@6.0.0: + dependencies: + string-width: 8.2.2 + word-extractor@1.0.4: dependencies: saxes: 5.0.1 @@ -16815,6 +17340,17 @@ snapshots: wordwrap@1.0.0: {} + wrap-ansi@10.0.1: + dependencies: + ansi-styles: 6.2.3 + string-width: 8.2.2 + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -16925,6 +17461,10 @@ snapshots: yocto-queue@1.2.2: {} + yoctocolors-cjs@2.1.3: {} + + yoga-layout@3.2.1: {} + zod-to-json-schema@3.25.2(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/scripts/setup.ts b/scripts/setup.ts index f2ea9a1e8..10544b832 100644 --- a/scripts/setup.ts +++ b/scripts/setup.ts @@ -89,6 +89,28 @@ async function getE2bApiKey(): Promise { return await getE2bApiKey(); } +async function getMiosaApiKey(): Promise { + console.log( + `\n${chalk.bold("Getting MIOSA API Key for cloud sandbox rollout")}`, + ); + console.log( + "MIOSA is the primary rollout provider. Leave this blank to keep E2B-only cloud execution.", + ); + console.log( + "You can create a MIOSA API Key at: https://miosa.ai/dashboard/api-keys", + ); + const key = await question("Enter your MIOSA API Key (optional): "); + + if (!key || key.startsWith("msk_")) { + return key; + } + + console.log(chalk.red("Invalid MIOSA API Key format")); + console.log('MIOSA keys should start with "msk_"'); + + return await getMiosaApiKey(); +} + async function getWorkOSApiKey(): Promise { console.log(`\n${chalk.bold("Getting WorkOS API Key")}`); console.log( @@ -278,8 +300,13 @@ OPENAI_API_KEY=${envVars.OPENAI_API_KEY} XAI_API_KEY=${envVars.XAI_API_KEY} # ============================================================================= -# CODE EXECUTION - E2B (Required for Agent Mode) +# CODE EXECUTION - CLOUD SANDBOX (Required for Agent Mode) # ============================================================================= +# MIOSA is gradually enabled by PostHog. Set the promoted HackerAI template ID +# supplied by MIOSA before enabling it. E2B remains the acquisition fallback. +MIOSA_API_KEY=${envVars.MIOSA_API_KEY} +MIOSA_TEMPLATE_ID= + # Sign up at: https://e2b.dev/ E2B_API_KEY=${envVars.E2B_API_KEY} E2B_TEMPLATE=terminal-agent-sandbox @@ -471,6 +498,7 @@ async function main() { const OPENROUTER_API_KEY = await getOpenRouterApiKey(); const OPENAI_API_KEY = await getOpenAiApiKey(); const XAI_API_KEY = await getXaiApiKey(); + const MIOSA_API_KEY = await getMiosaApiKey(); const E2B_API_KEY = await getE2bApiKey(); // Get WorkOS configuration @@ -495,6 +523,7 @@ async function main() { OPENROUTER_API_KEY, OPENAI_API_KEY, XAI_API_KEY, + MIOSA_API_KEY, E2B_API_KEY, WORKOS_API_KEY, WORKOS_CLIENT_ID, diff --git a/trigger/__tests__/agent-approval-sandbox-grants.test.ts b/trigger/__tests__/agent-approval-sandbox-grants.test.ts index c52f02b21..bac3e3aca 100644 --- a/trigger/__tests__/agent-approval-sandbox-grants.test.ts +++ b/trigger/__tests__/agent-approval-sandbox-grants.test.ts @@ -79,6 +79,30 @@ describe("sandbox-scoped reusable Agent approval grants", () => { ).toBeNull(); }); + it("does not reuse approval grants between E2B and MIOSA", () => { + const e2bGrant = serializeSandboxScopedAgentApprovalTargetPrefix({ + sandboxIdentity: "e2b", + targetPrefix, + }); + const miosaGrant = serializeSandboxScopedAgentApprovalTargetPrefix({ + sandboxIdentity: "miosa", + targetPrefix, + }); + + expect( + getAgentApprovalTargetPrefixForSandbox({ + persistedTargetPrefix: e2bGrant, + sandboxIdentity: "miosa", + }), + ).toBeNull(); + expect( + getAgentApprovalTargetPrefixForSandbox({ + persistedTargetPrefix: miosaGrant, + sandboxIdentity: "e2b", + }), + ).toBeNull(); + }); + it("does not reuse a grant on another local connection", () => { const persistedTargetPrefix = serializeSandboxScopedAgentApprovalTargetPrefix({ diff --git a/trigger/agent-long.ts b/trigger/agent-long.ts index aaa896aa5..f5c741fbc 100644 --- a/trigger/agent-long.ts +++ b/trigger/agent-long.ts @@ -25,6 +25,7 @@ import { recordGroupedSpikeAlert } from "@/lib/observability/grouped-spike-alert import { systemPrompt } from "@/lib/system-prompt"; import { getResumeSection } from "@/lib/system-prompt/resume"; import { createTools } from "@/lib/ai/tools"; +import { selectCloudSandboxProvider } from "@/lib/ai/tools/utils/cloud-sandbox-provider"; import { ptySessionManager } from "@/lib/ai/tools/utils/pty-session-manager"; import { generateTitleFromUserMessageWithWriter } from "@/lib/actions"; import { createTrackedProvider } from "@/lib/ai/providers"; @@ -194,7 +195,6 @@ import type { import { AGENT_TOOL_APPROVAL_PROTOCOL_VERSION, canUseExtraUsage, - getAgentApprovalConnectionSandboxIdentity, getAgentToolApprovalPromptKind, getAgentApprovalTargetPrefixForSandbox, normalizeMaxModelForSubscription, @@ -211,6 +211,7 @@ import { } from "@/lib/api/agent-stream-runner"; import { assertLocalSandboxFallbackAllowed, + getAgentApprovalSandboxIdentity, getSandboxFallbackPromptReminder, prepareSandboxContextForPrompt, writeSandboxFallbackEvent, @@ -264,7 +265,6 @@ import { uiMessagesContainImageViewResult, } from "@/lib/chat/multimodal-tool-result-recovery"; import { FREE_AGENT_LONG_RUN_LOCK_TTL_SECONDS } from "@/lib/rate-limit/free-config"; -import { isCentrifugoSandbox } from "@/lib/ai/tools/utils/sandbox-types"; import { AgentRunTimingTracker } from "@/lib/chat/agent-run-timing"; import { AgentLongMemoryTelemetry } from "@/lib/chat/agent-long-memory-telemetry"; import { @@ -2539,7 +2539,18 @@ export const agentLongTask = task({ selectedModelOverride, }); const posthog = PostHogClient(); - const cloudSandboxProvider = "e2b" as const; + const cloudSandboxSelection = + !sandboxPreference || sandboxPreference === "e2b" + ? await selectCloudSandboxProvider({ + userId, + environment: ctx.environment.type, + featureFlagClient: posthog, + }) + : ({ + provider: "e2b", + reason: "miosa_rollout_control", + } as const); + const cloudSandboxProvider = cloudSandboxSelection.provider; const baseTodos: Todo[] = getBaseTodosForRequest( (chat?.todos as unknown as Todo[]) || [], @@ -3107,18 +3118,11 @@ export const agentLongTask = task({ }; let approvalSandboxManager: SandboxManager | undefined; const resolveApprovalSandboxIdentity = async () => { - if (!sandboxPreference || sandboxPreference === "e2b") { - return "e2b" as const; - } if (!approvalSandboxManager) { throw new Error("Sandbox manager is unavailable for approval"); } const { sandbox } = await approvalSandboxManager.getSandbox(); - return isCentrifugoSandbox(sandbox) - ? getAgentApprovalConnectionSandboxIdentity( - sandbox.getConnectionId(), - ) - : ("e2b" as const); + return getAgentApprovalSandboxIdentity(sandbox); }; const requestToolApproval = buildAgentToolApprovalRequester({ agentPermissionMode, @@ -3210,6 +3214,7 @@ export const agentLongTask = task({ auxiliaryVision, { cloudSandboxProvider, + cloudSandboxSelectionReason: cloudSandboxSelection.reason, triggerRegion, keepE2BLeaseAliveForRun: true, ...(subagentsEnabled @@ -3524,7 +3529,7 @@ export const agentLongTask = task({ // Title generation starts in parallel with the main run. Wait // for it so its provider cost cannot race final settlement. await titlePromise; - const sandboxUsage = getSandboxSessionUsage(); + const sandboxUsage = await getSandboxSessionUsage(); const sandboxCost = sandboxUsage.totalCostDollars; if (sandboxCost > 0) { usageTracker.providerCost += sandboxCost; diff --git a/types/agent.ts b/types/agent.ts index 589327fbf..0e2954442 100644 --- a/types/agent.ts +++ b/types/agent.ts @@ -8,11 +8,12 @@ import type { PtySessionManager } from "@/lib/ai/tools/utils/pty-session-manager import type { PtyParserLogBudget } from "@/lib/ai/tools/utils/pty-output-formatter"; import type { ChatMode, SubscriptionTier } from "./chat"; import type { CentrifugoSandbox } from "@/lib/ai/tools/utils/centrifugo-sandbox"; +import type { MiosaSandbox } from "@/lib/ai/tools/utils/miosa-sandbox"; import type { SandboxFallbackInfo } from "@/lib/ai/tools/utils/hybrid-sandbox-manager"; import type { CloudSandboxProvider } from "@/lib/ai/tools/utils/cloud-sandbox-provider"; -// Union type for E2B Sandbox and local CentrifugoSandbox -export type AnySandbox = Sandbox | CentrifugoSandbox; +// Union type for cloud providers and local CentrifugoSandbox. +export type AnySandbox = Sandbox | MiosaSandbox | CentrifugoSandbox; // Type guard to check if sandbox is E2B export type IsE2BSandboxFn = (s: AnySandbox | null) => s is Sandbox; @@ -191,7 +192,8 @@ export type AgentToolApprovalGrant = "full_access" | "target_prefix"; export type AgentToolApprovalGrantKind = "terminal_command" | "terminal_interaction" | "file_change"; -export type AgentApprovalSandboxIdentity = "e2b" | `connection:${string}`; +export type AgentApprovalSandboxIdentity = + "e2b" | "miosa" | `connection:${string}`; const AGENT_APPROVAL_SANDBOX_SCOPE_VERSION = "agent-approval-sandbox-scope-v1" as const; @@ -211,6 +213,7 @@ const isAgentApprovalSandboxIdentity = ( value: unknown, ): value is AgentApprovalSandboxIdentity => value === "e2b" || + value === "miosa" || (typeof value === "string" && value.startsWith("connection:") && value.length > "connection:".length &&