openmausbot 0.1.66 → 0.1.68
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-CbaejjQA.js +278 -0
- package/dist/assets/{index-kW5L1QeO.js → index-HqooKFTS.js} +1 -1
- package/dist/assets/index-aJ5EWZOQ.css +1 -0
- package/dist/index.html +2 -2
- package/dist-server/computer-proxy.js +14 -3
- package/dist-server/container-mcp.js +44 -19
- package/dist-server/drivers/agents-proxy.js +59 -3
- package/dist-server/enterprise/server/index.js +14587 -0
- package/dist-server/index.js +2675 -1055
- package/dist-server/local-computer-proxy.js +433 -0
- package/dist-server/local-computer.js +48 -5
- package/dist-server/mcp-server.js +38 -17
- package/dist-server/openmausbot.js +1560 -745
- package/dist-server/pair-cli.js +1560 -745
- package/dist-server/proxy-paths.js +1 -0
- package/dist-server/server/account-signin.js +103 -0
- package/dist-server/server/brand.js +7 -0
- package/dist-server/server/caddy.js +203 -0
- package/dist-server/server/cli.js +185 -22
- package/dist-server/server/computer-proxy.js +11 -2
- package/dist-server/server/config.js +22 -1
- package/dist-server/server/control-client.js +7 -1
- package/dist-server/server/delegations.js +56 -11
- package/dist-server/server/drivers/agents-proxy.js +75 -5
- package/dist-server/server/drivers/claude-login-auth.js +334 -0
- package/dist-server/server/drivers/claude.js +8 -0
- package/dist-server/server/environment.js +1 -0
- package/dist-server/server/harness/registry.js +7 -1
- package/dist-server/server/index.js +965 -363
- package/dist-server/server/local-computer-proxy.js +28 -0
- package/dist-server/server/local-computer.js +17 -0
- package/dist-server/server/mcp-bridge.js +27 -11
- package/dist-server/server/peer-approval.js +19 -6
- package/dist-server/server/proxy-paths.js +1 -0
- package/dist-server/server/routines.js +36 -9
- package/dist-server/server/service-cli.js +63 -0
- package/dist-server/server/service-unit.js +129 -0
- package/dist-server/server/sessions.js +42 -1
- package/dist-server/server/steer-queue.js +12 -9
- package/dist-server/server/store.js +260 -34
- package/dist-server/server/system-prompt.js +1 -0
- package/dist-server/server/team-backup.js +1 -0
- package/dist-server/server/tunnel.js +27 -1
- package/dist-server/server/turn-dispatch-guard.js +6 -0
- package/dist-server/server/turn-resources.js +50 -0
- package/dist-server/server/workspace.js +53 -3
- package/dist-server/vps-container-mcp.js +44 -19
- package/enterprise/LICENSE +32 -0
- package/enterprise/README.md +89 -0
- package/enterprise/server/index.js +14587 -0
- package/package.json +2 -1
- package/dist/assets/index-BftlaHAF.css +0 -1
- package/dist/assets/index-DuR3H-Hf.js +0 -278
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Near-side gate for the host CUA process. The descriptor and turn token
|
|
2
|
+
// travel in environment variables, never command-line arguments or logs.
|
|
3
|
+
import { runMcpBridge } from "./mcp-bridge.js";
|
|
4
|
+
import { augmentedPath } from "./env-path.js";
|
|
5
|
+
const { OMB_CUA_COMMAND: command, OMB_CUA_ARGS: encodedArgs, OMB_CONTROL_URL: url, OMB_CONTROL_TOKEN: token, ...childEnv } = process.env;
|
|
6
|
+
let args;
|
|
7
|
+
try {
|
|
8
|
+
const parsed = JSON.parse(encodedArgs ?? "");
|
|
9
|
+
const endpoint = new URL(url ?? "");
|
|
10
|
+
if (!command?.trim() || command.includes("\0") ||
|
|
11
|
+
!Array.isArray(parsed) || !parsed.every((arg) => typeof arg === "string" && !arg.includes("\0")) ||
|
|
12
|
+
!token || !["http:", "https:"].includes(endpoint.protocol) ||
|
|
13
|
+
!["127.0.0.1", "localhost", "[::1]"].includes(endpoint.hostname) || endpoint.username || endpoint.password) {
|
|
14
|
+
throw new Error("invalid connection");
|
|
15
|
+
}
|
|
16
|
+
args = parsed;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
process.stderr.write("invalid local computer proxy connection\n");
|
|
20
|
+
process.exit(2);
|
|
21
|
+
}
|
|
22
|
+
runMcpBridge({
|
|
23
|
+
command: command,
|
|
24
|
+
args,
|
|
25
|
+
env: { ...childEnv, PATH: augmentedPath() },
|
|
26
|
+
label: "Local Cua Driver",
|
|
27
|
+
gate: { url: url, token: token },
|
|
28
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, isAbsolute, join } from "node:path";
|
|
4
|
+
import { SPAWNED_PROXIES } from "./proxy-paths.js";
|
|
4
5
|
export const REQUIRED_LINUX_TOOLS = ["click", "get_window_state", "list_apps", "type_text"];
|
|
5
6
|
// Keep this exact field set synchronized with DRIVER_FILE_IDENTITY_KEYS in
|
|
6
7
|
// electron/cua-linux.cjs; Electron publishes it and the server revalidates it.
|
|
@@ -14,6 +15,22 @@ export const DRIVER_FILE_IDENTITY_KEYS = [
|
|
|
14
15
|
"mtimeNs",
|
|
15
16
|
"ctimeNs",
|
|
16
17
|
];
|
|
18
|
+
/** Keep the platform descriptor intact, but acquire computer authority only
|
|
19
|
+
* when the first actual tool call reaches the shared stdio gate. */
|
|
20
|
+
export function gatedLocalComputer(connection, control) {
|
|
21
|
+
return {
|
|
22
|
+
...connection,
|
|
23
|
+
command: process.execPath,
|
|
24
|
+
args: ["--experimental-strip-types", SPAWNED_PROXIES.localComputer],
|
|
25
|
+
env: {
|
|
26
|
+
...connection.env,
|
|
27
|
+
OMB_CUA_COMMAND: connection.command,
|
|
28
|
+
OMB_CUA_ARGS: JSON.stringify(connection.args),
|
|
29
|
+
OMB_CONTROL_URL: control.url,
|
|
30
|
+
OMB_CONTROL_TOKEN: control.token,
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
17
34
|
function exactKeys(value, keys) {
|
|
18
35
|
const expected = new Set(keys);
|
|
19
36
|
return Object.keys(value).length === expected.size && Object.keys(value).every((key) => expected.has(key));
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
// The
|
|
2
|
-
// (container-mcp.ts for the Local VM, vps-container-mcp.ts for the BYO VPS).
|
|
1
|
+
// The shared stdio bridge for the host computer, Local VM, and BYO VPS.
|
|
3
2
|
//
|
|
4
3
|
// It is almost transparent — bytes in, bytes out — with two deliberate
|
|
5
4
|
// near-side exceptions:
|
|
@@ -152,7 +151,7 @@ export function createGateInterceptor(options) {
|
|
|
152
151
|
options.forward(line);
|
|
153
152
|
return;
|
|
154
153
|
}
|
|
155
|
-
const held = await options.isHeld().catch(() =>
|
|
154
|
+
const held = await options.isHeld().catch(() => true);
|
|
156
155
|
if (!held) {
|
|
157
156
|
options.forward(line);
|
|
158
157
|
return;
|
|
@@ -160,9 +159,10 @@ export function createGateInterceptor(options) {
|
|
|
160
159
|
options.refuse(JSON.stringify({
|
|
161
160
|
jsonrpc: "2.0",
|
|
162
161
|
id: frame.id ?? null,
|
|
163
|
-
result: { content: [{ type: "text", text: refusalText }], isError: true },
|
|
162
|
+
result: { content: [{ type: "text", text: options.getRefusalReason?.() ?? refusalText }], isError: true },
|
|
164
163
|
}));
|
|
165
164
|
});
|
|
165
|
+
return queue;
|
|
166
166
|
};
|
|
167
167
|
}
|
|
168
168
|
/** The near-side MCP method filter. `ping` is answered here so the bundled
|
|
@@ -175,8 +175,9 @@ export function createMcpBridgeInterceptor(options) {
|
|
|
175
175
|
forward: options.forward,
|
|
176
176
|
refuse: options.answer,
|
|
177
177
|
refusalText: options.gate.refusalText,
|
|
178
|
+
getRefusalReason: options.gate.getRefusalReason,
|
|
178
179
|
})
|
|
179
|
-
: options.forward;
|
|
180
|
+
: (line) => { options.forward(line); };
|
|
180
181
|
return (line) => {
|
|
181
182
|
let frame = null;
|
|
182
183
|
try {
|
|
@@ -192,13 +193,13 @@ export function createMcpBridgeInterceptor(options) {
|
|
|
192
193
|
}
|
|
193
194
|
return;
|
|
194
195
|
}
|
|
195
|
-
afterPing(line);
|
|
196
|
+
return afterPing(line);
|
|
196
197
|
};
|
|
197
198
|
}
|
|
198
199
|
export function runMcpBridge(options) {
|
|
199
200
|
const child = spawn(options.command, options.args, {
|
|
200
201
|
shell: false,
|
|
201
|
-
env: { ...process.env, PATH: augmentedPath() },
|
|
202
|
+
env: options.env ?? { ...process.env, PATH: augmentedPath() },
|
|
202
203
|
stdio: ["pipe", "pipe", "pipe"],
|
|
203
204
|
});
|
|
204
205
|
// docker may exit before it drains stdin; pipe() leaves this error unhandled.
|
|
@@ -207,24 +208,39 @@ export function runMcpBridge(options) {
|
|
|
207
208
|
const client = options.gate
|
|
208
209
|
? createControlClient({ url: options.gate.url, token: options.gate.token })
|
|
209
210
|
: null;
|
|
211
|
+
let refusalReason;
|
|
210
212
|
const answer = (line) => process.stdout.write(line + "\n");
|
|
211
213
|
const forward = (line) => child.stdin.write(line + "\n");
|
|
212
|
-
const
|
|
214
|
+
const intercept = createMcpBridgeInterceptor({
|
|
213
215
|
answer,
|
|
214
216
|
forward,
|
|
215
217
|
...(options.gate
|
|
216
218
|
? {
|
|
217
219
|
gate: {
|
|
218
|
-
isHeld: async () =>
|
|
220
|
+
isHeld: async () => {
|
|
221
|
+
refusalReason = undefined;
|
|
222
|
+
const state = await client.state(true);
|
|
223
|
+
refusalReason = state.blockedReason;
|
|
224
|
+
return state.held;
|
|
225
|
+
},
|
|
226
|
+
getRefusalReason: () => refusalReason,
|
|
219
227
|
},
|
|
220
228
|
}
|
|
221
229
|
: {}),
|
|
222
|
-
})
|
|
230
|
+
});
|
|
231
|
+
let pendingInput = Promise.resolve();
|
|
232
|
+
const inbound = createLineSplitter((line) => {
|
|
233
|
+
const completion = intercept(line);
|
|
234
|
+
if (completion)
|
|
235
|
+
pendingInput = completion;
|
|
236
|
+
});
|
|
223
237
|
const onStdin = (chunk) => inbound.push(chunk);
|
|
224
238
|
process.stdin.on("data", onStdin);
|
|
225
239
|
process.stdin.on("end", () => {
|
|
226
240
|
inbound.flush();
|
|
227
|
-
|
|
241
|
+
// A final tools/call can still be awaiting the control endpoint. Do not
|
|
242
|
+
// close the far end before the serialized gate has forwarded it.
|
|
243
|
+
void pendingInput.finally(() => child.stdin.end());
|
|
228
244
|
});
|
|
229
245
|
// Injected responses and refusals must never land inside one of the
|
|
230
246
|
// child's half-written frames, so the child's stdout is re-emitted at
|
|
@@ -31,9 +31,17 @@ function settleCard(pending, behavior, source) {
|
|
|
31
31
|
});
|
|
32
32
|
// answered (by whoever): the turn is working again — the same hand-back
|
|
33
33
|
// the request.resolved fold does for a provider's card
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
const store = pending.bus.store;
|
|
35
|
+
const task = store.projectBotForTask(pending.fromBotId, pending.threadId);
|
|
36
|
+
if (task) {
|
|
37
|
+
if (task.activity === "waiting-on-you")
|
|
38
|
+
store.setTaskActivity(task.id, pending.threadId, "working");
|
|
39
|
+
}
|
|
40
|
+
else if (store.groupByThread(pending.threadId)) {
|
|
41
|
+
const waiting = store.bot(pending.fromBotId);
|
|
42
|
+
if (waiting?.activity === "waiting-on-you")
|
|
43
|
+
store.setActivity(waiting.id, "working");
|
|
44
|
+
}
|
|
37
45
|
}
|
|
38
46
|
/** requestId → pending ask. Lives only in memory — restarting the
|
|
39
47
|
* server cancels every in-flight approval, like provider permissions do. */
|
|
@@ -79,10 +87,15 @@ function pushApprovalCard(bus, from, target, message, action, requestId, sourceT
|
|
|
79
87
|
function announceCard(bus, from, card, sourceThreadId) {
|
|
80
88
|
// the bot is not working now — it is waiting on a person. A delegation
|
|
81
89
|
// drained after its turn has already settled is idle, and stays so.
|
|
82
|
-
const
|
|
83
|
-
if (live?.busy)
|
|
84
|
-
bus.store.setActivity(from.id, "waiting-on-you");
|
|
90
|
+
const task = bus.store.projectBotForTask(from.id, sourceThreadId);
|
|
85
91
|
const room = bus.store.groupByThread(sourceThreadId);
|
|
92
|
+
if (task) {
|
|
93
|
+
if (task.busy)
|
|
94
|
+
bus.store.setTaskActivity(from.id, sourceThreadId, "waiting-on-you");
|
|
95
|
+
}
|
|
96
|
+
else if (room && bus.store.bot(from.id)?.busy) {
|
|
97
|
+
bus.store.setActivity(from.id, "waiting-on-you");
|
|
98
|
+
}
|
|
86
99
|
const group = room && !room.dm ? { id: room.id, name: room.name } : undefined;
|
|
87
100
|
const title = card.card?.title ?? "";
|
|
88
101
|
const detail = card.card?.subtitle ? `${title} — ${card.card.subtitle}` : title;
|
|
@@ -33,6 +33,7 @@ export function resolveProxy(relative) {
|
|
|
33
33
|
export const SPAWNED_PROXIES = {
|
|
34
34
|
browser: resolveProxy("browser-proxy"),
|
|
35
35
|
computer: resolveProxy("computer-proxy"),
|
|
36
|
+
localComputer: resolveProxy("local-computer-proxy"),
|
|
36
37
|
permission: resolveProxy("permission-proxy"),
|
|
37
38
|
containerMcp: resolveProxy("container-mcp"),
|
|
38
39
|
vpsContainerMcp: resolveProxy("vps-container-mcp"),
|
|
@@ -449,6 +449,10 @@ export class RoutineManager {
|
|
|
449
449
|
isActiveThread(threadId) {
|
|
450
450
|
return this.runs.some((run) => run.threadId === threadId && ["running", "waiting"].includes(run.status));
|
|
451
451
|
}
|
|
452
|
+
runForThread(threadId) {
|
|
453
|
+
const run = this.runs.find((candidate) => candidate.threadId === threadId);
|
|
454
|
+
return run ? cloneRun(run) : null;
|
|
455
|
+
}
|
|
452
456
|
create(input, request) {
|
|
453
457
|
if (request) {
|
|
454
458
|
const receipt = this.matchingRoutineRequestReceipt(request);
|
|
@@ -510,9 +514,15 @@ export class RoutineManager {
|
|
|
510
514
|
if (this.targetState(clean) === "missing")
|
|
511
515
|
throw new Error(this.missingTargetMessage(clean.target));
|
|
512
516
|
const cancelledRuns = [];
|
|
517
|
+
const scheduleChanged = JSON.stringify(clean.schedule) !== JSON.stringify(routine.schedule);
|
|
518
|
+
const enabledChanged = clean.enabled !== routine.enabled;
|
|
513
519
|
this.commitMutation(() => {
|
|
514
520
|
Object.assign(routine, clean, {
|
|
515
|
-
|
|
521
|
+
// Renaming/editing instructions must not skip an occurrence that
|
|
522
|
+
// became due since the last tick (or erase an offline catch-up).
|
|
523
|
+
nextRunAt: !clean.enabled ? null : scheduleChanged || enabledChanged
|
|
524
|
+
? this.initialOccurrence(clean.schedule, now)
|
|
525
|
+
: routine.nextRunAt,
|
|
516
526
|
// `updatedAt` doubles as the optimistic revision on durable routine
|
|
517
527
|
// confirmation cards. Keep it monotonic even for two writes in one ms.
|
|
518
528
|
updatedAt: Math.max(now, routine.updatedAt + 1),
|
|
@@ -868,7 +878,10 @@ export class RoutineManager {
|
|
|
868
878
|
this.save();
|
|
869
879
|
for (const missed of missedRuns)
|
|
870
880
|
this.options.onRunFailed?.(missed);
|
|
871
|
-
|
|
881
|
+
// Oldest queued requests have priority. New manual/webhook arrivals
|
|
882
|
+
// must not continually overtake work that has already waited. Snapshot
|
|
883
|
+
// the queue because each dispatch can asynchronously add/cancel work.
|
|
884
|
+
for (const run of this.runs.slice()) {
|
|
872
885
|
if (run.status !== "queued")
|
|
873
886
|
continue;
|
|
874
887
|
// A queued interval represents the latest useful check, not a backlog
|
|
@@ -949,9 +962,19 @@ export class RoutineManager {
|
|
|
949
962
|
// completion and private coordinator envelope are only intermediate
|
|
950
963
|
// protocol, never the routine receipt's result.
|
|
951
964
|
if (run.target === "room-goal" &&
|
|
952
|
-
(
|
|
965
|
+
(
|
|
966
|
+
// These outcomes ended the goal operation. Later room traffic is
|
|
967
|
+
// not a resume of that run; only the goal lifecycle can change its
|
|
968
|
+
// receipt. In-flight provider approvals have no goalStatus and
|
|
969
|
+
// continue to resolve normally below.
|
|
970
|
+
run.goalStatus === "needs-input" || run.goalStatus === "paused" ||
|
|
971
|
+
event.type === "turn.completed" || (event.type === "item.completed" && event.itemType === "assistant_text")))
|
|
953
972
|
return null;
|
|
954
|
-
if (event.type === "
|
|
973
|
+
if (event.type === "turn.started") {
|
|
974
|
+
run.status = "running";
|
|
975
|
+
run.attention = undefined;
|
|
976
|
+
}
|
|
977
|
+
else if (event.type === "request.opened") {
|
|
955
978
|
run.status = "waiting";
|
|
956
979
|
run.attention = redactSecretsInText(event.summary).trim().slice(0, 500) || undefined;
|
|
957
980
|
}
|
|
@@ -971,16 +994,20 @@ export class RoutineManager {
|
|
|
971
994
|
return null;
|
|
972
995
|
}
|
|
973
996
|
else if (event.type === "turn.completed") {
|
|
974
|
-
|
|
975
|
-
|
|
997
|
+
if (event.cost != null)
|
|
998
|
+
run.cost = (run.cost ?? 0) + event.cost;
|
|
999
|
+
if (event.denials?.length)
|
|
1000
|
+
run.denials = [...new Set([...(run.denials ?? []), ...event.denials])];
|
|
976
1001
|
if (!event.ok) {
|
|
977
1002
|
this.failRun(run, event.stopReason ?? run.error ?? "The bot did not complete this run");
|
|
978
1003
|
queueMicrotask(() => void this.tick());
|
|
979
1004
|
return cloneRun(run);
|
|
980
1005
|
}
|
|
981
|
-
|
|
982
|
-
run.
|
|
983
|
-
run.
|
|
1006
|
+
const pending = this.options.hasPendingDelegations?.(event.threadId) === true;
|
|
1007
|
+
run.status = pending ? "waiting" : "completed";
|
|
1008
|
+
run.attention = pending ? "Waiting for delegated work to finish" : undefined;
|
|
1009
|
+
if (!pending)
|
|
1010
|
+
run.finishedAt = this.now();
|
|
984
1011
|
run.error = undefined;
|
|
985
1012
|
}
|
|
986
1013
|
else {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The `openmausbot service` command, kept separate from cli.ts so it can be
|
|
2
|
+
// tested with explicit inputs: it renders the unit for this platform, writes
|
|
3
|
+
// it next to the data, and prints the commands that install it.
|
|
4
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { currentUser, launchdPlist, servicePlan, systemdUnit, unstableInstallWarning } from "./service-unit.js";
|
|
7
|
+
/** The `serve` arguments the service repeats, from the options given to `service install`. */
|
|
8
|
+
export function serviceServeArgs(input) {
|
|
9
|
+
const args = ["--port", String(input.port), "--data-dir", input.dataDir, "--no-pair"];
|
|
10
|
+
if (input.domain)
|
|
11
|
+
args.push("--domain", input.domain);
|
|
12
|
+
else if (input.tunnel)
|
|
13
|
+
args.push("--tunnel");
|
|
14
|
+
else if (input.tailscale)
|
|
15
|
+
args.push("--tailscale");
|
|
16
|
+
if (input.label)
|
|
17
|
+
args.push("--label", input.label);
|
|
18
|
+
return args;
|
|
19
|
+
}
|
|
20
|
+
export function runServiceCommand(input, io) {
|
|
21
|
+
const platform = input.platform ?? process.platform;
|
|
22
|
+
const home = input.home ?? homedir();
|
|
23
|
+
const plan = servicePlan(platform, input.dataDir, home);
|
|
24
|
+
if (!plan) {
|
|
25
|
+
io.error("services are written for Linux (systemd) and macOS (launchd); on Windows, use Task Scheduler to run `openmausbot serve` at startup");
|
|
26
|
+
return 1;
|
|
27
|
+
}
|
|
28
|
+
if (input.action === "uninstall") {
|
|
29
|
+
io.log(`to stop and remove the service:`);
|
|
30
|
+
for (const line of plan.deactivate)
|
|
31
|
+
io.log(` ${line}`);
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
const warning = unstableInstallWarning(input.script);
|
|
35
|
+
if (warning) {
|
|
36
|
+
io.error(warning);
|
|
37
|
+
return 1;
|
|
38
|
+
}
|
|
39
|
+
const spec = {
|
|
40
|
+
node: input.node,
|
|
41
|
+
script: input.script,
|
|
42
|
+
serveArgs: serviceServeArgs(input),
|
|
43
|
+
dataDir: input.dataDir,
|
|
44
|
+
user: input.user ?? currentUser(),
|
|
45
|
+
home,
|
|
46
|
+
bindsLowPorts: Boolean(input.domain),
|
|
47
|
+
...(input.label ? { label: input.label } : {}),
|
|
48
|
+
};
|
|
49
|
+
const rendered = platform === "darwin" ? launchdPlist(spec) : systemdUnit(spec);
|
|
50
|
+
mkdirSync(input.dataDir, { recursive: true, mode: 0o700 });
|
|
51
|
+
writeFileSync(plan.file, rendered, { mode: 0o644 });
|
|
52
|
+
io.log(`wrote ${plan.file}`);
|
|
53
|
+
io.log("");
|
|
54
|
+
io.log(platform === "darwin" ? "to install and start it (runs at login):" : "to install and start it (runs at boot, restarts if it stops):");
|
|
55
|
+
for (const line of plan.activate)
|
|
56
|
+
io.log(` ${line}`);
|
|
57
|
+
io.log("");
|
|
58
|
+
if (input.domain && platform === "linux")
|
|
59
|
+
io.log("the unit grants Caddy the capability for ports 80 and 443, so no setcap is needed under the service");
|
|
60
|
+
io.log(`logs: ${platform === "darwin" ? `${input.dataDir}/logs/service.log` : "journalctl -u openmausbot -f"}`);
|
|
61
|
+
io.log(`change options later by running \`openmausbot service install\` again with the new ones, then: ${platform === "darwin" ? plan.activate[1] : "sudo systemctl daemon-reload && sudo systemctl restart openmausbot"}`);
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// `openmausbot service install`: keep the server running across reboots.
|
|
2
|
+
// Renders a systemd unit (Linux) or a launchd agent (macOS) that runs the
|
|
3
|
+
// same `openmausbot serve …` the operator just used, and either installs it
|
|
4
|
+
// (when allowed to) or writes it next to the data and prints the two
|
|
5
|
+
// commands that install it. Pure rendering lives here so it is testable;
|
|
6
|
+
// the CLI decides where the file goes.
|
|
7
|
+
import { homedir, userInfo } from "node:os";
|
|
8
|
+
import { posix } from "node:path";
|
|
9
|
+
// Unit files describe a Linux or macOS machine, so their paths are POSIX
|
|
10
|
+
// whatever host renders them (the Windows CI runner included).
|
|
11
|
+
const { basename, dirname, join } = posix;
|
|
12
|
+
export const SYSTEMD_UNIT_NAME = "openmausbot.service";
|
|
13
|
+
export const LAUNCHD_LABEL = "com.openmausbot.serve";
|
|
14
|
+
function quoteSystemd(value) {
|
|
15
|
+
// systemd's ExecStart splits on whitespace and understands double quotes.
|
|
16
|
+
return /[\s"\\]/.test(value) ? `"${value.replace(/[\\"]/g, "\\$&")}"` : value;
|
|
17
|
+
}
|
|
18
|
+
function needsStripTypes(script) {
|
|
19
|
+
return script.endsWith(".ts");
|
|
20
|
+
}
|
|
21
|
+
export function serviceCommand(spec) {
|
|
22
|
+
return [spec.node, ...(needsStripTypes(spec.script) ? ["--experimental-strip-types"] : []), spec.script, "serve", ...spec.serveArgs];
|
|
23
|
+
}
|
|
24
|
+
export function systemdUnit(spec) {
|
|
25
|
+
const lines = [
|
|
26
|
+
"# Written by `openmausbot service install`. Re-run it to change the options.",
|
|
27
|
+
"[Unit]",
|
|
28
|
+
`Description=OpenMausBot${spec.label ? ` (${spec.label})` : ""}`,
|
|
29
|
+
"After=network-online.target",
|
|
30
|
+
"Wants=network-online.target",
|
|
31
|
+
"",
|
|
32
|
+
"[Service]",
|
|
33
|
+
"Type=simple",
|
|
34
|
+
`User=${spec.user}`,
|
|
35
|
+
`WorkingDirectory=${spec.home}`,
|
|
36
|
+
`Environment=HOME=${spec.home}`,
|
|
37
|
+
`Environment=OMB_DATA_DIR=${spec.dataDir}`,
|
|
38
|
+
`ExecStart=${serviceCommand(spec).map(quoteSystemd).join(" ")}`,
|
|
39
|
+
"Restart=always",
|
|
40
|
+
"RestartSec=3",
|
|
41
|
+
"KillMode=mixed",
|
|
42
|
+
"TimeoutStopSec=30",
|
|
43
|
+
];
|
|
44
|
+
if (spec.bindsLowPorts) {
|
|
45
|
+
lines.push("# serve --domain: Caddy binds ports 80 and 443 without running as root.");
|
|
46
|
+
lines.push("AmbientCapabilities=CAP_NET_BIND_SERVICE");
|
|
47
|
+
lines.push("CapabilityBoundingSet=CAP_NET_BIND_SERVICE");
|
|
48
|
+
}
|
|
49
|
+
lines.push("", "[Install]", "WantedBy=multi-user.target", "");
|
|
50
|
+
return lines.join("\n");
|
|
51
|
+
}
|
|
52
|
+
function xml(value) {
|
|
53
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
54
|
+
}
|
|
55
|
+
export function launchdPlist(spec) {
|
|
56
|
+
const args = serviceCommand(spec).map((arg) => `\t\t<string>${xml(arg)}</string>`).join("\n");
|
|
57
|
+
return [
|
|
58
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
59
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
60
|
+
'<plist version="1.0">',
|
|
61
|
+
"<dict>",
|
|
62
|
+
"\t<key>Label</key>",
|
|
63
|
+
`\t<string>${LAUNCHD_LABEL}</string>`,
|
|
64
|
+
"\t<key>ProgramArguments</key>",
|
|
65
|
+
"\t<array>",
|
|
66
|
+
args,
|
|
67
|
+
"\t</array>",
|
|
68
|
+
"\t<key>EnvironmentVariables</key>",
|
|
69
|
+
"\t<dict>",
|
|
70
|
+
"\t\t<key>HOME</key>",
|
|
71
|
+
`\t\t<string>${xml(spec.home)}</string>`,
|
|
72
|
+
"\t\t<key>OMB_DATA_DIR</key>",
|
|
73
|
+
`\t\t<string>${xml(spec.dataDir)}</string>`,
|
|
74
|
+
"\t\t<key>PATH</key>",
|
|
75
|
+
"\t\t<string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>",
|
|
76
|
+
"\t</dict>",
|
|
77
|
+
"\t<key>WorkingDirectory</key>",
|
|
78
|
+
`\t<string>${xml(spec.home)}</string>`,
|
|
79
|
+
"\t<key>RunAtLoad</key>",
|
|
80
|
+
"\t<true/>",
|
|
81
|
+
"\t<key>KeepAlive</key>",
|
|
82
|
+
"\t<true/>",
|
|
83
|
+
"\t<key>StandardOutPath</key>",
|
|
84
|
+
`\t<string>${xml(join(spec.dataDir, "logs", "service.log"))}</string>`,
|
|
85
|
+
"\t<key>StandardErrorPath</key>",
|
|
86
|
+
`\t<string>${xml(join(spec.dataDir, "logs", "service.log"))}</string>`,
|
|
87
|
+
"</dict>",
|
|
88
|
+
"</plist>",
|
|
89
|
+
"",
|
|
90
|
+
].join("\n");
|
|
91
|
+
}
|
|
92
|
+
/** An `npx` cache path is pruned without notice; a service must not point at it. */
|
|
93
|
+
export function unstableInstallWarning(script) {
|
|
94
|
+
const normalized = script.replace(/\\/g, "/");
|
|
95
|
+
if (/\/_npx\//.test(normalized) || /\/\.npm\/_npx\//.test(normalized)) {
|
|
96
|
+
return `this command runs from an npx cache (${dirname(script)}), which npm may delete at any time. Install it permanently first (npm install -g openmausbot) and run \`openmausbot service install\` from that install.`;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
/** Where the rendered file goes and how to activate it, per platform. */
|
|
101
|
+
export function servicePlan(platform, dataDir, home = homedir()) {
|
|
102
|
+
if (platform === "linux") {
|
|
103
|
+
const installed = `/etc/systemd/system/${SYSTEMD_UNIT_NAME}`;
|
|
104
|
+
return {
|
|
105
|
+
file: join(dataDir, SYSTEMD_UNIT_NAME),
|
|
106
|
+
installed,
|
|
107
|
+
activate: [`sudo install -m 644 ${join(dataDir, SYSTEMD_UNIT_NAME)} ${installed}`, "sudo systemctl daemon-reload", `sudo systemctl enable --now ${basename(SYSTEMD_UNIT_NAME, ".service")}`],
|
|
108
|
+
deactivate: [`sudo systemctl disable --now ${basename(SYSTEMD_UNIT_NAME, ".service")}`, `sudo rm ${installed}`, "sudo systemctl daemon-reload"],
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (platform === "darwin") {
|
|
112
|
+
const installed = join(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
113
|
+
return {
|
|
114
|
+
file: join(dataDir, `${LAUNCHD_LABEL}.plist`),
|
|
115
|
+
installed,
|
|
116
|
+
activate: [`mkdir -p ${dirname(installed)} && cp ${join(dataDir, `${LAUNCHD_LABEL}.plist`)} ${installed}`, `launchctl bootstrap gui/$(id -u) ${installed}`],
|
|
117
|
+
deactivate: [`launchctl bootout gui/$(id -u)/${LAUNCHD_LABEL}`, `rm ${installed}`],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
export function currentUser() {
|
|
123
|
+
try {
|
|
124
|
+
return userInfo().username;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return process.env.USER || process.env.USERNAME || "openmausbot";
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -66,6 +66,9 @@ const sessionSchema = z.object({
|
|
|
66
66
|
createdAt: z.number(),
|
|
67
67
|
lastSeenAt: z.number(),
|
|
68
68
|
expiresAt: z.number(),
|
|
69
|
+
/** Set when the session came from an account sign-in rather than a code. */
|
|
70
|
+
userId: z.string().max(256).optional(),
|
|
71
|
+
email: z.string().max(320).optional(),
|
|
69
72
|
});
|
|
70
73
|
const fileSchema = z.object({ version: z.literal(1), sessions: z.array(sessionSchema) });
|
|
71
74
|
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
|
@@ -102,7 +105,7 @@ export function formatPairingCode(code) {
|
|
|
102
105
|
return code.match(/.{1,4}/g)?.join("-") ?? code;
|
|
103
106
|
}
|
|
104
107
|
function publicSession(record) {
|
|
105
|
-
|
|
108
|
+
const view = {
|
|
106
109
|
id: record.id,
|
|
107
110
|
label: record.label,
|
|
108
111
|
scopes: [...record.scopes],
|
|
@@ -110,6 +113,9 @@ function publicSession(record) {
|
|
|
110
113
|
lastSeenAt: record.lastSeenAt,
|
|
111
114
|
expiresAt: record.expiresAt,
|
|
112
115
|
};
|
|
116
|
+
if (record.email)
|
|
117
|
+
view.email = record.email;
|
|
118
|
+
return view;
|
|
113
119
|
}
|
|
114
120
|
export class SessionRegistry {
|
|
115
121
|
sessions = [];
|
|
@@ -284,6 +290,41 @@ export class SessionRegistry {
|
|
|
284
290
|
this.replays.push({ codeHash: presented, attemptId, result, expiresAt: now + EXCHANGE_REPLAY_MS });
|
|
285
291
|
return result;
|
|
286
292
|
}
|
|
293
|
+
/** A session from a verified account sign-in (server/account-signin.ts)
|
|
294
|
+
* rather than a pairing code: same token, same term, same gates. */
|
|
295
|
+
issue(input) {
|
|
296
|
+
this.prune();
|
|
297
|
+
const now = this.now();
|
|
298
|
+
const token = `omb_sess_${randomBytes(32).toString("base64url")}`;
|
|
299
|
+
const record = {
|
|
300
|
+
id: randomUUID(),
|
|
301
|
+
tokenHash: sha256(token),
|
|
302
|
+
label: (input.label.trim() || "Unnamed device").slice(0, 80),
|
|
303
|
+
scopes: [...new Set(input.scopes)],
|
|
304
|
+
createdAt: now,
|
|
305
|
+
lastSeenAt: now,
|
|
306
|
+
expiresAt: now + Math.min(SESSION_TTL_MS, SESSION_MAX_AGE_MS),
|
|
307
|
+
};
|
|
308
|
+
if (input.userId)
|
|
309
|
+
record.userId = input.userId;
|
|
310
|
+
if (input.email)
|
|
311
|
+
record.email = input.email;
|
|
312
|
+
this.sessions.push(record);
|
|
313
|
+
this.lastSeenWrites.set(record.id, now);
|
|
314
|
+
this.persist();
|
|
315
|
+
return { token, session: publicSession(record) };
|
|
316
|
+
}
|
|
317
|
+
/** The pairing lockout, for other code-like exchanges on the same source. */
|
|
318
|
+
attemptAllowed(source) {
|
|
319
|
+
const lock = this.lockState(source);
|
|
320
|
+
return lock.locked ? { ok: false, retryAfterMs: lock.retryAfterMs } : { ok: true };
|
|
321
|
+
}
|
|
322
|
+
noteFailure(source) {
|
|
323
|
+
this.recordFailure(source);
|
|
324
|
+
}
|
|
325
|
+
clearFailures(source) {
|
|
326
|
+
this.failures.delete(source);
|
|
327
|
+
}
|
|
287
328
|
// ── sessions ───────────────────────────────────────────────────────────
|
|
288
329
|
authenticate(token) {
|
|
289
330
|
if (!token)
|
|
@@ -32,28 +32,31 @@ export function queueSteeredMessage(botId, threadId, text, options = {}) {
|
|
|
32
32
|
prompt: options.prompt ?? text,
|
|
33
33
|
replyToId: options.replyToId,
|
|
34
34
|
sendId: options.sendId,
|
|
35
|
+
reason: options.reason,
|
|
35
36
|
});
|
|
36
37
|
queues.set(threadId, entry);
|
|
37
38
|
return { id };
|
|
38
39
|
}
|
|
39
|
-
/** Drain every queue whose
|
|
40
|
+
/** Drain every queue whose task is idle: append the held lines (leaf is now
|
|
40
41
|
* the finished turn's last item), then one run per thread whose prompt is
|
|
41
42
|
* the texts separated by a blank line. `userMessage` is the last appended line
|
|
42
43
|
* so startTurn does not duplicate it; `excludeIds` is every drained line
|
|
43
44
|
* so transcript-replay adapters do not also see earlier queued texts.
|
|
44
45
|
* Entries leave the map BEFORE running so a settle racing another settle
|
|
45
46
|
* can never fire the same queue twice. */
|
|
46
|
-
export function drainSteeredMessages(store, run) {
|
|
47
|
+
export function drainSteeredMessages(store, run, isBlocked) {
|
|
47
48
|
// deleting only the entry being visited is safe under Map iteration
|
|
48
49
|
for (const [threadId, entry] of queues) {
|
|
49
|
-
const bot = store.
|
|
50
|
+
const bot = store.projectBotForTask
|
|
51
|
+
? store.projectBotForTask(entry.botId, threadId)
|
|
52
|
+
: store.bot(entry.botId);
|
|
50
53
|
if (!bot) {
|
|
51
|
-
// the bot was deleted while messages waited
|
|
54
|
+
// the bot or task was deleted while messages waited
|
|
52
55
|
queues.delete(threadId);
|
|
53
56
|
continue;
|
|
54
57
|
}
|
|
55
|
-
if (bot.busy)
|
|
56
|
-
continue;
|
|
58
|
+
if (bot.busy || isBlocked?.(entry.botId, threadId))
|
|
59
|
+
continue;
|
|
57
60
|
// committed to draining: the entry leaves the map before anything runs,
|
|
58
61
|
// so a settle racing another settle can never fire the same queue twice
|
|
59
62
|
queues.delete(threadId);
|
|
@@ -87,15 +90,15 @@ export function queuedSteeredMessage(botId, threadId, sendId) {
|
|
|
87
90
|
if (!entry || entry.botId !== botId)
|
|
88
91
|
return null;
|
|
89
92
|
const item = entry.items.find((candidate) => candidate.sendId === sendId);
|
|
90
|
-
return item ? { id: item.messageId, text: item.text, replyToId: item.replyToId } : null;
|
|
93
|
+
return item ? { id: item.messageId, text: item.text, replyToId: item.replyToId, ...(item.reason ? { reason: item.reason } : {}) } : null;
|
|
91
94
|
}
|
|
92
95
|
/** Drop one waiting send owned by this bot so it never drains. The queue id
|
|
93
96
|
* is stable even if the bot switches away from the task while the request is
|
|
94
97
|
* in flight. Returns false when it was already drained, belongs to another
|
|
95
98
|
* bot, or a restart lost the in-memory auto-run intent. */
|
|
96
|
-
export function cancelSteeredMessage(botId, messageId) {
|
|
99
|
+
export function cancelSteeredMessage(botId, messageId, expectedThreadId) {
|
|
97
100
|
for (const [threadId, entry] of queues) {
|
|
98
|
-
if (entry.botId !== botId)
|
|
101
|
+
if (entry.botId !== botId || (expectedThreadId !== undefined && threadId !== expectedThreadId))
|
|
99
102
|
continue;
|
|
100
103
|
const items = entry.items.filter((item) => item.messageId !== messageId);
|
|
101
104
|
if (items.length === entry.items.length)
|