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
|
@@ -326,6 +326,10 @@ function mimeFor(path) {
|
|
|
326
326
|
case ".jpeg": return "image/jpeg";
|
|
327
327
|
case ".gif": return "image/gif";
|
|
328
328
|
case ".webp": return "image/webp";
|
|
329
|
+
case ".mp4": return "video/mp4";
|
|
330
|
+
case ".m4v": return "video/x-m4v";
|
|
331
|
+
case ".webm": return "video/webm";
|
|
332
|
+
case ".mov": return "video/quicktime";
|
|
329
333
|
case ".doc": return "application/msword";
|
|
330
334
|
case ".docx": return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
|
331
335
|
case ".xls": return "application/vnd.ms-excel";
|
|
@@ -31,6 +31,8 @@ function modelIds(body) {
|
|
|
31
31
|
return ids;
|
|
32
32
|
}
|
|
33
33
|
export async function checkProviderKey(input, fetchImpl = fetch, timeoutMs = 10_000) {
|
|
34
|
+
if (!input.key.trim())
|
|
35
|
+
return { ok: false, reason: "rejected" };
|
|
34
36
|
let url;
|
|
35
37
|
try {
|
|
36
38
|
url = new URL(providerModelsUrl(input.provider, input.url));
|
|
@@ -41,6 +43,10 @@ export async function checkProviderKey(input, fetchImpl = fetch, timeoutMs = 10_
|
|
|
41
43
|
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback(url.hostname))) {
|
|
42
44
|
return { ok: false, reason: "unexpected" };
|
|
43
45
|
}
|
|
46
|
+
const authenticate = input.provider === "openaiCompat"
|
|
47
|
+
&& url.origin === "https://openrouter.ai" && url.pathname === "/api/v1/models";
|
|
48
|
+
if (authenticate)
|
|
49
|
+
url.pathname = "/api/v1/key";
|
|
44
50
|
const headers = input.provider === "anthropic"
|
|
45
51
|
? { "x-api-key": input.key, "anthropic-version": "2023-06-01" }
|
|
46
52
|
: { authorization: `Bearer ${input.key}` };
|
|
@@ -55,7 +61,18 @@ export async function checkProviderKey(input, fetchImpl = fetch, timeoutMs = 10_
|
|
|
55
61
|
if (!response.ok)
|
|
56
62
|
return { ok: false, reason: "unexpected", status: response.status };
|
|
57
63
|
const body = await response.json().catch(() => null);
|
|
58
|
-
|
|
64
|
+
if (!body || typeof body !== "object")
|
|
65
|
+
return { ok: false, reason: "unexpected", status: response.status };
|
|
66
|
+
if (authenticate) {
|
|
67
|
+
const data = body.data;
|
|
68
|
+
if (!data || typeof data !== "object" || Array.isArray(data)
|
|
69
|
+
|| typeof data.is_free_tier !== "boolean") {
|
|
70
|
+
return { ok: false, reason: "unexpected", status: response.status };
|
|
71
|
+
}
|
|
72
|
+
// /key also returns account and usage details; none belong in this verdict.
|
|
73
|
+
return { ok: true, check: "authentication", models: [] };
|
|
74
|
+
}
|
|
75
|
+
return { ok: true, check: "models", models: modelIds(body) };
|
|
59
76
|
}
|
|
60
77
|
catch {
|
|
61
78
|
return { ok: false, reason: "unreachable" };
|
|
@@ -157,6 +157,11 @@ export const CLIENT_ALLOW = [
|
|
|
157
157
|
{ methods: ["GET"], path: /^\/api\/auth\/session$/ },
|
|
158
158
|
{ methods: ["POST"], path: /^\/api\/auth\/stream-ticket$/ },
|
|
159
159
|
{ methods: ["POST"], path: /^\/api\/auth\/logout$/ },
|
|
160
|
+
// Own outbound desktop connector, additionally bound to a private secret.
|
|
161
|
+
// Only honoured while features.sharedComputers is on (see requiredScope):
|
|
162
|
+
// with the feature off these paths are as unlisted as any other, so a
|
|
163
|
+
// client session is refused exactly the way an unknown route refuses it.
|
|
164
|
+
{ methods: ["POST"], path: /^\/api\/shared-computers\/(?:connect|[\w-]+\/(?:poll|lease|result|disconnect))$/, feature: "sharedComputers" },
|
|
160
165
|
// liveness, identity, the stream
|
|
161
166
|
{ methods: ["GET"], path: /^\/api\/health$/ },
|
|
162
167
|
{ methods: ["GET"], path: /^\/api\/edition$/ },
|
|
@@ -218,9 +223,11 @@ export const CLIENT_ALLOW = [
|
|
|
218
223
|
// configured-or-not booleans; the handler strips the few identifying fields for clients
|
|
219
224
|
{ methods: ["GET"], path: /^\/api\/config$/ },
|
|
220
225
|
];
|
|
221
|
-
export function requiredScope(method, path) {
|
|
226
|
+
export function requiredScope(method, path, features = {}) {
|
|
222
227
|
const upper = method.toUpperCase();
|
|
223
228
|
for (const rule of CLIENT_ALLOW) {
|
|
229
|
+
if (rule.feature && features[rule.feature] !== true)
|
|
230
|
+
continue;
|
|
224
231
|
if (rule.path.test(path) && rule.methods.includes(upper))
|
|
225
232
|
return "client";
|
|
226
233
|
}
|
|
@@ -299,7 +306,7 @@ export function resolveRequestAuth(req, options) {
|
|
|
299
306
|
if (session && via) {
|
|
300
307
|
if (via === "cookie" && !isSameOrigin(req))
|
|
301
308
|
return deny(403, "forbidden: cross-origin request");
|
|
302
|
-
const needed = requiredScope(method, path);
|
|
309
|
+
const needed = requiredScope(method, path, options.features ?? {});
|
|
303
310
|
if (!session.scopes.includes(needed)) {
|
|
304
311
|
return deny(403, `forbidden: this session lacks the ${needed} scope`);
|
|
305
312
|
}
|
|
@@ -311,6 +318,11 @@ export function resolveRequestAuth(req, options) {
|
|
|
311
318
|
options.sessions.renew(session.id);
|
|
312
319
|
return { auth: { kind: "session", session, via, scopes: session.scopes }, status: 401, error: "" };
|
|
313
320
|
}
|
|
321
|
+
// A removed email member must not become the loopback owner merely because
|
|
322
|
+
// their now-invalid cookie or bearer was presented to a local address.
|
|
323
|
+
if (via) {
|
|
324
|
+
return deny(401, "unauthorized: this session has expired or was revoked; pair this device again");
|
|
325
|
+
}
|
|
314
326
|
const proxied = isProxied(req);
|
|
315
327
|
const loopback = !proxied && isLoopbackHost(headerValue(req.headers.host)) && isAllowedOrigin(headerValue(req.headers.origin));
|
|
316
328
|
if (loopback) {
|
|
@@ -330,9 +342,6 @@ export function resolveRequestAuth(req, options) {
|
|
|
330
342
|
}
|
|
331
343
|
return { auth: { kind: "loopback", scopes: LOOPBACK_SCOPES }, status: 401, error: "" };
|
|
332
344
|
}
|
|
333
|
-
if (via) {
|
|
334
|
-
return deny(401, "unauthorized: this session has expired or was revoked; pair this device again");
|
|
335
|
-
}
|
|
336
345
|
if (proxied) {
|
|
337
346
|
return deny(403, "forbidden: this request came through a proxy (pair this device to use the server remotely)");
|
|
338
347
|
}
|
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
// endpoint, because EventSource cannot set headers. Failed exchanges are
|
|
10
10
|
// counted per source: five in a minute lock that source out for ten.
|
|
11
11
|
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
12
|
-
import {
|
|
12
|
+
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, unlinkSync } from "node:fs";
|
|
13
13
|
import { dirname } from "node:path";
|
|
14
14
|
import { z } from "zod";
|
|
15
|
+
import { writeFileAtomic } from "./atomic.js";
|
|
15
16
|
export const SCOPES = ["admin", "client"];
|
|
16
17
|
export const PAIRING_CODE_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
|
|
17
18
|
export const PAIRING_CODE_LENGTH = 12;
|
|
@@ -69,6 +70,8 @@ const sessionSchema = z.object({
|
|
|
69
70
|
/** Set when the session came from an account sign-in rather than a code. */
|
|
70
71
|
userId: z.string().max(256).optional(),
|
|
71
72
|
email: z.string().max(320).optional(),
|
|
73
|
+
/** Set only by the internal verified-portal issuance path, never by email or pairing input. */
|
|
74
|
+
membershipAuthority: z.literal("portal").optional(),
|
|
72
75
|
});
|
|
73
76
|
const fileSchema = z.object({ version: z.literal(1), sessions: z.array(sessionSchema) });
|
|
74
77
|
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
|
@@ -127,14 +130,29 @@ export class SessionRegistry {
|
|
|
127
130
|
lastSeenWrites = new Map();
|
|
128
131
|
now;
|
|
129
132
|
options;
|
|
133
|
+
openMarker;
|
|
134
|
+
closed = false;
|
|
130
135
|
// No parameter properties: the server runs this file under Node's
|
|
131
136
|
// strip-only TypeScript mode, which only erases types.
|
|
132
137
|
constructor(options) {
|
|
133
138
|
this.options = options;
|
|
134
139
|
this.now = options.now ?? Date.now;
|
|
135
|
-
this.
|
|
140
|
+
this.openMarker = `${options.file}.open`;
|
|
141
|
+
const unclean = existsSync(this.openMarker);
|
|
142
|
+
try {
|
|
143
|
+
mkdirSync(dirname(options.file), { recursive: true, mode: 0o700 });
|
|
144
|
+
// Establish the guard before accepting any persisted bearer. Even if
|
|
145
|
+
// every subsequent write fails, an unclean restart will see this marker.
|
|
146
|
+
writeFileAtomic(this.openMarker, "Session registry is open.\n", { mode: 0o600 });
|
|
147
|
+
this.syncDirectory();
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
throw new Error("Could not establish safe session storage.");
|
|
151
|
+
}
|
|
152
|
+
this.load(!unclean);
|
|
153
|
+
this.revalidateEmailSessions();
|
|
136
154
|
}
|
|
137
|
-
load() {
|
|
155
|
+
load(restoreAccounts) {
|
|
138
156
|
if (!existsSync(this.options.file))
|
|
139
157
|
return;
|
|
140
158
|
let raw;
|
|
@@ -146,18 +164,50 @@ export class SessionRegistry {
|
|
|
146
164
|
}
|
|
147
165
|
const parsed = fileSchema.safeParse(raw);
|
|
148
166
|
if (parsed.success)
|
|
149
|
-
this.sessions = parsed.data.sessions;
|
|
167
|
+
this.sessions = parsed.data.sessions.filter(session => restoreAccounts || (session.email === undefined && session.userId === undefined));
|
|
168
|
+
}
|
|
169
|
+
syncDirectory() {
|
|
170
|
+
// Windows does not expose directory fsync. The open marker still guards
|
|
171
|
+
// normal process crashes; no stronger power-loss guarantee is claimed.
|
|
172
|
+
if (process.platform === "win32")
|
|
173
|
+
return;
|
|
174
|
+
const fd = openSync(dirname(this.options.file), "r");
|
|
175
|
+
try {
|
|
176
|
+
fsyncSync(fd);
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
closeSync(fd);
|
|
180
|
+
}
|
|
150
181
|
}
|
|
151
182
|
/** Atomic write, owner-only, directory owner-only. */
|
|
152
183
|
persist() {
|
|
184
|
+
if (this.closed)
|
|
185
|
+
throw new Error("Session registry is closed.");
|
|
153
186
|
const dir = dirname(this.options.file);
|
|
154
187
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
188
|
+
writeFileAtomic(this.options.file, JSON.stringify({ version: 1, sessions: this.sessions }, null, 2) + "\n", { mode: 0o600 });
|
|
189
|
+
this.syncDirectory();
|
|
190
|
+
}
|
|
191
|
+
/** Call after HTTP/stream shutdown. A failed close leaves the open marker,
|
|
192
|
+
* so account sessions require fresh sign-in after restart. Paired devices
|
|
193
|
+
* remain durable. Never remove the marker before the reduced set is saved. */
|
|
194
|
+
close() {
|
|
195
|
+
if (this.closed)
|
|
196
|
+
return;
|
|
197
|
+
try {
|
|
198
|
+
this.revalidateEmailSessions();
|
|
199
|
+
this.persist();
|
|
200
|
+
unlinkSync(this.openMarker);
|
|
201
|
+
this.closed = true;
|
|
202
|
+
// A marker deletion lost on power failure causes extra sign-in only;
|
|
203
|
+
// the session file and its rename were fsynced before removal.
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
throw new Error("Could not safely close session storage.");
|
|
207
|
+
}
|
|
159
208
|
}
|
|
160
209
|
prune() {
|
|
210
|
+
this.revalidateEmailSessions();
|
|
161
211
|
const now = this.now();
|
|
162
212
|
this.pairings = this.pairings.filter((p) => p.expiresAt > now);
|
|
163
213
|
this.replays = this.replays.filter((r) => r.expiresAt > now);
|
|
@@ -293,6 +343,16 @@ export class SessionRegistry {
|
|
|
293
343
|
/** A session from a verified account sign-in (server/account-signin.ts)
|
|
294
344
|
* rather than a pairing code: same token, same term, same gates. */
|
|
295
345
|
issue(input) {
|
|
346
|
+
return this.issueAccount(input);
|
|
347
|
+
}
|
|
348
|
+
/** Internal hosted-bridge seam: call only after consuming a verified,
|
|
349
|
+
* workspace-bound PKCE grant. No HTTP route accepts this marker as input. */
|
|
350
|
+
issuePortal(input) {
|
|
351
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(input.grant) || !input.email)
|
|
352
|
+
throw new Error("A verified portal identity is required");
|
|
353
|
+
return this.issueAccount({ label: "Hosted workspace", email: input.email, userId: `portal:${input.grant}`, scopes: input.scopes }, "portal");
|
|
354
|
+
}
|
|
355
|
+
issueAccount(input, membershipAuthority) {
|
|
296
356
|
this.prune();
|
|
297
357
|
const now = this.now();
|
|
298
358
|
const token = `omb_sess_${randomBytes(32).toString("base64url")}`;
|
|
@@ -309,6 +369,8 @@ export class SessionRegistry {
|
|
|
309
369
|
record.userId = input.userId;
|
|
310
370
|
if (input.email)
|
|
311
371
|
record.email = input.email;
|
|
372
|
+
if (membershipAuthority)
|
|
373
|
+
record.membershipAuthority = membershipAuthority;
|
|
312
374
|
this.sessions.push(record);
|
|
313
375
|
this.lastSeenWrites.set(record.id, now);
|
|
314
376
|
this.persist();
|
|
@@ -326,9 +388,53 @@ export class SessionRegistry {
|
|
|
326
388
|
this.failures.delete(source);
|
|
327
389
|
}
|
|
328
390
|
// ── sessions ───────────────────────────────────────────────────────────
|
|
391
|
+
/** Email membership is live, not a thirty-day grant. Losing any issued
|
|
392
|
+
* scope revokes the token and its streams; signing in again obtains the
|
|
393
|
+
* new role. Promotions never widen existing credentials. Pairing sessions
|
|
394
|
+
* have no email and remain independent of the hosted sign-in list. */
|
|
395
|
+
revalidateEmailSessions() {
|
|
396
|
+
if (this.closed)
|
|
397
|
+
throw new Error("Session registry is closed.");
|
|
398
|
+
const eligible = this.sessions.filter(session => session.email !== undefined && !(this.options.portalMembership && session.membershipAuthority === "portal"));
|
|
399
|
+
if (!eligible.length)
|
|
400
|
+
return;
|
|
401
|
+
let resolveScopes = this.options.emailScopes;
|
|
402
|
+
try {
|
|
403
|
+
if (this.options.emailScopesSnapshot)
|
|
404
|
+
resolveScopes = this.options.emailScopesSnapshot();
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
resolveScopes = undefined; // Never fall back to stale membership after a failed snapshot.
|
|
408
|
+
}
|
|
409
|
+
const revoked = eligible.filter((session) => {
|
|
410
|
+
try {
|
|
411
|
+
const allowed = resolveScopes?.(session.email);
|
|
412
|
+
return !allowed || session.scopes.some((scope) => !allowed.includes(scope));
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
return true; // missing or unreadable membership must fail closed
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
if (!revoked.length)
|
|
419
|
+
return;
|
|
420
|
+
const ids = new Set(revoked.map((session) => session.id));
|
|
421
|
+
this.sessions = this.sessions.filter((session) => !ids.has(session.id));
|
|
422
|
+
for (const session of revoked)
|
|
423
|
+
this.forget(session.id);
|
|
424
|
+
try {
|
|
425
|
+
this.persist();
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
// Revocation stays effective in memory and streams close. The boot
|
|
429
|
+
// marker prevents old account bearers from reviving after an unclean
|
|
430
|
+
// restart, even if membership is later restored and all writes failed.
|
|
431
|
+
console.error("Could not persist email session revocation.");
|
|
432
|
+
}
|
|
433
|
+
}
|
|
329
434
|
authenticate(token) {
|
|
330
435
|
if (!token)
|
|
331
436
|
return null;
|
|
437
|
+
this.revalidateEmailSessions();
|
|
332
438
|
const hash = sha256(token);
|
|
333
439
|
const now = this.now();
|
|
334
440
|
const record = this.sessions.find((s) => sameDigest(s.tokenHash, hash));
|
|
@@ -349,6 +455,7 @@ export class SessionRegistry {
|
|
|
349
455
|
* Nothing expired is revived. Writes at most once per half-term, so it
|
|
350
456
|
* adds nothing to the last-seen traffic. Returns whether it renewed. */
|
|
351
457
|
renew(sessionId) {
|
|
458
|
+
this.revalidateEmailSessions();
|
|
352
459
|
const record = this.sessions.find((s) => s.id === sessionId);
|
|
353
460
|
const now = this.now();
|
|
354
461
|
if (!record || record.expiresAt <= now)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** A local desktop call competes with ordinary bot turns for the same screen.
|
|
2
|
+
* Leases expire if Electron crashes; human takeover always wins renewal. */
|
|
3
|
+
export class SharedComputerControl {
|
|
4
|
+
leases = new Map();
|
|
5
|
+
resources;
|
|
6
|
+
held;
|
|
7
|
+
constructor(resources, held) { this.resources = resources; this.held = held; }
|
|
8
|
+
acquire(id) {
|
|
9
|
+
const owner = { threadId: `shared:${id}`, generation: id };
|
|
10
|
+
if (this.held() || !this.resources.claim("computer:host", owner)) {
|
|
11
|
+
this.release(id);
|
|
12
|
+
throw Object.assign(new Error("This computer is in use locally or held by a person. Wait, then observe it again before acting."), { status: 409 });
|
|
13
|
+
}
|
|
14
|
+
clearTimeout(this.leases.get(id));
|
|
15
|
+
this.leases.set(id, setTimeout(() => this.release(id), 40_000));
|
|
16
|
+
}
|
|
17
|
+
release(id) {
|
|
18
|
+
clearTimeout(this.leases.get(id));
|
|
19
|
+
this.leases.delete(id);
|
|
20
|
+
this.resources.release({ threadId: `shared:${id}`, generation: id });
|
|
21
|
+
}
|
|
22
|
+
close() { for (const id of this.leases.keys())
|
|
23
|
+
this.release(id); }
|
|
24
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
const folder = z.object({ id: z.string().uuid(), name: z.string().min(1).max(120), write: z.boolean() }).strict();
|
|
4
|
+
export const sharedComputerRegistration = z.object({
|
|
5
|
+
id: z.string().uuid(), name: z.string().min(1).max(120), environmentId: z.string().uuid(),
|
|
6
|
+
folders: z.array(folder).max(20), terminal: z.boolean(), computer: z.boolean(),
|
|
7
|
+
}).strict();
|
|
8
|
+
export const sharedComputerOperation = z.object({
|
|
9
|
+
computer_id: z.string().uuid(),
|
|
10
|
+
action: z.enum(["list_files", "read_file", "write_file", "run_command", "computer_tools", "computer_call"]),
|
|
11
|
+
folder_id: z.string().uuid().optional(), path: z.string().max(2048).optional(),
|
|
12
|
+
content: z.string().max(350_000).optional(), encoding: z.enum(["utf8", "base64"]).optional(),
|
|
13
|
+
expected_sha256: z.string().regex(/^[a-f0-9]{64}$/).optional(),
|
|
14
|
+
command: z.string().max(8000).optional(), tool_name: z.string().max(100).optional(),
|
|
15
|
+
arguments: z.record(z.string(), z.unknown()).optional(),
|
|
16
|
+
}).strict();
|
|
17
|
+
const failure = (message, status = 409) => Object.assign(new Error(message), { status });
|
|
18
|
+
/** In-memory rendezvous only. Authority over local resources stays on the
|
|
19
|
+
* desktop. Restart/disconnect NEVER replays an operation with an unknown outcome. */
|
|
20
|
+
export class SharedComputers {
|
|
21
|
+
computers = new Map();
|
|
22
|
+
sessionLive;
|
|
23
|
+
constructor(sessionLive) { this.sessionLive = sessionLive; }
|
|
24
|
+
register(registration, owner, secret) {
|
|
25
|
+
if (!/^[a-f0-9]{64}$/.test(secret))
|
|
26
|
+
throw failure("Invalid computer credential", 400);
|
|
27
|
+
const old = this.computers.get(registration.id);
|
|
28
|
+
if (old) {
|
|
29
|
+
this.authorize(registration.id, owner, secret);
|
|
30
|
+
old.registration = registration;
|
|
31
|
+
old.seen = Date.now();
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
// Reap dead devices before applying the bounded registration limit.
|
|
35
|
+
for (const [id, entry] of this.computers)
|
|
36
|
+
if (!this.online(entry) && entry.jobs.size === 0)
|
|
37
|
+
this.computers.delete(id);
|
|
38
|
+
if (this.computers.size >= 20)
|
|
39
|
+
throw failure("Too many shared computers");
|
|
40
|
+
this.computers.set(registration.id, { registration, owner, secret, seen: Date.now(), jobs: new Map() });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
online(entry) { return this.sessionLive(entry.owner) && Date.now() - entry.seen < 40_000; }
|
|
44
|
+
authorize(id, owner, secret) {
|
|
45
|
+
const entry = this.computers.get(id);
|
|
46
|
+
if (!entry || entry.owner !== owner || !this.sessionLive(owner) || !/^[a-f0-9]{64}$/.test(secret) || !timingSafeEqual(Buffer.from(secret), Buffer.from(entry.secret)))
|
|
47
|
+
throw failure("Shared computer is disconnected or not authorized", 403);
|
|
48
|
+
return entry;
|
|
49
|
+
}
|
|
50
|
+
list() { return [...this.computers.values()].filter(entry => this.online(entry)).map(entry => entry.registration); }
|
|
51
|
+
async poll(id, owner, secret) {
|
|
52
|
+
const entry = this.authorize(id, owner, secret);
|
|
53
|
+
if (entry.wake)
|
|
54
|
+
throw failure("A computer poll is already running");
|
|
55
|
+
entry.seen = Date.now();
|
|
56
|
+
const next = () => {
|
|
57
|
+
for (const job of entry.jobs.values()) {
|
|
58
|
+
if (!job.active()) {
|
|
59
|
+
this.finish(entry, job, failure("The requesting turn ended"));
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (!job.sent) {
|
|
63
|
+
job.sent = true;
|
|
64
|
+
return { id: job.id, operation: job.operation };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
};
|
|
69
|
+
const queued = next();
|
|
70
|
+
if (queued)
|
|
71
|
+
return queued;
|
|
72
|
+
await new Promise(resolve => {
|
|
73
|
+
const timer = setTimeout(() => { entry.wake = undefined; resolve(); }, 20_000);
|
|
74
|
+
entry.wake = () => { clearTimeout(timer); entry.wake = undefined; resolve(); };
|
|
75
|
+
});
|
|
76
|
+
this.authorize(id, owner, secret);
|
|
77
|
+
entry.seen = Date.now();
|
|
78
|
+
return next();
|
|
79
|
+
}
|
|
80
|
+
liveJob(id, owner, secret, jobId) {
|
|
81
|
+
const entry = this.authorize(id, owner, secret);
|
|
82
|
+
entry.seen = Date.now();
|
|
83
|
+
const job = entry.jobs.get(jobId);
|
|
84
|
+
return job?.sent === true && job.active();
|
|
85
|
+
}
|
|
86
|
+
complete(id, owner, secret, jobId, result) {
|
|
87
|
+
const entry = this.authorize(id, owner, secret);
|
|
88
|
+
const job = entry.jobs.get(jobId);
|
|
89
|
+
if (!job || !job.sent)
|
|
90
|
+
throw failure("This computer operation expired; it will not be replayed");
|
|
91
|
+
this.finish(entry, job, job.active() ? null : failure("The requesting turn ended"), result);
|
|
92
|
+
}
|
|
93
|
+
disconnect(id, owner, secret) {
|
|
94
|
+
const entry = this.authorize(id, owner, secret);
|
|
95
|
+
this.computers.delete(id);
|
|
96
|
+
entry.wake?.();
|
|
97
|
+
for (const job of entry.jobs.values())
|
|
98
|
+
this.finish(entry, job, failure("Computer disconnected. An in-flight action may have completed; inspect before retrying."));
|
|
99
|
+
}
|
|
100
|
+
request(operation, active) {
|
|
101
|
+
const entry = this.computers.get(operation.computer_id);
|
|
102
|
+
if (!entry || !this.online(entry))
|
|
103
|
+
return Promise.reject(failure("This shared computer is offline. Keep its desktop app open; do not substitute files on the server."));
|
|
104
|
+
if (entry.jobs.size >= 1)
|
|
105
|
+
return Promise.reject(failure("This shared computer is busy. Wait for its current action to finish."));
|
|
106
|
+
if (!active())
|
|
107
|
+
return Promise.reject(failure("The requesting turn ended", 401));
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
const id = randomUUID();
|
|
110
|
+
const job = { id, operation, active, sent: false, resolve, reject, timer: setTimeout(() => this.finish(entry, job, failure("Computer action timed out. Its outcome is unknown; inspect before retrying.")), 45_000) };
|
|
111
|
+
entry.jobs.set(id, job);
|
|
112
|
+
entry.wake?.();
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
close() {
|
|
116
|
+
for (const entry of this.computers.values()) {
|
|
117
|
+
entry.wake?.();
|
|
118
|
+
for (const job of entry.jobs.values())
|
|
119
|
+
this.finish(entry, job, failure("Workspace stopped. Inspect any in-flight action before retrying."));
|
|
120
|
+
}
|
|
121
|
+
this.computers.clear();
|
|
122
|
+
}
|
|
123
|
+
finish(entry, job, error, result) {
|
|
124
|
+
clearTimeout(job.timer);
|
|
125
|
+
entry.jobs.delete(job.id);
|
|
126
|
+
if (error)
|
|
127
|
+
job.reject(error);
|
|
128
|
+
else
|
|
129
|
+
job.resolve(result);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -71,6 +71,7 @@ export function queueSteeredMessage(botId, threadId, text, options = {}) {
|
|
|
71
71
|
sendId: options.sendId,
|
|
72
72
|
reason: options.reason,
|
|
73
73
|
unattended: options.unattended,
|
|
74
|
+
peerAsk: options.peerAsk,
|
|
74
75
|
};
|
|
75
76
|
saveChatFollowup({ id, kind: "bot", ownerId: botId, threadId, payload: item });
|
|
76
77
|
entry.items.push(item);
|
|
@@ -132,6 +133,7 @@ export function drainSteeredMessages(store, run, isBlocked) {
|
|
|
132
133
|
replyToId: item.replyToId,
|
|
133
134
|
sendId: item.sendId,
|
|
134
135
|
queueId: item.messageId,
|
|
136
|
+
peerAsk: item.peerAsk,
|
|
135
137
|
}));
|
|
136
138
|
}
|
|
137
139
|
const last = appended.at(-1);
|
|
@@ -378,15 +378,27 @@ export class Store {
|
|
|
378
378
|
// bots.json write. Revoke it before schedulers, listeners, or HTTP can
|
|
379
379
|
// start any new work.
|
|
380
380
|
if (b.approvalGrant !== undefined) {
|
|
381
|
-
b.
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
381
|
+
const threadOnly = b.approvalGrant.threadOnly === true;
|
|
382
|
+
if (threadOnly) {
|
|
383
|
+
// A crash may land between saving the target and clearing its
|
|
384
|
+
// journal. Revoke that target only, never unrelated threads.
|
|
385
|
+
const target = b.tasks?.find(task => task.threadId === b.approvalGrant?.threadId);
|
|
386
|
+
if (target) {
|
|
387
|
+
target.approvalMode = "ask";
|
|
388
|
+
target.autoApprove = false;
|
|
388
389
|
}
|
|
389
390
|
}
|
|
391
|
+
if (!threadOnly) {
|
|
392
|
+
b.approvalMode = "ask";
|
|
393
|
+
b.autoApprove = false;
|
|
394
|
+
for (const task of b.tasks ?? []) {
|
|
395
|
+
if (task.approvalMode === "full" || task.approvalMode === "custom") {
|
|
396
|
+
task.approvalMode = "ask";
|
|
397
|
+
task.autoApprove = false;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
delete b.approvalGrant;
|
|
390
402
|
botsMigrated = true;
|
|
391
403
|
}
|
|
392
404
|
const avatar = botAvatarProfile(b);
|
|
@@ -1617,6 +1629,7 @@ export class Store {
|
|
|
1617
1629
|
return {
|
|
1618
1630
|
...bot,
|
|
1619
1631
|
threadId: task.threadId,
|
|
1632
|
+
approvalGrant: bot.approvalGrant?.threadOnly && bot.approvalGrant.threadId !== threadId ? undefined : bot.approvalGrant,
|
|
1620
1633
|
modelSelection: structuredClone(task.modelSelection ?? bot.modelSelection),
|
|
1621
1634
|
resumeCursors: structuredClone(task.resumeCursors),
|
|
1622
1635
|
approvalMode: task.approvalMode ?? (task.autoApprove === undefined ? bot.approvalMode : undefined),
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { writeFileAtomic } from "./atomic.js";
|
|
5
|
+
export const teamComputerCreate = z.object({
|
|
6
|
+
name: z.string().trim().min(1).max(60).refine(value => [...value].every(character => character.charCodeAt(0) >= 32 && character.charCodeAt(0) !== 127)),
|
|
7
|
+
acknowledgeCost: z.literal(true),
|
|
8
|
+
// Clients may keep this id across a lost create response. It is not a Box id.
|
|
9
|
+
requestId: z.string().uuid(),
|
|
10
|
+
}).strict();
|
|
11
|
+
export const teamComputerAssignment = z.object({
|
|
12
|
+
section: z.string().trim().max(60).nullable(),
|
|
13
|
+
acknowledgeSharedAccess: z.literal(true),
|
|
14
|
+
}).strict();
|
|
15
|
+
const entrySchema = z.object({
|
|
16
|
+
id: z.string().uuid(), name: teamComputerCreate.shape.name,
|
|
17
|
+
section: z.string().trim().max(60).nullable(), createdAt: z.number().finite().nonnegative(),
|
|
18
|
+
problem: z.string().max(500).optional(),
|
|
19
|
+
}).strict();
|
|
20
|
+
const fileSchema = z.object({ version: z.literal(1), environmentId: z.string().uuid(), computers: z.array(entrySchema).max(100) }).strict();
|
|
21
|
+
export const teamComputerOwner = (id) => `computer_${id}`;
|
|
22
|
+
const failure = (message, status = 409) => Object.assign(new Error(message), { status });
|
|
23
|
+
/** One server writer owns the data directory. Persist identity before any
|
|
24
|
+
* provider call; failed/retried creates always retain the same Box journal key.
|
|
25
|
+
* Invalid or foreign restored state is never silently reset to an empty pool. */
|
|
26
|
+
export class TeamComputers {
|
|
27
|
+
entries = [];
|
|
28
|
+
problem;
|
|
29
|
+
file;
|
|
30
|
+
environmentId;
|
|
31
|
+
constructor(file, environmentId) {
|
|
32
|
+
this.file = file;
|
|
33
|
+
this.environmentId = environmentId;
|
|
34
|
+
try {
|
|
35
|
+
if (!existsSync(file))
|
|
36
|
+
return;
|
|
37
|
+
const stat = lstatSync(file);
|
|
38
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 100_000)
|
|
39
|
+
throw new Error("unsafe registry file");
|
|
40
|
+
const saved = fileSchema.parse(JSON.parse(readFileSync(file, "utf8")));
|
|
41
|
+
if (saved.environmentId !== environmentId)
|
|
42
|
+
throw new Error("registry belongs to another workspace; its computers were not transferred");
|
|
43
|
+
if (new Set(saved.computers.map(entry => entry.id)).size !== saved.computers.length)
|
|
44
|
+
throw new Error("duplicate computer identity");
|
|
45
|
+
const sections = saved.computers.flatMap(entry => entry.section === null ? [] : [entry.section]);
|
|
46
|
+
if (new Set(sections).size !== sections.length)
|
|
47
|
+
throw new Error("a team has more than one computer");
|
|
48
|
+
this.entries = saved.computers;
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
this.problem = `Team computer registry could not be loaded: ${error instanceof Error ? error.message : String(error)}`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
list() {
|
|
55
|
+
if (this.problem)
|
|
56
|
+
throw failure(this.problem, 503);
|
|
57
|
+
return this.entries.map(entry => ({ ...entry }));
|
|
58
|
+
}
|
|
59
|
+
get(id) { return this.list().find(entry => entry.id === id); }
|
|
60
|
+
forSection(section) {
|
|
61
|
+
return this.list().find(entry => entry.section !== null && entry.section === (section?.trim() || ""));
|
|
62
|
+
}
|
|
63
|
+
forBot(bot) {
|
|
64
|
+
// Explicit destinations are permissions, not suggestions. Only Auto inherits.
|
|
65
|
+
return bot.computer === undefined && bot.cloudBackend !== "vps" ? this.forSection(bot.section) : undefined;
|
|
66
|
+
}
|
|
67
|
+
create(name, id = randomUUID()) {
|
|
68
|
+
const entries = this.list();
|
|
69
|
+
const existing = entries.find(entry => entry.id === id);
|
|
70
|
+
if (existing) {
|
|
71
|
+
if (existing.name !== name)
|
|
72
|
+
throw failure("This creation id already names a different computer");
|
|
73
|
+
return existing;
|
|
74
|
+
}
|
|
75
|
+
if (entries.length >= 100)
|
|
76
|
+
throw failure("This workspace has reached its team computer limit");
|
|
77
|
+
const entry = entrySchema.parse({ id, name, section: null, createdAt: Date.now() });
|
|
78
|
+
this.save([...entries, entry]);
|
|
79
|
+
return { ...entry };
|
|
80
|
+
}
|
|
81
|
+
assign(id, section) {
|
|
82
|
+
section = entrySchema.shape.section.parse(section);
|
|
83
|
+
const entry = this.get(id);
|
|
84
|
+
if (!entry)
|
|
85
|
+
throw failure("No such team computer", 404);
|
|
86
|
+
if (entry.section !== null && section !== null && section !== entry.section)
|
|
87
|
+
throw failure("Unassign this computer before moving it to another team");
|
|
88
|
+
if (section !== null && this.list().some(other => other.id !== id && other.section === section))
|
|
89
|
+
throw failure("This team already has a computer; unassign it first");
|
|
90
|
+
return this.patch(id, { section });
|
|
91
|
+
}
|
|
92
|
+
setProblem(id, problem) {
|
|
93
|
+
return this.patch(id, { problem: problem?.slice(0, 500) });
|
|
94
|
+
}
|
|
95
|
+
patch(id, patch) {
|
|
96
|
+
const entries = this.list();
|
|
97
|
+
const entry = entries.find(candidate => candidate.id === id);
|
|
98
|
+
if (!entry)
|
|
99
|
+
throw failure("No such team computer", 404);
|
|
100
|
+
const next = entrySchema.parse({ ...entry, ...patch });
|
|
101
|
+
this.save(entries.map(candidate => candidate.id === id ? next : candidate));
|
|
102
|
+
return { ...next };
|
|
103
|
+
}
|
|
104
|
+
save(entries) {
|
|
105
|
+
writeFileAtomic(this.file, JSON.stringify({ version: 1, environmentId: this.environmentId, computers: entries }), { mode: 0o600 });
|
|
106
|
+
this.entries = entries;
|
|
107
|
+
}
|
|
108
|
+
}
|