Merge remote-tracking branch 'private/cloud/integration' into cloud/capability-off-recheck

This commit is contained in:
Neil
2026-11-13 00:43:49 -08:00
307 changed files with 12754 additions and 4830 deletions
+129
View File
@@ -0,0 +1,129 @@
// Billing changes for the team's audit log, which the sandbox control plane owns
// (POST /v1/internal/audit-events, CONTROL_PLANE_TOKEN: that bearer may only write `billing.*`).
// Best effort and bounded: billing's own state is already written, so a failed push loses a log
// line and nothing else. Ids are a stable hash, so the control plane drops a repeat.
import type { Env } from './env'
import type { SubscriptionRow } from './store'
const PUSH_TIMEOUT_MS = 5_000
export type BillingAuditAction =
| 'billing.plan_started'
| 'billing.plan_changed'
| 'billing.plan_canceled'
| 'billing.cancel_scheduled'
| 'billing.cancel_reverted'
| 'billing.seats_changed'
| 'billing.overage_enabled'
| 'billing.overage_disabled'
| 'billing.spend_cap_changed'
export type BillingAuditEvent = {
action: BillingAuditAction
at: number
/** The person who made the change in the desktop; absent for changes Stripe reports. */
actorUserId?: string
// Flat and small. No price ids, customer ids or card details: plan, interval, seats, cents.
detail?: Record<string, string | number | boolean | null>
}
type PushEnv = Pick<Env, 'CONTROL_PLANE_TOKEN' | 'SANDBOX_CONTROL_URL' | 'SANDBOX_CONTROL'>
/** Only teams have an audit log; a personal account's billing changes are its own. */
export function hasTeamAuditLog(accountId: string): boolean {
return accountId.startsWith('org:') || accountId.startsWith('dev:org:')
}
async function eventId(accountId: string, event: BillingAuditEvent): Promise<string> {
const text = `${accountId}|${event.action}|${event.at}|${event.actorUserId ?? ''}|${JSON.stringify(event.detail ?? {})}`
const bytes = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)))
return `billing_${Array.from(bytes.slice(0, 20), (byte) => byte.toString(16).padStart(2, '0')).join('')}`
}
export async function pushBillingAudit(
env: PushEnv,
accountId: string,
events: readonly BillingAuditEvent[],
fetchImpl: typeof fetch = fetch
): Promise<boolean> {
const token = (env.CONTROL_PLANE_TOKEN ?? '').split(',')[0]?.trim() ?? ''
if (!env.SANDBOX_CONTROL_URL || !token || events.length === 0 || !hasTeamAuditLog(accountId)) {
return false
}
// Over the service binding when there is one (same-account workers.dev fetches fail with 1042).
const send: typeof fetch = env.SANDBOX_CONTROL ? (input, init) => env.SANDBOX_CONTROL!.fetch(input, init) : fetchImpl
try {
const response = await send(new URL('/v1/internal/audit-events', env.SANDBOX_CONTROL_URL), {
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify({
accountId,
events: await Promise.all(
events.slice(0, 50).map(async (event) => ({
eventId: await eventId(accountId, event),
action: event.action,
at: event.at,
...(event.actorUserId ? { actorUserId: event.actorUserId } : {}),
target: { type: 'plan' },
...(event.detail ? { detail: event.detail } : {})
}))
)
}),
redirect: 'manual',
signal: AbortSignal.timeout(PUSH_TIMEOUT_MS)
})
await response.body?.cancel().catch(() => undefined)
if (!response.ok) {
console.error(JSON.stringify({ error: 'billing_audit_push', status: response.status }))
return false
}
return true
} catch (error) {
console.error(JSON.stringify({ error: 'billing_audit_push', message: error instanceof Error ? error.name : 'unknown' }))
return false
}
}
type SubscriptionTerms = Pick<SubscriptionRow, 'status' | 'plan' | 'billing_interval' | 'seats' | 'cancel_at_period_end'> & {
/** When the current period ends (ms): the day a scheduled cancellation takes effect. */
current_period_end?: number
}
const ENDED = new Set(['canceled', 'incomplete_expired'])
/** Not a plan yet: Checkout made the subscription but the first payment has not gone through. */
const NOT_STARTED = new Set(['incomplete'])
/**
* What changed between the stored subscription and the one Stripe now reports, as audit events.
* A replay of the same Stripe state finds no difference, so it records nothing twice.
*/
export function subscriptionAuditEvents(before: SubscriptionTerms | null, after: SubscriptionTerms, at: number): BillingAuditEvent[] {
const terms = { plan: after.plan, interval: after.billing_interval, seats: after.seats }
const wasLive = before !== null && !ENDED.has(before.status) && !NOT_STARTED.has(before.status)
const isLive = !ENDED.has(after.status) && !NOT_STARTED.has(after.status)
if (!wasLive) {
return isLive ? [{ action: 'billing.plan_started', at, detail: terms }] : []
}
if (!isLive) {
return ENDED.has(after.status) ? [{ action: 'billing.plan_canceled', at, detail: { plan: before!.plan } }] : []
}
const events: BillingAuditEvent[] = []
if (before!.plan !== after.plan || before!.billing_interval !== after.billing_interval) {
events.push({
action: 'billing.plan_changed',
at,
detail: { plan: after.plan, interval: after.billing_interval, previousPlan: before!.plan, previousInterval: before!.billing_interval }
})
}
if (before!.seats !== after.seats) {
events.push({ action: 'billing.seats_changed', at, detail: { seats: after.seats, previousSeats: before!.seats } })
}
if (before!.cancel_at_period_end !== after.cancel_at_period_end) {
events.push(
after.cancel_at_period_end === 1
? { action: 'billing.cancel_scheduled', at, ...(after.current_period_end ? { detail: { endsAt: after.current_period_end } } : {}) }
: { action: 'billing.cancel_reverted', at }
)
}
return events
}
+1 -1
View File
@@ -454,7 +454,7 @@ export async function handleRequest(request: Request, env: Env, ctx: ExecutionCo
if (!body.success) {
return json({ error: 'invalid_request' }, 400)
}
const outcome = await updateOverage(env, stripe, caller, body.data, now)
const outcome = await updateOverage(env, stripe, caller, body.data, now, deps.fetch)
if (!outcome.ok) {
return json({ error: outcome.error }, outcome.status)
}
+21 -5
View File
@@ -5,6 +5,7 @@
// metering immediately. The item is removed once it has nothing left to invoice (no meter report
// this Stripe period): at once if none was sent, otherwise at a sync after the period rolls over,
// so usage already reported is still invoiced and no $0 overage line is left on later invoices.
import { pushBillingAudit, type BillingAuditEvent } from './audit-push'
import { z } from 'zod'
import type { BillingCaller } from './auth'
import { OPEN_STATUSES } from './checkout'
@@ -36,7 +37,8 @@ export async function updateOverage(
stripe: StripeClient,
caller: BillingCaller,
request: z.infer<typeof OverageRequestSchema>,
now: number
now: number,
fetchImpl: typeof fetch = fetch
): Promise<OverageOutcome> {
if (!caller.canManage) {
return { ok: false, status: 403, error: 'billing_admin_required' }
@@ -56,11 +58,25 @@ export async function updateOverage(
}
}
// An omitted cap keeps the current one; null restores the plan default.
const spendCapCents =
request.spendCapCents === undefined
? (await accountSettings(env.BILLING_DB, caller.accountId)).spend_cap_cents
: request.spendCapCents
const previous = await accountSettings(env.BILLING_DB, caller.accountId)
const spendCapCents = request.spendCapCents === undefined ? previous.spend_cap_cents : request.spendCapCents
await setOverageSettings(env.BILLING_DB, caller.accountId, { optIn: request.enabled, spendCapCents }, now)
// The team's audit log: who turned overage on or off, or changed the cap. Only real changes.
const wasEnabled = previous.overage_opt_in === 1
const audit: BillingAuditEvent[] = []
if (wasEnabled !== request.enabled) {
audit.push({ action: request.enabled ? 'billing.overage_enabled' : 'billing.overage_disabled', at: now, actorUserId: caller.userId })
}
if (previous.spend_cap_cents !== spendCapCents) {
audit.push({
action: 'billing.spend_cap_changed',
at: now,
actorUserId: caller.userId,
// null is the plan's default cap.
detail: { spendCapCents, previousSpendCapCents: previous.spend_cap_cents }
})
}
await pushBillingAudit(env, caller.accountId, audit, fetchImpl)
if (!request.enabled) {
// Best effort: the opt-out already stops metering; a failed removal is retried by the next sync.
await retireOverageItem(env, stripe, subscription, now).catch((error: unknown) => {
+12
View File
@@ -15,6 +15,7 @@ import {
} from './store'
import { entitlementsMode } from './entitlements-mode'
import { loadCatalog } from './catalog'
import { pushBillingAudit, subscriptionAuditEvents } from './audit-push'
import { projectSubscription } from './grants'
import { catalogIfApplied, isOveragePrice, recordSubscriptionPrices, seatPriceMatch, syncCatalogPrices, type PriceMatch } from './catalog-sync'
import type { StripePrice } from './stripe'
@@ -160,6 +161,16 @@ export async function syncSubscription(env: Env, stripe: StripeClient, subscript
seat_price_id: seatPrice?.id ?? null
})
await projectGrants(env, subscription.id, now)
// The team's audit log: plan started, changed or canceled, seats, a scheduled cancellation.
await pushBillingAudit(
env,
accountId,
subscriptionAuditEvents(
known,
{ status: subscription.status, plan: match.plan, billing_interval: match.interval, seats, cancel_at_period_end: subscription.cancel_at_period_end ? 1 : 0, current_period_end: periodEnd * 1000 },
now
)
)
if (overageItemId) {
// An opted-out account's overage item goes once the period it was used in has been invoiced.
const stored = await currentSubscription(db, accountId)
@@ -290,6 +301,7 @@ export async function endCustomerSubscriptions(env: Env, customerId: string, now
async function markCanceled(env: Env, row: SubscriptionRow, now: number): Promise<void> {
await upsertSubscription(env.BILLING_DB, { ...row, status: 'canceled', past_due_since: null, member_count: null, updated_at: now })
await projectGrants(env, row.stripe_subscription_id, now)
await pushBillingAudit(env, row.account_id, subscriptionAuditEvents(row, { ...row, status: 'canceled' }, now))
}
/**
+172
View File
@@ -0,0 +1,172 @@
// AUDIT-AUTH-EVENTS (billing half): plan, seat, cancellation, overage and spend-cap changes
// reach the team's audit log on the sandbox control plane, with who acted when a person did.
import { describe, expect, it } from 'vitest'
import { hasTeamAuditLog, pushBillingAudit, subscriptionAuditEvents } from '../src/audit-push'
import { createHarness } from './harness'
import { checkoutCompleted, stripeEvent } from './stripe-fake'
const CONTROL = 'https://control.test'
const HOUR = 3600
const PERIOD_START = Math.floor(Date.now() / 1000) - 5 * 24 * HOUR
const PERIOD_END = PERIOD_START + 30 * 24 * HOUR
type Pushed = { accountId: string; eventId: string; action: string; at: number; actorUserId?: string; target: { type: string }; detail?: Record<string, unknown> }
/** The control plane over billing's service binding: every audit push, recorded. */
function controlPlane(options: { status?: number } = {}) {
const requests: Array<{ url: string; authorization: string | null; body: { accountId: string; events: Array<Omit<Pushed, 'accountId'>> } }> = []
const binding = {
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input instanceof Request ? input.url : input.toString()
requests.push({ url, authorization: new Headers(init?.headers).get('authorization'), body: JSON.parse(String(init?.body)) })
return Response.json({ recorded: 1, duplicates: 0 }, { status: options.status ?? 200 })
}
}
const events = (): Pushed[] => requests.flatMap((request) => request.body.events.map((event) => ({ ...event, accountId: request.body.accountId })))
return { requests, binding, events }
}
async function team(options: { status?: number } = {}) {
const control = controlPlane(options)
const h = await createHarness({ SANDBOX_CONTROL_URL: CONTROL })
// SAFETY: the Worker only calls fetch on the binding.
h.env.SANDBOX_CONTROL = control.binding as unknown as Fetcher
const token = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner', email: 'a@example.com' })
const auth = { authorization: `Bearer ${token}` }
const accountId = 'org:o1'
await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team', seats: 2 }, headers: auth })
const customer = [...h.stripe.customers.keys()][0]!
const subscription = h.stripe.putSubscription({ customer, accountId, basePrice: 'price_team_month', quantity: 2, periodStart: PERIOD_START, periodEnd: PERIOD_END })
await h.webhook(checkoutCompleted({ customer, subscription: subscription.id, accountId }))
return { h, auth, control, subscription, accountId }
}
describe('subscription changes', () => {
it('records the plan starting, seats changing, a cancellation scheduled and reverted, and the plan ending', async () => {
const { h, control, subscription } = await team()
expect(control.events()).toEqual([
{
accountId: 'org:o1',
eventId: expect.stringMatching(/^billing_[0-9a-f]{40}$/),
action: 'billing.plan_started',
at: expect.any(Number),
target: { type: 'plan' },
detail: { plan: 'team', interval: 'month', seats: 2 }
}
])
// Billing's own bearer, to the one route it may write.
expect(control.requests[0]).toMatchObject({ url: `${CONTROL}/v1/internal/audit-events`, authorization: `Bearer ${h.env.CONTROL_PLANE_TOKEN.split(',')[0]}` })
subscription.items.data[0]!.quantity = 5
await h.webhook(stripeEvent('customer.subscription.updated', subscription))
subscription.cancel_at_period_end = true
await h.webhook(stripeEvent('customer.subscription.updated', subscription))
subscription.cancel_at_period_end = false
await h.webhook(stripeEvent('customer.subscription.updated', subscription))
subscription.status = 'canceled'
await h.webhook(stripeEvent('customer.subscription.deleted', subscription))
expect(control.events().map((event) => [event.action, event.detail ?? null])).toEqual([
['billing.plan_started', { plan: 'team', interval: 'month', seats: 2 }],
['billing.seats_changed', { seats: 5, previousSeats: 2 }],
['billing.cancel_scheduled', { endsAt: PERIOD_END * 1000 }],
['billing.cancel_reverted', null],
['billing.plan_canceled', { plan: 'team' }]
])
// Stripe reported these: no person is named.
expect(control.events().every((event) => event.actorUserId === undefined)).toBe(true)
})
it('a replayed or unchanged Stripe event records nothing more', async () => {
const { h, control, subscription } = await team()
await h.webhook(stripeEvent('customer.subscription.updated', subscription))
await h.webhook(stripeEvent('customer.subscription.updated', subscription))
await h.webhook(stripeEvent('invoice.paid', { id: 'in_1', customer: subscription.customer, subscription: subscription.id }))
expect(control.events().map((event) => event.action)).toEqual(['billing.plan_started'])
})
it('works out what changed from the stored row alone', () => {
const live = { status: 'active', plan: 'team', billing_interval: 'month', seats: 2, cancel_at_period_end: 0 } as const
const actions = (before: Parameters<typeof subscriptionAuditEvents>[0], after: Parameters<typeof subscriptionAuditEvents>[1]) =>
subscriptionAuditEvents(before, after, 1).map((event) => event.action)
expect(actions(null, live)).toEqual(['billing.plan_started'])
// Checkout made it but the first payment has not gone through: not a plan yet.
expect(actions(null, { ...live, status: 'incomplete' })).toEqual([])
expect(actions({ ...live, status: 'incomplete' }, live)).toEqual(['billing.plan_started'])
expect(actions({ ...live, status: 'incomplete' }, { ...live, status: 'incomplete_expired' })).toEqual([])
// A failed payment is not a plan change; it keeps its own notices.
expect(actions(live, { ...live, status: 'past_due' })).toEqual([])
expect(actions(live, { ...live, billing_interval: 'year', seats: 3 })).toEqual(['billing.plan_changed', 'billing.seats_changed'])
expect(subscriptionAuditEvents(live, { ...live, billing_interval: 'year' }, 1)[0]!.detail).toEqual({ plan: 'team', interval: 'year', previousPlan: 'team', previousInterval: 'month' })
expect(actions(live, { ...live, status: 'canceled' })).toEqual(['billing.plan_canceled'])
expect(actions({ ...live, status: 'canceled' }, { ...live, status: 'canceled' })).toEqual([])
// A team that comes back after canceling starts a plan again.
expect(actions({ ...live, status: 'canceled' }, live)).toEqual(['billing.plan_started'])
})
})
describe('overage and the spend cap', () => {
it('records who turned overage on or off and who changed the cap, and only real changes', async () => {
const { h, auth, control } = await team()
const overage = (body: unknown) => h.call('POST', '/v1/billing/overage', { body, headers: auth })
const mine = () => control.events().filter((event) => event.action !== 'billing.plan_started' && event.action !== 'billing.seats_changed')
expect((await overage({ enabled: true, spendCapCents: 4000 })).status).toBe(200)
expect(mine().map((event) => [event.action, event.actorUserId, event.detail ?? null])).toEqual([
['billing.overage_enabled', 'u1', null],
['billing.spend_cap_changed', 'u1', { spendCapCents: 4000, previousSpendCapCents: null }]
])
// The same settings again: nothing changed, nothing recorded.
expect((await overage({ enabled: true, spendCapCents: 4000 })).status).toBe(200)
expect((await overage({ enabled: true })).status).toBe(200)
expect(mine()).toHaveLength(2)
expect((await overage({ enabled: true, spendCapCents: 9000 })).status).toBe(200)
expect((await overage({ enabled: false })).status).toBe(200)
expect(mine().slice(2).map((event) => [event.action, event.detail ?? null])).toEqual([
['billing.spend_cap_changed', { spendCapCents: 9000, previousSpendCapCents: 4000 }],
['billing.overage_disabled', null]
])
})
it('records nothing for a change that was refused', async () => {
const { h, control } = await team()
const member = await h.desktopToken({ sub: 'u2', org: 'o1', orgRole: 'member', email: 'b@example.com' })
const refused = await h.call('POST', '/v1/billing/overage', { body: { enabled: true }, headers: { authorization: `Bearer ${member}` } })
expect(refused.status).toBe(403)
expect(control.events().filter((event) => event.action.startsWith('billing.overage'))).toEqual([])
})
})
describe('the push itself', () => {
it('a control plane that refuses or is down never fails the billing change', async () => {
const { h, auth, control } = await team({ status: 500 })
const changed = await h.call('POST', '/v1/billing/overage', { body: { enabled: true }, headers: auth })
expect(changed.status).toBe(200)
expect(changed.body.overage.optedIn).toBe(true)
expect(control.requests.length).toBeGreaterThan(0)
})
it('a personal account has no team log: nothing is sent', async () => {
const control = controlPlane()
// SAFETY: the push only calls fetch on the binding.
const env = { CONTROL_PLANE_TOKEN: 'c'.repeat(40), SANDBOX_CONTROL_URL: CONTROL, SANDBOX_CONTROL: control.binding as unknown as Fetcher }
expect(await pushBillingAudit(env, 'user:u1', [{ action: 'billing.plan_started', at: 1 }])).toBe(false)
expect(control.requests).toEqual([])
expect(await pushBillingAudit(env, 'org:o1', [{ action: 'billing.plan_started', at: 1 }])).toBe(true)
expect(control.requests).toHaveLength(1)
expect([hasTeamAuditLog('org:o1'), hasTeamAuditLog('dev:org:x'), hasTeamAuditLog('user:u1'), hasTeamAuditLog('dev:alice')]).toEqual([true, true, false, false])
})
it('sends nothing when the control plane is not configured, and never a price, customer or card', async () => {
expect(await pushBillingAudit({ CONTROL_PLANE_TOKEN: 'c'.repeat(40) }, 'org:o1', [{ action: 'billing.plan_started', at: 1 }])).toBe(false)
const { h, auth, control, subscription } = await team()
await h.call('POST', '/v1/billing/overage', { body: { enabled: true, spendCapCents: 5000 }, headers: auth })
const sent = JSON.stringify(control.requests.map((request) => request.body))
expect(sent).not.toMatch(/price_|cus_|sub_test|si_test|card/)
expect(sent).not.toContain(subscription.id)
// Keys the control plane refuses as credential-like are never used.
for (const event of control.events()) {
for (const key of Object.keys(event.detail ?? {})) {
expect(key).not.toMatch(/secret|token|password|passphrase|hint|value|ciphertext|credential|private|code/i)
}
}
})
})
@@ -44,6 +44,23 @@ async function optedInTeam() {
return { h, auth, subscription, overage, prices, use, stored }
}
describe('overage on, off and on again (the settings card)', () => {
it('reads back off after turning off, and on with a cap after turning on again', async () => {
const { h, auth, overage } = await optedInTeam()
const read = async () => (await h.call('GET', '/v1/billing/entitlements', { headers: auth })).body.overage
expect((await overage({ enabled: true, spendCapCents: 4000 })).status).toBe(200)
expect(await read()).toMatchObject({ optedIn: true, spendCapCents: 4000 })
expect((await overage({ enabled: false })).status).toBe(200)
// Off keeps the cap the admin chose, so turning on again without one restores it.
expect(await read()).toMatchObject({ optedIn: false, active: false, spendCapCents: 4000 })
expect((await overage({ enabled: true, spendCapCents: 2500 })).status).toBe(200)
expect(await read()).toMatchObject({ optedIn: true, spendCapCents: 2500 })
expect((await overage({ enabled: false })).status).toBe(200)
expect((await overage({ enabled: true })).status).toBe(200)
expect(await read()).toMatchObject({ optedIn: true, spendCapCents: 2500 })
})
})
describe('overage opt-out', () => {
it('removes the unused metered item at once, and opting back in adds it again', async () => {
const { h, overage, prices, use, stored } = await optedInTeam()
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -48,7 +48,7 @@ nudge_once() {
local mark="${ORCA_GIT_NUDGE_DIR:-/dev/shm}/orca-git-nudge-$(id -u)"
[ -e "$mark" ] && return 0
: >"$mark" 2>/dev/null || return 0
printf '%s\n' "orca: This commit uses Orca Cloud's name. Connect your GitHub in Settings › Account to use yours." >&2
printf '%s\n' "orca: This commit uses Orca Cloud's name. Connect your GitHub in Settings → Account to use yours." >&2
}
# PUSH-RAW-PROMPT: a push, pull or fetch to github.com that no credential covers (neither the person's
# connected GitHub nor the App installation) must never fall through to git's own "Username for
@@ -91,7 +91,7 @@ github_remote_slug() {
no_access_line() {
local owner="${github_slug%%/*}" verb='push to'
[ "$sub" = push ] || verb='read'
printf '%s\n' "orca: Orca Cloud can't $verb $github_slug. Install the Orca GitHub App on $owner, or connect your GitHub in Settings › Account." >&2
printf '%s\n' "orca: Orca Cloud can't $verb $github_slug. Install the Orca GitHub App on $owner, or connect your GitHub in Settings → Account." >&2
}
# GitHub's "not found" is a typo'd name or a private repo it won't confirm: say which half fits whom.
not_found_line() {
@@ -4,14 +4,20 @@
// POST /v1/internal/audit-events {accountId, events: [{eventId, action, at?, actorUserId?, target?, detail?}]}
// -> {recorded, duplicates}
//
// Auth's token (MEMBER_REMOVED_TOKEN) or the operator token. The route only appends: it can't
// read, change or delete a row. `eventId` makes a retry a no-op. Actions are limited to the
// Auth's token (MEMBER_REMOVED_TOKEN) writes member, team, sso, scim and mfa events; billing's
// (CONTROL_PLANE_TOKEN) writes billing events and nothing else; the operator token writes any.
// The route only appends: it can't read, change or delete a row. `eventId` makes a retry a no-op. Actions are limited to the
// families those services own, so a pushed event can never pass for an API key or secret change.
// Member removal and role changes are not sent here: they have their own pushes, which also do
// the cleanup, and record the event themselves (actorUserId on those bodies).
import { z } from 'zod'
const EVENT_FAMILIES = ['member', 'team', 'sso', 'scim', 'mfa', 'billing'] as const
export type AuditEventFamily = (typeof EVENT_FAMILIES)[number]
/** What each sender may write, so one service's bearer can't forge another's events. */
export const AUTH_EVENT_FAMILIES: readonly AuditEventFamily[] = ['member', 'team', 'sso', 'scim', 'mfa']
export const BILLING_EVENT_FAMILIES: readonly AuditEventFamily[] = ['billing']
export const ALL_EVENT_FAMILIES: readonly AuditEventFamily[] = EVENT_FAMILIES
const ACTION = new RegExp(`^(${EVENT_FAMILIES.join('|')})\\.[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)?$`)
/** Recorded by this service from the member pushes; a second copy from outside would double them. */
const OWNED_HERE = new Set(['member.removed', 'member.left', 'member.role_changed', 'member.role_synced'])
@@ -53,9 +59,17 @@ export type AuditIngestBody = z.infer<typeof AuditIngestSchema>
export class AuditIngestError extends Error {}
/** Appends the events; one that was already recorded (same eventId) is skipped. */
export async function ingestAuditEvents(db: D1Database, body: AuditIngestBody, now: number): Promise<{ recorded: number; duplicates: number }> {
export async function ingestAuditEvents(
db: D1Database,
body: AuditIngestBody,
now: number,
families: readonly AuditEventFamily[] = ALL_EVENT_FAMILIES
): Promise<{ recorded: number; duplicates: number }> {
const statements: D1PreparedStatement[] = []
for (const event of body.events) {
if (!(families as readonly string[]).includes(event.action.split('.')[0]!)) {
throw new AuditIngestError(`action_not_allowed_for_sender:${event.action}`)
}
if (OWNED_HERE.has(event.action)) {
throw new AuditIngestError(`action_recorded_by_control_plane:${event.action}`)
}
@@ -4,6 +4,7 @@
// enter the VM or Vercel's policy store.
import { createRemoteJWKSet, decodeJwt, jwtVerify, type JWTVerifyGetKey } from 'jose'
import type { SandboxRow } from './store'
import { secretLog } from './secret-log'
const VERCEL_OIDC_HOSTNAME = 'oidc.vercel.com'
/** Hosts whose clients are Node (undici); git/curl hosts use transform rules instead. Model hosts are per session. */
@@ -144,7 +145,7 @@ export async function handleEgressProxy(request: Request, deps: EgressProxyDeps)
}
if (typeof credential !== 'string') {
if (credential.answer) {
console.log(JSON.stringify({ event: 'egress_denied', sandboxId: row.id, host, reason: credential.deny }))
secretLog('egress_denied', { sandboxId: row.id, host, reason: credential.deny })
return new Response(credential.answer.body, {
status: credential.answer.status,
headers: { 'content-type': credential.answer.contentType }
@@ -174,9 +175,7 @@ export async function handleEgressProxy(request: Request, deps: EgressProxyDeps)
redirect: 'manual'
})
// Names only: which sandbox, host and credential were swapped, and what the provider answered.
console.log(
JSON.stringify({ event: 'egress_swap', sandboxId: row.id, host, credential: route.label ?? route.header, status: upstream.status })
)
secretLog('egress_swap', { sandboxId: row.id, host, credential: route.label ?? route.header, status: upstream.status }, [credential])
route.onUpstreamStatus?.(upstream.status, { method: request.method, path })
if (route.keepResponseFields) {
return onlyFields(upstream, route.keepResponseFields)
@@ -9,6 +9,7 @@ import { refreshSessionEnv } from './sandbox-vm-commands'
import { normalizeRepo, type SecretChange } from './secrets'
import { forgetProviderEnv, sessionSecretEnv } from './session-env'
import { transition, type SandboxRow } from './store'
import { secretLog } from './secret-log'
/** Running sandboxes whose sessions read secrets of this owner. */
export async function sandboxesForSecretOwner(db: D1Database, change: SecretChange): Promise<SandboxRow[]> {
@@ -50,7 +51,7 @@ export async function envChangedSinceBoot(deps: LifecycleDeps, row: SandboxRow,
const { env } = await sessionSecretEnv(deps, row)
const same = Object.keys(env).length === Object.keys(bootEnv).length && Object.entries(env).every(([k, v]) => bootEnv[k] === v)
if (!same) {
console.log(JSON.stringify({ event: 'session_env_changed_during_boot', sandboxId: row.id }))
secretLog('session_env_changed_during_boot', { sandboxId: row.id })
}
return !same
}
@@ -89,11 +90,11 @@ export async function refreshSandboxEnv(deps: LifecycleDeps, row: SandboxRow): P
try {
const applied = await refreshSessionEnv(deps.provider, current.session_id!, secrets.env, generation)
// Counts only.
console.log(JSON.stringify({ event: 'session_env_refreshed', sandboxId: row.id, applied, names: Object.keys(secrets.env).length }))
secretLog('session_env_refreshed', { sandboxId: row.id, applied, names: Object.keys(secrets.env).length })
return applied
} catch (error) {
if (error instanceof ProviderError) {
console.log(JSON.stringify({ event: 'session_env_refresh_failed', sandboxId: row.id, code: error.code }))
secretLog('session_env_refresh_failed', { sandboxId: row.id, code: error.code })
return false
}
throw error
@@ -119,7 +120,7 @@ export async function refreshAfterSecretChange(deps: LifecycleDeps, changes: rea
refreshed++
}
} catch {
console.log(JSON.stringify({ event: 'session_env_refresh_failed', sandboxId: row.id }))
secretLog('session_env_refresh_failed', { sandboxId: row.id })
}
}
}
+15
View File
@@ -99,6 +99,21 @@ export type Env = {
SECRETS_KEK?: string
SECRETS_KEK_ID?: string
SECRETS_KEK_RETIRED?: string
/**
* `kms`: the KEKs are held encrypted by a Google Cloud KMS key and unwrapped once per isolate
* (src/secrets-kms.ts). Unset (default): SECRETS_KEK / SECRETS_KEK_RETIRED above, unchanged.
* With `kms`, SECRETS_KEK and SECRETS_KEK_RETIRED are ignored, and a KMS failure answers 503
* `secrets_unavailable` (sessions do not start without their secrets).
* SECRETS_KMS_KEY projects/<p>/locations/<l>/keyRings/<r>/cryptoKeys/<k>
* SECRETS_KMS_SERVICE_ACCOUNT (secret) service-account JSON allowed to decrypt with that key only
* SECRETS_KEK_WRAPPED base64 KMS ciphertext of the active KEK's 32 bytes, AAD `orca-secrets-kek:<id>`
* SECRETS_KEK_RETIRED_WRAPPED JSON {"<id>":"<base64 KMS ciphertext>"} after a rotation
*/
SECRETS_KEK_SOURCE?: string
SECRETS_KMS_KEY?: string
SECRETS_KMS_SERVICE_ACCOUNT?: string
SECRETS_KEK_WRAPPED?: string
SECRETS_KEK_RETIRED_WRAPPED?: string
/**
* `v1` (default) | `v2`: the row format new secrets are sealed in (src/secrets-crypto.ts). With
* v2 a KEK rotation re-wraps data keys only. Set it only once every Worker version that reads this
+32 -10
View File
@@ -119,6 +119,7 @@ import { DEFAULT_RETENTION, type RetentionConfig } from './retention'
import { reconcile } from './reconcile'
import { egressAccountRoutes, egressSecretHosts, placeholderValues, secretPlaceholder } from './secret-egress'
import { SecretError, handleSecretsRoute, keyringFor, type SecretChange } from './secrets'
import { SecretsKmsError } from './secrets-kms'
import { refreshAfterSecretChange, refreshSandboxEnv } from './env-refresh'
import { ACCOUNT_PROVIDERS, accountProvider, isSharedWorkspace } from './account-providers'
import { handleAccountUnauthorized, verifyAccountCredential } from './agent-credential-checks'
@@ -156,7 +157,7 @@ import { forgetShareLink, handleShareGateway } from './share-gateway'
import { handleShareRoute, revokeSandboxShares, sharePathMode, shareSecret, shareSecrets } from './share-links'
import { SshKeyRequestSchema, updateSshKey } from './ssh-access'
import { audit, auditSession } from './api/audit'
import { AuditIngestError, AuditIngestSchema, ingestAuditEvents } from './api/audit-ingest'
import { ALL_EVENT_FAMILIES, AUTH_EVENT_FAMILIES, AuditIngestError, AuditIngestSchema, BILLING_EVENT_FAMILIES, ingestAuditEvents } from './api/audit-ingest'
import { getSandbox, getSandboxByProviderName, listAccountSandboxes, markUserSlept, type SandboxRow } from './store'
import { createVercelSandboxApi } from './vercel-sandbox-api'
import { advanceAllBuilds, tickBuild, unfinishedBuilds, type BuildDeps } from './environment-builds'
@@ -880,7 +881,7 @@ export async function lifecycleDeps(env: Env, deps: Deps): Promise<LifecycleDeps
provider,
entitlements: deps.entitlements ?? withDevAccountBypass(env, createEntitlementClient(env)),
github: deps.github ?? githubMinter(env),
secrets: await secretsDeps(env),
...(await secretsState(env)),
memberIdentity: await memberIdentityFor(env, deps),
now: deps.now ?? Date.now,
sleep: deps.sleep,
@@ -1113,17 +1114,22 @@ export function secretsEgressMode(env: Pick<Env, 'SECRETS_EGRESS_PLACEHOLDERS' |
return { egressPlaceholders, egressPlaceholdersOff: off }
}
async function secretsDeps(env: Env): Promise<LifecycleDeps['secrets']> {
async function secretsState(env: Env): Promise<Pick<LifecycleDeps, 'secrets' | 'secretsUnavailable'>> {
const keyring = keyringFor(env)
if (!keyring) {
return undefined
return {}
}
try {
return { keyring: await keyring, ...secretsEgressMode(env), accountProviders: providerEnabled(env) }
} catch {
return { secrets: { keyring: await keyring, ...secretsEgressMode(env), accountProviders: providerEnabled(env) } }
} catch (error) {
if (error instanceof SecretsKmsError) {
// KMS holds the keys and is not handing them over. Routes that need no secrets keep working;
// a session start and the secrets routes answer 503 (keyringFor logged the reason).
return { secretsUnavailable: error.reason }
}
// A malformed KEK must not block sandbox starts; the secrets routes surface it as 503.
console.log(JSON.stringify({ event: 'secrets_kek_invalid' }))
return undefined
return {}
}
}
@@ -1255,7 +1261,9 @@ async function routeRequest(request: Request, env: Env, deps: Deps): Promise<Res
const memberPush =
request.method === 'POST' && (url.pathname === '/v1/internal/org-members/removed' || url.pathname === '/v1/internal/members/role-changed')
const authOnly = (memberPush || teamDelete !== null || auditIngest) && (await isOperator(request, env.MEMBER_REMOVED_TOKEN))
if (!authOnly && !(await isOperator(request, env.OPERATOR_TOKEN))) {
// Billing's bearer (CONTROL_PLANE_TOKEN) reaches this one route, for billing events only.
const billingOnly = !authOnly && auditIngest && (await isOperator(request, env.CONTROL_PLANE_TOKEN))
if (!authOnly && !billingOnly && !(await isOperator(request, env.OPERATOR_TOKEN))) {
return fail('not_found', 404)
}
if (auditIngest) {
@@ -1265,7 +1273,8 @@ async function routeRequest(request: Request, env: Env, deps: Deps): Promise<Res
return fail('invalid_request', 400)
}
try {
return json(await ingestAuditEvents(env.SANDBOX_DB, body.data, (deps.now ?? Date.now)()))
const families = authOnly ? AUTH_EVENT_FAMILIES : billingOnly ? BILLING_EVENT_FAMILIES : ALL_EVENT_FAMILIES
return json(await ingestAuditEvents(env.SANDBOX_DB, body.data, (deps.now ?? Date.now)(), families))
} catch (error) {
if (error instanceof AuditIngestError) {
return fail('invalid_request', 400, error.message)
@@ -1552,7 +1561,10 @@ async function routeRequest(request: Request, env: Env, deps: Deps): Promise<Res
// ORG-TOKEN-STALE: minted before this member's role changed; the desktop re-mints and retries.
return fail('unauthorized', 401, 'stale_token')
}
const secrets = await secretsDeps(env)
const { secrets, secretsUnavailable } = await secretsState(env)
if (secretsUnavailable) {
return secretsUnavailableResponse()
}
if (!secrets) {
return fail('not_configured', 503)
}
@@ -2315,6 +2327,13 @@ async function githubReturn(env: Env, deps: Deps, url: URL): Promise<Response> {
}
}
/** The KMS-held secret keys cannot be fetched right now (SECRETS_KEK_SOURCE=kms). Retryable. */
function secretsUnavailableResponse(): Response {
const response = fail('secrets_unavailable', 503)
response.headers.set('retry-after', '30')
return response
}
function errorResponse(error: unknown): Response {
if (error instanceof SyntaxError) {
return fail('invalid_json', 400)
@@ -2354,6 +2373,9 @@ function errorResponse(error: unknown): Response {
if (error instanceof BillingUnavailableError) {
return fail('billing_unavailable', 503)
}
if (error instanceof SecretsKmsError) {
return secretsUnavailableResponse()
}
// Provider and VM failures are summarized; their bodies never reach the desktop.
if (error instanceof ProviderError || error instanceof VmCommandError) {
return fail('provider_unavailable', 502, error instanceof VmCommandError ? error.code : undefined)
@@ -24,6 +24,7 @@ import { ProviderError, type ProviderSandboxState, type SandboxProviderApi } fro
import { egressAccountRoutes, egressSecretHosts, placeholderValues, secretPlaceholder } from './secret-egress'
import { egressAllowHosts, sessionSecretEnv, type SessionSecrets } from './session-env'
import type { Keyring } from './secrets-crypto'
import type { SecretsKmsReason } from './secrets-kms'
import { captureReadSnapshot, deleteReadSnapshot } from './read-snapshots'
import {
ORCAD_PORT,
@@ -89,6 +90,8 @@ export type LifecycleDeps = {
* egressPlaceholders: every account (`true`) or only the listed account ids (staged rollout);
* egressPlaceholdersOff: per-account kill switch that wins over both.
*/
/** Set while the KMS-held keys cannot be fetched: a session start fails instead of running without its secrets. */
secretsUnavailable?: SecretsKmsReason
secrets?: {
keyring: Keyring
egressPlaceholders: boolean | ReadonlySet<string>
+5 -1
View File
@@ -23,6 +23,7 @@ import { drainAll, housekeeping } from './retention'
import { memberChangeTime, recordMemberChange } from './member-changes'
import { memberEventActor, memberEventTarget, removeMember } from './member-removal'
import type { Keyring } from './secrets-crypto'
import { SecretsKmsError } from './secrets-kms'
import { kekStatus, rewrapBatch, RewrapError, REWRAP_MAX_LIMIT } from './secrets-rewrap'
import { fairUseReport, FAIR_USE_WINDOW_DAYS, sweepSessionCpu } from './session-cpu'
import { listByStatus } from './store'
@@ -174,7 +175,10 @@ export async function handleOperator(
let keyring: Keyring | null
try {
keyring = (await deps.keyring?.()) ?? null
} catch {
} catch (error) {
if (error instanceof SecretsKmsError) {
return Response.json({ error: 'secrets_unavailable', reason: error.reason }, { status: 503, headers: noStore })
}
// A malformed SECRETS_KEK* value. The cause is not echoed: it could quote key material.
return Response.json({ error: 'secrets_misconfigured' }, { status: 503, headers: noStore })
}
@@ -0,0 +1,87 @@
// SEC-6c: the only log sink for code that handles secret values (secrets.ts, session-env.ts,
// env-refresh.ts, egress-proxy.ts, secret-events.ts). Lines are structured JSON with names and
// counts by design; this layer is the backstop when a field slips through anyway, for example an
// error message or upstream body that quotes a key. Two passes over the serialized line:
// 1. exact values the caller is handling right now (`known`), replaced wherever they appear;
// 2. credential shapes (provider API keys, GitHub tokens, AWS key ids, JWTs, bearer headers).
// test/secret-log.test.ts fails when one of these files calls console directly, and when a
// planted value reaches the sink.
const REDACTED = '[redacted]'
/** Known credential shapes. Kept specific so names, ids and counts in log lines survive. */
const PATTERNS: readonly RegExp[] = [
// Anthropic, OpenAI (incl. sk-proj-), OpenRouter and other `sk-` keys.
/\bsk-[A-Za-z0-9_-]{16,}/g,
// GitHub tokens: classic, OAuth, app installation/user, refresh and fine-grained PATs.
/\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/g,
// AWS access key ids.
/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
// Google API keys.
/\bAIza[A-Za-z0-9_-]{30,}/g,
// Slack tokens.
/\bxox[abposr]-[A-Za-z0-9-]{10,}/g,
// Vercel AI Gateway keys.
/\bvck_[A-Za-z0-9]{20,}/g,
// JWTs (session tokens, OIDC tokens).
/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
// Authorization header values.
/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{12,}/gi
]
/** Shorter values are not matched by exact text: they would erase ordinary words and ids. */
const MIN_KNOWN_LENGTH = 8
function escapeJson(value: string): string {
// The value as it appears inside a JSON string (quotes, backslashes, control characters escaped).
return JSON.stringify(value).slice(1, -1)
}
export function redactLine(line: string, known: Iterable<string> = []): string {
let out = line
const values = [...new Set(known)].filter((value) => value.length >= MIN_KNOWN_LENGTH)
// Longest first, so a value that contains another is removed whole.
values.sort((a, b) => b.length - a.length)
for (const value of values) {
for (const form of new Set([value, escapeJson(value)])) {
out = out.split(form).join(REDACTED)
}
}
for (const pattern of PATTERNS) {
out = out.replace(pattern, (match, scheme?: string) =>
typeof scheme === 'string' && /^(bearer|basic)$/i.test(scheme) ? `${scheme} ${REDACTED}` : REDACTED
)
}
return out
}
/** Error-like values become their name and message (redacted with the rest), never their stack. */
function serializable(value: unknown): unknown {
if (value instanceof Error) {
return { name: value.name, message: value.message }
}
return value
}
export type SecretLogSink = (line: string) => void
let sink: SecretLogSink = (line) => console.log(line)
/** Tests only: capture lines exactly as they would reach Workers logs. Returns a restore function. */
export function setSecretLogSink(next: SecretLogSink): () => void {
const previous = sink
sink = next
return () => {
sink = previous
}
}
/** One structured log line. `known`: values in scope at the call site that must never be written. */
export function secretLog(event: string, fields: Record<string, unknown> = {}, known: Iterable<string> = []): void {
let line: string
try {
line = JSON.stringify({ event, ...fields }, (_key, value: unknown) => serializable(value))
} catch {
line = JSON.stringify({ event, unserializable: true })
}
sink(redactLine(line, known))
}
@@ -0,0 +1,143 @@
// Google Cloud KMS unwrap of the secret store's KEKs (SECRETS_KEK_SOURCE=kms; off by default).
// The Worker then holds no plaintext KEK in its config: it holds each KEK encrypted by a KMS key
// (SECRETS_KEK_WRAPPED) and a service account that may only decrypt with that key. keyringFor
// (secrets.ts) unwraps once per isolate and keeps the imported keys in memory.
// Each wrapped KEK is bound to its key id through KMS additional authenticated data, so a blob
// deployed under the wrong id fails to unwrap instead of sealing rows under a mislabelled key.
// Nothing from a KMS or token response is logged or put in an error: only a reason and a status.
import { SignJWT, importPKCS8 } from 'jose'
import { z } from 'zod'
export type SecretsKmsReason =
/** SECRETS_KEK_SOURCE=kms but a KMS setting is missing or malformed. */
| 'config'
/** Google refused the service account or its permission on the key (401/403). */
| 'denied'
/** KMS rejected the wrapped KEK: wrong key, wrong KEK id, or a corrupted blob (400/404). */
| 'invalid'
/** KMS or the token endpoint could not be reached, timed out or answered 5xx. */
| 'unavailable'
/** The secret store cannot get its keys right now. Callers answer 503; they never fall back to another key. */
export class SecretsKmsError extends Error {
constructor(
readonly reason: SecretsKmsReason,
readonly status?: number
) {
super(`secrets_kms_${reason}`)
this.name = 'SecretsKmsError'
}
}
const KEY_NAME = /^projects\/[a-z][a-z0-9-]{4,28}[a-z0-9]\/locations\/[a-z0-9-]{1,40}\/keyRings\/[A-Za-z0-9_-]{1,63}\/cryptoKeys\/[A-Za-z0-9_-]{1,63}$/
// Fixed endpoints: the service-account file's own token_uri is not trusted.
const TOKEN_URL = 'https://oauth2.googleapis.com/token'
const KMS_URL = 'https://cloudkms.googleapis.com/v1'
const SCOPE = 'https://www.googleapis.com/auth/cloudkms'
const TIMEOUT_MS = 5000
const ATTEMPTS = 2
const ServiceAccount = z.looseObject({ client_email: z.string().min(3).max(320), private_key: z.string().min(100) })
const TokenResponse = z.looseObject({ access_token: z.string().min(1), expires_in: z.number().positive().optional() })
const DecryptResponse = z.looseObject({ plaintext: z.string().min(1) })
/** The AAD a KEK is wrapped with (`gcloud kms encrypt --additional-authenticated-data-file`). */
export function kekWrapAad(kekId: string): string {
return `orca-secrets-kek:${kekId}`
}
export type KekUnwrapper = (wrapped: string, kekId: string) => Promise<string>
/**
* Returns `unwrap(wrapped, kekId)`: the base64 KEK inside a base64 KMS ciphertext. Throws
* SecretsKmsError; a 5xx or network failure is retried once.
*/
export function createKekUnwrapper(config: { keyName: string; serviceAccountJson: string; fetch?: typeof fetch; now?: () => number }): KekUnwrapper {
const doFetch = config.fetch ?? fetch
const now = config.now ?? Date.now
if (!KEY_NAME.test(config.keyName)) {
throw new SecretsKmsError('config')
}
let account: z.infer<typeof ServiceAccount>
try {
account = ServiceAccount.parse(JSON.parse(config.serviceAccountJson))
} catch {
// The parse error could quote the credential; it is dropped.
throw new SecretsKmsError('config')
}
let signingKey: CryptoKey | null = null
let token: { value: string; expiresAt: number } | null = null
async function send(url: string, init: RequestInit): Promise<Response> {
let failure = new SecretsKmsError('unavailable')
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
let response: Response
try {
response = await doFetch(url, { ...init, signal: AbortSignal.timeout(TIMEOUT_MS) })
} catch {
continue
}
if (response.ok) {
return response
}
await response.body?.cancel().catch(() => {})
if (response.status === 401 || response.status === 403) {
throw new SecretsKmsError('denied', response.status)
}
if (response.status < 500 && response.status !== 429) {
throw new SecretsKmsError('invalid', response.status)
}
failure = new SecretsKmsError('unavailable', response.status)
}
throw failure
}
async function accessToken(): Promise<string> {
if (token && token.expiresAt > now() + 60_000) {
return token.value
}
try {
signingKey ??= await importPKCS8(account.private_key, 'RS256')
} catch {
throw new SecretsKmsError('config')
}
const iat = Math.floor(now() / 1000) - 30
const assertion = await new SignJWT({ scope: SCOPE })
.setProtectedHeader({ alg: 'RS256', typ: 'JWT' })
.setIssuer(account.client_email)
.setAudience(TOKEN_URL)
.setIssuedAt(iat)
.setExpirationTime(iat + 600)
.sign(signingKey)
let response: Response
try {
response = await send(TOKEN_URL, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion }).toString()
})
} catch (error) {
// Google answers a bad or disabled service account with 400 invalid_grant: that is a refusal, not a bad KEK.
throw error instanceof SecretsKmsError && error.reason === 'invalid' ? new SecretsKmsError('denied', error.status) : error
}
const parsed = TokenResponse.safeParse(await response.json().catch(() => null))
if (!parsed.success) {
throw new SecretsKmsError('unavailable')
}
token = { value: parsed.data.access_token, expiresAt: now() + (parsed.data.expires_in ?? 3600) * 1000 }
return token.value
}
return async (wrapped, kekId) => {
const response = await send(`${KMS_URL}/${config.keyName}:decrypt`, {
method: 'POST',
headers: { authorization: `Bearer ${await accessToken()}`, 'content-type': 'application/json' },
body: JSON.stringify({ ciphertext: wrapped, additionalAuthenticatedData: btoa(kekWrapAad(kekId)) })
})
const parsed = DecryptResponse.safeParse(await response.json().catch(() => null))
if (!parsed.success) {
throw new SecretsKmsError('unavailable')
}
return parsed.data.plaintext
}
}
+140 -21
View File
@@ -9,9 +9,12 @@
import { z } from 'zod'
import type { SandboxCaller } from './auth'
import type { Env } from './env'
import { createKeyring, type Keyring, type SecretBinding } from './secrets-crypto'
import { createKeyring, type Keyring, type SealFormat, type SecretBinding } from './secrets-crypto'
import { createKekUnwrapper, SecretsKmsError } from './secrets-kms'
import { AUTH_IAT_BACKDATE_MS } from './member-changes'
import { BASE_URL_NAMES, baseUrlHost } from './provider-env'
import type { SandboxRow } from './store'
import { secretLog } from './secret-log'
import {
activityAccounts,
activityLimit,
@@ -132,10 +135,30 @@ export function checkValue(name: string, value: string): void {
}
}
/**
* SEC-6b: the last 4 characters of values of 16+ characters, stored in plaintext beside the ciphertext.
* Kept deliberately: owners and admins use it to tell which key is saved (as GitHub, Stripe and AWS
* consoles do), it reveals at most 4 characters of a value with at least 12 more, and it is useless for
* calling a provider. Shorter values get no hint. Only people who can write a scope see its hints: team
* members get `hint: null` for Shared and team Repository secrets (`hintVisible`). Encrypting it under
* the row's DEK would add nothing against someone holding the DB and the KEK, who can open the value.
*/
function hintFor(value: string): string | null {
return value.length >= 16 ? value.slice(-4) : null
}
/** Whether `caller` may see last-4 hints in `scope`: the same people who may write it. */
export function hintVisible(caller: SandboxCaller, scope: SecretScope): boolean {
switch (scope) {
case 'personal':
return true
case 'org':
return isOrgAdmin(caller)
case 'repo':
return !caller.orgId || isOrgAdmin(caller)
}
}
const REPO_PART = /^[A-Za-z0-9_.-]{1,100}$/
/** `owner/name`, `github.com/owner/name` or an https GitHub URL -> `github.com/owner/name` (lowercase). */
@@ -165,12 +188,18 @@ function repoOwner(accountId: string, repo: string): string {
return `${accountId}#${repo}`
}
/** The owners whose secrets a sandbox session receives. */
export function ownersForSandbox(row: Pick<SandboxRow, 'account_id' | 'user_id' | 'repo_url'>) {
/**
* The owners whose secrets a sandbox session receives.
* SEC-5: a workspace shared with teammates runs their shells too, so anything in its env is theirs to
* read (and a placeholder is theirs to spend). It gets the team's and the repository's secrets only,
* never its owner's personal ones, the same rule as personal accounts (D13). Fails closed: only a row
* known to be private counts as unshared.
*/
export function ownersForSandbox(row: Pick<SandboxRow, 'account_id' | 'user_id' | 'repo_url' | 'visibility'>) {
const repo = normalizeRepo(row.repo_url)
return {
org: ORG_ACCOUNT.test(row.account_id) ? row.account_id : null,
personal: personalOwner(row.user_id, row.account_id.startsWith('dev:')),
personal: row.visibility === 'private' ? personalOwner(row.user_id, row.account_id.startsWith('dev:')) : null,
repo: repo ? repoOwner(row.account_id, repo) : null
}
}
@@ -329,16 +358,22 @@ export type ResolvedSecrets = { env: Record<string, string>; scopes: Record<stri
export async function resolveSandboxSecrets(
db: D1Database,
keyring: Keyring,
row: Pick<SandboxRow, 'id' | 'account_id' | 'user_id' | 'repo_url'>
row: Pick<SandboxRow, 'id' | 'account_id' | 'user_id' | 'repo_url' | 'visibility'>
): Promise<ResolvedSecrets> {
const owners = ownersForSandbox(row)
const pairs: [SecretScope, string][] = [['personal', owners.personal]]
const pairs: [SecretScope, string][] = []
if (owners.personal) {
pairs.push(['personal', owners.personal])
}
if (owners.org) {
pairs.push(['org', owners.org])
}
if (owners.repo) {
pairs.push(['repo', owners.repo])
}
if (pairs.length === 0) {
return { env: {}, scopes: {}, skipped: 0 }
}
const where = pairs.map(() => '(scope = ? AND owner_id = ?)').join(' OR ')
const { results } = await db
.prepare(`SELECT * FROM secrets WHERE ${where}`)
@@ -368,13 +403,13 @@ export async function resolveSandboxSecrets(
} catch {
skipped++
// Name and scope only: never the value or ciphertext.
console.log(JSON.stringify({ event: 'secret_unreadable', sandboxId: row.id, scope: record.scope, name: record.name }))
secretLog('secret_unreadable', { sandboxId: row.id, scope: record.scope, name: record.name })
continue
}
bytes += record.name.length + value.length
if (bytes > MAX_INJECTED_BYTES) {
skipped++
console.log(JSON.stringify({ event: 'secret_injection_capped', sandboxId: row.id, name: record.name }))
secretLog('secret_injection_capped', { sandboxId: row.id, name: record.name }, [value])
continue
}
env[record.name] = value
@@ -424,37 +459,112 @@ export async function effectiveSecrets(db: D1Database, caller: SandboxCaller, re
const kind = kindOf(name)
const rank = RANK[kind]
const [winner, ...rest] = records.sort((a, b) => rank[b.scope] - rank[a.scope])
const hint = (record: SecretRecord) => (hintVisible(caller, record.scope) ? record.hint : null)
return {
name,
kind,
scope: winner!.scope,
hint: winner!.hint,
hint: hint(winner!),
updatedAt: winner!.updated_at,
shadowed: rest.map((record) => ({ scope: record.scope, hint: record.hint }))
shadowed: rest.map((record) => ({ scope: record.scope, hint: hint(record) }))
}
})
}
let cachedKeyring: { source: string; keyring: Promise<Keyring> } | null = null
let cachedKeyring: { source: string; keyring: Promise<Keyring>; failedAt?: number } | null = null
/** How long a failed KMS unwrap is remembered, so an outage is not one KMS round trip per request. */
export const KMS_RETRY_AFTER_MS = 10_000
/** The Worker's keyring, or null when SECRETS_KEK is not configured. */
export function keyringFor(env: Env): Promise<Keyring> | null {
function parseKeyMap(json: string | undefined): Record<string, string> {
return json ? (JSON.parse(json) as Record<string, string>) : {}
}
/**
* SECRETS_KEK_SOURCE=kms: every KEK comes out of Cloud KMS (src/secrets-kms.ts), once per isolate.
* SECRETS_KEK is never read on this path, so a KMS failure cannot fall back to a plaintext key.
*/
function kmsKeyring(env: Env, sealFormat: SealFormat, fetcher: typeof fetch | undefined): Promise<Keyring> {
const activeId = env.SECRETS_KEK_ID ?? 'k1'
const source = `kms\n${activeId}\n${env.SECRETS_KMS_KEY ?? ''}\n${env.SECRETS_KEK_WRAPPED ?? ''}\n${env.SECRETS_KEK_RETIRED_WRAPPED ?? ''}\n${sealFormat}`
const cached = cachedKeyring
if (cached?.source === source && (cached.failedAt === undefined || Date.now() - cached.failedAt < KMS_RETRY_AFTER_MS)) {
return cached.keyring
}
const load = async (): Promise<Keyring> => {
if (!env.SECRETS_KMS_KEY || !env.SECRETS_KMS_SERVICE_ACCOUNT || !env.SECRETS_KEK_WRAPPED) {
throw new SecretsKmsError('config')
}
let retiredWrapped: Record<string, string>
try {
retiredWrapped = parseKeyMap(env.SECRETS_KEK_RETIRED_WRAPPED)
} catch {
throw new SecretsKmsError('config')
}
const unwrap = createKekUnwrapper({ keyName: env.SECRETS_KMS_KEY, serviceAccountJson: env.SECRETS_KMS_SERVICE_ACCOUNT, fetch: fetcher })
const active = await unwrap(env.SECRETS_KEK_WRAPPED, activeId)
const retired: Record<string, string> = {}
for (const [id, wrapped] of Object.entries(retiredWrapped)) {
if (typeof wrapped !== 'string') {
throw new SecretsKmsError('config')
}
retired[id] = await unwrap(wrapped, id)
}
try {
return await createKeyring({ id: activeId, key: active }, retired, { sealFormat })
} catch {
// KMS returned something that is not a 32-byte key, or an id is malformed.
throw new SecretsKmsError('invalid')
}
}
const entry: NonNullable<typeof cachedKeyring> = { source, keyring: load() }
entry.keyring.catch((error: unknown) => {
entry.failedAt = Date.now()
const kms = error instanceof SecretsKmsError ? error : null
// Reason and HTTP status only: never a response body, a token or a key.
secretLog('secrets_kms_unavailable', { reason: kms?.reason ?? 'error', ...(kms?.status ? { status: kms.status } : {}) })
})
cachedKeyring = entry
return entry.keyring
}
/**
* The Worker's keyring, or null when the secret store is not configured. With SECRETS_KEK_SOURCE=kms
* the promise rejects with SecretsKmsError while KMS cannot hand over the keys.
*/
export function keyringFor(env: Env, fetcher?: typeof fetch): Promise<Keyring> | null {
// Anything but the exact opt-in stays v1, the format every deployed version reads.
const sealFormat = env.SECRETS_SEAL_FORMAT === 'v2' ? 'v2' : 'v1'
if (env.SECRETS_KEK_SOURCE === 'kms') {
return kmsKeyring(env, sealFormat, fetcher)
}
if (!env.SECRETS_KEK) {
return null
}
// Anything but the exact opt-in stays v1, the format every deployed version reads.
const sealFormat = env.SECRETS_SEAL_FORMAT === 'v2' ? 'v2' : 'v1'
const source = `${env.SECRETS_KEK_ID ?? 'k1'}\n${env.SECRETS_KEK}\n${env.SECRETS_KEK_RETIRED ?? ''}\n${sealFormat}`
if (cachedKeyring?.source !== source) {
const retired = env.SECRETS_KEK_RETIRED ? (JSON.parse(env.SECRETS_KEK_RETIRED) as Record<string, string>) : {}
cachedKeyring = {
source,
keyring: createKeyring({ id: env.SECRETS_KEK_ID ?? 'k1', key: env.SECRETS_KEK }, retired, { sealFormat })
keyring: createKeyring({ id: env.SECRETS_KEK_ID ?? 'k1', key: env.SECRETS_KEK }, parseKeyMap(env.SECRETS_KEK_RETIRED), { sealFormat })
}
}
return cachedKeyring.keyring
}
/**
* SEC-6a: admin-only writes (Shared, and a team's Repository secrets) need a token minted within the
* last minute. Auth's role-changed push already makes older tokens stale at once (index.ts,
* tokenMintedBeforeMemberChange); this bounds a demoted admin to under a minute even if that push is
* late or lost. The desktop re-mints and retries once on `stale_token` (hosted-sandbox-request.ts).
*/
export const ADMIN_WRITE_TOKEN_MAX_AGE_MS = 60_000
function tooOldForAdminWrite(caller: SandboxCaller, scope: SecretScope, now: number): boolean {
if (scope === 'personal' || !caller.orgId || !isOrgAdmin(caller) || caller.tokenIssuedAt === undefined) {
return false
}
return now - (caller.tokenIssuedAt + AUTH_IAT_BACKDATE_MS) > ADMIN_WRITE_TOKEN_MAX_AGE_MS
}
const ScopeSchema = z.enum(['org', 'personal', 'repo'])
const PutSchema = z.object({ value: z.string() }).strict()
const MAX_BATCH = 100
@@ -481,6 +591,8 @@ export async function handleSecretsRoute(
onChange: (change: SecretChange) => void = () => {}
): Promise<Response> {
const json = (body: unknown, status = 200) => Response.json(body, { status, headers: { 'cache-control': 'no-store' } })
// Same shape as index.ts ORG-TOKEN-STALE, so the desktop re-mints and retries once.
const staleToken = () => json({ error: 'unauthorized', reason: 'stale_token' }, 401)
const repoQuery = url.searchParams.get('repo')
if (url.pathname === '/v1/secrets/effective') {
if (request.method !== 'GET') {
@@ -527,6 +639,9 @@ export async function handleSecretsRoute(
if (!scope.success || request.method !== 'POST') {
return json({ error: 'not_found' }, 404)
}
if (tooOldForAdminWrite(caller, scope.data, now)) {
return staleToken()
}
const owner = ownerFor(caller, scope.data, repoQuery, true)
const body = BatchSchema.safeParse(await readJson(request))
if (!body.success) {
@@ -559,7 +674,7 @@ export async function handleSecretsRoute(
})
}
// Names only.
console.log(JSON.stringify({ event: 'secrets_batch', scope: scope.data, written: written.map((s) => s.name), deleted, failed: errors.length, by: caller.userId }))
secretLog('secrets_batch', { scope: scope.data, written: written.map((s) => s.name), deleted, failed: errors.length, by: caller.userId }, Object.values(body.data.set ?? {}))
if (written.length > 0 || deleted.length > 0) {
onChange({ scope: scope.data, ownerId: owner })
}
@@ -580,12 +695,16 @@ export async function handleSecretsRoute(
return json({ error: 'invalid_request', reason: 'invalid_scope' }, 400)
}
const owner = ownerFor(caller, scope.data, repoParam, false)
return json({ secrets: await listSecrets(db, scope.data, owner) })
const secrets = await listSecrets(db, scope.data, owner)
return json({ secrets: hintVisible(caller, scope.data) ? secrets : secrets.map((secret) => ({ ...secret, hint: null })) })
}
const scope = ScopeSchema.safeParse(scopeSegment)
if (!scope.success || !name) {
return json({ error: 'not_found' }, 404)
}
if ((request.method === 'PUT' || request.method === 'DELETE') && tooOldForAdminWrite(caller, scope.data, now)) {
return staleToken()
}
if (request.method === 'PUT') {
const owner = ownerFor(caller, scope.data, repoParam, true)
const body = PutSchema.safeParse(await readJson(request))
@@ -593,7 +712,7 @@ export async function handleSecretsRoute(
return json({ error: 'invalid_request' }, 400)
}
const secret = await putSecret(db, keyring, { scope: scope.data, ownerId: owner, name, value: body.data.value, updatedBy: caller.userId }, now, actor)
console.log(JSON.stringify({ event: 'secret_written', scope: scope.data, name, by: caller.userId }))
secretLog('secret_written', { scope: scope.data, name, by: caller.userId }, [body.data.value])
onChange({ scope: scope.data, ownerId: owner })
return json({ secret })
}
@@ -603,7 +722,7 @@ export async function handleSecretsRoute(
if (!(await deleteSecret(db, scope.data, owner, name, actor, now))) {
return json({ error: 'not_found' }, 404)
}
console.log(JSON.stringify({ event: 'secret_deleted', scope: scope.data, name, by: caller.userId }))
secretLog('secret_deleted', { scope: scope.data, name, by: caller.userId })
onChange({ scope: scope.data, ownerId: owner })
return json({ deleted: true })
}
+10 -4
View File
@@ -9,7 +9,9 @@ import { deriveProviderEnv, deriveProviderScopes, providerHosts } from './provid
import { applySecretPlaceholders, encodeAccountRoute, encodeEgressSecretHosts } from './secret-egress'
import { resolveSandboxSecrets } from './secrets'
import type { Keyring } from './secrets-crypto'
import { SecretsKmsError } from './secrets-kms'
import type { SandboxRow } from './store'
import { secretLog } from './secret-log'
export type SessionSecrets = {
env: Record<string, string>
@@ -27,7 +29,7 @@ export type ProviderEnv = { env: Record<string, string>; scopes: Record<string,
export async function resolvedProviderEnv(
db: D1Database,
keyring: Keyring,
row: Pick<SandboxRow, 'id' | 'account_id' | 'user_id' | 'repo_url'>
row: Pick<SandboxRow, 'id' | 'account_id' | 'user_id' | 'repo_url' | 'visibility'>
): Promise<ProviderEnv> {
const { env: stored, scopes } = await resolveSandboxSecrets(db, keyring, row)
// The proxy only needs the keys; dropping those of agents not on 'keep' happens at request time
@@ -43,7 +45,8 @@ const PROVIDER_ENV_CACHE_MS = 60_000
const providerEnvCache = new Map<string, { at: number; value: Promise<ProviderEnv> }>()
export function cachedProviderEnv(db: D1Database, keyring: Keyring, row: SandboxRow, now: number): Promise<ProviderEnv> {
const key = `${row.id}:${row.session_id ?? ''}`
// Visibility is part of the key: sharing a workspace stops its owner's personal keys at once (SEC-5).
const key = `${row.id}:${row.session_id ?? ''}:${row.visibility}`
const hit = providerEnvCache.get(key)
if (hit && now - hit.at < PROVIDER_ENV_CACHE_MS) {
return hit.value
@@ -79,9 +82,12 @@ function egressSwapOn(deps: Pick<LifecycleDeps, 'secrets' | 'config'>, row: Pick
export type { AgentMode }
export async function sessionSecretEnv(
deps: Pick<LifecycleDeps, 'db' | 'secrets' | 'config'>,
deps: Pick<LifecycleDeps, 'db' | 'secrets' | 'secretsUnavailable' | 'config'>,
row: SandboxRow
): Promise<SessionSecrets> {
if (deps.secretsUnavailable) {
throw new SecretsKmsError(deps.secretsUnavailable)
}
if (!deps.secrets) {
return EMPTY
}
@@ -104,7 +110,7 @@ export async function sessionSecretEnv(
const scopes = deriveProviderScopes(stored, env, storedScopes)
const egressAllowHosts = providerHosts(env).join(',') || null
// Counts only.
console.log(JSON.stringify({ event: 'session_secrets', sandboxId: row.id, injected: Object.keys(env).length, skipped }))
secretLog('session_secrets', { sandboxId: row.id, injected: Object.keys(env).length, skipped })
if (!swap) {
return { env, egressSecretHosts: null, egressAllowHosts }
}
@@ -121,7 +121,10 @@ describe('secret store routes for the desktop UI', () => {
expect(res.body.caller).toEqual({ userId: 'mia', orgId: 'acme', orgRole: 'member', canWrite: { personal: true, org: false, repo: false } })
expect(res.body.precedence.agent_key[0]).toBe('org')
const byName = Object.fromEntries(res.body.secrets.map((s: { name: string }) => [s.name, s]))
expect(byName.ANTHROPIC_API_KEY).toMatchObject({ kind: 'agent_key', scope: 'org', hint: 'aaaa', shadowed: [{ scope: 'personal', hint: 'bbbb' }] })
expect(byName.ANTHROPIC_API_KEY).toMatchObject({ kind: 'agent_key', scope: 'org', hint: null, shadowed: [{ scope: 'personal', hint: 'bbbb' }] })
// SEC-6b: a member sees no hint for a team value; the admin who can write it does.
const forAdmin = await h.call('GET', '/v1/secrets/effective?repo=acme/app', { bearer: ada })
expect(forAdmin.body.secrets.find((s: { name: string }) => s.name === 'ANTHROPIC_API_KEY').hint).toBe('aaaa')
expect(byName.SHARED_FLAG).toMatchObject({ kind: 'env', scope: 'repo', shadowed: [{ scope: 'personal' }, { scope: 'org' }] })
expect(JSON.stringify(res.body)).not.toContain(ORG_KEY)
})
@@ -8,6 +8,7 @@ import { createHarness } from './harness'
const KEK = Buffer.alloc(32, 7).toString('base64')
const AUTH_TOKEN = 'a'.repeat(40)
const OPERATOR = 'o'.repeat(40)
const BILLING_TOKEN = 'b'.repeat(40)
// Fake values only; none may appear in the log, in any format.
const SECRET_VALUES = ['sk-ant-api03-AUDIT-fake-0000-wxyz', 'audit-env-value-two-9876']
@@ -22,7 +23,7 @@ type Event = {
}
async function setup(options: { entitled?: boolean } = {}) {
const h = await createHarness({ SECRETS_KEK: KEK, MEMBER_REMOVED_TOKEN: AUTH_TOKEN, OPERATOR_TOKEN: OPERATOR, PUBLIC_API_URL: 'https://api.test' })
const h = await createHarness({ SECRETS_KEK: KEK, MEMBER_REMOVED_TOKEN: AUTH_TOKEN, OPERATOR_TOKEN: OPERATOR, CONTROL_PLANE_TOKEN: BILLING_TOKEN, PUBLIC_API_URL: 'https://api.test' })
h.billing.features = { auditLog: options.entitled ?? true }
const owner = await h.token('olga', 'acme', 'sandbox', { org_role: 'owner', name: 'Olga Owner' })
const admin = await h.token('alice', 'acme', 'sandbox', { org_role: 'admin', name: 'Alice Admin' })
@@ -363,6 +364,47 @@ describe('events from other services (POST /v1/internal/audit-events)', () => {
expect((await s.h.call('GET', '/v1/internal/status', { bearer: AUTH_TOKEN })).status).toBe(404)
})
it("billing's bearer writes billing events and nothing else; auth's writes none of billing's", async () => {
const s = await setup()
const plan = { eventId: 'billing_0000000001', action: 'billing.seats_changed', target: { type: 'plan' }, detail: { seats: 5, previousSeats: 2 } }
const overage = { eventId: 'billing_0000000002', action: 'billing.overage_enabled', actorUserId: 'olga', target: { type: 'plan' } }
await s.h.call('GET', '/v1/me', { bearer: s.owner })
expect((await s.internal('/v1/internal/audit-events', { accountId: 'org:acme', events: [plan, overage] }, BILLING_TOKEN)).body).toEqual({ recorded: 2, duplicates: 0 })
const events: Event[] = (await s.log(s.owner, 'category=billing')).body.events
expect(events.map((event) => [event.action, event.actor.kind, event.actor.name, event.target?.type, event.detail])).toEqual(
expect.arrayContaining([
['billing.seats_changed', 'system', null, 'plan', { seats: 5, previousSeats: 2 }],
['billing.overage_enabled', 'person', 'Olga Owner', 'plan', null]
])
)
expect(events).toHaveLength(2)
// One sender cannot write another's events.
const forged = await s.internal('/v1/internal/audit-events', { accountId: 'org:acme', events: [invite] }, BILLING_TOKEN)
expect([forged.status, forged.body.reason]).toEqual([400, 'action_not_allowed_for_sender:member.invited'])
const crossed = await s.internal('/v1/internal/audit-events', { accountId: 'org:acme', events: [{ ...plan, eventId: 'billing_0000000003' }] })
expect([crossed.status, crossed.body.reason]).toEqual([400, 'action_not_allowed_for_sender:billing.seats_changed'])
// The operator may write either.
expect((await s.internal('/v1/internal/audit-events', { accountId: 'org:acme', events: [{ ...plan, eventId: 'billing_0000000004' }, invite] }, OPERATOR)).status).toBe(200)
// Billing's bearer opens nothing else under /v1/internal.
expect((await s.h.call('GET', '/v1/internal/status', { bearer: BILLING_TOKEN })).status).toBe(404)
expect((await s.internal('/v1/internal/org-members/removed', { accountId: 'org:acme', userId: 'bob' }, BILLING_TOKEN)).status).toBe(404)
expect((await s.internal('/v1/internal/members/role-changed', { accountId: 'org:acme', userId: 'bob', role: 'owner' }, BILLING_TOKEN)).status).toBe(404)
})
it('accepts the team and join events auth sends', async () => {
const s = await setup()
const events = [
{ eventId: 'member_0000000001', action: 'member.joined', actorUserId: 'erin', target: { type: 'member', id: 'erin' }, detail: { source: 'sign-in', role: 'member' } },
{ eventId: 'member_0000000002', action: 'member.invite_revoked', actorUserId: 'olga', target: { type: 'invite', label: 'x@acme.dev' } },
{ eventId: 'team_0000000001', action: 'team.renamed', actorUserId: 'olga', target: { type: 'team', label: 'Acme Labs' }, detail: { via: 'cloud-setup', previousName: 'Acme' } },
{ eventId: 'team_0000000002', action: 'team.deleted', actorUserId: 'olga', target: { type: 'team', label: 'Acme Labs' }, detail: { members: 3 } }
]
expect((await s.internal('/v1/internal/audit-events', { accountId: 'org:acme', events })).body).toEqual({ recorded: 4, duplicates: 0 })
const members: Event[] = (await s.log(s.owner, 'category=members')).body.events
expect(members.map((event) => event.action).sort()).toEqual(['member.invite_revoked', 'member.joined', 'team.deleted', 'team.renamed'])
expect(members.find((event) => event.action === 'team.renamed')).toMatchObject({ target: { type: 'team', label: 'Acme Labs' }, detail: { previousName: 'Acme' } })
})
it('refuses anything that could pass for another kind of change, hold a credential, or post-date the log', async () => {
const s = await setup()
const post = (event: Record<string, unknown>, accountId = 'org:acme') => s.internal('/v1/internal/audit-events', { accountId, events: [{ ...invite, ...event }] })
@@ -63,7 +63,7 @@ describe('the git shim never lets a github.com push prompt for credentials', ()
expect(err).not.toContain("Username for 'https://github.com'")
expect(err).not.toContain('could not read Username')
expect(err).toContain(
"orca: Orca Cloud can't push to octocat/Hello-World. Install the Orca GitHub App on octocat, or connect your GitHub in Settings › Account."
"orca: Orca Cloud can't push to octocat/Hello-World. Install the Orca GitHub App on octocat, or connect your GitHub in Settings → Account."
)
})
@@ -1,5 +1,5 @@
// #503 follow-up (design-squad): a commit that goes out under the workspace identity (Orca Cloud's
// name) says once per session how to use your own: connect GitHub in Settings › Account.
// name) says once per session how to use your own: connect GitHub in Settings → Account.
// Pushes and read-only git never say it; orca-git-as's own fallback line is replaced by it.
import { spawnSync } from 'node:child_process'
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
@@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
const SHIM = fileURLToPath(new URL('../image/vm/git', import.meta.url).href)
const NUDGE = "orca: This commit uses Orca Cloud's name. Connect your GitHub in Settings › Account to use yours."
const NUDGE = "orca: This commit uses Orca Cloud's name. Connect your GitHub in Settings → Account to use yours."
let home = ''
let cwd = ''
@@ -0,0 +1,165 @@
// SEC-6: (a) a demoted admin can't write team secrets with an old token, and admin writes need a token
// under a minute old; (b) last-4 hints are shown only to people who can write the scope; (c) secret
// handling code logs through secretLog, which redacts, and no planted value reaches the log sink.
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AUTH_IAT_BACKDATE_MS, recordMemberChange } from '../src/member-changes'
import { ADMIN_WRITE_TOKEN_MAX_AGE_MS } from '../src/secrets'
import { redactLine, secretLog, setSecretLogSink } from '../src/secret-log'
import { createHarness } from './harness'
const KEK = Buffer.alloc(32, 7).toString('base64')
// Fake values only.
const TEAM_KEY = 'sk-ant-api03-HARDENING-fake-0000-aaaa'
const PLAIN_VALUE = 'unshaped-database-password-0042'
let sink: string[] = []
let consoleLines: string[] = []
let restoreSink: () => void = () => {}
beforeEach(() => {
sink = []
consoleLines = []
for (const method of ['log', 'info', 'warn', 'error', 'debug'] as const) {
vi.spyOn(console, method).mockImplementation((...args: unknown[]) => {
consoleLines.push(args.map((arg) => (typeof arg === 'string' ? arg : JSON.stringify(arg))).join(' '))
})
}
restoreSink = setSecretLogSink((line) => sink.push(line))
})
afterEach(() => {
restoreSink()
for (const value of [TEAM_KEY, PLAIN_VALUE]) {
expect([...sink, ...consoleLines].join('\n')).not.toContain(value)
}
vi.restoreAllMocks()
})
async function setup() {
const h = await createHarness({ SECRETS_KEK: KEK })
const put = (scope: string, name: string, value: string, bearer: string, repo?: string) =>
h.call('PUT', `/v1/secrets/${scope}/${name}${repo ? `?repo=${encodeURIComponent(repo)}` : ''}`, { body: { value }, bearer })
const rows = () => h.raw.prepare('SELECT scope, owner_id, name, updated_at FROM secrets ORDER BY name').all()
return { h, put, rows }
}
describe('SEC-6a: role changes reach secret writes within a minute', () => {
it('a demoted admin’s earlier token can’t put, delete or batch team secrets once auth reports the change', async () => {
const { h, put, rows } = await setup()
const admin = await h.token('ada', 'acme', 'sandbox', { org_role: 'admin' })
expect((await put('org', 'TEAM_KEY', TEAM_KEY, admin)).status).toBe(200)
const before = rows()
h.advance(AUTH_IAT_BACKDATE_MS + 1_000)
await recordMemberChange(h.env.SANDBOX_DB, 'org:acme', 'ada', 'role', h.now())
const attempts = [
await put('org', 'TEAM_KEY', 'replacement-value-123', admin),
await put('repo', 'DATABASE_URL', PLAIN_VALUE, admin, 'acme/web'),
await h.call('DELETE', '/v1/secrets/org/TEAM_KEY', { bearer: admin }),
await h.call('POST', '/v1/secrets/org/batch', { bearer: admin, body: { delete: ['TEAM_KEY'] } })
]
for (const res of attempts) {
expect(res.status).toBe(401)
expect(res.body).toMatchObject({ reason: 'stale_token' })
}
expect(rows()).toEqual(before)
// The re-minted token carries the new role and is refused for its role, not its age.
h.advance(1_000)
const member = await h.token('ada', 'acme', 'sandbox', { org_role: 'member' })
expect((await put('org', 'TEAM_KEY', 'replacement-value-123', member)).body).toMatchObject({ reason: 'org_admin_required' })
})
it('without the push, an admin write needs a token minted in the last minute; reads and personal writes don’t', async () => {
const { h, put } = await setup()
const admin = await h.token('ada', 'acme', 'sandbox', { org_role: 'admin' })
h.advance(ADMIN_WRITE_TOKEN_MAX_AGE_MS - 1_000)
expect((await put('org', 'TEAM_KEY', TEAM_KEY, admin)).status).toBe(200)
h.advance(2_000)
for (const res of [
await put('org', 'TEAM_KEY', TEAM_KEY, admin),
await put('repo', 'DATABASE_URL', PLAIN_VALUE, admin, 'acme/web'),
await h.call('DELETE', '/v1/secrets/org/TEAM_KEY', { bearer: admin }),
await h.call('POST', '/v1/secrets/repo/batch?repo=acme/web', { bearer: admin, body: { set: { A: PLAIN_VALUE } } })
]) {
expect(res.status).toBe(401)
expect(res.body).toMatchObject({ error: 'unauthorized', reason: 'stale_token' })
}
expect((await h.call('GET', '/v1/secrets?scope=org', { bearer: admin })).status).toBe(200)
expect((await put('personal', 'MY_VAR', PLAIN_VALUE, admin)).status).toBe(200)
// A personal account's repository secrets have no role to lose.
const solo = await h.token('carol')
h.advance(4 * 60_000)
expect((await put('repo', 'DATABASE_URL', PLAIN_VALUE, solo, 'carol/app')).status).toBe(200)
// A fresh admin token writes again.
const fresh = await h.token('ada', 'acme', 'sandbox', { org_role: 'admin' })
expect((await put('org', 'TEAM_KEY', TEAM_KEY, fresh)).status).toBe(200)
})
})
describe('SEC-6b: last-4 hints only for people who can write the scope', () => {
it('team members get no hints for Shared or team Repository secrets; admins and owners of personal ones do', async () => {
const { h, put } = await setup()
const admin = await h.token('ada', 'acme', 'sandbox', { org_role: 'admin' })
const member = await h.token('mia', 'acme', 'sandbox', { org_role: 'member' })
await put('org', 'TEAM_KEY', TEAM_KEY, admin)
await put('repo', 'DATABASE_URL', PLAIN_VALUE, admin, 'acme/web')
await put('personal', 'MY_VAR', PLAIN_VALUE, member)
const list = async (bearer: string, query: string) =>
(await h.call('GET', `/v1/secrets?${query}`, { bearer })).body.secrets.map((s: any) => s.hint)
expect(await list(admin, 'scope=org')).toEqual(['aaaa'])
expect(await list(member, 'scope=org')).toEqual([null])
expect(await list(admin, 'scope=repo&repo=acme/web')).toEqual(['0042'])
expect(await list(member, 'scope=repo&repo=acme/web')).toEqual([null])
expect(await list(member, 'scope=personal')).toEqual(['0042'])
const effective = (await h.call('GET', '/v1/secrets/effective?repo=acme/web', { bearer: member })).body.secrets
expect(effective.map((s: any) => [s.name, s.hint])).toEqual([
['DATABASE_URL', null],
['MY_VAR', '0042'],
['TEAM_KEY', null]
])
})
})
describe('SEC-6c: secret-handling code logs only through the redacting sink', () => {
const FILES = ['secrets.ts', 'session-env.ts', 'env-refresh.ts', 'egress-proxy.ts', 'secret-egress.ts', 'secrets-crypto.ts', 'secret-events.ts']
it('the secret-handling modules never call console directly', () => {
for (const file of FILES) {
const source = readFileSync(fileURLToPath(new URL(`../src/${file}`, import.meta.url)), 'utf8')
expect({ file, direct: /\bconsole\s*\.\s*[a-z]+\s*\(/.test(source) }).toEqual({ file, direct: false })
}
})
it('redacts values passed as known, in raw and JSON-escaped form, and credential shapes', () => {
const quoted = 'pa"ss\\word-with-quotes-99'
secretLog('probe', { error: new Error(`upstream said ${PLAIN_VALUE}`), body: { echoed: quoted } }, [PLAIN_VALUE, quoted])
secretLog('probe', {
message: `bad key ${TEAM_KEY}; auth=Bearer abcdefghijklmnop.qrstuvwx; gh=ghp_${'a'.repeat(36)}; aws=AKIAABCDEFGHIJKLMNOP`
})
const text = sink.join('\n')
expect(text).not.toContain(PLAIN_VALUE)
expect(text).not.toContain('ss\\\\word-with-quotes')
expect(text).not.toContain(TEAM_KEY)
expect(text).not.toContain('abcdefghijklmnop')
expect(text).not.toContain('ghp_aaaa')
expect(text).not.toContain('AKIAABCDEFGHIJKLMNOP')
expect(text).toContain('Bearer [redacted]')
// Names, ids and counts survive.
expect(redactLine('{"event":"secret_written","name":"ANTHROPIC_API_KEY","sandboxId":"sbx_123","count":4}')).toBe(
'{"event":"secret_written","name":"ANTHROPIC_API_KEY","sandboxId":"sbx_123","count":4}'
)
// Errors never carry their stack.
expect(text).not.toContain('at ')
})
it('a full write, batch and session start leave no planted value in the sink', async () => {
const { h, put } = await setup()
const admin = await h.token('ada', 'acme', 'sandbox', { org_role: 'admin' })
await put('org', 'ANTHROPIC_API_KEY', TEAM_KEY, admin)
await h.call('POST', '/v1/secrets/org/batch', { bearer: admin, body: { set: { DB_PASSWORD: PLAIN_VALUE } } })
const created = await h.call('POST', '/v1/sandboxes', { body: { workspaceId: 'ws-1', machineSize: 'standard' }, bearer: admin })
expect(created.status).toBe(200)
expect(sink.some((line) => line.includes('secrets_batch'))).toBe(true)
expect(sink.some((line) => line.includes('session_secrets'))).toBe(true)
// afterEach checks the sink and console for every planted value.
})
})
@@ -0,0 +1,321 @@
import { createCipheriv, createDecipheriv, generateKeyPairSync, randomBytes, type KeyObject } from 'node:crypto'
import { jwtVerify } from 'jose'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { START_COMMAND } from '../src/sandbox-vm-commands'
import { KMS_RETRY_AFTER_MS } from '../src/secrets'
import { createKeyring } from '../src/secrets-crypto'
import { createKekUnwrapper, kekWrapAad, SecretsKmsError } from '../src/secrets-kms'
import { createHarness } from './harness'
// Fake keys and values only; the log check looks for every one of them.
const K1 = Buffer.alloc(32, 7).toString('base64')
const K2 = Buffer.alloc(32, 9).toString('base64')
const PLAIN = Buffer.alloc(32, 5).toString('base64')
const OP = 'o'.repeat(40)
const KEY_NAME = 'projects/onorca-cloud/locations/us/keyRings/orca-secrets/cryptoKeys/secrets-kek'
const VALUE = 'kms-secret-value-aaaa-1111'
const VALUE_2 = 'kms-secret-value-bbbb-2222'
type FakeKms = ReturnType<typeof createFakeKms>
/**
* Google's token endpoint and Cloud KMS, in memory. The service-account assertion is verified against the
* account's public key, and encrypt/decrypt are real AES-256-GCM under a root key that never leaves this object.
*/
function createFakeKms() {
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
const root = randomBytes(32)
const email = 'orca-secrets@onorca-cloud.iam.gserviceaccount.com'
const state = {
tokens: 0,
decrypts: 0,
issued: [] as string[],
/** `down`: 503 from KMS. `denied`: 403 from KMS. `no-account`: token endpoint refuses. `offline`: fetch throws. */
mode: 'up' as 'up' | 'down' | 'denied' | 'no-account' | 'offline'
}
const fetcher = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
if (state.mode === 'offline') {
throw new TypeError('fetch failed')
}
if (url === 'https://oauth2.googleapis.com/token') {
state.tokens++
if (state.mode === 'no-account') {
return Response.json({ error: 'invalid_grant', error_description: 'account disabled' }, { status: 400 })
}
const form = new URLSearchParams(String(init?.body))
expect(form.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:jwt-bearer')
const { payload } = await jwtVerify(form.get('assertion') ?? '', publicKey as unknown as KeyObject, {
issuer: email,
audience: 'https://oauth2.googleapis.com/token'
})
expect(payload.scope).toBe('https://www.googleapis.com/auth/cloudkms')
const token = `ya29.fake-${randomBytes(8).toString('hex')}`
state.issued.push(token)
return Response.json({ access_token: token, expires_in: 3600, token_type: 'Bearer' })
}
if (url === `https://cloudkms.googleapis.com/v1/${KEY_NAME}:decrypt`) {
state.decrypts++
if (state.mode === 'down') {
return Response.json({ error: { code: 503, message: 'backend unavailable' } }, { status: 503 })
}
const bearer = new Headers(init?.headers).get('authorization')?.slice('Bearer '.length) ?? ''
if (state.mode === 'denied' || !state.issued.includes(bearer)) {
return Response.json({ error: { code: 403, message: 'Permission cloudkms.cryptoKeyVersions.useToDecrypt denied' } }, { status: 403 })
}
const body = JSON.parse(String(init?.body)) as { ciphertext: string; additionalAuthenticatedData?: string }
try {
const bytes = Buffer.from(body.ciphertext, 'base64')
const decipher = createDecipheriv('aes-256-gcm', root, bytes.subarray(0, 12))
decipher.setAAD(Buffer.from(body.additionalAuthenticatedData ?? '', 'base64'))
decipher.setAuthTag(bytes.subarray(bytes.length - 16))
const plaintext = Buffer.concat([decipher.update(bytes.subarray(12, bytes.length - 16)), decipher.final()])
return Response.json({ plaintext: plaintext.toString('base64') })
} catch {
return Response.json({ error: { code: 400, message: 'Decryption failed: the ciphertext is invalid.' } }, { status: 400 })
}
}
throw new Error(`unexpected fetch ${url}`)
}) as typeof fetch
return {
state,
fetch: fetcher,
serviceAccount: JSON.stringify({
type: 'service_account',
client_email: email,
private_key: privateKey.export({ type: 'pkcs8', format: 'pem' }) as string,
// Not trusted by the client: the real endpoint is pinned.
token_uri: 'https://attacker.example/token'
}),
/** What `gcloud kms encrypt` does in the runbook: wrap a KEK's 32 bytes, bound to its id. */
wrap(kekB64: string, kekId: string): string {
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', root, iv)
cipher.setAAD(Buffer.from(kekWrapAad(kekId)))
return Buffer.concat([iv, cipher.update(Buffer.from(kekB64, 'base64')), cipher.final(), cipher.getAuthTag()]).toString('base64')
}
}
}
let logs: string[] = []
let kms: FakeKms
let clock = 1_800_000_000_000
beforeEach(() => {
logs = []
kms = createFakeKms()
// The keyring cache is per isolate (module state); a fresh fake means fresh wrapped blobs, so no test sees another's entry.
vi.stubGlobal('fetch', kms.fetch)
// Past any earlier test's remembered failure.
clock += 10 * KMS_RETRY_AFTER_MS
vi.spyOn(Date, 'now').mockImplementation(() => clock)
for (const method of ['log', 'info', 'warn', 'error', 'debug'] as const) {
vi.spyOn(console, method).mockImplementation((...args: unknown[]) => {
logs.push(args.map((arg) => (typeof arg === 'string' ? arg : JSON.stringify(arg))).join(' '))
})
}
})
afterEach(() => {
// No value, key, credential or access token reached a log line.
const privateKey = (JSON.parse(kms.serviceAccount) as { private_key: string }).private_key
for (const secret of [VALUE, VALUE_2, K1, K2, PLAIN, privateKey.split('\n')[1]!, ...kms.state.issued]) {
expect(logs.join('\n')).not.toContain(secret)
}
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
function kmsEnv(extra: Record<string, string> = {}): Record<string, string> {
return {
OPERATOR_TOKEN: OP,
SECRETS_KEK_SOURCE: 'kms',
SECRETS_KMS_KEY: KEY_NAME,
SECRETS_KMS_SERVICE_ACCOUNT: kms.serviceAccount,
SECRETS_KEK_WRAPPED: kms.wrap(K1, 'k1'),
...extra
}
}
const put = (h: Awaited<ReturnType<typeof createHarness>>, bearer: string, name: string, value: string) =>
h.call('PUT', `/v1/secrets/personal/${name}`, { bearer, body: { value } })
describe('KMS client', () => {
it('unwraps a KEK wrapped for its id, with a pinned token endpoint and one token for many calls', async () => {
const unwrap = createKekUnwrapper({ keyName: KEY_NAME, serviceAccountJson: kms.serviceAccount, fetch: kms.fetch })
expect(await unwrap(kms.wrap(K1, 'k1'), 'k1')).toBe(K1)
expect(await unwrap(kms.wrap(K2, 'k2'), 'k2')).toBe(K2)
expect(kms.state).toMatchObject({ tokens: 1, decrypts: 2 })
// A blob wrapped for another id, or a corrupted one, does not unwrap.
await expect(unwrap(kms.wrap(K1, 'k1'), 'k2')).rejects.toMatchObject({ reason: 'invalid', status: 400 })
await expect(unwrap('AAAA', 'k1')).rejects.toMatchObject({ reason: 'invalid' })
})
it('classifies failures and retries only the retryable ones', async () => {
const unwrap = createKekUnwrapper({ keyName: KEY_NAME, serviceAccountJson: kms.serviceAccount, fetch: kms.fetch })
const wrapped = kms.wrap(K1, 'k1')
kms.state.mode = 'down'
await expect(unwrap(wrapped, 'k1')).rejects.toMatchObject({ name: 'SecretsKmsError', reason: 'unavailable', status: 503 })
expect(kms.state.decrypts).toBe(2)
kms.state.mode = 'denied'
await expect(unwrap(wrapped, 'k1')).rejects.toMatchObject({ reason: 'denied', status: 403 })
expect(kms.state.decrypts).toBe(3)
kms.state.mode = 'offline'
await expect(unwrap(wrapped, 'k1')).rejects.toMatchObject({ reason: 'unavailable' })
const refused = createKekUnwrapper({ keyName: KEY_NAME, serviceAccountJson: kms.serviceAccount, fetch: kms.fetch })
kms.state.mode = 'no-account'
await expect(refused(wrapped, 'k1')).rejects.toMatchObject({ reason: 'denied' })
// Error text is a fixed code: nothing Google said is carried along.
const error = await refused(wrapped, 'k1').catch((caught: unknown) => caught)
expect(error).toBeInstanceOf(SecretsKmsError)
expect(String(error)).toBe('SecretsKmsError: secrets_kms_denied')
})
it('refuses a malformed key name or credential without echoing it', () => {
for (const keyName of ['', 'projects/x', `${KEY_NAME}/../../other`, `${KEY_NAME}:encrypt`, 'https://evil.example/projects/a/locations/b/keyRings/c/cryptoKeys/d']) {
expect(() => createKekUnwrapper({ keyName, serviceAccountJson: kms.serviceAccount })).toThrow('secrets_kms_config')
}
for (const serviceAccountJson of ['', '{', '{"client_email":"a@b.c"}', 'not-json-PRIVATE']) {
expect(() => createKekUnwrapper({ keyName: KEY_NAME, serviceAccountJson })).toThrow('secrets_kms_config')
}
})
})
describe('secret store with SECRETS_KEK_SOURCE=kms', () => {
it('is off unless the flag is exactly `kms`: the plaintext SECRETS_KEK path is untouched and KMS is never called', async () => {
for (const source of [undefined, '', 'KMS', 'env']) {
const h = await createHarness({ ...kmsEnv(), SECRETS_KEK: PLAIN, ...(source === undefined ? {} : { SECRETS_KEK_SOURCE: source }) })
if (source === undefined) {
delete h.env.SECRETS_KEK_SOURCE
}
const alice = await h.token('alice')
expect((await put(h, alice, 'PLAIN_VAR', VALUE)).status).toBe(200)
const row = h.raw.prepare('SELECT kek_id, wrapped_dek, ciphertext FROM secrets').get() as { kek_id: string; wrapped_dek: string; ciphertext: string }
const plain = await createKeyring({ id: 'k1', key: PLAIN })
expect(await plain.open({ scope: 'personal', ownerId: 'user:alice', name: 'PLAIN_VAR' }, { kekId: row.kek_id, wrappedDek: row.wrapped_dek, ciphertext: row.ciphertext })).toBe(VALUE)
}
expect(kms.state).toMatchObject({ tokens: 0, decrypts: 0 })
})
it('unwraps the KEK once per isolate and seals with it, ignoring any plaintext SECRETS_KEK', async () => {
const h = await createHarness(kmsEnv({ SECRETS_KEK: PLAIN }))
const alice = await h.token('alice')
expect((await put(h, alice, 'FIRST_VAR', VALUE)).status).toBe(200)
expect((await put(h, alice, 'SECOND_VAR', VALUE_2)).status).toBe(200)
expect((await h.call('GET', '/v1/secrets?scope=personal', { bearer: alice })).body.secrets).toHaveLength(2)
expect((await h.call('GET', '/v1/internal/secrets/kek', { bearer: OP })).body).toMatchObject({ activeKekId: 'k1', pending: 0, unknownKekIds: [] })
// A session start decrypts them too.
expect((await h.call('POST', '/v1/sandboxes', { body: { workspaceId: 'ws-1', machineSize: 'standard' }, bearer: alice })).status).toBe(200)
expect(h.vercel.commands.find((c) => c.command === START_COMMAND)!.env.ORCA_SECRET_ENV).toBeDefined()
// One token and one decrypt for all of it; an outage afterwards does not matter to a warm isolate.
expect(kms.state).toMatchObject({ tokens: 1, decrypts: 1 })
kms.state.mode = 'down'
expect((await put(h, alice, 'THIRD_VAR', VALUE)).status).toBe(200)
expect(kms.state.decrypts).toBe(1)
// The rows are under the KMS-held key, not the plaintext one that was also configured.
const row = h.raw.prepare(`SELECT kek_id, wrapped_dek, ciphertext FROM secrets WHERE name = 'FIRST_VAR'`).get() as { kek_id: string; wrapped_dek: string; ciphertext: string }
const sealed = { kekId: row.kek_id, wrappedDek: row.wrapped_dek, ciphertext: row.ciphertext }
const binding = { scope: 'personal', ownerId: 'user:alice', name: 'FIRST_VAR' }
expect(await (await createKeyring({ id: 'k1', key: K1 })).open(binding, sealed)).toBe(VALUE)
await expect((await createKeyring({ id: 'k1', key: PLAIN })).open(binding, sealed)).rejects.toThrow()
})
it.each([
['down', 'unavailable'],
['denied', 'denied'],
['no-account', 'denied'],
['offline', 'unavailable']
] as const)('answers a clear 503 when KMS is %s, without falling back to the plaintext key', async (mode, reason) => {
kms.state.mode = mode
const h = await createHarness(kmsEnv({ SECRETS_KEK: PLAIN }))
const alice = await h.token('alice')
const refused = await put(h, alice, 'ANY_VAR', VALUE)
expect(refused).toEqual({ status: 503, body: { error: 'secrets_unavailable' } })
expect((await h.send(new Request('https://sandbox.test/v1/secrets?scope=personal', { headers: { authorization: `Bearer ${alice}` } }))).headers.get('retry-after')).toBe('30')
expect(await h.call('GET', '/v1/internal/secrets/kek', { bearer: OP })).toEqual({ status: 503, body: { error: 'secrets_unavailable', reason } })
expect(await h.call('POST', '/v1/internal/secrets/rewrap', { bearer: OP, body: {} })).toEqual({ status: 503, body: { error: 'secrets_unavailable', reason } })
// Nothing was stored under any key.
expect(h.raw.prepare('SELECT COUNT(*) AS n FROM secrets').get()).toEqual({ n: 0 })
// A session does not start without its secrets, and no VM is left behind.
const created = await h.call('POST', '/v1/sandboxes', { body: { workspaceId: 'ws-1', machineSize: 'standard' }, bearer: alice })
expect(created).toEqual({ status: 503, body: { error: 'secrets_unavailable' } })
expect(h.vercel.commands.filter((c) => c.command === START_COMMAND)).toHaveLength(0)
// Routes that need no secrets still answer.
expect((await h.call('GET', '/v1/internal/status', { bearer: OP })).status).toBe(200)
expect(logs.some((line) => line.includes('"secrets_kms_unavailable"') && line.includes(`"reason":"${reason}"`))).toBe(true)
})
it('remembers a failure briefly instead of calling KMS per request, then recovers', async () => {
kms.state.mode = 'down'
const h = await createHarness(kmsEnv())
const alice = await h.token('alice')
expect((await put(h, alice, 'ANY_VAR', VALUE)).status).toBe(503)
const attempts = kms.state.decrypts
expect((await put(h, alice, 'ANY_VAR', VALUE)).status).toBe(503)
expect((await h.call('GET', '/v1/secrets?scope=personal', { bearer: alice })).status).toBe(503)
expect(kms.state.decrypts).toBe(attempts)
kms.state.mode = 'up'
clock += KMS_RETRY_AFTER_MS + 1
expect((await put(h, alice, 'ANY_VAR', VALUE)).status).toBe(200)
expect((await h.call('POST', '/v1/sandboxes', { body: { workspaceId: 'ws-1', machineSize: 'standard' }, bearer: alice })).status).toBe(200)
})
it('answers 503 for an incomplete or mislabelled KMS config, never the plaintext key', async () => {
const cases: Record<string, string>[] = [
{ SECRETS_KMS_KEY: '' },
{ SECRETS_KMS_SERVICE_ACCOUNT: '' },
{ SECRETS_KEK_WRAPPED: '' },
{ SECRETS_KMS_KEY: 'projects/onorca-cloud' },
{ SECRETS_KEK_RETIRED_WRAPPED: '{' },
// The active blob was wrapped for k1 but is deployed as k2.
{ SECRETS_KEK_ID: 'k2' },
// KMS hands back something that is not a 32-byte key.
{ SECRETS_KEK_WRAPPED: kms.wrap(Buffer.alloc(16, 1).toString('base64'), 'k1') }
]
for (const broken of cases) {
const h = await createHarness(kmsEnv({ SECRETS_KEK: PLAIN, ...broken }))
expect(await put(h, await h.token('alice'), 'ANY_VAR', VALUE)).toEqual({ status: 503, body: { error: 'secrets_unavailable' } })
expect(h.raw.prepare('SELECT COUNT(*) AS n FROM secrets').get()).toEqual({ n: 0 })
}
})
it('rotates under KMS: new wrapped KEK active, old one retired, sweep, then the retired blob is dropped', async () => {
const h = await createHarness(kmsEnv())
const alice = await h.token('alice')
await put(h, alice, 'FIRST_VAR', VALUE)
await put(h, alice, 'SECOND_VAR', VALUE_2)
Object.assign(h.env, {
SECRETS_KEK_WRAPPED: kms.wrap(K2, 'k2'),
SECRETS_KEK_ID: 'k2',
SECRETS_KEK_RETIRED_WRAPPED: JSON.stringify({ k1: h.env.SECRETS_KEK_WRAPPED })
})
expect((await h.call('GET', '/v1/internal/secrets/kek', { bearer: OP })).body).toMatchObject({ activeKekId: 'k2', retiredKekIds: ['k1'], pending: 2, retiredInUse: ['k1'], unknownKekIds: [] })
expect((await h.call('POST', '/v1/internal/secrets/rewrap', { bearer: OP, body: { dryRun: true } })).body).toMatchObject({ scanned: 2, failed: 0 })
expect((await h.call('POST', '/v1/internal/secrets/rewrap', { bearer: OP, body: {} })).body).toMatchObject({ resealed: 2, pending: 0, done: true })
expect((await h.call('GET', '/v1/internal/secrets/kek', { bearer: OP })).body).toMatchObject({ pending: 0, retiredDroppable: ['k1'] })
delete h.env.SECRETS_KEK_RETIRED_WRAPPED
expect((await h.call('GET', '/v1/internal/secrets/kek', { bearer: OP })).body).toMatchObject({ retiredKekIds: [], pending: 0, unknownKekIds: [] })
const onlyNew = await createKeyring({ id: 'k2', key: K2 })
const rows = h.raw.prepare('SELECT name, kek_id, wrapped_dek, ciphertext FROM secrets ORDER BY name').all() as { name: string; kek_id: string; wrapped_dek: string; ciphertext: string }[]
const opened = await Promise.all(
rows.map((row) => onlyNew.open({ scope: 'personal', ownerId: 'user:alice', name: row.name }, { kekId: row.kek_id, wrappedDek: row.wrapped_dek, ciphertext: row.ciphertext }))
)
expect(opened).toEqual([VALUE, VALUE_2])
})
it('moves an existing plaintext-KEK store behind KMS without touching a row', async () => {
const h = await createHarness({ OPERATOR_TOKEN: OP, SECRETS_KEK: K1 })
const alice = await h.token('alice')
await put(h, alice, 'FIRST_VAR', VALUE)
const before = h.raw.prepare('SELECT * FROM secrets').all()
// Same KEK, same id, now held by KMS; the Worker secret is removed.
Object.assign(h.env, kmsEnv())
delete h.env.SECRETS_KEK
expect((await h.call('GET', '/v1/internal/secrets/kek', { bearer: OP })).body).toMatchObject({ activeKekId: 'k1', pending: 0, unknownKekIds: [] })
expect((await h.call('POST', '/v1/sandboxes', { body: { workspaceId: 'ws-1', machineSize: 'standard' }, bearer: alice })).status).toBe(200)
expect(h.vercel.commands.find((c) => c.command === START_COMMAND)!.env.ORCA_SECRET_ENV).toBeDefined()
expect(h.raw.prepare('SELECT * FROM secrets').all()).toEqual(before)
})
})
@@ -0,0 +1,216 @@
// SEC-5: a workspace shared with teammates runs their shells too, so nothing personal to its owner may
// be in its env: not a variable's value, and not a placeholder that would spend the owner's model key.
import { SignJWT, createLocalJWKSet, exportJWK, generateKeyPair } from 'jose'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ENV_COMMAND, START_COMMAND } from '../src/sandbox-vm-commands'
import { ownersForSandbox } from '../src/secrets'
import { createHarness } from './harness'
const KEK = Buffer.alloc(32, 7).toString('base64')
const PROXY_URL = 'https://sandbox.test/v1/egress/proxy'
// Fake values only.
const TEAM_KEY = 'sk-ant-api03-TEAM-fake-0000-aaaa'
const PERSONAL_OPENAI = 'sk-proj-fake-personal-openai-2222'
const PERSONAL_VALUES = [PERSONAL_OPENAI, 'personal-db-password', 'personal-foo-value', 'personal-aws-secret-value']
type Harness = Awaited<ReturnType<typeof createHarness>>
const logs: string[] = []
beforeEach(() => {
logs.length = 0
for (const method of ['log', 'info', 'warn', 'error', 'debug'] as const) {
vi.spyOn(console, method).mockImplementation((...args: unknown[]) => {
logs.push(args.map((arg) => (typeof arg === 'string' ? arg : JSON.stringify(arg))).join(' '))
})
}
})
afterEach(() => {
for (const value of [...PERSONAL_VALUES, TEAM_KEY]) {
expect(logs.join('\n')).not.toContain(value)
}
vi.restoreAllMocks()
})
function decodeEnv(encoded: string | undefined): Record<string, string> {
return Object.fromEntries(
(encoded ?? '')
.split('\n')
.filter(Boolean)
.map((line) => {
const at = line.indexOf('=')
return [line.slice(0, at), Buffer.from(line.slice(at + 1), 'base64').toString('utf8')]
})
)
}
/** The env the VM got at its last boot, or from its last live refresh. */
function vmEnv(h: Harness, command: string = START_COMMAND): Record<string, string> {
return decodeEnv(h.vercel.commands.filter((c) => c.command === command).at(-1)!.env.ORCA_SECRET_ENV)
}
/** Everything the control plane ever sent the VM, as one string: no personal value may be in it. */
function everythingSent(h: Harness, from = 0): string {
return JSON.stringify(
h.vercel.commands.slice(from).map((c) => ({ ...c, env: { ...c.env, ORCA_SECRET_ENV: decodeEnv(c.env.ORCA_SECRET_ENV) } }))
)
}
async function seeded(overrides: Record<string, string> = {}, deps: Parameters<typeof createHarness>[1] = {}) {
const h = await createHarness({ SECRETS_KEK: KEK, ...overrides }, deps)
const admin = await h.token('ada', 'acme', 'sandbox', { orgRole: 'admin' })
const mia = await h.token('mia', 'acme', 'sandbox', { orgRole: 'member' })
const put = (scope: string, name: string, value: string, bearer: string, repo?: string) =>
h.call('PUT', `/v1/secrets/${scope}/${name}${repo ? `?repo=${encodeURIComponent(repo)}` : ''}`, { body: { value }, bearer })
await put('org', 'ANTHROPIC_API_KEY', TEAM_KEY, admin)
await put('org', 'FOO', 'team-foo-value', admin)
await put('repo', 'BAR', 'repo-bar-value', admin, 'acme/app')
await put('personal', 'OPENAI_API_KEY', PERSONAL_OPENAI, mia)
await put('personal', 'DB_PASSWORD', 'personal-db-password', mia)
await put('personal', 'FOO', 'personal-foo-value', mia)
await put('personal', 'AWS_SECRET_ACCESS_KEY', 'personal-aws-secret-value', mia)
const create = (visibility?: 'team' | 'private') =>
h.call('POST', '/v1/sandboxes', {
body: { workspaceId: 'ws-1', machineSize: 'standard', repo: { url: 'https://github.com/acme/app' }, ...(visibility ? { visibility } : {}) },
bearer: mia
})
const share = (id: string, visibility: 'team' | 'private') =>
h.call('PATCH', `/v1/team/workspaces/${id}`, { body: { visibility }, bearer: mia })
return { h, mia, admin, put, create, share }
}
const TEAM_AND_REPO = { ANTHROPIC_API_KEY: TEAM_KEY, BAR: 'repo-bar-value', FOO: 'team-foo-value' }
describe('personal secrets in shared workspaces (SEC-5)', () => {
it('resolves no personal owner for a shared row, and fails closed on an unknown visibility', () => {
const row = { account_id: 'org:acme', user_id: 'mia', repo_url: 'https://github.com/acme/app' }
expect(ownersForSandbox({ ...row, visibility: 'private' }).personal).toBe('user:mia')
expect(ownersForSandbox({ ...row, visibility: 'team' })).toEqual({ org: 'org:acme', personal: null, repo: 'org:acme#github.com/acme/app' })
expect(ownersForSandbox({ ...row, visibility: undefined as never }).personal).toBeNull()
})
it('keeps the owner’s personal secrets in their own private workspace', async () => {
const { h, create } = await seeded()
expect((await create()).status).toBe(200)
expect(vmEnv(h)).toEqual({
...TEAM_AND_REPO,
AWS_SECRET_ACCESS_KEY: 'personal-aws-secret-value',
DB_PASSWORD: 'personal-db-password',
FOO: 'personal-foo-value',
OPENAI_API_KEY: PERSONAL_OPENAI
})
})
it('starts a shared workspace with team and repository secrets only; the team value replaces a personal override', async () => {
const { h, create } = await seeded()
expect((await create('team')).status).toBe(200)
expect(vmEnv(h)).toEqual(TEAM_AND_REPO)
for (const value of PERSONAL_VALUES) {
expect(everythingSent(h)).not.toContain(value)
}
})
it('drops them from a running workspace the moment it is shared, and brings them back when it is private again', async () => {
const { h, create, share, mia } = await seeded()
const id: string = (await create()).body.sandbox.id
expect(vmEnv(h).DB_PASSWORD).toBe('personal-db-password')
const before = h.vercel.commands.length
expect((await share(id, 'team')).status).toBe(200)
expect(vmEnv(h, ENV_COMMAND)).toEqual(TEAM_AND_REPO)
for (const value of PERSONAL_VALUES) {
expect(everythingSent(h, before)).not.toContain(value)
}
// A wake while shared resolves the same way.
await h.call('POST', `/v1/sandboxes/${id}/suspend`, { bearer: mia })
await h.call('POST', `/v1/sandboxes/${id}/resume`, { bearer: mia })
expect(vmEnv(h)).toEqual(TEAM_AND_REPO)
expect((await share(id, 'private')).status).toBe(200)
expect(vmEnv(h, ENV_COMMAND)).toMatchObject({ DB_PASSWORD: 'personal-db-password', FOO: 'personal-foo-value' })
})
it('does not pick up a personal secret the owner saves while the workspace is shared', async () => {
const { h, create, put, mia } = await seeded()
await create('team')
await put('personal', 'LATER', 'personal-db-password', mia)
const refreshes = h.vercel.commands.filter((c) => c.command === ENV_COMMAND)
for (const refresh of refreshes) {
expect(decodeEnv(refresh.env.ORCA_SECRET_ENV)).toEqual(TEAM_AND_REPO)
}
})
})
describe('personal model keys in shared workspaces with egress placeholders (SEC-5)', () => {
async function placeholderSetup() {
const { privateKey, publicKey } = await generateKeyPair('RS256')
const jwks = { keys: [{ ...(await exportJWK(publicKey)), kid: 'v1', alg: 'RS256' }] }
const upstream: { url: string; headers: Headers }[] = []
const ctx = await seeded(
{ SECRETS_EGRESS_PLACEHOLDERS: 'on', EGRESS_PROXY_URL: PROXY_URL },
{
egressKeysForIssuer: () => createLocalJWKSet(jwks),
credentials: { authorizationFor: async () => 'Bearer ghs_fake' },
egressFetch: async (input, init) => {
upstream.push({ url: String(input), headers: new Headers(init?.headers) })
return new Response('ok', { status: 200 })
}
}
)
async function proxied(sandboxId: string, host: string, headers: Record<string, string>) {
const token = await new SignJWT({ team_id: 'team_orca', project_id: 'prj_orca', sandbox_name: `orca-test-${sandboxId}` })
.setProtectedHeader({ alg: 'RS256', kid: 'v1' })
.setIssuer('https://oidc.vercel.com/team_orca')
.setAudience(PROXY_URL)
.setIssuedAt()
.setExpirationTime('5m')
.sign(privateKey)
const response = await ctx.h.send(
new Request(`${PROXY_URL}/v1/messages`, {
method: 'POST',
body: '{"model":"x"}',
headers: {
'vercel-forwarded-host': host,
'vercel-forwarded-scheme': 'https',
'vercel-forwarded-port': '443',
'vercel-forwarded-path': '/v1/messages',
'vercel-sandbox-oidc-token': token,
...headers
}
})
)
return response.status
}
return { ...ctx, proxied, upstream }
}
it('gives a shared workspace no placeholder and no route for the owner’s personal key; the team key still works', async () => {
const { h, create, proxied, upstream } = await placeholderSetup()
const id: string = (await create('team')).body.sandbox.id
const env = vmEnv(h)
expect(env.ANTHROPIC_API_KEY).toBe(`orca-ph-anthropic-api-key-${id}`)
expect(env).not.toHaveProperty('OPENAI_API_KEY')
expect(env).not.toHaveProperty('DB_PASSWORD')
const [box] = [...h.vercel.boxes.values()]
// No forward rule for the personal key: the host is plain allowlisted, nothing is swapped there.
expect(box!.policy.allow['api.openai.com']).toEqual([])
expect(await proxied(id, 'api.openai.com', { authorization: `Bearer orca-ph-openai-api-key-${id}` })).toBe(403)
expect(await proxied(id, 'api.anthropic.com', { 'x-api-key': `orca-ph-anthropic-api-key-${id}` })).toBe(200)
expect(upstream).toHaveLength(1)
expect(upstream[0]!.headers.get('x-api-key')).toBe(TEAM_KEY)
})
it('stops swapping the owner’s personal key at the proxy as soon as a running workspace is shared', async () => {
const { h, create, share, proxied, upstream } = await placeholderSetup()
const id: string = (await create()).body.sandbox.id
const placeholder = `orca-ph-openai-api-key-${id}`
expect(vmEnv(h).OPENAI_API_KEY).toBe(placeholder)
expect(await proxied(id, 'api.openai.com', { authorization: `Bearer ${placeholder}` })).toBe(200)
expect(upstream.at(-1)!.headers.get('authorization')).toBe(`Bearer ${PERSONAL_OPENAI}`)
expect((await share(id, 'team')).status).toBe(200)
expect(vmEnv(h, ENV_COMMAND)).not.toHaveProperty('OPENAI_API_KEY')
// A process still holding the old placeholder gets nothing for it.
expect(await proxied(id, 'api.openai.com', { authorization: `Bearer ${placeholder}` })).toBe(403)
expect(upstream).toHaveLength(1)
})
})
+2 -1
View File
@@ -1,6 +1,7 @@
# SEC-3: moving the secrets-store KEK behind a KMS (design, not implemented)
Status: design and recommendation, 2026-11-12. Nothing here has been built or provisioned. No prod, no Terraform.
Status: phase 1 Worker code is implemented behind `SECRETS_KEK_SOURCE=kms` (off by default; `src/secrets-kms.ts`, tested against a fake KMS only, never against Google). No KMS resources exist: provisioning and turn-on are in the launch runbook, for Neil. Phase 2 is design only.
Differences from the plan below: the wrapped KEK is bound to its KEK id with KMS additional authenticated data; there is no cache window or stale-on-error (keys stay in memory for the isolate's life, so revocation needs a redeploy); a failed unwrap is retried at most every 10 seconds; with the flag on, `SECRETS_KEK` is never read (no fallback), and create/wake answer 503 `secrets_unavailable`.
Prices, latencies and vendor feature claims are from memory of public docs and are **unmeasured**; check each before committing.
## Where we are after the SEC-3 PR
-21
View File
@@ -1,21 +0,0 @@
// "Copy link" on billing buttons: the Stripe Checkout or Customer Portal URL the button would open,
// minted the same way (same account, same order, same setup-session fallback) but returned instead.
import type { OrcaBillingCheckoutArgs, OrcaBillingLinkResult } from '../../shared/orca-billing'
import { createCheckoutUrl, createPortalUrl } from './billing-client'
import { mintHosted } from './billing-service'
import { withSetupSessionFallback } from './billing-setup-session-fallback'
export function getOrcaBillingCheckoutLink(
userDataPath: string,
args: OrcaBillingCheckoutArgs
): Promise<OrcaBillingLinkResult> {
return withSetupSessionFallback(args, (checkoutArgs) =>
mintHosted(userDataPath, (token, endpoints) =>
createCheckoutUrl(endpoints, token, checkoutArgs)
)
)
}
export function getOrcaBillingPortalLink(userDataPath: string): Promise<OrcaBillingLinkResult> {
return mintHosted(userDataPath, (token, endpoints) => createPortalUrl(endpoints, token))
}
+1 -1
View File
@@ -222,7 +222,7 @@ export async function getOrcaBillingTeamPlans(
type CreateHostedUrl = (token: string, endpoints: BillingEndpoints) => Promise<string>
/** Mints a Stripe-hosted Checkout/Portal URL for the active account without opening it. */
export async function mintHosted(
async function mintHosted(
userDataPath: string,
createUrl: CreateHostedUrl
): Promise<OrcaBillingLinkResult> {
+16 -5
View File
@@ -93,18 +93,23 @@ describe('cloud setup service', () => {
})
it('names the Mac by its Computer Name, apostrophe included, and falls back to the hostname', async () => {
const named = (name: string, final = true) => async () => ({ name, final })
const named =
(name: string, final = true) =>
async () => ({ name, final })
expect(
await cloudSetupDeviceLabel({ platform: 'darwin', detect: named("m4air's MacBook Air") })
).toBe("m4air's MacBook Air")
// scutil timed out or failed: the cleaned hostname, as before.
expect(
await cloudSetupDeviceLabel({ platform: 'darwin', detect: named('m4airs-MacBook-Air', false) })
await cloudSetupDeviceLabel({
platform: 'darwin',
detect: named('m4airs-MacBook-Air', false)
})
).toBe(setupDeviceLabel())
// Off macOS the hostname is the name.
expect(
await cloudSetupDeviceLabel({ platform: 'linux', detect: named('ignored') })
).toBe(setupDeviceLabel())
expect(await cloudSetupDeviceLabel({ platform: 'linux', detect: named('ignored') })).toBe(
setupDeviceLabel()
)
})
it('validates start args from the renderer', () => {
@@ -116,5 +121,11 @@ describe('cloud setup service', () => {
cloudSetupStartArgsFromUnknown({ needs: ['team'], repo: 'https://gitlab.com/a/b' })
).toThrow()
expect(() => cloudSetupStartArgsFromUnknown({ needs: ['bogus'] })).toThrow()
// COPY-LINK-FIRST-CLICK: Copy link starts setup without the browser.
expect(cloudSetupStartArgsFromUnknown({ needs: ['plan'], openBrowser: false })).toEqual({
needs: ['plan'],
openBrowser: false
})
expect(() => cloudSetupStartArgsFromUnknown({ needs: ['plan'], openBrowser: 'no' })).toThrow()
})
})
+15 -2
View File
@@ -153,8 +153,18 @@ export async function startCloudSetup(
try {
const signedIn = await create()
if (signedIn.status === 'ok') {
const opened = {
status: 'opened' as const,
sessionId: signedIn.value.id,
expiresAt: signedIn.value.expiresAt
}
// Copy link: the same session, its link returned instead of opened. It expires on the
// server at `expiresAt`, like a session whose opened tab was never used.
if (args.openBrowser === false) {
return { ...opened, url: signedIn.value.url }
}
await openInBrowser(signedIn.value.url)
return { status: 'opened', sessionId: signedIn.value.id, expiresAt: signedIn.value.expiresAt }
return opened
}
if (signedIn.status === 'unconfigured') {
return { status: 'unconfigured' }
@@ -180,7 +190,10 @@ export async function startCloudSetup(
outcome.error = error
}
return outcome.created?.url ?? null
}
},
// Copy link while signed out: the sign-in (which then carries on into setup in the same
// tab) starts without the browser; its link is the sign-in progress link.
openBrowser: args.openBrowser !== false
})
if (connected.status === 'cancelled' || connected.status === 'timed-out') {
return { status: 'cancelled' }
+78 -10
View File
@@ -147,19 +147,87 @@ describe('teammate names', () => {
expect(keys.map((key) => key.createdByName)).toEqual(['Ada Lovelace', null, undefined])
})
it('reads audit actor names', async () => {
const entry = {
id: 'a1',
actor: { type: 'user', id: 'u_ada', userId: 'u_ada', userName: 'Ada Lovelace' },
action: 'api_key.create',
target: null,
createdAt: 1
it('reads the team audit log: filters go in the query, unknown kinds and categories still parse', async () => {
const event = {
id: 'aud_1',
at: 5,
category: 'something_new',
action: 'secret.updated',
actor: { kind: 'robot', userId: 'u_ada', name: 'Ada Lovelace', via: 'carrier_pigeon' },
target: { type: 'secret', id: 'STRIPE_KEY', label: 'STRIPE_KEY' },
detail: { scope: 'org' }
}
const fetchImpl = vi.fn<typeof fetch>(async () =>
respond(200, { entries: [entry], nextBefore: null })
respond(200, {
events: [event],
nextCursor: '5.aud_1',
actors: [event.actor],
categories: ['secrets']
})
)
const page = await client(fetchImpl).listAudit()
expect(page.entries[0]?.actor.userName).toBe('Ada Lovelace')
const page = await client(fetchImpl).listAuditLog({
category: 'secrets',
actor: 'u_ada',
limit: 50
})
expect(String(fetchImpl.mock.calls[0]?.[0])).toBe(
'https://cp.example.dev/v1/audit-log?category=secrets&actor=u_ada&limit=50'
)
expect(page.nextCursor).toBe('5.aud_1')
expect(page.events[0]).toMatchObject({
category: 'other',
actor: { kind: 'person', name: 'Ada Lovelace', via: null },
target: { label: 'STRIPE_KEY' }
})
})
it('tells a plan without the audit log apart from a missing role and an older control plane', async () => {
const answer = (status: number, body: unknown) =>
codeOf(client(vi.fn<typeof fetch>(async () => respond(status, body))).listAuditLog())
expect(await answer(403, { error: 'entitlement_denied', reason: 'audit_log' })).toBe(
'plan-required'
)
expect(await answer(403, { error: 'forbidden', reason: 'admin_required' })).toBe(
'admin-required'
)
expect(await answer(403, { error: 'insufficient_scope', reason: 'audit:read' })).toBe(
'forbidden'
)
expect(await answer(404, { error: 'not_found' })).toBe('not-available')
expect(await answer(503, { error: 'billing_unavailable' })).toBe('server')
const refused = await cloudApiErrorOf(
client(
vi.fn<typeof fetch>(async () =>
respond(403, { error: 'entitlement_denied', reason: 'audit_log' })
)
).listAuditLog()
)
expect(refused.detail).toBe('audit_log')
})
it('exports the audit log as CSV text and says when the export was cut short', async () => {
const csv = 'time,category,action\r\n2026-11-12T00:00:00.000Z,secrets,secret.updated\r\n'
const fetchImpl = vi.fn<typeof fetch>(
async () =>
new Response(csv, {
status: 200,
headers: { 'content-type': 'text/csv', 'x-orca-audit-truncated': 'true' }
})
)
const exported = await client(fetchImpl).exportAuditLogCsv({ category: 'secrets' })
expect(String(fetchImpl.mock.calls[0]?.[0])).toBe(
'https://cp.example.dev/v1/audit-log?category=secrets&format=csv'
)
expect(exported).toEqual({ csv, truncated: true })
expect(
await codeOf(
client(
vi.fn<typeof fetch>(async () =>
respond(403, { error: 'entitlement_denied', reason: 'audit_log' })
)
).exportAuditLogCsv()
)
).toBe('plan-required')
})
})
+55 -10
View File
@@ -4,7 +4,7 @@
import { z } from 'zod'
import {
CloudApiApprovalSchema,
CloudApiAuditPageSchema,
CloudAuditLogPageSchema,
CloudApiCreatedKeySchema,
CloudApiGrantListSchema,
CloudApiInstallationListSchema,
@@ -20,6 +20,8 @@ import {
RoutineUpdatedSchema,
RoutineWebhookSchema,
type CloudApiErrorCode,
type CloudAuditLogExport,
type CloudAuditLogQuery,
type CreateCloudApiApprovalInput,
type CreateCloudApiKeyInput,
type RoutineInput,
@@ -29,6 +31,8 @@ import {
const REQUEST_TIMEOUT_MS = 20_000
const MAX_DETAIL_CHARS = 200
// 5000 events is about 1.5 MB; anything far past that is not the export this client asked for.
const MAX_EXPORT_CHARS = 16 * 1024 * 1024
export class CloudApiError extends Error {
constructor(
@@ -80,6 +84,9 @@ export function classifyCloudApiResponse(
return { code: 'signed-out' }
}
if (status === 403) {
if (error === 'entitlement_denied') {
return { code: 'plan-required', ...(reason ? { detail: reason } : {}) }
}
if (reason === 'admin_required' || reason?.startsWith('admin_scope:')) {
return { code: 'admin-required' }
}
@@ -100,21 +107,31 @@ export function classifyCloudApiResponse(
return { code: 'server', detail: error ?? `HTTP ${status}` }
}
/** The "Couldn't switch to {team}" message, when the token could not be minted for the team. */
function orgSwitchDetail(error: unknown): string | undefined {
return error instanceof Error && 'reason' in error && error.reason === 'org_mismatch'
? error.message
: undefined
}
export function createCloudApiClient(deps: CloudApiClientDeps) {
const fetchImpl = deps.fetchImpl ?? fetch
const base = `${deps.controlPlaneUrl.replace(/\/+$/, '')}/`
async function request<S extends z.ZodType>(
/** The authenticated call: a 2xx response, or a CloudApiError worded from the answer. */
async function send(
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
path: string,
schema: S,
options: RequestOptions = {}
): Promise<z.infer<S>> {
): Promise<Response> {
let token: string
try {
token = await deps.getAccessToken()
} catch (error) {
throw new CloudApiError(deps.classifyTokenError?.(error) ?? 'signed-out')
throw new CloudApiError(
deps.classifyTokenError?.(error) ?? 'signed-out',
orgSwitchDetail(error)
)
}
const url = new URL(path.replace(/^\//, ''), base)
for (const [name, value] of Object.entries(options.query ?? {})) {
@@ -135,11 +152,12 @@ export function createCloudApiClient(deps: CloudApiClientDeps) {
redirect: 'error',
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
})
} catch {
throw new CloudApiError('network')
} catch (error) {
// The control-plane fetch throws when a re-minted token still names another team.
throw new CloudApiError('network', orgSwitchDetail(error))
}
const body: unknown = await response.json().catch(() => null)
if (!response.ok) {
const body: unknown = await response.json().catch(() => null)
const { code, detail } = classifyCloudApiResponse(response.status, body, options)
const retryAfter = Number(response.headers.get('retry-after'))
throw new CloudApiError(
@@ -150,6 +168,17 @@ export function createCloudApiClient(deps: CloudApiClientDeps) {
: undefined
)
}
return response
}
async function request<S extends z.ZodType>(
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
path: string,
schema: S,
options: RequestOptions = {}
): Promise<z.infer<S>> {
const response = await send(method, path, options)
const body: unknown = await response.json().catch(() => null)
const parsed = schema.safeParse(body)
if (!parsed.success) {
throw new CloudApiError('invalid-response')
@@ -183,8 +212,24 @@ export function createCloudApiClient(deps: CloudApiClientDeps) {
getSettings: () => request('GET', 'v1/settings', CloudApiSettingsSchema),
putSettings: (settings: { vmCliEnabled: boolean }) =>
request('PUT', 'v1/settings', CloudApiSettingsSchema, { body: settings }),
listAudit: (query: { limit?: number; before?: number } = {}) =>
request('GET', 'v1/audit', CloudApiAuditPageSchema, { query }),
listAuditLog: (query: CloudAuditLogQuery = {}) =>
request('GET', 'v1/audit-log', CloudAuditLogPageSchema, {
query,
notFoundMeansUnavailable: true
}),
exportAuditLogCsv: async (
query: Pick<CloudAuditLogQuery, 'category' | 'actor'> = {}
): Promise<CloudAuditLogExport> => {
const response = await send('GET', 'v1/audit-log', {
query: { ...query, format: 'csv' },
notFoundMeansUnavailable: true
})
const csv = await response.text().catch(() => null)
if (csv === null || csv.length > MAX_EXPORT_CHARS) {
throw new CloudApiError('invalid-response')
}
return { csv, truncated: response.headers.get('x-orca-audit-truncated') === 'true' }
},
listProjects: () => request('GET', 'v1/projects', CloudApiProjectListSchema),
listInstallations: () =>
request('GET', 'v1/github/installations', CloudApiInstallationListSchema, {
+8 -7
View File
@@ -1,6 +1,7 @@
import { ipcMain } from 'electron'
import { z } from 'zod'
import {
CloudAuditLogQuerySchema,
CreateCloudApiApprovalInputSchema,
CreateCloudApiKeyInputSchema,
RoutineInputSchema,
@@ -16,12 +17,7 @@ import {
} from './cloud-api-service'
const Id = z.string().min(1).max(128)
const AuditQuery = z
.object({
limit: z.number().int().min(1).max(200).optional(),
before: z.number().int().nonnegative().optional()
})
.strict()
const AuditExportQuery = CloudAuditLogQuerySchema.pick({ category: true, actor: true })
const SettingsInput = z.object({ vmCliEnabled: z.boolean() }).strict()
type Handler = (...args: unknown[]) => Promise<CloudApiResult<unknown>>
@@ -59,7 +55,12 @@ const HANDLERS: Record<string, Handler> = {
'cloudApi:revokeGrant': call([Id], (c, id) => c.revokeGrant(id)),
'cloudApi:getSettings': call([], (c) => c.getSettings()),
'cloudApi:putSettings': call([SettingsInput], (c, input) => c.putSettings(input)),
'cloudApi:listAudit': call([AuditQuery.optional()], (c, q) => c.listAudit(q ?? {})),
'cloudApi:listAuditLog': call([CloudAuditLogQuerySchema.optional()], (c, q) =>
c.listAuditLog(q ?? {})
),
'cloudApi:exportAuditLogCsv': call([AuditExportQuery.optional()], (c, q) =>
c.exportAuditLogCsv(q ?? {})
),
'cloudApi:listProjects': call([], (c) => c.listProjects()),
'cloudApi:listInstallations': call([], (c) => c.listInstallations()),
'cloudApi:listRoutines': call([], (c) => c.listRoutines()),
+1
View File
@@ -49,6 +49,7 @@ function resolveClient(): { client: CloudApiClient; controlPlaneUrl: string } |
client: createCloudApiClient({
controlPlaneUrl: config.controlPlaneUrl,
getAccessToken: () => config.getAccessToken(),
fetchImpl: config.fetch,
classifyTokenError
})
}
@@ -1,4 +1,6 @@
import { describe, expect, it } from 'vitest'
import { EnvironmentProviderError } from '../environment-providers/environment-provider-driver'
import { createOrgCheckedFetch } from '../hosted-sandbox/hosted-sandbox-org-fetch'
import { cloudSecretsFailureCode, createCloudSecretsClient } from './cloud-secrets-client'
type Seen = { method: string; url: string; body: string | null; authorization: string | null }
@@ -34,6 +36,52 @@ const VIEW = {
}
describe('createCloudSecretsClient', () => {
it('re-mints once on 401 stale_token (admin write window), and not for other 401s', async () => {
// The single re-mint path is the control-plane fetch (hosted-sandbox-org-fetch), as wired by
// cloud-secrets-ipc; the request layer itself never re-mints.
const tokens = ['old', 'new']
let invalidated = 0
const seen: string[] = []
let answers: Response[] = []
const getAccessToken = async (): Promise<string> => tokens[Math.min(invalidated, 1)] ?? 'new'
const client = createCloudSecretsClient({
baseUrl: 'https://control.test',
getAccessToken,
fetch: createOrgCheckedFetch({
fetchImpl: async (_input, init) => {
seen.push(new Headers(init?.headers).get('authorization') ?? '')
return answers.shift() ?? Response.json({}, { status: 500 })
},
orgHeader: () => 'org-1',
getAccessToken,
invalidateAccessToken: () => {
invalidated++
},
orgSwitchError: () => new EnvironmentProviderError('conflict', 'switch failed')
})
})
answers = [
Response.json({ error: 'unauthorized', reason: 'stale_token' }, { status: 401 }),
Response.json({ secret: VIEW })
]
await client.put({ scope: 'org' }, 'DATABASE_URL', 'value-not-logged')
expect(seen).toEqual(['Bearer old', 'Bearer new'])
expect(invalidated).toBe(1)
// Still stale after the re-mint: exactly one re-mint, then the refusal surfaces.
answers = [
Response.json({ error: 'unauthorized', reason: 'stale_token' }, { status: 401 }),
Response.json({ error: 'unauthorized', reason: 'stale_token' }, { status: 401 })
]
await expect(client.remove({ scope: 'org' }, 'DATABASE_URL')).rejects.toThrow()
expect(invalidated).toBe(2)
expect(seen).toHaveLength(4)
answers = [Response.json({ error: 'unauthorized' }, { status: 401 })]
await expect(client.remove({ scope: 'org' }, 'DATABASE_URL')).rejects.toThrow()
expect(invalidated).toBe(2)
})
it('reads who owns a sandbox and whether it is shared, failing closed on an unknown owner', async () => {
const answers = [
{ owner: { userId: 'u2', displayName: 'Ada', isCaller: false }, sharedWorkspace: true },
+2 -1
View File
@@ -101,7 +101,8 @@ export function registerCloudSecretsHandlers(userDataPath: string): void {
const client = config
? createCloudSecretsClient({
baseUrl: config.controlPlaneUrl,
getAccessToken: config.getAccessToken
getAccessToken: config.getAccessToken,
fetch: config.fetch
})
: null
// Agent API keys saved while signed in are written to personal secrets through the same client.
@@ -83,7 +83,7 @@ export function createCloudWorkspacesService(deps: {
const client: CloudWorkspacesClient = createCloudWorkspacesClient({
baseUrl: deps.config.controlPlaneUrl,
getAccessToken: deps.config.getAccessToken,
fetch: deps.fetch
fetch: deps.fetch ?? deps.config.fetch
})
let active: {
sandboxId: string
@@ -73,11 +73,6 @@ export async function createCloudWorkspaceFromGitHub(
onProgress: (progress) => io.onLog?.(hostedProgressLine(progress))
}
)
if (io.signal?.aborted) {
// Cancelled just as it answered: nothing will open it, so it must not keep running (and billing).
await client.destroy(ready.sandbox.id).catch(() => undefined)
return { ok: false, code: 'failed', message: 'Cancelled.' }
}
return { ok: true, value: { sandboxId: ready.sandbox.id } }
} catch (error) {
const result = hostedCreateFailure(error)
@@ -32,7 +32,7 @@ export function createHostedEnvironmentService(
? createHostedEnvironmentClient({
baseUrl: config.controlPlaneUrl,
getAccessToken: config.getAccessToken,
fetch: fetchImpl
fetch: fetchImpl ?? config.fetch
})
: null
}
@@ -23,7 +23,6 @@ const SETUP_SESSION_ID = /^cs_[A-Za-z0-9_-]{22}$/
export const HOSTED_ACCOUNT_CHANNELS = [
CH.getConfig,
CH.connectGitHub,
CH.gitHubInstallLink,
CH.getRepoAccess,
CH.listGitHubRepositories,
CH.createFromGitHub,
@@ -107,15 +106,6 @@ export function registerHostedAccountHandlers(
}
})
// "Copy link": the same install page, minted the same way, for another browser or profile.
ipcMain.handle(CH.gitHubInstallLink, async (_event, args?: RepoArgs) => {
try {
return { ok: true, value: await mintInstallUrl(args) }
} catch (error) {
return failure(error)
}
})
ipcMain.handle(CH.getRepoAccess, async (_event, args?: RepoArgs) => {
const target = repoTarget(args)
if (!target) {
@@ -1,5 +1,6 @@
// "Copy link" twins of Connect GitHub (App install) and Connect your GitHub (OAuth): the same page,
// minted the same way, returned instead of opened, and still only ever a github.com page.
// "Copy link" for Connect your GitHub (OAuth sign-in): the same page, minted the same way, returned
// instead of opened, and still only ever a github.com page. The App install has no link channel:
// Copy link is for sign-in pages only.
import { afterEach, expect, it, vi } from 'vitest'
const handlers = new Map<string, (...args: unknown[]) => unknown>()
@@ -56,25 +57,24 @@ function register(url: string) {
return { connectGitHub, connectGitHubUser }
}
it('registers the link channels with the window allowlist', () => {
expect(HOSTED_ACCOUNT_CHANNELS).toEqual(
expect.arrayContaining([CH.gitHubInstallLink, CH.gitHubUserConnectLink])
)
it('registers the OAuth link channel, and no App-install link channel', () => {
expect(HOSTED_ACCOUNT_CHANNELS).toContain(CH.gitHubUserConnectLink)
expect(HOSTED_ACCOUNT_CHANNELS).not.toContain('orcaCloudWorkspace:gitHubInstallLink')
})
it('returns the install page minted like Connect GitHub, without opening it', async () => {
it('Connect GitHub still opens the install page minted for the repository', async () => {
const url = 'https://github.com/apps/x/installations/new?state=s'
const { connectGitHub } = register(url)
expect(
await handlers.get(CH.gitHubInstallLink)?.({}, { repoUrl: REPO, setupSessionId: SESSION })
).toEqual({ ok: true, value: url })
await handlers.get(CH.connectGitHub)?.({}, { repoUrl: REPO, setupSessionId: SESSION })
).toEqual({ ok: true, value: null })
expect(connectGitHub).toHaveBeenLastCalledWith(undefined, {
repo: REPO,
setupSessionId: SESSION,
repoId: 7,
ownerId: 50
})
expect(openExternal).not.toHaveBeenCalled()
expect(openExternal).toHaveBeenCalledWith(url)
})
it('returns the OAuth page minted like Connect your GitHub, without opening it', async () => {
@@ -84,7 +84,7 @@ it('returns the OAuth page minted like Connect your GitHub, without opening it',
expect(openExternal).not.toHaveBeenCalled()
})
it('never hands out a page that is not on github.com', async () => {
it('never opens or hands out a page that is not on github.com', async () => {
for (const url of [
'https://github.com.evil.example/install',
'http://github.com/login/oauth/authorize',
@@ -92,7 +92,7 @@ it('never hands out a page that is not on github.com', async () => {
]) {
handlers.clear()
register(url)
expect(await handlers.get(CH.gitHubInstallLink)?.({}, {})).toMatchObject({
expect(await handlers.get(CH.connectGitHub)?.({}, {})).toMatchObject({
ok: false,
code: 'failed'
})
@@ -101,4 +101,5 @@ it('never hands out a page that is not on github.com', async () => {
code: 'failed'
})
}
expect(openExternal).not.toHaveBeenCalled()
})
@@ -12,7 +12,7 @@ import {
unlinkOrcaProfileFromCloud
} from '../orca-profiles/cloud-auth/profile-cloud-index'
import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store'
import { getHostedSandboxConfig } from './hosted-sandbox-config'
import { getHostedSandboxConfig, isMfaRequiredRefusal } from './hosted-sandbox-config'
const state = { dir: '' }
installFakeAppEnvironment({ getPath: () => state.dir })
@@ -118,3 +118,21 @@ describe('getHostedSandboxConfig sandbox tokens', () => {
expect(claims(await b.getAccessToken()).sub).toBe('user-b')
})
})
describe('isMfaRequiredRefusal', () => {
it('recognizes the team MFA refusal and leaves the body readable', async () => {
const response = Response.json(
{ error: 'mfa_required', orgId: 'org_1', enforceAt: 5 },
{ status: 403 }
)
expect(await isMfaRequiredRefusal(response)).toBe(true)
expect(await response.json()).toMatchObject({ orgId: 'org_1' })
})
it('is false for other refusals and for bodies that are not JSON', async () => {
for (const body of [{ error: 'not_a_member' }, { error: 'sandbox_not_entitled' }, {}, null]) {
expect(await isMfaRequiredRefusal(Response.json(body, { status: 403 }))).toBe(false)
}
expect(await isMfaRequiredRefusal(new Response('<html>', { status: 403 }))).toBe(false)
})
})
+160 -62
View File
@@ -13,6 +13,9 @@ import {
type ActiveOrcaProfileState
} from '../orca-profiles/profile-index-store'
import { getOrcaProfileIndexGeneration } from '../orca-profiles/profile-index-generation'
import { getOrcaCloudTokenEpoch } from '../orca-profiles/cloud-token-epoch'
import { createOrgCheckedFetch, ORCA_ORG_HEADER_PERSONAL } from './hosted-sandbox-org-fetch'
import { isNotAMember, orgSwitchError, readTokenOrg } from './hosted-sandbox-token-org'
import { EnvironmentProviderError } from '../environment-providers/environment-provider-driver'
export type HostedSandboxConfig = {
@@ -20,6 +23,29 @@ export type HostedSandboxConfig = {
getAccessToken: (signal?: AbortSignal) => Promise<string>
/** Drops the cached token so the next call mints one (the control plane said it predates a role change). */
invalidateAccessToken?: () => void
/**
* fetch for control-plane calls: adds `x-orca-org` (the team the desktop shows, or `user`), and
* on a 409 org_mismatch or a stale_token refusal re-mints once and retries.
*/
fetch?: typeof fetch
}
/** `reason` on the provider error when the team's MFA policy refused the token. */
export const MFA_REQUIRED_REASON = 'mfa_required'
/** Reads a copy of the body, so the caller can still read the response. */
export async function isMfaRequiredRefusal(response: Response): Promise<boolean> {
try {
const body: unknown = await response.clone().json()
return (
typeof body === 'object' &&
body !== null &&
'error' in body &&
body.error === MFA_REQUIRED_REASON
)
} catch {
return false
}
}
const SandboxTokenBody = z.object({
@@ -45,8 +71,12 @@ type CachedSandboxToken = {
identity: string
/** Profile index generation the identity was last confirmed at. */
generation: number
/** Team-switch / membership / role epoch the token was minted under; any change drops it. */
epoch: number
}
type MintedSandboxToken = { token: string; expiresAt: number }
/** Sandbox tokens are minted per call by Orca Cloud auth; cached until a minute before expiry. */
export function getHostedSandboxConfig(
userDataPath: string,
@@ -62,69 +92,109 @@ export function getHostedSandboxConfig(
? env.ORCA_CLOUD_SANDBOX_DEV_KEY_FILE?.trim()
: undefined
let cached: CachedSandboxToken | null = null
const explicitOrg: ExplicitOrgSupport = { unsupportedUntil: 0 }
let inflight: {
identity: string
promise: Promise<{ token: string; expiresAt: number }>
epoch: number
promise: Promise<MintedSandboxToken>
} | null = null
const fresh = (entry: CachedSandboxToken | null): entry is CachedSandboxToken =>
entry !== null && entry.expiresAt - Date.now() > 60_000
entry !== null &&
entry.expiresAt - Date.now() > 60_000 &&
entry.epoch === getOrcaCloudTokenEpoch()
const mint = (active: ActiveOrcaProfileState) =>
devKeyFile
? mintDevSandboxToken(devKeyFile, active.profile.cloud, env)
: mintSandboxToken(userDataPath, active, env, packaged, fetchImpl)
return {
controlPlaneUrl,
getAccessToken: async () => {
// Why: sign-in, sign-out and team or profile switches all rewrite the profile index. A token
// minted for the previous identity would create sandboxes the new one can't see or delete.
if (fresh(cached) && cached.generation === getOrcaProfileIndexGeneration()) {
: mintSandboxToken(userDataPath, active, env, packaged, fetchImpl, explicitOrg)
// Concurrent callers for the same identity and epoch share one mint.
const sharedMint = (active: ActiveOrcaProfileState, identity: string, epoch: number) => {
if (inflight?.identity === identity && inflight.epoch === epoch) {
return inflight.promise
}
const entry = { identity, epoch, promise: mint(active) }
inflight = entry
void entry.promise
.catch(() => undefined)
.finally(() => {
if (inflight === entry) {
inflight = null
}
})
return entry.promise
}
const getAccessToken = async (): Promise<string> => {
// Why: sign-in, sign-out and team or profile switches all rewrite the profile index. A token
// minted for the previous identity would create sandboxes the new one can't see or delete.
if (fresh(cached) && cached.generation === getOrcaProfileIndexGeneration()) {
return cached.token
}
let orgMismatches = 0
for (let attempt = 0; ; attempt++) {
const generation = getOrcaProfileIndexGeneration()
const epoch = getOrcaCloudTokenEpoch()
const active = ensureActiveOrcaProfile(userDataPath)
const identity = sandboxIdentity(active)
if (fresh(cached) && cached.identity === identity) {
cached = { ...cached, generation }
return cached.token
}
for (let attempt = 0; ; attempt++) {
const generation = getOrcaProfileIndexGeneration()
const active = ensureActiveOrcaProfile(userDataPath)
const identity = sandboxIdentity(active)
if (fresh(cached) && cached.identity === identity) {
cached = { ...cached, generation }
return cached.token
}
// Concurrent callers for the same identity share one mint.
let shared = inflight?.identity === identity ? inflight : null
if (!shared) {
const entry = { identity, promise: mint(active) }
shared = entry
inflight = entry
void entry.promise
.catch(() => undefined)
.finally(() => {
if (inflight === entry) {
inflight = null
}
})
}
const minted = await shared.promise
// An identity change while minting must not hand the old identity's token to the caller.
if (
generation !== getOrcaProfileIndexGeneration() &&
sandboxIdentity(ensureActiveOrcaProfile(userDataPath)) !== identity
) {
if (attempt >= 2) {
throw new EnvironmentProviderError(
'unavailable',
'Your Orca account changed while signing in to Orca Cloud. Try again.'
)
}
continue
}
cached = { ...minted, identity, generation }
return minted.token
}
},
invalidateAccessToken: () => {
// A fresh mint on the next call; one already in flight is fresh anyway.
// Only one identity is ever cached: a switch drops every other team's token.
cached = null
const pending = sharedMint(active, identity, epoch)
const minted = await pending
// An identity, team or role change while minting must not hand the old token to the caller.
const identityMoved =
generation !== getOrcaProfileIndexGeneration() &&
sandboxIdentity(ensureActiveOrcaProfile(userDataPath)) !== identity
if (identityMoved || epoch !== getOrcaCloudTokenEpoch()) {
if (attempt >= 2) {
throw new EnvironmentProviderError(
'unavailable',
'Your Orca account changed while signing in to Orca Cloud. Try again.'
)
}
continue
}
// Why: auth may still pick the team from session state (the old team mid-switch). Never
// cache or use a token for any team other than the one this identity asked for.
const requestedOrg = active.profile.cloud?.activeOrgId ?? null
const tokenOrg = readTokenOrg(minted.token)
if (tokenOrg !== requestedOrg) {
console.warn(
`[orca-cloud] sandbox token org mismatch: requested=${requestedOrg ?? 'user'} got=${tokenOrg ?? 'user'}`
)
orgMismatches++
// The re-mint must ask auth again, not share the refused answer.
if (inflight?.promise === pending) {
inflight = null
}
if (orgMismatches >= 2) {
throw orgSwitchError(active)
}
continue
}
cached = { ...minted, identity, generation, epoch }
return minted.token
}
}
const invalidateAccessToken = (): void => {
// A fresh mint on the next call; one already in flight is fresh anyway.
cached = null
}
return {
controlPlaneUrl,
getAccessToken,
invalidateAccessToken,
fetch: createOrgCheckedFetch({
fetchImpl,
orgHeader: () =>
ensureActiveOrcaProfile(userDataPath).profile.cloud?.activeOrgId ??
ORCA_ORG_HEADER_PERSONAL,
getAccessToken,
invalidateAccessToken,
orgSwitchError: () => orgSwitchError(ensureActiveOrcaProfile(userDataPath))
})
}
}
function sandboxIdentity(active: ActiveOrcaProfileState): string {
@@ -132,17 +202,23 @@ function sandboxIdentity(active: ActiveOrcaProfileState): string {
return JSON.stringify([active.profile.id, cloud?.userId ?? null, cloud?.activeOrgId ?? null])
}
/** Auth older than the explicit-org mint body answers 400; skip the body for a while after one. */
type ExplicitOrgSupport = { unsupportedUntil: number }
const EXPLICIT_ORG_RETRY_MS = 10 * 60_000
async function mintSandboxToken(
userDataPath: string,
active: ActiveOrcaProfileState,
env: NodeJS.ProcessEnv,
packaged: boolean,
fetchImpl: typeof fetch
fetchImpl: typeof fetch,
explicitOrg: ExplicitOrgSupport
): Promise<{ token: string; expiresAt: number }> {
const auth = getOrcaCloudAuthConfig(env, packaged)
if (!auth.configured || !active.profile.cloud) {
throw new EnvironmentProviderError('unauthorized', 'Sign in to Orca to use Orca Cloud.')
}
const requestedOrg = active.profile.cloud.activeOrgId ?? null
const endpoint =
cleanCloudServiceUrl(env.ORCA_CLOUD_SANDBOX_TOKEN_URL, !packaged) ??
new URL('/v1/desktop/auth/sandbox-token', `${auth.config.apiBaseUrl}/`).toString()
@@ -151,16 +227,38 @@ async function mintSandboxToken(
active,
userDataPath,
async (session) => {
const response = await fetchImpl(endpoint, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${session.accessToken}`
},
body: '{}',
redirect: 'error',
signal: AbortSignal.timeout(15_000)
})
const post = (body: string) =>
fetchImpl(endpoint, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${session.accessToken}`
},
body,
redirect: 'error',
signal: AbortSignal.timeout(15_000)
})
// Ask for the team explicitly; auth must not pick it from session state mid-switch.
const explicit = Date.now() >= explicitOrg.unsupportedUntil
let response = await post(explicit ? JSON.stringify({ orgId: requestedOrg }) : '{}')
if (explicit && response.status === 400) {
// Auth older than the explicit-org contract rejects any body. The caller's org-claim check
// still refuses a token for the wrong team.
explicitOrg.unsupportedUntil = Date.now() + EXPLICIT_ORG_RETRY_MS
response = await post('{}')
}
if (response.status === 403 && requestedOrg && (await isNotAMember(response))) {
throw orgSwitchError(active)
}
if (response.status === 403 && (await isMfaRequiredRefusal(response))) {
// The team requires MFA and this person has none past the deadline. Signing in again
// would not help, so this is not a session error: the reason lets the UI show setup.
throw new EnvironmentProviderError(
'unauthorized',
'Your team requires two-factor authentication. Set it up to use its cloud workspaces.',
MFA_REQUIRED_REASON
)
}
if (response.status === 401 || response.status === 403) {
throw new OrcaCloudRequestError(response.status)
}
@@ -0,0 +1,105 @@
// GH-CREATE-CANCEL: a create cancelled in flight never leaves a sandbox running, for the owned
// create and the GitHub-only create alike (both use createSandboxWithProgress).
import { describe, expect, it, vi } from 'vitest'
import { EnvironmentProviderError } from '../environment-providers/environment-provider-driver'
import { HostedEntitlementDeniedError } from './hosted-environment-provider'
import { reclaimCancelledCreate } from './hosted-sandbox-create-reclaim'
import { createSandboxWithProgress } from './hosted-sandbox-driver'
const READY = {
sandbox: {
id: 'sbx_1',
workspaceId: 'web-1',
status: 'running' as const,
machineSize: 'standard' as const,
region: 'iad1',
session: null,
createdAt: 1
},
connection: { type: 'orca-server' as const, pairingCode: 'code', projectRoot: '/work' }
}
const BODY = { workspaceId: 'web-1', machineSize: 'standard' as const }
const quiet = { waitMs: 0, log: () => {} }
function client() {
return {
create: vi.fn(async () => READY),
bootPlan: vi.fn(async () => 'clone' as const),
cloneStatus: vi.fn(async () => ({ state: 'done' as const })),
destroy: vi.fn(async (id: string) => ({ id, status: 'destroyed' as const }))
}
}
describe('reclaimCancelledCreate', () => {
it('replays the create by its key and deletes the sandbox it gets back', async () => {
const api = client()
expect(await reclaimCancelledCreate(api, BODY, 'create:k1', quiet)).toBe(true)
expect(api.create).toHaveBeenCalledWith(BODY, undefined, 'create:k1')
expect(api.destroy).toHaveBeenCalledWith('sbx_1')
})
it('waits while the first create is still in progress (409), then deletes', async () => {
const api = client()
api.create
.mockRejectedValueOnce(new EnvironmentProviderError('conflict', 'idempotency_in_progress'))
.mockRejectedValueOnce(new EnvironmentProviderError('unavailable', 'try later'))
expect(await reclaimCancelledCreate(api, BODY, 'create:k1', quiet)).toBe(true)
expect(api.create).toHaveBeenCalledTimes(3)
expect(api.destroy).toHaveBeenCalledWith('sbx_1')
})
it('a refused replay means the cancelled create made nothing either', async () => {
const api = client()
api.create.mockRejectedValueOnce(new HostedEntitlementDeniedError('denied', 'no_subscription'))
expect(await reclaimCancelledCreate(api, BODY, 'create:k1', quiet)).toBe(true)
expect(api.destroy).not.toHaveBeenCalled()
})
it('gives up (false) when it can never tell', async () => {
const api = client()
api.create.mockRejectedValue(new EnvironmentProviderError('unavailable', 'down'))
expect(await reclaimCancelledCreate(api, BODY, 'create:k1', { ...quiet, attempts: 3 })).toBe(
false
)
})
})
describe('createSandboxWithProgress cancel', () => {
const spec = { workspaceId: 'web-1', machineSize: 'standard', idempotencyKey: 'create:k1' }
it('cancelled while the create was in flight: reclaims by key, then reports the cancel', async () => {
const api = client()
const controller = new AbortController()
api.create.mockImplementationOnce(async () => {
controller.abort()
throw new EnvironmentProviderError('aborted', 'Cancelled.')
})
await expect(
createSandboxWithProgress(api, spec, { signal: controller.signal })
).rejects.toMatchObject({ code: 'aborted' })
// The replay (no signal, same key) found the sandbox and deleted it.
expect(api.create).toHaveBeenLastCalledWith(expect.anything(), undefined, 'create:k1')
expect(api.destroy).toHaveBeenCalledWith('sbx_1')
})
it('cancelled just as the create answered: deletes the sandbox and reports the cancel', async () => {
const api = client()
const controller = new AbortController()
api.create.mockImplementationOnce(async () => {
controller.abort()
return READY
})
await expect(
createSandboxWithProgress(api, spec, { signal: controller.signal })
).rejects.toMatchObject({ code: 'aborted' })
expect(api.destroy).toHaveBeenCalledWith('sbx_1')
expect(api.create).toHaveBeenCalledTimes(1)
})
it('not cancelled: no replay, no delete', async () => {
const api = client()
await expect(createSandboxWithProgress(api, spec, {})).resolves.toBe(READY)
expect(api.create).toHaveBeenCalledTimes(1)
expect(api.destroy).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,64 @@
// A create cancelled while its request was in flight may still have made a sandbox on the control
// plane, and a sandbox bills (GH-CREATE-CANCEL). The control plane keys creates by Idempotency-Key
// and body: sending the same create again hands back the sandbox the first one made (or makes one, if
// the first never arrived), and that sandbox is deleted. Shared by the owned create (the recipe) and
// the GitHub-only create, which both go through createSandboxWithProgress.
import { isEnvironmentProviderError } from '../environment-providers/environment-provider-driver'
import type { HostedSandboxControlPlaneClient } from './hosted-sandbox-control-plane-client'
type CreateBody = Parameters<HostedSandboxControlPlaneClient['create']>[0]
const RECLAIM_ATTEMPTS = 8
const RECLAIM_WAIT_MS = 5_000
/** The first create may still be running (409 in progress) or the service briefly unreachable. */
function worthRetrying(error: unknown): boolean {
return (
isEnvironmentProviderError(error, 'conflict') ||
isEnvironmentProviderError(error, 'unavailable') ||
isEnvironmentProviderError(error, 'timeout') ||
!isEnvironmentProviderError(error)
)
}
/**
* Deletes whatever the cancelled create made. True once it is gone (or the control plane refused
* the create, so nothing was made); false when it still couldn't tell after every attempt.
*/
export async function reclaimCancelledCreate(
client: Pick<HostedSandboxControlPlaneClient, 'create' | 'destroy'>,
body: CreateBody,
idempotencyKey: string,
{
attempts = RECLAIM_ATTEMPTS,
waitMs = RECLAIM_WAIT_MS,
log = (message: string) => console.warn(message)
}: { attempts?: number; waitMs?: number; log?: (message: string) => void } = {}
): Promise<boolean> {
for (let attempt = 1; attempt <= attempts; attempt += 1) {
let sandboxId: string
try {
// No signal: this request is the cleanup and must not be cancelled with the create.
sandboxId = (await client.create(body, undefined, idempotencyKey)).sandbox.id
} catch (error) {
if (!worthRetrying(error)) {
// Refused (billing, sign-in, bad request): the cancelled create made nothing either.
return true
}
log(
`[orca-cloud] cancelled create: replay ${attempt}/${attempts} failed (${isEnvironmentProviderError(error) ? error.code : 'error'})`
)
await new Promise((resolve) => setTimeout(resolve, waitMs))
continue
}
try {
await client.destroy(sandboxId)
log(`[orca-cloud] cancelled create: deleted sandbox ${sandboxId}`)
return true
} catch {
await new Promise((resolve) => setTimeout(resolve, waitMs))
}
}
log('[orca-cloud] cancelled create: could not confirm the sandbox was deleted')
return false
}
@@ -25,7 +25,7 @@ async function isStaleToken(response: Response): Promise<boolean> {
export function createHostedSandboxDeviceEnroller(
config: HostedSandboxConfig,
fetchImpl: typeof fetch = fetch
fetchImpl: typeof fetch = config.fetch ?? fetch
): OrcaCloudDeviceEnroller {
async function token(): Promise<string> {
try {
@@ -25,6 +25,7 @@ import type {
} from './hosted-environment-provider'
import type { HostedSandboxControlPlaneClient } from './hosted-sandbox-control-plane-client'
import { takeSuspendReason } from './idle-sleep-requests'
import { reclaimCancelledCreate } from './hosted-sandbox-create-reclaim'
export const HOSTED_SANDBOX_DRIVER_ID = 'orca-cloud'
@@ -144,21 +145,34 @@ export async function createSandboxWithProgress(
)
.catch(() => undefined)
}
const ready = await client.create(
{
workspaceId: spec.workspaceId,
machineSize,
// Named rows: Home shows the workspace's name, never an untitled sandbox (PC-8).
...(spec.workspaceName?.trim() ? { title: spec.workspaceName.trim().slice(0, 120) } : {}),
...(spec.region ? { region: spec.region } : {}),
...(spec.repo ? { repo: spec.repo } : {})
},
ctx?.signal,
spec.idempotencyKey
)
const body = {
workspaceId: spec.workspaceId,
machineSize,
// Named rows: Home shows the workspace's name, never an untitled sandbox (PC-8).
...(spec.workspaceName?.trim() ? { title: spec.workspaceName.trim().slice(0, 120) } : {}),
...(spec.region ? { region: spec.region } : {}),
...(spec.repo ? { repo: spec.repo } : {})
}
let ready: HostedSandboxReadyResponse
try {
ready = await client.create(body, ctx?.signal, spec.idempotencyKey)
} catch (error) {
// GH-CREATE-CANCEL: cancelled in flight, the control plane may still have made it: find it by
// its key and delete it before reporting the cancel.
if (ctx?.signal?.aborted && spec.idempotencyKey) {
await reclaimCancelledCreate(client, body, spec.idempotencyKey)
}
throw error
}
if (ready.clone) {
// A cancelled, failed or stalled clone deletes the sandbox itself.
await waitForClone(client, ready.sandbox.id, ctx, { mode: ready.clone.mode ?? 'clone' })
}
if (ctx?.signal?.aborted) {
// Cancelled just as it answered: nothing will use it, so it must not keep running (and billing).
await client.destroy(ready.sandbox.id).catch(() => undefined)
throw new EnvironmentProviderError('aborted', 'Cancelled.')
}
ctx?.onProgress?.({ phase: 'connecting' })
return ready
}
@@ -136,7 +136,8 @@ export function installHostedSandbox(
installDevTokenClaimsProbe(() => config.getAccessToken(), app.isPackaged)
const mcpSync = installCloudMcpSync(userDataPath, {
baseUrl: config.controlPlaneUrl,
getAccessToken: config.getAccessToken
getAccessToken: config.getAccessToken,
fetch: config.fetch
})
const agentConfigSync = installCloudAgentConfigSync(userDataPath)
// Dev rehearsals shorten idle sleep; packaged builds always use the provider's default.
@@ -0,0 +1,72 @@
// fetch for Orca Cloud control-plane calls that names the team the desktop is acting for, so the
// Worker can refuse a token minted for another team (409 org_mismatch) instead of acting on it.
import type { EnvironmentProviderError } from '../environment-providers/environment-provider-driver'
/** Header the control plane checks against the token's `org` claim. */
export const ORCA_ORG_HEADER = 'x-orca-org'
/** `x-orca-org` value for a personal account: the token must carry no org. */
export const ORCA_ORG_HEADER_PERSONAL = 'user'
export const ORG_MISMATCH = 'org_mismatch'
const STALE_TOKEN = 'stale_token'
type Refusal = 'org_mismatch' | 'stale_token' | null
async function refusalOf(response: Response): Promise<Refusal> {
if (response.status !== 409 && response.status !== 401 && response.status !== 403) {
return null
}
const body: unknown = await response
.clone()
.json()
.catch(() => null)
if (typeof body !== 'object' || body === null) {
return null
}
const error = 'error' in body ? body.error : undefined
const reason = 'reason' in body ? body.reason : undefined
if (response.status === 409) {
return error === ORG_MISMATCH || reason === ORG_MISMATCH ? 'org_mismatch' : null
}
// Token minted before this user's role or membership changed (generic 401 means signed out).
return error === STALE_TOKEN || reason === STALE_TOKEN ? 'stale_token' : null
}
export function createOrgCheckedFetch(deps: {
fetchImpl: typeof fetch
/** The active team id, or `user` for a personal account; read per request. */
orgHeader: () => string
getAccessToken: (signal?: AbortSignal) => Promise<string>
invalidateAccessToken: () => void
orgSwitchError: () => EnvironmentProviderError
}): typeof fetch {
const send = (
input: Parameters<typeof fetch>[0],
init: RequestInit | undefined,
token?: string
): Promise<Response> => {
const headers = new Headers(init?.headers)
headers.set(ORCA_ORG_HEADER, deps.orgHeader())
if (token !== undefined) {
headers.set('authorization', `Bearer ${token}`)
}
return deps.fetchImpl(input, { ...init, headers })
}
return async (input, init) => {
const response = await send(input, init)
const refusal = await refusalOf(response)
if (!refusal) {
return response
}
// The token predates a team switch or role change: drop it, mint once more, ask again.
console.warn(`[orca-cloud] control plane refused token (${refusal}); re-minting once`)
await response.body?.cancel().catch(() => undefined)
deps.invalidateAccessToken()
const token = await deps.getAccessToken(init?.signal ?? undefined)
const retried = await send(input, init, token)
if ((await refusalOf(retried)) === 'org_mismatch') {
await retried.body?.cancel().catch(() => undefined)
throw deps.orgSwitchError()
}
return retried
}
}
@@ -0,0 +1,311 @@
// ORG-TOKEN-STALE: sandbox tokens are minted for the team the desktop shows, verified against the
// token's org claim, dropped on team/role changes, and re-minted once when the control plane refuses.
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installFakeAppEnvironment } from '../../../config/scripts/test-runtime/environment/vitest-host-ports-setup'
import { removeTreeSync } from '../../shared/windows-transient-lock-removal'
import type { OrcaProfileCloudSummary } from '../../shared/orca-profiles'
import { isEnvironmentProviderError } from '../environment-providers/environment-provider-driver'
import { linkOrcaProfileToCloud } from '../orca-profiles/cloud-auth/profile-cloud-index'
import { noteOrcaCloudMemberships } from '../orca-profiles/cloud-token-epoch'
import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store'
import { getHostedSandboxConfig } from './hosted-sandbox-config'
import { readTokenOrg } from './hosted-sandbox-token-org'
vi.mock('../orca-profiles/cloud-session/profile-cloud-session-refresh', () => ({
runWithFreshOrcaCloudSession: async (
_config: unknown,
_active: unknown,
_userDataPath: string,
operation: (session: { accessToken: string }) => Promise<unknown>
) => ({ status: 'ok', value: await operation({ accessToken: 'session-access' }) })
}))
const state = { dir: '' }
installFakeAppEnvironment({ getPath: () => state.dir })
const MINT_URL = 'http://127.0.0.1:9/v1/desktop/auth/sandbox-token'
const CONTROL_PLANE = 'https://sandbox.cloud.test'
// Fixed expiry (2100-01-01) so minted tokens stay fresh without reading the wall clock.
const FAR_FUTURE_MS = 4_102_444_800_000
/** Unsigned token-shaped test value; only the claims matter to the desktop. */
function fakeToken(org: string | null, serial: number): string {
const claims = { sub: 'user-a', ...(org ? { org } : {}), purpose: 'sandbox', serial }
return `h.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.s`
}
function tokenOrg(authorization: string | null): string | null {
return readTokenOrg(authorization?.replace(/^Bearer /, '') ?? '')
}
function cloud(activeOrgId?: string, activeOrgName?: string): OrcaProfileCloudSummary {
return {
cloudProfileId: 'cp-user-a',
userId: 'user-a',
email: 'user-a@example.test',
...(activeOrgId ? { activeOrgId } : {}),
...(activeOrgName ? { activeOrgName } : {}),
linkedAt: 1
}
}
type MintCall = { body: string }
/**
* Fake auth + control plane. `issue` decides the org claim of each minted token from the requested
* org; the control plane answers via `controlPlane`.
*/
function harness(options: {
issue?: (requested: string | null | undefined, call: number) => string | null
mintStatus?: (body: string, call: number) => number
controlPlane?: (request: { headers: Headers; call: number }) => Response
}) {
const mints: MintCall[] = []
const controlCalls: Headers[] = []
let gate: Promise<void> | null = null
const fetchImpl: typeof fetch = async (input, init) => {
const url = String(input)
if (url === MINT_URL) {
const body = String(init?.body ?? '')
mints.push({ body })
const call = mints.length
const status = options.mintStatus?.(body, call) ?? 200
if (status !== 200) {
return Response.json({ error: 'invalid_request' }, { status })
}
const parsed: unknown = JSON.parse(body)
const raw =
typeof parsed === 'object' && parsed && 'orgId' in parsed ? parsed.orgId : undefined
const requested = typeof raw === 'string' || raw === null ? raw : undefined
// Auth decides the team when asked, even if its answer arrives after a switch.
const org = options.issue ? options.issue(requested, call) : (requested ?? null)
const pending = gate
if (pending) {
await pending
}
return Response.json({ sandboxToken: fakeToken(org, call), expiresAt: FAR_FUTURE_MS })
}
const headers = new Headers(init?.headers)
controlCalls.push(headers)
return (
options.controlPlane?.({ headers, call: controlCalls.length }) ?? Response.json({ ok: true })
)
}
const config = getHostedSandboxConfig(
state.dir,
{
ORCA_CLOUD_SANDBOX_URL: CONTROL_PLANE,
ORCA_CLOUD_API_URL: 'http://127.0.0.1:9',
ORCA_CLOUD_CLIENT_ID: 'test-client'
},
false,
fetchImpl
)
if (!config) {
throw new Error('expected a hosted sandbox config')
}
return {
config,
mints,
controlCalls,
/** Holds every mint response until the returned release() is called. */
holdMints: () => {
let release!: () => void
gate = new Promise<void>((resolve) => {
release = () => {
gate = null
resolve()
}
})
return release
}
}
}
function link(activeOrgId?: string, activeOrgName?: string): string {
const profileId = ensureActiveOrcaProfile(state.dir).profile.id
linkOrcaProfileToCloud(profileId, cloud(activeOrgId, activeOrgName), state.dir)
return profileId
}
describe('sandbox token team checks', () => {
beforeEach(() => {
state.dir = mkdtempSync(join(tmpdir(), 'orca-org-token-test-'))
installFakeAppEnvironment({ getPath: () => state.dir })
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
})
afterEach(() => {
vi.restoreAllMocks()
removeTreeSync(state.dir)
})
it('mints with the active team named explicitly, and null for a personal account', async () => {
const h = harness({})
link('org-1')
expect(tokenOrg(`Bearer ${await h.config.getAccessToken()}`)).toBe('org-1')
link()
expect(tokenOrg(`Bearer ${await h.config.getAccessToken()}`)).toBeNull()
expect(h.mints.map((m) => JSON.parse(m.body))).toEqual([{ orgId: 'org-1' }, { orgId: null }])
})
it('rejects a token for the wrong team, re-mints once, and caches only the right one', async () => {
// Auth's membership mirror lacks the team the first time: it mints a personal token.
const h = harness({ issue: (requested, call) => (call === 1 ? null : (requested ?? null)) })
link('org-1')
const token = await h.config.getAccessToken()
expect(tokenOrg(`Bearer ${token}`)).toBe('org-1')
expect(await h.config.getAccessToken()).toBe(token)
expect(h.mints).toHaveLength(2)
})
it('never uses a wrong-team token: two mismatches surface a clear error', async () => {
const h = harness({ issue: () => 'org-old' })
link('org-new', 'Acme')
const error = await h.config.getAccessToken().catch((caught: unknown) => caught)
expect(isEnvironmentProviderError(error, 'conflict')).toBe(true)
expect(error).toHaveProperty('message', "Couldn't switch to Acme. Sign out and back in.")
// Nothing was cached: the next call asks auth again.
await h.config.getAccessToken().catch(() => undefined)
expect(h.mints).toHaveLength(4)
})
it('falls back to an empty body when auth predates the explicit-org body, and remembers', async () => {
const h = harness({ mintStatus: (body) => (body === '{}' ? 200 : 400) })
link()
expect(tokenOrg(`Bearer ${await h.config.getAccessToken()}`)).toBeNull()
h.config.invalidateAccessToken?.()
await h.config.getAccessToken()
expect(h.mints.map((m) => m.body)).toEqual(['{"orgId":null}', '{}', '{}'])
})
it('with the fallback, a team token missing its org claim is still refused', async () => {
const h = harness({
mintStatus: (body) => (body === '{}' ? 200 : 400),
// Old auth picks from its mirror, which lacks the team: personal token.
issue: () => null
})
link('org-1', 'Acme')
const error = await h.config.getAccessToken().catch((caught: unknown) => caught)
expect(isEnvironmentProviderError(error, 'conflict')).toBe(true)
})
it('switch race: a mint in flight for the old team is never cached for the new one', async () => {
// Auth that ignores the body and answers with whichever team its session held when asked.
let sessionOrg = 'org-old'
const h = harness({ issue: () => sessionOrg })
link('org-old')
const release = h.holdMints()
const pending = h.config.getAccessToken()
// The switch commits while the old mint is in flight.
sessionOrg = 'org-new'
link('org-new')
release()
const token = await pending
expect(tokenOrg(`Bearer ${token}`)).toBe('org-new')
expect(tokenOrg(`Bearer ${await h.config.getAccessToken()}`)).toBe('org-new')
})
it('a role change drops the cached token', async () => {
const h = harness({})
const profileId = link('org-1')
const key = `${profileId}:role-test`
noteOrcaCloudMemberships(key, [{ orgId: 'org-1', name: 'Acme', role: 'owner' }])
const first = await h.config.getAccessToken()
expect(await h.config.getAccessToken()).toBe(first)
noteOrcaCloudMemberships(key, [{ orgId: 'org-1', name: 'Acme', role: 'member' }])
const second = await h.config.getAccessToken()
expect(second).not.toBe(first)
// Same roles again: no further re-mint.
noteOrcaCloudMemberships(key, [{ orgId: 'org-1', name: 'Acme', role: 'member' }])
expect(await h.config.getAccessToken()).toBe(second)
})
})
describe('control-plane fetch', () => {
beforeEach(() => {
state.dir = mkdtempSync(join(tmpdir(), 'orca-org-fetch-test-'))
installFakeAppEnvironment({ getPath: () => state.dir })
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
})
afterEach(() => {
vi.restoreAllMocks()
removeTreeSync(state.dir)
})
async function call(h: ReturnType<typeof harness>): Promise<Response> {
const token = await h.config.getAccessToken()
const controlPlaneFetch = h.config.fetch
if (!controlPlaneFetch) {
throw new Error('expected the control-plane fetch')
}
return controlPlaneFetch(new URL('/v1/sandboxes', CONTROL_PLANE), {
method: 'POST',
headers: { authorization: `Bearer ${token}` },
body: '{"a":1}'
})
}
it('names the active team, or user for a personal account', async () => {
const h = harness({})
link('org-1')
await call(h)
link()
await call(h)
expect(h.controlCalls.map((headers) => headers.get('x-orca-org'))).toEqual(['org-1', 'user'])
})
it('409 org_mismatch drops the token, re-mints once, and retries with the new one', async () => {
const h = harness({
controlPlane: ({ call }) =>
call === 1
? Response.json({ error: 'org_mismatch', reason: 'org_mismatch' }, { status: 409 })
: Response.json({ ok: true })
})
link('org-1')
const response = await call(h)
expect(response.status).toBe(200)
expect(h.mints).toHaveLength(2)
const [first, retry] = h.controlCalls
expect(retry!.get('authorization')).not.toBe(first!.get('authorization'))
expect(tokenOrg(retry!.get('authorization'))).toBe('org-1')
// The retry uses the fresh token, which is now the cached one.
expect(`Bearer ${await h.config.getAccessToken()}`).toBe(retry!.get('authorization'))
})
it('401 stale_token re-mints once and retries instead of signing out', async () => {
const h = harness({
controlPlane: ({ call }) =>
call === 1
? Response.json({ error: 'unauthorized', reason: 'stale_token' }, { status: 401 })
: Response.json({ ok: true })
})
link('org-1')
expect((await call(h)).status).toBe(200)
expect(h.mints).toHaveLength(2)
})
it('a second org_mismatch surfaces the clear error rather than looping', async () => {
const h = harness({
controlPlane: () => Response.json({ error: 'org_mismatch' }, { status: 409 })
})
link('org-1', 'Acme')
const error = await call(h).catch((caught: unknown) => caught)
expect(isEnvironmentProviderError(error, 'conflict')).toBe(true)
expect(error).toHaveProperty('message', "Couldn't switch to Acme. Sign out and back in.")
expect(h.controlCalls).toHaveLength(2)
})
it('other refusals pass through untouched', async () => {
const h = harness({
controlPlane: () => Response.json({ error: 'unauthorized' }, { status: 401 })
})
link('org-1')
expect((await call(h)).status).toBe(401)
expect(h.controlCalls).toHaveLength(1)
})
})
@@ -49,7 +49,8 @@ const LifecyclePayload = z.looseObject({ recipeResult: EphemeralVmRecipeResultSc
export function createHostedSandboxRecipeClient(config: HostedSandboxConfig) {
return createHostedSandboxControlPlaneClient({
baseUrl: config.controlPlaneUrl,
getAccessToken: config.getAccessToken
getAccessToken: config.getAccessToken,
fetch: config.fetch
})
}
@@ -124,7 +124,8 @@ export function registerCloudShareHandlers(
? createHostedSandboxShareClient(
createHostedSandboxRequest({
baseUrl: config.controlPlaneUrl,
getAccessToken: config.getAccessToken
getAccessToken: config.getAccessToken,
fetch: config.fetch
})
)
: null
@@ -0,0 +1,45 @@
// Which team a minted sandbox token speaks for, and the error when it can't be made to speak for
// the team the desktop shows.
import { EnvironmentProviderError } from '../environment-providers/environment-provider-driver'
import type { ActiveOrcaProfileState } from '../orca-profiles/profile-index-store'
import { ORG_MISMATCH } from './hosted-sandbox-org-fetch'
/** The `org` claim of a sandbox token (null: personal). Decodes only; the control plane verifies. */
export function readTokenOrg(token: string): string | null {
let payload: unknown = null
try {
payload = JSON.parse(Buffer.from(token.split('.')[1] ?? '', 'base64url').toString('utf8'))
} catch {
// Reported below without echoing the token.
}
if (typeof payload !== 'object' || payload === null) {
throw new EnvironmentProviderError(
'invalid_response',
'Orca Cloud auth sent an invalid sandbox token.'
)
}
const org = 'org' in payload ? payload.org : undefined
return typeof org === 'string' && org ? org : null
}
export function orgSwitchError(active: ActiveOrcaProfileState): EnvironmentProviderError {
const cloud = active.profile.cloud
const team = cloud?.activeOrgId
? cloud.activeOrgName?.trim() || 'your team'
: 'your personal account'
return new EnvironmentProviderError(
'conflict',
`Couldn't switch to ${team}. Sign out and back in.`,
ORG_MISMATCH
)
}
export async function isNotAMember(response: Response): Promise<boolean> {
const body: unknown = await response
.clone()
.json()
.catch(() => null)
return (
typeof body === 'object' && body !== null && 'error' in body && body.error === 'not_a_member'
)
}
@@ -0,0 +1,95 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
// The only channel registered here returns a summary.
const handlers = new Map<string, (_event: unknown, args?: unknown) => RecipeForwardedEnvSummary>()
const { listRuntimesMock, recipeContextMock } = vi.hoisted(() => ({
listRuntimesMock: vi.fn(),
recipeContextMock: vi.fn()
}))
vi.mock('electron', () => ({
app: { getPath: () => '/tmp/orca-user-data' },
ipcMain: {
handle: (channel: string, handler: never) => handlers.set(channel, handler),
removeHandler: vi.fn()
}
}))
vi.mock('../../shared/ephemeral-vm-runtime-store', () => ({
listEphemeralVmRuntimes: listRuntimesMock
}))
vi.mock('./ephemeral-vm-recipe-context', () => ({
getRuntimeRecipeContext: recipeContextMock
}))
import { registerRecipeForwardedEnvHandler } from './ephemeral-vm-forwarded-env'
import type { RecipeForwardedEnvSummary } from '../../shared/ephemeral-vm-recipe-forwarded-env'
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler hands the store to the mocked recipe context only
const store = {} as never
function summarize(args?: unknown): RecipeForwardedEnvSummary {
const handler = handlers.get('ephemeralVm:forwardedEnvSummary')
if (!handler) {
throw new Error('handler not registered')
}
return handler({}, args)
}
beforeEach(() => {
vi.stubEnv('AWS_SECRET_ACCESS_KEY', 'aws-secret')
vi.stubEnv('OPENAI_API_KEY', 'sk-openai')
listRuntimesMock.mockReturnValue([
{ id: 'runtime-1', recipeId: 'aws', runtimeEnvironmentId: 'env-recipe' }
])
recipeContextMock.mockReturnValue({ recipe: { id: 'aws', passEnv: ['AWS_*'] } })
registerRecipeForwardedEnvHandler(store)
})
afterEach(() => {
vi.unstubAllEnvs()
vi.clearAllMocks()
handlers.clear()
})
it("applies the recipe's own passEnv, looked up from the host's runtime", () => {
const summary = summarize({ environmentId: 'env-recipe' })
expect(recipeContextMock).toHaveBeenCalledWith(store, '/tmp/orca-user-data', 'runtime-1')
expect(summary.narrowed).toBe(true)
expect(summary.keyLike).toContain('AWS_SECRET_ACCESS_KEY')
expect(summary.keyLike).not.toContain('OPENAI_API_KEY')
})
it('ignores a passEnv sent from the renderer', () => {
const summary = summarize({ environmentId: 'env-recipe', passEnv: ['OPENAI_*'] })
expect(summary.keyLike).toContain('AWS_SECRET_ACCESS_KEY')
expect(summary.keyLike).not.toContain('OPENAI_API_KEY')
const withoutHost = summarize({ passEnv: ['AWS_*'] })
expect(withoutHost.narrowed).toBe(false)
expect(withoutHost.keyLike).toContain('OPENAI_API_KEY')
})
it('falls back to the whole env for an unknown host or an unresolvable recipe', () => {
const unknown = summarize({ environmentId: 'env-missing' })
expect(recipeContextMock).not.toHaveBeenCalled()
expect(unknown.narrowed).toBe(false)
expect(unknown.keyLike).toEqual(
expect.arrayContaining(['AWS_SECRET_ACCESS_KEY', 'OPENAI_API_KEY'])
)
recipeContextMock.mockImplementation(() => {
throw new Error('recipe not found')
})
const unresolved = summarize({ environmentId: 'env-recipe' })
expect(unresolved.narrowed).toBe(false)
expect(unresolved.keyLike).toContain('OPENAI_API_KEY')
})
it('summarizes the whole env when the recipe sets no passEnv', () => {
recipeContextMock.mockReturnValue({ recipe: { id: 'aws' } })
const summary = summarize({ environmentId: 'env-recipe' })
expect(summary.narrowed).toBe(false)
expect(summary.keyLike).toEqual(
expect.arrayContaining(['AWS_SECRET_ACCESS_KEY', 'OPENAI_API_KEY'])
)
})
+37 -4
View File
@@ -1,13 +1,46 @@
import { ipcMain } from 'electron'
import { app, ipcMain } from 'electron'
import type { Store } from '../persistence/loading-store/store'
import { listEphemeralVmRuntimes } from '../../shared/ephemeral-vm-runtime-store'
import {
summarizeRecipeForwardedEnv,
type RecipeForwardedEnvSummary
} from '../../shared/ephemeral-vm-recipe-forwarded-env'
import { getRuntimeRecipeContext } from './ephemeral-vm-recipe-context'
/** Which recipe host the renderer asks about. Main looks its recipe up; it never takes a passEnv. */
export type RecipeForwardedEnvSummaryArgs = { environmentId?: string }
/** The recipe's passEnv as its lifecycle scripts get it; undefined (whole env) when unresolvable. */
export function resolveRecipeHostPassEnv(
store: Store,
userDataPath: string,
environmentId: unknown
): readonly string[] | undefined {
if (typeof environmentId !== 'string' || environmentId.length === 0) {
return undefined
}
try {
const runtime = listEphemeralVmRuntimes(userDataPath).find(
(entry) => entry.runtimeEnvironmentId === environmentId
)
return runtime
? getRuntimeRecipeContext(store, userDataPath, runtime.id).recipe.passEnv
: undefined
} catch {
// Why: an unresolvable recipe falls back to the whole env, which can only over-warn.
return undefined
}
}
/** Recipe scripts inherit this process's env (buildRecipeEnv); only the names cross to the renderer. */
export function registerRecipeForwardedEnvHandler(): void {
export function registerRecipeForwardedEnvHandler(store: Store): void {
ipcMain.removeHandler('ephemeralVm:forwardedEnvSummary')
ipcMain.handle('ephemeralVm:forwardedEnvSummary', (): RecipeForwardedEnvSummary =>
summarizeRecipeForwardedEnv(process.env)
ipcMain.handle(
'ephemeralVm:forwardedEnvSummary',
(_event, args?: RecipeForwardedEnvSummaryArgs): RecipeForwardedEnvSummary =>
summarizeRecipeForwardedEnv(
process.env,
resolveRecipeHostPassEnv(store, app.getPath('userData'), args?.environmentId)
)
)
}
+1 -1
View File
@@ -103,7 +103,7 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin
return listPickerRecipes(store, args.repoId, pluginService)
})
registerRecipeForwardedEnvHandler()
registerRecipeForwardedEnvHandler(store)
ipcMain.handle(
'ephemeralVm:listRecipeCatalog',
@@ -2,7 +2,6 @@ import { ipcMain } from 'electron'
import { z } from 'zod'
import type {
OrcaBillingCheckoutArgs,
OrcaBillingLinkResult,
OrcaBillingOpenResult,
OrcaBillingOverageArgs,
OrcaBillingOverageResult,
@@ -27,10 +26,6 @@ import {
openOrcaBillingPortal,
setOrcaBillingOverage
} from '../../billing/billing-service'
import {
getOrcaBillingCheckoutLink,
getOrcaBillingPortalLink
} from '../../billing/billing-hosted-links'
import { getProfileUserDataPath } from '../../orca-profiles/profile-storage-paths'
import { ensureActiveOrcaProfile } from '../../orca-profiles/profile-index-store'
import { notifyUsageAlertIfDue } from '../../billing/usage-alert-notifier'
@@ -111,7 +106,8 @@ const CloudSetupStart = z
.object({
repo: z.string().url().startsWith('https://github.com/').max(300).optional(),
needs: z.array(z.enum(CLOUD_SETUP_NEEDS)).max(3),
chooseTeam: z.boolean().optional()
chooseTeam: z.boolean().optional(),
openBrowser: z.boolean().optional()
})
.strict()
@@ -222,13 +218,4 @@ export function registerOrcaProfileBillingHandlers(store: Store): void {
ipcMain.handle('orcaProfiles:billingOpenPortal', async (): Promise<OrcaBillingOpenResult> =>
openOrcaBillingPortal(getProfileUserDataPath())
)
// "Copy link": the same Stripe URLs, minted but not opened (paste into another browser profile).
ipcMain.handle('orcaProfiles:billingPortalLink', async (): Promise<OrcaBillingLinkResult> =>
getOrcaBillingPortalLink(getProfileUserDataPath())
)
ipcMain.handle(
'orcaProfiles:billingCheckoutLink',
async (_event, rawArgs: unknown): Promise<OrcaBillingLinkResult> =>
getOrcaBillingCheckoutLink(getProfileUserDataPath(), billingCheckoutArgsFromUnknown(rawArgs))
)
}
@@ -11,6 +11,22 @@ import type {
OrcaProfileOrgMembersListArgs,
OrcaProfileOrgMembersListResult
} from '../../../shared/orca-profiles'
import type { OrcaAccountMfaResult } from '../../../shared/orca-account-mfa'
import { getOrcaProfileAccountMfa } from '../../orca-profiles/cloud-mfa/profile-cloud-mfa-service'
import {
ORCA_TEAM_MFA_GRACE_CHOICES,
type OrcaTeamMfaGrace,
type OrcaTeamMfaPendingResult,
type OrcaTeamMfaPolicyResult,
type OrcaTeamMfaPolicySetResult,
type OrcaTeamMfaRemindResult
} from '../../../shared/orca-team-mfa-policy'
import {
getOrcaTeamMfaPolicy,
listOrcaTeamMfaPending,
remindOrcaTeamMfa,
setOrcaTeamMfaPolicy
} from '../../orca-profiles/cloud-mfa/profile-cloud-team-mfa-service'
import { getProfileUserDataPath } from '../../orca-profiles/profile-storage-paths'
import {
changeOrcaProfileOrgMemberRole,
@@ -144,6 +160,48 @@ export function registerOrcaProfileOrgMemberHandlers(): void {
createOrcaProfileOrg(getProfileUserDataPath(), { name: orgCreateNameFromUnknown(rawArgs) })
)
ipcMain.handle('orcaProfiles:accountMfa', async (): Promise<OrcaAccountMfaResult> =>
getOrcaProfileAccountMfa(getProfileUserDataPath())
)
ipcMain.handle(
'orcaProfiles:teamMfaPolicy',
async (_event, rawArgs: unknown): Promise<OrcaTeamMfaPolicyResult> =>
getOrcaTeamMfaPolicy(getProfileUserDataPath(), orgMembersScopedArgs(rawArgs).orgId)
)
ipcMain.handle(
'orcaProfiles:teamMfaPolicySet',
async (_event, rawArgs: unknown): Promise<OrcaTeamMfaPolicySetResult> => {
const { orgId, record } = orgMembersScopedArgs(rawArgs)
// Only the fixed choices cross to the server; anything else is refused here.
const enforce: OrcaTeamMfaGrace | 'off' | undefined =
record.enforce === 'off'
? 'off'
: ORCA_TEAM_MFA_GRACE_CHOICES.find((choice) => choice === record.enforce)
if (enforce === undefined) {
return { status: 'failed', error: 'invalid_enforce' }
}
return setOrcaTeamMfaPolicy(getProfileUserDataPath(), { orgId, enforce })
}
)
ipcMain.handle(
'orcaProfiles:teamMfaPending',
async (_event, rawArgs: unknown): Promise<OrcaTeamMfaPendingResult> =>
listOrcaTeamMfaPending(getProfileUserDataPath(), orgMembersScopedArgs(rawArgs).orgId)
)
ipcMain.handle(
'orcaProfiles:teamMfaRemind',
async (_event, rawArgs: unknown): Promise<OrcaTeamMfaRemindResult> => {
const { orgId, record } = orgMembersScopedArgs(rawArgs)
const userId =
typeof record.userId === 'string' && record.userId.length <= 128 ? record.userId : undefined
return remindOrcaTeamMfa(getProfileUserDataPath(), { orgId, ...(userId ? { userId } : {}) })
}
)
ipcMain.handle(
'orcaProfiles:orgMembersList',
async (
+4 -2
View File
@@ -45,6 +45,7 @@ import {
selectCurrentOrcaProfileOrg,
signOutCurrentOrcaProfile
} from '../../orca-profiles/cloud-auth/profile-cloud-service'
import { connectCurrentArgsFromUnknown } from '../../orca-profiles/cloud-auth/profile-cloud-pkce'
import { refreshCurrentOrcaProfileAuth } from '../../orca-profiles/cloud-auth/profile-cloud-capability-refresh'
import { registerOrcaProfileOrgMemberHandlers } from './orca-profile-org-members-handlers'
import { registerOrcaProfileBillingHandlers } from './orca-profile-billing-handlers'
@@ -258,8 +259,9 @@ export function registerOrcaProfileHandlers(
ipcMain.handle(
'orcaProfiles:connectCurrent',
async (): Promise<ConnectCurrentOrcaProfileResult> => {
const result = await connectCurrentOrcaProfile(getProfileUserDataPath())
async (_event, args?: unknown): Promise<ConnectCurrentOrcaProfileResult> => {
const connect = connectCurrentArgsFromUnknown(args)
const result = await connectCurrentOrcaProfile(getProfileUserDataPath(), connect)
if (result.status === 'connected') {
options.onAuthMutation?.()
}
@@ -105,7 +105,8 @@ function serviceFor(userDataPath: string): OrcaCloudAgentsService {
client: config
? createAgentCredentialsClient({
baseUrl: config.controlPlaneUrl,
getAccessToken: config.getAccessToken
getAccessToken: config.getAccessToken,
fetch: config.fetch
})
: null,
spawnSetupToken: spawnClaudeSetupTokenPty,
@@ -68,6 +68,12 @@ export function beginOrcaCloudPkceFlow(
authorizeParams?: Record<string, string>
/** Keep the browser's callback request open until the caller calls finishBrowser. */
holdBrowser?: boolean
/**
* COPY-LINK-FIRST-CLICK: false starts the same flow without opening the browser (the person
* copied the link to paste into a private window). The listener and its timeout are unchanged,
* so an unused link ends like an unanswered click.
*/
openBrowser?: boolean
} = {}
): Promise<OrcaCloudAuthorizationCode> {
const codeVerifier = createCodeVerifier()
@@ -209,9 +215,12 @@ export function beginOrcaCloudPkceFlow(
const devUrlFile = app.isPackaged
? ''
: (process.env.ORCA_CLOUD_DEV_OPEN_URL_FILE?.trim() ?? '')
const open = devUrlFile
? appendFile(devUrlFile, `${authorizeUrl.toString()}\n`, { mode: 0o600 })
: shell.openExternal(authorizeUrl.toString())
const open =
options.openBrowser === false
? Promise.resolve()
: devUrlFile
? appendFile(devUrlFile, `${authorizeUrl.toString()}\n`, { mode: 0o600 })
: shell.openExternal(authorizeUrl.toString())
void open.catch((error) => {
rejectFlow(
error instanceof Error ? error : new Error('orca_cloud_auth_browser_open_failed')
@@ -220,3 +229,28 @@ export function beginOrcaCloudPkceFlow(
})
})
}
/** COPY-LINK-FIRST-CLICK: `openBrowser: false` starts sign-in without opening the browser. */
export type ConnectCurrentArgs = { openBrowser?: boolean }
/** The renderer's connect args: only an explicit `openBrowser: false` suppresses the browser. */
export function connectCurrentArgsFromUnknown(args: unknown): ConnectCurrentArgs {
const openBrowser =
typeof args === 'object' && args !== null
? Object.getOwnPropertyDescriptor(args, 'openBrowser')?.value
: undefined
return openBrowser === false ? { openBrowser: false } : {}
}
/**
* The PKCE options a sign-in passes: cloud setup's parameters and held tab, or a plain sign-in that
* may skip the browser (COPY-LINK-FIRST-CLICK; setup asks for that on its own `connect` args).
*/
export function pkceSignInOptions(
setup: ({ authorizeParams?: Record<string, string> } & ConnectCurrentArgs) | undefined
): { openBrowser: boolean; authorizeParams?: Record<string, string>; holdBrowser?: boolean } {
return {
openBrowser: setup?.openBrowser !== false,
...(setup?.authorizeParams ? { authorizeParams: setup.authorizeParams, holdBrowser: true } : {})
}
}
@@ -34,7 +34,8 @@ vi.mock('electron', () => ({
}))
vi.mock('./profile-cloud-pkce', () => ({
beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock
beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock,
pkceSignInOptions: () => ({})
}))
vi.mock('./profile-cloud-client', () => ({
@@ -32,7 +32,8 @@ vi.mock('electron', () => ({
}))
vi.mock('./profile-cloud-pkce', () => ({
beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock
beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock,
pkceSignInOptions: () => ({})
}))
vi.mock('./profile-cloud-client', () => ({
@@ -25,7 +25,7 @@ import {
exchangeOrcaCloudAuthCode,
revokeOrcaCloudSession
} from './profile-cloud-client'
import { beginOrcaCloudPkceFlow } from './profile-cloud-pkce'
import { beginOrcaCloudPkceFlow, pkceSignInOptions } from './profile-cloud-pkce'
import {
createCloudLinkedOrcaProfileRecord,
linkOrcaProfileToCloud,
@@ -65,7 +65,8 @@ export function getCurrentOrcaProfileAuthStatus(userDataPath: string): OrcaProfi
export async function connectCurrentOrcaProfile(
userDataPath: string,
setup?: OrcaCloudSetupSignIn
/** Cloud setup's sign-in, or (COPY-LINK-FIRST-CLICK) a plain one that may skip the browser. */
setup?: OrcaCloudSetupSignIn | { openBrowser?: boolean }
): Promise<ConnectCurrentOrcaProfileResult> {
const active = ensureActiveOrcaProfile(userDataPath)
if (isOrcaCloudDevAuthEnabled()) {
@@ -101,7 +102,7 @@ export async function connectCurrentOrcaProfile(
const code = await beginOrcaCloudPkceFlow(configState.config, active.profile.id, {
signal: attemptAbort.signal,
onAuthorizeUrl: (link) => setOrcaCloudSignInLink(attempt, link),
...(setup ? { authorizeParams: setup.authorizeParams, holdBrowser: true } : {})
...pkceSignInOptions(setup)
})
finishBrowser = code.finishBrowser
// The callback listener has closed, so its browser link is dead.
@@ -123,7 +124,7 @@ export async function connectCurrentOrcaProfile(
signInCloudSession(active.profile.id, userDataPath, exchange)
const list = linkOrcaProfileToCloud(active.profile.id, exchange.cloud, userDataPath)
linkedCloudConnectAttempt = attempt
if (setup && finishBrowser) {
if (setup && 'afterSignIn' in setup && finishBrowser) {
const next = await setup.afterSignIn().catch(() => null)
finishBrowser(next)
finishBrowser = undefined
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { normalizeAccountMfa } from './profile-cloud-mfa-service'
const API = 'https://cloud.orca.dev'
describe('normalizeAccountMfa', () => {
it('keeps the status and a setup link on Orca Cloud', () => {
expect(
normalizeAccountMfa({ mfa: 'off', setupUrl: `${API}/v1/account/security` }, API)
).toEqual({ mfa: 'off', setupUrl: `${API}/v1/account/security` })
expect(normalizeAccountMfa({ mfa: 'on', setupUrl: `${API}/x` }, `${API}/`).mfa).toBe('on')
})
it.each([
['another site', { mfa: 'off', setupUrl: 'https://evil.example/v1/account/security' }],
['a look-alike host', { mfa: 'off', setupUrl: 'https://cloud.orca.dev.evil.example/' }],
['plain http', { mfa: 'off', setupUrl: 'http://cloud.orca.dev/v1/account/security' }],
['credentials in the link', { mfa: 'off', setupUrl: 'https://user:pw@cloud.orca.dev/' }],
['a script link', { mfa: 'off', setupUrl: 'javascript:alert(1)' }],
['no link', { mfa: 'off' }],
['an unknown status', { mfa: 'pending', setupUrl: `${API}/v1/account/security` }],
['a boolean status', { mfa: true, setupUrl: `${API}/v1/account/security` }],
['nothing', null]
])('refuses %s', (_name, body) => {
expect(() => normalizeAccountMfa(body, API)).toThrow('invalid_orca_account_mfa')
})
})
@@ -0,0 +1,82 @@
import type { OrcaAccountMfa, OrcaAccountMfaResult } from '../../../shared/orca-account-mfa'
import type { OrcaCloudAuthConfig } from '../cloud-auth/profile-cloud-auth-config'
import {
getOrcaCloudAuthConfig,
isOrcaCloudDevAuthEnabled
} from '../cloud-auth/profile-cloud-auth-config'
import { OrcaCloudRequestError } from '../cloud-auth/profile-cloud-client'
import type { OrcaCloudSession } from '../cloud-session/profile-cloud-session-store'
import { extractErrorCode, requestInit } from '../cloud-org/profile-cloud-org-members-client'
import { runOrgMemberCall } from '../cloud-org/profile-cloud-org-members-service'
import { ensureActiveOrcaProfile } from '../profile-index-store'
/**
* The renderer opens `setupUrl` in the browser, so it must be a page on Orca Cloud's own origin:
* a compromised or misconfigured answer cannot send the person to another site to "set up MFA".
*/
export function normalizeAccountMfa(value: unknown, apiBaseUrl: string): OrcaAccountMfa {
const record: Record<string, unknown> =
typeof value === 'object' && value !== null ? { ...value } : {}
const mfa = record.mfa === 'on' || record.mfa === 'off' ? record.mfa : null
let setupUrl: URL | null = null
try {
setupUrl = typeof record.setupUrl === 'string' ? new URL(record.setupUrl) : null
} catch {
setupUrl = null
}
if (
!mfa ||
!setupUrl ||
setupUrl.origin !== new URL(apiBaseUrl).origin ||
setupUrl.username ||
setupUrl.password
) {
throw new Error('invalid_orca_account_mfa')
}
return { mfa, setupUrl: setupUrl.toString() }
}
async function fetchAccountMfa(
config: OrcaCloudAuthConfig,
session: OrcaCloudSession
): Promise<OrcaAccountMfa> {
const response = await fetch(
`${config.apiBaseUrl}/v1/desktop/auth/mfa`,
requestInit('GET', session.accessToken)
)
if (!response.ok) {
throw new OrcaCloudRequestError(response.status, await extractErrorCode(response))
}
const body: unknown = await response.json()
return normalizeAccountMfa(body, config.apiBaseUrl)
}
export async function getOrcaProfileAccountMfa(
userDataPath: string
): Promise<OrcaAccountMfaResult> {
const active = ensureActiveOrcaProfile(userDataPath)
// Local dev auth has no server and no factor to enrol.
if (isOrcaCloudDevAuthEnabled()) {
return { status: 'unsupported' }
}
const configState = getOrcaCloudAuthConfig()
if (!configState.configured) {
return { status: 'unconfigured' }
}
const result = await runOrgMemberCall(configState.config, active, userDataPath, (session) =>
fetchAccountMfa(configState.config, session)
)
switch (result.status) {
case 'ok':
return { status: 'ok', ...result.value }
case 'reconnect-required':
return { status: 'reconnect-required' }
case 'request-error':
// 404: an auth deployment from before this route, or a provider with no MFA status.
return result.error.statusCode === 404
? { status: 'unsupported' }
: { status: 'failed', error: result.error.message }
case 'failed':
return { status: 'failed', error: result.error }
}
}
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest'
import {
normalizePendingMembers,
normalizeTeamMfaPolicy,
sameOriginUrl
} from './profile-cloud-team-mfa-service'
const API = 'https://cloud.orca.dev'
const setupUrl = `${API}/v1/account/security`
describe('normalizeTeamMfaPolicy', () => {
it('reads a member answer: the deadline and their own state, no manager fields', () => {
expect(
normalizeTeamMfaPolicy(
{ policy: { enforceAt: 200, setAt: 100 }, self: { mfa: 'pending' }, setupUrl },
API
)
).toEqual({ policy: { enforceAt: 200, setAt: 100 }, self: 'pending', setupUrl })
})
it('reads a manager answer, including a policy the plan no longer covers', () => {
expect(
normalizeTeamMfaPolicy(
{
policy: null,
self: { mfa: 'on' },
setupUrl,
available: false,
pendingMembers: 3,
inactivePolicy: { enforceAt: 500 }
},
API
)
).toEqual({
policy: null,
self: 'on',
setupUrl,
manage: { available: false, pendingMembers: 3, inactiveEnforceAt: 500 }
})
})
it('never turns a malformed state into blocked', () => {
for (const self of [undefined, null, {}, { mfa: 'BLOCKED' }, { mfa: true }, 'blocked']) {
expect(normalizeTeamMfaPolicy({ policy: null, self, setupUrl }, API).self).toBe('unknown')
}
})
it('drops a half-formed deadline rather than guessing one', () => {
expect(
normalizeTeamMfaPolicy({ policy: { enforceAt: 'soon' }, self: { mfa: 'off' }, setupUrl }, API)
.policy
).toBeNull()
})
it('refuses an answer whose setup link is not on Orca Cloud', () => {
for (const link of ['https://evil.example/x', 'javascript:alert(1)', undefined, 7]) {
expect(() =>
normalizeTeamMfaPolicy({ policy: null, self: { mfa: 'off' }, setupUrl: link }, API)
).toThrow('invalid_orca_team_mfa_policy')
}
expect(sameOriginUrl('https://user:pw@cloud.orca.dev/x', API)).toBeNull()
expect(sameOriginUrl('http://cloud.orca.dev/x', API)).toBeNull()
})
})
describe('normalizePendingMembers', () => {
it('keeps members with an email and maps unknown states to off', () => {
expect(
normalizePendingMembers({
members: [
{ userId: 'u1', email: 'a@x.dev', displayName: 'Ada', mfa: 'pending' },
{ userId: null, email: 'b@x.dev', mfa: 'blocked' },
{ userId: 'u3', email: 'c@x.dev', mfa: 'whatever' },
{ userId: 'u4' },
'nope'
]
})
).toEqual([
{ userId: 'u1', email: 'a@x.dev', displayName: 'Ada', mfa: 'pending' },
{ userId: null, email: 'b@x.dev', mfa: 'blocked' },
{ userId: 'u3', email: 'c@x.dev', mfa: 'off' }
])
expect(normalizePendingMembers(null)).toEqual([])
})
})
@@ -0,0 +1,287 @@
import {
ORCA_TEAM_MFA_GRACE_CHOICES,
type OrcaTeamMfaGrace,
type OrcaTeamMfaPendingMember,
type OrcaTeamMfaPendingResult,
type OrcaTeamMfaPolicy,
type OrcaTeamMfaPolicyResult,
type OrcaTeamMfaPolicySetResult,
type OrcaTeamMfaRemindResult,
type OrcaTeamMfaSelfState
} from '../../../shared/orca-team-mfa-policy'
import {
getOrcaCloudAuthConfig,
isOrcaCloudDevAuthEnabled
} from '../cloud-auth/profile-cloud-auth-config'
import { OrcaCloudRequestError } from '../cloud-auth/profile-cloud-client'
import {
extractErrorCode,
orgMembersUrl,
requestInit
} from '../cloud-org/profile-cloud-org-members-client'
import {
runOrgMemberCall,
type OrgCallResult
} from '../cloud-org/profile-cloud-org-members-service'
import { ensureActiveOrcaProfile } from '../profile-index-store'
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
const SELF_STATES: readonly OrcaTeamMfaSelfState[] = ['on', 'off', 'pending', 'blocked', 'unknown']
function finiteNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
/** The setup link is opened in the browser, so only a page on Orca Cloud's own origin is kept. */
export function sameOriginUrl(value: unknown, apiBaseUrl: string): string | null {
if (typeof value !== 'string') {
return null
}
try {
const url = new URL(value)
return url.origin === new URL(apiBaseUrl).origin && !url.username && !url.password
? url.toString()
: null
} catch {
return null
}
}
function normalizeDeadline(value: unknown): { enforceAt: number; setAt: number } | null {
const record = isRecord(value) ? value : null
const enforceAt = finiteNumber(record?.enforceAt)
const setAt = finiteNumber(record?.setAt)
return enforceAt !== null && setAt !== null ? { enforceAt, setAt } : null
}
export function normalizeTeamMfaPolicy(value: unknown, apiBaseUrl: string): OrcaTeamMfaPolicy {
const record = isRecord(value) ? value : null
const setupUrl = sameOriginUrl(record?.setupUrl, apiBaseUrl)
if (!record || !setupUrl) {
throw new Error('invalid_orca_team_mfa_policy')
}
const selfRecord = isRecord(record.self) ? record.self : {}
// An unrecognized state is "unknown", never "blocked": only the server's word blocks anyone.
const self = SELF_STATES.find((state) => state === selfRecord.mfa) ?? 'unknown'
const pendingMembers = finiteNumber(record.pendingMembers)
const inactive = isRecord(record.inactivePolicy)
? finiteNumber(record.inactivePolicy.enforceAt)
: null
return {
policy: normalizeDeadline(record.policy),
self,
setupUrl,
// Present only when the server sent the manager fields; a member's answer has none.
...(typeof record.available === 'boolean'
? {
manage: {
available: record.available,
pendingMembers: pendingMembers ?? 0,
...(inactive !== null ? { inactiveEnforceAt: inactive } : {})
}
}
: {})
}
}
export function normalizePendingMembers(value: unknown): OrcaTeamMfaPendingMember[] {
const record = isRecord(value) ? value : null
const members = Array.isArray(record?.members) ? record.members : []
return members.flatMap((entry): OrcaTeamMfaPendingMember[] => {
const member = isRecord(entry) ? entry : null
const email = typeof member?.email === 'string' ? member.email.trim() : ''
if (!member || !email) {
return []
}
const displayName = typeof member.displayName === 'string' ? member.displayName.trim() : ''
return [
{
userId: typeof member.userId === 'string' && member.userId ? member.userId : null,
email,
...(displayName ? { displayName } : {}),
mfa: member.mfa === 'blocked' ? 'blocked' : member.mfa === 'pending' ? 'pending' : 'off'
}
]
})
}
async function request(url: string, init: RequestInit): Promise<unknown> {
const response = await fetch(url, init)
if (!response.ok) {
throw new OrcaCloudRequestError(response.status, await extractErrorCode(response))
}
const body: unknown = await response.json()
return body
}
type Unavailable = Extract<
OrcaTeamMfaPolicyResult,
{
status:
| 'unconfigured'
| 'reconnect-required'
| 'unsupported'
| 'forbidden'
| 'rate-limited'
| 'failed'
}
>
function unavailable(result: Exclude<OrgCallResult<unknown>, { status: 'ok' }>): Unavailable {
if (result.status === 'reconnect-required') {
return { status: 'reconnect-required' }
}
if (result.status === 'failed') {
return { status: 'failed', error: result.error }
}
const { statusCode, errorCode, message } = result.error
if (statusCode === 404 && !errorCode) {
// No JSON error: an auth deployment from before these routes.
return { status: 'unsupported' }
}
if (statusCode === 403) {
return { status: 'forbidden' }
}
if (statusCode === 429) {
return { status: 'rate-limited' }
}
return { status: 'failed', error: message }
}
async function call<T>(
userDataPath: string,
run: (apiBaseUrl: string, accessToken: string, url: (path: string) => string) => Promise<T>,
orgId: string
): Promise<OrgCallResult<T> | { status: 'unconfigured' } | { status: 'unsupported' }> {
const active = ensureActiveOrcaProfile(userDataPath)
// Local dev auth has no server, so no policy.
if (isOrcaCloudDevAuthEnabled()) {
return { status: 'unsupported' }
}
const configState = getOrcaCloudAuthConfig()
if (!configState.configured) {
return { status: 'unconfigured' }
}
const { config } = configState
return runOrgMemberCall(config, active, userDataPath, (session) =>
run(config.apiBaseUrl, session.accessToken, (path) => orgMembersUrl(config, orgId, path))
)
}
export async function getOrcaTeamMfaPolicy(
userDataPath: string,
orgId: string
): Promise<OrcaTeamMfaPolicyResult> {
const result = await call(
userDataPath,
async (apiBaseUrl, token, url) =>
normalizeTeamMfaPolicy(
await request(url('/mfa-policy'), requestInit('GET', token)),
apiBaseUrl
),
orgId
)
if (result.status === 'ok') {
return { status: 'ok', ...result.value }
}
return result.status === 'unconfigured' || result.status === 'unsupported'
? result
: unavailable(result)
}
export async function setOrcaTeamMfaPolicy(
userDataPath: string,
args: { orgId: string; enforce: OrcaTeamMfaGrace | 'off' }
): Promise<OrcaTeamMfaPolicySetResult> {
if (args.enforce !== 'off' && !ORCA_TEAM_MFA_GRACE_CHOICES.includes(args.enforce)) {
return { status: 'failed', error: 'invalid_enforce' }
}
const result = await call(
userDataPath,
async (_apiBaseUrl, token, url) => {
const init =
args.enforce === 'off'
? { ...requestInit('GET', token), method: 'DELETE' }
: { ...requestInit('POST', token, { enforce: args.enforce }), method: 'PUT' }
const answer = await request(url('/mfa-policy'), init)
const body = isRecord(answer) ? answer : null
return {
policy: normalizeDeadline(body?.policy),
pendingMembers: finiteNumber(body?.pendingMembers) ?? 0
}
},
args.orgId
)
if (result.status === 'ok') {
return { status: 'ok', ...result.value }
}
if (result.status === 'unconfigured' || result.status === 'unsupported') {
return result
}
if (result.status === 'request-error') {
const { errorCode } = result.error
if (errorCode === 'actor_needs_mfa') {
return { status: 'actor-needs-mfa' }
}
if (errorCode === 'mfa_enforcement_not_entitled') {
return { status: 'not-entitled' }
}
}
return unavailable(result)
}
export async function listOrcaTeamMfaPending(
userDataPath: string,
orgId: string
): Promise<OrcaTeamMfaPendingResult> {
const result = await call(
userDataPath,
async (_apiBaseUrl, token, url) =>
normalizePendingMembers(await request(url('/mfa-policy/pending'), requestInit('GET', token))),
orgId
)
if (result.status === 'ok') {
return { status: 'ok', members: result.value }
}
return result.status === 'unconfigured' || result.status === 'unsupported'
? result
: unavailable(result)
}
export async function remindOrcaTeamMfa(
userDataPath: string,
args: { orgId: string; userId?: string }
): Promise<OrcaTeamMfaRemindResult> {
const result = await call(
userDataPath,
async (_apiBaseUrl, token, url) => {
const answer = await request(
url('/mfa-policy/remind'),
requestInit('POST', token, args.userId ? { userId: args.userId } : {})
)
const body = isRecord(answer) ? answer : null
const delivery: 'log' | 'test-inbox' | 'unknown' =
body?.delivery === 'log' || body?.delivery === 'test-inbox' ? body.delivery : 'unknown'
return { recipients: finiteNumber(body?.recipients) ?? 0, delivery }
},
args.orgId
)
if (result.status === 'ok') {
return { status: 'ok', ...result.value }
}
if (result.status === 'unconfigured' || result.status === 'unsupported') {
return result
}
if (result.status === 'request-error') {
if (result.error.errorCode === 'no_policy') {
return { status: 'no-policy' }
}
if (result.error.errorCode === 'member_not_found') {
return { status: 'not-found' }
}
}
return unavailable(result)
}
@@ -32,7 +32,8 @@ function devSelf(): OrcaOrgMember {
userId: cleanEnvString(process.env.ORCA_CLOUD_DEV_USER_ID, 'dev-user'),
email: cleanEnvString(process.env.ORCA_CLOUD_DEV_EMAIL, 'dev@orca.local'),
displayName: cleanEnvString(process.env.ORCA_CLOUD_DEV_DISPLAY_NAME, 'Orca Dev'),
role: 'owner'
role: 'owner',
mfa: 'on'
}
}
@@ -44,10 +45,17 @@ function seedDevRoster(): DevOrgRoster {
userId: 'dev-teammate-1',
email: 'teammate@orca.local',
displayName: 'Dev Teammate',
role: 'admin'
role: 'admin',
mfa: 'off'
},
// Null user IDs exercise members who have not signed in yet.
{ userId: null, email: 'invited-member@orca.local', displayName: undefined, role: 'member' }
{
userId: null,
email: 'invited-member@orca.local',
displayName: undefined,
role: 'member',
mfa: 'off'
}
],
pendingInvites: [{ email: 'pending@orca.local', role: 'member', createdAt: Date.now() }]
}
@@ -1,3 +1,4 @@
import type { OrcaOrgMemberMfa } from '../../../shared/orca-account-mfa'
import type {
OrcaOrgMember,
OrcaOrgMembersRoster,
@@ -53,6 +54,22 @@ function optionalPictureUrl(value: unknown): string | undefined {
}
}
const MEMBER_MFA: readonly OrcaOrgMemberMfa[] = ['on', 'off', 'pending']
// Absent for plain members (the server leaves it out); an unknown value is dropped, never guessed.
function normalizeMemberMfa(
record: Record<string, unknown>
): Pick<OrcaOrgMember, 'mfa' | 'mfaDeadline'> {
const mfa = MEMBER_MFA.find((status) => status === record.mfa)
if (!mfa) {
return {}
}
const deadline = record.mfaDeadline
return mfa === 'pending' && typeof deadline === 'number' && Number.isFinite(deadline)
? { mfa, mfaDeadline: deadline }
: { mfa }
}
function normalizeMember(value: unknown): OrcaOrgMember | null {
if (!value || typeof value !== 'object') {
return null
@@ -70,7 +87,8 @@ function normalizeMember(value: unknown): OrcaOrgMember | null {
...(optionalPictureUrl(record.pictureUrl)
? { pictureUrl: optionalPictureUrl(record.pictureUrl) }
: {}),
role: normalizeRole(record.role, 'member')
role: normalizeRole(record.role, 'member'),
...normalizeMemberMfa(record)
}
}
@@ -21,6 +21,7 @@ import {
import type { OrcaCloudSession } from '../cloud-session/profile-cloud-session-store'
import { OrcaCloudRequestError } from '../cloud-auth/profile-cloud-client'
import { runWithFreshOrcaCloudSession } from '../cloud-session/profile-cloud-session-refresh'
import { bumpOrcaCloudTokenEpoch } from '../cloud-token-epoch'
import {
changeOrcaCloudOrgMemberRole,
createOrcaCloudOrg,
@@ -200,11 +201,14 @@ export async function changeOrcaProfileOrgMemberRole(
if (!configState.configured) {
return { status: 'unconfigured' }
}
return mapMutationResult(
const result = mapMutationResult(
await runOrgMemberCall(configState.config, active, userDataPath, (session) =>
changeOrcaCloudOrgMemberRole(configState.config, session, args)
)
)
// Why: the member may be this user; a cached sandbox token would keep the old role.
bumpOrcaCloudTokenEpoch()
return result
}
export async function removeOrcaProfileOrgMember(
@@ -219,11 +223,14 @@ export async function removeOrcaProfileOrgMember(
if (!configState.configured) {
return { status: 'unconfigured' }
}
return mapMutationResult(
const result = mapMutationResult(
await runOrgMemberCall(configState.config, active, userDataPath, (session) =>
removeOrcaCloudOrgMember(configState.config, session, args)
)
)
// Why: the member may be this user; a cached sandbox token would keep the old role.
bumpOrcaCloudTokenEpoch()
return result
}
/** Revoke then re-send so the provider emails a fresh link; the role is kept. */
@@ -0,0 +1,109 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getOrcaCloudAuthConfig } from '../cloud-auth/profile-cloud-auth-config'
import type { ActiveOrcaProfileState } from '../profile-index-store'
import { getOrcaCloudTokenEpoch } from '../cloud-token-epoch'
const mocks = vi.hoisted(() => ({
calls: new Array<string>(),
refresh: vi.fn(),
commit: vi.fn()
}))
vi.mock('../cloud-auth/profile-cloud-client', () => ({
OrcaCloudRequestError: class extends Error {
statusCode = 0
},
selectOrcaCloudOrg: async (_config: unknown, _session: unknown, orgId: string | null) => {
mocks.calls.push('select')
return {
cloud: { userId: 'user-a', cloudProfileId: 'cp-a', ...(orgId ? { activeOrgId: orgId } : {}) },
capabilities: { flags: {}, refreshedAt: 1 }
}
}
}))
vi.mock('../cloud-session/profile-cloud-session-refresh', () => ({
forceRefreshOrcaCloudSession: (...args: unknown[]) => {
mocks.calls.push('refresh')
return mocks.refresh(...args)
}
}))
vi.mock('../cloud-session/profile-cloud-session-owner', () => ({
beginCloudSessionRelink: () => ({ generation: 0, linkVersion: 1 }),
readCloudSession: () => ({ status: 'found', session: { accessToken: 'a', refreshToken: 'r' } }),
commitCloudSessionRelink: (...args: unknown[]) => {
mocks.calls.push('commit')
return mocks.commit(...args)
}
}))
const { selectCloudOrg, ORG_SWITCH_REFRESH_FAILED } = await import('./profile-cloud-org-selection')
const profile = {
id: 'p1',
name: 'Test',
avatar: { kind: 'initials', initials: 'T', color: 'neutral' },
kind: 'cloud-linked',
createdAt: 1,
updatedAt: 1,
lastOpenedAt: 1,
cloud: {
userId: 'user-a',
cloudProfileId: 'cp-a',
email: 'a@example.test',
activeOrgId: 'org-old',
linkedAt: 1
}
} satisfies ActiveOrcaProfileState['profile']
const active: ActiveOrcaProfileState = {
index: { schemaVersion: 1, activeProfileId: 'p1', profiles: [profile] },
profile,
dataFile: '/tmp/unused/data',
stateDatabaseFile: '/tmp/unused/state',
profileDirectory: '/tmp/unused'
}
const authConfig = getOrcaCloudAuthConfig(
{ ORCA_CLOUD_API_URL: 'http://127.0.0.1:9', ORCA_CLOUD_CLIENT_ID: 'test-client' },
false
)
function select() {
return selectCloudOrg({
config: authConfig.configured ? authConfig.config : expect.fail('auth config'),
active,
userDataPath: '/tmp/unused',
orgId: 'org-new'
})
}
describe('selectCloudOrg', () => {
beforeEach(() => {
mocks.calls.length = 0
mocks.refresh.mockReset()
mocks.commit.mockReset()
})
it('publishes the new team only after the session refreshed for it, then drops sandbox tokens', async () => {
mocks.refresh.mockResolvedValue({ status: 'found', session: {} })
mocks.commit.mockReturnValue({ activeProfileId: 'p1', profiles: [] })
const epoch = getOrcaCloudTokenEpoch()
await expect(select()).resolves.toEqual({ activeProfileId: 'p1', profiles: [] })
expect(mocks.calls).toEqual(['select', 'refresh', 'commit'])
expect(getOrcaCloudTokenEpoch()).toBeGreaterThan(epoch)
})
it('a failed refresh fails the switch loudly and leaves the old team in place', async () => {
mocks.refresh.mockRejectedValue(new Error('network down'))
await expect(select()).rejects.toThrow('network down')
expect(mocks.calls).toEqual(['select', 'refresh'])
expect(mocks.commit).not.toHaveBeenCalled()
})
it('a refresh that needs reconnecting fails the switch with a readable message', async () => {
mocks.refresh.mockResolvedValue({ status: 'reconnect-required' })
await expect(select()).rejects.toThrow(ORG_SWITCH_REFRESH_FAILED)
expect(mocks.commit).not.toHaveBeenCalled()
})
})
@@ -3,12 +3,16 @@ import { OrcaCloudRequestError, selectOrcaCloudOrg } from '../cloud-auth/profile
import { forceRefreshOrcaCloudSession } from '../cloud-session/profile-cloud-session-refresh'
import type { ActiveOrcaProfileState } from '../profile-index-store'
import type { OrcaProfileListState } from '../../../shared/orca-profiles'
import { bumpOrcaCloudTokenEpoch } from '../cloud-token-epoch'
import {
beginCloudSessionRelink,
commitCloudSessionRelink,
readCloudSession
} from '../cloud-session/profile-cloud-session-owner'
export const ORG_SWITCH_REFRESH_FAILED =
"Couldn't refresh your Orca sign-in for the new team. Try again, or sign out and back in."
export async function selectCloudOrg(input: {
config: OrcaCloudAuthConfig
active: ActiveOrcaProfileState
@@ -48,21 +52,28 @@ export async function selectCloudOrg(input: {
) {
throw new Error('orca_cloud_org_selection_identity_mismatch')
}
// Why: /auth/org updates the stored selection but not the access token, and auth (sandbox
// tokens included) reads the team from it. Publishing the new activeOrgId before the token
// follows lets a mint in between cache an old-team token under the new team. So refresh first,
// and commit only once the session speaks for the new team; a failed refresh fails the switch.
const current = readCloudSession(profileId, input.userDataPath)
if (current.status !== 'found') {
throw new Error(ORG_SWITCH_REFRESH_FAILED)
}
const refreshed = await forceRefreshOrcaCloudSession(
input.config,
input.active,
input.userDataPath,
current.session
)
if (refreshed.status !== 'found') {
throw new Error(ORG_SWITCH_REFRESH_FAILED)
}
const list = commitCloudSessionRelink(profileId, input.userDataPath, ticket, selected)
if (!list) {
throw new Error('stale_cloud_session_mutation')
}
// /auth/org updates the stored selection but not the access token, and some services read the
// team from the token. Refresh now so they follow the switch (best effort; the next refresh
// would pick it up anyway).
const current = readCloudSession(profileId, input.userDataPath)
if (current.status === 'found') {
await forceRefreshOrcaCloudSession(
input.config,
input.active,
input.userDataPath,
current.session
).catch(() => undefined)
}
// Drop every cached sandbox token minted for the previous team.
bumpOrcaCloudTokenEpoch()
return list
}
@@ -9,6 +9,7 @@ import type {
} from '../../../shared/orca-profiles'
import { getOrcaProfileDirectory } from '../profile-storage-paths'
import { allowsPlaintextOrcaCloudSession } from '../cloud-auth/profile-cloud-auth-config'
import { forgetOrcaCloudMemberships, noteOrcaCloudMemberships } from '../cloud-token-epoch'
export type OrcaCloudSession = {
accessToken: string
@@ -156,6 +157,8 @@ export function saveOrcaCloudSession(
options: { spentOnFailure?: boolean } = {}
): OrcaCloudSessionPersistence {
const key = orcaCloudSessionKey(profileId, userDataPath)
// Why: a role or membership change must drop cached sandbox tokens minted under the old role.
noteOrcaCloudMemberships(key, session.organizations)
try {
const persistence = writeOrcaCloudSession(profileId, userDataPath, session)
unsavedRotations.delete(key)
@@ -271,6 +274,7 @@ export function clearOrcaCloudSession(profileId: string, userDataPath: string):
const key = orcaCloudSessionKey(profileId, userDataPath)
unsavedRotations.delete(key)
memorySessions.delete(key)
forgetOrcaCloudMemberships(key)
rmSync(getOrcaCloudSessionPath(profileId, userDataPath), { force: true })
}
@@ -0,0 +1,42 @@
// Bumped whenever a cached Orca Cloud sandbox token may no longer speak for the right team or role:
// a team switch commit, or any change to the signed-in user's memberships or roles. Token caches
// compare epochs so a role change (owner -> member -> owner) never reuses a token minted before it.
import type { OrcaCloudOrgSummary } from '../../shared/orca-profiles'
let epoch = 0
const membershipSignatures = new Map<string, string>()
export function getOrcaCloudTokenEpoch(): number {
return epoch
}
export function bumpOrcaCloudTokenEpoch(): void {
epoch += 1
}
function membershipSignature(organizations: readonly OrcaCloudOrgSummary[] | undefined): string {
return (organizations ?? [])
.map((org) => `${org.orgId}:${org.role ?? ''}`)
.sort()
.join('|')
}
/** Record a session's memberships; bumps the epoch when they differ from the last ones seen. */
export function noteOrcaCloudMemberships(
sessionKey: string,
organizations: readonly OrcaCloudOrgSummary[] | undefined
): void {
const next = membershipSignature(organizations)
const previous = membershipSignatures.get(sessionKey)
membershipSignatures.set(sessionKey, next)
// First sighting counts as a change: a token minted at startup predates what we now know.
if (previous !== next) {
bumpOrcaCloudTokenEpoch()
}
}
export function forgetOrcaCloudMemberships(sessionKey: string): void {
if (membershipSignatures.delete(sessionKey)) {
bumpOrcaCloudTokenEpoch()
}
}
@@ -29,4 +29,30 @@ describe('normalizeRoster', () => {
])
expect('pictureUrl' in roster.members[4]!).toBe(false)
})
it('keeps a known MFA status and drops anything else', () => {
const roster = normalizeRoster({
viewerRole: 'owner',
canManageMembers: true,
pendingInvites: [],
members: [
{ userId: 'a', email: 'a@x.dev', role: 'owner', mfa: 'on' },
{ userId: 'b', email: 'b@x.dev', role: 'member', mfa: 'off', mfaDeadline: 5 },
{ userId: 'c', email: 'c@x.dev', role: 'member', mfa: 'pending', mfaDeadline: 1_800_000 },
{ userId: 'd', email: 'd@x.dev', role: 'member', mfa: 'pending', mfaDeadline: 'soon' },
{ userId: 'e', email: 'e@x.dev', role: 'member', mfa: true },
{ userId: 'f', email: 'f@x.dev', role: 'member' }
]
})
expect(roster.members.map((m) => [m.mfa, m.mfaDeadline])).toEqual([
['on', undefined],
// A deadline only means something while the member is pending.
['off', undefined],
['pending', 1_800_000],
['pending', undefined],
[undefined, undefined],
[undefined, undefined]
])
expect('mfa' in roster.members[5]!).toBe(false)
})
})
@@ -5,9 +5,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({ app: { isPackaged: false }, shell: { openExternal: vi.fn() } }))
import { shell } from 'electron'
import type { OrcaCloudAuthConfig } from './cloud-auth/profile-cloud-auth-config'
import {
beginOrcaCloudPkceFlow,
connectCurrentArgsFromUnknown,
pkceSignInOptions,
ORCA_CLOUD_SIGN_IN_TIMEOUT_MS,
orcaCloudSignInTimeoutMs
} from './cloud-auth/profile-cloud-pkce'
@@ -128,6 +131,26 @@ describe('PKCE loopback cancel and timeout', () => {
expect(onAuthorizeUrl).not.toHaveBeenCalled()
})
it('COPY-LINK-FIRST-CLICK: openBrowser false publishes the link with the listener up, opening nothing', async () => {
delete process.env.ORCA_CLOUD_DEV_OPEN_URL_FILE
vi.mocked(shell.openExternal).mockClear()
const abort = new AbortController()
let authorize = ''
const flow = beginOrcaCloudPkceFlow(config, 'profile-1', {
signal: abort.signal,
openBrowser: false,
onAuthorizeUrl: (url) => (authorize = url)
})
flow.catch(() => undefined)
await vi.waitFor(() => expect(authorize).not.toBe(''))
expect(shell.openExternal).not.toHaveBeenCalled()
// The same loopback listener a click would have: a pasted link completes against it.
const redirect = new URL(new URL(authorize).searchParams.get('redirect_uri')!)
expect((await fetch(new URL('/auth/callback?state=wrong', redirect))).status).toBe(400)
abort.abort()
await expect(flow).rejects.toThrow('orca_cloud_auth_cancelled')
})
it('times out a sign-in the browser never finishes', async () => {
process.env.ORCA_CLOUD_SIGN_IN_TIMEOUT_MS = '1000'
const { flow, redirect } = await start()
@@ -144,3 +167,18 @@ describe('PKCE loopback cancel and timeout', () => {
expect(orcaCloudSignInTimeoutMs({ ORCA_CLOUD_SIGN_IN_TIMEOUT_MS: 'x' }, false)).toBe(300_000)
})
})
describe('COPY-LINK-FIRST-CLICK options', () => {
it('only an explicit openBrowser false (from the renderer) skips the browser', () => {
expect(connectCurrentArgsFromUnknown({ openBrowser: false })).toEqual({ openBrowser: false })
expect(connectCurrentArgsFromUnknown({ openBrowser: 'false' })).toEqual({})
expect(connectCurrentArgsFromUnknown(undefined)).toEqual({})
expect(pkceSignInOptions(undefined)).toEqual({ openBrowser: true })
expect(pkceSignInOptions({ openBrowser: false })).toEqual({ openBrowser: false })
expect(pkceSignInOptions({ authorizeParams: { setup: '1' }, openBrowser: false })).toEqual({
openBrowser: false,
authorizeParams: { setup: '1' },
holdBrowser: true
})
})
})
@@ -24,7 +24,8 @@ vi.mock('electron', () => ({
}))
vi.mock('./cloud-auth/profile-cloud-pkce', () => ({
beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock
beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock,
pkceSignInOptions: () => ({})
}))
vi.mock('./cloud-auth/profile-cloud-client', () => ({
@@ -34,7 +35,10 @@ vi.mock('./cloud-auth/profile-cloud-client', () => ({
selectOrcaCloudOrg: vi.fn()
}))
import { connectCurrentOrcaProfile, getCurrentOrcaProfileAuthStatus } from './cloud-auth/profile-cloud-service'
import {
connectCurrentOrcaProfile,
getCurrentOrcaProfileAuthStatus
} from './cloud-auth/profile-cloud-service'
import {
cancelOrcaCloudSignIn,
getOrcaCloudSignInProgress,
+11 -7
View File
@@ -1,7 +1,6 @@
import { ipcRenderer } from 'electron'
import type {
CloudApiApproval,
CloudApiAuditPage,
CloudApiAvailability,
CloudApiCreatedKey,
CloudApiGrant,
@@ -11,6 +10,9 @@ import type {
CloudApiProject,
CloudApiResult,
CloudApiSettings,
CloudAuditLogExport,
CloudAuditLogPage,
CloudAuditLogQuery,
CreateCloudApiApprovalInput,
CreateCloudApiKeyInput,
Routine,
@@ -23,7 +25,7 @@ import type {
RoutineUpdated
} from '../../shared/cloud-api/cloud-api-types'
/** Desktop only: Orca Cloud API keys, org settings, audit and routines. The session token stays in main. */
/** Desktop only: Orca Cloud API keys, org settings, the team audit log and routines. The session token stays in main. */
export type CloudApiBridge = {
availability: () => Promise<CloudApiAvailability>
/** The desktop workspace running on this Orca Cloud sandbox, if this computer has it. */
@@ -37,10 +39,11 @@ export type CloudApiBridge = {
revokeGrant: (id: string) => Promise<CloudApiResult<unknown>>
getSettings: () => Promise<CloudApiResult<CloudApiSettings>>
putSettings: (input: { vmCliEnabled: boolean }) => Promise<CloudApiResult<CloudApiSettings>>
listAudit: (query?: {
limit?: number
before?: number
}) => Promise<CloudApiResult<CloudApiAuditPage>>
/** The team audit log: owners and admins of a team whose plan includes it (else `plan-required`). */
listAuditLog: (query?: CloudAuditLogQuery) => Promise<CloudApiResult<CloudAuditLogPage>>
exportAuditLogCsv: (
query?: Pick<CloudAuditLogQuery, 'category' | 'actor'>
) => Promise<CloudApiResult<CloudAuditLogExport>>
listProjects: () => Promise<CloudApiResult<{ projects: CloudApiProject[] }>>
listInstallations: () => Promise<CloudApiResult<{ installations: CloudApiInstallation[] }>>
listRoutines: () => Promise<CloudApiResult<{ routines: Routine[] }>>
@@ -69,7 +72,8 @@ export const cloudApiBridge: CloudApiBridge = {
revokeGrant: invoke('cloudApi:revokeGrant'),
getSettings: invoke('cloudApi:getSettings'),
putSettings: invoke('cloudApi:putSettings'),
listAudit: invoke('cloudApi:listAudit'),
listAuditLog: invoke('cloudApi:listAuditLog'),
exportAuditLogCsv: invoke('cloudApi:exportAuditLogCsv'),
listProjects: invoke('cloudApi:listProjects'),
listInstallations: invoke('cloudApi:listInstallations'),
listRoutines: invoke('cloudApi:listRoutines'),
+5 -2
View File
@@ -47,8 +47,11 @@ export type EphemeralVmApi = {
diagnostics: NonNullable<OrcaHooks['environmentRecipeDiagnostics']>
}[]
>
/** Names (never values) of this computer's env that recipe create scripts receive. */
forwardedEnvSummary: () => Promise<RecipeForwardedEnvSummary>
/**
* Names (never values) of this computer's env that recipe scripts receive. Given a recipe host's
* environmentId, main applies that recipe's passEnv; otherwise the whole env.
*/
forwardedEnvSummary: (args?: { environmentId?: string }) => Promise<RecipeForwardedEnvSummary>
/** Credential-like variable names (never values) recipe scripts would inherit from Orca. */
listCredentialEnvNames: () => Promise<string[]>
doctor: (args: { repoId: string; recipeId: string }) => Promise<EphemeralVmRecipeDoctorResult>
+1 -1
View File
@@ -14,7 +14,7 @@ import {
export const ephemeralVmApi = {
listRecipes: (args) => ipcRenderer.invoke('ephemeralVm:listRecipes', args),
listRecipeCatalog: () => ipcRenderer.invoke('ephemeralVm:listRecipeCatalog'),
forwardedEnvSummary: () => ipcRenderer.invoke('ephemeralVm:forwardedEnvSummary'),
forwardedEnvSummary: (args) => ipcRenderer.invoke('ephemeralVm:forwardedEnvSummary', args),
listCredentialEnvNames: () => ipcRenderer.invoke('ephemeralVm:listCredentialEnvNames'),
doctor: (args) => ipcRenderer.invoke('ephemeralVm:doctor', args),
provision: (args) => ipcRenderer.invoke('ephemeralVm:provision', args),
@@ -20,7 +20,6 @@ export const orcaCloudWorkspaceApi = {
listCloudWorkspaceIds: () => ipcRenderer.invoke(CH.listCloudWorkspaceIds),
getConfig: () => ipcRenderer.invoke(CH.getConfig),
connectGitHub: (args) => ipcRenderer.invoke(CH.connectGitHub, args),
gitHubInstallLink: (args) => ipcRenderer.invoke(CH.gitHubInstallLink, args),
getGitHubUser: () => ipcRenderer.invoke(CH.getGitHubUser),
connectGitHubUser: () => ipcRenderer.invoke(CH.connectGitHubUser),
gitHubUserConnectLink: () => ipcRenderer.invoke(CH.gitHubUserConnectLink),
+19 -6
View File
@@ -1,3 +1,13 @@
import type {
OrcaTeamMfaPendingResult,
OrcaTeamMfaPolicyArgs,
OrcaTeamMfaPolicyResult,
OrcaTeamMfaPolicySetArgs,
OrcaTeamMfaPolicySetResult,
OrcaTeamMfaRemindArgs,
OrcaTeamMfaRemindResult
} from '../../shared/orca-team-mfa-policy'
import type { OrcaAccountMfaResult } from '../../shared/orca-account-mfa'
import type {
CloudSetupGetResult,
CloudSetupLinkResult,
@@ -45,7 +55,6 @@ import type {
} from '../../shared/orca-profiles'
import type {
OrcaBillingCheckoutArgs,
OrcaBillingLinkResult,
OrcaBillingOpenResult,
OrcaBillingOverageArgs,
OrcaBillingOverageResult,
@@ -74,7 +83,8 @@ export type OrcaProfileApi = {
findProjectProfiles: (
args: FindOrcaProfileProjectsByPathArgs
) => Promise<FindOrcaProfileProjectsByPathResult>
connectCurrent: () => Promise<ConnectCurrentOrcaProfileResult>
/** COPY-LINK-FIRST-CLICK: `openBrowser: false` starts sign-in without opening the browser. */
connectCurrent: (args?: { openBrowser?: boolean }) => Promise<ConnectCurrentOrcaProfileResult>
signInProgress: () => Promise<OrcaCloudSignInProgress | null>
onSignInProgressChanged: (
/** `end` says why a sign-in stopped when the UI should tell the user (it timed out). */
@@ -84,6 +94,13 @@ export type OrcaProfileApi = {
refreshAuth: () => Promise<RefreshCurrentOrcaProfileAuthResult>
signOutCurrent: () => Promise<SignOutCurrentOrcaProfileResult>
selectOrg: (args: SelectOrcaProfileOrgArgs) => Promise<SelectOrcaProfileOrgResult>
/** The signed-in person's own two-factor state and where to manage it. */
accountMfa: () => Promise<OrcaAccountMfaResult>
/** The team's "require MFA" policy and the person's own state in it. */
teamMfaPolicy: (args: OrcaTeamMfaPolicyArgs) => Promise<OrcaTeamMfaPolicyResult>
teamMfaPolicySet: (args: OrcaTeamMfaPolicySetArgs) => Promise<OrcaTeamMfaPolicySetResult>
teamMfaPending: (args: OrcaTeamMfaPolicyArgs) => Promise<OrcaTeamMfaPendingResult>
teamMfaRemind: (args: OrcaTeamMfaRemindArgs) => Promise<OrcaTeamMfaRemindResult>
orgMembersList: (args: OrcaProfileOrgMembersListArgs) => Promise<OrcaProfileOrgMembersListResult>
orgMemberInvite: (
args: OrcaProfileOrgMemberInviteArgs
@@ -129,10 +146,6 @@ export type OrcaProfileApi = {
cloudSetupLink: (args: { sessionId: string }) => Promise<CloudSetupLinkResult>
/** Opens the Stripe Customer Portal in the browser. */
billingOpenPortal: () => Promise<OrcaBillingOpenResult>
/** "Copy link": a fresh Customer Portal URL, minted but not opened. */
billingPortalLink: () => Promise<OrcaBillingLinkResult>
/** "Copy link": the Checkout URL billingOpenCheckout would open, minted but not opened. */
billingCheckoutLink: (args: OrcaBillingCheckoutArgs) => Promise<OrcaBillingLinkResult>
/** The last 30 days of sandbox hours (breakdowns for owners and admins only). */
billingUsageHistory: (args: {
utcOffsetMinutes: number
+6 -3
View File
@@ -67,12 +67,17 @@ export const orcaProfilesApi = {
)
},
findProjectProfiles: (args) => ipcRenderer.invoke('orcaProfiles:findProjectProfiles', args),
connectCurrent: () => ipcRenderer.invoke('orcaProfiles:connectCurrent'),
connectCurrent: (args) => ipcRenderer.invoke('orcaProfiles:connectCurrent', args),
signInProgress: () => ipcRenderer.invoke('orcaProfiles:signInProgress'),
cancelSignIn: () => ipcRenderer.invoke('orcaProfiles:cancelSignIn'),
refreshAuth: () => ipcRenderer.invoke('orcaProfiles:refreshAuth'),
signOutCurrent: () => ipcRenderer.invoke('orcaProfiles:signOutCurrent'),
selectOrg: (args) => ipcRenderer.invoke('orcaProfiles:selectOrg', args),
accountMfa: () => ipcRenderer.invoke('orcaProfiles:accountMfa'),
teamMfaPolicy: (args) => ipcRenderer.invoke('orcaProfiles:teamMfaPolicy', args),
teamMfaPolicySet: (args) => ipcRenderer.invoke('orcaProfiles:teamMfaPolicySet', args),
teamMfaPending: (args) => ipcRenderer.invoke('orcaProfiles:teamMfaPending', args),
teamMfaRemind: (args) => ipcRenderer.invoke('orcaProfiles:teamMfaRemind', args),
orgMembersList: (args) => ipcRenderer.invoke('orcaProfiles:orgMembersList', args),
orgMemberInvite: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberInvite', args),
orgInviteRevoke: (args) => ipcRenderer.invoke('orcaProfiles:orgInviteRevoke', args),
@@ -102,8 +107,6 @@ export const orcaProfilesApi = {
billingPlans: () => ipcRenderer.invoke('orcaProfiles:billingPlans'),
billingOpenCheckout: (args) => ipcRenderer.invoke('orcaProfiles:billingOpenCheckout', args),
billingOpenPortal: () => ipcRenderer.invoke('orcaProfiles:billingOpenPortal'),
billingPortalLink: () => ipcRenderer.invoke('orcaProfiles:billingPortalLink'),
billingCheckoutLink: (args) => ipcRenderer.invoke('orcaProfiles:billingCheckoutLink', args),
billingTeamPlans: (args) => ipcRenderer.invoke('orcaProfiles:billingTeamPlans', args),
billingUsageHistory: (args) => ipcRenderer.invoke('orcaProfiles:billingUsageHistory', args),
cloudSetupStart: (args) => ipcRenderer.invoke('orcaProfiles:cloudSetupStart', args),
+2
View File
@@ -15,6 +15,7 @@ import { SecretRequestUnseenNotifier } from './components/agent-pane/SecretReque
import { LocalCommandApprovalDialogHost } from './components/local-commands/LocalCommandApprovalDialog'
import { TeamDialogHost } from './components/billing/team/TeamDialogHost'
import { TeammateProfileDialog } from './components/teammates/TeammateProfileDialog'
import { CloudWorkspacePickerDialog } from './components/cloud-workspaces/CloudWorkspacePicker'
import { PokesNotifier } from './components/teammates/PokesNotifier'
import { BrowserPagePrompts } from './components/browser-pane/browser-page-prompts'
import { LinkRoutingPreferenceDialogProvider } from './components/link-routing-preference-dialog'
@@ -137,6 +138,7 @@ function App(): React.JSX.Element {
<LocalCommandApprovalDialogHost />
<TeamDialogHost />
<TeammateProfileDialog />
<CloudWorkspacePickerDialog />
<PokesNotifier />
</LinkRoutingPreferenceDialogProvider>
</ConfirmationDialogProvider>
+41 -55
View File
@@ -1,12 +1,9 @@
import { LandingCloudCard } from './landing/LandingCloudCard'
import { LandingCloudSignInCard, useSignedOutOfOrcaCloud } from './landing/LandingCloudSignInCard'
import {
CloudWorkspacesHome,
useCloudWorkspacesPresent
} from './cloud-workspaces/CloudWorkspacesHome'
import { useCloudWorkspaceHighlight } from './cloud-workspaces/cloud-workspace-links'
import { LandingAddProjectMenu } from './landing/LandingAddProjectMenu'
import { useCloudWorkspacesPresent } from './cloud-workspaces/use-cloud-workspaces'
import { useEffect, useEffectEvent, useMemo, useRef, useState } from 'react'
import { AlertTriangle, ExternalLink, Star, X } from 'lucide-react'
import { AlertTriangle, ExternalLink, FolderPlus, GitBranchPlus, Star, X } from 'lucide-react'
import { cn } from '../lib/class-names'
import { useAppStore } from '../store/index'
import { isGitRepoKind } from '../../../shared/repo-kind'
@@ -24,7 +21,7 @@ import { useLandingPreflightRuntime } from './landing-preflight-runtime'
import { useLandingOrcaStarState, type LandingStarState } from './landing-github-star-state'
import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open'
import {
LandingActions,
LANDING_ACTION_BUTTON_CLASS,
LandingBrand,
LandingShortcutList,
type LandingShortcutItem
@@ -222,10 +219,9 @@ function PreflightBanner({
export default function Landing(): React.JSX.Element {
const t = useTranslate()
const cloudHighlightId = useCloudWorkspaceHighlight()
const cloudListPresent = useCloudWorkspacesPresent()
const repos = useAppStore((s) => s.repos)
const openModal = useAppStore((s) => s.openModal)
const cloudPresent = useCloudWorkspacesPresent()
const signedOutOfCloud = useSignedOutOfOrcaCloud()
const reconnectRequired = useAppStore(
(s) => s.orcaProfileAuthStatus?.state === 'reconnect-required'
@@ -264,57 +260,47 @@ export default function Landing(): React.JSX.Element {
])
return (
<div className="scrollbar-sleek absolute inset-0 flex flex-col items-center overflow-y-auto bg-background">
{/* Centered when alone. With the cloud list below, the list is the page (JA-30): no wordmark
or "select a workspace" line, just the primary actions as a top row aligned with it. */}
<div
className={cn('flex w-full flex-col items-center', cloudListPresent ? 'pt-8' : 'my-auto')}
>
<div className={cn('w-full px-6', cloudListPresent ? '@container max-w-3xl' : 'max-w-lg')}>
<div
className={cn(
'flex flex-col',
cloudListPresent ? 'items-start gap-3 pb-4' : 'items-center gap-4 py-8'
)}
>
{!cloudListPresent && <LandingBrand compact={false} />}
<div className="absolute inset-0 flex items-center justify-center bg-background">
<div className="w-full max-w-lg px-6">
<div className="flex flex-col items-center gap-4 py-8">
<LandingBrand />
{preflightIssues.length > 0 && (
<PreflightBanner issues={preflightIssues} repos={repos} />
)}
{preflightIssues.length > 0 && <PreflightBanner issues={preflightIssues} repos={repos} />}
{/* With the cloud list showing, only the no-project hint stays: the list is what to pick. */}
{(!cloudListPresent || !hasProjects) && (
<p className="text-sm text-muted-foreground text-center">
{hasProjects
? t(
'auto.components.Landing.9c00bd4adf',
'Select a workspace from the sidebar to begin.'
)
: t('auto.components.Landing.cd21242762', 'Add a project to get started.')}
</p>
)}
<p className="text-sm text-muted-foreground text-center">
{hasProjects
? t(
'auto.components.Landing.9c00bd4adf',
'Select a workspace from the sidebar to begin.'
)
: t('auto.components.Landing.cd21242762', 'Add a project to get started.')}
</p>
<LandingActions
overCloudList={cloudListPresent}
addProjectLabel={t('auto.components.Landing.f9eaa9e12d', 'Add project')}
createLabel={createActionLabel}
onAddProject={() => openModal('add-repo')}
onCreate={() => openModal('new-workspace-composer', { telemetrySource: 'unknown' })}
/>
<div className="flex items-center justify-center gap-2.5 flex-wrap">
<LandingAddProjectMenu>
<button className={LANDING_ACTION_BUTTON_CLASS}>
<FolderPlus className="size-3.5" />
{t('auto.components.Landing.f9eaa9e12d', 'Add project')}
</button>
</LandingAddProjectMenu>
{/* V3-08: signed out, the team's cloud workspaces can't be listed; say so where they'd be. */}
{hasProjects && signedOutOfCloud && !cloudListPresent ? (
<LandingCloudSignInCard reconnect={reconnectRequired} />
) : hasProjects ? (
<LandingCloudCard cloudWorkspacesPresent={cloudListPresent} />
) : null}
{!cloudListPresent && <LandingShortcutList shortcuts={shortcuts} />}
<button
className={LANDING_ACTION_BUTTON_CLASS}
onClick={() => openModal('new-workspace-composer', { telemetrySource: 'unknown' })}
>
<GitBranchPlus className="size-3.5" />
{createActionLabel}
</button>
</div>
</div>
<div className="w-full max-w-3xl px-6 pb-20 empty:hidden">
<CloudWorkspacesHome highlightId={cloudHighlightId} />
{/* V3-08: signed out, the team's cloud workspaces can't be opened; say so here. */}
{hasProjects && signedOutOfCloud ? (
<LandingCloudSignInCard reconnect={reconnectRequired} />
) : hasProjects ? (
<LandingCloudCard cloudWorkspacesPresent={cloudPresent} />
) : null}
<LandingShortcutList shortcuts={shortcuts} />
</div>
</div>
@@ -4,6 +4,7 @@ import { toast } from 'sonner'
import { registerSignInNotice } from '@/lib/orca-cloud/sign-in-notice-visibility'
import { Button } from './ui/button'
import { OpenExternalSplitButton } from './open-external/OpenExternalSplitButton'
import { mintFirstClickLink, signInLinkHint } from './orca-sign-in-link'
import { translate } from '@/i18n/i18n'
import { useTranslate } from '@/i18n/use-translate'
import { cn } from '@/lib/class-names'
@@ -12,7 +13,9 @@ import { useAppStore } from '../store/index'
async function copySignInLink(link: string): Promise<void> {
try {
await window.api.ui.writeClipboardText(link)
toast.success(translate('auto.components.OrcaSignInProgress.copied', 'Sign-in link copied'))
toast.success(translate('auto.components.OrcaSignInProgress.copied', 'Sign-in link copied'), {
description: signInLinkHint()
})
} catch (error) {
console.warn('Could not copy the Orca sign-in link:', error)
toast.error(
@@ -49,6 +52,7 @@ export function OrcaSignInButton({
groupClassName={props.className}
className="flex-1"
link={link}
copyHint={signInLinkHint()}
onClick={onClick}
>
{t('auto.components.OrcaSignInProgress.reopenBrowser', 'Reopen browser')}
@@ -56,10 +60,19 @@ export function OrcaSignInButton({
</OpenExternalSplitButton>
)
}
// COPY-LINK-FIRST-CLICK: Copy link starts the same sign-in without opening the browser and
// copies its link (the button then reads "Reopen browser" over that pending sign-in).
return (
<Button {...props} type="button" onClick={onClick}>
<OpenExternalSplitButton
{...props}
groupClassName={props.className}
className="flex-1"
link={() => mintFirstClickLink(() => void connect({ openBrowser: false }))}
copyHint={signInLinkHint()}
onClick={onClick}
>
{children}
</Button>
</OpenExternalSplitButton>
)
}
@@ -45,20 +45,27 @@ function useHostKeys(hostId: ExecutionHostId): AgentApiKeySummary[] | null {
return keys
}
function useRecipeEnvSummary(enabled: boolean): RecipeForwardedEnvSummary | null | undefined {
/** Main resolves this recipe host's passEnv from its recipe; the renderer only names the host. */
function useRecipeEnvSummary(
enabled: boolean,
environmentId: string | null
): RecipeForwardedEnvSummary | null | undefined {
const [summary, setSummary] = useState<RecipeForwardedEnvSummary | null | undefined>(null)
useEffect(() => {
if (!enabled) {
return
}
let cancelled = false
void (window.api.ephemeralVm?.forwardedEnvSummary() ?? Promise.resolve(undefined))
void (
window.api.ephemeralVm?.forwardedEnvSummary(environmentId ? { environmentId } : undefined) ??
Promise.resolve(undefined)
)
.catch(() => undefined)
.then((next) => !cancelled && setSummary(next))
return () => {
cancelled = true
}
}, [enabled])
}, [enabled, environmentId])
return summary
}
@@ -132,7 +139,7 @@ export function useHostPanelBody(
// A primitive snapshot so the selector stays stable between renders.
return `${accounts.claude ?? ''}\u0000${accounts.codex ?? ''}`
})
const recipeSummary = useRecipeEnvSummary(host.kind === 'recipe')
const recipeSummary = useRecipeEnvSummary(host.kind === 'recipe', host.environmentId)
const [signOutAgent, setSignOutAgent] = useState<HostAgentSignInAgent | null>(null)
const unavailable = host.availability !== 'available'
const agents = (
@@ -4,7 +4,8 @@ import { RecipeEnvForwardingWarning } from './RecipeEnvForwardingWarning'
const summary = {
count: 42,
keyLike: ['ANTHROPIC_API_KEY', 'GITHUB_TOKEN', 'NPM_TOKEN'],
other: ['EDITOR', 'HOME', 'LANG', 'PATH', 'SHELL', 'TERM', 'USER']
other: ['EDITOR', 'HOME', 'LANG', 'PATH', 'SHELL', 'TERM', 'USER'],
narrowed: false
}
export const warning = defineFixture({
@@ -29,6 +30,22 @@ export const manyKeys = defineFixture({
)
})
// A recipe with `passEnv: ['AWS_*']`: only base variables plus the AWS ones reach its scripts.
export const narrowed = defineFixture({
width: 420,
render: () => (
<RecipeEnvForwardingWarning
summary={{
count: 6,
keyLike: ['AWS_SECRET_ACCESS_KEY'],
other: ['HOME', 'LANG', 'PATH', 'SHELL', 'USER'],
narrowed: true
}}
defaultReviewOpen
/>
)
})
export const noKeys = defineFixture({
width: 420,
render: () => <RecipeEnvForwardingWarning summary={{ ...summary, keyLike: [] }} />
@@ -0,0 +1,25 @@
// @vitest-environment happy-dom
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, expect, it } from 'vitest'
import { RecipeEnvForwardingWarning } from './RecipeEnvForwardingWarning'
afterEach(cleanup)
const summary = { count: 2, keyLike: ['AWS_SECRET_ACCESS_KEY'], other: ['PATH'] }
it('says the recipe gets only what its passEnv allows, with passEnv as code', () => {
render(<RecipeEnvForwardingWarning summary={{ ...summary, narrowed: true }} />)
const code = screen.getByText('passEnv')
expect(code.tagName).toBe('CODE')
expect(code.parentElement?.textContent).toBe(
"This recipe's create script gets only the variables its passEnv allows."
)
expect(screen.queryByText(/this computer's environment/)).toBeNull()
})
it("keeps the whole-environment headline when passEnv didn't apply", () => {
render(<RecipeEnvForwardingWarning summary={{ ...summary, narrowed: false }} />)
expect(
screen.getByText("This recipe's create script gets this computer's environment.")
).toBeTruthy()
})
@@ -1,6 +1,8 @@
import { TriangleAlert } from 'lucide-react'
import type React from 'react'
import { useEffect, useId, useState } from 'react'
import { Trans } from 'react-i18next'
import { i18n } from '@/i18n/i18n'
import { useTranslate } from '@/i18n/use-translate'
import { Button } from '../ui/button'
import { Skeleton } from '../ui/skeleton'
@@ -9,8 +11,9 @@ import type { RecipeForwardedEnvSummary } from '../../../../shared/ephemeral-vm-
const NAMES_IN_LINE = 3
/**
* P-4 (agent accounts spec §4, Q9): recipe create scripts get this computer's whole environment.
* A warning only; names, never values. `summary` null while loading, undefined when unavailable.
* P-4 (agent accounts spec §4, Q9): recipe create scripts get this computer's environment, or only
* what the recipe's passEnv allows (`summary.narrowed`). A warning only; names, never values.
* `summary` null while loading, undefined when unavailable.
*/
export function RecipeEnvForwardingWarning({
summary,
@@ -70,9 +73,21 @@ export function RecipeEnvForwardingWarning({
<TriangleAlert className="mt-0.5 size-4 shrink-0 text-status-warning" aria-hidden />
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium text-foreground">
{t(
'agentHostPanel.recipeEnv',
"This recipe's create script gets this computer's environment."
{summary?.narrowed ? (
// passEnv is an orca.yaml key, so it reads as code rather than a word.
<Trans
i18n={i18n}
defaults={t(
'agentHostPanel.recipeEnvNarrowed',
"This recipe's create script gets only the variables its <code>passEnv</code> allows."
)}
components={{ code: <code className="rounded bg-muted px-1 py-0.5 font-mono" /> }}
/>
) : (
t(
'agentHostPanel.recipeEnv',
"This recipe's create script gets this computer's environment."
)
)}
</p>
{summary === null ? (
@@ -90,7 +90,8 @@ export function HostFixture({
summary={{
count: 42,
keyLike: ['AWS_SECRET_ACCESS_KEY', 'OPENAI_API_KEY'],
other: ['HOME', 'PATH']
other: ['HOME', 'PATH'],
narrowed: false
}}
/>
) : null
@@ -16,7 +16,12 @@ import type * as LaunchHoldModule from '@/lib/orca-cloud/cloud-agent-launch-hold
const stub = vi.hoisted(() => {
const state: {
hold: { agent: 'claude'; reason: 'notSetUp' | 'accountExpired'; prompt: string | null } | null
hold: {
agent: 'claude'
reason: 'notSetUp' | 'accountExpired' | 'awaitingStart'
awaiting?: 'setUp'
prompt: string | null
} | null
setup: OrcaCloudAgentSetup
} = { hold: null, setup: { phase: 'idle' } }
return state
@@ -200,6 +205,14 @@ describe('the held-launch strip in the chat view', () => {
).toBeTruthy()
})
it('HELD-STALE-COVERED: set up here: "is set up · Start", not "Set up once"', () => {
stub.hold = { agent: 'claude', reason: 'awaitingStart', awaiting: 'setUp', prompt: null }
const slot = inChat()
expect(within(slot).getByText('Claude is set up for Orca Cloud')).toBeTruthy()
expect(within(slot).getByRole('button', { name: /Start Claude|Start/ })).toBeTruthy()
expect(within(slot).queryByRole('button', { name: 'Set up once' })).toBeNull()
})
it('sign-in expired: Renew', () => {
stub.hold = { agent: 'claude', reason: 'accountExpired', prompt: null }
const slot = inChat()
@@ -2,7 +2,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const store = vi.hoisted(() => ({ stop: vi.fn(), run: vi.fn(), reload: vi.fn() }))
const hold = vi.hoisted(() => ({ release: vi.fn() }))
const hold = vi.hoisted(() => ({ release: vi.fn(), start: vi.fn(async () => 'held') }))
vi.mock('@/lib/orca-cloud/orca-cloud-agent-accounts-store', () => ({
runOrcaCloudAgentSetups: store.run,
@@ -10,7 +10,8 @@ vi.mock('@/lib/orca-cloud/orca-cloud-agent-accounts-store', () => ({
reloadOrcaCloudAgentAccounts: store.reload
}))
vi.mock('@/lib/orca-cloud/cloud-agent-launch-hold', () => ({
releaseCloudAgentHold: hold.release
releaseCloudAgentHold: hold.release,
startCloudAgentHold: hold.start
}))
// The strip reads the agents bridge at build time; these tests never reach it.
@@ -33,6 +34,7 @@ describe('cloudSetupStripActions (PC-3b, RR-a)', () => {
beforeEach(() => {
store.stop.mockReset()
hold.release.mockReset()
hold.start.mockClear()
})
it('Cancel during a setup stops only the setup: the launch stays held', () => {
@@ -47,8 +49,9 @@ describe('cloudSetupStripActions (PC-3b, RR-a)', () => {
expect(hold.release).toHaveBeenCalledExactlyOnceWith('tab:1', 'cancel')
})
it('Start releases the held launch', () => {
it('Start re-checks the account before it releases the held launch (HELD-STALE-COVERED)', () => {
actions(false).primary({ kind: 'start' })
expect(hold.release).toHaveBeenCalledExactlyOnceWith('tab:1', 'proceed')
expect(hold.start).toHaveBeenCalledExactlyOnceWith('tab:1')
expect(hold.release).not.toHaveBeenCalled()
})
})
@@ -3,7 +3,10 @@
// setup stops only that setup: the launch stays held on "Set up once", and a setup finished from
// anywhere (here or Settings) turns the strip into "Start". Cancel with no setup running gives up
// the held launch and opens a plain shell, like cancelling any held launch (RR-a).
import { releaseCloudAgentHold } from '@/lib/orca-cloud/cloud-agent-launch-hold'
import {
releaseCloudAgentHold,
startCloudAgentHold
} from '@/lib/orca-cloud/cloud-agent-launch-hold'
import {
reloadOrcaCloudAgentAccounts,
runOrcaCloudAgentSetups,
@@ -39,7 +42,8 @@ export function cloudSetupStripActions(args: {
args.copyText(action.code)
args.openLink(action.url)
} else if (action.kind === 'start') {
releaseCloudAgentHold(holdKey, 'proceed')
// HELD-STALE-COVERED: Start reads the account again; a revoked sign-in shows Renew instead.
void startCloudAgentHold(holdKey)
}
},
submitCode: (token) => {
@@ -69,27 +69,32 @@ export function useCloudHeldStrip(
// V3-01: a restored hold waiting for Start shares the setup hold's actions (Start, cancel).
const setupHold = hold?.reason === 'notSetUp' || awaiting !== null
const setup = accounts.setups[agent]
const model = awaiting
? cloudAgentAwaitingStartStripModel({
agent,
awaiting,
queuedPrompt: Boolean(hold?.prompt),
t
})
: setupHold
? cloudAgentSetupStripModel({
agent,
setup,
ready: accounts.load.kind === 'ready' && cloudAgentHoldReason(agent, accounts) === null,
t
})
: cloudAgentStripModel({
agent,
reason,
view: hold ? null : (cloudGate?.view ?? null),
hostLabel: cloudGate?.hostLabel ?? t('agentSignInStrip.sandbox', 'the sandbox'),
t
})
// HELD-STALE-COVERED: the setup just finished here: "is set up · Start" (the account, read fresh).
const model =
awaiting === 'setUp'
? cloudAgentSetupStripModel({ agent, setup: accounts.setups[agent], ready: true, t })
: awaiting
? cloudAgentAwaitingStartStripModel({
agent,
awaiting,
queuedPrompt: Boolean(hold?.prompt),
t
})
: setupHold
? cloudAgentSetupStripModel({
agent,
setup,
ready:
accounts.load.kind === 'ready' && cloudAgentHoldReason(agent, accounts) === null,
t
})
: cloudAgentStripModel({
agent,
reason,
view: hold ? null : (cloudGate?.view ?? null),
hostLabel: cloudGate?.hostLabel ?? t('agentSignInStrip.sandbox', 'the sandbox'),
t
})
const setupRunning = setup.phase !== 'idle' && setup.phase !== 'saved' && setup.phase !== 'failed'
const holdActions: CloudStripActions | null = setupHold
? cloudSetupStripActions({
@@ -1,7 +1,6 @@
import { ExternalLink } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { InlineError } from '@/components/ui/inline-error'
import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton'
import { billingPortalLink } from './billing-link'
import { useIntlLocale, useTranslate } from '@/i18n/use-translate'
import type { OrcaBillingEntitlement } from '../../../../shared/orca-billing'
@@ -63,20 +62,16 @@ export function PaymentIssueNotice({
}
action={
entitlement.canManageBilling ? (
<OpenExternalSplitButton
<Button
type="button"
variant="outline"
size="xs"
aria-disabled={busy || undefined}
link={billingPortalLink}
onClick={() => {
if (!busy) {
onUpdatePayment()
}
}}
onClick={busy ? undefined : onUpdatePayment}
>
{t('auto.components.billing.updatePayment', 'Update payment method')}
<ExternalLink />
</OpenExternalSplitButton>
</Button>
) : undefined
}
/>
@@ -0,0 +1,117 @@
// OVERAGE-REENABLE: the overage card through on → off → on, and each save state it can show.
import { useState } from 'react'
import { defineFixture } from '../../component-fixtures/component-fixture'
import type {
OrcaBillingEntitlement,
OrcaBillingOverageArgs
} from '../../../../shared/orca-billing'
import { PlanBillingView, type PlanBillingNotice } from './PlanBillingSection'
import { sampleTeam } from './plan-billing-fixtures'
type Outcome = 'ok' | 'failed' | 'pending'
const overageOn: OrcaBillingEntitlement = {
...sampleTeam,
overage: { ...sampleTeam.overage, optedIn: true, active: true, spendCapCents: 4000 }
}
/** Plays PlanBillingSection with a fake billing: busy while out, then a re-read or a failure. */
function OverageCard({
initial,
outcome
}: {
initial: OrcaBillingEntitlement
outcome: Outcome
}): React.JSX.Element {
const [entitlement, setEntitlement] = useState(initial)
const [busy, setBusy] = useState(false)
const [failures, setFailures] = useState(0)
const [notice, setNotice] = useState<PlanBillingNotice | null>(null)
const setOverage = (args: OrcaBillingOverageArgs): void => {
setBusy(true)
setNotice(null)
if (outcome === 'pending') {
return
}
setTimeout(() => {
setBusy(false)
if (outcome === 'failed') {
setNotice({ kind: 'error', text: 'Couldn’t reach billing. Try again.' })
setFailures((count) => count + 1)
return
}
setEntitlement((current) => ({
...current,
overage: {
...current.overage,
optedIn: args.enabled,
active: args.enabled,
spendCapCents: args.spendCapCents ?? current.overage.spendCapCents
}
}))
}, 20)
}
return (
<PlanBillingView
loading={false}
busy={busy}
notice={notice}
teamName="Orca Staging Eval Team"
result={{ status: 'ok', entitlement }}
overageFailures={failures}
onRefresh={() => {}}
onPortal={() => {}}
onSetOverage={setOverage}
onSeePlans={() => {}}
/>
)
}
function clickButton(frame: HTMLElement, label: RegExp): void {
const button = [...frame.querySelectorAll<HTMLButtonElement>('button')].find((candidate) =>
label.test(candidate.textContent ?? '')
)
button?.click()
}
const settle = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 150))
/** Turned on, then off: the off line, a working "Turn on with a $25 cap" and "Saved". */
export const overageOffAfterOn = defineFixture({
width: 680,
render: () => <OverageCard initial={overageOn} outcome="ok" />,
interact: async (frame) => {
clickButton(frame, /^Turn off overage$/)
await settle()
}
})
/** In flight: a spinner with "Saving…" (no check), Turn on still there but inert. */
export const overageSaving = defineFixture({
width: 680,
render: () => <OverageCard initial={sampleTeam} outcome="pending" />,
interact: async (frame) => {
clickButton(frame, /^Turn on with a/)
await settle()
}
})
/** Turned on again from off: the $25 cap field, focused, with "✓ Saved". */
export const overageSavedOnAgain = defineFixture({
width: 680,
render: () => <OverageCard initial={sampleTeam} outcome="ok" />,
interact: async (frame) => {
clickButton(frame, /^Turn on with a/)
await settle()
}
})
/** Turn off failed: overage stays on at its cap, "Couldn’t save", and billing's reason below. */
export const overageSaveError = defineFixture({
width: 680,
render: () => <OverageCard initial={overageOn} outcome="failed" />,
interact: async (frame) => {
clickButton(frame, /^Turn off overage$/)
await settle()
}
})
@@ -0,0 +1,197 @@
// @vitest-environment happy-dom
// OVERAGE-REENABLE: after Turn off the card showed "✓ Saving…" forever and no way to turn on again.
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { useState } from 'react'
import { afterEach, describe, expect, it } from 'vitest'
import type {
OrcaBillingEntitlement,
OrcaBillingOverageArgs
} from '../../../../shared/orca-billing'
import { OverageControls } from './PlanBillingUsage'
import { sampleTeam } from './plan-billing-fixtures'
afterEach(cleanup)
type Pending = { args: OrcaBillingOverageArgs; settle: (ok: boolean) => void }
/** Plays PlanBillingSection: busy while the request is out, then a re-read or a failure bump. */
function renderCard(initial: OrcaBillingEntitlement = sampleTeam): { pending: Pending[] } {
const pending: Pending[] = []
function Card(): React.JSX.Element {
const [entitlement, setEntitlement] = useState(initial)
const [busy, setBusy] = useState(false)
const [failures, setFailures] = useState(0)
return (
<OverageControls
entitlement={entitlement}
busy={busy}
failures={failures}
onSetOverage={(args) => {
setBusy(true)
pending.push({
args,
settle: (ok) => {
setBusy(false)
if (!ok) {
setFailures((count) => count + 1)
return
}
// Billing keeps the cap through an opt-out; an omitted cap keeps the current one.
setEntitlement((current) => ({
...current,
overage: {
...current.overage,
optedIn: args.enabled,
active: args.enabled,
spendCapCents: args.spendCapCents ?? current.overage.spendCapCents
}
}))
}
})
}}
/>
)
}
render(<Card />)
return { pending }
}
const settleLast = (pending: Pending[], ok: boolean): void => {
act(() => pending.at(-1)!.settle(ok))
}
const turnOnButton = (): HTMLElement =>
screen.getByRole('button', { name: 'Turn on with a $25 cap' })
const statusText = (): string | null => screen.queryByRole('status')?.textContent ?? null
describe('overage on, off and on again', () => {
it('turns on, off and on again with the same control, ending each change in Saved', () => {
const { pending } = renderCard()
fireEvent.click(turnOnButton())
expect(pending.at(-1)!.args).toEqual({ enabled: true, spendCapCents: 2500 })
expect(statusText()).toBe('Saving…')
settleLast(pending, true)
expect(screen.getByLabelText<HTMLInputElement>('Monthly spend cap').value).toBe('25')
expect(statusText()).toBe('Saved')
fireEvent.click(screen.getByRole('button', { name: 'Turn off overage' }))
expect(pending.at(-1)!.args).toEqual({ enabled: false })
expect(statusText()).toBe('Saving…')
settleLast(pending, true)
expect(screen.getByText(/^Overage is off\./)).toBeTruthy()
// The bug: the off view kept a stale flag and showed "✓ Saving…" in place of Turn on.
expect(statusText()).toBe('Saved')
expect(turnOnButton().getAttribute('aria-disabled')).toBeNull()
expect(document.activeElement).toBe(turnOnButton())
fireEvent.click(turnOnButton())
expect(pending).toHaveLength(3)
expect(pending.at(-1)!.args).toEqual({ enabled: true, spendCapCents: 2500 })
settleLast(pending, true)
expect(screen.getByLabelText('Monthly spend cap')).toBeTruthy()
expect(document.activeElement).toBe(screen.getByLabelText('Monthly spend cap'))
expect(statusText()).toBe('Saved')
})
it('keeps Turn on visible but inert while saving, so one click sends one change', () => {
const { pending } = renderCard()
fireEvent.click(turnOnButton())
expect(turnOnButton().getAttribute('aria-disabled')).toBe('true')
fireEvent.click(turnOnButton())
expect(pending).toHaveLength(1)
})
it('a cap change goes Saving… then Saved, and editing again clears it', () => {
const { pending } = renderCard({
...sampleTeam,
overage: { ...sampleTeam.overage, optedIn: true, active: true, spendCapCents: 4000 }
})
const field = screen.getByLabelText<HTMLInputElement>('Monthly spend cap')
fireEvent.change(field, { target: { value: '60' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(statusText()).toBe('Saving…')
expect(screen.queryByRole('button', { name: 'Save' })).toBeNull()
settleLast(pending, true)
expect(statusText()).toBe('Saved')
expect(field.value).toBe('60')
fireEvent.change(field, { target: { value: '70' } })
expect(statusText()).toBeNull()
expect(screen.getByRole('button', { name: 'Save' })).toBeTruthy()
})
})
describe('overage save state', () => {
// design-squad: the failure sits by the control that failed, not by the cap field.
it('a failed Turn off says so by its link and leaves overage on and changeable', () => {
const { pending } = renderCard({
...sampleTeam,
overage: { ...sampleTeam.overage, optedIn: true, active: true, spendCapCents: 4000 }
})
fireEvent.click(screen.getByRole('button', { name: 'Turn off overage' }))
settleLast(pending, false)
expect(statusText()).toBe('Couldn’t turn off overage.')
const off = screen.getByRole('button', { name: 'Turn off overage' })
expect(off.closest('div')?.contains(screen.getByRole('status'))).toBe(true)
const field = screen.getByLabelText<HTMLInputElement>('Monthly spend cap')
expect(field.value).toBe('40')
expect(field.closest('form')?.contains(screen.getByRole('status'))).toBe(false)
expect(off.getAttribute('aria-disabled')).toBeNull()
fireEvent.click(off)
expect(pending).toHaveLength(2)
})
it('a failed Turn on says so next to a working Turn on', () => {
const { pending } = renderCard()
fireEvent.click(turnOnButton())
settleLast(pending, false)
expect(statusText()).toBe('Couldn’t turn on overage.')
expect(screen.queryByLabelText('Monthly spend cap')).toBeNull()
fireEvent.click(turnOnButton())
expect(pending).toHaveLength(2)
settleLast(pending, true)
expect(statusText()).toBe('Saved')
})
it('a failed cap change says Couldn’t save by the field and rolls the value back', () => {
const { pending } = renderCard({
...sampleTeam,
overage: { ...sampleTeam.overage, optedIn: true, active: true, spendCapCents: 4000 }
})
const field = screen.getByLabelText<HTMLInputElement>('Monthly spend cap')
fireEvent.change(field, { target: { value: '60' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
settleLast(pending, false)
expect(statusText()).toBe('Couldn’t save')
expect(field.closest('form')?.contains(screen.getByRole('status'))).toBe(true)
expect(field.value).toBe('40')
})
it('never shows the check with Saving…', () => {
const { pending } = renderCard()
fireEvent.click(turnOnButton())
const saving = screen.getByRole('status')
expect(saving.textContent).toBe('Saving…')
expect(saving.querySelector('.lucide-check')).toBeNull()
settleLast(pending, true)
expect(screen.getByRole('status').querySelector('.lucide-check')).toBeTruthy()
})
// A host that never reports busy still resolves once the card reads the new state.
it('resolves Saving… from the re-read when busy never changes', () => {
const view = render(
<OverageControls entitlement={sampleTeam} busy={false} onSetOverage={() => {}} />
)
fireEvent.click(turnOnButton())
expect(statusText()).toBe('Saving…')
view.rerender(
<OverageControls
entitlement={{
...sampleTeam,
overage: { ...sampleTeam.overage, optedIn: true, active: true, spendCapCents: 2500 }
}}
busy={false}
onSetOverage={() => {}}
/>
)
expect(statusText()).toBe('Saved')
})
})

Some files were not shown because too many files have changed in this diff Show More