getbotons 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +505 -2
- package/installer/admin.mjs +6 -0
- package/installer/botons.mjs +430 -0
- package/installer/brain-clis.mjs +28 -0
- package/installer/linger.mjs +39 -0
- package/installer/output.mjs +27 -0
- package/installer/revision.mjs +30 -0
- package/installer/shell-path.mjs +66 -0
- package/installer/source-lock.json +2891 -0
- package/package.json +26 -4
- package/payload/LICENSE +21 -0
- package/payload/SECURITY.md +33 -0
- package/payload/installer/admin.mjs +6 -0
- package/payload/installer/botons.mjs +430 -0
- package/payload/installer/brain-clis.mjs +28 -0
- package/payload/installer/linger.mjs +39 -0
- package/payload/installer/output.mjs +27 -0
- package/payload/installer/revision.mjs +30 -0
- package/payload/installer/shell-path.mjs +66 -0
- package/payload/installer/source-lock.json +2891 -0
- package/payload/npm-shrinkwrap.json +51 -0
- package/payload/package.json +13 -0
- package/payload/scripts/backup.ts +47 -0
- package/payload/scripts/botons-backup.service +15 -0
- package/payload/scripts/botons-backup.timer +11 -0
- package/payload/scripts/botons.service +36 -0
- package/payload/scripts/check-browser.ts +90 -0
- package/payload/scripts/doctor.ts +790 -0
- package/payload/scripts/install-backup-timer.ts +52 -0
- package/payload/scripts/install-service.ts +206 -0
- package/payload/scripts/login.ts +17 -0
- package/payload/scripts/migrate-names.ts +47 -0
- package/payload/scripts/pair.ts +163 -0
- package/payload/scripts/restore.ts +108 -0
- package/payload/server/bin/botons +4 -0
- package/payload/server/bin/legacy/minion +4 -0
- package/payload/server/bin/legacy/tots +4 -0
- package/payload/server/src/acting.ts +16 -0
- package/payload/server/src/api-keys.ts +544 -0
- package/payload/server/src/api.ts +1484 -0
- package/payload/server/src/app.ts +546 -0
- package/payload/server/src/approvals.ts +873 -0
- package/payload/server/src/ask-rules.ts +181 -0
- package/payload/server/src/audit.ts +158 -0
- package/payload/server/src/auth.ts +497 -0
- package/payload/server/src/backup.ts +382 -0
- package/payload/server/src/brain-login.ts +75 -0
- package/payload/server/src/browser-cert.ts +67 -0
- package/payload/server/src/browser-handoff.ts +119 -0
- package/payload/server/src/browser-handover.ts +175 -0
- package/payload/server/src/browser-policy.ts +210 -0
- package/payload/server/src/browser-proxy.ts +580 -0
- package/payload/server/src/browser-request.ts +335 -0
- package/payload/server/src/browser-screenshot.ts +56 -0
- package/payload/server/src/browser-send.ts +155 -0
- package/payload/server/src/browser.ts +2164 -0
- package/payload/server/src/callback.ts +218 -0
- package/payload/server/src/capabilities.ts +90 -0
- package/payload/server/src/cards.ts +243 -0
- package/payload/server/src/cli/approve-mcp.ts +119 -0
- package/payload/server/src/cli/botons.ts +629 -0
- package/payload/server/src/cli/browser-mcp.ts +168 -0
- package/payload/server/src/cli/workspace-file.ts +78 -0
- package/payload/server/src/cli-homes.ts +38 -0
- package/payload/server/src/cli-install.ts +104 -0
- package/payload/server/src/cli-updates.ts +112 -0
- package/payload/server/src/codex-config-repair.ts +80 -0
- package/payload/server/src/config.ts +473 -0
- package/payload/server/src/connector-access.ts +158 -0
- package/payload/server/src/connector-launch.ts +203 -0
- package/payload/server/src/connectors.ts +947 -0
- package/payload/server/src/disk-usage.ts +62 -0
- package/payload/server/src/engine.ts +2328 -0
- package/payload/server/src/env-init.ts +5 -0
- package/payload/server/src/events.ts +46 -0
- package/payload/server/src/files.ts +528 -0
- package/payload/server/src/github-access.ts +52 -0
- package/payload/server/src/health.ts +117 -0
- package/payload/server/src/held-dir.ts +96 -0
- package/payload/server/src/hooks.ts +141 -0
- package/payload/server/src/http.ts +207 -0
- package/payload/server/src/index.ts +139 -0
- package/payload/server/src/install-type.ts +11 -0
- package/payload/server/src/instructions.ts +204 -0
- package/payload/server/src/isolation.ts +234 -0
- package/payload/server/src/legacy-upgrade.ts +668 -0
- package/payload/server/src/limits.ts +44 -0
- package/payload/server/src/lock.ts +75 -0
- package/payload/server/src/log.ts +137 -0
- package/payload/server/src/managed-clis.ts +65 -0
- package/payload/server/src/mcp-client.ts +229 -0
- package/payload/server/src/notify.ts +193 -0
- package/payload/server/src/oauth-connections.ts +650 -0
- package/payload/server/src/oauth.ts +311 -0
- package/payload/server/src/outbound.ts +142 -0
- package/payload/server/src/portable.ts +1669 -0
- package/payload/server/src/prompt.ts +676 -0
- package/payload/server/src/providers/api-brain.ts +612 -0
- package/payload/server/src/providers/api-history.ts +131 -0
- package/payload/server/src/providers/api-tools.ts +481 -0
- package/payload/server/src/providers/api-wire.ts +690 -0
- package/payload/server/src/providers/claude-cli.ts +620 -0
- package/payload/server/src/providers/cli-check.ts +265 -0
- package/payload/server/src/providers/cli-turn.ts +20 -0
- package/payload/server/src/providers/codex-cli.ts +552 -0
- package/payload/server/src/providers/codex-workspace.ts +61 -0
- package/payload/server/src/providers/failures.ts +143 -0
- package/payload/server/src/providers/index.ts +131 -0
- package/payload/server/src/providers/mcp-stdio.ts +147 -0
- package/payload/server/src/providers/mock.ts +94 -0
- package/payload/server/src/providers/process.ts +268 -0
- package/payload/server/src/providers/readiness.ts +43 -0
- package/payload/server/src/providers/sse.ts +73 -0
- package/payload/server/src/providers/types.ts +173 -0
- package/payload/server/src/rates.ts +128 -0
- package/payload/server/src/reactions.ts +266 -0
- package/payload/server/src/recent-logs.ts +489 -0
- package/payload/server/src/rehearsal.ts +593 -0
- package/payload/server/src/rehearsals.ts +303 -0
- package/payload/server/src/replay.ts +165 -0
- package/payload/server/src/sandbox.ts +767 -0
- package/payload/server/src/schedule.ts +214 -0
- package/payload/server/src/scheduler.ts +24 -0
- package/payload/server/src/schema.ts +882 -0
- package/payload/server/src/secrets.ts +46 -0
- package/payload/server/src/setup.ts +356 -0
- package/payload/server/src/skill-packages.ts +363 -0
- package/payload/server/src/skills.ts +527 -0
- package/payload/server/src/spend.ts +255 -0
- package/payload/server/src/ssrf.ts +126 -0
- package/payload/server/src/starter-gate.ts +168 -0
- package/payload/server/src/starters.ts +610 -0
- package/payload/server/src/storage.ts +32 -0
- package/payload/server/src/store.ts +3087 -0
- package/payload/server/src/tailnet.ts +132 -0
- package/payload/server/src/templates.ts +454 -0
- package/payload/server/src/trash.ts +772 -0
- package/payload/server/src/util.ts +97 -0
- package/payload/server/src/webhooks.ts +216 -0
- package/payload/server/src/work-feed.ts +473 -0
- package/payload/shared/app-version.d.mts +1 -0
- package/payload/shared/app-version.mjs +3 -0
- package/payload/shared/boton.ts +323 -0
- package/payload/shared/brand.ts +107 -0
- package/payload/shared/cards.ts +427 -0
- package/payload/shared/effort.ts +91 -0
- package/payload/shared/files.ts +104 -0
- package/payload/shared/handoff.ts +97 -0
- package/payload/shared/install-commands.ts +7 -0
- package/payload/shared/legacy-names.d.mts +35 -0
- package/payload/shared/legacy-names.mjs +244 -0
- package/payload/shared/live-view.ts +94 -0
- package/payload/shared/look-critter.ts +189 -0
- package/payload/shared/look-tater.ts +127 -0
- package/payload/shared/look.ts +200 -0
- package/payload/shared/reactions.ts +43 -0
- package/payload/shared/seeded.ts +34 -0
- package/payload/shared/services.ts +90 -0
- package/payload/shared/types.ts +3016 -0
- package/payload/shared/util.ts +68 -0
- package/payload/shared/versions.ts +44 -0
- package/payload/shared/wake.ts +164 -0
- package/payload/web/dist/apple-touch-icon.png +0 -0
- package/payload/web/dist/assets/Icons-nytVAa9g.js +1 -0
- package/payload/web/dist/assets/NewBotDialog-DXr2XhDP.js +1 -0
- package/payload/web/dist/assets/PairScreen-CMcJO7cQ.js +1 -0
- package/payload/web/dist/assets/Panel-Bvqz3I3m.js +4 -0
- package/payload/web/dist/assets/ReactionPicker-B0SKsjiy.js +1 -0
- package/payload/web/dist/assets/SettingRows-CvLomtuo.js +4 -0
- package/payload/web/dist/assets/SettingsDialog-CQ41LV-P.js +8 -0
- package/payload/web/dist/assets/Setup-C-3P7Mtn.js +1 -0
- package/payload/web/dist/assets/Skills-DXMH69kH.js +1 -0
- package/payload/web/dist/assets/StarterSetup-Ce2V1_Cs.js +5 -0
- package/payload/web/dist/assets/Steps-CKtB3GAM.js +1 -0
- package/payload/web/dist/assets/emoji-catalog-DfpPz8qw.js +540 -0
- package/payload/web/dist/assets/index-BEU7FDxV.css +1 -0
- package/payload/web/dist/assets/index-Dhwn-rf0.js +3 -0
- package/payload/web/dist/assets/react-C1iE8mnY.js +9 -0
- package/payload/web/dist/assets/reactions-rw0YeCmA.js +1 -0
- package/payload/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/payload/web/dist/assets/util-Cx5feSJu.js +1 -0
- package/payload/web/dist/assets/vendor-B3qdcjGo.js +14 -0
- package/payload/web/dist/favicon.svg +1 -0
- package/payload/web/dist/icon-192.png +0 -0
- package/payload/web/dist/icon-512.png +0 -0
- package/payload/web/dist/icon.svg +1 -0
- package/payload/web/dist/index.html +67 -0
- package/payload/web/dist/manifest.webmanifest +12 -0
- package/shared/legacy-names.d.mts +35 -0
- package/shared/legacy-names.mjs +244 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
import { earlierName } from '../../shared/legacy-names.mjs';
|
|
3
|
+
import { request as httpRequest, type IncomingMessage } from 'node:http';
|
|
4
|
+
import { request as httpsRequest } from 'node:https';
|
|
5
|
+
import type { CallbackDelivery, RoutineRun, RunCallback } from '../../shared/types.ts';
|
|
6
|
+
import type { CallbacksConfig } from './config.ts';
|
|
7
|
+
import type { EventHub } from './events.ts';
|
|
8
|
+
import { log } from './log.ts';
|
|
9
|
+
import { allowance, guardedLookup, refusedUrl } from './ssrf.ts';
|
|
10
|
+
import { HttpError, PENDING_CALLBACK_LIMIT, type Store } from './store.ts';
|
|
11
|
+
import { nowIso } from './util.ts';
|
|
12
|
+
|
|
13
|
+
type Attempt = { ok: boolean; status: number | null; error: string | null; retry: boolean };
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* POSTs each finished run's result to its routine's callbackUrl, signed with the webhook key.
|
|
17
|
+
*
|
|
18
|
+
* The store records a run's callback as pending in the same transaction that finishes the run, so what a stop or a
|
|
19
|
+
* crash cuts off is still pending at the next start, and resumePending sends it again. This only sends: a run.upserted
|
|
20
|
+
* carrying a pending callback starts a send, and the outcome replaces pending after the last attempt. Delivery is
|
|
21
|
+
* at-least-once, so a receiver may get a result twice: X-Boton-Run tells them apart.
|
|
22
|
+
*/
|
|
23
|
+
export class Callbacks {
|
|
24
|
+
#cfg: CallbacksConfig;
|
|
25
|
+
#store: Store;
|
|
26
|
+
/** Runs whose callback is being sent, so a run.upserted meanwhile doesn't send it again. */
|
|
27
|
+
#sending = new Set<string>();
|
|
28
|
+
#stopped = false;
|
|
29
|
+
#inflight = new Set<AbortController>();
|
|
30
|
+
#waits = new Map<NodeJS.Timeout, () => void>();
|
|
31
|
+
#unsubscribe: () => void;
|
|
32
|
+
|
|
33
|
+
constructor(cfg: CallbacksConfig, store: Store, hub: EventHub) {
|
|
34
|
+
this.#cfg = cfg;
|
|
35
|
+
this.#store = store;
|
|
36
|
+
this.#unsubscribe = hub.subscribe((ev) => {
|
|
37
|
+
if (ev.type === 'run.upserted' && ev.run.meta?.callback?.pending) this.#take(ev.run);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** On shutdown, before the database closes. */
|
|
42
|
+
stop(): void {
|
|
43
|
+
this.#stopped = true;
|
|
44
|
+
this.#unsubscribe();
|
|
45
|
+
for (const [t, wake] of this.#waits) {
|
|
46
|
+
clearTimeout(t);
|
|
47
|
+
wake();
|
|
48
|
+
}
|
|
49
|
+
this.#waits.clear();
|
|
50
|
+
for (const c of this.#inflight) c.abort();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** At start: send the callbacks the last stop or crash cut off, to the routine's callback URL now. */
|
|
54
|
+
resumePending(): number {
|
|
55
|
+
const runs = this.#store.pendingCallbackRuns();
|
|
56
|
+
for (const run of runs) {
|
|
57
|
+
if (this.#sending.has(run.id)) continue;
|
|
58
|
+
log.info('callback resumed after a restart', { runId: run.id, routineId: run.routineId });
|
|
59
|
+
this.#take(run);
|
|
60
|
+
}
|
|
61
|
+
return runs.length;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A run whose callback the store has recorded as pending. */
|
|
65
|
+
#take(run: RoutineRun) {
|
|
66
|
+
// A test run is a dry run: its result is not for the receiver, which may act on it.
|
|
67
|
+
if (this.#stopped || run.trigger === 'test' || this.#sending.has(run.id)) return;
|
|
68
|
+
// Read now: a run failed because its routine is being deleted still has its routine at this point.
|
|
69
|
+
const target = this.#store.callbackTarget(run.routineId);
|
|
70
|
+
if (!target) {
|
|
71
|
+
this.#record(run, { url: run.meta!.callback!.url, ok: false, status: null, error: 'Not sent: the routine no longer has a callback URL', attempts: 0, at: nowIso() });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
this.#send(run, target);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The owner sends a run's result again after its callback failed. */
|
|
78
|
+
resend(runId: string): RoutineRun {
|
|
79
|
+
if (this.#stopped) throw new HttpError(503, 'The server is stopping');
|
|
80
|
+
const run = this.#store.getRun(runId);
|
|
81
|
+
const last = run.meta?.callback;
|
|
82
|
+
if (run.trigger === 'test') throw new HttpError(409, 'A test run sends no callback');
|
|
83
|
+
if (!last) throw new HttpError(409, 'This run sent no callback');
|
|
84
|
+
if (last.pending || this.#sending.has(run.id)) throw new HttpError(409, 'This run\'s result is being sent now');
|
|
85
|
+
if (last.ok) throw new HttpError(409, 'This run\'s result was already sent');
|
|
86
|
+
const target = this.#store.callbackTarget(run.routineId);
|
|
87
|
+
if (!target) throw new HttpError(409, 'The routine no longer has a callback URL');
|
|
88
|
+
if (this.#store.pendingCallbackCount(run.routineId) >= PENDING_CALLBACK_LIMIT) {
|
|
89
|
+
throw new HttpError(409, `${PENDING_CALLBACK_LIMIT} results are already waiting for this callback URL; send it again once they are done`);
|
|
90
|
+
}
|
|
91
|
+
// Claimed in memory first, so the run.upserted this write publishes doesn't start a second send.
|
|
92
|
+
this.#sending.add(run.id);
|
|
93
|
+
let marked: RoutineRun | null;
|
|
94
|
+
try {
|
|
95
|
+
// Recorded before the first POST: what is still pending at the next start was cut off.
|
|
96
|
+
marked = this.#store.setRunCallback(run.id, { url: target.url, ok: false, status: null, error: null, attempts: 0, at: nowIso(), pending: true });
|
|
97
|
+
} catch (err) {
|
|
98
|
+
this.#sending.delete(run.id);
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
if (!marked) {
|
|
102
|
+
this.#sending.delete(run.id);
|
|
103
|
+
throw new HttpError(404, `No run with id ${run.id}`);
|
|
104
|
+
}
|
|
105
|
+
void this.#deliver(marked, target).finally(() => this.#sending.delete(run.id));
|
|
106
|
+
return marked;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
#send(run: RoutineRun, target: { url: string; key: string }) {
|
|
110
|
+
this.#sending.add(run.id);
|
|
111
|
+
void this.#deliver(run, target).finally(() => this.#sending.delete(run.id));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
#record(run: RoutineRun, delivery: CallbackDelivery): RoutineRun | null {
|
|
115
|
+
try {
|
|
116
|
+
return this.#store.setRunCallback(run.id, delivery);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
log.warn('could not record a callback for a run', { runId: run.id, err });
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async #deliver(run: RoutineRun, target: { url: string; key: string }) {
|
|
124
|
+
const body = JSON.stringify({
|
|
125
|
+
runId: run.id,
|
|
126
|
+
routineId: run.routineId,
|
|
127
|
+
botId: run.botId,
|
|
128
|
+
trigger: run.trigger,
|
|
129
|
+
status: run.status,
|
|
130
|
+
quiet: run.quiet,
|
|
131
|
+
reply: run.reply,
|
|
132
|
+
error: run.error,
|
|
133
|
+
startedAt: run.startedAt,
|
|
134
|
+
finishedAt: run.finishedAt,
|
|
135
|
+
attempts: run.attempts,
|
|
136
|
+
cancelled: !!run.meta?.cancelled,
|
|
137
|
+
timedOut: !!run.meta?.timedOut,
|
|
138
|
+
} satisfies RunCallback);
|
|
139
|
+
const headers = {
|
|
140
|
+
'content-type': 'application/json',
|
|
141
|
+
'user-agent': 'Botons-Callback/1',
|
|
142
|
+
'x-boton-signature': `sha256=${createHmac('sha256', target.key).update(body).digest('hex')}`,
|
|
143
|
+
'x-boton-run': run.id,
|
|
144
|
+
};
|
|
145
|
+
// And under their deprecated names, for one release, so receivers set up for them keep working (shared/legacy-names.mjs).
|
|
146
|
+
for (const k of ['x-boton-signature', 'x-boton-run'] as const) (headers as Record<string, string>)[earlierName(k)!] = headers[k];
|
|
147
|
+
let attempts = 0;
|
|
148
|
+
let last: Attempt;
|
|
149
|
+
for (;;) {
|
|
150
|
+
attempts++;
|
|
151
|
+
last = await this.#attempt(target.url, headers, body);
|
|
152
|
+
// Stopping, a callback not yet sent stays pending, for the next start.
|
|
153
|
+
if (this.#stopped && !last.ok) return;
|
|
154
|
+
if (last.ok || !last.retry || attempts > this.#cfg.retryDelaysMs.length) break;
|
|
155
|
+
await this.#sleep(this.#cfg.retryDelaysMs[attempts - 1]);
|
|
156
|
+
if (this.#stopped) return;
|
|
157
|
+
}
|
|
158
|
+
const fields = { runId: run.id, routineId: run.routineId, attempts, status: last.status ?? undefined };
|
|
159
|
+
if (last.ok) log.info('callback sent', fields);
|
|
160
|
+
else log.warn('callback failed', { ...fields, error: last.error });
|
|
161
|
+
this.#record(run, { url: target.url, ok: last.ok, status: last.status, error: last.error, attempts, at: nowIso() });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#sleep(ms: number): Promise<void> {
|
|
165
|
+
return new Promise((resolve) => {
|
|
166
|
+
const t = setTimeout(() => {
|
|
167
|
+
this.#waits.delete(t);
|
|
168
|
+
resolve();
|
|
169
|
+
}, ms);
|
|
170
|
+
t.unref();
|
|
171
|
+
this.#waits.set(t, resolve);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** One POST. Never throws. */
|
|
176
|
+
#attempt(url: string, headers: Record<string, string>, body: string): Promise<Attempt> {
|
|
177
|
+
const allow = allowance(this.#cfg.allow);
|
|
178
|
+
const refused = refusedUrl(url, allow);
|
|
179
|
+
if (refused) return Promise.resolve({ ok: false, status: null, error: refused, retry: false });
|
|
180
|
+
const u = new URL(url);
|
|
181
|
+
const controller = new AbortController();
|
|
182
|
+
this.#inflight.add(controller);
|
|
183
|
+
const timer = setTimeout(() => controller.abort(new Error(`no answer within ${this.#cfg.timeoutMs} ms`)), this.#cfg.timeoutMs);
|
|
184
|
+
return new Promise<Attempt>((resolve) => {
|
|
185
|
+
const request = u.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
186
|
+
// guardedLookup checks the addresses the name resolves to as it connects.
|
|
187
|
+
const req = request(u, { method: 'POST', headers: { ...headers, 'content-length': String(Buffer.byteLength(body)) }, lookup: guardedLookup(allow), signal: controller.signal }, (res: IncomingMessage) => {
|
|
188
|
+
res.resume();
|
|
189
|
+
res.on('end', () => {
|
|
190
|
+
const status = res.statusCode ?? 0;
|
|
191
|
+
const ok = status >= 200 && status < 300;
|
|
192
|
+
const redirect = status >= 300 && status < 400;
|
|
193
|
+
resolve({
|
|
194
|
+
ok,
|
|
195
|
+
status,
|
|
196
|
+
error: ok ? null : redirect ? `HTTP ${status}: redirects are not followed` : `HTTP ${status}`,
|
|
197
|
+
retry: status === 408 || status === 429 || status >= 500,
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
res.on('error', (err) => resolve({ ok: false, status: res.statusCode ?? null, error: describe(err), retry: true }));
|
|
201
|
+
res.on('close', () => resolve({ ok: false, status: res.statusCode ?? null, error: 'The connection closed before the answer ended', retry: true }));
|
|
202
|
+
});
|
|
203
|
+
req.on('error', (err: NodeJS.ErrnoException) => {
|
|
204
|
+
const reason = controller.signal.aborted ? controller.signal.reason : err;
|
|
205
|
+
resolve({ ok: false, status: null, error: describe(reason), retry: err.code !== 'EREFUSEDADDRESS' });
|
|
206
|
+
});
|
|
207
|
+
req.end(body);
|
|
208
|
+
}).finally(() => {
|
|
209
|
+
clearTimeout(timer);
|
|
210
|
+
this.#inflight.delete(controller);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function describe(err: unknown): string {
|
|
216
|
+
const e = err as { code?: string; message?: string };
|
|
217
|
+
return e?.message ?? e?.code ?? String(err);
|
|
218
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Bot, BotonCapabilities, BotonConnector, Routine } from '../../shared/types.ts';
|
|
2
|
+
import { splitRules } from './ask-rules.ts';
|
|
3
|
+
|
|
4
|
+
/** The Boton's brain, as far as Ask first goes: the engine only hands rules to a provider that enforces them. */
|
|
5
|
+
export interface BrainSupport {
|
|
6
|
+
label: string;
|
|
7
|
+
askFirst: boolean;
|
|
8
|
+
/** Botons runs its tools itself (an API key brain): its own sandbox, not a CLI's. */
|
|
9
|
+
serverTools?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* What a Boton may do, worded for its owner. It reads the same fields the
|
|
14
|
+
* engine enforces (access, connectors, skills, Ask first and Always allow
|
|
15
|
+
* rules, routines), so it never grants anything itself. Ask first rules are
|
|
16
|
+
* split the way the engine splits them: only a brain that pauses on them gets
|
|
17
|
+
* any, and the ones "Always allow" covers don't pause (except in webhook runs).
|
|
18
|
+
*/
|
|
19
|
+
export function botonCapabilities(bot: Bot, connectors: BotonConnector[], routines: Routine[], ownerName: string, brain: BrainSupport, browser: { enabled: boolean; sites: string[] } | null = null): BotonCapabilities {
|
|
20
|
+
const owner = ownerName || 'you';
|
|
21
|
+
const mine = connectors.filter((c) => c.state === 'yours');
|
|
22
|
+
const requestable = connectors.filter((c) => c.state === 'available');
|
|
23
|
+
const hooks = routines.filter((r) => r.webhook);
|
|
24
|
+
const fullHooks = hooks.filter((r) => !r.webhookWorkspace);
|
|
25
|
+
const callbacks = hooks.filter((r) => r.webhook?.callbackUrl);
|
|
26
|
+
const summary: string[] = [];
|
|
27
|
+
if (bot.access === 'host') {
|
|
28
|
+
summary.push(`Full computer access: it runs as the server's user and can read and change everything that user can, including the database, every workspace, the CLI logins and the server's environment.`);
|
|
29
|
+
} else {
|
|
30
|
+
summary.push(`Workspace access: it works in its own folder (and the shared one) inside ${brain.serverTools ? 'its sandbox' : "the CLI's sandbox"}, and can't read other workspaces, the data folder, the CLI logins or common credential folders.`);
|
|
31
|
+
}
|
|
32
|
+
if (brain.serverTools) {
|
|
33
|
+
summary.push(
|
|
34
|
+
`Its brain is an API key (${brain.label}): Botons runs its tools itself.${bot.access === 'host' ? ' With Full computer access they run as the server’s user, outside any sandbox.' : ' Its commands and file edits run in the OS sandbox (bubblewrap, with a filter that refuses Unix sockets) with no network; where that sandbox can’t be made, its turns don’t run.'} Its \`botons\` commands reach the Botons server as the CLIs' do. Botons’ tool loop has no step that pauses for ${owner}, so Ask first rules don’t apply to it. Each request’s tokens and estimated cost are counted, and its spending caps apply.`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
if (fullHooks.length) {
|
|
38
|
+
summary.push(`Webhooks can start ${fullHooks.length === 1 ? `"${fullHooks[0].name}"` : `${fullHooks.length} routines`} with Full computer access, on a payload from outside.`);
|
|
39
|
+
} else if (hooks.length) {
|
|
40
|
+
summary.push(`Webhooks can start ${hooks.length === 1 ? `"${hooks[0].name}"` : `${hooks.length} routines`}; those runs are held to Workspace access.`);
|
|
41
|
+
}
|
|
42
|
+
if (callbacks.length) summary.push(`${callbacks.length === 1 ? 'One routine sends' : `${callbacks.length} routines send`} run results to a callback URL you set.`);
|
|
43
|
+
if (browser?.enabled && browser.sites.length) {
|
|
44
|
+
summary.push(
|
|
45
|
+
`Browser: its own managed browser, reaching ${browser.sites.join(', ')}. It reads on its own (it opens and reads pages and takes screenshots) and clicks and types, but sends nothing a page would send (a form, a POST) unless you allow that exact request on a card, once; it never types passwords, codes or card numbers, signs in itself (you can sign its browser in yourself, by taking it over), downloads or uploads. No access to this computer's screen or your own browser profile. Not in webhook runs.`,
|
|
46
|
+
);
|
|
47
|
+
} else if (browser?.enabled) summary.push('Browser: on, but no site is listed yet, so it reaches nothing.');
|
|
48
|
+
const on = mine.filter((c) => !c.off);
|
|
49
|
+
const off = mine.filter((c) => c.off);
|
|
50
|
+
if (on.length) summary.push(`Connectors: ${on.map((c) => c.name).join(', ')}.`);
|
|
51
|
+
if (off.length) summary.push(`Turned off by ${owner}, so its turns go without ${off.length === 1 ? 'it' : 'them'} until ${off.length === 1 ? "it is" : 'they are'} on again (still granted): ${off.map((c) => c.name).join(', ')}.`);
|
|
52
|
+
if (!mine.length) summary.push('No connectors.');
|
|
53
|
+
if (requestable.length) {
|
|
54
|
+
const now = requestable.filter((c) => c.grant === 'immediate');
|
|
55
|
+
summary.push(
|
|
56
|
+
`It may ask for ${requestable.map((c) => c.name).join(', ')}${now.length ? `; ${now.map((c) => c.name).join(', ')} ${now.length === 1 ? 'is' : 'are'} granted without a card (except to webhook runs)` : `; ${owner} approves each request on a card`}.`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
const split = splitRules(bot.askFirst, bot.alwaysAllow, false);
|
|
60
|
+
const askFirst = brain.askFirst ? { enforced: split.ask, alwaysAllowed: split.allow, inactive: [] as string[] } : { enforced: [], alwaysAllowed: [], inactive: bot.askFirst };
|
|
61
|
+
if (askFirst.enforced.length) summary.push(`Asks ${owner} first before: ${askFirst.enforced.join(', ')}.`);
|
|
62
|
+
if (askFirst.alwaysAllowed.length) summary.push(`Ask first rules ${owner} always allows, so they pause only webhook runs: ${askFirst.alwaysAllowed.join(', ')}.`);
|
|
63
|
+
if (askFirst.inactive.length) {
|
|
64
|
+
summary.push(`Not enforced: its brain (${brain.label}) doesn't pause on Ask first rules, so these saved rules do nothing until it runs on Claude Code (the local CLI): ${askFirst.inactive.join(', ')}.`);
|
|
65
|
+
}
|
|
66
|
+
if (bot.alwaysAllow.length) summary.push(`Always allowed without asking: ${bot.alwaysAllow.join(', ')}.`);
|
|
67
|
+
summary.push(bot.skills === null ? 'Can use every skill in the library.' : bot.skills.length ? `Skills: ${bot.skills.join(', ')}.` : 'No skills.');
|
|
68
|
+
summary.push(`It can message other Botons (rate limited), notify ${owner}, and ask ${owner} for approval; it can never approve its own requests or pair a device.`);
|
|
69
|
+
if (bot.paused) summary.unshift('Paused: it runs nothing until resumed.');
|
|
70
|
+
return {
|
|
71
|
+
botId: bot.id,
|
|
72
|
+
access: bot.access,
|
|
73
|
+
paused: bot.paused,
|
|
74
|
+
summary,
|
|
75
|
+
connectors: mine.map((c) => ({ id: c.id, name: c.name, usable: c.usable, off: c.off })),
|
|
76
|
+
requestable: requestable.map((c) => ({ id: c.id, name: c.name, grant: c.grant ?? 'approval' })),
|
|
77
|
+
skills: bot.skills,
|
|
78
|
+
askFirst,
|
|
79
|
+
alwaysAllow: bot.alwaysAllow,
|
|
80
|
+
routines: routines.map((r) => ({
|
|
81
|
+
id: r.id,
|
|
82
|
+
name: r.name,
|
|
83
|
+
enabled: r.enabled,
|
|
84
|
+
schedule: r.schedule !== null,
|
|
85
|
+
webhook: r.webhook !== null,
|
|
86
|
+
webhookFullAccess: r.webhook !== null && !r.webhookWorkspace,
|
|
87
|
+
callback: !!r.webhook?.callbackUrl,
|
|
88
|
+
})),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import {
|
|
2
|
+
answerText,
|
|
3
|
+
type CardAnswer,
|
|
4
|
+
type CardBlock,
|
|
5
|
+
type CardDescriptor,
|
|
6
|
+
CardError,
|
|
7
|
+
interactiveBlock,
|
|
8
|
+
parseAnswer,
|
|
9
|
+
parseCard,
|
|
10
|
+
type TranscriptCard,
|
|
11
|
+
} from '../../shared/cards.ts';
|
|
12
|
+
import type { ApprovalOrigin, Message } from '../../shared/types.ts';
|
|
13
|
+
import type { Audit } from './audit.ts';
|
|
14
|
+
import type { Principal } from './auth.ts';
|
|
15
|
+
import { formatDuration, parseDuration } from './config.ts';
|
|
16
|
+
import type { Connectors } from './connectors.ts';
|
|
17
|
+
import { fileRef, resolveInWorkspace } from './files.ts';
|
|
18
|
+
import { RunLimiter } from './limits.ts';
|
|
19
|
+
import { log } from './log.ts';
|
|
20
|
+
import { HttpError, type Store } from './store.ts';
|
|
21
|
+
import { nowIso } from './util.ts';
|
|
22
|
+
|
|
23
|
+
/*
|
|
24
|
+
* Transcript cards (shared/cards.ts): a Boton posts one with `botons card post`, the owner answers it in the chat.
|
|
25
|
+
* A card is a chat event whose meta holds the checked card and its state (Message.meta.card); the message is its
|
|
26
|
+
* one record, so there is no other table to keep in step. Every answer is checked here against the card as saved:
|
|
27
|
+
* the client sends only which option, which values or which button, never what it means. A connector card's button
|
|
28
|
+
* is the same grant as the connector's own request (on request, at the policy generation the card was posted
|
|
29
|
+
* under, checked again at the click) and is audited as the same permission change Settings records.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const TTL_MS = 24 * 3_600_000;
|
|
33
|
+
const TTL_MIN_MS = 60_000;
|
|
34
|
+
const TTL_MAX_MS = 7 * 24 * 3_600_000;
|
|
35
|
+
const PENDING_MAX = 10;
|
|
36
|
+
const POSTS_PER_HOUR = 30;
|
|
37
|
+
|
|
38
|
+
const NO_TURN: ApprovalOrigin = { trigger: 'chat', turnId: null, routineId: null, routineName: null, runId: null, sandboxed: false, askedBy: [] };
|
|
39
|
+
|
|
40
|
+
const pastTense: Record<TranscriptCard['status'], string> = {
|
|
41
|
+
shown: 'shown (it has nothing to answer)',
|
|
42
|
+
pending: 'waiting',
|
|
43
|
+
answered: 'answered',
|
|
44
|
+
declined: 'declined',
|
|
45
|
+
expired: 'expired',
|
|
46
|
+
refused: 'closed',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const bad = (err: unknown): never => {
|
|
50
|
+
if (err instanceof CardError) throw new HttpError(400, err.message);
|
|
51
|
+
throw err;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export class Cards {
|
|
55
|
+
#store: Store;
|
|
56
|
+
#connectors: Connectors;
|
|
57
|
+
#audit: Audit;
|
|
58
|
+
#wake: (botId: string) => void;
|
|
59
|
+
#origin: (botId: string) => ApprovalOrigin | null;
|
|
60
|
+
/** In memory: a restart resets it. */
|
|
61
|
+
#limiter = new RunLimiter(POSTS_PER_HOUR, Number.MAX_SAFE_INTEGER);
|
|
62
|
+
|
|
63
|
+
constructor(opts: { store: Store; connectors: Connectors; audit: Audit; wake: (botId: string) => void; origin: (botId: string) => ApprovalOrigin | null }) {
|
|
64
|
+
this.#store = opts.store;
|
|
65
|
+
this.#connectors = opts.connectors;
|
|
66
|
+
this.#audit = opts.audit;
|
|
67
|
+
this.#wake = opts.wake;
|
|
68
|
+
this.#origin = opts.origin;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The Boton a card belongs to, or null (for route access: a Boton reads only its own). */
|
|
72
|
+
owner(id: string): string | null {
|
|
73
|
+
const m = this.#store.findMessage(id);
|
|
74
|
+
return m?.meta?.kind === 'card.posted' ? m.botId : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The card's message as it stands now; one past its expiry is closed as expired first. */
|
|
78
|
+
get(id: string, botId?: string): Message {
|
|
79
|
+
const m = this.#store.findMessage(id);
|
|
80
|
+
if (!m || m.role !== 'event' || m.meta?.kind !== 'card.posted' || !m.meta.card || (botId && m.botId !== botId)) throw new HttpError(404, `No card ${id}`);
|
|
81
|
+
const card = m.meta.card;
|
|
82
|
+
if (card.status === 'pending' && card.expiresAt <= nowIso()) return this.#store.closeCard(m.id, { ...card, status: 'expired' }) ?? this.get(id, botId);
|
|
83
|
+
return m;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** `botons card post`, from Boton `botId`'s turn token. Body: `{ card, expiresIn? }`. */
|
|
87
|
+
async post(botId: string, body: unknown, opts: { sandboxed: boolean }): Promise<{ message: Message; duplicate: boolean }> {
|
|
88
|
+
const bot = this.#store.getBot(botId);
|
|
89
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body)) throw new HttpError(400, 'The body must be {"card": {...}, "expiresIn": "1d"}');
|
|
90
|
+
const extra = Object.keys(body).find((k) => k !== 'card' && k !== 'expiresIn');
|
|
91
|
+
if (extra) throw new HttpError(400, `Unknown field "${extra}" (the body has card and expiresIn)`);
|
|
92
|
+
const { card: raw, expiresIn } = body as { card?: unknown; expiresIn?: unknown };
|
|
93
|
+
let descriptor: CardDescriptor;
|
|
94
|
+
try {
|
|
95
|
+
descriptor = parseCard(raw);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
return bad(err);
|
|
98
|
+
}
|
|
99
|
+
const ttl = expiresIn === undefined || expiresIn === null || expiresIn === '' ? TTL_MS : typeof expiresIn === 'string' ? parseDuration(expiresIn) : Number.NaN;
|
|
100
|
+
if (Number.isNaN(ttl)) throw new HttpError(400, 'expiresIn must be a duration such as "30m", "2h" or "3d"');
|
|
101
|
+
if (ttl < TTL_MIN_MS || ttl > TTL_MAX_MS) throw new HttpError(400, `expiresIn must be between ${formatDuration(TTL_MIN_MS)} and ${formatDuration(TTL_MAX_MS)}`);
|
|
102
|
+
|
|
103
|
+
const turn = this.#origin(botId) ?? NO_TURN;
|
|
104
|
+
const from = { trigger: turn.trigger, routineId: turn.routineId, routineName: turn.routineName, runId: turn.runId, sandboxed: opts.sandboxed || turn.sandboxed };
|
|
105
|
+
|
|
106
|
+
// Files: only regular files in the Boton's workspace, by the path the server resolved.
|
|
107
|
+
const blocks: CardBlock[] = [];
|
|
108
|
+
for (const b of descriptor.blocks) {
|
|
109
|
+
if (b.type !== 'file') {
|
|
110
|
+
blocks.push(b);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const found = await resolveInWorkspace(bot.workspace, b.path);
|
|
114
|
+
if (!found.stat.isFile()) throw new HttpError(400, `${b.path} is not a file in your workspace`);
|
|
115
|
+
blocks.push({ ...b, path: fileRef(found).path });
|
|
116
|
+
}
|
|
117
|
+
descriptor = { ...descriptor, blocks };
|
|
118
|
+
|
|
119
|
+
const ask = interactiveBlock(descriptor);
|
|
120
|
+
let connector: TranscriptCard['connector'];
|
|
121
|
+
if (ask?.type === 'connector') {
|
|
122
|
+
// A payload from outside must not ask to widen what later turns can reach.
|
|
123
|
+
if (from.sandboxed) throw new HttpError(403, "A webhook run can't post a connector card: it would widen what your later turns can reach");
|
|
124
|
+
if (bot.connections.includes(ask.connectorId)) throw new HttpError(400, `${ask.connectorId} is already one of your connectors`);
|
|
125
|
+
const problem = this.#connectors.grantProblem(bot, ask.connectorId, 'now', { usable: true });
|
|
126
|
+
if (problem) throw new HttpError(problem.startsWith('There is no') ? 404 : 403, `${problem}. See \`botons connector list\`: a card asks only for one you may ask for.`);
|
|
127
|
+
const c = this.#connectors.catalog().connectors.find((x) => x.id === ask.connectorId)!;
|
|
128
|
+
connector = { id: c.id, name: c.name, generation: this.#connectors.policyOf(c.id).generation };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const now = nowIso();
|
|
132
|
+
if (ask) {
|
|
133
|
+
const same = this.#store.samePendingCard(botId, JSON.stringify(descriptor), now);
|
|
134
|
+
if (same) return { message: same, duplicate: true };
|
|
135
|
+
if (this.#store.pendingCards(botId, now) >= PENDING_MAX) {
|
|
136
|
+
throw new HttpError(409, `You already have ${PENDING_MAX} cards waiting for ${this.#store.getSettings().ownerName}'s answer. Wait for their answers, or let them expire.`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const hit = this.#limiter.check(botId);
|
|
140
|
+
if (hit) throw new HttpError(429, `At most ${POSTS_PER_HOUR} cards an hour: try again in ${formatDuration(hit.retryAfterMs)}`);
|
|
141
|
+
this.#limiter.record(botId);
|
|
142
|
+
|
|
143
|
+
const card: TranscriptCard = {
|
|
144
|
+
descriptor,
|
|
145
|
+
status: ask ? 'pending' : 'shown',
|
|
146
|
+
expiresAt: new Date(Date.now() + ttl).toISOString(),
|
|
147
|
+
from,
|
|
148
|
+
...(connector ? { connector } : {}),
|
|
149
|
+
};
|
|
150
|
+
const run = from.routineId ? { routineId: from.routineId, ...(from.routineName ? { routineName: from.routineName } : {}), ...(from.runId ? { runId: from.runId } : {}) } : {};
|
|
151
|
+
const message = this.#store.addMessage(botId, 'event', `Posted a card: ${descriptor.title}`, { kind: 'card.posted', card, ...run });
|
|
152
|
+
log.info('card posted', { botId, cardId: message.id, ask: ask?.type ?? 'none', sandboxed: from.sandboxed || undefined, trigger: from.trigger });
|
|
153
|
+
return { message, duplicate: false };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The owner's answer (the route takes only paired devices). Returns the card's message as it is now. */
|
|
157
|
+
answer(botId: string, id: string, input: unknown, principal: Principal | null): Message {
|
|
158
|
+
const m = this.get(id, botId);
|
|
159
|
+
const card = m.meta!.card!;
|
|
160
|
+
if (card.status !== 'pending') throw new HttpError(409, `This card was already ${pastTense[card.status]}`);
|
|
161
|
+
let answer: ReturnType<typeof parseAnswer>;
|
|
162
|
+
try {
|
|
163
|
+
answer = parseAnswer(card.descriptor, input);
|
|
164
|
+
} catch (err) {
|
|
165
|
+
return bad(err);
|
|
166
|
+
}
|
|
167
|
+
const deviceId = principal?.kind === 'device' ? principal.deviceId : null;
|
|
168
|
+
const at = nowIso();
|
|
169
|
+
const record: CardAnswer = { action: answer.action, at, by: deviceId };
|
|
170
|
+
const ask = interactiveBlock(card.descriptor)!;
|
|
171
|
+
if (answer.action === 'choose' && ask.type === 'choice') record.option = ask.options.find((o) => o.id === answer.option)!;
|
|
172
|
+
if (answer.action === 'submit') record.values = answer.values;
|
|
173
|
+
|
|
174
|
+
// One transaction: the check of the connector (its policy generation, its sign-in, the Boton's brain and room),
|
|
175
|
+
// the card's one change, the grant with its turn, and both audit rows (required: either one failing undoes all of
|
|
176
|
+
// it, and the card still waits). Chat events go out and the Boton is woken only once it has committed.
|
|
177
|
+
const status = answer.action === 'decline' ? 'declined' : 'answered';
|
|
178
|
+
const done = this.#store.atomically((): { problem: string } | { closed: Message } => {
|
|
179
|
+
let granted = false;
|
|
180
|
+
if (answer.action === 'grant') {
|
|
181
|
+
const c = card.connector!;
|
|
182
|
+
const bot = this.#store.getBot(botId);
|
|
183
|
+
if (bot.connections.includes(c.id)) record.result = 'already';
|
|
184
|
+
else {
|
|
185
|
+
const problem = card.from.sandboxed ? 'A webhook run posted this card' : this.#connectors.grantProblem(bot, c.id, c.generation, { usable: true });
|
|
186
|
+
if (problem) return { problem };
|
|
187
|
+
record.result = 'granted';
|
|
188
|
+
granted = true;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const closed = this.#store.closeCard(id, { ...card, status, answer: record });
|
|
192
|
+
if (!closed) throw new HttpError(409, 'This card was already answered');
|
|
193
|
+
if (granted) {
|
|
194
|
+
const bot = this.#store.getBot(botId);
|
|
195
|
+
this.#store.setConnections(botId, [...bot.connections, card.connector!.id]);
|
|
196
|
+
// The connector's own follow-up turn (engine.ts grantedPrompt), as a request granted on a card gets.
|
|
197
|
+
this.#store.addMessage(botId, 'user', `Connector ${card.connector!.name} added`, { kind: 'connector.granted', connectorId: card.connector!.id, cardId: id }, true);
|
|
198
|
+
// The same record a change in Settings makes, so the connector's and the Boton's history show it.
|
|
199
|
+
this.#audit.require(principal, {
|
|
200
|
+
action: 'boton.permissions_changed',
|
|
201
|
+
target: { kind: 'boton', id: botId, label: bot.name },
|
|
202
|
+
details: { connectionsAdded: [card.connector!.id], via: 'card', card: id },
|
|
203
|
+
});
|
|
204
|
+
} else {
|
|
205
|
+
// A pending user message: it starts the Boton's turn with the answer, and waits out a pause or a usage limit.
|
|
206
|
+
this.#store.addMessage(botId, 'user', answerText(card.descriptor, record), { kind: 'card.answered', cardId: id }, true);
|
|
207
|
+
}
|
|
208
|
+
this.#audited(principal, m, card, answer.action, record.result ?? status, record);
|
|
209
|
+
return { closed };
|
|
210
|
+
});
|
|
211
|
+
if ('problem' in done) {
|
|
212
|
+
// Closed for good, with the reason, and nothing given: no grant, no turn. Its audit row is required too: if it
|
|
213
|
+
// can't be written the card still waits (and the answer fails), so a refusal is never unrecorded.
|
|
214
|
+
this.#store.atomically(() => {
|
|
215
|
+
if (!this.#store.closeCard(id, { ...card, status: 'refused', reason: done.problem })) throw new HttpError(409, 'This card was already answered');
|
|
216
|
+
this.#audited(principal, m, card, 'grant', 'refused');
|
|
217
|
+
});
|
|
218
|
+
throw new HttpError(409, `${done.problem}, so nothing was given and the card is closed.`);
|
|
219
|
+
}
|
|
220
|
+
log.info('card answered', { botId, cardId: id, action: answer.action, result: record.result, deviceId: deviceId ?? undefined });
|
|
221
|
+
this.#wake(botId);
|
|
222
|
+
return done.closed;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Which card, what was pressed and how it ended: never the values typed (they are in the chat, not the log).
|
|
227
|
+
* Required, inside the answer's transaction: should it fail, the card is not answered, refused or granted.
|
|
228
|
+
*/
|
|
229
|
+
#audited(principal: Principal | null, m: Message, card: TranscriptCard, action: string, result: string, answer?: CardAnswer) {
|
|
230
|
+
this.#audit.require(principal, {
|
|
231
|
+
action: 'card.answered',
|
|
232
|
+
target: { kind: 'card', id: m.id, label: card.descriptor.title },
|
|
233
|
+
details: {
|
|
234
|
+
boton: m.botId,
|
|
235
|
+
answer: action,
|
|
236
|
+
result,
|
|
237
|
+
...(answer?.option ? { option: answer.option.id } : {}),
|
|
238
|
+
...(answer?.values ? { fields: Object.keys(answer.values) } : {}),
|
|
239
|
+
...(card.connector ? { connector: card.connector.id, connectorName: card.connector.name } : {}),
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { VERSION } from '../../../shared/app-version.mjs';
|
|
2
|
+
// `botons mcp`: an MCP server (JSON-RPC over stdio, one message per line) whose one tool, approve, is
|
|
3
|
+
// Claude Code's --permission-prompt-tool for a turn with Ask first rules. It asks the Botons server with
|
|
4
|
+
// the turn's token, then polls until the owner decides or the call's wait ends.
|
|
5
|
+
import { createInterface } from 'node:readline';
|
|
6
|
+
import type { ToolCallVerdict } from '../../../shared/types.ts';
|
|
7
|
+
|
|
8
|
+
/** The MCP version this speaks when the client names none. */
|
|
9
|
+
const PROTOCOL = '2025-06-18';
|
|
10
|
+
/** Polls that may fail in a row (a server restarting, say) before the call is denied. */
|
|
11
|
+
const POLL_FAILURES_MAX = 5;
|
|
12
|
+
|
|
13
|
+
const APPROVE_TOOL = {
|
|
14
|
+
name: 'approve',
|
|
15
|
+
description: "Asks the Boton's owner whether a tool call one of their Ask first rules covers may run. Called by Claude Code for permission prompts only.",
|
|
16
|
+
inputSchema: {
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: {
|
|
19
|
+
tool_name: { type: 'string' },
|
|
20
|
+
input: { type: 'object' },
|
|
21
|
+
tool_use_id: { type: 'string' },
|
|
22
|
+
},
|
|
23
|
+
required: ['tool_name', 'input'],
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type Answer = { behavior: 'allow'; updatedInput: unknown } | { behavior: 'deny'; message: string };
|
|
28
|
+
|
|
29
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
30
|
+
|
|
31
|
+
/** Serve until stdin closes. */
|
|
32
|
+
export function serveApprovals(opts: { api: string; bot: string; token: string; pollMs?: number }): Promise<void> {
|
|
33
|
+
const pollMs = opts.pollMs ?? 1000;
|
|
34
|
+
const send = (m: unknown): void => {
|
|
35
|
+
process.stdout.write(`${JSON.stringify(m)}\n`);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
async function call(method: string, path: string, body?: unknown): Promise<ToolCallVerdict> {
|
|
39
|
+
const res = await fetch(`${opts.api}${path}`, {
|
|
40
|
+
method,
|
|
41
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${opts.token}` },
|
|
42
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
43
|
+
signal: AbortSignal.timeout(30_000),
|
|
44
|
+
});
|
|
45
|
+
const text = await res.text();
|
|
46
|
+
let data: any = null;
|
|
47
|
+
try {
|
|
48
|
+
data = text ? JSON.parse(text) : null;
|
|
49
|
+
} catch {}
|
|
50
|
+
if (!res.ok) throw new Error(data?.error ?? `HTTP ${res.status}`);
|
|
51
|
+
return data as ToolCallVerdict;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function approve(args: any): Promise<Answer> {
|
|
55
|
+
const input = args?.input ?? {};
|
|
56
|
+
if (!opts.bot || !opts.token) return { behavior: 'deny', message: 'This call needs your owner\'s approval, but this turn has no Botons token to ask with, so it is denied.' };
|
|
57
|
+
try {
|
|
58
|
+
let v = await call('POST', `/api/bots/${encodeURIComponent(opts.bot)}/approvals/tool-call`, { toolName: args?.tool_name, input, toolUseId: args?.tool_use_id });
|
|
59
|
+
let failures = 0;
|
|
60
|
+
// This call's own place on the card: an allow goes to one call only.
|
|
61
|
+
const waiter = v.waiter ?? '';
|
|
62
|
+
while (v.behavior === 'wait' && v.approval) {
|
|
63
|
+
await sleep(pollMs);
|
|
64
|
+
try {
|
|
65
|
+
v = await call('GET', `/api/approvals/${encodeURIComponent(v.approval.id)}/tool-call?waiter=${encodeURIComponent(waiter)}`);
|
|
66
|
+
failures = 0;
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (++failures >= POLL_FAILURES_MAX) throw err;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (v.behavior === 'allow') return { behavior: 'allow', updatedInput: input };
|
|
72
|
+
return { behavior: 'deny', message: v.message ?? 'Denied.' };
|
|
73
|
+
} catch (err) {
|
|
74
|
+
return { behavior: 'deny', message: `This call needs your owner's approval, but the Botons server could not be asked (${(err as Error).message}), so it is denied.` };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function handle(msg: any): Promise<void> {
|
|
79
|
+
const id = msg?.id;
|
|
80
|
+
const reply = (result: unknown) => send({ jsonrpc: '2.0', id, result });
|
|
81
|
+
switch (msg?.method) {
|
|
82
|
+
case 'initialize':
|
|
83
|
+
return reply({
|
|
84
|
+
protocolVersion: typeof msg.params?.protocolVersion === 'string' ? msg.params.protocolVersion : PROTOCOL,
|
|
85
|
+
capabilities: { tools: {} },
|
|
86
|
+
serverInfo: { name: 'botons', version: VERSION },
|
|
87
|
+
});
|
|
88
|
+
case 'ping':
|
|
89
|
+
return reply({});
|
|
90
|
+
case 'tools/list':
|
|
91
|
+
return reply({ tools: [APPROVE_TOOL] });
|
|
92
|
+
case 'tools/call': {
|
|
93
|
+
if (msg.params?.name !== APPROVE_TOOL.name) {
|
|
94
|
+
return send({ jsonrpc: '2.0', id, error: { code: -32602, message: `Unknown tool ${msg.params?.name}` } });
|
|
95
|
+
}
|
|
96
|
+
const answer = await approve(msg.params?.arguments);
|
|
97
|
+
return reply({ content: [{ type: 'text', text: JSON.stringify(answer) }] });
|
|
98
|
+
}
|
|
99
|
+
default:
|
|
100
|
+
// Notifications (no id) need no answer.
|
|
101
|
+
if (id !== undefined && id !== null) send({ jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${msg?.method}` } });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const lines = createInterface({ input: process.stdin, crlfDelay: Number.POSITIVE_INFINITY });
|
|
106
|
+
lines.on('line', (line) => {
|
|
107
|
+
if (!line.trim()) return;
|
|
108
|
+
let msg: unknown;
|
|
109
|
+
try {
|
|
110
|
+
msg = JSON.parse(line);
|
|
111
|
+
} catch {
|
|
112
|
+
send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// Answered as they finish: a call waiting for the owner holds up nothing else.
|
|
116
|
+
void handle(msg);
|
|
117
|
+
});
|
|
118
|
+
return new Promise((done) => lines.on('close', () => done()));
|
|
119
|
+
}
|