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.
- package/dist/assets/{index-C6blGSq5.js → index-DeK-z9ld.js} +1 -1
- package/dist/assets/index-XdHJ-9CF.css +1 -0
- package/dist/assets/index-lhqE1umn.js +305 -0
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +5 -1
- package/dist-server/drivers/agents-proxy.js +52 -1
- package/dist-server/enterprise/server/index.js +387 -29
- package/dist-server/index.js +1377 -459
- package/dist-server/openmausbot.js +725 -522
- package/dist-server/pair-cli.js +725 -522
- package/dist-server/server/agent-tool-policy.js +1 -0
- package/dist-server/server/browser-live.js +3 -0
- package/dist-server/server/config.js +16 -0
- package/dist-server/server/drivers/acp/core.js +3 -1
- package/dist-server/server/drivers/agents-proxy.js +58 -2
- package/dist-server/server/drivers/boxagent.js +1 -1
- package/dist-server/server/drivers/claude.js +3 -2
- package/dist-server/server/drivers/codex.js +3 -1
- package/dist-server/server/drivers/pi.js +4 -0
- package/dist-server/server/enterprise.js +38 -0
- package/dist-server/server/environment.js +3 -0
- package/dist-server/server/fleet-agent.js +57 -13
- package/dist-server/server/fleet-cli.js +248 -31
- package/dist-server/server/fleet.js +88 -14
- package/dist-server/server/hosted-contract.js +16 -0
- package/dist-server/server/index.js +671 -100
- package/dist-server/server/message-file.js +4 -0
- package/dist-server/server/provider-key-check.js +18 -1
- package/dist-server/server/request-auth.js +14 -5
- package/dist-server/server/sessions.js +115 -8
- package/dist-server/server/shared-computer-control.js +24 -0
- package/dist-server/server/shared-computers.js +131 -0
- package/dist-server/server/steer-queue.js +2 -0
- package/dist-server/server/store.js +20 -7
- package/dist-server/server/team-computers.js +108 -0
- package/dist-server/server/tool-summary.js +53 -1
- package/dist-server/server/workspace-backup.js +1 -0
- package/dist-server/shared/approval-mode.js +4 -1
- package/dist-server/shared/team-computer.js +1 -0
- package/dist-server/vps-container-mcp.js +5 -1
- package/enterprise/README.md +21 -3
- package/enterprise/server/index.js +387 -29
- package/package.json +1 -1
- package/dist/assets/index-BtT047dk.css +0 -1
- 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
|
-
|
|
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((
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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
|
|
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
|
-
:
|
|
1179
|
+
: previewComputer === "cloud"
|
|
1138
1180
|
? instance?.driverKind === "boxAgent" ? "box-agent" : caps?.computerMcp ? bot.cloudBackend === "vps" ? "vps" : "box" : null
|
|
1139
|
-
:
|
|
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:
|
|
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
|
|
1354
|
+
(bot.approvalGrant.threadOnly || bot.approvalMode === mode) &&
|
|
1355
|
+
!grantBusy(bot) &&
|
|
1304
1356
|
threadCanReceiveGrant(bot) &&
|
|
1305
|
-
|
|
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
|
-
|
|
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 (!
|
|
1344
|
-
|
|
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
|
-
|
|
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
|
|
1390
|
-
|
|
1391
|
-
activate(false, bot
|
|
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
|
-
|
|
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
|
|
1491
|
+
(bot.approvalGrant.threadOnly || bot.approvalMode === mode) &&
|
|
1492
|
+
!grantBusy(bot) &&
|
|
1436
1493
|
threadCanReceiveGrant(bot) &&
|
|
1437
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2389
|
-
|
|
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
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
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,
|
|
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.
|
|
4058
|
-
//
|
|
4059
|
-
//
|
|
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.
|
|
4105
|
-
*
|
|
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:
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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"
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
|
|
13684
|
-
|
|
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,
|
|
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(
|
|
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(
|
|
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(
|
|
15319
|
+
return json(res, 200, computerControl.take(controlKey));
|
|
14792
15320
|
if (action === "release")
|
|
14793
|
-
return json(res, 200, computerControl.release(
|
|
15321
|
+
return json(res, 200, computerControl.release(controlKey));
|
|
14794
15322
|
if (action === "dismiss-help")
|
|
14795
|
-
return json(res, 200, computerControl.dismissHelp(
|
|
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);
|