Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,37 @@ here. The release version is defined in the workspace root `package.json`.

## [Unreleased]

### Fixed

- Bill DeepSeek V4.1 Flash through Vercel AI Gateway at the gateway's peak
rate and full 1,048,576-token context, matching the gateway catalogue rather
than DeepSeek's off-peak list price.

## [0.3.3] - 2026-09-10

### Fixed

- Let the platform fetch its own URLs on the Community and Studio stacks.
The egress boundary refused every loopback and private destination outside
development, and those stacks run the production build on loopback, so
proxied images, attachments and presigned objects failed with `egress to
127.0.0.1 is not allowed`. The boundary now recognises the deployment
itself: the configured site, static, widget, API and app shell origins
connect unchecked, and where the site lives on loopback so does every
loopback and `*.localhost` destination. Other private addresses stay
refused, and a hosted deployment's public origins gain nothing.
- Stop the upgrade page from offering a checkout the billing API refuses.
An account that already holds a subscription - live, or lapsed after a
failed payment - is sent to the billing portal to change it, a lapsed one
is told its payment needs fixing, and a child account is told billing
belongs to the owner. A refused checkout now surfaces its message instead
of the button silently doing nothing. Billing modules gain
`hasOpenSubscription` on the subscription model.
- Retry a sandbox command that the AgentOS runtime refused to start because it
was still tearing down the previous command after a timeout. The refusal
surfaced as exit 127 with empty output on the first command after any
timed-out one, most often under load.

## [0.3.2] - 2026-09-10

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "platform",
"version": "0.3.2",
"version": "0.3.3",
"private": true,
"license": "Apache-2.0",
"packageManager": "[email protected]",
Expand Down
8 changes: 8 additions & 0 deletions packages/billing-spec/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ export interface SubscriptionModel {
/** Whether the account holds a live subscription or a grant. */
hasSubscription(user: SubscriptionHolder): boolean

/**
* Whether the account holds a subscription the provider still keeps open,
* live or not - a lapsed payment leaves one open. An open subscription is
* changed through the billing portal; a fresh checkout is refused against
* it.
*/
hasOpenSubscription(user: SubscriptionHolder): boolean

/**
* Whether the account has ever consumed its trial - one per account,
* regardless of the plan it ran on or how the trial ended.
Expand Down
4 changes: 4 additions & 0 deletions packages/billing/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ export function createSubscriptionModel(
return grantedPlan(user.email) !== undefined
},

hasOpenSubscription() {
return false
},

hasTrialed() {
return false
},
Expand Down
107 changes: 107 additions & 0 deletions packages/sandbox/src/conflict.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// @note the runtime is faked here, and only here: the conflict below is what
// the sidecar answers while it is still tearing down a timed-out command, and
// how long that takes depends on machine load, so the real runtime cannot be
// made to answer it on cue. The retry is what makes the sequence in
// index.test.js dependable, and this pins the retry itself.

import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

import { jest } from '@jest/globals'

const dataDir = mkdtempSync(join(tmpdir(), 'sandbox-conflict-test-'))

process.env.SANDBOX_DATA_DIR = dataDir

const CONFLICT =
'ERR_AGENTOS_VM_EXECUTION_CONFLICT: WebAssembly execution state for VM vm-1 is already in use during start WebAssembly execution'

const conflict = () => ({ outcome: 'failed', error: { message: CONFLICT } })

const succeeded = (stdout) => ({ outcome: 'succeeded', exitCode: 0, stdout })

/** Answers per command, consumed front to back; the last answer repeats. */
const answers = new Map()

const exec = jest.fn(async (cmd) => {
const queue = answers.get(cmd) ?? [succeeded('')]

return queue.length > 1 ? queue.shift() : queue[0]
})

const execute = jest.fn(async () => succeeded(''))

jest.unstable_mockModule('@rivet-dev/agentos-core', () => ({
AgentOs: {
create: async () => ({
process: { exec },
javascript: { execute },
python: {
execute: async () => ({
outcome: 'failed',
error: { message: 'ENOENT: command not found: python' },
}),
},
contexts: { reset: async () => {} },
createContext: async () => {},
filesystem: {},
dispose: async () => {},
}),
},
createHostDirBackend: () => ({}),
}))

const { default: provider, reset } = await import('./index.ts')

jest.setTimeout(30_000)

afterEach(() => {
answers.clear()
exec.mockClear()
execute.mockClear()
})

afterAll(async () => {
await reset()

rmSync(dataDir, { recursive: true, force: true })
})

it('runs a command again once the runtime lets go of the previous one', async () => {
answers.set('echo recovered', [conflict(), conflict(), succeeded('recovered\n')])

const result = await provider.exec({ sandboxId: 'a', cmd: 'echo recovered' })

expect(result.exitCode).toBe(0)
expect(result.stdout).toBe('recovered\n')
expect(exec.mock.calls.filter(([cmd]) => cmd === 'echo recovered')).toHaveLength(3)
})

it('does the same for code in a session', async () => {
execute
.mockResolvedValueOnce(conflict())
.mockResolvedValueOnce(succeeded('1\n'))

const result = await provider.runCode({
sandboxId: 'a',
language: 'javascript',
code: 'console.log(1)',
})

expect(result.stdout).toBe('1\n')
expect(execute).toHaveBeenCalledTimes(2)
})

it('reports a conflict that never clears as a command that could not run', async () => {
answers.set('echo stuck', [conflict()])

const started = Date.now()

const result = await provider.exec({ sandboxId: 'a', cmd: 'echo stuck' })

expect(result.exitCode).toBe(127)
expect(result.error).toMatch(/ERR_AGENTOS_VM_EXECUTION_CONFLICT/)
expect(Date.now() - started).toBeGreaterThanOrEqual(4_500)
expect(exec.mock.calls.filter(([cmd]) => cmd === 'echo stuck').length).toBeGreaterThan(10)
})
66 changes: 57 additions & 9 deletions packages/sandbox/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,50 @@ function toRunResult(
return { exitCode: result.exitCode, stdout, stderr }
}

/**
* How long a VM is given to let go of a killed command before the next one is
* reported as unable to start.
*/
const CONFLICT_SETTLE_MS = 5_000

const CONFLICT_RETRY_INTERVAL_MS = 50

/**
* @note the sidecar tears a timed-out process down after it has already
* answered `timed_out`, and until that finishes a new command on the VM is
* refused at start with an execution conflict - reported as a result, not a
* rejection. The queue rules out overlap of this module's own making, so a
* conflict can only be that teardown, and the command has not run, so running
* it again is safe. Under load the window is long enough that a conversation
* would otherwise see `command not found`-shaped failures right after a
* timeout.
*/
function isExecutionConflict(result: ExecutionResult): boolean {
return (
result.outcome !== 'succeeded' &&
result.exitCode === undefined &&
/ERR_AGENTOS_VM_EXECUTION_CONFLICT/.test(result.error?.message ?? '')
)
}

async function runSettled(
run: () => Promise<ExecutionResult>
): Promise<ExecutionResult> {
const deadline = Date.now() + CONFLICT_SETTLE_MS

for (;;) {
const result = await run()

if (!isExecutionConflict(result) || Date.now() >= deadline) {
return result
}

await new Promise((resolve) =>
setTimeout(resolve, CONFLICT_RETRY_INTERVAL_MS)
)
}
}

// --- interpreters ---

let pythonAvailable: Promise<boolean> | undefined
Expand Down Expand Up @@ -684,9 +728,11 @@ async function interpret(
}

const execute = () =>
language === 'python'
? vm.python.execute(code, executionOptions)
: vm.javascript.execute(code, executionOptions)
runSettled(() =>
language === 'python'
? vm.python.execute(code, executionOptions)
: vm.javascript.execute(code, executionOptions)
)

let result: ExecutionResult

Expand Down Expand Up @@ -763,12 +809,14 @@ async function exec(options: SandboxExecOptions): Promise<SandboxExecResult> {
// while providing none, and worse, a lingering shell is exactly the
// process the sidecar hangs on - see the module header.

const result = await vm.process.exec(cmd, {
cwd: WORKSPACE,
output: { capture: 'all' },
...(env ? { env } : {}),
...(timeout ? { timeoutMs: timeout } : {}),
})
const result = await runSettled(() =>
vm.process.exec(cmd, {
cwd: WORKSPACE,
output: { capture: 'all' },
...(env ? { env } : {}),
...(timeout ? { timeoutMs: timeout } : {}),
})
)

return { ...toRunResult(result, timeout), mountedPaths: entry.mountedPaths }
}
Expand Down
9 changes: 8 additions & 1 deletion packages/sandbox/src/pipelines.test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { jest } from '@jest/globals'

import { AgentOs } from '@rivet-dev/agentos-core'

let vm
Expand All @@ -14,11 +16,16 @@ afterAll(async () => {
await vm?.dispose()
})

// @note the first exec pays the VM warm-up, and CI runs the sandbox suites in
// parallel, each booting its own VM. The stall this suite guards against
// surfaces as EAGAIN through the short watchdog, not as a timeout
jest.setTimeout(30_000)

const sh = (cmd) =>
vm.process.exec(cmd, {
cwd: '/workspace',
output: { capture: 'all' },
timeoutMs: 5_000,
timeoutMs: 30_000,
})

it('sorts and filters environment output without a blocking-read failure', async () => {
Expand Down
61 changes: 58 additions & 3 deletions platform/components/UpgradePlans.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,15 +98,26 @@ function PlanBadge({ children }) {
// nothing of it rides in the client bundle. `subscriptions.pricing` carries
// null for a plan that is not self-serve: Infinity does not survive
// serialization, so it is restored here.
//
// Whether the cards may check out is decided there too: a child account
// never bills (`billable`), and an account already holding a subscription
// (`openSubscription`) changes it through the billing portal - a fresh
// checkout is refused against it, and a lapsed one (`lapsed`) needs its
// payment fixed there first.
export default function UpgradePlans({
currentPlan,
limits,
subscriptions,
trialPlans,
billable = true,
openSubscription = false,
lapsed = false,
}) {
const router = useRouter()

const { fetch } = useFetch()
// @note the checkout API refuses with a message the user has to see -
// without the failure toast a refused click reads as a dead button
const { fetch } = useFetch({ loadingMessage: true, failureMessage: true })

// @note the matrix opens on what differs - that is the comparison - and
// expands to the full catalogue on demand
Expand Down Expand Up @@ -165,6 +176,16 @@ export default function UpgradePlans({
}
}

async function goToPortal() {
const { data, error } = await fetch('/api/billing/session', {
data: { returnTo: router.asPath },
})

if (!error) {
router.push(data.redirectUrl)
}
}

if (!rungs.some(({ current }) => !current)) {
// @note a deployment with nothing left to sell this user - already on the
// top plan, or selling nothing self-serve
Expand All @@ -191,9 +212,37 @@ export default function UpgradePlans({

return (
<div className="space-y-16">
{!billable ? (
<div className="rounded-lg border border-gray-200 p-4 text-sm dark:border-gray-800">
Billing for this account is managed by the owner of the account.
Ask them to change the plan.
</div>
) : lapsed ? (
<div className="flex flex-wrap items-center justify-between gap-4 rounded-lg border border-gray-200 p-4 text-sm dark:border-gray-800">
<div>
Your subscription is not active. This usually means the latest
payment did not go through. Update your payment details in the
billing portal to restore your plan.
</div>

<button
type="button"
onClick={goToPortal}
className="rounded-lg bg-gray-900 px-4 py-2 text-sm font-bold text-white dark:bg-white dark:text-black"
>
Manage billing
</button>
</div>
) : null}

<div className="grid gap-6 pt-3 sm:grid-cols-2 lg:grid-cols-3">
{rungs.map(({ plan, label, price, current, selfServe }) => {
const trial = selfServe && !current && trialPlans?.includes(plan)
// @note no trial on top of a subscription the account already holds
const trial =
selfServe &&
!current &&
!openSubscription &&
trialPlans?.includes(plan)
const featured = !current && plan === featuredPlan

const entitlements = headlineEntitlements(limits?.[plan])
Expand Down Expand Up @@ -245,10 +294,16 @@ export default function UpgradePlans({
<div className="mt-8 w-full rounded-lg border border-gray-200 px-4 py-2 text-center text-sm font-bold text-gray-400 dark:border-gray-800 dark:text-gray-600">
Your plan
</div>
) : !billable ? (
<div className="mt-8 w-full rounded-lg border border-gray-200 px-4 py-2 text-center text-sm font-bold text-gray-400 dark:border-gray-800 dark:text-gray-600">
Managed by the owner
</div>
) : selfServe ? (
<button
type="button"
onClick={() => goToCheckout(plan, trial)}
onClick={() =>
openSubscription ? goToPortal() : goToCheckout(plan, trial)
}
className="mt-8 w-full rounded-lg bg-gray-900 px-4 py-2 text-sm font-bold text-white dark:bg-white dark:text-black"
>
{trial
Expand Down
Loading