diff --git a/cloud/apps/billing/src/audit-push.ts b/cloud/apps/billing/src/audit-push.ts new file mode 100644 index 00000000000..7ef81401cad --- /dev/null +++ b/cloud/apps/billing/src/audit-push.ts @@ -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 +} + +type PushEnv = Pick + +/** 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 { + 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 { + 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 & { + /** 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 +} diff --git a/cloud/apps/billing/src/index.ts b/cloud/apps/billing/src/index.ts index 24cfc27063e..419c7a6265e 100644 --- a/cloud/apps/billing/src/index.ts +++ b/cloud/apps/billing/src/index.ts @@ -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) } diff --git a/cloud/apps/billing/src/overage.ts b/cloud/apps/billing/src/overage.ts index f0ae0fb604f..7007edce43c 100644 --- a/cloud/apps/billing/src/overage.ts +++ b/cloud/apps/billing/src/overage.ts @@ -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, - now: number + now: number, + fetchImpl: typeof fetch = fetch ): Promise { 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) => { diff --git a/cloud/apps/billing/src/webhook.ts b/cloud/apps/billing/src/webhook.ts index 0a29a52b881..887e2cda185 100644 --- a/cloud/apps/billing/src/webhook.ts +++ b/cloud/apps/billing/src/webhook.ts @@ -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 { 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)) } /** diff --git a/cloud/apps/billing/test/audit-push.test.ts b/cloud/apps/billing/test/audit-push.test.ts new file mode 100644 index 00000000000..d5f7829407a --- /dev/null +++ b/cloud/apps/billing/test/audit-push.test.ts @@ -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 } + +/** 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> } }> = [] + 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[0], after: Parameters[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) + } + } + }) +}) diff --git a/cloud/apps/billing/test/overage-opt-out.test.ts b/cloud/apps/billing/test/overage-opt-out.test.ts index 544550aad18..c36d156e976 100644 --- a/cloud/apps/billing/test/overage-opt-out.test.ts +++ b/cloud/apps/billing/test/overage-opt-out.test.ts @@ -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() diff --git a/cloud/apps/sandbox-control/image/vm-scripts.generated.ts b/cloud/apps/sandbox-control/image/vm-scripts.generated.ts index d52314674b6..64a1c7816bb 100644 --- a/cloud/apps/sandbox-control/image/vm-scripts.generated.ts +++ b/cloud/apps/sandbox-control/image/vm-scripts.generated.ts @@ -3,9 +3,9 @@ import type { BuildInputs, GoldenImageManifest } from './golden-image' export const MANIFEST: GoldenImageManifest = {"imageVersion":"2026-11-09.1","image":"vercel/sandbox/node:24","region":"iad1","vcpus":4,"orcad":{"bundleUrl":null,"sha256":null},"gh":{"version":"2.103.3","sha256":"4c806978ffce1cec70853302a56d70ac6da4f19bae7adca4e1a8ab00633dd91d"},"chrome":{"version":"157.0.8103.38-1","sha256":"a62b277dc03da2cff50cc04589deb66f6875be339c066d46adb49b49eedb629d"},"agentCliPackages":["@anthropic-ai/claude-code@2.1.319","@openai/codex@0.166.0","opencode-ai@1.19.5","@google/gemini-cli@0.67.0","@github/copilot@1.0.108"],"cursorAgent":{"version":"2026.11.05-86047df","sha256":"170b77786cbf4c93064d64fff12b71a178b968730561c64afc2e8e752535442e"},"desktop":{"novnc":{"version":"1.6.0","sha256":"5066103959ef4e9b10f37e5a148627360dd8414e4cf8a7db92bdbd022e728aaa"},"websockify":{"version":"0.13.0","sha256":"b6413e364efd04f3c92ec8c17747e3c4adc20157c2ef1c5d019a26d944a46df8"}}} export const PROVISION_SCRIPT: string = "#!/usr/bin/env bash\n# Runs once inside the build sandbox (as the sandbox user, with sudo). Installs the pinned orcad\n# bundle, agent CLIs and Orca's VM entry points into /opt/orca, then smoke-tests orcad.\n# Inputs (env, from image/golden-image.manifest.json via build-golden-image.ts):\n# ORCAD_BUNDLE_URL ORCAD_BUNDLE_SHA256 [GH_VERSION GH_SHA256] [CHROME_VERSION CHROME_SHA256]\n# AGENT_NPM_PACKAGES (space-separated\n# name@version), [CURSOR_AGENT_VERSION CURSOR_AGENT_SHA256], [NOVNC_VERSION NOVNC_SHA256 WEBSOCKIFY_VERSION WEBSOCKIFY_SHA256] and ORCA_VM_SCRIPT_ (base64) for each /opt/orca/bin entry point.\n# [ORCA_VM_SCRIPTS_STAMP] (VM_SCRIPTS_STAMP of those scripts; written to /opt/orca/bin/.scripts-stamp).\nset -euo pipefail\nORCA_HOME=/opt/orca\nlog() { printf '[golden] %s\\n' \"$*\" >&2; }\n\nsudo mkdir -p \"$ORCA_HOME/bin\" \"$ORCA_HOME/runtime\" \"$ORCA_HOME/tools\"\nsudo chown -R \"$(id -u):$(id -g)\" \"$ORCA_HOME\"\n\nlog 'system packages'\nif command -v apt-get >/dev/null; then\n # Transient mirror errors and dpkg lock contention made apt exit 100 (2 of ~10 golden builds):\n # retry downloads and wait for the lock instead of failing the build.\n printf 'Acquire::Retries \"5\";\\nDPkg::Lock::Timeout \"180\";\\n' | sudo tee /etc/apt/apt.conf.d/80-orca-retries >/dev/null\n # Sandbox egress is TLS-only: plain-http apt mirrors time out at runtime (W2, wake on SSH).\n sudo sed -i 's#http://#https://#g' /etc/apt/sources.list.d/*.sources /etc/apt/sources.list 2>/dev/null || true\n sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq\n # Developer basics agents expect (Conductor parity): rg, tmux, vim, git-lfs alongside the build tools.\n sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \\\n ca-certificates curl git git-lfs jq ripgrep tmux vim-nox less openssh-client openssh-server build-essential python3 \\\n unzip zip util-linux procps >/dev/null\n git lfs install --system --skip-repo >/dev/null\n # sshd is started on demand by the wake-on-SSH bridge (key-only, loopback); never at boot.\n sudo systemctl disable ssh.service ssh.socket >/dev/null 2>&1 || true\n # A locked password field (\"!\") makes sshd refuse even key logins; '*' = no password, not locked.\n sudo usermod -p '*' \"$(id -un)\"\nelif command -v dnf >/dev/null; then\n sudo dnf install -y -q ca-certificates curl-minimal git git-lfs jq tmux vim-enhanced gcc gcc-c++ make python3 \\\n unzip util-linux procps-ng >/dev/null\nelse\n log 'no supported package manager'; exit 1\nfi\n\n# Managed images have no /vercel/sandbox; the project lives at /vercel/sandbox/project on every image.\nsudo install -d -o \"$(id -u)\" -g \"$(id -g)\" /vercel/sandbox\n\nfetch_verified() { # url sha256 dest\n curl -fsSL --retry 3 -o \"$3\" \"$1\"\n echo \"$2 $3\" | sha256sum -c --quiet -\n}\n\nlog 'orcad bundle'\nbundle=\"$(mktemp -d)/orcad.tgz\"\nfetch_verified \"$ORCAD_BUNDLE_URL\" \"$ORCAD_BUNDLE_SHA256\" \"$bundle\"\ntar -xzf \"$bundle\" -C \"$ORCA_HOME/runtime\"\nrm -rf \"$(dirname \"$bundle\")\"\n# Release tarballs (orca-server-linux-*) may nest the server; normalize to runtime/orcad + runtime/runtimes.\nif [ ! -s \"$ORCA_HOME/runtime/orcad/orcad-server.js\" ]; then\n server=\"$(find \"$ORCA_HOME/runtime\" -name orcad-server.js -type f | head -n 1)\"\n [ -n \"$server\" ] || { log 'bundle has no orcad-server.js'; exit 1; }\n root=\"$(dirname \"$(dirname \"$server\")\")\"\n ln -sfn \"$(dirname \"$server\")\" \"$ORCA_HOME/runtime/orcad\"\n [ -d \"$ORCA_HOME/runtime/runtimes\" ] || ln -sfn \"$root/runtimes\" \"$ORCA_HOME/runtime/runtimes\"\nfi\ntest -s \"$ORCA_HOME/runtime/orcad/orcad-server.js\"\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node | head -n 1)\"\ntest -x \"$node\"\n\nif [ -n \"${GH_VERSION:-}\" ]; then\n log \"gh $GH_VERSION\"\n fetch_verified \"https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_amd64.tar.gz\" \\\n \"$GH_SHA256\" /tmp/gh.tgz\n tar -xzf /tmp/gh.tgz -C /tmp\n mkdir -p \"$ORCA_HOME/tools/bin\"\n install -m 0755 \"/tmp/gh_${GH_VERSION}_linux_amd64/bin/gh\" \"$ORCA_HOME/tools/bin/gh\"\n rm -rf /tmp/gh.tgz \"/tmp/gh_${GH_VERSION}_linux_amd64\"\nfi\n\nif [ -n \"${CHROME_VERSION:-}\" ] && command -v apt-get >/dev/null; then\n log \"google-chrome $CHROME_VERSION (headless browser for agents and Orca browser automation)\"\n deb=\"$(mktemp -d)/chrome.deb\"\n fetch_verified \"https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}_amd64.deb\" \\\n \"$CHROME_SHA256\" \"$deb\"\n sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \"$deb\" >/dev/null\n rm -rf \"$(dirname \"$deb\")\"\n # The .deb adds Google's apt source; the image stays pinned, so drop it.\n sudo rm -f /etc/apt/sources.list.d/google-chrome.list /etc/cron.daily/google-chrome\n google-chrome --headless=new --no-sandbox --disable-gpu --dump-dom 'data:text/html,

ok

' 2>/dev/null | grep -q '

ok

'\nfi\n\nlog 'agent CLIs'\n# The image's system npm installs them; agents later run on whatever node orcad puts on PATH.\n# shellcheck disable=SC2086 # word splitting is the package list\nnpm install --global --no-fund --no-audit --prefix \"$ORCA_HOME/tools\" $AGENT_NPM_PACKAGES\n# opencode-ai installs every linux-x64 build variant (~205 MB each) and hardlinks the one its\n# postinstall picked into bin/opencode.exe, a standalone binary: drop the variants it did not pick.\nopencode_pkg=\"$ORCA_HOME/tools/lib/node_modules/opencode-ai\"\nif [ -x \"$opencode_pkg/bin/opencode.exe\" ]; then\n picked=\"$(stat -c %i \"$opencode_pkg/bin/opencode.exe\")\"\n for variant in \"$opencode_pkg\"/node_modules/opencode-*; do\n [ -d \"$variant\" ] || continue\n [ \"$(stat -c %i \"$variant/bin/opencode\" 2>/dev/null)\" = \"$picked\" ] || rm -rf \"$variant\"\n done\n \"$opencode_pkg/bin/opencode.exe\" --version >/dev/null\nfi\n\n# Copilot CLI prefers GH_TOKEN/GITHUB_TOKEN over the user's own /login. In Orca Cloud those hold the\n# workspace's GitHub App placeholder, which Copilot can't use (\"Resource not accessible by\n# integration\"), so a signed-in user could never get past it. The wrapper drops them for Copilot\n# only (COPILOT_GITHUB_TOKEN, a /login and BYOK still work), unless BYOK is set up, which needs no\n# GitHub sign-in and keeps them so `gh` in Copilot's shell still works.\ncopilot_loader=\"$ORCA_HOME/tools/lib/node_modules/@github/copilot/npm-loader.js\"\nif [ -f \"$copilot_loader\" ]; then\n rm -f \"$ORCA_HOME/tools/bin/copilot\"\n cat >\"$ORCA_HOME/tools/bin/copilot\" </dev/null; fi\ndone\n\nif [ -n \"${CURSOR_AGENT_VERSION:-}\" ]; then\n # Cursor's agent CLI is a pinned tarball (its own node + bundle), not an npm package. The image\n # copy lives under /opt/orca (root-owned) and its release channel is set to \"static\" at boot\n # (orca-sandbox-env), so it never replaces itself mid-session.\n log \"cursor-agent $CURSOR_AGENT_VERSION\"\n tgz=\"$(mktemp -d)/cursor-agent.tgz\"\n fetch_verified \"https://downloads.cursor.com/lab/${CURSOR_AGENT_VERSION}/linux/x64/agent-cli-package.tar.gz\" \\\n \"$CURSOR_AGENT_SHA256\" \"$tgz\"\n rm -rf \"$ORCA_HOME/tools/cursor-agent\"\n mkdir -p \"$ORCA_HOME/tools/cursor-agent\" \"$ORCA_HOME/tools/bin\"\n tar --strip-components=1 -xzf \"$tgz\" -C \"$ORCA_HOME/tools/cursor-agent\"\n rm -rf \"$(dirname \"$tgz\")\"\n ln -sfn ../cursor-agent/cursor-agent \"$ORCA_HOME/tools/bin/cursor-agent\"\n \"$ORCA_HOME/tools/bin/cursor-agent\" --version >/dev/null\nfi\n\nlog 'openssh server (binaries only)'\n# For `orca ssh`: an unprivileged sshd on loopback (see vm/orca-sshd). Only the server binaries,\n# kept out of /usr: the packages would pull in systemd units, SELinux policy (~38 MB on AL2023) and\n# system host keys a user-run sshd never uses. Its libraries are already in the base image, except\n# tcp-wrappers on Ubuntu.\nmkdir -p \"$ORCA_HOME/openssh\"\npkgs=\"$(mktemp -d)\"\nif command -v dnf >/dev/null; then\n sudo dnf install -y -q --setopt=install_weak_deps=False cpio >/dev/null\n sudo dnf install -y -q --downloadonly --downloaddir=\"$pkgs\" openssh-server >/dev/null\n (cd \"$pkgs\" && rpm2cpio openssh-server-[0-9]*.rpm | cpio -idm --quiet)\n sudo dnf clean all -q >/dev/null 2>&1 || true\n libexec=\"$pkgs/usr/libexec/openssh\"\nelse\n sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends libwrap0 >/dev/null\n (cd \"$pkgs\" && apt-get download -qq openssh-server openssh-sftp-server >/dev/null)\n for deb in \"$pkgs\"/*.deb; do dpkg-deb -x \"$deb\" \"$pkgs\"; done\n libexec=\"$pkgs/usr/lib/openssh\"\nfi\ninstall -m 0755 \"$pkgs/usr/sbin/sshd\" \"$libexec/sshd-session\" \"$libexec/sftp-server\" \"$ORCA_HOME/openssh/\"\n# OpenSSH 10 splits authentication into its own binary.\nif [ -x \"$libexec/sshd-auth\" ]; then install -m 0755 \"$libexec/sshd-auth\" \"$ORCA_HOME/openssh/\"; fi\nsudo rm -rf \"$pkgs\"\n\"$ORCA_HOME/openssh/sshd\" -V 2>&1 | head -n 1 >&2 || true\n# sshd refuses a locked account ('!') even for key logins; '*' unlocks it with no usable password.\nsudo usermod -p '*' \"$(id -un)\"\n\ninstall_desktop() {\n # Remote desktop (~80 MB): Xvnc + openbox + xterm from the distro, noVNC + websockify pinned\n # tarballs. Only orca-desktop-start runs it, on demand; nothing starts at boot. Every step says\n # `|| return 1`: errexit does not apply inside an `if` condition.\n command -v apt-get >/dev/null || { log 'remote desktop: apt only'; return 1; }\n sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \\\n tigervnc-standalone-server tigervnc-tools openbox xterm x11-xserver-utils >/dev/null || return 1\n local tmp dir=\"$ORCA_HOME/desktop\"\n tmp=\"$(mktemp -d)\" || return 1\n fetch_verified \"https://github.com/novnc/noVNC/archive/refs/tags/v${NOVNC_VERSION}.tar.gz\" \\\n \"$NOVNC_SHA256\" \"$tmp/novnc.tgz\" || return 1\n fetch_verified \"https://github.com/novnc/websockify/archive/refs/tags/v${WEBSOCKIFY_VERSION}.tar.gz\" \\\n \"$WEBSOCKIFY_SHA256\" \"$tmp/websockify.tgz\" || return 1\n rm -rf \"$dir\" && mkdir -p \"$dir/novnc\" \"$dir/websockify\" || return 1\n tar -xzf \"$tmp/novnc.tgz\" -C \"$dir/novnc\" --strip-components 1 || return 1\n tar -xzf \"$tmp/websockify.tgz\" -C \"$dir/websockify\" --strip-components 1 || return 1\n # Orca tab fit: title \"Remote desktop\" (Xvnc's -desktop name), no noVNC favicon, control bar on\n # the right (away from Orca's sidebar edge); a stale per-boot password ends the session with one\n # plain message instead of a reconnect loop. \"Unencrypted\" is noVNC's view of plain ws:// to\n # localhost; the hop to the sandbox rides the end-to-end encrypted runtime connection.\n sed -i -e 's/document.title = e.detail.name + \" - \" + PAGE_TITLE;/document.title = e.detail.name;/' \\\n -e \"s/WebUtil.readSetting('controlbar_pos') === 'right'/WebUtil.readSetting('controlbar_pos', 'right') === 'right'/\" \\\n -e 's/securityFailed(e) {/securityFailed(e) { UI.inhibitReconnect = true; UI.showStatus(\"This remote desktop session ended. Reopen it from the workspace menu.\", \"error\"); return;/' \\\n -e 's/_(\"Connected (unencrypted) to \")/_(\"Connected to \")/' \\\n \"$dir/novnc/app/ui.js\" || return 1\n sed -i '/rel=\"icon\"/d; /rel=\"apple-touch-icon\"/d' \"$dir/novnc/vnc.html\" || return 1\n rm -rf \"$tmp\"\n PYTHONPATH=\"$dir/websockify\" python3 -P -c 'import websockify' >/dev/null || return 1\n command -v Xvnc >/dev/null && test -s \"$dir/novnc/vnc.html\"\n}\ndesktop=false\nif [ -n \"${NOVNC_VERSION:-}\" ]; then\n log 'remote desktop'\n # Optional: a failure leaves an image without the desktop, never a failed build.\n if install_desktop; then\n desktop=true\n else\n log 'remote desktop install failed; continuing without it'\n rm -rf \"$ORCA_HOME/desktop\"\n fi\nfi\n\nlog 'Orca entry points'\nfor name in orca-sandbox-start orca-sandbox-drain orca-sandbox-env orca-session-env.sh orca-env orca-egress-probe orca-image-wipe orca-agent-signout orca-share-proxy orca-sshd orca-ssh-keys orca-desktop-start orca-desktop-proxy orca-desktop-page.js orca-sandbox-grant orca-sandbox-revoke orca-sandbox-members orca-sandbox-read-snapshot orca-read-snapshot.cjs orca-git-as orca-git-identity orcad-boot git; do\n var=\"ORCA_VM_SCRIPT_${name//[-.]/_}\"\n printf '%s' \"${!var}\" | base64 -d >\"$ORCA_HOME/bin/$name\"\n chmod 0755 \"$ORCA_HOME/bin/$name\"\ndone\n# Which entry points this image carries (VM_SCRIPTS_STAMP): a Worker with older scripts never\n# reinstalls over them. Absent (old callers) reads as 0, so any Worker may still update the image.\nrm -f \"$ORCA_HOME/bin/.scripts-stamp\" \"$ORCA_HOME\"/bin/.entry-points-*\nif [[ \"${ORCA_VM_SCRIPTS_STAMP:-}\" =~ ^[0-9]+$ ]]; then\n printf '%s\\n' \"$ORCA_VM_SCRIPTS_STAMP\" >\"$ORCA_HOME/bin/.scripts-stamp\"\nfi\n# The orca CLI (agents drive worktrees, terminals and the browser through it) talks to this VM's orcad.\ncli=\"$(find \"$ORCA_HOME/runtime\" -name orca-cli.cjs -type f | head -n 1)\"\nif [ -n \"$cli\" ]; then\n printf '#!/usr/bin/env bash\\nexport ORCA_USER_DATA_PATH=\"${ORCA_USER_DATA_PATH:-$HOME/.orcad-data}\"\\nexec \"%s\" \"%s\" \"$@\"\\n' \"$node\" \"$cli\" >\"$ORCA_HOME/bin/orca\"\n chmod 0755 \"$ORCA_HOME/bin/orca\"\nelse\n log 'bundle has no orca CLI (orca-cli.cjs): agents will not have `orca` on PATH'\nfi\n# The image's node/npm/npx live only on the build command's PATH; append them so agents (codex's\n# `env node` shebang) and stdio MCP servers launched with npx resolve them in Orca's login shells.\nsystem_node=\"$(command -v npx || command -v node || true)\"\nif [ -z \"$system_node\" ]; then\n echo 'no node/npx on the build PATH' >&2\n exit 1\nfi\nprintf 'export PATH=%s/tools/bin:%s/bin:$PATH:%s\\n' \"$ORCA_HOME\" \"$ORCA_HOME\" \"$(dirname \"$system_node\")\" |\n sudo tee /etc/profile.d/orca.sh >/dev/null\n# Every new shell (login or not: /etc/bashrc sources profile.d) re-reads the session env file.\nsudo install -m 0644 \"$ORCA_HOME/bin/orca-session-env.sh\" /etc/profile.d/zz-orca-session-env.sh\nif [ -f /etc/bash.bashrc ]; then\n # Ubuntu's bash.bashrc does not source profile.d for non-login interactive shells (AL2023's bashrc does).\n grep -q zz-orca-session-env /etc/bash.bashrc ||\n echo '[ -r /etc/profile.d/zz-orca-session-env.sh ] && . /etc/profile.d/zz-orca-session-env.sh' | sudo tee -a /etc/bash.bashrc >/dev/null\nfi\nif [ -f /etc/bash.bashrc ]; then\n # Ubuntu: zsh reads /etc/zsh/zshenv for every shell, so new zsh shells match bash (`orca-env` covers\n # shells that were already open).\n sudo install -d -m 0755 /etc/zsh\n sudo touch /etc/zsh/zshenv\n grep -q zz-orca-session-env /etc/zsh/zshenv ||\n echo '[ -r /etc/profile.d/zz-orca-session-env.sh ] && . /etc/profile.d/zz-orca-session-env.sh' | sudo tee -a /etc/zsh/zshenv >/dev/null\nfi\nsudo chown -R root:root \"$ORCA_HOME\"\n\nlog 'smoke: orcad reaches ready on loopback (and warms the V8 compile cache: ~1.5 s off every boot)'\nsudo install -d -o \"$(id -u)\" -g \"$(id -g)\" \"$ORCA_HOME/compile-cache\"\n# The runtime is root-owned, so its startup-qualified marker lives here. The smoke run leaves one,\n# so every boot from the image skips orcad's disposable startup preflight (~0.6 s of wake).\nsudo install -d -o \"$(id -u)\" -g \"$(id -g)\" \"$ORCA_HOME/startup-qualification\"\nsmoke_data=\"$(mktemp -d)\"\n# orcad-boot also writes orcad's full code cache as the smoke run exits (~0.3 s more off every boot).\nNODE_COMPILE_CACHE=\"$ORCA_HOME/compile-cache\" ORCA_ORCAD_STARTUP_QUALIFICATION_DIR=\"$ORCA_HOME/startup-qualification\" \\\n ORCA_USER_DATA=\"$smoke_data\" timeout 60 \"$node\" \"$ORCA_HOME/bin/orcad-boot\" \\\n --code-cache \"$ORCA_HOME/compile-cache/orcad-server.v8\" --write-code-cache \"$ORCA_HOME/runtime/orcad/orcad-server.js\" \\\n --port 6799 --json >/tmp/orcad-smoke.out 2>/tmp/orcad-smoke.err &\nsmoke_pid=$!\nready=0\nfor _ in $(seq 1 600); do\n if grep -q '\"orca_server_ready\"' /tmp/orcad-smoke.out 2>/dev/null; then ready=1; break; fi\n sleep 0.1\ndone\n# orcad writes the marker just after readiness; best effort, a boot without one only re-qualifies.\nfor _ in $(seq 1 30); do\n [ \"$ready\" = 1 ] && [ ! -s \"$ORCA_HOME/startup-qualification/.orcad-startup-qualified\" ] || break\n sleep 0.1\ndone\nkill \"$smoke_pid\" 2>/dev/null || true\nwait \"$smoke_pid\" 2>/dev/null || true\nrm -rf \"$smoke_data\" /tmp/orcad-smoke.out\nif [ \"$ready\" != 1 ]; then\n tail -n 40 /tmp/orcad-smoke.err >&2; exit 1\nfi\n# Best effort: without it every boot loads orcad as before.\nif [ -s \"$ORCA_HOME/compile-cache/orcad-server.v8\" ]; then\n log \"orcad code cache: $(stat -c %s \"$ORCA_HOME/compile-cache/orcad-server.v8\") bytes\"\nelse\n log 'orcad code cache was not written; boots compile orcad without it'\n grep 'orcad-boot' /tmp/orcad-smoke.err >&2 || true\nfi\n\nlog 'versions'\nversions=\"{}\"\nfor bin in claude codex opencode gemini cursor-agent copilot gh; do\n if [ -x \"$ORCA_HOME/tools/bin/$bin\" ]; then\n v=\"$(\"$ORCA_HOME/tools/bin/$bin\" --version 2>/dev/null | head -n 1 || echo unknown)\"\n versions=\"$(jq -c --arg k \"$bin\" --arg v \"$v\" '. + {($k): $v}' <<<\"$versions\")\"\n fi\ndone\nfor sys in rg vim git-lfs google-chrome; do\n if command -v \"$sys\" >/dev/null; then\n v=\"$(\"$sys\" --version 2>/dev/null | head -n 1 || echo unknown)\"\n versions=\"$(jq -c --arg k \"$sys\" --arg v \"$v\" '. + {($k): $v}' <<<\"$versions\")\"\n fi\ndone\ncommand -v tmux >/dev/null && versions=\"$(jq -c --arg v \"$(tmux -V)\" '. + {tmux: $v}' <<<\"$versions\")\"\n[ -x \"$ORCA_HOME/bin/orca\" ] && versions=\"$(jq -c '. + {orca: \"bundled\"}' <<<\"$versions\")\"\njq -c --arg node \"$(\"$node\" --version)\" --arg disk \"$(df -h / | awk 'NR==2{print $2}')\" \\\n --argjson desktop \"$desktop\" '{node: $node, disk: $disk, tools: ., desktop: $desktop}' <<<\"$versions\"\n" -export const VM_SCRIPT_SOURCES: BuildInputs['vmScripts'] = {"orca-sandbox-start":"#!/usr/bin/env bash\n# Starts orcad for this boot (idempotent) and prints its recipe line:\n# {\"schemaVersion\":1,\"pairingCode\":\"orca://pair?...\",\"projectRoot\":\"/vercel/sandbox/project\"}\n# Env from the control plane: ORCA_SANDBOX_ID, ORCA_PUBLIC_WSS, optional ORCA_REPO_URL/ORCA_REPO_REF,\n# ORCA_GITHUB_PLACEHOLDER, ORCA_SECRET_ENV (`NAME=` lines: org/personal/repo secrets for\n# orcad and its agents), ORCA_SHARE_PROXY_KEY (this session's share-link proxy key, port 6769).\n# Never prints anything else on stdout's last line.\n# With ORCA_READY_VIA=file it prints {\"schemaVersion\":1,\"readyFile\":\"\"} instead and the control\n# plane reads the offer with the files API: the provider keeps command stdout as logs, not files.\n# With ORCA_READY_KEY too (64 hex chars, this call's key; env is not kept) the pointer also carries\n# \"sealedOffer\": \".\", which saves that round trip.\n# Managed env for every process orcad starts: ORCA_IS_CLOUD=1, ORCA_SANDBOX_ID, ORCA_ROOT_PATH,\n# ORCA_WORKTREE_PATH, ORCA_WORKSPACE_PATH, ORCA_WORKSPACE_NAME, ORCA_DEFAULT_BRANCH, ORCA_PORT and the\n# CONDUCTOR_* names imported Conductor scripts read.\nset -euo pipefail\numask 077\nORCA_HOME=/opt/orca\nSTATE=\"$HOME/.orca-sandbox\"\nPROJECT=/vercel/sandbox/project\nmkdir -p \"$STATE\"\n# Only print_ready uses the key; nothing this script starts inherits it.\nready_key=\"${ORCA_READY_KEY:-}\"\nunset ORCA_READY_KEY\n[[ \"$ready_key\" =~ ^[0-9a-f]{64}$ ]] || ready_key=''\nexec 9>\"$STATE/start.lock\"\nflock -w 60 9\n# Background children close fd 9 (`9>&-`): an orcad that inherited it would hold the lock for its whole\n# life and every later start (resume of a running sandbox, a retried create) would wait out the 60 s.\n\n# orcad only lists a project once a client registers it (the desktop does on connect), so API, MCP,\n# routine and phone sessions saw no repo. Register the workspace project at every start, in the\n# background (ready is not delayed); `repo add` of an already-registered path is a no-op error.\nregister_project() {\n [ -x \"$ORCA_HOME/bin/orca\" ] && [ -d \"$PROJECT\" ] || return 0\n local kind=() name=()\n [ -e \"$PROJECT/.git\" ] || kind=(--kind folder)\n # The checkout lives at .../project; name it after the repository (owner/name.git -> name).\n if [ -n \"${ORCA_REPO_URL:-}\" ]; then\n local base=\"${ORCA_REPO_URL%/}\"\n base=\"${base##*/}\"\n base=\"${base%.git}\"\n [ -n \"$base\" ] && name=(--display-name \"$base\")\n fi\n setsid \"$ORCA_HOME/bin/orca\" repo add --path \"$PROJECT\" \"${kind[@]}\" \"${name[@]}\" --json \\\n >\"$STATE/register-project.log\" 2>&1 &- &\n}\n\nprint_ready() {\n if [ \"${ORCA_READY_VIA:-}\" = file ]; then\n local sealed='' iv\n if [ -n \"$ready_key\" ] && command -v openssl >/dev/null; then\n iv=\"$(od -An -N16 -tx1 /dev/urandom | tr -d ' \\n')\"\n # Best effort: without it the control plane reads the file, as before.\n sealed=\"$(tail -n 1 \"$STATE/ready.json\" | openssl enc -aes-256-ctr -K \"$ready_key\" -iv \"$iv\" -nosalt | base64 -w0)\" &&\n sealed=\"$iv.$sealed\" || sealed=''\n fi\n printf '{\"schemaVersion\":1,\"readyFile\":\"%s\"%s}\\n' \"$STATE/ready.json\" \"${sealed:+,\\\"sealedOffer\\\":\\\"$sealed\\\"}\"\n else\n tail -n 1 \"$STATE/ready.json\"\n fi\n}\n# Command stderr is kept by the provider: strip injected values and credential shapes from log tails.\nredact_log() {\n local text kv value\n text=\"$(cat)\"\n for kv in ${agent_env[@]+\"${agent_env[@]}\"}; do\n value=\"${kv#*=}\"\n if [ \"${#value}\" -ge 8 ]; then\n text=\"${text//\"$value\"/[REDACTED]}\"\n fi\n done\n printf '%s\\n' \"$text\" | sed -E \\\n -e 's#orca://[^[:space:]]+#[REDACTED_PAIRING]#g' \\\n -e 's#(gh[pousr]_|github_pat_|sk-|vc[kpiar]_)[A-Za-z0-9._-]{16,}#[REDACTED_TOKEN]#g' \\\n -e 's#eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}#[REDACTED_JWT]#g' \\\n -e 's#(Authorization|authorization):[[:space:]]*[A-Za-z]+[[:space:]]+[^[:space:]]+#\\1: [REDACTED]#g'\n}\n\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node | head -n 1)\"\n\n# Share-link proxy (port 6769): answers only requests the control plane signed with this session's\n# key, so its public route is useless on its own. The key goes in on stdin (printf is a builtin), so\n# it never appears in any argv or in the proxy's /proc//environ.\nstart_share_proxy() {\n [ -n \"${ORCA_SHARE_PROXY_KEY:-}\" ] && [ -x \"$ORCA_HOME/bin/orca-share-proxy\" ] || return 0\n if [ \"$(cat \"$STATE/share-proxy.boot\" 2>/dev/null || true)\" = \"$boot_id\" ] \\\n && kill -0 \"$(cat \"$STATE/share-proxy.pid\" 2>/dev/null || echo 0)\" 2>/dev/null; then\n return 0\n fi\n printf '%s' \"$ORCA_SHARE_PROXY_KEY\" | env -i HOME=\"$HOME\" LANG=C.UTF-8 PATH=/usr/bin:/bin \\\n setsid \"$node\" \"$ORCA_HOME/bin/orca-share-proxy\" >\"$STATE/share-proxy.log\" 2>&1 9>&- &\n echo $! >\"$STATE/share-proxy.pid\"\n printf '%s' \"$boot_id\" >\"$STATE/share-proxy.boot\"\n}\n\nboot_id=\"$(cat /proc/sys/kernel/random/boot_id)\"\n# CREATE-GIT-ASYNC: a create answers before member identities are installed; `git` holds commits and\n# pushes until orca-sandbox-members (or the control plane) marks this setup ready or failed. Marked\n# before the already-started exit below: a pooled VM's orcad may be up before its create claims it.\nif [ \"${ORCA_GIT_SETUP:-}\" = pending ]; then\n rm -f \"$STATE/git-setup.ready\" \"$STATE/git-setup.failed\"\n date +%s >\"$STATE/git-setup.pending\"\nfi\nstart_share_proxy\nunset ORCA_SHARE_PROXY_KEY\nif [ \"$(cat \"$STATE/boot-id\" 2>/dev/null || true)\" = \"$boot_id\" ] && [ -s \"$STATE/ready.json\" ] \\\n && kill -0 \"$(cat \"$STATE/orcad.pid\" 2>/dev/null || echo 0)\" 2>/dev/null; then\n print_ready\n exit 0\nfi\n# Resume = reboot from disk: locks and pids from the previous session name dead processes.\nrm -f \"$STATE/ready.json\" \"$STATE/orcad.pid\"\nprintf '%s' \"$boot_id\" >\"$STATE/boot-id\"\n# Any other boot (wake, restart) installs identities before it answers: a resumed disk's marks are stale.\n[ \"${ORCA_GIT_SETUP:-}\" = pending ] || rm -f \"$STATE/git-setup.pending\" \"$STATE/git-setup.ready\" \"$STATE/git-setup.failed\"\n\nif [ -n \"${ORCA_GIT_EXTRAHEADER:-}\" ]; then\n # Every github.com request carries the placeholder; the egress policy swaps in a 1h repo token.\n git config --global http.https://github.com/.extraheader \"$ORCA_GIT_EXTRAHEADER\"\nfi\n# Commits outside a teammate's turn (or before any member connected GitHub) need an author too.\n# The fallback moved to orca.dev with the domain; a workspace still on the old default follows it.\n[ \"$(git config --global user.email 2>/dev/null || true)\" = 'cloud@users.noreply.onorca.dev' ] \\\n && git config --global user.email 'cloud@users.noreply.orca.dev'\ngit config --global user.email >/dev/null 2>&1 || git config --global user.email 'cloud@users.noreply.orca.dev'\ngit config --global user.name >/dev/null 2>&1 || git config --global user.name 'Orca Cloud'\n# First boot clones while orcad starts (orcad only needs the directory to exist); ready waits for both.\nclone_pid=''\nif [ ! -d \"$PROJECT\" ]; then\n mkdir -p \"$PROJECT\"\n if [ -n \"${ORCA_REPO_URL:-}\" ]; then\n git clone --quiet ${ORCA_REPO_REF:+--branch \"$ORCA_REPO_REF\"} -- \"$ORCA_REPO_URL\" \"$PROJECT\" 2>\"$STATE/clone.log\" 9>&- &\n clone_pid=$!\n fi\nfi\n\n# The clone may still be running here, so the name comes from the URL; the default branch is\n# filled in lazily by the session-env shell hook once the clone exists.\nif [ -n \"${ORCA_REPO_URL:-}\" ]; then\n workspace_name=\"$(basename \"${ORCA_REPO_URL%/}\" .git)\"\nelse\n workspace_name=\"$(basename \"$(git -C \"$PROJECT\" remote get-url origin 2>/dev/null || echo \"$PROJECT\")\" .git)\"\nfi\n# One workspace per VM: its preview ports start at 3000 (Conductor allots CONDUCTOR_PORT..+9).\nmanaged_env=(\n ORCA_IS_CLOUD=1\n ORCA_ROOT_PATH=\"$PROJECT\" ORCA_WORKTREE_PATH=\"$PROJECT\" ORCA_WORKSPACE_PATH=\"$PROJECT\"\n ORCA_WORKSPACE_NAME=\"$workspace_name\" ORCA_PORT=3000\n CONDUCTOR_IS_CLOUD=1\n # Agent CLIs are pinned by the image (smoke-tested); a self-update mid-session can break a running agent.\n DISABLE_AUTOUPDATER=1 OPENCODE_DISABLE_AUTOUPDATE=1 COPILOT_AUTO_UPDATE=false\n CONDUCTOR_ROOT_PATH=\"$PROJECT\" CONDUCTOR_WORKSPACE_PATH=\"$PROJECT\"\n CONDUCTOR_WORKSPACE_NAME=\"$workspace_name\" CONDUCTOR_PORT=3000\n)\n\nagent_env=()\nif command -v google-chrome >/dev/null; then\n # Orca's browser automation and agent browser tools use the image's headless Chrome.\n agent_env+=(\"ORCA_BROWSER_EXECUTABLE=$(command -v google-chrome)\")\nfi\nif [ -n \"${ORCA_GITHUB_PLACEHOLDER:-}\" ]; then\n # The egress layer swaps the placeholder for a short-lived GitHub token; none lives in the VM.\n agent_env+=(\"GH_TOKEN=$ORCA_GITHUB_PLACEHOLDER\" \"GITHUB_TOKEN=$ORCA_GITHUB_PLACEHOLDER\")\nfi\n\nsecret_names=()\nif [ -n \"${ORCA_SECRET_ENV:-}\" ]; then\n # Secrets live only in memory: orcad's process env and a tmpfs file new shells re-read\n # (orca-sandbox-env), never under $HOME or the project, so the stop snapshot never carries them.\n # Resolved per start (and per edit) by the control plane: no image rebuild, no restart.\n while IFS= read -r line; do\n name=\"${line%%=*}\" b64=\"${line#*=}\" # not IFS='=': read would strip base64 padding\n case \"$name\" in '' | *[!A-Z0-9_]* | [0-9]* | ORCA_* | PATH | HOME | LD_* | DYLD_*) continue ;; esac\n agent_env+=(\"$name=$(printf '%s' \"$b64\" | base64 -d)\")\n secret_names+=(\"$name\")\n done < <(printf '%s\\n' \"$ORCA_SECRET_ENV\") # a pipe, not a here-string temp file\nfi\n# Always rewrite the file, so a resume with fewer secrets drops the old names too.\n\"$ORCA_HOME/bin/orca-sandbox-env\" >/dev/null || echo 'session env file unavailable; secrets apply at boot only' >&2\nunset ORCA_SECRET_ENV\n\n# Agent CLIs installed with the image's npm (codex, gemini, ...) are `#!/usr/bin/env node` scripts:\n# terminals need that node on PATH, not only orcad's bundled runtime.\nsystem_node=\"$(command -v node 2>/dev/null || echo \"$node\")\"\n# The egress proxy answers for hosts with credential rules (model APIs, api.github.com) with a\n# certificate from Vercel's CA, which this command's env trusts (Vercel sets these). Pure-Node clients\n# use Node's bundled roots otherwise: Gemini CLI retried forever (\"taking a bit longer\") in every\n# terminal while the same call worked here. Pass the provider's CA settings on to orcad and its shells.\nca_env=(NODE_USE_SYSTEM_CA=\"${NODE_USE_SYSTEM_CA:-1}\")\nfor name in NODE_EXTRA_CA_CERTS SSL_CERT_FILE; do\n if [ -n \"${!name:-}\" ]; then ca_env+=(\"$name=${!name}\"); fi\ndone\n# N-02: the host is a cloud terminal's source of truth across app reopens, so orcad and its\n# daemon keep a local terminal's depth (5000 rows) instead of the desktop daemon's 1000-row live\n# window. Scoped to the VM: local desktop daemons keep their default.\n# Ports panel: programs under $ORCA_HOME are the image's own services, and 23456 is Vercel's\n# sandbox-init (root, pid invisible to this user); neither is listed as the user's port.\nenv -i HOME=\"$HOME\" USER=\"$(id -un)\" LANG=C.UTF-8 TERM=xterm-256color \"${ca_env[@]}\" \\\n PATH=\"$ORCA_HOME/tools/bin:$ORCA_HOME/bin:$(dirname \"$system_node\"):/usr/local/bin:/usr/bin:/bin\" \\\n ORCA_USER_DATA=\"$HOME/.orcad-data\" ORCA_SANDBOX_ID=\"$ORCA_SANDBOX_ID\" \"${managed_env[@]}\" \\\n ORCA_HOST_MANAGED_LISTENER_PATHS=\"$ORCA_HOME\" ORCA_HOST_MANAGED_UNOWNED_PORTS=23456 \\\n NODE_COMPILE_CACHE=\"$ORCA_HOME/compile-cache\" ORCA_ORCAD_STARTUP_QUALIFICATION_DIR=\"$ORCA_HOME/startup-qualification\" \\\n ORCA_DAEMON_SESSION_SCROLLBACK_ROWS=5000 \\\n ORCA_SESSION_ENV_NAMES=\"${secret_names[*]}\" \"${agent_env[@]}\" \\\n setsid nohup \"$node\" \"$ORCA_HOME/bin/orcad-boot\" --code-cache \"$ORCA_HOME/compile-cache/orcad-server.v8\" \\\n \"$ORCA_HOME/runtime/orcad/orcad-server.js\" --port 6768 --bind 0.0.0.0 --pairing-address \"$ORCA_PUBLIC_WSS\" \\\n --recipe-json --project-root \"$PROJECT\" \\\n >\"$STATE/ready.json\" 2>\"$STATE/orcad.log\" &- &\necho $! >\"$STATE/orcad.pid\"\n\n# sshd for `orca ssh` (loopback only). Best effort: a workspace without it still serves orcad.\n\"$ORCA_HOME/bin/orca-sshd\" >/dev/null 2>\"$STATE/sshd-start.err\" || true\n\n# Polled every 10 ms (at least 60 s in all): ready is on the wake path, so detect it promptly.\nfor _ in $(seq 1 6000); do\n if grep -q '\"pairingCode\"' \"$STATE/ready.json\" 2>/dev/null; then\n if [ -n \"$clone_pid\" ] && ! wait \"$clone_pid\"; then\n echo 'repository clone failed' >&2\n tail -n 5 \"$STATE/clone.log\" | redact_log >&2\n exit 1\n fi\n register_project\n print_ready\n exit 0\n fi\n if ! kill -0 \"$(cat \"$STATE/orcad.pid\")\" 2>/dev/null; then\n echo 'orcad exited before ready' >&2\n tail -n 40 \"$STATE/orcad.log\" | redact_log >&2\n exit 1\n fi\n sleep 0.01\ndone\necho 'orcad start timed out' >&2\nexit 1\n","orca-sandbox-drain":"#!/usr/bin/env bash\n# Asks orcad to quit before the VM stops: its shutdown turns working agent chats into durable\n# restart offers and flushes terminal restore hints, which the desktop continues after resume.\nset -euo pipefail\nSTATE=\"$HOME/.orca-sandbox\"\nWAIT_SECONDS=\"${1:-280}\"\npid=\"$(cat \"$STATE/orcad.pid\" 2>/dev/null || true)\"\nif [ -z \"$pid\" ] || ! kill -0 \"$pid\" 2>/dev/null; then\n exit 0\nfi\nkill -TERM \"$pid\"\nfor _ in $(seq 1 $((WAIT_SECONDS * 10))); do\n if ! kill -0 \"$pid\" 2>/dev/null; then\n rm -f \"$STATE/orcad.pid\" \"$STATE/ready.json\"\n exit 0\n fi\n sleep 0.1\ndone\n# Leave it running: the provider stop ends it, and the drain already had its window.\necho 'orcad still draining at deadline' >&2\nexit 0\n","orca-sandbox-env":"#!/usr/bin/env bash\n# Writes this session's secret env where new shells and agent launches read it (orca-session-env.sh).\n# Input: ORCA_SECRET_ENV (`NAME=` lines) from the control plane, at boot (orca-sandbox-start)\n# and whenever a secret changes (POST /v1/sandboxes/:id/env/refresh). The file lives on tmpfs only:\n# never on the sandbox disk, so the stop snapshot never carries it. Prints {\"names\":N} on success.\n# Also keeps Codex pointed at a configured OpenAI-compatible provider (OPENAI_BASE_URL), since Codex\n# does not read that variable: a managed block in ~/.codex/config.toml naming only the env var.\nset -euo pipefail\numask 077\nHOME=\"${HOME:-$(getent passwd \"$(id -u)\" | cut -d: -f6)}\"\n# ORCA_SESSION_ENV_DIR: tests only (a scratch dir off tmpfs); the control plane never sets it.\ndir=\"${ORCA_SESSION_ENV_DIR:-/dev/shm/orca-env-$(id -u)}\"\nif [ -z \"${ORCA_SESSION_ENV_DIR:-}\" ] && [ \"$(stat -f -c %T /dev/shm 2>/dev/null || true)\" != tmpfs ]; then\n # No memory-only place to keep it: refuse rather than write secrets to disk.\n echo 'no tmpfs for the session env' >&2\n exit 3\nfi\nmkdir -p \"$dir\"\nchmod 700 \"$dir\"\n# Writes are ordered by the control plane's resolve time: an older refresh that lost a race is dropped.\ngeneration=\"${ORCA_ENV_GENERATION:-0}\"\ncase \"$generation\" in '' | *[!0-9]*) generation=0 ;; esac\ncurrent=\"$(cat \"$dir/generation\" 2>/dev/null || echo 0)\"\ncase \"$current\" in '' | *[!0-9]*) current=0 ;; esac\nif [ \"$generation\" -lt \"$current\" ]; then\n printf '{\"stale\":true}\\n'\n exit 0\nfi\ntmp=\"$(mktemp \"$dir/.session.env.XXXXXX\")\"\ncount=0\nopenai_base_url=''\nopenai_model=''\nanthropic_model=''\nanthropic_key_tail=''\ngateway_key=''\ngemini_key=''\ncodex_placeholder=''\nwhile IFS= read -r line; do\n name=\"${line%%=*}\" b64=\"${line#*=}\"\n # Not for shells: the Codex account placeholder becomes ~/.codex/auth.json below.\n if [ \"$name\" = ORCA_CODEX_AUTH_PLACEHOLDER ]; then\n codex_placeholder=\"$(printf '%s' \"$b64\" | base64 -d)\"\n case \"$codex_placeholder\" in *[!A-Za-z0-9._-]*) codex_placeholder='' ;; esac\n fi\n case \"$name\" in '' | *[!A-Z0-9_]* | [0-9]* | ORCA_* | PATH | HOME | LD_* | DYLD_*) continue ;; esac\n # Values stay base64 in the file; the shell hook decodes them.\n printf '%s=%s\\n' \"$name\" \"$b64\" >>\"$tmp\"\n count=$((count + 1))\n case \"$name\" in\n OPENAI_BASE_URL) openai_base_url=\"$(printf '%s' \"$b64\" | base64 -d)\" ;;\n OPENAI_MODEL) openai_model=\"$(printf '%s' \"$b64\" | base64 -d)\" ;;\n ANTHROPIC_MODEL) anthropic_model=\"$(printf '%s' \"$b64\" | base64 -d)\" ;;\n AI_GATEWAY_API_KEY) gateway_key=1 ;;\n GEMINI_API_KEY | GOOGLE_API_KEY) gemini_key=\"${gemini_key:-gemini-api-key}\" ;;\n GOOGLE_GENAI_USE_VERTEXAI) case \"$(printf '%s' \"$b64\" | base64 -d)\" in true | 1) gemini_key=vertex-ai ;; esac ;;\n # Claude Code asks once per key whether to use ANTHROPIC_API_KEY; it remembers the last 20 chars.\n ANTHROPIC_API_KEY) anthropic_key_tail=\"$(printf '%s' \"$b64\" | base64 -d | tail -c 20)\" ;;\n esac\ndone < <(printf '%s\\n' \"${ORCA_SECRET_ENV:-}\")\nmv -f \"$tmp\" \"$dir/session.env\"\nprintf '%s' \"$generation\" >\"$dir/generation\"\n\n# ---- Codex: trust the workspace and use the configured provider (no secret values) ----\nPROJECT=/vercel/sandbox/project\ncodex_config=\"$HOME/.codex/config.toml\"\ntoml_string() { # TOML basic string; the control plane only accepts https URLs and plain model ids\n local value=\"${1//\\\\/\\\\\\\\}\"\n printf '\"%s\"' \"${value//\\\"/\\\\\\\"}\"\n}\nstrip_managed() { # drop the managed blocks and the blank lines written before them (no growth per refresh)\n awk '/^# >>> orca-cloud managed/{skip=1; blank=\"\"} skip{if (/^# <<< orca-cloud managed/) skip=0; next}\n /^[[:space:]]*$/{blank=blank $0 \"\\n\"; next} {printf \"%s\", blank; blank=\"\"; print}' \"$1\"\n}\n# Always managed: an agent launched from the phone or the API has no one to answer Codex's\n# \"trust this folder?\" screen, so the workspace project is trusted up front.\nif true; then\n mkdir -p \"$HOME/.codex\"\n user_part=\"$(mktemp \"$HOME/.codex/.config.XXXXXX\")\"\n if [ -f \"$codex_config\" ]; then strip_managed \"$codex_config\" >\"$user_part\"; fi\n next=\"$(mktemp \"$HOME/.codex/.config.XXXXXX\")\"\n openai_host=\"${openai_base_url#https://}\"\n openai_host=\"${openai_host%%/*}\"\n custom_provider=''\n if [ -n \"$openai_base_url\" ] && [ \"$openai_host\" != api.openai.com ]; then custom_provider=1; fi\n # Top-level keys must precede every table; each is skipped when the user already set it.\n head_keys=\"$(awk '/^\\[/{exit} {print}' \"$user_part\")\"\n has_key() { grep -Eq \"^[[:space:]]*$1[[:space:]]*=\" <<<\"$head_keys\"; }\n {\n printf '# >>> orca-cloud managed (Orca Cloud; edit in Orca settings)\\n'\n # The sandbox is the isolation boundary: Codex's own bwrap sandbox can't start in it (\"Unexpected\n # capabilities\"), and a prompt delivered from the phone or the API has no one to approve commands.\n has_key sandbox_mode || printf 'sandbox_mode = \"danger-full-access\"\\n'\n has_key approval_policy || printf 'approval_policy = \"never\"\\n'\n if [ -n \"$custom_provider\" ]; then\n has_key model_provider || printf 'model_provider = \"orca_cloud\"\\n'\n if [ -n \"$openai_model\" ] && ! has_key model; then\n printf 'model = %s\\n' \"$(toml_string \"$openai_model\")\"\n fi\n elif [ -n \"$codex_placeholder\" ]; then\n has_key model_provider || printf 'model_provider = \"orca_chatgpt\"\\n'\n fi\n printf '# <<< orca-cloud managed\\n'\n cat \"$user_part\"\n if [ -n \"$custom_provider\" ]; then\n printf '\\n# >>> orca-cloud managed provider\\n[model_providers.orca_cloud]\\nname = \"Orca Cloud\"\\n'\n printf 'base_url = %s\\nenv_key = \"OPENAI_API_KEY\"\\nwire_api = \"responses\"\\n' \"$(toml_string \"$openai_base_url\")\"\n printf '# <<< orca-cloud managed\\n'\n elif [ -n \"$codex_placeholder\" ]; then\n # Codex via the Orca account: the ChatGPT backend over HTTPS. Its default Responses WebSocket can't\n # pass the egress proxy, where the placeholder becomes the real token (found live, Codex 0.166).\n printf '\\n# >>> orca-cloud managed provider\\n[model_providers.orca_chatgpt]\\nname = \"OpenAI\"\\n'\n printf 'base_url = \"https://chatgpt.com/backend-api/codex\"\\nwire_api = \"responses\"\\nrequires_openai_auth = true\\nsupports_websockets = false\\n'\n printf '# <<< orca-cloud managed\\n'\n fi\n } >\"$next\"\n if ! grep -qF \"[projects.\\\"$PROJECT\\\"]\" \"$user_part\"; then\n printf '\\n# >>> orca-cloud managed trust\\n[projects.\"%s\"]\\ntrust_level = \"trusted\"\\n# <<< orca-cloud managed\\n' \"$PROJECT\" >>\"$next\"\n fi\n chmod 600 \"$next\"\n mv -f \"$next\" \"$codex_config\"\n rm -f \"$user_part\"\nfi\n# ---- Codex via the person's Orca account (contract D11): a placeholder ChatGPT login, no refresh token.\n# The egress proxy swaps the real access token on chatgpt.com; the real tokens never reach this VM. The\n# marker remembers which placeholder Orca wrote (a clone gets its own), so a login of the person's own\n# (kill-switch fallback, or `codex login` run here) is set aside while the account applies and put back after.\ncodex_auth=\"$HOME/.codex/auth.json\"\ncodex_saved=\"$HOME/.codex/auth.json.orca-saved\"\ncodex_marker=\"$HOME/.codex/.orca-account-placeholder\"\ncodex_ours=''\n# Ours: the marker's placeholder, or any Orca placeholder (all end in `.orca-placeholder`; a real token never does).\nif [ -f \"$codex_auth\" ] && { { [ -f \"$codex_marker\" ] && grep -qF \"\\\"$(cat \"$codex_marker\")\\\"\" \"$codex_auth\"; } || grep -q '\\.orca-placeholder\"' \"$codex_auth\"; }; then\n codex_ours=1\nfi\nif [ -n \"$codex_placeholder\" ]; then\n mkdir -p \"$HOME/.codex\"\n if [ -f \"$codex_auth\" ] && [ -z \"$codex_ours\" ]; then\n # Never overwrite a login already set aside: a second one gets its own name.\n if [ -e \"$codex_saved\" ]; then mv -f \"$codex_auth\" \"$codex_saved.$(date -u +%Y%m%d%H%M%S)\"; else mv -f \"$codex_auth\" \"$codex_saved\"; fi\n fi\n # Marker first: a run cut short after the swap still recognises the file as Orca's.\n printf '%s' \"$codex_placeholder\" >\"$codex_marker\"\n codex_tmp=\"$(mktemp \"$HOME/.codex/.auth.XXXXXX\")\"\n printf '{\\n \"OPENAI_API_KEY\": null,\\n \"tokens\": {\\n \"id_token\": \"%s\",\\n \"access_token\": \"%s\",\\n \"refresh_token\": \"\",\\n \"account_id\": null\\n },\\n \"last_refresh\": \"%s\"\\n}\\n' \\\n \"$codex_placeholder\" \"$codex_placeholder\" \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" >\"$codex_tmp\"\n chmod 600 \"$codex_tmp\"\n mv -f \"$codex_tmp\" \"$codex_auth\"\nelif [ -f \"$codex_marker\" ] || [ -n \"$codex_ours\" ]; then\n if [ -n \"$codex_ours\" ]; then rm -f \"$codex_auth\"; fi\n rm -f \"$codex_marker\"\n if [ -f \"$codex_saved\" ] && [ ! -f \"$codex_auth\" ]; then mv -f \"$codex_saved\" \"$codex_auth\"; fi\nfi\n# ---- Claude Code, Gemini CLI, Cursor agent, Copilot CLI, OpenCode: first-run screens answered once\n# (Claude: theme picker, folder trust, bypass-mode warning, the \"use this API key?\" question; others:\n# folder trust, auth-method picker, self-update). A prompt delivered from the phone or the API has no one to click\n# through them, and the agent would sit there doing nothing. Merges; never touches credentials.\nnode_bin=\"$(command -v node || find /opt/orca/runtime/runtimes -type f -name node 2>/dev/null | head -n 1)\"\nif [ -n \"$node_bin\" ]; then\n ORCA_PROJECT=\"$PROJECT\" ORCA_KEY_TAIL=\"$anthropic_key_tail\" ORCA_GEMINI_KEY=\"$gemini_key\" \\\n ORCA_GATEWAY_MODEL=\"${gateway_key:+${anthropic_model:-$openai_model}}\" \"$node_bin\" -e '\nconst fs = require(\"fs\"), path = require(\"path\"), os = require(\"os\")\nconst home = os.homedir()\nconst readJson = (file) => { try { const v = JSON.parse(fs.readFileSync(file, \"utf8\")); return v && typeof v === \"object\" && !Array.isArray(v) ? v : {} } catch { return {} } }\nconst write = (file, value) => {\n fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })\n const tmp = `${file}.orca-${process.pid}`\n fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + \"\\n\", { mode: 0o600 })\n fs.renameSync(tmp, file)\n}\nconst claudeFile = path.join(process.env.CLAUDE_CONFIG_DIR || home, \".claude.json\")\nconst c = readJson(claudeFile)\nconst before = JSON.stringify(c)\nc.hasCompletedOnboarding = true\nc.theme = c.theme || \"dark\"\nc.bypassPermissionsModeAccepted = true\nc.projects = c.projects && typeof c.projects === \"object\" ? c.projects : {}\nconst p = process.env.ORCA_PROJECT\nc.projects[p] = { ...(c.projects[p] || {}), hasTrustDialogAccepted: true, hasCompletedProjectOnboarding: true }\nconst tail = process.env.ORCA_KEY_TAIL\nif (tail) {\n const r = c.customApiKeyResponses && typeof c.customApiKeyResponses === \"object\" ? c.customApiKeyResponses : {}\n const approved = Array.isArray(r.approved) ? r.approved : []\n const rejected = Array.isArray(r.rejected) ? r.rejected.filter((t) => t !== tail) : []\n c.customApiKeyResponses = { ...r, approved: approved.includes(tail) ? approved : [...approved, tail], rejected }\n}\nif (JSON.stringify(c) !== before) write(claudeFile, c)\nconst settingsFile = path.join(home, \".claude\", \"settings.json\")\nconst s = readJson(settingsFile)\nif (s.skipDangerousModePermissionPrompt !== true) write(settingsFile, { ...s, skipDangerousModePermissionPrompt: true })\n\n// Each block below only fills in what is unset and leaves a file it cannot parse alone.\nconst readStrict = (file) => { try { return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, \"utf8\")) : {} } catch { return null } }\nconst isObject = (v) => v !== null && typeof v === \"object\" && !Array.isArray(v)\nconst section = (o, key) => (isObject(o[key]) ? o[key] : (o[key] = {}))\n\n// Gemini CLI: folder trust and the auth-method picker (only when a Gemini key is configured; a\n// Google sign-in picks its own method), no self-update, no usage stats (its host is not on the\n// egress allowlist, so every send would fail).\nconst geminiTrustFile = path.join(home, \".gemini\", \"trustedFolders.json\")\nconst geminiTrust = readStrict(geminiTrustFile)\nif (isObject(geminiTrust) && !geminiTrust[p]) write(geminiTrustFile, { ...geminiTrust, [p]: \"TRUST_FOLDER\" })\nconst geminiSettingsFile = path.join(home, \".gemini\", \"settings.json\")\nconst g = readStrict(geminiSettingsFile)\nif (isObject(g)) {\n const gBefore = JSON.stringify(g)\n const general = section(g, \"general\")\n general.disableAutoUpdate ??= true\n general.disableUpdateNag ??= true\n section(g, \"privacy\").usageStatisticsEnabled ??= false\n // gemini-api-key or vertex-ai (GOOGLE_GENAI_USE_VERTEXAI=true with an API key): headless runs\n // refuse to start without a method (\"Invalid auth method selected\").\n if (process.env.ORCA_GEMINI_KEY) section(section(g, \"security\"), \"auth\").selectedType ??= process.env.ORCA_GEMINI_KEY\n if (JSON.stringify(g) !== gBefore) write(geminiSettingsFile, g)\n}\n\n// Cursor agent: trust the workspace (cursor-agent keys trust by a slug of the path) and pin the\n// release channel to \"static\", which turns off its background self-update.\nconst cursorTrust = path.join(home, \".cursor\", \"projects\", p.replace(/^\\/+/, \"\").replace(/[^A-Za-z0-9]+/g, \"-\"), \".workspace-trusted\")\nif (!fs.existsSync(cursorTrust)) {\n fs.mkdirSync(path.dirname(cursorTrust), { recursive: true, mode: 0o700 })\n fs.writeFileSync(cursorTrust, JSON.stringify({ trustedAt: new Date().toISOString(), workspacePath: p }, null, 2) + \"\\n\", { mode: 0o600 })\n}\nconst cursorConfigFile = path.join(home, \".cursor\", \"cli-config.json\")\nconst cc = readStrict(cursorConfigFile)\nif (isObject(cc) && cc.channel === undefined) write(cursorConfigFile, { version: 1, ...cc, channel: \"static\" })\n\n// GitHub Copilot CLI: trusted folders live in ~/.copilot/config.json, which Copilot prefixes with\n// \"//\" comment lines; keep them.\nconst copilotFile = path.join(home, \".copilot\", \"config.json\")\nlet copilotHead = \"\"\nlet co = {}\ntry {\n const raw = fs.existsSync(copilotFile) ? fs.readFileSync(copilotFile, \"utf8\") : \"\"\n const lines = raw.split(\"\\n\")\n let i = 0\n while (i < lines.length && /^\\s*\\/\\//.test(lines[i])) i++\n copilotHead = lines.slice(0, i).map((l) => l + \"\\n\").join(\"\")\n const body = lines.slice(i).join(\"\\n\").trim()\n co = body ? JSON.parse(body) : {}\n} catch { co = null }\nif (isObject(co)) {\n const folders = Array.isArray(co.trustedFolders) ? co.trustedFolders : []\n if (!folders.includes(p)) {\n fs.mkdirSync(path.dirname(copilotFile), { recursive: true, mode: 0o700 })\n const tmp = `${copilotFile}.orca-${process.pid}`\n fs.writeFileSync(tmp, copilotHead + JSON.stringify({ ...co, trustedFolders: [...folders, p] }, null, 2) + \"\\n\", { mode: 0o600 })\n fs.renameSync(tmp, copilotFile)\n }\n}\n\n// OpenCode: with the AI Gateway preset and a model secret (ANTHROPIC_MODEL, else OPENAI_MODEL), use\n// that model through the gateway unless the user picked one. A ledger remembers what Orca wrote, so a\n// changed model secret updates it and a model the user set is never touched.\nconst ocDir = path.join(home, \".config\", \"opencode\")\nconst ocFile = path.join(ocDir, \"opencode.json\")\nconst ocLedger = path.join(ocDir, \".orca-managed-model\")\nconst managedModel = process.env.ORCA_GATEWAY_MODEL ? `vercel/${process.env.ORCA_GATEWAY_MODEL}` : \"\"\nconst oc = readStrict(ocFile)\nlet jsoncHasModel = false\ntry { jsoncHasModel = /\"model\"\\s*:/.test(fs.readFileSync(path.join(ocDir, \"opencode.jsonc\"), \"utf8\")) } catch {}\nif (isObject(oc) && !jsoncHasModel) {\n let ours = \"\"\n try { ours = fs.readFileSync(ocLedger, \"utf8\").trim() } catch {}\n const userModel = typeof oc.model === \"string\" && oc.model !== ours\n if (!userModel && (oc.model ?? \"\") !== managedModel) {\n const next = { $schema: \"https://opencode.ai/config.json\", ...oc }\n if (managedModel) next.model = managedModel\n else delete next.model\n write(ocFile, next)\n }\n if (!userModel) {\n if (managedModel) fs.writeFileSync(ocLedger, managedModel + \"\\n\", { mode: 0o600 })\n else fs.rmSync(ocLedger, { force: true })\n }\n}\n\n// OpenCode: two built-in providers cannot work in Orca Cloud, and with no other credential OpenCode\n// picks one and fails (\"Authorization header is badly formatted\" / opencode.ai unreachable) instead\n// of showing \"Connect a provider\". GitHub Copilot reads GITHUB_TOKEN, which here is the stand-in for the\n// GitHub App token of the workspace, never a Copilot token; OpenCode Zen needs opencode.ai, which is not on the\n// egress allowlist. Both stay off unless the user connected them in OpenCode itself (auth.json).\nconst ocAuth = readStrict(path.join(process.env.XDG_DATA_HOME || path.join(home, \".local\", \"share\"), \"opencode\", \"auth.json\"))\nconst connected = new Set(isObject(ocAuth) ? Object.keys(ocAuth) : [])\nconst ocDisabledLedger = path.join(ocDir, \".orca-managed-disabled-providers\")\nconst ocNow = readStrict(ocFile)\nif (isObject(ocNow) && !/\"disabled_providers\"\\s*:/.test((() => { try { return fs.readFileSync(path.join(ocDir, \"opencode.jsonc\"), \"utf8\") } catch { return \"\" } })())) {\n let ours = []\n try { ours = fs.readFileSync(ocDisabledLedger, \"utf8\").split(\"\\n\").filter(Boolean) } catch {}\n const userList = (Array.isArray(ocNow.disabled_providers) ? ocNow.disabled_providers : []).filter((id) => typeof id === \"string\" && !ours.includes(id))\n const managed = [\"github-copilot\", \"opencode\"].filter((id) => !connected.has(id) && !userList.includes(id))\n const next = [...userList, ...managed]\n const before = JSON.stringify(ocNow.disabled_providers ?? [])\n if (JSON.stringify(next) !== before) {\n const updated = { $schema: \"https://opencode.ai/config.json\", ...ocNow }\n if (next.length > 0) updated.disabled_providers = next\n else delete updated.disabled_providers\n write(ocFile, updated)\n }\n if (managed.length > 0) fs.writeFileSync(ocDisabledLedger, managed.join(\"\\n\") + \"\\n\", { mode: 0o600 })\n else fs.rmSync(ocDisabledLedger, { force: true })\n}\n' || echo 'agent first-run state not seeded' >&2\nfi\nprintf '{\"names\":%d}\\n' \"$count\"\n","orca-session-env.sh":"# Orca Cloud: sourced by every new bash/zsh (via /etc/profile.d) so terminals and agents started\n# after a secret edit get the current values with no restart. The file is written by\n# /opt/orca/bin/orca-sandbox-env on tmpfs; names it no longer lists are unset. Cloud secrets win\n# over same-named values already in the environment.\n__orca_env_file=\"/dev/shm/orca-env-$(id -u 2>/dev/null)/session.env\"\nif [ -r \"$__orca_env_file\" ]; then\n # $(...) word-splits in both bash and zsh.\n for __orca_n in $(printf '%s' \"${ORCA_SESSION_ENV_NAMES-}\"); do\n unset \"$__orca_n\" 2>/dev/null || true\n done\n __orca_names=''\n while IFS= read -r __orca_line || [ -n \"$__orca_line\" ]; do\n __orca_n=\"${__orca_line%%=*}\"\n case \"$__orca_n\" in '' | *[!A-Z0-9_]* | [0-9]* | ORCA_* | PATH | HOME | LD_* | DYLD_*) continue ;; esac\n export \"$__orca_n=$(printf '%s' \"${__orca_line#*=}\" | base64 -d 2>/dev/null)\"\n __orca_names=\"$__orca_names $__orca_n\"\n done <\"$__orca_env_file\"\n export ORCA_SESSION_ENV_NAMES=\"${__orca_names# }\"\nfi\n# Default branch of the cloud workspace repo, once its first-boot clone exists (computed once per shell tree).\nif [ -n \"${ORCA_WORKSPACE_PATH-}\" ] && [ -z \"${ORCA_DEFAULT_BRANCH-}\" ]; then\n __orca_branch=\"$(git -C \"$ORCA_WORKSPACE_PATH\" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)\"\n if [ -n \"$__orca_branch\" ]; then\n export ORCA_DEFAULT_BRANCH=\"${__orca_branch#origin/}\" CONDUCTOR_DEFAULT_BRANCH=\"${__orca_branch#origin/}\"\n fi\nfi\n# CR-cmd-quiet: Orca's staged agent launch (` . '/tmp/orca-launch-….sh'`) stays out of zsh history.\n# zsh records a line before running it, so the hook must exist first; the staged script itself\n# drops the line from bash's history. eval: bash parses this file too.\nif [ -n \"${ZSH_VERSION-}\" ]; then\n eval '__orca_skip_launch_history() { [[ $1 == *orca-launch-* ]] && return 1; return 0 }\n (( ${zshaddhistory_functions[(Ie)__orca_skip_launch_history]} )) || zshaddhistory_functions+=(__orca_skip_launch_history)'\nfi\nunset __orca_env_file __orca_line __orca_n __orca_names __orca_branch\n","orca-env":"#!/usr/bin/env bash\n# Prints shell lines that bring the current shell up to date with this session's cloud env (secrets\n# saved after the shell started): eval \"$(orca-env)\" or, in fish: orca-env --fish | source\n# Reads /dev/shm/orca-env-/session.env (written by orca-sandbox-env, `NAME=` lines) and\n# prints an export for each name, plus an unset for each name in $ORCA_SESSION_ENV_NAMES that is gone.\n# Never writes a file. Values are single-quoted, so quotes, newlines and $ in them stay literal.\nset -euo pipefail\nfish=''\ncase \"${1-}\" in\n '') ;;\n --fish) fish=1 ;;\n -h | --help)\n printf 'usage: eval \"$(orca-env)\" (fish: orca-env --fish | source)\\n'\n exit 0\n ;;\n *)\n printf 'orca-env: unknown option %s\\n' \"$1\" >&2\n exit 2\n ;;\nesac\n# ORCA_SESSION_ENV_FILE is for tests; secrets can't set ORCA_* names.\nfile=\"${ORCA_SESSION_ENV_FILE:-/dev/shm/orca-env-$(id -u)/session.env}\"\nset -f # names are split below, never globbed\n# Not written yet (or not a cloud VM): nothing to change, like the shell hook.\n[ -r \"$file\" ] || exit 0\n\n# 'it'\\''s' for POSIX shells; fish single quotes escape only \\ and '.\nquote() {\n local v=\"$1\"\n if [ -n \"$fish\" ]; then\n v=\"${v//\\\\/\\\\\\\\}\"\n v=\"${v//\\'/\\\\\\'}\"\n printf \"'%s'\" \"$v\"\n else\n printf \"'%s'\" \"${v//\\'/\\'\\\\\\'\\'}\"\n fi\n}\n\n# The control plane's reserved names and prefixes (cloud/apps/sandbox-control/src/secrets.ts; a test\n# keeps this in sync): a session file never sets what would hijack the shell or Orca's own processes.\nskip_name() {\n case \"$1\" in\n '' | *[!A-Z0-9_]* | [0-9]*) return 0 ;;\n LD_* | DYLD_* | ORCA_* | VERCEL_* | BASH_FUNC_* | GIT_CONFIG*) return 0 ;;\n PATH | HOME | USER | LOGNAME | SHELL | PWD | OLDPWD | LANG | TERM | TMPDIR | IFS | ENV | \\\n BASH_ENV | PS4 | PROMPT_COMMAND | SHELLOPTS | BASHOPTS | NODE_OPTIONS | NODE_PATH | \\\n NODE_EXTRA_CA_CERTS | NODE_TLS_REJECT_UNAUTHORIZED | SSL_CERT_FILE | SSL_CERT_DIR | \\\n CURL_CA_BUNDLE | REQUESTS_CA_BUNDLE | HTTP_PROXY | HTTPS_PROXY | ALL_PROXY | NO_PROXY | \\\n PYTHONPATH | PYTHONSTARTUP | PERL5OPT | RUBYOPT | JAVA_TOOL_OPTIONS | GIT_SSH | \\\n GIT_SSH_COMMAND | GIT_ASKPASS | GIT_EXEC_PATH | GIT_PROXY_COMMAND | SSH_AUTH_SOCK | \\\n GH_TOKEN | GITHUB_TOKEN) return 0 ;;\n # CLAUDE_CODE_OAUTH_TOKEN is reserved for users but set by the control plane itself: only ever the\n # Orca-account placeholder (contract D10), never a stored value.\n esac\n return 1\n}\n\nnames=' '\nwhile IFS= read -r line || [ -n \"$line\" ]; do\n name=\"${line%%=*}\"\n skip_name \"$name\" && continue\n # $(...) drops trailing newlines; the x keeps them.\n value=\"$(printf '%s' \"${line#*=}\" | base64 -d 2>/dev/null; printf x)\"\n value=\"${value%x}\"\n if [ -n \"$fish\" ]; then\n printf 'set -gx %s %s\\n' \"$name\" \"$(quote \"$value\")\"\n else\n printf 'export %s=%s\\n' \"$name\" \"$(quote \"$value\")\"\n fi\n names=\"$names$name \"\ndone <\"$file\"\nfor name in ${ORCA_SESSION_ENV_NAMES-}; do\n skip_name \"$name\" && continue\n case \"$names\" in *\" $name \"*) continue ;; esac\n if [ -n \"$fish\" ]; then\n printf 'set -e %s\\n' \"$name\"\n else\n printf 'unset %s\\n' \"$name\"\n fi\ndone\nnames=\"${names# }\"\nnames=\"${names% }\"\nif [ -n \"$fish\" ]; then\n printf 'set -gx ORCA_SESSION_ENV_NAMES %s\\n' \"$(quote \"$names\")\"\nelse\n printf 'export ORCA_SESSION_ENV_NAMES=%s\\n' \"$(quote \"$names\")\"\nfi\n","orca-egress-probe":"#!/usr/bin/env bash\n# Bounded Node fetch against each credential host. Vercel transform rules have made undici hang on\n# response bodies (no content-length / transfer-encoding); report it instead of letting agents hang.\n# Prints one JSON line: {\"host\":\"api.github.com\",\"status\":\"ok\"|\"egress_framing_hang\"|\"error\"}.\nset -euo pipefail\nnode=\"$(find /opt/orca/runtime/runtimes -type f -name node | head -n 1)\"\nfor host in \"$@\"; do\n \"$node\" -e '\n const host = process.argv[1]\n const timer = setTimeout(() => { console.log(JSON.stringify({ host, status: \"egress_framing_hang\" })); process.exit(0) }, 8000)\n fetch(`https://${host}/`, { headers: { authorization: process.env.ORCA_PROBE_AUTHORIZATION ?? \"\" } })\n .then((r) => r.arrayBuffer())\n .then(() => { clearTimeout(timer); console.log(JSON.stringify({ host, status: \"ok\" })) })\n .catch(() => { clearTimeout(timer); console.log(JSON.stringify({ host, status: \"error\" })) })\n ' \"$host\"\ndone\n","orca-image-wipe":"#!/usr/bin/env bash\n# Runs last before the golden snapshot. Every sandbox cloned from the image must start without an\n# orcad identity, device registry, agent sign-in or shell history (clone-safe identity, risk #1).\nset -euo pipefail\npkill -f orcad-server.js 2>/dev/null || true\nfor path in \\\n \"$HOME/.orcad-data\" \"$HOME/.orca-sandbox\" \"$HOME/.orca-desktop\" \"$HOME/.config/orca\" \"$HOME/.config/orca-dev\" \\\n \"$HOME/.claude\" \"$HOME/.claude.json\" \"$HOME/.codex\" \"$HOME/.config/gh\" \"$HOME/.gemini\" \\\n \"$HOME/.local/share/opencode\" \"$HOME/.config/opencode\" \"$HOME/.cursor\" \"$HOME/.config/cursor\" \\\n \"$HOME/.local/share/cursor-agent\" \"$HOME/.copilot\" \"$HOME/.npm\" \"$HOME/.cache\" \"$HOME/.config/google-chrome\" \"$HOME/.bash_history\" \\\n /vercel/sandbox/project; do\n rm -rf -- \"$path\"\ndone\nsudo rm -rf /tmp/* /var/tmp/* 2>/dev/null || true\nfor leftover in \"$HOME/.orcad-data\" \"$HOME/.claude\" \"$HOME/.codex\" \"$HOME/.config/gh\" \"$HOME/.cursor\" \"$HOME/.copilot\" \"$HOME/.gemini\"; do\n if [ -e \"$leftover\" ]; then\n echo \"identity survived wipe: $leftover\" >&2\n exit 1\n fi\ndone\necho '{\"wiped\":true}'\n","orca-agent-signout":"#!/usr/bin/env bash\n# Signs every agent out on this sandbox before Orca deletes it (A9): each agent's own logout, then\n# its credential files. Covers logins made inside the sandbox (the kill-switch fallback path).\n# Never fails: the sandbox is deleted next whatever happens. Values are never printed.\nset -u\nHOME=\"${HOME:-$(getent passwd \"$(id -u)\" | cut -d: -f6)}\"\nexport PATH=\"/opt/orca/tools/bin:$HOME/.local/bin:$HOME/.npm-global/bin:/usr/local/bin:/usr/bin:/bin:/opt/orca/bin:${PATH:-}\"\nclaude_dir=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"\nclaude_json=\"${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json\"\ncodex_dir=\"${CODEX_HOME:-$HOME/.codex}\"\n\nlogged_out=0\n# Each logout gets a few seconds; a hung CLI must not hold up the delete.\nif command -v claude >/dev/null 2>&1 && timeout 8 claude auth logout /dev/null 2>&1; then\n logged_out=$((logged_out + 1))\nfi\nif command -v codex >/dev/null 2>&1 && timeout 8 codex logout /dev/null 2>&1; then\n logged_out=$((logged_out + 1))\nfi\n\n# The files go whether or not a logout ran: an older CLI, a missing binary or no network leaves them.\nrm -f -- \"$claude_dir\"/.credentials* \"$HOME/.claude\"/.credentials* \"$codex_dir/auth.json\" \"$HOME/.codex/auth.json\" 2>/dev/null\n# Orca-managed Codex homes keep their own auth.json under orcad's data dir.\nif [ -d \"$HOME/.orcad-data\" ]; then\n find \"$HOME/.orcad-data\" -path '*codex*' -type f -name auth.json -delete 2>/dev/null\nfi\n# ~/.claude.json keeps settings Orca wrote (onboarding, trust); only the sign-in fields go.\nnode_bin=\"$(command -v node || find /opt/orca/runtime/runtimes -type f -name node 2>/dev/null | head -n 1)\"\nfor file in \"$claude_json\" \"$HOME/.claude.json\"; do\n [ -f \"$file\" ] || continue\n if [ -n \"$node_bin\" ]; then\n ORCA_FILE=\"$file\" \"$node_bin\" -e '\nconst fs = require(\"fs\")\nconst file = process.env.ORCA_FILE\nlet c\ntry { c = JSON.parse(fs.readFileSync(file, \"utf8\")) } catch { process.exit(2) }\nif (!c || typeof c !== \"object\" || Array.isArray(c)) process.exit(2)\nlet changed = false\nfor (const key of Object.keys(c)) {\n if (/oauth/i.test(key) || key === \"primaryApiKey\") { delete c[key]; changed = true }\n}\nif (changed) {\n const tmp = `${file}.orca-${process.pid}`\n fs.writeFileSync(tmp, JSON.stringify(c, null, 2) + \"\\n\", { mode: 0o600 })\n fs.renameSync(tmp, file)\n}\n' 2>/dev/null || rm -f -- \"$file\"\n else\n # No node to edit it safely: losing Orca's settings beats leaving a sign-in behind.\n rm -f -- \"$file\"\n fi\ndone\n\nleft=0\nfor file in \"$claude_dir\"/.credentials* \"$HOME/.claude\"/.credentials* \"$codex_dir/auth.json\" \"$HOME/.codex/auth.json\"; do\n [ -e \"$file\" ] && left=$((left + 1))\ndone\nprintf '{\"loggedOut\":%d,\"left\":%d}\\n' \"$logged_out\" \"$left\"\n","orca-share-proxy":"#!/usr/bin/env node\n// Share-link proxy for this VM. Listens on 0.0.0.0:6769 (its public *.vercel.run route) and only\n// serves requests the control plane's share gateway signed with this session's key:\n// x-orca-share: v2.....\n// A request must start before `expiresAt` (60s windows); its response or WebSocket is closed at\n// `until` (the link's expiry, at most 30 minutes out), so a revoked or expired link also loses open\n// streams. Signed requests are reverse-proxied to 127.0.0.1:: streaming both ways, no\n// buffering, WebSocket upgrades piped raw. Anything unsigned gets a bare 404.\n// The key arrives on stdin (orca-sandbox-start pipes it), so it is never in this process's\n// environment or argv; ORCA_SHARE_PROXY_KEY in the env is accepted for tests. Optional\n// ORCA_SHARE_PROXY_PORT (default 6769). Plain Node, no dependencies.\n// getBuiltinModule (Node >= 22.3) works whether node loads this extensionless file as CommonJS\n// (in the VM) or as an ES module (under this repo's type:module package, in tests).\nconst crypto = process.getBuiltinModule('node:crypto')\nconst fs = process.getBuiltinModule('node:fs')\nconst http = process.getBuiltinModule('node:http')\nconst net = process.getBuiltinModule('node:net')\n\nconst LISTEN_PORT = Number(process.env.ORCA_SHARE_PROXY_PORT || 6769)\nconst KEY = Buffer.from((process.env.ORCA_SHARE_PROXY_KEY || fs.readFileSync(0, 'utf8')).trim(), 'base64url')\ndelete process.env.ORCA_SHARE_PROXY_KEY\n// Never proxy to the VM's sshd, orcad or this proxy itself.\n// 6080: the remote desktop (VNC behind a password) is for its owner's forward only.\nconst BLOCKED_PORTS = new Set([2222, 6080, 6768, LISTEN_PORT])\nconst MAX_SKEW_SECONDS = 300\nconst MAX_CONNECTION_SECONDS = 3600\nconst HOP_BY_HOP = ['connection', 'keep-alive', 'proxy-connection', 'transfer-encoding', 'te', 'trailer', 'upgrade']\n\nif (KEY.length < 32) {\n console.error('orca-share-proxy: share proxy key missing or short')\n process.exit(2)\n}\n\n/** The target port and close-by time (ms) for a validly signed request, else null. */\nfunction verify(header, nowMs) {\n if (typeof header !== 'string') {\n return null\n }\n const match = /^v2\\.(\\d{1,5})\\.(\\d{1,12})\\.(\\d{1,12})\\.([0-9a-f]{32})\\.([A-Za-z0-9_-]{43})$/.exec(header)\n if (!match) {\n return null\n }\n const now = Math.floor(nowMs / 1000)\n const port = Number(match[1])\n const expiresAt = Number(match[2])\n const until = Number(match[3])\n if (port < 1 || port > 65535 || BLOCKED_PORTS.has(port)) {\n return null\n }\n if (expiresAt < now || expiresAt > now + MAX_SKEW_SECONDS || until <= now || until > now + MAX_CONNECTION_SECONDS) {\n return null\n }\n const signed = header.slice(0, header.lastIndexOf('.'))\n const expected = crypto.createHmac('sha256', KEY).update(signed).digest()\n const given = Buffer.from(match[5], 'base64url')\n return given.length === expected.length && crypto.timingSafeEqual(given, expected) ? { port, untilMs: until * 1000 } : null\n}\n\nfunction upstreamHeaders(raw, port) {\n const headers = { ...raw }\n const connectionTokens = String(raw.connection || '')\n .split(',')\n .map((token) => token.trim().toLowerCase())\n for (const name of [...HOP_BY_HOP, ...connectionTokens, 'x-orca-share']) {\n delete headers[name]\n }\n // Dev servers (Vite, Next) allow localhost hosts by default; the public host rides x-forwarded-host.\n headers.host = `localhost:${port}`\n return headers\n}\n\nfunction refuse(res) {\n res.writeHead(404, { 'content-type': 'text/plain', 'cache-control': 'no-store' })\n res.end('not found')\n}\n\n/** Ends `close` at the grant's deadline; the timer goes away with the connection. */\nfunction deadline(untilMs, close, ...emitters) {\n const timer = setTimeout(close, Math.max(0, untilMs - Date.now()))\n for (const emitter of emitters) {\n emitter.once('close', () => clearTimeout(timer))\n }\n}\n\nconst server = http.createServer((req, res) => {\n const grant = verify(req.headers['x-orca-share'], Date.now())\n if (grant === null) {\n return refuse(res)\n }\n const { port } = grant\n const upstream = http.request(\n { host: '127.0.0.1', port, method: req.method, path: req.url, headers: upstreamHeaders(req.headers, port) },\n (answer) => {\n const headers = { ...answer.headers }\n for (const name of HOP_BY_HOP) {\n delete headers[name]\n }\n // Marks the answer as the app's, so the gateway can tell it from a route with no proxy behind it.\n headers['x-orca-share-proxy'] = '1'\n res.writeHead(answer.statusCode || 502, answer.statusMessage, headers)\n answer.pipe(res)\n }\n )\n deadline(grant.untilMs, () => {\n upstream.destroy()\n res.destroy()\n }, res)\n upstream.on('error', (error) => {\n if (res.headersSent) {\n res.destroy()\n return\n }\n // The gateway renders its own page for this; the code tells it why. Status 200 on purpose: a 5xx\n // from the VM reaches a Worker as Vercel's own 502 with our headers gone (found live).\n res.writeHead(200, { 'content-type': 'text/plain', 'x-orca-share-proxy': '1', 'x-orca-share-error': error.code === 'ECONNREFUSED' ? 'no_listener' : 'upstream_failed' })\n res.end('upstream unavailable')\n })\n req.on('aborted', () => upstream.destroy())\n req.pipe(upstream)\n})\n\nserver.on('upgrade', (req, socket, head) => {\n const grant = verify(req.headers['x-orca-share'], Date.now())\n if (grant === null) {\n socket.end('HTTP/1.1 404 Not Found\\r\\nconnection: close\\r\\ncontent-length: 0\\r\\n\\r\\n')\n return\n }\n const { port } = grant\n const headers = upstreamHeaders(req.headers, port)\n const target = net.connect(port, '127.0.0.1', () => {\n const lines = [`${req.method} ${req.url} HTTP/1.1`, `connection: upgrade`, `upgrade: ${req.headers.upgrade || 'websocket'}`]\n for (const [name, value] of Object.entries(headers)) {\n for (const item of Array.isArray(value) ? value : [value]) {\n lines.push(`${name}: ${item}`)\n }\n }\n target.write(`${lines.join('\\r\\n')}\\r\\n\\r\\n`)\n if (head && head.length > 0) {\n target.write(head)\n }\n socket.pipe(target)\n target.pipe(socket)\n })\n const close = () => {\n socket.destroy()\n target.destroy()\n }\n deadline(grant.untilMs, close, socket)\n target.on('error', () => {\n if (socket.writable) {\n socket.end('HTTP/1.1 200 OK\\r\\nx-orca-share-proxy: 1\\r\\nx-orca-share-error: no_listener\\r\\nconnection: close\\r\\ncontent-length: 0\\r\\n\\r\\n')\n }\n close()\n })\n socket.on('error', close)\n target.on('close', () => socket.destroy())\n socket.on('close', () => target.destroy())\n})\n\nserver.keepAliveTimeout = 65_000\nserver.headersTimeout = 70_000\n// No request timeout: previews can stream (SSE, long polls) until the grant's deadline.\nserver.requestTimeout = 0\nserver.listen(LISTEN_PORT, '0.0.0.0', () => {\n console.log(`orca-share-proxy listening on ${LISTEN_PORT}`)\n})\n","orca-sshd":"#!/usr/bin/env bash\n# Starts this boot's unprivileged sshd on 127.0.0.1:2222 (idempotent). Nothing outside the VM can\n# reach that port: the desktop tunnels to it over orcad's runtime connection (`orca ssh-proxy`).\n# Key-only, single user (the sandbox user), no root. Its host key and authorized_keys live under\n# ~/.orca-sandbox/ssh on the persistent disk, so the host key stays the same across stop/resume;\n# orca-image-wipe removes that directory, so every VM cloned from the golden image makes its own.\n# Prints {\"port\":2222,\"user\":\"\",\"hostKey\":\"ssh-ed25519 AAAA...\"} on success.\nset -euo pipefail\numask 077\nOPENSSH=/opt/orca/openssh\nSSH_DIR=\"$HOME/.orca-sandbox/ssh\"\nPORT=2222\nmkdir -p \"$SSH_DIR\"\nchmod 0700 \"$HOME/.orca-sandbox\" \"$SSH_DIR\"\nexec 8>\"$SSH_DIR/sshd.lock\"\nflock -w 30 8\n\nif [ ! -x \"$OPENSSH/sshd\" ]; then\n echo 'sshd is not installed in this image' >&2\n exit 3\nfi\nif [ ! -s \"$SSH_DIR/ssh_host_ed25519_key\" ]; then\n rm -f \"$SSH_DIR/ssh_host_ed25519_key\" \"$SSH_DIR/ssh_host_ed25519_key.pub\"\n ssh-keygen -q -t ed25519 -N '' -C \"orca-cloud-host\" -f \"$SSH_DIR/ssh_host_ed25519_key\"\nfi\ntouch \"$SSH_DIR/authorized_keys\"\nchmod 0600 \"$SSH_DIR/authorized_keys\"\n\n# Rewritten every start so an image update's settings apply to existing workspaces. PAM stays at\n# its compiled-in default (off): an unprivileged sshd cannot use it, and this build warns on `no`.\ncat >\"$SSH_DIR/sshd_config\" </dev/null || echo 0)\"\n# Resume reboots from disk, so a recorded pid can name a dead process or an unrelated one.\nif ! { kill -0 \"$pid\" 2>/dev/null && grep -q sshd \"/proc/$pid/cmdline\" 2>/dev/null; }; then\n rm -f \"$SSH_DIR/sshd.pid\"\n : >\"$SSH_DIR/sshd.log\"\n \"$OPENSSH/sshd\" -t -f \"$SSH_DIR/sshd_config\"\n \"$OPENSSH/sshd\" -f \"$SSH_DIR/sshd_config\" -E \"$SSH_DIR/sshd.log\"\n for _ in $(seq 1 100); do\n [ -s \"$SSH_DIR/sshd.pid\" ] && break\n sleep 0.02\n done\n [ -s \"$SSH_DIR/sshd.pid\" ] || { echo 'sshd did not start' >&2; tail -n 20 \"$SSH_DIR/sshd.log\" >&2; exit 1; }\nfi\n\nhost_key=\"$(cut -d' ' -f1-2 \"$SSH_DIR/ssh_host_ed25519_key.pub\")\"\nprintf '{\"port\":%s,\"user\":\"%s\",\"hostKey\":\"%s\"}\\n' \"$PORT\" \"$(id -un)\" \"$host_key\"\n","orca-ssh-keys":"#!/usr/bin/env bash\n# `orca-ssh-keys add|remove`: adds or removes one desktop's key in the Orca-managed authorized_keys\n# of this VM's sshd, then makes sure sshd runs. The control plane passes the key in\n# ORCA_SSH_PUBLIC_KEY (`ssh-ed25519 `), never on the command line. Keys are\n# matched by their base64 blob, so a renamed comment cannot keep a revoked key alive.\n# Prints orca-sshd's {\"port\":...,\"user\":...,\"hostKey\":...} line on success.\nset -euo pipefail\numask 077\nSSH_DIR=\"$HOME/.orca-sandbox/ssh\"\naction=\"${1:-}\"\nkey=\"${ORCA_SSH_PUBLIC_KEY:-}\"\n# grep matches per line: a second line could carry key options past the check below.\ncase \"$key\" in *$'\\n'* | *$'\\r'*) echo 'invalid public key' >&2; exit 2 ;; esac\n# One line, ed25519 only, a plain comment: nothing that sshd would read as key options.\nif ! printf '%s' \"$key\" | grep -Eq '^ssh-ed25519 [A-Za-z0-9+/]{68} [A-Za-z0-9@._:+-]{1,200}$'; then\n echo 'invalid public key' >&2\n exit 2\nfi\nblob=\"$(printf '%s' \"$key\" | cut -d' ' -f2)\"\nmkdir -p \"$SSH_DIR\"\nchmod 0700 \"$HOME/.orca-sandbox\" \"$SSH_DIR\"\nexec 7>\"$SSH_DIR/keys.lock\"\nflock -w 30 7\ntouch \"$SSH_DIR/authorized_keys\"\nnext=\"$(mktemp \"$SSH_DIR/authorized_keys.XXXXXX\")\"\n# Drop the key wherever it appears (with or without options), then append it on add.\nawk -v blob=\"$blob\" '{ for (i = 1; i <= NF; i++) if ($i == blob) next; print }' \\\n \"$SSH_DIR/authorized_keys\" >\"$next\"\ncase \"$action\" in\n add) printf '%s\\n' \"$key\" >>\"$next\" ;;\n remove) ;;\n *) rm -f \"$next\"; echo 'usage: orca-ssh-keys add|remove' >&2; exit 2 ;;\nesac\nchmod 0600 \"$next\"\nmv -f \"$next\" \"$SSH_DIR/authorized_keys\"\nexec /opt/orca/bin/orca-sshd\n","orca-desktop-start":"#!/usr/bin/env bash\n# Starts the on-demand remote desktop (idempotent) and mints one connection ticket. Prints one line:\n# {\"schemaVersion\":2,\"port\":6080,\"display\":\":1\",\"path\":\"vnc.html\",\"ticket\":\"<43 chars>\",\"expiresInMs\":60000}\n# Xvnc (X server + VNC in one) listens on a 0600 unix socket only, with no VNC auth: only this uid\n# can open it. orca-desktop-proxy (websockify + a ticket gate) serves noVNC on 127.0.0.1 and\n# bridges WebSockets to that socket. Nothing is reachable from outside the VM: the desktop app\n# reaches it through Orca's port forward, a localhost port on the user's computer that any local\n# process or web page can reach, so each WebSocket needs a ticket from here: 32 random bytes,\n# single use, valid for 60 s, stored only as its sha256 (the file name; its content is the expiry).\n# The ticket goes to stdout only (the control plane hands it to the desktop app). Never started at boot.\nset -euo pipefail\numask 077\nDESKTOP=/opt/orca/desktop\nSTATE=\"$HOME/.orca-desktop\"\nPORT=\"${ORCA_DESKTOP_PORT:-6080}\"\nDISPLAY_NUM=\"${ORCA_DESKTOP_DISPLAY:-1}\"\nGEOMETRY=\"${ORCA_DESKTOP_GEOMETRY:-1280x800}\"\nSOCKET=\"$STATE/vnc.sock\"\n# The parent's DISPLAY must not leak into the WM/xterm below; they use ours.\nexport DISPLAY=\":$DISPLAY_NUM\"\n\n# Exit 3 = this image has no desktop (built before it, or its optional install failed).\nfail() { printf 'orca-desktop: %s\\n' \"$1\" >&2; exit \"${2:-1}\"; }\ncase \"$PORT\" in '' | *[!0-9]*) fail 'ORCA_DESKTOP_PORT must be a number' ;; esac\ncase \"$DISPLAY_NUM\" in '' | *[!0-9]*) fail 'ORCA_DESKTOP_DISPLAY must be a number' ;; esac\ncase \"$GEOMETRY\" in [0-9]*x[0-9]*) ;; *) fail 'ORCA_DESKTOP_GEOMETRY must look like 1280x800' ;; esac\ncommand -v Xvnc >/dev/null || fail 'not installed in this image (Xvnc missing)' 3\n[ -s \"$DESKTOP/novnc/vnc.html\" ] || fail 'not installed in this image (noVNC missing)' 3\nPROXY=\"$(dirname \"$(readlink -f \"$0\")\")/orca-desktop-proxy\"\n[ -x \"$PROXY\" ] || fail 'orca-desktop-proxy missing next to this script'\nTICKETS=\"$STATE/tickets\"\nTICKET_TTL_S=60\n# Xvnc's mode: a previous version of this script ran it with VncAuth; restart it in this mode.\nMODE=ticket-v1\n\nmkdir -p \"$STATE\"\nexec 9>\"$STATE/start.lock\"\nflock -w 30 9 || fail 'another start is in progress'\n\npid_of() { # never \"0\": kill 0 targets our own process group\n local pid\n pid=\"$(cat \"$STATE/$1.pid\" 2>/dev/null || true)\"\n case \"$pid\" in '' | 0 | *[!0-9]*) return 1 ;; esac\n printf '%s' \"$pid\"\n}\nalive() { local pid; pid=\"$(pid_of \"$1\")\" && kill -0 \"$pid\" 2>/dev/null; }\nmint_ticket() { # prints the ticket; stores only its sha256, with the expiry as content\n local ticket now digest\n mkdir -p \"$TICKETS\"\n now=\"$(date +%s)\"\n # Expired or spent-but-unswept tickets; keep at most 16 live ones (oldest go first).\n for file in \"$TICKETS\"/*; do\n [ -f \"$file\" ] || continue\n [ \"$(cat \"$file\" 2>/dev/null || echo 0)\" -ge \"$now\" ] 2>/dev/null || rm -f \"$file\"\n done\n # shellcheck disable=SC2012 # names are hex digests\n ls -1t \"$TICKETS\" 2>/dev/null | tail -n +16 | while read -r old; do rm -f \"$TICKETS/$old\"; done\n ticket=\"$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=\\n')\"\n [ \"${#ticket}\" = 43 ] || fail 'could not generate a ticket'\n digest=\"$(printf '%s' \"$ticket\" | sha256sum | cut -d' ' -f1)\"\n printf '%s' \"$((now + TICKET_TTL_S))\" >\"$TICKETS/.$digest\" && mv \"$TICKETS/.$digest\" \"$TICKETS/$digest\"\n printf '%s' \"$ticket\"\n}\nready_line() {\n local ticket\n ticket=\"$(mint_ticket)\" || exit 1\n printf '{\"schemaVersion\":2,\"port\":%s,\"display\":\":%s\",\"path\":\"vnc.html\",\"ticket\":\"%s\",\"expiresInMs\":%s}\\n' \\\n \"$PORT\" \"$DISPLAY_NUM\" \"$ticket\" \"$((TICKET_TTL_S * 1000))\"\n}\nlistening() { # the proxy answers HTTP on loopback\n curl -fsS -o /dev/null --max-time 1 \"http://127.0.0.1:$PORT/vnc.html\" 2>/dev/null\n}\n\n# Pids from a previous boot (resume = reboot from disk) name dead or unrelated processes.\nboot_id=\"$(cat /proc/sys/kernel/random/boot_id)\"\nif [ \"$(cat \"$STATE/boot-id\" 2>/dev/null || true)\" != \"$boot_id\" ]; then\n rm -rf \"$STATE\"/*.pid \"$SOCKET\" \"$STATE/password\" \"$STATE/passwd\" \"$STATE/mode\" \"$TICKETS\"\n printf '%s' \"$boot_id\" >\"$STATE/boot-id\"\nfi\n\n# Same boot, older script: its websockify had no ticket gate and its Xvnc wanted a VNC password.\nrm -f \"$STATE/password\" \"$STATE/passwd\"\nif alive websockify; then kill \"$(pid_of websockify)\" 2>/dev/null || true; fi\nrm -f \"$STATE/websockify.pid\"\ncurrent_mode() { cat \"$STATE/mode\" 2>/dev/null || true; }\n\nif alive xvnc && alive proxy && listening && [ \"$(current_mode)\" = \"$MODE\" ]; then\n ready_line\n exit 0\nfi\n\nstart() { # name cmd... — detached, logs in $STATE/.log\n local name=$1; shift\n # 9>&-: children must not inherit the start lock, or the next call waits for them to exit.\n setsid nohup \"$@\" >\"$STATE/$name.log\" 2>&1 &- &\n echo $! >\"$STATE/$name.pid\"\n}\n\nif ! alive xvnc || [ \"$(current_mode)\" != \"$MODE\" ]; then\n if alive xvnc; then kill \"$(pid_of xvnc)\" 2>/dev/null || true; sleep 0.2; fi\n rm -f \"$SOCKET\"\n # -rfbport -1: no TCP VNC port at all, only a 0600 unix socket (in the 0700 state dir) for the\n # proxy. SecurityTypes None is safe only because of that: the ticket gate is in the proxy.\n # SendPrimary=0: only explicit copies (xterm copies its selection, below) leave the sandbox.\n # The X display itself takes a cookie from ~/.Xauthority (Xlib's default for this user), so only\n # this uid's processes (agents, apps they start on DISPLAY=:1) can open it.\n auth=()\n if command -v xauth >/dev/null && command -v mcookie >/dev/null; then\n xauth -q -f \"$HOME/.Xauthority\" add \":$DISPLAY_NUM\" . \"$(mcookie)\" 9>&- && auth=(-auth \"$HOME/.Xauthority\")\n fi\n start xvnc Xvnc \":$DISPLAY_NUM\" \"${auth[@]}\" -geometry \"$GEOMETRY\" -depth 24 -nolisten tcp \\\n -rfbport -1 -rfbunixpath \"$SOCKET\" -rfbunixmode 0600 \\\n -SecurityTypes None -SendPrimary=0 \\\n -AlwaysShared -AcceptSetDesktopSize -desktop 'Remote desktop'\n printf '%s' \"$MODE\" >\"$STATE/mode\"\n for _ in $(seq 1 100); do\n [ -S \"$SOCKET\" ] && break\n alive xvnc || { tail -n 20 \"$STATE/xvnc.log\" >&2; fail 'Xvnc exited'; }\n sleep 0.05\n done\n [ -S \"$SOCKET\" ] || fail 'Xvnc did not create its socket'\n if command -v xsetroot >/dev/null; then xsetroot -solid '#1e1f22' 9>&- || true; fi\n if command -v openbox >/dev/null; then\n # Dark, flat theme to match Orca; the first terminal stays maximized as the tab resizes the screen.\n printf '%s\\n' '' \\\n 'Onyx' \\\n 'yes' \\\n '' >\"$STATE/openbox.xml\"\n start wm openbox --config-file \"$STATE/openbox.xml\"\n # Let it manage the screen before the terminal maps, so its maximize rule applies.\n sleep 0.2\n elif command -v twm >/dev/null; then\n start wm twm\n fi\n if command -v xterm >/dev/null; then\n # selectToClipboard: a selection is a copy (to CLIPBOARD, which reaches this computer).\n start xterm xterm -maximized -bg '#1e1f22' -fg '#d4d4d4' -fa 'Liberation Mono,Monospace' -fs 11 \\\n -xrm 'XTerm*selectToClipboard: true'\n fi\nfi\n\nif ! alive proxy || ! listening; then\n if alive proxy; then kill \"$(pid_of proxy)\" 2>/dev/null || true; sleep 0.1; fi\n mkdir -p \"$TICKETS\"\n PYTHONPATH=\"$DESKTOP/websockify\" start proxy python3 -P -W ignore::UserWarning \"$PROXY\" \\\n --listen \"$PORT\" --web \"$DESKTOP/novnc\" --unix-target \"$SOCKET\" --tickets \"$TICKETS\"\n for _ in $(seq 1 100); do\n listening && break\n alive proxy || { tail -n 20 \"$STATE/proxy.log\" >&2; fail 'orca-desktop-proxy exited'; }\n sleep 0.05\n done\n listening || fail \"orca-desktop-proxy is not answering on 127.0.0.1:$PORT\"\nfi\nready_line\n","orca-desktop-proxy":"#!/usr/bin/env python3\n# Remote desktop gateway, started by orca-desktop-start: serves noVNC on 127.0.0.1 and bridges one\n# WebSocket per single-use ticket to Xvnc's 0600 unix socket (Xvnc itself has no VNC auth: only this\n# uid can open the socket). The port is reached only through Orca's localhost forward on the user's\n# computer, where any local process or web page could knock, so every WebSocket must carry an\n# unused, unexpired ticket minted by orca-desktop-start (see consume_ticket), from a page on the same\n# localhost origin. Tickets never reach a log: paths are scrubbed before websockify logs them.\n# orca-desktop-proxy --listen PORT --web DIR --unix-target SOCKET --tickets DIR\n# stdlib + websockify (pinned tarball in /opt/orca/desktop/websockify, on PYTHONPATH) only.\nimport argparse\nimport hashlib\nimport hmac\nimport logging\nimport os\nimport re\nimport sys\nimport time\nfrom urllib.parse import parse_qs, urlsplit\n\nfrom websockify import websocketproxy\n\nTICKET_RE = re.compile(r'^[A-Za-z0-9_-]{43}$') # 32 random bytes, base64url without padding\nSCRUB_RE = re.compile(r'([?&]ticket=)[^&\\s\\'\"]*')\nLOOPBACK_HOST_RE = re.compile(r'^(localhost|127\\.0\\.0\\.1|\\[::1\\])(:[0-9]{1,5})?$')\nWS_PATH = '/websockify'\nPAGE_SCRIPT = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'orca-desktop-page.js')\n# The page may only talk to its own origin (its WebSocket included): even a URL that tries to point\n# noVNC elsewhere cannot carry a ticket or the user's keys off this forward.\nPAGE_CSP = \"connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'\"\nPAGE_TAG = b'\\n'\n\n\ndef scrub(text):\n return SCRUB_RE.sub(r'\\1', str(text))\n\n\ndef ticket_digest(ticket):\n return hashlib.sha256(ticket.encode('ascii')).hexdigest()\n\n\ndef consume_ticket(directory, ticket, now=None):\n \"\"\"True once per minted ticket, within its lifetime. The ticket file (named by the ticket's\n sha256, holding its expiry) is unlinked on first use, so a replay or a racing second\n connection finds nothing; each request runs in its own forked process, so state is on disk.\"\"\"\n if not isinstance(ticket, str) or not TICKET_RE.match(ticket):\n return False\n digest = ticket_digest(ticket)\n match = None\n try:\n names = os.listdir(directory)\n except OSError:\n return False\n for name in names:\n # Compare every entry in constant time; never short-circuit on a prefix.\n if hmac.compare_digest(name.encode('ascii', 'replace'), digest.encode('ascii')):\n match = name\n if match is None:\n return False\n path = os.path.join(directory, match)\n try:\n with open(path, 'r', encoding='ascii') as handle:\n expires_at = int(handle.read().strip() or '0')\n except (OSError, ValueError):\n expires_at = 0\n try:\n os.unlink(path) # the consume: only one caller can win this\n except FileNotFoundError:\n return False\n return (now if now is not None else time.time()) <= expires_at\n\n\ndef same_loopback_origin(headers):\n \"\"\"The page and the WebSocket share one localhost origin (blocks other local sites and DNS\n rebinding). The port is the user's local forward, which may differ from ours, so compare the\n Origin with the Host the browser dialed rather than with a fixed port.\"\"\"\n host = (headers.get('Host') or '').strip().lower()\n origin = (headers.get('Origin') or '').strip().lower()\n return loopback_host(host) and origin == 'http://' + host\n\n\ndef loopback_host(host):\n return bool(LOOPBACK_HOST_RE.match(host or ''))\n\n\nclass TicketedProxyHandler(websocketproxy.ProxyRequestHandler):\n \"\"\"websockify's proxy handler plus the ticket + origin gate. Module level (not a closure) so\n websockify's per-connection multiprocessing can pickle it under any start method.\"\"\"\n\n def log_message(self, format, *args): # noqa: A002 (websockify's signature)\n super().log_message('%s', scrub(format % args if args else format))\n\n def reject(self, code, reason):\n self.send_response(code)\n self.send_header('Content-Length', '0')\n self.send_header('Connection', 'close')\n self.end_headers()\n self.close_connection = True\n raise self.server.EClose('desktop: websocket refused (%s)' % reason)\n\n def validate_connection(self):\n parts = urlsplit(self.path)\n ticket = (parse_qs(parts.query).get('ticket') or [''])[0]\n # Drop it before anything below (websockify's own logging included) can see self.path.\n self.path = parts.path\n if parts.path != WS_PATH:\n self.reject(404, 'path')\n if not same_loopback_origin(self.headers):\n self.reject(403, 'origin')\n if not consume_ticket(self.server.orca_tickets_dir, ticket):\n self.reject(403, 'ticket')\n\n def loopback_request(self):\n if loopback_host((self.headers.get('Host') or '').strip().lower()):\n return True\n self.send_error(403)\n return False\n\n def do_GET(self):\n if not self.loopback_request():\n return None\n path = urlsplit(self.path).path\n if path == '/orca-desktop-page.js':\n return self.send_bytes(read_file(PAGE_SCRIPT), 'text/javascript; charset=utf-8')\n if path == '/vnc.html':\n page = read_file(os.path.join(self.server.web, 'vnc.html'))\n return self.send_bytes(page.replace(b'', PAGE_TAG, 1), 'text/html; charset=utf-8', PAGE_CSP)\n return super().do_GET()\n\n def do_HEAD(self):\n if not self.loopback_request():\n return None\n return super().do_HEAD()\n\n def send_bytes(self, body, content_type, csp=None):\n self.send_response(200)\n self.send_header('Content-Type', content_type)\n if csp:\n self.send_header('Content-Security-Policy', csp)\n self.send_header('Content-Length', str(len(body)))\n self.send_header('Cache-Control', 'no-store')\n self.send_header('Referrer-Policy', 'no-referrer')\n self.end_headers()\n self.wfile.write(body)\n return None\n\n\ndef read_file(path):\n with open(path, 'rb') as handle:\n return handle.read()\n\n\ndef main(argv):\n parser = argparse.ArgumentParser()\n parser.add_argument('--listen', type=int, required=True)\n parser.add_argument('--web', required=True)\n parser.add_argument('--unix-target', required=True)\n parser.add_argument('--tickets', required=True)\n args = parser.parse_args(argv)\n logging.basicConfig(level=logging.INFO, format='%(message)s')\n server = websocketproxy.WebSocketProxy(\n RequestHandlerClass=TicketedProxyHandler,\n listen_host='127.0.0.1',\n listen_port=args.listen,\n web=args.web,\n file_only=True,\n unix_target=args.unix_target,\n )\n server.orca_tickets_dir = os.path.abspath(args.tickets)\n server.start_server()\n\n\nif __name__ == '__main__':\n main(sys.argv[1:])\n","orca-desktop-page.js":"// Orca's glue for noVNC, injected into vnc.html by orca-desktop-proxy. Orca (the desktop app's\n// main process) drives it through window.orcaDesktop:\n// - tickets: the page never connects by itself. Each WebSocket needs a fresh single-use ticket,\n// which Orca hands over with connect(ticket) after nextTicketRequest() resolves: 'ticket' on\n// load or after a session dropped, 'retry' after an attempt that never connected. Tickets live\n// only in memory here: never in the URL, storage or noVNC's settings.\n// - pause(message, label): Orca stopped retrying; show why, with a Reconnect button. resume()\n// (Orca reopened the desktop) or the button asks for a ticket again.\n// - fit: resize=remote makes Xvnc follow the tab; a server without SetDesktopSize gets scaling.\n// - clipboard: a copy inside the desktop right after user input there goes to this computer's\n// clipboard; paste(text, chord) sets the sandbox's clipboard first, then replays the paste key.\nimport UI from './app/ui.js';\nimport * as WebUtil from './app/webutil.js';\n\nconst WS_PATH = 'websockify';\nconst RESIZE_FALLBACK_MS = 1500;\n// A copy inside the desktop follows a copy key or a selection (drag or double-click) there; other\n// clipboard changes (an agent in the sandbox running xclip) must not overwrite this computer's\n// clipboard. Plain typing does not open the window.\nconst USER_COPY_WINDOW_MS = 1500;\nconst DRAG_PX = 4;\nconst MAX_CLIPBOARD_CHARS = 1 << 20;\nconst TICKET_RE = /^[A-Za-z0-9_-]{43}$/;\nconst XK = { v: 0x0076, V: 0x0056, Insert: 0xff63, Shift_L: 0xffe1, Super_L: 0xffeb, Super_R: 0xffec };\n\nlet state = 'idle'; // idle | connecting | connected | disconnected | paused\nlet attemptConnected = true;\nlet ticketWaiters = [];\nlet lastCopyIntent = 0;\nlet pointerStart = null;\n\nconst style = document.createElement('style');\n// Orca owns connecting and the chrome: noVNC's Connect button would only try without a ticket,\n// and its control bar duplicates what the Orca tab does (fit, clipboard, keyboard release).\n// The pause card follows Orca's theme: Orca sets Electron's theme source from its own setting, so\n// prefers-color-scheme here is Orca's light or dark, and it follows a switch while paused. The\n// page cannot read Orca's tokens, so these mirror them (popover, border, focus ring).\nstyle.textContent = `\n#noVNC_connect_dlg, #noVNC_control_bar_anchor { display: none !important; }\n#orca_pause { --orca-scrim: rgb(10 10 10 / 0.88); --orca-card: #171717; --orca-text: #f0f0f0;\n --orca-border: rgb(255 255 255 / 0.1); --orca-control: rgb(255 255 255 / 0.25);\n --orca-control-hover: rgb(255 255 255 / 0.08); --orca-focus: color-mix(in oklab, #f0f0f0 65%, #0a0a0a);\n color-scheme: dark; position: fixed; inset: 0; display: none; align-items: center;\n justify-content: center; padding: 16px; background: var(--orca-scrim); z-index: 1000;\n font: 13px/1.5 system-ui, sans-serif; }\n@media (prefers-color-scheme: light) {\n #orca_pause { --orca-scrim: rgb(255 255 255 / 0.88); --orca-card: #fff; --orca-text: #0a0a0a;\n --orca-border: #e5e5e5; --orca-control: #a3a3a3; --orca-control-hover: #f5f5f5;\n --orca-focus: color-mix(in oklab, #0a0a0a 55%, #fff); color-scheme: light; }\n}\n#orca_pause.open { display: flex; }\n#orca_pause > div { max-width: 360px; padding: 16px; border: 1px solid var(--orca-border);\n border-radius: 8px; background: var(--orca-card); color: var(--orca-text); text-align: center;\n box-shadow: 0 8px 24px rgb(0 0 0 / 0.16); }\n#orca_pause p { margin: 0; }\n#orca_pause button { margin-top: 12px; padding: 4px 12px; border: 1px solid var(--orca-control);\n border-radius: 6px; background: transparent; color: var(--orca-text); font: inherit; cursor: pointer; }\n#orca_pause button:hover { background: var(--orca-control-hover); }\n#orca_pause button:focus-visible { outline: 2px solid var(--orca-focus); outline-offset: 2px; }\n@media (prefers-reduced-motion: no-preference) {\n #orca_pause.open > div { animation: orca-pause-in 120ms ease-out; }\n}\n@keyframes orca-pause-in { from { opacity: 0; transform: translateY(4px); } }\n`;\ndocument.head.appendChild(style);\n\nconst overlay = document.createElement('div');\noverlay.id = 'orca_pause';\noverlay.innerHTML = '

';\noverlay.querySelector('button').addEventListener('click', () => resume());\ndocument.addEventListener('DOMContentLoaded', () => document.body.appendChild(overlay));\n\nwindow.addEventListener('keydown', (event) => {\n if ((event.ctrlKey || event.metaKey) && !event.altKey && (event.key === 'c' || event.key === 'C' || event.key === 'Insert')) {\n lastCopyIntent = Date.now();\n }\n}, true);\nwindow.addEventListener('pointerdown', (event) => {\n pointerStart = { x: event.clientX, y: event.clientY };\n}, true);\n// A double- or triple-click selects a word or line (pointer events carry no click count).\nwindow.addEventListener('mousedown', (event) => {\n if (event.detail >= 2) {\n lastCopyIntent = Date.now();\n }\n}, true);\nwindow.addEventListener('pointerup', (event) => {\n if (pointerStart && Math.hypot(event.clientX - pointerStart.x, event.clientY - pointerStart.y) >= DRAG_PX) {\n lastCopyIntent = Date.now();\n }\n pointerStart = null;\n}, true);\n\nfunction requestTicket() {\n const answer = attemptConnected ? 'ticket' : 'retry';\n const waiters = ticketWaiters;\n ticketWaiters = [];\n waiters.forEach((resolve) => resolve(answer));\n}\n\nasync function whenStarted() {\n while (document.documentElement.classList.contains('noVNC_loading')) {\n await new Promise((resolve) => setTimeout(resolve, 25));\n }\n}\n\nfunction onConnect() {\n state = 'connected';\n attemptConnected = true;\n UI.hideStatus();\n setTimeout(() => {\n // Xvnc answers SetDesktopSize; anything that does not gets scaled into the tab instead.\n if (UI.rfb && state === 'connected' && !UI.rfb._supportsSetDesktopSize) {\n UI.rfb.scaleViewport = true;\n }\n }, RESIZE_FALLBACK_MS);\n}\n\nfunction onDisconnect() {\n if (state === 'paused') {\n return;\n }\n state = 'disconnected';\n // The ticket is spent: ask Orca for a new one (a dropped forward, a restart of the desktop).\n requestTicket();\n}\n\nfunction onClipboard(event) {\n const text = event.detail && event.detail.text;\n if (typeof text !== 'string' || text.length > MAX_CLIPBOARD_CHARS) {\n return;\n }\n if (Date.now() - lastCopyIntent > USER_COPY_WINDOW_MS || !document.hasFocus()) {\n return;\n }\n navigator.clipboard.writeText(text).catch(() => {});\n}\n\nasync function connect(ticket) {\n if (typeof ticket !== 'string' || !TICKET_RE.test(ticket)) {\n return 'invalid';\n }\n if (state === 'connecting' || state === 'connected' || state === 'paused') {\n return state;\n }\n await whenStarted();\n if (UI.rfb) {\n return 'connecting';\n }\n state = 'connecting';\n attemptConnected = false;\n // UI.connect reads the path setting synchronously and opens the WebSocket in RFB's constructor;\n // the in-memory setting is restored right after, so the ticket is never kept or shown.\n // Only ever this page's own origin: noVNC also reads host, port, encrypt, repeater and logging\n // from the URL, which must never send a ticketed session (and the user's keys) elsewhere.\n WebUtil.setSetting('host', '');\n WebUtil.setSetting('port', '');\n WebUtil.setSetting('encrypt', false);\n WebUtil.setSetting('repeaterID', '');\n WebUtil.setSetting('path', `${WS_PATH}?ticket=${ticket}`);\n try {\n UI.connect();\n } finally {\n WebUtil.setSetting('path', WS_PATH);\n }\n const rfb = UI.rfb;\n if (!rfb) {\n state = 'disconnected';\n requestTicket();\n return 'failed';\n }\n rfb.addEventListener('connect', onConnect);\n rfb.addEventListener('disconnect', onDisconnect);\n rfb.addEventListener('clipboard', onClipboard);\n return 'connecting';\n}\n\n/** Pastes this computer's clipboard: the sandbox's clipboard first, then the paste key itself. */\nfunction paste(text, chord) {\n const rfb = UI.rfb;\n if (!rfb || state !== 'connected' || typeof text !== 'string' || text.length > MAX_CLIPBOARD_CHARS) {\n return false;\n }\n rfb.clipboardPasteFrom(text);\n if (chord && chord.kind === 'mac') {\n // Cmd+V: Linux apps do not paste on Super+V, so release Super and send Shift+Insert.\n rfb.sendKey(XK.Super_L, 'MetaLeft', false);\n rfb.sendKey(XK.Super_R, 'MetaRight', false);\n rfb.sendKey(XK.Shift_L, 'ShiftLeft', true);\n rfb.sendKey(XK.Insert, 'Insert');\n rfb.sendKey(XK.Shift_L, 'ShiftLeft', false);\n } else if (chord && chord.kind === 'replay') {\n // Ctrl+V, Ctrl+Shift+V, Shift+Insert: the held modifiers already reached the sandbox.\n const insert = chord.key === 'Insert';\n rfb.sendKey(insert ? XK.Insert : chord.key === 'V' ? XK.V : XK.v, insert ? 'Insert' : 'KeyV');\n }\n return true;\n}\n\nfunction pause(message, label, tone) {\n state = 'paused';\n if (UI.rfb) {\n UI.rfb.disconnect();\n }\n UI.hideStatus();\n overlay.querySelector('p').textContent = String(message || '');\n overlay.querySelector('button').textContent = String(label || 'Reconnect');\n // Asleep and starting are states, not errors: announce them politely.\n overlay.firstElementChild.setAttribute('role', tone === 'neutral' ? 'status' : 'alert');\n overlay.classList.add('open');\n overlay.querySelector('button').focus();\n return true;\n}\n\nfunction resume() {\n if (state !== 'paused') {\n return false;\n }\n overlay.classList.remove('open');\n state = 'disconnected';\n attemptConnected = true; // a person asked: start the retry budget over\n requestTicket();\n return true;\n}\n\nObject.defineProperty(window, 'orcaDesktop', {\n value: Object.freeze({\n version: 1,\n state: () => state,\n /** Resolves when the page needs a ticket: now if it is not connected, else after a drop. */\n nextTicketRequest: () => new Promise((resolve) => {\n ticketWaiters.push(resolve);\n if (state === 'idle' || state === 'disconnected') {\n requestTicket();\n }\n }),\n connect,\n paste,\n pause,\n resume\n })\n});\n","orca-sandbox-grant":"#!/usr/bin/env bash\n# Mints a fresh, single-device orcad pairing offer for one org member's device and seals it to that\n# device's X25519 key, so the control plane relays only ciphertext (the VM owner's E2EE model holds:\n# every member device gets its own revocable orcad credential, nobody else can read it).\n# Env from the control plane: ORCA_GRANT_ID, ORCA_GRANT_RECIPIENT_KEY (base64 raw X25519 public\n# key), ORCA_GRANT_KIND (desktop|mobile), ORCA_PUBLIC_WSS. Prints one JSON line:\n# {\"deviceId\":\"…\",\"sealed\":{\"v\":1,\"ephemeralPublicKeyB64\":\"…\",\"nonceB64\":\"…\",\"ciphertextB64\":\"…\"}}\n# Sealing: ECDH(ephemeral, recipient) -> HKDF-SHA256(salt = eph||recipient, info\n# \"orca-cloud-grant-v1\") -> AES-256-GCM, AAD \"orca-cloud-grant-v1:\".\nset -euo pipefail\numask 077\nORCA_HOME=/opt/orca\nSTATE=\"$HOME/.orca-sandbox\"\nDATA=\"$HOME/.orcad-data\"\nmkdir -p \"$STATE\"\nexec 9>\"$STATE/grant.lock\"\n# One mint at a time: --rotate retires unredeemed offers, so two members never share a credential.\nflock -w 30 9\n\ncase \"${ORCA_GRANT_ID:-}\" in '' | *[!A-Za-z0-9_-]*) echo 'bad grant id' >&2; exit 2 ;; esac\ncase \"${ORCA_GRANT_KIND:-}\" in desktop) scope=() ;; mobile) scope=(--mobile) ;; *) echo 'bad kind' >&2; exit 2 ;; esac\ncase \"${ORCA_GRANT_RECIPIENT_KEY:-}\" in '' | *[!A-Za-z0-9+/=]*) echo 'bad key' >&2; exit 2 ;; esac\ncase \"${ORCA_PUBLIC_WSS:-}\" in wss://*) ;; *) echo 'bad endpoint' >&2; exit 2 ;; esac\n\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node | head -n 1)\"\ncli=\"$ORCA_HOME/runtime/orcad/out/cli/index.js\"\n# The workspace's project must exist on this orcad before a phone opens it: phones may not add\n# projects themselves (mobile RPC scope), and a phone may be the first client this VM ever sees.\nproject=/vercel/sandbox/project\nif [ -d \"$project\" ]; then\n kind=folder\n [ -d \"$project/.git\" ] && kind=git\n ORCA_USER_DATA_PATH=\"$DATA\" timeout 20 \"$node\" \"$cli\" repo add --path \"$project\" --kind \"$kind\" --json \\\n >/dev/null 2>&1 || true\nfi\noffer=\"$(ORCA_USER_DATA_PATH=\"$DATA\" timeout 30 \"$node\" \"$cli\" pair --json --rotate \"${scope[@]}\" \\\n --address \"$ORCA_PUBLIC_WSS\" 2>/dev/null)\" || { echo 'pair failed' >&2; exit 1; }\n# This workspace's read-snapshot signer (public half), so the paired device pins it; best effort.\nsigner=\"$(\"$node\" \"$ORCA_HOME/bin/orca-read-snapshot.cjs\" --signer-public-key 2>/dev/null || true)\"\n# The offer goes to the sealer on stdin (never argv or disk); only the sealed form is printed.\n# Granted devices are listed so orcad can keep owner-only features (local commands) from them.\nprintf '%s' \"$offer\" | ORCA_GRANT_ID=\"$ORCA_GRANT_ID\" ORCA_GRANT_RECIPIENT_KEY=\"$ORCA_GRANT_RECIPIENT_KEY\" ORCA_READ_SIGNER=\"$signer\" \\\n ORCA_GRANT_LEDGER=\"$STATE/grant-devices\" \"$node\" -e '\nconst crypto = require(\"node:crypto\")\nlet input = \"\"\nprocess.stdin.on(\"data\", (c) => (input += c)).on(\"end\", () => {\n const res = JSON.parse(input).result\n if (!res || res.available !== true || !res.pairingUrl || !res.deviceId) {\n console.error(\"offer unavailable\")\n process.exit(1)\n }\n if (process.env.ORCA_GRANT_LEDGER) require(\"node:fs\").appendFileSync(process.env.ORCA_GRANT_LEDGER, res.deviceId + \"\\n\", { mode: 0o600 })\n const recipient = Buffer.from(process.env.ORCA_GRANT_RECIPIENT_KEY, \"base64\")\n if (recipient.length !== 32) { console.error(\"bad key length\"); process.exit(2) }\n const recipientKey = crypto.createPublicKey({ key: { kty: \"OKP\", crv: \"X25519\", x: recipient.toString(\"base64url\") }, format: \"jwk\" })\n const eph = crypto.generateKeyPairSync(\"x25519\")\n const ephRaw = Buffer.from(eph.publicKey.export({ format: \"jwk\" }).x, \"base64url\")\n const shared = crypto.diffieHellman({ privateKey: eph.privateKey, publicKey: recipientKey })\n const key = Buffer.from(crypto.hkdfSync(\"sha256\", shared, Buffer.concat([ephRaw, recipient]), \"orca-cloud-grant-v1\", 32))\n const nonce = crypto.randomBytes(12)\n const cipher = crypto.createCipheriv(\"aes-256-gcm\", key, nonce)\n cipher.setAAD(Buffer.from(\"orca-cloud-grant-v1:\" + process.env.ORCA_GRANT_ID))\n const signer = /^[A-Za-z0-9+/]{43}=$/.test(process.env.ORCA_READ_SIGNER || \"\") ? process.env.ORCA_READ_SIGNER : undefined\n const body = Buffer.from(JSON.stringify({ pairingUrl: res.pairingUrl, deviceId: res.deviceId, scope: res.scope, ...(signer ? { readSignerPublicKeyB64: signer } : {}) }))\n const ct = Buffer.concat([cipher.update(body), cipher.final(), cipher.getAuthTag()])\n process.stdout.write(JSON.stringify({ deviceId: res.deviceId, sealed: { v: 1, ephemeralPublicKeyB64: ephRaw.toString(\"base64\"), nonceB64: nonce.toString(\"base64\"), ciphertextB64: ct.toString(\"base64\") } }) + \"\\n\")\n})'\n","orca-sandbox-revoke":"#!/usr/bin/env bash\n# Revokes member devices from orcad's device registry. orcad adopts on-disk removals at every auth\n# (an admin revoke, device-registry-store.ts), so this works whether or not orcad is running; the\n# control plane also runs it at boot, before orcad starts, for revokes queued while suspended.\n# Env: ORCA_REVOKE_DEVICE_IDS (comma list of orcad device ids). Prints {\"revoked\":,\"live\":}.\nset -euo pipefail\numask 077\nSTATE=\"$HOME/.orca-sandbox\"\nREGISTRY=\"$HOME/.orcad-data/orca-devices.json\"\nmkdir -p \"$STATE\"\nexec 9>\"$STATE/grant.lock\"\nflock -w 30 9\n\nids=\"${ORCA_REVOKE_DEVICE_IDS:-}\"\ncase \"$ids\" in '' | *[!A-Za-z0-9,_-]*) echo 'bad device ids' >&2; exit 2 ;; esac\n# Live orcad first: `orca pair --revoke` also closes the device's open sockets now. Older orcad\n# builds lack it; the on-disk removal below still blocks their next sign-in.\nORCA_HOME=/opt/orca\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node 2>/dev/null | head -n 1)\"\ncli=\"$ORCA_HOME/runtime/orcad/out/cli/index.js\"\nlive=0\nif [ -n \"$node\" ] && [ -f \"$cli\" ] && [ -s \"$HOME/.orcad-data/orca-runtime.json\" ]; then\n for id in ${ids//,/ }; do\n if ORCA_USER_DATA_PATH=\"$HOME/.orcad-data\" timeout 15 \"$node\" \"$cli\" pair --revoke \"$id\" --json 2>/dev/null \\\n | jq -e '.ok == true and .result.revoked == true' >/dev/null 2>&1; then\n live=$((live + 1))\n fi\n done\nfi\nif [ ! -s \"$REGISTRY\" ]; then\n echo \"{\\\"revoked\\\":$live,\\\"live\\\":$live}\"\n exit 0\nfi\nids_json=\"$(jq -cn --arg ids \"$ids\" '$ids | split(\",\") | map(select(length > 0))')\"\nbefore=\"$(jq 'length' \"$REGISTRY\")\"\ntmp=\"$(mktemp \"$REGISTRY.revoke.XXXXXX\")\"\n# Removes the rows and any phones delegated through them (parentDeviceId), like orcad's own revoke.\njq --argjson ids \"$ids_json\" \\\n '[.[] | select((.deviceId as $d | $ids | index($d)) == null and ((.parentDeviceId // \"\") as $p | $ids | index($p)) == null)]' \\\n \"$REGISTRY\" >\"$tmp\"\nchmod 0600 \"$tmp\"\nsync \"$tmp\" 2>/dev/null || true\nmv -f \"$tmp\" \"$REGISTRY\"\nafter=\"$(jq 'length' \"$REGISTRY\")\"\necho \"{\\\"revoked\\\":$((live + before - after)),\\\"live\\\":$live}\"\n","orca-sandbox-members":"#!/usr/bin/env bash\n# Runs as root (control plane, sudo). Installs who-may-push-as-whom for `orca-git-as`:\n# /etc/orca/members/.gitconfig egress placeholder header + GitHub author identity\n# /etc/orca/members/devices.json {\"\": \"\"} (each member's paired devices)\n# Both root-only: agents never read another member's placeholder. `orca-git-identity` (root, via a\n# sudoers rule) hands an agent only the identity of the member whose device drove its turn, as orcad\n# attests it. No token lives here: the placeholder is swapped at egress, for this repository only.\n# Env: ORCA_MEMBERS_B64 = base64 JSON [{userId, name, email, authorization, deviceIds:[...]}]\n# The VM user agents run as is orcad's own user (detected). Prints {\"members\":,\"devices\":}.\nset -euo pipefail\numask 077\nDIR=/etc/orca/members\norcad_pid=\"$(pgrep -f orcad-server.js | head -n 1 || true)\"\nUSER_NAME=\"$( [ -n \"$orcad_pid\" ] && ps -o user= -p \"$orcad_pid\" | tr -d ' ' || true)\"\nUSER_NAME=\"${USER_NAME:-${ORCA_SANDBOX_USER:-${SUDO_USER:-}}}\"\ncase \"$USER_NAME\" in '' | *[!a-z0-9_-]*) echo 'bad user' >&2; exit 2 ;; esac\nmkdir -p /etc/orca\njson=\"$(printf '%s' \"${ORCA_MEMBERS_B64:-W10=}\" | base64 -d)\"\ntmp=\"$(mktemp -d /etc/orca/members.XXXXXX)\"\ncount=0\nwhile IFS= read -r row; do\n user=\"$(jq -r '.userId' <<<\"$row\")\"\n case \"$user\" in '' | *[!A-Za-z0-9_.:-]*) continue ;; esac\n file=\"$tmp/$(printf '%s' \"$user\" | tr ':' '_').gitconfig\"\n # An empty extraheader first resets the image-wide (app identity) header for this member's git.\n git config -f \"$file\" --add http.https://github.com/.extraheader ''\n git config -f \"$file\" --add http.https://github.com/.extraheader \"Authorization: $(jq -r '.authorization' <<<\"$row\")\"\n git config -f \"$file\" user.name \"$(jq -r '.name' <<<\"$row\")\"\n git config -f \"$file\" user.email \"$(jq -r '.email' <<<\"$row\")\"\n count=$((count + 1))\ndone < <(jq -c '.[]' <<<\"$json\")\njq -c '[.[] | select(.userId | test(\"^[A-Za-z0-9_.:-]+$\")) | .userId as $u | (.deviceIds // [])[] | select(test(\"^[A-Za-z0-9_-]{1,128}$\")) | {key: ., value: $u}] | from_entries' <<<\"$json\" >\"$tmp/devices.json\"\ndevices=\"$(jq 'length' \"$tmp/devices.json\")\"\nchmod 0700 \"$tmp\" && chmod 0600 \"$tmp\"/*\nrm -rf \"$DIR.old\"\n[ -d \"$DIR\" ] && mv \"$DIR\" \"$DIR.old\"\nmv \"$tmp\" \"$DIR\"\nrm -rf \"$DIR.old\"\n# Agents may ask for their own sender's identity, and only that (the helper checks with orcad).\nrule=\"$USER_NAME ALL=(root) NOPASSWD:SETENV: /opt/orca/bin/orca-git-identity\"\nprintf '%s\\n' \"$rule\" >/etc/sudoers.d/orca-git-identity.new\nchmod 0440 /etc/sudoers.d/orca-git-identity.new\nif visudo -cf /etc/sudoers.d/orca-git-identity.new >/dev/null; then\n mv -f /etc/sudoers.d/orca-git-identity.new /etc/sudoers.d/orca-git-identity\nelse\n rm -f /etc/sudoers.d/orca-git-identity.new\n echo 'sudoers rule rejected' >&2\n exit 1\nfi\n# Pre-attestation layout (agent-readable member files) must not linger.\nuser_home=\"$(getent passwd \"$USER_NAME\" | cut -d: -f6)\"\n[ -n \"$orcad_pid\" ] && user_home=\"$(tr '\\0' '\\n' <\"/proc/$orcad_pid/environ\" | sed -n 's/^HOME=//p' | head -n 1)\"\n[ -n \"$user_home\" ] && rm -rf \"$user_home/.orca-sandbox/members\"\n# Commits outside a teammate's turn still need an author (images older than orca-sandbox-start's default).\nif [ -n \"$user_home\" ]; then\n sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.email >/dev/null 2>&1 \\\n || sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.email 'cloud@users.noreply.orca.dev'\n sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.name >/dev/null 2>&1 \\\n || sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.name 'Orca Cloud'\nfi\n# CREATE-GIT-ASYNC: identities are in place, so `git` stops holding commits and pushes for this boot.\nif [ -n \"$user_home\" ] && [ -d \"$user_home/.orca-sandbox\" ]; then\n sudo -u \"$USER_NAME\" sh -c 'rm -f \"$1/git-setup.pending\" \"$1/git-setup.failed\" && date +%s >\"$1/git-setup.ready\"' \\\n _ \"$user_home/.orca-sandbox\" || true\nfi\necho \"{\\\"members\\\":$count,\\\"devices\\\":$devices}\"\n","orca-sandbox-read-snapshot":"#!/usr/bin/env bash\n# Seals a read-only snapshot of this workspace (chats, agent status, git, PR) to the reader keys the\n# control plane passes, right before the drain. Only ciphertext is printed (one JSON line); see\n# orca-read-snapshot.cjs. Exit 3: orcad isn't running (nothing to read).\nset -euo pipefail\numask 077\nORCA_HOME=/opt/orca\nexport ORCA_USER_DATA_PATH=\"${ORCA_USER_DATA_PATH:-$HOME/.orcad-data}\"\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node | head -n 1)\"\n[ -n \"$node\" ] || { echo 'node not found' >&2; exit 1; }\nexec timeout 35 \"$node\" \"$ORCA_HOME/bin/orca-read-snapshot.cjs\"\n","orca-read-snapshot.cjs":"#!/usr/bin/env node\n// Gathers what a sleeping workspace should still show (recent chats per agent session, agent\n// status, git status/diff summary, PR link) from the running orcad over its local socket, then seals\n// it to the reader keys the control plane passed, so only ciphertext leaves the VM.\n// Run by orca-sandbox-read-snapshot right before the drain. Env:\n// ORCA_READ_SNAPSHOT_ID rsn_<24 hex> ORCA_READ_WORKSPACE_ID sbx_<24 hex>\n// ORCA_READ_RECIPIENTS JSON [{keyId, publicKeyB64}] ORCA_READ_REASON sleep|rollover\n// ORCA_USER_DATA_PATH orcad's data dir (orca-runtime.json lives there)\n// Prints one JSON line: {v:1, snapshotId, workspaceId, nonceB64, ciphertextB64, recipients[], stats}.\n// Construction (keep in sync with src/shared/cloud-workspaces/read-snapshot.ts in the app):\n// body = AES-256-GCM(dek, nonce, JSON, AAD \"orca-cloud-read-v1::\")\n// wrapped = AES-256-GCM(HKDF-SHA256(X25519(eph, reader), eph||reader, \"orca-cloud-read-wrap-v1\"),\n// nonce_i, dek, AAD \"orca-cloud-read-wrap-v1:::\")\n'use strict'\nconst crypto = require('node:crypto')\nconst fs = require('node:fs')\nconst net = require('node:net')\nconst path = require('node:path')\n\nconst INFO = 'orca-cloud-read-v1'\nconst WRAP_INFO = 'orca-cloud-read-wrap-v1'\nconst PROJECT = '/vercel/sandbox/project'\nconst LIMITS = { sessions: 12, messagesPerSession: 60, messageChars: 4000, changes: 200, commits: 10 }\n/** Plaintext budget: sealed + base64 it stays under the control plane's 220 kB and the 256 kB stdout cap. */\nconst MAX_PLAIN_BYTES = 150_000\nconst CALL_TIMEOUT_MS = 5_000\nconst TOTAL_BUDGET_MS = 20_000\n\nfunction b64(bytes) {\n return Buffer.from(bytes).toString('base64')\n}\n\nfunction x25519PublicKey(raw) {\n return crypto.createPublicKey({ key: { kty: 'OKP', crv: 'X25519', x: Buffer.from(raw).toString('base64url') }, format: 'jwk' })\n}\n\nfunction gcmSeal(key, nonce, plain, aad) {\n const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce)\n cipher.setAAD(Buffer.from(aad))\n return Buffer.concat([cipher.update(plain), cipher.final(), cipher.getAuthTag()])\n}\n\nconst SIG_INFO = 'orca-cloud-read-sig-v1'\n\n/**\n * This VM's snapshot signing key (Ed25519), created once and kept on the VM's own disk (0600, never\n * printed). Devices pin its public half per workspace, so a snapshot the control plane forged or\n * swapped doesn't verify. Same file for the grant script, which hands the public half to pairing devices.\n */\nfunction signingKey(stateDir) {\n if (!path.isAbsolute(stateDir)) {\n throw new Error('no state dir')\n }\n const file = path.join(stateDir, 'read-signing-key.pem')\n const load = () => {\n try {\n const privateKey = crypto.createPrivateKey(fs.readFileSync(file, 'utf8'))\n return privateKey.asymmetricKeyType === 'ed25519' ? privateKey : null\n } catch {\n return null\n }\n }\n let privateKey = load()\n if (!privateKey) {\n fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 })\n // Written whole to a temp file, then linked into place: atomic, and a concurrent first run\n // (grant + capture) keeps whichever key landed first. A broken file is replaced (devices that\n // pinned the old signer then see \"signer changed\" until they trust the new one).\n const temp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}`\n fs.writeFileSync(temp, crypto.generateKeyPairSync('ed25519').privateKey.export({ format: 'pem', type: 'pkcs8' }), { mode: 0o600 })\n try {\n fs.linkSync(temp, file)\n } catch {\n if (!load()) {\n fs.renameSync(temp, file)\n }\n } finally {\n fs.rmSync(temp, { force: true })\n }\n privateKey = load()\n if (!privateKey) {\n throw new Error('signing key unavailable')\n }\n }\n const publicKeyB64 = Buffer.from(crypto.createPublicKey(privateKey).export({ format: 'jwk' }).x, 'base64url').toString('base64')\n return { privateKey, publicKeyB64 }\n}\n\n/** Signs the sealed body: \"orca-cloud-read-sig-v1::::\" || ciphertext bytes. */\nfunction sign(out, key) {\n const message = Buffer.concat([\n Buffer.from(`${SIG_INFO}:${out.workspaceId}:${out.snapshotId}:${out.nonceB64}:`),\n Buffer.from(out.ciphertextB64, 'base64')\n ])\n return { publicKeyB64: key.publicKeyB64, signatureB64: crypto.sign(null, message, key.privateKey).toString('base64') }\n}\n\n/** Seals `payload` (an object) to every recipient; a malformed key is skipped, not fatal. */\nfunction seal(payload, recipients, workspaceId, snapshotId) {\n const scope = `${workspaceId}:${snapshotId}`\n const dek = crypto.randomBytes(32)\n const nonce = crypto.randomBytes(12)\n const ciphertext = gcmSeal(dek, nonce, Buffer.from(JSON.stringify(payload)), `${INFO}:${scope}`)\n const sealedTo = []\n for (const recipient of recipients) {\n const reader = Buffer.from(String(recipient.publicKeyB64 || ''), 'base64')\n if (reader.length !== 32 || !/^rk_[0-9a-f]{24}$/.test(String(recipient.keyId || ''))) {\n continue\n }\n let shared\n const eph = crypto.generateKeyPairSync('x25519')\n try {\n shared = crypto.diffieHellman({ privateKey: eph.privateKey, publicKey: x25519PublicKey(reader) })\n } catch {\n continue\n }\n if (shared.every((byte) => byte === 0)) {\n continue\n }\n const ephRaw = Buffer.from(eph.publicKey.export({ format: 'jwk' }).x, 'base64url')\n const kek = Buffer.from(crypto.hkdfSync('sha256', shared, Buffer.concat([ephRaw, reader]), WRAP_INFO, 32))\n const wrapNonce = crypto.randomBytes(12)\n const wrapped = gcmSeal(kek, wrapNonce, dek, `${WRAP_INFO}:${scope}:${recipient.keyId}`)\n sealedTo.push({ keyId: recipient.keyId, ephemeralPublicKeyB64: b64(ephRaw), nonceB64: b64(wrapNonce), wrappedKeyB64: b64(wrapped) })\n }\n dek.fill(0)\n return { v: 1, snapshotId, workspaceId, nonceB64: b64(nonce), ciphertextB64: b64(ciphertext), recipients: sealedTo }\n}\n\n// --- gathering -------------------------------------------------------------------------------\n\n/** One request per connection, like the CLI. A streaming method resolves on its first frame. */\nfunction rpc(metadata, method, params, timeoutMs = CALL_TIMEOUT_MS) {\n return new Promise((resolve) => {\n const transport = (metadata.transports || [metadata.transport]).find((t) => t && (t.kind === 'unix' || t.kind === 'named-pipe'))\n if (!transport) {\n resolve(null)\n return\n }\n const socket = net.createConnection(transport.endpoint)\n const id = crypto.randomUUID()\n let buffer = ''\n let done = false\n const finish = (value) => {\n if (!done) {\n done = true\n clearTimeout(timer)\n socket.destroy()\n resolve(value)\n }\n }\n const timer = setTimeout(() => finish(null), timeoutMs)\n socket.setEncoding('utf8')\n socket.on('error', () => finish(null))\n socket.on('close', () => finish(null))\n socket.on('connect', () => socket.write(`${JSON.stringify({ id, authToken: metadata.authToken, method, params })}\\n`))\n socket.on('data', (chunk) => {\n buffer += chunk\n let newline\n while ((newline = buffer.indexOf('\\n')) !== -1) {\n const line = buffer.slice(0, newline)\n buffer = buffer.slice(newline + 1)\n let frame\n try {\n frame = JSON.parse(line)\n } catch {\n continue\n }\n if (!frame || frame.id !== id) {\n continue\n }\n finish(frame.ok === true ? frame.result : null)\n return\n }\n })\n })\n}\n\nconst isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v)\nconst str = (v, max) => (typeof v === 'string' ? v.slice(0, max) : null)\nconst num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null)\n\nfunction messagesOf(history) {\n const items = isObj(history) && isObj(history.page) && Array.isArray(history.page.items) ? history.page.items : []\n const out = []\n for (const item of items) {\n const body = isObj(item) && isObj(item.body) ? item.body : null\n if (!body || body.kind !== 'message' || !Array.isArray(body.blocks)) {\n continue\n }\n const role = body.role\n if (role !== 'user' && role !== 'assistant' && role !== 'system') {\n continue\n }\n const text = body.blocks\n .filter((b) => isObj(b) && b.type === 'text' && typeof b.text === 'string')\n .map((b) => b.text)\n .join('\\n')\n .trim()\n if (text) {\n out.push({ itemId: String(item.itemId).slice(0, 200), role, text: text.slice(0, LIMITS.messageChars), at: num(item.observedAt) ?? 0 })\n }\n }\n return { messages: out, hasOlder: isObj(history) && isObj(history.page) && history.page.hasOlder === true }\n}\n\nfunction gitOf(status, log) {\n if (!isObj(status) && !isObj(log)) {\n return null\n }\n const entries = isObj(status) && Array.isArray(status.entries) ? status.entries.filter(isObj) : []\n let added = 0\n let removed = 0\n let counted = false\n const changes = entries.slice(0, LIMITS.changes).map((e) => {\n const a = num(e.added)\n const r = num(e.removed)\n if (a !== null || r !== null) {\n counted = true\n }\n return { path: String(e.path).slice(0, 1024), status: str(e.status, 20) || 'modified', area: str(e.area, 20) || 'unstaged', added: a, removed: r }\n })\n for (const e of entries) {\n added += num(e.added) || 0\n removed += num(e.removed) || 0\n }\n const items = isObj(log) && Array.isArray(log.items) ? log.items.filter(isObj) : []\n return {\n branch: isObj(status) && typeof status.branch === 'string' ? status.branch.replace(/^refs\\/heads\\//, '') : null,\n head: isObj(status) ? str(status.head, 64) : null,\n changes,\n changeCount: entries.length,\n added: counted ? added : null,\n removed: counted ? removed : null,\n commits: items.slice(0, LIMITS.commits).map((c) => ({\n id: String(c.id).slice(0, 64),\n subject: String(c.subject ?? '').slice(0, 300),\n author: str(c.author, 120),\n at: num(c.timestamp)\n }))\n }\n}\n\nfunction prOf(linked) {\n if (!isObj(linked) || num(linked.number) === null || typeof linked.url !== 'string' || !linked.url.startsWith('https://')) {\n return null\n }\n return {\n number: linked.number,\n title: String(linked.title ?? '').slice(0, 300),\n state: str(linked.state, 20) || 'open',\n url: linked.url.slice(0, 500),\n checksStatus: str(linked.checksStatus, 20),\n checksSummary: str(linked.checksSummary, 200)\n }\n}\n\nconst STATUSES = new Set(['working', 'attention', 'idle'])\nconst PANE_PREFIX = 'structured-agent-session-'\n\n/**\n * Chats as orcad stores them (agent-session-journal.db, read-only): every session's provider, name\n * and last turn, open tabs first. The local socket can't stream agentSession.subscribeStatus, and\n * worktree.ps lists only agents that are live right now, so this is the complete list.\n */\nfunction storedSessions(dataDir) {\n let db\n try {\n const { DatabaseSync } = require('node:sqlite')\n db = new DatabaseSync(path.join(dataDir, 'agent-session-journal.db'), { readOnly: true })\n const open = new Set(db.prepare('SELECT session_id FROM agent_session_tabs').all().map((r) => r.session_id))\n const out = []\n for (const row of db.prepare('SELECT session_id, record_json FROM agent_session_records').all()) {\n let record\n try {\n record = JSON.parse(row.record_json)\n } catch {\n continue\n }\n const last = isObj(record.lastTurn) ? record.lastTurn : {}\n out.push({\n sessionId: row.session_id,\n agent: str(record.provider, 40),\n title: str(record.conversationName, 200) || str(last.prompt, 200),\n status: last.state === 'running' || last.state === 'submitted' ? 'working' : last.state === 'settled' ? 'idle' : 'unknown',\n turnOutcome: str(last.outcome, 40),\n updatedAt: num(record.updatedAt),\n open: open.has(row.session_id),\n lastPrompt: str(last.prompt, LIMITS.messageChars),\n lastReply: str(last.reply, LIMITS.messageChars),\n lastAt: num(last.at),\n // \"::\": which checkout this chat works in.\n worktreePath: isObj(record.location) && typeof record.location.workspaceId === 'string' ? record.location.workspaceId.split('::').pop() : null\n })\n }\n return out\n } catch {\n return []\n } finally {\n try {\n db && db.close()\n } catch {}\n }\n}\n\n/** Live agent rows from worktree.ps, keyed by session id (their status wins over the stored one). */\nfunction liveAgents(worktrees) {\n const out = new Map()\n for (const w of worktrees) {\n for (const a of Array.isArray(w.agents) ? w.agents.filter(isObj) : []) {\n const tab = typeof a.paneKey === 'string' ? a.paneKey.split(':')[0] : ''\n if (!tab.startsWith(PANE_PREFIX)) {\n continue\n }\n const state = a.mainAgent && typeof a.mainAgent.state === 'string' ? a.mainAgent.state : a.state\n out.set(tab.slice(PANE_PREFIX.length), {\n agent: str(a.agentType, 40),\n title: str(a.conversationName, 200) || str(a.taskTitle, 200) || str(a.displayName, 200),\n status: state === 'working' ? 'working' : state === 'blocked' || state === 'waiting' || state === 'attention' ? 'attention' : 'idle',\n updatedAt: num(a.updatedAt)\n })\n }\n }\n return out\n}\n\n/**\n * The checkout this workspace is about: the one its newest chat works in, else the most recently\n * active one (a workspace on its own branch is a separate worktree, not the base clone at\n * /vercel/sandbox/project), else the base clone.\n */\nfunction workspaceCheckout(worktrees, sessions) {\n const byPath = new Map(worktrees.filter((w) => typeof w.path === 'string').map((w) => [w.path, w]))\n const newest = sessions\n .filter((s) => s.worktreePath && byPath.has(s.worktreePath))\n .sort((a, b) => (num(b.updatedAt) || 0) - (num(a.updatedAt) || 0))[0]\n if (newest) {\n return byPath.get(newest.worktreePath)\n }\n const activity = (w) => Math.max(num(w.lastActivityAt) || 0, num(w.sortOrder) || 0)\n const ranked = [...byPath.values()].sort(\n (a, b) => activity(b) - activity(a) || Number(a.isMainWorktree === true) - Number(b.isMainWorktree === true)\n )\n return ranked[0] || null\n}\n\n/** `call(method, params)` -> result | null. Never throws: a missing piece is left out. */\nasync function gather(call, { workspaceId, snapshotId, reason, now, deadline, listSessions = () => [] }) {\n const timeLeft = () => deadline - Date.now()\n const ps = await call('worktree.ps', { limit: 20 })\n const worktrees = isObj(ps) && Array.isArray(ps.worktrees) ? ps.worktrees.filter(isObj) : []\n const stored = listSessions()\n const project = workspaceCheckout(worktrees, stored)\n const selector = project && typeof project.path === 'string' ? `path:${project.path}` : `path:${PROJECT}`\n const [status, log] = await Promise.all([\n call('git.status', { worktree: selector, includeLineStats: true }),\n call('git.history', { worktree: selector, limit: LIMITS.commits })\n ])\n const live = liveAgents(worktrees)\n const byId = new Map()\n for (const s of stored) {\n byId.set(s.sessionId, s)\n }\n for (const [sessionId, a] of live) {\n byId.set(sessionId, { ...(byId.get(sessionId) || { sessionId, open: true }), ...Object.fromEntries(Object.entries(a).filter(([, v]) => v !== null)) })\n }\n // Open chats first, then newest activity.\n const picked = [...byId.values()]\n .filter((s) => typeof s.sessionId === 'string')\n .sort((a, b) => Number(Boolean(b.open)) - Number(Boolean(a.open)) || (num(b.updatedAt) || 0) - (num(a.updatedAt) || 0))\n .slice(0, LIMITS.sessions)\n const sessions = []\n for (const s of picked) {\n const history = timeLeft() > 1_000 ? await call('agentSession.history', { sessionId: s.sessionId, direction: 'tail', limit: LIMITS.messagesPerSession }) : null\n let { messages, hasOlder } = messagesOf(history)\n if (messages.length === 0) {\n // No readable history (or out of time): the stored last turn still says what happened.\n const at = s.lastAt || s.updatedAt || 0\n messages = [\n ...(s.lastPrompt ? [{ itemId: `${s.sessionId}:last-prompt`, role: 'user', text: s.lastPrompt, at }] : []),\n ...(s.lastReply ? [{ itemId: `${s.sessionId}:last-reply`, role: 'assistant', text: s.lastReply, at }] : [])\n ]\n hasOlder = hasOlder || messages.length > 0\n }\n sessions.push({\n sessionId: s.sessionId.slice(0, 200),\n agent: s.agent || null,\n title: s.title || null,\n status: STATUSES.has(s.status) ? s.status : 'unknown',\n turnOutcome: s.turnOutcome || null,\n updatedAt: num(s.updatedAt),\n messages: messages.slice(-LIMITS.messagesPerSession),\n truncated: hasOlder || messages.length > LIMITS.messagesPerSession\n })\n }\n return {\n v: 1,\n workspaceId,\n snapshotId,\n capturedAt: now,\n reason,\n git: gitOf(status, log),\n pr: prOf(project && project.linkedPR),\n sessions\n }\n}\n\n/** Cuts the oldest messages (then long texts, then sessions) until the JSON fits the budget. */\nfunction fitToBudget(payload, maxBytes = MAX_PLAIN_BYTES) {\n const size = () => Buffer.byteLength(JSON.stringify(payload))\n let guard = 0\n while (size() > maxBytes && guard++ < 10_000) {\n const longest = payload.sessions.reduce((best, s) => (!best || s.messages.length > best.messages.length ? s : best), null)\n if (longest && longest.messages.length > 4) {\n longest.messages.shift()\n longest.truncated = true\n continue\n }\n const longText = payload.sessions.flatMap((s) => s.messages).find((m) => m.text.length > 500)\n if (longText) {\n longText.text = `${longText.text.slice(0, 500)}…`\n continue\n }\n if (payload.git && payload.git.changes.length > 20) {\n payload.git.changes = payload.git.changes.slice(0, 20)\n continue\n }\n if (payload.sessions.length > 1) {\n payload.sessions.pop()\n continue\n }\n payload.sessions = []\n break\n }\n return payload\n}\n\n// Fails closed without HOME (signingKey refuses a relative path).\nconst stateDir = process.env.HOME ? path.join(process.env.HOME, '.orca-sandbox') : ''\n\nasync function main() {\n const started = Date.now()\n const env = process.env\n if (process.argv[2] === '--signer-public-key') {\n // For orca-sandbox-grant: pairing devices learn (and pin) this workspace's signer.\n process.stdout.write(`${signingKey(stateDir).publicKeyB64}\\n`)\n return\n }\n const snapshotId = env.ORCA_READ_SNAPSHOT_ID || ''\n const workspaceId = env.ORCA_READ_WORKSPACE_ID || ''\n if (!/^rsn_[0-9a-f]{24}$/.test(snapshotId) || !/^sbx_[0-9a-f]{24}$/.test(workspaceId)) {\n process.stderr.write('bad ids\\n')\n process.exit(2)\n }\n let recipients\n try {\n recipients = JSON.parse(env.ORCA_READ_RECIPIENTS || '[]')\n } catch {\n recipients = null\n }\n if (!Array.isArray(recipients) || recipients.length === 0) {\n process.stderr.write('no recipients\\n')\n process.exit(2)\n }\n const dataDir = env.ORCA_USER_DATA_PATH || path.join(env.HOME || '', '.orcad-data')\n let metadata\n try {\n metadata = JSON.parse(fs.readFileSync(path.join(dataDir, 'orca-runtime.json'), 'utf8'))\n } catch {\n metadata = null\n }\n if (!metadata || !metadata.authToken) {\n process.stderr.write('orcad not running\\n')\n process.exit(3)\n }\n const deadline = started + TOTAL_BUDGET_MS\n const call = (method, params) => rpc(metadata, method, params, Math.max(250, Math.min(CALL_TIMEOUT_MS, deadline - Date.now())))\n const payload = fitToBudget(\n await gather(call, { workspaceId, snapshotId, reason: (env.ORCA_READ_REASON || 'sleep').slice(0, 20), now: Date.now(), deadline, listSessions: () => storedSessions(dataDir) })\n )\n const out = seal(payload, recipients, workspaceId, snapshotId)\n out.signature = sign(out, signingKey(stateDir))\n // Timing only: counts and sizes would describe the plaintext to the control plane's logs.\n out.stats = { collectMs: Date.now() - started }\n process.stdout.write(`${JSON.stringify(out)}\\n`)\n}\n\nmodule.exports = { workspaceCheckout, seal, sign, signingKey, gather, fitToBudget, messagesOf, gitOf, prOf, rpc, storedSessions, liveAgents }\n\nif (require.main === module) {\n // A fixed message: an error text could carry workspace content into the control plane's logs.\n main().catch(() => {\n process.stderr.write('read snapshot failed\\n')\n process.exit(1)\n })\n}\n","orca-git-as":"#!/usr/bin/env bash\n# Runs git as the teammate whose message started this agent's turn: their GitHub identity for\n# pushes/PRs (this repository only) and their commit author.\n# orca-git-as e.g. orca-git-as push origin HEAD\n# orca-git-as --as refuses unless that member really sent the message\n# Orca attests the sender from the paired device that sent the message, never from arguments or\n# message text. Without an attested sender (not run from an agent, or the owner's local session) it\n# runs plain git with the workspace identity.\nset -euo pipefail\nexpect=''\nif [ \"${1:-}\" = '--as' ]; then\n expect=\"${2:-}\"\n shift 2 || { echo 'usage: orca-git-as [--as ] ' >&2; exit 2; }\n case \"$expect\" in '' | *[!A-Za-z0-9_.:-]*) echo 'orca-git-as: bad member id' >&2; exit 2 ;; esac\nfi\nset +e\nfile=\"$(ORCA_GIT_AS=\"$expect\" sudo -n --preserve-env=ORCA_GIT_AS,ORCA_TERMINAL_HANDLE,ORCA_PANE_KEY,ORCA_AGENT_LAUNCH_TOKEN,ORCA_AGENT_SESSION_ID \\\n /opt/orca/bin/orca-git-identity)\"\nstatus=$?\nset -e\ncase \"$status\" in\n 0)\n trap 'rm -f \"$file\"' EXIT\n git -c \"include.path=$file\" \"$@\"\n ;;\n 3)\n echo 'orca-git-as: no teammate identity for this turn; using the workspace identity' >&2\n exec git \"$@\"\n ;;\n *) exit 4 ;;\nesac\n","orca-git-identity":"#!/usr/bin/env bash\n# Root helper behind `orca-git-as` (sudoers: the VM user may run only this). Asks orcad, as the\n# calling agent, which paired device sent the message that started the agent's latest turn\n# (`orca sender`: orcad identifies the agent from its launch evidence, never from arguments), maps\n# that device to a workspace member, and prints that member's git config file for the caller to\n# include. Optional ORCA_GIT_AS: the member the agent expects; any other answer is refused.\n# Exit codes: 0 printed a config path; 3 no member identity (use the workspace identity); 4 refused.\nset -euo pipefail\nDIR=/etc/orca/members\ncaller=\"${SUDO_USER:-}\"\ncase \"$caller\" in '' | root | *[!a-z0-9_-]*) echo 'orca-git-identity: run through orca-git-as' >&2; exit 4 ;; esac\n# orcad's own data dir and HOME (the agent's user may not use its passwd home, e.g. HOME=/vercel).\norcad_pid=\"$(pgrep -u \"$caller\" -f orcad-server.js | head -n 1 || true)\"\n[ -n \"$orcad_pid\" ] || exit 3\norcad_env() { tr '\\0' '\\n' <\"/proc/$orcad_pid/environ\" | sed -n \"s/^$1=//p\" | head -n 1; }\nhome=\"$(orcad_env HOME)\"\ndata=\"$(orcad_env ORCA_USER_DATA)\"\ndata=\"${data:-$home/.orcad-data}\"\nnode=\"$(find /opt/orca/runtime/runtimes -type f -name node | head -n 1)\"\ncli=/opt/orca/runtime/orcad/out/cli/index.js\n# Only the evidence variables orcad needs reach the query, which runs as the caller, not root.\nanswer=\"$(sudo -u \"$caller\" env -i HOME=\"$home\" PATH=/usr/bin:/bin ORCA_USER_DATA_PATH=\"$data\" \\\n ${ORCA_TERMINAL_HANDLE:+ORCA_TERMINAL_HANDLE=\"$ORCA_TERMINAL_HANDLE\"} \\\n ${ORCA_PANE_KEY:+ORCA_PANE_KEY=\"$ORCA_PANE_KEY\"} \\\n ${ORCA_AGENT_LAUNCH_TOKEN:+ORCA_AGENT_LAUNCH_TOKEN=\"$ORCA_AGENT_LAUNCH_TOKEN\"} \\\n ${ORCA_AGENT_SESSION_ID:+ORCA_AGENT_SESSION_ID=\"$ORCA_AGENT_SESSION_ID\"} \\\n timeout 15 \"$node\" \"$cli\" sender --json 2>/dev/null || true)\"\ndevice=\"$(jq -r '.result.sender.deviceId // empty' <<<\"$answer\" 2>/dev/null || true)\"\nif [ -z \"$device\" ] || [ ! -s \"$DIR/devices.json\" ]; then\n if [ -n \"${ORCA_GIT_AS:-}\" ]; then\n echo \"orca-git-as: Orca cannot confirm that $ORCA_GIT_AS sent this agent's latest message; refusing to push as them.\" >&2\n exit 4\n fi\n exit 3\nfi\nmember=\"$(jq -r --arg d \"$device\" '.[$d] // empty' \"$DIR/devices.json\")\"\nif [ -z \"$member\" ]; then\n [ -n \"${ORCA_GIT_AS:-}\" ] && { echo \"orca-git-as: the sender's device is not linked to a workspace member; refusing.\" >&2; exit 4; }\n exit 3\nfi\nif [ -n \"${ORCA_GIT_AS:-}\" ] && [ \"$ORCA_GIT_AS\" != \"$member\" ]; then\n echo \"orca-git-as: this agent's latest message came from $member, not $ORCA_GIT_AS; refusing to push as someone else.\" >&2\n exit 4\nfi\nfile=\"$DIR/$(printf '%s' \"$member\" | tr ':' '_').gitconfig\"\n[ -s \"$file\" ] || exit 3\n# The caller reads the identity through this one-shot copy (owned by them, removed by orca-git-as).\nout=\"$(sudo -u \"$caller\" mktemp)\"\ncat \"$file\" >\"$out\"\necho \"$out\"\n","orcad-boot":"#!/usr/bin/env node\n// Starts orcad from a V8 code cache taken after a full startup, so a boot skips compiling the\n// functions startup runs. NODE_COMPILE_CACHE only stores what V8 compiles before the bundle runs\n// (about 5 of its 15 MB); every other function compiled lazily on every boot (~300 ms of wake).\n// orcad-boot --code-cache [--write-code-cache] [orcad args...]\n// orcad sees the argv `node [orcad args...]`, as if started directly.\n// --write-code-cache (the image's smoke run) writes the cache when orcad exits. The cache is used\n// only when its header matches this bundle's bytes and this Node; otherwise orcad loads exactly as\n// before (plain require, which NODE_COMPILE_CACHE still serves). Plain Node, no dependencies.\n// getBuiltinModule (Node >= 22.3) works whether node loads this extensionless file as CommonJS\n// (in the VM) or as an ES module (under this repo's type:module package, in tests).\nconst fs = process.getBuiltinModule('node:fs')\nconst path = process.getBuiltinModule('node:path')\nconst vm = process.getBuiltinModule('node:vm')\nconst zlib = process.getBuiltinModule('node:zlib')\nconst { Module, createRequire } = process.getBuiltinModule('node:module')\n\nconst MAGIC = 'orcad-code-cache-v1'\n\nconst args = process.argv.slice(2)\nlet cacheFile = null\nlet writeCache = false\nwhile (args[0] === '--code-cache' || args[0] === '--write-code-cache') {\n if (args[0] === '--code-cache') {\n cacheFile = args[1]\n args.splice(0, 2)\n } else {\n writeCache = true\n args.shift()\n }\n}\nconst entry = args.shift()\nif (!entry || !cacheFile) {\n console.error('usage: orcad-boot --code-cache [--write-code-cache] [args...]')\n process.exit(2)\n}\n// As for `node `: argv[1] is the resolved path, __filename the real one.\nprocess.argv = [process.argv[0], path.resolve(entry), ...args]\nlet file = process.argv[1]\ntry {\n file = fs.realpathSync(file)\n} catch {\n // A missing bundle fails in require below, as it would under plain node.\n}\n\n// One line of identity: a cache from another bundle or Node must never run (V8 only checks length).\nfunction cacheHeader(bytes) {\n return `${MAGIC} ${process.version} ${process.arch} ${bytes.length} ${zlib.crc32(bytes)}\\n`\n}\n\nfunction readCache(header) {\n let data\n try {\n data = fs.readFileSync(cacheFile)\n } catch {\n return null\n }\n const headerLength = Buffer.byteLength(header)\n if (data.length <= headerLength || data.toString('latin1', 0, headerLength) !== header) {\n console.error('orcad-boot: code cache is for another orcad build; loading without it')\n return null\n }\n return data.subarray(headerLength)\n}\n\nfunction runCompiled(header, source, cachedData) {\n // A vm.Script can serialize what startup compiled; the CommonJS loader cannot. Same wrapper,\n // filename and require as the loader, and import() resolves from the bundle like it does there.\n const options = {\n filename: file,\n cachedData: cachedData ?? undefined,\n importModuleDynamically: vm.constants.USE_MAIN_CONTEXT_DEFAULT_LOADER\n }\n // Node flags the loader option experimental once, on the first import() through it. Spend that\n // one-time warning here, unprinted, so orcad's stderr stays as it was.\n const emitWarning = process.emitWarning\n process.emitWarning = (warning, ...rest) => {\n if (!String(warning?.message ?? warning).includes('USE_MAIN_CONTEXT_DEFAULT_LOADER')) {\n emitWarning.call(process, warning, ...rest)\n }\n }\n let script\n try {\n new vm.Script('import(\"node:path\")', {\n filename: file,\n importModuleDynamically: options.importModuleDynamically\n })\n .runInThisContext()\n .catch(() => {})\n script = new vm.Script(Module.wrap(source), options)\n } finally {\n process.emitWarning = emitWarning\n }\n if (cachedData && script.cachedDataRejected) {\n console.error('orcad-boot: V8 rejected the code cache (other V8 flags?); compiled without it')\n }\n if (writeCache) {\n process.on('exit', () => {\n try {\n const data = script.createCachedData()\n const temporary = `${cacheFile}.${process.pid}.tmp`\n fs.writeFileSync(temporary, Buffer.concat([Buffer.from(header, 'latin1'), data]))\n fs.renameSync(temporary, cacheFile)\n console.error(`orcad-boot: wrote ${data.length} bytes of code cache`)\n } catch (error) {\n console.error(`orcad-boot: could not write the code cache: ${error.message}`)\n }\n })\n }\n const module = new Module(file, null)\n module.filename = file\n module.paths = Module._nodeModulePaths(path.dirname(file))\n const compiled = script.runInThisContext()\n compiled.call(module.exports, module.exports, createRequire(file), module, file, path.dirname(file))\n}\n\n// Locals, so neither the bundle bytes nor the cache outlive the compile.\nfunction boot() {\n // Older Node (no crc32 or main-context import()) loads as before.\n if (typeof zlib.crc32 === 'function' && vm.constants?.USE_MAIN_CONTEXT_DEFAULT_LOADER) {\n let bytes = null\n try {\n bytes = fs.readFileSync(file)\n } catch {\n // Missing or unreadable: require reports it as plain node would.\n }\n if (bytes) {\n const header = cacheHeader(bytes)\n const cachedData = readCache(header)\n if (cachedData || writeCache) {\n runCompiled(header, bytes.toString('utf8'), cachedData)\n return\n }\n }\n }\n createRequire(file)(file)\n}\n\nboot()\n","git":"#!/usr/bin/env bash\n# git for agents in Orca Cloud workspaces (first on orcad's PATH). Commands that author or publish\n# work run as the teammate whose message started this agent's turn (orca-git-as: orcad attests the\n# sender's paired device; root-only member identities). Everything else is plain git, untouched.\nreal=\"${ORCA_REAL_GIT:-/usr/bin/git}\"\nsub=''\nskip=0\nfor arg in \"$@\"; do\n if [ \"$skip\" = 1 ]; then skip=0; continue; fi\n case \"$arg\" in\n -C | -c | --git-dir | --work-tree | --namespace | --exec-path | --config-env) skip=1 ;;\n -*) ;;\n *) sub=\"$arg\"; break ;;\n esac\ndone\n# CREATE-GIT-ASYNC: a new workspace answers before teammates' GitHub identities are installed. Until\n# they are (orca-sandbox-members writes git-setup.ready), commits and pushes wait up to 15 s rather\n# than go out under the workspace identity; a failed or stalled setup stops them with what to do.\n# Read-only git and fetches never wait (they use the workspace's own credentials, set before ready).\nhold_for_member_setup() {\n local state=\"${HOME:-/nonexistent}/.orca-sandbox\" ticks=0 since\n # A failed install on any boot (create or a later restart) refuses even with nothing pending.\n [ -f \"$state/git-setup.pending\" ] || [ -f \"$state/git-setup.failed\" ] || return 0\n since=\"$(cat \"$state/git-setup.pending\" 2>/dev/null || echo 0)\"\n case \"$since\" in '' | *[!0-9]*) since=0 ;; esac\n while [ -f \"$state/git-setup.pending\" ] && [ ! -f \"$state/git-setup.ready\" ] && [ ! -f \"$state/git-setup.failed\" ]; do\n # 15 s per command; a mark older than 2 min is a setup that died, not one still running.\n if [ \"$ticks\" -ge \"${ORCA_GIT_SETUP_WAIT_TICKS:-150}\" ] || [ $(($(date +%s) - since)) -gt 120 ]; then\n break\n fi\n sleep 0.1\n ticks=$((ticks + 1))\n done\n if [ -f \"$state/git-setup.ready\" ] || { [ ! -f \"$state/git-setup.pending\" ] && [ ! -f \"$state/git-setup.failed\" ]; }; then\n return 0\n fi\n if [ -f \"$state/git-setup.failed\" ]; then\n printf '%s\\n' \"orca: Orca couldn't set up your GitHub identity in this sandbox, so it stopped \\`git $sub\\` rather than use the wrong identity. Restart this workspace (right-click it in the sidebar > Restart), then try again.\" >&2\n else\n printf '%s\\n' \"orca: Orca is still setting up your GitHub identity in this sandbox, so it stopped \\`git $sub\\` rather than use the wrong identity. Try again in a few seconds. If this keeps happening, restart this workspace (right-click it in the sidebar > Restart).\" >&2\n fi\n exit 1\n}\n# A commit under the workspace identity (Orca Cloud's name) says once per session how to use your own\n# (design-squad). /dev/shm is per boot: a wake or restart says it again.\nnudge_once() {\n [ \"$sub\" = commit ] || return 0\n local mark=\"${ORCA_GIT_NUDGE_DIR:-/dev/shm}/orca-git-nudge-$(id -u)\"\n [ -e \"$mark\" ] && return 0\n : >\"$mark\" 2>/dev/null || return 0\n printf '%s\\n' \"orca: This commit uses Orca Cloud's name. Connect your GitHub in Settings › Account to use yours.\" >&2\n}\n# PUSH-RAW-PROMPT: a push, pull or fetch to github.com that no credential covers (neither the person's\n# connected GitHub nor the App installation) must never fall through to git's own \"Username for\n# 'https://github.com':\" prompt. Prompts are off for that remote, and git's auth failure becomes one\n# plain line saying what to do.\ngithub_slug=''\ngithub_remote_slug() {\n local arg skip=0 seen=0 remote='' url\n for arg in \"$@\"; do\n if [ \"$skip\" = 1 ]; then skip=0; continue; fi\n if [ \"$seen\" = 0 ]; then\n case \"$arg\" in\n -C | -c | --git-dir | --work-tree | --namespace | --exec-path | --config-env) skip=1 ;;\n \"$sub\") seen=1 ;;\n esac\n continue\n fi\n case \"$arg\" in\n --repo=*) remote=\"${arg#--repo=}\"; break ;;\n -o | --push-option | --receive-pack | --upload-pack | --exec | --depth | --deepen | --shallow-since | --shallow-exclude | --refmap | --recurse-submodules-default | -j | --jobs) skip=1 ;;\n -*) ;;\n *) remote=\"$arg\"; break ;;\n esac\n done\n [ -n \"$remote\" ] || remote=\"$(\"$real\" config --get \"branch.$(\"$real\" symbolic-ref --short -q HEAD 2>/dev/null).remote\" 2>/dev/null || true)\"\n [ -n \"$remote\" ] || remote=origin\n case \"$remote\" in\n *://*) url=\"$remote\" ;;\n *) url=\"$(\"$real\" remote get-url \"$remote\" 2>/dev/null || printf '%s' \"$remote\")\" ;;\n esac\n case \"$url\" in\n https://github.com/* | https://*@github.com/*) url=\"${url#*github.com/}\" ;;\n *) return 0 ;;\n esac\n url=\"${url%.git}\"\n url=\"${url%/}\"\n case \"$url\" in */*/*) url=\"${url%%/*}/$(printf '%s' \"${url#*/}\" | cut -d/ -f1)\" ;; esac\n case \"$url\" in [A-Za-z0-9_.-]*/[A-Za-z0-9_.-]*) github_slug=\"$url\" ;; esac\n}\nno_access_line() {\n local owner=\"${github_slug%%/*}\" verb='push to'\n [ \"$sub\" = push ] || verb='read'\n 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\n}\n# GitHub's \"not found\" is a typo'd name or a private repo it won't confirm: say which half fits whom.\nnot_found_line() {\n printf '%s\\n' \"orca: Orca Cloud can't find $github_slug. Check the name, or install the Orca GitHub App on ${github_slug%%/*} if it's private.\" >&2\n}\n# orca-git-as's own fallback line is replaced by the nudge; GitHub auth failures by the plain line.\nfallback_filter() {\n local line said=0\n while IFS= read -r line || [ -n \"$line\" ]; do\n case \"$line\" in\n 'orca-git-as: no teammate identity'*) nudge_once ;;\n 'remote: Repository not found.'* | \"fatal: repository 'https://github.com/\"*\"' not found\"*)\n if [ -n \"$github_slug\" ]; then\n [ \"$said\" = 1 ] || not_found_line\n said=1\n else\n printf '%s\\n' \"$line\"\n fi\n ;;\n *\"could not read Username for 'https://github.com'\"* | *\"could not read Password for 'https://\"*\"github.com'\"* | \\\n *\"Authentication failed for 'https://github.com/\"* | 'remote: Permission to '*' denied to '* | \\\n *\"The requested URL returned error: 403\"*)\n if [ -n \"$github_slug\" ]; then\n [ \"$said\" = 1 ] || no_access_line\n said=1\n else\n printf '%s\\n' \"$line\"\n fi\n ;;\n *) printf '%s\\n' \"$line\" ;;\n esac\n done\n}\ncase \"$sub\" in\n push | pull | fetch)\n github_remote_slug \"$@\"\n if [ -n \"$github_slug\" ]; then\n export GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=never\n fi\n ;;\nesac\ngit_as=\"${ORCA_GIT_AS:-/opt/orca/bin/orca-git-as}\"\ncase \"$sub\" in\n commit | push | merge | rebase | cherry-pick | revert | am | pull | tag)\n hold_for_member_setup\n # Outside an agent turn (no attested sender) orca-git-as falls back to the workspace identity.\n if [ -n \"${ORCA_TERMINAL_HANDLE:-}${ORCA_AGENT_SESSION_ID:-}\" ] && [ -x \"$git_as\" ]; then\n PATH=\"/usr/bin:/bin:$PATH\" exec \"$git_as\" \"$@\" 2> >(fallback_filter >&2)\n fi\n # No terminal or agent turn to attribute it to: the workspace identity.\n nudge_once\n ;;\nesac\nif [ -n \"$github_slug\" ]; then\n exec \"$real\" \"$@\" 2> >(fallback_filter >&2)\nfi\nexec \"$real\" \"$@\"\n"} -export const VM_SCRIPTS_HASH: string = "9bc77cc5" -export const VM_SCRIPTS_STAMP: number = 1794530964 +export const VM_SCRIPT_SOURCES: BuildInputs['vmScripts'] = {"orca-sandbox-start":"#!/usr/bin/env bash\n# Starts orcad for this boot (idempotent) and prints its recipe line:\n# {\"schemaVersion\":1,\"pairingCode\":\"orca://pair?...\",\"projectRoot\":\"/vercel/sandbox/project\"}\n# Env from the control plane: ORCA_SANDBOX_ID, ORCA_PUBLIC_WSS, optional ORCA_REPO_URL/ORCA_REPO_REF,\n# ORCA_GITHUB_PLACEHOLDER, ORCA_SECRET_ENV (`NAME=` lines: org/personal/repo secrets for\n# orcad and its agents), ORCA_SHARE_PROXY_KEY (this session's share-link proxy key, port 6769).\n# Never prints anything else on stdout's last line.\n# With ORCA_READY_VIA=file it prints {\"schemaVersion\":1,\"readyFile\":\"\"} instead and the control\n# plane reads the offer with the files API: the provider keeps command stdout as logs, not files.\n# With ORCA_READY_KEY too (64 hex chars, this call's key; env is not kept) the pointer also carries\n# \"sealedOffer\": \".\", which saves that round trip.\n# Managed env for every process orcad starts: ORCA_IS_CLOUD=1, ORCA_SANDBOX_ID, ORCA_ROOT_PATH,\n# ORCA_WORKTREE_PATH, ORCA_WORKSPACE_PATH, ORCA_WORKSPACE_NAME, ORCA_DEFAULT_BRANCH, ORCA_PORT and the\n# CONDUCTOR_* names imported Conductor scripts read.\nset -euo pipefail\numask 077\nORCA_HOME=/opt/orca\nSTATE=\"$HOME/.orca-sandbox\"\nPROJECT=/vercel/sandbox/project\nmkdir -p \"$STATE\"\n# Only print_ready uses the key; nothing this script starts inherits it.\nready_key=\"${ORCA_READY_KEY:-}\"\nunset ORCA_READY_KEY\n[[ \"$ready_key\" =~ ^[0-9a-f]{64}$ ]] || ready_key=''\nexec 9>\"$STATE/start.lock\"\nflock -w 60 9\n# Background children close fd 9 (`9>&-`): an orcad that inherited it would hold the lock for its whole\n# life and every later start (resume of a running sandbox, a retried create) would wait out the 60 s.\n\n# orcad only lists a project once a client registers it (the desktop does on connect), so API, MCP,\n# routine and phone sessions saw no repo. Register the workspace project at every start, in the\n# background (ready is not delayed); `repo add` of an already-registered path is a no-op error.\nregister_project() {\n [ -x \"$ORCA_HOME/bin/orca\" ] && [ -d \"$PROJECT\" ] || return 0\n local kind=() name=()\n [ -e \"$PROJECT/.git\" ] || kind=(--kind folder)\n # The checkout lives at .../project; name it after the repository (owner/name.git -> name).\n if [ -n \"${ORCA_REPO_URL:-}\" ]; then\n local base=\"${ORCA_REPO_URL%/}\"\n base=\"${base##*/}\"\n base=\"${base%.git}\"\n [ -n \"$base\" ] && name=(--display-name \"$base\")\n fi\n setsid \"$ORCA_HOME/bin/orca\" repo add --path \"$PROJECT\" \"${kind[@]}\" \"${name[@]}\" --json \\\n >\"$STATE/register-project.log\" 2>&1 &- &\n}\n\nprint_ready() {\n if [ \"${ORCA_READY_VIA:-}\" = file ]; then\n local sealed='' iv\n if [ -n \"$ready_key\" ] && command -v openssl >/dev/null; then\n iv=\"$(od -An -N16 -tx1 /dev/urandom | tr -d ' \\n')\"\n # Best effort: without it the control plane reads the file, as before.\n sealed=\"$(tail -n 1 \"$STATE/ready.json\" | openssl enc -aes-256-ctr -K \"$ready_key\" -iv \"$iv\" -nosalt | base64 -w0)\" &&\n sealed=\"$iv.$sealed\" || sealed=''\n fi\n printf '{\"schemaVersion\":1,\"readyFile\":\"%s\"%s}\\n' \"$STATE/ready.json\" \"${sealed:+,\\\"sealedOffer\\\":\\\"$sealed\\\"}\"\n else\n tail -n 1 \"$STATE/ready.json\"\n fi\n}\n# Command stderr is kept by the provider: strip injected values and credential shapes from log tails.\nredact_log() {\n local text kv value\n text=\"$(cat)\"\n for kv in ${agent_env[@]+\"${agent_env[@]}\"}; do\n value=\"${kv#*=}\"\n if [ \"${#value}\" -ge 8 ]; then\n text=\"${text//\"$value\"/[REDACTED]}\"\n fi\n done\n printf '%s\\n' \"$text\" | sed -E \\\n -e 's#orca://[^[:space:]]+#[REDACTED_PAIRING]#g' \\\n -e 's#(gh[pousr]_|github_pat_|sk-|vc[kpiar]_)[A-Za-z0-9._-]{16,}#[REDACTED_TOKEN]#g' \\\n -e 's#eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}#[REDACTED_JWT]#g' \\\n -e 's#(Authorization|authorization):[[:space:]]*[A-Za-z]+[[:space:]]+[^[:space:]]+#\\1: [REDACTED]#g'\n}\n\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node | head -n 1)\"\n\n# Share-link proxy (port 6769): answers only requests the control plane signed with this session's\n# key, so its public route is useless on its own. The key goes in on stdin (printf is a builtin), so\n# it never appears in any argv or in the proxy's /proc//environ.\nstart_share_proxy() {\n [ -n \"${ORCA_SHARE_PROXY_KEY:-}\" ] && [ -x \"$ORCA_HOME/bin/orca-share-proxy\" ] || return 0\n if [ \"$(cat \"$STATE/share-proxy.boot\" 2>/dev/null || true)\" = \"$boot_id\" ] \\\n && kill -0 \"$(cat \"$STATE/share-proxy.pid\" 2>/dev/null || echo 0)\" 2>/dev/null; then\n return 0\n fi\n printf '%s' \"$ORCA_SHARE_PROXY_KEY\" | env -i HOME=\"$HOME\" LANG=C.UTF-8 PATH=/usr/bin:/bin \\\n setsid \"$node\" \"$ORCA_HOME/bin/orca-share-proxy\" >\"$STATE/share-proxy.log\" 2>&1 9>&- &\n echo $! >\"$STATE/share-proxy.pid\"\n printf '%s' \"$boot_id\" >\"$STATE/share-proxy.boot\"\n}\n\nboot_id=\"$(cat /proc/sys/kernel/random/boot_id)\"\n# CREATE-GIT-ASYNC: a create answers before member identities are installed; `git` holds commits and\n# pushes until orca-sandbox-members (or the control plane) marks this setup ready or failed. Marked\n# before the already-started exit below: a pooled VM's orcad may be up before its create claims it.\nif [ \"${ORCA_GIT_SETUP:-}\" = pending ]; then\n rm -f \"$STATE/git-setup.ready\" \"$STATE/git-setup.failed\"\n date +%s >\"$STATE/git-setup.pending\"\nfi\nstart_share_proxy\nunset ORCA_SHARE_PROXY_KEY\nif [ \"$(cat \"$STATE/boot-id\" 2>/dev/null || true)\" = \"$boot_id\" ] && [ -s \"$STATE/ready.json\" ] \\\n && kill -0 \"$(cat \"$STATE/orcad.pid\" 2>/dev/null || echo 0)\" 2>/dev/null; then\n print_ready\n exit 0\nfi\n# Resume = reboot from disk: locks and pids from the previous session name dead processes.\nrm -f \"$STATE/ready.json\" \"$STATE/orcad.pid\"\nprintf '%s' \"$boot_id\" >\"$STATE/boot-id\"\n# Any other boot (wake, restart) installs identities before it answers: a resumed disk's marks are stale.\n[ \"${ORCA_GIT_SETUP:-}\" = pending ] || rm -f \"$STATE/git-setup.pending\" \"$STATE/git-setup.ready\" \"$STATE/git-setup.failed\"\n\nif [ -n \"${ORCA_GIT_EXTRAHEADER:-}\" ]; then\n # Every github.com request carries the placeholder; the egress policy swaps in a 1h repo token.\n git config --global http.https://github.com/.extraheader \"$ORCA_GIT_EXTRAHEADER\"\nfi\n# Commits outside a teammate's turn (or before any member connected GitHub) need an author too.\n# The fallback moved to orca.dev with the domain; a workspace still on the old default follows it.\n[ \"$(git config --global user.email 2>/dev/null || true)\" = 'cloud@users.noreply.onorca.dev' ] \\\n && git config --global user.email 'cloud@users.noreply.orca.dev'\ngit config --global user.email >/dev/null 2>&1 || git config --global user.email 'cloud@users.noreply.orca.dev'\ngit config --global user.name >/dev/null 2>&1 || git config --global user.name 'Orca Cloud'\n# First boot clones while orcad starts (orcad only needs the directory to exist); ready waits for both.\nclone_pid=''\nif [ ! -d \"$PROJECT\" ]; then\n mkdir -p \"$PROJECT\"\n if [ -n \"${ORCA_REPO_URL:-}\" ]; then\n git clone --quiet ${ORCA_REPO_REF:+--branch \"$ORCA_REPO_REF\"} -- \"$ORCA_REPO_URL\" \"$PROJECT\" 2>\"$STATE/clone.log\" 9>&- &\n clone_pid=$!\n fi\nfi\n\n# The clone may still be running here, so the name comes from the URL; the default branch is\n# filled in lazily by the session-env shell hook once the clone exists.\nif [ -n \"${ORCA_REPO_URL:-}\" ]; then\n workspace_name=\"$(basename \"${ORCA_REPO_URL%/}\" .git)\"\nelse\n workspace_name=\"$(basename \"$(git -C \"$PROJECT\" remote get-url origin 2>/dev/null || echo \"$PROJECT\")\" .git)\"\nfi\n# One workspace per VM: its preview ports start at 3000 (Conductor allots CONDUCTOR_PORT..+9).\nmanaged_env=(\n ORCA_IS_CLOUD=1\n ORCA_ROOT_PATH=\"$PROJECT\" ORCA_WORKTREE_PATH=\"$PROJECT\" ORCA_WORKSPACE_PATH=\"$PROJECT\"\n ORCA_WORKSPACE_NAME=\"$workspace_name\" ORCA_PORT=3000\n CONDUCTOR_IS_CLOUD=1\n # Agent CLIs are pinned by the image (smoke-tested); a self-update mid-session can break a running agent.\n DISABLE_AUTOUPDATER=1 OPENCODE_DISABLE_AUTOUPDATE=1 COPILOT_AUTO_UPDATE=false\n CONDUCTOR_ROOT_PATH=\"$PROJECT\" CONDUCTOR_WORKSPACE_PATH=\"$PROJECT\"\n CONDUCTOR_WORKSPACE_NAME=\"$workspace_name\" CONDUCTOR_PORT=3000\n)\n\nagent_env=()\nif command -v google-chrome >/dev/null; then\n # Orca's browser automation and agent browser tools use the image's headless Chrome.\n agent_env+=(\"ORCA_BROWSER_EXECUTABLE=$(command -v google-chrome)\")\nfi\nif [ -n \"${ORCA_GITHUB_PLACEHOLDER:-}\" ]; then\n # The egress layer swaps the placeholder for a short-lived GitHub token; none lives in the VM.\n agent_env+=(\"GH_TOKEN=$ORCA_GITHUB_PLACEHOLDER\" \"GITHUB_TOKEN=$ORCA_GITHUB_PLACEHOLDER\")\nfi\n\nsecret_names=()\nif [ -n \"${ORCA_SECRET_ENV:-}\" ]; then\n # Secrets live only in memory: orcad's process env and a tmpfs file new shells re-read\n # (orca-sandbox-env), never under $HOME or the project, so the stop snapshot never carries them.\n # Resolved per start (and per edit) by the control plane: no image rebuild, no restart.\n while IFS= read -r line; do\n name=\"${line%%=*}\" b64=\"${line#*=}\" # not IFS='=': read would strip base64 padding\n case \"$name\" in '' | *[!A-Z0-9_]* | [0-9]* | ORCA_* | PATH | HOME | LD_* | DYLD_*) continue ;; esac\n agent_env+=(\"$name=$(printf '%s' \"$b64\" | base64 -d)\")\n secret_names+=(\"$name\")\n done < <(printf '%s\\n' \"$ORCA_SECRET_ENV\") # a pipe, not a here-string temp file\nfi\n# Always rewrite the file, so a resume with fewer secrets drops the old names too.\n\"$ORCA_HOME/bin/orca-sandbox-env\" >/dev/null || echo 'session env file unavailable; secrets apply at boot only' >&2\nunset ORCA_SECRET_ENV\n\n# Agent CLIs installed with the image's npm (codex, gemini, ...) are `#!/usr/bin/env node` scripts:\n# terminals need that node on PATH, not only orcad's bundled runtime.\nsystem_node=\"$(command -v node 2>/dev/null || echo \"$node\")\"\n# The egress proxy answers for hosts with credential rules (model APIs, api.github.com) with a\n# certificate from Vercel's CA, which this command's env trusts (Vercel sets these). Pure-Node clients\n# use Node's bundled roots otherwise: Gemini CLI retried forever (\"taking a bit longer\") in every\n# terminal while the same call worked here. Pass the provider's CA settings on to orcad and its shells.\nca_env=(NODE_USE_SYSTEM_CA=\"${NODE_USE_SYSTEM_CA:-1}\")\nfor name in NODE_EXTRA_CA_CERTS SSL_CERT_FILE; do\n if [ -n \"${!name:-}\" ]; then ca_env+=(\"$name=${!name}\"); fi\ndone\n# N-02: the host is a cloud terminal's source of truth across app reopens, so orcad and its\n# daemon keep a local terminal's depth (5000 rows) instead of the desktop daemon's 1000-row live\n# window. Scoped to the VM: local desktop daemons keep their default.\n# Ports panel: programs under $ORCA_HOME are the image's own services, and 23456 is Vercel's\n# sandbox-init (root, pid invisible to this user); neither is listed as the user's port.\nenv -i HOME=\"$HOME\" USER=\"$(id -un)\" LANG=C.UTF-8 TERM=xterm-256color \"${ca_env[@]}\" \\\n PATH=\"$ORCA_HOME/tools/bin:$ORCA_HOME/bin:$(dirname \"$system_node\"):/usr/local/bin:/usr/bin:/bin\" \\\n ORCA_USER_DATA=\"$HOME/.orcad-data\" ORCA_SANDBOX_ID=\"$ORCA_SANDBOX_ID\" \"${managed_env[@]}\" \\\n ORCA_HOST_MANAGED_LISTENER_PATHS=\"$ORCA_HOME\" ORCA_HOST_MANAGED_UNOWNED_PORTS=23456 \\\n NODE_COMPILE_CACHE=\"$ORCA_HOME/compile-cache\" ORCA_ORCAD_STARTUP_QUALIFICATION_DIR=\"$ORCA_HOME/startup-qualification\" \\\n ORCA_DAEMON_SESSION_SCROLLBACK_ROWS=5000 \\\n ORCA_SESSION_ENV_NAMES=\"${secret_names[*]}\" \"${agent_env[@]}\" \\\n setsid nohup \"$node\" \"$ORCA_HOME/bin/orcad-boot\" --code-cache \"$ORCA_HOME/compile-cache/orcad-server.v8\" \\\n \"$ORCA_HOME/runtime/orcad/orcad-server.js\" --port 6768 --bind 0.0.0.0 --pairing-address \"$ORCA_PUBLIC_WSS\" \\\n --recipe-json --project-root \"$PROJECT\" \\\n >\"$STATE/ready.json\" 2>\"$STATE/orcad.log\" &- &\necho $! >\"$STATE/orcad.pid\"\n\n# sshd for `orca ssh` (loopback only). Best effort: a workspace without it still serves orcad.\n\"$ORCA_HOME/bin/orca-sshd\" >/dev/null 2>\"$STATE/sshd-start.err\" || true\n\n# Polled every 10 ms (at least 60 s in all): ready is on the wake path, so detect it promptly.\nfor _ in $(seq 1 6000); do\n if grep -q '\"pairingCode\"' \"$STATE/ready.json\" 2>/dev/null; then\n if [ -n \"$clone_pid\" ] && ! wait \"$clone_pid\"; then\n echo 'repository clone failed' >&2\n tail -n 5 \"$STATE/clone.log\" | redact_log >&2\n exit 1\n fi\n register_project\n print_ready\n exit 0\n fi\n if ! kill -0 \"$(cat \"$STATE/orcad.pid\")\" 2>/dev/null; then\n echo 'orcad exited before ready' >&2\n tail -n 40 \"$STATE/orcad.log\" | redact_log >&2\n exit 1\n fi\n sleep 0.01\ndone\necho 'orcad start timed out' >&2\nexit 1\n","orca-sandbox-drain":"#!/usr/bin/env bash\n# Asks orcad to quit before the VM stops: its shutdown turns working agent chats into durable\n# restart offers and flushes terminal restore hints, which the desktop continues after resume.\nset -euo pipefail\nSTATE=\"$HOME/.orca-sandbox\"\nWAIT_SECONDS=\"${1:-280}\"\npid=\"$(cat \"$STATE/orcad.pid\" 2>/dev/null || true)\"\nif [ -z \"$pid\" ] || ! kill -0 \"$pid\" 2>/dev/null; then\n exit 0\nfi\nkill -TERM \"$pid\"\nfor _ in $(seq 1 $((WAIT_SECONDS * 10))); do\n if ! kill -0 \"$pid\" 2>/dev/null; then\n rm -f \"$STATE/orcad.pid\" \"$STATE/ready.json\"\n exit 0\n fi\n sleep 0.1\ndone\n# Leave it running: the provider stop ends it, and the drain already had its window.\necho 'orcad still draining at deadline' >&2\nexit 0\n","orca-sandbox-env":"#!/usr/bin/env bash\n# Writes this session's secret env where new shells and agent launches read it (orca-session-env.sh).\n# Input: ORCA_SECRET_ENV (`NAME=` lines) from the control plane, at boot (orca-sandbox-start)\n# and whenever a secret changes (POST /v1/sandboxes/:id/env/refresh). The file lives on tmpfs only:\n# never on the sandbox disk, so the stop snapshot never carries it. Prints {\"names\":N} on success.\n# Also keeps Codex pointed at a configured OpenAI-compatible provider (OPENAI_BASE_URL), since Codex\n# does not read that variable: a managed block in ~/.codex/config.toml naming only the env var.\nset -euo pipefail\numask 077\nHOME=\"${HOME:-$(getent passwd \"$(id -u)\" | cut -d: -f6)}\"\n# ORCA_SESSION_ENV_DIR: tests only (a scratch dir off tmpfs); the control plane never sets it.\ndir=\"${ORCA_SESSION_ENV_DIR:-/dev/shm/orca-env-$(id -u)}\"\nif [ -z \"${ORCA_SESSION_ENV_DIR:-}\" ] && [ \"$(stat -f -c %T /dev/shm 2>/dev/null || true)\" != tmpfs ]; then\n # No memory-only place to keep it: refuse rather than write secrets to disk.\n echo 'no tmpfs for the session env' >&2\n exit 3\nfi\nmkdir -p \"$dir\"\nchmod 700 \"$dir\"\n# Writes are ordered by the control plane's resolve time: an older refresh that lost a race is dropped.\ngeneration=\"${ORCA_ENV_GENERATION:-0}\"\ncase \"$generation\" in '' | *[!0-9]*) generation=0 ;; esac\ncurrent=\"$(cat \"$dir/generation\" 2>/dev/null || echo 0)\"\ncase \"$current\" in '' | *[!0-9]*) current=0 ;; esac\nif [ \"$generation\" -lt \"$current\" ]; then\n printf '{\"stale\":true}\\n'\n exit 0\nfi\ntmp=\"$(mktemp \"$dir/.session.env.XXXXXX\")\"\ncount=0\nopenai_base_url=''\nopenai_model=''\nanthropic_model=''\nanthropic_key_tail=''\ngateway_key=''\ngemini_key=''\ncodex_placeholder=''\nwhile IFS= read -r line; do\n name=\"${line%%=*}\" b64=\"${line#*=}\"\n # Not for shells: the Codex account placeholder becomes ~/.codex/auth.json below.\n if [ \"$name\" = ORCA_CODEX_AUTH_PLACEHOLDER ]; then\n codex_placeholder=\"$(printf '%s' \"$b64\" | base64 -d)\"\n case \"$codex_placeholder\" in *[!A-Za-z0-9._-]*) codex_placeholder='' ;; esac\n fi\n case \"$name\" in '' | *[!A-Z0-9_]* | [0-9]* | ORCA_* | PATH | HOME | LD_* | DYLD_*) continue ;; esac\n # Values stay base64 in the file; the shell hook decodes them.\n printf '%s=%s\\n' \"$name\" \"$b64\" >>\"$tmp\"\n count=$((count + 1))\n case \"$name\" in\n OPENAI_BASE_URL) openai_base_url=\"$(printf '%s' \"$b64\" | base64 -d)\" ;;\n OPENAI_MODEL) openai_model=\"$(printf '%s' \"$b64\" | base64 -d)\" ;;\n ANTHROPIC_MODEL) anthropic_model=\"$(printf '%s' \"$b64\" | base64 -d)\" ;;\n AI_GATEWAY_API_KEY) gateway_key=1 ;;\n GEMINI_API_KEY | GOOGLE_API_KEY) gemini_key=\"${gemini_key:-gemini-api-key}\" ;;\n GOOGLE_GENAI_USE_VERTEXAI) case \"$(printf '%s' \"$b64\" | base64 -d)\" in true | 1) gemini_key=vertex-ai ;; esac ;;\n # Claude Code asks once per key whether to use ANTHROPIC_API_KEY; it remembers the last 20 chars.\n ANTHROPIC_API_KEY) anthropic_key_tail=\"$(printf '%s' \"$b64\" | base64 -d | tail -c 20)\" ;;\n esac\ndone < <(printf '%s\\n' \"${ORCA_SECRET_ENV:-}\")\nmv -f \"$tmp\" \"$dir/session.env\"\nprintf '%s' \"$generation\" >\"$dir/generation\"\n\n# ---- Codex: trust the workspace and use the configured provider (no secret values) ----\nPROJECT=/vercel/sandbox/project\ncodex_config=\"$HOME/.codex/config.toml\"\ntoml_string() { # TOML basic string; the control plane only accepts https URLs and plain model ids\n local value=\"${1//\\\\/\\\\\\\\}\"\n printf '\"%s\"' \"${value//\\\"/\\\\\\\"}\"\n}\nstrip_managed() { # drop the managed blocks and the blank lines written before them (no growth per refresh)\n awk '/^# >>> orca-cloud managed/{skip=1; blank=\"\"} skip{if (/^# <<< orca-cloud managed/) skip=0; next}\n /^[[:space:]]*$/{blank=blank $0 \"\\n\"; next} {printf \"%s\", blank; blank=\"\"; print}' \"$1\"\n}\n# Always managed: an agent launched from the phone or the API has no one to answer Codex's\n# \"trust this folder?\" screen, so the workspace project is trusted up front.\nif true; then\n mkdir -p \"$HOME/.codex\"\n user_part=\"$(mktemp \"$HOME/.codex/.config.XXXXXX\")\"\n if [ -f \"$codex_config\" ]; then strip_managed \"$codex_config\" >\"$user_part\"; fi\n next=\"$(mktemp \"$HOME/.codex/.config.XXXXXX\")\"\n openai_host=\"${openai_base_url#https://}\"\n openai_host=\"${openai_host%%/*}\"\n custom_provider=''\n if [ -n \"$openai_base_url\" ] && [ \"$openai_host\" != api.openai.com ]; then custom_provider=1; fi\n # Top-level keys must precede every table; each is skipped when the user already set it.\n head_keys=\"$(awk '/^\\[/{exit} {print}' \"$user_part\")\"\n has_key() { grep -Eq \"^[[:space:]]*$1[[:space:]]*=\" <<<\"$head_keys\"; }\n {\n printf '# >>> orca-cloud managed (Orca Cloud; edit in Orca settings)\\n'\n # The sandbox is the isolation boundary: Codex's own bwrap sandbox can't start in it (\"Unexpected\n # capabilities\"), and a prompt delivered from the phone or the API has no one to approve commands.\n has_key sandbox_mode || printf 'sandbox_mode = \"danger-full-access\"\\n'\n has_key approval_policy || printf 'approval_policy = \"never\"\\n'\n if [ -n \"$custom_provider\" ]; then\n has_key model_provider || printf 'model_provider = \"orca_cloud\"\\n'\n if [ -n \"$openai_model\" ] && ! has_key model; then\n printf 'model = %s\\n' \"$(toml_string \"$openai_model\")\"\n fi\n elif [ -n \"$codex_placeholder\" ]; then\n has_key model_provider || printf 'model_provider = \"orca_chatgpt\"\\n'\n fi\n printf '# <<< orca-cloud managed\\n'\n cat \"$user_part\"\n if [ -n \"$custom_provider\" ]; then\n printf '\\n# >>> orca-cloud managed provider\\n[model_providers.orca_cloud]\\nname = \"Orca Cloud\"\\n'\n printf 'base_url = %s\\nenv_key = \"OPENAI_API_KEY\"\\nwire_api = \"responses\"\\n' \"$(toml_string \"$openai_base_url\")\"\n printf '# <<< orca-cloud managed\\n'\n elif [ -n \"$codex_placeholder\" ]; then\n # Codex via the Orca account: the ChatGPT backend over HTTPS. Its default Responses WebSocket can't\n # pass the egress proxy, where the placeholder becomes the real token (found live, Codex 0.166).\n printf '\\n# >>> orca-cloud managed provider\\n[model_providers.orca_chatgpt]\\nname = \"OpenAI\"\\n'\n printf 'base_url = \"https://chatgpt.com/backend-api/codex\"\\nwire_api = \"responses\"\\nrequires_openai_auth = true\\nsupports_websockets = false\\n'\n printf '# <<< orca-cloud managed\\n'\n fi\n } >\"$next\"\n if ! grep -qF \"[projects.\\\"$PROJECT\\\"]\" \"$user_part\"; then\n printf '\\n# >>> orca-cloud managed trust\\n[projects.\"%s\"]\\ntrust_level = \"trusted\"\\n# <<< orca-cloud managed\\n' \"$PROJECT\" >>\"$next\"\n fi\n chmod 600 \"$next\"\n mv -f \"$next\" \"$codex_config\"\n rm -f \"$user_part\"\nfi\n# ---- Codex via the person's Orca account (contract D11): a placeholder ChatGPT login, no refresh token.\n# The egress proxy swaps the real access token on chatgpt.com; the real tokens never reach this VM. The\n# marker remembers which placeholder Orca wrote (a clone gets its own), so a login of the person's own\n# (kill-switch fallback, or `codex login` run here) is set aside while the account applies and put back after.\ncodex_auth=\"$HOME/.codex/auth.json\"\ncodex_saved=\"$HOME/.codex/auth.json.orca-saved\"\ncodex_marker=\"$HOME/.codex/.orca-account-placeholder\"\ncodex_ours=''\n# Ours: the marker's placeholder, or any Orca placeholder (all end in `.orca-placeholder`; a real token never does).\nif [ -f \"$codex_auth\" ] && { { [ -f \"$codex_marker\" ] && grep -qF \"\\\"$(cat \"$codex_marker\")\\\"\" \"$codex_auth\"; } || grep -q '\\.orca-placeholder\"' \"$codex_auth\"; }; then\n codex_ours=1\nfi\nif [ -n \"$codex_placeholder\" ]; then\n mkdir -p \"$HOME/.codex\"\n if [ -f \"$codex_auth\" ] && [ -z \"$codex_ours\" ]; then\n # Never overwrite a login already set aside: a second one gets its own name.\n if [ -e \"$codex_saved\" ]; then mv -f \"$codex_auth\" \"$codex_saved.$(date -u +%Y%m%d%H%M%S)\"; else mv -f \"$codex_auth\" \"$codex_saved\"; fi\n fi\n # Marker first: a run cut short after the swap still recognises the file as Orca's.\n printf '%s' \"$codex_placeholder\" >\"$codex_marker\"\n codex_tmp=\"$(mktemp \"$HOME/.codex/.auth.XXXXXX\")\"\n printf '{\\n \"OPENAI_API_KEY\": null,\\n \"tokens\": {\\n \"id_token\": \"%s\",\\n \"access_token\": \"%s\",\\n \"refresh_token\": \"\",\\n \"account_id\": null\\n },\\n \"last_refresh\": \"%s\"\\n}\\n' \\\n \"$codex_placeholder\" \"$codex_placeholder\" \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" >\"$codex_tmp\"\n chmod 600 \"$codex_tmp\"\n mv -f \"$codex_tmp\" \"$codex_auth\"\nelif [ -f \"$codex_marker\" ] || [ -n \"$codex_ours\" ]; then\n if [ -n \"$codex_ours\" ]; then rm -f \"$codex_auth\"; fi\n rm -f \"$codex_marker\"\n if [ -f \"$codex_saved\" ] && [ ! -f \"$codex_auth\" ]; then mv -f \"$codex_saved\" \"$codex_auth\"; fi\nfi\n# ---- Claude Code, Gemini CLI, Cursor agent, Copilot CLI, OpenCode: first-run screens answered once\n# (Claude: theme picker, folder trust, bypass-mode warning, the \"use this API key?\" question; others:\n# folder trust, auth-method picker, self-update). A prompt delivered from the phone or the API has no one to click\n# through them, and the agent would sit there doing nothing. Merges; never touches credentials.\nnode_bin=\"$(command -v node || find /opt/orca/runtime/runtimes -type f -name node 2>/dev/null | head -n 1)\"\nif [ -n \"$node_bin\" ]; then\n ORCA_PROJECT=\"$PROJECT\" ORCA_KEY_TAIL=\"$anthropic_key_tail\" ORCA_GEMINI_KEY=\"$gemini_key\" \\\n ORCA_GATEWAY_MODEL=\"${gateway_key:+${anthropic_model:-$openai_model}}\" \"$node_bin\" -e '\nconst fs = require(\"fs\"), path = require(\"path\"), os = require(\"os\")\nconst home = os.homedir()\nconst readJson = (file) => { try { const v = JSON.parse(fs.readFileSync(file, \"utf8\")); return v && typeof v === \"object\" && !Array.isArray(v) ? v : {} } catch { return {} } }\nconst write = (file, value) => {\n fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })\n const tmp = `${file}.orca-${process.pid}`\n fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + \"\\n\", { mode: 0o600 })\n fs.renameSync(tmp, file)\n}\nconst claudeFile = path.join(process.env.CLAUDE_CONFIG_DIR || home, \".claude.json\")\nconst c = readJson(claudeFile)\nconst before = JSON.stringify(c)\nc.hasCompletedOnboarding = true\nc.theme = c.theme || \"dark\"\nc.bypassPermissionsModeAccepted = true\nc.projects = c.projects && typeof c.projects === \"object\" ? c.projects : {}\nconst p = process.env.ORCA_PROJECT\nc.projects[p] = { ...(c.projects[p] || {}), hasTrustDialogAccepted: true, hasCompletedProjectOnboarding: true }\nconst tail = process.env.ORCA_KEY_TAIL\nif (tail) {\n const r = c.customApiKeyResponses && typeof c.customApiKeyResponses === \"object\" ? c.customApiKeyResponses : {}\n const approved = Array.isArray(r.approved) ? r.approved : []\n const rejected = Array.isArray(r.rejected) ? r.rejected.filter((t) => t !== tail) : []\n c.customApiKeyResponses = { ...r, approved: approved.includes(tail) ? approved : [...approved, tail], rejected }\n}\nif (JSON.stringify(c) !== before) write(claudeFile, c)\nconst settingsFile = path.join(home, \".claude\", \"settings.json\")\nconst s = readJson(settingsFile)\nif (s.skipDangerousModePermissionPrompt !== true) write(settingsFile, { ...s, skipDangerousModePermissionPrompt: true })\n\n// Each block below only fills in what is unset and leaves a file it cannot parse alone.\nconst readStrict = (file) => { try { return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, \"utf8\")) : {} } catch { return null } }\nconst isObject = (v) => v !== null && typeof v === \"object\" && !Array.isArray(v)\nconst section = (o, key) => (isObject(o[key]) ? o[key] : (o[key] = {}))\n\n// Gemini CLI: folder trust and the auth-method picker (only when a Gemini key is configured; a\n// Google sign-in picks its own method), no self-update, no usage stats (its host is not on the\n// egress allowlist, so every send would fail).\nconst geminiTrustFile = path.join(home, \".gemini\", \"trustedFolders.json\")\nconst geminiTrust = readStrict(geminiTrustFile)\nif (isObject(geminiTrust) && !geminiTrust[p]) write(geminiTrustFile, { ...geminiTrust, [p]: \"TRUST_FOLDER\" })\nconst geminiSettingsFile = path.join(home, \".gemini\", \"settings.json\")\nconst g = readStrict(geminiSettingsFile)\nif (isObject(g)) {\n const gBefore = JSON.stringify(g)\n const general = section(g, \"general\")\n general.disableAutoUpdate ??= true\n general.disableUpdateNag ??= true\n section(g, \"privacy\").usageStatisticsEnabled ??= false\n // gemini-api-key or vertex-ai (GOOGLE_GENAI_USE_VERTEXAI=true with an API key): headless runs\n // refuse to start without a method (\"Invalid auth method selected\").\n if (process.env.ORCA_GEMINI_KEY) section(section(g, \"security\"), \"auth\").selectedType ??= process.env.ORCA_GEMINI_KEY\n if (JSON.stringify(g) !== gBefore) write(geminiSettingsFile, g)\n}\n\n// Cursor agent: trust the workspace (cursor-agent keys trust by a slug of the path) and pin the\n// release channel to \"static\", which turns off its background self-update.\nconst cursorTrust = path.join(home, \".cursor\", \"projects\", p.replace(/^\\/+/, \"\").replace(/[^A-Za-z0-9]+/g, \"-\"), \".workspace-trusted\")\nif (!fs.existsSync(cursorTrust)) {\n fs.mkdirSync(path.dirname(cursorTrust), { recursive: true, mode: 0o700 })\n fs.writeFileSync(cursorTrust, JSON.stringify({ trustedAt: new Date().toISOString(), workspacePath: p }, null, 2) + \"\\n\", { mode: 0o600 })\n}\nconst cursorConfigFile = path.join(home, \".cursor\", \"cli-config.json\")\nconst cc = readStrict(cursorConfigFile)\nif (isObject(cc) && cc.channel === undefined) write(cursorConfigFile, { version: 1, ...cc, channel: \"static\" })\n\n// GitHub Copilot CLI: trusted folders live in ~/.copilot/config.json, which Copilot prefixes with\n// \"//\" comment lines; keep them.\nconst copilotFile = path.join(home, \".copilot\", \"config.json\")\nlet copilotHead = \"\"\nlet co = {}\ntry {\n const raw = fs.existsSync(copilotFile) ? fs.readFileSync(copilotFile, \"utf8\") : \"\"\n const lines = raw.split(\"\\n\")\n let i = 0\n while (i < lines.length && /^\\s*\\/\\//.test(lines[i])) i++\n copilotHead = lines.slice(0, i).map((l) => l + \"\\n\").join(\"\")\n const body = lines.slice(i).join(\"\\n\").trim()\n co = body ? JSON.parse(body) : {}\n} catch { co = null }\nif (isObject(co)) {\n const folders = Array.isArray(co.trustedFolders) ? co.trustedFolders : []\n if (!folders.includes(p)) {\n fs.mkdirSync(path.dirname(copilotFile), { recursive: true, mode: 0o700 })\n const tmp = `${copilotFile}.orca-${process.pid}`\n fs.writeFileSync(tmp, copilotHead + JSON.stringify({ ...co, trustedFolders: [...folders, p] }, null, 2) + \"\\n\", { mode: 0o600 })\n fs.renameSync(tmp, copilotFile)\n }\n}\n\n// OpenCode: with the AI Gateway preset and a model secret (ANTHROPIC_MODEL, else OPENAI_MODEL), use\n// that model through the gateway unless the user picked one. A ledger remembers what Orca wrote, so a\n// changed model secret updates it and a model the user set is never touched.\nconst ocDir = path.join(home, \".config\", \"opencode\")\nconst ocFile = path.join(ocDir, \"opencode.json\")\nconst ocLedger = path.join(ocDir, \".orca-managed-model\")\nconst managedModel = process.env.ORCA_GATEWAY_MODEL ? `vercel/${process.env.ORCA_GATEWAY_MODEL}` : \"\"\nconst oc = readStrict(ocFile)\nlet jsoncHasModel = false\ntry { jsoncHasModel = /\"model\"\\s*:/.test(fs.readFileSync(path.join(ocDir, \"opencode.jsonc\"), \"utf8\")) } catch {}\nif (isObject(oc) && !jsoncHasModel) {\n let ours = \"\"\n try { ours = fs.readFileSync(ocLedger, \"utf8\").trim() } catch {}\n const userModel = typeof oc.model === \"string\" && oc.model !== ours\n if (!userModel && (oc.model ?? \"\") !== managedModel) {\n const next = { $schema: \"https://opencode.ai/config.json\", ...oc }\n if (managedModel) next.model = managedModel\n else delete next.model\n write(ocFile, next)\n }\n if (!userModel) {\n if (managedModel) fs.writeFileSync(ocLedger, managedModel + \"\\n\", { mode: 0o600 })\n else fs.rmSync(ocLedger, { force: true })\n }\n}\n\n// OpenCode: two built-in providers cannot work in Orca Cloud, and with no other credential OpenCode\n// picks one and fails (\"Authorization header is badly formatted\" / opencode.ai unreachable) instead\n// of showing \"Connect a provider\". GitHub Copilot reads GITHUB_TOKEN, which here is the stand-in for the\n// GitHub App token of the workspace, never a Copilot token; OpenCode Zen needs opencode.ai, which is not on the\n// egress allowlist. Both stay off unless the user connected them in OpenCode itself (auth.json).\nconst ocAuth = readStrict(path.join(process.env.XDG_DATA_HOME || path.join(home, \".local\", \"share\"), \"opencode\", \"auth.json\"))\nconst connected = new Set(isObject(ocAuth) ? Object.keys(ocAuth) : [])\nconst ocDisabledLedger = path.join(ocDir, \".orca-managed-disabled-providers\")\nconst ocNow = readStrict(ocFile)\nif (isObject(ocNow) && !/\"disabled_providers\"\\s*:/.test((() => { try { return fs.readFileSync(path.join(ocDir, \"opencode.jsonc\"), \"utf8\") } catch { return \"\" } })())) {\n let ours = []\n try { ours = fs.readFileSync(ocDisabledLedger, \"utf8\").split(\"\\n\").filter(Boolean) } catch {}\n const userList = (Array.isArray(ocNow.disabled_providers) ? ocNow.disabled_providers : []).filter((id) => typeof id === \"string\" && !ours.includes(id))\n const managed = [\"github-copilot\", \"opencode\"].filter((id) => !connected.has(id) && !userList.includes(id))\n const next = [...userList, ...managed]\n const before = JSON.stringify(ocNow.disabled_providers ?? [])\n if (JSON.stringify(next) !== before) {\n const updated = { $schema: \"https://opencode.ai/config.json\", ...ocNow }\n if (next.length > 0) updated.disabled_providers = next\n else delete updated.disabled_providers\n write(ocFile, updated)\n }\n if (managed.length > 0) fs.writeFileSync(ocDisabledLedger, managed.join(\"\\n\") + \"\\n\", { mode: 0o600 })\n else fs.rmSync(ocDisabledLedger, { force: true })\n}\n' || echo 'agent first-run state not seeded' >&2\nfi\nprintf '{\"names\":%d}\\n' \"$count\"\n","orca-session-env.sh":"# Orca Cloud: sourced by every new bash/zsh (via /etc/profile.d) so terminals and agents started\n# after a secret edit get the current values with no restart. The file is written by\n# /opt/orca/bin/orca-sandbox-env on tmpfs; names it no longer lists are unset. Cloud secrets win\n# over same-named values already in the environment.\n__orca_env_file=\"/dev/shm/orca-env-$(id -u 2>/dev/null)/session.env\"\nif [ -r \"$__orca_env_file\" ]; then\n # $(...) word-splits in both bash and zsh.\n for __orca_n in $(printf '%s' \"${ORCA_SESSION_ENV_NAMES-}\"); do\n unset \"$__orca_n\" 2>/dev/null || true\n done\n __orca_names=''\n while IFS= read -r __orca_line || [ -n \"$__orca_line\" ]; do\n __orca_n=\"${__orca_line%%=*}\"\n case \"$__orca_n\" in '' | *[!A-Z0-9_]* | [0-9]* | ORCA_* | PATH | HOME | LD_* | DYLD_*) continue ;; esac\n export \"$__orca_n=$(printf '%s' \"${__orca_line#*=}\" | base64 -d 2>/dev/null)\"\n __orca_names=\"$__orca_names $__orca_n\"\n done <\"$__orca_env_file\"\n export ORCA_SESSION_ENV_NAMES=\"${__orca_names# }\"\nfi\n# Default branch of the cloud workspace repo, once its first-boot clone exists (computed once per shell tree).\nif [ -n \"${ORCA_WORKSPACE_PATH-}\" ] && [ -z \"${ORCA_DEFAULT_BRANCH-}\" ]; then\n __orca_branch=\"$(git -C \"$ORCA_WORKSPACE_PATH\" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)\"\n if [ -n \"$__orca_branch\" ]; then\n export ORCA_DEFAULT_BRANCH=\"${__orca_branch#origin/}\" CONDUCTOR_DEFAULT_BRANCH=\"${__orca_branch#origin/}\"\n fi\nfi\n# CR-cmd-quiet: Orca's staged agent launch (` . '/tmp/orca-launch-….sh'`) stays out of zsh history.\n# zsh records a line before running it, so the hook must exist first; the staged script itself\n# drops the line from bash's history. eval: bash parses this file too.\nif [ -n \"${ZSH_VERSION-}\" ]; then\n eval '__orca_skip_launch_history() { [[ $1 == *orca-launch-* ]] && return 1; return 0 }\n (( ${zshaddhistory_functions[(Ie)__orca_skip_launch_history]} )) || zshaddhistory_functions+=(__orca_skip_launch_history)'\nfi\nunset __orca_env_file __orca_line __orca_n __orca_names __orca_branch\n","orca-env":"#!/usr/bin/env bash\n# Prints shell lines that bring the current shell up to date with this session's cloud env (secrets\n# saved after the shell started): eval \"$(orca-env)\" or, in fish: orca-env --fish | source\n# Reads /dev/shm/orca-env-/session.env (written by orca-sandbox-env, `NAME=` lines) and\n# prints an export for each name, plus an unset for each name in $ORCA_SESSION_ENV_NAMES that is gone.\n# Never writes a file. Values are single-quoted, so quotes, newlines and $ in them stay literal.\nset -euo pipefail\nfish=''\ncase \"${1-}\" in\n '') ;;\n --fish) fish=1 ;;\n -h | --help)\n printf 'usage: eval \"$(orca-env)\" (fish: orca-env --fish | source)\\n'\n exit 0\n ;;\n *)\n printf 'orca-env: unknown option %s\\n' \"$1\" >&2\n exit 2\n ;;\nesac\n# ORCA_SESSION_ENV_FILE is for tests; secrets can't set ORCA_* names.\nfile=\"${ORCA_SESSION_ENV_FILE:-/dev/shm/orca-env-$(id -u)/session.env}\"\nset -f # names are split below, never globbed\n# Not written yet (or not a cloud VM): nothing to change, like the shell hook.\n[ -r \"$file\" ] || exit 0\n\n# 'it'\\''s' for POSIX shells; fish single quotes escape only \\ and '.\nquote() {\n local v=\"$1\"\n if [ -n \"$fish\" ]; then\n v=\"${v//\\\\/\\\\\\\\}\"\n v=\"${v//\\'/\\\\\\'}\"\n printf \"'%s'\" \"$v\"\n else\n printf \"'%s'\" \"${v//\\'/\\'\\\\\\'\\'}\"\n fi\n}\n\n# The control plane's reserved names and prefixes (cloud/apps/sandbox-control/src/secrets.ts; a test\n# keeps this in sync): a session file never sets what would hijack the shell or Orca's own processes.\nskip_name() {\n case \"$1\" in\n '' | *[!A-Z0-9_]* | [0-9]*) return 0 ;;\n LD_* | DYLD_* | ORCA_* | VERCEL_* | BASH_FUNC_* | GIT_CONFIG*) return 0 ;;\n PATH | HOME | USER | LOGNAME | SHELL | PWD | OLDPWD | LANG | TERM | TMPDIR | IFS | ENV | \\\n BASH_ENV | PS4 | PROMPT_COMMAND | SHELLOPTS | BASHOPTS | NODE_OPTIONS | NODE_PATH | \\\n NODE_EXTRA_CA_CERTS | NODE_TLS_REJECT_UNAUTHORIZED | SSL_CERT_FILE | SSL_CERT_DIR | \\\n CURL_CA_BUNDLE | REQUESTS_CA_BUNDLE | HTTP_PROXY | HTTPS_PROXY | ALL_PROXY | NO_PROXY | \\\n PYTHONPATH | PYTHONSTARTUP | PERL5OPT | RUBYOPT | JAVA_TOOL_OPTIONS | GIT_SSH | \\\n GIT_SSH_COMMAND | GIT_ASKPASS | GIT_EXEC_PATH | GIT_PROXY_COMMAND | SSH_AUTH_SOCK | \\\n GH_TOKEN | GITHUB_TOKEN) return 0 ;;\n # CLAUDE_CODE_OAUTH_TOKEN is reserved for users but set by the control plane itself: only ever the\n # Orca-account placeholder (contract D10), never a stored value.\n esac\n return 1\n}\n\nnames=' '\nwhile IFS= read -r line || [ -n \"$line\" ]; do\n name=\"${line%%=*}\"\n skip_name \"$name\" && continue\n # $(...) drops trailing newlines; the x keeps them.\n value=\"$(printf '%s' \"${line#*=}\" | base64 -d 2>/dev/null; printf x)\"\n value=\"${value%x}\"\n if [ -n \"$fish\" ]; then\n printf 'set -gx %s %s\\n' \"$name\" \"$(quote \"$value\")\"\n else\n printf 'export %s=%s\\n' \"$name\" \"$(quote \"$value\")\"\n fi\n names=\"$names$name \"\ndone <\"$file\"\nfor name in ${ORCA_SESSION_ENV_NAMES-}; do\n skip_name \"$name\" && continue\n case \"$names\" in *\" $name \"*) continue ;; esac\n if [ -n \"$fish\" ]; then\n printf 'set -e %s\\n' \"$name\"\n else\n printf 'unset %s\\n' \"$name\"\n fi\ndone\nnames=\"${names# }\"\nnames=\"${names% }\"\nif [ -n \"$fish\" ]; then\n printf 'set -gx ORCA_SESSION_ENV_NAMES %s\\n' \"$(quote \"$names\")\"\nelse\n printf 'export ORCA_SESSION_ENV_NAMES=%s\\n' \"$(quote \"$names\")\"\nfi\n","orca-egress-probe":"#!/usr/bin/env bash\n# Bounded Node fetch against each credential host. Vercel transform rules have made undici hang on\n# response bodies (no content-length / transfer-encoding); report it instead of letting agents hang.\n# Prints one JSON line: {\"host\":\"api.github.com\",\"status\":\"ok\"|\"egress_framing_hang\"|\"error\"}.\nset -euo pipefail\nnode=\"$(find /opt/orca/runtime/runtimes -type f -name node | head -n 1)\"\nfor host in \"$@\"; do\n \"$node\" -e '\n const host = process.argv[1]\n const timer = setTimeout(() => { console.log(JSON.stringify({ host, status: \"egress_framing_hang\" })); process.exit(0) }, 8000)\n fetch(`https://${host}/`, { headers: { authorization: process.env.ORCA_PROBE_AUTHORIZATION ?? \"\" } })\n .then((r) => r.arrayBuffer())\n .then(() => { clearTimeout(timer); console.log(JSON.stringify({ host, status: \"ok\" })) })\n .catch(() => { clearTimeout(timer); console.log(JSON.stringify({ host, status: \"error\" })) })\n ' \"$host\"\ndone\n","orca-image-wipe":"#!/usr/bin/env bash\n# Runs last before the golden snapshot. Every sandbox cloned from the image must start without an\n# orcad identity, device registry, agent sign-in or shell history (clone-safe identity, risk #1).\nset -euo pipefail\npkill -f orcad-server.js 2>/dev/null || true\nfor path in \\\n \"$HOME/.orcad-data\" \"$HOME/.orca-sandbox\" \"$HOME/.orca-desktop\" \"$HOME/.config/orca\" \"$HOME/.config/orca-dev\" \\\n \"$HOME/.claude\" \"$HOME/.claude.json\" \"$HOME/.codex\" \"$HOME/.config/gh\" \"$HOME/.gemini\" \\\n \"$HOME/.local/share/opencode\" \"$HOME/.config/opencode\" \"$HOME/.cursor\" \"$HOME/.config/cursor\" \\\n \"$HOME/.local/share/cursor-agent\" \"$HOME/.copilot\" \"$HOME/.npm\" \"$HOME/.cache\" \"$HOME/.config/google-chrome\" \"$HOME/.bash_history\" \\\n /vercel/sandbox/project; do\n rm -rf -- \"$path\"\ndone\nsudo rm -rf /tmp/* /var/tmp/* 2>/dev/null || true\nfor leftover in \"$HOME/.orcad-data\" \"$HOME/.claude\" \"$HOME/.codex\" \"$HOME/.config/gh\" \"$HOME/.cursor\" \"$HOME/.copilot\" \"$HOME/.gemini\"; do\n if [ -e \"$leftover\" ]; then\n echo \"identity survived wipe: $leftover\" >&2\n exit 1\n fi\ndone\necho '{\"wiped\":true}'\n","orca-agent-signout":"#!/usr/bin/env bash\n# Signs every agent out on this sandbox before Orca deletes it (A9): each agent's own logout, then\n# its credential files. Covers logins made inside the sandbox (the kill-switch fallback path).\n# Never fails: the sandbox is deleted next whatever happens. Values are never printed.\nset -u\nHOME=\"${HOME:-$(getent passwd \"$(id -u)\" | cut -d: -f6)}\"\nexport PATH=\"/opt/orca/tools/bin:$HOME/.local/bin:$HOME/.npm-global/bin:/usr/local/bin:/usr/bin:/bin:/opt/orca/bin:${PATH:-}\"\nclaude_dir=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"\nclaude_json=\"${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json\"\ncodex_dir=\"${CODEX_HOME:-$HOME/.codex}\"\n\nlogged_out=0\n# Each logout gets a few seconds; a hung CLI must not hold up the delete.\nif command -v claude >/dev/null 2>&1 && timeout 8 claude auth logout /dev/null 2>&1; then\n logged_out=$((logged_out + 1))\nfi\nif command -v codex >/dev/null 2>&1 && timeout 8 codex logout /dev/null 2>&1; then\n logged_out=$((logged_out + 1))\nfi\n\n# The files go whether or not a logout ran: an older CLI, a missing binary or no network leaves them.\nrm -f -- \"$claude_dir\"/.credentials* \"$HOME/.claude\"/.credentials* \"$codex_dir/auth.json\" \"$HOME/.codex/auth.json\" 2>/dev/null\n# Orca-managed Codex homes keep their own auth.json under orcad's data dir.\nif [ -d \"$HOME/.orcad-data\" ]; then\n find \"$HOME/.orcad-data\" -path '*codex*' -type f -name auth.json -delete 2>/dev/null\nfi\n# ~/.claude.json keeps settings Orca wrote (onboarding, trust); only the sign-in fields go.\nnode_bin=\"$(command -v node || find /opt/orca/runtime/runtimes -type f -name node 2>/dev/null | head -n 1)\"\nfor file in \"$claude_json\" \"$HOME/.claude.json\"; do\n [ -f \"$file\" ] || continue\n if [ -n \"$node_bin\" ]; then\n ORCA_FILE=\"$file\" \"$node_bin\" -e '\nconst fs = require(\"fs\")\nconst file = process.env.ORCA_FILE\nlet c\ntry { c = JSON.parse(fs.readFileSync(file, \"utf8\")) } catch { process.exit(2) }\nif (!c || typeof c !== \"object\" || Array.isArray(c)) process.exit(2)\nlet changed = false\nfor (const key of Object.keys(c)) {\n if (/oauth/i.test(key) || key === \"primaryApiKey\") { delete c[key]; changed = true }\n}\nif (changed) {\n const tmp = `${file}.orca-${process.pid}`\n fs.writeFileSync(tmp, JSON.stringify(c, null, 2) + \"\\n\", { mode: 0o600 })\n fs.renameSync(tmp, file)\n}\n' 2>/dev/null || rm -f -- \"$file\"\n else\n # No node to edit it safely: losing Orca's settings beats leaving a sign-in behind.\n rm -f -- \"$file\"\n fi\ndone\n\nleft=0\nfor file in \"$claude_dir\"/.credentials* \"$HOME/.claude\"/.credentials* \"$codex_dir/auth.json\" \"$HOME/.codex/auth.json\"; do\n [ -e \"$file\" ] && left=$((left + 1))\ndone\nprintf '{\"loggedOut\":%d,\"left\":%d}\\n' \"$logged_out\" \"$left\"\n","orca-share-proxy":"#!/usr/bin/env node\n// Share-link proxy for this VM. Listens on 0.0.0.0:6769 (its public *.vercel.run route) and only\n// serves requests the control plane's share gateway signed with this session's key:\n// x-orca-share: v2.....\n// A request must start before `expiresAt` (60s windows); its response or WebSocket is closed at\n// `until` (the link's expiry, at most 30 minutes out), so a revoked or expired link also loses open\n// streams. Signed requests are reverse-proxied to 127.0.0.1:: streaming both ways, no\n// buffering, WebSocket upgrades piped raw. Anything unsigned gets a bare 404.\n// The key arrives on stdin (orca-sandbox-start pipes it), so it is never in this process's\n// environment or argv; ORCA_SHARE_PROXY_KEY in the env is accepted for tests. Optional\n// ORCA_SHARE_PROXY_PORT (default 6769). Plain Node, no dependencies.\n// getBuiltinModule (Node >= 22.3) works whether node loads this extensionless file as CommonJS\n// (in the VM) or as an ES module (under this repo's type:module package, in tests).\nconst crypto = process.getBuiltinModule('node:crypto')\nconst fs = process.getBuiltinModule('node:fs')\nconst http = process.getBuiltinModule('node:http')\nconst net = process.getBuiltinModule('node:net')\n\nconst LISTEN_PORT = Number(process.env.ORCA_SHARE_PROXY_PORT || 6769)\nconst KEY = Buffer.from((process.env.ORCA_SHARE_PROXY_KEY || fs.readFileSync(0, 'utf8')).trim(), 'base64url')\ndelete process.env.ORCA_SHARE_PROXY_KEY\n// Never proxy to the VM's sshd, orcad or this proxy itself.\n// 6080: the remote desktop (VNC behind a password) is for its owner's forward only.\nconst BLOCKED_PORTS = new Set([2222, 6080, 6768, LISTEN_PORT])\nconst MAX_SKEW_SECONDS = 300\nconst MAX_CONNECTION_SECONDS = 3600\nconst HOP_BY_HOP = ['connection', 'keep-alive', 'proxy-connection', 'transfer-encoding', 'te', 'trailer', 'upgrade']\n\nif (KEY.length < 32) {\n console.error('orca-share-proxy: share proxy key missing or short')\n process.exit(2)\n}\n\n/** The target port and close-by time (ms) for a validly signed request, else null. */\nfunction verify(header, nowMs) {\n if (typeof header !== 'string') {\n return null\n }\n const match = /^v2\\.(\\d{1,5})\\.(\\d{1,12})\\.(\\d{1,12})\\.([0-9a-f]{32})\\.([A-Za-z0-9_-]{43})$/.exec(header)\n if (!match) {\n return null\n }\n const now = Math.floor(nowMs / 1000)\n const port = Number(match[1])\n const expiresAt = Number(match[2])\n const until = Number(match[3])\n if (port < 1 || port > 65535 || BLOCKED_PORTS.has(port)) {\n return null\n }\n if (expiresAt < now || expiresAt > now + MAX_SKEW_SECONDS || until <= now || until > now + MAX_CONNECTION_SECONDS) {\n return null\n }\n const signed = header.slice(0, header.lastIndexOf('.'))\n const expected = crypto.createHmac('sha256', KEY).update(signed).digest()\n const given = Buffer.from(match[5], 'base64url')\n return given.length === expected.length && crypto.timingSafeEqual(given, expected) ? { port, untilMs: until * 1000 } : null\n}\n\nfunction upstreamHeaders(raw, port) {\n const headers = { ...raw }\n const connectionTokens = String(raw.connection || '')\n .split(',')\n .map((token) => token.trim().toLowerCase())\n for (const name of [...HOP_BY_HOP, ...connectionTokens, 'x-orca-share']) {\n delete headers[name]\n }\n // Dev servers (Vite, Next) allow localhost hosts by default; the public host rides x-forwarded-host.\n headers.host = `localhost:${port}`\n return headers\n}\n\nfunction refuse(res) {\n res.writeHead(404, { 'content-type': 'text/plain', 'cache-control': 'no-store' })\n res.end('not found')\n}\n\n/** Ends `close` at the grant's deadline; the timer goes away with the connection. */\nfunction deadline(untilMs, close, ...emitters) {\n const timer = setTimeout(close, Math.max(0, untilMs - Date.now()))\n for (const emitter of emitters) {\n emitter.once('close', () => clearTimeout(timer))\n }\n}\n\nconst server = http.createServer((req, res) => {\n const grant = verify(req.headers['x-orca-share'], Date.now())\n if (grant === null) {\n return refuse(res)\n }\n const { port } = grant\n const upstream = http.request(\n { host: '127.0.0.1', port, method: req.method, path: req.url, headers: upstreamHeaders(req.headers, port) },\n (answer) => {\n const headers = { ...answer.headers }\n for (const name of HOP_BY_HOP) {\n delete headers[name]\n }\n // Marks the answer as the app's, so the gateway can tell it from a route with no proxy behind it.\n headers['x-orca-share-proxy'] = '1'\n res.writeHead(answer.statusCode || 502, answer.statusMessage, headers)\n answer.pipe(res)\n }\n )\n deadline(grant.untilMs, () => {\n upstream.destroy()\n res.destroy()\n }, res)\n upstream.on('error', (error) => {\n if (res.headersSent) {\n res.destroy()\n return\n }\n // The gateway renders its own page for this; the code tells it why. Status 200 on purpose: a 5xx\n // from the VM reaches a Worker as Vercel's own 502 with our headers gone (found live).\n res.writeHead(200, { 'content-type': 'text/plain', 'x-orca-share-proxy': '1', 'x-orca-share-error': error.code === 'ECONNREFUSED' ? 'no_listener' : 'upstream_failed' })\n res.end('upstream unavailable')\n })\n req.on('aborted', () => upstream.destroy())\n req.pipe(upstream)\n})\n\nserver.on('upgrade', (req, socket, head) => {\n const grant = verify(req.headers['x-orca-share'], Date.now())\n if (grant === null) {\n socket.end('HTTP/1.1 404 Not Found\\r\\nconnection: close\\r\\ncontent-length: 0\\r\\n\\r\\n')\n return\n }\n const { port } = grant\n const headers = upstreamHeaders(req.headers, port)\n const target = net.connect(port, '127.0.0.1', () => {\n const lines = [`${req.method} ${req.url} HTTP/1.1`, `connection: upgrade`, `upgrade: ${req.headers.upgrade || 'websocket'}`]\n for (const [name, value] of Object.entries(headers)) {\n for (const item of Array.isArray(value) ? value : [value]) {\n lines.push(`${name}: ${item}`)\n }\n }\n target.write(`${lines.join('\\r\\n')}\\r\\n\\r\\n`)\n if (head && head.length > 0) {\n target.write(head)\n }\n socket.pipe(target)\n target.pipe(socket)\n })\n const close = () => {\n socket.destroy()\n target.destroy()\n }\n deadline(grant.untilMs, close, socket)\n target.on('error', () => {\n if (socket.writable) {\n socket.end('HTTP/1.1 200 OK\\r\\nx-orca-share-proxy: 1\\r\\nx-orca-share-error: no_listener\\r\\nconnection: close\\r\\ncontent-length: 0\\r\\n\\r\\n')\n }\n close()\n })\n socket.on('error', close)\n target.on('close', () => socket.destroy())\n socket.on('close', () => target.destroy())\n})\n\nserver.keepAliveTimeout = 65_000\nserver.headersTimeout = 70_000\n// No request timeout: previews can stream (SSE, long polls) until the grant's deadline.\nserver.requestTimeout = 0\nserver.listen(LISTEN_PORT, '0.0.0.0', () => {\n console.log(`orca-share-proxy listening on ${LISTEN_PORT}`)\n})\n","orca-sshd":"#!/usr/bin/env bash\n# Starts this boot's unprivileged sshd on 127.0.0.1:2222 (idempotent). Nothing outside the VM can\n# reach that port: the desktop tunnels to it over orcad's runtime connection (`orca ssh-proxy`).\n# Key-only, single user (the sandbox user), no root. Its host key and authorized_keys live under\n# ~/.orca-sandbox/ssh on the persistent disk, so the host key stays the same across stop/resume;\n# orca-image-wipe removes that directory, so every VM cloned from the golden image makes its own.\n# Prints {\"port\":2222,\"user\":\"\",\"hostKey\":\"ssh-ed25519 AAAA...\"} on success.\nset -euo pipefail\numask 077\nOPENSSH=/opt/orca/openssh\nSSH_DIR=\"$HOME/.orca-sandbox/ssh\"\nPORT=2222\nmkdir -p \"$SSH_DIR\"\nchmod 0700 \"$HOME/.orca-sandbox\" \"$SSH_DIR\"\nexec 8>\"$SSH_DIR/sshd.lock\"\nflock -w 30 8\n\nif [ ! -x \"$OPENSSH/sshd\" ]; then\n echo 'sshd is not installed in this image' >&2\n exit 3\nfi\nif [ ! -s \"$SSH_DIR/ssh_host_ed25519_key\" ]; then\n rm -f \"$SSH_DIR/ssh_host_ed25519_key\" \"$SSH_DIR/ssh_host_ed25519_key.pub\"\n ssh-keygen -q -t ed25519 -N '' -C \"orca-cloud-host\" -f \"$SSH_DIR/ssh_host_ed25519_key\"\nfi\ntouch \"$SSH_DIR/authorized_keys\"\nchmod 0600 \"$SSH_DIR/authorized_keys\"\n\n# Rewritten every start so an image update's settings apply to existing workspaces. PAM stays at\n# its compiled-in default (off): an unprivileged sshd cannot use it, and this build warns on `no`.\ncat >\"$SSH_DIR/sshd_config\" </dev/null || echo 0)\"\n# Resume reboots from disk, so a recorded pid can name a dead process or an unrelated one.\nif ! { kill -0 \"$pid\" 2>/dev/null && grep -q sshd \"/proc/$pid/cmdline\" 2>/dev/null; }; then\n rm -f \"$SSH_DIR/sshd.pid\"\n : >\"$SSH_DIR/sshd.log\"\n \"$OPENSSH/sshd\" -t -f \"$SSH_DIR/sshd_config\"\n \"$OPENSSH/sshd\" -f \"$SSH_DIR/sshd_config\" -E \"$SSH_DIR/sshd.log\"\n for _ in $(seq 1 100); do\n [ -s \"$SSH_DIR/sshd.pid\" ] && break\n sleep 0.02\n done\n [ -s \"$SSH_DIR/sshd.pid\" ] || { echo 'sshd did not start' >&2; tail -n 20 \"$SSH_DIR/sshd.log\" >&2; exit 1; }\nfi\n\nhost_key=\"$(cut -d' ' -f1-2 \"$SSH_DIR/ssh_host_ed25519_key.pub\")\"\nprintf '{\"port\":%s,\"user\":\"%s\",\"hostKey\":\"%s\"}\\n' \"$PORT\" \"$(id -un)\" \"$host_key\"\n","orca-ssh-keys":"#!/usr/bin/env bash\n# `orca-ssh-keys add|remove`: adds or removes one desktop's key in the Orca-managed authorized_keys\n# of this VM's sshd, then makes sure sshd runs. The control plane passes the key in\n# ORCA_SSH_PUBLIC_KEY (`ssh-ed25519 `), never on the command line. Keys are\n# matched by their base64 blob, so a renamed comment cannot keep a revoked key alive.\n# Prints orca-sshd's {\"port\":...,\"user\":...,\"hostKey\":...} line on success.\nset -euo pipefail\numask 077\nSSH_DIR=\"$HOME/.orca-sandbox/ssh\"\naction=\"${1:-}\"\nkey=\"${ORCA_SSH_PUBLIC_KEY:-}\"\n# grep matches per line: a second line could carry key options past the check below.\ncase \"$key\" in *$'\\n'* | *$'\\r'*) echo 'invalid public key' >&2; exit 2 ;; esac\n# One line, ed25519 only, a plain comment: nothing that sshd would read as key options.\nif ! printf '%s' \"$key\" | grep -Eq '^ssh-ed25519 [A-Za-z0-9+/]{68} [A-Za-z0-9@._:+-]{1,200}$'; then\n echo 'invalid public key' >&2\n exit 2\nfi\nblob=\"$(printf '%s' \"$key\" | cut -d' ' -f2)\"\nmkdir -p \"$SSH_DIR\"\nchmod 0700 \"$HOME/.orca-sandbox\" \"$SSH_DIR\"\nexec 7>\"$SSH_DIR/keys.lock\"\nflock -w 30 7\ntouch \"$SSH_DIR/authorized_keys\"\nnext=\"$(mktemp \"$SSH_DIR/authorized_keys.XXXXXX\")\"\n# Drop the key wherever it appears (with or without options), then append it on add.\nawk -v blob=\"$blob\" '{ for (i = 1; i <= NF; i++) if ($i == blob) next; print }' \\\n \"$SSH_DIR/authorized_keys\" >\"$next\"\ncase \"$action\" in\n add) printf '%s\\n' \"$key\" >>\"$next\" ;;\n remove) ;;\n *) rm -f \"$next\"; echo 'usage: orca-ssh-keys add|remove' >&2; exit 2 ;;\nesac\nchmod 0600 \"$next\"\nmv -f \"$next\" \"$SSH_DIR/authorized_keys\"\nexec /opt/orca/bin/orca-sshd\n","orca-desktop-start":"#!/usr/bin/env bash\n# Starts the on-demand remote desktop (idempotent) and mints one connection ticket. Prints one line:\n# {\"schemaVersion\":2,\"port\":6080,\"display\":\":1\",\"path\":\"vnc.html\",\"ticket\":\"<43 chars>\",\"expiresInMs\":60000}\n# Xvnc (X server + VNC in one) listens on a 0600 unix socket only, with no VNC auth: only this uid\n# can open it. orca-desktop-proxy (websockify + a ticket gate) serves noVNC on 127.0.0.1 and\n# bridges WebSockets to that socket. Nothing is reachable from outside the VM: the desktop app\n# reaches it through Orca's port forward, a localhost port on the user's computer that any local\n# process or web page can reach, so each WebSocket needs a ticket from here: 32 random bytes,\n# single use, valid for 60 s, stored only as its sha256 (the file name; its content is the expiry).\n# The ticket goes to stdout only (the control plane hands it to the desktop app). Never started at boot.\nset -euo pipefail\numask 077\nDESKTOP=/opt/orca/desktop\nSTATE=\"$HOME/.orca-desktop\"\nPORT=\"${ORCA_DESKTOP_PORT:-6080}\"\nDISPLAY_NUM=\"${ORCA_DESKTOP_DISPLAY:-1}\"\nGEOMETRY=\"${ORCA_DESKTOP_GEOMETRY:-1280x800}\"\nSOCKET=\"$STATE/vnc.sock\"\n# The parent's DISPLAY must not leak into the WM/xterm below; they use ours.\nexport DISPLAY=\":$DISPLAY_NUM\"\n\n# Exit 3 = this image has no desktop (built before it, or its optional install failed).\nfail() { printf 'orca-desktop: %s\\n' \"$1\" >&2; exit \"${2:-1}\"; }\ncase \"$PORT\" in '' | *[!0-9]*) fail 'ORCA_DESKTOP_PORT must be a number' ;; esac\ncase \"$DISPLAY_NUM\" in '' | *[!0-9]*) fail 'ORCA_DESKTOP_DISPLAY must be a number' ;; esac\ncase \"$GEOMETRY\" in [0-9]*x[0-9]*) ;; *) fail 'ORCA_DESKTOP_GEOMETRY must look like 1280x800' ;; esac\ncommand -v Xvnc >/dev/null || fail 'not installed in this image (Xvnc missing)' 3\n[ -s \"$DESKTOP/novnc/vnc.html\" ] || fail 'not installed in this image (noVNC missing)' 3\nPROXY=\"$(dirname \"$(readlink -f \"$0\")\")/orca-desktop-proxy\"\n[ -x \"$PROXY\" ] || fail 'orca-desktop-proxy missing next to this script'\nTICKETS=\"$STATE/tickets\"\nTICKET_TTL_S=60\n# Xvnc's mode: a previous version of this script ran it with VncAuth; restart it in this mode.\nMODE=ticket-v1\n\nmkdir -p \"$STATE\"\nexec 9>\"$STATE/start.lock\"\nflock -w 30 9 || fail 'another start is in progress'\n\npid_of() { # never \"0\": kill 0 targets our own process group\n local pid\n pid=\"$(cat \"$STATE/$1.pid\" 2>/dev/null || true)\"\n case \"$pid\" in '' | 0 | *[!0-9]*) return 1 ;; esac\n printf '%s' \"$pid\"\n}\nalive() { local pid; pid=\"$(pid_of \"$1\")\" && kill -0 \"$pid\" 2>/dev/null; }\nmint_ticket() { # prints the ticket; stores only its sha256, with the expiry as content\n local ticket now digest\n mkdir -p \"$TICKETS\"\n now=\"$(date +%s)\"\n # Expired or spent-but-unswept tickets; keep at most 16 live ones (oldest go first).\n for file in \"$TICKETS\"/*; do\n [ -f \"$file\" ] || continue\n [ \"$(cat \"$file\" 2>/dev/null || echo 0)\" -ge \"$now\" ] 2>/dev/null || rm -f \"$file\"\n done\n # shellcheck disable=SC2012 # names are hex digests\n ls -1t \"$TICKETS\" 2>/dev/null | tail -n +16 | while read -r old; do rm -f \"$TICKETS/$old\"; done\n ticket=\"$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=\\n')\"\n [ \"${#ticket}\" = 43 ] || fail 'could not generate a ticket'\n digest=\"$(printf '%s' \"$ticket\" | sha256sum | cut -d' ' -f1)\"\n printf '%s' \"$((now + TICKET_TTL_S))\" >\"$TICKETS/.$digest\" && mv \"$TICKETS/.$digest\" \"$TICKETS/$digest\"\n printf '%s' \"$ticket\"\n}\nready_line() {\n local ticket\n ticket=\"$(mint_ticket)\" || exit 1\n printf '{\"schemaVersion\":2,\"port\":%s,\"display\":\":%s\",\"path\":\"vnc.html\",\"ticket\":\"%s\",\"expiresInMs\":%s}\\n' \\\n \"$PORT\" \"$DISPLAY_NUM\" \"$ticket\" \"$((TICKET_TTL_S * 1000))\"\n}\nlistening() { # the proxy answers HTTP on loopback\n curl -fsS -o /dev/null --max-time 1 \"http://127.0.0.1:$PORT/vnc.html\" 2>/dev/null\n}\n\n# Pids from a previous boot (resume = reboot from disk) name dead or unrelated processes.\nboot_id=\"$(cat /proc/sys/kernel/random/boot_id)\"\nif [ \"$(cat \"$STATE/boot-id\" 2>/dev/null || true)\" != \"$boot_id\" ]; then\n rm -rf \"$STATE\"/*.pid \"$SOCKET\" \"$STATE/password\" \"$STATE/passwd\" \"$STATE/mode\" \"$TICKETS\"\n printf '%s' \"$boot_id\" >\"$STATE/boot-id\"\nfi\n\n# Same boot, older script: its websockify had no ticket gate and its Xvnc wanted a VNC password.\nrm -f \"$STATE/password\" \"$STATE/passwd\"\nif alive websockify; then kill \"$(pid_of websockify)\" 2>/dev/null || true; fi\nrm -f \"$STATE/websockify.pid\"\ncurrent_mode() { cat \"$STATE/mode\" 2>/dev/null || true; }\n\nif alive xvnc && alive proxy && listening && [ \"$(current_mode)\" = \"$MODE\" ]; then\n ready_line\n exit 0\nfi\n\nstart() { # name cmd... — detached, logs in $STATE/.log\n local name=$1; shift\n # 9>&-: children must not inherit the start lock, or the next call waits for them to exit.\n setsid nohup \"$@\" >\"$STATE/$name.log\" 2>&1 &- &\n echo $! >\"$STATE/$name.pid\"\n}\n\nif ! alive xvnc || [ \"$(current_mode)\" != \"$MODE\" ]; then\n if alive xvnc; then kill \"$(pid_of xvnc)\" 2>/dev/null || true; sleep 0.2; fi\n rm -f \"$SOCKET\"\n # -rfbport -1: no TCP VNC port at all, only a 0600 unix socket (in the 0700 state dir) for the\n # proxy. SecurityTypes None is safe only because of that: the ticket gate is in the proxy.\n # SendPrimary=0: only explicit copies (xterm copies its selection, below) leave the sandbox.\n # The X display itself takes a cookie from ~/.Xauthority (Xlib's default for this user), so only\n # this uid's processes (agents, apps they start on DISPLAY=:1) can open it.\n auth=()\n if command -v xauth >/dev/null && command -v mcookie >/dev/null; then\n xauth -q -f \"$HOME/.Xauthority\" add \":$DISPLAY_NUM\" . \"$(mcookie)\" 9>&- && auth=(-auth \"$HOME/.Xauthority\")\n fi\n start xvnc Xvnc \":$DISPLAY_NUM\" \"${auth[@]}\" -geometry \"$GEOMETRY\" -depth 24 -nolisten tcp \\\n -rfbport -1 -rfbunixpath \"$SOCKET\" -rfbunixmode 0600 \\\n -SecurityTypes None -SendPrimary=0 \\\n -AlwaysShared -AcceptSetDesktopSize -desktop 'Remote desktop'\n printf '%s' \"$MODE\" >\"$STATE/mode\"\n for _ in $(seq 1 100); do\n [ -S \"$SOCKET\" ] && break\n alive xvnc || { tail -n 20 \"$STATE/xvnc.log\" >&2; fail 'Xvnc exited'; }\n sleep 0.05\n done\n [ -S \"$SOCKET\" ] || fail 'Xvnc did not create its socket'\n if command -v xsetroot >/dev/null; then xsetroot -solid '#1e1f22' 9>&- || true; fi\n if command -v openbox >/dev/null; then\n # Dark, flat theme to match Orca; the first terminal stays maximized as the tab resizes the screen.\n printf '%s\\n' '' \\\n 'Onyx' \\\n 'yes' \\\n '' >\"$STATE/openbox.xml\"\n start wm openbox --config-file \"$STATE/openbox.xml\"\n # Let it manage the screen before the terminal maps, so its maximize rule applies.\n sleep 0.2\n elif command -v twm >/dev/null; then\n start wm twm\n fi\n if command -v xterm >/dev/null; then\n # selectToClipboard: a selection is a copy (to CLIPBOARD, which reaches this computer).\n start xterm xterm -maximized -bg '#1e1f22' -fg '#d4d4d4' -fa 'Liberation Mono,Monospace' -fs 11 \\\n -xrm 'XTerm*selectToClipboard: true'\n fi\nfi\n\nif ! alive proxy || ! listening; then\n if alive proxy; then kill \"$(pid_of proxy)\" 2>/dev/null || true; sleep 0.1; fi\n mkdir -p \"$TICKETS\"\n PYTHONPATH=\"$DESKTOP/websockify\" start proxy python3 -P -W ignore::UserWarning \"$PROXY\" \\\n --listen \"$PORT\" --web \"$DESKTOP/novnc\" --unix-target \"$SOCKET\" --tickets \"$TICKETS\"\n for _ in $(seq 1 100); do\n listening && break\n alive proxy || { tail -n 20 \"$STATE/proxy.log\" >&2; fail 'orca-desktop-proxy exited'; }\n sleep 0.05\n done\n listening || fail \"orca-desktop-proxy is not answering on 127.0.0.1:$PORT\"\nfi\nready_line\n","orca-desktop-proxy":"#!/usr/bin/env python3\n# Remote desktop gateway, started by orca-desktop-start: serves noVNC on 127.0.0.1 and bridges one\n# WebSocket per single-use ticket to Xvnc's 0600 unix socket (Xvnc itself has no VNC auth: only this\n# uid can open the socket). The port is reached only through Orca's localhost forward on the user's\n# computer, where any local process or web page could knock, so every WebSocket must carry an\n# unused, unexpired ticket minted by orca-desktop-start (see consume_ticket), from a page on the same\n# localhost origin. Tickets never reach a log: paths are scrubbed before websockify logs them.\n# orca-desktop-proxy --listen PORT --web DIR --unix-target SOCKET --tickets DIR\n# stdlib + websockify (pinned tarball in /opt/orca/desktop/websockify, on PYTHONPATH) only.\nimport argparse\nimport hashlib\nimport hmac\nimport logging\nimport os\nimport re\nimport sys\nimport time\nfrom urllib.parse import parse_qs, urlsplit\n\nfrom websockify import websocketproxy\n\nTICKET_RE = re.compile(r'^[A-Za-z0-9_-]{43}$') # 32 random bytes, base64url without padding\nSCRUB_RE = re.compile(r'([?&]ticket=)[^&\\s\\'\"]*')\nLOOPBACK_HOST_RE = re.compile(r'^(localhost|127\\.0\\.0\\.1|\\[::1\\])(:[0-9]{1,5})?$')\nWS_PATH = '/websockify'\nPAGE_SCRIPT = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'orca-desktop-page.js')\n# The page may only talk to its own origin (its WebSocket included): even a URL that tries to point\n# noVNC elsewhere cannot carry a ticket or the user's keys off this forward.\nPAGE_CSP = \"connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'\"\nPAGE_TAG = b'\\n'\n\n\ndef scrub(text):\n return SCRUB_RE.sub(r'\\1', str(text))\n\n\ndef ticket_digest(ticket):\n return hashlib.sha256(ticket.encode('ascii')).hexdigest()\n\n\ndef consume_ticket(directory, ticket, now=None):\n \"\"\"True once per minted ticket, within its lifetime. The ticket file (named by the ticket's\n sha256, holding its expiry) is unlinked on first use, so a replay or a racing second\n connection finds nothing; each request runs in its own forked process, so state is on disk.\"\"\"\n if not isinstance(ticket, str) or not TICKET_RE.match(ticket):\n return False\n digest = ticket_digest(ticket)\n match = None\n try:\n names = os.listdir(directory)\n except OSError:\n return False\n for name in names:\n # Compare every entry in constant time; never short-circuit on a prefix.\n if hmac.compare_digest(name.encode('ascii', 'replace'), digest.encode('ascii')):\n match = name\n if match is None:\n return False\n path = os.path.join(directory, match)\n try:\n with open(path, 'r', encoding='ascii') as handle:\n expires_at = int(handle.read().strip() or '0')\n except (OSError, ValueError):\n expires_at = 0\n try:\n os.unlink(path) # the consume: only one caller can win this\n except FileNotFoundError:\n return False\n return (now if now is not None else time.time()) <= expires_at\n\n\ndef same_loopback_origin(headers):\n \"\"\"The page and the WebSocket share one localhost origin (blocks other local sites and DNS\n rebinding). The port is the user's local forward, which may differ from ours, so compare the\n Origin with the Host the browser dialed rather than with a fixed port.\"\"\"\n host = (headers.get('Host') or '').strip().lower()\n origin = (headers.get('Origin') or '').strip().lower()\n return loopback_host(host) and origin == 'http://' + host\n\n\ndef loopback_host(host):\n return bool(LOOPBACK_HOST_RE.match(host or ''))\n\n\nclass TicketedProxyHandler(websocketproxy.ProxyRequestHandler):\n \"\"\"websockify's proxy handler plus the ticket + origin gate. Module level (not a closure) so\n websockify's per-connection multiprocessing can pickle it under any start method.\"\"\"\n\n def log_message(self, format, *args): # noqa: A002 (websockify's signature)\n super().log_message('%s', scrub(format % args if args else format))\n\n def reject(self, code, reason):\n self.send_response(code)\n self.send_header('Content-Length', '0')\n self.send_header('Connection', 'close')\n self.end_headers()\n self.close_connection = True\n raise self.server.EClose('desktop: websocket refused (%s)' % reason)\n\n def validate_connection(self):\n parts = urlsplit(self.path)\n ticket = (parse_qs(parts.query).get('ticket') or [''])[0]\n # Drop it before anything below (websockify's own logging included) can see self.path.\n self.path = parts.path\n if parts.path != WS_PATH:\n self.reject(404, 'path')\n if not same_loopback_origin(self.headers):\n self.reject(403, 'origin')\n if not consume_ticket(self.server.orca_tickets_dir, ticket):\n self.reject(403, 'ticket')\n\n def loopback_request(self):\n if loopback_host((self.headers.get('Host') or '').strip().lower()):\n return True\n self.send_error(403)\n return False\n\n def do_GET(self):\n if not self.loopback_request():\n return None\n path = urlsplit(self.path).path\n if path == '/orca-desktop-page.js':\n return self.send_bytes(read_file(PAGE_SCRIPT), 'text/javascript; charset=utf-8')\n if path == '/vnc.html':\n page = read_file(os.path.join(self.server.web, 'vnc.html'))\n return self.send_bytes(page.replace(b'', PAGE_TAG, 1), 'text/html; charset=utf-8', PAGE_CSP)\n return super().do_GET()\n\n def do_HEAD(self):\n if not self.loopback_request():\n return None\n return super().do_HEAD()\n\n def send_bytes(self, body, content_type, csp=None):\n self.send_response(200)\n self.send_header('Content-Type', content_type)\n if csp:\n self.send_header('Content-Security-Policy', csp)\n self.send_header('Content-Length', str(len(body)))\n self.send_header('Cache-Control', 'no-store')\n self.send_header('Referrer-Policy', 'no-referrer')\n self.end_headers()\n self.wfile.write(body)\n return None\n\n\ndef read_file(path):\n with open(path, 'rb') as handle:\n return handle.read()\n\n\ndef main(argv):\n parser = argparse.ArgumentParser()\n parser.add_argument('--listen', type=int, required=True)\n parser.add_argument('--web', required=True)\n parser.add_argument('--unix-target', required=True)\n parser.add_argument('--tickets', required=True)\n args = parser.parse_args(argv)\n logging.basicConfig(level=logging.INFO, format='%(message)s')\n server = websocketproxy.WebSocketProxy(\n RequestHandlerClass=TicketedProxyHandler,\n listen_host='127.0.0.1',\n listen_port=args.listen,\n web=args.web,\n file_only=True,\n unix_target=args.unix_target,\n )\n server.orca_tickets_dir = os.path.abspath(args.tickets)\n server.start_server()\n\n\nif __name__ == '__main__':\n main(sys.argv[1:])\n","orca-desktop-page.js":"// Orca's glue for noVNC, injected into vnc.html by orca-desktop-proxy. Orca (the desktop app's\n// main process) drives it through window.orcaDesktop:\n// - tickets: the page never connects by itself. Each WebSocket needs a fresh single-use ticket,\n// which Orca hands over with connect(ticket) after nextTicketRequest() resolves: 'ticket' on\n// load or after a session dropped, 'retry' after an attempt that never connected. Tickets live\n// only in memory here: never in the URL, storage or noVNC's settings.\n// - pause(message, label): Orca stopped retrying; show why, with a Reconnect button. resume()\n// (Orca reopened the desktop) or the button asks for a ticket again.\n// - fit: resize=remote makes Xvnc follow the tab; a server without SetDesktopSize gets scaling.\n// - clipboard: a copy inside the desktop right after user input there goes to this computer's\n// clipboard; paste(text, chord) sets the sandbox's clipboard first, then replays the paste key.\nimport UI from './app/ui.js';\nimport * as WebUtil from './app/webutil.js';\n\nconst WS_PATH = 'websockify';\nconst RESIZE_FALLBACK_MS = 1500;\n// A copy inside the desktop follows a copy key or a selection (drag or double-click) there; other\n// clipboard changes (an agent in the sandbox running xclip) must not overwrite this computer's\n// clipboard. Plain typing does not open the window.\nconst USER_COPY_WINDOW_MS = 1500;\nconst DRAG_PX = 4;\nconst MAX_CLIPBOARD_CHARS = 1 << 20;\nconst TICKET_RE = /^[A-Za-z0-9_-]{43}$/;\nconst XK = { v: 0x0076, V: 0x0056, Insert: 0xff63, Shift_L: 0xffe1, Super_L: 0xffeb, Super_R: 0xffec };\n\nlet state = 'idle'; // idle | connecting | connected | disconnected | paused\nlet attemptConnected = true;\nlet ticketWaiters = [];\nlet lastCopyIntent = 0;\nlet pointerStart = null;\n\nconst style = document.createElement('style');\n// Orca owns connecting and the chrome: noVNC's Connect button would only try without a ticket,\n// and its control bar duplicates what the Orca tab does (fit, clipboard, keyboard release).\n// The pause card follows Orca's theme: Orca sets Electron's theme source from its own setting, so\n// prefers-color-scheme here is Orca's light or dark, and it follows a switch while paused. The\n// page cannot read Orca's tokens, so these mirror them (popover, border, focus ring).\nstyle.textContent = `\n#noVNC_connect_dlg, #noVNC_control_bar_anchor { display: none !important; }\n#orca_pause { --orca-scrim: rgb(10 10 10 / 0.88); --orca-card: #171717; --orca-text: #f0f0f0;\n --orca-border: rgb(255 255 255 / 0.1); --orca-control: rgb(255 255 255 / 0.25);\n --orca-control-hover: rgb(255 255 255 / 0.08); --orca-focus: color-mix(in oklab, #f0f0f0 65%, #0a0a0a);\n color-scheme: dark; position: fixed; inset: 0; display: none; align-items: center;\n justify-content: center; padding: 16px; background: var(--orca-scrim); z-index: 1000;\n font: 13px/1.5 system-ui, sans-serif; }\n@media (prefers-color-scheme: light) {\n #orca_pause { --orca-scrim: rgb(255 255 255 / 0.88); --orca-card: #fff; --orca-text: #0a0a0a;\n --orca-border: #e5e5e5; --orca-control: #a3a3a3; --orca-control-hover: #f5f5f5;\n --orca-focus: color-mix(in oklab, #0a0a0a 55%, #fff); color-scheme: light; }\n}\n#orca_pause.open { display: flex; }\n#orca_pause > div { max-width: 360px; padding: 16px; border: 1px solid var(--orca-border);\n border-radius: 8px; background: var(--orca-card); color: var(--orca-text); text-align: center;\n box-shadow: 0 8px 24px rgb(0 0 0 / 0.16); }\n#orca_pause p { margin: 0; }\n#orca_pause button { margin-top: 12px; padding: 4px 12px; border: 1px solid var(--orca-control);\n border-radius: 6px; background: transparent; color: var(--orca-text); font: inherit; cursor: pointer; }\n#orca_pause button:hover { background: var(--orca-control-hover); }\n#orca_pause button:focus-visible { outline: 2px solid var(--orca-focus); outline-offset: 2px; }\n@media (prefers-reduced-motion: no-preference) {\n #orca_pause.open > div { animation: orca-pause-in 120ms ease-out; }\n}\n@keyframes orca-pause-in { from { opacity: 0; transform: translateY(4px); } }\n`;\ndocument.head.appendChild(style);\n\nconst overlay = document.createElement('div');\noverlay.id = 'orca_pause';\noverlay.innerHTML = '

';\noverlay.querySelector('button').addEventListener('click', () => resume());\ndocument.addEventListener('DOMContentLoaded', () => document.body.appendChild(overlay));\n\nwindow.addEventListener('keydown', (event) => {\n if ((event.ctrlKey || event.metaKey) && !event.altKey && (event.key === 'c' || event.key === 'C' || event.key === 'Insert')) {\n lastCopyIntent = Date.now();\n }\n}, true);\nwindow.addEventListener('pointerdown', (event) => {\n pointerStart = { x: event.clientX, y: event.clientY };\n}, true);\n// A double- or triple-click selects a word or line (pointer events carry no click count).\nwindow.addEventListener('mousedown', (event) => {\n if (event.detail >= 2) {\n lastCopyIntent = Date.now();\n }\n}, true);\nwindow.addEventListener('pointerup', (event) => {\n if (pointerStart && Math.hypot(event.clientX - pointerStart.x, event.clientY - pointerStart.y) >= DRAG_PX) {\n lastCopyIntent = Date.now();\n }\n pointerStart = null;\n}, true);\n\nfunction requestTicket() {\n const answer = attemptConnected ? 'ticket' : 'retry';\n const waiters = ticketWaiters;\n ticketWaiters = [];\n waiters.forEach((resolve) => resolve(answer));\n}\n\nasync function whenStarted() {\n while (document.documentElement.classList.contains('noVNC_loading')) {\n await new Promise((resolve) => setTimeout(resolve, 25));\n }\n}\n\nfunction onConnect() {\n state = 'connected';\n attemptConnected = true;\n UI.hideStatus();\n setTimeout(() => {\n // Xvnc answers SetDesktopSize; anything that does not gets scaled into the tab instead.\n if (UI.rfb && state === 'connected' && !UI.rfb._supportsSetDesktopSize) {\n UI.rfb.scaleViewport = true;\n }\n }, RESIZE_FALLBACK_MS);\n}\n\nfunction onDisconnect() {\n if (state === 'paused') {\n return;\n }\n state = 'disconnected';\n // The ticket is spent: ask Orca for a new one (a dropped forward, a restart of the desktop).\n requestTicket();\n}\n\nfunction onClipboard(event) {\n const text = event.detail && event.detail.text;\n if (typeof text !== 'string' || text.length > MAX_CLIPBOARD_CHARS) {\n return;\n }\n if (Date.now() - lastCopyIntent > USER_COPY_WINDOW_MS || !document.hasFocus()) {\n return;\n }\n navigator.clipboard.writeText(text).catch(() => {});\n}\n\nasync function connect(ticket) {\n if (typeof ticket !== 'string' || !TICKET_RE.test(ticket)) {\n return 'invalid';\n }\n if (state === 'connecting' || state === 'connected' || state === 'paused') {\n return state;\n }\n await whenStarted();\n if (UI.rfb) {\n return 'connecting';\n }\n state = 'connecting';\n attemptConnected = false;\n // UI.connect reads the path setting synchronously and opens the WebSocket in RFB's constructor;\n // the in-memory setting is restored right after, so the ticket is never kept or shown.\n // Only ever this page's own origin: noVNC also reads host, port, encrypt, repeater and logging\n // from the URL, which must never send a ticketed session (and the user's keys) elsewhere.\n WebUtil.setSetting('host', '');\n WebUtil.setSetting('port', '');\n WebUtil.setSetting('encrypt', false);\n WebUtil.setSetting('repeaterID', '');\n WebUtil.setSetting('path', `${WS_PATH}?ticket=${ticket}`);\n try {\n UI.connect();\n } finally {\n WebUtil.setSetting('path', WS_PATH);\n }\n const rfb = UI.rfb;\n if (!rfb) {\n state = 'disconnected';\n requestTicket();\n return 'failed';\n }\n rfb.addEventListener('connect', onConnect);\n rfb.addEventListener('disconnect', onDisconnect);\n rfb.addEventListener('clipboard', onClipboard);\n return 'connecting';\n}\n\n/** Pastes this computer's clipboard: the sandbox's clipboard first, then the paste key itself. */\nfunction paste(text, chord) {\n const rfb = UI.rfb;\n if (!rfb || state !== 'connected' || typeof text !== 'string' || text.length > MAX_CLIPBOARD_CHARS) {\n return false;\n }\n rfb.clipboardPasteFrom(text);\n if (chord && chord.kind === 'mac') {\n // Cmd+V: Linux apps do not paste on Super+V, so release Super and send Shift+Insert.\n rfb.sendKey(XK.Super_L, 'MetaLeft', false);\n rfb.sendKey(XK.Super_R, 'MetaRight', false);\n rfb.sendKey(XK.Shift_L, 'ShiftLeft', true);\n rfb.sendKey(XK.Insert, 'Insert');\n rfb.sendKey(XK.Shift_L, 'ShiftLeft', false);\n } else if (chord && chord.kind === 'replay') {\n // Ctrl+V, Ctrl+Shift+V, Shift+Insert: the held modifiers already reached the sandbox.\n const insert = chord.key === 'Insert';\n rfb.sendKey(insert ? XK.Insert : chord.key === 'V' ? XK.V : XK.v, insert ? 'Insert' : 'KeyV');\n }\n return true;\n}\n\nfunction pause(message, label, tone) {\n state = 'paused';\n if (UI.rfb) {\n UI.rfb.disconnect();\n }\n UI.hideStatus();\n overlay.querySelector('p').textContent = String(message || '');\n overlay.querySelector('button').textContent = String(label || 'Reconnect');\n // Asleep and starting are states, not errors: announce them politely.\n overlay.firstElementChild.setAttribute('role', tone === 'neutral' ? 'status' : 'alert');\n overlay.classList.add('open');\n overlay.querySelector('button').focus();\n return true;\n}\n\nfunction resume() {\n if (state !== 'paused') {\n return false;\n }\n overlay.classList.remove('open');\n state = 'disconnected';\n attemptConnected = true; // a person asked: start the retry budget over\n requestTicket();\n return true;\n}\n\nObject.defineProperty(window, 'orcaDesktop', {\n value: Object.freeze({\n version: 1,\n state: () => state,\n /** Resolves when the page needs a ticket: now if it is not connected, else after a drop. */\n nextTicketRequest: () => new Promise((resolve) => {\n ticketWaiters.push(resolve);\n if (state === 'idle' || state === 'disconnected') {\n requestTicket();\n }\n }),\n connect,\n paste,\n pause,\n resume\n })\n});\n","orca-sandbox-grant":"#!/usr/bin/env bash\n# Mints a fresh, single-device orcad pairing offer for one org member's device and seals it to that\n# device's X25519 key, so the control plane relays only ciphertext (the VM owner's E2EE model holds:\n# every member device gets its own revocable orcad credential, nobody else can read it).\n# Env from the control plane: ORCA_GRANT_ID, ORCA_GRANT_RECIPIENT_KEY (base64 raw X25519 public\n# key), ORCA_GRANT_KIND (desktop|mobile), ORCA_PUBLIC_WSS. Prints one JSON line:\n# {\"deviceId\":\"…\",\"sealed\":{\"v\":1,\"ephemeralPublicKeyB64\":\"…\",\"nonceB64\":\"…\",\"ciphertextB64\":\"…\"}}\n# Sealing: ECDH(ephemeral, recipient) -> HKDF-SHA256(salt = eph||recipient, info\n# \"orca-cloud-grant-v1\") -> AES-256-GCM, AAD \"orca-cloud-grant-v1:\".\nset -euo pipefail\numask 077\nORCA_HOME=/opt/orca\nSTATE=\"$HOME/.orca-sandbox\"\nDATA=\"$HOME/.orcad-data\"\nmkdir -p \"$STATE\"\nexec 9>\"$STATE/grant.lock\"\n# One mint at a time: --rotate retires unredeemed offers, so two members never share a credential.\nflock -w 30 9\n\ncase \"${ORCA_GRANT_ID:-}\" in '' | *[!A-Za-z0-9_-]*) echo 'bad grant id' >&2; exit 2 ;; esac\ncase \"${ORCA_GRANT_KIND:-}\" in desktop) scope=() ;; mobile) scope=(--mobile) ;; *) echo 'bad kind' >&2; exit 2 ;; esac\ncase \"${ORCA_GRANT_RECIPIENT_KEY:-}\" in '' | *[!A-Za-z0-9+/=]*) echo 'bad key' >&2; exit 2 ;; esac\ncase \"${ORCA_PUBLIC_WSS:-}\" in wss://*) ;; *) echo 'bad endpoint' >&2; exit 2 ;; esac\n\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node | head -n 1)\"\ncli=\"$ORCA_HOME/runtime/orcad/out/cli/index.js\"\n# The workspace's project must exist on this orcad before a phone opens it: phones may not add\n# projects themselves (mobile RPC scope), and a phone may be the first client this VM ever sees.\nproject=/vercel/sandbox/project\nif [ -d \"$project\" ]; then\n kind=folder\n [ -d \"$project/.git\" ] && kind=git\n ORCA_USER_DATA_PATH=\"$DATA\" timeout 20 \"$node\" \"$cli\" repo add --path \"$project\" --kind \"$kind\" --json \\\n >/dev/null 2>&1 || true\nfi\noffer=\"$(ORCA_USER_DATA_PATH=\"$DATA\" timeout 30 \"$node\" \"$cli\" pair --json --rotate \"${scope[@]}\" \\\n --address \"$ORCA_PUBLIC_WSS\" 2>/dev/null)\" || { echo 'pair failed' >&2; exit 1; }\n# This workspace's read-snapshot signer (public half), so the paired device pins it; best effort.\nsigner=\"$(\"$node\" \"$ORCA_HOME/bin/orca-read-snapshot.cjs\" --signer-public-key 2>/dev/null || true)\"\n# The offer goes to the sealer on stdin (never argv or disk); only the sealed form is printed.\n# Granted devices are listed so orcad can keep owner-only features (local commands) from them.\nprintf '%s' \"$offer\" | ORCA_GRANT_ID=\"$ORCA_GRANT_ID\" ORCA_GRANT_RECIPIENT_KEY=\"$ORCA_GRANT_RECIPIENT_KEY\" ORCA_READ_SIGNER=\"$signer\" \\\n ORCA_GRANT_LEDGER=\"$STATE/grant-devices\" \"$node\" -e '\nconst crypto = require(\"node:crypto\")\nlet input = \"\"\nprocess.stdin.on(\"data\", (c) => (input += c)).on(\"end\", () => {\n const res = JSON.parse(input).result\n if (!res || res.available !== true || !res.pairingUrl || !res.deviceId) {\n console.error(\"offer unavailable\")\n process.exit(1)\n }\n if (process.env.ORCA_GRANT_LEDGER) require(\"node:fs\").appendFileSync(process.env.ORCA_GRANT_LEDGER, res.deviceId + \"\\n\", { mode: 0o600 })\n const recipient = Buffer.from(process.env.ORCA_GRANT_RECIPIENT_KEY, \"base64\")\n if (recipient.length !== 32) { console.error(\"bad key length\"); process.exit(2) }\n const recipientKey = crypto.createPublicKey({ key: { kty: \"OKP\", crv: \"X25519\", x: recipient.toString(\"base64url\") }, format: \"jwk\" })\n const eph = crypto.generateKeyPairSync(\"x25519\")\n const ephRaw = Buffer.from(eph.publicKey.export({ format: \"jwk\" }).x, \"base64url\")\n const shared = crypto.diffieHellman({ privateKey: eph.privateKey, publicKey: recipientKey })\n const key = Buffer.from(crypto.hkdfSync(\"sha256\", shared, Buffer.concat([ephRaw, recipient]), \"orca-cloud-grant-v1\", 32))\n const nonce = crypto.randomBytes(12)\n const cipher = crypto.createCipheriv(\"aes-256-gcm\", key, nonce)\n cipher.setAAD(Buffer.from(\"orca-cloud-grant-v1:\" + process.env.ORCA_GRANT_ID))\n const signer = /^[A-Za-z0-9+/]{43}=$/.test(process.env.ORCA_READ_SIGNER || \"\") ? process.env.ORCA_READ_SIGNER : undefined\n const body = Buffer.from(JSON.stringify({ pairingUrl: res.pairingUrl, deviceId: res.deviceId, scope: res.scope, ...(signer ? { readSignerPublicKeyB64: signer } : {}) }))\n const ct = Buffer.concat([cipher.update(body), cipher.final(), cipher.getAuthTag()])\n process.stdout.write(JSON.stringify({ deviceId: res.deviceId, sealed: { v: 1, ephemeralPublicKeyB64: ephRaw.toString(\"base64\"), nonceB64: nonce.toString(\"base64\"), ciphertextB64: ct.toString(\"base64\") } }) + \"\\n\")\n})'\n","orca-sandbox-revoke":"#!/usr/bin/env bash\n# Revokes member devices from orcad's device registry. orcad adopts on-disk removals at every auth\n# (an admin revoke, device-registry-store.ts), so this works whether or not orcad is running; the\n# control plane also runs it at boot, before orcad starts, for revokes queued while suspended.\n# Env: ORCA_REVOKE_DEVICE_IDS (comma list of orcad device ids). Prints {\"revoked\":,\"live\":}.\nset -euo pipefail\numask 077\nSTATE=\"$HOME/.orca-sandbox\"\nREGISTRY=\"$HOME/.orcad-data/orca-devices.json\"\nmkdir -p \"$STATE\"\nexec 9>\"$STATE/grant.lock\"\nflock -w 30 9\n\nids=\"${ORCA_REVOKE_DEVICE_IDS:-}\"\ncase \"$ids\" in '' | *[!A-Za-z0-9,_-]*) echo 'bad device ids' >&2; exit 2 ;; esac\n# Live orcad first: `orca pair --revoke` also closes the device's open sockets now. Older orcad\n# builds lack it; the on-disk removal below still blocks their next sign-in.\nORCA_HOME=/opt/orca\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node 2>/dev/null | head -n 1)\"\ncli=\"$ORCA_HOME/runtime/orcad/out/cli/index.js\"\nlive=0\nif [ -n \"$node\" ] && [ -f \"$cli\" ] && [ -s \"$HOME/.orcad-data/orca-runtime.json\" ]; then\n for id in ${ids//,/ }; do\n if ORCA_USER_DATA_PATH=\"$HOME/.orcad-data\" timeout 15 \"$node\" \"$cli\" pair --revoke \"$id\" --json 2>/dev/null \\\n | jq -e '.ok == true and .result.revoked == true' >/dev/null 2>&1; then\n live=$((live + 1))\n fi\n done\nfi\nif [ ! -s \"$REGISTRY\" ]; then\n echo \"{\\\"revoked\\\":$live,\\\"live\\\":$live}\"\n exit 0\nfi\nids_json=\"$(jq -cn --arg ids \"$ids\" '$ids | split(\",\") | map(select(length > 0))')\"\nbefore=\"$(jq 'length' \"$REGISTRY\")\"\ntmp=\"$(mktemp \"$REGISTRY.revoke.XXXXXX\")\"\n# Removes the rows and any phones delegated through them (parentDeviceId), like orcad's own revoke.\njq --argjson ids \"$ids_json\" \\\n '[.[] | select((.deviceId as $d | $ids | index($d)) == null and ((.parentDeviceId // \"\") as $p | $ids | index($p)) == null)]' \\\n \"$REGISTRY\" >\"$tmp\"\nchmod 0600 \"$tmp\"\nsync \"$tmp\" 2>/dev/null || true\nmv -f \"$tmp\" \"$REGISTRY\"\nafter=\"$(jq 'length' \"$REGISTRY\")\"\necho \"{\\\"revoked\\\":$((live + before - after)),\\\"live\\\":$live}\"\n","orca-sandbox-members":"#!/usr/bin/env bash\n# Runs as root (control plane, sudo). Installs who-may-push-as-whom for `orca-git-as`:\n# /etc/orca/members/.gitconfig egress placeholder header + GitHub author identity\n# /etc/orca/members/devices.json {\"\": \"\"} (each member's paired devices)\n# Both root-only: agents never read another member's placeholder. `orca-git-identity` (root, via a\n# sudoers rule) hands an agent only the identity of the member whose device drove its turn, as orcad\n# attests it. No token lives here: the placeholder is swapped at egress, for this repository only.\n# Env: ORCA_MEMBERS_B64 = base64 JSON [{userId, name, email, authorization, deviceIds:[...]}]\n# The VM user agents run as is orcad's own user (detected). Prints {\"members\":,\"devices\":}.\nset -euo pipefail\numask 077\nDIR=/etc/orca/members\norcad_pid=\"$(pgrep -f orcad-server.js | head -n 1 || true)\"\nUSER_NAME=\"$( [ -n \"$orcad_pid\" ] && ps -o user= -p \"$orcad_pid\" | tr -d ' ' || true)\"\nUSER_NAME=\"${USER_NAME:-${ORCA_SANDBOX_USER:-${SUDO_USER:-}}}\"\ncase \"$USER_NAME\" in '' | *[!a-z0-9_-]*) echo 'bad user' >&2; exit 2 ;; esac\nmkdir -p /etc/orca\njson=\"$(printf '%s' \"${ORCA_MEMBERS_B64:-W10=}\" | base64 -d)\"\ntmp=\"$(mktemp -d /etc/orca/members.XXXXXX)\"\ncount=0\nwhile IFS= read -r row; do\n user=\"$(jq -r '.userId' <<<\"$row\")\"\n case \"$user\" in '' | *[!A-Za-z0-9_.:-]*) continue ;; esac\n file=\"$tmp/$(printf '%s' \"$user\" | tr ':' '_').gitconfig\"\n # An empty extraheader first resets the image-wide (app identity) header for this member's git.\n git config -f \"$file\" --add http.https://github.com/.extraheader ''\n git config -f \"$file\" --add http.https://github.com/.extraheader \"Authorization: $(jq -r '.authorization' <<<\"$row\")\"\n git config -f \"$file\" user.name \"$(jq -r '.name' <<<\"$row\")\"\n git config -f \"$file\" user.email \"$(jq -r '.email' <<<\"$row\")\"\n count=$((count + 1))\ndone < <(jq -c '.[]' <<<\"$json\")\njq -c '[.[] | select(.userId | test(\"^[A-Za-z0-9_.:-]+$\")) | .userId as $u | (.deviceIds // [])[] | select(test(\"^[A-Za-z0-9_-]{1,128}$\")) | {key: ., value: $u}] | from_entries' <<<\"$json\" >\"$tmp/devices.json\"\ndevices=\"$(jq 'length' \"$tmp/devices.json\")\"\nchmod 0700 \"$tmp\" && chmod 0600 \"$tmp\"/*\nrm -rf \"$DIR.old\"\n[ -d \"$DIR\" ] && mv \"$DIR\" \"$DIR.old\"\nmv \"$tmp\" \"$DIR\"\nrm -rf \"$DIR.old\"\n# Agents may ask for their own sender's identity, and only that (the helper checks with orcad).\nrule=\"$USER_NAME ALL=(root) NOPASSWD:SETENV: /opt/orca/bin/orca-git-identity\"\nprintf '%s\\n' \"$rule\" >/etc/sudoers.d/orca-git-identity.new\nchmod 0440 /etc/sudoers.d/orca-git-identity.new\nif visudo -cf /etc/sudoers.d/orca-git-identity.new >/dev/null; then\n mv -f /etc/sudoers.d/orca-git-identity.new /etc/sudoers.d/orca-git-identity\nelse\n rm -f /etc/sudoers.d/orca-git-identity.new\n echo 'sudoers rule rejected' >&2\n exit 1\nfi\n# Pre-attestation layout (agent-readable member files) must not linger.\nuser_home=\"$(getent passwd \"$USER_NAME\" | cut -d: -f6)\"\n[ -n \"$orcad_pid\" ] && user_home=\"$(tr '\\0' '\\n' <\"/proc/$orcad_pid/environ\" | sed -n 's/^HOME=//p' | head -n 1)\"\n[ -n \"$user_home\" ] && rm -rf \"$user_home/.orca-sandbox/members\"\n# Commits outside a teammate's turn still need an author (images older than orca-sandbox-start's default).\nif [ -n \"$user_home\" ]; then\n sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.email >/dev/null 2>&1 \\\n || sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.email 'cloud@users.noreply.orca.dev'\n sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.name >/dev/null 2>&1 \\\n || sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.name 'Orca Cloud'\nfi\n# CREATE-GIT-ASYNC: identities are in place, so `git` stops holding commits and pushes for this boot.\nif [ -n \"$user_home\" ] && [ -d \"$user_home/.orca-sandbox\" ]; then\n sudo -u \"$USER_NAME\" sh -c 'rm -f \"$1/git-setup.pending\" \"$1/git-setup.failed\" && date +%s >\"$1/git-setup.ready\"' \\\n _ \"$user_home/.orca-sandbox\" || true\nfi\necho \"{\\\"members\\\":$count,\\\"devices\\\":$devices}\"\n","orca-sandbox-read-snapshot":"#!/usr/bin/env bash\n# Seals a read-only snapshot of this workspace (chats, agent status, git, PR) to the reader keys the\n# control plane passes, right before the drain. Only ciphertext is printed (one JSON line); see\n# orca-read-snapshot.cjs. Exit 3: orcad isn't running (nothing to read).\nset -euo pipefail\numask 077\nORCA_HOME=/opt/orca\nexport ORCA_USER_DATA_PATH=\"${ORCA_USER_DATA_PATH:-$HOME/.orcad-data}\"\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node | head -n 1)\"\n[ -n \"$node\" ] || { echo 'node not found' >&2; exit 1; }\nexec timeout 35 \"$node\" \"$ORCA_HOME/bin/orca-read-snapshot.cjs\"\n","orca-read-snapshot.cjs":"#!/usr/bin/env node\n// Gathers what a sleeping workspace should still show (recent chats per agent session, agent\n// status, git status/diff summary, PR link) from the running orcad over its local socket, then seals\n// it to the reader keys the control plane passed, so only ciphertext leaves the VM.\n// Run by orca-sandbox-read-snapshot right before the drain. Env:\n// ORCA_READ_SNAPSHOT_ID rsn_<24 hex> ORCA_READ_WORKSPACE_ID sbx_<24 hex>\n// ORCA_READ_RECIPIENTS JSON [{keyId, publicKeyB64}] ORCA_READ_REASON sleep|rollover\n// ORCA_USER_DATA_PATH orcad's data dir (orca-runtime.json lives there)\n// Prints one JSON line: {v:1, snapshotId, workspaceId, nonceB64, ciphertextB64, recipients[], stats}.\n// Construction (keep in sync with src/shared/cloud-workspaces/read-snapshot.ts in the app):\n// body = AES-256-GCM(dek, nonce, JSON, AAD \"orca-cloud-read-v1::\")\n// wrapped = AES-256-GCM(HKDF-SHA256(X25519(eph, reader), eph||reader, \"orca-cloud-read-wrap-v1\"),\n// nonce_i, dek, AAD \"orca-cloud-read-wrap-v1:::\")\n'use strict'\nconst crypto = require('node:crypto')\nconst fs = require('node:fs')\nconst net = require('node:net')\nconst path = require('node:path')\n\nconst INFO = 'orca-cloud-read-v1'\nconst WRAP_INFO = 'orca-cloud-read-wrap-v1'\nconst PROJECT = '/vercel/sandbox/project'\nconst LIMITS = { sessions: 12, messagesPerSession: 60, messageChars: 4000, changes: 200, commits: 10 }\n/** Plaintext budget: sealed + base64 it stays under the control plane's 220 kB and the 256 kB stdout cap. */\nconst MAX_PLAIN_BYTES = 150_000\nconst CALL_TIMEOUT_MS = 5_000\nconst TOTAL_BUDGET_MS = 20_000\n\nfunction b64(bytes) {\n return Buffer.from(bytes).toString('base64')\n}\n\nfunction x25519PublicKey(raw) {\n return crypto.createPublicKey({ key: { kty: 'OKP', crv: 'X25519', x: Buffer.from(raw).toString('base64url') }, format: 'jwk' })\n}\n\nfunction gcmSeal(key, nonce, plain, aad) {\n const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce)\n cipher.setAAD(Buffer.from(aad))\n return Buffer.concat([cipher.update(plain), cipher.final(), cipher.getAuthTag()])\n}\n\nconst SIG_INFO = 'orca-cloud-read-sig-v1'\n\n/**\n * This VM's snapshot signing key (Ed25519), created once and kept on the VM's own disk (0600, never\n * printed). Devices pin its public half per workspace, so a snapshot the control plane forged or\n * swapped doesn't verify. Same file for the grant script, which hands the public half to pairing devices.\n */\nfunction signingKey(stateDir) {\n if (!path.isAbsolute(stateDir)) {\n throw new Error('no state dir')\n }\n const file = path.join(stateDir, 'read-signing-key.pem')\n const load = () => {\n try {\n const privateKey = crypto.createPrivateKey(fs.readFileSync(file, 'utf8'))\n return privateKey.asymmetricKeyType === 'ed25519' ? privateKey : null\n } catch {\n return null\n }\n }\n let privateKey = load()\n if (!privateKey) {\n fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 })\n // Written whole to a temp file, then linked into place: atomic, and a concurrent first run\n // (grant + capture) keeps whichever key landed first. A broken file is replaced (devices that\n // pinned the old signer then see \"signer changed\" until they trust the new one).\n const temp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}`\n fs.writeFileSync(temp, crypto.generateKeyPairSync('ed25519').privateKey.export({ format: 'pem', type: 'pkcs8' }), { mode: 0o600 })\n try {\n fs.linkSync(temp, file)\n } catch {\n if (!load()) {\n fs.renameSync(temp, file)\n }\n } finally {\n fs.rmSync(temp, { force: true })\n }\n privateKey = load()\n if (!privateKey) {\n throw new Error('signing key unavailable')\n }\n }\n const publicKeyB64 = Buffer.from(crypto.createPublicKey(privateKey).export({ format: 'jwk' }).x, 'base64url').toString('base64')\n return { privateKey, publicKeyB64 }\n}\n\n/** Signs the sealed body: \"orca-cloud-read-sig-v1::::\" || ciphertext bytes. */\nfunction sign(out, key) {\n const message = Buffer.concat([\n Buffer.from(`${SIG_INFO}:${out.workspaceId}:${out.snapshotId}:${out.nonceB64}:`),\n Buffer.from(out.ciphertextB64, 'base64')\n ])\n return { publicKeyB64: key.publicKeyB64, signatureB64: crypto.sign(null, message, key.privateKey).toString('base64') }\n}\n\n/** Seals `payload` (an object) to every recipient; a malformed key is skipped, not fatal. */\nfunction seal(payload, recipients, workspaceId, snapshotId) {\n const scope = `${workspaceId}:${snapshotId}`\n const dek = crypto.randomBytes(32)\n const nonce = crypto.randomBytes(12)\n const ciphertext = gcmSeal(dek, nonce, Buffer.from(JSON.stringify(payload)), `${INFO}:${scope}`)\n const sealedTo = []\n for (const recipient of recipients) {\n const reader = Buffer.from(String(recipient.publicKeyB64 || ''), 'base64')\n if (reader.length !== 32 || !/^rk_[0-9a-f]{24}$/.test(String(recipient.keyId || ''))) {\n continue\n }\n let shared\n const eph = crypto.generateKeyPairSync('x25519')\n try {\n shared = crypto.diffieHellman({ privateKey: eph.privateKey, publicKey: x25519PublicKey(reader) })\n } catch {\n continue\n }\n if (shared.every((byte) => byte === 0)) {\n continue\n }\n const ephRaw = Buffer.from(eph.publicKey.export({ format: 'jwk' }).x, 'base64url')\n const kek = Buffer.from(crypto.hkdfSync('sha256', shared, Buffer.concat([ephRaw, reader]), WRAP_INFO, 32))\n const wrapNonce = crypto.randomBytes(12)\n const wrapped = gcmSeal(kek, wrapNonce, dek, `${WRAP_INFO}:${scope}:${recipient.keyId}`)\n sealedTo.push({ keyId: recipient.keyId, ephemeralPublicKeyB64: b64(ephRaw), nonceB64: b64(wrapNonce), wrappedKeyB64: b64(wrapped) })\n }\n dek.fill(0)\n return { v: 1, snapshotId, workspaceId, nonceB64: b64(nonce), ciphertextB64: b64(ciphertext), recipients: sealedTo }\n}\n\n// --- gathering -------------------------------------------------------------------------------\n\n/** One request per connection, like the CLI. A streaming method resolves on its first frame. */\nfunction rpc(metadata, method, params, timeoutMs = CALL_TIMEOUT_MS) {\n return new Promise((resolve) => {\n const transport = (metadata.transports || [metadata.transport]).find((t) => t && (t.kind === 'unix' || t.kind === 'named-pipe'))\n if (!transport) {\n resolve(null)\n return\n }\n const socket = net.createConnection(transport.endpoint)\n const id = crypto.randomUUID()\n let buffer = ''\n let done = false\n const finish = (value) => {\n if (!done) {\n done = true\n clearTimeout(timer)\n socket.destroy()\n resolve(value)\n }\n }\n const timer = setTimeout(() => finish(null), timeoutMs)\n socket.setEncoding('utf8')\n socket.on('error', () => finish(null))\n socket.on('close', () => finish(null))\n socket.on('connect', () => socket.write(`${JSON.stringify({ id, authToken: metadata.authToken, method, params })}\\n`))\n socket.on('data', (chunk) => {\n buffer += chunk\n let newline\n while ((newline = buffer.indexOf('\\n')) !== -1) {\n const line = buffer.slice(0, newline)\n buffer = buffer.slice(newline + 1)\n let frame\n try {\n frame = JSON.parse(line)\n } catch {\n continue\n }\n if (!frame || frame.id !== id) {\n continue\n }\n finish(frame.ok === true ? frame.result : null)\n return\n }\n })\n })\n}\n\nconst isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v)\nconst str = (v, max) => (typeof v === 'string' ? v.slice(0, max) : null)\nconst num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null)\n\nfunction messagesOf(history) {\n const items = isObj(history) && isObj(history.page) && Array.isArray(history.page.items) ? history.page.items : []\n const out = []\n for (const item of items) {\n const body = isObj(item) && isObj(item.body) ? item.body : null\n if (!body || body.kind !== 'message' || !Array.isArray(body.blocks)) {\n continue\n }\n const role = body.role\n if (role !== 'user' && role !== 'assistant' && role !== 'system') {\n continue\n }\n const text = body.blocks\n .filter((b) => isObj(b) && b.type === 'text' && typeof b.text === 'string')\n .map((b) => b.text)\n .join('\\n')\n .trim()\n if (text) {\n out.push({ itemId: String(item.itemId).slice(0, 200), role, text: text.slice(0, LIMITS.messageChars), at: num(item.observedAt) ?? 0 })\n }\n }\n return { messages: out, hasOlder: isObj(history) && isObj(history.page) && history.page.hasOlder === true }\n}\n\nfunction gitOf(status, log) {\n if (!isObj(status) && !isObj(log)) {\n return null\n }\n const entries = isObj(status) && Array.isArray(status.entries) ? status.entries.filter(isObj) : []\n let added = 0\n let removed = 0\n let counted = false\n const changes = entries.slice(0, LIMITS.changes).map((e) => {\n const a = num(e.added)\n const r = num(e.removed)\n if (a !== null || r !== null) {\n counted = true\n }\n return { path: String(e.path).slice(0, 1024), status: str(e.status, 20) || 'modified', area: str(e.area, 20) || 'unstaged', added: a, removed: r }\n })\n for (const e of entries) {\n added += num(e.added) || 0\n removed += num(e.removed) || 0\n }\n const items = isObj(log) && Array.isArray(log.items) ? log.items.filter(isObj) : []\n return {\n branch: isObj(status) && typeof status.branch === 'string' ? status.branch.replace(/^refs\\/heads\\//, '') : null,\n head: isObj(status) ? str(status.head, 64) : null,\n changes,\n changeCount: entries.length,\n added: counted ? added : null,\n removed: counted ? removed : null,\n commits: items.slice(0, LIMITS.commits).map((c) => ({\n id: String(c.id).slice(0, 64),\n subject: String(c.subject ?? '').slice(0, 300),\n author: str(c.author, 120),\n at: num(c.timestamp)\n }))\n }\n}\n\nfunction prOf(linked) {\n if (!isObj(linked) || num(linked.number) === null || typeof linked.url !== 'string' || !linked.url.startsWith('https://')) {\n return null\n }\n return {\n number: linked.number,\n title: String(linked.title ?? '').slice(0, 300),\n state: str(linked.state, 20) || 'open',\n url: linked.url.slice(0, 500),\n checksStatus: str(linked.checksStatus, 20),\n checksSummary: str(linked.checksSummary, 200)\n }\n}\n\nconst STATUSES = new Set(['working', 'attention', 'idle'])\nconst PANE_PREFIX = 'structured-agent-session-'\n\n/**\n * Chats as orcad stores them (agent-session-journal.db, read-only): every session's provider, name\n * and last turn, open tabs first. The local socket can't stream agentSession.subscribeStatus, and\n * worktree.ps lists only agents that are live right now, so this is the complete list.\n */\nfunction storedSessions(dataDir) {\n let db\n try {\n const { DatabaseSync } = require('node:sqlite')\n db = new DatabaseSync(path.join(dataDir, 'agent-session-journal.db'), { readOnly: true })\n const open = new Set(db.prepare('SELECT session_id FROM agent_session_tabs').all().map((r) => r.session_id))\n const out = []\n for (const row of db.prepare('SELECT session_id, record_json FROM agent_session_records').all()) {\n let record\n try {\n record = JSON.parse(row.record_json)\n } catch {\n continue\n }\n const last = isObj(record.lastTurn) ? record.lastTurn : {}\n out.push({\n sessionId: row.session_id,\n agent: str(record.provider, 40),\n title: str(record.conversationName, 200) || str(last.prompt, 200),\n status: last.state === 'running' || last.state === 'submitted' ? 'working' : last.state === 'settled' ? 'idle' : 'unknown',\n turnOutcome: str(last.outcome, 40),\n updatedAt: num(record.updatedAt),\n open: open.has(row.session_id),\n lastPrompt: str(last.prompt, LIMITS.messageChars),\n lastReply: str(last.reply, LIMITS.messageChars),\n lastAt: num(last.at),\n // \"::\": which checkout this chat works in.\n worktreePath: isObj(record.location) && typeof record.location.workspaceId === 'string' ? record.location.workspaceId.split('::').pop() : null\n })\n }\n return out\n } catch {\n return []\n } finally {\n try {\n db && db.close()\n } catch {}\n }\n}\n\n/** Live agent rows from worktree.ps, keyed by session id (their status wins over the stored one). */\nfunction liveAgents(worktrees) {\n const out = new Map()\n for (const w of worktrees) {\n for (const a of Array.isArray(w.agents) ? w.agents.filter(isObj) : []) {\n const tab = typeof a.paneKey === 'string' ? a.paneKey.split(':')[0] : ''\n if (!tab.startsWith(PANE_PREFIX)) {\n continue\n }\n const state = a.mainAgent && typeof a.mainAgent.state === 'string' ? a.mainAgent.state : a.state\n out.set(tab.slice(PANE_PREFIX.length), {\n agent: str(a.agentType, 40),\n title: str(a.conversationName, 200) || str(a.taskTitle, 200) || str(a.displayName, 200),\n status: state === 'working' ? 'working' : state === 'blocked' || state === 'waiting' || state === 'attention' ? 'attention' : 'idle',\n updatedAt: num(a.updatedAt)\n })\n }\n }\n return out\n}\n\n/**\n * The checkout this workspace is about: the one its newest chat works in, else the most recently\n * active one (a workspace on its own branch is a separate worktree, not the base clone at\n * /vercel/sandbox/project), else the base clone.\n */\nfunction workspaceCheckout(worktrees, sessions) {\n const byPath = new Map(worktrees.filter((w) => typeof w.path === 'string').map((w) => [w.path, w]))\n const newest = sessions\n .filter((s) => s.worktreePath && byPath.has(s.worktreePath))\n .sort((a, b) => (num(b.updatedAt) || 0) - (num(a.updatedAt) || 0))[0]\n if (newest) {\n return byPath.get(newest.worktreePath)\n }\n const activity = (w) => Math.max(num(w.lastActivityAt) || 0, num(w.sortOrder) || 0)\n const ranked = [...byPath.values()].sort(\n (a, b) => activity(b) - activity(a) || Number(a.isMainWorktree === true) - Number(b.isMainWorktree === true)\n )\n return ranked[0] || null\n}\n\n/** `call(method, params)` -> result | null. Never throws: a missing piece is left out. */\nasync function gather(call, { workspaceId, snapshotId, reason, now, deadline, listSessions = () => [] }) {\n const timeLeft = () => deadline - Date.now()\n const ps = await call('worktree.ps', { limit: 20 })\n const worktrees = isObj(ps) && Array.isArray(ps.worktrees) ? ps.worktrees.filter(isObj) : []\n const stored = listSessions()\n const project = workspaceCheckout(worktrees, stored)\n const selector = project && typeof project.path === 'string' ? `path:${project.path}` : `path:${PROJECT}`\n const [status, log] = await Promise.all([\n call('git.status', { worktree: selector, includeLineStats: true }),\n call('git.history', { worktree: selector, limit: LIMITS.commits })\n ])\n const live = liveAgents(worktrees)\n const byId = new Map()\n for (const s of stored) {\n byId.set(s.sessionId, s)\n }\n for (const [sessionId, a] of live) {\n byId.set(sessionId, { ...(byId.get(sessionId) || { sessionId, open: true }), ...Object.fromEntries(Object.entries(a).filter(([, v]) => v !== null)) })\n }\n // Open chats first, then newest activity.\n const picked = [...byId.values()]\n .filter((s) => typeof s.sessionId === 'string')\n .sort((a, b) => Number(Boolean(b.open)) - Number(Boolean(a.open)) || (num(b.updatedAt) || 0) - (num(a.updatedAt) || 0))\n .slice(0, LIMITS.sessions)\n const sessions = []\n for (const s of picked) {\n const history = timeLeft() > 1_000 ? await call('agentSession.history', { sessionId: s.sessionId, direction: 'tail', limit: LIMITS.messagesPerSession }) : null\n let { messages, hasOlder } = messagesOf(history)\n if (messages.length === 0) {\n // No readable history (or out of time): the stored last turn still says what happened.\n const at = s.lastAt || s.updatedAt || 0\n messages = [\n ...(s.lastPrompt ? [{ itemId: `${s.sessionId}:last-prompt`, role: 'user', text: s.lastPrompt, at }] : []),\n ...(s.lastReply ? [{ itemId: `${s.sessionId}:last-reply`, role: 'assistant', text: s.lastReply, at }] : [])\n ]\n hasOlder = hasOlder || messages.length > 0\n }\n sessions.push({\n sessionId: s.sessionId.slice(0, 200),\n agent: s.agent || null,\n title: s.title || null,\n status: STATUSES.has(s.status) ? s.status : 'unknown',\n turnOutcome: s.turnOutcome || null,\n updatedAt: num(s.updatedAt),\n messages: messages.slice(-LIMITS.messagesPerSession),\n truncated: hasOlder || messages.length > LIMITS.messagesPerSession\n })\n }\n return {\n v: 1,\n workspaceId,\n snapshotId,\n capturedAt: now,\n reason,\n git: gitOf(status, log),\n pr: prOf(project && project.linkedPR),\n sessions\n }\n}\n\n/** Cuts the oldest messages (then long texts, then sessions) until the JSON fits the budget. */\nfunction fitToBudget(payload, maxBytes = MAX_PLAIN_BYTES) {\n const size = () => Buffer.byteLength(JSON.stringify(payload))\n let guard = 0\n while (size() > maxBytes && guard++ < 10_000) {\n const longest = payload.sessions.reduce((best, s) => (!best || s.messages.length > best.messages.length ? s : best), null)\n if (longest && longest.messages.length > 4) {\n longest.messages.shift()\n longest.truncated = true\n continue\n }\n const longText = payload.sessions.flatMap((s) => s.messages).find((m) => m.text.length > 500)\n if (longText) {\n longText.text = `${longText.text.slice(0, 500)}…`\n continue\n }\n if (payload.git && payload.git.changes.length > 20) {\n payload.git.changes = payload.git.changes.slice(0, 20)\n continue\n }\n if (payload.sessions.length > 1) {\n payload.sessions.pop()\n continue\n }\n payload.sessions = []\n break\n }\n return payload\n}\n\n// Fails closed without HOME (signingKey refuses a relative path).\nconst stateDir = process.env.HOME ? path.join(process.env.HOME, '.orca-sandbox') : ''\n\nasync function main() {\n const started = Date.now()\n const env = process.env\n if (process.argv[2] === '--signer-public-key') {\n // For orca-sandbox-grant: pairing devices learn (and pin) this workspace's signer.\n process.stdout.write(`${signingKey(stateDir).publicKeyB64}\\n`)\n return\n }\n const snapshotId = env.ORCA_READ_SNAPSHOT_ID || ''\n const workspaceId = env.ORCA_READ_WORKSPACE_ID || ''\n if (!/^rsn_[0-9a-f]{24}$/.test(snapshotId) || !/^sbx_[0-9a-f]{24}$/.test(workspaceId)) {\n process.stderr.write('bad ids\\n')\n process.exit(2)\n }\n let recipients\n try {\n recipients = JSON.parse(env.ORCA_READ_RECIPIENTS || '[]')\n } catch {\n recipients = null\n }\n if (!Array.isArray(recipients) || recipients.length === 0) {\n process.stderr.write('no recipients\\n')\n process.exit(2)\n }\n const dataDir = env.ORCA_USER_DATA_PATH || path.join(env.HOME || '', '.orcad-data')\n let metadata\n try {\n metadata = JSON.parse(fs.readFileSync(path.join(dataDir, 'orca-runtime.json'), 'utf8'))\n } catch {\n metadata = null\n }\n if (!metadata || !metadata.authToken) {\n process.stderr.write('orcad not running\\n')\n process.exit(3)\n }\n const deadline = started + TOTAL_BUDGET_MS\n const call = (method, params) => rpc(metadata, method, params, Math.max(250, Math.min(CALL_TIMEOUT_MS, deadline - Date.now())))\n const payload = fitToBudget(\n await gather(call, { workspaceId, snapshotId, reason: (env.ORCA_READ_REASON || 'sleep').slice(0, 20), now: Date.now(), deadline, listSessions: () => storedSessions(dataDir) })\n )\n const out = seal(payload, recipients, workspaceId, snapshotId)\n out.signature = sign(out, signingKey(stateDir))\n // Timing only: counts and sizes would describe the plaintext to the control plane's logs.\n out.stats = { collectMs: Date.now() - started }\n process.stdout.write(`${JSON.stringify(out)}\\n`)\n}\n\nmodule.exports = { workspaceCheckout, seal, sign, signingKey, gather, fitToBudget, messagesOf, gitOf, prOf, rpc, storedSessions, liveAgents }\n\nif (require.main === module) {\n // A fixed message: an error text could carry workspace content into the control plane's logs.\n main().catch(() => {\n process.stderr.write('read snapshot failed\\n')\n process.exit(1)\n })\n}\n","orca-git-as":"#!/usr/bin/env bash\n# Runs git as the teammate whose message started this agent's turn: their GitHub identity for\n# pushes/PRs (this repository only) and their commit author.\n# orca-git-as e.g. orca-git-as push origin HEAD\n# orca-git-as --as refuses unless that member really sent the message\n# Orca attests the sender from the paired device that sent the message, never from arguments or\n# message text. Without an attested sender (not run from an agent, or the owner's local session) it\n# runs plain git with the workspace identity.\nset -euo pipefail\nexpect=''\nif [ \"${1:-}\" = '--as' ]; then\n expect=\"${2:-}\"\n shift 2 || { echo 'usage: orca-git-as [--as ] ' >&2; exit 2; }\n case \"$expect\" in '' | *[!A-Za-z0-9_.:-]*) echo 'orca-git-as: bad member id' >&2; exit 2 ;; esac\nfi\nset +e\nfile=\"$(ORCA_GIT_AS=\"$expect\" sudo -n --preserve-env=ORCA_GIT_AS,ORCA_TERMINAL_HANDLE,ORCA_PANE_KEY,ORCA_AGENT_LAUNCH_TOKEN,ORCA_AGENT_SESSION_ID \\\n /opt/orca/bin/orca-git-identity)\"\nstatus=$?\nset -e\ncase \"$status\" in\n 0)\n trap 'rm -f \"$file\"' EXIT\n git -c \"include.path=$file\" \"$@\"\n ;;\n 3)\n echo 'orca-git-as: no teammate identity for this turn; using the workspace identity' >&2\n exec git \"$@\"\n ;;\n *) exit 4 ;;\nesac\n","orca-git-identity":"#!/usr/bin/env bash\n# Root helper behind `orca-git-as` (sudoers: the VM user may run only this). Asks orcad, as the\n# calling agent, which paired device sent the message that started the agent's latest turn\n# (`orca sender`: orcad identifies the agent from its launch evidence, never from arguments), maps\n# that device to a workspace member, and prints that member's git config file for the caller to\n# include. Optional ORCA_GIT_AS: the member the agent expects; any other answer is refused.\n# Exit codes: 0 printed a config path; 3 no member identity (use the workspace identity); 4 refused.\nset -euo pipefail\nDIR=/etc/orca/members\ncaller=\"${SUDO_USER:-}\"\ncase \"$caller\" in '' | root | *[!a-z0-9_-]*) echo 'orca-git-identity: run through orca-git-as' >&2; exit 4 ;; esac\n# orcad's own data dir and HOME (the agent's user may not use its passwd home, e.g. HOME=/vercel).\norcad_pid=\"$(pgrep -u \"$caller\" -f orcad-server.js | head -n 1 || true)\"\n[ -n \"$orcad_pid\" ] || exit 3\norcad_env() { tr '\\0' '\\n' <\"/proc/$orcad_pid/environ\" | sed -n \"s/^$1=//p\" | head -n 1; }\nhome=\"$(orcad_env HOME)\"\ndata=\"$(orcad_env ORCA_USER_DATA)\"\ndata=\"${data:-$home/.orcad-data}\"\nnode=\"$(find /opt/orca/runtime/runtimes -type f -name node | head -n 1)\"\ncli=/opt/orca/runtime/orcad/out/cli/index.js\n# Only the evidence variables orcad needs reach the query, which runs as the caller, not root.\nanswer=\"$(sudo -u \"$caller\" env -i HOME=\"$home\" PATH=/usr/bin:/bin ORCA_USER_DATA_PATH=\"$data\" \\\n ${ORCA_TERMINAL_HANDLE:+ORCA_TERMINAL_HANDLE=\"$ORCA_TERMINAL_HANDLE\"} \\\n ${ORCA_PANE_KEY:+ORCA_PANE_KEY=\"$ORCA_PANE_KEY\"} \\\n ${ORCA_AGENT_LAUNCH_TOKEN:+ORCA_AGENT_LAUNCH_TOKEN=\"$ORCA_AGENT_LAUNCH_TOKEN\"} \\\n ${ORCA_AGENT_SESSION_ID:+ORCA_AGENT_SESSION_ID=\"$ORCA_AGENT_SESSION_ID\"} \\\n timeout 15 \"$node\" \"$cli\" sender --json 2>/dev/null || true)\"\ndevice=\"$(jq -r '.result.sender.deviceId // empty' <<<\"$answer\" 2>/dev/null || true)\"\nif [ -z \"$device\" ] || [ ! -s \"$DIR/devices.json\" ]; then\n if [ -n \"${ORCA_GIT_AS:-}\" ]; then\n echo \"orca-git-as: Orca cannot confirm that $ORCA_GIT_AS sent this agent's latest message; refusing to push as them.\" >&2\n exit 4\n fi\n exit 3\nfi\nmember=\"$(jq -r --arg d \"$device\" '.[$d] // empty' \"$DIR/devices.json\")\"\nif [ -z \"$member\" ]; then\n [ -n \"${ORCA_GIT_AS:-}\" ] && { echo \"orca-git-as: the sender's device is not linked to a workspace member; refusing.\" >&2; exit 4; }\n exit 3\nfi\nif [ -n \"${ORCA_GIT_AS:-}\" ] && [ \"$ORCA_GIT_AS\" != \"$member\" ]; then\n echo \"orca-git-as: this agent's latest message came from $member, not $ORCA_GIT_AS; refusing to push as someone else.\" >&2\n exit 4\nfi\nfile=\"$DIR/$(printf '%s' \"$member\" | tr ':' '_').gitconfig\"\n[ -s \"$file\" ] || exit 3\n# The caller reads the identity through this one-shot copy (owned by them, removed by orca-git-as).\nout=\"$(sudo -u \"$caller\" mktemp)\"\ncat \"$file\" >\"$out\"\necho \"$out\"\n","orcad-boot":"#!/usr/bin/env node\n// Starts orcad from a V8 code cache taken after a full startup, so a boot skips compiling the\n// functions startup runs. NODE_COMPILE_CACHE only stores what V8 compiles before the bundle runs\n// (about 5 of its 15 MB); every other function compiled lazily on every boot (~300 ms of wake).\n// orcad-boot --code-cache [--write-code-cache] [orcad args...]\n// orcad sees the argv `node [orcad args...]`, as if started directly.\n// --write-code-cache (the image's smoke run) writes the cache when orcad exits. The cache is used\n// only when its header matches this bundle's bytes and this Node; otherwise orcad loads exactly as\n// before (plain require, which NODE_COMPILE_CACHE still serves). Plain Node, no dependencies.\n// getBuiltinModule (Node >= 22.3) works whether node loads this extensionless file as CommonJS\n// (in the VM) or as an ES module (under this repo's type:module package, in tests).\nconst fs = process.getBuiltinModule('node:fs')\nconst path = process.getBuiltinModule('node:path')\nconst vm = process.getBuiltinModule('node:vm')\nconst zlib = process.getBuiltinModule('node:zlib')\nconst { Module, createRequire } = process.getBuiltinModule('node:module')\n\nconst MAGIC = 'orcad-code-cache-v1'\n\nconst args = process.argv.slice(2)\nlet cacheFile = null\nlet writeCache = false\nwhile (args[0] === '--code-cache' || args[0] === '--write-code-cache') {\n if (args[0] === '--code-cache') {\n cacheFile = args[1]\n args.splice(0, 2)\n } else {\n writeCache = true\n args.shift()\n }\n}\nconst entry = args.shift()\nif (!entry || !cacheFile) {\n console.error('usage: orcad-boot --code-cache [--write-code-cache] [args...]')\n process.exit(2)\n}\n// As for `node `: argv[1] is the resolved path, __filename the real one.\nprocess.argv = [process.argv[0], path.resolve(entry), ...args]\nlet file = process.argv[1]\ntry {\n file = fs.realpathSync(file)\n} catch {\n // A missing bundle fails in require below, as it would under plain node.\n}\n\n// One line of identity: a cache from another bundle or Node must never run (V8 only checks length).\nfunction cacheHeader(bytes) {\n return `${MAGIC} ${process.version} ${process.arch} ${bytes.length} ${zlib.crc32(bytes)}\\n`\n}\n\nfunction readCache(header) {\n let data\n try {\n data = fs.readFileSync(cacheFile)\n } catch {\n return null\n }\n const headerLength = Buffer.byteLength(header)\n if (data.length <= headerLength || data.toString('latin1', 0, headerLength) !== header) {\n console.error('orcad-boot: code cache is for another orcad build; loading without it')\n return null\n }\n return data.subarray(headerLength)\n}\n\nfunction runCompiled(header, source, cachedData) {\n // A vm.Script can serialize what startup compiled; the CommonJS loader cannot. Same wrapper,\n // filename and require as the loader, and import() resolves from the bundle like it does there.\n const options = {\n filename: file,\n cachedData: cachedData ?? undefined,\n importModuleDynamically: vm.constants.USE_MAIN_CONTEXT_DEFAULT_LOADER\n }\n // Node flags the loader option experimental once, on the first import() through it. Spend that\n // one-time warning here, unprinted, so orcad's stderr stays as it was.\n const emitWarning = process.emitWarning\n process.emitWarning = (warning, ...rest) => {\n if (!String(warning?.message ?? warning).includes('USE_MAIN_CONTEXT_DEFAULT_LOADER')) {\n emitWarning.call(process, warning, ...rest)\n }\n }\n let script\n try {\n new vm.Script('import(\"node:path\")', {\n filename: file,\n importModuleDynamically: options.importModuleDynamically\n })\n .runInThisContext()\n .catch(() => {})\n script = new vm.Script(Module.wrap(source), options)\n } finally {\n process.emitWarning = emitWarning\n }\n if (cachedData && script.cachedDataRejected) {\n console.error('orcad-boot: V8 rejected the code cache (other V8 flags?); compiled without it')\n }\n if (writeCache) {\n process.on('exit', () => {\n try {\n const data = script.createCachedData()\n const temporary = `${cacheFile}.${process.pid}.tmp`\n fs.writeFileSync(temporary, Buffer.concat([Buffer.from(header, 'latin1'), data]))\n fs.renameSync(temporary, cacheFile)\n console.error(`orcad-boot: wrote ${data.length} bytes of code cache`)\n } catch (error) {\n console.error(`orcad-boot: could not write the code cache: ${error.message}`)\n }\n })\n }\n const module = new Module(file, null)\n module.filename = file\n module.paths = Module._nodeModulePaths(path.dirname(file))\n const compiled = script.runInThisContext()\n compiled.call(module.exports, module.exports, createRequire(file), module, file, path.dirname(file))\n}\n\n// Locals, so neither the bundle bytes nor the cache outlive the compile.\nfunction boot() {\n // Older Node (no crc32 or main-context import()) loads as before.\n if (typeof zlib.crc32 === 'function' && vm.constants?.USE_MAIN_CONTEXT_DEFAULT_LOADER) {\n let bytes = null\n try {\n bytes = fs.readFileSync(file)\n } catch {\n // Missing or unreadable: require reports it as plain node would.\n }\n if (bytes) {\n const header = cacheHeader(bytes)\n const cachedData = readCache(header)\n if (cachedData || writeCache) {\n runCompiled(header, bytes.toString('utf8'), cachedData)\n return\n }\n }\n }\n createRequire(file)(file)\n}\n\nboot()\n","git":"#!/usr/bin/env bash\n# git for agents in Orca Cloud workspaces (first on orcad's PATH). Commands that author or publish\n# work run as the teammate whose message started this agent's turn (orca-git-as: orcad attests the\n# sender's paired device; root-only member identities). Everything else is plain git, untouched.\nreal=\"${ORCA_REAL_GIT:-/usr/bin/git}\"\nsub=''\nskip=0\nfor arg in \"$@\"; do\n if [ \"$skip\" = 1 ]; then skip=0; continue; fi\n case \"$arg\" in\n -C | -c | --git-dir | --work-tree | --namespace | --exec-path | --config-env) skip=1 ;;\n -*) ;;\n *) sub=\"$arg\"; break ;;\n esac\ndone\n# CREATE-GIT-ASYNC: a new workspace answers before teammates' GitHub identities are installed. Until\n# they are (orca-sandbox-members writes git-setup.ready), commits and pushes wait up to 15 s rather\n# than go out under the workspace identity; a failed or stalled setup stops them with what to do.\n# Read-only git and fetches never wait (they use the workspace's own credentials, set before ready).\nhold_for_member_setup() {\n local state=\"${HOME:-/nonexistent}/.orca-sandbox\" ticks=0 since\n # A failed install on any boot (create or a later restart) refuses even with nothing pending.\n [ -f \"$state/git-setup.pending\" ] || [ -f \"$state/git-setup.failed\" ] || return 0\n since=\"$(cat \"$state/git-setup.pending\" 2>/dev/null || echo 0)\"\n case \"$since\" in '' | *[!0-9]*) since=0 ;; esac\n while [ -f \"$state/git-setup.pending\" ] && [ ! -f \"$state/git-setup.ready\" ] && [ ! -f \"$state/git-setup.failed\" ]; do\n # 15 s per command; a mark older than 2 min is a setup that died, not one still running.\n if [ \"$ticks\" -ge \"${ORCA_GIT_SETUP_WAIT_TICKS:-150}\" ] || [ $(($(date +%s) - since)) -gt 120 ]; then\n break\n fi\n sleep 0.1\n ticks=$((ticks + 1))\n done\n if [ -f \"$state/git-setup.ready\" ] || { [ ! -f \"$state/git-setup.pending\" ] && [ ! -f \"$state/git-setup.failed\" ]; }; then\n return 0\n fi\n if [ -f \"$state/git-setup.failed\" ]; then\n printf '%s\\n' \"orca: Orca couldn't set up your GitHub identity in this sandbox, so it stopped \\`git $sub\\` rather than use the wrong identity. Restart this workspace (right-click it in the sidebar > Restart), then try again.\" >&2\n else\n printf '%s\\n' \"orca: Orca is still setting up your GitHub identity in this sandbox, so it stopped \\`git $sub\\` rather than use the wrong identity. Try again in a few seconds. If this keeps happening, restart this workspace (right-click it in the sidebar > Restart).\" >&2\n fi\n exit 1\n}\n# A commit under the workspace identity (Orca Cloud's name) says once per session how to use your own\n# (design-squad). /dev/shm is per boot: a wake or restart says it again.\nnudge_once() {\n [ \"$sub\" = commit ] || return 0\n local mark=\"${ORCA_GIT_NUDGE_DIR:-/dev/shm}/orca-git-nudge-$(id -u)\"\n [ -e \"$mark\" ] && return 0\n : >\"$mark\" 2>/dev/null || return 0\n printf '%s\\n' \"orca: This commit uses Orca Cloud's name. Connect your GitHub in Settings → Account to use yours.\" >&2\n}\n# PUSH-RAW-PROMPT: a push, pull or fetch to github.com that no credential covers (neither the person's\n# connected GitHub nor the App installation) must never fall through to git's own \"Username for\n# 'https://github.com':\" prompt. Prompts are off for that remote, and git's auth failure becomes one\n# plain line saying what to do.\ngithub_slug=''\ngithub_remote_slug() {\n local arg skip=0 seen=0 remote='' url\n for arg in \"$@\"; do\n if [ \"$skip\" = 1 ]; then skip=0; continue; fi\n if [ \"$seen\" = 0 ]; then\n case \"$arg\" in\n -C | -c | --git-dir | --work-tree | --namespace | --exec-path | --config-env) skip=1 ;;\n \"$sub\") seen=1 ;;\n esac\n continue\n fi\n case \"$arg\" in\n --repo=*) remote=\"${arg#--repo=}\"; break ;;\n -o | --push-option | --receive-pack | --upload-pack | --exec | --depth | --deepen | --shallow-since | --shallow-exclude | --refmap | --recurse-submodules-default | -j | --jobs) skip=1 ;;\n -*) ;;\n *) remote=\"$arg\"; break ;;\n esac\n done\n [ -n \"$remote\" ] || remote=\"$(\"$real\" config --get \"branch.$(\"$real\" symbolic-ref --short -q HEAD 2>/dev/null).remote\" 2>/dev/null || true)\"\n [ -n \"$remote\" ] || remote=origin\n case \"$remote\" in\n *://*) url=\"$remote\" ;;\n *) url=\"$(\"$real\" remote get-url \"$remote\" 2>/dev/null || printf '%s' \"$remote\")\" ;;\n esac\n case \"$url\" in\n https://github.com/* | https://*@github.com/*) url=\"${url#*github.com/}\" ;;\n *) return 0 ;;\n esac\n url=\"${url%.git}\"\n url=\"${url%/}\"\n case \"$url\" in */*/*) url=\"${url%%/*}/$(printf '%s' \"${url#*/}\" | cut -d/ -f1)\" ;; esac\n case \"$url\" in [A-Za-z0-9_.-]*/[A-Za-z0-9_.-]*) github_slug=\"$url\" ;; esac\n}\nno_access_line() {\n local owner=\"${github_slug%%/*}\" verb='push to'\n [ \"$sub\" = push ] || verb='read'\n 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\n}\n# GitHub's \"not found\" is a typo'd name or a private repo it won't confirm: say which half fits whom.\nnot_found_line() {\n printf '%s\\n' \"orca: Orca Cloud can't find $github_slug. Check the name, or install the Orca GitHub App on ${github_slug%%/*} if it's private.\" >&2\n}\n# orca-git-as's own fallback line is replaced by the nudge; GitHub auth failures by the plain line.\nfallback_filter() {\n local line said=0\n while IFS= read -r line || [ -n \"$line\" ]; do\n case \"$line\" in\n 'orca-git-as: no teammate identity'*) nudge_once ;;\n 'remote: Repository not found.'* | \"fatal: repository 'https://github.com/\"*\"' not found\"*)\n if [ -n \"$github_slug\" ]; then\n [ \"$said\" = 1 ] || not_found_line\n said=1\n else\n printf '%s\\n' \"$line\"\n fi\n ;;\n *\"could not read Username for 'https://github.com'\"* | *\"could not read Password for 'https://\"*\"github.com'\"* | \\\n *\"Authentication failed for 'https://github.com/\"* | 'remote: Permission to '*' denied to '* | \\\n *\"The requested URL returned error: 403\"*)\n if [ -n \"$github_slug\" ]; then\n [ \"$said\" = 1 ] || no_access_line\n said=1\n else\n printf '%s\\n' \"$line\"\n fi\n ;;\n *) printf '%s\\n' \"$line\" ;;\n esac\n done\n}\ncase \"$sub\" in\n push | pull | fetch)\n github_remote_slug \"$@\"\n if [ -n \"$github_slug\" ]; then\n export GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=never\n fi\n ;;\nesac\ngit_as=\"${ORCA_GIT_AS:-/opt/orca/bin/orca-git-as}\"\ncase \"$sub\" in\n commit | push | merge | rebase | cherry-pick | revert | am | pull | tag)\n hold_for_member_setup\n # Outside an agent turn (no attested sender) orca-git-as falls back to the workspace identity.\n if [ -n \"${ORCA_TERMINAL_HANDLE:-}${ORCA_AGENT_SESSION_ID:-}\" ] && [ -x \"$git_as\" ]; then\n PATH=\"/usr/bin:/bin:$PATH\" exec \"$git_as\" \"$@\" 2> >(fallback_filter >&2)\n fi\n # No terminal or agent turn to attribute it to: the workspace identity.\n nudge_once\n ;;\nesac\nif [ -n \"$github_slug\" ]; then\n exec \"$real\" \"$@\" 2> >(fallback_filter >&2)\nfi\nexec \"$real\" \"$@\"\n"} +export const VM_SCRIPTS_HASH: string = "f4d18985" +export const VM_SCRIPTS_STAMP: number = 1794532639 export const SOURCE_BUILD_SCRIPT: string = "#!/usr/bin/env bash\n# Golden build from source, run by the Worker inside the build sandbox: orcad + the orca CLI from one\n# commit of the Orca repo (cloud/integration carries host RPCs no upstream release has), then the same\n# provision-golden.sh every image uses. Inputs (env):\n# ORCA_SRC_SHA (40-hex) and either ORCA_SRC_DIR (source already uploaded) or ORCA_SRC_REPO\n# (https://github.com//) + ORCA_SRC_AUTH (an\n# `Authorization: Basic …` header value for a 1 h read-only installation token; never printed),\n# ORCA_PROVISION_SCRIPT (base64 of provision-golden.sh) + everything provision-golden.sh reads.\nset -euo pipefail\n# Vercel can report a detached build command as still running after it exits, so the outcome is also\n# a file the caller polls: /tmp/source-build.exit (exit code), /tmp/source-build.report (the\n# provision report line).\nrm -f /tmp/source-build.exit /tmp/source-build.report\ntrap 'echo $? >/tmp/source-build.exit' EXIT\nlog() { printf '[source-build] %s\\n' \"$*\" >&2; }\nstart=$(date +%s)\n[[ \"$ORCA_SRC_SHA\" =~ ^[0-9a-f]{40}$ ]] || { log 'ORCA_SRC_SHA must be a full commit sha'; exit 2; }\n\nlog 'build tools'\nif command -v apt-get >/dev/null; then\n # Transient mirror errors and dpkg lock contention made apt exit 100 (2 of ~10 golden builds):\n # retry downloads and wait for the lock instead of failing the build.\n printf 'Acquire::Retries \"5\";\\nDPkg::Lock::Timeout \"180\";\\n' | sudo tee /etc/apt/apt.conf.d/80-orca-retries >/dev/null\n sudo apt-get update -qq\n sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends build-essential python3 git ca-certificates >/dev/null\nelse\n sudo dnf install -y -q gcc gcc-c++ make tar gzip python3 git >/dev/null\nfi\nsudo corepack enable >/dev/null 2>&1 || true\n\nsrc=\"$HOME/orca-src\"\nif [ -n \"${ORCA_SRC_DIR:-}\" ]; then\n # Operator rotation (image/rotate-golden-from-source.ts) uploaded `git archive ` here already.\n log \"uploaded source for $ORCA_SRC_SHA\"\n src=\"$ORCA_SRC_DIR\"\n cd \"$src\" && test -f package.json\n git init -q && git add -A && git -c user.email=build@orca -c user.name=build commit -qm \"$ORCA_SRC_SHA\"\nelse\n log \"fetch $ORCA_SRC_SHA\"\n rm -rf \"$src\" && mkdir -p \"$src\" && cd \"$src\"\n git init -q\n # The header lives only in this one command's config, not in .git/config.\n git -c \"http.https://github.com/.extraheader=$ORCA_SRC_AUTH\" fetch -q --depth 1 \"$ORCA_SRC_REPO\" \"$ORCA_SRC_SHA\"\n unset ORCA_SRC_AUTH\n git -c advice.detachedHead=false checkout -q FETCH_HEAD\n test \"$(git rev-parse HEAD)\" = \"$ORCA_SRC_SHA\"\nfi\n\n# The image files come from this commit too (VM scripts, provision, manifest pins). The env the\n# caller passed is only a fallback for commits from before commit-provision-env.mjs existed.\nimage_dir=\"$src/cloud/apps/sandbox-control/image\"\nif [ -f \"$image_dir/commit-provision-env.mjs\" ]; then\n # The stamp must describe this commit's scripts, never the Worker's: a commit from before stamps\n # leaves it unset (the image then reads as stamp 0).\n unset ORCA_VM_SCRIPTS_STAMP\n node \"$image_dir/commit-provision-env.mjs\" \"$image_dir\" >/tmp/commit-provision.env\n # shellcheck disable=SC1091\n . /tmp/commit-provision.env\n rm -f /tmp/commit-provision.env\n log \"image files from $ORCA_SRC_SHA\"\nfi\n\nlog 'build orcad + CLI'\nexport CI=1 ELECTRON_SKIP_BINARY_DOWNLOAD=1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1\ncorepack pnpm install --frozen-lockfile >/tmp/src-install.log 2>&1 || { tail -40 /tmp/src-install.log >&2; exit 1; }\ncorepack pnpm -C mobile install --frozen-lockfile >/tmp/src-mobile.log 2>&1 || { tail -40 /tmp/src-mobile.log >&2; exit 1; }\ncorepack pnpm build:mobile-web >/tmp/src-mobile-web.log 2>&1 || { tail -40 /tmp/src-mobile-web.log >&2; exit 1; }\ncorepack pnpm build:orcad >/tmp/src-orcad.log 2>&1 || { tail -60 /tmp/src-orcad.log >&2; exit 1; }\ncorepack pnpm build:cli >/tmp/src-cli.log 2>&1 || { tail -40 /tmp/src-cli.log >&2; exit 1; }\nmkdir -p out/orca-cli\nnode_modules/.bin/esbuild out/cli/index.js --bundle --platform=node --target=node22 --format=cjs \\\n --outfile=out/orca-cli/orca-cli.cjs --log-level=error\nprintf '%s\\n' \"$ORCA_SRC_SHA\" >out/orcad/SOURCE_COMMIT\ntar czf /tmp/orcad.tgz -C out orcad runtimes orca-cli\nlog \"built in $(( $(date +%s) - start ))s\"\n\nexport ORCAD_BUNDLE_URL=file:///tmp/orcad.tgz\nORCAD_BUNDLE_SHA256=\"$(sha256sum /tmp/orcad.tgz | cut -d' ' -f1)\"\nexport ORCAD_BUNDLE_SHA256\ncd \"$HOME\"\nprintf '%s' \"$ORCA_PROVISION_SCRIPT\" | base64 -d >/tmp/provision-golden.sh\nunset ORCA_PROVISION_SCRIPT\nbash /tmp/provision-golden.sh | tee /tmp/source-build.report\n# Build leftovers stay out of the image.\nrm -rf \"$src\" \"$HOME/.local/share/pnpm\" \"$HOME/.cache/pnpm\" /tmp/orcad.tgz /tmp/provision-golden.sh /tmp/src-*.log\nlog \"done in $(( $(date +%s) - start ))s\"\n" export const ENVIRONMENT_BUILD_SCRIPT: string = "#!/usr/bin/env bash\n# Environment build (\"Cloud Computer\"), run by the control plane in a VM booted from the golden image.\n# Clones every repo, runs the org's install script once with `set -euo pipefail`, then records what it\n# built from (commit + lockfile blob ids per repo) so the control plane can tell when a rebuild is due.\n# Everything printed here streams live to the desktop build log.\n#\n# Env from the control plane (validated there): ORCA_BUILD_REPOS (`|||\n# ` lines: each private repo gets its own placeholder the egress swaps), ORCA_INSTALL_SCRIPT_B64, ORCA_LOCKFILES (space list), optional ORCA_GIT_EXTRAHEADER and ORCA_CLONE_FILTER, plus\n# ORCA_BUILD_SECRET_NAMES (space list of the team secrets the environment names; each value rides in\n# the variable of that name) and ORCA_BUILD_WITHHELD_SECRETS (names it lists that builds never get).\n# `::orca-step:: ` lines mark progress.\n# Exit codes: 10 clone, 20 install script, 30 recording the result, 40 a secret's value left on disk.\nset -uo pipefail\numask 022\n# One stream: the provider replays stdout and stderr separately after the fact, so a failed build's\n# log would read out of order. `::orca-error::` lines carry Orca's own failure explanations.\nexec 2>&1\nOUT=/tmp/orca-build\nROOT=/vercel/sandbox/repos\nmkdir -p \"$OUT\" \"$ROOT\"\n\n# SEC-2: the snapshot of this VM is the team's saved environment, booted by every member's workspace,\n# so a secret's value must never be in it. Secrets are for the install script alone: take_secrets\n# moves them out of this script's environment (clones and Orca's own steps never see them), the\n# install step hands them to that one child, and scan_secrets searches the disk before the build\n# may succeed.\nsecret_names=()\nsecret_values=()\ntake_secrets() {\n local name\n for name in ${ORCA_BUILD_SECRET_NAMES:-}; do\n case \"$name\" in '' | *[!A-Z0-9_]* | [0-9]* | ORCA_* | PATH | HOME | LD_* | DYLD_*) continue ;; esac\n [ -n \"${!name:-}\" ] || continue\n secret_names+=(\"$name\")\n secret_values+=(\"${!name}\")\n unset \"$name\"\n done\n unset ORCA_BUILD_SECRET_NAMES\n # Only a file that changes after this marker can hold a value: the base image never saw one.\n : >\"$OUT/.scan-since\"\n}\n\n# Fails (non-zero) when a secret's value is in a file the build created or changed, anywhere on the\n# disk the snapshot captures. Plain text only: a value the script encoded, encrypted or compressed is\n# not found, and neither is one shorter than 8 characters (it would match ordinary text). A search\n# that cannot run fails too. Prints names and paths, never a value.\n# ORCA_SCAN_ROOT: tests only (a scratch tree); the control plane never sets it.\nscan_secrets() {\n [ \"${#secret_names[@]}\" -gt 0 ] || return 0\n local root=\"${ORCA_SCAN_ROOT:-/}\" i line pattern hit named=0\n local names=() patterns=() args=() newer=() sudo=()\n for i in \"${!secret_names[@]}\"; do\n # grep -F reads a newline as \"or\": a multi-line value (a PEM key) is searched by its longest line.\n pattern=\"\"\n while IFS= read -r line; do\n case \"$line\" in -----*) continue ;; esac\n if [ \"${#line}\" -gt \"${#pattern}\" ]; then pattern=\"$line\"; fi\n done < <(printf '%s\\n' \"${secret_values[$i]}\") # a pipe, not a here-string temp file\n [ \"${#pattern}\" -ge 8 ] || continue\n names+=(\"${secret_names[$i]}\")\n patterns+=(\"$pattern\")\n args+=(-e \"$pattern\")\n done\n [ \"${#patterns[@]}\" -gt 0 ] || return 0\n if ! command -v find >/dev/null 2>&1 || ! command -v grep >/dev/null 2>&1 || ! command -v xargs >/dev/null 2>&1; then\n echo \"::orca-error:: Orca couldn’t check the build for secrets, so the environment wasn’t saved.\"\n return 1\n fi\n # Root-owned files count too (`sudo npm config set` writes /root/.npmrc).\n if sudo -n true 2>/dev/null; then sudo=(sudo -n); fi\n # A missing marker (the install script removed it) means every file is searched.\n if [ -f \"$OUT/.scan-since\" ]; then newer=(-cnewer \"$OUT/.scan-since\"); fi\n changed_files() {\n ${sudo[@]+\"${sudo[@]}\"} find \"$root\" \\( -path /proc -o -path /sys -o -path /dev -o -path /run \\) -prune \\\n -o -type f ${newer[@]+\"${newer[@]}\"} -print0 2>/dev/null\n }\n # One pass for every value; the per-secret passes only run on a build that is about to fail.\n if [ -z \"$(changed_files | ${sudo[@]+\"${sudo[@]}\"} xargs -0 grep -lsF \"${args[@]}\" -- 2>/dev/null | head -n 1)\" ]; then\n return 0\n fi\n for i in \"${!names[@]}\"; do\n while IFS= read -r hit; do\n [ -n \"$hit\" ] || continue\n named=1\n echo \"::orca-error:: ${names[$i]} was left in $hit. Saved environments are shared with your team, so remove it before the install script ends.\"\n done < <(changed_files | ${sudo[@]+\"${sudo[@]}\"} xargs -0 grep -lsF -e \"${patterns[$i]}\" -- 2>/dev/null | head -n 5)\n done\n if [ \"$named\" -eq 0 ]; then\n # The file moved between the two passes: still a build that held a value on disk.\n echo \"::orca-error:: The install script left a secret on disk, so the environment wasn’t saved. Remove it before the script ends, then save again.\"\n fi\n return 1\n}\n\ntake_secrets\nstep() { printf '\\n::orca-step:: %s\\n' \"$1\"; }\nstarted=$(date +%s)\nexport PATH=\"/opt/orca/tools/bin:/opt/orca/bin:$PATH\"\nexport ORCA_REPOS_ROOT=\"$ROOT\" ORCA_ENVIRONMENT_BUILD=1 CI=1 GIT_TERMINAL_PROMPT=0\nrepos() { printf '%s\\n' \"$ORCA_BUILD_REPOS\"; }\n\n# Tell the script author (often an agent) what they are installing onto.\npm=$(for c in apt-get dnf yum apk; do command -v \"$c\" >/dev/null 2>&1 && { echo \"$c\"; break; }; done)\necho \"Base image: $(. /etc/os-release 2>/dev/null && echo \"$PRETTY_NAME\"), $(nproc) vCPU, package manager: ${pm:-none} (with sudo)\"\n\nstep clone\nfirst=\"\"\nwhile IFS='|' read -r url branch dir header; do\n [ -n \"$url\" ] || continue\n dest=\"$ROOT/$dir\"\n [ -n \"$first\" ] || first=\"$dest\"\n # The egress layer swaps the placeholder for a short-lived repo token; nothing real is on disk,\n # and the header is never written to the repo's config.\n git_auth=()\n [ -z \"${header:-}\" ] || git_auth=(-c \"http.https://github.com/.extraheader=$header\")\n echo \"Cloning $url${branch:+ ($branch)}\"\n rm -rf -- \"$dest\"\n # The clone's own error shows only when it really failed (an empty repo is recovered below).\n # ORCA_CLONE_FILTER (automatic environments: blob:none) keeps history and trees, not every old blob.\n if ! git \"${git_auth[@]}\" clone --quiet ${ORCA_CLONE_FILTER:+--filter=\"$ORCA_CLONE_FILTER\"} ${branch:+--branch \"$branch\"} -- \"$url\" \"$dest\" 2>\"$OUT/clone.err\"; then\n refs=0\n git \"${git_auth[@]}\" ls-remote --exit-code -- \"$url\" >/dev/null 2>&1 || refs=$?\n if [ -n \"$branch\" ] && [ \"$refs\" -eq 2 ]; then\n # Reachable but no refs at all (ls-remote exits 2): a repo with no commits yet.\n echo \"$url has no commits yet; starting it empty on $branch.\"\n rm -rf -- \"$dest\"\n { git \"${git_auth[@]}\" clone --quiet -- \"$url\" \"$dest\" 2>/dev/null && git -C \"$dest\" checkout --quiet -b \"$branch\"; } || exit 10\n else\n cat \"$OUT/clone.err\"\n echo \"::orca-error:: Could not clone $url${branch:+ at $branch}. Check that the branch exists and Orca Cloud can reach the repository.\" \n exit 10\n fi\n fi\n git -C \"$dest\" log -1 --format=' at %h %s' 2>/dev/null || echo ' (no commits yet)'\ndone < <(repos)\n\nstep install\nprintf '%s' \"${ORCA_INSTALL_SCRIPT_B64:-}\" | base64 -d >\"$OUT/install.sh\"\nif [ -s \"$OUT/install.sh\" ]; then\n echo \"Running the install script in ${first:-$ROOT}\"\n for name in ${ORCA_BUILD_WITHHELD_SECRETS:-}; do\n echo \"$name is a model key, so environment builds don’t get it. Agents in workspaces still do.\"\n done\n (\n cd \"${first:-$ROOT}\" || exit 1\n if [ \"${#secret_names[@]}\" -gt 0 ]; then\n for i in \"${!secret_names[@]}\"; do export \"${secret_names[$i]}=${secret_values[$i]}\"; done\n fi\n exec bash -euo pipefail \"$OUT/install.sh\"\n ) /dev/null)\" || continue\n git -C \"$dest\" ls-files --others --ignored --exclude-standard --directory 2>/dev/null \\\n | sed 's#/$##' | grep -E \"$deps_re\" | head -n 500 >\"$common/orca-env-deps\" || true\n if [ -n \"${ORCA_DEPS_HOOK_B64:-}\" ]; then\n # A relative core.hooksPath (husky: .husky/_) names a gitignored folder a new worktree lacks; pin\n # it to the primary checkout so every worktree runs the same hooks, ours included.\n rel=\"$(git -C \"$dest\" config --get core.hooksPath 2>/dev/null || true)\"\n case \"$rel\" in '' | /*) ;; *) git -C \"$dest\" config core.hooksPath \"$dest/$rel\" ;; esac\n hooks=\"$(git -C \"$dest\" rev-parse --path-format=absolute --git-path hooks)\"\n mkdir -p \"$hooks\"\n if [ -e \"$hooks/post-checkout\" ] && ! grep -q 'orca-env-deps' \"$hooks/post-checkout\"; then\n mv -- \"$hooks/post-checkout\" \"$hooks/post-checkout.orca-chained\"\n fi\n printf '%s' \"$ORCA_DEPS_HOOK_B64\" | base64 -d >\"$hooks/post-checkout\" && chmod 0755 \"$hooks/post-checkout\"\n fi\n n=$(wc -l <\"$common/orca-env-deps\" | tr -d ' ')\n [ \"$n\" -gt 0 ] && echo \"$dir: $n installed folder(s) carried into new workspaces.\"\ndone < <(repos)\n{\n printf '{\"durationSec\":%d,\"repos\":[' \"$(( $(date +%s) - started ))\"\n sep=\"\"\n while IFS='|' read -r url branch dir _; do\n [ -n \"$url\" ] || continue\n dest=\"$ROOT/$dir\"\n commit=$(git -C \"$dest\" rev-parse --verify --quiet HEAD 2>/dev/null || true)\n head=$(git -C \"$dest\" symbolic-ref --quiet --short HEAD 2>/dev/null || true)\n printf '%s{\"url\":\"%s\",\"dir\":\"%s\",\"branch\":\"%s\",\"commit\":\"%s\",\"lockfiles\":{' \"$sep\" \"$url\" \"$dir\" \"$head\" \"$commit\"\n lsep=\"\"\n if [ -n \"$commit\" ]; then\n for f in ${ORCA_LOCKFILES:-}; do\n blob=$(git -C \"$dest\" rev-parse --verify --quiet \"HEAD:$f\" 2>/dev/null || true)\n if [ -n \"$blob\" ]; then printf '%s\"%s\":\"%s\"' \"$lsep\" \"$f\" \"$blob\"; lsep=\",\"; fi\n done\n fi\n printf '}}'\n sep=\",\"\n done < <(repos)\n printf '],\"diskUsedBytes\":%s,\"diskTotalBytes\":%s}\\n' \\\n \"$(df -B1 --output=used / | tail -n 1 | tr -d ' ')\" \"$(df -B1 --output=size / | tail -n 1 | tr -d ' ')\"\n} >\"$OUT/result.json\" || exit 30\n# Nothing from this VM's own session belongs in the image, only what the script installed.\nrm -rf -- \"$OUT/install.sh\" \"$OUT/.scan-since\" \"$HOME/.bash_history\" \"$HOME/.orcad-data\" \"$HOME/.orca-sandbox\"\necho \"Disk used: $(df -h --output=used / | tail -n 1 | tr -d ' ') of $(df -h --output=size / | tail -n 1 | tr -d ' ')\"\n" export const ENVIRONMENT_DEPS_HOOK: string = "#!/usr/bin/env bash\n# Installed by Orca environment builds as the repo's post-checkout hook. When git creates a new linked\n# worktree (`git worktree add` passes the null commit as the previous HEAD), give it the dependency\n# folders the build installed in the primary checkout, as hard links: near-instant, real paths (not\n# symlinks, so bundlers and native modules behave). Hard links share file contents: tools that rewrite\n# a file in place inside these folders change it for every worktree on this VM (one workspace per VM).\n# Any post-checkout hook the repo already had runs afterwards, unchanged.\nprev=\"${1:-}\"\nhere=\"$(dirname \"$0\")\"\nif [ \"$prev\" = \"0000000000000000000000000000000000000000\" ] && [ \"${ORCA_SKIP_DEPS_LINK:-}\" != 1 ]; then\n common=\"$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)\"\n top=\"$(git rev-parse --show-toplevel 2>/dev/null || true)\"\n primary=\"$(dirname \"$common\")\"\n list=\"$common/orca-env-deps\"\n if [ -n \"$top\" ] && [ \"$top\" != \"$primary\" ] && [ -f \"$list\" ]; then\n started=$(date +%s%N)\n linked=0\n while IFS= read -r rel; do\n case \"$rel\" in '' | /* | *..*) continue ;; esac\n [ -e \"$primary/$rel\" ] && [ ! -e \"$top/$rel\" ] || continue\n mkdir -p \"$(dirname \"$top/$rel\")\"\n if [ -d \"$primary/$rel\" ]; then\n if ! cp -al -- \"$primary/$rel\" \"$top/$rel\" 2>/dev/null; then\n # A partial hard-link copy would make the fallback nest inside it: start clean.\n rm -rf -- \"$top/$rel\"\n cp -a -- \"$primary/$rel\" \"$top/$rel\" 2>/dev/null || { rm -rf -- \"$top/$rel\"; continue; }\n fi\n else\n cp -a -- \"$primary/$rel\" \"$top/$rel\" 2>/dev/null || continue\n fi\n linked=$((linked + 1))\n done <\"$list\"\n if [ \"$linked\" -gt 0 ]; then\n echo \"Orca: reused $linked installed dependency folder(s) from the environment build ($(( ($(date +%s%N) - started) / 1000000 )) ms).\" >&2\n fi\n fi\nfi\nif [ -x \"$here/post-checkout.orca-chained\" ]; then\n if head -n 1 \"$here/post-checkout.orca-chained\" | grep -Eq '^#!.*(ba|da|z)?sh'; then\n # Sourced, so $0 still names `post-checkout` (husky v9 finds the user's hook by basename \"$0\").\n ( . \"$here/post-checkout.orca-chained\" \"$@\" )\n exit $?\n fi\n exec \"$here/post-checkout.orca-chained\" \"$@\"\nfi\nexit 0\n" diff --git a/cloud/apps/sandbox-control/image/vm/git b/cloud/apps/sandbox-control/image/vm/git index 2baa0706287..bba55f41db4 100755 --- a/cloud/apps/sandbox-control/image/vm/git +++ b/cloud/apps/sandbox-control/image/vm/git @@ -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() { diff --git a/cloud/apps/sandbox-control/src/api/audit-ingest.ts b/cloud/apps/sandbox-control/src/api/audit-ingest.ts index 2b967dfbc13..5986be07cac 100644 --- a/cloud/apps/sandbox-control/src/api/audit-ingest.ts +++ b/cloud/apps/sandbox-control/src/api/audit-ingest.ts @@ -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 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}`) } diff --git a/cloud/apps/sandbox-control/src/egress-proxy.ts b/cloud/apps/sandbox-control/src/egress-proxy.ts index ed81aeb48f6..b99bde5f4a6 100644 --- a/cloud/apps/sandbox-control/src/egress-proxy.ts +++ b/cloud/apps/sandbox-control/src/egress-proxy.ts @@ -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) diff --git a/cloud/apps/sandbox-control/src/env-refresh.ts b/cloud/apps/sandbox-control/src/env-refresh.ts index 6f386cc50d0..effb185137c 100644 --- a/cloud/apps/sandbox-control/src/env-refresh.ts +++ b/cloud/apps/sandbox-control/src/env-refresh.ts @@ -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 { @@ -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 }) } } } diff --git a/cloud/apps/sandbox-control/src/env.ts b/cloud/apps/sandbox-control/src/env.ts index c7a4a4322d4..d4586f574ab 100644 --- a/cloud/apps/sandbox-control/src/env.ts +++ b/cloud/apps/sandbox-control/src/env.ts @@ -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/

/locations//keyRings//cryptoKeys/ + * 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:` + * SECRETS_KEK_RETIRED_WRAPPED JSON {"":""} 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 diff --git a/cloud/apps/sandbox-control/src/index.ts b/cloud/apps/sandbox-control/src/index.ts index 83dac74951c..b2c38b2cc8a 100644 --- a/cloud/apps/sandbox-control/src/index.ts +++ b/cloud/apps/sandbox-control/src/index.ts @@ -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 { +async function secretsState(env: Env): Promise> { 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 { } } +/** 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) diff --git a/cloud/apps/sandbox-control/src/lifecycle.ts b/cloud/apps/sandbox-control/src/lifecycle.ts index 1886e9568be..2365a4cf5a8 100644 --- a/cloud/apps/sandbox-control/src/lifecycle.ts +++ b/cloud/apps/sandbox-control/src/lifecycle.ts @@ -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 diff --git a/cloud/apps/sandbox-control/src/operator.ts b/cloud/apps/sandbox-control/src/operator.ts index f97598a7837..72c2b1f86ef 100644 --- a/cloud/apps/sandbox-control/src/operator.ts +++ b/cloud/apps/sandbox-control/src/operator.ts @@ -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 }) } diff --git a/cloud/apps/sandbox-control/src/secret-log.ts b/cloud/apps/sandbox-control/src/secret-log.ts new file mode 100644 index 00000000000..cdf56bac9d3 --- /dev/null +++ b/cloud/apps/sandbox-control/src/secret-log.ts @@ -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 { + 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 = {}, known: Iterable = []): 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)) +} diff --git a/cloud/apps/sandbox-control/src/secrets-kms.ts b/cloud/apps/sandbox-control/src/secrets-kms.ts new file mode 100644 index 00000000000..ea11ce51ed2 --- /dev/null +++ b/cloud/apps/sandbox-control/src/secrets-kms.ts @@ -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 + +/** + * 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 + 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 { + 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 { + 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 + } +} diff --git a/cloud/apps/sandbox-control/src/secrets.ts b/cloud/apps/sandbox-control/src/secrets.ts index 88f0b5d1fc4..e214afa56f6 100644 --- a/cloud/apps/sandbox-control/src/secrets.ts +++ b/cloud/apps/sandbox-control/src/secrets.ts @@ -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) { +/** + * 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) { 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; scopes: Record + row: Pick ): Promise { 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 } | null = null +let cachedKeyring: { source: string; keyring: Promise; 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 | null { +function parseKeyMap(json: string | undefined): Record { + return json ? (JSON.parse(json) as Record) : {} +} + +/** + * 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 { + 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 => { + if (!env.SECRETS_KMS_KEY || !env.SECRETS_KMS_SERVICE_ACCOUNT || !env.SECRETS_KEK_WRAPPED) { + throw new SecretsKmsError('config') + } + let retiredWrapped: Record + 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 = {} + 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 = { 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 | 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) : {} 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 { 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 }) } diff --git a/cloud/apps/sandbox-control/src/session-env.ts b/cloud/apps/sandbox-control/src/session-env.ts index 92d2a15c485..c20fbebaa02 100644 --- a/cloud/apps/sandbox-control/src/session-env.ts +++ b/cloud/apps/sandbox-control/src/session-env.ts @@ -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 @@ -27,7 +29,7 @@ export type ProviderEnv = { env: Record; scopes: Record + row: Pick ): Promise { 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 }>() export function cachedProviderEnv(db: D1Database, keyring: Keyring, row: SandboxRow, now: number): Promise { - 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, row: Pick export type { AgentMode } export async function sessionSecretEnv( - deps: Pick, + deps: Pick, row: SandboxRow ): Promise { + 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 } } diff --git a/cloud/apps/sandbox-control/test/agent-providers.test.ts b/cloud/apps/sandbox-control/test/agent-providers.test.ts index 422ad721ba3..c3d521a0b82 100644 --- a/cloud/apps/sandbox-control/test/agent-providers.test.ts +++ b/cloud/apps/sandbox-control/test/agent-providers.test.ts @@ -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) }) diff --git a/cloud/apps/sandbox-control/test/audit-log.test.ts b/cloud/apps/sandbox-control/test/audit-log.test.ts index 0bc82a4660e..37b7c65b29b 100644 --- a/cloud/apps/sandbox-control/test/audit-log.test.ts +++ b/cloud/apps/sandbox-control/test/audit-log.test.ts @@ -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, accountId = 'org:acme') => s.internal('/v1/internal/audit-events', { accountId, events: [{ ...invite, ...event }] }) diff --git a/cloud/apps/sandbox-control/test/git-shim-github-no-prompt.test.ts b/cloud/apps/sandbox-control/test/git-shim-github-no-prompt.test.ts index b328489de5d..e5cda0b3b75 100644 --- a/cloud/apps/sandbox-control/test/git-shim-github-no-prompt.test.ts +++ b/cloud/apps/sandbox-control/test/git-shim-github-no-prompt.test.ts @@ -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." ) }) diff --git a/cloud/apps/sandbox-control/test/git-shim-identity-nudge.test.ts b/cloud/apps/sandbox-control/test/git-shim-identity-nudge.test.ts index 0c3c6518104..6a3a88fc090 100644 --- a/cloud/apps/sandbox-control/test/git-shim-identity-nudge.test.ts +++ b/cloud/apps/sandbox-control/test/git-shim-identity-nudge.test.ts @@ -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 = '' diff --git a/cloud/apps/sandbox-control/test/secrets-hardening.test.ts b/cloud/apps/sandbox-control/test/secrets-hardening.test.ts new file mode 100644 index 00000000000..ca05eaefeb2 --- /dev/null +++ b/cloud/apps/sandbox-control/test/secrets-hardening.test.ts @@ -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. + }) +}) diff --git a/cloud/apps/sandbox-control/test/secrets-kms.test.ts b/cloud/apps/sandbox-control/test/secrets-kms.test.ts new file mode 100644 index 00000000000..3b703981aae --- /dev/null +++ b/cloud/apps/sandbox-control/test/secrets-kms.test.ts @@ -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 + +/** + * 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 = {}): Record { + 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>, 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[] = [ + { 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) + }) +}) diff --git a/cloud/apps/sandbox-control/test/shared-workspace-secrets.test.ts b/cloud/apps/sandbox-control/test/shared-workspace-secrets.test.ts new file mode 100644 index 00000000000..e4e92b532ab --- /dev/null +++ b/cloud/apps/sandbox-control/test/shared-workspace-secrets.test.ts @@ -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> + +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 { + 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 { + 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 = {}, deps: Parameters[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) { + 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) + }) +}) diff --git a/cloud/docs/sec-kms-design.md b/cloud/docs/sec-kms-design.md index dd5bbf2a6ac..dd570d0ca28 100644 --- a/cloud/docs/sec-kms-design.md +++ b/cloud/docs/sec-kms-design.md @@ -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 diff --git a/src/main/billing/billing-hosted-links.ts b/src/main/billing/billing-hosted-links.ts deleted file mode 100644 index 83072c8740c..00000000000 --- a/src/main/billing/billing-hosted-links.ts +++ /dev/null @@ -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 { - return withSetupSessionFallback(args, (checkoutArgs) => - mintHosted(userDataPath, (token, endpoints) => - createCheckoutUrl(endpoints, token, checkoutArgs) - ) - ) -} - -export function getOrcaBillingPortalLink(userDataPath: string): Promise { - return mintHosted(userDataPath, (token, endpoints) => createPortalUrl(endpoints, token)) -} diff --git a/src/main/billing/billing-service.ts b/src/main/billing/billing-service.ts index c0845051b7a..9053f4402e8 100644 --- a/src/main/billing/billing-service.ts +++ b/src/main/billing/billing-service.ts @@ -222,7 +222,7 @@ export async function getOrcaBillingTeamPlans( type CreateHostedUrl = (token: string, endpoints: BillingEndpoints) => Promise /** 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 { diff --git a/src/main/billing/cloud-setup-service.test.ts b/src/main/billing/cloud-setup-service.test.ts index 1a919d1c058..020c25fbbf8 100644 --- a/src/main/billing/cloud-setup-service.test.ts +++ b/src/main/billing/cloud-setup-service.test.ts @@ -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() }) }) diff --git a/src/main/billing/cloud-setup-service.ts b/src/main/billing/cloud-setup-service.ts index 49623e44cb8..665b98ca51f 100644 --- a/src/main/billing/cloud-setup-service.ts +++ b/src/main/billing/cloud-setup-service.ts @@ -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' } diff --git a/src/main/cloud-api/cloud-api-client.test.ts b/src/main/cloud-api/cloud-api-client.test.ts index 6cae58ad6fb..dfb1bed7995 100644 --- a/src/main/cloud-api/cloud-api-client.test.ts +++ b/src/main/cloud-api/cloud-api-client.test.ts @@ -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(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(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(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( + 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(async () => + respond(403, { error: 'entitlement_denied', reason: 'audit_log' }) + ) + ).exportAuditLogCsv() + ) + ).toBe('plan-required') }) }) diff --git a/src/main/cloud-api/cloud-api-client.ts b/src/main/cloud-api/cloud-api-client.ts index 7b2a1b83390..c67998eea86 100644 --- a/src/main/cloud-api/cloud-api-client.ts +++ b/src/main/cloud-api/cloud-api-client.ts @@ -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( + /** 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> { + ): Promise { 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( + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', + path: string, + schema: S, + options: RequestOptions = {} + ): Promise> { + 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 = {} + ): Promise => { + 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, { diff --git a/src/main/cloud-api/cloud-api-ipc.ts b/src/main/cloud-api/cloud-api-ipc.ts index 494cac86df7..abb599316eb 100644 --- a/src/main/cloud-api/cloud-api-ipc.ts +++ b/src/main/cloud-api/cloud-api-ipc.ts @@ -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> @@ -59,7 +55,12 @@ const HANDLERS: Record = { '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()), diff --git a/src/main/cloud-api/cloud-api-service.ts b/src/main/cloud-api/cloud-api-service.ts index a90aca89939..bd2fcdb5bea 100644 --- a/src/main/cloud-api/cloud-api-service.ts +++ b/src/main/cloud-api/cloud-api-service.ts @@ -49,6 +49,7 @@ function resolveClient(): { client: CloudApiClient; controlPlaneUrl: string } | client: createCloudApiClient({ controlPlaneUrl: config.controlPlaneUrl, getAccessToken: () => config.getAccessToken(), + fetchImpl: config.fetch, classifyTokenError }) } diff --git a/src/main/cloud-secrets/cloud-secrets-client.test.ts b/src/main/cloud-secrets/cloud-secrets-client.test.ts index 03410c45d03..eb0724fe430 100644 --- a/src/main/cloud-secrets/cloud-secrets-client.test.ts +++ b/src/main/cloud-secrets/cloud-secrets-client.test.ts @@ -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 => 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 }, diff --git a/src/main/cloud-secrets/cloud-secrets-ipc.ts b/src/main/cloud-secrets/cloud-secrets-ipc.ts index 5c936e5d704..411cb5e4c61 100644 --- a/src/main/cloud-secrets/cloud-secrets-ipc.ts +++ b/src/main/cloud-secrets/cloud-secrets-ipc.ts @@ -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. diff --git a/src/main/cloud-workspaces/cloud-workspaces-service.ts b/src/main/cloud-workspaces/cloud-workspaces-service.ts index 6ee16e8539b..9050dd334a3 100644 --- a/src/main/cloud-workspaces/cloud-workspaces-service.ts +++ b/src/main/cloud-workspaces/cloud-workspaces-service.ts @@ -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 diff --git a/src/main/hosted-sandbox/cloud-create-from-github.ts b/src/main/hosted-sandbox/cloud-create-from-github.ts index 1809caa686c..23dca09b989 100644 --- a/src/main/hosted-sandbox/cloud-create-from-github.ts +++ b/src/main/hosted-sandbox/cloud-create-from-github.ts @@ -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) diff --git a/src/main/hosted-sandbox/environments/hosted-environment-service.ts b/src/main/hosted-sandbox/environments/hosted-environment-service.ts index 233a4d82fbb..0d15ea8fe51 100644 --- a/src/main/hosted-sandbox/environments/hosted-environment-service.ts +++ b/src/main/hosted-sandbox/environments/hosted-environment-service.ts @@ -32,7 +32,7 @@ export function createHostedEnvironmentService( ? createHostedEnvironmentClient({ baseUrl: config.controlPlaneUrl, getAccessToken: config.getAccessToken, - fetch: fetchImpl + fetch: fetchImpl ?? config.fetch }) : null } diff --git a/src/main/hosted-sandbox/hosted-account-ipc.ts b/src/main/hosted-sandbox/hosted-account-ipc.ts index f31a122724c..30b7e1294db 100644 --- a/src/main/hosted-sandbox/hosted-account-ipc.ts +++ b/src/main/hosted-sandbox/hosted-account-ipc.ts @@ -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) { diff --git a/src/main/hosted-sandbox/hosted-github-link-ipc.test.ts b/src/main/hosted-sandbox/hosted-github-link-ipc.test.ts index 3c2ab59b7bb..6d4b9421efa 100644 --- a/src/main/hosted-sandbox/hosted-github-link-ipc.test.ts +++ b/src/main/hosted-sandbox/hosted-github-link-ipc.test.ts @@ -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 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() }) diff --git a/src/main/hosted-sandbox/hosted-sandbox-config.test.ts b/src/main/hosted-sandbox/hosted-sandbox-config.test.ts index 2ff15926d7a..c22aa14be3d 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-config.test.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-config.test.ts @@ -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('', { status: 403 }))).toBe(false) + }) +}) diff --git a/src/main/hosted-sandbox/hosted-sandbox-config.ts b/src/main/hosted-sandbox/hosted-sandbox-config.ts index a85ed77e2b7..df34663574e 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-config.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-config.ts @@ -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 /** 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 { + 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 } | 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 => { + // 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) } diff --git a/src/main/hosted-sandbox/hosted-sandbox-create-reclaim.test.ts b/src/main/hosted-sandbox/hosted-sandbox-create-reclaim.test.ts new file mode 100644 index 00000000000..b5686f8a2ee --- /dev/null +++ b/src/main/hosted-sandbox/hosted-sandbox-create-reclaim.test.ts @@ -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() + }) +}) diff --git a/src/main/hosted-sandbox/hosted-sandbox-create-reclaim.ts b/src/main/hosted-sandbox/hosted-sandbox-create-reclaim.ts new file mode 100644 index 00000000000..343360ff400 --- /dev/null +++ b/src/main/hosted-sandbox/hosted-sandbox-create-reclaim.ts @@ -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[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, + 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 { + 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 +} diff --git a/src/main/hosted-sandbox/hosted-sandbox-device-enrollment.ts b/src/main/hosted-sandbox/hosted-sandbox-device-enrollment.ts index 1129d5748e6..15b5a0bb38f 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-device-enrollment.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-device-enrollment.ts @@ -25,7 +25,7 @@ async function isStaleToken(response: Response): Promise { export function createHostedSandboxDeviceEnroller( config: HostedSandboxConfig, - fetchImpl: typeof fetch = fetch + fetchImpl: typeof fetch = config.fetch ?? fetch ): OrcaCloudDeviceEnroller { async function token(): Promise { try { diff --git a/src/main/hosted-sandbox/hosted-sandbox-driver.ts b/src/main/hosted-sandbox/hosted-sandbox-driver.ts index cb0ccc76f5c..088f92f5b89 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-driver.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-driver.ts @@ -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 } diff --git a/src/main/hosted-sandbox/hosted-sandbox-install.ts b/src/main/hosted-sandbox/hosted-sandbox-install.ts index 3b84e6c6d75..54425f61413 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-install.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-install.ts @@ -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. diff --git a/src/main/hosted-sandbox/hosted-sandbox-org-fetch.ts b/src/main/hosted-sandbox/hosted-sandbox-org-fetch.ts new file mode 100644 index 00000000000..795a9b5f1c9 --- /dev/null +++ b/src/main/hosted-sandbox/hosted-sandbox-org-fetch.ts @@ -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 { + 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 + invalidateAccessToken: () => void + orgSwitchError: () => EnvironmentProviderError +}): typeof fetch { + const send = ( + input: Parameters[0], + init: RequestInit | undefined, + token?: string + ): Promise => { + 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 + } +} diff --git a/src/main/hosted-sandbox/hosted-sandbox-org-token.test.ts b/src/main/hosted-sandbox/hosted-sandbox-org-token.test.ts new file mode 100644 index 00000000000..38c78024ca2 --- /dev/null +++ b/src/main/hosted-sandbox/hosted-sandbox-org-token.test.ts @@ -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 + ) => ({ 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 | 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((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): Promise { + 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) + }) +}) diff --git a/src/main/hosted-sandbox/hosted-sandbox-recipe.ts b/src/main/hosted-sandbox/hosted-sandbox-recipe.ts index 68a40383a04..3c941bdcc16 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-recipe.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-recipe.ts @@ -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 }) } diff --git a/src/main/hosted-sandbox/hosted-sandbox-share-ipc.ts b/src/main/hosted-sandbox/hosted-sandbox-share-ipc.ts index 0a90bfc423a..97f3df6ea5b 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-share-ipc.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-share-ipc.ts @@ -124,7 +124,8 @@ export function registerCloudShareHandlers( ? createHostedSandboxShareClient( createHostedSandboxRequest({ baseUrl: config.controlPlaneUrl, - getAccessToken: config.getAccessToken + getAccessToken: config.getAccessToken, + fetch: config.fetch }) ) : null diff --git a/src/main/hosted-sandbox/hosted-sandbox-token-org.ts b/src/main/hosted-sandbox/hosted-sandbox-token-org.ts new file mode 100644 index 00000000000..7a28fa51ada --- /dev/null +++ b/src/main/hosted-sandbox/hosted-sandbox-token-org.ts @@ -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 { + const body: unknown = await response + .clone() + .json() + .catch(() => null) + return ( + typeof body === 'object' && body !== null && 'error' in body && body.error === 'not_a_member' + ) +} diff --git a/src/main/ipc/ephemeral-vm-forwarded-env.test.ts b/src/main/ipc/ephemeral-vm-forwarded-env.test.ts new file mode 100644 index 00000000000..e0fd791fd14 --- /dev/null +++ b/src/main/ipc/ephemeral-vm-forwarded-env.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +// The only channel registered here returns a summary. +const handlers = new Map 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']) + ) +}) diff --git a/src/main/ipc/ephemeral-vm-forwarded-env.ts b/src/main/ipc/ephemeral-vm-forwarded-env.ts index 80c94261d92..cca2decb82d 100644 --- a/src/main/ipc/ephemeral-vm-forwarded-env.ts +++ b/src/main/ipc/ephemeral-vm-forwarded-env.ts @@ -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) + ) ) } diff --git a/src/main/ipc/ephemeral-vm.ts b/src/main/ipc/ephemeral-vm.ts index 99063930d7f..6d30c664f68 100644 --- a/src/main/ipc/ephemeral-vm.ts +++ b/src/main/ipc/ephemeral-vm.ts @@ -103,7 +103,7 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin return listPickerRecipes(store, args.repoId, pluginService) }) - registerRecipeForwardedEnvHandler() + registerRecipeForwardedEnvHandler(store) ipcMain.handle( 'ephemeralVm:listRecipeCatalog', diff --git a/src/main/ipc/profiles/orca-profile-billing-handlers.ts b/src/main/ipc/profiles/orca-profile-billing-handlers.ts index 7b277f07486..79909a6fce7 100644 --- a/src/main/ipc/profiles/orca-profile-billing-handlers.ts +++ b/src/main/ipc/profiles/orca-profile-billing-handlers.ts @@ -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 => openOrcaBillingPortal(getProfileUserDataPath()) ) - // "Copy link": the same Stripe URLs, minted but not opened (paste into another browser profile). - ipcMain.handle('orcaProfiles:billingPortalLink', async (): Promise => - getOrcaBillingPortalLink(getProfileUserDataPath()) - ) - ipcMain.handle( - 'orcaProfiles:billingCheckoutLink', - async (_event, rawArgs: unknown): Promise => - getOrcaBillingCheckoutLink(getProfileUserDataPath(), billingCheckoutArgsFromUnknown(rawArgs)) - ) } diff --git a/src/main/ipc/profiles/orca-profile-org-members-handlers.ts b/src/main/ipc/profiles/orca-profile-org-members-handlers.ts index f8dc1c80713..889b38d0d94 100644 --- a/src/main/ipc/profiles/orca-profile-org-members-handlers.ts +++ b/src/main/ipc/profiles/orca-profile-org-members-handlers.ts @@ -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 => + getOrcaProfileAccountMfa(getProfileUserDataPath()) + ) + + ipcMain.handle( + 'orcaProfiles:teamMfaPolicy', + async (_event, rawArgs: unknown): Promise => + getOrcaTeamMfaPolicy(getProfileUserDataPath(), orgMembersScopedArgs(rawArgs).orgId) + ) + + ipcMain.handle( + 'orcaProfiles:teamMfaPolicySet', + async (_event, rawArgs: unknown): Promise => { + 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 => + listOrcaTeamMfaPending(getProfileUserDataPath(), orgMembersScopedArgs(rawArgs).orgId) + ) + + ipcMain.handle( + 'orcaProfiles:teamMfaRemind', + async (_event, rawArgs: unknown): Promise => { + 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 ( diff --git a/src/main/ipc/profiles/orca-profiles.ts b/src/main/ipc/profiles/orca-profiles.ts index 21a64c3e42c..379f26b30c2 100644 --- a/src/main/ipc/profiles/orca-profiles.ts +++ b/src/main/ipc/profiles/orca-profiles.ts @@ -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 => { - const result = await connectCurrentOrcaProfile(getProfileUserDataPath()) + async (_event, args?: unknown): Promise => { + const connect = connectCurrentArgsFromUnknown(args) + const result = await connectCurrentOrcaProfile(getProfileUserDataPath(), connect) if (result.status === 'connected') { options.onAuthMutation?.() } diff --git a/src/main/orca-cloud-agents/orca-cloud-agents-ipc.ts b/src/main/orca-cloud-agents/orca-cloud-agents-ipc.ts index 547abdad8d5..a7c0b0e58c1 100644 --- a/src/main/orca-cloud-agents/orca-cloud-agents-ipc.ts +++ b/src/main/orca-cloud-agents/orca-cloud-agents-ipc.ts @@ -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, diff --git a/src/main/orca-profiles/cloud-auth/profile-cloud-pkce.ts b/src/main/orca-profiles/cloud-auth/profile-cloud-pkce.ts index 4ecb4fd6807..4ff0a828517 100644 --- a/src/main/orca-profiles/cloud-auth/profile-cloud-pkce.ts +++ b/src/main/orca-profiles/cloud-auth/profile-cloud-pkce.ts @@ -68,6 +68,12 @@ export function beginOrcaCloudPkceFlow( authorizeParams?: Record /** 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 { 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 } & ConnectCurrentArgs) | undefined +): { openBrowser: boolean; authorizeParams?: Record; holdBrowser?: boolean } { + return { + openBrowser: setup?.openBrowser !== false, + ...(setup?.authorizeParams ? { authorizeParams: setup.authorizeParams, holdBrowser: true } : {}) + } +} diff --git a/src/main/orca-profiles/cloud-auth/profile-cloud-service-connect-overlap.test.ts b/src/main/orca-profiles/cloud-auth/profile-cloud-service-connect-overlap.test.ts index cb4dea3d017..973e7db0046 100644 --- a/src/main/orca-profiles/cloud-auth/profile-cloud-service-connect-overlap.test.ts +++ b/src/main/orca-profiles/cloud-auth/profile-cloud-service-connect-overlap.test.ts @@ -34,7 +34,8 @@ vi.mock('electron', () => ({ })) vi.mock('./profile-cloud-pkce', () => ({ - beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock + beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock, + pkceSignInOptions: () => ({}) })) vi.mock('./profile-cloud-client', () => ({ diff --git a/src/main/orca-profiles/cloud-auth/profile-cloud-service-sign-out-connect-race.test.ts b/src/main/orca-profiles/cloud-auth/profile-cloud-service-sign-out-connect-race.test.ts index d88825a2b2c..e00cf7dfb04 100644 --- a/src/main/orca-profiles/cloud-auth/profile-cloud-service-sign-out-connect-race.test.ts +++ b/src/main/orca-profiles/cloud-auth/profile-cloud-service-sign-out-connect-race.test.ts @@ -32,7 +32,8 @@ vi.mock('electron', () => ({ })) vi.mock('./profile-cloud-pkce', () => ({ - beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock + beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock, + pkceSignInOptions: () => ({}) })) vi.mock('./profile-cloud-client', () => ({ diff --git a/src/main/orca-profiles/cloud-auth/profile-cloud-service.ts b/src/main/orca-profiles/cloud-auth/profile-cloud-service.ts index ceb8e47cd6d..25d8631353c 100644 --- a/src/main/orca-profiles/cloud-auth/profile-cloud-service.ts +++ b/src/main/orca-profiles/cloud-auth/profile-cloud-service.ts @@ -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 { 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 diff --git a/src/main/orca-profiles/cloud-mfa/profile-cloud-mfa-service.test.ts b/src/main/orca-profiles/cloud-mfa/profile-cloud-mfa-service.test.ts new file mode 100644 index 00000000000..ed894f914a3 --- /dev/null +++ b/src/main/orca-profiles/cloud-mfa/profile-cloud-mfa-service.test.ts @@ -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') + }) +}) diff --git a/src/main/orca-profiles/cloud-mfa/profile-cloud-mfa-service.ts b/src/main/orca-profiles/cloud-mfa/profile-cloud-mfa-service.ts new file mode 100644 index 00000000000..f4349260791 --- /dev/null +++ b/src/main/orca-profiles/cloud-mfa/profile-cloud-mfa-service.ts @@ -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 = + 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 { + 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 { + 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 } + } +} diff --git a/src/main/orca-profiles/cloud-mfa/profile-cloud-team-mfa-service.test.ts b/src/main/orca-profiles/cloud-mfa/profile-cloud-team-mfa-service.test.ts new file mode 100644 index 00000000000..bc6b69f7632 --- /dev/null +++ b/src/main/orca-profiles/cloud-mfa/profile-cloud-team-mfa-service.test.ts @@ -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([]) + }) +}) diff --git a/src/main/orca-profiles/cloud-mfa/profile-cloud-team-mfa-service.ts b/src/main/orca-profiles/cloud-mfa/profile-cloud-team-mfa-service.ts new file mode 100644 index 00000000000..88cb059de74 --- /dev/null +++ b/src/main/orca-profiles/cloud-mfa/profile-cloud-team-mfa-service.ts @@ -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 { + 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 { + 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, { 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( + userDataPath: string, + run: (apiBaseUrl: string, accessToken: string, url: (path: string) => string) => Promise, + orgId: string +): Promise | { 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 { + 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 { + 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 { + 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 { + 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) +} diff --git a/src/main/orca-profiles/cloud-org/profile-cloud-dev-org-members.ts b/src/main/orca-profiles/cloud-org/profile-cloud-dev-org-members.ts index ed5671f15bd..452488e6305 100644 --- a/src/main/orca-profiles/cloud-org/profile-cloud-dev-org-members.ts +++ b/src/main/orca-profiles/cloud-org/profile-cloud-dev-org-members.ts @@ -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() }] } diff --git a/src/main/orca-profiles/cloud-org/profile-cloud-org-members-client.ts b/src/main/orca-profiles/cloud-org/profile-cloud-org-members-client.ts index 4c34ef96bee..90e14c3e393 100644 --- a/src/main/orca-profiles/cloud-org/profile-cloud-org-members-client.ts +++ b/src/main/orca-profiles/cloud-org/profile-cloud-org-members-client.ts @@ -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 +): Pick { + 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) } } diff --git a/src/main/orca-profiles/cloud-org/profile-cloud-org-members-service.ts b/src/main/orca-profiles/cloud-org/profile-cloud-org-members-service.ts index f886639940b..7f4e8d16c55 100644 --- a/src/main/orca-profiles/cloud-org/profile-cloud-org-members-service.ts +++ b/src/main/orca-profiles/cloud-org/profile-cloud-org-members-service.ts @@ -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. */ diff --git a/src/main/orca-profiles/cloud-org/profile-cloud-org-selection.test.ts b/src/main/orca-profiles/cloud-org/profile-cloud-org-selection.test.ts new file mode 100644 index 00000000000..1727d8c355b --- /dev/null +++ b/src/main/orca-profiles/cloud-org/profile-cloud-org-selection.test.ts @@ -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(), + 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() + }) +}) diff --git a/src/main/orca-profiles/cloud-org/profile-cloud-org-selection.ts b/src/main/orca-profiles/cloud-org/profile-cloud-org-selection.ts index 1061b0ad38b..40b704b9d1a 100644 --- a/src/main/orca-profiles/cloud-org/profile-cloud-org-selection.ts +++ b/src/main/orca-profiles/cloud-org/profile-cloud-org-selection.ts @@ -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 } diff --git a/src/main/orca-profiles/cloud-session/profile-cloud-session-store.ts b/src/main/orca-profiles/cloud-session/profile-cloud-session-store.ts index 00772a2cccd..6dc7264d656 100644 --- a/src/main/orca-profiles/cloud-session/profile-cloud-session-store.ts +++ b/src/main/orca-profiles/cloud-session/profile-cloud-session-store.ts @@ -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 }) } diff --git a/src/main/orca-profiles/cloud-token-epoch.ts b/src/main/orca-profiles/cloud-token-epoch.ts new file mode 100644 index 00000000000..bfc9d10daa2 --- /dev/null +++ b/src/main/orca-profiles/cloud-token-epoch.ts @@ -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() + +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() + } +} diff --git a/src/main/orca-profiles/profile-cloud-org-members-client.test.ts b/src/main/orca-profiles/profile-cloud-org-members-client.test.ts index 3d7eb390fde..4c4751672ea 100644 --- a/src/main/orca-profiles/profile-cloud-org-members-client.test.ts +++ b/src/main/orca-profiles/profile-cloud-org-members-client.test.ts @@ -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) + }) }) diff --git a/src/main/orca-profiles/profile-cloud-pkce.test.ts b/src/main/orca-profiles/profile-cloud-pkce.test.ts index dcaa4dc4d8b..ae709d9c067 100644 --- a/src/main/orca-profiles/profile-cloud-pkce.test.ts +++ b/src/main/orca-profiles/profile-cloud-pkce.test.ts @@ -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 + }) + }) +}) diff --git a/src/main/orca-profiles/profile-cloud-service-sign-in-cancel.test.ts b/src/main/orca-profiles/profile-cloud-service-sign-in-cancel.test.ts index e121a03bd3a..0c0e5443390 100644 --- a/src/main/orca-profiles/profile-cloud-service-sign-in-cancel.test.ts +++ b/src/main/orca-profiles/profile-cloud-service-sign-in-cancel.test.ts @@ -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, diff --git a/src/preload/api/cloud-api-bridge.ts b/src/preload/api/cloud-api-bridge.ts index 918dafee590..cd0d514a037 100644 --- a/src/preload/api/cloud-api-bridge.ts +++ b/src/preload/api/cloud-api-bridge.ts @@ -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 /** 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> getSettings: () => Promise> putSettings: (input: { vmCliEnabled: boolean }) => Promise> - listAudit: (query?: { - limit?: number - before?: number - }) => Promise> + /** The team audit log: owners and admins of a team whose plan includes it (else `plan-required`). */ + listAuditLog: (query?: CloudAuditLogQuery) => Promise> + exportAuditLogCsv: ( + query?: Pick + ) => Promise> listProjects: () => Promise> listInstallations: () => Promise> listRoutines: () => Promise> @@ -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'), diff --git a/src/preload/api/ephemeral-vm-api.ts b/src/preload/api/ephemeral-vm-api.ts index ddd892058bf..bdf4cda8b16 100644 --- a/src/preload/api/ephemeral-vm-api.ts +++ b/src/preload/api/ephemeral-vm-api.ts @@ -47,8 +47,11 @@ export type EphemeralVmApi = { diagnostics: NonNullable }[] > - /** Names (never values) of this computer's env that recipe create scripts receive. */ - forwardedEnvSummary: () => Promise + /** + * 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 /** Credential-like variable names (never values) recipe scripts would inherit from Orca. */ listCredentialEnvNames: () => Promise doctor: (args: { repoId: string; recipeId: string }) => Promise diff --git a/src/preload/api/ephemeral-vm-bridge.ts b/src/preload/api/ephemeral-vm-bridge.ts index 8b6bdd463c0..1f86524a010 100644 --- a/src/preload/api/ephemeral-vm-bridge.ts +++ b/src/preload/api/ephemeral-vm-bridge.ts @@ -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), diff --git a/src/preload/api/orca-cloud-workspace-bridge.ts b/src/preload/api/orca-cloud-workspace-bridge.ts index 891d4c7cbe7..86194e59d79 100644 --- a/src/preload/api/orca-cloud-workspace-bridge.ts +++ b/src/preload/api/orca-cloud-workspace-bridge.ts @@ -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), diff --git a/src/preload/api/orca-profile-api.ts b/src/preload/api/orca-profile-api.ts index 71572197248..effde9806e7 100644 --- a/src/preload/api/orca-profile-api.ts +++ b/src/preload/api/orca-profile-api.ts @@ -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 - connectCurrent: () => Promise + /** COPY-LINK-FIRST-CLICK: `openBrowser: false` starts sign-in without opening the browser. */ + connectCurrent: (args?: { openBrowser?: boolean }) => Promise signInProgress: () => Promise 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 signOutCurrent: () => Promise selectOrg: (args: SelectOrcaProfileOrgArgs) => Promise + /** The signed-in person's own two-factor state and where to manage it. */ + accountMfa: () => Promise + /** The team's "require MFA" policy and the person's own state in it. */ + teamMfaPolicy: (args: OrcaTeamMfaPolicyArgs) => Promise + teamMfaPolicySet: (args: OrcaTeamMfaPolicySetArgs) => Promise + teamMfaPending: (args: OrcaTeamMfaPolicyArgs) => Promise + teamMfaRemind: (args: OrcaTeamMfaRemindArgs) => Promise orgMembersList: (args: OrcaProfileOrgMembersListArgs) => Promise orgMemberInvite: ( args: OrcaProfileOrgMemberInviteArgs @@ -129,10 +146,6 @@ export type OrcaProfileApi = { cloudSetupLink: (args: { sessionId: string }) => Promise /** Opens the Stripe Customer Portal in the browser. */ billingOpenPortal: () => Promise - /** "Copy link": a fresh Customer Portal URL, minted but not opened. */ - billingPortalLink: () => Promise - /** "Copy link": the Checkout URL billingOpenCheckout would open, minted but not opened. */ - billingCheckoutLink: (args: OrcaBillingCheckoutArgs) => Promise /** The last 30 days of sandbox hours (breakdowns for owners and admins only). */ billingUsageHistory: (args: { utcOffsetMinutes: number diff --git a/src/preload/api/orca-profiles-bridge.ts b/src/preload/api/orca-profiles-bridge.ts index 22837449f8d..c360b2ac2d9 100644 --- a/src/preload/api/orca-profiles-bridge.ts +++ b/src/preload/api/orca-profiles-bridge.ts @@ -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), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 0626a79d9c6..9d4f2c684e7 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 { + diff --git a/src/renderer/src/components/Landing.tsx b/src/renderer/src/components/Landing.tsx index 87b6a076a32..3b1e854506f 100644 --- a/src/renderer/src/components/Landing.tsx +++ b/src/renderer/src/components/Landing.tsx @@ -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 ( -

- {/* 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. */} -
-
-
- {!cloudListPresent && } +
+
+
+ - {preflightIssues.length > 0 && ( - - )} + {preflightIssues.length > 0 && } - {/* With the cloud list showing, only the no-project hint stays: the list is what to pick. */} - {(!cloudListPresent || !hasProjects) && ( -

- {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.')} -

- )} +

+ {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.')} +

- openModal('add-repo')} - onCreate={() => openModal('new-workspace-composer', { telemetrySource: 'unknown' })} - /> +
+ + + - {/* V3-08: signed out, the team's cloud workspaces can't be listed; say so where they'd be. */} - {hasProjects && signedOutOfCloud && !cloudListPresent ? ( - - ) : hasProjects ? ( - - ) : null} - - {!cloudListPresent && } +
-
-
- + + {/* V3-08: signed out, the team's cloud workspaces can't be opened; say so here. */} + {hasProjects && signedOutOfCloud ? ( + + ) : hasProjects ? ( + + ) : null} + +
diff --git a/src/renderer/src/components/OrcaSignInProgress.tsx b/src/renderer/src/components/OrcaSignInProgress.tsx index a7e857ff597..280c7b5b4ae 100644 --- a/src/renderer/src/components/OrcaSignInProgress.tsx +++ b/src/renderer/src/components/OrcaSignInProgress.tsx @@ -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 { 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({ ) } + // 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 ( - + ) } diff --git a/src/renderer/src/components/agent-host-panel/HostPanelBody.tsx b/src/renderer/src/components/agent-host-panel/HostPanelBody.tsx index 195a7e167ce..056e6cefdab 100644 --- a/src/renderer/src/components/agent-host-panel/HostPanelBody.tsx +++ b/src/renderer/src/components/agent-host-panel/HostPanelBody.tsx @@ -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(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(null) const unavailable = host.availability !== 'available' const agents = ( diff --git a/src/renderer/src/components/agent-host-panel/RecipeEnvForwardingWarning.fixture.tsx b/src/renderer/src/components/agent-host-panel/RecipeEnvForwardingWarning.fixture.tsx index bd9169135f7..18bbb9582be 100644 --- a/src/renderer/src/components/agent-host-panel/RecipeEnvForwardingWarning.fixture.tsx +++ b/src/renderer/src/components/agent-host-panel/RecipeEnvForwardingWarning.fixture.tsx @@ -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: () => ( + + ) +}) + export const noKeys = defineFixture({ width: 420, render: () => diff --git a/src/renderer/src/components/agent-host-panel/RecipeEnvForwardingWarning.test.tsx b/src/renderer/src/components/agent-host-panel/RecipeEnvForwardingWarning.test.tsx new file mode 100644 index 00000000000..00718ef0fae --- /dev/null +++ b/src/renderer/src/components/agent-host-panel/RecipeEnvForwardingWarning.test.tsx @@ -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() + 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() + expect( + screen.getByText("This recipe's create script gets this computer's environment.") + ).toBeTruthy() +}) diff --git a/src/renderer/src/components/agent-host-panel/RecipeEnvForwardingWarning.tsx b/src/renderer/src/components/agent-host-panel/RecipeEnvForwardingWarning.tsx index ae1ad165c35..6e4b48150c6 100644 --- a/src/renderer/src/components/agent-host-panel/RecipeEnvForwardingWarning.tsx +++ b/src/renderer/src/components/agent-host-panel/RecipeEnvForwardingWarning.tsx @@ -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({

- {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. + passEnv allows." + )} + components={{ code: }} + /> + ) : ( + t( + 'agentHostPanel.recipeEnv', + "This recipe's create script gets this computer's environment." + ) )}

{summary === null ? ( diff --git a/src/renderer/src/components/agent-host-panel/agent-host-panel-host-fixtures.tsx b/src/renderer/src/components/agent-host-panel/agent-host-panel-host-fixtures.tsx index 5e13ee0fae6..afc77113843 100644 --- a/src/renderer/src/components/agent-host-panel/agent-host-panel-host-fixtures.tsx +++ b/src/renderer/src/components/agent-host-panel/agent-host-panel-host-fixtures.tsx @@ -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 diff --git a/src/renderer/src/components/agent-sign-in-strip/cloud-chat-banners.test.tsx b/src/renderer/src/components/agent-sign-in-strip/cloud-chat-banners.test.tsx index c437665c201..e4a650cbedd 100644 --- a/src/renderer/src/components/agent-sign-in-strip/cloud-chat-banners.test.tsx +++ b/src/renderer/src/components/agent-sign-in-strip/cloud-chat-banners.test.tsx @@ -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() diff --git a/src/renderer/src/components/agent-sign-in-strip/cloud-setup-strip-actions.test.ts b/src/renderer/src/components/agent-sign-in-strip/cloud-setup-strip-actions.test.ts index baa7cef4c6a..6cbac0377a1 100644 --- a/src/renderer/src/components/agent-sign-in-strip/cloud-setup-strip-actions.test.ts +++ b/src/renderer/src/components/agent-sign-in-strip/cloud-setup-strip-actions.test.ts @@ -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() }) }) diff --git a/src/renderer/src/components/agent-sign-in-strip/cloud-setup-strip-actions.ts b/src/renderer/src/components/agent-sign-in-strip/cloud-setup-strip-actions.ts index b80504a954f..ab90eac24da 100644 --- a/src/renderer/src/components/agent-sign-in-strip/cloud-setup-strip-actions.ts +++ b/src/renderer/src/components/agent-sign-in-strip/cloud-setup-strip-actions.ts @@ -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) => { diff --git a/src/renderer/src/components/agent-sign-in-strip/use-cloud-held-strip.ts b/src/renderer/src/components/agent-sign-in-strip/use-cloud-held-strip.ts index a4e6e11260c..fa9989f86ae 100644 --- a/src/renderer/src/components/agent-sign-in-strip/use-cloud-held-strip.ts +++ b/src/renderer/src/components/agent-sign-in-strip/use-cloud-held-strip.ts @@ -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({ diff --git a/src/renderer/src/components/billing/PaymentIssueNotice.tsx b/src/renderer/src/components/billing/PaymentIssueNotice.tsx index 7f7d9f8167f..88996b7aabf 100644 --- a/src/renderer/src/components/billing/PaymentIssueNotice.tsx +++ b/src/renderer/src/components/billing/PaymentIssueNotice.tsx @@ -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 ? ( - { - if (!busy) { - onUpdatePayment() - } - }} + onClick={busy ? undefined : onUpdatePayment} > {t('auto.components.billing.updatePayment', 'Update payment method')} - + ) : undefined } /> diff --git a/src/renderer/src/components/billing/PlanBillingOverageSave.fixture.tsx b/src/renderer/src/components/billing/PlanBillingOverageSave.fixture.tsx new file mode 100644 index 00000000000..98c0d1b1442 --- /dev/null +++ b/src/renderer/src/components/billing/PlanBillingOverageSave.fixture.tsx @@ -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(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 ( + {}} + onPortal={() => {}} + onSetOverage={setOverage} + onSeePlans={() => {}} + /> + ) +} + +function clickButton(frame: HTMLElement, label: RegExp): void { + const button = [...frame.querySelectorAll('button')].find((candidate) => + label.test(candidate.textContent ?? '') + ) + button?.click() +} + +const settle = (): Promise => 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: () => , + 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: () => , + 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: () => , + 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: () => , + interact: async (frame) => { + clickButton(frame, /^Turn off overage$/) + await settle() + } +}) diff --git a/src/renderer/src/components/billing/PlanBillingOverageSave.test.tsx b/src/renderer/src/components/billing/PlanBillingOverageSave.test.tsx new file mode 100644 index 00000000000..270502d213a --- /dev/null +++ b/src/renderer/src/components/billing/PlanBillingOverageSave.test.tsx @@ -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 ( + { + 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() + 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('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('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('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('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( + {}} /> + ) + fireEvent.click(turnOnButton()) + expect(statusText()).toBe('Saving…') + view.rerender( + {}} + /> + ) + expect(statusText()).toBe('Saved') + }) +}) diff --git a/src/renderer/src/components/billing/PlanBillingSection.tsx b/src/renderer/src/components/billing/PlanBillingSection.tsx index a719d090fd6..ab1ad5f35ae 100644 --- a/src/renderer/src/components/billing/PlanBillingSection.tsx +++ b/src/renderer/src/components/billing/PlanBillingSection.tsx @@ -14,8 +14,6 @@ import { } from '../../../../shared/orca-billing' import { openPlansPage } from './plans/plans-page-store' import { PaymentIssueNotice } from './PaymentIssueNotice' -import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' -import { billingPortalLink } from './billing-link' import { OverageControls, UsageSummary } from './PlanBillingUsage' import { denyReasonCopy, openFailureCopy, overageFailureCopy } from './plan-billing-copy' import { PlanStatusBadge } from './PlanStatusBadge' @@ -280,24 +278,20 @@ export function PlanBillingView({
{hasPlan || onTrial ? null : ( )} {hasPlan && entitlement.canManageBilling && entitlement.payment.pastDueSince === null ? ( - { - if (!busy) { - onPortal() - } - }} + onClick={busy ? undefined : onPortal} > {t('auto.components.billing.manageBilling', 'Manage billing')} - + ) : null} {hasPlan ? ( // Members have no other action here, so it gets the button's chrome (and its alignment). diff --git a/src/renderer/src/components/billing/PlanBillingUsage.tsx b/src/renderer/src/components/billing/PlanBillingUsage.tsx index c02871a199a..acffe8a9ff0 100644 --- a/src/renderer/src/components/billing/PlanBillingUsage.tsx +++ b/src/renderer/src/components/billing/PlanBillingUsage.tsx @@ -1,5 +1,4 @@ import { useEffect, useId, useRef, useState } from 'react' -import { Check } from 'lucide-react' import { Button } from '@/components/ui/button' import { SettingsDisabledControlTooltip } from '@/components/settings/form-controls/SettingsDisabledControlTooltip' import { Input } from '@/components/ui/input' @@ -18,6 +17,7 @@ import { } from './plan-billing-usage-copy' import { formatCents, formatRateCents } from './plans/plans-pricing' import { capTooHighCopy } from './plan-billing-copy' +import { OverageSaveStatus, useOverageSaveState, type OverageAction } from './overage-save-state' import { ORCA_BILLING_OVERAGE_SECTION_ID } from '../new-workspace/orca-cloud-run-target-access' /** "Resets and renews Dec 2" when both land the same day (monthly); separate parts otherwise. */ @@ -117,15 +117,19 @@ export function OverageControls({ const capId = useId() const hintId = useId() const capErrorId = useId() - const savedRef = useRef(null) const capInputRef = useRef(null) - // Set by Turn on: once the cap field appears, it takes focus (N-09). - const focusCapOnOpen = useRef(false) + const turnOnRef = useRef(null) + // Set by Turn on / Turn off: once the card switches, focus lands on the new view's control (N-09). + const focusOnSwitch = useRef(false) const { overage } = entitlement useEffect(() => { - if (overage.optedIn && focusCapOnOpen.current) { - focusCapOnOpen.current = false - capInputRef.current?.focus() + if (focusOnSwitch.current) { + focusOnSwitch.current = false + if (overage.optedIn) { + capInputRef.current?.focus() + } else { + turnOnRef.current?.focus() + } } }, [overage.optedIn]) // One field: the cap while overage is on, $0 while it's off. @@ -133,20 +137,20 @@ export function OverageControls({ // Billing's default cap; unknown (no catalog) sends none and billing applies its own. const defaultCapCents = entitlement.offers?.team.defaultSpendCapCents ?? null const [capDollars, setCapDollars] = useState(savedDollars) - // Shows "Saved" after a successful change until the field is edited again (review M13). - const [savedFlash, setSavedFlash] = useState(false) // Re-seed from the server whenever the saved values change (render-time adjustment, no effect). const [seededFrom, setSeededFrom] = useState(savedDollars) if (seededFrom !== savedDollars) { setSeededFrom(savedDollars) setCapDollars(savedDollars) } - const [seenFailures, setSeenFailures] = useState(failures) - if (seenFailures !== failures) { - setSeenFailures(failures) - setCapDollars(savedDollars) - setSavedFlash(false) - } + const { status, action, begin, clear } = useOverageSaveState({ + overage, + busy, + failures, + // Roll back to what billing has. + onFailure: () => setCapDollars(savedDollars) + }) + const saving = status === 'saving' const parsedCap = Number(capDollars) const maxCapCents = entitlement.offers?.team.maxSpendCapCents ?? null const capWhole = capDollars.trim() !== '' && Number.isInteger(parsedCap) && parsedCap >= 0 @@ -207,7 +211,9 @@ export function OverageControls({ ) } - const save = (enabled: boolean, cents: number | null): void => { + const save = (action: OverageAction, cents: number | null): void => { + const enabled = action !== 'off' + begin({ action, optedIn: enabled, capCents: enabled ? cents : null }) onSetOverage( !enabled ? { enabled: false } @@ -215,10 +221,11 @@ export function OverageControls({ ? { enabled: true } : { enabled: true, spendCapCents: cents } ) - setSavedFlash(true) - // The action turns into "Saving…" in place; move focus onto it. - window.requestAnimationFrame(() => savedRef.current?.focus()) } + const blocked = busy || saving || paymentFailed + const statusLine = + // Turn off's status sits by its link, so it doesn't read as the cap failing (design-squad). + const offStatus = action === 'off' const blockedReason = paymentFailed ? t( 'auto.components.billing.overagePaymentFailed', @@ -241,14 +248,14 @@ export function OverageControls({

) const turnOn = (): void => { - if (busy) { + if (blocked) { return } if (defaultCapCents !== null) { setCapDollars(String(Math.round(defaultCapCents / 100))) } - focusCapOnOpen.current = true - save(true, defaultCapCents) + focusOnSwitch.current = true + save('on', defaultCapCents) } // N-09: off is one status line and one action. No cap field and no "$ 0" (which reads as "on"). @@ -265,33 +272,24 @@ export function OverageControls({ 'Overage is off. Running sandboxes go to sleep and new ones can’t start when your team runs out of sandbox hours.' )}

+ {/* Turn on is always here, whatever the last change did (OVERAGE-REENABLE). */}
- {savedFlash ? ( - + - - )} + {defaultCapCents === null + ? t('auto.components.billing.overage.turnOnNoCap', 'Turn on overage') + : t('auto.components.billing.overage.turnOn', 'Turn on with a {{cap}} cap', { + cap: formatCents(defaultCapCents, locale) + })} + + + {statusLine}

{hint}

{alertsNote} @@ -310,8 +308,10 @@ export function OverageControls({ className="flex flex-wrap items-start gap-2" onSubmit={(event) => { event.preventDefault() - if (changed && !busy && !paymentFailed) { - save(parsedCap > 0, parsedCap * 100) + if (changed && !blocked) { + save('cap', parsedCap * 100) + // Save turns into the status in place; keep focus in the card on the field. + capInputRef.current?.focus() } }} > @@ -339,7 +339,7 @@ export function OverageControls({ aria-describedby={capValid ? hintId : `${capErrorId} ${hintId}`} onChange={(event) => { setCapDollars(event.target.value) - setSavedFlash(false) + clear() }} />
@@ -354,23 +354,15 @@ export function OverageControls({

)}
- {savedFlash ? ( - - - {busy || changed - ? t('auto.components.billing.capSaving', 'Saving…') - : t('auto.components.billing.capSaved', 'Saved')} - - ) : changed ? ( - - ) : null} + )}

{hint}

@@ -384,24 +376,28 @@ export function OverageControls({ ) : null} {spent} - - - +
+ + + + {offStatus ? statusLine : null} +
{alertsNote}
) diff --git a/src/renderer/src/components/billing/billing-link.test.ts b/src/renderer/src/components/billing/billing-link.test.ts deleted file mode 100644 index 8bca573d724..00000000000 --- a/src/renderer/src/components/billing/billing-link.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' - -const toastError = vi.fn() -vi.mock('sonner', () => ({ toast: { error: (...args: unknown[]) => toastError(...args) } })) - -import { billingCheckoutLink, billingPortalLink } from './billing-link' - -function stubApi(api: Record): void { - vi.stubGlobal('window', { api: { orcaProfiles: api } }) -} - -afterEach(() => { - toastError.mockReset() - vi.unstubAllGlobals() -}) - -describe('billing "Copy link"', () => { - it('returns the minted Portal URL without opening anything', async () => { - const billingPortalLink$ = vi.fn(async () => ({ - status: 'ok' as const, - url: 'https://billing.stripe.com/p/session/x' - })) - const billingOpenPortal = vi.fn() - stubApi({ billingPortalLink: billingPortalLink$, billingOpenPortal }) - await expect(billingPortalLink()).resolves.toBe('https://billing.stripe.com/p/session/x') - expect(billingOpenPortal).not.toHaveBeenCalled() - expect(toastError).not.toHaveBeenCalled() - }) - - it('mints Checkout for the same order the button would open', async () => { - const mint = vi.fn(async () => ({ - status: 'ok' as const, - url: 'https://checkout.stripe.com/c/pay/cs_1' - })) - stubApi({ billingCheckoutLink: mint }) - const args = { plan: 'team', interval: 'year' as const, seats: 5 } - await expect(billingCheckoutLink(args)).resolves.toBe('https://checkout.stripe.com/c/pay/cs_1') - expect(mint).toHaveBeenCalledWith(args) - }) - - it('says why there is no link with the open path’s copy, then yields null', async () => { - stubApi({ billingPortalLink: async () => ({ status: 'forbidden' }) }) - await expect(billingPortalLink()).resolves.toBeNull() - expect(toastError).toHaveBeenCalledWith('Only an owner or admin can manage billing.') - }) - - it('treats a rejected IPC as a failed billing read', async () => { - stubApi({ - billingPortalLink: async () => { - throw new Error('boom') - } - }) - await expect(billingPortalLink()).resolves.toBeNull() - expect(toastError).toHaveBeenCalledWith('Could not reach billing. Try again.') - }) -}) diff --git a/src/renderer/src/components/billing/billing-link.ts b/src/renderer/src/components/billing/billing-link.ts deleted file mode 100644 index 7bdae0ed29b..00000000000 --- a/src/renderer/src/components/billing/billing-link.ts +++ /dev/null @@ -1,35 +0,0 @@ -// "Copy link" for the Stripe buttons: mints the same Checkout/Portal URL the button opens, without -// opening it. A refusal (signed out, not an admin, no billing account…) is said in a toast with the -// open path's own copy, then null, so the split button adds nothing of its own. -import { toast } from 'sonner' -import { translate } from '@/i18n/i18n' -import type { - OrcaBillingCheckoutArgs, - OrcaBillingLinkResult -} from '../../../../shared/orca-billing' -import { openFailureCopy } from './plan-billing-copy' - -async function linkOrToast(mint: () => Promise): Promise { - const result = await mint().catch((error: unknown): OrcaBillingLinkResult => ({ - status: 'failed', - error: String(error) - })) - if (result.status === 'ok') { - return result.url - } - const failure = openFailureCopy(result, translate) - if (failure) { - toast.error(failure) - } - return null -} - -/** The Customer Portal URL "Manage billing" / "Update payment method" would open. */ -export function billingPortalLink(): Promise { - return linkOrToast(() => window.api.orcaProfiles.billingPortalLink()) -} - -/** The Checkout URL a plan's buy button would open for these args. */ -export function billingCheckoutLink(args: OrcaBillingCheckoutArgs): Promise { - return linkOrToast(() => window.api.orcaProfiles.billingCheckoutLink(args)) -} diff --git a/src/renderer/src/components/billing/checkout/CheckoutPanel.tsx b/src/renderer/src/components/billing/checkout/CheckoutPanel.tsx index 4794fc340e2..83870d2cb2a 100644 --- a/src/renderer/src/components/billing/checkout/CheckoutPanel.tsx +++ b/src/renderer/src/components/billing/checkout/CheckoutPanel.tsx @@ -2,7 +2,6 @@ import { useEffect, useId, useRef } from 'react' import { AlertCircle, Check, ExternalLink, Lock, RotateCcw, XCircle } from 'lucide-react' import { Button } from '@/components/ui/button' import { LoadingSpinner } from '@/components/LoadingSpinner' -import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' import { useIntlLocale, useTranslate } from '@/i18n/use-translate' import { cn } from '@/lib/class-names' import { openFailureCopy } from '../plan-billing-copy' @@ -21,8 +20,6 @@ export type CheckoutPanelProps = { /** When the order was placed; the renewal date counts from it. */ now: number onReopen: () => void - /** "Copy link" on Reopen / Try again: a fresh Checkout URL for this order (absent: no chevron). */ - reopenLink?: () => Promise onKeepWaiting: () => void onBack: () => void /** Where "Contact us" goes when billing can't sell the plan (the plan's supportUrl, else Enterprise's contact). */ @@ -220,26 +217,17 @@ export function CheckoutPanel(props: CheckoutPanelProps): React.JSX.Element { : t('auto.components.billing.checkout.openFailed', 'Checkout didn’t open') body = openFailureCopy(phase.result, t) ?? - t('auto.components.billing.openFailed', 'Could not reach billing. Try again.') + t('auto.components.billing.openFailed', 'Couldn’t reach billing. Try again.') break } - const reopenLabel = ( - <> + const reopen = ( + ) const back = ( @@ -287,14 +275,14 @@ export function CheckoutPanel(props: CheckoutPanelProps): React.JSX.Element { phase.result.status === 'plan-unavailable' && props.contactUrl && props.onContact ? ( - props.contactUrl && props.onContact?.(props.contactUrl)} > {t('auto.components.billing.checkout.contactUs', 'Contact us')} - + ) : null} {phase.kind === 'timed-out' ? (

{t( 'auto.components.billing.plans.entHint', diff --git a/src/renderer/src/components/billing/plans/PlansDialogHost.tsx b/src/renderer/src/components/billing/plans/PlansDialogHost.tsx index 3bf8240ec1b..7d5619538f5 100644 --- a/src/renderer/src/components/billing/plans/PlansDialogHost.tsx +++ b/src/renderer/src/components/billing/plans/PlansDialogHost.tsx @@ -88,13 +88,6 @@ export function PlansDialogHost(): React.JSX.Element { setOrderedAt(Date.now()) checkout.start({ planId, interval, seats: count }) }} - checkoutLink={async ({ planId, interval, seats: count }) => { - const url = await checkout.copyLink({ planId, interval, seats: count }) - if (url) { - setOrderedAt(Date.now()) - } - return url - }} onManage={() => void openPortal()} onContactSales={(url) => void window.api.shell.openUrl(url)} onRetry={() => { @@ -126,7 +119,6 @@ export function PlansDialogHost(): React.JSX.Element { } checkout.reopen() }} - reopenLink={checkout.copyReopenLink} contactUrl={plans.state === 'ready' ? planContactUrl(plans.model) : null} onContact={(url) => void window.api.shell.openUrl(url)} onKeepWaiting={checkout.keepWaiting} diff --git a/src/renderer/src/components/billing/plans/PlansView.test.tsx b/src/renderer/src/components/billing/plans/PlansView.test.tsx index 4f5311c5299..397ad211a4d 100644 --- a/src/renderer/src/components/billing/plans/PlansView.test.tsx +++ b/src/renderer/src/components/billing/plans/PlansView.test.tsx @@ -49,13 +49,6 @@ describe("plans dialog from billing's catalog", () => { expect(props.onContactSales).toHaveBeenCalledWith('https://orca.dev/enterprise') }) - it('gives checkout and Contact sales a "Copy link" chevron for the same order', () => { - const checkoutLink = vi.fn(async () => 'https://checkout.stripe.com/c/pay/cs_1') - render() - // One chevron on the checkout button, one on Contact sales. - expect(screen.getAllByRole('button', { name: 'More link options' })).toHaveLength(2) - }) - it('offers only the intervals Stripe prices: no toggle with one', () => { render() expect(screen.queryByRole('radiogroup')).toBeNull() diff --git a/src/renderer/src/components/billing/plans/PlansView.tsx b/src/renderer/src/components/billing/plans/PlansView.tsx index e07ef549b3e..fa1786b4a72 100644 --- a/src/renderer/src/components/billing/plans/PlansView.tsx +++ b/src/renderer/src/components/billing/plans/PlansView.tsx @@ -36,12 +36,6 @@ export type PlansViewProps = { /** Seats Checkout will bill: org members, or 1 for a personal account. */ seats: number onCheckout: (args: { planId: string; interval: OrcaBillingInterval; seats: number }) => void - /** "Copy link" on the checkout button: the Checkout URL it would open (absent: no chevron). */ - checkoutLink?: (args: { - planId: string - interval: OrcaBillingInterval - seats: number - }) => Promise onManage: () => void onContactSales: (url: string) => void onRetry: () => void @@ -209,11 +203,6 @@ export function PlansView(props: PlansViewProps): React.JSX.Element { notice={props.notice} hideManage={paymentIssue} onCheckout={() => props.onCheckout({ planId: team.planId, interval, seats })} - checkoutLink={ - props.checkoutLink - ? () => props.checkoutLink!({ planId: team.planId, interval, seats }) - : undefined - } onManage={props.onManage} /> {enterprise ? ( diff --git a/src/renderer/src/components/billing/plans/TeamPlanCard.tsx b/src/renderer/src/components/billing/plans/TeamPlanCard.tsx index ff2ecd1f74e..dbbde991a8b 100644 --- a/src/renderer/src/components/billing/plans/TeamPlanCard.tsx +++ b/src/renderer/src/components/billing/plans/TeamPlanCard.tsx @@ -2,8 +2,6 @@ import { ExternalLink, Lock, Users } from 'lucide-react' import { Badge } from '@/components/ui/badge' 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 type { PlanBillingNotice } from '../PlanBillingSection' import { LoadingSpinner } from '@/components/LoadingSpinner' import { useIntlLocale, useTranslate } from '@/i18n/use-translate' @@ -24,7 +22,6 @@ export function TeamPlanCard({ notice, hideManage, onCheckout, - checkoutLink, onManage }: { team: TeamPlanModel @@ -37,8 +34,6 @@ export function TeamPlanCard({ /** A payment banner already offers the same Portal action. */ hideManage: boolean onCheckout: () => void - /** "Copy link": mints the Checkout URL the button opens (absent: a plain button). */ - checkoutLink?: () => Promise onManage: () => void }): React.JSX.Element { const t = useTranslate() @@ -131,41 +126,20 @@ export function TeamPlanCard({

) } else if (mode === 'choose') { - const checkoutLabel = ( - <> - {busy ? : null} - {busy - ? t('auto.components.billing.plans.waiting', 'Waiting for checkout…') - : t('auto.components.billing.plans.checkout', 'Continue to checkout')} - {busy ? null : } - - ) footer = ( <> - {checkoutLink ? ( - { - if (!busy) { - onCheckout() - } - }} - > - {checkoutLabel} - - ) : ( - - )} + {notice?.kind === 'error' ? ( ) : ( @@ -189,21 +163,16 @@ export function TeamPlanCard({

) : ( <> - { - if (!busy) { - onManage() - } - }} + onClick={busy ? undefined : onManage} > {t('auto.components.billing.manageBilling', 'Manage billing')} - + {notice?.kind === 'error' ? ( ) : ( diff --git a/src/renderer/src/components/billing/team/TeamDialogHost.tsx b/src/renderer/src/components/billing/team/TeamDialogHost.tsx index 9db2b6353ce..0dd99da27e8 100644 --- a/src/renderer/src/components/billing/team/TeamDialogHost.tsx +++ b/src/renderer/src/components/billing/team/TeamDialogHost.tsx @@ -1,3 +1,4 @@ +import { useTeamMfaGate } from '@/components/team-mfa/TeamMfaGate' import { useCallback, useEffect, useRef, useState } from 'react' import { toast } from 'sonner' import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog' @@ -37,6 +38,7 @@ export function TeamPageBody({ const cloud = authStatus?.cloud const orgId = cloud?.activeOrgId ?? null const roster = useOrgRoster(orgId) + const mfa = useTeamMfaGate() const billing = useOrcaBillingStatus(true) const [userRefreshing, setUserRefreshing] = useState(false) const [billingBusy, setBillingBusy] = useState(false) @@ -174,6 +176,7 @@ export function TeamPageBody({ <> void window.api.shell.openUrl(url)} inline={inline} dangerZone={ org && teamDelete.check ? ( diff --git a/src/renderer/src/components/billing/team/TeamInviteComposer.test.tsx b/src/renderer/src/components/billing/team/TeamInviteComposer.test.tsx new file mode 100644 index 00000000000..3eb9fe09a3b --- /dev/null +++ b/src/renderer/src/components/billing/team/TeamInviteComposer.test.tsx @@ -0,0 +1,32 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, it } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import { TeamInviteComposer } from './TeamInviteComposer' + +afterEach(cleanup) + +const RULE = 'Each invite uses a seat once accepted.' + +it('says only the seat rule, with no seat counts or prices, however many are entered', () => { + render( ({ failed: [] })} />, { + wrapper: TooltipProvider + }) + const input = screen.getByRole('textbox') + const hint = document.getElementById(input.getAttribute('aria-describedby')!)! + expect(hint.textContent).toBe(RULE) + + fireEvent.change(input, { target: { value: 'jordan@acme.dev, kim@acme.dev ' } }) + expect(hint.textContent).toBe(RULE) + expect(hint.textContent).not.toMatch(/open seat|\$|prorated/) +}) + +it('still flags invalid entries after the rule', () => { + render( ({ failed: [] })} />, { + wrapper: TooltipProvider + }) + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'jordan@acme.dev, not-an-email ' } }) + const hint = document.getElementById(input.getAttribute('aria-describedby')!)! + expect(hint.textContent).toBe(`${RULE} Skipping 1 invalid entry.`) +}) diff --git a/src/renderer/src/components/billing/team/TeamInviteComposer.tsx b/src/renderer/src/components/billing/team/TeamInviteComposer.tsx index 566ea08c500..4c1429b5d25 100644 --- a/src/renderer/src/components/billing/team/TeamInviteComposer.tsx +++ b/src/renderer/src/components/billing/team/TeamInviteComposer.tsx @@ -10,9 +10,8 @@ import { } from '@/components/ui/select' import { LoadingSpinner } from '@/components/LoadingSpinner' import { SettingsDisabledControlTooltip } from '@/components/settings/form-controls/SettingsDisabledControlTooltip' -import { useIntlLocale, useTranslate } from '@/i18n/use-translate' +import { useTranslate } from '@/i18n/use-translate' import { cn } from '@/lib/class-names' -import type { OrcaBillingEntitlement } from '../../../../../shared/orca-billing' import type { OrcaOrgRole } from '../../../../../shared/orca-profiles' import { MAX_INVITES_PER_BATCH, @@ -22,17 +21,11 @@ import { } from './parse-invite-emails' import { roleDescription } from './team-copy' import { assignableRoles, roleLabel } from './team-roles' -import { inviteSeatImpactLine } from './team-invite-impact-copy' -import { inviteSeatImpact } from './team-seat-math' export type InviteBatchOutcome = { failed: { email: string; message: string }[] } export type TeamInviteComposerProps = { viewerRole: OrcaOrgRole - entitlement: OrcaBillingEntitlement | null - members: number - pendingInvites: number - now: number /** Sends every invite; resolves with the ones that failed so they stay in the field. */ onInvite: (emails: string[], role: OrcaOrgRole) => Promise inputRef?: React.Ref @@ -41,7 +34,6 @@ export type TeamInviteComposerProps = { /** Multi-email invite field: paste a list, review the chips, pick a role, send. */ export function TeamInviteComposer(props: TeamInviteComposerProps): React.JSX.Element { const t = useTranslate() - const locale = useIntlLocale() const id = useId() const [draft, setDraft] = useState('') const [candidates, setCandidates] = useState([]) @@ -65,19 +57,10 @@ export function TeamInviteComposer(props: TeamInviteComposerProps): React.JSX.El const pendingDraft = draft.trim() ? mergeInviteCandidates(candidates, splitInviteText(draft)) : candidates - // The button and the seat line both count what Send would send, including a typed, - // unseparated address, so the seat impact always matches the invites about to go out. + // The button counts what Send would send, including a typed, unseparated address. const valid = pendingDraft.filter((candidate) => candidate.valid) const committedValid = candidates.filter((candidate) => candidate.valid) const invalidCount = candidates.length - committedValid.length - const paymentFailed = props.entitlement?.payment.pastDueSince != null - const impact = inviteSeatImpact({ - entitlement: props.entitlement, - members: props.members, - pendingInvites: props.pendingInvites, - newInvites: valid.length, - now: props.now - }) const send = async (): Promise => { const all = commit(draft) @@ -271,13 +254,8 @@ export function TeamInviteComposer(props: TeamInviteComposerProps): React.JSX.El

- {inviteSeatImpactLine(impact, valid.length, locale, t)} - {paymentFailed && valid.length > 0 - ? ` ${t( - 'auto.components.billing.team.invitePaymentFailed2', - 'New seats are charged once the failed payment is fixed.' - )}` - : null} + {/* Only the rule: seat counts and prices live in Plan & billing (Neil, TEAM-DIALOG-2). */} + {t('auto.components.billing.team.inviteSeatRule', 'Each invite uses a seat once accepted.')} {invalidCount > 0 ? ` ${t( 'auto.components.billing.team.skipInvalid', diff --git a/src/renderer/src/components/billing/team/TeamMemberList.test.tsx b/src/renderer/src/components/billing/team/TeamMemberList.test.tsx index ac0b97469db..849ae209ecc 100644 --- a/src/renderer/src/components/billing/team/TeamMemberList.test.tsx +++ b/src/renderer/src/components/billing/team/TeamMemberList.test.tsx @@ -67,3 +67,90 @@ it('offers Leave from the ⋯ on your own row only', () => { // A plain member has nothing to do on someone else's row. expect(other.querySelector('button[aria-label^="Actions for"]')).toBeNull() }) + +function renderList( + listRoster: OrcaOrgMembersRoster, + viewerUserId: string +): ReturnType { + return render( + + ) +} + +const rosterWithMfa: OrcaOrgMembersRoster = { + ...roster, + members: [ + { ...roster.members[0]!, mfa: 'on' }, + { ...roster.members[1]!, mfa: 'pending', mfaDeadline: Date.UTC(2026, 10, 24, 12) } + ], + pendingInvites: [{ email: 'alex@acme.dev', role: 'member', createdAt: Date.UTC(2026, 10, 17) }] +} + +it.each(['owner', 'admin'] as const)('shows the MFA column to an %s', (viewerRole) => { + const { container } = renderList({ ...rosterWithMfa, viewerRole }, 'u-neil') + const cell = (key: string): Element => + container.querySelector(`[data-team-row="${key}"] [data-team-mfa]`)! + expect(cell('member:u-neil').textContent).toBe('MFA on') + expect(cell('member:u-grace').textContent).toBe('MFA due Nov 24') + // A small muted badge, not bare text. + expect(cell('member:u-neil').querySelector('[data-slot="badge"]')).not.toBeNull() + // Invites keep an empty cell so the role column stays aligned. + expect(cell('invite:alex@acme.dev').textContent).toBe('') +}) + +it('hides the MFA column from a plain member, even if a roster carried the status', () => { + const { container } = renderList( + { ...rosterWithMfa, viewerRole: 'member', canManageMembers: false }, + 'u-grace' + ) + expect(container.querySelector('[data-team-mfa]')).toBeNull() + expect(container.textContent).not.toContain('MFA') +}) + +it('shows no MFA column when the server sent no status', () => { + const { container } = renderList(roster, 'u-neil') + expect(container.querySelector('[data-team-mfa]')).toBeNull() +}) + +it('leaves the cell blank, not "off", for a member with no status', () => { + const { container } = renderList( + { + ...roster, + members: [{ ...roster.members[0]!, mfa: 'off' }, roster.members[1]!] + }, + 'u-neil' + ) + const cell = (key: string): Element => + container.querySelector(`[data-team-row="${key}"] [data-team-mfa]`)! + expect(cell('member:u-neil').textContent).toBe('MFA off') + expect(cell('member:u-grace').textContent).toBe('') +}) + +it('shows no MFA badge for a member who has not signed in yet', () => { + const { container } = renderList( + { + ...roster, + members: [ + { ...roster.members[0]!, mfa: 'on' }, + { userId: null, email: 'linus@acme.dev', role: 'member', mfa: 'off' } + ] + }, + 'u-neil' + ) + const pending = container.querySelector( + '[data-team-row="member:linus@acme.dev"] [data-team-mfa]' + )! + expect(pending.textContent).toBe('') +}) diff --git a/src/renderer/src/components/billing/team/TeamMemberList.tsx b/src/renderer/src/components/billing/team/TeamMemberList.tsx index ed65b8ed652..c23a07c9b61 100644 --- a/src/renderer/src/components/billing/team/TeamMemberList.tsx +++ b/src/renderer/src/components/billing/team/TeamMemberList.tsx @@ -29,6 +29,7 @@ import { useIntlLocale, useTranslate } from '@/i18n/use-translate' import type { OrcaOrgMember, OrcaOrgRole } from '../../../../../shared/orca-profiles' import { PersonAvatar } from './TeamAvatar' import { invitedAsCopy, memberStatusLabel, roleDescription } from './team-copy' +import { memberMfaLabel, showMfaColumn } from './team-member-mfa' import { assignableRoles, roleLabel } from './team-roles' import type { TeamRow } from './team-roster-rows' @@ -243,6 +244,7 @@ function RowMenu({ export function TeamMemberList(props: TeamMemberListProps): React.JSX.Element { const t = useTranslate() const locale = useIntlLocale() + const mfaColumn = showMfaColumn(props.rows, props.viewerRole) return (

    {state.busy ? : null} + {mfaColumn ? ( + // Rendered for every row, invites included, so the role column stays aligned. + + {/* Only for members who have signed in: someone still pending has no account + state yet, so "off" would mislead. */} + {row.kind === 'member' && + row.status === 'active' && + memberMfaLabel(row.member, locale, t) ? ( + {memberMfaLabel(row.member, locale, t)} + ) : null} + + ) : null} {row.kind === 'member' ? ( ): React.JSX.Element { @@ -129,6 +128,37 @@ export const memberReadOnly = defineFixture({ ) }) +// What the server sends an owner or admin: every member carries an MFA status. +const rosterWithMfa: OrcaOrgMembersRoster = { + ...roster, + members: [ + { ...roster.members[0]!, mfa: 'on' }, + { ...roster.members[1]!, mfa: 'off' }, + { ...roster.members[2]!, mfa: 'pending', mfaDeadline: NOW + 6 * DAY }, + { ...roster.members[3]!, mfa: 'off' } + ] +} + +export const ownerSeesMfaColumn = defineFixture({ + width, + render: () => +}) + +export const adminSeesMfaColumn = defineFixture({ + width, + render: () => ( + + ) +}) + +export const mfaColumnNarrow = defineFixture({ + width: 640, + render: () => +}) + export const rowBusyAndError = defineFixture({ width, render: () => ( @@ -245,34 +275,13 @@ async function typeInvites(text: string): Promise { await new Promise((resolve) => setTimeout(resolve, 100)) } -/** Will add seats: 4 paid, 4 members and 2 pending, so both new invites add a seat. */ +/** Pasted list: the chips, one invalid entry, and the seat rule (no counts or prices). */ export const invitePasted = defineFixture({ width, render: () => , interact: () => typeInvites('jordan@acme.dev, kim@acme.dev, not-an-email ') }) -/** Seats free: 8 paid, 6 taken (members plus pending), so one invite adds nothing. */ -export const inviteSeatsFree = defineFixture({ - width, - render: () => , - interact: () => typeInvites('jordan@acme.dev ') -}) - -/** Billing unknown (disabled in this build, or it failed): the rule without counts or prices. */ -export const inviteBillingUnknown = defineFixture({ - width, - render: () => , - interact: () => typeInvites('jordan@acme.dev, kim@acme.dev ') -}) - -/** Billing sent no price: seats are still counted, no price is shown. */ -export const inviteNoPrice = defineFixture({ - width, - render: () => , - interact: () => typeInvites('jordan@acme.dev, kim@acme.dev ') -}) - /** Short roster so the Enterprise row sits inside the frame. */ export const ssoRow = defineFixture({ width, diff --git a/src/renderer/src/components/billing/team/TeamPageView.test.tsx b/src/renderer/src/components/billing/team/TeamPageView.test.tsx new file mode 100644 index 00000000000..5ae89ebf7cf --- /dev/null +++ b/src/renderer/src/components/billing/team/TeamPageView.test.tsx @@ -0,0 +1,63 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, it } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { OrcaOrgMembersRoster } from '../../../../../shared/orca-profiles' +import { sampleTeam } from '../plan-billing-fixtures' +import { TeamPageView, type TeamPageViewProps } from './TeamPageView' + +afterEach(cleanup) + +const roster: OrcaOrgMembersRoster = { + viewerRole: 'owner', + canManageMembers: true, + members: [ + { userId: 'u-neil', email: 'neil@acme.dev', displayName: 'Neil Parker', role: 'owner' }, + { userId: 'u-ada', email: 'ada@acme.dev', displayName: 'Ada Lovelace', role: 'admin' } + ], + pendingInvites: [{ email: 'sam@acme.dev', role: 'member', createdAt: 0 }] +} + +const noop = (): void => {} +const props: TeamPageViewProps = { + org: { orgId: 'org-acme', name: 'Acme Engineering' }, + viewerUserId: 'u-neil', + roster: { status: 'ok', roster }, + billing: { status: 'ok', entitlement: { ...sampleTeam, canManageBilling: true } }, + arrival: null, + rowStates: {}, + refreshing: false, + billingBusy: false, + now: 0, + create: { + organizations: [], + creating: false, + error: null, + unavailable: false, + switchingOrgId: null + }, + onCreateTeam: noop, + onSwitchOrg: noop, + onRefresh: noop, + onInvite: async () => ({ failed: [] }), + onChangeRole: noop, + onMakeOwner: noop, + onRemove: noop, + onResend: noop, + onRevoke: noop, + onLeave: noop, + onSeePlans: noop, + onManageBilling: noop, + onCreateWorkspace: noop, + onDismissArrival: noop +} + +it('keeps the header to the name and counts: no role sentence, no Plan & billing button', () => { + render(, { wrapper: TooltipProvider }) + const header = document.querySelector('header')! + expect(header.textContent).toContain('2 members · 1 invited') + expect(header.textContent).not.toMatch(/You’re an|Plan & billing/) + expect(header.querySelector('button')).toBeNull() + // Seat counts and prices live only in Plan & billing. + expect(screen.queryByText(/open seat/)).toBeNull() +}) diff --git a/src/renderer/src/components/billing/team/TeamPageView.tsx b/src/renderer/src/components/billing/team/TeamPageView.tsx index c32779daf8f..ed80617ac47 100644 --- a/src/renderer/src/components/billing/team/TeamPageView.tsx +++ b/src/renderer/src/components/billing/team/TeamPageView.tsx @@ -1,7 +1,6 @@ -import { useEffect, useId, useRef } from 'react' -import { CreditCard, ExternalLink, RefreshCw } from 'lucide-react' +import { useEffect, useRef } from 'react' +import { RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' -import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' import { InlineError } from '@/components/ui/inline-error' import { Skeleton } from '@/components/ui/skeleton' import { useTranslate } from '@/i18n/use-translate' @@ -27,6 +26,8 @@ import type { TeamPageArrival } from './team-page-store' export type TeamPageViewProps = { /** The active team, or null on a personal account. */ org: { orgId: string; name: string } | null + /** The team's MFA requirement for this person: the countdown banner or the setup screen. */ + mfaNotice?: React.ReactNode viewerUserId: string | null roster: OrcaProfileOrgMembersListResult | null billing: OrcaBillingStatusResult | null @@ -52,8 +53,6 @@ export type TeamPageViewProps = { onManageBilling: () => void onCreateWorkspace: () => void onDismissArrival: () => void - /** Opens the Enterprise page (SSO/SCIM are Enterprise; nothing here pretends otherwise). */ - onContactSales: (url: string) => void /** Settings → Team renders the page inline: seats and invoices stay on Plan & billing (linked). */ inline?: { onOpenPlanBilling: () => void } /** Owners in Settings › Team: "Delete ", after "Leave " (TEAM-DELETE). */ @@ -83,7 +82,6 @@ function SkeletonRows(): React.JSX.Element { export function TeamPageView(props: TeamPageViewProps): React.JSX.Element { const t = useTranslate() const inviteInput = useRef(null) - const ssoTitleId = useId() const hasRoster = props.roster !== null const inline = props.inline ?? null useEffect(() => { @@ -109,7 +107,6 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element { const roster = props.roster?.status === 'ok' ? props.roster.roster : null const entitlement = props.billing?.status === 'ok' ? props.billing.entitlement : null - const enterprise = entitlement?.offers?.enterprise ?? null const rows = roster ? teamRows(roster, props.viewerUserId) : [] const selfRow = rows.find((row) => row.self) const viewerRole = roster?.viewerRole ?? 'member' @@ -119,6 +116,7 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element { return (
    + {props.mfaNotice ?? null}
    @@ -146,20 +144,6 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element { : t('auto.components.billing.team.loadFailedTitle', 'Couldn’t load team')}

    - {!inline && entitlement?.canManageBilling ? ( - // Seats, prices and invoices live in Plan & billing; one quiet way there from here. - // mr-6 clears the dialog's close button, which sits in this corner. - - ) : null}
    {props.arrival?.kind === 'checkout-success' && entitlement ? ( @@ -218,10 +202,6 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element { {roster?.canManageMembers ? ( @@ -298,24 +278,8 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element { ) : null} - {(roster?.canManageMembers && enterprise) || props.dangerZone ? ( + {props.dangerZone ? (
    - {roster?.canManageMembers && enterprise ? ( -
    -

    - {t('auto.components.billing.team.ssoTitle', 'Single sign-on and provisioning')} -

    - props.onContactSales(enterprise.contactUrl)} - > - {t('auto.components.billing.enterpriseContactSales', 'Contact sales')} - - -
    - ) : null} {props.dangerZone}
    ) : null} diff --git a/src/renderer/src/components/billing/team/TeamPlanSummary.tsx b/src/renderer/src/components/billing/team/TeamPlanSummary.tsx index 35a6fd49a3d..0505dd8b28c 100644 --- a/src/renderer/src/components/billing/team/TeamPlanSummary.tsx +++ b/src/renderer/src/components/billing/team/TeamPlanSummary.tsx @@ -233,7 +233,7 @@ export function TeamUpgradeCard({
    {entitlement.canManageBilling ? ( ) : null} diff --git a/src/renderer/src/components/billing/team/team-invite-impact-copy.test.ts b/src/renderer/src/components/billing/team/team-invite-impact-copy.test.ts deleted file mode 100644 index e642e810f4c..00000000000 --- a/src/renderer/src/components/billing/team/team-invite-impact-copy.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { OrcaBillingEntitlement } from '../../../../../shared/orca-billing' -import type { Translate } from '@/i18n/use-translate' -import { sampleTeam } from '../plan-billing-fixtures' -import { formatCents } from '../plans/plans-pricing' -import { inviteSeatImpactLine } from './team-invite-impact-copy' -import { inviteSeatImpact } from './team-seat-math' - -const DAY = 86_400_000 - -// Mirrors i18next's `_one` plural pick so both forms are checked. -const t: Translate = (_key, fallback, options) => { - const one = options?.count === 1 ? options.defaultValue_one : undefined - const text = typeof one === 'string' ? one : fallback - return text.replace(/\{\{(\w+)\}\}/g, (_, name: string) => String(options?.[name] ?? '')) -} - -function team(overrides: Partial = {}): OrcaBillingEntitlement { - return { - ...sampleTeam, - status: 'active', - interval: 'month', - seats: 3, - seatChange: null, - cancelAtPeriodEnd: false, - period: { start: 0, end: 30 * DAY }, - ...overrides - } -} - -function line( - entitlement: OrcaBillingEntitlement | null, - counts: { members: number; pendingInvites: number; newInvites: number } -): string { - const impact = inviteSeatImpact({ entitlement, ...counts, now: 0 }) - return inviteSeatImpactLine(impact, counts.newInvites, 'en-US', t) -} - -const monthly = sampleTeam.offers?.team.monthlyPriceCentsPerSeat ?? 0 - -describe('invite seat impact line', () => { - it('says how many seats are open before anything is typed', () => { - expect(line(team({ seats: 5 }), { members: 2, pendingInvites: 1, newInvites: 0 })).toBe( - 'Each invite uses a seat once accepted. You have 2 open seats.' - ) - expect(line(team({ seats: 3 }), { members: 3, pendingInvites: 0, newInvites: 0 })).toBe( - 'Each invite uses a seat once accepted. You have no open seats.' - ) - }) - - it('adds nothing while the invites fit in open seats', () => { - expect(line(team({ seats: 4 }), { members: 3, pendingInvites: 0, newInvites: 1 })).toBe( - 'Each invite uses a seat once accepted. You have 1 open seat, so no seats will be added.' - ) - }) - - it('prices the added seats from billing, per period, prorated', () => { - const total = formatCents(2 * monthly, 'en-US') - expect(line(team({ seats: 3 }), { members: 3, pendingInvites: 0, newInvites: 2 })).toBe( - `Each invite uses a seat once accepted. You have no open seats, so 2 more seats (${total}/mo, prorated) will be added.` - ) - }) - - it('counts open seats first, then pluralizes the added ones apart', () => { - const one = formatCents(monthly, 'en-US') - expect(line(team({ seats: 4 }), { members: 3, pendingInvites: 0, newInvites: 2 })).toBe( - `Each invite uses a seat once accepted. You have 1 open seat, so 1 more seat (${one}/mo, prorated) will be added.` - ) - }) - - it('names the yearly price on annual plans', () => { - expect( - line(team({ seats: 1, interval: 'year' }), { members: 1, pendingInvites: 0, newInvites: 1 }) - ).toMatch(/\/yr, prorated\) will be added\.$/) - }) - - it('never shows a price billing did not send', () => { - expect( - line(team({ seats: 3, offers: null }), { members: 3, pendingInvites: 0, newInvites: 2 }) - ).toBe( - 'Each invite uses a seat once accepted. You have no open seats, so 2 more seats will be added, prorated.' - ) - }) - - it('keeps to the rule when billing is off or unreachable', () => { - expect(line(null, { members: 3, pendingInvites: 0, newInvites: 2 })).toBe( - 'Each invite uses a seat once accepted.' - ) - }) - - it('says invites are free before the team has a plan', () => { - expect( - line(team({ plan: null, status: 'none' }), { members: 1, pendingInvites: 0, newInvites: 1 }) - ).toBe('Invites are free. Seats are billed once your team picks a plan.') - }) -}) diff --git a/src/renderer/src/components/billing/team/team-invite-impact-copy.ts b/src/renderer/src/components/billing/team/team-invite-impact-copy.ts deleted file mode 100644 index c51d6e50e79..00000000000 --- a/src/renderer/src/components/billing/team/team-invite-impact-copy.ts +++ /dev/null @@ -1,86 +0,0 @@ -// The one line under "Invite teammates" that says what the invites do to seats. Billing's own -// numbers only (ENTITLEMENTS-C): no price is shown when billing didn't send one. -import type { Translate } from '@/i18n/use-translate' -import { formatCents } from '../plans/plans-pricing' -import type { InviteSeatImpact } from './team-seat-math' - -/** "You have N open seats", its own key so the seat count and the added count pluralize apart. */ -function openSeatsClause(open: number, t: Translate): string { - return open === 0 - ? t('auto.components.billing.team.openSeatsNone', 'You have no open seats') - : t('auto.components.billing.team.openSeatsClause', 'You have {{count}} open seats', { - count: open, - defaultValue_one: 'You have {{count}} open seat' - }) -} - -export function inviteSeatImpactLine( - impact: InviteSeatImpact, - invites: number, - locale: string, - t: Translate -): string { - switch (impact.kind) { - case 'unknown': - return t( - 'auto.components.billing.team.inviteSeatUnknown', - 'Each invite uses a seat once accepted.' - ) - case 'no-plan': - return t( - 'auto.components.billing.team.inviteNoPlan', - 'Invites are free. Seats are billed once your team picks a plan.' - ) - case 'covered': - return invites === 0 - ? t( - 'auto.components.billing.team.inviteSeatOpen', - 'Each invite uses a seat once accepted. {{openSeats}}.', - { - openSeats: openSeatsClause(impact.freeSeats, t) - } - ) - : t( - 'auto.components.billing.team.inviteSeatCovered', - 'Each invite uses a seat once accepted. {{openSeats}}, so no seats will be added.', - { openSeats: openSeatsClause(impact.freeSeats, t) } - ) - case 'adds-seats-unpriced': - return t( - 'auto.components.billing.team.inviteSeatAddsUnpriced', - 'Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats will be added, prorated.', - { - openSeats: openSeatsClause(impact.freeSeats, t), - count: impact.addedSeats, - defaultValue_one: - 'Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat will be added, prorated.' - } - ) - case 'adds-seats': { - const price = formatCents(impact.addedSeats * impact.perSeatPeriodCents, locale) - return impact.interval === 'year' - ? t( - 'auto.components.billing.team.inviteSeatAddsYear', - 'Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats ({{price}}/yr, prorated) will be added.', - { - openSeats: openSeatsClause(impact.freeSeats, t), - count: impact.addedSeats, - price, - defaultValue_one: - 'Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat ({{price}}/yr, prorated) will be added.' - } - ) - : t( - 'auto.components.billing.team.inviteSeatAddsMonth', - 'Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats ({{price}}/mo, prorated) will be added.', - { - openSeats: openSeatsClause(impact.freeSeats, t), - count: impact.addedSeats, - price, - defaultValue_one: - 'Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat ({{price}}/mo, prorated) will be added.' - } - ) - } - } -} diff --git a/src/renderer/src/components/billing/team/team-member-mfa.ts b/src/renderer/src/components/billing/team/team-member-mfa.ts new file mode 100644 index 00000000000..c03e19d299a --- /dev/null +++ b/src/renderer/src/components/billing/team/team-member-mfa.ts @@ -0,0 +1,40 @@ +// The MFA column of the team list: who sees it and what each cell says. Pure, so the view and its +// tests share it. +import type { Translate } from '@/i18n/use-translate' +import type { OrcaOrgMember, OrcaOrgRole } from '../../../../../shared/orca-profiles' +import type { TeamRow } from './team-roster-rows' + +/** + * Owners and admins only. The server already leaves the status out for everyone else; the role + * check keeps the column hidden even if a roster carrying it ever reached a member's desktop. + */ +export function showMfaColumn(rows: readonly TeamRow[], viewerRole: OrcaOrgRole): boolean { + return ( + (viewerRole === 'owner' || viewerRole === 'admin') && + rows.some((row) => row.kind === 'member' && row.member.mfa !== undefined) + ) +} + +/** Null when the provider said nothing about this member: shown as blank, never as "off". */ +export function memberMfaLabel( + member: Pick, + locale: string, + t: Translate +): string | null { + switch (member.mfa) { + case 'on': + return t('auto.components.billing.team.mfaOn', 'MFA on') + case 'off': + return t('auto.components.billing.team.mfaOff', 'MFA off') + case 'pending': + return member.mfaDeadline === undefined + ? t('auto.components.billing.team.mfaPending', 'MFA pending') + : t('auto.components.billing.team.mfaPendingBy', 'MFA due {{value0}}', { + value0: new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric' }).format( + member.mfaDeadline + ) + }) + case undefined: + return null + } +} diff --git a/src/renderer/src/components/billing/team/team-seat-math.test.ts b/src/renderer/src/components/billing/team/team-seat-math.test.ts index 94e18f86817..6c6919b83d0 100644 --- a/src/renderer/src/components/billing/team/team-seat-math.test.ts +++ b/src/renderer/src/components/billing/team/team-seat-math.test.ts @@ -1,17 +1,9 @@ import { describe, expect, it } from 'vitest' import type { OrcaBillingEntitlement } from '../../../../../shared/orca-billing' import { sampleTeam as activeTeamEntitlement } from '../plan-billing-fixtures' -import { inviteSeatImpact, nextInvoiceEstimate, proratedSeatCents } from './team-seat-math' +import { nextInvoiceEstimate } from './team-seat-math' const DAY = 86_400_000 -const noPlanEntitlement: OrcaBillingEntitlement = { - ...activeTeamEntitlement, - plan: null, - planLabel: null, - status: 'none', - interval: null, - period: null -} function team(overrides: Partial = {}): OrcaBillingEntitlement { return { @@ -25,73 +17,6 @@ function team(overrides: Partial = {}): OrcaBillingEntit } } -describe('proratedSeatCents', () => { - it('charges the remaining share of the period', () => { - const pricing = { interval: 'month' as const, perSeatPeriodCents: 5000 } - expect(proratedSeatCents(pricing, { start: 0, end: 30 * DAY }, 15 * DAY)).toBe(2500) - expect(proratedSeatCents(pricing, { start: 0, end: 30 * DAY }, 31 * DAY)).toBe(0) - expect(proratedSeatCents(pricing, { start: 0, end: 30 * DAY }, -DAY)).toBe(5000) - }) -}) - -describe('inviteSeatImpact', () => { - it('reports no plan when unsubscribed', () => { - expect( - inviteSeatImpact({ - entitlement: noPlanEntitlement, - members: 1, - pendingInvites: 0, - newInvites: 2, - now: 0 - }) - ).toEqual({ kind: 'no-plan' }) - }) - - it('uses seats already paid this period first (peak seats)', () => { - // 3 paid, 1 member: two free seats, so two invites cost nothing. - expect( - inviteSeatImpact({ - entitlement: team(), - members: 1, - pendingInvites: 0, - newInvites: 2, - now: 0 - }) - ).toEqual({ kind: 'covered', freeSeats: 2 }) - }) - - it('counts pending invites against free seats before new ones', () => { - const impact = inviteSeatImpact({ - entitlement: team(), - members: 2, - pendingInvites: 1, - newInvites: 2, - now: 15 * DAY - }) - expect(impact).toMatchObject({ - kind: 'adds-seats', - addedSeats: 2, - coveredSeats: 0, - proratedCentsEach: 2500 - }) - }) -}) - -describe('inviteSeatImpact without prices', () => { - it('still says seats are added (never "covered") when pricing is unknown', () => { - const unpriced = team({ offers: null }) - expect( - inviteSeatImpact({ - entitlement: unpriced, - members: 3, - pendingInvites: 0, - newInvites: 2, - now: 0 - }) - ).toEqual({ kind: 'adds-seats-unpriced', addedSeats: 2, freeSeats: 0 }) - }) -}) - describe('nextInvoiceEstimate', () => { it('bills the renewal seat count, not the period peak', () => { const estimate = nextInvoiceEstimate( diff --git a/src/renderer/src/components/billing/team/team-seat-math.ts b/src/renderer/src/components/billing/team/team-seat-math.ts index 928ac36cc41..c4465884cd7 100644 --- a/src/renderer/src/components/billing/team/team-seat-math.ts +++ b/src/renderer/src/components/billing/team/team-seat-math.ts @@ -1,7 +1,6 @@ -// Seat money shown on the Team page. Display estimates only: Stripe computes the real invoice. -// Mirrors the billing design: seats billed = the period's peak membership; a member who joins -// above the paid peak is charged now, prorated; removals only lower seats at renewal; pending -// invites cost nothing until accepted. +// Seat money shown in Plan & billing. Display estimates only: Stripe computes the real invoice. +// Mirrors the billing design: seats billed = the period's peak membership; removals only lower +// seats at renewal. import type { OrcaBillingEntitlement, OrcaBillingInterval @@ -33,80 +32,6 @@ export function isActivePlan(entitlement: OrcaBillingEntitlement | null): boolea ) } -/** One seat for the rest of the current period, rounded to the cent like Stripe's proration. */ -export function proratedSeatCents( - pricing: SeatPricing, - period: { start: number; end: number }, - now: number -): number { - const length = period.end - period.start - if (length <= 0) { - return pricing.perSeatPeriodCents - } - const remaining = Math.min(Math.max(period.end - now, 0), length) - return Math.round((pricing.perSeatPeriodCents * remaining) / length) -} - -export type InviteSeatImpact = - /** Billing didn't answer (disabled in this build, signed out or failed): no seat facts to show. */ - | { kind: 'unknown' } - | { kind: 'no-plan' } - /** Every new invite fits in seats already paid this period. */ - | { kind: 'covered'; freeSeats: number } - /** Seats will be added but the price or period isn't known (older billing service). */ - | { kind: 'adds-seats-unpriced'; addedSeats: number; freeSeats: number } - | { - kind: 'adds-seats' - /** Paid seats not taken by a member or a pending invite. */ - freeSeats: number - /** How many of the new invites would add a seat when accepted. */ - addedSeats: number - /** Seats already paid but unused that the first acceptances take for free. */ - coveredSeats: number - proratedCentsEach: number - perSeatPeriodCents: number - interval: OrcaBillingInterval - } - -/** - * What inviting `newInvites` people costs if they all accept now. Pending invites are assumed to - * accept first (conservative: never promises a free seat that an earlier invite will take). - */ -export function inviteSeatImpact(args: { - entitlement: OrcaBillingEntitlement | null - members: number - pendingInvites: number - newInvites: number - now: number -}): InviteSeatImpact { - const { entitlement } = args - if (!entitlement) { - return { kind: 'unknown' } - } - if (!isActivePlan(entitlement)) { - return { kind: 'no-plan' } - } - const pricing = seatPricing(entitlement) - const paidSeats = Math.max(entitlement.seats, 1) - const freeSeats = Math.max(0, paidSeats - args.members - args.pendingInvites) - const addedSeats = Math.max(0, args.newInvites - freeSeats) - if (addedSeats === 0) { - return { kind: 'covered', freeSeats } - } - if (!pricing || !entitlement.period) { - return { kind: 'adds-seats-unpriced', addedSeats, freeSeats } - } - return { - kind: 'adds-seats', - freeSeats, - addedSeats, - coveredSeats: Math.min(freeSeats, args.newInvites), - proratedCentsEach: proratedSeatCents(pricing, entitlement.period, args.now), - perSeatPeriodCents: pricing.perSeatPeriodCents, - interval: pricing.interval - } -} - export type NextInvoiceEstimate = { seats: number amountCents: number diff --git a/src/renderer/src/components/billing/team/use-team-delete.tsx b/src/renderer/src/components/billing/team/use-team-delete.tsx index 9f7b8da0bb1..77b86d2ec32 100644 --- a/src/renderer/src/components/billing/team/use-team-delete.tsx +++ b/src/renderer/src/components/billing/team/use-team-delete.tsx @@ -9,7 +9,7 @@ import type { OrcaProfileOrgDeleteCheckResult, OrcaProfileOrgDeleteResult } from '../../../../../shared/orca-org-delete' -import { openCloudWorkspacesHome } from '../../cloud-workspaces/cloud-workspaces-home-nav' +import { openCloudWorkspacePicker } from '../../cloud-workspaces/cloud-workspace-picker-store' import { TeamDeleteDialogView } from './TeamDeleteDialog' import type { TeamDeleteCheck } from './TeamDeleteSection' import { teamDeleteFailure, type TeamDeleteDialogError } from './team-delete-copy' @@ -36,13 +36,9 @@ export function teamDeleteCheckFrom( } } -/** Home with every team workspace, and the Archived switch on when any are archived. */ -async function showTeamWorkspaces(): Promise { - const archived = await window.api.cloudWorkspaces - .list({ scope: 'team', archived: 'only' }) - .then((result) => result.ok && result.value.length > 0) - .catch(() => false) - openCloudWorkspacesHome({ scope: 'team', archived }) +/** Every team workspace, archived ones too, so each can be deleted before the team. */ +function showTeamWorkspaces(): void { + openCloudWorkspacePicker({ kind: 'team-cleanup' }) } export function useTeamDelete({ @@ -130,7 +126,7 @@ export function useTeamDelete({ if (code === 'subscription_active') { onManageBilling() } else { - void showTeamWorkspaces() + showTeamWorkspaces() } } diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspaceGroup.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceGroup.tsx deleted file mode 100644 index a4e73f38fe8..00000000000 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspaceGroup.tsx +++ /dev/null @@ -1,180 +0,0 @@ -// One group on Home: Pinned, a section, or "No section". Named sections collapse; every group's -// rows reorder by dragging (or Move up / Move down in the row menu), and the order syncs to your -// other devices through your prefs. -import React, { useState } from 'react' -import { ChevronRight, MoreHorizontal, Pin } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { useTranslate } from '@/i18n/use-translate' -import { cn } from '@/lib/class-names' -import type { CloudWorkspaceGroup } from '../../../../shared/cloud-workspaces/cloud-workspace-arrangement' -import type { CloudWorkspace } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' - -type Group = CloudWorkspaceGroup - -export type GroupDrag = { - /** The row being dragged and the group it came from (rows only drop within their own group). */ - dragging: { id: string; group: string } | null - start: (id: string, group: string) => void - drop: (overId: string, group: string) => void - end: () => void -} - -/** Drag props for one row's
  • ; inert while reduced to a single row. */ -export function rowDragProps( - drag: GroupDrag, - group: Group, - id: string -): React.LiHTMLAttributes & { 'data-drop-target'?: string } { - if (group.rows.length < 2) { - return {} - } - return { - draggable: true, - onDragStart: (event) => { - event.dataTransfer.effectAllowed = 'move' - // Some platforms need data set for the drag to start at all. - event.dataTransfer.setData('text/plain', id) - drag.start(id, group.key) - }, - onDragOver: (event) => { - if (drag.dragging?.group === group.key) { - event.preventDefault() - event.dataTransfer.dropEffect = 'move' - } - }, - onDrop: (event) => { - if (drag.dragging?.group === group.key) { - event.preventDefault() - drag.drop(id, group.key) - } - }, - onDragEnd: () => drag.end() - } -} - -export function CloudWorkspaceGroupView({ - group, - index, - showHeading, - canMoveUp, - canMoveDown, - onToggleCollapsed, - onMoveSection, - children -}: { - group: Group - index: number - showHeading: boolean - canMoveUp: boolean - canMoveDown: boolean - onToggleCollapsed: (collapsed: boolean) => void - onMoveSection: (delta: number) => void - children: React.ReactNode -}): React.JSX.Element { - const t = useTranslate() - const [menuOpen, setMenuOpen] = useState(false) - // Section names are free text (spaces), so the id uses the position instead. - const headingId = `cloud-workspaces-section-${index}` - const listId = `cloud-workspaces-section-list-${index}` - const label = - group.kind === 'pinned' - ? t('auto.components.cloudWorkspaces.pinned', 'Pinned') - : (group.section ?? t('auto.components.cloudWorkspaces.otherWorkspaces', 'Other workspaces')) - const count = ( - <> - {/* The bare number is visual; screen readers get the counted phrase. */} - - {group.rows.length} - {' '} - - {t('auto.components.cloudWorkspaces.sectionCount', '{{count}} workspaces', { - count: group.rows.length, - defaultValue_one: '{{count}} workspace' - })} - - - ) - const collapsible = group.kind === 'section' - return ( -
    - {showHeading && ( -
    -

    - {collapsible ? ( - - ) : ( - - {group.kind === 'pinned' && } - {label} - {count} - - )} -

    - {collapsible && (canMoveUp || canMoveDown) && ( - - - - - - - onMoveSection(-1)}> - {t('auto.components.cloudWorkspaces.moveSectionUp', 'Move section up')} - - onMoveSection(1)}> - {t('auto.components.cloudWorkspaces.moveSectionDown', 'Move section down')} - - - - - )} -
    - )} - {!group.collapsed && ( -
      - {children} -
    - )} -
    - ) -} diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspacePicker.fixture.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspacePicker.fixture.tsx new file mode 100644 index 00000000000..e79af11b3da --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspacePicker.fixture.tsx @@ -0,0 +1,115 @@ +// "Open cloud workspace…" (replaces the Home list): a teammate's shared workspace, one started on +// another computer, the empty and error states, and the focused lists other surfaces open it with. +import { defineFixture } from '../../component-fixtures/component-fixture' +import { CloudWorkspacePickerDialog } from './CloudWorkspacePicker' +import { + closeCloudWorkspacePicker, + openCloudWorkspacePicker, + type CloudWorkspacePickerFocus +} from './cloud-workspace-picker-store' +import { + AUTH_STATUS, + FIXTURE_NOW, + presence, + stubCloudWorkspacesApi, + workspace +} from './cloud-workspaces.test-fixture' +import type { CloudWorkspace } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' + +const WIDTH = 820 + +async function waitFor(check: () => boolean): Promise { + const deadline = performance.now() + 3000 + while (!check() && performance.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 50)) + } +} + +function rows(): CloudWorkspace[] { + return [ + workspace({ + title: 'Refund webhooks', + repo: { url: 'https://github.com/acme/payments.git', branch: 'refund-webhooks' }, + ownerUserId: 'user_grace', + createdBy: 'user_grace', + access: 'can_open', + presence: [presence('user_grace', 'typing')] + }), + workspace({ + title: 'Ledger migration', + ownerUserId: 'user_linus', + createdBy: 'user_linus', + access: 'can_open', + status: 'suspended', + lastActivityAt: FIXTURE_NOW - 3 * 3_600_000 + }), + workspace({ + // Yours, started on your laptop: this computer has no row for it yet. + title: 'Docs search', + repo: { url: 'https://github.com/acme/docs.git', branch: 'search' }, + status: 'suspended', + lastActivityAt: FIXTURE_NOW - 26 * 3_600_000 + }) + ] +} + +function picker( + focus: CloudWorkspacePickerFocus, + list: () => ReturnType[0]>, + ready: string +) { + return defineFixture({ + width: WIDTH, + store: { orcaProfileAuthStatus: AUTH_STATUS }, + setup: () => { + stubCloudWorkspacesApi(list) + closeCloudWorkspacePicker() + openCloudWorkspacePicker(focus) + }, + render: () => ( +
    + +
    + ), + interact: () => waitFor(() => document.querySelector(ready) !== null) + }) +} + +/** From the Add project menu or the palette: a teammate's shared one, and one from another computer. */ +export const sharedByTeammate = picker( + { kind: 'open' }, + () => Promise.resolve({ ok: true, value: rows() }), + '[data-cloud-workspace-row]' +) + +/** Nothing to open yet: start one. */ +export const empty = picker( + { kind: 'open' }, + () => Promise.resolve({ ok: true, value: [] }), + '[data-cloud-workspace-picker] p' +) + +/** The list could not be read: InlineError with Try again. */ +export const error = picker( + { kind: 'open' }, + () => Promise.resolve({ ok: false, error: 'unavailable' }), + '[data-slot="inline-error"]' +) + +/** A teammate's profile, "Show all": their workspaces. */ +export const teammate = picker( + { kind: 'teammate', userId: 'user_grace' }, + () => Promise.resolve({ ok: true, value: rows() }), + '[data-cloud-workspace-row]' +) + +/** At the running-sandbox limit: your running ones, to put one to sleep. */ +export const runningLimit = picker( + { kind: 'running' }, + () => + Promise.resolve({ + ok: true, + value: [workspace({ title: 'Checkout retries' }), workspace({ title: 'Flaky e2e repro' })] + }), + '[data-cloud-workspace-row]' +) diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspacePicker.test.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspacePicker.test.tsx new file mode 100644 index 00000000000..039c509cca8 --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspacePicker.test.tsx @@ -0,0 +1,161 @@ +// @vitest-environment happy-dom +// "Open cloud workspace…": lists what the sidebar doesn't show, opens through openCloudWorkspace, +// and is what every former Home pointer opens now. +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { CloudWorkspace } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' +import { workspace } from './cloud-workspaces.test-fixture' +import type * as OpenCloudWorkspaceNs from './open-cloud-workspace' + +type OpenCloudWorkspaceModule = typeof OpenCloudWorkspaceNs + +const mocks = vi.hoisted(() => { + const state = { + orcaProfileAuthStatus: { + activeProfileId: 'profile-1', + configured: true, + state: 'connected', + persistence: 'encrypted', + cloud: { userId: 'user_ada', activeOrgId: 'org_acme', activeOrgName: 'Acme' } + }, + connectCurrentOrcaProfile: async () => {}, + keybindings: {}, + ephemeralVmRuntimesByWorkspaceId: {} + } + return { + state, + openCloudWorkspace: vi.fn(), + inSidebar: new Set() + } +}) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (s: typeof mocks.state) => unknown) => selector(mocks.state), + { getState: () => mocks.state, subscribe: () => () => {} } + ) +})) +vi.mock('../billing/team/use-org-roster', async () => { + const { ROSTER } = await import('./cloud-workspaces.test-fixture') + const result = { status: 'ok' as const, roster: ROSTER } + return { useOrgRoster: () => ({ result }) } +}) +vi.mock('../new-workspace/run-on/OrcaCloudTrialUsedUpNotice', () => ({ + OrcaCloudTrialUsedUpBanner: () => null +})) +vi.mock('./cloud-workspaces-in-sidebar', () => ({ + useCloudWorkspaceIdsInSidebar: () => mocks.inSidebar +})) +vi.mock('./open-cloud-workspace', async (importOriginal) => ({ + ...(await importOriginal()), + openCloudWorkspace: mocks.openCloudWorkspace +})) + +import { CloudWorkspacePickerDialog } from './CloudWorkspacePicker' +import { + closeCloudWorkspacePicker, + getCloudWorkspacePickerRequest, + openCloudWorkspacePicker +} from './cloud-workspace-picker-store' +import { CloudNotPairedCard } from '../orca-cloud/CloudNotPairedCard' + +type ListResult = { ok: true; value: CloudWorkspace[] } | { ok: false; error: string } +let listResult: ListResult + +const MINE = workspace({ title: 'Already in my sidebar' }) +const SHARED = workspace({ + title: 'Refund webhooks', + ownerUserId: 'user_grace', + createdBy: 'user_grace', + access: 'can_open' +}) + +beforeEach(() => { + mocks.inSidebar = new Set([MINE.id]) + mocks.openCloudWorkspace.mockReset() + listResult = { ok: true, value: [MINE, SHARED] } + vi.stubGlobal('api', { + cloudWorkspaces: { + available: () => Promise.resolve(true), + list: vi.fn(() => Promise.resolve(listResult)) + } + }) +}) + +afterEach(() => { + act(() => closeCloudWorkspacePicker()) + cleanup() + vi.unstubAllGlobals() +}) + +function renderPicker(): void { + render( + + + + ) +} + +describe('Open cloud workspace…', () => { + it('lists a teammate’s shared workspace and leaves out what the sidebar already shows', async () => { + renderPicker() + act(() => openCloudWorkspacePicker()) + expect(await screen.findByText('Refund webhooks')).toBeTruthy() + expect(screen.queryByText('Already in my sidebar')).toBeNull() + expect(screen.getByText('1 more is already in your sidebar.')).toBeTruthy() + }) + + it('opens through openCloudWorkspace, then closes', async () => { + mocks.openCloudWorkspace.mockResolvedValue({ ok: true, worktreeId: 'wt-1' }) + renderPicker() + act(() => openCloudWorkspacePicker()) + fireEvent.click(await screen.findByRole('button', { name: /^Refund webhooks, Running/ })) + await waitFor(() => expect(getCloudWorkspacePickerRequest()).toBeNull()) + expect(mocks.openCloudWorkspace).toHaveBeenCalledWith(SHARED) + }) + + it('a failed open stays open (the error is a toast), so another can be picked', async () => { + mocks.openCloudWorkspace.mockResolvedValue({ ok: false, message: 'No access' }) + renderPicker() + act(() => openCloudWorkspacePicker()) + fireEvent.click(await screen.findByRole('button', { name: /^Refund webhooks, Running/ })) + await waitFor(() => expect(mocks.openCloudWorkspace).toHaveBeenCalled()) + expect(getCloudWorkspacePickerRequest()).not.toBeNull() + }) + + it('with everything in the sidebar, says so instead of inviting a first workspace', async () => { + listResult = { ok: true, value: [MINE] } + renderPicker() + act(() => openCloudWorkspacePicker()) + expect(await screen.findByText('Your cloud workspaces are already in the sidebar')).toBeTruthy() + expect(screen.queryByRole('button', { name: 'New cloud workspace' })).toBeNull() + }) + + it('a list that could not be read offers Try again', async () => { + listResult = { ok: false, error: 'unavailable' } + renderPicker() + act(() => openCloudWorkspacePicker()) + expect(await screen.findByRole('button', { name: 'Try again' })).toBeTruthy() + }) + + it('a teammate focus lists their workspaces, sidebar ones included', async () => { + mocks.inSidebar = new Set([SHARED.id]) + renderPicker() + act(() => openCloudWorkspacePicker({ kind: 'teammate', userId: 'user_grace' })) + expect(await screen.findByText('Refund webhooks')).toBeTruthy() + expect(screen.getByText('Grace Hopper’s cloud workspaces')).toBeTruthy() + expect(screen.queryByText('Already in my sidebar')).toBeNull() + }) +}) + +describe('pointers that used to send people to Home', () => { + it('a workspace whose pairing is gone offers Open cloud workspace…, not a text pointer', () => { + const onDismiss = vi.fn() + render() + expect(screen.getByText('Refund webhooks isn’t linked to this computer')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Open cloud workspace…' })) + expect(getCloudWorkspacePickerRequest()?.focus).toEqual({ kind: 'open' }) + expect(onDismiss).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspacePicker.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspacePicker.tsx new file mode 100644 index 00000000000..cfba49b2cef --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspacePicker.tsx @@ -0,0 +1,358 @@ +// "Open cloud workspace…": cloud workspaces this computer's sidebar doesn't show, as the same rows +// (and ⋯ menu) the team list had. Open goes through openCloudWorkspace and closes the picker. +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { RefreshCw, Search } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Switch } from '@/components/ui/switch' +import { SettingsSegmentedControl } from '@/components/settings/form-controls/SettingsFormControls' +import { useTranslate, type Translate } from '@/i18n/use-translate' +import { cn } from '@/lib/class-names' +import { installWindowVisibilityInterval } from '@/lib/window-activity/window-visibility-interval' +import { openNewCloudWorkspace } from '@/hooks/ipc-events/new-workspace-command' +import { useOrcaAccountKey } from '@/hooks/use-orca-account-key' +import { useAppStore } from '@/store' +import { OrcaCloudTrialUsedUpBanner } from '../new-workspace/run-on/OrcaCloudTrialUsedUpNotice' +import { CloudWorkspaceRow } from './CloudWorkspaceRow' +import { + ErrorState, + PickerEmptyState, + SkeletonRows, + type PickerEmptyReason +} from './CloudWorkspacesStates' +import { useListedCloudWorkspaces } from './cloud-workspace-local-titles' +import { useCloudWorkspaceIdsInSidebar } from './cloud-workspaces-in-sidebar' +import { + initialPickerControls, + pickerHidesSidebarWorkspaces, + pickerListQuery, + pickerRows, + pickerShowsArchived, + pickerShowsScope, + type CloudWorkspacePickerControls +} from './cloud-workspace-picker-model' +import { + closeCloudWorkspacePicker, + useCloudWorkspacePickerRequest, + type CloudWorkspacePickerFocus +} from './cloud-workspace-picker-store' +import { useCloudWorkspaceRowActions, useMemberDirectory } from './use-cloud-workspace-row-actions' +import { useCloudWorkspaces } from './use-cloud-workspaces' + +function pickerCopy( + focus: CloudWorkspacePickerFocus, + teammateName: string | null, + t: Translate +): { title: string; description: string } { + switch (focus.kind) { + case 'open': + return { + title: t('components.cloudWorkspaces.picker.title', 'Open cloud workspace'), + description: t( + 'components.cloudWorkspaces.picker.description', + 'Cloud workspaces that aren’t in your sidebar: shared by teammates, or started on another computer.' + ) + } + case 'teammate': + return { + title: teammateName + ? t('components.cloudWorkspaces.picker.teammateTitle', '{{name}}’s cloud workspaces', { + name: teammateName + }) + : t('components.cloudWorkspaces.picker.title', 'Open cloud workspace'), + description: t( + 'components.cloudWorkspaces.picker.teammateDescription', + 'The ones they own or started that you can see.' + ) + } + case 'running': + return { + title: t('components.cloudWorkspaces.picker.runningTitle', 'Your running cloud workspaces'), + description: t( + 'components.cloudWorkspaces.picker.runningDescription', + 'Put one to sleep or delete it to free a running slot, then try again.' + ) + } + case 'team-cleanup': + return { + title: t('components.cloudWorkspaces.picker.teamTitle', 'Your team’s cloud workspaces'), + description: t( + 'components.cloudWorkspaces.picker.teamDescription', + 'Delete these, archived ones too, before deleting the team.' + ) + } + } +} + +function emptyReason({ + focus, + controls, + hiddenInSidebar, + listedBeforeSearch, + teammateName +}: { + focus: CloudWorkspacePickerFocus + controls: CloudWorkspacePickerControls + hiddenInSidebar: number + listedBeforeSearch: boolean + teammateName: string | null +}): PickerEmptyReason { + if (controls.search.trim() && listedBeforeSearch) { + return { kind: 'search' } + } + if (controls.archived && pickerShowsArchived(focus)) { + return { kind: 'archived' } + } + if (focus.kind === 'running') { + return { kind: 'none-running' } + } + if (focus.kind === 'teammate') { + return { kind: 'teammate', name: teammateName ?? '' } + } + return hiddenInSidebar > 0 ? { kind: 'all-in-sidebar' } : { kind: 'none' } +} + +/** The picker's body; the dialog shell is CloudWorkspacePickerDialog. */ +export function CloudWorkspacePicker({ + focus, + onClose +}: { + focus: CloudWorkspacePickerFocus + onClose: () => void +}): React.JSX.Element { + const t = useTranslate() + const cloud = useAppStore((s) => s.orcaProfileAuthStatus?.cloud) + const signIn = useAppStore((s) => s.connectCurrentOrcaProfile) + const account = useOrcaAccountKey() + const orgId = cloud?.activeOrgId + const hasOrg = Boolean(orgId) + const selfId = cloud?.userId ?? null + const [controls, setControls] = useState(() => initialPickerControls(hasOrg)) + const [now, setNow] = useState(() => Date.now()) + const change = (next: Partial): void => + setControls((previous) => ({ ...previous, ...next })) + const query = pickerListQuery(focus, controls, hasOrg) + const { state, stale, reading, refresh, replace } = useCloudWorkspaces( + query, + Boolean(cloud), + account + ) + const inSidebar = useCloudWorkspaceIdsInSidebar(pickerHidesSidebarWorkspaces(focus)) + + // Relative "last active" times tick while the window is visible. + useEffect( + () => installWindowVisibilityInterval({ run: () => setNow(Date.now()), intervalMs: 30_000 }), + [] + ) + + const all = useListedCloudWorkspaces(state.status === 'ready' ? state.workspaces : null) + const { directory, canAdmin } = useMemberDirectory(orgId, all) + const { rows, hiddenInSidebar } = useMemo( + () => + pickerRows({ + workspaces: all, + focus, + inSidebar: inSidebar ?? new Set(), + search: controls.search, + directory + }), + [all, focus, inSidebar, controls.search, directory] + ) + const listedBeforeSearch = useMemo( + () => + pickerRows({ + workspaces: all, + focus, + inSidebar: inSidebar ?? new Set(), + search: '', + directory + }).rows.length > 0, + [all, focus, inSidebar, directory] + ) + const teammateName = + focus.kind === 'teammate' ? (directory.get(focus.userId)?.name ?? null) : null + const members = useMemo( + () => + [...directory.entries()].map(([userId, m]) => ({ + userId, + name: + userId === selfId + ? t('auto.components.cloudWorkspaces.memberYou', '{{value0}} (you)', { value0: m.name }) + : m.name + })), + [directory, selfId, t] + ) + const { actions, openingId, dialogs } = useCloudWorkspaceRowActions({ + workspaces: all, + directory, + selfId, + canAdmin, + refresh, + replace, + onOpened: onClose + }) + + const copy = pickerCopy(focus, teammateName, t) + const loading = state.status === 'loading' || (state.status === 'ready' && inSidebar === null) + + return ( +
    + + {copy.title} + {copy.description} + +
    +
    + + change({ search: event.target.value })} + /> +
    + {pickerShowsScope(focus, hasOrg) ? ( + change({ scope })} + options={[ + { value: 'team', label: t('auto.components.cloudWorkspaces.scopeTeam', 'Team') }, + { value: 'mine', label: t('auto.components.cloudWorkspaces.scopeMine', 'Mine') } + ]} + /> + ) : null} + {pickerShowsArchived(focus) ? ( + + ) : null} + +
    + {/* T4: the team's trial ran out; running sandboxes sleep until a plan starts. */} + +
    + {!cloud ? ( + // Signed out since it opened (or opened signed out): the list needs the account. + { + void signIn().then(refresh) + }} + /> + ) : state.status === 'error' ? ( + { + void signIn().then(refresh) + }} + /> + ) : loading ? ( + + ) : rows.length === 0 ? ( + { + onClose() + openNewCloudWorkspace(useAppStore.getState(), 'unknown') + }} + /> + ) : ( +
      + {rows.map((w) => ( + + ))} +
    + )} +
    + {hiddenInSidebar > 0 && rows.length > 0 ? ( +

    + {t( + 'components.cloudWorkspaces.picker.inSidebarNote', + '{{count}} more are already in your sidebar.', + { + count: hiddenInSidebar, + defaultValue_one: '{{count}} more is already in your sidebar.' + } + )} +

    + ) : null} + {dialogs} +
    + ) +} + +/** Mounted once in App; any surface opens it with openCloudWorkspacePicker(). */ +export function CloudWorkspacePickerDialog(): React.JSX.Element { + const request = useCloudWorkspacePickerRequest() + const close = useCallback(() => closeCloudWorkspacePicker(), []) + const open = request !== null + return ( + !next && close()}> + {open ? ( + + {/* Remount per request so a new focus starts with its own controls. */} + + + ) : null} + + ) +} diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.fixture.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.fixture.tsx index c2877722ee9..3c3d39ec0ec 100644 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.fixture.tsx +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.fixture.tsx @@ -24,13 +24,11 @@ const ACTIONS: RowActions = { sleep: () => {}, remove: () => {} } -const SECTIONS = ['Payments', 'Platform'] const MEMBERS = [...DIRECTORY.entries()].map(([userId, m]) => ({ userId, name: m.name })) -// Shared with the team, a section, and two teammates in it: every menu entry is present. +// Shared with the team and two teammates in it: every menu entry is present. const MENU_ROW = workspace({ title: 'Checkout retries', - section: 'Payments', presence: [presence('user_grace', 'typing'), presence('user_linus')] }) @@ -50,10 +48,8 @@ function Row({ directory={DIRECTORY} selfId={SELF_ID} canAdmin - sections={SECTIONS} members={MEMBERS} opening={opening} - highlighted={false} now={FIXTURE_NOW} actions={actions} /> @@ -149,16 +145,6 @@ export const menuChatFocused = defineFixture({ } }) -export const moveToSection = defineFixture({ - width: WIDTH, - render: () => ( -
    - -
    - ), - interact: (frame) => openSubmenu(frame, 'Move to section') -}) - export const reassignOwner = defineFixture({ width: WIDTH, render: () => ( diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.tsx index 910b0a3e9f8..59535571a87 100644 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.tsx +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.tsx @@ -1,5 +1,5 @@ import { CloudStatusGlyph } from '@/components/orca-cloud/CloudStatusGlyph' -import React, { useEffect, useId, useRef, useState } from 'react' +import React, { useId, useState } from 'react' import { GitBranch, Loader2 } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -11,12 +11,7 @@ import { keybindingMatchesAction } from '../../../../shared/keybindings/matching import type { CloudWorkspace } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' import type { CloudDotState } from '@/lib/orca-cloud/orca-cloud-workspace-state' import { MemberAvatar, PresenceStack } from './CloudMemberAvatars' -import { - CloudWorkspaceRowMenu, - isReadableAsleep, - type RowActions, - type RowArrangement -} from './CloudWorkspaceRowMenu' +import { CloudWorkspaceRowMenu, isReadableAsleep, type RowActions } from './CloudWorkspaceRowMenu' import { formatSandboxHours, formatRelative, @@ -72,7 +67,7 @@ export function statusLabel(w: CloudWorkspace, t: Translate): string { return t('auto.components.cloudWorkspaces.statusCouldntStart', "Couldn't start") case 'destroying': case 'destroyed': - // Home never lists these (see CloudWorkspacesHome); a stray one is not usable. + // The picker never lists these (useListedCloudWorkspaces); a stray one is not usable. return t('auto.components.cloudWorkspaces.statusNotResponding', 'Not responding') } } @@ -82,31 +77,19 @@ export function CloudWorkspaceRow({ directory, selfId, canAdmin, - sections, members, opening, - highlighted, now, - actions, - arrangement, - dragProps, - dragging = false + actions }: { workspace: CloudWorkspace directory: MemberDirectory selfId: string | null canAdmin: boolean - sections: string[] members: { userId: string; name: string }[] opening: boolean - highlighted: boolean now: number actions: RowActions - /** Pinned state and whether Move up / Move down apply (synced prefs). */ - arrangement?: RowArrangement - /** Drag-to-reorder within its group (from CloudWorkspaceGroup's rowDragProps). */ - dragProps?: React.LiHTMLAttributes - dragging?: boolean }): React.JSX.Element { const t = useTranslate() const intlLocale = useIntlLocale() @@ -118,13 +101,6 @@ export function CloudWorkspaceRow({ const canManage = w.access === 'owner' || canAdmin const title = cloudWorkspaceTitle(w, t) const ownerName = memberName(directory, w.ownerUserId, selfId, t) - const rowRef = useRef(null) - // A link brought this row up: make sure it is on screen (the tint fades on its own). - useEffect(() => { - if (highlighted) { - rowRef.current?.scrollIntoView({ block: 'nearest' }) - } - }, [highlighted]) const canOpen = w.access !== 'none' // Asleep: Enter / click reads the saved copy; waking is its own explicit action. const readable = isReadableAsleep(w) @@ -167,9 +143,7 @@ export function CloudWorkspaceRow({ return (
  • { // Menu items render in a portal but still bubble through React; only this row's own DOM counts. if (!(event.target instanceof Node) || !event.currentTarget.contains(event.target)) { @@ -189,18 +163,9 @@ export function CloudWorkspaceRow({ }} className={cn( 'group relative isolate flex items-center gap-3 rounded-lg pr-2 transition-colors hover:bg-accent', - menuOpen && 'bg-accent', - dragging && 'opacity-50' + menuOpen && 'bg-accent' )} > - {/* Opened from a link: an accent tint that fades out (instantly under reduced motion). */} - - - - - - - ) -} - export function SkeletonRows(): React.JSX.Element { const t = useTranslate() return ( @@ -140,89 +89,94 @@ export function SkeletonRows(): React.JSX.Element { ) } -export function EmptyState({ - scope, - archived, - filtered, - hasOrg = true, - archivedCount = 0, - onShowArchived +/** Why the list is empty, in the words of what the picker was opened for. */ +export type PickerEmptyReason = + | { kind: 'search' } + | { kind: 'archived' } + /** Everything you can open is in the sidebar already. */ + | { kind: 'all-in-sidebar' } + | { kind: 'none-running' } + | { kind: 'teammate'; name: string } + | { kind: 'none' } + +export function PickerEmptyState({ + reason, + onCreate }: { - scope: 'team' | 'mine' - archived: boolean - filtered: boolean - /** On a team: an empty Mine list says it's yours that's empty, not that nobody has started one. */ - hasOrg?: boolean - /** Archived workspaces in this scope: an empty list then says so and offers them (ARCHIVE-REACH). */ - archivedCount?: number - onShowArchived?: () => void + reason: PickerEmptyReason + /** Offered only when there are none at all: start one. */ + onCreate?: () => void }): React.JSX.Element { const t = useTranslate() - // Nothing to filter or share yet: say how to start one (JA-30), in the glossary's own words. - const startBody = t( - 'components.cloudWorkspaces.emptyStartBody', - 'It runs in a sandbox, so your agents keep working even when this computer is off.' - ) - const [title, body] = filtered - ? [ - t('auto.components.cloudWorkspaces.emptyFilteredTitle', 'Nothing matches these filters'), - t('auto.components.cloudWorkspaces.emptyFilteredBody', 'Try another owner or repository.') - ] - : archived - ? [ + const [title, body] = ((): [string, string] => { + switch (reason.kind) { + case 'search': + return [ + t('components.cloudWorkspaces.picker.emptySearchTitle', 'Nothing matches your search'), + t( + 'components.cloudWorkspaces.picker.emptySearchBody', + 'Try a title, repository, branch, owner or W-number.' + ) + ] + case 'archived': + return [ t('auto.components.cloudWorkspaces.emptyArchivedTitle', 'No archived workspaces'), t( 'auto.components.cloudWorkspaces.emptyArchivedBody', 'Archived cloud workspaces show up here.' ) ] - : archivedCount > 0 - ? [ - t( - 'components.cloudWorkspaces.emptyAllArchivedTitle', - 'All your cloud workspaces are archived' - ), - t( - 'components.cloudWorkspaces.emptyAllArchivedBody', - 'Unarchive one to keep working, or start a new one.' - ) - ] - : scope === 'mine' && hasOrg - ? [ - t( - 'components.cloudWorkspaces.emptyMineYoursTitle', - 'You don’t have any cloud workspaces yet.' - ), - startBody - ] - : [ - t('components.cloudWorkspaces.emptyFirstTitle', 'Start your first cloud workspace'), - startBody - ] - // Spec E3: an empty Mine or Team list offers a real button, not just copy. - const offerCreate = !filtered && !archived + case 'all-in-sidebar': + return [ + t( + 'components.cloudWorkspaces.picker.emptyAllInSidebarTitle', + 'Your cloud workspaces are already in the sidebar' + ), + t( + 'components.cloudWorkspaces.picker.emptyAllInSidebarBody', + 'Workspaces teammates share, and ones you start on another computer, show up here.' + ) + ] + case 'none-running': + return [ + t('components.cloudWorkspaces.picker.emptyRunningTitle', 'None of yours are running'), + t( + 'components.cloudWorkspaces.picker.emptyRunningBody', + 'Teammates’ running workspaces can count toward the limit too. Try again in a moment.' + ) + ] + case 'teammate': + return [ + t( + 'components.cloudWorkspaces.picker.emptyTeammateTitle', + '{{name}} has no cloud workspaces you can see', + { name: reason.name } + ), + t( + 'components.cloudWorkspaces.picker.emptyTeammateBody', + 'Private workspaces only show to their owner.' + ) + ] + case 'none': + return [ + t('components.cloudWorkspaces.emptyFirstTitle', 'Start your first cloud workspace'), + t( + 'components.cloudWorkspaces.emptyStartBody', + 'It runs in a sandbox, so your agents keep working even when this computer is off.' + ) + ] + } + })() return (

    {title}

    {body}

    - {offerCreate ? ( - ) : null} - {offerCreate && archivedCount > 0 && onShowArchived ? ( - - ) : null}
    ) } diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesToolbar.routine-runs.test.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesToolbar.routine-runs.test.tsx deleted file mode 100644 index d9302301b76..00000000000 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesToolbar.routine-runs.test.tsx +++ /dev/null @@ -1,128 +0,0 @@ -// @vitest-environment happy-dom -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { TooltipProvider } from '@/components/ui/tooltip' -import { - CloudWorkspacesToolbar, - INITIAL_CLOUD_WORKSPACE_FILTERS, - type CloudWorkspacesFilters -} from './CloudWorkspacesToolbar' -import { readHomeSwitches, writeHomeSwitches } from './cloud-workspaces-home-prefs' -import type { CloudWorkspaceRoutineRuns } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' - -// The toolbar's trial banner and Routines entry read the preload bridge; neither is under test. -vi.mock('../new-workspace/run-on/OrcaCloudTrialUsedUpNotice', () => ({ - OrcaCloudTrialUsedUpBanner: () => null -})) -vi.mock('@/hooks/useOrcaCloudApiAvailability', () => ({ useOrcaCloudApiAvailable: () => true })) -// The switch is behind the parked-Routines flag; flip it per test to cover both states. -const flag = vi.hoisted(() => ({ on: true })) -vi.mock('../../../../shared/cloud-api/cloud-routines-ui', () => ({ - get CLOUD_ROUTINES_UI_ENABLED() { - return flag.on - } -})) - -afterEach(cleanup) - -function toolbar( - routineRuns: CloudWorkspaceRoutineRuns | undefined, - filters: Partial = {}, - onFiltersChange = vi.fn() -) { - render( - - - - ) - return onFiltersChange -} - -const runs = (over: Partial = {}): CloudWorkspaceRoutineRuns => ({ - routines: 1, - hidden: 0, - hiddenRunning: 0, - hiddenComputeMs: 0, - ...over -}) - -describe('Home toolbar while Routines are parked', () => { - it('offers neither the Routines entry nor the Routine runs switch', () => { - flag.on = false - try { - toolbar(runs({ hidden: 2, hiddenRunning: 1 }), { routines: true }) - expect(screen.queryByLabelText('Show routine runs')).toBeNull() - expect(screen.queryByRole('button', { name: 'Routines' })).toBeNull() - } finally { - flag.on = true - } - }) -}) - -describe('Home toolbar: Routine runs switch', () => { - it('is not offered to a team with no routines (or an older control plane)', () => { - toolbar(runs({ routines: 0 })) - expect(screen.queryByLabelText('Show routine runs')).toBeNull() - cleanup() - toolbar(undefined) - expect(screen.queryByLabelText('Show routine runs')).toBeNull() - }) - - it('is off by default and counts the running runs it hides', () => { - const onChange = toolbar(runs({ hidden: 3, hiddenRunning: 2 })) - const toggle = screen.getByLabelText('Show routine runs') - expect(toggle.getAttribute('aria-checked')).toBe('false') - expect(screen.getByText('Routine runs · 2')).toBeTruthy() - fireEvent.click(toggle) - expect(onChange).toHaveBeenCalledWith({ routines: true }) - }) - - it('drops the count once the runs are listed', () => { - toolbar(runs({ hidden: 0 }), { routines: true }) - expect(screen.getByLabelText('Show routine runs').getAttribute('aria-checked')).toBe('true') - expect(screen.getByText('Routine runs')).toBeTruthy() - }) -}) - -describe('Home switch preferences', () => { - it('remembers Running, Archived and Routine runs per account', () => { - const store = new Map() - const storage: Storage = { - getItem: (k: string) => store.get(k) ?? null, - setItem: (k: string, v: string) => void store.set(k, v), - removeItem: (k: string) => void store.delete(k), - clear: () => store.clear(), - key: () => null, - get length() { - return store.size - } - } - const off = { running: false, archived: false, routines: false } - expect(readHomeSwitches('u1:org', storage)).toEqual(off) - writeHomeSwitches('u1:org', { routines: true }, storage) - writeHomeSwitches('u1:org', { running: true }, storage) - expect(readHomeSwitches('u1:org', storage)).toEqual({ - running: true, - archived: false, - routines: true - }) - expect(readHomeSwitches('u2:org', storage)).toEqual(off) - writeHomeSwitches('u1:org', { running: false, routines: false }, storage) - expect(readHomeSwitches('u1:org', storage)).toEqual(off) - store.set('orca.cloudWorkspacesHome.switches', 'not json') - expect(readHomeSwitches('u1:org', storage)).toEqual(off) - }) -}) diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesToolbar.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesToolbar.tsx deleted file mode 100644 index 8f64ccec8b4..00000000000 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesToolbar.tsx +++ /dev/null @@ -1,310 +0,0 @@ -import React from 'react' -import { CalendarClock, Cloud, RefreshCw } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from '@/components/ui/select' -import { Switch } from '@/components/ui/switch' -import { SettingsSegmentedControl } from '@/components/settings/form-controls/SettingsFormControls' -import { useOrcaCloudApiAvailable } from '@/hooks/useOrcaCloudApiAvailability' -import { useTranslate } from '@/i18n/use-translate' -import { useAppStore } from '@/store' -import { cn } from '@/lib/class-names' -import { repoLabel, type MemberDirectory } from './cloud-workspace-format' -import type { CloudWorkspaceRoutineRuns } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' -import { OrcaCloudTrialUsedUpBanner } from '../new-workspace/run-on/OrcaCloudTrialUsedUpNotice' -import { CLOUD_ROUTINES_UI_ENABLED } from '../../../../shared/cloud-api/cloud-routines-ui' - -export const ANY = '__any' - -function openRoutines(): void { - const state = useAppStore.getState() - state.openSettingsTarget({ pane: 'cloud-routines', repoId: null }) - state.openSettingsPage() -} - -export type CloudWorkspacesFilters = { - scope: 'team' | 'mine' - by: string - repo: string - archived: boolean - /** Only sandboxes that are running now (where the team's running slots go). */ - running?: boolean - /** Also list workspaces routine runs started (hidden by default, like Conductor). */ - routines?: boolean -} - -export const INITIAL_CLOUD_WORKSPACE_FILTERS: CloudWorkspacesFilters = { - scope: 'team', - by: ANY, - repo: ANY, - archived: false, - running: false, - routines: false -} - -/** Below this many workspaces there is little to filter: the filters and refresh stay out of the way. */ -export const CLOUD_HOME_FILTERS_MIN = 5 - -/** - * Whether Home shows its filter row (and refresh): only with enough workspaces to filter (JA-30), - * but always while any filter is off its default, so a filter that hides rows can still be undone. - */ -export function shouldShowCloudHomeFilters( - count: number, - filters: CloudWorkspacesFilters, - hasOrg: boolean -): boolean { - const active = - (hasOrg && filters.scope !== INITIAL_CLOUD_WORKSPACE_FILTERS.scope) || - filters.by !== ANY || - filters.repo !== ANY || - // The Archived switch is not a reason for the full row: the row follows the list it shows - // (5+ archived rows: full row; fewer: the switch on its own), so turning it on never floods. - filters.running === true || - filters.routines === true - return active || count >= CLOUD_HOME_FILTERS_MIN -} - -/** - * ARCHIVE-REACH: with the filter row hidden (few workspaces, JA-30), archived workspaces still get - * their Archived switch on its own, so archiving your only workspace never leaves Home without a way - * back to it. - */ -export function shouldShowCloudHomeArchivedOnly({ - showFilters, - archivedCount, - archivedOn, - activeCount -}: { - showFilters: boolean - archivedCount: number | null - /** The switch is on: it stays, so the active list is one click back. */ - archivedOn: boolean - /** With nothing active, the empty state's "Show archived (N)" is the one way in (no second control). */ - activeCount: number -}): boolean { - if (showFilters) { - return false - } - return archivedOn || ((archivedCount ?? 0) > 0 && activeCount > 0) -} - -/** Row 1: title, count and cost, refresh. Row 2: whose, by whom, which repo, running, routine runs, archived. */ -export function CloudWorkspacesToolbar({ - filters, - onFiltersChange, - hasOrg, - owners, - repos, - directory, - selfId, - meta, - metaStale, - reading, - onRefresh, - routineRuns, - showFilters = true, - archivedOnly = false, - archivedCount = null -}: { - filters: CloudWorkspacesFilters - onFiltersChange: (next: Partial) => void - hasOrg: boolean - owners: string[] - repos: string[] - directory: MemberDirectory - selfId: string | null - meta: string | null - /** The meta line belongs to the previous filter until the new read lands. */ - metaStale: boolean - reading: boolean - onRefresh: () => void - /** What the Routine runs switch hides; the switch exists only when the team has routines. */ - routineRuns?: CloudWorkspaceRoutineRuns - /** False with only a few workspaces and no filter on: title and count only (JA-30). */ - showFilters?: boolean - /** With the filters hidden: still show the Archived switch, because archived ones exist. */ - archivedOnly?: boolean - /** How many archived workspaces the switch shows; null while unknown. */ - archivedCount?: number | null -}): React.JSX.Element { - const t = useTranslate() - // Routines start cloud workspaces on their own, so they sit next to the list they fill. - // Routines are parked (cloud-routines-ui.ts): no Routines entry and no Routine runs switch. - const routinesAvailable = useOrcaCloudApiAvailable() && CLOUD_ROUTINES_UI_ENABLED - // The count says why the switch is there and what turning it on shows. - const archivedSwitch = ( - - ) - return ( -
    -
    - -

    - {t('auto.components.cloudWorkspaces.heading', 'Cloud workspaces')} -

    - {meta && ( - - {meta} - - )} - - {routinesAvailable ? ( - - ) : null} - {showFilters ? ( - - ) : null} - -
    -
    - {hasOrg && ( -
    - onFiltersChange({ scope })} - options={[ - { value: 'team', label: t('auto.components.cloudWorkspaces.scopeTeam', 'Team') }, - { value: 'mine', label: t('auto.components.cloudWorkspaces.scopeMine', 'Mine') } - ]} - /> -
    - )} - {hasOrg && filters.scope === 'team' && ( - - )} - - {/* The switches wrap as one group, so none is left alone on a line of its own. */} -
    - - {CLOUD_ROUTINES_UI_ENABLED && - ((routineRuns?.routines ?? 0) > 0 || filters.routines === true) ? ( - - ) : null} - {/* One Archived switch at a time: here, or on its own row below. */} - {showFilters ? archivedSwitch : null} -
    -
    - {!showFilters && archivedOnly ? ( -
    - {archivedSwitch} -
    - ) : null} - {/* T4: the team's trial ran out; running sandboxes sleep until a plan starts. */} - -
    - ) -} diff --git a/src/renderer/src/components/cloud-workspaces/OpenCloudWorkspaceMenuItem.tsx b/src/renderer/src/components/cloud-workspaces/OpenCloudWorkspaceMenuItem.tsx new file mode 100644 index 00000000000..78578c50a15 --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/OpenCloudWorkspaceMenuItem.tsx @@ -0,0 +1,51 @@ +// "Open cloud workspace…" in the Add project menu (sidebar and Landing), in its own "Orca Cloud" +// group: the way to open a cloud workspace the sidebar doesn't show yet. +import React, { useMemo } from 'react' +import { Cloud } from 'lucide-react' +import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu' +import { useTranslate } from '@/i18n/use-translate' +import { openCloudWorkspacePicker } from './cloud-workspace-picker-store' +import { useCloudWorkspacesPresent } from './use-cloud-workspaces' + +function OpenCloudWorkspaceItem({ onSelect }: { onSelect: () => void }): React.JSX.Element { + const t = useTranslate() + return ( + <> + {/* Its own group: the header's host picker doesn't apply to Orca Cloud. */} + +
    + {t('components.newWorkspace.cloudOnly.menuGroup', 'Orca Cloud')} +
    + + + + {t('components.cloudWorkspaces.picker.menuItem', 'Open cloud workspace…')} + + + + ) +} + +/** The Add project menu's `extraItems`, or undefined when signed out or without Orca Cloud. */ +export function useOpenCloudWorkspaceMenuItems(): + | ((closeMenu: () => void) => React.ReactNode) + | undefined { + const present = useCloudWorkspacesPresent() + return useMemo( + () => + present + ? (closeMenu: () => void) => ( + { + closeMenu() + openCloudWorkspacePicker() + }} + /> + ) + : undefined, + [present] + ) +} diff --git a/src/renderer/src/components/cloud-workspaces/cloud-archive-reach.test.tsx b/src/renderer/src/components/cloud-workspaces/cloud-archive-reach.test.tsx deleted file mode 100644 index 673b838ab46..00000000000 --- a/src/renderer/src/components/cloud-workspaces/cloud-archive-reach.test.tsx +++ /dev/null @@ -1,241 +0,0 @@ -// @vitest-environment happy-dom -// ARCHIVE-REACH: Home always reaches archived workspaces, however few are active. -import { - act, - cleanup, - fireEvent, - render, - renderHook, - screen, - waitFor -} from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { TooltipProvider } from '@/components/ui/tooltip' -import { EmptyState } from './CloudWorkspacesStates' -import { - CloudWorkspacesToolbar, - INITIAL_CLOUD_WORKSPACE_FILTERS as DEFAULTS, - shouldShowCloudHomeArchivedOnly, - shouldShowCloudHomeFilters -} from './CloudWorkspacesToolbar' -import { resetCloudArchivedCountCache, useCloudArchivedCount } from './use-cloud-archived-count' -import { noteCloudArchiveChanged } from '@/lib/orca-cloud/cloud-archive-changes' - -vi.mock('../new-workspace/run-on/OrcaCloudTrialUsedUpNotice', () => ({ - OrcaCloudTrialUsedUpBanner: () => null -})) -vi.mock('@/hooks/useOrcaCloudApiAvailability', () => ({ useOrcaCloudApiAvailable: () => false })) - -afterEach(cleanup) - -/** What Home decides for `active` workspaces, `archived` archived ones, and the switch on or off. */ -function homeRow(active: number, archived: number, archivedOn = false) { - // With the switch on, Home lists the archived ones: the row follows that list's size. - const shown = archivedOn ? archived : active - const showFilters = shouldShowCloudHomeFilters(shown, { ...DEFAULTS, archived: archivedOn }, true) - return { - showFilters, - archivedOnly: shouldShowCloudHomeArchivedOnly({ - showFilters, - archivedCount: archived, - archivedOn, - activeCount: active - }) - } -} - -describe('which filter row Home shows', () => { - it.each([ - // Nothing active: the empty state's "Show archived (N)" is the way in, not a second control. - [0, 0, false, { showFilters: false, archivedOnly: false }], - [0, 2, false, { showFilters: false, archivedOnly: false }], - [3, 0, false, { showFilters: false, archivedOnly: false }], - [3, 2, false, { showFilters: false, archivedOnly: true }], - [5, 0, false, { showFilters: true, archivedOnly: false }], - [5, 4, false, { showFilters: true, archivedOnly: false }], - // Switch on: a few archived rows keep the minimal row with the switch; 5+ get the full row. - [0, 2, true, { showFilters: false, archivedOnly: true }], - [3, 2, true, { showFilters: false, archivedOnly: true }], - [3, 6, true, { showFilters: true, archivedOnly: false }] - ])('%i active, %i archived, switch on: %s', (active, archived, on, expected) => { - expect(homeRow(active, archived, on)).toEqual(expected) - }) - - it('an unknown count (read failed or loading) keeps today’s row', () => { - expect( - shouldShowCloudHomeArchivedOnly({ - showFilters: false, - archivedCount: null, - archivedOn: false, - activeCount: 3 - }) - ).toBe(false) - }) -}) - -function toolbar(props: { - showFilters: boolean - archivedOnly: boolean - archivedCount: number | null - archived?: boolean -}) { - const onFiltersChange = vi.fn() - render( - - - - ) - return onFiltersChange -} - -describe('the Archived switch', () => { - it('below 5 active with archived ones: only the switch, with its count, and it turns on', () => { - const onFiltersChange = toolbar({ showFilters: false, archivedOnly: true, archivedCount: 3 }) - expect(screen.getByText('Archived (3)')).toBeTruthy() - // JA-30's minimal row otherwise: no owner/repo filters, no Running switch. - const own = document.querySelector('[data-cloud-home-archived-only]') - expect(own?.contains(screen.getByRole('switch', { name: 'Show archived' }))).toBe(true) - // JA-30's filter row (owner/repo, Running) stays hidden. - const running = screen.getByRole('switch', { name: 'Show running only' }) - expect(running.closest('.hidden')).not.toBeNull() - fireEvent.click(screen.getByRole('switch', { name: 'Show archived' })) - expect(onFiltersChange).toHaveBeenCalledWith({ archived: true }) - }) - - it('below 5 active with none archived: no switch at all (JA-30 unchanged)', () => { - toolbar({ showFilters: false, archivedOnly: false, archivedCount: 0 }) - expect(screen.queryByRole('switch', { name: 'Show archived' })).toBeNull() - }) - - it('at 5+ active: the full row, and the switch shows the count too', () => { - toolbar({ showFilters: true, archivedOnly: false, archivedCount: 4 }) - expect(screen.getByRole('switch', { name: 'Show running only' })).toBeTruthy() - expect(screen.getAllByRole('switch', { name: 'Show archived' })).toHaveLength(1) - expect(screen.getByText('Archived (4)')).toBeTruthy() - }) - - it('at 5+ active with none archived: plain "Archived"', () => { - toolbar({ showFilters: true, archivedOnly: false, archivedCount: 0 }) - expect(screen.getByText('Archived')).toBeTruthy() - }) -}) - -describe('the empty state', () => { - it('every workspace archived: says so, keeps New, and offers Show archived (N)', () => { - const onShowArchived = vi.fn() - render( - - ) - expect(screen.getByText('All your cloud workspaces are archived')).toBeTruthy() - expect(screen.queryByText('You don’t have any cloud workspaces yet.')).toBeNull() - expect(screen.getByText('Unarchive one to keep working, or start a new one.')).toBeTruthy() - // The first-run pitch isn't for someone who already has workspaces. - expect(screen.queryByText(/It runs in a sandbox/)).toBeNull() - expect(screen.getByRole('button', { name: 'New cloud workspace' })).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Show archived (2)' })) - expect(onShowArchived).toHaveBeenCalledOnce() - }) - - it('nothing archived: the first-run invitation, no Show archived link', () => { - render( - - ) - expect(screen.getByText('Start your first cloud workspace')).toBeTruthy() - expect(screen.queryByRole('button', { name: /Show archived/ })).toBeNull() - }) -}) - -describe('useCloudArchivedCount', () => { - const list = vi.fn() - beforeEach(() => { - resetCloudArchivedCountCache() - list.mockReset() - Object.defineProperty(window, 'api', { - configurable: true, - value: { cloudWorkspaces: { list } } - }) - }) - - it('reads archived=only once per active list, and again when the active list changes', async () => { - list.mockResolvedValue({ ok: true, value: [{ id: 'a' }, { id: 'b' }] }) - const { result, rerender } = renderHook( - (ids: string[]) => - useCloudArchivedCount({ account: 'u:o', scope: 'mine', activeIds: ids, enabled: true }), - { initialProps: ['w1'] } - ) - await waitFor(() => expect(result.current).toBe(2)) - expect(list).toHaveBeenCalledWith({ scope: 'mine', archived: 'only' }) - // Home polls: the same active list reuses the cached count. - rerender(['w1']) - renderHook(() => - useCloudArchivedCount({ account: 'u:o', scope: 'mine', activeIds: ['w1'], enabled: true }) - ) - expect(list).toHaveBeenCalledTimes(1) - // Archiving the only workspace empties the active list: read again. - list.mockResolvedValue({ ok: true, value: [{ id: 'a' }, { id: 'b' }, { id: 'w1' }] }) - rerender([]) - await waitFor(() => expect(result.current).toBe(3)) - expect(list).toHaveBeenCalledTimes(2) - }) - - const emptyIds: string[] = [] - it('archiving the only workspace never leaves a cached 0 standing: the archive itself re-reads', async () => { - // Before the first workspace existed, Home read "0 archived" for an empty active list. - list.mockResolvedValue({ ok: true, value: [] }) - const { result, rerender } = renderHook( - (ids: string[]) => - useCloudArchivedCount({ account: 'u:o', scope: 'mine', activeIds: ids, enabled: true }), - { initialProps: emptyIds } - ) - await waitFor(() => expect(list).toHaveBeenCalledTimes(1)) - await waitFor(() => expect(result.current).toBe(0)) - // Created w1, then archived it: the active list is empty again (same key as that cached 0), - // and the archive commits after Sleep, so the server only now lists it. - rerender(['w1']) - await waitFor(() => expect(list).toHaveBeenCalledTimes(2)) - rerender([]) - list.mockResolvedValue({ ok: true, value: [{ id: 'w1' }] }) - act(() => noteCloudArchiveChanged()) - await waitFor(() => expect(result.current).toBe(1)) - }) - - it('stays null on a failed read or while the active list loads, so Home keeps today’s row', async () => { - list.mockResolvedValue({ ok: false, error: 'unavailable' }) - const failed = renderHook(() => - useCloudArchivedCount({ account: 'u:o', scope: 'team', activeIds: [], enabled: true }) - ) - await waitFor(() => expect(list).toHaveBeenCalled()) - expect(failed.result.current).toBeNull() - const loading = renderHook(() => - useCloudArchivedCount({ account: 'u:o', scope: 'team', activeIds: null, enabled: true }) - ) - expect(loading.result.current).toBeNull() - }) -}) diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-action-groups.test.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-action-groups.test.ts index ad114048107..bc9be09f8e6 100644 --- a/src/renderer/src/components/cloud-workspaces/cloud-workspace-action-groups.test.ts +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-action-groups.test.ts @@ -5,7 +5,7 @@ import { } from './cloud-workspace-action-groups' const base: CloudWorkspaceActionInput = { - surface: 'home', + surface: 'list', status: 'running', archivedAt: null, visibility: 'private', @@ -20,16 +20,16 @@ const flat = (input: Partial) => cloudWorkspaceActionGroups({ ...base, ...input }).map((g) => `${g.id}:${g.actions.join(',')}`) describe('one action list for every surface (JA-14)', () => { - it('Home: Open, Share, Lifecycle, Danger in that order, with Restart', () => { + it('the list: Open, Share, Lifecycle, Danger in that order, with Restart', () => { expect(flat({})).toEqual([ 'open:open,copyLink,copyWorkspaceLink', - 'share:share,reassignOwner,moveToSection', + 'share:share,reassignOwner', 'lifecycle:restart,sleep,archive', 'danger:delete' ]) }) - it('the sidebar gets the same Share and Lifecycle actions, minus Home-only arrangement', () => { + it('the sidebar gets the same Share and Lifecycle actions, minus list-only items', () => { expect(flat({ surface: 'sidebar', visibility: 'team' })).toEqual([ 'open:copyWorkspaceLink', 'share:makePrivate', @@ -42,7 +42,7 @@ describe('one action list for every surface (JA-14)', () => { expect(flat({ archivedAt: 1, status: 'suspended' })).toContain('lifecycle:unarchive') }) - it('asleep on Home reads the saved copy, and Wake is its own action', () => { + it('asleep in the list reads the saved copy, and Wake is its own action', () => { expect(flat({ status: 'suspended', readableAsleep: true, canRestart: false })[0]).toBe( 'open:read,wake,copyLink,copyWorkspaceLink' ) diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-action-groups.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-action-groups.ts index eff027862a4..74745a7e3a0 100644 --- a/src/renderer/src/components/cloud-workspaces/cloud-workspace-action-groups.ts +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-action-groups.ts @@ -1,7 +1,7 @@ -// One action set per cloud workspace, the same on every surface (JA-14): the Home list's ⋯ menu and -// the sidebar's context menu both render these groups, in this order: Open, Share, Lifecycle, -// Danger. Each surface leaves out only what it already shows elsewhere (the sidebar row *is* the -// open workspace; Home has no local-only items) and never invents its own. +// One action set per cloud workspace, the same on every surface (JA-14): the "Open cloud +// workspace…" list's ⋯ menu and the sidebar's context menu both render these groups, in this order: +// Open, Share, Lifecycle, Danger. Each surface leaves out only what it already shows elsewhere (the +// sidebar row *is* the open workspace; the list has no local-only items) and never invents its own. import type { CloudWorkspace } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' export type CloudWorkspaceAction = @@ -13,7 +13,6 @@ export type CloudWorkspaceAction = | 'share' | 'makePrivate' | 'reassignOwner' - | 'moveToSection' | 'restart' | 'sleep' | 'archive' @@ -29,27 +28,27 @@ export type CloudWorkspaceActionInput = Pick< CloudWorkspace, 'status' | 'archivedAt' | 'visibility' > & { - surface: 'home' | 'sidebar' + surface: 'list' | 'sidebar' canOpen: boolean canManage: boolean - /** Asleep and readable without waking (Home reads the saved copy). */ + /** Asleep and readable without waking (the list reads the saved copy). */ readableAsleep: boolean /** Restart needs this desktop's own runtime for it. */ canRestart: boolean - /** Teammates to hand it to (Home only). */ + /** Teammates to hand it to (the list only). */ hasMembers?: boolean } export function cloudWorkspaceActionGroups( input: CloudWorkspaceActionInput ): CloudWorkspaceActionGroup[] { - const home = input.surface === 'home' + const list = input.surface === 'list' const archived = input.archivedAt !== null const open: CloudWorkspaceAction[] = [] - if (home && input.canOpen) { + if (list && input.canOpen) { open.push(...(input.readableAsleep ? (['read', 'wake'] as const) : (['open'] as const))) } - if (home) { + if (list) { open.push('copyLink') } open.push('copyWorkspaceLink') @@ -57,12 +56,9 @@ export function cloudWorkspaceActionGroups( const share: CloudWorkspaceAction[] = [] if (input.canManage) { share.push(input.visibility === 'private' ? 'share' : 'makePrivate') - if (home && input.hasMembers) { + if (list && input.hasMembers) { share.push('reassignOwner') } - if (home) { - share.push('moveToSection') - } } const lifecycle: CloudWorkspaceAction[] = [] diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-errors.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-errors.ts index 43ec3cb08cb..d17a476fca2 100644 --- a/src/renderer/src/components/cloud-workspaces/cloud-workspace-errors.ts +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-errors.ts @@ -29,12 +29,23 @@ export function isUnknownServerFailure(error: string, status?: number): boolean return status !== undefined && status >= 500 && !DESCRIBED_CODES.has(error) } +/** The team's MFA policy refused the request: not a session problem, so never "sign in again". */ +export function mfaRequiredMessage(t: Translate = translate): string { + return t( + 'auto.components.teamMfa.requiredInline', + 'Your team requires two-factor authentication. Set it up in Settings, Account to use its cloud workspaces.' + ) +} + export function describeError( error: string, reason?: string, t: Translate = translate, owner?: CloudWorkspaceSeatOwner ): string { + if (reason === 'mfa_required') { + return mfaRequiredMessage(t) + } if (error === 'not_found') { return t( 'auto.components.cloudWorkspaces.errorNotFound', @@ -133,6 +144,9 @@ export function describeInlineError( reason?: string, t: Translate = translate ): string { + if (reason === 'mfa_required') { + return mfaRequiredMessage(t) + } if (error === 'unauthorized') { return signInAgainMessage(t) } diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-home-archive.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-home-archive.ts index 33f40878a70..0147860ddd7 100644 --- a/src/renderer/src/components/cloud-workspaces/cloud-workspace-home-archive.ts +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-home-archive.ts @@ -1,4 +1,4 @@ -// Archive and unarchive from the team list. Both are lifecycle changes, never just a label: +// Archive and unarchive from the cloud workspace list ("Open cloud workspace…"). Both are lifecycle changes, never just a label: // a workspace this desktop runs goes through the same Sleep → archive (with Undo) as the sidebar; // any other one is archived by the control plane, which stops its sandbox and keeps a snapshot. import { noteCloudArchiveChanged } from '@/lib/orca-cloud/cloud-archive-changes' @@ -13,7 +13,6 @@ import { runUnarchiveCloudWorkspace } from '../orca-cloud/cloud-workspace-actions' import { describeError } from './cloud-workspace-errors' -export { usePendingHomeFilters } from './cloud-workspaces-home-nav' import type { Translate } from '@/i18n/use-translate' import { cloudWorkspaceTitle } from './open-cloud-workspace' import type { RowActions } from './CloudWorkspaceRowMenu' @@ -156,7 +155,7 @@ export async function deleteFromHome( export async function destroyFromHome(w: CloudWorkspace, name: string): Promise { const result = await window.api.cloudWorkspaces.destroy(w.id) if (result.ok) { - // It may have been an archived one: Home's archived count reads again. + // It may have been an archived one: anything counting archived workspaces reads again. noteCloudArchiveChanged() toast.success(translate('orcaCloudWorkspace.deletedNamed', 'Deleted {{name}}', { name })) return diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-links.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-links.ts index f988e467c7a..5944e754dfa 100644 --- a/src/renderer/src/components/cloud-workspaces/cloud-workspace-links.ts +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-links.ts @@ -1,6 +1,5 @@ // App-level wiring for team cloud workspaces: orca://cloud links, presence for the workspace in // front, and the copy-link shortcut. Installed once from main.tsx; no React tree needed. -import { useSyncExternalStore } from 'react' import { toast } from 'sonner' import { useAppStore } from '@/store' import { getShortcutPlatform } from '@/lib/keyboard/shortcut-platform' @@ -14,46 +13,19 @@ import { cloudWorkspacesAvailable } from './use-cloud-workspaces' import { installCloudWorkspaceSync } from './cloud-workspace-sync' import { rememberCloudWorkspaces } from './cloud-workspace-identity' -const HIGHLIGHT_MS = 4_000 /** No input for this long and the desktop stops reporting presence (and renewing a lease). */ const IDLE_MS = 15 * 60_000 const TYPING_REPORT_MS = 3_000 /** Tab and pane switches settle before the chat in front is re-read (layouts churn while resizing). */ const CHAT_SYNC_DEBOUNCE_MS = 300 -let highlightId: string | null = null -let highlightTimer: ReturnType | undefined -const listeners = new Set<() => void>() -function setHighlight(id: string | null, clearAfterMs?: number): void { - clearTimeout(highlightTimer) - highlightTimer = - clearAfterMs === undefined ? undefined : setTimeout(() => setHighlight(null), clearAfterMs) - highlightId = id - listeners.forEach((l) => l()) -} - -/** The workspace a link pointed at, so Home can ring it while it opens. */ -export function useCloudWorkspaceHighlight(): string | null { - return useSyncExternalStore( - (l) => { - listeners.add(l) - return () => listeners.delete(l) - }, - () => highlightId - ) -} - export async function handleCloudWorkspaceLink(link: string): Promise { const parsed = parseCloudWorkspaceLink(link) if (!parsed || !(await cloudWorkspacesAvailable())) { return } - if (parsed.target.kind === 'id') { - setHighlight(parsed.target.workspaceId) - } const resolved = await window.api.cloudWorkspaces.resolveLink(parsed.target, parsed.chatId) if (!resolved.ok) { - setHighlight(null) reportCloudWorkspaceLinkFailure(parsed.target, resolved, () => { void handleCloudWorkspaceLink(link) }) @@ -61,14 +33,12 @@ export async function handleCloudWorkspaceLink(link: string): Promise { } const workspace = resolved.value.workspace rememberCloudWorkspaces([workspace]) - setHighlight(workspace.id) const outcome = await openCloudWorkspace(workspace) if (!outcome.ok) { toast.error(outcome.message) } else if (resolved.value.chatId) { await focusCloudChat(outcome.worktreeId, resolved.value.chatId) } - setHighlight(workspace.id, HIGHLIGHT_MS) } type Front = { @@ -80,8 +50,8 @@ type Front = { let front: Front = null /** - * ⌘⌥⇧L: the workspace's own link (its W-number), like the tab-bar chip and Home's "Copy link to - * W-n". A link to the focused chat or terminal is a separate menu item, never the shortcut. + * ⌘⌥⇧L: the workspace's own link (its W-number), like the tab-bar chip and the cloud workspace + * list's "Copy link to W-n". A link to the focused chat or terminal is a separate menu item, never the shortcut. */ export function copyShortcutLink(sandboxId: string): Promise { return copyCloudWorkspaceLink(sandboxId, null) @@ -285,6 +255,5 @@ export function installCloudWorkspaceLinks(): () => void { const onUnload = (): void => void api.setActive({ sandboxId: null }) window.addEventListener('beforeunload', onUnload) cleanups.push(() => window.removeEventListener('beforeunload', onUnload)) - cleanups.push(() => setHighlight(null)) return () => cleanups.forEach((c) => c()) } diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-local-titles.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-local-titles.ts index 5359eb0943a..07da8515c31 100644 --- a/src/renderer/src/components/cloud-workspaces/cloud-workspace-local-titles.ts +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-local-titles.ts @@ -71,10 +71,12 @@ export function useBackfillLocalTitles( const NONE: readonly CloudWorkspace[] = [] /** - * What Home lists: a workspace being deleted (or gone) is not something to open or manage, so it - * is left out; untitled ones this computer started read by its name for them, not the repo. + * What the cloud workspace list shows: a workspace being deleted (or gone) is not something to open + * or manage, so it is left out; untitled ones this computer started read by its name, not the repo. */ -export function useHomeWorkspaces(workspaces: readonly CloudWorkspace[] | null): CloudWorkspace[] { +export function useListedCloudWorkspaces( + workspaces: readonly CloudWorkspace[] | null +): CloudWorkspace[] { const listed = useMemo( () => (workspaces ?? NONE).filter((w) => w.status !== 'destroying' && w.status !== 'destroyed'), [workspaces] diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-picker-model.test.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-picker-model.test.ts new file mode 100644 index 00000000000..a595804bdff --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-picker-model.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import { + initialPickerControls, + pickerListQuery, + pickerRows, + pickerShowsArchived, + pickerShowsScope +} from './cloud-workspace-picker-model' +import { DIRECTORY, workspace } from './cloud-workspaces.test-fixture' + +const NONE = new Set() +const controls = initialPickerControls(true) + +describe('what the picker reads', () => { + it('Open: the team list on a team, yours without one, archived only behind the switch', () => { + expect(controls).toEqual({ scope: 'team', archived: false, search: '' }) + expect(pickerListQuery({ kind: 'open' }, controls, true)).toEqual({ + scope: 'team', + archived: 'exclude' + }) + expect(pickerListQuery({ kind: 'open' }, { ...controls, archived: true }, true)).toEqual({ + scope: 'team', + archived: 'only' + }) + expect(pickerListQuery({ kind: 'open' }, initialPickerControls(false), false)).toEqual({ + scope: 'mine', + archived: 'exclude' + }) + }) + + it('a focused picker reads a fixed list and offers no scope switch', () => { + expect(pickerListQuery({ kind: 'running' }, controls, true)).toEqual({ + scope: 'mine', + archived: 'exclude' + }) + expect(pickerListQuery({ kind: 'team-cleanup' }, controls, true)).toEqual({ + scope: 'team', + archived: 'include' + }) + expect(pickerShowsScope({ kind: 'open' }, true)).toBe(true) + expect(pickerShowsScope({ kind: 'open' }, false)).toBe(false) + expect(pickerShowsScope({ kind: 'teammate', userId: 'user_grace' }, true)).toBe(false) + expect(pickerShowsArchived({ kind: 'running' })).toBe(false) + expect(pickerShowsArchived({ kind: 'team-cleanup' })).toBe(false) + }) +}) + +describe('what the picker lists', () => { + const mine = workspace({ title: 'Mine here', lastActivityAt: 3 }) + const shared = workspace({ + title: 'Grace’s shared one', + ownerUserId: 'user_grace', + createdBy: 'user_grace', + access: 'can_open', + lastActivityAt: 5 + }) + const elsewhere = workspace({ + title: 'Started on my laptop', + status: 'suspended', + lastActivityAt: 1 + }) + + it('Open leaves out what the sidebar already shows, and counts it', () => { + const { rows, hiddenInSidebar } = pickerRows({ + workspaces: [mine, shared, elsewhere], + focus: { kind: 'open' }, + inSidebar: new Set([mine.id]), + search: '', + directory: DIRECTORY + }) + // Most recently active first. + expect(rows.map((w) => w.title)).toEqual(['Grace’s shared one', 'Started on my laptop']) + expect(hiddenInSidebar).toBe(1) + }) + + it('the running limit lists your running ones, sidebar ones too', () => { + const { rows, hiddenInSidebar } = pickerRows({ + workspaces: [mine, elsewhere], + focus: { kind: 'running' }, + inSidebar: new Set([mine.id]), + search: '', + directory: DIRECTORY + }) + expect(rows).toEqual([mine]) + expect(hiddenInSidebar).toBe(0) + }) + + it('a teammate focus lists only what they own or started', () => { + const { rows } = pickerRows({ + workspaces: [mine, shared], + focus: { kind: 'teammate', userId: 'user_grace' }, + inSidebar: NONE, + search: '', + directory: DIRECTORY + }) + expect(rows).toEqual([shared]) + }) + + it('search matches title, repository, owner name and W-number', () => { + const rows = (search: string): (string | null)[] => + pickerRows({ + workspaces: [mine, shared], + focus: { kind: 'open' }, + inSidebar: NONE, + search, + directory: DIRECTORY + }).rows.map((w) => w.title) + expect(rows('grace hopper')).toEqual(['Grace’s shared one']) + expect(rows('acme/payments')).toHaveLength(2) + expect(rows(`W-${mine.number}`)).toEqual(['Mine here']) + expect(rows('nothing like this')).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-picker-model.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-picker-model.ts new file mode 100644 index 00000000000..e8df9f74c0b --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-picker-model.ts @@ -0,0 +1,110 @@ +// What "Open cloud workspace…" reads and lists for each focus. Pure, so the rules are tested apart +// from the dialog. +import type { + CloudWorkspace, + CloudWorkspaceListQuery +} from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' +import { formatWorkspaceNumber } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' +import type { MemberDirectory } from './cloud-workspace-format' +import type { CloudWorkspacePickerFocus } from './cloud-workspace-picker-store' + +export type CloudWorkspacePickerControls = { + scope: 'mine' | 'team' + archived: boolean + search: string +} + +export function initialPickerControls(hasOrg: boolean): CloudWorkspacePickerControls { + // On a team, what you can't open from the sidebar is mostly what teammates shared. + return { scope: hasOrg ? 'team' : 'mine', archived: false, search: '' } +} + +/** Which controls the picker offers: a focused list (limit, teammate, team delete) is fixed. */ +export function pickerShowsScope(focus: CloudWorkspacePickerFocus, hasOrg: boolean): boolean { + return focus.kind === 'open' && hasOrg +} + +export function pickerShowsArchived(focus: CloudWorkspacePickerFocus): boolean { + return focus.kind === 'open' || focus.kind === 'teammate' +} + +export function pickerListQuery( + focus: CloudWorkspacePickerFocus, + controls: CloudWorkspacePickerControls, + hasOrg: boolean +): CloudWorkspaceListQuery { + switch (focus.kind) { + case 'open': + return { + scope: hasOrg ? controls.scope : 'mine', + archived: controls.archived ? 'only' : 'exclude' + } + case 'teammate': + return { scope: 'team', archived: controls.archived ? 'only' : 'exclude' } + case 'running': + return { scope: 'mine', archived: 'exclude' } + case 'team-cleanup': + return { scope: 'team', archived: 'include' } + } +} + +/** Only "Open" leaves out what the sidebar already shows; the other focuses are about acting on them. */ +export function pickerHidesSidebarWorkspaces(focus: CloudWorkspacePickerFocus): boolean { + return focus.kind === 'open' +} + +function matchesSearch(w: CloudWorkspace, search: string, directory: MemberDirectory): boolean { + const needle = search.trim().toLowerCase() + if (!needle) { + return true + } + const haystack = [ + w.title, + w.repo?.url, + w.repo?.branch, + w.routineName, + w.number ? formatWorkspaceNumber(w.number) : null, + directory.get(w.ownerUserId)?.name, + w.memberNames?.[w.ownerUserId] + ] + return haystack.some((value) => typeof value === 'string' && value.toLowerCase().includes(needle)) +} + +export function pickerRows({ + workspaces, + focus, + inSidebar, + search, + directory +}: { + workspaces: readonly CloudWorkspace[] + focus: CloudWorkspacePickerFocus + inSidebar: ReadonlySet + search: string + directory: MemberDirectory +}): { rows: CloudWorkspace[]; hiddenInSidebar: number } { + let hiddenInSidebar = 0 + const rows: CloudWorkspace[] = [] + for (const w of workspaces) { + if ( + focus.kind === 'teammate' && + w.ownerUserId !== focus.userId && + w.createdBy !== focus.userId + ) { + continue + } + if (focus.kind === 'running' && w.status !== 'running') { + continue + } + if (pickerHidesSidebarWorkspaces(focus) && inSidebar.has(w.id)) { + hiddenInSidebar += 1 + continue + } + if (matchesSearch(w, search, directory)) { + rows.push(w) + } + } + // Most recently active first: the one you are looking for is usually the one just used. + rows.sort((a, b) => b.lastActivityAt - a.lastActivityAt) + return { rows, hiddenInSidebar } +} diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-picker-store.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-picker-store.ts new file mode 100644 index 00000000000..5b09b02580f --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-picker-store.ts @@ -0,0 +1,54 @@ +// "Open cloud workspace…": the one place to open a cloud workspace this computer's sidebar doesn't +// show (a teammate's shared one, one started on another computer, one whose pairing was removed). +// Every surface that used to send people to the Home list opens this instead, with a focus that +// says why: the running limit lists your running ones, a teammate's profile lists theirs. +import { useSyncExternalStore } from 'react' + +export type CloudWorkspacePickerFocus = + /** Anything not in this sidebar yet (the menu, the palette, a broken pairing). */ + | { kind: 'open' } + /** Everything one teammate has that you can see (their profile's "Show all"). */ + | { kind: 'teammate'; userId: string } + /** At the running-sandbox limit: your running ones, sidebar ones too, to put one to sleep. */ + | { kind: 'running' } + /** Deleting the team: every team workspace, archived ones too, so they can be deleted first. */ + | { kind: 'team-cleanup' } + +export type CloudWorkspacePickerRequest = { id: number; focus: CloudWorkspacePickerFocus } + +let request: CloudWorkspacePickerRequest | null = null +let nextId = 0 +const listeners = new Set<() => void>() + +function emit(): void { + listeners.forEach((listener) => listener()) +} + +export function openCloudWorkspacePicker( + focus: CloudWorkspacePickerFocus = { kind: 'open' } +): void { + // A new id each time: a second open (another focus) starts the picker over. + request = { id: ++nextId, focus } + emit() +} + +export function closeCloudWorkspacePicker(): void { + if (request) { + request = null + emit() + } +} + +export function getCloudWorkspacePickerRequest(): CloudWorkspacePickerRequest | null { + return request +} + +export function useCloudWorkspacePickerRequest(): CloudWorkspacePickerRequest | null { + return useSyncExternalStore( + (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + () => request + ) +} diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-share-link.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-share-link.ts new file mode 100644 index 00000000000..d64be4157e1 --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-share-link.ts @@ -0,0 +1,68 @@ +// A cloud workspace's share link: the org slug + W-number link when known, else the sandbox-id form. +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { cloudWorkspaceShareLink } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' +import { cloudWorkspaceIdentity, rememberCloudWorkspaces } from './cloud-workspace-identity' + +// How long Copy link waits on the control plane before settling for the sandbox-id link. +const LINK_LOOKUP_MS = 3_000 + +async function lookUpLink(id: string): Promise { + let timer: ReturnType | undefined + try { + const fetched = await Promise.race([ + window.api.cloudWorkspaces.get(id), + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), LINK_LOOKUP_MS) + }) + ]) + if (!fetched?.ok) { + return null + } + rememberCloudWorkspaces([fetched.value]) + return fetched.value.link ?? null + } catch { + return null + } finally { + clearTimeout(timer) + } +} + +/** + * The org slug + W-number link when the control plane gave one, else the sandbox id form, which + * opens for anyone with access. Never rejects: a device that didn't create the workspace (paired, + * offline, signed out) still gets a working link. + */ +export async function shareLinkFor(id: string, chatId?: string | null): Promise { + const link = cloudWorkspaceIdentity(id)?.link ?? (await lookUpLink(id)) + return cloudWorkspaceShareLink({ id, ...(link ? { link } : {}) }, chatId) +} + +export async function copyCloudWorkspaceLink( + id: string, + chatId?: string | null, + options: { quiet?: boolean } = {} +): Promise { + const link = await shareLinkFor(id, chatId) + try { + // Why: main-process clipboard, like every other renderer copy. navigator.clipboard needs a + // focused document with live user activation; after the link lookup (or from the menu or a + // shortcut) Electron rejects it with "Document is not focused". + await window.api.ui.writeClipboardText(link) + } catch (error) { + console.warn('[cloud-workspaces] copy link failed', error) + toast.error( + translate('auto.components.cloudWorkspaces.copyFailed', "Couldn't copy the link. Try again.") + ) + return false + } + if (options.quiet) { + return true + } + toast.success( + chatId + ? translate('auto.components.cloudWorkspaces.chatLinkCopied', 'Chat link copied') + : translate('auto.components.cloudWorkspaces.workspaceLinkCopied', 'Workspace link copied') + ) + return true +} diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspaces-home-nav.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspaces-home-nav.ts deleted file mode 100644 index 170bebb1a97..00000000000 --- a/src/renderer/src/components/cloud-workspaces/cloud-workspaces-home-nav.ts +++ /dev/null @@ -1,44 +0,0 @@ -// "Show cloud workspaces" from limit errors: go to the team list (Home), filtered to your own, -// so you can put one to sleep or delete it and try again. -import { useEffect } from 'react' -import { useAppStore } from '@/store' -import type { CloudWorkspacesFilters } from './CloudWorkspacesToolbar' - -let pendingFilters: Partial | null = null -const listeners = new Set<() => void>() - -/** Home reads (and clears) this when it mounts or is already showing. Limit errors ask for Mine + Running. */ -export function takePendingHomeFilters(): Partial | null { - const next = pendingFilters - pendingFilters = null - return next -} - -export function onPendingHomeFilters(listener: () => void): () => void { - listeners.add(listener) - return () => listeners.delete(listener) -} - -export function openCloudWorkspacesHome(filters: Partial = {}): void { - pendingFilters = { scope: 'mine', archived: false, ...filters } - const state = useAppStore.getState() - state.setActiveView('terminal') - state.setActiveWorktree(null) - listeners.forEach((listener) => listener()) -} - -/** Applies pending filters on mount and whenever another surface asks for Home again. */ -export function usePendingHomeFilters( - setFilters: React.Dispatch> -): void { - useEffect(() => { - const take = (): void => { - const pending = takePendingHomeFilters() - if (pending) { - setFilters((current) => ({ ...current, ...pending })) - } - } - take() - return onPendingHomeFilters(take) - }, [setFilters]) -} diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspaces-home-prefs.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspaces-home-prefs.ts deleted file mode 100644 index d8e9f6ebbf0..00000000000 --- a/src/renderer/src/components/cloud-workspaces/cloud-workspaces-home-prefs.ts +++ /dev/null @@ -1,60 +0,0 @@ -// Home's three list switches (Running, Archived, Routine runs) are remembered per signed-in account -// (user and team) on this desktop. Preferences only: they never decide access, so local storage is enough. -const KEY = 'orca.cloudWorkspacesHome.switches' - -export type HomeSwitches = { running: boolean; archived: boolean; routines: boolean } -const OFF: HomeSwitches = { running: false, archived: false, routines: false } - -function read(storage: Storage | undefined): Record> { - try { - const parsed: unknown = JSON.parse(storage?.getItem(KEY) ?? '{}') - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return {} - } - const out: Record> = {} - for (const [account, saved] of Object.entries(parsed)) { - if (saved && typeof saved === 'object') { - const flags: Record = { ...saved } - out[account] = { - running: flags.running === true, - archived: flags.archived === true, - routines: flags.routines === true - } - } - } - return out - } catch { - return {} - } -} - -export function readHomeSwitches( - account: string, - storage: Storage | undefined = globalThis.localStorage -): HomeSwitches { - const saved = read(storage)[account] ?? {} - return { - running: saved.running === true, - archived: saved.archived === true, - routines: saved.routines === true - } -} - -export function writeHomeSwitches( - account: string, - next: Partial, - storage: Storage | undefined = globalThis.localStorage -): void { - const all = read(storage) - const merged = { ...OFF, ...readHomeSwitches(account, storage), ...next } - if (!merged.running && !merged.archived && !merged.routines) { - delete all[account] - } else { - all[account] = merged - } - try { - storage?.setItem(KEY, JSON.stringify(all)) - } catch { - // Storage full or unavailable: the switches still work for this session. - } -} diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspaces-in-sidebar.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspaces-in-sidebar.ts new file mode 100644 index 00000000000..0c2bfe97d4f --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspaces-in-sidebar.ts @@ -0,0 +1,51 @@ +// Which cloud workspaces this window's sidebar already shows: ones this desktop runs (with a row), +// and paired ones whose server has projects in the sidebar. "Open cloud workspace…" leaves these +// out; they open from the sidebar. A pairing whose server is gone is not in the sidebar, so it lists. +import { useEffect, useState } from 'react' +import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' +import { hasLocalSidebarRow } from './cloud-local-row' +import { firstWorktreeOnHost } from './open-cloud-workspace' + +export async function cloudWorkspaceIdsInSidebar(): Promise> { + const api = window.api.cloudWorkspaces + const [local, bindings] = await Promise.all([ + api.localWorkspaces().catch(() => []), + api.environmentBindings().catch(() => ({})) + ]) + const ids = new Set() + for (const entry of local) { + if (hasLocalSidebarRow(entry)) { + ids.add(entry.sandboxId) + } + } + for (const [sandboxId, binding] of Object.entries(bindings)) { + if (firstWorktreeOnHost(toRuntimeExecutionHostId(binding.environmentId))) { + ids.add(sandboxId) + } + } + return ids +} + +const NONE: ReadonlySet = new Set() + +/** Null until read (the picker shows its skeleton meanwhile); read once per mount. */ +export function useCloudWorkspaceIdsInSidebar(enabled: boolean): ReadonlySet | null { + const [ids, setIds] = useState | null>(null) + useEffect(() => { + if (!enabled) { + return + } + let disposed = false + void cloudWorkspaceIdsInSidebar() + .catch(() => new Set()) + .then((next) => { + if (!disposed) { + setIds(next) + } + }) + return () => { + disposed = true + } + }, [enabled]) + return enabled ? ids : NONE +} diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspaces.test-fixture.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspaces.test-fixture.ts index cec0afee85a..584deb65fe5 100644 --- a/src/renderer/src/components/cloud-workspaces/cloud-workspaces.test-fixture.ts +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspaces.test-fixture.ts @@ -1,5 +1,4 @@ // Static team, workspaces and API stubs shared by the cloud workspaces component fixtures. -import { resetCloudArchivedCountCache } from './use-cloud-archived-count' import type { CloudWorkspace, CloudWorkspacePresence @@ -150,16 +149,14 @@ export function teamWorkspaces(): CloudWorkspace[] { type ListResult = Awaited> -/** Replaces the web build's "not configured" cloud workspaces bridge with static answers. */ /** - * `archived` is what the Archived switch (and Home's archived count, ARCHIVE-REACH) reads with - * `archived=only`; every other query gets `list`. + * Replaces the web build's "not configured" cloud workspaces bridge with static answers. `archived` + * is what the Archived switch reads with `archived=only`; every other query gets `list`. */ export function stubCloudWorkspacesApi( list: () => Promise, archived: CloudWorkspace[] = [] ): void { - resetCloudArchivedCountCache() Object.assign(window.api.cloudWorkspaces, { available: () => Promise.resolve(true), list: (query?: { archived?: string }) => diff --git a/src/renderer/src/components/cloud-workspaces/open-cloud-workspace.ts b/src/renderer/src/components/cloud-workspaces/open-cloud-workspace.ts index e2b376a817e..0ae44d82588 100644 --- a/src/renderer/src/components/cloud-workspaces/open-cloud-workspace.ts +++ b/src/renderer/src/components/cloud-workspaces/open-cloud-workspace.ts @@ -11,8 +11,6 @@ import { activateAndRevealWorktree } from '@/lib/workspace/activation/worktree-a import { refreshRuntimeProjectWorktreesAndLineage } from '@/hooks/runtime-project-refresh-scheduler' import { RuntimeRpcCallError, unwrapRuntimeRpcResult } from '@/runtime/rpc/calls/runtime-rpc-result' import type { CloudWorkspace } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' -import { cloudWorkspaceShareLink } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' -import { cloudWorkspaceIdentity, rememberCloudWorkspaces } from './cloud-workspace-identity' import { getWorktreeExecutionHostId, toRuntimeExecutionHostId @@ -66,7 +64,20 @@ function isPairingRejected(error: unknown): boolean { return error instanceof Error && /\b(unauthori[sz]ed|device_revoked)\b/i.test(error.message) } -async function connectAndReveal(environmentId: string, name: string): Promise { +export type OpenCloudWorkspaceOptions = { + /** + * The server this open connects to, before any of its rows load. A GitHub-only create's sheet + * hides that server's rows until it hands off to them, so its pending row never doubles up. + */ + onHost?: (hostId: string) => void +} + +async function connectAndReveal( + environmentId: string, + name: string, + options: OpenCloudWorkspaceOptions = {} +): Promise { + options.onHost?.(toRuntimeExecutionHostId(environmentId)) const environments = await window.api.runtimeEnvironments.list() useAppStore.getState().setRuntimeEnvironments(environments) const environment = environments.find((e) => e.id === environmentId) @@ -147,7 +158,11 @@ async function wakeIfAsleep(workspace: CloudWorkspace): Promise { +async function pairAndReveal( + workspace: CloudWorkspace, + name: string, + options: OpenCloudWorkspaceOptions +): Promise { const api = window.api.cloudWorkspaces const binding = (await api.environmentBindings())[workspace.id] if (binding) { @@ -161,7 +176,7 @@ async function pairAndReveal(workspace: CloudWorkspace, name: string): Promise undefined) - return await connectAndReveal(added.environment.id, name) + return await connectAndReveal(added.environment.id, name, options) } -async function openOnce(workspace: CloudWorkspace): Promise { +async function openOnce( + workspace: CloudWorkspace, + options: OpenCloudWorkspaceOptions +): Promise { const name = cloudWorkspaceTitle(workspace) try { if (workspace.access === 'owner') { @@ -244,7 +262,7 @@ async function openOnce(workspace: CloudWorkspace): Promise { ) } } - return await pairAndReveal(workspace, name) + return await pairAndReveal(workspace, name, options) } catch (error) { // Raw transport/RPC messages are not user copy; keep them for diagnostics only. console.warn('[cloud-workspaces] open failed', error) @@ -262,75 +280,18 @@ async function openOnce(workspace: CloudWorkspace): Promise { // instead of minting parallel grants. const opening = new Map>() -export function openCloudWorkspace(workspace: CloudWorkspace): Promise { +export function openCloudWorkspace( + workspace: CloudWorkspace, + options: OpenCloudWorkspaceOptions = {} +): Promise { const existing = opening.get(workspace.id) if (existing) { return existing } - const attempt = openOnce(workspace).finally(() => opening.delete(workspace.id)) + const attempt = openOnce(workspace, options).finally(() => opening.delete(workspace.id)) opening.set(workspace.id, attempt) return attempt } -// How long Copy link waits on the control plane before settling for the sandbox-id link. -const LINK_LOOKUP_MS = 3_000 - -async function lookUpLink(id: string): Promise { - let timer: ReturnType | undefined - try { - const fetched = await Promise.race([ - window.api.cloudWorkspaces.get(id), - new Promise((resolve) => { - timer = setTimeout(() => resolve(null), LINK_LOOKUP_MS) - }) - ]) - if (!fetched?.ok) { - return null - } - rememberCloudWorkspaces([fetched.value]) - return fetched.value.link ?? null - } catch { - return null - } finally { - clearTimeout(timer) - } -} - -/** - * The org slug + W-number link when the control plane gave one, else the sandbox id form, which - * opens for anyone with access. Never rejects: a device that didn't create the workspace (paired, - * offline, signed out) still gets a working link. - */ -export async function shareLinkFor(id: string, chatId?: string | null): Promise { - const link = cloudWorkspaceIdentity(id)?.link ?? (await lookUpLink(id)) - return cloudWorkspaceShareLink({ id, ...(link ? { link } : {}) }, chatId) -} - -export async function copyCloudWorkspaceLink( - id: string, - chatId?: string | null, - options: { quiet?: boolean } = {} -): Promise { - const link = await shareLinkFor(id, chatId) - try { - // Why: main-process clipboard, like every other renderer copy. navigator.clipboard needs a - // focused document with live user activation; after the link lookup (or from the menu or a - // shortcut) Electron rejects it with "Document is not focused". - await window.api.ui.writeClipboardText(link) - } catch (error) { - console.warn('[cloud-workspaces] copy link failed', error) - toast.error( - translate('auto.components.cloudWorkspaces.copyFailed', "Couldn't copy the link. Try again.") - ) - return false - } - if (options.quiet) { - return true - } - toast.success( - chatId - ? translate('auto.components.cloudWorkspaces.chatLinkCopied', 'Chat link copied') - : translate('auto.components.cloudWorkspaces.workspaceLinkCopied', 'Workspace link copied') - ) - return true -} +// Copy link lives beside it; re-exported so callers keep one import. +export { copyCloudWorkspaceLink, shareLinkFor } from './cloud-workspace-share-link' diff --git a/src/renderer/src/components/cloud-workspaces/shared-with-team-toast.ts b/src/renderer/src/components/cloud-workspaces/shared-with-team-toast.ts new file mode 100644 index 00000000000..b40318b1ab7 --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/shared-with-team-toast.ts @@ -0,0 +1,15 @@ +import { toast } from 'sonner' +import type { Translate } from '@/i18n/use-translate' + +/** + * "Shared with your team", with the one thing that changes for the owner (SEC-5): their personal + * secrets stop being injected once teammates can open the workspace. + */ +export function toastSharedWithTeam(t: Translate): void { + toast.success(t('auto.components.cloudWorkspaces.sharedWithYourTeam', 'Shared with your team'), { + description: t( + 'auto.components.cloudWorkspaces.sharedPersonalSecretsNote', + 'Your personal cloud secrets aren’t used in shared workspaces. Shared and repository secrets still are.' + ) + }) +} diff --git a/src/renderer/src/components/cloud-workspaces/use-cloud-archived-count.ts b/src/renderer/src/components/cloud-workspaces/use-cloud-archived-count.ts deleted file mode 100644 index be675548c01..00000000000 --- a/src/renderer/src/components/cloud-workspaces/use-cloud-archived-count.ts +++ /dev/null @@ -1,123 +0,0 @@ -// How many archived cloud workspaces Home's scope has, so Home can always reach them (ARCHIVE-REACH): -// with only a few active workspaces Home hides its filter row (JA-30), and that row holds the only -// Archived switch, so archiving your only workspace used to leave nothing on Home to get it back. -// -// One `archived=only` read per account, scope and active list. When the active list changes (a -// workspace archived or unarchived, here or elsewhere), the count is read again. Otherwise a cached -// answer is reused for a few minutes, so Home's polling never doubles its reads. An archive, -// unarchive or delete from this desktop starts a fresh read at once (cloud-archive-changes): the -// active list can change before the archive commits, and its cached 0 must not stand for 5 minutes. -import { useEffect, useState, useSyncExternalStore } from 'react' -import { - cloudArchiveVersion, - subscribeCloudArchiveChanges -} from '@/lib/orca-cloud/cloud-archive-changes' - -const TTL_MS = 5 * 60_000 - -type Cached = { count: number; at: number } -const cache = new Map() -const inflight = new Map>() - -async function readArchivedCount(scope: 'team' | 'mine'): Promise { - try { - const result = await window.api.cloudWorkspaces.list({ scope, archived: 'only' }) - return result.ok ? result.value.length : null - } catch { - return null - } -} - -function load(key: string, scope: 'team' | 'mine', now: number): Promise { - const hit = cache.get(key) - if (hit && now - hit.at < TTL_MS) { - return Promise.resolve(hit.count) - } - const pending = inflight.get(key) - if (pending) { - return pending - } - const request = readArchivedCount(scope).then((count) => { - inflight.delete(key) - if (count !== null) { - cache.set(key, { count, at: Date.now() }) - } - return count - }) - inflight.set(key, request) - return request -} - -/** - * Null until known (or when the read failed): callers then keep today's behavior. `activeIds` is the - * active list Home shows; null while it loads. Disabled while the Archived switch is already on. - */ -export function useCloudArchivedCount({ - account, - scope, - activeIds, - enabled -}: { - account: string - scope: 'team' | 'mine' - activeIds: readonly string[] | null - enabled: boolean -}): number | null { - const signature = activeIds ? [...activeIds].sort().join(',') : null - const changes = useSyncExternalStore(subscribeCloudArchiveChanges, cloudArchiveVersion) - const key = JSON.stringify({ account, scope, active: signature, changes }) - const [state, setState] = useState<{ key: string; count: number | null }>({ key, count: null }) - useEffect(() => { - if (!enabled || signature === null) { - return - } - let disposed = false - void load(key, scope, Date.now()).then((count) => { - if (!disposed) { - setState({ key, count }) - } - }) - return () => { - disposed = true - } - }, [enabled, key, scope, signature]) - if (!enabled) { - return null - } - // A count read for an earlier active list still says whether archived ones exist; keep it until - // the new read lands so the switch doesn't flicker off and on. - return state.count -} - -/** - * Home's two numbers: `archivedRead` (the count while the active list shows, for the empty state) - * and `archivedCount` (the switch's label: with the switch on, the archived rows shown). - */ -export function useHomeArchivedCount({ - account, - scope, - shown, - archivedOn, - enabled -}: { - account: string - scope: 'team' | 'mine' - /** The rows Home shows now; null while they load. */ - shown: readonly { id: string }[] | null - archivedOn: boolean - enabled: boolean -}): { archivedRead: number | null; archivedCount: number | null } { - const archivedRead = useCloudArchivedCount({ - account, - scope, - activeIds: shown ? shown.map((w) => w.id) : null, - enabled: enabled && !archivedOn - }) - return { archivedRead, archivedCount: archivedOn ? (shown?.length ?? null) : archivedRead } -} - -/** Tests: forget every cached count. */ -export function resetCloudArchivedCountCache(): void { - cache.clear() - inflight.clear() -} diff --git a/src/renderer/src/components/cloud-workspaces/use-cloud-home-filters.ts b/src/renderer/src/components/cloud-workspaces/use-cloud-home-filters.ts deleted file mode 100644 index 16477c2df93..00000000000 --- a/src/renderer/src/components/cloud-workspaces/use-cloud-home-filters.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { useCallback, useState } from 'react' -import { - ANY, - INITIAL_CLOUD_WORKSPACE_FILTERS, - type CloudWorkspacesFilters -} from './CloudWorkspacesToolbar' -import { readHomeSwitches, writeHomeSwitches } from './cloud-workspaces-home-prefs' -import { usePendingHomeFilters } from './cloud-workspaces-home-nav' -import { CLOUD_ROUTINES_UI_ENABLED } from '../../../../shared/cloud-api/cloud-routines-ui' - -/** Home's list filters for this account. The three switches are remembered; owner/repo/scope start fresh. */ -export function useCloudHomeFilters(account: string) { - const initialFilters = (key: string): CloudWorkspacesFilters => ({ - ...INITIAL_CLOUD_WORKSPACE_FILTERS, - ...readHomeSwitches(key), - // A switch saved before Routines were parked must not keep listing routine runs. - ...(CLOUD_ROUTINES_UI_ENABLED ? {} : { routines: false }) - }) - const [filters, setFilters] = useState(() => initialFilters(account)) - usePendingHomeFilters(setFilters) - // Owner and repo filters name the previous team's people and repos; a team switch starts over. - const [filtersAccount, setFiltersAccount] = useState(account) - if (filtersAccount !== account) { - setFiltersAccount(account) - setFilters(initialFilters(account)) - } - const changeFilters = useCallback( - (next: Partial) => { - const switches = { - ...(next.running !== undefined ? { running: next.running } : {}), - ...(next.archived !== undefined ? { archived: next.archived } : {}), - ...(next.routines !== undefined ? { routines: next.routines } : {}) - } - if (Object.keys(switches).length > 0) { - writeHomeSwitches(account, switches) - } - setFilters((previous) => { - // A different list (scope or archived) has different owners and repos; start unfiltered. - // Routine runs only add rows, so the owner/repo picks stay. - const reset = - (next.scope !== undefined && next.scope !== previous.scope) || - (next.archived !== undefined && next.archived !== previous.archived) - return { ...previous, ...(reset ? { by: ANY, repo: ANY } : {}), ...next } - }) - }, - [account] - ) - return { filters, changeFilters } -} diff --git a/src/renderer/src/components/cloud-workspaces/use-cloud-workspace-arrangement.ts b/src/renderer/src/components/cloud-workspaces/use-cloud-workspace-arrangement.ts deleted file mode 100644 index 26059499169..00000000000 --- a/src/renderer/src/components/cloud-workspaces/use-cloud-workspace-arrangement.ts +++ /dev/null @@ -1,182 +0,0 @@ -// Home's arrangement state: groups from your synced prefs, plus the edits (pin, move, drag, -// collapse, section order) that save back to them. -import { useCallback, useEffect, useMemo, useState } from 'react' -import { toast } from 'sonner' -import { translate } from '@/i18n/i18n' -import { - formatWorkspaceNumber, - type CloudWorkspace -} from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' -import { - MAX_PINNED, - arrangeCloudWorkspaces, - dropOnto, - moveSection, - nudge, - reorderGroup, - setCollapsed, - setPinned, - type CloudWorkspaceGroup -} from '../../../../shared/cloud-workspaces/cloud-workspace-arrangement' -import { - editCloudWorkspacePrefs, - getCloudWorkspacePrefs, - refreshCloudWorkspacePrefs, - useCloudWorkspacePrefs -} from './cloud-workspace-prefs' -import type { GroupDrag } from './CloudWorkspaceGroup' -import type { RowArrangement } from './CloudWorkspaceRowMenu' -import { cloudWorkspaceTitle } from './open-cloud-workspace' - -/** How a screen reader hears a workspace in move confirmations: its number, else its title. */ -function spoken(w: CloudWorkspace | undefined): string { - return !w ? '' : w.number ? formatWorkspaceNumber(w.number) : cloudWorkspaceTitle(w) -} - -function groupName(group: CloudWorkspaceGroup): string { - return group.kind === 'pinned' - ? translate('auto.components.cloudWorkspaces.pinned', 'Pinned') - : (group.section ?? - translate('auto.components.cloudWorkspaces.otherWorkspaces', 'Other workspaces')) -} - -export function useCloudWorkspaceArrangement(visible: readonly CloudWorkspace[]) { - const prefs = useCloudWorkspacePrefs() - // Drags and menu moves are confirmed politely for screen readers ("Moved W-12 to position 2 in Pinned"). - const [announcement, setAnnouncement] = useState('') - // Opening Home is a good moment to pick up another device's edits without waiting for the poll. - useEffect(() => { - void refreshCloudWorkspacePrefs() - }, []) - const groups = useMemo( - () => arrangeCloudWorkspaces(visible, prefs.document), - [visible, prefs.document] - ) - const groupOf = useCallback( - (id: string): CloudWorkspaceGroup | undefined => - groups.find((group) => group.rows.some((w) => w.id === id)), - [groups] - ) - const reorder = useCallback( - (group: CloudWorkspaceGroup, ids: string[] | null, movedId: string) => { - if (!ids) { - return - } - void editCloudWorkspacePrefs((doc) => reorderGroup(doc, group, ids)) - setAnnouncement( - translate( - 'auto.components.cloudWorkspaces.movedTo', - 'Moved {{name}} to position {{position}} in {{group}}', - { - name: spoken(group.rows.find((w) => w.id === movedId)), - position: ids.indexOf(movedId) + 1, - group: groupName(group) - } - ) - ) - }, - [] - ) - - const [dragging, setDragging] = useState(null) - const drag = useMemo( - () => ({ - dragging, - start: (id, group) => setDragging({ id, group }), - end: () => setDragging(null), - drop: (overId, groupKey) => { - const group = groups.find((g) => g.key === groupKey) - if (dragging && group) { - reorder( - group, - dropOnto( - group.rows.map((w) => w.id), - dragging.id, - overId - ), - dragging.id - ) - } - setDragging(null) - } - }), - [dragging, groups, reorder] - ) - - const pin = useCallback((w: CloudWorkspace, pinned: boolean) => { - if (pinned && getCloudWorkspacePrefs().document.pinned.length >= MAX_PINNED) { - toast.error( - translate( - 'auto.components.cloudWorkspaces.pinLimit', - 'You can pin up to {{count}} workspaces. Unpin one first.', - { count: MAX_PINNED } - ) - ) - return - } - void editCloudWorkspacePrefs((doc) => setPinned(doc, w.id, pinned)) - setAnnouncement( - pinned - ? translate('auto.components.cloudWorkspaces.pinnedName', 'Pinned {{name}}', { - name: spoken(w) - }) - : translate('auto.components.cloudWorkspaces.unpinnedName', 'Unpinned {{name}}', { - name: spoken(w) - }) - ) - }, []) - - const move = useCallback( - (w: CloudWorkspace, delta: number) => { - const group = groupOf(w.id) - if (group) { - reorder( - group, - nudge( - group.rows.map((row) => row.id), - w.id, - delta - ), - w.id - ) - } - }, - [groupOf, reorder] - ) - - const shownSections = useMemo( - () => - groups.flatMap((group) => (group.kind === 'section' && group.section ? [group.section] : [])), - [groups] - ) - const toggleCollapsed = useCallback((section: string, collapsed: boolean) => { - void editCloudWorkspacePrefs((doc) => setCollapsed(doc, section, collapsed)) - }, []) - const moveSectionBy = useCallback( - (section: string, delta: number) => { - void editCloudWorkspacePrefs((doc) => moveSection(doc, shownSections, section, delta) ?? doc) - }, - [shownSections] - ) - - const arrangementFor = useCallback( - (group: CloudWorkspaceGroup, index: number): RowArrangement => ({ - pinned: group.kind === 'pinned', - canMoveUp: index > 0, - canMoveDown: index < group.rows.length - 1 - }), - [] - ) - - return { - announcement, - groups, - drag, - pin, - move, - toggleCollapsed, - moveSection: moveSectionBy, - shownSections, - arrangementFor - } -} diff --git a/src/renderer/src/components/cloud-workspaces/use-cloud-workspace-row-actions.tsx b/src/renderer/src/components/cloud-workspaces/use-cloud-workspace-row-actions.tsx new file mode 100644 index 00000000000..7c4b3ead91d --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/use-cloud-workspace-row-actions.tsx @@ -0,0 +1,207 @@ +// What a cloud workspace row can do from "Open cloud workspace…": open (or read an Asleep one's +// saved copy), copy its link, share or hand it over, sleep, archive and delete. Opening goes through +// openCloudWorkspace, the same path as a workspace link and a teammate's profile. +import React, { useCallback, useMemo, useState } from 'react' +import { toast } from 'sonner' +import { useTranslate } from '@/i18n/use-translate' +import { refreshCloudMessageSenders } from '@/lib/cloud-message-sender' +import type { + CloudWorkspace, + CloudWorkspacePatch, + CloudWorkspacePresence +} from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' +import { useOrgRoster } from '../billing/team/use-org-roster' +import type { MemberDirectory } from './cloud-workspace-format' +import { describeError } from './cloud-workspace-errors' +import { focusCloudChat } from './cloud-chat-focus' +import * as archive from './cloud-workspace-home-archive' +import { focusedKind, frontCloudChat } from './cloud-workspace-links' +import { copyCloudWorkspaceLink, openCloudWorkspace } from './open-cloud-workspace' +import type { RowActions } from './CloudWorkspaceRowMenu' +import { CloudWorkspaceConfirmDialog, type PendingConfirm } from './CloudWorkspaceConfirmDialog' +import { CloudReadSnapshotSheet } from './CloudReadSnapshotSheet' +import { useCloudReadPanel } from './use-cloud-read-panel' +import { toastSharedWithTeam } from './shared-with-team-toast' + +/** Names for every member id the rows show, and whether you are a team owner or admin. */ +export function useMemberDirectory( + orgId: string | undefined, + workspaces: readonly CloudWorkspace[] +): { directory: MemberDirectory; canAdmin: boolean } { + const roster = useOrgRoster(orgId ?? '') + return useMemo(() => { + const directory: MemberDirectory = new Map() + let canAdmin = false + if (orgId && roster.result?.status === 'ok') { + for (const m of roster.result.roster.members) { + if (m.userId) { + directory.set(m.userId, { name: m.displayName || m.email, email: m.email }) + } + } + canAdmin = + roster.result.roster.viewerRole === 'owner' || roster.result.roster.viewerRole === 'admin' + } + // Ids the roster does not cover (still loading, a dev org, a member who left) fall back to the + // name each member's own token carries, as the control plane reports it. + for (const w of workspaces) { + for (const [userId, name] of Object.entries(w.memberNames ?? {})) { + if (name && !directory.has(userId)) { + directory.set(userId, { name, email: null }) + } + } + } + return { directory, canAdmin } + }, [orgId, roster.result, workspaces]) +} + +export function useCloudWorkspaceRowActions({ + workspaces, + directory, + selfId, + canAdmin, + refresh, + replace, + onOpened +}: { + /** The listed rows: the read-only sheet follows its row as polling refreshes it. */ + workspaces: readonly CloudWorkspace[] + directory: MemberDirectory + selfId: string | null + canAdmin: boolean + refresh: () => void + replace: (next: CloudWorkspace) => void + /** It opened in the sidebar: the picker closes. */ + onOpened: () => void +}): { actions: RowActions; openingId: string | null; dialogs: React.JSX.Element } { + const t = useTranslate() + const [openingId, setOpeningId] = useState(null) + const [confirm, setConfirm] = useState(null) + const { readingId, readingRef, setReadingId, readWake, setReadWake } = useCloudReadPanel() + + /** Opens the workspace, then (going to a teammate's chat) the chat they have in front. */ + const open = useCallback( + async (w: CloudWorkspace, chatId?: string | null) => { + setOpeningId(w.id) + const fromPanel = readingRef.current === w.id + if (fromPanel) { + setReadWake({ phase: 'waking', startedAt: Date.now() }) + } + try { + const outcome = await openCloudWorkspace(w) + if (!outcome.ok) { + if (fromPanel && readingRef.current === w.id) { + // The panel shows the failure in place (with the reason) instead of a toast. + setReadWake({ ...outcome, phase: 'failed' }) + } else { + toast.error(outcome.message) + } + return + } + setReadingId((current) => (current === w.id ? null : current)) + onOpened() + if (chatId) { + await focusCloudChat(outcome.worktreeId, chatId) + } + } finally { + setOpeningId((current) => (current === w.id ? null : current)) + } + }, + [onOpened, readingRef, setReadWake, setReadingId] + ) + + const patch = useCallback( + async (w: CloudWorkspace, change: CloudWorkspacePatch, confirmed = false) => { + // Making private and handing over ownership take access away; both ask first. + if (!confirmed && change.visibility === 'private') { + setConfirm({ kind: 'private', workspace: w }) + return + } + if (!confirmed && change.ownerUserId) { + setConfirm({ kind: 'owner', workspace: w, ownerUserId: change.ownerUserId }) + return + } + const result = await window.api.cloudWorkspaces.patch(w.id, change) + if (!result.ok) { + toast.error(describeError(result.error, result.reason, t)) + return + } + replace(result.value) + if (change.visibility !== undefined) { + // Shared or private changes whether messages there carry the sender line. + void refreshCloudMessageSenders().catch(() => undefined) + } + if (change.visibility === 'team') { + toastSharedWithTeam(t) + } else if (change.visibility === 'private') { + toast.success( + t( + 'auto.components.cloudWorkspaces.nowPrivate', + 'Now private. Teammates’ devices were disconnected.' + ) + ) + } else if (change.ownerUserId) { + toast.success(t('auto.components.cloudWorkspaces.ownerChanged', 'Owner changed')) + } + refresh() + }, + [refresh, replace, t] + ) + + const actions = useMemo( + () => ({ + open: (w) => void open(w), + read: (w) => setReadingId(w.id), + copyLink: (w) => void copyCloudWorkspaceLink(w.id), + copyWorkspaceLink: (w) => { + const front = frontCloudChat() + if (front?.sandboxId === w.id && front.chatId) { + void copyCloudWorkspaceLink(w.id, front.chatId) + } + }, + focusedItem: (w) => { + const front = frontCloudChat() + return front?.sandboxId === w.id ? focusedKind(front.chatId) : null + }, + goToChat: (w: CloudWorkspace, member: CloudWorkspacePresence) => void open(w, member.chatId), + patch: (w, change) => void patch(w, change), + ...archive.homeArchiveActions({ selfId, t, refresh, confirmArchive: setConfirm }) + }), + [open, patch, refresh, selfId, setReadingId, t] + ) + + const dialogs = ( + <> + w.id === readingId) ?? null) : null} + wake={readWake} + onWake={(w) => void open(w)} + onClose={() => setReadingId(null)} + /> + {confirm && ( + setConfirm(null)} + onConfirm={() => { + if (confirm.kind === 'archive' || confirm.kind === 'delete') { + setConfirm(null) + archive.runConfirmed(confirm, t, refresh) + return + } + const change: CloudWorkspacePatch = + confirm.kind === 'private' + ? { visibility: 'private' } + : { ownerUserId: confirm.ownerUserId } + setConfirm(null) + void patch(confirm.workspace, change, true) + }} + /> + )} + + ) + + return { actions, openingId, dialogs } +} diff --git a/src/renderer/src/components/cloud-workspaces/use-cloud-workspaces.ts b/src/renderer/src/components/cloud-workspaces/use-cloud-workspaces.ts index 072497ba928..15a7b5a958e 100644 --- a/src/renderer/src/components/cloud-workspaces/use-cloud-workspaces.ts +++ b/src/renderer/src/components/cloud-workspaces/use-cloud-workspaces.ts @@ -7,6 +7,7 @@ import type { import { availabilityRetryDelay } from '@/lib/availability-retry' import { installWindowVisibilityTimeoutPoller } from '@/lib/window-activity/window-visibility-timeout-poller' import { rememberCloudWorkspaces } from './cloud-workspace-identity' +import { useAppStore } from '@/store' /** Presence is live; the list re-reads on this beat while visible (and on focus). */ const POLL_MS = 8_000 @@ -84,6 +85,12 @@ export function useCloudWorkspacesAvailable(): boolean | null { ) } +/** Signed in to Orca and this build has a control plane: cloud workspaces can be listed and opened. */ +export function useCloudWorkspacesPresent(): boolean { + const signedIn = useAppStore((s) => Boolean(s.orcaProfileAuthStatus?.cloud)) + return useCloudWorkspacesAvailable() === true && signedIn +} + /** * `account` is the signed-in `${userId}:${activeOrgId}`: it is part of the read's key, so a team * switch re-reads at once and a list read for the previous team is never shown or kept. diff --git a/src/renderer/src/components/cmd-j/open-cloud-workspace-quick-action.test.ts b/src/renderer/src/components/cmd-j/open-cloud-workspace-quick-action.test.ts new file mode 100644 index 00000000000..0af12d8ad3f --- /dev/null +++ b/src/renderer/src/components/cmd-j/open-cloud-workspace-quick-action.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest' +import type { CmdJQuickActionContext } from './quick-action-context' +import { getOpenCloudWorkspaceQuickAction } from './open-cloud-workspace-quick-action' + +function context(overrides: Partial = {}): CmdJQuickActionContext { + return { + activeView: 'terminal', + activeWorktreeId: null, + activeWorktree: null, + isLoading: false, + sshStatus: null, + runtimeMode: 'local-desktop', + activeGroupId: null, + openNewBrowserTab: async () => {}, + openBrowserUrl: async () => {}, + openNewMarkdownFile: async () => {}, + openNewTerminalTab: async () => {}, + openCreateWorkspace: () => {}, + deleteActiveWorkspace: () => {}, + openAddQuickCommand: () => {}, + openCloudWorkspacePicker: vi.fn(), + ...overrides + } +} + +describe('Open cloud workspace… quick action', () => { + const action = getOpenCloudWorkspaceQuickAction() + + it('opens the picker, with no workspace active (it is how you get to one)', async () => { + const ctx = context() + expect(action.isAvailable(ctx)).toEqual({ available: true }) + await expect(action.run(ctx)).resolves.toEqual({ status: 'ok' }) + expect(ctx.openCloudWorkspacePicker).toHaveBeenCalledTimes(1) + }) + + it('is unavailable (so hidden) without Orca Cloud', async () => { + const ctx = context({ openCloudWorkspacePicker: undefined }) + expect(action.isAvailable(ctx)).toEqual({ available: false, reason: 'no-orca-cloud' }) + await expect(action.run(ctx)).resolves.toEqual({ + status: 'unavailable', + reason: 'no-orca-cloud' + }) + }) +}) diff --git a/src/renderer/src/components/cmd-j/open-cloud-workspace-quick-action.ts b/src/renderer/src/components/cmd-j/open-cloud-workspace-quick-action.ts new file mode 100644 index 00000000000..c3907abc4a3 --- /dev/null +++ b/src/renderer/src/components/cmd-j/open-cloud-workspace-quick-action.ts @@ -0,0 +1,35 @@ +import { Cloud } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import type { CmdJQuickAction } from './quick-actions' + +/** "Open cloud workspace…": a teammate's shared one, or one started on another computer. */ +export function getOpenCloudWorkspaceQuickAction(): CmdJQuickAction { + return { + id: 'open-cloud-workspace', + kind: 'action', + title: translate('components.cloudWorkspaces.picker.menuItem', 'Open cloud workspace…'), + description: translate( + 'components.cloudWorkspaces.picker.paletteDescription', + 'Open a cloud workspace that isn’t in your sidebar yet.' + ), + icon: Cloud, + verbKeywords: [ + translate('components.cloudWorkspaces.picker.verbs.cloudWorkspace', 'cloud workspace'), + translate('components.cloudWorkspaces.picker.verbs.sharedWorkspace', 'shared workspace'), + translate('components.cloudWorkspaces.picker.verbs.teammate', 'teammate'), + translate('components.cloudWorkspaces.picker.verbs.orcaCloud', 'orca cloud') + ], + // Unavailable actions are left out of the palette, so this stays hidden without Orca Cloud. + isAvailable: (ctx) => + ctx.openCloudWorkspacePicker + ? { available: true } + : { available: false, reason: 'no-orca-cloud' }, + run: async (ctx) => { + if (!ctx.openCloudWorkspacePicker) { + return { status: 'unavailable', reason: 'no-orca-cloud' } + } + ctx.openCloudWorkspacePicker() + return { status: 'ok' } + } + } +} diff --git a/src/renderer/src/components/cmd-j/quick-action-context.ts b/src/renderer/src/components/cmd-j/quick-action-context.ts index 11d6d750060..a6fe0b636f8 100644 --- a/src/renderer/src/components/cmd-j/quick-action-context.ts +++ b/src/renderer/src/components/cmd-j/quick-action-context.ts @@ -23,6 +23,7 @@ export type CmdJUnavailableReason = | 'no-active-chat' | 'client-action-unsupported' | 'not-cloud-workspace' + | 'no-orca-cloud' export type CmdJQuickActionAvailability = | { available: true } @@ -52,6 +53,8 @@ export type CmdJQuickActionContext = { /** The active workspace's Orca Cloud environment; null for every other workspace. */ activeCloudEnvironmentId?: string | null openCloudRemoteDesktop?: (worktreeId: string, environmentId: string) => void + /** Set only while signed in to Orca Cloud: "Open cloud workspace…". */ + openCloudWorkspacePicker?: () => void canSplitActiveChat?: boolean splitActiveChat?: (direction: NativeChatSplitDirection) => boolean } @@ -167,6 +170,7 @@ export function buildCmdJQuickActionContext(args: { deleteActiveWorkspace: () => void openAddQuickCommand: () => void openCloudRemoteDesktop?: (worktreeId: string, environmentId: string) => void + openCloudWorkspacePicker?: () => void }): CmdJQuickActionContext { const activeWorktreeId = args.state.activeWorktreeId const activeWorktree = activeWorktreeId @@ -214,6 +218,7 @@ export function buildCmdJQuickActionContext(args: { ? activeEnvironmentId : null, openCloudRemoteDesktop: args.openCloudRemoteDesktop, + openCloudWorkspacePicker: args.openCloudWorkspacePicker, canSplitActiveChat: canRunNativeChatSplitTarget(args.state, activeChatTarget), splitActiveChat: (direction) => runActiveNativeChatSplit(activeWorktreeId, activeGroupId, direction) @@ -263,6 +268,12 @@ export function getUnavailableQuickActionMessage( 'Can’t run “{{action}}” — the active workspace isn’t a cloud workspace.', options ) + case 'no-orca-cloud': + return translate( + 'auto.components.cmdJ.quickActionUnavailable.noOrcaCloud', + 'Can’t run “{{action}}” — sign in to Orca Cloud first.', + options + ) case 'client-action-unsupported': return translate( 'auto.components.cmdJ.quickActionUnavailable.clientUnsupported', diff --git a/src/renderer/src/components/cmd-j/quick-actions.ts b/src/renderer/src/components/cmd-j/quick-actions.ts index 2f0a45ec4e7..21e04f4f469 100644 --- a/src/renderer/src/components/cmd-j/quick-actions.ts +++ b/src/renderer/src/components/cmd-j/quick-actions.ts @@ -20,6 +20,7 @@ import { translate } from '@/i18n/i18n' import { createLocalizedCatalog } from '@/i18n/localized-catalog' import { getNativeChatSplitQuickActions } from './native-chat-split-quick-actions' import { getRemoteDesktopQuickAction } from './remote-desktop-quick-action' +import { getOpenCloudWorkspaceQuickAction } from './open-cloud-workspace-quick-action' export type CmdJQuickActionRunResult = | { status: 'ok' } @@ -179,6 +180,7 @@ export const getCmdJQuickActions = createLocalizedCatalog((): CmdJQuickAction[] }, ...getNativeChatSplitQuickActions(), getRemoteDesktopQuickAction(), + getOpenCloudWorkspaceQuickAction(), { id: CREATE_WORKSPACE_QUICK_ACTION_ID, kind: 'action', diff --git a/src/renderer/src/components/landing-layout.fixture.tsx b/src/renderer/src/components/landing-layout.fixture.tsx deleted file mode 100644 index 8ce7e2cb29f..00000000000 --- a/src/renderer/src/components/landing-layout.fixture.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { defineFixture } from '../component-fixtures/component-fixture' -import { LandingActions } from './landing-layout' - -const noop = (): void => {} - -function row(width: number): React.JSX.Element { - return ( -
    -
    - -
    -
    -
    - ) -} - -/** JA-30: over the cloud list in a wide centre column, the full-size top row. */ -export const actionsOverCloudListWide = defineFixture({ width: 800, render: () => row(768) }) - -/** JA-30: a narrow centre column (both side panels open): small buttons, still one row. */ -export const actionsOverCloudListNarrow = defineFixture({ width: 580, render: () => row(540) }) diff --git a/src/renderer/src/components/landing-layout.tsx b/src/renderer/src/components/landing-layout.tsx index bbbeb5f8c3c..66083eebee1 100644 --- a/src/renderer/src/components/landing-layout.tsx +++ b/src/renderer/src/components/landing-layout.tsx @@ -1,5 +1,3 @@ -import { FolderPlus, GitBranchPlus } from 'lucide-react' -import { cn } from '@/lib/class-names' import { ShortcutKeyCombo } from './ShortcutKeyCombo' import type { ShortcutKeyComboDetails } from '@/hooks/useShortcutLabel' import logo from '../../../../resources/logo.svg' @@ -14,30 +12,21 @@ export type LandingShortcutItem = { export const LANDING_ACTION_BUTTON_CLASS = 'inline-flex items-center gap-1.5 bg-secondary/70 border border-border/80 text-foreground font-medium text-sm px-4 py-2 rounded-md cursor-pointer hover:bg-accent transition-colors' -/** `compact` when Home also lists cloud workspaces, so the list starts higher on the page. */ -export function LandingBrand({ compact = false }: { compact?: boolean }): React.JSX.Element { +export function LandingBrand(): React.JSX.Element { const t = useTranslate() return ( <>
    {t('auto.components.Landing.520304a067',
    -

    +

    {t('auto.components.Landing.6ca6ff404e', 'ORCA')}

    @@ -64,44 +53,3 @@ export function LandingShortcutList({
    ) } - -/** - * Home's two primary actions. Over the cloud list they sit as a top row aligned with it; in a - * narrow centre column (both side panels open) they shrink and stay on one row instead of - * stacking large buttons (JA-30). Needs an `@container` ancestor for the narrow case. - */ -export function LandingActions({ - overCloudList, - addProjectLabel, - createLabel, - onAddProject, - onCreate -}: { - overCloudList: boolean - addProjectLabel: string - createLabel: string - onAddProject: () => void - onCreate: () => void -}): React.JSX.Element { - const buttonClass = cn( - LANDING_ACTION_BUTTON_CLASS, - overCloudList && '@max-[600px]:px-2.5 @max-[600px]:py-1 @max-[600px]:text-xs' - ) - return ( -
    - - -
    - ) -} diff --git a/src/renderer/src/components/landing/LandingAddProjectMenu.test.tsx b/src/renderer/src/components/landing/LandingAddProjectMenu.test.tsx new file mode 100644 index 00000000000..e4a6c7e0220 --- /dev/null +++ b/src/renderer/src/components/landing/LandingAddProjectMenu.test.tsx @@ -0,0 +1,96 @@ +// @vitest-environment happy-dom +// Landing's Add project is the sidebar's Add project menu (not the old modal), and both carry +// "Open cloud workspace…" while signed in to Orca Cloud. +import { cleanup, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import { useAppStore } from '@/store' +import { getDefaultSettings } from '../../../../shared/constants' +import { LandingAddProjectMenu } from './LandingAddProjectMenu' +import { SidebarHeaderActions } from '../sidebar/sidebar-header-actions' +import { + closeCloudWorkspacePicker, + getCloudWorkspacePickerRequest +} from '../cloud-workspaces/cloud-workspace-picker-store' + +const cloud = vi.hoisted(() => ({ present: true })) +vi.mock('@/components/cloud-workspaces/use-cloud-workspaces', () => ({ + useCloudWorkspacesPresent: () => cloud.present +})) +vi.mock('@/components/sidebar/host-admission/ssh/AddRemoteHostDialog', () => ({ + AddRemoteHostDialog: () => null +})) + +const initialState = useAppStore.getState() + +beforeEach(() => { + cloud.present = true + useAppStore.setState({ + activeModal: 'none', + modalData: {}, + settings: getDefaultSettings('/tmp/orca-test') + }) +}) + +afterEach(() => { + cleanup() + closeCloudWorkspacePicker() + useAppStore.setState(initialState, true) +}) + +function renderLanding(): void { + render( + + + + + + ) +} + +describe('Landing Add project', () => { + it('opens the Add project menu, not the old add-project modal', async () => { + const user = userEvent.setup() + renderLanding() + await user.click(screen.getByRole('button', { name: 'Add project' })) + expect(await screen.findByRole('menuitem', { name: /Browse folder/ })).toBeTruthy() + expect(screen.getByRole('menuitem', { name: /Create new project/ })).toBeTruthy() + expect(useAppStore.getState().activeModal).toBe('none') + + await user.click(screen.getByRole('menuitem', { name: /Clone from URL/ })) + expect(useAppStore.getState().activeModal).toBe('add-repo') + expect(useAppStore.getState().modalData.entryRequest).toMatchObject({ kind: 'clone' }) + }) + + it('with Orca Cloud, "Open cloud workspace…" opens the picker', async () => { + const user = userEvent.setup() + renderLanding() + await user.click(screen.getByRole('button', { name: 'Add project' })) + await user.click(await screen.findByRole('menuitem', { name: 'Open cloud workspace…' })) + expect(getCloudWorkspacePickerRequest()?.focus).toEqual({ kind: 'open' }) + }) + + it('without Orca Cloud, the menu has no cloud entry', async () => { + cloud.present = false + const user = userEvent.setup() + renderLanding() + await user.click(screen.getByRole('button', { name: 'Add project' })) + await screen.findByRole('menuitem', { name: /Browse folder/ }) + expect(screen.queryByRole('menuitem', { name: 'Open cloud workspace…' })).toBeNull() + }) +}) + +describe('sidebar Add project', () => { + it('carries the same "Open cloud workspace…" entry', async () => { + const user = userEvent.setup() + render( + + + + ) + await user.click(screen.getByRole('button', { name: 'Add project' })) + await user.click(await screen.findByRole('menuitem', { name: 'Open cloud workspace…' })) + expect(getCloudWorkspacePickerRequest()?.focus).toEqual({ kind: 'open' }) + }) +}) diff --git a/src/renderer/src/components/landing/LandingAddProjectMenu.tsx b/src/renderer/src/components/landing/LandingAddProjectMenu.tsx new file mode 100644 index 00000000000..e06f015b766 --- /dev/null +++ b/src/renderer/src/components/landing/LandingAddProjectMenu.tsx @@ -0,0 +1,33 @@ +// Landing's "Add project" opens the same Add project menu as the sidebar's folder button (browse, +// clone, create, a project host, and "Open cloud workspace…" with Orca Cloud), not the old modal. +import React, { useCallback } from 'react' +import { useAppStore } from '@/store' +import { AddProjectMenu } from '@/components/sidebar/project-admission/menu/AddProjectMenu' +import type { AddRepoDialogEntryRequest } from '@/components/sidebar/project-admission/dialog/add-repo-dialog-types' +import { useOpenCloudWorkspaceMenuItems } from '@/components/cloud-workspaces/OpenCloudWorkspaceMenuItem' + +export function LandingAddProjectMenu({ + children +}: { + /** The trigger: Landing's own button, so its look stays as upstream has it. */ + children: React.ReactNode +}): React.JSX.Element { + const openModal = useAppStore((s) => s.openModal) + const cloudItems = useOpenCloudWorkspaceMenuItems() + const handleOpenEntryRequest = useCallback( + (entryRequest: AddRepoDialogEntryRequest) => { + openModal('add-repo', { entryRequest }) + }, + [openModal] + ) + return ( + + {children} + + ) +} diff --git a/src/renderer/src/components/landing/LandingCloudCard.tsx b/src/renderer/src/components/landing/LandingCloudCard.tsx index 5e52ddb437b..7a70ff68e31 100644 --- a/src/renderer/src/components/landing/LandingCloudCard.tsx +++ b/src/renderer/src/components/landing/LandingCloudCard.tsx @@ -33,7 +33,7 @@ export function LandingCloudCard({ cloudWorkspacesPresent, accessOverride }: { - /** The account already has cloud workspaces listed below: the card has done its job. */ + /** Signed in with Orca Cloud: the account can already open cloud workspaces, so no pitch. */ cloudWorkspacesPresent: boolean /** Fixtures pin the account's access. */ accessOverride?: OrcaCloudRunTargetAccess diff --git a/src/renderer/src/components/landing/LandingCloudSignIn.fixture.tsx b/src/renderer/src/components/landing/LandingCloudSignIn.fixture.tsx index 80c3a444514..df7bf325177 100644 --- a/src/renderer/src/components/landing/LandingCloudSignIn.fixture.tsx +++ b/src/renderer/src/components/landing/LandingCloudSignIn.fixture.tsx @@ -1,11 +1,13 @@ -// V3-08: Home signed out of Orca. Cloud builds with projects show the "Cloud workspaces · Sign in" -// card where the list would be; without projects, and in local builds, Home is unchanged. +// V3-08: Landing signed out of Orca. Cloud builds with projects show the "Cloud workspaces · Sign in" +// card; without projects, and in local builds, Landing is unchanged. Signed in, Landing is +// upstream's, and Add project opens the sidebar's Add project menu. import type { Repo } from '../../../../shared/repo-types' import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles' import { defineFixture } from '../../component-fixtures/component-fixture' import { resetCloudWorkspacesAvailabilityForFixtures } from '../cloud-workspaces/use-cloud-workspaces' import { makeRepo } from '../worktree-jump/worktree-jump-palette-test-fixtures' import Landing from '../Landing' +import { AUTH_STATUS } from '../cloud-workspaces/cloud-workspaces.test-fixture' const repo: Repo = { ...makeRepo(), displayName: 'orca' } const auth = ( @@ -20,7 +22,7 @@ const build = (cloud: boolean) => (): void => { window.api.cloudWorkspaces = { ...window.api.cloudWorkspaces, available: async () => cloud } resetCloudWorkspacesAvailabilityForFixtures() } -// A healthy machine, so the shots show only Home's cloud area. +// A healthy machine, so the shots show only Landing's cloud area. const healthy = { preflightStatus: { git: { installed: true }, @@ -60,6 +62,38 @@ export const cloudSignedOutNoProjects = defineFixture({ render: home }) +/** Cloud build, signed in, has projects: Landing as upstream has it, no cloud list. */ +export const cloudSignedInWithProjects = defineFixture({ + width: 760, + setup: build(true), + store: { ...healthy, repos: [repo], orcaProfileAuthStatus: AUTH_STATUS }, + render: home +}) + +/** Signed in: Add project opens the sidebar's Add project menu, with "Open cloud workspace…". */ +export const addProjectMenuSignedIn = defineFixture({ + width: 760, + setup: build(true), + store: { ...healthy, repos: [repo], orcaProfileAuthStatus: AUTH_STATUS }, + render: home, + interact: async (frame) => { + const button = [...frame.querySelectorAll('button')].find( + (b) => b.textContent?.trim() === 'Add project' + ) + // Wait for the cloud availability probe so the Orca Cloud group is in the menu. + await new Promise((resolve) => setTimeout(resolve, 150)) + button?.dispatchEvent( + new PointerEvent('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + pointerType: 'mouse' + }) + ) + await new Promise((resolve) => setTimeout(resolve, 150)) + } +}) + /** Local build with projects: unchanged, no cloud card. */ export const localBuildWithProjects = defineFixture({ width: 760, diff --git a/src/renderer/src/components/landing/LandingCloudSignInCard.tsx b/src/renderer/src/components/landing/LandingCloudSignInCard.tsx index 1885daed00e..82accf4815a 100644 --- a/src/renderer/src/components/landing/LandingCloudSignInCard.tsx +++ b/src/renderer/src/components/landing/LandingCloudSignInCard.tsx @@ -1,6 +1,5 @@ -// V3-08: signed out of Orca in a build that offers Orca Cloud, Home says where the team's cloud -// workspaces went and how to see them, in the place the cloud list would be. Local builds and -// signed-in people never see it. While it shows, it stands in for the trial card (one cloud card). +// V3-08: signed out of Orca in a build that offers Orca Cloud, Landing says where the team's +// cloud workspaces went and how to see them. Local builds and signed-in people never see it. While it shows, it stands in for the trial card (one cloud card). import React from 'react' import { Cloud } from 'lucide-react' import { useAppStore } from '@/store' diff --git a/src/renderer/src/components/new-workspace/ProjectComboboxCloudAddProjectEntry.tsx b/src/renderer/src/components/new-workspace/ProjectComboboxCloudAddProjectEntry.tsx index 55d5e1cf011..44ba26c0267 100644 --- a/src/renderer/src/components/new-workspace/ProjectComboboxCloudAddProjectEntry.tsx +++ b/src/renderer/src/components/new-workspace/ProjectComboboxCloudAddProjectEntry.tsx @@ -1,20 +1,12 @@ // PROJ-MENU part 2: "Give Orca Cloud access to more repositories" moves out of the project list into // the Add project menu, in its own "Orca Cloud" group: "Add a GitHub repository…". import React from 'react' -import { Github, Link2 } from 'lucide-react' +import { Github } from 'lucide-react' import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu' import { useTranslate } from '@/i18n/use-translate' -import { copyExternalLink } from '@/components/open-external/copy-external-link' -import { gitHubInstallLinkSource } from '@/lib/orca-cloud/github-link-sources' import type { ProjectComboboxAddProjectMenu } from './ProjectComboboxAddProjectOption' -function FromGitHubItem({ - onSelect, - onCopyLink -}: { - onSelect: () => void - onCopyLink: () => void -}): React.JSX.Element { +function FromGitHubItem({ onSelect }: { onSelect: () => void }): React.JSX.Element { const t = useTranslate() return ( <> @@ -33,13 +25,6 @@ function FromGitHubItem({ {t('components.newWorkspace.cloudOnly.addRepositoryMenu', 'Add a GitHub repository…')}
    - {/* A menu row can't carry a chevron: the same GitHub page, copied for another browser. */} - - - - {t('components.newWorkspace.cloudOnly.copyAddRepositoryLink', 'Copy GitHub link')} - - ) } @@ -60,10 +45,6 @@ export function cloudAddProjectExtraItems( closeMenus() onMoreCloudAccess() }} - onCopyLink={() => { - closeMenus() - void copyExternalLink(gitHubInstallLinkSource()) - }} /> ) } diff --git a/src/renderer/src/components/new-workspace/run-on/CloudStartSurfaces.fixture.tsx b/src/renderer/src/components/new-workspace/run-on/CloudStartSurfaces.fixture.tsx index 9c69db0f2a4..ecaaa80d7d2 100644 --- a/src/renderer/src/components/new-workspace/run-on/CloudStartSurfaces.fixture.tsx +++ b/src/renderer/src/components/new-workspace/run-on/CloudStartSurfaces.fixture.tsx @@ -1,8 +1,8 @@ -// Cloud-start surfaces outside the composer (spec v2): the Landing trial card (E2), the empty Cloud -// workspaces button (E3), the build sheet's hours chip (A7, O4) and the trial-used-up notice (T4). +// Cloud-start surfaces outside the composer (spec v2): the Landing trial card (E2), the empty "Open +// cloud workspace…" list's button (E3), the build sheet's hours chip (A7, O4) and the trial-used-up notice (T4). import { defineFixture } from '../../../component-fixtures/component-fixture' import { LandingCloudCard } from '../../landing/LandingCloudCard' -import { EmptyState } from '../../cloud-workspaces/CloudWorkspacesStates' +import { PickerEmptyState } from '../../cloud-workspaces/CloudWorkspacesStates' import { OrcaCloudHoursChip } from './OrcaCloudHoursChip' import { OrcaCloudTrialUsedUpNotice } from './OrcaCloudTrialUsedUpNotice' @@ -28,11 +28,11 @@ export const e2LandingTrialCard = defineFixture({
) }) -/** E3: Cloud workspaces empty (Mine) with a real button. */ +/** E3: "Open cloud workspace…" with none at all, with a real button. */ export const e3CloudHomeEmpty = defineFixture({ width: 560, setup, - render: () => + render: () => {}} /> }) /** A7: the build sheet header chip while a trial start spins up. */ export const a7BuildSheetChip = defineFixture({ diff --git a/src/renderer/src/components/new-workspace/run-on/use-cloud-start-steps.ts b/src/renderer/src/components/new-workspace/run-on/use-cloud-start-steps.ts index df7f18d4054..23aadd91bce 100644 --- a/src/renderer/src/components/new-workspace/run-on/use-cloud-start-steps.ts +++ b/src/renderer/src/components/new-workspace/run-on/use-cloud-start-steps.ts @@ -78,7 +78,7 @@ export function useCloudStartSteps({ onGranted: () => grantedRef.current?.(repoNameRef.current), ...(setupSessionId ? { setupSessionId } : {}) }) - const { state, access: grant, repoName, owner, loading, openInstall, installLink } = repoAccess + const { state, access: grant, repoName, owner, loading, openInstall } = repoAccess useEffect(() => { repoNameRef.current = repoName }, [repoName]) @@ -120,5 +120,5 @@ export function useCloudStartSteps({ ), [access, agentRow, availability, github, githubOverride, session, state] ) - return { steps, session, openInstall, installLink } + return { steps, session, openInstall } } diff --git a/src/renderer/src/components/open-external/OpenExternalSplitButton.fixture.tsx b/src/renderer/src/components/open-external/OpenExternalSplitButton.fixture.tsx new file mode 100644 index 00000000000..2a939b4ced0 --- /dev/null +++ b/src/renderer/src/components/open-external/OpenExternalSplitButton.fixture.tsx @@ -0,0 +1,39 @@ +// The chevron's "Copy link" menu: a loopback sign-in link says where it works (muted second line); +// a link that works anywhere (billing, GitHub, help) doesn't. +import { ExternalLink } from 'lucide-react' +import { defineFixture } from '../../component-fixtures/component-fixture' +import { signInLinkHint } from '../orca-sign-in-link' +import { OpenExternalSplitButton } from './OpenExternalSplitButton' + +function menuOpen(label: string, link: string, hint?: string) { + return defineFixture({ + width: 360, + render: () => ( +
+ undefined} + {...(hint ? { copyHint: hint } : {})} + > + {label} + + +
+ ), + interact: async (frame) => { + frame + .querySelector('[data-open-external-chevron]') + ?.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0 })) + await new Promise((resolve) => setTimeout(resolve, 200)) + } + }) +} + +/** Orca sign-in (loopback): "Copy link" with "Open it in any browser on this computer." under it. */ +export const signInLinkMenu = menuOpen( + 'Sign in to Orca', + 'http://127.0.0.1:5555/auth?state=s', + signInLinkHint() +) +/** A link that works anywhere: just "Copy link". */ +export const plainLinkMenu = menuOpen('Open billing portal', 'https://billing.example/portal') diff --git a/src/renderer/src/components/open-external/OpenExternalSplitButton.test.tsx b/src/renderer/src/components/open-external/OpenExternalSplitButton.test.tsx index e1672521d1a..5464302fc61 100644 --- a/src/renderer/src/components/open-external/OpenExternalSplitButton.test.tsx +++ b/src/renderer/src/components/open-external/OpenExternalSplitButton.test.tsx @@ -56,6 +56,28 @@ describe('OpenExternalSplitButton', () => { ) }) + it('a loopback sign-in link says where it works, in the menu and once copied', async () => { + render( + + Sign in + + ) + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: 'More link options' })) + const item = await screen.findByRole('menuitem', { name: /Copy link/ }) + expect(item.textContent).toContain('Open it in any browser on this computer.') + await user.click(item) + await waitFor(() => + expect(toast.success).toHaveBeenCalledWith('Link copied', { + description: 'Open it in any browser on this computer.' + }) + ) + }) + it('mints a lazy URL the same way on Copy link, then copies it without opening', async () => { const onOpen = vi.fn() const mint = vi.fn(async () => 'https://billing.stripe.com/p/session/test_123') diff --git a/src/renderer/src/components/open-external/OpenExternalSplitButton.tsx b/src/renderer/src/components/open-external/OpenExternalSplitButton.tsx index 88075711253..507deadf84b 100644 --- a/src/renderer/src/components/open-external/OpenExternalSplitButton.tsx +++ b/src/renderer/src/components/open-external/OpenExternalSplitButton.tsx @@ -29,12 +29,18 @@ export type OpenExternalSplitButtonProps = Omit) => void /** The wrapper (place it in the parent's layout); `className` styles the main button. */ groupClassName?: string + /** + * Where the copied link works, when that's limited: a muted second line under "Copy link" and + * the copied toast's description. Only loopback sign-in links set it (`SIGN_IN_LINK_HINT`). + */ + copyHint?: string } export function OpenExternalSplitButton({ link, onClick, groupClassName, + copyHint, className, variant, size, @@ -61,7 +67,7 @@ export function OpenExternalSplitButton({ return } setState('copying') - const result = await copyExternalLink(link) + const result = await copyExternalLink(link, copyHint) if (!mounted.current) { return } @@ -138,7 +144,14 @@ export function OpenExternalSplitButton({ void copy()}> - {copyLabel} + {copyHint ? ( + + {copyLabel} + {copyHint} + + ) : ( + copyLabel + )} diff --git a/src/renderer/src/components/open-external/copy-external-link.ts b/src/renderer/src/components/open-external/copy-external-link.ts index 5316dd6e32b..3df47a48df8 100644 --- a/src/renderer/src/components/open-external/copy-external-link.ts +++ b/src/renderer/src/components/open-external/copy-external-link.ts @@ -21,7 +21,9 @@ export function requireLink(url: string | null): string { export type CopyExternalLinkResult = 'copied' | 'no-link' | 'failed' export async function copyExternalLink( - source: ExternalLinkSource + source: ExternalLinkSource, + /** A limit worth saying once it's copied (only loopback sign-in links: "on this computer"). */ + hint?: string ): Promise { let url: string | null try { @@ -43,6 +45,11 @@ export async function copyExternalLink( toast.error(translate('openExternal.copyLinkFailed', 'Couldn’t copy the link.')) return 'failed' } - toast.success(translate('openExternal.linkCopied', 'Link copied')) + const copied = translate('openExternal.linkCopied', 'Link copied') + if (hint) { + toast.success(copied, { description: hint }) + } else { + toast.success(copied) + } return 'copied' } diff --git a/src/renderer/src/components/open-external/copy-link-auth-only.test.tsx b/src/renderer/src/components/open-external/copy-link-auth-only.test.tsx new file mode 100644 index 00000000000..d47e57d793d --- /dev/null +++ b/src/renderer/src/components/open-external/copy-link-auth-only.test.tsx @@ -0,0 +1,102 @@ +// @vitest-environment happy-dom +// AUTH-ONLY-COPY-LINK: "Copy link" (the split's chevron) is for sign-in pages only, where people paste +// the URL into an incognito window. Billing, help, support and the GitHub App install keep a plain +// button, and nothing adds a "Copy … link" row next to them. +import { cleanup, render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import { PaymentIssueNotice } from '@/components/billing/PaymentIssueNotice' +import { sampleTeam } from '@/components/billing/plan-billing-fixtures' +import { + FailureActionRow, + type VmBuildFailureActions +} from '@/components/worktree-creation/VmBuildFailureActions' +import { translate } from '@/i18n/i18n' +import { OpenExternalSplitButton } from './OpenExternalSplitButton' + +afterEach(cleanup) + +const chevrons = (): number => document.querySelectorAll('[data-open-external-chevron]').length + +const actions: VmBuildFailureActions = { + onFixWithAgent: vi.fn(), + onRetry: vi.fn(), + onEditRecipe: vi.fn(), + onOpenAnyway: vi.fn(), + onStartAgain: vi.fn(), + onDismiss: vi.fn(), + onSeePlans: vi.fn(), + onOpenBilling: vi.fn(), + onCreateLocally: vi.fn(), + onSignIn: vi.fn(), + onShowWorkspaces: vi.fn(), + onContactSupport: vi.fn(), + onConnectGitHub: vi.fn(), + onPushToGitHub: vi.fn() +} + +function failureRow(kind: 'githubNotConnected' | 'noGithubRemote' | 'snapshotMissing'): void { + render( + + + + ) +} + +it('keeps the chevron on a sign-in button', () => { + render( + {}}> + Open again + + ) + expect(chevrons()).toBe(1) +}) + +it('"Update payment method" is a plain button', () => { + render( + {}} + /> + ) + expect(screen.getByRole('button', { name: /Update payment method/ })).toBeTruthy() + expect(chevrons()).toBe(0) +}) + +it('Connect GitHub (App install) and "How to push to GitHub" are plain buttons', () => { + failureRow('githubNotConnected') + expect(screen.getByRole('button', { name: /Connect GitHub/ })).toBeTruthy() + expect(chevrons()).toBe(0) + cleanup() + failureRow('noGithubRemote') + expect(screen.getByRole('button', { name: /How to push to GitHub/ })).toBeTruthy() + expect(chevrons()).toBe(0) +}) + +it('"Contact support" has no "Copy support link" row beside it', async () => { + failureRow('snapshotMissing') + const more = screen.queryAllByRole('button').find((b) => b.getAttribute('aria-haspopup')) + expect(more).toBeTruthy() + await userEvent.setup().click(more!) + const menu = await screen.findByRole('menu') + const items = within(menu) + .getAllByRole('menuitem') + .map((item) => item.textContent?.trim()) + expect(items.some((label) => label?.includes('Contact support'))).toBe(true) + expect(items.some((label) => /copy/i.test(label ?? ''))).toBe(false) +}) diff --git a/src/renderer/src/components/orca-cloud-setup/CloudCapabilityOffActions.tsx b/src/renderer/src/components/orca-cloud-setup/CloudCapabilityOffActions.tsx index cb02a34cb8d..a8ae019622c 100644 --- a/src/renderer/src/components/orca-cloud-setup/CloudCapabilityOffActions.tsx +++ b/src/renderer/src/components/orca-cloud-setup/CloudCapabilityOffActions.tsx @@ -1,7 +1,6 @@ import React from 'react' import { ExternalLink } from 'lucide-react' import { Button } from '@/components/ui/button' -import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open' import { useTranslate } from '@/i18n/use-translate' @@ -24,14 +23,10 @@ export function CloudCapabilityOffActions({ {t('cloudSetupPage.capability.tryAgain', 'Try again')} {contactUrl ? ( - openAppHttpLink(contactUrl)} - > + ) : null}
) diff --git a/src/renderer/src/components/orca-cloud-setup/CloudSetupBrowserButton.tsx b/src/renderer/src/components/orca-cloud-setup/CloudSetupBrowserButton.tsx index 9c6c4706d8e..e73be4ecfbc 100644 --- a/src/renderer/src/components/orca-cloud-setup/CloudSetupBrowserButton.tsx +++ b/src/renderer/src/components/orca-cloud-setup/CloudSetupBrowserButton.tsx @@ -4,23 +4,60 @@ import type React from 'react' import { ExternalLink, LoaderCircle } from 'lucide-react' import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' import type { ExternalLinkSource } from '@/components/open-external/copy-external-link' +import { signInLinkHint } from '../orca-sign-in-link' +import type { CloudSetupFlow } from './use-cloud-setup-flow' export function CloudSetupBrowserButton({ label, busy, onClick, - link + link, + copyHint }: { label: string busy: boolean onClick: () => void link?: ExternalLinkSource | null + /** A loopback sign-in link's "on this computer" line (signed out, the trip starts with sign-in). */ + copyHint?: string }): React.JSX.Element { return ( - + {busy ? : null} {label} ) } + +/** + * A step's browser button on the setup page. Signed out, the first "Continue in browser" copies the + * sign-in's loopback link, which only completes on this computer, so it says so. + */ +export function CloudSetupFlowBrowserButton({ + flow, + label, + onClick, + link +}: { + flow: Pick + label: string + onClick: () => void + link?: ExternalLinkSource | null +}): React.JSX.Element { + const signIn = flow.action === 'sign-in' && link === flow.continueLink + return ( + + ) +} diff --git a/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.fixture.tsx b/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.fixture.tsx index 590ef8896b3..929d12b28c3 100644 --- a/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.fixture.tsx +++ b/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.fixture.tsx @@ -29,14 +29,14 @@ function flow(rows: CloudSetupStep[], teamSwitch = false): CloudSetupFlow { canManageBilling: true, teamAction: teamSwitch ? switchTeamAction : null, continueInBrowser: noop, + continueLink: async () => 'https://auth.example/setup/abc', startTrial: noop, fixBilling: noop, installGitHub: noop, checkAgain: noop, contactUrl: null, reopenBrowser: noop, - reopenLink: null, - installGitHubLink: null + reopenLink: null } } diff --git a/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.tsx b/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.tsx index 8712c75a814..0ae81011e5b 100644 --- a/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.tsx +++ b/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.tsx @@ -7,7 +7,7 @@ import { ArrowLeft, Check, ExternalLink, LoaderCircle, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' import type { ExternalLinkSource } from '@/components/open-external/copy-external-link' -import { CloudSetupBrowserButton } from './CloudSetupBrowserButton' +import { CloudSetupFlowBrowserButton } from './CloudSetupBrowserButton' import { Skeleton } from '@/components/ui/skeleton' import { cn } from '@/lib/class-names' import { useTranslate } from '@/i18n/use-translate' @@ -176,9 +176,7 @@ function StepActions({ label: string, onClick: () => void, link?: ExternalLinkSource | null - ): React.JSX.Element => ( - - ) + ): React.JSX.Element => const inBrowserAction = flow.action === 'sign-in' || flow.action === 'choose-plan' || @@ -195,12 +193,13 @@ function StepActions({

{t('cloudSetupPage.signIn.didNotFinish', 'Sign-in didn’t finish. Try again.')}

- {browser(continueLabel, flow.continueInBrowser)} + {browser(continueLabel, flow.continueInBrowser, flow.continueLink)} ) : ( - browser(continueLabel, flow.continueInBrowser) + browser(continueLabel, flow.continueInBrowser, flow.continueLink) ) case 'choose-plan': + // Copy link is for sign-in only: a plan is chosen in the browser that opens. return browser(continueLabel, flow.continueInBrowser) case 'fix-billing': // A failed payment is fixed in the billing portal (browser); hours and the cap in Settings. @@ -212,7 +211,7 @@ function StepActions({ ) case 'github-install': - return browser(continueLabel, flow.installGitHub, flow.installGitHubLink) + return browser(continueLabel, flow.installGitHub) case 'start-trial': return ( + +
+ +
+ + ) +} diff --git a/src/renderer/src/components/orca-cloud/CloudPrimaryDeleteDialog.test.ts b/src/renderer/src/components/orca-cloud/CloudPrimaryDeleteDialog.test.ts index 1540ffc21f1..8cb77fa2111 100644 --- a/src/renderer/src/components/orca-cloud/CloudPrimaryDeleteDialog.test.ts +++ b/src/renderer/src/components/orca-cloud/CloudPrimaryDeleteDialog.test.ts @@ -67,7 +67,9 @@ describe('destroyCloudPrimary', () => { await destroyCloudPrimary(row) expect(destroy).not.toHaveBeenCalled() expect(toast.error).toHaveBeenCalledWith('Couldn’t delete ik-main', { - description: "Orca couldn't find this workspace's sandbox. Delete it from Cloud workspaces." + description: + "Orca couldn't find this workspace's sandbox. Find it in Open cloud workspace… to delete it.", + action: { label: 'Open cloud workspace…', onClick: expect.any(Function) } }) }) diff --git a/src/renderer/src/components/orca-cloud/CloudPrimaryDeleteDialog.tsx b/src/renderer/src/components/orca-cloud/CloudPrimaryDeleteDialog.tsx index 30525dc1b97..8644608a2de 100644 --- a/src/renderer/src/components/orca-cloud/CloudPrimaryDeleteDialog.tsx +++ b/src/renderer/src/components/orca-cloud/CloudPrimaryDeleteDialog.tsx @@ -1,7 +1,7 @@ // CLOUD-PRIMARY-DELETE: Delete for a cloud row that IS the cloud workspace (the sandbox's own // checkout, shown as "primary"), including paired ones this desktop did not create. A local removal // cannot delete it (Git keeps a primary checkout; a paired sandbox isn't this desktop's to destroy), -// so this is the cloud delete dialog from Home and the sidebar, acting through the control plane: +// so this is the cloud delete dialog from the sidebar, acting through the control plane: // Delete destroys the sandbox, Archive instead archives it (with Undo). import React, { useRef, useState, useSyncExternalStore } from 'react' import { toast } from 'sonner' @@ -26,14 +26,15 @@ import { subscribeCloudPrimaryDelete, type CloudPrimaryDeleteTarget } from '@/lib/orca-cloud/cloud-primary-delete-request' +import { openCloudWorkspacePicker } from '@/components/cloud-workspaces/cloud-workspace-picker-store' type Target = CloudPrimaryDeleteTarget const setRequested = requestCloudPrimaryDelete const NO_SANDBOX = (): string => translate( - 'orcaCloudWorkspace.primaryDelete.noSandbox', - "Orca couldn't find this workspace's sandbox. Delete it from Cloud workspaces." + 'orcaCloudWorkspace.primaryDelete.noSandbox2', + "Orca couldn't find this workspace's sandbox. Find it in Open cloud workspace… to delete it." ) async function withSandbox(target: Target, run: (sandboxId: string) => Promise) { @@ -43,7 +44,13 @@ async function withSandbox(target: Target, run: (sandboxId: string) => Promise openCloudWorkspacePicker() + } + } ) return } diff --git a/src/renderer/src/components/orca-cloud/CloudSetupNotices.tsx b/src/renderer/src/components/orca-cloud/CloudSetupNotices.tsx index 543eede81e2..f3f8abbef3b 100644 --- a/src/renderer/src/components/orca-cloud/CloudSetupNotices.tsx +++ b/src/renderer/src/components/orca-cloud/CloudSetupNotices.tsx @@ -17,8 +17,6 @@ import { repoSlug, runCloudSetupScript } from '@/lib/orca-cloud/cloud-setup-notice-actions' -import { gitHubInstallLinkSource } from '@/lib/orca-cloud/github-link-sources' -import type { ExternalLinkSource } from '@/components/open-external/copy-external-link' import { recheckCloudFetch } from '@/lib/orca-cloud/cloud-fetch-notice' import { runVmEnvironmentCheck } from '@/runtime/ephemeral-vm/ephemeral-vm-environment-check' import { hideCloudToast, showCloudToast, type CloudToastView } from './cloud-toast-stack' @@ -38,8 +36,6 @@ export type CloudSetupNoticeLine = { icon: React.ReactNode run: () => void pending?: boolean - /** Opens this page in the browser: "Copy link" beside it. */ - link?: ExternalLinkSource } | null } @@ -75,8 +71,7 @@ export function cloudSetupNoticeLine( ), run: handlers.install, - pending: handlers.installing, - link: gitHubInstallLinkSource({ repoUrl: record.repoUrl }) + pending: handlers.installing } } } @@ -257,8 +252,7 @@ export function cloudSetupNoticesView( label: line.action.label, icon: line.action.icon, onClick: line.action.run, - ...(line.action.pending ? { pending: true } : {}), - ...(line.action.link ? { link: line.action.link } : {}) + ...(line.action.pending ? { pending: true } : {}) } } : {}), diff --git a/src/renderer/src/components/orca-cloud/CloudWakingOverlay.tsx b/src/renderer/src/components/orca-cloud/CloudWakingOverlay.tsx index f2278a90d1f..f61996f4a66 100644 --- a/src/renderer/src/components/orca-cloud/CloudWakingOverlay.tsx +++ b/src/renderer/src/components/orca-cloud/CloudWakingOverlay.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useRef } from 'react' import { AlertTriangle, Cloud, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' +import { CloudNotPairedCard } from './CloudNotPairedCard' import { formatElapsedTime } from '@/components/worktree-creation/worktree-creation-clock' import { useNow } from '@/hooks/use-now' import { useTranslate } from '@/i18n/use-translate' @@ -201,7 +202,9 @@ export function CloudWakingOverlay({ } data-cloud-waking-anchor={passThrough ? 'terminal' : undefined} > - {failed ? ( + {failed && wake.notPaired ? ( + + ) : failed ? (
{ const { container } = render() await flush() expect(container.querySelector('[data-cloud-cover="waking"]')).not.toBeNull() + // PAIRED-WAKE-TYPE-THROUGH: while it wakes the pane underneath stays typeable, like owned. + expect(container.querySelector('[data-cloud-cover="waking"]')?.className).toContain( + 'pointer-events-none' + ) act(() => seedPaired('sleeping', { wake: { @@ -143,6 +147,10 @@ describe('the paired cloud workspace surface', () => { /Try again/.test(b.textContent ?? '') ) expect(retry).toBeDefined() + // A failed wake covers the pane again: nothing typed there would reach a terminal. + expect(container.querySelector('[data-cloud-cover="waking"]')?.className).not.toContain( + 'pointer-events-none' + ) fireEvent.click(retry!) expect(wakePaired).toHaveBeenCalledWith(ID) }) diff --git a/src/renderer/src/components/orca-cloud/PairedCloudWorkspaceSurface.tsx b/src/renderer/src/components/orca-cloud/PairedCloudWorkspaceSurface.tsx index cc903cee8ad..f4831d6675d 100644 --- a/src/renderer/src/components/orca-cloud/PairedCloudWorkspaceSurface.tsx +++ b/src/renderer/src/components/orca-cloud/PairedCloudWorkspaceSurface.tsx @@ -27,6 +27,8 @@ import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' import { coveredPaneHadFocus, useInertSiblings } from './cloud-cover-focus' import { CloudWakingOverlay } from './CloudWakingOverlay' import { useWakeOverlay } from './use-wake-overlay' +import { useWakeInput, useWakeInputNotices } from './use-wake-input' +import { clearWakeInput } from '@/components/terminal-pane/input/wake-input-buffer' import { useConfirmedRestart } from './use-cloud-panel-actions' import { useCloudWorkspaceView } from './use-cloud-workspace-view' import { useSleepCopyProgress } from './offline/CloudSleepCopyState' @@ -114,10 +116,15 @@ export function PairedCloudWorkspaceSurface({ const view = useCloudWorkspaceView(workspaceId, { pairedRows: true }) const name = useAppStore((state) => findWorktreeById(state, workspaceId)?.displayName ?? null) const now = useNow(1_000, view.wake !== undefined) + // PAIRED-WAKE-TYPE-THROUGH: typing is held while it wakes and delivered to the woken terminal, + // exactly as for an owned workspace (the store begins and ends the hold on wake progress). + const wakeInput = useWakeInput(workspaceId) + useWakeInputNotices(workspaceId) const overlay = useWakeOverlay( view.wake, Math.max(now, view.wake?.at ?? 0), - t('orcaCloudWorkspace.overlay.stalled', 'Orca Cloud stopped responding while waking.') + t('orcaCloudWorkspace.overlay.stalled', 'Orca Cloud stopped responding while waking.'), + wakeInput.pending > 0 ) const copySaveEntry = useSleepCopySave(workspaceId) const wakeInFlight = Boolean( @@ -141,10 +148,12 @@ export function PairedCloudWorkspaceSurface({ const slowNow = useNow(30_000, layer === 'asleep') const restart = useConfirmedRestart(workspaceId) + // While it wakes the pane stays typeable under the overlay (a failed wake covers it again). + const typeThrough = layer === 'waking' && overlay.wake?.phase !== 'failed' const coverRef = useRef(null) const headingRef = useRef(null) const [takeFocus, setTakeFocus] = useState(false) - useInertSiblings(coverRef, layer !== null && !overlay.leaving) + useInertSiblings(coverRef, layer !== null && !typeThrough && !overlay.leaving) useLayoutEffect(() => { setTakeFocus(layer !== null && coveredPaneHadFocus(coverRef.current)) }, [layer]) @@ -175,7 +184,13 @@ export function PairedCloudWorkspaceSurface({ {layer ? (
@@ -185,6 +200,9 @@ export function PairedCloudWorkspaceSurface({ workspaceName={name} leaving={overlay.leaving} autoFocus={takeFocus} + typeThrough={typeThrough} + pendingInput={wakeInput.pending} + onClearPending={() => clearWakeInput(workspaceId)} onRetry={() => void wakePairedCloudWorkspace(workspaceId)} onRestart={restart} onDismiss={() => dismissCloudWake(workspaceId)} diff --git a/src/renderer/src/components/orca-cloud/cloud-toast-stack.tsx b/src/renderer/src/components/orca-cloud/cloud-toast-stack.tsx index 9dfe2e2f7d6..af618195af6 100644 --- a/src/renderer/src/components/orca-cloud/cloud-toast-stack.tsx +++ b/src/renderer/src/components/orca-cloud/cloud-toast-stack.tsx @@ -13,8 +13,6 @@ import React, { useSyncExternalStore } from 'react' import { TriangleAlert, X } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' -import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' -import type { ExternalLinkSource } from '@/components/open-external/copy-external-link' import { useTranslate } from '@/i18n/use-translate' import { cn } from '@/lib/class-names' import { BANNER_BODY_TEXT, BANNER_TONE } from '@/components/ui/banner-tone' @@ -29,8 +27,6 @@ export type CloudToastAction = { primary?: boolean icon?: React.ReactNode pending?: boolean - /** The action opens this page in the browser: its chevron offers "Copy link". */ - link?: ExternalLinkSource } export type CloudToastLine = { @@ -368,19 +364,7 @@ export function CloudToastCard({

{line.description}

) : null}
- {line.action?.link ? ( - (line.action?.pending ? undefined : line.action?.onClick())} - aria-disabled={line.action.pending || undefined} - aria-busy={line.action.pending || undefined} - > - {line.action.icon} - {line.action.label} - - ) : line.action ? ( + {line.action ? ( - } - bodyClassName={open ? undefined : 'hidden'} - > - {!open ? null : audit.status === 'loading' ? ( - - ) : audit.status === 'error' ? ( -
- -
- ) : audit.data.entries.length === 0 ? ( -

- {t('auto.components.settings.cloudApi.audit.empty', 'Nothing has happened yet.')} -

- ) : ( -
    - {audit.data.entries.slice(0, 25).map((entry) => ( -
  • - - {auditActionLabel(entry, t)} - - - - - - {formatRelativeFromNow(entry.createdAt, now, locale)} - -
  • - ))} -
- )} - - ) -} diff --git a/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.fixture.tsx b/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.fixture.tsx index a31156a5809..b18f289c8f0 100644 --- a/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.fixture.tsx +++ b/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.fixture.tsx @@ -1,7 +1,6 @@ import { defineFixture } from '../../../component-fixtures/component-fixture' import type { CloudApiGrant, - CloudApiAuditPage, CloudApiKeyList, CloudApiMe, CloudApiSettings @@ -65,34 +64,6 @@ const KEYS: CloudApiKeyList = { } const SETTINGS: CloudApiSettings = { settings: { vmCliEnabled: true }, canEdit: true } -const AUDIT: CloudApiAuditPage = { - nextBefore: null, - entries: [ - { - id: 'a1', - actor: { type: 'user', id: 'u_7f3k', userId: 'u_7f3k', userName: null }, - action: 'api_key.create', - target: { type: 'api_key', id: 'key_ci' }, - detail: { name: 'GitHub Actions deploy' }, - createdAt: NOW - 3 * DAY - }, - { - id: 'a2', - actor: { type: 'api_key', id: 'key_dash', userId: 'u_grace', userName: 'Grace Hopper' }, - action: 'workspace.create', - target: { type: 'workspace', id: 'sbx_7Hq2' }, - createdAt: NOW - 5 * MIN - }, - { - id: 'a3', - actor: { type: 'workspace_token', id: 'key_ws', userId: 'u_ada' }, - action: 'workspace.message', - target: { type: 'workspace', id: 'sbx_7Hq2' }, - createdAt: NOW - 2 * MIN - } - ] -} - const GRANTS: CloudApiGrant[] = [ { id: 'fam_claude', @@ -126,13 +97,11 @@ const base: CloudApiKeysViewProps = { viewer: ADMIN, keys: { status: 'ok', data: KEYS }, settings: { status: 'ok', data: SETTINGS }, - audit: { status: 'ok', data: AUDIT }, now: NOW, onRefresh: () => {}, onCreate: async () => ({ ok: false, error: 'server' }), onRevoke: async () => null, onToggleVmCli: async () => null, - onLoadAudit: () => {}, onCreateApproval: async () => ({ ok: false, error: 'server' }), grants: { status: 'ok', data: [] }, onRevokeGrant: async () => null, @@ -155,11 +124,6 @@ export const disconnectAppConfirm = defineFixture({ ) }) -export const listWithActivity = defineFixture({ - width: 760, - render: () => -}) - export const empty = defineFixture({ width: 760, render: () => ( @@ -180,7 +144,6 @@ export const loading = defineFixture({ keys={{ status: 'loading' }} grants={{ status: 'loading' }} settings={{ status: 'loading' }} - audit={null} /> ) }) @@ -196,7 +159,6 @@ export const error = defineFixture({ }} settings={{ status: 'error', message: 'Couldn’t reach Orca Cloud.' }} grants={{ status: 'error', message: 'Couldn’t reach Orca Cloud.' }} - audit={null} /> ) }) @@ -212,7 +174,6 @@ export const memberView = defineFixture({ data: { availableScopes: MEMBER.scopes, keys: [{ ...KEYS.keys[0]!, createdBy: 'u_linus' }] } }} settings={{ status: 'ok', data: { settings: { vmCliEnabled: false }, canEdit: false } }} - audit={null} /> ) }) @@ -229,7 +190,6 @@ export const createDialogMemberCustom = defineFixture({ {...base} viewer={MEMBER} keys={{ status: 'ok', data: { availableScopes: MEMBER.scopes, keys: [] } }} - audit={null} initial={{ create: true }} /> ), diff --git a/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.tsx b/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.tsx index 73a6718cbf7..67ba455d68c 100644 --- a/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.tsx +++ b/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.tsx @@ -7,7 +7,6 @@ import { useTranslate } from '@/i18n/use-translate' import { cn } from '@/lib/class-names' import type { CloudApiApproval, - CloudApiAuditPage, CloudApiCreatedKey, CloudApiGrant, CloudApiKey, @@ -20,7 +19,7 @@ import type { } from '../../../../../shared/cloud-api/cloud-api-types' import { SettingsGroup } from '../section-layout/SettingsGroup' import { ApproveAppDialog } from './ApproveAppDialog' -import { AccessLinks, AuditLog, OrgSettings } from './CloudApiKeysSections' +import { AccessLinks, OrgSettings } from './CloudApiKeysSections' import { CloudConfirmDialog } from './CloudConfirmDialog' import { CloudConnectedApps } from './CloudConnectedApps' import type { CloudLoadState } from './cloud-load-state' @@ -37,7 +36,6 @@ export type CloudApiKeysViewProps = { keys: CloudLoadState settings: CloudLoadState /** Null hides the activity log (members cannot read it). */ - audit: CloudLoadState | null now: number onRefresh: () => void /** A refresh is running while the current data stays on screen. */ @@ -46,7 +44,6 @@ export type CloudApiKeysViewProps = { /** Resolves to an error message or null. */ onRevoke: (key: CloudApiKey) => Promise onToggleVmCli: (enabled: boolean) => Promise - onLoadAudit: () => void grants: CloudLoadState onRevokeGrant: (grant: CloudApiGrant) => Promise onRefreshGrants: () => void @@ -58,7 +55,6 @@ export type CloudApiKeysViewProps = { create?: boolean created?: CloudApiCreatedKey revokeKeyId?: string - auditOpen?: boolean approve?: boolean approval?: CloudApiApproval revokeGrantId?: string @@ -252,16 +248,6 @@ export function CloudApiKeysView(props: CloudApiKeysViewProps): React.JSX.Elemen settings={props.settings} onToggle={props.onToggleVmCli} /> - - {props.audit ? ( - - ) : null}
[ ...translateSearchKeyword('auto.components.settings.cloudApi.keywordToken', 'token'), ...translateSearchKeyword('auto.components.settings.cloudApi.keywordMcp', 'mcp'), ...translateSearchKeyword('auto.components.settings.cloudApi.keywordCli', 'cli'), - ...translateSearchKeyword('auto.components.settings.cloudApi.keywordAudit', 'audit log'), ...translateSearchKeyword('auto.components.settings.cloudApi.keywordCloud', 'cloud') ] } diff --git a/src/renderer/src/components/settings/cloud-secrets/CloudSecretsPane.fixture.tsx b/src/renderer/src/components/settings/cloud-secrets/CloudSecretsPane.fixture.tsx index ad5d0d8ea23..ab2291b3601 100644 --- a/src/renderer/src/components/settings/cloud-secrets/CloudSecretsPane.fixture.tsx +++ b/src/renderer/src/components/settings/cloud-secrets/CloudSecretsPane.fixture.tsx @@ -199,7 +199,7 @@ function list( initialMode={mode} initialSaved={options.saved} onSetUpAgent={noop} - description="Only you get these, in every Orca Cloud workspace you start." + description="Only you get these, in cloud workspaces you haven’t shared. Shared workspaces don’t get them." /> ) diff --git a/src/renderer/src/components/settings/cloud-secrets/CloudSecretsPane.tsx b/src/renderer/src/components/settings/cloud-secrets/CloudSecretsPane.tsx index ca4475202fd..70d236a775e 100644 --- a/src/renderer/src/components/settings/cloud-secrets/CloudSecretsPane.tsx +++ b/src/renderer/src/components/settings/cloud-secrets/CloudSecretsPane.tsx @@ -201,8 +201,8 @@ export function CloudSecretsPane({ ) : null } description={t( - 'cloudSecrets.personal.description', - 'Only you get these, in every Orca Cloud workspace you start.' + 'cloudSecrets.personal.descriptionUnshared', + 'Only you get these, in cloud workspaces you haven’t shared. Shared workspaces don’t get them.' )} emptyTitle={t('cloudSecrets.personal.emptyTitle', 'No personal secrets yet')} emptyDetail={t( diff --git a/src/renderer/src/components/settings/enterprise/EnterpriseAuditLog.tsx b/src/renderer/src/components/settings/enterprise/EnterpriseAuditLog.tsx new file mode 100644 index 00000000000..e3eca575956 --- /dev/null +++ b/src/renderer/src/components/settings/enterprise/EnterpriseAuditLog.tsx @@ -0,0 +1,393 @@ +// Settings › Enterprise › Audit log: every recorded change for the team in one list, filtered by +// category and by person, with CSV export. Owners and admins only. A team whose plan does not +// include it sees the upsell instead; the control plane refuses the data either way. +import { Download, History, Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { InlineError } from '@/components/ui/inline-error' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { useIntlLocale, useTranslate, type Translate } from '@/i18n/use-translate' +import { + CLOUD_AUDIT_CATEGORIES, + type CloudAuditActor, + type CloudAuditCategory, + type CloudAuditEvent +} from '../../../../../shared/cloud-api/cloud-api-types' +import { PersonName, PersonSentence } from '../cloud-api/PersonName' +import { formatAbsoluteTime, formatRelativeFromNow } from '../cloud-api/cloud-api-format' +import { + CloudApiEmptyState, + CloudApiErrorNotice, + CloudApiSkeletonRows +} from '../cloud-api/cloud-api-parts' +import { SettingsGroup } from '../section-layout/SettingsGroup' +import { EnterpriseUpsell, EnterpriseUpsellLine, type EnterpriseContact } from './EnterpriseUpsell' +import { auditCategoryLabel, auditEventSentence } from './enterprise-audit-copy' + +export type AuditLogFilters = { category: CloudAuditCategory | null; actor: string | null } + +export type AuditLogState = + | { status: 'loading' } + /** The team's plan does not include the audit log. */ + | { status: 'upsell' } + | { status: 'error'; message: string } + | { + status: 'ok' + events: CloudAuditEvent[] + /** More events exist past the ones loaded. */ + hasMore: boolean + loadingMore: boolean + /** A failed "Show more" keeps the list and says so under it. */ + moreError: string | null + /** A filter change is loading; the previous list stays on screen, dimmed. */ + refreshing: boolean + } + +export type AuditLogExportState = + | { status: 'idle' } + | { status: 'exporting' } + /** Saved; `truncated` means the file holds the newest events only. */ + | { status: 'done'; truncated: boolean } + | { status: 'error'; message: string } + +export type EnterpriseAuditLogProps = { + log: AuditLogState + filters: AuditLogFilters + /** Everyone who appears in the log, for the person filter. */ + actors: readonly CloudAuditActor[] + exportState: AuditLogExportState + now: number + viewerUserId: string | null + contact: EnterpriseContact + onFilter: (filters: AuditLogFilters) => void + onLoadMore: () => void + onRetry: () => void + onExport: () => void + onContactSales: (url: string) => void + /** False when the page banner already carries Contact sales: the upsell is then a plain line. */ + upsellButton?: boolean +} + +const ALL = '__all__' +const SYSTEM = 'system' + +function ActorCell({ + event, + viewerUserId +}: { + event: CloudAuditEvent + viewerUserId: string | null +}): React.JSX.Element { + const t = useTranslate() + const { actor } = event + if (actor.kind === 'system' || !actor.userId) { + return <>{t('settings.enterprise.audit.actor.system', 'Orca')} + } + const mine = actor.userId === viewerUserId + const sentence = + actor.via === 'api_key' + ? mine + ? t('auto.components.settings.cloudApi.audit.youViaKey', 'You via API key') + : t('auto.components.settings.cloudApi.audit.personViaKey', ' via API key') + : actor.via === 'mcp' + ? mine + ? t('auto.components.settings.cloudApi.audit.youViaMcp', 'You via MCP') + : t('auto.components.settings.cloudApi.audit.personViaMcp', ' via MCP') + : actor.via === 'agent' + ? mine + ? t('settings.enterprise.audit.actor.youViaAgent', 'Your agent') + : t('settings.enterprise.audit.actor.personViaAgent', '’s agent') + : actor.via === 'routine' + ? mine + ? t('settings.enterprise.audit.actor.youViaRoutine', 'Your routine') + : t('settings.enterprise.audit.actor.personViaRoutine', '’s routine') + : mine + ? t('auto.components.settings.cloudApi.audit.you', 'You') + : '' + if (mine) { + return <>{sentence} + } + return ( + <> + + {/* A named person who has left: say so, or they read as a current teammate. */} + {actor.kind === 'former_member' && actor.name ? ( + + {' · '} + {t('auto.components.settings.cloudApi.formerMember', 'Former member')} + + ) : null} + + ) +} + +function actorOptionLabel(actor: CloudAuditActor, viewerUserId: string | null, t: Translate) { + if (actor.userId && actor.userId === viewerUserId) { + return t('auto.components.settings.cloudApi.audit.you', 'You') + } + return ( + <> + + {actor.kind === 'former_member' && actor.name ? ( + + {t('auto.components.settings.cloudApi.formerMember', 'Former member')} + + ) : null} + + ) +} + +function Filters({ + filters, + actors, + viewerUserId, + disabled, + onFilter +}: Pick & { + disabled: boolean +}): React.JSX.Element { + const t = useTranslate() + return ( +
+ + +
+ ) +} + +export function EnterpriseAuditLog(props: EnterpriseAuditLogProps): React.JSX.Element { + const t = useTranslate() + const locale = useIntlLocale() + const { log, filters, exportState } = props + const filtered = filters.category !== null || filters.actor !== null + const canExport = log.status === 'ok' && log.events.length > 0 + return ( + (exportState.status === 'exporting' ? undefined : props.onExport())} + > + {exportState.status === 'exporting' ? ( + + ) : ( + + )} + {t('settings.enterprise.audit.export', 'Export CSV')} + + ) : undefined + } + > + {log.status === 'upsell' && props.upsellButton === false ? ( + + {t( + 'settings.enterprise.audit.upsellBody', + 'Orca records your team’s changes on every plan, so your full history is here from the day you upgrade.' + )} + + ) : log.status === 'upsell' ? ( + + ) : log.status === 'loading' ? ( + + ) : log.status === 'error' ? ( +
+ +
+ ) : ( + <> + {/* Nothing to narrow down in a log with nothing in it. */} + {filtered || log.events.length > 0 ? ( + + ) : null} + {exportState.status === 'error' ? ( +
+ +
+ ) : exportState.status === 'done' && exportState.truncated ? ( +

+ {t( + 'settings.enterprise.audit.exportTruncated', + 'The file has the newest 5,000 events. Filter by activity or person to export the rest.' + )} +

+ ) : null} + {log.events.length === 0 ? ( +
+ props.onFilter({ category: null, actor: null })} + > + {t('settings.enterprise.audit.clearFilters', 'Clear filters')} + + ) : undefined + } + /> +
+ ) : ( +
    + {log.events.map((event) => ( +
  • + + {auditEventSentence(event, t, locale)} + + + {auditCategoryLabel(event.category, t)} + + + + + + {formatRelativeFromNow(event.at, props.now, locale)} + +
  • + ))} +
+ )} + {log.moreError ? ( +
+ +
+ ) : log.hasMore ? ( +
+ +
+ ) : null} + + )} +
+ ) +} diff --git a/src/renderer/src/components/settings/enterprise/EnterpriseSettingsPane.tsx b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsPane.tsx new file mode 100644 index 00000000000..e86d1a4f38f --- /dev/null +++ b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsPane.tsx @@ -0,0 +1,65 @@ +// Settings › Enterprise, wired to the account: who is looking, billing's Enterprise contact page, +// and the audit log's data. Shown only to a team's owners and admins (the nav hides it otherwise, +// and the control plane refuses the data regardless). +import { useEffect } from 'react' +import { useOrcaAccountKey } from '@/hooks/use-orca-account-key' +import { useOrcaCloudApiAvailability } from '@/hooks/useOrcaCloudApiAvailability' +import { activeTeamRole } from '@/lib/orca-cloud/active-team-role' +import { useAppStore } from '@/store' +import { refreshBillingSummary, useBillingState } from '../../billing/billing-summary-store' +import { canManageMembers } from '../../billing/team/team-roles' +import { EnterpriseSettingsView } from './EnterpriseSettingsView' +import { ENTERPRISE_CONTACT_URL, type EnterpriseContact } from './EnterpriseUpsell' +import { useEnterpriseAuditLog } from './use-enterprise-audit-log' + +/** Billing's own name and contact page for Enterprise; the fixed page until billing has answered. */ +function useEnterpriseContact(): EnterpriseContact { + const billing = useBillingState() + useEffect(() => { + void refreshBillingSummary() + }, []) + const offer = + billing.result?.status === 'ok' ? billing.result.entitlement.offers?.enterprise : null + return { + planName: offer?.label || 'Enterprise', + contactUrl: offer?.contactUrl || ENTERPRISE_CONTACT_URL + } +} + +export function EnterpriseSettingsPane(): React.JSX.Element | null { + // Remount on a team switch so one team's log never shows under another. + return +} + +function EnterpriseSettingsPaneBody(): React.JSX.Element | null { + const availability = useOrcaCloudApiAvailability() + const authStatus = useAppStore((s) => s.orcaProfileAuthStatus) + const role = activeTeamRole(authStatus) + const admin = role !== null && canManageMembers(role) + const contact = useEnterpriseContact() + const audit = useEnterpriseAuditLog(availability?.status === 'ready' && admin) + if (availability?.status !== 'ready' || !admin) { + return null + } + return ( + void window.api.shell.openUrl(url) + }} + /> + ) +} diff --git a/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.fixture.tsx b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.fixture.tsx new file mode 100644 index 00000000000..69d5d749a90 --- /dev/null +++ b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.fixture.tsx @@ -0,0 +1,129 @@ +import { defineFixture } from '../../../component-fixtures/component-fixture' +import { SettingsSection } from '../section-layout/SettingsSection' +import { EnterpriseSettingsView } from './EnterpriseSettingsView' +import { fixtureAuditProps } from './enterprise-audit.fixtures' +import type { EnterpriseAuditLogProps } from './EnterpriseAuditLog' + +const BARE_BODY = 'rounded-none border-0 bg-transparent p-0 shadow-none' + +/** The pane as Settings draws it: page title and description over the bare body. */ +function Page({ + audit, + ssoIncluded +}: { + audit: EnterpriseAuditLogProps + ssoIncluded?: boolean | null +}): React.JSX.Element { + return ( +
+ + + +
+ ) +} + +/** An Enterprise team's owner: the merged log, filters and export; single sign-on is theirs, so no upsell. */ +export const auditLog = defineFixture({ + width: 860, + render: () => +}) + +/** A team on neither: one banner with the page's only Contact sales, and a plain line per block. */ +export const upsell = defineFixture({ + width: 860, + render: () => +}) + +/** Entitled, nothing recorded yet. */ +export const empty = defineFixture({ + width: 860, + render: () => ( + + ) +}) + +/** Filters that match nothing: say so and offer the way back. */ +export const emptyFiltered = defineFixture({ + width: 860, + render: () => ( + + ) +}) + +export const loading = defineFixture({ + width: 860, + render: () => +}) + +export const loadFailed = defineFixture({ + width: 860, + render: () => ( + + ) +}) + +/** After an export that hit the 5,000-event cap. */ +export const exportTruncated = defineFixture({ + width: 860, + render: () => ( + + ) +}) + +/** Only the audit log is missing (single sign-on is included): that block keeps its own button. */ +export const upsellAuditOnly = defineFixture({ + width: 860, + render: () => ( + + ) +}) diff --git a/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.test.tsx b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.test.tsx new file mode 100644 index 00000000000..dc8eb4fa11a --- /dev/null +++ b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.test.tsx @@ -0,0 +1,457 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { CloudAuditEvent } from '../../../../../shared/cloud-api/cloud-api-types' +import { + EnterpriseSettingsView as RawView, + type EnterpriseSettingsViewProps +} from './EnterpriseSettingsView' +import { FIXTURE_EVENTS, FIXTURE_NOW, fixtureAuditProps } from './enterprise-audit.fixtures' +import { auditCategoryLabel, auditEventSentence, isKnownAuditAction } from './enterprise-audit-copy' + +afterEach(cleanup) + +/** The view as the pane feeds it: the audit log's answer says which side of the plan the team is on. */ +function EnterpriseSettingsView( + props: Omit & { ssoIncluded?: boolean | null } +): React.JSX.Element { + const status = props.audit.log.status + const derived = status === 'ok' ? true : status === 'upsell' ? false : null + return ( + + ) +} + +const EMPTY = { + status: 'ok' as const, + events: [], + hasMore: false, + loadingMore: false, + moreError: null, + refreshing: false +} + +function rows(container: HTMLElement): string[] { + return [...container.querySelectorAll('ul[aria-label="Audit log"] > li')].map((row) => + [...row.children].map((cell) => cell.textContent).join(' | ') + ) +} + +describe('audit log', () => { + it('lists who did what to what and when, newest first, from every source', () => { + const { container } = render() + expect(rows(container)).toEqual([ + 'Changed secret STRIPE_SECRET_KEY | Secrets | Grace Hopper | 4 minutes ago', + 'Changed the role of Grace Hopper | Members | You | 38 minutes ago', + 'Shared a link to a port on workspace “checkout-redesign” | Workspaces | Grace Hopper | 3 hours ago', + 'Started workspace “nightly-evals” | Workspaces | Grace Hopper via API key | 5 hours ago', + 'Removed Linus Park from the team | Members | You | yesterday', + 'Turned off a routine | Routines | Orca | yesterday', + 'Created API key “GitHub Actions deploy” | API keys | Linus Park · Former member | 2 days ago', + 'Added secret ANTHROPIC_API_KEY | Secrets | You | 3 days ago', + 'Changed agent access | Settings | You | 6 days ago', + 'Connected an app | Connected apps | Grace Hopper via MCP | 9 days ago' + ]) + }) + + it('never renders a secret value, hint or raw user id inline; the id stays one hover away', () => { + const leaky: CloudAuditEvent = { + ...FIXTURE_EVENTS[0]!, + detail: { scope: 'org', hint: 'wxyz', value: 'sk-live-should-not-show' } + } + const { container } = render( + + ) + expect(container.textContent).not.toMatch(/wxyz|should-not-show/) + expect(container.textContent).not.toContain('u_linus') + expect(container.querySelector('[title="u_linus"]')).not.toBeNull() + }) + + it('shows an action this version does not know by its machine name instead of hiding it', () => { + const unknown: CloudAuditEvent = { + ...FIXTURE_EVENTS[0]!, + id: 'aud_new', + category: 'security', + action: 'sso.went_live' + } + const { container } = render( + + ) + expect(rows(container)).toEqual(['sso.went_live | Security | Grace Hopper | 4 minutes ago']) + }) + + it('pages with Show more, and keeps the list when a page fails', () => { + const onLoadMore = vi.fn() + const { rerender, container } = render( + + ) + fireEvent.click(screen.getByRole('button', { name: 'Show more' })) + expect(onLoadMore).toHaveBeenCalledTimes(1) + rerender( + + ) + // Busy, not disabled: the button keeps focus, and a second click does nothing. + const busy = screen.getByRole('button', { name: 'Show more' }) + expect(busy.getAttribute('aria-busy')).toBe('true') + fireEvent.click(busy) + expect(onLoadMore).toHaveBeenCalledTimes(1) + rerender( + + ) + expect(rows(container)).toHaveLength(FIXTURE_EVENTS.length) + expect(screen.getByText('Couldn’t reach Orca Cloud.')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Try again' })) + expect(onLoadMore).toHaveBeenCalledTimes(2) + // The last page has no Show more. + rerender( + + ) + expect(screen.queryByRole('button', { name: 'Show more' })).toBeNull() + }) + + it('offers both filters and the export, and exports once per click', () => { + const onExport = vi.fn() + const { rerender } = render() + expect(screen.getByRole('combobox', { name: 'Filter by activity' }).textContent).toBe( + 'All activity' + ) + expect(screen.getByRole('combobox', { name: 'Filter by person' }).textContent).toBe('Everyone') + fireEvent.click(screen.getByRole('button', { name: 'Export CSV' })) + expect(onExport).toHaveBeenCalledTimes(1) + rerender( + + ) + fireEvent.click(screen.getByRole('button', { name: 'Export CSV' })) + expect(onExport).toHaveBeenCalledTimes(1) + rerender( + + ) + expect(screen.getByRole('status').textContent).toContain('newest 5,000 events') + }) + + it('shows the chosen filters, and an empty filtered list offers the way back', () => { + const onFilter = vi.fn() + render( + + ) + expect(screen.getByRole('combobox', { name: 'Filter by activity' }).textContent).toBe('Secrets') + expect(screen.getByRole('combobox', { name: 'Filter by person' }).textContent).toBe( + 'Grace Hopper' + ) + expect(screen.getByText('Nothing matches')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Clear filters' })) + expect(onFilter).toHaveBeenCalledWith({ category: null, actor: null }) + // Nothing to export. + expect(screen.getByRole('button', { name: 'Export CSV' }).hasAttribute('disabled')).toBe(true) + }) + + it('says nothing is recorded yet when the log is empty and unfiltered', () => { + render() + expect(screen.getByText('Nothing recorded yet')).toBeTruthy() + expect(screen.queryByRole('combobox')).toBeNull() + expect(screen.queryByRole('button', { name: 'Clear filters' })).toBeNull() + }) + + it('words a load failure and retries', () => { + const onRetry = vi.fn() + render( + + ) + expect(screen.getByText('Orca Cloud is down.')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Try again' })) + expect(onRetry).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('button', { name: 'Export CSV' })).toBeNull() + }) +}) + +describe('without the Enterprise plan', () => { + it('a team with neither gets ONE Contact sales, in a banner, and a plain line in each block', () => { + const onContactSales = vi.fn() + const { container } = render( + + ) + expect(screen.getByText('Single sign-on and the audit log are part of Enterprise')).toBeTruthy() + // The blocks keep their titles and say what they are, without a second button. + expect(screen.getByText('Single sign-on and provisioning')).toBeTruthy() + expect(screen.getByText('Audit log')).toBeTruthy() + expect(container.textContent).toContain('so your full history is here from the day you upgrade') + expect(screen.queryByText('Audit log is part of Enterprise')).toBeNull() + expect(rows(container)).toEqual([]) + expect(screen.queryByRole('combobox')).toBeNull() + expect(screen.queryByRole('button', { name: 'Export CSV' })).toBeNull() + const contact = screen.getAllByRole('button', { name: 'Contact sales' }) + expect(contact).toHaveLength(1) + fireEvent.click(contact[0]!) + expect(onContactSales).toHaveBeenCalledWith('https://orca.dev/enterprise') + // Contact sales is not a sign-in page: a plain button, no Copy link chevron (AUTH-ONLY-COPY-LINK). + expect(screen.queryAllByRole('button', { name: 'More link options' })).toHaveLength(0) + }) + + it('a team missing only the audit log keeps the button on that block, with no banner', () => { + const onContactSales = vi.fn() + render( + + ) + expect(screen.queryByText('Single sign-on and the audit log are part of Enterprise')).toBeNull() + expect(screen.getByText('Audit log is part of Enterprise')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Contact sales' })) + expect(onContactSales).toHaveBeenCalledWith('https://orca.dev/enterprise') + }) + + it("uses billing's own plan name and contact page", () => { + const onContactSales = vi.fn() + render( + + ) + expect(screen.getByText('Single sign-on and the audit log are part of Scale')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Contact sales' })) + expect(onContactSales).toHaveBeenCalledWith('https://orca.dev/scale') + }) +}) + +describe('on the Enterprise plan', () => { + it('never sends the team to sales: single sign-on is set up with us, with Contact us', () => { + const onContactSales = vi.fn() + const { container } = render( + + ) + expect(container.textContent).not.toMatch(/part of Enterprise|Contact sales/) + expect(screen.getByText('Single sign-on is set up with us')).toBeTruthy() + expect(screen.getByText('To set up or change single sign-on, contact us.')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Contact us' })) + expect(onContactSales).toHaveBeenCalledWith('https://orca.dev/enterprise') + }) + + it('says nothing about single sign-on until the plan is known', () => { + const { container } = render( + + ) + expect(container.textContent).not.toMatch(/Single sign-on|Contact/) + expect(screen.getByText('Audit log')).toBeTruthy() + }) +}) + +describe('slots', () => { + it('lets the SSO and policy owners replace or add their blocks, in order above the audit log', () => { + const { container } = render( + SSO} + policies={
Require MFA
} + /> + ) + expect(screen.queryByText('Single sign-on is set up with us')).toBeNull() + const order = [...container.querySelectorAll('[id^="cloud-enterprise-"]')].map( + (node) => node.id + ) + expect(order).toEqual([ + 'cloud-enterprise-sso', + 'cloud-enterprise-policies', + 'cloud-enterprise-audit-log' + ]) + }) +}) + +describe('copy', () => { + it('names every category and words every action the control plane records today', () => { + expect(auditCategoryLabel('api_keys')).toBe('API keys') + const recorded = [ + 'api_key.create', + 'api_key.revoke', + 'workspace_token.issue', + 'settings.update', + 'workspace.create', + 'workspace.destroy', + 'workspace.sleep', + 'workspace.wake', + 'workspace.archive', + 'workspace.unarchive', + 'workspace.rename', + 'workspace.share_create', + 'workspace.share_revoke', + 'workspace.message', + 'workspace.attachment', + 'workspace.session.create', + 'workspace.session.cancel', + 'workspace.session.rename', + 'sandbox.ssh_key_added', + 'sandbox.ssh_key_removed', + 'project.create', + 'project.update', + 'project.delete', + 'mcp.approval_create', + 'mcp.authorize', + 'mcp.grant_revoke', + 'mcp.code_replay', + 'mcp.refresh_replay', + 'secret.created', + 'secret.updated', + 'secret.deleted', + 'member.removed', + 'member.left', + 'member.role_changed', + 'member.role_synced', + 'routine.create', + 'routine.update', + 'routine.delete', + 'routine.disable', + 'routine.run', + 'routine.run.start', + 'routine.run.finish', + 'routine.rotate_webhook', + // Sent by auth and billing (AUDIT-AUTH-EVENTS). + 'member.invited', + 'member.invite_revoked', + 'member.joined', + 'team.created', + 'team.renamed', + 'team.deleted', + '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' + ] + expect(recorded.filter((action) => !isKnownAuditAction(action))).toEqual([]) + }) + + it('words the events auth and billing send', () => { + const base: CloudAuditEvent = { + id: 'a', + at: FIXTURE_NOW, + category: 'members', + action: 'member.invited', + actor: { kind: 'person', userId: 'u_ada', name: 'Ada Lovelace', via: null }, + target: { type: 'invite', id: null, label: 'new@acme.dev' }, + detail: { role: 'member' } + } + const say = (action: string, target: CloudAuditEvent['target'] = null): string => + auditEventSentence({ ...base, action, target }) + expect(auditEventSentence(base)).toBe('Invited new@acme.dev') + expect(say('member.invite_revoked', base.target)).toBe('Canceled the invite for new@acme.dev') + expect(say('member.joined', { type: 'member', id: 'u_x', label: 'Bo' })).toBe('Joined the team') + expect(say('team.renamed', { type: 'team', id: null, label: 'Acme Labs' })).toBe( + 'Renamed the team to “Acme Labs”' + ) + expect(say('team.deleted', { type: 'team', id: null, label: 'Acme Labs' })).toBe( + 'Deleted the team' + ) + }) + + it('puts what billing recorded into the sentence, and falls back when it recorded nothing', () => { + const say = (action: string, detail: Record | null): string => + auditEventSentence( + { + id: 'b', + at: FIXTURE_NOW, + category: 'billing', + action, + actor: { kind: 'system', userId: null, name: null, via: null }, + target: { type: 'plan', id: null }, + detail + }, + undefined, + 'en-US' + ) + expect(say('billing.plan_started', { plan: 'team', interval: 'month', seats: 2 })).toBe( + 'Plan started (Team, monthly)' + ) + expect(say('billing.plan_started', null)).toBe('Plan started') + expect(say('billing.plan_changed', { plan: 'team', interval: 'year' })).toBe( + 'Plan changed to Team, annual' + ) + expect(say('billing.plan_changed', { plan: 'team' })).toBe('Plan changed') + expect(say('billing.seats_changed', { seats: 5, previousSeats: 3 })).toBe( + 'Seats changed from 3 to 5' + ) + expect(say('billing.seats_changed', { seats: 5 })).toBe('Seats changed') + expect(say('billing.plan_canceled', { plan: 'team' })).toBe('Plan ended') + expect(say('billing.cancel_scheduled', { endsAt: Date.UTC(2026, 11, 2, 12) })).toBe( + 'Plan set to end on Dec 2, 2026' + ) + expect(say('billing.cancel_scheduled', null)).toBe('Plan set to end at the next renewal') + expect(say('billing.cancel_reverted', null)).toBe('Plan set to renew again') + expect( + say('billing.spend_cap_changed', { spendCapCents: 5000, previousSpendCapCents: null }) + ).toBe('Changed the monthly spend cap to $50') + expect(say('billing.spend_cap_changed', { spendCapCents: 1250 })).toBe( + 'Changed the monthly spend cap to $12.50' + ) + // Back to the plan's default cap: no amount to show. + expect(say('billing.spend_cap_changed', { previousSpendCapCents: 5000 })).toBe( + 'Changed the monthly spend cap' + ) + expect(say('billing.overage_enabled', null)).toBe('Turned on overage') + expect(say('billing.overage_disabled', null)).toBe('Turned off overage') + }) + + it('falls back to the plain sentence when the target has no name', () => { + const event: CloudAuditEvent = { + id: 'a', + at: FIXTURE_NOW, + category: 'members', + action: 'member.removed', + actor: { kind: 'system', userId: null, name: null, via: null }, + target: { type: 'member', id: 'u_x', label: null }, + detail: null + } + expect(auditEventSentence(event)).toBe('Removed a teammate') + expect( + auditEventSentence({ ...event, target: { type: 'member', id: 'u_x', label: 'Bo' } }) + ).toBe('Removed Bo from the team') + }) +}) diff --git a/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.tsx b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.tsx new file mode 100644 index 00000000000..b374191a542 --- /dev/null +++ b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.tsx @@ -0,0 +1,123 @@ +// Settings › Enterprise, for a team's owners and admins: single sign-on and directory sync, the +// team's sign-in policies, and the audit log. Each block shows its own controls when the team's +// plan includes it and the shared upsell (EnterpriseUpsell) when it does not. +// +// Contact sales appears once per page: a team with neither single sign-on nor the audit log gets +// one banner at the top and a plain line in each block. A team on Enterprise is never sent to sales. +// +// Slots for the blocks other owners build (pass a node; nothing renders until one is passed): +// singleSignOn replaces the built-in single sign-on block (sso-scim) +// policies sign-in policies, e.g. "Require MFA" (mfa) +import { ExternalLink } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { useTranslate } from '@/i18n/use-translate' +import { SettingsGroup } from '../section-layout/SettingsGroup' +import { EnterpriseAuditLog, type EnterpriseAuditLogProps } from './EnterpriseAuditLog' +import { EnterpriseUpsell, EnterpriseUpsellLine } from './EnterpriseUpsell' + +export const ENTERPRISE_SSO_ANCHOR = 'cloud-enterprise-sso' +export const ENTERPRISE_POLICIES_ANCHOR = 'cloud-enterprise-policies' +export const ENTERPRISE_AUDIT_LOG_ANCHOR = 'cloud-enterprise-audit-log' + +export type EnterpriseSettingsViewProps = { + audit: EnterpriseAuditLogProps + /** + * Whether the team's plan includes single sign-on: true, false, or null while that is not known + * yet (the block waits rather than guess). Ignored once `singleSignOn` is passed. + */ + ssoIncluded: boolean | null + singleSignOn?: React.ReactNode + policies?: React.ReactNode +} + +export function EnterpriseSettingsView({ + audit, + ssoIncluded, + singleSignOn, + policies +}: EnterpriseSettingsViewProps): React.JSX.Element { + const t = useTranslate() + const { contact, onContactSales } = audit + const ssoLocked = singleSignOn === undefined && ssoIncluded === false + const auditLocked = audit.log.status === 'upsell' + // Neither: one banner carries the page's only Contact sales. + const banner = ssoLocked && auditLocked + const ssoUpsellBody = t( + 'settings.enterprise.sso.upsellBody', + 'Sign in with your identity provider over SAML, and add or remove teammates with SCIM.' + ) + return ( +
+ {banner ? ( + + + + ) : null} + {singleSignOn !== undefined ? ( +
{singleSignOn}
+ ) : ssoIncluded === null ? null : ( +
+ + {ssoIncluded ? ( + // On the plan: never an upsell. We set it up with the team until they can do it here. +
+
+

+ {t('settings.enterprise.sso.managedTitle', 'Single sign-on is set up with us')} +

+

+ {t( + 'settings.enterprise.sso.managedBody', + 'To set up or change single sign-on, contact us.' + )} +

+
+ +
+ ) : banner ? ( + {ssoUpsellBody} + ) : ( + + )} +
+
+ )} + {policies ?
{policies}
: null} +
+ +
+
+ ) +} diff --git a/src/renderer/src/components/settings/enterprise/EnterpriseUpsell.tsx b/src/renderer/src/components/settings/enterprise/EnterpriseUpsell.tsx new file mode 100644 index 00000000000..879316bb517 --- /dev/null +++ b/src/renderer/src/components/settings/enterprise/EnterpriseUpsell.tsx @@ -0,0 +1,56 @@ +// The one locked state for everything on Settings › Enterprise: what the feature is, that it is +// part of Enterprise, and Contact sales. The button opens the page (Copy link is for sign-in pages only). +import { ExternalLink } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { useTranslate } from '@/i18n/use-translate' + +/** Where Contact sales goes when billing has not answered yet (billing's own Enterprise page). */ +export const ENTERPRISE_CONTACT_URL = 'https://orca.dev/enterprise' + +/** Billing's name and contact page for the plan, so the upsell never drifts from the plans page. */ +export type EnterpriseContact = { planName: string; contactUrl: string } + +export function EnterpriseUpsell({ + title, + body, + contact, + onContactSales +}: { + /** "Audit log is part of Enterprise". */ + title: string + body: string + contact: EnterpriseContact + onContactSales: (url: string) => void +}): React.JSX.Element { + const t = useTranslate() + return ( +
+
+

{title}

+

{body}

+
+ +
+ ) +} + +/** The locked state without a button, for a block under the page banner that carries Contact sales. */ +export function EnterpriseUpsellLine({ + children +}: { + children: React.ReactNode +}): React.JSX.Element { + return ( +

+ {children} +

+ ) +} diff --git a/src/renderer/src/components/settings/enterprise/enterprise-audit-account-copy.ts b/src/renderer/src/components/settings/enterprise/enterprise-audit-account-copy.ts new file mode 100644 index 00000000000..f5cd6efaa55 --- /dev/null +++ b/src/renderer/src/components/settings/enterprise/enterprise-audit-account-copy.ts @@ -0,0 +1,177 @@ +// Wording for the audit events the auth service and billing send: members and invites, the team +// itself, and the plan. (Workspace, key, secret and routine wording is in enterprise-audit-copy.) +import type { Translate } from '@/i18n/use-translate' + +type Sentence = (t: Translate) => string +type Detail = Record +/** + * `named` takes the thing's name ({{name}}): a key, a secret, a teammate, a workspace. + * `detailed` builds the sentence from what the event recorded (a plan, seat counts, an amount); + * it returns null when the event does not carry enough, and the plain sentence is used. + */ +export type ActionCopy = { + plain: Sentence + named?: (t: Translate, name: string) => string + detailed?: (t: Translate, detail: Detail, locale: string | undefined) => string | null +} + +/** Billing sends plan ids ("team"); the log shows them as names ("Team"). */ +function planName(detail: Detail, key = 'plan'): string | null { + const id = detail[key] + return typeof id === 'string' && id.length > 0 ? id.charAt(0).toUpperCase() + id.slice(1) : null +} + +function intervalWord(detail: Detail, t: Translate): string | null { + return detail.interval === 'month' + ? t('settings.enterprise.audit.interval.month', 'monthly') + : detail.interval === 'year' + ? t('settings.enterprise.audit.interval.year', 'annual') + : null +} + +const count = (value: unknown): number | null => + typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null + +export const ACCOUNT_AUDIT_ACTIONS: Record = { + 'member.invited': { + plain: (t) => t('settings.enterprise.audit.action.memberInvite', 'Invited a teammate'), + named: (t, name) => + t('settings.enterprise.audit.action.memberInviteNamed', 'Invited {{name}}', { name }) + }, + 'member.invite_revoked': { + plain: (t) => t('settings.enterprise.audit.action.inviteRevoke', 'Canceled an invite'), + named: (t, name) => + t('settings.enterprise.audit.action.inviteRevokeNamed', 'Canceled the invite for {{name}}', { + name + }) + }, + 'member.removed': { + plain: (t) => t('settings.enterprise.audit.action.memberRemove', 'Removed a teammate'), + named: (t, name) => + t('settings.enterprise.audit.action.memberRemoveNamed', 'Removed {{name}} from the team', { + name + }) + }, + 'member.left': { + plain: (t) => t('settings.enterprise.audit.action.memberLeft', 'Left the team') + }, + 'member.role_changed': { + plain: (t) => t('settings.enterprise.audit.action.roleChange', 'Changed a teammate’s role'), + named: (t, name) => + t('settings.enterprise.audit.action.roleChangeNamed', 'Changed the role of {{name}}', { + name + }) + }, + 'member.role_synced': { + plain: (t) => + t( + 'settings.enterprise.audit.action.roleSync', + 'Updated a teammate’s API keys to match their role' + ) + }, + 'team.created': { + plain: (t) => t('settings.enterprise.audit.action.teamCreate', 'Created the team') + }, + 'team.renamed': { + plain: (t) => t('settings.enterprise.audit.action.teamRename', 'Renamed the team'), + named: (t, name) => + t('settings.enterprise.audit.action.teamRenameNamed', 'Renamed the team to “{{name}}”', { + name + }) + }, + 'member.joined': { + plain: (t) => t('settings.enterprise.audit.action.memberJoined', 'Joined the team') + }, + 'team.deleted': { + plain: (t) => t('settings.enterprise.audit.action.teamDelete', 'Deleted the team') + }, + // Billing. Stripe reports plan and seat changes, so those read without a subject (the person + // column says Orca); overage and the spend cap are changed by a person in Plan & billing. + 'billing.plan_started': { + plain: (t) => t('settings.enterprise.audit.action.planStarted', 'Plan started'), + detailed: (t, detail) => { + const plan = planName(detail) + const interval = intervalWord(detail, t) + return plan && interval + ? t( + 'settings.enterprise.audit.action.planStartedDetail', + 'Plan started ({{plan}}, {{interval}})', + { plan, interval } + ) + : null + } + }, + 'billing.plan_changed': { + plain: (t) => t('settings.enterprise.audit.action.planChanged', 'Plan changed'), + detailed: (t, detail) => { + const plan = planName(detail) + const interval = intervalWord(detail, t) + return plan && interval + ? t( + 'settings.enterprise.audit.action.planChangedDetail', + 'Plan changed to {{plan}}, {{interval}}', + { plan, interval } + ) + : null + } + }, + 'billing.plan_canceled': { + plain: (t) => t('settings.enterprise.audit.action.planCanceled', 'Plan ended') + }, + 'billing.cancel_scheduled': { + plain: (t) => + t('settings.enterprise.audit.action.cancelScheduled', 'Plan set to end at the next renewal'), + detailed: (t, detail, locale) => { + const endsAt = count(detail.endsAt) + return endsAt + ? t('settings.enterprise.audit.action.cancelScheduledOn', 'Plan set to end on {{date}}', { + date: new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(endsAt) + }) + : null + } + }, + 'billing.cancel_reverted': { + plain: (t) => t('settings.enterprise.audit.action.cancelReverted', 'Plan set to renew again') + }, + 'billing.seats_changed': { + plain: (t) => t('settings.enterprise.audit.action.seatsChanged', 'Seats changed'), + detailed: (t, detail) => { + const from = count(detail.previousSeats) + const to = count(detail.seats) + return from !== null && to !== null + ? t( + 'settings.enterprise.audit.action.seatsChangedDetail', + 'Seats changed from {{from}} to {{to}}', + { from: String(from), to: String(to) } + ) + : null + } + }, + 'billing.overage_enabled': { + plain: (t) => t('settings.enterprise.audit.action.overageOn', 'Turned on overage') + }, + 'billing.overage_disabled': { + plain: (t) => t('settings.enterprise.audit.action.overageOff', 'Turned off overage') + }, + 'billing.spend_cap_changed': { + plain: (t) => + t('settings.enterprise.audit.action.spendCapChanged2', 'Changed the monthly spend cap'), + detailed: (t, detail, locale) => { + // No amount (null) means the plan's default cap: say only that it changed. + const cents = count(detail.spendCapCents) + return cents === null + ? null + : t( + 'settings.enterprise.audit.action.spendCapChangedTo', + 'Changed the monthly spend cap to {{amount}}', + { + amount: new Intl.NumberFormat(locale, { + style: 'currency', + currency: 'USD', + minimumFractionDigits: cents % 100 === 0 ? 0 : 2 + }).format(cents / 100) + } + ) + } + } +} diff --git a/src/renderer/src/components/settings/enterprise/enterprise-audit-copy.ts b/src/renderer/src/components/settings/enterprise/enterprise-audit-copy.ts new file mode 100644 index 00000000000..cf31667beda --- /dev/null +++ b/src/renderer/src/components/settings/enterprise/enterprise-audit-copy.ts @@ -0,0 +1,271 @@ +// Wording for the team audit log: each recorded action as a sentence an admin can scan, and the +// names of the categories they filter by. An action this version does not know is shown by its +// machine name rather than hidden, so nothing recorded is ever missing from the log. +import { translate } from '@/i18n/i18n' +import type { Translate } from '@/i18n/use-translate' +import type { + CloudAuditCategory, + CloudAuditEvent +} from '../../../../../shared/cloud-api/cloud-api-types' +import { ACCOUNT_AUDIT_ACTIONS, type ActionCopy } from './enterprise-audit-account-copy' + +export function auditCategoryLabel(category: CloudAuditCategory, t: Translate = translate): string { + switch (category) { + case 'members': + return t('settings.enterprise.audit.category.members', 'Members') + case 'security': + return t('settings.enterprise.audit.category.security', 'Security') + case 'api_keys': + return t('settings.enterprise.audit.category.apiKeys', 'API keys') + case 'apps': + return t('settings.enterprise.audit.category.apps', 'Connected apps') + case 'workspaces': + return t('settings.enterprise.audit.category.workspaces', 'Workspaces') + case 'secrets': + return t('settings.enterprise.audit.category.secrets', 'Secrets') + case 'routines': + return t('settings.enterprise.audit.category.routines', 'Routines') + case 'settings': + return t('settings.enterprise.audit.category.settings', 'Settings') + case 'billing': + return t('settings.enterprise.audit.category.billing', 'Billing') + case 'other': + return t('settings.enterprise.audit.category.other', 'Other') + } +} + +const ACTIONS: Record = { + 'api_key.create': { + plain: (t) => t('auto.components.settings.cloudApi.audit.action.keyCreate', 'Created API key'), + named: (t, name) => + t('settings.enterprise.audit.action.keyCreateNamed', 'Created API key “{{name}}”', { name }) + }, + 'api_key.revoke': { + plain: (t) => t('auto.components.settings.cloudApi.audit.action.keyRevoke', 'Revoked API key'), + named: (t, name) => + t('settings.enterprise.audit.action.keyRevokeNamed', 'Revoked API key “{{name}}”', { name }) + }, + 'workspace_token.issue': { + plain: (t) => + t('settings.enterprise.audit.action.workspaceToken', 'Gave an agent access to the Orca API') + }, + 'settings.update': { + plain: (t) => + t('auto.components.settings.cloudApi.audit.action.settings', 'Changed agent access') + }, + 'workspace.create': { + plain: (t) => + t('auto.components.settings.cloudApi.audit.action.wsCreate', 'Started a workspace'), + named: (t, name) => + t('settings.enterprise.audit.action.wsCreateNamed', 'Started workspace “{{name}}”', { name }) + }, + 'workspace.destroy': { + plain: (t) => + t('auto.components.settings.cloudApi.audit.action.wsDestroy', 'Deleted a workspace'), + named: (t, name) => + t('settings.enterprise.audit.action.wsDestroyNamed', 'Deleted workspace “{{name}}”', { name }) + }, + 'workspace.sleep': { + plain: (t) => + t('auto.components.settings.cloudApi.audit.action.wsSleep', 'Put a workspace to sleep'), + named: (t, name) => + t('settings.enterprise.audit.action.wsSleepNamed', 'Put workspace “{{name}}” to sleep', { + name + }) + }, + 'workspace.wake': { + plain: (t) => t('auto.components.settings.cloudApi.audit.action.wsWake', 'Woke a workspace'), + named: (t, name) => + t('settings.enterprise.audit.action.wsWakeNamed', 'Woke workspace “{{name}}”', { name }) + }, + 'workspace.archive': { + plain: (t) => + t('auto.components.settings.cloudApi.audit.action.wsArchive', 'Archived a workspace'), + named: (t, name) => + t('settings.enterprise.audit.action.wsArchiveNamed', 'Archived workspace “{{name}}”', { + name + }) + }, + 'workspace.unarchive': { + plain: (t) => t('settings.enterprise.audit.action.wsUnarchive', 'Restored a workspace'), + named: (t, name) => + t('settings.enterprise.audit.action.wsUnarchiveNamed', 'Restored workspace “{{name}}”', { + name + }) + }, + 'workspace.rename': { + plain: (t) => t('settings.enterprise.audit.action.wsRename', 'Renamed a workspace'), + named: (t, name) => + t('settings.enterprise.audit.action.wsRenameNamed', 'Renamed a workspace to “{{name}}”', { + name + }) + }, + 'workspace.share_create': { + plain: (t) => + t('settings.enterprise.audit.action.wsShare', 'Shared a link to a workspace port'), + named: (t, name) => + t( + 'settings.enterprise.audit.action.wsShareNamed', + 'Shared a link to a port on workspace “{{name}}”', + { name } + ) + }, + 'workspace.share_revoke': { + plain: (t) => + t('settings.enterprise.audit.action.wsUnshare', 'Turned off a workspace share link'), + named: (t, name) => + t( + 'settings.enterprise.audit.action.wsUnshareNamed', + 'Turned off a share link on workspace “{{name}}”', + { name } + ) + }, + 'workspace.message': { + plain: (t) => t('auto.components.settings.cloudApi.audit.action.wsMessage', 'Messaged an agent') + }, + 'workspace.attachment': { + plain: (t) => t('settings.enterprise.audit.action.wsAttachment', 'Sent a file to an agent') + }, + 'workspace.session.create': { + plain: (t) => t('settings.enterprise.audit.action.sessionCreate', 'Started an agent') + }, + 'workspace.session.cancel': { + plain: (t) => t('settings.enterprise.audit.action.sessionCancel', 'Stopped an agent') + }, + 'workspace.session.rename': { + plain: (t) => t('settings.enterprise.audit.action.sessionRename', 'Renamed an agent session') + }, + 'sandbox.ssh_key_added': { + plain: (t) => t('settings.enterprise.audit.action.sshAdd', 'Added an SSH key to a workspace'), + named: (t, name) => + t( + 'settings.enterprise.audit.action.sshAddNamed', + 'Added an SSH key to workspace “{{name}}”', + { + name + } + ) + }, + 'sandbox.ssh_key_removed': { + plain: (t) => + t('settings.enterprise.audit.action.sshRemove', 'Removed an SSH key from a workspace'), + named: (t, name) => + t( + 'settings.enterprise.audit.action.sshRemoveNamed', + 'Removed an SSH key from workspace “{{name}}”', + { name } + ) + }, + 'project.create': { + plain: (t) => + t('auto.components.settings.cloudApi.audit.action.projectCreate', 'Created a project'), + named: (t, name) => + t('settings.enterprise.audit.action.projectCreateNamed', 'Created project “{{name}}”', { + name + }) + }, + 'project.update': { + plain: (t) => + t('auto.components.settings.cloudApi.audit.action.projectUpdate', 'Edited a project') + }, + 'project.delete': { + plain: (t) => + t('auto.components.settings.cloudApi.audit.action.projectDelete', 'Deleted a project') + }, + 'mcp.approval_create': { + plain: (t) => + t('auto.components.settings.cloudApi.audit.action.approval', 'Made an app approval code') + }, + 'mcp.authorize': { + plain: (t) => t('settings.enterprise.audit.action.mcpAuthorize', 'Connected an app') + }, + 'mcp.grant_revoke': { + plain: (t) => t('settings.enterprise.audit.action.mcpRevoke', 'Disconnected an app') + }, + 'mcp.code_replay': { + plain: (t) => + t( + 'settings.enterprise.audit.action.mcpCodeReplay', + 'Blocked a reused app sign-in code and disconnected the app' + ) + }, + 'mcp.refresh_replay': { + plain: (t) => + t( + 'settings.enterprise.audit.action.mcpRefreshReplay', + 'Blocked a reused app token and disconnected the app' + ) + }, + 'secret.created': { + plain: (t) => t('settings.enterprise.audit.action.secretCreate', 'Added a secret'), + named: (t, name) => + t('settings.enterprise.audit.action.secretCreateNamed', 'Added secret {{name}}', { name }) + }, + 'secret.updated': { + plain: (t) => t('settings.enterprise.audit.action.secretUpdate', 'Changed a secret'), + named: (t, name) => + t('settings.enterprise.audit.action.secretUpdateNamed', 'Changed secret {{name}}', { name }) + }, + 'secret.deleted': { + plain: (t) => t('settings.enterprise.audit.action.secretDelete', 'Deleted a secret'), + named: (t, name) => + t('settings.enterprise.audit.action.secretDeleteNamed', 'Deleted secret {{name}}', { name }) + }, + ...ACCOUNT_AUDIT_ACTIONS, + 'routine.create': { + plain: (t) => t('settings.enterprise.audit.action.routineCreate', 'Created a routine') + }, + 'routine.update': { + plain: (t) => t('settings.enterprise.audit.action.routineUpdate', 'Edited a routine') + }, + 'routine.delete': { + plain: (t) => t('settings.enterprise.audit.action.routineDelete', 'Deleted a routine') + }, + 'routine.disable': { + plain: (t) => t('settings.enterprise.audit.action.routineDisable', 'Turned off a routine') + }, + 'routine.run': { + plain: (t) => t('settings.enterprise.audit.action.routineRun', 'Ran a routine') + }, + 'routine.run.start': { + plain: (t) => t('settings.enterprise.audit.action.routineRunStart', 'A routine run started') + }, + 'routine.run.finish': { + plain: (t) => t('settings.enterprise.audit.action.routineRunFinish', 'A routine run finished') + }, + 'routine.rotate_webhook': { + plain: (t) => + t('settings.enterprise.audit.action.routineWebhook', 'Replaced a routine’s webhook URL') + } +} + +/** The name to put in the sentence: what the control plane labelled the target, else a recorded name. */ +function targetName(event: CloudAuditEvent): string | null { + const recorded = event.detail?.name + const name = event.target?.label ?? (typeof recorded === 'string' ? recorded : null) + const clean = name?.trim() + return clean ? clean.slice(0, 80) : null +} + +/** "Created API key “CI deploy”"; an unknown action reads as its machine name. */ +export function auditEventSentence( + event: CloudAuditEvent, + t: Translate = translate, + locale?: string +): string { + const copy = ACTIONS[event.action] + if (!copy) { + return event.action + } + const detailed = event.detail ? copy.detailed?.(t, event.detail, locale) : null + if (detailed) { + return detailed + } + const name = targetName(event) + return name && copy.named ? copy.named(t, name) : copy.plain(t) +} + +/** Whether this version words the action (the rest show their machine name). */ +export function isKnownAuditAction(action: string): boolean { + return action in ACTIONS +} diff --git a/src/renderer/src/components/settings/enterprise/enterprise-audit.fixtures.ts b/src/renderer/src/components/settings/enterprise/enterprise-audit.fixtures.ts new file mode 100644 index 00000000000..96e5206f993 --- /dev/null +++ b/src/renderer/src/components/settings/enterprise/enterprise-audit.fixtures.ts @@ -0,0 +1,152 @@ +// Sample audit log for the Enterprise settings fixtures and tests: one event from every source the +// control plane merges (API keys, secrets, members, workspaces, settings, apps), by current +// teammates, a former teammate, an API key, an agent and Orca itself. Names only, never values. +import type { + CloudAuditActor, + CloudAuditEvent +} from '../../../../../shared/cloud-api/cloud-api-types' +import type { EnterpriseAuditLogProps } from './EnterpriseAuditLog' + +export const FIXTURE_NOW = Date.UTC(2026, 10, 12, 17, 30) +const MIN = 60_000 +const HOUR = 60 * MIN +const DAY = 24 * HOUR + +const ADA: CloudAuditActor = { kind: 'person', userId: 'u_ada', name: 'Ada Lovelace', via: null } +const GRACE: CloudAuditActor = { + kind: 'person', + userId: 'u_grace', + name: 'Grace Hopper', + via: null +} +const LINUS: CloudAuditActor = { + kind: 'former_member', + userId: 'u_linus', + name: 'Linus Park', + via: null +} +const SYSTEM: CloudAuditActor = { kind: 'system', userId: null, name: null, via: null } + +export const FIXTURE_ACTORS: CloudAuditActor[] = [ADA, GRACE, LINUS] + +export const FIXTURE_EVENTS: CloudAuditEvent[] = [ + { + id: 'sev_9', + at: FIXTURE_NOW - 4 * MIN, + category: 'secrets', + action: 'secret.updated', + actor: GRACE, + target: { type: 'secret', id: 'STRIPE_SECRET_KEY', label: 'STRIPE_SECRET_KEY' }, + detail: { scope: 'org' } + }, + { + id: 'aud_8', + at: FIXTURE_NOW - 38 * MIN, + category: 'members', + action: 'member.role_changed', + actor: ADA, + target: { type: 'member', id: 'u_grace', label: 'Grace Hopper' }, + detail: { role: 'admin' } + }, + { + id: 'aud_7', + at: FIXTURE_NOW - 3 * HOUR, + category: 'workspaces', + action: 'workspace.share_create', + actor: GRACE, + target: { type: 'workspace', id: 'sbx_7Hq2', label: 'checkout-redesign' }, + detail: { port: 3000, audience: 'team', expiresIn: '1d' } + }, + { + id: 'aud_6', + at: FIXTURE_NOW - 5 * HOUR, + category: 'workspaces', + action: 'workspace.create', + actor: { ...GRACE, via: 'api_key' }, + target: { type: 'workspace', id: 'sbx_2Lm9', label: 'nightly-evals' }, + detail: null + }, + { + id: 'aud_5', + at: FIXTURE_NOW - 1 * DAY, + category: 'members', + action: 'member.removed', + actor: ADA, + target: { type: 'member', id: 'u_linus', label: 'Linus Park' }, + detail: { keysRevoked: 2, devicesRevoked: 1 } + }, + { + id: 'aud_4', + at: FIXTURE_NOW - 1 * DAY - 2 * MIN, + category: 'routines', + action: 'routine.disable', + actor: SYSTEM, + target: { type: 'routine', id: 'rtn_41', label: null }, + detail: { reason: 'member_removed' } + }, + { + id: 'aud_3', + at: FIXTURE_NOW - 2 * DAY, + category: 'api_keys', + action: 'api_key.create', + actor: LINUS, + target: { type: 'api_key', id: 'key_ci', label: 'GitHub Actions deploy' }, + detail: { name: 'GitHub Actions deploy' } + }, + { + id: 'sev_2', + at: FIXTURE_NOW - 3 * DAY, + category: 'secrets', + action: 'secret.created', + actor: ADA, + target: { type: 'secret', id: 'ANTHROPIC_API_KEY', label: 'ANTHROPIC_API_KEY' }, + detail: { scope: 'repo', repo: 'github.com/acme/web' } + }, + { + id: 'aud_1', + at: FIXTURE_NOW - 6 * DAY, + category: 'settings', + action: 'settings.update', + actor: ADA, + target: { type: 'account', id: 'org:acme', label: null }, + detail: { vmCliEnabled: true } + }, + { + id: 'aud_0', + at: FIXTURE_NOW - 9 * DAY, + category: 'apps', + action: 'mcp.authorize', + actor: { ...GRACE, via: 'mcp' }, + target: { type: 'oauth_client', id: 'cl_4q', label: null }, + detail: null + } +] + +const noop = (): void => {} + +export function fixtureAuditProps( + patch: Partial = {} +): EnterpriseAuditLogProps { + return { + log: { + status: 'ok', + events: FIXTURE_EVENTS, + hasMore: true, + loadingMore: false, + moreError: null, + refreshing: false + }, + filters: { category: null, actor: null }, + actors: FIXTURE_ACTORS, + exportState: { status: 'idle' }, + now: FIXTURE_NOW, + viewerUserId: 'u_ada', + contact: { planName: 'Enterprise', contactUrl: 'https://orca.dev/enterprise' }, + onFilter: noop, + onLoadMore: noop, + onRetry: noop, + onExport: noop, + onContactSales: noop, + ...patch + } +} diff --git a/src/renderer/src/components/settings/enterprise/use-enterprise-audit-log.ts b/src/renderer/src/components/settings/enterprise/use-enterprise-audit-log.ts new file mode 100644 index 00000000000..8e2be99ccb5 --- /dev/null +++ b/src/renderer/src/components/settings/enterprise/use-enterprise-audit-log.ts @@ -0,0 +1,196 @@ +// Loads the team audit log for Settings › Enterprise: the first page, more pages, filter changes +// and the CSV export. The control plane decides access; `plan-required` becomes the upsell. +import { useCallback, useEffect, useRef, useState } from 'react' +import { useTranslate } from '@/i18n/use-translate' +import type { + CloudAuditActor, + CloudAuditEvent +} from '../../../../../shared/cloud-api/cloud-api-types' +import { cloudApiErrorCopy } from '../cloud-api/cloud-api-copy' +import type { CloudApiFailure } from '../cloud-api/cloud-load-state' +import { useRequestGeneration } from '../cloud-api/use-request-generation' +import type { AuditLogExportState, AuditLogFilters, AuditLogState } from './EnterpriseAuditLog' + +const PAGE_SIZE = 50 +const NO_FILTERS: AuditLogFilters = { category: null, actor: null } + +function queryFor(filters: AuditLogFilters): { + category?: NonNullable + actor?: string +} { + return { + ...(filters.category ? { category: filters.category } : {}), + ...(filters.actor ? { actor: filters.actor } : {}) + } +} + +/** Hands the CSV to the browser's download path (Electron shows its save dialog). */ +export function saveAuditCsv(csv: string, at: number): void { + // A BOM so spreadsheet apps read names with accents as UTF-8. + const url = URL.createObjectURL(new Blob(['', csv], { type: 'text/csv;charset=utf-8' })) + const link = document.createElement('a') + link.href = url + link.download = `orca-audit-log-${new Date(at).toISOString().slice(0, 10)}.csv` + document.body.append(link) + link.click() + link.remove() + // Revoked after the click has been handled, or the download can start from a dead URL. + window.setTimeout(() => URL.revokeObjectURL(url), 10_000) +} + +export function useEnterpriseAuditLog(enabled: boolean): { + log: AuditLogState + filters: AuditLogFilters + actors: CloudAuditActor[] + exportState: AuditLogExportState + now: number + setFilters: (filters: AuditLogFilters) => void + loadMore: () => void + retry: () => void + exportCsv: () => void +} { + const t = useTranslate() + const api = window.api.cloudApi + const begin = useRequestGeneration() + const [log, setLog] = useState({ status: 'loading' }) + const [filters, setFiltersState] = useState(NO_FILTERS) + const [actors, setActors] = useState([]) + const [exportState, setExportState] = useState({ status: 'idle' }) + const [now, setNow] = useState(() => Date.now()) + const cursor = useRef(null) + const loaded = useRef([]) + + const word = useCallback( + (failure: CloudApiFailure) => cloudApiErrorCopy(failure.error, t, failure), + [t] + ) + + const loadFirst = useCallback( + async (next: AuditLogFilters): Promise => { + if (!api) { + return + } + const isCurrent = begin('audit-log') + // A filter change keeps the list on screen until the new one arrives. + setLog((current) => + current.status === 'ok' ? { ...current, refreshing: true } : { status: 'loading' } + ) + const result = await api.listAuditLog({ ...queryFor(next), limit: PAGE_SIZE }) + if (!isCurrent()) { + return + } + setNow(Date.now()) + if (!result.ok) { + cursor.current = null + loaded.current = [] + setLog( + result.error === 'plan-required' + ? { status: 'upsell' } + : { status: 'error', message: word(result) } + ) + return + } + cursor.current = result.data.nextCursor + loaded.current = result.data.events + if (result.data.actors) { + setActors(result.data.actors) + } + setLog({ + status: 'ok', + events: result.data.events, + hasMore: result.data.nextCursor !== null, + loadingMore: false, + moreError: null, + refreshing: false + }) + }, + [api, begin, word] + ) + + useEffect(() => { + if (enabled) { + void loadFirst(NO_FILTERS) + } + }, [enabled, loadFirst]) + + const setFilters = useCallback( + (next: AuditLogFilters): void => { + setFiltersState(next) + setExportState({ status: 'idle' }) + void loadFirst(next) + }, + [loadFirst] + ) + + const loadMore = useCallback((): void => { + const after = cursor.current + if (!api || !after) { + return + } + const isCurrent = begin('audit-log') + setLog((current) => + current.status === 'ok' ? { ...current, loadingMore: true, moreError: null } : current + ) + void api + .listAuditLog({ ...queryFor(filters), cursor: after, limit: PAGE_SIZE }) + .then((result) => { + if (!isCurrent()) { + return + } + if (!result.ok) { + setLog((current) => + current.status === 'ok' + ? { ...current, loadingMore: false, moreError: word(result) } + : current + ) + return + } + cursor.current = result.data.nextCursor + // A new event can shift a page boundary; never show one twice. + const seen = new Set(loaded.current.map((event) => event.id)) + loaded.current = [ + ...loaded.current, + ...result.data.events.filter((event) => !seen.has(event.id)) + ] + setLog({ + status: 'ok', + events: loaded.current, + hasMore: result.data.nextCursor !== null, + loadingMore: false, + moreError: null, + refreshing: false + }) + }) + }, [api, begin, filters, word]) + + const exportCsv = useCallback((): void => { + if (!api) { + return + } + const isCurrent = begin('audit-export') + setExportState({ status: 'exporting' }) + void api.exportAuditLogCsv(queryFor(filters)).then((result) => { + if (!isCurrent()) { + return + } + if (!result.ok) { + setExportState({ status: 'error', message: word(result) }) + return + } + saveAuditCsv(result.data.csv, Date.now()) + setExportState({ status: 'done', truncated: result.data.truncated }) + }) + }, [api, begin, filters, word]) + + return { + log, + filters, + actors, + exportState, + now, + setFilters, + loadMore, + retry: () => void loadFirst(filters), + exportCsv + } +} diff --git a/src/renderer/src/components/settings/navigation/settings-navigation-types.ts b/src/renderer/src/components/settings/navigation/settings-navigation-types.ts index 7dcc261b2b9..5cea2db888b 100644 --- a/src/renderer/src/components/settings/navigation/settings-navigation-types.ts +++ b/src/renderer/src/components/settings/navigation/settings-navigation-types.ts @@ -48,6 +48,7 @@ const SETTINGS_NAV_TARGETS = [ 'orca-account', 'cloud-billing', 'cloud-team', + 'cloud-enterprise', 'cloud-secrets', 'cloud-environments', 'cloud-mcp', diff --git a/src/renderer/src/components/settings/orca-cloud/OrcaCloudSettingsPanes.tsx b/src/renderer/src/components/settings/orca-cloud/OrcaCloudSettingsPanes.tsx index 064d183cd34..76147988a74 100644 --- a/src/renderer/src/components/settings/orca-cloud/OrcaCloudSettingsPanes.tsx +++ b/src/renderer/src/components/settings/orca-cloud/OrcaCloudSettingsPanes.tsx @@ -13,6 +13,7 @@ import { } from '../../cloud-environment/CloudEnvironmentSection' import { OrcaSignInButton, OrcaSignInWaitNotice } from '../../OrcaSignInProgress' import { CloudApiEmptyState } from '../cloud-api/cloud-api-parts' +import { EnterpriseSettingsPane } from '../enterprise/EnterpriseSettingsPane' import { CloudSecretsSection } from '../cloud-secrets/CloudSecretsSection' import { SettingsGroup } from '../section-layout/SettingsGroup' import { CloudPredictiveWakeSetting } from './CloudPredictiveWakeSetting' @@ -104,6 +105,11 @@ function OrcaCloudAccountGate({ ) } +/** Settings › Enterprise: single sign-on, sign-in policies and the team audit log. */ +export function OrcaCloudEnterprisePane(): React.JSX.Element { + return {() => } +} + export function OrcaCloudTeamPane(): React.JSX.Element { const t = useTranslate() return ( diff --git a/src/renderer/src/components/settings/orca-cloud/orca-cloud-settings-search.ts b/src/renderer/src/components/settings/orca-cloud/orca-cloud-settings-search.ts index 25c9981a208..b1ca6546f97 100644 --- a/src/renderer/src/components/settings/orca-cloud/orca-cloud-settings-search.ts +++ b/src/renderer/src/components/settings/orca-cloud/orca-cloud-settings-search.ts @@ -34,6 +34,24 @@ export const getOrcaCloudTeamSearchEntries = createLocalizedCatalog(() => [ } ]) +export const getOrcaCloudEnterpriseSearchEntries = createLocalizedCatalog(() => [ + { + title: translate('settings.enterprise.title', 'Enterprise'), + description: translate( + 'settings.enterprise.searchDescription', + 'Single sign-on and directory sync, and the audit log of changes across your team.' + ), + keywords: [ + ...translateSearchKeyword('settings.enterprise.keywordAudit', 'audit log'), + ...translateSearchKeyword('settings.enterprise.keywordActivity', 'activity'), + ...translateSearchKeyword('settings.enterprise.keywordSso', 'sso'), + ...translateSearchKeyword('settings.enterprise.keywordSaml', 'saml'), + ...translateSearchKeyword('settings.enterprise.keywordScim', 'scim'), + ...translateSearchKeyword('settings.enterprise.keywordCompliance', 'compliance') + ] + } +]) + export const getOrcaCloudSecretsSearchEntries = createLocalizedCatalog(() => [ { title: translate('cloudSecrets.title', 'Cloud secrets'), diff --git a/src/renderer/src/components/settings/page/settings-orca-cloud-section-renderers.tsx b/src/renderer/src/components/settings/page/settings-orca-cloud-section-renderers.tsx index fc73b9264fe..d4d004e20e2 100644 --- a/src/renderer/src/components/settings/page/settings-orca-cloud-section-renderers.tsx +++ b/src/renderer/src/components/settings/page/settings-orca-cloud-section-renderers.tsx @@ -1,4 +1,5 @@ import { + OrcaCloudEnterprisePane, OrcaCloudEnvironmentsPane, OrcaCloudLocalCommandsSettingsPane, OrcaCloudMcpPane, @@ -23,8 +24,10 @@ export function renderOrcaCloudSettingsSections( return null } // Titles and descriptions come from the nav metadata so the pane and the sidebar never disagree. - const sections = [ + const sections: { id: string; pane: React.JSX.Element; navOnly?: boolean }[] = [ { id: 'cloud-team', pane: }, + // Listed in the nav for team owners and admins only; without the entry there is no page. + { id: 'cloud-enterprise', pane: , navOnly: true }, { id: 'cloud-secrets', pane: }, { id: 'cloud-skills', pane: }, { id: 'cloud-environments', pane: }, @@ -33,8 +36,11 @@ export function renderOrcaCloudSettingsSections( ] return ( <> - {sections.map(({ id, pane }) => { + {sections.map(({ id, pane, navOnly }) => { const meta = navigation.navSections.find((section) => section.id === id) + if (navOnly && !meta) { + return null + } return ( + : null} > diff --git a/src/renderer/src/components/sidebar/sidebar-header-actions.tsx b/src/renderer/src/components/sidebar/sidebar-header-actions.tsx index ba7a4224ecd..5a1fb0249af 100644 --- a/src/renderer/src/components/sidebar/sidebar-header-actions.tsx +++ b/src/renderer/src/components/sidebar/sidebar-header-actions.tsx @@ -9,6 +9,7 @@ import { openWorkspaceCreationComposerWithTourHandoff } from '../contextual-tour import type { AddRepoDialogEntryRequest } from './project-admission/dialog/add-repo-dialog-types' import { AddProjectMenu } from './project-admission/menu/AddProjectMenu' import SidebarWorkspaceOptionsMenu from './worktree-list/controls/SidebarWorkspaceOptionsMenu' +import { useOpenCloudWorkspaceMenuItems } from '../cloud-workspaces/OpenCloudWorkspaceMenuItem' function AddProjectButton({ preserveWorkspaceBoardOpen, @@ -20,6 +21,7 @@ function AddProjectButton({ const t = useTranslate() const openModal = useAppStore((s) => s.openModal) const label = t('auto.components.sidebar.SidebarHeader.addProject', 'Add project') + const cloudItems = useOpenCloudWorkspaceMenuItems() const handleOpenEntryRequest = useCallback( (entryRequest: AddRepoDialogEntryRequest) => { openModal('add-repo', { entryRequest }) @@ -36,6 +38,7 @@ function AddProjectButton({ preserveWorkspaceBoardOpen={preserveWorkspaceBoardOpen} onOpenChange={onMenuOpenChange} onOpenEntryRequest={handleOpenEntryRequest} + extraItems={cloudItems} > + + ) : ( + + )} + + + {needsOwnMfa ? ( + + {t('auto.components.teamMfa.setUp', 'Set up MFA')} +
+ } + /> + ) : null} + + {active === null && grace === 'now' && pendingCount > 0 && !needsOwnMfa ? ( +

+ {t( + 'auto.components.teamMfa.nowWarning', + '{{count}} members don’t have it yet and will lose access to this team right away.', + { + count: pendingCount, + defaultValue_one: + '{{count}} member doesn’t have it yet and will lose access to this team right away.' + } + )} +

+ ) : null} + + {active !== null ? ( +
+
+

+ {pendingCount === 0 + ? t('auto.components.teamMfa.allSet', 'Every member has it set up.') + : t('auto.components.teamMfa.pendingCount', '{{count}} members still need it', { + count: pendingCount, + defaultValue_one: '{{count}} member still needs it' + })} +

+ {pendingCount > 0 ? ( + + ) : null} +
+ {pending && pending.length > 0 ? ( +
    + {pending.map((member) => ( +
  • +
    +

    {member.displayName ?? member.email}

    + {member.displayName ? ( +

    {member.email}

    + ) : null} +
    + + {member.mfa === 'blocked' + ? t('auto.components.teamMfa.memberBlocked', 'No access') + : t('auto.components.teamMfa.memberPending', 'Pending')} + + {member.userId ? ( + + ) : null} +
  • + ))} +
+ ) : null} +
+ ) : null} + + {notice?.kind === 'error' ? : null} + {notice?.kind === 'reminded' ? ( +

+ {notice.emailed + ? t('auto.components.teamMfa.reminded', 'Reminder sent to {{count}} members.', { + count: notice.recipients, + defaultValue_one: 'Reminder sent to {{count}} member.' + }) + : t( + 'auto.components.teamMfa.remindedNoEmail', + 'Reminder recorded, but email delivery is off on this server, so nothing was sent.' + )} +

+ ) : null} + + ) +} diff --git a/src/renderer/src/components/team-mfa/TeamMfa.fixture.tsx b/src/renderer/src/components/team-mfa/TeamMfa.fixture.tsx new file mode 100644 index 00000000000..ee234a16b22 --- /dev/null +++ b/src/renderer/src/components/team-mfa/TeamMfa.fixture.tsx @@ -0,0 +1,176 @@ +import { defineFixture } from '../../component-fixtures/component-fixture' +import type { + OrcaTeamMfaPendingMember, + OrcaTeamMfaPolicy +} from '../../../../shared/orca-team-mfa-policy' +import { CloudToastCard } from '@/components/orca-cloud/cloud-toast-stack' +import { RequireMfaRow, type RequireMfaRowProps } from './RequireMfaRow' +import { TeamMfaBanner } from './TeamMfaBanner' +import { TeamMfaBlockedScreen } from './TeamMfaBlockedScreen' + +const NOW = Date.UTC(2026, 10, 18, 15) +const DAY = 86_400_000 +const setupUrl = 'https://cloud.orca.dev/v1/account/security' +const noop = (): void => {} +const width = 760 + +function frame(children: React.ReactNode, frameWidth = width) { + return defineFixture({ + width: frameWidth, + render: () =>
{children}
+ }) +} + +// ---- Banner: a member without MFA, before the deadline ---- + +const banner = (daysLeft: number, frameWidth = width) => + frame( + , + frameWidth + ) + +export const bannerTwoWeeks = banner(14) +export const bannerOneDay = banner(1) +export const bannerNarrow = banner(6, 420) +// ---- Sidebar banner stack: the app-level reminder, at sidebar widths ---- + +const sidebar = (daysLeft: number, sidebarWidth: number) => + defineFixture({ + width: sidebarWidth, + render: () => ( +
+ +
+ ) + }) + +export const sidebarTwoWeeks = sidebar(14, 280) +/** Three days or fewer: the warning look. */ +export const sidebarThreeDays = sidebar(3, 280) +export const sidebarOneDay = sidebar(1, 280) +/** After the deadline: no access until set up. */ +export const sidebarBlocked = sidebar(-2, 280) +/** The narrowest sidebar. */ +export const sidebarBlockedNarrow = sidebar(-2, 220) + +// ---- The one-time toast, for people who keep the sidebar collapsed ---- + +export const toast = frame( + , + 420 +) + +// ---- Blocked screen: after the deadline ---- + +const blocked = (props: { checking?: boolean; stillMissing?: boolean; personal?: boolean }) => + frame( + + ) + +export const blockedScreen = blocked({}) +export const blockedChecking = blocked({ checking: true }) +/** "I've set it up" found no MFA yet. */ +export const blockedStillMissing = blocked({ stillMissing: true }) + +// ---- Settings, Enterprise, Multi-factor authentication ---- + +const pendingMembers: OrcaTeamMfaPendingMember[] = [ + { userId: 'u-ada', email: 'ada@acme.dev', displayName: 'Ada Lovelace', mfa: 'pending' }, + { userId: 'u-grace', email: 'grace@acme.dev', displayName: 'Grace Hopper', mfa: 'pending' }, + { userId: null, email: 'linus@acme.dev', mfa: 'pending' } +] + +const off: OrcaTeamMfaPolicy = { + policy: null, + self: 'on', + setupUrl, + manage: { available: true, pendingMembers: 3 } +} +const scheduled: OrcaTeamMfaPolicy = { + ...off, + policy: { enforceAt: NOW + 6 * DAY, setAt: NOW - 8 * DAY } +} + +const row = (props: Partial) => + frame( + + ) + +/** Not required yet: pick when, then Require MFA. */ +export const rowOff = row({}) +/** The admin has no MFA themselves: the change is held back until they add it. */ +export const rowAdminNeedsOwnMfa = row({ policy: { ...off, self: 'off' } }) +/** Scheduled: the date, the countdown, who is pending, and Remind. */ +export const rowScheduled = row({ policy: scheduled, pending: pendingMembers }) +/** Past the deadline: pending members have no access. */ +export const rowEnforced = row({ + policy: { ...off, policy: { enforceAt: NOW - 2 * DAY, setAt: NOW - 16 * DAY } }, + pending: pendingMembers.map((member) => ({ ...member, mfa: 'blocked' as const })) +}) +export const rowEveryoneSet = row({ + policy: { ...scheduled, manage: { available: true, pendingMembers: 0 } }, + pending: [] +}) +export const rowSaving = row({ policy: scheduled, pending: pendingMembers, busy: true }) +export const rowReminded = row({ + policy: scheduled, + pending: pendingMembers, + notice: { kind: 'reminded', recipients: 3, emailed: true } +}) +/** The server is in log mode: say plainly that no email went out. */ +export const rowRemindedNoEmail = row({ + policy: scheduled, + pending: pendingMembers, + notice: { kind: 'reminded', recipients: 3, emailed: false } +}) +export const rowError = row({ + policy: scheduled, + pending: pendingMembers, + notice: { kind: 'error', message: 'Couldn’t save the change. Try again.' } +}) diff --git a/src/renderer/src/components/team-mfa/TeamMfaAppBanner.tsx b/src/renderer/src/components/team-mfa/TeamMfaAppBanner.tsx new file mode 100644 index 00000000000..4d2a862bb06 --- /dev/null +++ b/src/renderer/src/components/team-mfa/TeamMfaAppBanner.tsx @@ -0,0 +1,75 @@ +import { useEffect } from 'react' +import { hideCloudToast, showCloudToast } from '@/components/orca-cloud/cloud-toast-stack' +import { useIntlLocale, useTranslate } from '@/i18n/use-translate' +import { claimMfaToast, mfaDeadlineDate } from './team-mfa-copy' +import { useTeamMfaGate } from './TeamMfaGate' + +/** + * The MFA notice in the sidebar's banner stack, for the active team: a countdown while there is + * still time, and "no access until you set it up" after the deadline. Renders nothing for a + * personal account, a team with no policy, or a member who already has MFA, which is almost + * everyone. + * + * The sidebar can be collapsed, so this is a reminder, not the enforcement surface: a refused + * team resource shows TeamMfaRefused in its own place whether or not the sidebar is visible. + */ +export function TeamMfaSidebarBanner(): React.JSX.Element | null { + const gate = useTeamMfaGate() + const t = useTranslate() + const locale = useIntlLocale() + const toast = gate.pendingToast + const orgId = toast?.orgId + const enforceAt = toast?.enforceAt + // One toast per team and deadline, for people who keep the sidebar collapsed. Not tied to any + // workspace, so it shows wherever they are; the sidebar card stays as the lasting reminder. + useEffect(() => { + if (!toast || orgId === undefined || enforceAt === undefined) { + return + } + if (!claimMfaToast(readLocalStorage(), orgId, enforceAt)) { + return + } + const id = `team-mfa:${orgId}` + showCloudToast(id, { + priority: 'action', + lines: [ + { + key: 'mfa', + text: t('auto.components.teamMfa.toast', '{{value0}} requires MFA from {{value1}}.', { + value0: toast.teamName, + value1: mfaDeadlineDate(enforceAt, locale) + }), + action: { + label: t('auto.components.teamMfa.setUp', 'Set up MFA'), + primary: true, + // A plain button: toasts no longer carry a Copy link chevron (AUTH-ONLY-COPY-LINK). + // The sidebar card and Settings, Account keep the split button. + onClick: () => { + void window.api.shell.openUrl(toast.setupUrl) + hideCloudToast(id) + } + } + } + ] + }) + // Deliberately keyed on the team and deadline only: copy or locale changes must not re-show. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [orgId, enforceAt]) + return gate.sidebarBanner +} + +function readLocalStorage(): Storage | null { + try { + return window.localStorage + } catch { + return null + } +} + +/** + * In place of a team resource the server refused with `mfa_required`: the setup screen, or (when + * the policy cannot be read to fill it in) nothing, so the caller's own error copy shows. + */ +export function TeamMfaRefused(): React.JSX.Element | null { + return useTeamMfaGate({ forceBlocked: true }).blocked +} diff --git a/src/renderer/src/components/team-mfa/TeamMfaBanner.tsx b/src/renderer/src/components/team-mfa/TeamMfaBanner.tsx new file mode 100644 index 00000000000..6f57b82ef1a --- /dev/null +++ b/src/renderer/src/components/team-mfa/TeamMfaBanner.tsx @@ -0,0 +1,125 @@ +import { ShieldAlert } from 'lucide-react' +import { InlineError } from '@/components/ui/inline-error' +import { cn } from '@/lib/class-names' +import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' +import { useIntlLocale, useTranslate } from '@/i18n/use-translate' +import { BANNER_BODY_TEXT, BANNER_TONE } from '@/components/ui/banner-tone' +import { mfaCountdownLabel, mfaDeadlineDate, mfaSoonTitle, mfaUrgency } from './team-mfa-copy' + +export type TeamMfaBannerProps = { + teamName: string + /** Epoch ms from which the team is closed to members without MFA. */ + enforceAt: number + now: number + setupUrl: string + onSetUp: () => void + /** The deadline has passed and the team is closed to this person until they set MFA up. */ + blocked?: boolean + /** `sidebar`: the narrow stacked layout of the sidebar's banner stack. */ + variant?: 'inline' | 'sidebar' + className?: string +} + +/** + * Shown to a member whose team requires MFA and who has none yet, until the deadline: how long + * is left and the way to set it up. After the deadline the blocked screen takes over. + */ +export function TeamMfaBanner({ + teamName, + enforceAt, + now, + setupUrl, + onSetUp, + blocked = false, + variant = 'inline', + className +}: TeamMfaBannerProps): React.JSX.Element { + const t = useTranslate() + const locale = useIntlLocale() + const urgency = mfaUrgency(enforceAt, now, blocked) + const title = + urgency === 'soon' + ? mfaSoonTitle(teamName, enforceAt, now, t) + : blocked + ? t( + 'auto.components.teamMfa.bannerBlockedTitle', + '{{value0}} requires two-factor authentication', + { value0: teamName } + ) + : t( + 'auto.components.teamMfa.bannerTitle', + '{{value0}} requires two-factor authentication. {{value1}}.', + { value0: teamName, value1: mfaCountdownLabel(enforceAt, now, t) } + ) + const detail = blocked + ? t( + 'auto.components.teamMfa.bannerBlockedBody', + 'You can’t use this team’s workspaces until you set it up. Your personal account isn’t affected.' + ) + : t( + 'auto.components.teamMfa.bannerBody', + 'Set it up by {{value0}}. After that you can’t use this team’s workspaces until you do.', + { value0: mfaDeadlineDate(enforceAt, locale) } + ) + const action = ( + + {t('auto.components.teamMfa.setUp', 'Set up MFA')} + + ) + if (variant === 'sidebar') { + // Same shape as the other banners in the sidebar stack (StalledTerminalDaemonBanner): the + // column is narrow, so the action sits under the text. + return ( +
+
+
+
+ ) + } + return ( + + ) +} diff --git a/src/renderer/src/components/team-mfa/TeamMfaBlockedScreen.tsx b/src/renderer/src/components/team-mfa/TeamMfaBlockedScreen.tsx new file mode 100644 index 00000000000..f89665070ec --- /dev/null +++ b/src/renderer/src/components/team-mfa/TeamMfaBlockedScreen.tsx @@ -0,0 +1,93 @@ +import { Loader2, ShieldAlert } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' +import { useIntlLocale, useTranslate } from '@/i18n/use-translate' +import { mfaDeadlineDate } from './team-mfa-copy' + +export type TeamMfaBlockedScreenProps = { + teamName: string + enforceAt: number + setupUrl: string + /** A re-check is running (after "I've set it up", or on returning from the browser). */ + checking: boolean + /** The last re-check still found no MFA. */ + stillMissing: boolean + onSetUp: () => void + onRecheck: () => void + /** Switch to the personal account, which the team's policy does not affect. */ + onUsePersonal?: () => void +} + +/** + * In place of a team's content once its MFA deadline has passed for a member without MFA: what + * happened, the one thing to do, and a way to keep working outside the team meanwhile. + */ +export function TeamMfaBlockedScreen({ + teamName, + enforceAt, + setupUrl, + checking, + stillMissing, + onSetUp, + onRecheck, + onUsePersonal +}: TeamMfaBlockedScreenProps): React.JSX.Element { + const t = useTranslate() + const locale = useIntlLocale() + return ( +
+
+ +
+
+

+ {t( + 'auto.components.teamMfa.blockedTitle', + 'Set up two-factor authentication to use {{value0}}', + { value0: teamName } + )} +

+

+ {t( + 'auto.components.teamMfa.blockedBody', + '{{value0}} has required it for all members since {{value1}}. Your workspaces and files are safe, and open again as soon as you add an authenticator app.', + { value0: teamName, value1: mfaDeadlineDate(enforceAt, locale) } + )} +

+
+
+ + {t('auto.components.teamMfa.setUp', 'Set up MFA')} + + +
+

+ {stillMissing && !checking + ? t( + 'auto.components.teamMfa.stillMissing', + 'Your account still has no two-factor authentication. Finish the setup in your browser, then try again.' + ) + : null} +

+ {onUsePersonal ? ( + + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/team-mfa/TeamMfaGate.tsx b/src/renderer/src/components/team-mfa/TeamMfaGate.tsx new file mode 100644 index 00000000000..b7cdf9657ef --- /dev/null +++ b/src/renderer/src/components/team-mfa/TeamMfaGate.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react' +import { useAppStore } from '@/store' +import { TeamMfaBanner } from './TeamMfaBanner' +import { TeamMfaBlockedScreen } from './TeamMfaBlockedScreen' +import { teamMfaSurface } from './team-mfa-copy' +import { useTeamMfaPolicy } from './use-team-mfa-policy' + +/** + * The active team's MFA requirement, for a surface that shows the team's cloud content. + * + * - `banner`: render above the content while the member still has time. + * - `blocked`: render in place of the content once the server refused the team's resources. + * `forceBlocked` is that refusal (`mfa_required`); the policy read alone also blocks when the + * server says so, so the screen shows without waiting for a failed request. + * + * Renders nothing for a personal account, a team with no policy, a member who has MFA, or a + * server without the policy API. + */ +export function useTeamMfaGate(options: { forceBlocked?: boolean } = {}): { + banner: React.JSX.Element | null + blocked: React.JSX.Element | null + /** The sidebar notice: the countdown, or "no access" once blocked. */ + sidebarBanner: React.JSX.Element | null + /** Set while the member still has time: what the one-time toast needs. */ + pendingToast: { orgId: string; teamName: string; enforceAt: number; setupUrl: string } | null +} { + const cloud = useAppStore((state) => state.orcaProfileAuthStatus?.cloud) + const selectOrg = useAppStore((state) => state.selectOrcaProfileOrg) + const orgId = cloud?.activeOrgId ?? null + const teamName = cloud?.activeOrgName ?? '' + const { policy, checking, refresh } = useTeamMfaPolicy(orgId) + const [now] = useState(() => Date.now()) + const [stillMissing, setStillMissing] = useState(false) + + if (!orgId || !policy?.policy) { + return { banner: null, blocked: null, sidebarBanner: null, pendingToast: null } + } + const surface = teamMfaSurface(policy) + const openSetup = (): void => { + void window.api.shell.openUrl(policy.setupUrl) + } + + // The server's refusal wins over a policy read that has not caught up, unless that read now + // says the member has MFA (they just set it up; the next request will go through). + const isBlocked = surface === 'blocked' || (options.forceBlocked === true && policy.self !== 'on') + const line = (blocked: boolean, variant: 'inline' | 'sidebar' = 'inline'): React.JSX.Element => ( + + ) + if (isBlocked) { + return { + banner: null, + sidebarBanner: line(true, 'sidebar'), + pendingToast: null, + blocked: ( + { + void refresh().then((next) => { + // Null: the read failed; say nothing rather than claim MFA is still missing. + setStillMissing(next !== null && next.self !== 'on') + }) + }} + onUsePersonal={() => void selectOrg(null)} + /> + ) + } + } + const pending = surface === 'banner' + return { + banner: pending ? line(false) : null, + sidebarBanner: pending ? line(false, 'sidebar') : null, + pendingToast: pending + ? { orgId, teamName, enforceAt: policy.policy.enforceAt, setupUrl: policy.setupUrl } + : null, + blocked: null + } +} diff --git a/src/renderer/src/components/team-mfa/team-mfa-copy.ts b/src/renderer/src/components/team-mfa/team-mfa-copy.ts new file mode 100644 index 00000000000..01f327171d6 --- /dev/null +++ b/src/renderer/src/components/team-mfa/team-mfa-copy.ts @@ -0,0 +1,116 @@ +// Copy and small rules shared by the MFA banner, the blocked screen and the Enterprise row. Pure, +// so the views and their tests agree on every boundary. +import type { Translate } from '@/i18n/use-translate' +import type { OrcaTeamMfaGrace, OrcaTeamMfaPolicy } from '../../../../shared/orca-team-mfa-policy' + +const DAY_MS = 24 * 60 * 60 * 1000 + +/** Whole days left, rounded up; 0 once the deadline has passed. Matches the server and the emails. */ +export function mfaDaysLeft(enforceAt: number, now: number): number { + return Math.max(0, Math.ceil((enforceAt - now) / DAY_MS)) +} + +/** + * What the person should see for this team: + * - `banner`: the team requires MFA, they have none, and there is still time. + * - `blocked`: the deadline passed; the team is closed to them until they set it up. + * - `none`: nothing to show (no policy, they have MFA, or their state is unknown). + * Only the server's `blocked` blocks: the desktop never decides that from the clock. + */ +export function teamMfaSurface(policy: OrcaTeamMfaPolicy | null): 'none' | 'banner' | 'blocked' { + if (!policy?.policy) { + return 'none' + } + if (policy.self === 'blocked') { + return 'blocked' + } + return policy.self === 'pending' ? 'banner' : 'none' +} + +/** From this many days before the deadline the notice stops being quiet (design-squad). */ +export const MFA_URGENT_DAYS = 3 + +/** + * How loud the notice is: quiet with more than three days left, a warning in the last three, + * and blocked only on the server's word (never from the clock). + */ +export type TeamMfaUrgency = 'quiet' | 'soon' | 'blocked' + +export function mfaUrgency(enforceAt: number, now: number, blocked: boolean): TeamMfaUrgency { + if (blocked) { + return 'blocked' + } + return mfaDaysLeft(enforceAt, now) <= MFA_URGENT_DAYS ? 'soon' : 'quiet' +} + +/** "Acme requires MFA in 2 days": the title once the deadline is close. */ +export function mfaSoonTitle(teamName: string, enforceAt: number, now: number, t: Translate): string { + const days = mfaDaysLeft(enforceAt, now) + if (days <= 0) { + return t('auto.components.teamMfa.soonTitleNow', '{{value0}} requires MFA now', { + value0: teamName + }) + } + return t('auto.components.teamMfa.soonTitle', '{{value0}} requires MFA in {{count}} days', { + value0: teamName, + count: days, + defaultValue_one: '{{value0}} requires MFA in {{count}} day' + }) +} + +/** + * The one-time toast is shown once per team and deadline, so a changed date announces itself + * again and a restart does not repeat it. + */ +export function mfaToastSeenKey(orgId: string, enforceAt: number): string { + return `orca.teamMfa.toastSeen:${orgId}:${enforceAt}` +} + +/** True the first time for a deadline; records it. A storage failure shows it rather than hide it. */ +export function claimMfaToast( + storage: Pick | null, + orgId: string, + enforceAt: number +): boolean { + const key = mfaToastSeenKey(orgId, enforceAt) + try { + if (storage?.getItem(key) === '1') { + return false + } + storage?.setItem(key, '1') + } catch { + // Private mode or a full quota: showing it again next launch is the lesser problem. + } + return true +} + +/** "6 days left", "1 day left", "Due now". i18next plurals, never ternaries on the count. */ +export function mfaCountdownLabel(enforceAt: number, now: number, t: Translate): string { + const days = mfaDaysLeft(enforceAt, now) + if (days <= 0) { + return t('auto.components.teamMfa.dueNow', 'Due now') + } + return t('auto.components.teamMfa.daysLeft', '{{count}} days left', { + count: days, + defaultValue_one: '{{count}} day left' + }) +} + +export function mfaDeadlineDate(enforceAt: number, locale: string): string { + return new Intl.DateTimeFormat(locale, { month: 'long', day: 'numeric', year: 'numeric' }).format( + enforceAt + ) +} + +export function mfaGraceLabel(grace: OrcaTeamMfaGrace, t: Translate): string { + switch (grace) { + case 'now': + return t('auto.components.teamMfa.graceNow', 'Immediately') + case 7: + return t('auto.components.teamMfa.grace7', 'In 7 days') + case 14: + return t('auto.components.teamMfa.grace14', 'In 14 days') + case 30: + return t('auto.components.teamMfa.grace30', 'In 30 days') + } +} diff --git a/src/renderer/src/components/team-mfa/team-mfa.test.tsx b/src/renderer/src/components/team-mfa/team-mfa.test.tsx new file mode 100644 index 00000000000..34bc8f81f65 --- /dev/null +++ b/src/renderer/src/components/team-mfa/team-mfa.test.tsx @@ -0,0 +1,317 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { translate } from '@/i18n/i18n' +import type { OrcaTeamMfaPolicy } from '../../../../shared/orca-team-mfa-policy' +import { RequireMfaRow, type RequireMfaRowProps } from './RequireMfaRow' +import { TeamMfaBanner } from './TeamMfaBanner' +import { TeamMfaBlockedScreen } from './TeamMfaBlockedScreen' +import { + claimMfaToast, + mfaCountdownLabel, + mfaDaysLeft, + mfaSoonTitle, + mfaUrgency, + teamMfaSurface +} from './team-mfa-copy' +import { describeError, describeInlineError } from '../cloud-workspaces/cloud-workspace-errors' + +afterEach(cleanup) + +const DAY = 86_400_000 +const NOW = Date.UTC(2026, 10, 18, 15) +const setupUrl = 'https://cloud.orca.dev/v1/account/security' + +const policy = (over: Partial = {}): OrcaTeamMfaPolicy => ({ + policy: { enforceAt: NOW + 6 * DAY, setAt: NOW - DAY }, + self: 'pending', + setupUrl, + ...over +}) + +describe('countdown', () => { + it('rounds up to whole days and stops at zero', () => { + expect(mfaDaysLeft(NOW + 7 * DAY, NOW)).toBe(7) + expect(mfaDaysLeft(NOW + 6 * DAY + 1, NOW)).toBe(7) + expect(mfaDaysLeft(NOW + DAY / 2, NOW)).toBe(1) + expect(mfaDaysLeft(NOW, NOW)).toBe(0) + expect(mfaDaysLeft(NOW - DAY, NOW)).toBe(0) + }) + + it('reads as days left, one day left, then due now', () => { + expect(mfaCountdownLabel(NOW + 6 * DAY, NOW, translate)).toBe('6 days left') + expect(mfaCountdownLabel(NOW + DAY, NOW, translate)).toBe('1 day left') + expect(mfaCountdownLabel(NOW, NOW, translate)).toBe('Due now') + }) +}) + +describe('what the member sees', () => { + it('is the banner while pending and the blocked screen only on the server’s word', () => { + expect(teamMfaSurface(policy())).toBe('banner') + expect(teamMfaSurface(policy({ self: 'blocked' }))).toBe('blocked') + // Past the deadline by the local clock, but the server still says pending: not blocked. + expect(teamMfaSurface(policy({ policy: { enforceAt: NOW - DAY, setAt: NOW - 8 * DAY } }))).toBe( + 'banner' + ) + }) + + it('is nothing with MFA on, an unknown state, or no policy', () => { + expect(teamMfaSurface(policy({ self: 'on' }))).toBe('none') + expect(teamMfaSurface(policy({ self: 'unknown' }))).toBe('none') + expect(teamMfaSurface(policy({ policy: null, self: 'off' }))).toBe('none') + expect(teamMfaSurface(policy({ policy: null, self: 'blocked' }))).toBe('none') + expect(teamMfaSurface(null)).toBe('none') + }) +}) + +describe('banner', () => { + it('names the team, the countdown and the date, and offers the setup action', () => { + const onSetUp = vi.fn() + const { container, getByRole } = render( + + ) + expect(container.textContent).toContain('Acme requires two-factor authentication. 6 days left.') + expect(container.textContent).toContain('November 24, 2026') + fireEvent.click(getByRole('button', { name: 'Set up MFA' })) + expect(onSetUp).toHaveBeenCalledTimes(1) + }) +}) + +describe('blocked screen', () => { + const screen = (props: { checking?: boolean; stillMissing?: boolean } = {}) => { + const handlers = { onSetUp: vi.fn(), onRecheck: vi.fn(), onUsePersonal: vi.fn() } + const view = render( + + ) + return { ...view, ...handlers } + } + + it('says what to do, not a generic error, and keeps the personal account reachable', () => { + const view = screen() + expect(view.container.textContent).toContain('Set up two-factor authentication to use Acme') + expect(view.container.textContent).toContain('Your workspaces and files are safe') + fireEvent.click(view.getByRole('button', { name: 'Set up MFA' })) + fireEvent.click(view.getByRole('button', { name: 'I’ve set it up' })) + fireEvent.click(view.getByRole('button', { name: 'Use my personal account for now' })) + expect(view.onSetUp).toHaveBeenCalledTimes(1) + expect(view.onRecheck).toHaveBeenCalledTimes(1) + expect(view.onUsePersonal).toHaveBeenCalledTimes(1) + }) + + it('ignores a second re-check while one runs, and says when MFA is still missing', () => { + const checking = screen({ checking: true }) + fireEvent.click(checking.getByRole('button', { name: 'I’ve set it up' })) + expect(checking.onRecheck).not.toHaveBeenCalled() + cleanup() + expect(screen({ stillMissing: true }).container.textContent).toContain( + 'Your account still has no two-factor authentication' + ) + }) +}) + +describe('Require MFA row', () => { + const manage = { available: true, pendingMembers: 2 } + const row = (props: Partial = {}) => { + const handlers = { + onRequire: vi.fn(), + onTurnOff: vi.fn(), + onRemind: vi.fn(), + onSetUpOwn: vi.fn() + } + const view = render( + + ) + return { ...view, ...handlers } + } + + it('requires MFA with the chosen grace period, 14 days by default', () => { + const view = row() + fireEvent.click(view.getByRole('button', { name: 'Require MFA' })) + expect(view.onRequire).toHaveBeenCalledWith(14) + }) + + it('holds the change back from an admin without MFA, and points them to setup', () => { + const view = row({ policy: policy({ policy: null, self: 'off', manage }) }) + fireEvent.click(view.getByRole('button', { name: 'Require MFA' })) + expect(view.onRequire).not.toHaveBeenCalled() + expect(view.container.textContent).toContain( + 'Set up two-factor authentication on your own account first' + ) + fireEvent.click(view.getByRole('button', { name: 'Set up MFA' })) + expect(view.onSetUpOwn).toHaveBeenCalledTimes(1) + }) + + it('holds turning it off back from an admin without MFA too', () => { + const view = row({ policy: policy({ self: 'pending', manage }) }) + fireEvent.click(view.getByRole('button', { name: 'Stop requiring' })) + expect(view.onTurnOff).not.toHaveBeenCalled() + }) + + it('shows the date, who is pending, and reminds everyone or one member', () => { + const view = row({ + policy: policy({ self: 'on', manage }), + pending: [ + { userId: 'u-ada', email: 'ada@acme.dev', displayName: 'Ada Lovelace', mfa: 'pending' }, + { userId: null, email: 'linus@acme.dev', mfa: 'pending' } + ] + }) + expect(view.container.textContent).toContain('Required from November 24, 2026. 6 days left.') + expect(view.container.textContent).toContain('2 members still need it') + fireEvent.click(view.getByRole('button', { name: 'Remind everyone' })) + expect(view.onRemind).toHaveBeenLastCalledWith() + // A member who never signed in to Orca has no id to remind by; only Ada gets a button. + const single = view.getAllByRole('button', { name: 'Remind' }) + expect(single).toHaveLength(1) + fireEvent.click(single[0]!) + expect(view.onRemind).toHaveBeenLastCalledWith('u-ada') + }) + + it('says members have no access once the deadline passed', () => { + const view = row({ + policy: policy({ + self: 'on', + manage, + policy: { enforceAt: NOW - DAY, setAt: NOW - 8 * DAY } + }), + pending: [{ userId: 'u-ada', email: 'ada@acme.dev', mfa: 'blocked' }] + }) + expect(view.container.textContent).toContain('Required since November 17, 2026') + expect(view.container.textContent).toContain('No access') + }) + + it('says plainly when a reminder was recorded but not emailed', () => { + const view = row({ + policy: policy({ self: 'on', manage }), + notice: { kind: 'reminded', recipients: 2, emailed: false } + }) + expect(view.container.textContent).toContain('nothing was sent') + expect(view.container.textContent).not.toContain('Reminder sent') + }) +}) + +describe('the server refusal in cloud workspace errors', () => { + it('says to set up MFA, never to sign in again', () => { + for (const explain of [describeError, describeInlineError]) { + const message = explain('unauthorized', 'mfa_required', translate) + expect(message).toContain('requires two-factor authentication') + expect(message).not.toMatch(/sign in/i) + } + // Any other unauthorized answer is still the sign-in message. + expect(describeInlineError('unauthorized', undefined, translate)).not.toContain('two-factor') + }) +}) + +describe('banner after the deadline', () => { + it('says there is no access until MFA is set up, and that the personal account is fine', () => { + const { container, getByRole } = render( + {}} + blocked + variant="sidebar" + /> + ) + // An alert once blocked; a quiet status while there is still time. + expect(container.querySelector('[data-team-mfa-banner="blocked"]')?.getAttribute('role')).toBe( + 'alert' + ) + expect(container.textContent).toContain('Acme requires two-factor authentication') + expect(container.textContent).toContain('until you set it up') + expect(container.textContent).toContain('Your personal account isn’t affected') + expect(container.textContent).not.toContain('days left') + expect(getByRole('button', { name: 'Set up MFA' })).toBeTruthy() + }) +}) + +describe('escalation', () => { + it('is quiet with more than three days left, a warning in the last three, blocked on the server', () => { + expect(mfaUrgency(NOW + 14 * DAY, NOW, false)).toBe('quiet') + expect(mfaUrgency(NOW + 3 * DAY + 1, NOW, false)).toBe('quiet') + expect(mfaUrgency(NOW + 3 * DAY, NOW, false)).toBe('soon') + expect(mfaUrgency(NOW + DAY, NOW, false)).toBe('soon') + // Past the deadline by the clock but not blocked by the server: still only a warning. + expect(mfaUrgency(NOW - DAY, NOW, false)).toBe('soon') + expect(mfaUrgency(NOW + 14 * DAY, NOW, true)).toBe('blocked') + }) + + it('names the days in the title once it is close', () => { + expect(mfaSoonTitle('Acme', NOW + 2 * DAY, NOW, translate)).toBe('Acme requires MFA in 2 days') + expect(mfaSoonTitle('Acme', NOW + DAY, NOW, translate)).toBe('Acme requires MFA in 1 day') + expect(mfaSoonTitle('Acme', NOW - 1, NOW, translate)).toBe('Acme requires MFA now') + }) + + it('marks the sidebar card with its urgency', () => { + const card = (daysLeft: number, blocked = false): string | null | undefined => + render( + {}} + blocked={blocked} + /> + ) + .container.querySelector('[data-team-mfa-banner]') + ?.getAttribute('data-team-mfa-banner') + expect(card(14)).toBe('quiet') + cleanup() + expect(card(2)).toBe('soon') + cleanup() + expect(card(-2, true)).toBe('blocked') + }) +}) + +describe('one-time toast', () => { + const memory = (): Pick => { + const map = new Map() + return { + getItem: (key) => map.get(key) ?? null, + setItem: (key, value) => void map.set(key, value) + } + } + + it('shows once per team and deadline, and again when the date changes', () => { + const storage = memory() + expect(claimMfaToast(storage, 'org_1', 100)).toBe(true) + expect(claimMfaToast(storage, 'org_1', 100)).toBe(false) + expect(claimMfaToast(storage, 'org_1', 200)).toBe(true) + expect(claimMfaToast(storage, 'org_2', 100)).toBe(true) + }) + + it('shows rather than hides when storage is unavailable', () => { + expect(claimMfaToast(null, 'org_1', 100)).toBe(true) + const broken = { + getItem: () => { + throw new Error('denied') + }, + setItem: () => {} + } + expect(claimMfaToast(broken, 'org_1', 100)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/team-mfa/use-team-mfa-policy.ts b/src/renderer/src/components/team-mfa/use-team-mfa-policy.ts new file mode 100644 index 00000000000..3b162b77502 --- /dev/null +++ b/src/renderer/src/components/team-mfa/use-team-mfa-policy.ts @@ -0,0 +1,70 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { OrcaTeamMfaPolicy } from '../../../../shared/orca-team-mfa-policy' + +export type TeamMfaPolicyState = { + /** Null until a read answers, when signed out of a team, or when the server has no policy API. */ + policy: OrcaTeamMfaPolicy | null + /** A read is running. */ + checking: boolean + /** Re-reads now; resolves with what the server said (null when it could not be read). */ + refresh: () => Promise +} + +/** + * The active team's "require MFA" policy and the person's own state in it. Read when the team + * changes and when the window regains focus: the person sets MFA up in the browser and comes back. + * A failed read keeps the last answer, so a flaky network neither hides the banner nor blocks. + */ +export function useTeamMfaPolicy(orgId: string | null): TeamMfaPolicyState { + const [policy, setPolicy] = useState(null) + const [checking, setChecking] = useState(false) + // Answers for a team the person has since switched away from are dropped. + const current = useRef(orgId) + current.current = orgId + + const refresh = useCallback(async (): Promise => { + if (!orgId) { + return null + } + setChecking(true) + let result: Awaited> | null + try { + result = (await window.api.orcaProfiles.teamMfaPolicy({ orgId })) ?? null + } catch { + result = null + } + if (current.current !== orgId) { + return null + } + setChecking(false) + if (result?.status !== 'ok') { + return null + } + const { status: _status, ...next } = result + setPolicy(next) + return next + }, [orgId]) + + useEffect(() => { + setPolicy(null) + setChecking(false) + if (!orgId) { + return + } + void refresh() + const onFocus = (): void => void refresh() + const onVisibility = (): void => { + if (document.visibilityState === 'visible') { + void refresh() + } + } + window.addEventListener('focus', onFocus) + document.addEventListener('visibilitychange', onVisibility) + return () => { + window.removeEventListener('focus', onFocus) + document.removeEventListener('visibilitychange', onVisibility) + } + }, [orgId, refresh]) + + return { policy, checking, refresh } +} diff --git a/src/renderer/src/components/teammates/TeammateProfileDialog.tsx b/src/renderer/src/components/teammates/TeammateProfileDialog.tsx index 5b0b7f3622c..ccd68a28629 100644 --- a/src/renderer/src/components/teammates/TeammateProfileDialog.tsx +++ b/src/renderer/src/components/teammates/TeammateProfileDialog.tsx @@ -8,7 +8,7 @@ import { useTranslate } from '@/i18n/use-translate' import { useAppStore } from '@/store/index' import type { TeammateProfile } from '../../../../shared/cloud-workspaces/team-presence-wire' import { useOrgRoster } from '../billing/team/use-org-roster' -import { openCloudWorkspacesHome } from '../cloud-workspaces/cloud-workspaces-home-nav' +import { openCloudWorkspacePicker } from '../cloud-workspaces/cloud-workspace-picker-store' import { signInAgainMessage } from '@/lib/orca-cloud/orca-sign-in-again' import { openCloudWorkspace } from '../cloud-workspaces/open-cloud-workspace' import { pokeRefusalCopy } from './poke-copy' @@ -107,7 +107,7 @@ function ProfileBody({ userId }: { userId: string }): React.JSX.Element { }} onShowAllWorkspaces={() => { closeTeammateProfile() - openCloudWorkspacesHome({ scope: 'team', by: userId }) + openCloudWorkspacePicker({ kind: 'teammate', userId }) }} onTurnOnPokes={() => { closeTeammateProfile() diff --git a/src/renderer/src/components/worktree-creation/VmBuildFailureActions.copy-links.test.tsx b/src/renderer/src/components/worktree-creation/VmBuildFailureActions.copy-links.test.tsx deleted file mode 100644 index 11c26b6d3af..00000000000 --- a/src/renderer/src/components/worktree-creation/VmBuildFailureActions.copy-links.test.tsx +++ /dev/null @@ -1,87 +0,0 @@ -// @vitest-environment happy-dom -// COPY-LINK-RULE on the build sheet: "How to push to GitHub" is a split button whose chevron copies -// GitHub's guide; "Contact support" lives in the More menu, so a "Copy support link" item sits -// right below it. -import { cleanup, render, screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })) -vi.mock('sonner', () => ({ toast })) - -import { TooltipProvider } from '@/components/ui/tooltip' -import { FailureActionRow, type VmBuildFailureActions } from './VmBuildFailureActions' -import type { VmBuildFailure } from './vm-build-failure' -import { ORCA_SUPPORT_URL, PUSH_TO_GITHUB_URL } from './vm-build-repo-actions' -import { translate } from '@/i18n/i18n' - -const writeClipboardText = vi.fn<(text: string) => Promise>() - -beforeEach(() => { - writeClipboardText.mockReset().mockResolvedValue(undefined) - Object.assign(window, { api: { ...window.api, ui: { writeClipboardText } } }) -}) -afterEach(cleanup) - -const actions = (): VmBuildFailureActions => ({ - onFixWithAgent: vi.fn(), - onRetry: vi.fn(), - onEditRecipe: vi.fn(), - onOpenAnyway: vi.fn(), - onStartAgain: vi.fn(), - onDismiss: vi.fn(), - onSeePlans: vi.fn(), - onOpenBilling: vi.fn(), - onCreateLocally: vi.fn(), - onSignIn: vi.fn(), - onShowWorkspaces: vi.fn(), - onContactSupport: vi.fn(), - onConnectGitHub: vi.fn(), - onPushToGitHub: vi.fn() -}) - -function row(failure: VmBuildFailure, handlers: VmBuildFailureActions) { - return render( - - - - ) -} - -describe('build sheet external links (COPY-LINK-RULE)', () => { - it('"How to push to GitHub" opens as before, and its chevron copies the guide without opening', async () => { - const handlers = actions() - row({ kind: 'noGithubRemote' }, handlers) - const user = userEvent.setup() - await user.click(screen.getByRole('button', { name: 'How to push to GitHub' })) - expect(handlers.onPushToGitHub).toHaveBeenCalledOnce() - expect(writeClipboardText).not.toHaveBeenCalled() - - await user.click(screen.getByRole('button', { name: 'More link options' })) - await user.click(await screen.findByRole('menuitem', { name: 'Copy link' })) - await waitFor(() => expect(writeClipboardText).toHaveBeenCalledWith(PUSH_TO_GITHUB_URL)) - expect(handlers.onPushToGitHub).toHaveBeenCalledOnce() - }) - - it('"Copy support link" sits right below "Contact support" and copies without opening', async () => { - const handlers = actions() - row({ kind: 'snapshotMissing' }, handlers) - const user = userEvent.setup() - await user.click(screen.getByRole('button', { name: /More/ })) - const items = (await screen.findAllByRole('menuitem')).map((item) => item.textContent) - expect(items).toEqual(['Contact support', 'Copy support link']) - await user.click(screen.getByRole('menuitem', { name: 'Copy support link' })) - await waitFor(() => expect(writeClipboardText).toHaveBeenCalledWith(ORCA_SUPPORT_URL)) - expect(handlers.onContactSupport).not.toHaveBeenCalled() - }) -}) diff --git a/src/renderer/src/components/worktree-creation/VmBuildFailureActions.tsx b/src/renderer/src/components/worktree-creation/VmBuildFailureActions.tsx index 8ee91cf353a..4187554cc97 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildFailureActions.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildFailureActions.tsx @@ -7,7 +7,6 @@ import { Github, Loader2, LifeBuoy, - Link2, LogIn, Monitor, PanelLeft, @@ -23,13 +22,6 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import type { Translate } from '@/i18n/use-translate' -import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' -import { - copyExternalLink, - type ExternalLinkSource -} from '@/components/open-external/copy-external-link' -import { ORCA_SUPPORT_URL, PUSH_TO_GITHUB_URL } from './vm-build-repo-actions' -import { gitHubInstallLinkSource } from '@/lib/orca-cloud/github-link-sources' import type { VmBuildFailure } from './vm-build-failure' export type VmBuildFailureActions = { @@ -61,8 +53,6 @@ type Action = { pending?: boolean /** Outline even when first: a way around the problem, not its fix. */ secondary?: boolean - /** Opens this page in the browser: a chevron beside it offers "Copy link". */ - link?: ExternalLinkSource /** A More-menu row that leaves Orca for the browser: marked like the Help menu's. */ external?: boolean } @@ -116,7 +106,7 @@ function failureActions( primary: [ { key: 'plans', - label: t('auto.lib.orcaCloudSession.seePlans', 'See plans'), + label: t('auto.components.billing.comparePlans', 'Compare plans'), onClick: actions.onSeePlans }, // Paying elsewhere (another device, the portal) is picked up by trying again. @@ -240,17 +230,6 @@ function failureActions( icon: , onClick: actions.onContactSupport, external: true - }, - // A menu row can't carry the split's chevron: the same page, copied for another browser - // (COPY-LINK-RULE, as "Copy GitHub link" under "Add a GitHub repository…"). - { - key: 'copySupportLink', - label: t( - 'auto.components.worktree.creation.VmBuildFailureCard.copySupportLink', - 'Copy support link' - ), - icon: , - onClick: () => void copyExternalLink(ORCA_SUPPORT_URL) } ] } @@ -270,9 +249,7 @@ function failureActions( ), icon: connectingGitHub ? : , onClick: actions.onConnectGitHub, - pending: connectingGitHub, - // The same install page connectOrcaCloudGitHub opens (no repository preselected). - link: gitHubInstallLinkSource() + pending: connectingGitHub }, tryAgain ], @@ -288,8 +265,7 @@ function failureActions( 'How to push to GitHub' ), icon: , - onClick: actions.onPushToGitHub, - link: PUSH_TO_GITHUB_URL + onClick: actions.onPushToGitHub }, // After pushing, the same create works without leaving the sheet. tryAgain @@ -339,37 +315,21 @@ export function FailureActionRow({ const destroys = vmKept return (
- {primary.map((action, index) => - action.link ? ( - (action.pending ? undefined : action.onClick())} - disabled={action.disabled} - aria-disabled={action.pending || undefined} - aria-busy={action.pending || undefined} - > - {action.icon} - {action.label} - - ) : ( - - ) - )} + {primary.map((action, index) => ( + + ))} {more.length > 0 ? ( diff --git a/src/renderer/src/components/worktree-creation/VmBuildFailureCard.test.tsx b/src/renderer/src/components/worktree-creation/VmBuildFailureCard.test.tsx index f14cdc4ecbb..b39ca763700 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildFailureCard.test.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildFailureCard.test.tsx @@ -91,11 +91,11 @@ it('keeps a pending Connect GitHub focusable and ignores repeat clicks', () => { expect(document.activeElement).toBe(button) }) -it('offers an owner See plans then Try again on no plan', () => { +it('offers an owner Compare plans then Try again on no plan', () => { const handlers = actions() render() const buttons = screen.getAllByRole('button').map((button) => button.textContent?.trim()) - expect(buttons.slice(0, 2)).toEqual(['See plans', 'Try again']) + expect(buttons.slice(0, 2)).toEqual(['Compare plans', 'Try again']) fireEvent.click(screen.getByRole('button', { name: /Try again/ })) expect(handlers.onRetry).toHaveBeenCalledOnce() }) @@ -106,7 +106,7 @@ it.each(['plan', 'hoursUsed', 'spendCap', 'payment'] as const)( const handlers = actions() render() expect( - screen.queryByRole('button', { name: /See plans|Raise spend cap|Turn on overage/ }) + screen.queryByRole('button', { name: /Compare plans|Raise spend cap|Turn on overage/ }) ).toBeNull() fireEvent.click(screen.getByRole('button', { name: /Use this computer instead/ })) expect(handlers.onCreateLocally).toHaveBeenCalledOnce() diff --git a/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx b/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx index fafb72079f7..8ebc473c98e 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { openCloudWorkspacesHome } from '../cloud-workspaces/cloud-workspaces-home-nav' +import { openCloudWorkspacePicker } from '../cloud-workspaces/cloud-workspace-picker-store' import { AlertTriangle, ChevronRight, CircleAlert, CircleCheck, Cloud, Loader2 } from 'lucide-react' import { Button } from '@/components/ui/button' import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open' @@ -374,8 +374,8 @@ export function VmBuildSheet({ store.openSettingsPage() }, onSignIn: signIn, - // Your workspaces on Home, where you can put one to sleep or delete it. - onShowWorkspaces: () => openCloudWorkspacesHome({ running: true }), + // Your running workspaces, where you can put one to sleep or delete it. + onShowWorkspaces: () => openCloudWorkspacePicker({ kind: 'running' }), onContactSupport: () => openAppHttpLink(ORCA_SUPPORT_URL), onConnectGitHub: connectGitHub, onPushToGitHub: () => openAppHttpLink(PUSH_TO_GITHUB_URL) diff --git a/src/renderer/src/components/worktree-creation/VmBuildSheetCopyLinks.fixture.tsx b/src/renderer/src/components/worktree-creation/VmBuildSheetCopyLinks.fixture.tsx deleted file mode 100644 index dbf931dff20..00000000000 --- a/src/renderer/src/components/worktree-creation/VmBuildSheetCopyLinks.fixture.tsx +++ /dev/null @@ -1,58 +0,0 @@ -// COPY-LINK-RULE on the build sheet: "How to push to GitHub" as a split button (its chevron menu -// open), and "Copy support link" right below "Contact support" in the More menu. -import { defineFixture } from '../../component-fixtures/component-fixture' -import { - CLOUD_RECIPE, - FAILED_LOG, - T0, - entry, - seedFixtureRepo, - sheet -} from './vm-build-sheet-fixture-support' - -const settle = (): Promise => new Promise((resolve) => setTimeout(resolve, 120)) - -// Radix menus open on pointerdown, not click. -function openMenu(selector: string) { - return async (frame: HTMLElement): Promise => { - frame - .querySelector(selector) - ?.dispatchEvent( - new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerType: 'mouse' }) - ) - await settle() - } -} - -function failed(errorCode: string, error: string, log: string) { - return { - value: entry( - { status: 'error', errorCode, provisioningLog: log, errorAt: T0 + 2_000 }, - CLOUD_RECIPE - ), - error - } -} - -const noRemote = failed( - 'cloudNoGithubRemote', - 'Orca Cloud clones your code from GitHub, and this repository has no GitHub remote.', - '' -) -const snapshot = failed('recipeExited', 'Recipe exited with code 1.', FAILED_LOG) - -/** "How to push to GitHub" with its chevron open: Copy link. */ -export const pushToGitHubCopyLink = defineFixture({ - width: 760, - setup: () => seedFixtureRepo(null), - render: () => sheet(noRemote.value, noRemote.error, T0 + 2_000), - interact: openMenu('button[aria-label="More link options"]') -}) - -/** "Contact support" in More, with "Copy support link" right below it. */ -export const contactSupportCopyLink = defineFixture({ - width: 760, - setup: () => seedFixtureRepo(), - render: () => sheet(snapshot.value, snapshot.error, T0 + 2_000), - interact: openMenu('[data-vm-build-sheet] button[aria-haspopup="menu"]') -}) diff --git a/src/renderer/src/components/worktree-creation/VmBuildSheetGitHub.fixture.tsx b/src/renderer/src/components/worktree-creation/VmBuildSheetGitHub.fixture.tsx index 2292e4a3429..22173568a8a 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildSheetGitHub.fixture.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildSheetGitHub.fixture.tsx @@ -79,7 +79,7 @@ export const openFailed = fixture( T0 + 12_000, { error: - 'Your cloud workspace started, but this computer couldn’t open it yet. Open it from Cloud workspaces.' + 'Your cloud workspace started, but this computer couldn’t open it yet. Try again, or open it later from Open cloud workspace…' } ) @@ -100,6 +100,6 @@ export const refusedPlan = refused('no_subscription') export const refusedHoursUsed = refused('included_hours_exhausted') export const refusedSpendCap = refused('overage_cap_reached') export const refusedPayment = refused('payment_past_due') -// A member can't fix billing: the card names who can, with no "See plans". +// A member can't fix billing: the card names who can, with no "Compare plans". export const refusedPlanMember = refused('no_subscription', false) export const refusedHoursUsedMember = refused('included_hours_exhausted', false) diff --git a/src/renderer/src/components/worktree-creation/vm-build-failure-toast.ts b/src/renderer/src/components/worktree-creation/vm-build-failure-toast.ts index 4e1ac5825b1..655aff2010e 100644 --- a/src/renderer/src/components/worktree-creation/vm-build-failure-toast.ts +++ b/src/renderer/src/components/worktree-creation/vm-build-failure-toast.ts @@ -62,7 +62,7 @@ export function showVmBuildFailureToast( ...(failure.kind === 'plan' ? { action: { - label: translate('auto.lib.orcaCloudSession.seePlans', 'See plans'), + label: translate('auto.components.billing.comparePlans', 'Compare plans'), onClick: () => openPlansPage('sandbox') } } diff --git a/src/renderer/src/components/worktree-jump/use-worktree-jump-palette-quick-actions.ts b/src/renderer/src/components/worktree-jump/use-worktree-jump-palette-quick-actions.ts index 0f474c8e3a7..cfd11e054eb 100644 --- a/src/renderer/src/components/worktree-jump/use-worktree-jump-palette-quick-actions.ts +++ b/src/renderer/src/components/worktree-jump/use-worktree-jump-palette-quick-actions.ts @@ -28,6 +28,13 @@ import { refreshCloudEnvironmentIds, useCloudEnvironmentIds } from '@/lib/cloud-remote-desktop/cloud-environment-ids' +import { openCloudWorkspacePicker } from '@/components/cloud-workspaces/cloud-workspace-picker-store' +import { useCloudWorkspacesPresent } from '@/components/cloud-workspaces/use-cloud-workspaces' + +function openCloudWorkspacePickerAction(): void { + // After the palette closes, so focus lands in the picker's search field. + queueMicrotask(() => openCloudWorkspacePicker()) +} function openCloudRemoteDesktopAction(worktreeId: string, environmentId: string): void { // Loaded on use: the desktop flow pulls in the cloud workspace store and toasts. @@ -87,6 +94,8 @@ export function useWorktreeJumpPaletteQuickActions({ }: WorktreeJumpPaletteQuickActionsInput) { // "Open remote desktop" shows only for Orca Cloud workspaces: read the list fresh on open. const cloudEnvironmentIds = useCloudEnvironmentIds() + // "Open cloud workspace…" shows only while signed in to Orca Cloud. + const cloudPresent = useCloudWorkspacesPresent() useEffect(() => { if (visible) { void refreshCloudEnvironmentIds() @@ -173,9 +182,11 @@ export function useWorktreeJumpPaletteQuickActions({ openCreateWorkspace: openCreateWorkspaceAction, deleteActiveWorkspace: deleteActiveWorkspaceAction, openAddQuickCommand: openAddQuickCommandAction, - openCloudRemoteDesktop: openCloudRemoteDesktopAction + openCloudRemoteDesktop: openCloudRemoteDesktopAction, + openCloudWorkspacePicker: cloudPresent ? openCloudWorkspacePickerAction : undefined }), [ + cloudPresent, activeGroupSnapshotRef, deleteActiveWorkspaceAction, openAddQuickCommandAction, @@ -202,7 +213,8 @@ export function useWorktreeJumpPaletteQuickActions({ isLoading, settings?.activeRuntimeEnvironmentId, runtimeStatusByEnvironmentId, - cloudEnvironmentIds + cloudEnvironmentIds, + cloudPresent ], () => { const context = buildQuickActionContext() @@ -247,7 +259,8 @@ export function useWorktreeJumpPaletteQuickActions({ isLoading, settings?.activeRuntimeEnvironmentId, runtimeStatusByEnvironmentId, - cloudEnvironmentIds + cloudEnvironmentIds, + cloudPresent ] ) const middleItems = useMemo<(SettingsPaletteItem | QuickActionPaletteItem)[]>(() => { diff --git a/src/renderer/src/hooks/settings-navigation-build-options.ts b/src/renderer/src/hooks/settings-navigation-build-options.ts index f72d0eb58f8..9f6cca5f58a 100644 --- a/src/renderer/src/hooks/settings-navigation-build-options.ts +++ b/src/renderer/src/hooks/settings-navigation-build-options.ts @@ -15,6 +15,8 @@ export type SettingsNavigationBuildOptions = { isLinearConnected: boolean /** Orca Cloud programmatic access (API keys, routines): only builds with a control plane. */ isOrcaCloudApiAvailable?: boolean + /** Owner or admin of the active Orca team: Settings › Enterprise is theirs alone. */ + isOrcaTeamAdmin?: boolean repos: readonly Repo[] projectGrouping?: ProjectGroupingModel } diff --git a/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.test.ts b/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.test.ts index cb8b4328ccb..f2bc2b3b428 100644 --- a/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.test.ts +++ b/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.test.ts @@ -53,6 +53,33 @@ describe('Orca Cloud settings group', () => { expect(new Set(sections.map((section) => section.group))).toEqual(new Set(['orca-cloud'])) }) + it('adds Enterprise after Team for a team owner or admin only', () => { + const ids = (patch: Parameters[0]) => + buildOrcaCloudSettingsSections(options(patch)).map((section) => section.id) + expect(ids({})).not.toContain('cloud-enterprise') + expect(ids({ isOrcaTeamAdmin: false })).not.toContain('cloud-enterprise') + const admin = ids({ isOrcaTeamAdmin: true }) + expect(admin.slice(0, 4)).toEqual([ + 'cloud-billing', + 'cloud-team', + 'cloud-enterprise', + 'cloud-secrets' + ]) + const section = buildOrcaCloudSettingsSections(options({ isOrcaTeamAdmin: true })).find( + (entry) => entry.id === 'cloud-enterprise' + )! + expect(section).toMatchObject({ title: 'Enterprise', group: 'orca-cloud' }) + // The audit log is found by searching for it; it no longer lives under Orca API keys. + const keywords = (id: string) => + buildOrcaCloudSettingsSections(options({ isOrcaTeamAdmin: true })) + .find((entry) => entry.id === id)! + .searchEntries.flatMap((entry) => entry.keywords ?? []) + expect(keywords('cloud-enterprise')).toContain('audit log') + expect(keywords('cloud-api-keys')).not.toContain('audit log') + // Never without a control plane, whatever the role. + expect(ids({ isOrcaTeamAdmin: true, isOrcaCloudApiAvailable: false })).toEqual([]) + }) + it('is absent without a control plane and on the web client', () => { expect(buildOrcaCloudSettingsSections(options({ isOrcaCloudApiAvailable: false }))).toEqual([]) expect(buildOrcaCloudSettingsSections(options({ isWebClient: true }))).toEqual([]) diff --git a/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.ts b/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.ts index 0d16b505b8e..edc2e5fc797 100644 --- a/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.ts +++ b/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.ts @@ -4,6 +4,7 @@ import { } from '@/components/settings/cloud-api/cloud-api-search' import { getOrcaCloudBillingSearchEntries, + getOrcaCloudEnterpriseSearchEntries, getOrcaCloudEnvironmentsSearchEntries, getOrcaCloudLocalCommandsSearchEntries, getOrcaCloudMcpSearchEntries, @@ -13,11 +14,15 @@ import { } from '@/components/settings/orca-cloud/orca-cloud-settings-search' import { translate } from '@/i18n/i18n' import type { Translate } from '@/i18n/use-translate' -import type { SettingsNavSection, SettingsNavTarget } from '@/components/settings/navigation/settings-navigation-types' +import type { + SettingsNavSection, + SettingsNavTarget +} from '@/components/settings/navigation/settings-navigation-types' import type { OrcaProfileAuthStatus } from '../../../shared/orca-profiles' import { BookOpenText, Boxes, + Building2, CreditCard, KeySquare, LockKeyhole, @@ -31,7 +36,7 @@ import { CLOUD_ROUTINES_UI_ENABLED } from '../../../shared/cloud-api/cloud-routi /** One home for everything Orca Cloud; only builds with a control plane show it. */ export function buildOrcaCloudSettingsSections( - { isWebClient, isOrcaCloudApiAvailable }: SettingsNavigationBuildOptions, + { isWebClient, isOrcaCloudApiAvailable, isOrcaTeamAdmin }: SettingsNavigationBuildOptions, t: Translate = translate ): SettingsNavSection[] { if (isWebClient || !isOrcaCloudApiAvailable) { @@ -61,6 +66,22 @@ export function buildOrcaCloudSettingsSections( searchEntries: getOrcaCloudTeamSearchEntries(), group }, + // Team owners and admins only; members and personal accounts never see the entry. + ...(isOrcaTeamAdmin + ? [ + { + id: 'cloud-enterprise', + title: t('settings.enterprise.title', 'Enterprise'), + description: t( + 'settings.enterprise.description', + 'Single sign-on, sign-in policies and the audit log for your team.' + ), + icon: Building2, + searchEntries: getOrcaCloudEnterpriseSearchEntries(), + group + } + ] + : []), { id: 'cloud-secrets', title: t('cloudSecrets.title', 'Cloud secrets'), diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index 4095d17fec1..ea7f3a896ba 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -15,6 +15,7 @@ import { useWindowsTerminalCapabilities } from '@/runtime/hosts/capabilities/windows-terminal-capabilities' import { defaultScopeHost } from '@/runtime/hosts/selection/default-creation-host' +import { isActiveTeamAdmin } from '@/lib/orca-cloud/active-team-role' import { useAppStore } from '@/store/index' import { useProjectHostSetupProjection } from '@/store/projects/use-project-host-setup-projection' import { @@ -62,6 +63,7 @@ export function buildSettingsNavigationMetadata( isDev = import.meta.env.DEV, isLinearConnected = false, isOrcaCloudApiAvailable = false, + isOrcaTeamAdmin = false, repos, projectGrouping }: { @@ -77,6 +79,7 @@ export function buildSettingsNavigationMetadata( isDev?: boolean isLinearConnected?: boolean isOrcaCloudApiAvailable?: boolean + isOrcaTeamAdmin?: boolean repos: readonly Repo[] projectGrouping?: ProjectGroupingModel }, @@ -112,6 +115,7 @@ export function buildSettingsNavigationMetadata( isDev, isLinearConnected, isOrcaCloudApiAvailable, + isOrcaTeamAdmin, repos, projectGrouping } @@ -153,6 +157,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { const isWebClient = isWebClientLocation() const isLinearConnected = useLinearProviderConnected() const isOrcaCloudApiAvailable = useOrcaCloudApiAvailable() + const isOrcaTeamAdmin = useAppStore((state) => isActiveTeamAdmin(state.orcaProfileAuthStatus)) const windowsTerminalCapabilityOwnerKey = useWindowsTerminalCapabilityOwnerKey( settings?.activeRuntimeEnvironmentId ) @@ -197,6 +202,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { isDev: import.meta.env.DEV, isLinearConnected, isOrcaCloudApiAvailable, + isOrcaTeamAdmin, repos, projectGrouping: { projects: projectHostSetupProjection.projects, @@ -218,6 +224,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { mobileEmulatorCreationEnabled, isLinearConnected, isOrcaCloudApiAvailable, + isOrcaTeamAdmin, repos, projectHostSetupProjection, languageRevision, diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index a987535364d..bf4e4552086 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -242,18 +242,10 @@ "blockedWorkspaces_one": "{{count}} cloud workspace still exists. Delete it first.", "blockedWorkspaces_other": "{{count}} cloud workspaces still exist. Delete them first." }, - "inviteSeatAddsMonth_one": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat ({{price}}/mo, prorated) will be added.", - "inviteSeatAddsMonth_other": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats ({{price}}/mo, prorated) will be added.", - "inviteSeatAddsUnpriced_one": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat will be added, prorated.", - "inviteSeatAddsUnpriced_other": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats will be added, prorated.", - "inviteSeatAddsYear_one": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat ({{price}}/yr, prorated) will be added.", - "inviteSeatAddsYear_other": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats ({{price}}/yr, prorated) will be added.", "invitedCount_one": "{{count}} invited", "invitedCount_other": "{{count}} invited", "memberCount_one": "{{count}} member", "memberCount_other": "{{count}} members", - "openSeatsClause_one": "You have {{count}} open seat", - "openSeatsClause_other": "You have {{count}} open seats", "seatCount_one": "{{count}} seat", "seatCount_other": "{{count}} seats", "sendInvites_one": "Send {{count}} invite", @@ -301,12 +293,8 @@ "errorTeamSandboxLimit_other": "Your team can run {{count}} sandboxes at once ({{perSeat}} per seat). Put one to sleep or delete it, then try again.", "errorTrialSandboxLimit_one": "Your trial can run {{count}} sandbox at once. Put it to sleep or delete it, then try again.", "errorTrialSandboxLimit_other": "Your trial can run {{count}} sandboxes at once. Put one to sleep or delete it, then try again.", - "listMetaCount_one": "{{count}} workspace", - "listMetaCount_other": "{{count}} workspaces", "rowSandboxHours_one": "{{hours}} sandbox hour", - "rowSandboxHours_other": "{{hours}} sandbox hours", - "sectionCount_one": "{{count}} workspace", - "sectionCount_other": "{{count}} workspaces" + "rowSandboxHours_other": "{{hours}} sandbox hours" }, "dashboard": { "DashboardAgentChildDisclosure": { @@ -1374,6 +1362,18 @@ "unavailableCount_one": "{{count}} unavailable", "unavailableCount_other": "{{count}} unavailable" }, + "teamMfa": { + "daysLeft_one": "{{count}} day left", + "daysLeft_other": "{{count}} days left", + "nowWarning_one": "{{count}} member doesn’t have it yet and will lose access to this team right away.", + "nowWarning_other": "{{count}} members don’t have it yet and will lose access to this team right away.", + "pendingCount_one": "{{count}} member still needs it", + "pendingCount_other": "{{count}} members still need it", + "reminded_one": "Reminder sent to {{count}} member.", + "reminded_other": "Reminder sent to {{count}} members.", + "soonTitle_one": "{{value0}} requires MFA in {{count}} day", + "soonTitle_other": "{{value0}} requires MFA in {{count}} days" + }, "teammates": { "daySandboxHours_one": "{{hours}} sandbox hour", "daySandboxHours_other": "{{hours}} sandbox hours" @@ -1841,6 +1841,12 @@ } }, "components": { + "cloudWorkspaces": { + "picker": { + "inSidebarNote_one": "{{count}} more is already in your sidebar.", + "inSidebarNote_other": "{{count}} more are already in your sidebar." + } + }, "confirmation": { "itemList": { "showMore_one": "Show {{count}} more", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index da425f3ca4c..53b9a0f04d0 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1478,6 +1478,144 @@ "title2": "Account & billing", "accountOnly": "Account", "accountOnlyDescription": "Your Orca account and what’s included with it." + }, + "enterprise": { + "audit": { + "actor": { + "system": "Orca", + "youViaAgent": "Your agent", + "personViaAgent": "’s agent", + "youViaRoutine": "Your routine", + "personViaRoutine": "’s routine" + }, + "filter": { + "categoryLabel": "Filter by activity", + "allActivity": "All activity", + "actorLabel": "Filter by person", + "everyone": "Everyone" + }, + "title": "Audit log", + "description": "Member, key, secret, workspace and settings changes across your team. Only owners and admins can see it.", + "export": "Export CSV", + "upsellTitle": "Audit log is part of {{plan}}", + "upsellBody": "Orca records your team’s changes on every plan, so your full history is here from the day you upgrade.", + "loading": "Loading the audit log", + "exportTruncated": "The file has the newest 5,000 events. Filter by activity or person to export the rest.", + "emptyFilteredTitle": "Nothing matches", + "emptyTitle": "Nothing recorded yet", + "emptyFilteredBody": "No recorded change matches these filters.", + "emptyBody": "Changes to members, API keys, secrets, workspaces and settings will show up here.", + "clearFilters": "Clear filters", + "showMore": "Show more", + "category": { + "members": "Members", + "security": "Security", + "apiKeys": "API keys", + "apps": "Connected apps", + "workspaces": "Workspaces", + "secrets": "Secrets", + "routines": "Routines", + "settings": "Settings", + "billing": "Billing", + "other": "Other" + }, + "action": { + "keyCreateNamed": "Created API key “{{name}}”", + "keyRevokeNamed": "Revoked API key “{{name}}”", + "workspaceToken": "Gave an agent access to the Orca API", + "wsCreateNamed": "Started workspace “{{name}}”", + "wsDestroyNamed": "Deleted workspace “{{name}}”", + "wsSleepNamed": "Put workspace “{{name}}” to sleep", + "wsWakeNamed": "Woke workspace “{{name}}”", + "wsArchiveNamed": "Archived workspace “{{name}}”", + "wsUnarchive": "Restored a workspace", + "wsUnarchiveNamed": "Restored workspace “{{name}}”", + "wsRename": "Renamed a workspace", + "wsRenameNamed": "Renamed a workspace to “{{name}}”", + "wsShare": "Shared a link to a workspace port", + "wsShareNamed": "Shared a link to a port on workspace “{{name}}”", + "wsUnshare": "Turned off a workspace share link", + "wsUnshareNamed": "Turned off a share link on workspace “{{name}}”", + "wsAttachment": "Sent a file to an agent", + "sessionCreate": "Started an agent", + "sessionCancel": "Stopped an agent", + "sessionRename": "Renamed an agent session", + "sshAdd": "Added an SSH key to a workspace", + "sshAddNamed": "Added an SSH key to workspace “{{name}}”", + "sshRemove": "Removed an SSH key from a workspace", + "sshRemoveNamed": "Removed an SSH key from workspace “{{name}}”", + "projectCreateNamed": "Created project “{{name}}”", + "mcpAuthorize": "Connected an app", + "mcpRevoke": "Disconnected an app", + "mcpCodeReplay": "Blocked a reused app sign-in code and disconnected the app", + "mcpRefreshReplay": "Blocked a reused app token and disconnected the app", + "secretCreate": "Added a secret", + "secretCreateNamed": "Added secret {{name}}", + "secretUpdate": "Changed a secret", + "secretUpdateNamed": "Changed secret {{name}}", + "secretDelete": "Deleted a secret", + "secretDeleteNamed": "Deleted secret {{name}}", + "memberInvite": "Invited a teammate", + "memberInviteNamed": "Invited {{name}}", + "inviteRevoke": "Canceled an invite", + "inviteRevokeNamed": "Canceled the invite for {{name}}", + "memberRemove": "Removed a teammate", + "memberRemoveNamed": "Removed {{name}} from the team", + "memberLeft": "Left the team", + "roleChange": "Changed a teammate’s role", + "roleChangeNamed": "Changed the role of {{name}}", + "roleSync": "Updated a teammate’s API keys to match their role", + "teamCreate": "Created the team", + "teamRename": "Renamed the team", + "teamRenameNamed": "Renamed the team to “{{name}}”", + "routineCreate": "Created a routine", + "routineUpdate": "Edited a routine", + "routineDelete": "Deleted a routine", + "routineDisable": "Turned off a routine", + "routineRun": "Ran a routine", + "routineRunStart": "A routine run started", + "routineRunFinish": "A routine run finished", + "routineWebhook": "Replaced a routine’s webhook URL", + "memberJoined": "Joined the team", + "teamDelete": "Deleted the team", + "planStarted": "Plan started", + "planChanged": "Plan changed", + "planCanceled": "Plan ended", + "cancelScheduled": "Plan set to end at the next renewal", + "cancelReverted": "Plan set to renew again", + "seatsChanged": "Seats changed", + "overageOn": "Turned on overage", + "overageOff": "Turned off overage", + "planStartedDetail": "Plan started ({{plan}}, {{interval}})", + "planChangedDetail": "Plan changed to {{plan}}, {{interval}}", + "cancelScheduledOn": "Plan set to end on {{date}}", + "seatsChangedDetail": "Seats changed from {{from}} to {{to}}", + "spendCapChanged2": "Changed the monthly spend cap", + "spendCapChangedTo": "Changed the monthly spend cap to {{amount}}" + }, + "interval": { + "month": "monthly", + "year": "annual" + } + }, + "sso": { + "upsellTitle": "Single sign-on is part of {{plan}}", + "upsellBody": "Sign in with your identity provider over SAML, and add or remove teammates with SCIM.", + "managedTitle": "Single sign-on is set up with us", + "managedBody": "To set up or change single sign-on, contact us." + }, + "title": "Enterprise", + "searchDescription": "Single sign-on and directory sync, and the audit log of changes across your team.", + "keywordAudit": "audit log", + "keywordActivity": "activity", + "keywordSso": "sso", + "keywordSaml": "saml", + "keywordScim": "scim", + "keywordCompliance": "compliance", + "description": "Single sign-on, sign-in policies and the audit log for your team.", + "bannerTitle": "Single sign-on and the audit log are part of {{plan}}", + "bannerBody": "Talk to us about moving your team to {{plan}}.", + "contactUs": "Contact us" } }, "menu": { @@ -2434,7 +2572,6 @@ "backgroundFailedCloud": "A new cloud workspace couldn’t start. Open it in the sidebar to continue." }, "orcaCloudSession": { - "seePlans": "See plans", "workspaceGone": "That workspace is no longer in Orca.", "thisWorkspaceLower": "this workspace", "sandboxRestartSoonDetail": "Cloud sandboxes restart after 24 hours. Files are kept and Orca resumes your agents after the restart.", @@ -2472,7 +2609,7 @@ "archiveSleepFailed": "It’s still running. Your files are safe." }, "pairedWake": { - "notPaired": "This workspace is no longer linked to this computer. Open it again from Cloud workspaces on Home." + "notPaired2": "This workspace is no longer linked to this computer. Use Open cloud workspace… to link it again." } }, "cli": { @@ -4577,8 +4714,7 @@ "trialLimitBodyCount_other": "Your trial can run {{count}} sandboxes at once. Put one to sleep or delete it, then try again. Nothing was created, and you weren’t charged.", "teamLimitBodyNoSeatCount_one": "Your team can run {{count}} sandbox at once. Put it to sleep or delete it, then try again. Nothing was created, and you weren’t charged.", "teamLimitBodyNoSeatCount_other": "Your team can run {{count}} sandboxes at once. Put one to sleep or delete it, then try again. Nothing was created, and you weren’t charged.", - "trialLimitTitle": "You’re at your trial’s limit of running sandboxes", - "copySupportLink": "Copy support link" + "trialLimitTitle": "You’re at your trial’s limit of running sandboxes" }, "VmBuildLog": { "jumpToError": "Jump to first error", @@ -9018,19 +9154,26 @@ "adminOnly": "Only team owners and admins can change this." }, "audit": { - "actionNamed": "{{action}} “{{name}}”", - "viaAgent": "Agent in a cloud workspace", "youViaKey": "You via API key", "personViaKey": " via API key", "youViaMcp": "You via MCP", "personViaMcp": " via MCP", "you": "You", - "title": "Recent activity", - "description": "Key, workspace and settings changes across your team. Visible to admins only.", - "hide": "Hide", - "show": "Show activity", - "loading": "Loading activity", - "empty": "Nothing has happened yet." + "action": { + "keyCreate": "Created API key", + "keyRevoke": "Revoked API key", + "settings": "Changed agent access", + "wsCreate": "Started a workspace", + "wsDestroy": "Deleted a workspace", + "wsSleep": "Put a workspace to sleep", + "wsWake": "Woke a workspace", + "wsArchive": "Archived a workspace", + "wsMessage": "Messaged an agent", + "projectCreate": "Created a project", + "projectUpdate": "Edited a project", + "projectDelete": "Deleted a project", + "approval": "Made an app approval code" + } }, "pageErrorDetail": "Keys, connected apps and team settings are unavailable until Orca Cloud answers.", "retry": "Try again", @@ -9104,7 +9247,8 @@ "rateLimited": "Too many requests. Try again in a minute.", "network": "Couldn’t reach Orca Cloud. Check your connection and try again.", "invalidResponse": "Orca Cloud sent an answer this version of Orca does not understand. Update Orca and try again.", - "server": "Orca Cloud had a problem and nothing was changed. Try again in a moment." + "server": "Orca Cloud had a problem and nothing was changed. Try again in a moment.", + "planRequired": "Your team’s plan doesn’t include this." }, "scope": { "workspacesRead": "List and inspect workspaces", @@ -9126,7 +9270,6 @@ "keywordToken": "token", "keywordMcp": "mcp", "keywordCli": "cli", - "keywordAudit": "audit log", "keywordCloud": "cloud", "sectionDescription": "For the Orca Cloud API, the orca CLI and MCP clients. Claude or Codex keys go in Cloud secrets." }, @@ -13935,6 +14078,15 @@ "keywordSignOut": "sign out", "keywordRelay": "relay", "keywordCloud": "cloud", + "mfa": { + "title": "Two-factor authentication", + "on": "On", + "off": "Off", + "onDescription": "Signing in asks for a code from your authenticator app. Keep your backup codes somewhere safe.", + "offDescription": "Add an authenticator app so a stolen password is not enough to sign in. You get backup codes too.", + "manage": "Manage in browser", + "setUp": "Set up in browser" + }, "github": { "disconnectTitle": "Disconnect GitHub?", "disconnectBody": "Commits in cloud workspaces will use Orca Cloud's name again.", @@ -20983,7 +21135,8 @@ "noTabGroup": "Can’t run “{{action}}” — no tab group is available.", "noMovableChat": "Can’t run “{{action}}” — no movable chat is active.", "clientUnsupported": "Can’t run “{{action}}” — this client and runtime do not support it.", - "notCloudWorkspace": "Can’t run “{{action}}” — the active workspace isn’t a cloud workspace." + "notCloudWorkspace": "Can’t run “{{action}}” — the active workspace isn’t a cloud workspace.", + "noOrcaCloud": "Can’t run “{{action}}” — sign in to Orca Cloud first." } }, "plugins": { @@ -21055,7 +21208,7 @@ "openAlreadySubscribed": "This account already has a plan. Use Manage billing to change it.", "openNoAccount": "There is no billing account yet. Choose a plan first.", "openUnconfigured": "Billing is unavailable in this build.", - "openFailed": "Could not reach billing. Try again.", + "openFailed": "Couldn’t reach billing. Try again.", "endsOn": "Plan ends {{value0}}", "renewsOn": "Renews {{value0}}", "continueInBrowser": "Continue in your browser. This updates when you come back.", @@ -21109,7 +21262,6 @@ "dismiss": "Dismiss", "enterEmail": "Enter a teammate’s email", "inviteLabel": "Invite teammates", - "inviteNoPlan": "Invites are free. Seats are billed once your team picks a plan.", "invitePlaceholder2": "alex@company.com, sam@company.com", "inviteSentBody": "They join, and take a seat, when they accept.", "inviteSentMany": "{{value0}} invites sent", @@ -21129,6 +21281,10 @@ "makeOwnerTitle": "Make {{value0}} an owner?", "members": "Members", "membersList": "Team members and invites", + "mfaOn": "MFA on", + "mfaOff": "MFA off", + "mfaPending": "MFA pending", + "mfaPendingBy": "MFA due {{value0}}", "nextInvoice": "Next invoice (est.)", "nextInvoiceOn": "{{value0}} · {{value1}}", "notAnEmail": "Not an email address", @@ -21165,7 +21321,6 @@ "createdNotSwitched": "{{value0}} was created, but Orca couldn’t switch to it.", "invitedCount_one": "{{count}} invited", "invitedCount_other": "{{count}} invited", - "invitePaymentFailed2": "New seats are charged once the failed payment is fixed.", "memberCount_one": "{{count}} member", "memberCount_other": "{{count}} members", "seatCount_one": "{{count}} seat", @@ -21206,22 +21361,10 @@ "deletedToast2": "{{value0}} was deleted" }, "leaveLastOwnerOrDelete": "You’re the only owner. Make someone else an owner first, or delete the team.", - "planBillingLink": "Plan & billing", "perSeat": "Per seat", "perSeatMonthValue": "{{value0}}/mo", "seatRulesShort": "New members above your paid seats are charged now, prorated. Removed seats stay paid until renewal.", - "openSeatsNone": "You have no open seats", - "openSeatsClause_one": "You have {{count}} open seat", - "openSeatsClause_other": "You have {{count}} open seats", - "inviteSeatUnknown": "Each invite uses a seat once accepted.", - "inviteSeatOpen": "Each invite uses a seat once accepted. {{openSeats}}.", - "inviteSeatCovered": "Each invite uses a seat once accepted. {{openSeats}}, so no seats will be added.", - "inviteSeatAddsUnpriced_one": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat will be added, prorated.", - "inviteSeatAddsUnpriced_other": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats will be added, prorated.", - "inviteSeatAddsYear_one": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat ({{price}}/yr, prorated) will be added.", - "inviteSeatAddsYear_other": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats ({{price}}/yr, prorated) will be added.", - "inviteSeatAddsMonth_one": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat ({{price}}/mo, prorated) will be added.", - "inviteSeatAddsMonth_other": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats ({{price}}/mo, prorated) will be added." + "inviteSeatRule": "Each invite uses a seat once accepted." }, "manageBilling": "Manage billing", "comparePlans": "Compare plans", @@ -21285,6 +21428,7 @@ "featureOverageNoRate": "Optional overage up to a monthly spend cap you set" }, "capLabel2": "Monthly spend cap", + "capNotSaved": "Couldn’t save", "capSaved": "Saved", "checkout": { "attention2": "Stripe needs one more step", @@ -21332,7 +21476,6 @@ "upgrade": { "askOwner": "Ask an owner or admin to upgrade.", "bodyNoPrice": "Hosted sandboxes for the whole team. Agents keep running when you close your laptop.", - "cta": "See plans", "menuItem": "Upgrade to Orca Cloud", "title": "Upgrade to use Orca Cloud", "body3": "Hosted sandboxes for the whole team. Agents keep running when you close your laptop. {{price}} per seat per month." @@ -21446,9 +21589,6 @@ "cloudWorkspaces": { "archive": "Archive", "archived": "Archived", - "byAnyone": "By anyone", - "byMe": "By me", - "byMember": "By {{value0}}", "cancel": "Cancel", "chatGone": "That chat is no longer open in this workspace. Showing the workspace instead.", "chatLinkCopied": "Chat link copied", @@ -21457,8 +21597,6 @@ "copyLink": "Copy link", "emptyArchivedBody": "Archived cloud workspaces show up here.", "emptyArchivedTitle": "No archived workspaces", - "emptyFilteredBody": "Try another owner or repository.", - "emptyFilteredTitle": "Nothing matches these filters", "environmentName": "{{value0}} (cloud)", "errorConcurrencyLimit": "You're at your team's limit of running sandboxes. Put one to sleep or delete it, then try again.", "errorMemberConcurrencyLimit": "You're at your limit of running sandboxes. Put one to sleep or delete it, then try again.", @@ -21474,13 +21612,8 @@ "errorUnknownInline": "Something went wrong. Nothing was changed.", "errorUnreachable": "Orca Cloud isn't reachable right now. Nothing was changed. Try again.", "errorUnreachableInline": "Orca Cloud isn't reachable right now. Nothing was changed.", - "filterByOwner": "Filter by owner", - "filterByRepository": "Filter by repository", "goToChat": "Go to {{value0}}'s chat", "goToTeammateChat": "Go to a teammate's chat", - "heading": "Cloud workspaces", - "inAnyRepo": "In any repo", - "inRepo": "In {{value0}}", "justNow": "just now", "linkNotFound": "This link points to a private workspace, another team, or a workspace that was deleted.", "loading": "Loading cloud workspaces", @@ -21493,16 +21626,10 @@ "memberViewing": "{{value0}} is viewing", "memberYou": "{{value0}} (you)", "moreMembers": "{{count}} more", - "move": "Move", - "moveToSection": "Move to section", - "newSection": "New section", - "newSectionEllipsis": "New section…", "noAccess": "You don't have access to this workspace. Ask its owner to share it with the team.", - "noSection": "No section", "nowPrivate": "Now private. Teammates’ devices were disconnected.", "open": "Open", "openFailed": "Couldn't open the workspace. Your files are safe. Try again.", - "openedFromLink": "Opened from link: {{title}}", "opening": "Opening", "ownerChanged": "Owner changed", "private": "Private", @@ -21524,10 +21651,6 @@ "sandboxHoursTitle": "Sandbox hours", "scopeMine": "Mine", "scopeTeam": "Team", - "sectionCount_one": "{{count}} workspace", - "sectionCount_other": "{{count}} workspaces", - "sectionName": "Section name", - "sectionNamePlaceholder": "e.g. Payments", "serverNotSaved": "Couldn't add the workspace to this computer. Your files are safe. Try again.", "shareWithTeam": "Share with team", "sharedWithTeam": "Shared with team", @@ -21550,21 +21673,15 @@ "youAreViewing": "You are viewing", "view": "View", "viewAsleep": "View read-only copy", - "routines": "Routines", "archiveConfirmTitle": "Archive {{name}}?", "archiveBusy": "This workspace is still starting or going to sleep. Nothing was changed. Try again in a moment.", "showCloudWorkspaces": "Show cloud workspaces", "deleteConfirmTitle": "Delete {{name}}?", "sleep": "Sleep", "deleteWorkspace": "Delete…", - "showRunningOnly": "Show running only", - "running": "Running", - "showRoutineRuns": "Show routine runs", - "routineRuns": "Routine runs", "routineRun": "Routine run", "routineRunTooltip": "Started by a routine. It's deleted when the run ends.", "routineRunOf": "Routine · {{name}}", - "routineRunsHidden": "Routine runs · {{count}}", "errorTeamSandboxLimit_one": "Your team can run {{count}} sandbox at once ({{perSeat}} per seat). Put it to sleep or delete it, then try again.", "errorTeamSandboxLimit_other": "Your team can run {{count}} sandboxes at once ({{perSeat}} per seat). Put one to sleep or delete it, then try again.", "errorSeatSandboxLimitSelf_one": "You already have {{count}} sandbox running, the most one seat can run. Put it to sleep or delete it, then try again.", @@ -21572,28 +21689,13 @@ "errorSeatSandboxLimitOwner_one": "{{name}} already has {{count}} sandbox running. This workspace runs on their seat, so ask them to put it to sleep, then try again.", "errorSeatSandboxLimitOwner_other": "{{name}} already has {{count}} sandboxes running. This workspace runs on their seat, so ask them to put one to sleep, then try again.", "viewOwnerProfile": "View {{name}}'s profile", - "pinned": "Pinned", - "sectionActions": "Actions for section {{name}}", - "moveSectionUp": "Move section up", - "moveSectionDown": "Move section down", "linkCopiedShort": "Link copied", "copyLinkTo": "Copy link to {{number}}", "copyLinkHint": "Teammates with access can open this workspace", - "unpin": "Unpin", - "pin": "Pin", - "moveUp": "Move up", - "moveDown": "Move down", "linkNoAccess": "You don't have access to {{number}}.", "linkOtherTeam2": "{{number}} is in {{team}}.", "linkSwitchTeam": "Switch and open", "prefsSaveFailed": "Couldn't save that change, so your other devices won't show it. Try again.", - "movedTo": "Moved {{name}} to position {{position}} in {{group}}", - "pinLimit": "You can pin up to {{count}} workspaces. Unpin one first.", - "pinnedName": "Pinned {{name}}", - "unpinnedName": "Unpinned {{name}}", - "otherWorkspaces": "Other workspaces", - "listMetaCount_one": "{{count}} workspace", - "listMetaCount_other": "{{count}} workspaces", "restartElsewhere": "Restart it from the computer that created it.", "makePrivateSidebar": "Teammates’ devices are disconnected. Only the owner will be able to open it.", "shareFailed": "Couldn’t change who can open it. Try again.", @@ -21602,11 +21704,11 @@ "copyLinkToChat": "Copy link to this chat", "linkToNumber": "Link to {{number}}", "archiveConfirmStops": "{{owner}}'s agents in this workspace will stop.", - "archivedCount": "Archived ({{count}})", "errorTrialSandboxLimit_one": "Your trial can run {{count}} sandbox at once. Put it to sleep or delete it, then try again.", "errorTrialSandboxLimit_other": "Your trial can run {{count}} sandboxes at once. Put one to sleep or delete it, then try again.", "errorTeamSandboxLimitNoSeat_one": "Your team can run {{count}} sandbox at once. Put it to sleep or delete it, then try again.", - "errorTeamSandboxLimitNoSeat_other": "Your team can run {{count}} sandboxes at once. Put one to sleep or delete it, then try again." + "errorTeamSandboxLimitNoSeat_other": "Your team can run {{count}} sandboxes at once. Put one to sleep or delete it, then try again.", + "sharedPersonalSecretsNote": "Your personal cloud secrets aren’t used in shared workspaces. Shared and repository secrets still are." }, "orcaCloud": { "QueuedPrompt": { @@ -21683,6 +21785,52 @@ }, "NewWorkspaceComposerFooter": { "create": "Create" + }, + "teamMfa": { + "dueNow": "Due now", + "daysLeft_one": "{{count}} day left", + "daysLeft_other": "{{count}} days left", + "graceNow": "Immediately", + "grace7": "In 7 days", + "grace14": "In 14 days", + "grace30": "In 30 days", + "bannerTitle": "{{value0}} requires two-factor authentication. {{value1}}.", + "bannerBody": "Set it up by {{value0}}. After that you can’t use this team’s workspaces until you do.", + "bannerBlockedTitle": "{{value0}} requires two-factor authentication", + "soonTitleNow": "{{value0}} requires MFA now", + "soonTitle_one": "{{value0}} requires MFA in {{count}} day", + "soonTitle_other": "{{value0}} requires MFA in {{count}} days", + "toast": "{{value0}} requires MFA from {{value1}}.", + "bannerBlockedBody": "You can’t use this team’s workspaces until you set it up. Your personal account isn’t affected.", + "setUp": "Set up MFA", + "blockedTitle": "Set up two-factor authentication to use {{value0}}", + "blockedBody": "{{value0}} has required it for all members since {{value1}}. Your workspaces and files are safe, and open again as soon as you add an authenticator app.", + "recheck": "I’ve set it up", + "stillMissing": "Your account still has no two-factor authentication. Finish the setup in your browser, then try again.", + "usePersonal": "Use my personal account for now", + "rowTitle": "Require two-factor authentication", + "rowOff": "Members without it lose access to this team’s workspaces after the date you pick, until they set it up. They get an email now and reminders before the date.", + "rowEnforced": "Required since {{value0}}. Members without it can’t use this team until they set it up.", + "rowScheduled": "Required from {{value0}}. {{value1}}.", + "graceLabel": "When to start requiring it", + "require": "Require MFA", + "turnOff": "Stop requiring", + "ownMfaTitle": "Set up two-factor authentication on your own account first", + "ownMfaBody": "Only someone who already has it can change this, so nobody locks the team out.", + "nowWarning_one": "{{count}} member doesn’t have it yet and will lose access to this team right away.", + "nowWarning_other": "{{count}} members don’t have it yet and will lose access to this team right away.", + "allSet": "Every member has it set up.", + "pendingCount_one": "{{count}} member still needs it", + "pendingCount_other": "{{count}} members still need it", + "remindAll": "Remind everyone", + "pendingList": "Members without MFA", + "memberBlocked": "No access", + "memberPending": "Pending", + "remindOne": "Remind", + "reminded_one": "Reminder sent to {{count}} member.", + "reminded_other": "Reminder sent to {{count}} members.", + "remindedNoEmail": "Reminder recorded, but email delivery is off on this server, so nothing was sent.", + "requiredInline": "Your team requires two-factor authentication. Set it up in Settings, Account to use its cloud workspaces." } }, "i18n": { @@ -23914,7 +24062,6 @@ "addRepository": "Add a GitHub repository", "moreAccess": "Give Orca Cloud access to more repositories…", "startFailed": "Couldn’t start the cloud workspace. Nothing was created, and you weren’t charged.", - "openLater": "Your cloud workspace started, but this computer couldn’t open it yet. Open it from Cloud workspaces.", "agentNotStarted": "{{agent}} didn’t start because the workspace was still getting ready. Start it from the workspace.", "theAgent": "The agent", "showMore": "Show more repositories…", @@ -23926,13 +24073,13 @@ "privateLabel": "Private", "menuGroup": "Orca Cloud", "addRepositoryMenu": "Add a GitHub repository…", - "copyAddRepositoryLink": "Copy GitHub link", "waitingForGitHubAccess": "Waiting for GitHub access…", "noNewRepositoryYet": "No new repository yet.", "checkAgain": "Check again", "reposUnavailable": "Couldn’t load GitHub repositories.", "retryRepos": "Retry", - "reposLoading": "Loading…" + "reposLoading": "Loading…", + "openLater2": "Your cloud workspace started, but this computer couldn’t open it yet. Try again, or open it later from Open cloud workspace…" }, "agentLabel": "Agent" }, @@ -24338,11 +24485,37 @@ "cloudWorkspaces": { "newCloudWorkspace": "New cloud workspace", "emptyStartBody": "It runs in a sandbox, so your agents keep working even when this computer is off.", - "emptyMineYoursTitle": "You don’t have any cloud workspaces yet.", "emptyFirstTitle": "Start your first cloud workspace", - "emptyAllArchivedTitle": "All your cloud workspaces are archived", - "showArchivedCount": "Show archived ({{count}})", - "emptyAllArchivedBody": "Unarchive one to keep working, or start a new one." + "picker": { + "title": "Open cloud workspace", + "description": "Cloud workspaces that aren’t in your sidebar: shared by teammates, or started on another computer.", + "teammateTitle": "{{name}}’s cloud workspaces", + "teammateDescription": "The ones they own or started that you can see.", + "runningTitle": "Your running cloud workspaces", + "runningDescription": "Put one to sleep or delete it to free a running slot, then try again.", + "teamTitle": "Your team’s cloud workspaces", + "teamDescription": "Delete these, archived ones too, before deleting the team.", + "search": "Search cloud workspaces", + "searchPlaceholder": "Search by title, repository, owner or W-number…", + "inSidebarNote_one": "{{count}} more is already in your sidebar.", + "inSidebarNote_other": "{{count}} more are already in your sidebar.", + "emptySearchTitle": "Nothing matches your search", + "emptySearchBody": "Try a title, repository, branch, owner or W-number.", + "emptyAllInSidebarTitle": "Your cloud workspaces are already in the sidebar", + "emptyAllInSidebarBody": "Workspaces teammates share, and ones you start on another computer, show up here.", + "emptyRunningTitle": "None of yours are running", + "emptyRunningBody": "Teammates’ running workspaces can count toward the limit too. Try again in a moment.", + "emptyTeammateTitle": "{{name}} has no cloud workspaces you can see", + "emptyTeammateBody": "Private workspaces only show to their owner.", + "menuItem": "Open cloud workspace…", + "paletteDescription": "Open a cloud workspace that isn’t in your sidebar yet.", + "verbs": { + "cloudWorkspace": "cloud workspace", + "sharedWorkspace": "shared workspace", + "teammate": "teammate", + "orcaCloud": "orca cloud" + } + } }, "worktreeCreation": { "pendingNativeChat": { @@ -24356,7 +24529,9 @@ "offStatus2": "Overage is off. Running sandboxes go to sleep and new ones can’t start when your team runs out of sandbox hours.", "turnOff": "Turn off overage", "memberOn5": "Overage is on, up to your {{cap}} spend cap.", - "memberOff5": "Off until an owner or admin turns it on." + "memberOff5": "Off until an owner or admin turns it on.", + "turnOffFailed": "Couldn’t turn off overage.", + "turnOnFailed": "Couldn’t turn on overage." }, "overageUsed": "{{value0}} of {{value1}} used", "capZero2": "Set a spend cap above $0, or turn overage off." @@ -27878,11 +28053,16 @@ "archived": "A read-only snapshot of its files, chats and terminal history. Unarchive within 30 days of archiving to open terminals and agents again; after that it’s deleted." }, "primaryDelete": { - "noSandbox": "Orca couldn't find this workspace's sandbox. Delete it from Cloud workspaces." + "noSandbox2": "Orca couldn't find this workspace's sandbox. Find it in Open cloud workspace… to delete it." }, "opensAndWakes": "Opens and wakes when you select it.", "sleepFailedUnnamed": "Couldn’t put this workspace to sleep", - "cloudWorkspaceTitle": "Cloud workspace" + "cloudWorkspaceTitle": "Cloud workspace", + "notPaired": { + "title": "{{name}} isn’t linked to this computer", + "body": "Open it again to link it. Your files and chats are saved.", + "titleUnnamed": "This workspace isn’t linked to this computer" + } }, "cloudAgentSetup": { "loading": "Checking what agents can use…", @@ -28031,9 +28211,9 @@ }, "setUpAgent": "Set up an agent", "personal": { - "description": "Only you get these, in every Orca Cloud workspace you start.", "emptyTitle": "No personal secrets yet", - "emptyDetail": "Add one or paste a .env file. New terminals and agents in your cloud workspaces get them as environment variables." + "emptyDetail": "Add one or paste a .env file. New terminals and agents in your cloud workspaces get them as environment variables.", + "descriptionUnshared": "Only you get these, in cloud workspaces you haven’t shared. Shared workspaces don’t get them." }, "org": { "description": "Everyone on your team gets these in their Orca Cloud workspaces.", @@ -28264,6 +28444,7 @@ "notSetUp": "{{agent}} isn’t set up for Orca Cloud", "expired": "{{agent}} sign-in expired", "notStarted": "{{agent}} hasn’t started. Open the workspace to start it.", + "setUpNotStarted": "{{agent}} is set up. Open the workspace to start it.", "notStartedRow": "{{agent}} hasn’t started" }, "chatSignIn": { @@ -28539,7 +28720,8 @@ "codeLabel": "Code from claude.ai", "deviceStep": "Enter this code on auth.openai.com. The sign-in is saved on {{host}}.", "codePlaceholder": "Paste the code" - } + }, + "recipeEnvNarrowed": "This recipe's create script gets only the variables its passEnv allows." }, "cloudSecretsAsk": { "scopeLabel": "Scope for {{name}}", @@ -29351,6 +29533,7 @@ "copyLink": "Copy link", "moreOptions": "More link options", "copyLinkFailed": "Couldn’t copy the link.", - "linkCopied": "Link copied" + "linkCopied": "Link copied", + "signInLinkHint": "Open it in any browser on this computer." } } diff --git a/src/renderer/src/lib/orca-cloud-session-events.ts b/src/renderer/src/lib/orca-cloud-session-events.ts index b2512193061..f2529118184 100644 --- a/src/renderer/src/lib/orca-cloud-session-events.ts +++ b/src/renderer/src/lib/orca-cloud-session-events.ts @@ -120,11 +120,11 @@ export function installOrcaCloudSessionEvents(): () => void { } } -/** "See plans" on the create failure when the team has no plan with sandbox hours. */ +/** "Compare plans" on the create failure when the team has no plan with sandbox hours. */ export function showOrcaCloudPlanUpsell(message: string): void { toast.error(message, { action: { - label: translate('auto.lib.orcaCloudSession.seePlans', 'See plans'), + label: translate('auto.components.billing.comparePlans', 'Compare plans'), onClick: () => openPlansPage('sandbox') } }) diff --git a/src/renderer/src/lib/orca-cloud/active-team-role.test.ts b/src/renderer/src/lib/orca-cloud/active-team-role.test.ts new file mode 100644 index 00000000000..c54a13e0763 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/active-team-role.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles' +import { activeTeamRole, isActiveTeamAdmin } from './active-team-role' + +function status(patch: Partial = {}): OrcaProfileAuthStatus { + return { + activeProfileId: 'p1', + configured: true, + state: 'connected', + persistence: 'encrypted', + cloud: { + cloudProfileId: 'cp', + userId: 'u1', + email: 'a@acme.dev', + activeOrgId: 'acme', + linkedAt: 1 + }, + organizations: [ + { orgId: 'acme', name: 'Acme', role: 'Owner' }, + { orgId: 'globex', name: 'Globex', role: 'member' } + ], + ...patch + } +} + +describe('activeTeamRole', () => { + it("reads the role in the active team, whatever case auth's provider spells it in", () => { + expect(activeTeamRole(status())).toBe('owner') + expect(isActiveTeamAdmin(status())).toBe(true) + const admin = status({ organizations: [{ orgId: 'acme', name: 'Acme', role: ' ADMIN ' }] }) + expect(activeTeamRole(admin)).toBe('admin') + expect(isActiveTeamAdmin(admin)).toBe(true) + }) + + it('is the role in the ACTIVE team, not any team the person owns', () => { + const inGlobex = status({ cloud: { ...status().cloud!, activeOrgId: 'globex' } }) + expect(activeTeamRole(inGlobex)).toBe('member') + expect(isActiveTeamAdmin(inGlobex)).toBe(false) + }) + + it('treats an unknown or missing role as member, never as admin', () => { + for (const role of ['Billing manager', '', undefined]) { + const odd = status({ organizations: [{ orgId: 'acme', name: 'Acme', role }] }) + expect(activeTeamRole(odd)).toBe('member') + expect(isActiveTeamAdmin(odd)).toBe(false) + } + expect(isActiveTeamAdmin(status({ organizations: [] }))).toBe(false) + }) + + it('has no team role on a personal account, signed out, or before auth has answered', () => { + expect( + activeTeamRole(status({ cloud: { ...status().cloud!, activeOrgId: undefined } })) + ).toBeNull() + expect(activeTeamRole(status({ state: 'reconnect-required' }))).toBeNull() + expect(activeTeamRole(null)).toBeNull() + expect(isActiveTeamAdmin(undefined)).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/active-team-role.ts b/src/renderer/src/lib/orca-cloud/active-team-role.ts new file mode 100644 index 00000000000..aff67dc620b --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/active-team-role.ts @@ -0,0 +1,29 @@ +import type { OrcaOrgRole, OrcaProfileAuthStatus } from '../../../../shared/orca-profiles' + +/** + * The signed-in user's role in the team they are acting as; null when signed out or on a personal + * account. Auth reports provider role names, so anything but owner/admin reads as member. This + * only decides what the UI offers: the services check the role on every request. + */ +export function activeTeamRole( + status: OrcaProfileAuthStatus | null | undefined +): OrcaOrgRole | null { + if (status?.state !== 'connected') { + return null + } + const orgId = status.cloud?.activeOrgId + if (!orgId) { + return null + } + const raw = status.organizations + ?.find((org) => org.orgId === orgId) + ?.role?.trim() + .toLowerCase() + return raw === 'owner' || raw === 'admin' ? raw : 'member' +} + +/** Owner or admin of the active team: who sees Settings › Enterprise. */ +export function isActiveTeamAdmin(status: OrcaProfileAuthStatus | null | undefined): boolean { + const role = activeTeamRole(status) + return role === 'owner' || role === 'admin' +} diff --git a/src/renderer/src/lib/orca-cloud/cloud-agent-launch-hold-stale.test.ts b/src/renderer/src/lib/orca-cloud/cloud-agent-launch-hold-stale.test.ts new file mode 100644 index 00000000000..21b0301c112 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-agent-launch-hold-stale.test.ts @@ -0,0 +1,157 @@ +// @vitest-environment happy-dom +// HELD-STALE-COVERED: a held launch re-decides on every account change, and Start never trusts the +// cached read. Real repro (m4air, #568 proof P3b): set up -> "Start Claude" -> the sign-in is revoked +// -> the strip still said "Start Claude", and Start launched Claude onto the revoked account. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { OrcaCloudAgentsStatus } from '../../../../shared/orca-cloud-agents/orca-cloud-agents-wire' +import { + holdCloudAgentLaunch, + readCloudAgentHold, + releaseCloudAgentHold, + startCloudAgentHold +} from './cloud-agent-launch-hold' +import { + setOrcaCloudAgentAccountsStateForTest, + type OrcaCloudAgentAccountsState +} from './orca-cloud-agent-accounts-store' + +type Claude = OrcaCloudAgentsStatus['credentials']['claude'] + +function status(claude: Claude): OrcaCloudAgentsStatus { + return { + credentials: { + claude, + codex: { state: 'none' }, + providerEnabled: { claude: true, codex: true } + }, + sources: { defaults: {}, coveredByKey: { claude: false, codex: false } } + } +} + +function accounts(claude: Claude): OrcaCloudAgentAccountsState { + return { + load: { kind: 'ready', status: status(claude) }, + setups: { claude: { phase: 'idle' }, codex: { phase: 'idle' } }, + keyKinds: { claude: null, codex: null }, + queue: { agents: [], index: 0 } + } +} + +const main = vi.hoisted(() => { + const value: { answer: { ok: true; value: unknown } | { ok: false; error: 'unknown' } } = { + answer: { ok: false, error: 'unknown' } + } + return value +}) + +const settle = async (): Promise => { + for (let i = 0; i < 3; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } +} + +beforeEach(() => { + Object.defineProperty(window, 'api', { + configurable: true, + value: { orcaCloudAgents: { status: async () => main.answer } } + }) +}) +afterEach(() => { + for (const key of ['tab:s1', 'tab:s2', 'tab:s3', 'tab:s4', 'tab:s5', 'tab:s6']) { + releaseCloudAgentHold(key, 'cancel') + } +}) + +async function setUpHold(paneKey: string): Promise<{ outcome: () => string }> { + setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'none' })) + let outcome = 'pending' + void holdCloudAgentLaunch({ paneKey, agent: 'claude', prompt: null }).then((o) => { + outcome = o + }) + await vi.waitFor(() => expect(readCloudAgentHold(paneKey)).toMatchObject({ reason: 'notSetUp' })) + return { outcome: () => outcome } +} + +describe('HELD-STALE-COVERED', () => { + it('setup finished here: "is set up · Start" (muted, not the amber key), and it waits for Start', async () => { + const hold = await setUpHold('tab:s1') + setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'active' })) + await settle() + expect(readCloudAgentHold('tab:s1')).toMatchObject({ + reason: 'awaitingStart', + awaiting: 'setUp' + }) + expect(hold.outcome()).toBe('pending') + }) + + it('covered -> revoked: the hold goes back to Renew (accountExpired), and Start does not launch', async () => { + const hold = await setUpHold('tab:s2') + setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'active' })) + await settle() + setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'revoked' })) + await settle() + expect(readCloudAgentHold('tab:s2')).toMatchObject({ reason: 'accountExpired' }) + main.answer = { ok: true, value: status({ state: 'revoked' }) } + await expect(startCloudAgentHold('tab:s2')).resolves.toBe('held') + await settle() + expect(readCloudAgentHold('tab:s2')).toMatchObject({ reason: 'accountExpired' }) + expect(hold.outcome()).toBe('pending') + }) + + it('Start with a stale "active" cache and a fresh read showing revoked: no launch, Renew shown', async () => { + const hold = await setUpHold('tab:s3') + setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'active' })) + await settle() + expect(readCloudAgentHold('tab:s3')).toMatchObject({ awaiting: 'setUp' }) + main.answer = { ok: true, value: status({ state: 'revoked' }) } + await expect(startCloudAgentHold('tab:s3')).resolves.toBe('held') + await settle() + expect(readCloudAgentHold('tab:s3')).toMatchObject({ reason: 'accountExpired' }) + expect(hold.outcome()).toBe('pending') + }) + + it('Start with a fresh read that still covers the agent launches it', async () => { + const hold = await setUpHold('tab:s4') + setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'active' })) + await settle() + main.answer = { ok: true, value: status({ state: 'active' }) } + await expect(startCloudAgentHold('tab:s4')).resolves.toBe('started') + await settle() + expect(hold.outcome()).toBe('proceed') + expect(readCloudAgentHold('tab:s4')).toBeNull() + }) + + it('a removed sign-in goes back to "Set up once"; a failed fresh read keeps it held', async () => { + const hold = await setUpHold('tab:s5') + setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'active' })) + await settle() + setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'none' })) + await settle() + expect(readCloudAgentHold('tab:s5')).toMatchObject({ reason: 'notSetUp' }) + main.answer = { ok: false, error: 'unknown' } + await expect(startCloudAgentHold('tab:s5')).resolves.toBe('held') + expect(readCloudAgentHold('tab:s5')).not.toBeNull() + expect(hold.outcome()).toBe('pending') + }) + + it('in-session expired -> sign-in removed -> set up again: waits for Start, never auto-starts (PC-3b)', async () => { + setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'expired' })) + let outcome = 'pending' + void holdCloudAgentLaunch({ paneKey: 'tab:s6', agent: 'claude', prompt: null }).then((o) => { + outcome = o + }) + await vi.waitFor(() => + expect(readCloudAgentHold('tab:s6')).toMatchObject({ reason: 'accountExpired' }) + ) + setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'none' })) + await settle() + expect(readCloudAgentHold('tab:s6')).toMatchObject({ reason: 'notSetUp' }) + setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'active' })) + await settle() + expect(readCloudAgentHold('tab:s6')).toMatchObject({ + reason: 'awaitingStart', + awaiting: 'setUp' + }) + expect(outcome).toBe('pending') + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/cloud-agent-launch-hold.ts b/src/renderer/src/lib/orca-cloud/cloud-agent-launch-hold.ts index aba12915a06..a3aa2cfb59f 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-agent-launch-hold.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-agent-launch-hold.ts @@ -11,6 +11,7 @@ import { ensureFreshOrcaCloudAgentAccounts, isOrcaCloudAgentAccountsFresh, readOrcaCloudAgentAccounts, + reloadOrcaCloudAgentAccounts, subscribeOrcaCloudAgentAccounts, type OrcaCloudAgentAccountsState } from './orca-cloud-agent-accounts-store' @@ -89,9 +90,10 @@ export type CloudAgentHold = { reason: CloudAgentHoldReason /** * Why an 'awaitingStart' hold waits: the account covers the agent now, Orca couldn't read it, - * or an expired sign-in was renewed (V3-01). Absent for every other reason. + * or an expired sign-in was renewed (V3-01), or the one-time setup just finished here ('setUp', + * HELD-STALE-COVERED: "is set up · Start", no longer the amber key). Absent for every other reason. */ - awaiting?: 'covered' | 'unread' | 'renewed' + awaiting?: 'covered' | 'unread' | 'renewed' | 'setUp' } /** The hold key of a host-published tab (RA-1): its first pane shows the strip. */ @@ -110,7 +112,11 @@ type HoldRequest = Omit & { restored?: boolean } -type Entry = CloudAgentHold & { release: (outcome: 'proceed' | 'cancel') => void } +type Entry = CloudAgentHold & { + release: (outcome: 'proceed' | 'cancel') => void + /** Renewed or covered, it waits for the person's Start instead of starting (restored, or setup). */ + askFirst: boolean +} const holds = new Map() /** Each live hold's outcome, so a second launch for the same key joins it instead of holding twice. */ @@ -269,57 +275,15 @@ async function placeCloudAgentHold({ reason === 'awaitingStart' ? (accountsRead ? ('covered' as const) : ('unread' as const)) : null return new Promise<'proceed' | 'cancel'>((resolve) => { const stop = subscribeOrcaCloudAgentAccounts(() => { - // Renewed (or a key now covers it): every pane held on recovery starts. A first-time setup - // waits for its "Start {{agent}}" click instead (PC-3b), so it never starts unasked. const entry = holds.get(hold.paneKey) if (entry?.release !== finish) { return } - // V3-01b: a restored hold placed before the account read could land ('unread') takes the - // read's answer once it arrives: "isn't set up", "expired" or covered. It still never starts. - if (entry.reason === 'awaitingStart' && entry.awaiting === 'unread') { - const read = readOrcaCloudAgentAccounts() - if (read.load.kind !== 'ready') { - return - } - const answer = cloudAgentHoldReason(hold.agent, read) - holds.set( - hold.paneKey, - answer - ? { ...entry, reason: answer, awaiting: undefined } - : { ...entry, awaiting: 'covered' } - ) - notify() - return - } - if (entry.reason !== 'accountExpired') { - return - } const accounts = readOrcaCloudAgentAccounts() - const now = accounts.load.kind === 'ready' ? cloudAgentHoldReason(hold.agent, accounts) : null - if (accounts.load.kind !== 'ready' || now === 'accountExpired') { + if (accounts.load.kind !== 'ready') { return } - if (now === 'notSetUp') { - // The sign-in was removed, not renewed: nothing covers the agent, so it never starts here; - // the hold becomes the one-time setup. - holds.set(hold.paneKey, { ...entry, reason: 'notSetUp' }) - notify() - return - } - // Only a real renewal (the account or a key covers it) starts it; a paused or withdrawn agent - // (kill switch, never offered) or an unknown coverage stays held. - const coverage = orcaCloudAgentCoverageFromAccounts(hold.agent, accounts)?.kind - if (coverage !== 'account' && coverage !== 'key') { - return - } - if (restored) { - // After a restart the person starts it: renewed, it waits for Start (V3-01). - holds.set(hold.paneKey, { ...entry, reason: 'awaitingStart', awaiting: 'renewed' }) - notify() - return - } - finish('proceed') + redecide(entry, accounts) }) function finish(outcome: 'proceed' | 'cancel'): void { if (holds.get(hold.paneKey)?.release !== finish) { @@ -330,11 +294,99 @@ async function placeCloudAgentHold({ notify() resolve(outcome) } - holds.set(hold.paneKey, { ...hold, reason, ...(awaiting ? { awaiting } : {}), release: finish }) + holds.set(hold.paneKey, { + ...hold, + reason, + ...(awaiting ? { awaiting } : {}), + release: finish, + askFirst: Boolean(restored) || reason !== 'accountExpired' + }) notify() }) } +/** + * HELD-STALE-COVERED: every account change re-decides a live hold, whatever it waits on. A sign-in + * that expires or is revoked under "is set up · Start" goes back to Renew; one removed goes back to + * "Set up once"; covered again, it waits for Start (or, held on recovery in this session, starts). + * A paused or withdrawn agent (kill switch, never offered) keeps the hold as it is. + */ +function redecide(entry: Entry, accounts: OrcaCloudAgentAccountsState): void { + const now = cloudAgentHoldReason(entry.agent, accounts) + if (now !== null) { + if (now !== entry.reason) { + // A removed sign-in becomes the one-time setup, which never starts unasked (PC-3b), even + // for a hold placed on an expired sign-in in this session. + holds.set(entry.paneKey, { + ...entry, + reason: now, + awaiting: undefined, + askFirst: entry.askFirst || now === 'notSetUp' + }) + notify() + } + return + } + const coverage = orcaCloudAgentCoverageFromAccounts(entry.agent, accounts)?.kind + if (coverage !== 'account' && coverage !== 'key') { + return + } + if (entry.reason === 'awaitingStart') { + // V3-01b: an unread restored hold takes the read's answer; it still never starts. + if (entry.awaiting === 'unread') { + holds.set(entry.paneKey, { ...entry, awaiting: 'covered' }) + notify() + } + return + } + if (!entry.askFirst) { + // Renewed in this session: every pane held on recovery starts. + entry.release('proceed') + return + } + const awaiting = entry.reason === 'notSetUp' ? 'setUp' : 'renewed' + holds.set(entry.paneKey, { ...entry, reason: 'awaitingStart', awaiting }) + notify() +} + +/** + * The strip's Start (HELD-STALE-COVERED): never trusts the cached read. It reads the account again + * and starts only if that read covers the agent; otherwise the hold takes the read's answer (Renew, + * Set up once) and nothing launches. A failed read keeps it held ("couldn't check", Start to retry). + */ +export async function startCloudAgentHold(paneKey: string): Promise<'started' | 'held'> { + if (!holds.has(paneKey)) { + return 'held' + } + await reloadOrcaCloudAgentAccounts().catch(() => undefined) + const entry = holds.get(paneKey) + if (!entry) { + return 'held' + } + const accounts = readOrcaCloudAgentAccounts() + if (accounts.load.kind !== 'ready' || !isOrcaCloudAgentAccountsFresh()) { + holds.set(paneKey, { ...entry, reason: 'awaitingStart', awaiting: 'unread' }) + notify() + return 'held' + } + const now = cloudAgentHoldReason(entry.agent, accounts) + const coverage = orcaCloudAgentCoverageFromAccounts(entry.agent, accounts)?.kind + if (now !== null || (coverage !== 'account' && coverage !== 'key')) { + if (now !== null && now !== entry.reason) { + holds.set(paneKey, { + ...entry, + reason: now, + awaiting: undefined, + askFirst: entry.askFirst || now === 'notSetUp' + }) + notify() + } + return 'held' + } + releaseCloudAgentHold(paneKey, 'proceed') + return 'started' +} + /** * The outcome of the hold already placed under this key, or null. V3-01b: a structured chat's hold * put back at startup is joined by its launch's retry, so one Start (or Cancel) settles both. diff --git a/src/renderer/src/lib/orca-cloud/cloud-glossary-allowlist.json b/src/renderer/src/lib/orca-cloud/cloud-glossary-allowlist.json index e9a169b5dae..450bc63eb3b 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-glossary-allowlist.json +++ b/src/renderer/src/lib/orca-cloud/cloud-glossary-allowlist.json @@ -13,7 +13,7 @@ }, { "path": "src/renderer/src/components/worktree-creation/VmBuildFailureActions.tsx", - "line": 412, + "line": 372, "term": "VM", "reason": "recipe-VM branch; cloud branch says sandbox" }, diff --git a/src/renderer/src/lib/orca-cloud/cloud-setup-session-seam.ts b/src/renderer/src/lib/orca-cloud/cloud-setup-session-seam.ts index d0ae500d0a3..466ebc0128e 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-setup-session-seam.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-setup-session-seam.ts @@ -41,6 +41,8 @@ export type CloudSetupStartRequest = { repoId: string /** The rows still open when the user clicked (account, team, plan, github). */ needs: CloudSetupStep['id'][] + /** COPY-LINK-FIRST-CLICK: false starts the same trip without opening the browser. */ + openBrowser?: boolean } /** * Null when the session couldn't open; the composer then falls back to today's actions. @@ -48,7 +50,7 @@ export type CloudSetupStartRequest = { */ type SessionStarter = ( request: CloudSetupStartRequest -) => Promise<{ sessionId: string } | { cancelled: true } | null> +) => Promise<{ sessionId: string; url?: string } | { cancelled: true } | null> let source: SessionSource | null = null let starter: SessionStarter | null = null diff --git a/src/renderer/src/lib/orca-cloud/cloud-setup-session.ts b/src/renderer/src/lib/orca-cloud/cloud-setup-session.ts index 51199abb3fd..f5f7de5ce2f 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-setup-session.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-setup-session.ts @@ -176,20 +176,25 @@ export function stopWatchingCloudSetupSession(sessionId: string): void { * What "Set up and start" got back: the session it opened, `cancelled` when the user cancelled the * sign-in (or it timed out, or a newer one replaced it), or null when setup couldn't open. */ -export type CloudSetupSessionStart = { sessionId: string } | { cancelled: true } | null +export type CloudSetupSessionStart = + | { sessionId: string; url?: string } + | { cancelled: true } + | null /** "Set up and start": opens the one browser tab and starts polling. */ export async function startCloudSetupSession(request: { repo?: string needs: CloudSetupNeed[] chooseTeam?: boolean + openBrowser?: boolean }): Promise { let result: CloudSetupStartResult try { result = await window.api.orcaProfiles.cloudSetupStart({ ...(request.repo ? { repo: request.repo } : {}), needs: request.needs, - ...(request.chooseTeam ? { chooseTeam: true } : {}) + ...(request.chooseTeam ? { chooseTeam: true } : {}), + ...(request.openBrowser === false ? { openBrowser: false } : {}) }) } catch { return null @@ -202,7 +207,9 @@ export async function startCloudSetupSession(request: { return null } watchCloudSetupSession(result.sessionId) - return { sessionId: result.sessionId } + return result.url + ? { sessionId: result.sessionId, url: result.url } + : { sessionId: result.sessionId } } /** The setup session still open in this window, for the plans dialog to join (T2 Compare plans). */ diff --git a/src/renderer/src/lib/orca-cloud/github-link-sources.ts b/src/renderer/src/lib/orca-cloud/github-link-sources.ts index 33030df09d7..40709c97956 100644 --- a/src/renderer/src/lib/orca-cloud/github-link-sources.ts +++ b/src/renderer/src/lib/orca-cloud/github-link-sources.ts @@ -1,19 +1,9 @@ -// "Copy link" for the GitHub handoffs: the same single-use page Connect GitHub (App install) and -// Connect your GitHub (OAuth) open, minted the same way in main but returned instead of opened. +// "Copy link" for connecting your GitHub identity (OAuth sign-in): the same single-use page Connect +// your GitHub opens, minted the same way in main but returned instead of opened. Copy link is for +// sign-in pages only; the GitHub App install keeps a plain button. import { requireLink, type ExternalLinkSource } from '@/components/open-external/copy-external-link' import { cloudWorkspaceApi } from './orca-cloud-workspace-store' -/** The GitHub App install page; with a repository, GitHub preselects it (and a setup session returns). */ -export function gitHubInstallLinkSource(args?: { - repoUrl?: string - setupSessionId?: string -}): ExternalLinkSource { - return async () => { - const result = await cloudWorkspaceApi()?.gitHubInstallLink(args) - return requireLink(result?.ok ? result.value : null) - } -} - /** GitHub's OAuth page for connecting the person's own GitHub identity. */ export function gitHubUserConnectLinkSource(): ExternalLinkSource { return async () => { diff --git a/src/renderer/src/lib/orca-cloud/install-cloud-setup-session.ts b/src/renderer/src/lib/orca-cloud/install-cloud-setup-session.ts index 90612da475f..f9618da72e8 100644 --- a/src/renderer/src/lib/orca-cloud/install-cloud-setup-session.ts +++ b/src/renderer/src/lib/orca-cloud/install-cloud-setup-session.ts @@ -76,7 +76,8 @@ export function installCloudSetupSession(): () => void { const repoUrl = githubRepoUrlFor(repo?.gitRemoteIdentity?.remoteUrl) return startCloudSetupSession({ ...(repoUrl ? { repo: repoUrl } : {}), - needs: sessionNeedsFor(request.needs, repoUrl) + needs: sessionNeedsFor(request.needs, repoUrl), + ...(request.openBrowser === false ? { openBrowser: false } : {}) }) }) const unsubscribe = subscribeCloudSetupSessions(() => notifyCloudSetupSessionChanged()) diff --git a/src/renderer/src/lib/orca-cloud/orca-cloud-environment-id.test.ts b/src/renderer/src/lib/orca-cloud/orca-cloud-environment-id.test.ts index 719a52a8cfa..d1af8604456 100644 --- a/src/renderer/src/lib/orca-cloud/orca-cloud-environment-id.test.ts +++ b/src/renderer/src/lib/orca-cloud/orca-cloud-environment-id.test.ts @@ -1,5 +1,14 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store/index' + +const crumbs = vi.hoisted(() => { + const value: { recorded: { name: string; data: unknown }[] } = { recorded: [] } + return value +}) +vi.mock('@/diagnostics/crash-reporting/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: (name: string, data: unknown) => + crumbs.recorded.push({ name, data }) +})) import { isOrcaCloudEnvironmentForLaunch, isOrcaCloudExecutionHostId @@ -62,3 +71,23 @@ describe('isOrcaCloudEnvironmentForLaunch (FAIL-CLOSED, #568)', () => { expect(await isOrcaCloudEnvironmentForLaunch('env-server')).toBe(false) }) }) + +describe('isOrcaCloudEnvironmentForLaunch when main cannot answer', () => { + it('a thrown confirm warns, leaves a breadcrumb, and returns false (kept open, logged)', async () => { + useAppStore.setState({ runtimeEnvironments: [] }) + stubMain(async () => { + throw new Error('ipc gone') + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + crumbs.recorded = [] + expect(await isOrcaCloudEnvironmentForLaunch('env-x')).toBe(false) + expect(warn).toHaveBeenCalledWith( + '[orca-cloud] cloud-host confirm failed; launching unheld', + expect.any(Error) + ) + expect(crumbs.recorded).toEqual([ + { name: 'cloud-host confirm failed; launching unheld', data: { environmentId: 'env-x' } } + ]) + warn.mockRestore() + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/orca-cloud-environment-id.ts b/src/renderer/src/lib/orca-cloud/orca-cloud-environment-id.ts index 21f6108a58d..816c4576450 100644 --- a/src/renderer/src/lib/orca-cloud/orca-cloud-environment-id.ts +++ b/src/renderer/src/lib/orca-cloud/orca-cloud-environment-id.ts @@ -1,6 +1,7 @@ import { useAppStore } from '@/store/index' import { isOrcaCloudRuntimeEnvironment } from '../../../../shared/remote-runtime/host-catalog/orca-cloud-environment-name' import { parseExecutionHostId } from '../../../../shared/execution-host' +import { recordRendererCrashBreadcrumb } from '@/diagnostics/crash-reporting/crash-breadcrumb-recorder' /** True when this runtime environment is an Orca Cloud sandbox (never guessed from its name alone). */ export function isOrcaCloudEnvironmentId(environmentId: string): boolean { @@ -20,7 +21,15 @@ export async function isOrcaCloudEnvironmentForLaunch(environmentId: string): Pr } const answer = await window.api.orcaCloudAgents ?.isCloudEnvironment?.({ runtimeEnvironmentId: environmentId }) - .catch(() => false) + .catch((error: unknown) => { + // Kept open (team-lead): a failed confirm must not block every non-cloud server's agents, + // so it is logged where a report will find it. + console.warn('[orca-cloud] cloud-host confirm failed; launching unheld', error) + recordRendererCrashBreadcrumb('cloud-host confirm failed; launching unheld', { + environmentId + }) + return false + }) return answer === true } diff --git a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-git-setup.test.ts b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-git-setup.test.ts index 0e6b72d3618..911e85d51a9 100644 --- a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-git-setup.test.ts +++ b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-git-setup.test.ts @@ -47,7 +47,6 @@ function stub( connectGitHub: vi.fn(unused), getGitHubUser: vi.fn(unused), connectGitHubUser: vi.fn(unused), - gitHubInstallLink: vi.fn(unused), gitHubUserConnectLink: vi.fn(unused), disconnectGitHubUser: vi.fn(unused), getRepoAccess: vi.fn(unused), diff --git a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-wake.test.ts b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-wake.test.ts index 2eaf4be43a6..f6c889b15f4 100644 --- a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-wake.test.ts +++ b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-wake.test.ts @@ -37,7 +37,6 @@ function stub( connectGitHub: vi.fn(unused), getGitHubUser: vi.fn(unused), connectGitHubUser: vi.fn(unused), - gitHubInstallLink: vi.fn(unused), gitHubUserConnectLink: vi.fn(unused), disconnectGitHubUser: vi.fn(unused), getRepoAccess: vi.fn(unused), diff --git a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store.test.ts b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store.test.ts index 2cb54706641..8b440d8b65f 100644 --- a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store.test.ts +++ b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store.test.ts @@ -42,7 +42,6 @@ function stubApi(overrides: Partial = {}): OrcaCloudWorks connectGitHub: vi.fn(async () => ok(null)), getGitHubUser: vi.fn(async () => ok({ connected: false as const })), connectGitHubUser: vi.fn(async () => ok(null)), - gitHubInstallLink: vi.fn(async () => ok('https://github.com/apps/x/installations/new')), gitHubUserConnectLink: vi.fn(async () => ok('https://github.com/login/oauth/authorize')), disconnectGitHubUser: vi.fn(async () => ok({ connected: false as const })), getRepoAccess: vi.fn(async () => diff --git a/src/renderer/src/lib/orca-cloud/paired-cloud-wake-not-paired.test.ts b/src/renderer/src/lib/orca-cloud/paired-cloud-wake-not-paired.test.ts new file mode 100644 index 00000000000..0e7ebdcc885 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/paired-cloud-wake-not-paired.test.ts @@ -0,0 +1,49 @@ +// A paired cloud workspace whose pairing is gone can't be woken from here: opening it shows the +// waking card marked not-paired (which offers "Open cloud workspace…"), and never calls wake. +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/store', () => ({ + useAppStore: { getState: () => ({ settings: {} }), subscribe: () => () => {} } +})) + +import { wakePairedCloudWorkspace } from './paired-cloud-wake' +import { getCloudWorkspaceSnapshot, seedCloudWorkspaceSnapshot } from './orca-cloud-workspace-store' +import { resetCloudWorkspaceIdentityForTests } from '@/components/cloud-workspaces/cloud-workspace-identity' + +const ROW = 'push-test::/workspace/orca-sandbox-push-test' +let wake: ReturnType + +beforeEach(() => { + resetCloudWorkspaceIdentityForTests() + seedCloudWorkspaceSnapshot({ paired: { [ROW]: 'env-gone' }, idsLoaded: true }) + wake = vi.fn() + vi.stubGlobal('window', { + api: { + cloudWorkspaces: { + wake, + localWorkspaces: async () => [], + // No binding names this server any more: its pairing was removed. + environmentBindings: async () => ({}) + } + } + }) +}) + +describe('a paired workspace whose pairing is gone', () => { + it('fails as not paired, with the card that offers Open cloud workspace…', async () => { + const result = await wakePairedCloudWorkspace(ROW) + expect(result).toMatchObject({ ok: false, reason: 'not_paired' }) + expect(wake).not.toHaveBeenCalled() + expect(getCloudWorkspaceSnapshot().wake[ROW]).toMatchObject({ + phase: 'failed', + notPaired: true + }) + // The copy points at the picker, never at a Home list that no longer exists. + expect(getCloudWorkspaceSnapshot().wake[ROW]?.message).not.toMatch(/Home/) + }) + + it('a predictive (quiet) wake leaves no card behind', async () => { + expect((await wakePairedCloudWorkspace(ROW, { quiet: true })).ok).toBe(false) + expect(getCloudWorkspaceSnapshot().wake[ROW]).toBeUndefined() + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/paired-cloud-wake.test.ts b/src/renderer/src/lib/orca-cloud/paired-cloud-wake.test.ts index 23436ea9625..809d229214e 100644 --- a/src/renderer/src/lib/orca-cloud/paired-cloud-wake.test.ts +++ b/src/renderer/src/lib/orca-cloud/paired-cloud-wake.test.ts @@ -13,6 +13,8 @@ vi.mock('@/lib/worktree-sleep-view', () => ({ isWorktreeSleepViewHeld: () => fal import { isPredictiveWakeCandidate } from './install-cloud-predictive-wake' import { + PAIRED_WAKE_MAX_WAIT_MS, + PAIRED_WAKE_POLL_MS, notePairedCloudSlept, wakePairedCloudWorkspace, wakePairedCloudWorkspaceIfAsleep @@ -39,7 +41,14 @@ beforeEach(() => { status: { [ROW]: statusEntry }, idsLoaded: true }) - wake = vi.fn(async () => ({ ok: true, value: undefined })) + // The control plane accepts the wake; the VM then reports running (what the overlay waits for). + wake = vi.fn(async () => { + window.api.orcaCloudWorkspace.getStatus = async () => ({ + ok: true as const, + value: { ...asleep, state: 'ready' as const } + }) + return { ok: true, value: undefined } + }) vi.stubGlobal('window', { api: { cloudWorkspaces: { @@ -180,3 +189,45 @@ describe('the first open right after Sleep (n38 GATE: it sat in "Reconnecting… expect(wake).not.toHaveBeenCalled() }) }) + +describe('the waking overlay for a paired wake (PAIRED-WAKING-OVERLAY)', () => { + it('stays "waking"/"connecting" until the VM reports running, then ready (not when the request returns)', async () => { + let polls = 0 + wake.mockImplementationOnce(async () => { + // Accepted, but the VM takes a moment: asleep on the first read, running on the next. + window.api.orcaCloudWorkspace.getStatus = async () => { + polls += 1 + return { + ok: true as const, + value: { ...asleep, state: polls > 1 ? ('ready' as const) : ('sleeping' as const) } + } + } + return { ok: true, value: undefined } + }) + vi.useFakeTimers() + try { + const pending = wakePairedCloudWorkspace(ROW) + await vi.advanceTimersByTimeAsync(0) + expect(getCloudWorkspaceSnapshot().wake[ROW]?.phase).toBe('connecting') + await vi.advanceTimersByTimeAsync(PAIRED_WAKE_POLL_MS) + expect(await pending).toEqual({ ok: true }) + // (The store re-reads once more on "ready".) + expect(polls).toBeGreaterThanOrEqual(2) + } finally { + vi.useRealTimers() + } + }) + + it('a VM that never reports running fails the wake (the overlay says so) instead of claiming ready', async () => { + wake.mockImplementationOnce(async () => ({ ok: true, value: undefined })) + vi.useFakeTimers() + try { + const pending = wakePairedCloudWorkspace(ROW) + await vi.advanceTimersByTimeAsync(PAIRED_WAKE_MAX_WAIT_MS + PAIRED_WAKE_POLL_MS) + await pending + expect(getCloudWorkspaceSnapshot().wake[ROW]?.phase).toBe('failed') + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/paired-cloud-wake.ts b/src/renderer/src/lib/orca-cloud/paired-cloud-wake.ts index 9bddd52ab09..5c5cfb263c9 100644 --- a/src/renderer/src/lib/orca-cloud/paired-cloud-wake.ts +++ b/src/renderer/src/lib/orca-cloud/paired-cloud-wake.ts @@ -14,8 +14,8 @@ import { function notPairedMessage(): string { return translate( - 'auto.lib.orcaCloud.pairedWake.notPaired', - 'This workspace is no longer linked to this computer. Open it again from Cloud workspaces on Home.' + 'auto.lib.orcaCloud.pairedWake.notPaired2', + 'This workspace is no longer linked to this computer. Use Open cloud workspace… to link it again.' ) } @@ -28,6 +28,51 @@ export function notePairedCloudSlept(workspaceId: string): void { void refreshCloudWorkspaceStatus(workspaceId) } +/** How often, and for how long, an explicit wake re-reads status until the VM runs. */ +export const PAIRED_WAKE_POLL_MS = 1_500 +export const PAIRED_WAKE_MAX_WAIT_MS = 120_000 + +function wakeStalledMessage(): string { + return translate( + 'orcaCloudWorkspace.overlay.stalled', + 'Orca Cloud stopped responding while waking.' + ) +} + +/** Re-reads status until it says running (true) or the wait runs out (false). Bounded: it stops. */ +async function waitUntilRunning(workspaceId: string): Promise { + const deadline = Date.now() + PAIRED_WAKE_MAX_WAIT_MS + for (;;) { + await refreshCloudWorkspaceStatus(workspaceId) + const entry = getCloudWorkspaceSnapshot().status[workspaceId] + if (!entry?.error && entry?.status?.state === 'ready') { + return true + } + if (Date.now() + PAIRED_WAKE_POLL_MS > deadline) { + return false + } + await new Promise((resolve) => setTimeout(resolve, PAIRED_WAKE_POLL_MS)) + } +} + +/** Its pairing is gone: the waking card says so, with "Open cloud workspace…" to pair it again. */ +function notPaired(workspaceId: string, quiet: boolean | undefined): PairedWakeResult { + const message = notPairedMessage() + if (!quiet) { + const at = Date.now() + noteCloudWakeProgress({ + workspaceId, + phase: 'failed', + source: 'open', + startedAt: at, + at, + message, + notPaired: true + }) + } + return { ok: false, message, reason: 'not_paired' } +} + export type PairedWakeResult = { ok: true } | { ok: false; message: string; reason?: string } /** Is this sidebar id a paired cloud workspace (registered by the paired registry)? */ @@ -45,19 +90,19 @@ export async function wakePairedCloudWorkspace( ): Promise { const environmentId = getCloudWorkspaceSnapshot().paired[workspaceId] if (!environmentId) { - return { ok: false, message: notPairedMessage() } + return notPaired(workspaceId, options.quiet) } const sandboxId = await resolveCloudRowSandboxId({ id: workspaceId, hostId: toRuntimeExecutionHostId(environmentId) }) if (!sandboxId) { - return { ok: false, message: notPairedMessage() } + return notPaired(workspaceId, options.quiet) } const startedAt = Date.now() const source = options.source ?? 'open' const report = ( - phase: 'waking' | 'ready' | 'failed', + phase: 'waking' | 'connecting' | 'ready' | 'failed', failure?: { message: string; reason?: string } ): void => { if (!options.quiet) { @@ -83,11 +128,20 @@ export async function wakePairedCloudWorkspace( })) if (result.ok) { sleptHere.delete(workspaceId) - report('ready') + // The control plane accepts the wake before the VM runs (it takes seconds more): "ready" only + // once its status says so, or the overlay never outlasts its show delay (PAIRED-WAKING-OVERLAY). + report('connecting') + if (options.quiet) { + await refreshCloudWorkspaceStatus(workspaceId) + } else if (await waitUntilRunning(workspaceId)) { + report('ready') + } else { + report('failed', { message: wakeStalledMessage() }) + } } else { report('failed', { message: result.error, ...(result.reason ? { reason: result.reason } : {}) }) + await refreshCloudWorkspaceStatus(workspaceId) } - await refreshCloudWorkspaceStatus(workspaceId) return result.ok ? { ok: true } : { ok: false, message: result.error, ...(result.reason ? { reason: result.reason } : {}) } diff --git a/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.cancel.test.ts b/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.cancel.test.ts new file mode 100644 index 00000000000..216791fa2c3 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.cancel.test.ts @@ -0,0 +1,117 @@ +// @vitest-environment happy-dom +// GH-CREATE-CANCEL and GH-CREATE-NO-ROW for a GitHub-only Orca Cloud create: Cancel at any step +// leaves no workspace (the sandbox is deleted, which drops the pairing too), and a create that +// opens hands its pending row off to the paired server's rows, which stay hidden until then. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' + +type OpenOptions = { onHost?: (hostId: string) => void } +type OpenOutcome = { ok: true; worktreeId: string | null; hostId?: string } + +const mocks = vi.hoisted(() => ({ + open: vi.fn<(workspace: unknown, options?: OpenOptions) => Promise>(), + launch: vi.fn() +})) + +vi.mock('@/components/cloud-workspaces/open-cloud-workspace', () => ({ + openCloudWorkspace: mocks.open +})) +vi.mock('@/lib/agent-session/launch/new-tab/launch-agent-in-new-tab', () => ({ + launchAgentInNewTabWithOutcome: mocks.launch +})) + +import { startCloudWorkspaceFromGitHub } from './start-cloud-workspace-from-github' +import { resetGitHubCreateKeysForTest } from './github-cloud-create-key' + +const createFromGitHub = vi.fn<(args: unknown) => Promise>() +const destroy = vi.fn(async () => ({ ok: true, value: undefined })) + +beforeEach(() => { + resetGitHubCreateKeysForTest() + useAppStore.setState({ pendingWorktreeCreations: {}, activePendingCreationId: null }) + createFromGitHub.mockResolvedValue({ ok: true, value: { sandboxId: 'sbx_1' } }) + mocks.open.mockResolvedValue({ ok: true, worktreeId: null }) + Object.assign(window, { + api: { + ...window.api, + orcaCloudWorkspace: { createFromGitHub }, + cloudWorkspaces: { + get: vi.fn(async (id: string) => ({ ok: true, value: { id } })), + destroy, + localWorkspaces: vi.fn(async () => []), + environmentBindings: vi.fn(async () => ({})) + }, + ephemeralVm: { + onProvisionEvent: () => () => {}, + cancelProvision: vi.fn(async () => ({ cancelled: true })), + // Cancel's cleanup looks for this create's runtime records; a GitHub-only create has none. + listRuntimes: vi.fn(async () => []) + } + } + }) +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +const REQUEST = { + repoUrl: 'https://github.com/acme/web', + fullName: 'acme/web', + prompt: '', + agent: null, + composerRepoId: 'repo-1' +} + +const creationId = (): string => + Object.keys(useAppStore.getState().pendingWorktreeCreations)[0] ?? '' +const cancel = (): void => useAppStore.getState().removePendingWorktreeCreation(creationId()) + +describe('Cancel leaves no workspace (GH-CREATE-CANCEL)', () => { + it('cancelled after the sandbox started, before it opened: deleted, never opened', async () => { + createFromGitHub.mockImplementationOnce(async () => { + cancel() + return { ok: true, value: { sandboxId: 'sbx_1' } } + }) + expect(await startCloudWorkspaceFromGitHub(REQUEST)).toBe(false) + expect(mocks.open).not.toHaveBeenCalled() + expect(destroy).toHaveBeenCalledWith('sbx_1') + }) + + it('cancelled while it opened: the sandbox is deleted (its pairing and row go with it)', async () => { + mocks.open.mockImplementationOnce(async (_workspace, options) => { + options?.onHost?.('runtime:p') + cancel() + return { ok: true, worktreeId: 'w1', hostId: 'runtime:p' } + }) + expect(await startCloudWorkspaceFromGitHub(REQUEST)).toBe(false) + expect(destroy).toHaveBeenCalledWith('sbx_1') + expect(useAppStore.getState().pendingWorktreeCreations).toEqual({}) + }) + + it('cancelled while the create was in flight: main reclaims it; nothing opens here', async () => { + createFromGitHub.mockImplementationOnce(async () => { + cancel() + return { ok: false, code: 'failed', message: 'Cancelled.' } + }) + expect(await startCloudWorkspaceFromGitHub(REQUEST)).toBe(false) + expect(window.api.ephemeralVm.cancelProvision).toHaveBeenCalled() + expect(mocks.open).not.toHaveBeenCalled() + }) +}) + +describe('pending row hand-off (GH-CREATE-NO-ROW)', () => { + it('records the paired server while opening, and the hand-off drops the pending row', async () => { + let pairingHostIdDuringOpen: string | undefined + mocks.open.mockImplementationOnce(async (_workspace, options) => { + options?.onHost?.('runtime:p') + pairingHostIdDuringOpen = + useAppStore.getState().pendingWorktreeCreations[creationId()]?.pairingHostId + return { ok: true, worktreeId: null } + }) + expect(await startCloudWorkspaceFromGitHub(REQUEST)).toBe(true) + expect(pairingHostIdDuringOpen).toBe('runtime:p') + expect(useAppStore.getState().pendingWorktreeCreations).toEqual({}) + expect(destroy).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.ts b/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.ts index 9190e580e4f..dd3fdd7000e 100644 --- a/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.ts +++ b/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.ts @@ -9,6 +9,7 @@ import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { openCloudWorkspace } from '@/components/cloud-workspaces/open-cloud-workspace' +import { refreshCloudWorktreeBindings } from '@/components/cloud-workspaces/cloud-workspace-identity' import { createNonSecureContextUuid } from '../../../../shared/non-secure-context-uuid' import { isTuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent-config' import { ORCA_CLOUD_RECIPE_ID } from '../../../../shared/hosted-sandbox/orca-cloud-recipe-id' @@ -87,7 +88,12 @@ export function startCloudWorkspaceFromGitHub(request: StartFromGitHubRequest): }) store.setActiveView('terminal') store.setSidebarOpen(true) - return runGitHubCloudCreate(creationId) + return runTracked(creationId) +} + +/** Cancel's "Deleting the sandbox…" waits for every step that may still own a sandbox. */ +function runTracked(creationId: string): Promise { + return trackOrcaCloudCreateStep(creationId, runGitHubCloudCreate(creationId)) } /** The sheet's Retry (and the plans page's retry after checkout) for a GitHub-only create. */ @@ -114,7 +120,7 @@ export function retryGitHubCloudCreate(creationId: string): void { }) store.setActivePendingWorktreeCreation(creationId) store.setActiveView('terminal') - void runGitHubCloudCreate(creationId) + void runTracked(creationId) } function current(creationId: string) { @@ -186,21 +192,35 @@ async function runGitHubCloudCreate(creationId: string): Promise { return false } if (!current(creationId)) { - // Cancelled while it started: main stopped the create and deleted what it had made. - startedSandboxes.delete(creationId) + // GH-CREATE-CANCEL: cancelled after the sandbox started but before it opened. + await discardSandbox(creationId, sandboxId) return false } useAppStore.getState().updatePendingWorktreeCreation(creationId, { phase: 'creating' }) const workspace = await window.api.cloudWorkspaces.get(sandboxId).catch(() => null) - const opened = workspace?.ok ? await openCloudWorkspace(workspace.value) : null + const opened = workspace?.ok + ? await openCloudWorkspace(workspace.value, { + // GH-CREATE-NO-ROW: that server's rows wait behind the pending row until the hand-off. + onHost: (hostId) => + useAppStore + .getState() + .updatePendingWorktreeCreation(creationId, { pairingHostId: hostId }) + }) + : null + if (!current(creationId)) { + // GH-CREATE-CANCEL: cancelled while it opened. Opening can't be stopped half way, so the + // sandbox is deleted, which also drops this computer's pairing and its row. + await discardSandbox(creationId, sandboxId) + return false + } if (!opened?.ok) { // The sandbox exists from here on: Retry opens it and never creates it again. return fail( creationId, opened?.message ?? translate( - 'components.newWorkspace.cloudOnly.openLater', - 'Your cloud workspace started, but this computer couldn’t open it yet. Open it from Cloud workspaces.' + 'components.newWorkspace.cloudOnly.openLater2', + 'Your cloud workspace started, but this computer couldn’t open it yet. Try again, or open it later from Open cloud workspace…' ) ) } @@ -219,3 +239,14 @@ async function runGitHubCloudCreate(creationId: string): Promise { }) return true } + +/** A cancelled create's sandbox: deleted, so nothing keeps running (or billing) behind the cancel. */ +async function discardSandbox(creationId: string, sandboxId: string): Promise { + startedSandboxes.delete(creationId) + forgetGitHubCreateKey(creationId) + const result = await window.api.cloudWorkspaces.destroy(sandboxId).catch(() => null) + if (!result?.ok) { + console.warn('[orca-cloud] cancelled GitHub create: could not delete sandbox', sandboxId) + } + void refreshCloudWorktreeBindings().catch(() => undefined) +} diff --git a/src/renderer/src/lib/orca-cloud/use-orca-cloud-repo-access.ts b/src/renderer/src/lib/orca-cloud/use-orca-cloud-repo-access.ts index d07a6a21b45..04e74e0aa00 100644 --- a/src/renderer/src/lib/orca-cloud/use-orca-cloud-repo-access.ts +++ b/src/renderer/src/lib/orca-cloud/use-orca-cloud-repo-access.ts @@ -3,14 +3,12 @@ // network call; everything else comes from the control plane's GET /v1/github/repo-access (N2), // cached 5 minutes. While a repository is waiting (an install in the browser, or an org owner's // approval, D2), `poll` re-checks on the spec's cadence: every 2 s for 3 minutes, then every 10 s. -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useAppStore } from '@/store' import { normalizeGitRemoteUrl } from '../../../../shared/git/remotes/git-remote-identity' import type { HostedGithubRepoAccessResponse } from '../../../../shared/hosted-sandbox/hosted-github-wire' import type { Repo } from '../../../../shared/repo-types' -import type { ExternalLinkSource } from '@/components/open-external/copy-external-link' import { cloudWorkspaceApi } from './orca-cloud-workspace-store' -import { gitHubInstallLinkSource } from './github-link-sources' import { useOrcaCloudIdentityKey } from './orca-cloud-identity' export type OrcaCloudRepoAccessState = @@ -139,12 +137,7 @@ export function useOrcaCloudRepoAccess( /** Fired once when a repository that was waiting becomes reachable (D2's "access granted"). */ onGranted?: () => void } = {} -): OrcaCloudRepoAccess & { - refresh: () => void - openInstall: () => Promise - /** "Copy link" for openInstall: the same single-use install page, minted without opening. */ - installLink: ExternalLinkSource | null -} { +): OrcaCloudRepoAccess & { refresh: () => void; openInstall: () => Promise } { const repo = useAppStore((s) => repoId ? s.repos.find((entry) => entry.id === repoId) : undefined ) @@ -218,14 +211,6 @@ export function useOrcaCloudRepoAccess( return Boolean(result?.ok) }, [repoUrl, setupSessionId]) - const installLink = useMemo( - () => - repoUrl - ? gitHubInstallLinkSource({ repoUrl, ...(setupSessionId ? { setupSessionId } : {}) }) - : null, - [repoUrl, setupSessionId] - ) - return { state: enabled || !repoUrl ? state : 'unknown', ...(current?.installUrl ? { installUrl: current.installUrl } : {}), @@ -235,7 +220,6 @@ export function useOrcaCloudRepoAccess( repoUrl, loading: enabled && current === undefined, refresh, - openInstall, - installLink + openInstall } } diff --git a/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.test.ts b/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.test.ts index 3e296c962c2..39cc60b4eff 100644 --- a/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.test.ts +++ b/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.test.ts @@ -125,3 +125,26 @@ describe('paired workspaces scoped by account (PAIRED-ACCOUNT-FILTER)', () => { expect(hostsFor('team-two', paired()).hiddenHostIds.has('runtime:gh')).toBe(false) }) }) + +describe('GH-CREATE-NO-ROW hand-off', () => { + it('hides the server a GitHub-only create is pairing to until its pending row hands off', () => { + const pairing = (pairingHostId?: string) => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the selector reads only these fields. + ({ + runtimeEnvironments: [{ id: 'p', name: 'web (cloud)', orcaCloudPaired: true }], + orcaProfileAuthStatus: { cloud: { userId: 'u1' } }, + pendingWorktreeCreations: { + gh: { + creationId: 'gh', + request: { githubSource: { fullName: 'acme/web' } }, + ...(pairingHostId ? { pairingHostId } : {}) + } + } + }) as unknown as Pick + const hidden = (state: Pick) => [ + ...parseOrcaCloudHostScopeKey(selectOrcaCloudHostScopeKey(state)).hiddenHostIds + ] + expect(hidden(pairing())).toEqual([]) + expect(hidden(pairing('runtime:p'))).toEqual(['runtime:p']) + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.ts b/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.ts index ac77b801dd3..538b4601197 100644 --- a/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.ts +++ b/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.ts @@ -13,7 +13,12 @@ import { isOrcaCloudRuntimeEnvironment } from '../../../../shared/remote-runtime import type { Repo } from '../../../../shared/repo-types' type CloudScopeState = Pick & - Partial> + Partial< + Pick< + AppState, + 'orcaProfileAuthStatus' | 'cloudAccountIdByEnvironmentId' | 'pendingWorktreeCreations' + > + > /** * Every Orca Cloud server this desktop runs, `+host` when the active team owns it and `-host` @@ -30,7 +35,12 @@ type CloudScopeState = Pick & export function selectOrcaCloudHostScopeKey(state: CloudScopeState): string { const active = orcaCloudAccountId(state.orcaProfileAuthStatus?.cloud) const owners = state.cloudAccountIdByEnvironmentId ?? {} - return (state.runtimeEnvironments ?? []) + // GH-CREATE-NO-ROW: a GitHub-only create's pending row stands for its server's rows until the + // hand-off removes it; both change in one store update, so the row never shows twice. + const handingOff = Object.values(state.pendingWorktreeCreations ?? {}).flatMap((entry) => + entry.request.githubSource && entry.pairingHostId ? [`-${entry.pairingHostId}`] : [] + ) + const scoped = (state.runtimeEnvironments ?? []) .filter((environment) => isOrcaCloudRuntimeEnvironment(environment)) .flatMap((environment) => { const hostId = toRuntimeExecutionHostId(environment.id) @@ -41,7 +51,7 @@ export function selectOrcaCloudHostScopeKey(state: CloudScopeState): string { const inTeam = active !== null && owners[environment.id] === active return [`${inTeam ? '+' : '-'}${hostId}`] }) - .join('\n') + return [...scoped, ...handingOff].join('\n') } export function parseOrcaCloudHostScopeKey(key: string): { diff --git a/src/renderer/src/lib/workspace/creation/pending-worktree-creation.ts b/src/renderer/src/lib/workspace/creation/pending-worktree-creation.ts index 6b8a9f043b4..d7b4b35f343 100644 --- a/src/renderer/src/lib/workspace/creation/pending-worktree-creation.ts +++ b/src/renderer/src/lib/workspace/creation/pending-worktree-creation.ts @@ -195,6 +195,8 @@ export type PendingWorktreeCreation = { resumedAt?: number /** VM creates: the provisioned VM's runtime environment, known once it answers. */ vmEnvironmentId?: string + /** GitHub-only Orca Cloud create: the server it is pairing to; its rows wait for the hand-off. */ + pairingHostId?: string // Local git checkout meter; absent when git emits none or creation runs over SSH/runtime. checkoutProgress?: WorktreeCheckoutProgress /** Phases entered so far with their start times, so the chat checklist can time each step. */ diff --git a/src/renderer/src/store/slices/orca-profiles/orca-profiles-auth-actions.ts b/src/renderer/src/store/slices/orca-profiles/orca-profiles-auth-actions.ts index cfa2bea7af3..b98cdb03def 100644 --- a/src/renderer/src/store/slices/orca-profiles/orca-profiles-auth-actions.ts +++ b/src/renderer/src/store/slices/orca-profiles/orca-profiles-auth-actions.ts @@ -16,7 +16,10 @@ export type OrcaProfilesAuthActions = { orgId?: string name?: string }) => Promise - connectCurrentOrcaProfile: () => Promise + /** COPY-LINK-FIRST-CLICK: `openBrowser: false` starts sign-in without opening the browser. */ + connectCurrentOrcaProfile: (args?: { + openBrowser?: boolean + }) => Promise refreshCurrentOrcaProfileAuth: () => Promise signOutCurrentOrcaProfile: () => Promise selectOrcaProfileOrg: (orgId: string | null) => Promise @@ -74,11 +77,11 @@ export const createOrcaProfilesAuthActions: StateCreator< } }, - connectCurrentOrcaProfile: async () => { + connectCurrentOrcaProfile: async (args) => { const attempt = ++nextConnectAttempt try { // Pending PKCE must not block retry; newer linked waits supersede older callbacks. - const result = await window.api.orcaProfiles.connectCurrent() + const result = await window.api.orcaProfiles.connectCurrent(args) if (attempt < appliedConnectAttempt) { return result } diff --git a/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts b/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts index 177eb7bf2f6..3ac855a0cf8 100644 --- a/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts +++ b/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts @@ -149,13 +149,7 @@ export function createRemovePendingWorktreeCreation( .catch(() => undefined) } const cleanup = cleanUpCancelledVm(creationId, removedEntry.request, get) - // A GitHub-only create past its start is opening a sandbox that stays; nothing is deleted. - const opening = Boolean(removedEntry.request.githubSource) && removedEntry.phase === 'creating' - if ( - removedEntry.status === 'creating' && - isOrcaCloudCreateRequest(removedEntry.request) && - !opening - ) { + if (removedEntry.status === 'creating' && isOrcaCloudCreateRequest(removedEntry.request)) { beginOrcaCloudCancelCleanup(creationId, cleanup ? [cleanup] : [], { runtimeId: removedEntry.request.ephemeralVmRuntimeId, createStartedAt: removedEntry.startedAt, diff --git a/src/renderer/src/store/slices/worktrees/worktree-slice-contract.ts b/src/renderer/src/store/slices/worktrees/worktree-slice-contract.ts index f55349c25be..8fc823291c3 100644 --- a/src/renderer/src/store/slices/worktrees/worktree-slice-contract.ts +++ b/src/renderer/src/store/slices/worktrees/worktree-slice-contract.ts @@ -186,6 +186,7 @@ export type WorktreeSlice = { pausedMs?: number resumedAt?: number vmEnvironmentId?: string + pairingHostId?: string checkoutProgress?: PendingWorktreeCreation['checkoutProgress'] worktreeId?: string heldChatMessage?: string diff --git a/src/renderer/src/web/preload-api/web-orca-profiles-api.ts b/src/renderer/src/web/preload-api/web-orca-profiles-api.ts index 4fa1c22b21e..43bede4c5ae 100644 --- a/src/renderer/src/web/preload-api/web-orca-profiles-api.ts +++ b/src/renderer/src/web/preload-api/web-orca-profiles-api.ts @@ -65,6 +65,11 @@ export function createWebOrcaProfilesApi(): Partial { status: 'unconfigured', auth: await webOrcaProfileAuthStatus() }), + accountMfa: async () => ({ status: 'unconfigured' }), + teamMfaPolicy: async () => ({ status: 'unconfigured' }), + teamMfaPolicySet: async () => ({ status: 'unconfigured' }), + teamMfaPending: async () => ({ status: 'unconfigured' }), + teamMfaRemind: async () => ({ status: 'unconfigured' }), orgMembersList: async () => ({ status: 'unconfigured' }), orgMemberInvite: async () => ({ status: 'unconfigured' }), orgInviteRevoke: async () => ({ status: 'unconfigured' }), @@ -85,8 +90,6 @@ export function createWebOrcaProfilesApi(): Partial { cloudSetupGet: async () => ({ status: 'unconfigured' }), cloudSetupLink: async () => ({ status: 'unconfigured' }), billingOpenPortal: async () => ({ status: 'unconfigured' }), - billingPortalLink: async () => ({ status: 'unconfigured' }), - billingCheckoutLink: async () => ({ status: 'unconfigured' }), billingTeamPlans: async () => ({ status: 'unconfigured' }), billingUsageHistory: async () => ({ status: 'unconfigured' }), billingSetOverage: async () => ({ status: 'unconfigured' }), diff --git a/src/shared/cloud-api/cloud-api-types.ts b/src/shared/cloud-api/cloud-api-types.ts index dffaca1ff4b..e1cd0897034 100644 --- a/src/shared/cloud-api/cloud-api-types.ts +++ b/src/shared/cloud-api/cloud-api-types.ts @@ -84,27 +84,76 @@ export const CloudApiSettingsSchema = z.object({ }) export type CloudApiSettings = z.infer -export const CloudApiAuditEntrySchema = z.object({ - id: Id, - actor: z.object({ - type: z.string().max(64), - id: z.string().max(128), - userId: z.string().max(128).nullable(), - userName: PersonName - }), - action: z.string().max(128), - target: z.object({ type: z.string().max(64), id: z.string().max(128).nullable() }).nullable(), - // Free-form per action (e.g. a key's name); only short strings are ever shown. - detail: z.record(z.string(), z.unknown()).nullable().optional(), - createdAt: Millis -}) -export type CloudApiAuditEntry = z.infer +// The team audit log (GET /v1/audit-log): every recorded change in one feed. Owners and admins of +// a team whose plan includes it. Unknown categories, actor kinds and actions from a newer control +// plane still parse; the renderer words what it knows and shows the rest by its machine name. +export const CLOUD_AUDIT_CATEGORIES = [ + 'members', + 'security', + 'api_keys', + 'apps', + 'workspaces', + 'secrets', + 'routines', + 'settings', + 'billing', + 'other' +] as const +export type CloudAuditCategory = (typeof CLOUD_AUDIT_CATEGORIES)[number] -export const CloudApiAuditPageSchema = z.object({ - entries: z.array(CloudApiAuditEntrySchema).max(500), - nextBefore: Millis.nullable() +export const CloudAuditActorSchema = z.object({ + kind: z.enum(['person', 'former_member', 'system']).catch('person'), + userId: z.string().max(128).nullable(), + name: PersonName, + via: z.enum(['api_key', 'mcp', 'agent', 'routine']).nullable().catch(null) }) -export type CloudApiAuditPage = z.infer +export type CloudAuditActor = z.infer + +export const CloudAuditEventSchema = z.object({ + id: Id, + at: Millis, + category: z.enum(CLOUD_AUDIT_CATEGORIES).catch('other'), + action: z.string().max(128), + actor: CloudAuditActorSchema, + target: z + .object({ + type: z.string().max(64), + id: z.string().max(256).nullable(), + label: z.string().max(256).nullable().optional() + }) + .nullable(), + // Free-form per action; only short strings and numbers are ever shown. Never a secret value. + detail: z.record(z.string(), z.unknown()).nullable().optional() +}) +export type CloudAuditEvent = z.infer + +export const CloudAuditLogPageSchema = z.object({ + events: z.array(CloudAuditEventSchema).max(500), + nextCursor: z.string().max(128).nullable(), + /** Everyone who appears in the log, for the person filter; sent with the first page only. */ + actors: z.array(CloudAuditActorSchema).max(500).optional() +}) +export type CloudAuditLogPage = z.infer + +export const CloudAuditLogQuerySchema = z + .object({ + category: z.enum(CLOUD_AUDIT_CATEGORIES).optional(), + /** A user id, or `system`. */ + actor: z + .string() + .regex(/^[A-Za-z0-9_.:-]{1,128}$/) + .optional(), + cursor: z + .string() + .regex(/^\d{1,16}\.[A-Za-z0-9_-]{1,80}$/) + .optional(), + limit: z.number().int().min(1).max(200).optional() + }) + .strict() +export type CloudAuditLogQuery = z.infer + +/** A CSV export: the text, and whether more events exist past the export's cap. */ +export type CloudAuditLogExport = { csv: string; truncated: boolean } export const CloudApiProjectSchema = z.object({ id: Id, @@ -148,6 +197,8 @@ export const CLOUD_API_ERROR_CODES = [ 'signed-out', /** Signed in, but this action needs an org owner/admin. */ 'admin-required', + /** The team's plan does not include this (detail: the entitlement, e.g. `audit_log`). */ + 'plan-required', /** The key or token lacks a scope. */ 'forbidden', 'not-found', diff --git a/src/shared/ephemeral-vm-recipe-env.ts b/src/shared/ephemeral-vm-recipe-env.ts index 0f4f7bf5ec5..22a52e1c30e 100644 --- a/src/shared/ephemeral-vm-recipe-env.ts +++ b/src/shared/ephemeral-vm-recipe-env.ts @@ -1,5 +1,6 @@ // Recipe scripts run locally and inherit Orca's environment. These helpers let a recipe opt into a // narrower environment (`passEnv`) and let the UI name the credential-like variables a recipe sees. +import { AGENT_CREDENTIAL_NAMES } from './agent-credentials/agent-credential-names' /** Variables every recipe keeps even with `passEnv`, so shells and provider CLIs still start. */ export const RECIPE_BASE_ENV_NAMES: readonly string[] = [ @@ -63,6 +64,36 @@ export function isCredentialLikeEnvName(name: string): boolean { ) } +const AGENT_CREDENTIAL_NAME_SET = new Set(Object.values(AGENT_CREDENTIAL_NAMES).flat()) +const CREDENTIAL_SEGMENTS = new Set([ + 'KEY', + 'KEYS', + 'APIKEY', + 'TOKEN', + 'TOKENS', + 'SECRET', + 'SECRETS', + 'CREDENTIAL', + 'CREDENTIALS', + 'PAT' +]) +const CREDENTIAL_SUBSTRINGS = /PASSWORD|PASSWD|SECRET|TOKEN|PRIVATE_KEY|API_?KEY/ + +/** The host panel's classifier: agent credential names, plus key/token/secret-shaped segments. */ +export function looksLikeCredentialEnvName(name: string): boolean { + const upper = name.toUpperCase() + return ( + AGENT_CREDENTIAL_NAME_SET.has(upper) || + CREDENTIAL_SUBSTRINGS.test(upper) || + upper.split(/[_\-.]/).some((segment) => CREDENTIAL_SEGMENTS.has(segment)) + ) +} + +/** The one rule every recipe env warning uses, so the host panel and Settings name the same variables. */ +export function isRecipeCredentialEnvName(name: string): boolean { + return looksLikeCredentialEnvName(name) || isCredentialLikeEnvName(name) +} + /** `passEnv` entries are exact names or a prefix ending in `*` (e.g. `AWS_*`). */ export function isRecipeEnvNamePassed( name: string, @@ -104,7 +135,7 @@ export function buildRecipeInheritedEnv( /** Sorted credential-like names with a non-empty value in `source`. Never returns values. */ export function listCredentialLikeEnvNames(source: NodeJS.ProcessEnv): string[] { return Object.entries(source) - .filter(([name, value]) => Boolean(value) && isCredentialLikeEnvName(name)) + .filter(([name, value]) => Boolean(value) && isRecipeCredentialEnvName(name)) .map(([name]) => name) .sort((a, b) => a.localeCompare(b)) } diff --git a/src/shared/ephemeral-vm-recipe-forwarded-env.test.ts b/src/shared/ephemeral-vm-recipe-forwarded-env.test.ts index 303830a7f88..314ffaa98a6 100644 --- a/src/shared/ephemeral-vm-recipe-forwarded-env.test.ts +++ b/src/shared/ephemeral-vm-recipe-forwarded-env.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { listCredentialLikeEnvNames } from './ephemeral-vm-recipe-env' import { summarizeRecipeForwardedEnv } from './ephemeral-vm-recipe-forwarded-env' describe('summarizeRecipeForwardedEnv', () => { @@ -23,10 +24,53 @@ describe('summarizeRecipeForwardedEnv', () => { 'GITHUB_TOKEN', 'openai_api_key' ], - other: ['HOME', 'KEYCHAIN_PATH', 'PATH'] + other: ['HOME', 'KEYCHAIN_PATH', 'PATH'], + narrowed: false }) expect(JSON.stringify(summary)).not.toMatch( /ghp_secret_value|aws-secret-value|hunter2|sk-lower/ ) }) + + const env = { AWS_SECRET_ACCESS_KEY: 'aws', OPENAI_API_KEY: 'sk', PATH: '/usr/bin' } + + it("summarizes only what the recipe's passEnv lets through", () => { + expect(summarizeRecipeForwardedEnv(env, ['AWS_*'], 'linux')).toEqual({ + count: 2, + keyLike: ['AWS_SECRET_ACCESS_KEY'], + other: ['PATH'], + narrowed: true + }) + }) + + it('summarizes the whole env without passEnv', () => { + expect(summarizeRecipeForwardedEnv(env, undefined, 'linux')).toEqual({ + count: 3, + keyLike: ['AWS_SECRET_ACCESS_KEY', 'OPENAI_API_KEY'], + other: ['PATH'], + narrowed: false + }) + }) + + it('names the same credentials as the Settings credentials notice', () => { + const sample = { + OPENAI_API_KEY: 'v', + GITHUB_TOKEN: 'v', + AWS_REGION: 'v', + MY_PASSWORD: 'v', + ANTHROPIC_AUTH_TOKEN: 'v', + PRIVATE_KEY_PATH: 'v', + PATH: 'v' + } + const keyLike = summarizeRecipeForwardedEnv(sample).keyLike + expect(keyLike).toEqual(listCredentialLikeEnvNames(sample)) + expect(keyLike).toEqual([ + 'ANTHROPIC_AUTH_TOKEN', + 'AWS_REGION', + 'GITHUB_TOKEN', + 'MY_PASSWORD', + 'OPENAI_API_KEY', + 'PRIVATE_KEY_PATH' + ]) + }) }) diff --git a/src/shared/ephemeral-vm-recipe-forwarded-env.ts b/src/shared/ephemeral-vm-recipe-forwarded-env.ts index 2f083e23e8c..352f234b127 100644 --- a/src/shared/ephemeral-vm-recipe-forwarded-env.ts +++ b/src/shared/ephemeral-vm-recipe-forwarded-env.ts @@ -1,46 +1,30 @@ -import { AGENT_CREDENTIAL_NAMES } from './agent-credentials/agent-credential-names' +import { buildRecipeInheritedEnv, isRecipeCredentialEnvName } from './ephemeral-vm-recipe-env' -/** Names only: recipe create scripts get this computer's whole env, so values never leave main. */ +/** Names only: the env a recipe's scripts inherit (passEnv-narrowed or whole); values never leave main. */ export type RecipeForwardedEnvSummary = { count: number - /** Sorted names that look like credentials (key/token/secret/password, or an agent credential). */ + /** Sorted names that look like credentials (isRecipeCredentialEnvName). */ keyLike: string[] /** Every other forwarded name, sorted. */ other: string[] + /** True when the recipe's passEnv narrowed the env; false means this computer's whole env. */ + narrowed: boolean } -const AGENT_CREDENTIAL_NAME_SET = new Set(Object.values(AGENT_CREDENTIAL_NAMES).flat()) -const CREDENTIAL_SEGMENTS = new Set([ - 'KEY', - 'KEYS', - 'APIKEY', - 'TOKEN', - 'TOKENS', - 'SECRET', - 'SECRETS', - 'CREDENTIAL', - 'CREDENTIALS', - 'PAT' -]) -const CREDENTIAL_SUBSTRINGS = /PASSWORD|PASSWD|SECRET|TOKEN|PRIVATE_KEY|API_?KEY/ - -export function looksLikeCredentialEnvName(name: string): boolean { - // Windows env names are case-insensitive; match them the way agents would read them. - const upper = name.toUpperCase() - return ( - AGENT_CREDENTIAL_NAME_SET.has(upper) || - CREDENTIAL_SUBSTRINGS.test(upper) || - upper.split(/[_\-.]/).some((segment) => CREDENTIAL_SEGMENTS.has(segment)) - ) -} - -export function summarizeRecipeForwardedEnv(env: NodeJS.ProcessEnv): RecipeForwardedEnvSummary { +/** Summarizes what buildRecipeEnv would inherit: the whole env, or only what `passEnv` allows. */ +export function summarizeRecipeForwardedEnv( + source: NodeJS.ProcessEnv, + passEnv?: readonly string[], + platform: NodeJS.Platform = process.platform +): RecipeForwardedEnvSummary { + const env = buildRecipeInheritedEnv(source, passEnv, platform) const names = Object.keys(env) .filter((name) => name.length > 0 && env[name] !== undefined) .sort((a, b) => a.localeCompare(b)) return { count: names.length, - keyLike: names.filter(looksLikeCredentialEnvName), - other: names.filter((name) => !looksLikeCredentialEnvName(name)) + keyLike: names.filter(isRecipeCredentialEnvName), + other: names.filter((name) => !isRecipeCredentialEnvName(name)), + narrowed: passEnv !== undefined } } diff --git a/src/shared/hosted-sandbox/cloud-workspace-api.ts b/src/shared/hosted-sandbox/cloud-workspace-api.ts index d7eda6f51e3..5578ec63c7a 100644 --- a/src/shared/hosted-sandbox/cloud-workspace-api.ts +++ b/src/shared/hosted-sandbox/cloud-workspace-api.ts @@ -40,6 +40,8 @@ export type CloudWakeProgressEvent = { workspaceId: string phase: 'waking' | 'connecting' | 'ready' | 'failed' source: CloudWakeSource + /** Failed because this computer's pairing to it is gone: the card offers "Open cloud workspace…". */ + notPaired?: true /** Epoch ms the wake began (the overlay's elapsed timer). */ startedAt: number at: number @@ -95,11 +97,6 @@ export type OrcaCloudWorkspaceApi = { repoUrl?: string setupSessionId?: string }) => Promise> - /** "Copy link" for connectGitHub: the same single-use GitHub install page, minted but not opened. */ - gitHubInstallLink: (args?: { - repoUrl?: string - setupSessionId?: string - }) => Promise> /** Whether the signed-in person connected their own GitHub (cloud commit attribution). */ getGitHubUser: () => Promise> /** Opens GitHub's OAuth page in the browser so the person connects their own GitHub identity. */ @@ -146,7 +143,6 @@ export const ORCA_CLOUD_WORKSPACE_CHANNELS = { listCloudWorkspaceIds: 'orcaCloudWorkspace:listIds', getConfig: 'orcaCloudWorkspace:getConfig', connectGitHub: 'orcaCloudWorkspace:connectGitHub', - gitHubInstallLink: 'orcaCloudWorkspace:gitHubInstallLink', getGitHubUser: 'orcaCloudWorkspace:getGitHubUser', connectGitHubUser: 'orcaCloudWorkspace:connectGitHubUser', gitHubUserConnectLink: 'orcaCloudWorkspace:gitHubUserConnectLink', diff --git a/src/shared/orca-account-mfa.ts b/src/shared/orca-account-mfa.ts new file mode 100644 index 00000000000..2274d9784eb --- /dev/null +++ b/src/shared/orca-account-mfa.ts @@ -0,0 +1,27 @@ +// The signed-in person's own two-factor state for their Orca account. The identity provider owns +// the factor; Orca only reads whether one is set and where to manage it. +export type OrcaAccountMfa = { + mfa: 'on' | 'off' + /** Opens the account's security page in the browser. Checked in main: Orca Cloud's own origin. */ + setupUrl: string +} + +export type OrcaAccountMfaResult = + | ({ status: 'ok' } & OrcaAccountMfa) + /** `unsupported`: this Orca Cloud deployment (or local dev auth) has no MFA status. */ + | { status: 'unconfigured' | 'reconnect-required' | 'unsupported' } + | { status: 'failed'; error: string } + +/** + * A team member's two-factor state as owners and admins see it. Absent for everyone else, and + * for a member the provider said nothing about. 'pending': a team policy requires it by the + * member's `mfaDeadline` and they have not set it up. + */ +export type OrcaOrgMemberMfa = 'on' | 'off' | 'pending' + +/** The two-factor fields of a team member (OrcaOrgMember in orca-profiles.ts). */ +export type OrcaOrgMemberMfaFields = { + mfa?: OrcaOrgMemberMfa + /** Epoch ms the member must have MFA by; only with `mfa: 'pending'`. */ + mfaDeadline?: number +} diff --git a/src/shared/orca-cloud-setup.ts b/src/shared/orca-cloud-setup.ts index a1477682041..b11788c7a13 100644 --- a/src/shared/orca-cloud-setup.ts +++ b/src/shared/orca-cloud-setup.ts @@ -13,10 +13,21 @@ export type CloudSetupStartArgs = { needs: CloudSetupNeed[] /** Ask which team even when the profile already has an active one. */ chooseTeam?: boolean + /** + * COPY-LINK-FIRST-CLICK: false starts the same setup without opening the browser; a signed-in + * start then returns the session's link (`url`) to copy instead. + */ + openBrowser?: boolean } export type CloudSetupStartResult = - | { status: 'opened'; sessionId: string; expiresAt: number } + | { + status: 'opened' + sessionId: string + expiresAt: number + /** Only for `openBrowser: false`: the link the browser would have opened. */ + url?: string + } | { status: 'unconfigured' } | { status: 'signed-out' } | { status: 'cancelled' } diff --git a/src/shared/orca-profiles.ts b/src/shared/orca-profiles.ts index 7704c8aee72..1f8d3daf904 100644 --- a/src/shared/orca-profiles.ts +++ b/src/shared/orca-profiles.ts @@ -1,3 +1,4 @@ +import type { OrcaOrgMemberMfaFields } from './orca-account-mfa' import './constants' import { ORCA_BROWSER_PARTITION } from './browser/browser-session-partition' import type { ExecutionHostId } from './execution-host' @@ -250,7 +251,8 @@ export type RefreshCurrentOrcaProfileAuthResult = // Mirror the server’s fixed role enum so UI options cannot drift from accepted API values. export type OrcaOrgRole = 'owner' | 'admin' | 'member' -export type OrcaOrgMember = { +// Two-factor state (owners and admins only) lives in OrcaOrgMemberMfaFields. +export type OrcaOrgMember = OrcaOrgMemberMfaFields & { // Provisioned teammates without sign-in have no userId; disable mutations requiring that key. userId: string | null email: string diff --git a/src/shared/orca-team-mfa-policy.ts b/src/shared/orca-team-mfa-policy.ts new file mode 100644 index 00000000000..7e86956b4ed --- /dev/null +++ b/src/shared/orca-team-mfa-policy.ts @@ -0,0 +1,66 @@ +// A team's "require MFA" policy (Enterprise). The auth service owns it; the desktop reads the +// deadline and the person's own state for the banner and the blocked screen, and owners and +// admins set it from Settings, Enterprise. + +/** Enforce now, or after a grace period in days. Mirrors the server's fixed choices. */ +export const ORCA_TEAM_MFA_GRACE_CHOICES = ['now', 7, 14, 30] as const +export type OrcaTeamMfaGrace = (typeof ORCA_TEAM_MFA_GRACE_CHOICES)[number] + +/** + * The person's own state in this team: + * - `pending`: the team requires MFA, they have none, the deadline has not passed. + * - `blocked`: the deadline passed; the team's workspaces and settings are closed to them. + * - `unknown`: the identity provider did not say; never treated as blocked. + */ +export type OrcaTeamMfaSelfState = 'on' | 'off' | 'pending' | 'blocked' | 'unknown' + +export type OrcaTeamMfaPolicy = { + /** Null when the team has no policy in force. */ + policy: { enforceAt: number; setAt: number } | null + self: OrcaTeamMfaSelfState + /** The account's security page, on Orca Cloud's own origin (checked in main). */ + setupUrl: string + /** Owners and admins only. */ + manage?: { + /** The team's plan includes MFA enforcement. */ + available: boolean + pendingMembers: number + /** A stored policy the plan no longer covers: it enforces nothing. */ + inactiveEnforceAt?: number + } +} + +export type OrcaTeamMfaPendingMember = { + userId: string | null + email: string + displayName?: string + mfa: 'pending' | 'blocked' | 'off' +} + +type Unavailable = + | { status: 'unconfigured' | 'reconnect-required' | 'unsupported' | 'forbidden' | 'rate-limited' } + | { status: 'failed'; error: string } + +export type OrcaTeamMfaPolicyResult = ({ status: 'ok' } & OrcaTeamMfaPolicy) | Unavailable + +export type OrcaTeamMfaPolicySetResult = + | { status: 'ok'; policy: { enforceAt: number; setAt: number } | null; pendingMembers: number } + /** The person changing the policy has no MFA themselves; the policy read has the setup link. */ + | { status: 'actor-needs-mfa' } + /** The team's plan does not include MFA enforcement. */ + | { status: 'not-entitled' } + | Unavailable + +export type OrcaTeamMfaPendingResult = + | { status: 'ok'; members: OrcaTeamMfaPendingMember[] } + | Unavailable + +export type OrcaTeamMfaRemindResult = + /** `delivery: 'log'` means the server recorded the reminder but sent no email. */ + | { status: 'ok'; recipients: number; delivery: 'log' | 'test-inbox' | 'unknown' } + | { status: 'no-policy' | 'not-found' } + | Unavailable + +export type OrcaTeamMfaPolicyArgs = { orgId: string } +export type OrcaTeamMfaPolicySetArgs = { orgId: string; enforce: OrcaTeamMfaGrace | 'off' } +export type OrcaTeamMfaRemindArgs = { orgId: string; userId?: string } diff --git a/src/shared/plugins/languages/protected-copy-keys.json b/src/shared/plugins/languages/protected-copy-keys.json index 741d48cb6f8..65a7493503e 100644 --- a/src/shared/plugins/languages/protected-copy-keys.json +++ b/src/shared/plugins/languages/protected-copy-keys.json @@ -778,6 +778,7 @@ "cloudShare.openFailed.signIn", "cloudShare.openFailed.title", "cloudShare.openFailed.unreachable", + "components.cloudWorkspaces.picker.menuItem", "components.managed-runtime.removeConfirm", "components.managed-runtime.removeDescription", "components.managed-runtime.removeKeep", @@ -952,7 +953,7 @@ "localCommands.text.trailingSpace", "orcaCloudWorkspace.deleteFailedNamed", "orcaCloudWorkspace.deletedNamed", - "orcaCloudWorkspace.primaryDelete.noSandbox", + "orcaCloudWorkspace.primaryDelete.noSandbox2", "plugins.frameOpenExternal.alwaysAllow", "plugins.frameOpenExternal.cancel", "plugins.frameOpenExternal.message", diff --git a/src/shared/sidebar-list/sidebar-row-plan.ts b/src/shared/sidebar-list/sidebar-row-plan.ts index 68dbcda8e62..195a5d30651 100644 --- a/src/shared/sidebar-list/sidebar-row-plan.ts +++ b/src/shared/sidebar-list/sidebar-row-plan.ts @@ -212,8 +212,10 @@ export function planSidebarRows(inputs: SidebarRowPlanInputs) { const naturalWorktrees = orderedGroups.flatMap(([, group]) => group.items) const render = (collapsedGroups: Set): Row[] => { const result: Row[] = [] - if (groupBy !== 'repo') { - for (const creation of pendingCreations) { + // By project, a create sits atop its own project; one with no project here yet (a GitHub-only + // Orca Cloud create, GH-CREATE-NO-ROW) sits atop the list, as in every other grouping. + for (const creation of pendingCreations) { + if (groupBy !== 'repo' || !repoMap.has(creation.repoId)) { result.push(buildPendingCreationRow(creation, repoMap)) } } diff --git a/src/shared/sidebar-list/worktree-grouping.ts b/src/shared/sidebar-list/worktree-grouping.ts index ad1d8dd639b..387aa316f96 100644 --- a/src/shared/sidebar-list/worktree-grouping.ts +++ b/src/shared/sidebar-list/worktree-grouping.ts @@ -71,7 +71,8 @@ export function buildOrderedGroups(args: { ([repoId, candidate]) => [repoId, candidate.repo] as const ) ], - pendingRepoIds: pendingByRepo.keys(), + // A create with no project here yet sits atop the list instead (GH-CREATE-NO-ROW). + pendingRepoIds: [...pendingByRepo.keys()].filter((repoId) => repoMap.has(repoId)), repoOrder, projectOrderBy })