openmausbot 0.1.76 → 0.1.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/assets/{index-C6blGSq5.js → index-DeK-z9ld.js} +1 -1
  2. package/dist/assets/index-XdHJ-9CF.css +1 -0
  3. package/dist/assets/index-lhqE1umn.js +305 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/container-mcp.js +5 -1
  6. package/dist-server/drivers/agents-proxy.js +52 -1
  7. package/dist-server/enterprise/server/index.js +387 -29
  8. package/dist-server/index.js +1377 -459
  9. package/dist-server/openmausbot.js +725 -522
  10. package/dist-server/pair-cli.js +725 -522
  11. package/dist-server/server/agent-tool-policy.js +1 -0
  12. package/dist-server/server/browser-live.js +3 -0
  13. package/dist-server/server/config.js +16 -0
  14. package/dist-server/server/drivers/acp/core.js +3 -1
  15. package/dist-server/server/drivers/agents-proxy.js +58 -2
  16. package/dist-server/server/drivers/boxagent.js +1 -1
  17. package/dist-server/server/drivers/claude.js +3 -2
  18. package/dist-server/server/drivers/codex.js +3 -1
  19. package/dist-server/server/drivers/pi.js +4 -0
  20. package/dist-server/server/enterprise.js +38 -0
  21. package/dist-server/server/environment.js +3 -0
  22. package/dist-server/server/fleet-agent.js +57 -13
  23. package/dist-server/server/fleet-cli.js +248 -31
  24. package/dist-server/server/fleet.js +88 -14
  25. package/dist-server/server/hosted-contract.js +16 -0
  26. package/dist-server/server/index.js +671 -100
  27. package/dist-server/server/message-file.js +4 -0
  28. package/dist-server/server/provider-key-check.js +18 -1
  29. package/dist-server/server/request-auth.js +14 -5
  30. package/dist-server/server/sessions.js +115 -8
  31. package/dist-server/server/shared-computer-control.js +24 -0
  32. package/dist-server/server/shared-computers.js +131 -0
  33. package/dist-server/server/steer-queue.js +2 -0
  34. package/dist-server/server/store.js +20 -7
  35. package/dist-server/server/team-computers.js +108 -0
  36. package/dist-server/server/tool-summary.js +53 -1
  37. package/dist-server/server/workspace-backup.js +1 -0
  38. package/dist-server/shared/approval-mode.js +4 -1
  39. package/dist-server/shared/team-computer.js +1 -0
  40. package/dist-server/vps-container-mcp.js +5 -1
  41. package/enterprise/README.md +21 -3
  42. package/enterprise/server/index.js +387 -29
  43. package/package.json +1 -1
  44. package/dist/assets/index-BtT047dk.css +0 -1
  45. package/dist/assets/index-fKXFpB_J.js +0 -305
@@ -6,6 +6,8 @@ import { existsSync, readFileSync, rmSync, unlinkSync } from "node:fs";
6
6
  import { createServer } from "node:http";
7
7
  import { extname, join } from "node:path";
8
8
  import { z } from "zod";
9
+ import { SharedComputers, sharedComputerOperation, sharedComputerRegistration } from "./shared-computers.js";
10
+ import { SharedComputerControl } from "./shared-computer-control.js";
9
11
  import { RoomHandoffs } from "./room-handoffs.js";
10
12
  import { botAvatarUrlFromStoredPath } from "../shared/bot-avatar.js";
11
13
  import { BOT_PROFILE_LIMITS } from "../shared/bot-profile.js";
@@ -26,6 +28,7 @@ import { fitsOnOneLine, parseBotProfilePatch } from "./bot-profile.js";
26
28
  import { groupTurnCwd } from "./room-cwd.js";
27
29
  import { RoomTurnDeadline, RoomTurnStallRegistry, roomTurnTimeoutMessage } from "./room-turn-timeout.js";
28
30
  import * as box from "./box.js";
31
+ import { TeamComputers, teamComputerAssignment, teamComputerCreate, teamComputerOwner } from "./team-computers.js";
29
32
  import { boxCreateRecoverySnapshot, retireDeletedBoxCreate } from "./box-create-idempotency.js";
30
33
  import { boxAccountResourceChangeError, cloudBackendChangeError, vpsAliasResourceChangeError, } from "./cloud-backend.js";
31
34
  import * as composio from "./composio.js";
@@ -33,7 +36,7 @@ import { chiefOfStaffSystemPrompt } from "./chief-of-staff.js";
33
36
  import { canAccessTeam, canReachPeer, peerAllowed, peerName, peerRosterSystemPrompt, peerStatus, peerStatusWords, reachablePeers, roomPeerRosterSystemPrompt, roomRosterLine } from "./peer-roster.js";
34
37
  import { openMausStatusSystemPrompt } from "./openmaus-status-capsule.js";
35
38
  import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerRuntimeStatus, localVmRecreatableOnDemand, perBotLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, } from "./container-computer.js";
36
- import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, maxConcurrentBotThreads, saveConfig, showToolCallsEnabled, skillAuthoringEnabled, builtInBrowserEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, } from "./config.js";
39
+ import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, maxConcurrentBotThreads, saveConfig, showToolCallsEnabled, skillAuthoringEnabled, sharedComputersEnabled, builtInBrowserEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, } from "./config.js";
37
40
  import { ComputerControl } from "./computer-control.js";
38
41
  import { MAX_REMOTE_COMMAND_LENGTH } from "./remote-computer.js";
39
42
  import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
@@ -43,6 +46,7 @@ import { checkProviderKey, PROVIDER_KEY_KINDS } from "./provider-key-check.js";
43
46
  import { assertWithinBudget, noteSpend, spendState } from "./spend.js";
44
47
  import { fleetAvailable, fleetRequest, fleetSocketPath } from "./fleet-client.js";
45
48
  import { entitled } from "./enterprise.js";
49
+ import { HOSTED_CONTRACT_HEADER, HOSTED_CONTRACT_METADATA, HOSTED_CONTRACT_VERSION } from "./hosted-contract.js";
46
50
  import { describeSpawnFailure, execCli } from "./procs.js";
47
51
  import { blockedTarget, buildNotification } from "./notify.js";
48
52
  import { isEffortLevel, newId, } from "./contracts.js";
@@ -129,15 +133,15 @@ import { resolveSurface } from "./surface.js";
129
133
  import { PendingTurnCancellations, ProviderTurnGenerationRegistry, RetiredTurnRegistry, guardTurnDispatch, isTurnAdmissionBlocked, isTurnEventQuarantined, } from "./turn-dispatch-guard.js";
130
134
  import { createGracefulShutdown } from "./graceful-shutdown.js";
131
135
  import { acquireDataDirLeaseForProcess } from "./data-dir-lease.js";
132
- import { describeEdition, editionStatus, loadEnterpriseLayer } from "./enterprise.js";
136
+ import { createWorkspaceAccess, describeEdition, editionStatus, hostedWorkspaceConfiguration, hostedWorkspaceConfigured, loadEnterpriseLayer } from "./enterprise.js";
133
137
  import { environmentDescriptor, loadEnvironmentId, serverVersion } from "./environment.js";
134
138
  import { WorkspaceBackupMaintenance } from "./workspace-backup-maintenance.js";
135
139
  import { createWorkspaceBackupRoutes, isWorkspaceBackupSessionControl } from "./workspace-backup-http.js";
136
140
  import { applyPendingWorkspaceRestore, readLastWorkspaceRestore } from "./workspace-backup.js";
137
141
  import { createCustomDomainVerifier, customDomainIpv4, normalizeCustomDomain } from "./custom-domain.js";
138
- import { createEmailSignIn, parseAllowList } from "./account-signin.js";
142
+ import { allowedScopes, createEmailSignIn, parseAllowList } from "./account-signin.js";
139
143
  import { ProviderAuthSessions } from "./provider-auth-sessions.js";
140
- import { clearSessionCookie, clientBotPatchViolation, clientGroupPatchViolation, labelFromUserAgent, requestOrigin, requestSource, resolveRequestAuth, parseCookies, serializeSessionCookie, sessionCookieName, } from "./request-auth.js";
144
+ import { clearSessionCookie, clientBotPatchViolation, clientGroupPatchViolation, isLoopbackHost, isProxied, labelFromUserAgent, requestOrigin, requestSource, resolveRequestAuth, parseCookies, serializeSessionCookie, sessionCookieName, } from "./request-auth.js";
141
145
  import { cookieMaxAgeSeconds, formatPairingCode, SessionRegistry } from "./sessions.js";
142
146
  import { describeBrand, loadBrand } from "./brand.js";
143
147
  import { deliverSseFrame } from "./sse-fanout.js";
@@ -192,8 +196,22 @@ const workspaceMaintenance = new WorkspaceBackupMaintenance();
192
196
  // Remote clients (server/request-auth.ts, server/sessions.ts): a stable identity
193
197
  // for this server, the paired sessions, and the cookie the served UI uses.
194
198
  const ENVIRONMENT_ID = loadEnvironmentId(DATA_DIR);
195
- const sessions = new SessionRegistry({ file: join(DATA_DIR, "sessions.json") });
199
+ function signInAllowList() {
200
+ const current = loadConfig().signIn;
201
+ return { admins: parseAllowList(current?.admins?.join(",")), members: parseAllowList(current?.members?.join(",")) };
202
+ }
203
+ const sessions = new SessionRegistry({
204
+ file: join(DATA_DIR, "sessions.json"),
205
+ emailScopesSnapshot: () => {
206
+ const membership = signInAllowList();
207
+ return (email) => allowedScopes(email, membership);
208
+ },
209
+ portalMembership: hostedWorkspaceConfiguration()?.portalMembership === true,
210
+ });
211
+ const sharedComputers = new SharedComputers(id => sessions.isLive(id));
196
212
  const SESSION_COOKIE = sessionCookieName(PORT, ENVIRONMENT_ID);
213
+ const HOSTED_WORKSPACE = hostedWorkspaceConfigured();
214
+ let workspaceAccess = null;
197
215
  const DESKTOP_MANAGED = process.env.OMB_DESKTOP_PARENT === "1";
198
216
  // Empty is deliberately a deny-all bootstrap state. Only Electron's private
199
217
  // utility-process port can replace it with the per-launch owner capability.
@@ -206,10 +224,7 @@ const customDomainVerifier = createCustomDomainVerifier({ environmentId: ENVIRON
206
224
  // "Sign in with your email" on /pair: the allow-list is read per call so a
207
225
  // Settings change or an env bootstrap applies without a restart.
208
226
  const emailSignIn = createEmailSignIn({
209
- allow: () => {
210
- const current = loadConfig().signIn;
211
- return { admins: parseAllowList(current?.admins?.join(",")), members: parseAllowList(current?.members?.join(",")) };
212
- },
227
+ allow: signInAllowList,
213
228
  });
214
229
  let customDomainRevision = 0;
215
230
  function savedCustomDomain() {
@@ -418,6 +433,12 @@ function authorizedInternalCapability(header) {
418
433
  return null;
419
434
  }
420
435
  function internalCapabilityIsActive(capability) {
436
+ if (capability.teamComputerId) {
437
+ const pinned = teamComputerTurns.get(capability.threadId);
438
+ if (pinned?.computerId !== capability.teamComputerId || pinned.owner.generation !== capability.generation ||
439
+ pinned.botId !== capability.botId)
440
+ return false;
441
+ }
421
442
  if (capability.localVmTarget) {
422
443
  const owner = localVmLeaseFor(capability.localVmTarget).current(localVmOwnerBusy);
423
444
  if (localVmThreadTargets.get(capability.threadId) !== capability.localVmTarget ||
@@ -454,7 +475,7 @@ const agentsProxyPath = SPAWNED_PROXIES.agents;
454
475
  const phoneProxyPath = SPAWNED_PROXIES.phone;
455
476
  // in the packaged app process.execPath is Electron — run the proxy as node
456
477
  const AGENTS_NODE_FLAG = { ELECTRON_RUN_AS_NODE: "1" };
457
- function agentsIntegration(botId, threadId, depth, skillAuthoring, generation, roomHandoffId, roomCoordination = false) {
478
+ function agentsIntegration(botId, threadId, depth, skillAuthoring, generation, roomHandoffId, roomCoordination = false, ownThreadCreation = false) {
458
479
  const token = mintInternalCapability({
459
480
  botId,
460
481
  threadId,
@@ -466,6 +487,7 @@ function agentsIntegration(botId, threadId, depth, skillAuthoring, generation, r
466
487
  openedThreads: 0,
467
488
  roomHandoffId,
468
489
  roomCoordination,
490
+ ownThreadCreation,
469
491
  });
470
492
  return {
471
493
  command: process.execPath,
@@ -478,7 +500,11 @@ function agentsIntegration(botId, threadId, depth, skillAuthoring, generation, r
478
500
  OMB_COMMS_TOKEN: token,
479
501
  OMB_TURN_DEPTH: String(depth),
480
502
  OMB_ROOM_TURN: roomCoordination ? "1" : "0",
503
+ OMB_OWN_THREAD_CREATION: ownThreadCreation ? "1" : "0",
481
504
  OMB_SKILL_AUTHORING_ENABLED: skillAuthoring ? "1" : "0",
505
+ // The shared-computer tools are advertised only while the workspace
506
+ // gate is on; the routes behind them refuse regardless.
507
+ OMB_SHARED_COMPUTERS_ENABLED: sharedComputersEnabled(cfg) ? "1" : "0",
482
508
  },
483
509
  };
484
510
  }
@@ -516,8 +542,10 @@ function settleDirectFollowup(generation) {
516
542
  const directTurnBots = new Map();
517
543
  let providerFleetReloading = false;
518
544
  const turnResources = new TurnResources();
545
+ const sharedComputerControl = new SharedComputerControl(turnResources, () => store.bots.some(bot => botComputerControlSnapshot(bot.id).held));
519
546
  const turnResourceOwners = new Map();
520
547
  const turnComputerResources = new Map();
548
+ const teamComputerTurns = new Map();
521
549
  const settlingResourceOwners = new Map();
522
550
  function claimTurnResource(owner, resource) {
523
551
  if (!turnResources.claim(resource, owner))
@@ -535,6 +563,11 @@ function releaseTurnResources(owner) {
535
563
  turnResourceOwners.delete(owner.threadId);
536
564
  if (turnComputerResources.get(owner.threadId)?.owner.generation === owner.generation)
537
565
  turnComputerResources.delete(owner.threadId);
566
+ const teamTurn = teamComputerTurns.get(owner.threadId);
567
+ if (teamTurn?.owner.generation === owner.generation) {
568
+ stopScreenPoller(teamTurn.botId, owner.threadId);
569
+ teamComputerTurns.delete(owner.threadId);
570
+ }
538
571
  }
539
572
  function bindTurnComputer(owner, resource, exclusive = false) {
540
573
  if (exclusive && !claimTurnResource(owner, resource)) {
@@ -790,16 +823,21 @@ function connectedAppsIntegration(botId, threadId, generation) {
790
823
  // they hold it, the bot's computer proxies refuse every action. The record
791
824
  // lives here; the proxies consult it over loopback with the boot token.
792
825
  const computerControlRevision = new Map();
793
- const computerControl = new ComputerControl((botId, snapshot) => {
794
- computerControlRevision.set(botId, (computerControlRevision.get(botId) ?? 0) + 1);
795
- // One-way, fail-closed mirror into the Electron process that owns the
796
- // native browser. Never send release: a loopback caller can influence the
797
- // server record, while only the trusted Browser panel may clear Electron's
798
- // local gate after its server-first release succeeds.
799
- if (snapshot.held && /^[A-Za-z0-9_-]{1,120}$/.test(botId)) {
800
- postDesktopPrivateMessage({ type: "openmausbot:browser-control", botId, held: true });
801
- }
802
- broadcast({ kind: "computer-control", botId, held: snapshot.held, helpReason: snapshot.helpReason });
826
+ const computerControl = new ComputerControl((key, snapshot) => {
827
+ const members = key.startsWith("computer_")
828
+ ? store.bots.filter(bot => inheritedTeamComputer(bot)?.id === key.slice("computer_".length)).map(bot => bot.id)
829
+ : [key];
830
+ for (const botId of members) {
831
+ computerControlRevision.set(botId, (computerControlRevision.get(botId) ?? 0) + 1);
832
+ // One-way, fail-closed mirror into the Electron process that owns the
833
+ // native browser. Never send release: a loopback caller can influence the
834
+ // server record, while only the trusted Browser panel may clear Electron's
835
+ // local gate after its server-first release succeeds.
836
+ if (snapshot.held && /^[A-Za-z0-9_-]{1,120}$/.test(botId)) {
837
+ postDesktopPrivateMessage({ type: "openmausbot:browser-control", botId, held: true });
838
+ }
839
+ broadcast({ kind: "computer-control", botId, held: snapshot.held, helpReason: snapshot.helpReason });
840
+ }
803
841
  });
804
842
  const controlLeaseIdSchema = z.string().min(16).max(120).regex(/^[A-Za-z0-9_-]+$/);
805
843
  const routineRequestSourceSchema = {
@@ -827,6 +865,7 @@ function controlIntegration(botId, threadId, generation, localVmTarget) {
827
865
  depth: 0,
828
866
  kind: "computer",
829
867
  ...(localVmTarget ? { localVmTarget } : {}),
868
+ ...(teamComputerTurns.get(threadId) ? { teamComputerId: teamComputerTurns.get(threadId).computerId } : {}),
830
869
  skillAuthoring: false,
831
870
  createdBots: 0,
832
871
  openedThreads: 0,
@@ -1064,6 +1103,7 @@ function checkedMemberIds(value) {
1064
1103
  }
1065
1104
  let bootSelection = { instanceId: "", model: "" };
1066
1105
  const store = new Store(() => bootSelection);
1106
+ const teamComputers = new TeamComputers(join(DATA_DIR, "team-computers.json"), ENVIRONMENT_ID);
1067
1107
  let followupsReady = false;
1068
1108
  const sendSequencer = new SendSequencer();
1069
1109
  bootSelection = await defaultSelection();
@@ -1105,7 +1145,7 @@ const wireBot = (bot) => {
1105
1145
  // An elevated selection is inert until the desktop confirms its exact
1106
1146
  // private reply. Every ordinary client sees the effective Ask state during
1107
1147
  // that two-phase window, never a grant that may still roll back.
1108
- const visible = approvalGrant
1148
+ const visible = approvalGrant && !approvalGrant.threadOnly
1109
1149
  ? { ...rest, approvalMode: "ask", autoApprove: false }
1110
1150
  : rest;
1111
1151
  return { ...visible, ...(activeCoordinationForThread(bot.threadId) && !visible.busy ? { busy: true, activity: "working" } : {}),
@@ -1115,7 +1155,7 @@ const wireBot = (bot) => {
1115
1155
  * can validate it before sending the confirmation that makes it effective. */
1116
1156
  const wireTrustedApprovalBot = (bot) => {
1117
1157
  const { resumeCursors: _resumeCursors, tasks, approvalGrant: _approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, ...rest } = bot;
1118
- return { ...rest, avatarUrl: rest.avatarUrl ?? null, ...(tasks ? { tasks: tasks.map(wireTask) } : {}) };
1158
+ return { ...rest, approvalMode: approvalModeFor(rest), avatarUrl: rest.avatarUrl ?? null, ...(tasks ? { tasks: tasks.map(wireTask) } : {}) };
1119
1159
  };
1120
1160
  /** A settings-based preview, not a receipt of a dispatched turn. No
1121
1161
  * provisioning or credentials are needed to inspect it. The selected engine
@@ -1132,11 +1172,13 @@ function previewSystemPrompt(bot) {
1132
1172
  .join(" ");
1133
1173
  const instance = registry.get(bot.modelSelection.instanceId);
1134
1174
  const caps = instance?.adapter.capabilities;
1135
- const computerPromptKind = bot.computer === "vm"
1175
+ const teamComputer = inheritedTeamComputer(bot);
1176
+ const previewComputer = teamComputer ? "cloud" : bot.computer;
1177
+ const computerPromptKind = previewComputer === "vm"
1136
1178
  ? caps?.computerMcp ? localVmMode(cfg) === "per-bot" ? "vm-private" : "vm-shared" : null
1137
- : bot.computer === "cloud"
1179
+ : previewComputer === "cloud"
1138
1180
  ? instance?.driverKind === "boxAgent" ? "box-agent" : caps?.computerMcp ? bot.cloudBackend === "vps" ? "vps" : "box" : null
1139
- : bot.computer === "local"
1181
+ : previewComputer === "local"
1140
1182
  ? caps?.localComputerMcp ? "local" : null
1141
1183
  : null;
1142
1184
  const peers = reachablePeers(store.bots, bot);
@@ -1154,7 +1196,7 @@ function previewSystemPrompt(bot) {
1154
1196
  // Spelling the browser rule out a second time here is what let Off keep a
1155
1197
  // browser in one place while the preview said it had none.
1156
1198
  const previewPlan = resolveSurface({
1157
- destination: bot.computer,
1199
+ destination: previewComputer,
1158
1200
  browserOn: caps?.browserMcp === true && builtInBrowserEnabled(cfg) && bot.browser !== false,
1159
1201
  });
1160
1202
  const privateWorkspace = instance && !["grok", "boxAgent"].includes(instance.driverKind);
@@ -1168,6 +1210,7 @@ function previewSystemPrompt(bot) {
1168
1210
  }),
1169
1211
  },
1170
1212
  { id: "computer", label: "Computer", text: computerPrompt(computerPromptKind) },
1213
+ { id: "team-computer", label: "Team computer", text: teamComputerPrompt(teamComputer) },
1171
1214
  { id: "plan", label: "Surface", text: previewPlan.note },
1172
1215
  { id: "composio", label: "Connected apps", text: caps?.composioMcp && bot.composio !== false && composio.configured(cfg) ? COMPOSIO_PROMPT : "" },
1173
1216
  { id: "mcp", label: "MCP servers", text: caps?.customMcp ? customMcpPrompt(Object.keys(customMcpServers(cfg, bot.mcpServers))) : "" },
@@ -1275,13 +1318,22 @@ function handleDesktopTrustedApprovalMessage(raw) {
1275
1318
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
1276
1319
  return false;
1277
1320
  const message = raw;
1321
+ const grantTarget = (bot) => bot.approvalGrant?.threadOnly
1322
+ ? store.projectBotForTask(bot.id, bot.approvalGrant.threadId) : bot;
1323
+ const grantBusy = (bot) => bot.approvalGrant?.threadOnly
1324
+ ? threadBusy(bot.id, bot.approvalGrant.threadId) : bot.busy;
1325
+ const grantSupported = (bot, mode) => supportsApprovalMode(registry.cliTarget(grantTarget(bot)?.modelSelection.instanceId ?? "")?.driverKind, mode);
1326
+ const clearGrant = (bot) => store.patchBot(bot.id, {
1327
+ ...(!bot.approvalGrant?.threadOnly ? { approvalMode: "ask", autoApprove: false } : {}),
1328
+ approvalGrant: undefined,
1329
+ });
1278
1330
  const threadCanReceiveGrant = (bot) => {
1279
1331
  const threadId = bot.approvalGrant?.threadId;
1280
1332
  if (!threadId)
1281
1333
  return true;
1282
1334
  const target = store.projectBotForTask(bot.id, threadId);
1283
- return Boolean(target && !threadBusy(bot.id, threadId) &&
1284
- registry.cliTarget(target.modelSelection.instanceId)?.driverKind === registry.cliTarget(bot.modelSelection.instanceId)?.driverKind);
1335
+ return Boolean(target && !threadBusy(bot.id, threadId) && (bot.approvalGrant?.threadOnly ||
1336
+ registry.cliTarget(target.modelSelection.instanceId)?.driverKind === registry.cliTarget(bot.modelSelection.instanceId)?.driverKind));
1285
1337
  };
1286
1338
  if (message.type === "approval-trusted-mode-commit") {
1287
1339
  const requestId = typeof message.requestId === "string" && /^[A-Za-z0-9_-]{1,80}$/.test(message.requestId)
@@ -1299,17 +1351,22 @@ function handleDesktopTrustedApprovalMessage(raw) {
1299
1351
  if (bot?.approvalGrant?.requestId === requestId &&
1300
1352
  bot.approvalGrant.mode === mode &&
1301
1353
  bot.approvalGrant.phase === "committed" &&
1302
- bot.approvalMode === mode &&
1303
- !bot.busy &&
1354
+ (bot.approvalGrant.threadOnly || bot.approvalMode === mode) &&
1355
+ !grantBusy(bot) &&
1304
1356
  threadCanReceiveGrant(bot) &&
1305
- supportsApprovalMode(registry.cliTarget(bot.modelSelection.instanceId)?.driverKind, mode)) {
1357
+ grantSupported(bot, mode)) {
1306
1358
  if (bot.approvalGrant.threadId) {
1307
1359
  store.patchTask(botId, bot.approvalGrant.threadId, { approvalMode: mode, autoApprove: false });
1308
1360
  }
1309
1361
  store.patchBot(botId, { approvalGrant: undefined });
1362
+ postDesktopPrivateMessage({ type: "approval-trusted-mode-commit-result", requestId, ok: true, bot: wireBot(store.bot(botId)) });
1310
1363
  }
1311
1364
  else if (bot?.approvalGrant?.requestId === requestId) {
1312
- store.patchBot(botId, { approvalMode: "ask", autoApprove: false, approvalGrant: undefined });
1365
+ clearGrant(bot);
1366
+ postDesktopPrivateMessage({ type: "approval-trusted-mode-commit-result", requestId, ok: false });
1367
+ }
1368
+ else {
1369
+ postDesktopPrivateMessage({ type: "approval-trusted-mode-commit-result", requestId, ok: false });
1313
1370
  }
1314
1371
  return true;
1315
1372
  }
@@ -1339,9 +1396,9 @@ function handleDesktopTrustedApprovalMessage(raw) {
1339
1396
  if (bot?.approvalGrant?.requestId === requestId &&
1340
1397
  bot.approvalGrant.mode === mode &&
1341
1398
  bot.approvalGrant.phase === "prepared" &&
1342
- bot.approvalMode === mode) {
1343
- if (!supportsApprovalMode(registry.cliTarget(bot.modelSelection.instanceId)?.driverKind, mode)) {
1344
- store.patchBot(botId, { approvalMode: "ask", autoApprove: false, approvalGrant: undefined });
1399
+ (bot.approvalGrant.threadOnly || bot.approvalMode === mode)) {
1400
+ if (!grantSupported(bot, mode)) {
1401
+ clearGrant(bot);
1345
1402
  confirm(false, "This provider does not support the selected approval level");
1346
1403
  return true;
1347
1404
  }
@@ -1354,7 +1411,7 @@ function handleDesktopTrustedApprovalMessage(raw) {
1354
1411
  // A matching journal whose other fields no longer agree is ambiguous.
1355
1412
  // Revoke only that request; never clear a newer grant for the same bot.
1356
1413
  if (bot?.approvalGrant?.requestId === requestId) {
1357
- store.patchBot(botId, { approvalMode: "ask", autoApprove: false, approvalGrant: undefined });
1414
+ clearGrant(bot);
1358
1415
  }
1359
1416
  confirm(false, "The approval confirmation no longer matches this bot");
1360
1417
  return true;
@@ -1385,10 +1442,10 @@ function handleDesktopTrustedApprovalMessage(raw) {
1385
1442
  if (bot?.approvalGrant?.requestId === requestId &&
1386
1443
  bot.approvalGrant.mode === mode &&
1387
1444
  bot.approvalGrant.phase === "confirmed" &&
1388
- bot.approvalMode === mode) {
1389
- if (bot.busy || !supportsApprovalMode(registry.cliTarget(bot.modelSelection.instanceId)?.driverKind, mode)) {
1390
- store.patchBot(botId, { approvalMode: "ask", autoApprove: false, approvalGrant: undefined });
1391
- activate(false, bot.busy
1445
+ (bot.approvalGrant.threadOnly || bot.approvalMode === mode)) {
1446
+ if (grantBusy(bot) || !grantSupported(bot, mode)) {
1447
+ clearGrant(bot);
1448
+ activate(false, grantBusy(bot)
1392
1449
  ? "Stop this bot's turn before changing its approval level"
1393
1450
  : "This provider does not support the selected approval level");
1394
1451
  return true;
@@ -1400,7 +1457,7 @@ function handleDesktopTrustedApprovalMessage(raw) {
1400
1457
  return true;
1401
1458
  }
1402
1459
  if (bot?.approvalGrant?.requestId === requestId) {
1403
- store.patchBot(botId, { approvalMode: "ask", autoApprove: false, approvalGrant: undefined });
1460
+ clearGrant(bot);
1404
1461
  }
1405
1462
  activate(false, "The approval activation no longer matches this bot");
1406
1463
  return true;
@@ -1431,10 +1488,10 @@ function handleDesktopTrustedApprovalMessage(raw) {
1431
1488
  if (bot?.approvalGrant?.requestId === requestId &&
1432
1489
  bot.approvalGrant.mode === mode &&
1433
1490
  bot.approvalGrant.phase === "activated" &&
1434
- bot.approvalMode === mode &&
1435
- !bot.busy &&
1491
+ (bot.approvalGrant.threadOnly || bot.approvalMode === mode) &&
1492
+ !grantBusy(bot) &&
1436
1493
  threadCanReceiveGrant(bot) &&
1437
- supportsApprovalMode(registry.cliTarget(bot.modelSelection.instanceId)?.driverKind, mode)) {
1494
+ grantSupported(bot, mode)) {
1438
1495
  // Durable but still inert. Electron must observe this exact ACK before
1439
1496
  // sending the one-way commit release that clears the journal.
1440
1497
  store.patchBot(botId, { approvalGrant: { ...bot.approvalGrant, requestId, mode, phase: "committed" } });
@@ -1442,7 +1499,7 @@ function handleDesktopTrustedApprovalMessage(raw) {
1442
1499
  return true;
1443
1500
  }
1444
1501
  if (bot?.approvalGrant?.requestId === requestId) {
1445
- store.patchBot(botId, { approvalMode: "ask", autoApprove: false, approvalGrant: undefined });
1502
+ clearGrant(bot);
1446
1503
  }
1447
1504
  finalize(false, bot?.busy
1448
1505
  ? "Stop this bot's turn before changing its approval level"
@@ -1482,6 +1539,41 @@ function handleDesktopTrustedApprovalMessage(raw) {
1482
1539
  }
1483
1540
  const currentMode = approvalModeFor(existing);
1484
1541
  const threadId = message.threadId;
1542
+ if (message.threadOnly !== undefined && typeof message.threadOnly !== "boolean") {
1543
+ respond({ ok: false, error: "Invalid thread approval scope" });
1544
+ return true;
1545
+ }
1546
+ if (message.threadOnly === true) {
1547
+ const target = typeof threadId === "string" ? store.projectBotForTask(botId, threadId) : null;
1548
+ if (!target || message.modelSelection !== undefined || message.updateBotDefault !== undefined) {
1549
+ respond({ ok: false, error: "Choose an existing thread for this approval change" });
1550
+ return true;
1551
+ }
1552
+ // An ambiguous scoped grant may be cleared, but never a different grant.
1553
+ const clearsOwnGrant = mode === "ask" && existing.approvalGrant?.threadOnly && existing.approvalGrant.threadId === threadId;
1554
+ if ((existing.approvalGrant && !clearsOwnGrant) || threadBusy(botId, target.threadId)) {
1555
+ respond({ ok: false, error: "Stop this thread and finish its pending approval change first" });
1556
+ return true;
1557
+ }
1558
+ if (!supportsApprovalMode(registry.cliTarget(target.modelSelection.instanceId)?.driverKind, mode)) {
1559
+ respond({ ok: false, error: "This thread's provider does not support that approval level" });
1560
+ return true;
1561
+ }
1562
+ if (mode === "auto" && target.computer === "local" && approvalModeFor(target) !== "auto" && message.acknowledgeLocalAuto !== true) {
1563
+ respond({ ok: false, error: "Auto mode on this computer requires confirming the warning" });
1564
+ return true;
1565
+ }
1566
+ if (mode === "full" || mode === "custom") {
1567
+ store.patchBot(botId, { approvalGrant: { requestId, mode, phase: "prepared", threadId: target.threadId, threadOnly: true } });
1568
+ }
1569
+ else {
1570
+ if (clearsOwnGrant)
1571
+ clearGrant(existing);
1572
+ store.patchTask(botId, target.threadId, { approvalMode: mode, autoApprove: mode === "auto", alwaysAllow: [] });
1573
+ }
1574
+ respond({ ok: true, bot: wireTrustedApprovalBot(store.bot(botId)) });
1575
+ return true;
1576
+ }
1485
1577
  if (message.modelSelection !== undefined) {
1486
1578
  const target = typeof threadId === "string" ? store.projectBotForTask(botId, threadId) : null;
1487
1579
  if (mode !== "ask" || !target || typeof message.updateBotDefault !== "boolean") {
@@ -2385,8 +2477,8 @@ function messageWindow(threadId, messageId, limit) {
2385
2477
  return { messages: all.slice(start, stop).map(slimMessage), hasMore: start > 0 };
2386
2478
  }
2387
2479
  const sseClients = new Set();
2388
- sessions.onSessionRevoked((sessionId) => {
2389
- providerAuthSessions.revokeOwner(sessionId);
2480
+ function closeSessionStreams(sessionId) {
2481
+ browserLive.closeForOwner(sessionId);
2390
2482
  for (const client of sseClients) {
2391
2483
  if (client.sessionId !== sessionId)
2392
2484
  continue;
@@ -2398,6 +2490,10 @@ sessions.onSessionRevoked((sessionId) => {
2398
2490
  /* already gone */
2399
2491
  }
2400
2492
  }
2493
+ }
2494
+ sessions.onSessionRevoked((sessionId) => {
2495
+ providerAuthSessions.revokeOwner(sessionId);
2496
+ closeSessionStreams(sessionId);
2401
2497
  });
2402
2498
  /** Every frame is numbered, and the last few hundred are kept, so a client
2403
2499
  * whose connection dropped can ask for what it missed instead of
@@ -2432,6 +2528,9 @@ function cursorSeq(raw) {
2432
2528
  return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
2433
2529
  }
2434
2530
  function broadcast(payload) {
2531
+ // Membership may also change through fleet/CLI config writes. Close stale
2532
+ // email streams before any further workspace data is delivered.
2533
+ sessions.revalidateEmailSessions();
2435
2534
  const seq = ++lastSeq;
2436
2535
  const kind = String(payload.kind ?? "");
2437
2536
  const frame = `id: ${STREAM_ID}:${seq}\ndata: ${JSON.stringify({ ...payload, seq })}\n\n`;
@@ -2836,20 +2935,116 @@ const LOCAL_VM_IDLE_MS = 8 * 60 * 60_000;
2836
2935
  * status it has rather than hanging on a container that will not come up. */
2837
2936
  const LOCAL_VM_DESKTOP_WAIT_MS = 90_000;
2838
2937
  const localVmIdles = new Map();
2938
+ function inheritedTeamComputer(bot) {
2939
+ return teamComputers.forBot(bot);
2940
+ }
2941
+ function teamComputerPrompt(computer) {
2942
+ return computer ? `Your team shares the Box computer ${JSON.stringify(computer.name)}. Its desktop files and desktop browser logins are shared with other Auto bots in your team; only one turn may drive it at a time. The separate built-in Browser is not this desktop and does not automatically share its logins.` : "";
2943
+ }
2944
+ function botComputerControlKey(bot) {
2945
+ const computer = inheritedTeamComputer(bot);
2946
+ return computer ? teamComputerOwner(computer.id) : bot.id;
2947
+ }
2948
+ function botComputerControlSnapshot(botId, pinnedComputerId) {
2949
+ const own = computerControl.snapshot(botId);
2950
+ const bot = store.bot(botId);
2951
+ const key = pinnedComputerId ? teamComputerOwner(pinnedComputerId) : bot ? botComputerControlKey(bot) : botId;
2952
+ const shared = computerControl.snapshot(key);
2953
+ return own.held ? own : shared.held || shared.helpReason ? shared : own;
2954
+ }
2955
+ function teamComputerInUse(computer) {
2956
+ computer = teamComputers.get(computer.id) ?? computer;
2957
+ return computerControl.snapshot(teamComputerOwner(computer.id)).held ||
2958
+ [...teamComputerTurns.values()].some(turn => turn.computerId === computer.id) ||
2959
+ store.bots.some(bot => computer.section !== null && sectionKey(bot.section) === computer.section && (botHasActiveTurn(bot.id) || Boolean(routines?.activeRunForBot(bot.id)) || computerControl.snapshot(bot.id).held));
2960
+ }
2961
+ function assertTeamControlCanBeTaken(computerId) {
2962
+ if ([...teamComputerTurns.values()].some(turn => turn.computerId === computerId && turn.remoteAgent)) {
2963
+ throw Object.assign(new Error("Stop the Computer engine's active turn before taking control of this shared desktop"), { status: 409 });
2964
+ }
2965
+ }
2966
+ function claimTeamComputerLifecycle(computer) {
2967
+ if (computerProviderConfigTransitions.has("box"))
2968
+ throw Object.assign(new Error(providerTransitionMessage("box")), { status: 409 });
2969
+ if (teamComputerInUse(computer))
2970
+ throw Object.assign(new Error("This team computer is in use; stop the team's work and release computer control first"), { status: 409 });
2971
+ return claimBotComputerLifecycle(teamComputerOwner(computer.id));
2972
+ }
2973
+ function assertTeamComputerChangeIdle(before, after) {
2974
+ const previous = inheritedTeamComputer(before);
2975
+ const next = inheritedTeamComputer(after);
2976
+ if (previous?.id === next?.id)
2977
+ return;
2978
+ if (botHasActiveTurn(before.id) || routines?.activeRunForBot(before.id) || botComputerControlSnapshot(before.id).held ||
2979
+ [previous, next].some(computer => computer && (teamComputerInUse(computer) || boxLifecycleBusyBots.has(teamComputerOwner(computer.id))))) {
2980
+ throw Object.assign(new Error("Stop the affected team's work and release computer control before changing its computer access"), { status: 409 });
2981
+ }
2982
+ }
2983
+ async function teamComputersPayload() {
2984
+ const entries = teamComputers.list();
2985
+ const inventory = await box.listManagedBoxes(cfg, managedBoxOwners());
2986
+ return {
2987
+ configured: inventory.configured,
2988
+ ...(inventory.problem ? { problem: inventory.problem } : {}),
2989
+ computers: entries.map(entry => {
2990
+ const machine = inventory.instances.find(instance => instance.ownerBotId === teamComputerOwner(entry.id));
2991
+ return {
2992
+ id: entry.id, name: entry.name, section: entry.section,
2993
+ held: computerControl.snapshot(teamComputerOwner(entry.id)).held,
2994
+ state: boxLifecycleBusyBots.has(teamComputerOwner(entry.id)) ? "working" : machine?.state ?? (inventory.available ? "missing" : "unavailable"),
2995
+ ...(entry.problem || inventory.problem ? { problem: entry.problem || inventory.problem } : {}),
2996
+ };
2997
+ }),
2998
+ };
2999
+ }
3000
+ /** The same named Box identity and whole-turn lease in chats and rooms.
3001
+ * Assignment authorizes waking, never replacing a missing paid machine. */
3002
+ async function attachTeamBox(computer, botId, owner, canMount, remoteAgent) {
3003
+ if (!canMount)
3004
+ throw new Error("This model engine cannot use the team's Box computer; choose an engine with computer tools or an explicit bot destination");
3005
+ if (!box.boxConfigured(cfg))
3006
+ throw new Error("The team's Box account is not configured; reconnect it in Settings");
3007
+ const ownerId = teamComputerOwner(computer.id);
3008
+ if (boxLifecycleBusyBots.has(ownerId))
3009
+ throw new Error("The team computer is being changed; wait for it to finish");
3010
+ if (computerControl.snapshot(ownerId).held)
3011
+ throw new Error("Release human control of the team computer before starting another turn");
3012
+ bindTurnComputer(owner, `computer:box-bot:${ownerId}`, true);
3013
+ teamComputerTurns.set(owner.threadId, { owner, computerId: computer.id, botId, remoteAgent });
3014
+ let machine = await box.findBox(cfg, ownerId);
3015
+ if (!machine)
3016
+ throw new Error("The team's Box computer is missing; explicitly create or retry it from the Team map");
3017
+ bindTurnComputer(owner, `computer:box:${machine.id}`, true);
3018
+ const action = box.boxTurnLifecycleAction({ explicitCloud: true, canMount: true, state: typeof machine.state === "string" ? machine.state : null });
3019
+ if (action === "wake")
3020
+ machine = await box.readyBox(cfg, ownerId);
3021
+ if (!machine || box.boxTurnLifecycleAction({ explicitCloud: true, canMount: true, state: typeof machine.state === "string" ? machine.state : null }) !== "attach") {
3022
+ throw new Error("The team computer is not ready; check it in the Team map");
3023
+ }
3024
+ if (turnResourceOwners.get(owner.threadId)?.generation !== owner.generation ||
3025
+ !turnResources.owns(`computer:box:${machine.id}`, owner))
3026
+ throw new Error("This computer turn ended while its machine was starting");
3027
+ return {
3028
+ integration: { kind: "box", boxId: machine.id, token: cfg.box.token, control: controlIntegration(botId, owner.threadId, owner.generation) },
3029
+ capture: () => box.screenshotBox(cfg, ownerId, machine.id),
3030
+ };
3031
+ }
2839
3032
  function managedBoxOwners() {
2840
- return store.bots.map((bot) => ({
2841
- botId: bot.id,
2842
- name: bot.name,
2843
- // A machine is not safe to mutate while any app-level work or human
2844
- // control lease still names its owner. This is deliberately conservative
2845
- // across destination changes: an old Box may still contain valuable state.
2846
- inUse: bot.busy === true ||
2847
- hasDirectDispatch(bot.id) ||
2848
- activeGroupTurnForBot(bot.id) !== null ||
2849
- Boolean(routines?.activeRunForBot(bot.id)) ||
2850
- activeVpsThreads.has(bot.id) ||
2851
- computerControl.snapshot(bot.id).held,
2852
- }));
3033
+ return [...store.bots.map((bot) => ({
3034
+ botId: bot.id,
3035
+ name: bot.name,
3036
+ // A machine is not safe to mutate while any app-level work or human
3037
+ // control lease still names its owner. This is deliberately conservative
3038
+ // across destination changes: an old Box may still contain valuable state.
3039
+ inUse: bot.busy === true ||
3040
+ hasDirectDispatch(bot.id) ||
3041
+ activeGroupTurnForBot(bot.id) !== null ||
3042
+ Boolean(routines?.activeRunForBot(bot.id)) ||
3043
+ activeVpsThreads.has(bot.id) ||
3044
+ computerControl.snapshot(bot.id).held,
3045
+ })), ...teamComputers.list().map(computer => ({
3046
+ botId: teamComputerOwner(computer.id), name: computer.name, inUse: teamComputerInUse(computer),
3047
+ }))];
2853
3048
  }
2854
3049
  function botHasActiveTurn(botId) {
2855
3050
  const bot = store.bot(botId);
@@ -2891,6 +3086,8 @@ function providerOperationConflict(provider) {
2891
3086
  function turnProvider(bot, runOn) {
2892
3087
  if (runOn === "cloud" || registry.get(bot.modelSelection.instanceId)?.driverKind === "boxAgent")
2893
3088
  return "box";
3089
+ if (inheritedTeamComputer(bot))
3090
+ return "box";
2894
3091
  if (bot.computer !== undefined && bot.computer !== "cloud")
2895
3092
  return null;
2896
3093
  return bot.cloudBackend === "vps" ? "vps" : "box";
@@ -2913,6 +3110,9 @@ function claimBoxInventoryRequest(boxId) {
2913
3110
  * a new turn and an irreversible lifecycle action cannot pass each other. */
2914
3111
  function claimManagedBoxMutation(instance) {
2915
3112
  const ownerBotId = instance.ownerBotId;
3113
+ const teamComputer = teamComputers.list().find(computer => teamComputerOwner(computer.id) === ownerBotId);
3114
+ if (teamComputer)
3115
+ return claimTeamComputerLifecycle(teamComputer);
2916
3116
  if (!ownerBotId) {
2917
3117
  if (orphanBoxLifecycleBusyIds.has(instance.boxId)) {
2918
3118
  throw Object.assign(new Error("this cloud computer is being changed — wait for it to finish"), { status: 409 });
@@ -3157,7 +3357,7 @@ bus.subscribe((event) => {
3157
3357
  const existing = store.messagesFor(event.threadId).find((m) => m.id === messageId)?.tool;
3158
3358
  toolName = existing?.name ?? "tool";
3159
3359
  store.patchMessage(event.threadId, messageId, {
3160
- tool: { name: toolName, ok: event.ok, spoken: existing?.spoken, summary: existing?.summary },
3360
+ tool: { ...existing, name: toolName, ok: event.ok, output: event.output },
3161
3361
  });
3162
3362
  toolMessageByItem.delete(itemKey);
3163
3363
  }
@@ -3189,7 +3389,7 @@ bus.subscribe((event) => {
3189
3389
  const message = pushMessage({
3190
3390
  role: "bot",
3191
3391
  kind: "activity",
3192
- tool: { name, spoken: narrateTool(name) ?? undefined, summary: event.summary },
3392
+ tool: { name, spoken: narrateTool(name) ?? undefined, summary: event.summary, input: event.input },
3193
3393
  });
3194
3394
  if (event.itemId)
3195
3395
  toolMessageByItem.set(`${event.threadId}:${event.itemId}`, message.id);
@@ -4054,9 +4254,9 @@ function drainQueuedSends() {
4054
4254
  return;
4055
4255
  drainSteeredMessages(store, (botId, threadId, prompt, userMessage, excludeIds, unattended) =>
4056
4256
  // A plain attended turn — no automationSource, no comms depth: exactly
4057
- // what typing the same words into an idle bot would run. The one
4058
- // exception is `unattended`: a thread a bot opened on itself while
4059
- // nobody was watching stays unwatched through the wait for a slot.
4257
+ // what typing the same words into an idle bot would run. Self-opened
4258
+ // work retains `unattended` and the message's bot-origin provenance
4259
+ // through the wait for a slot.
4060
4260
  // Drain just appended the held lines; userMessage keeps startTurn
4061
4261
  // from duplicating the last one, and excludeIds drops every drained
4062
4262
  // line from the transcript-replay so they are not also in `prompt`.
@@ -4101,18 +4301,19 @@ const MAX_THREADS_OPENED_PER_TURN = 5;
4101
4301
  /** A thread a bot opened on itself gets its first turn exactly the way a
4102
4302
  * person's message would: it runs now if the bot has a free slot, and
4103
4303
  * otherwise waits in the same composer queue, in line behind whatever the
4104
- * bot already has waiting. The only inheritance is `unattended` — a bot
4105
- * nobody is watching does not become watched by opening a thread. */
4304
+ * bot already has waiting. Its provenance survives the queue: this is the
4305
+ * bot's own request, not a new human request authorizing recursive fan-out. */
4106
4306
  async function startOrQueueOpenedThread(botId, threadId, text, unattended) {
4307
+ const peerAsk = { botId, name: store.bot(botId).name, unattended: unattended || undefined };
4107
4308
  // A room turn holds the bot too (startTurn refuses a direct turn during
4108
4309
  // one); the drain's own block check already waits for it, so the words
4109
4310
  // queue here rather than bounce.
4110
4311
  if (botAtThreadCapacity(botId) || activeGroupTurnForBot(botId)) {
4111
- queueSteeredMessage(botId, threadId, text, { reason: "capacity", unattended });
4312
+ queueSteeredMessage(botId, threadId, text, { reason: "capacity", unattended, peerAsk });
4112
4313
  return { state: "queued", position: queuedThreadPosition(botId, threadId) ?? 1 };
4113
4314
  }
4114
4315
  try {
4115
- await startTurn(botId, text, { threadId, unattended });
4316
+ await startTurn(botId, text, { threadId, unattended, peerAsk });
4116
4317
  return { state: "running" };
4117
4318
  }
4118
4319
  catch (error) {
@@ -4163,7 +4364,7 @@ function startScreenPoller(botId, threadId, captures, { screenIsTheWork = false
4163
4364
  browser: guarded(captures.browser, browserSession ? `browser:${browserSession}` : undefined),
4164
4365
  },
4165
4366
  control: () => ({
4166
- held: computerControl.snapshot(botId).held,
4367
+ held: botComputerControlSnapshot(botId, teamComputerTurns.get(threadId)?.computerId).held,
4167
4368
  revision: computerControlRevision.get(botId) ?? 0,
4168
4369
  }),
4169
4370
  onFrame: (frame) => broadcast({ kind: "screen", botId, threadId, ...frame }),
@@ -4388,7 +4589,7 @@ async function startTurn(botId, text, opts) {
4388
4589
  // A card continuation neither starts nor ends the person's ask: it
4389
4590
  // resumes the turn their last message began, so that record stands.
4390
4591
  if (!opts?.cardContinuation) {
4391
- if (commsDepth === 0 && opts?.automationSource === undefined && !opts?.unattended) {
4592
+ if (commsDepth === 0 && opts?.automationSource === undefined && !opts?.unattended && !userMessage.peerAsk) {
4392
4593
  personAskAt.set(threadId, userMessage.at);
4393
4594
  // Continuing an old run as a normal conversation makes that task
4394
4595
  // visible again; these new replies are not routine report updates.
@@ -4577,7 +4778,8 @@ async function startTurn(botId, text, opts) {
4577
4778
  integrations.dweb = { url: dwebUrl };
4578
4779
  // Cloud routines always use Box/BoxAgent. The per-bot backend applies
4579
4780
  // only to ordinary turns that mount a computer into the local agent.
4580
- const cloudBackend = opts?.runOn === "cloud" || bot.cloudBackend !== "vps" ? "box" : "vps";
4781
+ const teamComputer = inheritedTeamComputer(bot);
4782
+ const cloudBackend = teamComputer || opts?.runOn === "cloud" || bot.cloudBackend !== "vps" ? "box" : "vps";
4581
4783
  const mountsComputerMcp = instance.adapter.capabilities.computerMcp === true;
4582
4784
  const mountsCloudComputer = mountsComputerMcp || instance.driverKind === "boxAgent";
4583
4785
  const mountsLocalComputer = instance.adapter.capabilities.localComputerMcp === true;
@@ -4590,7 +4792,7 @@ async function startTurn(botId, text, opts) {
4590
4792
  bot.browser !== false &&
4591
4793
  instance.adapter.capabilities.browserMcp === true;
4592
4794
  const plan = resolveSurface({
4593
- destination: opts?.runOn === "cloud" ? "cloud" : bot.computer, // cloud routine overrides the MAUS default
4795
+ destination: teamComputer || opts?.runOn === "cloud" ? "cloud" : bot.computer, // cloud routine overrides the MAUS default
4594
4796
  browserOn,
4595
4797
  });
4596
4798
  if (bot.computer === "browser" && plan.computer === "off" && instance.driverKind === "boxAgent") {
@@ -4699,7 +4901,13 @@ async function startTurn(botId, text, opts) {
4699
4901
  }
4700
4902
  // Cloud is also strict when explicitly selected. Auto (unset) reuses an
4701
4903
  // existing cloud box, then falls back to host CUA without provisioning.
4702
- if ((wants === "cloud" || wants === undefined) && cloudBackend === "box" && box.boxConfigured(cfg)) {
4904
+ if (teamComputer) {
4905
+ const attached = await attachTeamBox(teamComputer, bot.id, resourceOwner, mountsCloudComputer, instance.driverKind === "boxAgent");
4906
+ integrations.computer = attached.integration;
4907
+ previewCapture = attached.capture;
4908
+ computerKind = "box";
4909
+ }
4910
+ if (!teamComputer && (wants === "cloud" || wants === undefined) && cloudBackend === "box" && box.boxConfigured(cfg)) {
4703
4911
  // Explicit cloud turns can provision/wake the same bot's Box. Claim
4704
4912
  // before any network await so setup itself cannot race another turn.
4705
4913
  if (wants === "cloud")
@@ -4795,7 +5003,14 @@ async function startTurn(botId, text, opts) {
4795
5003
  // that ask_bot and delegate_bot would then refuse.
4796
5004
  const sectionPeers = reachablePeers(store.bots, bot);
4797
5005
  if (agentsMounted) {
4798
- integrations.agents = agentsIntegration(bot.id, threadId, commsDepth, skillAuthoring, dispatchClaimId, opts?.coordination?.id, boundedCoordination);
5006
+ // Only a direct human request can create separate self-owned jobs.
5007
+ // Coordinated children and self-opened jobs stay inside their scope;
5008
+ // a later real user message on the same task is a fresh request.
5009
+ const origin = opts?.cardContinuation
5010
+ ? store.activePath(threadId).findLast(message => message.role === "user" && message.kind === "text")
5011
+ : userMessage;
5012
+ const ownThreadCreation = boundedCoordination && !opts?.coordination && Boolean(origin && !origin.peerAsk);
5013
+ integrations.agents = agentsIntegration(bot.id, threadId, commsDepth, skillAuthoring, dispatchClaimId, opts?.coordination?.id, boundedCoordination, ownThreadCreation);
4799
5014
  }
4800
5015
  // @mentions in the user's message (the composer's tagging UI) become
4801
5016
  // an explicit coordination nudge. The agent still chooses the matching
@@ -4891,6 +5106,7 @@ async function startTurn(botId, text, opts) {
4891
5106
  { id: "setup", label: "Setup", text: setupSystemPrompt(setupMode, { skills: skillAuthoring, cwd: liveBot?.cwd ?? bot.cwd }) },
4892
5107
  { id: "files", label: "File locations", text: worksInWorkspace && opts?.runOn !== "cloud" ? workspaceLocationsPrompt(bot.id, cwd, liveBot?.cwd ?? bot.cwd) : "" },
4893
5108
  { id: "computer", label: "Computer", text: computerPrompt(computerPromptKind) },
5109
+ { id: "team-computer", label: "Team computer", text: teamComputerPrompt(teamComputer) },
4894
5110
  { id: "plan", label: "Surface", text: plan.note },
4895
5111
  // gated on the integration, not the key: the hint only goes to a
4896
5112
  // bot whose driver actually mounted the tools
@@ -5733,6 +5949,15 @@ async function resolveAndSendTeamSetup(res, args, ownerReview) {
5733
5949
  json(res, 403, { error: "Approve team setup or deletion from the desktop app or a paired owner device. In a local browser, wait until every bot is idle." });
5734
5950
  return true;
5735
5951
  }
5952
+ if (args.behavior === "allow" && !card.answered && !card.dismissed) {
5953
+ // Confirmed Chief setup can move bots without the ordinary PATCH route.
5954
+ // Keep that atomic Store operation behind the same shared-machine fence.
5955
+ for (const operation of card.teamSetupRequest.operations) {
5956
+ const before = store.bot(operation.botId);
5957
+ if (before && operation.action === "update")
5958
+ assertTeamComputerChangeIdle(before, { ...before, ...operation.fields });
5959
+ }
5960
+ }
5736
5961
  const resumeGeneration = teamSetupResumeGenerations.get(args.threadId) ?? 0;
5737
5962
  const resolved = await teamSetupRequests.resolve(args);
5738
5963
  if (!resolved)
@@ -6242,8 +6467,9 @@ setupRetry = 0) {
6242
6467
  // "Works on" decides here exactly as it decides a 1:1 turn: the same
6243
6468
  // shared policy, so a room cannot become the loophole that hands a bot
6244
6469
  // set to Off the browser its own settings withhold everywhere else.
6470
+ const roomTeamComputer = inheritedTeamComputer(readyBot);
6245
6471
  const roomPlan = resolveSurface({
6246
- destination: readyBot.computer,
6472
+ destination: roomTeamComputer ? "cloud" : readyBot.computer,
6247
6473
  browserOn: builtInBrowserEnabled(cfg) &&
6248
6474
  readyBot.browser !== false &&
6249
6475
  instance.adapter.capabilities.browserMcp === true,
@@ -6263,6 +6489,14 @@ setupRetry = 0) {
6263
6489
  activeInternalGenerationByThread.get(threadId) !== internalGeneration) {
6264
6490
  return false;
6265
6491
  }
6492
+ if (roomTeamComputer) {
6493
+ const attached = await attachTeamBox(roomTeamComputer, readyBot.id, resourceOwner, instance.adapter.capabilities.computerMcp === true || instance.driverKind === "boxAgent", instance.driverKind === "boxAgent");
6494
+ if (isCancelled?.() || groupSpeakers.get(threadId) !== roomSpeaker ||
6495
+ activeInternalGenerationByThread.get(threadId) !== internalGeneration)
6496
+ return false;
6497
+ integrations.computer = attached.integration;
6498
+ startScreenPoller(readyBot.id, threadId, { computer: attached.capture }, { screenIsTheWork: instance.driverKind === "boxAgent" });
6499
+ }
6266
6500
  // Room and Goal turns use the speaker's desktop, never the coordinator's.
6267
6501
  // Claim the same lease as direct turns before asynchronous VM setup.
6268
6502
  if (readyBot.computer === "vm") {
@@ -6356,7 +6590,8 @@ setupRetry = 0) {
6356
6590
  const roomSystem = buildSystemPrompt(system, store.bot(bot.id)?.soul ?? bot.soul ?? "", [
6357
6591
  { id: "files", label: "File locations", text: workspace ? workspaceLocationsPrompt(bot.id, cwd, readyBot.cwd) : "" },
6358
6592
  { id: "mcp", label: "MCP servers", text: customMcpPrompt(Object.keys(integrations.custom ?? {})) },
6359
- { id: "computer", label: "Computer", text: computerPrompt(roomVmTarget ? localVmMode(cfg) === "per-bot" ? "vm-private" : "vm-shared" : null) },
6593
+ { id: "computer", label: "Computer", text: computerPrompt(roomTeamComputer ? instance.driverKind === "boxAgent" ? "box-agent" : "box" : roomVmTarget ? localVmMode(cfg) === "per-bot" ? "vm-private" : "vm-shared" : null) },
6594
+ { id: "team-computer", label: "Team computer", text: teamComputerPrompt(roomTeamComputer) },
6360
6595
  { id: "plan", label: "Surface", text: roomPlan.note },
6361
6596
  { id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
6362
6597
  { id: "recall", label: "Recall", text: integrations.agents ? SESSION_SEARCH_SYSTEM_PROMPT : "" },
@@ -8061,6 +8296,10 @@ function configStatus() {
8061
8296
  skillAuthoring: skillAuthoringEnabled(cfg),
8062
8297
  showToolCalls: showToolCallsEnabled(cfg),
8063
8298
  browser: builtInBrowserEnabled(cfg),
8299
+ // Maintainer-only escape hatch, not a Settings toggle: the desktop
8300
+ // shell and the Settings UI read it so they offer nothing this server
8301
+ // would refuse.
8302
+ sharedComputers: sharedComputersEnabled(cfg),
8064
8303
  },
8065
8304
  // first-run progress — not a secret; the app decides whether to show
8066
8305
  // the welcome tour from this, never from browser storage
@@ -8338,13 +8577,21 @@ const workspaceBackupRoutes = createWorkspaceBackupRoutes({
8338
8577
  return Boolean(current?.scopes.includes("admin") && current.kind === original.kind &&
8339
8578
  (current.kind !== "session" || (original.kind === "session" && current.session.id === original.session.id)));
8340
8579
  },
8341
- exclusive: (work, keepLocked) => workspaceMaintenance.run(work, {
8580
+ exclusive: (work, keepLocked) => workspaceMaintenance.run(async () => {
8581
+ // Recheck inside the exclusive gate: the restore body can arrive slowly
8582
+ // while another client assigns a computer after the initial route check.
8583
+ if (keepLocked && teamComputers.list().some(computer => computer.section !== null)) {
8584
+ throw Object.assign(new Error("Unassign team computers before restoring this workspace"), { status: 409 });
8585
+ }
8586
+ return work();
8587
+ }, {
8342
8588
  idle: () => !providerFleetReloading && !providerAuthSessions.active && !browserEngineInstall &&
8343
8589
  !routines?.isTicking && !calendarCalls?.isTicking &&
8344
8590
  !localVmImageBusy && !localVmProvisionBusy && !localVmModeChangeBusy &&
8345
8591
  !localVmLifecycleBusy.size && !boxLifecycleBusyBots.size && !vpsPreviewRequests.size && !orphanBoxLifecycleBusyIds.size &&
8346
8592
  !computerProviderConfigTransitions.size && !checkpointRestoreLeases.size &&
8347
- store.bots.every((bot) => !botHasActiveTurn(bot.id) && !routines?.activeRunForBot(bot.id) && !computerControl.snapshot(bot.id).held) &&
8593
+ teamComputers.list().every(computer => !teamComputerInUse(computer)) &&
8594
+ store.bots.every((bot) => !botHasActiveTurn(bot.id) && !routines?.activeRunForBot(bot.id) && !botComputerControlSnapshot(bot.id).held) &&
8348
8595
  store.groups.every((group) => !groupIsWorking(group)),
8349
8596
  pause: () => { routines?.stop(); calendarCalls?.stop(); watchdog.stop(); },
8350
8597
  resume: () => { routines?.start(); calendarCalls?.start(); watchdog.start(); },
@@ -8370,6 +8617,31 @@ const handleRequest = async (req, res) => {
8370
8617
  let m = null;
8371
8618
  let releaseWorkspaceRequest;
8372
8619
  try {
8620
+ // Unlike the legacy reachability probe, this attests the running
8621
+ // server's portal-membership capability, including live entitlement.
8622
+ if (method === "GET" && path === "/api/health/hosted") {
8623
+ res.setHeader("cache-control", "no-store");
8624
+ const hosted = hostedWorkspaceConfiguration();
8625
+ if (!hosted?.portalMembership || !workspaceAccess || !entitled("admin")) {
8626
+ return json(res, 503, { error: "Hosted workspace readiness is unavailable." });
8627
+ }
8628
+ res.setHeader(HOSTED_CONTRACT_HEADER, String(HOSTED_CONTRACT_VERSION));
8629
+ return json(res, 200, { ok: true, service: "openmausbot", membershipAuthority: "portal", workspace: hosted.workspace, ...HOSTED_CONTRACT_METADATA });
8630
+ }
8631
+ // Hosted workspaces have one sign-in authority. A missing optional layer
8632
+ // must not accidentally reactivate legacy email/QR credential minting.
8633
+ if (HOSTED_WORKSPACE) {
8634
+ if (method === "POST" && ["/api/auth/pair", "/api/auth/pairing", "/api/auth/email/start", "/api/auth/email/verify"].includes(path)) {
8635
+ return json(res, 403, { error: "Sign in through the workspace portal." });
8636
+ }
8637
+ if (workspaceAccess) {
8638
+ if (await workspaceAccess.handlePublic(req, res, url))
8639
+ return;
8640
+ }
8641
+ else if (path.startsWith("/api/auth/hosted/") || path === "/pair" || (path === "/" && (!isLoopbackHost(req.headers.host) || isProxied(req)))) {
8642
+ return json(res, 503, { error: "Workspace sign-in is unavailable." });
8643
+ }
8644
+ }
8373
8645
  // ── who is asking (server/request-auth.ts) ──────────────────────────
8374
8646
  // Two public routes come first: what this server is, and turning a pairing
8375
8647
  // code into a session. Everything else needs the loopback owner or a
@@ -8377,7 +8649,7 @@ const handleRequest = async (req, res) => {
8377
8649
  if (method === "GET" && !path.startsWith("/api/") && !path.startsWith("/.well-known/") && serveStatic(res, path))
8378
8650
  return;
8379
8651
  if (method === "GET" && path === "/.well-known/openmausbot/environment") {
8380
- return json(res, 200, environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: emailSignIn.enabled() }));
8652
+ return json(res, 200, environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: !HOSTED_WORKSPACE && emailSignIn.enabled(), sharedComputers: sharedComputersEnabled(cfg) }));
8381
8653
  }
8382
8654
  const domainCheck = /^\/\.well-known\/openmausbot\/domain-check\/([a-f0-9]{64})$/.exec(path);
8383
8655
  if (method === "GET" && domainCheck) {
@@ -8420,7 +8692,7 @@ const handleRequest = async (req, res) => {
8420
8692
  }
8421
8693
  sessions.clearFailures(source);
8422
8694
  const issued = sessions.issue({ label: label.trim() || labelFromUserAgent(req.headers["user-agent"]), scopes: verified.scopes, userId: verified.userId, email: verified.email });
8423
- const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: true });
8695
+ const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: true, sharedComputers: sharedComputersEnabled(cfg) });
8424
8696
  const secure = requestOrigin(req)?.startsWith("https://") === true;
8425
8697
  res.setHeader("set-cookie", serializeSessionCookie(SESSION_COOKIE, issued.token, { secure, maxAgeSeconds: cookieMaxAgeSeconds(issued.session) }));
8426
8698
  return json(res, 200, { session: issued.session, environment });
@@ -8442,7 +8714,7 @@ const handleRequest = async (req, res) => {
8442
8714
  console.warn(`pairing refused from ${requestSource(req)}: ${result.error}`);
8443
8715
  return json(res, result.status, { error: result.error });
8444
8716
  }
8445
- const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: emailSignIn.enabled() });
8717
+ const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: emailSignIn.enabled(), sharedComputers: sharedComputersEnabled(cfg) });
8446
8718
  if (wantsCookie) {
8447
8719
  const secure = requestOrigin(req)?.startsWith("https://") === true;
8448
8720
  res.setHeader("set-cookie", serializeSessionCookie(SESSION_COOKIE, result.token, { secure, maxAgeSeconds: cookieMaxAgeSeconds(result.session) }));
@@ -8457,6 +8729,7 @@ const handleRequest = async (req, res) => {
8457
8729
  url,
8458
8730
  loopbackMutationToken: desktopMutationToken,
8459
8731
  companionMutationToken,
8732
+ features: { sharedComputers: sharedComputersEnabled(cfg) },
8460
8733
  });
8461
8734
  // The browser's cookie carries the term it was set with, and the
8462
8735
  // session's term slides on use (sessions.ts `renew`), so re-issue the
@@ -8467,7 +8740,7 @@ const handleRequest = async (req, res) => {
8467
8740
  if (gate.auth?.kind === "session" && gate.auth.via === "cookie") {
8468
8741
  const presented = parseCookies(req.headers.cookie).get(SESSION_COOKIE);
8469
8742
  if (presented) {
8470
- const secure = requestOrigin(req)?.startsWith("https://") === true;
8743
+ const secure = HOSTED_WORKSPACE || requestOrigin(req)?.startsWith("https://") === true;
8471
8744
  res.setHeader("set-cookie", serializeSessionCookie(SESSION_COOKIE, presented, { secure, maxAgeSeconds: cookieMaxAgeSeconds(gate.auth.session) }));
8472
8745
  }
8473
8746
  }
@@ -8486,11 +8759,21 @@ const handleRequest = async (req, res) => {
8486
8759
  if (!gate.auth)
8487
8760
  return json(res, gate.status, { error: gate.error });
8488
8761
  const auth = gate.auth;
8762
+ if (HOSTED_WORKSPACE && auth.kind === "session") {
8763
+ const failure = workspaceAccess
8764
+ ? await workspaceAccess.authorize(req, auth)
8765
+ : { status: 503, error: "Workspace sign-in is unavailable." };
8766
+ if (failure)
8767
+ return json(res, failure.status, { error: failure.error });
8768
+ }
8769
+ if (method === "POST" && path === "/api/workspace-backup/restore" && teamComputers.list().some(computer => computer.section !== null)) {
8770
+ return json(res, 409, { error: "Unassign team computers before restoring a workspace; restored team names must not gain access to existing desktops" });
8771
+ }
8489
8772
  if (await workspaceBackupRoutes(req, res, path, auth))
8490
8773
  return;
8491
8774
  // Count ordinary requests until their asynchronous handler returns, not
8492
8775
  // merely until the browser disconnects. A cancelled upload can still write.
8493
- if (path.startsWith("/api/") && path !== "/api/events" && path !== "/api/health" && !isWorkspaceBackupSessionControl(method, path)) {
8776
+ if (path.startsWith("/api/") && path !== "/api/events" && path !== "/api/health" && !path.startsWith("/api/shared-computers/") && !isWorkspaceBackupSessionControl(method, path)) {
8494
8777
  releaseWorkspaceRequest = workspaceMaintenance.request();
8495
8778
  }
8496
8779
  // ── sessions: who am I, tickets, pairing and revocation ─────────────
@@ -8586,6 +8869,60 @@ const handleRequest = async (req, res) => {
8586
8869
  const cancelled = sessions.cancelPairing(m[1]);
8587
8870
  return json(res, cancelled ? 200 : 404, cancelled ? { ok: true } : { error: "no such pairing code" });
8588
8871
  }
8872
+ if (method === "POST" && path === "/api/desktop/shared-computer-control" && sharedComputersEnabled(cfg)) {
8873
+ if (auth.kind !== "loopback")
8874
+ return json(res, 403, { error: "Local desktop only" });
8875
+ const body = await readBody(req, 1024);
8876
+ if (!sharedComputersEnabled(cfg))
8877
+ return json(res, 404, { error: `no route: ${method} ${path}` });
8878
+ if (!z.string().uuid().safeParse(body?.id).success || !["acquire", "release"].includes(body?.action))
8879
+ return json(res, 400, { error: "Invalid computer lease" });
8880
+ if (body.action === "release")
8881
+ sharedComputerControl.release(body.id);
8882
+ else
8883
+ sharedComputerControl.acquire(body.id);
8884
+ return json(res, 200, { ok: true });
8885
+ }
8886
+ // A paired desktop registers only its own outbound connector. A second,
8887
+ // main-process-only secret binds poll/results to that exact desktop.
8888
+ // With features.sharedComputers off the whole family falls through to the
8889
+ // generic "no route" 404, so a probe cannot tell a disabled feature from
8890
+ // a build that never had one.
8891
+ if (method === "POST" && path.startsWith("/api/shared-computers/") && sharedComputersEnabled(cfg)) {
8892
+ if (auth.kind !== "session")
8893
+ return json(res, 403, { error: "Pair this desktop first" });
8894
+ if (!/^application\/json\b/i.test(String(req.headers["content-type"] ?? "")))
8895
+ return json(res, 415, { error: "JSON required" });
8896
+ const body = await readBody(req, 4_000_000);
8897
+ if (!sharedComputersEnabled(cfg))
8898
+ return json(res, 404, { error: `no route: ${method} ${path}` });
8899
+ if (!sessions.isLive(auth.session.id))
8900
+ return json(res, 401, { error: "Session ended" });
8901
+ const secret = String(req.headers["x-omb-computer-secret"] ?? "");
8902
+ if (path === "/api/shared-computers/connect") {
8903
+ const parsed = sharedComputerRegistration.safeParse(body);
8904
+ if (!parsed.success)
8905
+ return json(res, 400, { error: "Invalid computer registration" });
8906
+ const registration = parsed.data;
8907
+ if (registration.environmentId !== ENVIRONMENT_ID)
8908
+ return json(res, 409, { error: "Workspace identity changed. Pair again before sharing this computer." });
8909
+ sharedComputers.register(registration, auth.session.id, secret);
8910
+ return json(res, 200, { ok: true });
8911
+ }
8912
+ const route = /^\/api\/shared-computers\/([\w-]+)\/(poll|lease|result|disconnect)$/.exec(path);
8913
+ if (!route)
8914
+ return json(res, 404, { error: "not found" });
8915
+ const [, id, action] = route;
8916
+ if (action === "poll")
8917
+ return json(res, 200, { job: await sharedComputers.poll(id, auth.session.id, secret) });
8918
+ if (action === "lease")
8919
+ return json(res, 200, { active: sharedComputers.liveJob(id, auth.session.id, secret, String(body?.jobId)) });
8920
+ if (action === "result")
8921
+ sharedComputers.complete(id, auth.session.id, secret, String(body?.jobId), body?.result);
8922
+ if (action === "disconnect")
8923
+ sharedComputers.disconnect(id, auth.session.id, secret);
8924
+ return json(res, 200, { ok: true });
8925
+ }
8589
8926
  if (method === "GET" && path === "/api/auth/sessions") {
8590
8927
  return json(res, 200, { sessions: sessions.list(), current: auth.kind === "session" ? auth.session.id : null });
8591
8928
  }
@@ -8653,7 +8990,7 @@ const handleRequest = async (req, res) => {
8653
8990
  if (internalCapability.kind !== requiredCapabilityKind) {
8654
8991
  return json(res, 403, { error: "this internal capability cannot access that service" });
8655
8992
  }
8656
- if (internalCapability.roomCoordination && method === "POST" && ["/api/internal/ask-bot", "/api/internal/delegate-bot", "/api/internal/threads"].includes(path)) {
8993
+ if (internalCapability.roomCoordination && method === "POST" && ["/api/internal/ask-bot", "/api/internal/delegate-bot"].includes(path)) {
8657
8994
  return json(res, 409, { error: "Use coordinate_bots for teamwork; it queues actual teammates and resumes you automatically." });
8658
8995
  }
8659
8996
  // Query/body sender ids remain on the wire for proxy compatibility,
@@ -8741,6 +9078,18 @@ const handleRequest = async (req, res) => {
8741
9078
  requireActiveInternalCapability();
8742
9079
  return json(res, 200, { result });
8743
9080
  }
9081
+ // Off by default: both fall through to the same "unknown internal
9082
+ // endpoint" 404 a never-implemented route returns.
9083
+ if (method === "GET" && path === "/api/internal/shared-computers" && sharedComputersEnabled(cfg))
9084
+ return json(res, 200, { computers: sharedComputers.list() });
9085
+ if (method === "POST" && path === "/api/internal/shared-computers" && sharedComputersEnabled(cfg)) {
9086
+ const parsed = sharedComputerOperation.safeParse(await readInternalBody());
9087
+ if (!sharedComputersEnabled(cfg))
9088
+ return json(res, 404, { error: "unknown internal endpoint" });
9089
+ if (!parsed.success)
9090
+ return json(res, 400, { error: "Invalid shared computer operation" });
9091
+ return json(res, 200, { result: await sharedComputers.request(parsed.data, () => sharedComputersEnabled(cfg) && internalCapabilityIsActive(internalCapability)) });
9092
+ }
8744
9093
  if (method === "GET" && path === "/api/internal/agents") {
8745
9094
  const sender = internalSender;
8746
9095
  // title/description included so the caller can judge the team (who
@@ -8790,6 +9139,8 @@ const handleRequest = async (req, res) => {
8790
9139
  return "running";
8791
9140
  if (queuedThreadPosition(bot.id, task.threadId) !== null)
8792
9141
  return "queued";
9142
+ if (roomHandoffs.activeDirect(task.threadId))
9143
+ return "queued";
8793
9144
  if (task.closedBy)
8794
9145
  return "closed";
8795
9146
  return "idle";
@@ -8836,7 +9187,7 @@ const handleRequest = async (req, res) => {
8836
9187
  }
8837
9188
  if (threadId === fromThreadId)
8838
9189
  return json(res, 400, { error: "you cannot close the thread you are speaking in — finish your turn instead" });
8839
- if (threadBusy(owner.id, threadId)) {
9190
+ if (threadBusy(owner.id, threadId) || queuedThreadPosition(owner.id, threadId) !== null || roomHandoffs.activeDirect(threadId)) {
8840
9191
  return json(res, 409, { error: `#${task.title} is still running — wait for it to finish (list_threads), or the person can stop it from the app` });
8841
9192
  }
8842
9193
  // Closing twice is not an error and leaves no second chip: the
@@ -9540,7 +9891,7 @@ const handleRequest = async (req, res) => {
9540
9891
  if (existing)
9541
9892
  target.threadId = existing.threadId;
9542
9893
  else {
9543
- const task = store.createTask(target.botId, parsed.data.message, false);
9894
+ const task = store.createTask(target.botId, parsed.data.message, false, undefined, { botId: internalSender.id, name: internalSender.name, at: Date.now() });
9544
9895
  if (!task)
9545
9896
  throw new Error("The recipient no longer exists");
9546
9897
  target.threadId = createdThread = task.threadId;
@@ -9741,6 +10092,14 @@ const handleRequest = async (req, res) => {
9741
10092
  const target = store.bot(toBotId);
9742
10093
  if (!target)
9743
10094
  return json(res, 404, { error: "no such bot" });
10095
+ if (internalCapability.roomCoordination) {
10096
+ if (target.id !== from.id) {
10097
+ return json(res, 409, { error: "Use coordinate_bots for teamwork; it queues actual teammates and resumes you automatically." });
10098
+ }
10099
+ if (!internalCapability.ownThreadCreation) {
10100
+ return json(res, 409, { error: "Only a direct user request can open separate self-owned threads. Finish this assigned work here; use coordinate_bots for teammates." });
10101
+ }
10102
+ }
9744
10103
  // A folder is the target's own organisation; a name is what the
9745
10104
  // model has, an id is what the sidebar has, so accept either.
9746
10105
  const folder = typeof body.folder === "string" ? body.folder.trim() : "";
@@ -10044,7 +10403,7 @@ const handleRequest = async (req, res) => {
10044
10403
  if (!bot)
10045
10404
  return json(res, 404, { error: "no such bot" });
10046
10405
  if (method === "GET") {
10047
- const snapshot = computerControl.snapshot(botId);
10406
+ const snapshot = botComputerControlSnapshot(botId, internalCapability.teamComputerId);
10048
10407
  const computer = turnComputerResources.get(internalCapability.threadId);
10049
10408
  if (!snapshot.held && computer && computer.owner.generation === internalCapability.generation &&
10050
10409
  !claimTurnResource(computer.owner, computer.resource)) {
@@ -10057,7 +10416,8 @@ const handleRequest = async (req, res) => {
10057
10416
  }
10058
10417
  if (method === "POST") {
10059
10418
  const body = await readInternalBody();
10060
- const { snapshot, requestId } = computerControl.requestHelpLease(botId, body.reason);
10419
+ const controlKey = internalCapability.teamComputerId ? teamComputerOwner(internalCapability.teamComputerId) : botId;
10420
+ const { snapshot, requestId } = computerControl.requestHelpLease(controlKey, body.reason);
10061
10421
  // worth a buzz: the bot is blocked on the person's hands, which
10062
10422
  // is exactly the "blocked on you" rule notify.ts encodes.
10063
10423
  // A bot stuck mid-room is not in its 1:1 thread — the turn and the
@@ -10072,7 +10432,7 @@ const handleRequest = async (req, res) => {
10072
10432
  }
10073
10433
  if (method === "DELETE") {
10074
10434
  const body = await readInternalBody();
10075
- const snapshot = computerControl.expireHelp(botId, body.requestId);
10435
+ const snapshot = computerControl.expireHelp(internalCapability.teamComputerId ? teamComputerOwner(internalCapability.teamComputerId) : botId, body.requestId);
10076
10436
  return json(res, 200, { held: snapshot.held, helpOpen: snapshot.helpReason !== null });
10077
10437
  }
10078
10438
  return json(res, 405, { error: "method not allowed" });
@@ -10312,6 +10672,16 @@ const handleRequest = async (req, res) => {
10312
10672
  const browser = await browserIntegration(bot.id, bot.browserProfile);
10313
10673
  if (!browser)
10314
10674
  return json(res, 503, { error: "Install the browser engine first." });
10675
+ // Browser discovery may have awaited while the portal became unavailable
10676
+ // and closed this owner's streams. Do not open a late replacement on
10677
+ // the earlier authorization; once registered, exact-owner close covers it.
10678
+ if (HOSTED_WORKSPACE && auth.kind === "session") {
10679
+ const failure = workspaceAccess
10680
+ ? await workspaceAccess.authorize(req, auth)
10681
+ : { status: 503, error: "Workspace sign-in is unavailable." };
10682
+ if (failure)
10683
+ return json(res, failure.status, { error: failure.error });
10684
+ }
10315
10685
  const owner = auth.kind === "session" ? auth.session.id : "local-owner";
10316
10686
  const isCurrent = () => {
10317
10687
  const current = store.bot(bot.id);
@@ -10439,7 +10809,7 @@ const handleRequest = async (req, res) => {
10439
10809
  sections: store.sections,
10440
10810
  groups: store.groups.map((g) => ({ ...publicGroupState(g), ...messagePage(g.threadId, limit) })),
10441
10811
  computerControl: Object.fromEntries(store.bots.map((bot) => {
10442
- const snapshot = computerControl.snapshot(bot.id);
10812
+ const snapshot = botComputerControlSnapshot(bot.id);
10443
10813
  return [bot.id, { held: snapshot.held, helpReason: snapshot.helpReason }];
10444
10814
  })),
10445
10815
  });
@@ -11531,6 +11901,8 @@ const handleRequest = async (req, res) => {
11531
11901
  const section = url.searchParams.get("section")?.trim();
11532
11902
  if (!section)
11533
11903
  return json(res, 400, { error: "Choose a named team" });
11904
+ if (teamComputers.forSection(section))
11905
+ return json(res, 409, { error: "Unassign this team's computer before renaming or deleting the team" });
11534
11906
  let nextName = null;
11535
11907
  if (method === "PATCH") {
11536
11908
  const parsed = z.object({ name: z.string().trim().min(1).max(60) }).strict().safeParse(await readBody(req));
@@ -11555,6 +11927,11 @@ const handleRequest = async (req, res) => {
11555
11927
  const botIds = [...new Set(parsed.data.botIds)];
11556
11928
  if (!name && !botIds.length)
11557
11929
  return json(res, 400, { error: "Team name is required" });
11930
+ for (const botId of botIds) {
11931
+ const bot = store.bot(botId);
11932
+ if (bot)
11933
+ assertTeamComputerChangeIdle(bot, { ...bot, section: name || undefined });
11934
+ }
11558
11935
  const result = store.setBotsSection(botIds, name);
11559
11936
  if (!result.ok) {
11560
11937
  if (result.reason === "chief-conflict") {
@@ -12206,6 +12583,8 @@ const handleRequest = async (req, res) => {
12206
12583
  return json(res, 409, { error: "Stop the bot and release browser control before changing its profile." });
12207
12584
  }
12208
12585
  if (profile.patch.soul !== undefined) {
12586
+ if (freshBrowserBot)
12587
+ assertTeamComputerChangeIdle(freshBrowserBot, { ...freshBrowserBot, ...patch });
12209
12588
  // A mixed settings request must not turn a runtime revocation into
12210
12589
  // a persist-first edit. Apply runtime fields with their existing
12211
12590
  // fail-closed semantics; atomically commit only the profile fields.
@@ -12217,6 +12596,8 @@ const handleRequest = async (req, res) => {
12217
12596
  bot = store.patchBotProfile(m[1], profile.patch);
12218
12597
  }
12219
12598
  else {
12599
+ if (freshBrowserBot)
12600
+ assertTeamComputerChangeIdle(freshBrowserBot, { ...freshBrowserBot, ...patch });
12220
12601
  bot = store.patchBot(m[1], patch);
12221
12602
  }
12222
12603
  if (!bot)
@@ -13349,6 +13730,135 @@ const handleRequest = async (req, res) => {
13349
13730
  broadcast({ kind: "bot", bot: fresh });
13350
13731
  return json(res, 200, { bot: fresh });
13351
13732
  }
13733
+ // Named team Boxes use real independent ownership, never a hidden bot or
13734
+ // an arbitrary provider id. These new routes remain admin-only by default.
13735
+ if (path === "/api/team-computers" && method === "GET") {
13736
+ res.setHeader("cache-control", "private, no-store");
13737
+ return json(res, 200, await teamComputersPayload());
13738
+ }
13739
+ m = path.match(/^\/api\/team-computers(?:\/([\w-]+)(?:\/(provision|join|sleep|control))?)?$/);
13740
+ if (m) {
13741
+ const computerId = m[1];
13742
+ const action = m[2];
13743
+ let found = computerId ? teamComputers.get(computerId) : undefined;
13744
+ if (computerId && !found)
13745
+ return json(res, 404, { error: "No such team computer" });
13746
+ if (method === "GET" && action === "control" && found) {
13747
+ return json(res, 200, computerControl.snapshot(teamComputerOwner(found.id)));
13748
+ }
13749
+ if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json"))
13750
+ return json(res, 415, { error: "content-type must be application/json" });
13751
+ const body = await readBody(req);
13752
+ found = computerId ? teamComputers.get(computerId) : undefined;
13753
+ const assertCurrentOwner = () => {
13754
+ if (auth.kind === "session" && !sessions.isLive(auth.session.id))
13755
+ throw Object.assign(new Error("Your session ended; sign in again"), { status: 401 });
13756
+ if (computerProviderConfigTransitions.has("box"))
13757
+ throw Object.assign(new Error(providerTransitionMessage("box")), { status: 409 });
13758
+ };
13759
+ assertCurrentOwner();
13760
+ if (action === "control" && method === "POST" && found) {
13761
+ const parsed = z.object({ action: z.enum(["take", "release"]), controlLeaseId: controlLeaseIdSchema.optional() }).strict().safeParse(body);
13762
+ if (!parsed.success)
13763
+ return json(res, 400, { error: "Choose take or release with a valid optional controlLeaseId" });
13764
+ const key = teamComputerOwner(found.id);
13765
+ if (parsed.data.action === "take" && boxLifecycleBusyBots.has(key))
13766
+ return json(res, 409, { error: "Wait for this computer's action to finish before taking control" });
13767
+ if (parsed.data.action === "take")
13768
+ assertTeamControlCanBeTaken(found.id);
13769
+ if (parsed.data.controlLeaseId) {
13770
+ const result = parsed.data.action === "take"
13771
+ ? computerControl.acquireLease(key, parsed.data.controlLeaseId)
13772
+ : computerControl.releaseLease(key, parsed.data.controlLeaseId);
13773
+ return json(res, 200, { ...result.snapshot, ...result });
13774
+ }
13775
+ return json(res, 200, parsed.data.action === "take" ? computerControl.take(key) : computerControl.release(key));
13776
+ }
13777
+ if (method === "PATCH" && found && !action) {
13778
+ const parsed = teamComputerAssignment.safeParse(body);
13779
+ if (!parsed.success)
13780
+ return json(res, 400, { error: "Confirm shared desktop access with section and acknowledgeSharedAccess: true" });
13781
+ const section = parsed.data.section;
13782
+ const checkAssignment = () => {
13783
+ assertCurrentOwner();
13784
+ if (section !== null && section !== "" && !store.sections.includes(section))
13785
+ throw Object.assign(new Error("Create the team before assigning a computer"), { status: 404 });
13786
+ if (section !== null && store.bots.some(bot => sectionKey(bot.section) === section && (botHasActiveTurn(bot.id) || routines?.activeRunForBot(bot.id) || botComputerControlSnapshot(bot.id).held || boxLifecycleBusyBots.has(bot.id))))
13787
+ throw Object.assign(new Error("Stop the target team's work and release computer control before assigning this computer"), { status: 409 });
13788
+ };
13789
+ checkAssignment();
13790
+ const release = claimTeamComputerLifecycle(found);
13791
+ try {
13792
+ if (section !== null && !(await box.findBox(cfg, teamComputerOwner(found.id))))
13793
+ return json(res, 409, { error: "Create or retry this computer before assigning it to a team" });
13794
+ checkAssignment();
13795
+ if (teamComputerInUse(found))
13796
+ return json(res, 409, { error: "This team computer became busy; stop its work before assigning it" });
13797
+ teamComputers.assign(found.id, section);
13798
+ return json(res, 200, { ok: true });
13799
+ }
13800
+ finally {
13801
+ release();
13802
+ }
13803
+ }
13804
+ if (method === "POST" && !computerId) {
13805
+ const parsed = teamComputerCreate.safeParse(body);
13806
+ if (!parsed.success)
13807
+ return json(res, 400, { error: "Provide requestId (UUID), a name, and acknowledgeCost: true to create a paid Box" });
13808
+ if (!box.boxConfigured(cfg))
13809
+ return json(res, 409, { error: "Configure Box in Settings before creating a cloud computer" });
13810
+ const computer = teamComputers.create(parsed.data.name, parsed.data.requestId);
13811
+ const release = claimTeamComputerLifecycle(computer);
13812
+ try {
13813
+ await box.provisionBox(cfg, teamComputerOwner(computer.id), computer.name);
13814
+ teamComputers.setProblem(computer.id);
13815
+ return json(res, 201, { id: computer.id });
13816
+ }
13817
+ catch (error) {
13818
+ teamComputers.setProblem(computer.id, redactSecretsInText(error instanceof Error ? error.message : String(error)));
13819
+ throw error;
13820
+ }
13821
+ finally {
13822
+ release();
13823
+ }
13824
+ }
13825
+ if (method === "POST" && found && action && action !== "control") {
13826
+ if (action === "provision" && body?.acknowledgeCost !== true)
13827
+ return json(res, 400, { error: "Confirm Box creation or wake costs with acknowledgeCost: true" });
13828
+ // A ready-only join never wakes, provisions or steals an agent's turn.
13829
+ // The caller takes a separate explicit human-control lease first.
13830
+ if (action === "join") {
13831
+ const key = teamComputerOwner(found.id);
13832
+ if (boxLifecycleBusyBots.has(key))
13833
+ return json(res, 409, { error: "Wait for this computer's action to finish" });
13834
+ if (!computerControl.snapshot(key).held)
13835
+ return json(res, 409, { error: "Take control before opening this shared desktop" });
13836
+ const release = claimBotComputerLifecycle(key);
13837
+ try {
13838
+ return json(res, 200, await box.joinReadyBox(cfg, key));
13839
+ }
13840
+ finally {
13841
+ release();
13842
+ }
13843
+ }
13844
+ const release = claimTeamComputerLifecycle(found);
13845
+ try {
13846
+ const result = action === "provision"
13847
+ ? await box.provisionBox(cfg, teamComputerOwner(found.id), found.name)
13848
+ : await box.sleepBox(cfg, teamComputerOwner(found.id));
13849
+ teamComputers.setProblem(found.id);
13850
+ return json(res, 200, result);
13851
+ }
13852
+ catch (error) {
13853
+ teamComputers.setProblem(found.id, redactSecretsInText(error instanceof Error ? error.message : String(error)));
13854
+ throw error;
13855
+ }
13856
+ finally {
13857
+ release();
13858
+ }
13859
+ }
13860
+ return json(res, 405, { error: "method not allowed" });
13861
+ }
13352
13862
  // Account-wide Box inventory is a Settings surface, never a provisioning
13353
13863
  // path. Listing remains read-only; lifecycle changes require explicit
13354
13864
  // JSON actions and are revalidated against a fresh provider listing.
@@ -13680,8 +14190,10 @@ const handleRequest = async (req, res) => {
13680
14190
  }
13681
14191
  const kind = provider;
13682
14192
  const saved = kind === "anthropic" ? cfg.anthropic : kind === "openaiCompat" ? cfg.openaiCompat : cfg.xai;
13683
- const pasted = typeof body?.key === "string" ? body.key.trim() : "";
13684
- const key = pasted || saved?.key || "";
14193
+ if (body?.key !== undefined && typeof body.key !== "string") {
14194
+ return json(res, 400, { error: "key must be a string" });
14195
+ }
14196
+ const key = typeof body?.key === "string" ? body.key.trim() : saved?.key?.trim() || "";
13685
14197
  if (!key)
13686
14198
  return json(res, 400, { error: "No key to test. Paste one or save one first." });
13687
14199
  if (key.length > 512)
@@ -14414,6 +14926,12 @@ const handleRequest = async (req, res) => {
14414
14926
  throw error;
14415
14927
  }
14416
14928
  let browserReferenceCleanupError = null;
14929
+ if (patch.signIn !== undefined)
14930
+ sessions.revalidateEmailSessions();
14931
+ if (!sharedComputersEnabled(cfg)) {
14932
+ sharedComputers.close();
14933
+ sharedComputerControl.close();
14934
+ }
14417
14935
  if (disablingBuiltInBrowser)
14418
14936
  browserLive.closeAll();
14419
14937
  for (const request of browserCleanupRequests) {
@@ -14743,6 +15261,9 @@ const handleRequest = async (req, res) => {
14743
15261
  const bot = store.bot(m[1]);
14744
15262
  if (!bot)
14745
15263
  return json(res, 404, { error: "no such bot" });
15264
+ const teamComputer = inheritedTeamComputer(bot);
15265
+ if (teamComputer)
15266
+ return json(res, 200, { backend: "box", teamComputer: { id: teamComputer.id, name: teamComputer.name }, ...(await box.boxStatus(cfg, teamComputerOwner(teamComputer.id))) });
14746
15267
  return bot.cloudBackend === "vps"
14747
15268
  ? json(res, 200, { backend: "vps", ...(await vps.vpsComputerStatus(cfg, bot.id)) })
14748
15269
  : json(res, 200, { backend: "box", ...(await box.boxStatus(cfg, bot.id)) });
@@ -14756,7 +15277,7 @@ const handleRequest = async (req, res) => {
14756
15277
  if (!bot)
14757
15278
  return json(res, 404, { error: "no such bot" });
14758
15279
  if (method === "GET")
14759
- return json(res, 200, computerControl.snapshot(bot.id));
15280
+ return json(res, 200, botComputerControlSnapshot(bot.id));
14760
15281
  if (method === "POST") {
14761
15282
  // JSON-only for the same anti-form-POST reason as every other
14762
15283
  // computer mutation below.
@@ -14765,6 +15286,13 @@ const handleRequest = async (req, res) => {
14765
15286
  }
14766
15287
  const body = await readBody(req);
14767
15288
  const action = String(body.action ?? "");
15289
+ const currentBot = store.bot(bot.id);
15290
+ if (!currentBot)
15291
+ return json(res, 404, { error: "no such bot" });
15292
+ const controlKey = botComputerControlKey(currentBot);
15293
+ const teamComputer = inheritedTeamComputer(currentBot);
15294
+ if (action === "take" && teamComputer)
15295
+ assertTeamControlCanBeTaken(teamComputer.id);
14768
15296
  const leaseResult = body.controlLeaseId === undefined
14769
15297
  ? null
14770
15298
  : controlLeaseIdSchema.safeParse(body.controlLeaseId);
@@ -14772,11 +15300,11 @@ const handleRequest = async (req, res) => {
14772
15300
  return json(res, 400, { error: "controlLeaseId is invalid" });
14773
15301
  }
14774
15302
  const controlLeaseId = leaseResult?.data;
14775
- if (action === "take" && boxLifecycleBusyBots.has(bot.id)) {
15303
+ if (action === "take" && (boxLifecycleBusyBots.has(bot.id) || boxLifecycleBusyBots.has(controlKey))) {
14776
15304
  return json(res, 409, { error: "this bot's cloud computer is being changed — wait before taking control" });
14777
15305
  }
14778
15306
  if (action === "take" && controlLeaseId) {
14779
- const result = computerControl.acquireLease(bot.id, controlLeaseId);
15307
+ const result = computerControl.acquireLease(controlKey, controlLeaseId);
14780
15308
  return json(res, 200, {
14781
15309
  ...result.snapshot,
14782
15310
  owned: result.owned,
@@ -14784,15 +15312,15 @@ const handleRequest = async (req, res) => {
14784
15312
  });
14785
15313
  }
14786
15314
  if (action === "release" && controlLeaseId) {
14787
- const result = computerControl.releaseLease(bot.id, controlLeaseId);
15315
+ const result = computerControl.releaseLease(controlKey, controlLeaseId);
14788
15316
  return json(res, 200, { ...result.snapshot, released: result.released });
14789
15317
  }
14790
15318
  if (action === "take")
14791
- return json(res, 200, computerControl.take(bot.id));
15319
+ return json(res, 200, computerControl.take(controlKey));
14792
15320
  if (action === "release")
14793
- return json(res, 200, computerControl.release(bot.id));
15321
+ return json(res, 200, computerControl.release(controlKey));
14794
15322
  if (action === "dismiss-help")
14795
- return json(res, 200, computerControl.dismissHelp(bot.id));
15323
+ return json(res, 200, computerControl.dismissHelp(controlKey));
14796
15324
  return json(res, 400, { error: "action must be take, release, or dismiss-help" });
14797
15325
  }
14798
15326
  return json(res, 405, { error: "method not allowed" });
@@ -14828,6 +15356,29 @@ const handleRequest = async (req, res) => {
14828
15356
  if (boxLifecycleBusyBots.has(botId)) {
14829
15357
  return json(res, 409, { error: "this bot's cloud computer is being changed — wait for it to finish" });
14830
15358
  }
15359
+ const teamComputer = inheritedTeamComputer(bot);
15360
+ if (teamComputer) {
15361
+ const key = teamComputerOwner(teamComputer.id);
15362
+ if (boxLifecycleBusyBots.has(key))
15363
+ return json(res, 409, { error: "This team computer is being changed; wait for it to finish" });
15364
+ if (m[2] === "provision" || m[2] === "remove")
15365
+ return json(res, 409, { error: "Manage this shared computer from the Team map" });
15366
+ if (m[2] === "exec")
15367
+ return json(res, 409, { error: "Use the bot's scoped computer tools for this shared desktop" });
15368
+ if (m[2] === "join" && !computerControl.snapshot(key).held)
15369
+ return json(res, 409, { error: "Take control before opening this shared desktop" });
15370
+ const release = m[2] === "sleep" ? claimTeamComputerLifecycle(teamComputer) : claimBotComputerLifecycle(key);
15371
+ try {
15372
+ if (m[2] === "join")
15373
+ return json(res, 200, await box.joinReadyBox(cfg, key));
15374
+ if (m[2] === "screenshot")
15375
+ return json(res, 200, await box.screenshotBox(cfg, key));
15376
+ return json(res, 200, await box.sleepBox(cfg, key));
15377
+ }
15378
+ finally {
15379
+ release();
15380
+ }
15381
+ }
14831
15382
  if (bot.cloudBackend === "vps") {
14832
15383
  if (m[2] === "screenshot") {
14833
15384
  let preview = vpsPreviewRequests.get(botId);
@@ -14923,6 +15474,13 @@ const server = createServer(handleRequest);
14923
15474
  calendarCalls.start();
14924
15475
  // Resolve the edition before accepting requests so /api/edition is never a guess.
14925
15476
  console.log(describeEdition(await loadEnterpriseLayer()));
15477
+ workspaceAccess = createWorkspaceAccess({ sessions, cookieName: SESSION_COOKIE, closeSessionStreams });
15478
+ // Ten-second cadence plus the bridge's five-second backchannel deadline bounds
15479
+ // stale portal access on quiet event/browser streams to fifteen seconds.
15480
+ const workspaceAccessTimer = workspaceAccess ? setInterval(() => {
15481
+ void workspaceAccess.revalidate().catch((error) => console.warn("workspace access revalidation failed", error));
15482
+ }, 10_000) : null;
15483
+ workspaceAccessTimer?.unref();
14926
15484
  console.log(describeBrand(loadBrand()));
14927
15485
  // Reclaim upload partials a previous run crashed out of, and warm the
14928
15486
  // attachment quota cache off the same scan. This used to happen implicitly on
@@ -15020,10 +15578,14 @@ const gracefulShutdown = createGracefulShutdown({
15020
15578
  cleanup: [
15021
15579
  () => {
15022
15580
  followupsReady = false;
15581
+ if (workspaceAccessTimer)
15582
+ clearInterval(workspaceAccessTimer);
15023
15583
  // Child MCP processes and the HTTP listener can remain alive while the
15024
15584
  // asynchronous shutdown jobs drain. Invalidate their turn bearers before
15025
15585
  // any cleanup function reaches an await.
15026
15586
  revokeAllInternalCapabilities();
15587
+ sharedComputers.close();
15588
+ sharedComputerControl.close();
15027
15589
  browserLive.closeAll();
15028
15590
  for (const idle of localVmIdles.values())
15029
15591
  idle.cancel();
@@ -15048,6 +15610,15 @@ const gracefulShutdown = createGracefulShutdown({
15048
15610
  // the shutdown deadline), immediately before the process exits, so no new
15049
15611
  // server can overlap with a still-mutating old one.
15050
15612
  exit: (code) => {
15613
+ try {
15614
+ sessions.close();
15615
+ }
15616
+ catch {
15617
+ // An uncleared marker makes saved account sessions require sign-in on
15618
+ // the next boot; never label failed persistence a clean shutdown.
15619
+ console.error("Session persistence failed during shutdown; account sign-in will be required again.");
15620
+ code = 1;
15621
+ }
15051
15622
  closeMessageDb();
15052
15623
  releaseDataDirLeaseAtExit();
15053
15624
  process.exit(code);