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,873 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
3
|
+
import type {
|
|
4
|
+
AccessLevel,
|
|
5
|
+
Approval,
|
|
6
|
+
ApprovalDecisionInput,
|
|
7
|
+
ApprovalOrigin,
|
|
8
|
+
ApprovalRequestInput,
|
|
9
|
+
ApprovalRequestResult,
|
|
10
|
+
ApprovalStatus,
|
|
11
|
+
ApprovalToolCall,
|
|
12
|
+
ConnectorRequestResult,
|
|
13
|
+
Message,
|
|
14
|
+
MessageMeta,
|
|
15
|
+
Skill,
|
|
16
|
+
ToolCallInput,
|
|
17
|
+
ToolCallVerdict,
|
|
18
|
+
} from '../../shared/types.ts';
|
|
19
|
+
import { callKey, describeCall, matchRule, ruleKey } from './ask-rules.ts';
|
|
20
|
+
import type { Connectors } from './connectors.ts';
|
|
21
|
+
import { formatDuration, parseDuration } from './config.ts';
|
|
22
|
+
import type { EventHub } from './events.ts';
|
|
23
|
+
import { RunLimiter } from './limits.ts';
|
|
24
|
+
import { type LogFields, log } from './log.ts';
|
|
25
|
+
import type { Notifier } from './notify.ts';
|
|
26
|
+
import type { Skills } from './skills.ts';
|
|
27
|
+
import { actionKey, flag, HttpError, type Store } from './store.ts';
|
|
28
|
+
import { newId, nowIso, type Row } from './util.ts';
|
|
29
|
+
|
|
30
|
+
const APPROVAL_TTL_MS = 24 * 3_600_000;
|
|
31
|
+
const APPROVAL_TTL_MIN_MS = 60_000;
|
|
32
|
+
const APPROVAL_TTL_MAX_MS = 7 * 24 * 3_600_000;
|
|
33
|
+
const PENDING_MAX = 10;
|
|
34
|
+
const REQUESTS_PER_HOUR = 30;
|
|
35
|
+
const TITLE_MAX = 200;
|
|
36
|
+
const DETAILS_MAX = 4000;
|
|
37
|
+
const FOLLOW_UP_MAX = 20_000;
|
|
38
|
+
const NOTE_MAX = 2000;
|
|
39
|
+
const TOOL_INPUT_MAX = 64_000;
|
|
40
|
+
|
|
41
|
+
/** The tool_key of a request the managed browser holds: never the key of a CLI's tool call (those are JSON). */
|
|
42
|
+
export const BROWSER_KEY = 'browser-op:';
|
|
43
|
+
|
|
44
|
+
/** What the approvals need of the managed browser (browser.ts): whether it still holds a request, and to hear of its decision. */
|
|
45
|
+
export interface BrowserHolds {
|
|
46
|
+
holds(approvalId: string): boolean;
|
|
47
|
+
decided(approvalId: string): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Where a request comes from when no turn of the Boton is running (a token minted by hand, say). */
|
|
51
|
+
const NO_TURN: ApprovalOrigin = { trigger: 'chat', turnId: null, routineId: null, routineName: null, runId: null, sandboxed: false, askedBy: [] };
|
|
52
|
+
|
|
53
|
+
/** A string field, trimmed; `required` ones are titles, folded to one line. */
|
|
54
|
+
function text(v: unknown, field: string, max: number, required = false): string {
|
|
55
|
+
if (v === undefined || v === null) {
|
|
56
|
+
if (required) throw new HttpError(400, `${field} is required`);
|
|
57
|
+
return '';
|
|
58
|
+
}
|
|
59
|
+
if (typeof v !== 'string') throw new HttpError(400, `${field} must be a string`);
|
|
60
|
+
const t = required ? v.replace(/\s+/g, ' ').trim() : v.trim();
|
|
61
|
+
if (required && !t) throw new HttpError(400, `${field} is required`);
|
|
62
|
+
if (t.length > max) throw new HttpError(400, `${field} is too long (max ${max} characters)`);
|
|
63
|
+
return t;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Where a request was asked: "a webhook run of routine “Triage”". */
|
|
67
|
+
function originText(from: ApprovalOrigin, names = true): string {
|
|
68
|
+
const asked = from.askedBy.length ? ` answering ${from.askedBy.join(' and ')}` : '';
|
|
69
|
+
if (from.trigger === 'chat') return names ? `a chat turn${asked}` : 'a chat turn';
|
|
70
|
+
const run = from.trigger === 'schedule' ? 'scheduled run' : `${from.trigger} run`;
|
|
71
|
+
return names && from.routineName ? `a ${run} of routine “${from.routineName}”` : `a ${run} of a routine`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Text that would open or close an <approved_action> block, defused. */
|
|
75
|
+
const escapeBlock = (text: string) => text.replace(/<(?=\s*\/?\s*approved_action)/gi, '<');
|
|
76
|
+
|
|
77
|
+
const pastTense: Record<ApprovalStatus, string> = {
|
|
78
|
+
pending: 'still waiting',
|
|
79
|
+
allowed: 'allowed',
|
|
80
|
+
denied: 'denied',
|
|
81
|
+
expired: 'expired',
|
|
82
|
+
cancelled: 'withdrawn',
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** A skill card shows the body too (every Boton would follow it), cut to fit. */
|
|
86
|
+
function skillDetails(skill: Pick<Skill, 'description' | 'body'>): string {
|
|
87
|
+
const head = `${skill.description}\n\n---\n\n`;
|
|
88
|
+
const room = DETAILS_MAX - head.length;
|
|
89
|
+
if (skill.body.length <= room) return head + skill.body;
|
|
90
|
+
const note = `\n\n… (${skill.body.length.toLocaleString('en-US')} characters in all: read the rest on the Skills screen before approving it)`;
|
|
91
|
+
return head + skill.body.slice(0, Math.max(0, room - note.length)) + note;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class Approvals {
|
|
95
|
+
#db: DatabaseSync;
|
|
96
|
+
#store: Store;
|
|
97
|
+
#notifier: Notifier;
|
|
98
|
+
#wake: (botId: string) => void;
|
|
99
|
+
#origin: (botId: string) => ApprovalOrigin | null;
|
|
100
|
+
#access: (botId: string) => AccessLevel | null;
|
|
101
|
+
#waitMs: number;
|
|
102
|
+
/** In memory: a restart resets it. */
|
|
103
|
+
#limiter = new RunLimiter(REQUESTS_PER_HOUR, Number.MAX_SAFE_INTEGER);
|
|
104
|
+
/**
|
|
105
|
+
* The one call (its `botons mcp` request) waiting on each paused tool call's card, by approval id: only it is
|
|
106
|
+
* answered with an allow. In memory: a call answered in its turn needs that turn running in this process.
|
|
107
|
+
*/
|
|
108
|
+
#waiters = new Map<string, string>();
|
|
109
|
+
skills!: Skills;
|
|
110
|
+
connectors!: Connectors;
|
|
111
|
+
browser: BrowserHolds | null = null;
|
|
112
|
+
|
|
113
|
+
constructor(opts: {
|
|
114
|
+
store: Store;
|
|
115
|
+
hub: EventHub;
|
|
116
|
+
notifier: Notifier;
|
|
117
|
+
wake: (botId: string) => void;
|
|
118
|
+
origin: (botId: string) => ApprovalOrigin | null;
|
|
119
|
+
access: (botId: string) => AccessLevel | null;
|
|
120
|
+
waitMs: number;
|
|
121
|
+
}) {
|
|
122
|
+
this.#store = opts.store;
|
|
123
|
+
this.#db = opts.store.db;
|
|
124
|
+
this.#notifier = opts.notifier;
|
|
125
|
+
this.#wake = opts.wake;
|
|
126
|
+
this.#origin = opts.origin;
|
|
127
|
+
this.#access = opts.access;
|
|
128
|
+
this.#waitMs = opts.waitMs;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
#toApproval(r: Row): Approval {
|
|
132
|
+
return {
|
|
133
|
+
id: r.id,
|
|
134
|
+
botId: r.bot_id,
|
|
135
|
+
kind: r.kind,
|
|
136
|
+
title: r.title,
|
|
137
|
+
details: r.details,
|
|
138
|
+
followUp: r.follow_up,
|
|
139
|
+
action: r.action,
|
|
140
|
+
skillName: r.skill_name,
|
|
141
|
+
connectorId: r.connector_id ?? null,
|
|
142
|
+
status: r.status,
|
|
143
|
+
always: !!r.always,
|
|
144
|
+
auto: !!r.auto,
|
|
145
|
+
note: r.note,
|
|
146
|
+
from: {
|
|
147
|
+
trigger: r.trigger,
|
|
148
|
+
turnId: r.turn_id,
|
|
149
|
+
routineId: r.routine_id,
|
|
150
|
+
routineName: r.routine_name,
|
|
151
|
+
runId: r.run_id,
|
|
152
|
+
sandboxed: !!r.sandboxed,
|
|
153
|
+
askedBy: JSON.parse(r.asked_by),
|
|
154
|
+
},
|
|
155
|
+
createdAt: r.created_at,
|
|
156
|
+
expiresAt: r.expires_at,
|
|
157
|
+
decidedAt: r.decided_at,
|
|
158
|
+
messageId: r.message_id,
|
|
159
|
+
notifications: JSON.parse(r.notifications),
|
|
160
|
+
tool: r.tool_name
|
|
161
|
+
? toolCall(r)
|
|
162
|
+
: null,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
#select(sql: string, ...args: string[]): Approval[] {
|
|
167
|
+
return this.#db
|
|
168
|
+
.prepare(sql)
|
|
169
|
+
.all(...args)
|
|
170
|
+
.map((r) => this.#toApproval(r as Row));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
find(id: string): Approval | null {
|
|
174
|
+
return this.#select('select * from approvals where id = ?', id)[0] ?? null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
get(id: string): Approval {
|
|
178
|
+
const a = this.find(id);
|
|
179
|
+
if (!a) throw new HttpError(404, `No approval request with id ${id}`);
|
|
180
|
+
return a;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The Boton a request belongs to, or null. */
|
|
184
|
+
owner(id: string): string | null {
|
|
185
|
+
return (this.#db.prepare('select bot_id from approvals where id = ?').get(id) as Row | undefined)?.bot_id ?? null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Newest first, at most 200; `status` is "all" or one status (default pending). */
|
|
189
|
+
list(opts: { botId?: string; status?: string | null }): Approval[] {
|
|
190
|
+
const status = opts.status || 'pending';
|
|
191
|
+
if (status !== 'all' && !(status in pastTense)) throw new HttpError(400, 'status must be "all" or one of pending, allowed, denied, expired, cancelled');
|
|
192
|
+
if (opts.botId) this.#store.getBot(opts.botId);
|
|
193
|
+
const where: string[] = [];
|
|
194
|
+
const args: string[] = [];
|
|
195
|
+
if (opts.botId) {
|
|
196
|
+
where.push('bot_id = ?');
|
|
197
|
+
args.push(opts.botId);
|
|
198
|
+
}
|
|
199
|
+
if (status !== 'all') {
|
|
200
|
+
where.push('status = ?');
|
|
201
|
+
args.push(status);
|
|
202
|
+
}
|
|
203
|
+
return this.#select(`select * from approvals ${where.length ? `where ${where.join(' and ')}` : ''} order by created_at desc, rowid desc limit 200`, ...args);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The Boton's own action requests still waiting, oldest first (for its context block). */
|
|
207
|
+
pendingFor(botId: string): Approval[] {
|
|
208
|
+
return this.#select("select * from approvals where bot_id = ? and kind in ('action', 'connector') and status = 'pending' order by created_at, rowid", botId);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The owner's own latest decisions on its requests since `since`, newest first (not ones an always-allow rule made). */
|
|
212
|
+
recentDecisions(botId: string, since: Date, limit: number): { title: string; decision: 'allowed' | 'denied'; at: string }[] {
|
|
213
|
+
return (
|
|
214
|
+
this.#db
|
|
215
|
+
.prepare("select title, status, decided_at from approvals where bot_id = ? and status in ('allowed', 'denied') and auto = 0 and decided_at >= ? order by decided_at desc, rowid desc limit ?")
|
|
216
|
+
.all(botId, since.toISOString(), limit) as Row[]
|
|
217
|
+
).map((r) => ({ title: String(r.title), decision: r.status as 'allowed' | 'denied', at: String(r.decided_at) }));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
#emit(id: string): Approval {
|
|
221
|
+
const approval = this.get(id);
|
|
222
|
+
this.#store.publish({ type: 'approval.upserted', approval });
|
|
223
|
+
return approval;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
#meta(a: Pick<Approval, 'id' | 'title' | 'from'>, kind: MessageMeta['kind']): MessageMeta {
|
|
227
|
+
const run = a.from.routineId ? { routineId: a.from.routineId, ...(a.from.routineName ? { routineName: a.from.routineName } : {}), ...(a.from.runId ? { runId: a.from.runId } : {}) } : {};
|
|
228
|
+
return { kind, approvalId: a.id, approvalTitle: a.title, ...run };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The turn running now as the engine knows it (never what the Boton says); a sandboxed token makes it sandboxed. */
|
|
232
|
+
#from(botId: string, sandboxed: boolean): ApprovalOrigin {
|
|
233
|
+
const turn = this.#origin(botId) ?? NO_TURN;
|
|
234
|
+
return { ...turn, sandboxed: sandboxed || turn.sandboxed };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** `botons approval request`, from Boton `botId`'s turn token. */
|
|
238
|
+
request(botId: string, input: ApprovalRequestInput, opts: { sandboxed: boolean }): ApprovalRequestResult {
|
|
239
|
+
const bot = this.#store.getBot(botId);
|
|
240
|
+
const title = text(input.title, 'title', TITLE_MAX, true);
|
|
241
|
+
const details = text(input.details, 'details', DETAILS_MAX);
|
|
242
|
+
const followUp = text(input.followUp, 'followUp', FOLLOW_UP_MAX);
|
|
243
|
+
const action = input.action === undefined || input.action === null || input.action === '' ? null : actionKey(input.action);
|
|
244
|
+
const expiresIn: unknown = input.expiresIn;
|
|
245
|
+
const ttl = expiresIn === undefined || expiresIn === null || expiresIn === '' ? APPROVAL_TTL_MS : typeof expiresIn === 'string' ? parseDuration(expiresIn) : Number.NaN;
|
|
246
|
+
if (Number.isNaN(ttl)) throw new HttpError(400, 'expiresIn must be a duration such as "30m", "2h" or "3d"');
|
|
247
|
+
if (ttl < APPROVAL_TTL_MIN_MS || ttl > APPROVAL_TTL_MAX_MS) {
|
|
248
|
+
throw new HttpError(400, `expiresIn must be between ${formatDuration(APPROVAL_TTL_MIN_MS)} and ${formatDuration(APPROVAL_TTL_MAX_MS)}`);
|
|
249
|
+
}
|
|
250
|
+
const from = this.#from(botId, opts.sandboxed);
|
|
251
|
+
const fields: LogFields = { botId, trigger: from.trigger, routineId: from.routineId ?? undefined, runId: from.runId ?? undefined, turnId: from.turnId ?? undefined, sandboxed: from.sandboxed, action: action ?? undefined };
|
|
252
|
+
|
|
253
|
+
// Asked again (a retry, or a turn that forgot it asked): the one already waiting.
|
|
254
|
+
const same = this.#db
|
|
255
|
+
.prepare("select id from approvals where bot_id = ? and kind = 'action' and status = 'pending' and tool_key is null and title = ? and details = ? and follow_up = ? order by created_at limit 1")
|
|
256
|
+
.get(botId, title, details, followUp) as Row | undefined;
|
|
257
|
+
if (same) return { approval: this.get(String(same.id)), duplicate: true };
|
|
258
|
+
|
|
259
|
+
this.#limit(botId, fields);
|
|
260
|
+
// Never auto-allowed from a sandboxed turn: its follow-up runs with the Boton's usual access and connectors.
|
|
261
|
+
const auto = !!action && !from.sandboxed && bot.alwaysAllow.includes(action);
|
|
262
|
+
const id = this.#insert(botId, from, { title, details, followUp, action, auto, ttl });
|
|
263
|
+
if (auto) {
|
|
264
|
+
log.info('approval request allowed by an always-allow rule', { ...fields, approvalId: id });
|
|
265
|
+
return { approval: this.#emit(id), duplicate: false };
|
|
266
|
+
}
|
|
267
|
+
const approval = this.#emit(id);
|
|
268
|
+
log.info('approval requested', { ...fields, approvalId: id, expiresAt: approval.expiresAt });
|
|
269
|
+
this.#push(approval, bot);
|
|
270
|
+
return { approval, duplicate: false };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Save an action request with its card (pending), or its chip when allowed at once (`auto`). */
|
|
274
|
+
#insert(
|
|
275
|
+
botId: string,
|
|
276
|
+
from: ApprovalOrigin,
|
|
277
|
+
r: { title: string; details: string; followUp?: string; action: string | null; auto: boolean; ttl: number; tool?: { name: string; input: string; key: string; rule: string | null; waitUntil?: string } },
|
|
278
|
+
): string {
|
|
279
|
+
const id = newId('apr');
|
|
280
|
+
const now = new Date();
|
|
281
|
+
const at = now.toISOString();
|
|
282
|
+
const { tool } = r;
|
|
283
|
+
this.#store.atomically(() => {
|
|
284
|
+
this.#db
|
|
285
|
+
.prepare(
|
|
286
|
+
`insert into approvals (id, bot_id, kind, title, details, follow_up, action, status, auto, trigger, turn_id, routine_id, routine_name, run_id, sandboxed, asked_by, created_at, expires_at, decided_at,
|
|
287
|
+
tool_name, tool_input, tool_key, tool_rule, wait_until, in_turn)
|
|
288
|
+
values (?, ?, 'action', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
289
|
+
)
|
|
290
|
+
.run(
|
|
291
|
+
id,
|
|
292
|
+
botId,
|
|
293
|
+
r.title,
|
|
294
|
+
r.details,
|
|
295
|
+
r.followUp ?? '',
|
|
296
|
+
r.action,
|
|
297
|
+
r.auto ? 'allowed' : 'pending',
|
|
298
|
+
r.auto ? 1 : 0,
|
|
299
|
+
from.trigger,
|
|
300
|
+
from.turnId,
|
|
301
|
+
from.routineId,
|
|
302
|
+
from.routineName,
|
|
303
|
+
from.runId,
|
|
304
|
+
from.sandboxed ? 1 : 0,
|
|
305
|
+
JSON.stringify(from.askedBy),
|
|
306
|
+
at,
|
|
307
|
+
r.auto ? null : new Date(now.getTime() + r.ttl).toISOString(),
|
|
308
|
+
r.auto ? at : null,
|
|
309
|
+
tool?.name ?? null,
|
|
310
|
+
tool?.input ?? null,
|
|
311
|
+
tool?.key ?? null,
|
|
312
|
+
tool?.rule ?? null,
|
|
313
|
+
tool ? (tool.waitUntil ?? at) : null,
|
|
314
|
+
tool && r.auto ? 1 : 0,
|
|
315
|
+
);
|
|
316
|
+
const meta = this.#meta({ id, title: r.title, from }, r.auto ? 'approval.auto' : 'approval.requested');
|
|
317
|
+
const message = r.auto
|
|
318
|
+
? this.#store.addMessage(botId, 'event', `Allowed without asking: ${r.title}`, { ...meta, approvalAction: r.action! })
|
|
319
|
+
: this.#store.addMessage(botId, 'event', `Asks for your approval: ${r.title}`, meta);
|
|
320
|
+
this.#db.prepare('update approvals set message_id = ? where id = ?').run(message.id, id);
|
|
321
|
+
});
|
|
322
|
+
return id;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Refuse a new request beyond PENDING_MAX waiting (409) or REQUESTS_PER_HOUR (429); else count it. */
|
|
326
|
+
#limit(botId: string, fields: LogFields): void {
|
|
327
|
+
const pending = Number((this.#db.prepare("select count(*) as n from approvals where bot_id = ? and kind = 'action' and status = 'pending'").get(botId) as Row).n);
|
|
328
|
+
if (pending >= PENDING_MAX) {
|
|
329
|
+
const owner = this.#store.getSettings().ownerName;
|
|
330
|
+
throw new HttpError(409, `You already have ${PENDING_MAX} approval requests waiting for ${owner}. Wait for their decisions, or withdraw one with \`botons approval cancel <id>\` (see \`botons approval list\`).`);
|
|
331
|
+
}
|
|
332
|
+
const hit = this.#limiter.check(botId);
|
|
333
|
+
if (hit) {
|
|
334
|
+
const retryAfterS = Math.max(1, Math.ceil(hit.retryAfterMs / 1000));
|
|
335
|
+
log.warn('approval request refused: rate limit', { ...fields, max: hit.max });
|
|
336
|
+
throw new HttpError(429, `You have made ${hit.max} approval requests in the last hour, the limit. Try again in ${Math.ceil(retryAfterS / 60)} minutes, or put it in your reply.`, {
|
|
337
|
+
'retry-after': String(retryAfterS),
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
this.#limiter.record(botId);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** One push per request, recorded on it; none for a test run (a dry run). */
|
|
344
|
+
#push(a: Approval, bot: { id: string; name: string }) {
|
|
345
|
+
if (a.from.trigger === 'test') return;
|
|
346
|
+
void this.#notifier
|
|
347
|
+
.push({
|
|
348
|
+
bot,
|
|
349
|
+
reason: 'approval',
|
|
350
|
+
detail: `Asks for your approval: ${a.title} (from ${originText(a.from)})${a.followUp ? `. Then: ${a.followUp.replace(/\s+/g, ' ')}` : ''}`,
|
|
351
|
+
generic: `Needs your approval (from ${originText(a.from, false)}).`,
|
|
352
|
+
priority: 'high',
|
|
353
|
+
})
|
|
354
|
+
.then((deliveries) => {
|
|
355
|
+
if (!deliveries.length || !this.find(a.id)) return;
|
|
356
|
+
this.#db.prepare('update approvals set notifications = ? where id = ?').run(JSON.stringify([...a.notifications, ...deliveries]), a.id);
|
|
357
|
+
this.#emit(a.id);
|
|
358
|
+
})
|
|
359
|
+
.catch((err) => log.warn('could not record the push for an approval request', { botId: a.botId, approvalId: a.id, err }));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* A permission prompt from the Boton's CLI (`botons mcp`): a call an Ask
|
|
364
|
+
* first rule covers, or (Full computer) one Claude Code asked about.
|
|
365
|
+
*/
|
|
366
|
+
toolCall(botId: string, input: ToolCallInput, opts: { sandboxed: boolean }): ToolCallVerdict {
|
|
367
|
+
const bot = this.#store.getBot(botId);
|
|
368
|
+
const toolName = text(input.toolName, 'toolName', 200, true);
|
|
369
|
+
const toolUseId = typeof input.toolUseId === 'string' ? input.toolUseId.slice(0, 200) : undefined;
|
|
370
|
+
const args = input.input ?? {};
|
|
371
|
+
const json = JSON.stringify(args);
|
|
372
|
+
if (json.length > TOOL_INPUT_MAX) throw new HttpError(400, `input must be JSON of at most ${TOOL_INPUT_MAX} characters`);
|
|
373
|
+
const from = this.#from(botId, opts.sandboxed);
|
|
374
|
+
const access = this.#access(botId) ?? (from.sandboxed ? 'workspace' : bot.access);
|
|
375
|
+
const rule = matchRule(bot.askFirst, toolName, args, { cwd: bot.workspace, home: homedir() });
|
|
376
|
+
const key = callKey(toolName, args);
|
|
377
|
+
const fields: LogFields = { botId, trigger: from.trigger, turnId: from.turnId ?? undefined, sandboxed: from.sandboxed, tool: toolName, rule: rule ?? undefined, toolUseId };
|
|
378
|
+
|
|
379
|
+
if (!rule && access === 'workspace') {
|
|
380
|
+
const owner = this.#store.getSettings().ownerName;
|
|
381
|
+
log.info('tool call denied: no Ask first rule covers it, and Workspace access does not allow it', fields);
|
|
382
|
+
return {
|
|
383
|
+
behavior: 'deny',
|
|
384
|
+
message: `This call needs more than your Workspace access allows, so it was denied (only calls that one of ${owner}'s Ask first rules covers go to ${owner}). Do the work inside your workspace, or ask ${owner} with \`botons approval request\`.`,
|
|
385
|
+
approval: null,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
const action = rule ? ruleKey(rule) : null;
|
|
389
|
+
const call = { ...describeCall(toolName, args), action, ttl: APPROVAL_TTL_MS };
|
|
390
|
+
if (action && !from.sandboxed && bot.alwaysAllow.includes(action)) {
|
|
391
|
+
const id = this.#insert(botId, from, { ...call, auto: true, tool: { name: toolName, input: json, key, rule } });
|
|
392
|
+
log.info('tool call allowed by an always-allow rule', { ...fields, approvalId: id });
|
|
393
|
+
return { behavior: 'allow', message: null, approval: this.#emit(id) };
|
|
394
|
+
}
|
|
395
|
+
// Allowed after it stopped waiting: this same call (every argument), once, spent as it is handed out (never
|
|
396
|
+
// from a sandboxed turn).
|
|
397
|
+
if (!from.sandboxed) {
|
|
398
|
+
const grants = this.#db
|
|
399
|
+
.prepare("select id, tool_name, tool_input from approvals where bot_id = ? and tool_name = ? and tool_key not like 'browser-op:%' and status = 'allowed' and in_turn = 0 and auto = 0 and used_at is null order by decided_at")
|
|
400
|
+
.all(botId, toolName) as Row[];
|
|
401
|
+
for (const granted of grants.filter((r) => sameCall(r, key))) {
|
|
402
|
+
if (!Number(this.#db.prepare('update approvals set used_at = ? where id = ? and used_at is null').run(nowIso(), granted.id).changes)) continue;
|
|
403
|
+
log.info('tool call allowed: the owner allowed it after it stopped waiting', { ...fields, approvalId: granted.id });
|
|
404
|
+
return { behavior: 'allow', message: null, approval: this.#emit(String(granted.id)) };
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const waitUntil = new Date(Date.now() + this.#waitMs).toISOString();
|
|
408
|
+
const waiter = newId('wt', 16);
|
|
409
|
+
// The same call again while its card waits, from the same kind of turn (so the card's warning holds): it waits on
|
|
410
|
+
// that card, but only once no other call does (each allow answers one call). The call it replaces hears no allow.
|
|
411
|
+
const same = (
|
|
412
|
+
this.#db
|
|
413
|
+
.prepare("select id, tool_name, tool_input from approvals where bot_id = ? and tool_name = ? and tool_key not like 'browser-op:%' and status = 'pending' and sandboxed = ? order by created_at")
|
|
414
|
+
.all(botId, toolName, from.sandboxed ? 1 : 0) as Row[]
|
|
415
|
+
)
|
|
416
|
+
.filter((r) => sameCall(r, key))
|
|
417
|
+
.map((r) => this.get(String(r.id)))
|
|
418
|
+
.find((a) => !this.#waiters.has(a.id) || !this.#waiting(a));
|
|
419
|
+
if (same) {
|
|
420
|
+
this.#db.prepare('update approvals set wait_until = ?, turn_id = ? where id = ?').run(waitUntil, from.turnId, same.id);
|
|
421
|
+
this.#wait(same.id, waiter);
|
|
422
|
+
log.info('tool call waits on its card again', { ...fields, approvalId: same.id });
|
|
423
|
+
return { behavior: 'wait', message: null, approval: this.#emit(same.id), waiter };
|
|
424
|
+
}
|
|
425
|
+
this.#limit(botId, fields);
|
|
426
|
+
const id = this.#insert(botId, from, { ...call, auto: false, tool: { name: toolName, input: json, key, rule, waitUntil } });
|
|
427
|
+
this.#wait(id, waiter);
|
|
428
|
+
log.info('tool call paused for approval', { ...fields, approvalId: id, waitUntil });
|
|
429
|
+
const approval = this.#emit(id);
|
|
430
|
+
this.#push(approval, bot);
|
|
431
|
+
return { behavior: 'wait', message: null, approval, waiter };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** `waiter` is now the call waiting on the card; waiters of cards long answered go once there are many. */
|
|
435
|
+
#wait(id: string, waiter: string) {
|
|
436
|
+
if (this.#waiters.size >= 1000) {
|
|
437
|
+
for (const k of this.#waiters.keys()) {
|
|
438
|
+
const a = this.find(k);
|
|
439
|
+
if (!a || (a.status !== 'pending' && !(a.tool?.inTurn && this.#origin(a.botId)?.turnId === a.from.turnId))) this.#waiters.delete(k);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
this.#waiters.set(id, waiter);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Whether a paused call still waits: in its wait, and its turn still running. */
|
|
446
|
+
#waiting(a: Approval): boolean {
|
|
447
|
+
return !!a.tool && a.tool.waitUntil > nowIso() && !!a.from.turnId && this.#origin(a.botId)?.turnId === a.from.turnId;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* How a paused call stands, for the CLI waiting on it. `waiter` is the one its toolCall gave it: an allow goes only
|
|
452
|
+
* to the call that waits on the card now, and is spent as it is handed out (asking again hears it again).
|
|
453
|
+
*/
|
|
454
|
+
toolVerdict(id: string, waiter: string | null = null): ToolCallVerdict {
|
|
455
|
+
const a = this.get(id);
|
|
456
|
+
if (!a.tool) throw new HttpError(400, `${id} is not a paused tool call`);
|
|
457
|
+
const owner = this.#store.getSettings().ownerName;
|
|
458
|
+
const note = a.note ? ` ${owner}'s note: ${a.note}` : '';
|
|
459
|
+
const deny = (message: string): ToolCallVerdict => ({ behavior: 'deny', message, approval: a });
|
|
460
|
+
const current = this.#waiters.get(a.id);
|
|
461
|
+
const mine = !!waiter && waiter === current;
|
|
462
|
+
if (waiter && current && !mine) {
|
|
463
|
+
return deny(`Another call of yours now waits on this approval card (${a.id}), so this one is denied. Don't retry it in this turn: carry on without it, and say in your reply that it waits for ${owner}.`);
|
|
464
|
+
}
|
|
465
|
+
if (a.status === 'pending') {
|
|
466
|
+
if (a.tool.waitUntil > nowIso()) return { behavior: 'wait', message: null, approval: a };
|
|
467
|
+
return deny(
|
|
468
|
+
`${owner} hasn't decided yet, so this call is denied for now. It stays on ${owner}'s approval card (${a.id}): if ${owner} allows it, you get a new turn in which this same call is allowed once. Don't retry it in this turn or do it another way: carry on without it, and say in your reply that it waits for ${owner}.`,
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
if (a.status === 'allowed' && a.tool.inTurn) {
|
|
472
|
+
// Kept for the call it went to, so the same call asking again hears the same answer.
|
|
473
|
+
if (!mine) return deny(`${owner} allowed one call on this card (${a.id}), not this one. Don't make this call in this turn.`);
|
|
474
|
+
if (a.tool.usedAt) return { behavior: 'allow', message: null, approval: a };
|
|
475
|
+
this.#db.prepare('update approvals set used_at = ? where id = ? and used_at is null').run(nowIso(), a.id);
|
|
476
|
+
return { behavior: 'allow', message: null, approval: this.#emit(a.id) };
|
|
477
|
+
}
|
|
478
|
+
this.#waiters.delete(a.id);
|
|
479
|
+
if (a.status === 'denied' && a.tool.inTurn) {
|
|
480
|
+
return deny(`${owner} denied this call${a.tool.rule ? ` (Ask first rule ${a.tool.rule})` : ''}.${note} Don't do it another way; say in your reply that ${owner} denied it.`);
|
|
481
|
+
}
|
|
482
|
+
if (a.status === 'allowed' || a.status === 'denied') {
|
|
483
|
+
return deny(`${owner} decided after this call stopped waiting: you get a new turn with the decision. Don't make this call in this turn.`);
|
|
484
|
+
}
|
|
485
|
+
return deny(a.status === 'expired' ? 'Nobody decided on this call in time, so it was not made.' : 'This request was withdrawn, so the call was not made.');
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** The owner's decision (the route takes only paired devices). */
|
|
489
|
+
decide(id: string, input: ApprovalDecisionInput, deviceId: string | null): Approval {
|
|
490
|
+
const a = this.get(id);
|
|
491
|
+
if (input.decision !== 'allow' && input.decision !== 'deny') throw new HttpError(400, 'decision must be "allow" or "deny"');
|
|
492
|
+
const allow = input.decision === 'allow';
|
|
493
|
+
const always = flag(input.always, 'always', false);
|
|
494
|
+
const note = text(input.note, 'note', NOTE_MAX) || null;
|
|
495
|
+
if (always && !allow) throw new HttpError(400, 'always goes with allow');
|
|
496
|
+
if (always && !a.action) throw new HttpError(400, "This request names no kind of action, so it can't be always allowed");
|
|
497
|
+
if (a.status === 'pending' && a.expiresAt && a.expiresAt <= nowIso()) this.#expire(a);
|
|
498
|
+
const current = this.get(id);
|
|
499
|
+
if (current.status !== 'pending') throw new HttpError(409, `This request was already ${pastTense[current.status]}`);
|
|
500
|
+
if (a.kind === 'skill') return this.#decideSkill(a, allow, note, deviceId);
|
|
501
|
+
if (a.kind === 'connector') return this.#decideConnector(a, allow, note, deviceId);
|
|
502
|
+
if (a.tool?.browser) return this.#decideBrowser(current, allow, note, deviceId);
|
|
503
|
+
|
|
504
|
+
const decision = allow ? 'allowed' : 'denied';
|
|
505
|
+
// A paused call still waiting gets the answer itself: no follow-up turn.
|
|
506
|
+
const inTurn = this.#waiting(current);
|
|
507
|
+
this.#store.atomically(() => {
|
|
508
|
+
const changed = this.#db
|
|
509
|
+
.prepare("update approvals set status = ?, always = ?, note = ?, decided_at = ?, decided_by = ?, in_turn = ? where id = ? and status = 'pending'")
|
|
510
|
+
.run(decision, always ? 1 : 0, note, nowIso(), deviceId, inTurn ? 1 : 0, id).changes;
|
|
511
|
+
if (!Number(changed)) throw new HttpError(409, 'This request was already decided');
|
|
512
|
+
if (always) this.#store.addAlwaysAllow(a.botId, a.action!);
|
|
513
|
+
const meta: MessageMeta = { kind: 'approval.decided', approvalId: id, approvalTitle: a.title, decision, ...(note ? { note } : {}) };
|
|
514
|
+
if (inTurn) this.#store.addMessage(a.botId, 'event', `${allow ? 'Allowed' : 'Denied'}: ${a.title}`, meta);
|
|
515
|
+
// A pending user message: it starts the follow-up turn (see decisionPrompt), and waits out a pause or a usage limit.
|
|
516
|
+
else this.#store.addMessage(a.botId, 'user', `${allow ? 'Allowed' : 'Denied'}: ${a.title}`, meta, true);
|
|
517
|
+
});
|
|
518
|
+
log.info('approval decided', { botId: a.botId, approvalId: id, decision, always, inTurn: inTurn || undefined, deviceId: deviceId ?? undefined });
|
|
519
|
+
const out = this.#emit(id);
|
|
520
|
+
if (!inTurn) this.#wake(a.botId);
|
|
521
|
+
return out;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* `botons connector request <id>`: a connector the owner made available on request. Granted at once only if its
|
|
526
|
+
* policy says so and the turn is not sandboxed (a payload from outside must not widen what later turns can reach);
|
|
527
|
+
* else a card, which names the connector by id and the policy generation it was asked under.
|
|
528
|
+
*/
|
|
529
|
+
requestConnector(botId: string, connectorId: unknown, opts: { sandboxed: boolean }): ConnectorRequestResult {
|
|
530
|
+
const bot = this.#store.getBot(botId);
|
|
531
|
+
if (typeof connectorId !== 'string' || !/^[a-z]+:[a-z0-9][a-z0-9_-]{0,63}$/.test(connectorId)) throw new HttpError(400, 'connector must be a connector id from `botons connector list`, such as oauth:fleet');
|
|
532
|
+
const view = () => this.connectors.forBoton(this.#store.getBot(botId)).find((c) => c.id === connectorId)!;
|
|
533
|
+
if (bot.connections.includes(connectorId)) return { connector: view(), outcome: 'already', approval: null };
|
|
534
|
+
const problem = this.connectors.grantProblem(bot, connectorId);
|
|
535
|
+
if (problem) throw new HttpError(problem.startsWith('There is no') ? 404 : 403, `${problem}. See \`botons connector list\`.`);
|
|
536
|
+
const policy = this.connectors.policyOf(connectorId);
|
|
537
|
+
const name = this.connectors.catalog().connectors.find((c) => c.id === connectorId)!.name;
|
|
538
|
+
const from = this.#from(botId, opts.sandboxed);
|
|
539
|
+
const fields: LogFields = { botId, connector: connectorId, sandboxed: from.sandboxed, trigger: from.trigger };
|
|
540
|
+
if (policy.grant === 'immediate' && !from.sandboxed) {
|
|
541
|
+
this.#store.atomically(() => this.#grantConnector(botId, connectorId, name, null));
|
|
542
|
+
log.info('connector granted at once on request', fields);
|
|
543
|
+
this.#wake(botId);
|
|
544
|
+
return { connector: view(), outcome: 'granted', approval: null };
|
|
545
|
+
}
|
|
546
|
+
const same = this.#db.prepare("select id from approvals where bot_id = ? and kind = 'connector' and status = 'pending' and connector_id = ? order by created_at limit 1").get(botId, connectorId) as Row | undefined;
|
|
547
|
+
if (same) return { connector: view(), outcome: 'pending', approval: this.get(String(same.id)) };
|
|
548
|
+
this.#limit(botId, fields);
|
|
549
|
+
const id = newId('apr');
|
|
550
|
+
const now = new Date();
|
|
551
|
+
const title = `Give ${bot.name} the connector “${name}”`;
|
|
552
|
+
const details = `${bot.name} asks to use the connector ${name} (${connectorId}). If you allow it, it becomes one of ${bot.name}'s connectors from its next turn, until you take it off in its settings.`;
|
|
553
|
+
this.#store.atomically(() => {
|
|
554
|
+
this.#db
|
|
555
|
+
.prepare(
|
|
556
|
+
`insert into approvals (id, bot_id, kind, title, details, connector_id, connector_generation, status, trigger, turn_id, routine_id, routine_name, run_id, sandboxed, asked_by, created_at, expires_at)
|
|
557
|
+
values (?, ?, 'connector', ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
558
|
+
)
|
|
559
|
+
.run(id, botId, title, details, connectorId, policy.generation, from.trigger, from.turnId, from.routineId, from.routineName, from.runId, from.sandboxed ? 1 : 0, JSON.stringify(from.askedBy), now.toISOString(), new Date(now.getTime() + APPROVAL_TTL_MS).toISOString());
|
|
560
|
+
const card = this.#store.addMessage(botId, 'event', `Asks for your approval: ${title}`, { ...this.#meta({ id, title, from }, 'approval.requested'), connectorId });
|
|
561
|
+
this.#db.prepare('update approvals set message_id = ? where id = ?').run(card.id, id);
|
|
562
|
+
});
|
|
563
|
+
log.info('connector requested: waiting for approval', { ...fields, approvalId: id });
|
|
564
|
+
const approval = this.#emit(id);
|
|
565
|
+
this.#push(approval, bot);
|
|
566
|
+
return { connector: view(), outcome: 'pending', approval };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Inside a transaction: the connector joins the Boton's list, with a chip and a turn that says so. */
|
|
570
|
+
#grantConnector(botId: string, connectorId: string, name: string, approvalId: string | null): void {
|
|
571
|
+
const bot = this.#store.getBot(botId);
|
|
572
|
+
if (!bot.connections.includes(connectorId)) this.#store.setConnections(botId, [...bot.connections, connectorId]);
|
|
573
|
+
this.#store.addMessage(botId, 'user', `Connector ${name} added`, { kind: 'connector.granted', connectorId, ...(approvalId ? { approvalId } : {}) }, true);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
#decideConnector(a: Approval, allow: boolean, note: string | null, deviceId: string | null): Approval {
|
|
577
|
+
// As recorded, unconverted: a missing one (null) or one not issued as connectors.ts issues them is never allowed.
|
|
578
|
+
const generation: unknown = (this.#db.prepare('select connector_generation from approvals where id = ?').get(a.id) as Row).connector_generation;
|
|
579
|
+
const connectorId = a.connectorId ?? '';
|
|
580
|
+
if (allow) {
|
|
581
|
+
// Checked again now: the connector, its policy (same generation) and the Boton's brain may have changed.
|
|
582
|
+
const bot = this.#store.getBot(a.botId);
|
|
583
|
+
const problem = bot.connections.includes(connectorId) ? null : this.connectors.grantProblem(bot, connectorId, generation ?? null);
|
|
584
|
+
if (problem) {
|
|
585
|
+
this.#close(a.id, 'cancelled');
|
|
586
|
+
throw new HttpError(409, `${problem}, so this request was withdrawn.`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
const decision = allow ? 'allowed' : 'denied';
|
|
590
|
+
this.#store.atomically(() => {
|
|
591
|
+
const changed = this.#db.prepare("update approvals set status = ?, note = ?, decided_at = ?, decided_by = ? where id = ? and status = 'pending'").run(decision, note, nowIso(), deviceId, a.id).changes;
|
|
592
|
+
if (!Number(changed)) throw new HttpError(409, 'This request was already decided');
|
|
593
|
+
const meta: MessageMeta = { kind: 'approval.decided', approvalId: a.id, approvalTitle: a.title, decision, connectorId, ...(note ? { note } : {}) };
|
|
594
|
+
if (allow) {
|
|
595
|
+
const bot = this.#store.getBot(a.botId);
|
|
596
|
+
if (!bot.connections.includes(connectorId)) this.#store.setConnections(a.botId, [...bot.connections, connectorId]);
|
|
597
|
+
}
|
|
598
|
+
this.#store.addMessage(a.botId, 'user', `${allow ? 'Allowed' : 'Denied'}: ${a.title}`, meta, true);
|
|
599
|
+
});
|
|
600
|
+
log.info('approval decided', { botId: a.botId, approvalId: a.id, connector: connectorId, decision, deviceId: deviceId ?? undefined });
|
|
601
|
+
const out = this.#emit(a.id);
|
|
602
|
+
this.#wake(a.botId);
|
|
603
|
+
return out;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
#decideSkill(a: Approval, allow: boolean, note: string | null, deviceId: string | null): Approval {
|
|
607
|
+
const name = a.skillName ?? '';
|
|
608
|
+
const skill = this.skills.find(name);
|
|
609
|
+
if (allow && !skill) {
|
|
610
|
+
this.#close(a.id, 'cancelled');
|
|
611
|
+
throw new HttpError(409, `The skill ${name} no longer exists`);
|
|
612
|
+
}
|
|
613
|
+
// Allowing approves the skill as the card showed it, never a body swapped in since.
|
|
614
|
+
const version = (this.#db.prepare('select skill_version from approvals where id = ?').get(a.id) as Row).skill_version;
|
|
615
|
+
if (allow && skill!.version !== version) {
|
|
616
|
+
throw new HttpError(409, `The skill ${name} has changed since this request was made. Read it again (the newer request, or the Skills screen) before approving it.`);
|
|
617
|
+
}
|
|
618
|
+
const decision = allow ? 'allowed' : 'denied';
|
|
619
|
+
this.#db.prepare("update approvals set status = ?, note = ?, decided_at = ?, decided_by = ? where id = ? and status = 'pending'").run(decision, note, nowIso(), deviceId, a.id);
|
|
620
|
+
// After the update, so Skills.approve finds no card left to resolve.
|
|
621
|
+
if (allow) this.skills.approve(name, version);
|
|
622
|
+
const bot = this.#store.getBot(a.botId);
|
|
623
|
+
this.#store.addMessage(a.botId, 'event', allow ? `Approved the skill ${name} for every Boton` : `Kept the skill ${name} as ${bot.name}'s own`, {
|
|
624
|
+
kind: 'approval.decided',
|
|
625
|
+
approvalId: a.id,
|
|
626
|
+
approvalTitle: a.title,
|
|
627
|
+
decision,
|
|
628
|
+
skillName: name,
|
|
629
|
+
...(note ? { note } : {}),
|
|
630
|
+
});
|
|
631
|
+
log.info('approval decided', { botId: a.botId, approvalId: a.id, skill: name, decision, deviceId: deviceId ?? undefined });
|
|
632
|
+
return this.#emit(a.id);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* A request the managed browser holds (browser.ts), as one card: bound to `key` (the request's fingerprint, see
|
|
637
|
+
* browser-request.ts), waiting until `waitUntil` in the turn running now. Never always-allowed (it names no kind of
|
|
638
|
+
* action) and never auto-allowed: every request the browser would send gets its own card.
|
|
639
|
+
*/
|
|
640
|
+
browserRequest(botId: string, r: { title: string; details: string; input: unknown; key: string; waitUntil: string }): Approval {
|
|
641
|
+
const bot = this.#store.getBot(botId);
|
|
642
|
+
const from = this.#from(botId, false);
|
|
643
|
+
const fields: LogFields = { botId, trigger: from.trigger, turnId: from.turnId ?? undefined, tool: 'browser' };
|
|
644
|
+
this.#limit(botId, fields);
|
|
645
|
+
const ttl = Math.max(1000, new Date(r.waitUntil).getTime() - Date.now());
|
|
646
|
+
const id = this.#insert(botId, from, {
|
|
647
|
+
title: r.title,
|
|
648
|
+
details: r.details,
|
|
649
|
+
action: null,
|
|
650
|
+
auto: false,
|
|
651
|
+
ttl,
|
|
652
|
+
tool: { name: 'browser', input: JSON.stringify(r.input), key: `${BROWSER_KEY}${r.key}`, rule: null, waitUntil: r.waitUntil },
|
|
653
|
+
});
|
|
654
|
+
log.info('browser request held for approval', { ...fields, approvalId: id, waitUntil: r.waitUntil });
|
|
655
|
+
const approval = this.#emit(id);
|
|
656
|
+
this.#push(approval, bot);
|
|
657
|
+
return approval;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* The owner's decision on a held browser request: only while the browser still holds it, in the turn that made it,
|
|
662
|
+
* and before its wait ends. Otherwise the card is withdrawn (409): nothing is ever sent later, or in another turn.
|
|
663
|
+
*/
|
|
664
|
+
#decideBrowser(a: Approval, allow: boolean, note: string | null, deviceId: string | null): Approval {
|
|
665
|
+
// The browser holds a request only inside the turn that made it: its turn ending closes the browser, which withdraws the card.
|
|
666
|
+
const live = a.tool!.waitUntil > nowIso() && !!this.browser?.holds(a.id);
|
|
667
|
+
if (!live) {
|
|
668
|
+
if (this.#close(a.id, 'cancelled')) this.#store.addMessage(a.botId, 'event', `Withdrew an approval request: ${a.title}`, this.#meta(a, 'approval.cancelled'));
|
|
669
|
+
throw new HttpError(409, 'The browser step that held this request has ended, so it was withdrawn and nothing was sent.');
|
|
670
|
+
}
|
|
671
|
+
const decision = allow ? 'allowed' : 'denied';
|
|
672
|
+
this.#store.atomically(() => {
|
|
673
|
+
const changed = this.#db
|
|
674
|
+
.prepare("update approvals set status = ?, note = ?, decided_at = ?, decided_by = ?, in_turn = 1 where id = ? and status = 'pending'")
|
|
675
|
+
.run(decision, note, nowIso(), deviceId, a.id).changes;
|
|
676
|
+
if (!Number(changed)) throw new HttpError(409, 'This request was already decided');
|
|
677
|
+
this.#store.addMessage(a.botId, 'event', `${allow ? 'Allowed' : 'Denied'}: ${a.title}`, { kind: 'approval.decided', approvalId: a.id, approvalTitle: a.title, decision, ...(note ? { note } : {}) });
|
|
678
|
+
});
|
|
679
|
+
log.info('approval decided', { botId: a.botId, approvalId: a.id, decision, tool: 'browser', deviceId: deviceId ?? undefined });
|
|
680
|
+
const out = this.#emit(a.id);
|
|
681
|
+
this.browser?.decided(a.id);
|
|
682
|
+
return out;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* The browser is about to send an allowed request: spent here, once (whoever comes second gets false), and only for
|
|
687
|
+
* the fingerprint the card was made for.
|
|
688
|
+
*/
|
|
689
|
+
spendBrowser(id: string, key: string): boolean {
|
|
690
|
+
const changed = Number(
|
|
691
|
+
this.#db
|
|
692
|
+
.prepare("update approvals set used_at = ?, tool_input = json_set(tool_input, '$.delivery', 'sending') where id = ? and tool_key = ? and status = 'allowed' and in_turn = 1 and used_at is null")
|
|
693
|
+
.run(nowIso(), id, `${BROWSER_KEY}${key}`).changes,
|
|
694
|
+
);
|
|
695
|
+
if (changed) this.#emit(id);
|
|
696
|
+
return changed > 0;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* What became of a spent browser request: `sent` (the site answered), `not-sent` (it failed before it left), or
|
|
701
|
+
* `unknown` (it was written and the connection broke before an answer: the site may have acted on it). Until this is
|
|
702
|
+
* recorded it stays `sending`, which reads as unknown too (the server stopped mid-send).
|
|
703
|
+
*/
|
|
704
|
+
browserDelivered(id: string, delivery: 'sent' | 'not-sent' | 'unknown'): void {
|
|
705
|
+
const changed = Number(
|
|
706
|
+
this.#db.prepare("update approvals set tool_input = json_set(tool_input, '$.delivery', ?) where id = ? and used_at is not null and json_extract(tool_input, '$.delivery') = 'sending'").run(delivery, id).changes,
|
|
707
|
+
);
|
|
708
|
+
if (changed) this.#emit(id);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* The browser no longer holds a request whose card still waits: its wait ran out (`time`), or the browser closed
|
|
713
|
+
* (the turn ended, Stop, the connector taken away). Withdrawn; false if it was decided first.
|
|
714
|
+
*/
|
|
715
|
+
endBrowserWait(id: string, reason: 'time' | 'closed'): boolean {
|
|
716
|
+
const a = this.find(id);
|
|
717
|
+
if (!a || !this.#close(id, 'cancelled')) return false;
|
|
718
|
+
const why = reason === 'time' ? 'nobody decided while the browser step waited' : 'the browser closed';
|
|
719
|
+
this.#store.addMessage(a.botId, 'event', `Withdrew an approval request (${why}, so nothing was sent): ${a.title}`, this.#meta(a, 'approval.cancelled'));
|
|
720
|
+
log.info('browser request withdrawn', { botId: a.botId, approvalId: id, timedOut: reason === 'time' });
|
|
721
|
+
return true;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* `botons approval cancel`. A sandboxed turn may withdraw only requests
|
|
726
|
+
* sandboxed turns made: else an outside payload could change what later turns do.
|
|
727
|
+
*/
|
|
728
|
+
cancel(id: string, opts: { sandboxed: boolean }): Approval {
|
|
729
|
+
const a = this.get(id);
|
|
730
|
+
if (opts.sandboxed && !a.from.sandboxed) {
|
|
731
|
+
throw new HttpError(403, `This run was started by a webhook, so it can only withdraw requests that webhook runs made, not ${a.id}, which ${originText(a.from)} made.`);
|
|
732
|
+
}
|
|
733
|
+
if (a.status !== 'pending') throw new HttpError(409, `This request was already ${pastTense[a.status]}`);
|
|
734
|
+
this.#close(a.id, 'cancelled');
|
|
735
|
+
this.#store.addMessage(a.botId, 'event', `Withdrew an approval request: ${a.title}`, this.#meta(a, 'approval.cancelled'));
|
|
736
|
+
log.info('approval request withdrawn', { botId: a.botId, approvalId: id });
|
|
737
|
+
return this.get(id);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** End a pending request (no message). Returns whether it was still pending. */
|
|
741
|
+
#close(id: string, status: 'expired' | 'cancelled' | 'allowed'): boolean {
|
|
742
|
+
const changed = Number(this.#db.prepare("update approvals set status = ?, decided_at = ? where id = ? and status = 'pending'").run(status, nowIso(), id).changes);
|
|
743
|
+
if (changed) {
|
|
744
|
+
const a = this.#emit(id);
|
|
745
|
+
if (a.tool?.browser) this.#store.afterCommit(() => this.browser?.decided(id));
|
|
746
|
+
}
|
|
747
|
+
return changed > 0;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
#expire(a: Approval): void {
|
|
751
|
+
if (!this.#close(a.id, 'expired')) return;
|
|
752
|
+
this.#store.addMessage(a.botId, 'event', `Approval request expired: ${a.title}`, this.#meta(a, 'approval.expired'));
|
|
753
|
+
log.info('approval request expired', { botId: a.botId, approvalId: a.id });
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** Expire the requests whose time has come (every scheduler tick, and at startup). Returns how many. */
|
|
757
|
+
expireDue(now = new Date()): number {
|
|
758
|
+
const due = this.#select("select * from approvals where status = 'pending' and expires_at is not null and expires_at <= ? order by expires_at", now.toISOString());
|
|
759
|
+
for (const a of due) {
|
|
760
|
+
try {
|
|
761
|
+
this.#expire(a);
|
|
762
|
+
} catch (err) {
|
|
763
|
+
log.error('could not expire an approval request', { botId: a.botId, approvalId: a.id, err });
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
return due.length;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* A Boton saved or changed `skill`: a card that allows exactly this version.
|
|
771
|
+
* A card waiting for an earlier version is withdrawn first. No push and no expiry.
|
|
772
|
+
*/
|
|
773
|
+
forSkill(botId: string, skill: Skill): void {
|
|
774
|
+
const name = skill.name;
|
|
775
|
+
const open = this.#db
|
|
776
|
+
.prepare("select * from approvals where kind = 'skill' and skill_name = ? and status = 'pending'")
|
|
777
|
+
.all(name)
|
|
778
|
+
.map((r) => ({ a: this.#toApproval(r as Row), version: (r as Row).skill_version as string | null }));
|
|
779
|
+
if (open.length === 1 && open[0].version === skill.version) return;
|
|
780
|
+
for (const { a } of open) {
|
|
781
|
+
if (!this.#close(a.id, 'cancelled')) continue;
|
|
782
|
+
this.#store.addMessage(a.botId, 'event', `Changed the skill ${name} while it waited, so the request to approve it starts over`, {
|
|
783
|
+
...this.#meta(a, 'approval.cancelled'),
|
|
784
|
+
skillName: name,
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
const from = this.#from(botId, false);
|
|
788
|
+
const id = newId('apr');
|
|
789
|
+
const title = `Let the other Botons use the skill “${name}”`;
|
|
790
|
+
this.#store.atomically(() => {
|
|
791
|
+
this.#db
|
|
792
|
+
.prepare(
|
|
793
|
+
`insert into approvals (id, bot_id, kind, title, details, skill_name, skill_version, status, trigger, turn_id, routine_id, routine_name, run_id, sandboxed, asked_by, created_at)
|
|
794
|
+
values (?, ?, 'skill', ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
795
|
+
)
|
|
796
|
+
.run(id, botId, title, skillDetails(skill), name, skill.version, from.trigger, from.turnId, from.routineId, from.routineName, from.runId, from.sandboxed ? 1 : 0, JSON.stringify(from.askedBy), nowIso());
|
|
797
|
+
const card = this.#store.addMessage(botId, 'event', `Asks you to approve the skill ${name}`, { ...this.#meta({ id, title, from }, 'approval.requested'), skillName: name });
|
|
798
|
+
this.#db.prepare('update approvals set message_id = ? where id = ?').run(card.id, id);
|
|
799
|
+
});
|
|
800
|
+
log.info('skill waiting for approval', { botId, approvalId: id, skill: name, version: skill.version });
|
|
801
|
+
this.#emit(id);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** Skill `name` was approved or edited by the owner (`allowed`), or deleted (`cancelled`): close its waiting card. */
|
|
805
|
+
resolveSkill(name: string, status: 'allowed' | 'cancelled'): void {
|
|
806
|
+
const open = this.#db.prepare("select id from approvals where kind = 'skill' and skill_name = ? and status = 'pending'").all(name) as Row[];
|
|
807
|
+
for (const r of open) this.#close(r.id, status);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/** What the follow-up turn reads for a decision message: built from the request as saved, never from the message's text. */
|
|
811
|
+
decisionPrompt(m: Message, owner: string): string {
|
|
812
|
+
const found = m.meta?.approvalId ? this.find(m.meta.approvalId) : null;
|
|
813
|
+
if (!found) return `(From the Botons server: ${owner} decided on one of your approval requests, which no longer exists: ${escapeBlock(m.text)})`;
|
|
814
|
+
// The Boton wrote these: none of it may open or close the block below.
|
|
815
|
+
const a = { ...found, title: escapeBlock(found.title), details: escapeBlock(found.details), followUp: escapeBlock(found.followUp) };
|
|
816
|
+
const note = a.note ? `\n${owner}'s note: ${a.note}` : '';
|
|
817
|
+
if (a.kind === 'connector') {
|
|
818
|
+
const id = escapeBlock(a.connectorId ?? '');
|
|
819
|
+
return a.status === 'denied'
|
|
820
|
+
? `(From the Botons server: ${owner} denied your request ${a.id} for the connector ${id}. Don't try to reach it another way.)${note}\nSay briefly what you will do instead, if anything.`
|
|
821
|
+
: `(From the Botons server: ${owner} allowed your request ${a.id}: the connector ${id} is yours from this turn on; its tools are listed with your connectors.)${note}\nCarry on with what you needed it for, then say what you did.`;
|
|
822
|
+
}
|
|
823
|
+
if (a.status === 'denied') {
|
|
824
|
+
return `(From the Botons server: ${owner} denied your approval request ${a.id}, “${a.title}”. Don't do it.)${note}
|
|
825
|
+
Say briefly what you will do instead, if anything.`;
|
|
826
|
+
}
|
|
827
|
+
const always = a.always && a.action ? ` ${owner} also always allows “${a.action}” from now on: a request with --action "${a.action}" comes back allowed at once.` : '';
|
|
828
|
+
const outside = a.from.sandboxed
|
|
829
|
+
? `\nYou wrote this request during ${originText(a.from)}, whose payload came from outside. ${owner} read it on the approval card and allowed exactly this; this turn has your usual access and connectors.`
|
|
830
|
+
: `\nYou asked during ${originText(a.from)}.`;
|
|
831
|
+
if (a.tool?.browser) {
|
|
832
|
+
return `(From the Botons server: ${owner} decided on your browser's request ${a.id} after the browser step ended, so nothing was sent. Don't send it another way; read the page again if you still need it.)`;
|
|
833
|
+
}
|
|
834
|
+
if (a.tool) {
|
|
835
|
+
const rule = a.tool.rule ? `${owner}'s Ask first rule ${escapeBlock(a.tool.rule)}` : 'Claude Code';
|
|
836
|
+
return `(From the Botons server: ${owner} allowed your approval request ${a.id}, “${a.title}”.${always})${note}
|
|
837
|
+
It is a tool call ${rule} paused during ${originText(a.from)}, which stopped waiting before ${owner} decided. Make that same call again now (${escapeBlock(a.tool.name)}, with the same input): it is allowed once.
|
|
838
|
+
<approved_action id="${a.id}">
|
|
839
|
+
${a.details || a.title}
|
|
840
|
+
</approved_action>
|
|
841
|
+
Only this call was allowed, nothing more. When you are done, say what you did.`;
|
|
842
|
+
}
|
|
843
|
+
const what = a.followUp || [a.title, a.details].filter(Boolean).join('\n');
|
|
844
|
+
return `(From the Botons server: ${owner} allowed your approval request ${a.id}, “${a.title}”.${always})${note}${outside}
|
|
845
|
+
Do it now, as you said you would once allowed:
|
|
846
|
+
<approved_action id="${a.id}">
|
|
847
|
+
${what}
|
|
848
|
+
</approved_action>
|
|
849
|
+
Only this was allowed, nothing more. When you are done, say what you did.`;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** A paused or held tool call, as the owner sees it; a browser request's delivery comes from its input (no column of its own). */
|
|
854
|
+
/**
|
|
855
|
+
* Whether a stored tool call is the call with this key, judged from its whole stored input rather than its stored key
|
|
856
|
+
* (a key made by a rule that left an argument out names more than the one call its card shows).
|
|
857
|
+
*/
|
|
858
|
+
function sameCall(r: Row, key: string): boolean {
|
|
859
|
+
try {
|
|
860
|
+
return !!r.tool_name && r.tool_input != null && callKey(r.tool_name, JSON.parse(r.tool_input)) === key;
|
|
861
|
+
} catch {
|
|
862
|
+
return false;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function toolCall(r: Row): ApprovalToolCall {
|
|
867
|
+
const input = JSON.parse(r.tool_input ?? 'null');
|
|
868
|
+
const browser = String(r.tool_key ?? '').startsWith(BROWSER_KEY);
|
|
869
|
+
const d = browser && input && typeof input === 'object' ? (input as { delivery?: unknown }).delivery : undefined;
|
|
870
|
+
const delivery = d === 'sending' || d === 'sent' || d === 'not-sent' || d === 'unknown' ? d : null;
|
|
871
|
+
if (delivery) delete (input as { delivery?: unknown }).delivery;
|
|
872
|
+
return { name: r.tool_name!, input, rule: r.tool_rule, waitUntil: r.wait_until!, inTurn: !!r.in_turn, usedAt: r.used_at, browser, delivery };
|
|
873
|
+
}
|