From 7cec75a7fe86be5e9210540ac1be1de0ab77fbbc Mon Sep 17 00:00:00 2001 From: pdparchitect Date: Thu, 10 Sep 2026 17:13:59 +0000 Subject: [PATCH 1/3] feat: update version to 0.3.3 and add changelog entry for recent fixes (+3 more) - feat: update version to 0.3.3 and add changelog entry for recent fixes - feat: enhance egress handling for loopback addresses and self-deployment recognition - feat: add hasOpenSubscription logic and update billing upgrade flow - feat: implement retry logic for execution conflicts in sandbox commands --- CHANGELOG.md | 25 +++ package.json | 2 +- packages/billing-spec/src/index.ts | 8 + packages/billing/src/model.ts | 4 + packages/sandbox/src/conflict.test.js | 107 +++++++++ packages/sandbox/src/index.ts | 66 +++++- platform/components/UpgradePlans.jsx | 61 ++++- platform/components/UpgradePlans.utest.jsx | 75 +++++++ platform/lib/billing.core.ts | 1 + platform/lib/egress.core.ts | 141 ++++++++++-- platform/lib/egress.core.utest.js | 208 ++++++++++++++++++ platform/lib/ip.ts | 39 ++++ platform/lib/ip.utest.js | 30 ++- platform/lib/url3.utest.js | 7 + .../api/auxiliary/dataset/_chunk.utest.js | 7 + .../chatbotkit/dataset/file/_attach.utest.js | 7 + .../ability/chatbotkit/url/_git.utest.js | 7 + .../ability/chatbotkit/url/_sql.utest.js | 7 + .../api/oauth/connection/_callback.utest.js | 7 + .../api/v1/file/[fileId]/_upload.utest.js | 7 + platform/pages/api/v1/image/_edit.utest.js | 7 + .../[extractIntegrationId]/_queue.utest.js | 7 + .../[spaceId]/storage/upload/_path.utest.js | 7 + platform/pages/api/v1/url/_fetch.utest.js | 7 + .../v1/webhook/[webhookId]/_queue.utest.js | 7 + platform/pages/billing/upgrade.jsx | 32 ++- platform/tests/pages/billing/upgrade.utest.js | 116 ++++++++++ 27 files changed, 968 insertions(+), 31 deletions(-) create mode 100644 packages/sandbox/src/conflict.test.js create mode 100644 platform/tests/pages/billing/upgrade.utest.js diff --git a/CHANGELOG.md b/CHANGELOG.md index fd6f698..4336bff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ here. The release version is defined in the workspace root `package.json`. ## [Unreleased] +## [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 diff --git a/package.json b/package.json index acb44ce..66cf6f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "platform", - "version": "0.3.2", + "version": "0.3.3", "private": true, "license": "Apache-2.0", "packageManager": "pnpm@11.24.0", diff --git a/packages/billing-spec/src/index.ts b/packages/billing-spec/src/index.ts index fd56bc7..f9beec9 100644 --- a/packages/billing-spec/src/index.ts +++ b/packages/billing-spec/src/index.ts @@ -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. diff --git a/packages/billing/src/model.ts b/packages/billing/src/model.ts index 893a9be..cf66dec 100644 --- a/packages/billing/src/model.ts +++ b/packages/billing/src/model.ts @@ -32,6 +32,10 @@ export function createSubscriptionModel( return grantedPlan(user.email) !== undefined }, + hasOpenSubscription() { + return false + }, + hasTrialed() { return false }, diff --git a/packages/sandbox/src/conflict.test.js b/packages/sandbox/src/conflict.test.js new file mode 100644 index 0000000..e47f125 --- /dev/null +++ b/packages/sandbox/src/conflict.test.js @@ -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) +}) diff --git a/packages/sandbox/src/index.ts b/packages/sandbox/src/index.ts index ed6c8ba..e04dc56 100644 --- a/packages/sandbox/src/index.ts +++ b/packages/sandbox/src/index.ts @@ -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 +): Promise { + 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 | undefined @@ -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 @@ -763,12 +809,14 @@ async function exec(options: SandboxExecOptions): Promise { // 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 } } diff --git a/platform/components/UpgradePlans.jsx b/platform/components/UpgradePlans.jsx index 79ef1c5..e176236 100644 --- a/platform/components/UpgradePlans.jsx +++ b/platform/components/UpgradePlans.jsx @@ -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 @@ -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 @@ -191,9 +212,37 @@ export default function UpgradePlans({ return (
+ {!billable ? ( +
+ Billing for this account is managed by the owner of the account. + Ask them to change the plan. +
+ ) : lapsed ? ( +
+
+ 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. +
+ + +
+ ) : null} +
{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]) @@ -245,10 +294,16 @@ export default function UpgradePlans({
Your plan
+ ) : !billable ? ( +
+ Managed by the owner +
) : selfServe ? (