mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
Merge remote-tracking branch 'private/cloud/integration' into cloud/capability-off-recheck
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'])
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
+18
-1
@@ -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
Reference in New Issue
Block a user