mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
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)
|
||||
);
|
||||
@@ -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 })
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
+7
-2
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user