openmausbot 0.1.75 → 0.1.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/assets/{index-BTlyRwLl.js → index-DeK-z9ld.js} +1 -1
  2. package/dist/assets/index-XdHJ-9CF.css +1 -0
  3. package/dist/assets/index-lhqE1umn.js +305 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/companion/src/routes.js +6 -2
  6. package/dist-server/container-mcp.js +5 -1
  7. package/dist-server/drivers/agents-proxy.js +184 -11
  8. package/dist-server/enterprise/server/index.js +387 -29
  9. package/dist-server/index.js +3786 -1135
  10. package/dist-server/mcp-gate.js +28 -18
  11. package/dist-server/openmausbot.js +927 -642
  12. package/dist-server/pair-cli.js +927 -642
  13. package/dist-server/server/agent-tool-policy.js +2 -0
  14. package/dist-server/server/atomic.js +37 -1
  15. package/dist-server/server/bot-overview.js +5 -1
  16. package/dist-server/server/browser-live.js +3 -0
  17. package/dist-server/server/chief-of-staff.js +17 -12
  18. package/dist-server/server/config.js +16 -0
  19. package/dist-server/server/delegations.js +177 -63
  20. package/dist-server/server/drivers/acp/core.js +3 -1
  21. package/dist-server/server/drivers/acp/hermes.js +21 -3
  22. package/dist-server/server/drivers/agents-proxy.js +169 -10
  23. package/dist-server/server/drivers/boxagent.js +1 -1
  24. package/dist-server/server/drivers/claude.js +34 -16
  25. package/dist-server/server/drivers/codex.js +3 -1
  26. package/dist-server/server/drivers/openai-chat.js +90 -67
  27. package/dist-server/server/drivers/openai-compat.js +9 -1
  28. package/dist-server/server/drivers/pi.js +22 -3
  29. package/dist-server/server/enterprise.js +38 -0
  30. package/dist-server/server/env-path.js +18 -17
  31. package/dist-server/server/environment.js +3 -0
  32. package/dist-server/server/fleet-agent.js +57 -13
  33. package/dist-server/server/fleet-cli.js +248 -31
  34. package/dist-server/server/fleet.js +88 -14
  35. package/dist-server/server/hosted-contract.js +16 -0
  36. package/dist-server/server/index.js +1777 -406
  37. package/dist-server/server/message-db.js +21 -0
  38. package/dist-server/server/message-file.js +4 -0
  39. package/dist-server/server/peer-roster.js +48 -9
  40. package/dist-server/server/procs.js +19 -3
  41. package/dist-server/server/provider-key-check.js +18 -1
  42. package/dist-server/server/request-auth.js +14 -5
  43. package/dist-server/server/room-handoffs.js +237 -0
  44. package/dist-server/server/section-context.js +64 -14
  45. package/dist-server/server/sessions.js +115 -8
  46. package/dist-server/server/shared-computer-control.js +24 -0
  47. package/dist-server/server/shared-computers.js +131 -0
  48. package/dist-server/server/steer-queue.js +2 -0
  49. package/dist-server/server/store.js +267 -15
  50. package/dist-server/server/surface.js +16 -5
  51. package/dist-server/server/team-backup.js +17 -1
  52. package/dist-server/server/team-computers.js +108 -0
  53. package/dist-server/server/team-setup-requests.js +252 -0
  54. package/dist-server/server/tool-summary.js +53 -1
  55. package/dist-server/server/turn-dispatch-guard.js +31 -3
  56. package/dist-server/server/workspace-backup.js +1 -0
  57. package/dist-server/shared/approval-mode.js +11 -1
  58. package/dist-server/shared/team-backup.js +1 -0
  59. package/dist-server/shared/team-computer.js +1 -0
  60. package/dist-server/shared/team-setup.js +1 -0
  61. package/dist-server/vps-container-mcp.js +5 -1
  62. package/enterprise/README.md +21 -3
  63. package/enterprise/server/index.js +387 -29
  64. package/package.json +1 -1
  65. package/dist/assets/index-CeMbvsgx.js +0 -305
  66. package/dist/assets/index-DH7Zax6L.css +0 -1
@@ -0,0 +1,252 @@
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { newId } from "./contracts.js";
4
+ import { fitsOnOneLine, parseBotProfilePatch } from "./bot-profile.js";
5
+ import { profileSnapshot } from "./profile-revision.js";
6
+ import { redactSecretsInText } from "./redact.js";
7
+ const section = (value) => value?.trim() || "";
8
+ const teamName = z.string().trim().min(1).max(60).refine(fitsOnOneLine).refine((value) => redactSecretsInText(value) === value, "Team names cannot contain credentials");
9
+ const fieldsSchema = z.object({
10
+ name: z.string().optional(), title: z.string().optional(), description: z.string().optional(), soul: z.string().optional(),
11
+ section: z.string().trim().max(60).refine(fitsOnOneLine).refine((value) => redactSecretsInText(value) === value, "Team names cannot contain credentials").optional(),
12
+ modelSelection: z.object({ instanceId: z.string().trim().min(1), model: z.string().trim().min(1), effort: z.string().optional() }).strict().optional(),
13
+ }).strict();
14
+ const planSchema = z.object({
15
+ reason: z.string().trim().min(1).max(500),
16
+ newTeams: z.array(teamName).max(8).default([]),
17
+ operations: z.array(z.discriminatedUnion("action", [
18
+ z.object({ action: z.literal("create"), key: z.string().trim().min(1).max(80), fields: fieldsSchema }).strict(),
19
+ z.object({ action: z.literal("update"), botId: z.string().min(1).max(128), fields: fieldsSchema }).strict(),
20
+ ])).min(1).max(24),
21
+ }).strict();
22
+ export class TeamSetupError extends Error {
23
+ status;
24
+ constructor(message, status = 400) { super(message); this.status = status; }
25
+ }
26
+ /** Settings relevant to approval, including the previous durable receipt. */
27
+ export function teamSetupRevision(bot) {
28
+ return createHash("sha256").update(JSON.stringify({
29
+ ...profileSnapshot(bot), section: section(bot.section), modelSelection: bot.modelSelection,
30
+ hidden: Boolean(bot.hidden), chiefOfStaff: Boolean(bot.chiefOfStaff), peers: bot.peers,
31
+ managedSections: bot.managedSections,
32
+ approvalMode: bot.approvalMode, autoApprove: bot.autoApprove, approvalGrant: bot.approvalGrant,
33
+ receipt: bot.lastTeamSetupReceipt?.requestId,
34
+ })).digest("hex");
35
+ }
36
+ export class TeamSetupRequestService {
37
+ resolving = new Set();
38
+ options;
39
+ constructor(options) { this.options = options; }
40
+ chief(botId) {
41
+ const chief = this.options.store.bot(botId);
42
+ if (!chief || chief.hidden || !chief.chiefOfStaff)
43
+ throw new TeamSetupError("Only an active Chief of Staff can propose team setup", 403);
44
+ return chief;
45
+ }
46
+ fields(input, current) {
47
+ const { section: targetSection, modelSelection, ...profile } = input;
48
+ const safe = Object.fromEntries(Object.entries(profile).map(([key, value]) => [key, redactSecretsInText(value)]));
49
+ const parsed = parseBotProfilePatch(safe, true);
50
+ if (!parsed.ok)
51
+ throw new TeamSetupError(parsed.error);
52
+ const result = { ...parsed.patch };
53
+ if (targetSection !== undefined)
54
+ result.section = targetSection;
55
+ if (modelSelection) {
56
+ const selection = modelSelection;
57
+ const error = this.options.validateModel(selection, current);
58
+ if (error)
59
+ throw new TeamSetupError(error);
60
+ result.modelSelection = selection;
61
+ }
62
+ return result;
63
+ }
64
+ validate(request, confirming) {
65
+ const chief = this.chief(request.botId);
66
+ if (!this.options.ownsThread(request.botId, request.threadId))
67
+ throw new TeamSetupError("The requesting conversation no longer exists", 409);
68
+ if (confirming && !this.options.store.messagesFor(request.threadId).some((message) => message.card?.requestId === request.requestId && !message.card.answered && !message.card.dismissed))
69
+ throw new TeamSetupError("This setup card is no longer pending", 409);
70
+ if (confirming && teamSetupRevision(chief) !== request.requesterRevision)
71
+ throw new TeamSetupError("The Chief's settings changed. This setup was cancelled; review a new proposal.", 409);
72
+ const existingTeams = new Set(this.options.teams().map(section));
73
+ if (new Set([...(chief.managedSections ?? []), ...request.newTeams]).size > 100)
74
+ throw new TeamSetupError("A Chief may coordinate at most 100 additional teams", 409);
75
+ for (const name of request.newTeams)
76
+ if (existingTeams.has(name))
77
+ throw new TeamSetupError(`Team ${JSON.stringify(name)} now exists. Review a new proposal.`, 409);
78
+ const allowed = (name) => request.newTeams.includes(section(name)) || this.options.canAccessTeam(chief, name);
79
+ const targets = new Set();
80
+ const projected = this.options.store.bots.map((bot) => ({ id: bot.id, name: bot.name, section: section(bot.section), chiefOfStaff: bot.chiefOfStaff, hidden: bot.hidden }));
81
+ for (const operation of request.operations) {
82
+ if (targets.has(operation.botId))
83
+ throw new TeamSetupError("A bot appears twice in the prepared setup");
84
+ targets.add(operation.botId);
85
+ const target = this.options.store.bot(operation.botId);
86
+ if (operation.action === "create") {
87
+ if (target)
88
+ throw new TeamSetupError("A proposed bot already exists. Review a new proposal.", 409);
89
+ if (!operation.fields.name?.trim() || !operation.fields.title?.trim() || !operation.fields.soul?.trim() || !operation.fields.modelSelection)
90
+ throw new TeamSetupError("Each new bot needs a name, title, soul instructions, and exact model selection");
91
+ }
92
+ else {
93
+ if (!target || target.hidden || !this.options.canAccessTeam(chief, target.section) || (target.id !== chief.id && Array.isArray(chief.peers) && !chief.peers.includes(target.id)))
94
+ throw new TeamSetupError("A target bot is outside this Chief's authorized team and peer scope", 403);
95
+ if (teamSetupRevision(target) !== operation.expectedRevision)
96
+ throw new TeamSetupError(`@${target.name} changed. This setup was cancelled; review a new proposal.`, 409);
97
+ if (this.options.targetBusy(target.id) && (confirming || target.id !== chief.id))
98
+ throw new TeamSetupError(`Stop @${target.name}'s work before changing its setup`, 409);
99
+ }
100
+ const fields = this.fields(operation.fields, target ?? undefined);
101
+ const destination = fields.section ?? target?.section ?? chief.section;
102
+ if (!allowed(destination))
103
+ throw new TeamSetupError("The destination team is outside this Chief's authorized scope", 403);
104
+ if (!request.newTeams.includes(section(destination)) && !existingTeams.has(section(destination)))
105
+ throw new TeamSetupError("The destination team no longer exists", 409);
106
+ const next = { id: operation.botId, name: fields.name ?? target.name, section: section(destination), chiefOfStaff: target?.chiefOfStaff, hidden: false };
107
+ const at = projected.findIndex((bot) => bot.id === operation.botId);
108
+ if (at < 0)
109
+ projected.push(next);
110
+ else
111
+ projected[at] = next;
112
+ }
113
+ if (this.options.store.bots.length + request.operations.filter((op) => op.action === "create").length > this.options.maxBots)
114
+ throw new TeamSetupError("This setup exceeds the workspace bot limit", 409);
115
+ for (const name of request.newTeams)
116
+ if (!request.operations.some((op) => section(op.fields.section) === name))
117
+ throw new TeamSetupError(`New team ${JSON.stringify(name)} needs a specialist in this setup`);
118
+ for (const operation of request.operations) {
119
+ const candidate = projected.find((bot) => bot.id === operation.botId);
120
+ if (projected.some((bot) => bot.id !== candidate.id && !bot.hidden && bot.section === candidate.section && bot.name.trim().toLowerCase() === candidate.name.trim().toLowerCase()))
121
+ throw new TeamSetupError(`@${candidate.name} already exists in that team`, 409);
122
+ if (candidate.chiefOfStaff && projected.some((bot) => bot.id !== candidate.id && bot.chiefOfStaff && bot.section === candidate.section))
123
+ throw new TeamSetupError("That move would put two Chiefs in one team", 409);
124
+ }
125
+ if (request.deletion) {
126
+ const target = this.options.store.bot(request.deletion.botId);
127
+ if (!target || target.hidden || target.id === chief.id || !this.options.canAccessTeam(chief, target.section) || (Array.isArray(chief.peers) && !chief.peers.includes(target.id)))
128
+ throw new TeamSetupError("This deletion target is no longer an authorized teammate", 403);
129
+ if (teamSetupRevision(target) !== request.deletion.expectedRevision || this.options.targetBusy(target.id))
130
+ throw new TeamSetupError("The deletion target changed or is working. Review a new deletion proposal.", 409);
131
+ }
132
+ }
133
+ propose(args) {
134
+ const parsed = planSchema.safeParse(args.plan);
135
+ if (!parsed.success)
136
+ throw new TeamSetupError(parsed.error.issues[0]?.message ?? "Invalid team setup");
137
+ const chief = this.chief(args.botId);
138
+ const combined = new Map();
139
+ for (const operation of parsed.data.operations) {
140
+ const key = operation.action === "create" ? `create:${operation.key}` : `update:${operation.botId}`;
141
+ const previous = combined.get(key);
142
+ combined.set(key, { ...operation, fields: { ...previous?.fields, ...operation.fields } });
143
+ }
144
+ if (combined.size > 8)
145
+ throw new TeamSetupError("Review at most eight bots in one setup");
146
+ const operations = [...combined.values()].map((operation) => {
147
+ const target = operation.action === "update" ? this.options.store.bot(operation.botId) : undefined;
148
+ return { action: operation.action, botId: operation.action === "create" ? newId() : operation.botId,
149
+ ...(operation.action === "create" ? { threadId: newId() } : {}),
150
+ fields: this.fields({ ...operation.fields, section: operation.fields.section ?? target?.section ?? chief.section ?? "" }, target ?? undefined),
151
+ ...(target ? { expectedRevision: teamSetupRevision(target) } : {}),
152
+ };
153
+ });
154
+ const request = { version: 1, requestId: newId(), botId: args.botId, threadId: args.threadId,
155
+ reason: redactSecretsInText(parsed.data.reason), createdAt: Date.now(), requesterRevision: teamSetupRevision(chief),
156
+ newTeams: [...new Set(parsed.data.newTeams)], operations };
157
+ this.validate(request, false);
158
+ return this.card(request, args.from);
159
+ }
160
+ proposeDeletion(args) {
161
+ const chief = this.chief(args.botId);
162
+ const target = this.options.store.bot(args.targetBotId);
163
+ if (!target)
164
+ throw new TeamSetupError("That bot no longer exists", 404);
165
+ if (!args.reason.trim() || args.reason.length > 500)
166
+ throw new TeamSetupError("A deletion reason of at most 500 characters is required");
167
+ const request = { version: 1, requestId: newId(), botId: args.botId, threadId: args.threadId,
168
+ reason: redactSecretsInText(args.reason), createdAt: Date.now(), requesterRevision: teamSetupRevision(chief), newTeams: [], operations: [],
169
+ deletion: { botId: target.id, name: target.name, expectedRevision: teamSetupRevision(target) } };
170
+ this.validate(request, false);
171
+ return this.card(request, args.from);
172
+ }
173
+ card(request, from) {
174
+ const permission = this.options.canPersist(request.botId, request.threadId);
175
+ if (!permission.ok)
176
+ throw new TeamSetupError(permission.error, permission.status);
177
+ const chief = this.chief(request.botId);
178
+ const lines = [`Why: ${request.reason}`];
179
+ if (request.deletion)
180
+ lines.push(`Delete @${request.deletion.name} (${request.deletion.botId}).`, "Permanently removes this bot, all its conversations, memory, instructions, and skills. Generated project files remain. Active work or owned computers can block deletion.");
181
+ if (request.newTeams.length)
182
+ lines.push(`Create teams: ${request.newTeams.map((name) => JSON.stringify(name)).join(", ")}.`, `Authorize @${chief.name} to coordinate and propose setup changes in these new teams.`);
183
+ for (const operation of request.operations) {
184
+ const current = this.options.store.bot(operation.botId);
185
+ lines.push(`\n${operation.action === "create" ? "Create" : "Update"} @${operation.fields.name ?? current?.name} (${operation.action === "create" ? "new bot" : operation.botId})`);
186
+ for (const [key, value] of Object.entries(operation.fields)) {
187
+ const before = current ? (key === "section" ? current.section || "General" : current[key]) : undefined;
188
+ lines.push(`${key === "modelSelection" ? "Default engine/model" : key}: ${current ? `${JSON.stringify(before ?? "")} → ` : ""}${key === "section" ? JSON.stringify(value || "General") : JSON.stringify(value)}`);
189
+ }
190
+ }
191
+ if (!request.deletion)
192
+ lines.push("\nDefault models apply to groups and new threads. Every existing thread keeps its current model and permissions.", "New bots start in Ask with connected apps disabled. Existing execution permissions are unchanged.");
193
+ lines.push("After this decision the Chief continues once with the result.");
194
+ const title = request.deletion ? `Delete @${request.deletion.name}?` : `Apply setup for ${request.operations.length} ${request.operations.length === 1 ? "bot" : "bots"}?`;
195
+ const detail = lines.join("\n");
196
+ const message = this.options.store.appendMessage(request.threadId, { role: "bot", kind: "options", ...(from ? { from } : {}), card: {
197
+ title, subtitle: detail, options: [request.deletion ? "Delete bot" : "Apply setup", "Cancel"], requestId: request.requestId,
198
+ tool: request.deletion ? "delete_bot" : "set_up_team", teamSetupRequest: request,
199
+ } });
200
+ return { requestId: request.requestId, messageId: message.id, title, summary: title, detail };
201
+ }
202
+ async resolve(args) {
203
+ const message = this.options.store.messagesFor(args.threadId).find((item) => item.card?.requestId === args.requestId && item.card.teamSetupRequest);
204
+ const card = message?.card, request = card?.teamSetupRequest;
205
+ if (!message || !card || !request)
206
+ return null;
207
+ if (request.botId !== args.botId || request.threadId !== args.threadId || request.requestId !== args.requestId)
208
+ throw new TeamSetupError("This setup belongs to another conversation", 403);
209
+ if (args.behavior !== "allow" && args.behavior !== "deny")
210
+ throw new TeamSetupError("Confirm or cancel this setup", 400);
211
+ // Stop/dismiss can close a card without a setup decision. It must never
212
+ // resurrect that review or wake the stopped conversation.
213
+ if (card.answered || card.dismissed) {
214
+ const result = request.result ?? { state: "cancelled", bots: [], newTeams: [], error: "This setup card was closed" };
215
+ return { result, request: { ...request, result }, messageId: message.id, duplicate: true };
216
+ }
217
+ if (this.resolving.has(request.requestId))
218
+ throw new TeamSetupError("This setup is already being applied", 409);
219
+ this.resolving.add(request.requestId);
220
+ try {
221
+ const receipt = this.options.store.bot(request.botId)?.lastTeamSetupReceipt;
222
+ let result;
223
+ if (receipt?.requestId === request.requestId)
224
+ result = receipt.result;
225
+ else if (args.behavior === "deny")
226
+ result = { state: "denied", bots: [], newTeams: [] };
227
+ else {
228
+ try {
229
+ this.validate(request, true);
230
+ if (request.deletion) {
231
+ await this.options.deleteBot(request.deletion.botId, () => this.validate(request, true), request);
232
+ result = { state: "applied", bots: [{ id: request.deletion.botId, name: request.deletion.name, action: "deleted" }], newTeams: [] };
233
+ }
234
+ else
235
+ result = this.options.store.applyTeamSetup(request);
236
+ }
237
+ catch (error) {
238
+ const saved = this.options.store.bot(request.botId)?.lastTeamSetupReceipt;
239
+ const detail = redactSecretsInText(error instanceof Error ? error.message : String(error));
240
+ result = saved?.requestId === request.requestId
241
+ ? { ...saved.result, error: `The changes were saved, but cleanup needs attention: ${detail}` }
242
+ : { state: error instanceof TeamSetupError ? "cancelled" : "failed", bots: [], newTeams: [], error: detail };
243
+ }
244
+ }
245
+ this.options.store.patchMessage(args.threadId, message.id, { card: { ...card, answered: result.state === "applied" ? "allow" : "deny", held: result.error, teamSetupRequest: { ...request, result } } });
246
+ return { result, request: { ...request, result }, messageId: message.id, duplicate: false };
247
+ }
248
+ finally {
249
+ this.resolving.delete(request.requestId);
250
+ }
251
+ }
252
+ }
@@ -1,7 +1,59 @@
1
1
  // What a tool call is about to do, in words a chip or a permission card can
2
2
  // show. Redacted before it is cut: a command line is where credentials get
3
3
  // pasted, and a key sliced in half would slip past the shapes redaction knows.
4
- import { redactSecretsInText } from "./redact.js";
4
+ import { redactSecrets, redactSecretsInText } from "./redact.js";
5
+ /** Display-only excerpt, never the raw protocol payload. Bound traversal and
6
+ * omit binary bodies before redacting; truncate only AFTER redaction so a
7
+ * credential cannot be cut in half and escape detection. */
8
+ export function toolDetailPreview(value) {
9
+ if (value === undefined)
10
+ return undefined;
11
+ let budget = 200;
12
+ let textBudget = 256_000;
13
+ const bounded = (item, depth = 0) => {
14
+ if (--budget < 0 || depth > 6)
15
+ return "[additional data omitted]";
16
+ if (typeof item === "string") {
17
+ if (item.length > textBudget)
18
+ return "[large content omitted]";
19
+ if (/^data:[^,\s]+;base64,/i.test(item))
20
+ return "[binary content omitted]";
21
+ // MCP frequently puts JSON inside text content. Recover its field
22
+ // names so short credentials get the same masking as native objects.
23
+ if (/^\s*[[{]/.test(item)) {
24
+ try {
25
+ return bounded(JSON.parse(item), depth + 1);
26
+ }
27
+ catch { /* ordinary text/code: use the content redactor below */ }
28
+ }
29
+ textBudget -= item.length;
30
+ return item;
31
+ }
32
+ if (typeof item === "bigint")
33
+ return String(item);
34
+ if (item === null || typeof item !== "object")
35
+ return item;
36
+ if (Array.isArray(item))
37
+ return item.slice(0, 40).map((child) => bounded(child, depth + 1)).concat(item.length > 40 ? ["[additional items omitted]"] : []);
38
+ const entries = Object.entries(item);
39
+ const result = Object.create(null);
40
+ for (const [key, child] of entries.slice(0, 40)) {
41
+ result[key] = /token|secret|password|passwd|cookie|authorization|api.?key|(^|[_.-])keys?$/i.test(key)
42
+ ? "[redacted]"
43
+ : /^(data|base64|blob)$/i.test(key) && typeof child === "string"
44
+ ? "[binary content omitted]"
45
+ : bounded(child, depth + 1);
46
+ }
47
+ if (entries.length > 40)
48
+ result["…"] = "[additional fields omitted]";
49
+ return result;
50
+ };
51
+ const safe = redactSecrets(bounded(value));
52
+ const text = typeof safe === "string" ? safe : JSON.stringify(safe, null, 2);
53
+ if (!text?.trim() || text === "{}" || text === "[]")
54
+ return undefined;
55
+ return text.length > 6_000 ? `${text.slice(0, 6_000)}\n[… preview shortened]` : text;
56
+ }
5
57
  const QUESTION_LIMIT = 300;
6
58
  const LIMIT = 200;
7
59
  function fieldsOf(input) {
@@ -44,8 +44,11 @@ export class RetiredTurnRegistry {
44
44
  export class ProviderTurnGenerationRegistry {
45
45
  #owners = new Map();
46
46
  #completed;
47
+ #earlyCompletions = new Map();
48
+ #limit;
47
49
  constructor(limit = 4_096) {
48
50
  this.#completed = new RetiredTurnRegistry(limit);
51
+ this.#limit = limit;
49
52
  }
50
53
  bind(threadId, generation, turnId) {
51
54
  if (this.#completed.has(turnId))
@@ -53,23 +56,48 @@ export class ProviderTurnGenerationRegistry {
53
56
  this.#owners.set(turnId, { threadId, generation });
54
57
  return true;
55
58
  }
56
- complete(threadId, turnId) {
57
- this.#completed.retire(turnId);
59
+ complete(threadId, turnId, outcome) {
60
+ if (this.#completed.has(turnId))
61
+ return null;
58
62
  const owner = this.#owners.get(turnId);
59
- if (!owner || owner.threadId !== threadId)
63
+ if (owner && owner.threadId !== threadId)
60
64
  return null;
65
+ this.#completed.retire(turnId);
66
+ if (!owner) {
67
+ // Only a later ACK for this exact provider id may consume the result.
68
+ // Never attribute an unbound event to the thread's current generation.
69
+ if (outcome !== undefined) {
70
+ this.#earlyCompletions.set(turnId, { threadId, outcome });
71
+ while (this.#earlyCompletions.size > this.#limit) {
72
+ this.#earlyCompletions.delete(this.#earlyCompletions.keys().next().value);
73
+ }
74
+ }
75
+ return null;
76
+ }
61
77
  this.#owners.delete(turnId);
62
78
  return owner;
63
79
  }
80
+ takeEarlyCompletion(threadId, turnId) {
81
+ const receipt = this.#earlyCompletions.get(turnId);
82
+ if (receipt?.threadId !== threadId)
83
+ return undefined;
84
+ this.#earlyCompletions.delete(turnId);
85
+ return receipt.outcome;
86
+ }
64
87
  deleteGeneration(threadId, generation) {
88
+ const removed = [];
65
89
  for (const [turnId, owner] of this.#owners) {
66
90
  if (owner.threadId === threadId && owner.generation === generation) {
67
91
  this.#owners.delete(turnId);
92
+ this.#completed.retire(turnId);
93
+ removed.push(turnId);
68
94
  }
69
95
  }
96
+ return removed;
70
97
  }
71
98
  clear() {
72
99
  this.#owners.clear();
100
+ this.#earlyCompletions.clear();
73
101
  }
74
102
  }
75
103
  /** Thread gate for the narrow interval after Stop wins but before an async
@@ -23,6 +23,7 @@ const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}
23
23
  const EXCLUDED = new Set([
24
24
  ".backups", "tools", "cache", ".cache", "tmp", ".tmp", "dist-native", "tunnel-runtime",
25
25
  ".openmausbot-server-child", "environment-id", "sessions.json", "tunnel-account.json",
26
+ "team-computers.json",
26
27
  "openmausbot-server.lease", "box-create-requests.lock", "messages.db-wal", "messages.db-shm",
27
28
  ]);
28
29
  const EXCLUSION_NOTES = [
@@ -16,6 +16,13 @@ export function supportsApprovalMode(driverKind, mode) {
16
16
  return true;
17
17
  return ["codex", "claudeAgent", "antigravityAgent", "cursorAgent", "grokAgent", "opencodeGo"].includes(driverKind ?? "");
18
18
  }
19
+ /** A Full/Custom grant belongs to one provider's tool semantics. Other
20
+ * modes carry across only when the destination actually implements them.
21
+ * Adapted from tahodev's provider-switch guard in PR #1120. */
22
+ export function modelSwitchNeedsAsk(mode, fromDriver, toDriver) {
23
+ return !supportsApprovalMode(toDriver, mode) ||
24
+ ((mode === "full" || mode === "custom") && fromDriver !== toDriver);
25
+ }
19
26
  export function hasNativeAutoReview(driverKind) {
20
27
  return ["codex", "claudeAgent", "cursorAgent", "grokAgent"].includes(driverKind ?? "");
21
28
  }
@@ -27,7 +34,10 @@ export function isApprovalMode(value) {
27
34
  * behavior: autoApprove=true is safe Auto; everything else asks. Unknown
28
35
  * persisted values also fail closed to that legacy behavior. */
29
36
  export function approvalModeFor(bot) {
30
- if (bot.approvalGrant)
37
+ const grant = bot.approvalGrant;
38
+ const otherThread = grant && typeof grant === "object" && "threadOnly" in grant && grant.threadOnly === true &&
39
+ "threadId" in grant && typeof grant.threadId === "string" && typeof bot.threadId === "string" && grant.threadId !== bot.threadId;
40
+ if (grant && !otherThread)
31
41
  return "ask";
32
42
  if (isApprovalMode(bot.approvalMode))
33
43
  return bot.approvalMode;
@@ -21,6 +21,7 @@ const task = z.object({
21
21
  title: z.string().max(2_000),
22
22
  createdAt: timestamp,
23
23
  openedBy: z.object({ botId: key, name, at: timestamp }).optional(),
24
+ closedBy: z.object({ botId: key, name, at: timestamp }).optional(),
24
25
  activeLeafId: key.nullable(),
25
26
  messages: z.array(message).max(100_000),
26
27
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -15112,7 +15112,11 @@ var featureConfigSchema = external_exports.object({
15112
15112
  showToolCalls: external_exports.boolean().optional(),
15113
15113
  /** Experimental built-in browser. Off until explicitly enabled; each bot
15114
15114
  * also has its own switch. */
15115
- browser: external_exports.boolean().optional()
15115
+ browser: external_exports.boolean().optional(),
15116
+ /** Opt-in computer sharing (a desktop lending folders, a terminal or
15117
+ * computer control to a workspace). Off until explicitly enabled; there is
15118
+ * no Settings toggle — see sharedComputersEnabled. */
15119
+ sharedComputers: external_exports.boolean().optional()
15116
15120
  });
15117
15121
  var onboardingConfigSchema = external_exports.object({
15118
15122
  /** ISO timestamp of finishing (or skipping to the end of) the welcome flow. */
@@ -23,8 +23,11 @@ customer, because the key decides the feature set rather than the code.
23
23
  { "edition": "oss", "features": [], "notice": "OMB_LICENSE_KEY expired on 2027-09-02; renew it to keep enterprise features" }
24
24
  ```
25
25
 
26
- A missing, altered, or expired key never stops the server: it runs the
27
- open-source edition and the notice says what to fix.
26
+ A missing, altered, or expired key does not stop an ordinary standalone
27
+ server: it runs the open-source edition and the notice says what to fix.
28
+ A workspace explicitly configured for hosted sign-in fails closed for remote
29
+ access when its `admin` entitlement or identity service is unavailable. It
30
+ must not silently fall back to standalone email or pairing credentials.
28
31
 
29
32
  ## Entitlement ids
30
33
 
@@ -32,7 +35,7 @@ open-source edition and the notice says what to fix.
32
35
  |---|---|
33
36
  | `whitelabel` | product name, tagline, accent colour, logo, favicon and support link from `brand.json` (below) |
34
37
  | `sso` | identity-header trust behind an OIDC proxy |
35
- | `admin` | the admin panel routes |
38
+ | `admin` | Settings → Workspaces and the optional hosted workspace sign-in adapter |
36
39
  | `budgets` | per-bot and per-section spend limits |
37
40
 
38
41
  Core gates a feature with `entitled("id")` from `server/enterprise.ts`.
@@ -59,6 +62,21 @@ working until they expire.
59
62
  - Customer-specific brand, skills, packages, connectors? The customer's own
60
63
  repo: data and config, never a fork.
61
64
 
65
+ ## Hosted workspace sign-in (`admin`)
66
+
67
+ The workspace-side adapter in `server/workspace-access.ts` connects to an
68
+ independently deployed identity service over HTTPS. The service's console,
69
+ invitations, provider gateway and deployment automation are not shipped in
70
+ this repository. The desktop and ordinary self-hosted server do not start
71
+ or depend on a hosted console.
72
+
73
+ Operators opt a workspace into this protocol using `OMB_ADMIN_URL`,
74
+ `OMB_ADMIN_WORKSPACE` and `OMB_PUBLIC_URL`; a valid `admin` entitlement is
75
+ required. The default keeps the local sign-in allow-list as an additional,
76
+ narrowing check. Operators may explicitly delegate membership for verified
77
+ portal sessions with `OMB_ADMIN_MEMBERSHIP=portal`; this never exempts ordinary
78
+ email or pairing sessions. See the [protocol and isolated verification recipe](../docs/verification/hosted-workspaces.md).
79
+
62
80
  ## White-label (`whitelabel`)
63
81
 
64
82
  Put a `brand.json` in the server's data dir (`OMB_DATA_DIR`, the `/data`