feat(cloud): member git identity only within a window after the member's own client reports a send (0031); entry points kept current per boot

# Conflicts:
#	cloud/apps/sandbox-control/image/vm-scripts.generated.ts
This commit is contained in:
Neil
2026-11-09 07:30:39 -08:00
parent a3c5f1ed50
commit baef8f91e0
19 changed files with 183 additions and 30 deletions
File diff suppressed because one or more lines are too long
@@ -9,11 +9,17 @@ set -euo pipefail
DIR=/etc/orca/members
caller="${SUDO_USER:-}"
case "$caller" in '' | root | *[!a-z0-9_-]*) echo 'orca-git-identity: run through orca-git-as' >&2; exit 4 ;; esac
home="$(getent passwd "$caller" | cut -d: -f6)"
# orcad's own data dir and HOME (the agent's user may not use its passwd home, e.g. HOME=/vercel).
orcad_pid="$(pgrep -u "$caller" -f orcad-server.js | head -n 1 || true)"
[ -n "$orcad_pid" ] || exit 3
orcad_env() { tr '\0' '\n' <"/proc/$orcad_pid/environ" | sed -n "s/^$1=//p" | head -n 1; }
home="$(orcad_env HOME)"
data="$(orcad_env ORCA_USER_DATA)"
data="${data:-$home/.orcad-data}"
node="$(find /opt/orca/runtime/runtimes -type f -name node | head -n 1)"
cli=/opt/orca/runtime/orcad/out/cli/index.js
# Only the evidence variables orcad needs reach the query, which runs as the caller, not root.
answer="$(sudo -u "$caller" env -i HOME="$home" PATH=/usr/bin:/bin ORCA_USER_DATA_PATH="$home/.orcad-data" \
answer="$(sudo -u "$caller" env -i HOME="$home" PATH=/usr/bin:/bin ORCA_USER_DATA_PATH="$data" \
${ORCA_TERMINAL_HANDLE:+ORCA_TERMINAL_HANDLE="$ORCA_TERMINAL_HANDLE"} \
${ORCA_PANE_KEY:+ORCA_PANE_KEY="$ORCA_PANE_KEY"} \
${ORCA_AGENT_LAUNCH_TOKEN:+ORCA_AGENT_LAUNCH_TOKEN="$ORCA_AGENT_LAUNCH_TOKEN"} \
@@ -6,11 +6,13 @@
# sudoers rule) hands an agent only the identity of the member whose device drove its turn, as orcad
# attests it. No token lives here: the placeholder is swapped at egress, for this repository only.
# Env: ORCA_MEMBERS_B64 = base64 JSON [{userId, name, email, authorization, deviceIds:[...]}]
# ORCA_SANDBOX_USER = the VM user agents run as. Prints {"members":<n>,"devices":<n>}.
# The VM user agents run as is orcad's own user (detected). Prints {"members":<n>,"devices":<n>}.
set -euo pipefail
umask 077
DIR=/etc/orca/members
USER_NAME="${ORCA_SANDBOX_USER:-vercel-sandbox}"
orcad_pid="$(pgrep -f orcad-server.js | head -n 1 || true)"
USER_NAME="$( [ -n "$orcad_pid" ] && ps -o user= -p "$orcad_pid" | tr -d ' ' || true)"
USER_NAME="${USER_NAME:-${ORCA_SANDBOX_USER:-${SUDO_USER:-}}}"
case "$USER_NAME" in '' | *[!a-z0-9_-]*) echo 'bad user' >&2; exit 2 ;; esac
mkdir -p /etc/orca
json="$(printf '%s' "${ORCA_MEMBERS_B64:-W10=}" | base64 -d)"
@@ -46,5 +48,7 @@ else
exit 1
fi
# Pre-attestation layout (agent-readable member files) must not linger.
rm -rf "/home/$USER_NAME/.orca-sandbox/members"
user_home="$(getent passwd "$USER_NAME" | cut -d: -f6)"
[ -n "$orcad_pid" ] && user_home="$(tr '\0' '\n' <"/proc/$orcad_pid/environ" | sed -n 's/^HOME=//p' | head -n 1)"
[ -n "$user_home" ] && rm -rf "$user_home/.orca-sandbox/members"
echo "{\"members\":$count,\"devices\":$devices}"
@@ -0,0 +1,10 @@
-- When each member last sent a message to a shared workspace, reported by the member's own
-- signed-in client (their Orca token, not the VM). The egress proxy honors a member's GitHub
-- identity only within a short window after this, so nothing inside the VM can push as a member
-- who is not actively driving the workspace.
CREATE TABLE workspace_member_sends (
sandbox_id TEXT NOT NULL,
user_id TEXT NOT NULL,
sent_at INTEGER NOT NULL,
PRIMARY KEY (sandbox_id, user_id)
);
+2
View File
@@ -20,6 +20,8 @@ export type Env = {
GOLDEN_SNAPSHOT_OVERRIDE?: string
/** Git as the sender: `on`, `accounts:<id>,<id>` or off (default). See memberGitEnabled in index.ts. */
MEMBER_GIT_IDENTITY?: string
/** How long after a member's own send their GitHub identity stays usable (default 30). */
MEMBER_GIT_WINDOW_MINUTES?: string
/** `release:latest` | `release:v1.2.3`: build golden images from Orca's released orca-server tarball. */
GOLDEN_IMAGE_SOURCE?: string
/** TTL for Worker-built golden images; unset/0 keeps them until rotated out. */
@@ -314,6 +314,30 @@ async function stepBuild(deps: GoldenImageDeps, row: GoldenRow): Promise<GoldenT
/** Wipe identity, snapshot (stops the box), drop the build box; the snapshot then waits for its smoke test. */
/** Rewrites /opt/orca/bin/<entry point> from VM_SCRIPT_SOURCES (same bytes a release build installs). */
/** Stable fingerprint of the control plane's VM entry points (FNV-1a over names and bytes). */
export function entryPointsVersion(): string {
let hash = 0x811c9dc5
for (const name of VM_SCRIPTS) {
for (const char of `${name}\u0000${VM_SCRIPT_SOURCES[name]}\u0000`) {
hash = Math.imul(hash ^ char.charCodeAt(0), 0x01000193) >>> 0
}
}
return hash.toString(16).padStart(8, '0')
}
/**
* Sandboxes booted from an older image get the control plane's current entry points (grant, revoke,
* member identities, sender-aware git) once per boot: a marker check, and an install only if stale.
*/
export async function ensureEntryPoints(deps: { provider: Pick<SandboxProviderApi, 'runCommand'> }, sessionId: string): Promise<void> {
const probe = await deps.provider
.runCommand(sessionId, { command: 'test', args: ['-e', `/opt/orca/bin/.entry-points-${entryPointsVersion()}`], timeoutMs: 15_000 })
.catch(() => null)
if (probe?.exitCode !== 0) {
await installEntryPoints(deps, sessionId)
}
}
export async function installEntryPoints(deps: { provider: Pick<SandboxProviderApi, 'runCommand'> }, sessionId: string): Promise<void> {
const env = provisionEnv({ manifest: MANIFEST, provisionScript: PROVISION_SCRIPT, vmScripts: VM_SCRIPT_SOURCES, buildId: 'sync' })
const scriptEnv = Object.fromEntries(Object.entries(env).filter(([key]) => key.startsWith('ORCA_VM_SCRIPT_')))
@@ -325,6 +349,8 @@ export async function installEntryPoints(deps: { provider: Pick<SandboxProviderA
' sudo install -o root -g root -m 0755 "/tmp/$name.new" "/opt/orca/bin/$name"',
' rm -f "/tmp/$name.new"',
'done',
// Marks this set of entry points as installed (ensureEntryPoints skips the install next time).
`sudo rm -f /opt/orca/bin/.entry-points-* && sudo touch /opt/orca/bin/.entry-points-${entryPointsVersion()}`,
'echo synced'
].join('\n')
const result = await deps.provider.runCommand(sessionId, { command: 'bash', args: ['-c', install], env: scriptEnv, timeoutMs: 60_000 })
+4 -10
View File
@@ -11,7 +11,7 @@ import { z } from 'zod'
import type { SandboxCaller } from './auth'
import { markRevoked, runVmRevoke, type GrantRow } from './grant-revokes'
import { LifecycleError, resumeSandbox, syncMemberGit, type LifecycleDeps } from './lifecycle'
import { installEntryPoints } from './golden-images'
import { ensureEntryPoints } from './golden-images'
import type { CommandResult } from './sandbox-provider'
import { ORCAD_PORT } from './sandbox-vm-commands'
import { getSandbox, touchActivity, type SandboxRow } from './store'
@@ -55,15 +55,9 @@ export async function withEntryPoints(
command: string,
run: () => Promise<CommandResult>
): Promise<CommandResult> {
const first = await run().catch((error: unknown) => ({ exitCode: -1, stdout: '', stderr: String(error) }))
if (first.exitCode === 0) {
return first
}
const probe = await deps.provider.runCommand(sessionId, { command: 'test', args: ['-x', command], timeoutMs: 15_000 }).catch(() => null)
if (!probe || probe.exitCode === 0) {
return first
}
await installEntryPoints(deps, sessionId)
// Stale or missing scripts from an older image are replaced first (one marker check when current).
await ensureEntryPoints(deps, sessionId).catch(() => undefined)
void command
return run()
}
+19 -4
View File
@@ -131,7 +131,7 @@ import {
storeUserToken,
userAuthorizeUrl
} from './github-user-tokens'
import { memberGitRoutes } from './member-git'
import { MEMBER_GIT_WINDOW_MS, memberGitRoutes, memberSentWithin, recordMemberSend } from './member-git'
import {
PatchSchema,
PresenceSchema,
@@ -217,8 +217,10 @@ function egressRoutes(env: Env, deps: Deps): (row: SandboxRow, host: string) =>
db: env.SANDBOX_DB,
identity: async (row, userId) => {
const keyring = keyringFor(env)
return keyring && memberGit(row.account_id)
? memberGitIdentity(env.SANDBOX_DB, await keyring, githubConnect(env, deps), row.account_id, userId, (deps.now ?? Date.now)())
const now = (deps.now ?? Date.now)()
// Only while the member is actively driving this workspace (their own client reported a send).
return keyring && memberGit(row.account_id) && (await memberSentWithin(env.SANDBOX_DB, row.id, userId, now, memberGitWindowMs(env)))
? memberGitIdentity(env.SANDBOX_DB, await keyring, githubConnect(env, deps), row.account_id, userId, now)
: null
}
})
@@ -593,6 +595,11 @@ export function egressPlaceholderMode(value: string | undefined): boolean | Read
* orca-git-identity hands an agent only that member's identity. Residual risk: members share the VM
* user, so a member with a shell could subvert orcad itself; agents and prompts cannot.
*/
function memberGitWindowMs(env: Env): number {
const minutes = Number(env.MEMBER_GIT_WINDOW_MINUTES)
return Number.isFinite(minutes) && minutes > 0 ? minutes * 60_000 : MEMBER_GIT_WINDOW_MS
}
export function memberGitEnabled(env: Env): (accountId: string) => boolean {
// On by default: orcad attests each turn's sender and orca-git-identity refuses any other member.
if (env.MEMBER_GIT_IDENTITY === undefined || env.MEMBER_GIT_IDENTITY.trim() === '') {
@@ -1163,7 +1170,7 @@ async function workspaceRoute(request: Request, url: URL, env: Env, deps: Deps,
if (url.pathname === '/v1/team/workspaces') {
return request.method === 'GET' ? json(await listWorkspaces(db, caller, url, now)) : fail('not_found', 404)
}
const match = /^\/v1\/team\/workspaces\/(sbx_[0-9a-f]{24})(?:\/(presence|wake|lease|grants)(?:\/(grt_[0-9a-f]{24}))?)?$/.exec(url.pathname)
const match = /^\/v1\/team\/workspaces\/(sbx_[0-9a-f]{24})(?:\/(presence|wake|lease|grants|sent)(?:\/(grt_[0-9a-f]{24}))?)?$/.exec(url.pathname)
if (!match) {
return fail('not_found', 404)
}
@@ -1174,6 +1181,14 @@ async function workspaceRoute(request: Request, url: URL, env: Env, deps: Deps,
if (route === 'GET ') {
return json({ workspace: await view() })
}
if (route === 'POST sent') {
// The caller's own client: "I just sent a message here" (opens their git-as-sender window).
if (!canOpen(caller, row)) {
throw new WorkspaceError('not_found')
}
await recordMemberSend(db, row.id, caller.userId, now)
return json({ ok: true })
}
if (route === 'POST presence') {
const body = PresenceSchema.safeParse(await readJson(request))
if (!body.success) {
@@ -12,6 +12,7 @@ import {
} from './guardrails'
import { DEFAULT_EGRESS_ALLOWLIST, compileNetworkPolicy, gitBasicAuthorization, type CredentialRoute } from './network-policy'
import { applyQueuedRevokes } from './grant-revokes'
import { ensureEntryPoints } from './golden-images'
import { memberPlaceholder, type MemberGitIdentity } from './github-user-tokens'
import { connectedMembers, memberGitConfigs } from './member-git'
import type { RetentionConfig } from './retention'
@@ -382,6 +383,7 @@ export async function syncMemberGit(deps: LifecycleDeps, row: SandboxRow, sessio
return 0
}
try {
await ensureEntryPoints(deps, sessionId)
await refreshGitCredentials(deps, row, sessionId)
const configs = await memberGitConfigs({ db: deps.db, identity: deps.memberIdentity }, row)
const encoded = btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(configs))))
@@ -12,6 +12,28 @@ import { memberPlaceholder, type MemberGitIdentity } from './github-user-tokens'
import { gitBasicAuthorization } from './network-policy'
import type { SandboxRow } from './store'
/** A member's GitHub identity works only this long after they themselves sent a message (default). */
export const MEMBER_GIT_WINDOW_MS = 30 * 60_000
/** Their own client reports each send (their Orca token): the window cannot be opened from the VM. */
export async function recordMemberSend(db: D1Database, sandboxId: string, userId: string, now: number): Promise<void> {
await db
.prepare(
`INSERT INTO workspace_member_sends (sandbox_id, user_id, sent_at) VALUES (?, ?, ?)
ON CONFLICT (sandbox_id, user_id) DO UPDATE SET sent_at = excluded.sent_at`
)
.bind(sandboxId, userId, now)
.run()
}
export async function memberSentWithin(db: D1Database, sandboxId: string, userId: string, now: number, windowMs: number): Promise<boolean> {
const row = await db
.prepare('SELECT sent_at FROM workspace_member_sends WHERE sandbox_id = ? AND user_id = ?')
.bind(sandboxId, userId)
.first<{ sent_at: number }>()
return row !== null && now - row.sent_at <= windowMs
}
export type MemberGitDeps = {
db: D1Database
identity: (row: SandboxRow, userId: string) => Promise<MemberGitIdentity | null>
@@ -37,11 +37,19 @@ async function setup(memberGit: string | undefined = undefined) {
body: { workspaceId: 'ws-1', machineSize: 'standard', repo: { url: REPO }, visibility: 'team' }
})
const id: string = created.body.sandbox.id
const seed = (userId: string, login: string, githubUserId: number) =>
h.call('POST', '/v1/internal/dev/member-github', {
const sent = async (userId: string) =>
h.call('POST', `/v1/team/workspaces/${id}/sent`, { bearer: await as(userId) })
// Connect GitHub, then (their own client) report a send so their identity window is open.
const seed = async (userId: string, login: string, githubUserId: number, send = true) => {
const stored = await h.call('POST', '/v1/internal/dev/member-github', {
bearer: OP,
body: { accountId: 'org:acme', userId, repoUrl: REPO, login, githubUserId, name: `${login} name` }
})
if (send) {
await sent(userId)
}
return stored
}
async function oidc() {
return new SignJWT({ team_id: 'team_orca', project_id: 'prj_orca', sandbox_id: 'sbx-v', sandbox_name: `orca-test-${id}` })
.setProtectedHeader({ alg: 'RS256', kid: 'v1' })
@@ -68,7 +76,7 @@ async function setup(memberGit: string | undefined = undefined) {
)
return { status: response.status, body: await response.text() }
}
return { h, id, as, seed, proxied, upstream }
return { h, id, as, seed, sent, proxied, upstream }
}
describe('git as the sender', () => {
@@ -132,4 +140,19 @@ describe('git as the sender', () => {
expect(await t.proxied('github.com', '/acme/app.git/git-receive-pack', gitBasicAuthorization(alicePh))).toEqual({ status: 403, body: 'credential_unavailable' })
expect(t.h.vercel.commands.some((c) => c.command === MEMBERS_COMMAND)).toBe(false)
})
it('honors a member identity only within the window after their own client reported a send', async () => {
const t = await setup()
await t.seed('alice', 'alice-gh', 11, false)
const ph = gitBasicAuthorization(await memberPlaceholder(t.id, 'alice'))
// Connected, but never sent: nothing in the VM can use Alice's identity.
expect(await t.proxied('github.com', '/acme/app.git/git-receive-pack', ph)).toEqual({ status: 403, body: 'credential_unavailable' })
await t.sent('alice')
expect((await t.proxied('github.com', '/acme/app.git/git-receive-pack', ph)).status).toBe(200)
t.h.advance(31 * 60_000)
expect(await t.proxied('github.com', '/acme/app.git/git-receive-pack', ph)).toEqual({ status: 403, body: 'credential_unavailable' })
// A teammate cannot open Alice's window for her.
expect((await t.h.call('POST', `/v1/team/workspaces/${t.id}/sent`, { bearer: await t.as('bob') })).status).toBe(200)
expect(await t.proxied('github.com', '/acme/app.git/git-receive-pack', ph)).toEqual({ status: 403, body: 'credential_unavailable' })
})
})
@@ -1,6 +1,6 @@
// HTTPS client for the team workspace registry on the sandbox control plane. Same auth as the
// hosted sandbox driver: a short-lived Orca token (aud=orca-sandbox), never a provider credential.
import type { z } from 'zod'
import { z } from 'zod'
import { errorMessage } from '../../shared/error-message'
import {
CloudWorkspaceGrantIssuedSchema,
@@ -15,6 +15,8 @@ import {
type OrgPrompts
} from '../../shared/cloud-workspaces/cloud-workspaces-wire'
const NoteSentSchema = z.object({ ok: z.literal(true) })
export class CloudWorkspacesError extends Error {
constructor(
readonly status: number,
@@ -127,6 +129,9 @@ export function createCloudWorkspacesClient(options: CloudWorkspacesClientOption
async patch(id: string, patch: CloudWorkspacePatch) {
return call('PATCH', ws(id), CloudWorkspaceResponseSchema.extend({}).passthrough(), patch)
},
async noteSent(id: string) {
await call('POST', `${ws(id)}/sent`, NoteSentSchema, {})
},
async presence(
id: string,
body: { clientId: string; state: 'viewing' | 'typing' | 'left'; chatId?: string | null }
@@ -139,6 +139,7 @@ export function createCloudWorkspacesService(deps: {
putOrgPrompts: (document: OrgPrompts['document'], revision: number) =>
wrap(() => client.putOrgPrompts(document, revision)),
localWorkspaces: () => localWorkspaces(),
noteSent: (id: string) => wrap(() => client.noteSent(id)),
/**
* A per-device credential for a workspace this desktop does not own: a fresh X25519 key lives
+7
View File
@@ -155,6 +155,13 @@ export function registerCloudWorkspacesHandlers(): void {
const s = await getService()
return !s ? NOT_CONFIGURED : parsed.success ? s.get(parsed.data.id) : INVALID
})
handle('cloudWorkspaces:noteSent', async (args) => {
const parsed = z.object({ id: Id }).safeParse(args)
const s = await getService()
if (s && parsed.success) {
await s.noteSent(parsed.data.id)
}
})
handle('cloudWorkspaces:patch', async (args) => {
const parsed = z.object({ id: Id, patch: Patch }).safeParse(args)
const s = await getService()
+2
View File
@@ -36,6 +36,8 @@ export type CloudWorkspacesApi = {
listGrants: (id: string) => Promise<CloudWorkspacesResult<CloudWorkspaceGrant[]>>
revokeGrant: (id: string, grantId: string) => Promise<CloudWorkspacesResult<void>>
localWorkspaces: () => Promise<LocalCloudWorkspace[]>
/** "I just sent a message here" (opens my git-as-sender window on the control plane). */
noteSent: (id: string) => Promise<void>
/** Remembers which local runtime environment a granted workspace was added as. */
bindEnvironment: (
sandboxId: string,
@@ -12,6 +12,7 @@ export const cloudWorkspacesApi = {
listGrants: (id) => ipcRenderer.invoke('cloudWorkspaces:listGrants', { id }),
revokeGrant: (id, grantId) => ipcRenderer.invoke('cloudWorkspaces:revokeGrant', { id, grantId }),
localWorkspaces: () => ipcRenderer.invoke('cloudWorkspaces:localWorkspaces'),
noteSent: (id) => ipcRenderer.invoke('cloudWorkspaces:noteSent', { id }),
bindEnvironment: (sandboxId, runtimeEnvironmentId, grantId) =>
ipcRenderer.invoke('cloudWorkspaces:bindEnvironment', {
sandboxId,
@@ -1,7 +1,7 @@
// Terminal agents in a shared (team-visible) Orca Cloud workspace hear from several teammates, so a
// chat message typed into their PTY starts with the same one-line sender envelope structured sends
// carry. The terminal transcript shows the line as typed; only native chat rows can strip it.
import { cloudSenderForEnvironment } from '@/lib/cloud-message-sender'
import { cloudSenderForEnvironment, noteCloudSend } from '@/lib/cloud-message-sender'
import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/hosted-terminals/identity/remote-runtime-pty-owner'
import {
withSenderEnvelope,
@@ -11,11 +11,13 @@ import {
export type PtyCloudSenderDeps = {
environmentOf: (ptyId: string) => string | null
senderFor: (environmentId: string) => MessageSender | null
noteSend?: (environmentId: string) => void
}
const DEFAULT_DEPS: PtyCloudSenderDeps = {
environmentOf: getRemoteRuntimePtyEnvironmentId,
senderFor: cloudSenderForEnvironment
senderFor: cloudSenderForEnvironment,
noteSend: noteCloudSend
}
/** The text to type into this PTY: enveloped for a shared cloud workspace, unchanged otherwise. */
@@ -25,6 +27,9 @@ export function withPtyCloudSender(
deps: PtyCloudSenderDeps = DEFAULT_DEPS
): string {
const environmentId = deps.environmentOf(ptyId)
if (environmentId) {
deps.noteSend?.(environmentId)
}
const sender = environmentId ? deps.senderFor(environmentId) : null
return sender ? withSenderEnvelope(text, sender) : text
}
+31 -4
View File
@@ -10,6 +10,24 @@ import {
const REFRESH_MS = 60_000
let sharedEnvironments = new Set<string>()
/** Every cloud workspace environment this desktop uses (own or teammate's) → its sandbox id. */
let cloudSandboxByEnvironment = new Map<string, string>()
const SEND_REPORT_MIN_MS = 30_000
const lastSendReport = new Map<string, number>()
/**
* Tells the control plane "I just sent a message in this workspace" with my own Orca token: git as
* the sender works for me only for a while after this, so nothing in the sandbox can push as me
* when I am not driving it. Throttled; best effort.
*/
export function noteCloudSend(environmentId: string, now: number = Date.now()): void {
const sandboxId = cloudSandboxByEnvironment.get(environmentId)
if (!sandboxId || now - (lastSendReport.get(sandboxId) ?? 0) < SEND_REPORT_MIN_MS) {
return
}
lastSendReport.set(sandboxId, now)
void window.api.cloudWorkspaces?.noteSent(sandboxId).catch(() => undefined)
}
/** Me, as teammates' agents should see me; null when not signed into an org. */
function currentSender(): MessageSender | null {
@@ -29,6 +47,7 @@ export function withCloudSenderBody<T extends { blocks: readonly { type: string
environmentId: string,
body: T
): T {
noteCloudSend(environmentId)
const sender = cloudSenderForEnvironment(environmentId)
if (!sender) {
return body
@@ -61,17 +80,25 @@ export async function refreshCloudMessageSenders(): Promise<void> {
}
const team = new Set(list.value.filter((w) => w.visibility === 'team').map((w) => w.id))
const next = new Set<string>()
const byEnvironment = new Map<string, string>()
for (const l of local) {
if (team.has(l.sandboxId) && l.runtimeEnvironmentId) {
next.add(l.runtimeEnvironmentId)
if (l.runtimeEnvironmentId) {
byEnvironment.set(l.runtimeEnvironmentId, l.sandboxId)
if (team.has(l.sandboxId)) {
next.add(l.runtimeEnvironmentId)
}
}
}
for (const [sandboxId, binding] of Object.entries(bindings)) {
if (team.has(sandboxId) && binding.environmentId) {
next.add(binding.environmentId)
if (binding.environmentId) {
byEnvironment.set(binding.environmentId, sandboxId)
if (team.has(sandboxId)) {
next.add(binding.environmentId)
}
}
}
sharedEnvironments = next
cloudSandboxByEnvironment = byEnvironment
}
export function installCloudMessageSender(): () => void {
@@ -16,6 +16,7 @@ export function createWebCloudWorkspacesApi(): PreloadApi['cloudWorkspaces'] {
listGrants: () => Promise.resolve(NOT_CONFIGURED),
revokeGrant: () => Promise.resolve(NOT_CONFIGURED),
localWorkspaces: () => Promise.resolve([]),
noteSent: () => Promise.resolve(),
bindEnvironment: () => Promise.resolve(),
environmentBindings: () => Promise.resolve({}),
setActive: () => Promise.resolve(),